diff --git a/.claude/wf-d2-cache-recon.js b/.claude/wf-d2-cache-recon.js new file mode 100644 index 00000000000000..a6f974839555d0 --- /dev/null +++ b/.claude/wf-d2-cache-recon.js @@ -0,0 +1,266 @@ +export const meta = { + name: 'd2-cache-recon', + description: 'HEAD-grounded recon for D2 connector-owned scan-side cache (retire fe-core Hive/Hudi/Iceberg caches + 4 routing gates + max-partition-time)', + phases: [ + { title: 'Recon', detail: '8 dimension readers over the cache subsystem, consumers, connector templates, max-time/invalidation, Trino' }, + { title: 'Critique', detail: '3 adversarial critics: completeness, flip-safety, scope-boundary' }, + ], +} + +const READER_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + dimension: { type: 'string' }, + summary: { type: 'string', description: '4-8 sentence executive summary of what you found, in plain terms' }, + facts: { + type: 'array', + description: 'Code-grounded facts. Every one MUST cite file:line evidence verified at HEAD.', + items: { + type: 'object', + additionalProperties: false, + properties: { + claim: { type: 'string' }, + evidence: { type: 'string', description: 'file:line (repo-relative). Multiple allowed.' }, + implication: { type: 'string', description: 'why it matters for D2 / the flip' }, + }, + required: ['claim', 'evidence'], + }, + }, + flipImpact: { + type: 'array', + description: 'For this dimension: what silently breaks / changes / must be replaced at the atomic flip (when a type=hms catalog becomes a PluginDriven catalog and cache routing collapses to ENGINE_DEFAULT).', + items: { type: 'string' }, + }, + designOptions: { + type: 'array', + description: 'Concrete options/tradeoffs you surfaced for the design (do NOT pick one; list them with pros/cons).', + items: { type: 'string' }, + }, + openQuestions: { type: 'array', items: { type: 'string' } }, + filesRead: { type: 'array', items: { type: 'string' } }, + }, + required: ['dimension', 'summary', 'facts', 'flipImpact'], +} + +const CRITIC_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + verdict: { type: 'string' }, + gaps: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + area: { type: 'string' }, + whatsMissing: { type: 'string' }, + whyItMatters: { type: 'string' }, + severity: { type: 'string', enum: ['blocker', 'major', 'minor', 'nit'] }, + suggestedResolution: { type: 'string' }, + evidence: { type: 'string' }, + }, + required: ['area', 'whatsMissing', 'severity'], + }, + }, + confirmedRisks: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + risk: { type: 'string' }, + severity: { type: 'string', enum: ['blocker', 'major', 'minor', 'nit'] }, + evidence: { type: 'string' }, + }, + required: ['risk', 'severity'], + }, + }, + refuted: { type: 'array', description: 'Worries you checked against HEAD and dismissed, with why.', items: { type: 'string' } }, + }, + required: ['verdict', 'gaps'], +} + +const CTX = ` +CONTEXT — you are reconnoitering for a DESIGN DOC (do not write code, do not implement). + +Project: Apache Doris catalog-SPI migration. Legacy \`type=hms\` catalogs (HMSExternalCatalog/HMSExternalTable, in fe-core \`datasource/hive|hudi|iceberg\`) are being migrated to a plugin-connector model (PluginDrivenExternalCatalog/PluginDrivenExternalTable holding an fe-connector-hive connector loaded in a child-first classloader). The migration ends in ONE ATOMIC FLIP that adds "hms" to SPI_READY_TYPES and remaps GSON subtypes so every hms catalog/db/table instantiates as PluginDriven* classes instead of the legacy classes. + +THE D2 TASK (what this recon informs): "connector-owned scan-side cache." Today fe-core owns three engine metadata caches — HiveExternalMetaCache, HudiExternalMetaCache, IcebergExternalMetaCache — routed by ExternalMetaCacheRouteResolver.addBuiltinRoutes(): a type=hms catalog routes to HIVE+HUDI+ICEBERG caches (ExternalMetaCacheRouteResolver.java:66-70). At the flip, the catalog is no longer \`instanceof HMSExternalCatalog\`, so routing falls through to ENGINE_DEFAULT (:72-73) and these three caches SILENTLY stop being fed/invalidated for hms — no crash, no log. Decision D2 (LOCKED): the hive connector must OWN its scan-side cache (mirroring how the paimon & iceberg-native connectors own theirs today), so that routing-collapse is harmless. Then retire the three fe-core caches + the four instanceof routing gates WITH the flip set. Coupled sub-item §2.6: flipped plain-hive \`getNewestUpdateVersionOrTime\` (PluginDrivenMvccExternalTable.java:713-714) returns constant 0 (names-only listPartitions → all lastModifiedMillis -1 → filtered → orElse(0)), so hive-backed SQL-dictionary / MV auto-refresh silently never sees a newer version. D2 must let the connector surface a real max-partition modify time cheaply. + +HARD ARCHITECTURE RULES (from project memory — respect them in what you flag): +- fe-core does NOT parse connector properties; storage parsing → fe-filesystem, meta parsing → fe-connector (both plugin-side). +- fe-core depends only on fe-connector-api/-spi (maven). Plugin connector classes load in a SEPARATE child-first classloader. A connector CANNOT import fe-core datasource classes. If you see "connector references HiveExternalMetaCache", determine whether it's a real import, a comment/string, or a VENDORED same-simple-name copy in a different package — this matters a lot. +- The generic fe-core SPI layer must stay connector-agnostic (no source-specific branches). +- Cross-classloader calls into plugins must pin TCCL to the connector classloader. + +DELIVERABLE: return the READER_SCHEMA object. Every fact needs file:line evidence you actually opened and verified at HEAD (branch catalog-spi-11-hive, HEAD commit d43ba31f3b3). Distinguish legacy deletion-unit code (dies at flip, needs NO replacement) from flip-survivor code (needs a replacement path). Surface design OPTIONS with tradeoffs; do not decide. +` + +phase('Recon') + +const readers = [ + { + label: 'dim-cache-machinery', + prompt: `${CTX} + +YOUR DIMENSION = the shared fe-core cache MACHINERY (not the engine-specific caches themselves). Read and map: +- fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaCacheMgr.java (the manager; public entry points prepareCatalog/invalidate*/removeCatalog; the CacheKey inner class; invalidateTableCache) +- fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/ExternalMetaCache.java (interface — already known: engine()/aliases()/initCatalog/entry/invalidate*/stats/close) +- fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/AbstractExternalMetaCache.java +- fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/ExternalMetaCacheRegistry.java +- fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/ExternalMetaCacheRouteResolver.java (the routing — ENGINE_DEFAULT fallback at :72-73) +- fe/fe-core/src/main/java/org/apache/doris/connector/ExternalMetaCacheInvalidator.java +- fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/MetaCacheEntry.java / MetaCacheEntryStats.java +- fe/fe-core/src/main/java/org/apache/doris/datasource/doris/DorisExternalMetaCache.java (a peer engine cache — how a non-hive engine registers, for contrast) + +Answer: What is the engine→catalog→entry model? How do caches register with the registry and get resolved by engine name? What EXACTLY is ENGINE_DEFAULT — is there a registered "default" cache, or does registry.resolve("default") return null / throw? Trace what happens to every ExternalMetaCacheMgr public method for a flipped hms catalog (routing → ENGINE_DEFAULT). Which manager methods are called on the scan hot-path vs on REFRESH/DDL/event paths? Does the machinery itself survive the flip (it serves paimon/jdbc/doris too) or get retired? Note the CacheKey/registry stats surface.`, + }, + { + label: 'dim-hive-cache', + prompt: `${CTX} + +YOUR DIMENSION = HiveExternalMetaCache internals (the primary cache to relocate into the hive connector). +Read fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveExternalMetaCache.java IN FULL. Enumerate EVERY distinct cache it holds (schema cache, partition-value cache, partition cache, file cache, and any others — note the inner key classes PartitionValueCacheKey:849 / PartitionCacheKey:882 / FileCacheKey:942). For each cache: what is the key, what is the value, which loader populates it, and what remote calls (HMS thrift / filesystem listing) does the loader make. Document the four instanceof gates (:203, :209, :248, :274) — what does each gate protect and what does it do for a non-HMS catalog/table. Find how max-partition modify time / lastModifiedTime is computed here (relevant to §2.6 getNewestUpdateVersionOrTime). Note file-listing / partition-listing caching that is on the SCAN hot-path (this is the core of what the hive connector must own). Note the eager-init behavior (initCatalog). + +Then classify: which of these caches are SCAN-side (must be owned by the connector post-flip) vs which serve only legacy HMSExternalTable/Catalog paths that die at the flip. Surface options for WHERE the relocated cache lives (connector-internal vs the shared fe-connector-cache framework) with tradeoffs.`, + }, + { + label: 'dim-hudi-iceberg-cache', + prompt: `${CTX} + +YOUR DIMENSION = HudiExternalMetaCache + IcebergExternalMetaCache (the other two fe-core caches routed for hms), and whether they even need a replacement. +Read: +- fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiExternalMetaCache.java IN FULL (gate at :221 = \`!(dorisTable instanceof HMSExternalTable)\`). +- fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalMetaCache.java IN FULL (gates at :213 = \`!(dorisTable instanceof MTMVRelatedTableIf)\`, :234 = \`catalog instanceof HMSExternalCatalog\`). +- fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/cache/IcebergManifestCacheLoader.java + +CRUCIAL NUANCE to resolve with evidence: post-flip, an iceberg-on-HMS table is served by DELEGATION to the sibling iceberg connector (which has its OWN native caches IcebergLatestSnapshotCache / IcebergManifestCache), and a hudi-on-HMS table is served by the hudi sibling connector. So does the fe-core IcebergExternalMetaCache / HudiExternalMetaCache still serve ANYTHING for a flipped hms catalog, or does it become fully dead (served by siblings)? Enumerate exactly what each caches, who feeds it, and whether the flip's sibling-delegation already covers it. Determine: can these two caches simply be RETIRED at the flip (no connector-owned replacement needed) because the sibling connectors' native caches cover it, or is there a residual fe-core-served path? Cite evidence. Note the execution plan's claim that IcebergExternalMetaCache:234 is "unreached post-flip" — verify.`, + }, + { + label: 'dim-consumers', + prompt: `${CTX} + +YOUR DIMENSION = classify EVERY consumer of the three fe-core caches into (a) LEGACY deletion-unit → dies at flip, needs no replacement; (b) FLIP-SURVIVOR → needs a replacement path post-flip; (c) fe-core plumbing that just reroutes. For each survivor, say what it needs and where it should get it after the flip. + +Known consumer files (grep of HiveExternalMetaCache|HudiExternalMetaCache|IcebergExternalMetaCache, non-test): +- fe-core LEGACY-suspect: datasource/hive/HMSExternalCatalog.java, HMSExternalTable.java, HiveDlaTable.java, source/HiveScanNode.java, datasource/hudi/HudiUtils.java, datasource/iceberg/source/IcebergScanNode.java, datasource/iceberg/IcebergUtils.java, statistics/HMSAnalysisTask.java, nereids/.../insert/HiveInsertExecutor.java, planner/HiveTableSink.java, datasource/hive/AcidUtil.java +- fe-core SURVIVOR-suspect: catalog/RefreshManager.java, datasource/CatalogMgr.java, datasource/FilePartitionUtils.java, datasource/hive/event/MetastoreEventsProcessor.java, tablefunction/MetadataGenerator.java, nereids/rules/expression/rules/SortedPartitionRanges.java, datasource/ExternalMetaCacheMgr.java +- CONNECTOR references (RESOLVE whether real import / comment / vendored copy): fe-connector-hive/.../HiveConnectorMetadata.java, fe-connector-hudi/.../HudiConnectorMetadata.java, fe-connector-iceberg/.../IcebergConnector.java + IcebergConnectorMetadata.java + IcebergLatestSnapshotCache.java + IcebergManifestCache.java + +For each file: open the actual reference line(s), state which cache/method it touches, and classify. Pay special attention to RefreshManager, CatalogMgr, FilePartitionUtils, MetadataGenerator, SortedPartitionRanges — these likely SURVIVE and drive REFRESH/invalidation/TVF; describe exactly how they invoke the cache and what the post-flip replacement must provide. For the CONNECTOR references: this is critical — a connector importing fe-core would break layering; determine the truth (package + import statement) and report it.`, + }, + { + label: 'dim-connector-template', + prompt: `${CTX} + +YOUR DIMENSION = the CONNECTOR-OWNED cache TEMPLATE (how paimon & iceberg-native own scan-side caches today — the pattern the hive connector must copy). +Read: +- fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheFactory.java + CacheSpec.java + MetaCacheEntry.java + MetaCacheEntryStats.java + package-info.java (the SHARED cache framework — note the memory: fe-core does NOT depend on this; it's a copied framework compiled into each plugin; Caffeine pinned to 2.9.3 lowest-common version). +- fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergLatestSnapshotCache.java + IcebergManifestCache.java + ManifestCacheValue.java + dlf/client/DLFCachedClientPool.java +- fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonLatestSnapshotCache.java +- The IcebergConnector / PaimonConnector class to see where these caches are instantiated, held, and invalidated. + +Answer: How does a connector INSTANTIATE and HOLD a scan-side cache (per-connector field? via CacheFactory?)? How is it keyed/scoped (per-table, per-snapshot)? How is it INVALIDATED — is there an SPI the connector implements that fe-core's REFRESH TABLE / REFRESH CATALOG calls, or is it event/TTL only? CRITICALLY: how does fe-core's ExternalMetaCacheRouteResolver end up NOT routing to these connectors (paimon/iceberg-native catalogs get ENGINE_DEFAULT and it's harmless) — trace WHY it's harmless for them today, because that is exactly the state hive must reach. Note config (TTL, size, expire) — where does the connector read cache config from (catalog properties? Config?). This is the reference model for the hive connector cache; describe it precisely enough to copy.`, + }, + { + label: 'dim-hive-connector-state', + prompt: `${CTX} + +YOUR DIMENSION = the CURRENT state of caching (or absence) inside fe-connector-hive, and what scan-side metadata the hive connector fetches per query. +Read the fe-connector-hive metadata + scan path: +- fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java (does it cache anything? where does it get schema/partitions/files?) +- The HiveConnector class, the scan-plan provider, and the HMS client wrappers (HmsClient / ThriftHmsClient) in the hive/hms connector modules. +- fe/fe-connector/fe-connector-metastore-hms/** and fe-connector-metastore-spi/** if present (the shared HMS resolver). +Search the hive connector module for any existing Caffeine/cache usage, and for per-query getTableSchema / listPartitions / listPartitionNames / file-listing calls. + +Answer: Today (dormant, hms still legacy) does the hive connector cache ANY metadata, or does every getTableSchema/listPartitions/getTableStatistics call hit HMS thrift fresh? Enumerate the scan-side metadata reads the connector performs (schema, partition names, partition objects, file splits, column stats, table stats) and which are hot-path-per-query. Which of these does legacy HiveExternalMetaCache cache today that the connector currently re-fetches uncached? Note whether fe-connector-hive already depends on fe-connector-cache (check its pom.xml). Surface: what is the MINIMUM set of connector-owned caches needed at the flip for scan performance + correctness parity (schema? partition-name? partition-object? file-listing?), and note the TCCL/classloader considerations (filesystem listing runs plugin-side).`, + }, + { + label: 'dim-maxtime-invalidation', + prompt: `${CTX} + +YOUR DIMENSION = (§2.6) max-partition-time / getNewestUpdateVersionOrTime, PLUS the invalidation coupling between D2 and event-pipeline Model B. +Read: +- fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenMvccExternalTable.java around :713-714 (getNewestUpdateVersionOrTime returning 0 for flipped plain-hive) AND the getTableSnapshot / beginQuerySnapshot / freshness path (freshness-kind-aware, landed 3d784673ca4). +- How legacy computes newest-update-time: fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSExternalTable.java getNewestUpdateVersionOrTime + any max-partition-time logic, and how HiveExternalMetaCache serves it. +- fe/fe-core/src/main/java/org/apache/doris/dictionary/Dictionary.java + mtmv/MTMVRelatedTableIf.java (the CONSUMERS of getNewestUpdateVersionOrTime — SQL dictionary refresh + MV freshness). +- The invalidation path: ExternalMetaCacheInvalidator, ExternalMetaCacheMgr.invalidateTableCache / invalidateDb / invalidatePartitions, RefreshManager, and how REFRESH TABLE/CATALOG + the event pipeline currently invalidate the fe-core caches. +- The event pipeline: datasource/hive/event/MetastoreEventsProcessor.java (:116 instanceof HMSExternalCatalog) — note the planned "Connector.invalidatePartition(s)" SPI (§2.1 Model B) that pairs with D2. + +Answer: (1) Exactly how must the hive connector surface a cheap real max-partition modify time so flipped getNewestUpdateVersionOrTime matches legacy? What SPI shape is needed (ConnectorMvccPartitionView already exists — read fe-connector-api/.../mvcc/ConnectorMvccPartitionView.java and IcebergPartitionUtils.getNewestUpdateVersionOrTime for the iceberg precedent). (2) How is the connector-owned cache invalidated by REFRESH TABLE / REFRESH CATALOG and by metastore events — what invalidation SPI verbs does D2 need to expose, and how does that couple to event Model B (which is the NEXT task after D2)? Keep D2 and event-Model-B boundaries distinct but note the shared SPI. Surface options + tradeoffs for the max-time SPI and the invalidation SPI.`, + }, + { + label: 'dim-trino-reference', + prompt: `${CTX} + +YOUR DIMENSION = the Trino reference architecture for connector-owned Hive metadata caching, mapped onto this design. The user explicitly wants Trino consulted for architecture-level decisions. +From your knowledge of Trino (and any Trino-referencing comments in this codebase — grep for "Trino"/"trino" in fe-connector-hive and the metastore modules): +- How does Trino structure Hive metastore metadata caching? (CachingHiveMetastore: the per-plugin caching decorator wrapping the raw metastore client; cache config hive.metastore-cache-ttl / metastore-refresh-interval / metastore-cache-maximum-size; per-transaction vs shared caching; the SharedHiveMetastoreCache.) Where does caching live — engine or connector? How is it invalidated (flushMetadataCache procedure, per-table/per-partition flush)? +- How does Trino cache file listings / directory listings separately (the DirectoryLister / CachingDirectoryLister, hive.file-status-cache)? +- How does Trino avoid a central engine-side metadata cache (the SPI is stateless Metadata calls; the connector owns all caching)? + +Answer: Map each Trino mechanism to a concrete recommendation for the Doris hive connector D2 design: (1) a CachingHiveMetastore-style decorator around the HMS client inside fe-connector-hive; (2) a directory/file-listing cache; (3) invalidation verbs the connector exposes to fe-core REFRESH/events. Note where Doris ALREADY diverges (Doris fe-core historically centralized the cache in HiveExternalMetaCache; the D2 move re-aligns to Trino's connector-owned model). Flag any Trino mechanism that does NOT map cleanly (e.g. Doris's GSON edit-log replay, the SPI_READY flip model, the split file-listing across classloaders). Surface options where Trino offers more than one approach.`, + }, +] + +const readerResults = await parallel( + readers.map(r => () => agent(r.prompt, { label: r.label, phase: 'Recon', schema: READER_SCHEMA, effort: 'high' })) +) + +const valid = readerResults.filter(Boolean) +log(`Recon done: ${valid.length}/${readers.length} dimensions returned.`) + +// Build a compact digest of all reader findings for the critics. +function digest(list) { + return list.map(r => { + const facts = (r.facts || []).map(f => ` - ${f.claim} [${f.evidence}]${f.implication ? ' → ' + f.implication : ''}`).join('\n') + const flip = (r.flipImpact || []).map(x => ` - ${x}`).join('\n') + const opts = (r.designOptions || []).map(x => ` - ${x}`).join('\n') + const oq = (r.openQuestions || []).map(x => ` - ${x}`).join('\n') + return `### ${r.dimension}\nSUMMARY: ${r.summary}\nFACTS:\n${facts}\nFLIP-IMPACT:\n${flip}\nDESIGN-OPTIONS:\n${opts}\nOPEN-QUESTIONS:\n${oq}` + }).join('\n\n') +} +const allFindings = digest(valid) + +phase('Critique') + +const critics = [ + { + label: 'critic-completeness', + prompt: `${CTX} + +You are a COMPLETENESS critic. Below are the combined recon findings for the D2 connector-owned-cache design. Your job: find what is MISSING or unverified that would silently break post-flip if the connector-owned cache design omits it. Adversarially hunt for: (a) a consumer of the three fe-core caches not classified (legacy-die vs survivor); (b) a cached entry (schema/partition/file/stats/max-time) with no post-flip owner; (c) a REFRESH / DDL / event / TVF / dictionary / MV path that reads the cache and would collapse to ENGINE_DEFAULT silently; (d) a scan hot-path that becomes uncached (perf regression) at the flip; (e) an invalidation verb the connector cache won't receive. For each gap, cite the evidence file:line if you can and rank severity. Also RE-VERIFY any load-bearing claim you doubt by reasoning about the cited evidence. Return CRITIC_SCHEMA. + +COMBINED FINDINGS: +${allFindings}`, + }, + { + label: 'critic-flip-safety', + prompt: `${CTX} + +You are a FLIP-SAFETY & DORMANCY critic. Below are the combined recon findings. Your job: stress-test the core D2 premise — "the hive connector owns its scan-side cache, so at the flip, cache routing collapsing to ENGINE_DEFAULT is HARMLESS, and the 3 fe-core caches + 4 gates retire cleanly." Adversarially check: (1) Can the connector-owned cache land DORMANT (inert while hms is still legacy, exactly like every prior connector step) — or does anything force it to be co-committed with the flip? What must be dormant vs must ride the flip commit? (2) Is routing-collapse actually harmless, or is there a path where ENGINE_DEFAULT routing does something WRONG (not just no-op) — e.g. registry.resolve("default") throwing, or double-caching? (3) The iceberg/hudi fe-core caches: are they truly covered by sibling delegation post-flip, or is there a residual? (4) Ordering: does D2 have hidden dependencies on event Model B (the NEXT task) or vice versa — can D2 truly land first? (5) Any TCCL/classloader hazard when the connector-owned cache does filesystem listing or HMS calls. Rank severity, cite evidence, and list worries you REFUTED. Return CRITIC_SCHEMA. + +COMBINED FINDINGS: +${allFindings}`, + }, + { + label: 'critic-scope', + prompt: `${CTX} + +You are a SCOPE-BOUNDARY critic. Below are the combined recon findings. Your job: pin down the RIGHT scope for D2 so the design doc is neither bloated nor missing a flip-blocker. Adversarially decide, with evidence: (1) What is genuinely REQUIRED for a correct+performant flip (minimum viable connector cache set: which of schema/partition-name/partition-object/file-listing/stats/max-time) vs what can be DEFERRED post-flip as a perf item. (2) Does hudi/iceberg fe-core cache RETIREMENT belong inside the D2 commit-set or does it just ride the flip's deletion (since siblings cover them)? (3) Where is the clean boundary between D2 (cache) and event Model B (invalidation source) and §2.6 (max-time) — what shared SPI sits on the seam, and should max-time + invalidation SPI land in D2 or their own steps? (4) Is the shared fe-connector-cache framework the right home, or should the hive cache be connector-internal like PaimonLatestSnapshotCache? (5) How should D2 be decomposed into independent dormant commits (propose an ordered sub-step list, mirroring how the iceberg/hudi lines were decomposed). Cite evidence, rank, and note anything the plan §2.2/§2.6 got wrong vs HEAD. Return CRITIC_SCHEMA. + +COMBINED FINDINGS: +${allFindings}`, + }, +] + +const criticResults = await parallel( + critics.map(c => () => agent(c.prompt, { label: c.label, phase: 'Critique', schema: CRITIC_SCHEMA, effort: 'high' })) +) + +return { + readers: valid, + critics: criticResults.filter(Boolean), +} diff --git a/be/src/common/config.cpp b/be/src/common/config.cpp index 9f697a2dcbed81..0af0cccdb996fc 100644 --- a/be/src/common/config.cpp +++ b/be/src/common/config.cpp @@ -1543,7 +1543,10 @@ DEFINE_mInt64(s3_put_token_limit, "0"); DEFINE_mInt64(s3_rate_limiter_log_interval, "1000"); DEFINE_Validator(s3_rate_limiter_log_interval, [](int64_t config) -> bool { return config >= 0; }); -DEFINE_String(trino_connector_plugin_dir, "${DORIS_HOME}/plugins/connectors"); +// The dir TrinoConnectorPluginLoader loads Trino's own plugins from, used verbatim. Keep the default +// in sync with FE Config.trino_connector_plugin_dir: FE and BE load the same plugins and an operator +// who leaves both untouched expects both to find them. +DEFINE_String(trino_connector_plugin_dir, "${DORIS_HOME}/plugins/trino_plugins"); // ca_cert_file is in this path by default, Normally no modification is required // ca cert default path is different from different OS diff --git a/be/src/io/fs/connectivity/s3_connectivity_tester.cpp b/be/src/io/fs/connectivity/s3_connectivity_tester.cpp index b6115f4a2f99d8..347c1b40fb2c18 100644 --- a/be/src/io/fs/connectivity/s3_connectivity_tester.cpp +++ b/be/src/io/fs/connectivity/s3_connectivity_tester.cpp @@ -24,6 +24,10 @@ namespace doris::io { Status S3ConnectivityTester::test(const std::map& properties) { auto it = properties.find(TEST_LOCATION); + if (it == properties.end()) { + return Status::InvalidArgument("Missing '{}' property in S3 connectivity test request", + TEST_LOCATION); + } S3URI s3_uri(it->second); RETURN_IF_ERROR(s3_uri.parse()); diff --git a/build.sh b/build.sh index b10d789892376a..59c4dce964dff4 100755 --- a/build.sh +++ b/build.sh @@ -1041,7 +1041,11 @@ if [[ "${BUILD_FE}" -eq 1 ]]; then mkdir -p "${DORIS_OUTPUT}/fe/conf/ssl" mkdir -p "${DORIS_OUTPUT}/fe/plugins/jdbc_drivers/" mkdir -p "${DORIS_OUTPUT}/fe/plugins/java_udf/" - mkdir -p "${DORIS_OUTPUT}/fe/plugins/connectors/" + # Drop point for the trino-connector's own Trino plugins. Deliberately NOT the legacy + # plugins/connectors/: that name is still read as a fallback for deployments upgrading from + # <= 2.1.8, so a fresh install must not create it (an empty dir would be harmless, but the + # one-letter gap to the plugins/connector/ tree above is not). + mkdir -p "${DORIS_OUTPUT}/fe/plugins/trino_plugins/" mkdir -p "${DORIS_OUTPUT}/fe/plugins/hadoop_conf/" mkdir -p "${DORIS_OUTPUT}/fe/plugins/java_extensions/" @@ -1081,6 +1085,23 @@ if [[ "${BUILD_FE}" -eq 1 ]]; then done unset CONN_PLUGIN_DIR conn_module conn_plugin_target conn_module_dir conn_zip + # RC-4: self-contain the paimon connector plugin for OSS. The connector sets + # fs.oss.impl=com.aliyun.jindodata.oss.JindoOssFileSystem; that impl lives in the jindofs jars, + # which are packaged from thirdparty by post-build.sh into fe/lib/jindofs (NOT a maven artifact). + # The plugin runs child-first, so without its OWN copy JindoOssFileSystem resolves from the parent + # 'app' classloader and cannot be cast to the plugin's child-loaded org.apache.hadoop.fs.FileSystem. + # Copy the jindofs jars into the paimon plugin lib so JindoOssFileSystem loads child-first alongside + # the plugin's own hadoop FileSystem (same self-contained intent as the bundled hadoop-aws/S3A). + # Naturally gated: a no-op unless jindofs was packaged (--jindofs / DISABLE_BUILD_JINDOFS=OFF). + # CAVEAT (docker-gated, enablePaimonTest=true): jindo-core ships a native lib that can bind to only one + # classloader per JVM, so this is safe only while no concurrent non-paimon path loads jindo from + # fe/lib/jindofs in the same FE process. + PAIMON_CONN_LIB="${DORIS_OUTPUT}/fe/plugins/connector/paimon/lib" + if [[ -d "${PAIMON_CONN_LIB}" && -d "${DORIS_OUTPUT}/fe/lib/jindofs" ]]; then + cp -p "${DORIS_OUTPUT}/fe/lib/jindofs/"*.jar "${PAIMON_CONN_LIB}/" 2>/dev/null || true + fi + unset PAIMON_CONN_LIB + if [ "${TARGET_SYSTEM}" = "Darwin" ] || [ "${TARGET_SYSTEM}" = "Linux" ]; then mkdir -p "${DORIS_OUTPUT}/fe/arthas" rm -rf "${DORIS_OUTPUT}/fe/arthas/*" @@ -1249,7 +1270,8 @@ EOF mkdir -p "${DORIS_OUTPUT}/be/plugins/jdbc_drivers/" mkdir -p "${DORIS_OUTPUT}/be/plugins/java_udf/" mkdir -p "${DORIS_OUTPUT}/be/plugins/python_udf/" - mkdir -p "${DORIS_OUTPUT}/be/plugins/connectors/" + # Mirrors the FE drop point above; the BE JNI scanner loads the same Trino plugins independently. + mkdir -p "${DORIS_OUTPUT}/be/plugins/trino_plugins/" mkdir -p "${DORIS_OUTPUT}/be/plugins/hadoop_conf/" mkdir -p "${DORIS_OUTPUT}/be/plugins/java_extensions/" cp -r -p "${DORIS_HOME}/be/src/udf/python/python_server.py" "${DORIS_OUTPUT}/be/plugins/python_udf/" diff --git a/docker/thirdparties/hive-regression-local-env.md b/docker/thirdparties/hive-regression-local-env.md new file mode 100644 index 00000000000000..62e82997299b60 --- /dev/null +++ b/docker/thirdparties/hive-regression-local-env.md @@ -0,0 +1,162 @@ +# Hive Regression Local Environment + +This document records the local Docker and regression configuration needed to run: + +```bash +regression-test/suites/external_table_p0/hive/ +``` + +## Required Thirdparty Components + +Run Hive2 and Hive3 through the thirdparty launcher: + +```bash +./docker/thirdparties/run-thirdparties-docker.sh -c hive2,hive3 --hive-mode refresh +``` + +The Hive startup script may also start `mysql` automatically. Both +`docker-compose/hive/hive-2x_settings.env` and +`docker-compose/hive/hive-3x_settings.env` default `JFS_CLUSTER_META` to: + +```bash +mysql://root:123456@(127.0.0.1:3316)/juicefs_meta +``` + +Because of that setting, `run-thirdparties-docker.sh` treats local MySQL 5.7 as +an implicit dependency for Hive. + +Each Hive version starts its own compose services: + +- Hive2: `hive2-server`, `hive2-metastore`, `hadoop2-namenode`, + `hadoop2-datanode`, `hive2-metastore-postgresql` +- Hive3: `hive3-server`, `hive3-metastore`, `hadoop3-namenode`, + `hadoop3-datanode`, `hive3-metastore-postgresql` +- MySQL 5.7: required by the default JuiceFS metadata URI when using the local + `127.0.0.1:3316` endpoint + +The Hive p0 directory does not require these components by default: + +- `pg` +- `iceberg` +- `minio` +- `hudi` +- `kerberos` +- `ranger` + +`test_external_catalog_hive.groovy` has a Ranger-specific branch guarded by +`enableRangerTest`; if this option is not set to `true`, that branch is skipped. + +## Current Running Containers Observed + +The following relevant containers were running when checked with +`sudo docker ps`: + +- Hive3 stack: `hive3-server`, `hive3-metastore`, `hadoop3-datanode`, + `hadoop3-namenode`, `hive3-metastore-postgresql`; all healthy +- Hive2 stack: `hive2-server`, `hive2-metastore`, `hadoop2-datanode`, + `hadoop2-namenode`, `hive2-metastore-postgresql`; all healthy +- MySQL 5.7: `mysql-doris--syt--mysql_57-1`, healthy, published as + `127.0.0.1:3316 -> 3306` + +Other Doris thirdparty containers were also running, but they are not required +for `external_table_p0/hive/`: + +- PostgreSQL 14 on `5442` +- Iceberg REST, Spark, Postgres, and MinIO +- Oracle on `1521` +- ClickHouse on `8123` +- SQLServer on `1433` +- OceanBase on `2881` + +The observed Hive2/Hive3 and MySQL compose projects came from another checkout +under `/mnt/disk2/suyiteng/doris`, not from this worktree. PostgreSQL and +Iceberg compose projects came from this worktree. + +## Regression Configuration + +The Hive p0 suite directly reads these keys from +`regression-test/conf/regression-conf.groovy`: + +```groovy +enableHiveTest +externalEnvIp +hive2HmsPort +hive2HdfsPort +hive3HmsPort +hive3HdfsPort +hdfsUser +``` + +It also uses framework helpers backed by: + +```groovy +brokerName +hdfsPasswd +hdfsFs +``` + +For local Hive2/Hive3 Docker with default ports, the relevant values are: + +```groovy +enableHiveTest = true + +hive2HmsPort = 9083 +hive2HdfsPort = 8020 +hive2ServerPort = 10000 +hive2PgPort = 5432 + +hive3HmsPort = 9383 +hive3HdfsPort = 8320 +hive3ServerPort = 13000 +hive3PgPort = 5732 + +hdfsUser = "doris-test" +brokerName = "broker_name" +hdfsPasswd = "" +``` + +`externalEnvIp` must be the address that Doris BE can use to reach the Hive +Docker services. For a fully local setup this is usually: + +```groovy +externalEnvIp = "127.0.0.1" +``` + +If BE runs in a context where `127.0.0.1` is not the Docker host, use the host +IP selected by `run-thirdparties-docker.sh` through `IP_HOST`. + +## Config Gaps Noted In regression-conf.groovy.bak + +`regression-test/conf/regression-conf.groovy.bak` already contains the core Hive +keys listed above. The notable follow-ups are: + +- `externalEnvIp` was set to `172.20.32.136`; verify this is reachable from BE, + or replace it with `127.0.0.1` for local host-mode Docker. +- FE/JDBC addresses in the file used `9033`, `9022`, and `8033`; align these + with the current Doris worktree cluster ports before running regression. +- `enableRangerTest` is not present. This is acceptable for the Hive p0 suite + because the Ranger-specific branch is guarded and skipped unless explicitly + enabled. + +## Thirdparty Script Settings + +Before starting Hive through `run-thirdparties-docker.sh`, set a unique +`CONTAINER_UID` in: + +```bash +docker/thirdparties/custom_settings.env +``` + +The default `CONTAINER_UID="doris--"` is rejected by the startup script. + +Hive baseline restore also depends on these settings: + +```bash +s3Endpoint +s3BucketName +HIVE_BASELINE_VERSION +HIVE_BASELINE_TARBALL_CACHE +``` + +If baseline download is unavailable, place the matching tarball manually under +`HIVE_BASELINE_TARBALL_CACHE`. diff --git a/fe/be-java-extensions/hadoop-hudi-scanner/src/main/java/org/apache/doris/hudi/HadoopHudiJniScanner.java b/fe/be-java-extensions/hadoop-hudi-scanner/src/main/java/org/apache/doris/hudi/HadoopHudiJniScanner.java index 85655b56016c60..7fc87e80389614 100644 --- a/fe/be-java-extensions/hadoop-hudi-scanner/src/main/java/org/apache/doris/hudi/HadoopHudiJniScanner.java +++ b/fe/be-java-extensions/hadoop-hudi-scanner/src/main/java/org/apache/doris/hudi/HadoopHudiJniScanner.java @@ -20,8 +20,8 @@ import org.apache.doris.common.classloader.ThreadClassLoaderContext; import org.apache.doris.common.jni.JniScanner; import org.apache.doris.common.jni.vec.ColumnType; -import org.apache.doris.common.security.authentication.PreExecutionAuthenticator; -import org.apache.doris.common.security.authentication.PreExecutionAuthenticatorCache; +import org.apache.doris.kerberos.PreExecutionAuthenticator; +import org.apache.doris.kerberos.PreExecutionAuthenticatorCache; import com.google.common.base.Joiner; import com.google.common.base.Preconditions; diff --git a/fe/be-java-extensions/iceberg-metadata-scanner/src/main/java/org/apache/doris/iceberg/IcebergSysTableJniScanner.java b/fe/be-java-extensions/iceberg-metadata-scanner/src/main/java/org/apache/doris/iceberg/IcebergSysTableJniScanner.java index 4e04d5bfa1dd30..5b1df1ab93a7f8 100644 --- a/fe/be-java-extensions/iceberg-metadata-scanner/src/main/java/org/apache/doris/iceberg/IcebergSysTableJniScanner.java +++ b/fe/be-java-extensions/iceberg-metadata-scanner/src/main/java/org/apache/doris/iceberg/IcebergSysTableJniScanner.java @@ -21,8 +21,8 @@ import org.apache.doris.common.jni.JniScanner; import org.apache.doris.common.jni.vec.ColumnType; import org.apache.doris.common.jni.vec.ColumnValue; -import org.apache.doris.common.security.authentication.PreExecutionAuthenticator; -import org.apache.doris.common.security.authentication.PreExecutionAuthenticatorCache; +import org.apache.doris.kerberos.PreExecutionAuthenticator; +import org.apache.doris.kerberos.PreExecutionAuthenticatorCache; import com.google.common.base.Preconditions; import org.apache.iceberg.FileScanTask; @@ -110,8 +110,12 @@ protected int getNext() throws IOException { } StructLike row = reader.next(); for (int i = 0; i < requiredFieldCount; i++) { - // FE keeps the fields requested by BE at the start of the Iceberg projection. - // FileScanTask.schema() is not the row schema for every DataTask implementation. + // Read positionally: FE (IcebergScanPlanProvider.doPlanSystemTableScan) projects the + // metadata-table scan to exactly the BE-requested fields, in required_fields order, so the + // i-th projected row field is the i-th required field. Do NOT index via scanTask.schema(): + // for a metadata StaticDataTask, schema() returns the FULL table schema while rows() yields a + // narrowed StructProjection, so a full-schema ordinal overruns the projected row (upstream + // #65262 -- reverting this to a by-name/schema() lookup reintroduces ArrayIndexOutOfBounds). Object value = row.get(i, Object.class); ColumnValue columnValue = new IcebergSysTableColumnValue(value, timezone); appendData(i, columnValue); diff --git a/fe/be-java-extensions/java-common/pom.xml b/fe/be-java-extensions/java-common/pom.xml index 6b1b028494429a..a59f77c42e49f8 100644 --- a/fe/be-java-extensions/java-common/pom.xml +++ b/fe/be-java-extensions/java-common/pom.xml @@ -34,6 +34,11 @@ under the License. + + org.apache.doris + fe-kerberos + ${project.version} + org.apache.doris fe-common diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/maxcompute/MCUtils.java b/fe/be-java-extensions/max-compute-connector/src/main/java/org/apache/doris/maxcompute/MCUtils.java similarity index 97% rename from fe/fe-common/src/main/java/org/apache/doris/common/maxcompute/MCUtils.java rename to fe/be-java-extensions/max-compute-connector/src/main/java/org/apache/doris/maxcompute/MCUtils.java index fc7f47fc2689a8..225f953b82e753 100644 --- a/fe/fe-common/src/main/java/org/apache/doris/common/maxcompute/MCUtils.java +++ b/fe/be-java-extensions/max-compute-connector/src/main/java/org/apache/doris/maxcompute/MCUtils.java @@ -15,7 +15,9 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common.maxcompute; +package org.apache.doris.maxcompute; + +import org.apache.doris.common.maxcompute.MCProperties; import com.aliyun.auth.credentials.Credential; import com.aliyun.auth.credentials.provider.EcsRamRoleCredentialProvider; diff --git a/fe/be-java-extensions/max-compute-connector/src/main/java/org/apache/doris/maxcompute/MaxComputeJniScanner.java b/fe/be-java-extensions/max-compute-connector/src/main/java/org/apache/doris/maxcompute/MaxComputeJniScanner.java index 336991f3802726..fad4c82a9245da 100644 --- a/fe/be-java-extensions/max-compute-connector/src/main/java/org/apache/doris/maxcompute/MaxComputeJniScanner.java +++ b/fe/be-java-extensions/max-compute-connector/src/main/java/org/apache/doris/maxcompute/MaxComputeJniScanner.java @@ -19,7 +19,6 @@ import org.apache.doris.common.jni.JniScanner; import org.apache.doris.common.jni.vec.ColumnType; -import org.apache.doris.common.maxcompute.MCUtils; import com.aliyun.odps.Odps; import com.aliyun.odps.table.configuration.CompressionCodec; diff --git a/fe/be-java-extensions/max-compute-connector/src/main/java/org/apache/doris/maxcompute/MaxComputeJniWriter.java b/fe/be-java-extensions/max-compute-connector/src/main/java/org/apache/doris/maxcompute/MaxComputeJniWriter.java index ecb01d9092f9f5..4a0ffe1f4ff8f4 100644 --- a/fe/be-java-extensions/max-compute-connector/src/main/java/org/apache/doris/maxcompute/MaxComputeJniWriter.java +++ b/fe/be-java-extensions/max-compute-connector/src/main/java/org/apache/doris/maxcompute/MaxComputeJniWriter.java @@ -21,7 +21,6 @@ import org.apache.doris.common.jni.vec.VectorColumn; import org.apache.doris.common.jni.vec.VectorTable; import org.apache.doris.common.maxcompute.MCProperties; -import org.apache.doris.common.maxcompute.MCUtils; import com.aliyun.odps.Odps; import com.aliyun.odps.OdpsType; diff --git a/fe/be-java-extensions/paimon-scanner/src/main/java/org/apache/doris/paimon/PaimonJniScanner.java b/fe/be-java-extensions/paimon-scanner/src/main/java/org/apache/doris/paimon/PaimonJniScanner.java index 46caf45aaeee8c..27ecc1555351c6 100644 --- a/fe/be-java-extensions/paimon-scanner/src/main/java/org/apache/doris/paimon/PaimonJniScanner.java +++ b/fe/be-java-extensions/paimon-scanner/src/main/java/org/apache/doris/paimon/PaimonJniScanner.java @@ -20,8 +20,8 @@ import org.apache.doris.common.jni.JniScanner; import org.apache.doris.common.jni.vec.ColumnType; import org.apache.doris.common.jni.vec.TableSchema; -import org.apache.doris.common.security.authentication.PreExecutionAuthenticator; -import org.apache.doris.common.security.authentication.PreExecutionAuthenticatorCache; +import org.apache.doris.kerberos.PreExecutionAuthenticator; +import org.apache.doris.kerberos.PreExecutionAuthenticatorCache; import com.google.common.base.Preconditions; import org.apache.paimon.CoreOptions; @@ -50,6 +50,7 @@ import java.nio.file.Files; import java.nio.file.Paths; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -271,6 +272,12 @@ static int getFieldIndex(List fieldNames, String fieldName) { } private List getPredicates() { + // Backstop for a missing paimon_predicate param (scan with no pushed-down filter): a null here means + // "no filter", not an error. Guard the unconditional deserialize so the JNI reader never NPEs on + // deserialize(null) ("encodedStr is null"). The FE producer also always emits an (empty) predicate now. + if (paimonPredicate == null) { + return Collections.emptyList(); + } List predicates = PaimonUtils.deserialize(paimonPredicate); if (LOG.isDebugEnabled()) { LOG.debug("predicates:{}", predicates); diff --git a/fe/be-java-extensions/preload-extensions/pom.xml b/fe/be-java-extensions/preload-extensions/pom.xml index 6ec9b1e6158d7f..7ffc2ea15c3a37 100644 --- a/fe/be-java-extensions/preload-extensions/pom.xml +++ b/fe/be-java-extensions/preload-extensions/pom.xml @@ -62,6 +62,18 @@ under the License. commons-io ${commons-io.version} + + + commons-lang + commons-lang + runtime + org.apache.arrow arrow-memory-unsafe diff --git a/fe/be-java-extensions/trino-connector-scanner/src/main/java/org/apache/doris/trinoconnector/TrinoConnectorPluginLoader.java b/fe/be-java-extensions/trino-connector-scanner/src/main/java/org/apache/doris/trinoconnector/TrinoConnectorPluginLoader.java index 2347bd52c168ab..c6f30c7175eec8 100644 --- a/fe/be-java-extensions/trino-connector-scanner/src/main/java/org/apache/doris/trinoconnector/TrinoConnectorPluginLoader.java +++ b/fe/be-java-extensions/trino-connector-scanner/src/main/java/org/apache/doris/trinoconnector/TrinoConnectorPluginLoader.java @@ -40,7 +40,10 @@ public class TrinoConnectorPluginLoader { private static final Logger LOG = LogManager.getLogger(TrinoConnectorPluginLoader.class); - private static String pluginsDir = EnvUtils.getDorisHome() + "/plugins/connectors"; + // Overwritten via setPluginsDir() with BE config trino_connector_plugin_dir before the plugins are + // loaded (see be/src/format*/**/trino_connector_jni_reader.cpp); this initializer only matters if + // that call is ever missed. Mirrors that config's default. + private static String pluginsDir = EnvUtils.getDorisHome() + "/plugins/trino_plugins"; // Suppress default constructor for noninstantiability private TrinoConnectorPluginLoader() { @@ -87,7 +90,7 @@ private static class TrinoConnectorPluginLoad { TypeRegistry typeRegistry = new TypeRegistry(typeOperators, featuresConfig); ServerPluginsProviderConfig serverPluginsProviderConfig = new ServerPluginsProviderConfig() - .setInstalledPluginsDir(new File(checkAndReturnPluginDir())); + .setInstalledPluginsDir(new File(pluginsDir)); ServerPluginsProvider serverPluginsProvider = new ServerPluginsProvider(serverPluginsProviderConfig, MoreExecutors.directExecutor()); HandleResolver handleResolver = new HandleResolver(); @@ -95,9 +98,9 @@ private static class TrinoConnectorPluginLoad { typeRegistry, handleResolver); trinoConnectorPluginManager.loadPlugins(); - LOG.info("TrinoConnectorPluginLoader successfully loaded plugins from: " + checkAndReturnPluginDir()); + LOG.info("TrinoConnectorPluginLoader successfully loaded plugins from: " + pluginsDir); } catch (Exception e) { - LOG.warn("Failed load trino-connector plugins from " + checkAndReturnPluginDir() + LOG.warn("Failed load trino-connector plugins from " + pluginsDir + ", Exception:" + e.getMessage(), e); } } @@ -118,29 +121,6 @@ public static void setPluginsDir(String pluginsDir) { TrinoConnectorPluginLoader.pluginsDir = pluginsDir; } - private static String checkAndReturnPluginDir() { - final String defaultDir = System.getenv("DORIS_HOME") + "/plugins/connectors"; - final String defaultOldDir = System.getenv("DORIS_HOME") + "/connectors"; - if (TrinoConnectorPluginLoader.pluginsDir.equals(defaultDir)) { - // If true, which means user does not set `trino_connector_plugin_dir` and use the default one. - // Because in 2.1.8, we change the default value of `trino_connector_plugin_dir` - // from `DORIS_HOME/connectors` to `DORIS_HOME/plugins/connectors`, - // so we need to check the old default dir for compatibility. - File oldDir = new File(defaultOldDir); - if (oldDir.exists() && oldDir.isDirectory()) { - String[] contents = oldDir.list(); - if (contents != null && contents.length > 0) { - // there are contents in old dir, use old one - return defaultOldDir; - } - } - return defaultDir; - } else { - // Return user specified dir directly. - return TrinoConnectorPluginLoader.pluginsDir; - } - } - public static TrinoConnectorPluginManager getTrinoConnectorPluginManager() { return TrinoConnectorPluginLoad.trinoConnectorPluginManager; } diff --git a/fe/check/checkstyle/import-control.xml b/fe/check/checkstyle/import-control.xml index d96f2e9692fc1f..310e8cac721a07 100644 --- a/fe/check/checkstyle/import-control.xml +++ b/fe/check/checkstyle/import-control.xml @@ -43,6 +43,10 @@ under the License. + + + + diff --git a/fe/fe-authentication/fe-authentication-handler/src/test/java/org/apache/doris/authentication/handler/AuthenticationPluginManagerTest.java b/fe/fe-authentication/fe-authentication-handler/src/test/java/org/apache/doris/authentication/handler/AuthenticationPluginManagerTest.java index 6cde32bb90f0f1..260afb30a9646c 100644 --- a/fe/fe-authentication/fe-authentication-handler/src/test/java/org/apache/doris/authentication/handler/AuthenticationPluginManagerTest.java +++ b/fe/fe-authentication/fe-authentication-handler/src/test/java/org/apache/doris/authentication/handler/AuthenticationPluginManagerTest.java @@ -70,7 +70,6 @@ void testPluginsAutoLoaded() { // Then Assertions.assertNotNull(pluginNames); Assertions.assertFalse(pluginNames.isEmpty(), "Should load at least built-in plugins"); - Assertions.assertTrue(pluginNames.contains("oidc"), "Should include oidc plugin"); Assertions.assertTrue(pluginNames.contains("password"), "Should include password plugin"); } @@ -147,10 +146,6 @@ void testGetFactory() { // Then Assertions.assertTrue(factory.isPresent()); Assertions.assertEquals("password", factory.get().name()); - - Optional oidcFactory = pluginManager.getFactory("oidc"); - Assertions.assertTrue(oidcFactory.isPresent()); - Assertions.assertEquals("oidc", oidcFactory.get().name()); } @Test diff --git a/fe/fe-catalog/src/main/java/org/apache/doris/catalog/Column.java b/fe/fe-catalog/src/main/java/org/apache/doris/catalog/Column.java index aaf76f5c5da864..bc1991dc1073f9 100644 --- a/fe/fe-catalog/src/main/java/org/apache/doris/catalog/Column.java +++ b/fe/fe-catalog/src/main/java/org/apache/doris/catalog/Column.java @@ -182,6 +182,15 @@ public static Column generateBeforeValueColumn(Column column) { private boolean isCompoundKey = false; + // Marks a connector-reserved passthrough column (e.g. iceberg v3 row-lineage _row_id / + // _last_updated_sequence_number). Set by ConnectorColumnConverter from the connector-declared + // ConnectorColumn.reservedPassthrough(); read by engine MERGE/UPDATE and sink binding so they recognize the + // synthetic passthrough column generically instead of string-matching source column names. NOT persisted + // (no @SerializedName on purpose): it is an external-table-only marker rebuilt each load from connector + // metadata and must never enter an internal-table schema image; on replay it stays at its default false + // (mirrors the runtime-only isCompoundKey / defineExpr fields). + private boolean reservedPassthrough = false; + @SerializedName(value = "hasOnUpdateDefaultValue") private boolean hasOnUpdateDefaultValue = false; @@ -376,6 +385,7 @@ public Column(Column column) { this.visible = column.visible; this.children = column.getChildren(); this.uniqueId = column.getUniqueId(); + this.reservedPassthrough = column.reservedPassthrough; this.defineExpr = column.getDefineExpr(); this.defineName = column.getRealDefineName(); this.hasOnUpdateDefaultValue = column.hasOnUpdateDefaultValue; @@ -987,6 +997,14 @@ public int getUniqueId() { return this.uniqueId; } + public boolean isReservedPassthrough() { + return reservedPassthrough; + } + + public void setReservedPassthrough(boolean reservedPassthrough) { + this.reservedPassthrough = reservedPassthrough; + } + public long getAutoIncInitValue() { return this.autoIncInitValue; } diff --git a/fe/fe-common/pom.xml b/fe/fe-common/pom.xml index 8dad7e6f3efc82..67ae7e2f1285d8 100644 --- a/fe/fe-common/pom.xml +++ b/fe/fe-common/pom.xml @@ -84,11 +84,6 @@ under the License. org.apache.commons commons-lang3 - - org.apache.doris - hive-catalog-shade - provided - org.roaringbitmap RoaringBitmap @@ -139,23 +134,15 @@ under the License. antlr4-runtime ${antlr4.version} + - com.aliyun.odps - odps-sdk-core - - - org.apache.arrow - arrow-vector - - - org.ini4j - ini4j - - - org.bouncycastle - bcprov-jdk18on - - + io.netty + netty-all + + + com.google.protobuf + protobuf-java diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/CatalogConfigFileUtils.java b/fe/fe-common/src/main/java/org/apache/doris/common/CatalogConfigFileUtils.java index bce08713131140..d8e9f2e5133740 100644 --- a/fe/fe-common/src/main/java/org/apache/doris/common/CatalogConfigFileUtils.java +++ b/fe/fe-common/src/main/java/org/apache/doris/common/CatalogConfigFileUtils.java @@ -20,7 +20,6 @@ import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; -import org.apache.hadoop.hive.conf.HiveConf; import java.io.File; import java.util.function.BiConsumer; @@ -29,14 +28,13 @@ public class CatalogConfigFileUtils { /** - * Generic method to load configuration files (e.g., Hadoop or Hive) from a directory. + * Generic method to load Hadoop-style configuration files from a directory. * * @param resourcesPath Comma-separated list of resource file names to be loaded. * @param configDir Directory prefix where the configuration files reside. - * @param configSupplier Supplier that creates a new configuration object - * (e.g., new Configuration or new HiveConf). + * @param configSupplier Supplier that creates a new configuration object. * @param addResourceMethod Method to add a resource file to the configuration object. - * @param Type of the configuration (e.g., Configuration or HiveConf). + * @param Type of the configuration. * @return A configuration object loaded with the given resource files. * @throws IllegalArgumentException if the resourcesPath is empty or if any file does not exist. */ @@ -84,20 +82,4 @@ public static Configuration loadConfigurationFromHadoopConfDir(String resourcesP Configuration::addResource ); } - - /** - * Loads a HiveConf object from a list of files under the specified config directory. - * - * @param resourcesPath Comma-separated list of file names to be loaded. - * @return A HiveConf object. - * @throws IllegalArgumentException if the input is invalid or files are missing. - */ - public static HiveConf loadHiveConfFromHiveConfDir(String resourcesPath) { - return loadConfigFromDir( - resourcesPath, - Config.hadoop_config_dir, - HiveConf::new, - HiveConf::addResource - ); - } } diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java index 440e5599735fb5..01c2f6cbf97e33 100644 --- a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java +++ b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java @@ -2271,12 +2271,18 @@ public class Config extends ConfigBase { public static boolean enable_fqdn_mode = false; /** - * If set to true, doris will try to parse the ddl of a hive view and try to execute the query - * otherwise it will throw an AnalysisException. + * @deprecated No-op since the hms/iceberg SPI cutover: external views (hive, iceberg) are always served. + * Retained for one release so operator fe.conf that sets it still parses; will be removed later. */ + @Deprecated @ConfField(mutable = true) public static boolean enable_query_hive_views = true; + /** + * @deprecated No-op since the iceberg SPI cutover: external views (hive, iceberg) are always served. + * Retained for one release so operator fe.conf that sets it still parses; will be removed later. + */ + @Deprecated @ConfField(mutable = true) public static boolean enable_query_iceberg_views = true; @@ -2881,9 +2887,12 @@ public class Config extends ConfigBase { + "multiple integration names are comma-separated"}) public static String authentication_chain = ""; + // The dir the trino-connector catalog loads Trino's own plugins from, used verbatim. Keep the + // default in sync with BE config trino_connector_plugin_dir: FE and BE load the same plugins and + // an operator who leaves both untouched expects both to find them. @ConfField(mutable = true, masterOnly = false, description = { "Specify the default plugins loading path for the trino-connector catalog"}) - public static String trino_connector_plugin_dir = EnvUtils.getDorisHome() + "/plugins/connectors"; + public static String trino_connector_plugin_dir = EnvUtils.getDorisHome() + "/plugins/trino_plugins"; @ConfField(mutable = true) public static boolean fix_tablet_partition_id_eq_0 = false; diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/AuthType.java b/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/AuthType.java deleted file mode 100644 index 6cf3358fe7f32d..00000000000000 --- a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/AuthType.java +++ /dev/null @@ -1,60 +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.common.security.authentication; - -/** - * Define different auth type for external table such as hive/iceberg - * so that BE could call secured under fileStorageSystem (enable kerberos) - */ -public enum AuthType { - SIMPLE(0, "simple"), - KERBEROS(1, "kerberos"); - - private int code; - private String desc; - - AuthType(int code, String desc) { - this.code = code; - this.desc = desc; - } - - public static boolean isSupportedAuthType(String authType) { - for (AuthType auth : values()) { - if (auth.getDesc().equals(authType)) { - return true; - } - } - return false; - } - - public int getCode() { - return code; - } - - public void setCode(int code) { - this.code = code; - } - - public String getDesc() { - return desc; - } - - public void setDesc(String desc) { - this.desc = desc; - } -} diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/HadoopAuthenticator.java b/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/HadoopAuthenticator.java deleted file mode 100644 index c25fb8cc1b257f..00000000000000 --- a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/HadoopAuthenticator.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.common.security.authentication; - -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.security.UserGroupInformation; - -import java.io.IOException; -import java.lang.reflect.UndeclaredThrowableException; -import java.security.PrivilegedExceptionAction; - -public interface HadoopAuthenticator { - - UserGroupInformation getUGI() throws IOException; - - default T doAs(PrivilegedExceptionAction action) throws IOException { - try { - return getUGI().doAs(action); - } catch (InterruptedException e) { - throw new IOException(e); - } catch (UndeclaredThrowableException e) { - if (e.getCause() instanceof RuntimeException) { - throw (RuntimeException) e.getCause(); - } else { - throw new RuntimeException(e.getCause()); - } - } - } - - static HadoopAuthenticator getHadoopAuthenticator(AuthenticationConfig config) { - if (config instanceof KerberosAuthenticationConfig) { - return new HadoopKerberosAuthenticator((KerberosAuthenticationConfig) config); - } else { - return new HadoopSimpleAuthenticator((SimpleAuthenticationConfig) config); - } - } - - static HadoopAuthenticator getHadoopAuthenticator(Configuration configuration) { - AuthenticationConfig authConfig = AuthenticationConfig.getKerberosConfig(configuration); - return getHadoopAuthenticator(authConfig); - } -} 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 cd2b1766adaec2..e1bdc83c088324 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 @@ -17,12 +17,20 @@ package org.apache.doris.connector.api; +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.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; import java.util.Set; /** @@ -36,16 +44,193 @@ public interface Connector extends Closeable { /** Returns the metadata interface for the given session. */ ConnectorMetadata getMetadata(ConnectorSession session); + /** + * Whether {@code handle} is one of THIS connector's own concrete {@link ConnectorTableHandle} subclasses. + * + *

A heterogeneous gateway connector that serves several table formats through embedded sibling + * connectors uses this to route a foreign handle to the sibling that produced it: the sibling's concrete + * handle type is invisible across the plugin classloader split, so the gateway cannot {@code instanceof} it + * directly — it asks each sibling, and the sibling tests its OWN in-loader type. The default returns + * {@code false} (a connector owns no handle it did not define), so every non-gateway connector is + * unaffected.

+ * + *

fe-core NEVER calls this — it is a connector-to-sibling routing predicate only, so the engine stays + * format-agnostic (it discriminates handles solely by the gateway's own handle type, never by asking a + * connector to classify one).

+ */ + default boolean ownsHandle(ConnectorTableHandle handle) { + return false; + } + /** Returns the scan plan provider for split generation. */ default ConnectorScanPlanProvider getScanPlanProvider() { return null; } + /** + * Returns the scan plan provider for the given table, allowing one connector to select a + * different provider per table. + * + *

The selection MUST happen here, at provider-acquisition time — not inside a single + * dispatching provider — because {@link ConnectorScanPlanProvider} has methods that do not + * carry the handle (e.g. {@code appendExplainInfo}) and providers are built fresh/stateless + * per call, so a provider returned here must already be bound to the correct backing scanner + * for {@code handle}. This is the seam a heterogeneous gateway connector (one catalog serving + * multiple table formats) overrides to delegate to per-format sub-providers by the concrete + * (connector-defined) handle type; the engine never inspects the format.

+ * + *

The default ignores {@code handle} and returns the connector-level + * {@link #getScanPlanProvider()}, so every single-format connector is unaffected.

+ */ + default ConnectorScanPlanProvider getScanPlanProvider(ConnectorTableHandle handle) { + return getScanPlanProvider(); + } + + /** + * Returns the write plan provider for sink ({@code TDataSink}) generation, + * or {@code null} if this connector does not support writes. + */ + default ConnectorWritePlanProvider getWritePlanProvider() { + return null; + } + + /** + * Returns the write plan provider for the given table, allowing one connector to select a different + * provider per table — the write-side analogue of {@link #getScanPlanProvider(ConnectorTableHandle)}. + * + *

The default ignores {@code handle} and returns the connector-level {@link #getWritePlanProvider()}, so + * every single-format connector is unaffected. A heterogeneous gateway connector (one catalog serving + * multiple table formats) overrides this to delegate to a per-format sub-provider by the concrete + * (connector-defined) handle type; the engine never inspects the format.

+ */ + default ConnectorWritePlanProvider getWritePlanProvider(ConnectorTableHandle handle) { + 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()}. + */ + default ConnectorProcedureOps getProcedureOps() { + return null; + } + + /** + * Returns the procedure ops for the given table, allowing one connector to select a different set of + * procedures per table — the procedure-side analogue of {@link #getScanPlanProvider( + * ConnectorTableHandle)} / {@link #getWritePlanProvider(ConnectorTableHandle)}. + * + *

The default ignores {@code handle} and returns the connector-level {@link #getProcedureOps()}, so every + * single-format connector is unaffected. A heterogeneous gateway connector (one catalog serving multiple + * table formats) overrides this to delegate a foreign (e.g. iceberg-on-HMS) handle to a sibling connector's + * procedure ops by the concrete (connector-defined) handle type; the engine never inspects the format.

+ */ + default ConnectorProcedureOps getProcedureOps(ConnectorTableHandle handle) { + return getProcedureOps(); + } + /** Returns the set of capabilities this connector supports. */ default Set getCapabilities() { return Collections.emptySet(); } + /** + * 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 — + * 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 + * ({@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 + * iceberg connector overrides this to bridge a hadoop-catalog {@code warehouse=hdfs:///path} into + * {@code fs.defaultFS=hdfs://}, which the shared HDFS detection never derives from {@code warehouse}.

+ * + * @param rawCatalogProps the catalog's current persisted properties + * @return extra storage-property defaults; an empty map when there is nothing to derive + */ + default Map deriveStorageProperties(Map rawCatalogProps) { + return Collections.emptyMap(); + } + /** Returns the table-level property descriptors. */ default List> getTableProperties() { return Collections.emptyList(); @@ -118,4 +303,60 @@ default void close() throws IOException { 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 + * the REMOTE db/table names (as seen by the connector). Default no-op for connectors that cache nothing. + */ + default void invalidateTable(String dbName, String tableName) { + } + + /** Invalidates all connector-side per-table caches. Default no-op. */ + default void invalidateAll() { + } + + /** + * Invalidates the connector-side caches for every table in one database. Called by the engine on + * {@code REFRESH DATABASE}. The name is the REMOTE db name (as seen by the connector). Default no-op for + * connectors that cache nothing. + */ + default void invalidateDb(String dbName) { + } + + /** + * Invalidates the connector-side caches for specific partitions of a table so a subsequent read + * reflects the latest external state. Driven by the engine's metastore-event sync when partitions + * are added/dropped/altered. The names are the REMOTE db/table names and canonical partition names + * ({@code "col=val/.../colN=valN"}); an empty/whole-table drop is expressed by + * {@link #invalidateTable(String, String)}. A connector whose partition cache cannot target a single + * name may degrade to invalidating the whole table's partition caches (correctness-safe when the + * cache re-lists on miss). Default no-op for connectors that cache nothing. + */ + default void invalidatePartition(String dbName, String tableName, List partitionNames) { + } + + /** + * Returns this connector's incremental metadata-change source, or {@code null} if it has none. + * A capability-probe getter (mirrors {@link #getScanPlanProvider()} / {@link #getProcedureOps()}): + * the engine's single, connector-agnostic, role-aware event driver iterates catalogs and calls + * {@link ConnectorEventSource#pollOnce} only on connectors that expose a source, never via + * {@code instanceof}. The default returns {@code null}, so every connector without a metastore-event + * feed is unaffected. + */ + default ConnectorEventSource getEventSource() { + 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}: + * {@code 0} disables schema caching (always read fresh), {@code -1} = no expiration, {@code > 0} = TTL. + * Lets a connector make its own cache knob also govern schema freshness (e.g. paimon's + * {@code meta.cache.paimon.table.ttl-second}, which legacy used for the whole table cache). An explicit + * user {@code schema.cache.ttl-second} always wins over this. Default: no override. + */ + default OptionalLong schemaCacheTtlSecondOverride() { + return OptionalLong.empty(); + } } 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 53337ed656a3c2..1e9f21416a41b6 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 @@ -18,37 +18,17 @@ package org.apache.doris.connector.api; /** - * Enumerates the optional capabilities a connector may declare. - * The planner and execution engine use these to decide which - * pushdown and write paths are available. + * Enumerates optional, connector-declared capability switches consumed directly by + * static query-planning code (pushdown/DDL/view/statistics gating, etc.). + * + *

This is an escape-hatch layer for capability checks that don't warrant a dedicated + * provider abstraction. Write operations and sink traits (parallel write, partition-local + * 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()}.

*/ public enum ConnectorCapability { - SUPPORTS_FILTER_PUSHDOWN, - SUPPORTS_PROJECTION_PUSHDOWN, - SUPPORTS_LIMIT_PUSHDOWN, - SUPPORTS_PARTITION_PRUNING, - SUPPORTS_INSERT, - SUPPORTS_DELETE, - SUPPORTS_UPDATE, - SUPPORTS_MERGE, - SUPPORTS_CREATE_TABLE, SUPPORTS_MVCC_SNAPSHOT, - SUPPORTS_METASTORE_EVENTS, - SUPPORTS_STATISTICS, - SUPPORTS_VENDED_CREDENTIALS, - SUPPORTS_ACID_TRANSACTIONS, - SUPPORTS_TIME_TRAVEL, - /** - * Indicates the connector supports multiple concurrent writers (sink instances). - * - *

Connectors that do NOT declare this capability will use GATHER distribution - * (single writer), which is the safe default for transactional sinks like JDBC - * where each writer commits independently.

- * - *

File-based connectors (Hive, Iceberg, etc.) that can safely handle - * parallel writers should declare this capability.

- */ - SUPPORTS_PARALLEL_WRITE, /** * Indicates the connector supports passthrough query via the {@code query()} TVF. * @@ -56,5 +36,127 @@ public enum ConnectorCapability { * {@link ConnectorTableOps#getColumnsFromQuery} to provide column metadata * for arbitrary SQL queries passed through to the remote data source.

*/ - SUPPORTS_PASSTHROUGH_QUERY + SUPPORTS_PASSTHROUGH_QUERY, + /** + * Indicates the connector exposes per-partition statistics (record count, on-disk size, + * file count) via {@link ConnectorTableOps#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}.

+ */ + SUPPORTS_PARTITION_STATS, + /** + * Indicates the connector's tables support background per-column auto-analyze (NDV / min / max / + * null-count collection) through the generic {@code ExternalAnalysisTask} FULL path. + * + *

The statistics auto-collector admits a plugin-driven table into the background auto-analyze + * framework only when its connector declares this (replacing the legacy {@code instanceof + * IcebergExternalTable} whitelist), and then forces {@code AnalysisMethod.FULL} — sample analyze is + * 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.

+ */ + SUPPORTS_COLUMN_AUTO_ANALYZE, + /** + * Indicates the connector's file-scan tables support Top-N lazy materialization: the scan first + * reads only the ordering/filter columns to locate the Top-N row ids, then materializes the + * remaining columns for just those rows (via the synthesized {@code GLOBAL_ROWID_COL}). + * + *

The nereids Top-N lazy-materialize probe enables the {@code LazyMaterializeTopN} post-processor + * 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.

+ */ + SUPPORTS_TOPN_LAZY_MATERIALIZE, + /** + * Indicates the connector's table/database properties are user-facing and safe to render in + * {@code SHOW CREATE TABLE} / {@code SHOW CREATE DATABASE}. + * + *

The SHOW CREATE TABLE plugin-driven arm renders LOCATION + PROPERTIES (and, when the + * connector pre-renders them under the {@code show.*} reserved keys, the PARTITION BY / ORDER BY + * clauses) only for connectors declaring this (replacing the legacy paimon-only engine-name gate). + * 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.

+ */ + 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 + * 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.

+ */ + SUPPORTS_VIEW, + /** + * Indicates the connector's file-scan tables support nested-column pruning: a query that reads only some + * sub-fields of a STRUCT/ARRAY/MAP column reads just those leaves from the data file instead of the whole + * complex column (read-amplification avoidance). + * + *

The nereids nested-column-prune probe ({@code LogicalFileScan.supportPruneNestedColumn}) enables it + * for a plugin-driven table only when its connector declares this (replacing the legacy exact-class + * {@code IcebergExternalTable} arm). It is only correct when the connector also carries a stable per-field + * id down its column tree (top-level via {@link ConnectorColumn#withUniqueId} + nested via + * {@link ConnectorType#withChildrenFieldIds}), because the engine rewrites the nested access path from + * field names to those ids ({@code SlotTypeReplacer}) and the BE field-id scan path matches + * 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.

+ */ + SUPPORTS_NESTED_COLUMN_PRUNE, + /** + * Indicates the connector's external metadata (schema / partitions / snapshot) can be pre-warmed + * asynchronously by the planner before it takes the internal read lock, rather than loaded lazily + * during binding. + * + *

{@code PluginDrivenExternalTable.supportsExternalMetadataPreload} returns true for a plugin-driven + * table only when its connector declares this (replacing the legacy engine-name {@code "jdbc"} gate), so + * {@code StatementContext.registerExternalTableForPreload} admits the table into the async pre-load pass + * (itself opt-in via the {@code enable_preload_external_metadata} session variable, default off). It is a + * 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.

+ */ + SUPPORTS_METADATA_PRELOAD, + /** + * Indicates the connector projects the querying user's per-connection delegated credential (OIDC/JWT/SAML) + * onto the remote metadata source, so metadata reads are authorized as that user rather than a single shared + * catalog identity (the Iceberg REST {@code iceberg.rest.session=user} model). + * + *

This capability gates two behaviors. (a) FE credential injection: {@code ConnectorSessionBuilder.from} + * copies the user's delegated credential onto the {@link ConnectorSession} ONLY for connectors declaring + * this, so a JDBC/ES/hive-iceberg session never carries an OIDC token it would never use (least-privilege). + * (b) Shared-cache bypass: {@code ExternalCatalog.shouldBypassTableNameCache} / {@code ExternalDatabase} + * skip the catalog+name-keyed (NOT user-keyed) FE metadata caches for a credential-bearing session, so one + * 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}.

+ */ + SUPPORTS_USER_SESSION, + /** + * Indicates the connector exposes a metadata table (e.g. the hudi commit timeline) whose rows are read via + * {@link ConnectorMetadata#getMetadataTableRows}. + * + *

The {@code hudi_meta()} / TIMELINE table-valued function's plugin-driven arm delegates to the connector + * only when it declares this; a connector with no metadata table must NOT declare it so the TVF rejects the + * table with "not a hudi table". Hudi declares it connector-wide (every hudi table has a commit timeline).

+ */ + SUPPORTS_METADATA_TABLE, + /** + * Indicates the connector's file-scan tables support {@code ANALYZE ... WITH SAMPLE} (scale-factor estimation + * from raw per-file byte sizes via {@link ConnectorStatisticsOps#listFileSizes}, with fe-core doing the + * 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 + * 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.

+ */ + SUPPORTS_SAMPLE_ANALYZE } 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 5b8b537d0a3841..6737197000cecc 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 @@ -24,12 +24,40 @@ */ public final class ConnectorColumn { + /** Sentinel for "no reserved field id declared"; mirrors Doris Column's default uniqueId. */ + public static final int UNSET_UNIQUE_ID = -1; + private final String name; private final ConnectorType type; private final String comment; private final boolean nullable; private final String defaultValue; private final boolean isKey; + private final boolean isAutoInc; + private final boolean isAggregated; + // Marks a "with local time zone" timestamp column. fe-core's ConnectorColumnConverter translates + // this into Column.setWithTZExtraInfo() so DESC shows the WITH_TIMEZONE "Extra" marker, matching + // legacy PaimonExternalTable/PaimonSysExternalTable/IcebergUtils which set it from the SOURCE type + // root regardless of the timestamp_tz mapping flag. Defaults false; set via withTimeZone(). + private final boolean withTimeZone; + // Marks a hidden (non-visible) column. fe-core's ConnectorColumnConverter translates this into + // Column.setIsVisible(false). Used by synthetic write columns a connector declares through the schema + // SPI (e.g. iceberg's __DORIS_ICEBERG_ROWID_COL__ / v3 row-lineage), which must stay hidden. Defaults + // true (visible); set via invisible(). + private final boolean visible; + // Reserved Doris field id (Column uniqueId). fe-core's ConnectorColumnConverter re-applies it via + // Column.setUniqueId() only when set (>= 0). Used by synthetic write columns whose Doris column identity + // must equal a connector-reserved field id (e.g. iceberg v3 row-lineage _row_id=2147483540 / + // _last_updated_sequence_number=2147483539, matched by field id BE-side). Defaults UNSET_UNIQUE_ID (-1), + // leaving the Doris default untouched; set via withUniqueId(). + private final int uniqueId; + // Marks a connector-reserved passthrough column: a synthetic column whose identity the ENGINE must + // recognize generically (without knowing the connector's column names) — e.g. iceberg v3 row-lineage + // (_row_id / _last_updated_sequence_number), which fe-core MERGE/UPDATE must pass through rather than treat + // as user data. fe-core's ConnectorColumnConverter re-applies it via Column.setReservedPassthrough(true); + // engine consumers then ask Column.isReservedPassthrough() instead of string-matching source column names. + // Defaults false; set via reservedPassthrough(). + private final boolean reservedPassthrough; public ConnectorColumn(String name, ConnectorType type, String comment, boolean nullable, String defaultValue) { @@ -38,12 +66,78 @@ public ConnectorColumn(String name, ConnectorType type, String comment, public ConnectorColumn(String name, ConnectorType type, String comment, boolean nullable, String defaultValue, boolean isKey) { + this(name, type, comment, nullable, defaultValue, isKey, false); + } + + public ConnectorColumn(String name, ConnectorType type, String comment, + boolean nullable, String defaultValue, boolean isKey, boolean isAutoInc) { + this(name, type, comment, nullable, defaultValue, isKey, isAutoInc, false); + } + + public ConnectorColumn(String name, ConnectorType type, String comment, + boolean nullable, String defaultValue, boolean isKey, boolean isAutoInc, + boolean isAggregated) { + this(name, type, comment, nullable, defaultValue, isKey, isAutoInc, isAggregated, false, true, + UNSET_UNIQUE_ID, false); + } + + private ConnectorColumn(String name, ConnectorType type, String comment, + boolean nullable, String defaultValue, boolean isKey, boolean isAutoInc, + boolean isAggregated, boolean withTimeZone, boolean visible, int uniqueId, + boolean reservedPassthrough) { this.name = Objects.requireNonNull(name, "name"); this.type = Objects.requireNonNull(type, "type"); this.comment = comment; this.nullable = nullable; this.defaultValue = defaultValue; this.isKey = isKey; + this.isAutoInc = isAutoInc; + this.isAggregated = isAggregated; + this.withTimeZone = withTimeZone; + this.visible = visible; + this.uniqueId = uniqueId; + this.reservedPassthrough = reservedPassthrough; + } + + /** + * Returns a copy of this column marked as a "with local time zone" timestamp. See + * {@link #isWithTimeZone()}; the marker is intentionally orthogonal to the mapped {@link #getType()} + * so it survives even when the column is mapped to a plain DATETIME (timestamp_tz mapping off). + */ + public ConnectorColumn withTimeZone() { + return new ConnectorColumn(name, type, comment, nullable, defaultValue, + isKey, isAutoInc, isAggregated, true, visible, uniqueId, reservedPassthrough); + } + + /** + * Returns a copy of this column marked hidden (non-visible). See {@link #isVisible()}; used to declare + * synthetic write columns through the schema SPI so the converter re-applies {@code setIsVisible(false)}. + */ + public ConnectorColumn invisible() { + return new ConnectorColumn(name, type, comment, nullable, defaultValue, + isKey, isAutoInc, isAggregated, withTimeZone, false, uniqueId, reservedPassthrough); + } + + /** + * Returns a copy of this column marked as a connector-reserved passthrough column. See + * {@link #isReservedPassthrough()}; the converter re-applies it via {@code Column.setReservedPassthrough(true)} + * so the engine can recognize a synthetic column (iceberg v3 row-lineage) generically, without knowing its + * source name. + */ + public ConnectorColumn reservedPassthrough() { + return new ConnectorColumn(name, type, comment, nullable, defaultValue, + isKey, isAutoInc, isAggregated, withTimeZone, visible, uniqueId, true); + } + + /** + * Returns a copy of this column carrying the given reserved field id. See {@link #getUniqueId()}; the + * converter re-applies it via {@code Column.setUniqueId()} only when set (>= 0). Used to declare + * synthetic write columns whose Doris column identity must equal a connector-reserved field id + * (iceberg v3 row-lineage). + */ + public ConnectorColumn withUniqueId(int uniqueId) { + return new ConnectorColumn(name, type, comment, nullable, defaultValue, + isKey, isAutoInc, isAggregated, withTimeZone, visible, uniqueId, reservedPassthrough); } public String getName() { @@ -70,6 +164,30 @@ public boolean isKey() { return isKey; } + public boolean isAutoInc() { + return isAutoInc; + } + + public boolean isAggregated() { + return isAggregated; + } + + public boolean isWithTimeZone() { + return withTimeZone; + } + + public boolean isVisible() { + return visible; + } + + public int getUniqueId() { + return uniqueId; + } + + public boolean isReservedPassthrough() { + return reservedPassthrough; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -81,6 +199,12 @@ public boolean equals(Object o) { ConnectorColumn that = (ConnectorColumn) o; return nullable == that.nullable && isKey == that.isKey + && isAutoInc == that.isAutoInc + && isAggregated == that.isAggregated + && withTimeZone == that.withTimeZone + && visible == that.visible + && uniqueId == that.uniqueId + && reservedPassthrough == that.reservedPassthrough && name.equals(that.name) && type.equals(that.type) && Objects.equals(comment, that.comment) @@ -89,7 +213,8 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(name, type, comment, nullable, defaultValue, isKey); + return Objects.hash(name, type, comment, nullable, defaultValue, isKey, isAutoInc, isAggregated, + withTimeZone, visible, uniqueId, reservedPassthrough); } @Override 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 new file mode 100644 index 00000000000000..7c413e5d313ab8 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorColumnStatistics.java @@ -0,0 +1,101 @@ +// 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; + +/** + * Per-column statistics a connector can serve WITHOUT a table scan (e.g. hive's HMS/spark column stats), + * feeding the query-planner column-statistics fast path. + * + *

This carries only RAW facts. The connector does NOT compute the Doris-type-dependent + * {@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.

+ */ +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; + private final double avgSizeBytes; + + public ConnectorColumnStatistics(long rowCount, long ndv, long numNulls, double avgSizeBytes) { + this.rowCount = rowCount; + this.ndv = ndv; + this.numNulls = numNulls; + this.avgSizeBytes = avgSizeBytes; + } + + /** The table row count the stats are relative to (source {@code numRows}), or -1 if unknown. */ + public long getRowCount() { + return rowCount; + } + + /** Number of distinct values, or -1 if unknown. */ + public long getNdv() { + return ndv; + } + + /** Number of nulls, or -1 if unknown. */ + public long getNumNulls() { + return numNulls; + } + + /** + * Average per-value size in bytes when the source knows it (e.g. a hive string column's {@code avgColLen}), + * or {@code -1} when it does not — fe-core then falls back to the Doris column's fixed slot width. + */ + public double getAvgSizeBytes() { + return avgSizeBytes; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ConnectorColumnStatistics)) { + return false; + } + ConnectorColumnStatistics that = (ConnectorColumnStatistics) o; + return rowCount == that.rowCount + && ndv == that.ndv + && numNulls == that.numNulls + && Double.compare(that.avgSizeBytes, avgSizeBytes) == 0; + } + + @Override + public int hashCode() { + return Objects.hash(rowCount, ndv, numNulls, avgSizeBytes); + } + + @Override + public String toString() { + return "ConnectorColumnStatistics{rowCount=" + rowCount + + ", ndv=" + ndv + + ", numNulls=" + numNulls + + ", avgSizeBytes=" + avgSizeBytes + "}"; + } +} 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 new file mode 100644 index 00000000000000..eeacdf232b6441 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorContractValidator.java @@ -0,0 +1,71 @@ +// 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.WriteOperation; + +import java.util.Set; + +/** + * Fails loud ({@link IllegalStateException}) if a connector's declared write capabilities are internally + * inconsistent. The invariants are purely structural (no table handle, no live catalog needed) and mirror + * 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}), + * 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.

+ */ +public final class ConnectorContractValidator { + + private ConnectorContractValidator() {} + + /** @throws IllegalStateException if any write-capability invariant is violated. */ + public static void validate(Connector connector, String catalogType) { + Set ops = connector.supportedWriteOperations(); + // #2 branch-write implies plain INSERT is supported (branch is an INSERT modifier). + if (connector.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())) { + 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())) { + 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()) { + 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/ConnectorDatabaseMetadata.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorDatabaseMetadata.java index 881df7eef4b4bb..eb5a210f2fe679 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorDatabaseMetadata.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorDatabaseMetadata.java @@ -26,6 +26,12 @@ */ public final class ConnectorDatabaseMetadata { + /** + * Property key carrying the database (namespace) base location, used by SHOW CREATE DATABASE to + * render the {@code LOCATION '...'} clause. Trino-aligned ({@code IcebergSchemaProperties.LOCATION_PROPERTY}). + */ + public static final String LOCATION_PROPERTY = "location"; + private final String name; private final Map properties; diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorDelegatedCredential.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorDelegatedCredential.java new file mode 100644 index 00000000000000..00e3bbd27f05e3 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorDelegatedCredential.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.connector.api; + +import java.util.Objects; +import java.util.OptionalLong; + +/** + * Neutral, immutable SPI carrier for a user's per-connection delegated (OIDC/JWT/SAML) credential. + * + *

The engine captures the credential at authentication time (fe-core {@code DelegatedCredential}) and + * copies it into this neutral DTO on the {@link ConnectorSession} — see + * {@link ConnectorSession#getDelegatedCredential()}. A connector that declares + * {@link ConnectorCapability#SUPPORTS_USER_SESSION} consumes it to project per-user identity onto the + * remote metadata source (e.g. an Iceberg REST catalog's {@code SessionCatalog}), WITHOUT importing any + * fe-core type. The field shape mirrors fe-core {@code DelegatedCredential} exactly so the copy is a + * lossless 1:1 mapping.

+ * + *

The {@link #getToken() token} is security-sensitive: it is connection-scoped and in-memory only, and + * must never be edit-logged, persisted, or rendered by {@code SHOW} / profile / {@code information_schema} + * surfaces. {@link #toString()} redacts it.

+ */ +public final class ConnectorDelegatedCredential { + + private final Type type; + private final String token; + // Absolute expiry in epoch millis, or null when the authenticator supplied none. Kept as a boxed Long + // (not OptionalLong) so the field is nullable; getExpiresAtMillis() re-wraps it, mirroring fe-core. + private final Long expiresAtMillis; + + public ConnectorDelegatedCredential(Type type, String token) { + this(type, token, OptionalLong.empty()); + } + + public ConnectorDelegatedCredential(Type type, String token, OptionalLong expiresAtMillis) { + this.type = Objects.requireNonNull(type, "type is required"); + this.token = Objects.requireNonNull(token, "token is required"); + Objects.requireNonNull(expiresAtMillis, "expiresAtMillis is required"); + this.expiresAtMillis = expiresAtMillis.isPresent() ? expiresAtMillis.getAsLong() : null; + } + + public Type getType() { + return type; + } + + public String getToken() { + return token; + } + + public OptionalLong getExpiresAtMillis() { + return expiresAtMillis == null ? OptionalLong.empty() : OptionalLong.of(expiresAtMillis); + } + + public boolean isExpired(long currentTimeMillis) { + // Inclusive comparison (>=) on purpose (mirrors fe-core DelegatedCredential.isExpired): at the exact + // expiration instant the credential is already treated as expired, so a token is never handed to the + // downstream REST server right as it stops being accepted — fail closed on the boundary. + return expiresAtMillis != null && currentTimeMillis >= expiresAtMillis; + } + + @Override + public String toString() { + return "ConnectorDelegatedCredential{" + + "type=" + type + + ", token=" + + ", expiresAtMillis=" + expiresAtMillis + + '}'; + } + + /** + * Credential kinds, mirroring fe-core {@code DelegatedCredential.Type} one-to-one. The connector maps + * each to the matching Iceberg OAuth2 token-type key when the delegated-token mode is + * {@code token_exchange}; {@code access_token} mode passes the token verbatim as the OAuth2 bearer. + */ + public enum Type { + ACCESS_TOKEN, + ID_TOKEN, + JWT, + SAML + } +} 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 56adb847880e80..ae7e1e2df373a0 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 @@ -17,10 +17,21 @@ package org.apache.doris.connector.api; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.mvcc.ConnectorMvccPartitionView; +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.pushdown.ConnectorExpression; + 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; /** * Central metadata interface that a connector must implement. @@ -44,6 +55,195 @@ default Map getProperties() { return Collections.emptyMap(); } + // ──────────────────── MVCC Snapshots ──────────────────── + + /** + * Returns the current snapshot at query begin time, used as the MVCC pin + * for all subsequent reads of {@code handle}. + * + *

Returning {@link Optional#empty()} means the connector does not + * support MVCC and reads see whatever is current.

+ */ + default Optional beginQuerySnapshot( + ConnectorSession session, ConnectorTableHandle handle) { + return Optional.empty(); + } + + /** + * Returns a connector-supplied, range-aware partition view for the MTMV / partition-aware + * materialized-view refresh path, or {@link Optional#empty()} when the connector has none. + * + *

The generic table model materializes its partition view from {@code listPartitions} by + * default (LIST partitions keyed on a last-modified timestamp). A connector whose partitions are + * intrinsically ranges with a snapshot-id freshness marker (e.g. iceberg's time transforms) + * overrides this to return a {@link ConnectorMvccPartitionView}; the generic model then builds + * {@code RangePartitionItem}s from the pre-rendered bounds and picks the snapshot type from the + * view's {@link ConnectorMvccPartitionView#getFreshness()}. All data-source-specific math + * (transform-to-range, partition-evolution overlap merge, snapshot-id resolution) happens inside + * the connector — fe-core stays source-agnostic.

+ * + *

The default returns empty: connectors without a range partition view keep the generic + * {@code listPartitions} / LIST / timestamp behavior unchanged.

+ */ + default Optional getMvccPartitionView( + ConnectorSession session, ConnectorTableHandle handle) { + return Optional.empty(); + } + + /** + * Whole-table MTMV freshness for a connector whose table-level change signal is a last-modified + * TIMESTAMP rather than a snapshot id (e.g. hive: {@code transient_lastDdlTime} / the max partition + * modify time). The generic model wraps the result in an {@code MTMVMaxTimestampSnapshot} table snapshot. + * + *

Consulted ONLY when the query-begin pin's {@link ConnectorMvccSnapshot#isLastModifiedFreshness()} + * is set — i.e. a last-modified connector, which the generic model reads off the pin it already holds. + * A snapshot-id connector (paimon/iceberg) leaves the pin flag false, so this is NEVER called for it and it + * pays ZERO extra metadata round-trips. And it is on the MTMV refresh path, never the scan hot path — so a + * partitioned last-modified connector may pay a {@code get_partitions_by_names} round-trip here (mirroring + * legacy, which fetched per-partition modify time only at refresh time) without regressing queries.

+ * + *

The default returns empty: a connector that sets the pin flag MUST override this.

+ */ + default Optional getTableFreshness( + ConnectorSession session, ConnectorTableHandle handle) { + return Optional.empty(); + } + + /** + * Per-partition last-modified millis for a last-modified connector, wrapped by the generic model in an + * {@code MTMVTimestampSnapshot}. Fetched on the MTMV refresh path only — a last-modified connector's + * {@code listPartitions} is names-only on the scan hot path (its per-partition {@code lastModifiedMillis} + * stays {@code -1}), so the real time is fetched here on demand instead. + * + *

Consulted ONLY when the query-begin pin's {@link ConnectorMvccSnapshot#isLastModifiedFreshness()} + * is set (a snapshot-id connector never reaches here — zero extra round-trips). fe-core validates the + * partition exists in the materialized set BEFORE calling this; an {@code empty} return therefore means the + * partition VANISHED between the materialize and this fetch (a refresh-time race), and fe-core raises the + * legacy "can not find partition" error. The default returns empty: a last-modified connector MUST + * override it.

+ */ + default OptionalLong getPartitionFreshnessMillis( + ConnectorSession session, ConnectorTableHandle handle, String partitionName) { + return OptionalLong.empty(); + } + + /** + * Resolves an explicit time-travel spec (extracted from {@code FOR TIME AS OF} / + * {@code FOR VERSION AS OF}, or the {@code @tag} / {@code @branch} / {@code @incr} + * scan params) into a pinned snapshot. + * + *

The connector owns all provider-specific parsing of {@code spec} (snapshot-id + * lookup, datetime parsing, tag/branch resolution, incremental-window validation). + * The returned snapshot's {@link ConnectorMvccSnapshot#getProperties()} carries the + * connector's scan options and its {@link ConnectorMvccSnapshot#getSchemaId()} is the + * resolved schema version.

+ * + *

Returns {@link Optional#empty()} when the spec is unsupported or the target is not + * found, in which case the engine surfaces a user error. The default returns empty: + * connectors without time-travel do not honor explicit specs.

+ */ + default Optional resolveTimeTravel( + ConnectorSession session, ConnectorTableHandle handle, + ConnectorTimeTravelSpec spec) { + return Optional.empty(); + } + + /** + * Threads a pinned MVCC / time-travel {@code snapshot} into the table handle BEFORE + * {@code planScan}, so an MVCC-capable connector can return a handle that reads at that + * snapshot (mirrors the {@code applyFilter} / {@code applyProjection} handle-update pattern). + * + *

Contract for MVCC connectors: thread the FULL {@code snapshot.getProperties()} + * (the scan-options map) into the returned handle so the read path sees exactly the + * connector-resolved options. When {@code properties} is empty, fall back to setting + * {@code scan.snapshot-id = snapshot.getSnapshotId()} (latest-pin parity).

+ * + *

The default returns {@code handle} unchanged: connectors without time-travel ignore the + * pin and read whatever is current.

+ */ + default ConnectorTableHandle applySnapshot(ConnectorSession session, + ConnectorTableHandle handle, ConnectorMvccSnapshot snapshot) { + return handle; // default: connectors without time-travel ignore the pin + } + + /** + * Returns extra scan-level predicates the engine MUST apply for {@code handle} at the pinned + * {@code snapshot} — a connector "residual predicate" the read cannot enforce by file selection alone. + * The canonical case is an incremental / CDC commit-time window: a rewritten base file also carries + * forward out-of-window rows, so a ROW-LEVEL commit-time filter is required for correctness. The engine + * reverse-converts each returned {@link ConnectorExpression} into its native predicate and wraps a filter + * 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.

+ * + *

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 + * (e.g. hudi incremental read) returns a non-empty list, and only for the scans that need it.

+ */ + default List getSyntheticScanPredicates(ConnectorSession session, + ConnectorTableHandle handle, ConnectorMvccSnapshot snapshot) { + return List.of(); // default: connectors without a residual scan predicate add nothing + } + + /** + * Threads a per-group rewrite file scope into the table handle BEFORE {@code planScan}, so the + * distributed {@code rewrite_data_files} driver can scope each per-group INSERT-SELECT scan to only the + * data files that group bin-packed (mirrors {@link #applySnapshot} / {@code applyFilter} handle-update + * pattern). {@code rawDataFilePaths} are the RAW file paths the connector's {@code planRewrite} emitted on + * its {@link org.apache.doris.connector.api.procedure.ConnectorRewriteGroup}s; the connector matches its + * re-enumerated scan tasks against the SAME raw paths (no normalization on either side — over-reading the + * full table would make each group rewrite far beyond its bin-pack set and produce duplicate rows under + * OCC). + * + *

The default returns {@code handle} unchanged: connectors without distributed rewrite ignore the + * scope and scan the whole table. A {@code null}/empty set is also a no-op (no scope = full scan), so the + * pin is only applied when a real per-group path set is present.

+ */ + default ConnectorTableHandle applyRewriteFileScope(ConnectorSession session, + ConnectorTableHandle handle, Set rawDataFilePaths) { + return handle; // default: connectors without distributed rewrite ignore the scope + } + + /** + * Threads a Top-N lazy-materialization signal into the table handle BEFORE {@code planScan} / + * {@code getScanNodeProperties} (mirrors {@link #applySnapshot} / {@link #applyRewriteFileScope} + * handle-update pattern). The generic engine calls this when the scan carries the synthesized, + * engine-wide lazy-materialization row-id column ({@code __DORIS_GLOBAL_ROWID_COL__*}, injected by + * {@code LazyMaterializeTopN} for {@code ORDER BY ... LIMIT}): under lazy materialization BE reads the + * sort key first, then re-fetches the OTHER (non-projected) columns of the surviving rows by row-id. + * + *

Contract: a connector that builds column-pruned scan metadata keyed by the REQUESTED columns + * (e.g. a field-id schema dictionary) MUST, once this is applied, build that metadata over the FULL + * table schema instead — otherwise a lazily re-fetched column has no entry and the native read resolves + * it wrong (schema-evolved tables) or drops it. A connector whose scan metadata already spans all + * columns ignores this.

+ * + *

The default returns {@code handle} unchanged: connectors without column-pruned scan metadata are + * unaffected by lazy materialization.

+ */ + default ConnectorTableHandle applyTopnLazyMaterialization(ConnectorSession session, + ConnectorTableHandle handle) { + return handle; // default: connectors without column-pruned scan metadata ignore the signal + } + + /** + * Engine-neutral rows for a connector metadata table (e.g. the hudi commit timeline), one row per record in + * the fixed column order the table-valued function declares. The TVF owns the column schema; the connector + * returns only the {@code String} cell values in that order (a {@code null} cell renders as SQL NULL). + * {@code kind} selects the metadata table (currently only {@code "timeline"}). + * + *

Default empty: a connector without a metadata table returns nothing. The plugin-driven TVF arm gates on + * {@link ConnectorCapability#SUPPORTS_METADATA_TABLE} before delegating here, so only a connector that + * declares the capability is ever asked. Connectors overriding this that read remote metadata off the + * planning thread must pin the TCCL to the plugin classloader themselves (fe-core does not).

+ */ + default List> getMetadataTableRows(ConnectorSession session, ConnectorTableHandle handle, + String kind) { + return Collections.emptyList(); + } + @Override default void close() throws IOException { } 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 fb8d8879ee420a..2eb569d2642b3d 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 @@ -17,7 +17,9 @@ package org.apache.doris.connector.api; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -26,13 +28,99 @@ */ public final class ConnectorPartitionInfo { + /** Sentinel for "unknown" on the numeric stats fields. */ + public static final long UNKNOWN = -1L; + private final String partitionName; private final Map partitionValues; private final Map properties; + private final long rowCount; + private final long sizeBytes; + private final long lastModifiedMillis; + private final long fileCount; + /** + * Per-partition-value SQL-NULL flags, positionally aligned to the values parsed out of + * {@link #partitionName} (i.e. flag {@code i} describes the {@code i}-th {@code key=value} segment). + * Empty means "no value is NULL" — a connector that does not opt in leaves it empty and the + * fe-core partition-item builder treats every value as non-null (unchanged behavior). A connector + * that renders a genuine-NULL partition value (e.g. hive's {@code __HIVE_DEFAULT_PARTITION__} or + * paimon's {@code partition.default-name}) sets the corresponding flag {@code true} so fe-core builds + * a typed {@code NullLiteral} instead of parsing the sentinel string into the column type. + */ + private final List partitionValueNullFlags; + + /** + * The RENDERED partition values in name-segment order, positionally aligned to + * {@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. + */ + private final List orderedPartitionValues; + /** + * Backward-compatible constructor. Numeric stats fields are set to + * {@link #UNKNOWN}. + */ public ConnectorPartitionInfo(String partitionName, Map partitionValues, Map properties) { + this(partitionName, partitionValues, properties, + UNKNOWN, UNKNOWN, UNKNOWN, UNKNOWN); + } + + /** + * Convenience constructor for a partition with unknown numeric stats but connector-supplied + * per-value NULL flags (e.g. hive, which lists names only). + */ + public ConnectorPartitionInfo(String partitionName, + Map partitionValues, + Map properties, + List partitionValueNullFlags) { + this(partitionName, partitionValues, properties, + UNKNOWN, UNKNOWN, UNKNOWN, UNKNOWN, partitionValueNullFlags); + } + + /** + * Convenience constructor for a partition with unknown numeric stats but connector-supplied ordered + * partition values (and optional per-value NULL flags). Used by the MVCC partition-item connectors + * (hive/paimon/iceberg/hudi) so fe-core zips values instead of re-parsing the rendered name. + */ + public ConnectorPartitionInfo(String partitionName, + Map partitionValues, + Map properties, + List orderedPartitionValues, + List partitionValueNullFlags) { + this(partitionName, partitionValues, properties, + UNKNOWN, UNKNOWN, UNKNOWN, UNKNOWN, orderedPartitionValues, partitionValueNullFlags); + } + + public ConnectorPartitionInfo(String partitionName, + Map partitionValues, + Map properties, + long rowCount, long sizeBytes, long lastModifiedMillis, long fileCount) { + this(partitionName, partitionValues, properties, + rowCount, sizeBytes, lastModifiedMillis, fileCount, Collections.emptyList()); + } + + public ConnectorPartitionInfo(String partitionName, + Map partitionValues, + Map properties, + long rowCount, long sizeBytes, long lastModifiedMillis, long fileCount, + List partitionValueNullFlags) { + this(partitionName, partitionValues, properties, + rowCount, sizeBytes, lastModifiedMillis, fileCount, + Collections.emptyList(), partitionValueNullFlags); + } + + public ConnectorPartitionInfo(String partitionName, + Map partitionValues, + Map properties, + long rowCount, long sizeBytes, long lastModifiedMillis, long fileCount, + List orderedPartitionValues, + List partitionValueNullFlags) { this.partitionName = Objects.requireNonNull( partitionName, "partitionName"); this.partitionValues = partitionValues == null @@ -41,6 +129,16 @@ public ConnectorPartitionInfo(String partitionName, this.properties = properties == null ? Collections.emptyMap() : Collections.unmodifiableMap(properties); + this.rowCount = rowCount; + this.sizeBytes = sizeBytes; + this.lastModifiedMillis = lastModifiedMillis; + this.fileCount = fileCount; + this.orderedPartitionValues = orderedPartitionValues == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(orderedPartitionValues)); + this.partitionValueNullFlags = partitionValueNullFlags == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(partitionValueNullFlags)); } public String getPartitionName() { @@ -55,6 +153,43 @@ public Map getProperties() { return properties; } + /** @return row count, or {@link #UNKNOWN} when not collected. */ + public long getRowCount() { + return rowCount; + } + + /** @return on-disk size in bytes, or {@link #UNKNOWN}. */ + public long getSizeBytes() { + return sizeBytes; + } + + /** @return last-modified epoch millis, or {@link #UNKNOWN}. */ + public long getLastModifiedMillis() { + return lastModifiedMillis; + } + + /** @return number of data files in the partition, or {@link #UNKNOWN}. */ + public long getFileCount() { + return fileCount; + } + + /** + * @return per-value SQL-NULL flags positionally aligned to the {@link #partitionName} value parse; + * empty when the connector did not opt in (no value is NULL). Unmodifiable. + */ + public List getPartitionValueNullFlags() { + return partitionValueNullFlags; + } + + /** + * @return the rendered partition values in name-segment order (aligned to + * {@link #getPartitionValueNullFlags()}); empty when the connector did not supply them, in which + * case fe-core parses {@link #getPartitionName()} itself. Unmodifiable. + */ + public List getOrderedPartitionValues() { + return orderedPartitionValues; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -64,19 +199,32 @@ public boolean equals(Object o) { return false; } ConnectorPartitionInfo that = (ConnectorPartitionInfo) o; - return partitionName.equals(that.partitionName) + return rowCount == that.rowCount + && sizeBytes == that.sizeBytes + && lastModifiedMillis == that.lastModifiedMillis + && fileCount == that.fileCount + && partitionName.equals(that.partitionName) && partitionValues.equals(that.partitionValues) - && properties.equals(that.properties); + && properties.equals(that.properties) + && orderedPartitionValues.equals(that.orderedPartitionValues) + && partitionValueNullFlags.equals(that.partitionValueNullFlags); } @Override public int hashCode() { - return Objects.hash(partitionName, partitionValues, properties); + return Objects.hash(partitionName, partitionValues, properties, + rowCount, sizeBytes, lastModifiedMillis, fileCount, + orderedPartitionValues, partitionValueNullFlags); } @Override public String toString() { return "ConnectorPartitionInfo{name='" + partitionName - + "', values=" + partitionValues + "}"; + + "', values=" + partitionValues + + ", rowCount=" + rowCount + + ", sizeBytes=" + sizeBytes + + ", fileCount=" + fileCount + + ", orderedValues=" + orderedPartitionValues + + ", nullFlags=" + partitionValueNullFlags + "}"; } } 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 addb6d929ac20f..fe7c6a485128ce 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 @@ -37,11 +37,26 @@ default boolean databaseExists(ConnectorSession session, return false; } - /** Retrieves metadata for the specified database. */ + /** + * Retrieves metadata for the specified database. The default returns metadata with an empty + * property map (so SHOW CREATE DATABASE renders no LOCATION/PROPERTIES for connectors with no + * database-level metadata, matching their pre-flip behavior); connectors that expose namespace + * metadata (e.g. iceberg's namespace location) override this. Mirrors the graceful empty defaults + * of {@link #listDatabaseNames}/{@link #databaseExists} rather than throwing. + */ default ConnectorDatabaseMetadata getDatabase( ConnectorSession session, String dbName) { - throw new DorisConnectorException( - "getDatabase not implemented"); + return new ConnectorDatabaseMetadata(dbName, Collections.emptyMap()); + } + + /** + * 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. + */ + default boolean supportsCreateDatabase() { + return false; } /** Creates a new database with the given name and properties. */ @@ -57,4 +72,15 @@ default void dropDatabase(ConnectorSession session, 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. + */ + default void dropDatabase(ConnectorSession session, + String dbName, boolean ifExists, boolean force) { + dropDatabase(session, dbName, ifExists); + } } 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 16a471b7dbd4b1..320805bacd0faf 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSession.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSession.java @@ -17,7 +17,10 @@ package org.apache.doris.connector.api; +import org.apache.doris.connector.api.handle.ConnectorTransaction; + import java.util.Map; +import java.util.Optional; /** * Session context passed to every connector operation. @@ -27,6 +30,32 @@ public interface ConnectorSession { /** Returns the unique query identifier. */ String getQueryId(); + /** + * Returns a stable per-connection session identifier, preserved across FE observer→master forwarding. + * + *

Used as the Iceberg {@code SessionCatalog.SessionContext.sessionId()} — the OAuth2 {@code AuthSession} + * cache key — for {@link ConnectorCapability#SUPPORTS_USER_SESSION} connectors, so a session's queries reuse + * one minted auth session rather than re-authenticating per query. The default falls back to + * {@link #getQueryId()} for sessions/tests that carry no session id; the engine session implementation + * overrides it with the captured (and FE-forward-preserved) session id.

+ */ + default String getSessionId() { + return getQueryId(); + } + + /** + * Returns the user's per-connection delegated credential (OIDC/JWT/SAML), when one was captured at + * authentication and this session targets a connector that consumes it. + * + *

Populated ONLY when the connector declares {@link ConnectorCapability#SUPPORTS_USER_SESSION} + * (least-privilege: a connector that would never use the token never receives it). The credential is a + * neutral SPI DTO — the connector reads it here instead of any fe-core type. Empty by default (no + * credential, or a connector that does not opt in).

+ */ + default Optional getDelegatedCredential() { + return Optional.empty(); + } + /** Returns the authenticated user name. */ String getUser(); @@ -60,4 +89,57 @@ public interface ConnectorSession { default Map getSessionProperties() { return java.util.Collections.emptyMap(); } + + /** + * Returns the transaction this session is currently bound to, if any. + * + *

Used by connectors whose {@code begin*} write operations need to + * attach work to an outer transaction opened by + * {@link ConnectorWriteOps#beginTransaction(ConnectorSession)}. + * Connectors with statement-scoped writes (e.g. JDBC auto-commit) can + * ignore this and the default empty value.

+ */ + default Optional getCurrentTransaction() { + return Optional.empty(); + } + + /** + * Binds a transaction to this session so that connector {@code begin*} / + * {@code planWrite} operations can attach their work to it. Mutable session + * implementations (e.g. the engine's {@code ConnectorSessionImpl}) override + * this; the default rejects binding, matching the empty default of + * {@link #getCurrentTransaction()}. + */ + default void setCurrentTransaction(ConnectorTransaction txn) { + throw new UnsupportedOperationException("setCurrentTransaction is not supported by this session"); + } + + /** + * Allocates a globally-unique engine (Doris) transaction id for a connector + * transaction opened via {@link ConnectorWriteOps#beginTransaction(ConnectorSession)}. + * + *

The id is the engine-side transaction id: it is registered in the engine + * transaction registry and stamped into the connector's data sink, so a + * connector must obtain it from the engine rather than mint its own. The + * default throws; the engine session implementation overrides it.

+ * + * @return a fresh engine transaction id + */ + default long allocateTransactionId() { + throw new UnsupportedOperationException("transaction id allocation not supported"); + } + + /** + * Returns the per-statement scope for this session — a memoization arena that lives exactly as long + * as the current SQL statement, letting a connector load a table (and derive per-statement state) + * once and share that single object across every read + write resolver in the statement. + * + *

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

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

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

+ * + *

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

+ * + *

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

+ */ +public interface ConnectorStatementScope { + + /** + * Returns the value cached under {@code key}, computing it with {@code loader} on first access and + * caching it for the rest of the statement. Within one statement the same key returns the same + * instance to every caller; under {@link #NONE} the loader runs on every call. + */ + T computeIfAbsent(String key, Supplier loader); + + /** + * Typed convenience over {@link #computeIfAbsent} for the ONE {@link ConnectorMetadata} a statement uses per + * {@code key}. The engine's metadata funnel builds {@code key} (as {@code "metadata:" + catalogId}, plus the + * owning connector's label for a heterogeneous gateway) and passes a {@code factory} that calls + * {@code Connector#getMetadata(session)}; every read / scan / DDL / MVCC resolver of the statement then shares + * the single memoized instance and the factory runs at most once per statement. Under {@link #NONE} the + * factory runs on every call (byte-identical to building metadata every time). + */ + default ConnectorMetadata getOrCreateMetadata(String key, Supplier factory) { + return computeIfAbsent(key, factory); + } + + /** + * Deterministically closes, at statement end, every value this scope holds that is {@link AutoCloseable} (a + * memoized {@link ConnectorMetadata} is, via {@link java.io.Closeable}). Best-effort and log-and-continue: a + * failure closing one value does not abort the rest. MUST be idempotent (close-once) in implementations — the + * engine fires it from more than one locus (the query-finish callback, and a reused prepared statement's + * per-execution reset). The default is a no-op, so {@link #NONE} — which memoizes nothing — stays inert. + */ + default void closeAll() { + } + + /** The no-op scope: never caches; each call invokes the loader (offline / no-context / tests). */ + ConnectorStatementScope NONE = new ConnectorStatementScope() { + @Override + public T computeIfAbsent(String key, Supplier loader) { + return loader.get(); + } + }; +} diff --git a/fe/fe-connector/fe-connector-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 fb762355e53071..2e89a09d7dd881 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 @@ -19,6 +19,8 @@ import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import java.util.Collections; +import java.util.List; import java.util.Optional; /** @@ -32,4 +34,45 @@ default Optional getTableStatistics( ConnectorTableHandle handle) { return Optional.empty(); } + + /** + * Returns per-column statistics the connector can serve WITHOUT a table scan — the query-planner + * column-statistics fast path, consulted on a stats-cache miss (fe-core's + * {@code ColumnStatisticsCacheLoader}). Must be cheap (a metadata read, no scan). Returns empty when + * unavailable, so a connector with no cheap column stats simply does not override it and fe-core falls + * back to a full ANALYZE. fe-core derives the Doris {@code ColumnStatistic} (dataSize / avgSize) from the + * returned raw facts — see {@link ConnectorColumnStatistics}. + */ + default Optional getColumnStatistics( + ConnectorSession session, + ConnectorTableHandle handle, + String columnName) { + return Optional.empty(); + } + + /** + * Estimates the table's on-disk data size in bytes by listing its data files, for connectors that can + * cheaply enumerate them (e.g. hive). fe-core uses this to estimate a row count + * ({@code dataSize / }) when neither an exact row count nor a metastore-recorded size (from + * {@link #getTableStatistics}) is available — fe-core only calls it when the + * {@code enable_get_row_count_from_file_list} global is set. A potentially expensive remote listing, so + * connectors that cannot do it cheaply must NOT override it. Returns -1 when the size cannot be + * estimated (not supported, unlistable, empty, or any error) — the default. + */ + default long estimateDataSizeByListingFiles(ConnectorSession session, ConnectorTableHandle handle) { + return -1; + } + + /** + * Returns the RAW byte length of every data file across ALL partitions of the table (not sampled, not summed), + * for {@code ANALYZE ... WITH SAMPLE}: fe-core seed-shuffles and cumulates these sizes to a sample scale + * 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 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/ConnectorTableOps.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java index 8a6caa7cb84f6f..aa6d38ef669983 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,8 +17,16 @@ package org.apache.doris.connector.api; +import org.apache.doris.connector.api.ddl.BranchChange; +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; @@ -37,6 +45,50 @@ default Optional getTableHandle( 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) { @@ -44,6 +96,38 @@ default ConnectorTableSchema getTableSchema( "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) { @@ -57,6 +141,46 @@ default List listTableNames(ConnectorSession session, 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, @@ -65,6 +189,27 @@ default void createTable(ConnectorSession session, "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) { @@ -72,6 +217,115 @@ default void dropTable(ConnectorSession session, "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"); + } + + /** 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) { @@ -126,4 +380,38 @@ default org.apache.doris.thrift.TTableDescriptor buildTableDescriptor( 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(); + } } 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 079008933e4bf5..8a83ad1d95b488 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 @@ -17,10 +17,13 @@ package org.apache.doris.connector.api; +import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; /** * Describes the schema of a connector table, including its columns @@ -28,6 +31,95 @@ */ 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. + */ + public static final String INTERNAL_KEY_PREFIX = "__internal."; + + /** + * Reserved property key carrying the table location string for SHOW CREATE TABLE rendering. + * Connectors emit it here (rather than as a user-facing property) so the FE renders it as the + * {@code LOCATION '...'} clause and strips it from the PROPERTIES(...) block. Distinct from a + * connector's own user-facing location property (e.g. paimon's SDK {@code path} option, which + * legitimately stays in PROPERTIES). + */ + public static final String SHOW_LOCATION_KEY = INTERNAL_KEY_PREFIX + "show.location"; + + /** + * Reserved property key carrying the fully-rendered {@code PARTITION BY ...} clause (Doris SQL, + * including transform terms like {@code BUCKET(8, `c`)} / {@code DAY(`c`)}) for SHOW CREATE TABLE. + * The connector pre-renders it (the transform-aware logic is connector-specific); the FE appends + * it verbatim and strips it from PROPERTIES. + */ + public static final String SHOW_PARTITION_CLAUSE_KEY = INTERNAL_KEY_PREFIX + "show.partition-clause"; + + /** + * Reserved property key carrying the fully-rendered {@code ORDER BY (...)} clause for SHOW CREATE + * TABLE. The connector pre-renders it; the FE appends it verbatim and strips it from PROPERTIES. + */ + public static final String SHOW_SORT_CLAUSE_KEY = INTERNAL_KEY_PREFIX + "show.sort-clause"; + + /** + * Reserved property key carrying a CSV of the RAW remote partition-column names (declaration order), so + * the generic fe-core consumer ({@code PluginDrivenExternalTable.toSchemaCacheValue}) can model the table + * as partitioned. Emitted by every connector that has partition columns (hive/hudi/iceberg/paimon/ + * maxcompute). FE-only (BE receives partition columns via the separate {@code path_partition_keys} scan + * property); stripped from the user-facing SHOW CREATE TABLE PROPERTIES(...) block. + */ + 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 + * connector-wide trait, so it emits it here per-table. + * + *

fe-core's {@code PluginDrivenExternalTable.getDistributionColumnNames()} reads it from the cached schema + * (no remote round-trip) so a flipped bucketed hive table picks the same NDV estimator as legacy + * {@code HMSExternalTable.getDistributionColumnNames} (a single bucket column selects the linear estimator in + * sampled analyze). A non-bucketed table omits it and connectors with no bucketing concept never emit it. + * Stripped from the user-facing SHOW CREATE TABLE PROPERTIES(...) block.

+ */ + public static final String DISTRIBUTION_COLUMNS_KEY = INTERNAL_KEY_PREFIX + "connector.distribution-columns"; + + /** + * The full set of FE-internal reserved control keys. fe-core strips every member from the user-facing + * SHOW CREATE TABLE PROPERTIES(...) block; centralizing the set here means adding a new reserved key is a + * single edit that the strip picks up automatically. All members share {@link #INTERNAL_KEY_PREFIX}. + */ + 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))); + private final String tableName; private final List columns; private final String tableFormatType; 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 b91bd78803847e..e357b20dcbb6fa 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 @@ -28,6 +28,25 @@ *

A type is identified by its name (e.g. "INT", "VARCHAR", "DECIMAL"), * optional precision/scale parameters, and optional child types for * complex types like ARRAY or MAP.

+ * + *

Per-child nullability + comment ({@link #isChildNullable(int)} / + * {@link #getChildComment(int)}): for complex types the type optionally carries the nullability and + * comment of each child (STRUCT field, ARRAY element, MAP value), parallel to {@link #getChildren()}. + * These are additive — the legacy factories ({@link #of}/{@link #arrayOf(ConnectorType)}/ + * {@link #mapOf(ConnectorType, ConnectorType)}/{@link #structOf(List, List)}) leave them unset, in which + * case every child defaults to nullable with no comment. They let a connector preserve a NOT NULL declared + * inside a complex type (e.g. iceberg CREATE TABLE / MODIFY COLUMN of a STRUCT field) and the per-field + * comments needed to diff a complex MODIFY. They are intentionally excluded from {@link #equals(Object)}/ + * {@link #hashCode()}: type identity stays the structural shape (name/precision/scale/children/field + * names), matching the legacy Doris {@code Type} comparison that drives schema-change detection (nullability + * and comment are compared separately, field-by-field, by the consumer) and keeping every existing + * equality-based caller/test unaffected.

+ * + *

Per-child field id ({@link #getChildFieldId(int)}, set via {@link #withChildrenFieldIds(List)}): + * the stable id of each child field, parallel to {@link #getChildren()}. Also additive and excluded + * 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.

*/ public final class ConnectorType { @@ -36,6 +55,20 @@ public final class ConnectorType { private final int scale; private final List children; private final List fieldNames; + // Per-child nullability, parallel to children (STRUCT field / ARRAY element / MAP value). Empty (or + // shorter than children) means "unset" -> the missing entries default to nullable. NOT part of equals(). + private final List childrenNullable; + // Per-child comment, parallel to children. Empty / shorter than children means "unset" -> null comment. + // Only STRUCT fields carry meaningful comments today; ARRAY element / MAP value are left null (legacy + // parity: the complex-MODIFY diff drops element/value comments). NOT part of equals(). + private final List childrenComments; + // Per-child stable field id, parallel to children (STRUCT field / ARRAY element / MAP key+value). Empty + // (or shorter than children) means "unset" -> the missing entries default to -1. NOT part of equals(): + // like childrenNullable/childrenComments it is metadata carried alongside the structural shape, not + // identity. Used by connectors (iceberg) that track a stable per-field id so the engine can rewrite a + // nested access path from field names to ids (SlotTypeReplacer) and the BE field-id scan path can match + // nested leaves by id; ConnectorColumnConverter applies these onto the Doris child column tree's uniqueId. + private final List childrenFieldIds; public ConnectorType(String typeName) { this(typeName, -1, -1, Collections.emptyList(), @@ -55,6 +88,21 @@ public ConnectorType(String typeName, int precision, int scale, public ConnectorType(String typeName, int precision, int scale, List children, List fieldNames) { + this(typeName, precision, scale, children, fieldNames, + Collections.emptyList(), Collections.emptyList()); + } + + public ConnectorType(String typeName, int precision, int scale, + List children, List fieldNames, + List childrenNullable, List childrenComments) { + this(typeName, precision, scale, children, fieldNames, childrenNullable, childrenComments, + Collections.emptyList()); + } + + public ConnectorType(String typeName, int precision, int scale, + List children, List fieldNames, + List childrenNullable, List childrenComments, + List childrenFieldIds) { this.typeName = Objects.requireNonNull(typeName, "typeName"); this.precision = precision; this.scale = scale; @@ -64,6 +112,15 @@ public ConnectorType(String typeName, int precision, int scale, this.fieldNames = fieldNames == null ? Collections.emptyList() : Collections.unmodifiableList(fieldNames); + this.childrenNullable = childrenNullable == null + ? Collections.emptyList() + : Collections.unmodifiableList(childrenNullable); + this.childrenComments = childrenComments == null + ? Collections.emptyList() + : Collections.unmodifiableList(childrenComments); + this.childrenFieldIds = childrenFieldIds == null + ? Collections.emptyList() + : Collections.unmodifiableList(childrenFieldIds); } /** Factory: simple type with no parameters. */ @@ -77,25 +134,61 @@ public static ConnectorType of(String typeName, return new ConnectorType(typeName, precision, scale); } - /** Factory: ARRAY type with element type. */ + /** Factory: ARRAY type with element type (element defaults to nullable). */ public static ConnectorType arrayOf(ConnectorType elementType) { return new ConnectorType("ARRAY", -1, -1, Collections.singletonList(elementType)); } - /** Factory: MAP type with key and value types. */ + /** Factory: ARRAY type with element type and element nullability. */ + public static ConnectorType arrayOf(ConnectorType elementType, boolean elementNullable) { + return new ConnectorType("ARRAY", -1, -1, + Collections.singletonList(elementType), Collections.emptyList(), + Collections.singletonList(elementNullable), Collections.emptyList()); + } + + /** Factory: MAP type with key and value types (value defaults to nullable; iceberg keys are required). */ public static ConnectorType mapOf(ConnectorType keyType, ConnectorType valueType) { return new ConnectorType("MAP", -1, -1, Arrays.asList(keyType, valueType)); } - /** Factory: STRUCT type with named fields. */ + /** + * Factory: MAP type with key/value types and value nullability. The key is reported as non-nullable + * (iceberg / Doris map keys are always required); only the value nullability is meaningful. + */ + public static ConnectorType mapOf(ConnectorType keyType, + ConnectorType valueType, boolean valueNullable) { + return new ConnectorType("MAP", -1, -1, + Arrays.asList(keyType, valueType), Collections.emptyList(), + Arrays.asList(false, valueNullable), Collections.emptyList()); + } + + /** Factory: STRUCT type with named fields (every field defaults to nullable, no comment). */ public static ConnectorType structOf(List names, List fieldTypes) { return new ConnectorType("STRUCT", -1, -1, fieldTypes, names); } + /** Factory: STRUCT type with named fields plus per-field nullability and comments (parallel lists). */ + public static ConnectorType structOf(List names, + List fieldTypes, List fieldNullable, List fieldComments) { + return new ConnectorType("STRUCT", -1, -1, fieldTypes, names, fieldNullable, fieldComments); + } + + /** + * Returns a copy of this type carrying the given per-child field ids (parallel to {@link #getChildren()}: + * STRUCT fields in order / ARRAY element / MAP key+value). Additive and excluded from equality — used by + * connectors that track a stable per-field id (iceberg) so {@code ConnectorColumnConverter} can stamp the + * Doris child column tree's {@code uniqueId} for the BE field-id scan path. The other facets + * (children/fieldNames/nullability/comments) are preserved. + */ + public ConnectorType withChildrenFieldIds(List fieldIds) { + return new ConnectorType(typeName, precision, scale, children, fieldNames, + childrenNullable, childrenComments, fieldIds); + } + public String getTypeName() { return typeName; } @@ -116,6 +209,44 @@ 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 stable field id of the child at {@code index} (STRUCT field / ARRAY element / MAP key|value), or + * {@code -1} when none was carried for that index (legacy factories / connectors without field ids). + */ + public int getChildFieldId(int index) { + return index < childrenFieldIds.size() ? childrenFieldIds.get(index) : -1; + } + + /** + * Whether the child at {@code index} (STRUCT field / ARRAY element / MAP value) is nullable. Defaults to + * {@code true} when the nullability was not carried for that index (legacy factories / older connectors). + */ + public boolean isChildNullable(int index) { + return index >= childrenNullable.size() || childrenNullable.get(index); + } + + /** + * The comment of the child at {@code index}, or {@code null} when none was carried for that index. + */ + public String getChildComment(int index) { + return index < childrenComments.size() ? childrenComments.get(index) : null; + } + @Override public String toString() { if (precision < 0) { 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 new file mode 100644 index 00000000000000..865d2fcfed3268 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorViewDefinition.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 java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * 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 + * 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 + * fields). + */ +public final class ConnectorViewDefinition { + + private final String sql; + private final String dialect; + private final List columns; + + public ConnectorViewDefinition(String sql, String dialect, List columns) { + this.sql = Objects.requireNonNull(sql, "sql"); + this.dialect = Objects.requireNonNull(dialect, "dialect"); + this.columns = columns == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(columns)); + } + + /** The stored view SQL text. */ + public String getSql() { + return sql; + } + + /** The SQL dialect the {@link #getSql() text} is written in (e.g. {@code spark}, {@code trino}). */ + public String getDialect() { + return dialect; + } + + /** The view's column schema (an empty list when the connector did not carry columns). */ + public List getColumns() { + return columns; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ConnectorViewDefinition)) { + return false; + } + ConnectorViewDefinition that = (ConnectorViewDefinition) o; + return sql.equals(that.sql) + && dialect.equals(that.dialect) + && columns.equals(that.columns); + } + + @Override + public int hashCode() { + return Objects.hash(sql, dialect, columns); + } + + @Override + public String toString() { + return "ConnectorViewDefinition{dialect='" + dialect + "', columnCount=" + columns.size() + "}"; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorWriteOps.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorWriteOps.java index 8c20247867d3ee..fefacb7abaa8cd 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorWriteOps.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorWriteOps.java @@ -17,184 +17,111 @@ package org.apache.doris.connector.api; -import org.apache.doris.connector.api.handle.ConnectorDeleteHandle; -import org.apache.doris.connector.api.handle.ConnectorInsertHandle; -import org.apache.doris.connector.api.handle.ConnectorMergeHandle; import org.apache.doris.connector.api.handle.ConnectorTableHandle; -import org.apache.doris.connector.api.write.ConnectorWriteConfig; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.connector.api.handle.WriteOperation; -import java.util.Collection; import java.util.List; /** * Write (DML) operations that a connector may support. * - *

Follows a two-phase lifecycle for each write operation:

- *
    - *
  1. {@code begin*} — initialize the write, return an opaque handle
  2. - *
  3. {@code finish*} — commit using collected BE fragments; or {@code abort*} on failure
  4. - *
+ *

Every write goes through a single transaction model: the engine opens a + * {@link ConnectorTransaction} via {@link #beginTransaction(ConnectorSession)}, the + * connector's write plan attaches to it, BE feeds commit fragments back through + * {@link ConnectorTransaction#addCommitData}, and the engine finally calls + * {@code commit()} / {@code rollback()}. Connectors whose writes are auto-committed + * by BE return a degenerate no-op transaction.

* - *

All methods have default implementations that throw - * {@link DorisConnectorException}, so connectors only override what they support.

+ *

All methods have default implementations (throwing / read-only), so connectors + * only override what they support.

*/ public interface ConnectorWriteOps { - // ──────────────────── Capability Queries ──────────────────── - - /** Returns {@code true} if this connector supports INSERT operations. */ - default boolean supportsInsert() { - return false; - } - - /** Returns {@code true} if this connector supports DELETE operations. */ - default boolean supportsDelete() { - return false; - } - - /** Returns {@code true} if this connector supports MERGE (INSERT + DELETE) operations. */ - default boolean supportsMerge() { - return false; - } - - // ──────────────────── Write Configuration ──────────────────── - - /** - * Returns the write configuration for this table. - * - *

The engine uses the returned {@link ConnectorWriteConfig} to select the - * appropriate Thrift data sink type and pass properties to BE.

- * - * @param session current session - * @param handle the target table handle - * @param columns the columns being written (ordered to match INSERT column list) - * @return write configuration describing sink type, format, location, etc. - */ - default ConnectorWriteConfig getWriteConfig( - ConnectorSession session, - ConnectorTableHandle handle, - List columns) { - throw new DorisConnectorException("Write not supported"); - } - - // ──────────────────── INSERT ──────────────────── - - /** - * Begins an insert operation and returns an opaque handle. - * - * @param session current session - * @param handle the target table handle - * @param columns the columns being inserted (ordered to match INSERT column list) - * @return an opaque insert handle carrying connector-internal state - */ - default ConnectorInsertHandle beginInsert( - ConnectorSession session, - ConnectorTableHandle handle, - List columns) { - throw new DorisConnectorException("INSERT not supported"); - } - - /** - * Commits the insert operation using collected fragments from BE. - * - * @param session current session - * @param handle the insert handle from {@link #beginInsert} - * @param fragments serialized commit info collected from BE nodes - */ - default void finishInsert(ConnectorSession session, - ConnectorInsertHandle handle, - Collection fragments) { - throw new DorisConnectorException("INSERT not supported"); - } - - /** - * Aborts a previously started insert operation. - * Called on failure to clean up any partial writes. - * - * @param session current session - * @param handle the insert handle from {@link #beginInsert} - */ - default void abortInsert(ConnectorSession session, - ConnectorInsertHandle handle) { - // default: no-op — connector may not require explicit cleanup - } - - // ──────────────────── DELETE ──────────────────── - /** - * Begins a delete operation and returns an opaque handle. + * Validates that a row-level DML {@code op} ({@link WriteOperation#DELETE} / {@link WriteOperation#UPDATE} / + * {@link WriteOperation#MERGE}) is permitted on {@code handle} under the table's configured write mode, + * throwing a {@link DorisConnectorException} with a connector-authored message otherwise. Called at analysis + * time, before synthesizing the write plan, so the engine rejects an unsupported statement up front (fail + * loud) rather than producing a broken write. * - * @param session current session - * @param handle the target table handle - * @return an opaque delete handle + *

The default permits everything: connectors with no per-table mode constraint need not override. A + * connector whose tables can be configured in a mode that forbids row-level DML (e.g. iceberg + * copy-on-write) MUST override this and throw, so the rejection — and its message — stay in the connector + * rather than being drafted by the engine. {@code op} values other than DELETE/UPDATE/MERGE are not + * row-level DML and an overriding connector should treat them as a no-op.

*/ - default ConnectorDeleteHandle beginDelete( - ConnectorSession session, - ConnectorTableHandle handle) { - throw new DorisConnectorException("DELETE not supported"); + default void validateRowLevelDmlMode(ConnectorSession session, ConnectorTableHandle handle, WriteOperation op) { + // default: no per-table mode constraint } /** - * Commits the delete operation using collected fragments. + * Validates that every column named in a static-partition spec ({@code INSERT [OVERWRITE] ... PARTITION + * (col=val)}) is a legal static-partition target on {@code handle}, throwing a {@link DorisConnectorException} + * with a connector-authored message otherwise. Called at analysis time, before synthesizing the write plan, + * so the engine rejects an unknown / non-identity / unpartitioned static-partition column up front (fail + * loud) rather than letting it slip through to physical planning. * - * @param session current session - * @param handle the delete handle from {@link #beginDelete} - * @param fragments serialized commit info collected from BE nodes + *

The default accepts everything: connectors with no static-partition concept (e.g. name-mapped JDBC) + * need not override. A connector that supports {@code PARTITION(...)} writes and can reject a column (e.g. + * iceberg, where only identity partition fields may be targeted statically) MUST override this and throw, so + * the rejection — and its message — stay in the connector rather than being drafted by the engine. {@code + * staticPartitionColumnNames} is the set of column names from the PARTITION clause.

*/ - default void finishDelete(ConnectorSession session, - ConnectorDeleteHandle handle, - Collection fragments) { - throw new DorisConnectorException("DELETE not supported"); + default void validateStaticPartitionColumns(ConnectorSession session, ConnectorTableHandle handle, + List staticPartitionColumnNames) { + // default: no static-partition constraint } /** - * Aborts a previously started delete operation. + * Validates that the dynamic partition-NAME list form ({@code INSERT [OVERWRITE] ... PARTITION (p1, p2)} — a + * list of partition column NAMES with no values, distinct from the static {@code PARTITION(col=val)} spec) is + * permitted on {@code handle}, throwing a {@link DorisConnectorException} with a connector-authored message + * otherwise. Called at analysis time, before synthesizing the write plan, so the engine rejects an + * unsupported statement up front (fail loud). * - * @param session current session - * @param handle the delete handle from {@link #beginDelete} + *

The default accepts everything: connectors that ignore the name-list form need not override. A connector + * that must reject it (e.g. hive, where {@code INSERT ... PARTITION(p1, p2)} is unsupported) MUST override + * this and throw, so the rejection — and its message — stay in the connector rather than being drafted by the + * engine. {@code partitionNames} is the list of partition column names from the PARTITION clause (the engine + * calls this only when the list is non-empty).

*/ - default void abortDelete(ConnectorSession session, - ConnectorDeleteHandle handle) { - // default: no-op + default void validateWritePartitionNames(ConnectorSession session, ConnectorTableHandle handle, + List partitionNames) { + // default: no partition-name-list constraint } - // ──────────────────── MERGE (INSERT + DELETE) ──────────────────── - - /** - * Begins a merge (combined insert+delete) operation. - * Used by connectors that support merge-on-read (e.g., Iceberg). - * - * @param session current session - * @param handle the target table handle - * @return an opaque merge handle - */ - default ConnectorMergeHandle beginMerge( - ConnectorSession session, - ConnectorTableHandle handle) { - throw new DorisConnectorException("MERGE not supported"); - } + // ──────────────────── TRANSACTION ──────────────────── /** - * Commits the merge operation using collected fragments. + * Begins a new transaction scoped to a single SQL statement (auto-commit) or to an + * explicit BEGIN..COMMIT block. The engine binds the returned transaction onto the + * {@link ConnectorSession} and drives its {@code commit()} / {@code rollback()} after + * the write completes; BE feeds commit fragments back through + * {@link ConnectorTransaction#addCommitData}. * - * @param session current session - * @param handle the merge handle from {@link #beginMerge} - * @param fragments serialized commit info collected from BE nodes + *

Every write-capable connector must return a transaction here. Connectors whose + * writes are auto-committed by BE (e.g. jdbc) return a degenerate + * {@link org.apache.doris.connector.api.handle.NoOpConnectorTransaction}; the default + * throws (a connector that supports writes but does not override this is misconfigured + * — fail loud).

*/ - default void finishMerge(ConnectorSession session, - ConnectorMergeHandle handle, - Collection fragments) { - throw new DorisConnectorException("MERGE not supported"); + default ConnectorTransaction beginTransaction(ConnectorSession session) { + throw new DorisConnectorException("Transactions not supported"); } /** - * Aborts a previously started merge operation. + * Per-table view of {@link #beginTransaction(ConnectorSession)}: opens the transaction for the connector + * that owns {@code handle}. The default ignores {@code handle} and returns the connector-level + * {@link #beginTransaction(ConnectorSession)}, so every single-format connector is unaffected. * - * @param session current session - * @param handle the merge handle from {@link #beginMerge} + *

A heterogeneous gateway (one catalog serving multiple table formats) overrides this to route a foreign + * handle to its sibling connector's transaction, so the session-bound transaction's concrete type matches + * the per-handle-selected write plan provider. The no-arg version alone would bind the gateway's own + * transaction and the sibling's write plan would fail to downcast it. Mirrors the per-handle + * {@code getWritePlanProvider(handle)} seam.

*/ - default void abortMerge(ConnectorSession session, - ConnectorMergeHandle handle) { - // default: no-op + default ConnectorTransaction beginTransaction(ConnectorSession session, ConnectorTableHandle handle) { + return beginTransaction(session); } } diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/BranchChange.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/BranchChange.java new file mode 100644 index 00000000000000..67cafacdcd1c23 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/BranchChange.java @@ -0,0 +1,89 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.api.ddl; + +/** + * Neutral carrier for a {@code CREATE [OR REPLACE] BRANCH} request, decoupling the connector SPI from the + * fe-core/nereids {@code CreateOrReplaceBranchInfo}/{@code BranchOptions} types. + * + *

The retention fields are nullable ({@code null} = not specified in the SQL, so the connector leaves the + * corresponding setting untouched), mirroring the {@code Optional<>} fields of the source {@code BranchOptions}. + * They are named after the snapshot-management knobs they drive, so a connector applies them 1:1: + * {@code maxSnapshotAgeMs} (SQL {@code RETAIN}), {@code minSnapshotsToKeep} (SQL {@code WITH SNAPSHOT RETENTION + * n SNAPSHOTS}), {@code maxRefAgeMs} (SQL {@code WITH SNAPSHOT RETENTION ... MINUTES}). A {@code null} + * {@code snapshotId} means "use the table's current snapshot", resolved by the connector against the live table.

+ */ +public final class BranchChange { + + private final String name; + private final boolean create; + private final boolean replace; + private final boolean ifNotExists; + private final Long snapshotId; + private final Long maxSnapshotAgeMs; + private final Integer minSnapshotsToKeep; + private final Long maxRefAgeMs; + + public BranchChange(String name, boolean create, boolean replace, boolean ifNotExists, + Long snapshotId, Long maxSnapshotAgeMs, Integer minSnapshotsToKeep, Long maxRefAgeMs) { + this.name = name; + this.create = create; + this.replace = replace; + this.ifNotExists = ifNotExists; + this.snapshotId = snapshotId; + this.maxSnapshotAgeMs = maxSnapshotAgeMs; + this.minSnapshotsToKeep = minSnapshotsToKeep; + this.maxRefAgeMs = maxRefAgeMs; + } + + public String getName() { + return name; + } + + public boolean isCreate() { + return create; + } + + public boolean isReplace() { + return replace; + } + + public boolean isIfNotExists() { + return ifNotExists; + } + + /** The target snapshot id, or {@code null} to use the table's current snapshot. */ + public Long getSnapshotId() { + return snapshotId; + } + + /** SQL {@code RETAIN} in ms, or {@code null} when unset. */ + public Long getMaxSnapshotAgeMs() { + return maxSnapshotAgeMs; + } + + /** SQL {@code WITH SNAPSHOT RETENTION n SNAPSHOTS}, or {@code null} when unset. */ + public Integer getMinSnapshotsToKeep() { + return minSnapshotsToKeep; + } + + /** SQL {@code WITH SNAPSHOT RETENTION ... MINUTES} in ms, or {@code null} when unset. */ + public Long getMaxRefAgeMs() { + return maxRefAgeMs; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorBucketSpec.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorBucketSpec.java new file mode 100644 index 00000000000000..32c5381a279658 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorBucketSpec.java @@ -0,0 +1,87 @@ +// 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; + +/** + * Bucket / distribution specification carried by + * {@link ConnectorCreateTableRequest}. + * + *

{@code algorithm} is a connector-known string. Common values:

+ *
    + *
  • {@code "hive_hash"} — Hive-compatible 32-bit hash.
  • + *
  • {@code "iceberg_bucket"} — Iceberg bucket transform.
  • + *
  • {@code "doris_default"} — Doris CRC32 distribution.
  • + *
+ */ +public final class ConnectorBucketSpec { + + private final List columns; + private final int numBuckets; + private final String algorithm; + + public ConnectorBucketSpec(List columns, int numBuckets, + String algorithm) { + this.columns = columns == null + ? Collections.emptyList() + : Collections.unmodifiableList(columns); + this.numBuckets = numBuckets; + this.algorithm = Objects.requireNonNull(algorithm, "algorithm"); + } + + public List getColumns() { + return columns; + } + + public int getNumBuckets() { + return numBuckets; + } + + public String getAlgorithm() { + return algorithm; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ConnectorBucketSpec)) { + return false; + } + ConnectorBucketSpec that = (ConnectorBucketSpec) o; + return numBuckets == that.numBuckets + && columns.equals(that.columns) + && algorithm.equals(that.algorithm); + } + + @Override + public int hashCode() { + return Objects.hash(columns, numBuckets, algorithm); + } + + @Override + public String toString() { + return "ConnectorBucketSpec{algorithm=" + algorithm + + ", columns=" + columns + + ", numBuckets=" + numBuckets + "}"; + } +} 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 new file mode 100644 index 00000000000000..4ab5fafc536c6f --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorColumnPosition.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.ddl; + +import java.util.Objects; + +/** + * 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}. + * + *

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 + * compile-time dependency on fe-catalog/nereids types is forbidden by the iron law, so the SPI + * passes this DTO instead.

+ * + *

A {@code null} position means "no position clause" (append at the end / keep current position), + * matching the legacy {@code if (position != null)} guard.

+ */ +public final class ConnectorColumnPosition { + + /** Place the column first. Mirrors fe-catalog {@code ColumnPosition.FIRST}. */ + public static final ConnectorColumnPosition FIRST = new ConnectorColumnPosition(true, null); + + private final boolean first; + // The column name to place this column after; non-null iff !first. + private final String afterColumn; + + private ConnectorColumnPosition(boolean first, String afterColumn) { + this.first = first; + this.afterColumn = afterColumn; + } + + /** Place the column after the named column. Mirrors fe-catalog {@code new ColumnPosition(col)}. */ + public static ConnectorColumnPosition after(String afterColumn) { + return new ConnectorColumnPosition(false, Objects.requireNonNull(afterColumn, "afterColumn")); + } + + public boolean isFirst() { + return first; + } + + /** The column to place this column after; only meaningful when {@link #isFirst()} is false. */ + public String getAfterColumn() { + return afterColumn; + } + + @Override + public String toString() { + return first ? "FIRST" : "AFTER " + afterColumn; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ConnectorColumnPosition)) { + return false; + } + ConnectorColumnPosition that = (ConnectorColumnPosition) o; + return first == that.first && Objects.equals(afterColumn, that.afterColumn); + } + + @Override + public int hashCode() { + return Objects.hash(first, afterColumn); + } +} 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 new file mode 100644 index 00000000000000..78bd956db0158e --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorCreateTableRequest.java @@ -0,0 +1,202 @@ +// 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 org.apache.doris.connector.api.ConnectorColumn; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Full {@code CREATE TABLE} payload passed to + * {@code ConnectorTableOps.createTable(session, request)}. + * + *

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

+ * + *

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

+ */ +public final class ConnectorCreateTableRequest { + + private final String dbName; + private final String tableName; + private final List columns; + private final ConnectorPartitionSpec partitionSpec; + private final ConnectorBucketSpec bucketSpec; + private final List sortOrder; + 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"); + this.tableName = Objects.requireNonNull(b.tableName, "tableName"); + this.columns = b.columns == null + ? Collections.emptyList() + : Collections.unmodifiableList(b.columns); + this.partitionSpec = b.partitionSpec; + this.bucketSpec = b.bucketSpec; + this.sortOrder = b.sortOrder == null + ? Collections.emptyList() + : Collections.unmodifiableList(b.sortOrder); + this.comment = b.comment; + this.properties = b.properties == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(b.properties); + this.ifNotExists = b.ifNotExists; + this.external = b.external; + } + + public String getDbName() { + return dbName; + } + + public String getTableName() { + return tableName; + } + + public List getColumns() { + return columns; + } + + /** @return partition spec, or {@code null} for non-partitioned tables. */ + public ConnectorPartitionSpec getPartitionSpec() { + return partitionSpec; + } + + /** @return bucket spec, or {@code null} when no bucketing is declared. */ + public ConnectorBucketSpec getBucketSpec() { + return bucketSpec; + } + + /** + * @return the {@code ORDER BY (...)} write-order fields (never {@code null}; empty when the DDL + * omits an ORDER BY clause or the engine drops it). Iceberg builds a write sort order from + * these; engines without a write order ignore them. + */ + public List getSortOrder() { + return sortOrder; + } + + public String getComment() { + return comment; + } + + public Map getProperties() { + return properties; + } + + public boolean isIfNotExists() { + return ifNotExists; + } + + public boolean isExternal() { + return external; + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public String toString() { + return "ConnectorCreateTableRequest{" + dbName + "." + tableName + + ", cols=" + columns.size() + + ", partition=" + partitionSpec + + ", bucket=" + bucketSpec + + ", external=" + external + + ", ifNotExists=" + ifNotExists + "}"; + } + + public static final class Builder { + private String dbName; + private String tableName; + private List columns; + private ConnectorPartitionSpec partitionSpec; + private ConnectorBucketSpec bucketSpec; + private List sortOrder; + private String comment; + private Map properties; + private boolean ifNotExists; + private boolean external; + + public Builder dbName(String dbName) { + this.dbName = dbName; + return this; + } + + public Builder tableName(String tableName) { + this.tableName = tableName; + return this; + } + + public Builder columns(List columns) { + this.columns = columns; + return this; + } + + public Builder partitionSpec(ConnectorPartitionSpec partitionSpec) { + this.partitionSpec = partitionSpec; + return this; + } + + public Builder bucketSpec(ConnectorBucketSpec bucketSpec) { + this.bucketSpec = bucketSpec; + return this; + } + + public Builder sortOrder(List sortOrder) { + this.sortOrder = sortOrder; + return this; + } + + public Builder comment(String comment) { + this.comment = comment; + return this; + } + + public Builder properties(Map properties) { + // copy to preserve caller's map identity and keep insertion order + this.properties = properties == null + ? null + : new LinkedHashMap<>(properties); + return this; + } + + public Builder ifNotExists(boolean ifNotExists) { + this.ifNotExists = 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/ConnectorPartitionField.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorPartitionField.java new file mode 100644 index 00000000000000..ce16c29973440a --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorPartitionField.java @@ -0,0 +1,87 @@ +// 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; + +/** + * A single field in a {@link ConnectorPartitionSpec}. + * + *

The {@code transform} string follows Appendix B of the connector SPI RFC: + * {@code identity / year / month / day / hour / bucket / truncate / list / range}. + * Unlisted values are treated as {@code CUSTOM} and interpreted by the connector.

+ * + *

{@code transformArgs} carries numeric parameters (e.g., {@code [16]} for + * {@code bucket(16, col)} or {@code [10]} for {@code truncate(10, col)}).

+ */ +public final class ConnectorPartitionField { + + private final String columnName; + private final String transform; + private final List transformArgs; + + public ConnectorPartitionField(String columnName, String transform, + List transformArgs) { + this.columnName = Objects.requireNonNull(columnName, "columnName"); + this.transform = Objects.requireNonNull(transform, "transform"); + this.transformArgs = transformArgs == null + ? Collections.emptyList() + : Collections.unmodifiableList(transformArgs); + } + + public String getColumnName() { + return columnName; + } + + public String getTransform() { + return transform; + } + + public List getTransformArgs() { + return transformArgs; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ConnectorPartitionField)) { + return false; + } + ConnectorPartitionField that = (ConnectorPartitionField) o; + return columnName.equals(that.columnName) + && transform.equals(that.transform) + && transformArgs.equals(that.transformArgs); + } + + @Override + public int hashCode() { + return Objects.hash(columnName, transform, transformArgs); + } + + @Override + public String toString() { + if (transformArgs.isEmpty()) { + return transform + "(" + columnName + ")"; + } + return transform + transformArgs + "(" + columnName + ")"; + } +} 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 new file mode 100644 index 00000000000000..8397fe271d3f84 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorPartitionSpec.java @@ -0,0 +1,122 @@ +// 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; + +/** + * Partition specification carried by {@link ConnectorCreateTableRequest}. + * + *

{@link Style} distinguishes the four supported partition flavors:

+ *
    + *
  • {@code IDENTITY} — Hive style: {@code PARTITIONED BY (col1, col2)}.
  • + *
  • {@code TRANSFORM} — Iceberg style: {@code PARTITIONED BY (bucket(16, c), year(d))}.
  • + *
  • {@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 { + + public enum Style { + IDENTITY, + TRANSFORM, + LIST, + RANGE, + } + + 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, + 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; + } + + public Style getStyle() { + return style; + } + + 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. + */ + public boolean hasExplicitPartitionValues() { + return hasExplicitPartitionValues; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ConnectorPartitionSpec)) { + return false; + } + ConnectorPartitionSpec that = (ConnectorPartitionSpec) o; + return style == that.style + && hasExplicitPartitionValues == that.hasExplicitPartitionValues + && fields.equals(that.fields) + && initialValues.equals(that.initialValues); + } + + @Override + public int hashCode() { + return Objects.hash(style, fields, initialValues, 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 new file mode 100644 index 00000000000000..e86acaa242b4fb --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorPartitionValueDef.java @@ -0,0 +1,77 @@ +// 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/ddl/ConnectorSortField.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorSortField.java new file mode 100644 index 00000000000000..ad756ed4d69a07 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorSortField.java @@ -0,0 +1,78 @@ +// 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.Objects; + +/** + * One field of a {@code CREATE TABLE ... ORDER BY (...)} write-order clause, carried neutrally + * across the SPI in {@link ConnectorCreateTableRequest#getSortOrder()}. + * + *

Mirrors the fe-core {@code SortFieldInfo} (column name + ASC/DESC + NULLS FIRST/LAST) without + * the connector taking a compile-time dependency on fe-core. Connectors that do not support a + * write order (e.g. paimon) simply ignore the list.

+ */ +public final class ConnectorSortField { + + private final String columnName; + private final boolean ascending; + private final boolean nullFirst; + + public ConnectorSortField(String columnName, boolean ascending, boolean nullFirst) { + this.columnName = Objects.requireNonNull(columnName, "columnName"); + this.ascending = ascending; + this.nullFirst = nullFirst; + } + + public String getColumnName() { + return columnName; + } + + public boolean isAscending() { + return ascending; + } + + public boolean isNullFirst() { + return nullFirst; + } + + @Override + public String toString() { + return columnName + (ascending ? " ASC" : " DESC") + + (nullFirst ? " NULLS FIRST" : " NULLS LAST"); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ConnectorSortField)) { + return false; + } + ConnectorSortField that = (ConnectorSortField) o; + return ascending == that.ascending + && nullFirst == that.nullFirst + && columnName.equals(that.columnName); + } + + @Override + public int hashCode() { + return Objects.hash(columnName, ascending, nullFirst); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/DropRefChange.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/DropRefChange.java new file mode 100644 index 00000000000000..259aaccf725e66 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/DropRefChange.java @@ -0,0 +1,42 @@ +// 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; + +/** + * Neutral carrier for a {@code DROP BRANCH}/{@code DROP TAG} request, decoupling the connector SPI from the + * fe-core/nereids {@code DropBranchInfo}/{@code DropTagInfo} types. {@code ifExists} makes the drop a no-op when + * the named ref is absent. + */ +public final class DropRefChange { + + private final String name; + private final boolean ifExists; + + public DropRefChange(String name, boolean ifExists) { + this.name = name; + this.ifExists = ifExists; + } + + public String getName() { + return name; + } + + public boolean isIfExists() { + return ifExists; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/PartitionFieldChange.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/PartitionFieldChange.java new file mode 100644 index 00000000000000..89dbcc72ddbfce --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/PartitionFieldChange.java @@ -0,0 +1,106 @@ +// 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; + +/** + * Neutral carrier for a partition-field evolution request ({@code ALTER TABLE ... ADD/DROP/REPLACE PARTITION + * KEY}), decoupling the connector SPI from the fe-core/nereids {@code AddPartitionFieldOp}/ + * {@code DropPartitionFieldOp}/{@code ReplacePartitionFieldOp} types. + * + *

A "partition field" is a column reference run through an optional transform. The four leading fields describe + * ONE such field; how the connector reads them depends on the SPI method it is passed to:

+ *
    + *
  • {@code addPartitionField} — the field to ADD: {@code transformName}/{@code transformArg}/ + * {@code columnName} build the transform, {@code partitionFieldName} is the optional {@code AS} alias.
  • + *
  • {@code dropPartitionField} — the field to REMOVE: when {@code partitionFieldName} is set it names the + * field directly; otherwise the transform triple identifies it.
  • + *
  • {@code replacePartitionField} — the NEW field (same reading as add); the {@code old*} fields below + * identify the OLD field to remove first (by {@code oldPartitionFieldName}, else by old transform triple).
  • + *
+ * + *

A transform is identity when {@code transformName} is {@code null} (a bare column ref); {@code transformArg} + * carries the width for {@code bucket(n)}/{@code truncate(n)} and is {@code null} otherwise. The {@code old*} + * fields are only populated for {@code replacePartitionField}; they are {@code null} for add/drop.

+ */ +public final class PartitionFieldChange { + + // ---- The field to add / drop, or the NEW field of a replace ---- + private final String transformName; + private final Integer transformArg; + private final String columnName; + private final String partitionFieldName; + + // ---- The OLD field of a replace (null for add / drop) ---- + private final String oldPartitionFieldName; + private final String oldTransformName; + private final Integer oldTransformArg; + private final String oldColumnName; + + public PartitionFieldChange(String transformName, Integer transformArg, String columnName, + String partitionFieldName, String oldPartitionFieldName, String oldTransformName, + Integer oldTransformArg, String oldColumnName) { + this.transformName = transformName; + this.transformArg = transformArg; + this.columnName = columnName; + this.partitionFieldName = partitionFieldName; + this.oldPartitionFieldName = oldPartitionFieldName; + this.oldTransformName = oldTransformName; + this.oldTransformArg = oldTransformArg; + this.oldColumnName = oldColumnName; + } + + /** Transform name (e.g. {@code bucket}/{@code truncate}/{@code year}); {@code null} = identity. */ + public String getTransformName() { + return transformName; + } + + /** Width for {@code bucket(n)}/{@code truncate(n)}; {@code null} otherwise. */ + public Integer getTransformArg() { + return transformArg; + } + + /** Source column the transform is applied to. */ + public String getColumnName() { + return columnName; + } + + /** Optional partition-field name: the {@code AS} alias (add/replace) or the field to remove (drop). */ + public String getPartitionFieldName() { + return partitionFieldName; + } + + /** Replace only: the existing partition field to remove by name; {@code null} = remove by old transform. */ + public String getOldPartitionFieldName() { + return oldPartitionFieldName; + } + + /** Replace only: old transform name; {@code null} = identity. */ + public String getOldTransformName() { + return oldTransformName; + } + + /** Replace only: old transform width. */ + public Integer getOldTransformArg() { + return oldTransformArg; + } + + /** Replace only: old source column. */ + public String getOldColumnName() { + return oldColumnName; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/TagChange.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/TagChange.java new file mode 100644 index 00000000000000..d53ecf3016aed6 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/TagChange.java @@ -0,0 +1,72 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.api.ddl; + +/** + * Neutral carrier for a {@code CREATE [OR REPLACE] TAG} request, decoupling the connector SPI from the + * fe-core/nereids {@code CreateOrReplaceTagInfo}/{@code TagOptions} types. + * + *

A {@code null} {@code snapshotId} means "use the table's current snapshot" (resolved by the connector + * against the live table); a tag, unlike a branch, requires a snapshot, so the connector fails loud when the + * current snapshot is also absent. {@code maxRefAgeMs} (SQL {@code RETAIN}) is {@code null} when unset.

+ */ +public final class TagChange { + + private final String name; + private final boolean create; + private final boolean replace; + private final boolean ifNotExists; + private final Long snapshotId; + private final Long maxRefAgeMs; + + public TagChange(String name, boolean create, boolean replace, boolean ifNotExists, + Long snapshotId, Long maxRefAgeMs) { + this.name = name; + this.create = create; + this.replace = replace; + this.ifNotExists = ifNotExists; + this.snapshotId = snapshotId; + this.maxRefAgeMs = maxRefAgeMs; + } + + public String getName() { + return name; + } + + public boolean isCreate() { + return create; + } + + public boolean isReplace() { + return replace; + } + + public boolean isIfNotExists() { + return ifNotExists; + } + + /** The target snapshot id, or {@code null} to use the table's current snapshot. */ + public Long getSnapshotId() { + return snapshotId; + } + + /** SQL {@code RETAIN} in ms, or {@code null} when unset. */ + public Long getMaxRefAgeMs() { + return maxRefAgeMs; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/ConnectorEventSource.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/ConnectorEventSource.java new file mode 100644 index 00000000000000..1955ef681ed11e --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/ConnectorEventSource.java @@ -0,0 +1,58 @@ +// 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.event; + +/** + * A connector's incremental-metadata-change source: it fetches the underlying metastore's native + * notification events and parses them into connector-neutral {@link MetastoreChangeDescriptor}s. + * + *

Surfaced via {@link org.apache.doris.connector.api.Connector#getEventSource()} (a + * capability-probe getter, NOT an {@code instanceof} — a connector without an event source returns + * {@code null}). The engine runs one connector-agnostic, role-aware background driver that iterates + * catalogs, calls {@link #pollOnce} on those whose connector exposes a source, and applies the + * returned descriptors to its own object graph and caches.

+ * + *

Engine/connector split (Trino-aligned: engine owns HA/replication, plugin owns fetch/parse). + * The connector owns ONLY the metastore RPC and the message deserialization/merge behind {@link + * #getCurrentEventId()} and {@link #pollOnce}. The engine owns everything stateful and replicated: the + * per-catalog cursor (passed in via {@link EventPollRequest}, stored from {@link EventPollResult}), + * the master/follower role, the edit-log write of the synced cursor, and the follower→master + * {@code REFRESH CATALOG} forward. The connector is stateless with respect to the cursor.

+ * + *

Classloader. The engine calls these methods under a context-classloader pin to this + * source's own plugin classloader, so the notification RPC and the JSON/GZIP deserialization inside + * resolve the plugin's bundled metastore classes. Implementations therefore need no pin of their own + * for the fetch/parse path.

+ */ +public interface ConnectorEventSource { + + /** + * The metastore's current (latest) notification event id. Used by the master to cheaply decide + * whether there is anything new to pull before calling {@link #pollOnce}. Returns {@code -1} if the + * id cannot be read this cycle. + */ + long getCurrentEventId(); + + /** + * Fetch and parse one batch of notification events for a catalog, returning connector-neutral + * descriptors plus the new cursor and an optional full-refresh signal. Never returns source-native + * types. See {@link EventPollRequest} / {@link EventPollResult} for the role/cursor/refresh + * semantics. The batch size is the connector's own concern (read from its catalog properties). + */ + EventPollResult pollOnce(EventPollRequest request); +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/EventPollRequest.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/EventPollRequest.java new file mode 100644 index 00000000000000..4662c308b22e51 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/EventPollRequest.java @@ -0,0 +1,66 @@ +// 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.event; + +/** + * The engine-supplied input to one {@link ConnectorEventSource#pollOnce} call. The engine owns the + * cursor and the role; the connector owns the fetch strategy those imply. + * + *
    + *
  • {@link #getLastSyncedEventId()} — the last event id this FE has already applied for the + * catalog (the engine's per-catalog cursor; {@code -1} means "never synced" → the connector + * should signal a full refresh rather than replay from 0).
  • + *
  • {@link #isMaster()} — whether this FE is the master. The master fetches the metastore's + * current event id and pulls new events directly; a follower pulls only up to + * {@link #getMasterUpperBound()} (what the master has already committed and replicated), so it + * never reads past the master's high-water mark.
  • + *
  • {@link #getMasterUpperBound()} — the master's committed event id, learned by the follower via + * edit-log replay; ignored on the master. {@code -1} means "not yet learned" → a follower + * does nothing this cycle.
  • + *
+ */ +public final class EventPollRequest { + + private final long lastSyncedEventId; + private final boolean isMaster; + private final long masterUpperBound; + + public EventPollRequest(long lastSyncedEventId, boolean isMaster, long masterUpperBound) { + this.lastSyncedEventId = lastSyncedEventId; + this.isMaster = isMaster; + this.masterUpperBound = masterUpperBound; + } + + public long getLastSyncedEventId() { + return lastSyncedEventId; + } + + public boolean isMaster() { + return isMaster; + } + + public long getMasterUpperBound() { + return masterUpperBound; + } + + @Override + public String toString() { + return "EventPollRequest{lastSyncedEventId=" + lastSyncedEventId + ", isMaster=" + isMaster + + ", masterUpperBound=" + masterUpperBound + '}'; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/EventPollResult.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/EventPollResult.java new file mode 100644 index 00000000000000..9673ddc3fe44bc --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/EventPollResult.java @@ -0,0 +1,89 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.api.event; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * The connector's answer to one {@link ConnectorEventSource#pollOnce} call. + * + *
    + *
  • {@link #getNewCursor()} — the event id the engine should store as its new per-catalog cursor. + * This is NOT always {@code lastSyncedEventId + descriptors.size()}: on a first sync or an + * events-gap recovery the connector advances the cursor to the metastore's current id WITHOUT + * emitting events (paired with {@link #isNeedsFullRefresh()}); on an empty poll it is the + * unchanged cursor.
  • + *
  • {@link #getDescriptors()} — the already-merged, connector-neutral changes to apply, in order. + * Empty when nothing changed or when a full refresh is requested instead.
  • + *
  • {@link #isNeedsFullRefresh()} — the connector could not produce an incremental delta (first + * sync, or the metastore reported its notification log was trimmed past the cursor). The engine + * responds per role: the master invalidates the whole catalog; a follower forwards + * {@code REFRESH CATALOG} to the master. The connector owns detecting the source-specific gap + * condition; the engine owns the HA response.
  • + *
+ */ +public final class EventPollResult { + + private final long newCursor; + private final List descriptors; + private final boolean needsFullRefresh; + + public EventPollResult(long newCursor, List descriptors, + boolean needsFullRefresh) { + this.newCursor = newCursor; + this.descriptors = descriptors == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(descriptors)); + this.needsFullRefresh = needsFullRefresh; + } + + /** A result carrying incremental changes; the cursor advances to {@code newCursor}. */ + public static EventPollResult ofChanges(long newCursor, List descriptors) { + return new EventPollResult(newCursor, descriptors, false); + } + + /** A result requesting a full catalog refresh and seeding the cursor to {@code newCursor}. */ + public static EventPollResult ofFullRefresh(long newCursor) { + return new EventPollResult(newCursor, Collections.emptyList(), true); + } + + /** A no-op result: nothing changed, keep the cursor at {@code cursor}. */ + public static EventPollResult ofNothing(long cursor) { + return new EventPollResult(cursor, Collections.emptyList(), false); + } + + public long getNewCursor() { + return newCursor; + } + + public List getDescriptors() { + return descriptors; + } + + public boolean isNeedsFullRefresh() { + return needsFullRefresh; + } + + @Override + public String toString() { + return "EventPollResult{newCursor=" + newCursor + ", descriptors=" + descriptors.size() + + ", needsFullRefresh=" + needsFullRefresh + '}'; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/MetastoreChangeDescriptor.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/MetastoreChangeDescriptor.java new file mode 100644 index 00000000000000..a491c1fe58cd46 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/MetastoreChangeDescriptor.java @@ -0,0 +1,182 @@ +// 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.event; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * A single connector-neutral description of one metadata change the engine must apply after a + * metastore poll. It is the vocabulary {@link ConnectorEventSource#pollOnce} returns: the plugin + * fetches and parses the source's native notification events (Hive {@code NotificationEvent} + + * JSON/GZIP messages, iceberg/hudi-on-HMS included) and maps each to one of these, so the engine can + * update its catalog→db→table object graph and caches WITHOUT ever seeing a source-native type. + * + *

Type-neutrality is load-bearing. Every field is a primitive, a {@code String}, or a + * {@code List} — no Hive/thrift class ever crosses this boundary. The engine calls the plugin + * under a classloader pin; if a plugin-loaded object leaked into a field, a field access on the engine + * side would fail across the loader split. Keep it neutral.

+ * + *

Engine/connector split. The connector owns fetch+parse and emits these descriptors + * (already merged/deduplicated). The engine owns the structural application — register/unregister/ + * rename in {@code CatalogMgr}, cache invalidation via {@code Connector.invalidateTable/Db/Partition}, + * the master's edit-log write, and follower/master role handling. Trino-aligned: the plugin surfaces + * what changed; the engine decides how its own metadata reacts.

+ * + *

Partition granularity is by canonical partition NAME ({@code "col=val/.../colN=valN"}), never by + * raw column values — the caches are name-keyed, so carrying names avoids the values→whole-table + * degrade.

+ */ +public final class MetastoreChangeDescriptor { + + /** + * The kind of change. Maps 1:1 to what the engine does when applying the descriptor. A source + * event that changes nothing the engine tracks (e.g. a properties-only ALTER DATABASE, or an + * unsupported event type) produces NO descriptor rather than a no-op {@code Op}. + */ + public enum Op { + /** A new database exists; register it into the catalog. */ + REGISTER_DATABASE, + /** A database was dropped; unregister it from the catalog. */ + UNREGISTER_DATABASE, + /** A database was renamed; {@link #getDbName()} → {@link #getDbNameAfter()}. */ + RENAME_DATABASE, + /** A new table exists; register it into its database. */ + REGISTER_TABLE, + /** A table was dropped; unregister it from its database. */ + UNREGISTER_TABLE, + /** + * A table was renamed OR a view was recreated; {@link #getTableName()} → + * {@link #getTableNameAfter()} (the after-name equals the before-name for a view recreate, + * which rebuilds the table object so a changed view definition takes effect). + */ + RENAME_TABLE, + /** A table changed in place (insert / plain alter); drop its caches, keep it registered. */ + REFRESH_TABLE, + /** Partitions were added; invalidate the table's partition caches so a re-list picks them up. */ + ADD_PARTITIONS, + /** Partitions were dropped; invalidate the table's partition caches. */ + DROP_PARTITIONS, + /** Partitions changed in place; invalidate the named partitions' caches. */ + REFRESH_PARTITIONS + } + + private final Op op; + private final String dbName; + private final String tableName; + private final String dbNameAfter; + private final String tableNameAfter; + private final List partitionNames; + private final long updateTime; + private final long eventId; + + private MetastoreChangeDescriptor(Op op, String dbName, String tableName, String dbNameAfter, + String tableNameAfter, List partitionNames, long updateTime, long eventId) { + this.op = Objects.requireNonNull(op, "op"); + this.dbName = dbName; + this.tableName = tableName; + this.dbNameAfter = dbNameAfter; + this.tableNameAfter = tableNameAfter; + this.partitionNames = partitionNames == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(partitionNames)); + this.updateTime = updateTime; + this.eventId = eventId; + } + + /** A database-level change ({@code REGISTER_/UNREGISTER_/RENAME_DATABASE}). */ + public static MetastoreChangeDescriptor forDatabase(Op op, String dbName, String dbNameAfter, + long eventId, long updateTime) { + return new MetastoreChangeDescriptor(op, dbName, null, dbNameAfter, null, null, updateTime, eventId); + } + + /** A table-level change ({@code REGISTER_/UNREGISTER_/RENAME_TABLE}, {@code REFRESH_TABLE}). */ + public static MetastoreChangeDescriptor forTable(Op op, String dbName, String tableName, + String tableNameAfter, long eventId, long updateTime) { + return new MetastoreChangeDescriptor(op, dbName, tableName, null, tableNameAfter, null, + updateTime, eventId); + } + + /** + * A {@link Op#RENAME_TABLE} change (also used for view-recreate, where the after-db/table equal the + * before-db/table). Carries both the before ({@code dbName}/{@code tableName}) and after + * ({@code dbNameAfter}/{@code tableNameAfter}) identities, since a Hive table rename may move the + * table across databases. + */ + public static MetastoreChangeDescriptor forTableRename(String dbName, String tableName, + String dbNameAfter, String tableNameAfter, long eventId, long updateTime) { + return new MetastoreChangeDescriptor(Op.RENAME_TABLE, dbName, tableName, dbNameAfter, + tableNameAfter, null, updateTime, eventId); + } + + /** A partition-level change ({@code ADD_/DROP_/REFRESH_PARTITIONS}). */ + public static MetastoreChangeDescriptor forPartitions(Op op, String dbName, String tableName, + List partitionNames, long eventId, long updateTime) { + return new MetastoreChangeDescriptor(op, dbName, tableName, null, null, partitionNames, + updateTime, eventId); + } + + public Op getOp() { + return op; + } + + /** The remote database name (as the connector sees it). */ + public String getDbName() { + return dbName; + } + + /** The remote table name, or {@code null} for database-level ops. */ + public String getTableName() { + return tableName; + } + + /** The new database name for {@link Op#RENAME_DATABASE}, else {@code null}. */ + public String getDbNameAfter() { + return dbNameAfter; + } + + /** The new table name for {@link Op#RENAME_TABLE} (may equal {@link #getTableName()}), else {@code null}. */ + public String getTableNameAfter() { + return tableNameAfter; + } + + /** Canonical partition names for partition ops (empty for non-partition ops). */ + public List getPartitionNames() { + return partitionNames; + } + + /** The source update time in epoch millis, used as the object's freshness signal. */ + public long getUpdateTime() { + return updateTime; + } + + /** The source notification event id this descriptor was derived from. */ + public long getEventId() { + return eventId; + } + + @Override + public String toString() { + return "MetastoreChangeDescriptor{op=" + op + ", db=" + dbName + ", table=" + tableName + + ", dbAfter=" + dbNameAfter + ", tableAfter=" + tableNameAfter + + ", partitions=" + partitionNames + ", updateTime=" + updateTime + + ", eventId=" + eventId + '}'; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorDeleteHandle.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorDeleteHandle.java deleted file mode 100644 index 27e059dc7cd4b8..00000000000000 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorDeleteHandle.java +++ /dev/null @@ -1,29 +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 delete handle returned by - * {@link org.apache.doris.connector.api.ConnectorWriteOps#beginDelete}. - * - *

Connector implementations carry internal state (e.g., Iceberg - * delete snapshot, position delete file context) in their concrete - * implementation of this interface.

- */ -public interface ConnectorDeleteHandle { -} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorInsertHandle.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorInsertHandle.java deleted file mode 100644 index 3b831a94c8a5eb..00000000000000 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorInsertHandle.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 insert handle returned by {@code beginInsert}. - */ -public interface ConnectorInsertHandle { -} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorMergeHandle.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorMergeHandle.java deleted file mode 100644 index 4eae994e8e914a..00000000000000 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorMergeHandle.java +++ /dev/null @@ -1,29 +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 merge handle returned by - * {@link org.apache.doris.connector.api.ConnectorWriteOps#beginMerge}. - * - *

Connector implementations carry merge-on-read state (e.g., - * Iceberg merge context, combined insert+delete handling) in their - * concrete implementation of this interface.

- */ -public interface ConnectorMergeHandle { -} 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 new file mode 100644 index 00000000000000..9c58d282ea09c0 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorTransaction.java @@ -0,0 +1,157 @@ +// 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 org.apache.doris.connector.api.pushdown.ConnectorPredicate; + +import java.io.Closeable; +import java.util.Set; + +/** + * A connector-managed transaction that scopes one or more write operations. + * + *

Lifecycle: the engine calls {@link #commit()} on success or + * {@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 { + + /** Stable transaction ID assigned by the connector. */ + long getTransactionId(); + + /** + * Commits all pending operations bound to this transaction. + * + * @throws org.apache.doris.connector.api.DorisConnectorException + * on conflict, IO failure, or external system error + */ + void commit(); + + /** + * Aborts all pending operations and releases resources. + * Safe to call multiple times; subsequent calls are no-ops. + */ + void rollback(); + + /** Called by the engine after commit OR rollback to release connections etc. */ + @Override + void close(); + + /** + * Receives one serialized commit fragment produced by BE after writing a + * data fragment. The connector deserializes its own Thrift payload (e.g. + * {@code TMCCommitData} / {@code THivePartitionUpdate} / {@code TIcebergCommitData}) + * and accumulates it for {@link #commit()}. + * + *

Default is a no-op for read-only / non-writing connectors.

+ * + * @param commitFragment the serialized connector-specific commit payload + */ + default void addCommitData(byte[] commitFragment) { + // no-op: connectors that participate in writes override this + } + + /** + * Whether this transaction allocates write block ranges through a write-time + * BE→FE callback. Only connectors with a stateful write session that + * hands out block ids (e.g. maxcompute) return {@code true}. + */ + default boolean supportsWriteBlockAllocation() { + return false; + } + + /** + * Allocates a contiguous range of write block ids for the given write + * session, returning the first allocated id. Called from the BE→FE RPC + * path during a write. + * + *

Only invoked when {@link #supportsWriteBlockAllocation()} returns + * {@code true}; the default throws.

+ * + * @param writeSessionId opaque connector-defined write session identifier + * @param count number of block ids to allocate + * @return the first allocated block id + */ + default long allocateWriteBlockRange(String writeSessionId, long count) { + throw new UnsupportedOperationException("write block allocation not supported"); + } + + /** Returns the number of rows affected by the write(s) bound to this transaction. */ + default long getUpdateCnt() { + return 0; + } + + /** + * 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 + * 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}. + * + *

A connector that does optimistic conflict detection converts the neutral predicate to its own + * dialect and uses it as the conflict-detection filter when the transaction commits (ANDed with any + * commit-time partition filter it derives itself). Connectors that do not do conflict detection — or + * that traffic in opaque handles — ignore it. The default is a no-op.

+ * + * @param targetOnlyFilter the neutral target-only predicate, or {@code null} when the plan yielded none + */ + default void applyWriteConstraint(ConnectorPredicate targetOnlyFilter) { + // no-op: connectors that do optimistic conflict detection override this + } + + /** + * A short, connector-identifying label for the query profile (cosmetic), e.g. + * {@code "JDBC"} / {@code "MAXCOMPUTE"}. The insert executor maps this label to a + * profile transaction type. Replaces the executor's former hard-coded connector + * switch; the default is a generic external label. + */ + default String profileLabel() { + return "EXTERNAL"; + } + + /** + * Compaction rewrite ({@code rewrite_data_files}): registers the original data files this transaction + * will atomically replace, by their RAW file paths. The engine rewrite driver hands the connector the + * neutral {@code String} paths from its bin-packed groups (fe-core cannot traffic in connector-native + * file objects across the wall); the connector resolves them back to its own file objects — e.g. by + * re-deriving from the table at the transaction's pinned snapshot — and removes them at {@link #commit()}. + * + *

Default throws: only rewrite-capable connectors override it.

+ * + * @param dataFilePaths the raw paths of the source data files to replace + */ + default void registerRewriteSourceFiles(Set dataFilePaths) { + throw new UnsupportedOperationException("rewrite source file registration not supported"); + } + + /** + * Compaction rewrite: the number of new compacted data files this transaction added, available only + * AFTER {@link #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. + * + *

Default throws: only rewrite-capable connectors override it.

+ */ + default int getRewriteAddedDataFilesCount() { + throw new UnsupportedOperationException("rewrite statistics not supported"); + } +} 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 new file mode 100644 index 00000000000000..7af148e8d6b881 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorWriteHandle.java @@ -0,0 +1,91 @@ +// 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 org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.thrift.TSortInfo; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * A bound write request passed to + * {@link org.apache.doris.connector.api.write.ConnectorWritePlanProvider#planWrite}. + * + *

Carries the engine-resolved facts about a single DML write: the target + * table handle, the column list, whether it is an OVERWRITE, and a free-form + * write context (static partition spec, write path, etc.). The connector reads + * these to build its Thrift data sink.

+ */ +public interface ConnectorWriteHandle { + + /** The target table handle (the connector's own opaque table handle). */ + ConnectorTableHandle getTableHandle(); + + /** The columns being written, ordered to match the INSERT column list. */ + List getColumns(); + + /** Whether this is an INSERT OVERWRITE. */ + boolean isOverwrite(); + + /** + * Free-form write context: static partition spec, write path, and other + * connector-defined keys carried from the bound sink to {@code planWrite}. + */ + Map getWriteContext(); + + /** + * The kind of DML write (INSERT / OVERWRITE / DELETE / UPDATE / MERGE). A single + * {@code planWrite} dispatches on this to pick the connector's Thrift sink dialect, and a + * file-transactional connector (iceberg) dispatches on it to pick the SDK operation. + * + *

Defaults to {@link WriteOperation#INSERT} so connectors that only do plain appends + * (jdbc / maxcompute) — which never set it — keep append semantics and stay byte-compatible.

+ */ + default WriteOperation getWriteOperation() { + return WriteOperation.INSERT; + } + + /** + * The engine-built BE sort instruction for this write, or {@code null} if the target needs no + * write-side sort. A connector declares its write-sort columns via + * {@link org.apache.doris.connector.api.write.ConnectorWritePlanProvider#getWriteSortColumns} + * (e.g. an iceberg table with a {@code WRITE ORDERED BY} sort order); the engine resolves those + * column indices against the bound sink output and builds the {@link TSortInfo}, which the + * connector then stamps onto its opaque Thrift sink in {@code planWrite}. + * + *

The split is necessary because the bound output expressions live only in the engine + * (translation time), not in this source-agnostic handle. Defaults to {@code null} so connectors + * that declare no write sort (jdbc / maxcompute) keep their byte-identical unsorted sink output.

+ */ + default TSortInfo getSortInfo() { + return null; + } + + /** + * The named table branch this write targets ({@code INSERT INTO t@branch(name)}), or + * {@link Optional#empty()} when the write goes to the table's default ref. Threaded from the + * generic insert command context onto this handle; a versioned-table connector (iceberg / paimon) + * reads it in {@code planWrite} to point the commit at the branch. Defaults to empty so connectors + * with no branch concept (jdbc / maxcompute) keep their byte-identical default-ref write. + */ + default Optional getBranchName() { + return Optional.empty(); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/NoOpConnectorTransaction.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/NoOpConnectorTransaction.java new file mode 100644 index 00000000000000..bda31e03f4e554 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/NoOpConnectorTransaction.java @@ -0,0 +1,76 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.api.handle; + +/** + * A degenerate {@link ConnectorTransaction} for connectors whose writes are + * auto-committed by BE (e.g. jdbc, where each row is written through a + * {@code PreparedStatement}) and therefore need no FE-side transaction + * coordination. {@link #commit()} / {@link #rollback()} are no-ops; the engine + * still routes every write through {@code beginTransaction} so the write + * lifecycle is uniform across connectors (no per-connector transaction fork). + * + *

{@link #getUpdateCnt()} returns {@code -1} — "no row count is produced by + * this transaction" — which signals the insert executor to keep the + * coordinator's row counter (DPP_NORMAL_ALL) for affected-rows rather than + * overwrite it with {@code 0}. {@code -1} is deliberately distinct from a + * genuine zero-row write ({@code 0}).

+ */ +public class NoOpConnectorTransaction implements ConnectorTransaction { + + private final long transactionId; + private final String profileLabel; + + public NoOpConnectorTransaction(long transactionId, String profileLabel) { + this.transactionId = transactionId; + this.profileLabel = profileLabel; + } + + @Override + public long getTransactionId() { + return transactionId; + } + + @Override + public void commit() { + // no-op: the write is already durably committed by BE (auto-commit sink) + } + + @Override + public void rollback() { + // no-op: there is nothing to undo on the FE side + } + + @Override + public void close() { + // no-op: no resources are held + } + + @Override + public long getUpdateCnt() { + // -1 = "no row count from this transaction; use the coordinator counter". + // Distinct from 0 (a genuine zero-row write) so the executor does not + // clobber loadedRows that BE already reported via DPP_NORMAL_ALL. + return -1; + } + + @Override + public String profileLabel() { + return profileLabel; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/WriteOperation.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/WriteOperation.java new file mode 100644 index 00000000000000..d1ef64770e23ed --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/WriteOperation.java @@ -0,0 +1,49 @@ +// 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; + +/** + * The kind of DML write a {@link ConnectorWriteHandle} carries. + * + *

This is the operation axis (what the statement does), distinct from the sink + * mechanism that the removed {@code ConnectorWriteType} used to encode. A single + * {@code planWrite} reads it to pick the connector's Thrift sink dialect (e.g. iceberg's + * {@code TIcebergTableSink} vs {@code TIcebergDeleteSink} vs {@code TIcebergMergeSink}), and the + * iceberg transaction reads it to pick the SDK operation (AppendFiles / ReplacePartitions / + * OverwriteFiles / RowDelta / RewriteFiles). The default on {@link ConnectorWriteHandle#getWriteOperation()} + * is {@link #INSERT}, so connectors that only do plain appends (jdbc / maxcompute) need not declare it.

+ */ +public enum WriteOperation { + /** Plain INSERT (append rows). */ + INSERT, + /** INSERT OVERWRITE (truncate-and-insert, whole-table / dynamic / static partition). */ + OVERWRITE, + /** DELETE rows matching a predicate. */ + DELETE, + /** UPDATE rows (delete + re-insert under merge-on-read). */ + UPDATE, + /** MERGE INTO (matched/not-matched clauses; insert + delete). */ + MERGE, + /** + * Compaction rewrite ({@code ALTER TABLE ... EXECUTE rewrite_data_files}): atomically replace a set of + * existing data files with newly written, larger ones. The iceberg transaction maps it to the SDK + * {@code RewriteFiles} op (delete-old + add-new) guarded by {@code validateFromSnapshot} (OCC). Driven by + * the rewrite execution half, which holds one transaction across N per-group INSERT-SELECT writes. + */ + REWRITE +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccPartition.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccPartition.java new file mode 100644 index 00000000000000..102a906a0cfc6e --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccPartition.java @@ -0,0 +1,114 @@ +// 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.mvcc; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * One final (post-merge) Doris partition in a {@link ConnectorMvccPartitionView}. + * + *

For a {@link ConnectorMvccPartitionView.Style#RANGE} view, {@link #getLowerBound()} / + * {@link #getUpperBound()} are the connector's pre-rendered partition-key value tuples for the + * closed-open {@code [lower, upper)} range (one string per partition column), and the generic model + * assembles them into a {@code RangePartitionItem} using the table's partition column types — so no + * data-source-specific range math leaks into fe-core. {@link #getFreshnessValue()} is the per-partition + * marker (a snapshot id or epoch-millis timestamp, per the view's + * {@link ConnectorMvccPartitionView#getFreshness()}) the generic model wraps into the matching + * {@code MTMVSnapshotIf}.

+ * + *

NULL-min sentinel: an empty {@link #getUpperBound()} (with a non-empty + * {@link #getLowerBound()}) denotes the genuine-NULL / minimum partition. The exclusive upper bound is NOT + * pre-rendered because it is the column-type/scale-aware successor of the lower key, which only the + * generic model can compute (it owns the Doris {@code Column}/{@code PartitionKey}). The model MUST derive the + * upper as {@code lowerKey.successor()} in that case — matching the connector's source behavior (e.g. iceberg's + * {@code nullLowKey.successor()}). A non-NULL RANGE partition always carries BOTH bounds non-empty; the model + * must not call {@code createPartitionKey} on an empty upper tuple.

+ */ +public final class ConnectorMvccPartition { + + private final String name; + private final List lowerBound; + private final List upperBound; + private final long freshnessValue; + + public ConnectorMvccPartition(String name, List lowerBound, List upperBound, + long freshnessValue) { + this.name = Objects.requireNonNull(name, "name"); + this.lowerBound = lowerBound == null + ? Collections.emptyList() + : Collections.unmodifiableList(lowerBound); + this.upperBound = upperBound == null + ? Collections.emptyList() + : Collections.unmodifiableList(upperBound); + this.freshnessValue = freshnessValue; + } + + /** The final Doris partition name (the enclosing partition's name after any overlap merge). */ + public String getName() { + return name; + } + + /** Pre-rendered closed lower-bound value tuple (one entry per partition column). */ + public List getLowerBound() { + return lowerBound; + } + + /** + * Pre-rendered open upper-bound value tuple (one entry per partition column), OR empty for the + * NULL-min partition — in which case the generic model derives the exclusive upper as + * {@code lowerKey.successor()} (see the class javadoc). + */ + public List getUpperBound() { + return upperBound; + } + + /** The per-partition freshness marker (snapshot id or epoch millis, per the view's freshness kind). */ + public long getFreshnessValue() { + return freshnessValue; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ConnectorMvccPartition)) { + return false; + } + ConnectorMvccPartition that = (ConnectorMvccPartition) o; + return freshnessValue == that.freshnessValue + && name.equals(that.name) + && lowerBound.equals(that.lowerBound) + && upperBound.equals(that.upperBound); + } + + @Override + public int hashCode() { + return Objects.hash(name, lowerBound, upperBound, freshnessValue); + } + + @Override + public String toString() { + return "ConnectorMvccPartition{name='" + name + + "', lower=" + lowerBound + + ", upper=" + upperBound + + ", freshness=" + freshnessValue + "}"; + } +} 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 new file mode 100644 index 00000000000000..7b7e799fb5ca2f --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccPartitionView.java @@ -0,0 +1,144 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.api.mvcc; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * A connector-supplied, range-aware partition view for the MTMV / partition-aware materialized-view + * 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 + * 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 + * pre-SPI behavior. Such a connector returns this view from + * {@link org.apache.doris.connector.api.ConnectorMetadata#getMvccPartitionView}; the generic model then + * assembles {@code RangePartitionItem}s from the pre-rendered bounds and selects the snapshot type from + * {@link #getFreshness()} — all connector-specific math (transform-to-range, partition-evolution overlap + * merge, snapshot-id resolution) having already happened inside the connector.

+ * + *

The view also carries a {@link #getNewestUpdateTimeMillis() newest-update-time} marker: a monotonically + * non-decreasing table-change timestamp the generic model answers the dictionary auto-refresh probe with + * (the snapshot id alone is unusable there because it need not be monotonic).

+ * + *

A connector that does NOT override {@code getMvccPartitionView} leaves the generic model on its + * default {@code listPartitions} / LIST / timestamp path (byte-unchanged), so this view is purely + * additive.

+ */ +public final class ConnectorMvccPartitionView { + + /** How the generic model should model this table's partitions. */ + public enum Style { + /** The table is not a valid partitioned related table (e.g. the connector's eligibility gate + * failed); the generic model treats it as unpartitioned. */ + UNPARTITIONED, + /** Range partitions: each {@link ConnectorMvccPartition} carries pre-rendered [lower, upper) + * bounds the generic model turns into a {@code RangePartitionItem}. */ + RANGE + } + + /** Which per-partition value the generic model compares to decide MTMV staleness. */ + public enum Freshness { + /** {@code getFreshnessValue()} is a snapshot id; the model wraps it in {@code MTMVSnapshotIdSnapshot}. */ + SNAPSHOT_ID, + /** {@code getFreshnessValue()} is an epoch-millis timestamp; wrapped in {@code MTMVTimestampSnapshot}. */ + LAST_MODIFIED + } + + private final Style style; + private final Freshness freshness; + private final List partitions; + private final long newestUpdateTimeMillis; + + public ConnectorMvccPartitionView(Style style, Freshness freshness, + List partitions, long newestUpdateTimeMillis) { + this.style = Objects.requireNonNull(style, "style"); + this.freshness = Objects.requireNonNull(freshness, "freshness"); + this.partitions = partitions == null + ? Collections.emptyList() + : Collections.unmodifiableList(partitions); + this.newestUpdateTimeMillis = newestUpdateTimeMillis; + } + + /** Returns an {@code UNPARTITIONED} view (no partitions, newest-update-time {@code 0}); the freshness + * marker is irrelevant. */ + public static ConnectorMvccPartitionView unpartitioned() { + return new ConnectorMvccPartitionView(Style.UNPARTITIONED, Freshness.SNAPSHOT_ID, + Collections.emptyList(), 0L); + } + + public Style getStyle() { + return style; + } + + public Freshness getFreshness() { + return freshness; + } + + public List getPartitions() { + return partitions; + } + + /** + * The table's newest data-update marker, used by the dictionary auto-refresh path + * ({@code MTMVRelatedTableIf.getNewestUpdateVersionOrTime}). This is a MONOTONICALLY non-decreasing + * change marker (NOT the snapshot id, which need not be monotonic), so the generic model can answer + * the dictionary's "is the source newer?" probe without crashing on a smaller-than-previous value. + * + *

Unit is source-defined, NOT guaranteed epoch millis — only its monotonicity is relied upon, never + * its absolute scale. For iceberg this is the {@code last_updated_at} value from the PARTITIONS metadata table, + * which iceberg represents in MICROSECONDS; the generic model passes it through verbatim (parity with master, + * which also compares this raw value without conversion). Do NOT treat it as millis or convert it. + * {@code 0} when the table has no partitions / is unpartitioned (treated as "unchanged").

+ */ + public long getNewestUpdateTimeMillis() { + return newestUpdateTimeMillis; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ConnectorMvccPartitionView)) { + return false; + } + ConnectorMvccPartitionView that = (ConnectorMvccPartitionView) o; + return style == that.style + && freshness == that.freshness + && newestUpdateTimeMillis == that.newestUpdateTimeMillis + && partitions.equals(that.partitions); + } + + @Override + public int hashCode() { + return Objects.hash(style, freshness, partitions, newestUpdateTimeMillis); + } + + @Override + public String toString() { + return "ConnectorMvccPartitionView{style=" + style + + ", freshness=" + freshness + + ", partitions=" + partitions.size() + + ", newestUpdateTimeMillis=" + newestUpdateTimeMillis + "}"; + } +} 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 new file mode 100644 index 00000000000000..d89bc7cc976367 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccSnapshot.java @@ -0,0 +1,150 @@ +// 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.mvcc; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Immutable description of a point-in-time snapshot taken from an MVCC-capable + * external table (Iceberg, Paimon, Hudi, ...). + * + *

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.

+ */ +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() + : Collections.unmodifiableMap(new HashMap<>(b.properties)); + this.lastModifiedFreshness = b.lastModifiedFreshness; + } + + /** Connector-assigned snapshot identifier (e.g. Iceberg snapshot id). */ + 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. + */ + public long getSchemaId() { + return schemaId; + } + + /** Connector-specific metadata propagated to BE. Unmodifiable, never null. */ + public Map getProperties() { + return properties; + } + + /** + * Whether this table's MTMV freshness is a last-modified TIMESTAMP rather than a snapshot id. When + * {@code true} the generic model ({@code PluginDrivenMvccExternalTable}) serves the table/partition MTMV + * snapshots from {@code ConnectorMetadata.getTableFreshness} / {@code getPartitionFreshnessMillis} (fetched + * on the MTMV refresh path only); when {@code false} (the default, e.g. paimon/iceberg — a snapshot-id + * connector) it keeps the snapshot-id / pin-timestamp freshness with NO extra metadata call. The flag rides + * on the query-begin pin so fe-core reads it off the pin it already holds — a snapshot-id connector pays + * zero extra round-trips. + */ + public boolean isLastModifiedFreshness() { + return lastModifiedFreshness; + } + + public static Builder builder() { + return new 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; + + public Builder snapshotId(long snapshotId) { + this.snapshotId = snapshotId; + return this; + } + + /** Marks this table's MTMV freshness as last-modified (see {@link #isLastModifiedFreshness()}). */ + public Builder lastModifiedFreshness(boolean lastModifiedFreshness) { + this.lastModifiedFreshness = 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"), + Objects.requireNonNull(value, "value")); + return this; + } + + public Builder properties(Map properties) { + this.properties.putAll(Objects.requireNonNull(properties, "properties")); + return this; + } + + public ConnectorMvccSnapshot build() { + return new ConnectorMvccSnapshot(this); + } + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorTableFreshness.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorTableFreshness.java new file mode 100644 index 00000000000000..b924e403927a95 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorTableFreshness.java @@ -0,0 +1,86 @@ +// 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.mvcc; + +import java.util.Objects; + +/** + * A connector-supplied, whole-table MTMV freshness marker for a connector whose table-level change + * signal is a last-modified TIMESTAMP rather than a snapshot id. + * + *

Returned by {@link org.apache.doris.connector.api.ConnectorMetadata#getTableFreshness}. The + * generic table model ({@code PluginDrivenMvccExternalTable}) wraps it into an + * {@code MTMVMaxTimestampSnapshot(name, timestampMillis)} — byte-parity with legacy hive, whose table + * snapshot is {@code MTMVMaxTimestampSnapshot}. A connector that returns {@link java.util.Optional#empty()} + * (the default, e.g. paimon/iceberg) keeps the snapshot-id table snapshot unchanged.

+ * + *

Semantics (mirror legacy {@code HiveDlaTable.getTableSnapshot}):

+ *
    + *
  • partitioned table ⇒ {@code name} = the partition name carrying the newest modify time, + * {@code timestampMillis} = that max modify time (0 when there are no partitions, with + * {@code name} = the table name);
  • + *
  • unpartitioned table ⇒ {@code name} = the table name, {@code timestampMillis} = the table's + * last-DDL time (0 when absent).
  • + *
+ * + *

The connector computes the millis (the {@code MTMVMaxTimestampSnapshot} carries BOTH the name and + * the timestamp so that dropping the partition that owns the max time is detected as a change); fe-core + * never parses the underlying source properties.

+ */ +public final class ConnectorTableFreshness { + + private final String name; + private final long timestampMillis; + + public ConnectorTableFreshness(String name, long timestampMillis) { + this.name = Objects.requireNonNull(name, "name"); + this.timestampMillis = timestampMillis; + } + + /** The partition name owning the newest modify time (partitioned) or the table name (unpartitioned). */ + public String getName() { + return name; + } + + /** The newest modify time in epoch millis (max partition modify time, or the table last-DDL time). */ + public long getTimestampMillis() { + return timestampMillis; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ConnectorTableFreshness)) { + return false; + } + ConnectorTableFreshness that = (ConnectorTableFreshness) o; + return timestampMillis == that.timestampMillis && name.equals(that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name, timestampMillis); + } + + @Override + public String toString() { + return "ConnectorTableFreshness{name='" + name + "', timestampMillis=" + timestampMillis + "}"; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorTimeTravelSpec.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorTimeTravelSpec.java new file mode 100644 index 00000000000000..7759f5898f7a5f --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorTimeTravelSpec.java @@ -0,0 +1,220 @@ +// 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.mvcc; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Immutable, source-agnostic description of an explicit time-travel request that + * fe-core extracts from the SQL and hands to the connector to resolve into a + * pinned {@link ConnectorMvccSnapshot}. + * + *

fe-core performs only source-agnostic dispatch/extraction (deciding the + * {@link Kind}, splitting out the raw string / params, and the digital flag for + * timestamps); the connector owns ALL provider-specific parsing (e.g. paimon + * snapshot-id lookup, datetime parsing, tag/branch resolution, incremental + * window validation).

+ * + *

Each {@link Kind} maps to a piece of Doris time-travel syntax:

+ *
    + *
  • {@link Kind#SNAPSHOT_ID} — {@code FOR VERSION AS OF } (numeric): + * {@code stringValue} holds the snapshot-id digits.
  • + *
  • {@link Kind#VERSION_REF} — {@code FOR VERSION AS OF ''} (non-numeric): + * {@code stringValue} holds a ref name the connector resolves per its own + * semantics (e.g. iceberg: a branch OR a tag; paimon: a tag).
  • + *
  • {@link Kind#TIMESTAMP} — {@code FOR TIME AS OF }: + * {@code stringValue} holds either an epoch-millis literal (when + * {@code digital} is {@code true}) or a datetime string to be parsed by + * the connector (when {@code digital} is {@code false}).
  • + *
  • {@link Kind#TAG} — {@code @tag('name')} scan param: + * {@code stringValue} holds the tag name.
  • + *
  • {@link Kind#BRANCH} — {@code @branch('name')} scan param: + * {@code stringValue} holds the branch name.
  • + *
  • {@link Kind#INCREMENTAL} — {@code @incr(...)} scan param: + * {@code stringValue} is {@code null} and {@code incrementalParams} + * carries the raw key/value window arguments.
  • + *
+ */ +public final class ConnectorTimeTravelSpec { + + /** Which flavor of explicit time-travel this spec describes. */ + public enum Kind { + /** {@code FOR VERSION AS OF } (numeric value): a snapshot id. */ + SNAPSHOT_ID, + /** + * {@code FOR VERSION AS OF ''} (non-numeric value): a named ref the connector + * resolves per its own semantics. fe-core does NOT pre-decide whether the name is a + * tag or a branch — that is source-specific (iceberg accepts a branch OR a tag; paimon + * resolves it as a tag). Distinct from {@link #TAG}, which is the explicit + * {@code @tag('name')} scan param (tag-only). + */ + VERSION_REF, + /** {@code FOR TIME AS OF }. */ + TIMESTAMP, + /** {@code @tag('name')}. */ + TAG, + /** {@code @branch('name')}. */ + BRANCH, + /** {@code @incr(...)}. */ + INCREMENTAL + } + + private final Kind kind; + private final String stringValue; + private final boolean digital; + private final Map incrementalParams; + + private ConnectorTimeTravelSpec(Kind kind, String stringValue, boolean digital, + Map incrementalParams) { + this.kind = kind; + this.stringValue = stringValue; + this.digital = digital; + this.incrementalParams = (incrementalParams == null || incrementalParams.isEmpty()) + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(incrementalParams)); + } + + /** + * {@code FOR VERSION AS OF }: pin by snapshot id. + * + * @param idDigits the snapshot-id digits (connector parses to a number) + */ + public static ConnectorTimeTravelSpec snapshotId(String idDigits) { + Objects.requireNonNull(idDigits, "idDigits"); + return new ConnectorTimeTravelSpec(Kind.SNAPSHOT_ID, idDigits, false, null); + } + + /** + * {@code FOR VERSION AS OF ''} (non-numeric): pin to a named ref. The connector + * resolves {@code name} per its own semantics (iceberg: a branch OR a tag; paimon: a tag). + * fe-core does not pre-decide tag-vs-branch — that distinguishes this from {@link #tag(String)} + * (the explicit {@code @tag('name')}, which is tag-only). + * + * @param name the ref name (branch or tag, source-resolved) + */ + public static ConnectorTimeTravelSpec versionRef(String name) { + Objects.requireNonNull(name, "name"); + return new ConnectorTimeTravelSpec(Kind.VERSION_REF, name, false, null); + } + + /** + * {@code FOR TIME AS OF }: pin by wall-clock time. + * + * @param value epoch-millis literal when {@code digital} is true, otherwise a + * datetime string the connector parses with the session time zone + * @param digital whether {@code value} is already epoch-millis + */ + public static ConnectorTimeTravelSpec timestamp(String value, boolean digital) { + Objects.requireNonNull(value, "value"); + return new ConnectorTimeTravelSpec(Kind.TIMESTAMP, value, digital, null); + } + + /** + * {@code @tag('name')}: pin to a named tag. + * + * @param name the tag name + */ + public static ConnectorTimeTravelSpec tag(String name) { + Objects.requireNonNull(name, "name"); + return new ConnectorTimeTravelSpec(Kind.TAG, name, false, null); + } + + /** + * {@code @branch('name')}: pin to a named branch. + * + * @param name the branch name + */ + public static ConnectorTimeTravelSpec branch(String name) { + Objects.requireNonNull(name, "name"); + return new ConnectorTimeTravelSpec(Kind.BRANCH, name, false, null); + } + + /** + * {@code @incr(...)}: incremental read over a window described by + * {@code rawParams}. The connector validates and interprets the window keys. + * + * @param rawParams the raw key/value window arguments (defensively copied) + */ + public static ConnectorTimeTravelSpec incremental(Map rawParams) { + Objects.requireNonNull(rawParams, "rawParams"); + return new ConnectorTimeTravelSpec(Kind.INCREMENTAL, null, false, rawParams); + } + + /** The flavor of this spec; never null. */ + public Kind getKind() { + return kind; + } + + /** + * The snapshot-id digits / timestamp expression / tag name / branch name, + * depending on {@link #getKind()}. {@code null} for {@link Kind#INCREMENTAL}. + */ + public String getStringValue() { + return stringValue; + } + + /** + * Only meaningful for {@link Kind#TIMESTAMP}: {@code true} means + * {@link #getStringValue()} is already epoch-millis, {@code false} means it is + * a datetime string the connector must parse. Always {@code false} otherwise. + */ + public boolean isDigital() { + return digital; + } + + /** + * The raw incremental window arguments; non-empty only for + * {@link Kind#INCREMENTAL}, an unmodifiable empty map otherwise. Never null. + */ + public Map getIncrementalParams() { + return incrementalParams; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ConnectorTimeTravelSpec)) { + return false; + } + ConnectorTimeTravelSpec that = (ConnectorTimeTravelSpec) o; + return digital == that.digital + && kind == that.kind + && Objects.equals(stringValue, that.stringValue) + && incrementalParams.equals(that.incrementalParams); + } + + @Override + public int hashCode() { + return Objects.hash(kind, stringValue, digital, incrementalParams); + } + + @Override + public String toString() { + return "ConnectorTimeTravelSpec{" + + "kind=" + kind + + ", stringValue=" + stringValue + + ", digital=" + digital + + ", incrementalParams=" + incrementalParams + + '}'; + } +} 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 new file mode 100644 index 00000000000000..77d044e68bb506 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorProcedureOps.java @@ -0,0 +1,124 @@ +// 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; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; + +import java.util.List; +import java.util.Map; + +/** + * Executes a connector's table procedures for {@code ALTER TABLE t EXECUTE (args)}. + * + *

This is the procedure-side analogue of + * {@link org.apache.doris.connector.api.scan.ConnectorScanPlanProvider} and + * {@link org.apache.doris.connector.api.write.ConnectorWritePlanProvider}: a connector that exposes + * table procedures returns an implementation from + * {@link org.apache.doris.connector.api.Connector#getProcedureOps()}; the engine routes + * {@code ExecuteActionCommand} to {@link #execute} and wraps the returned rows into a result set.

+ * + *

Engine/connector split. The engine owns the command shell — the {@code ALTER} privilege + * check, the {@code ResultSet} wrapping, and the edit-log refresh broadcast. The connector owns the + * procedure body — argument validation, the underlying maintenance call, and the result schema/rows. + * This mirrors Trino's {@code TableProcedureMetadata} model (procedure body in the connector, + * orchestration in the engine), not the {@code CALL}/{@code MethodHandle} model.

+ * + *

Maps to Doris's flat {@code ALTER TABLE EXECUTE} actions (one procedure per name); there is no + * connector-level {@code CALL} procedure surface.

+ */ +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. + */ + List getSupportedProcedures(); + + /** + * Returns how the engine must drive {@code procedureName}: {@link ProcedureExecutionMode#SINGLE_CALL} + * (the default — a synchronous body dispatched through {@link #execute}) or + * {@link ProcedureExecutionMode#DISTRIBUTED} (an engine-orchestrated distributed rewrite that does NOT + * go through {@link #execute}). This lets the engine route a distributed procedure (iceberg + * {@code rewrite_data_files}) without hard-coding its name in a general engine class. The default covers + * every pure-SDK procedure; a connector overrides it only for its distributed procedures. + * + * @param procedureName the procedure name (case-insensitive at the connector's discretion) + */ + default ProcedureExecutionMode getExecutionMode(String procedureName) { + return ProcedureExecutionMode.SINGLE_CALL; + } + + /** + * 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. + * + *

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 + * procedure never has this called (the engine checks {@code getExecutionMode} first and routes + * {@code SINGLE_CALL} procedures through {@link #execute}).

+ * + * @param session the current session + * @param table the target table handle + * @param properties the procedure arguments (validated by the connector against the procedure's spec) + * @param whereCondition the engine-lowered {@code WHERE} predicate restricting which files to rewrite, or + * {@code null} + * @param partitionNames the {@code PARTITION (...)} names (pass-through), never {@code null} + * @return the bin-packed rewrite groups (empty when there is nothing to rewrite) + */ + default List planRewrite( + ConnectorSession session, + ConnectorTableHandle table, + String procedureName, + Map properties, + ConnectorPredicate whereCondition, + List partitionNames) { + throw new UnsupportedOperationException( + "planRewrite 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}. + * + *

The connector validates the {@code properties} against the procedure's own argument schema (the + * engine does not know per-procedure argument shapes), performs the maintenance call, and returns the + * result. Each procedure emits exactly one row today (see {@link ConnectorProcedureResult}).

+ * + * @param session the current session + * @param table the target table handle + * @param procedureName the procedure name (e.g. {@code rollback_to_snapshot}) + * @param properties the procedure arguments as key/value pairs + * @param whereCondition the engine-lowered {@code WHERE} predicate (only data-rewriting procedures + * accept one; {@code null} when absent), or {@code null} + * @param partitionNames the {@code PARTITION (...)} names (pass-through; all current procedures reject + * a non-empty list), never {@code null} + * @return the procedure result (column schema + rows) + */ + ConnectorProcedureResult execute( + ConnectorSession session, + ConnectorTableHandle table, + String procedureName, + Map properties, + ConnectorPredicate whereCondition, + List partitionNames); +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorProcedureResult.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorProcedureResult.java new file mode 100644 index 00000000000000..f292e625600ad2 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorProcedureResult.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.procedure; + +import org.apache.doris.connector.api.ConnectorColumn; + +import java.util.List; +import java.util.Objects; + +/** + * The engine-neutral result of a table procedure: the result-column schema plus the row values. + * + *

Returned by {@link ConnectorProcedureOps#execute}. The engine maps each {@link ConnectorColumn} + * to a {@code Column} (via the shared {@code ConnectorColumnConverter}) to build the result-set + * metadata, then sends {@link #getRows()} unchanged. Decoupling the column metadata from the row + * values keeps the SPI free of any engine result-set type.

+ * + *

Every current procedure emits exactly one row whose size equals {@link #getResultSchema()} size + * (legacy {@code BaseExecuteAction} enforces a single row of matching width). {@code List>} + * preserves that contract without locking the SPI to single-row results.

+ */ +public final class ConnectorProcedureResult { + + private final List resultSchema; + private final List> rows; + + public ConnectorProcedureResult(List resultSchema, List> rows) { + this.resultSchema = Objects.requireNonNull(resultSchema, "resultSchema"); + this.rows = Objects.requireNonNull(rows, "rows"); + } + + /** The result-column schema (name + type per column). */ + public List getResultSchema() { + return resultSchema; + } + + /** The result rows; each row's size equals {@link #getResultSchema()} size. */ + public List> getRows() { + return rows; + } +} 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 new file mode 100644 index 00000000000000..cf9b94723d54e8 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorRewriteGroup.java @@ -0,0 +1,89 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.api.procedure; + +import java.util.Collections; +import java.util.Objects; +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. + * + *

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.

+ */ +public class ConnectorRewriteGroup { + + private final Set dataFilePaths; + private final int dataFileCount; + private final long totalSizeBytes; + private final int deleteFileCount; + + /** + * @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}) + */ + public ConnectorRewriteGroup(Set dataFilePaths, int dataFileCount, long totalSizeBytes, + int deleteFileCount) { + this.dataFilePaths = Collections.unmodifiableSet( + Objects.requireNonNull(dataFilePaths, "dataFilePaths is null")); + this.dataFileCount = dataFileCount; + this.totalSizeBytes = totalSizeBytes; + this.deleteFileCount = deleteFileCount; + } + + /** The raw data-file paths in this group, used to scope the per-group rewrite scan. */ + public Set getDataFilePaths() { + return dataFilePaths; + } + + /** The number of data files this group rewrites ({@code rewritten_data_files_count} contribution). */ + public int getDataFileCount() { + return dataFileCount; + } + + /** The total byte size of this group's data files ({@code rewritten_bytes_count} contribution). */ + public long getTotalSizeBytes() { + return totalSizeBytes; + } + + /** The number of delete files attached to this group ({@code removed_delete_files_count} contribution). */ + public int getDeleteFileCount() { + return deleteFileCount; + } + + @Override + public String toString() { + return "ConnectorRewriteGroup{dataFileCount=" + dataFileCount + + ", totalSizeBytes=" + totalSizeBytes + + ", deleteFileCount=" + deleteFileCount + + ", dataFilePaths=" + dataFilePaths.size() + " files}"; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ProcedureExecutionMode.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ProcedureExecutionMode.java new file mode 100644 index 00000000000000..b7f8242e76a097 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ProcedureExecutionMode.java @@ -0,0 +1,46 @@ +// 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; + +/** + * How the engine must drive a connector table procedure ({@code ALTER TABLE t EXECUTE proc(...)}). + * + *

This is the procedure-routing analogue of Trino's {@code TableProcedureExecutionMode} + * ({@code coordinatorOnly()} vs {@code distributedWithFilteringAndRepartitioning()}). The engine reads + * {@link ConnectorProcedureOps#getExecutionMode(String)} to decide whether a procedure can run as a single + * synchronous in-FE call or needs distributed orchestration — WITHOUT hard-coding a procedure name in a + * general engine class.

+ */ +public enum ProcedureExecutionMode { + + /** + * Coordinator-local: the procedure body is a single synchronous call that the engine dispatches through + * {@link ConnectorProcedureOps#execute} and wraps into one result set. This is the default for every + * procedure (e.g. the eight pure-SDK iceberg snapshot/maintenance procedures). + */ + SINGLE_CALL, + + /** + * Distributed: the procedure rewrites data and must be orchestrated by the engine as a distributed + * read-write job (e.g. iceberg {@code rewrite_data_files}: N per-group INSERT-SELECT writes under one + * shared connector transaction). The connector contributes only planning and commit through narrow SPI; + * the distributed execution loop stays in the engine. Such procedures are NOT routed through + * {@link ConnectorProcedureOps#execute} (whose single-result contract cannot express them). + */ + DISTRIBUTED +} 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 new file mode 100644 index 00000000000000..6a7da18a6b6815 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorPredicate.java @@ -0,0 +1,48 @@ +// 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.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 + * by scan pushdown — so the connector can convert it to its own predicate dialect without depending on + * fe-core / nereids types. + * + *

The engine builds it from the analyzed DML plan by keeping only the conjuncts over the target + * table's own columns (slot origin-table == target, excluding synthetic {@code $row_id} / metadata / + * join columns) and passes it via + * {@link org.apache.doris.connector.api.handle.ConnectorTransaction#applyWriteConstraint(ConnectorPredicate)}. + * It carries no plan view — only the neutral expression — so it does not breach the connector import gate.

+ */ +public final class ConnectorPredicate implements Serializable { + + private static final long serialVersionUID = 1L; + + private final ConnectorExpression expression; + + public ConnectorPredicate(ConnectorExpression expression) { + this.expression = expression; + } + + /** The engine-neutral boolean expression over the target table's own columns. May be {@code null}. */ + public ConnectorExpression getExpression() { + return expression; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorColumnCategory.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorColumnCategory.java new file mode 100644 index 00000000000000..6d75373acbb137 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorColumnCategory.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; + +/** + * Engine-neutral category for a connector's SPECIAL (non-data-file) columns, returned by + * {@link ConnectorScanPlanProvider#classifyColumn(String)}. + * + *

This lets a connector tell the generic scan node how to classify the synthetic / generated + * columns it owns (e.g. iceberg's hidden row-id and v3 row-lineage columns) WITHOUT the generic node + * importing any connector-specific code. The generic node maps these to its internal BE column + * categories; {@link #DEFAULT} means "not a connector special column" — the node falls through to its + * own partition-key / regular classification.

+ */ +public enum ConnectorColumnCategory { + + /** Not a connector special column: let the generic node classify it (partition key / regular). */ + DEFAULT, + + /** Synthesized column: never present in the data file, materialized by the connector reader. */ + SYNTHESIZED, + + /** Generated column: read from the file when present, otherwise backfilled by the connector reader. */ + GENERATED +} 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 fdb483f25cb9ba..a0cb2c4e4fcf25 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 @@ -21,12 +21,15 @@ 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.apache.doris.thrift.TFileScanRangeParams; +import org.apache.doris.thrift.TTableFormatFileDesc; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.OptionalLong; /** * Plans the set of scan ranges (splits) needed to read a connector table. @@ -50,6 +53,79 @@ 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. + * + *

A connector whose {@link #planScan} re-plans through its own SDK from the pushed predicate and + * does NOT consume {@code requiredPartitions} (e.g. paimon) must return {@code true}. The engine then + * maps a GENUINE prune-to-zero (FE pruning emptied the partition set over a non-empty universe) to + * scan-all instead of short-circuiting to zero rows. This is required for master parity once a + * genuine-null partition is rendered as a NON-null sentinel ({@code isNull=false}): {@code col IS NULL} + * prunes every partition away, yet the genuine-null rows must still be returned via the pushed + * predicate (the legacy {@code PaimonScanNode} never consults the FE partition selection).

+ * + *

Default {@code false}: connectors that genuinely restrict the read to the pruned partitions + * (e.g. MaxCompute, whose read session spans only {@code requiredPartitions}) keep the short-circuit.

+ * + * @return {@code true} to disable the prune-to-zero short-circuit for this connector + */ + default boolean ignorePartitionPruneShortCircuit() { + return false; + } + + /** + * Whether this connector's SYSTEM tables honor a pinned read — {@code FOR TIME/VERSION AS OF} + * (snapshot) and {@code @branch}/{@code @tag} scan-params. Consulted by the generic + * {@code PluginDrivenScanNode} sys-table guard: a connector returning {@code true} (e.g. iceberg, + * whose metadata tables legally time-travel) lets those pinned sys reads through to {@link #planScan}; + * a connector returning {@code false} (the default — e.g. paimon, whose binlog/audit_log sys tables + * have no point-in-time semantics) keeps the fail-loud rejection. {@code @incr} (incremental read) is + * rejected for EVERY connector regardless of this flag — it is undefined on a synthetic metadata table. + * + * @return {@code true} if this connector's system tables honor a time-travel / branch-tag pin + * (default: {@code false}) + */ + default boolean supportsSystemTableTimeTravel() { + return false; + } + + /** + * Classifies a SPECIAL (synthesized / generated) column that this connector owns, by name. Consulted by + * the generic {@code PluginDrivenScanNode.classifyColumn} so the engine can tag a connector's hidden / + * metadata columns (e.g. iceberg's {@code __DORIS_ICEBERG_ROWID_COL__} row-id and v3 row-lineage columns) + * with the right BE column category WITHOUT the generic node importing any connector-specific code. + * + *

Returning {@link ConnectorColumnCategory#DEFAULT} (the default — for every regular data column and + * for connectors with no special columns, e.g. paimon/jdbc/es) means "not mine": the generic node falls + * through to its own partition-key / regular classification. The engine-wide lazy-materialization row-id + * column ({@code __DORIS_GLOBAL_ROWID_COL__*}) is NOT classified here — it is a generic Doris mechanism + * handled by the generic node itself.

+ * + * @param columnName the (already identifier-mapped) Doris column name of a query slot + * @return the special-column category, or {@link ConnectorColumnCategory#DEFAULT} if not a special column + */ + default ConnectorColumnCategory classifyColumn(String columnName) { + return ConnectorColumnCategory.DEFAULT; + } + + /** + * Lets a connector adjust the file compression type the generic node inferred from the file path/extension + * (via {@code Util.inferFileCompressTypeByPath}) before it is shipped to BE on the scan range. The default + * is identity — the inferred type is used as-is. + * + *

Hive overrides this to remap {@code LZ4FRAME -> LZ4BLOCK}: hadoop/hive write {@code .lz4} files with + * the LZ4 block codec, not the LZ4 frame format the {@code .lz4} extension implies, so the frame + * inferred from the path would make BE's frame decoder fail ({@code LZ4F_getFrameInfo ERROR_frameType_unknown}). + * This keeps that hadoop-specific fact inside the connector; the generic node stays connector-agnostic.

+ * + * @param inferred the compression type the generic node inferred from the file path + * @return the compression type to actually send to BE (identity by default) + */ + default TFileCompressType adjustFileCompressType(TFileCompressType inferred) { + return inferred; + } + /** * Plans the scan for the given table, returning a list of scan ranges. * @@ -88,6 +164,242 @@ default List planScan( 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 + */ + 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); + } + + /** + * Whether this connector supports batched / streaming split generation for a partitioned scan. + * + *

When {@code true}, a partition-aware ScanNode (e.g. {@code PluginDrivenScanNode}) may + * enter batch mode: instead of enumerating all splits synchronously via {@link #planScan}, + * it slices the pruned partitions into batches and calls {@link #planScanForPartitionBatch} + * per batch on a background executor, streaming splits as they are produced (mirrors legacy + * {@code MaxComputeScanNode.startSplit}). The default is {@code false}, so connectors stay on + * the synchronous {@code planScan} path unless they opt in.

+ * + * @param session the current session + * @param handle the table handle + * @return whether batched split generation is supported for this table (default: false) + */ + default boolean supportsBatchScan(ConnectorSession session, ConnectorTableHandle handle) { + return false; + } + + /** + * Whether this connector's scan ranges carry meaningful byte lengths + * ({@link ConnectorScanRange#getLength()}) so the engine can apply {@code TABLESAMPLE} by + * size-weighted split selection ({@code PluginDrivenScanNode.sampleSplits}). Returning + * {@code false} (the default) makes {@code TABLESAMPLE} a no-op — the full table is scanned (with a + * warning) — matching these connectors' behavior before the SPI migration (only the legacy Hive scan + * node ever sampled). A connector must NOT return {@code true} unless EVERY range it plans exposes a + * positive, byte-proportional length: MaxCompute's default byte-size ranges and Paimon's JNI-read + * ranges report {@code -1}, and MaxCompute row_offset ranges report a ROW count (not bytes), so they + * must stay {@code false}. Mirrors {@link #supportsBatchScan}'s opt-in shape and Trino's + * {@code ConnectorMetadata.applySample}. + * + * @return whether split-size TABLESAMPLE is valid for this connector (default: false) + */ + default boolean supportsTableSample() { + 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. + * Feeds the scan node's {@code selectedPartitionNum} (EXPLAIN {@code partition=N/M} and the + * {@code sql_block_rule} {@code partition_num} guard), so the reported count reflects what is + * really scanned rather than the engine's declared-partition-column (Nereids) prune count. + * + *

The default returns {@link OptionalLong#empty()}, so the generic node keeps its Nereids-pruned + * count — correct for directory-partitioned / requiredPartition-driven connectors (hive, MaxCompute), + * where the two coincide. A predicate-driven connector whose SDK prunes beyond the engine (Paimon + * manifest pruning, Iceberg hidden/transform partitioning) overrides this. Mirrors the + * {@link #supportsTableSample} opt-in shape; the connector downcasts its OWN range type (it produced + * these very ranges) to read partition identity, so the generic node stays connector-agnostic. It + * must never over-count (each native partition must map to exactly one identity within a scan), so it + * can only tighten, never loosen, the {@code partition_num} guard relative to the Nereids count.

+ * + * @param scanRanges the ranges this provider just returned from {@code planScan} + * @return the distinct scanned-partition count, or empty to keep the engine's Nereids count (default) + */ + default OptionalLong scannedPartitionCount(List scanRanges) { + return OptionalLong.empty(); + } + + /** + * Connector SDK scan diagnostics harvested during the just-finished {@link #planScan} — manifest cache + * hit/miss, scan/planning durations, files and manifests scanned vs skipped — as connector-neutral + * {@link ConnectorScanProfile} groups the engine writes into the query's profile execution summary. + * + *

The default returns an empty list (connector reports nothing). A connector that wants scan + * diagnostics harvests them from its SDK during {@code planScan} (the paimon SDK exposes a metric + * registry, the iceberg SDK a metrics reporter), stashes them keyed by {@link ConnectorSession#getQueryId()}, + * and drains them here — mirroring the per-query queryId stashes this SPI already uses (read-transaction + * release, rewritable-delete supply). The engine calls this immediately after {@code planScan} on the + * same thread, so the harvest is complete; the connector must also drop its stash on + * {@link #releaseReadTransaction} to reclaim any entry a thrown {@code planScan} left behind.

+ * + * @param session the current session (its queryId keys the connector's per-query stash) + * @return this scan's diagnostics, or an empty list (the default) to contribute nothing to the profile + */ + default List collectScanProfiles(ConnectorSession session) { + return Collections.emptyList(); + } + + /** + * Plans the scan for a single batch of partitions (used by batch-mode scans). + * + *

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.

+ * + * @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 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, + List partitionBatch) { + return planScan(session, handle, columns, filter, limit, partitionBatch); + } + + /** + * Decides whether this scan should use streaming (lazy) split generation and, if so, estimates + * the number of splits it will produce. This is the file-count counterpart of the partition-count + * batch gate ({@link #supportsBatchScan}), and echoes Trino's lazy {@code ConnectorSplitSource} + * model: the connector owns the whole decision (e.g. Iceberg: matched-manifest file count ≥ + * {@code num_files_in_batch_mode} with {@code enable_external_table_batch_mode} on, a snapshot + * present, not a system table, count pushdown not servable). The engine additionally requires the + * scan to have output slots before entering streaming mode. + * + *

When this returns a non-negative value, the engine drives streaming split generation via + * {@link #streamSplits} (pulling ranges with backpressure) instead of the synchronous + * {@link #planScan}; the returned estimate doubles as the BE concurrency hint. A negative value + * keeps the scan on the synchronous {@code planScan} path (the default).

+ * + *

The decision MUST be cheap (e.g. manifest metadata counts), NOT a full split enumeration — + * the heavy enumeration is deferred to {@link #streamSplits}.

+ * + * @param session the current session + * @param handle the table handle (carries any pushed-down filter) + * @param filter an optional remaining filter expression + * @param countPushdown whether a no-grouping {@code COUNT(*)} is pushed down for this scan + * @return the approximate streamed split count if streaming should be used, else a negative value + * (default: -1) + */ + default long streamingSplitEstimate( + ConnectorSession session, + ConnectorTableHandle handle, + Optional filter, + boolean countPushdown) { + return -1; + } + + /** + * Builds a lazy {@link ConnectorSplitSource} for streaming split generation. Called once, on a + * background task, only when {@link #streamingSplitEstimate} returned a non-negative value. The + * engine pulls ranges from the source with backpressure and pumps them into the split queue + * (mirrors legacy {@code IcebergScanNode.doStartSplit}). + * + *

The source MUST defer the heavy planning (e.g. {@code TableScan.planFiles()}) until ranges + * are consumed, so FE heap usage stays bounded for very large scans. The default throws, so a + * connector that returns a non-negative {@link #streamingSplitEstimate} must override this.

+ * + * @param session the current session + * @param handle the table handle (snapshot/time-travel already pinned by the engine) + * @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 lazy, closeable source of scan ranges + */ + default ConnectorSplitSource streamSplits( + ConnectorSession session, + ConnectorTableHandle handle, + List columns, + Optional filter, + long limit) { + throw new UnsupportedOperationException( + "streamSplits requires streamingSplitEstimate(...) >= 0; connector did not implement it"); + } + /** * Returns scan-node-level properties shared across all scan ranges. * @@ -180,6 +492,21 @@ default void appendExplainInfo(StringBuilder output, // Default: no extra EXPLAIN info } + /** + * Returns the delete-file paths carried by one scan range's table-format descriptor, for the + * VERBOSE per-backend EXPLAIN block ({@code deleteFileNum}/{@code deleteSplitNum}). + * + *

The default returns an empty list, so connectors without merge-on-read deletes contribute + * nothing. A connector that threads delete files onto its per-range thrift (e.g. Paimon's + * deletion vectors) overrides this to read them back from {@code tableFormatParams}.

+ * + * @param tableFormatParams the per-range table-format descriptor (may be {@code null}) + * @return the delete-file paths for this range (default: empty) + */ + default List getDeleteFiles(TTableFormatFileDesc tableFormatParams) { + return Collections.emptyList(); + } + /** * Returns the serialized table representation for this connector, * or {@code null} if not applicable. @@ -193,4 +520,23 @@ default void appendExplainInfo(StringBuilder output, 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 + * not open a per-query read transaction (every connector except transactional/ACID hive) need not override + * it. + * + *

A connector that opens a metastore read transaction + shared read lock during {@link #planScan} (hive + * full-ACID / insert-only reads) MUST override this to commit that transaction and release the lock, else + * the shared read lock leaks for the metastore's lifetime. Best-effort: an implementation should log and + * swallow a commit failure rather than propagate (the callback registry isolates exceptions anyway). + * {@code queryId} is the engine query id string ({@link ConnectorSession#getQueryId()}), the same key the + * provider registered the transaction under.

+ * + * @param queryId the finishing query's id (== {@link ConnectorSession#getQueryId()}) + */ + default void releaseReadTransaction(String queryId) { + // default: no per-query read transaction to release + } } diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanProfile.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanProfile.java new file mode 100644 index 00000000000000..034941a24261d9 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanProfile.java @@ -0,0 +1,66 @@ +// 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 java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A connector-neutral bundle of scan diagnostics for one table scan, produced by a connector from its own + * SDK metrics and written verbatim into the query profile by the generic scan node + * (via {@link ConnectorScanPlanProvider#collectScanProfiles}). + * + *

The engine treats all three fields opaquely — it get-or-creates a profile group named + * {@link #getGroupName()} under the execution summary, adds a child named {@link #getScanLabel()}, and + * writes each {@link #getMetrics()} entry as an info string. This keeps the engine connector-agnostic: + * it never interprets a metric, only the connector knows what its SDK exposes (paimon manifest cache + * hit/miss, iceberg scanned/skipped manifests, etc.). Values are ALREADY formatted strings because the + * formatting (durations, byte sizes) lives with the SDK-specific harvest in the connector.

+ * + *

Immutable: the metrics map is copied into an unmodifiable, insertion-ordered view so the profile + * rendering order is stable.

+ */ +public final class ConnectorScanProfile { + private final String groupName; + private final String scanLabel; + private final Map metrics; + + public ConnectorScanProfile(String groupName, String scanLabel, Map metrics) { + this.groupName = groupName; + this.scanLabel = scanLabel; + this.metrics = metrics == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(metrics)); + } + + /** The profile group name (e.g. {@code "Paimon Scan Metrics"}); must match the engine's ordering key. */ + public String getGroupName() { + return groupName; + } + + /** The per-scan child label (e.g. {@code "Table Scan (db.tbl)"}). */ + public String getScanLabel() { + return scanLabel; + } + + /** The ordered metric name → already-formatted value pairs (unmodifiable). */ + public Map getMetrics() { + return metrics; + } +} 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 2bab45080b24e4..b54fe18abfa1cb 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 @@ -78,6 +78,31 @@ default long getModificationTime() { return 0; } + /** + * Returns this split's weight numerator for proportional BE assignment, or {@code -1} when the + * connector provides no weight. + * + *

The engine forms a proportional split weight {@code getSelfSplitWeight() / getTargetSplitSize()} + * (clamped) only when BOTH this and {@link #getTargetSplitSize()} are provided; otherwise it falls back + * to {@code SplitWeight.standard()} (uniform). A connector with no size-based weight model keeps the + * {@code -1} default and is unaffected. {@code 0} is a legitimate weight (e.g. an empty file or a + * zero-row system-table split), distinct from the {@code -1} "not provided" sentinel.

+ */ + default long getSelfSplitWeight() { + return -1; + } + + /** + * Returns the weight denominator (scan-level target split size) used with {@link #getSelfSplitWeight()} + * to form the proportional split weight, or {@code -1} when not provided. + * + *

Proportional weighting is applied only when this is positive AND {@link #getSelfSplitWeight()} is + * non-negative; otherwise the engine uses {@code SplitWeight.standard()}.

+ */ + default long getTargetSplitSize() { + return -1; + } + /** Returns preferred host locations for data locality. */ default List getHosts() { return Collections.emptyList(); @@ -105,6 +130,18 @@ default Map getPartitionValues() { return Collections.emptyMap(); } + /** + * Whether this range belongs to a partitioned table whose partition values come from the connector's + * metadata (NOT encoded in the file path). When {@code true}, an empty {@link #getPartitionValues()} + * map means "this file genuinely has no path-derived partition values" and the engine must use it verbatim + * instead of falling back to Hive-style path parsing — which would fail for connectors (e.g. Iceberg) whose + * data files are not laid out as {@code key=value} directories. The default {@code false} preserves the + * legacy behavior (an empty map is treated as "no partition info", letting the engine path-parse). + */ + default boolean isPartitionBearing() { + return false; + } + /** * Returns delete files associated with this scan range. * Used by Iceberg merge-on-read tables for positional/equality deletes. @@ -113,6 +150,31 @@ default List getDeleteFiles() { return Collections.emptyList(); } + /** + * Returns the precomputed pushed-down COUNT(*) row count this range carries, or {@code -1} when + * the range carries no precomputed count. + * + *

When a no-grouping {@code COUNT(*)} is pushed down, a connector that can produce a precomputed + * row count (e.g. Paimon's collapsed count range) surfaces the summed total here so the scan node + * can render the EXPLAIN {@code pushdown agg=COUNT (n)} line. Ranges with no precomputed count keep + * the {@code -1} default, which renders as the {@code (-1)} sentinel.

+ */ + default long getPushDownRowCount() { + return -1; + } + + /** + * Whether this range is read by BE's NATIVE (ORC/Parquet) reader rather than the JNI scanner. + * + *

Used by a connector that distinguishes native vs JNI sub-splits (e.g. Paimon) so the scan + * node can accumulate the native/total split counts for the EXPLAIN + * {@code paimonNativeReadSplits=/} line. The default is {@code false} (JNI), so + * connectors without a native read path are unaffected.

+ */ + default boolean isNativeReadRange() { + return false; + } + /** * Populates per-range Thrift params from this scan range's data. * diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorSplitSource.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorSplitSource.java new file mode 100644 index 00000000000000..3fa85df7f9b99a --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorSplitSource.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.scan; + +import java.io.Closeable; + +/** + * A lazy, closeable source of {@link ConnectorScanRange}s for streaming (batched) split generation, + * echoing Trino's {@code ConnectorSplitSource}. Returned by + * {@link ConnectorScanPlanProvider#streamSplits} when a connector opts into streaming split + * generation (see {@link ConnectorScanPlanProvider#streamingSplitEstimate}). + * + *

The engine (e.g. {@code PluginDrivenScanNode}) pulls ranges incrementally with backpressure + * and pumps them into the split queue, instead of materializing all ranges up front via + * {@link ConnectorScanPlanProvider#planScan}. This bounds FE heap usage for very large scans + * (mirrors legacy {@code IcebergScanNode.doStartSplit}).

+ * + *

Implementations MUST defer the heavy planning (e.g. iceberg {@code TableScan.planFiles()}) + * until ranges are actually consumed, and MUST release the underlying resources in {@link #close()}. + * Instances are single-pass and not thread-safe; the engine drives one source from a single + * background task.

+ */ +public interface ConnectorSplitSource extends Closeable { + + /** + * Returns whether more ranges remain. May advance over internally-skipped tasks (e.g. files + * filtered out by a rewrite scope), so it is the only safe way to test for completion. + */ + boolean hasNext(); + + /** + * Returns the next range. Must only be called when {@link #hasNext()} is {@code true}. + */ + ConnectorScanRange next(); +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorSinkPlan.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorSinkPlan.java new file mode 100644 index 00000000000000..8f9155de3cc613 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorSinkPlan.java @@ -0,0 +1,42 @@ +// 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.write; + +import org.apache.doris.thrift.TDataSink; + +/** + * The result of {@link ConnectorWritePlanProvider#planWrite}: a connector-built + * Thrift data sink describing how BE should write the target table. + * + *

Wraps an opaque {@link TDataSink} (e.g. {@code TMaxComputeTableSink}, + * {@code THiveTableSink}, {@code TIcebergTableSink}). The engine dispatches the + * sink to BE unchanged.

+ */ +public class ConnectorSinkPlan { + + private final TDataSink dataSink; + + public ConnectorSinkPlan(TDataSink dataSink) { + this.dataSink = dataSink; + } + + /** Returns the connector-built data sink to dispatch to BE. */ + public TDataSink getDataSink() { + return dataSink; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWriteConfig.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWriteConfig.java deleted file mode 100644 index 88342bc44f4638..00000000000000 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWriteConfig.java +++ /dev/null @@ -1,160 +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.write; - -import java.io.Serializable; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -/** - * Configuration for a connector write operation. - * - *

Returned by {@link org.apache.doris.connector.api.ConnectorWriteOps#getWriteConfig} - * to describe how data should be written. The engine (fe-core) uses this to - * construct the appropriate Thrift data sink for BE execution.

- * - *

This is a value object — all fields are immutable once constructed.

- */ -public class ConnectorWriteConfig implements Serializable { - - private static final long serialVersionUID = 1L; - - private final ConnectorWriteType writeType; - private final String fileFormat; - private final String compression; - private final String writeLocation; - private final List partitionColumns; - private final Map staticPartitionValues; - private final Map properties; - - private ConnectorWriteConfig(Builder builder) { - this.writeType = builder.writeType; - this.fileFormat = builder.fileFormat; - this.compression = builder.compression; - this.writeLocation = builder.writeLocation; - this.partitionColumns = builder.partitionColumns != null - ? Collections.unmodifiableList(builder.partitionColumns) - : Collections.emptyList(); - this.staticPartitionValues = builder.staticPartitionValues != null - ? Collections.unmodifiableMap(builder.staticPartitionValues) - : Collections.emptyMap(); - this.properties = builder.properties != null - ? Collections.unmodifiableMap(builder.properties) - : Collections.emptyMap(); - } - - /** Returns the write type determining BE sink behavior. */ - public ConnectorWriteType getWriteType() { - return writeType; - } - - /** Returns the file format for file-based writes (e.g., "parquet", "orc"). */ - public String getFileFormat() { - return fileFormat; - } - - /** Returns the compression codec (e.g., "snappy", "zstd"). */ - public String getCompression() { - return compression; - } - - /** Returns the target location for file writes. */ - public String getWriteLocation() { - return writeLocation; - } - - /** Returns partition column names for partitioned writes. */ - public List getPartitionColumns() { - return partitionColumns; - } - - /** Returns static partition values (column name → value) for static partition inserts. */ - public Map getStaticPartitionValues() { - return staticPartitionValues; - } - - /** Returns connector-specific properties passed through to BE. */ - public Map getProperties() { - return properties; - } - - /** Creates a new builder. */ - public static Builder builder(ConnectorWriteType writeType) { - return new Builder(writeType); - } - - @Override - public String toString() { - return "ConnectorWriteConfig{type=" + writeType - + ", format=" + fileFormat - + ", compression=" + compression - + ", location=" + writeLocation + "}"; - } - - /** - * Builder for {@link ConnectorWriteConfig}. - */ - public static class Builder { - private final ConnectorWriteType writeType; - private String fileFormat; - private String compression; - private String writeLocation; - private List partitionColumns; - private Map staticPartitionValues; - private Map properties; - - private Builder(ConnectorWriteType writeType) { - this.writeType = writeType; - } - - public Builder fileFormat(String fileFormat) { - this.fileFormat = fileFormat; - return this; - } - - public Builder compression(String compression) { - this.compression = compression; - return this; - } - - public Builder writeLocation(String writeLocation) { - this.writeLocation = writeLocation; - return this; - } - - public Builder partitionColumns(List partitionColumns) { - this.partitionColumns = partitionColumns; - return this; - } - - public Builder staticPartitionValues(Map staticPartitionValues) { - this.staticPartitionValues = staticPartitionValues; - return this; - } - - public Builder properties(Map properties) { - this.properties = properties; - return this; - } - - public ConnectorWriteConfig build() { - return new ConnectorWriteConfig(this); - } - } -} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePartitionField.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePartitionField.java new file mode 100644 index 00000000000000..c962b46a6828d7 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePartitionField.java @@ -0,0 +1,78 @@ +// 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.write; + +/** + * One field of a connector's write-time partitioning, in an engine-neutral form. + * + *

A write-capable connector returns these from {@link ConnectorWritePlanProvider#getWritePartitioning} + * so the engine can build its merge-write distribution (the iceberg {@code DistributionSpecMerge}) without + * importing the connector's native partition-spec types. The engine resolves {@link #getSourceColumnName()} + * to a bound output expr id locally and constructs the distribution field from + * {@code (transform, exprId, transformParam, fieldName, sourceId)}.

+ * + *

The fields map 1:1 onto the legacy iceberg partition walk + * ({@code PhysicalIcebergMergeSink.buildInsertPartitionFields}): {@code transform} is the iceberg + * {@code PartitionField.transform().toString()} (e.g. {@code "identity"}, {@code "bucket[16]"}, + * {@code "day"}); {@code transformParam} is its parsed bracket argument ({@code 16} for {@code bucket[16]}, + * {@code null} when absent); {@code sourceColumnName} is the base column name the field is derived from + * (resolved from {@code sourceId} against the schema); {@code fieldName} is the partition field's own name + * (e.g. {@code "id_bucket"}); {@code sourceId} is the iceberg source field id.

+ */ +public final class ConnectorWritePartitionField { + + private final String transform; + private final Integer transformParam; + private final String sourceColumnName; + private final String fieldName; + private final int sourceId; + + public ConnectorWritePartitionField(String transform, Integer transformParam, + String sourceColumnName, String fieldName, int sourceId) { + this.transform = transform; + this.transformParam = transformParam; + this.sourceColumnName = sourceColumnName; + this.fieldName = fieldName; + this.sourceId = sourceId; + } + + /** The transform name, verbatim from the native spec (e.g. {@code "identity"}, {@code "bucket[16]"}). */ + public String getTransform() { + return transform; + } + + /** The parsed bracket argument of the transform ({@code 16} for {@code bucket[16]}), or {@code null}. */ + public Integer getTransformParam() { + return transformParam; + } + + /** The base (source) column name the partition field is derived from; may be {@code null} if unresolvable. */ + public String getSourceColumnName() { + return sourceColumnName; + } + + /** The partition field's own name (e.g. {@code "id_bucket"}). */ + public String getFieldName() { + return fieldName; + } + + /** The iceberg source field id of the base column. */ + public int getSourceId() { + return sourceId; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePartitionSpec.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePartitionSpec.java new file mode 100644 index 00000000000000..6c13b1bf468e02 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePartitionSpec.java @@ -0,0 +1,54 @@ +// 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.write; + +import java.util.Collections; +import java.util.List; + +/** + * A connector's write-time partitioning, in an engine-neutral form: the current partition spec id plus its + * ordered {@link ConnectorWritePartitionField}s. + * + *

Returned by {@link ConnectorWritePlanProvider#getWritePartitioning} for a partitioned target whose + * write distribution the engine must reproduce (the iceberg merge-write {@code DistributionSpecMerge}). + * {@code null} (not an empty spec) means the target is unpartitioned — mirroring the legacy + * {@code spec().isPartitioned()} gate. The engine maps each field's source-column name to a bound expr id + * locally and carries {@link #getSpecId()} into the distribution.

+ */ +public final class ConnectorWritePartitionSpec { + + private final int specId; + private final List fields; + + public ConnectorWritePartitionSpec(int specId, List fields) { + this.specId = specId; + this.fields = fields == null + ? Collections.emptyList() + : Collections.unmodifiableList(fields); + } + + /** The current partition spec id (carried into the engine's merge distribution). */ + public int getSpecId() { + return specId; + } + + /** The ordered partition fields of the current spec. */ + public List getFields() { + return fields; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePlanProvider.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePlanProvider.java new file mode 100644 index 00000000000000..5de4cc93713b21 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePlanProvider.java @@ -0,0 +1,223 @@ +// 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.write; + +import org.apache.doris.connector.api.ConnectorColumn; +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 java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +/** + * Plans the write (sink) for a connector table: produces the opaque + * {@link org.apache.doris.thrift.TDataSink} that BE uses to write data. + * + *

This is the write-side analogue of + * {@link org.apache.doris.connector.api.scan.ConnectorScanPlanProvider}. A + * connector with write capability returns an implementation from + * {@link org.apache.doris.connector.api.Connector#getWritePlanProvider()}; the + * engine calls {@link #planWrite} when translating a physical table sink, then + * dispatches the resulting Thrift data sink to BE unchanged.

+ */ +public interface ConnectorWritePlanProvider { + + /** + * Builds the data sink for the given bound write request. + * + * @param session the current session + * @param handle the bound write request (target table, columns, overwrite, context) + * @return a {@link ConnectorSinkPlan} wrapping the Thrift data sink + */ + ConnectorSinkPlan planWrite(ConnectorSession session, ConnectorWriteHandle handle); + + /** + * Appends connector-specific EXPLAIN detail for the write (e.g. the generated INSERT SQL, + * sink dialect, target format). Write-side analogue of the scan provider's + * {@code appendExplainInfo}: the engine emits the generic plugin-driven sink line, then calls + * this so the connector can surface its own write details without the engine knowing the + * sink dialect. + * + *

This runs when the plan's EXPLAIN string is generated, which is before the write + * plan is bound (the sink's {@code planWrite} has not run yet for an EXPLAIN). The connector + * therefore derives the detail from the {@code handle} and may consult the source for metadata + * (e.g. remote column names). Default: no extra EXPLAIN info.

+ * + * @param output the EXPLAIN string being built + * @param prefix the current indentation prefix + * @param session the current session (may be consulted for session-scoped write options) + * @param handle the write request (target table handle and write columns) + */ + default void appendExplainInfo(StringBuilder output, String prefix, + ConnectorSession session, ConnectorWriteHandle handle) { + // Default: no extra EXPLAIN info + } + + /** + * Declares whether the target has a write-side sort order and, if so, its sort columns, in an + * engine-neutral form. The engine calls this when translating the table sink: for a non-{@code null} + * result it builds the Thrift {@code TSortInfo} from the columns (resolving each + * {@link ConnectorWriteSortColumn#getColumnIndex()} against the bound sink output) and threads it + * back to {@link #planWrite} via {@link ConnectorWriteHandle#getSortInfo()} so the connector can stamp + * it onto its opaque sink. + * + *

The {@code null}-vs-list distinction mirrors a source's {@code isSorted()} gate: {@code null} + * means the target has no write sort order (no {@code TSortInfo}); a non-{@code null} list + * means it has one — even an empty list, which yields an empty {@code TSortInfo} (a target + * sorted only by non-resolvable transforms still requests sorted-write semantics). Depends only on + * the target table (not the bound write), so it takes the {@link ConnectorTableHandle}: at + * translation time the full write handle is not yet formed. Default: {@code null} — jdbc / maxcompute + * keep their byte-identical unsorted sink output.

+ * + * @param session the current session + * @param tableHandle the target table handle + * @return the ordered write-sort columns (possibly empty) if the target has a sort order, or + * {@code null} if it has none + */ + default List getWriteSortColumns(ConnectorSession session, + ConnectorTableHandle tableHandle) { + return null; + } + + /** + * Declares the target's write-time partitioning, in an engine-neutral form, so the engine can reproduce + * the connector's write distribution (the iceberg merge-write {@code DistributionSpecMerge}) without + * importing the connector's native partition-spec types. The engine resolves each + * {@link ConnectorWritePartitionField#getSourceColumnName()} to a bound output expr id locally and builds + * the distribution from the field tuple + {@link ConnectorWritePartitionSpec#getSpecId()}. + * + *

{@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. Depends only on the target table (not the bound write), so it takes the + * {@link ConnectorTableHandle}. Default: {@code null} — jdbc / maxcompute / paimon keep their + * byte-identical non-partitioned write distribution.

+ * + * @param session the current session + * @param tableHandle the target table handle + * @return the current spec id + ordered partition fields if the target is partitioned, or {@code null} + * if it is unpartitioned + */ + default ConnectorWritePartitionSpec getWritePartitioning(ConnectorSession session, + ConnectorTableHandle tableHandle) { + return null; + } + + /** + * Declares the connector's synthetic write columns for the target — request-scoped hidden + * columns the engine injects into {@code PluginDrivenExternalTable.getFullSchema()} while a write/DML + * over this table is in flight, in an engine-neutral form. The engine appends these (converted via + * its {@code ConnectorColumnConverter}) to the table's full schema only when the request signals it + * (show-hidden, or the synthetic-write-column ctx flag set for this table during row-level DML), so a + * synthesized DELETE/UPDATE/MERGE plan can bind slots that reference them. + * + *

These are the per-row write metadata a connector needs for row-level DML — for iceberg the + * {@code __DORIS_ICEBERG_ROWID_COL__} STRUCT (file_path / row_position / partition_spec_id / + * partition_data), declared {@link ConnectorColumn#invisible() invisible} so it never surfaces in + * SELECT/SHOW. Distinct from the connector's always-present hidden columns (e.g. iceberg v3 + * row-lineage), which are declared through the schema SPI and cached: those live in the schema cache, + * whereas synthetic write columns are request-scoped and must not be cached. Depends only on the + * target table (not the bound write), so it takes the {@link ConnectorTableHandle}. Default: empty — + * a connector without synthetic write columns (jdbc / es / paimon / maxcompute) injects nothing, + * keeping its byte-identical full schema.

+ * + * @param session the current session + * @param tableHandle the target table handle + * @return the synthetic write columns to inject, or an empty list if the target has none + */ + default List getSyntheticWriteColumns(ConnectorSession session, + ConnectorTableHandle tableHandle) { + return Collections.emptyList(); + } + + /** + * The write operations this provider can plan, in one place — the single source of truth for a + * connector's write capability. Replaces the removed {@code ConnectorWriteOps} boolean methods and + * the removed INSERT-support capability switch. Default: INSERT only (any write provider can at least + * append). A connector overrides this to add OVERWRITE / DELETE / MERGE / REWRITE. Connector-level + * (does not vary per table); per-table mode constraints stay in + * {@link org.apache.doris.connector.api.ConnectorWriteOps#validateRowLevelDmlMode}. + */ + default Set supportedOperations() { + return EnumSet.of(WriteOperation.INSERT); + } + + /** Whether this connector can write into a named table branch ({@code INSERT INTO t@branch(name)}). Default: no. */ + default boolean supportsWriteBranch() { + return false; + } + + /** + * Whether the connector supports multiple concurrent writers (parallel sink instances). Connectors that + * do not declare this get GATHER (single-writer) distribution. Formerly a static + * {@code ConnectorCapability} switch; now this per-provider method is the single source of truth. + * Default: no. + */ + default boolean requiresParallelWrite() { + return false; + } + + /** + * Whether the connector maps write data columns positionally against the full table schema (so the sink + * must project rows to full-schema order with unmentioned columns filled). Formerly a static + * {@code ConnectorCapability} switch; now this per-provider method is the single source of truth. + * Default: no. + */ + default boolean requiresFullSchemaWriteOrder() { + return false; + } + + /** + * Whether dynamic-partition writes must be hash-distributed by partition columns and locally sorted by + * them before the sink (e.g. MaxCompute Storage API). Formerly a static {@code ConnectorCapability} + * switch; now this per-provider method is the single source of truth. A connector declaring this must + * also declare {@link #requiresParallelWrite()} and {@link #requiresFullSchemaWriteOrder()}. Default: no. + */ + default boolean requiresPartitionLocalSort() { + return false; + } + + /** + * Whether dynamic-partition writes must be hash-distributed by partition columns but not locally + * sorted by them. A hive-style file writer buffers a separate partition writer per partition value, so — + * unlike {@link #requiresPartitionLocalSort()} (MaxCompute's streaming Storage-API writer, which closes a + * partition writer as soon as a different partition value appears and therefore needs the rows grouped by a + * local sort) — the hash distribution alone keeps each partition's rows on one instance and the output file + * count at ~one-per-partition, with no sort cost. The engine ({@code PhysicalConnectorTableSink}) picks the + * matching distribution: {@code requiresPartitionLocalSort} ⇒ hash + {@code MustLocalSortOrderSpec}; + * {@code requiresPartitionHashWrite} ⇒ hash only. A connector sets at most one of the two hash arms; a + * connector declaring this should also declare {@link #requiresParallelWrite()} + + * {@link #requiresFullSchemaWriteOrder()}. Default: no. + */ + default boolean requiresPartitionHashWrite() { + return false; + } + + /** + * Whether the connector's data files physically retain partition columns, so a static-partition write + * must materialize the PARTITION-clause literal into the data column instead of NULL-filling it (e.g. + * Iceberg). Formerly a static {@code ConnectorCapability} switch; now this per-provider method is the + * single source of truth. Default: no. + */ + default boolean requiresMaterializeStaticPartitionValues() { + return false; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWriteSortColumn.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWriteSortColumn.java new file mode 100644 index 00000000000000..b141ee758c62b5 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWriteSortColumn.java @@ -0,0 +1,60 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.api.write; + +/** + * One column of a connector's declared write-side sort, in an engine-neutral form. + * + *

A write-capable connector returns a list of these from + * {@link ConnectorWritePlanProvider#getWriteSortColumns} when its target requires the BE writer to + * sort rows before writing (e.g. an iceberg table with a {@code WRITE ORDERED BY} sort order). The + * engine resolves {@link #getColumnIndex()} against the bound sink output (the same full-schema + * indexing the sink uses for write distribution) and builds the Thrift {@code TSortInfo}, which the + * connector stamps onto its opaque sink in {@code planWrite}.

+ * + *

The three fields map 1:1 onto the legacy iceberg sink's {@code orderingExprs} / + * {@code isAscOrder} / {@code isNullsFirst} triple. The connector cannot build the {@code TSortInfo} + * itself because the bound output expressions live only in the engine (translation time).

+ */ +public final class ConnectorWriteSortColumn { + + private final int columnIndex; + private final boolean asc; + private final boolean nullsFirst; + + public ConnectorWriteSortColumn(int columnIndex, boolean asc, boolean nullsFirst) { + this.columnIndex = columnIndex; + this.asc = asc; + this.nullsFirst = nullsFirst; + } + + /** Position of the sort column in the sink's full-schema output. */ + public int getColumnIndex() { + return columnIndex; + } + + /** Whether the column sorts ascending. */ + public boolean isAsc() { + return asc; + } + + /** Whether nulls sort first. */ + public boolean isNullsFirst() { + return nullsFirst; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWriteType.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWriteType.java deleted file mode 100644 index 9b64f01d2db9fa..00000000000000 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWriteType.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.write; - -/** - * Identifies the type of a write operation, which determines how BE - * processes the data sink. - * - *

Each type maps to a specific Thrift data sink variant in the - * execution layer. Connectors choose the appropriate type based on - * their write mechanism:

- *
    - *
  • {@link #FILE_WRITE} — write data files to external storage (Hive, Iceberg, Paimon, Hudi)
  • - *
  • {@link #JDBC_WRITE} — execute INSERT statements via JDBC
  • - *
  • {@link #REMOTE_OLAP_WRITE} — stream load to remote Doris cluster
  • - *
  • {@link #CUSTOM} — connector-specific write mechanism
  • - *
- */ -public enum ConnectorWriteType { - - /** File-based write: produce data files (Parquet, ORC, etc.) in external storage. */ - FILE_WRITE, - - /** JDBC write: execute INSERT/UPSERT statements through JDBC connection. */ - JDBC_WRITE, - - /** Remote OLAP write: stream load to another Doris cluster. */ - REMOTE_OLAP_WRITE, - - /** Custom write: all configuration carried in properties map. */ - CUSTOM -} diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorColumnTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorColumnTest.java new file mode 100644 index 00000000000000..57f7d4b995664d --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorColumnTest.java @@ -0,0 +1,99 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.api; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Covers the additive {@code isAutoInc} (P2-8 FIX-AUTOINC-REJECT) and {@code isAggregated} + * (G5 FIX-AGG-COLUMN-REJECT) fields added to {@link ConnectorColumn}. + * + *

WHY this matters: each such flag is now a semantic discriminator that the + * connector validation rejects on. equals/hashCode must include it (else a set/map deduping + * {@code ConnectorColumn}s could collapse an auto-inc column onto a plain one, silently dropping + * the flag), and the legacy arities (5/6-arg) must keep {@code isAutoInc=false} so the other six + * connectors and all read-path producers are zero behavior change.

+ */ +public class ConnectorColumnTest { + + @Test + public void equalsAndHashCodeDistinguishAutoInc() { + ConnectorColumn plain = new ConnectorColumn( + "id", ConnectorType.of("INT"), "", false, null, false, false); + ConnectorColumn autoInc = new ConnectorColumn( + "id", ConnectorType.of("INT"), "", false, null, false, true); + + // WHY (Rule 9): two columns differing ONLY by auto-inc are genuinely different; if + // equals/hashCode ignored the field, dedup could re-drop the flag downstream. + // MUTATION: removing `&& isAutoInc == that.isAutoInc` from equals makes this red. + Assertions.assertNotEquals(plain, autoInc, + "columns differing only by isAutoInc must not be equal"); + Assertions.assertNotEquals(plain.hashCode(), autoInc.hashCode(), + "hashCode must reflect isAutoInc"); + } + + @Test + public void defaultCtorsLeaveAutoIncFalse() { + // WHY: locks the additive-default contract -- the 5-arg and 6-arg ctors (used by the other + // six connectors and read-path producers) must keep isAutoInc=false, i.e. zero behavior + // change. MUTATION: changing a delegation default to true makes this red. + ConnectorColumn fiveArg = new ConnectorColumn( + "c", ConnectorType.of("INT"), "", true, null); + ConnectorColumn sixArg = new ConnectorColumn( + "c", ConnectorType.of("INT"), "", true, null, true); + + Assertions.assertFalse(fiveArg.isAutoInc(), "5-arg ctor must default isAutoInc=false"); + Assertions.assertFalse(sixArg.isAutoInc(), "6-arg ctor must default isAutoInc=false"); + Assertions.assertTrue(sixArg.isKey(), "6-arg ctor must still honor isKey=true"); + } + + @Test + public void equalsAndHashCodeDistinguishAggregated() { + ConnectorColumn plain = new ConnectorColumn( + "c", ConnectorType.of("INT"), "", false, null, false, false, false); + ConnectorColumn aggregated = new ConnectorColumn( + "c", ConnectorType.of("INT"), "", false, null, false, false, true); + + // WHY (Rule 9): two columns differing ONLY by isAggregated are genuinely different; if + // equals/hashCode ignored the field, dedup could re-drop the aggregate flag downstream. + // MUTATION: removing `&& isAggregated == that.isAggregated` from equals makes this red. + Assertions.assertNotEquals(plain, aggregated, + "columns differing only by isAggregated must not be equal"); + Assertions.assertNotEquals(plain.hashCode(), aggregated.hashCode(), + "hashCode must reflect isAggregated"); + } + + @Test + public void defaultCtorsLeaveAggregatedFalse() { + // WHY: locks the additive-default contract -- the 5/6/7-arg ctors (used by the other six + // connectors and read-path producers) must keep isAggregated=false, i.e. zero behavior + // change. MUTATION: changing the 7-arg delegation default to true makes this red. + ConnectorColumn fiveArg = new ConnectorColumn( + "c", ConnectorType.of("INT"), "", true, null); + ConnectorColumn sixArg = new ConnectorColumn( + "c", ConnectorType.of("INT"), "", true, null, true); + ConnectorColumn sevenArg = new ConnectorColumn( + "c", ConnectorType.of("INT"), "", true, null, false, true); + + Assertions.assertFalse(fiveArg.isAggregated(), "5-arg ctor must default isAggregated=false"); + Assertions.assertFalse(sixArg.isAggregated(), "6-arg ctor must default isAggregated=false"); + Assertions.assertFalse(sevenArg.isAggregated(), "7-arg ctor must default isAggregated=false"); + Assertions.assertTrue(sevenArg.isAutoInc(), "7-arg ctor must still honor isAutoInc=true"); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorMetadataTimeTravelDefaultsTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorMetadataTimeTravelDefaultsTest.java new file mode 100644 index 00000000000000..f602fcf0b71350 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorMetadataTimeTravelDefaultsTest.java @@ -0,0 +1,89 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.api; + +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; +import org.apache.doris.connector.api.mvcc.ConnectorTimeTravelSpec; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.Optional; + +/** + * Pins the default behavior of the two B5b time-travel SPI seams on a connector that does + * NOT override them. + * + *

WHY this matters: these defaults are the zero-behavior-change contract for the + * other connectors. {@code resolveTimeTravel} must default to {@code empty()} (a connector + * without time-travel resolves nothing, and the engine then surfaces a user error rather than + * silently reading latest). The snapshot-aware {@code getTableSchema} overload must default to + * delegating to the 2-arg latest variant — if it ignored the delegation a non-evolving + * connector would return null/throw on time-travel reads.

+ */ +public class ConnectorMetadataTimeTravelDefaultsTest { + + /** A no-method handle; the defaults under test never inspect it. */ + private static final ConnectorTableHandle HANDLE = new ConnectorTableHandle() { + }; + + /** + * Minimal metadata that overrides ONLY the 2-arg latest {@code getTableSchema}, so the test + * can prove the 3-arg snapshot-aware default routes back to it. + */ + private static final class LatestOnlyMetadata implements ConnectorMetadata { + static final ConnectorTableSchema LATEST = + new ConnectorTableSchema("t", Collections.emptyList(), null, Collections.emptyMap()); + + @Override + public ConnectorTableSchema getTableSchema(ConnectorSession session, + ConnectorTableHandle handle) { + return LATEST; + } + } + + @Test + public void resolveTimeTravelDefaultsToEmpty() { + ConnectorMetadata metadata = new LatestOnlyMetadata(); + ConnectorTimeTravelSpec spec = ConnectorTimeTravelSpec.snapshotId("1"); + + // MUTATION: a default that returned a fabricated snapshot would make a non-MVCC connector + // silently honor FOR VERSION AS OF instead of erroring. + Optional resolved = + metadata.resolveTimeTravel(null, HANDLE, spec); + Assertions.assertFalse(resolved.isPresent(), + "a connector without time-travel must resolve nothing by default"); + } + + @Test + public void snapshotAwareGetTableSchemaDelegatesToLatest() { + LatestOnlyMetadata metadata = new LatestOnlyMetadata(); + ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder() + .snapshotId(9L) + .schemaId(2L) + .build(); + + // MUTATION: a default that returned null (or threw) instead of delegating to the 2-arg + // variant would break time-travel reads on any connector that does not override it. + ConnectorTableSchema schema = metadata.getTableSchema(null, HANDLE, snapshot); + Assertions.assertSame(LatestOnlyMetadata.LATEST, schema, + "default snapshot-aware getTableSchema must return the latest schema unchanged"); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorPartitionInfoTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorPartitionInfoTest.java new file mode 100644 index 00000000000000..dc2c5d2f3afad7 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorPartitionInfoTest.java @@ -0,0 +1,191 @@ +// 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; + +/** + * Value-type tests for {@link ConnectorPartitionInfo}, pinning the {@code fileCount} field added for + * the paimon SHOW PARTITIONS 5-column parity (D-045). + * + *

{@code fileCount} is the carrier for the legacy FileCount column. Because the class relies on + * value-based {@code equals}/{@code hashCode}, the field must be threaded through the 7-arg + * constructor, the getter, AND equals/hashCode — a common place to forget one.

+ */ +public class ConnectorPartitionInfoTest { + + @Test + public void sevenArgCtorCarriesFileCount() { + ConnectorPartitionInfo info = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap(), + /*rowCount*/ 42L, /*sizeBytes*/ 1024L, /*lastModifiedMillis*/ 1700000000000L, + /*fileCount*/ 7L); + // WHY: SHOW PARTITIONS' FileCount column reads getFileCount(); it must return the 7th ctor + // arg, not be confused with rowCount/sizeBytes/lastModifiedMillis. MUTATION: returning any + // other field, or dropping the assignment (-> 0) -> red. + Assertions.assertEquals(7L, info.getFileCount()); + Assertions.assertEquals(42L, info.getRowCount()); + Assertions.assertEquals(1024L, info.getSizeBytes()); + Assertions.assertEquals(1700000000000L, info.getLastModifiedMillis()); + } + + @Test + public void backwardCompatCtorDefaultsFileCountToUnknown() { + ConnectorPartitionInfo info = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap()); + // WHY: the 3-arg back-compat ctor (used by connectors without per-partition stats, e.g. + // MaxCompute) must default fileCount to the UNKNOWN sentinel, like the other numeric stats. + // MUTATION: defaulting to 0 instead of UNKNOWN -> red. + Assertions.assertEquals(ConnectorPartitionInfo.UNKNOWN, info.getFileCount()); + Assertions.assertEquals(ConnectorPartitionInfo.UNKNOWN, info.getRowCount()); + } + + @Test + public void equalsAndHashCodeIncludeFileCount() { + ConnectorPartitionInfo a = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap(), 1L, 2L, 3L, 7L); + ConnectorPartitionInfo b = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap(), 1L, 2L, 3L, 7L); + ConnectorPartitionInfo differByFileCount = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap(), 1L, 2L, 3L, 8L); + + Assertions.assertEquals(a, b); + Assertions.assertEquals(a.hashCode(), b.hashCode()); + // WHY: value equality must distinguish on fileCount, or two partitions differing only in + // file count would be (wrongly) treated as equal. MUTATION: omitting fileCount from + // equals()/hashCode() -> a.equals(differByFileCount) -> red. + Assertions.assertNotEquals(a, differByFileCount); + } + + @Test + public void nullFlagsCtorsCarryPerValueNullFlags() { + // 4-arg convenience ctor (hive: UNKNOWN stats + connector-supplied per-value NULL flags). + ConnectorPartitionInfo hive = new ConnectorPartitionInfo( + "year=__HIVE_DEFAULT_PARTITION__/month=01", Collections.emptyMap(), Collections.emptyMap(), + Arrays.asList(true, false)); + // WHY: fe-core zips getPartitionValueNullFlags() index-for-index with the parsed values to decide + // NullLiteral vs typed literal, so the order and values must round-trip. MUTATION: dropping the + // flags assignment (-> empty) or reordering -> red. + Assertions.assertEquals(Arrays.asList(true, false), hive.getPartitionValueNullFlags()); + Assertions.assertEquals(ConnectorPartitionInfo.UNKNOWN, hive.getRowCount()); + + // 8-arg ctor (paimon: real stats + NULL flags). + ConnectorPartitionInfo paimon = new ConnectorPartitionInfo( + "region=__HIVE_DEFAULT_PARTITION__", Collections.emptyMap(), Collections.emptyMap(), + 1L, 2L, 3L, 4L, Collections.singletonList(true)); + Assertions.assertEquals(Collections.singletonList(true), paimon.getPartitionValueNullFlags()); + Assertions.assertEquals(4L, paimon.getFileCount()); + } + + @Test + public void backwardCompatCtorsDefaultNullFlagsEmpty() { + // WHY: connectors that do not opt in (3-arg MaxCompute/iceberg, 7-arg hudi) must default the flags + // to empty so fe-core treats every value as non-null (unchanged behavior). MUTATION: defaulting to + // a non-empty list -> red. A null flags arg must normalize to empty (not NPE). + ConnectorPartitionInfo threeArg = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap()); + ConnectorPartitionInfo sevenArg = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap(), 1L, 2L, 3L, 4L); + ConnectorPartitionInfo nullArg = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap(), null); + Assertions.assertTrue(threeArg.getPartitionValueNullFlags().isEmpty()); + Assertions.assertTrue(sevenArg.getPartitionValueNullFlags().isEmpty()); + Assertions.assertTrue(nullArg.getPartitionValueNullFlags().isEmpty()); + } + + @Test + public void equalsAndHashCodeIncludeNullFlags() { + ConnectorPartitionInfo a = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap(), Arrays.asList(true, false)); + ConnectorPartitionInfo b = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap(), Arrays.asList(true, false)); + ConnectorPartitionInfo differByFlags = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap(), Arrays.asList(false, false)); + + Assertions.assertEquals(a, b); + Assertions.assertEquals(a.hashCode(), b.hashCode()); + // WHY: two partitions differing only in per-value nullness (a genuine-NULL value vs a literal + // value that happens to render the same string) must not compare equal. MUTATION: omitting + // nullFlags from equals()/hashCode() -> a.equals(differByFlags) -> red. + Assertions.assertNotEquals(a, differByFlags); + } + + @Test + public void orderedValuesCtorsCarryValuesAlignedToNullFlags() { + // 5-arg convenience ctor (hive/iceberg: UNKNOWN stats + connector-supplied ordered values [+ flags]). + ConnectorPartitionInfo hive = new ConnectorPartitionInfo( + "nation=cn/city=__HIVE_DEFAULT_PARTITION__", Collections.emptyMap(), Collections.emptyMap(), + Arrays.asList("cn", "__HIVE_DEFAULT_PARTITION__"), Arrays.asList(false, true)); + // WHY: fe-core zips getOrderedPartitionValues() index-for-index with types + nullFlags INSTEAD of + // re-parsing the rendered name, so the ordered values must round-trip exactly and stay aligned to the + // flags. MUTATION: dropping the ordered-values assignment (-> empty, fe-core falls back to the name + // parse) or reordering -> red. + Assertions.assertEquals(Arrays.asList("cn", "__HIVE_DEFAULT_PARTITION__"), hive.getOrderedPartitionValues()); + Assertions.assertEquals(Arrays.asList(false, true), hive.getPartitionValueNullFlags()); + Assertions.assertEquals(ConnectorPartitionInfo.UNKNOWN, hive.getRowCount()); + + // 9-arg full ctor (paimon/hudi: real stats + ordered values + flags). + ConnectorPartitionInfo paimon = new ConnectorPartitionInfo( + "region=us", Collections.emptyMap(), Collections.emptyMap(), + 1L, 2L, 3L, 4L, Collections.singletonList("us"), Collections.singletonList(false)); + Assertions.assertEquals(Collections.singletonList("us"), paimon.getOrderedPartitionValues()); + Assertions.assertEquals(4L, paimon.getFileCount()); + } + + @Test + public void backwardCompatCtorsDefaultOrderedValuesEmpty() { + // WHY: ctors predating the ordered-values field (3-arg, 7-arg, 8-arg nullFlags, 4-arg) must default + // ordered values to empty so fe-core falls back to parsing the rendered name (unchanged behavior). + // MUTATION: defaulting to a non-empty list -> fe-core skips the parse with garbage -> red. A null arg + // must normalize to empty (not NPE). + ConnectorPartitionInfo threeArg = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap()); + ConnectorPartitionInfo eightArgFlags = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap(), 1L, 2L, 3L, 4L, + Collections.singletonList(true)); + ConnectorPartitionInfo nullArg = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap(), null, null); + Assertions.assertTrue(threeArg.getOrderedPartitionValues().isEmpty()); + Assertions.assertTrue(eightArgFlags.getOrderedPartitionValues().isEmpty()); + Assertions.assertTrue(nullArg.getOrderedPartitionValues().isEmpty()); + Assertions.assertTrue(nullArg.getPartitionValueNullFlags().isEmpty()); + } + + @Test + public void equalsAndHashCodeIncludeOrderedValues() { + ConnectorPartitionInfo a = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap(), + Arrays.asList("cn", "bj"), Collections.emptyList()); + ConnectorPartitionInfo b = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap(), + Arrays.asList("cn", "bj"), Collections.emptyList()); + ConnectorPartitionInfo differByValues = new ConnectorPartitionInfo( + "p1", Collections.emptyMap(), Collections.emptyMap(), + Arrays.asList("cn", "sh"), Collections.emptyList()); + + Assertions.assertEquals(a, b); + Assertions.assertEquals(a.hashCode(), b.hashCode()); + // WHY: two partitions with the same rendered name but different ordered values must not compare equal. + // MUTATION: omitting orderedPartitionValues from equals()/hashCode() -> a.equals(differByValues) -> red. + Assertions.assertNotEquals(a, differByValues); + } +} 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 new file mode 100644 index 00000000000000..fe0c66e23dc43c --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorScanProviderSelectionTest.java @@ -0,0 +1,154 @@ +// 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.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; +import org.apache.doris.connector.api.scan.ConnectorScanRange; + +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 + * {@link Connector#getScanPlanProvider(ConnectorTableHandle)}. + * + *

WHY this matters (Rule 9): after the hive/hms cut-over a single catalog is heterogeneous + * (plain-hive + iceberg-on-HMS + hudi-on-HMS under one gateway connector). The engine must pick a + * DIFFERENT scan provider per table without ever inspecting the format itself. The selection must happen + * here — at provider-acquisition time — rather than inside a single dispatching provider, because + * {@link ConnectorScanPlanProvider} has handle-less methods (e.g. {@code appendExplainInfo}) and providers + * are built fresh/stateless per call, so a returned provider must already be bound to the right backing + * scanner for the handle. The default must stay inert for every single-format connector.

+ */ +public class ConnectorScanProviderSelectionTest { + + /** A scan provider identified by name so tests can assert WHICH provider was selected. */ + private static final class NamedProvider implements ConnectorScanPlanProvider { + private final String name; + + private NamedProvider(String name) { + this.name = name; + } + + @Override + public List planScan(ConnectorSession session, ConnectorTableHandle handle, + List columns, Optional filter) { + return Collections.emptyList(); + } + + @Override + public String toString() { + return name; + } + } + + /** Bare connector implementing only the abstract methods; nothing scan-related is overridden. */ + private abstract static class FakeConnector implements Connector { + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + return null; + } + + @Override + public void close() { + } + } + + private static ConnectorTableHandle handle() { + return new ConnectorTableHandle() { + }; + } + + @Test + public void defaultDelegatesToNoArgProvider() { + // A single-format connector overrides only the no-arg getter. The per-handle default must delegate + // to it (NOT return null), so every existing connector routes unchanged after the seam is added. + // MUTATION: making the default return null instead of getScanPlanProvider() -> non-null assert red. + NamedProvider only = new NamedProvider("only"); + Connector connector = new FakeConnector() { + @Override + public ConnectorScanPlanProvider getScanPlanProvider() { + return only; + } + }; + + Assertions.assertSame(only, connector.getScanPlanProvider(handle()), + "the per-handle default must delegate to the connector-level no-arg provider"); + } + + @Test + public void defaultReturnsNullWhenConnectorHasNoScanCapability() { + // A connector with no scan capability (no-arg default returns null) must keep returning null through + // the per-handle seam, preserving the null-tolerant scan path in PluginDrivenScanNode. + Connector connector = new FakeConnector() { + }; + + Assertions.assertNull(connector.getScanPlanProvider(handle()), + "with no scan provider at all the per-handle seam stays null"); + } + + @Test + public void overrideSelectsProviderPerHandle() { + // A heterogeneous gateway overrides the per-handle seam and returns a DIFFERENT provider per table, + // and must NOT fall back to the no-arg provider once it has an answer for the handle. + // MUTATION: keying the override on the no-arg getter (ignoring the handle) -> per-handle assert red. + ConnectorTableHandle icebergHandle = handle(); + ConnectorTableHandle hiveHandle = handle(); + NamedProvider icebergProvider = new NamedProvider("iceberg"); + NamedProvider hiveProvider = new NamedProvider("hive"); + NamedProvider fallback = new NamedProvider("fallback"); + Connector gateway = new FakeConnector() { + @Override + public ConnectorScanPlanProvider getScanPlanProvider() { + return fallback; + } + + @Override + public ConnectorScanPlanProvider getScanPlanProvider(ConnectorTableHandle handle) { + return handle == icebergHandle ? icebergProvider : hiveProvider; + } + }; + + Assertions.assertSame(icebergProvider, gateway.getScanPlanProvider(icebergHandle), + "gateway routes the iceberg-on-HMS handle to the iceberg provider"); + Assertions.assertSame(hiveProvider, gateway.getScanPlanProvider(hiveHandle), + "gateway routes the plain-hive handle to the hive provider"); + Assertions.assertNotSame(fallback, gateway.getScanPlanProvider(icebergHandle), + "an overriding gateway does not fall back to the connector-level no-arg provider"); + } + + @Test + public void ownsHandleDefaultsFalse() { + // A connector owns no handle it did not define. The default must be false so a gateway asking each + // embedded sibling "is this foreign handle yours?" gets a negative from every connector that did not + // produce it, and only the producing sibling (which overrides ownsHandle) claims it. + // MUTATION: flipping the default to true -> a gateway would route a foreign handle to the WRONG sibling. + Connector connector = new FakeConnector() { + }; + + Assertions.assertFalse(connector.ownsHandle(handle()), + "the ownsHandle default must be false (a connector owns no handle it did not define)"); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorSchemaOpsDefaultsTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorSchemaOpsDefaultsTest.java new file mode 100644 index 00000000000000..28af464c164e31 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorSchemaOpsDefaultsTest.java @@ -0,0 +1,57 @@ +// 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.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Pins the default behavior of {@link ConnectorSchemaOps#getDatabase} on a connector that does NOT + * override it. + * + *

WHY this matters: the default was softened from throwing to returning empty metadata so + * SHOW CREATE DATABASE renders a bare {@code CREATE DATABASE} (no LOCATION) for connectors without + * database-level metadata (paimon/jdbc/es), matching their pre-flip generic-else behavior — rather than + * failing the command. The single fe-core caller ({@code PluginDrivenExternalDatabase.getLocation}) + * tolerates the empty map via {@code getOrDefault}.

+ */ +public class ConnectorSchemaOpsDefaultsTest { + + /** A bare metadata implementing only the one abstract SPI method; exercises the schema-ops defaults. */ + private static final class BareMetadata implements ConnectorMetadata { + @Override + public ConnectorTableSchema getTableSchema(ConnectorSession session, ConnectorTableHandle handle) { + return null; // not exercised by this test + } + } + + @Test + public void getDatabaseDefaultsToEmptyMetadataInsteadOfThrowing() { + ConnectorMetadata metadata = new BareMetadata(); + + // MUTATION: reverting the default to `throw` -> SHOW CREATE DATABASE on every non-overriding plugin + // connector (paimon/jdbc/es) fails instead of rendering a bare CREATE DATABASE -> red. + ConnectorDatabaseMetadata db = metadata.getDatabase(null, "db1"); + Assertions.assertNotNull(db, "the default getDatabase must return metadata, not null and not throw"); + Assertions.assertEquals("db1", db.getName(), "the default echoes the requested db name"); + Assertions.assertTrue(db.getProperties().isEmpty(), + "the default carries no properties -> SHOW CREATE DATABASE renders no LOCATION"); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorViewDefaultsTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorViewDefaultsTest.java new file mode 100644 index 00000000000000..62faa733333bd2 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorViewDefaultsTest.java @@ -0,0 +1,86 @@ +// 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.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +/** + * Pins the default behavior of the two B0 view SPI seams on a connector that does NOT override them. + * + *

WHY this matters: these defaults are the zero-behavior-change contract for the other + * connectors. {@code viewExists} must default to {@code false} and {@code listViewNames} to an empty list, + * which is precisely what guarantees a view-less connector (jdbc / es / paimon / maxcompute, none of which + * declare {@code SUPPORTS_VIEW}) reports every object as a non-view and issues no view round-trips. A + * default of {@code true} / a non-empty list would make those connectors mis-classify tables as views and + * leak phantom view names into {@code SHOW TABLES}.

+ */ +public class ConnectorViewDefaultsTest { + + /** + * Minimal metadata that overrides ONLY the abstract {@code getTableSchema}, leaving viewExists / + * listViewNames at their interface defaults — the seams under test. + */ + private static final class NoViewMetadata implements ConnectorMetadata { + @Override + public ConnectorTableSchema getTableSchema(ConnectorSession session, ConnectorTableHandle handle) { + return new ConnectorTableSchema("t", Collections.emptyList(), null, Collections.emptyMap()); + } + } + + @Test + public void viewExistsDefaultsToFalse() { + // MUTATION: a default returning true would make every connector report its tables as views + // (isView()==true on the flipped plugin table) -> scanned as views / rejected for INSERT -> red. + Assertions.assertFalse(new NoViewMetadata().viewExists(null, "db1", "v1"), + "a connector without view support must report no view by default"); + } + + @Test + public void listViewNamesDefaultsToEmpty() { + // MUTATION: a non-empty default would leak phantom view names into the catalog's SHOW TABLES + // merge for connectors that have no views -> red. + Assertions.assertTrue(new NoViewMetadata().listViewNames(null, "db1").isEmpty(), + "a connector without view support must list no views by default"); + } + + @Test + public void getViewDefinitionDefaultsToFailLoud() { + // WHY: callers gate on SUPPORTS_VIEW + isView() before asking for a view body; a view-less connector + // must never silently return a definition. MUTATION: a default returning null / an empty definition + // would let a non-view connector pretend to have a view body -> red. + Assertions.assertThrows(DorisConnectorException.class, + () -> new NoViewMetadata().getViewDefinition(null, "db1", "v1"), + "a connector without view support must fail loud when asked for a view definition"); + } + + @Test + public void dropViewDefaultsToFailLoud() { + // WHY: PluginDrivenExternalCatalog.dropTable routes a DROP to dropView only after viewExists() is + // true, so for a view-less connector (viewExists defaults to false) this default is unreachable in + // production; it is a fail-loud guard. MUTATION: a default that silently no-ops would let a refactor + // that bypasses the viewExists gate drop nothing without surfacing the unsupported operation -> red. + Assertions.assertThrows(DorisConnectorException.class, + () -> new NoViewMetadata().dropView(null, "db1", "v1"), + "a connector without view support must fail loud when asked to drop a view"); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorViewDefinitionTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorViewDefinitionTest.java new file mode 100644 index 00000000000000..ca60ddd5ff766a --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorViewDefinitionTest.java @@ -0,0 +1,102 @@ +// 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.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Value-semantics tests for {@link ConnectorViewDefinition}: the neutral {sql, dialect, columns} carrier the + * connector returns for a flipped external view. The sql + dialect are required (a view always has a body and a + * dialect the body is written in); the columns carry the view's schema (DESC / SHOW COLUMNS / + * information_schema.columns, the H8 fix). Value equality keys on all three. + */ +public class ConnectorViewDefinitionTest { + + private static ConnectorColumn col(String name) { + return new ConnectorColumn(name, ConnectorType.of("INT"), "", true, null, true); + } + + @Test + public void exposesSqlDialectAndColumns() { + List columns = Arrays.asList(col("a"), col("b")); + ConnectorViewDefinition def = new ConnectorViewDefinition("SELECT 1", "spark", columns); + Assertions.assertEquals("SELECT 1", def.getSql()); + Assertions.assertEquals("spark", def.getDialect()); + // WHY (H8): the view's columns must survive the carrier so initSchema can build the view schema from + // them. MUTATION: dropping the columns field / not returning them -> DESC of a flipped view is empty. + Assertions.assertEquals(columns, def.getColumns()); + } + + @Test + public void getColumnsIsDefensiveAndUnmodifiable() { + // WHY: the carrier is shared across the schema cache; a leaked mutable list (or aliasing the caller's + // list) would let a later mutation corrupt a cached view schema. MUTATION: returning the live list / + // skipping the defensive copy -> one of these assertions goes red. + List source = new ArrayList<>(); + source.add(col("a")); + ConnectorViewDefinition def = new ConnectorViewDefinition("SELECT 1", "spark", source); + + // Defensive copy: mutating the source after construction must NOT change the carrier's columns. + source.add(col("late")); + Assertions.assertEquals(1, def.getColumns().size(), + "the carrier must copy the columns defensively, not alias the caller's list"); + + // Unmodifiable view: callers cannot mutate the returned list. + Assertions.assertThrows(UnsupportedOperationException.class, () -> def.getColumns().add(col("x"))); + } + + @Test + public void nullColumnsBecomesEmptyList() { + // WHY: a null columns argument is normalized to an empty (never-null) list so callers (initSchema) + // never NPE on getColumns(). MUTATION: storing null -> getColumns() NPEs downstream -> red. + ConnectorViewDefinition def = new ConnectorViewDefinition("SELECT 1", "spark", null); + Assertions.assertTrue(def.getColumns().isEmpty()); + } + + @Test + public void equalsAndHashCodeKeyOnAllThreeFields() { + List columns = Collections.singletonList(col("a")); + ConnectorViewDefinition base = new ConnectorViewDefinition("SELECT 1", "spark", columns); + Assertions.assertEquals(base, new ConnectorViewDefinition("SELECT 1", "spark", + Collections.singletonList(col("a")))); + Assertions.assertEquals(base.hashCode(), new ConnectorViewDefinition("SELECT 1", "spark", + Collections.singletonList(col("a"))).hashCode()); + // MUTATION: an equals/hashCode that ignores sql, dialect, OR columns would collapse distinct + // definitions -> one of these goes red. + Assertions.assertNotEquals(base, new ConnectorViewDefinition("SELECT 2", "spark", columns)); + Assertions.assertNotEquals(base, new ConnectorViewDefinition("SELECT 1", "trino", columns)); + Assertions.assertNotEquals(base, new ConnectorViewDefinition("SELECT 1", "spark", + Collections.singletonList(col("b")))); + } + + @Test + public void rejectsNullSqlAndDialect() { + // WHY: a view definition with a null body or null dialect is a programming error, not a valid state; + // fail fast at construction. MUTATION: dropping requireNonNull -> a null leaks downstream -> red. + Assertions.assertThrows(NullPointerException.class, + () -> new ConnectorViewDefinition(null, "spark", Collections.emptyList())); + Assertions.assertThrows(NullPointerException.class, + () -> new ConnectorViewDefinition("SELECT 1", null, Collections.emptyList())); + } +} 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 new file mode 100644 index 00000000000000..b837cede0fd916 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/handle/ConnectorWriteHandleTest.java @@ -0,0 +1,102 @@ +// 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 org.apache.doris.connector.api.ConnectorColumn; + +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; + +/** + * Pins the {@link ConnectorWriteHandle#getWriteOperation()} default (P6.3-T03, deferred from T01). + * + *

WHY this matters: the {@code writeOperation} is the SPI vocabulary on which the iceberg + * write adopter selects its SDK op (T04 AppendFiles/RowDelta/…) and its Thrift sink dialect (T06 + * {@code TIcebergTableSink}/{@code TIcebergDeleteSink}/{@code TIcebergMergeSink}). The default MUST be + * {@code INSERT} so every existing write handle (jdbc/maxcompute) — none of which override it — keeps + * plain-append semantics and is byte-compatible (RFC §6 "default INSERT, 向后兼容").

+ */ +public class ConnectorWriteHandleTest { + + /** Minimal handle that overrides nothing write-op related, to read the SPI default. */ + private static final class BareWriteHandle implements ConnectorWriteHandle { + @Override + public ConnectorTableHandle getTableHandle() { + return null; + } + + @Override + public List getColumns() { + return Collections.emptyList(); + } + + @Override + public boolean isOverwrite() { + return false; + } + + @Override + public Map getWriteContext() { + return Collections.emptyMap(); + } + } + + @Test + public void writeOperationDefaultsToInsert() { + Assertions.assertEquals(WriteOperation.INSERT, new BareWriteHandle().getWriteOperation(), + "a write handle that does not declare an operation must default to INSERT so existing " + + "connectors (jdbc/maxcompute) keep plain-append semantics"); + } + + @Test + public void writeOperationEnumCoversAllDmlKinds() { + // Guards parity-by-omission: the iceberg op-selection matrix (T04) and the sink-dialect switch depend on + // exactly these kinds existing. REWRITE (P6.4-T06) maps rewrite_data_files onto the SDK RewriteFiles op. + Assertions.assertArrayEquals( + new WriteOperation[] { + WriteOperation.INSERT, WriteOperation.OVERWRITE, WriteOperation.DELETE, + WriteOperation.UPDATE, WriteOperation.MERGE, WriteOperation.REWRITE}, + WriteOperation.values()); + } + + @Test + public void sortInfoDefaultsToNull() { + // WHY: a write handle carries an engine-built TSortInfo only when the connector declares + // write-sort columns (T06 getWriteSortColumns, iceberg WRITE ORDERED BY). The default MUST be + // null so every existing write handle (jdbc/maxcompute, which never sets it) keeps its + // byte-identical unsorted sink output — the engine sets sort_info only for sorted iceberg tables. + Assertions.assertNull(new BareWriteHandle().getSortInfo(), + "a write handle that declares no write sort must default to a null TSortInfo"); + } + + @Test + public void branchNameDefaultsToEmpty() { + // WHY: an INSERT INTO t@branch threads the target branch onto the write handle so a + // versioned-table connector (iceberg/paimon) points the commit at the branch. The default MUST + // be empty so every existing write handle (jdbc/maxcompute, which never sets it) keeps its + // byte-identical default-ref write. MUTATION: a non-empty default would make a branchless write + // appear branch-targeted. + Assertions.assertEquals(Optional.empty(), new BareWriteHandle().getBranchName(), + "a write handle that declares no branch must default to Optional.empty()"); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/handle/NoOpConnectorTransactionTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/handle/NoOpConnectorTransactionTest.java new file mode 100644 index 00000000000000..67dc7af4d57434 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/handle/NoOpConnectorTransactionTest.java @@ -0,0 +1,74 @@ +// 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 org.apache.doris.connector.api.pushdown.ConnectorPredicate; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Pins the contract of the degenerate {@link NoOpConnectorTransaction} used by connectors whose + * writes are auto-committed by BE (e.g. jdbc). + * + *

WHY this matters: {@link NoOpConnectorTransaction#getUpdateCnt()} must return + * {@code -1}, NOT the {@link ConnectorTransaction} default of {@code 0}. The insert executor + * backfills {@code loadedRows} from {@code getUpdateCnt()} only when it is {@code >= 0}; a + * {@code 0} here would clobber the coordinator's row count and report "affected rows: 0" for + * every jdbc INSERT. {@code -1} ("no count from the transaction") is the agreed sentinel and is + * deliberately distinct from a genuine zero-row write.

+ */ +public class NoOpConnectorTransactionTest { + + @Test + public void getUpdateCntReturnsMinusOneSentinelNotZeroDefault() { + NoOpConnectorTransaction txn = new NoOpConnectorTransaction(123L, "JDBC"); + Assertions.assertEquals(-1L, txn.getUpdateCnt(), + "no-op transaction must report -1 (no count) so the executor keeps the coordinator " + + "row counter rather than overwriting affected-rows with 0"); + } + + @Test + public void carriesIdAndProfileLabel() { + NoOpConnectorTransaction txn = new NoOpConnectorTransaction(456L, "JDBC"); + Assertions.assertEquals(456L, txn.getTransactionId()); + Assertions.assertEquals("JDBC", txn.profileLabel()); + } + + @Test + public void commitRollbackCloseAreNoOps() { + NoOpConnectorTransaction txn = new NoOpConnectorTransaction(789L, "JDBC"); + // Auto-committed by BE; FE-side lifecycle must do nothing and never throw. + Assertions.assertDoesNotThrow(() -> { + txn.commit(); + txn.rollback(); + txn.close(); + }); + } + + @Test + public void applyWriteConstraintIsNoOpByDefault() { + NoOpConnectorTransaction txn = new NoOpConnectorTransaction(321L, "JDBC"); + // O5-2 default: a connector that does no optimistic conflict detection ignores the write constraint + // (and tolerates a null), so jdbc/maxcompute/es/trino are unaffected by the new SPI method. + Assertions.assertDoesNotThrow(() -> { + txn.applyWriteConstraint(null); + txn.applyWriteConstraint(new ConnectorPredicate(null)); + }); + } +} 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 new file mode 100644 index 00000000000000..c5665f72f96e17 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/mvcc/ConnectorMvccSnapshotTest.java @@ -0,0 +1,74 @@ +// 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.mvcc; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Contracts for the additive {@code schemaId} field on {@link ConnectorMvccSnapshot} + * (B5b schema-at-pinned-snapshot support). + * + *

WHY this matters: {@code schemaId} carries the resolved schema version of a + * pinned snapshot so a time-travel read under schema evolution can fetch the schema AS OF + * that snapshot. The unset default MUST be {@code -1} (= unknown) so every pre-existing + * builder caller keeps reading the latest schema with zero behavior change; the existing + * fields must round-trip unchanged so adding the field did not perturb them.

+ */ +public class ConnectorMvccSnapshotTest { + + @Test + public void schemaIdDefaultsToMinusOneWhenUnset() { + // WHY: -1 is the "unknown => fall back to latest schema" sentinel. Every existing builder + // caller (which never calls schemaId(..)) must observe -1, i.e. zero behavior change. + // MUTATION: defaulting the builder field to 0 makes this red and would wrongly pin schema 0. + ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder() + .snapshotId(7L) + .build(); + Assertions.assertEquals(-1L, snapshot.getSchemaId(), + "unset schemaId must default to -1 (unknown => latest schema)"); + } + + @Test + public void builderSetsSchemaId() { + ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder() + .schemaId(3L) + .build(); + // MUTATION: a builder that ignored schemaId (returned -1) makes this red. + Assertions.assertEquals(3L, snapshot.getSchemaId()); + } + + @Test + public void existingFieldsRoundTripUnaffectedBySchemaId() { + // WHY: the schemaId addition is purely additive; the other fields must carry through + // 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()); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/mvcc/ConnectorTimeTravelSpecTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/mvcc/ConnectorTimeTravelSpecTest.java new file mode 100644 index 00000000000000..d3009cc0fd40b5 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/mvcc/ConnectorTimeTravelSpecTest.java @@ -0,0 +1,163 @@ +// 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.mvcc; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Contracts for {@link ConnectorTimeTravelSpec}, the source-agnostic carrier fe-core uses + * to hand an explicit time-travel request to a connector for resolution. + * + *

WHY this matters: each factory must stamp exactly one {@link + * ConnectorTimeTravelSpec.Kind} and leave the irrelevant fields null/empty — the + * connector dispatches on {@code kind} and reads only the field that kind owns, so a wrong + * kind or a leaked field silently routes a query to the wrong time-travel branch. The map + * must be defensively copied and unmodifiable so a later mutation of the caller's map cannot + * change an already-resolved spec, and equals/hashCode must include every field so a spec + * cannot be confused with a same-named spec of a different kind/flag.

+ */ +public class ConnectorTimeTravelSpecTest { + + @Test + public void snapshotIdFactorySetsOnlySnapshotKind() { + ConnectorTimeTravelSpec spec = ConnectorTimeTravelSpec.snapshotId("42"); + // MUTATION: a factory that stamped TIMESTAMP/TAG here would route the digits down the + // wrong connector branch. + Assertions.assertEquals(ConnectorTimeTravelSpec.Kind.SNAPSHOT_ID, spec.getKind()); + Assertions.assertEquals("42", spec.getStringValue()); + Assertions.assertFalse(spec.isDigital(), "digital is only meaningful for TIMESTAMP"); + Assertions.assertTrue(spec.getIncrementalParams().isEmpty(), + "non-incremental specs carry no incremental params"); + } + + @Test + public void timestampFactoryCarriesDigitalFlagBothWays() { + // WHY: digital decides whether the connector treats the value as epoch-millis or as a + // datetime string to parse; flipping it changes the resolved instant. Lock both states. + ConnectorTimeTravelSpec epoch = ConnectorTimeTravelSpec.timestamp("1700000000000", true); + ConnectorTimeTravelSpec text = ConnectorTimeTravelSpec.timestamp("2024-01-01 00:00:00", false); + + Assertions.assertEquals(ConnectorTimeTravelSpec.Kind.TIMESTAMP, epoch.getKind()); + Assertions.assertTrue(epoch.isDigital(), "epoch-millis literal must be digital=true"); + Assertions.assertEquals(ConnectorTimeTravelSpec.Kind.TIMESTAMP, text.getKind()); + Assertions.assertFalse(text.isDigital(), "datetime string must be digital=false"); + } + + @Test + public void tagAndBranchFactoriesAreDistinctKinds() { + // WHY: tag and branch carry the same shape (a name in stringValue) but resolve via + // different SDK paths; if the factory collapsed them to one kind the connector would + // pick the wrong resolution path. + ConnectorTimeTravelSpec tag = ConnectorTimeTravelSpec.tag("v1"); + ConnectorTimeTravelSpec branch = ConnectorTimeTravelSpec.branch("v1"); + + Assertions.assertEquals(ConnectorTimeTravelSpec.Kind.TAG, tag.getKind()); + Assertions.assertEquals(ConnectorTimeTravelSpec.Kind.BRANCH, branch.getKind()); + Assertions.assertEquals("v1", tag.getStringValue()); + Assertions.assertEquals("v1", branch.getStringValue()); + // Same name, different kind => must not be equal (else a tag query reuses a branch result). + Assertions.assertNotEquals(tag, branch); + } + + @Test + public void versionRefFactoryIsDistinctFromTag() { + // WHY: a non-numeric FOR VERSION AS OF '' is VERSION_REF (the connector resolves it as a + // branch OR a tag), NOT the explicit @tag (TAG, tag-only). Same name shape, different kind: if the + // factory collapsed VERSION_REF into TAG, iceberg would reject a branch ref (regression H-7). + ConnectorTimeTravelSpec versionRef = ConnectorTimeTravelSpec.versionRef("v1"); + ConnectorTimeTravelSpec tag = ConnectorTimeTravelSpec.tag("v1"); + + Assertions.assertEquals(ConnectorTimeTravelSpec.Kind.VERSION_REF, versionRef.getKind()); + Assertions.assertEquals("v1", versionRef.getStringValue()); + Assertions.assertFalse(versionRef.isDigital(), "digital is only meaningful for TIMESTAMP"); + Assertions.assertTrue(versionRef.getIncrementalParams().isEmpty()); + // Same name, different kind => must not be equal (else a @tag query reuses a VERSION_REF result). + Assertions.assertNotEquals(versionRef, tag); + } + + @Test + public void incrementalFactoryHasNullStringValueAndParams() { + Map raw = new HashMap<>(); + raw.put("startSnapshotId", "1"); + raw.put("endSnapshotId", "5"); + ConnectorTimeTravelSpec spec = ConnectorTimeTravelSpec.incremental(raw); + + Assertions.assertEquals(ConnectorTimeTravelSpec.Kind.INCREMENTAL, spec.getKind()); + // MUTATION: stuffing a stringValue for INCREMENTAL would mislead a connector that keys + // off stringValue presence. + Assertions.assertNull(spec.getStringValue(), + "INCREMENTAL carries its args in the params map, not stringValue"); + Assertions.assertEquals(raw, spec.getIncrementalParams()); + } + + @Test + public void incrementalParamsAreDefensivelyCopiedAndUnmodifiable() { + Map raw = new HashMap<>(); + raw.put("startSnapshotId", "1"); + ConnectorTimeTravelSpec spec = ConnectorTimeTravelSpec.incremental(raw); + + // WHY (Rule 9): a spec is a resolved request; mutating the caller's source map afterwards + // must NOT retroactively change the spec the engine already dispatched on. + // MUTATION: storing the map by reference (no copy) makes this assertion red. + raw.put("endSnapshotId", "5"); + Assertions.assertFalse(spec.getIncrementalParams().containsKey("endSnapshotId"), + "spec must snapshot the params at construction, not alias the caller's map"); + + Assertions.assertThrows(UnsupportedOperationException.class, + () -> spec.getIncrementalParams().put("x", "y"), + "exposed params map must be unmodifiable"); + } + + @Test + public void equalsAndHashCodeIncludeAllFields() { + // WHY: two specs that differ in digital alone (or kind alone) are genuinely different + // time-travel targets; equals/hashCode must separate them or a cache could serve the wrong + // pinned snapshot. + ConnectorTimeTravelSpec a = ConnectorTimeTravelSpec.timestamp("100", true); + ConnectorTimeTravelSpec b = ConnectorTimeTravelSpec.timestamp("100", true); + ConnectorTimeTravelSpec digitalFlipped = ConnectorTimeTravelSpec.timestamp("100", false); + + Assertions.assertEquals(a, b); + Assertions.assertEquals(a.hashCode(), b.hashCode()); + // MUTATION: dropping `digital ==` from equals makes this red. + Assertions.assertNotEquals(a, digitalFlipped, + "specs differing only by the digital flag must not be equal"); + } + + @Test + public void factoriesRejectNullMeaningfulArgs() { + // WHY: a null where a snapshot id / name / params map is required is a programming error in + // the fe-core extractor; fail loud at construction rather than NPE deep in the connector. + Assertions.assertThrows(NullPointerException.class, + () -> ConnectorTimeTravelSpec.snapshotId(null)); + Assertions.assertThrows(NullPointerException.class, + () -> ConnectorTimeTravelSpec.timestamp(null, true)); + Assertions.assertThrows(NullPointerException.class, + () -> ConnectorTimeTravelSpec.versionRef(null)); + Assertions.assertThrows(NullPointerException.class, + () -> ConnectorTimeTravelSpec.tag(null)); + Assertions.assertThrows(NullPointerException.class, + () -> ConnectorTimeTravelSpec.branch(null)); + Assertions.assertThrows(NullPointerException.class, + () -> ConnectorTimeTravelSpec.incremental(null)); + } +} 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 new file mode 100644 index 00000000000000..af45adba1980c5 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/procedure/ConnectorProcedureOpsDefaultsTest.java @@ -0,0 +1,211 @@ +// 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; + +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.ConnectorType; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Pins the {@link Connector#getProcedureOps()} default and the {@link ConnectorProcedureResult} shape. + * + *

WHY this matters: P6.4 adds the {@code getProcedureOps()} accessor so the engine can route + * {@code ALTER TABLE EXECUTE} to a connector. The default MUST be {@code null} so every connector that + * declares no table procedures (jdbc / es / maxcompute / paimon / trino) inherits the no-op and stays + * behaviorally unchanged — only iceberg overrides it. A non-null default would make the engine attempt a + * procedure dispatch on connectors that have none.

+ */ +public class ConnectorProcedureOpsDefaultsTest { + + /** Minimal connector overriding only the single mandatory method, to read the inherited defaults. */ + private static final class BareConnector implements Connector { + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + return null; + } + } + + /** Minimal {@link ConnectorProcedureOps} overriding only the mandatory methods, to read the defaults. */ + private static final class BareProcedureOps implements ConnectorProcedureOps { + @Override + public List getSupportedProcedures() { + return Collections.emptyList(); + } + + @Override + public ConnectorProcedureResult execute(ConnectorSession session, ConnectorTableHandle table, + String procedureName, Map properties, ConnectorPredicate whereCondition, + List partitionNames) { + return null; + } + } + + @Test + public void getProcedureOpsDefaultsToNull() { + Assertions.assertNull(new BareConnector().getProcedureOps(), + "a connector that declares no table procedures must inherit a null getProcedureOps() so " + + "ALTER TABLE EXECUTE is never dispatched to it (jdbc/es/maxcompute/paimon/trino " + + "stay behaviorally unchanged)"); + } + + @Test + public void getProcedureOpsPerHandleDefaultsToNoArg() { + // A single-format connector overrides only the no-arg getter; the per-handle default must delegate to it + // (NOT return null), so every existing connector routes ALTER TABLE EXECUTE unchanged after the seam is + // added. MUTATION: making the default return null instead of getProcedureOps() -> non-null assert red. + ConnectorProcedureOps only = new BareProcedureOps(); + Connector connector = new Connector() { + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + return null; + } + + @Override + public ConnectorProcedureOps getProcedureOps() { + return only; + } + }; + + Assertions.assertSame(only, connector.getProcedureOps(handle()), + "the per-handle default must delegate to the connector-level no-arg procedure ops"); + } + + @Test + public void getProcedureOpsPerHandleStaysNullWhenConnectorHasNoProcedures() { + // A connector with no procedures (no-arg default returns null) must keep returning null through the + // per-handle seam, so ALTER TABLE EXECUTE is never dispatched to it. + Assertions.assertNull(new BareConnector().getProcedureOps(handle()), + "with no procedure ops at all the per-handle seam stays null"); + } + + @Test + public void getProcedureOpsPerHandleOverrideSelectsPerHandle() { + // A heterogeneous gateway overrides the per-handle seam and returns the SIBLING's ops for a foreign + // handle while a plain (hive) handle keeps the connector-level null, and must NOT fall back to the no-arg + // getter once it has a per-handle answer. MUTATION: keying the override on the no-arg getter (ignoring + // the handle) -> the foreign-handle assert reads null -> red. + ConnectorTableHandle foreignHandle = handle(); + ConnectorProcedureOps siblingOps = new BareProcedureOps(); + Connector gateway = new Connector() { + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + return null; + } + + @Override + public ConnectorProcedureOps getProcedureOps() { + return null; + } + + @Override + public ConnectorProcedureOps getProcedureOps(ConnectorTableHandle handle) { + return handle == foreignHandle ? siblingOps : getProcedureOps(); + } + }; + + Assertions.assertSame(siblingOps, gateway.getProcedureOps(foreignHandle), + "a gateway routes the foreign (iceberg-on-HMS) handle to the sibling's procedure ops"); + Assertions.assertNull(gateway.getProcedureOps(handle()), + "a non-foreign (plain-hive) handle keeps the connector-level null (no procedures)"); + } + + private static ConnectorTableHandle handle() { + return new ConnectorTableHandle() { + }; + } + + @Test + public void getExecutionModeDefaultsToSingleCall() { + // A connector that declares only synchronous procedures inherits SINGLE_CALL for every name, so the + // engine never attempts distributed orchestration on a procedure that has none. Only a connector with + // a genuinely distributed procedure (iceberg rewrite_data_files) overrides this. + ConnectorProcedureOps ops = new BareProcedureOps(); + Assertions.assertEquals(ProcedureExecutionMode.SINGLE_CALL, + ops.getExecutionMode("any_procedure"), + "the default execution mode must be SINGLE_CALL so the engine routes through execute()"); + Assertions.assertEquals(ProcedureExecutionMode.SINGLE_CALL, + ops.getExecutionMode("rewrite_data_files"), + "a connector that does not override getExecutionMode never reports DISTRIBUTED, even for a " + + "name another connector treats as distributed"); + } + + @Test + public void planRewriteDefaultsToUnsupported() { + ConnectorProcedureOps ops = new BareProcedureOps(); + // planRewrite is only meaningful for a DISTRIBUTED procedure; a connector that declares none must never + // have it called (the engine checks getExecutionMode first). The default FAILS LOUD rather than + // silently returning an empty plan (which would make a misrouted rewrite a no-op). MUTATION: defaulting + // to `return Collections.emptyList()` -> no throw -> red. + Assertions.assertThrows(UnsupportedOperationException.class, + () -> ops.planRewrite(null, null, "rewrite_data_files", + Collections.emptyMap(), null, Collections.emptyList())); + } + + @Test + public void rewriteGroupExposesPathsAndStats() { + ConnectorRewriteGroup g = new ConnectorRewriteGroup( + Collections.singleton("oss://b/db/t1/f1.parquet"), 3, 4096L, 2); + // The engine reads the raw paths (to scope each group's scan) and the per-group counts (to sum into the + // result row), so all four must be carried verbatim. MUTATION: any getter returning a wrong field -> red. + Assertions.assertEquals(Collections.singleton("oss://b/db/t1/f1.parquet"), g.getDataFilePaths()); + Assertions.assertEquals(3, g.getDataFileCount()); + Assertions.assertEquals(4096L, g.getTotalSizeBytes()); + Assertions.assertEquals(2, g.getDeleteFileCount()); + } + + @Test + public void rewriteGroupRejectsNullPaths() { + // Fail-loud construction: the engine scopes the scan by these paths, so a null set is a programming + // error, not an empty scope. + Assertions.assertThrows(NullPointerException.class, + () -> new ConnectorRewriteGroup(null, 0, 0L, 0)); + } + + @Test + public void procedureResultExposesSchemaAndRows() { + ConnectorColumn col = new ConnectorColumn("current_snapshot_id", ConnectorType.of("BIGINT"), + null, true, null); + List> rows = Collections.singletonList(Collections.singletonList("42")); + ConnectorProcedureResult result = new ConnectorProcedureResult(Collections.singletonList(col), rows); + + Assertions.assertEquals(1, result.getResultSchema().size()); + Assertions.assertEquals("current_snapshot_id", result.getResultSchema().get(0).getName()); + Assertions.assertEquals(rows, result.getRows(), + "rows are returned to the engine unchanged for result-set wrapping"); + } + + @Test + public void procedureResultRejectsNullArgs() { + // Fail-loud construction: the engine builds the result set from non-null schema + rows. + Assertions.assertThrows(NullPointerException.class, + () -> new ConnectorProcedureResult(null, Collections.emptyList())); + Assertions.assertThrows(NullPointerException.class, + () -> new ConnectorProcedureResult(Collections.emptyList(), null)); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/pushdown/ConnectorPredicateTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/pushdown/ConnectorPredicateTest.java new file mode 100644 index 00000000000000..f8948fe9102d64 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/pushdown/ConnectorPredicateTest.java @@ -0,0 +1,43 @@ +// 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; + +/** + * Pins the neutral O5-2 write-constraint carrier {@link ConnectorPredicate}: it is a transparent holder over + * the engine-neutral {@link ConnectorExpression}, with a nullable inner expression (the plan may yield none). + */ +public class ConnectorPredicateTest { + + @Test + public void exposesWrappedExpression() { + ConnectorColumnRef ref = new ConnectorColumnRef("region", ConnectorType.of("VARCHAR")); + ConnectorPredicate predicate = new ConnectorPredicate(ref); + Assertions.assertSame(ref, predicate.getExpression()); + } + + @Test + public void allowsNullExpression() { + Assertions.assertNull(new ConnectorPredicate(null).getExpression(), + "a plan with no target-only conjunct yields a predicate with a null inner expression"); + } +} 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 new file mode 100644 index 00000000000000..4bedcf01bf1647 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProviderBatchScanTest.java @@ -0,0 +1,112 @@ +// 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.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * FIX-BATCH-MODE-SPLIT (P4-T06e / NG-7) — guards the two additive SPI defaults on + * {@link ConnectorScanPlanProvider}: {@code supportsBatchScan} and {@code planScanForPartitionBatch}. + * + *

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 + * correct per-batch behaviour without overriding it.

+ */ +public class ConnectorScanPlanProviderBatchScanTest { + + /** Records the partition list / limit 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; + + @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; + return MARKER; + } + } + + @Test + public void testSupportsBatchScanDefaultsFalse() { + // Default MUST be false: any connector that does not opt in stays on the synchronous path. + ConnectorScanPlanProvider provider = new RecordingProvider(); + Assertions.assertFalse(provider.supportsBatchScan(null, null)); + } + + @Test + public void testStreamingSplitEstimateDefaultsNegative() { + // FIX-M3: default MUST be < 0 so no connector silently enters file-count streaming without opting in + // (the engine treats < 0 as "stay on the synchronous planScan path"). + ConnectorScanPlanProvider provider = new RecordingProvider(); + Assertions.assertTrue(provider.streamingSplitEstimate(null, null, Optional.empty(), false) < 0); + } + + @Test + public void testStreamSplitsDefaultThrows() { + // FIX-M3: the default producer MUST fail loud — it is only reachable if a connector returns a + // non-negative streamingSplitEstimate without implementing streamSplits, which is a connector bug. + ConnectorScanPlanProvider provider = new RecordingProvider(); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> provider.streamSplits(null, null, Collections.emptyList(), Optional.empty(), -1L)); + } + + @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. + RecordingProvider provider = new RecordingProvider(); + List batch = Arrays.asList("pt=1", "pt=2"); + + List result = + provider.planScanForPartitionBatch(null, null, Collections.emptyList(), + Optional.empty(), -1L, 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); + } +} 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 new file mode 100644 index 00000000000000..1862224b47f69f --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProviderCompressTypeTest.java @@ -0,0 +1,61 @@ +// 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.apache.doris.thrift.TFileCompressType; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * Guards the additive {@code adjustFileCompressType} SPI default on {@link ConnectorScanPlanProvider}. + * + *

WHY: the default MUST be identity so no connector's inferred file compression type is silently altered + * — only a connector that explicitly opts in (hive/hudi remap {@code LZ4FRAME -> LZ4BLOCK}) changes it. If the + * default ever stopped being identity, every non-opting connector's reads would ship a different compress type + * to BE. This is the zero-break guard for es/jdbc/paimon/iceberg/trino/maxcompute.

+ */ +public class ConnectorScanPlanProviderCompressTypeTest { + + /** Bare provider: only the abstract 4-arg 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) { + return Collections.emptyList(); + } + } + + @Test + public void testAdjustFileCompressTypeDefaultsToIdentity() { + ConnectorScanPlanProvider provider = new BareProvider(); + // The default must NOT touch any type — including LZ4FRAME, the one hive/hudi opt in to remap. + for (TFileCompressType type : TFileCompressType.values()) { + Assertions.assertEquals(type, provider.adjustFileCompressType(type), + "default adjustFileCompressType must be identity for " + type); + } + } +} 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 new file mode 100644 index 00000000000000..05d625512ba0cc --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanRangeWeightDefaultsTest.java @@ -0,0 +1,55 @@ +// 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; + +import java.util.Collections; +import java.util.Map; + +/** + * FIX-A1: a {@link ConnectorScanRange} that does not override the split-weight getters must inherit the + * {@code -1} "not provided" sentinel, so the engine ({@code PluginDrivenSplit}) leaves the FileSplit + * scheduling fields null and keeps {@code SplitWeight.standard()} (the no-regression guarantee for + * connectors with no size-based weight model: jdbc / es / trino / maxcompute). + */ +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(); + } + }; + + // MUTATION: a 0 default would pass PluginDrivenSplit's weight>=0 gate and (with a target) flip + // these connectors to proportional weighting -> a behavior change for every non-weighting connector. + Assertions.assertEquals(-1L, range.getSelfSplitWeight(), + "getSelfSplitWeight() default must be the -1 sentinel, not 0"); + Assertions.assertEquals(-1L, range.getTargetSplitSize(), + "getTargetSplitSize() default must be the -1 sentinel, not 0"); + } +} 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 new file mode 100644 index 00000000000000..0e9cb532985bae --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/write/ConnectorWritePlanProviderDefaultsTest.java @@ -0,0 +1,54 @@ +// 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.write; + +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.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * 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.

+ */ +public class ConnectorWritePlanProviderDefaultsTest { + + /** Minimal provider that overrides only the mandatory {@code planWrite}. */ + private static final class BareWritePlanProvider implements ConnectorWritePlanProvider { + @Override + public ConnectorSinkPlan planWrite(ConnectorSession session, ConnectorWriteHandle handle) { + return null; + } + } + + @Test + public void writeSortColumnsDefaultsToNull() { + ConnectorTableHandle anyTable = new ConnectorTableHandle() { + }; + Assertions.assertNull(new BareWritePlanProvider().getWriteSortColumns(null, anyTable), + "a connector that declares no write sort order must return null (not an empty list, which " + + "would signal a present-but-empty sort order) so its sink output stays " + + "byte-identical with no engine-built TSortInfo"); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/write/ConnectorWriteSortColumnTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/write/ConnectorWriteSortColumnTest.java new file mode 100644 index 00000000000000..9aaaf9ffff02ee --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/write/ConnectorWriteSortColumnTest.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.write; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Pins the {@link ConnectorWriteSortColumn} carrier shape. + * + *

WHY this matters: it is the engine-neutral vocabulary by which a connector declares its + * write-side sort (T06, iceberg {@code WRITE ORDERED BY}). {@code columnIndex} is a position into the + * sink's full-schema output (the same indexing {@code PhysicalConnectorTableSink} uses for write + * distribution), so the generic translator can resolve it to a bound slot and build a {@code TSortInfo} + * without knowing any connector's sort dialect. The three fields map 1:1 onto the legacy iceberg sink's + * {@code orderingExprs}/{@code isAscOrder}/{@code isNullsFirst} triple.

+ */ +public class ConnectorWriteSortColumnTest { + + @Test + public void carriesColumnIndexAscAndNullsFirst() { + ConnectorWriteSortColumn col = new ConnectorWriteSortColumn(3, true, false); + Assertions.assertEquals(3, col.getColumnIndex()); + Assertions.assertTrue(col.isAsc()); + Assertions.assertFalse(col.isNullsFirst()); + } + + @Test + public void carriesDescendingNullsFirst() { + ConnectorWriteSortColumn col = new ConnectorWriteSortColumn(0, false, true); + Assertions.assertEquals(0, col.getColumnIndex()); + Assertions.assertFalse(col.isAsc()); + Assertions.assertTrue(col.isNullsFirst()); + } +} diff --git a/fe/fe-connector/fe-connector-cache/pom.xml b/fe/fe-connector/fe-connector-cache/pom.xml new file mode 100644 index 00000000000000..f9ba332609f91b --- /dev/null +++ b/fe/fe-connector/fe-connector-cache/pom.xml @@ -0,0 +1,66 @@ + + + + 4.0.0 + + + org.apache.doris + fe-connector + ${revision} + ../pom.xml + + + fe-connector-cache + jar + Doris FE Connector Cache Framework + + Connector-side meta-cache framework (CacheSpec + MetaCacheEntry + CacheFactory + MetaCacheEntryStats), + an INDEPENDENT copy of fe-core's `org.apache.doris.datasource.metacache` framework re-homed under the + `org.apache.doris.connector.*` prefix so the connector plugins can reuse it (they cannot import fe-core). + fe-core keeps its own copy untouched; the two live side-by-side until every connector has migrated, then + the fe-core copy is retired. fe-core does NOT depend on this module. + + This module is bundled into each connector plugin zip (child-first), so it uses the plugin's own bundled + Caffeine at runtime; Caffeine is therefore `provided` here (compiled against, never packaged by this + module). The framework's public API (MetaCacheEntry) is Caffeine-free, and fe-core and the connectors + never share a cache object across the classloader boundary, so no Caffeine type crosses and there is no + split-brain. Two knobs fe-core reads from static Config are constructor-injected here. + + + + + com.github.ben-manes.caffeine + caffeine + 2.9.3 + provided + + + org.junit.jupiter + junit-jupiter + test + + + + + doris-fe-connector-cache + + diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheFactory.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheFactory.java new file mode 100644 index 00000000000000..03e4126e9911be --- /dev/null +++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheFactory.java @@ -0,0 +1,127 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.cache; + +import com.github.benmanes.caffeine.cache.AsyncCacheLoader; +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; +import com.github.benmanes.caffeine.cache.CacheLoader; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.RemovalListener; +import com.github.benmanes.caffeine.cache.Ticker; + +import java.time.Duration; +import java.util.OptionalLong; +import java.util.concurrent.ExecutorService; + +/** + * Factory to create Caffeine cache. + * + *

Connector-side copy of fe-core {@code org.apache.doris.common.CacheFactory} (independent-copy meta-cache + * migration): connector plugins cannot import fe-core, so the framework is duplicated under + * {@code org.apache.doris.connector.cache}. This type is framework-internal — its public methods RETURN + * Caffeine types, which must never cross to connector (child-first) code; connectors only touch the + * Caffeine-free {@link MetaCacheEntry} API. Keep behaviourally in sync with the fe-core original. + * + *

This class is used to create Caffeine cache with specified parameters. + * It is used to create both sync and async cache. + * The cache is created with the following parameters: + * - expireAfterAccessSec: The duration after which the cache entries will expire. + * - refreshAfterWriteSec: The duration after which the cache entries will be refreshed. + * - maxSize: The maximum size of the cache. + * - enableStats: Whether to enable stats for the cache. + * - ticker: The ticker to use for the cache. + */ +public class CacheFactory { + + private OptionalLong expireAfterAccessSec; + private OptionalLong refreshAfterWriteSec; + private long maxSize; + private boolean enableStats; + // Ticker is used to provide a time source for the cache. + // Only used for test, to provide a fake time source. + // If not provided, the system time is used. + private Ticker ticker; + + public CacheFactory( + OptionalLong expireAfterAccessSec, + OptionalLong refreshAfterWriteSec, + long maxSize, + boolean enableStats, + Ticker ticker) { + this.expireAfterAccessSec = expireAfterAccessSec; + this.refreshAfterWriteSec = refreshAfterWriteSec; + this.maxSize = maxSize; + this.enableStats = enableStats; + this.ticker = ticker; + } + + // Build a loading cache, without executor, it will use fork-join pool for refresh + public LoadingCache buildCache(CacheLoader cacheLoader) { + Caffeine builder = buildWithParams(); + return builder.build(cacheLoader); + } + + // Build a loading cache, with executor, it will use given executor for refresh + public LoadingCache buildCache(CacheLoader cacheLoader, + ExecutorService executor) { + Caffeine builder = buildWithParams(); + builder.executor(executor); + return builder.build(cacheLoader); + } + + // Build cache with sync removal listener to prevent deadlock when listener calls invalidateAll() + public LoadingCache buildCacheWithSyncRemovalListener(CacheLoader cacheLoader, + RemovalListener removalListener) { + Caffeine builder = buildWithParams(); + if (removalListener != null) { + builder.removalListener(removalListener); + } + builder.executor(Runnable::run); // Sync execution to avoid thread pool deadlock + return builder.build(cacheLoader); + } + + // Build an async loading cache + public AsyncLoadingCache buildAsyncCache(AsyncCacheLoader cacheLoader, + ExecutorService executor) { + Caffeine builder = buildWithParams(); + builder.executor(executor); + return builder.buildAsync(cacheLoader); + } + + private Caffeine buildWithParams() { + Caffeine builder = Caffeine.newBuilder(); + builder.maximumSize(maxSize); + + if (expireAfterAccessSec.isPresent()) { + builder.expireAfterAccess(Duration.ofSeconds(expireAfterAccessSec.getAsLong())); + } + if (refreshAfterWriteSec.isPresent()) { + builder.refreshAfterWrite(Duration.ofSeconds(refreshAfterWriteSec.getAsLong())); + } + + if (enableStats) { + builder.recordStats(); + } + + if (ticker != null) { + builder.ticker(ticker); + } + return builder; + } +} diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheSpec.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheSpec.java new file mode 100644 index 00000000000000..47cd0596b46096 --- /dev/null +++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheSpec.java @@ -0,0 +1,311 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.cache; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.OptionalLong; + +/** + * Common cache specification for external metadata caches. + * + *

Connector-side copy of the meta-cache property model (independent-copy meta-cache migration). fe-core is + * NOT changed: it keeps its own {@code org.apache.doris.datasource.metacache.CacheSpec}; this is a separate + * class under {@code org.apache.doris.connector.*} used only by the connector plugins. Although that prefix is + * parent-first, fe-core does not depend on this module, so the class resolves parent → miss → CHILD and is + * child-loaded per plugin — fe-core and the plugins do NOT share one {@code Class} identity. It carries no + * third-party dependency (JDK only) and never crosses the fe-core↔connector boundary as an object (only its + * {@code IllegalArgumentException}, a JDK type, crosses), so it is safe on both classpaths. + * + *

The {@code check*Property} validators throw {@link IllegalArgumentException} (fe-core's + * {@code PluginDrivenExternalCatalog.checkProperties} re-wraps it into a {@code DdlException} verbatim; the + * legacy fe-core catalogs that still call these validators declare {@code throws DdlException} but no longer + * need it). The user-facing message text is identical to the legacy one ({@code "... is wrong, value is ..."}). + * + *

Semantics: + *

    + *
  • enable=false disables cache
  • + *
  • ttlSecond=0 disables cache, ttlSecond=-1 means no expiration
  • + *
  • capacity=0 disables cache; capacity is count-based
  • + *
+ */ +public final class CacheSpec { + public static final long CACHE_NO_TTL = -1L; + public static final long CACHE_TTL_DISABLE_CACHE = 0L; + private static final String META_CACHE_PREFIX = "meta.cache."; + private static final String KEY_ENABLE = ".enable"; + private static final String KEY_TTL_SECOND = ".ttl-second"; + private static final String KEY_CAPACITY = ".capacity"; + + private final boolean enable; + private final long ttlSecond; + private final long capacity; + + private CacheSpec(boolean enable, long ttlSecond, long capacity) { + this.enable = enable; + this.ttlSecond = ttlSecond; + this.capacity = capacity; + } + + public static CacheSpec of(boolean enable, long ttlSecond, long capacity) { + return new CacheSpec(enable, ttlSecond, capacity); + } + + public static PropertySpec.Builder propertySpecBuilder() { + return new PropertySpec.Builder(); + } + + public static CacheSpec fromProperties(Map properties, + String enableKey, boolean defaultEnable, + String ttlKey, long defaultTtlSecond, + String capacityKey, long defaultCapacity) { + return fromProperties(properties, propertySpecBuilder() + .enable(enableKey, defaultEnable) + .ttl(ttlKey, defaultTtlSecond) + .capacity(capacityKey, defaultCapacity) + .build()); + } + + public static CacheSpec fromProperties(Map properties, PropertySpec propertySpec) { + boolean enable = getBooleanProperty(properties, propertySpec.getEnableKey(), propertySpec.isDefaultEnable()); + long ttlSecond = getLongProperty(properties, propertySpec.getTtlKey(), propertySpec.getDefaultTtlSecond()); + long capacity = getLongProperty(properties, propertySpec.getCapacityKey(), propertySpec.getDefaultCapacity()); + return of(enable, ttlSecond, capacity); + } + + /** + * Build a cache spec from catalog properties by standard external meta cache key pattern: + * meta.cache.<engine>.<entry>.(enable|ttl-second|capacity) + */ + public static CacheSpec fromProperties(Map properties, + String engine, String entryName, CacheSpec defaultSpec) { + return fromProperties(properties, metaCachePropertySpec(engine, entryName, defaultSpec)); + } + + public static PropertySpec metaCachePropertySpec(String engine, String entryName, CacheSpec defaultSpec) { + String cacheKeyPrefix = META_CACHE_PREFIX + engine + "." + entryName; + return propertySpecBuilder() + .enable(cacheKeyPrefix + KEY_ENABLE, defaultSpec.isEnable()) + .ttl(cacheKeyPrefix + KEY_TTL_SECOND, defaultSpec.getTtlSecond()) + .capacity(cacheKeyPrefix + KEY_CAPACITY, defaultSpec.getCapacity()) + .build(); + } + + /** + * Apply compatibility key mapping before cache spec parsing. + * + *

Map format: {@code legacyKey -> newKey}. If both keys exist, new key wins. + */ + public static Map applyCompatibilityMap( + Map properties, Map compatibilityMap) { + Map mapped = new HashMap<>(); + if (properties != null) { + mapped.putAll(properties); + } + if (compatibilityMap == null || compatibilityMap.isEmpty()) { + return mapped; + } + compatibilityMap.forEach((legacyKey, newKey) -> { + if (legacyKey == null || newKey == null || legacyKey.equals(newKey)) { + return; + } + if (!mapped.containsKey(newKey) && mapped.containsKey(legacyKey)) { + mapped.put(newKey, mapped.get(legacyKey)); + } + }); + return mapped; + } + + public static void checkBooleanProperty(String value, String key) { + if (value == null) { + return; + } + if (!value.equalsIgnoreCase("true") && !value.equalsIgnoreCase("false")) { + throw new IllegalArgumentException("The parameter " + key + " is wrong, value is " + value); + } + } + + public static void checkLongProperty(String value, long minValue, String key) { + if (value == null) { + return; + } + long parsed; + try { + parsed = Long.parseLong(value); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("The parameter " + key + " is wrong, value is " + value); + } + if (parsed < minValue) { + throw new IllegalArgumentException("The parameter " + key + " is wrong, value is " + value); + } + } + + public static boolean isCacheEnabled(boolean enable, long ttlSecond, long capacity) { + return enable && ttlSecond != 0 && capacity != 0; + } + + /** + * Build standard external meta cache key prefix for one engine. + * Example: {@code meta.cache.iceberg.} + */ + public static String metaCacheKeyPrefix(String engine) { + return META_CACHE_PREFIX + engine + "."; + } + + /** + * Build the standard external meta cache TTL key for one engine+entry. + * Example: {@code meta.cache.hive.file.ttl-second}. + * + *

Used to translate a legacy catalog TTL knob (e.g. {@code file.meta.cache.ttl-second}) into the + * namespaced key a cache actually reads, via {@link #applyCompatibilityMap}. + */ + public static String metaCacheTtlKey(String engine, String entryName) { + return META_CACHE_PREFIX + engine + "." + entryName + KEY_TTL_SECOND; + } + + /** + * Returns true when the given property key belongs to one engine's meta cache namespace. + */ + public static boolean isMetaCacheKeyForEngine(String key, String engine) { + return key != null && engine != null && key.startsWith(metaCacheKeyPrefix(engine)); + } + + /** + * Convert ttlSecond to OptionalLong for CacheFactory. + * ttlSecond=-1 means no expiration; ttlSecond=0 disables cache. + */ + public static OptionalLong toExpireAfterAccess(long ttlSecond) { + if (ttlSecond == CACHE_NO_TTL) { + return OptionalLong.empty(); + } + return OptionalLong.of(Math.max(ttlSecond, CACHE_TTL_DISABLE_CACHE)); + } + + private static boolean getBooleanProperty(Map properties, String key, boolean defaultValue) { + String value = properties.get(key); + if (value == null) { + return defaultValue; + } + return Boolean.parseBoolean(value); + } + + private static long getLongProperty(Map properties, String key, long defaultValue) { + String value = properties.get(key); + if (value == null) { + return defaultValue; + } + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + public boolean isEnable() { + return enable; + } + + public long getTtlSecond() { + return ttlSecond; + } + + public long getCapacity() { + return capacity; + } + + public static final class PropertySpec { + private final String enableKey; + private final boolean defaultEnable; + private final String ttlKey; + private final long defaultTtlSecond; + private final String capacityKey; + private final long defaultCapacity; + + private PropertySpec(String enableKey, boolean defaultEnable, String ttlKey, + long defaultTtlSecond, String capacityKey, long defaultCapacity) { + this.enableKey = enableKey; + this.defaultEnable = defaultEnable; + this.ttlKey = ttlKey; + this.defaultTtlSecond = defaultTtlSecond; + this.capacityKey = capacityKey; + this.defaultCapacity = defaultCapacity; + } + + public String getEnableKey() { + return enableKey; + } + + public boolean isDefaultEnable() { + return defaultEnable; + } + + public String getTtlKey() { + return ttlKey; + } + + public long getDefaultTtlSecond() { + return defaultTtlSecond; + } + + public String getCapacityKey() { + return capacityKey; + } + + public long getDefaultCapacity() { + return defaultCapacity; + } + + public static final class Builder { + private String enableKey; + private boolean defaultEnable; + private String ttlKey; + private long defaultTtlSecond; + private String capacityKey; + private long defaultCapacity; + + public Builder enable(String key, boolean defaultValue) { + this.enableKey = key; + this.defaultEnable = defaultValue; + return this; + } + + public Builder ttl(String key, long defaultValue) { + this.ttlKey = key; + this.defaultTtlSecond = defaultValue; + return this; + } + + public Builder capacity(String key, long defaultValue) { + this.capacityKey = key; + this.defaultCapacity = defaultValue; + return this; + } + + public PropertySpec build() { + return new PropertySpec( + Objects.requireNonNull(enableKey, "enableKey is required"), + defaultEnable, + Objects.requireNonNull(ttlKey, "ttlKey is required"), + defaultTtlSecond, + Objects.requireNonNull(capacityKey, "capacityKey is required"), + defaultCapacity); + } + } + } +} diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheEntry.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheEntry.java new file mode 100644 index 00000000000000..425697ef9b1098 --- /dev/null +++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheEntry.java @@ -0,0 +1,345 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.cache; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.stats.CacheStats; + +import java.util.Objects; +import java.util.OptionalLong; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.function.Predicate; + +/** + * Unified cache entry abstraction. + * It stores one logical cache dataset and provides optional lazy loading, + * key/predicate/full invalidation, and lightweight runtime stats. + * + *

Connector-side copy of fe-core {@code org.apache.doris.datasource.metacache.MetaCacheEntry} + * (independent-copy meta-cache migration): connector plugins cannot import fe-core, so the framework is + * duplicated under {@code org.apache.doris.connector.cache}. The public API is Caffeine-free (Caffeine is + * encapsulated), so instances are safe to hold in connector (child-first) code. Two knobs that fe-core reads + * from static {@code Config} are here supplied by the connector via the constructor + * ({@code refreshAfterWriteSeconds}, {@code manualMissLoadEnabled}); otherwise keep in sync with fe-core. + */ +public class MetaCacheEntry { + // Use striped locks to deduplicate slow external loads without managing per-key lock lifecycle. + private static final int LOAD_LOCK_STRIPES = 128; + + private final String name; + private final Function loader; + private final CacheSpec cacheSpec; + private final boolean effectiveEnabled; + private final boolean autoRefresh; + // fe-core reads these two from Config; the connector copy has no fe-core Config, so they are injected. + // refreshAfterWriteSeconds is already in seconds (fe-core computes Config.*_minutes * 60 at its call site). + private final long refreshAfterWriteSeconds; + private final boolean manualMissLoadEnabled; + // Keep the loading cache for refreshAfterWrite and the legacy sync-load path when the feature is disabled. + private final LoadingCache loadingData; + // Use the plain cache view for manual miss load so slow I/O does not happen in Caffeine's sync load path. + private final Cache data; + // Protect one key stripe at a time to deduplicate concurrent miss loads with bounded lock count. + private final Object[] loadLocks = new Object[LOAD_LOCK_STRIPES]; + private final AtomicLong invalidateCount = new AtomicLong(0); + // Bump generation before invalidation so in-flight manual loads do not repopulate stale values. + private final AtomicLong invalidateGeneration = new AtomicLong(0); + // Track load statistics outside Caffeine because manual miss loads bypass the built-in load counters. + private final AtomicLong loadSuccessCount = new AtomicLong(0); + private final AtomicLong loadFailureCount = new AtomicLong(0); + private final AtomicLong totalLoadTimeNanos = new AtomicLong(0); + private final AtomicLong lastLoadSuccessTimeMs = new AtomicLong(-1L); + private final AtomicLong lastLoadFailureTimeMs = new AtomicLong(-1L); + private final AtomicReference lastError = new AtomicReference<>(""); + + /** + * Convenience constructor for the common connector case: a loader-backed entry with no auto-refresh and no + * manual miss load (Caffeine's sync load path). Use the full constructor for contextual-only entries, + * auto-refresh, or manual miss load. + */ + public MetaCacheEntry(String name, Function loader, CacheSpec cacheSpec, ExecutorService refreshExecutor) { + this(name, loader, cacheSpec, refreshExecutor, false, false, 0L, false); + } + + public MetaCacheEntry(String name, Function loader, CacheSpec cacheSpec, + ExecutorService refreshExecutor, boolean autoRefresh, boolean contextualOnly, + long refreshAfterWriteSeconds, boolean manualMissLoadEnabled) { + this.name = name; + if (contextualOnly) { + if (loader != null) { + throw new IllegalArgumentException("contextual-only entry loader must be null"); + } + if (autoRefresh) { + throw new IllegalArgumentException("contextual-only entry can not enable auto refresh"); + } + } else { + Objects.requireNonNull(loader, "loader can not be null"); + } + this.loader = loader; + this.cacheSpec = Objects.requireNonNull(cacheSpec, "cacheSpec can not be null"); + this.autoRefresh = autoRefresh; + this.refreshAfterWriteSeconds = refreshAfterWriteSeconds; + this.manualMissLoadEnabled = manualMissLoadEnabled; + Objects.requireNonNull(refreshExecutor, "refreshExecutor can not be null"); + this.effectiveEnabled = CacheSpec.isCacheEnabled( + this.cacheSpec.isEnable(), this.cacheSpec.getTtlSecond(), this.cacheSpec.getCapacity()); + OptionalLong expireAfterAccessSec = + effectiveEnabled ? CacheSpec.toExpireAfterAccess(this.cacheSpec.getTtlSecond()) : OptionalLong.empty(); + OptionalLong refreshAfterWriteSec = + effectiveEnabled && autoRefresh + ? OptionalLong.of(refreshAfterWriteSeconds) + : OptionalLong.empty(); + long maxSize = effectiveEnabled ? this.cacheSpec.getCapacity() : 0L; + CacheFactory cacheFactory = new CacheFactory( + expireAfterAccessSec, + refreshAfterWriteSec, + maxSize, + true, + null); + this.loadingData = cacheFactory.buildCache(this::loadFromDefaultLoader, refreshExecutor); + this.data = loadingData; + // Initialize striped locks eagerly to keep the hot path allocation-free. + for (int i = 0; i < loadLocks.length; i++) { + loadLocks[i] = new Object(); + } + } + + public String name() { + return name; + } + + public V get(K key) { + if (!isManualMissLoadEnabled()) { + return loadingData.get(key); + } + return getWithManualLoad(key, this::applyDefaultLoader); + } + + public V get(K key, Function missLoader) { + Function loadFunction = Objects.requireNonNull(missLoader, "missLoader can not be null"); + if (!isManualMissLoadEnabled()) { + return loadingData.get(key, typedKey -> loadAndTrack(typedKey, loadFunction)); + } + return getWithManualLoad(key, loadFunction); + } + + public V getIfPresent(K key) { + if (!effectiveEnabled) { + return null; + } + return data.getIfPresent(key); + } + + public void put(K key, V value) { + if (!effectiveEnabled) { + return; + } + data.put(key, value); + } + + /** + * The current invalidation generation. Capture this BEFORE a slow external load, then hand it to + * {@link #putIfNotInvalidatedSince} so a {@code flush}/{@code invalidate*} that raced the load does not + * get its clear silently undone by a stale write-back. Mirrors the guard the manual-miss-load path + * ({@link #getWithManualLoad}) applies around its own put; exposed so a caller that does its OWN bulk + * external read (e.g. a decorator batching a multi-key RPC and putting each result under its own key) + * can reuse the same generation guard instead of an unguarded {@link #put}. + */ + public long invalidationGeneration() { + return invalidateGeneration.get(); + } + + /** + * Generation-guarded put: caches {@code (key, value)} only if no invalidation has happened since + * {@code generation} was captured (before the caller's external load). If a {@code flush}/{@code + * invalidate*} raced the load — bumping the generation either before the put (skip) or between the put + * and the recheck (drop only the value we wrote, via {@link #removeLoadedValue}) — the stale value is + * NOT left cached, exactly as {@link #getWithManualLoad} does for its single-key load. Additive: the + * existing {@link #put} is unchanged; a disabled entry is a no-op. + */ + public void putIfNotInvalidatedSince(long generation, K key, V value) { + if (!effectiveEnabled) { + return; + } + synchronized (loadLock(key)) { + // A racing flush already bumped the generation before we could put: skip so a stale pre-flush + // value is not re-cached (mirrors getWithManualLoad's pre-put guard). + if (generation != invalidateGeneration.get()) { + return; + } + data.put(key, value); + // A flush landing between the check and the put: drop only the value we just wrote, keeping any + // newer replacement intact (mirrors getWithManualLoad's post-put guard). + if (generation != invalidateGeneration.get()) { + removeLoadedValue(key, value); + } + } + } + + public void invalidateKey(K key) { + invalidateGeneration.incrementAndGet(); + if (data.asMap().remove(key) != null) { + invalidateCount.incrementAndGet(); + } + } + + public void invalidateIf(Predicate predicate) { + invalidateGeneration.incrementAndGet(); + data.asMap().keySet().removeIf(key -> { + if (predicate.test(key)) { + invalidateCount.incrementAndGet(); + return true; + } + return false; + }); + } + + public void invalidateAll() { + invalidateGeneration.incrementAndGet(); + long size = data.estimatedSize(); + data.invalidateAll(); + invalidateCount.addAndGet(size); + } + + public void forEach(BiConsumer consumer) { + data.asMap().forEach(consumer); + } + + public MetaCacheEntryStats stats() { + CacheStats cacheStats = loadingData.stats(); + long successCount = loadSuccessCount.get(); + long failureCount = loadFailureCount.get(); + long totalLoadTime = totalLoadTimeNanos.get(); + long totalLoadCount = successCount + failureCount; + return new MetaCacheEntryStats( + cacheSpec.isEnable(), + effectiveEnabled, + autoRefresh, + cacheSpec.getTtlSecond(), + cacheSpec.getCapacity(), + data.estimatedSize(), + cacheStats.requestCount(), + cacheStats.hitCount(), + cacheStats.missCount(), + cacheStats.hitRate(), + successCount, + failureCount, + totalLoadTime, + totalLoadCount == 0 ? 0D : (double) totalLoadTime / totalLoadCount, + cacheStats.evictionCount(), + invalidateCount.get(), + lastLoadSuccessTimeMs.get(), + lastLoadFailureTimeMs.get(), + lastError.get()); + } + + // Injected at construction (fe-core reads Config.enable_external_meta_cache_manual_miss_load dynamically). + private boolean isManualMissLoadEnabled() { + return manualMissLoadEnabled; + } + + // Execute slow miss loads outside Caffeine's sync load path and suppress stale write-back after invalidation. + private V getWithManualLoad(K key, Function loadFunction) { + if (!effectiveEnabled) { + // Bypass cache entirely when the entry is disabled so manual miss load does not relax disable semantics. + return loadAndTrack(key, loadFunction); + } + + V value = data.getIfPresent(key); + if (value != null) { + return value; + } + + synchronized (loadLock(key)) { + value = data.asMap().get(key); + if (value != null) { + return value; + } + + long generation = invalidateGeneration.get(); + V loaded = loadAndTrack(key, loadFunction); + if (generation != invalidateGeneration.get()) { + return loaded; + } + + // Keep null results uncached so manual miss load matches LoadingCache null-return behavior. + if (loaded == null) { + return null; + } + + // Leave a narrow hook for tests to pause exactly before the cache put race window. + beforeManualCachePutForTest(key, loaded); + data.put(key, loaded); + if (generation != invalidateGeneration.get()) { + removeLoadedValue(key, loaded); + } + return loaded; + } + } + + // Remove only the value loaded by the current request and keep newer replacements intact. + private void removeLoadedValue(K key, V loaded) { + data.asMap().computeIfPresent(key, (ignored, currentValue) -> currentValue == loaded ? null : currentValue); + } + + // Map keys to a fixed lock stripe set to bound memory usage while keeping same-key deduplication. + private Object loadLock(K key) { + int hash = key == null ? 0 : key.hashCode(); + return loadLocks[(hash & Integer.MAX_VALUE) % loadLocks.length]; + } + + // Let tests pause between the first generation check and data.put without affecting production behavior. + void beforeManualCachePutForTest(K key, V loaded) { + } + + private V loadFromDefaultLoader(K key) { + return loadAndTrack(key, this::applyDefaultLoader); + } + + // Resolve the default loader separately so the manual path can share tracking without double counting. + private V applyDefaultLoader(K key) { + if (loader == null) { + throw new UnsupportedOperationException( + String.format("Entry '%s' requires a contextual miss loader.", name)); + } + return loader.apply(key); + } + + // Track load outcomes locally because manual miss loads do not contribute to Caffeine load statistics. + private V loadAndTrack(K key, Function loadFunction) { + long startNanos = System.nanoTime(); + try { + V value = loadFunction.apply(key); + loadSuccessCount.incrementAndGet(); + totalLoadTimeNanos.addAndGet(System.nanoTime() - startNanos); + lastLoadSuccessTimeMs.set(System.currentTimeMillis()); + return value; + } catch (RuntimeException | Error e) { + loadFailureCount.incrementAndGet(); + totalLoadTimeNanos.addAndGet(System.nanoTime() - startNanos); + lastLoadFailureTimeMs.set(System.currentTimeMillis()); + lastError.set(e.toString()); + throw e; + } + } +} diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheEntryStats.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheEntryStats.java new file mode 100644 index 00000000000000..41c8b89192cd1b --- /dev/null +++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCacheEntryStats.java @@ -0,0 +1,201 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.cache; + +import java.util.Objects; + +/** + * Immutable stats snapshot of one {@link MetaCacheEntry}. + * + *

Connector-side copy of fe-core {@code org.apache.doris.datasource.metacache.MetaCacheEntryStats} + * (independent-copy migration): the connector plugins cannot import fe-core, so the meta-cache framework is + * duplicated under {@code org.apache.doris.connector.cache}. Keep behaviourally in sync with the fe-core + * original until the fe-core copy is retired. + * + *

Time fields use the following units: + *

    + *
  • {@code totalLoadTimeNanos}/{@code averageLoadPenaltyNanos}: nanoseconds
  • + *
  • {@code lastLoadSuccessTimeMs}/{@code lastLoadFailureTimeMs}: epoch milliseconds
  • + *
+ * + *

For last-load timestamps, {@code -1} means no corresponding event happened yet. + * {@code lastError} keeps the latest load failure message; empty string means no failure recorded. + */ +public final class MetaCacheEntryStats { + private final boolean configEnabled; + private final boolean effectiveEnabled; + private final boolean autoRefresh; + private final long ttlSecond; + private final long capacity; + private final long estimatedSize; + private final long requestCount; + private final long hitCount; + private final long missCount; + private final double hitRate; + private final long loadSuccessCount; + private final long loadFailureCount; + private final long totalLoadTimeNanos; + private final double averageLoadPenaltyNanos; + private final long evictionCount; + private final long invalidateCount; + private final long lastLoadSuccessTimeMs; + private final long lastLoadFailureTimeMs; + private final String lastError; + + /** + * Build an immutable stats snapshot. + */ + public MetaCacheEntryStats( + boolean configEnabled, + boolean effectiveEnabled, + boolean autoRefresh, + long ttlSecond, + long capacity, + long estimatedSize, + long requestCount, + long hitCount, + long missCount, + double hitRate, + long loadSuccessCount, + long loadFailureCount, + long totalLoadTimeNanos, + double averageLoadPenaltyNanos, + long evictionCount, + long invalidateCount, + long lastLoadSuccessTimeMs, + long lastLoadFailureTimeMs, + String lastError) { + this.configEnabled = configEnabled; + this.effectiveEnabled = effectiveEnabled; + this.autoRefresh = autoRefresh; + this.ttlSecond = ttlSecond; + this.capacity = capacity; + this.estimatedSize = estimatedSize; + this.requestCount = requestCount; + this.hitCount = hitCount; + this.missCount = missCount; + this.hitRate = hitRate; + this.loadSuccessCount = loadSuccessCount; + this.loadFailureCount = loadFailureCount; + this.totalLoadTimeNanos = totalLoadTimeNanos; + this.averageLoadPenaltyNanos = averageLoadPenaltyNanos; + this.evictionCount = evictionCount; + this.invalidateCount = invalidateCount; + this.lastLoadSuccessTimeMs = lastLoadSuccessTimeMs; + this.lastLoadFailureTimeMs = lastLoadFailureTimeMs; + this.lastError = Objects.requireNonNull(lastError, "lastError"); + } + + public boolean isConfigEnabled() { + return configEnabled; + } + + /** + * Effective cache enable state evaluated by {@link CacheSpec#isCacheEnabled(boolean, long, long)}. + */ + public boolean isEffectiveEnabled() { + return effectiveEnabled; + } + + public boolean isAutoRefresh() { + return autoRefresh; + } + + public long getTtlSecond() { + return ttlSecond; + } + + public long getCapacity() { + return capacity; + } + + public long getEstimatedSize() { + return estimatedSize; + } + + public long getRequestCount() { + return requestCount; + } + + public long getHitCount() { + return hitCount; + } + + public long getMissCount() { + return missCount; + } + + public double getHitRate() { + return hitRate; + } + + public long getLoadSuccessCount() { + return loadSuccessCount; + } + + public long getLoadFailureCount() { + return loadFailureCount; + } + + public long getTotalLoadTimeNanos() { + return totalLoadTimeNanos; + } + + /** + * Average load penalty in nanoseconds. + */ + public double getAverageLoadPenaltyNanos() { + return averageLoadPenaltyNanos; + } + + public long getEvictionCount() { + return evictionCount; + } + + public double getEvictionRate() { + if (requestCount == 0) { + return 0D; + } + return (double) evictionCount / requestCount; + } + + public long getInvalidateCount() { + return invalidateCount; + } + + /** + * Last successful load timestamp in epoch milliseconds, or {@code -1} if absent. + */ + public long getLastLoadSuccessTimeMs() { + return lastLoadSuccessTimeMs; + } + + /** + * Last failed load timestamp in epoch milliseconds, or {@code -1} if absent. + */ + public long getLastLoadFailureTimeMs() { + return lastLoadFailureTimeMs; + } + + /** + * Latest load failure message, or empty string if no failure is recorded. + */ + public String getLastError() { + return lastError; + } +} diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/package-info.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/package-info.java new file mode 100644 index 00000000000000..492da2e2ff24f1 --- /dev/null +++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/package-info.java @@ -0,0 +1,26 @@ +// 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. + +/** + * Shared external meta-cache framework, reused by fe-core and the connector plugins. + * + *

Under the parent-first {@code org.apache.doris.connector.*} prefix, so all instances load on the app + * classloader with a single {@code Class} identity across the fe-core ↔ plugin boundary. Classes are + * moved here from fe-core {@code org.apache.doris.datasource.metacache} + {@code org.apache.doris.common} + * (see plan-doc/tasks/designs/metacache-framework-unification-design.md, Option A / P1). + */ +package org.apache.doris.connector.cache; diff --git a/fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/CacheSpecTest.java b/fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/CacheSpecTest.java new file mode 100644 index 00000000000000..3e918bd80e811a --- /dev/null +++ b/fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/CacheSpecTest.java @@ -0,0 +1,220 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.cache; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.OptionalLong; + +/** + * Pins the shared {@link CacheSpec} validators and parsing (the single source of truth after the + * three prior copies — fe-core {@code datasource.metacache.CacheSpec}, the {@code connector.api.cache} + * mirror, and the connectors' hand-rolled checks — were collapsed here). + * + *

WHY this matters: this class restores the legacy CREATE/ALTER CATALOG meta-cache property + * validation that was dropped at the SPI cutover. The validators MUST throw {@link IllegalArgumentException} + * (NOT a fe-core {@code DdlException}, which is unavailable here and would not be caught by + * {@code PluginDrivenExternalCatalog.checkProperties}) and MUST emit the exact legacy message substring + * {@code "is wrong"} so the user-facing error and the regression assertions (e.g. + * {@code test_iceberg_table_meta_cache} / {@code test_paimon_table_meta_cache}) still match. + */ +public class CacheSpecTest { + + @Test + public void checkBooleanPropertyAcceptsTrueFalseAndNull() { + CacheSpec.checkBooleanProperty("true", "k.enable"); + CacheSpec.checkBooleanProperty("false", "k.enable"); + CacheSpec.checkBooleanProperty("TRUE", "k.enable"); + CacheSpec.checkBooleanProperty(null, "k.enable"); + } + + @Test + public void checkBooleanPropertyRejectsNonBoolean() { + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, + () -> CacheSpec.checkBooleanProperty("on", "k.enable")); + Assertions.assertEquals("The parameter k.enable is wrong, value is on", e.getMessage()); + } + + @Test + public void checkLongPropertyAcceptsInRangeAndNull() { + CacheSpec.checkLongProperty("10", 0, "k.ttl"); + CacheSpec.checkLongProperty("0", 0, "k.ttl"); + CacheSpec.checkLongProperty(null, 0, "k.ttl"); + // iceberg/paimon ttl min is -1 (the "no expiration" sentinel), so -1 is accepted. + CacheSpec.checkLongProperty("-1", -1L, "k.ttl"); + } + + @Test + public void checkLongPropertyRejectsBelowMin() { + // Legacy hive-style min 0 rejects -1. + IllegalArgumentException belowZero = Assertions.assertThrows(IllegalArgumentException.class, + () -> CacheSpec.checkLongProperty("-1", 0, "k.ttl")); + Assertions.assertEquals("The parameter k.ttl is wrong, value is -1", belowZero.getMessage()); + + // The concrete failing case: -2 with iceberg/paimon min -1. + IllegalArgumentException belowMinusOne = Assertions.assertThrows(IllegalArgumentException.class, + () -> CacheSpec.checkLongProperty("-2", -1L, "meta.cache.iceberg.table.ttl-second")); + Assertions.assertEquals( + "The parameter meta.cache.iceberg.table.ttl-second is wrong, value is -2", + belowMinusOne.getMessage()); + Assertions.assertTrue(belowMinusOne.getMessage().contains("is wrong")); + } + + @Test + public void checkLongPropertyRejectsNonNumeric() { + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, + () -> CacheSpec.checkLongProperty("abc", 0, "k.capacity")); + Assertions.assertEquals("The parameter k.capacity is wrong, value is abc", e.getMessage()); + } + + @Test + public void fromPropertiesWithExplicitKeys() { + Map properties = new HashMap<>(); + properties.put("k.enable", "false"); + properties.put("k.ttl", "123"); + properties.put("k.capacity", "456"); + + CacheSpec spec = CacheSpec.fromProperties( + properties, + "k.enable", true, + "k.ttl", CacheSpec.CACHE_NO_TTL, + "k.capacity", 100); + + Assertions.assertFalse(spec.isEnable()); + Assertions.assertEquals(123, spec.getTtlSecond()); + Assertions.assertEquals(456, spec.getCapacity()); + } + + @Test + public void fromPropertiesWithPropertySpecBuilder() { + Map properties = new HashMap<>(); + properties.put("k.enable", "false"); + properties.put("k.ttl", "123"); + properties.put("k.capacity", "456"); + + CacheSpec spec = CacheSpec.fromProperties(properties, CacheSpec.propertySpecBuilder() + .enable("k.enable", true) + .ttl("k.ttl", CacheSpec.CACHE_NO_TTL) + .capacity("k.capacity", 100) + .build()); + + Assertions.assertFalse(spec.isEnable()); + Assertions.assertEquals(123, spec.getTtlSecond()); + Assertions.assertEquals(456, spec.getCapacity()); + } + + @Test + public void fromPropertiesWithEngineEntryKeys() { + Map properties = new HashMap<>(); + properties.put("meta.cache.hive.schema.ttl-second", "0"); + + CacheSpec defaultSpec = CacheSpec.fromProperties( + new HashMap<>(), + "enable", true, + "ttl", 60, + "capacity", 100); + + CacheSpec spec = CacheSpec.fromProperties(properties, "hive", "schema", defaultSpec); + Assertions.assertTrue(spec.isEnable()); + Assertions.assertEquals(0, spec.getTtlSecond()); + Assertions.assertEquals(100, spec.getCapacity()); + } + + @Test + public void fromPropertiesIsBestEffort() { + // Parsing must never throw; a bad value falls back to the default (validation is a separate step). + Map props = new HashMap<>(); + props.put("meta.cache.iceberg.table.enable", "true"); + props.put("meta.cache.iceberg.table.ttl-second", "not-a-number"); + CacheSpec spec = CacheSpec.fromProperties(props, "iceberg", "table", + CacheSpec.of(false, 3600L, 1000L)); + Assertions.assertTrue(spec.isEnable()); + Assertions.assertEquals(3600L, spec.getTtlSecond()); + Assertions.assertEquals(1000L, spec.getCapacity()); + } + + @Test + public void applyCompatibilityMap() { + Map properties = new HashMap<>(); + properties.put("legacy.ttl", "10"); + properties.put("new.ttl", "20"); + properties.put("legacy.capacity", "30"); + + Map compatibilityMap = new HashMap<>(); + compatibilityMap.put("legacy.ttl", "new.ttl"); + compatibilityMap.put("legacy.capacity", "new.capacity"); + + Map mapped = CacheSpec.applyCompatibilityMap(properties, compatibilityMap); + + // New key keeps precedence if already present. + Assertions.assertEquals("20", mapped.get("new.ttl")); + // Missing new key is copied from legacy key. + Assertions.assertEquals("30", mapped.get("new.capacity")); + // Original map is not modified. + Assertions.assertFalse(properties.containsKey("new.capacity")); + } + + @Test + public void ofSemantics() { + CacheSpec enabled = CacheSpec.of(true, 60, 100); + Assertions.assertTrue(enabled.isEnable()); + Assertions.assertEquals(60, enabled.getTtlSecond()); + Assertions.assertEquals(100, enabled.getCapacity()); + + CacheSpec zeroTtl = CacheSpec.of(true, 0, 100); + Assertions.assertTrue(zeroTtl.isEnable()); + Assertions.assertEquals(0, zeroTtl.getTtlSecond()); + Assertions.assertEquals(100, zeroTtl.getCapacity()); + + CacheSpec disabled = CacheSpec.of(false, 60, 100); + Assertions.assertFalse(disabled.isEnable()); + } + + @Test + public void isCacheEnabledMatchesLegacyFormula() { + Assertions.assertTrue(CacheSpec.isCacheEnabled(true, CacheSpec.CACHE_NO_TTL, 1)); + Assertions.assertFalse(CacheSpec.isCacheEnabled(false, CacheSpec.CACHE_NO_TTL, 1)); + Assertions.assertFalse(CacheSpec.isCacheEnabled(true, 0, 1)); + Assertions.assertFalse(CacheSpec.isCacheEnabled(true, CacheSpec.CACHE_NO_TTL, 0)); + } + + @Test + public void toExpireAfterAccessMapsSentinels() { + Assertions.assertFalse(CacheSpec.toExpireAfterAccess(CacheSpec.CACHE_NO_TTL).isPresent()); + + OptionalLong disabled = CacheSpec.toExpireAfterAccess(0); + Assertions.assertTrue(disabled.isPresent()); + Assertions.assertEquals(0, disabled.getAsLong()); + + Assertions.assertEquals(15, CacheSpec.toExpireAfterAccess(15).getAsLong()); + // -2 is not the -1 sentinel, so it clamps to 0 (disable), not empty. + Assertions.assertEquals(0, CacheSpec.toExpireAfterAccess(-2).getAsLong()); + } + + @Test + public void isMetaCacheKeyForEngine() { + Assertions.assertTrue( + CacheSpec.isMetaCacheKeyForEngine("meta.cache.iceberg.table.ttl-second", "iceberg")); + Assertions.assertFalse( + CacheSpec.isMetaCacheKeyForEngine("meta.cache.paimon.table.ttl-second", "iceberg")); + Assertions.assertFalse(CacheSpec.isMetaCacheKeyForEngine(null, "iceberg")); + } +} diff --git a/fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/MetaCacheEntryTest.java b/fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/MetaCacheEntryTest.java new file mode 100644 index 00000000000000..8284f4ae1ed672 --- /dev/null +++ b/fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/MetaCacheEntryTest.java @@ -0,0 +1,263 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.cache; + +import com.github.benmanes.caffeine.cache.LoadingCache; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Behaviour parity tests for the connector-side {@link MetaCacheEntry} copy. Where fe-core's + * {@code MetaCacheEntryTest} toggles {@code Config.enable_external_meta_cache_manual_miss_load}, this copy + * passes the flag through the constructor instead (the connector copy has no fe-core Config). + */ +public class MetaCacheEntryTest { + + @Test + public void loaderGetTracksHitMissAndLastError() { + ExecutorService refreshExecutor = Executors.newSingleThreadExecutor(); + try { + CacheSpec cacheSpec = CacheSpec.of(true, CacheSpec.CACHE_NO_TTL, 10L); + MetaCacheEntry entry = new MetaCacheEntry<>( + "test", + key -> { + if ("fail".equals(key)) { + throw new IllegalStateException("mock failure"); + } + return 1; + }, + cacheSpec, + refreshExecutor); + + Assertions.assertEquals(Integer.valueOf(1), entry.get("ok")); + Assertions.assertEquals(Integer.valueOf(1), entry.get("ok")); + Assertions.assertThrows(IllegalStateException.class, () -> entry.get("fail")); + + MetaCacheEntryStats stats = entry.stats(); + Assertions.assertEquals(3L, stats.getRequestCount()); + Assertions.assertEquals(1L, stats.getHitCount()); + Assertions.assertEquals(2L, stats.getMissCount()); + Assertions.assertEquals(1L, stats.getLoadSuccessCount()); + Assertions.assertEquals(1L, stats.getLoadFailureCount()); + Assertions.assertTrue(stats.getLastLoadSuccessTimeMs() > 0); + Assertions.assertTrue(stats.getLastLoadFailureTimeMs() > 0); + Assertions.assertTrue(stats.getLastError().contains("mock failure")); + + // A per-call miss loader overrides the default loader. + Assertions.assertEquals(Integer.valueOf(101), entry.get("miss-loader", key -> 101)); + } finally { + refreshExecutor.shutdownNow(); + } + } + + @Test + public void disabledSpecMakesEntryIneffective() { + ExecutorService refreshExecutor = Executors.newSingleThreadExecutor(); + try { + // ttl == 0 disables the cache (CacheSpec.isCacheEnabled). + CacheSpec cacheSpec = CacheSpec.of(true, 0L, 10L); + AtomicInteger loadCounter = new AtomicInteger(); + MetaCacheEntry entry = new MetaCacheEntry<>( + "test", + key -> loadCounter.incrementAndGet(), + cacheSpec, + refreshExecutor); + + // getIfPresent short-circuits to null for a disabled entry, even right after a get() populated it. + Assertions.assertNull(entry.getIfPresent("k")); + Assertions.assertEquals(Integer.valueOf(1), entry.get("k")); + Assertions.assertNull(entry.getIfPresent("k")); + + MetaCacheEntryStats stats = entry.stats(); + Assertions.assertTrue(stats.isConfigEnabled()); + Assertions.assertFalse(stats.isEffectiveEnabled()); + + // The manual miss-load path bypasses the cache entirely when disabled, so every get reloads. + AtomicInteger manualCounter = new AtomicInteger(); + MetaCacheEntry manualEntry = new MetaCacheEntry<>( + "test-manual", key -> manualCounter.incrementAndGet(), cacheSpec, refreshExecutor, + false, false, 0L, true); + manualEntry.get("k"); + manualEntry.get("k"); + Assertions.assertEquals(2, manualCounter.get()); + } finally { + refreshExecutor.shutdownNow(); + } + } + + @Test + public void capacityEvictsAndStatsCountEviction() throws Exception { + ExecutorService refreshExecutor = Executors.newSingleThreadExecutor(); + try { + CacheSpec cacheSpec = CacheSpec.of(true, CacheSpec.CACHE_NO_TTL, 1L); + MetaCacheEntry entry = new MetaCacheEntry<>( + "test", + String::length, + cacheSpec, + refreshExecutor); + + Assertions.assertEquals(0D, entry.stats().getEvictionRate(), 0D); + Assertions.assertEquals(Integer.valueOf(1), entry.get("a")); + Assertions.assertEquals(Integer.valueOf(2), entry.get("bb")); + // Force Caffeine to run pending maintenance so the eviction is observable. + extractLoadingCache(entry).cleanUp(); + Assertions.assertEquals(1L, entry.stats().getEvictionCount()); + Assertions.assertEquals(0.5D, entry.stats().getEvictionRate(), 0D); + } finally { + refreshExecutor.shutdownNow(); + } + } + + @Test + public void contextualOnlyEntryRejectsDefaultGetButServesMissLoader() { + ExecutorService refreshExecutor = Executors.newSingleThreadExecutor(); + try { + CacheSpec cacheSpec = CacheSpec.of(true, CacheSpec.CACHE_NO_TTL, 10L); + MetaCacheEntry entry = new MetaCacheEntry<>( + "contextual", null, cacheSpec, refreshExecutor, + false, true, 0L, false); + + UnsupportedOperationException ex = Assertions.assertThrows( + UnsupportedOperationException.class, () -> entry.get("k")); + Assertions.assertTrue(ex.getMessage().contains("contextual miss loader")); + Assertions.assertEquals(Integer.valueOf(7), entry.get("k", key -> 7)); + // Second call is a cache hit; the miss loader is not consulted again. + Assertions.assertEquals(Integer.valueOf(7), entry.get("k", key -> 999)); + } finally { + refreshExecutor.shutdownNow(); + } + } + + @Test + public void invalidationDropsEntriesAndCounts() { + ExecutorService refreshExecutor = Executors.newSingleThreadExecutor(); + try { + CacheSpec cacheSpec = CacheSpec.of(true, CacheSpec.CACHE_NO_TTL, 10L); + MetaCacheEntry entry = new MetaCacheEntry<>( + "test", String::length, cacheSpec, refreshExecutor); + + entry.get("a"); + entry.get("bb"); + entry.get("ccc"); + entry.invalidateKey("a"); + Assertions.assertNull(entry.getIfPresent("a")); + Assertions.assertEquals(Integer.valueOf(2), entry.getIfPresent("bb")); + + entry.invalidateIf(key -> key.length() == 2); + Assertions.assertNull(entry.getIfPresent("bb")); + Assertions.assertEquals(Integer.valueOf(3), entry.getIfPresent("ccc")); + + entry.invalidateAll(); + Assertions.assertNull(entry.getIfPresent("ccc")); + Assertions.assertTrue(entry.stats().getInvalidateCount() >= 3L); + } finally { + refreshExecutor.shutdownNow(); + } + } + + @Test + public void manualMissLoadDeduplicatesConcurrentSameKey() throws Exception { + ExecutorService refreshExecutor = Executors.newSingleThreadExecutor(); + ExecutorService queryExecutor = Executors.newFixedThreadPool(2); + try { + CacheSpec cacheSpec = CacheSpec.of(true, CacheSpec.CACHE_NO_TTL, 10L); + CountDownLatch loaderStarted = new CountDownLatch(1); + CountDownLatch releaseLoader = new CountDownLatch(1); + AtomicInteger loadCounter = new AtomicInteger(); + // manualMissLoadEnabled = true (last ctor arg) exercises the striped-lock manual load path. + MetaCacheEntry entry = new MetaCacheEntry<>( + "test", + key -> { + loaderStarted.countDown(); + awaitLatch(releaseLoader); + return loadCounter.incrementAndGet(); + }, + cacheSpec, refreshExecutor, + false, false, 0L, true); + + Future first = queryExecutor.submit(() -> entry.get("k")); + Assertions.assertTrue(loaderStarted.await(3L, TimeUnit.SECONDS)); + Future second = queryExecutor.submit(() -> entry.get("k")); + releaseLoader.countDown(); + + Assertions.assertEquals(Integer.valueOf(1), first.get(3L, TimeUnit.SECONDS)); + Assertions.assertEquals(Integer.valueOf(1), second.get(3L, TimeUnit.SECONDS)); + Assertions.assertEquals(1, loadCounter.get()); + } finally { + queryExecutor.shutdownNow(); + refreshExecutor.shutdownNow(); + } + } + + @Test + public void putIfNotInvalidatedSinceHonorsGenerationGuard() { + ExecutorService refreshExecutor = Executors.newSingleThreadExecutor(); + try { + // Contextual + manual-miss entry, mirroring the hive partition-object cache that uses the guarded put. + CacheSpec cacheSpec = CacheSpec.of(true, CacheSpec.CACHE_NO_TTL, 10L); + MetaCacheEntry entry = new MetaCacheEntry<>( + "test", null, cacheSpec, refreshExecutor, false, true, 0L, true); + + // No invalidation since the captured generation -> the guarded put caches normally. + long g1 = entry.invalidationGeneration(); + entry.putIfNotInvalidatedSince(g1, "a", 1); + Assertions.assertEquals(Integer.valueOf(1), entry.getIfPresent("a")); + + // An invalidation between the capture and the put (the flush-races-an-in-flight-load case) must make + // the put a no-op, so a stale pre-invalidation value is NOT re-cached to the TTL. + long g2 = entry.invalidationGeneration(); + entry.invalidateAll(); // bumps the generation, as a racing flush / REFRESH would + entry.putIfNotInvalidatedSince(g2, "b", 2); + // MUTATION: an unguarded put (the pre-R3 raw put) leaves "b" cached here -> getIfPresent == 2 -> red. + Assertions.assertNull(entry.getIfPresent("b"), "a stale-generation put must not re-cache the value"); + + // A generation captured AFTER the invalidation puts normally again (the guard is not a one-shot latch). + long g3 = entry.invalidationGeneration(); + entry.putIfNotInvalidatedSince(g3, "c", 3); + Assertions.assertEquals(Integer.valueOf(3), entry.getIfPresent("c")); + } finally { + refreshExecutor.shutdownNow(); + } + } + + @SuppressWarnings("unchecked") + private static LoadingCache extractLoadingCache(MetaCacheEntry entry) throws Exception { + Field field = MetaCacheEntry.class.getDeclaredField("loadingData"); + field.setAccessible(true); + return (LoadingCache) field.get(entry); + } + + private static void awaitLatch(CountDownLatch latch) { + try { + if (!latch.await(3L, TimeUnit.SECONDS)) { + throw new IllegalStateException("latch timed out"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } +} diff --git a/fe/fe-connector/fe-connector-es/src/main/assembly/plugin-zip.xml b/fe/fe-connector/fe-connector-es/src/main/assembly/plugin-zip.xml index 84a94995f3347c..507951f198355d 100644 --- a/fe/fe-connector/fe-connector-es/src/main/assembly/plugin-zip.xml +++ b/fe/fe-connector/fe-connector-es/src/main/assembly/plugin-zip.xml @@ -54,6 +54,9 @@ under the License. org.apache.doris:fe-extension-spi org.apache.doris:fe-thrift org.apache.thrift:libthrift + + org.apache.logging.log4j:* + org.slf4j:* 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 a0fd0b77533a2d..9b204e55f213e3 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 @@ -17,6 +17,9 @@ package org.apache.doris.connector.es; +import org.apache.doris.connector.api.ConnectorContractValidator; +import org.apache.doris.connector.spi.ConnectorContext; + import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -143,15 +146,22 @@ void testDifferentIndexesFetchSeparately() { } @Test - void testEsMetadataDoesNotSupportWrite() { - EsConnectorMetadata metadata = new EsConnectorMetadata( - new CountingRestClient(), minimalProps()); - Assertions.assertFalse(metadata.supportsInsert(), - "ES connector metadata should not support INSERT"); - Assertions.assertFalse(metadata.supportsDelete(), - "ES connector metadata should not support DELETE"); - Assertions.assertFalse(metadata.supportsMerge(), - "ES connector metadata should not support MERGE"); + void testEsConnectorDoesNotSupportWrite() { + EsConnector connector = new EsConnector(minimalProps(), new ConnectorContext() { + @Override + public String getCatalogName() { + return "test"; + } + + @Override + public long getCatalogId() { + return 0; + } + }); + Assertions.assertTrue(connector.supportedWriteOperations().isEmpty(), + "ES connector should declare no supported write operations"); + // Task 6 P2: the structural contract validator must pass for a real connector (positive control). + ConnectorContractValidator.validate(connector, "es"); } @Test diff --git a/fe/fe-connector/fe-connector-hive/pom.xml b/fe/fe-connector/fe-connector-hive/pom.xml index 055331c269c4de..29721c3a038425 100644 --- a/fe/fe-connector/fe-connector-hive/pom.xml +++ b/fe/fe-connector/fe-connector-hive/pom.xml @@ -47,6 +47,30 @@ under the License. ${project.version} + + + ${project.groupId} + fe-connector-cache + ${project.version} + + + + + com.github.ben-manes.caffeine + caffeine + 2.9.3 + + ${project.groupId} @@ -62,6 +86,17 @@ under the License. provided + + + ${project.groupId} + fe-filesystem-spi + ${project.version} + provided + + ${project.groupId} @@ -69,6 +104,16 @@ under the License. ${project.version} + + + ${project.groupId} + fe-connector-metastore-hms + ${project.version} + + org.apache.logging.log4j log4j-api @@ -79,6 +124,28 @@ under the License. junit-jupiter test + + + + ${project.groupId} + fe-filesystem-local + ${project.version} + test + + + + + commons-lang + commons-lang + test + diff --git a/fe/fe-connector/fe-connector-hive/src/main/assembly/plugin-zip.xml b/fe/fe-connector/fe-connector-hive/src/main/assembly/plugin-zip.xml index 13262ad745ad6d..2e141b72f15096 100644 --- a/fe/fe-connector/fe-connector-hive/src/main/assembly/plugin-zip.xml +++ b/fe/fe-connector/fe-connector-hive/src/main/assembly/plugin-zip.xml @@ -54,6 +54,9 @@ under the License. org.apache.doris:fe-filesystem-api org.apache.doris:fe-thrift org.apache.thrift:libthrift + + org.apache.logging.log4j:* + org.slf4j:* diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveAcidUtil.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveAcidUtil.java new file mode 100644 index 00000000000000..00b391ee3a18d8 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveAcidUtil.java @@ -0,0 +1,486 @@ +// 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.hms.HmsAcidConstants; +import org.apache.doris.filesystem.FileEntry; +import org.apache.doris.filesystem.FileIterator; +import org.apache.doris.filesystem.FileSystem; +import org.apache.doris.filesystem.Location; + +import org.apache.hadoop.hive.common.ValidReadTxnList; +import org.apache.hadoop.hive.common.ValidReaderWriteIdList; +import org.apache.hadoop.hive.common.ValidTxnList; +import org.apache.hadoop.hive.common.ValidWriteIdList; +import org.apache.hadoop.hive.common.ValidWriteIdList.RangeResponse; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Pure directory-name ACID state resolution for transactional Hive tables. + * + *

Plugin-side port of fe-core {@code AcidUtil.getAcidState}. It resolves, for one partition + * directory, the "best" base plus the working set of delta / delete-delta directories under a snapshot + * ({@code ValidTxnList} + {@code ValidWriteIdList}) and returns the surviving data files (base + + * non-delete deltas) plus the delete-delta descriptors. The BE later applies row deletes from the + * delete deltas.

+ * + *

The fe-core version drags in {@code FileCacheValue}/{@code LocationPath}/{@code AcidInfo}/ + * {@code HivePartition}; those all drop out at the plugin boundary because the plugin lists files with the + * engine-injected Doris {@link FileSystem} and emits {@link HiveScanRange} directly. Only the pure name-parsing + * plus the {@code hive-common} {@code Valid*} algorithm ports.

+ * + *

Ref: hive/ql/src/java/org/apache/hadoop/hive/ql/io/AcidUtils.java#getAcidState (the fe-core copy + * exists because hive3 cannot read hive4 transaction tables and using hive4 directly is problematic).

+ */ +public final class HiveAcidUtil { + private static final Logger LOG = LogManager.getLogger(HiveAcidUtil.class); + + private static final String HIVE_TRANSACTIONAL_ORC_BUCKET_PREFIX = "bucket_"; + private static final String DELTA_SIDE_FILE_SUFFIX = "_flush_length"; + + private HiveAcidUtil() { + } + + /** Resolved ACID state for one partition: surviving data files + delete-delta descriptors. */ + public static final class AcidState { + private final List dataFiles; + private final List deleteDeltas; + + AcidState(List dataFiles, List deleteDeltas) { + this.dataFiles = dataFiles; + this.deleteDeltas = deleteDeltas; + } + + /** Base + non-delete delta bucket files that survive the snapshot; each becomes a scan split. */ + public List getDataFiles() { + return dataFiles; + } + + /** Delete-delta directories (with the bucket file names inside) the BE must subtract. */ + public List getDeleteDeltas() { + return deleteDeltas; + } + } + + /** A single delete-delta directory plus the (filtered) delete file names inside it. */ + public static final class DeleteDelta { + private final String directoryLocation; + private final List fileNames; + + DeleteDelta(String directoryLocation, List fileNames) { + this.directoryLocation = directoryLocation; + this.fileNames = fileNames; + } + + public String getDirectoryLocation() { + return directoryLocation; + } + + public List getFileNames() { + return fileNames; + } + } + + private static final class ParsedBase { + private final long writeId; + private final long visibilityId; + + ParsedBase(long writeId, long visibilityId) { + this.writeId = writeId; + this.visibilityId = visibilityId; + } + } + + private static ParsedBase parseBase(String name) { + //format1 : base_writeId + //format2 : base_writeId_visibilityId detail: https://issues.apache.org/jira/browse/HIVE-20823 + name = name.substring("base_".length()); + int index = name.indexOf("_v"); + if (index == -1) { + return new ParsedBase(Long.parseLong(name), 0); + } + return new ParsedBase( + Long.parseLong(name.substring(0, index)), + Long.parseLong(name.substring(index + 2))); + } + + private static final class ParsedDelta implements Comparable { + private final long min; + private final long max; + private final String path; + private final int statementId; + private final boolean deleteDelta; + private final long visibilityId; + + ParsedDelta(long min, long max, String path, int statementId, + boolean deleteDelta, long visibilityId) { + this.min = min; + this.max = max; + this.path = path; + this.statementId = statementId; + this.deleteDelta = deleteDelta; + this.visibilityId = visibilityId; + } + + /* + * Smaller minWID orders first; + * If minWID is the same, larger maxWID orders first; + * Otherwise, sort by stmtID; files w/o stmtID orders first. + * + * Compactions (Major/Minor) merge deltas/bases but delete of old files + * happens in a different process; thus it's possible to have bases/deltas with + * overlapping writeId boundaries. The sort order helps figure out the "best" set of files + * to use to get data. + * This sorts "wider" delta before "narrower" i.e. delta_5_20 sorts before delta_5_10 (and delta_11_20) + */ + @Override + public int compareTo(ParsedDelta other) { + return min != other.min ? Long.compare(min, other.min) : + other.max != max ? Long.compare(other.max, max) : + statementId != other.statementId + ? Integer.compare(statementId, other.statementId) : + path.compareTo(other.path); + } + } + + private static boolean isValidMetaDataFile(FileSystem fileSystem, String baseDir) + throws IOException { + String fileLocation = baseDir + "_metadata_acid"; + try { + return fileSystem.exists(Location.of(fileLocation)); + } catch (IOException e) { + return false; + } + } + + private static boolean isValidBase(FileSystem fileSystem, String baseDir, + ParsedBase base, ValidWriteIdList writeIdList) throws IOException { + if (base.writeId == Long.MIN_VALUE) { + //Ref: https://issues.apache.org/jira/browse/HIVE-13369 + //such base is created by 1st compaction in case of non-acid to acid table conversion.(you + //will get dir: `base_-9223372036854775808`) + //By definition there are no open txns with id < 1. + //After this: https://issues.apache.org/jira/browse/HIVE-18192, txns(global transaction ID) => writeId. + return true; + } + + // hive 4 : just check "_v" suffix, before hive 4 : check `_metadata_acid` file in baseDir. + if ((base.visibilityId > 0) || isValidMetaDataFile(fileSystem, baseDir)) { + return writeIdList.isValidBase(base.writeId); + } + + // if here, it's a result of IOW + return writeIdList.isWriteIdValid(base.writeId); + } + + private static ParsedDelta parseDelta(String fileName, String deltaPrefix, String path) { + // format1: delta_min_max_statementId_visibilityId, delete_delta_min_max_statementId_visibilityId + // _visibilityId maybe not exists. + // detail: https://issues.apache.org/jira/browse/HIVE-20823 + // format2: delta_min_max_visibilityId, delete_delta_min_visibilityId + // when minor compaction runs, we collapse per statement delta files inside a single + // transaction so we no longer need a statementId in the file name + + long visibilityId = 0; + int visibilityIdx = fileName.indexOf("_v"); + if (visibilityIdx != -1) { + visibilityId = Long.parseLong(fileName.substring(visibilityIdx + 2)); + fileName = fileName.substring(0, visibilityIdx); + } + + boolean deleteDelta = deltaPrefix.equals("delete_delta_"); + + String rest = fileName.substring(deltaPrefix.length()); + int split = rest.indexOf('_'); + int split2 = rest.indexOf('_', split + 1); + long min = Long.parseLong(rest.substring(0, split)); + + if (split2 == -1) { + long max = Long.parseLong(rest.substring(split + 1)); + return new ParsedDelta(min, max, path, -1, deleteDelta, visibilityId); + } + + long max = Long.parseLong(rest.substring(split + 1, split2)); + int statementId = Integer.parseInt(rest.substring(split2 + 1)); + return new ParsedDelta(min, max, path, statementId, deleteDelta, visibilityId); + } + + private interface FileFilter { + boolean accept(String fileName); + } + + private static final class FullAcidFileFilter implements FileFilter { + @Override + public boolean accept(String fileName) { + return fileName.startsWith(HIVE_TRANSACTIONAL_ORC_BUCKET_PREFIX) + && !fileName.endsWith(DELTA_SIDE_FILE_SUFFIX); + } + } + + private static final class InsertOnlyFileFilter implements FileFilter { + @Override + public boolean accept(String fileName) { + return true; + } + } + + /** + * Lists the immediate children (files and directories) of {@code dir}, non-recursively, via the + * engine-injected Doris {@link FileSystem#list} — the literal listing that mirrors the old + * {@code FileSystem.listStatus}. + * + *

Literal, not glob: uses {@code fs.list(loc)}, never {@code fs.listFiles(loc)}. The per-scheme + * filesystems ({@code DFSFileSystem}, {@code S3CompatibleFileSystem}) override {@code listFiles} with a + * glob-aware branch that would treat a location containing {@code [}/{@code *}/{@code ?} as a pattern; a hive + * partition/delta location can legitimately contain those characters, and the old {@code listStatus} never + * glob-expanded. Mirrors {@code HiveFileListingCache.listFromFileSystem}.

+ */ + private static List listEntries(FileSystem fs, String dir) throws IOException { + List entries = new ArrayList<>(); + try (FileIterator it = fs.list(Location.of(dir))) { + while (it.hasNext()) { + entries.add(it.next()); + } + } + return entries; + } + + /** + * Lists the immediate file children of {@code dir} (directories excluded). + * + *

Mirrors fe-core {@code globList(fs, dir, false)} on a bare directory path: with no wildcard the + * glob has a {@code null} pattern, so it returns files only and skips any nested directory. The literal + * {@link #listEntries} returns both, so the directory entries are filtered out here.

+ */ + private static List listFiles(FileSystem fs, String dir) throws IOException { + List files = new ArrayList<>(); + for (FileEntry entry : listEntries(fs, dir)) { + if (!entry.isDirectory()) { + files.add(entry); + } + } + return files; + } + + /** + * Resolves the ACID state of one partition directory under the given snapshot. + * + * @param fs engine-injected Doris file system for the partition location + * @param partitionPath the partition directory (e.g. {@code hdfs://.../data_id=200103}) + * @param txnValidIds the two serialized {@code Valid*} lists keyed by {@link HmsAcidConstants} + * @param isFullAcid full-ACID (bucket_ filter + delete deltas) vs insert-only (accept-all) + * @return surviving data files + delete-delta descriptors for the partition + */ + public static AcidState getAcidState(FileSystem fs, String partitionPath, + Map txnValidIds, boolean isFullAcid) throws IOException { + // Ref: https://issues.apache.org/jira/browse/HIVE-18192 + // Readers should use the combination of ValidTxnList and ValidWriteIdList(Table) for snapshot isolation. + // ValidReadTxnList implements ValidTxnList + // ValidReaderWriteIdList implements ValidWriteIdList + ValidTxnList validTxnList; + if (txnValidIds.containsKey(HmsAcidConstants.VALID_TXNS_KEY)) { + validTxnList = new ValidReadTxnList(); + validTxnList.readFromString(txnValidIds.get(HmsAcidConstants.VALID_TXNS_KEY)); + } else { + throw new RuntimeException("Miss ValidTxnList"); + } + + ValidWriteIdList validWriteIdList; + if (txnValidIds.containsKey(HmsAcidConstants.VALID_WRITEIDS_KEY)) { + validWriteIdList = new ValidReaderWriteIdList(); + validWriteIdList.readFromString(txnValidIds.get(HmsAcidConstants.VALID_WRITEIDS_KEY)); + } else { + throw new RuntimeException("Miss ValidWriteIdList"); + } + + //hdfs://xxxxx/user/hive/warehouse/username/data_id=200103 + // List all files and folders, without recursion. + List partitionEntries = listEntries(fs, partitionPath); + + String oldestBase = null; + long oldestBaseWriteId = Long.MAX_VALUE; + String bestBasePath = null; + long bestBaseWriteId = 0; + boolean haveOriginalFiles = false; + List workingDeltas = new ArrayList<>(); + + for (FileEntry entry : partitionEntries) { + if (entry.isDirectory()) { + String dirName = entry.name(); //dirName: base_xxx,delta_xxx,... + String dirPath = partitionPath + "/" + dirName; + + if (dirName.startsWith("base_")) { + ParsedBase base = parseBase(dirName); + if (!validTxnList.isTxnValid(base.visibilityId)) { + //checks visibilityTxnId to see if it is committed in current snapshot. + continue; + } + + long writeId = base.writeId; + if (oldestBaseWriteId > writeId) { + oldestBase = dirPath; + oldestBaseWriteId = writeId; + } + + if (((bestBasePath == null) || (bestBaseWriteId < writeId)) + && isValidBase(fs, dirPath, base, validWriteIdList)) { + //IOW will generator a base_N/ directory: https://issues.apache.org/jira/browse/HIVE-14988 + //So maybe need consider: https://issues.apache.org/jira/browse/HIVE-25777 + + bestBasePath = dirPath; + bestBaseWriteId = writeId; + } + } else if (dirName.startsWith("delta_") || dirName.startsWith("delete_delta_")) { + String deltaPrefix = dirName.startsWith("delta_") ? "delta_" : "delete_delta_"; + ParsedDelta delta = parseDelta(dirName, deltaPrefix, dirPath); + + if (!validTxnList.isTxnValid(delta.visibilityId)) { + continue; + } + + // No need check (validWriteIdList.isWriteIdRangeAborted(min,max) != RangeResponse.ALL) + // It is a subset of (validWriteIdList.isWriteIdRangeValid(min, max) != RangeResponse.NONE) + if (validWriteIdList.isWriteIdRangeValid(delta.min, delta.max) != RangeResponse.NONE) { + workingDeltas.add(delta); + } + } else { + //Sometimes hive will generate temporary directories(`.hive-staging_hive_xxx` ), + // which do not need to be read. + LOG.warn("Read Hive Acid Table ignore the contents of this folder:" + dirName); + } + } else { + haveOriginalFiles = true; + } + } + + if (bestBasePath == null && haveOriginalFiles) { + // ALTER TABLE nonAcidTbl SET TBLPROPERTIES ('transactional'='true'); + throw new UnsupportedOperationException("For no acid table convert to acid, please COMPACT 'major'."); + } + + if ((oldestBase != null) && (bestBasePath == null)) { + /* + * If here, it means there was a base_x (> 1 perhaps) but none were suitable for given + * {@link writeIdList}. Note that 'original' files are logically a base_Long.MIN_VALUE and thus + * cannot have any data for an open txn. We could check {@link deltas} has files to cover + * [1,n] w/o gaps but this would almost never happen... + * + * We only throw for base_x produced by Compactor since that base erases all history and + * cannot be used for a client that has a snapshot in which something inside this base is + * open. (Nor can we ignore this base of course) But base_x which is a result of IOW, + * contains all history so we treat it just like delta wrt visibility. Imagine, IOW which + * aborts. It creates a base_x, which can and should just be ignored.*/ + long[] exceptions = validWriteIdList.getInvalidWriteIds(); + String minOpenWriteId = ((exceptions != null) + && (exceptions.length > 0)) ? String.valueOf(exceptions[0]) : "x"; + throw new IOException( + String.format("Not enough history available for ({},{}). Oldest available base: {}", + validWriteIdList.getHighWatermark(), minOpenWriteId, oldestBase)); + } + + workingDeltas.sort(null); + + List deltas = new ArrayList<>(); + long current = bestBaseWriteId; + int lastStatementId = -1; + ParsedDelta prev = null; + // find need read delta/delete_delta file. + for (ParsedDelta next : workingDeltas) { + if (next.max > current) { + if (validWriteIdList.isWriteIdRangeValid(current + 1, next.max) != RangeResponse.NONE) { + deltas.add(next); + current = next.max; + lastStatementId = next.statementId; + prev = next; + } + } else if ((next.max == current) && (lastStatementId >= 0)) { + //make sure to get all deltas within a single transaction; multi-statement txn + //generate multiple delta files with the same txnId range + //of course, if maxWriteId has already been minor compacted, + // all per statement deltas are obsolete + + deltas.add(next); + prev = next; + } else if ((prev != null) + && (next.max == prev.max) + && (next.min == prev.min) + && (next.statementId == prev.statementId)) { + // The 'next' parsedDelta may have everything equal to the 'prev' parsedDelta, except + // the path. This may happen when we have split update and we have two types of delta + // directories- 'delta_x_y' and 'delete_delta_x_y' for the SAME txn range. + + // Also note that any delete_deltas in between a given delta_x_y range would be made + // obsolete. For example, a delta_30_50 would make delete_delta_40_40 obsolete. + // This is valid because minor compaction always compacts the normal deltas and the delete + // deltas for the same range. That is, if we had 3 directories, delta_30_30, + // delete_delta_40_40 and delta_50_50, then running minor compaction would produce + // delta_30_50 and delete_delta_30_50. + deltas.add(next); + prev = next; + } + } + + List dataFiles = new ArrayList<>(); + List deleteDeltas = new ArrayList<>(); + + FileFilter fileFilter = isFullAcid ? new FullAcidFileFilter() : new InsertOnlyFileFilter(); + + // delta directories + for (ParsedDelta delta : deltas) { + String location = delta.path; + List entries = listFiles(fs, location); + if (delta.deleteDelta) { + List deleteDeltaFileNames = new ArrayList<>(); + for (FileEntry entry : entries) { + String name = entry.name(); + if (fileFilter.accept(name)) { + deleteDeltaFileNames.add(name); + } + } + deleteDeltas.add(new DeleteDelta(location, deleteDeltaFileNames)); + continue; + } + for (FileEntry entry : entries) { + if (fileFilter.accept(entry.name())) { + dataFiles.add(entry); + } + } + } + + // base + if (bestBasePath != null) { + List entries = listFiles(fs, bestBasePath); + for (FileEntry entry : entries) { + if (fileFilter.accept(entry.name())) { + dataFiles.add(entry); + } + } + } + + if (!isFullAcid && !deleteDeltas.isEmpty()) { + throw new RuntimeException("No Hive Full Acid Table have delete_delta_* Dir."); + } + return new AcidState(dataFiles, deleteDeltas); + } +} 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 1f30e8f242c3d6..1ba13db82c1b64 100644 --- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnector.java +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnector.java @@ -18,21 +18,42 @@ package org.apache.doris.connector.hive; import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorTestResult; import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.event.ConnectorEventSource; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.procedure.ConnectorProcedureOps; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; +import org.apache.doris.connector.hms.CachingHmsClient; import org.apache.doris.connector.hms.HmsClient; import org.apache.doris.connector.hms.HmsClientConfig; import org.apache.doris.connector.hms.ThriftHmsClient; +import org.apache.doris.connector.hms.event.HmsEventSource; +import org.apache.doris.connector.metastore.HmsMetaStoreProperties; +import org.apache.doris.connector.metastore.spi.MetaStoreProviders; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.kerberos.HadoopAuthenticator; +import org.apache.doris.kerberos.KerberosAuthSpec; +import org.apache.doris.kerberos.KerberosAuthenticationConfig; +import org.apache.hadoop.conf.Configuration; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.io.IOException; import java.util.Collections; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.function.Consumer; /** * Hive connector implementation. Manages the lifecycle of @@ -42,23 +63,414 @@ public class HiveConnector implements Connector { private static final Logger LOG = LogManager.getLogger(HiveConnector.class); + // Catalog property key gating the plugin-side Kerberos authenticator (value matches AuthType.KERBEROS). + private static final String HADOOP_SECURITY_AUTHENTICATION = "hadoop.security.authentication"; + + // 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. + 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. + private static final String HUDI_CONNECTOR_TYPE = "hudi"; + private final Map properties; private final ConnectorContext context; private volatile HmsClient hmsClient; + // Lazily-built plugin-side Kerberos authenticator (single-owner auth), null for a non-Kerberos catalog. + // Its doAs acts on the PLUGIN's UserGroupInformation copy — the one the plugin's ThriftHmsClient RPC reads + // (hadoop + fe-kerberos bundled child-first) — not the app-loader copy the FE-injected context would use. + private volatile HadoopAuthenticator pluginAuth; + private volatile boolean pluginAuthComputed; + + // Read-transaction manager for transactional (ACID) Hive scans. One per connector, keyed by query. + // Plugin-owned and dormant until the read cutover wires its query-finish commit (see the manager). + private final HiveReadTransactionManager readTxnManager = new HiveReadTransactionManager(); + + // Connector-owned directory-listing cache, shared by the (per-call) scan provider and metadata. One per + // connector (like readTxnManager above) — the scan provider / metadata are rebuilt per query, so the cache + // must live on the long-lived connector to survive across scans. Built from catalog props; dormant until hms + // enters SPI_READY_TYPES. Its metastore-metadata sibling is the CachingHmsClient wrapping the HmsClient. + private final HiveFileListingCache fileListingCache; + + // Embedded iceberg SIBLING connector: a flipped hms gateway delegates its iceberg-on-HMS tables to it. Built + // once per gateway connector (lazily) in the iceberg plugin's OWN child-first classloader via + // context.createSiblingConnector — never co-packaged into the hive zip (a second AWS SDK would poison S3 + // JVM-wide). Held ONLY as the parent-first Connector interface and NEVER cast: its concrete type is invisible + // to the hive loader, so a cast would CCE across the loader split. Dormant until hms enters SPI_READY_TYPES — + // nothing builds it today. + private volatile Connector icebergSibling; + + // Embedded hudi SIBLING connector: a flipped hms gateway delegates its hudi-on-HMS tables to it. Same + // lifecycle/classloader contract as icebergSibling above — built once per gateway (lazily) in the hudi + // plugin's OWN child-first classloader via context.createSiblingConnector, never co-packaged into the hive + // zip (a second AWS SDK would poison S3 JVM-wide). Held ONLY as the parent-first Connector interface and + // NEVER cast (a cast would CCE across the loader split). Dormant until hms enters SPI_READY_TYPES AND the + // getTableHandle HUDI divert is wired (a later substep) — nothing references it today. + private volatile Connector hudiSibling; + public HiveConnector(Map properties, ConnectorContext context) { this.properties = Collections.unmodifiableMap(properties); this.context = context; + this.fileListingCache = new HiveFileListingCache(this.properties); } @Override public ConnectorMetadata getMetadata(ConnectorSession session) { - return new HiveConnectorMetadata(getOrCreateClient(), properties); + return newMetadata(getOrCreateClient()); + } + + /** + * Eagerly validates metastore connectivity during CREATE CATALOG (when {@code test_connection=true}), + * so a wrong {@code hive.metastore.uris} or a bad Kerberos setup fails the DDL instead of surfacing at + * the first query. Listing databases forces a real HMS round-trip through the authenticated client, + * which is what the legacy fe-core {@code HMSBaseConnectivityTester} did with {@code getAllDatabases()}. + * + *

The message deliberately carries both the {@code "HMS"} tag and the phrase + * {@code "connectivity test failed"} — the CREATE CATALOG regression asserts on both.

+ */ + @Override + public ConnectorTestResult testConnection(ConnectorSession session) { + try { + getMetadata(session).listDatabaseNames(session); + return ConnectorTestResult.success(); + } catch (Exception e) { + LOG.warn("HMS connectivity test failed for catalog '{}'", context.getCatalogName(), e); + return ConnectorTestResult.failure("HMS connectivity test failed: " + rootCauseMessage(e)); + } + } + + /** Unwraps to the deepest cause so the DDL error names the actual failure (e.g. a refused connection). */ + private static String rootCauseMessage(Throwable t) { + Throwable cause = t; + while (cause.getCause() != null && cause.getCause() != cause) { + cause = cause.getCause(); + } + String message = cause.getMessage(); + return message == null ? cause.toString() : message; + } + + /** + * Builds the connector's metadata with its sibling seams wired in. Extracted (package-private) from + * {@link #getMetadata} so a unit test can assert the wiring WITHOUT {@link #getOrCreateClient()} building a + * real ThriftHmsClient (whose Hadoop stack is absent from connector unit tests). The seams: + *
    + *
  • getOrCreateIcebergSibling / getOrCreateHudiSibling (by-TYPE, force-build): only the getTableHandle + * ICEBERG / HUDI diverts use them, to build+ask the matching sibling for a table detected as iceberg/hudi + * (no handle exists yet to route by). These two args share the static type {@code Supplier}, + * so a transposition would compile clean — HiveConnectorThreeWayRoutingTest pins the pairing.
  • + *
  • resolveSiblingOwner (by-HANDLE, peek): every per-handle site routes a foreign handle to whichever + * ALREADY-BUILT sibling owns it. Passing the resolver (not a built sibling) keeps a pure-hive query from + * ever building/throwing a sibling.
  • + *
+ */ + HiveConnectorMetadata newMetadata(HmsClient client) { + return new HiveConnectorMetadata(client, properties, context, + this::getOrCreateIcebergSibling, this::getOrCreateHudiSibling, this::resolveSiblingOwnerLabeled, + fileListingCache); + } + + /** + * Resolves which embedded sibling connector OWNS a foreign (non-hive) table handle AND its stable owner label, + * for the per-handle gateway seams (the connector-level {@code get*Provider(handle)} below and the + * guard-and-forward methods in {@link HiveConnectorMetadata}). Asks each sibling's {@link Connector#ownsHandle} + * — the sibling tests its OWN in-loader handle type, which is invisible to the hive loader across the plugin + * split, so the gateway can never {@code instanceof} the foreign handle itself. The MATCHED ARM supplies the + * label ({@link SiblingOwner#ICEBERG_LABEL} / {@link SiblingOwner#HUDI_LABEL}), so a per-handle forward keys the + * per-statement metadata funnel by owner without a force-build or an identity comparison against the suppliers. + * + *

Consults only ALREADY-BUILT siblings (a plain field read, never {@code getOrCreate*}). The owning + * sibling is always already built: a foreign handle can only originate from {@code getTableHandle}'s divert, + * which force-builds that sibling before producing the handle. Reading the field (not force-building) avoids + * demanding an UNRELATED plugin merely to classify a handle — e.g. a hudi-only hms catalog with no iceberg + * plugin must still route its hudi handles without building an iceberg sibling. Fails loud when no built + * sibling owns the handle (an orphan handle is a bug, not a route), naming the catalog. + */ + SiblingOwner resolveSiblingOwnerLabeled(ConnectorTableHandle handle) { + Connector iceberg = icebergSibling; + if (iceberg != null && iceberg.ownsHandle(handle)) { + return new SiblingOwner(iceberg, SiblingOwner.ICEBERG_LABEL); + } + Connector hudi = hudiSibling; + if (hudi != null && hudi.ownsHandle(handle)) { + return new SiblingOwner(hudi, SiblingOwner.HUDI_LABEL); + } + throw new DorisConnectorException("Cannot route a foreign table handle in catalog '" + + context.getCatalogName() + "': no embedded sibling connector owns it"); + } + + /** + * The owning sibling {@link Connector} alone (label dropped) for the connector-level per-handle provider seams + * ({@code get*Provider(handle)} below), which route by owner but never touch the metadata funnel. Delegates to + * {@link #resolveSiblingOwnerLabeled} so the 3-way ownsHandle dispatch has a single implementation. + */ + private Connector resolveSiblingOwner(ConnectorTableHandle handle) { + return resolveSiblingOwnerLabeled(handle).connector(); } @Override public ConnectorScanPlanProvider getScanPlanProvider() { - return new HiveScanPlanProvider(getOrCreateClient(), properties); + return new HiveScanPlanProvider(getOrCreateClient(), properties, context, readTxnManager, fileListingCache); + } + + /** + * Selects the scan provider for a given table handle — the gateway seam a flipped hms catalog uses to serve + * its iceberg-on-HMS tables from the embedded iceberg sibling. A hive handle (the gateway's OWN hive-loader + * type) runs the hive scan provider; any foreign handle (the raw iceberg handle the sibling's getTableHandle + * produced) is delegated to the sibling's per-handle scan provider. Because the returned sibling provider is + * built in the iceberg plugin's classloader, {@code PluginDrivenScanNode.onPluginClassLoader} auto-pins the + * scan-thread TCCL to the iceberg loader for free (it keys off {@code provider.getClass().getClassLoader()}), + * so no pinning is needed here. The foreign handle is passed through UNMODIFIED and NEVER cast (its concrete + * sibling type is invisible across the loader split — a cast would CCE). A HUDI table (once its divert lands) + * routes to the hudi sibling by the 3-way {@link #resolveSiblingOwner} — a HUDI-stamped HiveTableHandle stays + * hive. Pairs with the getTableHandle diverts; dormant until hms enters SPI_READY_TYPES (nothing selects a + * scan provider for this connector today). + */ + @Override + public ConnectorScanPlanProvider getScanPlanProvider(ConnectorTableHandle handle) { + if (handle instanceof HiveTableHandle) { + return getScanPlanProvider(); + } + return resolveSiblingOwner(handle).getScanPlanProvider(handle); + } + + @Override + public ConnectorWritePlanProvider getWritePlanProvider() { + return new HiveWritePlanProvider(getOrCreateClient(), properties, context); + } + + /** + * Per-table write provider: a hive handle uses the hive write provider; a foreign handle is delegated to the + * OWNING sibling's per-handle write provider (resolved 3-way by {@link #resolveSiblingOwner}). The foreign + * handle is passed through UNMODIFIED and NEVER cast (its concrete sibling type is invisible across the loader + * split — a cast would CCE). A HUDI-stamped HiveTableHandle stays on the hive write path. Mirrors {@link + * #getScanPlanProvider(ConnectorTableHandle)}; dormant until hms enters SPI_READY_TYPES. The returned sibling + * provider's planWrite runs on fe-core threads, but the write-path TCCL pin is already carried by the sibling's + * own {@code TcclPinningConnectorContext} (e.g. {@code IcebergConnector} wraps {@code executeAuthenticated}, + * classloader-thread-independent), so no additional pin is needed here — verified, not an open flip-time gap. + * The iceberg-on-HMS write path is e2e-owed on a heterogeneous HMS catalog. + */ + @Override + public ConnectorWritePlanProvider getWritePlanProvider(ConnectorTableHandle handle) { + if (handle instanceof HiveTableHandle) { + return getWritePlanProvider(); + } + return resolveSiblingOwner(handle).getWritePlanProvider(handle); + } + + /** + * Per-table procedure ops for {@code ALTER TABLE ... EXECUTE}: a hive handle has NO procedures — it inherits + * the connector-level {@code null} (plain-hive exposes none) — while a foreign handle is delegated to the + * OWNING sibling's per-handle procedure ops (resolved 3-way by {@link #resolveSiblingOwner}), so an + * iceberg-on-HMS table gains the native iceberg procedures (rollback_to_snapshot, rewrite_data_files, ...). + * The foreign handle is passed through UNMODIFIED and NEVER cast (its concrete sibling type is invisible + * across the loader split — a cast would CCE). A HUDI-stamped HiveTableHandle stays hive and inherits the + * null (no procedures), same as plain-hive. Mirrors {@link #getWritePlanProvider(ConnectorTableHandle)}; + * dormant until hms enters SPI_READY_TYPES (nothing selects procedure ops for this connector today). + */ + @Override + public ConnectorProcedureOps getProcedureOps(ConnectorTableHandle handle) { + if (handle instanceof HiveTableHandle) { + return getProcedureOps(); + } + return resolveSiblingOwner(handle).getProcedureOps(handle); + } + + @Override + public Set getCapabilities() { + // Connector-wide capabilities for the flipped hms catalog, each a faithful port of a legacy + // HMSExternalTable/HMS admission. Inert until hms enters SPI_READY_TYPES. + // - SUPPORTS_VIEW: legacy resolves isView() from the remote table's view text; the plugin path then + // consults viewExists, routes a view DROP to dropView, and merges listViewNames into SHOW TABLES — + // hive returns an EMPTY listViewNames (its listTableNames already includes views), so the merge is a + // no-op and each view is listed once (legacy parity). + // - SUPPORTS_METADATA_PRELOAD: legacy HMSExternalTable.supportsExternalMetadataPreload() returned true; + // the capability replaces the legacy engine-name "jdbc" gate. Opt-in via enable_preload_external_ + // metadata (default off), a pure lock-latency optimization with no correctness effect. + // - SUPPORTS_MVCC_SNAPSHOT: the heterogeneous hms catalog needs it (its iceberg/hudi-on-HMS tables are + // MvccTable, and the GSON single-row maps "HMSExternalTable" -> PluginDrivenMvccExternalTable, so + // buildTableInternal selects the Mvcc subclass from this catalog-level capability). Declared HERE + // together with its MTMV freshness machinery: HiveConnectorMetadata.getTableFreshness / + // getPartitionFreshnessMillis surface hive's last-modified freshness (transient_lastDdlTime), which + // PluginDrivenMvccExternalTable wraps into MTMVMaxTimestampSnapshot / MTMVTimestampSnapshot on the + // MTMV refresh path (byte-parity with legacy HiveDlaTable) — so a plain-hive base table's MV detects + // change instead of pinning a constant. Plain-hive stays non-MVCC per table (beginQuerySnapshot + // default-empty -> empty pin -> scan reads current); iceberg/hudi-on-HMS keep their snapshot-id + // freshness through the sibling delegation substep. (Fixes the earlier "hive is non-MVCC" note: hive + // is non-MVCC per table, but the catalog-level flag is required for the mixed catalog.) + // + // 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_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 + // HIVE and ICEBERG (NOT hudi) into background per-column auto-analyze. A connector-wide flag here would + // over-admit hudi-on-HMS (which legacy excluded), so it is NOT declared connector-wide: getTableSchema + // emits it per-table for every plain-hive table, and the sibling-delegation branch reflects the iceberg + // sibling's connector-wide capability set onto an iceberg-on-HMS table's schema (so it survives the + // delegation) — hudi-on-HMS, whose connector declares neither, is thereby correctly withheld. + // - SUPPORTS_NESTED_COLUMN_PRUNE: NOT emitted for plain-hive yet — a genuine deferral like MVCC. Legacy + // parquet/orc pruned nested columns, but the connector must first carry stable nested field-ids down its + // column tree (SlotTypeReplacer rewrites nested access to field-ids; without them BE reads NULL leaves). + // Restore it as a per-table marker once hive field-ids are verified. (An iceberg-on-HMS table DOES get + // it, via the sibling-capability reflection above, because the iceberg sibling carries field-ids.) + return EnumSet.of( + ConnectorCapability.SUPPORTS_VIEW, + ConnectorCapability.SUPPORTS_METADATA_PRELOAD, + ConnectorCapability.SUPPORTS_MVCC_SNAPSHOT); + } + + /** + * REFRESH TABLE hook: drop this table's connector-owned scan caches. Clears BOTH cache layers for the table — + * the metastore-metadata entries ({@link CachingHmsClient#flush}: table meta, partition names, partition + * objects, column stats) AND the {@link HiveFileListingCache} directory listings — because a hive table's + * schema, partitions AND files are all mutable, so a REFRESH must re-read all of them (unlike iceberg, whose + * manifests are immutable and kept across REFRESH TABLE). fe-core already routes {@code REFRESH TABLE} to + * {@code connector.invalidateTable} for a plugin-driven catalog; dormant until hms enters SPI_READY_TYPES. + * Also forwarded to the already-built embedded siblings — see {@link #forEachBuiltSibling}. + */ + @Override + public void invalidateTable(String dbName, String tableName) { + // Read the client field WITHOUT building it (getOrCreateClient would force a real ThriftHmsClient just to + // flush an empty cache): a never-built client means no metastore cache exists to flush. The file cache is + // a final field and always present. + invalidateTable(hmsClient, dbName, tableName); + forEachBuiltSibling(sibling -> sibling.invalidateTable(dbName, tableName)); + } + + // Package-private seam: the metastore half needs an observable CachingHmsClient, which a unit test can build + // via wrapWithCache (the hmsClient field is otherwise only set by getOrCreateClient building a real client). + void invalidateTable(HmsClient client, String dbName, String tableName) { + if (client instanceof CachingHmsClient) { + ((CachingHmsClient) client).flush(dbName, tableName); + } + fileListingCache.invalidateTable(dbName, tableName); + } + + /** + * REFRESH DATABASE hook: drop the connector-owned scan caches for EVERY table in this database — both cache + * layers ({@link CachingHmsClient#flushDb} metastore-metadata + {@link HiveFileListingCache#invalidateDb} + * directory listings). Same no-force-build read of the client as {@link #invalidateTable(String, String)}. + * fe-core routes {@code REFRESH DATABASE} to {@code connector.invalidateDb} for a plugin-driven catalog; + * dormant until hms enters SPI_READY_TYPES. + * Also forwarded to the already-built embedded siblings — see {@link #forEachBuiltSibling}. + */ + @Override + public void invalidateDb(String dbName) { + invalidateDb(hmsClient, dbName); + forEachBuiltSibling(sibling -> sibling.invalidateDb(dbName)); + } + + // Package-private seam (see invalidateTable above). + void invalidateDb(HmsClient client, String dbName) { + if (client instanceof CachingHmsClient) { + ((CachingHmsClient) client).flushDb(dbName); + } + fileListingCache.invalidateDb(dbName); + } + + /** + * REFRESH CATALOG hook: drop ALL of this catalog's connector-owned scan caches — every metastore-metadata + * entry ({@link CachingHmsClient#flushAll}) and every directory listing. Same no-force-build read of the + * client as {@link #invalidateTable(String, String)}. Dormant until the flip. + * Also forwarded to the already-built embedded siblings — see {@link #forEachBuiltSibling}. + */ + @Override + public void invalidateAll() { + invalidateAll(hmsClient); + forEachBuiltSibling(Connector::invalidateAll); + } + + // Package-private seam (see invalidateTable above). + void invalidateAll(HmsClient client) { + if (client instanceof CachingHmsClient) { + ((CachingHmsClient) client).flushAll(); + } + fileListingCache.invalidateAll(); + } + + /** + * Invalidates exactly the named partitions on a partition add/drop/alter, mirroring legacy + * {@code HiveExternalMetaCache}'s per-partition invalidation. The partition NAMES are parsed into VALUES + * purely ({@link HiveWriteUtils#toPartitionValues}, no metastore round-trip), which key both connector + * caches: the directory-listing cache ({@link HiveFileListingCache#invalidatePartitions}) and the metastore + * partition-metadata cache ({@link CachingHmsClient#invalidatePartitions}). Deriving values from the name + * (rather than looking the partition up) is exactly what stops an evicted partition-metadata entry from + * leaving a stale file listing — the #65334 failure mode. Table-level column stats and the table object are + * intentionally left intact (legacy did not drop them on a partition-level refresh). Also forwarded to the + * already-built embedded siblings. + */ + @Override + public void invalidatePartition(String dbName, String tableName, List partitionNames) { + invalidatePartition(hmsClient, dbName, tableName, partitionNames); + forEachBuiltSibling(sibling -> sibling.invalidatePartition(dbName, tableName, partitionNames)); + } + + // Package-private seam (mirrors invalidateTable): the metastore half needs an observable CachingHmsClient. + void invalidatePartition(HmsClient client, String dbName, String tableName, List partitionNames) { + Set> partitionValues = new HashSet<>(); + for (String name : partitionNames) { + partitionValues.add(HiveWriteUtils.toPartitionValues(name)); + } + if (client instanceof CachingHmsClient) { + ((CachingHmsClient) client).invalidatePartitions(dbName, tableName, partitionValues); + } + fileListingCache.invalidatePartitions(dbName, tableName, partitionValues); + } + + /** + * The catalog's incremental metadata-change source, or {@code null} when this catalog does not opt into + * HMS notification-event sync. The per-catalog opt-in is the connector's concern (fe-core does not parse + * hive properties): a source is returned only when the + * {@code hive.enable_hms_events_incremental_sync} property is set, and the engine's role-aware event + * driver skips connectors whose source is null. A malformed / not-yet-initialized property reads as + * disabled, mirroring the legacy poller's skip-on-throw. + */ + @Override + public ConnectorEventSource getEventSource() { + try { + if (!HiveConnectorProperties.getBoolean(properties, + HiveConnectorProperties.ENABLE_HMS_EVENTS_INCREMENTAL_SYNC, false)) { + return null; + } + } catch (RuntimeException e) { + return null; + } + int batchSize = HiveConnectorProperties.getInt(properties, + HiveConnectorProperties.HMS_EVENTS_BATCH_SIZE_PER_RPC, + HiveConnectorProperties.DEFAULT_HMS_EVENTS_BATCH_SIZE); + return new HmsEventSource(getOrCreateClient(), batchSize); + } + + /** + * Runs one invalidation action on each ALREADY-BUILT embedded sibling (iceberg, hudi). fe-core routes + * {@code REFRESH TABLE/DATABASE/CATALOG} only to a catalog's PRIMARY connector, so the gateway must + * propagate invalidation to the sibling-owned caches — e.g. the iceberg sibling's latest-snapshot pin, + * whose ACCESS-based expiry keeps a continuously-queried stale entry alive indefinitely, making an explicit + * REFRESH the only way to unpin an iceberg-on-HMS table's staleness. Mirrors {@link #close()}: plain + * volatile reads, never force-builds (a never-built sibling has no cache to drop, and building one just to + * flush it could fail-loud spuriously on a catalog whose sibling plugin is absent). + */ + private void forEachBuiltSibling(Consumer action) { + Connector iceberg = icebergSibling; + if (iceberg != null) { + action.accept(iceberg); + } + Connector hudi = hudiSibling; + if (hudi != null) { + action.accept(hudi); + } + } + + /** The connector-owned directory-listing cache, exposed for unit tests (mirrors iceberg manifestCacheForTest). */ + HiveFileListingCache fileListingCacheForTest() { + return fileListingCache; } private HmsClient getOrCreateClient() { @@ -72,7 +484,91 @@ private HmsClient getOrCreateClient() { return hmsClient; } + /** + * Lazily builds and memoizes the embedded iceberg sibling connector this hive gateway delegates its + * iceberg-on-HMS tables to. There is exactly ONE sibling per gateway connector (not per table): the iceberg + * connector holds per-catalog caches (latest-snapshot, manifest, scan→write delete stash) shared across + * its tables, which a per-op sibling would fragment. The sibling is built through + * {@link ConnectorContext#createSiblingConnector} so its concrete class is loaded by the iceberg plugin's own + * child-first classloader, sharing this gateway catalog's id/auth/storage; it is therefore held ONLY as the + * parent-first {@link Connector} interface and MUST NOT be cast (a cast would CCE across the loader split). + * + *

Fails loud when no iceberg provider is available (e.g. the plugin is not installed). The failure is NOT + * memoized (a null sibling leaves the field unset), so a later-available plugin recovers on the next access. + */ + Connector getOrCreateIcebergSibling() { + if (icebergSibling == null) { + synchronized (this) { + if (icebergSibling == null) { + Connector sibling = context.createSiblingConnector( + ICEBERG_CONNECTOR_TYPE, IcebergSiblingProperties.synthesize(properties)); + if (sibling == null) { + throw new DorisConnectorException( + "Cannot serve iceberg-on-HMS tables in catalog '" + context.getCatalogName() + + "': the iceberg connector plugin is not available"); + } + // Fail-loud invariant guard for the cache-isolation security track: the hive gateway FRONT + // DOOR never declares SUPPORTS_USER_SESSION, so fe-core keys its per-user schema/name cache + // bypass off THIS (front-door) connector's capabilities and would NOT bypass for a delegated + // sibling. The iceberg sibling is forced iceberg.catalog.type=hms (IcebergSiblingProperties + // .synthesize) and can never be REST session=user, so this must hold today. If a future change + // ever let the sibling be session=user, the front-door-only bypass would silently leak + // cross-user metadata — fail here instead. + if (sibling.getCapabilities().contains(ConnectorCapability.SUPPORTS_USER_SESSION)) { + throw new DorisConnectorException( + "iceberg-on-HMS sibling in catalog '" + context.getCatalogName() + + "' unexpectedly declares SUPPORTS_USER_SESSION: the hive gateway front " + + "door is not session=user, so fe-core's per-user schema/name cache bypass " + + "would not trigger and cross-user metadata would leak. The sibling must " + + "stay iceberg.catalog.type=hms (never REST session=user)."); + } + icebergSibling = sibling; + } + } + } + return icebergSibling; + } + + /** + * Lazily builds and memoizes the embedded hudi sibling connector this hive gateway delegates its + * hudi-on-HMS tables to. Mirrors {@link #getOrCreateIcebergSibling()}: exactly ONE sibling per gateway + * connector (not per table), built through {@link ConnectorContext#createSiblingConnector} so its concrete + * class is loaded by the hudi plugin's own child-first classloader, sharing this gateway catalog's + * id/auth/storage; it is therefore held ONLY as the parent-first {@link Connector} interface and MUST NOT be + * cast (a cast would CCE across the loader split). + * + *

Fails loud when no hudi provider is available (e.g. the plugin is not installed). The failure is NOT + * memoized (a null sibling leaves the field unset), so a later-available plugin recovers on the next access. + * + *

Dormant: no production path references it until the getTableHandle HUDI divert lands (a later substep). + */ + Connector getOrCreateHudiSibling() { + if (hudiSibling == null) { + synchronized (this) { + if (hudiSibling == null) { + Connector sibling = context.createSiblingConnector( + HUDI_CONNECTOR_TYPE, HudiSiblingProperties.synthesize(properties)); + if (sibling == null) { + throw new DorisConnectorException( + "Cannot serve hudi-on-HMS tables in catalog '" + context.getCatalogName() + + "': the hudi connector plugin is not available"); + } + hudiSibling = sibling; + } + } + } + return hudiSibling; + } + private HmsClient createClient() { + // Catches catalogs created before the type was removed: they deserialize from the image without ever + // reaching validateProperties, so this lazy path is their only chance at a message that names glue. + // Must precede the URI check below — a glue catalog sets no hive.metastore.uris. + String removedType = HmsClientConfig.removedMetastoreTypeError(properties); + if (removedType != null) { + throw new DorisConnectorException(removedType); + } + String metastoreUri = properties.get(HiveConnectorProperties.HIVE_METASTORE_URIS); if (metastoreUri == null || metastoreUri.isEmpty()) { // Also check the "uri" short form @@ -92,8 +588,137 @@ private HmsClient createClient() { context.getCatalogName(), config.getMetastoreUri(), config.getMetastoreType(), poolSize); - ThriftHmsClient.AuthAction authAction = context::executeAuthenticated; - return new ThriftHmsClient(config, authAction); + // For a Kerberos catalog run the metastore RPC under the PLUGIN's UGI doAs (buildPluginAuthenticator), + // NOT the FE-injected context: after the catalog flip that context resolves to NOOP (SIMPLE) auth, which + // would silently downgrade a Kerberos HMS. AuthAction.execute is a generic method ( T execute(...)), + // so it cannot be a lambda — use an anonymous class. ThriftHmsClient.doAs pins the RPC's TCCL to the + // plugin (child-first) classloader (so SecurityUtil. resolves hadoop from the plugin copy, not a + // split-brain against fe-core's copy); the plugin's HadoopAuthenticator only wraps it in a UGI doAs. + HadoopAuthenticator auth = pluginAuthenticator(); + ThriftHmsClient.AuthAction authAction; + if (auth != null) { + authAction = new ThriftHmsClient.AuthAction() { + @Override + public T execute(Callable callable) throws Exception { + return auth.doAs(callable::call); + } + }; + } else { + authAction = context::executeAuthenticated; + } + // Feed the catalog's type-mapping options (enable.mapping.varbinary / enable.mapping.timestamp_tz) to + // the LIVE client: ThriftHmsClient.convertFieldSchemas maps hive column types with the client's own + // options, so the 2-arg ctor (HmsTypeMapping.Options.DEFAULT) would ignore the catalog toggles and + // always map hive BINARY -> STRING / timestamp -> non-TZ. Commit 5672d7c0209 read the dot-keys but only + // into a dead metadata field; the fix is to build the options here where the client is constructed. + return wrapWithCache(new ThriftHmsClient(config, authAction, + HiveConnectorMetadata.buildTypeMappingOptions(properties))); + } + + /** + * Wraps the raw pooled metastore client in the connector-owned {@link CachingHmsClient} so this connector + * keeps caching its scan-side metastore reads AFTER the hms cutover. Once a hive catalog becomes plugin-driven + * the fe-core engine-side {@code HiveExternalMetaCache} stops routing to it, so without this wrap every + * {@code getTable} / {@code listPartitionNames} / {@code getPartitions} / column-stats read would regress to a + * fresh Thrift RPC on every scan. This single wrap makes ALL {@code hmsClient.*} reads in + * {@link HiveConnectorMetadata} cache-backed transparently — including the + * {@link HiveConnectorMetadata#getTableFreshness}/{@link HiveConnectorMetadata#getPartitionFreshnessMillis} + * MTMV probes (both read {@code hmsClient.getPartitions}), so the periodic SQL-dictionary / MV freshness poll + * stays cheap instead of hitting the metastore every tick. + * + *

The decorator reads its per-entry knobs from this catalog's own {@code meta.cache.hive.*} properties; a + * disabled entry (or {@code ttl-second}/{@code capacity} <= 0) makes it a transparent pass-through. The + * cache lives on the {@code HmsClient} (rebuilt on connector init / {@code ADD}/{@code MODIFY CATALOG}), never + * on a handle or the GSON edit log. + * + *

Extracted (package-private) so a unit test can verify the wrap + caching WITHOUT {@link #createClient()} + * building a real {@code ThriftHmsClient} (whose Hadoop stack is absent from connector unit tests) — mirrors + * {@link #newMetadata(HmsClient)}. Dormant: {@code "hms"} is not in {@code SPI_READY_TYPES}, so no live catalog + * builds a {@code HiveConnector} — this wrap only runs in unit tests until the flip. + */ + HmsClient wrapWithCache(HmsClient raw) { + return new CachingHmsClient(raw, properties); + } + + /** + * Lazily builds and memoizes the plugin-side Kerberos authenticator that {@link #createClient()} wraps the + * metastore RPC under, so the RPC uses the PLUGIN's own {@code UserGroupInformation} copy (hadoop + + * fe-kerberos are bundled child-first in the hive plugin). Returns {@code null} for a non-Kerberos catalog + * so the FE-injected auth path is preserved unchanged. Construction is cheap — the keytab login is lazy in + * {@code getUGI()} on the first {@code doAs}. + */ + private HadoopAuthenticator pluginAuthenticator() { + if (!pluginAuthComputed) { + synchronized (this) { + if (!pluginAuthComputed) { + pluginAuth = buildPluginAuthenticator(properties); + pluginAuthComputed = true; + } + } + } + return pluginAuth; + } + + /** + * Resolves the plugin-side Kerberos authenticator for the catalog, or {@code null} for a non-Kerberos + * catalog. Two Kerberos sources are covered, in precedence order (mirroring the legacy + * {@code HMSBaseProperties.initHadoopAuthenticator}): + *

    + *
  1. Storage Kerberos — the raw {@code hadoop.security.authentication=kerberos} passthrough + * (HDFS login), built from the catalog Hadoop configuration. When storage is Kerberos this single + * login also carries the HMS metastore RPC (same UGI).
  2. + *
  3. HMS-metastore Kerberos with non-Kerberos storage — a secured Hive Metastore whose data + * storage is simple (e.g. a Kerberized HMS over S3). The HMS client principal/keytab facts + * ({@link HmsMetaStoreProperties#kerberos()}, resolved through the shared metastore-spi parser) feed a + * {@link KerberosAuthenticationConfig}, so the {@code doAs} logs in the same client identity fe-core + * used. The HMS service principal / SASL settings ride the catalog's own HiveConf, not the + * login.
  4. + *
+ * Package-visible + static for KDC-free unit testing. + */ + static HadoopAuthenticator buildPluginAuthenticator(Map properties) { + // Pin the TCCL to the plugin (child-first) classloader for the whole resolution. A catalog that declares + // hadoop.security.authentication=kerberos WITHOUT a principal/keytab falls back to a + // HadoopSimpleAuthenticator whose ctor EAGERLY calls UserGroupInformation.createRemoteUser -> + // SecurityUtil., whose internal `new Configuration()` captures the current TCCL. This runs on the + // (unpinned) createClient thread, so without the pin it would resolve hadoop's DNSDomainNameResolver from + // fe-core's system-loader copy and split-brain-poison SecurityUtil against the plugin copy (the same + // failure ThriftHmsClient.doAs guards). buildHadoopConf pins only the OUTER conf's loader, not the TCCL + // that SecurityUtil's own Configuration reads — so the thread pin is required here too. + // (HadoopKerberosAuthenticator's keytab login is lazy in getUGI, already under doAs's pin.) + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(HiveConnector.class.getClassLoader()); + if ("kerberos".equalsIgnoreCase(properties.get(HADOOP_SECURITY_AUTHENTICATION))) { + return HadoopAuthenticator.getHadoopAuthenticator(buildHadoopConf(properties)); + } + HmsMetaStoreProperties hms = (HmsMetaStoreProperties) MetaStoreProviders.bindForType( + HmsClientConfig.METASTORE_TYPE_HMS, properties, Collections.emptyMap()); + Optional spec = hms.kerberos(); + if (spec.isPresent() && spec.get().hasCredentials()) { + Configuration conf = buildHadoopConf(properties); + conf.set("hadoop.security.authentication", "kerberos"); + conf.set("hive.metastore.sasl.enabled", "true"); + return HadoopAuthenticator.getHadoopAuthenticator( + new KerberosAuthenticationConfig(spec.get().getPrincipal(), spec.get().getKeytab(), conf)); + } + return null; + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + } + + /** + * Builds a plain Hadoop {@link Configuration} from the catalog properties for the authenticator. A plain + * {@code new Configuration()} (NOT {@code HiveConf}) is used deliberately: HiveConf static-init would drag + * hadoop-mapreduce onto the unit-test classpath. The classloader is pinned to the plugin loader so the + * child-first (plugin) copy of the auth classes is resolved. + */ + private static Configuration buildHadoopConf(Map properties) { + Configuration conf = new Configuration(); + conf.setClassLoader(HiveConnector.class.getClassLoader()); + properties.forEach(conf::set); + return conf; } @Override @@ -103,5 +728,18 @@ public void close() throws IOException { c.close(); hmsClient = null; } + // Forward close to the embedded iceberg sibling: the engine closes only a catalog's PRIMARY connector, + // so the gateway owns the sibling's lifecycle. No-op when the sibling was never built (dormant path). + Connector sibling = icebergSibling; + if (sibling != null) { + sibling.close(); + icebergSibling = null; + } + // Same for the embedded hudi sibling — the gateway owns its lifecycle too. No-op when never built. + Connector hudi = hudiSibling; + if (hudi != null) { + hudi.close(); + hudiSibling = null; + } } } 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 906236a44cb12f..07f50ca76ca4a2 100644 --- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java @@ -17,12 +17,36 @@ package org.apache.doris.connector.hive; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorColumnStatistics; +import org.apache.doris.connector.api.ConnectorDatabaseMetadata; import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorPartitionInfo; import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.ConnectorTableSchema; +import org.apache.doris.connector.api.ConnectorTableStatistics; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.ConnectorViewDefinition; +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.ConnectorColumnPosition; +import org.apache.doris.connector.api.ddl.ConnectorCreateTableRequest; +import org.apache.doris.connector.api.ddl.ConnectorPartitionField; +import org.apache.doris.connector.api.ddl.ConnectorPartitionSpec; +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.handle.ConnectorTransaction; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.mvcc.ConnectorMvccPartitionView; +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.pushdown.ConnectorAnd; import org.apache.doris.connector.api.pushdown.ConnectorComparison; import org.apache.doris.connector.api.pushdown.ConnectorExpression; @@ -30,23 +54,46 @@ import org.apache.doris.connector.api.pushdown.ConnectorIn; import org.apache.doris.connector.api.pushdown.ConnectorLiteral; import org.apache.doris.connector.api.pushdown.FilterApplicationResult; +import org.apache.doris.connector.api.scan.ConnectorPartitionValues; +import org.apache.doris.connector.hms.HiveShowCreateTableRenderer; import org.apache.doris.connector.hms.HmsClient; import org.apache.doris.connector.hms.HmsClientException; +import org.apache.doris.connector.hms.HmsColumnStatistics; +import org.apache.doris.connector.hms.HmsCreateDatabaseRequest; +import org.apache.doris.connector.hms.HmsCreateTableRequest; import org.apache.doris.connector.hms.HmsPartitionInfo; 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.filesystem.FileSystem; +import org.apache.doris.thrift.THiveTable; +import org.apache.doris.thrift.TTableDescriptor; +import org.apache.doris.thrift.TTableType; +import com.google.gson.Gson; +import com.google.gson.JsonObject; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Base64; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.OptionalLong; import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.function.ToLongBiFunction; import java.util.stream.Collectors; /** @@ -66,171 +113,1816 @@ public class HiveConnectorMetadata implements ConnectorMetadata { private static final Logger LOG = LogManager.getLogger(HiveConnectorMetadata.class); + // 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 + // stripped from the user-facing SHOW CREATE properties by fe-core. The central reserved-key definition + // (namespaced under __internal.) lives in ConnectorTableSchema. + private static final String PARTITION_COLUMNS_PROPERTY = ConnectorTableSchema.PARTITION_COLUMNS_KEY; + + // Connector-side spelling of fe-type ScalarType.MAX_VARCHAR_LENGTH (the connector must not import fe-type); + // a hive `string` partition column is widened to varchar(65533) for legacy parity. Paimon hardcodes the + // identical 65533. + private static final int MAX_VARCHAR_LENGTH = 65533; + + // Hive-canonical partition text for a DATETIME/TIMESTAMP literal: space separator, full seconds. See + // hiveDateTimeString / extractLiteralValue (H2: String.valueOf(LocalDateTime) would yield ISO "…T…" and drop + // zero seconds, never matching the stored Hive partition value). + private static final DateTimeFormatter HIVE_DATETIME_SECONDS_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + // Hive input formats eligible for Top-N lazy materialization, replicating legacy + // HMSExternalTable.SUPPORTED_HIVE_TOPN_LAZY_FILE_FORMATS (parquet/orc only). The match is on the EXACT + // input-format class (not a substring), so a HoodieParquetInputFormatBase hive table — which contains + // "parquet" but is not a Top-N-lazy format in legacy — is correctly excluded. + private static final String MAPRED_PARQUET_INPUT_FORMAT = + "org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat"; + private static final String ORC_INPUT_FORMAT = + "org.apache.hadoop.hive.ql.io.orc.OrcInputFormat"; + + // HMS table type for a view, mirroring legacy HMSExternalTable.isView (which keyed off the view text flags); + // a Hive view carries tableType VIRTUAL_VIEW. + private static final String VIRTUAL_VIEW_TABLE_TYPE = "VIRTUAL_VIEW"; + + // Presto/Trino view markers, replicating legacy HMSExternalTable.getViewText / parseTrinoViewDefinition. A + // Presto/Trino-authored hive view stores the bare sentinel as its expanded text (skipped) and the real + // definition as base64-encoded JSON inside the original text ("/* Presto View: */"). + private static final String PRESTO_VIEW_EXPANDED_SENTINEL = "/* Presto View */"; + private static final String PRESTO_VIEW_PREFIX = "/* Presto View: "; + private static final String PRESTO_VIEW_SUFFIX = " */"; + private static final String PRESTO_VIEW_ORIGINAL_SQL_KEY = "originalSql"; + + // Placeholder SQL dialect for a hive view. fe-core never reads ConnectorViewDefinition.getDialect() (the + // view body is converted by the session dialect in BindRelation.parseAndAnalyzeExternalView), but the DTO + // requires a non-null value; legacy getSqlDialect was likewise never consumed by the query path. + private static final String HIVE_VIEW_DIALECT = "hive"; + + // HMS table-parameter keys for table statistics, replicating legacy StatisticsUtil.getHiveRowCount / + // getRowCountFromParameters / getTotalSizeFromHMS. numRows is the exact row count; totalSize is the on-disk + // data size. Each has a spark-written alternative key (spark writes its own stats keys, not the standard + // hive ones). Read as RAW facts only — the connector must NOT do the Doris-type-dependent estimation. + private static final String PARAM_NUM_ROWS = "numRows"; + private static final String PARAM_SPARK_NUM_ROWS = "spark.sql.statistics.numRows"; + private static final String PARAM_TOTAL_SIZE = "totalSize"; + private static final String PARAM_SPARK_TOTAL_SIZE = "spark.sql.statistics.totalSize"; + + // Partition-sampling cap for the file-list data-size estimate, matching the default of the legacy + // Config.hive_stats_partition_sample_size (fe-common, unreadable from the plugin). Runtime tuning of that + // specific config no longer applies on the plugin path (negligible — it is an internal estimation knob); + // the on/off feature gate (enable_get_row_count_from_file_list) is still honored, fe-core side. + private static final int STATS_PARTITION_SAMPLE_SIZE = 30; + + // Upper bound on partitions listed from HMS for the file-list estimate, matching HiveScanPlanProvider. + private static final int MAX_PARTITIONS_FOR_STATS = 100000; + + // A Supplier installed by the 3-arg constructor when no iceberg sibling is available (hive-only + // construction, e.g. unit tests exercising only hive-handle paths). It is invoked only when a NON-hive + // handle is delegated — which such a construction never does — so it fails loud instead of NPEing. + private static final Supplier NO_ICEBERG_SIBLING = () -> { + throw new DorisConnectorException("no iceberg sibling connector configured for this hive metadata"); + }; + + // The hudi analog of NO_ICEBERG_SIBLING installed by the 3-arg constructor (hive-only construction). Invoked + // only when a HUDI table is diverted BY TYPE — which such a construction never triggers — so it fails loud + // instead of NPEing. + private static final Supplier NO_HUDI_SIBLING = () -> { + throw new DorisConnectorException("no hudi sibling connector configured for this hive metadata"); + }; + + // The by-handle owner resolver installed by the 3-arg constructor (hive-only construction). Invoked only when + // a NON-hive handle reaches a per-handle guard-and-forward site — which such a construction never does — so it + // fails loud instead of NPEing. + private static final Function NO_SIBLING_OWNER = handle -> { + throw new DorisConnectorException("no sibling connector configured for this hive metadata"); + }; + private final HmsClient hmsClient; private final Map properties; - private final HmsTypeMapping.Options typeMappingOptions; + // 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 + // pass a bare context degrade to the hard-coded fallbacks in createTable. + private final ConnectorContext context; + // Supplies the embedded iceberg SIBLING connector BY TYPE, for the getTableHandle ICEBERG divert only (an + // iceberg-detected table has no handle yet to route by, so the sibling is force-built and asked directly). + // Lazy: HiveConnector.getOrCreateIcebergSibling builds it only on first use, so a pure-hive query never + // triggers it. Used ONLY through the parent-first Connector / ConnectorMetadata interfaces — its concrete + // iceberg types are never cast here (cross-loader CCE). + private final Supplier icebergSiblingSupplier; + // The hudi analog of icebergSiblingSupplier: supplies the embedded hudi SIBLING connector BY TYPE, for the + // getTableHandle HUDI divert only (a hudi-detected table has no handle yet to route by). Lazy via + // HiveConnector.getOrCreateHudiSibling; used ONLY through the parent-first Connector / ConnectorMetadata + // interfaces — its concrete hudi types are never cast here (cross-loader CCE). + private final Supplier hudiSiblingSupplier; + // Resolves the embedded sibling connector that OWNS a foreign (non-hive) table handle, for the per-handle + // guard-and-forward methods below. Backed by HiveConnector.resolveSiblingOwner (a 3-way ownsHandle dispatch + // over the ALREADY-BUILT iceberg / hudi siblings). Used ONLY through the parent-first Connector / + // ConnectorMetadata interfaces — the owning sibling's concrete types are never cast here (cross-loader CCE). + private final Function siblingOwnerResolver; + // Connector-owned directory-listing cache, shared with the scan provider so estimateDataSizeByListingFiles + // (the periodic ExternalRowCountCache refresh source) reuses listings a scan warmed and vice versa. Injected + // by HiveConnector.newMetadata as the SAME instance; the convenience constructors below build a private + // default (harmless for the direct-construction tests, which inject their file sizes and never list). + private final HiveFileListingCache fileListingCache; + + public HiveConnectorMetadata(HmsClient hmsClient, Map properties, ConnectorContext context) { + this(hmsClient, properties, context, NO_ICEBERG_SIBLING, NO_HUDI_SIBLING, NO_SIBLING_OWNER); + } + + public HiveConnectorMetadata(HmsClient hmsClient, Map properties, ConnectorContext context, + Supplier icebergSiblingSupplier, + Supplier hudiSiblingSupplier, + Function siblingOwnerResolver) { + this(hmsClient, properties, context, icebergSiblingSupplier, hudiSiblingSupplier, siblingOwnerResolver, + new HiveFileListingCache(properties)); + } - public HiveConnectorMetadata(HmsClient hmsClient, Map properties) { + public HiveConnectorMetadata(HmsClient hmsClient, Map properties, ConnectorContext context, + Supplier icebergSiblingSupplier, + Supplier hudiSiblingSupplier, + Function siblingOwnerResolver, + HiveFileListingCache fileListingCache) { this.hmsClient = hmsClient; this.properties = properties; - this.typeMappingOptions = buildTypeMappingOptions(properties); + this.context = context; + this.icebergSiblingSupplier = icebergSiblingSupplier; + this.hudiSiblingSupplier = hudiSiblingSupplier; + this.siblingOwnerResolver = siblingOwnerResolver; + this.fileListingCache = fileListingCache; + } + + /** + * Obtains the sibling's {@link ConnectorMetadata} through the per-statement funnel: within one statement, the + * first forward for a given owner runs {@code owner.getMetadata(session)} and every later forward (read / scan + * / DDL / MVCC / the per-handle {@code beginTransaction} open) reuses that ONE memoized instance — mirroring + * fe-core's own {@code PluginDrivenMetadata} funnel for a plain connector (the sibling's heavy catalog/caches + * live on the single memoized sibling CONNECTOR regardless of this). The key is + * {@code "metadata:" + catalogId + ":" + ownerLabel}: the three connectors of a heterogeneous gateway + * (hive + iceberg + hudi) share ONE catalogId, so the owner label keeps their metadata entries distinct — a + * plain catalogId key would collapse them onto one metadata and misroute. Under a + * {@link org.apache.doris.connector.api.ConnectorStatementScope#NONE NONE} scope (offline / no statement) the + * factory runs on every call — byte-identical to the pre-funnel behavior. Only fe-connector-api types are + * touched, so no fe-core dependency is introduced. The returned metadata and any handle it produces are used + * ONLY through the parent-first SPI interfaces and MUST NOT be cast (the sibling's concrete iceberg/hudi types + * would CCE across the loader split). + */ + private ConnectorMetadata memoizedSiblingMetadata(ConnectorSession session, Connector owner, String ownerLabel) { + String key = "metadata:" + session.getCatalogId() + ":" + ownerLabel; + return session.getStatementScope().getOrCreateMetadata(key, () -> owner.getMetadata(session)); + } + + /** + * The embedded iceberg sibling's metadata resolved BY TYPE, for the getTableHandle ICEBERG divert only (an + * iceberg-detected table has no handle yet, so the sibling is force-built and asked directly). Routed through + * {@link #memoizedSiblingMetadata} under {@link SiblingOwner#ICEBERG_LABEL}, so the divert and the same + * statement's later per-handle forwards share ONE iceberg metadata instance. The returned metadata and any + * handle it produces are used ONLY through the parent-first SPI interfaces and MUST NOT be cast. + * + *

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

Package-private (not private) so HiveConnectorThreeWayRoutingTest can assert that + * {@link HiveConnector#getMetadata} wires the hudi by-TYPE supplier to THIS arm (see + * {@link #icebergSiblingMetadata}). + */ + ConnectorMetadata hudiSiblingMetadata(ConnectorSession session) { + return memoizedSiblingMetadata(session, hudiSiblingSupplier.get(), SiblingOwner.HUDI_LABEL); + } + + /** + * The OWNING sibling's metadata for a foreign (non-hive) table handle, resolved BY HANDLE (3-way ownsHandle + * dispatch over the already-built iceberg / hudi siblings — see HiveConnector.resolveSiblingOwnerLabeled). + * Every per-handle guard-and-forward method (and the per-handle {@code beginTransaction} open) routes through + * here, so a hudi handle reaches the hudi sibling and an iceberg handle the iceberg sibling. The resolver + * supplies the owner label from its matched arm, and {@link #memoizedSiblingMetadata} keys the funnel by it — + * so a statement's forwards for one owner share ONE metadata instance, and the by-HANDLE label matches the + * by-TYPE label above (same owner → same key → the getTableHandle divert and these forwards reuse + * one instance). The handle is used ONLY through the parent-first SPI interfaces and MUST NOT be cast + * (cross-loader CCE). + */ + private ConnectorMetadata siblingMetadata(ConnectorSession session, ConnectorTableHandle handle) { + SiblingOwner owner = siblingOwnerResolver.apply(handle); + return memoizedSiblingMetadata(session, owner.connector(), owner.label()); + } + + // ========== ConnectorSchemaOps ========== + + @Override + public List listDatabaseNames(ConnectorSession session) { + return hmsClient.listDatabases(); + } + + @Override + public boolean databaseExists(ConnectorSession session, String dbName) { + try { + hmsClient.getDatabase(dbName); + return true; + } catch (HmsClientException e) { + LOG.debug("Database '{}' not found: {}", dbName, e.getMessage()); + return false; + } + } + + @Override + public ConnectorDatabaseMetadata getDatabase(ConnectorSession session, String dbName) { + // Surface the HMS database base location for SHOW CREATE DATABASE under the neutral "location" + // property key (Trino-aligned properties-map model). Mirrors IcebergConnectorMetadata.getDatabase + // (and legacy HMSExternalCatalog which emitted LOCATION via db.getLocationUri()). Without this + // override the ConnectorSchemaOps default returns an empty property map, so SHOW CREATE DATABASE + // rendered no LOCATION clause. The key is omitted when blank so a location-less namespace renders + // no LOCATION rather than LOCATION ''. The hmsClient is already auth-wrapped (see databaseExists). + Map props = new HashMap<>(); + String location = hmsClient.getDatabase(dbName).getLocationUri(); + if (location != null && !location.isEmpty()) { + props.put(ConnectorDatabaseMetadata.LOCATION_PROPERTY, location); + } + return new ConnectorDatabaseMetadata(dbName, props); + } + + // ========== ConnectorTableOps ========== + + @Override + public List listTableNames(ConnectorSession session, String dbName) { + return hmsClient.listTables(dbName); + } + + @Override + public Optional getTableHandle( + ConnectorSession session, String dbName, String tableName) { + if (!hmsClient.tableExists(dbName, tableName)) { + return Optional.empty(); + } + HmsTableInfo tableInfo = hmsClient.getTable(dbName, tableName); + HiveTableType tableType = HiveTableFormatDetector.detect(tableInfo); + // Foreign-handle divert: an iceberg-on-HMS or hudi-on-HMS table registered in this HMS catalog is served by + // the embedded iceberg / hudi SIBLING connector, not by hive. Return the sibling's OWN table handle (the + // raw foreign iceberg/hudi handle) verbatim — NOT a HiveTableHandle stamped ICEBERG/HUDI — so the sibling's + // scan/metadata path, which unconditionally casts the handle to its concrete IcebergTableHandle / + // HudiTableHandle, succeeds. This is the pivot that activates the guard-and-forward overrides throughout + // this class: every gateway consumer discriminates by `instanceof HiveTableHandle` (the gateway's OWN + // hive-loader type) and forwards any non-hive handle to whichever sibling OWNS it (3-way ownsHandle + // dispatch); the foreign handle is NEVER cast here (its concrete type is invisible across the classloader + // split). Iceberg is checked before hudi, matching the detector's own precedence (a table carrying both + // resolves iceberg). Dormant overall until hms enters SPI_READY_TYPES: today getTableHandle is never called + // for this connector. + if (tableType == HiveTableType.ICEBERG) { + return icebergSiblingMetadata(session).getTableHandle(session, dbName, tableName); + } + if (tableType == HiveTableType.HUDI) { + return hudiSiblingMetadata(session).getTableHandle(session, dbName, tableName); + } + // Fail-loud parity with legacy HMSExternalTable.supportedHiveTable(), which threw on a null or + // unrecognized input format instead of silently degrading (the old detector returned UNKNOWN). A view + // short-circuits: legacy returns true for a view before the format check — a view has no data files so + // its (usually null) input format is irrelevant, and it is served through the view SPI, not the scan + // path, so its handle keeps the UNKNOWN type (never scanned) rather than being rejected here. + if (tableType == HiveTableType.UNKNOWN && !isViewTable(tableInfo)) { + String inputFormat = tableInfo.getInputFormat(); + throw new DorisConnectorException(inputFormat == null + ? "remote table's storage input format is null" + : "Unsupported hive input format: " + inputFormat); + } + + // Build partition key column names + List partKeyNames = Collections.emptyList(); + List partKeys = tableInfo.getPartitionKeys(); + if (partKeys != null && !partKeys.isEmpty()) { + partKeyNames = partKeys.stream() + .map(ConnectorColumn::getName) + .collect(Collectors.toList()); + } + + HiveTableHandle handle = new HiveTableHandle.Builder(dbName, tableName, tableType) + .inputFormat(tableInfo.getInputFormat()) + .serializationLib(tableInfo.getSerializationLib()) + .location(tableInfo.getLocation()) + .partitionKeyNames(partKeyNames) + .sdParameters(tableInfo.getSdParameters()) + .tableParameters(tableInfo.getParameters()) + .firstColumnIsString(firstColumnIsString(tableInfo)) + .build(); + return Optional.of(handle); + } + + /** + * Renders native hive {@code SHOW CREATE TABLE} DDL from a FRESH metastore read (see + * {@link HiveShowCreateTableRenderer}). Fetches via {@link HmsClient#getTableFresh} — SHOW CREATE must show + * the latest schema even while {@code DESC}, served from the schema cache, is stale (the {@code use_meta_cache} + * freshness contract). A delegated iceberg/hudi-on-HMS table routes through THIS hive gateway metadata + * ({@code getTableHandle} returns the sibling's foreign handle), so guard exactly like {@link #getTableSchema}: + * a non-{@link HiveTableHandle} is not a plain-hive base table — return empty to defer to the engine + * ({@code Env.getDdlStmt}), keeping delegated-table SHOW CREATE at today's behavior. + */ + @Override + public Optional renderShowCreateTableDdl( + ConnectorSession session, ConnectorTableHandle handle) { + if (!(handle instanceof HiveTableHandle)) { + return Optional.empty(); + } + HiveTableHandle hiveHandle = (HiveTableHandle) handle; + HmsTableInfo tableInfo = hmsClient.getTableFresh(hiveHandle.getDbName(), hiveHandle.getTableName()); + return Optional.of(HiveShowCreateTableRenderer.render(tableInfo)); + } + + @Override + 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 + // (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. + SiblingOwner owner = siblingOwnerResolver.apply(handle); + ConnectorTableSchema siblingSchema = memoizedSiblingMetadata(session, owner.connector(), owner.label()) + .getTableSchema(session, handle); + return reflectSiblingScanCapabilities(owner.connector(), siblingSchema); + } + HiveTableHandle hiveHandle = (HiveTableHandle) handle; + String dbName = hiveHandle.getDbName(); + String tableName = hiveHandle.getTableName(); + + HmsTableInfo tableInfo = hmsClient.getTable(dbName, tableName); + List columns = buildColumns(tableInfo); + List partitionKeys = coercePartitionKeyStringToVarchar(buildPartitionKeys(tableInfo)); + + // Merge: regular columns + partition columns (partition columns last, mirroring legacy + // HMSExternalTable full-schema order: data columns then partition keys). + List allColumns = new ArrayList<>(columns.size() + partitionKeys.size()); + allColumns.addAll(columns); + allColumns.addAll(partitionKeys); + + String formatType = detectFormatType(tableInfo); + // Copy the HMS table parameters so the FE-internal partition_columns marker can be stamped without + // mutating the shared tableInfo map. + Map tableProperties = new HashMap<>( + tableInfo.getParameters() != null ? tableInfo.getParameters() : Collections.emptyMap()); + // Mark which emitted columns are partition columns for the generic fe-core consumer. Without this + // property every partitioned hive/hudi table is read as unpartitioned (wrong pruning/row count, MTMV + // breakage). The value is a CSV of the RAW partition-key names in declaration order; hive partition-key + // names are identifiers (no comma) so the CSV encoding is unambiguous. + if (!partitionKeys.isEmpty()) { + tableProperties.put(PARTITION_COLUMNS_PROPERTY, partitionKeys.stream() + .map(ConnectorColumn::getName).collect(Collectors.joining(","))); + } + + // Per-table scan capabilities that the generic fe-core consumer refines the connector-wide capability + // set with. Top-N lazy materialization is orc/parquet-only in hive (legacy + // 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<>(); + // 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 + // 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()); + } + if (supportsHiveSampleAnalyze(tableInfo)) { + perTableCapabilities.add(ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE.name()); + } + if (supportsHiveTopNLazyMaterialize(tableInfo)) { + perTableCapabilities.add(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE.name()); + } + if (!perTableCapabilities.isEmpty()) { + tableProperties.put(ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY, + String.join(",", perTableCapabilities)); + } + + // Distribution (bucketing) columns for the flipped table's getDistributionColumnNames() — legacy + // HMSExternalTable read getSd().getBucketCols(). Emitted RAW (fe-core lowercases, mirroring the legacy + // getDistributionColumnNames); only a bucketed table carries it. Consumed by sampled ANALYZE to pick the + // linear-vs-DUJ1 NDV estimator (a single bucket column that IS the analyzed column -> linear). + List bucketCols = tableInfo.getBucketCols(); + if (bucketCols != null && !bucketCols.isEmpty()) { + tableProperties.put(ConnectorTableSchema.DISTRIBUTION_COLUMNS_KEY, String.join(",", bucketCols)); + } + + return new ConnectorTableSchema(tableName, allColumns, formatType, tableProperties); + } + + /** + * 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. + */ + 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); + } + } + } + for (ConnectorCapability cap : ownerCaps) { + caps.add(cap.name()); + } + Map props = new HashMap<>(siblingSchema.getProperties()); + props.put(ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY, String.join(",", caps)); + return new ConnectorTableSchema(siblingSchema.getTableName(), siblingSchema.getColumns(), + siblingSchema.getTableFormatType(), props); + } + + @Override + public Map getProperties() { + return properties; + } + + // ========== ConnectorTableOps: Column Handles ========== + + @Override + public Map getColumnHandles( + ConnectorSession session, ConnectorTableHandle handle) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).getColumnHandles(session, handle); + } + HiveTableHandle hiveHandle = (HiveTableHandle) handle; + HmsTableInfo tableInfo = hmsClient.getTable( + hiveHandle.getDbName(), hiveHandle.getTableName()); + + Set partKeyNames = hiveHandle.getPartitionKeyNames() != null + ? hiveHandle.getPartitionKeyNames().stream().collect(Collectors.toSet()) + : Collections.emptySet(); + + Map result = new LinkedHashMap<>(); + List allCols = new ArrayList<>(); + if (tableInfo.getColumns() != null) { + allCols.addAll(tableInfo.getColumns()); + } + if (tableInfo.getPartitionKeys() != null) { + allCols.addAll(tableInfo.getPartitionKeys()); + } + for (ConnectorColumn col : allCols) { + boolean isPartKey = partKeyNames.contains(col.getName()); + result.put(col.getName(), new HiveColumnHandle( + col.getName(), col.getType().getTypeName(), isPartKey)); + } + return result; + } + + /** + * Builds the BE table descriptor for a hive table, a direct port of legacy + * {@code HMSExternalTable.toThrift}: a {@code TTableType.HIVE_TABLE} carrying a {@link THiveTable}. Without + * this override the SPI default returns {@code null} and fe-core ({@code PluginDrivenExternalTable.toThrift}) + * falls back to a generic {@code SCHEMA_TABLE} descriptor. Mirrors the iceberg connector's HIVE_TABLE + * branch; the SPI signature carries no handle, so this single override serves base and system tables alike + * (legacy used the identical fork for both). + */ + @Override + public TTableDescriptor buildTableDescriptor(ConnectorSession session, + long tableId, String tableName, String dbName, String remoteName, int numCols, long catalogId) { + THiveTable tHiveTable = new THiveTable(dbName, tableName, new HashMap<>()); + TTableDescriptor desc = new TTableDescriptor( + tableId, TTableType.HIVE_TABLE, numCols, 0, tableName, dbName); + desc.setHiveTable(tHiveTable); + return desc; + } + + // ========== ConnectorTableOps: Views ========== + + /** + * Whether {@code dbName.viewName} is a hive VIEW, a connector-side port of legacy + * {@code HMSExternalTable.isView}: the authoritative signal is the PRESENCE OF VIEW TEXT + * ({@code viewOriginalText} or {@code viewExpandedText} set), not the {@code tableType} — a hive view + * always carries view text and a base table never does. Consumed by {@code PluginDrivenExternalTable} + * to resolve {@code isView()} (only when the connector declares {@link ConnectorCapability#SUPPORTS_VIEW}) + * and by {@code PluginDrivenExternalCatalog.dropTable} to route a DROP onto {@link #dropView}; returning + * {@code false} for a base table is exactly what keeps a normal DROP TABLE on the table-handle path. This + * uses the same single {@code getTable} the caller path needs and does NOT wrap in an auth context + * (ThriftHmsClient authenticates internally, unlike the iceberg connector). A missing table is not a view. + * + *

Distinct from the {@code tableType}-based {@link #isView(HmsTableInfo)} the Top-N gate uses: that gate + * only excludes views from an optimization (a tableType proxy is adequate there and its unit test relies on + * it), whereas this view signal must be the legacy-exact text predicate so {@link #getViewDefinition} is + * only reached when the text needed to build the view SQL exists. + */ + @Override + public boolean viewExists(ConnectorSession session, String dbName, String viewName) { + try { + return isViewTable(hmsClient.getTable(dbName, viewName)); + } catch (HmsClientException e) { + LOG.debug("View existence check: '{}.{}' not found: {}", dbName, viewName, e.getMessage()); + return false; + } + } + + /** + * Loads the stored definition of a hive view, a connector-side port of legacy + * {@code HMSExternalTable.getViewText} plus the view-column half of {@code initHiveSchema}. ONE + * {@code hmsClient.getTable} supplies both the SQL body (via {@link #resolveViewText}) and the view's + * columns — a hive view exposes ordinary columns from its StorageDescriptor, built exactly like a base + * table's data columns. fe-core ({@code PluginDrivenExternalTable.initSchema}) takes a view's columns + * SOLELY from here (it never calls {@code getTableSchema} for a view), so the column list is non-empty for + * a real view. The {@code dialect} is a required-non-null placeholder fe-core never reads. Callers gate on + * {@link #viewExists}, so the view text is present; a defensive fail-loud guards the pathological + * empty-text case rather than letting the DTO constructor NPE. + */ + @Override + public ConnectorViewDefinition getViewDefinition(ConnectorSession session, String dbName, String viewName) { + HmsTableInfo tableInfo = hmsClient.getTable(dbName, viewName); + String sql = resolveViewText(tableInfo); + if (sql == null) { + throw new DorisConnectorException( + "Hive view " + dbName + "." + viewName + " has no view definition text"); + } + List columns = buildColumns(tableInfo); + return new ConnectorViewDefinition(sql, HIVE_VIEW_DIALECT, columns); + } + + /** + * Drops a hive view, a connector-side port of the way legacy {@code HiveMetadataOps.dropTableImpl} dropped a + * view: hive has no separate drop-view, a view is deleted through the same metastore {@code dropTable}. This + * is reached only via {@code PluginDrivenExternalCatalog.dropTable} after {@link #viewExists} confirmed the + * target is a view; a view is never transactional, so the transactional-table guard the table drop applies + * is unnecessary here. Failures are normalized into a {@link DorisConnectorException} (not a bare + * RuntimeException) so {@code PluginDrivenExternalCatalog.dropTable} rewraps them as a {@code DdlException}. + */ + @Override + public void dropView(ConnectorSession session, String dbName, String viewName) { + try { + hmsClient.dropTable(dbName, viewName); + } catch (HmsClientException e) { + throw new DorisConnectorException("Failed to drop Hive view " + + dbName + "." + viewName + ": " + e.getMessage(), e); + } + } + + // 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; + // the SPI default (empty) keeps SHOW TABLES listing each view exactly once, matching legacy. This is the + // opposite of iceberg, whose listTableNames subtracts views and whose listViewNames re-supplies them. + + /** + * Whether the metastore table carries view text, the exact predicate of legacy + * {@code HMSExternalTable.isView} ({@code isSetViewOriginalText() || isSetViewExpandedText()}). + */ + private static boolean isViewTable(HmsTableInfo tableInfo) { + return tableInfo.getViewOriginalText() != null || tableInfo.getViewExpandedText() != null; + } + + /** + * Resolves a hive view's SQL body, a byte-faithful port of legacy {@code HMSExternalTable.getViewText}: + * prefer {@code viewExpandedText} unless it is empty or the bare {@code "/* Presto View *}{@code /"} + * sentinel, otherwise parse the base64 Presto/Trino definition out of {@code viewOriginalText}. + */ + private static String resolveViewText(HmsTableInfo tableInfo) { + String expanded = tableInfo.getViewExpandedText(); + if (expanded != null && !expanded.isEmpty() && !PRESTO_VIEW_EXPANDED_SENTINEL.equals(expanded)) { + return expanded; + } + return parseTrinoViewDefinition(tableInfo.getViewOriginalText()); + } + + /** + * Extracts the SQL out of a Presto/Trino view definition stored in {@code originalText}, a port of legacy + * {@code HMSExternalTable.parseTrinoViewDefinition}. The format is + * {@code "/* Presto View: *}{@code /"} where the JSON carries an {@code originalSql} field. + * Returns {@code originalText} unchanged when it is not a Presto view, and falls back to the raw + * {@code originalText} on ANY decode/parse failure (legacy parity). + */ + private static String parseTrinoViewDefinition(String originalText) { + if (originalText == null || !originalText.contains(PRESTO_VIEW_PREFIX)) { + return originalText; + } + try { + String base64String = originalText.substring( + originalText.indexOf(PRESTO_VIEW_PREFIX) + PRESTO_VIEW_PREFIX.length(), + originalText.lastIndexOf(PRESTO_VIEW_SUFFIX)).trim(); + byte[] decodedBytes = Base64.getDecoder().decode(base64String); + String decodedString = new String(decodedBytes, StandardCharsets.UTF_8); + JsonObject jsonObject = new Gson().fromJson(decodedString, JsonObject.class); + if (jsonObject.has(PRESTO_VIEW_ORIGINAL_SQL_KEY)) { + return jsonObject.get(PRESTO_VIEW_ORIGINAL_SQL_KEY).getAsString(); + } + } catch (Exception e) { + LOG.warn("Decoding Presto view definition failed", e); + } + return originalText; + } + + // ========== ConnectorStatisticsOps ========== + + /** + * Table-level statistics for a hive table, a port of legacy {@code StatisticsUtil.getHiveRowCount} + + * {@code getTotalSizeFromHMS} restricted to the two RAW metastore facts (no Doris-type math — the + * connector must not import fe-type): + *

    + *
  • {@code rowCount} = the exact HMS {@code numRows}, falling back to the spark-written + * {@code spark.sql.statistics.numRows} ONLY when {@code numRows} is present but non-positive + * (legacy {@code getRowCountFromParameters} — a table carrying only the spark key and no plain + * {@code numRows} deliberately does NOT surface a spark count here). A count {@code <= 0} maps to + * -1 (UNKNOWN), matching the legacy "0 -> UNKNOWN" gate and the paimon/iceberg connectors.
  • + *
  • {@code dataSize} = the on-disk {@code totalSize}, falling back to + * {@code spark.sql.statistics.totalSize} when the standard key is ABSENT (legacy size branch — + * note the asymmetry with the row-count fallback).
  • + *
+ * When the exact count is unknown but a size is present, fe-core + * ({@code PluginDrivenExternalTable.fetchRowCount}) estimates the cardinality as + * {@code dataSize / } — the type-dependent division this connector cannot do. Returns + * empty when neither fact is available (fe-core then falls through to the file-list estimate). Params + * are read from the handle (loaded live by {@code getTableHandle}), so this adds no HMS round-trip. + */ + @Override + public Optional getTableStatistics( + ConnectorSession session, ConnectorTableHandle handle) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).getTableStatistics(session, handle); + } + Map params = ((HiveTableHandle) handle).getTableParameters(); + if (params == null) { + return Optional.empty(); + } + + long rowCount = -1; + if (params.containsKey(PARAM_NUM_ROWS)) { + rowCount = parseLongOrDefault(params.get(PARAM_NUM_ROWS), -1); + if (rowCount <= 0 && params.containsKey(PARAM_SPARK_NUM_ROWS)) { + rowCount = parseLongOrDefault(params.get(PARAM_SPARK_NUM_ROWS), -1); + } + } + + long dataSize = -1; + if (params.containsKey(PARAM_TOTAL_SIZE)) { + dataSize = parseLongOrDefault(params.get(PARAM_TOTAL_SIZE), -1); + } else if (params.containsKey(PARAM_SPARK_TOTAL_SIZE)) { + dataSize = parseLongOrDefault(params.get(PARAM_SPARK_TOTAL_SIZE), -1); + } + + // Collapse a non-positive count/size to the -1 UNKNOWN sentinel (0 -> UNKNOWN, legacy parity). + long reportedRows = rowCount > 0 ? rowCount : -1; + long reportedSize = dataSize > 0 ? dataSize : -1; + if (reportedRows < 0 && reportedSize < 0) { + return Optional.empty(); + } + return Optional.of(new ConnectorTableStatistics(reportedRows, reportedSize)); + } + + /** + * Serves the query-planner column-statistics fast path from HMS-recorded (no-scan) column stats, a port of + * legacy {@code HMSExternalTable.getHiveColumnStats}. Returns RAW facts only (rowCount / ndv / numNulls / + * avgColLen) — fe-core does the Doris-type-dependent {@code dataSize}/{@code avgSize} math in + * {@code PluginDrivenExternalTable.getColumnStatistic} (it must not import fe-type). + * + *

Empty (fe-core then falls back to a full ANALYZE) when: the table is not a plain-hive table + * (iceberg-on-HMS is served by the iceberg sibling; hudi had no fast path); the table has no positive + * {@code numRows} parameter (legacy required it as the per-column data-size basis, and does NOT fall back + * to the spark count here, unlike the table-level size branch); or HMS holds no stats for the column.

+ */ + @Override + public Optional getColumnStatistics( + ConnectorSession session, ConnectorTableHandle handle, String columnName) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).getColumnStatistics(session, handle, columnName); + } + HiveTableHandle hiveHandle = (HiveTableHandle) handle; + if (hiveHandle.getTableType() != HiveTableType.HIVE) { + return Optional.empty(); + } + Map params = hiveHandle.getTableParameters(); + if (params == null || !params.containsKey(PARAM_NUM_ROWS)) { + return Optional.empty(); + } + long rowCount = parseLongOrDefault(params.get(PARAM_NUM_ROWS), -1); + if (rowCount <= 0) { + return Optional.empty(); + } + List stats = hmsClient.getTableColumnStatistics( + hiveHandle.getDbName(), hiveHandle.getTableName(), Collections.singletonList(columnName)); + if (stats.isEmpty()) { + return Optional.empty(); + } + // Legacy read at most one stats object per column; take the first. + HmsColumnStatistics stat = stats.get(0); + return Optional.of(new ConnectorColumnStatistics( + rowCount, stat.getNdv(), stat.getNumNulls(), stat.getAvgColLenBytes())); + } + + /** + * Parses a metastore numeric parameter defensively. Legacy read these with a bare {@code Long.parseLong} + * under an outer try/catch that logged and returned UNKNOWN; returning {@code defaultValue} on a + * null/blank/malformed value is the same net effect without letting one bad parameter abort the read. + */ + private static long parseLongOrDefault(String value, long defaultValue) { + if (value == null) { + return defaultValue; + } + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /** + * Estimates the table's on-disk data size (bytes) by listing its data files, a port of the file-listing + * half of legacy {@code HMSExternalTable.getRowCountFromFileList} (fe-core does the + * {@code size / rowWidth} division). Only plain-hive tables are estimated (hudi/iceberg-on-HMS are served + * by their own connectors; a view has no data files) — anything else returns -1. Partitions are sampled + * ({@link #STATS_PARTITION_SAMPLE_SIZE}) and the sampled size scaled back up to the whole table, exactly + * as legacy did. Best-effort: ANY error (unlistable location, remote failure) degrades to -1, never + * throwing — statistics must not fail a query. The Hadoop {@code FileSystem} reflection resolves its + * filesystem impl through the thread context classloader, so this pins the TCCL to the plugin classloader + * for the duration (the statistics thread is not pinned by fe-core, unlike the scan thread). + */ + @Override + public long estimateDataSizeByListingFiles(ConnectorSession session, ConnectorTableHandle handle) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).estimateDataSizeByListingFiles(session, handle); + } + HiveTableHandle hiveHandle = (HiveTableHandle) handle; + if (hiveHandle.getTableType() != HiveTableType.HIVE) { + return -1; + } + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(getClass().getClassLoader()); + // Resolve the engine FileSystem INSIDE the size lambda so it runs within estimateDataSize's + // catch(RuntimeException)->-1 region: statistics collection must degrade to -1, never fail a query, + // if getFileSystem throws. (context.getFileSystem returns the cached per-catalog FS, so per-location + // calls are cheap.) + return estimateDataSize(hiveHandle, STATS_PARTITION_SAMPLE_SIZE, + (location, values) -> sumCachedFileSizes( + hiveHandle, location, values, context.getFileSystem(session))); + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + } + + /** + * Returns the raw byte length of every data file across ALL partitions (not sampled, not summed), a port of + * legacy {@code HMSExternalTable.getChunkSizes} for {@code ANALYZE ... WITH SAMPLE}. Only plain-hive tables + * are listed (iceberg/hudi-on-HMS are served by their own connectors via the sibling divert; a view has no + * data files) — anything else returns empty. Lists EVERY partition (no {@link #STATS_PARTITION_SAMPLE_SIZE} + * sampling, unlike {@link #estimateDataSizeByListingFiles}) because the fe-core sampler needs the individual + * file sizes to seed-shuffle and cumulate. A listing error PROPAGATES here (unlike + * {@link #estimateDataSizeByListingFiles}'s best-effort {@code -1}): this backs an explicit + * {@code ANALYZE ... WITH SAMPLE}, and legacy {@code HMSExternalTable.getChunkSizes} failed the command loud + * rather than let the sampler collapse the scale factor to {@code 1.0} while {@code TABLESAMPLE} still fires + * (a silent stat undercount); a genuinely empty table still yields an empty list naturally. Pins the TCCL to + * the plugin classloader for the {@code FileSystem} reflection (the statistics thread is not pinned by + * fe-core), restored on the throw path by the finally. + */ + @Override + public List listFileSizes(ConnectorSession session, ConnectorTableHandle handle) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).listFileSizes(session, handle); + } + HiveTableHandle hiveHandle = (HiveTableHandle) handle; + if (hiveHandle.getTableType() != HiveTableType.HIVE) { + return Collections.emptyList(); + } + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(getClass().getClassLoader()); + FileSystem fs = context.getFileSystem(session); + List sizes = new ArrayList<>(); + for (PartitionRef ref : resolvePartitionRefs(hiveHandle)) { + for (HiveFileStatus file : fileListingCache.listDataFiles( + hiveHandle.getDbName(), hiveHandle.getTableName(), + ref.location, ref.partitionValues, fs)) { + sizes.add(file.getLength()); + } + } + return sizes; + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + } + + /** + * Engine-neutral rows for a connector metadata table (currently only the hudi commit timeline). A plain-hive + * table has no metadata table, so the hive branch returns nothing; a foreign (hudi-on-HMS) handle is diverted + * to the owning sibling connector, whose {@code getMetadataTableRows} produces the real timeline (and pins the + * TCCL itself). Without this divert a flipped hudi-on-HMS table would fall through to the SPI-default empty list + * even though {@link #reflectSiblingScanCapabilities} reflects the hudi sibling's {@code SUPPORTS_METADATA_TABLE} + * and so makes the {@code hudi_meta()} / TIMELINE gate pass — i.e. OK-but-empty instead of the real timeline. + * Mirrors the {@link #listFileSizes} / {@link #estimateDataSizeByListingFiles} per-handle divert. + */ + @Override + public List> getMetadataTableRows(ConnectorSession session, ConnectorTableHandle handle, + String kind) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).getMetadataTableRows(session, handle, kind); + } + return Collections.emptyList(); + } + + /** + * Sampling + summing + scale-up core of {@link #estimateDataSizeByListingFiles}, isolated from the + * {@code FileSystem} I/O (injected as {@code sizeOf}) so the estimation math is unit-testable. Returns -1 + * when the size cannot be estimated (no listable location, a zero/negative sum, or any error). + */ + long estimateDataSize(HiveTableHandle handle, int sampleSize, ToLongBiFunction> sizeOf) { + try { + List refs = resolvePartitionRefs(handle); + if (refs.isEmpty()) { + return -1; + } + int totalPartitions = refs.size(); + boolean sampled = sampleSize > 0 && sampleSize < totalPartitions; + List chosen = refs; + if (sampled) { + List shuffled = new ArrayList<>(refs); + Collections.shuffle(shuffled); + chosen = shuffled.subList(0, sampleSize); + } + long totalSize = 0; + for (PartitionRef ref : chosen) { + totalSize += Math.max(0, sizeOf.applyAsLong(ref.location, ref.partitionValues)); + } + if (totalSize <= 0) { + return -1; + } + // Scale the sampled size up to the whole table (legacy: totalSize * total / sampled). + if (sampled) { + totalSize = scaleSampledSize(totalSize, totalPartitions, chosen.size()); + } + return totalSize; + } catch (RuntimeException e) { + LOG.warn("Failed to estimate hive data size for {}.{} from file list", + handle.getDbName(), handle.getTableName(), e); + return -1; + } + } + + /** + * Scales a sampled data size up to the whole table: {@code sampledSize * totalPartitions / + * sampledPartitions} (legacy {@code HMSExternalTable.getRowCountFromFileList}). Multiplies BEFORE dividing + * to avoid early integer truncation (a divide-first ordering rounds the per-partition average down first + * and yields a smaller, less accurate estimate). The multiply carries the same theoretical long-overflow + * exposure as legacy for a petabyte-scale sample, accepted for parity. + */ + static long scaleSampledSize(long sampledSize, int totalPartitions, int sampledPartitions) { + return sampledSize * totalPartitions / sampledPartitions; + } + + /** + * Resolves the data locations to list: the table location for an unpartitioned table, else every + * partition's location (bounded by {@link #MAX_PARTITIONS_FOR_STATS}). A partition or table with no + * location contributes nothing. + */ + private List resolvePartitionRefs(HiveTableHandle handle) { + List partKeyNames = handle.getPartitionKeyNames(); + if (partKeyNames == null || partKeyNames.isEmpty()) { + String location = handle.getLocation(); + return (location == null || location.isEmpty()) + ? Collections.emptyList() + : Collections.singletonList(new PartitionRef(location, Collections.emptyList())); + } + List partNames = hmsClient.listPartitionNames( + handle.getDbName(), handle.getTableName(), MAX_PARTITIONS_FOR_STATS); + if (partNames.isEmpty()) { + return Collections.emptyList(); + } + List partitions = hmsClient.getPartitions( + handle.getDbName(), handle.getTableName(), partNames); + List refs = new ArrayList<>(partitions.size()); + for (HmsPartitionInfo partition : partitions) { + String location = partition.getLocation(); + if (location != null && !location.isEmpty()) { + refs.add(new PartitionRef(location, partition.getValues())); + } + } + return refs; + } + + /** + * A partition's data location plus its ordered values, so the size-estimate / stats paths carry the same + * partition identity into {@link HiveFileListingCache} as the scan path — keeping the two sharing one cached + * listing per partition, and letting a partition-level refresh invalidate exactly that entry (legacy parity). + */ + private static final class PartitionRef { + final String location; + final List partitionValues; + + PartitionRef(String location, List partitionValues) { + this.location = location; + this.partitionValues = partitionValues == null ? Collections.emptyList() : partitionValues; + } + } + + /** + * Sums the sizes of the data files directly under {@code location}, served from the connector's shared + * {@link HiveFileListingCache} (which does the non-recursive {@code listStatus} and filters directories and + * {@code _}/{@code .}-prefixed hidden files — the same filter, and the same listing, the scan path uses). A + * listing failure propagates as a {@link DorisConnectorException} so {@link #estimateDataSize} degrades the + * whole estimate to -1 (legacy's file-list estimate was all-or-nothing best-effort). Routing through the + * cache keeps the periodic row-count refresh from re-listing directories a scan already cached. + */ + private long sumCachedFileSizes(HiveTableHandle handle, String location, + List partitionValues, FileSystem fs) { + long sum = 0; + for (HiveFileStatus file : fileListingCache.listDataFiles( + handle.getDbName(), handle.getTableName(), location, partitionValues, fs)) { + sum += file.getLength(); + } + return sum; + } + + // ========== ConnectorPushdownOps: Filter Pushdown ========== + + @Override + public Optional> applyFilter( + ConnectorSession session, ConnectorTableHandle handle, + ConnectorFilterConstraint constraint) { + if (!(handle instanceof HiveTableHandle)) { + // Forward AND return the sibling's result UNMODIFIED (a rewrap would poison a downstream scan cast). + return siblingMetadata(session, handle).applyFilter(session, handle, constraint); + } + HiveTableHandle hiveHandle = (HiveTableHandle) handle; + List partKeyNames = hiveHandle.getPartitionKeyNames(); + if (partKeyNames == null || partKeyNames.isEmpty()) { + return Optional.empty(); + } + + // Extract equality predicates on partition columns from the expression + Map> partitionPredicates = extractPartitionPredicates( + constraint.getExpression(), partKeyNames); + if (partitionPredicates.isEmpty()) { + return Optional.empty(); + } + + // Build partition name filter patterns for HMS + List allPartNames = hmsClient.listPartitionNames( + hiveHandle.getDbName(), hiveHandle.getTableName(), 100000); + List matchedPartNames = prunePartitionNames( + allPartNames, partKeyNames, partitionPredicates); + + if (matchedPartNames.size() == allPartNames.size()) { + // No pruning effect + return Optional.empty(); + } + + List prunedPartitions = matchedPartNames.isEmpty() + ? Collections.emptyList() + : hmsClient.getPartitions(hiveHandle.getDbName(), + hiveHandle.getTableName(), matchedPartNames); + + LOG.info("Partition pruning: {}.{} all={} pruned={}", + hiveHandle.getDbName(), hiveHandle.getTableName(), + allPartNames.size(), prunedPartitions.size()); + + HiveTableHandle newHandle = hiveHandle.toBuilder() + .prunedPartitions(prunedPartitions) + .build(); + return Optional.of(new FilterApplicationResult<>( + newHandle, constraint.getExpression(), false)); + } + + // ========== ConnectorTableOps: partition listing ========== + + /** + * Lists a partitioned table's partition display names (e.g. {@code "year=2024/month=01"}), taken + * straight from the metastore's {@code get_partition_names}. Byte-parity with legacy + * {@code HiveExternalMetaCache.loadPartitionValues}, whose hot partition-pruning path listed NAMES ONLY + * (no per-partition metadata round-trip). An unpartitioned table lists nothing (the metastore has no + * partitions; the guard mirrors {@code PaimonConnectorMetadata.collectPartitions}, avoiding a pointless + * RPC). + */ + @Override + public List listPartitionNames(ConnectorSession session, ConnectorTableHandle handle) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).listPartitionNames(session, handle); + } + // SHOW PARTITIONS / partitions metadata TVF: FRESH listing (bypass cache), matching legacy's raw-client + // read — else an externally-added partition stays invisible until TTL/REFRESH (test_hive_use_meta_cache_true). + return collectPartitionNames((HiveTableHandle) handle, true); + } + + /** + * Lists all partitions with metadata. The {@code filter} is intentionally ignored: legacy hive + * materialized its full partition view and pruned FE-side (mirrors {@code PaimonConnectorMetadata} / + * {@code MaxComputeConnectorMetadata}). + * + *

{@code lastModifiedMillis} is deliberately left {@link ConnectorPartitionInfo#UNKNOWN} (-1): + * reading each partition's {@code transient_lastDdlTime} requires a {@code get_partitions_by_names} + * round-trip that legacy's per-query partition-view path did NOT pay (it read only partition names), + * so filling it here would regress every partitioned-hive query. Legacy fetched per-partition modify + * time only at MTMV-refresh time; that freshness path is rewired connector-side in the MVCC/MTMV step + * (until then a hive MTMV base table's per-partition freshness is UNKNOWN, harmless while hive is + * dormant). {@code rowCount}/{@code sizeBytes}/{@code fileCount} are likewise UNKNOWN — hive does not + * declare {@code SUPPORTS_PARTITION_STATS} (legacy SHOW PARTITIONS lists names only).

+ */ + @Override + public List listPartitions(ConnectorSession session, + ConnectorTableHandle handle, Optional filter) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).listPartitions(session, handle, filter); + } + HiveTableHandle hiveHandle = (HiveTableHandle) handle; + List partKeyNames = hiveHandle.getPartitionKeyNames(); + // Query partition pruning: CACHED listing (use_meta_cache contract; legacy pruned off HiveExternalMetaCache). + List partitionNames = collectPartitionNames(hiveHandle, false); + List result = new ArrayList<>(partitionNames.size()); + for (String partitionName : partitionNames) { + // Parse the ordered values ONCE (connector-side); supply them to fe-core so it does not re-run the + // hive-style parse, and derive the per-value NULL flags from the SAME list (positional alignment). + List orderedValues = HiveWriteUtils.toPartitionValues(partitionName); + result.add(new ConnectorPartitionInfo(partitionName, + toPartitionValueMap(partitionName, partKeyNames), + Collections.emptyMap(), + orderedValues, + toPartitionValueNullFlags(orderedValues))); + } + return result; + } + + /** + * Per-value SQL-NULL flags for the ordered partition values (as produced by + * {@link HiveWriteUtils#toPartitionValues}), positionally aligned so flag {@code i} zips to value {@code i} + * 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}). + */ + private static List toPartitionValueNullFlags(List values) { + List flags = new ArrayList<>(values.size()); + for (String value : values) { + flags.add(ConnectorPartitionValues.HIVE_DEFAULT_PARTITION.equals(value)); + } + return flags; + } + + /** + * Shared partition-name lister backing {@link #listPartitionNames}, {@link #listPartitions} and + * {@link #getTableFreshness}. Returns the metastore's rendered partition names ({@code key=value/...}); an + * unpartitioned table (no partition keys) lists nothing without touching the metastore. + * + *

{@code bypassCache} selects the freshness contract: the SHOW-PARTITIONS / partitions-TVF path + * ({@link #listPartitionNames}) lists FRESH (legacy read the raw pooled client, never the metadata cache), + * while the query-pruning path ({@link #listPartitions}) and the MTMV freshness path + * ({@link #getTableFreshness}) stay CACHED (the {@code use_meta_cache} contract; legacy pruning/MTMV both read + * the cached {@code HiveExternalMetaCache}). The {@code CachingHmsClient} decorator owns the two behaviours; + * a non-caching client serves both identically. + */ + private List collectPartitionNames(HiveTableHandle handle, boolean bypassCache) { + List partKeyNames = handle.getPartitionKeyNames(); + if (partKeyNames == null || partKeyNames.isEmpty()) { + return Collections.emptyList(); + } + // -1 = "all partitions": ThriftHmsClient maps it to an unbounded HMS listing (no silent cap), + // matching legacy's default (Config.max_hive_list_partition_num = -1). + return bypassCache + ? hmsClient.listPartitionNamesFresh(handle.getDbName(), handle.getTableName(), -1) + : hmsClient.listPartitionNames(handle.getDbName(), handle.getTableName(), -1); + } + + /** + * Parses a rendered partition name ({@code key1=v1/key2=v2}) into a remote-key -> value map, unescaping + * each value via {@link HiveWriteUtils#toPartitionValues} (the byte-faithful port of legacy + * {@code HiveUtil.toPartitionValues}). Keyed by the handle's remote partition-column names in schema + * order, which is how {@code PluginDrivenExternalTable.getNameToPartitionItems} reads the values back. + * Returns an empty map when the parsed value arity does not match the partition-key arity (defensive; a + * malformed name is logged-and-skipped by the fe-core partition-item builder). + */ + private static Map toPartitionValueMap(String partitionName, List partKeyNames) { + List values = HiveWriteUtils.toPartitionValues(partitionName); + if (partKeyNames == null || values.size() != partKeyNames.size()) { + return Collections.emptyMap(); + } + Map valueMap = new LinkedHashMap<>(); + for (int i = 0; i < partKeyNames.size(); i++) { + valueMap.put(partKeyNames.get(i), values.get(i)); + } + return valueMap; + } + + // ========== MTMV freshness (last-modified; MTMV refresh path only, NOT the scan hot path) ========== + + /** HMS parameter carrying a table/partition's last-DDL time in SECONDS (byte-parity with legacy hive). */ + private static final String TRANSIENT_LAST_DDL_TIME = "transient_lastDdlTime"; + + /** + * The query-begin pin for a hive table: a non-MVCC EMPTY pin (snapshot id {@code -1}, no scan options — so + * {@code applySnapshot} is a no-op and the scan reads current) but flagged {@code lastModifiedFreshness} so + * the generic model serves this table's MTMV table/partition snapshots from {@link #getTableFreshness} / + * {@link #getPartitionFreshnessMillis} (last-modified) instead of pinning a constant snapshot id. The flag + * rides on the pin so fe-core reads it off the pin it already holds — a snapshot-id connector never fires + * the freshness probe. (Iceberg/hudi-on-HMS delegation, which returns a real snapshot-id pin for those + * handles, lands with the sibling-connector substep; until then every hive-connector handle is last-modified.) + */ + @Override + public Optional beginQuerySnapshot(ConnectorSession session, + ConnectorTableHandle handle) { + if (!(handle instanceof HiveTableHandle)) { + // Diverts in lockstep with getTableFreshness/getPartitionFreshnessMillis: the pin's + // isLastModifiedFreshness flag (false for an iceberg snapshot-id pin) gates whether fe-core consults + // freshness at all, so half-diverting the pin would corrupt MVCC. + return siblingMetadata(session, handle).beginQuerySnapshot(session, handle); + } + return Optional.of(ConnectorMvccSnapshot.builder().snapshotId(-1L).lastModifiedFreshness(true).build()); + } + + /** + * Whole-table MTMV freshness for hive: the table's newest modify time, wrapped by fe-core into an + * {@code MTMVMaxTimestampSnapshot} (byte-parity with legacy {@code HiveDlaTable.getTableSnapshot}). + * Hive's whole-table change signal is a last-modified TIMESTAMP, never a snapshot id. + * + *

    + *
  • Unpartitioned ⇒ the table's {@code transient_lastDdlTime} (already on the handle, no + * round-trip), named by the table.
  • + *
  • Partitioned ⇒ the max {@code transient_lastDdlTime} over all partitions, named by the + * partition owning it (an empty partition set ⇒ {@code (tableName, 0)}). This pays a + * {@code get_partitions_by_names} round-trip, which is why this lives on the MTMV path, NOT + * {@link #listPartitions} (the scan hot path stays names-only).
  • + *
+ */ + @Override + public Optional getTableFreshness(ConnectorSession session, + ConnectorTableHandle handle) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).getTableFreshness(session, handle); + } + HiveTableHandle hiveHandle = (HiveTableHandle) handle; + List partKeyNames = hiveHandle.getPartitionKeyNames(); + if (partKeyNames == null || partKeyNames.isEmpty()) { + // Parity HiveDlaTable.getTableSnapshot UNPARTITIONED branch: MTMVMaxTimestampSnapshot(name, lastDdl). + return Optional.of(new ConnectorTableFreshness(hiveHandle.getTableName(), + lastDdlMillis(hiveHandle.getTableParameters()))); + } + // MTMV whole-table freshness: CACHED listing (legacy HiveDlaTable.getTableSnapshot read cached names too). + List partitionNames = collectPartitionNames(hiveHandle, false); + if (partitionNames.isEmpty()) { + // Parity: an empty partition list yields MTMVMaxTimestampSnapshot(tableName, 0). + return Optional.of(new ConnectorTableFreshness(hiveHandle.getTableName(), 0L)); + } + List partitions = + hmsClient.getPartitions(hiveHandle.getDbName(), hiveHandle.getTableName(), partitionNames); + String maxName = hiveHandle.getTableName(); + long maxMillis = 0L; + for (HmsPartitionInfo partition : partitions) { + long millis = lastDdlMillis(partition.getParameters()); + // Strictly-greater keeps the FIRST partition on a tie (parity HiveDlaTable's `> maxVersionTime`). + if (millis > maxMillis) { + maxMillis = millis; + maxName = renderPartitionName(partKeyNames, partition.getValues()); + } + } + return Optional.of(new ConnectorTableFreshness(maxName, maxMillis)); + } + + /** + * Per-partition last-modified millis for hive (parity {@code HiveDlaTable.getPartitionSnapshot} -> + * {@code MTMVTimestampSnapshot(hivePartition.getLastModifiedTime())}). Fetched on demand on the MTMV + * refresh path — {@link #listPartitions} withholds it (names-only) to keep partitioned queries cheap. + * fe-core has already validated the partition exists in the materialized set; an {@code empty} return + * therefore means the partition VANISHED between the materialize and this fetch (a refresh-time race), and + * fe-core raises the legacy "can not find partition" error (parity {@code HiveDlaTable.checkPartitionExists}). + */ + @Override + public OptionalLong getPartitionFreshnessMillis(ConnectorSession session, ConnectorTableHandle handle, + String partitionName) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).getPartitionFreshnessMillis(session, handle, partitionName); + } + HiveTableHandle hiveHandle = (HiveTableHandle) handle; + List partitions = hmsClient.getPartitions(hiveHandle.getDbName(), + hiveHandle.getTableName(), Collections.singletonList(partitionName)); + if (partitions.isEmpty()) { + return OptionalLong.empty(); + } + return OptionalLong.of(lastDdlMillis(partitions.get(0).getParameters())); + } + + /** + * The last-DDL time in MILLIS from an HMS parameter map, byte-parity with legacy + * {@code HivePartition.getLastModifiedTime} / {@code HMSExternalTable.getLastDdlTime}: the + * {@code transient_lastDdlTime} value (seconds) times 1000, or 0 when the parameter is absent. + */ + private static long lastDdlMillis(Map parameters) { + if (parameters == null || !parameters.containsKey(TRANSIENT_LAST_DDL_TIME)) { + return 0L; + } + return Long.parseLong(parameters.get(TRANSIENT_LAST_DDL_TIME)) * 1000; + } + + // ========== Iceberg-on-HMS sibling delegation (forward-absent) ========== + // Handle-based methods hive does NOT implement (it uses the SPI default) but iceberg DOES. Without an + // override here a delegated iceberg-on-HMS table would get hive's SPI default — a SILENT wrong answer + // (empty MVCC/time-travel/sys-tables, or an unthreaded snapshot pin), not a fail-loud. Each forwards a + // foreign handle to the sibling and reproduces the SPI default for a real hive handle. The handle-out + // methods (apply* / getSysTableHandle) return the sibling's handle UNMODIFIED — a rewrap would poison a + // downstream scan cast. + + @Override + public ConnectorTableSchema getTableSchema(ConnectorSession session, ConnectorTableHandle handle, + ConnectorMvccSnapshot snapshot) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).getTableSchema(session, handle, snapshot); + } + // Hive has no schema-at-snapshot; the SPI default ignores the snapshot and returns the latest schema. + return getTableSchema(session, handle); + } + + @Override + public Optional getMvccPartitionView(ConnectorSession session, + ConnectorTableHandle handle) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).getMvccPartitionView(session, handle); + } + // Hive has no range-aware partition view; fe-core builds it from listPartitions (SPI default empty). + return Optional.empty(); + } + + @Override + public Optional resolveTimeTravel(ConnectorSession session, + ConnectorTableHandle handle, ConnectorTimeTravelSpec spec) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).resolveTimeTravel(session, handle, spec); + } + // Hive has no time travel (SPI default empty): an explicit spec on a hive table is unsupported upstream. + return Optional.empty(); + } + + @Override + public ConnectorTableHandle applySnapshot(ConnectorSession session, ConnectorTableHandle handle, + ConnectorMvccSnapshot snapshot) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).applySnapshot(session, handle, snapshot); + } + // Hive's empty pin carries no scan options; the SPI default returns the handle unchanged. + return handle; + } + + @Override + public List getSyntheticScanPredicates(ConnectorSession session, + ConnectorTableHandle handle, ConnectorMvccSnapshot snapshot) { + if (!(handle instanceof HiveTableHandle)) { + // Route a foreign (iceberg / hudi) handle to its owning sibling so a hudi-on-HMS @incr read gets + // its row-level `_hoodie_commit_time` window filter. Without this the foreign handle would inherit + // the empty SPI default -> no filter -> out-of-window rows leak (a SILENT correctness bug). + return siblingMetadata(session, handle).getSyntheticScanPredicates(session, handle, snapshot); + } + // Plain hive has no synthetic scan predicate (SPI default empty). + return List.of(); + } + + @Override + public ConnectorTableHandle applyRewriteFileScope(ConnectorSession session, ConnectorTableHandle handle, + Set rawDataFilePaths) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).applyRewriteFileScope(session, handle, rawDataFilePaths); + } + // Hive has no distributed rewrite scope; the SPI default returns the handle unchanged. + return handle; + } + + @Override + public ConnectorTableHandle applyTopnLazyMaterialization(ConnectorSession session, + ConnectorTableHandle handle) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).applyTopnLazyMaterialization(session, handle); + } + // Hive scan metadata already spans all columns; the SPI default returns the handle unchanged. + return handle; + } + + @Override + public List listSupportedSysTables(ConnectorSession session, ConnectorTableHandle baseTableHandle) { + if (!(baseTableHandle instanceof HiveTableHandle)) { + return siblingMetadata(session, baseTableHandle).listSupportedSysTables(session, baseTableHandle); + } + // Hive exposes the "partitions" system table (t$partitions), served by the generic partition_values + // TVF (see isPartitionValuesSysTable). Exposed UNCONDITIONALLY (partitioned or not), mirroring legacy + // HMSExternalTable.getSupportedSysTables for dlaType==HIVE: a $partitions query on a NON-partitioned + // table must reach the TVF and throw "… is not a partitioned table", not "Unknown sys table". + return List.of("partitions"); + } + + @Override + public Optional getSysTableHandle(ConnectorSession session, + ConnectorTableHandle baseTableHandle, String sysName) { + if (!(baseTableHandle instanceof HiveTableHandle)) { + // Return the sibling's sys-table handle UNMODIFIED (a rewrap would poison a downstream scan cast). + return siblingMetadata(session, baseTableHandle).getSysTableHandle(session, baseTableHandle, sysName); + } + // Hive's "partitions" sys table is TVF-backed (isPartitionValuesSysTable), so the native handle path + // never consults this — no hive sys-table handle to return. + return Optional.empty(); + } + + @Override + public boolean isPartitionValuesSysTable(ConnectorSession session, ConnectorTableHandle baseTableHandle, + String sysName) { + if (!(baseTableHandle instanceof HiveTableHandle)) { + // A foreign (iceberg/hudi-on-HMS) handle's sys tables are NATIVE — delegate so fe-core wraps them + // native. Dropping this guard would misroute an iceberg-on-HMS t$partitions into the hive TVF. + return siblingMetadata(session, baseTableHandle).isPartitionValuesSysTable(session, baseTableHandle, + sysName); + } + // Plain hive's only sys table, "partitions", is served by the generic partition_values TVF. + return "partitions".equals(sysName); + } + + /** + * Renders {@code key1=v1/key2=v2} from partition-key names + values, byte-parity with legacy + * {@code HivePartition.getPartitionName} (a raw, unescaped {@code name=value} join). Used only to label + * the {@code MTMVMaxTimestampSnapshot} with the partition owning the max modify time. + */ + private static String renderPartitionName(List partKeyNames, List values) { + StringBuilder sb = new StringBuilder(); + int n = Math.min(partKeyNames.size(), values.size()); + for (int i = 0; i < n; i++) { + if (i != 0) { + sb.append("/"); + } + sb.append(partKeyNames.get(i)).append("=").append(values.get(i)); + } + return sb.toString(); + } + + // ========== ConnectorSchemaOps: DDL writes (create/drop database) ========== + + /** + * Hive supports CREATE DATABASE. Declaring it lets {@code PluginDrivenExternalCatalog.createDb} consult + * the remote database existence for IF NOT EXISTS (the SPI default {@code false} would skip that check). + */ + @Override + public boolean supportsCreateDatabase() { + return true; + } + + /** + * Creates a Hive database, mirroring legacy {@code HiveMetadataOps.createDbImpl}: the {@code location} + * property becomes the database location URI (and is dropped from the parameter map), the {@code comment} + * property becomes the description, and the remaining properties become database parameters. Existence / + * IF NOT EXISTS is resolved upstream by {@code PluginDrivenExternalCatalog.createDb}. + */ + @Override + public void createDatabase(ConnectorSession session, String dbName, Map dbProperties) { + Map params = new HashMap<>(dbProperties); + String location = params.remove(HiveConnectorProperties.CREATE_LOCATION); + String comment = params.getOrDefault(HiveConnectorProperties.CREATE_COMMENT, ""); + try { + hmsClient.createDatabase(new HmsCreateDatabaseRequest(dbName, location, comment, params)); + } catch (HmsClientException e) { + throw new DorisConnectorException( + "Failed to create Hive database " + dbName + ": " + e.getMessage(), e); + } + } + + /** + * Drops a Hive database, mirroring legacy {@code HiveMetadataOps.dropDbImpl}: with {@code force} every + * table in the database is dropped first (a table that vanished remotely is skipped; a transactional table + * is rejected exactly as a direct DROP TABLE would be), then the database itself. Existence / IF EXISTS is + * resolved upstream by {@code PluginDrivenExternalCatalog.dropDb}, so {@code ifExists} is accepted for SPI + * parity but not re-checked here. + */ + @Override + public void dropDatabase(ConnectorSession session, String dbName, boolean ifExists, boolean force) { + try { + if (force) { + for (String tableName : hmsClient.listTables(dbName)) { + HmsTableInfo tableInfo; + try { + tableInfo = hmsClient.getTable(dbName, tableName); + } catch (HmsClientException e) { + // The table disappeared between listing and load (dropped out-of-band); skip it, + // mirroring legacy dropDbImpl which swallowed getTableOrDdlException and continued. + LOG.warn("failed to load table {}.{} during force drop database: {}", + dbName, tableName, e.getMessage()); + continue; + } + dropTableChecked(dbName, tableName, tableInfo.getParameters()); + } + } + hmsClient.dropDatabase(dbName); + } catch (HmsClientException e) { + throw new DorisConnectorException( + "Failed to drop Hive database " + dbName + ": " + e.getMessage(), e); + } } - // ========== ConnectorSchemaOps ========== + // ========== ConnectorTableOps: DDL writes (create/drop/truncate table) ========== + /** + * Creates a Hive table, a faithful port of legacy {@code HiveMetadataOps.createTableImpl}. All property + * interpretation happens here (plugin side); fe-core does not parse hive properties. Existence / + * IF NOT EXISTS is resolved upstream by {@code PluginDrivenExternalCatalog.createTable}. + */ @Override - public List listDatabaseNames(ConnectorSession session) { - return hmsClient.listDatabases(); + public void createTable(ConnectorSession session, ConnectorCreateTableRequest request) { + // Working copy of the user CREATE TABLE properties; the default owner is added here (legacy added it + // to the same map before deriving the metastore parameters). + Map userProps = new HashMap<>(request.getProperties()); + if (session.getUser() != null) { + userProps.putIfAbsent(HiveConnectorProperties.CREATE_OWNER, session.getUser()); + } + // Reject a transactional table create (legacy parity: a hive transactional table only appears to + // accept inserts). Matches legacy's case-sensitive "transactional" key check. + String transactional = userProps.get(HiveConnectorProperties.CREATE_TRANSACTIONAL); + if (transactional != null && transactional.equalsIgnoreCase("true")) { + throw new DorisConnectorException("Not support create hive transactional table."); + } + Map env = context.getEnvironment(); + String fileFormat = userProps.getOrDefault(HiveConnectorProperties.CREATE_FILE_FORMAT, + env.getOrDefault(HiveConnectorProperties.ENV_HIVE_DEFAULT_FILE_FORMAT, + HiveConnectorProperties.DEFAULT_FILE_FORMAT)); + + // Metastore table parameters: lower-case every key and stamp the file_format / location keys under a + // doris. prefix so they round-trip (legacy HiveMetadataOps ddlProps loop). + Map tableParams = new HashMap<>(); + for (Map.Entry entry : userProps.entrySet()) { + String key = entry.getKey().toLowerCase(Locale.ROOT); + if (HiveConnectorProperties.DORIS_HIVE_KEYS.contains(key)) { + tableParams.put(HiveConnectorProperties.DORIS_PROP_PREFIX + key, entry.getValue()); + } else { + tableParams.put(key, entry.getValue()); + } + } + + // Partition columns: LIST only (reject RANGE), and reject explicit partition value definitions + // (hive external tables discover partitions from the data layout). Legacy parity. + List partitionColNames = new ArrayList<>(); + ConnectorPartitionSpec partitionSpec = request.getPartitionSpec(); + if (partitionSpec != null) { + if (partitionSpec.getStyle() == ConnectorPartitionSpec.Style.RANGE) { + throw new DorisConnectorException("Only support 'LIST' partition type in hive catalog."); + } + for (ConnectorPartitionField field : partitionSpec.getFields()) { + partitionColNames.add(field.getColumnName()); + } + if (partitionSpec.hasExplicitPartitionValues()) { + throw new DorisConnectorException( + "Partition values expressions is not supported in hive catalog."); + } + } + + HmsCreateTableRequest.Builder builder = HmsCreateTableRequest.builder() + .dbName(request.getDbName()) + .tableName(request.getTableName()) + .location(userProps.get(HiveConnectorProperties.CREATE_LOCATION)) + .columns(request.getColumns()) + .partitionKeys(partitionColNames) + .fileFormat(fileFormat) + .comment(request.getComment()) + .properties(tableParams) + .defaultTextCompression(resolveTextCompressionDefault(session)) + .dorisVersion(env.get(HiveConnectorProperties.ENV_DORIS_VERSION)); + + // Bucketing: gated on the FE-global toggle, and hive supports hash bucketing only. Legacy checks the + // enable gate first, then the hash requirement. + ConnectorBucketSpec bucketSpec = request.getBucketSpec(); + if (bucketSpec != null) { + boolean bucketEnabled = Boolean.parseBoolean(env.getOrDefault( + HiveConnectorProperties.ENV_ENABLE_CREATE_HIVE_BUCKET_TABLE, "false")); + if (!bucketEnabled) { + throw new DorisConnectorException( + "Create hive bucket table need set enable_create_hive_bucket_table to true"); + } + if (HiveConnectorProperties.BUCKET_ALGO_RANDOM.equals(bucketSpec.getAlgorithm())) { + throw new DorisConnectorException("External hive table only supports hash bucketing"); + } + builder.bucketCols(bucketSpec.getColumns()).numBuckets(bucketSpec.getNumBuckets()); + } + + try { + hmsClient.createTable(builder.build()); + } catch (HmsClientException | IllegalArgumentException e) { + throw new DorisConnectorException("Failed to create Hive table " + + request.getDbName() + "." + request.getTableName() + ": " + e.getMessage(), e); + } } + /** + * Drops a Hive table, mirroring legacy {@code HiveMetadataOps.dropTableImpl}: a transactional table is + * rejected. {@code PluginDrivenExternalCatalog} has already resolved the handle / IF EXISTS upstream and + * routed a view DROP elsewhere. + */ @Override - public boolean databaseExists(ConnectorSession session, String dbName) { + public void dropTable(ConnectorSession session, ConnectorTableHandle handle) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).dropTable(session, handle); + return; + } + HiveTableHandle hiveHandle = (HiveTableHandle) handle; try { - hmsClient.getDatabase(dbName); - return true; + // The handle was just built by the bridge's getTableHandle (which loaded the table), so its + // parameters carry the transactional flag; reuse them instead of re-fetching, matching legacy's + // AcidUtils.isTransactionalTable(client.getTable(...)) check. + dropTableChecked(hiveHandle.getDbName(), hiveHandle.getTableName(), + hiveHandle.getTableParameters()); } catch (HmsClientException e) { - LOG.debug("Database '{}' not found: {}", dbName, e.getMessage()); - return false; + throw new DorisConnectorException("Failed to drop Hive table " + + hiveHandle.getDbName() + "." + hiveHandle.getTableName() + ": " + e.getMessage(), e); } } - // ========== ConnectorTableOps ========== - + /** + * Truncates a Hive table, or the given partitions of it, mirroring legacy + * {@code HiveMetadataOps.truncateTableImpl}. {@code partitions} is {@code null}/empty for a whole-table + * truncate. + */ @Override - public List listTableNames(ConnectorSession session, String dbName) { - return hmsClient.listTables(dbName); + public void truncateTable(ConnectorSession session, ConnectorTableHandle handle, List partitions) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).truncateTable(session, handle, partitions); + return; + } + HiveTableHandle hiveHandle = (HiveTableHandle) handle; + try { + hmsClient.truncateTable(hiveHandle.getDbName(), hiveHandle.getTableName(), partitions); + } catch (HmsClientException e) { + throw new DorisConnectorException("Failed to truncate Hive table " + + hiveHandle.getDbName() + "." + hiveHandle.getTableName() + ": " + e.getMessage(), e); + } } + // ========== ConnectorTableOps: ALTER-DDL -- foreign (iceberg) handles divert to the sibling ========== + // + // Every mutating ALTER method already carries a handle. A foreign (iceberg-on-HMS) handle is forwarded to the + // embedded iceberg sibling (which implements the real column / branch / tag / partition-field evolution); the + // foreign handle is NEVER cast. A hive handle reproduces the pre-flip behavior: hive supports none of these + // through this SPI, so its branch throws the exact SPI-default message it inherited before this override. + // Dormant until hms enters SPI_READY_TYPES. + @Override - public Optional getTableHandle( - ConnectorSession session, String dbName, String tableName) { - if (!hmsClient.tableExists(dbName, tableName)) { - return Optional.empty(); + public void renameTable(ConnectorSession session, ConnectorTableHandle handle, String newName) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).renameTable(session, handle, newName); + return; } - HmsTableInfo tableInfo = hmsClient.getTable(dbName, tableName); - HiveTableType tableType = HiveTableFormatDetector.detect(tableInfo); + // hive does not support ALTER TABLE RENAME (legacy HMSCachedClient has no rename). + throw new DorisConnectorException("RENAME TABLE not supported"); + } - // Build partition key column names - List partKeyNames = Collections.emptyList(); - List partKeys = tableInfo.getPartitionKeys(); - if (partKeys != null && !partKeys.isEmpty()) { - partKeyNames = partKeys.stream() - .map(ConnectorColumn::getName) - .collect(Collectors.toList()); + @Override + public void addColumn(ConnectorSession session, ConnectorTableHandle handle, ConnectorColumn column, + ConnectorColumnPosition position) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).addColumn(session, handle, column, position); + return; } - - HiveTableHandle handle = new HiveTableHandle.Builder(dbName, tableName, tableType) - .inputFormat(tableInfo.getInputFormat()) - .serializationLib(tableInfo.getSerializationLib()) - .location(tableInfo.getLocation()) - .partitionKeyNames(partKeyNames) - .sdParameters(tableInfo.getSdParameters()) - .tableParameters(tableInfo.getParameters()) - .build(); - return Optional.of(handle); + throw new DorisConnectorException("ADD COLUMN not supported"); } @Override - public ConnectorTableSchema getTableSchema( - ConnectorSession session, ConnectorTableHandle handle) { - HiveTableHandle hiveHandle = (HiveTableHandle) handle; - String dbName = hiveHandle.getDbName(); - String tableName = hiveHandle.getTableName(); + public void addColumns(ConnectorSession session, ConnectorTableHandle handle, List columns) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).addColumns(session, handle, columns); + return; + } + throw new DorisConnectorException("ADD COLUMNS not supported"); + } - HmsTableInfo tableInfo = hmsClient.getTable(dbName, tableName); - List columns = buildColumns(tableInfo); - List partitionKeys = buildPartitionKeys(tableInfo); + @Override + public void dropColumn(ConnectorSession session, ConnectorTableHandle handle, String columnName) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).dropColumn(session, handle, columnName); + return; + } + throw new DorisConnectorException("DROP COLUMN not supported"); + } - // Merge: regular columns + partition columns - List allColumns = new ArrayList<>(columns.size() + partitionKeys.size()); - allColumns.addAll(columns); - allColumns.addAll(partitionKeys); + @Override + public void renameColumn(ConnectorSession session, ConnectorTableHandle handle, String oldName, + String newName) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).renameColumn(session, handle, oldName, newName); + return; + } + throw new DorisConnectorException("RENAME COLUMN not supported"); + } - String formatType = detectFormatType(tableInfo); - Map tableProperties = tableInfo.getParameters() != null - ? tableInfo.getParameters() : Collections.emptyMap(); + @Override + public void modifyColumn(ConnectorSession session, ConnectorTableHandle handle, ConnectorColumn column, + ConnectorColumnPosition position) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).modifyColumn(session, handle, column, position); + return; + } + throw new DorisConnectorException("MODIFY COLUMN not supported"); + } - return new ConnectorTableSchema(tableName, allColumns, formatType, tableProperties); + @Override + public void reorderColumns(ConnectorSession session, ConnectorTableHandle handle, List newOrder) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).reorderColumns(session, handle, newOrder); + return; + } + throw new DorisConnectorException("REORDER COLUMNS not supported"); } @Override - public Map getProperties() { - return properties; + public void createOrReplaceBranch(ConnectorSession session, ConnectorTableHandle handle, BranchChange branch) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).createOrReplaceBranch(session, handle, branch); + return; + } + throw new DorisConnectorException("CREATE/REPLACE BRANCH not supported"); } - // ========== ConnectorTableOps: Column Handles ========== + @Override + public void createOrReplaceTag(ConnectorSession session, ConnectorTableHandle handle, TagChange tag) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).createOrReplaceTag(session, handle, tag); + return; + } + throw new DorisConnectorException("CREATE/REPLACE TAG not supported"); + } @Override - public Map getColumnHandles( - ConnectorSession session, ConnectorTableHandle handle) { - HiveTableHandle hiveHandle = (HiveTableHandle) handle; - HmsTableInfo tableInfo = hmsClient.getTable( - hiveHandle.getDbName(), hiveHandle.getTableName()); + public void dropBranch(ConnectorSession session, ConnectorTableHandle handle, DropRefChange branch) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).dropBranch(session, handle, branch); + return; + } + throw new DorisConnectorException("DROP BRANCH not supported"); + } - Set partKeyNames = hiveHandle.getPartitionKeyNames() != null - ? hiveHandle.getPartitionKeyNames().stream().collect(Collectors.toSet()) - : Collections.emptySet(); + @Override + public void dropTag(ConnectorSession session, ConnectorTableHandle handle, DropRefChange tag) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).dropTag(session, handle, tag); + return; + } + throw new DorisConnectorException("DROP TAG not supported"); + } - Map result = new LinkedHashMap<>(); - List allCols = new ArrayList<>(); - if (tableInfo.getColumns() != null) { - allCols.addAll(tableInfo.getColumns()); + @Override + public void addPartitionField(ConnectorSession session, ConnectorTableHandle handle, + PartitionFieldChange change) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).addPartitionField(session, handle, change); + return; } - if (tableInfo.getPartitionKeys() != null) { - allCols.addAll(tableInfo.getPartitionKeys()); + throw new DorisConnectorException("ADD PARTITION FIELD not supported"); + } + + @Override + public void dropPartitionField(ConnectorSession session, ConnectorTableHandle handle, + PartitionFieldChange change) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).dropPartitionField(session, handle, change); + return; } - for (ConnectorColumn col : allCols) { - boolean isPartKey = partKeyNames.contains(col.getName()); - result.put(col.getName(), new HiveColumnHandle( - col.getName(), col.getType().getTypeName(), isPartKey)); + throw new DorisConnectorException("DROP PARTITION FIELD not supported"); + } + + @Override + public void replacePartitionField(ConnectorSession session, ConnectorTableHandle handle, + PartitionFieldChange change) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).replacePartitionField(session, handle, change); + return; } - return result; + throw new DorisConnectorException("REPLACE PARTITION FIELD not supported"); } - // ========== ConnectorPushdownOps: Filter Pushdown ========== + // ========== ConnectorWriteOps: write validation -- foreign (iceberg) handles divert to the sibling ========== + // + // Both validators carry a handle and run at analysis time. A foreign (iceberg-on-HMS) handle forwards to the + // sibling so iceberg's real write-mode / static-partition rejections apply. A hive handle MUST reproduce the + // permissive SPI default (return silently, NEVER throw) -- a throw here would newly reject legal plain-hive + // row-level DML / static-partition INSERTs. Dormant until hms enters SPI_READY_TYPES. @Override - public Optional> applyFilter( - ConnectorSession session, ConnectorTableHandle handle, - ConnectorFilterConstraint constraint) { - HiveTableHandle hiveHandle = (HiveTableHandle) handle; - List partKeyNames = hiveHandle.getPartitionKeyNames(); - if (partKeyNames == null || partKeyNames.isEmpty()) { - return Optional.empty(); + public void validateRowLevelDmlMode(ConnectorSession session, ConnectorTableHandle handle, WriteOperation op) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).validateRowLevelDmlMode(session, handle, op); + return; } + // hive: no per-table row-level DML mode constraint (SPI default no-op). + } - // Extract equality predicates on partition columns from the expression - Map> partitionPredicates = extractPartitionPredicates( - constraint.getExpression(), partKeyNames); - if (partitionPredicates.isEmpty()) { - return Optional.empty(); + @Override + public void validateStaticPartitionColumns(ConnectorSession session, ConnectorTableHandle handle, + List staticPartitionColumnNames) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle) + .validateStaticPartitionColumns(session, handle, staticPartitionColumnNames); + return; } + // hive: no static-partition constraint (SPI default no-op). + } - // Build partition name filter patterns for HMS - List allPartNames = hmsClient.listPartitionNames( - hiveHandle.getDbName(), hiveHandle.getTableName(), 100000); - List matchedPartNames = prunePartitionNames( - allPartNames, partKeyNames, partitionPredicates); + /** + * Rejects the dynamic partition-NAME list form ({@code INSERT ... PARTITION(p1, p2)}) on a hive table with the + * exact legacy message. UNLIKE the two permissive validators above, a hive handle here THROWS on a non-empty + * list — this is the net-new port of the legacy fe-core reject ({@code BindSink.bindHiveTableSink}), not a + * silent no-op. A foreign (iceberg-on-HMS) handle forwards to the sibling, which accepts {@code + * PARTITION(names)} exactly as a standalone {@code type=iceberg} catalog does (no heterogeneous-vs-standalone + * divergence); the forward happens regardless of emptiness (the empty-early-return is hive-only). An empty + * list returns silently for a hive handle (a plain {@code INSERT ... SELECT} or a static {@code + * PARTITION(col='val')} INSERT is legal plain-hive). Dormant until hms enters SPI_READY_TYPES. + */ + @Override + public void validateWritePartitionNames(ConnectorSession session, ConnectorTableHandle handle, + List partitionNames) { + if (!(handle instanceof HiveTableHandle)) { + siblingMetadata(session, handle).validateWritePartitionNames(session, handle, partitionNames); + return; + } + if (partitionNames != null && !partitionNames.isEmpty()) { + throw new DorisConnectorException("Not support insert with partition spec in hive catalog."); + } + } - if (matchedPartNames.size() == allPartNames.size()) { - // No pruning effect - return Optional.empty(); + // ========== ConnectorWriteOps: transactions ========== + + /** + * Opens a {@link HiveConnectorTransaction} for a hive non-ACID INSERT / INSERT OVERWRITE, mirroring the + * iceberg one-liner (design D1: {@code planWrite} lives in {@code HiveWritePlanProvider}, the metadata + * carries only the begin factory). The transaction id is the engine-allocated Doris global id (it is + * registered in the engine transaction registry and stamped into the sink), so it must come from the + * session, not be minted here. Dormant until the P7.4/P7.5 cutover. + */ + @Override + public ConnectorTransaction beginTransaction(ConnectorSession session) { + return new HiveConnectorTransaction(session.allocateTransactionId(), hmsClient, context); + } + + /** + * Per-handle transaction open: a FOREIGN (iceberg-on-HMS) handle forwards to the sibling so the + * session-bound transaction is the sibling's {@code IcebergConnectorTransaction} that iceberg's write plan + * downcasts; a hive handle falls through to the connector-level {@link #beginTransaction(ConnectorSession)} + * (a {@code HiveConnectorTransaction} that the hive write plan downcasts). The two write plans downcast to + * DIFFERENT concrete transaction types, so the selection MUST be symmetric — an always-forward (or + * always-hive) shortcut breaks the opposite side. The engine passes the resolved write-target handle + * (never null). Dormant until hms enters SPI_READY_TYPES. + */ + @Override + public ConnectorTransaction beginTransaction(ConnectorSession session, ConnectorTableHandle handle) { + if (!(handle instanceof HiveTableHandle)) { + return siblingMetadata(session, handle).beginTransaction(session, handle); } + return beginTransaction(session); + } - List prunedPartitions = matchedPartNames.isEmpty() - ? Collections.emptyList() - : hmsClient.getPartitions(hiveHandle.getDbName(), - hiveHandle.getTableName(), matchedPartNames); + /** + * Drops {@code dbName.tableName} after rejecting a transactional table, mirroring legacy + * {@code HiveMetadataOps.dropTableImpl}. Shared by the direct DROP TABLE and the force DROP DATABASE + * cascade. + */ + private void dropTableChecked(String dbName, String tableName, Map tableParameters) { + if (isTransactionalTable(tableParameters)) { + throw new DorisConnectorException("Not support drop hive transactional table."); + } + hmsClient.dropTable(dbName, tableName); + } - LOG.info("Partition pruning: {}.{} all={} pruned={}", - hiveHandle.getDbName(), hiveHandle.getTableName(), - allPartNames.size(), prunedPartitions.size()); + /** + * Whether the metastore table parameters mark the table transactional, replicating Hive's + * {@code AcidUtils.isTransactionalTable} (case-insensitive "true" under the "transactional" key, with the + * upper-cased key as a fallback) without pulling in the hive-exec dependency. + */ + private static boolean isTransactionalTable(Map tableParameters) { + if (tableParameters == null) { + return false; + } + String value = tableParameters.get(HiveConnectorProperties.CREATE_TRANSACTIONAL); + if (value == null) { + value = tableParameters.get( + HiveConnectorProperties.CREATE_TRANSACTIONAL.toUpperCase(Locale.ROOT)); + } + return "true".equalsIgnoreCase(value); + } - HiveTableHandle newHandle = hiveHandle.toBuilder() - .prunedPartitions(prunedPartitions) - .build(); - return Optional.of(new FilterApplicationResult<>( - newHandle, constraint.getExpression(), false)); + /** + * Resolves the compression a {@code text} table falls back to when the user set no {@code compression} + * property, replicating legacy {@code SessionVariable.hiveTextCompression()} (the "uncompressed" alias maps + * to "plain"). The value rides on the request; the write converter only consults it for a text table. + */ + private static String resolveTextCompressionDefault(ConnectorSession session) { + String textCompression = session.getSessionProperties() + .get(HiveConnectorProperties.SESSION_HIVE_TEXT_COMPRESSION); + if (HiveConnectorProperties.TEXT_COMPRESSION_UNCOMPRESSED.equals(textCompression)) { + return HiveConnectorProperties.TEXT_COMPRESSION_PLAIN; + } + return textCompression; } // ========== Internal helpers ========== @@ -242,22 +1934,23 @@ private List buildColumns(HmsTableInfo tableInfo) { } // HmsTableInfo already returns ConnectorColumn with types mapped by HmsTypeMapping // during ThriftHmsClient.getTable(). Enrich with default values if available. + List columns = spiColumns; Map defaults = getDefaultValues(tableInfo); - if (defaults.isEmpty()) { - return spiColumns; - } - List enriched = new ArrayList<>(spiColumns.size()); - for (ConnectorColumn col : spiColumns) { - String defaultVal = defaults.get(col.getName()); - if (defaultVal != null && col.getDefaultValue() == null) { - enriched.add(new ConnectorColumn( - col.getName(), col.getType(), col.getComment(), - col.isNullable(), defaultVal)); - } else { - enriched.add(col); + if (!defaults.isEmpty()) { + List enriched = new ArrayList<>(spiColumns.size()); + for (ConnectorColumn col : spiColumns) { + String defaultVal = defaults.get(col.getName()); + if (defaultVal != null && col.getDefaultValue() == null) { + enriched.add(new ConnectorColumn( + col.getName(), col.getType(), col.getComment(), + col.isNullable(), defaultVal)); + } else { + enriched.add(col); + } } + columns = enriched; } - return enriched; + return coerceOpenCsvColumnsToString(tableInfo, columns); } private List buildPartitionKeys(HmsTableInfo tableInfo) { @@ -268,6 +1961,68 @@ private List buildPartitionKeys(HmsTableInfo tableInfo) { return partKeys; } + /** + * Widens a hive {@code string} partition column to {@code varchar(65533)}, replicating legacy + * {@code HMSExternalTable.initPartitionColumns}: a bare-string partition column is coerced to + * {@code varchar(ScalarType.MAX_VARCHAR_LENGTH)} "to be same as doris managed table", while every other + * declared type (int/date/timestamp/decimal/varchar(n)/char(n)/...) is kept exactly as + * {@code HmsTypeMapping} produced it. The gate is the mapped connector type name {@code STRING} (hive + * {@code string}, and {@code binary} when not mapped to varbinary, both land on it), matching legacy's + * {@code PrimitiveType.STRING} check. The widened column keeps the same name/comment/nullability/flags, so + * the full-schema entry and the partition-column view carry the identical type (legacy mutated one shared + * {@code Column} in place). + */ + private static List coercePartitionKeyStringToVarchar(List partitionKeys) { + if (partitionKeys.isEmpty()) { + return partitionKeys; + } + List coerced = new ArrayList<>(partitionKeys.size()); + for (ConnectorColumn col : partitionKeys) { + if ("STRING".equals(col.getType().getTypeName())) { + coerced.add(new ConnectorColumn(col.getName(), + ConnectorType.of("VARCHAR", MAX_VARCHAR_LENGTH, -1), + col.getComment(), col.isNullable(), col.getDefaultValue(), + col.isKey(), col.isAutoInc(), col.isAggregated())); + } else { + coerced.add(col); + } + } + return coerced; + } + + /** + * Flattens every DATA column of a hive {@code OpenCSVSerde} table to {@code STRING}, reproducing the + * schema legacy obtained through the metastore {@code get_schema} RPC. {@code OpenCSVSerde} + * ({@code org.apache.hadoop.hive.serde2.OpenCSVSerde}) reads a delimited file as PLAIN text: its + * deserializer's ObjectInspector reports every top-level column as {@code string}, so a declared + * {@code int}/{@code date}/{@code boolean} — and even an {@code array}/{@code map}/{@code struct} — is + * served verbatim as a string and never parsed. The SPI reads the RAW stored column types + * ({@code sd.getCols()}), which for OpenCSV disagree with what the reader actually returns; legacy's + * default {@code get_schema} path (server-side deserializer) collapsed them to all-string. We reproduce + * that RESULT connector-side, WITHOUT the extra per-table RPC, by forcing the whole column type to a flat + * {@code STRING} here. Partition keys are left untouched (hive appends them after the deserializer, so + * they keep their declared types — see {@link #coercePartitionKeyStringToVarchar}); a view is never an + * OpenCSV data table (guarded). Placing the rule in this hive metadata layer (not the shared hms + * {@code ThriftHmsClient}, which also feeds the hudi connector) keeps the serde-specific typing off the + * shared path and mirrors where Trino applies CSV=all-string. Non-OpenCSV tables return unchanged, so + * every other serde stays byte-identical to the raw {@code sd.getCols()} path. + */ + private static List coerceOpenCsvColumnsToString( + HmsTableInfo tableInfo, List columns) { + if (isView(tableInfo) + || !HiveTextProperties.HIVE_OPEN_CSV_SERDE.equals(tableInfo.getSerializationLib())) { + return columns; + } + ConnectorType stringType = ConnectorType.of("STRING"); + List forced = new ArrayList<>(columns.size()); + for (ConnectorColumn col : columns) { + forced.add(new ConnectorColumn(col.getName(), stringType, col.getComment(), + col.isNullable(), col.getDefaultValue(), + col.isKey(), col.isAutoInc(), col.isAggregated())); + } + return forced; + } + private Map getDefaultValues(HmsTableInfo tableInfo) { try { return hmsClient.getDefaultColumnValues( @@ -312,13 +2067,78 @@ private static String resolveHiveFileFormat(String inputFormat) { return "HIVE"; } - private static HmsTypeMapping.Options buildTypeMappingOptions(Map props) { - boolean binaryAsString = Boolean.parseBoolean( - props.getOrDefault(HiveConnectorProperties.ENABLE_MAPPING_BINARY_AS_STRING, "false")); + /** + * Whether {@code tableInfo} is a plain-hive orc/parquet base table eligible for Top-N lazy materialization, + * replicating legacy {@code HMSExternalTable.supportedHiveTopNLazyTable} plus the {@code getDlaType()==HIVE} + * guard the legacy consumer ({@code MaterializeProbeVisitor}) applied: a view is excluded, an + * iceberg/hudi-on-HMS table is excluded (those are served by their own connector, which declares the + * capability connector-wide after the cutover), and only the parquet/orc input formats qualify. + */ + private boolean supportsHiveTopNLazyMaterialize(HmsTableInfo tableInfo) { + if (isView(tableInfo)) { + return false; + } + if (HiveTableFormatDetector.detect(tableInfo) != HiveTableType.HIVE) { + return false; + } + String inputFormat = tableInfo.getInputFormat(); + return MAPRED_PARQUET_INPUT_FORMAT.equals(inputFormat) || ORC_INPUT_FORMAT.equals(inputFormat); + } + + /** + * Whether {@code tableInfo} is a plain-hive data table (any file format) eligible for background per-column + * auto-analyze, replicating legacy {@code StatisticsUtil.supportAutoAnalyze}'s {@code dlaType==HIVE} gate. + * Unlike {@link #supportsHiveTopNLazyMaterialize} there is NO orc/parquet restriction (legacy analyzed any hive + * format); a view is excluded (nothing to analyze) and an iceberg/hudi-on-HMS table is excluded here + * ({@code detect() != HIVE}) — iceberg-on-HMS instead inherits the capability from its sibling via + * {@link #reflectSiblingScanCapabilities}, and hudi-on-HMS is withheld. + */ + private boolean supportsHiveColumnAutoAnalyze(HmsTableInfo tableInfo) { + return !isView(tableInfo) && HiveTableFormatDetector.detect(tableInfo) == HiveTableType.HIVE; + } + + /** + * Whether {@code tableInfo} is a plain-hive data table (any file format) eligible for {@code ANALYZE ... WITH + * SAMPLE}, replicating legacy {@code AnalysisManager.canSample}'s {@code dlaType==HIVE} gate. Like + * {@link #supportsHiveColumnAutoAnalyze} there is NO orc/parquet restriction (legacy sampled any hive format); + * a view is excluded and an iceberg/hudi-on-HMS table is excluded ({@code detect() != HIVE}) so sampled + * analyze stays rejected for them (their {@code doSample} is unimplemented). + */ + private boolean supportsHiveSampleAnalyze(HmsTableInfo tableInfo) { + return !isView(tableInfo) && HiveTableFormatDetector.detect(tableInfo) == HiveTableType.HIVE; + } + + /** Whether the HMS table is a view (tableType VIRTUAL_VIEW), mirroring legacy {@code HMSExternalTable.isView}. */ + private static boolean isView(HmsTableInfo tableInfo) { + return VIRTUAL_VIEW_TABLE_TYPE.equalsIgnoreCase(tableInfo.getTableType()); + } + + /** + * Whether the table's first (data) column is a {@code STRING}, reproducing legacy + * {@code HMSExternalTable.firstColumnIsString} ({@code isScalarType(PrimitiveType.STRING)} — {@code STRING} + * only, NOT {@code varchar}/{@code char}). Stamped onto the handle so the read-format detector can apply the + * OpenX-JSON {@code read_hive_json_in_one_column} gate without a second metastore fetch. A table with no data + * columns degrades to {@code false} (the OpenX one-column mode is nonsensical there). + */ + private static boolean firstColumnIsString(HmsTableInfo tableInfo) { + List columns = tableInfo.getColumns(); + if (columns == null || columns.isEmpty()) { + return false; + } + return "STRING".equals(columns.get(0).getType().getTypeName()); + } + + // Package-private: HiveConnector.createClient builds the LIVE ThriftHmsClient's type-mapping options from + // the catalog properties (enable.mapping.varbinary / enable.mapping.timestamp_tz). The client — not this + // metadata — converts hive column types (ThriftHmsClient.convertFieldSchemas), so the options must be fed at + // client construction; a metadata-local copy would be dead (that was the 5672d7c0209 gap). + static HmsTypeMapping.Options buildTypeMappingOptions(Map props) { + boolean enableMappingVarbinary = Boolean.parseBoolean( + props.getOrDefault(HiveConnectorProperties.ENABLE_MAPPING_VARBINARY, "false")); boolean timestampTz = Boolean.parseBoolean( props.getOrDefault(HiveConnectorProperties.ENABLE_MAPPING_TIMESTAMP_TZ, "false")); return new HmsTypeMapping.Options( - HmsTypeMapping.DEFAULT_TIME_SCALE, binaryAsString, timestampTz); + HmsTypeMapping.DEFAULT_TIME_SCALE, enableMappingVarbinary, timestampTz); } /** @@ -376,11 +2196,52 @@ private String extractColumnName(ConnectorExpression expr) { } private String extractLiteralValue(ConnectorExpression expr) { - if (expr instanceof ConnectorLiteral) { - Object val = ((ConnectorLiteral) expr).getValue(); - return val != null ? String.valueOf(val) : null; + if (!(expr instanceof ConnectorLiteral)) { + return null; } - return null; + Object val = ((ConnectorLiteral) expr).getValue(); + if (val == null) { + return null; + } + if (val instanceof LocalDateTime) { + // H2: a DATETIME/TIMESTAMP partition literal arrives as a LocalDateTime (DATE arrives as LocalDate). + // String.valueOf would call toString() -> ISO "2024-01-01T10:00" (T separator, dropped zero seconds), + // which never string-equals the Hive-canonical stored partition value "2024-01-01 10:00:00" in + // matchesPredicates -> the whole table prunes to 0 rows. Render Hive-canonical text instead. + return hiveDateTimeString((LocalDateTime) val); + } + if (val instanceof BigDecimal) { + // A DECIMAL partition literal arrives as a BigDecimal carrying the column's declared scale + // (e.g. decimal(8,4) -> "1.0000"), but the Hive-canonical stored partition value is trailing-zero + // trimmed ("1"). String.valueOf keeps the scale, so matchesPredicates' string-compare misses and + // the table prunes to 0 rows. Render trailing-zero-trimmed plain text to string-equal the stored + // value (toPlainString avoids scientific notation from stripTrailingZeros). + return ((BigDecimal) val).stripTrailingZeros().toPlainString(); + } + return String.valueOf(val); + } + + /** + * Renders a DATETIME/TIMESTAMP literal as Hive-canonical partition text: {@code yyyy-MM-dd HH:mm:ss} (space + * separator, full seconds), appending trailing-zero-trimmed microseconds only when a sub-second part is + * present. Matches the stored Hive partition value for a scale-0 DATETIME partition (the only realistic case) + * so the pruning string-compare hits. Package-private static for offline unit testing. + * + *

Contract: {@code convertDateLiteral} produces microsecond precision (nano = micros*1000), so the nano is + * always a multiple of 1000; a sub-microsecond nano (unreachable on the pruning path) would be truncated. + */ + static String hiveDateTimeString(LocalDateTime ldt) { + String base = ldt.format(HIVE_DATETIME_SECONDS_FORMAT); + int nano = ldt.getNano(); + if (nano == 0) { + return base; + } + String micros = String.format("%06d", nano / 1000); + int end = micros.length(); + while (end > 1 && micros.charAt(end - 1) == '0') { + end--; + } + return base + "." + micros.substring(0, end); } /** @@ -399,14 +2260,23 @@ private List prunePartitionNames(List allPartNames, return matched; } - private Map parsePartitionName(String partName, + static Map parsePartitionName(String partName, List partKeyNames) { Map values = new HashMap<>(); String[] parts = partName.split("/"); for (String part : parts) { int eq = part.indexOf('='); if (eq > 0) { - values.put(part.substring(0, eq), part.substring(eq + 1)); + // Unescape the VALUE: HMS get_partition_names returns Hive-escaped names (e.g. "%3A" for ':'). + // The predicate literal side (extractLiteralValue) is unescaped, so matchesPredicates' string + // compare needs the value unescaped too — otherwise an escaped partition value silently drops + // rows. Mirrors the sibling partition-value parse (HiveWriteUtils.toPartitionValues) and legacy + // FileUtils.unescapePathName. The KEY must be unescaped too: Hive's makePartName escapes the + // column name as well (a special-char partition column such as `pt2=x!!!! **1+1/&^%3` comes + // back as an escaped key), and matchesPredicates looks it up by the real unescaped column name, + // so an escaped key would silently miss and drop every row. Unescaping a plain name is a no-op. + values.put(HiveWriteUtils.unescapePathName(part.substring(0, eq)), + HiveWriteUtils.unescapePathName(part.substring(eq + 1))); } } return values; diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorProperties.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorProperties.java index 0d1a5ee48c6129..dae9f256b0d62b 100644 --- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorProperties.java +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorProperties.java @@ -17,7 +17,11 @@ package org.apache.doris.connector.hive; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; import java.util.Map; +import java.util.Set; /** * Property constants for Hive connector configuration. @@ -49,8 +53,67 @@ private HiveConnectorProperties() { public static final String FLINK_CONNECTOR = "connector"; // -- type mapping options -- - public static final String ENABLE_MAPPING_BINARY_AS_STRING = "enable_mapping_binary_as_string"; - public static final String ENABLE_MAPPING_TIMESTAMP_TZ = "enable_mapping_timestamp_tz"; + // Catalog-level property keys (dot form), matching CatalogProperty and the iceberg/paimon connectors. + // ExternalCatalog forwards these two keys to the connector; the earlier underscore spellings were never + // populated, so the toggles silently no-op'd (hive BINARY always STRING, timestamp never TIMESTAMPTZ). + public static final String ENABLE_MAPPING_VARBINARY = "enable.mapping.varbinary"; + public static final String ENABLE_MAPPING_TIMESTAMP_TZ = "enable.mapping.timestamp_tz"; + + // -- CREATE TABLE / DATABASE property keys (legacy HiveMetadataOps) -- + public static final String CREATE_FILE_FORMAT = "file_format"; + public static final String CREATE_LOCATION = "location"; + public static final String CREATE_OWNER = "owner"; + public static final String CREATE_COMMENT = "comment"; + public static final String CREATE_TRANSACTIONAL = "transactional"; + /** + * Property keys that legacy {@code HiveMetadataOps} stamps into the metastore table parameters under a + * {@code doris.} prefix (so they round-trip). Mirrors legacy {@code HiveMetadataOps.DORIS_HIVE_KEYS}. + */ + public static final Set DORIS_HIVE_KEYS = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList(CREATE_FILE_FORMAT, CREATE_LOCATION))); + public static final String DORIS_PROP_PREFIX = "doris."; + + // -- environment keys threaded from fe-core DefaultConnectorContext (must stay byte-identical there) -- + public static final String ENV_HIVE_DEFAULT_FILE_FORMAT = "hive_default_file_format"; + public static final String ENV_ENABLE_CREATE_HIVE_BUCKET_TABLE = "enable_create_hive_bucket_table"; + public static final String ENV_DORIS_VERSION = "doris_version"; + /** Fallback default file format, matching legacy {@code Config.hive_default_file_format} default. */ + public static final String DEFAULT_FILE_FORMAT = "orc"; + + // -- session variable read for a text table's compression default (legacy hive_text_compression) -- + public static final String SESSION_HIVE_TEXT_COMPRESSION = "hive_text_compression"; + public static final String TEXT_COMPRESSION_UNCOMPRESSED = "uncompressed"; + public static final String TEXT_COMPRESSION_PLAIN = "plain"; + + // Session variable gating the OpenX-JSON "read the whole JSON row into one CSV column" mode (legacy + // SessionVariable.read_hive_json_in_one_column). Byte-identical to the fe-core session-var name; it is + // surfaced through ConnectorSession.getSessionProperties() (VariableMgr dumps all visible vars). + public static final String SESSION_READ_HIVE_JSON_IN_ONE_COLUMN = "read_hive_json_in_one_column"; + + /** + * Bucket algorithm string produced by {@code CreateTableInfoToConnectorRequestConverter} for a + * random (non-hash) distribution. Hive external tables only support hash bucketing. + */ + public static final String BUCKET_ALGO_RANDOM = "doris_random"; + + // ===== Metastore incremental event sync (per-catalog opt-in) ===== + + /** Whether this catalog polls HMS notification events for incremental metadata refresh. */ + public static final String ENABLE_HMS_EVENTS_INCREMENTAL_SYNC = + "hive.enable_hms_events_incremental_sync"; + + /** Max notification events fetched per RPC when incremental event sync is enabled. */ + public static final String HMS_EVENTS_BATCH_SIZE_PER_RPC = "hive.hms_events_batch_size_per_rpc"; + + /** Default batch size, matching the engine's legacy {@code hms_events_batch_size_per_rpc} default. */ + public static final int DEFAULT_HMS_EVENTS_BATCH_SIZE = 500; + + /** + * When {@code false}, a partition whose storage location does not exist fails the query loud + * ({@code "Partition location does not exist"}); the default {@code true} tolerates it by skipping + * the partition with a warning. Mirrors legacy {@code HiveExternalMetaCache} semantics. + */ + public static final String IGNORE_ABSENT_PARTITIONS = "hive.ignore_absent_partitions"; /** * Parse an integer property with a default value. @@ -66,4 +129,15 @@ public static int getInt(Map props, String key, int defaultVal) return defaultVal; } } + + /** + * Parse a boolean property with a default value. + */ + public static boolean getBoolean(Map props, String key, boolean defaultVal) { + String value = props.get(key); + if (value == null || value.isEmpty()) { + return defaultVal; + } + return Boolean.parseBoolean(value.trim()); + } } 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 924ffa879464d1..15f564866fafb5 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 @@ -18,6 +18,8 @@ package org.apache.doris.connector.hive; import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.cache.CacheSpec; +import org.apache.doris.connector.hms.HmsClientConfig; import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorProvider; @@ -40,4 +42,26 @@ public String getType() { public Connector create(Map properties, ConnectorContext context) { return new HiveConnector(properties, context); } + + @Override + public void validateProperties(Map properties) { + // Reject removed metastore types at CREATE/ALTER CATALOG. This runs only for a user-issued statement, + // never during edit-log replay, so an already-created glue catalog cannot block FE startup here; it is + // rejected later, at HiveConnector.createClient(). IllegalArgumentException is required: it is the only + // type PluginDrivenExternalCatalog.checkProperties unwraps, preserving the message verbatim. + String removedType = HmsClientConfig.removedMetastoreTypeError(properties); + if (removedType != null) { + throw new IllegalArgumentException(removedType); + } + + // Restore the legacy HMSExternalCatalog.checkProperties fail-fast for the two meta-cache TTL knobs: + // after the hms cutover an "hms" catalog is created via this SPI provider (not HMSExternalCatalog), so + // the old per-property validation no longer runs and an invalid ttl (e.g. -2) was silently accepted. + // Legacy semantics: the value, when present, must be a long >= 0 (CACHE_TTL_DISABLE_CACHE); < 0 is + // rejected. checkLongProperty emits the identical "The parameter ... is wrong, value is ..." message. + CacheSpec.checkLongProperty(properties.get("file.meta.cache.ttl-second"), + 0L, "file.meta.cache.ttl-second"); + CacheSpec.checkLongProperty(properties.get("partition.cache.ttl-second"), + 0L, "partition.cache.ttl-second"); + } } 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 new file mode 100644 index 00000000000000..89acc1f0070fad --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorTransaction.java @@ -0,0 +1,1694 @@ +// 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. +// This file ports the write/commit lifecycle of the legacy fe-core +// org.apache.doris.datasource.hive.HMSTransaction (itself derived from Trino's +// SemiTransactionalHiveMetastore) onto the connector SPI, breaking the fe-core couplings per the +// P7.3 design decisions (D1-D12). Control flow is preserved; only the fe-core seams are replaced. + +package org.apache.doris.connector.hive; + +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.handle.ConnectorTransaction; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsCommonStatistics; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsPartitionStatistics; +import org.apache.doris.connector.hms.HmsPartitionWithStatistics; +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.filesystem.FileEntry; +import org.apache.doris.filesystem.FileSystem; +import org.apache.doris.filesystem.FileSystemUtil; +import org.apache.doris.filesystem.Location; +import org.apache.doris.filesystem.spi.ObjFileSystem; +import org.apache.doris.thrift.TFileType; +import org.apache.doris.thrift.THiveLocationParams; +import org.apache.doris.thrift.THivePartitionUpdate; +import org.apache.doris.thrift.TS3MPUPendingUpload; +import org.apache.doris.thrift.TUpdateMode; + +import com.google.common.base.Preconditions; +import com.google.common.base.Verify; +import com.google.common.collect.Iterables; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.hive.metastore.api.FieldSchema; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.thrift.TDeserializer; +import org.apache.thrift.TException; +import org.apache.thrift.protocol.TBinaryProtocol; + +import java.io.IOException; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +/** + * Hive non-ACID write transaction ported from the legacy fe-core {@code HMSTransaction} onto the + * connector {@link ConnectorTransaction} SPI (design P7.3). It accumulates the BE-reported commit + * fragments ({@link THivePartitionUpdate}, fed via {@link #addCommitData}), classifies each partition + * update into a table/partition action (APPEND / NEW / OVERWRITE, with a NEW→APPEND downgrade on an + * HMS existence probe), and drives the {@link HmsCommitter}: staging→target renames, object-store + * multipart-upload complete/abort, {@code addPartitions}, and statistics updates. + * + *

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 + * 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 + * routes through {@code HiveWritePlanProvider.planWrite} → {@link #beginWrite} → this class. The engine + * {@link FileSystem} is borrowed (never closed here — the catalog owns its lifecycle). + */ +public class HiveConnectorTransaction implements ConnectorTransaction { + + private static final Logger LOG = LogManager.getLogger(HiveConnectorTransaction.class); + + private final long transactionId; + private final HmsClient hmsClient; + private final ConnectorContext context; + + // Plugin-owned async pool for staging renames + MPU complete/abort (the legacy class had this injected + // by fe-core). Shut down in close(). authWrappingExecutor wraps each submitted task in the catalog auth + // context (D5: plugin-owned pool threads do not inherit the caller pin). + private final ExecutorService fileSystemExecutor; + private final Executor authWrappingExecutor; + + // Captured at beginWrite (D6). Threaded to context.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; + + private NameMapping nameMapping; + private volatile HmsTableInfo hmsTableInfo; + private String queryId; + private boolean isOverwrite; + private TFileType fileType; + private Optional stagingDirectory = Optional.empty(); + private boolean isMockedPartitionUpdate = false; + + private List hivePartitionUpdates = new ArrayList<>(); + private final Map> tableActions = new HashMap<>(); + private final Map, Action>> partitionActions = new HashMap<>(); + private final Set uncompletedMpuPendingUploads = new HashSet<>(); + + private HmsCommitter hmsCommitter; + + public HiveConnectorTransaction(long transactionId, HmsClient hmsClient, ConnectorContext context) { + this.transactionId = transactionId; + this.hmsClient = hmsClient; + this.context = context; + this.fileSystemExecutor = Executors.newFixedThreadPool(16, namedDaemonThreadFactory("hive-write-fs-%d")); + this.authWrappingExecutor = command -> fileSystemExecutor.execute(() -> { + try { + context.executeAuthenticated(() -> { + command.run(); + return null; + }); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + // ─────────────────────────────── SPI surface ─────────────────────────────── + + @Override + public long getTransactionId() { + return transactionId; + } + + @Override + public String profileLabel() { + // D2: maps to the existing fe-core TransactionType.HMS; any other string would fall back to UNKNOWN. + return "HMS"; + } + + @Override + public void addCommitData(byte[] commitFragment) { + THivePartitionUpdate pu = new THivePartitionUpdate(); + try { + new TDeserializer(new TBinaryProtocol.Factory()).deserialize(pu, commitFragment); + } catch (TException e) { + throw new DorisConnectorException("failed to deserialize Hive partition update", e); + } + synchronized (this) { + hivePartitionUpdates.add(pu); + } + } + + @Override + public long getUpdateCnt() { + // D3: preserve legacy behavior — the affected-row count is the sum of the fragment row counts. + return hivePartitionUpdates.stream().mapToLong(THivePartitionUpdate::getRowCount).sum(); + } + + @Override + public void close() { + // Only the self-owned async pool is closed here. The FileSystem is borrowed from the engine + // (context.getFileSystem) — the catalog owns its lifecycle, so the connector must not close it (D6). + shutdownExecutorService(fileSystemExecutor); + } + + /** + * Opens the write for {@code db.tableName} (analogue of iceberg {@code beginWrite}; folds the legacy + * {@code beginInsertTable} plus the table load and the full-ACID-write guard). Called by INC-4's + * {@code HiveWritePlanProvider.planWrite}. The table is loaded under the catalog auth context (D5) — the + * only pre-commit point that has the table — so the full-ACID reject (D7) can run here. + */ + public void beginWrite(ConnectorSession session, String db, String tableName, HiveWriteContext ctx) { + this.session = session; + this.queryId = ctx.getQueryId(); + this.isOverwrite = ctx.isOverwrite(); + this.fileType = ctx.getFileType(); + this.stagingDirectory = (fileType == TFileType.FILE_S3) + ? Optional.empty() : Optional.of(ctx.getWritePath()); + this.nameMapping = new NameMapping(context.getCatalogId(), db, tableName, db, tableName); + try { + context.executeAuthenticated(() -> { + HmsTableInfo table = hmsClient.getTable(db, tableName); + rejectTransactionalWrite(table.getParameters()); + this.hmsTableInfo = table; + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to begin write for hive table " + tableName + ": " + e.getMessage(), e); + } + } + + @Override + public void commit() { + // The classification (finishInsertTable) ran from the executor in the legacy class; the unified SPI + // exposes only commit(), so it runs here (before the committer) to populate the action maps. If it + // throws, the committer was never created and the engine's subsequent rollback() cleans up. + finishInsertTable(nameMapping); + hmsCommitter = new HmsCommitter(); + try { + for (Map.Entry> entry : tableActions.entrySet()) { + NameMapping nm = entry.getKey(); + Action action = entry.getValue(); + switch (action.getType()) { + case INSERT_EXISTING: + hmsCommitter.prepareInsertExistingTable(nm, action.getData()); + break; + case ALTER: + hmsCommitter.prepareAlterTable(nm, action.getData()); + break; + default: + throw new UnsupportedOperationException( + "Unsupported table action type: " + action.getType()); + } + } + + for (Map.Entry, Action>> tableEntry + : partitionActions.entrySet()) { + NameMapping nm = tableEntry.getKey(); + for (Map.Entry, Action> partitionEntry + : tableEntry.getValue().entrySet()) { + Action action = partitionEntry.getValue(); + switch (action.getType()) { + case INSERT_EXISTING: + hmsCommitter.prepareInsertExistPartition(nm, action.getData()); + break; + case ADD: + hmsCommitter.prepareAddPartition(nm, action.getData()); + break; + case ALTER: + hmsCommitter.prepareAlterPartition(nm, action.getData()); + break; + default: + throw new UnsupportedOperationException( + "Unsupported partition action type: " + action.getType()); + } + } + } + + hmsCommitter.doCommit(); + } catch (Throwable t) { + LOG.warn("Failed to commit for {}, abort it.", queryId); + try { + hmsCommitter.abort(); + hmsCommitter.rollback(); + } catch (RuntimeException e) { + t.addSuppressed(new Exception("Failed to roll back after commit failure", e)); + } + throw t; + } finally { + hmsCommitter.runClearPathsForFinish(); + hmsCommitter.shutdownExecutorService(); + } + } + + @Override + public void rollback() { + if (hmsCommitter == null) { + collectUncompletedMpuPendingUploads(hivePartitionUpdates); + if (uncompletedMpuPendingUploads.isEmpty()) { + return; + } + hmsCommitter = new HmsCommitter(); + try { + hmsCommitter.rollback(); + } finally { + hmsCommitter.shutdownExecutorService(); + } + return; + } + try { + hmsCommitter.abort(); + hmsCommitter.rollback(); + } finally { + hmsCommitter.shutdownExecutorService(); + } + } + + // ─────────────────────────────── begin-guard (D7) ─────────────────────────────── + + // DEC-2: reject ANY transactional Hive table (full-ACID AND insert-only), matching legacy + // InsertIntoTableCommand's AcidUtils.isTransactionalTable gate. A narrower full-ACID-only reject would let + // an insert-only ACID table slip through and get plain files (corrupt/invisible data). + private static void rejectTransactionalWrite(Map tableParameters) { + if (isTransactionalTable(tableParameters)) { + throw new DorisConnectorException( + "Cannot write to a transactional Hive table (only non-ACID INSERT/OVERWRITE is supported)"); + } + } + + // Mirrors hive AcidUtils.isTablePropertyTransactional: "transactional" (or its upper-cased key) parsed as + // a boolean. D8: derived plugin-side from the raw HMS parameters (fe-core parses no properties). + private static boolean isTransactionalTable(Map params) { + if (params == null) { + return false; + } + String value = params.get("transactional"); + if (value == null) { + value = params.get("transactional".toUpperCase(Locale.ROOT)); + } + return Boolean.parseBoolean(value); + } + + // Mirrors hive AcidUtils.isInsertOnlyTable: transactional_properties == "insert_only" (case-insensitive). + private static boolean isInsertOnlyTable(Map params) { + if (params == null) { + return false; + } + return "insert_only".equalsIgnoreCase(params.get("transactional_properties")); + } + + // Mirrors hive AcidUtils.isFullAcidTable: transactional AND NOT insert-only. + private static boolean isFullAcidTable(Map params) { + return isTransactionalTable(params) && !isInsertOnlyTable(params); + } + + // ─────────────────────────────── classification (legacy finishInsertTable) ─────────────────────────────── + + void finishInsertTable(NameMapping nameMapping) { + HmsTableInfo table = getTable(nameMapping); + if (hivePartitionUpdates.isEmpty() && isOverwrite && table.getPartitionKeys().isEmpty()) { + // INSERT OVERWRITE from an empty source: fabricate one empty OVERWRITE update to clean the table. + isMockedPartitionUpdate = true; + THivePartitionUpdate emptyUpdate = new THivePartitionUpdate(); + emptyUpdate.setUpdateMode(TUpdateMode.OVERWRITE); + emptyUpdate.setFileSize(0); + emptyUpdate.setRowCount(0); + emptyUpdate.setFileNames(Collections.emptyList()); + if (fileType == TFileType.FILE_S3) { + emptyUpdate.setS3MpuPendingUploads(new ArrayList<>(Collections.singletonList( + new TS3MPUPendingUpload()))); + THiveLocationParams location = new THiveLocationParams(); + location.setWritePath(table.getLocation()); + emptyUpdate.setLocation(location); + } else if (stagingDirectory.isPresent()) { + String v = stagingDirectory.get(); + try { + getFileSystem().mkdirs(Location.of(v)); + } catch (IOException e) { + throw new RuntimeException("Failed to create staging directory: " + v, e); + } + THiveLocationParams location = new THiveLocationParams(); + location.setWritePath(v); + emptyUpdate.setLocation(location); + } + hivePartitionUpdates = new ArrayList<>(Collections.singletonList(emptyUpdate)); + } + + List mergedPUs = HiveWriteUtils.mergePartitions(hivePartitionUpdates); + collectUncompletedMpuPendingUploads(mergedPUs); + List> insertExistsPartitions = new ArrayList<>(); + for (THivePartitionUpdate pu : mergedPUs) { + TUpdateMode updateMode = pu.getUpdateMode(); + HmsPartitionStatistics stats = HmsPartitionStatistics.fromCommonStatistics( + pu.getRowCount(), pu.getFileNamesSize(), pu.getFileSize()); + String writePath = pu.getLocation().getWritePath(); + if (table.getPartitionKeys().isEmpty()) { + Preconditions.checkArgument(mergedPUs.size() == 1, + "When updating a non-partitioned table, multiple partitions should not be written"); + switch (updateMode) { + case APPEND: + finishChangingExistingTable(ActionType.INSERT_EXISTING, nameMapping, writePath, + pu.getFileNames(), stats, pu); + break; + case OVERWRITE: + dropTable(nameMapping); + createTable(nameMapping, table, writePath, pu.getFileNames(), stats, pu); + break; + default: + throw new RuntimeException("Not support mode:[" + updateMode + "] in unPartitioned table"); + } + } else { + switch (updateMode) { + case APPEND: + insertExistsPartitions.add(new AbstractMap.SimpleImmutableEntry<>(pu, stats)); + break; + case NEW: + String partitionName = pu.getName(); + if (partitionName == null || partitionName.isEmpty()) { + LOG.warn("Partition name is null/empty for NEW mode in partitioned table, skipping"); + break; + } + List partitionValues = HiveWriteUtils.toPartitionValues(partitionName); + boolean existsInHms; + try { + existsInHms = hmsClient.partitionExists(nameMapping.getRemoteDbName(), + nameMapping.getRemoteTblName(), partitionValues); + } catch (Exception e) { + // Not found (or the probe failed) -> treat as truly new, mirroring the legacy + // getPartition()-in-try-catch. + existsInHms = false; + if (LOG.isDebugEnabled()) { + LOG.debug("Partition {} existence probe failed, will create it", partitionName); + } + } + if (existsInHms) { + LOG.info("Partition {} already exists in HMS (Doris cache miss), treating as APPEND", + partitionName); + insertExistsPartitions.add(new AbstractMap.SimpleImmutableEntry<>(pu, stats)); + } else { + createAndAddPartition(nameMapping, table, partitionValues, writePath, pu, stats, false); + } + break; + case OVERWRITE: + String overwritePartitionName = pu.getName(); + if (overwritePartitionName == null || overwritePartitionName.isEmpty()) { + LOG.warn("Partition name is null/empty for OVERWRITE mode in partitioned table, " + + "skipping"); + break; + } + createAndAddPartition(nameMapping, table, + HiveWriteUtils.toPartitionValues(overwritePartitionName), + writePath, pu, stats, true); + break; + default: + throw new RuntimeException("Not support mode:[" + updateMode + "] in partitioned table"); + } + } + } + + if (!insertExistsPartitions.isEmpty()) { + convertToInsertExistingPartitionAction(nameMapping, insertExistsPartitions); + } + } + + private void collectUncompletedMpuPendingUploads(List hivePartitionUpdates) { + for (THivePartitionUpdate pu : hivePartitionUpdates) { + if (pu.getS3MpuPendingUploads() != null) { + for (TS3MPUPendingUpload s3MpuPendingUpload : pu.getS3MpuPendingUploads()) { + uncompletedMpuPendingUploads.add( + new UncompletedMpuPendingUpload(s3MpuPendingUpload, pu.getLocation().getWritePath())); + } + } + } + } + + private void convertToInsertExistingPartitionAction( + NameMapping nameMapping, + List> partitions) { + Map, Action> partitionActionsForTable = + partitionActions.computeIfAbsent(nameMapping, k -> new HashMap<>()); + + for (List> partitionBatch + : Iterables.partition(partitions, 100)) { + + List partitionNames = partitionBatch.stream() + .map(pair -> pair.getKey().getName()) + .collect(Collectors.toList()); + + // check in partitionAction + Action oldPartitionAction = partitionActionsForTable.get(partitionNames); + if (oldPartitionAction != null) { + switch (oldPartitionAction.getType()) { + case DROP: + case DROP_PRESERVE_DATA: + throw new RuntimeException("Not found partition from partition actions" + + "for " + nameMapping.getFullLocalName() + ", partitions: " + partitionNames); + case ADD: + case ALTER: + case INSERT_EXISTING: + case MERGE: + throw new UnsupportedOperationException("Inserting into a partition that were added, altered," + + "or inserted into in the same transaction is not supported"); + default: + throw new IllegalStateException("Unknown action type: " + oldPartitionAction.getType()); + } + } + + List hmsPartitions = hmsClient.getPartitions( + nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), partitionNames); + // Mirror HiveUtil.convertToNamePartitionMap's Collectors.toMap: fail loud on a duplicate + // key (two HMS partitions with identical values, or a repeated requested partition name) + // instead of silently overwriting. A silent overwrite would shrink partitionsByNamesMap + // below partitionNames.size(), and the size()-bounded loop below would then drop a + // partition's INSERT_EXISTING action (silent write loss) rather than abort the txn. + Map, HmsPartitionInfo> partitionsByValues = new HashMap<>(); + for (HmsPartitionInfo p : hmsPartitions) { + if (partitionsByValues.put(p.getValues(), p) != null) { + throw new IllegalStateException("Duplicate key " + p.getValues()); + } + } + Map partitionsByNamesMap = new HashMap<>(); + for (String name : partitionNames) { + HmsPartitionInfo p = partitionsByValues.get(HiveWriteUtils.toPartitionValues(name)); + if (p != null && partitionsByNamesMap.put(name, p) != null) { + throw new IllegalStateException("Duplicate key " + name); + } + } + + for (int i = 0; i < partitionsByNamesMap.size(); i++) { + String partitionName = partitionNames.get(i); + HmsPartitionInfo partition = partitionsByNamesMap.get(partitionName); + if (partition == null) { + // Prevent this partition from being deleted by other engines. + throw new RuntimeException("Not found partition from hms for " + nameMapping.getFullLocalName() + + ", partitions: " + partitionNames); + } + THivePartitionUpdate pu = partitionBatch.get(i).getKey(); + HmsPartitionStatistics updateStats = partitionBatch.get(i).getValue(); + List partitionValues = HiveWriteUtils.toPartitionValues(pu.getName()); + + partitionActionsForTable.put( + partitionValues, + new Action<>(ActionType.INSERT_EXISTING, + new PartitionAndMore( + partitionValues, + partition.getLocation(), + pu.getLocation().getWritePath(), + pu.getName(), + pu.getFileNames(), + updateStats, + pu))); + } + } + } + + // ─────────────────────────────── state-transition helpers (legacy, synchronized) ─────────────────────────────── + + private synchronized HmsTableInfo getTable(NameMapping nameMapping) { + Action tableAction = tableActions.get(nameMapping); + if (tableAction == null) { + // Reuse the begin-time table snapshot (loaded in beginWrite for the D7 reject); the transaction + // targets exactly this one table, so no re-fetch is needed. + return hmsTableInfo; + } + switch (tableAction.getType()) { + case ADD: + case ALTER: + case INSERT_EXISTING: + case MERGE: + return tableAction.getData().getTable(); + case DROP: + case DROP_PRESERVE_DATA: + break; + default: + throw new IllegalStateException("Unknown action type: " + tableAction.getType()); + } + throw new RuntimeException("Not Found table: " + nameMapping); + } + + private synchronized void finishChangingExistingTable( + ActionType actionType, + NameMapping nameMapping, + String location, + List fileNames, + HmsPartitionStatistics statisticsUpdate, + THivePartitionUpdate hivePartitionUpdate) { + Action oldTableAction = tableActions.get(nameMapping); + if (oldTableAction == null) { + tableActions.put(nameMapping, + new Action<>(actionType, + new TableAndMore(hmsTableInfo, location, fileNames, statisticsUpdate, + hivePartitionUpdate))); + return; + } + switch (oldTableAction.getType()) { + case DROP: + throw new RuntimeException("Not found table: " + nameMapping.getFullLocalName()); + case ADD: + case ALTER: + case INSERT_EXISTING: + case MERGE: + throw new UnsupportedOperationException("Inserting into an unpartitioned table that were added, " + + "altered,or inserted into in the same transaction is not supported"); + case DROP_PRESERVE_DATA: + break; + default: + throw new IllegalStateException("Unknown action type: " + oldTableAction.getType()); + } + } + + private synchronized void createTable( + NameMapping nameMapping, + HmsTableInfo table, String location, List fileNames, + HmsPartitionStatistics statistics, + THivePartitionUpdate hivePartitionUpdate) { + // When creating a table, it should never have partition actions. This is just a sanity check. + checkNoPartitionAction(nameMapping); + Action oldTableAction = tableActions.get(nameMapping); + TableAndMore tableAndMore = new TableAndMore(table, location, fileNames, statistics, hivePartitionUpdate); + if (oldTableAction == null) { + tableActions.put(nameMapping, new Action<>(ActionType.ADD, tableAndMore)); + return; + } + switch (oldTableAction.getType()) { + case DROP: + tableActions.put(nameMapping, new Action<>(ActionType.ALTER, tableAndMore)); + return; + case ADD: + case ALTER: + case INSERT_EXISTING: + case MERGE: + throw new RuntimeException("Table already exists: " + nameMapping.getFullLocalName()); + case DROP_PRESERVE_DATA: + break; + default: + throw new IllegalStateException("Unknown action type: " + oldTableAction.getType()); + } + } + + private synchronized void dropTable(NameMapping nameMapping) { + // Dropping table with partition actions requires cleaning up staging data, which is not implemented yet. + checkNoPartitionAction(nameMapping); + Action oldTableAction = tableActions.get(nameMapping); + if (oldTableAction == null || oldTableAction.getType() == ActionType.ALTER) { + tableActions.put(nameMapping, new Action<>(ActionType.DROP, null)); + return; + } + switch (oldTableAction.getType()) { + case DROP: + throw new RuntimeException("Not found table: " + nameMapping.getFullLocalName()); + case ADD: + case ALTER: + case INSERT_EXISTING: + case MERGE: + throw new RuntimeException("Dropping a table added/modified in the same transaction is not supported"); + case DROP_PRESERVE_DATA: + break; + default: + throw new IllegalStateException("Unknown action type: " + oldTableAction.getType()); + } + } + + private void checkNoPartitionAction(NameMapping nameMapping) { + Map, Action> partitionActionsForTable = partitionActions.get(nameMapping); + if (partitionActionsForTable != null && !partitionActionsForTable.isEmpty()) { + throw new RuntimeException( + "Cannot make schema changes to a table with modified partitions in the same transaction"); + } + } + + private void createAndAddPartition( + NameMapping nameMapping, + HmsTableInfo table, + List partitionValues, + String writePath, + THivePartitionUpdate pu, + HmsPartitionStatistics statistics, + boolean dropFirst) { + String pathForHms = this.fileType == TFileType.FILE_S3 + ? writePath + : pu.getLocation().getTargetPath(); + if (dropFirst) { + dropPartition(nameMapping, partitionValues, true); + } + addPartition(nameMapping, partitionValues, pathForHms, writePath, pu.getName(), pu.getFileNames(), + statistics, pu); + } + + private synchronized void addPartition( + NameMapping nameMapping, + List partitionValues, + String targetPath, + String currentLocation, + String partitionName, + List files, + HmsPartitionStatistics statistics, + THivePartitionUpdate hivePartitionUpdate) { + Map, Action> partitionActionsForTable = + partitionActions.computeIfAbsent(nameMapping, k -> new HashMap<>()); + Action oldPartitionAction = partitionActionsForTable.get(partitionValues); + if (oldPartitionAction == null) { + partitionActionsForTable.put(partitionValues, + new Action<>(ActionType.ADD, + new PartitionAndMore(partitionValues, targetPath, currentLocation, partitionName, files, + statistics, hivePartitionUpdate))); + return; + } + switch (oldPartitionAction.getType()) { + case DROP: + case DROP_PRESERVE_DATA: + partitionActionsForTable.put(partitionValues, + new Action<>(ActionType.ALTER, + new PartitionAndMore(partitionValues, targetPath, currentLocation, partitionName, + files, statistics, hivePartitionUpdate))); + return; + case ADD: + case ALTER: + case INSERT_EXISTING: + case MERGE: + throw new RuntimeException("Partition already exists for table: " + + nameMapping.getFullLocalName() + ", partition values: " + partitionValues); + default: + throw new IllegalStateException("Unknown action type: " + oldPartitionAction.getType()); + } + } + + private synchronized void dropPartition( + NameMapping nameMapping, + List partitionValues, + boolean deleteData) { + Map, Action> partitionActionsForTable = + partitionActions.computeIfAbsent(nameMapping, k -> new HashMap<>()); + Action oldPartitionAction = partitionActionsForTable.get(partitionValues); + if (oldPartitionAction == null) { + if (deleteData) { + partitionActionsForTable.put(partitionValues, new Action<>(ActionType.DROP, null)); + } else { + partitionActionsForTable.put(partitionValues, new Action<>(ActionType.DROP_PRESERVE_DATA, null)); + } + return; + } + switch (oldPartitionAction.getType()) { + case DROP: + case DROP_PRESERVE_DATA: + throw new RuntimeException("Not found partition from partition actions for " + + nameMapping.getFullLocalName() + ", partitions: " + partitionValues); + case ADD: + case ALTER: + case INSERT_EXISTING: + case MERGE: + throw new RuntimeException("Dropping a partition added in the same transaction is not supported: " + + nameMapping.getFullLocalName() + ", partition values: " + partitionValues); + default: + throw new IllegalStateException("Unknown action type: " + oldPartitionAction.getType()); + } + } + + // ─────────────────────────────── filesystem (D6) ─────────────────────────────── + + /** + * Returns the engine-owned {@link FileSystem} for this write, borrowed via + * {@code context.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); + if (engineFs == null) { + throw new DorisConnectorException("No engine FileSystem available for hive write transaction " + + transactionId + " (catalog has no storage properties)"); + } + return engineFs; + } + + // ─────────────────────────────── commit-time object-store MPU (D6) ─────────────────────────────── + + /** + * Completes the BE-initiated multipart uploads on the object store (FE finalizes what BE staged). Ported + * from the legacy {@code objCommit}; only the FileSystem source (plugin-built vs SpiSwitchingFileSystem) + * and the per-task auth wrap (D5) differ. Skipped for a mocked empty overwrite. + */ + private void objCommit(List> asyncFileSystemTaskFutures, + AtomicBoolean fileSystemTaskCancelled, THivePartitionUpdate hivePartitionUpdate, String path) { + if (isMockedPartitionUpdate) { + return; + } + // Narrow the borrowed switching FileSystem to the concrete ObjFileSystem for this object-store write. + // path is the native-scheme write target (s3://, oss://, abfss://, …); catch Exception because + // forLocation can throw StoragePropertiesException (a RuntimeException) on a props-resolution miss. + FileSystem resolved; + try { + resolved = getFileSystem().forLocation(Location.of(path)); + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to resolve object-store filesystem for MPU commit at '" + path + "': " + + e.getMessage(), e); + } + if (!(resolved instanceof ObjFileSystem)) { + throw new RuntimeException("Expected ObjFileSystem for MPU commit at path '" + path + "', got: " + + resolved.getClass().getSimpleName() + ". This path does not point to an object-storage " + + "backend."); + } + ObjFileSystem objFs = (ObjFileSystem) resolved; + for (TS3MPUPendingUpload s3MpuPendingUpload : hivePartitionUpdate.getS3MpuPendingUploads()) { + asyncFileSystemTaskFutures.add(CompletableFuture.runAsync(() -> { + if (fileSystemTaskCancelled.get()) { + return; + } + String remotePath = "s3://" + s3MpuPendingUpload.getBucket() + "/" + s3MpuPendingUpload.getKey(); + try { + context.executeAuthenticated(() -> { + objFs.completeMultipartUpload(remotePath, s3MpuPendingUpload.getUploadId(), + s3MpuPendingUpload.getEtags()); + return null; + }); + } catch (Exception e) { + throw new RuntimeException("Failed to complete MPU for " + remotePath, e); + } + uncompletedMpuPendingUploads.remove(new UncompletedMpuPendingUpload(s3MpuPendingUpload, path)); + }, fileSystemExecutor)); + } + } + + // ─────────────────────────────── filesystem walk helpers (legacy) ─────────────────────────────── + + private void recursiveDeleteItems(Path directory, boolean deleteEmptyDir, boolean reverse) { + DeleteRecursivelyResult deleteResult = recursiveDeleteFiles(directory, deleteEmptyDir, reverse); + if (!deleteResult.getNotDeletedEligibleItems().isEmpty()) { + LOG.warn("Failed to delete directory {}. Some eligible items can't be deleted: {}.", + directory.toString(), deleteResult.getNotDeletedEligibleItems()); + throw new RuntimeException( + "Failed to delete directory for files: " + deleteResult.getNotDeletedEligibleItems()); + } else if (deleteEmptyDir && !deleteResult.dirNotExists()) { + LOG.warn("Failed to delete directory {} due to dir isn't empty", directory.toString()); + throw new RuntimeException("Failed to delete directory for empty dir: " + directory.toString()); + } + } + + private DeleteRecursivelyResult recursiveDeleteFiles(Path directory, boolean deleteEmptyDir, boolean reverse) { + try { + boolean dirExists = getFileSystem().exists(Location.of(directory.toString())); + if (!dirExists) { + return new DeleteRecursivelyResult(true, Collections.emptyList()); + } + } catch (IOException e) { + return new DeleteRecursivelyResult(false, + new ArrayList<>(Collections.singletonList(directory.toString() + "/*"))); + } + return doRecursiveDeleteFiles(directory, deleteEmptyDir, queryId, reverse); + } + + private DeleteRecursivelyResult doRecursiveDeleteFiles(Path directory, boolean deleteEmptyDir, + String queryId, boolean reverse) { + List allFiles; + Set allDirs; + try { + allFiles = getFileSystem().listFilesRecursive(Location.of(directory.toString())); + allDirs = getFileSystem().listDirectories(Location.of(directory.toString())); + } catch (IOException e) { + return new DeleteRecursivelyResult(false, + new ArrayList<>(Collections.singletonList(directory + "/*"))); + } + + boolean allDescendentsDeleted = true; + List notDeletedEligibleItems = new ArrayList<>(); + for (FileEntry file : allFiles) { + String fileName = new Path(file.location().uri()).getName(); + String filePath = file.location().uri(); + if (reverse ^ fileName.startsWith(queryId)) { + if (!deleteIfExists(new Path(filePath))) { + allDescendentsDeleted = false; + notDeletedEligibleItems.add(filePath); + } + } else { + allDescendentsDeleted = false; + } + } + + for (String dir : allDirs) { + DeleteRecursivelyResult subResult = doRecursiveDeleteFiles(new Path(dir), deleteEmptyDir, queryId, reverse); + if (!subResult.dirNotExists()) { + allDescendentsDeleted = false; + } + if (!subResult.getNotDeletedEligibleItems().isEmpty()) { + notDeletedEligibleItems.addAll(subResult.getNotDeletedEligibleItems()); + } + } + + if (allDescendentsDeleted && deleteEmptyDir) { + Verify.verify(notDeletedEligibleItems.isEmpty()); + if (!deleteDirectoryIfExists(directory)) { + return new DeleteRecursivelyResult(false, + new ArrayList<>(Collections.singletonList(directory + "/"))); + } + // all items of the location have been deleted. + return new DeleteRecursivelyResult(true, Collections.emptyList()); + } + return new DeleteRecursivelyResult(false, notDeletedEligibleItems); + } + + private boolean deleteIfExists(Path path) { + deleteFile(path.toString()); + try { + return !getFileSystem().exists(Location.of(path.toString())); + } catch (IOException e) { + return false; + } + } + + private boolean deleteDirectoryIfExists(Path path) { + deleteDir(path.toString()); + try { + return !getFileSystem().exists(Location.of(path.toString())); + } catch (IOException e) { + return false; + } + } + + private void deleteFile(String remotePath) { + try { + getFileSystem().delete(Location.of(remotePath), false); + } catch (IOException e) { + LOG.warn("Failed to delete {}: {}", remotePath, e.getMessage()); + } + } + + private void deleteDir(String remotePath) { + try { + getFileSystem().delete(Location.of(remotePath), true); + } catch (IOException e) { + LOG.warn("Failed to delete directory {}: {}", remotePath, e.getMessage()); + } + } + + private void renameDirectory(String origFilePath, String destFilePath, Runnable runWhenPathNotExist) { + try { + getFileSystem().renameDirectory(Location.of(origFilePath), Location.of(destFilePath), + runWhenPathNotExist); + } catch (IOException e) { + throw new RuntimeException("Failed to rename directory from " + origFilePath + + " to " + destFilePath + ": " + e.getMessage(), e); + } + } + + private void deleteTargetPathContents(String targetPath, String excludedChildPath) { + try { + Set dirs = getFileSystem().listDirectories(Location.of(targetPath)); + for (String dir : dirs) { + if (excludedChildPath != null && HiveWriteUtils.pathsEqual(dir, excludedChildPath)) { + continue; + } + deleteDir(dir); + } + List files = getFileSystem().listFiles(Location.of(targetPath)); + for (FileEntry file : files) { + deleteFile(file.location().uri()); + } + } catch (IOException e) { + throw new RuntimeException("Failed to list/delete contents under " + targetPath, e); + } + } + + private void ensureDirectory(String path) { + try { + getFileSystem().mkdirs(Location.of(path)); + } catch (IOException e) { + throw new RuntimeException("Failed to create directory " + path + ": " + e.getMessage(), e); + } + } + + // ─────────────────────────────── column conversion (GAP-4) ─────────────────────────────── + + private static List toFieldSchemas(List columns) { + List result = new ArrayList<>(columns.size()); + for (ConnectorColumn c : columns) { + result.add(new FieldSchema(c.getName(), HmsTypeMapping.toHiveTypeString(c.getType()), c.getComment())); + } + return result; + } + + // ─────────────────────────────── test seams (package-private) ─────────────────────────────── + + NameMapping getNameMapping() { + return nameMapping; + } + + Map> getTableActions() { + return tableActions; + } + + Map, Action>> getPartitionActions() { + return partitionActions; + } + + // ─────────────────────────────── static helpers ─────────────────────────────── + + private static ThreadFactory namedDaemonThreadFactory(String nameFormat) { + AtomicInteger counter = new AtomicInteger(0); + return runnable -> { + Thread thread = new Thread(runnable); + thread.setName(String.format(nameFormat, counter.getAndIncrement())); + thread.setDaemon(true); + return thread; + }; + } + + private static Object getFutureValue(CompletableFuture future) { + try { + return future.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw new RuntimeException(cause); + } + } + + private static void addSuppressedExceptions(List suppressedExceptions, Throwable t, + List descriptions, String description) { + descriptions.add(description); + // A limit is needed to avoid having a huge exception object. 5 was chosen arbitrarily. + if (suppressedExceptions.size() < 5) { + suppressedExceptions.add(t); + } + } + + private static void shutdownExecutorService(ExecutorService executor) { + // Disable new tasks from being submitted. + executor.shutdown(); + try { + // Wait a while for existing tasks to terminate. + if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { + // Cancel currently executing tasks. + executor.shutdownNow(); + // Wait a while for tasks to respond to being cancelled. + if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { + LOG.warn("Pool did not terminate"); + } + } + } catch (InterruptedException e) { + // (Re-)Cancel if current thread also interrupted. + executor.shutdownNow(); + // Preserve interrupt status. + Thread.currentThread().interrupt(); + } + } + + // ─────────────────────────────── inner: committer ─────────────────────────────── + + class HmsCommitter { + + // update statistics for unPartitioned table or existed partition + private final List updateStatisticsTasks = new ArrayList<>(); + private final ExecutorService updateStatisticsExecutor = Executors.newFixedThreadPool(16); + + // add new partition + private final AddPartitionsTask addPartitionsTask = new AddPartitionsTask(); + + // for file system rename operation: whether to cancel the file system tasks + private final AtomicBoolean fileSystemTaskCancelled = new AtomicBoolean(false); + // file system tasks that are executed asynchronously, including rename_file, rename_dir + private final List> asyncFileSystemTaskFutures = new ArrayList<>(); + // when aborted, we need to delete all files under this path, even the current directory + private final Queue directoryCleanUpTasksForAbort = new ConcurrentLinkedQueue<>(); + // when aborted, we need restore directory + private final List renameDirectoryTasksForAbort = new ArrayList<>(); + // when finished, we need clear some directories + private final List clearDirsForFinish = new ArrayList<>(); + private final List s3cleanWhenSuccess = new ArrayList<>(); + + void cancelUnStartedAsyncFileSystemTask() { + fileSystemTaskCancelled.set(true); + } + + private void undoUpdateStatisticsTasks() { + List> undoUpdateFutures = new ArrayList<>(); + for (UpdateStatisticsTask task : updateStatisticsTasks) { + undoUpdateFutures.add(CompletableFuture.runAsync(() -> { + try { + task.undo(hmsClient); + } catch (Throwable throwable) { + LOG.warn("Failed to rollback: {}", task.getDescription(), throwable); + } + }, updateStatisticsExecutor)); + } + for (CompletableFuture undoUpdateFuture : undoUpdateFutures) { + getFutureValue(undoUpdateFuture); + } + updateStatisticsTasks.clear(); + } + + private void undoAddPartitionsTask() { + if (addPartitionsTask.isEmpty()) { + return; + } + List> rollbackFailedPartitions = addPartitionsTask.rollback(hmsClient); + if (!rollbackFailedPartitions.isEmpty()) { + LOG.warn("Failed to rollback: add_partition for partition values {}", rollbackFailedPartitions); + } + addPartitionsTask.clear(); + } + + private void waitForAsyncFileSystemTaskSuppressThrowable() { + for (CompletableFuture future : asyncFileSystemTaskFutures) { + try { + future.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Throwable t) { + // ignore + } + } + asyncFileSystemTaskFutures.clear(); + } + + void prepareInsertExistingTable(NameMapping nameMapping, TableAndMore tableAndMore) { + HmsTableInfo table = tableAndMore.getTable(); + String targetPath = table.getLocation(); + String writePath = tableAndMore.getCurrentLocation(); + // In the BE, all object stores are unified under the "s3" URI scheme, so a rename is only needed + // when target and write paths differ AFTER ignoring an s3-vs-native scheme difference (GAP-11: + // NOT HiveWriteUtils.pathsEqual, which treats a scheme mismatch as a different filesystem). + boolean needRename = !HiveWriteUtils.equalsIgnoreSchemeIfOneIsS3(targetPath, writePath); + if (needRename) { + FileSystemUtil.asyncRenameFiles(getFileSystem(), authWrappingExecutor, asyncFileSystemTaskFutures, + fileSystemTaskCancelled, writePath, targetPath, tableAndMore.getFileNames()); + } else if (hasPendingUploads(tableAndMore.getHivePartitionUpdate())) { + objCommit(asyncFileSystemTaskFutures, fileSystemTaskCancelled, + tableAndMore.getHivePartitionUpdate(), targetPath); + } + directoryCleanUpTasksForAbort.add(new DirectoryCleanUpTask(targetPath, false)); + updateStatisticsTasks.add(new UpdateStatisticsTask(nameMapping, Optional.empty(), + tableAndMore.getStatisticsUpdate(), true)); + } + + void prepareAlterTable(NameMapping nameMapping, TableAndMore tableAndMore) { + HmsTableInfo table = tableAndMore.getTable(); + String targetPath = table.getLocation(); + String writePath = tableAndMore.getCurrentLocation(); + if (!targetPath.equals(writePath)) { + if (HiveWriteUtils.isSubDirectory(targetPath, writePath)) { + String stagingRoot = HiveWriteUtils.getImmediateChildPath(targetPath, writePath); + deleteTargetPathContents(targetPath, stagingRoot); + ensureDirectory(targetPath); + FileSystemUtil.asyncRenameFiles(getFileSystem(), authWrappingExecutor, asyncFileSystemTaskFutures, + fileSystemTaskCancelled, writePath, targetPath, tableAndMore.getFileNames()); + } else { + Path path = new Path(targetPath); + String oldTablePath = new Path( + path.getParent(), "_temp_" + queryId + "_" + path.getName()).toString(); + renameDirectoryTasksForAbort.add(new RenameDirectoryTask(oldTablePath, targetPath)); + renameDirectory(targetPath, oldTablePath, () -> { }); + clearDirsForFinish.add(oldTablePath); + directoryCleanUpTasksForAbort.add(new DirectoryCleanUpTask(targetPath, true)); + renameDirectory(writePath, targetPath, () -> { }); + } + } else if (hasPendingUploads(tableAndMore.getHivePartitionUpdate())) { + s3cleanWhenSuccess.add(targetPath); + objCommit(asyncFileSystemTaskFutures, fileSystemTaskCancelled, + tableAndMore.getHivePartitionUpdate(), targetPath); + } + updateStatisticsTasks.add(new UpdateStatisticsTask(nameMapping, Optional.empty(), + tableAndMore.getStatisticsUpdate(), false)); + } + + void prepareAddPartition(NameMapping nameMapping, PartitionAndMore partitionAndMore) { + String targetPath = partitionAndMore.getTargetPath(); + String writePath = partitionAndMore.getCurrentLocation(); + if (!targetPath.equals(writePath)) { + directoryCleanUpTasksForAbort.add(new DirectoryCleanUpTask(targetPath, true)); + FileSystemUtil.asyncRenameDir(getFileSystem(), authWrappingExecutor, asyncFileSystemTaskFutures, + fileSystemTaskCancelled, writePath, targetPath, () -> { }); + } else if (hasPendingUploads(partitionAndMore.getHivePartitionUpdate())) { + objCommit(asyncFileSystemTaskFutures, fileSystemTaskCancelled, + partitionAndMore.getHivePartitionUpdate(), targetPath); + } + + // Rebuild the partition storage descriptor from the table at commit time (mirrors the legacy + // "build partition SD from table SD"): the ADD case is the only one that needs columns/formats. + HmsTableInfo table = getTable(nameMapping); + HmsPartitionWithStatistics partitionWithStats = HmsPartitionWithStatistics.builder() + .name(partitionAndMore.getPartitionName()) + .partitionValues(partitionAndMore.getPartitionValues()) + .location(targetPath) + .columns(toFieldSchemas(table.getColumns())) + .inputFormat(table.getInputFormat()) + .outputFormat(table.getOutputFormat()) + .serde(table.getSerializationLib()) + .parameters(new HashMap<>()) + .statistics(partitionAndMore.getStatisticsUpdate()) + .build(); + addPartitionsTask.addPartition(nameMapping, partitionWithStats); + } + + void prepareInsertExistPartition(NameMapping nameMapping, PartitionAndMore partitionAndMore) { + String targetPath = partitionAndMore.getTargetPath(); + String writePath = partitionAndMore.getCurrentLocation(); + directoryCleanUpTasksForAbort.add(new DirectoryCleanUpTask(targetPath, false)); + if (!targetPath.equals(writePath)) { + FileSystemUtil.asyncRenameFiles(getFileSystem(), authWrappingExecutor, asyncFileSystemTaskFutures, + fileSystemTaskCancelled, writePath, targetPath, partitionAndMore.getFileNames()); + } else if (hasPendingUploads(partitionAndMore.getHivePartitionUpdate())) { + objCommit(asyncFileSystemTaskFutures, fileSystemTaskCancelled, + partitionAndMore.getHivePartitionUpdate(), targetPath); + } + updateStatisticsTasks.add(new UpdateStatisticsTask(nameMapping, + Optional.of(partitionAndMore.getPartitionName()), partitionAndMore.getStatisticsUpdate(), true)); + } + + void prepareAlterPartition(NameMapping nameMapping, PartitionAndMore partitionAndMore) { + String targetPath = partitionAndMore.getTargetPath(); + String writePath = partitionAndMore.getCurrentLocation(); + if (!targetPath.equals(writePath)) { + Path path = new Path(targetPath); + String oldPartitionPath = new Path( + path.getParent(), "_temp_" + queryId + "_" + path.getName()).toString(); + renameDirectoryTasksForAbort.add(new RenameDirectoryTask(oldPartitionPath, targetPath)); + renameDirectory(targetPath, oldPartitionPath, () -> { }); + clearDirsForFinish.add(oldPartitionPath); + directoryCleanUpTasksForAbort.add(new DirectoryCleanUpTask(targetPath, true)); + renameDirectory(writePath, targetPath, () -> { }); + } else if (hasPendingUploads(partitionAndMore.getHivePartitionUpdate())) { + s3cleanWhenSuccess.add(targetPath); + objCommit(asyncFileSystemTaskFutures, fileSystemTaskCancelled, + partitionAndMore.getHivePartitionUpdate(), targetPath); + } + updateStatisticsTasks.add(new UpdateStatisticsTask(nameMapping, + Optional.of(partitionAndMore.getPartitionName()), partitionAndMore.getStatisticsUpdate(), false)); + } + + private void runDirectoryClearUpTasksForAbort() { + for (DirectoryCleanUpTask cleanUpTask : directoryCleanUpTasksForAbort) { + recursiveDeleteItems(cleanUpTask.getPath(), cleanUpTask.isDeleteEmptyDir(), false); + } + directoryCleanUpTasksForAbort.clear(); + } + + private void runRenameDirTasksForAbort() { + for (RenameDirectoryTask task : renameDirectoryTasksForAbort) { + try { + boolean srcExists = getFileSystem().exists(Location.of(task.getRenameFrom())); + if (srcExists) { + renameDirectory(task.getRenameFrom(), task.getRenameTo(), () -> { }); + } + } catch (IOException e) { + LOG.warn("Failed to abort rename dir from {} to {}: {}", + task.getRenameFrom(), task.getRenameTo(), e.getMessage()); + } + } + renameDirectoryTasksForAbort.clear(); + } + + void runClearPathsForFinish() { + for (String path : clearDirsForFinish) { + deleteDir(path); + } + } + + private void runS3cleanWhenSuccess() { + for (String path : s3cleanWhenSuccess) { + recursiveDeleteItems(new Path(path), false, true); + } + } + + private void waitForAsyncFileSystemTasks() { + for (CompletableFuture future : asyncFileSystemTaskFutures) { + getFutureValue(future); + } + } + + private void doAddPartitionsTask() { + // No committer-side batching: ThriftHmsClient.addPartitions batches internally (GAP-7), so the + // whole list is added in one call. + if (!addPartitionsTask.isEmpty()) { + addPartitionsTask.run(hmsClient); + } + } + + private void doUpdateStatisticsTasks() { + List> updateStatsFutures = new ArrayList<>(); + List failedTaskDescriptions = new ArrayList<>(); + List suppressedExceptions = new ArrayList<>(); + for (UpdateStatisticsTask task : updateStatisticsTasks) { + updateStatsFutures.add(CompletableFuture.runAsync(() -> { + try { + task.run(hmsClient); + } catch (Throwable t) { + synchronized (suppressedExceptions) { + addSuppressedExceptions(suppressedExceptions, t, failedTaskDescriptions, + task.getDescription()); + } + } + }, updateStatisticsExecutor)); + } + for (CompletableFuture executeUpdateFuture : updateStatsFutures) { + getFutureValue(executeUpdateFuture); + } + if (!suppressedExceptions.isEmpty()) { + StringBuilder message = new StringBuilder(); + message.append("Failed to execute some updating statistics tasks: "); + message.append(String.join("; ", failedTaskDescriptions)); + RuntimeException exception = new RuntimeException(message.toString()); + suppressedExceptions.forEach(exception::addSuppressed); + throw exception; + } + } + + private void pruneAndDeleteStagingDirectories() { + stagingDirectory.ifPresent((v) -> recursiveDeleteItems(new Path(v), true, false)); + } + + private void abortMultiUploads() { + if (uncompletedMpuPendingUploads.isEmpty()) { + return; + } + for (UncompletedMpuPendingUpload uncompletedMpuPendingUpload : uncompletedMpuPendingUploads) { + TS3MPUPendingUpload mpu = uncompletedMpuPendingUpload.s3MPUPendingUpload; + String remotePath = "s3://" + mpu.getBucket() + "/" + mpu.getKey(); + // Resolve the concrete ObjFileSystem from the upload's native-scheme write path (NOT the + // BE-unified s3:// remotePath, which an Azure-typed catalog cannot resolve). Lenient: any + // resolution failure (incl. StoragePropertiesException, a RuntimeException) warns and skips. + FileSystem resolved; + try { + resolved = getFileSystem().forLocation(Location.of(uncompletedMpuPendingUpload.path)); + } catch (Exception e) { + LOG.warn("Failed to resolve filesystem for MPU abort {}: {}", remotePath, e.getMessage()); + continue; + } + if (!(resolved instanceof ObjFileSystem)) { + LOG.warn("FileSystem {} is not object-storage, skipping MPU abort for {}", + resolved.getClass().getSimpleName(), uncompletedMpuPendingUpload.path); + continue; + } + ObjFileSystem objFs = (ObjFileSystem) resolved; + asyncFileSystemTaskFutures.add(CompletableFuture.runAsync(() -> { + try { + context.executeAuthenticated(() -> { + objFs.getObjStorage().abortMultipartUpload(remotePath, mpu.getUploadId()); + return null; + }); + } catch (Exception e) { + LOG.warn("Failed to abort MPU for {}: {}", remotePath, e.getMessage()); + } + }, fileSystemExecutor)); + } + uncompletedMpuPendingUploads.clear(); + } + + void doCommit() { + waitForAsyncFileSystemTasks(); + runS3cleanWhenSuccess(); + doAddPartitionsTask(); + doUpdateStatisticsTasks(); + // delete write path + pruneAndDeleteStagingDirectories(); + } + + void abort() { + cancelUnStartedAsyncFileSystemTask(); + undoUpdateStatisticsTasks(); + undoAddPartitionsTask(); + waitForAsyncFileSystemTaskSuppressThrowable(); + runDirectoryClearUpTasksForAbort(); + runRenameDirTasksForAbort(); + } + + void rollback() { + // delete write path + pruneAndDeleteStagingDirectories(); + // abort the in-progress multipart uploads + abortMultiUploads(); + for (CompletableFuture future : asyncFileSystemTaskFutures) { + getFutureValue(future); + } + asyncFileSystemTaskFutures.clear(); + } + + void shutdownExecutorService() { + HiveConnectorTransaction.shutdownExecutorService(updateStatisticsExecutor); + } + + private boolean hasPendingUploads(THivePartitionUpdate pu) { + List uploads = pu.getS3MpuPendingUploads(); + return uploads != null && !uploads.isEmpty(); + } + } + + // ─────────────────────────────── inner: tasks ─────────────────────────────── + + private static class UpdateStatisticsTask { + private final NameMapping nameMapping; + private final Optional partitionName; + private final HmsPartitionStatistics updatePartitionStat; + private final boolean merge; + private boolean done; + + UpdateStatisticsTask(NameMapping nameMapping, Optional partitionName, + HmsPartitionStatistics statistics, boolean merge) { + this.nameMapping = Objects.requireNonNull(nameMapping, "nameMapping is null"); + this.partitionName = Objects.requireNonNull(partitionName, "partitionName is null"); + this.updatePartitionStat = Objects.requireNonNull(statistics, "statistics is null"); + this.merge = merge; + } + + void run(HmsClient client) { + if (partitionName.isPresent()) { + client.updatePartitionStatistics(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), + partitionName.get(), this::updateStatistics); + } else { + client.updateTableStatistics(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), + this::updateStatistics); + } + done = true; + } + + void undo(HmsClient client) { + if (!done) { + return; + } + if (partitionName.isPresent()) { + client.updatePartitionStatistics(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), + partitionName.get(), this::resetStatistics); + } else { + client.updateTableStatistics(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), + this::resetStatistics); + } + } + + String getDescription() { + if (partitionName.isPresent()) { + return "alter partition parameters " + nameMapping.getFullLocalName() + " " + partitionName.get(); + } else { + return "alter table parameters " + nameMapping.getFullRemoteName(); + } + } + + private HmsPartitionStatistics updateStatistics(HmsPartitionStatistics currentStats) { + return merge ? HmsPartitionStatistics.merge(currentStats, updatePartitionStat) : updatePartitionStat; + } + + private HmsPartitionStatistics resetStatistics(HmsPartitionStatistics currentStatistics) { + // GAP-2: ReduceOperator lives on HmsCommonStatistics. + return HmsPartitionStatistics.reduce(currentStatistics, updatePartitionStat, + HmsCommonStatistics.ReduceOperator.SUBTRACT); + } + } + + private static class AddPartitionsTask { + private final List partitions = new ArrayList<>(); + private final List> createdPartitionValues = new ArrayList<>(); + private NameMapping nameMapping; + + boolean isEmpty() { + return partitions.isEmpty(); + } + + void clear() { + partitions.clear(); + createdPartitionValues.clear(); + } + + void addPartition(NameMapping nameMapping, HmsPartitionWithStatistics partition) { + this.nameMapping = nameMapping; + partitions.add(partition); + } + + void run(HmsClient client) { + client.addPartitions(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), partitions); + for (HmsPartitionWithStatistics partition : partitions) { + createdPartitionValues.add(partition.getPartitionValues()); + } + } + + List> rollback(HmsClient client) { + List> rollbackFailedPartitions = new ArrayList<>(); + for (List createdPartitionValue : createdPartitionValues) { + try { + client.dropPartition(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), + createdPartitionValue, false); + } catch (Throwable t) { + LOG.warn("Failed to drop partition on {} when rollback: {}", + nameMapping.getFullLocalName(), createdPartitionValue); + rollbackFailedPartitions.add(createdPartitionValue); + } + } + return rollbackFailedPartitions; + } + } + + private static class DirectoryCleanUpTask { + private final Path path; + private final boolean deleteEmptyDir; + + DirectoryCleanUpTask(String path, boolean deleteEmptyDir) { + this.path = new Path(path); + this.deleteEmptyDir = deleteEmptyDir; + } + + Path getPath() { + return path; + } + + boolean isDeleteEmptyDir() { + return deleteEmptyDir; + } + } + + private static class DeleteRecursivelyResult { + private final boolean dirNoLongerExists; + private final List notDeletedEligibleItems; + + DeleteRecursivelyResult(boolean dirNoLongerExists, List notDeletedEligibleItems) { + this.dirNoLongerExists = dirNoLongerExists; + this.notDeletedEligibleItems = notDeletedEligibleItems; + } + + boolean dirNotExists() { + return dirNoLongerExists; + } + + List getNotDeletedEligibleItems() { + return notDeletedEligibleItems; + } + } + + private static class RenameDirectoryTask { + private final String renameFrom; + private final String renameTo; + + RenameDirectoryTask(String renameFrom, String renameTo) { + this.renameFrom = renameFrom; + this.renameTo = renameTo; + } + + String getRenameFrom() { + return renameFrom; + } + + String getRenameTo() { + return renameTo; + } + } + + private static class UncompletedMpuPendingUpload { + private final TS3MPUPendingUpload s3MPUPendingUpload; + private final String path; + + UncompletedMpuPendingUpload(TS3MPUPendingUpload s3MPUPendingUpload, String path) { + this.s3MPUPendingUpload = s3MPUPendingUpload; + this.path = path; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + UncompletedMpuPendingUpload that = (UncompletedMpuPendingUpload) o; + return Objects.equals(s3MPUPendingUpload, that.s3MPUPendingUpload) && Objects.equals(path, that.path); + } + + @Override + public int hashCode() { + return Objects.hash(s3MPUPendingUpload, path); + } + } + + // ─────────────────────────────── inner: action model ─────────────────────────────── + + enum ActionType { + // drop a table/partition + DROP, + // drop a table/partition but will preserve data + DROP_PRESERVE_DATA, + // add a table/partition + ADD, + // drop then add a table/partition, like overwrite + ALTER, + // insert into an existing table/partition + INSERT_EXISTING, + // merge into an existing table/partition + MERGE + } + + static class Action { + private final ActionType type; + private final T data; + + Action(ActionType type, T data) { + this.type = Objects.requireNonNull(type, "type is null"); + if (type == ActionType.DROP || type == ActionType.DROP_PRESERVE_DATA) { + Preconditions.checkArgument(data == null, "data is not null"); + } else { + Objects.requireNonNull(data, "data is null"); + } + this.data = data; + } + + ActionType getType() { + return type; + } + + T getData() { + Preconditions.checkState(type != ActionType.DROP); + return data; + } + } + + private static class TableAndMore { + private final HmsTableInfo table; + private final String currentLocation; + private final List fileNames; + private final HmsPartitionStatistics statisticsUpdate; + private final THivePartitionUpdate hivePartitionUpdate; + + TableAndMore(HmsTableInfo table, String currentLocation, List fileNames, + HmsPartitionStatistics statisticsUpdate, THivePartitionUpdate hivePartitionUpdate) { + this.table = Objects.requireNonNull(table, "table is null"); + this.currentLocation = Objects.requireNonNull(currentLocation, "currentLocation is null"); + this.fileNames = Objects.requireNonNull(fileNames, "fileNames is null"); + this.statisticsUpdate = Objects.requireNonNull(statisticsUpdate, "statisticsUpdate is null"); + this.hivePartitionUpdate = Objects.requireNonNull(hivePartitionUpdate, "hivePartitionUpdate is null"); + } + + HmsTableInfo getTable() { + return table; + } + + String getCurrentLocation() { + return currentLocation; + } + + List getFileNames() { + return fileNames; + } + + HmsPartitionStatistics getStatisticsUpdate() { + return statisticsUpdate; + } + + THivePartitionUpdate getHivePartitionUpdate() { + return hivePartitionUpdate; + } + } + + private static class PartitionAndMore { + private final List partitionValues; + private final String targetPath; + private final String currentLocation; + private final String partitionName; + private final List fileNames; + private final HmsPartitionStatistics statisticsUpdate; + private final THivePartitionUpdate hivePartitionUpdate; + + PartitionAndMore(List partitionValues, String targetPath, String currentLocation, + String partitionName, List fileNames, HmsPartitionStatistics statisticsUpdate, + THivePartitionUpdate hivePartitionUpdate) { + this.partitionValues = Objects.requireNonNull(partitionValues, "partitionValues is null"); + this.targetPath = Objects.requireNonNull(targetPath, "targetPath is null"); + this.currentLocation = Objects.requireNonNull(currentLocation, "currentLocation is null"); + this.partitionName = Objects.requireNonNull(partitionName, "partitionName is null"); + this.fileNames = Objects.requireNonNull(fileNames, "fileNames is null"); + this.statisticsUpdate = Objects.requireNonNull(statisticsUpdate, "statisticsUpdate is null"); + this.hivePartitionUpdate = Objects.requireNonNull(hivePartitionUpdate, "hivePartitionUpdate is null"); + } + + List getPartitionValues() { + return partitionValues; + } + + String getTargetPath() { + return targetPath; + } + + String getCurrentLocation() { + return currentLocation; + } + + String getPartitionName() { + return partitionName; + } + + List getFileNames() { + return fileNames; + } + + HmsPartitionStatistics getStatisticsUpdate() { + return statisticsUpdate; + } + + THivePartitionUpdate getHivePartitionUpdate() { + return hivePartitionUpdate; + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveDirectoryListingException.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveDirectoryListingException.java new file mode 100644 index 00000000000000..158e11ecd8e284 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveDirectoryListingException.java @@ -0,0 +1,41 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.DorisConnectorException; + +/** + * Thrown when listing ONE partition directory fails ({@code FileSystem.listStatus} raised an + * {@code IOException}: a missing / unreadable / transiently-failing directory). This is a local + * failure that the scan path tolerates by skipping that partition with a warning — the pre-cache resilience + * (legacy {@code HiveScanPlanProvider.listAndSplitFiles} caught the {@code listStatus} {@code IOException} + * and skipped the partition). + * + *

It is deliberately a distinct subtype of {@link DorisConnectorException} so the scan path can catch + * only this (skip) while letting a plain {@link DorisConnectorException} — used for a systemic + * filesystem-resolution failure ({@code FileSystem.get}: unknown scheme, bad credentials/endpoint, which + * affects every partition of the table) — propagate and fail the query loud, exactly as legacy did before the + * listing cache folded the two failure modes together. Never cached (the cache loader throws, and + * {@code MetaCacheEntry} never caches a failed load).

+ */ +public class HiveDirectoryListingException extends DorisConnectorException { + + public HiveDirectoryListingException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveFileFormat.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveFileFormat.java index 034f0d80429b15..dbdf56550b85e0 100644 --- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveFileFormat.java +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveFileFormat.java @@ -17,16 +17,59 @@ package org.apache.doris.connector.hive; +import org.apache.doris.connector.api.DorisConnectorException; + +import java.util.Locale; + /** - * Maps Hive InputFormat class names to file format strings understood by BE. + * Resolves a Hive table's read file format, a faithful port of legacy + * {@code HMSExternalTable.getFileFormatType} (+ {@code HiveMetaStoreClientHelper.HiveFileFormat.getFormat}). + * + *

The algorithm is TWO-STAGE and serde-authoritative for the text family: the {@code inputFormat} + * class name picks the coarse family (parquet / orc / text) by substring, and for the text family the + * SerDe picks the fine format. This is why detection must NOT be input-format-first: a standard Hive + * JSON table has {@code inputFormat=org.apache.hadoop.mapred.TextInputFormat} — its JSON-ness lives ONLY in + * the JsonSerDe, so an input-format-first classifier would mis-read it as text/CSV.

+ * + *

The five values map 1:1 to the BE {@code TFileFormatType} the generic + * {@code PluginDrivenScanNode.mapFileFormatType} resolves the emitted {@link #getFormatName() token} to: + * {@code parquet}->FORMAT_PARQUET, {@code orc}->FORMAT_ORC, {@code text}->FORMAT_TEXT, {@code csv}-> + * FORMAT_CSV_PLAIN, {@code json}->FORMAT_JSON. {@code FORMAT_TEXT} (hive {@code LazySimpleSerDe} / + * {@code MultiDelimitSerDe}) is a DISTINCT BE reader from {@code FORMAT_CSV_PLAIN} (hive {@code OpenCSVSerde}): + * the text reader honors hive collection/map delimiters, {@code \\N} nulls and hive escaping, so the two must + * not be collapsed.

*/ public enum HiveFileFormat { PARQUET("parquet"), ORC("orc"), + // Hive text family (LazySimpleSerDe / MultiDelimitSerDe) -> BE FORMAT_TEXT. TEXT("text"), - JSON("json"), - UNKNOWN("unknown"); + // Hive OpenCSVSerde (and OpenX-JSON read in one column) -> BE FORMAT_CSV_PLAIN. + CSV("csv"), + // Hive JSON serdes -> BE FORMAT_JSON. + JSON("json"); + + // Coarse inputFormat families, mirroring legacy HiveMetaStoreClientHelper.HiveFileFormat descs. Detection + // is a lowercase SUBSTRING match in THIS order (text first), first match wins; the LZO text input formats + // (com.hadoop...LzoTextInputFormat / DeprecatedLzoTextInputFormat) contain "text" and so land on text. + private static final String FAMILY_TEXT = "text"; + private static final String FAMILY_PARQUET = "parquet"; + private static final String FAMILY_ORC = "orc"; + + // SerDe class names, mirroring the legacy constants in HiveMetaStoreClientHelper (the connector cannot + // import fe-core). MultiDelimitSerDe is matched under BOTH its modern (serde2, the legacy Doris constant) + // and historical (contrib.serde2) package names — both are valid Hive classes and both read as FORMAT_TEXT; + // recognizing both is a small, safe superset of legacy's serde2-only match (it only makes more tables + // readable, never breaks one). + private static final String LAZY_SIMPLE_SERDE = "org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe"; + private static final String MULTI_DELIMIT_SERDE = "org.apache.hadoop.hive.serde2.MultiDelimitSerDe"; + private static final String CONTRIB_MULTI_DELIMIT_SERDE = + "org.apache.hadoop.hive.contrib.serde2.MultiDelimitSerDe"; + private static final String OPEN_CSV_SERDE = "org.apache.hadoop.hive.serde2.OpenCSVSerde"; + private static final String HIVE_JSON_SERDE = "org.apache.hive.hcatalog.data.JsonSerDe"; + private static final String LEGACY_HIVE_JSON_SERDE = "org.apache.hadoop.hive.serde2.JsonSerDe"; + private static final String OPENX_JSON_SERDE = "org.openx.data.jsonserde.JsonSerDe"; private final String formatName; @@ -34,79 +77,97 @@ public enum HiveFileFormat { this.formatName = formatName; } + /** The neutral format token emitted to fe-core (mapped to a BE TFileFormatType there). */ public String getFormatName() { return formatName; } /** - * Detects the file format from the Hive inputFormat class name. + * Resolves the read format of a hive table, reproducing legacy {@code HMSExternalTable.getFileFormatType}. + * The {@code inputFormat} chooses parquet / orc / text-file; for the text-file family the {@code serDeLib} + * chooses json / csv / hive-text. The two extra flags reproduce the legacy OpenX-JSON special case: with + * {@code readHiveJsonInOneColumn} an OpenX-JSON table is read as a single CSV column (only when its first + * column is a string). Fails loud on an unrecognized inputFormat or serde, matching legacy (which threw + * rather than silently degrading). + * + * @param inputFormat the HMS {@code StorageDescriptor.inputFormat} class name + * @param serDeLib the HMS {@code SerDeInfo.serializationLib} class name + * @param readHiveJsonInOneColumn the session {@code read_hive_json_in_one_column} flag + * @param firstColumnIsString whether the table's first column is a {@code STRING} (OpenX one-column gate) + * @throws DorisConnectorException if the inputFormat/serde is unsupported, or the OpenX one-column mode is + * requested on a table whose first column is not a string */ - public static HiveFileFormat fromInputFormat(String inputFormat) { - if (inputFormat == null) { - return UNKNOWN; - } - String lower = inputFormat.toLowerCase(); - if (lower.contains("parquet")) { - return PARQUET; - } - if (lower.contains("orc")) { - return ORC; - } - if (lower.contains("text") || lower.contains("lazySimple") - || lower.contains("lazysimple")) { - return TEXT; - } - if (lower.contains("json")) { - return JSON; + public static HiveFileFormat detect(String inputFormat, String serDeLib, + boolean readHiveJsonInOneColumn, boolean firstColumnIsString) { + switch (detectFamily(inputFormat)) { + case FAMILY_PARQUET: + return PARQUET; + case FAMILY_ORC: + return ORC; + default: + // text family: the serde decides json / csv / hive-text. + return detectTextFamily(serDeLib, readHiveJsonInOneColumn, firstColumnIsString); } - // Many Hive tables use TextInputFormat as the default - if (lower.contains("textinputformat")) { - return TEXT; - } - return UNKNOWN; } /** - * Detects the file format from the SerDe library class name. + * Maps an inputFormat class name to its coarse family token ({@link #FAMILY_TEXT}/{@link #FAMILY_PARQUET}/ + * {@link #FAMILY_ORC}) by lowercase substring, first match wins in text->parquet->orc order (legacy + * {@code HiveMetaStoreClientHelper.HiveFileFormat.getFormat}). Throws on an unrecognized inputFormat, + * matching legacy's {@code "Not supported Hive file format"}. */ - public static HiveFileFormat fromSerDeLib(String serDeLib) { - if (serDeLib == null) { - return UNKNOWN; - } - if (serDeLib.contains("ParquetHiveSerDe") || serDeLib.contains("parquet")) { - return PARQUET; + private static String detectFamily(String inputFormat) { + if (inputFormat == null) { + throw new DorisConnectorException("Not supported Hive file format: null inputFormat"); } - if (serDeLib.contains("OrcSerde") || serDeLib.contains("orc")) { - return ORC; + String lower = inputFormat.toLowerCase(Locale.ROOT); + if (lower.contains(FAMILY_TEXT)) { + return FAMILY_TEXT; } - if (serDeLib.contains("LazySimpleSerDe") || serDeLib.contains("OpenCSVSerde") - || serDeLib.contains("MultiDelimitSerDe")) { - return TEXT; + if (lower.contains(FAMILY_PARQUET)) { + return FAMILY_PARQUET; } - if (serDeLib.contains("JsonSerDe") || serDeLib.contains("json")) { - return JSON; + if (lower.contains(FAMILY_ORC)) { + return FAMILY_ORC; } - return UNKNOWN; + throw new DorisConnectorException("Not supported Hive file format: " + inputFormat); } /** - * Determines the file format using both inputFormat and serDeLib, - * preferring inputFormat when available. + * Chooses the fine text-family format from the serde, reproducing the legacy serde switch verbatim, + * including the OpenX-JSON {@code read_hive_json_in_one_column} branch and the unsupported-serde throw. */ - public static HiveFileFormat detect(String inputFormat, String serDeLib) { - HiveFileFormat fromInput = fromInputFormat(inputFormat); - if (fromInput != UNKNOWN) { - return fromInput; + private static HiveFileFormat detectTextFamily(String serDeLib, + boolean readHiveJsonInOneColumn, boolean firstColumnIsString) { + if (HIVE_JSON_SERDE.equals(serDeLib) || LEGACY_HIVE_JSON_SERDE.equals(serDeLib)) { + return JSON; + } + if (OPENX_JSON_SERDE.equals(serDeLib)) { + if (!readHiveJsonInOneColumn) { + return JSON; + } + if (firstColumnIsString) { + return CSV; + } + throw new DorisConnectorException("read_hive_json_in_one_column = true, but the first column of " + + "the hive table is not a string column."); + } + if (LAZY_SIMPLE_SERDE.equals(serDeLib) + || MULTI_DELIMIT_SERDE.equals(serDeLib) + || CONTRIB_MULTI_DELIMIT_SERDE.equals(serDeLib)) { + return TEXT; + } + if (OPEN_CSV_SERDE.equals(serDeLib)) { + return CSV; } - return fromSerDeLib(serDeLib); + throw new DorisConnectorException("Unsupported hive table serde: " + serDeLib); } /** - * Returns true if this format supports file splitting at byte boundaries. - * Parquet and ORC have internal block structures that support splitting. - * Text files can be split at line boundaries. + * Whether this format supports splitting a file at byte boundaries. Parquet/ORC have internal block + * structure; text/csv are line-splittable. JSON is read whole (not split), matching the legacy behavior. */ public boolean isSplittable() { - return this == PARQUET || this == ORC || this == TEXT; + return this == PARQUET || this == ORC || this == TEXT || this == CSV; } } 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 new file mode 100644 index 00000000000000..7149b0cf99b806 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveFileListingCache.java @@ -0,0 +1,386 @@ +// 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.DorisConnectorException; +import org.apache.doris.connector.cache.CacheSpec; +import org.apache.doris.connector.cache.MetaCacheEntry; +import org.apache.doris.filesystem.FileEntry; +import org.apache.doris.filesystem.FileIterator; +import org.apache.doris.filesystem.FileSystem; +import org.apache.doris.filesystem.Location; + +import org.apache.hadoop.fs.UnsupportedFileSystemException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ForkJoinPool; + +/** + * The hive connector's own directory-listing cache — the second, separate cache layer of the D2 scan-side cache + * (the metastore-metadata layer is {@link org.apache.doris.connector.hms.CachingHmsClient}). It memoizes the + * (expensive) {@code FileSystem.listStatus} of each partition directory, keyed by {@code (db, table, location)}. + * + *

Why this exists. Legacy fe-core kept directory listings in the engine-side + * {@code HiveExternalMetaCache}'s {@code file} entry; once a hive catalog becomes plugin-driven that cache stops + * routing to it, so without this connector-owned cache every scan (and every periodic row-count refresh) would + * re-list every partition directory from the filesystem. Trino keeps the equivalent {@code CachingDirectoryLister} + * as a layer separate from its metastore cache — D2 mirrors that split.

+ * + *

Who reads it. {@link HiveScanPlanProvider} (the scan hot path) and + * {@link HiveConnectorMetadata#estimateDataSizeByListingFiles} (the row-count estimate) — both go through the SAME + * instance, held as a {@code final} field on the per-catalog {@link HiveConnector} (the only object that outlives a + * single query; the scan provider / metadata are rebuilt per call). A scan therefore warms the estimate and vice + * versa.

+ * + *

TCCL. The entry is contextual-only + manual-miss + no auto-refresh, so the loader runs synchronously on + * the CALLING thread — which is already pinned to the plugin classloader (the scan thread by + * {@code PluginDrivenScanNode.onPluginClassLoader}; the stats thread by {@code estimateDataSizeByListingFiles} + * itself). A background refresh thread would NOT inherit that pin and Hadoop's {@code FileSystem} reflection would + * fail to resolve its impl.

+ * + *

Failures are not cached, and are split by blast radius. The loader never caches a failed load (matching + * {@code MetaCacheEntry}'s null-is-a-miss / exception-propagates contract). A SYSTEMIC filesystem-resolution failure + * ({@link FileSystem#forLocation} unresolvable scheme/storage, or a lazily-surfaced {@code "No FileSystem for + * scheme"} — it fails for every partition of the table) is thrown as a plain {@link DorisConnectorException} and the + * scan path lets it propagate to fail the query loud. A LOCAL per-directory failure ({@link FileSystem#list}: this + * partition missing/unreadable) is thrown as {@link HiveDirectoryListingException} and the scan path skips just that + * partition with a warning (the pre-cache tolerance). The estimate path degrades to {@code -1} on either. This keeps + * a broken storage config from silently returning an empty scan (legacy failed {@code FileSystem.get} loud) while + * preserving one-bad-partition resilience.

+ * + *

Dormant. {@code "hms"} is not in {@code SPI_READY_TYPES}, so no live catalog builds a + * {@link HiveConnector}, so this cache is never instantiated for a live catalog until the flip. Byte-neutral for + * every other connector.

+ */ +public class HiveFileListingCache { + + /** Engine token for the {@code meta.cache...*} property namespace. */ + static final String ENGINE = "hive"; + /** {@code meta.cache.hive.file.*} — cached directory listings. */ + static final String ENTRY_FILE = "file"; + + /** + * Legacy fe-core catalog knob ({@code HMSExternalCatalog.FILE_META_CACHE_TTL_SECOND}) remapped onto this + * cache's namespaced {@code meta.cache.hive.file.ttl-second} for backward compatibility. See the constructor. + */ + static final String LEGACY_FILE_META_CACHE_TTL_SECOND = "file.meta.cache.ttl-second"; + + // Legacy fe-core Config values, mirrored locally (the connector never touches fe-core Config): + // TTL = Config.external_cache_expire_time_seconds_after_access (86400s = 24h) + // capacity = Config.max_external_file_cache_num (10000) + static final long DEFAULT_TTL_SECOND = 86400L; + static final long DEFAULT_FILE_CAPACITY = 10000L; + + /** + * Catalog property controlling whether partition directories are listed recursively (descend into + * sub-directories). Default {@code true} — legacy {@code HiveExternalMetaCache.getFileCache} defaulted the + * same. When {@code false}, a table whose data lives in sub-directories silently loses those rows. + */ + static final String RECURSIVE_DIRECTORIES_PROPERTY = "hive.recursive_directories"; + + /** + * 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} + * (engine-owned, per-catalog, must NOT be closed by the connector). + */ + @FunctionalInterface + interface DirectoryLister { + List list(String location, FileSystem fs); + } + + private final MetaCacheEntry> cache; + private final DirectoryLister lister; + + public HiveFileListingCache(Map properties) { + this(properties, defaultLister(properties)); + } + + /** + * The production {@link DirectoryLister}: {@link #listFromFileSystem} with the catalog's + * {@code hive.recursive_directories} flag (default {@code true}) baked in. The flag is a per-catalog + * constant, so capturing it here makes every consumer of the shared cache (scan, size estimate, stats + * sampling) recurse consistently without a hot-path signature change. + */ + private static DirectoryLister defaultLister(Map properties) { + Map props = properties == null ? Collections.emptyMap() : properties; + boolean recursive = Boolean.parseBoolean( + props.getOrDefault(RECURSIVE_DIRECTORIES_PROPERTY, "true")); + return (location, fs) -> listFromFileSystem(location, fs, recursive); + } + + HiveFileListingCache(Map properties, DirectoryLister lister) { + Map props = properties == null ? Collections.emptyMap() : properties; + // Translate the legacy fe-core catalog knob file.meta.cache.ttl-second into the namespaced key this cache + // reads (mirrors HiveExternalMetaCache.catalogPropertyCompatibilityMap: FILE_META_CACHE_TTL_SECOND -> + // ENTRY_FILE ttl). Without this, an "hms" catalog that set the legacy key silently kept the default 24h + // file cache after the SPI cutover, so e.g. file.meta.cache.ttl-second=0 no longer disabled the listing + // cache and a newly-written file in an already-listed partition stayed invisible until REFRESH. + props = CacheSpec.applyCompatibilityMap(props, + Collections.singletonMap(LEGACY_FILE_META_CACHE_TTL_SECOND, + CacheSpec.metaCacheTtlKey(ENGINE, ENTRY_FILE))); + CacheSpec spec = CacheSpec.fromProperties(props, ENGINE, ENTRY_FILE, + CacheSpec.of(true, DEFAULT_TTL_SECOND, DEFAULT_FILE_CAPACITY)); + // Contextual-only + manual-miss so the slow listStatus runs on the caller (TCCL-pinned) thread outside + // Caffeine's sync compute lock, deduplicated by a striped lock — mirrors CachingHmsClient's entries. + this.cache = new MetaCacheEntry<>("hive.file", null, spec, ForkJoinPool.commonPool(), false, true, 0L, true); + this.lister = Objects.requireNonNull(lister, "lister can not be null"); + } + + /** + * Lists the data files under {@code location} (recursively into non-hidden sub-directories when the catalog's + * {@code hive.recursive_directories} is set, default {@code true}; directories and {@code _}/{@code .} + * -prefixed hidden files removed), served from the cache. Keyed by {@code (db, table, location)} so + * {@link #invalidateTable} can drop exactly one table's entries. The loader runs on the calling thread; an I/O + * failure throws {@link DorisConnectorException} (and is NOT cached). The returned list is shared by reference + * (immutable elements) — callers must treat it as read-only, the codebase-wide metadata-cache convention. + */ + public List listDataFiles(String dbName, String tableName, String location, FileSystem fs) { + return listDataFiles(dbName, tableName, location, Collections.emptyList(), fs); + } + + /** + * As {@link #listDataFiles(String, String, String, FileSystem)}, but tags the entry with the partition's + * VALUES so {@link #invalidatePartitions} can drop exactly that partition — mirroring legacy + * {@code HiveExternalMetaCache}'s per-partition file-cache invalidation keyed by {@code tableId + + * partitionValues}. The values are part of the cache key, so the scan and size-estimate paths must pass the + * SAME values for a given partition (both derive them from {@code HmsPartitionInfo.getValues()}); an + * unpartitioned table passes an empty list. + */ + public List listDataFiles(String dbName, String tableName, String location, + List partitionValues, FileSystem fs) { + return cache.get(new FileListingKey(dbName, tableName, location, partitionValues), + key -> lister.list(key.location, fs)); + } + + /** Drops every cached listing for one table. Backs {@code REFRESH TABLE}. */ + public void invalidateTable(String dbName, String tableName) { + cache.invalidateIf(key -> key.matches(dbName, tableName)); + } + + /** + * Drops the cached listings for exactly the given partitions of one table, matched by partition VALUES. + * Mirrors legacy {@code HiveExternalMetaCache}'s per-partition file-cache invalidation (its {@code tableId + + * partitionValues} predicate). The match is on values carried in the key — which the caller derives purely + * from the partition NAME ({@code HiveWriteUtils.toPartitionValues}) — so it needs NO partition-metadata + * lookup; that is precisely why an evicted partition-metadata entry can no longer leave a stale file listing + * (the #65334 failure mode). Backs a partition add/drop/alter refresh. + */ + public void invalidatePartitions(String dbName, String tableName, Set> partitionValues) { + if (partitionValues.isEmpty()) { + return; + } + cache.invalidateIf(key -> key.matches(dbName, tableName) + && partitionValues.contains(key.partitionValues)); + } + + /** Drops every cached listing for one database (all its tables). Backs {@code REFRESH DATABASE}. */ + public void invalidateDb(String dbName) { + cache.invalidateIf(key -> key.matchesDb(dbName)); + } + + /** Drops the whole file-listing cache. Backs {@code REFRESH CATALOG}. */ + public void invalidateAll() { + cache.invalidateAll(); + } + + /** Current number of cached directory listings — for unit tests only (mirrors iceberg manifestCache.size()). */ + long size() { + long[] count = {0L}; + cache.forEach((key, value) -> count[0]++); + return count[0]; + } + + /** + * The production {@link DirectoryLister}: a LITERAL listing through the engine-injected Doris + * {@link FileSystem} (a per-catalog {@code SpiSwitchingFileSystem}), filtering out directories and + * {@code _}/{@code .}-prefixed hidden files (byte-parity with the pre-cache filters in + * {@code HiveScanPlanProvider.listAndSplitFiles} and {@code HiveConnectorMetadata.sumCachedFileSizes}). When + * {@code recursive} (from {@code hive.recursive_directories}, default {@code true}) it descends into non-hidden + * sub-directories (see {@link #collectFiles}). A zero-length data file is kept (the scan splitter skips it; the + * size estimate adds 0) so both consumers keep their exact prior behaviour. + * + *

Two-boundary failure split (byte-parity with the pre-cache {@code FileSystem.get}/{@code listStatus} + * split): {@link FileSystem#forLocation} does the scheme/storage resolution + concrete-FS construction (no + * I/O) — a failure here (unresolvable scheme, no {@code StorageProperties}, factory error) is SYSTEMIC, wrapped + * loud in a plain {@link DorisConnectorException}. {@link FileSystem#list} then does the actual directory + * listing — a failure here is LOCAL (this partition missing/unreadable), wrapped in the skippable + * {@link HiveDirectoryListingException}, EXCEPT a lazily-surfaced {@code "No FileSystem for scheme"} + * ({@link UnsupportedFileSystemException}, the migration's own failure class — a missing engine-side FS impl, + * affecting every partition) which is re-classified SYSTEMIC/loud by {@link #isSystemicResolutionFailure} so a + * broken deployment fails the query instead of silently returning an empty scan. Neither is ever cached. + * + *

Literal listing: {@code fs.list(loc)} (not {@code listFiles(loc)}) is used deliberately — the + * per-scheme filesystems ({@code DFSFileSystem}, {@code S3CompatibleFileSystem}) override {@code listFiles} with + * a glob-aware branch that would treat a location containing {@code [}/{@code *}/{@code ?} as a pattern; the old + * {@code listStatus} never glob-expanded, and a hive location can legitimately contain those characters. + */ + static List listFromFileSystem(String location, FileSystem fs) { + return listFromFileSystem(location, fs, false); + } + + static List listFromFileSystem(String location, FileSystem fs, boolean recursive) { + if (fs == null) { + // No engine filesystem for this catalog (empty storage): a SYSTEMIC config error affecting every + // partition. Fail loud (the scan path does NOT skip this), never a silent empty scan. + throw new DorisConnectorException("No filesystem configured for " + location); + } + Location loc = Location.of(location); + FileSystem resolved; + try { + resolved = fs.forLocation(loc); + } catch (IOException | RuntimeException e) { + // Scheme/storage resolution or concrete-FS construction failed: a SYSTEMIC storage-config error + // affecting every partition of the table. (RuntimeException is also caught: the FileSystem factory may + // report a misconfiguration as an unchecked exception, which must still wrap uniformly as the loud + // systemic type rather than escape untyped.) + throw new DorisConnectorException("Failed to resolve filesystem for " + location, e); + } + try { + List files = new ArrayList<>(); + collectFiles(resolved, loc, recursive, files); + return files; + } catch (IOException e) { + if (isSystemicResolutionFailure(e)) { + // A lazily-surfaced "No FileSystem for scheme X": the engine-side FS impl for this scheme is missing + // (broken packaging) — SYSTEMIC, affecting every partition. Fail loud, matching the pre-cache + // FileSystem.get behavior (this is the exact error class FIX-HIVEFS exists to keep loud). + throw new DorisConnectorException("Failed to resolve filesystem for " + location, e); + } + // Listing THIS partition directory (or one of its sub-directories) failed (missing / unreadable / + // transient): a LOCAL failure the scan path tolerates by skipping the partition with a warning + // (pre-cache parity). Distinct exception type so only this is skipped, while systemic failures stay loud. + throw new HiveDirectoryListingException("Failed to list files under " + location, e); + } + } + + /** + * Collects the visible data files under {@code dir} into {@code out}, filtering directories and + * {@code _}/{@code .}-prefixed hidden files. When {@code recursive}, descends into every NON-hidden + * sub-directory; a hidden sub-directory ({@code _temporary} / {@code .hive-staging}) is skipped — exact net + * parity with legacy {@code HiveExternalMetaCache}'s full-path {@code containsHiddenPath} filter (the connector + * filters only the leaf {@link FileEntry#name()}, so descending into a hidden dir would surface staging files + * legacy suppresses). A listing failure at any level throws {@link IOException} up to the single classifier in + * {@link #listFromFileSystem}, so a sub-directory failure gets the same systemic/local verdict as the top. + * Descent reuses the already-resolved {@code resolved} (a sub-directory shares scheme/authority). + */ + private static void collectFiles(FileSystem resolved, Location dir, boolean recursive, + List out) throws IOException { + try (FileIterator it = resolved.list(dir)) { + while (it.hasNext()) { + FileEntry entry = it.next(); + String name = entry.name(); + if (entry.isDirectory()) { + if (recursive && !isHidden(name)) { + collectFiles(resolved, entry.location(), true, out); + } + continue; + } + if (isHidden(name)) { + continue; + } + out.add(new HiveFileStatus(entry.location().uri(), entry.length(), + entry.modificationTime())); + } + } + } + + private static boolean isHidden(String name) { + return name.startsWith("_") || name.startsWith("."); + } + + /** + * Whether {@code t} (or any exception in its cause chain — robust to {@code authenticator.doAs} wrapping) + * is a scheme-not-registered failure ({@link UnsupportedFileSystemException} / {@code "No FileSystem for + * scheme"}). Such a failure is a deterministic, whole-table storage/packaging error and must stay LOUD, unlike + * a per-directory listing failure. + */ + private static boolean isSystemicResolutionFailure(Throwable t) { + for (Throwable c = t; c != null; c = c.getCause()) { + if (c instanceof UnsupportedFileSystemException) { + return true; + } + String msg = c.getMessage(); + if (msg != null && msg.contains("No FileSystem for scheme")) { + return true; + } + if (c.getCause() == c) { + break; + } + } + return false; + } + + /** + * Cache key: (db, table, location, partitionValues). db+table let {@link #invalidateTable} select one table's + * entries; db alone lets {@link #invalidateDb} select one database's entries; the partition values let + * {@link #invalidatePartitions} select exactly one partition's entries (legacy per-partition parity). The + * values co-vary with the location (a partition has one location), so including both keeps the scan and + * size-estimate paths sharing the same entry while making per-partition invalidation possible. + */ + static final class FileListingKey { + private final String dbName; + private final String tableName; + private final String location; + private final List partitionValues; + + FileListingKey(String dbName, String tableName, String location, List partitionValues) { + this.dbName = dbName; + this.tableName = tableName; + this.location = location; + this.partitionValues = partitionValues == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(partitionValues)); + } + + boolean matches(String db, String table) { + return Objects.equals(dbName, db) && Objects.equals(tableName, table); + } + + boolean matchesDb(String db) { + return Objects.equals(dbName, db); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof FileListingKey)) { + return false; + } + FileListingKey that = (FileListingKey) o; + return Objects.equals(dbName, that.dbName) + && Objects.equals(tableName, that.tableName) + && Objects.equals(location, that.location) + && Objects.equals(partitionValues, that.partitionValues); + } + + @Override + public int hashCode() { + return Objects.hash(dbName, tableName, location, partitionValues); + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveFileStatus.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveFileStatus.java new file mode 100644 index 00000000000000..86b82ef895d2eb --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveFileStatus.java @@ -0,0 +1,53 @@ +// 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; + +/** + * An immutable, slim view of one data file in a partition directory: exactly the three fields the scan planner + * ({@link HiveScanPlanProvider#splitFile}) and the row-count estimate + * ({@link HiveConnectorMetadata#estimateDataSizeByListingFiles}) read from a Hadoop {@code FileStatus}. It is the + * value element cached by {@link HiveFileListingCache}, deliberately decoupled from the (heavier, Hadoop-internal) + * {@code FileStatus} so the cache holds only immutable, small values (the codebase-wide metadata-cache convention). + */ +public final class HiveFileStatus { + + private final String path; + private final long length; + private final long modificationTime; + + public HiveFileStatus(String path, long length, long modificationTime) { + this.path = path; + this.length = length; + this.modificationTime = modificationTime; + } + + /** The file's full path (e.g. {@code s3://wh/db/t/dt=1/000000_0}). */ + public String getPath() { + return path; + } + + /** The file length in bytes. */ + public long getLength() { + return length; + } + + /** The file's last-modification time in millis (BE uses it to detect a changed split). */ + public long getModificationTime() { + return modificationTime; + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveReadTransaction.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveReadTransaction.java new file mode 100644 index 00000000000000..3f6247402356e6 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveReadTransaction.java @@ -0,0 +1,100 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.hms.HmsClient; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Holds the state of one Hive read transaction, used when reading a transactional (ACID) Hive table. + * Each instance is bound to a single query. + * + *

Plugin-side port of fe-core {@code HiveTransaction}. It drives the four read-side metastore + * primitives through the shared {@link HmsClient} ({@code openTxn} / {@code acquireSharedLock} / + * {@code getValidWriteIds} / {@code commitTxn}) instead of the fe-core {@code HMSExternalCatalog}/ + * {@code HMSCachedClient}/{@code TableNameInfo} chain — the table identity is carried as plain + * {@code (dbName, tableName)} strings.

+ */ +public class HiveReadTransaction { + private final String queryId; + private final String user; + private final String dbName; + private final String tableName; + private final boolean isFullAcid; + private final HmsClient hmsClient; + + private long txnId; + private final List partitionNames = new ArrayList<>(); + + private Map txnValidIds = null; + + public HiveReadTransaction(String queryId, String user, String dbName, String tableName, + boolean isFullAcid, HmsClient hmsClient) { + this.queryId = queryId; + this.user = user; + this.dbName = dbName; + this.tableName = tableName; + this.isFullAcid = isFullAcid; + this.hmsClient = hmsClient; + } + + public String getQueryId() { + return queryId; + } + + public void addPartition(String partitionName) { + this.partitionNames.add(partitionName); + } + + public boolean isFullAcid() { + return isFullAcid; + } + + /** + * Acquires a shared read lock and fetches the snapshot ({@code ValidTxnList} + {@code + * ValidWriteIdList}) for the current transaction, memoizing so the lock is taken at most once. The + * lock is released when the transaction is committed at query finish. + */ + public Map getValidWriteIds() { + if (txnValidIds == null) { + hmsClient.acquireSharedLock(queryId, txnId, user, dbName, tableName, partitionNames, 5000); + txnValidIds = hmsClient.getValidWriteIds(dbName + "." + tableName, txnId); + } + return txnValidIds; + } + + public void begin() { + try { + this.txnId = hmsClient.openTxn(user); + } catch (RuntimeException e) { + throw new DorisConnectorException(e.getMessage(), e); + } + } + + public void commit() { + try { + hmsClient.commitTxn(txnId); + } catch (RuntimeException e) { + throw new DorisConnectorException(e.getMessage(), e); + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveReadTransactionManager.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveReadTransactionManager.java new file mode 100644 index 00000000000000..b5b36ff171b091 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveReadTransactionManager.java @@ -0,0 +1,62 @@ +// 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.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Manages the read transaction of each query against transactional (ACID) Hive tables. For each query a + * {@link HiveReadTransaction} is registered (which opens the metastore transaction); when the query + * finishes it is deregistered (which commits the transaction, releasing the shared read lock). + * + *

Plugin-side port of fe-core {@code HiveTransactionMgr}. It is plugin-owned and dormant until + * the read cutover: today transactional-hive reads still flow through fe-core + * {@code HiveScanNode}/{@code Env.getCurrentHiveTransactionMgr()}. At cutover the query-finish trigger + * (fe-core {@code QueryFinishCallbackRegistry}) is wired to {@link #deregister} and the legacy + * {@code Env} manager is removed. Until then nothing invokes this manager on a live path.

+ */ +public class HiveReadTransactionManager { + private static final Logger LOG = LogManager.getLogger(HiveReadTransactionManager.class); + + private final Map txnMap = new ConcurrentHashMap<>(); + + /** + * Opens the transaction (see {@link HiveReadTransaction#begin}) and registers it under its query id, so + * the query-finish path can find and commit it. Mirrors fe-core: one {@code HiveReadTransaction} is + * created per transactional-table scan (each pins its own table's write-id snapshot), keyed by query id. + */ + public void register(HiveReadTransaction txn) { + txn.begin(); + txnMap.put(txn.getQueryId(), txn); + } + + public void deregister(String queryId) { + HiveReadTransaction txn = txnMap.remove(queryId); + if (txn != null) { + try { + txn.commit(); + } catch (RuntimeException e) { + LOG.warn("failed to commit hive read txn: " + queryId, e); + } + } + } +} 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 2baae9a79926d3..4da1b40adbd9b3 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 @@ -27,22 +27,27 @@ import org.apache.doris.connector.api.scan.ConnectorScanRangeType; 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.filesystem.FileEntry; +import org.apache.doris.filesystem.FileSystem; +import org.apache.doris.thrift.TFileCompressType; +import org.apache.doris.thrift.TFileScanRangeParams; +import org.apache.doris.thrift.TTableFormatFileDesc; -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.fs.FileStatus; -import org.apache.hadoop.fs.FileSystem; -import org.apache.hadoop.fs.Path; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.io.FileNotFoundException; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.function.UnaryOperator; /** * Scan plan provider for Hive tables. @@ -59,8 +64,8 @@ *
    *
  • No file listing cache (lists files directly each time)
  • *
  • No ACID transaction support (non-transactional tables only)
  • - *
  • No batch/lazy split mode
  • - *
  • No table sampling
  • + *
  • No file-count streaming split mode; partition-batch mode is limited to partitioned + * non-transactional tables (see {@link #supportsBatchScan})
  • *
*/ public class HiveScanPlanProvider implements ConnectorScanPlanProvider { @@ -77,13 +82,35 @@ public class HiveScanPlanProvider implements ConnectorScanPlanProvider { 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. + */ + public static final String PROP_TRANSACTIONAL_HIVE = "transactional_hive"; + + /** Input format of a full-ACID (ORC) transactional Hive table; other formats are rejected. */ + private static final String ORC_ACID_INPUT_FORMAT = "org.apache.hadoop.hive.ql.io.orc.OrcInputFormat"; private final HmsClient hmsClient; private final Map catalogProperties; - - public HiveScanPlanProvider(HmsClient hmsClient, 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 + // directories, replacing bare Hadoop FileSystem.get (FIX-HIVEFS: the hive plugin bundles no HDFS impl). + private final ConnectorContext context; + private final HiveReadTransactionManager readTxnManager; + // Connector-owned directory-listing cache (the SAME instance HiveConnector shares with HiveConnectorMetadata), + // so a repeated scan of the same partition directory is served from the cache instead of re-listing. Only the + // plain (non-ACID) path uses it; the ACID path lists via HiveAcidUtil and is uncached (legacy parity). + private final HiveFileListingCache fileListingCache; + + public HiveScanPlanProvider(HmsClient hmsClient, Map catalogProperties, + ConnectorContext context, HiveReadTransactionManager readTxnManager, + HiveFileListingCache fileListingCache) { this.hmsClient = hmsClient; this.catalogProperties = catalogProperties; + this.context = context; + this.readTxnManager = readTxnManager; + this.fileListingCache = fileListingCache; } @Override @@ -107,28 +134,243 @@ public List planScan( } HiveFileFormat fileFormat = HiveFileFormat.detect( - hiveHandle.getInputFormat(), hiveHandle.getSerializationLib()); + hiveHandle.getInputFormat(), hiveHandle.getSerializationLib(), + readHiveJsonInOneColumn(session), hiveHandle.isFirstColumnString()); long targetSplitSize = getTargetSplitSize(session); - boolean splittable = fileFormat.isSplittable(); + boolean isLzo = isLzoInputFormat(hiveHandle.getInputFormat()); + // LZO text is NOT splittable: a .lzo stream cannot be decompressed from an arbitrary byte offset. + // Legacy HiveUtil.isSplittable returned false for LZO; HiveFileFormat maps LZO text to TEXT (which + // reports splittable), so the LZO case must be masked out here. ACID is never LZO, so this leaves the + // transactional branch unchanged. + boolean splittable = fileFormat.isSplittable() && !isLzo; List ranges = new ArrayList<>(); - Configuration hadoopConf = buildHadoopConf(); + + if (hiveHandle.isTransactional()) { + // 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, + splittable, targetSplitSize, ranges); + } else { + // Borrow the engine's per-catalog Doris FileSystem to list partition directories (see field javadoc). + FileSystem fs = context.getFileSystem(session); + for (PartitionScanInfo partition : partitions) { + HiveFileFormat partFormat = partition.fileFormat != null + ? partition.fileFormat : fileFormat; + listAndSplitFiles(dbName, tableName, partition, partFormat, + splittable, isLzo, targetSplitSize, fs, ranges); + } + } + + LOG.info("Hive scan plan: table={}.{}, partitions={}, splits={}", + dbName, tableName, partitions.size(), ranges.size()); + return ranges; + } + + /** + * Whether this hive table supports batched split generation (see + * {@link ConnectorScanPlanProvider#supportsBatchScan}): {@code true} iff the table is PARTITIONED (has + * partition keys) and NOT transactional. A large partitioned scan then streams its splits per partition + * batch via {@link #planScanForPartitionBatch} on a background pool instead of materializing every + * partition's files synchronously (legacy {@code HiveScanNode} went async at + * {@code num_partitions_in_batch_mode}). + * + *

Transactional/ACID tables are excluded DELIBERATELY: their scan opens one metastore read transaction + * (see {@link #planAcidScan}); running the per-batch resolution on background threads would open — and leak + * — a read transaction per batch. ACID partitioned tables keep the synchronous {@link #planScan} path + * (correct, just not streamed). The transactional test uses the SAME {@code isTransactional()} accessor + * {@link #planScan} branches on.

+ */ + @Override + public boolean supportsBatchScan(ConnectorSession session, ConnectorTableHandle handle) { + HiveTableHandle hiveHandle = (HiveTableHandle) handle; + List partKeyNames = hiveHandle.getPartitionKeyNames(); + return partKeyNames != null && !partKeyNames.isEmpty() && !hiveHandle.isTransactional(); + } + + /** + * Hive scan ranges (base/insert files) carry positive byte lengths, so the engine can apply + * {@code TABLESAMPLE} by size-weighted split selection — restoring the legacy + * {@code HiveScanNode.selectFiles} behavior lost at the SPI cutover. Only Hive ever sampled; the + * other connectors keep the default {@code false} (their ranges do not carry byte-proportional lengths). + */ + @Override + public boolean supportsTableSample() { + return true; + } + + /** + * Remaps {@code LZ4FRAME -> LZ4BLOCK} for hive splits, restoring legacy {@code HiveScanNode.getFileCompressType} + * parity lost at the SPI cutover. Hadoop/hive write {@code .lz4} files with the LZ4 block codec, but + * the generic node infers {@code LZ4FRAME} from the {@code .lz4} extension; sending frame to BE makes its + * text/CSV reader fail with {@code LZ4F_getFrameInfo ERROR_frameType_unknown} on block-format bytes. Only + * {@code LZ4FRAME} is remapped — every other codec (incl. an actual frame-format non-hive file, which hive + * never produces) passes through, so this is byte-identical to legacy in every reachable case. + */ + @Override + public TFileCompressType adjustFileCompressType(TFileCompressType inferred) { + return inferred == TFileCompressType.LZ4FRAME ? TFileCompressType.LZ4BLOCK : inferred; + } + + /** + * Plans the scan for a SINGLE partition batch (see + * {@link ConnectorScanPlanProvider#planScanForPartitionBatch}). Unlike {@link #planScan} — which resolves + * partitions from {@code handle.getPrunedPartitions()} and IGNORES the passed partition set — this MUST scope + * resolution to {@code partitionBatch}: {@code PluginDrivenScanNode} slices the pruned partition NAMES into + * batches and calls this once per batch, so inheriting the SPI default (which re-runs the whole-pruned-set + * {@link #planScan} per batch) would emit every partition's files once per batch — duplicate rows. The batch + * strings are the metastore-rendered {@code key=value/...} partition names (the keys of the Nereids + * selected-partition map), exactly the form {@code hmsClient.getPartitions} accepts, so batch-scoped + * resolution is a clean {@code getPartitions(batch)} round-trip. Only the non-ACID path is reachable here + * ({@link #supportsBatchScan} excludes transactional tables). Reuses the SAME helpers as {@link #planScan}: + * format detect, split size, hadoop conf, {@link #convertPartitions}, {@link #listAndSplitFiles}. + */ + @Override + public List planScanForPartitionBatch( + ConnectorSession session, + ConnectorTableHandle handle, + List columns, + Optional filter, + long limit, + List partitionBatch) { + HiveTableHandle hiveHandle = (HiveTableHandle) handle; + String dbName = hiveHandle.getDbName(); + String tableName = hiveHandle.getTableName(); + + // Resolve ONLY this batch's partitions (scoped to partitionBatch), NOT handle.getPrunedPartitions(). + List hmsPartitions = hmsClient.getPartitions(dbName, tableName, partitionBatch); + List partitions = convertPartitions( + hmsPartitions, hiveHandle.getPartitionKeyNames()); + if (partitions.isEmpty()) { + return Collections.emptyList(); + } + + HiveFileFormat fileFormat = HiveFileFormat.detect( + hiveHandle.getInputFormat(), hiveHandle.getSerializationLib(), + readHiveJsonInOneColumn(session), hiveHandle.isFirstColumnString()); + long targetSplitSize = getTargetSplitSize(session); + boolean isLzo = isLzoInputFormat(hiveHandle.getInputFormat()); + // LZO text is not splittable (see planScan); mask it out of the TEXT-derived splittable flag. + 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); + + List ranges = new ArrayList<>(); + for (PartitionScanInfo partition : partitions) { + HiveFileFormat partFormat = partition.fileFormat != null + ? partition.fileFormat : fileFormat; + listAndSplitFiles(dbName, tableName, partition, partFormat, + splittable, isLzo, targetSplitSize, fs, ranges); + } + return ranges; + } + + /** + * Plans a scan of a transactional (ACID) Hive table. + * + *

Opens (or reuses) the query's read transaction — which acquires a shared read lock and pins a + * write-id snapshot — then, per partition, runs {@link HiveAcidUtil#getAcidState} to resolve the + * 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 + * 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 + * {@link HiveReadTransactionManager#deregister} at query finish (via {@link #releaseReadTransaction}), + * without which the shared read lock would leak for the metastore's lifetime.

+ */ + private void planAcidScan(ConnectorSession session, HiveTableHandle handle, + List partitions, FileSystem fs, HiveFileFormat fileFormat, + boolean splittable, long targetSplitSize, List ranges) { + boolean isFullAcid = handle.isFullAcid(); + if (isFullAcid && !ORC_ACID_INPUT_FORMAT.equals(handle.getInputFormat())) { + // Full-ACID data is only stored in the ORC ACID layout; reject other formats loudly + // (legacy parity: HMSExternalTable.isFullAcidTable throws "no Orc Format"). + throw new DorisConnectorException("This table is full Acid Table, but no Orc Format."); + } + + // One transaction per transactional-table scan, pinning this table's write-id snapshot (mirrors + // fe-core, which opens a HiveTransaction per scan node). The manager holds it for commit at finish. + HiveReadTransaction txn = new HiveReadTransaction(session.getQueryId(), session.getUser(), + handle.getDbName(), handle.getTableName(), isFullAcid, hmsClient); + readTxnManager.register(txn); + for (PartitionScanInfo partition : partitions) { + if (!partition.partitionValues.isEmpty()) { + txn.addPartition(buildPartitionName(partition.partitionValues)); + } + } + Map txnValidIds = txn.getValidWriteIds(); for (PartitionScanInfo partition : partitions) { HiveFileFormat partFormat = partition.fileFormat != null ? partition.fileFormat : fileFormat; try { - listAndSplitFiles(hadoopConf, partition, partFormat, - splittable, targetSplitSize, ranges); + // Descend the ACID directory tree through the engine-injected Doris FileSystem. The hive plugin + // bundles no HDFS impl, so a bare Hadoop FileSystem.get here would throw "No FileSystem for + // scheme hdfs" (FIX-HIVEFS); the engine owns this FileSystem's lifecycle — never closed here. + HiveAcidUtil.AcidState state = HiveAcidUtil.getAcidState( + fs, partition.location, txnValidIds, isFullAcid); + // Only full-ACID reads are marked transactional_hive (delete deltas applied by the BE + // merge-on-read reader). Insert-only tables use the write-id snapshot to pick files but + // store plain data files, so their splits stay plain hive — matching legacy AcidUtil, + // which sets acidInfo only when isFullAcid. + // BE-facing ACID paths (delete-delta dir + partition location) also carry the raw HMS scheme; the + // BE transactional reader opens them via the same native S3 factory, so normalize s3a://->s3://. + String acidLocation = isFullAcid ? normalizeNativeUri(partition.location) : null; + List encodedDeltas = isFullAcid + ? encodeDeleteDeltas(state.getDeleteDeltas(), this::normalizeNativeUri) : null; + for (FileEntry dataFile : state.getDataFiles()) { + splitFile(dataFile.location().uri(), dataFile.length(), + dataFile.modificationTime(), partition, partFormat, splittable, + targetSplitSize, acidLocation, encodedDeltas, ranges); + } } catch (IOException e) { throw new DorisConnectorException( - "Failed to list files for partition: " + partition.location, e); + "Failed to list ACID files for partition: " + partition.location, e); } } + } - LOG.info("Hive scan plan: table={}.{}, partitions={}, splits={}", - dbName, tableName, partitions.size(), ranges.size()); - return ranges; + /** + * Commits and deregisters this query's read transaction (opened by {@link #planAcidScan} via + * {@link HiveReadTransactionManager#register}), releasing the metastore shared read lock. Driven by the + * generic fe-core query-finish callback at query end; without it a transactional-hive read leaks the shared + * read lock for the metastore's lifetime. {@code readTxnManager} is the same per-connector manager that + * {@code register} used (both injected by {@code HiveConnector}), so the {@code queryId} keys match. + * {@code deregister} is idempotent (a no-op for a query that opened no transaction) and swallows a commit + * failure, matching the best-effort SPI contract. + */ + @Override + public void releaseReadTransaction(String queryId) { + readTxnManager.deregister(queryId); + } + + /** Encodes each delete-delta as {@code "dir|file1,file2"} for {@link HiveScanRange.Builder#acidInfo}. */ + private static List encodeDeleteDeltas(List deltas, + UnaryOperator nativePathNormalizer) { + List encoded = new ArrayList<>(deltas.size()); + for (HiveAcidUtil.DeleteDelta delta : deltas) { + // Normalize the delete-delta directory scheme (s3a://->s3://) for BE's native reader; the file names + // are bare names appended after '|'. + encoded.add(nativePathNormalizer.apply(delta.getDirectoryLocation()) + "|" + + String.join(",", delta.getFileNames())); + } + return encoded; + } + + /** Builds the Hive partition name {@code k1=v1/k2=v2} used for the shared read lock components. */ + private static String buildPartitionName(Map partitionValues) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : partitionValues.entrySet()) { + if (sb.length() > 0) { + sb.append('/'); + } + sb.append(entry.getKey()).append('=').append(entry.getValue()); + } + return sb.toString(); } @Override @@ -142,7 +384,8 @@ public Map getScanNodeProperties( // File format type HiveFileFormat fileFormat = HiveFileFormat.detect( - hiveHandle.getInputFormat(), hiveHandle.getSerializationLib()); + hiveHandle.getInputFormat(), hiveHandle.getSerializationLib(), + readHiveJsonInOneColumn(session), hiveHandle.isFirstColumnString()); props.put(PROP_FILE_FORMAT_TYPE, fileFormat.getFormatName()); // Partition key column names @@ -151,7 +394,19 @@ public Map getScanNodeProperties( props.put(PROP_PATH_PARTITION_KEYS, String.join(",", partKeys)); } - // Location properties (Hadoop/S3 config for BE file access) + // Location properties (Hadoop/S3 config for BE file access). + // (1) BE-canonical static credentials (AWS_* for object stores, resolved hadoop.*/dfs.* for HDFS): BE's + // native (FILE_S3) reader understands ONLY these canonical keys — it reads AWS_ACCESS_KEY / + // AWS_SECRET_KEY / AWS_ENDPOINT (s3_util.cpp), NOT the raw s3.access_key/... aliases — so without + // them a private bucket 403s. Legacy HiveScanNode.getLocationProperties() emitted exactly this + // (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)); + } + // (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 + // reader) but kept so no configured key is dropped. for (Map.Entry entry : catalogProperties.entrySet()) { String key = entry.getKey(); if (isLocationProperty(key)) { @@ -159,8 +414,11 @@ public Map getScanNodeProperties( } } - // Text format properties (if applicable) - if (fileFormat == HiveFileFormat.TEXT || fileFormat == HiveFileFormat.JSON) { + // Text format properties (delimiters / enclose / escape / null_format / is_json) for every non-columnar + // format — TEXT (hive text serde), CSV (OpenCSV serde) and JSON. BE reads these from the hive.text.* + // props regardless of the resolved TFileFormatType, so all three families must emit them. + if (fileFormat == HiveFileFormat.TEXT || fileFormat == HiveFileFormat.CSV + || fileFormat == HiveFileFormat.JSON) { Map textProps = HiveTextProperties.extract( hiveHandle.getSerializationLib(), hiveHandle.getSdParameters(), @@ -168,9 +426,49 @@ public Map getScanNodeProperties( props.putAll(textProps); } + // #65437: BE selects the file scanner from SCAN-LEVEL params before the per-range splits arrive, and its + // FileScannerV2 gate excludes scans whose scan-level table_format_type is "transactional_hive" (V2 does + // not apply ACID delete deltas). Signal a full-ACID scan so populateScanLevelParams stamps it at scan + // level; gate on isFullAcid() to match the per-range marker (insert-only tables keep the V2 fast path). + if (hiveHandle.isFullAcid()) { + props.put(PROP_TRANSACTIONAL_HIVE, "true"); + } + return props; } + /** + * Stamps the SCAN-LEVEL table format for a full-ACID Hive scan (signalled by {@link #PROP_TRANSACTIONAL_HIVE} + * from {@link #getScanNodeProperties}). BE's FileScannerV2 selection reads {@code table_format_type} from the + * scan-level params (before per-range splits are fetched); without this stamp a transactional Hive read would + * wrongly use FileScannerV2, which does not apply ACID delete deltas — deleted rows would reappear. Mirrors the + * legacy {@code HiveScanNode.markTransactionalHiveScanParams} (#65437); the generic {@code PluginDrivenScanNode} + * invokes this hook after building the scan-level params. + */ + @Override + public void populateScanLevelParams(TFileScanRangeParams params, Map nodeProperties) { + if ("true".equals(nodeProperties.get(PROP_TRANSACTIONAL_HIVE))) { + markTransactionalHiveScanParams(params); + } + } + + static void markTransactionalHiveScanParams(TFileScanRangeParams params) { + TTableFormatFileDesc tableFormatFileDesc = new TTableFormatFileDesc(); + // Same literal the per-range marker uses (HiveScanRange); BE matches table_format_type == "transactional_hive". + tableFormatFileDesc.setTableFormatType("transactional_hive"); + params.setTableFormatParams(tableFormatFileDesc); + } + + /** + * Reads the {@code read_hive_json_in_one_column} session flag (default false) from the connector session — + * the same channel the write path uses for {@code hive_text_compression}. The engine dumps every visible + * session variable into {@code getSessionProperties()}, so no extra plumbing is needed. + */ + private static boolean readHiveJsonInOneColumn(ConnectorSession session) { + return Boolean.parseBoolean(session.getSessionProperties() + .getOrDefault(HiveConnectorProperties.SESSION_READ_HIVE_JSON_IN_ONE_COLUMN, "false")); + } + /** * Resolves the partitions to scan, using pruned partitions from the handle * if available, or listing all partitions from HMS. @@ -210,71 +508,139 @@ private List convertPartitions( for (int i = 0; i < partKeyNames.size() && i < values.size(); i++) { partValues.put(partKeyNames.get(i), values.get(i)); } - HiveFileFormat partFormat = HiveFileFormat.detect( - part.getInputFormat(), part.getSerializationLib()); + // Per-partition file format is not carried to BE (the whole scan node reads with the single + // table-level format resolved in planScan/getScanNodeProperties); leave it null so planScan falls + // back to the table format, matching the unpartitioned case. Detecting it per partition here would + // also fail-loud spuriously if one partition carried an unusual serde the table itself does not. result.add(new PartitionScanInfo( - part.getLocation(), partValues, partFormat)); + part.getLocation(), partValues, null)); } return result; } /** - * Lists files in a partition directory and splits them into scan ranges. + * Lists the data files of a partition directory (through the connector's shared {@link HiveFileListingCache}, + * which filters directories and {@code _}/{@code .}-prefixed hidden files) and splits them into scan ranges. + * A LOCAL per-directory listing failure ({@link HiveDirectoryListingException}) is tolerated — the partition is + * skipped with a warning, the same resilience the pre-cache code gave a missing/unreadable partition directory. + * A SYSTEMIC filesystem-resolution failure (a plain {@link DorisConnectorException} from {@code FileSystem.get}, + * which affects every partition) is NOT caught here: it propagates to fail the query loud, matching the pre-cache + * behavior where a {@code FileSystem.get} failure aborted the query instead of silently returning an empty scan. + * Failures are never cached (the cache loader throws). */ - private void listAndSplitFiles(Configuration conf, + private void listAndSplitFiles(String dbName, String tableName, PartitionScanInfo partition, HiveFileFormat fileFormat, - boolean splittable, long targetSplitSize, - List ranges) throws IOException { - Path partPath = new Path(partition.location); - FileSystem fs = FileSystem.get(partPath.toUri(), conf); - FileStatus[] statuses; + boolean splittable, boolean isLzo, long targetSplitSize, FileSystem fs, + List ranges) { + List files; try { - statuses = fs.listStatus(partPath); - } catch (IOException e) { + files = fileListingCache.listDataFiles(dbName, tableName, partition.location, + new ArrayList<>(partition.partitionValues.values()), fs); + } catch (HiveDirectoryListingException e) { + // hive.ignore_absent_partitions=false (non-default): a partition whose LOCATION does not exist + // must fail the query loud with the legacy message rather than be silently skipped. Only a + // not-found cause counts as "absent"; a transient/unreadable listing failure still follows the + // tolerant skip-with-warning path below. Default true preserves the skip behavior. + if (isLocationNotFound(e) && !HiveConnectorProperties.getBoolean( + catalogProperties, HiveConnectorProperties.IGNORE_ABSENT_PARTITIONS, true)) { + throw new DorisConnectorException( + "Partition location does not exist: " + partition.location, e); + } LOG.warn("Cannot list files in partition: {}", partition.location, e); return; } - - for (FileStatus status : statuses) { - if (status.isDirectory()) { - // Skip directories (could be _temporary, etc.) + for (HiveFileStatus file : files) { + // LZO text tables: only *.lzo files are data. Exclude *.lzo.index sidecars (and any other + // non-*.lzo entry), mirroring Hive's LzoTextInputFormat.listStatus() and legacy + // HiveExternalMetaCache's HiveUtil.isLzoDataFile filter. HiveFileListingCache strips only + // _/.-prefixed hidden files, so without this a *.lzo.index sidecar is read as an extra text row. + if (isLzo && !isLzoDataFile(file.getPath())) { continue; } - String fileName = status.getPath().getName(); - if (shouldSkipFile(fileName)) { - continue; + splitFile(file.getPath(), file.getLength(), file.getModificationTime(), + partition, fileFormat, splittable, targetSplitSize, null, null, ranges); + } + } + + /** + * Whether a listing failure was caused by the directory not existing (a {@link FileNotFoundException} + * anywhere in the cause chain), as opposed to a transient or unreadable-permission failure. Used to + * decide whether {@code hive.ignore_absent_partitions=false} should turn a skipped partition into a + * loud "Partition location does not exist" error. + */ + private static boolean isLocationNotFound(Throwable e) { + for (Throwable t = e; t != null; t = t.getCause()) { + if (t instanceof FileNotFoundException) { + return true; } - splitFile(status, partition, fileFormat, splittable, - targetSplitSize, ranges); } + return false; } /** - * Splits a file into scan ranges based on target split size. + * Whether {@code inputFormat} is one of the hadoop-lzo text InputFormat variants (the twitter + * {@code com.hadoop.compression.lzo.LzoTextInputFormat}, the anarres {@code com.hadoop.mapreduce. + * LzoTextInputFormat}, and the legacy {@code com.hadoop.mapred.DeprecatedLzoTextInputFormat}). Such tables + * read only {@code *.lzo} data files and must exclude {@code *.lzo.index} sidecars. Ports legacy + * {@code HiveUtil.isLzoInputFormat}; the {@code contains("lzo")} match (rather than exact class names) + * mirrors legacy and covers all variants alike. The connector cannot import fe-core, hence the local copy. + * Package-private for unit testing. + */ + static boolean isLzoInputFormat(String inputFormat) { + return inputFormat != null && inputFormat.toLowerCase(Locale.ROOT).contains("lzo"); + } + + /** + * For an LZO text InputFormat, only {@code *.lzo} files are data files; {@code *.lzo.index} sidecars and any + * other extension are metadata to exclude, mirroring Hive's {@code LzoTextInputFormat.listStatus()}. Ports + * legacy {@code HiveUtil.isLzoDataFile}. Package-private for unit testing. + */ + static boolean isLzoDataFile(String filePath) { + // Strip any object-store query string/fragment before matching the extension. + String path = filePath; + int q = path.indexOf('?'); + if (q >= 0) { + path = path.substring(0, q); + } + return path.toLowerCase(Locale.ROOT).endsWith(".lzo"); + } + + /** + * 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 + * {@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 + * {@code LocationPath.of(path, storagePropertiesMap)}. Non-object-store schemes (hdfs://, local) pass through + * unchanged. A null context (offline unit tests) preserves the raw URI. */ - private void splitFile(FileStatus fileStatus, PartitionScanInfo partition, - HiveFileFormat fileFormat, boolean splittable, - long targetSplitSize, List ranges) { - long fileSize = fileStatus.getLen(); - String filePath = fileStatus.getPath().toString(); - long modTime = fileStatus.getModificationTime(); + private String normalizeNativeUri(String rawUri) { + return context != null ? context.normalizeStorageUri(rawUri) : rawUri; + } + /** + * Splits a file into scan ranges based on target split size. + * + *

When {@code acidPartitionLocation} is non-null the ranges carry ACID delete-delta info + * (marking them {@code transactional_hive}); otherwise they are plain Hive ranges.

+ */ + private void splitFile(String filePath, long fileSize, long modTime, PartitionScanInfo partition, + HiveFileFormat fileFormat, boolean splittable, long targetSplitSize, + String acidPartitionLocation, List acidDeltas, + List ranges) { if (fileSize == 0) { return; } + // Normalize the BE-facing native data-file path scheme (s3a://->s3://): the connector lists files via the + // engine FileSystem with the raw scheme (Hadoop wants s3a), but BE's native S3 reader rejects s3a. ACID + // delete-delta / partition paths are normalized separately at their emit sites. + filePath = normalizeNativeUri(filePath); if (!splittable || targetSplitSize <= 0 || fileSize <= targetSplitSize) { // Single range for the whole file - ranges.add(HiveScanRange.builder() - .path(filePath) - .start(0) - .length(fileSize) - .fileSize(fileSize) - .modificationTime(modTime) - .fileFormat(fileFormat.getFormatName()) - .tableFormatType("hive") - .partitionValues(partition.partitionValues) - .build()); + ranges.add(newRangeBuilder(filePath, 0, fileSize, fileSize, modTime, fileFormat, + partition, acidPartitionLocation, acidDeltas).build()); return; } @@ -282,22 +648,29 @@ private void splitFile(FileStatus fileStatus, PartitionScanInfo partition, long offset = 0; while (offset < fileSize) { long splitSize = Math.min(targetSplitSize, fileSize - offset); - ranges.add(HiveScanRange.builder() - .path(filePath) - .start(offset) - .length(splitSize) - .fileSize(fileSize) - .modificationTime(modTime) - .fileFormat(fileFormat.getFormatName()) - .tableFormatType("hive") - .partitionValues(partition.partitionValues) - .build()); + ranges.add(newRangeBuilder(filePath, offset, splitSize, fileSize, modTime, fileFormat, + partition, acidPartitionLocation, acidDeltas).build()); offset += splitSize; } } - private boolean shouldSkipFile(String fileName) { - return fileName.startsWith("_") || fileName.startsWith("."); + private static HiveScanRange.Builder newRangeBuilder(String filePath, long start, long length, + long fileSize, long modTime, HiveFileFormat fileFormat, PartitionScanInfo partition, + String acidPartitionLocation, List acidDeltas) { + HiveScanRange.Builder builder = HiveScanRange.builder() + .path(filePath) + .start(start) + .length(length) + .fileSize(fileSize) + .modificationTime(modTime) + .fileFormat(fileFormat.getFormatName()) + .tableFormatType("hive") + .partitionValues(partition.partitionValues); + if (acidPartitionLocation != null) { + // Sets tableFormatType="transactional_hive" and attaches the delete-delta descriptors. + builder.acidInfo(acidPartitionLocation, acidDeltas); + } + return builder; } private long getTargetSplitSize(ConnectorSession session) { @@ -316,22 +689,6 @@ private long getTargetSplitSize(ConnectorSession session) { return DEFAULT_TARGET_SPLIT_SIZE; } - private Configuration buildHadoopConf() { - Configuration conf = new Configuration(); - for (Map.Entry entry : catalogProperties.entrySet()) { - conf.set(entry.getKey(), entry.getValue()); - } - // Set default FS from location properties if present - String defaultFs = catalogProperties.get("fs.defaultFS"); - if (defaultFs == null) { - defaultFs = catalogProperties.get("hadoop.fs.defaultFS"); - } - if (defaultFs != null) { - conf.set("fs.defaultFS", defaultFs); - } - return conf; - } - private boolean isLocationProperty(String key) { return key.startsWith("fs.") || key.startsWith("hadoop.") 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 610bfeaeb88631..a9539299e8c1e9 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 @@ -17,6 +17,7 @@ package org.apache.doris.connector.hive; +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; @@ -25,6 +26,7 @@ import org.apache.doris.thrift.TTransactionalHiveDesc; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -145,6 +147,38 @@ public void populateRangeParams(TTableFormatFileDesc formatDesc, populateTransactionalHiveParams(formatDesc); } // Non-transactional hive needs no per-split TTableFormatFileDesc fields. + + // Rewrite columns-from-path from the connector's partition values, mirroring + // IcebergScanRange/PaimonScanRange. This connector now OWNS the Hive default-partition + // sentinel (__HIVE_DEFAULT_PARTITION__ -> SQL NULL) mapping that fe-core's + // FilePartitionUtils.normalizeColumnsFromPath used to do — hive was the last connector + // relying on that engine-side string match. The parent (FileQueryScanNode) has pre-filled a + // 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. + rangeDesc.unsetColumnsFromPath(); + rangeDesc.unsetColumnsFromPathKeys(); + rangeDesc.unsetColumnsFromPathIsNull(); + if (!partitionValues.isEmpty()) { + List keys = new ArrayList<>(partitionValues.size()); + List values = new ArrayList<>(partitionValues.size()); + List isNull = new ArrayList<>(partitionValues.size()); + for (Map.Entry entry : partitionValues.entrySet()) { + String value = entry.getValue(); + boolean nullValue = value == null + || ConnectorPartitionValues.HIVE_DEFAULT_PARTITION.equals(value); + keys.add(entry.getKey()); + values.add(nullValue ? "" : value); + isNull.add(nullValue); + } + rangeDesc.setColumnsFromPathKeys(keys); + rangeDesc.setColumnsFromPath(values); + rangeDesc.setColumnsFromPathIsNull(isNull); + } } private void populateTransactionalHiveParams(TTableFormatFileDesc formatDesc) { @@ -165,9 +199,19 @@ private void populateTransactionalHiveParams(TTableFormatFileDesc formatDesc) { if (deltaStr != null) { TTransactionalHiveDeleteDeltaDesc delta = new TTransactionalHiveDeleteDeltaDesc(); - delta.setDirectoryLocation(deltaStr.contains("|") - ? deltaStr.substring(0, deltaStr.indexOf('|')) - : deltaStr); + // Encoded as "dir|file1,file2" (see Builder#acidInfo). BE needs BOTH the + // delete-delta directory AND the file names to correctly apply row deletes; + // dropping the file names silently under-deletes on ACID reads. + int sep = deltaStr.indexOf('|'); + if (sep >= 0) { + delta.setDirectoryLocation(deltaStr.substring(0, sep)); + String fileNamesPart = deltaStr.substring(sep + 1); + if (!fileNamesPart.isEmpty()) { + delta.setFileNames(Arrays.asList(fileNamesPart.split(","))); + } + } else { + delta.setDirectoryLocation(deltaStr); + } deltas.add(delta); } } diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveSinkHelper.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveSinkHelper.java new file mode 100644 index 00000000000000..37a6f2d2b51a8e --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveSinkHelper.java @@ -0,0 +1,246 @@ +// 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.DorisConnectorException; +import org.apache.doris.connector.hms.HmsTableInfo; +import org.apache.doris.thrift.TFileCompressType; +import org.apache.doris.thrift.TFileFormatType; +import org.apache.doris.thrift.THiveSerDeProperties; + +import java.util.EnumSet; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Pure, metastore-parameter-only helpers ported verbatim from the legacy fe-core write planner + * ({@code planner.BaseExternalTableDataSink} format/compress resolution and + * {@code datasource.hive.HiveProperties} + {@code HiveMetaStoreClientHelper} SerDe-delimiter resolution), + * homed here so {@link HiveWritePlanProvider} stays readable. Operates only on the SPI + * {@link HmsTableInfo} (table params + SD params + serialization lib) and Thrift types — no fe-core. + */ +final class HiveSinkHelper { + + private HiveSinkHelper() { + } + + // The write file-format types the hive sink supports (legacy HiveTableSink.supportedTypes). FORMAT_UNKNOWN + // is intentionally absent so an unrecognized input format is rejected loudly. + private static final Set SUPPORTED_FORMAT_TYPES = EnumSet.of( + TFileFormatType.FORMAT_CSV_PLAIN, + TFileFormatType.FORMAT_ORC, + TFileFormatType.FORMAT_PARQUET, + TFileFormatType.FORMAT_TEXT); + + // Serialization lib that triggers multi-char field delimiters (legacy + // HiveMetaStoreClientHelper.HIVE_MULTI_DELIMIT_SERDE — NOT the contrib MultiDelimitSerDe variant). + private static final String HIVE_MULTI_DELIMIT_SERDE = "org.apache.hadoop.hive.serde2.MultiDelimitSerDe"; + + // SerDe / SD property keys (legacy HiveProperties). + private static final String PROP_FIELD_DELIMITER = "field.delim"; + private static final String PROP_SERIALIZATION_FORMAT = "serialization.format"; + private static final String PROP_LINE_DELIMITER = "line.delim"; + // "colelction.delim" is Hive2's intentional typo; "collection.delim" is Hive3. Both are checked. + private static final String PROP_COLLECTION_DELIMITER_HIVE2 = "colelction.delim"; + private static final String PROP_COLLECTION_DELIMITER_HIVE3 = "collection.delim"; + private static final String PROP_MAP_KV_DELIMITER = "mapkey.delim"; + private static final String PROP_ESCAPE_DELIMITER = "escape.delim"; + private static final String PROP_NULL_FORMAT = "serialization.null.format"; + + // Per-delimiter defaults (legacy HiveProperties). + private static final String DEFAULT_FIELD_DELIMITER = "\1"; + private static final String DEFAULT_LINE_DELIMITER = "\n"; + private static final String DEFAULT_COLLECTION_DELIMITER = "\2"; + private static final String DEFAULT_MAP_KV_DELIMITER = "\003"; + private static final String DEFAULT_ESCAPE_DELIMITER = "\\"; + private static final String DEFAULT_NULL_FORMAT = "\\N"; + + /** + * Port of legacy {@code BaseExternalTableDataSink.getTFileFormatType}. LZO input formats are rejected + * FIRST — their class names also contain "text" (e.g. {@code LzoTextInputFormat}) and would otherwise + * match {@code FORMAT_CSV_PLAIN}, but the BE hive sink has no LZO codec so a Doris-written LZO partition + * becomes permanently invisible. Then {@code orc}/{@code parquet}/{@code text} substring-match (text maps + * to {@code FORMAT_CSV_PLAIN}); an unsupported result is rejected loudly. Called for the table SD and for + * every existing partition SD (per-partition LZO guard). + */ + static TFileFormatType getTFileFormatType(String format) { + if (format != null && format.toLowerCase(Locale.ROOT).contains("lzo")) { + throw new DorisConnectorException("INSERT INTO is not supported for LZO Hive tables " + + "(input format: " + format + "). LZO tables are read-only in Doris."); + } + TFileFormatType fileFormatType = TFileFormatType.FORMAT_UNKNOWN; + String lowerCase = format.toLowerCase(Locale.ROOT); + if (lowerCase.contains("orc")) { + fileFormatType = TFileFormatType.FORMAT_ORC; + } else if (lowerCase.contains("parquet")) { + fileFormatType = TFileFormatType.FORMAT_PARQUET; + } else if (lowerCase.contains("text")) { + fileFormatType = TFileFormatType.FORMAT_CSV_PLAIN; + } + if (!SUPPORTED_FORMAT_TYPES.contains(fileFormatType)) { + throw new DorisConnectorException("Unsupported input format type: " + format); + } + return fileFormatType; + } + + /** Port of legacy {@code BaseExternalTableDataSink.getTFileCompressType} (hive codecs; null -> PLAIN). */ + static TFileCompressType getTFileCompressType(String compressType) { + if ("snappy".equalsIgnoreCase(compressType)) { + return TFileCompressType.SNAPPYBLOCK; + } else if ("lz4".equalsIgnoreCase(compressType)) { + return TFileCompressType.LZ4BLOCK; + } else if ("lzo".equalsIgnoreCase(compressType)) { + return TFileCompressType.LZO; + } else if ("zlib".equalsIgnoreCase(compressType)) { + return TFileCompressType.ZLIB; + } else if ("zstd".equalsIgnoreCase(compressType)) { + return TFileCompressType.ZSTD; + } else if ("gzip".equalsIgnoreCase(compressType)) { + return TFileCompressType.GZ; + } else if ("bzip2".equalsIgnoreCase(compressType)) { + return TFileCompressType.BZ2; + } else if ("uncompressed".equalsIgnoreCase(compressType)) { + return TFileCompressType.PLAIN; + } else { + // try to use plain type to decompress parquet or orc file + return TFileCompressType.PLAIN; + } + } + + /** + * Port of legacy {@code HiveTableSink.setSerDeProperties} + {@code HiveProperties}: builds the six BE + * SerDe delimiters from the table's SD/table parameters. Field delimiter allows multi-char only for the + * {@code MultiDelimitSerDe} lib; the escape char is emitted only when present. + */ + static THiveSerDeProperties buildSerDeProperties(HmsTableInfo table) { + Map tableParams = table.getParameters(); + Map sdParams = table.getSdParameters(); + String serDeLib = table.getSerializationLib(); + + THiveSerDeProperties serDeProperties = new THiveSerDeProperties(); + // 1. field delimiter + if (HIVE_MULTI_DELIMIT_SERDE.equals(serDeLib)) { + serDeProperties.setFieldDelim(getFieldDelimiter(tableParams, sdParams, true)); + } else { + serDeProperties.setFieldDelim(getFieldDelimiter(tableParams, sdParams, false)); + } + // 2. line delimiter + serDeProperties.setLineDelim(getLineDelimiter(tableParams, sdParams)); + // 3. collection delimiter + serDeProperties.setCollectionDelim(getCollectionDelimiter(tableParams, sdParams)); + // 4. mapkv delimiter + serDeProperties.setMapkvDelim(getMapKvDelimiter(tableParams, sdParams)); + // 5. escape delimiter (only when present) + getEscapeDelimiter(tableParams, sdParams).ifPresent(serDeProperties::setEscapeChar); + // 6. null format + serDeProperties.setNullFormat(getNullFormat(tableParams, sdParams)); + return serDeProperties; + } + + private static String getFieldDelimiter(Map tableParams, Map sdParams, + boolean supportMultiChar) { + Optional fieldDelim = getSerdeProperty(tableParams, sdParams, PROP_FIELD_DELIMITER); + Optional serFormat = getSerdeProperty(tableParams, sdParams, PROP_SERIALIZATION_FORMAT); + String delimiter = firstPresentOrDefault("", fieldDelim, serFormat); + return supportMultiChar ? delimiter : getByte(delimiter, DEFAULT_FIELD_DELIMITER); + } + + private static String getLineDelimiter(Map tableParams, Map sdParams) { + Optional lineDelim = getSerdeProperty(tableParams, sdParams, PROP_LINE_DELIMITER); + return getByte(firstPresentOrDefault("", lineDelim), DEFAULT_LINE_DELIMITER); + } + + private static String getMapKvDelimiter(Map tableParams, Map sdParams) { + Optional mapkvDelim = getSerdeProperty(tableParams, sdParams, PROP_MAP_KV_DELIMITER); + return getByte(firstPresentOrDefault("", mapkvDelim), DEFAULT_MAP_KV_DELIMITER); + } + + private static String getCollectionDelimiter(Map tableParams, Map sdParams) { + Optional collectionDelimHive2 = getSerdeProperty(tableParams, sdParams, + PROP_COLLECTION_DELIMITER_HIVE2); + Optional collectionDelimHive3 = getSerdeProperty(tableParams, sdParams, + PROP_COLLECTION_DELIMITER_HIVE3); + return getByte(firstPresentOrDefault("", collectionDelimHive2, collectionDelimHive3), + DEFAULT_COLLECTION_DELIMITER); + } + + private static Optional getEscapeDelimiter(Map tableParams, + Map sdParams) { + Optional escapeDelim = getSerdeProperty(tableParams, sdParams, PROP_ESCAPE_DELIMITER); + if (escapeDelim.isPresent()) { + return Optional.of(getByte(escapeDelim.get(), DEFAULT_ESCAPE_DELIMITER)); + } + return Optional.empty(); + } + + private static String getNullFormat(Map tableParams, Map sdParams) { + Optional nullFormat = getSerdeProperty(tableParams, sdParams, PROP_NULL_FORMAT); + return firstPresentOrDefault(DEFAULT_NULL_FORMAT, nullFormat); + } + + // Port of legacy HiveMetaStoreClientHelper.getSerdeProperty: table parameters win over SD SerDe parameters. + private static Optional getSerdeProperty(Map tableParams, + Map sdParams, String key) { + String valueFromTbl = tableParams == null ? null : tableParams.get(key); + String valueFromSd = sdParams == null ? null : sdParams.get(key); + return firstNonNullable(valueFromTbl, valueFromSd); + } + + private static Optional firstNonNullable(String first, String second) { + if (first != null) { + return Optional.of(first); + } + if (second != null) { + return Optional.of(second); + } + return Optional.empty(); + } + + private static String firstPresentOrDefault(String defaultValue, Optional first) { + return first.orElse(defaultValue); + } + + private static String firstPresentOrDefault(String defaultValue, Optional first, + Optional second) { + if (first.isPresent()) { + return first.get(); + } + if (second.isPresent()) { + return second.get(); + } + return defaultValue; + } + + /** + * Port of legacy {@code HiveMetaStoreClientHelper.getByte}: a numeric delimiter value like {@code "9"} + * decodes to the byte char {@code (byte + 256) % 256}; a non-numeric value falls back to its first raw + * char; a null/empty value falls back to {@code defValue}. + */ + static String getByte(String altValue, String defValue) { + if (altValue != null && altValue.length() > 0) { + try { + return Character.toString((char) ((Byte.parseByte(altValue) + 256) % 256)); + } catch (NumberFormatException e) { + return altValue.substring(0, 1); + } + } + return defValue; + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveTableFormatDetector.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveTableFormatDetector.java index 13c1c046467728..02b08656d0cb91 100644 --- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveTableFormatDetector.java +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveTableFormatDetector.java @@ -46,6 +46,16 @@ public final class HiveTableFormatDetector { // Some Hudi tables use HoodieParquetInputFormatBase as input format // but cannot be treated as Hudi — read parquet files directly as Hive. hiveFormats.add("org.apache.hudi.hadoop.HoodieParquetInputFormatBase"); + // LZO-compressed text InputFormats (hadoop-lzo / lzo-hadoop). Read-only: all three class names + // contain "text", so HiveFileFormat resolves them to TEXT_FILE, which with LazySimpleSerDe yields + // BE FORMAT_TEXT. Parity with legacy HMSExternalTable.SUPPORTED_HIVE_FILE_FORMATS; without these an + // LZO-text table would fail the (now fail-loud) format check. + // com.hadoop.compression.lzo.LzoTextInputFormat - twitter hadoop-lzo (GPL) + // com.hadoop.mapreduce.LzoTextInputFormat - lzo-hadoop mapreduce API (org.anarres) + // com.hadoop.mapred.DeprecatedLzoTextInputFormat - lzo-hadoop legacy mapred API (org.anarres) + hiveFormats.add("com.hadoop.compression.lzo.LzoTextInputFormat"); + hiveFormats.add("com.hadoop.mapreduce.LzoTextInputFormat"); + hiveFormats.add("com.hadoop.mapred.DeprecatedLzoTextInputFormat"); SUPPORTED_HIVE_INPUT_FORMATS = Collections.unmodifiableSet(hiveFormats); SUPPORTED_HUDI_INPUT_FORMATS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveTableHandle.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveTableHandle.java index b6272ee2409c90..f5ee0d1430af71 100644 --- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveTableHandle.java +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveTableHandle.java @@ -22,6 +22,7 @@ import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.Map; /** @@ -36,6 +37,10 @@ public class HiveTableHandle implements ConnectorTableHandle { private static final long serialVersionUID = 1L; + /** Metastore parameter key marking an ACID table insert-only (vs full-ACID). */ + private static final String TRANSACTIONAL_PROPERTIES = "transactional_properties"; + private static final String INSERT_ONLY = "insert_only"; + private final String dbName; private final String tableName; private final HiveTableType tableType; @@ -47,6 +52,10 @@ public class HiveTableHandle implements ConnectorTableHandle { private final List partitionKeyNames; private final Map sdParameters; private final Map tableParameters; + // Whether the table's first column is a STRING, precomputed at handle build time (the metastore table is + // already loaded there). Reproduces legacy HMSExternalTable.firstColumnIsString, consulted ONLY for the + // OpenX-JSON read_hive_json_in_one_column read-format branch (see HiveFileFormat.detect). + private final boolean firstColumnIsString; // Set after applyFilter for partition pruning private final List prunedPartitions; @@ -67,6 +76,7 @@ private HiveTableHandle(Builder builder) { this.tableParameters = builder.tableParameters != null ? Collections.unmodifiableMap(builder.tableParameters) : Collections.emptyMap(); + this.firstColumnIsString = builder.firstColumnIsString; this.prunedPartitions = builder.prunedPartitions; } @@ -111,6 +121,49 @@ public Map getTableParameters() { return tableParameters; } + /** + * Whether the table's first column is a {@code STRING}, the gate legacy {@code HMSExternalTable} applies + * before reading an OpenX-JSON table as a single CSV column under {@code read_hive_json_in_one_column}. + */ + public boolean isFirstColumnString() { + return firstColumnIsString; + } + + /** + * Whether the metastore parameters mark this table transactional (ACID), replicating Hive's + * {@code AcidUtils.isTransactionalTable} (case-insensitive {@code "true"} under the + * {@code transactional} key, with the upper-cased key as a fallback). + */ + public boolean isTransactional() { + return isTransactionalTable(tableParameters); + } + + /** + * Whether this table is full-ACID (transactional and not insert-only), i.e. its reads must + * apply row-level deletes from delete-delta directories. Mirrors Hive's + * {@code AcidUtils.isFullAcidTable}: transactional and {@code transactional_properties} is not + * {@code insert_only}. + */ + public boolean isFullAcid() { + if (!isTransactional()) { + return false; + } + String props = tableParameters.get(TRANSACTIONAL_PROPERTIES); + return !INSERT_ONLY.equalsIgnoreCase(props); + } + + private static boolean isTransactionalTable(Map tableParameters) { + if (tableParameters == null) { + return false; + } + String value = tableParameters.get(HiveConnectorProperties.CREATE_TRANSACTIONAL); + if (value == null) { + value = tableParameters.get( + HiveConnectorProperties.CREATE_TRANSACTIONAL.toUpperCase(Locale.ROOT)); + } + return "true".equalsIgnoreCase(value); + } + public List getPrunedPartitions() { return prunedPartitions; } @@ -124,6 +177,7 @@ public Builder toBuilder() { b.partitionKeyNames = this.partitionKeyNames; b.sdParameters = this.sdParameters; b.tableParameters = this.tableParameters; + b.firstColumnIsString = this.firstColumnIsString; b.prunedPartitions = this.prunedPartitions; return b; } @@ -146,6 +200,7 @@ public static final class Builder { private List partitionKeyNames; private Map sdParameters; private Map tableParameters; + private boolean firstColumnIsString; private List prunedPartitions; public Builder(String dbName, String tableName, HiveTableType tableType) { @@ -184,6 +239,11 @@ public Builder tableParameters(Map val) { return this; } + public Builder firstColumnIsString(boolean val) { + this.firstColumnIsString = val; + return this; + } + public Builder prunedPartitions(List val) { this.prunedPartitions = val; return this; 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 e2d56238d3f423..e335d0accc871c 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 @@ -32,15 +32,23 @@ */ public final class HiveTextProperties { - // SerDe library class names + // SerDe library class names. Must stay in sync with HiveFileFormat's detection set: every serde the + // detector classifies as TEXT/CSV/JSON needs a branch here, or its text params (delimiters etc.) are + // dropped and BE reads with defaults. public static final String HIVE_TEXT_SERDE = "org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe"; + // MultiDelimitSerDe exists under two package names across Hive versions; both read as FORMAT_TEXT. public static final String HIVE_MULTI_DELIMIT_SERDE = "org.apache.hadoop.hive.contrib.serde2.MultiDelimitSerDe"; + public static final String HIVE_MULTI_DELIMIT_SERDE_SERDE2 = + "org.apache.hadoop.hive.serde2.MultiDelimitSerDe"; public static final String HIVE_OPEN_CSV_SERDE = "org.apache.hadoop.hive.serde2.OpenCSVSerde"; public static final String HIVE_JSON_SERDE = "org.apache.hive.hcatalog.data.JsonSerDe"; + // Legacy hive-2 JSON serde; legacy fe-core maps it to FORMAT_JSON like the hcatalog one. + public static final String LEGACY_HIVE_JSON_SERDE = + "org.apache.hadoop.hive.serde2.JsonSerDe"; public static final String OPENX_JSON_SERDE = "org.openx.data.jsonserde.JsonSerDe"; @@ -49,10 +57,26 @@ public final class HiveTextProperties { private static final String SERIALIZATION_FORMAT = "serialization.format"; private static final String LINE_DELIM = "line.delim"; private static final String MAPKEY_DELIM = "mapkey.delim"; + // Hive3 uses "collection.delim"; Hive2 uses the historically misspelled "colelction.delim". Legacy + // fe-core checks both, so parity requires recognizing both. private static final String COLLECTION_DELIM = "collection.delim"; + private static final String COLLECTION_DELIM_HIVE2 = "colelction.delim"; private static final String ESCAPE_DELIM = "escape.delim"; private static final String SERIALIZATION_NULL_FORMAT = "serialization.null.format"; private static final String SKIP_HEADER_LINE_COUNT = "skip.header.line.count"; + // OpenX JSON serde: skip malformed rows instead of erroring. Mirrors legacy + // HiveProperties.PROP_OPENX_IGNORE_MALFORMED_JSON / DEFAULT_OPENX_IGNORE_MALFORMED_JSON. + private static final String IGNORE_MALFORMED_JSON = "ignore.malformed.json"; + private static final String DEFAULT_IGNORE_MALFORMED_JSON = "false"; + + // Default delimiters, mirroring legacy HiveProperties. These are byte values (Ctrl-A etc.), not the + // literal digit characters Hive stores them as in serialization.format / *.delim SerDe params. + private static final String DEFAULT_FIELD_DELIM = "\001"; + private static final String DEFAULT_LINE_DELIM = "\n"; + private static final String DEFAULT_MAPKV_DELIM = "\003"; + private static final String DEFAULT_COLLECTION_DELIM = "\002"; + private static final String DEFAULT_ESCAPE_DELIM = "\\"; + private static final String DEFAULT_NULL_FORMAT = "\\N"; // CSV SerDe property keys private static final String SEPARATOR_CHAR = "separatorChar"; @@ -80,13 +104,15 @@ public static Map extract(String serDeLib, if (serDeLib == null) { return result; } - if (HIVE_TEXT_SERDE.equals(serDeLib) || HIVE_MULTI_DELIMIT_SERDE.equals(serDeLib)) { - extractTextSerDeProps(sdParams, result, - HIVE_MULTI_DELIMIT_SERDE.equals(serDeLib)); + boolean multiDelimit = HIVE_MULTI_DELIMIT_SERDE.equals(serDeLib) + || HIVE_MULTI_DELIMIT_SERDE_SERDE2.equals(serDeLib); + if (HIVE_TEXT_SERDE.equals(serDeLib) || multiDelimit) { + extractTextSerDeProps(sdParams, tableParams, result, multiDelimit); } else if (HIVE_OPEN_CSV_SERDE.equals(serDeLib)) { extractCsvSerDeProps(sdParams, result); - } else if (HIVE_JSON_SERDE.equals(serDeLib) || OPENX_JSON_SERDE.equals(serDeLib)) { - extractJsonSerDeProps(serDeLib, result); + } else if (HIVE_JSON_SERDE.equals(serDeLib) || LEGACY_HIVE_JSON_SERDE.equals(serDeLib) + || OPENX_JSON_SERDE.equals(serDeLib)) { + extractJsonSerDeProps(serDeLib, sdParams, tableParams, result); } else { return result; } @@ -98,25 +124,31 @@ public static Map extract(String serDeLib, return result; } - private static void extractTextSerDeProps(Map params, - Map result, boolean supportMultiChar) { - // Column separator - String fieldDelim = getFieldDelimiter(params, supportMultiChar); - result.put(PROP_PREFIX + "column_separator", fieldDelim); + private static void extractTextSerDeProps(Map sdParams, + Map tableParams, Map result, boolean supportMultiChar) { + // 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", + getFieldDelimiter(sdParams, tableParams, supportMultiChar)); // Line delimiter - result.put(PROP_PREFIX + "line_delimiter", getLineDelimiter(params)); + result.put(PROP_PREFIX + "line_delimiter", + getByte(serdeVal(sdParams, tableParams, LINE_DELIM), DEFAULT_LINE_DELIM)); // MapKV delimiter - result.put(PROP_PREFIX + "mapkv_delimiter", getMapKvDelimiter(params)); - // Collection delimiter - result.put(PROP_PREFIX + "collection_delimiter", getCollectionDelimiter(params)); - // Escape delimiter - String escape = getParamOrDefault(params, ESCAPE_DELIM, null); - if (escape != null && !escape.isEmpty()) { - result.put(PROP_PREFIX + "escape", escape); + result.put(PROP_PREFIX + "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", + 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)); } - // Null format - result.put(PROP_PREFIX + "null_format", - getParamOrDefault(params, SERIALIZATION_NULL_FORMAT, "\\N")); + // 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); } private static void extractCsvSerDeProps(Map params, @@ -126,44 +158,80 @@ private static void extractCsvSerDeProps(Map params, result.put(PROP_PREFIX + "line_delimiter", getLineDelimiter(params)); String quoteChar = getParamOrDefault(params, QUOTE_CHAR, "\""); result.put(PROP_PREFIX + "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)); String escapeChar = getParamOrDefault(params, ESCAPE_CHAR, "\\"); result.put(PROP_PREFIX + "escape", escapeChar); result.put(PROP_PREFIX + "null_format", ""); } - private static void extractJsonSerDeProps(String serDeLib, - Map result) { + 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); + // 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", + ignoreMalformed != null ? ignoreMalformed : DEFAULT_IGNORE_MALFORMED_JSON); + } } - private static String getFieldDelimiter(Map params, - boolean supportMultiChar) { - String delim = getParamOrDefault(params, FIELD_DELIM, null); - if (delim == null || delim.isEmpty()) { - delim = getParamOrDefault(params, SERIALIZATION_FORMAT, null); - } - if (delim == null || delim.isEmpty()) { - return "\001"; // Default Hive field delimiter (Ctrl-A) - } - if (!supportMultiChar && delim.length() == 1) { - return delim; + private static String getFieldDelimiter(Map sdParams, + Map tableParams, boolean supportMultiChar) { + String delim = serdeVal(sdParams, tableParams, FIELD_DELIM, SERIALIZATION_FORMAT); + if (delim == null) { + delim = ""; } - return delim; + // MultiDelimitSerDe delimiters may be multiple characters; keep them raw (no byte decode). + return supportMultiChar ? delim : getByte(delim, DEFAULT_FIELD_DELIM); } private static String getLineDelimiter(Map params) { return getParamOrDefault(params, LINE_DELIM, "\n"); } - private static String getMapKvDelimiter(Map params) { - return getParamOrDefault(params, MAPKEY_DELIM, "\003"); + /** + * Looks up a SerDe property mirroring legacy {@code HiveMetaStoreClientHelper.getSerdeProperty}: + * table parameters take precedence over StorageDescriptor/SerDeInfo parameters, and the keys are + * tried in order. An empty string counts as present. Returns {@code null} if no key is set. + */ + private static String serdeVal(Map sdParams, Map tableParams, + String... keys) { + for (String key : keys) { + if (tableParams != null && tableParams.get(key) != null) { + return tableParams.get(key); + } + if (sdParams != null && sdParams.get(key) != null) { + return sdParams.get(key); + } + } + return null; } - private static String getCollectionDelimiter(Map params) { - return getParamOrDefault(params, COLLECTION_DELIM, "\002"); + /** + * Decodes a Hive delimiter. Hive stores single-char delimiters as their numeric byte value + * ("1" == 0x01, "9" == 0x09); a non-numeric value is taken literally and truncated to its first + * character; an empty/absent value falls back to {@code defValue}. Mirrors legacy + * {@code HiveMetaStoreClientHelper.getByte}. + */ + private static String getByte(String altValue, String defValue) { + if (altValue != null && !altValue.isEmpty()) { + try { + return Character.toString((char) ((Byte.parseByte(altValue) + 256) % 256)); + } catch (NumberFormatException e) { + return altValue.substring(0, 1); + } + } + return defValue; } private static int getSkipHeaderCount(Map tableParams) { diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveWriteContext.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveWriteContext.java new file mode 100644 index 00000000000000..9d0f9e31070f4a --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveWriteContext.java @@ -0,0 +1,89 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.thrift.TFileType; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Immutable op context for a single hive write, threaded into + * {@link HiveConnectorTransaction#beginWrite}. The connector-internal equivalent of the fe-core + * {@code HiveInsertCommandContext} (which the connector cannot import): it carries the write operation, + * the overwrite mode, the static partition spec (for {@code INSERT OVERWRITE ... PARTITION}), the query + * id (replaces {@code ConnectContext}, per design D4), and the BE-facing file type / staging write path + * (which decides an in-place object-store write from a staged HDFS/local write). + * + *

Peer of {@code IcebergWriteContext}; drops iceberg's {@code branchName}/{@code readSnapshotId} and + * adds hive's {@code queryId}/{@code fileType}/{@code writePath}. INC-4's {@code HiveWritePlanProvider} + * constructs it in {@code buildWriteContext}; INC-3 only consumes it via {@link + * HiveConnectorTransaction#beginWrite}.

+ */ +final class HiveWriteContext { + + private final WriteOperation writeOperation; + private final boolean overwrite; + private final Map staticPartitionValues; + private final String queryId; + private final TFileType fileType; + private final String writePath; + + HiveWriteContext(WriteOperation writeOperation, boolean overwrite, + Map staticPartitionValues, String queryId, + TFileType fileType, String writePath) { + this.writeOperation = writeOperation; + this.overwrite = overwrite; + this.staticPartitionValues = staticPartitionValues == null + ? Collections.emptyMap() : new HashMap<>(staticPartitionValues); + this.queryId = queryId; + this.fileType = fileType; + this.writePath = writePath; + } + + WriteOperation getWriteOperation() { + return writeOperation; + } + + boolean isOverwrite() { + return overwrite; + } + + Map getStaticPartitionValues() { + return staticPartitionValues; + } + + /** An {@code INSERT OVERWRITE ... PARTITION(col=val, ...)} (a non-empty static partition spec). */ + boolean isStaticPartitionOverwrite() { + return overwrite && !staticPartitionValues.isEmpty(); + } + + String getQueryId() { + return queryId; + } + + TFileType getFileType() { + return fileType; + } + + String getWritePath() { + return writePath; + } +} 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 new file mode 100644 index 00000000000000..68adc1471d2b01 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveWritePlanProvider.java @@ -0,0 +1,370 @@ +// 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.ConnectorColumn; +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.ConnectorWriteHandle; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.write.ConnectorSinkPlan; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsPartitionInfo; +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.filesystem.properties.StorageProperties; +import org.apache.doris.thrift.TDataSink; +import org.apache.doris.thrift.TDataSinkType; +import org.apache.doris.thrift.TFileFormatType; +import org.apache.doris.thrift.TFileType; +import org.apache.doris.thrift.THiveBucket; +import org.apache.doris.thrift.THiveColumn; +import org.apache.doris.thrift.THiveColumnType; +import org.apache.doris.thrift.THiveLocationParams; +import org.apache.doris.thrift.THivePartition; +import org.apache.doris.thrift.THiveTableSink; +import org.apache.doris.thrift.TNetworkAddress; + +import com.google.common.base.Strings; +import org.apache.hadoop.fs.Path; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +/** + * Write plan provider for hive non-ACID INSERT / INSERT OVERWRITE. + * + *

Builds the opaque {@link THiveTableSink} for a bound write and binds the write to the current + * {@link HiveConnectorTransaction}: it loads the table under the catalog auth context, opens the write via + * {@link HiveConnectorTransaction#beginWrite} (which re-loads the table and applies the begin-guards, incl. + * the transactional-table reject), then assembles the sink Thrift from the loaded table. The Thrift is + * byte-identical to the legacy fe-core {@code planner.HiveTableSink.bindDataSink} (zero BE change), so the + * BE writer is unaffected by the migration.

+ * + *

Scope. INSERT / OVERWRITE only ({@code THiveTableSink}); an overwriting INSERT is promoted to the + * OVERWRITE operation in {@link #buildWriteContext}. The write distribution capability + * {@link #requiresPartitionHashWrite()} (hash-by-partition, no local sort) matches the legacy + * {@code PhysicalHiveTableSink}.

+ * + *

Gate-closed / dormant. Hive is not in {@code SPI_READY_TYPES} until the P7.4/P7.5 cutover, so + * nothing routes hive writes through this provider yet; {@link #planWrite} requires the executor-bound + * connector transaction and fails loud if absent.

+ */ +public class HiveWritePlanProvider implements ConnectorWritePlanProvider { + + // Staging-directory keys (connector-local copies of HMSExternalCatalog.HIVE_STAGING_DIR / + // DEFAULT_STAGING_BASE_DIR — connectors must not import fe-core). + private static final String HIVE_STAGING_DIR = "hive.staging_dir"; + private static final String DEFAULT_STAGING_BASE_DIR = "/tmp/.doris_staging"; + + private final HmsClient hmsClient; + private final Map properties; + private final ConnectorContext context; + + public HiveWritePlanProvider(HmsClient hmsClient, Map properties, ConnectorContext context) { + this.hmsClient = hmsClient; + this.properties = properties; + this.context = context; + } + + @Override + public ConnectorSinkPlan planWrite(ConnectorSession session, ConnectorWriteHandle handle) { + HiveTableHandle tableHandle = (HiveTableHandle) handle.getTableHandle(); + HiveConnectorTransaction transaction = currentTransaction(session); + + // Load the table under the catalog auth context; it drives both the location resolution + // (buildWriteContext) and the sink assembly (buildSink). beginWrite re-loads it for its own + // begin-guard — the double-load is accepted (mirrors iceberg), keeping the flow simple. + HmsTableInfo table = loadTable(tableHandle); + HiveWriteContext writeContext = buildWriteContext(session, tableHandle, table, handle); + transaction.beginWrite(session, tableHandle.getDbName(), tableHandle.getTableName(), writeContext); + + THiveTableSink sink = buildSink(session, tableHandle, table, handle, writeContext); + TDataSink dataSink = new TDataSink(TDataSinkType.HIVE_TABLE_SINK); + dataSink.setHiveTableSink(sink); + return new ConnectorSinkPlan(dataSink); + } + + @Override + public void appendExplainInfo(StringBuilder output, String prefix, + ConnectorSession session, ConnectorWriteHandle handle) { + // Surface the connector-specific write detail the generic plugin-driven sink line cannot (mirrors the + // legacy HiveTableSink.getExplainString "HIVE TABLE SINK" block). + HiveTableHandle tableHandle = (HiveTableHandle) handle.getTableHandle(); + output.append(prefix).append(" HIVE TABLE: ") + .append(tableHandle.getDbName()).append(".").append(tableHandle.getTableName()).append("\n"); + } + + @Override + public Set supportedOperations() { + return EnumSet.of(WriteOperation.INSERT, WriteOperation.OVERWRITE); + } + + @Override + public boolean requiresParallelWrite() { + return true; + } + + @Override + public boolean requiresFullSchemaWriteOrder() { + return true; + } + + @Override + public boolean requiresPartitionHashWrite() { + return true; + } + + /** + * Builds the op-context threaded into {@link HiveConnectorTransaction#beginWrite}. Port of the legacy + * {@code HiveTableSink.bindDataSink} location block: resolves the BE file type from the raw table + * location and the write path (an in-place normalized path for an object store, or a staging temp path + * for HDFS/local/broker). An overwriting INSERT is promoted to the OVERWRITE operation. Package-private so + * the promotion is directly assertable. + */ + HiveWriteContext buildWriteContext(ConnectorSession session, HiveTableHandle tableHandle, + HmsTableInfo table, ConnectorWriteHandle handle) { + String rawLocation = table.getLocation(); + TFileType fileType = TFileType.valueOf(context.getBackendFileType(rawLocation, Collections.emptyMap())); + String writePath = fileType == TFileType.FILE_S3 + ? context.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(), + session.getQueryId(), fileType, writePath); + } + + // Re-impl of legacy HiveTableSink.createTempPath + LocationPath.getTempWritePath (pure hadoop Path + UUID): + // ///. A relative stagingBaseDir resolves under rawLoc; an absolute one + // keeps rawLoc's scheme/authority. + private String createTempPath(ConnectorSession session, String rawLocation) { + String user = session.getUser(); + String stagingBaseDir = properties.getOrDefault(HIVE_STAGING_DIR, DEFAULT_STAGING_BASE_DIR); + Path prefix = new Path(stagingBaseDir, user); + Path temp = new Path(new Path(rawLocation, prefix), UUID.randomUUID().toString().replace("-", "")); + return temp.toString(); + } + + private THiveTableSink buildSink(ConnectorSession session, HiveTableHandle tableHandle, + HmsTableInfo table, ConnectorWriteHandle handle, HiveWriteContext writeContext) { + THiveTableSink tSink = new THiveTableSink(); + tSink.setDbName(tableHandle.getDbName()); + tSink.setTableName(tableHandle.getTableName()); + + // Columns: data columns tagged REGULAR first, then partition keys tagged PARTITION_KEY (HMS order). + tSink.setColumns(buildColumns(table)); + + // Existing partitions (partitioned table only; empty otherwise). + tSink.setPartitions(buildExistingPartitions(table)); + + // Bucket info. + THiveBucket bucketInfo = new THiveBucket(); + bucketInfo.setBucketedBy(table.getBucketCols()); + bucketInfo.setBucketCount(table.getNumBuckets()); + tSink.setBucketInfo(bucketInfo); + + // File format (ports the LZO reject + supported-set validation) + compression. + TFileFormatType formatType = HiveSinkHelper.getTFileFormatType(table.getInputFormat()); + tSink.setFileFormat(formatType); + setCompressType(tSink, formatType, table, session); + + // SerDe delimiters. + tSink.setSerdeProperties(HiveSinkHelper.buildSerDeProperties(table)); + + // Location: an object-store write goes in-place (write == target == normalized, original == raw); an + // HDFS/local/broker write goes to a staging temp path (write == original == staging, target == raw). + THiveLocationParams locationParams = new THiveLocationParams(); + String rawLocation = table.getLocation(); + if (writeContext.getFileType() == TFileType.FILE_S3) { + locationParams.setWritePath(writeContext.getWritePath()); + locationParams.setOriginalWritePath(rawLocation); + locationParams.setTargetPath(writeContext.getWritePath()); + } else { + locationParams.setWritePath(writeContext.getWritePath()); + locationParams.setOriginalWritePath(writeContext.getWritePath()); + locationParams.setTargetPath(rawLocation); + } + locationParams.setFileType(writeContext.getFileType()); + tSink.setLocation(locationParams); + + // Broker addresses only for a broker backend (fails loud when empty, mirroring legacy). + if (writeContext.getFileType() == TFileType.FILE_BROKER) { + tSink.setBrokerAddresses(resolveBrokerAddresses()); + } + + // Hadoop config (BE-canonical static creds; hive has no vended overlay). + tSink.setHadoopConfig(buildHadoopConfig()); + + tSink.setOverwrite(handle.isOverwrite()); + return tSink; + } + + private static List buildColumns(HmsTableInfo table) { + List columns = new ArrayList<>(); + for (ConnectorColumn col : table.getColumns()) { + THiveColumn tHiveColumn = new THiveColumn(); + tHiveColumn.setName(col.getName()); + tHiveColumn.setColumnType(THiveColumnType.REGULAR); + columns.add(tHiveColumn); + } + for (ConnectorColumn col : table.getPartitionKeys()) { + THiveColumn tHiveColumn = new THiveColumn(); + tHiveColumn.setName(col.getName()); + tHiveColumn.setColumnType(THiveColumnType.PARTITION_KEY); + columns.add(tHiveColumn); + } + return columns; + } + + // Port of legacy HiveTableSink.setPartitionValues: for a partitioned table, list live partitions and + // convert each to a THivePartition (values + per-partition file format + in-place location). Live/uncached + // (matches the scan path; registered deviation DV-INC4-livepart). HmsClient calls self-authenticate. + private List buildExistingPartitions(HmsTableInfo table) { + List partitions = new ArrayList<>(); + if (table.getPartitionKeys().isEmpty()) { + return partitions; + } + List partitionNames = hmsClient.listPartitionNames( + table.getDbName(), table.getTableName(), -1); + List hmsPartitions = hmsClient.getPartitions( + table.getDbName(), table.getTableName(), partitionNames); + for (HmsPartitionInfo partition : hmsPartitions) { + THivePartition hivePartition = new THivePartition(); + hivePartition.setValues(partition.getValues()); + hivePartition.setFileFormat(HiveSinkHelper.getTFileFormatType(partition.getInputFormat())); + THiveLocationParams locationParams = new THiveLocationParams(); + String location = partition.getLocation(); + // The write and target path of an existing partition are the same (BE reads it in place). + locationParams.setWritePath(location); + locationParams.setTargetPath(location); + locationParams.setFileType(TFileType.valueOf( + context.getBackendFileType(location, Collections.emptyMap()))); + hivePartition.setLocation(locationParams); + partitions.add(hivePartition); + } + return partitions; + } + + // Port of legacy HiveTableSink.setCompressType: the compression codec is read from the table parameters by + // format (orc.compress / parquet.compression / text.compression, the text one falling back to the session + // default), then mapped to the BE compress type. + private void setCompressType(THiveTableSink tSink, TFileFormatType formatType, + HmsTableInfo table, ConnectorSession session) { + Map params = table.getParameters(); + String compressType; + switch (formatType) { + case FORMAT_ORC: + compressType = params.get("orc.compress"); + break; + case FORMAT_PARQUET: + compressType = params.get("parquet.compression"); + break; + case FORMAT_CSV_PLAIN: + case FORMAT_TEXT: + compressType = params.get("text.compression"); + if (Strings.isNullOrEmpty(compressType)) { + compressType = resolveTextCompressionDefault(session); + } + break; + default: + compressType = "plain"; + break; + } + tSink.setCompressionType(HiveSinkHelper.getTFileCompressType(compressType)); + } + + // Re-impl of legacy SessionVariable.hiveTextCompression() (the "uncompressed" alias maps to "plain"); the + // value rides on the session properties threaded from the engine. + private static String resolveTextCompressionDefault(ConnectorSession session) { + String textCompression = session.getSessionProperties() + .get(HiveConnectorProperties.SESSION_HIVE_TEXT_COMPRESSION); + if (HiveConnectorProperties.TEXT_COMPRESSION_UNCOMPRESSED.equals(textCompression)) { + return HiveConnectorProperties.TEXT_COMPRESSION_PLAIN; + } + return textCompression; + } + + // Mirror iceberg buildHadoopConfig: BE-canonical static catalog creds (AWS_* for object stores, dfs/hadoop + // for HDFS). Hive has no vended overlay. + private Map buildHadoopConfig() { + Map merged = new HashMap<>(); + if (context != null) { + // Backend properties from the catalog's parsed storage map, the SAME source the hive READ path uses + // (HiveScanPlanProvider.getBackendStorageProperties) and legacy HiveTableSink emitted. It carries the + // resolved hadoop./dfs./fs./juicefs.* passthrough, so it is the sole source of fs..impl for + // schemes whose fe-filesystem plugin has no typed bind() (jfs, oss-hdfs) — without it a jfs write ships + // 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()) { + sp.toBackendProperties().ifPresent(b -> merged.putAll(b.toMap())); + } + } + return merged; + } + + // Resolve the broker backend addresses through the neutral SPI; fail loud when none is resolved (the same + // message legacy BaseExternalTableDataSink.getBrokerAddresses threw), so a broker write never ships BE an + // empty broker list. + private List resolveBrokerAddresses() { + List addresses = context.getBrokerAddresses(); + if (addresses.isEmpty()) { + throw new DorisConnectorException("No alive broker."); + } + List result = new ArrayList<>(addresses.size()); + for (ConnectorBrokerAddress address : addresses) { + result.add(new TNetworkAddress(address.getHost(), address.getPort())); + } + return result; + } + + private HmsTableInfo loadTable(HiveTableHandle tableHandle) { + try { + return context.executeAuthenticated( + () -> hmsClient.getTable(tableHandle.getDbName(), tableHandle.getTableName())); + } catch (Exception e) { + throw new DorisConnectorException("Failed to load hive table " + + tableHandle.getDbName() + "." + tableHandle.getTableName() + ": " + e.getMessage(), e); + } + } + + private HiveConnectorTransaction currentTransaction(ConnectorSession session) { + Optional transaction = session.getCurrentTransaction(); + if (!transaction.isPresent()) { + throw new DorisConnectorException( + "Hive write requires an active connector transaction bound to the session; none is " + + "present. The executor must open it via beginTransaction and bind it to the " + + "session (wired at the hive cutover)."); + } + return (HiveConnectorTransaction) transaction.get(); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveWriteUtils.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveWriteUtils.java new file mode 100644 index 00000000000000..8e4c46dee4c92e --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveWriteUtils.java @@ -0,0 +1,268 @@ +// 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.thrift.THivePartitionUpdate; + +import org.apache.hadoop.fs.Path; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Pure, side-effect-free helpers for the Hive connector write path, ported verbatim from the legacy + * fe-core {@code HMSTransaction}. Deliberately fe-core-free: the only dependencies are the Hadoop + * {@code Path}, {@code java.net.URI}, and the shared thrift {@code THivePartitionUpdate}. Consumed + * by the (in-progress) connector write transaction and its staging-cleanup logic. + */ +final class HiveWriteUtils { + private HiveWriteUtils() { + } + + /** + * Merges partition updates that target the same partition name. For collisions the file sizes + * and row counts are summed and the pending MPU-upload and file-name lists are concatenated onto + * the first-seen update; distinct names are kept as-is. Mirrors the legacy + * {@code HMSTransaction.mergePartitions}. + * + *

The first-seen update's {@code fileNames} / {@code s3MpuPendingUploads} lists are mutated in + * place, so they must be mutable (thrift deserialization produces mutable {@code ArrayList}s). + */ + static List mergePartitions(List hivePartitionUpdates) { + Map merged = new HashMap<>(); + for (THivePartitionUpdate pu : hivePartitionUpdates) { + if (merged.containsKey(pu.getName())) { + THivePartitionUpdate old = merged.get(pu.getName()); + old.setFileSize(old.getFileSize() + pu.getFileSize()); + old.setRowCount(old.getRowCount() + pu.getRowCount()); + if (old.getS3MpuPendingUploads() != null && pu.getS3MpuPendingUploads() != null) { + old.getS3MpuPendingUploads().addAll(pu.getS3MpuPendingUploads()); + } + old.getFileNames().addAll(pu.getFileNames()); + } else { + merged.put(pu.getName(), pu); + } + } + return new ArrayList<>(merged.values()); + } + + /** + * Returns true when {@code child} is a strict subdirectory of {@code parent} on the same file + * system (matching scheme + authority, path-prefix under a {@code /} boundary). + */ + static boolean isSubDirectory(String parent, String child) { + if (parent == null || child == null) { + return false; + } + Path parentPath = new Path(parent); + Path childPath = new Path(child); + URI parentUri = parentPath.toUri(); + URI childUri = childPath.toUri(); + if (!sameFileSystem(parentUri, childUri)) { + return false; + } + String parentPathValue = normalizePath(parentUri.getPath()); + String childPathValue = normalizePath(childUri.getPath()); + if (parentPathValue.isEmpty() || childPathValue.isEmpty()) { + return false; + } + return !parentPathValue.equals(childPathValue) + && childPathValue.startsWith(parentPathValue + "/"); + } + + /** + * Returns the first-level child path of {@code parent} that contains {@code child}, + * or null if {@code child} is not a subdirectory of {@code parent}. + * Example: parent=/warehouse/table, child=/warehouse/table/.doris_staging/user/uuid + * returns /warehouse/table/.doris_staging. + */ + static String getImmediateChildPath(String parent, String child) { + if (!isSubDirectory(parent, child)) { + return null; + } + Path parentPath = new Path(parent); + URI parentUri = parentPath.toUri(); + URI childUri = new Path(child).toUri(); + String parentPathValue = normalizePath(parentUri.getPath()); + String childPathValue = normalizePath(childUri.getPath()); + String relative = childPathValue.substring(parentPathValue.length() + 1); + int slashIndex = relative.indexOf("/"); + String firstComponent = slashIndex == -1 ? relative : relative.substring(0, slashIndex); + return new Path(parentPath, firstComponent).toString(); + } + + /** + * Returns true when {@code left} and {@code right} resolve to the same normalized path on the + * same file system. Null-safe: two nulls are equal, one null is not. + */ + static boolean pathsEqual(String left, String right) { + if (left == null || right == null) { + return left == null && right == null; + } + URI leftUri = new Path(left).toUri(); + URI rightUri = new Path(right).toUri(); + if (!sameFileSystem(leftUri, rightUri)) { + return false; + } + return normalizePath(leftUri.getPath()).equals(normalizePath(rightUri.getPath())); + } + + /** + * Compares two URI strings for equality with special handling for the "s3" scheme. Byte-faithful port + * of fe-core {@code PathUtils.equalsIgnoreSchemeIfOneIsS3} (NOT the same as {@link #pathsEqual}, which + * treats a scheme mismatch as a different file system): in the BE all object stores are unified under + * the "s3" URI scheme, so a path written with a different underlying scheme (e.g. "oss://") is the SAME + * physical location as the "s3://" form. Used by the committer's {@code needRename} decision so an + * in-place object-store write is not needlessly renamed. + * + *

Rules: same scheme -> case-insensitive full-string compare; different schemes but one is "s3" + * -> compare only authority + path (trailing slashes stripped); otherwise not equal. + */ + static boolean equalsIgnoreSchemeIfOneIsS3(String p1, String p2) { + if (p1 == null || p2 == null) { + return p1 == null && p2 == null; + } + try { + URI uri1 = new URI(p1); + URI uri2 = new URI(p2); + + String scheme1 = uri1.getScheme(); + String scheme2 = uri2.getScheme(); + + // If schemes are equal, compare the full URI strings ignoring case + if (scheme1 != null && scheme1.equalsIgnoreCase(scheme2)) { + return p1.equalsIgnoreCase(p2); + } + + // If schemes differ but one is "s3", compare only authority and path ignoring scheme + if ("s3".equalsIgnoreCase(scheme1) || "s3".equalsIgnoreCase(scheme2)) { + String auth1 = stripTrailingSlashes(uri1.getAuthority()); + String auth2 = stripTrailingSlashes(uri2.getAuthority()); + String path1 = stripTrailingSlashes(uri1.getPath()); + String path2 = stripTrailingSlashes(uri2.getPath()); + return Objects.equals(auth1, auth2) && Objects.equals(path1, path2); + } + + // Otherwise, URIs are not equal + return false; + } catch (URISyntaxException e) { + // If URI parsing fails, fallback to simple case-insensitive string comparison + return p1.equalsIgnoreCase(p2); + } + } + + /** + * Splits a Hive partition name ("c1=a/c2=b/c3=c") into its ordered values ("a", "b", "c"), URL-decoding + * each value with {@link #unescapePathName}. Byte-faithful port of fe-core {@code HiveUtil.toPartitionValues} + * (which delegated to Hive's {@code FileUtils.unescapePathName}). Ported inline so the connector needs no + * hive-common dependency. Used to key the write transaction's per-partition action map and to build the + * partition-value argument passed to the metastore write primitives. + */ + static List toPartitionValues(String partitionName) { + List result = new ArrayList<>(); + int start = 0; + while (true) { + while (start < partitionName.length() && partitionName.charAt(start) != '=') { + start++; + } + start++; + int end = start; + while (end < partitionName.length() && partitionName.charAt(end) != '/') { + end++; + } + if (start > partitionName.length()) { + break; + } + result.add(unescapePathName(partitionName.substring(start, end))); + start = end + 1; + } + return result; + } + + /** + * URL-decodes a Hive-escaped path component (e.g. "a%2Fb" -> "a/b"). Byte-faithful port of Hive's + * {@code org.apache.hadoop.hive.common.FileUtils.unescapePathName}, inlined to avoid a hive-common + * dependency. + */ + static String unescapePathName(String path) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + if (c == '%' && i + 2 < path.length()) { + int code = -1; + try { + code = Integer.parseInt(path.substring(i + 1, i + 3), 16); + } catch (Exception e) { + code = -1; + } + if (code >= 0) { + sb.append((char) code); + i += 2; + continue; + } + } + sb.append(c); + } + return sb.toString(); + } + + /** Strip trailing slashes for {@link #equalsIgnoreSchemeIfOneIsS3} (mirrors PathUtils.normalize). */ + private static String stripTrailingSlashes(String s) { + if (s == null) { + return ""; + } + String trimmed = s.replaceAll("/+$", ""); + return trimmed.isEmpty() ? "" : trimmed; + } + + private static boolean sameFileSystem(URI left, URI right) { + String leftScheme = normalizeUriPart(left.getScheme()); + String rightScheme = normalizeUriPart(right.getScheme()); + if (!leftScheme.isEmpty() && !rightScheme.isEmpty() + && !leftScheme.equalsIgnoreCase(rightScheme)) { + return false; + } + String leftAuthority = normalizeUriPart(left.getAuthority()); + String rightAuthority = normalizeUriPart(right.getAuthority()); + if (!leftAuthority.isEmpty() && !rightAuthority.isEmpty() + && !leftAuthority.equalsIgnoreCase(rightAuthority)) { + return false; + } + return true; + } + + private static String normalizeUriPart(String value) { + return value == null ? "" : value; + } + + private static String normalizePath(String path) { + if (path == null || path.isEmpty()) { + return ""; + } + int end = path.length(); + while (end > 1 && path.charAt(end - 1) == '/') { + end--; + } + return path.substring(0, end); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HudiSiblingProperties.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HudiSiblingProperties.java new file mode 100644 index 00000000000000..3b7bd7b366a832 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HudiSiblingProperties.java @@ -0,0 +1,54 @@ +// 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 java.util.HashMap; +import java.util.Map; + +/** + * Synthesizes the catalog-property map for the embedded Hudi sibling connector that a flipped HMS + * gateway delegates its hudi-on-HMS tables to. Mirrors {@link IcebergSiblingProperties} so the two sibling + * paths read identically at the {@code getOrCreate*Sibling} seams. + * + *

The sibling is built once per gateway catalog (not per table) via + * {@code ConnectorContext.createSiblingConnector("hudi", synthesize(catalogProps))}, sharing the gateway's + * context (metastore auth + storage). Unlike the iceberg sibling there is no flavor key to inject: hudi + * has no {@code iceberg.catalog.type} analogue — {@code HudiConnector.createClient} reads + * {@code hive.metastore.uris}/{@code uri} plus the raw {@code hadoop.*}/{@code fs.*}/{@code dfs.*}/{@code hive.*}/ + * {@code s3.*} storage + kerberos passthrough straight from this map. So synthesis is a plain verbatim copy of + * the gateway catalog's whole property map. Carrying the whole map (rather than a hand-picked subset) is the + * robust choice: it cannot silently drop a connectivity key (the {@code uri} short form, an HDFS-HA + * {@code dfs.*} set, an S3 endpoint override, a kerberos variant, ...); the connector ignores keys it does not + * recognize. + * + *

A defensive copy is returned so the gateway's own (unmodifiable, shared) property map is never aliased into + * the sibling connector. + */ +final class HudiSiblingProperties { + + private HudiSiblingProperties() { + } + + /** + * Returns a NEW property map = the gateway catalog's properties verbatim (a defensive copy). The input is + * never mutated. No flavor key is injected — a hudi-on-HMS sibling needs none. + */ + static Map synthesize(Map gatewayCatalogProperties) { + return new HashMap<>(gatewayCatalogProperties); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/IcebergSiblingProperties.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/IcebergSiblingProperties.java new file mode 100644 index 00000000000000..607b532b79bcbc --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/IcebergSiblingProperties.java @@ -0,0 +1,66 @@ +// 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 java.util.HashMap; +import java.util.Map; + +/** + * Synthesizes the catalog-property map for the embedded Iceberg sibling connector that a flipped HMS + * gateway delegates its iceberg-on-HMS tables to. + * + *

The sibling is built once per gateway catalog (not per table) via + * {@code ConnectorContext.createSiblingConnector("iceberg", synthesize(catalogProps))}, sharing the gateway's + * context (metastore auth + storage). The only synthesis needed is to declare the iceberg catalog flavor + * as {@code hms}: the Iceberg connector then reads {@code hive.metastore.uris}/{@code uri}, + * {@code hive.conf.resources}, and the raw {@code hive.*}/{@code fs.*}/{@code dfs.*}/{@code hadoop.*} storage + + * kerberos passthrough straight from this map. That is the SAME map shape a native + * {@code type=iceberg, iceberg.catalog.type=hms} catalog already hands the connector — fe-core builds that + * connector from the full catalog property map ({@code PluginDrivenExternalCatalog + * .createConnectorFromProperties}) — so carrying the gateway catalog's whole property map verbatim and injecting + * the flavor is both sufficient and exactly what the connector expects. Carrying the whole map (rather than a + * hand-picked subset) is also the robust choice: it cannot silently drop a connectivity key (the {@code uri} + * short form, an HDFS-HA {@code dfs.*} set, an S3 endpoint override, a kerberos variant, …). The connector + * ignores keys it does not recognize; its {@code create()} path does no property validation. + * + *

The flavor key/value are hardcoded literals on purpose: the Iceberg connector's + * {@code IcebergConnectorProperties} constants live in the iceberg plugin's child-first classloader and are not + * visible from the hive loader. + */ +final class IcebergSiblingProperties { + + // Literals of the iceberg-plugin IcebergConnectorProperties.ICEBERG_CATALOG_TYPE / TYPE_HMS: those constants + // live in the iceberg plugin's child-first classloader and are not visible from the hive loader. + static final String ICEBERG_CATALOG_TYPE_KEY = "iceberg.catalog.type"; + static final String ICEBERG_CATALOG_TYPE_HMS = "hms"; + + private IcebergSiblingProperties() { + } + + /** + * Returns a NEW property map = the gateway catalog's properties verbatim with the iceberg catalog flavor + * forced to {@code hms}. The input is never mutated (the gateway holds it unmodifiable and shared). An + * existing {@code iceberg.catalog.type} is overridden unconditionally — an iceberg-on-HMS sibling is always + * the hms flavor. + */ + static Map synthesize(Map gatewayCatalogProperties) { + Map siblingProperties = new HashMap<>(gatewayCatalogProperties); + siblingProperties.put(ICEBERG_CATALOG_TYPE_KEY, ICEBERG_CATALOG_TYPE_HMS); + return siblingProperties; + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/NameMapping.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/NameMapping.java new file mode 100644 index 00000000000000..bc715a6207d848 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/NameMapping.java @@ -0,0 +1,104 @@ +// 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 java.util.Objects; + +/** + * Mapping between the local (Doris) and remote (metastore) database/table names of a Hive + * write/read transaction. This is a plugin-local, fe-core-free copy of {@code + * org.apache.doris.datasource.NameMapping} (JDK-only, no lombok/guava), used as the identity key of + * the connector transaction's per-table / per-partition action maps: the remote names drive the + * actual metastore calls, and the local names surface in diagnostics. + */ +public final class NameMapping { + private final long ctlId; + private final String localDbName; + private final String localTblName; + private final String remoteDbName; + private final String remoteTblName; + + public NameMapping(long ctlId, String localDbName, String localTblName, + String remoteDbName, String remoteTblName) { + this.ctlId = ctlId; + this.localDbName = localDbName; + this.localTblName = localTblName; + this.remoteDbName = remoteDbName; + this.remoteTblName = remoteTblName; + } + + public long getCtlId() { + return ctlId; + } + + public String getLocalDbName() { + return localDbName; + } + + public String getLocalTblName() { + return localTblName; + } + + public String getRemoteDbName() { + return remoteDbName; + } + + public String getRemoteTblName() { + return remoteTblName; + } + + public String getFullLocalName() { + return String.format("%s.%s", localDbName, localTblName); + } + + public String getFullRemoteName() { + return String.format("%s.%s", remoteDbName, remoteTblName); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof NameMapping)) { + return false; + } + NameMapping that = (NameMapping) o; + return ctlId == that.ctlId + && localDbName.equals(that.localDbName) + && localTblName.equals(that.localTblName) + && remoteDbName.equals(that.remoteDbName) + && remoteTblName.equals(that.remoteTblName); + } + + @Override + public int hashCode() { + return Objects.hash(ctlId, localDbName, localTblName, remoteDbName, remoteTblName); + } + + @Override + public String toString() { + return "NameMapping{" + + "ctlId=" + ctlId + + ", localDbName='" + localDbName + '\'' + + ", localTblName='" + localTblName + '\'' + + ", remoteDbName='" + remoteDbName + '\'' + + ", remoteTblName='" + remoteTblName + '\'' + + '}'; + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/SiblingOwner.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/SiblingOwner.java new file mode 100644 index 00000000000000..890b5e9b8f712e --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/SiblingOwner.java @@ -0,0 +1,60 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.Connector; + +/** + * The embedded sibling connector that owns a foreign (iceberg/hudi-on-HMS) table, paired with its stable owner + * label. Produced by the 3-way ownsHandle dispatch ({@code HiveConnector.resolveSiblingOwnerLabeled}) so the + * per-handle gateway seams learn WHICH sibling owns a handle AND its label in a single peek (no force-build, no + * identity comparison). + * + *

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

The connector is held ONLY as the parent-first {@link Connector} interface — never cast (its concrete + * iceberg/hudi types are invisible across the plugin classloader split; a cast would CCE). + */ +final class SiblingOwner { + + /** Owner label for the embedded iceberg sibling — the funnel-key discriminator, shared by both routes. */ + static final String ICEBERG_LABEL = "iceberg"; + /** Owner label for the embedded hudi sibling — the funnel-key discriminator, shared by both routes. */ + static final String HUDI_LABEL = "hudi"; + + private final Connector connector; + private final String label; + + SiblingOwner(Connector connector, String label) { + this.connector = connector; + this.label = label; + } + + Connector connector() { + return connector; + } + + String label() { + return label; + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/FakeConnectorContext.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/FakeConnectorContext.java new file mode 100644 index 00000000000000..d1baf0771dbb26 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/FakeConnectorContext.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.hive; + +import org.apache.doris.connector.spi.ConnectorContext; + +import java.util.Collections; +import java.util.Map; + +/** + * Minimal {@link ConnectorContext} test double: carries a fixed catalog identity and an environment map (the + * channel through which fe-core threads the FE-global CREATE TABLE defaults). Everything else uses the + * interface defaults. + */ +public class FakeConnectorContext implements ConnectorContext { + + private final String catalogName; + private final long catalogId; + private final Map environment; + + public FakeConnectorContext() { + this("test_catalog", 0L, Collections.emptyMap()); + } + + public FakeConnectorContext(Map environment) { + this("test_catalog", 0L, environment); + } + + public FakeConnectorContext(String catalogName, long catalogId, Map environment) { + this.catalogName = catalogName; + this.catalogId = catalogId; + this.environment = environment == null ? Collections.emptyMap() : environment; + } + + @Override + public String getCatalogName() { + return catalogName; + } + + @Override + public long getCatalogId() { + return catalogId; + } + + @Override + public Map getEnvironment() { + return environment; + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/FakeFileSystem.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/FakeFileSystem.java new file mode 100644 index 00000000000000..27d2ee375588ee --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/FakeFileSystem.java @@ -0,0 +1,183 @@ +// 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.filesystem.DorisInputFile; +import org.apache.doris.filesystem.DorisOutputFile; +import org.apache.doris.filesystem.FileEntry; +import org.apache.doris.filesystem.FileIterator; +import org.apache.doris.filesystem.FileSystem; +import org.apache.doris.filesystem.Location; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * A recording {@link FileSystem} test double for the hive connector's file-listing tests (the module has no + * Mockito). Configurable to return a canned {@link FileEntry} listing from {@link #list}, or to fail at the + * resolution boundary ({@link #forLocation}) or the listing boundary ({@link #list}) — exercising the two + * failure semantics {@code HiveFileListingCache.listFromFileSystem} keeps distinct. + * + *

{@link #listFiles} deliberately throws {@link AssertionError}: the production lister MUST list via the + * literal {@link #list} (matching the old {@code FileSystem.listStatus}), never the glob-aware {@code listFiles} + * override that a real per-scheme filesystem provides — so any test that lists through this double also pins that + * contract. + */ +final class FakeFileSystem implements FileSystem { + + private List entries = Collections.emptyList(); + // Tree mode: a per-location listing, keyed by Location.uri(). Populated => list(loc) returns tree.get(uri) + // (empty if absent); empty (the default) => list() falls back to the flat `entries`, so the existing flat-fake + // tests are untouched. Needed to model recursive descent, where each sub-directory must list its OWN entries. + private Map> tree = Collections.emptyMap(); + private IOException forLocationError; + private IOException listError; + // Per-location list() failures (uri -> error): models "top-level dir lists fine, one sub-directory fails", + // which the single global listError cannot (it fails every list()). + private final Map listErrorByLocation = new HashMap<>(); + + FakeFileSystem withEntries(FileEntry... e) { + this.entries = Arrays.asList(e); + return this; + } + + /** Tree mode: {@code list(loc)} returns the entries mapped to {@code loc.uri()} (empty if unmapped). */ + FakeFileSystem withTree(Map> t) { + this.tree = t; + return this; + } + + /** Makes {@link #list} throw only for {@code location} (a single failing directory among healthy ones). */ + FakeFileSystem failListAt(String location, IOException e) { + this.listErrorByLocation.put(location, e); + return this; + } + + /** Makes {@link #forLocation} throw — the SYSTEMIC (scheme/storage resolution) boundary. */ + FakeFileSystem failForLocation(IOException e) { + this.forLocationError = e; + return this; + } + + /** Makes {@link #list} throw — the LOCAL per-directory boundary (or, with UnsupportedFileSystemException, + * the lazily-surfaced systemic scheme-not-registered case). */ + FakeFileSystem failList(IOException e) { + this.listError = e; + return this; + } + + static FileEntry file(String uri, long length, long modificationTime) { + return new FileEntry(Location.of(uri), length, false, modificationTime, null); + } + + static FileEntry dir(String uri) { + return new FileEntry(Location.of(uri), 0L, true, 0L, null); + } + + @Override + public FileSystem forLocation(Location location) throws IOException { + if (forLocationError != null) { + throw forLocationError; + } + return this; + } + + @Override + public FileIterator list(Location location) throws IOException { + IOException perLocation = listErrorByLocation.get(location.uri()); + if (perLocation != null) { + throw perLocation; + } + if (listError != null) { + throw listError; + } + if (!tree.isEmpty()) { + return new ListFileIterator(tree.getOrDefault(location.uri(), Collections.emptyList()).iterator()); + } + return new ListFileIterator(entries.iterator()); + } + + @Override + public List listFiles(Location dir) { + throw new AssertionError( + "listFromFileSystem must list via the literal list(), never the glob-aware listFiles()"); + } + + // ---- unused abstract methods (no listing test drives them) ---- + + @Override + public boolean exists(Location location) { + throw new UnsupportedOperationException(); + } + + @Override + public void mkdirs(Location location) { + throw new UnsupportedOperationException(); + } + + @Override + public void delete(Location location, boolean recursive) { + throw new UnsupportedOperationException(); + } + + @Override + public void rename(Location src, Location dst) { + throw new UnsupportedOperationException(); + } + + @Override + public DorisInputFile newInputFile(Location location) { + throw new UnsupportedOperationException(); + } + + @Override + public DorisOutputFile newOutputFile(Location location) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() { + } + + private static final class ListFileIterator implements FileIterator { + private final Iterator it; + + ListFileIterator(Iterator it) { + this.it = it; + } + + @Override + public boolean hasNext() { + return it.hasNext(); + } + + @Override + public FileEntry next() { + return it.next(); + } + + @Override + public void close() { + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveAcidUtilTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveAcidUtilTest.java new file mode 100644 index 00000000000000..7be2319459b6a1 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveAcidUtilTest.java @@ -0,0 +1,263 @@ +// 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.hms.HmsAcidConstants; +import org.apache.doris.filesystem.FileEntry; +import org.apache.doris.filesystem.FileSystem; +import org.apache.doris.filesystem.Location; +import org.apache.doris.filesystem.local.LocalFileSystem; + +import org.apache.hadoop.hive.common.ValidReadTxnList; +import org.apache.hadoop.hive.common.ValidReaderWriteIdList; +import org.apache.hadoop.hive.common.ValidTxnList; +import org.apache.hadoop.hive.common.ValidWriteIdList; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.BitSet; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Tests the pure ACID directory descent {@link HiveAcidUtil#getAcidState} against a real Doris + * {@link FileSystem} (the test-only {@link LocalFileSystem}) over a local temp tree — the Doris equivalent of the + * old Hadoop {@code LocalFileSystem}, now that {@link HiveAcidUtil} lists via the engine-injected Doris filesystem. + * + *

WHY: for a transactional Hive table the reader must reconstruct the correct snapshot from the + * base/delta/delete-delta directory layout — pick the best valid base, layer on the deltas whose + * write-id range is still valid, drop obsolete/out-of-snapshot directories, and hand the BE the + * delete-delta file names so it can subtract deleted rows. A wrong base or a dropped delta silently + * returns stale or over-/under-deleted data. These tests pin that selection algorithm.

+ * + *

The filesystem is a {@link LiteralListingLocalFileSystem}: it forbids the glob-aware + * {@link FileSystem#listFiles}, so every test here also pins that {@link HiveAcidUtil} lists via the literal + * {@link FileSystem#list} (matching the old {@code FileSystem.listStatus}).

+ */ +public class HiveAcidUtilTest { + + @TempDir + java.nio.file.Path tempDir; + + private FileSystem localFs() { + return new LiteralListingLocalFileSystem(); + } + + /** Creates {@code //} with 1 byte of content. */ + private void createBucketFile(String dir, String fileName) throws IOException { + java.nio.file.Path d = tempDir.resolve(dir); + Files.createDirectories(d); + Files.write(d.resolve(fileName), new byte[] {1}); + } + + /** Snapshot with all visibility txns valid and write-ids valid up to {@code highWatermark}. */ + private Map snapshot(long highWatermark) { + ValidTxnList validTxnList = + new ValidReadTxnList(new long[0], new BitSet(), Long.MAX_VALUE, Long.MAX_VALUE); + ValidWriteIdList validWriteIdList = + new ValidReaderWriteIdList("db.tbl", new long[0], new BitSet(), highWatermark); + Map ids = new HashMap<>(); + ids.put(HmsAcidConstants.VALID_TXNS_KEY, validTxnList.writeToString()); + ids.put(HmsAcidConstants.VALID_WRITEIDS_KEY, validWriteIdList.writeToString()); + return ids; + } + + @Test + public void testBestBaseWithDeltaAndDeleteDelta() throws IOException { + // Best base = base_5 (base_2 superseded); delta_6 layered on; delete_delta_6 survives via the + // split-update pairing with delta_6; delta_9 is out of the write-id=6 snapshot; the staging dir + // and the _flush_length side file are ignored. + createBucketFile("base_0000005", "bucket_00000"); + createBucketFile("base_0000002", "bucket_00000"); + createBucketFile("delta_0000006_0000006", "bucket_00000"); + createBucketFile("delta_0000006_0000006", "bucket_00000_flush_length"); + createBucketFile("delete_delta_0000006_0000006", "bucket_00000"); + createBucketFile("delta_0000009_0000009", "bucket_00000"); + createBucketFile(".hive-staging_hive_2026-01-01", "junk"); + + HiveAcidUtil.AcidState state = HiveAcidUtil.getAcidState( + localFs(), tempDir.toString(), snapshot(6L), true); + + List dataFiles = state.getDataFiles(); + Assertions.assertEquals(2, dataFiles.size(), + "surviving data files must be exactly base_5 + delta_6 bucket files"); + boolean hasBase5 = false; + boolean hasDelta6 = false; + for (FileEntry f : dataFiles) { + String p = f.location().uri(); + Assertions.assertFalse(p.contains("base_0000002"), "superseded base must be dropped: " + p); + Assertions.assertFalse(p.contains("delta_0000009"), "out-of-snapshot delta dropped: " + p); + Assertions.assertFalse(p.endsWith("_flush_length"), "side file excluded: " + p); + hasBase5 |= p.contains("/base_0000005/bucket_00000"); + hasDelta6 |= p.contains("/delta_0000006_0000006/bucket_00000"); + } + Assertions.assertTrue(hasBase5, "best base_5 bucket file must survive"); + Assertions.assertTrue(hasDelta6, "delta_6 bucket file must survive"); + + List deletes = state.getDeleteDeltas(); + Assertions.assertEquals(1, deletes.size(), "the paired delete_delta_6 must survive"); + HiveAcidUtil.DeleteDelta d = deletes.get(0); + Assertions.assertTrue(d.getDirectoryLocation().endsWith("/delete_delta_0000006_0000006"), + "delete-delta dir: " + d.getDirectoryLocation()); + Assertions.assertEquals(List.of("bucket_00000"), d.getFileNames(), + "delete-delta file names must be captured (BE under-deletes without them)"); + } + + @Test + public void testInsertOnlyRejectsDeleteDelta() throws IOException { + // An insert-only ACID table must never carry delete deltas; a stray one is a corruption signal. + createBucketFile("base_0000005", "000000_0"); + createBucketFile("delta_0000006_0000006", "000000_0"); + createBucketFile("delete_delta_0000006_0000006", "000000_0"); + + RuntimeException ex = Assertions.assertThrows(RuntimeException.class, + () -> HiveAcidUtil.getAcidState(localFs(), tempDir.toString(), snapshot(6L), false)); + Assertions.assertTrue(ex.getMessage().contains("delete_delta"), + "insert-only table with a delete delta must fail loud: " + ex.getMessage()); + } + + @Test + public void testInsertOnlyAcceptsAllFileNames() throws IOException { + // Insert-only uses the accept-all filter: files that are not bucket_* still count as data. + createBucketFile("base_0000005", "000000_0"); + createBucketFile("delta_0000006_0000006", "000001_0"); + + HiveAcidUtil.AcidState state = HiveAcidUtil.getAcidState( + localFs(), tempDir.toString(), snapshot(6L), false); + Assertions.assertEquals(2, state.getDataFiles().size(), + "insert-only accepts non-bucket_ data file names"); + Assertions.assertTrue(state.getDeleteDeltas().isEmpty()); + } + + @Test + public void testMissingValidWriteIdsThrows() throws IOException { + createBucketFile("base_0000005", "bucket_00000"); + Map ids = snapshot(6L); + ids.remove(HmsAcidConstants.VALID_WRITEIDS_KEY); + + RuntimeException ex = Assertions.assertThrows(RuntimeException.class, + () -> HiveAcidUtil.getAcidState(localFs(), tempDir.toString(), ids, true)); + Assertions.assertTrue(ex.getMessage().contains("ValidWriteIdList"), ex.getMessage()); + } + + @Test + public void testOriginalFilesWithoutBaseThrows() throws IOException { + // A bare file directly under the partition (no base_) means an unconverted non-ACID table. + Files.write(tempDir.resolve("000000_0"), new byte[] {1}); + + Assertions.assertThrows(UnsupportedOperationException.class, + () -> HiveAcidUtil.getAcidState(localFs(), tempDir.toString(), snapshot(6L), true)); + } + + @Test + public void testBaseWithUncommittedVisibilityTxnIsSkipped() throws IOException { + // base_5 was produced by visibility txn 100, which is NOT committed in this snapshot -> it must + // be skipped, falling back to the older committed base_3. This pins the visibility-txn filter. + createBucketFile("base_0000005_v0000100", "bucket_00000"); + createBucketFile("base_0000003", "bucket_00000"); + + // Txn high-watermark 50: visibility txn 100 is not yet valid; base_3's visibility (0) is valid. + ValidTxnList txns = new ValidReadTxnList(new long[0], new BitSet(), 50L, Long.MAX_VALUE); + ValidWriteIdList writeIds = new ValidReaderWriteIdList("db.tbl", new long[0], new BitSet(), 5L); + Map ids = new HashMap<>(); + ids.put(HmsAcidConstants.VALID_TXNS_KEY, txns.writeToString()); + ids.put(HmsAcidConstants.VALID_WRITEIDS_KEY, writeIds.writeToString()); + + HiveAcidUtil.AcidState state = HiveAcidUtil.getAcidState( + localFs(), tempDir.toString(), ids, true); + Assertions.assertEquals(1, state.getDataFiles().size()); + Assertions.assertTrue(state.getDataFiles().get(0).location().uri().contains("/base_0000003/"), + "a base whose visibility txn is uncommitted must be skipped for the committed base"); + } + + @Test + public void testHighestBaseInvalidFallsBackToLowerValidBaseAndDropsObsoleteDelta() throws IOException { + // base_8 is beyond the write-id watermark (invalid) and must be rejected despite its higher + // write id, falling back to base_4; delta_2 predates base_4 so it is obsolete and dropped. This + // pins isValidBase discrimination + the selection loop's "delta below the base" rejection. + createBucketFile("base_0000008", "bucket_00000"); + createBucketFile("base_0000004", "bucket_00000"); + createBucketFile("delta_0000002_0000002", "bucket_00000"); + + HiveAcidUtil.AcidState state = HiveAcidUtil.getAcidState( + localFs(), tempDir.toString(), snapshot(5L), true); + + Assertions.assertEquals(1, state.getDataFiles().size(), + "only the best VALID base (base_4) survives; the higher but invalid base_8 is rejected"); + String p = state.getDataFiles().get(0).location().uri(); + Assertions.assertTrue(p.contains("/base_0000004/"), p); + Assertions.assertFalse(p.contains("base_0000008"), "base above the write-id watermark is invalid"); + } + + @Test + public void testNoValidBaseThrowsNotEnoughHistory() throws IOException { + // Only base_8 exists but it is beyond the write-id watermark -> no usable base and no original + // files -> the reader cannot reconstruct the snapshot and must fail loud. + createBucketFile("base_0000008", "bucket_00000"); + + IOException ex = Assertions.assertThrows(IOException.class, + () -> HiveAcidUtil.getAcidState(localFs(), tempDir.toString(), snapshot(5L), true)); + Assertions.assertTrue(ex.getMessage().contains("Not enough history"), ex.getMessage()); + } + + @Test + public void testMissingValidTxnListThrows() throws IOException { + createBucketFile("base_0000005", "bucket_00000"); + Map ids = snapshot(6L); + ids.remove(HmsAcidConstants.VALID_TXNS_KEY); + + RuntimeException ex = Assertions.assertThrows(RuntimeException.class, + () -> HiveAcidUtil.getAcidState(localFs(), tempDir.toString(), ids, true)); + Assertions.assertTrue(ex.getMessage().contains("ValidTxnList"), ex.getMessage()); + } + + /** + * A Doris {@link LocalFileSystem} that FORBIDS the glob-aware {@link FileSystem#listFiles} / + * {@link FileSystem#listFilesRecursive}. Every test lists through this, so any regression in + * {@link HiveAcidUtil} from the literal {@link FileSystem#list} to {@code listFiles()} fails loud here. + * + *

The production per-scheme filesystems ({@code DFSFileSystem}, {@code S3CompatibleFileSystem}) override + * {@code listFiles} to treat a location containing {@code [}/{@code *}/{@code ?} as a glob pattern; a real hive + * delta/partition location can contain those, and the old {@code FileSystem.listStatus} never glob-expanded. + * Plain {@link LocalFileSystem#listFiles} is the (literal) interface default, so it alone cannot catch a + * {@code list()->listFiles()} regression — hence this guard. Mirrors {@code FakeFileSystem.listFiles} throwing + * in the non-ACID listing tests. + */ + private static final class LiteralListingLocalFileSystem extends LocalFileSystem { + LiteralListingLocalFileSystem() { + super(Collections.emptyMap()); + } + + @Override + public List listFiles(Location dir) { + throw new AssertionError( + "HiveAcidUtil must list via the literal list(), never the glob-aware listFiles()"); + } + + @Override + public List listFilesRecursive(Location dir) { + throw new AssertionError( + "HiveAcidUtil must list via the literal list(), never the glob-aware listFilesRecursive()"); + } + } +} 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 new file mode 100644 index 00000000000000..72482e3a0030c3 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorCapabilitiesTest.java @@ -0,0 +1,100 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.ConnectorCapability; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.Set; + +/** + * Pins the exact connector-wide capability set the hive connector declares (HMS cutover §4.2, dormant). + * + *

Each capability is either a faithful port of a legacy HMSExternalTable/HMS admission (declared) or a + * deliberate deferral (withheld). The set is inert until hms enters SPI_READY_TYPES, so these assertions are + * a Rule-9 guard: flipping any capability without the supporting machinery (or dropping a legacy-parity one) + * would silently change post-flip behavior, and this test fails loud instead.

+ */ +public class HiveConnectorCapabilitiesTest { + + private Set capabilities() { + return new HiveConnector(Collections.emptyMap(), new FakeConnectorContext()).getCapabilities(); + } + + @Test + public void declaresLegacyParityCapabilities() { + Set caps = capabilities(); + // Legacy resolved isView() from the remote view text; the plugin view path is gated on this. + Assertions.assertTrue(caps.contains(ConnectorCapability.SUPPORTS_VIEW), + "SUPPORTS_VIEW: legacy hive views are queryable/droppable/listed"); + // Legacy HMSExternalTable.supportsExternalMetadataPreload() returned true. + Assertions.assertTrue(caps.contains(ConnectorCapability.SUPPORTS_METADATA_PRELOAD), + "SUPPORTS_METADATA_PRELOAD: legacy HMS tables were preload-eligible"); + // The mixed hms catalog needs MVCC (iceberg/hudi-on-HMS are MvccTable; GSON maps HMSExternalTable -> + // PluginDrivenMvccExternalTable, and buildTableInternal selects the Mvcc subclass from this + // catalog-level flag). Declared TOGETHER with its MTMV freshness machinery + // (HiveConnectorMetadata.getTableFreshness / getPartitionFreshnessMillis surface hive's last-modified + // freshness), so a plain-hive base table's MV detects change instead of pinning a constant. + Assertions.assertTrue(caps.contains(ConnectorCapability.SUPPORTS_MVCC_SNAPSHOT), + "SUPPORTS_MVCC_SNAPSHOT: the mixed hms catalog needs it; freshness is served last-modified"); + } + + @Test + public void withholdsCapabilitiesWithoutSupportingSpi() { + Set caps = capabilities(); + // 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 has no passthrough query()"); + // Legacy SHOW PARTITIONS lists names only; listPartitions emits UNKNOWN stats. + Assertions.assertFalse(caps.contains(ConnectorCapability.SUPPORTS_PARTITION_STATS), + "hive SHOW PARTITIONS is names-only"); + } + + @Test + public void perTableScanCapabilitiesAreNotConnectorWide() { + Set caps = capabilities(); + // Both are per-table markers emitted in getTableSchema (orc/parquet only), never connector-wide flags, + // otherwise a text/json/csv/view/hudi table would be wrongly eligible. + Assertions.assertFalse(caps.contains(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE), + "Top-N lazy is a per-table marker, not connector-wide"); + Assertions.assertFalse(caps.contains(ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE), + "nested-column-prune is a per-table marker, not connector-wide"); + // SUPPORTS_COLUMN_AUTO_ANALYZE is likewise per-table (getTableSchema emits it for every plain-hive table). + // A connector-wide flag would over-admit hudi-on-HMS, which legacy StatisticsUtil.supportAutoAnalyze + // excluded (it admitted only dlaType HIVE || ICEBERG). MUTATION: re-declaring it connector-wide silently + // re-admits hudi-on-HMS -> red here. + Assertions.assertFalse(caps.contains(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE), + "auto-analyze is a per-table marker (excludes hudi-on-HMS), not connector-wide"); + // SUPPORTS_SAMPLE_ANALYZE is likewise per-table (getTableSchema emits it for plain-hive tables only, + // any format). A connector-wide flag would over-admit iceberg/hudi-on-HMS to sampled ANALYZE, which + // legacy gated on dlaType==HIVE. MUTATION: declaring it connector-wide -> red here. + Assertions.assertFalse(caps.contains(ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE), + "sample-analyze is a per-table marker (plain-hive only), not connector-wide"); + // SUPPORTS_METADATA_TABLE reaches a hudi-on-HMS table ONLY via reflectSiblingScanCapabilities (the hudi + // sibling declares it connector-wide); hive must NEVER declare it, or every hms table (incl. plain-hive and + // iceberg-on-HMS) would wrongly pass the hudi_meta()/TIMELINE gate. MUTATION: declaring it -> red here. + Assertions.assertFalse(caps.contains(ConnectorCapability.SUPPORTS_METADATA_TABLE), + "metadata-table reaches hudi-on-HMS via sibling reflection, never hive connector-wide"); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorClientCacheTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorClientCacheTest.java new file mode 100644 index 00000000000000..518d5aaa499134 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorClientCacheTest.java @@ -0,0 +1,228 @@ +// 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.mvcc.ConnectorTableFreshness; +import org.apache.doris.connector.hms.CachingHmsClient; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsDatabaseInfo; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsTableInfo; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalLong; + +/** + * Tests the C-c wiring: {@link HiveConnector} hands its {@link HiveConnectorMetadata} a caching metastore client, + * so every scan-side read (and the MTMV freshness probes) is served from the connector-owned cache after the hms + * cutover instead of a fresh Thrift RPC. + * + *

Why this matters (Rule 9): when a hive catalog becomes plugin-driven at the flip, the fe-core + * engine-side {@code HiveExternalMetaCache} stops routing to it (it collapses to the schema-only + * {@code ENGINE_DEFAULT}). Without this connector-level wrap, {@code getTable} / {@code listPartitionNames} / + * {@code getPartitions} would each become an uncached RPC on every scan, and the periodic SQL-dictionary / MV + * freshness poll ({@link HiveConnectorMetadata#getTableFreshness} / + * {@link HiveConnectorMetadata#getPartitionFreshnessMillis}, both backed by {@code getPartitions}) would hit the + * metastore every tick. These tests pin that the connector's own {@code wrapWithCache} produces a + * {@link CachingHmsClient}, that reads through it are cache-backed end-to-end, and that the catalog's + * {@code meta.cache.hive.*} properties reach that cache (so it can be turned off). The decorator's internal + * caching correctness is covered separately by {@code CachingHmsClientTest} — this suite tests only the wiring. + * + *

Dormant: {@code "hms"} is not in {@code SPI_READY_TYPES}, so no live catalog builds a {@link HiveConnector}; + * this exercises the wrap directly, as production {@code createClient} will at the flip. + */ +public class HiveConnectorClientCacheTest { + + private static final String METASTORE_URI = "thrift://host:9083"; + private static final List PART_KEYS = Arrays.asList("year", "month"); + private static final String PART_NAME = "year=2024/month=01"; + private static final String TRANSIENT_LAST_DDL_TIME = "transient_lastDdlTime"; + + private static Map props(String... kv) { + Map m = new HashMap<>(); + m.put("hive.metastore.uris", METASTORE_URI); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + private HiveTableHandle partitionedHandle() { + return new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .partitionKeyNames(PART_KEYS) + .build(); + } + + // ==================== the connector wraps its client in a caching decorator ==================== + + @Test + public void wrapWithCacheReturnsACachingDecorator() { + HiveConnector connector = new HiveConnector(props(), new FakeConnectorContext()); + RecordingHmsClient raw = new RecordingHmsClient(); + + HmsClient wrapped = connector.wrapWithCache(raw); + + // WHY: production createClient hands this wrapped client to HiveConnectorMetadata. If it returned the raw + // client (no wrap), every metadata read would be an uncached RPC after the flip. + Assertions.assertTrue(wrapped instanceof CachingHmsClient, + "the connector must wrap its metastore client in the caching decorator"); + Assertions.assertNotSame(raw, wrapped, "the wrapped client must not be the raw delegate"); + } + + // ==================== table freshness is served from the cache (§2.6 dictionary/MV poll stays cheap) ==== + + @Test + public void repeatedTableFreshnessHitsTheCacheNotTheMetastore() { + HiveConnector connector = new HiveConnector(props(), new FakeConnectorContext()); + RecordingHmsClient raw = new RecordingHmsClient(); + HiveConnectorMetadata md = metadataOver(connector.wrapWithCache(raw)); + + ConnectorTableFreshness first = md.getTableFreshness(null, partitionedHandle()).orElse(null); + ConnectorTableFreshness second = md.getTableFreshness(null, partitionedHandle()).orElse(null); + + Assertions.assertNotNull(first); + Assertions.assertNotNull(second); + Assertions.assertEquals(300_000L, first.getTimestampMillis(), + "freshness must still surface the real max transient_lastDdlTime (x1000)"); + // WHY: the second poll must be served from the cache — one listPartitionNames + one getPartitions RPC + // total, not two. This is exactly what keeps a hive-backed SQL dictionary's periodic version poll cheap. + Assertions.assertEquals(1, raw.listPartitionNamesCalls, + "a repeated table-freshness poll must not re-list partition names"); + Assertions.assertEquals(1, raw.getPartitionsCalls, + "a repeated table-freshness poll must not re-fetch partitions (served from the cache)"); + } + + @Test + public void repeatedPartitionFreshnessHitsTheCache() { + HiveConnector connector = new HiveConnector(props(), new FakeConnectorContext()); + RecordingHmsClient raw = new RecordingHmsClient(); + HiveConnectorMetadata md = metadataOver(connector.wrapWithCache(raw)); + + OptionalLong first = md.getPartitionFreshnessMillis(null, partitionedHandle(), PART_NAME); + OptionalLong second = md.getPartitionFreshnessMillis(null, partitionedHandle(), PART_NAME); + + Assertions.assertTrue(first.isPresent()); + Assertions.assertEquals(300_000L, first.getAsLong()); + Assertions.assertEquals(first.getAsLong(), second.getAsLong()); + // WHY: the same partition requested twice is one cache entry (RPC-argument granularity) — one round-trip. + Assertions.assertEquals(1, raw.getPartitionsCalls, + "a repeated per-partition freshness fetch for the same partition must be served from the cache"); + } + + // ==================== the catalog's meta.cache.hive.* props reach the connector-owned cache ============== + + @Test + public void disablingThePartitionCacheViaPropsMakesFreshnessReloadEachTime() { + // Disable ONLY the partition-object cache; leave the partition-name cache on. This proves the connector + // threads its own catalog properties into the decorator (so an operator can turn caching off) and that the + // knobs are read PER entry. + HiveConnector connector = + new HiveConnector(props("meta.cache.hive.partition.enable", "false"), new FakeConnectorContext()); + RecordingHmsClient raw = new RecordingHmsClient(); + HiveConnectorMetadata md = metadataOver(connector.wrapWithCache(raw)); + + md.getTableFreshness(null, partitionedHandle()); + md.getTableFreshness(null, partitionedHandle()); + + // WHY: with the partition cache disabled, getPartitions reloads every poll... + Assertions.assertEquals(2, raw.getPartitionsCalls, + "disabling meta.cache.hive.partition must make getPartitions reload on every freshness poll"); + // ...while the still-enabled partition-name cache is served once — proving the knob is per entry. + Assertions.assertEquals(1, raw.listPartitionNamesCalls, + "the still-enabled partition-name cache must not reload when only the partition cache is off"); + } + + private HiveConnectorMetadata metadataOver(HmsClient client) { + return new HiveConnectorMetadata(client, Collections.emptyMap(), new FakeConnectorContext()); + } + + /** + * A minimal {@link HmsClient} that counts the two freshness-backing calls and returns a single partition with + * a {@code transient_lastDdlTime}, so a cache hit (one call) is distinguishable from a reload (two calls). + */ + private static final class RecordingHmsClient implements HmsClient { + int getPartitionsCalls; + int listPartitionNamesCalls; + + @Override + public List listPartitionNames(String dbName, String tableName, int maxParts) { + listPartitionNamesCalls++; + return new ArrayList<>(Collections.singletonList(PART_NAME)); + } + + @Override + public List getPartitions(String dbName, String tableName, List partNames) { + getPartitionsCalls++; + List out = new ArrayList<>(); + for (String name : partNames) { + List values = HiveWriteUtils.toPartitionValues(name); + Map params = Collections.singletonMap(TRANSIENT_LAST_DDL_TIME, "300"); + out.add(new HmsPartitionInfo(values, "loc", "if", "of", "serde", params)); + } + return out; + } + + // Unused abstract methods — trivial stubs (never hit by the freshness path). + @Override + public List listDatabases() { + return Collections.emptyList(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + return null; + } + + @Override + public List listTables(String dbName) { + return Collections.emptyList(); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + return false; + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + return null; + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + return Collections.emptyMap(); + } + + @Override + public HmsPartitionInfo getPartition(String dbName, String tableName, List values) { + return null; + } + + @Override + public void close() { + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorInvalidateTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorInvalidateTest.java new file mode 100644 index 00000000000000..1d505ea7803406 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorInvalidateTest.java @@ -0,0 +1,289 @@ +// 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.hms.CachingHmsClient; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsDatabaseInfo; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsTableInfo; +import org.apache.doris.filesystem.FileSystem; + +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.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Tests {@link HiveConnector#invalidateTable}/{@link HiveConnector#invalidateAll} — the REFRESH TABLE / REFRESH + * CATALOG hooks that arm the connector-owned D2 caches. + * + *

WHY (Rule 9): a flipped hive catalog's caches never expire on external change (event sync is off until the + * event Model B step), so {@code REFRESH TABLE} / {@code REFRESH CATALOG} are the user's explicit way to see new + * data. fe-core already routes them to {@code connector.invalidateTable} / {@code invalidateAll}; these tests pin + * that the hive connector then drops BOTH cache layers — the metastore-metadata cache ({@link CachingHmsClient}) + * AND the directory-listing cache ({@link HiveFileListingCache}) — because a hive table's schema, partitions and + * files are all mutable (unlike iceberg's immutable manifests). {@code invalidateTable} is scoped to one table; + * {@code invalidateAll} clears everything; and the public hooks never force-build a metastore client just to + * flush (a REFRESH on a never-scanned catalog must be a cheap no-op on the metastore side).

+ * + *

Dormant: {@code "hms"} is not in {@code SPI_READY_TYPES}; this drives the hooks directly.

+ */ +public class HiveConnectorInvalidateTest { + + // The file-listing cache above the FileSystem seam: the injected FileSystem lists successfully (empty is fine — + // a successful load still leaves a cache entry), so these size()-based invalidation assertions don't need real + // files. Mirrors the role the old Configuration CONF constant played. + private static final FileSystem FS = new FakeFileSystem(); + + private static Map props() { + Map m = new HashMap<>(); + m.put("hive.metastore.uris", "thrift://host:9083"); + return m; + } + + @Test + public void invalidateTableFlushesBothCachesForThatTableOnly() { + HiveConnector connector = new HiveConnector(props(), new FakeConnectorContext()); + RecordingHmsClient raw = new RecordingHmsClient(); + CachingHmsClient cachingClient = (CachingHmsClient) connector.wrapWithCache(raw); + + // Metastore-metadata cache: populate t1 and t2 (each one RPC, then cached). + cachingClient.getTable("db", "t1"); + cachingClient.getTable("db", "t2"); + Assertions.assertEquals(2, raw.getTableCalls); + + // Directory-listing cache: populate t1 and t2 (each one listing, then cached). + HiveFileListingCache fileCache = connector.fileListingCacheForTest(); + fileCache.listDataFiles("db", "t1", "file:///wh/db/t1", FS); + fileCache.listDataFiles("db", "t2", "file:///wh/db/t2", FS); + Assertions.assertEquals(2, fileCache.size()); + + connector.invalidateTable(cachingClient, "db", "t1"); + + // Metastore cache: t1 re-fetches; t2 (a different table) is still served from the cache. + cachingClient.getTable("db", "t1"); + Assertions.assertEquals(3, raw.getTableCalls, "REFRESH TABLE must drop t1's metastore entry"); + cachingClient.getTable("db", "t2"); + Assertions.assertEquals(3, raw.getTableCalls, "REFRESH TABLE must NOT drop another table's metastore entry"); + + // File cache: t1's listing dropped, t2's survives (invalidateTable is scoped by (db, table)). + Assertions.assertEquals(1, fileCache.size(), "REFRESH TABLE must drop only that table's directory listings"); + } + + @Test + public void invalidateDbFlushesBothCachesForThatDbOnly() { + HiveConnector connector = new HiveConnector(props(), new FakeConnectorContext()); + RecordingHmsClient raw = new RecordingHmsClient(); + CachingHmsClient cachingClient = (CachingHmsClient) connector.wrapWithCache(raw); + + // Metastore cache: TWO tables in db1 (t1, t2) and one in db2. REFRESH DATABASE db1 must drop every db1 + // table, not just one, while db2 survives. + cachingClient.getTable("db1", "t1"); + cachingClient.getTable("db1", "t2"); + cachingClient.getTable("db2", "t1"); + Assertions.assertEquals(3, raw.getTableCalls); + + // Directory-listing cache: db1.t1 and db2.t1 (each one listing, then cached). + HiveFileListingCache fileCache = connector.fileListingCacheForTest(); + fileCache.listDataFiles("db1", "t1", "file:///wh/db1/t1", FS); + fileCache.listDataFiles("db2", "t1", "file:///wh/db2/t1", FS); + Assertions.assertEquals(2, fileCache.size()); + + connector.invalidateDb(cachingClient, "db1"); + + // Metastore cache: every db1 table re-fetches (t1 AND t2); db2 (another database) is still cached. + cachingClient.getTable("db1", "t1"); + cachingClient.getTable("db1", "t2"); + Assertions.assertEquals(5, raw.getTableCalls, "REFRESH DATABASE must drop every db1 table's metastore entry"); + cachingClient.getTable("db2", "t1"); + Assertions.assertEquals(5, raw.getTableCalls, "REFRESH DATABASE must NOT drop another database's entries"); + + // File cache: db1's listing dropped, db2's survives (invalidateDb is scoped by db). + Assertions.assertEquals(1, fileCache.size(), "REFRESH DATABASE must drop only that db's directory listings"); + } + + @Test + public void invalidateAllFlushesBothCachesEntirely() { + HiveConnector connector = new HiveConnector(props(), new FakeConnectorContext()); + RecordingHmsClient raw = new RecordingHmsClient(); + CachingHmsClient cachingClient = (CachingHmsClient) connector.wrapWithCache(raw); + + cachingClient.getTable("db", "t1"); + Assertions.assertEquals(1, raw.getTableCalls); + HiveFileListingCache fileCache = connector.fileListingCacheForTest(); + fileCache.listDataFiles("db", "t1", "file:///wh/db/t1", FS); + Assertions.assertEquals(1, fileCache.size()); + + connector.invalidateAll(cachingClient); + + // Both caches fully cleared: the metastore entry re-fetches and the file cache is empty. + cachingClient.getTable("db", "t1"); + Assertions.assertEquals(2, raw.getTableCalls, "REFRESH CATALOG must drop the metastore cache"); + Assertions.assertEquals(0, fileCache.size(), "REFRESH CATALOG must drop the directory-listing cache"); + } + + @Test + public void publicHooksAreNoThrowAndClearFileCacheWithoutBuildingAClient() { + // A fresh connector never built its metastore client (hmsClient == null). The public hooks must not + // force-build one (a REFRESH on a never-scanned catalog is a cheap no-op on the metastore side), must not + // throw on the null client, and must still clear the file cache. + HiveConnector connector = new HiveConnector(props(), new FakeConnectorContext()); + HiveFileListingCache fileCache = connector.fileListingCacheForTest(); + + fileCache.listDataFiles("db", "t", "file:///wh/db/t", FS); + Assertions.assertEquals(1, fileCache.size()); + Assertions.assertDoesNotThrow(() -> connector.invalidateTable("db", "t")); + Assertions.assertEquals(0, fileCache.size(), "REFRESH TABLE clears the file cache even with no client built"); + + fileCache.listDataFiles("db", "t", "file:///wh/db/t", FS); + Assertions.assertEquals(1, fileCache.size()); + Assertions.assertDoesNotThrow(() -> connector.invalidateDb("db")); + Assertions.assertEquals(0, fileCache.size(), "REFRESH DATABASE clears the file cache with no client built"); + + fileCache.listDataFiles("db", "t", "file:///wh/db/t", FS); + Assertions.assertEquals(1, fileCache.size()); + Assertions.assertDoesNotThrow(() -> connector.invalidateAll()); + Assertions.assertEquals(0, fileCache.size(), "REFRESH CATALOG clears the file cache even with no client built"); + } + + @Test + public void invalidatePartitionDropsOnlyThatPartitionsFileListing() { + // WHY (Rule 9): a partition add/drop/alter must drop ONLY that partition's cached listing — legacy + // HiveExternalMetaCache scoped its file-cache invalidation to (tableId + partitionValues), NOT the whole + // table. The values are derived purely from the partition NAME (no metastore lookup), which is what stops + // an evicted partition-metadata entry from leaving a stale listing (the #65334 failure mode). + HiveConnector connector = new HiveConnector(props(), new FakeConnectorContext()); + CachingHmsClient cachingClient = (CachingHmsClient) connector.wrapWithCache(new RecordingHmsClient()); + HiveFileListingCache fileCache = connector.fileListingCacheForTest(); + + // Two partitions of table t, plus a same-named partition of a DIFFERENT table t2 (must survive: scoped + // by db+table, mirroring legacy's tableId in the predicate). + fileCache.listDataFiles("db", "t", "file:///wh/db/t/dt=2024-01-01", + Collections.singletonList("2024-01-01"), FS); + fileCache.listDataFiles("db", "t", "file:///wh/db/t/dt=2024-01-02", + Collections.singletonList("2024-01-02"), FS); + fileCache.listDataFiles("db", "t2", "file:///wh/db/t2/dt=2024-01-01", + Collections.singletonList("2024-01-01"), FS); + Assertions.assertEquals(3, fileCache.size()); + + connector.invalidatePartition(cachingClient, "db", "t", Collections.singletonList("dt=2024-01-01")); + + // Exactly one entry dropped (t's dt=2024-01-01); t's other partition and t2's same-named partition survive. + Assertions.assertEquals(2, fileCache.size(), + "invalidatePartition must drop ONLY the refreshed partition's listing, not the whole table"); + // Prove WHICH one was dropped: re-listing dt=2024-01-01 is a miss that re-adds it (size 3 again); had a + // survivor been dropped instead, its earlier re-list would already have shown the miss. + fileCache.listDataFiles("db", "t", "file:///wh/db/t/dt=2024-01-01", + Collections.singletonList("2024-01-01"), FS); + Assertions.assertEquals(3, fileCache.size(), "the dropped partition re-lists on the next scan"); + } + + @Test + public void invalidatePartitionDropsOnlyThatPartitionsMetadata() { + // The metastore-metadata half mirrors legacy's per-partition partitionEntry.invalidateKey: only the named + // partition's cached HmsPartitionInfo is dropped; another partition of the same table stays cached. + HiveConnector connector = new HiveConnector(props(), new FakeConnectorContext()); + RecordingHmsClient raw = new RecordingHmsClient(); + CachingHmsClient cachingClient = (CachingHmsClient) connector.wrapWithCache(raw); + + // Populate the partition-metadata cache for two partitions (misses fetched in ONE delegate round-trip). + cachingClient.getPartitions("db", "t", Arrays.asList("dt=2024-01-01", "dt=2024-01-02")); + Assertions.assertEquals(1, raw.getPartitionsCalls); + + connector.invalidatePartition(cachingClient, "db", "t", Collections.singletonList("dt=2024-01-01")); + + // dt=2024-01-01 re-fetches (a new delegate round-trip); dt=2024-01-02 is still served from the cache. + cachingClient.getPartitions("db", "t", Collections.singletonList("dt=2024-01-01")); + Assertions.assertEquals(2, raw.getPartitionsCalls, "the refreshed partition's metadata must be dropped"); + cachingClient.getPartitions("db", "t", Collections.singletonList("dt=2024-01-02")); + Assertions.assertEquals(2, raw.getPartitionsCalls, "another partition's metadata must NOT be dropped"); + } + + /** + * Minimal {@link HmsClient} that counts {@code getTable} calls and returns a fresh table info per call (so a + * cache hit — same instance — is distinguishable from a reload). Only the abstract read methods are stubbed; + * the write/txn methods are interface defaults. + */ + private static final class RecordingHmsClient implements HmsClient { + int getTableCalls; + int getPartitionsCalls; + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + getTableCalls++; + return HmsTableInfo.builder().dbName(dbName).tableName(tableName).build(); + } + + @Override + public List listDatabases() { + return Collections.emptyList(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + return null; + } + + @Override + public List listTables(String dbName) { + return Collections.emptyList(); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + return false; + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + return Collections.emptyMap(); + } + + @Override + public List listPartitionNames(String dbName, String tableName, int maxParts) { + return Collections.emptyList(); + } + + @Override + public List getPartitions(String dbName, String tableName, List partNames) { + getPartitionsCalls++; + List result = new ArrayList<>(partNames.size()); + for (String name : partNames) { + result.add(new HmsPartitionInfo(HiveWriteUtils.toPartitionValues(name), + "file:///wh/" + dbName + "/" + tableName + "/" + name, null, null, null, + Collections.emptyMap())); + } + return result; + } + + @Override + public HmsPartitionInfo getPartition(String dbName, String tableName, List values) { + return null; + } + + @Override + public void close() { + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataColumnStatsTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataColumnStatsTest.java new file mode 100644 index 00000000000000..8d47964d0617c7 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataColumnStatsTest.java @@ -0,0 +1,182 @@ +// 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.ConnectorColumnStatistics; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsColumnStatistics; +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; +import java.util.Optional; + +/** + * Tests {@link HiveConnectorMetadata#getColumnStatistics}, the query-planner column-stat fast path ported from + * legacy {@code HMSExternalTable.getHiveColumnStats} (HMS cutover §4.2a, dormant). + * + *

WHY: the connector must serve the no-scan HMS column stats as RAW facts (rowCount / ndv / numNulls / + * avgColLen) and gate exactly as legacy did — a positive {@code numRows} is required as the data-size basis + * (and, unlike the table-size branch, there is NO spark-count fallback here), only a plain-hive table is + * served (iceberg-on-HMS goes to the sibling; hudi had no fast path), and a missing basis/stat degrades to + * empty so fe-core falls back to a full ANALYZE — WITHOUT paying the HMS column-stat round-trip.

+ */ +public class HiveConnectorMetadataColumnStatsTest { + + private HiveConnectorMetadata metadata(FakeHmsClient client) { + return new HiveConnectorMetadata(client, Collections.emptyMap(), new FakeConnectorContext()); + } + + private HiveTableHandle hiveHandle(Map params) { + return new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .tableParameters(params) + .build(); + } + + private static Map numRows(String value) { + Map m = new HashMap<>(); + m.put("numRows", value); + return m; + } + + @Test + public void serviceHiveColumnStats() { + FakeHmsClient client = new FakeHmsClient( + Collections.singletonList(new HmsColumnStatistics("c", 10, 2, 5.0))); + Optional stats = + metadata(client).getColumnStatistics(null, hiveHandle(numRows("1000")), "c"); + Assertions.assertTrue(stats.isPresent()); + Assertions.assertEquals(1000, stats.get().getRowCount()); + Assertions.assertEquals(10, stats.get().getNdv()); + Assertions.assertEquals(2, stats.get().getNumNulls()); + Assertions.assertEquals(5.0, stats.get().getAvgSizeBytes(), 0.0); + } + + @Test + public void missingNumRowsReturnsEmptyWithoutHmsCall() { + FakeHmsClient client = new FakeHmsClient( + Collections.singletonList(new HmsColumnStatistics("c", 10, 2, 5.0))); + Assertions.assertFalse( + metadata(client).getColumnStatistics(null, hiveHandle(Collections.emptyMap()), "c").isPresent()); + Assertions.assertFalse(client.columnStatsCalled, + "no numRows basis => must not pay the HMS column-stat round-trip"); + } + + @Test + public void zeroOrMalformedNumRowsReturnsEmpty() { + FakeHmsClient client = new FakeHmsClient( + Collections.singletonList(new HmsColumnStatistics("c", 10, 2, 5.0))); + Assertions.assertFalse( + metadata(client).getColumnStatistics(null, hiveHandle(numRows("0")), "c").isPresent()); + Assertions.assertFalse( + metadata(client).getColumnStatistics(null, hiveHandle(numRows("abc")), "c").isPresent()); + } + + @Test + public void noHmsStatsReturnsEmpty() { + FakeHmsClient client = new FakeHmsClient(Collections.emptyList()); + Assertions.assertFalse( + metadata(client).getColumnStatistics(null, hiveHandle(numRows("1000")), "c").isPresent()); + } + + @Test + public void nonHiveTableReturnsEmptyWithoutHmsCall() { + FakeHmsClient client = new FakeHmsClient( + Collections.singletonList(new HmsColumnStatistics("c", 10, 2, 5.0))); + HiveTableHandle hudiHandle = new HiveTableHandle.Builder("db", "t", HiveTableType.HUDI) + .tableParameters(numRows("1000")) + .build(); + Assertions.assertFalse( + metadata(client).getColumnStatistics(null, hudiHandle, "c").isPresent()); + Assertions.assertFalse(client.columnStatsCalled, + "iceberg/hudi-on-HMS are served by their own connector; the hive fast path must not run"); + } + + /** Minimal {@link HmsClient} double serving preset column stats and recording the fetch. */ + private static final class FakeHmsClient implements HmsClient { + private final List columnStats; + private boolean columnStatsCalled; + + FakeHmsClient(List columnStats) { + this.columnStats = columnStats; + } + + @Override + public List getTableColumnStatistics(String dbName, String tableName, + List columns) { + columnStatsCalled = true; + return columnStats; + } + + @Override + public List listDatabases() { + throw new UnsupportedOperationException(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public List listTables(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public 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() { + } + } +} 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 new file mode 100644 index 00000000000000..34f3c9a9872fdb --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataDdlTest.java @@ -0,0 +1,535 @@ +// 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.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.ddl.ConnectorBucketSpec; +import org.apache.doris.connector.api.ddl.ConnectorCreateTableRequest; +import org.apache.doris.connector.api.ddl.ConnectorPartitionField; +import org.apache.doris.connector.api.ddl.ConnectorPartitionSpec; +import org.apache.doris.connector.hms.HmsClientException; +import org.apache.doris.connector.hms.HmsCreateDatabaseRequest; +import org.apache.doris.connector.hms.HmsCreateTableRequest; +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.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * DDL tests for {@link HiveConnectorMetadata}'s create/drop database, create/drop/truncate table overrides + * (P7.1). They run offline against a recording {@link org.apache.doris.connector.hms.HmsClient} fake (no + * live metastore, no Mockito), asserting the neutral request is turned into the same metastore write spec the + * legacy {@code HiveMetadataOps.createTableImpl}/{@code createDbImpl}/{@code dropTableImpl}/ + * {@code truncateTableImpl} produced: file-format / owner defaults, the {@code doris.}-prefixed round-trip + * parameters, the bucket gate, the transactional-table rejections, and the partition rules. + */ +public class HiveConnectorMetadataDdlTest { + + private static final String CATALOG_USER = "hive_user"; + + // ==================== createTable: file format + owner + doris.version ==================== + + @Test + public void createTableUsesEnvDefaultFileFormatAndStampsOwnerAndVersion() { + RecordingHmsClient client = new RecordingHmsClient(); + // WHY: with no file_format property the connector must fall back to the FE-global + // hive_default_file_format threaded through the environment (legacy Config.hive_default_file_format), + // stamp the connecting user as the owner (legacy set owner from ConnectContext), and stamp the build + // version threaded via the environment. MUTATION: dropping any of the three assertions' sources + // (env fallback / owner default / doris_version) flips it red. + Map env = new LinkedHashMap<>(); + env.put(HiveConnectorProperties.ENV_HIVE_DEFAULT_FILE_FORMAT, "parquet"); + env.put(HiveConnectorProperties.ENV_DORIS_VERSION, "9.9-deadbeef"); + metadata(client, Collections.emptyMap(), env) + .createTable(session(), request().build()); + + HmsCreateTableRequest req = client.lastCreateTable; + Assertions.assertNotNull(req); + Assertions.assertEquals("parquet", req.getFileFormat()); + Assertions.assertEquals(CATALOG_USER, req.getProperties().get("owner")); + Assertions.assertEquals("9.9-deadbeef", req.getDorisVersion()); + } + + @Test + public void createTableUserFileFormatOverridesEnvAndRoundTripsUnderDorisPrefix() { + RecordingHmsClient client = new RecordingHmsClient(); + Map props = new LinkedHashMap<>(); + props.put("file_format", "orc"); + props.put("location", "s3://bucket/t"); + props.put("some_key", "v"); + Map env = Collections.singletonMap( + HiveConnectorProperties.ENV_HIVE_DEFAULT_FILE_FORMAT, "parquet"); + + // WHY: a user-set file_format wins over the env default; and file_format/location must round-trip as + // metastore parameters under a doris. prefix while an ordinary property keeps its plain key (legacy + // ddlProps loop). location is ALSO surfaced as the storage-descriptor location. MUTATION: dropping + // the doris. prefix, or not honoring the user file_format, flips these. + metadata(client, Collections.emptyMap(), env) + .createTable(session(), request().properties(props).build()); + + HmsCreateTableRequest req = client.lastCreateTable; + Assertions.assertEquals("orc", req.getFileFormat()); + Assertions.assertEquals("s3://bucket/t", req.getLocation()); + Assertions.assertEquals("orc", req.getProperties().get("doris.file_format")); + Assertions.assertEquals("s3://bucket/t", req.getProperties().get("doris.location")); + Assertions.assertEquals("v", req.getProperties().get("some_key")); + } + + @Test + public void createTableFallsBackToOrcWhenEnvMissing() { + RecordingHmsClient client = new RecordingHmsClient(); + // WHY: a direct-construction context with no environment (getEnvironment() empty) must still create a + // table, degrading to the hard-coded orc default (matches Config.hive_default_file_format's default). + metadata(client, Collections.emptyMap(), Collections.emptyMap()) + .createTable(session(), request().build()); + Assertions.assertEquals("orc", client.lastCreateTable.getFileFormat()); + } + + // ==================== createTable: transactional rejection ==================== + + @Test + public void createTableRejectsTransactional() { + RecordingHmsClient client = new RecordingHmsClient(); + Map props = Collections.singletonMap("transactional", "TRUE"); + // WHY: legacy rejects creating a hive transactional table (it only appears to accept inserts). The + // value check is case-insensitive. MUTATION: dropping the reject lets the create through -> the seam + // records a createTable and the assertThrows fails. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(client, Collections.emptyMap(), Collections.emptyMap()) + .createTable(session(), request().properties(props).build())); + Assertions.assertTrue(ex.getMessage().contains("transactional")); + Assertions.assertNull(client.lastCreateTable, "reject must happen before the metastore create"); + } + + // ==================== createTable: bucketing gate ==================== + + @Test + public void createTableBucketRejectedWhenGloballyDisabled() { + RecordingHmsClient client = new RecordingHmsClient(); + Map env = Collections.singletonMap( + HiveConnectorProperties.ENV_ENABLE_CREATE_HIVE_BUCKET_TABLE, "false"); + ConnectorBucketSpec bucket = new ConnectorBucketSpec( + Collections.singletonList("id"), 8, "doris_default"); + // WHY: bucketed hive tables require the FE-global enable_create_hive_bucket_table toggle (default + // off). The gate is checked BEFORE the hash requirement (legacy order). MUTATION: skipping the gate + // lets a bucket table through. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(client, Collections.emptyMap(), env) + .createTable(session(), request().bucketSpec(bucket).build())); + Assertions.assertTrue(ex.getMessage().contains("enable_create_hive_bucket_table")); + } + + @Test + public void createTableBucketRejectsNonHashDistribution() { + RecordingHmsClient client = new RecordingHmsClient(); + Map env = Collections.singletonMap( + HiveConnectorProperties.ENV_ENABLE_CREATE_HIVE_BUCKET_TABLE, "true"); + ConnectorBucketSpec random = new ConnectorBucketSpec( + Collections.singletonList("id"), 8, HiveConnectorProperties.BUCKET_ALGO_RANDOM); + // WHY: hive external tables only support hash bucketing; a random distribution is rejected AFTER the + // enable gate passed. MUTATION: accepting random creates an unsupported table. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(client, Collections.emptyMap(), env) + .createTable(session(), request().bucketSpec(random).build())); + Assertions.assertTrue(ex.getMessage().contains("hash bucketing")); + } + + @Test + public void createTableHashBucketThreadsColsAndCount() { + RecordingHmsClient client = new RecordingHmsClient(); + Map env = Collections.singletonMap( + HiveConnectorProperties.ENV_ENABLE_CREATE_HIVE_BUCKET_TABLE, "true"); + ConnectorBucketSpec hash = new ConnectorBucketSpec( + Collections.singletonList("id"), 16, "doris_default"); + // WHY: an enabled hash bucket spec must reach the write spec with its columns + count intact. + metadata(client, Collections.emptyMap(), env) + .createTable(session(), request().bucketSpec(hash).build()); + Assertions.assertEquals(Collections.singletonList("id"), client.lastCreateTable.getBucketCols()); + Assertions.assertEquals(16, client.lastCreateTable.getNumBuckets()); + } + + // ==================== createTable: partitioning ==================== + + @Test + public void createTableRejectsRangePartition() { + RecordingHmsClient client = new RecordingHmsClient(); + ConnectorPartitionSpec range = new ConnectorPartitionSpec( + ConnectorPartitionSpec.Style.RANGE, + Collections.singletonList(new ConnectorPartitionField("dt", "identity", + 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, + () -> metadata(client, Collections.emptyMap(), Collections.emptyMap()) + .createTable(session(), request().partitionSpec(range).build())); + Assertions.assertTrue(ex.getMessage().contains("'LIST' partition type")); + } + + @Test + public void createTableRejectsExplicitPartitionValues() { + RecordingHmsClient client = new RecordingHmsClient(); + ConnectorPartitionSpec listWithValues = new ConnectorPartitionSpec( + 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 + // threads this presence flag so the rejection survives. MUTATION: ignoring the flag turns a hard + // error into a silent success. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(client, Collections.emptyMap(), Collections.emptyMap()) + .createTable(session(), request().partitionSpec(listWithValues).build())); + Assertions.assertTrue(ex.getMessage().contains("Partition values expressions")); + } + + @Test + public void createTableListPartitionThreadsPartitionKeys() { + RecordingHmsClient client = new RecordingHmsClient(); + ConnectorPartitionSpec list = new ConnectorPartitionSpec( + ConnectorPartitionSpec.Style.LIST, + Arrays.asList( + new ConnectorPartitionField("dt", "identity", Collections.emptyList()), + new ConnectorPartitionField("region", "identity", Collections.emptyList())), + 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()); + Assertions.assertEquals(Arrays.asList("dt", "region"), + client.lastCreateTable.getPartitionKeys()); + } + + @Test + public void createTableAllowsColumnDefaultsOnNonDlfCatalog() { + RecordingHmsClient client = new RecordingHmsClient(); + List cols = Arrays.asList( + new ConnectorColumn("id", ConnectorType.of("INT"), null, false, 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. + metadata(client, Collections.emptyMap(), Collections.emptyMap()) + .createTable(session(), request().columns(cols).build()); + Assertions.assertNotNull(client.lastCreateTable); + } + + // ==================== createTable: text compression default ==================== + + @Test + public void createTableThreadsTextCompressionSessionDefaultMappingUncompressedToPlain() { + RecordingHmsClient client = new RecordingHmsClient(); + Map sessionProps = Collections.singletonMap( + HiveConnectorProperties.SESSION_HIVE_TEXT_COMPRESSION, "uncompressed"); + // WHY: a text table's fallback compression comes from the hive_text_compression session variable, and + // legacy maps the "uncompressed" alias to "plain". MUTATION: skipping the alias mapping would thread + // "uncompressed" (an unsupported metastore value). + metadata(client, Collections.emptyMap(), Collections.emptyMap()) + .createTable(sessionWith(sessionProps), request().build()); + Assertions.assertEquals("plain", client.lastCreateTable.getDefaultTextCompression()); + } + + // ==================== createDatabase ==================== + + @Test + public void createDatabaseSplitsLocationCommentAndParams() { + RecordingHmsClient client = new RecordingHmsClient(); + Map props = new LinkedHashMap<>(); + props.put("location", "s3://bucket/db"); + props.put("comment", "my db"); + props.put("k", "v"); + // WHY (legacy createDbImpl): the location property becomes the db location URI and is REMOVED from the + // parameters; the comment becomes the description; the rest stay as db parameters. MUTATION: leaving + // location in the parameters, or dropping the description, flips these. + metadata(client, Collections.emptyMap(), Collections.emptyMap()) + .createDatabase(session(), "db1", props); + HmsCreateDatabaseRequest req = client.lastCreateDatabase; + Assertions.assertEquals("db1", req.getDbName()); + Assertions.assertEquals("s3://bucket/db", req.getLocationUri()); + Assertions.assertEquals("my db", req.getComment()); + Assertions.assertFalse(req.getProperties().containsKey("location"), + "location must be removed from db parameters"); + Assertions.assertEquals("v", req.getProperties().get("k")); + } + + // ==================== dropTable / truncateTable ==================== + + @Test + public void dropTableRejectsTransactionalTable() { + RecordingHmsClient client = new RecordingHmsClient(); + HiveTableHandle handle = new HiveTableHandle.Builder("db1", "t1", HiveTableType.HIVE) + .tableParameters(Collections.singletonMap("transactional", "true")) + .build(); + // WHY: legacy dropTableImpl rejects dropping a hive transactional table (via + // AcidUtils.isTransactionalTable). MUTATION: dropping the check lets the drop through -> the seam + // records a dropTable and assertThrows fails. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(client, Collections.emptyMap(), Collections.emptyMap()) + .dropTable(session(), handle)); + Assertions.assertTrue(ex.getMessage().contains("transactional")); + Assertions.assertTrue(client.log.isEmpty(), "reject must happen before the metastore drop"); + } + + @Test + public void dropTableDelegatesForNonTransactionalTable() { + RecordingHmsClient client = new RecordingHmsClient(); + HiveTableHandle handle = new HiveTableHandle.Builder("db1", "t1", HiveTableType.HIVE) + .tableParameters(Collections.emptyMap()) + .build(); + metadata(client, Collections.emptyMap(), Collections.emptyMap()).dropTable(session(), handle); + Assertions.assertEquals(Collections.singletonList("dropTable:db1.t1"), client.log); + } + + @Test + public void truncateTableDelegatesWithPartitions() { + RecordingHmsClient client = new RecordingHmsClient(); + HiveTableHandle handle = new HiveTableHandle.Builder("db1", "t1", HiveTableType.HIVE).build(); + List partitions = Collections.singletonList("dt=2024-01-01"); + metadata(client, Collections.emptyMap(), Collections.emptyMap()) + .truncateTable(session(), handle, partitions); + Assertions.assertEquals( + Collections.singletonList("truncateTable:db1.t1:[dt=2024-01-01]"), client.log); + } + + // ==================== dropDatabase (force cascade) ==================== + + @Test + public void dropDatabaseForceCascadesTableDropsThenDb() { + RecordingHmsClient client = new RecordingHmsClient(); + client.tables.put("t1", tableInfo("db1", "t1", Collections.emptyMap())); + client.tables.put("t2", tableInfo("db1", "t2", Collections.emptyMap())); + // WHY (legacy dropDbImpl with force): every table is dropped first, then the database. MUTATION: + // dropping the cascade would call dropDatabase on a non-empty database. + metadata(client, Collections.emptyMap(), Collections.emptyMap()) + .dropDatabase(session(), "db1", false, true); + Assertions.assertEquals( + Arrays.asList("dropTable:db1.t1", "dropTable:db1.t2", "dropDatabase:db1"), client.log); + } + + @Test + public void dropDatabaseForceRejectsTransactionalTableInCascade() { + RecordingHmsClient client = new RecordingHmsClient(); + client.tables.put("t1", tableInfo("db1", "t1", + Collections.singletonMap("transactional", "true"))); + // WHY: the force cascade drops each table through the SAME transactional check as a direct DROP TABLE, + // so a transactional table aborts the whole force drop (legacy dropTableImpl propagated the error). + // MUTATION: cascading without the check would silently drop a transactional table. + Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(client, Collections.emptyMap(), Collections.emptyMap()) + .dropDatabase(session(), "db1", false, true)); + Assertions.assertTrue(client.log.isEmpty(), "transactional table must abort before any drop"); + } + + @Test + public void dropDatabaseNonForceJustDropsDb() { + RecordingHmsClient client = new RecordingHmsClient(); + client.tables.put("t1", tableInfo("db1", "t1", Collections.emptyMap())); + // WHY: without force the tables are NOT cascaded (legacy left the metastore to reject a non-empty db). + metadata(client, Collections.emptyMap(), Collections.emptyMap()) + .dropDatabase(session(), "db1", false, false); + Assertions.assertEquals(Collections.singletonList("dropDatabase:db1"), client.log); + } + + // ==================== helpers ==================== + + private static HiveConnectorMetadata metadata(RecordingHmsClient client, + Map catalogProps, Map env) { + return new HiveConnectorMetadata(client, catalogProps, new FakeConnectorContext(env)); + } + + private static ConnectorCreateTableRequest.Builder request() { + List cols = Arrays.asList( + new ConnectorColumn("id", ConnectorType.of("INT"), "id", false, null), + new ConnectorColumn("dt", ConnectorType.of("STRING"), null, true, null)); + return ConnectorCreateTableRequest.builder() + .dbName("db1") + .tableName("t1") + .columns(cols) + .comment("t comment") + .properties(new LinkedHashMap<>()); + } + + private static ConnectorSession session() { + return sessionWith(Collections.emptyMap()); + } + + private static ConnectorSession sessionWith(Map sessionProps) { + return new FakeSession(CATALOG_USER, sessionProps); + } + + private static HmsTableInfo tableInfo(String db, String table, Map params) { + return HmsTableInfo.builder().dbName(db).tableName(table).parameters(params).build(); + } + + /** Minimal recording {@link org.apache.doris.connector.hms.HmsClient}: records writes, serves canned reads. */ + private static final class RecordingHmsClient implements org.apache.doris.connector.hms.HmsClient { + private final List log = new ArrayList<>(); + private final Map tables = new LinkedHashMap<>(); + private HmsCreateTableRequest lastCreateTable; + private HmsCreateDatabaseRequest lastCreateDatabase; + + @Override + public List listDatabases() { + return Collections.emptyList(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + throw new HmsClientException("no database: " + dbName); + } + + @Override + public List listTables(String dbName) { + return new ArrayList<>(tables.keySet()); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + return tables.containsKey(tableName); + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + HmsTableInfo info = tables.get(tableName); + if (info == null) { + throw new HmsClientException("no table: " + tableName); + } + return info; + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + return Collections.emptyMap(); + } + + @Override + public List listPartitionNames(String dbName, String tableName, int maxParts) { + return Collections.emptyList(); + } + + @Override + public List getPartitions(String dbName, String tableName, List partNames) { + return Collections.emptyList(); + } + + @Override + public HmsPartitionInfo getPartition(String dbName, String tableName, List values) { + throw new HmsClientException("no partition"); + } + + @Override + public void createDatabase(HmsCreateDatabaseRequest request) { + lastCreateDatabase = request; + log.add("createDatabase:" + request.getDbName()); + } + + @Override + public void dropDatabase(String dbName) { + log.add("dropDatabase:" + dbName); + } + + @Override + public void createTable(HmsCreateTableRequest request) { + lastCreateTable = request; + log.add("createTable:" + request.getDbName() + "." + request.getTableName()); + } + + @Override + public void dropTable(String dbName, String tableName) { + log.add("dropTable:" + dbName + "." + tableName); + } + + @Override + public void truncateTable(String dbName, String tableName, List partitions) { + log.add("truncateTable:" + dbName + "." + tableName + ":" + partitions); + } + + @Override + public void close() { + } + } + + /** Minimal {@link ConnectorSession}: fixed user + a configurable session-property map. */ + private static final class FakeSession implements ConnectorSession { + private final String user; + private final Map sessionProperties; + + private FakeSession(String user, Map sessionProperties) { + this.user = user; + this.sessionProperties = sessionProperties; + } + + @Override + public String getQueryId() { + return "q"; + } + + @Override + public String getUser() { + return user; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public long getCatalogId() { + return 0L; + } + + @Override + public String getCatalogName() { + return "hive_catalog"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + + @Override + public Map getSessionProperties() { + return sessionProperties; + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataFileListStatsTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataFileListStatsTest.java new file mode 100644 index 00000000000000..af52d7e4f5f487 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataFileListStatsTest.java @@ -0,0 +1,281 @@ +// 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.hms.HmsClient; +import org.apache.doris.connector.hms.HmsDatabaseInfo; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsTableInfo; +import org.apache.doris.filesystem.FileSystem; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.ToLongBiFunction; + +/** + * Tests {@link HiveConnectorMetadata#estimateDataSizeByListingFiles} (§4.2 read-side SPI, layer 3 — the + * file-list data-size estimate that fe-core divides by the row width when no exact count or metastore size + * exists). The real {@code FileSystem} listing is injected as a {@code ToLongFunction} so the + * sampling / scale-up / summing math (the tricky part, ported from legacy + * {@code HMSExternalTable.getRowCountFromFileList}) is unit-tested; the raw filesystem I/O is covered by the + * docker e2e gate. + */ +public class HiveConnectorMetadataFileListStatsTest { + + private static HiveConnectorMetadata metadata(HmsClient client) { + return new HiveConnectorMetadata(client, Collections.emptyMap(), new FakeConnectorContext()); + } + + private static HiveTableHandle unpartitioned(String location) { + return new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .location(location) + .build(); + } + + private static HiveTableHandle partitioned() { + return new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .partitionKeyNames(Collections.singletonList("dt")) + .build(); + } + + @Test + public void unpartitionedTableSumsTableLocation() { + long size = metadata(new PartitionFakeHmsClient(Collections.emptyList())) + .estimateDataSize(unpartitioned("s3://wh/t"), 30, + (loc, vals) -> "s3://wh/t".equals(loc) ? 1000 : 0); + Assertions.assertEquals(1000L, size); + } + + @Test + public void unpartitionedTableWithNoLocationReturnsMinusOne() { + long size = metadata(new PartitionFakeHmsClient(Collections.emptyList())) + .estimateDataSize(unpartitioned(null), 30, (loc, vals) -> 999); + Assertions.assertEquals(-1L, size); + } + + @Test + public void allPartitionsSummedWhenBelowSampleCap() { + // 3 partitions (< sample cap): the whole table is listed, no scale-up. 100+200+300 = 600. + PartitionFakeHmsClient client = new PartitionFakeHmsClient(Arrays.asList("p0", "p1", "p2")); + ToLongBiFunction> sizes = + (loc, vals) -> loc.endsWith("p0") ? 100 : loc.endsWith("p1") ? 200 : 300; + Assertions.assertEquals(600L, metadata(client).estimateDataSize(partitioned(), 30, sizes)); + } + + @Test + public void sampledPartitionsAreScaledUpToTheWholeTable() { + // 4 partitions, sampleSize 2 -> list 2, scale up by total/sampled = 4/2. With a UNIFORM per-partition + // size the result is deterministic regardless of which 2 are shuffled in: 2*100 * (4/2) = 400. This + // pins the legacy scale-up (totalSize * totalPartitions / samplePartitions). MUTATION: dropping the + // scale-up returns 200 -> red. + PartitionFakeHmsClient client = new PartitionFakeHmsClient(Arrays.asList("p0", "p1", "p2", "p3")); + Assertions.assertEquals(400L, metadata(client).estimateDataSize(partitioned(), 2, (loc, vals) -> 100)); + } + + @Test + public void zeroTotalSizeReturnsMinusOne() { + PartitionFakeHmsClient client = new PartitionFakeHmsClient(Arrays.asList("p0", "p1")); + Assertions.assertEquals(-1L, metadata(client).estimateDataSize(partitioned(), 30, (loc, vals) -> 0)); + } + + @Test + public void listingErrorDegradesToMinusOneNotThrow() { + // A per-location listing failure aborts the estimate to -1 (legacy all-or-nothing best-effort), and + // must NOT propagate as a query-killing exception. MUTATION: not catching -> the estimate throws -> red. + PartitionFakeHmsClient client = new PartitionFakeHmsClient(Arrays.asList("p0")); + long size = Assertions.assertDoesNotThrow(() -> metadata(client).estimateDataSize( + partitioned(), 30, (loc, vals) -> { + throw new RuntimeException("boom"); + })); + Assertions.assertEquals(-1L, size); + } + + @Test + public void partitionWithoutLocationIsSkipped() { + // A partition carrying no location contributes nothing but must not break the estimate. + PartitionFakeHmsClient client = new PartitionFakeHmsClient(Arrays.asList("p0", "p1")); + client.dropLocationFor("p1"); + Assertions.assertEquals(100L, metadata(client).estimateDataSize( + partitioned(), 30, (loc, vals) -> loc.endsWith("p0") ? 100 : 0)); + } + + @Test + public void scaleSampledSizeMultipliesBeforeDividing() { + // Non-divisible case pins multiply-first (250*4/3 = 333), distinguishing it from a divide-first + // reordering (250/3*4 = 83*4 = 332) that a mutation might introduce. MUTATION: divide-first -> 332 -> red. + Assertions.assertEquals(333L, HiveConnectorMetadata.scaleSampledSize(250, 4, 3)); + } + + @Test + public void publicEntryDegradesToMinusOneAndRestoresClassLoaderOnError() { + // Drives the PUBLIC entry point: its tableType guard passes for HIVE, then it pins the thread context + // classloader and does real FileSystem I/O. A bogus location makes the listing fail, so the estimate + // must degrade to -1 WITHOUT throwing, and the TCCL must be restored to whatever it was (the pin is in + // a try/finally). MUTATION: dropping the finally leaves the plugin loader set -> the marker assertion + // fails; letting the listing error propagate -> assertDoesNotThrow fails. + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .location("file:///__doris_stats_nonexistent_xyz_998877").build(); + HiveConnectorMetadata metadata = metadata(new PartitionFakeHmsClient(Collections.emptyList())); + + ClassLoader marker = new URLClassLoader(new URL[0], + HiveConnectorMetadataFileListStatsTest.class.getClassLoader()); + ClassLoader original = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(marker); + try { + long size = Assertions.assertDoesNotThrow( + () -> metadata.estimateDataSizeByListingFiles(null, handle)); + Assertions.assertEquals(-1L, size, "an unlistable location must degrade to -1"); + Assertions.assertSame(marker, Thread.currentThread().getContextClassLoader(), + "the TCCL pin must be restored on the error path (try/finally)"); + } finally { + Thread.currentThread().setContextClassLoader(original); + } + } + + @Test + public void listFileSizesPropagatesListingErrorAndRestoresClassLoader() { + // ANALYZE ... WITH SAMPLE reads raw per-file sizes here. UNLIKE estimateDataSizeByListingFiles (best-effort + // -1 for query planning), a listing failure must PROPAGATE: legacy HMSExternalTable.getChunkSizes failed the + // sampled ANALYZE loud rather than let the sampler collapse the scale factor to 1.0 while TABLESAMPLE still + // fires (a silent stat undercount). The TCCL pin must still be restored on the throw path (try/finally). + // MUTATION: re-adding a catch -> Collections.emptyList swallows the error -> assertThrows red. + HiveConnectorMetadata metadata = new HiveConnectorMetadata( + new PartitionFakeHmsClient(Collections.emptyList()), Collections.emptyMap(), new FakeConnectorContext(), + () -> null, () -> null, handle -> null, new ThrowingFileListingCache()); + HiveTableHandle handle = unpartitioned("s3://wh/t"); + + ClassLoader marker = new URLClassLoader(new URL[0], + HiveConnectorMetadataFileListStatsTest.class.getClassLoader()); + ClassLoader original = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(marker); + try { + Assertions.assertThrows(RuntimeException.class, () -> metadata.listFileSizes(null, handle), + "a listing failure during sample analyze must fail loud, not degrade to empty"); + Assertions.assertSame(marker, Thread.currentThread().getContextClassLoader(), + "the TCCL pin must be restored on the throw path (try/finally)"); + } finally { + Thread.currentThread().setContextClassLoader(original); + } + } + + @Test + public void nonHiveTableTypeIsNotEstimated() { + // A hudi/iceberg-on-HMS table is served by its own connector; the hive gateway must return -1 BEFORE + // any filesystem I/O (the public entry point's tableType guard). MUTATION: dropping the guard would + // list files for a foreign format. + HiveTableHandle hudiHandle = new HiveTableHandle.Builder("db", "t", HiveTableType.HUDI) + .location("s3://wh/t").build(); + Assertions.assertEquals(-1L, + metadata(new PartitionFakeHmsClient(Collections.emptyList())) + .estimateDataSizeByListingFiles(null, hudiHandle)); + } + + /** A {@link HiveFileListingCache} whose listing always fails, to prove listFileSizes propagates (not swallows). */ + private static final class ThrowingFileListingCache extends HiveFileListingCache { + ThrowingFileListingCache() { + super(Collections.emptyMap()); + } + + @Override + public List listDataFiles(String dbName, String tableName, String location, + List partitionValues, FileSystem fs) { + throw new RuntimeException("simulated listing failure"); + } + } + + /** + * {@link HmsClient} double serving a fixed set of partition names, each with a synthetic location + * {@code s3://wh/t/}. {@code listPartitionNames} echoes the names; {@code getPartitions} builds an + * {@link HmsPartitionInfo} per requested name. The rest fail loud. + */ + private static final class PartitionFakeHmsClient implements HmsClient { + private final List partitionNames; + private final java.util.Set withoutLocation = new java.util.HashSet<>(); + + PartitionFakeHmsClient(List partitionNames) { + this.partitionNames = partitionNames; + } + + void dropLocationFor(String name) { + withoutLocation.add(name); + } + + @Override + public List listPartitionNames(String dbName, String tableName, int maxParts) { + return partitionNames; + } + + @Override + public List getPartitions(String dbName, String tableName, List partNames) { + List result = new ArrayList<>(partNames.size()); + for (String name : partNames) { + String location = withoutLocation.contains(name) ? null : "s3://wh/t/" + name; + result.add(new HmsPartitionInfo( + Collections.singletonList(name), location, null, null, null, Collections.emptyMap())); + } + return result; + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public List listDatabases() { + throw new UnsupportedOperationException(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public List listTables(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public HmsPartitionInfo getPartition(String dbName, String tableName, List values) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() { + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataFreshnessTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataFreshnessTest.java new file mode 100644 index 00000000000000..b552215ea0f5de --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataFreshnessTest.java @@ -0,0 +1,301 @@ +// 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.mvcc.ConnectorMvccSnapshot; +import org.apache.doris.connector.api.mvcc.ConnectorTableFreshness; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsDatabaseInfo; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsTableInfo; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalLong; + +/** + * Tests {@link HiveConnectorMetadata#getTableFreshness} / {@link HiveConnectorMetadata#getPartitionFreshnessMillis} + * (HMS cutover — MVCC/MTMV substep, dormant). + * + *

Why these matter: a flipped hive table is a {@code PluginDrivenMvccExternalTable}, and MTMV + * change-detection over it must vary with the source. Hive's whole-table / per-partition change signal is a + * last-modified TIMESTAMP ({@code transient_lastDdlTime}), NOT a snapshot id — so these methods must reproduce + * legacy {@code HiveDlaTable}:

+ *
    + *
  • table freshness = the table's last-DDL time (unpartitioned) or the max partition modify time + * (partitioned), carrying the owning partition name so dropping it is detected as a change;
  • + *
  • the seconds->millis (×1000) conversion and absent->0 policy live connector-side (fe-core must + * not parse the raw HMS property);
  • + *
  • the unpartitioned table pays NO {@code get_partitions_by_names} round-trip (the time is already on the + * handle), and per-partition freshness is fetched on demand here (the MTMV path), never in the names-only + * {@code listPartitions} hot path.
  • + *
+ */ +public class HiveConnectorMetadataFreshnessTest { + + private static final List PART_KEYS = Arrays.asList("year", "month"); + private static final String TRANSIENT_LAST_DDL_TIME = "transient_lastDdlTime"; + + private HiveConnectorMetadata metadata(FakeHmsClient client) { + return new HiveConnectorMetadata(client, Collections.emptyMap(), new FakeConnectorContext()); + } + + private HiveTableHandle partitionedHandle() { + return new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .partitionKeyNames(PART_KEYS) + .build(); + } + + private HiveTableHandle unpartitionedHandle(Map tableParams) { + return new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .partitionKeyNames(Collections.emptyList()) + .tableParameters(tableParams) + .build(); + } + + // ==================== table freshness: unpartitioned ==================== + + @Test + public void testUnpartitionedUsesTableLastDdlTimeAndNoRoundTrip() { + FakeHmsClient client = new FakeHmsClient(); + // transient_lastDdlTime is in SECONDS; the connector must return millis (x1000), parity + // HMSExternalTable.getLastDdlTime. + ConnectorTableFreshness freshness = metadata(client).getTableFreshness(null, + unpartitionedHandle(Collections.singletonMap(TRANSIENT_LAST_DDL_TIME, "100"))).orElse(null); + + Assertions.assertNotNull(freshness); + Assertions.assertEquals("t", freshness.getName(), + "an unpartitioned table's freshness is named by the table (parity MTMVMaxTimestampSnapshot)"); + Assertions.assertEquals(100_000L, freshness.getTimestampMillis(), + "transient_lastDdlTime seconds must be converted to millis"); + // The time is already on the handle; touching the metastore here would be a needless round-trip. + Assertions.assertFalse(client.listPartitionNamesCalled, + "an unpartitioned table must not list partitions for freshness"); + Assertions.assertFalse(client.getPartitionsCalled, + "an unpartitioned table must not fetch partitions for freshness"); + } + + @Test + public void testUnpartitionedAbsentParamReturnsZero() { + // Absent transient_lastDdlTime -> 0 (parity HMSExternalTable.getLastDdlTime, e.g. hive views). + ConnectorTableFreshness freshness = metadata(new FakeHmsClient()).getTableFreshness(null, + unpartitionedHandle(Collections.emptyMap())).orElse(null); + Assertions.assertNotNull(freshness); + Assertions.assertEquals("t", freshness.getName()); + Assertions.assertEquals(0L, freshness.getTimestampMillis(), + "an absent transient_lastDdlTime must yield 0, not a crash"); + } + + // ==================== table freshness: partitioned ==================== + + @Test + public void testPartitionedReturnsMaxModifyTimeAndOwningPartitionName() { + FakeHmsClient client = new FakeHmsClient() + .partition("year=2023/month=12", 100L) + .partition("year=2024/month=01", 300L) // the max + .partition("year=2024/month=02", 200L); + + ConnectorTableFreshness freshness = + metadata(client).getTableFreshness(null, partitionedHandle()).orElse(null); + + Assertions.assertNotNull(freshness); + // Parity HiveDlaTable.getTableSnapshot: max(partition lastModifiedTime) + the owning partition NAME, + // rendered from values (raw key=value join, parity HivePartition.getPartitionName). + Assertions.assertEquals("year=2024/month=01", freshness.getName(), + "the freshness must be named by the partition owning the max modify time"); + Assertions.assertEquals(300_000L, freshness.getTimestampMillis(), + "the freshness millis must be the max transient_lastDdlTime x 1000"); + } + + @Test + public void testPartitionedEmptyPartitionSetReturnsTableNameZero() { + // No partitions -> MTMVMaxTimestampSnapshot(tableName, 0) parity HiveDlaTable.getTableSnapshot. + ConnectorTableFreshness freshness = + metadata(new FakeHmsClient()).getTableFreshness(null, partitionedHandle()).orElse(null); + Assertions.assertNotNull(freshness); + Assertions.assertEquals("t", freshness.getName()); + Assertions.assertEquals(0L, freshness.getTimestampMillis()); + } + + @Test + public void testPartitionedTieKeepsFirstMax() { + // Two partitions share the max: strictly-greater comparison keeps the FIRST (parity HiveDlaTable's + // `> maxVersionTime`), so the earlier-listed partition name wins. + FakeHmsClient client = new FakeHmsClient() + .partition("year=2024/month=01", 300L) + .partition("year=2024/month=02", 300L); + ConnectorTableFreshness freshness = + metadata(client).getTableFreshness(null, partitionedHandle()).orElse(null); + Assertions.assertNotNull(freshness); + Assertions.assertEquals("year=2024/month=01", freshness.getName(), + "a tie on the max modify time must keep the first partition (strictly-greater)"); + Assertions.assertEquals(300_000L, freshness.getTimestampMillis()); + } + + // ==================== per-partition freshness ==================== + + @Test + public void testPartitionFreshnessMillis() { + FakeHmsClient client = new FakeHmsClient() + .partition("year=2024/month=01", 300L); + OptionalLong millis = metadata(client).getPartitionFreshnessMillis(null, partitionedHandle(), + "year=2024/month=01"); + Assertions.assertTrue(millis.isPresent()); + Assertions.assertEquals(300_000L, millis.getAsLong(), + "per-partition freshness is transient_lastDdlTime x 1000 (parity HivePartition.getLastModifiedTime)"); + } + + @Test + public void testPartitionFreshnessMillisAbsentParamZero() { + FakeHmsClient client = new FakeHmsClient().partitionNoParam("year=2024/month=01"); + OptionalLong millis = metadata(client).getPartitionFreshnessMillis(null, partitionedHandle(), + "year=2024/month=01"); + Assertions.assertTrue(millis.isPresent()); + Assertions.assertEquals(0L, millis.getAsLong(), + "an absent transient_lastDdlTime on a partition must yield 0"); + } + + @Test + public void testPartitionFreshnessMillisVanishedPartitionReturnsEmpty() { + // A partition that vanished between the materialize (existence check) and this fetch (a rare + // refresh-time race): return EMPTY so fe-core raises the legacy "can not find partition" (parity + // HiveDlaTable.checkPartitionExists), rather than emitting a bogus MTMVTimestampSnapshot(0). + OptionalLong millis = metadata(new FakeHmsClient()).getPartitionFreshnessMillis(null, + partitionedHandle(), "year=2024/month=01"); + Assertions.assertFalse(millis.isPresent(), + "a vanished partition must yield empty (fe-core throws 'can not find partition')"); + } + + // ==================== query-begin pin: flags last-modified freshness ==================== + + @Test + public void testBeginQuerySnapshotIsEmptyPinFlaggedLastModified() { + // Hive's query-begin pin is a non-MVCC EMPTY pin (snapshot id -1, no scan options) but FLAGGED + // lastModifiedFreshness, so the generic model serves this table's MTMV snapshots from the last-modified + // freshness SPI instead of pinning a constant snapshot id. The flag rides on the pin so a snapshot-id + // connector (which leaves it false) never fires the freshness probe. + ConnectorMvccSnapshot pin = metadata(new FakeHmsClient()) + .beginQuerySnapshot(null, partitionedHandle()).orElse(null); + Assertions.assertNotNull(pin); + Assertions.assertEquals(-1L, pin.getSnapshotId(), + "hive's pin is the empty (-1) pin: scan reads current (applySnapshot is a no-op)"); + Assertions.assertTrue(pin.isLastModifiedFreshness(), + "hive's pin must flag last-modified freshness so MTMV freshness comes from the on-demand SPI"); + } + + /** + * Minimal {@link HmsClient} double: records the requested partitions by name and returns their + * parameters (with {@code transient_lastDdlTime}) via {@code getPartitions}; {@code listPartitionNames} + * returns the registered names. Records which of the two metastore calls were made. + */ + private static final class FakeHmsClient implements HmsClient { + // Insertion-ordered so getPartitions returns partitions in registration order (drives the tie test). + private final Map partitionDdlSeconds = new LinkedHashMap<>(); + private final List paramlessPartitions = new ArrayList<>(); + private boolean listPartitionNamesCalled; + private boolean getPartitionsCalled; + + FakeHmsClient partition(String name, long ddlSeconds) { + partitionDdlSeconds.put(name, ddlSeconds); + return this; + } + + FakeHmsClient partitionNoParam(String name) { + paramlessPartitions.add(name); + return this; + } + + private HmsPartitionInfo toInfo(String name) { + List values = HiveWriteUtils.toPartitionValues(name); + Map params; + if (paramlessPartitions.contains(name)) { + params = Collections.emptyMap(); + } else { + params = Collections.singletonMap(TRANSIENT_LAST_DDL_TIME, + Long.toString(partitionDdlSeconds.get(name))); + } + return new HmsPartitionInfo(values, "loc", "if", "of", "serde", params); + } + + @Override + public List listPartitionNames(String dbName, String tableName, int maxParts) { + listPartitionNamesCalled = true; + List names = new ArrayList<>(partitionDdlSeconds.keySet()); + names.addAll(paramlessPartitions); + return names; + } + + @Override + public List getPartitions(String dbName, String tableName, List partNames) { + getPartitionsCalled = true; + List result = new ArrayList<>(); + for (String name : partNames) { + if (partitionDdlSeconds.containsKey(name) || paramlessPartitions.contains(name)) { + result.add(toInfo(name)); + } + } + return result; + } + + @Override + public List listDatabases() { + throw new UnsupportedOperationException(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public List listTables(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public HmsPartitionInfo getPartition(String dbName, String tableName, List values) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() { + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataPartitionListTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataPartitionListTest.java new file mode 100644 index 00000000000000..1b3b2ed16027ff --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataPartitionListTest.java @@ -0,0 +1,264 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.ConnectorPartitionInfo; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsDatabaseInfo; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsTableInfo; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Tests {@link HiveConnectorMetadata#listPartitions} / {@link HiveConnectorMetadata#listPartitionNames} + * (HMS cutover §4.2, dormant). + * + *

WHY these assertions matter:

+ *
    + *
  • Names-only, no per-partition round-trip. The single most important invariant: listing + * partitions must call {@code get_partition_names} ONLY and never {@code get_partitions_by_names}. + * Legacy's hot partition-pruning path ({@code HiveExternalMetaCache.loadPartitionValues}) listed names + * only; paying the heavier per-partition fetch here would regress every partitioned-hive query. The + * fake fails loud if {@code getPartitions} is touched.
  • + *
  • {@code lastModifiedMillis} and the stat fields are UNKNOWN(-1). This is the deliberate + * §4.2 decision (freshness deferred to the MVCC/MTMV step); a regression that silently started filling + * them would re-introduce the round-trip this method exists to avoid.
  • + *
  • Value maps are keyed by remote partition-column name and unescaped. + * {@code PluginDrivenExternalTable.getNameToPartitionItems} reads values back by remote name, and + * legacy decoded Hive path-escaping ({@code %2F} -> {@code /}); getting either wrong corrupts the + * partition-value view.
  • + *
  • Unpartitioned tables list nothing without any metastore call (parity guard).
  • + *
  • The filter is ignored (legacy materialized the full set and pruned FE-side).
  • + *
+ */ +public class HiveConnectorMetadataPartitionListTest { + + private static final List PARTITIONS = Arrays.asList( + "year=2023/month=12", + "year=2024/month=01", + "year=2024/month=02"); + + private static final List PART_KEYS = Arrays.asList("year", "month"); + + private HiveConnectorMetadata metadata(FakeHmsClient client) { + return new HiveConnectorMetadata(client, Collections.emptyMap(), new FakeConnectorContext()); + } + + private HiveTableHandle partitionedHandle() { + return new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .partitionKeyNames(PART_KEYS) + .build(); + } + + @Test + public void testListPartitionNames() { + FakeHmsClient client = new FakeHmsClient(PARTITIONS); + List names = metadata(client).listPartitionNames(null, partitionedHandle()); + Assertions.assertEquals(PARTITIONS, names); + // The connector must request ALL partitions via the -1 "unbounded" sentinel; requesting a finite + // cap would silently truncate a >32767-partition table. That -1 maps to an unbounded HMS listing + // (not Short.MAX_VALUE) is pinned separately by ThriftHmsClientMaxPartsTest. + Assertions.assertEquals(-1, client.lastMaxParts); + Assertions.assertFalse(client.getPartitionsCalled, + "listPartitionNames must not touch get_partitions_by_names"); + } + + @Test + public void testListPartitionsNamesAndValues() { + FakeHmsClient client = new FakeHmsClient(PARTITIONS); + List parts = + metadata(client).listPartitions(null, partitionedHandle(), Optional.empty()); + + Assertions.assertEquals(3, parts.size()); + ConnectorPartitionInfo first = parts.get(0); + Assertions.assertEquals("year=2023/month=12", first.getPartitionName()); + Map values = first.getPartitionValues(); + Assertions.assertEquals(2, values.size()); + Assertions.assertEquals("2023", values.get("year")); + Assertions.assertEquals("12", values.get("month")); + } + + @Test + public void testListPartitionsLeavesStatsUnknown() { + FakeHmsClient client = new FakeHmsClient(PARTITIONS); + List parts = + metadata(client).listPartitions(null, partitionedHandle(), Optional.empty()); + for (ConnectorPartitionInfo part : parts) { + Assertions.assertEquals(ConnectorPartitionInfo.UNKNOWN, part.getLastModifiedMillis(), + "lastModifiedMillis must stay UNKNOWN (freshness deferred to the MVCC/MTMV step)"); + Assertions.assertEquals(ConnectorPartitionInfo.UNKNOWN, part.getRowCount()); + Assertions.assertEquals(ConnectorPartitionInfo.UNKNOWN, part.getSizeBytes()); + Assertions.assertEquals(ConnectorPartitionInfo.UNKNOWN, part.getFileCount()); + } + } + + @Test + public void testListPartitionsNeverFetchesPerPartitionMetadata() { + FakeHmsClient client = new FakeHmsClient(PARTITIONS); + metadata(client).listPartitions(null, partitionedHandle(), Optional.empty()); + Assertions.assertFalse(client.getPartitionsCalled, + "listPartitions must list names only, not get_partitions_by_names (hot-path parity)"); + } + + @Test + public void testEscapedPartitionValueIsUnescaped() { + // A Hive partition value containing '/' is path-escaped as %2F in the partition name; the value + // map must decode it (byte-parity with legacy HiveUtil.toPartitionValues). + FakeHmsClient client = new FakeHmsClient(Collections.singletonList("year=2024/month=a%2Fb")); + List parts = + metadata(client).listPartitions(null, partitionedHandle(), Optional.empty()); + Assertions.assertEquals("a/b", parts.get(0).getPartitionValues().get("month")); + } + + @Test + public void testFilterIsIgnored() { + FakeHmsClient client = new FakeHmsClient(PARTITIONS); + ConnectorExpression filter = new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef("year", org.apache.doris.connector.api.ConnectorType.of("STRING")), + new ConnectorLiteral(org.apache.doris.connector.api.ConnectorType.of("STRING"), "2024")); + List parts = + metadata(client).listPartitions(null, partitionedHandle(), Optional.of(filter)); + // Full set returned despite the predicate: pruning is a separate applyFilter concern. + Assertions.assertEquals(3, parts.size()); + } + + @Test + public void testUnpartitionedTableListsNothingWithoutMetastoreCall() { + FakeHmsClient client = new FakeHmsClient(PARTITIONS); + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .partitionKeyNames(Collections.emptyList()) + .build(); + HiveConnectorMetadata metadata = metadata(client); + Assertions.assertTrue(metadata.listPartitionNames(null, handle).isEmpty()); + Assertions.assertTrue(metadata.listPartitions(null, handle, Optional.empty()).isEmpty()); + Assertions.assertFalse(client.listPartitionNamesCalled, + "an unpartitioned table must not call the metastore"); + } + + @Test + public void listPartitionsMarksHiveDefaultSentinelNull() { + // A genuine-NULL partition on the `year` column: HMS renders it as year=__HIVE_DEFAULT_PARTITION__. + // The connector must supply isNull=true for that value (byte-parity with legacy + // HiveExternalMetaCache:309) and false for the ordinary `month` value, positionally aligned to the + // name parse fe-core re-runs -> fe-core builds a typed NullLiteral for `year` (INT/DATE-safe). + FakeHmsClient client = new FakeHmsClient(Collections.singletonList( + "year=__HIVE_DEFAULT_PARTITION__/month=01")); + List parts = + metadata(client).listPartitions(null, partitionedHandle(), Optional.empty()); + // MUTATION: dropping the flag (empty list) or marking the wrong position -> red. + Assertions.assertEquals(Arrays.asList(true, false), + parts.get(0).getPartitionValueNullFlags(), + "year (sentinel) -> null flag true; month -> false"); + // The raw value string is still carried (the flag, not the string, drives nullness downstream). + Assertions.assertEquals("__HIVE_DEFAULT_PARTITION__", parts.get(0).getPartitionValues().get("year")); + // Stats stay UNKNOWN — the opt-in flag must not perturb the names-only contract. + Assertions.assertEquals(ConnectorPartitionInfo.UNKNOWN, parts.get(0).getLastModifiedMillis()); + } + + @Test + public void listPartitionsMarksNoNullForOrdinaryValues() { + // Regression floor: ordinary partitions get all-false flags (no value is the sentinel), so fe-core + // builds plain typed literals exactly as before this fix. + FakeHmsClient client = new FakeHmsClient(PARTITIONS); + List parts = + metadata(client).listPartitions(null, partitionedHandle(), Optional.empty()); + for (ConnectorPartitionInfo part : parts) { + Assertions.assertEquals(Arrays.asList(false, false), part.getPartitionValueNullFlags()); + } + } + + /** + * Minimal {@link HmsClient} double: {@code listPartitionNames} returns a fixed list and records the + * requested {@code maxParts}; {@code getPartitions} fails loud (the per-partition round-trip this path + * must never make). The rest are unsupported. + */ + private static final class FakeHmsClient implements HmsClient { + private final List partitionNames; + private boolean listPartitionNamesCalled; + private boolean getPartitionsCalled; + private int lastMaxParts; + + FakeHmsClient(List partitionNames) { + this.partitionNames = partitionNames; + } + + @Override + public List listPartitionNames(String dbName, String tableName, int maxParts) { + listPartitionNamesCalled = true; + lastMaxParts = maxParts; + return partitionNames; + } + + @Override + public List getPartitions(String dbName, String tableName, List partNames) { + getPartitionsCalled = true; + throw new AssertionError("get_partitions_by_names must not be called by partition listing"); + } + + @Override + public List listDatabases() { + throw new UnsupportedOperationException(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public List listTables(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public HmsPartitionInfo getPartition(String dbName, String tableName, List values) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() { + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataPartitionPruningTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataPartitionPruningTest.java new file mode 100644 index 00000000000000..e3eb2a049f57ad --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataPartitionPruningTest.java @@ -0,0 +1,343 @@ +// 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.ConnectorType; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorFilterConstraint; +import org.apache.doris.connector.api.pushdown.ConnectorIn; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.FilterApplicationResult; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsDatabaseInfo; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsTableInfo; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Tests {@link HiveConnectorMetadata#applyFilter} partition pruning (P3-T07 batch C). + * + *

WHY: this is the direct analog of fe-connector-hudi's HudiPartitionPruningTest — + * both exercise the same EQ/IN partition-pruning helpers (the Hudi T05 fix was mirrored + * from this Hive code). The tests are intentionally near-identical; they differ only in + * the handle type and that Hive resolves matched partition NAMES to + * {@link HmsPartitionInfo} via {@code getPartitions} (capped at 100000), whereas Hudi + * keeps the matched relative paths. Consolidating the two is deferred to the P7 Hive + * migration. These assertions pin: EQ / IN on partition columns prune; predicates on + * non-partition columns never prune; a no-effect predicate leaves the handle untouched + * ({@code Optional.empty()}); a zero-match predicate yields an empty pruned set.

+ */ +public class HiveConnectorMetadataPartitionPruningTest { + + private static final List PARTITIONS = Arrays.asList( + "year=2023/month=12", + "year=2024/month=01", + "year=2024/month=02"); + + private static final List PART_KEYS = Arrays.asList("year", "month"); + + @Test + public void testEqOnPartitionColumnPrunes() { + Optional> result = + applyFilter(partitionedHandle(), eq("year", "2024")); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals( + Arrays.asList("year=2024/month=01", "year=2024/month=02"), + prunedLocations(result)); + } + + @Test + public void testInOnPartitionColumnPrunes() { + Optional> result = + applyFilter(partitionedHandle(), in("month", "01", "12")); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals( + Arrays.asList("year=2023/month=12", "year=2024/month=01"), + prunedLocations(result)); + } + + @Test + public void testAndOfTwoPartitionColumnsPrunes() { + Optional> result = + applyFilter(partitionedHandle(), and(eq("year", "2024"), eq("month", "01"))); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals( + Collections.singletonList("year=2024/month=01"), + prunedLocations(result)); + } + + @Test + public void testNonPartitionColumnInAndIsIgnored() { + Optional> result = + applyFilter(partitionedHandle(), and(eq("year", "2024"), eq("price", "100"))); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals( + Arrays.asList("year=2024/month=01", "year=2024/month=02"), + prunedLocations(result)); + } + + @Test + public void testNonPartitionPredicateOnlyLeavesHandleUntouched() { + Optional> result = + applyFilter(partitionedHandle(), eq("price", "100")); + Assertions.assertFalse(result.isPresent()); + } + + @Test + public void testPredicateMatchingAllPartitionsHasNoEffect() { + Optional> result = + applyFilter(partitionedHandle(), in("year", "2023", "2024")); + Assertions.assertFalse(result.isPresent()); + } + + @Test + public void testPredicateMatchingNoPartitionYieldsEmptyPrunedList() { + Optional> result = + applyFilter(partitionedHandle(), eq("year", "1999")); + Assertions.assertTrue(result.isPresent()); + Assertions.assertTrue(prunedLocations(result).isEmpty()); + } + + @Test + public void testUnpartitionedTableIsNotTouched() { + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .partitionKeyNames(Collections.emptyList()) + .build(); + Optional> result = + applyFilter(handle, eq("year", "2024")); + Assertions.assertFalse(result.isPresent()); + } + + @Test + public void parsePartitionNameUnescapesValues() { + // H1 (unit, durable): the pruning-decision parse must unescape values the same way the sibling + // HiveWriteUtils.toPartitionValues does, or an escaped partition value never string-equals the + // unescaped predicate literal. RED before the fix: "US%3ACA". + Map values = HiveConnectorMetadata.parsePartitionName( + "code=US%3ACA", Collections.singletonList("code")); + Assertions.assertEquals("US:CA", values.get("code"), "colon-escaped value must be decoded"); + } + + @Test + public void testEscapedPartitionValuePrunesInsteadOfDropping() { + // H1 (end-to-end via applyFilter): a partition value with a Hive-escaped char (":" stored as "%3A") + // must still match its unescaped predicate literal. RED before the fix: both escaped names fail the + // string compare -> the pruned set is EMPTY, dropping the real partition (silent row loss). + List escaped = Arrays.asList("code=US%3ACA", "code=EU%3ADE"); + HiveConnectorMetadata metadata = new HiveConnectorMetadata( + new FakeHmsClient(escaped), Collections.emptyMap(), new FakeConnectorContext()); + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .partitionKeyNames(Collections.singletonList("code")) + .build(); + Optional> result = + metadata.applyFilter(null, handle, new ConnectorFilterConstraint(eq("code", "US:CA"))); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals(Collections.singletonList("code=US%3ACA"), prunedLocations(result)); + } + + @Test + public void hiveDateTimeStringRendersHiveCanonicalText() { + // H2 (unit): a DATETIME/TIMESTAMP predicate literal (LocalDateTime) must render Hive-canonical text. + // String.valueOf would yield ISO "2024-01-01T10:00", never matching "2024-01-01 10:00:00". RED before. + Assertions.assertEquals("2024-01-01 10:00:00", + HiveConnectorMetadata.hiveDateTimeString(LocalDateTime.of(2024, 1, 1, 10, 0, 0))); + Assertions.assertEquals("2024-01-01 00:00:00", + HiveConnectorMetadata.hiveDateTimeString(LocalDateTime.of(2024, 1, 1, 0, 0, 0))); + Assertions.assertEquals("2024-01-01 10:00:30", + HiveConnectorMetadata.hiveDateTimeString(LocalDateTime.of(2024, 1, 1, 10, 0, 30))); + Assertions.assertEquals("2024-01-01 10:00:00.123456", + HiveConnectorMetadata.hiveDateTimeString(LocalDateTime.of(2024, 1, 1, 10, 0, 0, 123456 * 1000))); + Assertions.assertEquals("2024-01-01 10:00:00.1", + HiveConnectorMetadata.hiveDateTimeString(LocalDateTime.of(2024, 1, 1, 10, 0, 0, 100000 * 1000))); + } + + @Test + public void testDatePartitionPredicatePrunesUnchanged() { + // H2 non-regression: a DATE predicate literal arrives as a LocalDate (not LocalDateTime), so it is NOT + // diverted to hiveDateTimeString -- String.valueOf(LocalDate) = "2024-01-01" already matches the stored + // Hive DATE partition value. Guards against the datetime branch accidentally catching DATE columns. + List parts = Arrays.asList("dt=2024-01-01", "dt=2024-01-02"); + HiveConnectorMetadata metadata = new HiveConnectorMetadata( + new FakeHmsClient(parts), Collections.emptyMap(), new FakeConnectorContext()); + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .partitionKeyNames(Collections.singletonList("dt")) + .build(); + ConnectorComparison dateEq = new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef("dt", ConnectorType.of("DATEV2")), + new ConnectorLiteral(ConnectorType.of("DATEV2"), LocalDate.of(2024, 1, 1))); + Optional> result = + metadata.applyFilter(null, handle, new ConnectorFilterConstraint(dateEq)); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals(Collections.singletonList("dt=2024-01-01"), prunedLocations(result)); + } + + @Test + public void testDatetimePartitionPredicatePrunesWithHiveCanonicalText() { + // H1+H2 composed end-to-end: a DATETIME partition value stored escaped in HMS ("dt=2024-01-01 10%3A00%3A00") + // must prune-in against a DATETIME predicate literal. RED before H2: the literal renders ISO + // "2024-01-01T10:00" and matches nothing; RED before H1: the stored ":" stays "%3A". Both are required for + // the real partition to survive pruning (else silent row loss). + List parts = Arrays.asList("dt=2024-01-01 10%3A00%3A00", "dt=2024-01-02 00%3A00%3A00"); + HiveConnectorMetadata metadata = new HiveConnectorMetadata( + new FakeHmsClient(parts), Collections.emptyMap(), new FakeConnectorContext()); + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .partitionKeyNames(Collections.singletonList("dt")) + .build(); + ConnectorComparison dtEq = new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef("dt", ConnectorType.of("DATETIMEV2", 6, 0)), + new ConnectorLiteral(ConnectorType.of("DATETIMEV2", 6, 0), LocalDateTime.of(2024, 1, 1, 10, 0, 0))); + Optional> result = + metadata.applyFilter(null, handle, new ConnectorFilterConstraint(dtEq)); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals( + Collections.singletonList("dt=2024-01-01 10%3A00%3A00"), prunedLocations(result)); + } + + // ===== helpers ===== + + private Optional> applyFilter( + HiveTableHandle handle, ConnectorExpression expr) { + HiveConnectorMetadata metadata = new HiveConnectorMetadata( + new FakeHmsClient(PARTITIONS), Collections.emptyMap(), new FakeConnectorContext()); + return metadata.applyFilter(null, handle, new ConnectorFilterConstraint(expr)); + } + + private HiveTableHandle partitionedHandle() { + return new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .partitionKeyNames(PART_KEYS) + .build(); + } + + private List prunedLocations(Optional> result) { + List pruned = + ((HiveTableHandle) result.get().getHandle()).getPrunedPartitions(); + List locations = new ArrayList<>(); + for (HmsPartitionInfo p : pruned) { + locations.add(p.getLocation()); + } + return locations; + } + + private static ConnectorColumnRef colRef(String name) { + return new ConnectorColumnRef(name, ConnectorType.of("STRING")); + } + + private static ConnectorLiteral lit(String value) { + return new ConnectorLiteral(ConnectorType.of("STRING"), value); + } + + private static ConnectorComparison eq(String col, String value) { + return new ConnectorComparison(ConnectorComparison.Operator.EQ, colRef(col), lit(value)); + } + + private static ConnectorIn in(String col, String... values) { + List inList = new ArrayList<>(); + for (String v : values) { + inList.add(lit(v)); + } + return new ConnectorIn(colRef(col), inList, false); + } + + private static ConnectorAnd and(ConnectorExpression... children) { + return new ConnectorAnd(Arrays.asList(children)); + } + + /** + * Minimal {@link HmsClient} double. {@code listPartitionNames} returns a fixed list; + * {@code getPartitions} echoes each requested name back as an {@link HmsPartitionInfo} + * whose location IS the partition name (so the pruning selection can be asserted). + * The rest fail loud. + */ + private static final class FakeHmsClient implements HmsClient { + private final List partitionNames; + + FakeHmsClient(List partitionNames) { + this.partitionNames = partitionNames; + } + + @Override + public List listPartitionNames(String dbName, String tableName, int maxParts) { + return partitionNames; + } + + @Override + public List getPartitions(String dbName, String tableName, + List partNames) { + List result = new ArrayList<>(); + for (String name : partNames) { + result.add(new HmsPartitionInfo(Collections.emptyList(), name, + null, null, null, Collections.emptyMap())); + } + return result; + } + + @Override + public List listDatabases() { + throw new UnsupportedOperationException(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public List listTables(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public HmsPartitionInfo getPartition(String dbName, String tableName, List values) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() { + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSchemaTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSchemaTest.java new file mode 100644 index 00000000000000..23aabc8397d54a --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSchemaTest.java @@ -0,0 +1,443 @@ +// 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.ConnectorCapability; +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorTableSchema; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsDatabaseInfo; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsTableInfo; +import org.apache.doris.thrift.TTableDescriptor; +import org.apache.doris.thrift.TTableType; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Tests {@link HiveConnectorMetadata#getTableSchema} partition-column emission (§4.2 read-side SPI). + * + *

WHY: the generic fe-core consumer {@code PluginDrivenExternalTable.toSchemaCacheValue} derives a table's + * partition columns SOLELY from a {@code partition_columns} CSV-of-raw-names table-property (the same key + * paimon/iceberg/maxcompute emit). If the hive connector appends partition columns to the schema but omits + * that property, every partitioned hive/hudi table reads as unpartitioned (wrong pruning / row count, MTMV + * breakage). These assertions pin: the property carries the raw partition-key names in declaration order; a + * {@code string} PARTITION column is widened to {@code varchar(65533)} for legacy + * {@code HMSExternalTable.initPartitionColumns} parity while a {@code string} DATA column and a non-string + * partition column are left untouched; partition columns come after data columns; an unpartitioned table + * emits no property.

+ */ +public class HiveConnectorMetadataSchemaTest { + + private static final String PARQUET_INPUT_FORMAT = + "org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat"; + private static final String ORC_INPUT_FORMAT = + "org.apache.hadoop.hive.ql.io.orc.OrcInputFormat"; + private static final String TEXT_INPUT_FORMAT = + "org.apache.hadoop.mapred.TextInputFormat"; + private static final String OPEN_CSV_SERDE = "org.apache.hadoop.hive.serde2.OpenCSVSerde"; + private static final String LAZY_SIMPLE_SERDE = "org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe"; + + private ConnectorTableSchema schemaOf(HmsTableInfo tableInfo) { + HiveConnectorMetadata metadata = new HiveConnectorMetadata( + new FakeHmsClient(tableInfo), Collections.emptyMap(), new FakeConnectorContext()); + HiveTableHandle handle = new HiveTableHandle.Builder( + tableInfo.getDbName(), tableInfo.getTableName(), HiveTableType.HIVE).build(); + return metadata.getTableSchema(null, handle); + } + + private static ConnectorColumn col(String name, String typeName) { + return new ConnectorColumn(name, ConnectorType.of(typeName), null, true, null); + } + + private static HmsTableInfo.Builder partitionedTable() { + return HmsTableInfo.builder() + .dbName("db").tableName("t") + .inputFormat(PARQUET_INPUT_FORMAT) + .columns(Arrays.asList(col("id", "INT"), col("name", "STRING"))) + .partitionKeys(Arrays.asList(col("year", "INT"), col("region", "STRING"))) + .parameters(Collections.emptyMap()); + } + + private static HmsTableInfo.Builder unpartitionedTable(String inputFormat) { + return HmsTableInfo.builder() + .dbName("db").tableName("t") + .inputFormat(inputFormat) + .columns(Arrays.asList(col("id", "INT"), col("name", "STRING"))) + .partitionKeys(Collections.emptyList()) + .parameters(Collections.emptyMap()); + } + + private static ConnectorColumn arrayCol(String name, String elementTypeName) { + return new ConnectorColumn(name, + ConnectorType.arrayOf(ConnectorType.of(elementTypeName)), null, true, null); + } + + /** + * A delimited-text table with DECLARED TYPED data columns (int/datetime/boolean + a complex array) and a + * non-string DATE partition key, parameterized by serde lib. Under OpenCSVSerde the reader serves every data + * column as plain string; under any other serde the declared types stand. + */ + private static HmsTableInfo.Builder csvTypedTable(String serdeLib) { + return HmsTableInfo.builder() + .dbName("db").tableName("t") + .inputFormat(TEXT_INPUT_FORMAT) + .serializationLib(serdeLib) + .columns(Arrays.asList(col("id", "INT"), col("ts", "DATETIMEV2"), + col("active", "BOOLEAN"), arrayCol("arr_col", "INT"))) + .partitionKeys(Collections.singletonList(col("dt", "DATEV2"))) + .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; + } + + @Test + public void testPartitionColumnsPropertyEmittedWithRawNamesInOrder() { + ConnectorTableSchema schema = schemaOf(partitionedTable().build()); + Assertions.assertEquals("year,region", + schema.getProperties().get(ConnectorTableSchema.PARTITION_COLUMNS_KEY)); + } + + @Test + public void testStringPartitionColumnWidenedToVarchar65533() { + ConnectorTableSchema schema = schemaOf(partitionedTable().build()); + ConnectorColumn region = columnByName(schema, "region"); + Assertions.assertEquals("VARCHAR", region.getType().getTypeName()); + Assertions.assertEquals(65533, region.getType().getPrecision()); + } + + @Test + public void testNonStringPartitionColumnKeepsDeclaredType() { + ConnectorTableSchema schema = schemaOf(partitionedTable().build()); + Assertions.assertEquals("INT", columnByName(schema, "year").getType().getTypeName()); + } + + @Test + public void testStringDataColumnIsNotWidened() { + // Only PARTITION string columns are coerced; a plain data column of type string stays STRING. + ConnectorTableSchema schema = schemaOf(partitionedTable().build()); + Assertions.assertEquals("STRING", columnByName(schema, "name").getType().getTypeName()); + } + + @Test + public void testOpenCsvSerdeFlattensEveryDataColumnToString() { + // WHY: OpenCSVSerde reads the file as PLAIN text — its deserializer reports every top-level column as + // string, so a declared int/datetime/boolean, and even a complex array/map/struct, is served verbatim as + // a string and never parsed. Legacy resolved this via the metastore get_schema RPC (all-string); the SPI + // reads raw sd.getCols() types, so the connector must reproduce the all-string RESULT. MUTATION: emitting + // the declared typed columns flips TRUE vs 'true', raw datetime vs ISO, empty-string vs NULL — the exact + // regression in test_open_csv_serde / test_hive_serde_prop. + ConnectorTableSchema schema = schemaOf(csvTypedTable(OPEN_CSV_SERDE).build()); + for (String dataCol : Arrays.asList("id", "ts", "active", "arr_col")) { + Assertions.assertEquals("STRING", columnByName(schema, dataCol).getType().getTypeName(), + "OpenCSV data column " + dataCol + " must be flattened to STRING"); + } + } + + @Test + public void testOpenCsvSerdePartitionKeyKeepsDeclaredType() { + // Partition keys are appended by hive AFTER the deserializer, so they keep their declared types: a DATE + // partition key stays a date, NOT string-forced. Guards against flattening the whole schema. + ConnectorTableSchema schema = schemaOf(csvTypedTable(OPEN_CSV_SERDE).build()); + Assertions.assertEquals("DATEV2", columnByName(schema, "dt").getType().getTypeName()); + } + + @Test + public void testNonOpenCsvSerdeKeepsDeclaredDataTypes() { + // The flatten is OpenCSV-gated: an identical table under LazySimpleSerDe (plain text, typed columns) keeps + // its declared int/datetime types — the LazySimple half of test_hive_serde_prop, which must stay typed. + // MUTATION: an ungated flatten would break every typed text/parquet/orc table. + ConnectorTableSchema schema = schemaOf(csvTypedTable(LAZY_SIMPLE_SERDE).build()); + Assertions.assertEquals("INT", columnByName(schema, "id").getType().getTypeName()); + Assertions.assertEquals("DATETIMEV2", columnByName(schema, "ts").getType().getTypeName()); + } + + @Test + public void testOpenCsvViewColumnsAreNotFlattened() { + // buildColumns also serves getViewDefinition; a view's logical columns are never an OpenCSV data payload, + // so the isView guard keeps them at declared types even when the SD carries an OpenCSV serde lib. + ConnectorTableSchema schema = schemaOf( + csvTypedTable(OPEN_CSV_SERDE).tableType("VIRTUAL_VIEW").build()); + Assertions.assertEquals("INT", columnByName(schema, "id").getType().getTypeName()); + } + + @Test + public void testPartitionColumnsComeAfterDataColumns() { + ConnectorTableSchema schema = schemaOf(partitionedTable().build()); + List names = schema.getColumns().stream() + .map(ConnectorColumn::getName).collect(Collectors.toList()); + Assertions.assertEquals(Arrays.asList("id", "name", "year", "region"), names); + } + + @Test + public void testUnpartitionedTableEmitsNoPartitionColumnsProperty() { + HmsTableInfo tableInfo = HmsTableInfo.builder() + .dbName("db").tableName("t") + .inputFormat(PARQUET_INPUT_FORMAT) + .columns(Arrays.asList(col("id", "INT"), col("name", "STRING"))) + .partitionKeys(Collections.emptyList()) + .parameters(Collections.emptyMap()) + .build(); + ConnectorTableSchema schema = schemaOf(tableInfo); + Assertions.assertFalse(schema.getProperties().containsKey(ConnectorTableSchema.PARTITION_COLUMNS_KEY)); + } + + @Test + public void testUserPartitionColumnsParameterCannotCollideWithReservedKey() { + // A user TBLPROPERTY literally named "partition_columns" (bare) can NEVER be mistaken for the reserved + // partition marker, which is namespaced under __internal. (ConnectorTableSchema.PARTITION_COLUMNS_KEY). + // On a NON-partitioned hive table: the connector emits NO reserved key -> fe-core sees no partition + // marker -> the table stays unpartitioned; and the user's bare property flows through unchanged (no + // silent strip). MUTATION: reverting the reserved key to the bare "partition_columns" -> the user + // value would be read as the partition CSV -> the assertNull below fails -> red. + Map params = new HashMap<>(); + params.put("partition_columns", "id"); // a real column name, as a plain user property + HmsTableInfo tableInfo = unpartitionedTable(PARQUET_INPUT_FORMAT).parameters(params).build(); + ConnectorTableSchema schema = schemaOf(tableInfo); + Assertions.assertNull(schema.getProperties().get(ConnectorTableSchema.PARTITION_COLUMNS_KEY), + "an unpartitioned hive table must emit no reserved partition marker"); + Assertions.assertEquals("id", schema.getProperties().get("partition_columns"), + "the user's bare partition_columns property must flow through unchanged (no collision, no strip)"); + } + + @Test + public void testPartitionedTableReservedKeyCoexistsWithCollidingUserParameter() { + // A genuinely partitioned table whose parameters ALSO carry a colliding bare "partition_columns"=id: + // the reserved key (__internal.partition_columns) carries the CONNECTOR's own partition-key CSV + // (year,region), and the user's bare property coexists independently — the two live in different + // namespaces and never overwrite each other. MUTATION: bare reserved key -> the user value would + // overwrite (or be overwritten) -> one of the assertions fails -> red. + Map params = new HashMap<>(); + params.put("partition_columns", "id"); + ConnectorTableSchema schema = schemaOf(partitionedTable().parameters(params).build()); + Assertions.assertEquals("year,region", + schema.getProperties().get(ConnectorTableSchema.PARTITION_COLUMNS_KEY), + "the reserved key must carry the connector's partition-key CSV"); + Assertions.assertEquals("id", schema.getProperties().get("partition_columns"), + "the user's bare property coexists, untouched"); + } + + @Test + public void testTopNLazyCapabilityMarkerEmittedForParquetAndOrc() { + // WHY: Top-N lazy materialize is orc/parquet-only in legacy hive (HMSExternalTable.supportedHiveTopNLazyTable). + // The connector-wide SUPPORTS_TOPN_LAZY_MATERIALIZE cannot express that for a heterogeneous hive catalog, so + // the connector emits it per-table; fe-core (PluginDrivenExternalTable.supportsTopNLazyMaterialize) enables the + // optimization only for tables carrying this marker. MUTATION: not emitting it -> orc/parquet hive tables lose + // Top-N lazy materialization. Membership (not exact CSV) because the same marker also carries auto-analyze. + Assertions.assertTrue(hasCapability(schemaOf(unpartitionedTable(PARQUET_INPUT_FORMAT).build()), + ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE)); + Assertions.assertTrue(hasCapability(schemaOf(unpartitionedTable(ORC_INPUT_FORMAT).build()), + ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE)); + } + + @Test + public void testTopNLazyCapabilityMarkerAbsentForText() { + // A text hive table is not Top-N-lazy eligible in legacy; emitting the Top-N marker would over-enable it. + // (Auto-analyze IS emitted for it — legacy analyzed any hive format — asserted separately below.) + Assertions.assertFalse(hasCapability(schemaOf(unpartitionedTable(TEXT_INPUT_FORMAT).build()), + ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE)); + } + + @Test + public void testColumnAutoAnalyzeMarkerEmittedForEveryPlainHiveFormat() { + // WHY: legacy StatisticsUtil.supportAutoAnalyze admitted EVERY plain-hive (dlaType==HIVE) table into + // background per-column auto-analyze regardless of file format. Emitting it per-table (not connector-wide) + // is what lets fe-core exclude hudi-on-HMS (which legacy excluded) while admitting plain-hive. Unlike Top-N, + // it has NO orc/parquet restriction. MUTATION: gating it on input format -> text/csv/json hive tables + // silently drop out of auto-analyze. + Assertions.assertTrue(hasCapability(schemaOf(unpartitionedTable(PARQUET_INPUT_FORMAT).build()), + ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE)); + Assertions.assertTrue(hasCapability(schemaOf(unpartitionedTable(ORC_INPUT_FORMAT).build()), + ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE)); + Assertions.assertTrue(hasCapability(schemaOf(unpartitionedTable(TEXT_INPUT_FORMAT).build()), + ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE)); + } + + @Test + public void testColumnAutoAnalyzeMarkerAbsentForView() { + // A view has nothing to analyze; excluded like Top-N (isView guard before the format check). + ConnectorTableSchema schema = schemaOf( + unpartitionedTable(PARQUET_INPUT_FORMAT).tableType("VIRTUAL_VIEW").build()); + Assertions.assertFalse(hasCapability(schema, ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE)); + } + + @Test + public void testSampleAnalyzeMarkerEmittedForEveryPlainHiveFormat() { + // Legacy AnalysisManager.canSample gated on dlaType==HIVE (any file format). Emit SUPPORTS_SAMPLE_ANALYZE + // per-table for every plain-hive table so fe-core admits ANALYZE ... WITH SAMPLE while excluding + // iceberg/hudi-on-HMS. Like auto-analyze there is NO orc/parquet restriction. MUTATION: gating on input + // format -> text/csv/json hive tables silently lose sample. + Assertions.assertTrue(hasCapability(schemaOf(unpartitionedTable(PARQUET_INPUT_FORMAT).build()), + ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE)); + Assertions.assertTrue(hasCapability(schemaOf(unpartitionedTable(ORC_INPUT_FORMAT).build()), + ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE)); + Assertions.assertTrue(hasCapability(schemaOf(unpartitionedTable(TEXT_INPUT_FORMAT).build()), + ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE)); + } + + @Test + public void testSampleAnalyzeMarkerAbsentForView() { + // A view is not sampled (legacy canSample excluded it via dlaType); excluded before the format check. + ConnectorTableSchema schema = schemaOf( + unpartitionedTable(PARQUET_INPUT_FORMAT).tableType("VIRTUAL_VIEW").build()); + Assertions.assertFalse(hasCapability(schema, ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE)); + } + + @Test + public void testDistributionColumnsEmittedRawForBucketedTable() { + // Bucketing columns are emitted RAW (fe-core lowercases, mirroring legacy getDistributionColumnNames); + // only a bucketed table carries the marker. MUTATION: not emitting -> a flipped bucketed hive table loses + // the linear NDV estimator in sampled analyze. + ConnectorTableSchema schema = schemaOf( + unpartitionedTable(PARQUET_INPUT_FORMAT).bucketCols(Arrays.asList("Id", "region")).build()); + Assertions.assertEquals("Id,region", + schema.getProperties().get(ConnectorTableSchema.DISTRIBUTION_COLUMNS_KEY)); + } + + @Test + public void testDistributionColumnsAbsentForNonBucketedTable() { + Assertions.assertNull(schemaOf(unpartitionedTable(PARQUET_INPUT_FORMAT).build()) + .getProperties().get(ConnectorTableSchema.DISTRIBUTION_COLUMNS_KEY)); + } + + @Test + public void testTopNLazyCapabilityMarkerAbsentForView() { + // A view is excluded even when its SD carries a parquet input format, mirroring legacy + // 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)); + } + + @Test + public void testTopNLazyCapabilityMarkerAbsentForIcebergOnHms() { + // An iceberg-on-HMS table (table_type=ICEBERG) is served by the iceberg connector after the cutover; the + // 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)); + } + + @Test + public void testBuildTableDescriptorIsHiveTable() { + // Without the override fe-core falls back to a generic SCHEMA_TABLE descriptor; pin that a hive table + // produces a HIVE_TABLE descriptor carrying a THiveTable with the db/table names and column count. + HiveConnectorMetadata metadata = new HiveConnectorMetadata( + new FakeHmsClient(partitionedTable().build()), Collections.emptyMap(), new FakeConnectorContext()); + TTableDescriptor desc = metadata.buildTableDescriptor(null, 42L, "t", "db", "t", 4, 7L); + Assertions.assertEquals(TTableType.HIVE_TABLE, desc.getTableType()); + Assertions.assertEquals(4, desc.getNumCols()); + Assertions.assertNotNull(desc.getHiveTable()); + Assertions.assertEquals("db", desc.getHiveTable().getDbName()); + Assertions.assertEquals("t", desc.getHiveTable().getTableName()); + } + + private static ConnectorColumn columnByName(ConnectorTableSchema schema, String name) { + return schema.getColumns().stream() + .filter(c -> c.getName().equals(name)) + .findFirst() + .orElseThrow(() -> new AssertionError("column not found: " + name)); + } + + /** + * Minimal {@link HmsClient} double: {@code getTable} echoes the prebuilt {@link HmsTableInfo}; + * {@code getDefaultColumnValues} returns none. The rest fail loud. + */ + private static final class FakeHmsClient implements HmsClient { + private final HmsTableInfo tableInfo; + + FakeHmsClient(HmsTableInfo tableInfo) { + this.tableInfo = tableInfo; + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + return tableInfo; + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + return Collections.emptyMap(); + } + + @Override + public List listDatabases() { + throw new UnsupportedOperationException(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public List listTables(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public 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() { + } + } +} 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 new file mode 100644 index 00000000000000..c0121cb70fbba0 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSiblingDelegationTest.java @@ -0,0 +1,865 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorColumnStatistics; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorPartitionInfo; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.ConnectorTableSchema; +import org.apache.doris.connector.api.ConnectorTableStatistics; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.ddl.BranchChange; +import org.apache.doris.connector.api.ddl.ConnectorColumnPosition; +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.handle.ConnectorTransaction; +import org.apache.doris.connector.api.handle.NoOpConnectorTransaction; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.mvcc.ConnectorMvccPartitionView; +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.pushdown.ConnectorColumnRef; +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.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.function.Supplier; + +/** + * Pins the HMS-cutover §4.4 S3 gateway metadata delegation: {@link HiveConnectorMetadata}'s per-handle methods + * route by the concrete handle type — a hive handle runs the existing hive logic, a foreign (iceberg) handle is + * forwarded to the embedded iceberg sibling connector — and NEVER cast the foreign handle. + * + *

Dormant until hms enters {@code SPI_READY_TYPES}: no production path builds a foreign handle for this + * metadata yet, so these assertions are a Rule-9 guard that the divert contract (forward every per-handle read + + * DROP/TRUNCATE, return the sibling's handle UNMODIFIED, fill the iceberg-only silent gaps) is correct BEFORE the + * flip wires it. The hive-handle byte-parity for these methods is covered by the existing per-method suites.

+ */ +public class HiveConnectorMetadataSiblingDelegationTest { + + /** A foreign (non-hive) handle — the marker type the iceberg sibling's getTableHandle produces post-flip. */ + private static final class ForeignHandle implements ConnectorTableHandle { + } + + private final ForeignHandle foreignHandle = new ForeignHandle(); + private final RecordingSiblingMetadata siblingMetadata = new RecordingSiblingMetadata(); + private final RecordingSiblingConnector siblingConnector = new RecordingSiblingConnector(siblingMetadata); + + // A session whose per-statement scope is NONE (offline): the sibling-metadata funnel runs its factory on every + // forward, so the sibling is consulted per call exactly as before the funnel — these forwarding assertions are + // byte-equivalent to the pre-funnel behavior. Per-statement REUSE (one sibling metadata across many forwards) + // is pinned by the "per-statement sibling-metadata funnel" tests below, which run under a live TestStatementScope. + private final ConnectorSession session = new ScopeSession(1L, "q1", ConnectorStatementScope.NONE); + + /** + * The by-TYPE force-build supplier constructor arg. This suite exercises only per-handle (by-handle) sites — + * which must ALL route via the peek resolver — and never calls getTableHandle (the only by-type site), so the + * supplier must never be invoked here. It fails loud if it is, so a per-handle site that regressed from + * {@code siblingMetadata(session, handle)} (peek resolver) to {@code icebergSiblingMetadata(session)} (by-type + * force-build supplier) blows up instead of silently returning the same sibling. + */ + private static final Supplier SUPPLIER_MUST_NOT_BE_USED = () -> { + throw new AssertionError( + "a per-handle site must route via the peek resolver, not the by-type force-build supplier"); + }; + + /** + * Metadata wired so every foreign-handle per-handle site MUST route via the by-handle peek resolver (which + * returns the recording sibling), while the by-type force-build supplier is a fail-loud stub (see + * {@link #SUPPLIER_MUST_NOT_BE_USED}). hmsClient is null: the hive path is never exercised here. This suite + * pins that the per-handle sites FORWARD the whole surface; the 3-way ownsHandle dispatch that PICKS the owner + * is pinned by {@code HiveConnectorThreeWayRoutingTest}. + */ + private HiveConnectorMetadata withSibling() { + return new HiveConnectorMetadata(null, Collections.emptyMap(), new FakeConnectorContext(), + SUPPLIER_MUST_NOT_BE_USED, SUPPLIER_MUST_NOT_BE_USED, + handle -> new SiblingOwner(siblingConnector, SiblingOwner.ICEBERG_LABEL)); + } + + private HiveTableHandle hiveHandle() { + return new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE).build(); + } + + @Test + public void everyPerHandleMethodForwardsAForeignHandleToTheSibling() { + HiveConnectorMetadata md = withSibling(); + + // ---- set (a): methods hive overrides — a foreign handle must NOT run hive logic, it must divert ---- + md.getTableSchema(session, foreignHandle); + md.getColumnHandles(session, foreignHandle); + md.getTableStatistics(session, foreignHandle); + md.getColumnStatistics(session, foreignHandle, "c"); + long size = md.estimateDataSizeByListingFiles(session, foreignHandle); + List> timelineRows = md.getMetadataTableRows(session, foreignHandle, "timeline"); + Optional> filter = md.applyFilter(session, foreignHandle, null); + List partNames = md.listPartitionNames(session, foreignHandle); + md.listPartitions(session, foreignHandle, Optional.empty()); + ConnectorMvccSnapshot pin = md.beginQuerySnapshot(session, foreignHandle).orElse(null); + md.getTableFreshness(session, foreignHandle); + md.getPartitionFreshnessMillis(session, foreignHandle, "p"); + md.dropTable(session, foreignHandle); + md.truncateTable(session, foreignHandle, Collections.emptyList()); + + // ---- set (b): methods hive does NOT override — the silent gaps that must be filled by forwarding ---- + md.getTableSchema(session, foreignHandle, null); + md.getMvccPartitionView(session, foreignHandle); + md.resolveTimeTravel(session, foreignHandle, null); + ConnectorTableHandle afterSnapshot = md.applySnapshot(session, foreignHandle, null); + List predicates = md.getSyntheticScanPredicates(session, foreignHandle, null); + ConnectorTableHandle afterScope = md.applyRewriteFileScope(session, foreignHandle, Collections.emptySet()); + ConnectorTableHandle afterTopn = md.applyTopnLazyMaterialization(session, foreignHandle); + List sysTables = md.listSupportedSysTables(session, foreignHandle); + Optional sysHandle = md.getSysTableHandle(session, foreignHandle, "snapshots"); + // "snapshots" (not "partitions"): hive's own logic returns false for it, so a true answer proves the + // reply came from the sibling, not hive. + boolean sysIsTvf = md.isPartitionValuesSysTable(session, foreignHandle, "snapshots"); + + // Every per-handle method reached the sibling (proves the divert covers the whole surface). + Assertions.assertEquals(RecordingSiblingMetadata.EXPECTED_METHODS, siblingMetadata.calls, + "every per-handle read + DROP/TRUNCATE + iceberg-only gap method must forward a foreign handle"); + + // A few return values prove the ANSWER is the sibling's, not hive's default. + Assertions.assertEquals(RecordingSiblingMetadata.SENTINEL_SIZE, size, + "estimateDataSize must return the sibling's value, not hive's -1"); + Assertions.assertEquals(RecordingSiblingMetadata.SENTINEL_SNAPSHOT_ID, pin.getSnapshotId(), + "beginQuerySnapshot must return the sibling's snapshot-id pin, not hive's -1 last-modified pin"); + Assertions.assertEquals(Collections.singletonList("sibling-part"), partNames, + "listPartitionNames must return the sibling's names"); + Assertions.assertEquals(Collections.singletonList("snapshots"), sysTables, + "iceberg-on-HMS system tables must resolve through the sibling (hive exposes only partitions)"); + Assertions.assertTrue(sysIsTvf, + "isPartitionValuesSysTable must return the sibling's answer for a foreign handle, not hive's — " + + "dropping this delegation would misroute an iceberg-on-HMS t$partitions into the hive TVF"); + Assertions.assertSame(RecordingSiblingMetadata.SIBLING_TIMELINE_ROWS, timelineRows, + "getMetadataTableRows must return the sibling's timeline rows, not hive's empty default — a " + + "hudi-on-HMS hudi_meta()/TIMELINE read gets its rows from the hudi sibling post-flip"); + + // Handle-out methods must return the sibling's handle/result UNMODIFIED (a rewrap poisons a scan cast). + Assertions.assertSame(siblingMetadata.filterResult, filter, "applyFilter must return the sibling result"); + Assertions.assertSame(RecordingSiblingMetadata.SIBLING_HANDLE, afterSnapshot, + "applySnapshot must thread and return the sibling's handle unmodified"); + Assertions.assertSame(RecordingSiblingMetadata.SIBLING_HANDLE, afterScope, + "applyRewriteFileScope must return the sibling's handle unmodified"); + Assertions.assertSame(RecordingSiblingMetadata.SIBLING_HANDLE, afterTopn, + "applyTopnLazyMaterialization must return the sibling's handle unmodified"); + Assertions.assertSame(RecordingSiblingMetadata.SIBLING_HANDLE, sysHandle.orElse(null), + "getSysTableHandle must return the sibling's sys-table handle unmodified"); + Assertions.assertSame(RecordingSiblingMetadata.SIBLING_PREDICATES, predicates, + "getSyntheticScanPredicates must return the sibling's residual predicates unmodified — a " + + "hudi-on-HMS @incr read gets its row filter from the hudi sibling, not hive's empty default"); + } + + @Test + public void hiveHandleRunsHiveBranchAndNeverConsultsSibling() { + HiveConnectorMetadata md = withSibling(); + HiveTableHandle hive = hiveHandle(); + + // The set-(b) + beginQuerySnapshot branches reproduce the SPI default / hive pin WITHOUT the sibling and + // without touching the (null) hmsClient — proving the guard falls through to the hive path for a hive handle. + Assertions.assertFalse(md.getMvccPartitionView(session, hive).isPresent(), "hive has no range partition view"); + Assertions.assertFalse(md.resolveTimeTravel(session, hive, null).isPresent(), "hive has no time travel"); + Assertions.assertSame(hive, md.applySnapshot(session, hive, null), "hive applySnapshot returns the handle"); + Assertions.assertTrue(md.getSyntheticScanPredicates(session, hive, null).isEmpty(), + "plain hive has no synthetic scan predicate"); + Assertions.assertSame(hive, md.applyRewriteFileScope(session, hive, Collections.emptySet()), + "hive applyRewriteFileScope returns the handle"); + Assertions.assertSame(hive, md.applyTopnLazyMaterialization(session, hive), + "hive applyTopnLazyMaterialization returns the handle"); + Assertions.assertEquals(Collections.singletonList("partitions"), md.listSupportedSysTables(session, hive), + "hive exposes the partitions sys table (t$partitions), served by the partition_values TVF"); + Assertions.assertTrue(md.isPartitionValuesSysTable(session, hive, "partitions"), + "hive's partitions sys table is TVF-backed"); + Assertions.assertFalse(md.isPartitionValuesSysTable(session, hive, "snapshots"), + "hive exposes no sys table other than partitions"); + Assertions.assertFalse(md.getSysTableHandle(session, hive, "snapshots").isPresent(), + "hive's TVF-backed sys table has no native handle"); + ConnectorMvccSnapshot pin = md.beginQuerySnapshot(session, hive).orElse(null); + Assertions.assertNotNull(pin); + Assertions.assertEquals(-1L, pin.getSnapshotId(), "hive's pin is the empty (-1) last-modified pin"); + Assertions.assertTrue(pin.isLastModifiedFreshness(), "hive's pin flags last-modified freshness"); + + Assertions.assertEquals(0, siblingConnector.getMetadataCount, + "a hive handle must never build/consult the iceberg sibling"); + Assertions.assertTrue(siblingMetadata.calls.isEmpty(), "the sibling must not be forwarded a hive handle"); + } + + @Test + public void foreignHandleFailsLoudWhenNoSiblingConfigured() { + // The 3-arg constructor (hive-only construction) installs a fail-loud supplier: a foreign handle must + // raise a clear error, not NPE deep in a forward. + HiveConnectorMetadata md = new HiveConnectorMetadata(null, Collections.emptyMap(), + new FakeConnectorContext()); + Assertions.assertThrows(DorisConnectorException.class, () -> md.getTableSchema(session, foreignHandle), + "a foreign handle with no sibling configured must fail loud"); + } + + @Test + public void everyAlterDdlAndValidateMethodForwardsAForeignHandleToTheSibling() { + HiveConnectorMetadata md = withSibling(); + + // The 14 ALTER-DDL mutators + 2 write validators: a foreign (iceberg-on-HMS) handle must divert, never + // run the hive branch and never be cast. Change objects are null — the guard fires on the handle type + // before any param is touched. + md.renameTable(session, foreignHandle, "new"); + md.addColumn(session, foreignHandle, null, null); + md.addColumns(session, foreignHandle, Collections.emptyList()); + md.dropColumn(session, foreignHandle, "c"); + md.renameColumn(session, foreignHandle, "a", "b"); + md.modifyColumn(session, foreignHandle, null, null); + md.reorderColumns(session, foreignHandle, Collections.emptyList()); + md.createOrReplaceBranch(session, foreignHandle, null); + md.createOrReplaceTag(session, foreignHandle, null); + md.dropBranch(session, foreignHandle, null); + md.dropTag(session, foreignHandle, null); + md.addPartitionField(session, foreignHandle, null); + md.dropPartitionField(session, foreignHandle, null); + md.replacePartitionField(session, foreignHandle, null); + md.validateRowLevelDmlMode(session, foreignHandle, null); + md.validateStaticPartitionColumns(session, foreignHandle, Collections.emptyList()); + // Empty list on purpose: a foreign handle must forward REGARDLESS of emptiness (the empty-early-return is + // hive-only) — this would fail if the empty check were placed before the foreign-handle divert. + md.validateWritePartitionNames(session, foreignHandle, Collections.emptyList()); + + Assertions.assertEquals(RecordingSiblingMetadata.EXPECTED_WRITE_METHODS, siblingMetadata.calls, + "every ALTER-DDL mutator + write validator must forward a foreign handle to the sibling"); + } + + @Test + public void hiveHandleRejectsNonEmptyPartitionNamesWithLegacyMessage() { + HiveConnectorMetadata md = withSibling(); + HiveTableHandle hive = hiveHandle(); + + // Net-new port of the legacy fe-core reject (retired BindSink.bindHiveTableSink): the dynamic + // partition-NAME list form INSERT ... PARTITION(p1, p2) is unsupported on a hive table. UNLIKE the two + // permissive validators, a hive handle here THROWS the EXACT legacy message on a non-empty list. The e2e + // test_hive_write_type.groovy asserts on this literal substring, so it must stay byte-identical. + assertThrowsMessage(() -> md.validateWritePartitionNames(session, hive, Arrays.asList("p1", "p2")), + "Not support insert with partition spec in hive catalog."); + + // An empty list (a plain INSERT ... SELECT or a static PARTITION(col='val') INSERT) is legal plain-hive + // and MUST return silently — a throw here would newly reject legal writes. + md.validateWritePartitionNames(session, hive, Collections.emptyList()); + + Assertions.assertEquals(0, siblingConnector.getMetadataCount, + "a hive handle must never build/consult the iceberg sibling to validate partition names"); + Assertions.assertTrue(siblingMetadata.calls.isEmpty(), "the sibling must not be forwarded a hive handle"); + } + + @Test + public void hiveHandleAlterDdlThrowsAndValidateIsNoopAndNeverConsultsSibling() { + HiveConnectorMetadata md = withSibling(); + HiveTableHandle hive = hiveHandle(); + + // Group-1: ALTER-DDL for a hive handle throws the EXACT inherited SPI-default message (byte-parity with + // pre-override behavior) without building or consulting the sibling. + assertThrowsMessage(() -> md.renameTable(session, hive, "n"), "RENAME TABLE not supported"); + assertThrowsMessage(() -> md.addColumn(session, hive, null, null), "ADD COLUMN not supported"); + assertThrowsMessage(() -> md.addColumns(session, hive, Collections.emptyList()), "ADD COLUMNS not supported"); + assertThrowsMessage(() -> md.dropColumn(session, hive, "c"), "DROP COLUMN not supported"); + assertThrowsMessage(() -> md.renameColumn(session, hive, "a", "b"), "RENAME COLUMN not supported"); + assertThrowsMessage(() -> md.modifyColumn(session, hive, null, null), "MODIFY COLUMN not supported"); + assertThrowsMessage(() -> md.reorderColumns(session, hive, Collections.emptyList()), + "REORDER COLUMNS not supported"); + assertThrowsMessage(() -> md.createOrReplaceBranch(session, hive, null), "CREATE/REPLACE BRANCH not supported"); + assertThrowsMessage(() -> md.createOrReplaceTag(session, hive, null), "CREATE/REPLACE TAG not supported"); + assertThrowsMessage(() -> md.dropBranch(session, hive, null), "DROP BRANCH not supported"); + assertThrowsMessage(() -> md.dropTag(session, hive, null), "DROP TAG not supported"); + assertThrowsMessage(() -> md.addPartitionField(session, hive, null), "ADD PARTITION FIELD not supported"); + assertThrowsMessage(() -> md.dropPartitionField(session, hive, null), "DROP PARTITION FIELD not supported"); + assertThrowsMessage(() -> md.replacePartitionField(session, hive, null), "REPLACE PARTITION FIELD not supported"); + + // Group-2: validate* for a hive handle MUST return silently — a throw here would newly reject legal + // plain-hive row-level DML / static-partition INSERTs. + md.validateRowLevelDmlMode(session, hive, null); + md.validateStaticPartitionColumns(session, hive, Collections.emptyList()); + + Assertions.assertEquals(0, siblingConnector.getMetadataCount, + "a hive handle must never build/consult the iceberg sibling for ALTER-DDL / validate"); + Assertions.assertTrue(siblingMetadata.calls.isEmpty(), "the sibling must not be forwarded a hive handle"); + } + + @Test + public void beginTransactionForwardsAForeignHandleToTheSibling() { + HiveConnectorMetadata md = withSibling(); + + // A foreign (iceberg-on-HMS) write must open the SIBLING's transaction, so iceberg's write plan can + // downcast the session-bound transaction to IcebergConnectorTransaction — a HiveConnectorTransaction + // (what the unconditional open would bind) would ClassCastException there. + ConnectorTransaction txn = md.beginTransaction(session, foreignHandle); + + Assertions.assertSame(RecordingSiblingMetadata.SIBLING_TXN, txn, + "a foreign handle must open the sibling's transaction, not a hive one"); + Assertions.assertEquals(Collections.singletonList("beginTransaction"), siblingMetadata.calls, + "beginTransaction must forward the foreign handle to the sibling"); + Assertions.assertEquals(1, siblingConnector.getMetadataCount, "the sibling must be consulted once"); + } + + @Test + public void beginTransactionForHiveHandleOpensHiveTxnAndNeverConsultsSibling() { + // A hive handle must fall through to the connector-level beginTransaction. Stub the no-arg factory so the + // test does not build a real HiveConnectorTransaction (which spins a file-system thread pool); the point + // is the per-handle guard routes a hive handle to the connector's OWN transaction, a foreign one to the + // sibling. The selection must be symmetric — hive and iceberg write plans downcast to different types. + ConnectorTransaction hiveTxn = new NoOpConnectorTransaction(70099L, "HIVE"); + HiveConnectorMetadata md = new HiveConnectorMetadata(null, Collections.emptyMap(), new FakeConnectorContext(), + SUPPLIER_MUST_NOT_BE_USED, SUPPLIER_MUST_NOT_BE_USED, + handle -> new SiblingOwner(siblingConnector, SiblingOwner.ICEBERG_LABEL)) { + @Override + public ConnectorTransaction beginTransaction(ConnectorSession session) { + return hiveTxn; + } + }; + + Assertions.assertSame(hiveTxn, md.beginTransaction(session, hiveHandle()), + "a hive handle must open the connector-level (hive) transaction, not the sibling's"); + Assertions.assertEquals(0, siblingConnector.getMetadataCount, + "a hive handle must never build/consult the iceberg sibling to open a transaction"); + Assertions.assertTrue(siblingMetadata.calls.isEmpty(), "the sibling must not be forwarded a hive handle"); + } + + @Test + public void foreignHandleSchemaReflectsSiblingScanCapabilitiesAsPerTableMarker() { + // Option C: fe-core's PluginDrivenExternalTable.hasScanCapability 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). + // MUTATION: dropping the reflection -> the returned schema carries no marker -> the embedded table drops the + // capabilities post-flip -> red here. + Set siblingCaps = EnumSet.of( + ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE, + ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE, + ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE); + HiveConnectorMetadata md = new HiveConnectorMetadata(null, Collections.emptyMap(), new FakeConnectorContext(), + SUPPLIER_MUST_NOT_BE_USED, SUPPLIER_MUST_NOT_BE_USED, + handle -> new SiblingOwner(new CapabilityDeclaringSiblingConnector(siblingCaps), + 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"); + } + + @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. + 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"); + } + + @Test + public void foreignHandleSchemaWithholdsAutoAnalyzeFromRealHudiSibling() { + // The REAL hudi sibling declares a NON-EMPTY connector-wide set that does NOT include auto-analyze + // (HudiConnector.getCapabilities() = {SUPPORTS_METADATA_TABLE}), so it never takes the isEmpty() + // early-return — it goes through the copy loop. reflectSiblingScanCapabilities copies EXACTLY that set, so a + // flipped hudi-on-HMS table gains the metadata-table capability (hudi_meta()/TIMELINE works) but stays OUT of + // background column auto-analyze — legacy StatisticsUtil.supportAutoAnalyze excluded dlaType HUDI. This pins + // the copy-fidelity path that actually governs hudi withholding (the empty-sibling test only exercises the + // early-return). MUTATION: reflecting auto-analyze for any non-empty owner (or HudiConnector gaining the + // flag) -> the marker would contain SUPPORTS_COLUMN_AUTO_ANALYZE -> red here. + HiveConnectorMetadata md = new HiveConnectorMetadata(null, Collections.emptyMap(), new FakeConnectorContext(), + SUPPLIER_MUST_NOT_BE_USED, SUPPLIER_MUST_NOT_BE_USED, + handle -> new SiblingOwner(new CapabilityDeclaringSiblingConnector( + EnumSet.of(ConnectorCapability.SUPPORTS_METADATA_TABLE)), SiblingOwner.ICEBERG_LABEL)); + + ConnectorTableSchema schema = md.getTableSchema(session, foreignHandle); + String csv = schema.getProperties().get(ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY); + Assertions.assertNotNull(csv, "a non-empty hudi sibling must still stamp its declared capabilities"); + List names = Arrays.asList(csv.split(",")); + Assertions.assertTrue(names.contains(ConnectorCapability.SUPPORTS_METADATA_TABLE.name()), + "the hudi sibling's metadata-table capability must survive delegation (hudi_meta works post-flip)"); + Assertions.assertFalse(names.contains(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE.name()), + "hudi-on-HMS must stay OUT of background auto-analyze (legacy excluded dlaType HUDI)"); + Assertions.assertFalse(names.contains(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE.name()), + "hudi-on-HMS gains no Top-N lazy from a sibling that does not declare it"); + Assertions.assertFalse(names.contains(ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE.name()), + "hudi-on-HMS gains no nested-column prune from a sibling that does not declare it"); + } + + // ============== per-statement sibling-metadata funnel (HMS heterogeneous gateway) ============== + // Within one statement, the gateway obtains ONE sibling ConnectorMetadata per (catalogId, owner) and reuses it + // across every forward (read / scan / DDL / MVCC / the write-transaction open), keyed + // "metadata::" on the session's per-statement scope — mirroring fe-core's own funnel for + // a plain connector. RecordingSiblingConnector.getMetadataCount is the load count. + + @Test + public void liveScopeSharesOneSiblingMetadataAcrossEveryForwardIncludingTheWriteTxn() { + // Many read forwards (including the getTableSchema stray) + the per-handle beginTransaction open, all in one + // statement -> the sibling is built ONCE and every forward (reads AND the write transaction) reuses it. + // MUTATION: not memoizing -> a build per forward -> count > 1 -> red. + HiveConnectorMetadata md = withSibling(); + ConnectorSession live = new ScopeSession(1L, "q1", new TestStatementScope()); + + md.getColumnHandles(live, foreignHandle); + md.listPartitionNames(live, foreignHandle); + md.getMetadataTableRows(live, foreignHandle, "timeline"); + md.getTableSchema(live, foreignHandle); + md.beginTransaction(live, foreignHandle); + + Assertions.assertEquals(1, siblingConnector.getMetadataCount, + "one sibling metadata per (catalog, owner) per statement — reads and the write txn share it"); + } + + @Test + public void noneScopeBuildsAFreshSiblingMetadataEachForward() { + // No live statement scope (offline / no ConnectContext): the funnel factory runs on every forward, exactly + // as before the funnel existed. This is the byte-equivalence guard for the NONE path. + HiveConnectorMetadata md = withSibling(); + + md.getColumnHandles(session, foreignHandle); + md.listPartitionNames(session, foreignHandle); + md.getColumnHandles(session, foreignHandle); + + Assertions.assertEquals(3, siblingConnector.getMetadataCount, + "NONE scope -> the sibling metadata factory runs on every forward (pre-funnel behavior)"); + } + + @Test + public void byTypeDivertAndByHandleForwardShareOneSiblingMetadata() { + // The getTableHandle divert asks the sibling BY TYPE (icebergSiblingMetadata) before any handle exists; the + // later per-handle forwards resolve BY HANDLE. Both must mint the SAME funnel key for the same owner (the + // by-TYPE literal label == the by-HANDLE resolver-arm label), or the statement would hold two sibling + // metadata instances. Wire the iceberg by-TYPE supplier to the SAME recording connector the resolver + // returns, then drive both paths under one live scope. MUTATION: mismatched labels -> two builds -> red. + HiveConnectorMetadata md = new HiveConnectorMetadata(null, Collections.emptyMap(), new FakeConnectorContext(), + () -> siblingConnector, SUPPLIER_MUST_NOT_BE_USED, + handle -> new SiblingOwner(siblingConnector, SiblingOwner.ICEBERG_LABEL)); + ConnectorSession live = new ScopeSession(1L, "q1", new TestStatementScope()); + + md.icebergSiblingMetadata(live); // by-TYPE (getTableHandle divert path) + md.getColumnHandles(live, foreignHandle); // by-HANDLE (per-handle forward) + + Assertions.assertEquals(1, siblingConnector.getMetadataCount, + "the by-TYPE divert and the by-HANDLE forward key the same owner -> one shared sibling metadata"); + } + + @Test + public void differentCatalogIdIsolatesTheSiblingMetadata() { + // A statement joining two heterogeneous HMS catalogs shares one scope map; the catalog id in the key keeps + // each catalog's sibling metadata isolated. MUTATION: dropping catalogId from the key -> the two collide. + HiveConnectorMetadata md = withSibling(); + TestStatementScope scope = new TestStatementScope(); + + md.getColumnHandles(new ScopeSession(1L, "q1", scope), foreignHandle); + md.getColumnHandles(new ScopeSession(2L, "q1", scope), foreignHandle); + + Assertions.assertEquals(2, siblingConnector.getMetadataCount, + "a different catalog id keys a different sibling metadata (no cross-catalog collapse)"); + } + + @Test + public void icebergAndHudiSiblingsAreIsolatedWithinAStatement() { + // The whole point of the owner label: iceberg-on-HMS and hudi-on-HMS tables of ONE gateway share a catalog + // id, so ONLY the label ("iceberg" vs "hudi") keeps their metadata entries apart. Each owner is built once + // and never collapses onto the other. MUTATION: a shared (label-less) key -> one owner's metadata serves + // the other -> a count is 0 while the other is 2 -> red. + RecordingSiblingConnector hudiConnector = new RecordingSiblingConnector(new RecordingSiblingMetadata()); + ForeignHandle hudiHandle = new ForeignHandle(); + HiveConnectorMetadata md = new HiveConnectorMetadata(null, Collections.emptyMap(), new FakeConnectorContext(), + SUPPLIER_MUST_NOT_BE_USED, SUPPLIER_MUST_NOT_BE_USED, + handle -> handle == hudiHandle + ? new SiblingOwner(hudiConnector, SiblingOwner.HUDI_LABEL) + : new SiblingOwner(siblingConnector, SiblingOwner.ICEBERG_LABEL)); + ConnectorSession live = new ScopeSession(1L, "q1", new TestStatementScope()); + + md.getColumnHandles(live, foreignHandle); // iceberg owner + md.getColumnHandles(live, hudiHandle); // hudi owner + md.getColumnHandles(live, foreignHandle); // iceberg owner again -> reuse + + Assertions.assertEquals(1, siblingConnector.getMetadataCount, + "the iceberg owner is built once under its label"); + Assertions.assertEquals(1, hudiConnector.getMetadataCount, + "the hudi owner is isolated under its own label and built once (never collapsed onto iceberg)"); + } + + private static void assertThrowsMessage(Executable exec, String expectedMessage) { + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, exec); + Assertions.assertEquals(expectedMessage, e.getMessage(), + "the hive branch must reproduce the exact inherited SPI-default message"); + } + + /** A sibling {@link Connector} whose getMetadata hands back the recording metadata and counts the calls. */ + private static final class RecordingSiblingConnector implements Connector { + private final ConnectorMetadata metadata; + private int getMetadataCount; + + RecordingSiblingConnector(ConnectorMetadata metadata) { + this.metadata = metadata; + } + + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + getMetadataCount++; + return metadata; + } + } + + /** A sibling {@link Connector} declaring a fixed capability set; its metadata returns a marker-less schema. */ + private static final class CapabilityDeclaringSiblingConnector implements Connector { + private final Set caps; + + CapabilityDeclaringSiblingConnector(Set caps) { + this.caps = caps; + } + + @Override + public Set getCapabilities() { + return caps; + } + + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + return new RecordingSiblingMetadata(); + } + } + + /** Records each forwarded method name and returns distinguishable sentinels. */ + private static final class RecordingSiblingMetadata implements ConnectorMetadata { + static final ConnectorTableHandle SIBLING_HANDLE = new ForeignHandle(); + static final ConnectorTransaction SIBLING_TXN = new NoOpConnectorTransaction(4243L, "ICEBERG"); + static final long SENTINEL_SIZE = 4242L; + static final long SENTINEL_SNAPSHOT_ID = 99L; + static final List SIBLING_PREDICATES = Collections.singletonList( + new ConnectorColumnRef("sibling-pred", ConnectorType.of("STRING"))); + static final List> SIBLING_TIMELINE_ROWS = Collections.singletonList( + Arrays.asList("20260101000000000", "commit", "COMPLETED", "20260101000001000")); + + // The exact set + order of forwarded methods the foreign-handle test drives (a Rule-9 completeness lock: + // dropping a guard, or adding one that should not forward, changes this list and fails the test). + static final List EXPECTED_METHODS = Collections.unmodifiableList(Arrays.asList( + "getTableSchema", "getColumnHandles", "getTableStatistics", "getColumnStatistics", + "estimateDataSizeByListingFiles", "getMetadataTableRows", + "applyFilter", "listPartitionNames", "listPartitions", + "beginQuerySnapshot", "getTableFreshness", "getPartitionFreshnessMillis", "dropTable", + "truncateTable", "getTableSchemaAtSnapshot", "getMvccPartitionView", "resolveTimeTravel", + "applySnapshot", "getSyntheticScanPredicates", "applyRewriteFileScope", + "applyTopnLazyMaterialization", "listSupportedSysTables", "getSysTableHandle", + "isPartitionValuesSysTable")); + + // The exact set + order of ALTER-DDL / validate methods the foreign-handle write test drives (Rule-9 + // 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", + "addPartitionField", "dropPartitionField", "replacePartitionField", + "validateRowLevelDmlMode", "validateStaticPartitionColumns", "validateWritePartitionNames")); + + final List calls = new ArrayList<>(); + final Optional> filterResult = + Optional.of(new FilterApplicationResult<>(SIBLING_HANDLE, null, false)); + + @Override + public ConnectorTableSchema getTableSchema(ConnectorSession session, ConnectorTableHandle handle) { + calls.add("getTableSchema"); + return new ConnectorTableSchema("sibling", Collections.emptyList(), "iceberg", Collections.emptyMap()); + } + + @Override + public ConnectorTableSchema getTableSchema(ConnectorSession session, ConnectorTableHandle handle, + ConnectorMvccSnapshot snapshot) { + calls.add("getTableSchemaAtSnapshot"); + return new ConnectorTableSchema("sibling", Collections.emptyList(), "iceberg", Collections.emptyMap()); + } + + @Override + public Map getColumnHandles(ConnectorSession session, + ConnectorTableHandle handle) { + calls.add("getColumnHandles"); + return Collections.emptyMap(); + } + + @Override + public Optional getTableStatistics(ConnectorSession session, + ConnectorTableHandle handle) { + calls.add("getTableStatistics"); + return Optional.of(new ConnectorTableStatistics(1L, 2L)); + } + + @Override + public Optional getColumnStatistics(ConnectorSession session, + ConnectorTableHandle handle, String columnName) { + calls.add("getColumnStatistics"); + return Optional.empty(); + } + + @Override + public long estimateDataSizeByListingFiles(ConnectorSession session, ConnectorTableHandle handle) { + calls.add("estimateDataSizeByListingFiles"); + return SENTINEL_SIZE; + } + + @Override + public List> getMetadataTableRows(ConnectorSession session, ConnectorTableHandle handle, + String kind) { + calls.add("getMetadataTableRows"); + return SIBLING_TIMELINE_ROWS; + } + + @Override + public Optional> applyFilter(ConnectorSession session, + ConnectorTableHandle handle, ConnectorFilterConstraint constraint) { + calls.add("applyFilter"); + return filterResult; + } + + @Override + public List listPartitionNames(ConnectorSession session, ConnectorTableHandle handle) { + calls.add("listPartitionNames"); + return Collections.singletonList("sibling-part"); + } + + @Override + public List listPartitions(ConnectorSession session, ConnectorTableHandle handle, + Optional filter) { + calls.add("listPartitions"); + return Collections.emptyList(); + } + + @Override + public Optional beginQuerySnapshot(ConnectorSession session, + ConnectorTableHandle handle) { + calls.add("beginQuerySnapshot"); + return Optional.of(ConnectorMvccSnapshot.builder().snapshotId(SENTINEL_SNAPSHOT_ID).build()); + } + + @Override + public Optional getTableFreshness(ConnectorSession session, + ConnectorTableHandle handle) { + calls.add("getTableFreshness"); + return Optional.empty(); + } + + @Override + public OptionalLong getPartitionFreshnessMillis(ConnectorSession session, ConnectorTableHandle handle, + String partitionName) { + calls.add("getPartitionFreshnessMillis"); + return OptionalLong.of(55L); + } + + @Override + public void dropTable(ConnectorSession session, ConnectorTableHandle handle) { + calls.add("dropTable"); + } + + @Override + public void truncateTable(ConnectorSession session, ConnectorTableHandle handle, List partitions) { + calls.add("truncateTable"); + } + + @Override + public ConnectorTransaction beginTransaction(ConnectorSession session, ConnectorTableHandle handle) { + calls.add("beginTransaction"); + return SIBLING_TXN; + } + + @Override + public Optional getMvccPartitionView(ConnectorSession session, + ConnectorTableHandle handle) { + calls.add("getMvccPartitionView"); + return Optional.empty(); + } + + @Override + public Optional resolveTimeTravel(ConnectorSession session, + ConnectorTableHandle handle, ConnectorTimeTravelSpec spec) { + calls.add("resolveTimeTravel"); + return Optional.empty(); + } + + @Override + public ConnectorTableHandle applySnapshot(ConnectorSession session, ConnectorTableHandle handle, + ConnectorMvccSnapshot snapshot) { + calls.add("applySnapshot"); + return SIBLING_HANDLE; + } + + @Override + public List getSyntheticScanPredicates(ConnectorSession session, + ConnectorTableHandle handle, ConnectorMvccSnapshot snapshot) { + calls.add("getSyntheticScanPredicates"); + return SIBLING_PREDICATES; + } + + @Override + public ConnectorTableHandle applyRewriteFileScope(ConnectorSession session, ConnectorTableHandle handle, + Set rawDataFilePaths) { + calls.add("applyRewriteFileScope"); + return SIBLING_HANDLE; + } + + @Override + public ConnectorTableHandle applyTopnLazyMaterialization(ConnectorSession session, + ConnectorTableHandle handle) { + calls.add("applyTopnLazyMaterialization"); + return SIBLING_HANDLE; + } + + @Override + public List listSupportedSysTables(ConnectorSession session, ConnectorTableHandle baseTableHandle) { + calls.add("listSupportedSysTables"); + return Collections.singletonList("snapshots"); + } + + @Override + public Optional getSysTableHandle(ConnectorSession session, + ConnectorTableHandle baseTableHandle, String sysName) { + calls.add("getSysTableHandle"); + return Optional.of(SIBLING_HANDLE); + } + + @Override + public boolean isPartitionValuesSysTable(ConnectorSession session, + ConnectorTableHandle baseTableHandle, String sysName) { + calls.add("isPartitionValuesSysTable"); + // A distinctive true (hive's own logic would say false for "snapshots") proves the divert. + return true; + } + + // ---- §4.4 W1: ALTER-DDL mutators + write validators (the write-delegation surface) ---- + + @Override + public void renameTable(ConnectorSession session, ConnectorTableHandle handle, String newName) { + calls.add("renameTable"); + } + + @Override + public void addColumn(ConnectorSession session, ConnectorTableHandle handle, ConnectorColumn column, + ConnectorColumnPosition position) { + calls.add("addColumn"); + } + + @Override + public void addColumns(ConnectorSession session, ConnectorTableHandle handle, List columns) { + calls.add("addColumns"); + } + + @Override + public void dropColumn(ConnectorSession session, ConnectorTableHandle handle, String columnName) { + calls.add("dropColumn"); + } + + @Override + public void renameColumn(ConnectorSession session, ConnectorTableHandle handle, String oldName, + String newName) { + calls.add("renameColumn"); + } + + @Override + public void modifyColumn(ConnectorSession session, ConnectorTableHandle handle, ConnectorColumn column, + ConnectorColumnPosition position) { + calls.add("modifyColumn"); + } + + @Override + public void reorderColumns(ConnectorSession session, ConnectorTableHandle handle, List newOrder) { + calls.add("reorderColumns"); + } + + @Override + public void createOrReplaceBranch(ConnectorSession session, ConnectorTableHandle handle, + BranchChange branch) { + calls.add("createOrReplaceBranch"); + } + + @Override + public void createOrReplaceTag(ConnectorSession session, ConnectorTableHandle handle, TagChange tag) { + calls.add("createOrReplaceTag"); + } + + @Override + public void dropBranch(ConnectorSession session, ConnectorTableHandle handle, DropRefChange branch) { + calls.add("dropBranch"); + } + + @Override + public void dropTag(ConnectorSession session, ConnectorTableHandle handle, DropRefChange tag) { + calls.add("dropTag"); + } + + @Override + public void addPartitionField(ConnectorSession session, ConnectorTableHandle handle, + PartitionFieldChange change) { + calls.add("addPartitionField"); + } + + @Override + public void dropPartitionField(ConnectorSession session, ConnectorTableHandle handle, + PartitionFieldChange change) { + calls.add("dropPartitionField"); + } + + @Override + public void replacePartitionField(ConnectorSession session, ConnectorTableHandle handle, + PartitionFieldChange change) { + calls.add("replacePartitionField"); + } + + @Override + public void validateRowLevelDmlMode(ConnectorSession session, ConnectorTableHandle handle, + WriteOperation op) { + calls.add("validateRowLevelDmlMode"); + } + + @Override + public void validateStaticPartitionColumns(ConnectorSession session, ConnectorTableHandle handle, + List staticPartitionColumnNames) { + calls.add("validateStaticPartitionColumns"); + } + + @Override + public void validateWritePartitionNames(ConnectorSession session, ConnectorTableHandle handle, + List partitionNames) { + calls.add("validateWritePartitionNames"); + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataStatisticsTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataStatisticsTest.java new file mode 100644 index 00000000000000..0d9b105662373d --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataStatisticsTest.java @@ -0,0 +1,188 @@ +// 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.ConnectorTableStatistics; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Tests {@link HiveConnectorMetadata#getTableStatistics} (§4.2 read-side SPI, layers 1+2). + * + *

WHY: without this override the connector inherits {@code ConnectorStatisticsOps}'s + * {@code Optional.empty()}, so every flipped hive table reports row count -1 (UNKNOWN) and the Nereids + * cost model collapses cardinality to 1 (join-reorder disabled). The connector surfaces two RAW metastore + * facts and does NO Doris-type math: the exact {@code numRows} row count, and the on-disk {@code totalSize} + * data size (fe-core turns a size-without-count into an estimated row count). These assertions pin the + * legacy {@code StatisticsUtil.getHiveRowCount} / {@code getRowCountFromParameters} / {@code getTotalSizeFromHMS} + * behaviour, including two deliberate asymmetries: the spark {@code numRows} key is consulted ONLY when the + * standard {@code numRows} is present-but-non-positive, whereas the spark {@code totalSize} key is consulted + * when the standard {@code totalSize} is ABSENT.

+ */ +public class HiveConnectorMetadataStatisticsTest { + + // getTableStatistics never touches the HmsClient (it reads the handle's captured parameters), so a null + // client is sufficient and keeps the test focused on the parameter interpretation. + private static Optional statsOf(Map tableParameters) { + HiveConnectorMetadata metadata = new HiveConnectorMetadata( + null, Collections.emptyMap(), new FakeConnectorContext()); + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .tableParameters(tableParameters) + .build(); + return metadata.getTableStatistics(null, handle); + } + + private static Map params(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + @Test + public void numRowsSurfacedAsExactRowCount() { + // A positive numRows is the exact cardinality; it MUST reach the FE cost model. MUTATION: + // inheriting the default empty -> not present -> red. + Optional stats = statsOf(params("numRows", "1234")); + Assertions.assertTrue(stats.isPresent()); + Assertions.assertEquals(1234L, stats.get().getRowCount()); + } + + @Test + public void zeroNumRowsMapsToUnknown() { + // Legacy gated on rows > 0; a 0 count means UNKNOWN, not a real 0 cardinality (which would corrupt + // cost estimates). With no size either, the whole result is empty. MUTATION: dropping the >0 gate + // (reporting rowCount 0) -> present with rowCount 0 -> red. + Assertions.assertFalse(statsOf(params("numRows", "0")).isPresent(), + "a 0 numRows with no size must map to UNKNOWN (empty)"); + } + + @Test + public void sparkNumRowsUsedOnlyWhenNumRowsPresentButNonPositive() { + // Legacy consults spark.sql.statistics.numRows as a fallback ONLY inside the "numRows key present" + // branch, when numRows <= 0. Here numRows=0 present -> spark 500 is used. + Optional stats = statsOf( + params("numRows", "0", "spark.sql.statistics.numRows", "500")); + Assertions.assertTrue(stats.isPresent()); + Assertions.assertEquals(500L, stats.get().getRowCount()); + } + + @Test + public void sparkNumRowsIgnoredWhenNumRowsKeyAbsent() { + // WHY (legacy quirk, faithfully preserved): getRowCountFromParameters only enters the spark + // fallback if the STANDARD numRows key is present. A table carrying ONLY the spark row-count key + // does not surface it as a row count. MUTATION: checking spark unconditionally would return 500 + // here -> rowCount 500 -> red. + Optional stats = statsOf( + params("spark.sql.statistics.numRows", "500")); + Assertions.assertFalse(stats.isPresent(), + "spark numRows must be ignored when the standard numRows key is absent (legacy parity)"); + } + + @Test + public void totalSizeSurfacedAsDataSizeWithUnknownRowCount() { + // A table with totalSize but no numRows reports rowCount UNKNOWN(-1) + the raw dataSize; fe-core + // performs the totalSize/rowWidth estimation (the connector must not import fe-type). MUTATION: + // estimating in the connector, or dropping dataSize, breaks the fe-core estimate. + Optional stats = statsOf(params("totalSize", "4000000")); + Assertions.assertTrue(stats.isPresent()); + Assertions.assertEquals(-1L, stats.get().getRowCount()); + Assertions.assertEquals(4000000L, stats.get().getDataSize()); + } + + @Test + public void sparkTotalSizeUsedWhenStandardTotalSizeAbsent() { + // The size branch's asymmetry: the spark totalSize IS consulted when the standard totalSize key is + // absent (contrast the numRows fallback). MUTATION: requiring the standard key present -> dataSize + // -1 here -> red. + Optional stats = statsOf( + params("spark.sql.statistics.totalSize", "9999")); + Assertions.assertTrue(stats.isPresent()); + Assertions.assertEquals(9999L, stats.get().getDataSize()); + } + + @Test + public void standardTotalSizePreferredOverSpark() { + // When both size keys exist the standard totalSize wins (legacy: containsKey(TOTAL_SIZE) ? ... : spark). + Optional stats = statsOf( + params("totalSize", "100", "spark.sql.statistics.totalSize", "200")); + Assertions.assertEquals(100L, stats.get().getDataSize()); + } + + @Test + public void exactCountAndSizeBothSurfaced() { + // numRows present AND totalSize present: both facts surface (rowCount wins downstream, dataSize is + // still reported for callers that consume it). Legacy returned only the count, but reporting the + // size too is harmless and lets fe-core keep both. + Optional stats = statsOf( + params("numRows", "10", "totalSize", "4096")); + Assertions.assertEquals(10L, stats.get().getRowCount()); + Assertions.assertEquals(4096L, stats.get().getDataSize()); + } + + @Test + public void bothAbsentReturnsEmpty() { + Assertions.assertFalse(statsOf(params("comment", "hi")).isPresent(), + "a table with neither a row count nor a size must report UNKNOWN (empty)"); + } + + @Test + public void nullParametersReturnsEmpty() { + Assertions.assertFalse(statsOf(null).isPresent()); + } + + @Test + public void malformedNumRowsDoesNotThrowAndRecoversSparkCount() { + // DELIBERATE DEVIATION from legacy (documented): legacy's bare Long.parseLong on a malformed numRows + // threw, aborting the whole metastore-stat path so the query fell through to the file-list estimate. + // The connector instead parses defensively (-1) and, because the numRows KEY is present, recovers the + // valid spark count — strictly more useful on corrupt metadata, and it must never throw. MUTATION: + // letting the parse propagate -> exception -> red. + Optional stats = Assertions.assertDoesNotThrow(() -> + statsOf(params("numRows", "not-a-number", "spark.sql.statistics.numRows", "7"))); + Assertions.assertTrue(stats.isPresent()); + Assertions.assertEquals(7L, stats.get().getRowCount()); + } + + @Test + public void malformedTotalSizeDegradesToEmpty() { + // A malformed size parses to -1; with no count either, the result is empty (not an exception). + Optional stats = Assertions.assertDoesNotThrow(() -> + statsOf(params("totalSize", "garbage"))); + Assertions.assertFalse(stats.isPresent()); + } + + @Test + public void malformedStandardTotalSizeShortCircuitsSparkKey() { + // The size branch is an if/else-if on the STANDARD key: a present-but-malformed totalSize parses to -1 + // and must NOT fall through to the spark key (the standard key's presence wins). With no row count the + // result is empty. MUTATION: restructuring the else-if so a malformed standard key falls to spark would + // surface 9999 -> present -> red. + Optional stats = statsOf( + params("totalSize", "garbage", "spark.sql.statistics.totalSize", "9999")); + Assertions.assertFalse(stats.isPresent(), + "a present (even malformed) standard totalSize must short-circuit the spark size key"); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSysTableTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSysTableTest.java new file mode 100644 index 00000000000000..5a2bf073f4144d --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSysTableTest.java @@ -0,0 +1,107 @@ +// 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.handle.ConnectorTableHandle; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.Optional; + +/** + * Tests the hive connector's system-table exposure ({@code t$partitions}), the FIX-R3 restoration of + * legacy {@code HMSExternalTable.getSupportedSysTables()} for {@code dlaType==HIVE}. + * + *

WHY these assertions matter:

+ *
    + *
  • {@code partitions} is advertised, TVF-backed. A flipped hive table is a + * {@code PluginDrivenExternalTable}; fe-core discovers its sys tables from + * {@code listSupportedSysTables} and asks {@code isPartitionValuesSysTable} whether each is served + * by the generic {@code partition_values} TVF (fe-core {@code PartitionsSysTable}) vs a native + * scan. Reporting nothing here left {@code t$partitions} resolving to "Unknown sys table".
  • + *
  • Exposed UNCONDITIONALLY (partitioned or not). Mirrors legacy: a {@code $partitions} + * query on a NON-partitioned table must reach the TVF and throw "… is not a partitioned table", + * not "Unknown sys table" — so a non-partitioned handle must still advertise {@code partitions}.
  • + *
  • No native handle. Because {@code partitions} is TVF-backed, {@code getSysTableHandle} + * stays empty — the native handle path must never be consulted for it.
  • + *
+ * + *

The hive-handle path never touches the metastore client, so the client is {@code null} + * (mirroring {@code HiveConnectorMetadataSiblingDelegationTest}); a foreign-handle divert is covered + * there.

+ */ +public class HiveConnectorMetadataSysTableTest { + + private HiveConnectorMetadata metadata() { + return new HiveConnectorMetadata(null, Collections.emptyMap(), new FakeConnectorContext()); + } + + private HiveTableHandle partitionedHandle() { + return new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .partitionKeyNames(Collections.singletonList("p")) + .build(); + } + + private HiveTableHandle unpartitionedHandle() { + return new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE).build(); + } + + @Test + public void listsOnlyPartitionsForAHiveHandle() { + Assertions.assertEquals(Collections.singletonList("partitions"), + metadata().listSupportedSysTables(null, partitionedHandle()), + "hive exposes the partitions sys table (t$partitions), served by the partition_values TVF"); + } + + @Test + public void exposesPartitionsUnconditionallyEvenWhenUnpartitioned() { + // Load-bearing: a $partitions query on a non-partitioned table must reach the TVF (which throws + // "is not a partitioned table"), not short-circuit to "Unknown sys table". So it must still advertise. + Assertions.assertEquals(Collections.singletonList("partitions"), + metadata().listSupportedSysTables(null, unpartitionedHandle()), + "partitions must be advertised for a non-partitioned hive table too (legacy parity)"); + } + + @Test + public void partitionsIsPartitionValuesTvfBacked() { + Assertions.assertTrue(metadata().isPartitionValuesSysTable(null, partitionedHandle(), "partitions"), + "hive's partitions sys table is served by the generic partition_values TVF"); + } + + @Test + public void onlyPartitionsIsTvfBacked() { + HiveConnectorMetadata md = metadata(); + HiveTableHandle h = partitionedHandle(); + Assertions.assertFalse(md.isPartitionValuesSysTable(null, h, "snapshots"), + "hive exposes no sys table other than partitions"); + Assertions.assertFalse(md.isPartitionValuesSysTable(null, h, "PARTITIONS"), + "the sys-table name is case-sensitive lowercase (findSysTable is exact-match)"); + Assertions.assertFalse(md.isPartitionValuesSysTable(null, h, null), + "a null sys name is simply not exposed (no NPE)"); + } + + @Test + public void tvfBackedSysTableHasNoNativeHandle() { + Optional handle = + metadata().getSysTableHandle(null, partitionedHandle(), "partitions"); + Assertions.assertFalse(handle.isPresent(), + "a TVF-backed sys table is served without a native connector handle"); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataTableHandleDivertTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataTableHandleDivertTest.java new file mode 100644 index 00000000000000..a7aa4830fb39b2 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataTableHandleDivertTest.java @@ -0,0 +1,333 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.hms.HmsClient; +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.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +/** + * Pins the HMS-cutover foreign-handle diverts in {@link HiveConnectorMetadata#getTableHandle}: an ICEBERG table is + * diverted to the embedded iceberg sibling and a HUDI table (HD-B2, the arming pivot) to the embedded hudi sibling — + * each returning that sibling's OWN (foreign) handle verbatim — while a plain HIVE table keeps building a + * {@link HiveTableHandle} on the hive path and never touches either sibling. + * + *

WHY (Rule 9): these diverts are what makes a foreign iceberg/hudi handle START flowing out of getTableHandle, + * which activates every {@code instanceof HiveTableHandle} guard-and-forward override in {@link HiveConnectorMetadata} + * (routed 3-way by the owning sibling). The contract that must hold BEFORE the flip wires it:

+ *
    + *
  • An iceberg-on-HMS table must return the iceberg connector's handle unchanged, and a hudi-on-HMS table the + * hudi connector's handle unchanged — so each sibling's own unconditional concrete-handle cast on its + * scan/metadata path succeeds — NOT a HiveTableHandle stamped ICEBERG/HUDI, which would CCE the moment the + * sibling cast it.
  • + *
  • Routing must be BY TYPE and to the RIGHT sibling: a HUDI table must reach the hudi sibling, never the iceberg + * one (diverting it to iceberg would CCE / silently mis-read hudi data).
  • + *
  • A plain-hive table must resolve entirely on the hive path, never building either sibling (a hive-only + * deployment has no iceberg or hudi plugin).
  • + *
  • The HUDI arm must not swallow the genuine-UNKNOWN fail-loud (an unsupported non-view format still throws).
  • + *
+ * + *

Dormant until hms enters {@code SPI_READY_TYPES}: no production path calls getTableHandle for this connector + * yet, so these assertions are a guard, not a live-path test.

+ */ +public class HiveConnectorMetadataTableHandleDivertTest { + + private static final String PARQUET = "org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat"; + private static final String HUDI = "org.apache.hudi.hadoop.HoodieParquetInputFormat"; + private static final String UNKNOWN_FORMAT = "com.example.NotAHiveOrHudiOrIcebergInputFormat"; + + // getTableHandle routes BY TYPE (the two by-TYPE suppliers), NEVER via the by-handle owner resolver — so wire + // the owner resolver to fail loud if anything reaches for it. + private static final Function OWNER_RESOLVER_UNUSED = handle -> { + throw new AssertionError("getTableHandle must divert BY TYPE, never via the by-handle owner resolver"); + }; + + // getTableHandle's by-TYPE divert routes through the per-statement sibling-metadata funnel, which reads + // session.getStatementScope() + getCatalogId(); a NONE scope makes the factory run every call (byte-equivalent + // to the pre-funnel divert), so these by-type routing assertions are unchanged. + private final ConnectorSession session = new ScopeSession(1L, "q1", ConnectorStatementScope.NONE); + + /** The foreign (non-hive) handle a sibling's getTableHandle produces post-flip. */ + private static final class ForeignHandle implements ConnectorTableHandle { + } + + private final ForeignHandle icebergHandle = new ForeignHandle(); + private final ForeignHandle hudiHandle = new ForeignHandle(); + private final RecordingSibling icebergSibling = new RecordingSibling(icebergHandle); + private final RecordingSibling hudiSibling = new RecordingSibling(hudiHandle); + + @Test + public void icebergTableDivertsToIcebergSiblingNotHudi() { + HiveConnectorMetadata md = withSiblings(icebergTable()); + + Optional handle = md.getTableHandle(session, "db", "t"); + + Assertions.assertTrue(handle.isPresent(), "an existing iceberg-on-HMS table must resolve a handle"); + Assertions.assertSame(icebergHandle, handle.get(), + "getTableHandle must return the iceberg sibling's OWN handle unmodified (a rewrap poisons the " + + "downstream iceberg cast)"); + Assertions.assertFalse(handle.get() instanceof HiveTableHandle, + "an iceberg table must NOT be served a HiveTableHandle stamped ICEBERG"); + Assertions.assertEquals(1, icebergSibling.metadata.getTableHandleCalls, + "the divert must consult the iceberg sibling exactly once"); + Assertions.assertEquals(0, hudiSibling.getMetadataCalls, + "an iceberg table must NEVER build or consult the hudi sibling"); + } + + @Test + public void icebergDivertPropagatesSiblingEmpty() { + // The sibling is authoritative for iceberg existence: if its getTableHandle returns empty (e.g. the + // iceberg catalog cannot load it), the gateway forwards that empty rather than fabricating a hive handle. + icebergSibling.metadata.returnHandle = null; + HiveConnectorMetadata md = withSiblings(icebergTable()); + + Assertions.assertFalse(md.getTableHandle(session, "db", "t").isPresent(), + "an empty from the sibling must pass through unchanged"); + // Prove the empty was FORWARDED from the sibling (its getTableHandle was consulted), not short-circuited + // to empty by the gateway — otherwise a broken `if (ICEBERG) return Optional.empty()` would pass this test. + Assertions.assertEquals(1, icebergSibling.metadata.getTableHandleCalls, + "the sibling is authoritative for iceberg existence: its getTableHandle must be the source of empty"); + } + + @Test + public void hudiTableDivertsToHudiSiblingNotIceberg() { + // HD-B2 arming pivot: a hudi-on-HMS table is diverted to the hudi sibling and returns the hudi sibling's + // OWN foreign handle verbatim — NOT a HiveTableHandle stamped HUDI, and NOT routed to the iceberg sibling. + HiveConnectorMetadata md = withSiblings(hiveTable(HUDI)); + + Optional handle = md.getTableHandle(session, "db", "t"); + + Assertions.assertTrue(handle.isPresent(), "an existing hudi-on-HMS table must resolve a handle"); + Assertions.assertSame(hudiHandle, handle.get(), + "getTableHandle must return the hudi sibling's OWN handle unmodified (a rewrap poisons the " + + "downstream hudi cast)"); + Assertions.assertFalse(handle.get() instanceof HiveTableHandle, + "a hudi table must NOT be served a HiveTableHandle stamped HUDI"); + Assertions.assertEquals(1, hudiSibling.metadata.getTableHandleCalls, + "the divert must consult the hudi sibling exactly once"); + Assertions.assertEquals(0, icebergSibling.getMetadataCalls, + "a hudi table must NEVER be diverted to the iceberg sibling"); + } + + @Test + public void hudiDivertPropagatesSiblingEmpty() { + // The hudi sibling is authoritative for hudi existence: an empty from it passes through, and it must be + // the SOURCE of that empty (its getTableHandle was consulted), not a gateway short-circuit. + hudiSibling.metadata.returnHandle = null; + HiveConnectorMetadata md = withSiblings(hiveTable(HUDI)); + + Assertions.assertFalse(md.getTableHandle(session, "db", "t").isPresent(), + "an empty from the hudi sibling must pass through unchanged"); + Assertions.assertEquals(1, hudiSibling.metadata.getTableHandleCalls, + "the hudi sibling is authoritative for hudi existence: its getTableHandle must be the source of empty"); + } + + @Test + public void hiveTableBuildsHiveHandleWithoutConsultingSibling() { + HiveConnectorMetadata md = withSiblings(hiveTable(PARQUET)); + + Optional handle = md.getTableHandle(session, "db", "t"); + + Assertions.assertTrue(handle.get() instanceof HiveTableHandle, "a hive table resolves a HiveTableHandle"); + Assertions.assertEquals(HiveTableType.HIVE, ((HiveTableHandle) handle.get()).getTableType()); + Assertions.assertEquals(0, icebergSibling.getMetadataCalls, + "a hive table must never build or consult the iceberg sibling"); + Assertions.assertEquals(0, hudiSibling.getMetadataCalls, + "a hive table must never build or consult the hudi sibling"); + } + + @Test + public void unknownNonViewTableStillFailsLoud() { + // The HUDI arm sits BEFORE the UNKNOWN fail-loud and fires only on tableType == HUDI, so a genuine-UNKNOWN + // non-view table is still rejected (not swallowed into a hudi divert). + HiveConnectorMetadata md = withSiblings(hiveTable(UNKNOWN_FORMAT)); + + Assertions.assertThrows(DorisConnectorException.class, () -> md.getTableHandle(session, "db", "t"), + "an unsupported non-view input format must fail loud, not be diverted to a sibling"); + Assertions.assertEquals(0, hudiSibling.getMetadataCalls, + "the UNKNOWN fail-loud must not consult the hudi sibling"); + Assertions.assertEquals(0, icebergSibling.getMetadataCalls, + "the UNKNOWN fail-loud must not consult the iceberg sibling"); + } + + @Test + public void missingTableReturnsEmptyWithoutConsultingSibling() { + HiveConnectorMetadata md = new HiveConnectorMetadata( + new FakeHmsClient(icebergTable(), false), Collections.emptyMap(), new FakeConnectorContext(), + () -> icebergSibling, () -> hudiSibling, OWNER_RESOLVER_UNUSED); + + Assertions.assertFalse(md.getTableHandle(session, "db", "t").isPresent(), + "a non-existent table short-circuits to empty before any format detection or divert"); + Assertions.assertEquals(0, icebergSibling.getMetadataCalls, "a missing table must not build the iceberg sibling"); + Assertions.assertEquals(0, hudiSibling.getMetadataCalls, "a missing table must not build the hudi sibling"); + } + + @Test + public void icebergTableFailsLoudWhenNoSiblingConfigured() { + // The 3-arg constructor (hive-only construction) installs a fail-loud sibling supplier: an iceberg table + // must raise a clear error, not NPE, when the iceberg plugin is unavailable. + HiveConnectorMetadata md = new HiveConnectorMetadata( + new FakeHmsClient(icebergTable(), true), Collections.emptyMap(), new FakeConnectorContext()); + + Assertions.assertThrows(DorisConnectorException.class, () -> md.getTableHandle(session, "db", "t"), + "an iceberg table with no sibling configured must fail loud"); + } + + @Test + public void hudiTableFailsLoudWhenNoSiblingConfigured() { + // Symmetric with iceberg: the 3-arg constructor installs a fail-loud hudi sibling supplier, so a hudi + // table must raise a clear error, not NPE, when the hudi plugin is unavailable. + HiveConnectorMetadata md = new HiveConnectorMetadata( + new FakeHmsClient(hiveTable(HUDI), true), Collections.emptyMap(), new FakeConnectorContext()); + + Assertions.assertThrows(DorisConnectorException.class, () -> md.getTableHandle(session, "db", "t"), + "a hudi table with no sibling configured must fail loud"); + } + + // ===== helpers ===== + + private HiveConnectorMetadata withSiblings(HmsTableInfo tableInfo) { + // getTableHandle diverts iceberg/hudi BY TYPE (the two by-TYPE suppliers); the by-handle owner resolver is + // never used on this path, so it fails loud if anything reaches for it. + return new HiveConnectorMetadata(new FakeHmsClient(tableInfo, true), Collections.emptyMap(), + new FakeConnectorContext(), () -> icebergSibling, () -> hudiSibling, OWNER_RESOLVER_UNUSED); + } + + private static HmsTableInfo hiveTable(String inputFormat) { + return HmsTableInfo.builder() + .dbName("db").tableName("t").tableType("MANAGED_TABLE") + .inputFormat(inputFormat) + .build(); + } + + private static HmsTableInfo icebergTable() { + return HmsTableInfo.builder() + .dbName("db").tableName("t").tableType("EXTERNAL_TABLE") + .parameters(Collections.singletonMap("table_type", "ICEBERG")) + .build(); + } + + /** A sibling {@link Connector} whose getMetadata hands back a recording metadata and counts the builds. */ + private static final class RecordingSibling implements Connector { + private final RecordingSiblingMetadata metadata; + private int getMetadataCalls; + + RecordingSibling(ConnectorTableHandle handle) { + this.metadata = new RecordingSiblingMetadata(handle); + } + + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + getMetadataCalls++; + return metadata; + } + } + + /** Records getTableHandle calls and returns a configurable foreign handle (null -> empty). */ + private static final class RecordingSiblingMetadata implements ConnectorMetadata { + private ConnectorTableHandle returnHandle; + private int getTableHandleCalls; + + RecordingSiblingMetadata(ConnectorTableHandle handle) { + this.returnHandle = handle; + } + + @Override + public Optional getTableHandle(ConnectorSession session, String dbName, + String tableName) { + getTableHandleCalls++; + return Optional.ofNullable(returnHandle); + } + } + + /** Minimal {@link HmsClient} double serving one prebuilt table; the rest fail loud. */ + private static final class FakeHmsClient implements HmsClient { + private final HmsTableInfo table; + private final boolean exists; + + FakeHmsClient(HmsTableInfo table, boolean exists) { + this.table = table; + this.exists = exists; + } + + @Override + public boolean tableExists(String dbName, String tableName) { + return exists; + } + + @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() { + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataViewTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataViewTest.java new file mode 100644 index 00000000000000..487abb2ff6af7f --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataViewTest.java @@ -0,0 +1,267 @@ +// 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.ConnectorCapability; +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.ConnectorViewDefinition; +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.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Tests the hive connector VIEW SPI (§4.2 read-side SPI): {@link HiveConnectorMetadata#viewExists}, + * {@link HiveConnectorMetadata#getViewDefinition}, {@link HiveConnectorMetadata#dropView}, the empty + * {@code listViewNames} default, and {@link HiveConnector}'s {@code SUPPORTS_VIEW} declaration. + * + *

WHY these assertions matter: + *

    + *
  • {@code viewExists} keys off the PRESENCE OF VIEW TEXT (legacy {@code HMSExternalTable.isView}), not the + * {@code tableType}. It drives both {@code PluginDrivenExternalTable.isView()} AND the unconditional + * {@code PluginDrivenExternalCatalog.dropTable -> viewExists -> dropView} routing, so it MUST return + * {@code false} for a base table — otherwise every hive DROP TABLE would misroute into dropView.
  • + *
  • {@code getViewDefinition} reproduces legacy {@code getViewText} bit-for-bit (expanded-first; skip the + * bare {@code /* Presto View *}{@code /} sentinel; else base64-decode the Presto/Trino {@code originalSql}; + * raw-original fallback on any decode failure) and supplies the view's columns — fe-core builds a flipped + * view's schema SOLELY from here, so the column list must be non-empty.
  • + *
  • {@code listViewNames} MUST stay empty: hive {@code listTableNames} already includes views, and the + * fe-core SHOW TABLES merge is a plain {@code addAll} with no dedup, so a non-empty value double-lists.
  • + *
+ */ +public class HiveConnectorMetadataViewTest { + + private static final String DB = "db"; + private static final String VIRTUAL_VIEW = "VIRTUAL_VIEW"; + + private HiveConnectorMetadata metadataOf(FakeHmsClient client) { + return new HiveConnectorMetadata(client, Collections.emptyMap(), new FakeConnectorContext()); + } + + private static ConnectorColumn col(String name, String typeName) { + return new ConnectorColumn(name, ConnectorType.of(typeName), null, true, null); + } + + /** A hive VIEW carrying a plain (non-Presto) expanded text and ordinary columns. */ + private static HmsTableInfo plainView(String name, String expandedText) { + return HmsTableInfo.builder() + .dbName(DB).tableName(name) + .tableType(VIRTUAL_VIEW) + .viewExpandedText(expandedText) + .viewOriginalText(expandedText) + .columns(Arrays.asList(col("id", "INT"), col("name", "STRING"))) + .parameters(Collections.emptyMap()) + .build(); + } + + /** A base table: no view text. */ + private static HmsTableInfo baseTable(String name) { + return HmsTableInfo.builder() + .dbName(DB).tableName(name) + .tableType("MANAGED_TABLE") + .columns(Arrays.asList(col("id", "INT"))) + .parameters(Collections.emptyMap()) + .build(); + } + + /** A Presto/Trino-authored hive view: sentinel expanded text + base64 JSON original text. */ + private static HmsTableInfo prestoView(String name, String originalSql) { + String json = "{\"originalSql\":\"" + originalSql + "\",\"catalog\":\"hive\",\"schema\":\"db\"}"; + String base64 = Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + return HmsTableInfo.builder() + .dbName(DB).tableName(name) + .tableType(VIRTUAL_VIEW) + .viewExpandedText("/* Presto View */") + .viewOriginalText("/* Presto View: " + base64 + " */") + .columns(Arrays.asList(col("id", "INT"))) + .parameters(Collections.emptyMap()) + .build(); + } + + @Test + public void viewExistsTrueForViewFalseForBaseTableAndMissing() { + FakeHmsClient client = new FakeHmsClient(); + client.put(plainView("v", "SELECT 1")); + client.put(baseTable("t")); + HiveConnectorMetadata metadata = metadataOf(client); + + Assertions.assertTrue(metadata.viewExists(null, DB, "v"), "a table with view text is a view"); + Assertions.assertFalse(metadata.viewExists(null, DB, "t"), + "a base table must NOT be a view (else DROP TABLE misroutes to dropView)"); + Assertions.assertFalse(metadata.viewExists(null, DB, "missing"), + "a missing table is not a view (getTable throws HmsClientException -> false)"); + } + + @Test + public void getViewDefinitionReturnsExpandedTextColumnsAndPlaceholderDialect() { + FakeHmsClient client = new FakeHmsClient(); + client.put(plainView("v", "SELECT id, name FROM base")); + HiveConnectorMetadata metadata = metadataOf(client); + + ConnectorViewDefinition def = metadata.getViewDefinition(null, DB, "v"); + Assertions.assertEquals("SELECT id, name FROM base", def.getSql(), + "a plain view returns its expanded text verbatim"); + Assertions.assertEquals("hive", def.getDialect(), "dialect is the placeholder (fe-core never reads it)"); + Assertions.assertEquals(Arrays.asList("id", "name"), + def.getColumns().stream().map(ConnectorColumn::getName).collect(Collectors.toList()), + "view columns come from the metastore table columns (non-empty)"); + } + + @Test + public void getViewDefinitionDecodesPrestoBase64OriginalSql() { + FakeHmsClient client = new FakeHmsClient(); + client.put(prestoView("pv", "SELECT * FROM employees")); + HiveConnectorMetadata metadata = metadataOf(client); + + ConnectorViewDefinition def = metadata.getViewDefinition(null, DB, "pv"); + Assertions.assertEquals("SELECT * FROM employees", def.getSql(), + "the sentinel expanded text is skipped and the base64 originalSql is decoded"); + } + + @Test + public void getViewDefinitionFallsBackToRawOriginalOnMalformedBase64() { + FakeHmsClient client = new FakeHmsClient(); + HmsTableInfo malformed = HmsTableInfo.builder() + .dbName(DB).tableName("bad") + .tableType(VIRTUAL_VIEW) + .viewExpandedText("/* Presto View */") + .viewOriginalText("/* Presto View: @@not-base64@@ */") + .columns(Arrays.asList(col("id", "INT"))) + .parameters(Collections.emptyMap()) + .build(); + client.put(malformed); + HiveConnectorMetadata metadata = metadataOf(client); + + ConnectorViewDefinition def = metadata.getViewDefinition(null, DB, "bad"); + Assertions.assertEquals("/* Presto View: @@not-base64@@ */", def.getSql(), + "a decode failure falls back to the raw original text (legacy parity)"); + } + + @Test + public void dropViewRoutesToMetastoreDropTable() { + FakeHmsClient client = new FakeHmsClient(); + client.put(plainView("v", "SELECT 1")); + HiveConnectorMetadata metadata = metadataOf(client); + + metadata.dropView(null, DB, "v"); + Assertions.assertEquals(Collections.singletonList(DB + ".v"), client.droppedTables, + "a view drop is a metastore dropTable (hive has no separate drop-view)"); + } + + @Test + public void listViewNamesIsEmptyToAvoidDoubleListing() { + HiveConnectorMetadata metadata = metadataOf(new FakeHmsClient()); + Assertions.assertTrue(metadata.listViewNames(null, DB).isEmpty(), + "hive listTableNames already includes views; listViewNames must be empty or SHOW TABLES doubles"); + } + + @Test + public void connectorDeclaresSupportsView() { + HiveConnector connector = new HiveConnector(Collections.emptyMap(), new FakeConnectorContext()); + Assertions.assertTrue(connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_VIEW), + "the hive connector must declare SUPPORTS_VIEW so the generic view path lights up post-flip"); + } + + /** + * Recording fake {@link HmsClient}: serves prebuilt {@link HmsTableInfo}s by name (throwing + * {@link HmsClientException} for an unknown name, like the real client) and records dropTable calls. All + * other operations are unused by the view SPI and throw. + */ + private static final class FakeHmsClient implements HmsClient { + private final Map tables = new HashMap<>(); + private final List droppedTables = new ArrayList<>(); + + void put(HmsTableInfo info) { + tables.put(info.getTableName(), info); + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + HmsTableInfo info = tables.get(tableName); + if (info == null) { + throw new HmsClientException("table not found: " + dbName + "." + tableName); + } + return info; + } + + @Override + public boolean tableExists(String dbName, String tableName) { + return tables.containsKey(tableName); + } + + @Override + public void dropTable(String dbName, String tableName) { + droppedTables.add(dbName + "." + tableName); + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + return Collections.emptyMap(); + } + + @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 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() { + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorPluginAuthenticatorTcclTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorPluginAuthenticatorTcclTest.java new file mode 100644 index 00000000000000..5655e24cbbb4b7 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorPluginAuthenticatorTcclTest.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.hive; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.HashMap; + +/** + * Tests that {@link HiveConnector#buildPluginAuthenticator(java.util.Map)} runs under the plugin (child-first) + * classloader (TCCL pinned), then restores the caller's TCCL. + * + *

WHY: a catalog that declares {@code hadoop.security.authentication=kerberos} WITHOUT a principal/keytab + * falls back to a {@code HadoopSimpleAuthenticator}, whose constructor EAGERLY calls + * {@code UserGroupInformation.createRemoteUser} → {@code SecurityUtil.} — whose internal + * {@code new Configuration()} captures the current TCCL. This resolution runs on the (unpinned) createClient + * thread, so an unpinned TCCL would load hadoop's {@code DNSDomainNameResolver} from fe-core's system-loader + * copy and split-brain-poison {@code SecurityUtil} against the plugin's copy (the same failure + * {@code ThriftHmsClient.doAs} guards). This is the latent-edge companion to that fix. + * + *

The map records the TCCL the first time {@code buildPluginAuthenticator} reads a key (its very first + * statement), which is inside the pinned region. Simple-auth properties are used so the method returns quickly + * without an eager UGI side effect, yet still exercises the pin. Without the pin the observed loader would be + * the caller's marker — so the assertion is RED on a missing pin, and on a dropped restore. + */ +public class HiveConnectorPluginAuthenticatorTcclTest { + + /** A properties map that records the TCCL the first time a key is read. */ + private static final class TcclRecordingMap extends HashMap { + private static final long serialVersionUID = 1L; + private transient ClassLoader observed; + + @Override + public String get(Object key) { + if (observed == null) { + observed = Thread.currentThread().getContextClassLoader(); + } + return super.get(key); + } + } + + @Test + public void buildPluginAuthenticatorRunsUnderPluginClassLoaderAndRestores() { + TcclRecordingMap props = new TcclRecordingMap(); + props.put("hive.metastore.uris", "thrift://hms:9083"); + + ClassLoader marker = new URLClassLoader(new URL[0], getClass().getClassLoader()); + ClassLoader pluginLoader = HiveConnector.class.getClassLoader(); + ClassLoader original = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(marker); + try { + HiveConnector.buildPluginAuthenticator(props); + + Assertions.assertNotNull(props.observed, "buildPluginAuthenticator must have read the properties"); + Assertions.assertSame(pluginLoader, props.observed, + "buildPluginAuthenticator must run under the plugin classloader, so an eager " + + "HadoopSimpleAuthenticator UGI init cannot split-brain SecurityUtil"); + Assertions.assertNotSame(marker, props.observed, + "buildPluginAuthenticator must re-pin the TCCL away from the caller's loader"); + Assertions.assertSame(marker, Thread.currentThread().getContextClassLoader(), + "buildPluginAuthenticator must restore the caller's TCCL (try/finally)"); + } finally { + Thread.currentThread().setContextClassLoader(original); + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorPluginAuthenticatorTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorPluginAuthenticatorTest.java new file mode 100644 index 00000000000000..2a3fdd46c511cb --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorPluginAuthenticatorTest.java @@ -0,0 +1,106 @@ +// 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.kerberos.HadoopAuthenticator; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Unit tests for {@link HiveConnector#buildPluginAuthenticator(Map)} — the connector-owned plugin-side + * Kerberos authenticator resolution. + * + *

The load-bearing case is HMS-metastore Kerberos with simple (non-Kerberos) storage + * (e.g. a Kerberized Hive Metastore over S3). After the catalog flip the FE-injected + * {@code ConnectorContext.executeAuthenticated} resolves to NOOP (SIMPLE) auth, so a Kerberos HMS would be + * silently downgraded unless the connector owns the login itself. These tests pin that the connector builds a + * plugin authenticator from the HMS client principal/keytab facts, and does NOT build one when the metastore + * is simple-auth (which would force needless SIMPLE-vs-Kerberos churn). + * + *

The actual keytab login is lazy (on first {@code doAs}), so these assertions never touch a KDC. + */ +public class HiveConnectorPluginAuthenticatorTest { + + private static Map props(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + /** Storage-level Kerberos (raw hadoop.security.authentication) — unchanged prior behavior. */ + @Test + public void storageKerberosBuildsAuthenticator() { + HadoopAuthenticator auth = HiveConnector.buildPluginAuthenticator( + props("hive.metastore.uris", "thrift://hms:9083", + "hadoop.security.authentication", "kerberos", + "hadoop.kerberos.principal", "doris@EXAMPLE.COM", + "hadoop.kerberos.keytab", "/etc/security/doris.keytab")); + Assertions.assertNotNull(auth, "storage kerberos must yield a plugin authenticator"); + } + + /** + * THE BLOCKER CASE: a Kerberized HMS whose data storage is simple. Storage auth is unset, so the storage + * gate is off; the connector must fall back to the HMS client-principal/keytab facts and still build a + * plugin authenticator (mirroring the fe-core HMS authenticator it replaces). + */ + @Test + public void hmsMetastoreKerberosWithSimpleStorageBuildsAuthenticator() { + HadoopAuthenticator auth = HiveConnector.buildPluginAuthenticator( + props("hive.metastore.uris", "thrift://hms:9083", + "hive.metastore.authentication.type", "kerberos", + "hive.metastore.client.principal", "doris@EXAMPLE.COM", + "hive.metastore.client.keytab", "/etc/security/doris.keytab")); + Assertions.assertNotNull(auth, + "HMS-metastore kerberos with simple storage must yield a plugin authenticator"); + } + + /** A simple-auth HMS builds no authenticator (a spurious one would force needless SIMPLE-vs-Kerberos churn). */ + @Test + public void hmsSimpleAuthReturnsNull() { + HadoopAuthenticator auth = HiveConnector.buildPluginAuthenticator( + props("hive.metastore.uris", "thrift://hms:9083", + "hive.metastore.authentication.type", "simple")); + Assertions.assertNull(auth, "simple-auth HMS must not build a plugin authenticator"); + } + + /** A plain HMS with no auth configured builds no authenticator. */ + @Test + public void plainHmsWithoutKerberosReturnsNull() { + HadoopAuthenticator auth = HiveConnector.buildPluginAuthenticator( + props("hive.metastore.uris", "thrift://hms:9083")); + Assertions.assertNull(auth, "plain HMS without kerberos must not build an authenticator"); + } + + /** + * HMS declares kerberos auth-type but the client principal/keytab are blank — the {@code hasCredentials} + * guard must reject it (an authenticator with no login pair would fail obscurely at first doAs). + */ + @Test + public void hmsKerberosWithBlankCredsReturnsNull() { + HadoopAuthenticator auth = HiveConnector.buildPluginAuthenticator( + props("hive.metastore.uris", "thrift://hms:9083", + "hive.metastore.authentication.type", "kerberos")); + Assertions.assertNull(auth, "kerberos HMS without a client principal/keytab pair must not build one"); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorProcedureOpsDivertTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorProcedureOpsDivertTest.java new file mode 100644 index 00000000000000..5fe0f8922677a6 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorProcedureOpsDivertTest.java @@ -0,0 +1,188 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.Connector; +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.ConnectorTableHandle; +import org.apache.doris.connector.api.procedure.ConnectorProcedureOps; +import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; + +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 the HMS-cutover §4.4 write-delegation W5 seam: {@link HiveConnector#getProcedureOps(ConnectorTableHandle)} + * routes {@code ALTER TABLE ... EXECUTE} procedure-ops selection by the concrete handle type — a hive handle has + * no procedures (inherits the connector-level {@code null}), any foreign (iceberg-on-HMS) handle is delegated to + * the embedded iceberg sibling's per-handle procedure ops — and NEVER casts the foreign handle. The + * procedure-side twin of {@link HiveConnectorWriteProviderDivertTest} / {@link HiveConnectorScanProviderDivertTest}. + * + *

WHY (Rule 9): post-flip an iceberg-on-HMS table gains the native iceberg procedures (rollback_to_snapshot, + * rewrite_data_files, ...), so a foreign handle must pick the SIBLING's ops; a plain-hive (or hudi-stamped hive) + * handle has none and must keep the null, so EXECUTE on it is fail-loud rejected (plain-hive exposes no + * procedures, and hudi's delegation is a later substep). The foreign handle is passed through UNMODIFIED (a + * rewrap would poison the sibling's downstream iceberg cast).

+ * + *

Dormant until hms enters {@code SPI_READY_TYPES}: no production path selects procedure ops for this + * connector yet, so these assertions are a guard, not a live-path test.

+ */ +public class HiveConnectorProcedureOpsDivertTest { + + private static final String METASTORE_URI = "thrift://host:9083"; + + /** The foreign (non-hive) handle the iceberg sibling's getTableHandle produces post-flip. */ + private static final class ForeignHandle implements ConnectorTableHandle { + } + + @Test + public void foreignHandleDelegatesToSiblingProcedureOpsUnmodified() { + RecordingSibling sibling = new RecordingSibling(); + RecordingSiblingContext context = new RecordingSiblingContext(sibling); + HiveConnector connector = new HiveConnector(props(), context); + // Pre-build the owning sibling, mirroring production: getTableHandle builds the sibling before it produces + // a foreign handle, so the per-handle router only ever PEEKS an already-built one. + connector.getOrCreateIcebergSibling(); + ForeignHandle foreign = new ForeignHandle(); + + ConnectorProcedureOps ops = connector.getProcedureOps(foreign); + + Assertions.assertSame(sibling.ops, ops, + "a foreign handle must return the OWNING sibling's OWN procedure ops"); + Assertions.assertSame(foreign, sibling.lastHandle, + "the foreign handle must reach the sibling's per-handle selector UNMODIFIED (a rewrap would " + + "poison the downstream iceberg cast)"); + Assertions.assertEquals(1, context.buildCount, + "the router PEEKS the already-built sibling (built once, here explicitly) — it never rebuilds"); + } + + @Test + public void hiveHandleHasNoProceduresWithoutConsultingSibling() { + // A hive handle inherits the connector-level null (plain-hive has no procedures). It must NOT build or + // consult the sibling, and — unlike the write/scan seams — it needs no HmsClient (no-arg getProcedureOps + // returns null directly). + RecordingSibling sibling = new RecordingSibling(); + RecordingSiblingContext context = new RecordingSiblingContext(sibling); + HiveConnector connector = new HiveConnector(props(), context); + HiveTableHandle hive = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE).build(); + + Assertions.assertNull(connector.getProcedureOps(hive), + "a hive handle has no procedures — it inherits the connector-level null"); + Assertions.assertEquals(0, context.buildCount, "a hive handle must never build or consult the sibling"); + Assertions.assertNull(sibling.lastHandle, "the sibling's procedure ops must not be consulted for a hive handle"); + } + + @Test + public void hudiStampedHiveHandleHasNoProcedures() { + // The route keys on the JVM handle TYPE (HiveTableHandle), not the format enum: a HUDI-stamped hive handle + // is still a HiveTableHandle, so it inherits the null (no procedures) — hudi delegation is a later substep. + RecordingSibling sibling = new RecordingSibling(); + RecordingSiblingContext context = new RecordingSiblingContext(sibling); + HiveConnector connector = new HiveConnector(props(), context); + HiveTableHandle hudi = new HiveTableHandle.Builder("db", "t", HiveTableType.HUDI).build(); + + Assertions.assertNull(connector.getProcedureOps(hudi), + "a hudi-stamped hive handle inherits the connector-level null (no procedures)"); + Assertions.assertEquals(0, context.buildCount, "a hudi table must NOT be diverted to the iceberg sibling"); + } + + @Test + public void foreignHandleFailsLoudWhenNoBuiltSiblingOwnsIt() { + // No sibling is ever built here, so the 3-way peek router finds no owner and must fail loud (naming the + // catalog), not NPE — the procedure-side stand-in for an orphan handle reaching a per-handle seam. + RecordingSiblingContext context = new RecordingSiblingContext(null); + HiveConnector connector = new HiveConnector(props(), context); + + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> connector.getProcedureOps(new ForeignHandle()), + "a foreign handle no built sibling owns must fail loud"); + Assertions.assertTrue(ex.getMessage().contains("test_catalog"), ex.getMessage()); + } + + private static Map props() { + Map props = new HashMap<>(); + props.put("hive.metastore.uris", METASTORE_URI); + return props; + } + + /** Records the {@code createSiblingConnector} call and returns a configurable (possibly null) sibling. */ + private static final class RecordingSiblingContext extends FakeConnectorContext { + int buildCount; + Connector siblingToReturn; + + RecordingSiblingContext(Connector siblingToReturn) { + this.siblingToReturn = siblingToReturn; + } + + @Override + public Connector createSiblingConnector(String catalogType, Map properties) { + buildCount++; + return siblingToReturn; + } + } + + /** A sibling {@link Connector} whose per-handle procedure ops are a distinguishable marker, recording the handle. */ + private static final class RecordingSibling implements Connector { + final ConnectorProcedureOps ops = new MarkerProcedureOps(); + ConnectorTableHandle lastHandle; + + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + return null; + } + + // Owns the test's ForeignHandle — the 3-way gateway router asks each built sibling this before routing. + @Override + public boolean ownsHandle(ConnectorTableHandle handle) { + return handle instanceof ForeignHandle; + } + + @Override + public ConnectorProcedureOps getProcedureOps(ConnectorTableHandle handle) { + lastHandle = handle; + return ops; + } + + @Override + public void close() { + } + } + + /** A bare procedure-ops stand-in; only its identity matters here. */ + private static final class MarkerProcedureOps implements ConnectorProcedureOps { + @Override + public List getSupportedProcedures() { + return Collections.emptyList(); + } + + @Override + public ConnectorProcedureResult execute(ConnectorSession session, ConnectorTableHandle table, + String procedureName, Map properties, ConnectorPredicate whereCondition, + List partitionNames) { + return null; + } + } +} 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 new file mode 100644 index 00000000000000..e00179a9666989 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorScanProviderDivertTest.java @@ -0,0 +1,206 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.Connector; +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.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Pins the HMS-cutover §4.4 S5 scan-provider gateway seam: {@link HiveConnector#getScanPlanProvider( + * ConnectorTableHandle)} routes by the concrete handle type — a hive handle runs the hive scan provider, any + * foreign (iceberg-on-HMS) handle is delegated to the embedded iceberg sibling's scan provider — and NEVER casts + * the foreign handle. + * + *

WHY (Rule 9): this pairs with the getTableHandle iceberg divert. Once getTableHandle returns a foreign + * iceberg handle, the scan of that table must pick the SIBLING's scan provider (iceberg-loader-built, so + * {@code PluginDrivenScanNode.onPluginClassLoader} auto-pins the scan-thread TCCL to the iceberg loader). A hive + * (or hudi-stamped hive) handle must NOT be diverted — a hive-only deployment has no iceberg plugin, and hudi's + * delegation is a later substep. The selection MUST happen at provider-acquisition time, so a wrong route here + * would hand iceberg splits to the hive scanner (or vice versa).

+ * + *

Dormant until hms enters {@code SPI_READY_TYPES}: no production path selects a scan provider for this + * connector yet, so these assertions are a guard, not a live-path test.

+ */ +public class HiveConnectorScanProviderDivertTest { + + private static final String METASTORE_URI = "thrift://host:9083"; + + /** The foreign (non-hive) handle the iceberg sibling's getTableHandle produces post-flip. */ + private static final class ForeignHandle implements ConnectorTableHandle { + } + + /** + * The connector-level hive scan provider, stubbed so the divert test does not build a real HmsClient. A hive + * handle must resolve to exactly THIS (what the no-arg {@link HiveConnector#getScanPlanProvider()} returns); + * the real no-arg provider construction (which needs a HiveConf, off the unit-test classpath) is covered by + * the scan-planning suites. Distinct instance from any sibling provider so {@code assertSame} is meaningful. + */ + private final ConnectorScanPlanProvider stubbedHiveProvider = new MarkerScanProvider(); + + /** A gateway whose no-arg hive provider is the stub above — isolates the per-handle routing from HmsClient. */ + private HiveConnector gatewayWithStubbedHiveProvider(RecordingSiblingContext context) { + return new HiveConnector(props(), context) { + @Override + public ConnectorScanPlanProvider getScanPlanProvider() { + return stubbedHiveProvider; + } + }; + } + + @Test + public void foreignHandleDelegatesToSiblingScanProviderUnmodified() { + RecordingSibling sibling = new RecordingSibling(); + RecordingSiblingContext context = new RecordingSiblingContext(sibling); + HiveConnector connector = new HiveConnector(props(), context); + // Pre-build the owning sibling, mirroring production: getTableHandle's divert force-builds the sibling + // before it can produce a foreign handle, so the per-handle router only ever PEEKS an already-built one. + connector.getOrCreateIcebergSibling(); + ForeignHandle foreign = new ForeignHandle(); + + ConnectorScanPlanProvider provider = connector.getScanPlanProvider(foreign); + + Assertions.assertSame(sibling.provider, provider, + "a foreign handle must return the OWNING sibling's OWN scan provider"); + Assertions.assertSame(foreign, sibling.lastHandle, + "the foreign handle must reach the sibling's per-handle selector UNMODIFIED (a rewrap would " + + "poison the downstream iceberg cast)"); + Assertions.assertEquals(1, context.buildCount, + "the router PEEKS the already-built sibling (built once, here explicitly) — it never rebuilds"); + } + + @Test + public void hiveHandleUsesHiveProviderWithoutConsultingSibling() { + RecordingSibling sibling = new RecordingSibling(); + RecordingSiblingContext context = new RecordingSiblingContext(sibling); + HiveConnector connector = gatewayWithStubbedHiveProvider(context); + HiveTableHandle hive = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE).build(); + + ConnectorScanPlanProvider provider = connector.getScanPlanProvider(hive); + + Assertions.assertSame(stubbedHiveProvider, provider, + "a hive handle resolves to the connector-level hive provider (the no-arg getScanPlanProvider)"); + Assertions.assertEquals(0, context.buildCount, "a hive handle must never build or consult the sibling"); + Assertions.assertNull(sibling.lastHandle, "the sibling's scan provider must not be consulted for a hive handle"); + } + + @Test + public void hudiStampedHiveHandleStaysOnHiveProvider() { + // The route keys on the JVM handle TYPE (HiveTableHandle), not the format enum: a HUDI-stamped hive handle + // is still a HiveTableHandle, so it stays on the hive scan path (hudi delegation is a later substep). + RecordingSibling sibling = new RecordingSibling(); + RecordingSiblingContext context = new RecordingSiblingContext(sibling); + HiveConnector connector = gatewayWithStubbedHiveProvider(context); + HiveTableHandle hudi = new HiveTableHandle.Builder("db", "t", HiveTableType.HUDI).build(); + + ConnectorScanPlanProvider provider = connector.getScanPlanProvider(hudi); + + Assertions.assertSame(stubbedHiveProvider, provider, "a hudi-stamped hive handle stays on the hive provider"); + Assertions.assertEquals(0, context.buildCount, "a hudi table must NOT be diverted to the iceberg sibling"); + } + + @Test + public void foreignHandleFailsLoudWhenNoBuiltSiblingOwnsIt() { + // No sibling is ever built here (nothing calls getOrCreate*/getTableHandle), so the 3-way peek router + // finds no owner and must fail loud (naming the catalog), not NPE. This stands in for an orphan handle — + // a foreign handle that reached a per-handle seam without its owning sibling built (a bug, not a route). + RecordingSiblingContext context = new RecordingSiblingContext(null); + HiveConnector connector = new HiveConnector(props(), context); + + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> connector.getScanPlanProvider(new ForeignHandle()), + "a foreign handle no built sibling owns must fail loud"); + Assertions.assertTrue(ex.getMessage().contains("test_catalog"), ex.getMessage()); + } + + private static Map props() { + // A metastore uri so the hive-handle branch can build its (offline, lazy-pool) HmsClient; the foreign + // branch never touches it. + Map props = new HashMap<>(); + props.put("hive.metastore.uris", METASTORE_URI); + return props; + } + + /** Records the {@code createSiblingConnector} call and returns a configurable (possibly null) sibling. */ + private static final class RecordingSiblingContext extends FakeConnectorContext { + int buildCount; + Connector siblingToReturn; + + RecordingSiblingContext(Connector siblingToReturn) { + this.siblingToReturn = siblingToReturn; + } + + @Override + public Connector createSiblingConnector(String catalogType, Map properties) { + buildCount++; + return siblingToReturn; + } + } + + /** A sibling {@link Connector} whose per-handle scan provider is a distinguishable marker, recording the handle. */ + private static final class RecordingSibling implements Connector { + final ConnectorScanPlanProvider provider = new MarkerScanProvider(); + ConnectorTableHandle lastHandle; + + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + return null; + } + + // Owns the test's ForeignHandle — the 3-way gateway router (HiveConnector.resolveSiblingOwner) asks each + // built sibling this before routing, since the foreign handle's concrete type is loader-invisible. + @Override + public boolean ownsHandle(ConnectorTableHandle handle) { + return handle instanceof ForeignHandle; + } + + @Override + public ConnectorScanPlanProvider getScanPlanProvider(ConnectorTableHandle handle) { + lastHandle = handle; + return provider; + } + + @Override + 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) { + return Collections.emptyList(); + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorSiblingTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorSiblingTest.java new file mode 100644 index 00000000000000..0ff3bd02108a0f --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorSiblingTest.java @@ -0,0 +1,384 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.DorisConnectorException; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * Pins the HMS-cutover embedded-sibling holders: a flipped hms gateway lazily builds ONE embedded iceberg + * connector and ONE embedded hudi connector (each via + * {@link org.apache.doris.connector.spi.ConnectorContext#createSiblingConnector}) that it delegates its + * iceberg-on-HMS / hudi-on-HMS tables to, and forwards {@code close()} to both. + * + *

The whole surface is dormant until hms enters {@code SPI_READY_TYPES}: no production path calls + * {@code getOrCreateIcebergSibling()} / {@code getOrCreateHudiSibling()} yet, so these assertions are a Rule-9 + * guard that each holder's contract (single sibling per gateway, correctly synthesized props — hms-flavor for + * iceberg, verbatim for hudi — fail-loud when the plugin is absent, independent lifecycle forwarding) is correct + * BEFORE the flip wires a consumer. + */ +public class HiveConnectorSiblingTest { + + @Test + public void buildsIcebergSiblingWithSynthesizedHmsFlavorProps() { + Map catalogProps = new HashMap<>(); + catalogProps.put("hive.metastore.uris", "thrift://host:9083"); + catalogProps.put("iceberg.catalog.type", "rest"); // a stray flavor the sibling must override to hms + FakeSibling sibling = new FakeSibling(); + RecordingSiblingContext context = new RecordingSiblingContext(sibling); + HiveConnector connector = new HiveConnector(catalogProps, context); + + Connector built = connector.getOrCreateIcebergSibling(); + + Assertions.assertSame(sibling, built, "the accessor must return the context-built sibling"); + // The sibling is always an iceberg connector — the delegate type must reach the seam verbatim. + Assertions.assertEquals("iceberg", context.lastType, "the sibling connector type must be iceberg"); + // Props are the gateway's catalog map + the hms flavor forced on (S1 synthesis), so the embedded + // connector reaches the SAME metastore/storage and always resolves the hms flavor. + Assertions.assertEquals("hms", context.lastProps.get("iceberg.catalog.type"), + "the sibling must be built as the hms flavor, overriding any stray gateway value"); + Assertions.assertEquals("thrift://host:9083", context.lastProps.get("hive.metastore.uris"), + "the gateway's metastore uri must be carried to the sibling"); + } + + @Test + public void memoizesSingleSiblingPerGateway() { + // The iceberg connector holds per-catalog caches (latest-snapshot / manifest / scan->write delete stash) + // shared across its tables; a per-op sibling would fragment them. There must be exactly one build. + RecordingSiblingContext context = new RecordingSiblingContext(new FakeSibling()); + HiveConnector connector = new HiveConnector(new HashMap<>(), context); + + Connector first = connector.getOrCreateIcebergSibling(); + Connector second = connector.getOrCreateIcebergSibling(); + + Assertions.assertSame(first, second, "repeated access must return the same single sibling"); + Assertions.assertEquals(1, context.buildCount, "the sibling must be built exactly once per gateway"); + } + + @Test + public void failsLoudWhenIcebergPluginAbsent() { + // A missing iceberg provider surfaces as a null sibling from the seam; the gateway must fail loud with a + // catalog-scoped message rather than NPE deep in a later delegation. + RecordingSiblingContext context = new RecordingSiblingContext(null); + HiveConnector connector = new HiveConnector(new HashMap<>(), context); + + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + connector::getOrCreateIcebergSibling); + Assertions.assertTrue(ex.getMessage().contains("test_catalog"), + "the failure must name the catalog it could not serve"); + } + + @Test + public void failLoudIsNotMemoized() { + // The absence is not cached: a plugin that becomes available (or a transient factory failure that + // clears) must be picked up on the next access, not permanently poison the gateway. + RecordingSiblingContext context = new RecordingSiblingContext(null); + HiveConnector connector = new HiveConnector(new HashMap<>(), context); + Assertions.assertThrows(DorisConnectorException.class, connector::getOrCreateIcebergSibling); + + FakeSibling sibling = new FakeSibling(); + context.siblingToReturn = sibling; + Assertions.assertSame(sibling, connector.getOrCreateIcebergSibling(), + "a later-available sibling must be built after an earlier fail-loud"); + Assertions.assertEquals(2, context.buildCount, "the failed build must be retried, not memoized"); + } + + @Test + public void closeForwardsToSiblingAndClearsIt() throws Exception { + // The engine closes only the primary connector; the gateway owns the sibling's lifecycle, and a second + // close must not double-close a released sibling. + FakeSibling sibling = new FakeSibling(); + RecordingSiblingContext context = new RecordingSiblingContext(sibling); + HiveConnector connector = new HiveConnector(new HashMap<>(), context); + connector.getOrCreateIcebergSibling(); + + connector.close(); + connector.close(); + + Assertions.assertEquals(1, sibling.closeCount, "close must forward to the sibling exactly once"); + } + + @Test + public void closeIsNoOpWhenSiblingNeverBuilt() throws Exception { + // Dormant path: a gateway that never delegated must not build a sibling just to close it. + RecordingSiblingContext context = new RecordingSiblingContext(new FakeSibling()); + HiveConnector connector = new HiveConnector(new HashMap<>(), context); + + connector.close(); + + Assertions.assertEquals(0, context.buildCount, "close must not trigger a sibling build"); + } + + // ---- hudi sibling holder (mirrors the iceberg cases above; hudi synthesizes props verbatim, no flavor) ---- + + @Test + public void buildsHudiSiblingWithVerbatimProps() { + Map catalogProps = new HashMap<>(); + catalogProps.put("hive.metastore.uris", "thrift://host:9083"); + catalogProps.put("iceberg.catalog.type", "rest"); // hudi injects NO flavor: a stray key survives verbatim + FakeSibling sibling = new FakeSibling(); + RecordingSiblingContext context = new RecordingSiblingContext(sibling); + HiveConnector connector = new HiveConnector(catalogProps, context); + + Connector built = connector.getOrCreateHudiSibling(); + + Assertions.assertSame(sibling, built, "the accessor must return the context-built sibling"); + // The sibling is always a hudi connector — the delegate type must reach the seam verbatim. + Assertions.assertEquals("hudi", context.lastType, "the sibling connector type must be hudi"); + Assertions.assertEquals("thrift://host:9083", context.lastProps.get("hive.metastore.uris"), + "the gateway's metastore uri must be carried to the sibling"); + // Unlike iceberg, hudi synthesis injects no flavor: a stray gateway key is carried through unchanged + // (there is no iceberg.catalog.type analogue for hudi to force). + Assertions.assertEquals("rest", context.lastProps.get("iceberg.catalog.type"), + "hudi synthesis injects no flavor — a stray gateway key is carried verbatim, not overridden"); + } + + @Test + public void memoizesSingleHudiSiblingPerGateway() { + RecordingSiblingContext context = new RecordingSiblingContext(new FakeSibling()); + HiveConnector connector = new HiveConnector(new HashMap<>(), context); + + Connector first = connector.getOrCreateHudiSibling(); + Connector second = connector.getOrCreateHudiSibling(); + + Assertions.assertSame(first, second, "repeated access must return the same single sibling"); + Assertions.assertEquals(1, context.buildCount, "the sibling must be built exactly once per gateway"); + } + + @Test + public void failsLoudWhenHudiPluginAbsent() { + RecordingSiblingContext context = new RecordingSiblingContext(null); + HiveConnector connector = new HiveConnector(new HashMap<>(), context); + + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + connector::getOrCreateHudiSibling); + Assertions.assertTrue(ex.getMessage().contains("test_catalog"), + "the failure must name the catalog it could not serve"); + Assertions.assertTrue(ex.getMessage().contains("hudi"), + "the failure must name the missing hudi plugin"); + } + + @Test + public void hudiFailLoudIsNotMemoized() { + RecordingSiblingContext context = new RecordingSiblingContext(null); + HiveConnector connector = new HiveConnector(new HashMap<>(), context); + Assertions.assertThrows(DorisConnectorException.class, connector::getOrCreateHudiSibling); + + FakeSibling sibling = new FakeSibling(); + context.siblingToReturn = sibling; + Assertions.assertSame(sibling, connector.getOrCreateHudiSibling(), + "a later-available sibling must be built after an earlier fail-loud"); + Assertions.assertEquals(2, context.buildCount, "the failed build must be retried, not memoized"); + } + + @Test + public void closeForwardsToHudiSiblingAndClearsIt() throws Exception { + FakeSibling sibling = new FakeSibling(); + RecordingSiblingContext context = new RecordingSiblingContext(sibling); + HiveConnector connector = new HiveConnector(new HashMap<>(), context); + connector.getOrCreateHudiSibling(); + + connector.close(); + connector.close(); + + Assertions.assertEquals(1, sibling.closeCount, "close must forward to the hudi sibling exactly once"); + } + + @Test + public void closeForwardsToBothSiblingsIndependently() throws Exception { + // Regression guard for adding the hudi holder: close() must forward to the iceberg AND the hudi field, + // each exactly once — adding the hudi arm must not drop or double the iceberg arm. Use a type-dispatching + // context so the two siblings are distinct instances. + FakeSibling icebergSibling = new FakeSibling(); + FakeSibling hudiSibling = new FakeSibling(); + FakeConnectorContext context = new FakeConnectorContext() { + @Override + public Connector createSiblingConnector(String catalogType, Map properties) { + return "hudi".equals(catalogType) ? hudiSibling : icebergSibling; + } + }; + HiveConnector connector = new HiveConnector(new HashMap<>(), context); + connector.getOrCreateIcebergSibling(); + connector.getOrCreateHudiSibling(); + + connector.close(); + + Assertions.assertEquals(1, icebergSibling.closeCount, "close must forward to the iceberg sibling once"); + Assertions.assertEquals(1, hudiSibling.closeCount, "close must forward to the hudi sibling once"); + } + + /** Records the {@code createSiblingConnector} call and returns a configurable (possibly null) sibling. */ + private static final class RecordingSiblingContext extends FakeConnectorContext { + int buildCount; + String lastType; + Map lastProps; + Connector siblingToReturn; + + RecordingSiblingContext(Connector siblingToReturn) { + this.siblingToReturn = siblingToReturn; + } + + @Override + public Connector createSiblingConnector(String catalogType, Map properties) { + buildCount++; + lastType = catalogType; + lastProps = properties; + return siblingToReturn; + } + } + + @Test + public void refreshHooksForwardToBothBuiltSiblings() { + // WHY: fe-core routes REFRESH TABLE/DATABASE/CATALOG only to a catalog's PRIMARY connector. If the + // gateway did not forward its invalidate hooks, the iceberg sibling's latest-snapshot pin could NEVER + // be dropped by an explicit REFRESH — and its access-based expiry keeps a continuously-queried stale + // entry alive indefinitely, so staleness would be unbounded (breaks "bounded by TTL + REFRESH"). + FakeSibling icebergSibling = new FakeSibling(); + FakeSibling hudiSibling = new FakeSibling(); + FakeConnectorContext context = new FakeConnectorContext() { + @Override + public Connector createSiblingConnector(String catalogType, Map properties) { + return "hudi".equals(catalogType) ? hudiSibling : icebergSibling; + } + }; + HiveConnector connector = new HiveConnector(new HashMap<>(), context); + connector.getOrCreateIcebergSibling(); + connector.getOrCreateHudiSibling(); + + connector.invalidateTable("db1", "t1"); + connector.invalidateDb("db2"); + connector.invalidateAll(); + + for (FakeSibling sibling : new FakeSibling[] {icebergSibling, hudiSibling}) { + Assertions.assertEquals("db1.t1", sibling.lastInvalidatedTable, + "invalidateTable must forward the (db, table) pair to each built sibling"); + Assertions.assertEquals("db2", sibling.lastInvalidatedDb, + "invalidateDb must forward the db to each built sibling"); + Assertions.assertEquals(1, sibling.invalidateAllCount, + "invalidateAll must forward to each built sibling exactly once"); + } + } + + @Test + public void refreshHooksNeverForceBuildSiblings() { + // WHY: a REFRESH on a pure-hive catalog must not construct sibling connectors — a never-built sibling + // has no cache to drop, and building one just to flush it would fail-loud spuriously whenever the + // sibling plugin is not installed (REFRESH would start throwing on plain hive catalogs). + RecordingSiblingContext context = new RecordingSiblingContext(new FakeSibling()); + HiveConnector connector = new HiveConnector(new HashMap<>(), context); + + connector.invalidateTable("db", "t"); + connector.invalidateDb("db"); + connector.invalidateAll(); + + Assertions.assertEquals(0, context.buildCount, + "invalidate hooks must only forward to ALREADY-BUILT siblings, never force-build one"); + } + + @Test + public void icebergSiblingWithoutUserSessionCapabilityIsAccepted() { + // The normal case: an hms-flavor sibling declares no SUPPORTS_USER_SESSION, so the fail-loud guard is + // inert and the sibling is returned as-is. + FakeSibling plainSibling = new FakeSibling(); + HiveConnector connector = + new HiveConnector(new HashMap<>(), new RecordingSiblingContext(plainSibling)); + + Assertions.assertSame(plainSibling, connector.getOrCreateIcebergSibling(), + "a sibling without SUPPORTS_USER_SESSION must be accepted"); + } + + @Test + public void icebergSiblingDeclaringUserSessionFailsLoud() { + // Cache-isolation security invariant: the hive gateway FRONT DOOR is never session=user, so fe-core keys + // its per-user schema/name cache bypass off the front door and would NOT bypass for a delegated sibling. + // The iceberg sibling is forced iceberg.catalog.type=hms and can never be session=user; if a future change + // ever broke that, building the sibling must FAIL LOUD rather than silently leak cross-user metadata. + // MUTATION: dropping the guard -> the session=user sibling is accepted -> no throw -> red. + FakeSibling userSessionSibling = + new FakeSibling(EnumSet.of(ConnectorCapability.SUPPORTS_USER_SESSION)); + HiveConnector connector = + new HiveConnector(new HashMap<>(), new RecordingSiblingContext(userSessionSibling)); + + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + connector::getOrCreateIcebergSibling); + Assertions.assertTrue(ex.getMessage().contains("SUPPORTS_USER_SESSION"), + "the failure must name the broken session=user invariant"); + } + + /** + * A bare {@link Connector} stand-in for the cross-loader iceberg/hudi sibling; records lifecycle + * ({@code close}) and invalidation forwarding. + */ + private static final class FakeSibling implements Connector { + int closeCount; + int invalidateAllCount; + String lastInvalidatedTable; + String lastInvalidatedDb; + private final Set capabilities; + + FakeSibling() { + this(Collections.emptySet()); + } + + FakeSibling(Set capabilities) { + this.capabilities = capabilities; + } + + @Override + public Set getCapabilities() { + return capabilities; + } + + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + return null; + } + + @Override + public void close() { + closeCount++; + } + + @Override + public void invalidateTable(String dbName, String tableName) { + lastInvalidatedTable = dbName + "." + tableName; + } + + @Override + public void invalidateDb(String dbName) { + lastInvalidatedDb = dbName; + } + + @Override + public void invalidateAll() { + invalidateAllCount++; + } + } +} 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 new file mode 100644 index 00000000000000..e8a85c1830a416 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorThreeWayRoutingTest.java @@ -0,0 +1,319 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorColumnHandle; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +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.write.ConnectorSinkPlan; +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.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 + * gateway, the binary {@code else -> iceberg} discriminator no longer works — a hudi handle must not be + * wrong-routed to the iceberg sibling. {@link HiveConnector} routes every per-handle seam by asking each + * ALREADY-BUILT sibling {@link Connector#ownsHandle} (a hive handle stays hive; else the owning sibling by + * {@code ownsHandle}; else fail loud). The concrete foreign handle type is invisible across the plugin + * classloader split, so the gateway can never {@code instanceof} it itself. + * + *

The same {@code HiveConnector.resolveSiblingOwner} brain backs both the connector-level + * {@code get*Provider(handle)} seams exercised here AND the ~34 per-handle guard-and-forward methods in + * {@link HiveConnectorMetadata} (which forward via the injected resolver — see + * {@link HiveConnectorMetadataSiblingDelegationTest}).

+ * + *

WHY the PEEK design (Rule 9): routing consults only siblings that are already built (the owning + * sibling is always built first, by getTableHandle, before it can produce the handle). It never force-builds an + * unrelated plugin merely to classify a handle — so a catalog serving only hudi (no iceberg plugin installed) + * still routes its hudi handles, and the iceberg arm stays byte-behaviour-identical (an iceberg handle routes to + * iceberg without ever touching the hudi holder). Dormant until hms enters {@code SPI_READY_TYPES}.

+ */ +public class HiveConnectorThreeWayRoutingTest { + + private static final String METASTORE_URI = "thrift://host:9083"; + + /** Stand-in for the raw iceberg-loader handle the iceberg sibling produces (loader-invisible to the gateway). */ + private static final class IcebergLikeHandle implements ConnectorTableHandle { + } + + /** Stand-in for the raw hudi-loader handle the hudi sibling produces. */ + private static final class HudiLikeHandle implements ConnectorTableHandle { + } + + @Test + public void icebergHandleRoutesToIcebergSiblingAcrossAllSeams() { + RoutingSibling iceberg = new RoutingSibling("iceberg", IcebergLikeHandle.class); + RoutingSibling hudi = new RoutingSibling("hudi", HudiLikeHandle.class); + TwoSiblingContext ctx = new TwoSiblingContext(iceberg, hudi); + HiveConnector connector = new HiveConnector(props(), ctx); + connector.getOrCreateIcebergSibling(); + connector.getOrCreateHudiSibling(); + IcebergLikeHandle handle = new IcebergLikeHandle(); + + Assertions.assertSame(iceberg.scanProvider, connector.getScanPlanProvider(handle), + "an iceberg handle must route to the iceberg sibling's scan provider"); + Assertions.assertSame(iceberg.writeProvider, connector.getWritePlanProvider(handle), + "an iceberg handle must route to the iceberg sibling's write provider"); + Assertions.assertSame(iceberg.procedureOps, connector.getProcedureOps(handle), + "an iceberg handle must route to the iceberg sibling's procedure ops"); + Assertions.assertNull(hudi.lastScanHandle, "the hudi sibling must never be consulted for an iceberg handle"); + } + + @Test + public void hudiHandleRoutesToHudiSiblingAcrossAllSeams() { + RoutingSibling iceberg = new RoutingSibling("iceberg", IcebergLikeHandle.class); + RoutingSibling hudi = new RoutingSibling("hudi", HudiLikeHandle.class); + TwoSiblingContext ctx = new TwoSiblingContext(iceberg, hudi); + HiveConnector connector = new HiveConnector(props(), ctx); + connector.getOrCreateIcebergSibling(); + connector.getOrCreateHudiSibling(); + HudiLikeHandle handle = new HudiLikeHandle(); + + Assertions.assertSame(hudi.scanProvider, connector.getScanPlanProvider(handle), + "a hudi handle must route to the hudi sibling's scan provider, NOT the iceberg sibling's"); + Assertions.assertSame(hudi.writeProvider, connector.getWritePlanProvider(handle), + "a hudi handle must route to the hudi sibling's write provider"); + Assertions.assertSame(hudi.procedureOps, connector.getProcedureOps(handle), + "a hudi handle must route to the hudi sibling's procedure ops"); + Assertions.assertNull(iceberg.lastScanHandle, "the iceberg sibling must never be consulted for a hudi handle"); + } + + @Test + public void hiveHandleStaysHiveAndConsultsNeitherSibling() { + RoutingSibling iceberg = new RoutingSibling("iceberg", IcebergLikeHandle.class); + RoutingSibling hudi = new RoutingSibling("hudi", HudiLikeHandle.class); + TwoSiblingContext ctx = new TwoSiblingContext(iceberg, hudi); + HiveConnector connector = new HiveConnector(props(), ctx); + connector.getOrCreateIcebergSibling(); + connector.getOrCreateHudiSibling(); + HiveTableHandle hive = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE).build(); + + // getProcedureOps needs no HmsClient: a hive handle inherits the connector-level null (plain-hive has none). + Assertions.assertNull(connector.getProcedureOps(hive), "a hive handle has no procedures (connector-level null)"); + Assertions.assertNull(iceberg.lastProcedureHandle, "a hive handle must not consult the iceberg sibling"); + Assertions.assertNull(hudi.lastProcedureHandle, "a hive handle must not consult the hudi sibling"); + } + + @Test + public void unknownForeignHandleFailsLoud() { + RoutingSibling iceberg = new RoutingSibling("iceberg", IcebergLikeHandle.class); + RoutingSibling hudi = new RoutingSibling("hudi", HudiLikeHandle.class); + TwoSiblingContext ctx = new TwoSiblingContext(iceberg, hudi); + HiveConnector connector = new HiveConnector(props(), ctx); + connector.getOrCreateIcebergSibling(); + connector.getOrCreateHudiSibling(); + + // A foreign handle owned by NEITHER built sibling is an orphan (a bug, not a route) — fail loud, do not + // silently hand it to an arbitrary sibling. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> connector.getScanPlanProvider(new ConnectorTableHandle() { + }), "a foreign handle no sibling owns must fail loud"); + Assertions.assertTrue(ex.getMessage().contains("test_catalog"), ex.getMessage()); + } + + @Test + public void hudiHandleRoutesWithoutBuildingIcebergSibling() { + // The PEEK no-regression guarantee: a catalog serving only hudi tables (no iceberg plugin installed) must + // route its hudi handles WITHOUT the router force-building an iceberg sibling. Iceberg is absent (null); + // only the hudi sibling is built. + RoutingSibling hudi = new RoutingSibling("hudi", HudiLikeHandle.class); + TwoSiblingContext ctx = new TwoSiblingContext(null, hudi); + HiveConnector connector = new HiveConnector(props(), ctx); + connector.getOrCreateHudiSibling(); + + Assertions.assertSame(hudi.scanProvider, connector.getScanPlanProvider(new HudiLikeHandle()), + "a hudi handle must route to the hudi sibling even with no iceberg plugin present"); + Assertions.assertEquals(0, ctx.icebergBuilds, + "routing a hudi handle must NOT build (or require) the iceberg sibling"); + } + + @Test + public void icebergHandleRoutesWithoutBuildingHudiSibling() { + // Symmetric guarantee — the iceberg arm stays byte-behaviour-identical after adding the hudi holder: an + // iceberg handle routes to iceberg without the router touching the hudi holder (no hudi plugin required). + RoutingSibling iceberg = new RoutingSibling("iceberg", IcebergLikeHandle.class); + TwoSiblingContext ctx = new TwoSiblingContext(iceberg, null); + HiveConnector connector = new HiveConnector(props(), ctx); + connector.getOrCreateIcebergSibling(); + + Assertions.assertSame(iceberg.scanProvider, connector.getScanPlanProvider(new IcebergLikeHandle()), + "an iceberg handle must route to the iceberg sibling with no hudi plugin present"); + Assertions.assertEquals(0, ctx.hudiBuilds, + "routing an iceberg handle must NOT build (or require) the hudi sibling"); + } + + @Test + public void getMetadataWiresEachByTypeSupplierToItsOwnSibling() { + // The by-HANDLE routing above is one brain (resolveSiblingOwner). getTableHandle instead diverts BY TYPE + // (no handle exists yet), and HiveConnector.newMetadata (extracted from getMetadata) is the SOLE production + // point that wires those two by-TYPE suppliers: `this::getOrCreateIcebergSibling, + // this::getOrCreateHudiSibling`. They share the static type Supplier, so a transposition compiles + // clean and would silently send getTableHandle's ICEBERG arm to the hudi sibling (and vice versa) at flip. + // The per-handle DivertTest builds the metadata DIRECTLY with hand-labeled lambdas and cannot observe this + // order — guard it here, at the real wiring. newMetadata(null) exercises that wiring without getMetadata's + // eager ThriftHmsClient build (whose Hadoop stack is absent from unit tests); the null client is never + // dereferenced because only the by-TYPE sibling arms are driven. + RoutingSibling iceberg = new RoutingSibling("iceberg", IcebergLikeHandle.class); + RoutingSibling hudi = new RoutingSibling("hudi", HudiLikeHandle.class); + TwoSiblingContext ctx = new TwoSiblingContext(iceberg, hudi); + HiveConnector connector = new HiveConnector(props(), ctx); + + HiveConnectorMetadata md = connector.newMetadata(null); + // A NONE-scope session: the by-TYPE helpers now route through the per-statement metadata funnel (which + // reads session.getStatementScope()/getCatalogId()), so a null session would NPE after the force-build we + // assert. NONE makes the funnel run its factory straight through — the force-build order is unchanged. + ConnectorSession session = new ScopeSession(1L, "q1", ConnectorStatementScope.NONE); + + // The getTableHandle ICEBERG arm resolves BY TYPE via icebergSiblingMetadata, which force-builds the + // iceberg sibling (createSiblingConnector("iceberg")). A transposed getMetadata would build hudi here. + md.icebergSiblingMetadata(session); + Assertions.assertEquals(1, ctx.icebergBuilds, "the iceberg by-TYPE arm must force-build the iceberg sibling"); + Assertions.assertEquals(0, ctx.hudiBuilds, "the iceberg by-TYPE arm must NOT build the hudi sibling"); + + // Symmetrically the HUDI arm must resolve the hudi sibling, not rebuild iceberg. + md.hudiSiblingMetadata(session); + Assertions.assertEquals(1, ctx.hudiBuilds, "the hudi by-TYPE arm must force-build the hudi sibling"); + Assertions.assertEquals(1, ctx.icebergBuilds, "the hudi by-TYPE arm must not rebuild the iceberg sibling"); + } + + private static Map props() { + Map props = new HashMap<>(); + props.put("hive.metastore.uris", METASTORE_URI); + return props; + } + + /** A context that builds a distinct sibling per type (iceberg / hudi), each possibly null (absent plugin). */ + private static final class TwoSiblingContext extends FakeConnectorContext { + private final Connector iceberg; + private final Connector hudi; + int icebergBuilds; + int hudiBuilds; + + TwoSiblingContext(Connector iceberg, Connector hudi) { + this.iceberg = iceberg; + this.hudi = hudi; + } + + @Override + public Connector createSiblingConnector(String catalogType, Map properties) { + if ("iceberg".equals(catalogType)) { + icebergBuilds++; + return iceberg; + } + if ("hudi".equals(catalogType)) { + hudiBuilds++; + return hudi; + } + return null; + } + } + + /** A sibling that owns exactly one handle type and returns identity-marked, call-recording providers. */ + private static final class RoutingSibling implements Connector { + private final Class ownedType; + final ConnectorScanPlanProvider scanProvider; + final ConnectorWritePlanProvider writeProvider = new MarkerWriteProvider(); + final ConnectorProcedureOps procedureOps = new MarkerProcedureOps(); + ConnectorTableHandle lastScanHandle; + ConnectorTableHandle lastProcedureHandle; + + RoutingSibling(String name, Class ownedType) { + this.ownedType = ownedType; + this.scanProvider = new MarkerScanProvider(); + } + + @Override + public boolean ownsHandle(ConnectorTableHandle handle) { + return ownedType.isInstance(handle); + } + + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + return null; + } + + @Override + public ConnectorScanPlanProvider getScanPlanProvider(ConnectorTableHandle handle) { + lastScanHandle = handle; + return scanProvider; + } + + @Override + public ConnectorWritePlanProvider getWritePlanProvider(ConnectorTableHandle handle) { + return writeProvider; + } + + @Override + public ConnectorProcedureOps getProcedureOps(ConnectorTableHandle handle) { + lastProcedureHandle = handle; + return procedureOps; + } + + @Override + public void close() { + } + } + + private static final class MarkerScanProvider implements ConnectorScanPlanProvider { + @Override + public List planScan(ConnectorSession session, ConnectorTableHandle handle, + List columns, Optional filter) { + return Collections.emptyList(); + } + } + + private static final class MarkerWriteProvider implements ConnectorWritePlanProvider { + @Override + public ConnectorSinkPlan planWrite(ConnectorSession session, ConnectorWriteHandle handle) { + return null; + } + } + + private static final class MarkerProcedureOps implements ConnectorProcedureOps { + @Override + public List getSupportedProcedures() { + return Collections.emptyList(); + } + + @Override + public ConnectorProcedureResult execute(ConnectorSession session, ConnectorTableHandle table, + String procedureName, Map properties, ConnectorPredicate whereCondition, + List partitionNames) { + return null; + } + } +} 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 new file mode 100644 index 00000000000000..ddf2e1a4163455 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorTransactionTest.java @@ -0,0 +1,723 @@ +// 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.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.handle.WriteOperation; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsDatabaseInfo; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsPartitionStatistics; +import org.apache.doris.connector.hms.HmsPartitionWithStatistics; +import org.apache.doris.connector.hms.HmsTableInfo; +import org.apache.doris.filesystem.DorisInputFile; +import org.apache.doris.filesystem.DorisOutputFile; +import org.apache.doris.filesystem.FileIterator; +import org.apache.doris.filesystem.FileSystem; +import org.apache.doris.filesystem.Location; +import org.apache.doris.filesystem.UploadPartResult; +import org.apache.doris.filesystem.spi.ObjFileSystem; +import org.apache.doris.filesystem.spi.ObjStorage; +import org.apache.doris.filesystem.spi.RemoteObject; +import org.apache.doris.filesystem.spi.RemoteObjects; +import org.apache.doris.filesystem.spi.RequestBody; +import org.apache.doris.thrift.TFileType; +import org.apache.doris.thrift.THiveLocationParams; +import org.apache.doris.thrift.THivePartitionUpdate; +import org.apache.doris.thrift.TS3MPUPendingUpload; +import org.apache.doris.thrift.TUpdateMode; + +import org.apache.hadoop.hive.metastore.api.FieldSchema; +import org.apache.thrift.TException; +import org.apache.thrift.TSerializer; +import org.apache.thrift.protocol.TBinaryProtocol; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Unit tests for {@link HiveConnectorTransaction}, exercised offline against a recording {@link HmsClient} + * fake (no metastore, no Mockito — the connector test convention). These cover the parts of the ported + * {@code HMSTransaction} whose behaviour a compile cannot verify and where a silent regression corrupts + * data or drops writes: + * + *
    + *
  • Classification / NEW→APPEND downgrade — the {@code finishInsertTable} state machine and + * the {@code partitionExists} probe that saves a partitioned {@code INSERT} from failing when Doris' + * cache missed a partition that already exists in HMS (a wrong branch here either double-adds a + * partition or loses the rows).
  • + *
  • transactional write reject (DEC-2) — writing to ANY transactional table (full-ACID AND + * insert-only) must be refused; the transactional flag is derived plugin-side from the raw HMS + * parameters (an insert-only ACID table would otherwise get plain files — corrupt/invisible data).
  • + *
  • {@code addCommitData}/{@code getUpdateCnt} — the affected-row count reported back to the + * engine is the sum of the BE fragment row counts (D3); an off-by-one here misreports load results.
  • + *
  • SPI identity — {@code profileLabel()} must stay {@code "HMS"} (D2: maps to the existing + * fe-core {@code TransactionType.HMS}; any other value silently degrades to UNKNOWN).
  • + *
+ * + *

The commit-time object-store work is driven through a fake {@code ObjFileSystem}/{@code ObjStorage} + * injected via the {@code FakeConnectorContext.getFileSystem(session)} seam behind a non-{@code ObjFileSystem} + * routing facade (mirroring the engine's {@code SpiSwitchingFileSystem}, so the connector must narrow via + * {@code forLocation}): multipart-upload complete on commit and abort on rollback (D6/D9), the + * idempotency of a repeated rollback, and the single batched {@code addPartitions} call (GAP-7 — the + * 20-at-a-time batching lives in the client, so the committer adds the whole list once). The staging-directory + * rename walk and the full multi-partition commit are covered by the e2e write suites.

+ */ +public class HiveConnectorTransactionTest { + + private static final long CATALOG_ID = 0L; + private static final String DB = "db"; + private static final String TBL = "t"; + + private HiveConnectorTransaction newTxn(HmsClient client) { + return new HiveConnectorTransaction(42L, client, new FakeConnectorContext( + "test_catalog", CATALOG_ID, Collections.emptyMap())); + } + + private NameMapping nameMapping() { + return new NameMapping(CATALOG_ID, DB, TBL, DB, TBL); + } + + private HiveWriteContext ctx(boolean overwrite) { + // FILE_S3 keeps the classification path from needing a real staging filesystem (createAndAddPartition + // then uses the write path directly rather than a target path). + return new HiveWriteContext(overwrite ? WriteOperation.OVERWRITE : WriteOperation.INSERT, overwrite, + Collections.emptyMap(), "query-1", TFileType.FILE_S3, "s3://bucket/db/t/_staging"); + } + + private static ConnectorColumn col(String name, String type) { + return new ConnectorColumn(name, ConnectorType.of(type), "", true, null); + } + + private static HmsTableInfo table(boolean partitioned, Map params) { + return HmsTableInfo.builder() + .dbName(DB).tableName(TBL).tableType("MANAGED_TABLE") + .location("s3://bucket/db/t") + .inputFormat("org.apache.hadoop.mapred.TextInputFormat") + .outputFormat("org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat") + .serializationLib("org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe") + .columns(Collections.singletonList(col("c1", "int"))) + .partitionKeys(partitioned + ? Collections.singletonList(col("dt", "string")) + : Collections.emptyList()) + .parameters(params == null ? Collections.emptyMap() : params) + .build(); + } + + private static THivePartitionUpdate pu(String name, TUpdateMode mode, String writePath, + List fileNames, long fileSize, long rowCount) { + THivePartitionUpdate u = new THivePartitionUpdate(); + u.setName(name); + u.setUpdateMode(mode); + THiveLocationParams loc = new THiveLocationParams(); + loc.setWritePath(writePath); + loc.setTargetPath(writePath); + u.setLocation(loc); + u.setFileNames(fileNames); + u.setFileSize(fileSize); + u.setRowCount(rowCount); + return u; + } + + private static byte[] serialize(THivePartitionUpdate u) throws TException { + return new TSerializer(new TBinaryProtocol.Factory()).serialize(u); + } + + private static THivePartitionUpdate puWithMpu(String name, TUpdateMode mode, String writePath, + String bucket, String key, String uploadId, Map etags) { + THivePartitionUpdate u = pu(name, mode, writePath, Collections.singletonList("f1"), 100, 4); + TS3MPUPendingUpload mpu = new TS3MPUPendingUpload(); + mpu.setBucket(bucket); + mpu.setKey(key); + mpu.setUploadId(uploadId); + mpu.setEtags(etags); + u.setS3MpuPendingUploads(new ArrayList<>(Collections.singletonList(mpu))); + return u; + } + + // 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 + // 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) { + FileSystem borrowed = new RoutingFacadeFileSystem(concreteFs); + return new HiveConnectorTransaction(42L, client, + new FakeConnectorContext("test_catalog", CATALOG_ID, Collections.emptyMap()) { + @Override + public FileSystem getFileSystem(ConnectorSession session) { + return borrowed; + } + }); + } + + // ─────────────────────────────── SPI identity ─────────────────────────────── + + @Test + public void testProfileLabelAndTransactionId() { + // D2: the profile label must resolve to the existing fe-core TransactionType.HMS; a drift to "HIVE" + // (or anything else) would silently map to UNKNOWN and mislabel the transaction in the engine. + HiveConnectorTransaction txn = newTxn(new RecordingHmsClient()); + Assertions.assertEquals("HMS", txn.profileLabel()); + Assertions.assertEquals(42L, txn.getTransactionId()); + } + + // ─────────────────────────────── addCommitData / getUpdateCnt ─────────────────────────────── + + @Test + public void testGetUpdateCntSumsFragmentRowCounts() throws TException { + // D3: the affected-row count is the SUM of the BE fragment row counts. Feed three serialized + // fragments and assert the running total — proves both the TBinaryProtocol round-trip in + // addCommitData and the accumulation (a wrong reducer here misreports load results to the client). + HiveConnectorTransaction txn = newTxn(new RecordingHmsClient()); + txn.addCommitData(serialize(pu("dt=a", TUpdateMode.NEW, "s3://b/a", Arrays.asList("f1"), 10, 3))); + txn.addCommitData(serialize(pu("dt=b", TUpdateMode.NEW, "s3://b/b", Arrays.asList("f2"), 20, 5))); + txn.addCommitData(serialize(pu("dt=c", TUpdateMode.NEW, "s3://b/c", Arrays.asList("f3"), 30, 7))); + Assertions.assertEquals(15L, txn.getUpdateCnt()); + } + + @Test + public void testAddCommitDataRejectsGarbageBytes() { + // A corrupt commit fragment must surface as a DorisConnectorException (not a raw TException leaking + // through the SPI), so the engine can fail the load cleanly. + HiveConnectorTransaction txn = newTxn(new RecordingHmsClient()); + Assertions.assertThrows(DorisConnectorException.class, + () -> txn.addCommitData(new byte[] {1, 2, 3, 4, 5})); + } + + // ─────────────────────────────── transactional write reject (DEC-2) ─────────────────────────────── + + @Test + public void testBeginWriteRejectsFullAcidTable() { + // DEC-2: writing to a full-ACID table (transactional=true, not insert-only) is not supported and must + // be refused at begin — otherwise the write would silently corrupt the ACID base/delta layout. + RecordingHmsClient client = new RecordingHmsClient(); + Map params = new HashMap<>(); + params.put("transactional", "true"); + client.table = table(false, params); + HiveConnectorTransaction txn = newTxn(client); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> txn.beginWrite(null, DB, TBL, ctx(false))); + Assertions.assertTrue(ex.getMessage().contains("begin write"), + "expected the begin-write wrapper message, got: " + ex.getMessage()); + } + + @Test + public void testBeginWriteRejectsInsertOnlyTransactionalTable() { + // DEC-2: the write guard now rejects ANY transactional table — full-ACID AND insert-only. An + // insert-only ACID table would otherwise slip through the narrower full-ACID-only guard and receive + // plain files (corrupt/invisible data), matching legacy InsertIntoTableCommand's isTransactionalTable + // reject. + RecordingHmsClient client = new RecordingHmsClient(); + Map params = new HashMap<>(); + params.put("transactional", "true"); + params.put("transactional_properties", "insert_only"); + client.table = table(false, params); + HiveConnectorTransaction txn = newTxn(client); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> txn.beginWrite(null, DB, TBL, ctx(false))); + Assertions.assertTrue(ex.getMessage().contains("transactional"), + "expected the transactional-table reject, got: " + ex.getMessage()); + } + + @Test + public void testBeginWriteAllowsPlainTable() { + // A plain non-transactional table is the common INSERT case — begin must succeed. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = table(false, Collections.emptyMap()); + HiveConnectorTransaction txn = newTxn(client); + txn.beginWrite(null, DB, TBL, ctx(false)); // must not throw + } + + // ─────────────────────────────── classification / NEW→APPEND downgrade ─────────────────────────────── + + @Test + public void testPartitionedNewWhenAbsentCreatesPartition() throws TException { + // A NEW partition that HMS does NOT already have must be created: the existence probe is consulted, + // and because it is absent we take the create branch — which needs no getPartitions() lookup. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = table(true, Collections.emptyMap()); + client.partitionExistsResult = false; + HiveConnectorTransaction txn = newTxn(client); + txn.beginWrite(null, DB, TBL, ctx(false)); + txn.addCommitData(serialize(pu("dt=2024-01-01", TUpdateMode.NEW, "s3://bucket/db/t/dt=2024-01-01", + Arrays.asList("f1"), 100, 4))); + + txn.finishInsertTable(nameMapping()); + + Assertions.assertTrue(client.calls.contains("partitionExists:[2024-01-01]"), + "the NEW-mode branch must probe partition existence; calls=" + client.calls); + Assertions.assertFalse(client.calls.stream().anyMatch(c -> c.startsWith("getPartitions")), + "a genuinely-new partition takes the create path, which does not fetch existing partitions; " + + "calls=" + client.calls); + } + + @Test + public void testPartitionedNewWhenPresentDowngradesToAppend() throws TException { + // NEW→APPEND downgrade: when Doris' cache missed a partition that HMS actually has, the probe returns + // true and we must treat the write as an APPEND into the existing partition (getPartitions() is then + // consulted to build the INSERT_EXISTING action) — instead of trying to ADD a duplicate partition. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = table(true, Collections.emptyMap()); + client.partitionExistsResult = true; + client.partitions = Collections.singletonList(new HmsPartitionInfo( + Collections.singletonList("2024-01-01"), "s3://bucket/db/t/dt=2024-01-01", + "org.apache.hadoop.mapred.TextInputFormat", + "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat", + "org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe", Collections.emptyMap())); + HiveConnectorTransaction txn = newTxn(client); + txn.beginWrite(null, DB, TBL, ctx(false)); + txn.addCommitData(serialize(pu("dt=2024-01-01", TUpdateMode.NEW, "s3://bucket/db/t/dt=2024-01-01", + Arrays.asList("f1"), 100, 4))); + + txn.finishInsertTable(nameMapping()); + + Assertions.assertTrue(client.calls.contains("partitionExists:[2024-01-01]"), + "the downgrade must first probe existence; calls=" + client.calls); + Assertions.assertTrue(client.calls.stream().anyMatch(c -> c.startsWith("getPartitions")), + "an existing partition downgrades to APPEND, which fetches the real partition metadata; " + + "calls=" + client.calls); + } + + @Test + public void testPartitionedAppendFetchesExistingPartition() throws TException { + // A declared APPEND into a partitioned table goes straight to the INSERT_EXISTING path, which fetches + // the existing partition (no existence probe needed) — a wrong branch here would try to add it. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = table(true, Collections.emptyMap()); + client.partitions = Collections.singletonList(new HmsPartitionInfo( + Collections.singletonList("2024-01-01"), "s3://bucket/db/t/dt=2024-01-01", + "org.apache.hadoop.mapred.TextInputFormat", + "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat", + "org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe", Collections.emptyMap())); + HiveConnectorTransaction txn = newTxn(client); + txn.beginWrite(null, DB, TBL, ctx(false)); + txn.addCommitData(serialize(pu("dt=2024-01-01", TUpdateMode.APPEND, "s3://bucket/db/t/dt=2024-01-01", + Arrays.asList("f1"), 100, 4))); + + txn.finishInsertTable(nameMapping()); + + Assertions.assertTrue(client.calls.stream().anyMatch(c -> c.startsWith("getPartitions")), + "APPEND into a partitioned table must fetch the existing partition; calls=" + client.calls); + Assertions.assertFalse(client.calls.contains("partitionExists:[2024-01-01]"), + "a declared APPEND does not need the NEW-mode existence probe; calls=" + client.calls); + } + + @Test + public void testDuplicatePartitionValuesFromHmsFailsLoud() throws TException { + // Fidelity guard, ported from HiveUtil.convertToNamePartitionMap's Collectors.toMap: if HMS returns + // two partitions with identical values for a batch, the txn must abort LOUDLY rather than silently + // overwrite one. A silent HashMap overwrite would shrink the name→partition map below the requested + // count, and the size()-bounded loop would then drop a trailing partition's INSERT_EXISTING action — + // a silently lost write instead of a failed load. (HEAD's toMap throws IllegalStateException here.) + RecordingHmsClient client = new RecordingHmsClient(); + client.table = table(true, Collections.emptyMap()); + HmsPartitionInfo dup = new HmsPartitionInfo( + Collections.singletonList("2024-01-01"), "s3://bucket/db/t/dt=2024-01-01", + "org.apache.hadoop.mapred.TextInputFormat", + "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat", + "org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe", Collections.emptyMap()); + client.partitions = Arrays.asList(dup, dup); // HMS returns the same partition values twice + HiveConnectorTransaction txn = newTxn(client); + txn.beginWrite(null, DB, TBL, ctx(false)); + txn.addCommitData(serialize(pu("dt=2024-01-01", TUpdateMode.APPEND, "s3://bucket/db/t/dt=2024-01-01", + Arrays.asList("f1"), 100, 4))); + + IllegalStateException ex = Assertions.assertThrows(IllegalStateException.class, + () -> txn.finishInsertTable(nameMapping())); + Assertions.assertTrue(ex.getMessage().contains("Duplicate key"), + "expected a fail-loud abort on duplicate partition values, got: " + ex.getMessage()); + } + + // ─────────────────────────────── commit-time object-store MPU (D6) ─────────────────────────────── + + @Test + public void testCommitCompletesMultipartUploads() throws TException { + // On commit the FE finalizes the multipart uploads that BE staged on the object store (D6). For an + // unpartitioned APPEND whose write path already equals the table location (no rename needed), the + // committer must complete the MPU with the s3://bucket/key URI, the upload id, and the part ETags + // sorted by part number — a missing/mis-ordered completion would leave the written data invisible. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = table(false, Collections.emptyMap()); + RecordingObjStorage objStorage = new RecordingObjStorage(); + HiveConnectorTransaction txn = newTxnWithFs(client, new RecordingObjFileSystem(objStorage)); + txn.beginWrite(null, DB, TBL, ctx(false)); + Map etags = new HashMap<>(); + etags.put(2, "etag-2"); + etags.put(1, "etag-1"); + // writePath == table location "s3://bucket/db/t" -> needRename=false -> objCommit (MPU complete) path. + txn.addCommitData(serialize(puWithMpu("", TUpdateMode.APPEND, "s3://bucket/db/t", + "bucket", "db/t/data", "upload-1", etags))); + + txn.commit(); + txn.close(); + + Assertions.assertEquals( + Collections.singletonList("complete:s3://bucket/db/t/data:upload-1:[1=etag-1, 2=etag-2]"), + objStorage.calls, + "commit must complete the MPU once with the ETags sorted by part number; calls=" + objStorage.calls); + Assertions.assertTrue(client.calls.contains("updateTableStatistics:" + DB + "." + TBL), + "an unpartitioned INSERT_EXISTING must also update the table statistics; calls=" + client.calls); + } + + @Test + public void testRollbackAbortsPendingMultipartUploads() throws TException { + // rollback() is NOT a no-op for hive (D9): data files are staged before commit, so a rollback must + // abort the in-flight object-store multipart uploads — otherwise they linger server-side (leaked / + // billed). Here the committer was never built (rollback before commit), so the pending uploads are + // collected straight from the accumulated commit fragments. + RecordingObjStorage objStorage = new RecordingObjStorage(); + HiveConnectorTransaction txn = newTxnWithFs(new RecordingHmsClient(), + new RecordingObjFileSystem(objStorage)); + txn.addCommitData(serialize(puWithMpu("", TUpdateMode.APPEND, "s3://bucket/db/t", + "bucket", "db/t/data", "upload-9", Collections.singletonMap(1, "etag-1")))); + + txn.rollback(); + txn.close(); + + Assertions.assertEquals(Collections.singletonList("abort:s3://bucket/db/t/data:upload-9"), + objStorage.calls, + "rollback must abort the pending MPU exactly once; calls=" + objStorage.calls); + } + + @Test + public void testSecondRollbackIsIdempotent() throws TException { + // Rollback must be safe to call more than once (the engine may retry cleanup): the second call must + // neither throw nor re-abort an already-aborted upload (a double abort would error against the store). + RecordingObjStorage objStorage = new RecordingObjStorage(); + HiveConnectorTransaction txn = newTxnWithFs(new RecordingHmsClient(), + new RecordingObjFileSystem(objStorage)); + txn.addCommitData(serialize(puWithMpu("", TUpdateMode.APPEND, "s3://bucket/db/t", + "bucket", "db/t/data", "upload-9", Collections.singletonMap(1, "etag-1")))); + + txn.rollback(); + Assertions.assertDoesNotThrow(txn::rollback, "a second rollback must not throw"); + txn.close(); + + Assertions.assertEquals(Collections.singletonList("abort:s3://bucket/db/t/data:upload-9"), + objStorage.calls, + "the pending MPU must be aborted exactly once across repeated rollbacks; calls=" + objStorage.calls); + } + + @Test + public void testCommitAddsNewPartitionOnce() throws TException { + // GAP-7: the 20-at-a-time batching moved INTO ThriftHmsClient.addPartitions, so the committer must + // call addPartitions ONCE with the whole list (not re-batch it). GAP-4: the new partition's storage + // descriptor (values/location/columns) is rebuilt from the table at commit time. A genuinely-new + // partition takes the ADD path; on FILE_S3 the write path == target path, so no rename/MPU runs and + // the object-store FileSystem is never resolved (hence newTxn, not newTxnWithFs). + RecordingHmsClient client = new RecordingHmsClient(); + client.table = table(true, Collections.emptyMap()); + client.partitionExistsResult = false; + HiveConnectorTransaction txn = newTxn(client); + txn.beginWrite(null, DB, TBL, ctx(false)); + txn.addCommitData(serialize(pu("dt=2024-01-01", TUpdateMode.NEW, "s3://bucket/db/t/dt=2024-01-01", + Collections.singletonList("f1"), 100, 4))); + + txn.commit(); + txn.close(); + + Assertions.assertEquals(1, client.addedPartitions.size(), + "exactly one partition must be added; calls=" + client.calls); + Assertions.assertEquals(1L, client.calls.stream().filter(c -> c.startsWith("addPartitions:")).count(), + "addPartitions must be invoked exactly once (batching lives in the client); calls=" + client.calls); + HmsPartitionWithStatistics added = client.addedPartitions.get(0); + Assertions.assertEquals(Collections.singletonList("2024-01-01"), added.getPartitionValues()); + Assertions.assertEquals("s3://bucket/db/t/dt=2024-01-01", added.getLocation()); + Assertions.assertEquals(Collections.singletonList("c1"), + added.getColumns().stream().map(FieldSchema::getName).collect(Collectors.toList()), + "the new partition's SD columns must be rebuilt from the table schema"); + } + + /** + * Recording {@link HmsClient} fake: implements the abstract read surface, records the calls the + * transaction makes, and returns canned metadata. The Phase-3+ write primitives ({@code addPartitions} / + * statistics / {@code dropPartition}) are recorded too — the committer reaches them once the FS tests + * drive a full commit. + */ + private static final class RecordingHmsClient implements HmsClient { + private final List calls = new ArrayList<>(); + private final List addedPartitions = new ArrayList<>(); + private HmsTableInfo table; + private boolean partitionExistsResult; + private List partitions = Collections.emptyList(); + + @Override + public List listDatabases() { + return Collections.emptyList(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + throw new UnsupportedOperationException("getDatabase"); + } + + @Override + public List listTables(String dbName) { + return Collections.emptyList(); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + return table != null; + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + calls.add("getTable:" + dbName + "." + tableName); + if (table == null) { + throw new UnsupportedOperationException("no canned table"); + } + return table; + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + return Collections.emptyMap(); + } + + @Override + public List listPartitionNames(String dbName, String tableName, int maxParts) { + return Collections.emptyList(); + } + + @Override + public List getPartitions(String dbName, String tableName, List partNames) { + calls.add("getPartitions:" + partNames); + return partitions; + } + + @Override + public HmsPartitionInfo getPartition(String dbName, String tableName, List values) { + throw new UnsupportedOperationException("getPartition"); + } + + @Override + public boolean partitionExists(String dbName, String tableName, List partitionValues) { + calls.add("partitionExists:" + partitionValues); + return partitionExistsResult; + } + + @Override + public void addPartitions(String dbName, String tableName, List partitions) { + calls.add("addPartitions:" + dbName + "." + tableName + ":" + partitions.size()); + addedPartitions.addAll(partitions); + } + + @Override + public void updateTableStatistics(String dbName, String tableName, + Function update) { + calls.add("updateTableStatistics:" + dbName + "." + tableName); + } + + @Override + public void updatePartitionStatistics(String dbName, String tableName, String partitionName, + Function update) { + calls.add("updatePartitionStatistics:" + dbName + "." + tableName + ":" + partitionName); + } + + @Override + public boolean dropPartition(String dbName, String tableName, List partitionValues, + boolean deleteData) { + calls.add("dropPartition:" + partitionValues + ":" + deleteData); + return true; + } + + @Override + public void close() { + } + } + + /** + * Recording {@link ObjStorage} fake capturing both multipart-upload completions and aborts (the two + * object-store operations the commit/rollback paths reach). Every other operation fails loud so an + * unexpected code path surfaces immediately rather than silently no-op'ing. + */ + private static final class RecordingObjStorage implements ObjStorage { + private final List calls = new ArrayList<>(); + + @Override + public void completeMultipartUpload(String remotePath, String uploadId, List parts) { + String partStr = parts.stream().map(p -> p.partNumber() + "=" + p.etag()) + .collect(Collectors.joining(", ", "[", "]")); + calls.add("complete:" + remotePath + ":" + uploadId + ":" + partStr); + } + + @Override + public void abortMultipartUpload(String remotePath, String uploadId) { + calls.add("abort:" + remotePath + ":" + uploadId); + } + + @Override + public Object getClient() { + throw new UnsupportedOperationException("getClient"); + } + + @Override + public RemoteObjects listObjects(String remotePath, String continuationToken) { + throw new UnsupportedOperationException("listObjects"); + } + + @Override + public RemoteObject headObject(String remotePath) { + throw new UnsupportedOperationException("headObject"); + } + + @Override + public void putObject(String remotePath, RequestBody requestBody) { + throw new UnsupportedOperationException("putObject"); + } + + @Override + public void deleteObject(String remotePath) { + throw new UnsupportedOperationException("deleteObject"); + } + + @Override + public void copyObject(String srcPath, String dstPath) { + throw new UnsupportedOperationException("copyObject"); + } + + @Override + public String initiateMultipartUpload(String remotePath) { + throw new UnsupportedOperationException("initiateMultipartUpload"); + } + + @Override + public UploadPartResult uploadPart(String remotePath, String uploadId, int partNum, RequestBody body) { + throw new UnsupportedOperationException("uploadPart"); + } + + @Override + public void close() { + } + } + + /** + * Fake {@link ObjFileSystem} over a {@link RecordingObjStorage}. {@code ObjFileSystem} already provides + * {@code exists()}/{@code close()} and the {@code completeMultipartUpload(Map)} overload that converts the + * ETag map to a sorted part list before delegating to the storage; the remaining core FileSystem methods + * are not exercised by the MPU tests and fail loud if unexpectedly reached. + */ + private static final class RecordingObjFileSystem extends ObjFileSystem { + RecordingObjFileSystem(ObjStorage objStorage) { + super(objStorage); + } + + @Override + public void mkdirs(Location location) { + throw new UnsupportedOperationException("mkdirs"); + } + + @Override + public void delete(Location location, boolean recursive) { + throw new UnsupportedOperationException("delete"); + } + + @Override + public void rename(Location src, Location dst) { + throw new UnsupportedOperationException("rename"); + } + + @Override + public FileIterator list(Location location) { + throw new UnsupportedOperationException("list"); + } + + @Override + public DorisInputFile newInputFile(Location location) { + throw new UnsupportedOperationException("newInputFile"); + } + + @Override + public DorisOutputFile newOutputFile(Location location) { + throw new UnsupportedOperationException("newOutputFile"); + } + } + + /** + * A non-{@link ObjFileSystem} routing facade mirroring the engine's {@code SpiSwitchingFileSystem}: it is + * NOT itself an {@code ObjFileSystem}, so the connector must call {@link FileSystem#forLocation} to narrow + * to the concrete {@code ObjFileSystem} before an MPU (a regression that casts {@code getFileSystem()} + * directly then fails {@code instanceof} here). Base operations delegate to the wrapped concrete FS; + * {@code close()} throws to lock in the borrow contract — the connector must never close the engine FS. + */ + private static final class RoutingFacadeFileSystem implements FileSystem { + private final FileSystem delegate; + + RoutingFacadeFileSystem(FileSystem delegate) { + this.delegate = delegate; + } + + @Override + public FileSystem forLocation(Location location) { + return delegate; + } + + @Override + public boolean exists(Location location) throws IOException { + return delegate.exists(location); + } + + @Override + public void mkdirs(Location location) throws IOException { + delegate.mkdirs(location); + } + + @Override + public void delete(Location location, boolean recursive) throws IOException { + delegate.delete(location, recursive); + } + + @Override + public void rename(Location src, Location dst) throws IOException { + delegate.rename(src, dst); + } + + @Override + public FileIterator list(Location location) throws IOException { + return delegate.list(location); + } + + @Override + public DorisInputFile newInputFile(Location location) throws IOException { + return delegate.newInputFile(location); + } + + @Override + public DorisOutputFile newOutputFile(Location location) throws IOException { + return delegate.newOutputFile(location); + } + + @Override + public void close() { + throw new AssertionError("connector must not close the borrowed engine FileSystem"); + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorWriteProviderDivertTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorWriteProviderDivertTest.java new file mode 100644 index 00000000000000..09f31cbb5a9dfc --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorWriteProviderDivertTest.java @@ -0,0 +1,195 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.Connector; +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.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.ConnectorWriteHandle; +import org.apache.doris.connector.api.write.ConnectorSinkPlan; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Pins the HMS-cutover §4.4 write-delegation W2 seam: {@link HiveConnector#getWritePlanProvider( + * ConnectorTableHandle)} routes by the concrete handle type — a hive handle runs the hive write provider, any + * foreign (iceberg-on-HMS) handle is delegated to the embedded iceberg sibling's per-handle write provider — and + * NEVER casts the foreign handle. The write-side twin of {@link HiveConnectorScanProviderDivertTest}. + * + *

WHY (Rule 9): once getTableHandle returns a foreign iceberg handle (S4), a write to that table must pick the + * SIBLING's write provider so an iceberg-on-HMS INSERT/DELETE/MERGE builds an iceberg sink, not a hive one. A hive + * (or hudi-stamped hive) handle must NOT be diverted — a hive-only deployment has no iceberg plugin, and hudi's + * write delegation is a later substep. The foreign handle is passed through UNMODIFIED (a rewrap would poison the + * downstream iceberg cast).

+ * + *

Dormant until hms enters {@code SPI_READY_TYPES}: no production path selects a write provider for this + * connector yet, so these assertions are a guard, not a live-path test.

+ */ +public class HiveConnectorWriteProviderDivertTest { + + private static final String METASTORE_URI = "thrift://host:9083"; + + /** The foreign (non-hive) handle the iceberg sibling's getTableHandle produces post-flip. */ + private static final class ForeignHandle implements ConnectorTableHandle { + } + + /** + * The connector-level hive write provider, stubbed so the divert test does not build a real HmsClient. A hive + * handle must resolve to exactly THIS (what the no-arg {@link HiveConnector#getWritePlanProvider()} returns); + * the real no-arg provider construction (which needs an HmsClient) is covered by the write-plan suites. + */ + private final ConnectorWritePlanProvider stubbedHiveProvider = new MarkerWriteProvider(); + + /** A gateway whose no-arg hive provider is the stub above — isolates the per-handle routing from HmsClient. */ + private HiveConnector gatewayWithStubbedHiveProvider(RecordingSiblingContext context) { + return new HiveConnector(props(), context) { + @Override + public ConnectorWritePlanProvider getWritePlanProvider() { + return stubbedHiveProvider; + } + }; + } + + @Test + public void foreignHandleDelegatesToSiblingWriteProviderUnmodified() { + RecordingSibling sibling = new RecordingSibling(); + RecordingSiblingContext context = new RecordingSiblingContext(sibling); + HiveConnector connector = new HiveConnector(props(), context); + // Pre-build the owning sibling, mirroring production: getTableHandle builds the sibling before it produces + // a foreign handle, so the per-handle router only ever PEEKS an already-built one. + connector.getOrCreateIcebergSibling(); + ForeignHandle foreign = new ForeignHandle(); + + ConnectorWritePlanProvider provider = connector.getWritePlanProvider(foreign); + + Assertions.assertSame(sibling.provider, provider, + "a foreign handle must return the OWNING sibling's OWN write provider"); + Assertions.assertSame(foreign, sibling.lastHandle, + "the foreign handle must reach the sibling's per-handle selector UNMODIFIED (a rewrap would " + + "poison the downstream iceberg cast)"); + Assertions.assertEquals(1, context.buildCount, + "the router PEEKS the already-built sibling (built once, here explicitly) — it never rebuilds"); + } + + @Test + public void hiveHandleUsesHiveProviderWithoutConsultingSibling() { + RecordingSibling sibling = new RecordingSibling(); + RecordingSiblingContext context = new RecordingSiblingContext(sibling); + HiveConnector connector = gatewayWithStubbedHiveProvider(context); + HiveTableHandle hive = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE).build(); + + ConnectorWritePlanProvider provider = connector.getWritePlanProvider(hive); + + Assertions.assertSame(stubbedHiveProvider, provider, + "a hive handle resolves to the connector-level hive provider (the no-arg getWritePlanProvider)"); + Assertions.assertEquals(0, context.buildCount, "a hive handle must never build or consult the sibling"); + Assertions.assertNull(sibling.lastHandle, "the sibling's write provider must not be consulted for a hive handle"); + } + + @Test + public void hudiStampedHiveHandleStaysOnHiveProvider() { + // The route keys on the JVM handle TYPE (HiveTableHandle), not the format enum: a HUDI-stamped hive handle + // is still a HiveTableHandle, so it stays on the hive write path (hudi delegation is a later substep). + RecordingSibling sibling = new RecordingSibling(); + RecordingSiblingContext context = new RecordingSiblingContext(sibling); + HiveConnector connector = gatewayWithStubbedHiveProvider(context); + HiveTableHandle hudi = new HiveTableHandle.Builder("db", "t", HiveTableType.HUDI).build(); + + ConnectorWritePlanProvider provider = connector.getWritePlanProvider(hudi); + + Assertions.assertSame(stubbedHiveProvider, provider, "a hudi-stamped hive handle stays on the hive provider"); + Assertions.assertEquals(0, context.buildCount, "a hudi table must NOT be diverted to the iceberg sibling"); + } + + @Test + public void foreignHandleFailsLoudWhenNoBuiltSiblingOwnsIt() { + // No sibling is ever built here, so the 3-way peek router finds no owner and must fail loud (naming the + // catalog), not NPE — the write-side stand-in for an orphan handle reaching a per-handle seam. + RecordingSiblingContext context = new RecordingSiblingContext(null); + HiveConnector connector = new HiveConnector(props(), context); + + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> connector.getWritePlanProvider(new ForeignHandle()), + "a foreign handle no built sibling owns must fail loud"); + Assertions.assertTrue(ex.getMessage().contains("test_catalog"), ex.getMessage()); + } + + private static Map props() { + Map props = new HashMap<>(); + props.put("hive.metastore.uris", METASTORE_URI); + return props; + } + + /** Records the {@code createSiblingConnector} call and returns a configurable (possibly null) sibling. */ + private static final class RecordingSiblingContext extends FakeConnectorContext { + int buildCount; + Connector siblingToReturn; + + RecordingSiblingContext(Connector siblingToReturn) { + this.siblingToReturn = siblingToReturn; + } + + @Override + public Connector createSiblingConnector(String catalogType, Map properties) { + buildCount++; + return siblingToReturn; + } + } + + /** A sibling {@link Connector} whose per-handle write provider is a distinguishable marker, recording the handle. */ + private static final class RecordingSibling implements Connector { + final ConnectorWritePlanProvider provider = new MarkerWriteProvider(); + ConnectorTableHandle lastHandle; + + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + return null; + } + + // Owns the test's ForeignHandle — the 3-way gateway router asks each built sibling this before routing. + @Override + public boolean ownsHandle(ConnectorTableHandle handle) { + return handle instanceof ForeignHandle; + } + + @Override + public ConnectorWritePlanProvider getWritePlanProvider(ConnectorTableHandle handle) { + lastHandle = handle; + return provider; + } + + @Override + public void close() { + } + } + + /** A bare write provider stand-in; only its identity matters here. */ + private static final class MarkerWriteProvider implements ConnectorWritePlanProvider { + @Override + public ConnectorSinkPlan planWrite(ConnectorSession session, ConnectorWriteHandle handle) { + return null; + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveFileFormatTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveFileFormatTest.java new file mode 100644 index 00000000000000..30ce7d8ba19693 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveFileFormatTest.java @@ -0,0 +1,139 @@ +// 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.DorisConnectorException; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link HiveFileFormat} read-format detection, pinned to the legacy + * {@code HMSExternalTable.getFileFormatType} truth table. + * + *

WHY these assertions matter: + *

    + *
  • Detection is TWO-STAGE and serde-authoritative for the text family. A standard hive JSON table has + * {@code inputFormat=TextInputFormat} (its JSON-ness is only in the serde); an input-format-first + * classifier mis-read it as text/CSV — the headline read-correctness bug this fixes.
  • + *
  • {@code LazySimpleSerDe}/{@code MultiDelimitSerDe} -> TEXT (BE FORMAT_TEXT), {@code OpenCSVSerde} -> CSV + * (BE FORMAT_CSV_PLAIN): these are DISTINCT BE readers (the text reader honors hive collection/map + * delimiters, {@code \\N} nulls, hive escaping), so they must not collapse.
  • + *
  • Unknown inputFormat/serde fails loud (legacy parity), rather than silently degrading to a JNI read.
  • + *
+ */ +public class HiveFileFormatTest { + + private static final String TEXT_INPUT_FORMAT = "org.apache.hadoop.mapred.TextInputFormat"; + private static final String PARQUET_INPUT_FORMAT = + "org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat"; + private static final String ORC_INPUT_FORMAT = "org.apache.hadoop.hive.ql.io.orc.OrcInputFormat"; + + private static final String LAZY_SIMPLE_SERDE = "org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe"; + private static final String MULTI_DELIMIT_SERDE = "org.apache.hadoop.hive.serde2.MultiDelimitSerDe"; + private static final String CONTRIB_MULTI_DELIMIT_SERDE = + "org.apache.hadoop.hive.contrib.serde2.MultiDelimitSerDe"; + private static final String OPEN_CSV_SERDE = "org.apache.hadoop.hive.serde2.OpenCSVSerde"; + private static final String HCATALOG_JSON_SERDE = "org.apache.hive.hcatalog.data.JsonSerDe"; + private static final String LEGACY_JSON_SERDE = "org.apache.hadoop.hive.serde2.JsonSerDe"; + private static final String OPENX_JSON_SERDE = "org.openx.data.jsonserde.JsonSerDe"; + private static final String ORC_SERDE = "org.apache.hadoop.hive.ql.io.orc.OrcSerde"; + + private static HiveFileFormat detect(String inputFormat, String serDeLib) { + return HiveFileFormat.detect(inputFormat, serDeLib, false, false); + } + + @Test + public void inputFormatPicksParquetAndOrcRegardlessOfSerde() { + Assertions.assertEquals(HiveFileFormat.PARQUET, detect(PARQUET_INPUT_FORMAT, LAZY_SIMPLE_SERDE)); + Assertions.assertEquals(HiveFileFormat.ORC, detect(ORC_INPUT_FORMAT, ORC_SERDE)); + } + + @Test + public void textFamilySerdeDecidesTextVsCsvVsJson() { + // The DEFAULT hive text serde -> FORMAT_TEXT (not CSV): the regression that mis-read hive-text tables. + Assertions.assertEquals(HiveFileFormat.TEXT, detect(TEXT_INPUT_FORMAT, LAZY_SIMPLE_SERDE)); + // MultiDelimit under BOTH package names -> TEXT. + Assertions.assertEquals(HiveFileFormat.TEXT, detect(TEXT_INPUT_FORMAT, MULTI_DELIMIT_SERDE)); + Assertions.assertEquals(HiveFileFormat.TEXT, detect(TEXT_INPUT_FORMAT, CONTRIB_MULTI_DELIMIT_SERDE)); + // OpenCSV -> CSV (FORMAT_CSV_PLAIN). + Assertions.assertEquals(HiveFileFormat.CSV, detect(TEXT_INPUT_FORMAT, OPEN_CSV_SERDE)); + } + + @Test + public void jsonTableWithTextInputFormatResolvesToJsonNotText() { + // THE HEADLINE BUG: a JSON table's inputFormat is TextInputFormat; its JSON-ness lives only in the + // serde. Detection must consult the serde and return JSON, not text/CSV. + Assertions.assertEquals(HiveFileFormat.JSON, detect(TEXT_INPUT_FORMAT, HCATALOG_JSON_SERDE)); + Assertions.assertEquals(HiveFileFormat.JSON, detect(TEXT_INPUT_FORMAT, LEGACY_JSON_SERDE)); + // OpenX JSON with the one-column mode OFF (default) -> JSON. + Assertions.assertEquals(HiveFileFormat.JSON, detect(TEXT_INPUT_FORMAT, OPENX_JSON_SERDE)); + } + + @Test + public void openxJsonOneColumnModeReadsAsCsvWhenFirstColumnIsString() { + // read_hive_json_in_one_column=true + first column is string -> the whole row is read as one CSV column. + Assertions.assertEquals(HiveFileFormat.CSV, + HiveFileFormat.detect(TEXT_INPUT_FORMAT, OPENX_JSON_SERDE, true, true)); + // Non-OpenX json serdes ignore the flag (stay JSON). + Assertions.assertEquals(HiveFileFormat.JSON, + HiveFileFormat.detect(TEXT_INPUT_FORMAT, HCATALOG_JSON_SERDE, true, true)); + } + + @Test + public void openxJsonOneColumnModeFailsLoudWhenFirstColumnNotString() { + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> HiveFileFormat.detect(TEXT_INPUT_FORMAT, OPENX_JSON_SERDE, true, false)); + Assertions.assertTrue(e.getMessage().contains("read_hive_json_in_one_column"), + "message should name the offending session flag"); + } + + @Test + public void unknownSerdeInTextFamilyFailsLoud() { + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> detect(TEXT_INPUT_FORMAT, "com.example.WeirdSerDe")); + Assertions.assertTrue(e.getMessage().contains("Unsupported hive table serde"), + "unknown serde must fail loud (legacy parity), not silently degrade to JNI"); + } + + @Test + public void unknownInputFormatFailsLoud() { + Assertions.assertThrows(DorisConnectorException.class, + () -> detect("com.example.CustomInputFormat", LAZY_SIMPLE_SERDE)); + Assertions.assertThrows(DorisConnectorException.class, + () -> detect(null, LAZY_SIMPLE_SERDE)); + } + + @Test + public void formatNameMatchesGenericVocabularyToken() { + Assertions.assertEquals("parquet", HiveFileFormat.PARQUET.getFormatName()); + Assertions.assertEquals("orc", HiveFileFormat.ORC.getFormatName()); + Assertions.assertEquals("text", HiveFileFormat.TEXT.getFormatName()); + Assertions.assertEquals("csv", HiveFileFormat.CSV.getFormatName()); + Assertions.assertEquals("json", HiveFileFormat.JSON.getFormatName()); + } + + @Test + public void splittability() { + Assertions.assertTrue(HiveFileFormat.PARQUET.isSplittable()); + Assertions.assertTrue(HiveFileFormat.ORC.isSplittable()); + Assertions.assertTrue(HiveFileFormat.TEXT.isSplittable()); + Assertions.assertTrue(HiveFileFormat.CSV.isSplittable()); + Assertions.assertFalse(HiveFileFormat.JSON.isSplittable()); + } +} 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 new file mode 100644 index 00000000000000..ec37a965c09299 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveFileListingCacheTest.java @@ -0,0 +1,634 @@ +// 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.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorColumnHandle; +import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.filesystem.FileEntry; +import org.apache.doris.filesystem.FileSystem; + +import org.apache.hadoop.fs.UnsupportedFileSystemException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +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 + * from the CachingHmsClient metastore layer — Trino keeps CachingDirectoryLister separate too). + * + *

WHY (Rule 9): after the hms cutover the fe-core engine-side {@code HiveExternalMetaCache} stops routing to a + * hive catalog, so without this connector-owned cache every scan (and every periodic row-count refresh) would + * re-list every partition directory from the filesystem. These tests pin the behaviours that make the re-homed + * cache correct: (1) a directory listing is cached keyed by {@code (db, table, location)} so it loads once; the db + * / table / location dimensions never collide; (2) {@code invalidateTable} drops exactly one table's entries and + * {@code invalidateAll} clears all (arming REFRESH); (3) an I/O failure propagates and is NOT cached; (4) the + * {@code meta.cache.hive.file.*} knobs turn it off; (5) the production lister — now driving the engine-injected + * Doris {@link FileSystem} instead of bare Hadoop — filters directories and {@code _}/{@code .}-hidden files, lists + * literally (never glob-expands), and keeps the two failure semantics (systemic loud vs local skippable). Two + * integration tests prove BOTH consumers — the scan provider and the row-count estimate — are served from the SAME + * cache, so a repeated scan / refresh does not re-list.

+ * + *

Dormant: {@code "hms"} is not in {@code SPI_READY_TYPES}, so no live catalog builds a connector; this + * exercises the cache directly.

+ */ +public class HiveFileListingCacheTest { + + // A filesystem placeholder for the CountingLister-based tests: the fake lister ignores it (it lists above the + // FS seam), so any non-null FileSystem suffices — mirrors the role the old Configuration CONF constant played. + private static final FileSystem FS = new FakeFileSystem(); + + private static Map props(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + // ==================== caching: hit / miss keyed by (db, table, location) ==================== + + @Test + public void listingIsCachedPerLocation() { + CountingLister lister = new CountingLister(); + HiveFileListingCache cache = new HiveFileListingCache(Collections.emptyMap(), lister); + + List a = cache.listDataFiles("db", "t", "loc1", FS); + List b = cache.listDataFiles("db", "t", "loc1", FS); + // WHY: a hit must serve the cached listing without re-listing the filesystem. + Assertions.assertSame(a, b); + Assertions.assertEquals(1, lister.totalCalls); + + // WHY: a different directory is a different key — must re-list. + cache.listDataFiles("db", "t", "loc2", FS); + Assertions.assertEquals(2, lister.totalCalls); + } + + @Test + public void keyIsScopedByDbTableAndLocation() { + CountingLister lister = new CountingLister(); + HiveFileListingCache cache = new HiveFileListingCache(Collections.emptyMap(), lister); + + // Same location string, different db / table. WHY: (db, table) must be part of the key so invalidateTable + // can scope one table — and so two tables that happen to share a path never serve each other's listing. + cache.listDataFiles("db1", "t", "loc", FS); + cache.listDataFiles("db2", "t", "loc", FS); + cache.listDataFiles("db1", "t2", "loc", FS); + Assertions.assertEquals(3, lister.totalCalls); + } + + // ==================== invalidation (arms REFRESH TABLE / REFRESH CATALOG) ==================== + + @Test + public void invalidateTableDropsOnlyThatTablesEntries() { + CountingLister lister = new CountingLister(); + HiveFileListingCache cache = new HiveFileListingCache(Collections.emptyMap(), lister); + + cache.listDataFiles("db", "t1", "locA", FS); + cache.listDataFiles("db", "t2", "locB", FS); + Assertions.assertEquals(2, lister.totalCalls); + + cache.invalidateTable("db", "t1"); + + // WHY: t1 must re-list after its invalidation... + cache.listDataFiles("db", "t1", "locA", FS); + Assertions.assertEquals(2, (int) lister.callsPerLocation.get("locA")); + // ...while t2's entry (a different table) must survive — invalidateTable is scoped by (db, table). + cache.listDataFiles("db", "t2", "locB", FS); + Assertions.assertEquals(1, (int) lister.callsPerLocation.get("locB")); + } + + @Test + public void invalidateAllDropsEverything() { + CountingLister lister = new CountingLister(); + HiveFileListingCache cache = new HiveFileListingCache(Collections.emptyMap(), lister); + + cache.listDataFiles("db", "t1", "locA", FS); + cache.listDataFiles("db", "t2", "locB", FS); + Assertions.assertEquals(2, lister.totalCalls); + + cache.invalidateAll(); + + // WHY: every entry must reload after invalidateAll (REFRESH CATALOG). + cache.listDataFiles("db", "t1", "locA", FS); + cache.listDataFiles("db", "t2", "locB", FS); + Assertions.assertEquals(4, lister.totalCalls); + } + + // ==================== failures are propagated, never cached ==================== + + @Test + public void listingFailureIsPropagatedAndNotCached() { + CountingLister lister = new CountingLister(); + lister.error = new DorisConnectorException("boom"); + HiveFileListingCache cache = new HiveFileListingCache(Collections.emptyMap(), lister); + + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> cache.listDataFiles("db", "t", "loc", FS)); + Assertions.assertEquals("boom", e.getMessage()); + Assertions.assertEquals(1, lister.totalCalls); + + // WHY: a transient listing failure must NOT be cached — after recovery the next call re-lists and succeeds + // (otherwise a momentary storage blip would poison the listing for the whole TTL). + lister.error = null; + List ok = cache.listDataFiles("db", "t", "loc", FS); + Assertions.assertEquals(1, ok.size()); + Assertions.assertEquals(2, lister.totalCalls); + } + + // ==================== per-entry property knob ==================== + + @Test + public void disablingViaPropsBypassesTheCache() { + CountingLister lister = new CountingLister(); + HiveFileListingCache cache = new HiveFileListingCache( + props("meta.cache.hive.file.enable", "false"), lister); + + cache.listDataFiles("db", "t", "loc", FS); + cache.listDataFiles("db", "t", "loc", FS); + // WHY: meta.cache.hive.file.enable=false must bypass caching entirely — every call re-lists. + Assertions.assertEquals(2, lister.totalCalls); + } + + @Test + public void legacyFileMetaCacheTtlZeroBypassesTheCache() { + CountingLister lister = new CountingLister(); + HiveFileListingCache cache = new HiveFileListingCache( + props("file.meta.cache.ttl-second", "0"), lister); + + cache.listDataFiles("db", "t", "loc", FS); + cache.listDataFiles("db", "t", "loc", FS); + // WHY: the legacy fe-core knob file.meta.cache.ttl-second=0 must still disable the listing cache after the + // SPI cutover (it is remapped onto meta.cache.hive.file.ttl-second). Without the compatibility remap the + // key was ignored, the default 24h cache stayed on, and a newly-written file in an already-listed + // partition stayed invisible until REFRESH (regression that failed test_hive_meta_cache's sql_2row). + Assertions.assertEquals(2, lister.totalCalls); + } + + // ==================== the production lister: filters dirs + hidden files, lists literally ==================== + + @Test + public void listFromFileSystemFiltersDirectoriesAndHiddenFiles() { + String dir = "file:///wh/db/t/dt=1"; + FakeFileSystem fs = new FakeFileSystem().withEntries( + FakeFileSystem.file(dir + "/data1", 100L, 1L), + FakeFileSystem.file(dir + "/data2", 200L, 1L), + FakeFileSystem.file(dir + "/_SUCCESS", 3L, 1L), // hive success marker — must be skipped + FakeFileSystem.file(dir + "/.hidden", 3L, 1L), // dot-file — must be skipped + FakeFileSystem.dir(dir + "/subdir")); // directory — must be skipped + + List files = HiveFileListingCache.listFromFileSystem(dir, fs); + + // WHY: only the two real data files survive; the total size is exactly their sum. This pins the same + // dir/_-/.-filter the pre-cache scan and estimate paths applied inline. + Assertions.assertEquals(2, files.size(), "only the two data files must survive the dir/hidden filter"); + long totalSize = files.stream().mapToLong(HiveFileStatus::getLength).sum(); + Assertions.assertEquals(300L, totalSize); + for (HiveFileStatus f : files) { + String name = f.getPath().substring(f.getPath().lastIndexOf('/') + 1); + Assertions.assertTrue(name.equals("data1") || name.equals("data2"), "unexpected file: " + name); + } + } + + @Test + public void listFromFileSystemPreservesPathLengthAndModTime() { + // WHY (Rule 9): the FileEntry -> HiveFileStatus mapping must be byte-parity — the path string verbatim + // (Location.uri(), no re-encoding of hive's %3A timestamp partitions), the byte length and the mtime the + // BE reads to detect a changed split. A field transposition would flip these. + String dir = "file:///wh/db/t/pt=2024-04-09 12%3A34%3A56"; + FakeFileSystem fs = new FakeFileSystem().withEntries( + FakeFileSystem.file(dir + "/000000_0", 4096L, 1712660096000L)); + + List files = HiveFileListingCache.listFromFileSystem(dir, fs); + + Assertions.assertEquals(1, files.size()); + Assertions.assertEquals(dir + "/000000_0", files.get(0).getPath()); + Assertions.assertEquals(4096L, files.get(0).getLength()); + Assertions.assertEquals(1712660096000L, files.get(0).getModificationTime()); + } + + @Test + public void listFromFileSystemListsLiterallyForGlobCharLocation() { + // WHY (Rule 9): a location containing a glob metachar ('[' '*' '?') — e.g. a custom external SET LOCATION — + // must be listed LITERALLY (old fs.listStatus never glob-expanded). The production lister must use the + // literal list(), not the per-scheme glob-aware listFiles() override. FakeFileSystem.listFiles() throws + // AssertionError, so if the lister ever regressed to listFiles() this test would fail loudly. + String dir = "file:///wh/db/my[table]/dt=1"; + FakeFileSystem fs = new FakeFileSystem().withEntries( + FakeFileSystem.file(dir + "/000000_0", 10L, 1L)); + + List files = HiveFileListingCache.listFromFileSystem(dir, fs); + + Assertions.assertEquals(1, files.size(), "the literal directory must be listed, not glob-matched"); + Assertions.assertEquals(dir + "/000000_0", files.get(0).getPath()); + } + + // ==================== recursive listing (hive.recursive_directories, default true) ==================== + + /** A three-level tree: top-level exp_a plus sub-directories 1/ (exp_b) and 2/ (exp_c). */ + private static Map> recursiveTree(String top) { + Map> tree = new HashMap<>(); + tree.put(top, Arrays.asList( + FakeFileSystem.file(top + "/exp_a", 1L, 1L), + FakeFileSystem.dir(top + "/1"), + FakeFileSystem.dir(top + "/2"))); + tree.put(top + "/1", Collections.singletonList(FakeFileSystem.file(top + "/1/exp_b", 1L, 1L))); + tree.put(top + "/2", Collections.singletonList(FakeFileSystem.file(top + "/2/exp_c", 1L, 1L))); + return tree; + } + + @Test + public void recursiveDescendsIntoSubdirectories() { + // WHY (Rule 9): a table whose data lives in sub-directories (top + 1/ + 2/) must contribute ALL its files + // when recursion is on — else those rows are silently lost (the regression this restores). Mirrors + // hive_config_test's hive_recursive_directories_table (tags 2/21 = 6 rows). + String top = "file:///wh/db/t/dt=1"; + FakeFileSystem fs = new FakeFileSystem().withTree(recursiveTree(top)); + + List files = HiveFileListingCache.listFromFileSystem(top, fs, true); + + Assertions.assertEquals(3, files.size(), "recursive listing must include files from every sub-directory"); + } + + @Test + public void nonRecursiveListsTopLevelOnly() { + // WHY (Rule 9): with recursion off, only top-level files are returned — sub-directories are NOT descended + // (byte-identical to today; pins hive_config_test tag 1 = 2 rows). + String top = "file:///wh/db/t/dt=1"; + FakeFileSystem fs = new FakeFileSystem().withTree(recursiveTree(top)); + + List files = HiveFileListingCache.listFromFileSystem(top, fs, false); + + Assertions.assertEquals(1, files.size(), "non-recursive listing must not descend into sub-directories"); + Assertions.assertTrue(files.get(0).getPath().endsWith("/exp_a"), "only the top-level file survives"); + } + + @Test + public void recursiveSkipsHiddenSubdirectoriesAndFiles() { + // WHY (Rule 9): recursion must skip hidden sub-directories (_temporary / .hive-staging write-staging) and + // hidden files at every level — exact net parity with legacy's full-path containsHiddenPath filter. A + // descend-all-then-leaf-filter regression would surface _temporary/part-0 staging files. + String top = "file:///wh/db/t/dt=1"; + Map> tree = new HashMap<>(); + tree.put(top, Arrays.asList( + FakeFileSystem.file(top + "/exp_a", 1L, 1L), + FakeFileSystem.file(top + "/.hidden", 1L, 1L), // hidden file — skipped + FakeFileSystem.dir(top + "/_temporary"), // hidden dir — NOT descended + FakeFileSystem.dir(top + "/1"))); // real sub-dir — descended + tree.put(top + "/_temporary", + Collections.singletonList(FakeFileSystem.file(top + "/_temporary/part-0", 1L, 1L))); + tree.put(top + "/1", Collections.singletonList(FakeFileSystem.file(top + "/1/exp_b", 1L, 1L))); + FakeFileSystem fs = new FakeFileSystem().withTree(tree); + + List files = HiveFileListingCache.listFromFileSystem(top, fs, true); + + Assertions.assertEquals(2, files.size(), "only real data files survive; hidden dir/file are excluded"); + for (HiveFileStatus f : files) { + Assertions.assertFalse(f.getPath().contains("_temporary"), + "must not read a hidden staging dir: " + f.getPath()); + Assertions.assertFalse(f.getPath().endsWith("/.hidden"), "must not read a hidden file"); + } + } + + @Test + public void defaultIsRecursive() { + // WHY (Rule 9): with NO hive.recursive_directories property the catalog defaults to recursive (legacy + // default "true"); pins tag 21. Drives the REAL production lister through listDataFiles — the + // RED-against-literal-HEAD guarantee (today's non-recursive lister returns 1, not 3). + String top = "file:///wh/db/t/dt=1"; + FakeFileSystem fs = new FakeFileSystem().withTree(recursiveTree(top)); + HiveFileListingCache cache = new HiveFileListingCache(Collections.emptyMap()); + + List files = cache.listDataFiles("db", "t", top, fs); + + Assertions.assertEquals(3, files.size(), "default (no property) must be recursive"); + } + + @Test + public void recursiveFlagFalseFromProperty() { + // WHY (Rule 9): hive.recursive_directories=false is honoured through the public ctor / listDataFiles path — + // pins that the tag-1 vs tag-2 divergence is driven by the property, not hardcoded. + String top = "file:///wh/db/t/dt=1"; + FakeFileSystem fs = new FakeFileSystem().withTree(recursiveTree(top)); + HiveFileListingCache cache = new HiveFileListingCache(props("hive.recursive_directories", "false")); + + List files = cache.listDataFiles("db", "t", top, fs); + + Assertions.assertEquals(1, files.size(), "hive.recursive_directories=false must list top-level only"); + } + + @Test + public void recursiveSubdirListingFailureIsSkippable() { + // WHY (Rule 9): a listing failure in a DESCENDED sub-directory must reach the SAME classifier as a + // top-level failure — a local FileNotFound stays the skippable HiveDirectoryListingException (so the scan + // skips just this partition). Guards a future swallowing/reclassifying catch around the recursion. + String top = "file:///wh/db/t/dt=1"; + Map> tree = new HashMap<>(); + tree.put(top, Arrays.asList( + FakeFileSystem.file(top + "/exp_a", 1L, 1L), + FakeFileSystem.dir(top + "/1"))); + FakeFileSystem fs = new FakeFileSystem().withTree(tree) + .failListAt(top + "/1", new FileNotFoundException("Path does not exist")); + + Assertions.assertThrows(HiveDirectoryListingException.class, + () -> HiveFileListingCache.listFromFileSystem(top, fs, true)); + } + + // ==================== failure split: systemic is loud, local is skippable ==================== + + @Test + public void listFromFileSystemFailsLoudWhenFsIsNull() { + // A null engine filesystem (catalog with no storage) is a SYSTEMIC config error affecting every partition. + // WHY (Rule 9): it must fail the query loud (plain DorisConnectorException), not skip / return empty. + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> HiveFileListingCache.listFromFileSystem("hdfs://host/path", null)); + Assertions.assertFalse(e instanceof HiveDirectoryListingException, + "a missing filesystem must be loud, not skippable"); + } + + @Test + public void listFromFileSystemFailsLoudWhenFilesystemCannotBeResolved() { + // forLocation failing (unresolvable scheme / no StorageProperties / factory error) is a SYSTEMIC + // storage-config error affecting every partition. It must throw a plain DorisConnectorException (which the + // scan path does NOT skip), and NOT the skippable HiveDirectoryListingException. + // WHY (Rule 9): if this were the skippable subtype, a misconfigured storage would silently return an empty + // scan instead of failing the query loud — the exact regression this fix prevents. + FakeFileSystem fs = new FakeFileSystem().failForLocation(new IOException("no StorageProperties for scheme")); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> HiveFileListingCache.listFromFileSystem("nosuchscheme://host/path", fs)); + Assertions.assertFalse(e instanceof HiveDirectoryListingException, + "a filesystem-resolution failure must be loud (plain DorisConnectorException), not skippable"); + } + + @Test + public void listFromFileSystemFailsLoudWhenSchemeImplMissing() { + // A lazily-surfaced "No FileSystem for scheme X" (the engine-side FS impl for the scheme is absent — broken + // packaging) is thrown from list(), not forLocation(). It is nonetheless SYSTEMIC (affects every partition). + // WHY (Rule 9): it must be re-classified LOUD (plain DorisConnectorException), matching the pre-migration + // FileSystem.get behaviour — this is the exact "No FileSystem for scheme hdfs" error class FIX-HIVEFS keeps + // loud. If it degraded to the skippable subtype, a broken deployment would scan EMPTY silently. + FakeFileSystem fs = new FakeFileSystem() + .failList(new UnsupportedFileSystemException("No FileSystem for scheme \"hdfs\"")); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> HiveFileListingCache.listFromFileSystem("hdfs://host/path", fs)); + Assertions.assertFalse(e instanceof HiveDirectoryListingException, + "a scheme-not-registered failure must stay loud, not skippable"); + } + + @Test + public void listFromFileSystemIsSkippableWhenDirectoryMissing() { + // A resolvable filesystem but a non-existent directory makes list() fail — a LOCAL failure of one partition + // directory. It must throw the skippable HiveDirectoryListingException so the scan skips just that partition + // (pre-cache tolerance of a missing/unreadable partition dir). + FakeFileSystem fs = new FakeFileSystem().failList(new FileNotFoundException("Path does not exist")); + Assertions.assertThrows(HiveDirectoryListingException.class, + () -> HiveFileListingCache.listFromFileSystem("file:///wh/db/t/does-not-exist", fs)); + } + + @Test + public void resolutionFailureThroughCacheFailsLoudAndIsNotCached() { + // Drives the REAL production lister THROUGH the cache lookup with a forLocation failure. WHY (Rule 9): pins + // that (1) a systemic storage-config failure propagates from listDataFiles as the loud plain + // DorisConnectorException — MetaCacheEntry's manual-miss load rethrows RuntimeException unwrapped, so the + // type survives the cache boundary — and NOT the skippable subtype; and (2) the failure leaves NO cache + // entry. (2) kills the mutation "catch -> return emptyList" in the loader, which would cache a poisoned + // empty listing and silently turn every later scan into 0 rows for the TTL. + HiveFileListingCache cache = new HiveFileListingCache(Collections.emptyMap()); + FakeFileSystem fs = new FakeFileSystem().failForLocation(new IOException("no StorageProperties for scheme")); + + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> cache.listDataFiles("db", "t", "nosuchfs://host/path", fs)); + Assertions.assertFalse(e instanceof HiveDirectoryListingException, + "a filesystem-resolution failure through the cache must be the loud type, not the skippable one"); + Assertions.assertEquals(0L, cache.size(), "a failed load must never leave a cache entry"); + } + + @Test + public void listFailureThroughCacheIsSkippableAndNotCached() { + // Drives the REAL production lister THROUGH the cache with a resolvable filesystem but a list() failure, so + // the listing genuinely fails. WHY (Rule 9): pins that the LOCAL per-directory failure keeps its distinct + // skippable type (HiveDirectoryListingException, exactly what the scan's per-partition skip catches) across + // the cache boundary, and leaves no cache entry either. + HiveFileListingCache cache = new HiveFileListingCache(Collections.emptyMap()); + FakeFileSystem fs = new FakeFileSystem().failList(new FileNotFoundException("Path does not exist")); + + Assertions.assertThrows(HiveDirectoryListingException.class, + () -> cache.listDataFiles("db", "t", "file:///wh/db/t/does-not-exist", fs)); + Assertions.assertEquals(0L, cache.size(), "a failed load must never leave a cache entry"); + } + + @Test + public void scanSkipsOnlyTheFailedPartitionAndPlansTheRest() { + // A LOCAL per-directory listing failure (HiveDirectoryListingException) on ONE partition among several + // must be tolerated PER PARTITION: the failed one is skipped with a warning, every other partition still + // plans its splits — the pre-cache resilience. WHY (Rule 9): if the scan aborted on the subtype, or the + // skip were scan-wide instead of per-partition, one bad directory would lose the whole table. + CountingLister lister = new CountingLister(); + lister.error = new HiveDirectoryListingException("dir gone", new IOException("boom")); + lister.errorLocation = "loc/dt=1"; + HiveScanPlanProvider provider = new HiveScanPlanProvider(null, Collections.emptyMap(), + new FakeConnectorContext(), new HiveReadTransactionManager(), + new HiveFileListingCache(Collections.emptyMap(), lister)); + + List ranges = provider.planScan( + new FakeSession(), twoPartitionHandle(), Collections.emptyList(), + Optional.empty()); + + // 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")); + Assertions.assertEquals(1, (int) lister.callsPerLocation.get("loc/dt=2")); + Assertions.assertEquals(1, ranges.size(), + "the failed partition is skipped, the healthy partition must still plan its split"); + } + + @Test + public void scanFailsLoudOnSystemicFilesystemFailure() { + // A SYSTEMIC filesystem-resolution failure (plain DorisConnectorException, affects every partition) must + // fail the query loud, NOT be swallowed into a silent empty scan. + // MUTATION: if listAndSplitFiles caught the base DorisConnectorException (the pre-fix behavior) this would + // return an empty range list instead of throwing -> red. + CountingLister lister = new CountingLister(); + lister.error = new DorisConnectorException("bad storage config"); + HiveScanPlanProvider provider = new HiveScanPlanProvider(null, Collections.emptyMap(), + new FakeConnectorContext(), new HiveReadTransactionManager(), + new HiveFileListingCache(Collections.emptyMap(), lister)); + + Assertions.assertThrows(DorisConnectorException.class, () -> provider.planScan( + new FakeSession(), singlePartitionHandle(), Collections.emptyList(), + Optional.empty())); + } + + private static HiveTableHandle singlePartitionHandle() { + return new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .inputFormat("org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat") + .serializationLib("org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe") + .partitionKeyNames(Collections.singletonList("dt")) + .prunedPartitions(Collections.singletonList( + new HmsPartitionInfo(Collections.singletonList("1"), "loc/dt=1", null, null, null, null))) + .build(); + } + + private static HiveTableHandle twoPartitionHandle() { + return new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .inputFormat("org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat") + .serializationLib("org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe") + .partitionKeyNames(Collections.singletonList("dt")) + .prunedPartitions(Arrays.asList( + new HmsPartitionInfo(Collections.singletonList("1"), "loc/dt=1", null, null, null, null), + new HmsPartitionInfo(Collections.singletonList("2"), "loc/dt=2", null, null, null, null))) + .build(); + } + + // ==================== integration: the scan provider is cache-backed ==================== + + @Test + public void scanProviderServesRepeatedScansFromTheCache() { + CountingLister lister = new CountingLister(); + HiveFileListingCache cache = new HiveFileListingCache(Collections.emptyMap(), lister); + // hmsClient is null: with pruned partitions on the handle the scan never calls the metastore. + HiveScanPlanProvider provider = new HiveScanPlanProvider( + null, Collections.emptyMap(), new FakeConnectorContext(), new HiveReadTransactionManager(), cache); + + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .inputFormat("org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat") + .serializationLib("org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe") + .partitionKeyNames(Collections.singletonList("dt")) + .prunedPartitions(Arrays.asList( + new HmsPartitionInfo(Collections.singletonList("1"), "loc/dt=1", null, null, null, null), + 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()); + + // 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. + Assertions.assertEquals(1, (int) lister.callsPerLocation.get("loc/dt=1")); + Assertions.assertEquals(1, (int) lister.callsPerLocation.get("loc/dt=2")); + // One 10-byte file per partition -> one range each; both scans plan the same ranges. + Assertions.assertEquals(2, first.size()); + Assertions.assertEquals(2, second.size()); + } + + // ==================== integration: the row-count estimate is cache-backed (7-arg wiring) ==================== + + @Test + public void estimateDataSizeIsServedFromTheCache() { + CountingLister lister = new CountingLister(); + HiveFileListingCache cache = new HiveFileListingCache(Collections.emptyMap(), lister); + // The production 7-arg constructor injects the connector's shared cache; the sibling seams are unused for + // a plain-hive handle, so dummy suppliers suffice. + HiveConnectorMetadata md = new HiveConnectorMetadata( + null, Collections.emptyMap(), new FakeConnectorContext(), + () -> null, () -> null, h -> null, cache); + + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .location("file:///wh/t") + .build(); + + long first = md.estimateDataSizeByListingFiles(new FakeSession(), handle); + long second = md.estimateDataSizeByListingFiles(new FakeSession(), handle); + + // WHY: the estimate returns the one 10-byte file's size, and the second (periodic ExternalRowCountCache + // refresh) call is served from the SAME cache — one listing, not two. This proves the row-count refresh + // reuses a listing a scan warmed (and vice versa), the §4.4 5th-cache coupling. + Assertions.assertEquals(10L, first); + Assertions.assertEquals(10L, second); + Assertions.assertEquals(1, lister.totalCalls); + } + + /** + * A {@link HiveFileListingCache.DirectoryLister} double: counts calls (total + per location) and returns a + * fresh single-file listing per call, so reference identity distinguishes a cache hit from a reload. Throws + * {@link #error} when set, to exercise the failure-not-cached path. Lists above the FileSystem seam, so it + * ignores the injected {@link FileSystem}. + */ + private static final class CountingLister implements HiveFileListingCache.DirectoryLister { + final Map callsPerLocation = new HashMap<>(); + int totalCalls; + RuntimeException error; + // When set, error is thrown only for this location (a single bad partition among healthy ones). + String errorLocation; + + @Override + public List list(String location, FileSystem fs) { + totalCalls++; + callsPerLocation.merge(location, 1, Integer::sum); + if (error != null && (errorLocation == null || errorLocation.equals(location))) { + throw error; + } + return new ArrayList<>(Collections.singletonList(new HiveFileStatus(location + "/000000_0", 10L, 1L))); + } + } + + /** Minimal {@link ConnectorSession} for planScan (no split-size override, empty session properties). */ + private static final class FakeSession implements ConnectorSession { + @Override + public String getQueryId() { + return "q"; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public long getCatalogId() { + return 0L; + } + + @Override + public String getCatalogName() { + return "hive_catalog"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveReadTransactionTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveReadTransactionTest.java new file mode 100644 index 00000000000000..b7298e4b0a66db --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveReadTransactionTest.java @@ -0,0 +1,231 @@ +// 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.hms.HmsClient; +import org.apache.doris.connector.hms.HmsDatabaseInfo; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsTableInfo; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Tests the plugin read-side transaction lifecycle ({@link HiveReadTransaction} / + * {@link HiveReadTransactionManager}) against a recording-fake {@link HmsClient}. + * + *

WHY: reading a transactional Hive table must open a metastore transaction, take a shared read lock + * exactly once (holding it for the whole scan pins a consistent write-id snapshot), and release it by + * committing when the query finishes. Acquiring the lock more than once, or forgetting to commit, either + * corrupts snapshot isolation or leaks the lock. These tests pin that once-per-query lock + commit + * contract.

+ */ +public class HiveReadTransactionTest { + + @Test + public void testBeginOpensTxnAndValidWriteIdsAcquiresLockOnce() { + RecordingHmsClient client = new RecordingHmsClient(); + client.openTxnReturn = 777L; + client.validIds.put("hive.txn.valid.writeids", "db.tbl:8:x"); + + HiveReadTransaction txn = new HiveReadTransaction( + "q1", "alice", "db", "tbl", true, client); + txn.begin(); + Assertions.assertEquals(List.of("openTxn:alice"), client.calls); + + txn.addPartition("dt=2026-01-01"); + Map ids = txn.getValidWriteIds(); + Assertions.assertEquals("db.tbl:8:x", ids.get("hive.txn.valid.writeids")); + // The lock must be scoped to the transaction, the (db, table), and the added partitions. + Assertions.assertEquals(777L, client.lockTxnId); + Assertions.assertEquals("db.tbl", client.getValidWriteIdsTable); + Assertions.assertEquals(777L, client.getValidWriteIdsTxnId); + Assertions.assertEquals(List.of("dt=2026-01-01"), client.lockPartitions); + + // Second call must be memoized: no extra lock / getValidWriteIds round-trips. + Map ids2 = txn.getValidWriteIds(); + Assertions.assertSame(ids, ids2); + Assertions.assertEquals(1, count(client.calls, "acquireSharedLock:q1")); + Assertions.assertEquals(1, count(client.calls, "getValidWriteIds:db.tbl")); + } + + @Test + public void testCommitCommitsTheTxn() { + RecordingHmsClient client = new RecordingHmsClient(); + client.openTxnReturn = 42L; + HiveReadTransaction txn = new HiveReadTransaction("q1", "bob", "db", "t", false, client); + txn.begin(); + txn.commit(); + Assertions.assertTrue(client.calls.contains("commitTxn:42"), client.calls.toString()); + } + + @Test + public void testManagerRegisterBeginsAndDeregisterCommits() { + RecordingHmsClient client = new RecordingHmsClient(); + client.openTxnReturn = 5L; + HiveReadTransactionManager mgr = new HiveReadTransactionManager(); + + HiveReadTransaction txn = new HiveReadTransaction("q1", "u", "db", "t", true, client); + mgr.register(txn); + Assertions.assertEquals(1, count(client.calls, "openTxn:u"), "register opens the txn"); + + mgr.deregister("q1"); + Assertions.assertEquals(1, count(client.calls, "commitTxn:5"), "deregister commits the txn"); + + // Idempotent: a second deregister and an unknown query must be no-ops. + mgr.deregister("q1"); + mgr.deregister("unknown"); + Assertions.assertEquals(1, count(client.calls, "commitTxn:5"), "no double commit"); + } + + @Test + public void testScanProviderReleaseReadTransactionCommitsViaSharedManager() { + RecordingHmsClient client = new RecordingHmsClient(); + client.openTxnReturn = 9L; + // register and the provider's release MUST share the same per-connector manager (HiveConnector injects + // one instance into every provider), so the release finds and commits the txn register opened. + HiveReadTransactionManager mgr = new HiveReadTransactionManager(); + HiveScanPlanProvider provider = new HiveScanPlanProvider(client, new HashMap<>(), + new FakeConnectorContext(), mgr, new HiveFileListingCache(new HashMap<>())); + + // A transactional-hive scan opened a read txn (as planAcidScan does via mgr.register), taking the shared + // read lock. + mgr.register(new HiveReadTransaction("q9", "u", "db", "t", true, client)); + Assertions.assertEquals(1, count(client.calls, "openTxn:u"), "the scan opened a read txn"); + + // The query-finish callback routes through the provider's releaseReadTransaction, which must commit the + // txn (releasing the shared read lock) exactly once — otherwise the lock leaks for the metastore's life. + provider.releaseReadTransaction("q9"); + Assertions.assertEquals(1, count(client.calls, "commitTxn:9"), + "releaseReadTransaction must commit the txn (release the shared read lock) exactly once"); + + // Idempotent: a second release, or a release for a query that opened no txn, is a safe no-op. + provider.releaseReadTransaction("q9"); + provider.releaseReadTransaction("never-opened"); + Assertions.assertEquals(1, count(client.calls, "commitTxn:9"), "no double commit on repeat release"); + } + + private static int count(List calls, String prefix) { + int n = 0; + for (String c : calls) { + if (c.startsWith(prefix)) { + n++; + } + } + return n; + } + + /** + * Recording {@link HmsClient} fake: stubs the abstract read surface and records the four read-ACID + * primitives the read transaction drives. All other primitives keep their default {@code throw}. + */ + private static final class RecordingHmsClient implements HmsClient { + final List calls = new ArrayList<>(); + long openTxnReturn; + long lockTxnId; + List lockPartitions; + long getValidWriteIdsTxnId; + String getValidWriteIdsTable; + final Map validIds = new HashMap<>(); + + @Override + public long openTxn(String user) { + calls.add("openTxn:" + user); + return openTxnReturn; + } + + @Override + public void acquireSharedLock(String queryId, long txnId, String user, String dbName, + String tableName, List partitionNames, long timeoutMs) { + calls.add("acquireSharedLock:" + queryId + ":" + txnId); + this.lockTxnId = txnId; + this.lockPartitions = new ArrayList<>(partitionNames); + } + + @Override + public Map getValidWriteIds(String fullTableName, long currentTransactionId) { + calls.add("getValidWriteIds:" + fullTableName + ":" + currentTransactionId); + this.getValidWriteIdsTxnId = currentTransactionId; + this.getValidWriteIdsTable = fullTableName; + return validIds; + } + + @Override + public void commitTxn(long txnId) { + calls.add("commitTxn:" + txnId); + } + + // ---- unused abstract read surface ---- + + @Override + public List listDatabases() { + return Collections.emptyList(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + throw new UnsupportedOperationException("getDatabase"); + } + + @Override + public List listTables(String dbName) { + return Collections.emptyList(); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + return false; + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + throw new UnsupportedOperationException("getTable"); + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + return Collections.emptyMap(); + } + + @Override + public List listPartitionNames(String dbName, String tableName, int maxParts) { + return Collections.emptyList(); + } + + @Override + public List getPartitions(String dbName, String tableName, + List partNames) { + return Collections.emptyList(); + } + + @Override + public HmsPartitionInfo getPartition(String dbName, String tableName, List values) { + throw new UnsupportedOperationException("getPartition"); + } + + @Override + public void close() { + } + } +} 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 new file mode 100644 index 00000000000000..b987643bfce76f --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanBatchModeTest.java @@ -0,0 +1,431 @@ +// 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.handle.ConnectorColumnHandle; +import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsDatabaseInfo; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsTableInfo; +import org.apache.doris.filesystem.FileSystem; +import org.apache.doris.thrift.TFileCompressType; +import org.apache.doris.thrift.TFileScanRangeParams; + +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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Tests the two connector-local batch-mode overrides {@link HiveScanPlanProvider} adds so a large partitioned + * hive scan streams its splits per partition batch instead of materializing every file synchronously into the FE + * heap (post-HMS-cutover, hive scans through the generic {@code PluginDrivenScanNode}, which enters batch mode + * only when the connector opts in). + * + *

WHY (Rule 9): the two overrides encode two correctness-critical decisions.

+ *
    + *
  • {@code supportsBatchScan}: batch iff PARTITIONED and NOT transactional. ACID tables are excluded so the + * per-batch resolution does not open (and leak) a metastore read transaction per batch — they keep the + * synchronous path.
  • + *
  • {@code planScanForPartitionBatch}: hive's {@code planScan} resolves partitions from the handle's pruned + * set and IGNORES the passed partition set, so hive MUST override the batch hook to scope resolution to the + * batch. If it inherited the SPI default (which re-runs the whole-pruned-set {@code planScan} per batch), + * every partition's files would be emitted once per batch — DUPLICATE ROWS. The {@code ranges.size()==1} + * (and single-location listing) assertion below fails precisely under that bug.
  • + *
+ * + *

No Mockito: reuses the proven {@code FakeHmsClient} echo (getPartitions(names) -> one partition per name, + * location = name) and a counting {@link HiveFileListingCache.DirectoryLister} so the listed locations can be + * asserted, exactly as {@code HiveConnectorMetadataPartitionPruningTest} / {@code HiveFileListingCacheTest}.

+ */ +public class HiveScanBatchModeTest { + + private static final List PART_KEYS = Arrays.asList("year", "month"); + private static final String PARQUET_INPUT_FORMAT = + "org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat"; + private static final String PARQUET_SERDE = + "org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe"; + + // ==================== supportsBatchScan: partitioned AND non-transactional ==================== + + @Test + public void supportsBatchScanIsTrueForPartitionedNonTransactionalTable() { + HiveScanPlanProvider provider = provider(null, new CountingLister()); + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .partitionKeyNames(PART_KEYS) + .build(); + // WHY: a partitioned, non-transactional table is exactly the case that must stream its splits per batch. + Assertions.assertTrue(provider.supportsBatchScan(new FakeSession(), handle)); + } + + @Test + public void supportsBatchScanIsFalseForUnpartitionedTable() { + HiveScanPlanProvider provider = provider(null, new CountingLister()); + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .partitionKeyNames(Collections.emptyList()) + .build(); + // WHY: an unpartitioned table has a single partition (the table location); there is nothing to batch, so + // it stays on the synchronous planScan path. + Assertions.assertFalse(provider.supportsBatchScan(new FakeSession(), handle)); + } + + @Test + public void supportsBatchScanIsFalseForTransactionalPartitionedTable() { + HiveScanPlanProvider provider = provider(null, new CountingLister()); + Map txnParams = new HashMap<>(); + txnParams.put("transactional", "true"); + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .partitionKeyNames(PART_KEYS) + .tableParameters(txnParams) + .build(); + // WHY (Rule 9): ACID tables are excluded DELIBERATELY — running planScanForPartitionBatch per batch on + // background threads would open (and leak) a metastore read transaction per batch. They keep the + // synchronous path even though they are partitioned. + Assertions.assertFalse(provider.supportsBatchScan(new FakeSession(), handle)); + } + + // ==================== planScanForPartitionBatch: scoped to the batch, no duplication ==================== + + @Test + public void planScanForPartitionBatchResolvesOnlyTheBatch() { + CountingLister lister = new CountingLister(); + // getPartitions echoes each requested name back as a partition whose location IS the name, so the counting + // lister's keys are the partition names actually resolved for this batch. + HiveScanPlanProvider provider = provider(new FakeHmsClient(), lister); + + // The handle carries the FULL pruned set (all three partitions). If the batch hook were NOT scoped to the + // batch (SPI default -> whole-pruned-set planScan), all three would be listed -> 3 ranges. + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .inputFormat(PARQUET_INPUT_FORMAT) + .serializationLib(PARQUET_SERDE) + .partitionKeyNames(PART_KEYS) + .prunedPartitions(Arrays.asList( + part("year=2023/month=12"), + part("year=2024/month=01"), + part("year=2024/month=02"))) + .build(); + + List ranges = provider.planScanForPartitionBatch( + new FakeSession(), handle, Collections.emptyList(), + Optional.empty(), -1L, 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. + Assertions.assertEquals(1, ranges.size()); + // ...and the file lister was asked ONLY for the batch's partition, never the other two. + Assertions.assertEquals(1, lister.totalCalls); + Assertions.assertEquals(1, (int) lister.callsPerLocation.get("year=2024/month=01")); + Assertions.assertNull(lister.callsPerLocation.get("year=2023/month=12")); + Assertions.assertNull(lister.callsPerLocation.get("year=2024/month=02")); + } + + // ===== object-store native read (FIX-hive-s3a: scheme normalization + canonical creds) ===== + + @Test + public void nativeScanRangePathNormalizedS3aToS3() { + // BE's native S3 reader rejects the s3a scheme (S3URI accepts only s3/http/https). The connector lists + // files with the raw scheme (Hadoop wants s3a) but must normalize the BE-facing range path to s3://. + // MUTATION: dropping the splitFile normalization leaves the range path s3a:// -> BE "Invalid S3 URI". + HiveFileListingCache.DirectoryLister s3aLister = (location, fs) -> + new ArrayList<>(Collections.singletonList( + new HiveFileStatus("s3a://bucket/db/t/p/000000_0", 10L, 1L))); + FakeConnectorContext normCtx = new FakeConnectorContext() { + @Override + public String normalizeStorageUri(String rawUri) { + return rawUri == null ? null : rawUri.replace("s3a://", "s3://"); + } + }; + HiveScanPlanProvider provider = new HiveScanPlanProvider(new FakeHmsClient(), + Collections.emptyMap(), normCtx, new HiveReadTransactionManager(), + new HiveFileListingCache(Collections.emptyMap(), s3aLister)); + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .inputFormat(PARQUET_INPUT_FORMAT) + .serializationLib(PARQUET_SERDE) + .partitionKeyNames(PART_KEYS) + .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")); + + Assertions.assertEquals(1, ranges.size()); + Assertions.assertEquals("s3://bucket/db/t/p/000000_0", + ((HiveScanRange) ranges.get(0)).getPath().orElse(null), + "native hive range path must be scheme-normalized s3a->s3 for BE's native reader"); + } + + @Test + public void scanNodePropertiesEmitsCanonicalCredsForNativeReader() { + // BE's native FILE_S3 reader reads ONLY AWS_ACCESS_KEY/AWS_SECRET_KEY/AWS_ENDPOINT (s3_util.cpp), never the + // raw s3.access_key alias. getScanNodeProperties must emit the BE-canonical creds + // (getBackendStorageProperties) like legacy HiveScanNode.getLocationProperties did; without them a private + // bucket 403s. MUTATION: dropping the canonical emission (pre-fix: only raw s3. aliases were emitted). + FakeConnectorContext credCtx = new FakeConnectorContext() { + @Override + public Map getBackendStorageProperties() { + return Collections.singletonMap("AWS_ACCESS_KEY", "canonAK"); + } + }; + HiveScanPlanProvider provider = new HiveScanPlanProvider(new FakeHmsClient(), + Collections.singletonMap("s3.access_key", "aliasAK"), credCtx, + new HiveReadTransactionManager(), + new HiveFileListingCache(Collections.emptyMap(), new CountingLister())); + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .inputFormat(PARQUET_INPUT_FORMAT) + .serializationLib(PARQUET_SERDE) + .build(); + + Map props = provider.getScanNodeProperties( + new FakeSession(), handle, Collections.emptyList(), Optional.empty()); + + Assertions.assertEquals("canonAK", props.get("location.AWS_ACCESS_KEY"), + "BE-canonical AWS_* creds must be emitted for the native reader (legacy parity)"); + // the raw s3. alias is still forwarded (harmless, ignored by BE), so no configured key is dropped + Assertions.assertEquals("aliasAK", props.get("location.s3.access_key")); + } + + // ============ #65437: scan-level transactional_hive marker (FileScannerV2 exclusion) ============ + // Sibling to supportsBatchScan's ACID exclusion above. BE picks FileScannerV2 from SCAN-LEVEL params before the + // per-range splits arrive, and V2 does not apply ACID delete deltas. A full-ACID hive scan must therefore carry + // the "transactional_hive" marker at scan level (not only per-range) so BE keeps it on FileScanner V1 — + // otherwise deleted rows reappear. + + @Test + public void getScanNodePropertiesEmitsTransactionalHiveForFullAcid() { + HiveScanPlanProvider provider = provider(new FakeHmsClient(), new CountingLister()); + Map txnParams = new HashMap<>(); + txnParams.put("transactional", "true"); // full-ACID: transactional AND not insert_only + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .inputFormat(PARQUET_INPUT_FORMAT) + .serializationLib(PARQUET_SERDE) + .tableParameters(txnParams) + .build(); + Map props = provider.getScanNodeProperties( + new FakeSession(), handle, Collections.emptyList(), Optional.empty()); + // WHY (Rule 9): drives the scan-level stamp so BE excludes the full-ACID read from FileScannerV2. + Assertions.assertEquals("true", props.get(HiveScanPlanProvider.PROP_TRANSACTIONAL_HIVE)); + } + + @Test + public void getScanNodePropertiesOmitsTransactionalHiveForInsertOnly() { + HiveScanPlanProvider provider = provider(new FakeHmsClient(), new CountingLister()); + Map params = new HashMap<>(); + params.put("transactional", "true"); + params.put("transactional_properties", "insert_only"); + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .inputFormat(PARQUET_INPUT_FORMAT) + .serializationLib(PARQUET_SERDE) + .tableParameters(params) + .build(); + Map props = provider.getScanNodeProperties( + new FakeSession(), handle, Collections.emptyList(), Optional.empty()); + // Insert-only ACID reads have no delete deltas, so FileScannerV2 is correct for them: the marker gates on + // isFullAcid() (NOT isTransactional()), so it must be ABSENT here to keep the V2 fast path. + Assertions.assertNull(props.get(HiveScanPlanProvider.PROP_TRANSACTIONAL_HIVE)); + } + + @Test + public void getScanNodePropertiesOmitsTransactionalHiveForNonTransactional() { + HiveScanPlanProvider provider = provider(new FakeHmsClient(), new CountingLister()); + HiveTableHandle handle = new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .inputFormat(PARQUET_INPUT_FORMAT) + .serializationLib(PARQUET_SERDE) + .build(); + Map props = provider.getScanNodeProperties( + new FakeSession(), handle, Collections.emptyList(), Optional.empty()); + Assertions.assertNull(props.get(HiveScanPlanProvider.PROP_TRANSACTIONAL_HIVE)); + } + + @Test + public void populateScanLevelParamsStampsTransactionalHiveOnlyWhenSignalled() { + HiveScanPlanProvider provider = provider(new FakeHmsClient(), new CountingLister()); + // With the signal -> scan-level table_format_type = "transactional_hive" (the exact string BE matches on, + // identical to the per-range HiveScanRange marker). + TFileScanRangeParams marked = new TFileScanRangeParams(); + provider.populateScanLevelParams(marked, + Collections.singletonMap(HiveScanPlanProvider.PROP_TRANSACTIONAL_HIVE, "true")); + Assertions.assertTrue(marked.isSetTableFormatParams()); + Assertions.assertEquals("transactional_hive", marked.getTableFormatParams().getTableFormatType()); + // Without the signal -> untouched, so non-ACID hive keeps the FileScannerV2 fast path. + TFileScanRangeParams unmarked = new TFileScanRangeParams(); + provider.populateScanLevelParams(unmarked, Collections.emptyMap()); + Assertions.assertFalse(unmarked.isSetTableFormatParams()); + } + + // ===== helpers ===== + + @Test + public void testAdjustFileCompressTypeRemapsOnlyLz4Frame() { + // hadoop/hive write .lz4 as the LZ4 BLOCK codec, but the generic node infers LZ4FRAME from the .lz4 + // extension; BE's text reader then fails on block bytes decoded as frame. Restore legacy + // HiveScanNode.getFileCompressType parity: remap ONLY LZ4FRAME -> LZ4BLOCK, pass every other codec + // through unchanged. MUTATION: broadening or dropping the remap would either mis-decode other codecs + // or reintroduce the LZ4F_getFrameInfo failure on .lz4 text tables. + HiveScanPlanProvider provider = provider(null, new CountingLister()); + Assertions.assertEquals(TFileCompressType.LZ4BLOCK, + provider.adjustFileCompressType(TFileCompressType.LZ4FRAME)); + Assertions.assertEquals(TFileCompressType.LZ4BLOCK, + provider.adjustFileCompressType(TFileCompressType.LZ4BLOCK)); + Assertions.assertEquals(TFileCompressType.GZ, + provider.adjustFileCompressType(TFileCompressType.GZ)); + Assertions.assertEquals(TFileCompressType.ZSTD, + provider.adjustFileCompressType(TFileCompressType.ZSTD)); + Assertions.assertEquals(TFileCompressType.SNAPPYBLOCK, + provider.adjustFileCompressType(TFileCompressType.SNAPPYBLOCK)); + Assertions.assertEquals(TFileCompressType.PLAIN, + provider.adjustFileCompressType(TFileCompressType.PLAIN)); + } + + private static HiveScanPlanProvider provider(HmsClient hmsClient, CountingLister lister) { + return new HiveScanPlanProvider(hmsClient, Collections.emptyMap(), new FakeConnectorContext(), + new HiveReadTransactionManager(), new HiveFileListingCache(Collections.emptyMap(), lister)); + } + + private static HmsPartitionInfo part(String name) { + return new HmsPartitionInfo(Collections.emptyList(), name, null, null, null, Collections.emptyMap()); + } + + /** + * A {@link HiveFileListingCache.DirectoryLister} double: counts calls (total + per location) and returns a + * fresh single-file listing per call, so each partition location contributes exactly one range. + */ + private static final class CountingLister implements HiveFileListingCache.DirectoryLister { + final Map callsPerLocation = new HashMap<>(); + int totalCalls; + + @Override + public List list(String location, FileSystem fs) { + totalCalls++; + callsPerLocation.merge(location, 1, Integer::sum); + return new ArrayList<>(Collections.singletonList(new HiveFileStatus(location + "/000000_0", 10L, 1L))); + } + } + + /** + * Minimal {@link HmsClient} double whose {@code getPartitions} echoes each requested name back as an + * {@link HmsPartitionInfo} whose location IS the name, so the batch-scoped resolution can be asserted through + * the listed locations. The rest fail loud. + */ + private static final class FakeHmsClient implements HmsClient { + @Override + public List getPartitions(String dbName, String tableName, List partNames) { + List result = new ArrayList<>(); + for (String name : partNames) { + result.add(new HmsPartitionInfo(Collections.emptyList(), name, + null, null, null, Collections.emptyMap())); + } + return result; + } + + @Override + public List listPartitionNames(String dbName, String tableName, int maxParts) { + throw new UnsupportedOperationException(); + } + + @Override + public List listDatabases() { + throw new UnsupportedOperationException(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public List listTables(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public HmsPartitionInfo getPartition(String dbName, String tableName, List values) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() { + } + } + + /** Minimal {@link ConnectorSession} (no split-size override, empty session properties). */ + private static final class FakeSession implements ConnectorSession { + @Override + public String getQueryId() { + return "q"; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public long getCatalogId() { + return 0L; + } + + @Override + public String getCatalogName() { + return "hive_catalog"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanPlanProviderLzoFilterTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanPlanProviderLzoFilterTest.java new file mode 100644 index 00000000000000..f6bc88ff421a51 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanPlanProviderLzoFilterTest.java @@ -0,0 +1,87 @@ +// 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.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Tests the LZO file-filtering helpers on {@link HiveScanPlanProvider}. These reproduce legacy + * {@code HiveExternalMetaCache}'s {@code HiveUtil.isLzoInputFormat}/{@code isLzoDataFile} filtering, which was + * lost at the SPI cutover: {@link HiveFileListingCache} only strips {@code _}/{@code .}-prefixed hidden files, + * so for an LZO text table the {@code *.lzo.index} sidecar (which starts with neither) would otherwise be read + * as an extra text row — the exact failure {@code test_hive_lzo_text_format} caught (count 6 instead of 5). + */ +public class HiveScanPlanProviderLzoFilterTest { + + @Test + public void testIsLzoInputFormatRecognizesAllVariants() { + // The three hadoop-lzo text InputFormat variants must all be recognized. + Assertions.assertTrue( + HiveScanPlanProvider.isLzoInputFormat("com.hadoop.compression.lzo.LzoTextInputFormat")); + Assertions.assertTrue( + HiveScanPlanProvider.isLzoInputFormat("com.hadoop.mapreduce.LzoTextInputFormat")); + Assertions.assertTrue( + HiveScanPlanProvider.isLzoInputFormat("com.hadoop.mapred.DeprecatedLzoTextInputFormat")); + } + + @Test + public void testIsLzoInputFormatRejectsNonLzo() { + // Plain text / parquet / orc tables are not LZO and must NOT trigger the sidecar filter. + Assertions.assertFalse( + HiveScanPlanProvider.isLzoInputFormat("org.apache.hadoop.mapred.TextInputFormat")); + Assertions.assertFalse(HiveScanPlanProvider.isLzoInputFormat( + "org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat")); + Assertions.assertFalse(HiveScanPlanProvider.isLzoInputFormat(null)); + } + + @Test + public void testIsLzoDataFileExcludesIndexSidecar() { + // Only *.lzo is data; the *.lzo.index sidecar (and any other extension) must be excluded. + Assertions.assertTrue(HiveScanPlanProvider.isLzoDataFile( + "hdfs://ns/user/doris/preinstalled_data/text_lzo/part-m-00000.lzo")); + Assertions.assertFalse(HiveScanPlanProvider.isLzoDataFile( + "hdfs://ns/user/doris/preinstalled_data/text_lzo/part-m-00000.lzo.index")); + // Case-insensitive extension match. + Assertions.assertTrue(HiveScanPlanProvider.isLzoDataFile("hdfs://ns/dir/part-m-00000.LZO")); + Assertions.assertFalse(HiveScanPlanProvider.isLzoDataFile("hdfs://ns/dir/part-m-00000.txt")); + } + + @Test + public void testIsLzoDataFileStripsObjectStoreQueryString() { + // An object-store URI may carry a signed query string; the extension match must ignore it. + Assertions.assertTrue(HiveScanPlanProvider.isLzoDataFile("s3://bucket/dir/part.lzo?sig=abc&exp=123")); + Assertions.assertFalse( + HiveScanPlanProvider.isLzoDataFile("s3://bucket/dir/part.lzo.index?sig=abc&exp=123")); + } + + @Test + public void testLzoTextDetectsAsSplittableTextSoMustBeMasked() { + // A LZO text table's InputFormat resolves to the TEXT format, whose isSplittable() is true — the enum + // alone would (wrongly) split a .lzo stream at byte boundaries, which cannot be decompressed from an + // arbitrary offset. This is exactly why planScan masks splittable with !isLzoInputFormat(...): without + // the mask, a large LZO file would be split and produce garbage. + HiveFileFormat fmt = HiveFileFormat.detect( + "com.hadoop.mapreduce.LzoTextInputFormat", + "org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe", + false, false); + Assertions.assertEquals(HiveFileFormat.TEXT, fmt); + Assertions.assertTrue(fmt.isSplittable()); + Assertions.assertTrue(HiveScanPlanProvider.isLzoInputFormat("com.hadoop.mapreduce.LzoTextInputFormat")); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanRangeAcidTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanRangeAcidTest.java new file mode 100644 index 00000000000000..5ba592feb1967e --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanRangeAcidTest.java @@ -0,0 +1,106 @@ +// 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.thrift.TFileRangeDesc; +import org.apache.doris.thrift.TTableFormatFileDesc; +import org.apache.doris.thrift.TTransactionalHiveDeleteDeltaDesc; +import org.apache.doris.thrift.TTransactionalHiveDesc; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +/** + * Tests that {@link HiveScanRange} carries ACID delete-delta info to the BE. + * + *

WHY: for transactional Hive tables the BE applies row-level deletes by reading the + * delete-delta files listed per partition. The delta descriptor needs BOTH the directory + * AND the file names within it. If the file names are dropped, the BE cannot locate the + * delete records and silently under-deletes — returning rows that were logically deleted. + * These tests pin the "dir|file1,file2" encode/decode round-trip end to end.

+ */ +public class HiveScanRangeAcidTest { + + @Test + public void testDeleteDeltaCarriesDirectoryAndFileNames() { + HiveScanRange range = HiveScanRange.builder() + .path("/tbl/delta_0000005_0000005/bucket_00000") + .acidInfo("/tbl/p=1", Arrays.asList( + "/tbl/p=1/delete_delta_0000003_0000003|bucket_00000,bucket_00001", + "/tbl/p=1/delete_delta_0000004_0000004|bucket_00000")) + .build(); + + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + range.populateRangeParams(formatDesc, new TFileRangeDesc()); + + Assertions.assertTrue(formatDesc.isSetTransactionalHiveParams(), + "transactional_hive range must emit transactional params"); + TTransactionalHiveDesc txnDesc = formatDesc.getTransactionalHiveParams(); + Assertions.assertEquals("/tbl/p=1", txnDesc.getPartition()); + + List deltas = txnDesc.getDeleteDeltas(); + Assertions.assertEquals(2, deltas.size()); + + TTransactionalHiveDeleteDeltaDesc first = deltas.get(0); + Assertions.assertEquals("/tbl/p=1/delete_delta_0000003_0000003", + first.getDirectoryLocation()); + // The regression: file names must survive the "dir|file1,file2" round-trip. + Assertions.assertEquals(Arrays.asList("bucket_00000", "bucket_00001"), + first.getFileNames()); + + TTransactionalHiveDeleteDeltaDesc second = deltas.get(1); + Assertions.assertEquals("/tbl/p=1/delete_delta_0000004_0000004", + second.getDirectoryLocation()); + Assertions.assertEquals(Arrays.asList("bucket_00000"), second.getFileNames()); + } + + @Test + public void testDeleteDeltaWithoutFileNamesLeavesFileNamesUnset() { + // A directory-only encoding (no '|') must still set the directory and simply + // carry no file names, rather than mis-parsing the whole string as a directory + // with a bogus trailing file name. + HiveScanRange range = HiveScanRange.builder() + .path("/tbl/base_0000005/bucket_00000") + .acidInfo("/tbl/p=1", Arrays.asList("/tbl/p=1/delete_delta_dir_only")) + .build(); + + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + range.populateRangeParams(formatDesc, new TFileRangeDesc()); + + TTransactionalHiveDeleteDeltaDesc delta = + formatDesc.getTransactionalHiveParams().getDeleteDeltas().get(0); + Assertions.assertEquals("/tbl/p=1/delete_delta_dir_only", delta.getDirectoryLocation()); + Assertions.assertFalse(delta.isSetFileNames()); + } + + @Test + public void testNonTransactionalRangeEmitsNoTransactionalParams() { + HiveScanRange range = HiveScanRange.builder() + .path("/tbl/000000_0") + .fileFormat("parquet") + .build(); + + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + range.populateRangeParams(formatDesc, new TFileRangeDesc()); + + Assertions.assertFalse(formatDesc.isSetTransactionalHiveParams()); + } +} 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 new file mode 100644 index 00000000000000..c40abc7c41d704 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanRangePartitionValuesTest.java @@ -0,0 +1,146 @@ +// 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.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; + +/** + * Tests that {@link HiveScanRange#populateRangeParams} emits {@code columns_from_path} from the + * connector's partition values. + * + *

WHY: hive was the last connector still relying on fe-core's + * {@code FilePartitionUtils.normalizeColumnsFromPath} to translate the Hive default-partition + * directory sentinel ({@code __HIVE_DEFAULT_PARTITION__}) into a SQL NULL before sending it to BE. + * That is a Hive-specific convention and must live in the Hive connector (iron rule: fe-core keeps no + * 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 + * 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.

+ */ +public class HiveScanRangePartitionValuesTest { + + private static Map orderedPartitionValues(String... keyThenValue) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < keyThenValue.length; i += 2) { + map.put(keyThenValue[i], keyThenValue[i + 1]); + } + return map; + } + + private static TFileRangeDesc populate(HiveScanRange range) { + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(new TTableFormatFileDesc(), rangeDesc); + return rangeDesc; + } + + @Test + public void testRealPartitionValuesEmittedNotNull() { + HiveScanRange range = HiveScanRange.builder() + .path("/tbl/dt=2024-01-01/city=beijing/000000_0") + .partitionValues(orderedPartitionValues("dt", "2024-01-01", "city", "beijing")) + .build(); + + TFileRangeDesc rangeDesc = populate(range); + + Assertions.assertEquals(Arrays.asList("dt", "city"), rangeDesc.getColumnsFromPathKeys()); + Assertions.assertEquals(Arrays.asList("2024-01-01", "beijing"), rangeDesc.getColumnsFromPath()); + Assertions.assertEquals(Arrays.asList(false, false), rangeDesc.getColumnsFromPathIsNull()); + } + + @Test + public void testDefaultPartitionSentinelMapsToSqlNull() { + // The HMS default-partition directory sentinel is a genuine SQL NULL: value -> "" + is_null=true. + HiveScanRange range = HiveScanRange.builder() + .path("/tbl/dt=2024-01-01/city=__HIVE_DEFAULT_PARTITION__/000000_0") + .partitionValues(orderedPartitionValues( + "dt", "2024-01-01", "city", "__HIVE_DEFAULT_PARTITION__")) + .build(); + + TFileRangeDesc rangeDesc = populate(range); + + Assertions.assertEquals(Arrays.asList("dt", "city"), rangeDesc.getColumnsFromPathKeys()); + Assertions.assertEquals(Arrays.asList("2024-01-01", ""), rangeDesc.getColumnsFromPath()); + Assertions.assertEquals(Arrays.asList(false, true), rangeDesc.getColumnsFromPathIsNull()); + } + + @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). + HiveScanRange range = HiveScanRange.builder() + .path("/tbl/city=%5CN/000000_0") + .partitionValues(orderedPartitionValues("city", "\\N")) + .build(); + + TFileRangeDesc rangeDesc = populate(range); + + Assertions.assertEquals(Collections.singletonList("city"), rangeDesc.getColumnsFromPathKeys()); + Assertions.assertEquals(Collections.singletonList("\\N"), rangeDesc.getColumnsFromPath()); + Assertions.assertEquals(Collections.singletonList(false), rangeDesc.getColumnsFromPathIsNull()); + } + + @Test + public void testUnpartitionedEmitsNoColumnsFromPath() { + HiveScanRange range = HiveScanRange.builder() + .path("/tbl/000000_0") + .fileFormat("parquet") + .build(); + + TFileRangeDesc rangeDesc = populate(range); + + Assertions.assertFalse(rangeDesc.isSetColumnsFromPath()); + Assertions.assertFalse(rangeDesc.isSetColumnsFromPathKeys()); + Assertions.assertFalse(rangeDesc.isSetColumnsFromPathIsNull()); + } + + @Test + public void testAcidTableEmitsBothTransactionalParamsAndPartitionValues() { + // ACID (transactional_hive) ranges must still carry columns-from-path in addition to the + // transactional delete-delta params; the partition-value rebuild runs regardless of ACID. + HiveScanRange range = HiveScanRange.builder() + .path("/tbl/dt=2024-01-01/delta_0000005_0000005/bucket_00000") + .partitionValues(orderedPartitionValues("dt", "2024-01-01")) + .acidInfo("/tbl/dt=2024-01-01", Arrays.asList( + "/tbl/dt=2024-01-01/delete_delta_0000003_0000003|bucket_00000")) + .build(); + + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(formatDesc, rangeDesc); + + Assertions.assertTrue(formatDesc.isSetTransactionalHiveParams(), + "ACID range must still emit transactional params"); + Assertions.assertEquals(Collections.singletonList("dt"), rangeDesc.getColumnsFromPathKeys()); + Assertions.assertEquals(Collections.singletonList("2024-01-01"), rangeDesc.getColumnsFromPath()); + Assertions.assertEquals(Collections.singletonList(false), rangeDesc.getColumnsFromPathIsNull()); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveTableFormatDetectionTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveTableFormatDetectionTest.java new file mode 100644 index 00000000000000..9e1c9c8b639c33 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveTableFormatDetectionTest.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.connector.hive; + +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsDatabaseInfo; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsTableInfo; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Tests {@link HiveTableFormatDetector#detect} format classification and the fail-loud + view short-circuit + * that {@link HiveConnectorMetadata#getTableHandle} layers on top (HMS cutover §4.2, dormant). + * + *

WHY:

+ *
    + *
  • LZO parity. Legacy {@code HMSExternalTable.SUPPORTED_HIVE_FILE_FORMATS} includes three + * LZO-text InputFormats; omitting them would make an LZO-text table fail the (now fail-loud) format + * check even though legacy read it as hive text.
  • + *
  • Fail-loud parity. Legacy {@code supportedHiveTable()} threw on a null/unrecognized input + * format; the old connector silently returned UNKNOWN, which would let an unreadable table degrade + * instead of surfacing the error.
  • + *
  • View short-circuit. A view has no data files (null input format) but is valid; legacy + * returned true for it before the format check. The fail-loud guard must skip views or every hive + * view would be rejected at handle resolution.
  • + *
+ */ +public class HiveTableFormatDetectionTest { + + private static final String PARQUET = "org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat"; + private static final String ORC = "org.apache.hadoop.hive.ql.io.orc.OrcInputFormat"; + private static final String TEXT = "org.apache.hadoop.mapred.TextInputFormat"; + private static final String HUDI = "org.apache.hudi.hadoop.HoodieParquetInputFormat"; + + // ===== detect(): pure format classification ===== + + @Test + public void lzoTextFormatsClassifyAsHive() { + // All three LZO-text InputFormats legacy supported (com.hadoop.compression.lzo / mapreduce / mapred). + Assertions.assertEquals(HiveTableType.HIVE, + HiveTableFormatDetector.detect(hiveTable("com.hadoop.compression.lzo.LzoTextInputFormat"))); + Assertions.assertEquals(HiveTableType.HIVE, + HiveTableFormatDetector.detect(hiveTable("com.hadoop.mapreduce.LzoTextInputFormat"))); + Assertions.assertEquals(HiveTableType.HIVE, + HiveTableFormatDetector.detect(hiveTable("com.hadoop.mapred.DeprecatedLzoTextInputFormat"))); + } + + @Test + public void standardFormatsClassifyCorrectly() { + Assertions.assertEquals(HiveTableType.HIVE, HiveTableFormatDetector.detect(hiveTable(PARQUET))); + Assertions.assertEquals(HiveTableType.HIVE, HiveTableFormatDetector.detect(hiveTable(ORC))); + Assertions.assertEquals(HiveTableType.HIVE, HiveTableFormatDetector.detect(hiveTable(TEXT))); + Assertions.assertEquals(HiveTableType.HUDI, HiveTableFormatDetector.detect(hiveTable(HUDI))); + Assertions.assertEquals(HiveTableType.ICEBERG, HiveTableFormatDetector.detect(icebergTable())); + } + + @Test + public void unrecognizedOrNullFormatIsUnknown() { + Assertions.assertEquals(HiveTableType.UNKNOWN, + HiveTableFormatDetector.detect(hiveTable("com.example.MyCustomInputFormat"))); + Assertions.assertEquals(HiveTableType.UNKNOWN, HiveTableFormatDetector.detect(hiveTable(null))); + // detect() is format-only: a view (no data-file format) also classifies UNKNOWN here; the + // short-circuit that keeps it from being rejected lives in getTableHandle. + Assertions.assertEquals(HiveTableType.UNKNOWN, HiveTableFormatDetector.detect(viewTable())); + } + + // ===== getTableHandle(): fail-loud + view short-circuit ===== + + @Test + public void unsupportedFormatFailsLoud() { + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> handleFor(hiveTable("com.example.MyCustomInputFormat"))); + Assertions.assertTrue(ex.getMessage().contains("Unsupported hive input format"), ex.getMessage()); + } + + @Test + public void nullFormatNonViewFailsLoud() { + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> handleFor(hiveTable(null))); + Assertions.assertTrue(ex.getMessage().contains("input format is null"), ex.getMessage()); + } + + @Test + public void viewIsNotRejected() { + // A view has a null input format but must NOT fail loud; its handle keeps the UNKNOWN type (a view is + // served by the view SPI, never scanned). + Optional handle = handleFor(viewTable()); + Assertions.assertTrue(handle.isPresent(), "a hive view must resolve a handle, not be rejected"); + Assertions.assertEquals(HiveTableType.UNKNOWN, ((HiveTableHandle) handle.get()).getTableType()); + } + + @Test + public void supportedFormatsResolveHandle() { + Assertions.assertEquals(HiveTableType.HIVE, + ((HiveTableHandle) handleFor(hiveTable(PARQUET)).get()).getTableType()); + // An LZO-text table now resolves instead of failing loud. + Assertions.assertEquals(HiveTableType.HIVE, + ((HiveTableHandle) handleFor(hiveTable("com.hadoop.compression.lzo.LzoTextInputFormat")) + .get()).getTableType()); + } + + // ===== helpers ===== + + private Optional handleFor(HmsTableInfo tableInfo) { + HiveConnectorMetadata metadata = new HiveConnectorMetadata( + new FakeHmsClient(tableInfo), Collections.emptyMap(), new FakeConnectorContext()); + return metadata.getTableHandle(null, "db", "t"); + } + + private static HmsTableInfo hiveTable(String inputFormat) { + return HmsTableInfo.builder() + .dbName("db").tableName("t").tableType("MANAGED_TABLE") + .inputFormat(inputFormat) + .build(); + } + + private static HmsTableInfo icebergTable() { + return HmsTableInfo.builder() + .dbName("db").tableName("t").tableType("EXTERNAL_TABLE") + .parameters(Collections.singletonMap("table_type", "ICEBERG")) + .build(); + } + + private static HmsTableInfo viewTable() { + return HmsTableInfo.builder() + .dbName("db").tableName("t").tableType("VIRTUAL_VIEW") + .viewOriginalText("SELECT 1") + .build(); + } + + /** Minimal {@link HmsClient} double serving one prebuilt table; the rest fail loud. */ + private static final 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() { + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveTableHandleAcidTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveTableHandleAcidTest.java new file mode 100644 index 00000000000000..12adca92e4b655 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveTableHandleAcidTest.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.hive; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Tests the ACID classification derived from a {@link HiveTableHandle}'s metastore parameters. + * + *

WHY: the scan path branches on these flags. {@code isTransactional} decides whether the ACID + * descent runs at all; {@code isFullAcid} (transactional AND not insert-only) decides whether delete + * deltas and the bucket_ file filter apply. Getting insert-only vs full-ACID wrong either skips real + * row deletes or mis-filters data files.

+ */ +public class HiveTableHandleAcidTest { + + private HiveTableHandle handleWithParams(Map params) { + return new HiveTableHandle.Builder("db", "t", HiveTableType.HIVE) + .inputFormat("org.apache.hadoop.hive.ql.io.orc.OrcInputFormat") + .tableParameters(params) + .build(); + } + + @Test + public void testNonTransactionalByDefault() { + HiveTableHandle handle = handleWithParams(new HashMap<>()); + Assertions.assertFalse(handle.isTransactional()); + Assertions.assertFalse(handle.isFullAcid()); + } + + @Test + public void testFullAcidWhenTransactionalAndNotInsertOnly() { + Map params = new HashMap<>(); + params.put("transactional", "true"); + HiveTableHandle handle = handleWithParams(params); + Assertions.assertTrue(handle.isTransactional()); + Assertions.assertTrue(handle.isFullAcid()); + } + + @Test + public void testInsertOnlyIsTransactionalButNotFullAcid() { + Map params = new HashMap<>(); + params.put("transactional", "true"); + params.put("transactional_properties", "insert_only"); + HiveTableHandle handle = handleWithParams(params); + Assertions.assertTrue(handle.isTransactional()); + Assertions.assertFalse(handle.isFullAcid(), + "insert_only ACID tables have no row-level deletes"); + } + + @Test + public void testInsertOnlyDetectionIsCaseInsensitive() { + Map params = new HashMap<>(); + params.put("transactional", "true"); + params.put("transactional_properties", "INSERT_ONLY"); + Assertions.assertFalse(handleWithParams(params).isFullAcid(), + "insert_only detection must be case-insensitive"); + } + + @Test + public void testTransactionalTrueIsCaseInsensitive() { + Map params = new HashMap<>(); + params.put("transactional", "TRUE"); + Assertions.assertTrue(handleWithParams(params).isTransactional()); + + Map upperKey = new HashMap<>(); + upperKey.put("TRANSACTIONAL", "true"); + Assertions.assertTrue(handleWithParams(upperKey).isTransactional(), + "the upper-cased parameter key is accepted as a fallback"); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveTextPropertiesTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveTextPropertiesTest.java new file mode 100644 index 00000000000000..8723ad4d0a4b64 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveTextPropertiesTest.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.hive; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Tests {@link HiveTextProperties} delimiter extraction, pinned to legacy fe-core parity + * ({@code HiveProperties} + {@code HiveMetaStoreClientHelper.getByte}). + * + *

WHY these assertions matter: Hive stores text delimiters as NUMERIC BYTE-VALUE STRINGS + * in SerDe params. The canonical case is a default {@code LazySimpleSerDe} table, whose field + * delimiter is stored as {@code serialization.format=1} — meaning byte {@code 0x01} (Ctrl-A), + * NOT the character {@code '1'} (0x31). Legacy applies {@code getByte()} to decode the numeric + * string into the real delimiter byte; the connector must reproduce this exactly, or BE splits + * text rows on the wrong character and every column collapses to null/merged.

+ */ +public class HiveTextPropertiesTest { + + private static final String TEXT_SERDE = "org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe"; + private static final String MULTI_DELIMIT_SERDE = "org.apache.hadoop.hive.serde2.MultiDelimitSerDe"; + private static final String OPENX_JSON_SERDE = "org.openx.data.jsonserde.JsonSerDe"; + private static final String HCATALOG_JSON_SERDE = "org.apache.hive.hcatalog.data.JsonSerDe"; + private static final String OPEN_CSV_SERDE = "org.apache.hadoop.hive.serde2.OpenCSVSerde"; + private static final String PREFIX = "hive.text."; + + private static String colSep(String serde, Map sd) { + return HiveTextProperties.extract(serde, sd, new HashMap<>()).get(PREFIX + "column_separator"); + } + + private static Map sd(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + @Test + public void testDefaultSerializationFormatDecodesToCtrlA() { + // Default LazySimpleSerDe: Hive stores serialization.format="1" == byte 0x01, no field.delim. + Assertions.assertEquals("\001", colSep(TEXT_SERDE, sd("serialization.format", "1"))); + } + + @Test + public void testNoDelimiterFallsBackToDefaultCtrlA() { + Assertions.assertEquals("\001", colSep(TEXT_SERDE, sd())); + } + + @Test + public void testLiteralFieldDelimiterPreserved() { + // Explicit "FIELDS TERMINATED BY ','": Hive stores the literal comma; getByte keeps it. + Assertions.assertEquals(",", colSep(TEXT_SERDE, sd("field.delim", ","))); + } + + @Test + public void testNumericFieldDelimiterDecodesToByte() { + // field.delim="9" means byte 0x09 (tab), not the character '9'. + Assertions.assertEquals("\t", colSep(TEXT_SERDE, sd("field.delim", "9"))); + } + + @Test + public void testFieldDelimTakesPrecedenceOverSerializationFormat() { + Assertions.assertEquals(",", colSep(TEXT_SERDE, sd("field.delim", ",", "serialization.format", "1"))); + } + + @Test + public void testMultiDelimitSerDeKeepsRawMultiCharDelimiter() { + // MultiDelimitSerDe supports multi-character delimiters; they must NOT be byte-decoded/truncated. + Assertions.assertEquals("||", colSep(MULTI_DELIMIT_SERDE, sd("field.delim", "||"))); + } + + @Test + public void testCollectionDelimiterDefaultAndHive2Typo() { + Map r = HiveTextProperties.extract(TEXT_SERDE, sd(), new HashMap<>()); + Assertions.assertEquals("\002", r.get(PREFIX + "collection_delimiter")); + // Hive2 uses the famously misspelled key "colelction.delim"; "2" decodes to byte 0x02. + Map r2 = HiveTextProperties.extract(TEXT_SERDE, sd("colelction.delim", "2"), new HashMap<>()); + Assertions.assertEquals("\002", r2.get(PREFIX + "collection_delimiter")); + } + + @Test + public void testMapkvAndLineDefaults() { + Map r = HiveTextProperties.extract(TEXT_SERDE, sd(), new HashMap<>()); + Assertions.assertEquals("\003", r.get(PREFIX + "mapkv_delimiter")); + Assertions.assertEquals("\n", r.get(PREFIX + "line_delimiter")); + } + + @Test + public void testTableParamsTakePrecedenceOverSerdeParams() { + // Legacy getSerdeProperty checks table params first, then serde params. + Map sdParams = sd("serialization.format", "1"); + Map tblParams = new HashMap<>(); + tblParams.put("field.delim", ","); + String sep = HiveTextProperties.extract(TEXT_SERDE, sdParams, tblParams).get(PREFIX + "column_separator"); + Assertions.assertEquals(",", sep); + } + + // ---- OpenX JSON ignore.malformed.json (legacy HiveScanNode OPENX_JSON_SERDE branch parity) ---- + + @Test + public void testOpenxJsonIgnoreMalformedTrueEmitted() { + // OpenX table with SERDEPROPERTIES ignore.malformed.json=true -> BE must skip malformed rows. + Map r = HiveTextProperties.extract( + OPENX_JSON_SERDE, sd("ignore.malformed.json", "true"), new HashMap<>()); + Assertions.assertEquals("true", r.get(PREFIX + "openx_ignore_malformed")); + Assertions.assertEquals("true", r.get(PREFIX + "is_json")); + } + + @Test + public void testOpenxJsonIgnoreMalformedDefaultsFalse() { + // No property -> "false" (== Thrift default): malformed rows still error, matching legacy default. + Map r = HiveTextProperties.extract(OPENX_JSON_SERDE, sd(), new HashMap<>()); + Assertions.assertEquals("false", r.get(PREFIX + "openx_ignore_malformed")); + } + + @Test + public void testOpenxJsonIgnoreMalformedTableParamPrecedence() { + // Table param (true) beats serde param (false), mirroring legacy getSerdeProperty precedence. + Map tblParams = new HashMap<>(); + tblParams.put("ignore.malformed.json", "true"); + Map r = HiveTextProperties.extract( + OPENX_JSON_SERDE, sd("ignore.malformed.json", "false"), tblParams); + Assertions.assertEquals("true", r.get(PREFIX + "openx_ignore_malformed")); + } + + @Test + public void testHcatalogJsonSerdeOmitsOpenxKey() { + // Non-OpenX JSON serde never carried this flag: the key must be absent (legacy set it only for OpenX). + Map r = HiveTextProperties.extract(HCATALOG_JSON_SERDE, sd(), new HashMap<>()); + Assertions.assertFalse(r.containsKey(PREFIX + "openx_ignore_malformed")); + Assertions.assertEquals("true", r.get(PREFIX + "is_json")); + } + + // ---- #65501: trim_double_quotes must be emitted ONLY when the enclose char is the double-quote '"' ---- + + @Test + public void testCsvDefaultQuoteCharTrimsDoubleQuotes() { + // OpenCSVSerde with no quoteChar -> default '"' -> BE must trim the wrapping double quotes. + Map r = HiveTextProperties.extract(OPEN_CSV_SERDE, sd(), new HashMap<>()); + Assertions.assertEquals("\"", r.get(PREFIX + "enclose")); + Assertions.assertEquals("true", r.get(PREFIX + "trim_double_quotes")); + } + + @Test + public void testCsvNonDoubleQuoteEncloseDoesNotTrim() { + // A single-quote quoteChar must NOT set trim_double_quotes; otherwise BE would strip literal '"' + // characters from the data (the exact pre-#65501 bug the master fix corrected). + Map r = HiveTextProperties.extract(OPEN_CSV_SERDE, sd("quoteChar", "'"), new HashMap<>()); + Assertions.assertEquals("'", r.get(PREFIX + "enclose")); + Assertions.assertEquals("false", r.get(PREFIX + "trim_double_quotes")); + } + + @Test + public void testCsvExplicitDoubleQuoteEncloseTrims() { + // An explicit quoteChar="\"" is the double-quote case and must still trim. + Map r = HiveTextProperties.extract(OPEN_CSV_SERDE, sd("quoteChar", "\""), new HashMap<>()); + Assertions.assertEquals("true", r.get(PREFIX + "trim_double_quotes")); + } +} 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 new file mode 100644 index 00000000000000..fc9afd733d930f --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveWritePlanProviderTest.java @@ -0,0 +1,737 @@ +// 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.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.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.connector.api.handle.ConnectorWriteHandle; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.write.ConnectorSinkPlan; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsDatabaseInfo; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsTableInfo; +import org.apache.doris.connector.spi.ConnectorBrokerAddress; +import org.apache.doris.filesystem.FileSystemType; +import org.apache.doris.filesystem.properties.BackendStorageKind; +import org.apache.doris.filesystem.properties.BackendStorageProperties; +import org.apache.doris.filesystem.properties.StorageKind; +import org.apache.doris.filesystem.properties.StorageProperties; +import org.apache.doris.thrift.TDataSinkType; +import org.apache.doris.thrift.TFileCompressType; +import org.apache.doris.thrift.TFileFormatType; +import org.apache.doris.thrift.TFileType; +import org.apache.doris.thrift.THiveColumn; +import org.apache.doris.thrift.THiveColumnType; +import org.apache.doris.thrift.THiveLocationParams; +import org.apache.doris.thrift.THivePartition; +import org.apache.doris.thrift.THiveTableSink; +import org.apache.doris.thrift.TNetworkAddress; + +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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Pins {@link HiveWritePlanProvider#planWrite} for INSERT / INSERT OVERWRITE against legacy + * {@code planner.HiveTableSink.bindDataSink} expected values (recording {@link HmsClient} fake, no + * Mockito). + * + *

WHY this matters: the {@code THiveTableSink} assembly moved out of the fe-core planner into the + * connector. The sink Thrift goes to BE unchanged (zero BE change), so every field must be byte-identical to + * the legacy sink: the tagged column list, the existing-partition list, bucket info, file format/compression, + * the staging-vs-in-place location, the SerDe delimiters, and the overwrite flag. A parity-by-omission (a + * dropped/mis-tagged field) silently corrupts hive writes once hive cuts over. Each assertion pins the WHY the + * field is load-bearing for BE.

+ */ +public class HiveWritePlanProviderTest { + + private static final String DB = "db"; + private static final String TBL = "t"; + + private static final String TEXT_INPUT_FORMAT = "org.apache.hadoop.mapred.TextInputFormat"; + private static final String ORC_INPUT_FORMAT = "org.apache.hadoop.hive.ql.io.orc.OrcInputFormat"; + private static final String PARQUET_INPUT_FORMAT = + "org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat"; + private static final String LZO_INPUT_FORMAT = "com.hadoop.mapred.DeprecatedLzoTextInputFormat"; + private static final String TEXT_OUTPUT_FORMAT = + "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat"; + private static final String LAZY_SIMPLE_SERDE = "org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe"; + private static final String MULTI_DELIMIT_SERDE = "org.apache.hadoop.hive.serde2.MultiDelimitSerDe"; + + // ───────────────────────────── helpers ───────────────────────────── + + private static ConnectorColumn col(String name, String type) { + return new ConnectorColumn(name, ConnectorType.of(type), "", true, null); + } + + private static HmsTableInfo.Builder tableBuilder() { + return HmsTableInfo.builder() + .dbName(DB).tableName(TBL).tableType("MANAGED_TABLE") + .location("oss://bucket/db/t") + .inputFormat(TEXT_INPUT_FORMAT) + .outputFormat(TEXT_OUTPUT_FORMAT) + .serializationLib(LAZY_SIMPLE_SERDE) + .columns(Collections.singletonList(col("c1", "int"))) + .partitionKeys(Collections.emptyList()) + .parameters(Collections.emptyMap()); + } + + private HiveWritePlanProvider providerFor(RecordingHmsClient client, RecordingConnectorContext ctx) { + return new HiveWritePlanProvider(client, Collections.emptyMap(), ctx); + } + + private WriteSession sessionFor(RecordingHmsClient client, RecordingConnectorContext ctx, + Map sessionProperties) { + return new WriteSession(new HiveConnectorTransaction(42L, client, ctx), sessionProperties); + } + + private THiveTableSink planSink(RecordingHmsClient client, RecordingConnectorContext ctx, + WriteHandle handle) { + return planSink(client, ctx, handle, Collections.emptyMap()); + } + + private THiveTableSink planSink(RecordingHmsClient client, RecordingConnectorContext ctx, + WriteHandle handle, Map sessionProperties) { + ConnectorSinkPlan plan = providerFor(client, ctx) + .planWrite(sessionFor(client, ctx, sessionProperties), handle); + Assertions.assertEquals(TDataSinkType.HIVE_TABLE_SINK, plan.getDataSink().getType()); + return plan.getDataSink().getHiveTableSink(); + } + + private static WriteHandle handle() { + return new WriteHandle(new HiveTableHandle(DB, TBL, HiveTableType.HIVE)); + } + + // ───────────────────────────── columns ───────────────────────────── + + @Test + public void planWriteEmitsRegularColumnsThenPartitionKeys() { + // BE writes the data columns then maps the trailing partition keys; a wrong tag or order would write + // rows into the wrong file layout. Data cols are REGULAR (in schema order), partition keys are + // PARTITION_KEY, appended last (HMS/HMSExternalTable order). + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder() + .columns(Arrays.asList(col("c1", "int"), col("c2", "string"))) + .partitionKeys(Collections.singletonList(col("dt", "string"))) + .build(); + + List columns = planSink(client, new RecordingConnectorContext(), handle()).getColumns(); + + Assertions.assertEquals(3, columns.size()); + Assertions.assertEquals("c1", columns.get(0).getName()); + Assertions.assertEquals(THiveColumnType.REGULAR, columns.get(0).getColumnType()); + Assertions.assertEquals("c2", columns.get(1).getName()); + Assertions.assertEquals(THiveColumnType.REGULAR, columns.get(1).getColumnType()); + Assertions.assertEquals("dt", columns.get(2).getName()); + Assertions.assertEquals(THiveColumnType.PARTITION_KEY, columns.get(2).getColumnType(), + "partition keys must be tagged PARTITION_KEY and appended after the data columns"); + } + + // ───────────────────────────── bucket ───────────────────────────── + + @Test + public void planWriteSetsBucketInfo() { + // The BE hive writer buckets rows by these columns into this many files; a dropped bucket spec would + // write an un-bucketed layout that a bucketed reader then mis-reads. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder() + .bucketCols(Collections.singletonList("c1")) + .numBuckets(8) + .build(); + + THiveTableSink sink = planSink(client, new RecordingConnectorContext(), handle()); + + Assertions.assertEquals(Collections.singletonList("c1"), sink.getBucketInfo().getBucketedBy()); + Assertions.assertEquals(8, sink.getBucketInfo().getBucketCount()); + } + + // ───────────────────────────── file format ───────────────────────────── + + @Test + public void planWriteFileFormatPerInputFormat() { + // The input-format class name decides the BE writer dialect: orc -> ORC, parquet -> PARQUET, and + // (parity trap) text -> FORMAT_CSV_PLAIN. Writing the wrong container makes every row unreadable. + Assertions.assertEquals(TFileFormatType.FORMAT_ORC, fileFormatFor(ORC_INPUT_FORMAT)); + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, fileFormatFor(PARQUET_INPUT_FORMAT)); + Assertions.assertEquals(TFileFormatType.FORMAT_CSV_PLAIN, fileFormatFor(TEXT_INPUT_FORMAT)); + } + + private TFileFormatType fileFormatFor(String inputFormat) { + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder().inputFormat(inputFormat).build(); + return planSink(client, new RecordingConnectorContext(), handle()).getFileFormat(); + } + + // ───────────────────────────── compression ───────────────────────────── + + @Test + public void planWriteCompressionPerFormat() { + // The compression codec is read from the table parameters per format (orc.compress / + // parquet.compression / text.compression). BE writes the file with this codec; a wrong codec yields + // files a reader cannot decompress. + Assertions.assertEquals(TFileCompressType.ZLIB, + compressionFor(ORC_INPUT_FORMAT, Collections.singletonMap("orc.compress", "ZLIB"), + Collections.emptyMap())); + Assertions.assertEquals(TFileCompressType.SNAPPYBLOCK, + compressionFor(PARQUET_INPUT_FORMAT, Collections.singletonMap("parquet.compression", "snappy"), + Collections.emptyMap())); + Assertions.assertEquals(TFileCompressType.GZ, + compressionFor(TEXT_INPUT_FORMAT, Collections.singletonMap("text.compression", "gzip"), + Collections.emptyMap())); + } + + @Test + public void planWriteTextCompressionFallsBackToSessionDefault() { + // A text table with no text.compression property falls back to the session hive_text_compression + // default (legacy SessionVariable.hiveTextCompression); "uncompressed" is aliased to "plain". + Assertions.assertEquals(TFileCompressType.ZSTD, + compressionFor(TEXT_INPUT_FORMAT, Collections.emptyMap(), + Collections.singletonMap("hive_text_compression", "zstd"))); + Assertions.assertEquals(TFileCompressType.PLAIN, + compressionFor(TEXT_INPUT_FORMAT, Collections.emptyMap(), + Collections.singletonMap("hive_text_compression", "uncompressed"))); + } + + private TFileCompressType compressionFor(String inputFormat, Map tableParams, + Map sessionProperties) { + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder().inputFormat(inputFormat).parameters(tableParams).build(); + return planSink(client, new RecordingConnectorContext(), handle(), sessionProperties).getCompressionType(); + } + + // ───────────────────────────── location ───────────────────────────── + + @Test + public void planWriteLocationInPlaceForObjectStore() { + // An object-store write goes in place: the write and target paths are the normalized (s3://) URI and + // the original raw URI is preserved so BE can resolve credentials for the native scheme. No staging + // dir — BE writes straight to the table location. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder().location("oss://bucket/db/t").build(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.backendFileType = TFileType.FILE_S3; + + THiveLocationParams loc = planSink(client, ctx, handle()).getLocation(); + + Assertions.assertEquals(TFileType.FILE_S3, loc.getFileType()); + Assertions.assertEquals("s3://bucket/db/t", loc.getWritePath(), + "an object-store write path must be the normalized (s3://) URI BE opens"); + Assertions.assertEquals("s3://bucket/db/t", loc.getTargetPath()); + Assertions.assertEquals("oss://bucket/db/t", loc.getOriginalWritePath(), + "the original raw URI must be preserved so BE can resolve creds for the native scheme"); + } + + @Test + public void planWriteLocationStagingForHdfs() { + // A non-object-store write goes to a staging temp dir under the table location; BE writes there and the + // commit renames it to the target. write == original == staging; target == the raw table location. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder().location("hdfs://ns/wh/db/t").build(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.backendFileType = TFileType.FILE_HDFS; + + THiveLocationParams loc = planSink(client, ctx, handle()).getLocation(); + + Assertions.assertEquals(TFileType.FILE_HDFS, loc.getFileType()); + Assertions.assertEquals("hdfs://ns/wh/db/t", loc.getTargetPath(), + "the target path must remain the live table location the commit renames into"); + Assertions.assertEquals(loc.getWritePath(), loc.getOriginalWritePath(), + "a staged write has no scheme rewrite, so write and original paths are identical"); + Assertions.assertNotEquals("hdfs://ns/wh/db/t", loc.getWritePath(), + "the staged write path must differ from the live table location"); + Assertions.assertTrue(loc.getWritePath().contains(".doris_staging"), + "the staged write path must sit under the .doris_staging base dir; got: " + loc.getWritePath()); + } + + // ───────────────────────────── serde delimiters ───────────────────────────── + + @Test + public void planWriteSerdeDefaultDelimiters() { + // With no SerDe params BE must fall back to the hive text defaults; a wrong default silently mis-splits + // every text row. escape is unset (no escape.delim), matching legacy's Optional-only emission. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder().build(); + + THiveTableSink sink = planSink(client, new RecordingConnectorContext(), handle()); + + Assertions.assertEquals("\1", sink.getSerdeProperties().getFieldDelim()); + Assertions.assertEquals("\n", sink.getSerdeProperties().getLineDelim()); + Assertions.assertEquals("\2", sink.getSerdeProperties().getCollectionDelim()); + Assertions.assertEquals("\003", sink.getSerdeProperties().getMapkvDelim()); + Assertions.assertEquals("\\N", sink.getSerdeProperties().getNullFormat()); + Assertions.assertFalse(sink.getSerdeProperties().isSetEscapeChar(), + "with no escape.delim the escape char must stay unset (legacy sets it only when present)"); + } + + @Test + public void planWriteSerdeMultiDelimitKeepsMultiCharFieldDelim() { + // The MultiDelimitSerDe lib is the ONLY case where a multi-char field delimiter is passed through + // verbatim (not byte-decoded); collapsing "||" to one char would corrupt every row boundary. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder() + .serializationLib(MULTI_DELIMIT_SERDE) + .sdParameters(Collections.singletonMap("field.delim", "||")) + .build(); + + Assertions.assertEquals("||", + planSink(client, new RecordingConnectorContext(), handle()).getSerdeProperties().getFieldDelim()); + } + + @Test + public void planWriteSerdeNumericFieldDelimDecodesByte() { + // A numeric field.delim like "9" is a byte value, decoded to that char ((9 + 256) % 256 -> char 9 = + // TAB) — not the literal digit '9'. A missed decode would split on the wrong byte. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder() + .sdParameters(Collections.singletonMap("field.delim", "9")) + .build(); + + Assertions.assertEquals("\t", + planSink(client, new RecordingConnectorContext(), handle()).getSerdeProperties().getFieldDelim()); + } + + @Test + public void planWriteSerdeTableParamWinsOverSdParam() { + // getSerdeProperty is table-param-FIRST: a field.delim in the table parameters overrides the SD SerDe + // parameters. A reversed precedence would pick the wrong delimiter for a table that carries both. + RecordingHmsClient client = new RecordingHmsClient(); + Map tableParams = new HashMap<>(); + tableParams.put("field.delim", "a"); + client.table = tableBuilder() + .parameters(tableParams) + .sdParameters(Collections.singletonMap("field.delim", "b")) + .build(); + + // "a" is non-numeric, so getByte returns its first raw char "a" (proves the table value, not "b", won). + Assertions.assertEquals("a", + planSink(client, new RecordingConnectorContext(), handle()).getSerdeProperties().getFieldDelim()); + } + + // ───────────────────────────── overwrite + promotion ───────────────────────────── + + @Test + public void planWriteOverwriteFlag() { + // The overwrite flag drives BE's truncate-and-insert vs append; a dropped flag turns an INSERT + // OVERWRITE into a silent append (duplicated data). + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder().build(); + Assertions.assertTrue(planSink(client, new RecordingConnectorContext(), handle().overwrite(true)) + .isOverwrite()); + + RecordingHmsClient client2 = new RecordingHmsClient(); + client2.table = tableBuilder().build(); + Assertions.assertFalse(planSink(client2, new RecordingConnectorContext(), handle().overwrite(false)) + .isOverwrite()); + } + + @Test + public void buildWriteContextPromotesOverwritingInsertToOverwrite() { + // An overwriting INSERT is promoted to the OVERWRITE operation on the op-context (the transaction + // classifies APPEND vs OVERWRITE off this); a plain INSERT stays INSERT. + RecordingHmsClient client = new RecordingHmsClient(); + HmsTableInfo table = tableBuilder().build(); + client.table = table; + RecordingConnectorContext ctx = new RecordingConnectorContext(); + HiveWritePlanProvider provider = providerFor(client, ctx); + ConnectorSession session = new WriteSession(null, Collections.emptyMap()); + HiveTableHandle tableHandle = new HiveTableHandle(DB, TBL, HiveTableType.HIVE); + + HiveWriteContext promoted = provider.buildWriteContext(session, tableHandle, table, + new WriteHandle(tableHandle).overwrite(true)); + Assertions.assertEquals(WriteOperation.OVERWRITE, promoted.getWriteOperation(), + "an overwriting INSERT must be promoted to OVERWRITE"); + + HiveWriteContext plain = provider.buildWriteContext(session, tableHandle, table, + new WriteHandle(tableHandle).overwrite(false)); + Assertions.assertEquals(WriteOperation.INSERT, plain.getWriteOperation(), + "a non-overwriting INSERT must stay INSERT"); + } + + // ───────────────────────────── broker backend ───────────────────────────── + + @Test + public void planWriteBrokerBackendResolvesAddresses() { + // A broker-backed write must carry the resolved broker addresses, or BE gets a broker sink with an + // empty broker list and the write fails. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder().location("ofs://bucket/db/t").build(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.backendFileType = TFileType.FILE_BROKER; + ctx.brokerAddresses = Collections.singletonList(new ConnectorBrokerAddress("h1", 8000)); + + List brokers = planSink(client, ctx, handle()).getBrokerAddresses(); + + Assertions.assertEquals(1, brokers.size()); + Assertions.assertEquals("h1", brokers.get(0).getHostname()); + Assertions.assertEquals(8000, brokers.get(0).getPort()); + } + + @Test + public void planWriteBrokerBackendFailsLoudWhenNoBroker() { + // No alive broker for a broker-backed write must fail loud rather than ship BE an empty broker list. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder().location("ofs://bucket/db/t").build(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.backendFileType = TFileType.FILE_BROKER; + + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> planSink(client, ctx, handle())); + Assertions.assertTrue(ex.getMessage().contains("No alive broker"), + "the broker resolution must fail loud with 'No alive broker.', got: " + ex.getMessage()); + } + + // ───────────────────────────── LZO reject ───────────────────────────── + + @Test + public void planWriteRejectsLzoInputFormat() { + // A Doris-written LZO file has no .lzo suffix while the LZO read path filters to *.lzo only, so every + // written row would be permanently invisible. The write must be rejected loudly at plan time. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder().inputFormat(LZO_INPUT_FORMAT).build(); + + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> planSink(client, new RecordingConnectorContext(), handle())); + Assertions.assertTrue(ex.getMessage().contains("LZO"), + "the LZO reject must name LZO, got: " + ex.getMessage()); + } + + // ───────────────────────────── existing partitions ───────────────────────────── + + @Test + public void planWritePartitionedTableEmitsExistingPartitions() { + // For a partitioned table BE needs the existing partition dirs (to distinguish APPEND from NEW); the + // list is fetched live. Each THivePartition carries its values, its own file format, and its in-place + // location (write == target). + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder() + .partitionKeys(Collections.singletonList(col("dt", "string"))) + .build(); + client.partitionNames = Collections.singletonList("dt=2024-01-01"); + client.partitions = Collections.singletonList(new HmsPartitionInfo( + Collections.singletonList("2024-01-01"), "oss://bucket/db/t/dt=2024-01-01", + PARQUET_INPUT_FORMAT, TEXT_OUTPUT_FORMAT, LAZY_SIMPLE_SERDE, Collections.emptyMap())); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.backendFileType = TFileType.FILE_S3; + + List partitions = planSink(client, ctx, handle()).getPartitions(); + + Assertions.assertEquals(1, partitions.size()); + THivePartition part = partitions.get(0); + Assertions.assertEquals(Collections.singletonList("2024-01-01"), part.getValues()); + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, part.getFileFormat(), + "each partition's file format is resolved from its own input format"); + Assertions.assertEquals("oss://bucket/db/t/dt=2024-01-01", part.getLocation().getWritePath()); + Assertions.assertEquals("oss://bucket/db/t/dt=2024-01-01", part.getLocation().getTargetPath(), + "an existing partition is read in place, so its write and target paths are the same"); + Assertions.assertEquals(TFileType.FILE_S3, part.getLocation().getFileType()); + Assertions.assertTrue(client.calls.contains("listPartitionNames"), + "the existing-partition list must be fetched live; calls=" + client.calls); + Assertions.assertTrue(client.calls.stream().anyMatch(c -> c.startsWith("getPartitions")), + "the existing-partition metadata must be fetched live; calls=" + client.calls); + } + + @Test + public void planWriteUnpartitionedTableEmitsNoPartitions() { + // An unpartitioned table must emit an empty partition list (and never touch the partition-listing + // client calls) — a spurious partition would confuse the BE commit classification. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder().build(); + + THiveTableSink sink = planSink(client, new RecordingConnectorContext(), handle()); + + Assertions.assertTrue(sink.getPartitions().isEmpty()); + Assertions.assertFalse(client.calls.contains("listPartitionNames"), + "an unpartitioned table must not list partitions; calls=" + client.calls); + } + + // ───────────────────────────── hadoop config ───────────────────────────── + + @Test + public void planWriteHadoopConfigFromStorageProperties() { + // BE's S3 sink reads ONLY the AWS_* canonical creds; they are sourced from the typed fe-filesystem + // StorageProperties (the same source the scan path uses). A dropped cred yields a 403 on a private + // bucket. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder().build(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.storageProperties = Collections.singletonList( + fakeBackendStorage(Collections.singletonMap("AWS_ACCESS_KEY", "AK123"))); + + Assertions.assertEquals("AK123", + planSink(client, ctx, handle()).getHadoopConfig().get("AWS_ACCESS_KEY")); + } + + @Test + public void planWriteHadoopConfigIncludesBackendStoragePropertiesForUntypedFsSchemes() { + // C1 regression (External Regression build 1000131, test_jfs_hms_catalog_read): the jfs/oss-hdfs + // fe-filesystem plugins have no typed bind(), so getStorageProperties() is EMPTY for a jfs catalog and + // fs.jfs.impl (+ juicefs.*) would be dropped from the BE writer hadoopConfig -> libhdfs fails + // "No FileSystem for scheme jfs" on INSERT. buildHadoopConfig must ALSO merge + // getBackendStorageProperties() — the SAME source the hive READ path (HiveScanPlanProvider) uses, which + // carries the fs./juicefs.* passthrough. MUTATION: dropping the getBackendStorageProperties() merge from + // buildHadoopConfig -> fs.jfs.impl absent -> red. + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder().build(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + // Typed storageProperties intentionally EMPTY (models a jfs catalog whose plugin has no typed binding); + // the fs..impl passthrough is available only via the backend-storage source. + ctx.backendStorageProperties = Collections.singletonMap("fs.jfs.impl", "io.juicefs.JuiceFileSystem"); + + Assertions.assertEquals("io.juicefs.JuiceFileSystem", + planSink(client, ctx, handle()).getHadoopConfig().get("fs.jfs.impl"), + "the backend-storage passthrough (fs..impl) must reach the BE writer hadoopConfig"); + } + + // ───────────────────────────── no transaction ───────────────────────────── + + @Test + public void planWriteFailsLoudWithoutTransaction() { + // planWrite requires the executor-bound connector transaction; without it the write must fail loud + // (the cutover wires the transaction onto the session). + RecordingHmsClient client = new RecordingHmsClient(); + client.table = tableBuilder().build(); + HiveWritePlanProvider provider = providerFor(client, new RecordingConnectorContext()); + + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> provider.planWrite(new WriteSession(null, Collections.emptyMap()), handle())); + Assertions.assertTrue(ex.getMessage().contains("active connector transaction"), + "expected the missing-transaction message, got: " + ex.getMessage()); + } + + // ───────────────────────────── test doubles ───────────────────────────── + + /** A fe-filesystem {@link StorageProperties} whose {@code toBackendProperties().toMap()} returns the given + * BE-canonical map — mirrors how a real object-store binding hands BE creds to the connector. Adapted from + * the iceberg write test. */ + private static StorageProperties fakeBackendStorage(Map beMap) { + BackendStorageProperties backend = new BackendStorageProperties() { + @Override + public BackendStorageKind backendKind() { + return BackendStorageKind.S3_COMPATIBLE; + } + + @Override + public Map toMap() { + return beMap; + } + }; + return new StorageProperties() { + @Override + public String providerName() { + return "fake"; + } + + @Override + public StorageKind kind() { + return StorageKind.OBJECT_STORAGE; + } + + @Override + public FileSystemType type() { + return FileSystemType.S3; + } + + @Override + public Map rawProperties() { + return Collections.emptyMap(); + } + + @Override + public Map matchedProperties() { + return Collections.emptyMap(); + } + + @Override + public Optional toBackendProperties() { + return Optional.of(backend); + } + }; + } + + /** A bound write request wrapping a {@link HiveTableHandle}; mirrors the engine's PluginDrivenWriteHandle. */ + private static final class WriteHandle implements ConnectorWriteHandle { + private final ConnectorTableHandle tableHandle; + private boolean overwrite; + + WriteHandle(ConnectorTableHandle tableHandle) { + this.tableHandle = tableHandle; + } + + WriteHandle overwrite(boolean v) { + this.overwrite = v; + return this; + } + + @Override + public ConnectorTableHandle getTableHandle() { + return tableHandle; + } + + @Override + public List getColumns() { + return Collections.emptyList(); + } + + @Override + public boolean isOverwrite() { + return overwrite; + } + + @Override + public Map getWriteContext() { + return Collections.emptyMap(); + } + } + + /** A session carrying the bound connector transaction and the session-variable overrides. */ + private static final class WriteSession implements ConnectorSession { + private final ConnectorTransaction txn; + private final Map sessionProperties; + + WriteSession(ConnectorTransaction txn, Map sessionProperties) { + this.txn = txn; + this.sessionProperties = sessionProperties; + } + + @Override + public Optional getCurrentTransaction() { + return Optional.ofNullable(txn); + } + + @Override + public Map getSessionProperties() { + return sessionProperties; + } + + @Override + public String getQueryId() { + return "q"; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public String getCatalogName() { + return "test"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + } + + /** Recording {@link HmsClient} fake returning canned table/partition metadata and recording the calls the + * provider (and the transaction's begin-guard) make. */ + private static final class RecordingHmsClient implements HmsClient { + private final List calls = new ArrayList<>(); + private HmsTableInfo table; + private List partitionNames = Collections.emptyList(); + private List partitions = Collections.emptyList(); + + @Override + public List listDatabases() { + return Collections.emptyList(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + throw new UnsupportedOperationException("getDatabase"); + } + + @Override + public List listTables(String dbName) { + return Collections.emptyList(); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + return table != null; + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + calls.add("getTable:" + dbName + "." + tableName); + if (table == null) { + throw new UnsupportedOperationException("no canned table"); + } + return table; + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + return Collections.emptyMap(); + } + + @Override + public List listPartitionNames(String dbName, String tableName, int maxParts) { + calls.add("listPartitionNames"); + return partitionNames; + } + + @Override + public List getPartitions(String dbName, String tableName, List partNames) { + calls.add("getPartitions:" + partNames); + return partitions; + } + + @Override + public HmsPartitionInfo getPartition(String dbName, String tableName, List values) { + throw new UnsupportedOperationException("getPartition"); + } + + @Override + public void close() { + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveWriteUtilsTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveWriteUtilsTest.java new file mode 100644 index 00000000000000..e4633e6e032fbe --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveWriteUtilsTest.java @@ -0,0 +1,165 @@ +// 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.thrift.THivePartitionUpdate; +import org.apache.doris.thrift.TS3MPUPendingUpload; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Unit tests for {@link HiveWriteUtils}. These pin the pure write-path helpers that the connector + * write transaction relies on: staging-directory containment/equality checks and the partition + * update accumulator merge. A behavior change in any of these silently corrupts commit accounting + * or staging cleanup, so the tests encode the exact contract, not just smoke coverage. + */ +public class HiveWriteUtilsTest { + + private static THivePartitionUpdate update(String name, long fileSize, long rowCount, String... fileNames) { + return new THivePartitionUpdate() + .setName(name) + .setFileSize(fileSize) + .setRowCount(rowCount) + .setFileNames(new ArrayList<>(Arrays.asList(fileNames))); + } + + @Test + public void mergePartitionsSumsAndConcatsSameName() { + THivePartitionUpdate first = update("p=1", 100L, 10L, "f1", "f2"); + THivePartitionUpdate second = update("p=1", 55L, 5L, "f3"); + + List merged = HiveWriteUtils.mergePartitions(Arrays.asList(first, second)); + + Assertions.assertEquals(1, merged.size()); + THivePartitionUpdate m = merged.get(0); + Assertions.assertEquals("p=1", m.getName()); + Assertions.assertEquals(155L, m.getFileSize(), "file sizes must be summed"); + Assertions.assertEquals(15L, m.getRowCount(), "row counts must be summed"); + Assertions.assertEquals(Arrays.asList("f1", "f2", "f3"), m.getFileNames(), "file names must be concatenated"); + } + + @Test + public void mergePartitionsKeepsDistinctNames() { + List merged = HiveWriteUtils.mergePartitions(Arrays.asList( + update("p=1", 10L, 1L, "a"), + update("p=2", 20L, 2L, "b"), + update("p=1", 30L, 3L, "c"))); + + Map byName = merged.stream() + .collect(Collectors.toMap(THivePartitionUpdate::getName, u -> u)); + Assertions.assertEquals(2, byName.size()); + Assertions.assertEquals(40L, byName.get("p=1").getFileSize()); + Assertions.assertEquals(4L, byName.get("p=1").getRowCount()); + Assertions.assertEquals(20L, byName.get("p=2").getFileSize()); + } + + @Test + public void mergePartitionsConcatsPendingUploads() { + THivePartitionUpdate first = update("p=1", 0L, 0L, "a"); + first.setS3MpuPendingUploads(new ArrayList<>(Arrays.asList(new TS3MPUPendingUpload()))); + THivePartitionUpdate second = update("p=1", 0L, 0L, "b"); + second.setS3MpuPendingUploads(new ArrayList<>(Arrays.asList( + new TS3MPUPendingUpload(), new TS3MPUPendingUpload()))); + + List merged = HiveWriteUtils.mergePartitions(Arrays.asList(first, second)); + + Assertions.assertEquals(1, merged.size()); + Assertions.assertEquals(3, merged.get(0).getS3MpuPendingUploads().size(), + "pending MPU uploads across BE reports for one partition must be aggregated"); + } + + @Test + public void mergePartitionsToleratesNullPendingUploads() { + // Neither update carries pending uploads; the merge must not NPE and just sums files. + List merged = HiveWriteUtils.mergePartitions(Arrays.asList( + update("p=1", 1L, 1L, "a"), + update("p=1", 2L, 2L, "b"))); + Assertions.assertEquals(1, merged.size()); + Assertions.assertEquals(3L, merged.get(0).getFileSize()); + } + + @Test + public void isSubDirectoryHappyPath() { + Assertions.assertTrue(HiveWriteUtils.isSubDirectory("/warehouse/table", "/warehouse/table/p=1")); + Assertions.assertTrue(HiveWriteUtils.isSubDirectory( + "/warehouse/table", "/warehouse/table/.doris_staging/user/uuid")); + } + + @Test + public void isSubDirectoryRejectsEqualAndSiblingAndPrefix() { + // Equal paths are not a strict subdirectory. + Assertions.assertFalse(HiveWriteUtils.isSubDirectory("/warehouse/table", "/warehouse/table")); + // A shared textual prefix without a path boundary is not containment. + Assertions.assertFalse(HiveWriteUtils.isSubDirectory("/warehouse/table", "/warehouse/table2")); + // Unrelated path. + Assertions.assertFalse(HiveWriteUtils.isSubDirectory("/warehouse/table", "/other/x")); + } + + @Test + public void isSubDirectoryRejectsDifferentFileSystem() { + // Same path suffix but different authority (namenode) => different file system. + Assertions.assertFalse(HiveWriteUtils.isSubDirectory("hdfs://nn1/w/t", "hdfs://nn2/w/t/p=1")); + // Different scheme. + Assertions.assertFalse(HiveWriteUtils.isSubDirectory("s3://bucket/w/t", "hdfs://nn/w/t/p=1")); + } + + @Test + public void isSubDirectoryNullSafe() { + Assertions.assertFalse(HiveWriteUtils.isSubDirectory(null, "/a/b")); + Assertions.assertFalse(HiveWriteUtils.isSubDirectory("/a", null)); + } + + @Test + public void getImmediateChildPathReturnsFirstLevel() { + Assertions.assertEquals("/warehouse/table/.doris_staging", + HiveWriteUtils.getImmediateChildPath( + "/warehouse/table", "/warehouse/table/.doris_staging/user/uuid")); + // Direct child returns the child itself. + Assertions.assertEquals("/warehouse/table/p=1", + HiveWriteUtils.getImmediateChildPath("/warehouse/table", "/warehouse/table/p=1")); + } + + @Test + public void getImmediateChildPathReturnsNullWhenNotChild() { + Assertions.assertNull(HiveWriteUtils.getImmediateChildPath("/warehouse/table", "/other/x")); + Assertions.assertNull(HiveWriteUtils.getImmediateChildPath("/warehouse/table", "/warehouse/table")); + } + + @Test + public void pathsEqualNormalizesTrailingSlashAndFileSystem() { + Assertions.assertTrue(HiveWriteUtils.pathsEqual("/a/b", "/a/b/")); + Assertions.assertTrue(HiveWriteUtils.pathsEqual("hdfs://nn/a/b", "hdfs://nn/a/b")); + Assertions.assertFalse(HiveWriteUtils.pathsEqual("/a/b", "/a/c")); + // Different namenode => not equal even with identical path. + Assertions.assertFalse(HiveWriteUtils.pathsEqual("hdfs://nn1/a/b", "hdfs://nn2/a/b")); + } + + @Test + public void pathsEqualNullSafe() { + Assertions.assertTrue(HiveWriteUtils.pathsEqual(null, null)); + Assertions.assertFalse(HiveWriteUtils.pathsEqual(null, "/a")); + Assertions.assertFalse(HiveWriteUtils.pathsEqual("/a", null)); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HudiSiblingPropertiesTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HudiSiblingPropertiesTest.java new file mode 100644 index 00000000000000..04d3222c3660d2 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HudiSiblingPropertiesTest.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.hive; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Tests the pure hudi-sibling property synthesis. Unlike iceberg there is no flavor key to inject: hudi has no + * {@code iceberg.catalog.type} analogue, so synthesis is a verbatim defensive copy of the gateway's catalog map. + */ +public class HudiSiblingPropertiesTest { + + @Test + public void carriesMetastoreStorageAndKerberosKeysVerbatim() { + // The sibling connects to the SAME metastore/storage as the gateway; dropping any of these keys would + // leave the embedded hudi connector unable to reach HMS or object storage. The uri short form must + // survive too (HudiConnector.createClient reads both hive.metastore.uris and uri). + Map in = new HashMap<>(); + in.put("uri", "thrift://host:9083"); + in.put("fs.s3a.access.key", "AK"); + in.put("dfs.nameservices", "ns1"); + in.put("hadoop.security.authentication", "kerberos"); + in.put("hive.metastore.client.principal", "hive/_HOST@REALM"); + + Map out = HudiSiblingProperties.synthesize(in); + + Assertions.assertEquals("thrift://host:9083", out.get("uri"), "the uri short form must be carried"); + Assertions.assertEquals("AK", out.get("fs.s3a.access.key"), "object-storage creds must be carried"); + Assertions.assertEquals("ns1", out.get("dfs.nameservices"), "HDFS-HA config must be carried"); + Assertions.assertEquals("kerberos", out.get("hadoop.security.authentication"), + "kerberos auth mode must be carried"); + Assertions.assertEquals("hive/_HOST@REALM", out.get("hive.metastore.client.principal"), + "kerberos principal must be carried"); + } + + @Test + public void injectsNoFlavorKey() { + // Hudi has no iceberg.catalog.type analogue; synthesis must NOT invent one. The output equals the input. + Map in = new HashMap<>(); + in.put("hive.metastore.uris", "thrift://host:9083"); + + Map out = HudiSiblingProperties.synthesize(in); + + Assertions.assertFalse(out.containsKey("iceberg.catalog.type"), + "hudi synthesis must inject no flavor key"); + Assertions.assertEquals(in, out, "synthesis is a verbatim copy of the gateway property map"); + } + + @Test + public void returnsDefensiveCopyThatDoesNotMutateInput() { + // The gateway holds its catalog properties unmodifiable and shared; the returned map must be a distinct + // instance so a later mutation by the sibling connector cannot corrupt the gateway's own hive path. + Map in = new HashMap<>(); + in.put("hive.metastore.uris", "thrift://host:9083"); + + Map out = HudiSiblingProperties.synthesize(in); + out.put("extra.key", "v"); + + Assertions.assertNotSame(in, out, "synthesis must return a NEW map, not alias the gateway's"); + Assertions.assertFalse(in.containsKey("extra.key"), + "mutating the synthesized map must not affect the gateway's input map"); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/IcebergSiblingPropertiesTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/IcebergSiblingPropertiesTest.java new file mode 100644 index 00000000000000..6895840932b68a --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/IcebergSiblingPropertiesTest.java @@ -0,0 +1,97 @@ +// 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.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Tests the pure iceberg-sibling property synthesis. Each case pins WHY the behavior matters for the embedded + * iceberg-on-HMS connector the flipped gateway builds from the returned map. + */ +public class IcebergSiblingPropertiesTest { + + @Test + public void injectsHmsFlavor() { + // Without iceberg.catalog.type the sibling's createCatalog() throws "Missing 'iceberg.catalog.type'"; + // an iceberg-on-HMS table is always served by the hms flavor. + Map in = new HashMap<>(); + in.put("hive.metastore.uris", "thrift://host:9083"); + + Map out = IcebergSiblingProperties.synthesize(in); + + Assertions.assertEquals("hms", out.get("iceberg.catalog.type"), + "the sibling must resolve the hms flavor"); + } + + @Test + public void carriesMetastoreStorageAndKerberosKeys() { + // The sibling connects to the SAME metastore/storage as the gateway; dropping any of these keys would + // leave the embedded HiveCatalog unable to reach HMS or object storage. The uri short form must survive + // too (the iceberg HMS parser binds both hive.metastore.uris and uri). + Map in = new HashMap<>(); + in.put("uri", "thrift://host:9083"); + in.put("fs.s3a.access.key", "AK"); + in.put("dfs.nameservices", "ns1"); + in.put("hadoop.security.authentication", "kerberos"); + in.put("hive.metastore.client.principal", "hive/_HOST@REALM"); + in.put("hive.conf.resources", "hive-site.xml"); + + Map out = IcebergSiblingProperties.synthesize(in); + + Assertions.assertEquals("thrift://host:9083", out.get("uri"), "the uri short form must be carried"); + Assertions.assertEquals("AK", out.get("fs.s3a.access.key"), "object-storage creds must be carried"); + Assertions.assertEquals("ns1", out.get("dfs.nameservices"), "HDFS-HA config must be carried"); + Assertions.assertEquals("kerberos", out.get("hadoop.security.authentication"), + "kerberos auth mode must be carried"); + Assertions.assertEquals("hive/_HOST@REALM", out.get("hive.metastore.client.principal"), + "kerberos principal must be carried"); + Assertions.assertEquals("hive-site.xml", out.get("hive.conf.resources"), + "external hive-site.xml reference must be carried"); + Assertions.assertEquals("hms", out.get("iceberg.catalog.type")); + } + + @Test + public void doesNotMutateInput() { + // The gateway holds its catalog properties unmodifiable and shared; mutating them would corrupt the + // gateway's own hive path. + Map in = new HashMap<>(); + in.put("hive.metastore.uris", "thrift://host:9083"); + + IcebergSiblingProperties.synthesize(in); + + Assertions.assertFalse(in.containsKey("iceberg.catalog.type"), "the input map must not be mutated"); + Assertions.assertEquals(1, in.size()); + } + + @Test + public void overridesAnyPreexistingFlavor() { + // Defensive: even a stray iceberg.catalog.type on the gateway catalog must not select a non-hms flavor + // for the iceberg-on-HMS sibling. + Map in = new HashMap<>(); + in.put("iceberg.catalog.type", "rest"); + + Map out = IcebergSiblingProperties.synthesize(in); + + Assertions.assertEquals("hms", out.get("iceberg.catalog.type"), + "the iceberg-on-HMS sibling is always the hms flavor, overriding any pre-existing value"); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/NameMappingTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/NameMappingTest.java new file mode 100644 index 00000000000000..026c1bbb761b8a --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/NameMappingTest.java @@ -0,0 +1,67 @@ +// 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.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Unit tests for {@link NameMapping}. The connector write transaction keys its per-table and + * per-partition action maps by {@code NameMapping}, so value-based {@code equals}/{@code hashCode} + * is load-bearing: a broken key would split one table's actions across map buckets and drop writes. + * These tests pin that contract, not just field storage. + */ +public class NameMappingTest { + + @Test + public void equalValuesAreEqualAndHashAlike() { + NameMapping a = new NameMapping(1L, "localDb", "localTbl", "remoteDb", "remoteTbl"); + NameMapping b = new NameMapping(1L, "localDb", "localTbl", "remoteDb", "remoteTbl"); + Assertions.assertEquals(a, b); + Assertions.assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + public void anyFieldDifferenceBreaksEquality() { + NameMapping base = new NameMapping(1L, "ld", "lt", "rd", "rt"); + Assertions.assertNotEquals(base, new NameMapping(2L, "ld", "lt", "rd", "rt")); + Assertions.assertNotEquals(base, new NameMapping(1L, "LD", "lt", "rd", "rt")); + Assertions.assertNotEquals(base, new NameMapping(1L, "ld", "LT", "rd", "rt")); + Assertions.assertNotEquals(base, new NameMapping(1L, "ld", "lt", "RD", "rt")); + Assertions.assertNotEquals(base, new NameMapping(1L, "ld", "lt", "rd", "RT")); + } + + @Test + public void usableAsMapKeyByValue() { + Map actions = new HashMap<>(); + actions.put(new NameMapping(7L, "ld", "lt", "rd", "rt"), "action"); + // A distinct instance with identical values must resolve to the same bucket. + Assertions.assertEquals("action", actions.get(new NameMapping(7L, "ld", "lt", "rd", "rt"))); + Assertions.assertNull(actions.get(new NameMapping(7L, "ld", "lt", "rd", "other"))); + } + + @Test + public void fullNamesJoinDbAndTable() { + NameMapping m = new NameMapping(1L, "localDb", "localTbl", "remoteDb", "remoteTbl"); + Assertions.assertEquals("localDb.localTbl", m.getFullLocalName()); + Assertions.assertEquals("remoteDb.remoteTbl", m.getFullRemoteName()); + } +} 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 new file mode 100644 index 00000000000000..9623155b9d4971 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/RecordingConnectorContext.java @@ -0,0 +1,108 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.spi.ConnectorBrokerAddress; +import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.filesystem.properties.StorageProperties; +import org.apache.doris.thrift.TFileType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; + +/** + * Hand-written {@link ConnectorContext} test double (no Mockito) for the hive write-plan tests, adapted from + * the iceberg connector's {@code RecordingConnectorContext}. Resolves the BE file type / normalized write + * 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 { + + int authCount; + + /** BE file type the fake returns from {@link #getBackendFileType} (drives in-place vs staging write path). */ + TFileType backendFileType = TFileType.FILE_S3; + + /** Raw URIs the connector routed through {@link #normalizeStorageUri}. */ + final List normalizedUris = new ArrayList<>(); + + /** Static storage properties the fake returns from {@link #getStorageProperties()} (BE-canonical creds via + * {@code sp.toBackendProperties().toMap()}); default none. */ + List storageProperties = Collections.emptyList(); + + /** BE-canonical backend props the fake returns from {@link #getBackendStorageProperties()} — the read-path + * source that carries the {@code fs./juicefs.*} passthrough for untyped fe-filesystem schemes (jfs, oss-hdfs) + * whose typed {@link #getStorageProperties()} binding is empty; default none. */ + Map backendStorageProperties = Collections.emptyMap(); + + /** Broker addresses the fake returns from {@link #getBrokerAddresses()}; default none, so a FILE_BROKER + * write fails loud ("No alive broker.") unless a test populates it. */ + List brokerAddresses = Collections.emptyList(); + + @Override + public String getCatalogName() { + return "test"; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public String getBackendFileType(String rawUri, Map rawVendedCredentials) { + return backendFileType.name(); + } + + @Override + public String normalizeStorageUri(String rawUri) { + return normalizeStorageUri(rawUri, null); + } + + @Override + public String normalizeStorageUri(String rawUri, Map rawVendedCredentials) { + normalizedUris.add(rawUri); + // Canonicalize the scheme the way DefaultConnectorContext does for native object-store paths + // (oss/cos/obs/s3a -> s3), so a test can prove the connector routes the location through this seam. + return rawUri == null ? null : rawUri.replaceFirst("^(oss|cos|obs|s3a)://", "s3://"); + } + + @Override + public List getStorageProperties() { + return storageProperties; + } + + @Override + public Map getBackendStorageProperties() { + return backendStorageProperties; + } + + @Override + public List getBrokerAddresses() { + return brokerAddresses; + } + + @Override + public T executeAuthenticated(Callable task) throws Exception { + authCount++; + return task.call(); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/ScopeSession.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/ScopeSession.java new file mode 100644 index 00000000000000..f2e1d6d7c29ff8 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/ScopeSession.java @@ -0,0 +1,89 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; + +import java.util.Collections; +import java.util.Map; + +/** + * Minimal {@link ConnectorSession} for hive-connector unit tests, carrying a catalog id and a per-statement + * scope. The heterogeneous gateway keys its per-statement sibling-metadata funnel on + * {@code "metadata:" + getCatalogId() + ":" + ownerLabel} and reads the scope via {@link #getStatementScope()}, + * so a test wires a live {@link TestStatementScope} to prove sharing (or {@link ConnectorStatementScope#NONE} to + * prove the pre-funnel load-every-time behavior). Mirrors the iceberg connector's test session. + */ +final class ScopeSession implements ConnectorSession { + + private final long catalogId; + private final String queryId; + private final ConnectorStatementScope scope; + + ScopeSession(long catalogId, String queryId, ConnectorStatementScope scope) { + this.catalogId = catalogId; + this.queryId = queryId; + this.scope = scope; + } + + @Override + public long getCatalogId() { + return catalogId; + } + + @Override + public String getQueryId() { + return queryId; + } + + @Override + public ConnectorStatementScope getStatementScope() { + return scope; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public String getCatalogName() { + return "c"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/TestStatementScope.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/TestStatementScope.java new file mode 100644 index 00000000000000..7d52c162a12dac --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/TestStatementScope.java @@ -0,0 +1,40 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.ConnectorStatementScope; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * A memoizing {@link ConnectorStatementScope} for hive-connector unit tests. The hive connector module does not + * depend on fe-core, so tests cannot use the engine's {@code ConnectorStatementScopeImpl}; this is a faithful + * copy of it (mirrors the iceberg connector's test copy). Sharing one instance across a statement's forwards lets + * a test prove that the heterogeneous gateway reuses ONE sibling metadata per owner per statement. + */ +final class TestStatementScope implements ConnectorStatementScope { + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + @Override + @SuppressWarnings("unchecked") + public T computeIfAbsent(String key, Supplier loader) { + return (T) cache.computeIfAbsent(key, k -> loader.get()); + } +} diff --git a/fe/fe-connector/fe-connector-hms-hive-shade/pom.xml b/fe/fe-connector/fe-connector-hms-hive-shade/pom.xml new file mode 100644 index 00000000000000..d0391c238635cf --- /dev/null +++ b/fe/fe-connector/fe-connector-hms-hive-shade/pom.xml @@ -0,0 +1,386 @@ + + + + 4.0.0 + + + org.apache.doris + fe-connector + ${revision} + ../pom.xml + + + fe-connector-hms-hive-shade + jar + Doris FE Connector - HMS Hive Shade + + Plugin-private slim replacement for the fat org.apache.doris:hive-catalog-shade (122MB) + that fe-connector-hms and fe-connector-iceberg used to consume. It shades ONLY the Hive + metastore-CLIENT closure (hive-standalone-metastore + hive-common/HiveConf + + hive-storage-api + hive-serde + iceberg-hive-metastore's HiveCatalog) plus a pinned + libthrift/libfb303 0.9.3, and relocates org.apache.thrift -> + shade.doris.hive.org.apache.thrift. + + Why relocate thrift: Doris internal thrift is 0.16.0 (fe/pom.xml manages libthrift there), + and the doris-gen stubs (TFileScanRangeParams / TIcebergFileDesc ...) are compiled against + it; the Hive 3.1.3 metastore-client stubs are compiled against thrift ~0.9.x (TFramedTransport + in .transport, TBase/scheme contracts drifted) and are binary-incompatible. One + org.apache.thrift namespace cannot host both versions, so the Hive client gets its own private + package. The relocation prefix (shade.doris.hive.org.apache.thrift) is REUSED from the fat + hive-catalog-shade on purpose: Doris's vendored patch client + (fe-connector-hms/.../org/apache/hadoop/hive/metastore/HiveMetaStoreClient.java) and + ThriftHmsClient already import that prefix, so no connector source changes are needed, and the + bundled libthrift 0.9.3 is byte-identical to the fat shade's, so the atomic global->slim swap + never splits the namespace. + + Why iceberg-hive-metastore rides along here (one shared shade, not two): iceberg's HiveCatalog + builds Doris's patch HiveMetaStoreClient BY CLASS NAME, so it must link against the SAME + relocated thrift + the SAME metastore-api class identities as the plain-HMS client. Bundling it + here means hive/hudi carry ~1MB of iceberg-hive classes they never load (bytes only, no leak), + in exchange for a single thrift private namespace. + + See plan-doc/fe-connector-hive-shade-localization/design.md (mirrors the pattern established by + fe-connector-paimon-hive-shade / plan-doc/fix-c-hms-thrift-design.md). + + + + + + org.apache.hive + hive-standalone-metastore + 3.1.3 + + true + + org.apache.orcorc-core + com.fasterxml.jackson.corejackson-databind + com.github.joshelserdropwizard-metrics-hadoop-metrics2-reporter + com.google.guavaguava + com.google.protobufprotobuf-java + com.jolboxbonecp + com.zaxxerHikariCP + commons-dbcpcommons-dbcp + io.dropwizard.metricsmetrics-core + io.dropwizard.metricsmetrics-jvm + io.dropwizard.metricsmetrics-json + javolutionjavolution + org.antlrantlr-runtime + org.apache.derbyderby + org.apache.hadoophadoop-common + org.apache.hadoophadoop-distcp + org.apache.hadoophadoop-hdfs + org.apache.hadoophadoop-hdfs-client + org.apache.hadoophadoop-mapreduce-client-core + org.apache.logging.log4jlog4j-slf4j-impl + org.apache.logging.log4jlog4j-1.2-api + org.apache.logging.log4jlog4j-core + org.apache.thriftlibthrift + org.apache.thriftlibfb303 + org.datanucleusdatanucleus-api-jdo + org.datanucleusdatanucleus-core + org.datanucleusdatanucleus-rdbms + org.datanucleusjavax.jdo + sqllinesqlline + ant-contribant-contrib + org.apache.commonscommons-lang3 + + + + + + org.apache.hive + hive-common + 3.1.3 + true + + org.apache.orcorc-core + org.eclipse.jettyjetty-http + org.eclipse.jettyjetty-rewrite + org.eclipse.jettyjetty-server + org.eclipse.jettyjetty-servlet + org.eclipse.jettyjetty-webapp + javax.servletjavax.servlet-api + jlinejline + org.apache.antant + net.sf.jpamjpam + org.apache.hadoophadoop-common + org.apache.hadoophadoop-mapreduce-client-core + com.tdunningjson + io.dropwizard.metricsmetrics-core + io.dropwizard.metricsmetrics-jvm + io.dropwizard.metricsmetrics-json + com.fasterxml.jackson.corejackson-databind + com.github.joshelserdropwizard-metrics-hadoop-metrics2-reporter + javolutionjavolution + org.apache.logging.log4jlog4j-1.2-api + org.apache.logging.log4jlog4j-web + org.apache.logging.log4jlog4j-slf4j-impl + + + + + + org.apache.hive + hive-storage-api + 2.7.0 + true + + + + + org.apache.hive + hive-serde + 3.1.3 + true + + org.apache.hivehive-service-rpc + org.apache.arrowarrow-vector + org.apache.avroavro + org.apache.parquetparquet-hadoop-bundle + org.apache.hadoophadoop-common + org.apache.hadoophadoop-mapreduce-client-core + org.apache.thriftlibthrift + com.carrotsearchhppc + com.vlkanflatbuffers + net.sf.opencsvopencsv + + + + + + org.apache.iceberg + iceberg-hive-metastore + 1.10.1 + true + + org.apache.icebergiceberg-core + org.apache.icebergiceberg-api + org.apache.icebergiceberg-common + org.apache.icebergiceberg-bundled-guava + com.github.ben-manes.caffeinecaffeine + org.slf4jslf4j-api + + + + + + org.apache.thrift + libthrift + 0.9.3 + true + + org.apache.httpcomponentshttpcore + org.apache.httpcomponentshttpclient + org.slf4jslf4j-api + + + + + + org.apache.thrift + libfb303 + 0.9.3 + true + + + + + org.codehaus.jackson + jackson-mapper-asl + 1.9.2 + compile + true + + + org.codehaus.jackson + jackson-core-asl + 1.9.2 + compile + true + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + default-jar + package + + jar + + + true + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + + shade + + package + + + + + org.apache.hive:hive-standalone-metastore + org.apache.hive:hive-common + org.apache.hive:hive-serde + org.apache.hive:hive-storage-api + org.apache.hive:hive-classification + org.apache.hive:hive-shims + org.apache.hive.shims:hive-shims-common + org.apache.hive.shims:hive-shims-0.23 + org.apache.hive.shims:hive-shims-scheduler + org.apache.iceberg:iceberg-hive-metastore + org.apache.thrift:libthrift + org.apache.thrift:libfb303 + commons-lang:commons-lang + org.codehaus.jackson:jackson-mapper-asl + org.codehaus.jackson:jackson-core-asl + + + + true + ${project.basedir}/target/dependency-reduced-pom.xml + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + META-INF/maven/** + META-INF/versions/** + + + + + + + org.apache.thrift + shade.doris.hive.org.apache.thrift + + + + it.unimi.dsi.fastutil + shade.doris.hive.it.unimi.dsi.fastutil + + + + + + + + + diff --git a/fe/fe-connector/fe-connector-hms/pom.xml b/fe/fe-connector/fe-connector-hms/pom.xml index 553891aab14476..b0fde2ef732c68 100644 --- a/fe/fe-connector/fe-connector-hms/pom.xml +++ b/fe/fe-connector/fe-connector-hms/pom.xml @@ -47,10 +47,33 @@ under the License. ${project.version} - + - org.apache.doris - hive-catalog-shade + ${project.groupId} + fe-connector-cache + ${project.version} + + + + + ${project.groupId} + fe-connector-hms-hive-shade + ${project.version} @@ -93,6 +116,42 @@ under the License. junit-jupiter test + + + + org.apache.hadoop + hadoop-mapreduce-client-core + test + + + + + commons-lang + commons-lang + test + + + + + com.github.ben-manes.caffeine + caffeine + 2.9.3 + test + diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/CachingHmsClient.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/CachingHmsClient.java new file mode 100644 index 00000000000000..8c46cde92c07d3 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/CachingHmsClient.java @@ -0,0 +1,607 @@ +// 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.hms; + +import org.apache.doris.connector.cache.CacheSpec; +import org.apache.doris.connector.cache.MetaCacheEntry; + +import org.apache.hadoop.hive.common.FileUtils; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ForkJoinPool; +import java.util.function.Function; + +/** + * A caching {@link HmsClient} decorator: it wraps another {@code HmsClient} (in production the pooled + * {@link ThriftHmsClient}) and serves the three scan-hot-path read methods from a bounded, TTL-expiring + * cache, delegating every other method verbatim. + * + *

Why this exists. Today the hive connector caches nothing — {@code getTable}, + * {@code listPartitionNames} and {@code getPartitions} are fresh Thrift RPCs on every scan. Legacy fe-core + * kept these in the engine-side {@code HiveExternalMetaCache}, which stops routing to a hive catalog once + * it becomes a plugin-driven ({@code SPI_READY}) catalog. This decorator re-homes that caching inside the + * connector (Trino {@code CachingHiveMetastore} shape), so the connector stays performance-neutral vs + * legacy after the cutover. Because the {@code HmsClient} is also held by the hudi/iceberg siblings from + * this same module, the decorator is reusable by them later.

+ * + *

What it caches (4 methods), each on its own {@link MetaCacheEntry} configured from catalog + * properties {@code meta.cache.hive..(enable|ttl-second|capacity)} (defaults mirror the legacy + * fe-core {@code Config} values — the connector is {@code Config}-free):

+ *
    + *
  • {@code getTable} — keyed by {@code (db, table)} → {@link HmsTableInfo}.
  • + *
  • {@code listPartitionNames} — keyed by {@code (db, table, maxParts)} → partition-name list. Real + * callers pass the unbounded {@code maxParts}, so this is effectively one entry per table; keeping + * {@code maxParts} in the key keeps a bounded request from ever being served a fuller list.
  • + *
  • {@code getPartitions} — one entry PER PARTITION, keyed by {@code (db, table, partition-values)} → + * {@link HmsPartitionInfo}. A bulk request looks up each requested name (parsed to its values) and + * fetches only the misses in a single delegate call, storing each returned partition under its OWN + * values — so overlapping requests SHARE partition entries and the capacity bounds partition OBJECTS + * (legacy {@code HiveExternalMetaCache} / Trino {@code CachingHiveMetastore} shape), not request-lists. + * {@link HmsPartitionInfo} carries {@code transient_lastDdlTime} in its parameters, which a later step + * reads through this cache for the table max-modify-time.
  • + *
  • {@code getTableColumnStatistics} — keyed by {@code (db, table, requested-column-list)} → the + * (possibly sparse or empty) stats list. Same RPC-argument granularity; the empty-list "no stats" + * result is a legitimate cached value (only {@code null} loads are skipped). This is the planner + * column-stats fast path, off the scan hot path, so it caches at low priority but on the same + * machinery as the rest.
  • + *
+ * + *

Pass-through. Every other read, plus every write / DDL / ACID method, is passed straight + * through to the delegate. A later invalidation step arms {@link #flush(String, String)} / + * {@link #flushDb(String)} / {@link #flushAll()} onto {@code REFRESH TABLE} / {@code REFRESH DATABASE} / + * {@code REFRESH CATALOG}. This decorator does NOT + * self-invalidate around writes — coarse REFRESH + TTL bound staleness.

+ * + *

Cache-value safety. {@code HmsTableInfo} / {@code HmsPartitionInfo} / {@code HmsColumnStatistics} + * are immutable (all fields final, collections unmodifiable), so caching them by reference is safe. The + * three list-returning methods cache and return the delegate's outer {@code List} container by reference and + * do NOT defensively copy it — its elements are immutable but the container is shared, so callers must treat + * a returned collection as read-only (the codebase-wide metadata-cache convention). Null loads are never + * cached (the framework treats {@code null} as a miss), and a loader exception ({@link HmsClientException}) + * propagates to the caller and is not cached.

+ * + *

Dormant. Nothing wraps a client with this decorator yet — {@code HiveConnector} still returns a + * raw {@code ThriftHmsClient}, and {@code "hms"} is not in {@code SPI_READY_TYPES}, so no live catalog + * builds a {@code HiveConnector} at all. Wiring the decorator into the client and the freshness probes is a + * later step; this class is fully unit-testable in isolation now.

+ */ +public class CachingHmsClient implements HmsClient { + + /** Engine token for the {@code meta.cache...*} property namespace. */ + static final String ENGINE = "hive"; + /** {@code meta.cache.hive.table.*} — cached {@link HmsTableInfo}. */ + static final String ENTRY_TABLE = "table"; + /** {@code meta.cache.hive.partition_names.*} — cached partition-name lists. */ + static final String ENTRY_PARTITION_NAMES = "partition_names"; + /** {@code meta.cache.hive.partition.*} — cached partition-object lists. */ + static final String ENTRY_PARTITION = "partition"; + /** {@code meta.cache.hive.column_stats.*} — cached column-statistics lists. */ + static final String ENTRY_COLUMN_STATS = "column_stats"; + + // Legacy fe-core Config values, mirrored locally (the connector never touches fe-core Config): + // TTL = Config.external_cache_expire_time_seconds_after_access (86400s = 24h), shared by all entries + // table cap = Config.max_external_schema_cache_num (per-table metadata sizing) + // names cap = Config.max_hive_partition_table_cache_num (per-table partition-name lists) + // part cap = Config.max_hive_partition_cache_num (partition objects) + // stats cap = Config.max_external_schema_cache_num (per-table, no legacy hive cache; reuse table sizing) + static final long DEFAULT_TTL_SECOND = 86400L; + static final long DEFAULT_TABLE_CAPACITY = 10000L; + static final long DEFAULT_PARTITION_NAMES_CAPACITY = 10000L; + static final long DEFAULT_PARTITION_CAPACITY = 100000L; + static final long DEFAULT_COLUMN_STATS_CAPACITY = 10000L; + + private final HmsClient delegate; + private final MetaCacheEntry tableCache; + private final MetaCacheEntry> partitionNamesCache; + private final MetaCacheEntry partitionsCache; + private final MetaCacheEntry> columnStatsCache; + + public CachingHmsClient(HmsClient delegate, Map properties) { + this.delegate = Objects.requireNonNull(delegate, "delegate can not be null"); + Map props = applyLegacyTtlCompatibility( + properties == null ? Collections.emptyMap() : properties); + this.tableCache = newEntry("hive.table", props, ENTRY_TABLE, DEFAULT_TABLE_CAPACITY); + this.partitionNamesCache = + newEntry("hive.partition_names", props, ENTRY_PARTITION_NAMES, DEFAULT_PARTITION_NAMES_CAPACITY); + this.partitionsCache = newEntry("hive.partition", props, ENTRY_PARTITION, DEFAULT_PARTITION_CAPACITY); + this.columnStatsCache = + newEntry("hive.column_stats", props, ENTRY_COLUMN_STATS, DEFAULT_COLUMN_STATS_CAPACITY); + } + + private static MetaCacheEntry newEntry(String name, Map props, + String entry, long defaultCapacity) { + CacheSpec spec = CacheSpec.fromProperties(props, ENGINE, entry, + CacheSpec.of(true, DEFAULT_TTL_SECOND, defaultCapacity)); + // Contextual-only + manual-miss load so a slow HMS RPC runs outside Caffeine's sync compute lock + // (deduplicated by a striped lock instead), mirroring PaimonLatestSnapshotCache / IcebergLatestSnapshotCache. + return new MetaCacheEntry<>(name, null, spec, ForkJoinPool.commonPool(), false, true, 0L, true); + } + + /** Legacy fe-core catalog knob ({@code ExternalCatalog.SCHEMA_CACHE_TTL_SECOND}) for the table/schema cache. */ + static final String LEGACY_SCHEMA_CACHE_TTL_SECOND = "schema.cache.ttl-second"; + /** Legacy fe-core knob ({@code HMSExternalCatalog.PARTITION_CACHE_TTL_SECOND}) for the partition-list cache. */ + static final String LEGACY_PARTITION_CACHE_TTL_SECOND = "partition.cache.ttl-second"; + + /** + * Translate the legacy fe-core catalog TTL knobs into this client's namespaced entry keys, mirroring + * {@code HiveExternalMetaCache.catalogPropertyCompatibilityMap} so an existing "hms" catalog that set the old + * keys keeps working after the SPI cutover: + *
    + *
  • {@code schema.cache.ttl-second} → {@code meta.cache.hive.table.ttl-second} (schema/table meta, + * backs DESC)
  • + *
  • {@code partition.cache.ttl-second} → {@code meta.cache.hive.partition_names.ttl-second} (the + * partition-name list — legacy's {@code partition_values} entry; disabling it makes a newly-added + * partition visible without REFRESH)
  • + *
+ * Only the TTL is remapped (the sole knob the legacy keys exposed); {@code enable}/{@code capacity} have no + * legacy equivalent. If both the legacy and namespaced keys are present, the namespaced key wins + * ({@link CacheSpec#applyCompatibilityMap} contract). + */ + private static Map applyLegacyTtlCompatibility(Map props) { + Map compat = new HashMap<>(); + compat.put(LEGACY_SCHEMA_CACHE_TTL_SECOND, CacheSpec.metaCacheTtlKey(ENGINE, ENTRY_TABLE)); + compat.put(LEGACY_PARTITION_CACHE_TTL_SECOND, CacheSpec.metaCacheTtlKey(ENGINE, ENTRY_PARTITION_NAMES)); + return CacheSpec.applyCompatibilityMap(props, compat); + } + + // ========== Cached reads ========== + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + return tableCache.get(new TableKey(dbName, tableName), + key -> delegate.getTable(key.dbName, key.tableName)); + } + + @Override + public HmsTableInfo getTableFresh(String dbName, String tableName) { + // Fresh (cache-bypassing) table read for SHOW CREATE TABLE, which must reflect the latest remote schema + // even while DESC (served from the schema cache backed by this tableCache) still shows a stale one. This + // neither READS nor WRITES tableCache: reading would serve the stale table this method exists to avoid, + // writing would let a non-cache path repopulate off-band (mirrors listPartitionNamesFresh). + return delegate.getTableFresh(dbName, tableName); + } + + @Override + public List listPartitionNames(String dbName, String tableName, int maxParts) { + return partitionNamesCache.get(new PartitionNamesKey(dbName, tableName, maxParts), + key -> delegate.listPartitionNames(key.dbName, key.tableName, key.maxParts)); + } + + @Override + public List listPartitionNamesFresh(String dbName, String tableName, int maxParts) { + // Fresh (cache-bypassing) listing for SHOW PARTITIONS / the partitions metadata TVF — legacy read the raw + // pooled client, never the metadata cache. This neither READS nor WRITES partitionNamesCache: reading would + // serve the stale list this method exists to avoid, and writing would let a non-cache path repopulate the + // cache off-band. The query-pruning path stays on the cached listPartitionNames (use_meta_cache contract). + return delegate.listPartitionNames(dbName, tableName, maxParts); + } + + @Override + public List getPartitions(String dbName, String tableName, List partNames) { + if (partNames == null || partNames.isEmpty()) { + return Collections.emptyList(); + } + // Per-partition assembly (Trino CachingHiveMetastore / legacy HiveExternalMetaCache shape): serve each + // requested partition from its own entry and fetch only the misses in ONE delegate round-trip, so + // overlapping requests share partition objects and the capacity bounds partition OBJECTS, not + // request-lists. Correctness is independent of name-parse fidelity: a LOOKUP is keyed by the requested + // name parsed to values, but a STORE is always keyed by the partition's OWN values, so a name whose + // parse diverges (a rare escaped value) simply misses and is re-fetched — never a wrong or dropped + // partition. Callers consume the result as a SET (they never rely on order or 1:1 name↔result + // correspondence — the delegate get_partitions_by_names never guaranteed either). + List result = new ArrayList<>(partNames.size()); + List missNames = null; + for (String name : partNames) { + HmsPartitionInfo hit = + partitionsCache.getIfPresent(new PartitionKey(dbName, tableName, toPartitionValues(name))); + if (hit != null) { + result.add(hit); + } else { + if (missNames == null) { + missNames = new ArrayList<>(); + } + missNames.add(name); + } + } + if (missNames != null) { + // Capture the invalidation generation BEFORE the delegate RPC so a REFRESH (flush) that races this + // in-flight cold-cache fetch does not get silently undone by re-caching the pre-refresh partitions. + // The pre-D2 code went through partitionsCache.get(key, loader) -> getWithManualLoad, which had this + // guard; the per-partition put must restore it (getTable/listPartitionNames/getTableColumnStatistics + // still use the guarded get path). The delegate results still populate the RESULT list directly, + // preserving the misparse->never-drop safety (only the CACHE put is generation-guarded). + long generation = partitionsCache.invalidationGeneration(); + for (HmsPartitionInfo info : delegate.getPartitions(dbName, tableName, missNames)) { + partitionsCache.putIfNotInvalidatedSince( + generation, new PartitionKey(dbName, tableName, info.getValues()), info); + result.add(info); + } + } + return result; + } + + /** + * Splits a Hive partition name ("c1=a/c2=b") into its ordered values ("a", "b"), unescaping each via + * Hive's {@code FileUtils} (already a hms-module dependency — {@code HmsEventParser} uses it). Semantics + * match the write path's {@code HiveWriteUtils.toPartitionValues}, so scan and write correlate partitions + * identically. Only used to build the per-partition LOOKUP key: a parse that diverges from the stored + * partition's own values just misses and re-fetches (never a wrong/dropped partition), so this is a + * hit-rate optimization, not a correctness dependency. + */ + private static List toPartitionValues(String partitionName) { + List values = new ArrayList<>(); + int start = 0; + while (true) { + while (start < partitionName.length() && partitionName.charAt(start) != '=') { + start++; + } + start++; + int end = start; + while (end < partitionName.length() && partitionName.charAt(end) != '/') { + end++; + } + if (start > partitionName.length()) { + break; + } + values.add(FileUtils.unescapePathName(partitionName.substring(start, end))); + start = end + 1; + } + return values; + } + + @Override + public List getTableColumnStatistics(String dbName, String tableName, + List columns) { + return columnStatsCache.get(new ColumnStatsKey(dbName, tableName, columns), + key -> delegate.getTableColumnStatistics(key.dbName, key.tableName, key.columns)); + } + + // ========== Coarse invalidation (wired onto REFRESH TABLE / REFRESH CATALOG in a later step) ========== + + /** Drop every cached entry for one table. Backs {@code REFRESH TABLE}. */ + public void flush(String dbName, String tableName) { + tableCache.invalidateKey(new TableKey(dbName, tableName)); + partitionNamesCache.invalidateIf(key -> key.matches(dbName, tableName)); + partitionsCache.invalidateIf(key -> key.matches(dbName, tableName)); + columnStatsCache.invalidateIf(key -> key.matches(dbName, tableName)); + } + + /** + * Per-partition invalidation for a partition add/drop/alter refresh, mirroring legacy + * {@code HiveExternalMetaCache}'s per-partition metadata invalidation. Drops exactly the given partitions + * from the partition-metadata cache (keyed by values) and re-fetches the partition-NAME list (its membership + * may have changed on add/drop, so it must be refreshed whole). Deliberately does NOT touch {@code tableCache} + * or {@code columnStatsCache} — legacy did not invalidate the table object or its column statistics on a + * partition-level refresh. + */ + public void invalidatePartitions(String dbName, String tableName, Set> partitionValues) { + partitionNamesCache.invalidateIf(key -> key.matches(dbName, tableName)); + if (!partitionValues.isEmpty()) { + partitionsCache.invalidateIf(key -> key.matchesPartitions(dbName, tableName, partitionValues)); + } + } + + /** Drop every cached entry for one database (all its tables). Backs {@code REFRESH DATABASE}. */ + public void flushDb(String dbName) { + tableCache.invalidateIf(key -> key.matchesDb(dbName)); + partitionNamesCache.invalidateIf(key -> key.matchesDb(dbName)); + partitionsCache.invalidateIf(key -> key.matchesDb(dbName)); + columnStatsCache.invalidateIf(key -> key.matchesDb(dbName)); + } + + /** Drop the whole cache. Backs {@code REFRESH CATALOG}. */ + public void flushAll() { + tableCache.invalidateAll(); + partitionNamesCache.invalidateAll(); + partitionsCache.invalidateAll(); + columnStatsCache.invalidateAll(); + } + + // ========== Pass-through: everything else is delegated verbatim ========== + + @Override + public List listDatabases() { + return delegate.listDatabases(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + return delegate.getDatabase(dbName); + } + + @Override + public List listTables(String dbName) { + return delegate.listTables(dbName); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + return delegate.tableExists(dbName, tableName); + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + return delegate.getDefaultColumnValues(dbName, tableName); + } + + @Override + public HmsPartitionInfo getPartition(String dbName, String tableName, List values) { + return delegate.getPartition(dbName, tableName, values); + } + + @Override + public void createDatabase(HmsCreateDatabaseRequest request) { + delegate.createDatabase(request); + } + + @Override + public void dropDatabase(String dbName) { + delegate.dropDatabase(dbName); + } + + @Override + public void createTable(HmsCreateTableRequest request) { + delegate.createTable(request); + } + + @Override + public void dropTable(String dbName, String tableName) { + delegate.dropTable(dbName, tableName); + } + + @Override + public void truncateTable(String dbName, String tableName, List partitions) { + delegate.truncateTable(dbName, tableName, partitions); + } + + @Override + public void addPartitions(String dbName, String tableName, List partitions) { + delegate.addPartitions(dbName, tableName, partitions); + } + + @Override + public void updateTableStatistics(String dbName, String tableName, + Function update) { + delegate.updateTableStatistics(dbName, tableName, update); + } + + @Override + public void updatePartitionStatistics(String dbName, String tableName, String partitionName, + Function update) { + delegate.updatePartitionStatistics(dbName, tableName, partitionName, update); + } + + @Override + public boolean dropPartition(String dbName, String tableName, List partitionValues, + boolean deleteData) { + return delegate.dropPartition(dbName, tableName, partitionValues, deleteData); + } + + @Override + public boolean partitionExists(String dbName, String tableName, List partitionValues) { + return delegate.partitionExists(dbName, tableName, partitionValues); + } + + @Override + public long openTxn(String user) { + return delegate.openTxn(user); + } + + @Override + public void commitTxn(long txnId) { + delegate.commitTxn(txnId); + } + + @Override + public Map getValidWriteIds(String fullTableName, long currentTransactionId) { + return delegate.getValidWriteIds(fullTableName, currentTransactionId); + } + + @Override + public void acquireSharedLock(String queryId, long txnId, String user, String dbName, + String tableName, List partitionNames, long timeoutMs) { + delegate.acquireSharedLock(queryId, txnId, user, dbName, tableName, partitionNames, timeoutMs); + } + + @Override + public long getCurrentNotificationEventId() { + return delegate.getCurrentNotificationEventId(); + } + + @Override + public List getNextNotification(long lastEventId, int maxEvents) { + return delegate.getNextNotification(lastEventId, maxEvents); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + + // ========== Cache keys ========== + // All keys carry (db, table) so flush(db, table) can select every entry for one table. + + static final class TableKey { + private final String dbName; + private final String tableName; + + TableKey(String dbName, String tableName) { + this.dbName = dbName; + this.tableName = tableName; + } + + boolean matchesDb(String db) { + return Objects.equals(dbName, db); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TableKey)) { + return false; + } + TableKey that = (TableKey) o; + return Objects.equals(dbName, that.dbName) && Objects.equals(tableName, that.tableName); + } + + @Override + public int hashCode() { + return Objects.hash(dbName, tableName); + } + } + + static final class PartitionNamesKey { + private final String dbName; + private final String tableName; + private final int maxParts; + + PartitionNamesKey(String dbName, String tableName, int maxParts) { + this.dbName = dbName; + this.tableName = tableName; + this.maxParts = maxParts; + } + + boolean matches(String db, String table) { + return Objects.equals(dbName, db) && Objects.equals(tableName, table); + } + + boolean matchesDb(String db) { + return Objects.equals(dbName, db); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof PartitionNamesKey)) { + return false; + } + PartitionNamesKey that = (PartitionNamesKey) o; + return maxParts == that.maxParts + && Objects.equals(dbName, that.dbName) + && Objects.equals(tableName, that.tableName); + } + + @Override + public int hashCode() { + return Objects.hash(dbName, tableName, maxParts); + } + } + + static final class PartitionKey { + private final String dbName; + private final String tableName; + // ONE partition's ordered values (defensively copied). The cache stores one entry per partition keyed + // by these values (legacy / Trino per-partition shape), so the capacity bounds partition OBJECTS and + // overlapping requests share entries rather than duplicating partitions across request-list keys. + private final List values; + + PartitionKey(String dbName, String tableName, List values) { + this.dbName = dbName; + this.tableName = tableName; + this.values = values == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(values)); + } + + boolean matches(String db, String table) { + return Objects.equals(dbName, db) && Objects.equals(tableName, table); + } + + boolean matchesDb(String db) { + return Objects.equals(dbName, db); + } + + /** This partition (its db, table and values) is one of {@code valueSet}. Backs per-partition invalidation. */ + boolean matchesPartitions(String db, String table, Set> valueSet) { + return matches(db, table) && valueSet.contains(values); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof PartitionKey)) { + return false; + } + PartitionKey that = (PartitionKey) o; + return Objects.equals(dbName, that.dbName) + && Objects.equals(tableName, that.tableName) + && Objects.equals(values, that.values); + } + + @Override + public int hashCode() { + return Objects.hash(dbName, tableName, values); + } + } + + static final class ColumnStatsKey { + private final String dbName; + private final String tableName; + // Order-sensitive, defensively copied (same as PartitionsKey): the value is exactly the (sparse or + // empty) stats list for this requested column set. + private final List columns; + + ColumnStatsKey(String dbName, String tableName, List columns) { + this.dbName = dbName; + this.tableName = tableName; + this.columns = columns == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(columns)); + } + + boolean matches(String db, String table) { + return Objects.equals(dbName, db) && Objects.equals(tableName, table); + } + + boolean matchesDb(String db) { + return Objects.equals(dbName, db); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ColumnStatsKey)) { + return false; + } + ColumnStatsKey that = (ColumnStatsKey) o; + return Objects.equals(dbName, that.dbName) + && Objects.equals(tableName, that.tableName) + && Objects.equals(columns, that.columns); + } + + @Override + public int hashCode() { + return Objects.hash(dbName, tableName, columns); + } + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HiveShowCreateTableRenderer.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HiveShowCreateTableRenderer.java new file mode 100644 index 00000000000000..8eebf826880e09 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HiveShowCreateTableRenderer.java @@ -0,0 +1,140 @@ +// 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.hms; + +import org.apache.doris.connector.api.ConnectorColumn; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Renders the native hive {@code SHOW CREATE TABLE} DDL for a base table — a byte-for-byte connector-side port of + * legacy {@code HiveMetaStoreClientHelper.showCreateTable} (base-table branch, L749-824). It lives connector-side + * (not fe-core) because it emits hive-format specifics — {@code ROW FORMAT SERDE}, {@code WITH SERDEPROPERTIES}, + * {@code STORED AS INPUTFORMAT/OUTPUTFORMAT} — that fe-core must not know about (iron rule). + * + *

Two quoting conventions are load-bearing and must both be preserved (the acceptance suites discriminate on + * them): {@code WITH SERDEPROPERTIES ('k' = 'v')} has SPACES around {@code =}, while {@code TBLPROPERTIES + * ('k'='v')} has NONE. Column comments are emitted only when non-null (an empty {@code COMMENT ''} would break the + * exact substring the meta-cache suite asserts), and the single {@code comment} table param is lifted out to the + * top-level {@code COMMENT} clause rather than left in {@code TBLPROPERTIES}. + * + *

Column/partition types are reconstructed from the mapped {@link org.apache.doris.connector.api.ConnectorType} + * via {@link HmsTypeMapping#toHiveTypeString} (the SPI carries the mapped type, not the raw thrift string). That + * round-trip is exact for scalars/decimal/date/timestamp/nested; a raw HMS {@code varchar(n)} would render as + * {@code string} (length dropped), harmless here because such columns are stored as {@code string} in HMS. An + * unmappable type throws {@code IllegalArgumentException} — legacy could not hit this (it echoed the raw string), + * so it is left to fail loud rather than guessed at (Rule 2). This method must stay reflection-free: the caller + * runs it on the fe-core thread AFTER the pinned metastore fetch, so any future name-based reflection here would + * need its own TCCL pin. + */ +public final class HiveShowCreateTableRenderer { + + /** HMS table-param key carrying the table comment (lifted to the top-level COMMENT clause). */ + private static final String COMMENT_KEY = "comment"; + + private HiveShowCreateTableRenderer() { + } + + public static String render(HmsTableInfo table) { + StringBuilder output = new StringBuilder(); + output.append(String.format("CREATE TABLE `%s`(\n", table.getTableName())); + + List columns = table.getColumns(); + for (int i = 0; i < columns.size(); i++) { + ConnectorColumn col = columns.get(i); + // 2-space indent for data columns (partition keys below use 1-space, matching legacy). + output.append(String.format(" `%s` %s", col.getName(), + HmsTypeMapping.toHiveTypeString(col.getType()))); + if (col.getComment() != null) { + output.append(String.format(" COMMENT '%s'", col.getComment())); + } + if (i < columns.size() - 1) { + output.append(",\n"); + } + } + output.append(")\n"); + + Map params = table.getParameters(); + if (params != null && params.containsKey(COMMENT_KEY)) { + output.append(String.format("COMMENT '%s'", params.get(COMMENT_KEY))).append("\n"); + } + + List partitionKeys = table.getPartitionKeys(); + if (partitionKeys != null && !partitionKeys.isEmpty()) { + output.append("PARTITIONED BY (\n") + .append(partitionKeys.stream() + .map(p -> String.format(" `%s` %s", p.getName(), + HmsTypeMapping.toHiveTypeString(p.getType()))) + .collect(Collectors.joining(",\n"))) + .append(")\n"); + } + + List bucketCols = table.getBucketCols(); + if (bucketCols != null && !bucketCols.isEmpty()) { + output.append("CLUSTERED BY (\n") + .append(bucketCols.stream().map(c -> " " + c).collect(Collectors.joining(",\n"))) + .append(")\n") + .append(String.format("INTO %d BUCKETS\n", table.getNumBuckets())); + } + + String serdeLib = table.getSerializationLib(); + if (serdeLib != null && !serdeLib.isEmpty()) { + output.append("ROW FORMAT SERDE\n").append(String.format(" '%s'\n", serdeLib)); + } + + Map serdeParams = table.getSdParameters(); + if (serdeParams != null && !serdeParams.isEmpty()) { + // SERDEPROPERTIES: spaces around '=' (legacy L789). + output.append("WITH SERDEPROPERTIES (\n") + .append(serdeParams.entrySet().stream() + .map(e -> String.format(" '%s' = '%s'", e.getKey(), e.getValue())) + .collect(Collectors.joining(",\n"))) + .append(")\n"); + } + + String inputFormat = table.getInputFormat(); + if (inputFormat != null && !inputFormat.isEmpty()) { + output.append("STORED AS INPUTFORMAT\n").append(String.format(" '%s'\n", inputFormat)); + } + String outputFormat = table.getOutputFormat(); + if (outputFormat != null && !outputFormat.isEmpty()) { + output.append("OUTPUTFORMAT\n").append(String.format(" '%s'\n", outputFormat)); + } + String location = table.getLocation(); + if (location != null && !location.isEmpty()) { + output.append("LOCATION\n").append(String.format(" '%s'\n", location)); + } + + if (params != null && !params.isEmpty()) { + // TBLPROPERTIES: no spaces around '=' (legacy L818); the comment key is lifted to COMMENT above. + Map tblProps = new LinkedHashMap<>(params); + tblProps.remove(COMMENT_KEY); + if (!tblProps.isEmpty()) { + output.append("TBLPROPERTIES (\n") + .append(tblProps.entrySet().stream() + .map(e -> String.format(" '%s'='%s'", e.getKey(), e.getValue())) + .collect(Collectors.joining(",\n"))) + .append(")"); + } + } + return output.toString(); + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsAcidConstants.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsAcidConstants.java new file mode 100644 index 00000000000000..eac80de20042a3 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsAcidConstants.java @@ -0,0 +1,39 @@ +// 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.hms; + +/** + * Hadoop-configuration keys that carry the ACID snapshot (valid transaction list + valid write-id + * list) from FE to BE for transactional Hive reads. + * + *

These are a fixed Hive/BE contract: the same literal strings fe-core's {@code AcidUtil} + * produces (via {@link HmsClient#getValidWriteIds}) and consumes. Defined once here so the write-id + * producer ({@link ThriftHmsClient}) and the future read-side ACID descent share one source of + * truth.

+ */ +public final class HmsAcidConstants { + + /** Serialized {@code ValidTxnList}. */ + public static final String VALID_TXNS_KEY = "hive.txn.valid.txns"; + + /** Serialized {@code ValidWriteIdList}. */ + public static final String VALID_WRITEIDS_KEY = "hive.txn.valid.writeids"; + + private HmsAcidConstants() { + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsClient.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsClient.java index 26b2263bb99fda..1be6692974bd51 100644 --- a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsClient.java +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsClient.java @@ -18,8 +18,10 @@ package org.apache.doris.connector.hms; import java.io.Closeable; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.function.Function; /** * Clean interface for Hive MetaStore client operations. @@ -33,8 +35,8 @@ *
    *
  • Phase 1: Read-only metadata (list, get, exists)
  • *
  • Phase 2: Partition operations
  • - *
  • Phase 3: DDL / write operations (future)
  • - *
  • Phase 4: ACID + events (future)
  • + *
  • Phase 3: DDL / write operations
  • + *
  • Phase 4: ACID transactions
  • *
*/ public interface HmsClient extends Closeable { @@ -88,6 +90,25 @@ public interface HmsClient extends Closeable { */ HmsTableInfo getTable(String dbName, String tableName); + /** + * Get table metadata bypassing any connector-side table cache — always a fresh metastore read. Used by + * SHOW CREATE TABLE, which must reflect the latest remote schema even while {@code DESC} (served from the + * schema cache) still shows a stale one (the {@code use_meta_cache} freshness contract). + * + *

Default = the cached path: a non-decorating client (e.g. the raw {@link ThriftHmsClient}) has no cache + * to bypass, so the two are identical for it. Any caching {@code HmsClient} decorator MUST override this + * to reach its delegate directly, or SHOW CREATE regresses to serving a stale cached table (mirrors + * {@link #listPartitionNamesFresh}).

+ * + * @param dbName database name + * @param tableName table name + * @return table info with SPI-typed columns, read fresh from the metastore + * @throws HmsClientException if the table is not found or operation fails + */ + default HmsTableInfo getTableFresh(String dbName, String tableName) { + return getTable(dbName, tableName); + } + /** * Get default column values for a table. * @@ -112,6 +133,25 @@ public interface HmsClient extends Closeable { List listPartitionNames(String dbName, String tableName, int maxParts); + /** + * Lists partition names bypassing any decorator cache (a FRESH metastore listing). SHOW PARTITIONS and the + * {@code partitions} metadata TVF need a fresh view (legacy read the raw pooled client, never the metadata + * cache); the query-pruning path keeps {@link #listPartitionNames} (cached under {@code use_meta_cache}). + * + *

Default = the cached path: a non-decorating client (e.g. the raw {@link ThriftHmsClient}) has no cache + * to bypass, so the two are identical for it. Any caching {@code HmsClient} decorator MUST override this + * to reach its delegate directly, or SHOW PARTITIONS regresses to serving a stale cached list. + * + * @param dbName database name + * @param tableName table name + * @param maxParts maximum number of partitions to return + * @return list of partition name strings (e.g. "dt=2024-01-01/region=us") + * @throws HmsClientException if the operation fails + */ + default List listPartitionNamesFresh(String dbName, String tableName, int maxParts) { + return listPartitionNames(dbName, tableName, maxParts); + } + /** * Get partition metadata by partition names. * @@ -135,4 +175,249 @@ List getPartitions(String dbName, String tableName, */ HmsPartitionInfo getPartition(String dbName, String tableName, List values); + + /** + * Returns HMS-recorded (no-scan) column statistics for the named columns, e.g. for the query-planner + * column-statistics fast path. + * + *

Optional: defaults to an empty list so read-only test doubles and clients without column-stats + * support need not implement it (the caller then degrades to "no stats"). The production + * {@link ThriftHmsClient} overrides it.

+ * + * @param dbName database name + * @param tableName table name + * @param columns column names to fetch stats for + * @return one {@link HmsColumnStatistics} per column that HAS stats (may be shorter than {@code columns}) + */ + default List getTableColumnStatistics(String dbName, String tableName, + List columns) { + return Collections.emptyList(); + } + + // ========== Phase 3: DDL / write operations ========== + // + // Optional operations: default to throwing so read-only implementations (the partition-pruning + // test fakes, and connectors that never issue DDL) need not implement them. The production + // {@link ThriftHmsClient} overrides all of them. + + /** + * Create a database. + * + * @param request database create spec + * @throws HmsClientException if the operation fails + */ + default void createDatabase(HmsCreateDatabaseRequest request) { + throw new UnsupportedOperationException("createDatabase is not supported by this client"); + } + + /** + * Drop a database. Callers must have already handled IF EXISTS / cascade semantics. + * + * @param dbName database name + * @throws HmsClientException if the operation fails + */ + default void dropDatabase(String dbName) { + throw new UnsupportedOperationException("dropDatabase is not supported by this client"); + } + + /** + * Create a table. When any column carries a default value, it is registered as a Hive default + * constraint (equivalent to legacy {@code createTableWithConstraints}). + * + * @param request table create spec + * @throws HmsClientException if the table already exists or the operation fails + */ + default void createTable(HmsCreateTableRequest request) { + throw new UnsupportedOperationException("createTable is not supported by this client"); + } + + /** + * Drop a table. Callers must have already handled IF EXISTS and transactional-table rejection. + * + * @param dbName database name + * @param tableName table name + * @throws HmsClientException if the operation fails + */ + default void dropTable(String dbName, String tableName) { + throw new UnsupportedOperationException("dropTable is not supported by this client"); + } + + /** + * Truncate a table, or the given partitions of it. + * + * @param dbName database name + * @param tableName table name + * @param partitions partition names to truncate; empty/null truncates the whole table + * @throws HmsClientException if the operation fails + */ + default void truncateTable(String dbName, String tableName, List partitions) { + throw new UnsupportedOperationException("truncateTable is not supported by this client"); + } + + /** + * Add partitions to a table, stamping each partition's basic statistics onto its parameters. + * Ports the legacy {@code HMSTransaction} add-partition commit; the implementation batches large + * lists internally. + * + * @param dbName database name + * @param tableName table name + * @param partitions partitions to create, each with its data-layout and statistics + * @throws HmsClientException if the operation fails + */ + default void addPartitions(String dbName, String tableName, + List partitions) { + throw new UnsupportedOperationException("addPartitions is not supported by this client"); + } + + /** + * Read-modify-write a table's basic statistics parameters (numRows / totalSize / numFiles). + * + * @param dbName database name + * @param tableName table name + * @param update receives the table's current statistics and returns the new statistics + * @throws HmsClientException if the operation fails + */ + default void updateTableStatistics(String dbName, String tableName, + Function update) { + throw new UnsupportedOperationException( + "updateTableStatistics is not supported by this client"); + } + + /** + * Read-modify-write a single partition's basic statistics parameters. + * + * @param dbName database name + * @param tableName table name + * @param partitionName partition name (e.g. "dt=2024-01-01") + * @param update receives the partition's current statistics and returns the new statistics + * @throws HmsClientException if the operation fails + */ + default void updatePartitionStatistics(String dbName, String tableName, String partitionName, + Function update) { + throw new UnsupportedOperationException( + "updatePartitionStatistics is not supported by this client"); + } + + /** + * Drop a partition by its values. + * + * @param dbName database name + * @param tableName table name + * @param partitionValues partition column values in declaration order + * @param deleteData whether to also delete the partition's data files + * @return true if a partition was dropped + * @throws HmsClientException if the operation fails + */ + default boolean dropPartition(String dbName, String tableName, + List partitionValues, boolean deleteData) { + throw new UnsupportedOperationException("dropPartition is not supported by this client"); + } + + /** + * Not-found-tolerant probe for whether a partition exists (used to downgrade a NEW-partition + * write to an APPEND when the Doris cache missed a partition that already exists in HMS). + * + * @param dbName database name + * @param tableName table name + * @param partitionValues partition column values in declaration order + * @return true if the partition exists in the metastore + * @throws HmsClientException if the operation fails for a reason other than not-found + */ + default boolean partitionExists(String dbName, String tableName, + List partitionValues) { + throw new UnsupportedOperationException("partitionExists is not supported by this client"); + } + + // ========== Phase 4: ACID transactions ========== + // + // Read-side ACID primitives for transactional Hive tables. Like the Phase 3 block, they default + // to throwing so read-only / non-transactional clients need not implement them. + + /** + * Open a Hive ACID transaction. + * + * @param user the user opening the transaction + * @return the new transaction id + * @throws HmsClientException if the operation fails + */ + default long openTxn(String user) { + throw new UnsupportedOperationException("openTxn is not supported by this client"); + } + + /** + * Commit a Hive ACID transaction (also releases the transaction's locks). + * + * @param txnId the transaction id + * @throws HmsClientException if the operation fails + */ + default void commitTxn(long txnId) { + throw new UnsupportedOperationException("commitTxn is not supported by this client"); + } + + /** + * Get the valid-transaction / valid-write-id snapshot for a transactional table, as the two + * Hadoop-configuration entries ({@link HmsAcidConstants}) that BE consumes for ACID reads. On a + * metastore-incompatibility error the implementation degrades to a max watermark rather than + * failing the read. + * + * @param fullTableName fully-qualified "db.table" + * @param currentTransactionId the reader's open transaction id + * @return map with {@link HmsAcidConstants#VALID_TXNS_KEY} and + * {@link HmsAcidConstants#VALID_WRITEIDS_KEY} + * @throws HmsClientException if the operation fails + */ + default Map getValidWriteIds(String fullTableName, long currentTransactionId) { + throw new UnsupportedOperationException("getValidWriteIds is not supported by this client"); + } + + /** + * Acquire a shared (read) lock over a table and, if given, specific partitions, polling until the + * lock is granted or the timeout elapses. + * + * @param queryId the query id (lock owner) + * @param txnId the transaction id + * @param user the requesting user + * @param dbName database name + * @param tableName table name + * @param partitionNames partitions to lock; empty locks the whole table + * @param timeoutMs maximum time to wait for the lock, in milliseconds + * @throws HmsClientException if the lock cannot be acquired within the timeout + */ + default void acquireSharedLock(String queryId, long txnId, String user, String dbName, + String tableName, List partitionNames, long timeoutMs) { + throw new UnsupportedOperationException("acquireSharedLock is not supported by this client"); + } + + // ========== Phase 5: Metastore notification events (incremental metadata sync) ========== + // + // Optional: the incremental-metadata event feed. Only the production {@link ThriftHmsClient} + // (and the {@link CachingHmsClient} pass-through) implement these; read-only test doubles and + // connectors without an event feed keep the throwing defaults. + + /** + * The metastore's current (latest) notification event id, or {@code -1} if unavailable. Used to + * cheaply decide whether there is anything new to pull. + * + * @throws HmsClientException if the operation fails + */ + default long getCurrentNotificationEventId() { + throw new UnsupportedOperationException( + "getCurrentNotificationEventId is not supported by this client"); + } + + /** + * Fetch the next batch of notification events after {@code lastEventId} (exclusive), up to + * {@code maxEvents}, as SPI-clean DTOs. + * + * @param lastEventId the last event id already consumed + * @param maxEvents maximum number of events to return + * @return the events in ascending id order (empty when none) + * @throws HmsClientException if the operation fails; when the metastore has trimmed its + * notification log past {@code lastEventId} the message carries the + * {@code REPL_EVENTS_MISSING_IN_METASTORE} sentinel so the caller can fall back to a + * full refresh + */ + default List getNextNotification(long lastEventId, int maxEvents) { + throw new UnsupportedOperationException("getNextNotification is not supported by this client"); + } } diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsClientConfig.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsClientConfig.java index a13b27acf17792..e989d0b20960e4 100644 --- a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsClientConfig.java +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsClientConfig.java @@ -18,6 +18,8 @@ package org.apache.doris.connector.hms; import java.util.Collections; +import java.util.HashMap; +import java.util.Locale; import java.util.Map; import java.util.Objects; @@ -33,17 +35,52 @@ public final class HmsClientConfig { /** Property key: HMS Thrift URI (e.g. "thrift://host:9083"). */ public static final String HMS_URI_KEY = "hive.metastore.uris"; - /** Property key: metastore type — "hms" (default), "dlf", or "glue". */ + /** Property key: metastore type — "hms" (the default, and the only routable value). */ public static final String METASTORE_TYPE_KEY = "hive.metastore.type"; /** Standard HMS (Thrift). */ public static final String METASTORE_TYPE_HMS = "hms"; - /** Alibaba Cloud DLF. */ - public static final String METASTORE_TYPE_DLF = "dlf"; + /** + * Metastore types that have been REMOVED and are no longer routable, mapped to what each one was. + * + *

Retained only so each removal can be recognised and rejected explicitly. The dispatch in + * {@link ThriftHmsClient#getMetastoreClientClassName} ends in a plain-HMS fallback, so a removed value that + * is merely absent from the dispatch silently connects somewhere the user never configured. + */ + private static final Map REMOVED_METASTORE_TYPES; - /** AWS Glue Data Catalog. */ - public static final String METASTORE_TYPE_GLUE = "glue"; + static { + Map removed = new HashMap<>(); + removed.put("glue", "AWS Glue as an HMS thrift metastore"); + removed.put("dlf", "Alibaba Cloud DLF 1.0 as an HMS thrift metastore"); + REMOVED_METASTORE_TYPES = Collections.unmodifiableMap(removed); + } + + /** + * Returns the rejection message when {@code properties} selects a metastore type that has been removed, + * else null. + * + *

Callers throw their own exception type rather than this returning a throw: property validation must + * raise {@link IllegalArgumentException} (the only type the catalog layer unwraps into a clean DdlException), + * while the lazy client path raises DorisConnectorException like its neighbours. + * + *

Must be checked BEFORE the HMS URI requirement — a glue/dlf catalog carries no + * {@code hive.metastore.uris}, so the URI check would otherwise shadow this with an error that never mentions + * the removed type. + */ + public static String removedMetastoreTypeError(Map properties) { + String type = properties.get(METASTORE_TYPE_KEY); + if (type == null) { + return null; + } + String removed = REMOVED_METASTORE_TYPES.get(type.toLowerCase(Locale.ROOT)); + if (removed == null) { + return null; + } + return METASTORE_TYPE_KEY + " = " + type.toLowerCase(Locale.ROOT) + " is no longer supported: " + + removed + " has been removed. Supported types: " + METASTORE_TYPE_HMS + "."; + } private final Map properties; private final int poolSize; diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsColumnStatistics.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsColumnStatistics.java new file mode 100644 index 00000000000000..d6288158b7f276 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsColumnStatistics.java @@ -0,0 +1,89 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hms; + +import java.util.Objects; + +/** + * Neutral per-column statistics extracted from a hive metastore {@code ColumnStatisticsObj}, hiding the + * hive-thrift type from callers. + * + *

{@code avgColLenBytes} is set only for string columns (hive's {@code avgColLen}); it is {@code -1} for + * every other type, where the consumer uses the column's fixed slot width instead. {@code ndv}/{@code numNulls} + * are {@code 0} for a stats variant hive records but the legacy reader does not recognize (boolean / binary / + * timestamp), matching legacy {@code HMSExternalTable.setStatData}.

+ */ +public final class HmsColumnStatistics { + + private final String columnName; + private final long ndv; + private final long numNulls; + private final double avgColLenBytes; + + public HmsColumnStatistics(String columnName, long ndv, long numNulls, double avgColLenBytes) { + this.columnName = columnName; + this.ndv = ndv; + this.numNulls = numNulls; + this.avgColLenBytes = avgColLenBytes; + } + + public String getColumnName() { + return columnName; + } + + public long getNdv() { + return ndv; + } + + public long getNumNulls() { + return numNulls; + } + + /** Average string length in bytes for a string column, or {@code -1} for non-string columns. */ + public double getAvgColLenBytes() { + return avgColLenBytes; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof HmsColumnStatistics)) { + return false; + } + HmsColumnStatistics that = (HmsColumnStatistics) o; + return ndv == that.ndv + && numNulls == that.numNulls + && Double.compare(that.avgColLenBytes, avgColLenBytes) == 0 + && Objects.equals(columnName, that.columnName); + } + + @Override + public int hashCode() { + return Objects.hash(columnName, ndv, numNulls, avgColLenBytes); + } + + @Override + public String toString() { + return "HmsColumnStatistics{columnName='" + columnName + + "', ndv=" + ndv + + ", numNulls=" + numNulls + + ", avgColLenBytes=" + avgColLenBytes + "}"; + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsCommonStatistics.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsCommonStatistics.java new file mode 100644 index 00000000000000..6e2a53de3c261d --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsCommonStatistics.java @@ -0,0 +1,86 @@ +// 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.hms; + +/** + * Basic ("common") statistics for a Hive table or partition: row count, file count, total file bytes. + * + *

SPI-clean port of fe-core's {@code datasource.statistics.CommonStatistics} (which is JDK-only in + * the original). Carried by {@link HmsPartitionStatistics} and consumed by the metastore + * statistics-update primitives on {@link HmsClient}.

+ */ +public final class HmsCommonStatistics { + + /** Operator for combining two statistic values. */ + public enum ReduceOperator { + ADD, + SUBTRACT, + MIN, + MAX + } + + public static final HmsCommonStatistics EMPTY = new HmsCommonStatistics(0L, 0L, 0L); + + private final long rowCount; + private final long fileCount; + private final long totalFileBytes; + + public HmsCommonStatistics(long rowCount, long fileCount, long totalFileBytes) { + this.rowCount = rowCount; + this.fileCount = fileCount; + this.totalFileBytes = totalFileBytes; + } + + public long getRowCount() { + return rowCount; + } + + public long getFileCount() { + return fileCount; + } + + public long getTotalFileBytes() { + return totalFileBytes; + } + + public static HmsCommonStatistics reduce(HmsCommonStatistics current, HmsCommonStatistics update, + ReduceOperator operator) { + return new HmsCommonStatistics( + reduce(current.getRowCount(), update.getRowCount(), operator), + reduce(current.getFileCount(), update.getFileCount(), operator), + reduce(current.getTotalFileBytes(), update.getTotalFileBytes(), operator)); + } + + public static long reduce(long current, long update, ReduceOperator operator) { + if (current >= 0 && update >= 0) { + switch (operator) { + case ADD: + return current + update; + case SUBTRACT: + return current - update; + case MAX: + return Math.max(current, update); + case MIN: + return Math.min(current, update); + default: + throw new IllegalArgumentException("Unexpected operator: " + operator); + } + } + return 0; + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsConfHelper.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsConfHelper.java index 0a6f7df090a737..e0c0a3eea49e69 100644 --- a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsConfHelper.java +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsConfHelper.java @@ -46,9 +46,30 @@ private HmsConfHelper() { */ public static HiveConf createHiveConf(Map properties) { HiveConf hiveConf = new HiveConf(); + // Pin the conf classloader to the plugin loader, mirroring PaimonCatalogFactory.assembleHiveConf. + // HiveMetaStoreClient.loadFilterHooks resolves metastore.filter.hook via Configuration.getClass, which + // uses the conf's OWN classLoader field (= the thread-context CL captured at new HiveConf() above), NOT + // the live TCCL. createHiveConf runs in the ThriftHmsClient constructor on the FE query thread, BEFORE + // ThriftHmsClient.doAs pins the TCCL, so that captured CL is still the parent 'app' loader (fe-core's own + // hive-metastore copy). HiveMetaStoreClient later copies this conf (new Configuration(hiveConf) copies the + // classLoader field), so under child-first plugin loading it resolves DefaultMetaStoreFilterHookImpl from + // the parent while MetaStoreFilterHook is child-loaded, giving "class DefaultMetaStoreFilterHookImpl not + // MetaStoreFilterHook" and failing client creation before any metastore RPC. doAs pins the LIVE TCCL + // (fixes SecurityUtil.) but cannot fix this conf-cached CL. Pinning here keeps the whole + // hive-metastore class graph in one loader. + hiveConf.setClassLoader(HmsConfHelper.class.getClassLoader()); for (Map.Entry entry : properties.entrySet()) { hiveConf.set(entry.getKey(), entry.getValue()); } + // A kerberized HMS requires SASL transport on the metastore Thrift connection. The legacy fe-core + // HMSBaseProperties.initHadoopAuthenticator auto-enabled hive.metastore.sasl.enabled whenever the + // metastore/hadoop auth was kerberos; preserve that here so a catalog that only declares kerberos auth + // (without an explicit hive.metastore.sasl.enabled) still negotiates SASL, instead of opening a plain + // TSocket that a kerberized metastore drops with TTransportException. + if ("kerberos".equalsIgnoreCase(properties.get("hadoop.security.authentication")) + || "kerberos".equalsIgnoreCase(properties.get("hive.metastore.authentication.type"))) { + hiveConf.set("hive.metastore.sasl.enabled", "true"); + } return hiveConf; } diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsCreateDatabaseRequest.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsCreateDatabaseRequest.java new file mode 100644 index 00000000000000..b89f50fcc15ead --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsCreateDatabaseRequest.java @@ -0,0 +1,71 @@ +// 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.hms; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; + +/** + * Write-side spec describing a database to create in the metastore. + * + *

The write-side counterpart of the read DTO {@link HmsDatabaseInfo}. It is + * SPI-clean (only JDK types) and mirrors fe-core's {@code HiveDatabaseMetadata} + * without depending on any fe-core class. A connector plugin assembles it from + * a CREATE DATABASE request and hands it to {@link HmsClient#createDatabase}.

+ */ +public final class HmsCreateDatabaseRequest { + + private final String dbName; + private final String locationUri; + private final String comment; + private final Map properties; + + public HmsCreateDatabaseRequest(String dbName, String locationUri, + String comment, Map properties) { + this.dbName = Objects.requireNonNull(dbName, "dbName"); + this.locationUri = locationUri; + this.comment = comment; + this.properties = properties == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(properties); + } + + public String getDbName() { + return dbName; + } + + /** Optional database location URI; {@code null} when the metastore should pick a default. */ + public String getLocationUri() { + return locationUri; + } + + /** Database comment; never {@code null} (empty when unset). */ + public String getComment() { + return comment == null ? "" : comment; + } + + public Map getProperties() { + return properties; + } + + @Override + public String toString() { + return "HmsCreateDatabaseRequest{" + dbName + "}"; + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsCreateTableRequest.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsCreateTableRequest.java new file mode 100644 index 00000000000000..8a9994b0728bab --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsCreateTableRequest.java @@ -0,0 +1,237 @@ +// 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.hms; + +import org.apache.doris.connector.api.ConnectorColumn; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Write-side spec describing a Hive-compatible table to create in the metastore. + * + *

The write-side counterpart of the read DTO {@link HmsTableInfo}. It is + * SPI-clean (connector-api + JDK types only) and mirrors fe-core's + * {@code HiveTableMetadata} without depending on any fe-core class. A connector + * plugin assembles it from a CREATE TABLE request (resolving file format, owner, + * location, bucketing and the text-compression default on its side) and hands it + * to {@link HmsClient#createTable}.

+ * + *

{@link #getColumns()} carries all columns (data + partition, in + * declaration order); {@link #getPartitionKeys()} lists the names of the ones that + * are partition keys. The write converter splits them apart, matching legacy + * {@code HiveUtil.toHiveSchema}. Per-column default values ride on the + * {@link ConnectorColumn#getDefaultValue()} of each column.

+ */ +public final class HmsCreateTableRequest { + + private final String dbName; + private final String tableName; + private final String location; + private final List columns; + private final List partitionKeys; + private final List bucketCols; + private final int numBuckets; + private final String fileFormat; + private final String comment; + private final Map properties; + private final String defaultTextCompression; + private final String dorisVersion; + + private HmsCreateTableRequest(Builder b) { + this.dbName = Objects.requireNonNull(b.dbName, "dbName"); + this.tableName = Objects.requireNonNull(b.tableName, "tableName"); + this.location = b.location; + this.columns = b.columns == null + ? Collections.emptyList() + : Collections.unmodifiableList(b.columns); + this.partitionKeys = b.partitionKeys == null + ? Collections.emptyList() + : Collections.unmodifiableList(b.partitionKeys); + this.bucketCols = b.bucketCols == null + ? Collections.emptyList() + : Collections.unmodifiableList(b.bucketCols); + this.numBuckets = b.numBuckets; + this.fileFormat = Objects.requireNonNull(b.fileFormat, "fileFormat"); + this.comment = b.comment; + this.properties = b.properties == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(b.properties); + this.defaultTextCompression = b.defaultTextCompression; + this.dorisVersion = b.dorisVersion; + } + + public String getDbName() { + return dbName; + } + + public String getTableName() { + return tableName; + } + + /** Optional table location; {@code null} when the metastore should pick a default under the db. */ + public String getLocation() { + return location; + } + + /** All columns (data + partition keys) in declaration order. */ + public List getColumns() { + return columns; + } + + /** Names of the columns that are partition keys (subset of {@link #getColumns()} names). */ + public List getPartitionKeys() { + return partitionKeys; + } + + public List getBucketCols() { + return bucketCols; + } + + public int getNumBuckets() { + return numBuckets; + } + + /** File format string: one of "orc", "parquet", "text" (case-insensitive). */ + public String getFileFormat() { + return fileFormat; + } + + /** Table comment; never {@code null} (empty when unset). */ + public String getComment() { + return comment == null ? "" : comment; + } + + public Map getProperties() { + return properties; + } + + /** + * The compression to fall back to for a {@code text} table when the user did not set a + * {@code compression} property. The connector resolves it from its session (legacy read the + * {@code hive_text_compression} session variable); {@code null} falls back to "plain". + */ + public String getDefaultTextCompression() { + return defaultTextCompression; + } + + /** + * The value stamped into the {@code doris.version} table parameter. The connector sources it from + * its injected properties (fe-core has the build version; the plugin must not import it). When + * {@code null}/empty the write converter omits the parameter. + */ + public String getDorisVersion() { + return dorisVersion; + } + + @Override + public String toString() { + return "HmsCreateTableRequest{" + dbName + "." + tableName + "}"; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for {@link HmsCreateTableRequest}. + */ + public static final class Builder { + private String dbName; + private String tableName; + private String location; + private List columns; + private List partitionKeys; + private List bucketCols; + private int numBuckets; + private String fileFormat; + private String comment; + private Map properties; + private String defaultTextCompression; + private String dorisVersion; + + private Builder() { + } + + public Builder dbName(String val) { + this.dbName = val; + return this; + } + + public Builder tableName(String val) { + this.tableName = val; + return this; + } + + public Builder location(String val) { + this.location = val; + return this; + } + + public Builder columns(List val) { + this.columns = val; + return this; + } + + public Builder partitionKeys(List val) { + this.partitionKeys = val; + return this; + } + + public Builder bucketCols(List val) { + this.bucketCols = val; + return this; + } + + public Builder numBuckets(int val) { + this.numBuckets = val; + return this; + } + + public Builder fileFormat(String val) { + this.fileFormat = val; + return this; + } + + public Builder comment(String val) { + this.comment = val; + return this; + } + + public Builder properties(Map val) { + this.properties = val; + return this; + } + + public Builder defaultTextCompression(String val) { + this.defaultTextCompression = val; + return this; + } + + public Builder dorisVersion(String val) { + this.dorisVersion = val; + return this; + } + + public HmsCreateTableRequest build() { + return new HmsCreateTableRequest(this); + } + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsNotificationEvent.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsNotificationEvent.java new file mode 100644 index 00000000000000..ae8b01d7986289 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsNotificationEvent.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.hms; + +/** + * One HMS notification event as a connector-SPI DTO — the SPI-clean projection of Hive's + * {@code NotificationEvent} that {@link HmsClient#getNextNotification} returns, so no Hive thrift type + * crosses the client interface. It carries exactly the fields the plugin's event parser needs to build + * a neutral change descriptor: the event id + type + affected db/table, and the raw message payload + + * its format so the JSON/GZIP message deserializers can extract the details (partition names, renamed + * objects, etc.). + */ +public final class HmsNotificationEvent { + + private final long eventId; + private final String eventType; + private final String dbName; + private final String tableName; + private final String message; + private final String messageFormat; + private final long eventTime; + + public HmsNotificationEvent(long eventId, String eventType, String dbName, String tableName, + String message, String messageFormat, long eventTime) { + this.eventId = eventId; + this.eventType = eventType; + this.dbName = dbName; + this.tableName = tableName; + this.message = message; + this.messageFormat = messageFormat; + this.eventTime = eventTime; + } + + /** The event's unique incremental id. */ + public long getEventId() { + return eventId; + } + + /** The event type name (e.g. {@code CREATE_TABLE}, {@code ADD_PARTITION}). */ + public String getEventType() { + return eventType; + } + + /** The affected database name (may be {@code null} for some event types). */ + public String getDbName() { + return dbName; + } + + /** The affected table name (may be {@code null} for database-level events). */ + public String getTableName() { + return tableName; + } + + /** The raw message payload, to be parsed by the message deserializer for this event's format. */ + public String getMessage() { + return message; + } + + /** The message format (e.g. {@code json-0.2}, {@code gzip(json-0.2)}); selects the deserializer. */ + public String getMessageFormat() { + return messageFormat; + } + + /** The event time in epoch seconds (as recorded by the metastore). */ + public long getEventTime() { + return eventTime; + } + + @Override + public String toString() { + return "HmsNotificationEvent{eventId=" + eventId + ", eventType=" + eventType + + ", db=" + dbName + ", table=" + tableName + ", messageFormat=" + messageFormat + + ", eventTime=" + eventTime + '}'; + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsPartitionStatistics.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsPartitionStatistics.java new file mode 100644 index 00000000000000..a06a3eabbd2131 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsPartitionStatistics.java @@ -0,0 +1,71 @@ +// 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.hms; + +/** + * Statistics for a Hive partition/table write. SPI-clean port of fe-core's + * {@code datasource.hive.HivePartitionStatistics}, reduced to the basic (common) statistics. + * + *

The legacy type also carried a {@code Map} column-statistics map, + * but the INSERT/commit write path never populates it (it is always empty, and legacy {@code merge} + * does not actually merge column stats), and {@code HiveColumnStatistics} is a fe-core type. It is + * therefore dropped from the plugin DTO — only the row/file/byte common statistics travel.

+ */ +public final class HmsPartitionStatistics { + + public static final HmsPartitionStatistics EMPTY = + new HmsPartitionStatistics(HmsCommonStatistics.EMPTY); + + private final HmsCommonStatistics commonStatistics; + + public HmsPartitionStatistics(HmsCommonStatistics commonStatistics) { + this.commonStatistics = commonStatistics; + } + + public HmsCommonStatistics getCommonStatistics() { + return commonStatistics; + } + + public static HmsPartitionStatistics fromCommonStatistics(long rowCount, long fileCount, + long totalFileBytes) { + return new HmsPartitionStatistics( + new HmsCommonStatistics(rowCount, fileCount, totalFileBytes)); + } + + public static HmsPartitionStatistics merge(HmsPartitionStatistics current, + HmsPartitionStatistics update) { + if (current.getCommonStatistics().getRowCount() <= 0) { + return update; + } else if (update.getCommonStatistics().getRowCount() <= 0) { + return current; + } + return new HmsPartitionStatistics(HmsCommonStatistics.reduce( + current.getCommonStatistics(), update.getCommonStatistics(), + HmsCommonStatistics.ReduceOperator.ADD)); + } + + public static HmsPartitionStatistics reduce(HmsPartitionStatistics first, + HmsPartitionStatistics second, HmsCommonStatistics.ReduceOperator operator) { + HmsCommonStatistics left = first.getCommonStatistics(); + HmsCommonStatistics right = second.getCommonStatistics(); + return HmsPartitionStatistics.fromCommonStatistics( + HmsCommonStatistics.reduce(left.getRowCount(), right.getRowCount(), operator), + HmsCommonStatistics.reduce(left.getFileCount(), right.getFileCount(), operator), + HmsCommonStatistics.reduce(left.getTotalFileBytes(), right.getTotalFileBytes(), operator)); + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsPartitionWithStatistics.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsPartitionWithStatistics.java new file mode 100644 index 00000000000000..fcce1fea9d34ab --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsPartitionWithStatistics.java @@ -0,0 +1,175 @@ +// 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.hms; + +import org.apache.hadoop.hive.metastore.api.FieldSchema; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * A Hive partition to be created, bundled with the statistics to stamp on it. + * + *

SPI-clean port of fe-core's {@code datasource.hive.HivePartitionWithStatistics} plus the + * storage-descriptor fields of {@code HivePartition} (a fe-core type), flattened so the DTO depends + * only on connector-api / JDK / hive-metastore-api types. {@link HmsWriteConverter#toHivePartitions} + * turns a list of these into Hive metastore {@code Partition} objects for + * {@link HmsClient#addPartitions}.

+ */ +public final class HmsPartitionWithStatistics { + + private final String name; + private final List partitionValues; + private final String location; + private final List columns; + private final String inputFormat; + private final String outputFormat; + private final String serde; + private final Map parameters; + private final HmsPartitionStatistics statistics; + + private HmsPartitionWithStatistics(Builder builder) { + this.name = builder.name; + this.partitionValues = builder.partitionValues == null + ? Collections.emptyList() + : Collections.unmodifiableList(builder.partitionValues); + this.location = builder.location; + this.columns = builder.columns == null + ? Collections.emptyList() + : Collections.unmodifiableList(builder.columns); + this.inputFormat = builder.inputFormat; + this.outputFormat = builder.outputFormat; + this.serde = builder.serde; + this.parameters = builder.parameters == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(builder.parameters); + this.statistics = builder.statistics == null + ? HmsPartitionStatistics.EMPTY + : builder.statistics; + } + + /** Partition name (e.g. "dt=2024-01-01"). Used by the committer for tracking/logging. */ + public String getName() { + return name; + } + + /** Partition column values in declaration order. */ + public List getPartitionValues() { + return partitionValues; + } + + public String getLocation() { + return location; + } + + public List getColumns() { + return columns; + } + + public String getInputFormat() { + return inputFormat; + } + + public String getOutputFormat() { + return outputFormat; + } + + public String getSerde() { + return serde; + } + + public Map getParameters() { + return parameters; + } + + public HmsPartitionStatistics getStatistics() { + return statistics; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for HmsPartitionWithStatistics. + */ + public static final class Builder { + private String name; + private List partitionValues; + private String location; + private List columns; + private String inputFormat; + private String outputFormat; + private String serde; + private Map parameters; + private HmsPartitionStatistics statistics; + + private Builder() { + } + + public Builder name(String val) { + this.name = val; + return this; + } + + public Builder partitionValues(List val) { + this.partitionValues = val; + return this; + } + + public Builder location(String val) { + this.location = val; + return this; + } + + public Builder columns(List val) { + this.columns = val; + return this; + } + + public Builder inputFormat(String val) { + this.inputFormat = val; + return this; + } + + public Builder outputFormat(String val) { + this.outputFormat = val; + return this; + } + + public Builder serde(String val) { + this.serde = val; + return this; + } + + public Builder parameters(Map val) { + this.parameters = val; + return this; + } + + public Builder statistics(HmsPartitionStatistics val) { + this.statistics = val; + return this; + } + + public HmsPartitionWithStatistics build() { + return new HmsPartitionWithStatistics(this); + } + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsTableInfo.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsTableInfo.java index e103b3c3ebae68..f16d3bbdd29aaf 100644 --- a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsTableInfo.java +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsTableInfo.java @@ -39,8 +39,12 @@ public final class HmsTableInfo { private final String inputFormat; private final String outputFormat; private final String serializationLib; + private final String viewOriginalText; + private final String viewExpandedText; private final List columns; private final List partitionKeys; + private final List bucketCols; + private final int numBuckets; private final Map parameters; private final Map sdParameters; @@ -52,12 +56,18 @@ private HmsTableInfo(Builder builder) { this.inputFormat = builder.inputFormat; this.outputFormat = builder.outputFormat; this.serializationLib = builder.serializationLib; + this.viewOriginalText = builder.viewOriginalText; + this.viewExpandedText = builder.viewExpandedText; this.columns = builder.columns == null ? Collections.emptyList() : Collections.unmodifiableList(builder.columns); this.partitionKeys = builder.partitionKeys == null ? Collections.emptyList() : Collections.unmodifiableList(builder.partitionKeys); + this.bucketCols = builder.bucketCols == null + ? Collections.emptyList() + : Collections.unmodifiableList(builder.bucketCols); + this.numBuckets = builder.numBuckets; this.parameters = builder.parameters == null ? Collections.emptyMap() : Collections.unmodifiableMap(builder.parameters); @@ -95,6 +105,21 @@ public String getSerializationLib() { return serializationLib; } + /** + * Raw {@code viewOriginalText} of a view ({@code null} for a base table). For a Presto/Trino-authored hive + * view this carries the {@code "/* Presto View: *}{@code /"} definition; for a native hive view it + * is the original CREATE VIEW SQL. Presence of this (or {@link #getViewExpandedText()}) is the hive + * view signal (legacy {@code HMSExternalTable.isView}). + */ + public String getViewOriginalText() { + return viewOriginalText; + } + + /** Raw {@code viewExpandedText} of a view ({@code null} for a base table); the fully-qualified view SQL. */ + public String getViewExpandedText() { + return viewExpandedText; + } + /** Data columns (excludes partition keys). */ public List getColumns() { return columns; @@ -105,6 +130,16 @@ public List getPartitionKeys() { return partitionKeys; } + /** Bucketing columns (empty when the table is not bucketed). */ + public List getBucketCols() { + return bucketCols; + } + + /** Bucket count (0 when the table is not bucketed). */ + public int getNumBuckets() { + return numBuckets; + } + public Map getParameters() { return parameters; } @@ -135,8 +170,12 @@ public static final class Builder { private String inputFormat; private String outputFormat; private String serializationLib; + private String viewOriginalText; + private String viewExpandedText; private List columns; private List partitionKeys; + private List bucketCols; + private int numBuckets; private Map parameters; private Map sdParameters; @@ -178,6 +217,16 @@ public Builder serializationLib(String val) { return this; } + public Builder viewOriginalText(String val) { + this.viewOriginalText = val; + return this; + } + + public Builder viewExpandedText(String val) { + this.viewExpandedText = val; + return this; + } + public Builder columns(List val) { this.columns = val; return this; @@ -188,6 +237,16 @@ public Builder partitionKeys(List val) { return this; } + public Builder bucketCols(List val) { + this.bucketCols = val; + return this; + } + + public Builder numBuckets(int val) { + this.numBuckets = val; + return this; + } + public Builder parameters(Map val) { this.parameters = val; return this; diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsTypeMapping.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsTypeMapping.java index 45f10809356c00..a4df2a7a8df0e0 100644 --- a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsTypeMapping.java +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsTypeMapping.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -195,6 +196,101 @@ private static ConnectorType toConnectorTypeInternal(String lowerType, return ConnectorType.of("UNSUPPORTED"); } + /** + * Convert a {@link ConnectorType} to its Hive type string. This is the reverse direction of + * {@link #toConnectorType(String, Options)} for the shapes a Doris CREATE TABLE emits, and the + * SPI-clean equivalent of fe-core's {@code HiveMetaStoreClientHelper.dorisTypeToHiveType()}. + * + *

Scalar type names are Doris {@code PrimitiveType} names, matching what fe-core's + * {@code ConnectorColumnConverter.toConnectorType} emits ({@code PrimitiveType.toString()}); complex + * types use "ARRAY"/"MAP"/"STRUCT" with populated children. CHAR carries its length in the precision + * field (mirroring the create-request encoding). VARCHAR and STRING both map to Hive {@code string}, + * matching legacy.

+ * + * @throws IllegalArgumentException for a type Hive tables cannot represent — matching legacy, which + * threw for the same unsupported primitive/complex shapes. + */ + public static String toHiveTypeString(ConnectorType type) { + String name = type.getTypeName().toUpperCase(Locale.ROOT); + switch (name) { + case "ARRAY": { + List children = type.getChildren(); + if (children.isEmpty()) { + throw new IllegalArgumentException("Unsupported type conversion of " + type); + } + return "array<" + toHiveTypeString(children.get(0)) + ">"; + } + case "MAP": { + List children = type.getChildren(); + if (children.size() < 2) { + throw new IllegalArgumentException("Unsupported type conversion of " + type); + } + return "map<" + toHiveTypeString(children.get(0)) + "," + + toHiveTypeString(children.get(1)) + ">"; + } + case "STRUCT": { + List children = type.getChildren(); + List fieldNames = type.getFieldNames(); + StringBuilder sb = new StringBuilder("struct<"); + for (int i = 0; i < children.size(); i++) { + if (i > 0) { + sb.append(","); + } + String fieldName = i < fieldNames.size() ? fieldNames.get(i) : "col" + i; + sb.append(fieldName).append(":").append(toHiveTypeString(children.get(i))); + } + sb.append(">"); + return sb.toString(); + } + default: + return scalarToHiveTypeString(name, type); + } + } + + private static String scalarToHiveTypeString(String name, ConnectorType type) { + switch (name) { + case "BOOLEAN": + return "boolean"; + case "TINYINT": + return "tinyint"; + case "SMALLINT": + return "smallint"; + case "INT": + return "int"; + case "BIGINT": + return "bigint"; + case "DATE": + case "DATEV2": + return "date"; + case "DATETIME": + case "DATETIMEV2": + return "timestamp"; + case "FLOAT": + return "float"; + case "DOUBLE": + return "double"; + case "CHAR": + return "char(" + type.getPrecision() + ")"; + case "VARCHAR": + case "STRING": + return "string"; + case "DECIMALV2": + case "DECIMAL32": + case "DECIMAL64": + case "DECIMAL128": + case "DECIMAL256": + case "DECIMALV3": { + int precision = type.getPrecision(); + if (precision == 0) { + precision = DEFAULT_DECIMAL_PRECISION; + } + return "decimal(" + precision + "," + type.getScale() + ")"; + } + default: + throw new IllegalArgumentException("Unsupported type conversion of " + type); + } + } + /** * Find the index of the next top-level comma separator in a * comma-separated nested type string. Respects angle-bracket and diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsWriteConverter.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsWriteConverter.java new file mode 100644 index 00000000000000..f9ef32db40b07b --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsWriteConverter.java @@ -0,0 +1,358 @@ +// 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.hms; + +import org.apache.doris.connector.api.ConnectorColumn; + +import org.apache.hadoop.hive.common.StatsSetupConst; +import org.apache.hadoop.hive.metastore.api.Database; +import org.apache.hadoop.hive.metastore.api.FieldSchema; +import org.apache.hadoop.hive.metastore.api.Partition; +import org.apache.hadoop.hive.metastore.api.PrincipalType; +import org.apache.hadoop.hive.metastore.api.SerDeInfo; +import org.apache.hadoop.hive.metastore.api.StorageDescriptor; +import org.apache.hadoop.hive.metastore.api.Table; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Builds Hive metastore API objects ({@link Table}, {@link Database}) from the connector-SPI write specs. + * + *

This is the SPI-clean equivalent of fe-core's {@code HiveUtil.toHiveTable}/{@code toHiveDatabase} + * (plus the serde/table property split from {@code HiveProperties.setTableProperties}). It takes only + * {@link HmsCreateTableRequest}/{@link HmsCreateDatabaseRequest} and connector-api types, with no fe-core + * dependency. Doris→Hive type strings come from {@link HmsTypeMapping#toHiveTypeString}.

+ * + *

Behavior is a faithful port of the legacy code: storage-descriptor / serde / input-output-format per + * orc/parquet/text, per-format compression defaults, {@code MANAGED_TABLE} table type, and the + * {@code "doris external hive table"} storage-descriptor tag.

+ */ +public final class HmsWriteConverter { + + /** Table parameter key stamping which Doris build created the table (legacy {@code DORIS_VERSION}). */ + static final String DORIS_VERSION_KEY = "doris.version"; + /** User-facing compression property key consumed while building the table (removed afterwards). */ + static final String COMPRESSION_KEY = "compression"; + /** Fallback text compression when neither the request nor a session default supplies one. */ + static final String DEFAULT_TEXT_COMPRESSION = "plain"; + + private static final Set SUPPORTED_ORC_COMPRESSIONS = + unmodifiableSet("plain", "zlib", "snappy", "zstd", "lz4"); + private static final Set SUPPORTED_PARQUET_COMPRESSIONS = + unmodifiableSet("plain", "snappy", "zstd", "lz4"); + private static final Set SUPPORTED_TEXT_COMPRESSIONS = + unmodifiableSet("plain", "gzip", "zstd", "bzip2", "lz4", "snappy"); + + // Property keys that belong on the SerDe (not the table). Mirrors fe-core HiveProperties.HIVE_SERDE_PROPERTIES. + private static final Set HIVE_SERDE_PROPERTIES = unmodifiableSet( + "field.delim", + "colelction.delim", // Hive 2 (legacy metastore typo, kept for parity) + "collection.delim", // Hive 3 + "separatorChar", // OpenCSVSerde.SEPARATORCHAR + "serialization.format", + "line.delim", + "quoteChar", // OpenCSVSerde.QUOTECHAR + "mapkey.delim", + "escape.delim", + "escapeChar", // OpenCSVSerde.ESCAPECHAR + "serialization.null.format", + "skip.header.line.count", + "skip.footer.line.count"); + + private HmsWriteConverter() { + } + + /** + * Build a Hive metastore {@link Table} from a create-table request. Faithful port of legacy + * {@code HiveUtil.toHiveTable}. + */ + public static Table toHiveTable(HmsCreateTableRequest req) { + Objects.requireNonNull(req.getDbName(), "Hive database name should be not null"); + Objects.requireNonNull(req.getTableName(), "Hive table name should be not null"); + Table table = new Table(); + table.setDbName(req.getDbName()); + table.setTableName(req.getTableName()); + // Legacy overflows the millis cast to int before multiplying; kept verbatim for byte-for-byte parity. + int createTime = (int) System.currentTimeMillis() * 1000; + table.setCreateTime(createTime); + table.setLastAccessTime(createTime); + Set partitionSet = new HashSet<>(req.getPartitionKeys()); + SchemaSplit schema = toHiveSchema(req.getColumns(), partitionSet); + + table.setSd(toHiveStorageDesc(schema.dataColumns, req.getBucketCols(), req.getNumBuckets(), + req.getFileFormat(), req.getLocation())); + table.setPartitionKeys(schema.partitionColumns); + + table.setTableType("MANAGED_TABLE"); + Map props = new HashMap<>(req.getProperties()); + // Legacy always stamps DORIS_VERSION; the plugin cannot compute the build version (no fe-core import), + // so it is threaded on the request and stamped only when supplied. See HmsCreateTableRequest#getDorisVersion. + if (req.getDorisVersion() != null && !req.getDorisVersion().isEmpty()) { + props.put(DORIS_VERSION_KEY, req.getDorisVersion()); + } + setCompressType(req, props); + // set hive table comment by table properties + props.put("comment", req.getComment()); + if (props.containsKey("owner")) { + table.setOwner(props.get("owner")); + } + setTableProperties(table, props); + return table; + } + + /** + * Build a Hive metastore {@link Database} from a create-database request. Faithful port of legacy + * {@code HiveUtil.toHiveDatabase}. + */ + public static Database toHiveDatabase(HmsCreateDatabaseRequest req) { + Database database = new Database(); + database.setName(req.getDbName()); + String locationUri = req.getLocationUri(); + if (locationUri != null && !locationUri.isEmpty()) { + database.setLocationUri(locationUri); + } + Map props = new HashMap<>(req.getProperties()); + database.setParameters(props); + database.setDescription(req.getComment()); + if (props.containsKey("owner")) { + database.setOwnerName(props.get("owner")); + database.setOwnerType(PrincipalType.USER); + } + return database; + } + + /** + * Build Hive metastore {@link Partition} objects for a set of partitions to add, stamping each + * partition's basic statistics onto its parameters. Faithful port of legacy + * {@code HiveUtil.toMetastoreApiPartition} + {@code makeStorageDescriptorFromHivePartition}, with + * the fe-core {@code HivePartition} fields carried directly on {@link HmsPartitionWithStatistics}. + */ + public static List toHivePartitions(String dbName, String tableName, + List partitions) { + List result = new ArrayList<>(partitions.size()); + for (HmsPartitionWithStatistics partition : partitions) { + result.add(toHivePartition(dbName, tableName, partition)); + } + return result; + } + + private static Partition toHivePartition(String dbName, String tableName, + HmsPartitionWithStatistics partitionWithStatistics) { + Partition partition = new Partition(); + partition.setDbName(dbName); + partition.setTableName(tableName); + partition.setValues(partitionWithStatistics.getPartitionValues()); + partition.setSd(toHivePartitionStorageDesc(tableName, partitionWithStatistics)); + partition.setParameters(toStatisticsParameters( + partitionWithStatistics.getParameters(), + partitionWithStatistics.getStatistics().getCommonStatistics())); + return partition; + } + + private static StorageDescriptor toHivePartitionStorageDesc(String tableName, + HmsPartitionWithStatistics partition) { + SerDeInfo serdeInfo = new SerDeInfo(); + serdeInfo.setName(tableName); + serdeInfo.setSerializationLib(partition.getSerde()); + + StorageDescriptor sd = new StorageDescriptor(); + sd.setLocation(emptyToNull(partition.getLocation())); + sd.setCols(partition.getColumns()); + sd.setSerdeInfo(serdeInfo); + sd.setInputFormat(partition.getInputFormat()); + sd.setOutputFormat(partition.getOutputFormat()); + sd.setParameters(new HashMap<>()); + return sd; + } + + /** + * Merge basic statistics into a copy of the given parameters. Faithful port of legacy + * {@code HiveUtil.updateStatisticsParameters} (numRows / totalSize / numFiles, plus the CDH + * stats-task workaround flag). + */ + public static Map toStatisticsParameters(Map parameters, + HmsCommonStatistics statistics) { + Map result = new HashMap<>(parameters); + result.put(StatsSetupConst.NUM_FILES, String.valueOf(statistics.getFileCount())); + result.put(StatsSetupConst.ROW_COUNT, String.valueOf(statistics.getRowCount())); + result.put(StatsSetupConst.TOTAL_SIZE, String.valueOf(statistics.getTotalFileBytes())); + // CDH 5.16 metastore ignores stats unless STATS_GENERATED_VIA_STATS_TASK is set. + if (!parameters.containsKey("STATS_GENERATED_VIA_STATS_TASK")) { + result.put("STATS_GENERATED_VIA_STATS_TASK", + "workaround for potential lack of HIVE-12730"); + } + return result; + } + + /** + * Read basic statistics out of table/partition parameters. Faithful port of legacy + * {@code HiveUtil.toHivePartitionStatistics}. + */ + public static HmsPartitionStatistics toPartitionStatistics(Map params) { + long rowCount = Long.parseLong(params.getOrDefault(StatsSetupConst.ROW_COUNT, "-1")); + long totalSize = Long.parseLong(params.getOrDefault(StatsSetupConst.TOTAL_SIZE, "-1")); + long numFiles = Long.parseLong(params.getOrDefault(StatsSetupConst.NUM_FILES, "-1")); + return HmsPartitionStatistics.fromCommonStatistics(rowCount, numFiles, totalSize); + } + + private static void setCompressType(HmsCreateTableRequest req, Map props) { + String fileFormat = req.getFileFormat(); + String compression = props.get(COMPRESSION_KEY); + // on HMS, default orc compression type is zlib and default parquet compression type is snappy. + if (fileFormat.equalsIgnoreCase("parquet")) { + if (isNotEmpty(compression) && !SUPPORTED_PARQUET_COMPRESSIONS.contains(compression)) { + throw new IllegalArgumentException("Unsupported parquet compression type " + compression); + } + props.putIfAbsent("parquet.compression", isEmpty(compression) ? "snappy" : compression); + } else if (fileFormat.equalsIgnoreCase("orc")) { + if (isNotEmpty(compression) && !SUPPORTED_ORC_COMPRESSIONS.contains(compression)) { + throw new IllegalArgumentException("Unsupported orc compression type " + compression); + } + props.putIfAbsent("orc.compress", isEmpty(compression) ? "zlib" : compression); + } else if (fileFormat.equalsIgnoreCase("text")) { + if (isNotEmpty(compression) && !SUPPORTED_TEXT_COMPRESSIONS.contains(compression)) { + throw new IllegalArgumentException("Unsupported text compression type " + compression); + } + String textDefault = isEmpty(req.getDefaultTextCompression()) + ? DEFAULT_TEXT_COMPRESSION : req.getDefaultTextCompression(); + props.putIfAbsent("text.compression", isEmpty(compression) ? textDefault : compression); + } else { + throw new IllegalArgumentException("Compression is not supported on " + fileFormat); + } + // remove if exists + props.remove(COMPRESSION_KEY); + } + + private static StorageDescriptor toHiveStorageDesc(List columns, + List bucketCols, int numBuckets, String fileFormat, String location) { + StorageDescriptor sd = new StorageDescriptor(); + sd.setCols(columns); + setFileFormat(fileFormat, sd); + if (location != null) { + sd.setLocation(location); + } + sd.setBucketCols(bucketCols); + sd.setNumBuckets(numBuckets); + Map parameters = new HashMap<>(); + parameters.put("tag", "doris external hive table"); + sd.setParameters(parameters); + return sd; + } + + private static void setFileFormat(String fileFormat, StorageDescriptor sd) { + String inputFormat; + String outputFormat; + String serDe; + if (fileFormat.equalsIgnoreCase("orc")) { + inputFormat = "org.apache.hadoop.hive.ql.io.orc.OrcInputFormat"; + outputFormat = "org.apache.hadoop.hive.ql.io.orc.OrcOutputFormat"; + serDe = "org.apache.hadoop.hive.ql.io.orc.OrcSerde"; + } else if (fileFormat.equalsIgnoreCase("parquet")) { + inputFormat = "org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat"; + outputFormat = "org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat"; + serDe = "org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe"; + } else if (fileFormat.equalsIgnoreCase("text")) { + inputFormat = "org.apache.hadoop.mapred.TextInputFormat"; + outputFormat = "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat"; + serDe = "org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe"; + } else { + throw new IllegalArgumentException("Creating table with an unsupported file format: " + fileFormat); + } + SerDeInfo serDeInfo = new SerDeInfo(); + serDeInfo.setSerializationLib(serDe); + sd.setSerdeInfo(serDeInfo); + sd.setInputFormat(inputFormat); + sd.setOutputFormat(outputFormat); + } + + private static SchemaSplit toHiveSchema(List columns, Set partitionSet) { + List hiveCols = new ArrayList<>(); + List hiveParts = new ArrayList<>(); + for (ConnectorColumn column : columns) { + FieldSchema hiveFieldSchema = new FieldSchema(); + hiveFieldSchema.setType(HmsTypeMapping.toHiveTypeString(column.getType())); + hiveFieldSchema.setName(column.getName()); + hiveFieldSchema.setComment(column.getComment()); + if (partitionSet.contains(column.getName())) { + hiveParts.add(hiveFieldSchema); + } else { + hiveCols.add(hiveFieldSchema); + } + } + return new SchemaSplit(hiveCols, hiveParts); + } + + // Faithful port of fe-core HiveProperties.setTableProperties: serde keys land on the SerDe params, + // everything else on the table params. + private static void setTableProperties(Table table, Map properties) { + Map serdeProps = new HashMap<>(); + Map tblProps = new HashMap<>(); + for (String k : properties.keySet()) { + if (HIVE_SERDE_PROPERTIES.contains(k)) { + serdeProps.put(k, properties.get(k)); + } else { + tblProps.put(k, properties.get(k)); + } + } + if (table.getParameters() == null) { + table.setParameters(tblProps); + } else { + table.getParameters().putAll(tblProps); + } + if (table.getSd().getSerdeInfo().getParameters() == null) { + table.getSd().getSerdeInfo().setParameters(serdeProps); + } else { + table.getSd().getSerdeInfo().getParameters().putAll(serdeProps); + } + } + + private static boolean isEmpty(String s) { + return s == null || s.isEmpty(); + } + + private static String emptyToNull(String s) { + return isEmpty(s) ? null : s; + } + + private static boolean isNotEmpty(String s) { + return !isEmpty(s); + } + + private static Set unmodifiableSet(String... values) { + return Collections.unmodifiableSet(new HashSet<>(Arrays.asList(values))); + } + + /** Data columns and partition-key columns split out of the full column list. */ + private static final class SchemaSplit { + private final List dataColumns; + private final List partitionColumns; + + private SchemaSplit(List dataColumns, List partitionColumns) { + this.dataColumns = dataColumns; + this.partitionColumns = partitionColumns; + } + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/ThriftHmsClient.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/ThriftHmsClient.java index 401c9170dab16b..16bd89621b29a1 100644 --- a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/ThriftHmsClient.java +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/ThriftHmsClient.java @@ -25,28 +25,56 @@ import org.apache.commons.pool2.impl.DefaultPooledObject; import org.apache.commons.pool2.impl.GenericObjectPool; import org.apache.commons.pool2.impl.GenericObjectPoolConfig; +import org.apache.hadoop.hive.common.ValidReadTxnList; +import org.apache.hadoop.hive.common.ValidReaderWriteIdList; +import org.apache.hadoop.hive.common.ValidTxnList; +import org.apache.hadoop.hive.common.ValidTxnWriteIdList; +import org.apache.hadoop.hive.common.ValidWriteIdList; import org.apache.hadoop.hive.conf.HiveConf; import org.apache.hadoop.hive.metastore.HiveMetaHookLoader; import org.apache.hadoop.hive.metastore.HiveMetaStoreClient; import org.apache.hadoop.hive.metastore.IMetaStoreClient; +import org.apache.hadoop.hive.metastore.LockComponentBuilder; +import org.apache.hadoop.hive.metastore.LockRequestBuilder; import org.apache.hadoop.hive.metastore.RetryingMetaStoreClient; +import org.apache.hadoop.hive.metastore.api.ColumnStatisticsData; +import org.apache.hadoop.hive.metastore.api.ColumnStatisticsObj; +import org.apache.hadoop.hive.metastore.api.CurrentNotificationEventId; +import org.apache.hadoop.hive.metastore.api.DataOperationType; import org.apache.hadoop.hive.metastore.api.Database; +import org.apache.hadoop.hive.metastore.api.DateColumnStatsData; +import org.apache.hadoop.hive.metastore.api.DecimalColumnStatsData; +import org.apache.hadoop.hive.metastore.api.DoubleColumnStatsData; import org.apache.hadoop.hive.metastore.api.FieldSchema; +import org.apache.hadoop.hive.metastore.api.LockComponent; +import org.apache.hadoop.hive.metastore.api.LockRequest; +import org.apache.hadoop.hive.metastore.api.LockResponse; +import org.apache.hadoop.hive.metastore.api.LockState; +import org.apache.hadoop.hive.metastore.api.LongColumnStatsData; import org.apache.hadoop.hive.metastore.api.MetaException; +import org.apache.hadoop.hive.metastore.api.NoSuchObjectException; +import org.apache.hadoop.hive.metastore.api.NotificationEvent; +import org.apache.hadoop.hive.metastore.api.NotificationEventResponse; import org.apache.hadoop.hive.metastore.api.Partition; +import org.apache.hadoop.hive.metastore.api.SQLDefaultConstraint; import org.apache.hadoop.hive.metastore.api.StorageDescriptor; +import org.apache.hadoop.hive.metastore.api.StringColumnStatsData; import org.apache.hadoop.hive.metastore.api.Table; +import org.apache.hadoop.hive.metastore.api.TableValidWriteIds; +import org.apache.hadoop.hive.metastore.txn.TxnUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import shade.doris.hive.org.apache.thrift.TApplicationException; import java.io.IOException; import java.util.ArrayList; +import java.util.BitSet; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Callable; +import java.util.function.Function; import java.util.stream.Collectors; /** @@ -54,8 +82,7 @@ * *

This mirrors fe-core's {@code ThriftHMSCachedClient} but returns * SPI types ({@link HmsTableInfo}, {@link ConnectorColumn}, etc.) - * instead of fe-core's Column/DatabaseMetadata. It supports standard - * HMS, Alibaba DLF, and AWS Glue metastores.

+ * instead of fe-core's Column/DatabaseMetadata.

* *

Each method borrows a client from the pool, executes the HMS * operation, and returns the client. If an error occurs, the client is @@ -69,8 +96,9 @@ public class ThriftHmsClient implements HmsClient { private static final Logger LOG = LogManager.getLogger(ThriftHmsClient.class); private static final HiveMetaHookLoader DUMMY_HOOK_LOADER = tbl -> null; - private static final int MAX_LIST_PARTITION_NUM = Short.MAX_VALUE; private static final long POOL_BORROW_TIMEOUT_MS = 60_000L; + private static final int ADD_PARTITIONS_BATCH_SIZE = 20; + private static final String TRANSIENT_LAST_DDL_TIME = "transient_lastDdlTime"; private final HiveConf hiveConf; private final GenericObjectPool clientPool; @@ -184,10 +212,28 @@ public Map getDefaultColumnValues(String dbName, @Override public List listPartitionNames(String dbName, String tableName, int maxParts) { - int limit = maxParts <= 0 ? MAX_LIST_PARTITION_NUM : maxParts; return execute( client -> client.listPartitionNames(dbName, tableName, - (short) limit)); + toThriftMaxParts(maxParts))); + } + + /** + * Maps the connector's {@code maxParts} contract onto the {@code short max_parts} that HMS + * {@code get_partition_names} accepts. + * + *

A non-positive {@code maxParts} means "all partitions" and maps to {@code -1}: HMS treats a + * negative {@code max_parts} as unbounded. It must NOT be clamped to {@code Short.MAX_VALUE} — that + * silently truncates a table with more than 32767 partitions and defeats the {@code -1} "unlimited" + * contract the hive/hudi callers rely on (the legacy {@code ThriftHMSCachedClient} passed the + * {@code Config.max_hive_list_partition_num} default of {@code -1} straight through to HMS, i.e. + * unbounded).

+ * + *

A positive value is passed through, narrowing to {@code short}; a value above + * {@code Short.MAX_VALUE} narrows to a negative {@code short}, which HMS likewise treats as unbounded — + * the pre-existing behavior of the callers that request up to {@code 100000} partitions.

+ */ + static short toThriftMaxParts(int maxParts) { + return maxParts <= 0 ? (short) -1 : (short) maxParts; } @Override @@ -212,6 +258,339 @@ public HmsPartitionInfo getPartition(String dbName, String tableName, }); } + @Override + public List getTableColumnStatistics(String dbName, String tableName, + List columns) { + List stats = execute( + client -> client.getTableColumnStatistics(dbName, tableName, columns)); + List result = new ArrayList<>(stats.size()); + for (ColumnStatisticsObj obj : stats) { + result.add(convertColumnStatistics(obj)); + } + return result; + } + + /** + * Extracts the neutral ndv / numNulls / avgColLen from a hive {@code ColumnStatisticsObj}, a byte-faithful + * port of legacy {@code HMSExternalTable.setStatData}'s per-variant reads: {@code avgColLen} is captured + * only for a string column (non-string columns leave it -1 so the consumer uses the fixed slot width), and + * a variant the legacy reader does not handle (boolean / binary / timestamp) yields ndv=0 / numNulls=0. + */ + static HmsColumnStatistics convertColumnStatistics(ColumnStatisticsObj obj) { + long ndv = 0; + long numNulls = 0; + double avgColLen = -1; + if (obj.isSetStatsData()) { + ColumnStatisticsData data = obj.getStatsData(); + if (data.isSetStringStats()) { + StringColumnStatsData stringStats = data.getStringStats(); + ndv = stringStats.getNumDVs(); + numNulls = stringStats.getNumNulls(); + avgColLen = stringStats.getAvgColLen(); + } else if (data.isSetLongStats()) { + LongColumnStatsData longStats = data.getLongStats(); + ndv = longStats.getNumDVs(); + numNulls = longStats.getNumNulls(); + } else if (data.isSetDecimalStats()) { + DecimalColumnStatsData decimalStats = data.getDecimalStats(); + ndv = decimalStats.getNumDVs(); + numNulls = decimalStats.getNumNulls(); + } else if (data.isSetDoubleStats()) { + DoubleColumnStatsData doubleStats = data.getDoubleStats(); + ndv = doubleStats.getNumDVs(); + numNulls = doubleStats.getNumNulls(); + } else if (data.isSetDateStats()) { + DateColumnStatsData dateStats = data.getDateStats(); + ndv = dateStats.getNumDVs(); + numNulls = dateStats.getNumNulls(); + } + } + return new HmsColumnStatistics(obj.getColName(), ndv, numNulls, avgColLen); + } + + // ========== Phase 3: DDL / write operations ========== + + @Override + public void createDatabase(HmsCreateDatabaseRequest request) { + execute(client -> { + client.createDatabase(HmsWriteConverter.toHiveDatabase(request)); + return null; + }); + } + + @Override + public void dropDatabase(String dbName) { + execute(client -> { + client.dropDatabase(dbName); + return null; + }); + } + + @Override + public void createTable(HmsCreateTableRequest request) { + if (tableExists(request.getDbName(), request.getTableName())) { + throw new HmsClientException("Table '" + request.getTableName() + + "' has existed in '" + request.getDbName() + "'."); + } + Table hiveTable = HmsWriteConverter.toHiveTable(request); + List defaults = buildDefaultConstraints(request); + execute(client -> { + if (!defaults.isEmpty()) { + // foreignKeys, uniqueConstraints, notNullConstraints, defaultConstraints, checkConstraints + client.createTableWithConstraints(hiveTable, null, null, null, null, defaults, null); + } else { + client.createTable(hiveTable); + } + return null; + }); + } + + @Override + public void dropTable(String dbName, String tableName) { + execute(client -> { + client.dropTable(dbName, tableName); + return null; + }); + } + + @Override + public void truncateTable(String dbName, String tableName, List partitions) { + execute(client -> { + client.truncateTable(dbName, tableName, partitions); + return null; + }); + } + + private static List buildDefaultConstraints(HmsCreateTableRequest request) { + List defaults = new ArrayList<>(); + for (ConnectorColumn column : request.getColumns()) { + String defaultValue = column.getDefaultValue(); + if (defaultValue != null) { + SQLDefaultConstraint dv = new SQLDefaultConstraint(); + dv.setTable_db(request.getDbName()); + dv.setTable_name(request.getTableName()); + dv.setColumn_name(column.getName()); + dv.setDefault_value(defaultValue); + dv.setDc_name(column.getName() + "_dv_constraint"); + defaults.add(dv); + } + } + return defaults; + } + + // ========== Phase 3: partition / statistics writes ========== + + @Override + public void addPartitions(String dbName, String tableName, + List partitions) { + execute(client -> { + List hivePartitions = + HmsWriteConverter.toHivePartitions(dbName, tableName, partitions); + for (int i = 0; i < hivePartitions.size(); i += ADD_PARTITIONS_BATCH_SIZE) { + int end = Math.min(i + ADD_PARTITIONS_BATCH_SIZE, hivePartitions.size()); + client.add_partitions(new ArrayList<>(hivePartitions.subList(i, end))); + } + return null; + }); + } + + @Override + public void updateTableStatistics(String dbName, String tableName, + Function update) { + execute(client -> { + Table originTable = client.getTable(dbName, tableName); + Map originParams = originTable.getParameters(); + HmsPartitionStatistics updatedStats = + update.apply(HmsWriteConverter.toPartitionStatistics(originParams)); + Table newTable = originTable.deepCopy(); + Map newParams = HmsWriteConverter.toStatisticsParameters( + originParams, updatedStats.getCommonStatistics()); + newParams.put(TRANSIENT_LAST_DDL_TIME, + String.valueOf(System.currentTimeMillis() / 1000)); + newTable.setParameters(newParams); + client.alter_table(dbName, tableName, newTable); + return null; + }); + } + + @Override + public void updatePartitionStatistics(String dbName, String tableName, String partitionName, + Function update) { + execute(client -> { + List partitions = client.getPartitionsByNames( + dbName, tableName, Collections.singletonList(partitionName)); + if (partitions.size() != 1) { + throw new HmsClientException( + "Metastore returned multiple partitions for name: " + partitionName); + } + Partition originPartition = partitions.get(0); + Map originParams = originPartition.getParameters(); + HmsPartitionStatistics updatedStats = + update.apply(HmsWriteConverter.toPartitionStatistics(originParams)); + Partition modifiedPartition = originPartition.deepCopy(); + Map newParams = HmsWriteConverter.toStatisticsParameters( + originParams, updatedStats.getCommonStatistics()); + newParams.put(TRANSIENT_LAST_DDL_TIME, + String.valueOf(System.currentTimeMillis() / 1000)); + modifiedPartition.setParameters(newParams); + client.alter_partition(dbName, tableName, modifiedPartition); + return null; + }); + } + + @Override + public boolean dropPartition(String dbName, String tableName, + List partitionValues, boolean deleteData) { + return execute(client -> + client.dropPartition(dbName, tableName, partitionValues, deleteData)); + } + + @Override + public boolean partitionExists(String dbName, String tableName, + List partitionValues) { + return execute(client -> { + try { + client.getPartition(dbName, tableName, partitionValues); + return Boolean.TRUE; + } catch (NoSuchObjectException e) { + // Not found: a normal answer, not a client fault — do not taint the pooled client. + return Boolean.FALSE; + } + }); + } + + // ========== Phase 4: ACID transactions ========== + + @Override + public long openTxn(String user) { + return execute(client -> client.openTxn(user)); + } + + @Override + public void commitTxn(long txnId) { + execute(client -> { + client.commitTxn(txnId); + return null; + }); + } + + @Override + public Map getValidWriteIds(String fullTableName, long currentTransactionId) { + Map conf = new HashMap<>(); + try { + return execute(client -> { + // Use the recent snapshot of valid transactions (no currentTxn arg): passing + // currentTransactionId here would break Hive's delta-directory listing if a major + // compaction removed deltas for transactions that were valid when this txn opened. + ValidTxnList validTransactions = client.getValidTxns(); + List tableValidWriteIdsList = client.getValidWriteIds( + Collections.singletonList(fullTableName), validTransactions.toString()); + if (tableValidWriteIdsList.size() != 1) { + throw new HmsClientException("tableValidWriteIdsList's size should be 1"); + } + ValidTxnWriteIdList validTxnWriteIdList = TxnUtils.createValidTxnWriteIdList( + currentTransactionId, tableValidWriteIdsList); + ValidWriteIdList writeIdList = + validTxnWriteIdList.getTableValidWriteIdList(fullTableName); + conf.put(HmsAcidConstants.VALID_TXNS_KEY, validTransactions.writeToString()); + conf.put(HmsAcidConstants.VALID_WRITEIDS_KEY, writeIdList.writeToString()); + return conf; + }); + } catch (Exception e) { + // Older / incompatible metastores may lack these APIs; degrade to a max watermark + // instead of failing the scan (mirrors legacy ThriftHMSCachedClient). + LOG.warn("failed to get valid write ids for {}, transaction {}", + fullTableName, currentTransactionId, e); + ValidTxnList validTransactions = new ValidReadTxnList( + new long[0], new BitSet(), Long.MAX_VALUE, Long.MAX_VALUE); + ValidWriteIdList writeIdList = new ValidReaderWriteIdList( + fullTableName, new long[0], new BitSet(), Long.MAX_VALUE); + conf.put(HmsAcidConstants.VALID_TXNS_KEY, validTransactions.writeToString()); + conf.put(HmsAcidConstants.VALID_WRITEIDS_KEY, writeIdList.writeToString()); + return conf; + } + } + + @Override + public void acquireSharedLock(String queryId, long txnId, String user, String dbName, + String tableName, List partitionNames, long timeoutMs) { + LockRequestBuilder requestBuilder = new LockRequestBuilder(queryId) + .setTransactionId(txnId) + .setUser(user); + for (LockComponent component + : createLockComponentsForRead(dbName, tableName, partitionNames)) { + requestBuilder.addLockComponent(component); + } + LockRequest lockRequest = requestBuilder.build(); + LockResponse response = execute(client -> client.lock(lockRequest)); + long start = System.currentTimeMillis(); + while (response.getState() == LockState.WAITING) { + if (System.currentTimeMillis() - start > timeoutMs) { + throw new HmsClientException("acquire lock timeout for txn " + txnId + + " of query " + queryId + ", timeout(ms): " + timeoutMs); + } + long lockId = response.getLockid(); + response = execute(client -> client.checkLock(lockId)); + } + if (response.getState() != LockState.ACQUIRED) { + throw new HmsClientException( + "failed to acquire lock, lock in state " + response.getState()); + } + } + + @Override + public long getCurrentNotificationEventId() { + return execute(client -> { + CurrentNotificationEventId id = client.getCurrentNotificationEventId(); + return id == null ? -1L : id.getEventId(); + }); + } + + @Override + public List getNextNotification(long lastEventId, int maxEvents) { + return execute(client -> { + NotificationEventResponse response = + client.getNextNotification(lastEventId, maxEvents, null); + List events = new ArrayList<>(); + if (response != null && response.getEvents() != null) { + for (NotificationEvent event : response.getEvents()) { + events.add(new HmsNotificationEvent(event.getEventId(), event.getEventType(), + event.getDbName(), event.getTableName(), event.getMessage(), + event.getMessageFormat(), event.getEventTime())); + } + } + return events; + }); + } + + private static List createLockComponentsForRead(String dbName, String tableName, + List partitionNames) { + List components = new ArrayList<>( + partitionNames.isEmpty() ? 1 : partitionNames.size()); + if (partitionNames.isEmpty()) { + components.add(createLockComponentForRead(dbName, tableName, null)); + } else { + for (String partitionName : partitionNames) { + components.add(createLockComponentForRead(dbName, tableName, partitionName)); + } + } + return components; + } + + private static LockComponent createLockComponentForRead(String dbName, String tableName, + String partitionName) { + LockComponentBuilder builder = new LockComponentBuilder(); + builder.setShared(); + builder.setOperationType(DataOperationType.SELECT); + builder.setDbName(dbName); + builder.setTableName(tableName); + if (partitionName != null) { + builder.setPartitionName(partitionName); + } + builder.setIsTransactional(true); + return builder.build(); + } + @Override public synchronized void close() throws IOException { if (closed) { @@ -251,8 +630,17 @@ private T execute(HmsAction action) { private T doAs(Callable callable) throws Exception { ClassLoader original = Thread.currentThread().getContextClassLoader(); try { - Thread.currentThread().setContextClassLoader( - ClassLoader.getSystemClassLoader()); + // Pin the TCCL to the plugin (child-first) classloader that loaded THIS client — NOT the system + // classloader. Metastore client creation runs Hadoop's SecurityUtil., whose internal + // `new Configuration()` captures the current TCCL and uses it to reflectively load + // DNSDomainNameResolver. The system classloader holds fe-core's own hadoop copy, while + // SecurityUtil/DomainNameResolver here resolve from the plugin's child-first copy — so a + // system-loader TCCL loads the two from different loaders ("class DNSDomainNameResolver not + // DomainNameResolver") and permanently poisons SecurityUtil JVM-wide. Pinning the plugin loader + // (a strict superset of the system loader) keeps every reflective load on the plugin side. Mirrors + // iceberg/paimon TcclPinningConnectorContext and HiveConnectorMetadata's stats pins; covers both the + // non-Kerberos (context.executeAuthenticated) and Kerberos (ugi.doAs) authAction paths. + Thread.currentThread().setContextClassLoader(getClass().getClassLoader()); return authAction.execute(callable); } finally { Thread.currentThread().setContextClassLoader(original); @@ -273,13 +661,18 @@ private HmsTableInfo convertTable(Table table) { .tableName(table.getTableName()) .tableType(table.getTableType()) .parameters(table.getParameters()) + // View text (null for a base table) — the hive VIEW SPI's isView signal + view-SQL source. + .viewOriginalText(table.getViewOriginalText()) + .viewExpandedText(table.getViewExpandedText()) .columns(columns) .partitionKeys(partKeys); if (sd != null) { builder.location(sd.getLocation()) .inputFormat(sd.getInputFormat()) - .outputFormat(sd.getOutputFormat()); + .outputFormat(sd.getOutputFormat()) + .bucketCols(sd.getBucketCols()) + .numBuckets(sd.getNumBuckets()); if (sd.getSerdeInfo() != null) { builder.serializationLib( sd.getSerdeInfo().getSerializationLib()); @@ -300,8 +693,10 @@ private List convertFieldSchemas( for (FieldSchema fs : schemas) { ConnectorType type = HmsTypeMapping.toConnectorType( fs.getType(), typeMappingOptions); + // isKey=true: external-table semantics (legacy HMSExternalTable and the iceberg connector both + // mark external columns as key so DESC shows Key=true). The 5-arg ctor defaults isKey=false. result.add(new ConnectorColumn( - fs.getName(), type, fs.getComment(), true, null)); + fs.getName(), type, fs.getComment(), true, null, true)); } return result; } @@ -327,8 +722,8 @@ private PooledHmsClient borrowClient() { try { return clientPool.borrowObject(); } catch (Exception e) { - throw new HmsClientException("Failed to borrow HMS client " - + "from pool: " + e.getMessage(), e); + throw new HmsClientException(withRootCause("Failed to borrow HMS client " + + "from pool: " + e.getMessage(), e), e); } } @@ -337,9 +732,41 @@ private PooledHmsClient createFreshClient() { return doAs(() -> new PooledHmsClient( clientProvider.create(hiveConf))); } catch (Exception e) { - throw new HmsClientException("Failed to create HMS client: " - + e.getMessage(), e); + throw new HmsClientException(withRootCause("Failed to create HMS client: " + + e.getMessage(), e), e); + } + } + + /** + * Appends the deepest cause's message to {@code message} so the actionable reason survives. + * + *

WHY: Hive wraps the real connection failure (e.g. a thrift {@code TTransportException: + * GSS initiate failed} from a SASL/kerberos misconfiguration) inside a generic + * {@code RuntimeException("Unable to instantiate ...HiveMetaStoreClient")} whose own + * {@link Throwable#getMessage()} drops that cause. FE surfaces only the top exception's message, + * so without this the user (and regression assertions) would see "Unable to instantiate ..." + * with no hint of the SASL/GSS/transport root cause. This mirrors the legacy + * {@code ThriftHMSCachedClient}/{@code HMSClientException}, which appended + * {@code Util.getRootCauseMessage(cause)} in the same {@code className: message} form. + * + *

The guard avoids duplicating the reason when a fresh-client failure is re-wrapped by the + * pool's {@link #borrowClient()} (the inner message already carries the appended root cause). + */ + static String withRootCause(String message, Throwable cause) { + if (cause == null) { + return message; + } + Throwable root = cause; + while (root.getCause() != null && root.getCause() != root) { + root = root.getCause(); + } + String rootMessage = root.getMessage(); + String rootDescription = root.getClass().getName() + + (rootMessage == null ? "" : ": " + rootMessage); + if (message != null && message.contains(rootDescription)) { + return message; } + return message + ". reason: " + rootDescription; } private GenericObjectPoolConfig createPoolConfig( @@ -470,10 +897,7 @@ interface MetaStoreClientProvider { IMetaStoreClient create(HiveConf hiveConf) throws MetaException; } - /** - * Default provider using RetryingMetaStoreClient with - * auto-detection of standard HMS, DLF, or Glue. - */ + /** Default provider using RetryingMetaStoreClient over the standard HMS thrift client. */ private static class DefaultMetaStoreClientProvider implements MetaStoreClientProvider { @Override @@ -481,26 +905,7 @@ public IMetaStoreClient create(HiveConf hiveConf) throws MetaException { return RetryingMetaStoreClient.getProxy( hiveConf, DUMMY_HOOK_LOADER, - getMetastoreClientClassName(hiveConf)); - } - } - - /** Alibaba Cloud DLF ProxyMetaStoreClient class name. */ - private static final String DLF_CLIENT_CLASS = - "com.aliyun.datalake.metastore.hive2.ProxyMetaStoreClient"; - - /** AWS Glue AWSCatalogMetastoreClient class name. */ - private static final String GLUE_CLIENT_CLASS = - "com.amazonaws.glue.catalog.metastore.AWSCatalogMetastoreClient"; - - static String getMetastoreClientClassName(HiveConf hiveConf) { - String type = hiveConf.get(HmsClientConfig.METASTORE_TYPE_KEY); - if (HmsClientConfig.METASTORE_TYPE_DLF.equalsIgnoreCase(type)) { - return DLF_CLIENT_CLASS; - } else if (HmsClientConfig.METASTORE_TYPE_GLUE.equalsIgnoreCase(type)) { - return GLUE_CLIENT_CLASS; - } else { - return HiveMetaStoreClient.class.getName(); + HiveMetaStoreClient.class.getName()); } } } diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/event/HmsEventParser.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/event/HmsEventParser.java new file mode 100644 index 00000000000000..a407bd62092a24 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/event/HmsEventParser.java @@ -0,0 +1,264 @@ +// 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.hms.event; + +import org.apache.doris.connector.api.event.MetastoreChangeDescriptor; +import org.apache.doris.connector.api.event.MetastoreChangeDescriptor.Op; +import org.apache.doris.connector.hms.HmsNotificationEvent; + +import org.apache.commons.io.IOUtils; +import org.apache.hadoop.hive.common.FileUtils; +import org.apache.hadoop.hive.metastore.api.Database; +import org.apache.hadoop.hive.metastore.api.FieldSchema; +import org.apache.hadoop.hive.metastore.api.Partition; +import org.apache.hadoop.hive.metastore.api.Table; +import org.apache.hadoop.hive.metastore.messaging.AddPartitionMessage; +import org.apache.hadoop.hive.metastore.messaging.AlterPartitionMessage; +import org.apache.hadoop.hive.metastore.messaging.CreateTableMessage; +import org.apache.hadoop.hive.metastore.messaging.DropPartitionMessage; +import org.apache.hadoop.hive.metastore.messaging.json.JSONAlterDatabaseMessage; +import org.apache.hadoop.hive.metastore.messaging.json.JSONAlterTableMessage; +import org.apache.hadoop.hive.metastore.messaging.json.JSONDropTableMessage; +import org.apache.hadoop.hive.metastore.messaging.json.JSONMessageDeserializer; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.zip.GZIPInputStream; + +/** + * Parses one HMS {@link HmsNotificationEvent} into connector-neutral {@link MetastoreChangeDescriptor}s. + * + *

This is the plugin-side half of the metastore-event relocation: it replaces the fe-core + * {@code datasource.hive.event.*} classes' {@code process()} bodies, but instead of mutating the + * engine's object graph it emits neutral descriptors the engine applies. It preserves the legacy + * semantics faithfully (table-name lowercasing, rename vs view-recreate, alter-partition rename = + * drop+add, canonical partition names), so a flipped catalog behaves as the legacy poller did.

+ * + *

The GZIP message format (some HDP/CDH Hive versions) base64+gzip-wraps the JSON payload; we + * decompress it before handing the plain {@link JSONMessageDeserializer} the body, avoiding a bespoke + * deserializer subclass.

+ */ +public final class HmsEventParser { + + private static final JSONMessageDeserializer JSON = new JSONMessageDeserializer(); + private static final String GZIP_FORMAT_PREFIX = "gzip"; + + private HmsEventParser() { + } + + /** + * Map one notification event to zero or more descriptors (empty for unsupported / no-op events, + * e.g. a properties-only ALTER DATABASE or a transaction event). + */ + public static List parse(HmsNotificationEvent event) { + try { + return doParse(event); + } catch (Exception e) { + throw new RuntimeException("failed to parse HMS notification event " + event, e); + } + } + + private static List doParse(HmsNotificationEvent event) throws Exception { + String type = event.getEventType(); + if (type == null) { + return Collections.emptyList(); + } + String dbName = lower(event.getDbName()); + String tblName = event.getTableName(); + long eventId = event.getEventId(); + // Hive records event time in seconds; the engine tracks freshness in millis. + long updateTime = event.getEventTime() * 1000L; + // Decompress/parse the message body ONLY for event types that actually read it — mirrors the legacy + // events, which deserialize lazily inside the supported-event ctors. So a db-level / INSERT / ignored + // (e.g. high-frequency ACID txn) event never touches the payload: no wasted gzip work, and a corrupt + // body on an event Doris ignores can never throw. + String body = needsBody(type) ? prepareBody(event.getMessageFormat(), event.getMessage()) : null; + + switch (type) { + case "CREATE_TABLE": { + CreateTableMessage message = JSON.getCreateTableMessage(body); + String table = message.getTableObj().getTableName().toLowerCase(Locale.ROOT); + return one(MetastoreChangeDescriptor.forTable( + Op.REGISTER_TABLE, dbName, table, null, eventId, updateTime)); + } + case "DROP_TABLE": { + JSONDropTableMessage message = (JSONDropTableMessage) JSON.getDropTableMessage(body); + return one(MetastoreChangeDescriptor.forTable( + Op.UNREGISTER_TABLE, dbName, message.getTable(), null, eventId, updateTime)); + } + case "ALTER_TABLE": { + JSONAlterTableMessage message = (JSONAlterTableMessage) JSON.getAlterTableMessage(body); + Table after = message.getTableObjAfter(); + Table before = message.getTableObjBefore(); + String afterTable = after.getTableName().toLowerCase(Locale.ROOT); + boolean isRename = !before.getDbName().equalsIgnoreCase(after.getDbName()) + || !before.getTableName().equalsIgnoreCase(afterTable); + boolean isView = before.isSetViewExpandedText() || before.isSetViewOriginalText(); + if (isRename || isView) { + // rename (possibly across dbs) or view-recreate (same name) => unregister+register + return one(MetastoreChangeDescriptor.forTableRename( + before.getDbName(), before.getTableName(), + after.getDbName(), afterTable, eventId, updateTime)); + } + return one(MetastoreChangeDescriptor.forTable( + Op.REFRESH_TABLE, before.getDbName(), before.getTableName(), null, + eventId, updateTime)); + } + case "CREATE_DATABASE": + return one(MetastoreChangeDescriptor.forDatabase( + Op.REGISTER_DATABASE, dbName, null, eventId, updateTime)); + case "DROP_DATABASE": + return one(MetastoreChangeDescriptor.forDatabase( + Op.UNREGISTER_DATABASE, dbName, null, eventId, updateTime)); + case "ALTER_DATABASE": { + JSONAlterDatabaseMessage message = + (JSONAlterDatabaseMessage) JSON.getAlterDatabaseMessage(body); + Database before = message.getDbObjBefore(); + Database after = message.getDbObjAfter(); + if (before.getName().equalsIgnoreCase(after.getName())) { + // properties-only change: nothing the engine must react to + return Collections.emptyList(); + } + return one(MetastoreChangeDescriptor.forDatabase( + Op.RENAME_DATABASE, before.getName(), after.getName(), eventId, updateTime)); + } + case "ADD_PARTITION": { + AddPartitionMessage message = JSON.getAddPartitionMessage(body); + Table table = message.getTableObj(); + List names = new ArrayList<>(); + List colNames = partitionColNames(table); + for (Partition partition : message.getPartitionObjs()) { + names.add(FileUtils.makePartName(colNames, partition.getValues())); + } + return one(MetastoreChangeDescriptor.forPartitions( + Op.ADD_PARTITIONS, dbName, table.getTableName(), names, eventId, updateTime)); + } + case "DROP_PARTITION": { + DropPartitionMessage message = JSON.getDropPartitionMessage(body); + Table table = message.getTableObj(); + List names = new ArrayList<>(); + List colNames = partitionColNames(table); + for (Map partition : message.getPartitions()) { + names.add(rawPartName(partition, colNames)); + } + return one(MetastoreChangeDescriptor.forPartitions( + Op.DROP_PARTITIONS, dbName, table.getTableName(), names, eventId, updateTime)); + } + case "ALTER_PARTITION": { + AlterPartitionMessage message = JSON.getAlterPartitionMessage(body); + Table table = message.getTableObj(); + List colNames = partitionColNames(table); + String before = FileUtils.makePartName(colNames, message.getPtnObjBefore().getValues()); + String after = FileUtils.makePartName(colNames, message.getPtnObjAfter().getValues()); + if (!before.equalsIgnoreCase(after)) { + // partition rename = drop old + add new (on the event's db/table) + List out = new ArrayList<>(2); + out.add(MetastoreChangeDescriptor.forPartitions( + Op.DROP_PARTITIONS, dbName, tblName, Collections.singletonList(before), + eventId, updateTime)); + out.add(MetastoreChangeDescriptor.forPartitions( + Op.ADD_PARTITIONS, dbName, tblName, Collections.singletonList(after), + eventId, updateTime)); + return out; + } + return one(MetastoreChangeDescriptor.forPartitions( + Op.REFRESH_PARTITIONS, dbName, table.getTableName(), + Collections.singletonList(after), eventId, updateTime)); + } + case "INSERT": + // non-partitioned insert: no partition event fires, just drop the table's caches + return one(MetastoreChangeDescriptor.forTable( + Op.REFRESH_TABLE, dbName, tblName, null, eventId, updateTime)); + default: + // ALTER_PARTITIONS / INSERT_PARTITIONS / ALLOC_WRITE_ID / COMMIT_TXN / ... => ignored + return Collections.emptyList(); + } + } + + private static List partitionColNames(Table table) { + return table.getPartitionKeys().stream().map(FieldSchema::getName).collect(Collectors.toList()); + } + + // Raw "col=val/col2=val2" name (mirrors the legacy DropPartition path, which does no escaping). + private static String rawPartName(Map part, List colNames) { + if (part.isEmpty() || colNames.size() != part.size()) { + return ""; + } + StringBuilder name = new StringBuilder(); + int i = 0; + for (String colName : colNames) { + if (i++ > 0) { + name.append('/'); + } + name.append(colName).append('=').append(part.get(colName)); + } + return name.toString(); + } + + private static String lower(String s) { + return (s == null || s.isEmpty()) ? s : s.toLowerCase(Locale.ROOT); + } + + private static List one(MetastoreChangeDescriptor descriptor) { + return Collections.singletonList(descriptor); + } + + // The event types whose descriptor mapping reads the (possibly gzip) message payload; all others + // (CREATE/DROP DATABASE, INSERT, and ignored/unsupported types) build their descriptor from the base + // event fields alone and never decompress. + private static boolean needsBody(String type) { + switch (type) { + case "CREATE_TABLE": + case "DROP_TABLE": + case "ALTER_TABLE": + case "ALTER_DATABASE": + case "ADD_PARTITION": + case "DROP_PARTITION": + case "ALTER_PARTITION": + return true; + default: + return false; + } + } + + private static String prepareBody(String format, String message) { + if (format != null && format.startsWith(GZIP_FORMAT_PREFIX)) { + return deCompress(message); + } + return message; + } + + private static String deCompress(String messageBody) { + try { + byte[] decodedBytes = Base64.getDecoder().decode(messageBody.getBytes(StandardCharsets.UTF_8)); + try (ByteArrayInputStream in = new ByteArrayInputStream(decodedBytes); + GZIPInputStream is = new GZIPInputStream(in)) { + return new String(IOUtils.toByteArray(is), StandardCharsets.UTF_8); + } + } catch (Exception e) { + throw new RuntimeException("cannot decode the gzip notification message", e); + } + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/event/HmsEventSource.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/event/HmsEventSource.java new file mode 100644 index 00000000000000..856c58e3713ddf --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/event/HmsEventSource.java @@ -0,0 +1,143 @@ +// 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.hms.event; + +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.hms.HmsClient; +import org.apache.doris.connector.hms.HmsClientException; +import org.apache.doris.connector.hms.HmsNotificationEvent; + +import org.apache.hadoop.hive.metastore.HiveMetaStoreClient; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.ArrayList; +import java.util.List; + +/** + * The hive connector's {@link ConnectorEventSource}: fetches HMS notification events and parses them + * into neutral descriptors. Stateless with respect to the cursor — the engine passes the cursor in and + * stores the returned one, so the same source instance serves both master (fetch-directly) and follower + * (bounded-by-master) roles. + * + *

Mirrors the legacy fe-core {@code MetastoreEventsProcessor} fetch decisions exactly: the master + * reads the metastore's current id to decide whether to pull; a follower pulls only up to the master's + * committed high-water mark; a first sync or a trimmed notification log (the + * {@code REPL_EVENTS_MISSING_IN_METASTORE} sentinel) yields a full-refresh signal instead of events.

+ */ +public class HmsEventSource implements ConnectorEventSource { + private static final Logger LOG = LogManager.getLogger(HmsEventSource.class); + + private final HmsClient client; + private final int batchSize; + + public HmsEventSource(HmsClient client, int batchSize) { + this.client = client; + this.batchSize = batchSize; + } + + @Override + public long getCurrentEventId() { + return client.getCurrentNotificationEventId(); + } + + @Override + public EventPollResult pollOnce(EventPollRequest request) { + if (request.isMaster()) { + return pollForMaster(request.getLastSyncedEventId()); + } + return pollForFollower(request.getLastSyncedEventId(), request.getMasterUpperBound()); + } + + private EventPollResult pollForMaster(long lastSyncedEventId) { + long currentEventId = client.getCurrentNotificationEventId(); + if (lastSyncedEventId < 0) { + // first pull: seed the cursor to now and rebuild via a full refresh (no events to replay) + return EventPollResult.ofFullRefresh(currentEventId); + } + if (currentEventId == lastSyncedEventId) { + return EventPollResult.ofNothing(lastSyncedEventId); + } + try { + return toResult(client.getNextNotification(lastSyncedEventId, batchSize), lastSyncedEventId); + } catch (HmsClientException e) { + if (isEventsMissing(e)) { + // the metastore trimmed its log past our cursor; jump to now and full-refresh + return EventPollResult.ofFullRefresh(currentEventId); + } + // transient fetch error (metastore blip): retry the same cursor next cycle without resetting or + // invalidating. A deterministic PARSE error is a RuntimeException, not an HmsClientException, so it + // propagates to the engine's self-heal instead of being swallowed here. + LOG.warn("Failed to fetch HMS notifications from event id {}; will retry", lastSyncedEventId, e); + return EventPollResult.ofNothing(lastSyncedEventId); + } + } + + private EventPollResult pollForFollower(long lastSyncedEventId, long masterUpperBound) { + // -1 => the master's cursor has not been learned yet (via edit-log replay); do nothing + if (masterUpperBound == -1L || lastSyncedEventId == masterUpperBound) { + return EventPollResult.ofNothing(lastSyncedEventId); + } + if (lastSyncedEventId < 0) { + // first pull: seed to the master's committed id and full-refresh (the engine forwards + // REFRESH CATALOG to the master for a follower) + return EventPollResult.ofFullRefresh(masterUpperBound); + } + // never read past what the master has already committed and replicated + int maxEvents = (int) Math.min(masterUpperBound - lastSyncedEventId, batchSize); + try { + return toResult(client.getNextNotification(lastSyncedEventId, maxEvents), lastSyncedEventId); + } catch (HmsClientException e) { + if (isEventsMissing(e)) { + return EventPollResult.ofFullRefresh(masterUpperBound); + } + // transient fetch error: retry the same cursor next cycle (see pollForMaster). + LOG.warn("Failed to fetch HMS notifications from event id {}; will retry", lastSyncedEventId, e); + return EventPollResult.ofNothing(lastSyncedEventId); + } + } + + private EventPollResult toResult(List events, long lastSyncedEventId) { + if (events.isEmpty()) { + return EventPollResult.ofNothing(lastSyncedEventId); + } + List descriptors = new ArrayList<>(); + for (HmsNotificationEvent event : events) { + descriptors.addAll(HmsEventParser.parse(event)); + } + long newCursor = events.get(events.size() - 1).getEventId(); + return EventPollResult.ofChanges(newCursor, descriptors); + } + + private static boolean isEventsMissing(HmsClientException e) { + // ThriftHmsClient.execute wraps the vendored client's + // IllegalStateException(REPL_EVENTS_MISSING_IN_METASTORE) into an HmsClientException; the + // sentinel survives in the message chain. + for (Throwable t = e; t != null; t = t.getCause()) { + String message = t.getMessage(); + if (message != null + && message.contains(HiveMetaStoreClient.REPL_EVENTS_MISSING_IN_METASTORE)) { + return true; + } + } + return false; + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveVersionUtil.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/datasource/hive/HiveVersionUtil.java similarity index 84% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveVersionUtil.java rename to fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/datasource/hive/HiveVersionUtil.java index 6f2346d3d80cc4..9c9a380eb6427c 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveVersionUtil.java +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/datasource/hive/HiveVersionUtil.java @@ -25,6 +25,13 @@ * For getting a compatible version of hive * if user specified the version, it will parse it and return the compatible HiveVersion, * otherwise, use DEFAULT_HIVE_VERSION + * + *

NOTE: verbatim copy of fe-core {@code org.apache.doris.datasource.hive.HiveVersionUtil}, vendored into + * the shared fe-connector-hms module. HMS-backed connector plugins run on their own child-first classloaders + * and cannot see fe-core classes, but the bundled copy of Doris's patched + * {@code org.apache.hadoop.hive.metastore.HiveMetaStoreClient} needs this to pick the Hive-version-aware + * catalog-name handling (Hive 1/2 metastores reject the Hive-3 {@code @cat#} db marker). Keep in sync with + * the fe-core original.

*/ public class HiveVersionUtil { private static final Logger LOG = LogManager.getLogger(HiveVersionUtil.class); diff --git a/fe/fe-core/src/main/java/org/apache/hadoop/hive/metastore/HiveMetaStoreClient.java b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/hadoop/hive/metastore/HiveMetaStoreClient.java similarity index 99% rename from fe/fe-core/src/main/java/org/apache/hadoop/hive/metastore/HiveMetaStoreClient.java rename to fe/fe-connector/fe-connector-hms/src/main/java/org/apache/hadoop/hive/metastore/HiveMetaStoreClient.java index bdd8cbfbaba6b3..ca5113a57d8dee 100644 --- a/fe/fe-core/src/main/java/org/apache/hadoop/hive/metastore/HiveMetaStoreClient.java +++ b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/hadoop/hive/metastore/HiveMetaStoreClient.java @@ -20,7 +20,6 @@ import org.apache.doris.datasource.hive.HiveVersionUtil; import org.apache.doris.datasource.hive.HiveVersionUtil.HiveVersion; -import org.apache.doris.datasource.property.metastore.HMSBaseProperties; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Lists; @@ -291,6 +290,17 @@ * * ATTN: There is a copy of this file in be-java-extensions. * If you want to modify this file, please modify the file in be-java-extensions. + * + * ATTN (fe-connector-hms): this is a verbatim copy of the fe-core file, vendored into the SHARED + * fe-connector-hms module so every HMS-backed connector plugin that bundles it (fe-connector-iceberg, + * fe-connector-hive, ...) loads THIS Hive-version-aware client instead of the unpatched + * org.apache.hadoop.hive.metastore.HiveMetaStoreClient bundled inside hive-catalog-shade. A plugin's lib + * jars are searched alphabetically, and fe-connector-hms-*.jar sorts before hive-catalog-shade-*.jar, so + * this class wins. Without it, a HiveCatalog / HMS client talks to a Hive-1/2 metastore using the Hive-3 + * "@cat#" db-name marker, which those servers reject -> list/get database return empty. The ONLY deviation + * from the fe-core copy is that fe-core's HMSBaseProperties.HIVE_VERSION constant is inlined here as + * HIVE_VERSION ("hive.version") to avoid pulling the heavy fe-core property class into the plugin. Keep the + * rest in sync with the fe-core original. */ @InterfaceAudience.Public @InterfaceStability.Evolving @@ -335,6 +345,11 @@ public class HiveMetaStoreClient implements IMetaStoreClient, AutoCloseable { //copied from ErrorMsg.java public static final String REPL_EVENTS_MISSING_IN_METASTORE = "Notification events are missing in the meta store."; + // Inlined from fe-core HMSBaseProperties.HIVE_VERSION (see class header) to avoid pulling the heavy + // fe-core property class into the plugin. Catalog property key for the user's hive version; when absent, + // HiveVersionUtil.getVersion(null) defaults to Hive 2.3 -> no Hive-3 "@cat#" db-name marker. + private static final String HIVE_VERSION = "hive.version"; + public HiveMetaStoreClient(Configuration conf) throws MetaException { this(conf, null, true); } @@ -354,8 +369,8 @@ public HiveMetaStoreClient(Configuration conf, HiveMetaHookLoader hookLoader, Bo this.conf = new Configuration(conf); } - hiveVersion = HiveVersionUtil.getVersion(conf.get(HMSBaseProperties.HIVE_VERSION)); - LOG.info("Loading Doris HiveMetaStoreClient. Hive version: " + conf.get(HMSBaseProperties.HIVE_VERSION)); + hiveVersion = HiveVersionUtil.getVersion(conf.get(HIVE_VERSION)); + LOG.info("Loading Doris HiveMetaStoreClient. Hive version: " + conf.get(HIVE_VERSION)); // For hive 2.3.7, there is no ClientCapability.INSERT_ONLY_TABLES if (hiveVersion == HiveVersion.V1_0 || hiveVersion == HiveVersion.V2_0 || hiveVersion == HiveVersion.V2_3) { diff --git a/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/CachingHmsClientTest.java b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/CachingHmsClientTest.java new file mode 100644 index 00000000000000..8400138aa3ed99 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/CachingHmsClientTest.java @@ -0,0 +1,664 @@ +// 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.hms; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Tests {@link CachingHmsClient}: the caching decorator over an {@link HmsClient}. + * + *

WHY: at the HMS cutover a hive catalog stops routing to the engine-side {@code HiveExternalMetaCache}, + * so the connector must cache these reads itself or every scan regresses to fresh Thrift RPCs. These tests + * pin the behaviours that make that re-homed cache correct: (1) the four read methods actually cache (loader + * runs once per key), keyed exactly by their arguments — including the database dimension, so two databases + * never collide; (2) the per-entry {@code meta.cache.hive.*} knobs turn a cache off; (3) + * {@link CachingHmsClient#flush} / {@code flushAll} drop the right entries across all four caches (arming + * REFRESH) and {@code flush} is scoped to one table; and that other methods are a verbatim pass-through and + * a loader failure is neither swallowed nor cached.

+ */ +public class CachingHmsClientTest { + + private static Map props(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + // ---- getTable ---- + + @Test + public void getTableCachesByDbAndTable() { + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + HmsTableInfo first = cache.getTable("db", "t1"); + HmsTableInfo second = cache.getTable("db", "t1"); + // WHY: a hit must serve the cached instance without re-hitting the metastore. + Assertions.assertSame(first, second); + Assertions.assertEquals(1, delegate.getTableCalls); + + // WHY: a different table is a different key — must NOT serve t1's value. + HmsTableInfo other = cache.getTable("db", "t2"); + Assertions.assertNotSame(first, other); + Assertions.assertEquals(2, delegate.getTableCalls); + } + + @Test + public void cacheKeysAreScopedByDatabase() { + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + // Same table name, different database, across all four caches. WHY: the db dimension MUST be part of + // every key — otherwise "db2.t" would be served "db1.t"'s cached metadata (a cross-database mix-up). + HmsTableInfo t1 = cache.getTable("db1", "t"); + HmsTableInfo t2 = cache.getTable("db2", "t"); + Assertions.assertNotSame(t1, t2); + Assertions.assertEquals(2, delegate.getTableCalls); + + cache.listPartitionNames("db1", "t", -1); + cache.listPartitionNames("db2", "t", -1); + Assertions.assertEquals(2, delegate.listPartitionNamesCalls); + + cache.getPartitions("db1", "t", Arrays.asList("p=1")); + cache.getPartitions("db2", "t", Arrays.asList("p=1")); + Assertions.assertEquals(2, delegate.getPartitionsCalls); + + cache.getTableColumnStatistics("db1", "t", Arrays.asList("c1")); + cache.getTableColumnStatistics("db2", "t", Arrays.asList("c1")); + Assertions.assertEquals(2, delegate.getColumnStatsCalls); + } + + // ---- listPartitionNames ---- + + @Test + public void listPartitionNamesCachesByDbTableAndMaxParts() { + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + List a = cache.listPartitionNames("db", "t", -1); + List b = cache.listPartitionNames("db", "t", -1); + Assertions.assertSame(a, b); + Assertions.assertEquals(1, delegate.listPartitionNamesCalls); + + // WHY: maxParts is part of the key — a bounded request must never be served the unbounded list. + cache.listPartitionNames("db", "t", 10); + Assertions.assertEquals(2, delegate.listPartitionNamesCalls); + } + + // ---- getTableFresh (SHOW CREATE TABLE — must bypass the table cache) ---- + + @Test + public void getTableFreshAlwaysHitsDelegate() { + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + // WHY: SHOW CREATE TABLE must see the latest schema (a column added externally after the cache filled) + // even while DESC serves the stale cached table. Every fresh call goes to the metastore. (test_hive_meta_cache.) + cache.getTableFresh("db", "t1"); + cache.getTableFresh("db", "t1"); + Assertions.assertEquals(2, delegate.getTableCalls); + } + + @Test + public void getTableFreshDoesNotPopulateCache() { + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + // Fresh call must NOT write the table cache: a following cached getTable must still MISS (delegate call #2) + // and only THEN populate — proving fresh bypasses the cache in both directions. + cache.getTableFresh("db", "t1"); // delegate #1, no populate + cache.getTable("db", "t1"); // cache miss -> delegate #2 + populate + cache.getTable("db", "t1"); // cache hit -> no delegate call + Assertions.assertEquals(2, delegate.getTableCalls); + } + + @Test + public void getTableFreshDefaultOnNonCachingClientIsPlainGet() { + // A bare HmsClient (no caching decorator) inherits the interface default: fresh == the raw getTable. + RecordingHmsClient raw = new RecordingHmsClient(); + raw.getTableFresh("db", "t1"); + raw.getTable("db", "t1"); + Assertions.assertEquals(2, raw.getTableCalls); + } + + // ---- listPartitionNamesFresh (SHOW PARTITIONS / partitions TVF — must bypass the names cache) ---- + + @Test + public void listPartitionNamesFreshAlwaysHitsDelegate() { + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + // WHY: SHOW PARTITIONS must see partitions added externally after the cache filled. Every fresh call + // goes to the metastore — never served from partitionNamesCache. (test_hive_use_meta_cache_true sql09.) + cache.listPartitionNamesFresh("db", "t", -1); + cache.listPartitionNamesFresh("db", "t", -1); + Assertions.assertEquals(2, delegate.listPartitionNamesCalls); + } + + @Test + public void listPartitionNamesFreshDoesNotPopulateCache() { + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + // Fresh call must NOT write the names cache: a following cached listPartitionNames must still MISS + // (delegate call #2) and only THEN populate — proving fresh bypasses the cache in both directions. + cache.listPartitionNamesFresh("db", "t", -1); // delegate #1, no populate + cache.listPartitionNames("db", "t", -1); // cache miss -> delegate #2 + populate + cache.listPartitionNames("db", "t", -1); // cache hit -> no delegate call + Assertions.assertEquals(2, delegate.listPartitionNamesCalls); + } + + @Test + public void listPartitionNamesFreshDefaultOnNonCachingClientIsPlainListing() { + // A bare HmsClient (no caching decorator) inherits the interface default: fresh == the raw listing. + // Guards the C4 foot-gun — a non-decorating client has nothing to bypass, so the two must be identical. + RecordingHmsClient raw = new RecordingHmsClient(); + raw.listPartitionNamesFresh("db", "t", -1); + raw.listPartitionNames("db", "t", -1); + Assertions.assertEquals(2, raw.listPartitionNamesCalls); + } + + // ---- getPartitions ---- + + @Test + public void getPartitionsSharesPerPartitionEntriesAcrossRequests() { + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + // First request loads BOTH partitions in one delegate round-trip and caches each PER PARTITION. + List a = cache.getPartitions("db", "t", Arrays.asList("p=1", "p=2")); + Assertions.assertEquals(2, a.size()); + Assertions.assertEquals(1, delegate.getPartitionsCalls); + + // WHY (Rule 9 / the D2 fix): an OVERLAPPING subset request must be served entirely from the shared + // per-partition entries — no new delegate call. The OLD list-keyed cache re-fetched any distinct + // request list (this was `getPartitionsCalls == 2` here); a mutation reverting to list keying — + // storing the whole list under a request-name-list key — makes this re-fetch and go red. + cache.getPartitions("db", "t", Arrays.asList("p=1")); + Assertions.assertEquals(1, delegate.getPartitionsCalls, + "p=1 is served from the shared per-partition entry (no re-fetch)"); + + // WHY: order-independent too (the old list key was order-sensitive and re-loaded on a reversed list); + // both partitions are already cached, so a reversed request still hits. + List rev = cache.getPartitions("db", "t", Arrays.asList("p=2", "p=1")); + Assertions.assertEquals(2, rev.size()); + Assertions.assertEquals(1, delegate.getPartitionsCalls, "reversed order still hits the shared entries"); + + // WHY: only a genuinely new partition triggers a delegate fetch — and ONLY for the miss (p=1 stays + // cached), proving misses are fetched in one round-trip while hits are served locally. + cache.getPartitions("db", "t", Arrays.asList("p=1", "p=3")); + Assertions.assertEquals(2, delegate.getPartitionsCalls, "only the new p=3 is fetched; p=1 stays cached"); + Assertions.assertEquals(Arrays.asList("p=3"), delegate.lastGetPartitionsArg, + "the delegate is asked for the MISS only, not the whole requested list"); + } + + @Test + public void getPartitionsOmitsMissingPartitionWithoutNegativeCaching() { + RecordingHmsClient delegate = new RecordingHmsClient(); + delegate.absentPartitionNames.add("p=9"); // HMS has no such partition -> omitted from the response + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + List r = cache.getPartitions("db", "t", Arrays.asList("p=1", "p=9")); + // WHY: a non-existent partition is OMITTED (get_partitions_by_names parity), never fabricated. + Assertions.assertEquals(1, r.size(), "the absent partition is omitted, not fabricated"); + Assertions.assertEquals(1, delegate.getPartitionsCalls); + + // WHY (Rule 9): the missing p=9 must NOT be negative-cached — a later request re-attempts it (so once + // the partition is created + REFRESH'd it is picked up). Only p=9 re-fetches; p=1 stays cached. + cache.getPartitions("db", "t", Arrays.asList("p=1", "p=9")); + Assertions.assertEquals(2, delegate.getPartitionsCalls, + "the absent partition is re-attempted (no negative cache); p=1 still hits"); + Assertions.assertEquals(Arrays.asList("p=9"), delegate.lastGetPartitionsArg); + } + + @Test + public void getPartitionsStaysCorrectWhenParsedNameDivergesFromStoredValues() { + // Pathological: the delegate returns a partition whose values do NOT match the requested name's parse + // (models a value the name-parse cannot round-trip). The decorator keys the STORE by the partition's + // OWN values but the LOOKUP by the parsed name, so they never match -> the partition is re-fetched + // every time. WHY (Rule 9 / Rule 12): this pins the safety contract — a parse divergence degrades to a + // reload (perf), NEVER a wrong or dropped partition. A mutation that keyed the STORE by the parsed name + // instead would make the lookup "hit" a mis-keyed entry (or drop the partition) -> the size/values + // asserts go red. + RecordingHmsClient delegate = new RecordingHmsClient(); + delegate.forcedValues = Arrays.asList("EXOTIC"); // stored values != parse("p=1") == ["1"] + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + List r1 = cache.getPartitions("db", "t", Arrays.asList("p=1")); + Assertions.assertEquals(1, r1.size(), "the partition is returned even though its values diverge from the name"); + Assertions.assertEquals(Arrays.asList("EXOTIC"), r1.get(0).getValues()); + Assertions.assertEquals(1, delegate.getPartitionsCalls); + + List r2 = cache.getPartitions("db", "t", Arrays.asList("p=1")); + Assertions.assertEquals(1, r2.size()); + Assertions.assertEquals("EXOTIC", r2.get(0).getValues().get(0)); + Assertions.assertEquals(2, delegate.getPartitionsCalls, + "divergence degrades to a reload, never a wrong/dropped result"); + } + + @Test + public void getPartitionsFlushRacingInFlightFetchDoesNotRecacheStale() { + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + // Model a REFRESH TABLE (flush) landing DURING the cold-cache delegate RPC: getPartitions captures the + // invalidation generation BEFORE the RPC, the flush bumps it mid-RPC, so the per-partition guarded put + // must be dropped rather than re-cache the pre-refresh partition. The in-flight query still returns the + // freshly-fetched partition (only the CACHE put is guarded). + delegate.onGetPartitions = () -> cache.flush("db", "t"); + List r = cache.getPartitions("db", "t", Arrays.asList("p=1")); + Assertions.assertEquals(1, r.size(), "the in-flight query still returns the delegate's partition"); + Assertions.assertEquals(1, delegate.getPartitionsCalls); + + // WHY (Rule 9 / R3): the racing flush must have prevented the stale put, so a follow-up read is a MISS + // and re-fetches — it is NOT served the pre-refresh partition up to the TTL. MUTATION: the pre-R3 raw + // partitionsCache.put re-caches the stale partition here, so the next read hits and getPartitionsCalls + // stays 1 -> red. (getTable/listPartitionNames/getTableColumnStatistics kept the guard via get(); only + // getPartitions' per-partition put had lost it.) + delegate.onGetPartitions = null; + cache.getPartitions("db", "t", Arrays.asList("p=1")); + Assertions.assertEquals(2, delegate.getPartitionsCalls, + "a flush racing the in-flight fetch must not leave the stale partition cached (guarded put)"); + } + + // ---- column statistics ---- + + @Test + public void columnStatisticsCacheByRequestedColumnList() { + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + List cols = Arrays.asList("c1", "c2"); + List a = cache.getTableColumnStatistics("db", "t", cols); + List b = cache.getTableColumnStatistics("db", "t", new ArrayList<>(cols)); + // WHY: same requested column set+order hits. + Assertions.assertSame(a, b); + Assertions.assertEquals(1, delegate.getColumnStatsCalls); + // WHY: the delegate's real stats must survive the cache, not the interface's empty-list default. + Assertions.assertEquals(1, a.size()); + Assertions.assertEquals("c1", a.get(0).getColumnName()); + + // WHY: a different requested column set is a distinct entry (RPC-argument granularity). + cache.getTableColumnStatistics("db", "t", Arrays.asList("c1")); + Assertions.assertEquals(2, delegate.getColumnStatsCalls); + } + + @Test + public void emptyColumnStatisticsResultIsCached() { + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + // The fake returns an empty (no-stats) list for an empty column request. + cache.getTableColumnStatistics("db", "t", Collections.emptyList()); + cache.getTableColumnStatistics("db", "t", Collections.emptyList()); + // WHY: an empty "no stats" result is a real cached value (only null is treated as a miss) — it must + // NOT be re-fetched, or a table without column stats would hit HMS on every planner probe. + Assertions.assertEquals(1, delegate.getColumnStatsCalls); + } + + // ---- per-entry property knobs ---- + + @Test + public void perEntryPropertiesControlCaching() { + // table cache disabled via enable=false; partition_names disabled via ttl-second=0; partition left on. + Map properties = props( + "meta.cache.hive.table.enable", "false", + "meta.cache.hive.partition_names.ttl-second", "0"); + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, properties); + + cache.getTable("db", "t"); + cache.getTable("db", "t"); + // WHY: enable=false must bypass caching entirely — every call reloads. + Assertions.assertEquals(2, delegate.getTableCalls); + + cache.listPartitionNames("db", "t", -1); + cache.listPartitionNames("db", "t", -1); + // WHY: ttl-second=0 also disables the cache (a distinct knob from enable). + Assertions.assertEquals(2, delegate.listPartitionNamesCalls); + + cache.getPartitions("db", "t", Arrays.asList("p=1")); + cache.getPartitions("db", "t", Arrays.asList("p=1")); + // WHY: an unconfigured entry stays enabled by default — proves the knobs are read PER entry. + Assertions.assertEquals(1, delegate.getPartitionsCalls); + } + + @Test + public void legacyTtlPropertiesControlCaching() { + // The legacy fe-core catalog knobs must still work after the SPI cutover: schema.cache.ttl-second maps + // onto the table/schema cache; partition.cache.ttl-second onto the partition-NAME list (legacy's + // partition_values), NOT the per-partition objects cache. Mirrors HiveExternalMetaCache's compat map; + // this is what test_hive_meta_cache's schema-cache and partition-cache sections exercise. + Map properties = props( + "schema.cache.ttl-second", "0", + "partition.cache.ttl-second", "0"); + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, properties); + + cache.getTable("db", "t"); + cache.getTable("db", "t"); + // WHY: schema.cache.ttl-second=0 must disable the table/schema cache (backs DESC) — every call reloads. + Assertions.assertEquals(2, delegate.getTableCalls); + + cache.listPartitionNames("db", "t", -1); + cache.listPartitionNames("db", "t", -1); + // WHY: partition.cache.ttl-second=0 must disable the partition-name list — a newly-added partition is + // then visible without REFRESH. + Assertions.assertEquals(2, delegate.listPartitionNamesCalls); + + cache.getPartitions("db", "t", Arrays.asList("p=1")); + cache.getPartitions("db", "t", Arrays.asList("p=1")); + // WHY: the per-partition objects cache has NO legacy knob (fe-core mapped partition.cache only to the + // partition-values list), so it stays enabled — pins the faithful legacy mapping. + Assertions.assertEquals(1, delegate.getPartitionsCalls); + } + + // ---- flush(db, table) ---- + + @Test + public void flushDropsOnlyThatTablesEntries() { + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + // Populate ALL four caches for BOTH t1 and t2 (t2 must live in the three predicate-invalidated caches + // too, not just the table cache, so an over-broad flush of them is detectable). + cache.getTable("db", "t1"); + cache.listPartitionNames("db", "t1", -1); + cache.getPartitions("db", "t1", Arrays.asList("p=1")); + cache.getTableColumnStatistics("db", "t1", Arrays.asList("c1")); + cache.getTable("db", "t2"); + cache.listPartitionNames("db", "t2", -1); + cache.getPartitions("db", "t2", Arrays.asList("p=1")); + cache.getTableColumnStatistics("db", "t2", Arrays.asList("c1")); + Assertions.assertEquals(2, delegate.getTableCalls); + Assertions.assertEquals(2, delegate.listPartitionNamesCalls); + Assertions.assertEquals(2, delegate.getPartitionsCalls); + Assertions.assertEquals(2, delegate.getColumnStatsCalls); + + cache.flush("db", "t1"); + + // WHY: t1 must reload across all four caches after its flush. + cache.getTable("db", "t1"); + cache.listPartitionNames("db", "t1", -1); + cache.getPartitions("db", "t1", Arrays.asList("p=1")); + cache.getTableColumnStatistics("db", "t1", Arrays.asList("c1")); + Assertions.assertEquals(3, delegate.getTableCalls); + Assertions.assertEquals(3, delegate.listPartitionNamesCalls); + Assertions.assertEquals(3, delegate.getPartitionsCalls); + Assertions.assertEquals(3, delegate.getColumnStatsCalls); + + // WHY: flush is scoped to ONE table — t2's entries in ALL four caches must survive (no reload). This + // pins the matches() per-table scoping of the three predicate caches, not just the table cache's + // exact-key invalidation: an over-broad flush that wiped every table would reload t2 here. + cache.getTable("db", "t2"); + cache.listPartitionNames("db", "t2", -1); + cache.getPartitions("db", "t2", Arrays.asList("p=1")); + cache.getTableColumnStatistics("db", "t2", Arrays.asList("c1")); + Assertions.assertEquals(3, delegate.getTableCalls); + Assertions.assertEquals(3, delegate.listPartitionNamesCalls); + Assertions.assertEquals(3, delegate.getPartitionsCalls); + Assertions.assertEquals(3, delegate.getColumnStatsCalls); + } + + // ---- flushDb() ---- + + @Test + public void flushDbDropsOnlyThatDatabasesEntries() { + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + // Populate all four caches for db1.t1, plus db1.t2 (a SECOND table in the same db) and db2.t1 (a table in + // ANOTHER db). flushDb("db1") must drop EVERY db1 table (t1 AND t2) across all four caches, while db2 lives. + cache.getTable("db1", "t1"); + cache.listPartitionNames("db1", "t1", -1); + cache.getPartitions("db1", "t1", Arrays.asList("p=1")); + cache.getTableColumnStatistics("db1", "t1", Arrays.asList("c1")); + cache.getTable("db1", "t2"); + cache.getTable("db2", "t1"); + Assertions.assertEquals(3, delegate.getTableCalls); + + cache.flushDb("db1"); + + // WHY: every db1 table reloads across all four caches — this pins the matchesDb() db scoping (not the + // per-table matches()): t2 reloading proves the whole database was dropped, not just one table. + cache.getTable("db1", "t1"); + cache.listPartitionNames("db1", "t1", -1); + cache.getPartitions("db1", "t1", Arrays.asList("p=1")); + cache.getTableColumnStatistics("db1", "t1", Arrays.asList("c1")); + cache.getTable("db1", "t2"); + Assertions.assertEquals(5, delegate.getTableCalls, "flushDb must drop EVERY table in the database (t1 and t2)"); + Assertions.assertEquals(2, delegate.listPartitionNamesCalls); + Assertions.assertEquals(2, delegate.getPartitionsCalls); + Assertions.assertEquals(2, delegate.getColumnStatsCalls); + + // WHY: flushDb is scoped to ONE database — db2's entry must survive (no reload). An over-broad flushDb that + // wiped every db would reload db2 here -> red. + cache.getTable("db2", "t1"); + Assertions.assertEquals(5, delegate.getTableCalls, "flushDb must NOT drop another database's entries"); + } + + // ---- flushAll() ---- + + @Test + public void flushAllDropsEverything() { + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + // Populate all four caches so flushAll's independent invalidateAll() call on each is exercised. + cache.getTable("db", "t"); + cache.listPartitionNames("db", "t", -1); + cache.getPartitions("db", "t", Arrays.asList("p=1")); + cache.getTableColumnStatistics("db", "t", Arrays.asList("c1")); + Assertions.assertEquals(1, delegate.getTableCalls); + Assertions.assertEquals(1, delegate.listPartitionNamesCalls); + Assertions.assertEquals(1, delegate.getPartitionsCalls); + Assertions.assertEquals(1, delegate.getColumnStatsCalls); + + cache.flushAll(); + + // WHY: flushAll drops ALL four caches — every one reloads (not just the table cache). + cache.getTable("db", "t"); + cache.listPartitionNames("db", "t", -1); + cache.getPartitions("db", "t", Arrays.asList("p=1")); + cache.getTableColumnStatistics("db", "t", Arrays.asList("c1")); + Assertions.assertEquals(2, delegate.getTableCalls); + Assertions.assertEquals(2, delegate.listPartitionNamesCalls); + Assertions.assertEquals(2, delegate.getPartitionsCalls); + Assertions.assertEquals(2, delegate.getColumnStatsCalls); + } + + // ---- pass-through delegation ---- + + @Test + public void nonCachedMethodsDelegate() throws IOException { + RecordingHmsClient delegate = new RecordingHmsClient(); + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + cache.listDatabases(); + Assertions.assertEquals(1, delegate.listDatabasesCalls); + + cache.dropTable("db", "t"); + Assertions.assertEquals(1, delegate.dropTableCalls); + + cache.close(); + Assertions.assertEquals(1, delegate.closeCalls); + } + + // ---- loader failures ---- + + @Test + public void loaderExceptionPropagatesAndIsNotCached() { + RecordingHmsClient delegate = new RecordingHmsClient(); + delegate.getTableError = new HmsClientException("boom"); + CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap()); + + HmsClientException e = Assertions.assertThrows(HmsClientException.class, + () -> cache.getTable("db", "t")); + Assertions.assertEquals("boom", e.getMessage()); + Assertions.assertEquals(1, delegate.getTableCalls); + + // WHY: a failed load must NOT be cached — after recovery, the next call reloads and succeeds. + delegate.getTableError = null; + HmsTableInfo ok = cache.getTable("db", "t"); + Assertions.assertNotNull(ok); + Assertions.assertEquals(2, delegate.getTableCalls); + } + + @Test + public void nullDelegateRejected() { + Assertions.assertThrows(NullPointerException.class, + () -> new CachingHmsClient(null, Collections.emptyMap())); + } + + /** + * A minimal {@link HmsClient} that counts calls and returns a fresh instance per call, so reference + * identity distinguishes a cache hit (same instance) from a reload (new instance). + */ + private static final class RecordingHmsClient implements HmsClient { + int getTableCalls; + int listPartitionNamesCalls; + int getPartitionsCalls; + int getColumnStatsCalls; + int listDatabasesCalls; + int dropTableCalls; + int closeCalls; + RuntimeException getTableError; + // Partition names the fake has NO partition for (mirrors HMS omitting non-existent partitions). + final Set absentPartitionNames = new HashSet<>(); + // When set, every returned partition carries these exact values regardless of the requested name + // (used to model a value the name-parse cannot round-trip, exercising the store-by-real-values path). + List forcedValues; + // The partition-name list the decorator actually asked the delegate for on the LAST getPartitions call + // (so a test can assert the decorator fetches only the MISSES, not the whole requested list). + List lastGetPartitionsArg; + // Optional hook fired INSIDE getPartitions (after counting, before returning) to model a concurrent + // mutation (e.g. a REFRESH flush) racing the in-flight cold-cache RPC. + Runnable onGetPartitions; + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + getTableCalls++; + if (getTableError != null) { + throw getTableError; + } + return HmsTableInfo.builder().dbName(dbName).tableName(tableName).build(); + } + + @Override + public List listPartitionNames(String dbName, String tableName, int maxParts) { + listPartitionNamesCalls++; + return new ArrayList<>(Arrays.asList("p=1", "p=2")); + } + + @Override + public List getPartitions(String dbName, String tableName, List partNames) { + getPartitionsCalls++; + lastGetPartitionsArg = new ArrayList<>(partNames); + if (onGetPartitions != null) { + onGetPartitions.run(); + } + List out = new ArrayList<>(); + for (String name : partNames) { + if (absentPartitionNames.contains(name)) { + continue; // no such partition -> omitted (get_partitions_by_names parity) + } + // The partition's OWN values must correspond to its name ("k=v/..." -> ["v", ...]) so the + // decorator can key it per-partition the same way it parses the lookup name; forcedValues + // overrides this to model a value the name-parse cannot round-trip. + List values = forcedValues != null ? forcedValues : valuesOf(name); + out.add(new HmsPartitionInfo(values, "loc/" + name, null, null, null, null)); + } + return out; + } + + // "p=1" -> ["1"]; "k1=a/k2=b" -> ["a", "b"] (simple split; test names carry no escaped characters). + private static List valuesOf(String partitionName) { + List values = new ArrayList<>(); + for (String seg : partitionName.split("/")) { + int eq = seg.indexOf('='); + values.add(eq >= 0 ? seg.substring(eq + 1) : seg); + } + return values; + } + + @Override + public List getTableColumnStatistics(String dbName, String tableName, + List columns) { + getColumnStatsCalls++; + if (columns.isEmpty()) { + return Collections.emptyList(); + } + return new ArrayList<>(Arrays.asList(new HmsColumnStatistics("c1", 1L, 0L, 4.0))); + } + + @Override + public List listDatabases() { + listDatabasesCalls++; + return Collections.emptyList(); + } + + @Override + public void dropTable(String dbName, String tableName) { + dropTableCalls++; + } + + @Override + public void close() { + closeCalls++; + } + + // Unused abstract methods — trivial stubs. + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + return null; + } + + @Override + public List listTables(String dbName) { + return Collections.emptyList(); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + return false; + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + return Collections.emptyMap(); + } + + @Override + public HmsPartitionInfo getPartition(String dbName, String tableName, List values) { + return null; + } + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/DoAsTcclProbe.java b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/DoAsTcclProbe.java new file mode 100644 index 00000000000000..48dc0547b47ae3 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/DoAsTcclProbe.java @@ -0,0 +1,99 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hms; + +import org.apache.hadoop.hive.metastore.IMetaStoreClient; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** + * Test entrypoint, invoked reflectively THROUGH an isolated child-first classloader (see + * {@link ThriftHmsClientDoAsClassLoaderTest}). Because the child-first loader defines this class — and + * therefore {@link ThriftHmsClient} — itself, {@code ThriftHmsClient.class.getClassLoader()} inside here is + * that isolated loader, distinct from the system classloader. That is what reproduces the production two-copy + * topology (fe-core hadoop on the system loader, plugin hadoop child-first) so the difference between + * {@code doAs} pinning {@code getClass().getClassLoader()} (correct) and {@code getSystemClassLoader()} (the + * split-brain bug) becomes observable in a unit test. + */ +public final class DoAsTcclProbe { + + private DoAsTcclProbe() { + } + + /** + * Drives one metastore call and inspects the TCCL {@code doAs} pinned while creating the client. Returns + * {@code "OK"} iff {@code doAs} pinned the connector's own (this isolated) loader AND restored the caller's + * TCCL; otherwise a diagnostic string. Returning a plain String avoids any cross-loader type coupling with + * the invoking test. + */ + public static String check() throws Exception { + // A fake IMetaStoreClient (no Mockito): List-returning calls yield an empty list, else null. + InvocationHandler handler = (proxy, method, args) -> + List.class.isAssignableFrom(method.getReturnType()) ? new ArrayList<>() : null; + IMetaStoreClient fake = (IMetaStoreClient) Proxy.newProxyInstance( + DoAsTcclProbe.class.getClassLoader(), new Class[] {IMetaStoreClient.class}, handler); + + final ClassLoader[] observedDuringCreate = new ClassLoader[1]; + ThriftHmsClient.MetaStoreClientProvider provider = hiveConf -> { + if (observedDuringCreate[0] == null) { + observedDuringCreate[0] = Thread.currentThread().getContextClassLoader(); + } + return fake; + }; + + // poolSize 0 -> no pool: borrowClient() -> createFreshClient() -> doAs() -> provider.create(). + HmsClientConfig config = new HmsClientConfig(new HashMap<>(), 0); + ThriftHmsClient client = new ThriftHmsClient(config, null, provider, HmsTypeMapping.Options.DEFAULT); + + ClassLoader connectorLoader = ThriftHmsClient.class.getClassLoader(); + ClassLoader system = ClassLoader.getSystemClassLoader(); + // Drive under a DISTINCT caller TCCL so both "pinned to system" and "never pinned" are observable. + ClassLoader caller = new URLClassLoader(new URL[0], connectorLoader); + ClassLoader original = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(caller); + try { + client.listDatabases(); + } finally { + ClassLoader afterCall = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(original); + client.close(); + if (observedDuringCreate[0] == null) { + return "NO_CLIENT_CREATED"; + } + if (observedDuringCreate[0] == system && system != connectorLoader) { + return "PIN_WRONG_SYSTEM_LOADER"; + } + if (observedDuringCreate[0] == caller) { + return "PIN_MISSING_LEFT_CALLER_LOADER"; + } + if (observedDuringCreate[0] != connectorLoader) { + return "PIN_WRONG_OTHER_LOADER"; + } + if (afterCall != caller) { + return "TCCL_NOT_RESTORED"; + } + } + return "OK"; + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/HiveShowCreateTableRendererTest.java b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/HiveShowCreateTableRendererTest.java new file mode 100644 index 00000000000000..a609ff99fd2ab8 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/HiveShowCreateTableRendererTest.java @@ -0,0 +1,111 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hms; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorType; + +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; + +/** + * Byte-parity tests for {@link HiveShowCreateTableRenderer} — the connector-side port of legacy + * {@code HiveMetaStoreClientHelper.showCreateTable} that native hive SHOW CREATE TABLE relies on. + * + *

The load-bearing details the acceptance suites (test_hive_show_create_table / _ddl_text_format / + * _meta_cache / test_multi_delimit_serde / test_hive_ddl) discriminate on: the TWO quoting conventions + * (SERDEPROPERTIES {@code 'k' = 'v'} with spaces vs TBLPROPERTIES {@code 'k'='v'} without), the 2-space data / + * 1-space partition column indents, the null-comment guard (an empty {@code COMMENT ''} would break the + * meta-cache column substring), and lifting the {@code comment} table param to a top-level COMMENT clause.

+ */ +public class HiveShowCreateTableRendererTest { + + private static ConnectorColumn col(String name, String typeName, String comment) { + return new ConnectorColumn(name, ConnectorType.of(typeName), comment, true, null); + } + + /** A partitioned TEXT/LazySimpleSerDe table with a commented + an uncommented column and a comment param. */ + private static HmsTableInfo textTable() { + Map serdeParams = new LinkedHashMap<>(); + serdeParams.put("field.delim", "|"); + Map tableParams = new LinkedHashMap<>(); + tableParams.put("comment", "my table"); + tableParams.put("doris.file_format", "text"); + return HmsTableInfo.builder() + .dbName("db").tableName("t") + .columns(Arrays.asList(col("id", "INT", null), col("name", "STRING", "the name"))) + .partitionKeys(Collections.singletonList(col("dt", "DATEV2", null))) + .serializationLib("org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe") + .sdParameters(serdeParams) + .inputFormat("org.apache.hadoop.mapred.TextInputFormat") + .outputFormat("org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat") + .location("hdfs://ns/wh/t") + .parameters(tableParams) + .build(); + } + + @Test + public void testColumnBlockIndentTypesAndNullCommentGuard() { + // 2-space data-column indent, mapped hive type strings, and the null-comment guard: `id` has NO comment + // token (a spurious COMMENT '' would break test_hive_meta_cache's exact ``k3` string)` substring), + // `name` carries its comment, and the block closes with `)\n`. + String ddl = HiveShowCreateTableRenderer.render(textTable()); + Assertions.assertTrue(ddl.contains("CREATE TABLE `t`(\n `id` int,\n `name` string COMMENT 'the name')\n"), + ddl); + } + + @Test + public void testTableCommentLiftedAndPartitionOneSpaceIndent() { + String ddl = HiveShowCreateTableRenderer.render(textTable()); + // The `comment` table param becomes a top-level COMMENT clause (not a TBLPROPERTY). + Assertions.assertTrue(ddl.contains("COMMENT 'my table'\n"), ddl); + // Partition columns use a ONE-space indent (data columns use two), matching legacy. + Assertions.assertTrue(ddl.contains("PARTITIONED BY (\n `dt` date)\n"), ddl); + } + + @Test + public void testSerdeAndFormatBlocks() { + String ddl = HiveShowCreateTableRenderer.render(textTable()); + Assertions.assertTrue(ddl.contains( + "ROW FORMAT SERDE\n 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe'\n"), ddl); + Assertions.assertTrue(ddl.contains( + "STORED AS INPUTFORMAT\n 'org.apache.hadoop.mapred.TextInputFormat'\n" + + "OUTPUTFORMAT\n 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'\n"), ddl); + Assertions.assertTrue(ddl.contains("LOCATION\n 'hdfs://ns/wh/t'\n"), ddl); + } + + @Test + public void testTwoQuotingConventions() { + // The discriminator the suites rely on: SERDEPROPERTIES has SPACES around '=', TBLPROPERTIES has NONE. + String ddl = HiveShowCreateTableRenderer.render(textTable()); + Assertions.assertTrue(ddl.contains("WITH SERDEPROPERTIES (\n 'field.delim' = '|')\n"), ddl); + Assertions.assertTrue(ddl.contains("'doris.file_format'='text'"), ddl); + } + + @Test + public void testCommentParamNotLeakedIntoTblproperties() { + // The comment was lifted to COMMENT '...'; it must NOT also appear as a TBLPROPERTY ('comment'='my table'). + String ddl = HiveShowCreateTableRenderer.render(textTable()); + Assertions.assertFalse(ddl.contains("'comment'='my table'"), ddl); + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/HmsClientConfigRemovedTypeTest.java b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/HmsClientConfigRemovedTypeTest.java new file mode 100644 index 00000000000000..e2f766922c939a --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/HmsClientConfigRemovedTypeTest.java @@ -0,0 +1,101 @@ +// 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.hms; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Tests {@link HmsClientConfig#removedMetastoreTypeError(Map)} — the rejection of metastore types that have + * been removed ({@code glue}, {@code dlf}). + * + *

WHY: both removed types used to be selected by {@code hive.metastore.type} and dispatched to a vendored + * client. The dispatch they were removed from ends in a plain-HMS fallback, so a removed value that is merely + * absent from the dispatch does NOT fail — it silently connects to whatever plain HMS is reachable while the + * user believes they configured Glue/DLF. Nothing else covers this, so without these tests the rejection could + * be deleted and no test would go red. + * + *

The tests pin both halves of the contract: every removed type is REJECTED with a message that names it, + * and the surviving type is NOT — a rejection that also caught {@code hms} would be a worse regression than + * the silent fallthrough it prevents. + */ +public class HmsClientConfigRemovedTypeTest { + + private static Map propsWithType(String type) { + Map props = new HashMap<>(); + if (type != null) { + props.put(HmsClientConfig.METASTORE_TYPE_KEY, type); + } + return props; + } + + private static void assertRejected(String type, String expectedSubject) { + String error = HmsClientConfig.removedMetastoreTypeError(propsWithType(type)); + Assertions.assertNotNull(error, + "hive.metastore.type=" + type + " must be rejected, never silently ignored"); + Assertions.assertTrue(error.contains(HmsClientConfig.METASTORE_TYPE_KEY), + "message must name the offending property, got: " + error); + Assertions.assertTrue(error.contains(type), + "message must name the removed type, got: " + error); + Assertions.assertTrue(error.contains(expectedSubject), + "message must say WHAT was removed, got: " + error); + Assertions.assertTrue(error.contains("no longer supported"), + "message must say the type was removed rather than merely invalid, got: " + error); + } + + @Test + public void removedTypesAreRejectedAndNameWhatWasRemoved() { + // WHY: the message reaches the user verbatim (the catalog layer unwraps it into a DdlException with no + // prefix), so it must be self-contained and say which feature is gone. MUTATION: dropping either + // rejection, or emitting a message that never names the type, leaves an error nobody can act on -> red. + assertRejected("glue", "AWS Glue"); + assertRejected("dlf", "DLF 1.0"); + } + + @Test + public void rejectionIsCaseInsensitive() { + // WHY: the dispatch these rejections replace matched with equalsIgnoreCase, so "GLUE"/"Dlf" must not + // slip through into the plain-HMS fallback. MUTATION: a case-sensitive lookup -> red. + Assertions.assertNotNull(HmsClientConfig.removedMetastoreTypeError(propsWithType("GLUE"))); + Assertions.assertNotNull(HmsClientConfig.removedMetastoreTypeError(propsWithType("Dlf"))); + } + + @Test + public void survivingTypeIsNotRejected() { + // WHY: guards the blast radius of the rejection. MUTATION: rejecting hms (or the absent-type default, + // which every catalog without an explicit type relies on) would break every Hive catalog -> red. + Assertions.assertNull(HmsClientConfig.removedMetastoreTypeError(propsWithType(null)), + "absent type defaults to plain hms and must be accepted"); + Assertions.assertNull(HmsClientConfig.removedMetastoreTypeError( + propsWithType(HmsClientConfig.METASTORE_TYPE_HMS))); + } + + @Test + public void messageAdvertisesOnlyTheSurvivingType() { + // WHY: the message tells the user what to switch to. Now that dlf is gone too, advertising it would + // send them at a type that is itself rejected. MUTATION: a stale "Supported types: hms, dlf" -> red. + String error = HmsClientConfig.removedMetastoreTypeError(propsWithType("glue")); + Assertions.assertTrue(error.contains("Supported types: " + HmsClientConfig.METASTORE_TYPE_HMS + "."), + "message must advertise hms as the only supported type, got: " + error); + Assertions.assertFalse(error.contains("dlf"), + "message must not advertise the removed dlf type, got: " + error); + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/HmsConfHelperTest.java b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/HmsConfHelperTest.java new file mode 100644 index 00000000000000..c67151234a6422 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/HmsConfHelperTest.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.connector.hms; + +import org.apache.hadoop.hive.conf.HiveConf; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Tests {@link HmsConfHelper#createHiveConf(Map)} — the HiveConf builder for the hms connector's + * metastore Thrift client ({@code ThriftHmsClient} constructs its conf here). + * + *

WHY: a kerberized HMS only accepts a SASL-negotiated Thrift transport. The legacy fe-core + * {@code HMSBaseProperties.initHadoopAuthenticator} auto-enabled {@code hive.metastore.sasl.enabled} + * whenever the metastore/hadoop auth was kerberos (HMSBaseProperties.java:162,179). The SPI connector's + * builder previously only copied raw properties, so a catalog that declared kerberos auth but did not + * spell out {@code hive.metastore.sasl.enabled} opened a plain TSocket the metastore dropped with + * {@code TTransportException} (test_single_hive_kerberos / test_two_hive_kerberos). These tests pin the + * restored auto-injection and its opposite — non-kerberos auth must NOT flip SASL on (Rule 9: encode WHY + * the injection is gated on kerberos, not just that it happens).

+ */ +public class HmsConfHelperTest { + + private static String saslOf(Map props) { + HiveConf conf = HmsConfHelper.createHiveConf(props); + return conf.get("hive.metastore.sasl.enabled"); + } + + @Test + public void hadoopSecurityAuthKerberosEnablesSasl() { + Map props = new HashMap<>(); + props.put("hive.metastore.uris", "thrift://host:9583"); + props.put("hadoop.security.authentication", "kerberos"); + props.put("hive.metastore.kerberos.principal", "hive/hadoop-master@LABS.TERADATA.COM"); + // No explicit hive.metastore.sasl.enabled — the kerberized catalog relies on auto-injection. + Assertions.assertEquals("true", saslOf(props)); + } + + @Test + public void hiveMetastoreAuthTypeKerberosEnablesSasl() { + Map props = new HashMap<>(); + props.put("hive.metastore.uris", "thrift://host:9583"); + props.put("hive.metastore.authentication.type", "kerberos"); + Assertions.assertEquals("true", saslOf(props)); + } + + @Test + public void kerberosAuthIsCaseInsensitive() { + Map props = new HashMap<>(); + props.put("hadoop.security.authentication", "KERBEROS"); + Assertions.assertEquals("true", saslOf(props)); + } + + @Test + public void simpleAuthDoesNotEnableSasl() { + Map props = new HashMap<>(); + props.put("hive.metastore.uris", "thrift://host:9083"); + props.put("hadoop.security.authentication", "simple"); + Assertions.assertNotEquals("true", saslOf(props)); + } + + @Test + public void noAuthPropertyDoesNotEnableSasl() { + Map props = new HashMap<>(); + props.put("hive.metastore.uris", "thrift://host:9083"); + Assertions.assertNotEquals("true", saslOf(props)); + } + + @Test + public void arbitraryPropertiesArePassedThrough() { + Map props = new HashMap<>(); + props.put("hive.metastore.uris", "thrift://host:9083"); + props.put("some.custom.key", "custom-value"); + HiveConf conf = HmsConfHelper.createHiveConf(props); + Assertions.assertEquals("thrift://host:9083", conf.get("hive.metastore.uris")); + Assertions.assertEquals("custom-value", conf.get("some.custom.key")); + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/HmsTypeMappingTest.java b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/HmsTypeMappingTest.java new file mode 100644 index 00000000000000..8247b4349301dd --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/HmsTypeMappingTest.java @@ -0,0 +1,247 @@ +// 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.hms; + +import org.apache.doris.connector.api.ConnectorType; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +/** + * Tests {@link HmsTypeMapping} — the Hive type-string parser shared by the hms and hive + * connectors (first test for fe-connector-hms; P3-T07 batch C baseline). + * + *

WHY: this is the SPI-clean equivalent of fe-core + * {@code HiveMetaStoreClientHelper.hiveTypeToDorisType}. It is pure parsing logic where + * bugs hide — nested complex types, precision/scale extraction, and option-driven + * mappings. A wrong mapping silently mistypes every column of an HMS/Hive/Iceberg-on-HMS + * table. These tests pin the exact ConnectorType per Hive type string and the + * nesting-aware field splitting (Rule 9: encode the contract, not just the happy path).

+ */ +public class HmsTypeMappingTest { + + private static ConnectorType map(String hiveType) { + return HmsTypeMapping.toConnectorType(hiveType); + } + + @Test + public void testPrimitives() { + Assertions.assertEquals(ConnectorType.of("BOOLEAN"), map("boolean")); + Assertions.assertEquals(ConnectorType.of("TINYINT"), map("tinyint")); + Assertions.assertEquals(ConnectorType.of("SMALLINT"), map("smallint")); + Assertions.assertEquals(ConnectorType.of("INT"), map("int")); + Assertions.assertEquals(ConnectorType.of("BIGINT"), map("bigint")); + Assertions.assertEquals(ConnectorType.of("FLOAT"), map("float")); + Assertions.assertEquals(ConnectorType.of("DOUBLE"), map("double")); + Assertions.assertEquals(ConnectorType.of("STRING"), map("string")); + Assertions.assertEquals(ConnectorType.of("DATEV2"), map("date")); + } + + @Test + public void testTimestampUsesTimeScale() { + // Default time scale is 6. + Assertions.assertEquals(ConnectorType.of("DATETIMEV2", 6, -1), map("timestamp")); + // A custom time scale flows through. + Assertions.assertEquals(ConnectorType.of("DATETIMEV2", 3, -1), + HmsTypeMapping.toConnectorType("timestamp", new HmsTypeMapping.Options(3, false, false))); + } + + @Test + public void testBinaryDefaultAndVarbinaryOption() { + Assertions.assertEquals(ConnectorType.of("STRING"), map("binary")); + Assertions.assertEquals(ConnectorType.of("VARBINARY"), + HmsTypeMapping.toConnectorType("binary", new HmsTypeMapping.Options(6, true, false))); + } + + @Test + public void testCharAndVarcharLength() { + Assertions.assertEquals(ConnectorType.of("CHAR", 10, -1), map("char(10)")); + Assertions.assertEquals(ConnectorType.of("VARCHAR", 255, -1), map("varchar(255)")); + // Missing length parameter degrades to the unparameterized type, not a crash. + Assertions.assertEquals(ConnectorType.of("CHAR"), map("char")); + Assertions.assertEquals(ConnectorType.of("VARCHAR"), map("varchar")); + } + + @Test + public void testDecimalPrecisionScaleAndDefaults() { + Assertions.assertEquals(ConnectorType.of("DECIMALV3", 10, 2), map("decimal(10,2)")); + // Only precision given -> default scale 0. + Assertions.assertEquals(ConnectorType.of("DECIMALV3", 10, 0), map("decimal(10)")); + // Bare decimal -> default precision 9, scale 0. + Assertions.assertEquals(ConnectorType.of("DECIMALV3", 9, 0), map("decimal")); + } + + @Test + public void testArrayIncludingNested() { + Assertions.assertEquals(ConnectorType.arrayOf(ConnectorType.of("INT")), map("array")); + Assertions.assertEquals( + ConnectorType.arrayOf(ConnectorType.arrayOf(ConnectorType.of("STRING"))), + map("array>")); + } + + @Test + public void testMapIncludingNestedValue() { + Assertions.assertEquals( + ConnectorType.mapOf(ConnectorType.of("STRING"), ConnectorType.of("INT")), + map("map")); + // The inner comma of the nested array value must NOT be mistaken for the key/value + // separator — this is exactly what findNextNestedField guards. + Assertions.assertEquals( + ConnectorType.mapOf(ConnectorType.of("INT"), + ConnectorType.arrayOf(ConnectorType.of("STRING"))), + map("map>")); + } + + @Test + public void testStructIncludingNestedFields() { + Assertions.assertEquals( + ConnectorType.structOf(Arrays.asList("a", "b"), + Arrays.asList(ConnectorType.of("INT"), ConnectorType.of("STRING"))), + map("struct")); + Assertions.assertEquals( + ConnectorType.structOf(Arrays.asList("x", "y"), + Arrays.asList(ConnectorType.arrayOf(ConnectorType.of("INT")), + ConnectorType.mapOf(ConnectorType.of("STRING"), ConnectorType.of("BIGINT")))), + map("struct,y:map>")); + } + + @Test + public void testTimestampWithLocalTimeZone() { + // Default: mapped to DATETIMEV2. + Assertions.assertEquals(ConnectorType.of("DATETIMEV2", 6, -1), + map("timestamp with local time zone")); + // With the timestamp-tz option: mapped to TIMESTAMPTZ. + Assertions.assertEquals(ConnectorType.of("TIMESTAMPTZ", 6, -1), + HmsTypeMapping.toConnectorType("timestamp with local time zone", + new HmsTypeMapping.Options(6, false, true))); + } + + @Test + public void testUnsupportedTypeIsUnsupportedNotCrash() { + Assertions.assertEquals(ConnectorType.of("UNSUPPORTED"), map("interval_day_time")); + Assertions.assertEquals(ConnectorType.of("UNSUPPORTED"), map("void")); + } + + @Test + public void testCaseInsensitiveAndLowercasesNestedNames() { + Assertions.assertEquals(ConnectorType.of("INT"), map("INT")); + Assertions.assertEquals(ConnectorType.arrayOf(ConnectorType.of("STRING")), map("ARRAY")); + // The whole type string is lowercased first, so struct field names are lowercased too. + Assertions.assertEquals( + ConnectorType.structOf(Arrays.asList("name"), Arrays.asList(ConnectorType.of("INT"))), + map("STRUCT")); + } + + @Test + public void testFindNextNestedFieldRespectsNesting() { + // Top-level comma found at the right index... + Assertions.assertEquals(3, HmsTypeMapping.findNextNestedField("int,string")); + Assertions.assertEquals(10, HmsTypeMapping.findNextNestedField("array,string")); + // ...and a comma nested inside <> is skipped (returns the next top-level comma). + Assertions.assertEquals(15, HmsTypeMapping.findNextNestedField("map,extra")); + // No top-level comma -> returns the length. + Assertions.assertEquals(3, HmsTypeMapping.findNextNestedField("int")); + } + + // ==================== reverse mapping: ConnectorType -> Hive type string ==================== + // + // WHY: toHiveTypeString is the CREATE TABLE direction, the SPI-clean equivalent of fe-core + // HiveMetaStoreClientHelper.dorisTypeToHiveType. Its input type names are Doris PrimitiveType + // names (what ConnectorColumnConverter.toConnectorType emits via PrimitiveType.toString()). A + // wrong reverse mapping silently mistypes every column of a table Doris creates in Hive, so + // these pin the exact Hive string per Doris type — especially the ones that intentionally + // collapse (VARCHAR->string) or drop parameters (timestamp), and the unsupported ones that + // must throw rather than emit a bogus type. + + private static String hive(ConnectorType type) { + return HmsTypeMapping.toHiveTypeString(type); + } + + @Test + public void testToHiveTypeStringPrimitives() { + Assertions.assertEquals("boolean", hive(ConnectorType.of("BOOLEAN"))); + Assertions.assertEquals("tinyint", hive(ConnectorType.of("TINYINT"))); + Assertions.assertEquals("smallint", hive(ConnectorType.of("SMALLINT"))); + Assertions.assertEquals("int", hive(ConnectorType.of("INT"))); + Assertions.assertEquals("bigint", hive(ConnectorType.of("BIGINT"))); + Assertions.assertEquals("float", hive(ConnectorType.of("FLOAT"))); + Assertions.assertEquals("double", hive(ConnectorType.of("DOUBLE"))); + Assertions.assertEquals("string", hive(ConnectorType.of("STRING"))); + } + + @Test + public void testToHiveTypeStringDateAndTimestampVariants() { + // Both the legacy and V2 date/datetime primitives collapse to Hive date/timestamp. + Assertions.assertEquals("date", hive(ConnectorType.of("DATE"))); + Assertions.assertEquals("date", hive(ConnectorType.of("DATEV2"))); + Assertions.assertEquals("timestamp", hive(ConnectorType.of("DATETIME"))); + // The datetime scale carried on the ConnectorType is intentionally dropped (Hive timestamp has none). + Assertions.assertEquals("timestamp", hive(ConnectorType.of("DATETIMEV2", 6, -1))); + } + + @Test + public void testToHiveTypeStringCharVarcharString() { + // CHAR carries its length in the precision field (create-request encoding). + Assertions.assertEquals("char(10)", hive(ConnectorType.of("CHAR", 10, 0))); + // VARCHAR intentionally maps to Hive string (parity with legacy dorisTypeToHiveType). + Assertions.assertEquals("string", hive(ConnectorType.of("VARCHAR", 255, 0))); + Assertions.assertEquals("string", hive(ConnectorType.of("STRING"))); + } + + @Test + public void testToHiveTypeStringDecimalVariantsAndDefault() { + // Every Doris decimal storage width maps to Hive decimal(p,s). + Assertions.assertEquals("decimal(10,2)", hive(ConnectorType.of("DECIMAL64", 10, 2))); + Assertions.assertEquals("decimal(38,10)", hive(ConnectorType.of("DECIMAL128", 38, 10))); + Assertions.assertEquals("decimal(9,0)", hive(ConnectorType.of("DECIMALV2", 9, 0))); + Assertions.assertEquals("decimal(76,0)", hive(ConnectorType.of("DECIMAL256", 76, 0))); + // A read-origin DECIMALV3 name is accepted too. + Assertions.assertEquals("decimal(5,3)", hive(ConnectorType.of("DECIMALV3", 5, 3))); + // Precision 0 falls back to the default precision 9 (parity with legacy). + Assertions.assertEquals("decimal(9,0)", hive(ConnectorType.of("DECIMAL32", 0, 0))); + } + + @Test + public void testToHiveTypeStringComplexIncludingNested() { + Assertions.assertEquals("array", hive(ConnectorType.arrayOf(ConnectorType.of("INT")))); + Assertions.assertEquals("map", + hive(ConnectorType.mapOf(ConnectorType.of("STRING"), ConnectorType.of("BIGINT")))); + Assertions.assertEquals("struct", + hive(ConnectorType.structOf(Arrays.asList("a", "b"), + Arrays.asList(ConnectorType.of("INT"), ConnectorType.of("STRING"))))); + // Nested: an array of structs of a map. + ConnectorType nested = ConnectorType.arrayOf( + ConnectorType.structOf(Arrays.asList("m"), + Arrays.asList(ConnectorType.mapOf(ConnectorType.of("STRING"), + ConnectorType.of("INT"))))); + Assertions.assertEquals("array>>", hive(nested)); + } + + @Test + public void testToHiveTypeStringUnsupportedThrows() { + // Types Hive tables cannot represent must throw, not emit a bogus type string. + Assertions.assertThrows(IllegalArgumentException.class, + () -> hive(ConnectorType.of("LARGEINT"))); + Assertions.assertThrows(IllegalArgumentException.class, + () -> hive(ConnectorType.of("IPV4"))); + Assertions.assertThrows(IllegalArgumentException.class, + () -> hive(ConnectorType.of("JSONB"))); + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/HmsWriteConverterTest.java b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/HmsWriteConverterTest.java new file mode 100644 index 00000000000000..f74950af21c3a2 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/HmsWriteConverterTest.java @@ -0,0 +1,335 @@ +// 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.hms; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorType; + +import org.apache.hadoop.hive.common.StatsSetupConst; +import org.apache.hadoop.hive.metastore.api.Database; +import org.apache.hadoop.hive.metastore.api.FieldSchema; +import org.apache.hadoop.hive.metastore.api.Partition; +import org.apache.hadoop.hive.metastore.api.PrincipalType; +import org.apache.hadoop.hive.metastore.api.StorageDescriptor; +import org.apache.hadoop.hive.metastore.api.Table; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Tests {@link HmsWriteConverter} — the CREATE TABLE / CREATE DATABASE direction, the SPI-clean + * equivalent of fe-core {@code HiveUtil.toHiveTable}/{@code toHiveDatabase} plus + * {@code HiveProperties.setTableProperties}. + * + *

WHY: this converter decides exactly what metastore object Doris writes when a user creates a + * Hive table. Bugs here silently produce an unreadable table (wrong serde/format), lose the + * data/partition-column split, or misplace serde properties. These pin the storage descriptor, + * the per-format compression defaults, the property split, and the column split — the behavior + * the connector's DDL path depends on (Rule 9: encode the contract).

+ */ +public class HmsWriteConverterTest { + + private static ConnectorColumn col(String name, String type) { + return new ConnectorColumn(name, ConnectorType.of(type), null, true, null); + } + + private static HmsCreateTableRequest.Builder baseTable(String fileFormat, List columns, + List partitionKeys) { + return HmsCreateTableRequest.builder() + .dbName("db") + .tableName("t") + .columns(columns) + .partitionKeys(partitionKeys) + .fileFormat(fileFormat) + .properties(new HashMap<>()); + } + + @Test + public void testOrcTableFormatAndColumnSplit() { + List columns = Arrays.asList( + col("id", "INT"), + col("name", "STRING"), + col("dt", "DATEV2")); + Table table = HmsWriteConverter.toHiveTable( + baseTable("orc", columns, Collections.singletonList("dt")) + .location("hdfs://ns/db/t") + .dorisVersion("2.1.0-abc123") + .comment("hello") + .properties(mutableMap("owner", "alice")) + .build()); + + Assertions.assertEquals("db", table.getDbName()); + Assertions.assertEquals("t", table.getTableName()); + Assertions.assertEquals("MANAGED_TABLE", table.getTableType()); + Assertions.assertEquals("alice", table.getOwner()); + + // Data columns exclude the partition key; partition key is carried separately. + StorageDescriptor sd = table.getSd(); + Assertions.assertEquals(Arrays.asList("id", "name"), names(sd.getCols())); + Assertions.assertEquals(Arrays.asList("int", "string"), types(sd.getCols())); + Assertions.assertEquals(Collections.singletonList("dt"), names(table.getPartitionKeys())); + Assertions.assertEquals(Collections.singletonList("date"), types(table.getPartitionKeys())); + + // ORC storage formats + serde. + Assertions.assertEquals("org.apache.hadoop.hive.ql.io.orc.OrcInputFormat", sd.getInputFormat()); + Assertions.assertEquals("org.apache.hadoop.hive.ql.io.orc.OrcOutputFormat", sd.getOutputFormat()); + Assertions.assertEquals("org.apache.hadoop.hive.ql.io.orc.OrcSerde", + sd.getSerdeInfo().getSerializationLib()); + Assertions.assertEquals("hdfs://ns/db/t", sd.getLocation()); + Assertions.assertEquals("doris external hive table", sd.getParameters().get("tag")); + + // Table params: doris.version stamped, comment set, default ORC compression applied. + Assertions.assertEquals("2.1.0-abc123", table.getParameters().get("doris.version")); + Assertions.assertEquals("hello", table.getParameters().get("comment")); + Assertions.assertEquals("zlib", table.getParameters().get("orc.compress")); + } + + @Test + public void testParquetDefaultCompression() { + Table table = HmsWriteConverter.toHiveTable( + baseTable("parquet", Collections.singletonList(col("id", "INT")), + Collections.emptyList()).build()); + StorageDescriptor sd = table.getSd(); + Assertions.assertEquals("org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat", + sd.getInputFormat()); + Assertions.assertEquals("org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe", + sd.getSerdeInfo().getSerializationLib()); + Assertions.assertEquals("snappy", table.getParameters().get("parquet.compression")); + } + + @Test + public void testTextCompressionUsesRequestDefaultThenPlainFallback() { + // The connector-resolved session default flows through for a text table. + Table withDefault = HmsWriteConverter.toHiveTable( + baseTable("text", Collections.singletonList(col("id", "INT")), + Collections.emptyList()).defaultTextCompression("gzip").build()); + Assertions.assertEquals("org.apache.hadoop.mapred.TextInputFormat", + withDefault.getSd().getInputFormat()); + Assertions.assertEquals("gzip", withDefault.getParameters().get("text.compression")); + + // No request default -> "plain" fallback (matches the legacy session-var default). + Table noDefault = HmsWriteConverter.toHiveTable( + baseTable("text", Collections.singletonList(col("id", "INT")), + Collections.emptyList()).build()); + Assertions.assertEquals("plain", noDefault.getParameters().get("text.compression")); + } + + @Test + public void testExplicitCompressionHonoredAndKeyRemoved() { + Table table = HmsWriteConverter.toHiveTable( + baseTable("orc", Collections.singletonList(col("id", "INT")), + Collections.emptyList()).properties(mutableMap("compression", "snappy")).build()); + Assertions.assertEquals("snappy", table.getParameters().get("orc.compress")); + // The transient "compression" property must not leak onto the table. + Assertions.assertFalse(table.getParameters().containsKey("compression")); + } + + @Test + public void testUnsupportedCompressionAndFormatThrow() { + Assertions.assertThrows(IllegalArgumentException.class, () -> + HmsWriteConverter.toHiveTable( + baseTable("orc", Collections.singletonList(col("id", "INT")), + Collections.emptyList()) + .properties(mutableMap("compression", "bogus")).build())); + Assertions.assertThrows(IllegalArgumentException.class, () -> + HmsWriteConverter.toHiveTable( + baseTable("avro", Collections.singletonList(col("id", "INT")), + Collections.emptyList()).build())); + } + + @Test + public void testSerdePropertiesSplitFromTableProperties() { + Map props = mutableMap("field.delim", ","); + props.put("my.custom", "v"); + Table table = HmsWriteConverter.toHiveTable( + baseTable("text", Collections.singletonList(col("id", "INT")), + Collections.emptyList()).properties(props).build()); + // field.delim is a serde property -> goes to the SerDe params, not the table params. + Assertions.assertEquals(",", table.getSd().getSerdeInfo().getParameters().get("field.delim")); + Assertions.assertFalse(table.getParameters().containsKey("field.delim")); + // A non-serde property stays on the table. + Assertions.assertEquals("v", table.getParameters().get("my.custom")); + } + + @Test + public void testDorisVersionOmittedWhenAbsent() { + Table table = HmsWriteConverter.toHiveTable( + baseTable("orc", Collections.singletonList(col("id", "INT")), + Collections.emptyList()).build()); + Assertions.assertFalse(table.getParameters().containsKey("doris.version")); + } + + @Test + public void testBucketingCarriedToStorageDescriptor() { + Table table = HmsWriteConverter.toHiveTable( + baseTable("orc", Collections.singletonList(col("id", "INT")), + Collections.emptyList()) + .bucketCols(Collections.singletonList("id")).numBuckets(8).build()); + Assertions.assertEquals(Collections.singletonList("id"), table.getSd().getBucketCols()); + Assertions.assertEquals(8, table.getSd().getNumBuckets()); + } + + @Test + public void testToHiveDatabase() { + Database db = HmsWriteConverter.toHiveDatabase(new HmsCreateDatabaseRequest( + "mydb", "hdfs://ns/mydb", "a comment", mutableMap("owner", "bob"))); + Assertions.assertEquals("mydb", db.getName()); + Assertions.assertEquals("hdfs://ns/mydb", db.getLocationUri()); + Assertions.assertEquals("a comment", db.getDescription()); + Assertions.assertEquals("bob", db.getOwnerName()); + Assertions.assertEquals(PrincipalType.USER, db.getOwnerType()); + } + + @Test + public void testToHiveDatabaseNoLocationNoOwner() { + Database db = HmsWriteConverter.toHiveDatabase(new HmsCreateDatabaseRequest( + "mydb", null, null, new HashMap<>())); + Assertions.assertEquals("mydb", db.getName()); + Assertions.assertFalse(db.isSetLocationUri()); + Assertions.assertNull(db.getOwnerName()); + // Comment normalizes to empty string, never null. + Assertions.assertEquals("", db.getDescription()); + } + + @Test + public void testToHivePartitionsBuildsPartitionWithStatsAndSd() { + // WHY: the add-partition commit path depends on the storage descriptor (location/serde/format) + // and the basic-stats parameters being stamped exactly as HMS expects, or the created + // partition is unreadable or its row/byte counts are wrong. + HmsPartitionWithStatistics part = HmsPartitionWithStatistics.builder() + .name("dt=2024-01-01") + .partitionValues(Collections.singletonList("2024-01-01")) + .location("hdfs://ns/db/t/dt=2024-01-01") + .columns(Collections.singletonList(fieldSchema("id", "int"))) + .inputFormat("org.apache.hadoop.hive.ql.io.orc.OrcInputFormat") + .outputFormat("org.apache.hadoop.hive.ql.io.orc.OrcOutputFormat") + .serde("org.apache.hadoop.hive.ql.io.orc.OrcSerde") + .parameters(new HashMap<>()) + .statistics(HmsPartitionStatistics.fromCommonStatistics(10, 2, 100)) + .build(); + + List partitions = HmsWriteConverter.toHivePartitions("db", "t", + Collections.singletonList(part)); + + Assertions.assertEquals(1, partitions.size()); + Partition p = partitions.get(0); + Assertions.assertEquals("db", p.getDbName()); + Assertions.assertEquals("t", p.getTableName()); + Assertions.assertEquals(Collections.singletonList("2024-01-01"), p.getValues()); + + StorageDescriptor sd = p.getSd(); + Assertions.assertEquals("hdfs://ns/db/t/dt=2024-01-01", sd.getLocation()); + Assertions.assertEquals(Collections.singletonList("id"), names(sd.getCols())); + Assertions.assertEquals("org.apache.hadoop.hive.ql.io.orc.OrcInputFormat", sd.getInputFormat()); + Assertions.assertEquals("org.apache.hadoop.hive.ql.io.orc.OrcOutputFormat", sd.getOutputFormat()); + Assertions.assertEquals("org.apache.hadoop.hive.ql.io.orc.OrcSerde", + sd.getSerdeInfo().getSerializationLib()); + + // Basic statistics land on the partition parameters (numRows / numFiles / totalSize). + Assertions.assertEquals("10", p.getParameters().get(StatsSetupConst.ROW_COUNT)); + Assertions.assertEquals("2", p.getParameters().get(StatsSetupConst.NUM_FILES)); + Assertions.assertEquals("100", p.getParameters().get(StatsSetupConst.TOTAL_SIZE)); + } + + @Test + public void testToHivePartitionsEmptyLocationBecomesNull() { + // Strings.emptyToNull parity: an empty location must not be written as "" (HMS treats "" and + // null differently for partition location resolution). + HmsPartitionWithStatistics part = HmsPartitionWithStatistics.builder() + .partitionValues(Collections.singletonList("2024")) + .location("") + .columns(Collections.emptyList()) + .parameters(new HashMap<>()) + .statistics(HmsPartitionStatistics.EMPTY) + .build(); + Partition p = HmsWriteConverter.toHivePartitions("db", "t", + Collections.singletonList(part)).get(0); + Assertions.assertNull(p.getSd().getLocation()); + } + + @Test + public void testToStatisticsParametersStampsBasicStatsAndWorkaroundFlag() { + Map origin = mutableMap("existing", "kept"); + Map result = HmsWriteConverter.toStatisticsParameters( + origin, new HmsCommonStatistics(7, 3, 70)); + Assertions.assertEquals("7", result.get(StatsSetupConst.ROW_COUNT)); + Assertions.assertEquals("3", result.get(StatsSetupConst.NUM_FILES)); + Assertions.assertEquals("70", result.get(StatsSetupConst.TOTAL_SIZE)); + // Pre-existing params survive. + Assertions.assertEquals("kept", result.get("existing")); + // CDH workaround flag added when absent. + Assertions.assertEquals("workaround for potential lack of HIVE-12730", + result.get("STATS_GENERATED_VIA_STATS_TASK")); + // The source map is not mutated (defensive copy). + Assertions.assertFalse(origin.containsKey(StatsSetupConst.ROW_COUNT)); + } + + @Test + public void testToStatisticsParametersKeepsExistingWorkaroundFlag() { + Map origin = mutableMap("STATS_GENERATED_VIA_STATS_TASK", "already-set"); + Map result = HmsWriteConverter.toStatisticsParameters( + origin, HmsCommonStatistics.EMPTY); + Assertions.assertEquals("already-set", result.get("STATS_GENERATED_VIA_STATS_TASK")); + } + + @Test + public void testToPartitionStatisticsReadsBackParamsAndDefaults() { + Map params = new HashMap<>(); + params.put(StatsSetupConst.ROW_COUNT, "42"); + params.put(StatsSetupConst.NUM_FILES, "4"); + params.put(StatsSetupConst.TOTAL_SIZE, "420"); + HmsCommonStatistics stats = + HmsWriteConverter.toPartitionStatistics(params).getCommonStatistics(); + Assertions.assertEquals(42, stats.getRowCount()); + Assertions.assertEquals(4, stats.getFileCount()); + Assertions.assertEquals(420, stats.getTotalFileBytes()); + + // Missing params default to -1 (legacy sentinel for "unknown"). + HmsCommonStatistics missing = + HmsWriteConverter.toPartitionStatistics(new HashMap<>()).getCommonStatistics(); + Assertions.assertEquals(-1, missing.getRowCount()); + Assertions.assertEquals(-1, missing.getFileCount()); + Assertions.assertEquals(-1, missing.getTotalFileBytes()); + } + + private static FieldSchema fieldSchema(String name, String type) { + FieldSchema fs = new FieldSchema(); + fs.setName(name); + fs.setType(type); + return fs; + } + + private static List names(List schemas) { + return schemas.stream().map(FieldSchema::getName).collect(java.util.stream.Collectors.toList()); + } + + private static List types(List schemas) { + return schemas.stream().map(FieldSchema::getType).collect(java.util.stream.Collectors.toList()); + } + + private static Map mutableMap(String k, String v) { + Map m = new HashMap<>(); + m.put(k, v); + return m; + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientColumnStatsTest.java b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientColumnStatsTest.java new file mode 100644 index 00000000000000..87b037411903a0 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientColumnStatsTest.java @@ -0,0 +1,108 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hms; + +import org.apache.hadoop.hive.metastore.api.BooleanColumnStatsData; +import org.apache.hadoop.hive.metastore.api.ColumnStatisticsData; +import org.apache.hadoop.hive.metastore.api.ColumnStatisticsObj; +import org.apache.hadoop.hive.metastore.api.LongColumnStatsData; +import org.apache.hadoop.hive.metastore.api.StringColumnStatsData; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link ThriftHmsClient#convertColumnStatistics}: the byte-faithful extraction of ndv / numNulls / + * avgColLen from a hive {@code ColumnStatisticsObj}, ported from legacy {@code HMSExternalTable.setStatData}. + * + *

WHY: the variant handling must match legacy exactly — a string column captures {@code avgColLen} (used + * for its data-size estimate), a numeric column leaves it {@code -1} (the consumer uses the fixed slot width), + * and a variant the legacy reader does NOT handle (boolean / binary / timestamp) must yield ndv=0 / numNulls=0 + * even though the metastore recorded a null count — otherwise a boolean column would report a spurious ndv.

+ */ +public class ThriftHmsClientColumnStatsTest { + + @Test + public void stringStatsCaptureAvgColLen() { + StringColumnStatsData s = new StringColumnStatsData(); + s.setNumDVs(10); + s.setNumNulls(2); + s.setAvgColLen(5.0); + s.setMaxColLen(20); + HmsColumnStatistics stat = ThriftHmsClient.convertColumnStatistics(objWith("c", "string", s, true)); + Assertions.assertEquals(10, stat.getNdv()); + Assertions.assertEquals(2, stat.getNumNulls()); + Assertions.assertEquals(5.0, stat.getAvgColLenBytes(), 0.0); + } + + @Test + public void longStatsLeaveAvgColLenUnset() { + LongColumnStatsData l = new LongColumnStatsData(); + l.setNumDVs(7); + l.setNumNulls(1); + HmsColumnStatistics stat = ThriftHmsClient.convertColumnStatistics(objWith("c", "bigint", l, false)); + Assertions.assertEquals(7, stat.getNdv()); + Assertions.assertEquals(1, stat.getNumNulls()); + // -1 => non-string; the consumer uses the Doris slot width. + Assertions.assertEquals(-1, stat.getAvgColLenBytes(), 0.0); + } + + @Test + public void unhandledVariantYieldsZeroNdvAndNulls() { + // Boolean is a variant legacy setStatData does not read, so ndv/numNulls stay 0 even though the + // metastore recorded numNulls=3 — pinning legacy parity (no spurious ndv for boolean/binary/timestamp). + BooleanColumnStatsData b = new BooleanColumnStatsData(); + b.setNumTrues(5); + b.setNumFalses(3); + b.setNumNulls(3); + ColumnStatisticsData data = new ColumnStatisticsData(); + data.setBooleanStats(b); + ColumnStatisticsObj obj = new ColumnStatisticsObj(); + obj.setColName("c"); + obj.setColType("boolean"); + obj.setStatsData(data); + HmsColumnStatistics stat = ThriftHmsClient.convertColumnStatistics(obj); + Assertions.assertEquals(0, stat.getNdv()); + Assertions.assertEquals(0, stat.getNumNulls()); + Assertions.assertEquals(-1, stat.getAvgColLenBytes(), 0.0); + } + + @Test + public void missingStatsDataYieldsZeros() { + ColumnStatisticsObj obj = new ColumnStatisticsObj(); + obj.setColName("c"); + HmsColumnStatistics stat = ThriftHmsClient.convertColumnStatistics(obj); + Assertions.assertEquals(0, stat.getNdv()); + Assertions.assertEquals(0, stat.getNumNulls()); + Assertions.assertEquals(-1, stat.getAvgColLenBytes(), 0.0); + Assertions.assertEquals("c", stat.getColumnName()); + } + + private static ColumnStatisticsObj objWith(String name, String type, Object variant, boolean isString) { + ColumnStatisticsData data = new ColumnStatisticsData(); + if (isString) { + data.setStringStats((StringColumnStatsData) variant); + } else { + data.setLongStats((LongColumnStatsData) variant); + } + ColumnStatisticsObj obj = new ColumnStatisticsObj(); + obj.setColName(name); + obj.setColType(type); + obj.setStatsData(data); + return obj; + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientDoAsClassLoaderTest.java b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientDoAsClassLoaderTest.java new file mode 100644 index 00000000000000..6ea2e24d4dbdd7 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientDoAsClassLoaderTest.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.hms; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.List; + +/** + * Tests {@link ThriftHmsClient}'s internal {@code doAs}: it MUST pin the thread-context classloader (TCCL) to + * the plugin (child-first) classloader that loaded this client while it creates/uses the metastore client, + * then restore the caller's TCCL. + * + *

WHY: metastore client creation runs Hadoop's {@code SecurityUtil.}, whose internal + * {@code new Configuration()} captures the current TCCL to reflectively load {@code DNSDomainNameResolver}. In + * production the hive plugin bundles its own child-first hadoop copy while fe-core carries another on the + * system classloader; a system-loader TCCL loads {@code DNSDomainNameResolver} from fe-core's copy while + * {@code SecurityUtil}/{@code DomainNameResolver} resolve from the plugin's copy — a classloader split-brain + * ("class DNSDomainNameResolver not DomainNameResolver") that permanently poisons {@code SecurityUtil} + * JVM-wide. TeamCity build 991951 failed 49 hive/iceberg-on-HMS/hudi/mtmv/kerberos cases exactly this way. + * + *

The bug is invisible under a plain surefire loader (there {@code getSystemClassLoader()} and + * {@code ThriftHmsClient.class.getClassLoader()} are the SAME object). To make it observable we reproduce the + * production two-copy topology: the {@link DoAsTcclProbe} is invoked THROUGH an isolated child-first loader, so + * inside it {@code ThriftHmsClient.class.getClassLoader()} is that isolated loader — distinct from the system + * loader. A regression to {@code getSystemClassLoader()}, to no pin, or to a dropped restore is then RED. This + * mirrors {@code OdpsClassloaderIsolationTest}'s isolation approach. + */ +public class ThriftHmsClientDoAsClassLoaderTest { + + @Test + public void doAsPinsPluginLoaderNotSystemAndRestores() throws Exception { + try (IsolatedChildFirstClassLoader loader = new IsolatedChildFirstClassLoader(classpathUrls())) { + Class probe = loader.loadClass("org.apache.doris.connector.hms.DoAsTcclProbe"); + Assertions.assertSame(loader, probe.getClassLoader(), + "sanity: the probe must be defined by the isolated child-first loader"); + Assertions.assertSame(loader, loader.loadClass(ThriftHmsClient.class.getName()).getClassLoader(), + "sanity: ThriftHmsClient must be defined by the isolated loader, so getClass().getClassLoader() " + + "differs from the system loader (that is what makes the split-brain observable)"); + + Method check = probe.getMethod("check"); + String result = (String) check.invoke(null); + Assertions.assertEquals("OK", result, + "doAs must pin the TCCL to the connector's own (plugin child-first) classloader — NOT the " + + "system classloader (the SecurityUtil split-brain root cause) — and restore the caller's " + + "TCCL afterwards; probe reported: " + result); + } + } + + private static URL[] classpathUrls() throws Exception { + String classpath = System.getProperty("java.class.path"); + String[] entries = classpath.split(File.pathSeparator); + List urls = new ArrayList<>(entries.length); + for (String entry : entries) { + if (!entry.isEmpty()) { + urls.add(new File(entry).toURI().toURL()); + } + } + return urls.toArray(new URL[0]); + } + + /** + * Child-first loader: defines every non-JDK class from its own URLs (delegating only JDK packages to the + * parent). This gives the probe — and the {@code ThriftHmsClient} + hadoop it touches — an isolated copy, + * a superset of the production plugin isolation. Mirrors {@code OdpsClassloaderIsolationTest}. + */ + private static final class IsolatedChildFirstClassLoader extends URLClassLoader { + + IsolatedChildFirstClassLoader(URL[] urls) { + super(urls, ClassLoader.getSystemClassLoader().getParent()); + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + if (loaded == null) { + if (isJdkClass(name)) { + loaded = super.loadClass(name, false); + } else { + try { + loaded = findClass(name); + } catch (ClassNotFoundException notLocal) { + loaded = super.loadClass(name, false); + } + } + } + if (resolve) { + resolveClass(loaded); + } + return loaded; + } + } + + private static boolean isJdkClass(String name) { + return name.startsWith("java.") || name.startsWith("javax.") + || name.startsWith("jdk.") || name.startsWith("sun.") + || name.startsWith("com.sun.") || name.startsWith("org.w3c.") + || name.startsWith("org.xml."); + } + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientMaxPartsTest.java b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientMaxPartsTest.java new file mode 100644 index 00000000000000..2231451a48527e --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientMaxPartsTest.java @@ -0,0 +1,59 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hms; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link ThriftHmsClient#toThriftMaxParts}: the connector's {@code maxParts} contract mapped onto the + * {@code short max_parts} that HMS {@code get_partition_names} accepts. + * + *

WHY: a non-positive {@code maxParts} means "all partitions" and MUST map to a negative short (HMS reads + * a negative {@code max_parts} as unbounded). A prior implementation clamped it to {@code Short.MAX_VALUE} + * (32767), which silently truncated any table with more than 32767 partitions — defeating the {@code -1} + * "unlimited" contract the hive/hudi partition-listing callers rely on and diverging from the legacy client, + * which passed {@code Config.max_hive_list_partition_num = -1} straight through. These assertions pin the + * unbounded mapping so the truncation cannot silently return.

+ */ +public class ThriftHmsClientMaxPartsTest { + + @Test + public void testNonPositiveMeansUnbounded() { + // -1 and 0 both mean "all"; HMS reads a negative short as unbounded. + Assertions.assertEquals((short) -1, ThriftHmsClient.toThriftMaxParts(-1)); + Assertions.assertEquals((short) -1, ThriftHmsClient.toThriftMaxParts(0)); + // Must NOT be the old silent cap. + Assertions.assertNotEquals(Short.MAX_VALUE, ThriftHmsClient.toThriftMaxParts(-1)); + } + + @Test + public void testPositiveWithinShortIsPassedThrough() { + Assertions.assertEquals((short) 1, ThriftHmsClient.toThriftMaxParts(1)); + Assertions.assertEquals((short) 100, ThriftHmsClient.toThriftMaxParts(100)); + Assertions.assertEquals(Short.MAX_VALUE, ThriftHmsClient.toThriftMaxParts(Short.MAX_VALUE)); + } + + @Test + public void testPositiveAboveShortNarrowsToUnbounded() { + // The 100000-cap callers rely on this: (short) 100000 is negative, so HMS treats it as unbounded. + short mapped = ThriftHmsClient.toThriftMaxParts(100000); + Assertions.assertEquals((short) 100000, mapped); + Assertions.assertTrue(mapped < 0, "a value above Short.MAX_VALUE must narrow to a negative (unbounded) short"); + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientRootCauseMessageTest.java b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientRootCauseMessageTest.java new file mode 100644 index 00000000000000..57347d27deb322 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientRootCauseMessageTest.java @@ -0,0 +1,92 @@ +// 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.hms; + +import org.apache.hadoop.hive.metastore.api.MetaException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationTargetException; + +/** + * Tests {@link ThriftHmsClient#withRootCause}: the client-creation failure message must preserve the deepest + * cause so a SASL/kerberos/thrift transport error stays visible to the user. + * + *

WHY: Hive buries the real connection failure — e.g. {@code TTransportException: GSS initiate failed} on a + * kerberos misconfiguration — inside a generic {@code RuntimeException("Unable to instantiate + * ...HiveMetaStoreClient")} whose own message drops that cause, and FE surfaces only the top exception's + * message. The legacy {@code ThriftHMSCachedClient} appended {@code Util.getRootCauseMessage(cause)}, so + * {@code external_table_p0/kerberos/test_single_hive_kerberos} asserts the surfaced error contains the thrift + * transport reason. These assertions pin that the connector restores the same behavior — and does not + * duplicate the reason when the pool re-wraps a fresh-client failure. + */ +public class ThriftHmsClientRootCauseMessageTest { + + // The message Hive builds via StringUtils.stringifyException when it cannot open the metastore transport. + private static final String GSS_REASON = + "Could not connect to meta store using any of the URIs provided. Most recent failure: " + + "shade.doris.hive.org.apache.thrift.transport.TTransportException: GSS initiate failed"; + + /** Rebuilds the exact nesting the plugin-driven HMS client produces for a kerberos SASL failure. */ + private static Throwable unableToInstantiate() { + MetaException root = new MetaException(GSS_REASON); + InvocationTargetException reflective = new InvocationTargetException(root); + return new RuntimeException( + "Unable to instantiate org.apache.hadoop.hive.metastore.HiveMetaStoreClient", reflective); + } + + @Test + public void freshClientMessageSurfacesThriftRootCause() { + Throwable cause = unableToInstantiate(); + String message = ThriftHmsClient.withRootCause("Failed to create HMS client: " + cause.getMessage(), cause); + // err1 assertion in test_single_hive_kerberos.groovy. + Assertions.assertTrue(message.contains("thrift.transport.TTransportException"), message); + // err2 assertion in the same suite expects the full "could not connect ... GSS initiate failed" reason. + Assertions.assertTrue(message.contains(GSS_REASON), message); + } + + @Test + public void poolReWrapDoesNotDuplicateReason() { + Throwable cause = unableToInstantiate(); + // First wrap: what createFreshClient() throws. + String createMessage = + ThriftHmsClient.withRootCause("Failed to create HMS client: " + cause.getMessage(), cause); + HmsClientException createFailure = new HmsClientException(createMessage, cause); + // Second wrap: what borrowClient() throws around the pool factory failure. + String borrowMessage = ThriftHmsClient.withRootCause( + "Failed to borrow HMS client from pool: " + createFailure.getMessage(), createFailure); + + Assertions.assertTrue(borrowMessage.contains("thrift.transport.TTransportException"), borrowMessage); + Assertions.assertTrue(borrowMessage.contains(GSS_REASON), borrowMessage); + // The reason must be appended exactly once even though the exception is wrapped twice. + Assertions.assertEquals(1, countOccurrences(borrowMessage, ". reason: "), borrowMessage); + } + + @Test + public void nullCauseLeavesMessageUnchanged() { + Assertions.assertEquals("plain", ThriftHmsClient.withRootCause("plain", null)); + } + + private static int countOccurrences(String haystack, String needle) { + int count = 0; + for (int i = haystack.indexOf(needle); i >= 0; i = haystack.indexOf(needle, i + needle.length())) { + count++; + } + return count; + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientWriteAcidTest.java b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientWriteAcidTest.java new file mode 100644 index 00000000000000..2d495293a5e013 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/ThriftHmsClientWriteAcidTest.java @@ -0,0 +1,455 @@ +// 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.hms; + +import org.apache.hadoop.hive.common.StatsSetupConst; +import org.apache.hadoop.hive.common.ValidReadTxnList; +import org.apache.hadoop.hive.metastore.IMetaStoreClient; +import org.apache.hadoop.hive.metastore.api.DataOperationType; +import org.apache.hadoop.hive.metastore.api.LockComponent; +import org.apache.hadoop.hive.metastore.api.LockRequest; +import org.apache.hadoop.hive.metastore.api.LockResponse; +import org.apache.hadoop.hive.metastore.api.LockState; +import org.apache.hadoop.hive.metastore.api.LockType; +import org.apache.hadoop.hive.metastore.api.NoSuchObjectException; +import org.apache.hadoop.hive.metastore.api.Partition; +import org.apache.hadoop.hive.metastore.api.Table; +import org.apache.hadoop.hive.metastore.api.TableValidWriteIds; +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.Proxy; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.BitSet; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Tests the write / read-ACID primitives added to {@link ThriftHmsClient} — the SPI-clean port of + * fe-core {@code ThriftHMSCachedClient}'s add-partition / statistics / txn / lock / valid-write-id + * calls. + * + *

WHY: these primitives are the metastore mutations behind a Hive INSERT commit and a + * transactional-read snapshot. A dropped argument, a wrong forward, or a lost graceful-degradation + * fallback silently corrupts a commit or fails a scan. The tests inject a recording fake + * {@link IMetaStoreClient} (a JDK {@link Proxy}; no Mockito in the connector modules) via the + * package-private client-provider seam with pooling disabled, then assert exactly what each primitive + * forwards to the metastore and what it returns — the behavior contract the connector transaction + * depends on (Rule 9).

+ */ +public class ThriftHmsClientWriteAcidTest { + + // ---- openTxn / commitTxn -------------------------------------------------------------------- + + @Test + public void testOpenTxnForwardsUserAndReturnsId() { + RecordingClient fake = new RecordingClient().stub("openTxn", 42L); + ThriftHmsClient client = newClient(fake); + + long txnId = client.openTxn("alice"); + + Assertions.assertEquals(42L, txnId); + Assertions.assertEquals("alice", argsOf(fake, "openTxn")[0]); + } + + @Test + public void testCommitTxnForwardsId() { + RecordingClient fake = new RecordingClient(); + ThriftHmsClient client = newClient(fake); + + client.commitTxn(7L); + + Assertions.assertEquals(7L, argsOf(fake, "commitTxn")[0]); + } + + // ---- dropPartition / partitionExists -------------------------------------------------------- + + @Test + public void testDropPartitionForwardsArgsAndReturnsResult() { + RecordingClient fake = new RecordingClient().stub("dropPartition", Boolean.TRUE); + ThriftHmsClient client = newClient(fake); + + boolean dropped = client.dropPartition("db", "t", + Collections.singletonList("2024"), false); + + Assertions.assertTrue(dropped); + Object[] args = argsOf(fake, "dropPartition"); + Assertions.assertEquals("db", args[0]); + Assertions.assertEquals("t", args[1]); + Assertions.assertEquals(Collections.singletonList("2024"), args[2]); + Assertions.assertEquals(false, args[3]); + } + + @Test + public void testPartitionExistsTrueWhenFound() { + RecordingClient fake = new RecordingClient().stub("getPartition", new Partition()); + ThriftHmsClient client = newClient(fake); + + Assertions.assertTrue(client.partitionExists("db", "t", + Collections.singletonList("2024"))); + } + + @Test + public void testPartitionExistsFalseOnNoSuchObject() { + // A not-found probe must swallow NoSuchObjectException and return false (drives the + // NEW->APPEND downgrade), not propagate it as a client failure. + RecordingClient fake = new RecordingClient() + .stub("getPartition", new NoSuchObjectException("no such partition")); + ThriftHmsClient client = newClient(fake); + + Assertions.assertFalse(client.partitionExists("db", "t", + Collections.singletonList("2024"))); + } + + // ---- addPartitions -------------------------------------------------------------------------- + + @Test + public void testAddPartitionsBuildsMetastorePartitions() { + RecordingClient fake = new RecordingClient().stub("add_partitions", 1); + ThriftHmsClient client = newClient(fake); + + HmsPartitionWithStatistics part = HmsPartitionWithStatistics.builder() + .partitionValues(Collections.singletonList("2024")) + .location("hdfs://ns/db/t/dt=2024") + .columns(Collections.emptyList()) + .inputFormat("in") + .outputFormat("out") + .serde("serde") + .parameters(new HashMap<>()) + .statistics(HmsPartitionStatistics.fromCommonStatistics(10, 2, 100)) + .build(); + + client.addPartitions("db", "t", Collections.singletonList(part)); + + Object[] args = argsOf(fake, "add_partitions"); + @SuppressWarnings("unchecked") + List sent = (List) args[0]; + Assertions.assertEquals(1, sent.size()); + Partition p = sent.get(0); + Assertions.assertEquals("db", p.getDbName()); + Assertions.assertEquals("t", p.getTableName()); + Assertions.assertEquals(Collections.singletonList("2024"), p.getValues()); + Assertions.assertEquals("hdfs://ns/db/t/dt=2024", p.getSd().getLocation()); + Assertions.assertEquals("10", p.getParameters().get(StatsSetupConst.ROW_COUNT)); + } + + @Test + public void testAddPartitionsBatchesInChunksOfTwenty() { + // 45 partitions -> ceil(45/20) = 3 add_partitions calls; a single giant call can exceed the + // metastore's thrift message limit. + RecordingClient fake = new RecordingClient().stub("add_partitions", 0); + ThriftHmsClient client = newClient(fake); + + List many = new ArrayList<>(); + for (int i = 0; i < 45; i++) { + many.add(HmsPartitionWithStatistics.builder() + .partitionValues(Collections.singletonList("p" + i)) + .columns(Collections.emptyList()) + .parameters(new HashMap<>()) + .statistics(HmsPartitionStatistics.EMPTY) + .build()); + } + + client.addPartitions("db", "t", many); + + // 3 chunks (20 + 20 + 5), and — the load-bearing contract — no partition is lost or + // duplicated across the chunk boundaries: the union of forwarded partitions is exactly p0..p44. + List forwarded = new ArrayList<>(); + long calls = 0; + for (int idx = 0; idx < fake.methodNames.size(); idx++) { + if (!"add_partitions".equals(fake.methodNames.get(idx))) { + continue; + } + calls++; + @SuppressWarnings("unchecked") + List batch = (List) fake.argsList.get(idx)[0]; + Assertions.assertTrue(batch.size() <= 20, "a batch exceeded ADD_PARTITIONS_BATCH_SIZE"); + for (Partition p : batch) { + forwarded.add(p.getValues().get(0)); + } + } + Assertions.assertEquals(3, calls); + Assertions.assertEquals(45, forwarded.size()); + Set expected = new HashSet<>(); + for (int i = 0; i < 45; i++) { + expected.add("p" + i); + } + Assertions.assertEquals(expected, new HashSet<>(forwarded)); + } + + // ---- table / partition statistics ----------------------------------------------------------- + + @Test + public void testUpdateTableStatisticsRebuildsParamsAndAlters() { + RecordingClient fake = new RecordingClient(); + Table origin = new Table(); + origin.setParameters(new HashMap<>()); + fake.stub("getTable", origin); + ThriftHmsClient client = newClient(fake); + + client.updateTableStatistics("db", "t", + current -> HmsPartitionStatistics.fromCommonStatistics(5, 1, 50)); + + Object[] args = argsOf(fake, "alter_table"); + Assertions.assertEquals("db", args[0]); + Assertions.assertEquals("t", args[1]); + Table altered = (Table) args[2]; + Assertions.assertEquals("5", altered.getParameters().get(StatsSetupConst.ROW_COUNT)); + Assertions.assertEquals("1", altered.getParameters().get(StatsSetupConst.NUM_FILES)); + Assertions.assertEquals("50", altered.getParameters().get(StatsSetupConst.TOTAL_SIZE)); + Assertions.assertTrue(altered.getParameters().containsKey("transient_lastDdlTime")); + } + + @Test + public void testUpdatePartitionStatisticsRebuildsParamsAndAlters() { + RecordingClient fake = new RecordingClient(); + Partition origin = new Partition(); + origin.setParameters(new HashMap<>()); + fake.stub("getPartitionsByNames", Collections.singletonList(origin)); + ThriftHmsClient client = newClient(fake); + + client.updatePartitionStatistics("db", "t", "dt=2024", + current -> HmsPartitionStatistics.fromCommonStatistics(3, 1, 30)); + + Object[] args = argsOf(fake, "alter_partition"); + Partition altered = (Partition) args[2]; + Assertions.assertEquals("3", altered.getParameters().get(StatsSetupConst.ROW_COUNT)); + Assertions.assertEquals("30", altered.getParameters().get(StatsSetupConst.TOTAL_SIZE)); + } + + @Test + public void testUpdatePartitionStatisticsRejectsAmbiguousName() { + RecordingClient fake = new RecordingClient(); + fake.stub("getPartitionsByNames", new ArrayList()); // size 0 != 1 + ThriftHmsClient client = newClient(fake); + + Assertions.assertThrows(HmsClientException.class, () -> + client.updatePartitionStatistics("db", "t", "dt=2024", + current -> HmsPartitionStatistics.EMPTY)); + } + + // ---- getValidWriteIds ----------------------------------------------------------------------- + + @Test + public void testGetValidWriteIdsSuccessPathEmitsSnapshotThenWriteIds() { + // Primary read-ACID path: a compatible metastore returns exactly one write-id list. A + // distinctive snapshot (high-watermark 100, NOT the fallback's Long.MAX_VALUE) makes this test + // fail if the code silently degrades to the fallback branch. + ValidReadTxnList snapshot = new ValidReadTxnList(new long[0], new BitSet(), 100L, 5L); + TableValidWriteIds writeIds = new TableValidWriteIds( + "db.t", 100L, Collections.emptyList(), ByteBuffer.allocate(0)); + RecordingClient fake = new RecordingClient() + .stub("getValidTxns", snapshot) + .stub("getValidWriteIds", Collections.singletonList(writeIds)); + ThriftHmsClient client = newClient(fake); + + Map conf = client.getValidWriteIds("db.t", 42L); + + // (a) The recent snapshot string — NOT currentTransactionId (42) — drives the write-id lookup. + Object[] gvwiArgs = argsOf(fake, "getValidWriteIds"); + Assertions.assertEquals(Collections.singletonList("db.t"), gvwiArgs[0]); + Assertions.assertEquals(snapshot.toString(), gvwiArgs[1]); + // (b) VALID_TXNS_KEY carries the txn snapshot; VALID_WRITEIDS_KEY carries the write-id list — + // no key swap. Both are the SUCCESS values (snapshot watermark 100), not the fallback watermark. + Assertions.assertEquals(snapshot.writeToString(), conf.get(HmsAcidConstants.VALID_TXNS_KEY)); + Assertions.assertTrue(conf.get(HmsAcidConstants.VALID_WRITEIDS_KEY).contains("db.t")); + Assertions.assertNotEquals(conf.get(HmsAcidConstants.VALID_TXNS_KEY), + conf.get(HmsAcidConstants.VALID_WRITEIDS_KEY)); + } + + @Test + public void testGetValidWriteIdsFallsBackToMaxWatermark() { + // An incompatible metastore returns an unexpected write-id list; rather than fail the scan, + // getValidWriteIds must degrade to a max watermark and still emit both config keys. + ValidReadTxnList snapshot = new ValidReadTxnList(); + RecordingClient fake = new RecordingClient() + .stub("getValidTxns", snapshot) + .stub("getValidWriteIds", new ArrayList<>()); // size 0 -> triggers fallback + ThriftHmsClient client = newClient(fake); + + Map conf = client.getValidWriteIds("db.t", 5L); + + // The recent snapshot (not currentTransactionId) was still forwarded before the size guard threw. + Object[] gvwiArgs = argsOf(fake, "getValidWriteIds"); + Assertions.assertEquals(Collections.singletonList("db.t"), gvwiArgs[0]); + Assertions.assertEquals(snapshot.toString(), gvwiArgs[1]); + // Both BE-contract keys are present, and the fallback write-id list is scoped to the table. + Assertions.assertNotNull(conf.get(HmsAcidConstants.VALID_TXNS_KEY)); + Assertions.assertNotNull(conf.get(HmsAcidConstants.VALID_WRITEIDS_KEY)); + Assertions.assertTrue(conf.get(HmsAcidConstants.VALID_WRITEIDS_KEY).contains("db.t")); + } + + // ---- acquireSharedLock ---------------------------------------------------------------------- + + @Test + public void testAcquireSharedLockBuildsSharedReadComponentsAndReturnsWhenAcquired() { + RecordingClient fake = new RecordingClient() + .stub("lock", new LockResponse(1L, LockState.ACQUIRED)); + ThriftHmsClient client = newClient(fake); + + client.acquireSharedLock("q1", 5L, "alice", "db", "t", + Collections.emptyList(), 1000L); + + LockRequest request = (LockRequest) argsOf(fake, "lock")[0]; + Assertions.assertEquals(1, request.getComponent().size()); + LockComponent component = request.getComponent().get(0); + Assertions.assertEquals("db", component.getDbname()); + Assertions.assertEquals("t", component.getTablename()); + Assertions.assertEquals(LockType.SHARED_READ, component.getType()); + Assertions.assertEquals(DataOperationType.SELECT, component.getOperationType()); + } + + @Test + public void testAcquireSharedLockOneComponentPerPartition() { + RecordingClient fake = new RecordingClient() + .stub("lock", new LockResponse(1L, LockState.ACQUIRED)); + ThriftHmsClient client = newClient(fake); + + client.acquireSharedLock("q1", 5L, "alice", "db", "t", + java.util.Arrays.asList("dt=1", "dt=2"), 1000L); + + LockRequest request = (LockRequest) argsOf(fake, "lock")[0]; + Assertions.assertEquals(2, request.getComponent().size()); + Assertions.assertEquals("dt=1", request.getComponent().get(0).getPartitionname()); + Assertions.assertEquals("dt=2", request.getComponent().get(1).getPartitionname()); + } + + @Test + public void testAcquireSharedLockPollsUntilAcquired() { + RecordingClient fake = new RecordingClient() + .stub("lock", new LockResponse(2L, LockState.WAITING)) + .stub("checkLock", new LockResponse(2L, LockState.ACQUIRED)); + ThriftHmsClient client = newClient(fake); + + client.acquireSharedLock("q", 5L, "u", "db", "t", Collections.emptyList(), 5000L); + + Assertions.assertTrue(fake.methodNames.contains("checkLock")); + } + + @Test + public void testAcquireSharedLockTimesOut() { + RecordingClient fake = new RecordingClient() + .stub("lock", new LockResponse(3L, LockState.WAITING)) + .stub("checkLock", new LockResponse(3L, LockState.WAITING)); + ThriftHmsClient client = newClient(fake); + + // timeoutMs = -1 => the elapsed guard trips on the first poll iteration, deterministically. + Assertions.assertThrows(HmsClientException.class, () -> + client.acquireSharedLock("q", 5L, "u", "db", "t", Collections.emptyList(), -1L)); + } + + // ---- harness -------------------------------------------------------------------------------- + + private static ThriftHmsClient newClient(RecordingClient handler) { + IMetaStoreClient fake = (IMetaStoreClient) Proxy.newProxyInstance( + IMetaStoreClient.class.getClassLoader(), + new Class[] {IMetaStoreClient.class}, + handler); + // poolSize 0 -> no pool: every call creates a fresh client via the provider (our fake). + HmsClientConfig config = new HmsClientConfig(new HashMap<>(), 0); + return new ThriftHmsClient(config, null, hiveConf -> fake, HmsTypeMapping.Options.DEFAULT); + } + + private static Object[] argsOf(RecordingClient handler, String method) { + int idx = handler.methodNames.indexOf(method); + Assertions.assertTrue(idx >= 0, "expected a call to " + method); + return handler.argsList.get(idx); + } + + /** + * A recording fake {@link IMetaStoreClient}: records every method name + args in call order and + * returns per-method canned values (a {@link Throwable} value is thrown from the call). + * Implemented as an {@link InvocationHandler} because {@code IMetaStoreClient} has hundreds of + * methods — an anonymous class is impractical and Mockito is not on the connector test path. + */ + private static final class RecordingClient implements InvocationHandler { + private final List methodNames = new ArrayList<>(); + private final List argsList = new ArrayList<>(); + private final Map responses = new HashMap<>(); + + RecordingClient stub(String method, Object value) { + responses.put(method, value); + return this; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + String name = method.getName(); + if (method.getDeclaringClass() == Object.class) { + switch (name) { + case "toString": + return "RecordingClient"; + case "hashCode": + return System.identityHashCode(proxy); + case "equals": + return proxy == args[0]; + default: + return null; + } + } + if ("close".equals(name)) { + return null; + } + methodNames.add(name); + argsList.add(args == null ? new Object[0] : args); + if (responses.containsKey(name)) { + Object value = responses.get(name); + if (value instanceof Throwable) { + throw (Throwable) value; + } + return value; + } + return defaultValue(method.getReturnType()); + } + + private static Object defaultValue(Class type) { + if (!type.isPrimitive() || 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 == short.class) { + return (short) 0; + } + if (type == byte.class) { + return (byte) 0; + } + if (type == char.class) { + return (char) 0; + } + if (type == float.class) { + return 0f; + } + return 0d; + } + } +} diff --git a/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/event/HmsEventParserTest.java b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/event/HmsEventParserTest.java new file mode 100644 index 00000000000000..78d44033a47173 --- /dev/null +++ b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/event/HmsEventParserTest.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.connector.hms.event; + +import org.apache.doris.connector.api.event.MetastoreChangeDescriptor; +import org.apache.doris.connector.api.event.MetastoreChangeDescriptor.Op; +import org.apache.doris.connector.hms.HmsNotificationEvent; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; + +/** + * Dormant unit coverage of the neutral (body-free) event mappings and the lazy-decompress guarantee. + * The body-parsing table/partition paths (which need captured Hive JSON/GZIP message fixtures) are + * covered by the heterogeneous-HMS e2e matrix owed at the flip. + */ +public class HmsEventParserTest { + + private static HmsNotificationEvent event(long id, String type, String db, String table, + String message, String format, long timeSec) { + return new HmsNotificationEvent(id, type, db, table, message, format, timeSec); + } + + @Test + public void createDatabaseMapsToRegisterDb() { + List out = HmsEventParser.parse( + event(7L, "CREATE_DATABASE", "MyDb", null, null, "json-2.0", 100L)); + Assertions.assertEquals(1, out.size()); + MetastoreChangeDescriptor d = out.get(0); + Assertions.assertEquals(Op.REGISTER_DATABASE, d.getOp()); + // db name is lowercased, mirroring the legacy base MetastoreEvent + Assertions.assertEquals("mydb", d.getDbName()); + Assertions.assertEquals(7L, d.getEventId()); + // event time (seconds) is surfaced as millis + Assertions.assertEquals(100L * 1000L, d.getUpdateTime()); + } + + @Test + public void dropDatabaseMapsToUnregisterDb() { + List out = HmsEventParser.parse( + event(8L, "DROP_DATABASE", "db1", null, null, "json-2.0", 0L)); + Assertions.assertEquals(1, out.size()); + Assertions.assertEquals(Op.UNREGISTER_DATABASE, out.get(0).getOp()); + Assertions.assertEquals("db1", out.get(0).getDbName()); + } + + @Test + public void insertMapsToRefreshTable() { + List out = HmsEventParser.parse( + event(9L, "INSERT", "db1", "t1", null, "json-2.0", 0L)); + Assertions.assertEquals(1, out.size()); + Assertions.assertEquals(Op.REFRESH_TABLE, out.get(0).getOp()); + Assertions.assertEquals("db1", out.get(0).getDbName()); + Assertions.assertEquals("t1", out.get(0).getTableName()); + } + + @Test + public void unsupportedTypeProducesNoDescriptor() { + Assertions.assertTrue(HmsEventParser.parse( + event(10L, "COMMIT_TXN", "db1", null, null, "json-2.0", 0L)).isEmpty()); + } + + @Test + public void nullEventTypeIsIgnored() { + Assertions.assertTrue(HmsEventParser.parse( + event(13L, null, "db1", null, null, "json-2.0", 0L)).isEmpty()); + } + + @Test + public void bodyFreeEventNeverDecompressesTheMessage() { + // A db-level / ignored event must not touch the payload: even a gzip-tagged, un-decompressible body + // (or a null body) cannot throw, because prepareBody is gated on needsBody(type). This is the + // regression the lazy-decompress fix closed. + Assertions.assertDoesNotThrow(() -> HmsEventParser.parse( + event(11L, "CREATE_DATABASE", "db1", null, "not-valid-gzip", "gzip(json-2.0)", 0L))); + Assertions.assertDoesNotThrow(() -> HmsEventParser.parse( + event(12L, "COMMIT_TXN", "db1", null, null, "gzip(json-2.0)", 0L))); + } +} diff --git a/fe/fe-connector/fe-connector-hudi/pom.xml b/fe/fe-connector/fe-connector-hudi/pom.xml index 2285112734b753..d6ec60153c6a96 100644 --- a/fe/fe-connector/fe-connector-hudi/pom.xml +++ b/fe/fe-connector/fe-connector-hudi/pom.xml @@ -70,6 +70,16 @@ under the License. ${project.version} + + + ${project.groupId} + fe-connector-metastore-hms + ${project.version} + + org.apache.hudi @@ -83,6 +93,71 @@ under the License. hudi-hadoop-mr + + + org.apache.parquet + parquet-hadoop + + + org.apache.parquet + parquet-avro + + + + + org.apache.hadoop + hadoop-aws + + + + + software.amazon.awssdk + s3 + + + software.amazon.awssdk + apache-client + + + software.amazon.awssdk + s3-transfer-manager + + org.apache.logging.log4j log4j-api diff --git a/fe/fe-connector/fe-connector-hudi/src/main/assembly/plugin-zip.xml b/fe/fe-connector/fe-connector-hudi/src/main/assembly/plugin-zip.xml index 0d29baa55b34bf..9927cb0882454c 100644 --- a/fe/fe-connector/fe-connector-hudi/src/main/assembly/plugin-zip.xml +++ b/fe/fe-connector/fe-connector-hudi/src/main/assembly/plugin-zip.xml @@ -46,6 +46,12 @@ under the License. org.apache.doris:fe-connector-spi org.apache.doris:fe-extension-spi org.apache.doris:fe-filesystem-api + + org.apache.doris:fe-thrift + org.apache.thrift:libthrift org.apache.logging.log4j:* org.slf4j:* diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/COWIncrementalRelation.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/COWIncrementalRelation.java new file mode 100644 index 00000000000000..8d461cf4b09d8f --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/COWIncrementalRelation.java @@ -0,0 +1,232 @@ +// 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.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.GlobPattern; +import org.apache.hadoop.fs.Path; +import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.common.model.FileSlice; +import org.apache.hudi.common.model.HoodieCommitMetadata; +import org.apache.hudi.common.model.HoodieReplaceCommitMetadata; +import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.timeline.HoodieInstant; +import org.apache.hudi.common.table.timeline.HoodieTimeline; +import org.apache.hudi.common.table.timeline.TimelineUtils; +import org.apache.hudi.common.table.timeline.TimelineUtils.HollowCommitHandling; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.storage.StoragePath; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; + +/** + * Selects the base files a COW {@code @incr(...)} read must scan over a resolved {@code (begin, end]} window. + * Connector-internal port of legacy {@code datasource.hudi.source.COWIncrementalRelation}, with the window-parse + * prologue removed (the window is resolved ONCE by {@link HudiConnectorMetadata#resolveTimeTravel} and consumed + * here, see {@link IncrementalRelation}) and the split type re-homed from fe-core {@code HudiSplit} to + * {@link HudiScanRange}. Everything from the archived-flag computation onward is byte-faithful to legacy. + * + *

{@link #collectSplits()} yields native ranges directly (COW has only base files); {@link #collectFileSlices()} + * is unsupported (the MOR shape). + */ +final class COWIncrementalRelation implements IncrementalRelation { + + private final Map optParams; + private final HoodieTableMetaClient metaClient; + private final HollowCommitHandling hollowCommitHandling; + private final boolean startInstantArchived; + private final boolean endInstantArchived; + private final boolean fullTableScan; + private final FileSystem fs; + private final Map fileToWriteStat; + private final Collection filteredRegularFullPaths; + private final Collection filteredMetaBootstrapFullPaths; + + private final String startTs; + private final String endTs; + + COWIncrementalRelation(HoodieTableMetaClient metaClient, Configuration configuration, + String startTs, String endTs, HollowCommitHandling hollowCommitHandling, + Map optParams) throws IOException { + this.optParams = optParams; + this.metaClient = metaClient; + this.hollowCommitHandling = hollowCommitHandling; + this.startTs = startTs; + this.endTs = endTs; + HoodieTimeline commitTimeline = TimelineUtils.handleHollowCommitIfNeeded( + metaClient.getCommitTimeline().filterCompletedInstants(), metaClient, hollowCommitHandling); + // Meta-fields guard (ported from INC-1's deferral list): fail loud before any file selection. It is the + // first guard because the empty-completed-timeline case routes to EmptyIncrementalRelation upstream, so + // this relation is built only for a non-empty timeline (legacy's "non-empty only" semantics). + IncrementalRelation.checkIncrementalMetaFields(metaClient.getTableConfig().populateMetaFields()); + + startInstantArchived = commitTimeline.isBeforeTimelineStarts(startTs); + endInstantArchived = commitTimeline.isBeforeTimelineStarts(endTs); + + HoodieTimeline commitsTimelineToReturn; + if (hollowCommitHandling == HollowCommitHandling.USE_TRANSITION_TIME) { + commitsTimelineToReturn = commitTimeline.findInstantsInRangeByCompletionTime(startTs, endTs); + } else { + commitsTimelineToReturn = commitTimeline.findInstantsInRange(startTs, endTs); + } + List commitsToReturn = commitsTimelineToReturn.getInstants(); + + // todo: support configuration hoodie.datasource.read.incr.filters + StoragePath basePath = metaClient.getBasePath(); + Map regularFileIdToFullPath = new HashMap<>(); + Map metaBootstrapFileIdToFullPath = new HashMap<>(); + HoodieTimeline replacedTimeline = commitsTimelineToReturn.getCompletedReplaceTimeline(); + Map replacedFile = new HashMap<>(); + for (HoodieInstant instant : replacedTimeline.getInstants()) { + HoodieReplaceCommitMetadata metadata = metaClient.getActiveTimeline() + .readReplaceCommitMetadata(instant); + metadata.getPartitionToReplaceFileIds().forEach( + (key, value) -> value.forEach( + e -> replacedFile.put(e, FSUtils.constructAbsolutePath(basePath, key).toString()))); + } + + fileToWriteStat = new HashMap<>(); + for (HoodieInstant commit : commitsToReturn) { + HoodieCommitMetadata metadata = metaClient.getActiveTimeline().readCommitMetadata(commit); + metadata.getPartitionToWriteStats().forEach((partition, stats) -> { + for (HoodieWriteStat stat : stats) { + fileToWriteStat.put(FSUtils.constructAbsolutePath(basePath, stat.getPath()).toString(), stat); + } + }); + if (HoodieTimeline.METADATA_BOOTSTRAP_INSTANT_TS.equals(commit.requestedTime())) { + metadata.getFileIdAndFullPaths(basePath).forEach((k, v) -> { + if (!(replacedFile.containsKey(k) && v.startsWith(replacedFile.get(k)))) { + metaBootstrapFileIdToFullPath.put(k, v); + } + }); + } else { + metadata.getFileIdAndFullPaths(basePath).forEach((k, v) -> { + if (!(replacedFile.containsKey(k) && v.startsWith(replacedFile.get(k)))) { + regularFileIdToFullPath.put(k, v); + } + }); + } + } + + if (!metaBootstrapFileIdToFullPath.isEmpty()) { + // filer out meta bootstrap files that have had more commits since metadata bootstrap + metaBootstrapFileIdToFullPath.entrySet().removeIf(e -> regularFileIdToFullPath.containsKey(e.getKey())); + } + String pathGlobPattern = optParams.getOrDefault("hoodie.datasource.read.incr.path.glob", ""); + if ("".equals(pathGlobPattern)) { + filteredRegularFullPaths = regularFileIdToFullPath.values(); + filteredMetaBootstrapFullPaths = metaBootstrapFileIdToFullPath.values(); + } else { + GlobPattern globMatcher = new GlobPattern("*" + pathGlobPattern); + filteredRegularFullPaths = regularFileIdToFullPath.values().stream().filter(globMatcher::matches) + .collect(Collectors.toList()); + filteredMetaBootstrapFullPaths = metaBootstrapFileIdToFullPath.values().stream() + .filter(globMatcher::matches).collect(Collectors.toList()); + } + + fs = new Path(basePath.toUri().getPath()).getFileSystem(configuration); + fullTableScan = shouldFullTableScan(); + } + + private boolean shouldFullTableScan() throws IOException { + boolean fallbackToFullTableScan = Boolean.parseBoolean( + optParams.getOrDefault("hoodie.datasource.read.incr.fallback.fulltablescan.enable", "false")); + if (IncrementalRelation.decideArchivalFullTableScan( + fallbackToFullTableScan, startInstantArchived, endInstantArchived, hollowCommitHandling)) { + return true; + } + if (fallbackToFullTableScan) { + for (String path : filteredMetaBootstrapFullPaths) { + if (!fs.exists(new Path(path))) { + return true; + } + } + for (String path : filteredRegularFullPaths) { + if (!fs.exists(new Path(path))) { + return true; + } + } + } + return false; + } + + @Override + public List collectFileSlices() { + throw new UnsupportedOperationException(); + } + + @Override + public List collectSplits(UnaryOperator nativePathNormalizer) { + IncrementalRelation.checkNotFullTableScan(fullTableScan); + if (filteredRegularFullPaths.isEmpty() && filteredMetaBootstrapFullPaths.isEmpty()) { + return Collections.emptyList(); + } + List splits = new ArrayList<>(); + // Partition-column NAMES come from the hudi table config (byte-faithful to legacy COW:212), NOT the + // HMS-sourced handle.partitionKeyNames the snapshot path uses; the two coincide for hive-synced tables. + Option partitionColumns = metaClient.getTableConfig().getPartitionFields(); + List partitionNames = partitionColumns.isPresent() ? Arrays.asList(partitionColumns.get()) + : Collections.emptyList(); + + Consumer generatorSplit = baseFile -> { + HoodieWriteStat stat = fileToWriteStat.get(baseFile); + splits.add(new HudiScanRange.Builder() + // Native COW @incr range: normalize scheme (s3a->s3) for BE's native reader. The raw baseFile + // is a full HMS path anchored on metaClient.getBasePath(); BE's S3URI rejects s3a. + .path(nativePathNormalizer.apply(baseFile)) + .start(0) + // length + fileSize both from the write stat, matching legacy HudiSplit(0, size, size, ...). + .length(stat.getFileSizeInBytes()) + .fileSize(stat.getFileSizeInBytes()) + .fileFormat(HudiScanPlanProvider.detectFileFormat(baseFile)) + .partitionValues( + HudiScanPlanProvider.parsePartitionValues(stat.getPartitionPath(), partitionNames)) + .build()); + }; + + for (String baseFile : filteredMetaBootstrapFullPaths) { + generatorSplit.accept(baseFile); + } + for (String baseFile : filteredRegularFullPaths) { + generatorSplit.accept(baseFile); + } + return splits; + } + + @Override + public boolean fallbackFullTableScan() { + return fullTableScan; + } + + @Override + public String getEndTs() { + return endTs; + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/EmptyIncrementalRelation.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/EmptyIncrementalRelation.java new file mode 100644 index 00000000000000..e7e2c8dc381d69 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/EmptyIncrementalRelation.java @@ -0,0 +1,60 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hudi; + +import org.apache.hudi.common.model.FileSlice; + +import java.util.Collections; +import java.util.List; +import java.util.function.UnaryOperator; + +/** + * The {@code @incr} relation for an empty completed timeline: it selects nothing. Connector-internal port of + * legacy {@code datasource.hudi.source.EmptyIncrementalRelation}. The scan planner routes the empty-timeline + * case here (legacy {@code LogicalHudiScan.withScanParams:261-262}), so COW/MOR are never built empty. + * + *

Legacy's {@code getHoodieParams()} (which set {@code hoodie.datasource.read.incr.operation} / + * {@code includeStartTime} on the BE-facing params) is intentionally NOT ported: the connector uses the FE-side + * synthetic-predicate model for row correctness and emits NO {@code hoodie.datasource.read.*} incremental keys + * to BE, so those keys are inert (see the incremental-read step design). {@code getStartTs()} is likewise + * dropped (the resolved window lives on the handle); {@code getEndTs()} returns the legacy {@code "000"} bound. + */ +final class EmptyIncrementalRelation implements IncrementalRelation { + + private static final String EMPTY_TS = "000"; + + @Override + public List collectFileSlices() { + return Collections.emptyList(); + } + + @Override + public List collectSplits(UnaryOperator nativePathNormalizer) { + return Collections.emptyList(); + } + + @Override + public boolean fallbackFullTableScan() { + return false; + } + + @Override + public String getEndTs() { + return EMPTY_TS; + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiColumnHandle.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiColumnHandle.java index 6579aa2476ee56..d350937a516acb 100644 --- a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiColumnHandle.java +++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiColumnHandle.java @@ -22,8 +22,9 @@ import java.util.Objects; /** - * Column handle for Hudi tables, carrying column name, type, and - * whether the column is a partition key. + * Column handle for Hudi tables, carrying column name, type, + * whether the column is a partition key, and the Hudi InternalSchema + * field id (for schema-evolution BY_FIELD_ID matching; HD-C4b). */ public class HudiColumnHandle implements ConnectorColumnHandle { @@ -32,11 +33,21 @@ public class HudiColumnHandle implements ConnectorColumnHandle { private final String name; private final String typeName; private final boolean isPartitionKey; + // Hudi InternalSchema field id (stable across renames), sourced from the mode-aware InternalSchema and + // threaded here by HudiConnectorMetadata.getColumnHandles. ConnectorColumn.UNSET_UNIQUE_ID (-1) when no id + // was resolved (e.g. a _hoodie_* meta column absent from a commit-metadata schema). BE's field-id mode is + // PER-FILE, not per-column, so an unresolved id CANNOT fall back BY_NAME on its own; instead the whole + // scan-level dict is gated OFF when any projected column is unresolved (see + // HudiSchemaUtils.buildSchemaEvolutionProp) -> BE stays on BY_NAME for the entire scan. Deliberately NOT part + // of equals/hashCode: the handle's identity stays name+type (mirror IcebergColumnHandle, which keeps identity + // by name and does not fold the field id in). + private final int fieldId; - public HudiColumnHandle(String name, String typeName, boolean isPartitionKey) { + public HudiColumnHandle(String name, String typeName, boolean isPartitionKey, int fieldId) { this.name = Objects.requireNonNull(name); this.typeName = Objects.requireNonNull(typeName); this.isPartitionKey = isPartitionKey; + this.fieldId = fieldId; } public String getName() { @@ -51,6 +62,10 @@ public boolean isPartitionKey() { return isPartitionKey; } + public int getFieldId() { + return fieldId; + } + public String getColumnName() { return name; } @@ -74,7 +89,7 @@ public int hashCode() { @Override public String toString() { - return "HudiColumnHandle{" + name + ":" + typeName + return "HudiColumnHandle{" + name + ":" + typeName + "[" + fieldId + "]" + (isPartitionKey ? " [partition]" : "") + "}"; } } diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnector.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnector.java index bbf4fff30c0b32..8474c003010fc7 100644 --- a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnector.java +++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnector.java @@ -18,21 +18,33 @@ package org.apache.doris.connector.hudi; import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.hms.HmsClient; import org.apache.doris.connector.hms.HmsClientConfig; import org.apache.doris.connector.hms.ThriftHmsClient; +import org.apache.doris.connector.metastore.HmsMetaStoreProperties; +import org.apache.doris.connector.metastore.spi.MetaStoreProviders; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.kerberos.HadoopAuthenticator; +import org.apache.doris.kerberos.KerberosAuthSpec; +import org.apache.doris.kerberos.KerberosAuthenticationConfig; +import org.apache.hadoop.conf.Configuration; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.io.IOException; import java.util.Collections; +import java.util.EnumSet; import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.Callable; /** * Hudi connector implementation. Manages the lifecycle of an @@ -42,15 +54,32 @@ *

Phase 1 provides read-only metadata operations (list databases, * list tables, get schema via Hudi's Avro schema). Phase 2 adds scan * planning for COW and MOR tables (snapshot reads).

+ * + *

Built only as an embedded sibling of the hive {@code hms} gateway (via + * {@code ConnectorContext.createSiblingConnector("hudi", ...)}), never as a standalone {@code type=hudi} + * catalog — see {@link HudiConnectorProvider}.

*/ public class HudiConnector implements Connector { private static final Logger LOG = LogManager.getLogger(HudiConnector.class); + // Catalog property key gating the plugin-side Kerberos authenticator (value matches AuthType.KERBEROS). + private static final String HADOOP_SECURITY_AUTHENTICATION = "hadoop.security.authentication"; + private final Map properties; private final ConnectorContext context; private volatile HmsClient hmsClient; + // Lazily-built plugin-side Kerberos authenticator (single-owner auth), null for a non-Kerberos catalog. + // Its doAs acts on the PLUGIN's UserGroupInformation copy — the one this connector's ThriftHmsClient RPC + // reads (hadoop + fe-kerberos bundled child-first in the hudi plugin zip) — not the app-loader copy the + // FE-injected context would use. Mirrors HiveConnector: after the catalog flip the sibling shares the hms + // gateway's context whose executeAuthenticated resolves to NOOP (SIMPLE), which would silently downgrade a + // Kerberos HMS. A hudi sibling runs in its OWN classloader, so it must own its authenticator (sharing the + // gateway's hive-loader authenticator would split the UGI copy across loaders). + private volatile HadoopAuthenticator pluginAuth; + private volatile boolean pluginAuthComputed; + public HudiConnector(Map properties, ConnectorContext context) { this.properties = Collections.unmodifiableMap(properties); this.context = context; @@ -58,12 +87,68 @@ public HudiConnector(Map properties, ConnectorContext context) { @Override public ConnectorMetadata getMetadata(ConnectorSession session) { - return new HudiConnectorMetadata(getOrCreateClient(), properties); + return new HudiConnectorMetadata(getOrCreateClient(), properties, metaClientExecutor(), + HudiScanPlanProvider.storageHadoopConfig(context)); + } + + /** + * Builds the metaClient execute-wrapper the metadata partition/snapshot methods run their + * {@code HoodieTableMetaClient}-touching work inside: a TCCL pin to the hudi plugin classloader (so + * hudi-bundled reflection resolves the plugin's child-first copies) around the plugin UGI {@code doAs} + * (Kerberos) — or the FE-injected {@code context.executeAuthenticated} when this is a non-Kerberos + * catalog — restoring the previous TCCL in a {@code finally}. Mirrors the {@link #createClient()} auth + * choice; the TCCL pin is added because — unlike the HMS thrift RPC ({@code ThriftHmsClient.doAs} pins the + * system loader) — building a metaClient / listing partitions off the (unpinned) planning thread needs the + * plugin loader. See {@link HudiMetaClientExecutor} and memory + * {@code catalog-spi-plugin-tccl-classloader-gotcha}. + */ + private HudiMetaClientExecutor metaClientExecutor() { + return new HudiMetaClientExecutor() { + @Override + public T execute(Callable action) { + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(HudiConnector.class.getClassLoader()); + try { + HadoopAuthenticator auth = pluginAuthenticator(); + if (auth != null) { + return auth.doAs(action::call); + } + return context.executeAuthenticated(action); + } catch (Exception e) { + throw new DorisConnectorException("Hudi metadata operation failed for catalog '" + + context.getCatalogName() + "'", e); + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + } + }; + } + + /** + * True for a handle this connector produced (a {@link HudiTableHandle}). Tested against this connector's OWN + * in-loader type, so a heterogeneous hms gateway that embeds this connector as a sibling can route a foreign + * hudi handle here without casting it across the plugin classloader split. Returns false for any other + * connector's handle (e.g. an iceberg sibling's), so the gateway keeps looking. + */ + @Override + public boolean ownsHandle(ConnectorTableHandle handle) { + return handle instanceof HudiTableHandle; + } + + /** + * Every hudi table exposes its commit timeline via the {@code hudi_meta()} / TIMELINE TVF, so declare the + * metadata-table capability connector-wide. fe-core's plugin-driven {@code hudiMetadataResult} arm reads this + * (via {@code hasScanCapability}) to admit a flipped hudi table, and the hive gateway reflects it onto a + * hudi-on-HMS table's delegated schema so hudi_meta keeps working through the sibling delegation. + */ + @Override + public Set getCapabilities() { + return EnumSet.of(ConnectorCapability.SUPPORTS_METADATA_TABLE); } @Override public ConnectorScanPlanProvider getScanPlanProvider() { - return new HudiScanPlanProvider(properties); + return new HudiScanPlanProvider(properties, context); } private HmsClient getOrCreateClient() { @@ -95,10 +180,90 @@ private HmsClient createClient() { LOG.info("Creating Hudi connector HMS client for catalog='{}', uri={}, poolSize={}", context.getCatalogName(), config.getMetastoreUri(), poolSize); - ThriftHmsClient.AuthAction authAction = context::executeAuthenticated; + // For a Kerberos catalog run the metastore RPC under the PLUGIN's UGI doAs (buildPluginAuthenticator), + // NOT the FE-injected context: after the catalog flip that context resolves to NOOP (SIMPLE) auth, which + // would silently downgrade a Kerberos HMS. AuthAction.execute is a generic method ( T execute(...)), + // so it cannot be a lambda — use an anonymous class. ThriftHmsClient.doAs already pins the RPC's TCCL to + // the system classloader; the plugin's HadoopAuthenticator only wraps it in a UGI doAs (no TCCL change). + HadoopAuthenticator auth = pluginAuthenticator(); + ThriftHmsClient.AuthAction authAction; + if (auth != null) { + authAction = new ThriftHmsClient.AuthAction() { + @Override + public T execute(Callable callable) throws Exception { + return auth.doAs(callable::call); + } + }; + } else { + authAction = context::executeAuthenticated; + } return new ThriftHmsClient(config, authAction); } + /** + * Lazily builds and memoizes the plugin-side Kerberos authenticator that {@link #createClient()} wraps the + * metastore RPC under, so the RPC uses the PLUGIN's own {@code UserGroupInformation} copy (hadoop + + * fe-kerberos are bundled child-first in the hudi plugin). Returns {@code null} for a non-Kerberos catalog + * so the FE-injected auth path is preserved unchanged. Construction is cheap — the keytab login is lazy in + * {@code getUGI()} on the first {@code doAs}. Mirrors {@code HiveConnector.pluginAuthenticator}. + */ + private HadoopAuthenticator pluginAuthenticator() { + if (!pluginAuthComputed) { + synchronized (this) { + if (!pluginAuthComputed) { + pluginAuth = buildPluginAuthenticator(properties); + pluginAuthComputed = true; + } + } + } + return pluginAuth; + } + + /** + * Resolves the plugin-side Kerberos authenticator for the catalog, or {@code null} for a non-Kerberos + * catalog. Byte-faithful mirror of {@code HiveConnector.buildPluginAuthenticator} — two Kerberos sources in + * precedence order (mirroring the legacy {@code HMSBaseProperties.initHadoopAuthenticator}): + *
    + *
  1. Storage Kerberos — the raw {@code hadoop.security.authentication=kerberos} passthrough (HDFS + * login). When storage is Kerberos this single login also carries the HMS metastore RPC (same UGI).
  2. + *
  3. HMS-metastore Kerberos with non-Kerberos storage — a secured Hive Metastore whose data + * storage is simple (e.g. a Kerberized HMS over S3). The HMS client principal/keytab facts + * ({@link HmsMetaStoreProperties#kerberos()}, resolved through the shared metastore-spi parser) feed a + * {@link KerberosAuthenticationConfig}, so the {@code doAs} logs in the same client identity fe-core + * used.
  4. + *
+ * Package-visible + static for KDC-free unit testing. + */ + static HadoopAuthenticator buildPluginAuthenticator(Map properties) { + if ("kerberos".equalsIgnoreCase(properties.get(HADOOP_SECURITY_AUTHENTICATION))) { + return HadoopAuthenticator.getHadoopAuthenticator(buildHadoopConf(properties)); + } + HmsMetaStoreProperties hms = (HmsMetaStoreProperties) MetaStoreProviders.bindForType( + HmsClientConfig.METASTORE_TYPE_HMS, properties, Collections.emptyMap()); + Optional spec = hms.kerberos(); + if (spec.isPresent() && spec.get().hasCredentials()) { + Configuration conf = buildHadoopConf(properties); + conf.set("hadoop.security.authentication", "kerberos"); + conf.set("hive.metastore.sasl.enabled", "true"); + return HadoopAuthenticator.getHadoopAuthenticator( + new KerberosAuthenticationConfig(spec.get().getPrincipal(), spec.get().getKeytab(), conf)); + } + return null; + } + + /** + * Builds a plain Hadoop {@link Configuration} from the catalog properties for the authenticator. A plain + * {@code new Configuration()} (NOT {@code HiveConf}) is used deliberately: HiveConf static-init would drag + * hadoop-mapreduce onto the unit-test classpath. The classloader is pinned to the plugin loader so the + * child-first (plugin) copy of the auth classes is resolved. Mirrors {@code HiveConnector.buildHadoopConf}. + */ + private static Configuration buildHadoopConf(Map properties) { + Configuration conf = new Configuration(); + conf.setClassLoader(HudiConnector.class.getClassLoader()); + properties.forEach(conf::set); + return conf; + } + @Override public void close() throws IOException { HmsClient c = hmsClient; 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 7b4fe4b0b791e5..46e63741007821 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 @@ -19,30 +19,55 @@ import org.apache.doris.connector.api.ConnectorColumn; import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorPartitionInfo; import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.ConnectorTableSchema; import org.apache.doris.connector.api.ConnectorType; +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.ConnectorTransaction; +import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; +import org.apache.doris.connector.api.mvcc.ConnectorTimeTravelSpec; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; import org.apache.doris.connector.api.pushdown.ConnectorFilterConstraint; +import org.apache.doris.connector.api.pushdown.ConnectorIn; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; import org.apache.doris.connector.api.pushdown.FilterApplicationResult; import org.apache.doris.connector.hms.HmsClient; import org.apache.doris.connector.hms.HmsClientException; import org.apache.doris.connector.hms.HmsTableInfo; +import org.apache.doris.thrift.THiveTable; +import org.apache.doris.thrift.TTableDescriptor; +import org.apache.doris.thrift.TTableType; import org.apache.avro.Schema; import org.apache.hadoop.conf.Configuration; 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.util.Option; +import org.apache.hudi.internal.schema.InternalSchema; +import org.apache.hudi.internal.schema.Types; +import org.apache.hudi.internal.schema.convert.AvroInternalSchemaConverter; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.AbstractMap; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; /** @@ -63,12 +88,91 @@ public class HudiConnectorMetadata implements ConnectorMetadata { private static final Logger LOG = LogManager.getLogger(HudiConnectorMetadata.class); + // Catalog property gating the partition-name source (mirrors legacy HMSExternalTable.USE_HIVE_SYNC_PARTITION). + private static final String USE_HIVE_SYNC_PARTITION = "use_hive_sync_partition"; + + // fe-core SPI schema property marking which emitted columns are partition columns (CSV of RAW partition-key + // names, in declaration order). Mirrors HiveConnectorMetadata.PARTITION_COLUMNS_PROPERTY; fe-core derives the + // partition-column set SOLELY from this key (PluginDrivenExternalTable.toSchemaCacheValue). Without it every + // partitioned Hudi table is read as UNPARTITIONED -> wrong pruning/row-count and MTMV "not partition table". + private static final String PARTITION_COLUMNS_PROPERTY = ConnectorTableSchema.PARTITION_COLUMNS_KEY; + + // Hive-canonical partition text for a DATETIME/TIMESTAMP literal: space separator, full seconds. See + // hiveDateTimeString / extractLiteralValue (H2: String.valueOf(LocalDateTime) would yield ISO "…T…" and drop + // zero seconds, never matching the stored Hive partition value). + private static final DateTimeFormatter HIVE_DATETIME_SECONDS_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + // Internal MVCC-snapshot property carrying the FOR TIME AS OF instant from resolveTimeTravel to applySnapshot + // (FE-internal transport, paimon scan-options model). It is NEVER serialized to BE: fe-core only feeds the + // snapshot to applySnapshot, which consumes this property and stamps HudiTableHandle.queryInstant. Not a + // BE-facing key. + private static final String HUDI_QUERY_INSTANT_PROPERTY = "hudi.query-instant"; + + // Internal MVCC-snapshot properties carrying the resolved @incr (begin, end] window from resolveTimeTravel to + // applySnapshot (same FE-internal transport as HUDI_QUERY_INSTANT_PROPERTY). NEVER serialized to BE: fe-core's + // INCREMENTAL loadSnapshot branch lists the LATEST partitions + LATEST schema itself and only carries these on + // the pin; applySnapshot consumes them to stamp HudiTableHandle.begin/endInstant. Not BE-facing keys. + private static final String HUDI_INCREMENTAL_BEGIN_PROPERTY = "hudi.incremental-begin"; + private static final String HUDI_INCREMENTAL_END_PROPERTY = "hudi.incremental-end"; + + // Prefix under which the RAW @incr option params ride the same FE-internal MVCC-snapshot transport, so the + // ported IncrementalRelation family reads its glob / fallback / hollow-commit-policy options at planScan + // exactly as legacy did (planScan has only the handle, not the original scan params). Each raw key becomes + // "hudi.incr-opt." in the snapshot properties and is stripped back in applySnapshot into + // HudiTableHandle.incrementalParams. The "hudi." prefix (never "hoodie.") keeps it namespace-consistent with + // the other FE-internal carriers and guarantees no raw hoodie.* key masquerades as a BE-facing property (and + // the transport is FE-only regardless: fe-core consumes the snapshot ONLY via applySnapshot, never + // serializing its properties to BE). + private static final String HUDI_INCR_OPT_PREFIX = "hudi.incr-opt."; + + // The @incr window param keys fe-core threads verbatim via getIncrementalParams() (byte-faithful to legacy + // LogicalHudiScan.withScanParams, which reads "beginTime"/"endTime" from the scan params). + private static final String INCR_BEGIN_TIME_KEY = "beginTime"; + private static final String INCR_END_TIME_KEY = "endTime"; + + // Window sentinels (byte-faithful to legacy IncrementalRelation.EARLIEST_TIME / LATEST_TIME; "000" is the + // earliest-resolved / empty-timeline bound). fe-core's legacy IncrementalRelation constants live in fe-core + // and must not be imported across the plugin boundary, so they are re-declared here. + private static final String INCR_EARLIEST_SENTINEL = "earliest"; + private static final String INCR_LATEST_SENTINEL = "latest"; + private static final String INCR_ZERO_INSTANT = "000"; + + // The legacy hollow-commit policy key and the one non-default value that shifts window resolution. Under + // USE_TRANSITION_TIME legacy resolves the default / "latest" end on the completion-time axis + // (COWIncrementalRelation:94-96 / MORIncrementalRelation:88-90) and its file selection uses + // findInstantsInRangeByCompletionTime; resolving the end here on the SAME axis keeps ONE handle-stamped end + // correct for both file selection and the later synthetic _hoodie_commit_time row filter. Any other value + // (or absent) -> the default requested-time axis = byte-identical to the pre-policy behaviour. + private static final String INCR_HOLLOW_POLICY_KEY = "hoodie.read.timeline.holes.resolution.policy"; + private static final String INCR_STATE_TRANSITION_POLICY = "USE_TRANSITION_TIME"; + + // The Hudi per-record commit-time meta column the synthetic incremental row filter references + // (HoodieRecord.COMMIT_TIME_METADATA_FIELD; lower-cased in the connector schema). Byte-faithful to legacy + // LogicalHudiScan.generateIncrementalExpression, which matched the scan-output slot by this exact name. + private static final String HUDI_COMMIT_TIME_COLUMN = "_hoodie_commit_time"; + private final HmsClient hmsClient; private final Map properties; + // Runs the metaClient-touching partition/snapshot work under the plugin UGI doAs + TCCL pin (see R4). + private final HudiMetaClientExecutor metaClientExecutor; + // Canonical fs.s3a.*/hadoop.* storage config translated from the catalog's fe-filesystem StorageProperties, + // overlaid into the FE-side metaClient's Hadoop conf so its S3AFileSystem has object-store credentials (the + // raw s3.access_key/… aliases are not Hadoop keys). Empty in same-loader unit tests (which override + // getSchemaFromMetaClient or never touch S3). See HudiScanPlanProvider#storageHadoopConfig. + private final Map storageHadoopConfig; - public HudiConnectorMetadata(HmsClient hmsClient, Map properties) { + public HudiConnectorMetadata(HmsClient hmsClient, Map properties, + HudiMetaClientExecutor metaClientExecutor) { + this(hmsClient, properties, metaClientExecutor, Collections.emptyMap()); + } + + public HudiConnectorMetadata(HmsClient hmsClient, Map properties, + HudiMetaClientExecutor metaClientExecutor, Map storageHadoopConfig) { this.hmsClient = hmsClient; this.properties = properties; + this.metaClientExecutor = metaClientExecutor; + this.storageHadoopConfig = storageHadoopConfig; } // ========== ConnectorSchemaOps ========== @@ -133,9 +237,12 @@ public Map getColumnHandles( for (ConnectorColumn col : schema.getColumns()) { boolean isPartKey = partKeyNames != null && partKeyNames.contains(col.getName()); + // Thread the Hudi InternalSchema field id (set by getSchemaFromMetaClient) onto the handle so + // schema-evolution reads match old files BY FIELD ID (HD-C4b). UNSET (-1) when unresolved -> the + // scan-level dict is gated OFF for the whole scan (per-file BY_NAME), not per-column. handles.put(col.getName(), new HudiColumnHandle(col.getName(), - col.getType().getTypeName(), isPartKey)); + col.getType().getTypeName(), isPartKey, col.getUniqueId())); } return handles; } @@ -150,17 +257,57 @@ public Optional> applyFilter( return Optional.empty(); } - // List all partition names from HMS (e.g. "year=2024/month=01") - // These are relative paths that double as partition identifiers - List partitionNames = hmsClient.listPartitionNames( - hudiHandle.getDbName(), hudiHandle.getTableName(), -1); - if (partitionNames == null || partitionNames.isEmpty()) { + // Extract equality/IN predicates on partition columns from the expression. + // No partition predicate -> leave the handle untouched so resolvePartitions + // falls back to Hudi's own metadata listing (HoodieTableMetadata.getAllPartitionPaths). + Map> partitionPredicates = extractPartitionPredicates( + constraint.getExpression(), partKeyNames); + if (partitionPredicates.isEmpty()) { return Optional.empty(); } - // Build updated handle with partition paths for scan planning + // H3: candidate partition paths MUST be the SAME shape the scan feeds fsView (Hudi RELATIVE STORAGE + // paths), and use_hive_sync_partition-aware (mirroring collectPartitions). The old code fed HMS hive-style + // names ("year=2024/month=01") unconditionally; for a non-hive-style table (Hudi default) the physical + // layout is positional ("2024/01"), so fsView (keyed by relative storage paths) matched nothing -> 0 + // splits for any filtered query. Keep maxParts=-1 (unlimited): no silent partition truncation. + boolean hiveSync = useHiveSyncPartition(); + List allPartPaths; + List matchedPartPaths; + if (hiveSync) { + // hive-sync: HMS registers the hive-style names, which ARE the relative storage layout, so fsView + // accepts them directly (no relativization, matching legacy / collectPartitions). Prune the HMS names. + allPartPaths = hmsClient.listPartitionNames( + hudiHandle.getDbName(), hudiHandle.getTableName(), -1); + if (allPartPaths == null || allPartPaths.isEmpty()) { + return Optional.empty(); + } + matchedPartPaths = prunePartitionNames(allPartPaths, partKeyNames, partitionPredicates); + } else { + // non-hive-sync (Hudi default): list the RELATIVE storage paths from Hudi metadata -- the SAME source + // the unpruned scan (resolvePartitions -> listAllPartitionPaths) uses -- under the plugin auth + TCCL + // pin. Net-neutral: resolvePartitions short-circuits once prunedPaths is set, so a filtered query lists + // exactly once (here) instead of there. parsePartitionValues handles the positional layout ("2024/01"). + allPartPaths = metaClientExecutor.execute(() -> + HudiScanPlanProvider.listAllPartitionPaths( + HudiScanPlanProvider.buildMetaClient(buildHadoopConf(), hudiHandle.getBasePath()))); + if (allPartPaths == null || allPartPaths.isEmpty()) { + return Optional.empty(); + } + matchedPartPaths = prunePartitionPaths(allPartPaths, partKeyNames, partitionPredicates); + } + if (matchedPartPaths.size() == allPartPaths.size()) { + // No pruning effect + return Optional.empty(); + } + + LOG.info("Partition pruning: {}.{} hiveSync={} all={} pruned={}", + hudiHandle.getDbName(), hudiHandle.getTableName(), + hiveSync, allPartPaths.size(), matchedPartPaths.size()); + + // Build updated handle carrying only the matched (relative-shape) partition paths for scan planning. HudiTableHandle updatedHandle = hudiHandle.toBuilder() - .prunedPartitionPaths(partitionNames) + .prunedPartitionPaths(matchedPartPaths) .build(); return Optional.of(new FilterApplicationResult<>(updatedHandle, constraint.getExpression(), false)); @@ -169,18 +316,55 @@ public Optional> applyFilter( @Override public ConnectorTableSchema getTableSchema( ConnectorSession session, ConnectorTableHandle handle) { + // Latest schema (no time-travel pin). Shares the single build path with the at-instant overload below + // (null instant = latest) so the two can never drift. + return buildTableSchema((HudiTableHandle) handle, null); + } + + /** + * Returns the schema AS OF the pinned instant for a {@code FOR TIME AS OF} read under schema evolution + * (HD-C5a), closing HD-C2 residual #1 (the SPI default previously returned the LATEST schema, ignoring the + * pin). Keys off {@link HudiTableHandle#getQueryInstant()} — the instant {@link #applySnapshot} stamps + * onto the handle — NOT {@code snapshot.getSchemaId()}, which stays {@code -1} for hudi (hudi pins by + * instant, not by a numeric schema id, so the generic schema-id carrier is inert here; fe-core passes the + * post-{@code applySnapshot} handle, so the instant is already on it). A {@code null} instant (a plain read, + * or a non-{@code FOR TIME} pin such as {@code @incr}, whose {@code loadSnapshot} branch lists the LATEST + * schema itself and never reaches here) resolves via the SAME shared build method with a null instant, so + * latest and at-instant cannot drift. Hive gateway delegation for this 3-arg overload already shipped + * ({@code HiveConnectorMetadata}), so no hive change is needed. + */ + @Override + public ConnectorTableSchema getTableSchema(ConnectorSession session, + ConnectorTableHandle handle, ConnectorMvccSnapshot snapshot) { HudiTableHandle hudiHandle = (HudiTableHandle) handle; + return buildTableSchema(hudiHandle, hudiHandle.getQueryInstant()); + } + + /** + * Single build path for {@link #getTableSchema}: reads the columns from the Hudi metaClient AS OF + * {@code queryInstant} ({@code null} = latest) and wraps them in a {@link ConnectorTableSchema}. Shared by + * the latest (2-arg) and at-instant (3-arg) entry points so they cannot diverge. + */ + private ConnectorTableSchema buildTableSchema(HudiTableHandle hudiHandle, String queryInstant) { String basePath = hudiHandle.getBasePath(); List columns; if (basePath != null && !basePath.isEmpty()) { - columns = getSchemaFromMetaClient(basePath); + columns = getSchemaFromMetaClient(basePath, queryInstant); } else { columns = getSchemaFromHms(hudiHandle.getDbName(), hudiHandle.getTableName()); } - Map tableProperties = Collections.singletonMap( - "hudi.table.type", hudiHandle.getHudiTableType()); + Map tableProperties = new HashMap<>(); + tableProperties.put("hudi.table.type", hudiHandle.getHudiTableType()); + // Stamp the partition-column marker fe-core needs to model the table as partitioned (parity with the OLD + // HMSExternalTable/HudiDlaTable path, which read the HMS partition keys). The keys already live on the + // handle (getTableHandle -> tableInfo.getPartitionKeys()); emit them as the RAW-name CSV, matching the + // schema column names (the partition columns are appended to `columns` by both schema sources). + List partitionKeyNames = hudiHandle.getPartitionKeyNames(); + if (partitionKeyNames != null && !partitionKeyNames.isEmpty()) { + tableProperties.put(PARTITION_COLUMNS_PROPERTY, String.join(",", partitionKeyNames)); + } return new ConnectorTableSchema( hudiHandle.getTableName(), columns, "HUDI", tableProperties); } @@ -190,29 +374,560 @@ public Map getProperties() { return properties; } + // ========== Read-only write-reject safety net ========== + + /** + * Hudi-on-HMS tables are READ-ONLY in this catalog (data is written by Spark/Flink; Doris only reads). A hudi + * table's write is already rejected UP FRONT by the engine's admission gate — {@code + * supportedWriteOperations(handle)} derives from {@link #getWritePlanProvider(ConnectorTableHandle)} which + * this connector leaves at the SPI default {@code null} → empty operation set → the {@code + * PhysicalPlanTranslator} INSERT/row-level-DML gates throw a clean {@code AnalysisException} before ever + * opening a transaction; {@code EXECUTE} is rejected by {@code getProcedureOps} → {@code null}; every DDL + * op throws the SPI default {@code "... not supported"}. This override is the explicit LAST-LINE DEFENSE: it + * replaces the generic {@code "Transactions not supported"} default with a hudi-specific read-only message so + * that any future write path reaching transaction-open (bypassing the gate) fails loud with the RIGHT reason + * rather than a confusing generic one. Overriding the no-arg form covers the per-handle overload too (its SPI + * default delegates here). It is deliberately NOT placed on {@code getWritePlanProvider}: the admission gate + * calls {@code getWritePlanProvider} to DECIDE, so throwing there would make the gate throw a {@code + * DorisConnectorException} the engine misclassifies as an internal error instead of the clean "does not + * support INSERT" — keep the provider {@code null} and reject explicitly only here (dormant until hms enters + * {@code SPI_READY_TYPES}). + */ + @Override + public ConnectorTransaction beginTransaction(ConnectorSession session) { + throw new DorisConnectorException( + "Hudi tables are read-only in this catalog; INSERT/UPDATE/DELETE/MERGE are not supported"); + } + + // ========== ConnectorMvccOps (MTMV freshness) ========== + + /** + * Pins the LATEST completed instant as the query-begin MVCC snapshot (snapshot-id freshness, paimon model). + * The generic model then serves {@code MTMVSnapshotIdSnapshot(instant)} for the table and + * {@code MTMVTimestampSnapshot(instant)} per partition, so a hudi materialized view auto-refreshes on a new + * base commit and stays stable otherwise. This is an INTENTIONAL improvement over legacy {@code HudiDlaTable} + * (which pinned a constant {@code 0L} and never detected change), NOT a byte-parity port. + * + *

{@code lastModifiedFreshness} is deliberately LEFT FALSE — that flag routes a last-modified connector + * (hive) to the on-demand {@code getTableFreshness}/{@code getPartitionFreshnessMillis} probes; a snapshot-id + * connector like hudi keeps the instant pin and pays zero extra metadata calls. {@code schemaId} is left + * default ({@code -1}): explicit time travel is a later step. + */ + @Override + public Optional beginQuerySnapshot( + ConnectorSession session, ConnectorTableHandle handle) { + return buildBeginQuerySnapshot(latestInstant((HudiTableHandle) handle)); + } + + /** Builds the query-begin snapshot from a pinned instant. Static for offline unit testing. */ + static Optional buildBeginQuerySnapshot(long instant) { + return Optional.of(ConnectorMvccSnapshot.builder().snapshotId(instant).build()); + } + + /** + * Resolves an explicit {@code FOR TIME AS OF} / {@code FOR VERSION AS OF} into a pinned snapshot, owning all + * hudi-specific parsing (byte-faithful to legacy {@code HudiScanNode}). The generic seam + * ({@code PluginDrivenMvccExternalTable.loadSnapshot}) turns an empty result into a "not found" error and + * lets a thrown exception propagate verbatim. + * + *

    + *
  • {@code TIMESTAMP} ({@code FOR TIME AS OF}) — strip {@code [-: ]} from the value and pin it as a + * completed-timeline instant read BEFORE-OR-ON at scan time (legacy {@code HudiScanNode.java:211}). NO + * epoch-millis conversion, NO session time zone (unlike paimon), NO timeline-existence validation: + * legacy validates nothing and never errors on a well-formed {@code FOR TIME AS OF} (a too-early / + * future instant simply reads empty / latest via {@code getLatest*BeforeOrOn}). So ALWAYS return a + * non-empty pin — never empty, never throw for a not-found timestamp. {@code spec.isDigital()} is + * ignored (legacy strips regardless). The instant rides {@link #HUDI_QUERY_INSTANT_PROPERTY}; + * {@link #applySnapshot} stamps it onto the handle.
  • + *
  • {@code SNAPSHOT_ID} / {@code VERSION_REF} ({@code FOR VERSION AS OF}, numeric / named) — hudi + * rejects both with the byte-for-byte legacy message ({@code HudiScanNode.java:209}). It is THROWN (not + * {@code Optional.empty()}) so the exact wording reaches the user; an empty return would surface + * fe-core's wrong-domain "can't find snapshot" text.
  • + *
  • {@code INCREMENTAL} ({@code @incr(...)}) — see {@link #resolveIncremental}: resolves the + * {@code (begin, end]} window and returns a NON-EMPTY property-only pin. NEVER empty for a valid + * window (the generic {@code loadSnapshot} fail-loud has no INCREMENTAL arm, so empty would surface a + * wrong-domain "can't resolve time travel" message).
  • + *
  • Other kinds ({@code TAG}/{@code BRANCH} — hudi has none) → {@code Optional.empty()} = the + * SPI default (no worse than not overriding).
  • + *
+ */ + @Override + public Optional resolveTimeTravel(ConnectorSession session, + ConnectorTableHandle handle, ConnectorTimeTravelSpec spec) { + switch (spec.getKind()) { + case TIMESTAMP: + return Optional.of(ConnectorMvccSnapshot.builder() + .property(HUDI_QUERY_INSTANT_PROPERTY, spec.getStringValue().replaceAll("[-: ]", "")) + .build()); + case SNAPSHOT_ID: + case VERSION_REF: + throw new DorisConnectorException( + "Hudi does not support `FOR VERSION AS OF`, please use `FOR TIME AS OF`"); + case INCREMENTAL: + return Optional.of(resolveIncremental((HudiTableHandle) handle, spec.getIncrementalParams())); + default: + return Optional.empty(); + } + } + + /** + * Resolves an {@code @incr(...)} incremental read into a NON-EMPTY property-only pin carrying the resolved + * {@code (begin, end]} completed-timeline window. Consolidates legacy's per-relation window resolution + * (spread byte-identically across {@code COWIncrementalRelation}/{@code MORIncrementalRelation} constructors) + * into ONE connector locus, so the resolved bounds are on the handle for both file selection (a later step) + * and the synthetic {@code _hoodie_commit_time} filter. Runs the single metaClient touch (latest completed + * instant) under {@link HudiMetaClientExecutor#execute} (TCCL pin + plugin UGI {@code doAs}). + * + *
    + *
  • Empty completed timeline → the {@code (000, 000]} window — legacy {@code withScanParams} + * short-circuits to {@code EmptyIncrementalRelation} before the begin-required check, so a + * missing {@code beginTime} is NOT an error here; the window selects nothing.
  • + *
  • {@code beginTime} is required (legacy fail-loud, byte-for-byte message); {@code "earliest"} → + * {@code "000"}.
  • + *
  • {@code endTime} defaults to the latest completed instant; {@code "latest"} → the latest completed + * instant. The sentinel test is on the RESOLVED end value (legacy COW form, + * {@code COWIncrementalRelation:98}); the single locus inherently avoids the dead-code MOR bug + * ({@code MORIncrementalRelation:92}, which tested {@code latestTime} and so never fired). The + * "latest completed instant" is taken on the COMPLETION-time axis under the {@code USE_TRANSITION_TIME} + * hollow-commit policy (legacy parity) so the ONE resolved end matches the ported relation's + * completion-time file selection AND the later synthetic {@code _hoodie_commit_time} row filter — the + * connector never lets the file set and the row filter diverge on the window. An explicitly-supplied + * {@code endTime} is used verbatim on either axis (the user supplies an axis-appropriate value).
  • + *
+ * + *

The pin is property-only: {@code snapshotId}/{@code schemaId} are inert because fe-core's INCREMENTAL + * {@code loadSnapshot} branch lists the LATEST partitions + LATEST schema itself and reads only these window + * properties. COW/MOR/RO-as-RT file selection is deferred to {@code planScan}; {@link #applySnapshot} stamps + * the window onto the handle.

+ */ + private ConnectorMvccSnapshot resolveIncremental(HudiTableHandle handle, Map params) { + // Resolve the latest completed instant on the completion-time axis when the hollow-commit policy is + // USE_TRANSITION_TIME (legacy parity, see INCR_HOLLOW_POLICY_KEY); the default axis is requested-time. + boolean useCompletionTime = INCR_STATE_TRANSITION_POLICY.equals(params.get(INCR_HOLLOW_POLICY_KEY)); + Optional latestTime = metaClientExecutor.execute(() -> + HudiScanPlanProvider.latestCompletedInstantTime( + HudiScanPlanProvider.buildMetaClient(buildHadoopConf(), handle.getBasePath()), + useCompletionTime)); + String begin; + String end; + if (!latestTime.isPresent()) { + // Empty completed timeline: legacy short-circuits to EmptyIncrementalRelation (begin = end = "000") + // WITHOUT the begin-required check. + begin = INCR_ZERO_INSTANT; + end = INCR_ZERO_INSTANT; + } else { + begin = params.get(INCR_BEGIN_TIME_KEY); + if (begin == null) { + throw new DorisConnectorException("Specify the begin instant time to pull from using " + + "option hoodie.datasource.read.begin.instanttime"); + } + if (INCR_EARLIEST_SENTINEL.equals(begin)) { + begin = INCR_ZERO_INSTANT; + } + end = params.getOrDefault(INCR_END_TIME_KEY, latestTime.get()); + if (INCR_LATEST_SENTINEL.equals(end)) { + end = latestTime.get(); + } + } + ConnectorMvccSnapshot.Builder builder = ConnectorMvccSnapshot.builder() + .property(HUDI_INCREMENTAL_BEGIN_PROPERTY, begin) + .property(HUDI_INCREMENTAL_END_PROPERTY, end); + // Carry the raw @incr option params forward so planScan can feed them to the ported relations (glob / + // fallback / hollow-commit policy). Skip null values (Builder.property NPEs on null; the begin/end keys + // above are their own carriers and are not re-copied here — they use the "hudi.incremental-*" namespace, + // which does not match HUDI_INCR_OPT_PREFIX). + params.forEach((k, v) -> { + if (v != null) { + builder.property(HUDI_INCR_OPT_PREFIX + k, v); + } + }); + return builder.build(); + } + + /** + * Threads a resolved pin onto the handle BEFORE planScan, reading the FE-internal carrier properties set by + * {@link #resolveTimeTravel} and stamping via {@code toBuilder()}, which PRESERVES any + * {@code prunedPartitionPaths} applyFilter set earlier (applyFilter runs before applySnapshot at scan time, + * so a rebuild-from-scratch would drop the pruning). Two mutually exclusive carriers: + * + *
    + *
  • {@link #HUDI_QUERY_INSTANT_PROPERTY} ({@code FOR TIME AS OF}) → stamp {@code queryInstant}.
  • + *
  • {@link #HUDI_INCREMENTAL_BEGIN_PROPERTY}/{@link #HUDI_INCREMENTAL_END_PROPERTY} ({@code @incr}) + * → stamp {@code begin/endInstant}.
  • + *
+ * + *

For the query-begin latest pin ({@code beginQuerySnapshot} carries only a {@code snapshotId}, NO + * property) — and for a null snapshot — the handle is returned UNCHANGED, so a plain read stays + * byte-identical to today (planScan falls back to {@code timeline.lastInstant()}). Mirrors paimon's + * empty-properties / invalid-pin no-op.

+ */ + @Override + public ConnectorTableHandle applySnapshot(ConnectorSession session, + ConnectorTableHandle handle, ConnectorMvccSnapshot snapshot) { + if (snapshot == null) { + return handle; + } + Map properties = snapshot.getProperties(); + String queryInstant = properties.get(HUDI_QUERY_INSTANT_PROPERTY); + if (queryInstant != null) { + return ((HudiTableHandle) handle).toBuilder().queryInstant(queryInstant).build(); + } + String beginInstant = properties.get(HUDI_INCREMENTAL_BEGIN_PROPERTY); + if (beginInstant != null) { + // Reconstruct the raw @incr option params from their prefixed carriers (the begin/end keys use a + // different "hudi.incremental-*" namespace, so they are not collected here). + Map incrementalParams = new HashMap<>(); + for (Map.Entry entry : properties.entrySet()) { + if (entry.getKey().startsWith(HUDI_INCR_OPT_PREFIX)) { + incrementalParams.put( + entry.getKey().substring(HUDI_INCR_OPT_PREFIX.length()), entry.getValue()); + } + } + return ((HudiTableHandle) handle).toBuilder() + .beginInstant(beginInstant) + .endInstant(properties.get(HUDI_INCREMENTAL_END_PROPERTY)) + .incrementalParams(incrementalParams) + .build(); + } + return handle; + } + + /** + * Supplies the ROW-LEVEL correctness filter for an {@code @incr} incremental read as a connector-neutral + * residual predicate (the neutral synthetic-predicate SPI). A COW base file rewritten inside the + * {@code (begin, end]} window also carries forward older-commit rows, so selecting the touched files is NOT + * enough — the engine must additionally apply {@code _hoodie_commit_time > begin AND _hoodie_commit_time + * <= end} at the row level. This is exactly the filter legacy injected via + * {@code LogicalHudiScan.generateIncrementalExpression}, re-homed here so fe-core stays source-agnostic + * (it reverse-converts the returned {@link ConnectorExpression}s and wraps a filter; it never branches on + * the connector). + * + *

The window is read from the SAME resolved {@code snapshot} that {@link #applySnapshot} consumes + * ({@link #HUDI_INCREMENTAL_BEGIN_PROPERTY}/{@link #HUDI_INCREMENTAL_END_PROPERTY}, stamped ONCE by + * {@link #resolveIncremental}), so the row filter and the file-selection window are the single same + * resolution and can never diverge on an advancing timeline. Both bounds are STRING literals over a STRING + * column ref — lexicographic compare over fixed-width Hudi instants, byte-faithful to legacy (a numeric + * coercion would silently corrupt the ordering).

+ * + *

Returns an EMPTY list for any non-incremental read (a plain latest read or {@code FOR TIME AS OF} + * pin carries no {@code (begin, end]} window), so those plans stay byte-identical.

+ */ + @Override + public List getSyntheticScanPredicates(ConnectorSession session, + ConnectorTableHandle handle, ConnectorMvccSnapshot snapshot) { + if (snapshot == null) { + return Collections.emptyList(); + } + Map properties = snapshot.getProperties(); + String begin = properties.get(HUDI_INCREMENTAL_BEGIN_PROPERTY); + String end = properties.get(HUDI_INCREMENTAL_END_PROPERTY); + if (begin == null || end == null) { + // Not an incremental read (plain / FOR TIME AS OF pins carry no window) -> no synthetic filter. + return Collections.emptyList(); + } + ConnectorType stringType = ConnectorType.of("STRING"); + org.apache.doris.connector.api.pushdown.ConnectorColumnRef commitTime = + new org.apache.doris.connector.api.pushdown.ConnectorColumnRef(HUDI_COMMIT_TIME_COLUMN, stringType); + ConnectorExpression lower = new ConnectorComparison( + ConnectorComparison.Operator.GT, commitTime, ConnectorLiteral.ofString(begin)); + ConnectorExpression upper = new ConnectorComparison( + ConnectorComparison.Operator.LE, commitTime, ConnectorLiteral.ofString(end)); + // Two flat conjuncts: fe-core ANDs them into one LogicalFilter (byte-faithful to legacy + // ImmutableSet.of(great, less)); no ConnectorAnd wrapper is needed. + return List.of(lower, upper); + } + + // ========== ConnectorTableOps (partitions) ========== + + /** + * Lists all partitions with metadata. {@code filter} is intentionally ignored (paimon / maxcompute parity): + * the generic model lists the full universe and prunes FE-side. + */ + @Override + public List listPartitions(ConnectorSession session, + ConnectorTableHandle handle, Optional filter) { + return collectPartitions((HudiTableHandle) handle); + } + + @Override + public List listPartitionNames(ConnectorSession session, ConnectorTableHandle handle) { + List partitions = collectPartitions((HudiTableHandle) handle); + List names = new ArrayList<>(partitions.size()); + for (ConnectorPartitionInfo partition : partitions) { + names.add(partition.getPartitionName()); + } + return names; + } + + @Override + public List> listPartitionValues(ConnectorSession session, + ConnectorTableHandle handle, List partitionColumns) { + List partitions = collectPartitions((HudiTableHandle) handle); + 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 + * {@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 + * table). Explicit time-travel (non-latest) partition listing is a later step. + */ + private List collectPartitions(HudiTableHandle handle) { + List partKeyNames = handle.getPartitionKeyNames(); + if (partKeyNames == null || partKeyNames.isEmpty()) { + return Collections.emptyList(); + } + if (useHiveSyncPartition()) { + // hive-sync tables register their partitions in HMS: list the names from there (already authed via + // 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). + List hmsNames = hmsClient.listPartitionNames( + handle.getDbName(), handle.getTableName(), -1); + if (hmsNames != null && !hmsNames.isEmpty()) { + return buildPartitionInfos(hmsNames, partKeyNames, latestInstant(handle)); + } + LOG.warn("hive-sync hudi table {}.{} has no HMS partitions; " + + "falling back to hudi metadata partition listing", + handle.getDbName(), handle.getTableName()); + } + // Non-hive-sync (or hive-sync HMS-empty fallback): the instant and the partition paths both come from + // the metaClient, built ONCE under the plugin auth + TCCL pin. Byte-consistent with the scan's unpruned + // partition source (resolvePartitions -> listAllPartitionPaths), which is the R2 prune-to-zero guard. + Map.Entry> listing = metaClientExecutor.execute(() -> { + HoodieTableMetaClient metaClient = + HudiScanPlanProvider.buildMetaClient(buildHadoopConf(), handle.getBasePath()); + return new AbstractMap.SimpleImmutableEntry<>( + HudiScanPlanProvider.latestCompletedInstant(metaClient), + HudiScanPlanProvider.listAllPartitionPaths(metaClient)); + }); + return buildPartitionInfos(listing.getValue(), partKeyNames, listing.getKey()); + } + + /** + * 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. + */ + static List buildPartitionInfos( + List rawPaths, List partKeyNames, long instant) { + 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 + // the values map agree by construction (the name re-parses back to these values in fe-core). + Map values = HudiScanPlanProvider.parsePartitionValues(rawPath, partKeyNames); + String name = HudiScanPlanProvider.renderHiveStylePartitionName(partKeyNames, values); + // Ordered values in partKeyNames (render) order; render/parse are exact inverses, so this equals + // fe-core's legacy parse of `name`. Supplied so fe-core skips the parse. + List orderedValues = new ArrayList<>(partKeyNames.size()); + for (String col : partKeyNames) { + orderedValues.add(values.get(col)); + } + result.add(new ConnectorPartitionInfo(name, values, Collections.emptyMap(), + ConnectorPartitionInfo.UNKNOWN, ConnectorPartitionInfo.UNKNOWN, + instant, ConnectorPartitionInfo.UNKNOWN, + orderedValues, Collections.emptyList())); + } + return result; + } + + /** Pins the latest completed instant, building the metaClient under the plugin auth + TCCL pin. */ + private long latestInstant(HudiTableHandle handle) { + return metaClientExecutor.execute(() -> + HudiScanPlanProvider.latestCompletedInstant( + HudiScanPlanProvider.buildMetaClient(buildHadoopConf(), handle.getBasePath()))); + } + + /** + * Engine-neutral rows for the {@code hudi_meta()} / TIMELINE metadata table: one row per instant of the FULL + * active timeline (all states, NOT the completed-only helper — the TVF shows a {@code state} column), each + * mapped to the 4 String cells the TVF renders (requestedTime / action / state / completionTime). The metaClient + * touch runs under the plugin auth + TCCL pin (like {@link #latestInstant} / {@link #collectPartitions}), so + * fe-core adds no pin of its own. {@code completionTime} is {@code null} for a non-completed instant (rendered + * SQL NULL) — byte-parity with the legacy fe-core inline loop. Unknown {@code kind} returns nothing. + */ + @Override + public List> getMetadataTableRows(ConnectorSession session, ConnectorTableHandle handle, + String kind) { + if (!"timeline".equals(kind)) { + return Collections.emptyList(); + } + HudiTableHandle hudiHandle = (HudiTableHandle) handle; + return metaClientExecutor.execute(() -> { + HoodieTableMetaClient metaClient = + HudiScanPlanProvider.buildMetaClient(buildHadoopConf(), hudiHandle.getBasePath()); + List> rows = new ArrayList<>(); + for (HoodieInstant instant : metaClient.getActiveTimeline().getInstants()) { + rows.add(Arrays.asList(instant.requestedTime(), instant.getAction(), + instant.getState().name(), instant.getCompletionTime())); + } + return rows; + }); + } + + private boolean useHiveSyncPartition() { + return Boolean.parseBoolean(properties.getOrDefault(USE_HIVE_SYNC_PARTITION, "false")); + } + + /** + * Builds the BE table descriptor for a hudi table: a {@code TTableType.HIVE_TABLE} carrying a + * {@link THiveTable}, a direct port of legacy hudi (which rode {@code HMSExternalTable.toThrift} / + * {@code HudiScanNode extends HiveScanNode} = HIVE_TABLE). Without this override the SPI default returns + * {@code null} and fe-core ({@code PluginDrivenExternalTable.toThrift}) falls back to a generic + * {@code SCHEMA_TABLE} descriptor, so BE builds a SchemaTableDescriptor instead of a HiveTableDescriptor. + * Mirrors {@code HiveConnectorMetadata.buildTableDescriptor}; the SPI signature carries no handle, so this + * single override serves base and system tables alike. + */ + @Override + public TTableDescriptor buildTableDescriptor(ConnectorSession session, + long tableId, String tableName, String dbName, String remoteName, int numCols, long catalogId) { + THiveTable tHiveTable = new THiveTable(dbName, tableName, new HashMap<>()); + TTableDescriptor desc = new TTableDescriptor( + tableId, TTableType.HIVE_TABLE, numCols, 0, tableName, dbName); + desc.setHiveTable(tHiveTable); + return desc; + } + // ========== Internal helpers ========== /** - * Read schema from HoodieTableMetaClient's latest Avro schema. - * This is the authoritative schema for Hudi tables. + * Reads the columns from the Hudi metaClient AS OF {@code queryInstant} ({@code null} = the latest Avro + * schema, the authoritative schema for a plain read). The whole metaClient touch runs under + * {@link HudiMetaClientExecutor#execute} (plugin UGI {@code doAs} + TCCL pin) — HD-C5a closes the + * previously-unwrapped auth/TCCL gap, because {@code getTableSchema} can be called off the TCCL-pinned scan + * thread (e.g. catalog metadata load / MTMV refresh). + * + *

At-instant ({@code queryInstant != null}, {@code FOR TIME AS OF}). Byte-faithful to legacy + * {@code HiveMetaStoreClientHelper.getHudiTableSchema} + {@code HMSExternalTable.initHudiSchema}: reload the + * active timeline, then {@code getTableInternalSchemaFromCommitMetadata(instant)}. When present + * ({@code hoodie.schema.on.read.enable} is ON) the columns/ids come from that AT-INSTANT + * {@link InternalSchema} (stable ids across renames), so a renamed column shows its historical name and BE + * matches its old files BY FIELD ID. When absent (evolution off) it falls through to the latest path — + * byte-equivalent for a non-evolution table whose schema never changed (legacy's latest-fallback, design + * decision D3).

+ * + *

Package-private (not static) so a same-loader test can override it to assert the {@code queryInstant} + * is threaded from the handle without a live metaClient (the actual at-instant read is e2e).

*/ - private List getSchemaFromMetaClient(String basePath) { + List getSchemaFromMetaClient(String basePath, String queryInstant) { try { - Configuration conf = buildHadoopConf(); - HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder() - .setConf(new org.apache.hudi.storage.hadoop.HadoopStorageConfiguration(conf)) - .setBasePath(basePath) - .build(); - TableSchemaResolver schemaResolver = new TableSchemaResolver(metaClient); - Schema avroSchema = schemaResolver.getTableAvroSchema(); - return avroSchemaToColumns(avroSchema); + return metaClientExecutor.execute(() -> { + HoodieTableMetaClient metaClient = + HudiScanPlanProvider.buildMetaClient(buildHadoopConf(), basePath); + TableSchemaResolver schemaResolver = new TableSchemaResolver(metaClient); + if (queryInstant != null) { + // Reload so a recently-committed instant's schema file is readable, not a stale cached one + // (legacy getHudiTableSchema reloads for exactly this reason). + metaClient.reloadActiveTimeline(); + Option atInstant = + schemaResolver.getTableInternalSchemaFromCommitMetadata(queryInstant); + if (atInstant.isPresent()) { + return columnsFromInternalSchema(atInstant.get()); + } + // schema.on.read off -> no commit-metadata InternalSchema -> latest fallback (D3): + // byte-equivalent for a non-evolution table (its schema never changed). + } + // Latest schema. Include the 5 `_hoodie_*` meta columns (byte-faithful to legacy + // getHudiTableSchema, which passes `true` unconditionally). The explicit `true` (a) restores + // legacy SELECT * / DESCRIBE parity even for a populate.meta.fields=false table and (b) keeps + // `_hoodie_commit_time` a visible, name-bindable column for the synthetic incremental row filter. + Schema avroSchema = schemaResolver.getTableAvroSchema(true); + List columns = avroSchemaToColumns(avroSchema); + return attachHudiFieldIds(schemaResolver, avroSchema, columns); + }); } catch (Exception e) { - LOG.warn("Failed to get schema from Hudi MetaClient for path '{}': {}", - basePath, e.getMessage()); + // Pass the throwable (not e.getMessage()) so the full cause chain + stack survives the + // HudiMetaClientExecutor.execute DorisConnectorException wrapper (whose fixed message would otherwise + // mask WHY this best-effort read degraded to an empty column list / BY_NAME). + LOG.warn("Failed to get schema from Hudi MetaClient for path '{}' (instant={})", + basePath, queryInstant, e); return Collections.emptyList(); } } + /** + * Builds the column list from an AT-INSTANT {@link InternalSchema} (schema-on-read time travel). Mirror of + * legacy {@code HMSExternalTable.initHudiSchema}: convert the InternalSchema to Avro to derive the column + * names/types AT the instant, then attach each top-level field id from the SAME InternalSchema (stable + * across renames). The record name passed to {@code convert} is cosmetic (only the ROOT record is named; the + * derived columns come from its fields), so a constant is used. Field-id resolution and lowercasing match + * the latest path exactly (shared {@link #attachTopLevelFieldIds}). + */ + private List columnsFromInternalSchema(InternalSchema internalSchema) { + Schema avroSchema = AvroInternalSchemaConverter.convert(internalSchema, "hudi_table"); + List columns = avroSchemaToColumns(avroSchema); + return attachTopLevelFieldIds(columns, internalSchema); + } + + /** + * Resolve the mode-aware InternalSchema for the LATEST commit and attach each column's top-level field id + * (HD-C4b). Mirror of legacy {@code HiveMetaStoreClientHelper.getHudiTableSchema}: when {@code + * hoodie.schema.on.read.enable} is on, the ids come from the commit-metadata {@link InternalSchema} (STABLE + * across renames, so a renamed column keeps its id and BE matches its old files BY FIELD ID); otherwise from + * {@code AvroInternalSchemaConverter.convert(latest avro)} (positional ids). The no-arg {@code + * getTableInternalSchemaFromCommitMetadata()} pins the latest commit — steady-state / no time-travel pin (an + * at-instant variant is a later step). + * + *

On ANY resolution failure the columns are returned unchanged (ids stay {@code UNSET_UNIQUE_ID} -> BE + * BY_NAME, the safe baseline) rather than dropping the whole schema — a schema-evolution id hiccup must not + * fail a plain read.

+ * + *

Unlike legacy (which sources columns AND ids from the same InternalSchema and zips positionally), the + * connector keeps its independent {@code getTableAvroSchema(true)} column source (preserving the shipped + * meta-column-inclusive schema), so ids are matched BY NAME in {@link #attachTopLevelFieldIds}. This runs + * inside the {@link HudiMetaClientExecutor#execute} wrapper that {@link #getSchemaFromMetaClient} now + * establishes (HD-C5a closed the previously-unwrapped auth/TCCL gap).

+ */ + private List attachHudiFieldIds(TableSchemaResolver schemaResolver, Schema latestAvro, + List columns) { + try { + InternalSchema internalSchema = + HudiSchemaUtils.resolveTableInternalSchema(schemaResolver, latestAvro).internalSchema; + return attachTopLevelFieldIds(columns, internalSchema); + } catch (Exception e) { + LOG.warn("Failed to resolve Hudi field ids; falling back to name-based (BY_NAME) matching: {}", + e.getMessage()); + return columns; + } + } + + /** + * Attach each column's top-level field id from {@code internalSchema}, matched by (lower-cased) name. Port of + * legacy {@code HudiUtils.updateHudiColumnUniqueId} at the top level only: the handle carries the top-level + * field id, while nested field ids for the BE schema dictionary come straight from the InternalSchema via + * {@link HudiSchemaUtils}. A column with no matching InternalSchema field (e.g. a {@code _hoodie_*} meta + * column absent from a commit-metadata schema) keeps {@link ConnectorColumn#UNSET_UNIQUE_ID}; because BE's + * field-id mode is per-file (not per-column), that unresolved id gates the whole scan-level dict OFF -> + * BE BY_NAME for the entire scan (see {@code HudiSchemaUtils.buildSchemaEvolutionProp}), never a silent + * per-column drop. Package-private + static for same-loader unit testing. + */ + static List attachTopLevelFieldIds(List columns, InternalSchema internalSchema) { + Map idByName = new HashMap<>(); + for (Types.Field field : internalSchema.getRecord().fields()) { + idByName.put(field.name().toLowerCase(Locale.ROOT), field.fieldId()); + } + List result = new ArrayList<>(columns.size()); + for (ConnectorColumn col : columns) { + Integer id = idByName.get(col.getName().toLowerCase(Locale.ROOT)); + result.add(id != null ? col.withUniqueId(id) : col); + } + return result; + } + /** * Fallback: read schema from HMS if MetaClient fails. */ @@ -230,8 +945,11 @@ private List getSchemaFromHms(String dbName, String tableName) /** * Convert Avro schema fields to ConnectorColumn list. + * + *

Package-private and static so it can be unit-tested directly with a + * hand-built Avro schema (no live HoodieTableMetaClient needed).

*/ - private List avroSchemaToColumns(Schema avroSchema) { + static List avroSchemaToColumns(Schema avroSchema) { List fields = avroSchema.getFields(); List columns = new ArrayList<>(fields.size()); for (Schema.Field field : fields) { @@ -239,7 +957,12 @@ private List avroSchemaToColumns(Schema avroSchema) { Schema fieldSchema = unwrapNullable(field.schema()); ConnectorType connectorType = HudiTypeMapping.fromAvroSchema(fieldSchema); String comment = field.doc() != null ? field.doc() : ""; - columns.add(new ConnectorColumn(field.name(), connectorType, comment, nullable, null)); + // Lower-case the top-level column name to mirror legacy + // HMSExternalTable.initHudiSchema (name().toLowerCase(Locale.ROOT)). + // Nested struct field names are left as-is here and in HudiTypeMapping, + // matching legacy (which lowercases only the top-level column name). + String columnName = field.name().toLowerCase(Locale.ROOT); + columns.add(new ConnectorColumn(columnName, connectorType, comment, nullable, null)); } return columns; } @@ -294,6 +1017,9 @@ private String detectHudiTableType(HmsTableInfo tableInfo) { private Configuration buildHadoopConf() { Configuration conf = new Configuration(); + // Storage credentials (fs.s3a.* …) first so an inline user fs./dfs./hadoop. key still wins below; see + // storageHadoopConfig field. Without it the metaClient's S3AFileSystem cannot read hoodie.properties. + storageHadoopConfig.forEach(conf::set); for (Map.Entry entry : properties.entrySet()) { String key = entry.getKey(); if (key.startsWith("hadoop.") || key.startsWith("fs.") @@ -303,4 +1029,171 @@ private Configuration buildHadoopConf() { } return conf; } + + // ========== Partition pruning helpers ========== + // Mirrors HiveConnectorMetadata's EQ/IN partition pruning. Duplicated rather than + // shared because fe-connector-hudi depends on fe-connector-hms, not fe-connector-hive; + // consolidate during the Hive (P7) migration. See P3-T05 design. + + /** + * Extracts equality predicates on partition columns from the expression tree. + * Supports: col = 'value', col IN ('v1', 'v2', ...), AND combinations. + */ + private Map> extractPartitionPredicates( + ConnectorExpression expr, List partKeyNames) { + Set partKeySet = partKeyNames.stream().collect(Collectors.toSet()); + Map> result = new HashMap<>(); + extractPredicatesRecursive(expr, partKeySet, result); + return result; + } + + private void extractPredicatesRecursive(ConnectorExpression expr, + Set partKeySet, Map> result) { + if (expr instanceof ConnectorAnd) { + for (ConnectorExpression child : ((ConnectorAnd) expr).getConjuncts()) { + extractPredicatesRecursive(child, partKeySet, result); + } + } else if (expr instanceof ConnectorComparison) { + ConnectorComparison cmp = (ConnectorComparison) expr; + if (cmp.getOperator() == ConnectorComparison.Operator.EQ) { + String colName = extractColumnName(cmp.getLeft()); + String value = extractLiteralValue(cmp.getRight()); + if (colName != null && value != null && partKeySet.contains(colName)) { + result.computeIfAbsent(colName, k -> new ArrayList<>()).add(value); + } + } + } else if (expr instanceof ConnectorIn) { + ConnectorIn inExpr = (ConnectorIn) expr; + if (!inExpr.isNegated()) { + String colName = extractColumnName(inExpr.getValue()); + if (colName != null && partKeySet.contains(colName)) { + List values = new ArrayList<>(); + for (ConnectorExpression item : inExpr.getInList()) { + String val = extractLiteralValue(item); + if (val != null) { + values.add(val); + } + } + if (!values.isEmpty()) { + result.computeIfAbsent(colName, k -> new ArrayList<>()).addAll(values); + } + } + } + } + } + + private String extractColumnName(ConnectorExpression expr) { + if (expr instanceof org.apache.doris.connector.api.pushdown.ConnectorColumnRef) { + return ((org.apache.doris.connector.api.pushdown.ConnectorColumnRef) expr).getColumnName(); + } + return null; + } + + private String extractLiteralValue(ConnectorExpression expr) { + if (!(expr instanceof ConnectorLiteral)) { + return null; + } + Object val = ((ConnectorLiteral) expr).getValue(); + if (val == null) { + return null; + } + if (val instanceof LocalDateTime) { + // H2: a DATETIME/TIMESTAMP partition literal arrives as a LocalDateTime (DATE arrives as LocalDate). + // String.valueOf would call toString() -> ISO "2024-01-01T10:00" (T separator, dropped zero seconds), + // which never string-equals the Hive-canonical stored partition value "2024-01-01 10:00:00" in + // matchesPredicates -> the whole table prunes to 0 rows. Render Hive-canonical text instead. + return hiveDateTimeString((LocalDateTime) val); + } + return String.valueOf(val); + } + + /** + * Renders a DATETIME/TIMESTAMP literal as Hive-canonical partition text: {@code yyyy-MM-dd HH:mm:ss} (space + * separator, full seconds), appending trailing-zero-trimmed microseconds only when a sub-second part is + * present. Matches the stored Hive partition value for a scale-0 DATETIME partition (the only realistic case) + * so the pruning string-compare hits. Package-private static for offline unit testing. + * + *

Contract: {@code convertDateLiteral} produces microsecond precision (nano = micros*1000), so the nano is + * always a multiple of 1000; a sub-microsecond nano (unreachable on the pruning path) would be truncated. + */ + static String hiveDateTimeString(LocalDateTime ldt) { + String base = ldt.format(HIVE_DATETIME_SECONDS_FORMAT); + int nano = ldt.getNano(); + if (nano == 0) { + return base; + } + String micros = String.format("%06d", nano / 1000); + int end = micros.length(); + while (end > 1 && micros.charAt(end - 1) == '0') { + end--; + } + return base + "." + micros.substring(0, end); + } + + /** + * Prunes partition names based on extracted equality predicates. + * Partition names follow the Hive convention: key1=val1/key2=val2 + */ + private List prunePartitionNames(List allPartNames, + List partKeyNames, Map> predicates) { + List matched = new ArrayList<>(); + for (String partName : allPartNames) { + Map partValues = parsePartitionName(partName, partKeyNames); + if (matchesPredicates(partValues, predicates)) { + matched.add(partName); + } + } + return matched; + } + + /** + * Prunes Hudi RELATIVE partition paths (positional {@code "2024/01"} or hive-style {@code + * "year=2024/month=01"}) using {@link HudiScanPlanProvider#parsePartitionValues} (handles both layouts and + * unescapes) + {@link #matchesPredicates}. Used by the non-hive-sync {@link #applyFilter} branch, whose + * candidate source is the Hudi metadata listing — the same relative-path shape the scan feeds fsView. Static + + * package-private for offline unit testing. + */ + static List prunePartitionPaths(List allPartPaths, + List partKeyNames, Map> predicates) { + List matched = new ArrayList<>(); + for (String partPath : allPartPaths) { + Map partValues = HudiScanPlanProvider.parsePartitionValues(partPath, partKeyNames); + if (matchesPredicates(partValues, predicates)) { + matched.add(partPath); + } + } + return matched; + } + + static Map parsePartitionName(String partName, + List partKeyNames) { + Map values = new HashMap<>(); + String[] parts = partName.split("/"); + for (String part : parts) { + int eq = part.indexOf('='); + if (eq > 0) { + // Unescape the VALUE: HMS get_partition_names returns Hive-escaped names (e.g. "%3A" for ':'). + // The predicate literal side (extractLiteralValue) is unescaped, so matchesPredicates' string + // compare needs the value unescaped too — otherwise an escaped partition value silently drops + // rows. Mirrors the sibling scan-side parse (HudiScanPlanProvider.parsePartitionValues) and + // legacy FileUtils.unescapePathName. The key is a column name (never escaped), left as-is. + values.put(part.substring(0, eq), + HudiScanPlanProvider.unescapePathName(part.substring(eq + 1))); + } + } + return values; + } + + static boolean matchesPredicates(Map partValues, + Map> predicates) { + for (Map.Entry> entry : predicates.entrySet()) { + String colName = entry.getKey(); + List allowedValues = entry.getValue(); + String actualValue = partValues.get(colName); + if (actualValue == null || !allowedValues.contains(actualValue)) { + return false; + } + } + return true; + } } 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 f055aa3a5c5329..993775d904c401 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 @@ -27,13 +27,23 @@ * SPI entry point for the Hudi connector plugin. * *

Registered via {@code META-INF/services/org.apache.doris.connector.spi.ConnectorProvider}. - * The type is {@code "hudi"} for dedicated Hudi catalogs that connect to HMS - * and expose Hudi tables.

+ * + *

The type {@code "hudi"} is a SIBLING-ONLY type string — NOT a user-facing catalog type. There is no + * {@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). */ 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. return "hudi"; } diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiMetaClientExecutor.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiMetaClientExecutor.java new file mode 100644 index 00000000000000..159b451b6c1990 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiMetaClientExecutor.java @@ -0,0 +1,38 @@ +// 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 java.util.concurrent.Callable; + +/** + * Runs a Hudi {@code HoodieTableMetaClient}-touching action under the plugin's Kerberos UGI {@code doAs} and a + * TCCL pin to the hudi plugin classloader. + * + *

Built by {@link HudiConnector} and injected into {@link HudiConnectorMetadata} so the partition-listing / + * MVCC-snapshot metadata methods — which build a live metaClient off the query-planning / MTMV-refresh thread, + * NOT the TCCL-pinned scan thread ({@code PluginDrivenScanNode.onPluginClassLoader}) — resolve hudi-bundled + * reflection against the plugin's child-first copies and authenticate to a secured HMS/HDFS (post-flip the + * FE-injected {@code context.executeAuthenticated} is NOOP for a sibling). See + * {@code HudiConnector.metaClientExecutor()} and memory {@code catalog-spi-plugin-tccl-classloader-gotcha}.

+ * + *

A generic method (not a lambda target): the implementation is an anonymous class in {@link HudiConnector}. + * Checked exceptions from {@code action} are wrapped by the implementation.

+ */ +interface HudiMetaClientExecutor { + T execute(Callable action); +} 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 d5f6b3628ddc66..9d65299509ea91 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,32 +24,46 @@ 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.spi.ConnectorContext; +import org.apache.doris.thrift.TFileCompressType; +import org.apache.doris.thrift.TFileScanRangeParams; import org.apache.avro.Schema; import org.apache.hadoop.conf.Configuration; import org.apache.hudi.common.config.HoodieMetadataConfig; import org.apache.hudi.common.engine.HoodieLocalEngineContext; import org.apache.hudi.common.model.BaseFile; +import org.apache.hudi.common.model.FileSlice; import org.apache.hudi.common.model.HoodieBaseFile; import org.apache.hudi.common.model.HoodieLogFile; +import org.apache.hudi.common.model.HoodieTableType; 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.HoodieTimeline; +import org.apache.hudi.common.table.timeline.TimelineUtils.HollowCommitHandling; import org.apache.hudi.common.table.view.FileSystemViewManager; import org.apache.hudi.common.table.view.HoodieTableFileSystemView; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.internal.schema.InternalSchema; import org.apache.hudi.metadata.HoodieTableMetadata; import org.apache.hudi.metadata.HoodieTableMetadataUtil; import org.apache.hudi.storage.StoragePath; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.function.Function; +import java.util.function.UnaryOperator; import java.util.stream.Collectors; /** @@ -68,17 +82,57 @@ * * * - *

Scope: Snapshot reads of non-incremental tables. - * Incremental reads, schema evolution, and time travel are deferred.

+ *

Scope: snapshot reads, {@code FOR TIME AS OF} time travel (a pinned {@code queryInstant}), {@code @incr} + * incremental FILE selection (a resolved {@code (begin, end]} window), and schema evolution (per-file {@code + * schema_id} + the scan-level {@code -1}/history dictionary for BE's field-id matching, resolved AT the pinned + * instant under time travel). Incremental row-level filtering to the window (a synthetic {@code + * _hoodie_commit_time} predicate) is an fe-core analysis-time step, not this provider's concern.

*/ public class HudiScanPlanProvider implements ConnectorScanPlanProvider { private static final Logger LOG = LogManager.getLogger(HudiScanPlanProvider.class); + // The force_jni_scanner session flag (VariableMgr.toMap channel, read via + // ConnectorSession.getSessionProperties()). When true, the JNI escape hatch is engaged: a native-eligible + // slice is routed to the JNI reader (dodging native-reader bugs), matching legacy + // HudiScanNode.canUseNativeReader() / setScanParams (sessionVariable.isForceJniScanner()). Same key + read + // path as the paimon connector's FORCE_JNI_SCANNER. Default false, so normal reads are unaffected. + private static final String FORCE_JNI_SCANNER = "force_jni_scanner"; + + // Scan-node prop carrying the base64 native-reader schema-evolution dictionary (current_schema_id + + // history_schema_info). getScanNodeProperties builds it; populateScanLevelParams copies it onto the real + // TFileScanRangeParams. Mirrors the paimon connector's SCHEMA_EVOLUTION_PROP. + private static final String SCHEMA_EVOLUTION_PROP = "hudi.schema_evolution"; + private final Map properties; + private final ConnectorContext context; - public HudiScanPlanProvider(Map properties) { + public HudiScanPlanProvider(Map properties, ConnectorContext context) { this.properties = properties; + this.context = context; + } + + /** + * Reads the {@code force_jni_scanner} session flag from the SPI session properties. Package-private static + * for offline unit testing. Default false (legacy default) when unset or the session is null. + */ + static boolean isForceJniScannerEnabled(ConnectorSession session) { + if (session == null) { + return false; + } + return Boolean.parseBoolean(session.getSessionProperties().get(FORCE_JNI_SCANNER)); + } + + /** + * Remaps {@code LZ4FRAME -> LZ4BLOCK}, preserving the behavior legacy {@code HudiScanNode} inherited from its + * superclass {@code HiveScanNode.getFileCompressType} (hadoop writes {@code .lz4} as the LZ4 block codec, not + * the frame format the extension implies). The new provider does not extend the hive one, so this override + * restores the inherited legacy remap rather than relying on "hudi never produces a {@code .lz4} split". + * Only {@code LZ4FRAME} is remapped; every other codec passes through. + */ + @Override + public TFileCompressType adjustFileCompressType(TFileCompressType inferred) { + return inferred == TFileCompressType.LZ4FRAME ? TFileCompressType.LZ4BLOCK : inferred; } @Override @@ -89,7 +143,6 @@ public List planScan( Optional filter) { HudiTableHandle hudiHandle = (HudiTableHandle) handle; String basePath = hudiHandle.getBasePath(); - boolean isCow = "COPY_ON_WRITE".equals(hudiHandle.getHudiTableType()); Configuration conf = buildHadoopConf(); HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder() @@ -97,6 +150,16 @@ public List planScan( .setBasePath(basePath) .build(); + // Determine COW vs MOR from the Hudi table config (authoritative), NOT the substring-detected handle + // type: an UNKNOWN detection must not silently pick the wrong read path for a COW table (detection + // hardening). + boolean isCow = metaClient.getTableType() == HoodieTableType.COPY_ON_WRITE; + // force_jni_scanner routes even a native-eligible read to the JNI reader (legacy + // HudiScanNode.canUseNativeReader() = !isForceJniScanner() && isCowTable), so a COW table under + // force_jni takes the merged-file-slice (JNI) path too. + boolean forceJni = isForceJniScannerEnabled(session); + boolean useNativeCowPath = isCow && !forceJni; + HoodieTimeline timeline = metaClient.getCommitsAndCompactionTimeline() .filterCompletedInstants(); @@ -105,18 +168,39 @@ public List planScan( LOG.info("No completed instants on timeline for {}, returning empty splits", basePath); return Collections.emptyList(); } - String queryInstant = lastInstant.get().requestedTime(); + // FOR TIME AS OF pins an explicit instant (applySnapshot stamped it on the handle); a plain read has + // none and reads the latest completed instant (byte-identical to before this step). This single local + // drives every downstream instant use: COW/MOR getLatest*BeforeOrOn file selection AND the MOR-JNI + // THudiFileDesc.instantTime, so FE file selection and the BE merge instant stay consistent. + String queryInstant = hudiHandle.getQueryInstant() != null + ? hudiHandle.getQueryInstant() + : lastInstant.get().requestedTime(); // Resolve column names and types for JNI reader List columnNames; List columnTypes; + Schema avroSchema = null; try { TableSchemaResolver schemaResolver = new TableSchemaResolver(metaClient); - Schema avroSchema = schemaResolver.getTableAvroSchema(); - columnNames = avroSchema.getFields().stream() - .map(Schema.Field::name).collect(Collectors.toList()); - columnTypes = avroSchema.getFields().stream() - .map(f -> HudiTypeMapping.fromAvroSchema(unwrapNullable(f.schema())).getTypeName()) + // The LATEST table Avro schema (5 `_hoodie_*` meta columns included, explicit `true` = legacy + // parity): the base for the per-file schema_id resolver below, which classifies each file's OWN + // written version (independent of the query instant). + avroSchema = schemaResolver.getTableAvroSchema(true); + // HD-C5b: the JNI (MOR-realtime) reader's column list must be the schema AT the pinned instant for a + // FOR TIME AS OF read over a schema-on-read table (legacy HudiScanNode:222-224), kept in lockstep + // with the getScanNodeProperties native -1 overlay so a renamed column shows its historical name to + // the merge reader. A plain read (queryInstant == null) or a non-evolution table (no commit-metadata + // InternalSchema at the instant, D3) keeps the latest schema — byte-identical. + Schema jniSchema = HudiSchemaUtils.resolveJniColumnSchema( + schemaResolver, metaClient, avroSchema, hudiHandle.getQueryInstant()); + // JNI reader column names MUST be lower-cased: BE HadoopHudiJniScanner.initRequiredColumnsAndTypes + // keys hudiColNameToType by these names, then looks each requiredField up as an EXACT lower-case key + // (mixed-case "Id" would miss lower-case "id" -> throw, crashing every MOR/JNI split). Matches the + // Doris-column path (avroSchemaToColumns:905) and the native history_schema_info dict + // (HudiSchemaUtils.buildField:137); legacy HudiScanNode:223 also emits lower-case names. + columnNames = jniColumnNames(jniSchema); + columnTypes = jniSchema.getFields().stream() + .map(f -> HudiTypeMapping.toHiveTypeString(f.schema())) .collect(Collectors.toList()); } catch (Exception e) { LOG.warn("Failed to resolve Hudi schema for JNI reader, JNI splits may fail: {}", @@ -125,6 +209,66 @@ public List planScan( columnTypes = Collections.emptyList(); } + // The mode-aware table InternalSchema (+ evolution flag), resolved ONCE, drives the per-file + // THudiFileDesc.schema_id stamped on native slices for BE's field-id path. Resolved in its OWN try/catch + // (NOT the JNI-column block above): a schema-evolution / commit-metadata hiccup must degrade to null -> + // no schema_id -> BE BY_NAME (the safe baseline), WITHOUT discarding the already-computed JNI columnNames/ + // columnTypes (which a plain MOR read needs). schema_id is dormant/inert until the dict is emitted. + HudiSchemaUtils.ResolvedInternalSchema resolvedSchema = null; + if (avroSchema != null) { + try { + resolvedSchema = HudiSchemaUtils.resolveTableInternalSchema( + new TableSchemaResolver(metaClient), avroSchema); + } catch (Exception e) { + LOG.warn("Failed to resolve Hudi InternalSchema for schema_id; native reads fall back to BY_NAME: {}", + e.getMessage()); + } + } + + // Per-file native-reader schema_id resolver (base-file path -> version), or null to skip stamping + // (base schema unresolved -> BY_NAME). A per-file resolution failure logs and returns null for that file + // (BY_NAME) rather than failing the whole scan. Runs on this TCCL-pinned scan thread. + final HudiSchemaUtils.ResolvedInternalSchema baseSchema = resolvedSchema; + Function schemaIdResolver = baseSchema == null ? null + : filePath -> { + try { + return HudiSchemaUtils.resolveFileInternalSchema(filePath, + baseSchema.enableSchemaEvolution, baseSchema.internalSchema, metaClient).schemaId(); + } catch (Exception e) { + LOG.warn("Failed to resolve Hudi per-file schema_id for {}: {}", filePath, e.getMessage()); + return null; + } + }; + + String inputFormat = hudiHandle.getInputFormat(); + String serdeLib = hudiHandle.getSerdeLib(); + + // @incr incremental read: a non-null beginInstant is the marker (INC-1 stamped the resolved (begin, end] + // window onto the handle). Select the files the window touches via the ported IncrementalRelation family + // instead of the latest-snapshot partition scan below. NOTE: this selects FILES only — row-level + // filtering to (begin, end] is a LATER step (an FE-side synthetic _hoodie_commit_time predicate), so a + // bare @incr read would over-read until that lands (harmless while the connector is dormant). The COW-vs- + // MOR relation choice is driven by the SAME isCow the snapshot path uses (metaClient.getTableType(), + // hoodie.properties-authoritative): this SUBSUMES the legacy RO-as-RT flip (LogicalHudiScan:251-260 / + // HudiScanNode:187-199), which existed only because legacy classifies from the hive inputFormat (a MOR + // _ro facade uses HoodieParquetInputFormat, so legacy misreads it as COW and the flip re-routes it to + // MOR). metaClient.getTableType() reads MERGE_ON_READ for that facade directly, so no flip — and no serde + // params are needed on the handle. Do NOT restore the flip. + if (hudiHandle.getBeginInstant() != null) { + IncrementalRelation relation = buildIncrementalRelation(metaClient, conf, hudiHandle, isCow); + Optional> incrementalRanges = incrementalRanges(relation, isCow, forceJni, + basePath, inputFormat, serdeLib, columnNames, columnTypes, partitionFieldNames(metaClient), + this::normalizeNativeUri); + if (incrementalRanges.isPresent()) { + LOG.info("Hudi incremental scan planning: {}.{} window=({}, {}] splits={}", + hudiHandle.getDbName(), hudiHandle.getTableName(), + hudiHandle.getBeginInstant(), hudiHandle.getEndInstant(), incrementalRanges.get().size()); + return incrementalRanges.get(); + } + // relation.fallbackFullTableScan() (archived instant / missing file) → degrade to the normal + // latest-snapshot partition scan below (legacy HudiScanNode.getSplits:470), reading the latest instant. + } + // Build file system view via FileSystemViewManager (Hudi 1.0.2 API) HoodieMetadataConfig metadataConfig = HoodieMetadataConfig.newBuilder() .enable(HoodieTableMetadataUtil.isFilesPartitionAvailable(metaClient)) @@ -136,21 +280,18 @@ public List planScan( // Resolve partitions List partitionPaths = resolvePartitions(hudiHandle, metaClient); - String inputFormat = hudiHandle.getInputFormat(); - String serdeLib = hudiHandle.getSerdeLib(); - List ranges = new ArrayList<>(); for (String partitionPath : partitionPaths) { Map partValues = parsePartitionValues( partitionPath, hudiHandle.getPartitionKeyNames()); - if (isCow) { + if (useNativeCowPath) { collectCowSplits(fsView, partitionPath, queryInstant, - basePath, partValues, ranges); + basePath, partValues, ranges, schemaIdResolver); } else { collectMorSplits(fsView, partitionPath, queryInstant, basePath, inputFormat, serdeLib, - columnNames, columnTypes, partValues, ranges); + columnNames, columnTypes, partValues, forceJni, ranges, schemaIdResolver); } } @@ -181,7 +322,27 @@ public Map getScanNodeProperties( props.put("path_partition_keys", String.join(",", partKeys)); } - // Location/storage properties for native and JNI readers + // BE-facing storage for the native + JNI readers, mirroring legacy getLocationProperties' dual merge. + // (1) BE-canonical static credentials (AWS_* for object stores, resolved hadoop.*/dfs.* for HDFS): BE's + // native (FILE_S3) reader understands ONLY these canonical keys, so without them a private bucket + // 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)); + } + // (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. + // S3AFileSystem reads ONLY fs.s3a.* — never the AWS_* canonical keys (1) nor the s3. aliases (2) — so + // without this a private s3a warehouse configured with the Doris s3. aliases throws + // NoAuthWithAWSException in the JNI scanner. This is the "hadoopProperties" half of legacy + // 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)); + // (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. + // Doris aliases are harmless to BE (ignored by both readers) but kept so no configured key is dropped. for (Map.Entry entry : properties.entrySet()) { String key = entry.getKey(); if (key.startsWith("hadoop.") || key.startsWith("fs.") @@ -192,9 +353,77 @@ public Map getScanNodeProperties( } } + // Emit the native-reader schema-evolution dictionary so BE matches file<->table columns BY FIELD ID + // across rename/reorder (else a renamed column reads NULL on its old files). Skipped under force_jni: + // every split then goes JNI and never consults the dict (paimon-parity gate). Best-effort: a build + // failure logs and drops the prop -> native reads fall back to BE BY_NAME (the safe baseline), never a + // hard scan failure. populateScanLevelParams copies it onto the real params. + if (!isForceJniScannerEnabled(session)) { + try { + HoodieTableMetaClient metaClient = buildMetaClient(buildHadoopConf(), hudiHandle.getBasePath()); + TableSchemaResolver schemaResolver = new TableSchemaResolver(metaClient); + // HD-C5b: FOR TIME AS OF over a schema-on-read table -> re-resolve the native -1 overlay from the + // FULL schema AT the pinned instant. The requested column HANDLES are latest-keyed + // (getColumnHandles runs before the MVCC pin), so a column renamed after the pin is absent from + // them under its pinned name; building the overlay from them would drop that BE scan slot (BE's + // field-id reader SIGABRTs on a scan slot missing from the overlay). A plain read (no pin) or a + // non-evolution FOR TIME AS OF (latest == at-instant, D3) uses the steady-state dict keyed off the + // requested columns. NOTE: no meta column can reach the pinned path — the at-instant bound schema + // (HD-C5a, from the InternalSchema) has no `_hoodie_*` columns, so the query cannot project one. + String pin = hudiHandle.getQueryInstant(); + Optional pinnedSchema = pin == null + ? Optional.empty() + : HudiSchemaUtils.resolveInternalSchemaAtInstant(schemaResolver, metaClient, pin); + Optional dict; + if (pinnedSchema.isPresent()) { + dict = Optional.of( + HudiSchemaUtils.buildSchemaEvolutionDictAtInstant(metaClient, pinnedSchema.get())); + } else { + Schema latestAvro = schemaResolver.getTableAvroSchema(true); + dict = HudiSchemaUtils.buildSchemaEvolutionProp(metaClient, schemaResolver, latestAvro, + castHudiColumns(columns)); + } + dict.ifPresent(v -> props.put(SCHEMA_EVOLUTION_PROP, v)); + } catch (Exception e) { + LOG.warn("Failed to build Hudi schema-evolution dict for {}.{}; native reads fall back to BY_NAME: {}", + hudiHandle.getDbName(), hudiHandle.getTableName(), e.getMessage()); + } + } + return props; } + /** The requested column handles as {@link HudiColumnHandle}s (this connector's own handle type). */ + private static List castHudiColumns(List columns) { + if (columns == null) { + return Collections.emptyList(); + } + List result = new ArrayList<>(columns.size()); + for (ConnectorColumnHandle handle : columns) { + result.add((HudiColumnHandle) handle); + } + return result; + } + + @Override + public void populateScanLevelParams(TFileScanRangeParams params, Map nodeProperties) { + HudiSchemaUtils.applySchemaEvolution(params, nodeProperties.get(SCHEMA_EVOLUTION_PROP)); + } + + /** + * Normalizes a raw HMS/Hudi-SDK storage URI into BE's canonical scheme for the NATIVE reader's range 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 + * {@code LocationPath}. BE's native S3 file factory (S3URI) accepts ONLY {@code s3://}, so an un-normalized + * {@code s3a://} range path fails the native read with "Invalid S3 URI". Mirrors iceberg/paimon + * {@code normalizeUri}. Applied ONLY to the native range {@code .path()}; the JNI reader's + * {@code THudiFileDesc} base/data/delta-log paths stay raw {@code s3a://} (Hadoop {@code S3AFileSystem} + * wants the {@code s3a} scheme). A null context (offline unit tests) preserves the raw URI. + */ + private String normalizeNativeUri(String rawUri) { + return context != null ? context.normalizeStorageUri(rawUri) : rawUri; + } + /** * Collect splits for COW (Copy on Write) tables. * COW tables only have base files — use native Parquet/ORC reader. @@ -204,21 +433,27 @@ private void collectCowSplits( String partitionPath, String queryInstant, String basePath, Map partValues, - List ranges) { + List ranges, + Function schemaIdResolver) { fsView.getLatestBaseFilesBeforeOrOn(partitionPath, queryInstant) .forEach(baseFile -> { String filePath = baseFile.getPath(); long fileSize = baseFile.getFileSize(); String format = detectFileFormat(filePath); - ranges.add(new HudiScanRange.Builder() - .path(filePath) + HudiScanRange.Builder builder = new HudiScanRange.Builder() + .path(normalizeNativeUri(filePath)) .start(0) .length(fileSize) .fileSize(fileSize) .fileFormat(format) - .partitionValues(partValues) - .build()); + .partitionValues(partValues); + // COW base files always read native -> stamp the per-file schema version for BE's field-id path. + Long schemaId = schemaIdResolver == null ? null : schemaIdResolver.apply(filePath); + if (schemaId != null) { + builder.schemaId(schemaId); + } + ranges.add(builder.build()); }); } @@ -232,50 +467,163 @@ private void collectMorSplits( String partitionPath, String queryInstant, String basePath, String inputFormat, String serdeLib, List columnNames, List columnTypes, - Map partValues, - List ranges) { + Map partValues, boolean forceJni, + List ranges, + Function schemaIdResolver) { fsView.getLatestMergedFileSlicesBeforeOrOn(partitionPath, queryInstant) - .forEach(fileSlice -> { - Optional baseFileOpt = fileSlice.getBaseFile().toJavaOptional(); - String filePath = baseFileOpt.map(BaseFile::getPath).orElse(""); - long fileSize = baseFileOpt.map(BaseFile::getFileSize).orElse(0L); - - List logs = fileSlice.getLogFiles() - .map(HoodieLogFile::getPath) - .map(StoragePath::toString) - .collect(Collectors.toList()); + .forEach(fileSlice -> ranges.add(buildMorRange(fileSlice, partValues, queryInstant, + forceJni, basePath, inputFormat, serdeLib, columnNames, columnTypes, schemaIdResolver, + this::normalizeNativeUri))); + } - // Dynamic format decision: no logs → native reader - boolean useNative = logs.isEmpty() && !filePath.isEmpty(); - String format = useNative ? detectFileFormat(filePath) : "jni"; + /** + * Builds one MOR {@link HudiScanRange} from a merged {@link FileSlice}, shared by the snapshot MOR path + * ({@link #collectMorSplits}) and the {@code @incr} MOR path ({@link #incrementalRanges}). Byte-faithful port + * of legacy {@code HudiScanNode.generateHudiSplit}: a slice with no delta logs reads natively (parquet/orc) + * UNLESS {@code force_jni} keeps it on JNI, a log-only slice uses its first log as the agency path, and a JNI + * slice carries the full merge metadata. The {@code jniInstant} is the merge instant BE reads: the snapshot + * path passes its {@code queryInstant}, the incremental path passes the resolved window END + * ({@code relation.getEndTs()}). Package-private static so the mapping is unit-testable with a hand-built + * {@link FileSlice} and reused by both paths without duplication. + * + *

{@code schemaIdResolver} (base-file path -> native schema version) is applied ONLY to a native + * (no-log, non-force-jni) slice — the JNI merge reader consumes no schema_id. {@code null} skips stamping + * (the {@code @incr} path passes null: {@code @incr} lists the latest schema, no per-file dict).

+ */ + static HudiScanRange buildMorRange(FileSlice fileSlice, Map partValues, String jniInstant, + boolean forceJni, String basePath, String inputFormat, String serdeLib, + List columnNames, List columnTypes, + Function schemaIdResolver, UnaryOperator nativePathNormalizer) { + Optional baseFileOpt = fileSlice.getBaseFile().toJavaOptional(); + String filePath = baseFileOpt.map(BaseFile::getPath).orElse(""); + long fileSize = baseFileOpt.map(BaseFile::getFileSize).orElse(0L); + + List logs = fileSlice.getLogFiles() + .map(HoodieLogFile::getPath) + .map(StoragePath::toString) + .collect(Collectors.toList()); + + // Dynamic format decision: no logs → native reader, UNLESS force_jni keeps it on JNI + // (legacy HudiScanNode.setScanParams' !isForceJniScanner() guard on the no-log downgrade). + boolean useNative = logs.isEmpty() && !filePath.isEmpty() && !forceJni; + String format = useNative ? detectFileFormat(filePath) : "jni"; + + // For log-only slices, use first log as agency path + String agencyPath = filePath.isEmpty() && !logs.isEmpty() + ? logs.get(0) : filePath; + + HudiScanRange.Builder builder = new HudiScanRange.Builder() + .path(nativePathNormalizer.apply(agencyPath)) + .start(0) + .length(fileSize) + .fileSize(fileSize) + .fileFormat(format) + .partitionValues(partValues) + // Bake force_jni so populateRangeParams (no session) keeps this slice on JNI too. + .forceJni(forceJni); + + if (!useNative) { + // JNI reader needs full metadata + builder.instantTime(jniInstant) + .serde(serdeLib) + .inputFormat(inputFormat) + .basePath(basePath) + .dataFilePath(filePath) + .dataFileLength(fileSize) + .deltaLogs(logs) + .columnNames(columnNames) + .columnTypes(columnTypes); + } else if (schemaIdResolver != null) { + // Native no-log slice reads via the field-id native reader -> stamp its base file's schema version. + Long schemaId = schemaIdResolver.apply(filePath); + if (schemaId != null) { + builder.schemaId(schemaId); + } + } - // For log-only slices, use first log as agency path - String agencyPath = filePath.isEmpty() && !logs.isEmpty() - ? logs.get(0) : filePath; + return builder.build(); + } - HudiScanRange.Builder builder = new HudiScanRange.Builder() - .path(agencyPath) - .start(0) - .length(fileSize) - .fileSize(fileSize) - .fileFormat(format) - .partitionValues(partValues); + /** + * Builds the ported {@link IncrementalRelation} for a resolved {@code @incr} window: a COW relation when + * {@code isCow} (metaClient-authoritative), else a MOR relation. The relation consumes the ALREADY-RESOLVED + * {@code begin/endInstant} from the handle (INC-1) and the raw {@code @incr} option params (glob / fallback / + * hollow-commit policy) threaded onto the handle, and does file selection only. Runs inline in {@code + * planScan}, reusing its metaClient + Hadoop conf, so the relation's filesystem/timeline I/O inherits the + * scan thread's plugin classloader pin (the same context the snapshot path's metaClient I/O runs in). The + * ctor's {@link IOException} is re-typed to {@link DorisConnectorException} (parity with {@link + * #resolvePartitions}). + */ + private static IncrementalRelation buildIncrementalRelation(HoodieTableMetaClient metaClient, + Configuration conf, HudiTableHandle handle, boolean isCow) { + Map optParams = handle.getIncrementalParams(); + HollowCommitHandling policy = IncrementalRelation.hollowCommitHandling(optParams); + try { + return isCow + ? new COWIncrementalRelation(metaClient, conf, handle.getBeginInstant(), + handle.getEndInstant(), policy, optParams) + : new MORIncrementalRelation(metaClient, conf, handle.getBeginInstant(), + handle.getEndInstant(), policy, optParams); + } catch (IOException e) { + throw new DorisConnectorException( + "Failed to build incremental relation for " + handle.getBasePath(), e); + } + } - if (!useNative) { - // JNI reader needs full metadata - builder.instantTime(queryInstant) - .serde(serdeLib) - .inputFormat(inputFormat) - .basePath(basePath) - .dataFilePath(filePath) - .dataFileLength(fileSize) - .deltaLogs(logs) - .columnNames(columnNames) - .columnTypes(columnTypes); - } + /** + * The incremental split set for a resolved {@code @incr} window, or {@link Optional#empty()} to signal the + * caller must DEGRADE to the normal latest-snapshot scan. Byte-parity with legacy {@code + * HudiScanNode.getSplits:470} + {@code getIncrementalSplits}, but routing on the relation TYPE + * ({@code isCow}) rather than legacy's {@code canUseNativeReader()}: + *
    + *
  • {@code relation.fallbackFullTableScan()} (an archived instant / missing file) → + * {@link Optional#empty()} = degrade to the latest-snapshot scan (NOT an error), legacy {@code :470}.
  • + *
  • COW → {@link IncrementalRelation#collectSplits()} yields native ranges directly. + * {@code force_jni} is intentionally IGNORED for a COW incremental read (it always reads native) + * — a signed, deliberate deviation from legacy, which routes {@code force_jni}+COW to the MOR-style + * branch and calls {@code collectFileSlices()} on a COW relation → {@code UnsupportedOperationException} + * (a latent legacy crash). Routing on the relation type never calls the unsupported shape.
  • + *
  • MOR → {@link IncrementalRelation#collectFileSlices()} (a FLAT cross-partition slice list) turned + * into JNI ranges at the resolved window END ({@code relation.getEndTs()}), with per-slice partition + * values parsed from the slice's own partition path against the Hudi table-config partition fields + * (the same non-handle source the COW relation uses). {@code force_jni} still keeps a no-log MOR slice + * on JNI via {@link #buildMorRange}.
  • + *
+ * Package-private static, pure over the {@link IncrementalRelation} contract, so file-selection routing + + * the degrade decision are unit-testable with a fake relation (no live metaClient). + */ + static Optional> incrementalRanges(IncrementalRelation relation, boolean isCow, + boolean forceJni, String basePath, String inputFormat, String serdeLib, + List columnNames, List columnTypes, List partitionFieldNames, + UnaryOperator nativePathNormalizer) { + if (relation.fallbackFullTableScan()) { + return Optional.empty(); + } + List ranges = new ArrayList<>(); + if (isCow) { + // COW @incr yields native ranges directly; normalize their scheme (s3a->s3) for BE's native reader + // (COWIncrementalRelation.collectSplits builds .path() from the raw HMS base path). + ranges.addAll(relation.collectSplits(nativePathNormalizer)); + return Optional.of(ranges); + } + String endTs = relation.getEndTs(); + for (FileSlice fileSlice : relation.collectFileSlices()) { + Map partValues = parsePartitionValues(fileSlice.getPartitionPath(), partitionFieldNames); + // @incr lists the LATEST schema (no per-file schema_id dict on the incremental path) -> null resolver. + ranges.add(buildMorRange(fileSlice, partValues, endTs, forceJni, + basePath, inputFormat, serdeLib, columnNames, columnTypes, null, nativePathNormalizer)); + } + return Optional.of(ranges); + } - ranges.add(builder.build()); - }); + /** + * The Hudi table-config partition-field names (byte-faithful to legacy {@code HudiScanNode:391-393}), the + * source the incremental MOR path parses per-slice partition values against — NOT the HMS-sourced + * handle partition keys the snapshot path uses (the two coincide only for hive-synced tables). + */ + private static List partitionFieldNames(HoodieTableMetaClient metaClient) { + Option fields = metaClient.getTableConfig().getPartitionFields(); + return fields.isPresent() ? Arrays.asList(fields.get()) : Collections.emptyList(); } /** @@ -297,14 +645,7 @@ private List resolvePartitions( } try { - HoodieMetadataConfig metadataConfig = HoodieMetadataConfig.newBuilder() - .enable(HoodieTableMetadataUtil.isFilesPartitionAvailable(metaClient)) - .build(); - HoodieLocalEngineContext engineCtx = new HoodieLocalEngineContext(metaClient.getStorageConf()); - HoodieTableMetadata tableMetadata = HoodieTableMetadata.create( - engineCtx, metaClient.getStorage(), metadataConfig, - metaClient.getBasePath().toString(), true); - return tableMetadata.getAllPartitionPaths(); + return listAllPartitionPaths(metaClient); } catch (Exception e) { throw new DorisConnectorException( "Failed to list partitions for " + handle.getBasePath(), e); @@ -312,29 +653,250 @@ private List resolvePartitions( } /** - * Parse partition path "year=2024/month=01" into column→value map. + * Builds a {@link HoodieTableMetaClient} from a Hadoop {@link Configuration} and base path. Package-private + * static so the metadata path ({@link HudiConnectorMetadata}) builds the metaClient the same way the scan + * does, from inside the plugin-auth + TCCL pin its execute-wrapper supplies. + */ + static HoodieTableMetaClient buildMetaClient(Configuration conf, String basePath) { + return HoodieTableMetaClient.builder() + .setConf(new org.apache.hudi.storage.hadoop.HadoopStorageConfiguration(conf)) + .setBasePath(basePath) + .build(); + } + + /** + * Returns the LATEST completed instant as its raw {@code requestedTime} String (e.g. + * {@code yyyyMMddHHmmssSSS}, compared lexicographically), or {@code Optional.empty()} when the timeline has + * no completed instants. Reads the same {@code getCommitsAndCompactionTimeline().filterCompletedInstants()} + * as {@link #latestCompletedInstant} / {@link #planScan}. + * + *

This ONE shared helper is byte-parity with legacy COW/MOR incremental {@code latestTime} for BOTH table + * types, because {@code metaClient.getCommitsAndCompactionTimeline()} resolves per table type to exactly the + * timeline legacy uses per type (verified against hudi-common 1.0.2 bytecode): + *

    + *
  • COW → {@code getActiveTimeline().getCommitAndReplaceTimeline()} = {@code {commit, replacecommit, + * clustering}} — identical to what legacy COW's {@code metaClient.getCommitTimeline()} returns + * (that metaClient method ALSO delegates to {@code getCommitAndReplaceTimeline()}, so it is NOT + * commit-only; it includes the replacecommit/clustering instants COW produces via INSERT OVERWRITE / + * clustering).
  • + *
  • MOR → {@code getActiveTimeline().getWriteTimeline()} — identical to legacy MOR's + * {@code metaClient.getCommitsAndCompactionTimeline()}.
  • + *
+ * Both legacy and this helper take {@code lastInstant().requestedTime()} under the default hollow-commit + * policy; the {@code USE_TRANSITION_TIME} completion-time variant is served by the overload below. + */ + static Optional latestCompletedInstantTime(HoodieTableMetaClient metaClient) { + return latestCompletedInstantTime(metaClient, false); + } + + /** + * The LATEST completed instant on the requested-time axis (default) or the completion-time axis when + * {@code useCompletionTime} is true. The completion-time axis is legacy's {@code USE_TRANSITION_TIME} + * hollow-commit policy path: legacy COW/MOR derive the default / {@code "latest"} end via + * {@code lastInstant().getCompletionTime()} rather than {@code requestedTime()} under that policy + * ({@code COWIncrementalRelation:94-96} / {@code MORIncrementalRelation:88-90}), and both their file + * selection ({@code findInstantsInRangeByCompletionTime}) and the row filter consume that completion-time + * end. {@link HudiConnectorMetadata#resolveTimeTravel} resolves the end on the SAME axis so the ONE + * handle-stamped end is correct for both — the connector never lets the file set and the row filter diverge + * on the window. Completion time {@code >=} requested time for any instant, so a requested-time end fed into + * completion-time selection would silently drop the final in-window commit (under-read). + */ + static Optional latestCompletedInstantTime(HoodieTableMetaClient metaClient, boolean useCompletionTime) { + return metaClient.getCommitsAndCompactionTimeline() + .filterCompletedInstants().lastInstant().toJavaOptional() + .map(instant -> useCompletionTime ? instant.getCompletionTime() : instant.requestedTime()); + } + + /** + * Returns the LATEST completed instant as a numeric long ({@code yyyyMMddHHmmssSSS}), or {@code 0L} when + * the timeline has none. Byte-faithful port of legacy {@code HudiUtils.getLastTimeStamp} and the same + * timeline {@link #planScan} reads at query time — so the MVCC pin and the scan take the identical instant. + */ + static long latestCompletedInstant(HoodieTableMetaClient metaClient) { + return requestedTimeToInstant(latestCompletedInstantTime(metaClient)); + } + + /** + * Pure numeric mapping backing {@link #latestCompletedInstant}: a present {@code requestedTime} parses to a + * long ({@code Long.parseLong}, fail-loud on malformed = legacy parity); absent → {@code 0L} (legacy + * empty-timeline sentinel, {@code >= 0} so it survives the dictionary-refresh filter). Extracted so the + * empty/value semantics are unit-testable without a live metaClient. + */ + static long requestedTimeToInstant(Optional requestedTime) { + return requestedTime.map(Long::parseLong).orElse(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 + * the metadata partition-listing path share one copy of the {@code HoodieTableMetadata.create(...)} dance. */ - private Map parsePartitionValues( + static List listAllPartitionPaths(HoodieTableMetaClient metaClient) throws Exception { + HoodieMetadataConfig metadataConfig = HoodieMetadataConfig.newBuilder() + .enable(HoodieTableMetadataUtil.isFilesPartitionAvailable(metaClient)) + .build(); + HoodieLocalEngineContext engineCtx = new HoodieLocalEngineContext(metaClient.getStorageConf()); + HoodieTableMetadata tableMetadata = HoodieTableMetadata.create( + engineCtx, metaClient.getStorage(), metadataConfig, + metaClient.getBasePath().toString(), true); + return tableMetadata.getAllPartitionPaths(); + } + + /** + * Parse a Hudi partition's relative path into a column→value map, byte-faithful to legacy + * {@code HudiPartitionUtils.parsePartitionValues}. Handles BOTH hive-style ("year=2024/month=01") and Hudi's + * DEFAULT non-hive-style POSITIONAL ("2024/01") layouts, and URL-unescapes every value: + *
    + *
  • A fragment carrying the "col=" prefix contributes the suffix; a fragment WITHOUT it is mapped + * POSITIONALLY to the i-th partition column. The old split-on-'=' logic silently DROPPED a + * prefix-less fragment, so a non-hive-style partitioned table read NULL partition columns on a plain + * snapshot read — this is the regression this fix closes.
  • + *
  • Single partition column with a mismatched fragment count: the whole path (minus an optional "col=" + * prefix) is that column's value (legacy single-column-whole-path fallback).
  • + *
  • Fragment count != column count with > 1 column: fail loud, exactly like legacy.
  • + *
  • Every value is unescaped via {@link #unescapePathName} (e.g. "%20" → space) — legacy delegated + * to Hive's {@code FileUtils.unescapePathName}; inlined here so the connector needs no hive-common + * dependency (mirrors the fe-connector-hive inlined copy).
  • + *
+ * + *

Static + package-private for direct unit testing (no live HoodieTableMetaClient needed). + * + *

NOTE: this derives values from the partition path fed to the FileSystemView. On the UNPRUNED path that + * path is Hudi's own relative path (getAllPartitionPaths) = the shape the FileSystemView uses, so values are + * consistent. The PRUNED path (applyFilter) currently feeds HMS hive-style partition NAMES, which match the + * FileSystemView only for hive-sync'd tables; making the pruned partition SOURCE useHiveSyncPartition-aware + * for non-hive-style tables belongs to the partition-listing step (which ports that source once), and is + * likewise closed before the catalog flip. + */ + static Map parsePartitionValues( String partitionPath, List partKeyNames) { - if (partitionPath == null || partitionPath.isEmpty() - || partKeyNames == null || partKeyNames.isEmpty()) { + if (partKeyNames == null || partKeyNames.isEmpty()) { + // Non-partitioned table (legacy returns an empty value list). The unpartitioned scan path always + // reaches here with an empty key list, so an empty partitionPath needs no separate guard. return Collections.emptyMap(); } Map values = new LinkedHashMap<>(); - String[] parts = partitionPath.split("/"); - for (String part : parts) { - int eq = part.indexOf('='); - if (eq > 0) { - values.put(part.substring(0, eq), part.substring(eq + 1)); + String[] fragments = partitionPath.split("/"); + if (fragments.length != partKeyNames.size()) { + if (partKeyNames.size() == 1) { + String prefix = partKeyNames.get(0) + "="; + String value = partitionPath.startsWith(prefix) + ? partitionPath.substring(prefix.length()) : partitionPath; + values.put(partKeyNames.get(0), unescapePathName(value)); + return values; } + throw new DorisConnectorException( + "Failed to parse partition values of path: " + partitionPath); + } + for (int i = 0; i < fragments.length; i++) { + String prefix = partKeyNames.get(i) + "="; + String raw = fragments[i].startsWith(prefix) + ? fragments[i].substring(prefix.length()) : fragments[i]; + values.put(partKeyNames.get(i), unescapePathName(raw)); } return values; } /** - * Detect file format from file path suffix. + * Renders a Hive-style partition name ({@code "col0=val0/col1=val1/..."}) from a column→value map, in + * partition-key order, ESCAPING each value with {@link #escapePathName} (the canonical Hive {@code + * makePartName}). + * + *

MANDATORY for the generic MVCC model: fe-core rebuilds the partition item by re-parsing this + * name via {@code HiveUtil.toPartitionValues} under a {@code checkState(values.size()==types.size())}. A raw + * positional path ({@code "2024/01"}) would yield the wrong value count → the partition is skipped + * → silent UNPARTITIONED degrade, so a hive-style name is required. Escaping is MANDATORY too: + * {@code HiveUtil.toPartitionValues} splits on {@code '/'}, so a value that itself spans {@code '/'} (e.g. a + * single partition column with a {@code yyyy/MM/dd} output format → path {@code "2024/01/02"}) must be + * escaped ({@code "%2F"}) or the re-parse would truncate/collide it. Since {@code escapePathName} is the + * exact inverse of {@link #escapePathName}'s unescape (the same set {@code HiveUtil.toPartitionValues} uses), + * the re-parse recovers EXACTLY the values {@link #parsePartitionValues} produced. Static + package-private + * for direct unit testing. + */ + static String renderHiveStylePartitionName(List partKeyNames, Map values) { + StringBuilder sb = new StringBuilder(); + for (String col : partKeyNames) { + if (sb.length() > 0) { + sb.append('/'); + } + sb.append(col).append('=').append(escapePathName(values.get(col))); + } + return sb.toString(); + } + + // Hive FileUtils.charToEscape minus the control range: escaped so a partition VALUE containing one of these + // survives the round-trip through HiveUtil.toPartitionValues (which url-unescapes). '/' and '=' are the + // load-bearing ones (structural to the re-parse); the rest mirror Hive for name faithfulness. + private static final String CHARS_TO_ESCAPE = "\"#%'*/:=?\\{[]^"; + + /** + * URL-encodes a partition value into a Hive-escaped path component (e.g. {@code "a/b"} → {@code + * "a%2Fb"}). Byte-faithful port of Hive's {@code org.apache.hadoop.hive.common.FileUtils.escapePathName} and + * the exact inverse of {@link #unescapePathName}, so a rendered hive-style name re-parses (unescapes) back + * to the original value. Inlined so the connector needs no hive-common dependency. + */ + private static String escapePathName(String value) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c < 0x20 || c == 0x7F || CHARS_TO_ESCAPE.indexOf(c) >= 0) { + sb.append('%').append(String.format("%02X", (int) c)); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + /** + * URL-decodes a Hive-escaped path component (e.g. "a%2Fb" → "a/b"). Byte-faithful port of Hive's + * {@code org.apache.hadoop.hive.common.FileUtils.unescapePathName} (identical to the fe-connector-hive + * inlined copy in {@code HiveWriteUtils}), so the connector needs no hive-common dependency. + */ + static String unescapePathName(String path) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + if (c == '%' && i + 2 < path.length()) { + int code = -1; + try { + code = Integer.parseInt(path.substring(i + 1, i + 3), 16); + } catch (Exception e) { + code = -1; + } + if (code >= 0) { + sb.append((char) code); + i += 2; + continue; + } + } + sb.append(c); + } + return sb.toString(); + } + + /** + * The JNI (MOR-realtime) reader's required-column names, LOWER-CASED. BE + * {@code HadoopHudiJniScanner.initRequiredColumnsAndTypes} builds {@code hudiColNameToType} keyed by these + * names and resolves each requiredField with an EXACT lower-case {@code containsKey}, so a mixed-case Avro + * name ({@code "Id"}) would miss the lower-case slot ({@code "id"}) and throw, crashing every MOR/JNI split. + * Byte-consistent with the Doris-column casing ({@code HudiConnectorMetadata.avroSchemaToColumns} / + * {@code HudiSchemaUtils.buildField}) and legacy {@code HudiScanNode} (which emits lower-case Column names). + * Column ORDER is preserved so the parallel {@code columnTypes} list stays positionally aligned. Package- + * private static for offline unit testing (planScan itself needs a live metaClient). */ - private static String detectFileFormat(String filePath) { + static List jniColumnNames(Schema jniSchema) { + return jniSchema.getFields().stream() + .map(f -> f.name().toLowerCase(Locale.ROOT)) + .collect(Collectors.toList()); + } + + /** + * Detect file format from file path suffix. Package-private static so the ported incremental relations + * ({@code COWIncrementalRelation}) stamp the required explicit {@code fileFormat} on their native + * {@link HudiScanRange}s the same way the snapshot COW path does. + */ + static String detectFileFormat(String filePath) { if (filePath == null || filePath.isEmpty()) { return "parquet"; } @@ -347,19 +909,13 @@ private static String detectFileFormat(String filePath) { return "parquet"; } - private static Schema unwrapNullable(Schema schema) { - if (schema.getType() == Schema.Type.UNION) { - for (Schema s : schema.getTypes()) { - if (s.getType() != Schema.Type.NULL) { - return s; - } - } - } - return schema; - } - private Configuration buildHadoopConf() { Configuration conf = new Configuration(); + // Overlay the catalog's bound fe-filesystem storage config (s3.access_key -> fs.s3a.access.key, etc.) + // FIRST so an inline user fs./dfs./hadoop. key still wins in the loop below. Without this the FE-side + // HoodieTableMetaClient's S3AFileSystem gets no object-store credentials and cannot read + // .hoodie/hoodie.properties (NoAuthWithAWSException). Mirrors iceberg/paimon buildStorageHadoopConfig. + storageHadoopConfig(context).forEach(conf::set); for (Map.Entry entry : properties.entrySet()) { String key = entry.getKey(); if (key.startsWith("hadoop.") || key.startsWith("fs.") @@ -369,4 +925,20 @@ private Configuration buildHadoopConf() { } return conf; } + + /** + * The canonical object-store/HDFS Hadoop config (fs.s3a.* / fs.oss.* / hadoop.* …) translated from the + * catalog's bound fe-filesystem {@code StorageProperties}. This is the ONLY source of the S3/OSS credentials + * the FE-side {@link HoodieTableMetaClient} needs — the raw catalog aliases (s3.access_key, …) are NOT Hadoop + * keys and S3AFileSystem never reads them. Empty for a null context (offline unit tests) or a catalog with no + * typed storage. Mirrors {@code IcebergConnector.buildStorageHadoopConfig} / {@code PaimonConnector}. + */ + static Map storageHadoopConfig(ConnectorContext context) { + Map merged = new HashMap<>(); + if (context != null) { + context.getStorageProperties().forEach(sp -> + sp.toHadoopProperties().ifPresent(h -> merged.putAll(h.toHadoopConfigurationMap()))); + } + return merged; + } } 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 3e2526a261adc4..c18c2d866ae5ad 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 @@ -26,7 +26,6 @@ import org.apache.doris.thrift.TTableFormatFileDesc; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -56,6 +55,21 @@ public class HudiScanRange implements ConnectorScanRange { private final String fileFormat; private final Map partitionValues; private final Map properties; + // JNI reader list fields. Kept as typed lists (NOT joined into the + // properties map) because Hive type strings contain commas + // (e.g. decimal(10,2), struct): a comma join+split + // round-trip would shatter them and misalign column_names/column_types. + // BE (hudi_jni_reader.cpp) joins these lists itself with the correct + // delimiters (names ',', types '#', delta logs ','). + private final List deltaLogs; + private final List columnNames; + private final List columnTypes; + // When true (force_jni_scanner), the JNI escape hatch is engaged for this split: the no-delta-log native + // downgrade in populateRangeParams is suppressed so a native-eligible slice still reads via the JNI reader + // (dodging native-reader bugs). Baked in at plan time by HudiScanPlanProvider from the session flag, so + // populateRangeParams (which has no session) stays CONSISTENT with planScan's native/JNI branch. Legacy + // parity: HudiScanNode.setScanParams guards the same downgrade with !sessionVariable.isForceJniScanner(). + private final boolean forceJni; private HudiScanRange(Builder builder) { this.path = builder.path; @@ -85,16 +99,24 @@ private HudiScanRange(Builder builder) { props.put("hudi.data_file_path", builder.dataFilePath); } props.put("hudi.data_file_length", String.valueOf(builder.dataFileLength)); - if (builder.deltaLogs != null && !builder.deltaLogs.isEmpty()) { - props.put("hudi.delta_logs", String.join(",", builder.deltaLogs)); - } - if (builder.columnNames != null && !builder.columnNames.isEmpty()) { - props.put("hudi.column_names", String.join(",", builder.columnNames)); - } - if (builder.columnTypes != null && !builder.columnTypes.isEmpty()) { - props.put("hudi.column_types", String.join(",", builder.columnTypes)); + // Per-split native-reader schema version (mirror paimon.schema_id). Only carried when the provider + // resolved one for a native slice; populateRangeParams stamps THudiFileDesc.schema_id (field 12) from it + // ONLY on the native branch (never JNI). Absent -> BE BY_NAME. + if (builder.schemaId != null) { + props.put("hudi.schema_id", String.valueOf(builder.schemaId)); } this.properties = Collections.unmodifiableMap(props); + + this.deltaLogs = builder.deltaLogs != null + ? Collections.unmodifiableList(new ArrayList<>(builder.deltaLogs)) + : Collections.emptyList(); + this.columnNames = builder.columnNames != null + ? Collections.unmodifiableList(new ArrayList<>(builder.columnNames)) + : Collections.emptyList(); + this.columnTypes = builder.columnTypes != null + ? Collections.unmodifiableList(new ArrayList<>(builder.columnTypes)) + : Collections.emptyList(); + this.forceJni = builder.forceJni; } @Override @@ -156,26 +178,25 @@ public void populateRangeParams(TTableFormatFileDesc formatDesc, boolean isJni = "jni".equalsIgnoreCase(getFileFormat()); - // Dynamic format downgrade: if JNI but no delta logs, use native reader - if (isJni) { - String deltaLogs = props.get("hudi.delta_logs"); - if (deltaLogs == null || deltaLogs.isEmpty()) { - String dataFilePath = props.getOrDefault( - "hudi.data_file_path", ""); - if (!dataFilePath.isEmpty()) { - String lower = dataFilePath.toLowerCase(); - if (lower.endsWith(".parquet")) { - rangeDesc.setFormatType(TFileFormatType.FORMAT_PARQUET); - isJni = false; - } else if (lower.endsWith(".orc")) { - rangeDesc.setFormatType(TFileFormatType.FORMAT_ORC); - isJni = false; - } - } + // A JNI-format split with no delta logs (a read-optimized / log-less slice) reads natively — UNLESS + // force_jni is engaged (legacy HudiScanNode.setScanParams' !isForceJniScanner() guard). In practice + // collectMorSplits/collectCowSplits already stamp the native format directly, so this only resolves a + // defensively-built "jni"+no-log range. + if (isJni && deltaLogs.isEmpty() && !forceJni) { + String dataFilePath = props.getOrDefault("hudi.data_file_path", ""); + String lower = dataFilePath.toLowerCase(); + if (lower.endsWith(".parquet") || lower.endsWith(".orc")) { + isJni = false; } } + // Set the per-range format EXPLICITLY (mirroring PaimonScanRange): the node-level file_format_type is a + // SINGLE default per table and cannot be correct for every slice — a MOR table mixes native no-log + // slices with JNI log slices, a COW ORC table's node default is parquet, and force_jni keeps a COW slice + // on JNI. Relying on that default silently delivered the wrong reader to BE (an empty THudiFileDesc under + // FORMAT_JNI for a native no-log slice, or the native reader for a force_jni / ORC slice). if (isJni) { + rangeDesc.setFormatType(TFileFormatType.FORMAT_JNI); fileDesc.setInstantTime( props.getOrDefault("hudi.instant_time", "")); fileDesc.setSerde(props.getOrDefault("hudi.serde", "")); @@ -188,20 +209,27 @@ public void populateRangeParams(TTableFormatFileDesc formatDesc, fileDesc.setDataFileLength(Long.parseLong( props.getOrDefault("hudi.data_file_length", "0"))); - String deltaLogs = props.get("hudi.delta_logs"); - if (deltaLogs != null && !deltaLogs.isEmpty()) { - fileDesc.setDeltaLogs( - Arrays.asList(deltaLogs.split(","))); + // Set typed lists directly. BE (hudi_jni_reader.cpp) joins them with + // the correct delimiters: column_names ',', column_types '#', delta + // logs ','. Joining/splitting here would shatter comma-bearing Hive + // type strings (decimal(10,2), struct<...>). + if (!deltaLogs.isEmpty()) { + fileDesc.setDeltaLogs(deltaLogs); } - String colNames = props.get("hudi.column_names"); - if (colNames != null && !colNames.isEmpty()) { - fileDesc.setColumnNames( - Arrays.asList(colNames.split(","))); + if (!columnNames.isEmpty()) { + fileDesc.setColumnNames(columnNames); } - String colTypes = props.get("hudi.column_types"); - if (colTypes != null && !colTypes.isEmpty()) { - fileDesc.setColumnTypes( - Arrays.asList(colTypes.split(","))); + if (!columnTypes.isEmpty()) { + fileDesc.setColumnTypes(columnTypes); + } + } else { + rangeDesc.setFormatType(nativeFormatType(props)); + // Native field-id path only (paimon parity): the per-split schema version the native reader matches + // the base file's columns against. The JNI reader consumes no schema_id (it reads column_names/types + // @instant), so this is NEVER set on the JNI branch. Absent -> BE BY_NAME (no evolution). + String schemaId = props.get("hudi.schema_id"); + if (schemaId != null) { + fileDesc.setSchemaId(Long.parseLong(schemaId)); } } @@ -224,6 +252,24 @@ public void populateRangeParams(TTableFormatFileDesc formatDesc, } } + /** + * The BE native reader format for a non-JNI slice: from the range's own file format when it is already + * native (collectCowSplits / a no-log MOR slice stamp "parquet"/"orc" directly), else — for a "jni" range + * downgraded above — from the base file suffix. Defaults to parquet (matching {@code detectFileFormat}). + */ + private TFileFormatType nativeFormatType(Map props) { + String fmt = getFileFormat(); + if ("orc".equalsIgnoreCase(fmt)) { + return TFileFormatType.FORMAT_ORC; + } + if ("parquet".equalsIgnoreCase(fmt)) { + return TFileFormatType.FORMAT_PARQUET; + } + String dataFilePath = props.getOrDefault("hudi.data_file_path", ""); + return dataFilePath.toLowerCase().endsWith(".orc") + ? TFileFormatType.FORMAT_ORC : TFileFormatType.FORMAT_PARQUET; + } + /** Builder for constructing HudiScanRange instances. */ public static class Builder { private String path; @@ -243,6 +289,9 @@ public static class Builder { private List deltaLogs; private List columnNames; private List columnTypes; + private boolean forceJni; + // Native-reader per-split schema version (nullable = not stamped; JNI slices never carry one). + private Long schemaId; public Builder path(String path) { this.path = path; @@ -319,6 +368,16 @@ public Builder columnTypes(List columnTypes) { return this; } + public Builder forceJni(boolean forceJni) { + this.forceJni = forceJni; + return this; + } + + public Builder schemaId(long schemaId) { + this.schemaId = schemaId; + return this; + } + public HudiScanRange build() { return new HudiScanRange(this); } diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiSchemaUtils.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiSchemaUtils.java new file mode 100644 index 00000000000000..a1665d154fbc70 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiSchemaUtils.java @@ -0,0 +1,461 @@ +// 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.TColumnType; +import org.apache.doris.thrift.TFileScanRangeParams; +import org.apache.doris.thrift.TPrimitiveType; +import org.apache.doris.thrift.schema.external.TArrayField; +import org.apache.doris.thrift.schema.external.TField; +import org.apache.doris.thrift.schema.external.TFieldPtr; +import org.apache.doris.thrift.schema.external.TMapField; +import org.apache.doris.thrift.schema.external.TNestedField; +import org.apache.doris.thrift.schema.external.TSchema; +import org.apache.doris.thrift.schema.external.TStructField; + +import org.apache.avro.Schema; +import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.TableSchemaResolver; +import org.apache.hudi.common.util.InternalSchemaCache; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.internal.schema.InternalSchema; +import org.apache.hudi.internal.schema.Types; +import org.apache.hudi.internal.schema.convert.AvroInternalSchemaConverter; +import org.apache.hudi.internal.schema.io.FileBasedInternalSchemaStorageManager; +import org.apache.hudi.internal.schema.utils.SerDeHelper; +import org.apache.thrift.TDeserializer; +import org.apache.thrift.TSerializer; +import org.apache.thrift.protocol.TBinaryProtocol; + +import java.io.File; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * Builds the native-reader schema dictionary ({@code current_schema_id} + {@code history_schema_info}) so BE + * matches file↔table columns BY FIELD ID across schema evolution (rename/reorder), instead of falling back + * to NAME matching (which silently reads NULL for a renamed column on its old files). Self-contained + * hudi→thrift port of legacy {@code HudiUtils.getSchemaInfo(InternalSchema)} + {@code getSchemaInfo( + * List<Types.Field>)} + {@code getSchemaInfo(Types.Field)} (zero fe-core import). + * + *

Template = paimon, lowercase = iceberg. Like paimon ({@code by_table_field_id}) hudi supplies the + * per-file schema to BE, so the dictionary needs a per-committed-version history ({@code -1} target entry + one + * entry per referenced split {@code schema_id}) — unlike iceberg's single {@code -1} entry. Field ids come from + * hudi's {@link InternalSchema} (stable across renames). This class is only the pure {@code InternalSchema -> + * thrift} converter + the base64 transport round-trip; the dictionary orchestration (the {@code -1} entry from + * the requested columns, the per-referenced-version entries, the per-split {@code schema_id}) lands in later + * steps that reuse {@link #buildSchemaInfo} + {@link #encode}.

+ * + *

Two deliberate deviations from legacy ({@code HudiUtils.getSchemaInfo}): + *

    + *
  • Lowercase EVERY nesting level ({@code Locale.ROOT}, mirroring + * {@code IcebergSchemaUtils.buildField}) — legacy lowercased nothing. The same {@code history_schema_info} + * thrift is also consumed by the v1 {@code format/table} hudi reader, whose {@code StructNode} is keyed by + * the RAW history {@code TField.name} then looked up by the LOWERCASED Doris slot name; a mixed-case + * nested field name there throws {@code std::out_of_range} and SIGABRTs the whole BE (mirror of the iceberg + * {@code DROP_AND_ADD} fix).
  • + *
  • Scalar {@code TColumnType} = {@code STRING} placeholder — legacy emitted the full Doris type + * ({@code fromAvroHudiTypeToDorisType(...).toColumnTypeThrift()}); BE uses {@code type.type} only as a + * nested-vs-scalar discriminator on the field-id path, so a single placeholder suffices and avoids porting + * the avro→Doris type map.
  • + *
+ * {@code id} + {@code name} + {@code is_optional} are carried at EVERY nesting level (legacy parity — a missing + * {@code id} at any level makes BE's field-id matcher silently drop the column, worse than {@code BY_NAME}).

+ */ +public final class HudiSchemaUtils { + + // Legacy parity: current_schema_id is the -1 sentinel ("latest"/target); the current/target schema is also + // pushed into history_schema_info under this id. BE's find_external_root_field selects the -1 entry as the + // table-side overlay; a real id equal to any per-split id would drive the banned v1 case-sensitive path. + static final long CURRENT_SCHEMA_ID = -1L; + + private static final Base64.Encoder BASE64_ENCODER = Base64.getEncoder(); + + private HudiSchemaUtils() { + } + + /** + * Build one {@link TSchema} from a hudi {@link InternalSchema}, keyed by the schema's own committed id + * (a per-version history entry). Port of legacy {@code HudiUtils.getSchemaInfo(InternalSchema)}. + */ + static TSchema buildSchemaInfo(InternalSchema internalSchema) { + return buildSchemaInfo(internalSchema.schemaId(), internalSchema.getRecord().fields()); + } + + /** + * Build one {@link TSchema} from an explicit schema id + top-level fields. The later {@code -1} target entry + * (built from the requested columns) reuses this with {@link #CURRENT_SCHEMA_ID}. + */ + static TSchema buildSchemaInfo(long schemaId, List fields) { + TSchema tSchema = new TSchema(); + tSchema.setSchemaId(schemaId); + tSchema.setRootField(buildStructField(fields)); + return tSchema; + } + + private static TStructField buildStructField(List fields) { + TStructField structField = new TStructField(); + for (Types.Field field : fields) { + structField.addToFields(fieldPtr(buildField(field))); + } + return structField; + } + + /** + * Recursively convert a hudi {@link Types.Field} to a {@link TField}, carrying {@code id} / {@code name} + * (lowercased with {@code Locale.ROOT} at every level) / {@code is_optional}, a nested-vs-scalar + * {@code type.type} tag ({@code STRING} placeholder for scalars), and the nested {@code array}/{@code map}/ + * {@code struct} structure. Port of legacy {@code HudiUtils.getSchemaInfo(Types.Field)}. + */ + static TField buildField(Types.Field field) { + TField tField = new TField(); + // Lowercase every level (Locale.ROOT): BE's table-side StructNode is looked up by the lowercase Doris + // slot name; a mixed-case nested name would SIGABRT the v1 reader (see class javadoc). + tField.setName(field.name().toLowerCase(Locale.ROOT)); + tField.setId(field.fieldId()); + tField.setIsOptional(field.isOptional()); + + TColumnType columnType = new TColumnType(); + TNestedField nestedField = new TNestedField(); + switch (field.type().typeId()) { + case ARRAY: { + columnType.setType(TPrimitiveType.ARRAY); + Types.ArrayType arrayType = (Types.ArrayType) field.type(); + TArrayField arrayField = new TArrayField(); + arrayField.setItemField(fieldPtr(buildField(arrayType.fields().get(0)))); + nestedField.setArrayField(arrayField); + tField.setNestedField(nestedField); + break; + } + case MAP: { + columnType.setType(TPrimitiveType.MAP); + Types.MapType mapType = (Types.MapType) field.type(); + TMapField mapField = new TMapField(); + mapField.setKeyField(fieldPtr(buildField(mapType.fields().get(0)))); + mapField.setValueField(fieldPtr(buildField(mapType.fields().get(1)))); + nestedField.setMapField(mapField); + tField.setNestedField(nestedField); + break; + } + case RECORD: { + columnType.setType(TPrimitiveType.STRUCT); + Types.RecordType recordType = (Types.RecordType) field.type(); + nestedField.setStructField(buildStructField(recordType.fields())); + tField.setNestedField(nestedField); + break; + } + default: + // Scalar: BE reads type.type only as a nested-vs-scalar discriminator on the field-id path (it + // never inspects the specific scalar tag), so a single placeholder is sufficient and avoids + // replicating the full hudi->Doris primitive mapping (drops legacy fromAvroHudiTypeToDorisType). + columnType.setType(TPrimitiveType.STRING); + break; + } + tField.setType(columnType); + return tField; + } + + /** + * Serialize the schema dictionary into a base64 {@code TBinaryProtocol} blob, carried by a throwaway + * {@link TFileScanRangeParams} (the exact thrift target so {@link #applySchemaEvolution} only copies the two + * fields back). Mirrors iceberg/paimon. + */ + static String encode(long currentSchemaId, List history) { + TFileScanRangeParams carrier = new TFileScanRangeParams(); + carrier.setCurrentSchemaId(currentSchemaId); + carrier.setHistorySchemaInfo(history); + try { + byte[] bytes = new TSerializer(new TBinaryProtocol.Factory()).serialize(carrier); + return BASE64_ENCODER.encodeToString(bytes); + } catch (Exception | LinkageError e) { + // Catch LinkageError (e.g. IncompatibleClassChangeError from a thrift classloader split) too: wrapped + // as a RuntimeException it surfaces as a clean per-query failure instead of escaping the connection + // handler as an uncaught Error and killing the whole mysql session (mirrors iceberg/paimon). + throw new RuntimeException("Failed to serialize hudi schema-evolution info", e); + } + } + + /** + * Decode the prop produced by {@link #encode} and copy {@code current_schema_id} + {@code history_schema_info} + * onto the real scan params. Fail loud on a decode error — the prop is produced by us, so a failure is a real + * bug, and silently dropping it would re-introduce the silent wrong-rows risk on schema-evolved native reads. + * A {@code null}/empty prop (e.g. another connector's props map) is a no-op. + */ + static void applySchemaEvolution(TFileScanRangeParams params, String encoded) { + if (encoded == null || encoded.isEmpty()) { + return; + } + try { + byte[] bytes = Base64.getDecoder().decode(encoded); + TFileScanRangeParams carrier = new TFileScanRangeParams(); + new TDeserializer(new TBinaryProtocol.Factory()).deserialize(carrier, bytes); + if (carrier.isSetCurrentSchemaId()) { + params.setCurrentSchemaId(carrier.getCurrentSchemaId()); + } + if (carrier.isSetHistorySchemaInfo()) { + params.setHistorySchemaInfo(carrier.getHistorySchemaInfo()); + } + } catch (Exception e) { + throw new RuntimeException("Failed to apply hudi schema-evolution info to scan params", e); + } + } + + private static TFieldPtr fieldPtr(TField field) { + TFieldPtr ptr = new TFieldPtr(); + ptr.setFieldPtr(field); + return ptr; + } + + // ========== mode-aware InternalSchema resolvers (shared by field-id / schema-id / dict steps) ========== + + /** The mode-aware table {@link InternalSchema} for the latest commit + whether schema-on-read evolution is on. */ + static final class ResolvedInternalSchema { + final InternalSchema internalSchema; + final boolean enableSchemaEvolution; + + ResolvedInternalSchema(InternalSchema internalSchema, boolean enableSchemaEvolution) { + this.internalSchema = internalSchema; + this.enableSchemaEvolution = enableSchemaEvolution; + } + } + + /** + * Resolve the mode-aware table {@link InternalSchema} for the LATEST commit. Mirror of legacy + * {@code HiveMetaStoreClientHelper.getHudiTableSchema}: when {@code hoodie.schema.on.read.enable} is on, the + * ids come from the commit-metadata {@link InternalSchema} (STABLE across renames) and evolution is flagged + * true; otherwise from {@code AvroInternalSchemaConverter.convert(latestAvro)} (positional ids, version 0) + * with evolution false. The no-arg {@code getTableInternalSchemaFromCommitMetadata()} pins the latest commit + * (steady-state / no time-travel pin). Shared by the field-id ({@code HudiConnectorMetadata}) and the + * per-file schema-id / dict scan paths so both agree on the id source. + */ + static ResolvedInternalSchema resolveTableInternalSchema(TableSchemaResolver schemaResolver, Schema latestAvro) { + Option fromCommit = schemaResolver.getTableInternalSchemaFromCommitMetadata(); + if (fromCommit.isPresent()) { + return new ResolvedInternalSchema(fromCommit.get(), true); + } + return new ResolvedInternalSchema(AvroInternalSchemaConverter.convert(latestAvro), false); + } + + /** + * Resolve the {@link InternalSchema} of the commit that WROTE a given base file — its {@code schemaId()} is + * the per-split {@code THudiFileDesc.schema_id} BE stamps native files with on the field-id path. Port of + * legacy {@code HudiScanNode.setHudiParams}: evolution on → {@code FSUtils.getCommitTime(fileName)} + * → {@code InternalSchemaCache.searchSchemaAndCache} (the real committed versionId); off → the base + * (non-evolution) InternalSchema ({@code convert(latestAvro)}, version {@code 0}). The base schema is passed + * in (resolved once per scan via {@link #resolveTableInternalSchema}) so a non-evolution scan pays no + * per-file metaClient touch. Runs on the TCCL-pinned scan thread; shared with the dict step (which reuses the + * returned InternalSchema to build the history entry for that version). + */ + static InternalSchema resolveFileInternalSchema(String filePath, boolean enableSchemaEvolution, + InternalSchema baseInternalSchema, HoodieTableMetaClient metaClient) { + if (!enableSchemaEvolution) { + return baseInternalSchema; + } + long commitTime = Long.parseLong(FSUtils.getCommitTime(new File(filePath).getName())); + return InternalSchemaCache.searchSchemaAndCache(commitTime, metaClient); + } + + // ========== HD-C5b: table schema AT the pinned instant (FOR TIME AS OF over a schema-on-read table) ========== + + /** + * Resolve the table {@link InternalSchema} AS OF {@code queryInstant} (a {@code FOR TIME AS OF} pin), or + * {@link Optional#empty()} when the table is not schema-on-read (no commit-metadata {@link InternalSchema} at + * the instant) — the caller then uses the LATEST schema, which for a non-evolution table is byte-equivalent + * (its schema never changed; design decision D3). Mirror of legacy {@code + * HiveMetaStoreClientHelper.getHudiTableSchema} at-instant path + HD-C5a's {@code getSchemaFromMetaClient}: + * reload the active timeline first (so a just-committed instant's schema file is readable, not a stale cached + * one) then {@code getTableInternalSchemaFromCommitMetadata(instant)}. Runs on the caller's TCCL-pinned scan + * thread ({@code planScan} / {@code getScanNodeProperties}); the off-scan-thread {@code getTableSchema} + * at-instant read is wrapped by HD-C5a's {@code HudiMetaClientExecutor.execute} separately. + */ + static Optional resolveInternalSchemaAtInstant(TableSchemaResolver schemaResolver, + HoodieTableMetaClient metaClient, String queryInstant) { + metaClient.reloadActiveTimeline(); + Option atInstant = schemaResolver.getTableInternalSchemaFromCommitMetadata(queryInstant); + return atInstant.isPresent() ? Optional.of(atInstant.get()) : Optional.empty(); + } + + /** + * The Avro schema whose fields drive the JNI (MOR-realtime) reader's {@code column_names}/{@code column_types}: + * the schema AT {@code queryInstant} for a {@code FOR TIME AS OF} read over a schema-on-read table (legacy + * {@code HudiScanNode:222-224}, kept in lockstep with the native {@code -1} overlay), else {@code latestAvro} + * (a plain read {@code queryInstant == null}, or a non-evolution table whose schema never changed — D3). A + * {@code null} instant short-circuits BEFORE {@link #resolveInternalSchemaAtInstant}, so a plain read does not + * reload the timeline (byte-identical to before this step); the pure choice is {@link #chooseJniSchema}. + */ + static Schema resolveJniColumnSchema(TableSchemaResolver schemaResolver, HoodieTableMetaClient metaClient, + Schema latestAvro, String queryInstant) { + Optional pinned = queryInstant == null + ? Optional.empty() + : resolveInternalSchemaAtInstant(schemaResolver, metaClient, queryInstant); + return chooseJniSchema(latestAvro, pinned); + } + + /** + * Pure JNI-schema choice (package-private for same-loader testing): the at-instant schema converted back to + * Avro when a pinned {@link InternalSchema} is present, else {@code latestAvro} unchanged. Uses the same + * {@code AvroInternalSchemaConverter.convert(InternalSchema, name)} as HD-C5a's {@code + * columnsFromInternalSchema} (the record name is cosmetic — only the root record is named). + */ + static Schema chooseJniSchema(Schema latestAvro, Optional pinnedSchema) { + return pinnedSchema.isPresent() + ? AvroInternalSchemaConverter.convert(pinnedSchema.get(), "hudi_table") + : latestAvro; + } + + // ========== scan-level schema-evolution dictionary (current_schema_id + history_schema_info) ========== + + /** + * Build + serialize the native-reader schema dictionary for the scan-node prop. The {@code -1} target entry + * is keyed off the {@code requestedColumns} (its top-level names == BE scan slots by construction); the + * history entries cover every schema version any native file could carry. Touches the metaClient to resolve + * the mode-aware base schema and the historical versions; the pure dict assembly is + * {@link #buildSchemaEvolutionDict}. Runs on the TCCL-pinned scan thread. + */ + static Optional buildSchemaEvolutionProp(HoodieTableMetaClient metaClient, + TableSchemaResolver schemaResolver, Schema latestAvro, List requestedColumns) { + // Field-id matching is PER-FILE in BE, NOT per-column: once a native file carries a schema_id, EVERY + // projected column of that file is matched by id, with NO per-column BY_NAME fallback. So if ANY projected + // column has no resolved field id, emitting the dict would silently read that column as const-NULL. The + // case that hits this is a projected _hoodie_* meta column on a schema-on-read (evolution) table: the + // connector exposes the meta columns (getTableAvroSchema(true)) but the mode-aware commit-metadata + // InternalSchema omits them, so their handle field id stays UNSET (-1). Skip the dict entirely then -> BE + // stays on BY_NAME for the WHOLE scan (the safe baseline: only renamed columns read wrong, exactly as + // before this feature; meta columns read correctly by name). A data-only projection (all ids resolved) + // still gets full field-id / rename matching. FLIP-TIME RESIDUAL: full rename-correctness for SELECT* over + // an evolution table needs reserved meta-column field ids injected into every entry (iceberg row-lineage + // pattern) or dropping meta exposure to mirror legacy -- deferred, owed the flip e2e. + for (HudiColumnHandle handle : requestedColumns) { + if (handle.getFieldId() < 0) { + return Optional.empty(); + } + } + ResolvedInternalSchema resolved = resolveTableInternalSchema(schemaResolver, latestAvro); + Collection historical = resolved.enableSchemaEvolution + ? allHistoricalSchemas(metaClient) + : Collections.emptyList(); + return Optional.of(buildSchemaEvolutionDict(requestedColumns, resolved.internalSchema, + resolved.enableSchemaEvolution, historical)); + } + + /** + * The native-reader schema dictionary for a {@code FOR TIME AS OF} read over a schema-on-read table (HD-C5b): + * the {@code -1} target overlay is built from the FULL {@code pinnedSchema} (a SUPERSET of the pinned scan + * slots), NOT from the requested column handles. The handles are LATEST-keyed ({@code getColumnHandles} runs + * before the MVCC pin), so a column renamed after the pinned instant is absent from them under its pinned + * (historical) name; building the overlay from them would emit a SUBSET missing that scan slot, and BE's + * field-id reader ({@code by_table_field_id} StructNode) requires EVERY scan slot to be present in the {@code + * -1} overlay (a missing slot std::out_of_range / SIGABRTs; extra fields are looked up by name only, so a + * superset is safe). The history entries are ALL committed versions (Option B, identical to the steady-state + * dict). {@code pinnedSchema} being present already implies schema-on-read is on (its commit-metadata + * InternalSchema resolved), so evolution is {@code true}. Touches the metaClient to read the schema history; + * runs on the TCCL-pinned scan thread. + */ + static String buildSchemaEvolutionDictAtInstant(HoodieTableMetaClient metaClient, InternalSchema pinnedSchema) { + return buildSchemaEvolutionDict(Collections.emptyList(), pinnedSchema, true, + allHistoricalSchemas(metaClient)); + } + + /** + * Pure assembly of the schema dictionary (package-private for same-loader testing): one {@code -1} target + * entry (from the requested columns + base schema) + the history entries. HISTORY-SET = ALL committed + * schema versions (a robust SUPERSET of the referenced-file versions, mirroring the paimon connector's + * all-{@code listAllIds} emission): self-consistent by construction (every native file's {@code schema_id} + * is present, so BE never fails "miss schema info"), with no coupling between the dict and which files a + * given scan happens to select. For a non-evolution table there is no history file, so the single + * non-evolution InternalSchema (version {@code 0}) is emitted as the only history entry. + */ + static String buildSchemaEvolutionDict(List requestedColumns, InternalSchema baseSchema, + boolean enableSchemaEvolution, Collection historicalVersions) { + List history = new ArrayList<>(); + history.add(buildTargetSchema(requestedColumns, baseSchema)); + if (enableSchemaEvolution) { + for (InternalSchema version : historicalVersions) { + history.add(buildSchemaInfo(version)); + } + } else { + // Non-evolution: no history file; the base convert()-schema (version 0) is the only file version. + history.add(buildSchemaInfo(baseSchema)); + } + return encode(CURRENT_SCHEMA_ID, history); + } + + /** + * The {@code -1} target/current overlay, keyed off the requested (pruned) columns so its top-level names + * equal the BE scan slots (the CI-969249 StructNode invariant). Each requested column's full nested + * structure + stable field id is looked up BY NAME in {@code baseSchema}. Empty {@code requestedColumns} + * (count-only scan) falls back to all base top-level fields. + * + *

The scalar-placeholder fallback (a requested column absent from {@code baseSchema}) is DEFENSIVE only: + * {@link #buildSchemaEvolutionProp} already gates the whole dict OFF when any projected column has an unset + * field id, so in production every column reaching here resolves in {@code baseSchema}. The fallback is kept + * so a direct/test call still produces a complete overlay rather than dropping a scan slot.

+ */ + static TSchema buildTargetSchema(List requestedColumns, InternalSchema baseSchema) { + TSchema tSchema = new TSchema(); + tSchema.setSchemaId(CURRENT_SCHEMA_ID); + TStructField root = new TStructField(); + if (requestedColumns == null || requestedColumns.isEmpty()) { + for (Types.Field field : baseSchema.getRecord().fields()) { + root.addToFields(fieldPtr(buildField(field))); + } + } else { + Map byName = new HashMap<>(); + for (Types.Field field : baseSchema.getRecord().fields()) { + byName.put(field.name().toLowerCase(Locale.ROOT), field); + } + for (HudiColumnHandle handle : requestedColumns) { + Types.Field field = byName.get(handle.getName().toLowerCase(Locale.ROOT)); + root.addToFields(fieldPtr(field != null + ? buildField(field) + : scalarField(handle.getName().toLowerCase(Locale.ROOT), handle.getFieldId()))); + } + } + tSchema.setRootField(root); + return tSchema; + } + + /** All committed InternalSchema versions of the table (empty for a non-evolution table). */ + private static Collection allHistoricalSchemas(HoodieTableMetaClient metaClient) { + String historyStr = new FileBasedInternalSchemaStorageManager(metaClient).getHistorySchemaStr(); + if (historyStr == null || historyStr.isEmpty()) { + return Collections.emptyList(); + } + return SerDeHelper.parseSchemas(historyStr).values(); + } + + /** A scalar-leaf {@link TField} (STRING placeholder) carrying a name + field id (for the target-entry fallback). */ + private static TField scalarField(String name, int id) { + TField tField = new TField(); + tField.setName(name); + tField.setId(id); + tField.setIsOptional(true); + TColumnType columnType = new TColumnType(); + columnType.setType(TPrimitiveType.STRING); + tField.setType(columnType); + return tField; + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiTableHandle.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiTableHandle.java index 67d3c9d8c271cd..21fdc50cf90c6e 100644 --- a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiTableHandle.java +++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiTableHandle.java @@ -49,6 +49,27 @@ public class HudiTableHandle implements ConnectorTableHandle { // Set after applyFilter for partition pruning private final List prunedPartitionPaths; + // Set after applySnapshot for FOR TIME AS OF time travel: the completed-timeline instant the scan reads + // BEFORE-OR-ON (a String like "20240101120000", not a numeric snapshot id). Null = no time travel; the scan + // reads the latest completed instant (byte-identical to a plain snapshot read). + private final String queryInstant; + + // Set after applySnapshot for an @incr incremental read: the resolved (begin, end] completed-timeline window + // (String instants like "20240101120000"; empty timeline / "earliest" collapse to "000") the scan reads. + // Both bounds are FULLY resolved by HudiConnectorMetadata.resolveTimeTravel (EARLIEST -> "000", an omitted / + // "latest" end -> the latest completed instant), so downstream file selection + the synthetic commit-time + // filter consume them directly. A non-null beginInstant is the incremental-read marker; null = not an + // incremental read. + private final String beginInstant; + private final String endInstant; + + // Set after applySnapshot for an @incr incremental read: the RAW @incr option params fe-core threaded via + // getIncrementalParams() (e.g. hoodie.datasource.read.incr.path.glob / ...fallback.fulltablescan.enable / + // hoodie.read.timeline.holes.resolution.policy). planScan feeds this map straight to the ported + // IncrementalRelation constructors as their optParams (and derives HollowCommitHandling from it), so the + // relations read the glob / fallback / policy exactly as legacy did. Empty for a non-incremental read. + private final Map incrementalParams; + private HudiTableHandle(Builder builder) { this.dbName = builder.dbName; this.tableName = builder.tableName; @@ -63,6 +84,12 @@ private HudiTableHandle(Builder builder) { ? Collections.unmodifiableMap(builder.tableParameters) : Collections.emptyMap(); this.prunedPartitionPaths = builder.prunedPartitionPaths; + this.queryInstant = builder.queryInstant; + this.beginInstant = builder.beginInstant; + this.endInstant = builder.endInstant; + this.incrementalParams = builder.incrementalParams != null + ? Collections.unmodifiableMap(builder.incrementalParams) + : Collections.emptyMap(); } /** Legacy constructor for Phase 1 compatibility (metadata-only). */ @@ -106,6 +133,32 @@ public List getPrunedPartitionPaths() { return prunedPartitionPaths; } + /** The FOR TIME AS OF instant the scan reads before-or-on, or {@code null} for a latest read. */ + public String getQueryInstant() { + return queryInstant; + } + + /** + * The resolved incremental-read begin instant (exclusive lower bound of the {@code (begin, end]} window), + * or {@code null} for a non-incremental read. A non-null value is the incremental-read marker. + */ + public String getBeginInstant() { + return beginInstant; + } + + /** The resolved incremental-read end instant (inclusive upper bound), or {@code null} if non-incremental. */ + public String getEndInstant() { + return endInstant; + } + + /** + * The raw {@code @incr} option params (glob / fallback-full-table-scan / hollow-commit policy), fed verbatim + * to the ported incremental relations at scan time. Empty (never null) for a non-incremental read. + */ + public Map getIncrementalParams() { + return incrementalParams; + } + /** Returns a builder pre-populated with this handle's state, for creating modified copies. */ public Builder toBuilder() { Builder b = new Builder(dbName, tableName, basePath, hudiTableType); @@ -114,6 +167,10 @@ public Builder toBuilder() { b.partitionKeyNames = this.partitionKeyNames; b.tableParameters = this.tableParameters; b.prunedPartitionPaths = this.prunedPartitionPaths; + b.queryInstant = this.queryInstant; + b.beginInstant = this.beginInstant; + b.endInstant = this.endInstant; + b.incrementalParams = this.incrementalParams; return b; } @@ -136,6 +193,10 @@ public static final class Builder { private List partitionKeyNames; private Map tableParameters; private List prunedPartitionPaths; + private String queryInstant; + private String beginInstant; + private String endInstant; + private Map incrementalParams; public Builder(String dbName, String tableName, String basePath, String hudiTableType) { this.dbName = dbName; @@ -169,6 +230,26 @@ public Builder prunedPartitionPaths(List val) { return this; } + public Builder queryInstant(String val) { + this.queryInstant = val; + return this; + } + + public Builder beginInstant(String val) { + this.beginInstant = val; + return this; + } + + public Builder endInstant(String val) { + this.endInstant = val; + return this; + } + + public Builder incrementalParams(Map val) { + this.incrementalParams = val; + return this; + } + public HudiTableHandle build() { return new HudiTableHandle(this); } diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiTypeMapping.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiTypeMapping.java index 3e3d10bff7ad8c..3581bc2d1893c2 100644 --- a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiTypeMapping.java +++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiTypeMapping.java @@ -78,6 +78,99 @@ public static ConnectorType fromAvroSchema(Schema avroSchema) { } } + /** + * Convert an Avro schema to a Hive type string, mirroring fe-core + * {@code HudiUtils.convertAvroToHiveType}. + * + *

This feeds the BE Hudi JNI scanner's {@code hudi_column_types} param. + * The BE joins the per-column type list with {@code '#'} and the scanner + * ({@code HadoopHudiJniScanner}) splits it back on {@code '#'} — so each + * returned string is a single list element and may safely contain commas + * (e.g. {@code decimal(10,2)}, {@code struct}, + * {@code map}).

+ * + *

This is distinct from {@link #fromAvroSchema}, which maps Avro to a + * Doris {@link ConnectorType} for schema reporting. The JNI reader needs + * Hive type strings, not Doris type names.

+ * + * @throws IllegalArgumentException for unsupported types (matches the + * legacy fail-loud behavior) + */ + public static String toHiveTypeString(Schema schema) { + Schema.Type type = schema.getType(); + LogicalType logicalType = schema.getLogicalType(); + + switch (type) { + case BOOLEAN: + return "boolean"; + case INT: + if (logicalType instanceof LogicalTypes.Date) { + return "date"; + } + if (logicalType instanceof LogicalTypes.TimeMillis) { + throw unsupportedLogicalType(schema); + } + return "int"; + case LONG: + if (logicalType instanceof LogicalTypes.TimestampMillis + || logicalType instanceof LogicalTypes.TimestampMicros) { + return "timestamp"; + } + if (logicalType instanceof LogicalTypes.TimeMicros) { + throw unsupportedLogicalType(schema); + } + return "bigint"; + case FLOAT: + return "float"; + case DOUBLE: + return "double"; + case STRING: + return "string"; + case FIXED: + case BYTES: + if (logicalType instanceof LogicalTypes.Decimal) { + LogicalTypes.Decimal decimalType = (LogicalTypes.Decimal) logicalType; + return String.format("decimal(%d,%d)", + decimalType.getPrecision(), decimalType.getScale()); + } + return "string"; + case ARRAY: + return String.format("array<%s>", + toHiveTypeString(schema.getElementType())); + case RECORD: + List recordFields = schema.getFields(); + if (recordFields.isEmpty()) { + throw new IllegalArgumentException("Record must have fields"); + } + String structFields = recordFields.stream() + .map(field -> String.format("%s:%s", field.name(), + toHiveTypeString(field.schema()))) + .collect(Collectors.joining(",")); + return String.format("struct<%s>", structFields); + case MAP: + return String.format("map", + toHiveTypeString(schema.getValueType())); + case UNION: + List unionTypes = schema.getTypes().stream() + .filter(s -> s.getType() != Schema.Type.NULL) + .collect(Collectors.toList()); + if (unionTypes.size() == 1) { + return toHiveTypeString(unionTypes.get(0)); + } + break; + default: + break; + } + + throw new IllegalArgumentException(String.format( + "Unsupported type: %s for column: %s", type.getName(), schema.getName())); + } + + private static IllegalArgumentException unsupportedLogicalType(Schema schema) { + return new IllegalArgumentException( + String.format("Unsupported logical type: %s", schema.getLogicalType())); + } + private static ConnectorType mapIntType(LogicalType logicalType) { if (logicalType instanceof LogicalTypes.Date) { return ConnectorType.of("DATEV2"); diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/IncrementalRelation.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/IncrementalRelation.java new file mode 100644 index 00000000000000..169ec386cf017b --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/IncrementalRelation.java @@ -0,0 +1,149 @@ +// 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.connector.api.DorisConnectorException; + +import org.apache.hudi.common.model.FileSlice; +import org.apache.hudi.common.table.timeline.TimelineUtils.HollowCommitHandling; + +import java.util.List; +import java.util.Map; +import java.util.function.UnaryOperator; + +/** + * Selects the files an {@code @incr(...)} incremental read must scan over a resolved {@code (begin, end]} + * commit-time window. Connector-internal port of legacy {@code datasource.hudi.source.IncrementalRelation}, + * with the split type re-homed from fe-core {@code spi.Split}/{@code HudiSplit} to the connector's + * {@link HudiScanRange} (fe-core is off-limits across the plugin boundary). + * + *

Window is already resolved. Unlike legacy — where each relation constructor re-parsed / re-resolved + * the window from {@code optParams} — the ported relations take the ALREADY-RESOLVED {@code startTs}/{@code endTs} + * that {@link HudiConnectorMetadata#resolveTimeTravel} stamped on the {@link HudiTableHandle}, and do FILE + * SELECTION only. This keeps a SINGLE window authority (the handle) feeding both the file set here and the + * synthetic {@code _hoodie_commit_time} row filter a later step injects, so the two can never disagree on the + * window; the dead {@code MORIncrementalRelation:92} sentinel bug (which tested {@code latestTime} instead of the + * end) vanishes by construction because no sentinel re-resolution survives in the relation. + * + *

Two shapes, mirroring legacy's own asymmetry: a COW relation yields native {@link HudiScanRange}s directly + * ({@link #collectSplits()}); a MOR relation yields merged {@link FileSlice}s ({@link #collectFileSlices()}) that + * the scan planner later turns into JNI ranges at {@code endTs}. Each relation supports only its own shape and + * throws {@link UnsupportedOperationException} for the other. + * + *

DORMANT. Nothing wires these relations into {@code HudiScanPlanProvider.planScan} yet (that is the + * next step); they are ported + unit-testable in isolation. The empty-completed-timeline case routes to + * {@link EmptyIncrementalRelation} in the scan planner (legacy {@code LogicalHudiScan.withScanParams:261-262}), + * so COW/MOR are never constructed on an empty timeline and their meta-fields guard fires only for a non-empty + * one (legacy parity). + */ +interface IncrementalRelation { + + /** Merged file slices at {@code endTs} for the MOR path (COW throws {@link UnsupportedOperationException}). */ + List collectFileSlices(); + + /** + * Native base-file ranges for the COW path (MOR throws {@link UnsupportedOperationException}). + * {@code nativePathNormalizer} rewrites each range's raw storage URI to BE's canonical scheme + * (s3a->s3) for the native reader; implementations that emit no native ranges here ignore it. + */ + List collectSplits(UnaryOperator nativePathNormalizer); + + /** + * Whether the window fell back to a full-table scan (archived instant / missing file). The scan planner + * checks this BEFORE calling {@link #collectSplits()}/{@link #collectFileSlices()} and, when true, degrades + * to the normal latest-snapshot partition scan instead of the incremental path (legacy + * {@code HudiScanNode.getSplits:470}). + */ + boolean fallbackFullTableScan(); + + /** The resolved inclusive window end (the MOR-JNI merge instant); {@code "000"} for the empty relation. */ + String getEndTs(); + + /** + * Fail loud when a table has meta fields disabled, byte-for-byte with legacy + * ({@code COWIncrementalRelation:81-83} / {@code MORIncrementalRelation:73-75}) but re-typed to the + * connector's {@link DorisConnectorException} (the user-visible message is preserved, exactly as INC-1 + * re-typed the begin-required throw). Ported here from INC-1's deferral list. Because the empty timeline + * routes to {@link EmptyIncrementalRelation} upstream, this is reached only for a non-empty timeline + * (legacy's "non-empty only" semantics), so it stays the FIRST guard in each COW/MOR constructor. + */ + static void checkIncrementalMetaFields(boolean populateMetaFields) { + if (!populateMetaFields) { + throw new DorisConnectorException( + "Incremental queries are not supported when meta fields are disabled"); + } + } + + /** + * Maps the {@code @incr} hollow-commit policy option to its {@link HollowCommitHandling} enum, byte-faithful + * to legacy ({@code COWIncrementalRelation:74-75} / {@code MORIncrementalRelation:76-77}): the policy key + * defaults to {@code FAIL} and any explicit value is passed to {@link HollowCommitHandling#valueOf} (which + * THROWS {@link IllegalArgumentException} on a bogus value — legacy parity, same terminal error). The + * policy-key literal is RE-DECLARED here rather than imported from fe-core's {@code IncrementalRelation} + * across the plugin boundary, and matches {@code HudiConnectorMetadata.INCR_HOLLOW_POLICY_KEY} (the ONE place + * the END-axis resolution reads the same key). Called by {@code HudiScanPlanProvider.planScan} so the ported + * relation's OWN completion-time file selection uses the SAME policy that drove the window END resolution. + */ + static HollowCommitHandling hollowCommitHandling(Map optParams) { + return HollowCommitHandling.valueOf( + optParams.getOrDefault("hoodie.read.timeline.holes.resolution.policy", "FAIL")); + } + + /** + * Fail loud when the {@code USE_TRANSITION_TIME} hollow-commit policy meets a full-table-scan fallback, + * byte-for-byte with legacy ({@code COWIncrementalRelation:178-180} inside {@code shouldFullTableScan}, + * {@code MORIncrementalRelation:104-106} in the constructor). Shared so both relations emit the identical + * message. + */ + static void checkStateTransitionTimeFullTableScan(HollowCommitHandling policy, boolean fullTableScan) { + if (policy == HollowCommitHandling.USE_TRANSITION_TIME && fullTableScan) { + throw new DorisConnectorException( + "Cannot use stateTransitionTime while enables full table scan"); + } + } + + /** + * Fail loud when a resolved window fell back to a full-table scan, byte-for-byte with legacy + * ({@code COWIncrementalRelation:206} / {@code MORIncrementalRelation:177}), re-typed to the connector's + * {@link DorisConnectorException}. A DEFENSIVE invariant: the scan planner is contracted to check + * {@link #fallbackFullTableScan()} and degrade to the snapshot path BEFORE calling {@link #collectSplits()} + * / {@link #collectFileSlices()}, so this normally never fires. Extracted (like the other guards) so the + * message is byte-verifiable offline. + */ + static void checkNotFullTableScan(boolean fullTableScan) { + if (fullTableScan) { + throw new DorisConnectorException("Fallback to full table scan"); + } + } + + /** + * The archival half of legacy COW {@code shouldFullTableScan} ({@code COWIncrementalRelation:177-182}): a + * full-table scan is required when the fallback is enabled AND either bound is archived (before the + * timeline start); under {@code USE_TRANSITION_TIME} that combination is rejected instead. Pure booleans in, + * so it is unit-testable as a matrix; the file-existence half of {@code shouldFullTableScan} stays in the + * COW relation because it probes the filesystem. + */ + static boolean decideArchivalFullTableScan(boolean fallbackEnabled, boolean startArchived, + boolean endArchived, HollowCommitHandling policy) { + if (fallbackEnabled && (startArchived || endArchived)) { + checkStateTransitionTimeFullTableScan(policy, true); + return true; + } + return false; + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/MORIncrementalRelation.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/MORIncrementalRelation.java new file mode 100644 index 00000000000000..eb9ddb07f502ba --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/MORIncrementalRelation.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.hudi; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.GlobPattern; +import org.apache.hudi.common.model.BaseFile; +import org.apache.hudi.common.model.FileSlice; +import org.apache.hudi.common.model.HoodieCommitMetadata; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.timeline.HoodieInstant; +import org.apache.hudi.common.table.timeline.HoodieTimeline; +import org.apache.hudi.common.table.timeline.TimelineUtils; +import org.apache.hudi.common.table.timeline.TimelineUtils.HollowCommitHandling; +import org.apache.hudi.common.table.view.HoodieTableFileSystemView; +import org.apache.hudi.hadoop.utils.HoodieInputFormatUtils; +import org.apache.hudi.metadata.HoodieTableMetadataUtil; +import org.apache.hudi.storage.StoragePathInfo; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Selects the merged file slices a MOR {@code @incr(...)} read must scan over a resolved {@code (begin, end]} + * window. Connector-internal port of legacy {@code datasource.hudi.source.MORIncrementalRelation}, with the + * window-parse prologue removed (the window is resolved ONCE by + * {@link HudiConnectorMetadata#resolveTimeTravel} and consumed here, see {@link IncrementalRelation}). The dead + * {@code MORIncrementalRelation:92} sentinel bug (which tested {@code latestTime} instead of the end and so never + * fired) is gone by construction — no sentinel re-resolution survives in the relation. + * + *

{@link #collectFileSlices()} yields the merged slices for the scan planner to turn into JNI ranges at + * {@code endTs}; {@link #collectSplits()} is unsupported (the COW shape). + */ +final class MORIncrementalRelation implements IncrementalRelation { + + private final Map optParams; + private final HoodieTableMetaClient metaClient; + private final HoodieTimeline timeline; + private final HollowCommitHandling hollowCommitHandling; + private final String startTimestamp; + private final String endTimestamp; + private final boolean startInstantArchived; + private final boolean endInstantArchived; + private final List includedCommits; + private final List commitsMetadata; + private final List affectedFilesInCommits; + private final boolean fullTableScan; + private final String globPattern; + + MORIncrementalRelation(HoodieTableMetaClient metaClient, Configuration configuration, + String startTs, String endTs, HollowCommitHandling hollowCommitHandling, + Map optParams) throws IOException { + this.optParams = optParams; + this.metaClient = metaClient; + this.hollowCommitHandling = hollowCommitHandling; + this.startTimestamp = startTs; + this.endTimestamp = endTs; + timeline = metaClient.getCommitsAndCompactionTimeline().filterCompletedInstants(); + // Meta-fields guard (ported from INC-1's deferral list): fail loud before any file selection. First guard + // because the empty-completed-timeline case routes to EmptyIncrementalRelation upstream, so this relation + // is built only for a non-empty timeline (legacy's "non-empty only" semantics). + IncrementalRelation.checkIncrementalMetaFields(metaClient.getTableConfig().populateMetaFields()); + + startInstantArchived = timeline.isBeforeTimelineStarts(startTimestamp); + endInstantArchived = timeline.isBeforeTimelineStarts(endTimestamp); + + includedCommits = getIncludedCommits(); + commitsMetadata = getCommitsMetadata(); + affectedFilesInCommits = HoodieInputFormatUtils.listAffectedFilesForCommits(configuration, + metaClient.getBasePath(), commitsMetadata); + fullTableScan = shouldFullTableScan(); + IncrementalRelation.checkStateTransitionTimeFullTableScan(hollowCommitHandling, fullTableScan); + globPattern = optParams.getOrDefault("hoodie.datasource.read.incr.path.glob", ""); + } + + private List getIncludedCommits() { + if (!startInstantArchived || !endInstantArchived) { + // If endTimestamp commit is not archived, will filter instants + // before endTimestamp. + if (hollowCommitHandling == HollowCommitHandling.USE_TRANSITION_TIME) { + return timeline.findInstantsInRangeByCompletionTime(startTimestamp, endTimestamp).getInstants(); + } else { + return timeline.findInstantsInRange(startTimestamp, endTimestamp).getInstants(); + } + } else { + return timeline.getInstants(); + } + } + + private List getCommitsMetadata() throws IOException { + List result = new ArrayList<>(); + for (HoodieInstant commit : includedCommits) { + result.add(TimelineUtils.getCommitMetadata(commit, timeline)); + } + return result; + } + + private boolean shouldFullTableScan() throws IOException { + boolean should = Boolean.parseBoolean( + optParams.getOrDefault("hoodie.datasource.read.incr.fallback.fulltablescan.enable", "false")) && ( + startInstantArchived || endInstantArchived); + if (should) { + return true; + } + for (StoragePathInfo fileStatus : affectedFilesInCommits) { + if (!metaClient.getStorage().exists(fileStatus.getPath())) { + return true; + } + } + return false; + } + + @Override + public boolean fallbackFullTableScan() { + return fullTableScan; + } + + @Override + public String getEndTs() { + return endTimestamp; + } + + @Override + public List collectFileSlices() { + if (includedCommits.isEmpty()) { + return Collections.emptyList(); + } + IncrementalRelation.checkNotFullTableScan(fullTableScan); + HoodieTimeline scanTimeline; + if (hollowCommitHandling == HollowCommitHandling.USE_TRANSITION_TIME) { + scanTimeline = metaClient.getCommitsAndCompactionTimeline() + .findInstantsInRangeByCompletionTime(startTimestamp, endTimestamp); + } else { + scanTimeline = TimelineUtils.handleHollowCommitIfNeeded( + metaClient.getCommitsAndCompactionTimeline(), metaClient, hollowCommitHandling) + .findInstantsInRange(startTimestamp, endTimestamp); + } + String latestCommit = includedCommits.get(includedCommits.size() - 1).requestedTime(); + HoodieTableFileSystemView fsView = new HoodieTableFileSystemView(metaClient, scanTimeline, + affectedFilesInCommits); + Stream fileSlices = HoodieTableMetadataUtil.getWritePartitionPaths(commitsMetadata) + .stream().flatMap(relativePartitionPath -> + fsView.getLatestMergedFileSlicesBeforeOrOn(relativePartitionPath, latestCommit)); + if ("".equals(globPattern)) { + return fileSlices.collect(Collectors.toList()); + } + GlobPattern globMatcher = new GlobPattern("*" + globPattern); + return fileSlices.filter(fileSlice -> globMatcher.matches(fileSlice.getBaseFile().map(BaseFile::getPath) + .or(fileSlice.getLatestLogFile().map(f -> f.getPath().toString())).get())) + .collect(Collectors.toList()); + } + + @Override + public List collectSplits(UnaryOperator nativePathNormalizer) { + // MOR emits ranges via collectFileSlices()/buildMorRange, not here; the normalizer is irrelevant. + throw new UnsupportedOperationException(); + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/DirectHudiMetaClientExecutor.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/DirectHudiMetaClientExecutor.java new file mode 100644 index 00000000000000..088e288c1eeca1 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/DirectHudiMetaClientExecutor.java @@ -0,0 +1,36 @@ +// 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 java.util.concurrent.Callable; + +/** + * Test {@link HudiMetaClientExecutor} that runs the action directly (no plugin auth, no TCCL pin). Used by + * tests that do not exercise the metaClient-touching partition/snapshot paths; a checked exception surfaces + * as an unchecked wrapper so a mis-set-up fixture fails loud. + */ +final class DirectHudiMetaClientExecutor implements HudiMetaClientExecutor { + @Override + public T execute(Callable action) { + try { + return action.call(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} 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 new file mode 100644 index 00000000000000..ce531bdb9f145b --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiBackendDescriptorTest.java @@ -0,0 +1,204 @@ +// 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.connector.spi.ConnectorContext; +import org.apache.doris.filesystem.FileSystemType; +import org.apache.doris.filesystem.properties.HadoopStorageProperties; +import org.apache.doris.filesystem.properties.StorageKind; +import org.apache.doris.filesystem.properties.StorageProperties; +import org.apache.doris.thrift.TFileCompressType; +import org.apache.doris.thrift.TTableDescriptor; +import org.apache.doris.thrift.TTableType; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Tests the BE-facing surface for hudi tables: + *

    + *
  • {@code buildTableDescriptor} -> TTableType.HIVE_TABLE + THiveTable (legacy hudi rode HIVE_TABLE; without + * this the SPI default null degrades BE to a generic SchemaTableDescriptor).
  • + *
  • {@code getScanNodeProperties} storage -> BE-canonical creds (for the native FILE_S3 reader) + the + * hadoop-format passthrough (for the Hudi JNI reader), the legacy getLocationProperties dual merge.
  • + *
+ */ +public class HudiBackendDescriptorTest { + + @Test + public void buildTableDescriptorIsHiveTable() { + HudiConnectorMetadata md = new HudiConnectorMetadata(null, Collections.emptyMap(), + new DirectHudiMetaClientExecutor()); + + TTableDescriptor desc = md.buildTableDescriptor(null, 7L, "t", "db", "t", 3, 100L); + + Assertions.assertEquals(TTableType.HIVE_TABLE, desc.getTableType(), + "legacy hudi rides HIVE_TABLE; a SCHEMA_TABLE default would build the wrong BE descriptor"); + Assertions.assertNotNull(desc.getHiveTable(), "the HIVE_TABLE descriptor must carry a THiveTable"); + Assertions.assertEquals("db", desc.getHiveTable().getDbName()); + Assertions.assertEquals("t", desc.getHiveTable().getTableName()); + } + + @Test + public void scanNodePropertiesEmitsCanonicalCredsAndHadoopPassthrough() { + // A private-bucket read needs BOTH: BE-canonical AWS_* (native FILE_S3 reader) from the context hook, and + // the hadoop fs.s3a.* passthrough (Hudi JNI reader). The old code emitted only the raw passthrough, so the + // native reader had no usable creds (403). + Map catalogProps = new HashMap<>(); + catalogProps.put("fs.s3a.access.key", "hadoopAK"); + HudiScanPlanProvider provider = new HudiScanPlanProvider( + catalogProps, contextWithBackendProps(Collections.singletonMap("AWS_ACCESS_KEY", "canonAK"))); + + Map result = provider.getScanNodeProperties( + null, new HudiTableHandle("db", "t", "s3://b/t", "COPY_ON_WRITE"), + Collections.emptyList(), Optional.empty()); + + Assertions.assertEquals("canonAK", result.get("location.AWS_ACCESS_KEY"), + "BE-canonical creds must be emitted for the native reader"); + Assertions.assertEquals("hadoopAK", result.get("location.fs.s3a.access.key"), + "the hadoop passthrough must be emitted for the JNI reader"); + } + + @Test + public void scanNodePropertiesWithoutContextStillEmitsHadoopPassthrough() { + // Offline / credential-less warehouse: no context -> no canonical overlay, but the hadoop passthrough + // still flows (so a public bucket / HDFS still reads). + Map catalogProps = new HashMap<>(); + catalogProps.put("fs.s3a.access.key", "hadoopAK"); + HudiScanPlanProvider provider = new HudiScanPlanProvider(catalogProps, null); + + Map result = provider.getScanNodeProperties( + null, new HudiTableHandle("db", "t", "s3://b/t", "COPY_ON_WRITE"), + Collections.emptyList(), Optional.empty()); + + Assertions.assertFalse(result.containsKey("location.AWS_ACCESS_KEY"), + "no context must not synthesize canonical creds"); + Assertions.assertEquals("hadoopAK", result.get("location.fs.s3a.access.key")); + } + + @Test + public void scanNodePropertiesEmitsTranslatedFsS3aFromTypedStorageForJniReader() { + // The real failing scenario (FIX-hudi-s3a-jni-creds): the catalog carries Doris s3. aliases + // (s3.access_key/...), NOT inline fs.s3a.* keys. The Hudi JNI reader's S3AFileSystem reads ONLY fs.s3a.*, + // so getScanNodeProperties must emit the TRANSLATED fs.s3a.* from the context's typed StorageProperties + // (storageHadoopConfig). Without it the JNI scanner throws NoAuthWithAWSException. Kills a mutation that + // drops the storageHadoopConfig emission (the pre-fix behavior: only s3. aliases were emitted, useless to + // S3AFileSystem). + Map catalogProps = new HashMap<>(); // s3. aliases only; NO inline fs.s3a.* key + catalogProps.put("s3.access_key", "aliasAK"); + HudiScanPlanProvider provider = new HudiScanPlanProvider(catalogProps, + contextWithHadoopStorage(Collections.singletonMap("fs.s3a.access.key", "translatedAK"))); + + Map result = provider.getScanNodeProperties( + null, new HudiTableHandle("db", "t", "s3a://b/t", "COPY_ON_WRITE"), + Collections.emptyList(), Optional.empty()); + + Assertions.assertEquals("translatedAK", result.get("location.fs.s3a.access.key"), + "translated fs.s3a.* from the catalog's typed StorageProperties must be emitted for the JNI reader"); + } + + @Test + public void adjustFileCompressTypeRemapsLz4FrameLikeLegacyInheritance() { + // Legacy HudiScanNode extended HiveScanNode and INHERITED its LZ4FRAME -> LZ4BLOCK remap (hadoop writes + // .lz4 as the LZ4 block codec). The new HudiScanPlanProvider does not extend the hive provider, so it + // re-declares the remap to preserve that inherited behavior rather than assuming "hudi never emits .lz4". + HudiScanPlanProvider provider = new HudiScanPlanProvider(Collections.emptyMap(), null); + Assertions.assertEquals(TFileCompressType.LZ4BLOCK, + provider.adjustFileCompressType(TFileCompressType.LZ4FRAME)); + Assertions.assertEquals(TFileCompressType.GZ, + provider.adjustFileCompressType(TFileCompressType.GZ)); + Assertions.assertEquals(TFileCompressType.PLAIN, + provider.adjustFileCompressType(TFileCompressType.PLAIN)); + } + + private static ConnectorContext contextWithBackendProps(Map backendProps) { + return new ConnectorContext() { + @Override + public String getCatalogName() { + return "c"; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public Map getBackendStorageProperties() { + return backendProps; + } + }; + } + + /** A context whose typed StorageProperties translate to the given Hadoop config (fs.s3a.* etc). */ + private static ConnectorContext contextWithHadoopStorage(Map hadoopConf) { + StorageProperties sp = new StorageProperties() { + @Override + public String providerName() { + return "s3"; + } + + @Override + public StorageKind kind() { + return null; + } + + @Override + public FileSystemType type() { + return null; + } + + @Override + public Map rawProperties() { + return Collections.emptyMap(); + } + + @Override + public Map matchedProperties() { + return Collections.emptyMap(); + } + + @Override + public Optional toHadoopProperties() { + return Optional.of(() -> hadoopConf); + } + }; + return new ConnectorContext() { + @Override + public String getCatalogName() { + return "c"; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public List getStorageProperties() { + return Collections.singletonList(sp); + } + }; + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiColumnFieldIdTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiColumnFieldIdTest.java new file mode 100644 index 00000000000000..9a2a92f05835f6 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiColumnFieldIdTest.java @@ -0,0 +1,177 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hudi; + +import org.apache.doris.connector.api.ConnectorColumn; + +import org.apache.avro.Schema; +import org.apache.hudi.internal.schema.InternalSchema; +import org.apache.hudi.internal.schema.Types; +import org.apache.hudi.internal.schema.convert.AvroInternalSchemaConverter; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Same-loader unit tests for HD-C4b: sourcing Hudi InternalSchema field ids onto the columns + * ({@link HudiConnectorMetadata#attachTopLevelFieldIds}) and carrying them on {@link HudiColumnHandle}. The + * field id is the rename-safe join key BE uses for schema-evolution BY_FIELD_ID matching; without it a renamed + * column reads NULL on its old files. + * + *

Covers the non-evolution {@code AvroInternalSchemaConverter.convert(avro)} id source (positional ids). The + * evolution-mode commit-metadata id source ({@code getTableInternalSchemaFromCommitMetadata}) needs a live + * metaClient with schema.on.read commit history and is exercised only by the flip-time docker e2e.

+ */ +public class HudiColumnFieldIdTest { + + // A representative Avro schema with mixed-case top-level names (Id/Name/Addr) exercising the case-insensitive + // name match, plus a nested struct (only the TOP-LEVEL id is threaded onto the handle; nested ids for the BE + // dictionary come straight from the InternalSchema via HudiSchemaUtils, not from here). + private static final String SCHEMA_JSON = + "{\"type\":\"record\",\"name\":\"hudi_t\",\"fields\":[" + + "{\"name\":\"Id\",\"type\":[\"null\",\"int\"],\"default\":null}," + + "{\"name\":\"Name\",\"type\":\"string\"}," + + "{\"name\":\"Addr\",\"type\":[\"null\",{\"type\":\"record\",\"name\":\"addr_t\"," + + "\"fields\":[{\"name\":\"Street\",\"type\":[\"null\",\"string\"],\"default\":null}]}]," + + "\"default\":null}" + + "]}"; + + private static Schema parse(String json) { + return new Schema.Parser().parse(json); + } + + /** Expected top-level id per (lower-cased) name, read back from the InternalSchema (not hard-coded). */ + private static Map expectedIds(InternalSchema internalSchema) { + Map ids = new HashMap<>(); + for (Types.Field field : internalSchema.getRecord().fields()) { + ids.put(field.name().toLowerCase(Locale.ROOT), field.fieldId()); + } + return ids; + } + + @Test + public void fieldIdsSourcedByNameFromInternalSchema() { + // getSchemaFromMetaClient builds columns from getTableAvroSchema(true) and sources ids from the mode-aware + // InternalSchema; for a non-evolution table that InternalSchema is convert(latest avro). Each column must + // carry the InternalSchema field id for its (lower-cased) name — the rename-safe BE join key. MUTATION: + // leave uniqueId UNSET / source a fabricated positional value -> the id != the InternalSchema id -> red. + Schema avro = parse(SCHEMA_JSON); + List columns = HudiConnectorMetadata.avroSchemaToColumns(avro); + InternalSchema internalSchema = AvroInternalSchemaConverter.convert(avro); + Map expected = expectedIds(internalSchema); + + List attached = HudiConnectorMetadata.attachTopLevelFieldIds(columns, internalSchema); + + Assertions.assertEquals(3, attached.size()); + for (ConnectorColumn col : attached) { + Integer want = expected.get(col.getName()); + Assertions.assertNotNull(want, "no InternalSchema field for column " + col.getName()); + Assertions.assertEquals(want.intValue(), col.getUniqueId(), + "field id mismatch for column " + col.getName()); + } + // Mixed-case avro name "Id" is matched case-insensitively to the lower-cased column "id" (the byte name BE + // keys by). MUTATION: drop the toLowerCase on either side -> the mixed-case name misses -> UNSET -> red. + Map attachedById = new HashMap<>(); + for (ConnectorColumn col : attached) { + attachedById.put(col.getName(), col.getUniqueId()); + } + Assertions.assertTrue(attachedById.containsKey("id")); + Assertions.assertTrue(attachedById.containsKey("name")); + Assertions.assertTrue(attachedById.containsKey("addr")); + Assertions.assertNotEquals(ConnectorColumn.UNSET_UNIQUE_ID, attachedById.get("addr").intValue()); + } + + @Test + public void unmatchedColumnKeepsUnsetId() { + // A column with no matching InternalSchema field (e.g. a _hoodie_* meta column absent from a + // commit-metadata schema) must keep UNSET_UNIQUE_ID so BE falls back to BY_NAME — never a wrong id. + // Here the columns include an extra "_hoodie_commit_time" that the (data-only) InternalSchema lacks. + // MUTATION: default a matched-but-missing column to 0 / to a neighbour's id -> not UNSET -> red. + Schema dataAvro = parse(SCHEMA_JSON); + List columns = new ArrayList<>( + HudiConnectorMetadata.avroSchemaToColumns(dataAvro)); + // An extra column not present in the (data-only) InternalSchema, reusing an existing column's type. + columns.add(new ConnectorColumn("_hoodie_commit_time", columns.get(1).getType(), "", true, null)); + InternalSchema internalSchema = AvroInternalSchemaConverter.convert(dataAvro); + + List attached = HudiConnectorMetadata.attachTopLevelFieldIds(columns, internalSchema); + + ConnectorColumn attachedExtra = attached.get(attached.size() - 1); + Assertions.assertEquals("_hoodie_commit_time", attachedExtra.getName()); + Assertions.assertEquals(ConnectorColumn.UNSET_UNIQUE_ID, attachedExtra.getUniqueId()); + // the real data columns are still resolved + Assertions.assertNotEquals(ConnectorColumn.UNSET_UNIQUE_ID, attached.get(0).getUniqueId()); + } + + @Test + public void fieldIdsMatchedByNameNotPosition() { + // The load-bearing distinction of C4b: columns come from getTableAvroSchema(true) (which PREPENDS the 5 + // _hoodie_* meta columns) while ids come from an independent InternalSchema (data-only on an evolution + // table), so ids MUST be joined BY NAME, not zipped positionally like legacy. Here the UNMATCHED column is + // FIRST: columns = [_hoodie_commit_time, id, name] against a data-only InternalSchema [id, name]. + // MUTATION (regress to positional zip): _hoodie_commit_time would grab field[0]=id's id and id would grab + // field[1]=name's id -> every column shifts onto the wrong field id (silent BY_FIELD_ID corruption). + Schema metaFirst = parse("{\"type\":\"record\",\"name\":\"t\",\"fields\":[" + + "{\"name\":\"_hoodie_commit_time\",\"type\":[\"null\",\"string\"],\"default\":null}," + + "{\"name\":\"Id\",\"type\":[\"null\",\"int\"],\"default\":null}," + + "{\"name\":\"Name\",\"type\":\"string\"}]}"); + Schema dataOnly = parse("{\"type\":\"record\",\"name\":\"t\",\"fields\":[" + + "{\"name\":\"Id\",\"type\":[\"null\",\"int\"],\"default\":null}," + + "{\"name\":\"Name\",\"type\":\"string\"}]}"); + List columns = HudiConnectorMetadata.avroSchemaToColumns(metaFirst); + InternalSchema dataSchema = AvroInternalSchemaConverter.convert(dataOnly); + Map expected = expectedIds(dataSchema); + + List attached = HudiConnectorMetadata.attachTopLevelFieldIds(columns, dataSchema); + Map byName = new HashMap<>(); + for (ConnectorColumn col : attached) { + byName.put(col.getName(), col.getUniqueId()); + } + + // the unmatched meta column (FIRST) stays UNSET — a positional zip would give it "id"'s field id + Assertions.assertEquals(ConnectorColumn.UNSET_UNIQUE_ID, byName.get("_hoodie_commit_time").intValue()); + // the data columns keep THEIR OWN field id by name (not shifted by one position) + Assertions.assertEquals(expected.get("id"), byName.get("id")); + Assertions.assertEquals(expected.get("name"), byName.get("name")); + } + + @Test + public void handleCarriesFieldId() { + // Pins only the ctor + getFieldId() round-trip (the field id is carried, not dropped/reordered). The + // getColumnHandles threading of col.getUniqueId() onto the handle needs a live metaClient and is + // covered only by the flip-time e2e, not by this same-loader test. + HudiColumnHandle handle = new HudiColumnHandle("c", "int", false, 7); + Assertions.assertEquals(7, handle.getFieldId()); + } + + @Test + public void fieldIdIsNotPartOfHandleIdentity() { + // The field id must NOT enter equals/hashCode (mirror IcebergColumnHandle: identity by name, not id). + // Otherwise a plan that keys handles by identity would treat the same column with a differently-resolved + // id as two columns. MUTATION: fold fieldId into equals/hashCode -> these two become unequal -> red. + HudiColumnHandle a = new HudiColumnHandle("c", "int", false, 7); + HudiColumnHandle b = new HudiColumnHandle("c", "int", false, ConnectorColumn.UNSET_UNIQUE_ID); + Assertions.assertEquals(a, b); + Assertions.assertEquals(a.hashCode(), b.hashCode()); + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorOwnsHandleTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorOwnsHandleTest.java new file mode 100644 index 00000000000000..6cf314933361ee --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorOwnsHandleTest.java @@ -0,0 +1,76 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hudi; + +import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.spi.ConnectorContext; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.Set; + +/** + * Pins {@link HudiConnector#ownsHandle} for the hms 3-way sibling routing: a flipped hms gateway embeds this + * connector as a sibling and asks it "is this foreign handle yours?" to route a scan/metadata call, because the + * concrete handle type is invisible across the plugin classloader split. + * + *

ownsHandle must be TRUE for this connector's own {@link HudiTableHandle} and FALSE for any other connector's + * handle (e.g. an iceberg sibling's), so the gateway routes correctly. Dormant until hms enters + * {@code SPI_READY_TYPES}: no production path asks this connector yet, so this is a Rule-9 routing guard. + */ +public class HudiConnectorOwnsHandleTest { + + private static HudiConnector connector() { + return new HudiConnector(Collections.emptyMap(), new ConnectorContext() { + @Override + public String getCatalogName() { + return "test_catalog"; + } + + @Override + public long getCatalogId() { + return 1L; + } + }); + } + + @Test + public void ownsHandleOnlyForHudiTableHandle() { + // MUTATION: returning true unconditionally -> the gateway sends iceberg handles here -> red. + HudiConnector connector = connector(); + Assertions.assertTrue(connector.ownsHandle(new HudiTableHandle("db", "t", "s3://b/t", "COPY_ON_WRITE")), + "a HudiTableHandle is owned by the hudi connector"); + Assertions.assertFalse(connector.ownsHandle(new ConnectorTableHandle() { + }), "a foreign (non-hudi) handle is NOT owned by the hudi connector"); + } + + @Test + public void declaresMetadataTableCapabilityForTimeline() { + // WHY: the hudi_meta() / TIMELINE TVF's plugin-driven arm delegates only when the connector declares + // SUPPORTS_METADATA_TABLE (read via PluginDrivenExternalTable.hasScanCapability), and the hive gateway + // reflects it onto a hudi-on-HMS table's schema so hudi_meta keeps working through the sibling delegation. + // Every hudi table has a commit timeline, so it is connector-wide. MUTATION: dropping the capability -> a + // flipped hudi table's hudi_meta() returns "not a hudi table". + Set caps = connector().getCapabilities(); + Assertions.assertTrue(caps.contains(ConnectorCapability.SUPPORTS_METADATA_TABLE), + "hudi declares the metadata-table capability so hudi_meta() works post-flip"); + } +} 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 new file mode 100644 index 00000000000000..a6693234d15006 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorPartitionListingTest.java @@ -0,0 +1,430 @@ +// 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.connector.api.ConnectorPartitionInfo; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsDatabaseInfo; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsTableInfo; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.concurrent.Callable; + +/** + * Tests the Hudi MVCC / listPartitions / freshness surface added so a PARTITIONED hudi-on-HMS table, served + * post-flip through the GENERIC {@code PluginDrivenScanNode} path (like paimon), has a correct partition + + * MVCC-snapshot surface. Each assertion pins WHY the behavior matters: + *

    + *
  • the query-begin pin is a snapshot-id (instant) freshness, NOT a last-modified one (paimon model);
  • + *
  • per-partition names are HIVE-STYLE so the generic model's {@code HiveUtil.toPartitionValues} re-parse + * matches the partition-column arity (a raw positional name silently degrades to UNPARTITIONED);
  • + *
  • {@code lastModifiedMillis} is the pinned instant (a stable freshness marker), never {@code -1};
  • + *
  • the partition-name SOURCE follows {@code use_hive_sync_partition} (HMS vs hudi metadata);
  • + *
  • the dead-code MVCC/freshness seams are NOT overridden (SPI defaults hold).
  • + *
+ */ +public class HudiConnectorPartitionListingTest { + + private static final List YEAR_MONTH = Arrays.asList("year", "month"); + + // ── item 1: instant string → long (pure) ────────────────────────────────────────────────────────── + + @Test + public void requestedTimeParsesToLong() { + Assertions.assertEquals(20240101120000000L, + HudiScanPlanProvider.requestedTimeToInstant(Optional.of("20240101120000000"))); + } + + @Test + public void emptyTimelinePinsZero() { + // Empty timeline pins 0L (>= 0 so it survives getNewestUpdateVersionOrTime's v>=0 filter), NOT -1. + Assertions.assertEquals(0L, HudiScanPlanProvider.requestedTimeToInstant(Optional.empty())); + } + + // ── item 2: hive-style NAME rendering + arity round-trip ─────────────────────────────────────────── + + @Test + public void positionalPathRendersHiveStyleWithCorrectArity() { + // Hudi's DEFAULT layout is positional "2024/01" with NO "col=" prefix. Rendering MUST inject the keys + // so the generic re-parse yields exactly partKeys.size() values (else checkState throws -> the + // partition is dropped -> silent UNPARTITIONED degrade). + String name = render("2024/01", YEAR_MONTH); + Assertions.assertEquals("year=2024/month=01", name); + assertRoundTrips(name, YEAR_MONTH, Arrays.asList("2024", "01")); + } + + @Test + public void singleColumnPositionalPathRendersOneSegment() { + // Single partition column with a bare value: size 1, not 0. + String name = render("2024", Collections.singletonList("dt")); + Assertions.assertEquals("dt=2024", name); + assertRoundTrips(name, Collections.singletonList("dt"), Collections.singletonList("2024")); + } + + @Test + public void hiveStylePathIsRenderedIdempotently() { + String name = render("year=2024/month=01", YEAR_MONTH); + Assertions.assertEquals("year=2024/month=01", name); + assertRoundTrips(name, YEAR_MONTH, Arrays.asList("2024", "01")); + } + + @Test + public void onDiskEscapedValueRoundTripsToRealValue() { + // A value escaped on disk ("a%20b" = "a b") parses to the real value and the rendered name re-parses + // back to it. (Space is not in Hive's escape set, so the rendered value carries a literal space.) + List dt = Collections.singletonList("dt"); + Assertions.assertEquals("a b", + HudiScanPlanProvider.parsePartitionValues("dt=a%20b", dt).get("dt")); + String name = render("dt=a%20b", dt); + Assertions.assertEquals("dt=a b", name); + assertRoundTrips(name, dt, Collections.singletonList("a b")); + } + + @Test + public void singleColumnSlashValueIsEscapedSoItRoundTrips() { + // THE bug this guards: a single partition column whose value spans '/' (e.g. TimestampBasedKeyGenerator + // OutputDateFormat=yyyy/MM/dd -> path "2024/01/02"). The value is the WHOLE path; without escaping the + // '/', HiveUtil.toPartitionValues would truncate it to "2024". Escaping to "%2F" makes it round-trip. + List dt = Collections.singletonList("dt"); + Assertions.assertEquals("2024/01/02", + HudiScanPlanProvider.parsePartitionValues("2024/01/02", dt).get("dt")); + String name = render("2024/01/02", dt); + Assertions.assertEquals("dt=2024%2F01%2F02", name); + assertRoundTrips(name, dt, Collections.singletonList("2024/01/02")); + } + + @Test + public void distinctSlashValuedPartitionsDoNotCollide() { + // Two distinct single-column slash paths must render distinct names AND re-parse to distinct values — + // else the generic model collapses them onto one partition key, corrupting MTMV per-partition tracking. + List dt = Collections.singletonList("dt"); + String a = render("2024/01/02", dt); + String b = render("2024/03/04", dt); + Assertions.assertNotEquals(a, b); + assertRoundTrips(a, dt, Collections.singletonList("2024/01/02")); + assertRoundTrips(b, dt, Collections.singletonList("2024/03/04")); + } + + // ── item 3/4: buildPartitionInfos + listPartitions/Names/Values ──────────────────────────────────── + + @Test + public void buildPartitionInfosStampsInstantAndValues() { + List infos = HudiConnectorMetadata.buildPartitionInfos( + Arrays.asList("2024/01", "2024/02"), YEAR_MONTH, 20240101120000000L); + + 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()); + Assertions.assertNotEquals(ConnectorPartitionInfo.UNKNOWN, first.getLastModifiedMillis()); + // row/size/file counts stay UNKNOWN (not collected on the hot path). + Assertions.assertEquals(ConnectorPartitionInfo.UNKNOWN, first.getRowCount()); + Assertions.assertEquals(ConnectorPartitionInfo.UNKNOWN, first.getSizeBytes()); + Assertions.assertEquals(ConnectorPartitionInfo.UNKNOWN, first.getFileCount()); + } + + @Test + public void listPartitionNamesMatchesListPartitions() { + HudiConnectorMetadata md = metadata(false, + new RecordingHmsClient(null), stub(new AbstractMap.SimpleImmutableEntry<>( + 99L, Arrays.asList("2024/01", "2024/02")))); + ConnectorTableHandle handle = partitioned(); + + List names = md.listPartitionNames(null, handle); + List parts = md.listPartitions(null, handle, Optional.empty()); + + Assertions.assertEquals(Arrays.asList("year=2024/month=01", "year=2024/month=02"), names); + Assertions.assertEquals(names.size(), parts.size()); + for (int i = 0; i < names.size(); i++) { + Assertions.assertEquals(names.get(i), parts.get(i).getPartitionName()); + } + } + + @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), + new DirectHudiMetaClientExecutor()); + ConnectorTableHandle handle = new HudiTableHandle.Builder("db", "t", "s3://b/t", "COPY_ON_WRITE") + .partitionKeyNames(Collections.emptyList()).build(); + Assertions.assertTrue(md.listPartitions(null, handle, Optional.empty()).isEmpty()); + Assertions.assertTrue(md.listPartitionNames(null, handle).isEmpty()); + } + + // ── item 5: use_hive_sync_partition source selection ─────────────────────────────────────────────── + + @Test + public void hiveSyncTableListsPartitionsFromHms() { + RecordingHmsClient hms = new RecordingHmsClient(Arrays.asList("year=2024/month=01", "year=2024/month=02")); + // Stub returns the instant (a Long) for the timeline call; the partition NAMES must come from HMS. + HudiConnectorMetadata md = metadata(true, hms, stub(7L)); + + List names = md.listPartitionNames(null, partitioned()); + + Assertions.assertEquals(Arrays.asList("year=2024/month=01", "year=2024/month=02"), names); + Assertions.assertTrue(hms.listPartitionNamesCalled, "hive-sync must list partitions from HMS"); + } + + @Test + public void hiveSyncWithEmptyHmsFallsBackToMetaClientListing() { + // A hive-sync table not yet synced to HMS: empty HMS must fall through to the hudi metadata listing + // (the prune-to-zero guard), stamped with the metaClient instant — NOT return zero partitions. + RecordingHmsClient hms = new RecordingHmsClient(Collections.emptyList()); + HudiConnectorMetadata md = metadata(true, hms, + stub(new AbstractMap.SimpleImmutableEntry<>(5L, Collections.singletonList("2024/01")))); + + List parts = md.listPartitions(null, partitioned(), Optional.empty()); + + Assertions.assertTrue(hms.listPartitionNamesCalled, "hive-sync must first consult HMS"); + Assertions.assertEquals(1, parts.size()); + Assertions.assertEquals("year=2024/month=01", parts.get(0).getPartitionName()); + Assertions.assertEquals(5L, parts.get(0).getLastModifiedMillis()); + } + + @Test + public void nonHiveSyncTableNeverConsultsHms() { + RecordingHmsClient hms = new RecordingHmsClient(null); // throws if listPartitionNames is called + HudiConnectorMetadata md = metadata(false, hms, + stub(new AbstractMap.SimpleImmutableEntry<>(88L, Collections.singletonList("2024/01")))); + + List parts = md.listPartitions(null, partitioned(), Optional.empty()); + + Assertions.assertEquals(1, parts.size()); + Assertions.assertEquals("year=2024/month=01", parts.get(0).getPartitionName()); + Assertions.assertEquals(88L, parts.get(0).getLastModifiedMillis()); + Assertions.assertFalse(hms.listPartitionNamesCalled, "non-hive-sync must NOT consult HMS"); + } + + // ── item 6: beginQuerySnapshot ───────────────────────────────────────────────────────────────────── + + @Test + public void beginQuerySnapshotPinsInstantWithoutLastModifiedFlag() { + Optional snapshot = + HudiConnectorMetadata.buildBeginQuerySnapshot(20240101120000000L); + Assertions.assertTrue(snapshot.isPresent()); + Assertions.assertEquals(20240101120000000L, snapshot.get().getSnapshotId()); + // The one bit that separates hudi (snapshot-id) from hive (last-modified): MUST stay false, else the + // generic model would route hudi to the on-demand getTableFreshness probe (which hudi does not provide). + Assertions.assertFalse(snapshot.get().isLastModifiedFreshness()); + // Time travel is a later step: schemaId stays default (-1 => latest schema). + Assertions.assertEquals(-1L, snapshot.get().getSchemaId()); + } + + @Test + public void beginQuerySnapshotOverrideThreadsInstantIntoPin() { + // Drive the ACTUAL SPI override (not just the static helper): the stub executor returns the instant + // latestInstant would read off the timeline, so the override must thread it into the pin unchanged and + // leave lastModifiedFreshness false. Guards a mutation of the override body to empty / a wrong instant. + HudiConnectorMetadata md = metadata(false, new RecordingHmsClient(null), stub(20240101120000000L)); + Optional snapshot = md.beginQuerySnapshot(null, partitioned()); + Assertions.assertTrue(snapshot.isPresent()); + Assertions.assertEquals(20240101120000000L, snapshot.get().getSnapshotId()); + Assertions.assertFalse(snapshot.get().isLastModifiedFreshness()); + } + + // ── item 7: dead-code guard (SPI defaults hold) ──────────────────────────────────────────────────── + + @Test + public void mvccPartitionViewAndFreshnessSeamsAreNotOverridden() { + HudiConnectorMetadata md = metadata(false, new RecordingHmsClient(null), + new DirectHudiMetaClientExecutor()); + ConnectorTableHandle handle = partitioned(); + // A snapshot-id connector must NOT provide a range view (that would flip getPartitionSnapshot to the + // MTMVSnapshotIdSnapshot branch) nor the last-modified freshness probes (dead code under flag=false). + Assertions.assertFalse(md.getMvccPartitionView(null, handle).isPresent()); + Assertions.assertFalse(md.getTableFreshness(null, handle).isPresent()); + Assertions.assertEquals(OptionalLong.empty(), + md.getPartitionFreshnessMillis(null, handle, "year=2024/month=01")); + } + + // ── helpers ──────────────────────────────────────────────────────────────────────────────────────── + + private static HudiTableHandle partitioned() { + return new HudiTableHandle.Builder("db", "t", "s3://b/t", "COPY_ON_WRITE") + .partitionKeyNames(YEAR_MONTH).build(); + } + + private static HudiConnectorMetadata metadata(boolean useHiveSync, HmsClient hms, + HudiMetaClientExecutor executor) { + Map props = useHiveSync + ? Collections.singletonMap("use_hive_sync_partition", "true") + : Collections.emptyMap(); + return new HudiConnectorMetadata(hms, props, executor); + } + + /** Executor that ignores the action and returns a canned value (stubs out the live metaClient). */ + private static HudiMetaClientExecutor stub(Object cannedReturn) { + return new HudiMetaClientExecutor() { + @Override + @SuppressWarnings("unchecked") + public T execute(Callable action) { + return (T) cannedReturn; + } + }; + } + + /** Renders the hive-style name for a raw partition path the way buildPartitionInfos does (parse then render). */ + private static String render(String rawPath, List partKeys) { + return HudiScanPlanProvider.renderHiveStylePartitionName( + partKeys, HudiScanPlanProvider.parsePartitionValues(rawPath, partKeys)); + } + + /** Asserts the rendered name re-parses (fe-core-style) to exactly the expected values, correct arity. */ + private static void assertRoundTrips(String name, List partKeys, List expectedValues) { + List reparsed = hiveToPartitionValues(name); + Assertions.assertEquals(partKeys.size(), reparsed.size(), + "re-parsed value count must equal the partition-column count (else checkState throws)"); + Assertions.assertEquals(expectedValues, reparsed); + } + + /** + * Local mirror of fe-core {@code HiveUtil.toPartitionValues} (cannot import fe-core from a connector test): + * the generic model re-parses the rendered name EXACTLY this way under + * {@code checkState(values.size() == types.size())}. + */ + private static List hiveToPartitionValues(String partitionName) { + List result = new ArrayList<>(); + int start = 0; + while (true) { + while (start < partitionName.length() && partitionName.charAt(start) != '=') { + start++; + } + start++; + int end = start; + while (end < partitionName.length() && partitionName.charAt(end) != '/') { + end++; + } + if (start > partitionName.length()) { + break; + } + result.add(unescape(partitionName.substring(start, end))); + start = end + 1; + } + return result; + } + + private static String unescape(String path) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + if (c == '%' && i + 2 < path.length()) { + int code; + try { + code = Integer.parseInt(path.substring(i + 1, i + 3), 16); + } catch (Exception e) { + code = -1; + } + if (code >= 0) { + sb.append((char) code); + i += 2; + continue; + } + } + sb.append(c); + } + return sb.toString(); + } + + /** Minimal {@link HmsClient} double: records whether listPartitionNames was called; the rest fail loud. */ + private static final class RecordingHmsClient implements HmsClient { + private final List partitionNames; + private boolean listPartitionNamesCalled; + + RecordingHmsClient(List partitionNames) { + this.partitionNames = partitionNames; + } + + @Override + public List listPartitionNames(String dbName, String tableName, int maxParts) { + listPartitionNamesCalled = true; + if (partitionNames == null) { + throw new UnsupportedOperationException("listPartitionNames must not be called here"); + } + return partitionNames; + } + + @Override + public List listDatabases() { + throw new UnsupportedOperationException(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public List listTables(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public 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() { + } + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorPluginAuthenticatorTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorPluginAuthenticatorTest.java new file mode 100644 index 00000000000000..06a084cde4cdb5 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorPluginAuthenticatorTest.java @@ -0,0 +1,107 @@ +// 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.kerberos.HadoopAuthenticator; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Unit tests for {@link HudiConnector#buildPluginAuthenticator(Map)} — the connector-owned plugin-side Kerberos + * authenticator resolution for the hudi sibling. Mirrors {@code HiveConnectorPluginAuthenticatorTest}. + * + *

The load-bearing case is HMS-metastore Kerberos with simple (non-Kerberos) storage (e.g. a + * Kerberized Hive Metastore over S3). After the catalog flip the hudi sibling shares the hms gateway's + * FE-injected {@code ConnectorContext}, whose {@code executeAuthenticated} resolves to NOOP (SIMPLE) auth, so a + * Kerberos HMS would be silently downgraded unless the connector owns the login itself. A hudi sibling runs in + * its OWN classloader, so it must build its OWN authenticator (sharing the gateway's hive-loader authenticator + * would split the UGI copy across loaders). + * + *

The actual keytab login is lazy (on first {@code doAs}), so these assertions never touch a KDC. + */ +public class HudiConnectorPluginAuthenticatorTest { + + private static Map props(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + /** Storage-level Kerberos (raw hadoop.security.authentication) — the prior behavior, still honored. */ + @Test + public void storageKerberosBuildsAuthenticator() { + HadoopAuthenticator auth = HudiConnector.buildPluginAuthenticator( + props("hive.metastore.uris", "thrift://hms:9083", + "hadoop.security.authentication", "kerberos", + "hadoop.kerberos.principal", "doris@EXAMPLE.COM", + "hadoop.kerberos.keytab", "/etc/security/doris.keytab")); + Assertions.assertNotNull(auth, "storage kerberos must yield a plugin authenticator"); + } + + /** + * THE BLOCKER CASE: a Kerberized HMS whose data storage is simple. Storage auth is unset, so the storage + * gate is off; the connector must fall back to the HMS client-principal/keytab facts and still build a + * plugin authenticator (mirroring the fe-core HMS authenticator it replaces). Without this a Kerberized + * hudi-on-HMS table would silently downgrade to SIMPLE at the flip. + */ + @Test + public void hmsMetastoreKerberosWithSimpleStorageBuildsAuthenticator() { + HadoopAuthenticator auth = HudiConnector.buildPluginAuthenticator( + props("hive.metastore.uris", "thrift://hms:9083", + "hive.metastore.authentication.type", "kerberos", + "hive.metastore.client.principal", "doris@EXAMPLE.COM", + "hive.metastore.client.keytab", "/etc/security/doris.keytab")); + Assertions.assertNotNull(auth, + "HMS-metastore kerberos with simple storage must yield a plugin authenticator"); + } + + /** A simple-auth HMS builds no authenticator (a spurious one would force needless SIMPLE-vs-Kerberos churn). */ + @Test + public void hmsSimpleAuthReturnsNull() { + HadoopAuthenticator auth = HudiConnector.buildPluginAuthenticator( + props("hive.metastore.uris", "thrift://hms:9083", + "hive.metastore.authentication.type", "simple")); + Assertions.assertNull(auth, "simple-auth HMS must not build a plugin authenticator"); + } + + /** A plain HMS with no auth configured builds no authenticator. */ + @Test + public void plainHmsWithoutKerberosReturnsNull() { + HadoopAuthenticator auth = HudiConnector.buildPluginAuthenticator( + props("hive.metastore.uris", "thrift://hms:9083")); + Assertions.assertNull(auth, "plain HMS without kerberos must not build an authenticator"); + } + + /** + * HMS declares kerberos auth-type but the client principal/keytab are blank — the {@code hasCredentials} + * guard must reject it (an authenticator with no login pair would fail obscurely at first doAs). + */ + @Test + public void hmsKerberosWithBlankCredsReturnsNull() { + HadoopAuthenticator auth = HudiConnector.buildPluginAuthenticator( + props("hive.metastore.uris", "thrift://hms:9083", + "hive.metastore.authentication.type", "kerberos")); + Assertions.assertNull(auth, "kerberos HMS without a client principal/keytab pair must not build one"); + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiForceJniTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiForceJniTest.java new file mode 100644 index 00000000000000..34452f148afa10 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiForceJniTest.java @@ -0,0 +1,162 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hudi; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.thrift.TFileFormatType; +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.Map; + +/** + * Tests the {@code force_jni_scanner} escape hatch (legacy {@code HudiScanNode.canUseNativeReader()} / + * {@code setScanParams} parity): reading the session flag and suppressing the no-delta-log native downgrade so a + * native-eligible slice still reads via the JNI reader. + */ +public class HudiForceJniTest { + + @Test + public void sessionFlagTrueEnablesForceJni() { + // Pins the EXACT session key ("force_jni_scanner", same key the paimon connector reads and byte-identical + // to SessionVariable.FORCE_JNI_SCANNER). MUTATION: wrong key -> red. + Assertions.assertTrue(HudiScanPlanProvider.isForceJniScannerEnabled( + sessionWithProps(Collections.singletonMap("force_jni_scanner", "true")))); + } + + @Test + public void sessionFlagUnsetDefaultsFalse() { + // Default false (legacy default): normal reads must be unaffected. MUTATION: defaulting true -> red. + Assertions.assertFalse(HudiScanPlanProvider.isForceJniScannerEnabled( + sessionWithProps(Collections.emptyMap()))); + } + + @Test + public void sessionFlagExplicitFalseIsFalse() { + Assertions.assertFalse(HudiScanPlanProvider.isForceJniScannerEnabled( + sessionWithProps(Collections.singletonMap("force_jni_scanner", "false")))); + } + + @Test + public void nullSessionDefaultsFalse() { + Assertions.assertFalse(HudiScanPlanProvider.isForceJniScannerEnabled(null)); + } + + @Test + public void forceJniSuppressesNoDeltaLogNativeDowngrade() { + // A no-delta-log slice with a parquet base file that would normally downgrade to the native reader must + // STAY on JNI when force_jni was engaged at plan time. This is the escape hatch's whole point. + HudiScanRange range = noDeltaLogParquetRange(true); + + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(formatDesc, rangeDesc); + + Assertions.assertEquals(TFileFormatType.FORMAT_JNI, rangeDesc.getFormatType(), + "force_jni must keep the slice on the JNI reader (explicit FORMAT_JNI), not the native reader"); + Assertions.assertTrue(formatDesc.getHudiParams().isSetColumnNames(), + "JNI fileDesc fields must be set when the slice stays on JNI"); + Assertions.assertEquals("20240101000000000", formatDesc.getHudiParams().getInstantTime()); + } + + @Test + public void withoutForceJniNoDeltaLogDowngradesToNative() { + // The paired contrast: SAME inputs, force_jni off -> the no-log slice downgrades to native parquet and + // sets no JNI fields. Together these pin that the ONLY difference is the force_jni flag. + HudiScanRange range = noDeltaLogParquetRange(false); + + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(formatDesc, rangeDesc); + + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, rangeDesc.getFormatType(), + "without force_jni a no-log parquet slice downgrades to the native reader"); + Assertions.assertFalse(formatDesc.getHudiParams().isSetColumnNames(), + "native downgrade must not set JNI fileDesc fields"); + } + + private static HudiScanRange noDeltaLogParquetRange(boolean forceJni) { + return new HudiScanRange.Builder() + .path("s3://bucket/t/base.parquet") + .fileFormat("jni") + .instantTime("20240101000000000") + .serde("serde") + .inputFormat("fmt") + .basePath("s3://bucket/t") + .dataFilePath("s3://bucket/t/base.parquet") + .dataFileLength(456L) + .columnNames(Arrays.asList("x")) + .columnTypes(Arrays.asList("int")) + .forceJni(forceJni) + .build(); + } + + private static ConnectorSession sessionWithProps(Map sessionProps) { + return new ConnectorSession() { + @Override + public String getQueryId() { + return "q"; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public String getCatalogName() { + return "c"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + + @Override + public Map getSessionProperties() { + return sessionProps; + } + }; + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiIncrementalPlanScanTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiIncrementalPlanScanTest.java new file mode 100644 index 00000000000000..8d3a0040da03ec --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiIncrementalPlanScanTest.java @@ -0,0 +1,276 @@ +// 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.connector.api.scan.ConnectorScanRange; + +import org.apache.hudi.common.model.FileSlice; +import org.apache.hudi.common.model.HoodieBaseFile; +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; +import java.util.Map; +import java.util.Optional; +import java.util.function.UnaryOperator; + +/** + * Tests the OFFLINE-verifiable core of the INC-3 incremental {@code planScan} wiring: the pure static helpers + * {@link HudiScanPlanProvider#incrementalRanges} (COW/MOR routing + fallback degrade) and + * {@link HudiScanPlanProvider#buildMorRange} (file-slice → JNI range mapping). Both are exercised without a + * live {@code HoodieTableMetaClient} — {@code incrementalRanges} via a FAKE {@link IncrementalRelation}, and + * {@code buildMorRange} via a hand-built {@link FileSlice}. + * + *

Coverage scope (Rule 12 — no over-claim). These cover the routing + mapping DECISIONS only. Building + * the real relation (which does eager timeline/metadata/filesystem I/O) and the actual file SELECTION + * (commit-range, write-stat mapping, {@code fs.exists} full-table-scan probes, the completion-time END axis) are + * e2e-only (design §5) — same boundary as {@link HudiIncrementalRelationTest}. Row-level filtering of the read to + * the {@code (begin, end]} window is a LATER step (an FE-side synthetic {@code _hoodie_commit_time} predicate), + * NOT this file-selection step. + */ +public class HudiIncrementalPlanScanTest { + + private static final List YEAR_MONTH = Arrays.asList("year", "month"); + private static final String END_TS = "20240101120000"; + private static final String BASE_PATH = "s3://b/t"; + private static final String INPUT_FORMAT = "org.apache.hudi.hadoop.realtime.HoodieParquetRealtimeInputFormat"; + private static final String SERDE = "org.apache.hudi.hadoop.realtime.HoodieParquetRealtimeInputFormat"; + + // ── fallback → degrade (Optional.empty), collect* NOT called ───────────────────────────────────────────── + + @Test + public void fallbackFullTableScanDegradesToSnapshotScanWithoutCollecting() { + // relation.fallbackFullTableScan()==true must yield Optional.empty() = the caller degrades to the normal + // latest-snapshot scan (legacy HudiScanNode.getSplits:470), and NEITHER collect method is invoked (both + // throw to prove they are not called). Kills a mutation inverting the fallback check. + FakeRelation fallback = new FakeRelation(); + fallback.fallback = true; + fallback.collectSplitsThrows = true; + fallback.collectFileSlicesThrows = true; + Optional> result = HudiScanPlanProvider.incrementalRanges( + fallback, /*isCow*/ true, /*forceJni*/ false, BASE_PATH, INPUT_FORMAT, SERDE, + Collections.emptyList(), Collections.emptyList(), YEAR_MONTH, UnaryOperator.identity()); + Assertions.assertFalse(result.isPresent(), + "fallbackFullTableScan must signal degrade to the snapshot scan (Optional.empty)"); + } + + // ── COW → collectSplits (native ranges returned verbatim); force_jni IGNORED for COW ───────────────────── + + @Test + public void cowRoutesToCollectSplitsAndNeverCallsCollectFileSlices() { + // A COW incremental read returns the relation's native collectSplits() ranges directly and must NEVER + // call collectFileSlices() (which throws on a COW relation = the shape contract). Kills a mutation that + // routes COW through the MOR branch (which would reproduce the legacy UnsupportedOperationException crash). + HudiScanRange native1 = new HudiScanRange.Builder().path("s3://b/t/f1.parquet").fileFormat("parquet").build(); + FakeRelation cow = new FakeRelation(); + cow.splits = Collections.singletonList(native1); + cow.collectFileSlicesThrows = true; + Optional> result = HudiScanPlanProvider.incrementalRanges( + cow, /*isCow*/ true, /*forceJni*/ false, BASE_PATH, INPUT_FORMAT, SERDE, + Collections.emptyList(), Collections.emptyList(), YEAR_MONTH, UnaryOperator.identity()); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals(1, result.get().size()); + Assertions.assertSame(native1, result.get().get(0), "COW ranges must be the relation's collectSplits output"); + } + + @Test + public void cowIncrementalIgnoresForceJniAndStaysNative() { + // The signed graceful deviation: under force_jni a COW incremental read STILL reads native (collectSplits), + // instead of legacy's route to collectFileSlices() on a COW relation → UnsupportedOperationException. So + // force_jni=true must NOT change COW routing. collectFileSlices throws to prove it is not taken. + HudiScanRange native1 = new HudiScanRange.Builder().path("s3://b/t/f1.parquet").fileFormat("parquet").build(); + FakeRelation cow = new FakeRelation(); + cow.splits = Collections.singletonList(native1); + cow.collectFileSlicesThrows = true; + Optional> result = HudiScanPlanProvider.incrementalRanges( + cow, /*isCow*/ true, /*forceJni*/ true, BASE_PATH, INPUT_FORMAT, SERDE, + Collections.emptyList(), Collections.emptyList(), YEAR_MONTH, UnaryOperator.identity()); + Assertions.assertTrue(result.isPresent()); + Assertions.assertSame(native1, result.get().get(0), + "COW incremental must stay native even under force_jni (never call collectFileSlices)"); + } + + // ── MOR → collectFileSlices mapped to JNI ranges at endTs; collectSplits NEVER called ──────────────────── + + @Test + public void morRoutesToCollectFileSlicesMappedAtEndTs() { + // A MOR incremental read maps each collectFileSlices() slice to a JNI range at the resolved window END + // (relation.getEndTs()), with per-slice partition values from the slice's OWN partition path. It must + // NEVER call collectSplits() (which throws on a MOR relation). Uses force_jni so a base-only (no-log) + // slice stays on the JNI reader, exercising the instantTime=endTs stamping. + FileSlice slice = baseOnlySlice("year=2024/month=01", + "s3://b/t/year=2024/month=01/fileid-1_0_20240101000000.parquet"); + FakeRelation mor = new FakeRelation(); + mor.fileSlices = Collections.singletonList(slice); + mor.collectSplitsThrows = true; + Optional> result = HudiScanPlanProvider.incrementalRanges( + mor, /*isCow*/ false, /*forceJni*/ true, BASE_PATH, INPUT_FORMAT, SERDE, + Arrays.asList("c1"), Arrays.asList("int"), YEAR_MONTH, UnaryOperator.identity()); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals(1, result.get().size()); + HudiScanRange range = (HudiScanRange) result.get().get(0); + Assertions.assertEquals("jni", range.getFileFormat(), "force_jni keeps the no-log MOR slice on JNI"); + Assertions.assertEquals(END_TS, range.getProperties().get("hudi.instant_time"), + "the JNI merge instant must be the resolved window END (getEndTs), not the latest instant"); + Map partValues = range.getPartitionValues(); + Assertions.assertEquals("2024", partValues.get("year")); + Assertions.assertEquals("01", partValues.get("month"), + "partition values must be parsed from the slice's own partition path against the table-config fields"); + } + + @Test + public void morEmptySliceListYieldsEmptyRangesButTakesMorBranch() { + // A MOR relation with no slices returns Optional.of([]) — present (not degrade) but empty. collectSplits + // throws to prove the MOR (collectFileSlices) branch was taken, not the COW branch. + FakeRelation mor = new FakeRelation(); + mor.fileSlices = Collections.emptyList(); + mor.collectSplitsThrows = true; + Optional> result = HudiScanPlanProvider.incrementalRanges( + mor, /*isCow*/ false, /*forceJni*/ false, BASE_PATH, INPUT_FORMAT, SERDE, + Collections.emptyList(), Collections.emptyList(), YEAR_MONTH, UnaryOperator.identity()); + Assertions.assertTrue(result.isPresent()); + Assertions.assertTrue(result.get().isEmpty()); + } + + // ── buildMorRange mapping (hand-built FileSlice) ───────────────────────────────────────────────────────── + + @Test + public void buildMorRangeStampsJniInstantAndMetadataForForceJniSlice() { + // buildMorRange on a base-only slice with force_jni: a JNI range carrying instantTime=jniInstant + serde + + // input format + base path + column lists. Pins the exact JNI metadata BE needs. + FileSlice slice = baseOnlySlice("year=2024/month=01", + "s3://b/t/year=2024/month=01/fileid-1_0_20240101000000.parquet"); + Map partValues = HudiScanPlanProvider.parsePartitionValues( + slice.getPartitionPath(), YEAR_MONTH); + HudiScanRange range = HudiScanPlanProvider.buildMorRange(slice, partValues, END_TS, /*forceJni*/ true, + BASE_PATH, INPUT_FORMAT, SERDE, Arrays.asList("c1"), Arrays.asList("int"), p -> 7L, + UnaryOperator.identity()); + Assertions.assertEquals("jni", range.getFileFormat()); + Assertions.assertEquals(END_TS, range.getProperties().get("hudi.instant_time")); + Assertions.assertEquals(SERDE, range.getProperties().get("hudi.serde")); + Assertions.assertEquals(BASE_PATH, range.getProperties().get("hudi.base_path")); + // C4c: a JNI slice NEVER carries schema_id even when the resolver returns one (native field-id path only). + Assertions.assertNull(range.getProperties().get("hudi.schema_id")); + } + + @Test + public void buildMorRangeDowngradesNoLogSliceToNativeWithoutForceJni() { + // A base-only (no delta log) slice WITHOUT force_jni reads natively: format from the base file suffix, and + // NO JNI instant metadata (the native reader needs only the path). Guards the native downgrade parity. + FileSlice slice = baseOnlySlice("year=2024/month=01", + "s3://b/t/year=2024/month=01/fileid-1_0_20240101000000.parquet"); + HudiScanRange range = HudiScanPlanProvider.buildMorRange(slice, Collections.emptyMap(), END_TS, + /*forceJni*/ false, BASE_PATH, INPUT_FORMAT, SERDE, + Arrays.asList("c1"), Arrays.asList("int"), p -> 7L, UnaryOperator.identity()); + Assertions.assertEquals("parquet", range.getFileFormat(), + "a no-log slice without force_jni must downgrade to the native parquet reader"); + // C4c: a native downgraded slice carries the per-file schema_id from the resolver (native field-id path). + Assertions.assertEquals("7", range.getProperties().get("hudi.schema_id")); + } + + // ── native path scheme normalization (FIX-hudi-s3a-native-scheme) ──────────────────────────────────────── + + @Test + public void cowIncrementalNormalizesNativePathScheme() { + // The COW @incr branch must thread incrementalRanges' normalizer into relation.collectSplits so the native + // range path is rewritten s3a->s3 for BE's native S3 reader (which rejects s3a). Kills a mutation that drops + // the normalizer or passes identity: the range path would stay s3a:// and BE would throw "Invalid S3 URI". + FakeRelation cow = new FakeRelation(); + cow.rawCowPath = "s3a://datalake/warehouse/t/f1.parquet"; + cow.collectFileSlicesThrows = true; + Optional> result = HudiScanPlanProvider.incrementalRanges( + cow, /*isCow*/ true, /*forceJni*/ false, BASE_PATH, INPUT_FORMAT, SERDE, + Collections.emptyList(), Collections.emptyList(), YEAR_MONTH, + s -> s.replace("s3a://", "s3://")); + Assertions.assertTrue(result.isPresent()); + HudiScanRange range = (HudiScanRange) result.get().get(0); + Assertions.assertEquals("s3://datalake/warehouse/t/f1.parquet", range.getPath().orElse(null), + "COW @incr native range path must be scheme-normalized (s3a->s3) via the threaded normalizer"); + } + + @Test + public void buildMorRangeNormalizesNativeNoLogSlicePathScheme() { + // A no-log MOR slice reads native; buildMorRange must apply the normalizer to the base-file path (agencyPath) + // so BE's native reader gets s3://, not the raw s3a:// from the Hudi SDK. The JNI metadata paths are a + // separate concern (this slice reads native, so none are set). + FileSlice slice = baseOnlySlice("year=2024/month=01", + "s3a://datalake/warehouse/t/year=2024/month=01/fileid-1_0_20240101000000.parquet"); + HudiScanRange range = HudiScanPlanProvider.buildMorRange(slice, Collections.emptyMap(), END_TS, + /*forceJni*/ false, BASE_PATH, INPUT_FORMAT, SERDE, + Arrays.asList("c1"), Arrays.asList("int"), null, s -> s.replace("s3a://", "s3://")); + Assertions.assertEquals("s3://datalake/warehouse/t/year=2024/month=01/fileid-1_0_20240101000000.parquet", + range.getPath().orElse(null), + "a native no-log MOR slice's range path must be scheme-normalized (s3a->s3) by buildMorRange"); + } + + // ── helpers ──────────────────────────────────────────────────────────────────────────────────────────── + + /** A MOR file slice with a single base file and no delta logs. */ + private static FileSlice baseOnlySlice(String partitionPath, String baseFilePath) { + FileSlice slice = new FileSlice(partitionPath, "20240101000000", "fileid-1"); + slice.setBaseFile(new HoodieBaseFile(baseFilePath)); + return slice; + } + + /** A recording fake {@link IncrementalRelation}: canned outputs, throws on the shape it should not serve. */ + private static final class FakeRelation implements IncrementalRelation { + private List splits = new ArrayList<>(); + private List fileSlices = new ArrayList<>(); + private boolean fallback; + private String endTs = END_TS; + private boolean collectSplitsThrows; + private boolean collectFileSlicesThrows; + // When set, collectSplits builds ONE native range whose path is the normalizer applied to this raw URI — + // stands in for the real COWIncrementalRelation.collectSplits (which needs a live metaClient), letting the + // test verify incrementalRanges THREADS its normalizer into the COW collectSplits call. + private String rawCowPath; + + @Override + public List collectFileSlices() { + if (collectFileSlicesThrows) { + throw new UnsupportedOperationException("collectFileSlices must not be called on this route"); + } + return fileSlices; + } + + @Override + public List collectSplits(UnaryOperator nativePathNormalizer) { + if (collectSplitsThrows) { + throw new UnsupportedOperationException("collectSplits must not be called on this route"); + } + if (rawCowPath != null) { + return Collections.singletonList(new HudiScanRange.Builder() + .path(nativePathNormalizer.apply(rawCowPath)).fileFormat("parquet").build()); + } + return splits; + } + + @Override + public boolean fallbackFullTableScan() { + return fallback; + } + + @Override + public String getEndTs() { + return endTs; + } + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiIncrementalRelationTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiIncrementalRelationTest.java new file mode 100644 index 00000000000000..f8183aebf53d16 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiIncrementalRelationTest.java @@ -0,0 +1,204 @@ +// 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.connector.api.DorisConnectorException; + +import org.apache.hudi.common.table.timeline.TimelineUtils.HollowCommitHandling; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.function.UnaryOperator; + +/** + * Tests the OFFLINE-verifiable surface of the ported {@code @incr} IncrementalRelation family (INC-2): the pure + * fail-loud guards extracted onto {@link IncrementalRelation} and the {@link EmptyIncrementalRelation}. Each + * assertion pins WHY the behavior matters. + * + *

Coverage scope (Rule 12 — no over-claim). The COW/MOR relation CONSTRUCTORS do all timeline + + * metadata + filesystem work EAGERLY on a live {@code HoodieTableMetaClient}, and the connector deliberately has + * no Mockito / no hudi write deps, so the file-SELECTION pipeline (commit-range selection, write-stat → + * {@link HudiScanRange} mapping, file-slice selection, {@code fs.exists} full-table-scan probes, the meta-fields + * guard on a REAL disabled table, and the {@code USE_TRANSITION_TIME} completion-time END axis) is inherently + * e2e-only and is DEFERRED to the flip-time e2e (design §5 now mandates a meta-fields-disabled fixture and a + * USE_TRANSITION_TIME completion-axis fixture). Until that e2e lands, the completion-time END axis is UNVERIFIED + * at unit level (the stubbed executor swallows it). These unit tests cover ONLY the pure decisions they name; + * they do NOT prove file selection or the axis resolution. + */ +public class HudiIncrementalRelationTest { + + // ── meta-fields fail-loud (ported from INC-1 deferral; byte-for-byte message) ──────────────────────────── + + @Test + public void metaFieldsDisabledThrowsByteForByteLegacyMessage() { + // Legacy COW:81-83 / MOR:73-75 reject incremental on a meta-fields-disabled table. The message must be + // byte-for-byte (re-typed to DorisConnectorException, the connector's fail-loud type) so it reaches the + // user verbatim. This guard is the FIRST check in each ported COW/MOR constructor. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> IncrementalRelation.checkIncrementalMetaFields(false)); + Assertions.assertEquals( + "Incremental queries are not supported when meta fields are disabled", ex.getMessage()); + } + + @Test + public void metaFieldsEnabledIsNoOp() { + // A normal (meta-fields-enabled) table must pass the guard. Guards a mutation that inverts the condition. + Assertions.assertDoesNotThrow(() -> IncrementalRelation.checkIncrementalMetaFields(true)); + } + + // ── state-transition-time + full-table-scan rejection (byte-for-byte message) ──────────────────────────── + + @Test + public void stateTransitionTimeWithFullTableScanThrows() { + // Legacy COW:178-180 / MOR:104-106 reject USE_TRANSITION_TIME combined with a full-table-scan fallback. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> IncrementalRelation.checkStateTransitionTimeFullTableScan( + HollowCommitHandling.USE_TRANSITION_TIME, true)); + Assertions.assertEquals( + "Cannot use stateTransitionTime while enables full table scan", ex.getMessage()); + } + + @Test + public void stateTransitionTimeWithoutFullTableScanIsNoOp() { + // USE_TRANSITION_TIME alone (no full-table scan) is fine — only the COMBINATION is rejected. + Assertions.assertDoesNotThrow(() -> IncrementalRelation.checkStateTransitionTimeFullTableScan( + HollowCommitHandling.USE_TRANSITION_TIME, false)); + } + + @Test + public void fullTableScanUnderDefaultPolicyIsNoOp() { + // A full-table scan under the default FAIL policy must NOT throw — the throw is specific to + // USE_TRANSITION_TIME. Guards a mutation that drops the policy condition. + Assertions.assertDoesNotThrow(() -> IncrementalRelation.checkStateTransitionTimeFullTableScan( + HollowCommitHandling.FAIL, true)); + } + + // ── archival full-table-scan decision matrix (COW pure gate) ───────────────────────────────────────────── + + @Test + public void archivalFullTableScanRequiresFallbackEnabled() { + // Fallback disabled -> never a full-table scan on archival, even if a bound is archived. Guards the + // fallback-gates-everything semantics (legacy COW:177). + Assertions.assertFalse(IncrementalRelation.decideArchivalFullTableScan( + false, true, true, HollowCommitHandling.FAIL)); + } + + @Test + public void archivalFullTableScanRequiresAnArchivedBound() { + // Fallback enabled but NEITHER bound archived -> the archival gate does not fire (the file-existence + // probe, not tested here, may still trigger). Guards a mutation that drops the archived condition. + Assertions.assertFalse(IncrementalRelation.decideArchivalFullTableScan( + true, false, false, HollowCommitHandling.FAIL)); + } + + @Test + public void archivalFullTableScanFiresWhenStartOrEndArchived() { + // Either archived bound (with fallback) triggers a full-table scan under a non-transition policy. + Assertions.assertTrue(IncrementalRelation.decideArchivalFullTableScan( + true, true, false, HollowCommitHandling.FAIL)); + Assertions.assertTrue(IncrementalRelation.decideArchivalFullTableScan( + true, false, true, HollowCommitHandling.BLOCK)); + } + + @Test + public void archivalFullTableScanRejectsStateTransitionTime() { + // The archival trigger under USE_TRANSITION_TIME is rejected (legacy COW:178-180), not returned as true. + Assertions.assertThrows(DorisConnectorException.class, + () -> IncrementalRelation.decideArchivalFullTableScan( + true, true, false, HollowCommitHandling.USE_TRANSITION_TIME)); + } + + @Test + public void stateTransitionTimeRejectionIsGatedOnAnArchivedBound() { + // USE_TRANSITION_TIME alone must NOT reject: legacy nests the transition-time throw INSIDE the archival + // trigger `fallback && (startArchived||endArchived)` (COW:177-181), so a non-archived window returns false + // even under USE_TRANSITION_TIME. Kills a mutation that hoists the state-transition check above the + // archival guard (rejecting any USE_TRANSITION_TIME+fallback window). + Assertions.assertFalse(IncrementalRelation.decideArchivalFullTableScan( + true, false, false, HollowCommitHandling.USE_TRANSITION_TIME)); + } + + // ── fallback-to-full-table-scan defensive throw (byte-for-byte message) ────────────────────────────────── + + @Test + public void fullTableScanFallbackThrowsByteForByteLegacyMessage() { + // The defensive guard COW.collectSplits / MOR.collectFileSlices fire when the window fell back to a full + // scan (legacy COW:206 / MOR:177). Byte-for-byte message, re-typed to DorisConnectorException. The scan + // planner is contracted to degrade before calling collect*, so this rarely fires — but the message must + // still reach the user verbatim if it does. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> IncrementalRelation.checkNotFullTableScan(true)); + Assertions.assertEquals("Fallback to full table scan", ex.getMessage()); + } + + @Test + public void notFullTableScanIsNoOp() { + // A window that did NOT fall back must pass the guard (the normal incremental path). Guards a condition + // inversion. + Assertions.assertDoesNotThrow(() -> IncrementalRelation.checkNotFullTableScan(false)); + } + + // ── hollow-commit policy → HollowCommitHandling enum (byte-faithful to legacy COW:74-75 / MOR:76-77) ───── + + @Test + public void hollowCommitHandlingDefaultsToFailWhenPolicyAbsent() { + // No policy param -> FAIL (legacy getOrDefault(policyKey, "FAIL")). This is the default axis + // (requested-time); guards a mutation that drops the "FAIL" default (which would NPE valueOf(null)). + Assertions.assertEquals(HollowCommitHandling.FAIL, + IncrementalRelation.hollowCommitHandling(Collections.emptyMap())); + } + + @Test + public void hollowCommitHandlingReadsUseTransitionTime() { + // The one non-default value that matters: USE_TRANSITION_TIME switches the relation's own file selection + // to the completion-time axis (COW:93-97 / MOR:100-104), which must match the END axis resolveIncremental + // resolved on the SAME policy. Guards dropping/misreading the policy key. + Map params = new HashMap<>(); + params.put("hoodie.read.timeline.holes.resolution.policy", "USE_TRANSITION_TIME"); + Assertions.assertEquals(HollowCommitHandling.USE_TRANSITION_TIME, + IncrementalRelation.hollowCommitHandling(params)); + } + + @Test + public void hollowCommitHandlingThrowsOnBogusPolicyLikeLegacy() { + // A bogus policy value reaches HollowCommitHandling.valueOf and THROWS IllegalArgumentException — legacy + // parity (legacy also valueOf's it, COW:74-75 / MOR:76-77). Same terminal error, one phase later (at + // planScan rather than the relation ctor). Guards a mutation that would swallow the bad value. + Map params = new HashMap<>(); + params.put("hoodie.read.timeline.holes.resolution.policy", "NOT_A_POLICY"); + Assertions.assertThrows(IllegalArgumentException.class, + () -> IncrementalRelation.hollowCommitHandling(params)); + } + + // ── empty relation (empty completed timeline) ──────────────────────────────────────────────────────────── + + @Test + public void emptyRelationSelectsNothing() { + // The empty-timeline relation selects no files on either shape and never falls back. Its end bound is the + // legacy "000" sentinel. Guards against a mutation returning non-empty / a non-"000" bound. + EmptyIncrementalRelation empty = new EmptyIncrementalRelation(); + Assertions.assertTrue(empty.collectSplits(UnaryOperator.identity()).isEmpty(), + "empty relation must select no splits"); + Assertions.assertTrue(empty.collectFileSlices().isEmpty(), "empty relation must select no file slices"); + Assertions.assertFalse(empty.fallbackFullTableScan(), "empty relation never falls back to a full scan"); + Assertions.assertEquals("000", empty.getEndTs(), "empty relation end bound is the legacy \"000\""); + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiIncrementalTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiIncrementalTest.java new file mode 100644 index 00000000000000..5556b2ab21d355 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiIncrementalTest.java @@ -0,0 +1,384 @@ +// 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.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; +import org.apache.doris.connector.api.mvcc.ConnectorTimeTravelSpec; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Callable; + +/** + * Tests the Hudi {@code @incr(...)} incremental-read window-resolution surface (INC-1): the + * {@code resolveTimeTravel(INCREMENTAL)} case + {@code applySnapshot} + the {@code begin/endInstant} pin on the + * handle, added so a hudi-on-HMS table served post-flip through the GENERIC {@code PluginDrivenScanNode} path + * resolves an incremental window byte-faithfully to legacy {@code COW/MORIncrementalRelation} — but consolidated + * into ONE connector locus. Each assertion pins WHY the behavior matters: + *

    + *
  • {@code beginTime} is required with the byte-for-byte legacy fail-loud message, so a missing bound reaches + * the user verbatim (an empty return would surface fe-core's wrong-domain "can't resolve time travel" + * text, since {@code loadSnapshot} has no INCREMENTAL not-found arm);
  • + *
  • an omitted / {@code "latest"} end bound resolves to the latest completed instant — the sentinel test is + * on the RESOLVED end value (legacy COW form), which is why {@code end="latest"} yields the instant, not + * the literal (guarding against the dead-code MOR bug that tested {@code latestTime});
  • + *
  • an empty completed timeline yields the {@code (000, 000]} window WITHOUT the begin-required check (legacy + * {@code withScanParams} short-circuits to {@code EmptyIncrementalRelation} first);
  • + *
  • applySnapshot stamps the window via {@code toBuilder()} so it PRESERVES applyFilter's prunedPartitionPaths + * and does not cross-contaminate the {@code FOR TIME AS OF} queryInstant carrier.
  • + *
+ * + *

Unlike {@code FOR TIME AS OF}, INCREMENTAL resolution DOES touch the metaClient (to resolve the latest + * completed instant), so the metadata is built with a STUB {@link HudiMetaClientExecutor} that returns a canned + * latest-instant {@code Optional} without building a live metaClient — the same offline pattern as the + * partition-listing tests. + */ +public class HudiIncrementalTest { + + private static final List YEAR_MONTH = Arrays.asList("year", "month"); + private static final String LATEST = "20240102030405006"; + private static final String BEGIN_REQUIRED_MESSAGE = + "Specify the begin instant time to pull from using option hoodie.datasource.read.begin.instanttime"; + + // ── window resolution: begin/end pinned onto the handle ───────────────────────────────────────────── + + @Test + public void explicitBeginAndEndAreCarriedVerbatimOntoHandle() { + // Both bounds explicit and non-sentinel: they pass through unchanged (latestTime is resolved but unused). + // Drive the full path resolveTimeTravel -> applySnapshot so the FE-internal carrier properties are + // exercised end-to-end. + HudiConnectorMetadata md = metadata(stub(Optional.of(LATEST))); + HudiTableHandle pinned = resolveAndApply(md, window("20240101000000", "20240101120000")); + Assertions.assertEquals("20240101000000", pinned.getBeginInstant()); + Assertions.assertEquals("20240101120000", pinned.getEndInstant()); + } + + @Test + public void omittedEndDefaultsToLatestCompletedInstant() { + // No endTime param -> end defaults to the latest completed instant (legacy getOrDefault(end-key, + // latestTime)). Guards a mutation that would leave end null / empty for an open-ended @incr window. + HudiConnectorMetadata md = metadata(stub(Optional.of(LATEST))); + HudiTableHandle pinned = resolveAndApply(md, window("20240101000000", null)); + Assertions.assertEquals("20240101000000", pinned.getBeginInstant()); + Assertions.assertEquals(LATEST, pinned.getEndInstant(), + "an omitted endTime must resolve to the latest completed instant"); + } + + @Test + public void latestSentinelResolvesEndToTheLatestCompletedInstant() { + // end="latest" must resolve to the instant, NOT stay the literal "latest". The sentinel test is on the + // RESOLVED end value (COWIncrementalRelation:98); the dead-code MOR bug (MORIncrementalRelation:92 tested + // latestTime, so end="latest" was left unresolved) is inherently avoided by the single locus. This + // assertion KILLS a mutation back to the buggy MOR form. + HudiConnectorMetadata md = metadata(stub(Optional.of(LATEST))); + HudiTableHandle pinned = resolveAndApply(md, window("20240101000000", "latest")); + Assertions.assertEquals(LATEST, pinned.getEndInstant(), + "end=\"latest\" must resolve to the latest completed instant, not the literal sentinel"); + } + + @Test + public void earliestSentinelResolvesBeginToZero() { + // begin="earliest" -> "000" (legacy EARLIEST_TIME). Guards dropping the sentinel mapping. + HudiConnectorMetadata md = metadata(stub(Optional.of(LATEST))); + HudiTableHandle pinned = resolveAndApply(md, window("earliest", "20240101120000")); + Assertions.assertEquals("000", pinned.getBeginInstant(), + "begin=\"earliest\" must collapse to \"000\" (legacy EARLIEST_TIME)"); + } + + @Test + public void useTransitionTimePolicyStillResolvesWindow() { + // A USE_TRANSITION_TIME hollow-commit policy param must not break window resolution: resolveIncremental + // computes the completion-time axis (useCompletionTime=true) and still pins a valid (begin, end]. The stub + // executor returns a canned latest regardless of the axis, so the requested-vs-completion AXIS itself is + // NOT verified here — it is deferred to the §5 USE_TRANSITION_TIME completion-axis e2e fixture. This test + // only guards that adding the policy branch did not crash resolution or drop the pin. + HudiConnectorMetadata md = metadata(stub(Optional.of(LATEST))); + Map params = window("20240101000000", null); + params.put("hoodie.read.timeline.holes.resolution.policy", "USE_TRANSITION_TIME"); + HudiTableHandle pinned = resolveAndApply(md, params); + Assertions.assertEquals("20240101000000", pinned.getBeginInstant()); + Assertions.assertEquals(LATEST, pinned.getEndInstant(), + "USE_TRANSITION_TIME must still resolve an omitted end to the (canned) latest completed instant"); + } + + // ── fail-loud + empty-timeline ─────────────────────────────────────────────────────────────────────── + + @Test + public void missingBeginThrowsByteForByteLegacyMessageWhenTimelineNonEmpty() { + // Non-empty timeline (stub returns a latest instant) + no beginTime -> THROW the byte-for-byte legacy + // message (it propagates as-is through loadSnapshot). An empty return would surface fe-core's wrong-domain + // "can't resolve time travel" text. + HudiConnectorMetadata md = metadata(stub(Optional.of(LATEST))); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> md.resolveTimeTravel(null, partitioned(), + ConnectorTimeTravelSpec.incremental(window(null, "20240101120000")))); + Assertions.assertEquals(BEGIN_REQUIRED_MESSAGE, ex.getMessage()); + } + + @Test + public void emptyTimelineYieldsZeroWindowWithoutBeginRequiredCheck() { + // Empty completed timeline (stub returns Optional.empty()) short-circuits to the (000, 000] window BEFORE + // the begin-required check — so a MISSING beginTime is NOT an error here (legacy withScanParams builds + // EmptyIncrementalRelation first). The window selects nothing. + HudiConnectorMetadata md = metadata(stub(Optional.empty())); + HudiTableHandle pinned = resolveAndApply(md, window(null, null)); + Assertions.assertEquals("000", pinned.getBeginInstant()); + Assertions.assertEquals("000", pinned.getEndInstant()); + } + + @Test + public void resolveIncrementalNeverReturnsEmptyForAValidWindow() { + // The generic loadSnapshot fail-loud has no INCREMENTAL not-found arm, so INCREMENTAL must ALWAYS pin. + HudiConnectorMetadata md = metadata(stub(Optional.of(LATEST))); + Optional pin = md.resolveTimeTravel(null, partitioned(), + ConnectorTimeTravelSpec.incremental(window("20240101000000", null))); + Assertions.assertTrue(pin.isPresent(), "a valid @incr window must always pin, never return empty"); + } + + // ── applySnapshot: stamp preserving pruning / carrier isolation ───────────────────────────────────── + + @Test + public void applySnapshotStampsWindowPreservingPrunedPartitions() { + // applyFilter runs BEFORE applySnapshot at scan time, so a pruned handle must keep its pruning after the + // window pin. Guards a rebuild-from-scratch mutation (which would silently turn a pruned incremental scan + // into a full scan). + HudiConnectorMetadata md = metadata(stub(Optional.of(LATEST))); + List pruned = Arrays.asList("year=2024/month=01", "year=2024/month=02"); + HudiTableHandle prunedHandle = partitioned().toBuilder().prunedPartitionPaths(pruned).build(); + ConnectorMvccSnapshot pin = md.resolveTimeTravel(null, prunedHandle, + ConnectorTimeTravelSpec.incremental(window("20240101000000", "20240101120000"))) + .orElseThrow(AssertionError::new); + HudiTableHandle stamped = (HudiTableHandle) md.applySnapshot(null, prunedHandle, pin); + Assertions.assertEquals("20240101000000", stamped.getBeginInstant()); + Assertions.assertEquals("20240101120000", stamped.getEndInstant()); + Assertions.assertEquals(pruned, stamped.getPrunedPartitionPaths(), + "the incremental pin must preserve applyFilter's partition pruning"); + Assertions.assertNull(stamped.getQueryInstant(), + "an incremental pin must not set the FOR TIME AS OF queryInstant carrier"); + } + + @Test + public void timeTravelPinDoesNotSetIncrementalWindow() { + // Cross-isolation the other way: a FOR TIME AS OF pin must leave begin/endInstant null (the two carriers + // are mutually exclusive). Guards accidental cross-wiring in applySnapshot. + HudiConnectorMetadata md = metadata(stub(Optional.of(LATEST))); + ConnectorMvccSnapshot pin = md.resolveTimeTravel(null, partitioned(), + ConnectorTimeTravelSpec.timestamp("2024-01-01 12:00:00", false)).orElseThrow(AssertionError::new); + HudiTableHandle stamped = (HudiTableHandle) md.applySnapshot(null, partitioned(), pin); + Assertions.assertEquals("20240101120000", stamped.getQueryInstant()); + Assertions.assertNull(stamped.getBeginInstant()); + Assertions.assertNull(stamped.getEndInstant()); + } + + @Test + public void applySnapshotLeavesLatestPinUnchanged() { + // The query-begin latest pin (beginQuerySnapshot output) carries ONLY a snapshotId, NO window property. + // applySnapshot must return the handle UNCHANGED so a plain read stays byte-identical. + HudiConnectorMetadata md = metadata(stub(Optional.of(LATEST))); + ConnectorMvccSnapshot latestPin = + HudiConnectorMetadata.buildBeginQuerySnapshot(20240101120000000L).orElseThrow(AssertionError::new); + HudiTableHandle base = partitioned(); + HudiTableHandle result = (HudiTableHandle) md.applySnapshot(null, base, latestPin); + Assertions.assertSame(base, result, "a latest pin must not rebuild the handle"); + Assertions.assertNull(result.getBeginInstant()); + Assertions.assertNull(result.getEndInstant()); + } + + // ── raw @incr option-param threading (glob / fallback / policy → handle) ──────────────────────────── + + @Test + public void resolveThreadsRawIncrParamsOntoHandleExcludingWindowCarriers() { + // The raw @incr option params (glob / fallback / hollow policy) must ride the FE-internal transport to + // the handle so planScan can feed them to the ported relations; the begin/end WINDOW carriers (their own + // "hudi.incremental-*" keys) must NOT leak into that opt-param map. Guards both the copy AND the namespace + // isolation (a mutation that dropped the prefix filter would pollute the relations' optParams with the + // internal carriers). + HudiConnectorMetadata md = metadata(stub(Optional.of(LATEST))); + Map params = window("20240101000000", "20240101120000"); + params.put("hoodie.datasource.read.incr.path.glob", "*/2024/*"); + params.put("hoodie.datasource.read.incr.fallback.fulltablescan.enable", "true"); + params.put("hoodie.read.timeline.holes.resolution.policy", "USE_TRANSITION_TIME"); + HudiTableHandle pinned = resolveAndApply(md, params); + + Map incr = pinned.getIncrementalParams(); + Assertions.assertEquals("*/2024/*", incr.get("hoodie.datasource.read.incr.path.glob")); + Assertions.assertEquals("true", incr.get("hoodie.datasource.read.incr.fallback.fulltablescan.enable")); + Assertions.assertEquals("USE_TRANSITION_TIME", incr.get("hoodie.read.timeline.holes.resolution.policy")); + // The resolved window rides begin/endInstant separately; the FE-internal carriers must not leak into the + // opt-param map the relations read. + Assertions.assertFalse(incr.containsKey("hudi.incremental-begin"), + "the FE-internal begin carrier must not appear in the relations' opt-param map"); + Assertions.assertFalse(incr.containsKey("hudi.incremental-end"), + "the FE-internal end carrier must not appear in the relations' opt-param map"); + // beginTime/endTime aliases are carried verbatim (legacy passed the full optParams); they are inert for + // the relations (which read only glob/fallback/policy) but round-trip fidelity is asserted. + Assertions.assertEquals("20240101000000", incr.get("beginTime")); + Assertions.assertEquals("20240101120000", incr.get("endTime")); + // And the window itself still lands on the dedicated fields. + Assertions.assertEquals("20240101000000", pinned.getBeginInstant()); + Assertions.assertEquals("20240101120000", pinned.getEndInstant()); + } + + @Test + public void nonIncrementalPinsLeaveIncrementalParamsEmpty() { + // A FOR TIME AS OF pin and a plain (latest) handle must both carry an EMPTY incrementalParams — only an + // @incr read populates it. Guards accidental cross-wiring in applySnapshot. + HudiConnectorMetadata md = metadata(stub(Optional.of(LATEST))); + ConnectorMvccSnapshot ttPin = md.resolveTimeTravel(null, partitioned(), + ConnectorTimeTravelSpec.timestamp("2024-01-01 12:00:00", false)).orElseThrow(AssertionError::new); + HudiTableHandle ttStamped = (HudiTableHandle) md.applySnapshot(null, partitioned(), ttPin); + Assertions.assertTrue(ttStamped.getIncrementalParams().isEmpty(), + "a FOR TIME AS OF pin must not populate incrementalParams"); + Assertions.assertTrue(partitioned().getIncrementalParams().isEmpty(), + "a plain handle has empty incrementalParams"); + } + + @Test + public void toBuilderRoundTripsIncrementalParams() { + // Guards the toBuilder() copy line for incrementalParams: a dropped copy would silently lose glob/ + // fallback/policy when applyFilter/applySnapshot rebuild the handle, so the relations would read defaults. + Map params = new HashMap<>(); + params.put("hoodie.datasource.read.incr.path.glob", "*/x/*"); + HudiTableHandle original = new HudiTableHandle.Builder("db", "t", "s3://b/t", "MERGE_ON_READ") + .beginInstant("20240101000000").endInstant("20240101120000").incrementalParams(params).build(); + HudiTableHandle copy = original.toBuilder().build(); + Assertions.assertEquals("*/x/*", copy.getIncrementalParams().get("hoodie.datasource.read.incr.path.glob")); + } + + // ── synthetic scan predicate (the neutral row-level @incr filter SPI) ─────────────────────────────── + + @Test + public void syntheticScanPredicatesEmitStringTypedCommitTimeWindow() { + // The @incr row filter is required because a COW base file rewritten inside the window ALSO carries + // forward out-of-window rows. The SPI reads the window off the SAME resolved pin applySnapshot consumes + // (single window authority, so file selection and the row filter can never diverge) and emits + // `_hoodie_commit_time > begin AND <= end` as two flat conjuncts, STRING-typed both sides for + // lexicographic instant compare. Byte-faithful to legacy LogicalHudiScan.generateIncrementalExpression. + HudiConnectorMetadata md = metadata(stub(Optional.of(LATEST))); + ConnectorMvccSnapshot pin = md.resolveTimeTravel(null, partitioned(), + ConnectorTimeTravelSpec.incremental(window("20240101000000", "20240101120000"))) + .orElseThrow(AssertionError::new); + + List predicates = md.getSyntheticScanPredicates(null, partitioned(), pin); + + ConnectorColumnRef commitTime = new ConnectorColumnRef("_hoodie_commit_time", ConnectorType.of("STRING")); + Assertions.assertEquals(Arrays.asList( + new ConnectorComparison(ConnectorComparison.Operator.GT, commitTime, + ConnectorLiteral.ofString("20240101000000")), + new ConnectorComparison(ConnectorComparison.Operator.LE, commitTime, + ConnectorLiteral.ofString("20240101120000"))), + predicates, + "an @incr read must emit `_hoodie_commit_time > begin AND <= end`, STRING-typed on both sides"); + } + + @Test + public void syntheticScanPredicatesAreEmptyForTimeTravelPin() { + // A FOR TIME AS OF pin carries a queryInstant, NOT a (begin, end] window -> no row filter (its rows are + // already correct by file selection at the instant). Guards a mutation that would emit a bogus window. + HudiConnectorMetadata md = metadata(stub(Optional.of(LATEST))); + ConnectorMvccSnapshot ttPin = md.resolveTimeTravel(null, partitioned(), + ConnectorTimeTravelSpec.timestamp("2024-01-01 12:00:00", false)).orElseThrow(AssertionError::new); + Assertions.assertTrue(md.getSyntheticScanPredicates(null, partitioned(), ttPin).isEmpty(), + "a FOR TIME AS OF pin must not produce a synthetic row filter"); + } + + @Test + public void syntheticScanPredicatesAreEmptyForPlainAndNullPin() { + // The query-begin latest pin (beginQuerySnapshot) carries only a snapshotId, no window -> a plain read + // gets NO synthetic filter, so its plan is byte-identical to today. A null snapshot is likewise a no-op. + HudiConnectorMetadata md = metadata(stub(Optional.of(LATEST))); + ConnectorMvccSnapshot latestPin = + HudiConnectorMetadata.buildBeginQuerySnapshot(20240101120000000L).orElseThrow(AssertionError::new); + Assertions.assertTrue(md.getSyntheticScanPredicates(null, partitioned(), latestPin).isEmpty(), + "a plain latest pin must not produce a synthetic row filter"); + Assertions.assertTrue(md.getSyntheticScanPredicates(null, partitioned(), null).isEmpty(), + "a null snapshot must not produce a synthetic row filter"); + } + + // ── handle field round-trip ───────────────────────────────────────────────────────────────────────── + + @Test + public void toBuilderRoundTripsWindowFields() { + // Guards the toBuilder() copy lines for begin/endInstant (a dropped copy would silently lose the window + // when applyFilter/applySnapshot rebuild the handle). + HudiTableHandle original = new HudiTableHandle.Builder("db", "t", "s3://b/t", "MERGE_ON_READ") + .partitionKeyNames(YEAR_MONTH) + .beginInstant("20240101000000") + .endInstant("20240101120000") + .build(); + HudiTableHandle copy = original.toBuilder().build(); + Assertions.assertEquals("20240101000000", copy.getBeginInstant()); + Assertions.assertEquals("20240101120000", copy.getEndInstant()); + // A fresh handle carries no window (null), so a plain read is not treated as incremental. + Assertions.assertNull(partitioned().getBeginInstant()); + Assertions.assertNull(partitioned().getEndInstant()); + } + + // ── helpers ──────────────────────────────────────────────────────────────────────────────────────── + + private static HudiConnectorMetadata metadata(HudiMetaClientExecutor executor) { + return new HudiConnectorMetadata(null, Collections.emptyMap(), executor); + } + + private static HudiTableHandle partitioned() { + return new HudiTableHandle.Builder("db", "t", "s3://b/t", "COPY_ON_WRITE") + .partitionKeyNames(YEAR_MONTH).build(); + } + + /** Builds the raw @incr param map fe-core threads via getIncrementalParams() (null entries omitted). */ + private static Map window(String beginTime, String endTime) { + Map params = new HashMap<>(); + if (beginTime != null) { + params.put("beginTime", beginTime); + } + if (endTime != null) { + params.put("endTime", endTime); + } + return params; + } + + private static HudiTableHandle resolveAndApply(HudiConnectorMetadata md, Map params) { + ConnectorMvccSnapshot pin = md.resolveTimeTravel(null, partitioned(), + ConnectorTimeTravelSpec.incremental(params)).orElseThrow(AssertionError::new); + return (HudiTableHandle) md.applySnapshot(null, partitioned(), pin); + } + + /** Executor that ignores the action and returns a canned value (stubs out the live metaClient). */ + private static HudiMetaClientExecutor stub(Object cannedReturn) { + return new HudiMetaClientExecutor() { + @Override + @SuppressWarnings("unchecked") + public T execute(Callable action) { + return (T) cannedReturn; + } + }; + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiPartitionPruningTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiPartitionPruningTest.java new file mode 100644 index 00000000000000..4bb9b733759464 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiPartitionPruningTest.java @@ -0,0 +1,353 @@ +// 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.connector.api.ConnectorType; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorFilterConstraint; +import org.apache.doris.connector.api.pushdown.ConnectorIn; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.FilterApplicationResult; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsDatabaseInfo; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsTableInfo; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Callable; + +/** + * Tests {@link HudiConnectorMetadata#applyFilter} partition pruning (P3-T05). + * + *

WHY: the SPI Hudi path previously listed ALL partitions unconditionally and + * stored them as {@code prunedPartitionPaths}, doing no EQ/IN pruning at all and + * silently forcing the partition source to HMS for any filtered query. These tests + * pin the corrected behavior, mirroring {@code HiveConnectorMetadata}: + *

    + *
  • EQ / IN predicates on partition columns reduce the scanned partition set;
  • + *
  • predicates on non-partition columns (or range predicates) never prune;
  • + *
  • when no partition predicate applies, the handle is left untouched + * ({@code Optional.empty()}) so scan planning falls back to Hudi's own listing;
  • + *
  • a predicate that matches every / no partition is handled correctly.
  • + *
+ * A test that passed against the old stub (which always returned all partitions) + * would be wrong — each assertion checks the precise pruned set.

+ */ +public class HudiPartitionPruningTest { + + private static final List PARTITIONS = Arrays.asList( + "year=2023/month=12", + "year=2024/month=01", + "year=2024/month=02"); + + private static final List PART_KEYS = Arrays.asList("year", "month"); + + @Test + public void testEqOnPartitionColumnPrunes() { + // year = '2024' -> only the two 2024 partitions + Optional> result = + applyFilter(partitionedHandle(), eq("year", "2024")); + + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals( + Arrays.asList("year=2024/month=01", "year=2024/month=02"), + prunedPaths(result)); + } + + @Test + public void testInOnPartitionColumnPrunes() { + // month IN ('01', '12') -> spans years, keeps original order + Optional> result = + applyFilter(partitionedHandle(), in("month", "01", "12")); + + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals( + Arrays.asList("year=2023/month=12", "year=2024/month=01"), + prunedPaths(result)); + } + + @Test + public void testAndOfTwoPartitionColumnsPrunes() { + // year = '2024' AND month = '01' -> a single partition + ConnectorExpression expr = and(eq("year", "2024"), eq("month", "01")); + Optional> result = + applyFilter(partitionedHandle(), expr); + + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals( + Collections.singletonList("year=2024/month=01"), + prunedPaths(result)); + } + + @Test + public void testNonPartitionColumnInAndIsIgnored() { + // year = '2024' AND price = '100' -> prune on year only; non-partition pred ignored + ConnectorExpression expr = and(eq("year", "2024"), eq("price", "100")); + Optional> result = + applyFilter(partitionedHandle(), expr); + + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals( + Arrays.asList("year=2024/month=01", "year=2024/month=02"), + prunedPaths(result)); + } + + @Test + public void testNonPartitionPredicateOnlyLeavesHandleUntouched() { + // price = '100' -> no partition predicate -> Optional.empty() (no source switch) + Optional> result = + applyFilter(partitionedHandle(), eq("price", "100")); + + Assertions.assertFalse(result.isPresent()); + } + + @Test + public void testPredicateMatchingAllPartitionsHasNoEffect() { + // year IN ('2023', '2024') -> matches every partition -> Optional.empty() + Optional> result = + applyFilter(partitionedHandle(), in("year", "2023", "2024")); + + Assertions.assertFalse(result.isPresent()); + } + + @Test + public void testPredicateMatchingNoPartitionYieldsEmptyPrunedList() { + // year = '1999' -> matches nothing -> present handle with empty pruned set (scan 0) + Optional> result = + applyFilter(partitionedHandle(), eq("year", "1999")); + + Assertions.assertTrue(result.isPresent()); + Assertions.assertTrue(prunedPaths(result).isEmpty()); + } + + @Test + public void testUnpartitionedTableIsNotTouched() { + HudiTableHandle handle = new HudiTableHandle.Builder("db", "t", "s3://b/t", "COPY_ON_WRITE") + .partitionKeyNames(Collections.emptyList()) + .build(); + Optional> result = + applyFilter(handle, eq("year", "2024")); + + Assertions.assertFalse(result.isPresent()); + } + + @Test + public void testNonHiveStylePositionalPathsPruneToRelativePaths() { + // H3 core: a non-hive-style Hudi table (hive_style_partitioning=false, the DEFAULT) has a POSITIONAL + // physical layout ("2024/01"), NOT the HMS hive-style name ("year=2024/month=01"). applyFilter must prune + // the RELATIVE storage paths (the Hudi metadata listing that the scan also feeds fsView), so the pruned + // set is the shape fsView is keyed by. RED before the fix: applyFilter fed HMS hive-style names to fsView, + // which finds nothing on a non-hive-style table -> 0 splits for any filtered query. + HudiConnectorMetadata metadata = new HudiConnectorMetadata( + new FakeHmsClient(PARTITIONS), // HMS hive-style names -- must NOT be used here + Collections.emptyMap(), // use_hive_sync_partition=false -> non-hive-sync + new StubMetaClientExecutor(Arrays.asList("2024/01", "2024/02", "2023/12"))); + Optional> result = + metadata.applyFilter(null, partitionedHandle(), new ConnectorFilterConstraint(eq("year", "2024"))); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals(Arrays.asList("2024/01", "2024/02"), prunedPaths(result)); + } + + @Test + public void testHiveSyncBranchPrunesHmsNames() { + // use_hive_sync_partition=true: partitions are registered in HMS and the hive-style name IS the relative + // storage layout, so applyFilter prunes the HMS names directly (no Hudi metadata listing / stub unused). + HudiConnectorMetadata metadata = new HudiConnectorMetadata( + new FakeHmsClient(PARTITIONS), + Collections.singletonMap("use_hive_sync_partition", "true"), + new StubMetaClientExecutor(Collections.emptyList())); + Optional> result = + metadata.applyFilter(null, partitionedHandle(), new ConnectorFilterConstraint(eq("year", "2024"))); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals( + Arrays.asList("year=2024/month=01", "year=2024/month=02"), prunedPaths(result)); + } + + @Test + public void prunePartitionPathsMatchesPositionalLayout() { + // Direct offline unit for the non-hive-sync prune helper: positional relative paths matched by the values + // parsePartitionValues extracts positionally. + Assertions.assertEquals( + Arrays.asList("2024/01", "2024/02"), + HudiConnectorMetadata.prunePartitionPaths( + Arrays.asList("2024/01", "2024/02", "2023/12"), + PART_KEYS, + Collections.singletonMap("year", Collections.singletonList("2024")))); + } + + @Test + public void testDatePartitionPredicatePrunesUnchanged() { + // H2 non-regression: a DATE predicate literal is a LocalDate (not LocalDateTime), so it is NOT diverted to + // hiveDateTimeString -- String.valueOf(LocalDate) = "2024-01-01" already matches the stored DATE value. + HudiConnectorMetadata metadata = new HudiConnectorMetadata( + new FakeHmsClient(PARTITIONS), Collections.emptyMap(), + new StubMetaClientExecutor(Arrays.asList("2024-01-01", "2024-01-02"))); + HudiTableHandle handle = new HudiTableHandle.Builder("db", "t", "s3://b/t", "COPY_ON_WRITE") + .partitionKeyNames(Collections.singletonList("dt")) + .build(); + ConnectorComparison dateEq = new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef("dt", ConnectorType.of("DATEV2")), + new ConnectorLiteral(ConnectorType.of("DATEV2"), LocalDate.of(2024, 1, 1))); + Optional> result = + metadata.applyFilter(null, handle, new ConnectorFilterConstraint(dateEq)); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals(Collections.singletonList("2024-01-01"), prunedPaths(result)); + } + + // ========== helpers ========== + + private Optional> applyFilter( + HudiTableHandle handle, ConnectorExpression expr) { + // Default (use_hive_sync_partition=false) -> the non-hive-sync branch, whose candidate source is the Hudi + // metadata listing. Feed the canned partition list via the stub executor (no live metaClient). The + // hive-style names here parse the same via parsePartitionValues, so the pruning assertions are unchanged. + HudiConnectorMetadata metadata = new HudiConnectorMetadata( + new FakeHmsClient(PARTITIONS), Collections.emptyMap(), + new StubMetaClientExecutor(PARTITIONS)); + return metadata.applyFilter(null, handle, new ConnectorFilterConstraint(expr)); + } + + private HudiTableHandle partitionedHandle() { + return new HudiTableHandle.Builder("db", "t", "s3://b/t", "COPY_ON_WRITE") + .partitionKeyNames(PART_KEYS) + .build(); + } + + @SuppressWarnings("unchecked") + private List prunedPaths(Optional> result) { + return ((HudiTableHandle) result.get().getHandle()).getPrunedPartitionPaths(); + } + + private static ConnectorColumnRef colRef(String name) { + return new ConnectorColumnRef(name, ConnectorType.of("STRING")); + } + + private static ConnectorLiteral lit(String value) { + return new ConnectorLiteral(ConnectorType.of("STRING"), value); + } + + private static ConnectorComparison eq(String col, String value) { + return new ConnectorComparison(ConnectorComparison.Operator.EQ, colRef(col), lit(value)); + } + + private static ConnectorIn in(String col, String... values) { + List inList = new ArrayList<>(); + for (String v : values) { + inList.add(lit(v)); + } + return new ConnectorIn(colRef(col), inList, false); + } + + private static ConnectorAnd and(ConnectorExpression... children) { + return new ConnectorAnd(Arrays.asList(children)); + } + + /** + * Minimal {@link HmsClient} double returning a fixed partition-name list. + * Only {@code listPartitionNames} is exercised by partition pruning; the rest fail loud. + */ + private static final class FakeHmsClient implements HmsClient { + private final List partitionNames; + + FakeHmsClient(List partitionNames) { + this.partitionNames = partitionNames; + } + + @Override + public List listPartitionNames(String dbName, String tableName, int maxParts) { + return partitionNames; + } + + @Override + public List listDatabases() { + throw new UnsupportedOperationException(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public List listTables(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public 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() { + } + } + + /** + * {@link HudiMetaClientExecutor} test double that returns a canned value WITHOUT running the action, so a test + * can supply the non-hive-sync {@code applyFilter} branch's {@code listAllPartitionPaths} result offline (no + * live metaClient / filesystem). Mirrors the stub pattern in HudiConnectorPartitionListingTest. + */ + private static final class StubMetaClientExecutor implements HudiMetaClientExecutor { + private final Object canned; + + StubMetaClientExecutor(Object canned) { + this.canned = canned; + } + + @SuppressWarnings("unchecked") + @Override + public T execute(Callable action) { + return (T) canned; + } + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiPartitionValuesTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiPartitionValuesTest.java new file mode 100644 index 00000000000000..f892c7a6f29d59 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiPartitionValuesTest.java @@ -0,0 +1,154 @@ +// 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.connector.api.DorisConnectorException; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +/** + * Byte-parity tests for {@link HudiScanPlanProvider#parsePartitionValues} against legacy + * {@code HudiPartitionUtils.parsePartitionValues}. Each case pins WHY the behavior matters for BE-visible + * partition columns on a snapshot read. + */ +public class HudiPartitionValuesTest { + + @Test + public void nonHiveStylePositionalPathMapsByPosition() { + // THE regression this fix closes: Hudi's DEFAULT layout (hive_style_partitioning=false) yields relative + // paths like "2024/01" with NO "col=" prefix. The old split-on-'=' logic dropped every prefix-less + // fragment, so the value map was EMPTY -> BE returned NULL partition columns on a plain snapshot read. + Map values = HudiScanPlanProvider.parsePartitionValues( + "2024/01", Arrays.asList("year", "month")); + + Assertions.assertEquals(2, values.size(), "both positional fragments must map to their columns"); + Assertions.assertEquals("2024", values.get("year")); + Assertions.assertEquals("01", values.get("month")); + } + + @Test + public void hiveStylePathStripsColumnPrefix() { + Map values = HudiScanPlanProvider.parsePartitionValues( + "year=2024/month=01", Arrays.asList("year", "month")); + + Assertions.assertEquals("2024", values.get("year")); + Assertions.assertEquals("01", values.get("month")); + } + + @Test + public void mixedPrefixedAndPositionalFragments() { + // Legacy decides per fragment (startsWith "col=" or not), so a mixed path must resolve each side. + Map values = HudiScanPlanProvider.parsePartitionValues( + "year=2024/01", Arrays.asList("year", "month")); + + Assertions.assertEquals("2024", values.get("year"), "prefixed fragment strips the col= prefix"); + Assertions.assertEquals("01", values.get("month"), "prefix-less fragment maps positionally"); + } + + @Test + public void unescapesEscapedValues() { + // Legacy unescaped every value via Hive's FileUtils.unescapePathName; %20 -> space, %2F -> slash. A + // partition value with an escaped char would otherwise reach BE literally (wrong value). + Map values = HudiScanPlanProvider.parsePartitionValues( + "dt=2024-01-01%2012%3A00%3A00", Collections.singletonList("dt")); + + Assertions.assertEquals("2024-01-01 12:00:00", values.get("dt"), "escaped chars must be decoded"); + } + + @Test + public void singleColumnWholePathFallbackWhenFragmentCountMismatches() { + // Single partition column, path has more '/' fragments than columns: legacy maps the WHOLE path to the + // single column (after stripping an optional "col=" prefix), not throw. + Map values = HudiScanPlanProvider.parsePartitionValues( + "2024/01/01", Collections.singletonList("dt")); + + Assertions.assertEquals(1, values.size()); + Assertions.assertEquals("2024/01/01", values.get("dt"), "whole path maps to the single column"); + } + + @Test + public void singleColumnStripsPrefixInWholePathFallback() { + Map values = HudiScanPlanProvider.parsePartitionValues( + "dt=2024/01/01", Collections.singletonList("dt")); + + Assertions.assertEquals("2024/01/01", values.get("dt"), + "the leading col= prefix is stripped before the whole-path fallback"); + } + + @Test + public void multiColumnFragmentCountMismatchFailsLoud() { + // > 1 partition column and a fragment count that does not match: legacy throws rather than silently + // producing a partial/wrong value map. Fail loud, matching legacy. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> HudiScanPlanProvider.parsePartitionValues( + "2024/01/extra", Arrays.asList("year", "month"))); + Assertions.assertTrue(ex.getMessage().contains("2024/01/extra"), + "the failure must name the offending partition path"); + } + + @Test + public void parsePartitionNameUnescapesValuesForPruning() { + // H1: the PRUNING-decision parse (HudiConnectorMetadata.parsePartitionName, fed the ESCAPED HMS + // get_partition_names output) must unescape values the same way the scan-side parsePartitionValues does. + // Otherwise an escaped partition value ("code=US%3ACA") never string-equals the unescaped predicate + // literal ("US:CA") in matchesPredicates, so the real partition is pruned OUT -> silent row loss. RED + // before the fix: values stay "US%3ACA" / "a%2Fb". + Map values = HudiConnectorMetadata.parsePartitionName( + "code=US%3ACA/kind=a%2Fb", Arrays.asList("code", "kind")); + + Assertions.assertEquals("US:CA", values.get("code"), "colon-escaped value must be decoded"); + Assertions.assertEquals("a/b", values.get("kind"), "slash-escaped value must be decoded"); + } + + @Test + public void hiveDateTimeStringRendersHiveCanonicalText() { + // H2: a DATETIME/TIMESTAMP predicate literal arrives as a LocalDateTime. It must render as Hive-canonical + // partition text (space separator, full seconds) so it string-matches the stored partition value in + // matchesPredicates. String.valueOf(LocalDateTime) yields ISO "2024-01-01T10:00" (T separator, dropped + // zero seconds) which never matches "2024-01-01 10:00:00" -> the whole table prunes to 0 rows. RED before. + Assertions.assertEquals("2024-01-01 10:00:00", + HudiConnectorMetadata.hiveDateTimeString(LocalDateTime.of(2024, 1, 1, 10, 0, 0))); + // midnight: ISO would collapse to "2024-01-01T00:00" + Assertions.assertEquals("2024-01-01 00:00:00", + HudiConnectorMetadata.hiveDateTimeString(LocalDateTime.of(2024, 1, 1, 0, 0, 0))); + // non-zero seconds: ISO keeps the 'T' separator ("2024-01-01T10:00:30") + Assertions.assertEquals("2024-01-01 10:00:30", + HudiConnectorMetadata.hiveDateTimeString(LocalDateTime.of(2024, 1, 1, 10, 0, 30))); + // sub-second (nano = micros*1000): trailing-zero-trimmed microseconds + Assertions.assertEquals("2024-01-01 10:00:00.123456", + HudiConnectorMetadata.hiveDateTimeString(LocalDateTime.of(2024, 1, 1, 10, 0, 0, 123456 * 1000))); + Assertions.assertEquals("2024-01-01 10:00:00.1", + HudiConnectorMetadata.hiveDateTimeString(LocalDateTime.of(2024, 1, 1, 10, 0, 0, 100000 * 1000))); + } + + @Test + public void emptyPartitionKeysReturnsEmptyForUnpartitionedTable() { + // Unpartitioned tables reach here with an empty key list and an empty path; the result must be empty + // (no spurious partition column). + Assertions.assertTrue( + HudiScanPlanProvider.parsePartitionValues("", Collections.emptyList()).isEmpty()); + Assertions.assertTrue( + HudiScanPlanProvider.parsePartitionValues("", null).isEmpty()); + } +} 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 new file mode 100644 index 00000000000000..a3b6d1753befda --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiReadOnlyWriteRejectTest.java @@ -0,0 +1,140 @@ +// 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.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.spi.ConnectorContext; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +/** + * Locks the READ-ONLY safety net for hudi-on-HMS tables: once a flipped hms gateway diverts a hudi table to a + * foreign handle, the gateway's 3-way routing sends that handle to THIS (the hudi sibling) connector, so this + * connector — not iceberg — answers every write / procedure / DDL / transaction call. Hudi data is written by + * Spark/Flink; Doris only reads. This test pins that the hudi connector rejects EVERY mutation fail-loud, so a + * future change cannot silently make a hudi table writable or DDL-mutable, and a hudi write is never + * mis-executed as an iceberg write. + * + *

Dormant until hms enters {@code SPI_READY_TYPES} and hudi handles are diverted (no production path reaches + * this connector yet); this is a Rule-9 behavior lock. The correctness at flip is asserted end-to-end (a + * heterogeneous HMS catalog where hudi INSERT/DELETE/MERGE/EXECUTE all fail loud read-only). + */ +public class HudiReadOnlyWriteRejectTest { + + private static final ConnectorTableHandle HUDI_HANDLE = + new HudiTableHandle("db", "t", "s3://b/t", "COPY_ON_WRITE"); + + private static HudiConnector connector() { + return new HudiConnector(Collections.emptyMap(), new ConnectorContext() { + @Override + public String getCatalogName() { + return "test_catalog"; + } + + @Override + public long getCatalogId() { + return 1L; + } + }); + } + + /** The write/DDL methods throw before touching the client/executor, so null collaborators are sufficient. */ + private static HudiConnectorMetadata metadata() { + return new HudiConnectorMetadata(null, Collections.emptyMap(), null); + } + + @Test + public void noWriterSoDataWritesRejectedByAdmissionGate() { + // Read-only: the hudi connector supplies NO write plan provider, so supportedWriteOperations is empty and + // the engine's PhysicalPlanTranslator INSERT / row-level-DML gates throw a clean AnalysisException up + // front (before opening any transaction). Keeping the provider null (NOT throwing) is deliberate: the + // admission gate CALLS getWritePlanProvider to decide, so a throw there would make the gate raise a + // DorisConnectorException the engine misclassifies as an internal error instead of "does not support + // INSERT". MUTATION: return a write plan provider / a non-empty op set -> a hudi INSERT/DELETE would be + // admitted and mis-executed -> red. + HudiConnector connector = connector(); + Assertions.assertNull(connector.getWritePlanProvider(HUDI_HANDLE)); + Assertions.assertTrue(connector.supportedWriteOperations(HUDI_HANDLE).isEmpty()); + } + + @Test + public void noProceduresSoExecuteRejected() { + // No procedure ops -> EXECUTE on a hudi table is rejected ("does not support EXECUTE"), same as + // plain-hive. MUTATION: return a non-null ProcedureOps -> EXECUTE admitted -> red. + Assertions.assertNull(connector().getProcedureOps(HUDI_HANDLE)); + } + + @Test + public void beginTransactionThrowsExplicitReadOnly() { + // The explicit last-line defense: opening a write transaction on a hudi table fails loud with a + // HUDI-SPECIFIC read-only message rather than the generic "Transactions not supported" default, so any + // write path that reaches transaction-open (bypassing the admission gate) reports the right reason. + // Overriding the no-arg form covers the per-handle overload (its SPI default delegates to it). MUTATION: + // drop the override -> the message is the generic default, not read-only -> red. + HudiConnectorMetadata metadata = metadata(); + DorisConnectorException noArg = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata.beginTransaction(null)); + Assertions.assertTrue(noArg.getMessage().toLowerCase().contains("read-only"), + "expected an explicit hudi read-only message, got: " + noArg.getMessage()); + // The production-routed path is the PER-HANDLE overload (the gateway forwards + // siblingMetadata(session, handle).beginTransaction(session, handle)); its SPI default delegates to the + // no-arg override, so it too fails with the explicit read-only message. Lock BOTH so the delegation the + // real path relies on cannot silently break. + DorisConnectorException perHandle = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata.beginTransaction(null, HUDI_HANDLE)); + Assertions.assertTrue(perHandle.getMessage().toLowerCase().contains("read-only"), + "the per-handle overload the gateway routes to must also be explicit read-only"); + } + + @Test + public void allDdlRejected() { + // Hudi tables are externally managed, so Doris rejects ALL DDL — catalog metadata ops (DROP TABLE / + // RENAME TABLE / TRUNCATE, a deliberate strict-read-only choice: a signed-off behavior change vs legacy + // HMS, which allowed them) AND the full ALTER family (add/drop/rename/modify/reorder column, create/drop + // branch/tag, add/drop/replace partition field). These are the SPI defaults; this test LOCKS the WHOLE + // mutation surface so a future override cannot silently make a hudi table DDL-mutable. MUTATION: implement + // any of these on the hudi metadata -> that one assertion goes red. + HudiConnectorMetadata m = metadata(); + // Table-level catalog DDL. + Assertions.assertThrows(DorisConnectorException.class, () -> m.dropTable(null, HUDI_HANDLE)); + Assertions.assertThrows(DorisConnectorException.class, () -> m.renameTable(null, HUDI_HANDLE, "new_name")); + Assertions.assertThrows(DorisConnectorException.class, + () -> m.truncateTable(null, HUDI_HANDLE, Collections.emptyList())); + // Column ALTER. + Assertions.assertThrows(DorisConnectorException.class, () -> m.addColumn(null, HUDI_HANDLE, null, null)); + Assertions.assertThrows(DorisConnectorException.class, () -> m.addColumns(null, HUDI_HANDLE, null)); + Assertions.assertThrows(DorisConnectorException.class, () -> m.dropColumn(null, HUDI_HANDLE, null)); + Assertions.assertThrows(DorisConnectorException.class, () -> m.renameColumn(null, HUDI_HANDLE, null, null)); + Assertions.assertThrows(DorisConnectorException.class, () -> m.modifyColumn(null, HUDI_HANDLE, null, null)); + Assertions.assertThrows(DorisConnectorException.class, () -> m.reorderColumns(null, HUDI_HANDLE, null)); + // Branch / tag / partition-field ALTER. + Assertions.assertThrows(DorisConnectorException.class, + () -> m.createOrReplaceBranch(null, HUDI_HANDLE, null)); + Assertions.assertThrows(DorisConnectorException.class, () -> m.createOrReplaceTag(null, HUDI_HANDLE, null)); + Assertions.assertThrows(DorisConnectorException.class, () -> m.dropBranch(null, HUDI_HANDLE, null)); + Assertions.assertThrows(DorisConnectorException.class, () -> m.dropTag(null, HUDI_HANDLE, null)); + Assertions.assertThrows(DorisConnectorException.class, () -> m.addPartitionField(null, HUDI_HANDLE, null)); + Assertions.assertThrows(DorisConnectorException.class, () -> m.dropPartitionField(null, HUDI_HANDLE, null)); + Assertions.assertThrows(DorisConnectorException.class, + () -> m.replacePartitionField(null, HUDI_HANDLE, null)); + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiScanRangeTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiScanRangeTest.java new file mode 100644 index 00000000000000..30e359f1220ae4 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiScanRangeTest.java @@ -0,0 +1,196 @@ +// 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.TFileFormatType; +import org.apache.doris.thrift.TFileRangeDesc; +import org.apache.doris.thrift.THudiFileDesc; +import org.apache.doris.thrift.TTableFormatFileDesc; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +/** + * Tests {@link HudiScanRange#populateRangeParams}. + * + *

WHY: column_names/column_types/delta_logs are thrift {@code list}; + * BE ({@code hudi_jni_reader.cpp}) joins them with distinct delimiters + * (names ',', types '#', delta logs ','). The FE must pass each per-column type + * as a single list element. The previous code joined them with ',' and split + * back by ',', which shattered comma-bearing Hive type strings + * ({@code decimal(10,2)}, {@code struct<...>}) and misaligned names/types. + * These tests pin that the typed lists survive intact and aligned.

+ */ +public class HudiScanRangeTest { + + @Test + public void testJniListsSurviveIntactAndAligned() { + HudiScanRange range = new HudiScanRange.Builder() + .path("s3://bucket/t/file") + .fileFormat("jni") + .instantTime("20240101000000000") + .serde("org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe") + .inputFormat("org.apache.hudi.hadoop.realtime.HoodieParquetRealtimeInputFormat") + .basePath("s3://bucket/t") + .dataFilePath("s3://bucket/t/base.parquet") + .dataFileLength(123L) + .deltaLogs(Arrays.asList("s3://bucket/t/.f.log.1_0", "s3://bucket/t/.f.log.2_0")) + .columnNames(Arrays.asList("x", "y", "z")) + .columnTypes(Arrays.asList("int", "decimal(10,2)", "struct")) + .build(); + + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(formatDesc, rangeDesc); + + THudiFileDesc fileDesc = formatDesc.getHudiParams(); + + // A log-bearing MOR slice must set FORMAT_JNI explicitly (not rely on the node default). + Assertions.assertEquals(TFileFormatType.FORMAT_JNI, rangeDesc.getFormatType()); + + // Types must NOT be shattered: 3 columns -> 3 type strings (old bug + // produced 5: "decimal(10","2)","struct"). + Assertions.assertEquals(Arrays.asList("int", "decimal(10,2)", "struct"), + fileDesc.getColumnTypes()); + Assertions.assertEquals(Arrays.asList("x", "y", "z"), fileDesc.getColumnNames()); + Assertions.assertEquals(Arrays.asList("s3://bucket/t/.f.log.1_0", "s3://bucket/t/.f.log.2_0"), + fileDesc.getDeltaLogs()); + + // names <-> types alignment (the JNI scanner zips them positionally). + Assertions.assertEquals(fileDesc.getColumnNames().size(), fileDesc.getColumnTypes().size()); + } + + @Test + public void testNoDeltaLogsDowngradesToNativeParquet() { + // MOR file slice with no delta logs -> native parquet reader; no JNI lists set. + HudiScanRange range = new HudiScanRange.Builder() + .path("s3://bucket/t/base.parquet") + .fileFormat("jni") + .dataFilePath("s3://bucket/t/base.parquet") + .dataFileLength(456L) + .build(); + + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(formatDesc, rangeDesc); + + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, rangeDesc.getFormatType()); + Assertions.assertFalse(formatDesc.getHudiParams().isSetColumnTypes()); + Assertions.assertFalse(formatDesc.getHudiParams().isSetColumnNames()); + } + + @Test + public void nativeParquetSliceExplicitlySetsFormatType() { + // A native slice as collectMorSplits/collectCowSplits actually build it: fileFormat="parquet" (NOT + // "jni"), no JNI metadata. populateRangeParams MUST set FORMAT_PARQUET explicitly — relying on the + // node-level default (jni for a MOR table) shipped an empty THudiFileDesc under FORMAT_JNI to BE. + HudiScanRange range = new HudiScanRange.Builder() + .path("s3://bucket/t/base.parquet") + .fileFormat("parquet") + .fileSize(456L) + .build(); + + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(formatDesc, rangeDesc); + + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, rangeDesc.getFormatType(), + "a native parquet slice must set FORMAT_PARQUET, not inherit the node default"); + Assertions.assertFalse(formatDesc.getHudiParams().isSetColumnNames(), "native slice sets no JNI fields"); + } + + @Test + public void nativeOrcSliceExplicitlySetsFormatType() { + // A COW ORC table's node default is parquet; without an explicit per-range set BE would read the ORC + // base file as parquet. + HudiScanRange range = new HudiScanRange.Builder() + .path("s3://bucket/t/base.orc") + .fileFormat("orc") + .fileSize(456L) + .build(); + + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(formatDesc, rangeDesc); + + Assertions.assertEquals(TFileFormatType.FORMAT_ORC, rangeDesc.getFormatType(), + "a native orc slice must set FORMAT_ORC, not inherit the parquet node default"); + } + + @Test + public void nativeSliceStampsSchemaId() { + // C4c: a native slice carrying a schema_id must stamp THudiFileDesc.schema_id (field 12) so BE's native + // field-id reader can match old files across schema evolution. MUTATION: skip the native-branch stamp -> + // isSetSchemaId false -> red. + HudiScanRange range = new HudiScanRange.Builder() + .path("s3://bucket/t/base.parquet") + .fileFormat("parquet") + .fileSize(456L) + .schemaId(20240101000000000L) + .build(); + + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(formatDesc, rangeDesc); + + Assertions.assertTrue(formatDesc.getHudiParams().isSetSchemaId()); + Assertions.assertEquals(20240101000000000L, formatDesc.getHudiParams().getSchemaId()); + } + + @Test + public void jniSliceNeverStampsSchemaId() { + // C4c: schema_id is a NATIVE-only field (the JNI merge reader reads column_names/types @instant and + // consumes no schema_id). Even if a schema_id is set on the Builder, a JNI (log-bearing) slice must NOT + // stamp it. MUTATION: stamp schema_id in the JNI branch -> isSetSchemaId true -> red. + HudiScanRange range = new HudiScanRange.Builder() + .path("s3://bucket/t/file") + .fileFormat("jni") + .instantTime("20240101000000000") + .basePath("s3://bucket/t") + .dataFilePath("s3://bucket/t/base.parquet") + .deltaLogs(Arrays.asList("s3://bucket/t/.f.log.1_0")) + .schemaId(20240101000000000L) + .build(); + + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(formatDesc, rangeDesc); + + Assertions.assertEquals(TFileFormatType.FORMAT_JNI, rangeDesc.getFormatType()); + Assertions.assertFalse(formatDesc.getHudiParams().isSetSchemaId()); + } + + @Test + public void nativeSliceWithoutSchemaIdLeavesItUnset() { + // A native slice with no resolved schema_id (non-evolution unresolved / BY_NAME baseline) must not stamp + // field 12. MUTATION: default schema_id to 0/-1 unconditionally -> isSetSchemaId true -> red. + HudiScanRange range = new HudiScanRange.Builder() + .path("s3://bucket/t/base.parquet") + .fileFormat("parquet") + .fileSize(456L) + .build(); + + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(formatDesc, rangeDesc); + + Assertions.assertFalse(formatDesc.getHudiParams().isSetSchemaId()); + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiSchemaAtInstantTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiSchemaAtInstantTest.java new file mode 100644 index 00000000000000..09f3870377a712 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiSchemaAtInstantTest.java @@ -0,0 +1,114 @@ +// 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.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorTableSchema; +import org.apache.doris.connector.api.ConnectorType; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +/** + * Tests the routing of the schema-at-instant surface (HD-C5a): the 3-arg + * {@code getTableSchema(session, handle, snapshot)} override must resolve the schema AS OF the instant that + * {@code applySnapshot} stamped onto the handle for a {@code FOR TIME AS OF} read, while a plain read (or a + * non-{@code FOR TIME} pin) resolves the LATEST schema. Each assertion pins WHY the behavior matters: + *
    + *
  • the 2-arg entry point always resolves LATEST (no time-travel pin exists), so a plain read stays + * byte-identical to before this step;
  • + *
  • a handle WITHOUT a {@code queryInstant} threads a {@code null} instant = LATEST — the shared build + * path means a non-{@code FOR TIME} 3-arg call cannot drift from the 2-arg latest path;
  • + *
  • a handle WITH a {@code queryInstant} threads exactly that instant into the metaClient read — proving + * the override keys off {@link HudiTableHandle#getQueryInstant()} (the {@code applySnapshot}-stamped + * pin) and NOT {@code snapshot.getSchemaId()} (which stays {@code -1} for hudi; the snapshot is + * deliberately passed as {@code null} here so a mutant that keyed off it would surface latest, not the + * instant).
  • + *
+ * + *

The actual at-instant metaClient read is e2e (a live schema-evolved Hudi table); this same-loader unit + * locks only the instant-threading routing by overriding the {@link HudiConnectorMetadata#getSchemaFromMetaClient} + * seam, so no live metaClient / plugin auth is needed.

+ */ +public class HudiSchemaAtInstantTest { + + /** + * Recording fake: overrides the metaClient schema seam to capture the {@code queryInstant} the build path + * threads down from the handle (no live metaClient). {@code null} = the LATEST path. + */ + private static final class RecordingMetadata extends HudiConnectorMetadata { + // Distinct from both null (= latest) and any real instant, so an un-invoked seam is detectable. + String lastInstant = "SEAM-NOT-CALLED"; + int calls; + + RecordingMetadata() { + super(null, Collections.emptyMap(), null); + } + + @Override + List getSchemaFromMetaClient(String basePath, String queryInstant) { + this.lastInstant = queryInstant; + this.calls++; + return Collections.singletonList( + new ConnectorColumn("c", ConnectorType.of("INT"), "", true, null)); + } + } + + private static HudiTableHandle handle(String queryInstant) { + HudiTableHandle.Builder b = new HudiTableHandle.Builder("db", "t", "s3://b/t", "COPY_ON_WRITE"); + if (queryInstant != null) { + b.queryInstant(queryInstant); + } + return b.build(); + } + + @Test + void twoArgGetTableSchemaAlwaysResolvesLatest() { + RecordingMetadata m = new RecordingMetadata(); + ConnectorTableSchema schema = m.getTableSchema(null, handle(null)); + Assertions.assertNull(m.lastInstant, "2-arg getTableSchema must resolve the LATEST schema (null instant)"); + Assertions.assertEquals(1, m.calls, "the metaClient seam must be reached exactly once"); + Assertions.assertEquals("t", schema.getTableName()); + Assertions.assertEquals("HUDI", schema.getTableFormatType()); + } + + @Test + void threeArgWithoutPinResolvesLatest() { + RecordingMetadata m = new RecordingMetadata(); + // No queryInstant on the handle (plain read / @incr pin): the shared build path threads a null instant. + m.getTableSchema(null, handle(null), null); + Assertions.assertNull(m.lastInstant, + "a handle without a queryInstant pin must resolve the LATEST schema, not an at-instant one"); + Assertions.assertEquals(1, m.calls); + } + + @Test + void threeArgThreadsPinnedInstantFromHandle() { + RecordingMetadata m = new RecordingMetadata(); + // FOR TIME AS OF: applySnapshot has stamped queryInstant onto the handle. getTableSchema must resolve AT + // that instant. The snapshot arg is null on purpose: keying off it (schemaId) instead of the handle + // would surface latest here, failing this assertion. + m.getTableSchema(null, handle("20240101120000"), null); + Assertions.assertEquals("20240101120000", m.lastInstant, + "the handle's queryInstant must be threaded to the metaClient schema read"); + Assertions.assertEquals(1, m.calls); + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiSchemaParityTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiSchemaParityTest.java new file mode 100644 index 00000000000000..9ee408c97f2ede --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiSchemaParityTest.java @@ -0,0 +1,174 @@ +// 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.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorType; + +import org.apache.avro.Schema; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +/** + * Schema-level parity for the SPI Hudi metadata path (P3-T07, batch C). + * + *

WHY: {@code getTableSchema} derives its column list from the Hudi Avro schema + * via {@link HudiConnectorMetadata#avroSchemaToColumns}. This must produce the same + * column set — names, order, Doris types, nullability — and the same per-column + * Hive type strings ({@code colTypes}) as legacy fe-core + * {@code HMSExternalTable.initHudiSchema} (:740-753) + + * {@code HudiUtils.fromAvroHudiTypeToDorisType} / {@code convertAvroToHiveType}. + * Because no compile path sees both modules (fe-core does not depend on the concrete + * connector modules), parity is asserted against golden values transcribed from — + * and annotated with — the legacy contract.

+ * + *

COW vs MOR: schema derivation is table-type-agnostic on BOTH sides (neither + * consults COW/MOR), so a single golden schema covers both; the COW/MOR distinction + * lives only in scan planning and is pinned separately by {@link HudiTableTypeTest}.

+ * + *

Two assertions deliberately encode the P3-T07 column-name-casing fix: the + * top-level column name is lower-cased (legacy {@code toLowerCase(Locale.ROOT)} at + * {@code HMSExternalTable.java:745}), while a NESTED struct field name keeps its + * original case (legacy lowercases only the top-level column). A test that passed + * with the old raw-case behavior would be wrong.

+ */ +public class HudiSchemaParityTest { + + // A representative Hudi table schema in Avro JSON (the form Hudi actually stores). + // Mixed-case top-level names (Id, Name, Addr) and a mixed-case nested field + // (Street) exercise the casing boundary; the type variety mirrors the legacy + // type matrix (primitive, decimal, date, timestamp, nullable, array, map, struct). + private static final String SCHEMA_JSON = + "{\"type\":\"record\",\"name\":\"hudi_t\",\"fields\":[" + + "{\"name\":\"Id\",\"type\":\"long\"}," + + "{\"name\":\"Name\",\"type\":[\"null\",\"string\"],\"default\":null}," + + "{\"name\":\"price\",\"type\":{\"type\":\"bytes\",\"logicalType\":\"decimal\"," + + "\"precision\":10,\"scale\":2}}," + + "{\"name\":\"event_date\",\"type\":{\"type\":\"int\",\"logicalType\":\"date\"}}," + + "{\"name\":\"created_at\",\"type\":{\"type\":\"long\",\"logicalType\":\"timestamp-micros\"}}," + + "{\"name\":\"tags\",\"type\":{\"type\":\"array\",\"items\":\"string\"}}," + + "{\"name\":\"props\",\"type\":{\"type\":\"map\",\"values\":\"int\"}}," + + "{\"name\":\"Addr\",\"type\":{\"type\":\"record\",\"name\":\"AddrRec\",\"fields\":[" + + "{\"name\":\"Street\",\"type\":\"string\"},{\"name\":\"zip\",\"type\":\"int\"}]}}" + + "]}"; + + // Golden column contract, mirroring legacy initHudiSchema field-by-field. + private static final List EXPECTED_NAMES = Arrays.asList( + "id", "name", "price", "event_date", "created_at", "tags", "props", "addr"); + + private static final List EXPECTED_TYPES = Arrays.asList( + ConnectorType.of("BIGINT"), + ConnectorType.of("STRING"), + ConnectorType.of("DECIMALV3", 10, 2), + ConnectorType.of("DATEV2"), + ConnectorType.of("DATETIMEV2", 6, 0), + ConnectorType.arrayOf(ConnectorType.of("STRING")), + ConnectorType.mapOf(ConnectorType.of("STRING"), ConnectorType.of("INT")), + ConnectorType.structOf(Arrays.asList("Street", "zip"), + Arrays.asList(ConnectorType.of("STRING"), ConnectorType.of("INT")))); + + // Only the union-typed "Name" field is nullable; the flag must track the union, + // not be a constant. + private static final List EXPECTED_NULLABLE = Arrays.asList( + false, true, false, false, false, false, false, false); + + // Hive type strings = legacy colTypes (convertAvroToHiveType per field). + private static final List EXPECTED_HIVE_TYPES = Arrays.asList( + "bigint", "string", "decimal(10,2)", "date", "timestamp", + "array", "map", "struct"); + + private static Schema schema() { + return new Schema.Parser().parse(SCHEMA_JSON); + } + + @Test + public void testSchemaColumnsMirrorLegacyContract() { + List columns = HudiConnectorMetadata.avroSchemaToColumns(schema()); + Assertions.assertEquals(EXPECTED_NAMES.size(), columns.size()); + for (int i = 0; i < columns.size(); i++) { + ConnectorColumn col = columns.get(i); + Assertions.assertEquals(EXPECTED_NAMES.get(i), col.getName(), "name[" + i + "]"); + Assertions.assertEquals(EXPECTED_TYPES.get(i), col.getType(), "type[" + i + "]"); + Assertions.assertEquals(EXPECTED_NULLABLE.get(i), col.isNullable(), "nullable[" + i + "]"); + } + } + + @Test + public void testColumnTypeStringsMirrorLegacyColTypes() { + List fields = schema().getFields(); + Assertions.assertEquals(EXPECTED_HIVE_TYPES.size(), fields.size()); + for (int i = 0; i < fields.size(); i++) { + Assertions.assertEquals(EXPECTED_HIVE_TYPES.get(i), + HudiTypeMapping.toHiveTypeString(fields.get(i).schema()), "colType[" + i + "]"); + } + } + + @Test + public void avroSchemaToColumnsPreservesMetaCommitTimeAsBindableStringColumn() { + // The synthetic incremental row filter binds a ConnectorColumnRef to a scan-output slot named EXACTLY + // "_hoodie_commit_time" (byte-faithful to legacy LogicalHudiScan.generateIncrementalExpression). This test + // pins avroSchemaToColumns' HALF of that binding precondition: GIVEN an avro schema that carries the meta + // field (which the getSchemaFromMetaClient getTableAvroSchema(true) call guarantees at runtime), + // avroSchemaToColumns must surface it as a column with that exact lower-case name, STRING type, and + // visible — never dropped/renamed/mistyped/hidden — or the filter's ConnectorColumnRef fails to bind. + // The getTableAvroSchema(true) call itself runs only against a live metaClient, so the end-to-end + // meta-column EXPOSURE for a populate.meta.fields=false table is an e2e guard, NOT this unit test. + String metaInclusive = + "{\"type\":\"record\",\"name\":\"hudi_t\",\"fields\":[" + + "{\"name\":\"_hoodie_commit_time\",\"type\":[\"null\",\"string\"],\"default\":null}," + + "{\"name\":\"id\",\"type\":\"long\"}" + + "]}"; + List columns = + HudiConnectorMetadata.avroSchemaToColumns(new Schema.Parser().parse(metaInclusive)); + ConnectorColumn commitTime = columns.stream() + .filter(c -> "_hoodie_commit_time".equals(c.getName())) + .findFirst().orElseThrow(() -> new AssertionError( + "_hoodie_commit_time must be exposed as a column for the incremental row filter to bind")); + Assertions.assertTrue(commitTime.isVisible(), + "_hoodie_commit_time must be VISIBLE (legacy SELECT * parity + the row-filter's slot binding)"); + Assertions.assertEquals(ConnectorType.of("STRING"), commitTime.getType(), + "_hoodie_commit_time must be STRING so the window compare is lexicographic over Hudi instants"); + } + + @Test + public void jniColumnNamesAreLowerCased() { + // H4: HudiScanPlanProvider.planScan feeds these names into THudiFileDesc.column_names, which BE + // (HadoopHudiJniScanner.initRequiredColumnsAndTypes) keys a map by and then resolves each requiredField + // as an EXACT lower-case containsKey. A mixed-case Avro name ("Id") missing the lower-case slot ("id") + // throws and crashes every MOR/JNI split, so the JNI column-name list MUST be lower-cased — consistent + // with avroSchemaToColumns and legacy HudiScanNode. RED before the fix: raw-case "Id"/"Name"/"Addr". + Assertions.assertEquals( + Arrays.asList("id", "name", "price", "event_date", "created_at", "tags", "props", "addr"), + HudiScanPlanProvider.jniColumnNames(schema())); + } + + @Test + public void testTopLevelNameLoweredButNestedStructNamePreserved() { + List columns = HudiConnectorMetadata.avroSchemaToColumns(schema()); + ConnectorColumn addr = columns.get(7); + // top-level "Addr" -> "addr" + Assertions.assertEquals("addr", addr.getName()); + // nested struct field "Street" keeps its case (legacy lowercases only top-level) + Assertions.assertEquals(Arrays.asList("Street", "zip"), addr.getType().getFieldNames()); + Assertions.assertEquals("struct", + HudiTypeMapping.toHiveTypeString(schema().getFields().get(7).schema())); + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiSchemaUtilsTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiSchemaUtilsTest.java new file mode 100644 index 00000000000000..0c5111e11c215d --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiSchemaUtilsTest.java @@ -0,0 +1,445 @@ +// 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.TFileScanRangeParams; +import org.apache.doris.thrift.TPrimitiveType; +import org.apache.doris.thrift.schema.external.TField; +import org.apache.doris.thrift.schema.external.TFieldPtr; +import org.apache.doris.thrift.schema.external.TSchema; + +import org.apache.avro.Schema; +import org.apache.hudi.internal.schema.InternalSchema; +import org.apache.hudi.internal.schema.Types; +import org.apache.hudi.internal.schema.convert.AvroInternalSchemaConverter; +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.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Same-loader unit tests for {@link HudiSchemaUtils} — the HD-C4a {@code InternalSchema -> thrift} converter that + * later steps (C4c/C4d) turn into the native-reader schema dictionary. A wrong/missing entry makes BE either + * silently read NULL for a renamed column or SIGABRT the whole process on a mixed-case nested name, so these + * assertions pin the two deliberate deviations from legacy {@code HudiUtils.getSchemaInfo} (every-level + * lowercasing + STRING-placeholder scalar) plus the id/optional/nested structure legacy already carried. No + * Mockito, no live metaClient: the {@link InternalSchema} is hand-built via {@link Types}. + * + *

End-to-end BE field-id matching is only observable on the flip-time docker e2e (native + * {@code by_table_field_id}); these tests cover the FE-side thrift shape the dictionary is built from.

+ */ +public class HudiSchemaUtilsTest { + + // A hand-built hudi InternalSchema exercising the casing boundary + every nested container: + // Id INT (required -> is_optional=false) field id 1 + // Name STRING (optional) field id 2 + // Addr STRUCT field id 3 (child Street id 4) <- mixed-case nested + // Tags ARRAY field id 5 (element id 6) + // Props MAP field id 7 (key id 8, value id 9) + // Mixed-case top-level names (Id/Name/Addr/Tags/Props) and a mixed-case nested struct child (Street) + // exercise the every-level lowercasing crux. schemaId 42 proves the InternalSchema-keyed path uses the + // schema's OWN committed id (a per-version history entry), not the -1 sentinel. + private static final long SCHEMA_ID = 42L; + + private static InternalSchema buildInternalSchema() { + Types.Field street = Types.Field.get(4, true, "Street", Types.StringType.get()); + Types.RecordType addrType = Types.RecordType.get(Collections.singletonList(street)); + List fields = Arrays.asList( + Types.Field.get(1, false, "Id", Types.IntType.get()), + Types.Field.get(2, true, "Name", Types.StringType.get()), + Types.Field.get(3, true, "Addr", addrType), + Types.Field.get(5, true, "Tags", Types.ArrayType.get(6, true, Types.StringType.get())), + Types.Field.get(7, true, "Props", + Types.MapType.get(8, 9, Types.StringType.get(), Types.IntType.get()))); + return new InternalSchema(SCHEMA_ID, Types.RecordType.get(fields)); + } + + /** Index a struct's top-level {@link TField} children by name (preserving order for ordering assertions). */ + private static Map topFields(TSchema schema) { + Map byName = new LinkedHashMap<>(); + for (TFieldPtr ptr : schema.getRootField().getFields()) { + byName.put(ptr.getFieldPtr().getName(), ptr.getFieldPtr()); + } + return byName; + } + + private static TField structChild(TField parent, String name) { + for (TFieldPtr ptr : parent.getNestedField().getStructField().getFields()) { + if (ptr.getFieldPtr().getName().equals(name)) { + return ptr.getFieldPtr(); + } + } + throw new AssertionError("no nested field named " + name); + } + + // --- schema id: InternalSchema-keyed uses the schema's own id; explicit-id keys off the sentinel --- + + @Test + public void schemaKeyedOffInternalSchemaCommittedId() { + // A per-version history entry must carry the InternalSchema's OWN committed id so BE can resolve a native + // file's schema_id against it. MUTATION: hard-code -1 in buildSchemaInfo(InternalSchema) -> the history + // entry no longer matches any file's committed schema_id -> BE "miss table/file schema info" -> red. + TSchema schema = HudiSchemaUtils.buildSchemaInfo(buildInternalSchema()); + Assertions.assertEquals(SCHEMA_ID, schema.getSchemaId()); + } + + @Test + public void explicitSchemaIdIsCarried() { + // The later -1 target entry is built via buildSchemaInfo(CURRENT_SCHEMA_ID, fields). MUTATION: ignore the + // schemaId arg -> the target entry loses the -1 sentinel BE selects as the table-side overlay -> red. + List fields = buildInternalSchema().getRecord().fields(); + TSchema schema = HudiSchemaUtils.buildSchemaInfo(HudiSchemaUtils.CURRENT_SCHEMA_ID, fields); + Assertions.assertEquals(-1L, schema.getSchemaId()); + } + + // --- top-level: field ids + lowercased names + is_optional (legacy parity) --- + + @Test + public void topLevelFieldsCarryHudiFieldIdsAndLowercasedNames() { + // The dictionary's top-level names must be LOWERCASED (Locale.ROOT) so BE's table-side StructNode keys + // match the lowercase Doris scan slots, while the id is the hudi InternalSchema field id (the rename-safe + // join key). MUTATION: keep the hudi case ("Id") -> the lowercase slot lookup misses -> red here, silent + // NULL on BE. + Map fields = topFields(HudiSchemaUtils.buildSchemaInfo(buildInternalSchema())); + + Assertions.assertEquals(Arrays.asList("id", "name", "addr", "tags", "props"), + Arrays.asList(fields.keySet().toArray())); + Assertions.assertFalse(fields.containsKey("Id")); + Assertions.assertFalse(fields.containsKey("Name")); + + Assertions.assertEquals(1, fields.get("id").getId()); + Assertions.assertEquals(2, fields.get("name").getId()); + Assertions.assertEquals(3, fields.get("addr").getId()); + Assertions.assertEquals(5, fields.get("tags").getId()); + Assertions.assertEquals(7, fields.get("props").getId()); + } + + @Test + public void isOptionalMirrorsHudiNullability() { + // Legacy carries hudi's own optional flag at every level (getSchemaInfo sets is_optional from + // field.isOptional()). A required column stays is_optional=false; an optional one true. MUTATION: + // hard-code true (leak iceberg's force-nullable habit) -> the required "id" flips -> red. + Map fields = topFields(HudiSchemaUtils.buildSchemaInfo(buildInternalSchema())); + Assertions.assertFalse(fields.get("id").isIsOptional()); + Assertions.assertTrue(fields.get("name").isIsOptional()); + } + + // --- scalar placeholder + nested struct/array/map carry field ids at every level --- + + @Test + public void scalarFieldsUseStringPlaceholder() { + // BE reads type.type only as a nested-vs-scalar discriminator on the field-id path, so every scalar is a + // single STRING placeholder regardless of the real hudi type — the deliberate deviation from legacy's full + // fromAvroHudiTypeToDorisType map. MUTATION: port the real type (INT for "id") -> not STRING -> red; the + // assertion pins the simplification as intentional. + Map fields = topFields(HudiSchemaUtils.buildSchemaInfo(buildInternalSchema())); + Assertions.assertEquals(TPrimitiveType.STRING, fields.get("id").getType().getType()); + Assertions.assertEquals(TPrimitiveType.STRING, fields.get("name").getType().getType()); + } + + @Test + public void nestedStructChildIsLowercasedAndCarriesId() { + // THE SIGABRT crux (mirror of the iceberg DROP_AND_ADD fix): a mixed-case nested struct child ("Street") + // must be emitted LOWERCASED. The same history_schema_info thrift feeds the v1 format/table hudi reader, + // whose StructNode is looked up by the lowercase Doris slot name; keeping the hudi case makes BE's + // children.at("street") throw std::out_of_range -> whole-process SIGABRT. MUTATION (the bug): emit + // field.name() verbatim for struct children -> the lowercase key is absent / the mixed-case key leaks -> red. + Map fields = topFields(HudiSchemaUtils.buildSchemaInfo(buildInternalSchema())); + TField addr = fields.get("addr"); + Assertions.assertEquals(TPrimitiveType.STRUCT, addr.getType().getType()); + + TField street = structChild(addr, "street"); + Assertions.assertEquals(4, street.getId()); + Assertions.assertEquals(TPrimitiveType.STRING, street.getType().getType()); + // ... and the hudi-cased name must NOT leak through (that is exactly what aborts BE). + Assertions.assertThrows(AssertionError.class, () -> structChild(addr, "Street")); + } + + @Test + public void arrayElementCarriesIdAndType() { + // Faithful to legacy: the array element is a nested TField carrying its own hudi field id (BE field-id + // matches nested fields too). MUTATION: drop the element field id -> BE cannot map the element -> red. + Map fields = topFields(HudiSchemaUtils.buildSchemaInfo(buildInternalSchema())); + TField tags = fields.get("tags"); + Assertions.assertEquals(TPrimitiveType.ARRAY, tags.getType().getType()); + + TField element = tags.getNestedField().getArrayField().getItemField().getFieldPtr(); + Assertions.assertEquals(6, element.getId()); + Assertions.assertEquals(TPrimitiveType.STRING, element.getType().getType()); + } + + @Test + public void mapKeyAndValueCarryIdsAndTypes() { + // Faithful to legacy: map key (index 0) + value (index 1) each carry their own hudi field id. MUTATION: + // swap or drop the key/value ids -> BE mis-maps the map entries -> red. + Map fields = topFields(HudiSchemaUtils.buildSchemaInfo(buildInternalSchema())); + TField props = fields.get("props"); + Assertions.assertEquals(TPrimitiveType.MAP, props.getType().getType()); + + TField key = props.getNestedField().getMapField().getKeyField().getFieldPtr(); + TField value = props.getNestedField().getMapField().getValueField().getFieldPtr(); + Assertions.assertEquals(8, key.getId()); + Assertions.assertEquals(9, value.getId()); + Assertions.assertEquals(TPrimitiveType.STRING, key.getType().getType()); + Assertions.assertEquals(TPrimitiveType.STRING, value.getType().getType()); + } + + // --- round-trip through the base64 prop transport (what C4d's getScanNodeProperties/apply will do) --- + + @Test + public void encodeApplyRoundTripsThroughBase64() { + // encode() serializes the dict; applySchemaEvolution() copies current_schema_id + history_schema_info back + // onto the real params — the exact transport C4d round-trips through the scan-node props. MUTATION: drop + // either copied field in applySchemaEvolution -> the params are missing it -> red. The nested "street" + // stays lowercased after the round-trip (proves the whole tree survives serialization). + TSchema history = HudiSchemaUtils.buildSchemaInfo(buildInternalSchema()); + String encoded = HudiSchemaUtils.encode(HudiSchemaUtils.CURRENT_SCHEMA_ID, + Collections.singletonList(history)); + + TFileScanRangeParams params = new TFileScanRangeParams(); + HudiSchemaUtils.applySchemaEvolution(params, encoded); + + Assertions.assertTrue(params.isSetCurrentSchemaId()); + Assertions.assertEquals(-1L, params.getCurrentSchemaId()); + Assertions.assertEquals(1, params.getHistorySchemaInfoSize()); + TSchema decoded = params.getHistorySchemaInfo().get(0); + Assertions.assertEquals(SCHEMA_ID, decoded.getSchemaId()); + Map decodedTop = topFields(decoded); + Assertions.assertEquals(5, decodedTop.size()); + // The nested struct child survives serialization with its lowercased name AND its field id (structChild's + // case-sensitive lookup fails if the round-trip dropped it or left it mixed-case; the id assertion proves + // the whole nested TField — not just the name — round-trips). + Assertions.assertEquals(4, structChild(decodedTop.get("addr"), "street").getId()); + } + + @Test + public void applyIsNoOpForNullOrEmpty() { + // A null/empty prop (e.g. another connector's props map, or a handle that emits no dict) must leave the + // params untouched, not throw. MUTATION: drop the guard -> Base64.decode(null) NPEs -> red. + TFileScanRangeParams params = new TFileScanRangeParams(); + HudiSchemaUtils.applySchemaEvolution(params, null); + HudiSchemaUtils.applySchemaEvolution(params, ""); + Assertions.assertFalse(params.isSetCurrentSchemaId()); + Assertions.assertFalse(params.isSetHistorySchemaInfo()); + } + + @Test + public void applyFailsLoudOnCorruptProp() { + // The prop is produced by us, so a decode failure is a real bug — fail loud rather than silently drop it + // (a dropped dict re-introduces the silent wrong-rows risk on schema-evolved native reads). MUTATION: + // swallow the exception -> no throw -> red. + TFileScanRangeParams params = new TFileScanRangeParams(); + Assertions.assertThrows(RuntimeException.class, + () -> HudiSchemaUtils.applySchemaEvolution(params, "!!!not-base64-thrift!!!")); + } + + // ========== C4d: scan-level dictionary (-1 target entry + history entries) ========== + + private static HudiColumnHandle handle(String name, int fieldId) { + return new HudiColumnHandle(name, "string", false, fieldId); + } + + private static InternalSchema internalSchemaWithId(long schemaId) { + return new InternalSchema(schemaId, Types.RecordType.get(Collections.singletonList( + Types.Field.get(1, false, "id", Types.IntType.get())))); + } + + @Test + public void targetEntryKeyedOffRequestedColumnsWithSchemaIds() { + // The -1 target entry's top-level names must be EXACTLY the requested (scan-slot) columns, in order, each + // carrying the stable field id + full nested structure looked up BY NAME in the base InternalSchema. This + // is the overlay BE stamps each table column's id from. MUTATION: emit all base columns instead of the + // requested subset -> extra "name"/"tags"/"props" appear -> red. + InternalSchema base = buildInternalSchema(); + TSchema target = HudiSchemaUtils.buildTargetSchema( + Arrays.asList(handle("id", 1), handle("Addr", 3)), base); + + Assertions.assertEquals(-1L, target.getSchemaId()); + Map top = topFields(target); + Assertions.assertEquals(Arrays.asList("id", "addr"), Arrays.asList(top.keySet().toArray())); + // "id" carries the base InternalSchema field id (1) + scalar placeholder type ... + Assertions.assertEquals(1, top.get("id").getId()); + Assertions.assertEquals(TPrimitiveType.STRING, top.get("id").getType().getType()); + // ... "Addr" (mixed-case request) is matched case-insensitively, lowercased, id 3, with its nested struct + // child "street" carried (id 4) — proves the target entry keeps nested field ids for BE nested matching. + TField addr = top.get("addr"); + Assertions.assertEquals(3, addr.getId()); + Assertions.assertEquals(TPrimitiveType.STRUCT, addr.getType().getType()); + Assertions.assertEquals(4, structChild(addr, "street").getId()); + } + + @Test + public void targetEntryFallsBackToScalarForColumnAbsentFromSchema() { + // A requested column not in the base InternalSchema (e.g. a _hoodie_* meta column on a schema-evolved + // table whose commit-metadata schema omits meta fields) must still appear in the -1 entry (it IS a BE scan + // slot) as a scalar placeholder carrying the handle's field id. MUTATION: drop it -> the -1 entry is + // missing a scan slot -> BE StructNode lookup miss -> red. + InternalSchema base = buildInternalSchema(); + TSchema target = HudiSchemaUtils.buildTargetSchema( + Arrays.asList(handle("id", 1), handle("_hoodie_commit_time", 77)), base); + + Map top = topFields(target); + Assertions.assertTrue(top.containsKey("_hoodie_commit_time")); + Assertions.assertEquals(77, top.get("_hoodie_commit_time").getId()); + Assertions.assertEquals(TPrimitiveType.STRING, top.get("_hoodie_commit_time").getType().getType()); + } + + @Test + public void targetEntryEmptyRequestedFallsBackToAllBaseFields() { + // A count-only scan (no projected columns) yields an empty requested list; the -1 entry then carries all + // base top-level fields (a valid superset). MUTATION: emit an empty root -> BE has no target overlay -> red. + TSchema target = HudiSchemaUtils.buildTargetSchema(Collections.emptyList(), buildInternalSchema()); + Assertions.assertEquals(5, target.getRootField().getFieldsSize()); + } + + @Test + public void dictNonEvolutionEmitsTargetPlusBaseVersion() { + // Non-evolution table: no schema history file, so the dict is the -1 target + the single base + // (convert()) version. MUTATION: skip the base entry -> a native file's schema_id (0) has no history + // entry -> BE "miss schema info" fail-loud -> red. + InternalSchema base = internalSchemaWithId(0L); + String encoded = HudiSchemaUtils.buildSchemaEvolutionDict( + Collections.singletonList(handle("id", 1)), base, false, Collections.emptyList()); + + TFileScanRangeParams params = new TFileScanRangeParams(); + HudiSchemaUtils.applySchemaEvolution(params, encoded); + Assertions.assertEquals(-1L, params.getCurrentSchemaId()); + Assertions.assertEquals(2, params.getHistorySchemaInfoSize()); + Assertions.assertEquals(-1L, params.getHistorySchemaInfo().get(0).getSchemaId()); + Assertions.assertEquals(0L, params.getHistorySchemaInfo().get(1).getSchemaId()); + } + + @Test + public void dictGatedOffWhenAnyProjectedColumnUnresolved() { + // BE field-id matching is per-FILE, not per-column: a projected column with no field id (e.g. a _hoodie_* + // meta column on a schema-evolved table whose commit-metadata schema omits meta fields) cannot be BY_NAME'd + // on its own, so the WHOLE dict must be suppressed -> BE stays on BY_NAME for the scan (no silent + // const-NULL). The gate returns empty BEFORE touching the metaClient/schemaResolver, so the nulls here are + // never dereferenced. MUTATION: drop the gate -> the dict is built (NPEs on the null resolver here, and at + // runtime would emit a -1 target field with id=-1 that BE reads as const-NULL) -> not empty -> red. + Assertions.assertFalse(HudiSchemaUtils.buildSchemaEvolutionProp( + null, null, null, Arrays.asList(handle("id", 1), handle("_hoodie_commit_time", -1))).isPresent()); + } + + @Test + public void resolveFileSchemaNonEvolutionReturnsBaseWithoutMetaClient() { + // The COMMON (schema.on.read off) per-file schema_id source: the non-evolution branch returns the base + // schema (its schemaId, 0 for convert()) WITHOUT reading the file path or metaClient -> it must equal the + // version-0 history entry the dict emits (self-consistency, else BE "miss schema info"). Passing a null + // metaClient proves the branch never dereferences it. MUTATION: return null / a different schema -> red. + InternalSchema base = internalSchemaWithId(0L); + InternalSchema resolved = HudiSchemaUtils.resolveFileInternalSchema( + "s3://b/t/anyfile.parquet", false, base, null); + Assertions.assertSame(base, resolved); + Assertions.assertEquals(0L, resolved.schemaId()); + } + + @Test + public void dictEvolutionEmitsTargetPlusAllHistoricalVersions() { + // Evolution table: the dict is the -1 target + ONE entry per committed schema version (all-historical = + // robust superset of the referenced-file versions). MUTATION: emit only the base version -> a native file + // written under an older version has no history entry -> BE "miss schema info" -> red. + InternalSchema base = internalSchemaWithId(2L); + String encoded = HudiSchemaUtils.buildSchemaEvolutionDict( + Collections.singletonList(handle("id", 1)), base, true, + Arrays.asList(internalSchemaWithId(0L), internalSchemaWithId(1L), internalSchemaWithId(2L))); + + TFileScanRangeParams params = new TFileScanRangeParams(); + HudiSchemaUtils.applySchemaEvolution(params, encoded); + Assertions.assertEquals(-1L, params.getCurrentSchemaId()); + // -1 target + versions 0/1/2 + Assertions.assertEquals(4, params.getHistorySchemaInfoSize()); + Assertions.assertEquals(-1L, params.getHistorySchemaInfo().get(0).getSchemaId()); + Assertions.assertEquals(0L, params.getHistorySchemaInfo().get(1).getSchemaId()); + Assertions.assertEquals(1L, params.getHistorySchemaInfo().get(2).getSchemaId()); + Assertions.assertEquals(2L, params.getHistorySchemaInfo().get(3).getSchemaId()); + } + + // ========== HD-C5b: scan-side schema AT the pinned instant (FOR TIME AS OF over an evolution table) ========== + + @Test + public void jniSchemaNoPinReturnsLatestWithoutResolving() { + // A plain read (queryInstant == null) must NOT reload the timeline / resolve at-instant -> byte-identical + // to before HD-C5b, and the JNI column list stays the latest schema. Passing a null schemaResolver + + // metaClient proves the pin branch is never entered. MUTATION: drop the null-instant short-circuit -> + // resolveInternalSchemaAtInstant NPEs on the null metaClient -> red. + Schema latest = AvroInternalSchemaConverter.convert(buildInternalSchema(), "hudi_table"); + Assertions.assertSame(latest, + HudiSchemaUtils.resolveJniColumnSchema(null, null, latest, null)); + } + + @Test + public void jniSchemaUsesPinnedInstantSchemaNames() { + // FOR TIME AS OF over a schema-on-read table: the JNI (MOR-realtime) reader's column list must be the + // schema AT the pin, so a column renamed AFTER the pin shows its HISTORICAL (pinned) name to the merge + // reader (legacy HudiScanNode:222-224). MUTATION: return latestAvro instead of converting the pinned + // InternalSchema -> the pinned field name is absent -> red. + InternalSchema pinned = new InternalSchema(3L, Types.RecordType.get(Collections.singletonList( + Types.Field.get(1, false, "oldname", Types.IntType.get())))); + // The SAME field is "newname" at latest — the JNI list must NOT use it under the pin. + Schema latest = AvroInternalSchemaConverter.convert(new InternalSchema(5L, Types.RecordType.get( + Collections.singletonList(Types.Field.get(1, false, "newname", Types.IntType.get())))), + "hudi_table"); + + Schema jni = HudiSchemaUtils.chooseJniSchema(latest, Optional.of(pinned)); + List names = new ArrayList<>(); + jni.getFields().forEach(f -> names.add(f.name())); + Assertions.assertEquals(Collections.singletonList("oldname"), names); + } + + @Test + public void atInstantOverlayCarriesFullPinnedSchemaWithHistoricalNames() { + // HD-C5b: the at-instant -1 overlay is built from the FULL pinned schema (the empty-requested path over + // the pinned InternalSchema), so every pinned field carries its PINNED (historical) name + stable id and + // the overlay is a SUPERSET of the scan slots — BE matches old files BY FIELD ID and looks each scan slot + // up BY NAME (a scan slot MISSING from the overlay would std::out_of_range/SIGABRT; extra fields are + // ignored). This test pins that PURE assembly with a rename-shaped fixture (field "oldname"@pin), asserting + // the overlay keys off the PINNED names, not latest. MUTATION: buildSchemaEvolutionDict dropping a pinned + // field from the empty-requested overlay, or emitting only the base version instead of all versions -> red. + // NOT covered here (e2e-owed, design §5 HD-C5b): the PROVIDER ROUTING that selects this full-pinned path + // when a FOR TIME AS OF pin is present (getScanNodeProperties `if (pinnedSchema.isPresent())` + + // resolveInternalSchemaAtInstant / buildSchemaEvolutionDictAtInstant) needs a LIVE metaClient; a mutation + // deleting that branch (a pinned read falling back to the latest-keyed steady-state dict) is only + // observable at flip-time e2e — no same-loader test can catch it. + InternalSchema pinned = new InternalSchema(3L, Types.RecordType.get(Arrays.asList( + Types.Field.get(1, false, "oldname", Types.IntType.get()), + Types.Field.get(2, true, "other", Types.StringType.get())))); + String encoded = HudiSchemaUtils.buildSchemaEvolutionDict( + Collections.emptyList(), pinned, true, + Arrays.asList(internalSchemaWithId(0L), internalSchemaWithId(3L))); + + TFileScanRangeParams params = new TFileScanRangeParams(); + HudiSchemaUtils.applySchemaEvolution(params, encoded); + Assertions.assertEquals(-1L, params.getCurrentSchemaId()); + TSchema overlay = params.getHistorySchemaInfo().get(0); + Assertions.assertEquals(-1L, overlay.getSchemaId()); + Map top = topFields(overlay); + // BOTH pinned fields present with their pinned names + stable ids (full-pinned superset). + Assertions.assertEquals(Arrays.asList("oldname", "other"), new ArrayList<>(top.keySet())); + Assertions.assertEquals(1, top.get("oldname").getId()); + Assertions.assertEquals(2, top.get("other").getId()); + // history = -1 overlay + the two committed versions (all-versions, Option B). + Assertions.assertEquals(3, params.getHistorySchemaInfoSize()); + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiTableTypeTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiTableTypeTest.java new file mode 100644 index 00000000000000..5d1aee3f137181 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiTableTypeTest.java @@ -0,0 +1,149 @@ +// 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.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsDatabaseInfo; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsTableInfo; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * COW vs MOR table-type classification on the SPI Hudi metadata path (P3-T07, batch C). + * + *

WHY: schema derivation is table-type-agnostic, so the ONLY place the metadata SPI + * distinguishes Copy-On-Write from Merge-On-Read is {@code detectHudiTableType}, surfaced + * through {@code getTableHandle}. Misclassifying the type routes scan planning to the wrong + * split/reader strategy. These tests pin the detection from the HMS input format and the + * Spark provider table parameter — the "COW & MOR each one" parity requirement — plus the + * UNKNOWN fallback when no Hudi signal is present.

+ */ +public class HudiTableTypeTest { + + private String detect(String inputFormat, Map parameters) { + HmsTableInfo info = HmsTableInfo.builder() + .dbName("db").tableName("t") + .location("s3://b/t") + .inputFormat(inputFormat) + .parameters(parameters) + .build(); + HudiConnectorMetadata metadata = + new HudiConnectorMetadata(new FakeHmsClient(info), Collections.emptyMap(), + new DirectHudiMetaClientExecutor()); + Optional handle = metadata.getTableHandle(null, "db", "t"); + Assertions.assertTrue(handle.isPresent()); + return ((HudiTableHandle) handle.get()).getHudiTableType(); + } + + @Test + public void testCowDetectedFromInputFormat() { + Assertions.assertEquals("COPY_ON_WRITE", + detect("org.apache.hudi.hadoop.HoodieParquetInputFormat", Collections.emptyMap())); + } + + @Test + public void testCowDetectedFromSparkProviderParam() { + // A Spark-registered Hudi table may carry no Hudi input format; the provider + // parameter still identifies it as COW. + Assertions.assertEquals("COPY_ON_WRITE", + detect(null, Collections.singletonMap("spark.sql.sources.provider", "hudi"))); + } + + @Test + public void testMorDetectedFromRealtimeInputFormat() { + Assertions.assertEquals("MERGE_ON_READ", + detect("org.apache.hudi.hadoop.realtime.HoodieParquetRealtimeInputFormat", + Collections.emptyMap())); + } + + @Test + public void testUnknownWhenNoHudiSignal() { + Assertions.assertEquals("UNKNOWN", + detect("org.apache.hadoop.mapred.TextInputFormat", Collections.emptyMap())); + } + + /** + * Minimal {@link HmsClient} double returning a fixed table. Only {@code tableExists} + * and {@code getTable} are exercised by {@code getTableHandle}; the rest fail loud. + */ + private static final class FakeHmsClient implements HmsClient { + private final HmsTableInfo tableInfo; + + FakeHmsClient(HmsTableInfo tableInfo) { + this.tableInfo = tableInfo; + } + + @Override + public boolean tableExists(String dbName, String tableName) { + return true; + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + return tableInfo; + } + + @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() { + } + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiTimeTravelTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiTimeTravelTest.java new file mode 100644 index 00000000000000..3f38545306858a --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiTimeTravelTest.java @@ -0,0 +1,198 @@ +// 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.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; +import org.apache.doris.connector.api.mvcc.ConnectorTimeTravelSpec; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Tests the Hudi {@code FOR TIME AS OF} time-travel surface (resolveTimeTravel + applySnapshot + the + * queryInstant pin on the handle) added so a hudi-on-HMS table served post-flip through the GENERIC + * {@code PluginDrivenScanNode} path honors an explicit instant, byte-faithfully to legacy {@code HudiScanNode}. + * Each assertion pins WHY the behavior matters: + *
    + *
  • {@code FOR TIME AS OF} is a pure lexical {@code replaceAll("[-: ]","")} of the value (legacy + * {@code HudiScanNode.java:211}) — NO session time zone, NO epoch-millis conversion, NO timeline + * validation, and it NEVER errors on a well-formed value (a too-early/future instant reads empty/latest + * via before-or-on);
  • + *
  • {@code FOR VERSION AS OF} (numeric snapshot-id or named ref) is rejected by THROWING the byte-for-byte + * legacy message, so the exact wording reaches the user (an empty return would surface fe-core's + * wrong-domain "can't find snapshot" text);
  • + *
  • applySnapshot stamps the instant via {@code toBuilder()} so it PRESERVES applyFilter's + * prunedPartitionPaths (applyFilter runs first at scan time);
  • + *
  • the query-begin latest pin (empty properties) leaves the handle UNCHANGED, so a plain read stays + * byte-identical to before this step.
  • + *
+ * + *

resolveTimeTravel and applySnapshot never touch the hmsClient or the metaClient executor, so the metadata + * is built with {@code null} collaborators: any accidental metadata access would NPE, which itself proves the + * fully-offline / no-validation contract. + */ +public class HudiTimeTravelTest { + + private static final String VERSION_REJECT_MESSAGE = + "Hudi does not support `FOR VERSION AS OF`, please use `FOR TIME AS OF`"; + private static final List YEAR_MONTH = Arrays.asList("year", "month"); + + // ── FOR TIME AS OF: normalize + pin ──────────────────────────────────────────────────────────────── + + @Test + public void resolveTimestampStripsSeparatorsAndPinsInstantOntoHandle() { + HudiConnectorMetadata md = metadata(); + // "2024-01-01 12:00:00" -> "20240101120000": dashes/colons/space stripped, nothing else. Drive the + // full path resolveTimeTravel -> applySnapshot so the private carrier property is exercised end-to-end. + ConnectorMvccSnapshot pin = resolveTimestamp(md, "2024-01-01 12:00:00"); + HudiTableHandle pinned = (HudiTableHandle) md.applySnapshot(null, partitioned(), pin); + Assertions.assertEquals("20240101120000", pinned.getQueryInstant(), + "FOR TIME AS OF must strip [-: ] and pin the value verbatim (legacy HudiScanNode:211) — " + + "no session-TZ shift, no epoch-millis conversion"); + } + + @Test + public void resolveTimestampIgnoresDigitalFlagAndDoesNoConversion() { + HudiConnectorMetadata md = metadata(); + // A digital (epoch-millis-looking) value has no [-: ] to strip, so it passes through VERBATIM — legacy + // hudi never treated FOR TIME AS OF as epoch-millis (unlike paimon); it is compared lexically before-or-on. + ConnectorMvccSnapshot pin = md.resolveTimeTravel(null, partitioned(), + ConnectorTimeTravelSpec.timestamp("1704067200000", true)).orElseThrow(AssertionError::new); + HudiTableHandle pinned = (HudiTableHandle) md.applySnapshot(null, partitioned(), pin); + Assertions.assertEquals("1704067200000", pinned.getQueryInstant()); + } + + @Test + public void resolveTimestampIsPermissiveAndFullyOffline() { + // A too-early instant (before any commit) resolves to a NON-EMPTY pin, NOT Optional.empty(): legacy + // never validates FOR TIME AS OF against the timeline and never errors — before-or-on simply reads + // empty. The metadata's hmsClient + executor are null, so a present result also proves resolveTimeTravel + // performs ZERO metadata access (any timeline lookup would NPE here). + HudiConnectorMetadata md = metadata(); + Optional pin = md.resolveTimeTravel(null, partitioned(), + ConnectorTimeTravelSpec.timestamp("1999-01-01 00:00:00", false)); + Assertions.assertTrue(pin.isPresent(), + "well-formed FOR TIME AS OF must always pin (permissive, legacy parity), never return empty"); + } + + // ── FOR VERSION AS OF: reject with the byte-for-byte legacy message ───────────────────────────────── + + @Test + public void resolveSnapshotIdRejectsWithByteForByteLegacyMessage() { + HudiConnectorMetadata md = metadata(); + // FOR VERSION AS OF arrives as SNAPSHOT_ID. Must THROW (not empty) so the exact legacy string + // reaches the user verbatim (it propagates as-is through loadSnapshot, which has no try/catch). + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> md.resolveTimeTravel(null, partitioned(), ConnectorTimeTravelSpec.snapshotId("123"))); + Assertions.assertEquals(VERSION_REJECT_MESSAGE, ex.getMessage()); + } + + @Test + public void resolveVersionRefRejectsWithByteForByteLegacyMessage() { + HudiConnectorMetadata md = metadata(); + // FOR VERSION AS OF '' arrives as VERSION_REF — hudi rejects it identically (it has no + // tags/branches), with the SAME message. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> md.resolveTimeTravel(null, partitioned(), ConnectorTimeTravelSpec.versionRef("v1.0"))); + Assertions.assertEquals(VERSION_REJECT_MESSAGE, ex.getMessage()); + } + + // ── applySnapshot: stamp preserving pruning / no-op for the latest pin ────────────────────────────── + + @Test + public void applySnapshotStampsInstantPreservingPrunedPartitions() { + HudiConnectorMetadata md = metadata(); + // applyFilter runs BEFORE applySnapshot at scan time, so a pruned handle must keep its pruning after the + // pin. Guards a rebuild-from-scratch mutation (which would silently turn a pruned scan into a full scan). + List pruned = Arrays.asList("year=2024/month=01", "year=2024/month=02"); + HudiTableHandle prunedHandle = partitioned().toBuilder().prunedPartitionPaths(pruned).build(); + ConnectorMvccSnapshot pin = resolveTimestamp(md, "2024-06-01 00:00:00"); + HudiTableHandle stamped = (HudiTableHandle) md.applySnapshot(null, prunedHandle, pin); + Assertions.assertEquals("20240601000000", stamped.getQueryInstant()); + Assertions.assertEquals(pruned, stamped.getPrunedPartitionPaths()); + } + + @Test + public void applySnapshotLeavesLatestPinUnchanged() { + HudiConnectorMetadata md = metadata(); + // The query-begin latest pin (beginQuerySnapshot output) carries ONLY a snapshotId, NO query-instant + // property. applySnapshot must return the handle UNCHANGED so planScan falls back to timeline.lastInstant() + // — the no-regression guard for every ordinary (non-time-travel) hudi read. + ConnectorMvccSnapshot latestPin = + HudiConnectorMetadata.buildBeginQuerySnapshot(20240101120000000L).orElseThrow(AssertionError::new); + HudiTableHandle base = partitioned(); + HudiTableHandle result = (HudiTableHandle) md.applySnapshot(null, base, latestPin); + Assertions.assertSame(base, result, "latest pin must not rebuild the handle"); + Assertions.assertNull(result.getQueryInstant()); + } + + @Test + public void applySnapshotWithNullSnapshotIsUnchanged() { + HudiConnectorMetadata md = metadata(); + HudiTableHandle base = partitioned(); + Assertions.assertSame(base, md.applySnapshot(null, base, null)); + } + + // ── handle field round-trip ───────────────────────────────────────────────────────────────────────── + + @Test + public void toBuilderRoundTripsQueryInstantAndPreservesEveryField() { + Map params = Collections.singletonMap("k", "v"); + HudiTableHandle original = new HudiTableHandle.Builder("db", "t", "s3://b/t", "MERGE_ON_READ") + .inputFormat("org.apache.hudi.hadoop.realtime.HoodieParquetRealtimeInputFormat") + .serdeLib("org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe") + .partitionKeyNames(YEAR_MONTH) + .tableParameters(params) + .prunedPartitionPaths(Arrays.asList("year=2024/month=01")) + .queryInstant("20240101120000") + .build(); + HudiTableHandle copy = original.toBuilder().build(); + Assertions.assertEquals("20240101120000", copy.getQueryInstant()); + Assertions.assertEquals(original.getInputFormat(), copy.getInputFormat()); + Assertions.assertEquals(original.getSerdeLib(), copy.getSerdeLib()); + Assertions.assertEquals(original.getPartitionKeyNames(), copy.getPartitionKeyNames()); + Assertions.assertEquals(original.getTableParameters(), copy.getTableParameters()); + Assertions.assertEquals(original.getPrunedPartitionPaths(), copy.getPrunedPartitionPaths()); + // A fresh handle carries no pin (null), so a plain read stays on timeline.lastInstant(). + Assertions.assertNull(partitioned().getQueryInstant()); + } + + // ── helpers ──────────────────────────────────────────────────────────────────────────────────────── + + /** resolveTimeTravel/applySnapshot never touch hmsClient or the executor → null collaborators are safe. */ + private static HudiConnectorMetadata metadata() { + return new HudiConnectorMetadata(null, Collections.emptyMap(), null); + } + + private static HudiTableHandle partitioned() { + return new HudiTableHandle.Builder("db", "t", "s3://b/t", "COPY_ON_WRITE") + .partitionKeyNames(YEAR_MONTH).build(); + } + + private static ConnectorMvccSnapshot resolveTimestamp(HudiConnectorMetadata md, String value) { + return md.resolveTimeTravel(null, partitioned(), + ConnectorTimeTravelSpec.timestamp(value, false)).orElseThrow(AssertionError::new); + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiTypeMappingTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiTypeMappingTest.java new file mode 100644 index 00000000000000..669d5f4f96b9b3 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiTypeMappingTest.java @@ -0,0 +1,220 @@ +// 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.connector.api.ConnectorType; + +import org.apache.avro.LogicalTypes; +import org.apache.avro.Schema; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +/** + * Tests {@link HudiTypeMapping#toHiveTypeString} and {@link HudiTypeMapping#fromAvroSchema}. + * + *

WHY (toHiveTypeString): the BE Hudi JNI scanner ({@code HadoopHudiJniScanner}) + * parses {@code hudi_column_types} as Hive type strings split on {@code '#'}. The FE + * must therefore emit full Hive type strings carrying precision/scale and + * subtypes — not Doris type names — or the scanner reads wrong/null columns. + * These tests pin the exact strings, matching fe-core + * {@code HudiUtils.convertAvroToHiveType}.

+ * + *

WHY (fromAvroSchema): {@code getTableSchema} reports each column's + * {@link ConnectorType} from this mapper. These tests pin the Doris type per Avro + * type, matching fe-core {@code HudiUtils.fromAvroHudiTypeToDorisType} (P3-T07 + * parity baseline — previously uncovered). Note the deliberate asymmetry: time + * types map to {@code TIMEV2} here but fail loud in {@code toHiveTypeString}, + * exactly as the two legacy converters diverge.

+ */ +public class HudiTypeMappingTest { + + @Test + public void testPrimitives() { + Assertions.assertEquals("boolean", HudiTypeMapping.toHiveTypeString(Schema.create(Schema.Type.BOOLEAN))); + Assertions.assertEquals("int", HudiTypeMapping.toHiveTypeString(Schema.create(Schema.Type.INT))); + Assertions.assertEquals("bigint", HudiTypeMapping.toHiveTypeString(Schema.create(Schema.Type.LONG))); + Assertions.assertEquals("float", HudiTypeMapping.toHiveTypeString(Schema.create(Schema.Type.FLOAT))); + Assertions.assertEquals("double", HudiTypeMapping.toHiveTypeString(Schema.create(Schema.Type.DOUBLE))); + Assertions.assertEquals("string", HudiTypeMapping.toHiveTypeString(Schema.create(Schema.Type.STRING))); + } + + @Test + public void testDateAndTimestampLogicalTypes() { + Schema date = LogicalTypes.date().addToSchema(Schema.create(Schema.Type.INT)); + Assertions.assertEquals("date", HudiTypeMapping.toHiveTypeString(date)); + + Schema tsMillis = LogicalTypes.timestampMillis().addToSchema(Schema.create(Schema.Type.LONG)); + Assertions.assertEquals("timestamp", HudiTypeMapping.toHiveTypeString(tsMillis)); + + Schema tsMicros = LogicalTypes.timestampMicros().addToSchema(Schema.create(Schema.Type.LONG)); + Assertions.assertEquals("timestamp", HudiTypeMapping.toHiveTypeString(tsMicros)); + } + + @Test + public void testDecimalKeepsPrecisionAndScale() { + // Directly targets bug (a): getTypeName() previously dropped precision/scale. + Schema decimal = LogicalTypes.decimal(10, 2).addToSchema(Schema.create(Schema.Type.BYTES)); + Assertions.assertEquals("decimal(10,2)", HudiTypeMapping.toHiveTypeString(decimal)); + + Schema decimalFixed = LogicalTypes.decimal(38, 18) + .addToSchema(Schema.createFixed("d", null, null, 16)); + Assertions.assertEquals("decimal(38,18)", HudiTypeMapping.toHiveTypeString(decimalFixed)); + } + + @Test + public void testArray() { + Schema arr = Schema.createArray(Schema.create(Schema.Type.INT)); + Assertions.assertEquals("array", HudiTypeMapping.toHiveTypeString(arr)); + } + + @Test + public void testMap() { + // Avro maps always have string keys. + Schema map = Schema.createMap(Schema.create(Schema.Type.LONG)); + Assertions.assertEquals("map", HudiTypeMapping.toHiveTypeString(map)); + } + + @Test + public void testStructContainsCommas() { + // Directly targets bug (b): the comma in struct<...> must survive as a + // single type string; a comma join+split would shatter it. + Schema struct = Schema.createRecord("r", null, null, false, Arrays.asList( + new Schema.Field("a", Schema.create(Schema.Type.INT)), + new Schema.Field("b", Schema.create(Schema.Type.STRING)))); + Assertions.assertEquals("struct", HudiTypeMapping.toHiveTypeString(struct)); + } + + @Test + public void testNestedComplexType() { + Schema struct = Schema.createRecord("r", null, null, false, Arrays.asList( + new Schema.Field("id", Schema.create(Schema.Type.LONG)), + new Schema.Field("amount", + LogicalTypes.decimal(12, 4).addToSchema(Schema.create(Schema.Type.BYTES))))); + Schema arrOfStruct = Schema.createArray(struct); + Assertions.assertEquals("array>", + HudiTypeMapping.toHiveTypeString(arrOfStruct)); + } + + @Test + public void testNullableUnionIsUnwrapped() { + Schema nullableInt = Schema.createUnion( + Schema.create(Schema.Type.NULL), Schema.create(Schema.Type.INT)); + Assertions.assertEquals("int", HudiTypeMapping.toHiveTypeString(nullableInt)); + } + + @Test + public void testUnsupportedLogicalTypeFailsLoud() { + // Matches legacy fail-loud: time types are unsupported. + Schema timeMillis = LogicalTypes.timeMillis().addToSchema(Schema.create(Schema.Type.INT)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> HudiTypeMapping.toHiveTypeString(timeMillis)); + } + + // ===== fromAvroSchema -> ConnectorType (parity with HudiUtils.fromAvroHudiTypeToDorisType) ===== + + @Test + public void testFromAvroSchemaPrimitives() { + Assertions.assertEquals(ConnectorType.of("BOOLEAN"), + HudiTypeMapping.fromAvroSchema(Schema.create(Schema.Type.BOOLEAN))); + Assertions.assertEquals(ConnectorType.of("INT"), + HudiTypeMapping.fromAvroSchema(Schema.create(Schema.Type.INT))); + Assertions.assertEquals(ConnectorType.of("BIGINT"), + HudiTypeMapping.fromAvroSchema(Schema.create(Schema.Type.LONG))); + Assertions.assertEquals(ConnectorType.of("FLOAT"), + HudiTypeMapping.fromAvroSchema(Schema.create(Schema.Type.FLOAT))); + Assertions.assertEquals(ConnectorType.of("DOUBLE"), + HudiTypeMapping.fromAvroSchema(Schema.create(Schema.Type.DOUBLE))); + Assertions.assertEquals(ConnectorType.of("STRING"), + HudiTypeMapping.fromAvroSchema(Schema.create(Schema.Type.STRING))); + // Avro bytes/fixed without a decimal logical type degrade to STRING (legacy parity). + Assertions.assertEquals(ConnectorType.of("STRING"), + HudiTypeMapping.fromAvroSchema(Schema.create(Schema.Type.BYTES))); + } + + @Test + public void testFromAvroSchemaLogicalTypes() { + Assertions.assertEquals(ConnectorType.of("DATEV2"), + HudiTypeMapping.fromAvroSchema( + LogicalTypes.date().addToSchema(Schema.create(Schema.Type.INT)))); + Assertions.assertEquals(ConnectorType.of("DATETIMEV2", 3, 0), + HudiTypeMapping.fromAvroSchema( + LogicalTypes.timestampMillis().addToSchema(Schema.create(Schema.Type.LONG)))); + Assertions.assertEquals(ConnectorType.of("DATETIMEV2", 6, 0), + HudiTypeMapping.fromAvroSchema( + LogicalTypes.timestampMicros().addToSchema(Schema.create(Schema.Type.LONG)))); + // Time types map to TIMEV2 here, unlike toHiveTypeString which fails loud — + // matching legacy HudiUtils.fromAvroHudiTypeToDorisType. + Assertions.assertEquals(ConnectorType.of("TIMEV2", 3, 0), + HudiTypeMapping.fromAvroSchema( + LogicalTypes.timeMillis().addToSchema(Schema.create(Schema.Type.INT)))); + Assertions.assertEquals(ConnectorType.of("TIMEV2", 6, 0), + HudiTypeMapping.fromAvroSchema( + LogicalTypes.timeMicros().addToSchema(Schema.create(Schema.Type.LONG)))); + } + + @Test + public void testFromAvroSchemaDecimalKeepsPrecisionAndScale() { + Schema decimal = LogicalTypes.decimal(10, 2).addToSchema(Schema.create(Schema.Type.BYTES)); + Assertions.assertEquals(ConnectorType.of("DECIMALV3", 10, 2), + HudiTypeMapping.fromAvroSchema(decimal)); + } + + @Test + public void testFromAvroSchemaComplexTypes() { + Assertions.assertEquals( + ConnectorType.arrayOf(ConnectorType.of("INT")), + HudiTypeMapping.fromAvroSchema(Schema.createArray(Schema.create(Schema.Type.INT)))); + // Avro maps always have string keys. + Assertions.assertEquals( + ConnectorType.mapOf(ConnectorType.of("STRING"), ConnectorType.of("BIGINT")), + HudiTypeMapping.fromAvroSchema(Schema.createMap(Schema.create(Schema.Type.LONG)))); + Schema struct = Schema.createRecord("r", null, null, false, Arrays.asList( + new Schema.Field("a", Schema.create(Schema.Type.INT)), + new Schema.Field("b", Schema.create(Schema.Type.STRING)))); + Assertions.assertEquals( + ConnectorType.structOf(Arrays.asList("a", "b"), + Arrays.asList(ConnectorType.of("INT"), ConnectorType.of("STRING"))), + HudiTypeMapping.fromAvroSchema(struct)); + } + + @Test + public void testFromAvroSchemaNullableUnionUnwrapped() { + Schema nullableInt = Schema.createUnion( + Schema.create(Schema.Type.NULL), Schema.create(Schema.Type.INT)); + Assertions.assertEquals(ConnectorType.of("INT"), + HudiTypeMapping.fromAvroSchema(nullableInt)); + } + + @Test + public void testFromAvroSchemaEnumMapsToString() { + Schema enumSchema = Schema.createEnum("e", null, null, Arrays.asList("A", "B")); + Assertions.assertEquals(ConnectorType.of("STRING"), + HudiTypeMapping.fromAvroSchema(enumSchema)); + } + + @Test + public void testFromAvroSchemaMultiMemberUnionUnsupported() { + // A true union (no single non-null member) is unsupported (legacy parity). + Schema union = Schema.createUnion( + Schema.create(Schema.Type.INT), Schema.create(Schema.Type.STRING)); + Assertions.assertEquals(ConnectorType.of("UNSUPPORTED"), + HudiTypeMapping.fromAvroSchema(union)); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/pom.xml b/fe/fe-connector/fe-connector-iceberg/pom.xml index 575f1220084690..d05edc1e8e7c10 100644 --- a/fe/fe-connector/fe-connector-iceberg/pom.xml +++ b/fe/fe-connector/fe-connector-iceberg/pom.xml @@ -47,20 +47,119 @@ under the License. ${project.version}
- + + + ${project.groupId} + fe-connector-cache + ${project.version} + + + + + ${project.groupId} + fe-connector-hms + ${project.version} + + + + + ${project.groupId} + fe-foundation + ${project.version} + + + + + ${project.groupId} + fe-connector-metastore-iceberg + ${project.version} + + + + + ${project.groupId} + fe-thrift + ${project.version} + provided + + + org.apache.iceberg iceberg-core ${iceberg.version} - + + + com.github.ben-manes.caffeine + caffeine + 2.9.3 + + + + + org.apache.iceberg + iceberg-bundled-guava + ${iceberg.version} + + + org.apache.iceberg iceberg-aws ${iceberg.version} + + org.apache.hadoop @@ -68,6 +167,116 @@ under the License. ${hadoop.version} + + + org.apache.hadoop + hadoop-hdfs-client + ${hadoop.version} + runtime + + + org.apache.hadoop + hadoop-common + + + + + + + org.apache.hadoop + hadoop-aws + + + + + software.amazon.awssdk + s3 + + + software.amazon.awssdk + glue + + + software.amazon.awssdk + apache-client + + + + + software.amazon.awssdk + sts + + + software.amazon.awssdk + apache-client + + + + + software.amazon.awssdk + s3tables + + + software.amazon.awssdk + s3-transfer-manager + + + software.amazon.awssdk + sdk-core + + + software.amazon.awssdk + aws-json-protocol + + + software.amazon.awssdk + protocol-core + + + software.amazon.awssdk + url-connection-client + + + + + software.amazon.s3tables + s3-tables-catalog-for-iceberg + + org.apache.logging.log4j log4j-api @@ -78,6 +287,28 @@ under the License. junit-jupiter test + + + + org.apache.hadoop + hadoop-mapreduce-client-core + test + + + + + commons-lang + commons-lang + test + diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/assembly/plugin-zip.xml b/fe/fe-connector/fe-connector-iceberg/src/main/assembly/plugin-zip.xml index 0d29baa55b34bf..9c46915cc7bf65 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/assembly/plugin-zip.xml +++ b/fe/fe-connector/fe-connector-iceberg/src/main/assembly/plugin-zip.xml @@ -46,6 +46,14 @@ under the License. org.apache.doris:fe-connector-spi org.apache.doris:fe-extension-spi org.apache.doris:fe-filesystem-api + + org.apache.doris:fe-thrift + org.apache.thrift:libthrift + + io.netty:netty-codec-native-quic org.apache.logging.log4j:* org.slf4j:* diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/AwsCredentialsProviderModes.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/AwsCredentialsProviderModes.java new file mode 100644 index 00000000000000..b8ea01f5893ef0 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/AwsCredentialsProviderModes.java @@ -0,0 +1,127 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.ContainerCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider; +import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider; +import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider; +import software.amazon.awssdk.auth.credentials.WebIdentityTokenFileCredentialsProvider; + +import java.util.Locale; +import java.util.Map; + +/** + * F14: resolves the user's AWS credential provider mode into either the iceberg-SDK + * {@code client.credentials-provider} class name (for the S3FileIO / REST-signing property maps) or a live AWS + * SDK v2 provider instance (for the s3tables control-plane client). The connector cannot import the fe-core + * {@code AwsCredentialsProviderFactory}, so this is a self-contained twin of its + * {@code getV2ClassName(mode)} / {@code createV2(mode)} plus the {@code AwsCredentialsProviderMode.fromString} + * normalization ({@code trim / toUpperCase / '-' -> '_'}). + * + *

The mode string comes from the original catalog properties under {@code s3.credentials_provider_type} (and + * its aliases) or {@code iceberg.rest.credentials_provider_type}. {@code DEFAULT} — the common case, and also + * blank / unknown — yields NO explicit class name ({@code null}) and the SDK default-chain provider, exactly + * mirroring legacy {@code putCredentialsProvider}'s early return for {@code DEFAULT}. Only the six non-DEFAULT + * modes (which legacy pinned to a specific provider class) were being silently dropped on the connector path + * because {@link org.apache.doris.filesystem.properties.S3CompatibleFileSystemProperties} exposes no + * provider-mode accessor. {@code AwsCredentialsProviderModesTest} pins the emitted class names against the AWS + * SDK classes so a drift fails loud. + */ +final class AwsCredentialsProviderModes { + + // Aliases for the S3 store's credential-provider mode (generic S3 / glue / s3tables signing paths). + // Byte-identical to the alias set master binds S3Properties.credentialsProviderType from + // (S3Properties.java @ConnectorProperty: s3.credentials_provider_type / glue.credentials_provider_type / + // iceberg.rest.credentials_provider_type) — a glue/s3tables PROVIDER_CHAIN catalog may carry the mode under + // any of the three, so all must be honored or the pin is silently dropped for the rest-alias form. + static final String[] S3_MODE_KEYS = { + "s3.credentials_provider_type", "glue.credentials_provider_type", + "iceberg.rest.credentials_provider_type"}; + + private AwsCredentialsProviderModes() {} + + /** + * The non-DEFAULT provider class name for the first non-blank mode key in {@code props}, or {@code null} + * for DEFAULT / blank / unknown (so callers emit nothing and the SDK default chain applies). + */ + static String classNameFor(Map props, String... modeKeys) { + Class clazz = classFor(resolveMode(props, modeKeys)); + return clazz == null ? null : clazz.getName(); + } + + /** + * The AWS SDK v2 provider instance for the first non-blank mode key in {@code props}; + * {@link DefaultCredentialsProvider} for DEFAULT / blank / unknown. + */ + static AwsCredentialsProvider providerFor(Map props, String... modeKeys) { + switch (resolveMode(props, modeKeys)) { + case "ENV": + return EnvironmentVariableCredentialsProvider.create(); + case "SYSTEM_PROPERTIES": + return SystemPropertyCredentialsProvider.create(); + case "WEB_IDENTITY": + return WebIdentityTokenFileCredentialsProvider.create(); + case "CONTAINER": + return ContainerCredentialsProvider.create(); + case "INSTANCE_PROFILE": + return InstanceProfileCredentialsProvider.create(); + case "ANONYMOUS": + return AnonymousCredentialsProvider.create(); + default: + return DefaultCredentialsProvider.create(); + } + } + + private static Class classFor(String mode) { + switch (mode) { + case "ENV": + return EnvironmentVariableCredentialsProvider.class; + case "SYSTEM_PROPERTIES": + return SystemPropertyCredentialsProvider.class; + case "WEB_IDENTITY": + return WebIdentityTokenFileCredentialsProvider.class; + case "CONTAINER": + return ContainerCredentialsProvider.class; + case "INSTANCE_PROFILE": + return InstanceProfileCredentialsProvider.class; + case "ANONYMOUS": + return AnonymousCredentialsProvider.class; + default: + // DEFAULT / blank / unknown -> SDK default chain, no explicit class emitted. + return null; + } + } + + /** First non-blank mode value normalized like legacy AwsCredentialsProviderMode.fromString; else "DEFAULT". */ + private static String resolveMode(Map props, String... modeKeys) { + if (props == null) { + return "DEFAULT"; + } + for (String key : modeKeys) { + String value = props.get(key); + if (value != null && !value.trim().isEmpty()) { + return value.trim().toUpperCase(Locale.ROOT).replace('-', '_'); + } + } + return "DEFAULT"; + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergAuthenticatedFileIO.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergAuthenticatedFileIO.java new file mode 100644 index 00000000000000..bfefb188fb7f23 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergAuthenticatedFileIO.java @@ -0,0 +1,114 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.kerberos.HadoopAuthenticator; + +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.security.PrivilegedExceptionAction; +import java.util.Map; +import java.util.Objects; + +/** + * A {@link FileIO} decorator that runs every file-factory call ({@code newInputFile} / {@code newOutputFile} + * / {@code deleteFile}) inside a plugin-side Kerberos {@code doAs}. Installed by + * {@code IcebergConnectorTransaction.openTransaction} for a Kerberos catalog so that iceberg's parallel manifest + * writes — fanned onto the shared {@code ThreadPools.getWorkerPool()}, which runs OUTSIDE the caller-thread + * {@code doAs} — still authenticate against secured HDFS. + * + *

Why wrapping only the factory methods suffices. {@code HadoopFileIO.newOutputFile}/{@code newInputFile} + * resolve and capture the {@code FileSystem} (via {@code FileSystem.get(uri, conf)}, keyed by + * {@code UserGroupInformation.getCurrentUser()}) at factory-call time and hand it to the returned + * {@code HadoopOutputFile}/{@code HadoopInputFile}. Running the factory call under {@code doAs} therefore captures + * the Kerberos FileSystem (whose cached {@code DFSClient} proxy is bound to the Kerberos UGI); the deferred + * stream I/O ({@code createOrOverwrite()} / {@code newStream()}), even when it later runs on an unauthenticated + * worker-pool thread, reuses that FileSystem's Kerberos connection. {@code deleteFile} resolves the FileSystem and + * issues the delete in one call, so wrapping it covers both. The streams need no wrapping. + * + *

The {@code doAs} is an exact mirror of {@code HadoopExecutionAuthenticator.execute} + * ({@code hadoopAuthenticator.doAs(action)}) — the same single-owner authenticator instance + * {@link TcclPinningConnectorContext} uses on the caller thread. {@code IOException} from the authenticator is + * surfaced as {@link UncheckedIOException} because the {@link FileIO} factory methods declare no checked exception + * (iceberg wraps it into its own {@code RuntimeIOException} at the call site, as it does for a raw factory failure). + * + *

The bundled {@code HadoopFileIO} additionally implements {@code DelegateFileIO} (bulk / prefix ops), used only + * by maintenance actions (orphan-file cleanup), never by the append/rewrite commit path exercised here; a caller + * that probes for those interfaces simply falls back to per-file operations, which route through the wrapped + * primitives above. Kept a plain {@link FileIO} deliberately to avoid coupling to that optional surface. + */ +final class IcebergAuthenticatedFileIO implements FileIO { + + private final FileIO delegate; + private final HadoopAuthenticator authenticator; + + IcebergAuthenticatedFileIO(FileIO delegate, HadoopAuthenticator authenticator) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.authenticator = Objects.requireNonNull(authenticator, "authenticator"); + } + + @Override + public InputFile newInputFile(String path) { + return doAs(() -> delegate.newInputFile(path)); + } + + @Override + public InputFile newInputFile(String path, long length) { + return doAs(() -> delegate.newInputFile(path, length)); + } + + @Override + public OutputFile newOutputFile(String path) { + return doAs(() -> delegate.newOutputFile(path)); + } + + @Override + public void deleteFile(String path) { + doAs(() -> { + delegate.deleteFile(path); + return null; + }); + } + + @Override + public Map properties() { + return delegate.properties(); + } + + @Override + public void initialize(Map properties) { + delegate.initialize(properties); + } + + @Override + public void close() { + delegate.close(); + } + + private T doAs(PrivilegedExceptionAction action) { + try { + return authenticator.doAs(action); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergAuthenticatedTableOperations.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergAuthenticatedTableOperations.java new file mode 100644 index 00000000000000..da94def0c34c25 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergAuthenticatedTableOperations.java @@ -0,0 +1,105 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.encryption.EncryptionManager; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.LocationProvider; + +import java.util.Objects; + +/** + * A {@link TableOperations} decorator that forwards every call to the delegate EXCEPT {@link #io()}, which returns + * an auth-wrapping {@link IcebergAuthenticatedFileIO}. Used by {@code IcebergConnectorTransaction.openTransaction} + * to build a Kerberos transaction via {@code Transactions.newTransaction(name, ops, reporter)}: iceberg's + * {@code SnapshotProducer} takes its manifest {@code OutputFile} from {@code ops.io()}, so routing {@code io()} + * through the wrapper is what carries the plugin Kerberos {@code doAs} onto the worker-pool manifest writes. + * + *

Only {@code io()} is altered; {@code current}/{@code refresh}/{@code commit} and the metadata/location seams + * forward unchanged, so commit semantics (optimistic concurrency, metadata JSON write on the caller thread — which + * is already inside the caller-thread {@code doAs}) are byte-for-byte the delegate's. {@code temp()} must ALSO + * wrap: {@code BaseTransaction.TransactionTableOperations} never reads {@code io()} from this instance — its + * {@code io()} returns {@code tempOps.io()} where {@code tempOps = ops.temp(current)} (rebuilt on every + * intermediate commit), and that is exactly where {@code SnapshotProducer} takes the manifest {@code OutputFile} + * from. Forwarding {@code temp()} unwrapped hands the raw FileIO to the worker-pool manifest writes and reopens + * the Kerberos SIMPLE-auth failure this class exists to fix. + */ +final class IcebergAuthenticatedTableOperations implements TableOperations { + + private final TableOperations delegate; + private final FileIO io; + + IcebergAuthenticatedTableOperations(TableOperations delegate, FileIO io) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.io = Objects.requireNonNull(io, "io"); + } + + @Override + public TableMetadata current() { + return delegate.current(); + } + + @Override + public TableMetadata refresh() { + return delegate.refresh(); + } + + @Override + public void commit(TableMetadata base, TableMetadata metadata) { + delegate.commit(base, metadata); + } + + @Override + public FileIO io() { + return io; + } + + @Override + public EncryptionManager encryption() { + return delegate.encryption(); + } + + @Override + public String metadataFileLocation(String fileName) { + return delegate.metadataFileLocation(fileName); + } + + @Override + public LocationProvider locationProvider() { + return delegate.locationProvider(); + } + + @Override + public TableOperations temp(TableMetadata uncommittedMetadata) { + // The delegate's temp ops (e.g. HadoopTableOperations.temp) expose the RAW FileIO; re-wrap so the + // transaction's tempOps — the io() source for worker-pool manifest writes — stays authenticated. + return new IcebergAuthenticatedTableOperations(delegate.temp(uncommittedMetadata), io); + } + + @Override + public long newSnapshotId() { + return delegate.newSnapshotId(); + } + + @Override + public boolean requireStrictCleanup() { + return delegate.requireStrictCleanup(); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogFactory.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogFactory.java new file mode 100644 index 00000000000000..d270b9b6897fab --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogFactory.java @@ -0,0 +1,734 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.cache.CacheSpec; +import org.apache.doris.filesystem.properties.S3CompatibleFileSystemProperties; +import org.apache.doris.filesystem.properties.StorageProperties; + +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.hive.conf.HiveConf; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.aws.AssumeRoleAwsClientFactory; +import org.apache.iceberg.aws.AwsClientProperties; +import org.apache.iceberg.aws.AwsProperties; +import org.apache.iceberg.aws.s3.S3FileIOProperties; +import org.apache.iceberg.rest.auth.OAuth2Properties; + +import java.io.File; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Pure, testable assembly core for the Iceberg connector flavor switch — the iceberg-SDK-specific bits + * that stay in the connector. Mirrors the role of {@code PaimonCatalogFactory}: a stateless static + * holder whose methods are PURE (they read only the supplied props — no env, no clock, no live + * catalog), which is what makes them unit-testable offline. + * + *

P6.1 (this task) holds only the flavor resolution: {@link #resolveFlavor(Map)} (the lower-cased + * {@code iceberg.catalog.type}) and {@link #resolveCatalogImpl(String)} (the catalog-impl class name + * for the five {@code CatalogUtil}-built flavors plus the two bespoke ones). The full per-flavor + * property / Hadoop-{@code Configuration} / {@code HiveConf} assembly currently dropped by the + * skeleton — ported from the fe-core {@code AbstractIcebergProperties} + each + * {@code Iceberg*MetaStoreProperties#initCatalog} — lands in a later task (P6-T05/T06/T07); this task + * is a structural inversion only, with no behavior change. + * + *

Note: {@code s3tables} is listed here for completeness, but legacy does NOT build it via + * {@code CatalogUtil.buildIcebergCatalog} (it hand-builds an {@code S3TablesClient}). Routing it through + * the impl-name path is the existing skeleton behavior, preserved verbatim here. + */ +public final class IcebergCatalogFactory { + + // Manifest-cache derivation defaults — mirror fe-core IcebergExternalCatalog so the + // meta.cache.iceberg.manifest.* -> io.manifest.cache-enabled derivation matches legacy exactly. + private static final boolean DEFAULT_MANIFEST_CACHE_ENABLE = false; + private static final long DEFAULT_MANIFEST_CACHE_TTL_SECOND = 48L * 60 * 60; + private static final long DEFAULT_MANIFEST_CACHE_CAPACITY = 1024L; + + // Mirror of legacy AWSGlueMetaStoreBaseProperties.ENDPOINT_PATTERN: extracts the region from a glue + // endpoint host (e.g. glue.us-east-1.amazonaws.com / glue-fips.us-east-1.api.aws) when no explicit + // glue.region is set, before falling back to us-east-1. + private static final Pattern GLUE_ENDPOINT_PATTERN = Pattern.compile( + "^(?:https?://)?(?:glue|glue-fips)\\.([a-z0-9-]+)\\.(?:api\\.aws|amazonaws\\.com)$"); + + // Region-field aliases scanned to propagate client.region when NO fe-filesystem S3 storage is bound + // (e.g. REST vended credentials: no static AK/SK/role, so S3FileSystemProvider.supports is false and + // chosenS3 is empty). Verbatim connector-side copy (fe-connector must not import fe-core) of the fe-core + // S3Properties @ConnectorProperty(isRegionField=true) region aliases — the S3 subset of the alias set the + // legacy getRegionFromProperties scanned; declared order preserved so s3.region still wins on conflict. + // The OSS/COS/OBS/Minio subclass region aliases are deliberately excluded (irrelevant to an AWS-S3-backed + // vended REST catalog). The raw s3.region copied by buildBaseCatalogProperties is inert because iceberg + // S3FileIO reads client.region, not s3.region. + private static final String[] S3_REGION_ALIASES = { + "s3.region", "AWS_REGION", "region", "REGION", "aws.region", "glue.region", + "aws.glue.region", "iceberg.rest.signing-region", "rest.signing-region", "client.region"}; + + private IcebergCatalogFactory() { + } + + /** + * Builds the COMMON iceberg catalog-property map shared by all five {@code CatalogUtil} flavors, + * mirroring the legacy {@code AbstractIcebergProperties.initializeCatalog} base: (1) seed from ALL + * raw props (legacy {@code getOrigProps()} copy-all — arbitrary user/iceberg keys pass through to + * the SDK; the per-flavor appenders + the connector add the derived keys on top), (2) map + * {@code warehouse} to {@link CatalogProperties#WAREHOUSE_LOCATION}, (3) add manifest-cache keys. + * PURE: depends only on {@code props}. The flavor's {@code catalog-impl} and the {@code type} + * removal are applied by the caller (the connector / per-flavor path). + */ + public static Map buildBaseCatalogProperties(Map props) { + Map opts = new HashMap<>(props); + String warehouse = props.get(CatalogProperties.WAREHOUSE_LOCATION); + if (StringUtils.isNotBlank(warehouse)) { + opts.put(CatalogProperties.WAREHOUSE_LOCATION, warehouse); + } + appendManifestCacheProperties(props, opts); + return opts; + } + + /** + * Mirrors legacy {@code AbstractIcebergProperties.addManifestCacheProperties}: pass through any + * explicitly-set {@code io.manifest.cache.*} keys, then — only when the user did NOT set + * {@code io.manifest.cache-enabled} directly — derive it to {@code "true"} from the FE meta-cache + * spec ({@code meta.cache.iceberg.manifest.*}) using the same {@code enable && ttl != 0 && + * capacity != 0} rule. Default-disabled (legacy {@code DEFAULT_ICEBERG_MANIFEST_CACHE_ENABLE}). + */ + private static void appendManifestCacheProperties(Map props, Map opts) { + boolean hasExplicitEnabled = StringUtils.isNotBlank(props.get(CatalogProperties.IO_MANIFEST_CACHE_ENABLED)); + copyIfPresent(props, opts, CatalogProperties.IO_MANIFEST_CACHE_ENABLED); + copyIfPresent(props, opts, CatalogProperties.IO_MANIFEST_CACHE_EXPIRATION_INTERVAL_MS); + copyIfPresent(props, opts, CatalogProperties.IO_MANIFEST_CACHE_MAX_TOTAL_BYTES); + copyIfPresent(props, opts, CatalogProperties.IO_MANIFEST_CACHE_MAX_CONTENT_LENGTH); + if (!hasExplicitEnabled) { + CacheSpec spec = CacheSpec.fromProperties(props, + IcebergConnectorProperties.MANIFEST_CACHE_ENABLE, DEFAULT_MANIFEST_CACHE_ENABLE, + IcebergConnectorProperties.MANIFEST_CACHE_TTL, DEFAULT_MANIFEST_CACHE_TTL_SECOND, + IcebergConnectorProperties.MANIFEST_CACHE_CAPACITY, DEFAULT_MANIFEST_CACHE_CAPACITY); + if (CacheSpec.isCacheEnabled(spec.isEnable(), spec.getTtlSecond(), spec.getCapacity())) { + opts.put(CatalogProperties.IO_MANIFEST_CACHE_ENABLED, "true"); + } + } + } + + private static void copyIfPresent(Map props, Map opts, String key) { + String value = props.get(key); + if (StringUtils.isNotBlank(value)) { + opts.put(key, value); + } + } + + /** + * Selects the S3-compatible storage whose iceberg S3FileIO config should be emitted, mirroring + * legacy {@code AbstractIcebergProperties.toFileIOProperties}: prefer the first NON-generic-S3 + * provider (an explicit {@code OSS}/{@code COS}/{@code OBS} choice trumps the generic {@code S3} + * fallback), else the first S3-compatible storage. The generic-S3 analog of legacy {@code + * S3Properties} is identified by {@code providerName().equals("S3")} — note OSS/COS/OBS all report + * {@code FileSystemType.S3}, so the provider NAME (not {@code type()}) is the discriminator. PURE. + */ + public static Optional chooseS3Compatible( + List storages) { + S3CompatibleFileSystemProperties fallback = null; + S3CompatibleFileSystemProperties target = null; + for (StorageProperties sp : storages) { + if (sp instanceof S3CompatibleFileSystemProperties) { + S3CompatibleFileSystemProperties s3 = (S3CompatibleFileSystemProperties) sp; + if (fallback == null) { + fallback = s3; + } + if (target == null && !"S3".equals(s3.providerName())) { + target = s3; + } + } + } + return Optional.ofNullable(target != null ? target : fallback); + } + + /** + * Emits the iceberg {@code S3FileIO} catalog properties from the chosen fe-filesystem S3-compatible + * storage, mirroring legacy {@code AbstractIcebergProperties.toS3FileIOProperties} (D-061): the + * connector reads the typed {@link S3CompatibleFileSystemProperties} getters and writes the iceberg + * S3FileIO dialect ({@code s3.*} + {@link AwsClientProperties#CLIENT_REGION}); the assume-role block + * (the legacy {@code IcebergAwsAssumeRoleProperties} analog) is emitted only for the generic + * {@code S3} provider (legacy {@code instanceof S3Properties}). Every put is blank-guarded. PURE. + */ + public static void appendS3FileIOProperties(Map opts, S3CompatibleFileSystemProperties s3) { + putS3FileIODialect(opts, s3); + if ("S3".equals(s3.providerName())) { + appendAssumeRoleProperties(opts, s3); + } + } + + /** + * Emits the S3FileIO dialect from the bound fe-filesystem S3 storage when present; otherwise (no bound + * S3 storage, e.g. a REST catalog with vended credentials and no static AK/SK) still propagates + * {@code client.region} from the raw catalog properties so S3FileIO does not fall through to the AWS + * SDK {@code DefaultAwsRegionProviderChain} and fail the write commit with "Unable to load region". + * Legacy parity with {@code AbstractIcebergProperties.toFileIOProperties}, whose {@code chosen == null} + * branch supplied the region for exactly the rest/hadoop/jdbc flavors. + */ + private static void appendS3FileIO(Map opts, Map props, + Optional chosenS3) { + if (chosenS3.isPresent()) { + appendS3FileIOProperties(opts, chosenS3.get()); + } else { + putIfNotBlank(opts, AwsClientProperties.CLIENT_REGION, resolveS3Region(props)); + } + } + + /** + * Resolves the S3 region from the raw catalog props over {@link #S3_REGION_ALIASES} (the fe-core + * {@code S3Properties} {@code isRegionField} set). Single source of truth for the region-alias fallback, + * shared by {@link #appendS3FileIO} (the vended-cred S3FileIO branch) and the s3tables region gate + * ({@code IcebergConnector.resolveS3TablesRegion}). Returns null when no alias is set. + */ + public static String resolveS3Region(Map props) { + return firstNonBlank(props, S3_REGION_ALIASES); + } + + /** + * Emits ONLY the {@code s3.*} + {@link AwsClientProperties#CLIENT_REGION} S3FileIO dialect keys (no + * credential-type block), shared by {@link #appendS3FileIOProperties} (rest/hadoop/jdbc/glue) and the + * s3tables emitter. Every put is blank-guarded. + */ + private static void putS3FileIODialect(Map opts, S3CompatibleFileSystemProperties s3) { + putIfNotBlank(opts, S3FileIOProperties.ENDPOINT, s3.getEndpoint()); + putIfNotBlank(opts, S3FileIOProperties.PATH_STYLE_ACCESS, s3.getUsePathStyle()); + putIfNotBlank(opts, AwsClientProperties.CLIENT_REGION, s3.getRegion()); + putIfNotBlank(opts, S3FileIOProperties.ACCESS_KEY_ID, s3.getAccessKey()); + putIfNotBlank(opts, S3FileIOProperties.SECRET_ACCESS_KEY, s3.getSecretKey()); + putIfNotBlank(opts, S3FileIOProperties.SESSION_TOKEN, s3.getSessionToken()); + } + + /** + * Emits the s3tables S3FileIO credential block mirroring legacy + * {@code IcebergAwsClientCredentialsProperties.putS3FileIOCredentialProperties} — the EXPLICIT-wins ladder, + * which is DISTINCT from the generic {@link #appendS3FileIOProperties} ({@code toS3FileIOProperties}) used by + * rest/hadoop/jdbc/glue: the {@code s3.*} dialect always, then ONLY the credential-type addition. + * {@code EXPLICIT} (static AK/SK present) adds NOTHING — the static keys suffice and, per legacy + * {@code getCredentialType}, EXPLICIT precedes ASSUME_ROLE so a role ARN is ignored when static creds are set. + * {@code ASSUME_ROLE} (role ARN, no static) adds the assume-role block. {@code PROVIDER_CHAIN} sets + * {@code client.credentials-provider} to the non-DEFAULT provider class the user selected (F14; DEFAULT -> + * nothing, SDK default chain). PURE. + */ + private static void appendS3TablesFileIOProperties(Map opts, S3CompatibleFileSystemProperties s3, + Map props) { + putS3FileIODialect(opts, s3); + if (s3.hasStaticCredentials()) { + return; + } + if (s3.hasAssumeRole()) { + appendAssumeRoleProperties(opts, s3); + } else { + // F14: PROVIDER_CHAIN — pin the non-DEFAULT provider class (mirrors legacy putCredentialsProvider). + putIfNotBlank(opts, AwsClientProperties.CLIENT_CREDENTIALS_PROVIDER, + AwsCredentialsProviderModes.classNameFor(props, AwsCredentialsProviderModes.S3_MODE_KEYS)); + } + } + + /** + * Mirrors legacy {@code IcebergAwsAssumeRoleProperties.putAssumeRoleProperties}: no-op unless the + * role ARN is set; otherwise wires {@link AssumeRoleAwsClientFactory} + the {@code aws.region} alias + * and {@code client.assume-role.*} keys (external-id only when present). + */ + private static void appendAssumeRoleProperties(Map opts, S3CompatibleFileSystemProperties s3) { + if (StringUtils.isBlank(s3.getRoleArn())) { + return; + } + opts.put(AwsProperties.CLIENT_FACTORY, AssumeRoleAwsClientFactory.class.getName()); + opts.put("aws.region", s3.getRegion()); + opts.put(AwsProperties.CLIENT_ASSUME_ROLE_REGION, s3.getRegion()); + opts.put(AwsProperties.CLIENT_ASSUME_ROLE_ARN, s3.getRoleArn()); + if (StringUtils.isNotBlank(s3.getExternalId())) { + opts.put(AwsProperties.CLIENT_ASSUME_ROLE_EXTERNAL_ID, s3.getExternalId()); + } + } + + private static void putIfNotBlank(Map opts, String key, String value) { + if (StringUtils.isNotBlank(value)) { + opts.put(key, value); + } + } + + /** Resolves the lower-cased flavor from {@code iceberg.catalog.type}; null/blank stays null. */ + public static String resolveFlavor(Map props) { + String catalogType = props.get(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE); + if (catalogType == null || catalogType.isEmpty()) { + return null; + } + return catalogType.toLowerCase(Locale.ROOT); + } + + /** + * Resolve the Iceberg catalog implementation class name from the catalog type string. PURE: + * depends only on {@code catalogType}. Lifted verbatim from the former + * {@code IcebergConnector.resolveCatalogImpl}. + */ + public static String resolveCatalogImpl(String catalogType) { + if (catalogType == null) { + throw new DorisConnectorException( + "Missing '" + IcebergConnectorProperties.ICEBERG_CATALOG_TYPE + "' property"); + } + switch (catalogType.toLowerCase(Locale.ROOT)) { + case IcebergConnectorProperties.TYPE_REST: + return "org.apache.iceberg.rest.RESTCatalog"; + case IcebergConnectorProperties.TYPE_HMS: + return "org.apache.iceberg.hive.HiveCatalog"; + case IcebergConnectorProperties.TYPE_GLUE: + return "org.apache.iceberg.aws.glue.GlueCatalog"; + case IcebergConnectorProperties.TYPE_HADOOP: + return "org.apache.iceberg.hadoop.HadoopCatalog"; + case IcebergConnectorProperties.TYPE_JDBC: + return "org.apache.iceberg.jdbc.JdbcCatalog"; + case IcebergConnectorProperties.TYPE_S3_TABLES: + return "software.amazon.s3tables.iceberg.S3TablesCatalog"; + default: + throw new DorisConnectorException( + "Unknown " + IcebergConnectorProperties.ICEBERG_CATALOG_TYPE + ": " + catalogType + + ". Supported types: rest, hms, glue, hadoop, jdbc, s3tables"); + } + } + + /** + * Assembles the full iceberg catalog OPTIONS map for one of the five {@code CatalogUtil}-built flavors + * (rest / hms / glue / hadoop / jdbc), mirroring the legacy fe-core + * {@code AbstractIcebergProperties.initializeCatalog} base + each {@code Iceberg*MetaStoreProperties#initCatalog}: + * the common base (copy-all + warehouse + manifest cache), the flavor's {@code catalog-impl}, the + * per-flavor derivations, the S3FileIO dialect (for rest/hadoop/jdbc; glue emits its own), the jdbc + * {@code catalog_name} positional removal, and finally the removal of the {@code type} key (the iceberg SDK + * forbids both {@code type} and {@code catalog-impl}). PURE: a function of {@code props} + {@code chosenS3}. + * + *

The metastore connection (HMS {@code HiveConf}) and storage {@code Configuration} are SEPARATE sinks + * built by the connector ({@link #assembleHiveConf} / {@link #buildHadoopConfiguration}); they are not part + * of this options map. {@code s3tables} is bespoke and falls through to the base + + * impl only here (the existing skeleton behavior), so this method covers exactly the five SDK-built flavors. + */ + public static Map buildCatalogProperties(Map props, String flavor, + Optional chosenS3) { + Map opts = buildBaseCatalogProperties(props); + opts.put(CatalogProperties.CATALOG_IMPL, resolveCatalogImpl(flavor)); + switch (flavor) { + case IcebergConnectorProperties.TYPE_REST: + appendRestProperties(opts, props, chosenS3); + appendS3FileIO(opts, props, chosenS3); + break; + case IcebergConnectorProperties.TYPE_GLUE: + // glue emits its OWN s3.* (unconditional) + glue-client creds; it does NOT use the base + // S3FileIO path (legacy IcebergGlueMetaStoreProperties ignores storagePropertiesList). + appendGlueProperties(opts, props, chosenS3); + break; + case IcebergConnectorProperties.TYPE_JDBC: + appendJdbcProperties(opts, props); + appendS3FileIO(opts, props, chosenS3); + // iceberg.jdbc.catalog_name is the positional catalog NAME (see resolveCatalogName); legacy + // removes it from the options map before building. + opts.remove(IcebergConnectorProperties.JDBC_CATALOG_NAME); + break; + case IcebergConnectorProperties.TYPE_HMS: + // No S3FileIO options: legacy iceberg HMS does not call toFileIOProperties; object-store access + // rides the HiveConf (fs.s3a.* from storage), built by the connector via assembleHiveConf. + break; + case IcebergConnectorProperties.TYPE_HADOOP: + appendS3FileIO(opts, props, chosenS3); + break; + default: + // s3tables: bespoke instantiation. Preserve the skeleton's base+impl routing. + break; + } + // The iceberg SDK forbids both "type" and "catalog-impl"; legacy buildIcebergCatalog removes "type". + opts.remove(CatalogUtil.ICEBERG_CATALOG_TYPE); + return opts; + } + + /** + * Assembles the iceberg catalog OPTIONS map for the BESPOKE {@code s3tables} flavor, mirroring the legacy + * fe-core {@code AbstractIcebergProperties.initializeCatalog} base + {@code IcebergS3TablesMetaStoreProperties} + * {@code buildS3CatalogProperties}: the common base (copy-all + warehouse=table-bucket ARN + manifest cache) + * plus the {@code S3FileIO} dialect ({@code client.region} + {@code s3.*}) and the EXPLICIT-wins credential + * block ({@link #appendS3TablesFileIOProperties}) — which, unlike the generic rest/hadoop/jdbc FileIO path, + * suppresses the assume-role keys when static AK/SK are present (legacy {@code putS3FileIOCredentialProperties} + * returns early for EXPLICIT). PURE: a function of {@code props} + {@code chosenS3}. + * + *

Unlike {@link #buildCatalogProperties}, this does NOT add a {@code catalog-impl} and does NOT remove the + * {@code type} key: s3tables is built by the connector via {@code new S3TablesCatalog().initialize(name, opts, + * client)} (the 3-arg path), NOT by {@code CatalogUtil.buildIcebergCatalog}, so neither the catalog-impl nor + * the type-exclusion the SDK demands of the {@code CatalogUtil} path applies. The only hard requirement of the + * 3-arg initialize is a non-blank {@code warehouse} (the table-bucket ARN), carried here by the base copy-all. + * The control-plane {@code S3TablesClient} (region + credentials + endpoint + http) is built LIVE by the + * connector ({@code IcebergConnector.buildS3TablesClient}); it is not part of this pure options map. + */ + public static Map buildS3TablesCatalogProperties(Map props, + Optional chosenS3) { + Map opts = buildBaseCatalogProperties(props); + if (chosenS3.isPresent()) { + appendS3TablesFileIOProperties(opts, chosenS3.get(), props); + } else { + // No bound S3 storage (e.g. an EC2 instance-profile s3tables catalog: region + warehouse ARN, no + // static creds): still propagate client.region from the raw props so the data-plane S3FileIO honors + // an explicit s3.region rather than only IMDS / DefaultAwsRegionProviderChain (mirrors the vended- + // cred branch in appendS3FileIO). Credentials are left to the SDK default chain (none are bound). + putIfNotBlank(opts, AwsClientProperties.CLIENT_REGION, resolveS3Region(props)); + } + return opts; + } + + /** + * Resolves the catalog NAME to pass to {@code CatalogUtil.buildIcebergCatalog}. For the jdbc flavor this is + * the required {@code iceberg.jdbc.catalog_name} (legacy passes it as the positional {@code catalogName} arg, + * overriding the Doris catalog name); every other flavor uses {@code defaultName} (the Doris catalog name). + */ + public static String resolveCatalogName(Map props, String flavor, String defaultName) { + if (IcebergConnectorProperties.TYPE_JDBC.equals(flavor)) { + String name = firstNonBlank(props, IcebergConnectorProperties.JDBC_CATALOG_NAME); + if (StringUtils.isBlank(name)) { + throw new DorisConnectorException( + IcebergConnectorProperties.JDBC_CATALOG_NAME + " is required for an iceberg jdbc catalog"); + } + return name; + } + return defaultName; + } + + // --------------------------------------------------------------------- + // REST appender (mirror IcebergRestProperties.initIcebergRestCatalogProperties) + // --------------------------------------------------------------------- + + /** + * Mirrors legacy {@code IcebergRestProperties}: core ({@code uri} always, default empty), optional + * ({@code prefix} / vended-credentials header / the two effectively-always timeouts), oauth2, and the glue + * sigv4 signing block (with credentials sourced from the chosen S3 store for glue/s3tables, else from the + * {@code iceberg.rest.*} aliases). PURE. + */ + public static void appendRestProperties(Map opts, Map props, + Optional chosenS3) { + // Core: uri is put UNCONDITIONALLY (legacy field default ""), alias priority iceberg.rest.uri > uri. + opts.put(CatalogProperties.URI, + firstNonBlankOrEmpty(props, IcebergConnectorProperties.REST_URI, IcebergConnectorProperties.URI)); + // Optional. + putIfNotBlank(opts, IcebergConnectorProperties.REST_PREFIX_KEY, + firstNonBlank(props, IcebergConnectorProperties.REST_PREFIX)); + String vendedEnabled = + firstNonBlankOrEmpty(props, IcebergConnectorProperties.REST_VENDED_CREDENTIALS_ENABLED); + if (Boolean.parseBoolean(vendedEnabled)) { + opts.put(IcebergConnectorProperties.REST_VENDED_CREDENTIALS_HEADER, + IcebergConnectorProperties.REST_VENDED_CREDENTIALS_VALUE); + } + // Timeouts: legacy fields default non-blank, so they are effectively always emitted. + opts.put(IcebergConnectorProperties.REST_CONNECTION_TIMEOUT_MS_KEY, + firstNonBlankOr(props, IcebergConnectorProperties.DEFAULT_REST_CONNECTION_TIMEOUT_MS, + IcebergConnectorProperties.REST_CONNECTION_TIMEOUT_MS)); + opts.put(IcebergConnectorProperties.REST_SOCKET_TIMEOUT_MS_KEY, + firstNonBlankOr(props, IcebergConnectorProperties.DEFAULT_REST_SOCKET_TIMEOUT_MS, + IcebergConnectorProperties.REST_SOCKET_TIMEOUT_MS)); + appendRestOAuth2Properties(opts, props); + appendRestSigningProperties(opts, props, chosenS3); + } + + private static void appendRestOAuth2Properties(Map opts, Map props) { + String securityType = firstNonBlank(props, IcebergConnectorProperties.REST_SECURITY_TYPE); + if (!IcebergConnectorProperties.SECURITY_TYPE_OAUTH2.equalsIgnoreCase(securityType)) { + return; + } + String credential = firstNonBlank(props, IcebergConnectorProperties.REST_OAUTH2_CREDENTIAL); + if (StringUtils.isNotBlank(credential)) { + // Client Credentials Flow. + opts.put(OAuth2Properties.CREDENTIAL, credential); + putIfNotBlank(opts, OAuth2Properties.OAUTH2_SERVER_URI, + firstNonBlank(props, IcebergConnectorProperties.REST_OAUTH2_SERVER_URI)); + putIfNotBlank(opts, OAuth2Properties.SCOPE, + firstNonBlank(props, IcebergConnectorProperties.REST_OAUTH2_SCOPE)); + opts.put(OAuth2Properties.TOKEN_REFRESH_ENABLED, + firstNonBlankOr(props, String.valueOf(OAuth2Properties.TOKEN_REFRESH_ENABLED_DEFAULT), + IcebergConnectorProperties.REST_OAUTH2_TOKEN_REFRESH_ENABLED)); + } else { + // Pre-configured Token Flow (validation guarantees a token here when credential is absent). + opts.put(OAuth2Properties.TOKEN, + firstNonBlankOrEmpty(props, IcebergConnectorProperties.REST_OAUTH2_TOKEN)); + } + } + + private static void appendRestSigningProperties(Map opts, Map props, + Optional chosenS3) { + String signingName = firstNonBlank(props, IcebergConnectorProperties.REST_SIGNING_NAME); + if (StringUtils.isBlank(signingName)) { + return; + } + // signing-name is case-sensitive; do not lower-case it. + opts.put(IcebergConnectorProperties.REST_SIGNING_NAME_KEY, signingName); + opts.put(IcebergConnectorProperties.REST_SIGV4_ENABLED_KEY, + firstNonBlankOrEmpty(props, IcebergConnectorProperties.REST_SIGV4_ENABLED)); + opts.put(IcebergConnectorProperties.REST_SIGNING_REGION_KEY, + firstNonBlankOrEmpty(props, IcebergConnectorProperties.REST_SIGNING_REGION)); + if (IcebergConnectorProperties.SIGNING_NAME_GLUE.equals(signingName) + || IcebergConnectorProperties.SIGNING_NAME_S3TABLES.equals(signingName)) { + // glue/s3tables: credentials come from the chosen S3 store, switching on its credential type + // (legacy getCredentialType precedence: EXPLICIT before ASSUME_ROLE before PROVIDER_CHAIN). + if (chosenS3.isPresent()) { + S3CompatibleFileSystemProperties s3 = chosenS3.get(); + if (s3.hasStaticCredentials()) { + putRestExplicitCredentials(opts, s3.getAccessKey(), s3.getSecretKey(), s3.getSessionToken()); + } else if (s3.hasAssumeRole()) { + appendAssumeRoleProperties(opts, s3); + } else { + // F14: PROVIDER_CHAIN — pin the non-DEFAULT provider class the user selected via + // s3.credentials_provider_type (mirrors legacy putCredentialsProvider). DEFAULT/blank -> + // null -> nothing emitted (SDK default chain, the common case). + putIfNotBlank(opts, AwsClientProperties.CLIENT_CREDENTIALS_PROVIDER, + AwsCredentialsProviderModes.classNameFor(props, AwsCredentialsProviderModes.S3_MODE_KEYS)); + } + } + } else { + // other signing-name: explicit iceberg.rest.* credentials, else the non-DEFAULT provider chain (F14). + String restAccessKey = firstNonBlank(props, IcebergConnectorProperties.REST_ACCESS_KEY_ID); + String restSecretKey = firstNonBlank(props, IcebergConnectorProperties.REST_SECRET_ACCESS_KEY); + if (StringUtils.isNotBlank(restAccessKey) && StringUtils.isNotBlank(restSecretKey)) { + putRestExplicitCredentials(opts, restAccessKey, restSecretKey, + firstNonBlank(props, IcebergConnectorProperties.REST_SESSION_TOKEN)); + } else { + putIfNotBlank(opts, AwsClientProperties.CLIENT_CREDENTIALS_PROVIDER, + AwsCredentialsProviderModes.classNameFor( + props, IcebergConnectorProperties.REST_CREDENTIALS_PROVIDER_TYPE)); + } + } + } + + /** Mirrors legacy {@code putExplicitRestCredentials}: emit rest.* creds only when AK and SK are both set. */ + private static void putRestExplicitCredentials(Map opts, String accessKey, String secretKey, + String sessionToken) { + if (StringUtils.isBlank(accessKey) || StringUtils.isBlank(secretKey)) { + return; + } + opts.put(AwsProperties.REST_ACCESS_KEY_ID, accessKey); + opts.put(AwsProperties.REST_SECRET_ACCESS_KEY, secretKey); + putIfNotBlank(opts, AwsProperties.REST_SESSION_TOKEN, sessionToken); + } + + // --------------------------------------------------------------------- + // GLUE appender (mirror IcebergGlueMetaStoreProperties.initCatalog) + // --------------------------------------------------------------------- + + /** + * Mirrors legacy {@code IcebergGlueMetaStoreProperties}: the 5 {@code s3.*} FileIO keys emitted + * UNCONDITIONALLY from the chosen S3 store (legacy plain puts allow empty strings), {@code glue.endpoint}, + * exactly one credential branch (AK/SK provider OR assume-role), {@code client.region} (always, with the + * endpoint-regex / us-east-1 fallback), and a {@code putIfAbsent} warehouse placeholder. {@code conf=null}. + * PURE. NOTE (D-061): the s3.* values come from the fe-filesystem typed store, not legacy's + * {@code S3Properties.of(origProps)}; for a glue catalog whose creds were supplied ONLY via {@code glue.*} + * aliases (which fe-filesystem does not read into the S3 store) the s3.* FileIO creds may be absent — a + * UT-invisible edge handled at the P6.6 docker gate (the glue-client creds below still come from glue.*). + */ + public static void appendGlueProperties(Map opts, Map props, + Optional chosenS3) { + chosenS3.ifPresent(s3 -> { + opts.put(S3FileIOProperties.ACCESS_KEY_ID, nullToEmpty(s3.getAccessKey())); + opts.put(S3FileIOProperties.SECRET_ACCESS_KEY, nullToEmpty(s3.getSecretKey())); + opts.put(S3FileIOProperties.ENDPOINT, nullToEmpty(s3.getEndpoint())); + opts.put(S3FileIOProperties.PATH_STYLE_ACCESS, nullToEmpty(s3.getUsePathStyle())); + opts.put(S3FileIOProperties.SESSION_TOKEN, nullToEmpty(s3.getSessionToken())); + }); + String glueEndpoint = firstNonBlank(props, IcebergConnectorProperties.GLUE_ENDPOINT); + putIfNotBlank(opts, AwsProperties.GLUE_CATALOG_ENDPOINT, glueEndpoint); + String glueRegion = resolveGlueRegion(props, glueEndpoint); + String glueAccessKey = firstNonBlank(props, IcebergConnectorProperties.GLUE_ACCESS_KEY); + String glueSecretKey = firstNonBlank(props, IcebergConnectorProperties.GLUE_SECRET_KEY); + if (StringUtils.isNotBlank(glueAccessKey) && StringUtils.isNotBlank(glueSecretKey)) { + opts.put(IcebergConnectorProperties.GLUE_CREDENTIALS_PROVIDER_KEY, + IcebergConnectorProperties.GLUE_CREDENTIALS_PROVIDER_2X); + opts.put(IcebergConnectorProperties.GLUE_CREDENTIALS_PROVIDER_ACCESS_KEY, glueAccessKey); + opts.put(IcebergConnectorProperties.GLUE_CREDENTIALS_PROVIDER_SECRET_KEY, glueSecretKey); + putIfNotBlank(opts, IcebergConnectorProperties.GLUE_CREDENTIALS_PROVIDER_SESSION_TOKEN, + firstNonBlank(props, IcebergConnectorProperties.GLUE_SESSION_TOKEN)); + } else { + String glueIamRole = firstNonBlank(props, IcebergConnectorProperties.GLUE_IAM_ROLE); + if (StringUtils.isNotBlank(glueIamRole)) { + opts.put(AwsProperties.CLIENT_FACTORY, AssumeRoleAwsClientFactory.class.getName()); + opts.put(IcebergConnectorProperties.AWS_REGION_KEY, glueRegion); + opts.put(AwsProperties.CLIENT_ASSUME_ROLE_ARN, glueIamRole); + opts.put(AwsProperties.CLIENT_ASSUME_ROLE_REGION, glueRegion); + putIfNotBlank(opts, AwsProperties.CLIENT_ASSUME_ROLE_EXTERNAL_ID, + firstNonBlank(props, IcebergConnectorProperties.GLUE_EXTERNAL_ID)); + } + } + opts.put(AwsClientProperties.CLIENT_REGION, glueRegion); + opts.putIfAbsent(CatalogProperties.WAREHOUSE_LOCATION, IcebergConnectorProperties.GLUE_CHECKED_WAREHOUSE); + } + + /** + * Mirrors legacy {@code AWSGlueMetaStoreBaseProperties.checkAndInit} region resolution: an explicit + * glue.region / aws.region / aws.glue.region wins; else extract from the endpoint host; else us-east-1. + */ + private static String resolveGlueRegion(Map props, String glueEndpoint) { + String region = firstNonBlank(props, IcebergConnectorProperties.GLUE_REGION); + if (StringUtils.isNotBlank(region)) { + return region; + } + if (StringUtils.isNotBlank(glueEndpoint)) { + Matcher matcher = GLUE_ENDPOINT_PATTERN.matcher(glueEndpoint.toLowerCase(Locale.ROOT)); + if (matcher.matches() && StringUtils.isNotBlank(matcher.group(1))) { + return matcher.group(1); + } + } + return IcebergConnectorProperties.GLUE_DEFAULT_REGION; + } + + // --------------------------------------------------------------------- + // JDBC appender (mirror IcebergJdbcMetaStoreProperties.initIcebergJdbcCatalogProperties) + // --------------------------------------------------------------------- + + /** + * Mirrors legacy {@code IcebergJdbcMetaStoreProperties}: {@code uri} (alias priority {@code uri} > + * {@code iceberg.jdbc.uri}, required), the five dotted {@code jdbc.*} keys added only-if-non-blank from + * their {@code iceberg.jdbc.*} aliases. The raw {@code jdbc.*} passthrough legacy performs is already + * covered by the base copy-all ({@link #buildBaseCatalogProperties} seeds the map from all props), so it is + * not repeated here. The {@code catalog_name} positional removal + driver registration are handled by the + * connector. PURE. + */ + public static void appendJdbcProperties(Map opts, Map props) { + opts.put(CatalogProperties.URI, firstNonBlankOrEmpty(props, IcebergConnectorProperties.JDBC_URI)); + putIfNotBlank(opts, IcebergConnectorProperties.JDBC_USER_KEY, + firstNonBlank(props, IcebergConnectorProperties.JDBC_USER)); + putIfNotBlank(opts, IcebergConnectorProperties.JDBC_PASSWORD_KEY, + firstNonBlank(props, IcebergConnectorProperties.JDBC_PASSWORD)); + putIfNotBlank(opts, IcebergConnectorProperties.JDBC_INIT_CATALOG_TABLES_KEY, + firstNonBlank(props, IcebergConnectorProperties.JDBC_INIT_CATALOG_TABLES)); + putIfNotBlank(opts, IcebergConnectorProperties.JDBC_SCHEMA_VERSION_KEY, + firstNonBlank(props, IcebergConnectorProperties.JDBC_SCHEMA_VERSION)); + putIfNotBlank(opts, IcebergConnectorProperties.JDBC_STRICT_MODE_KEY, + firstNonBlank(props, IcebergConnectorProperties.JDBC_STRICT_MODE)); + } + + // --------------------------------------------------------------------- + // Storage Configuration / HiveConf builders (mirror PaimonCatalogFactory) + // --------------------------------------------------------------------- + + /** + * Builds the storage Hadoop {@link Configuration} for the rest/hadoop/jdbc flavors, mirroring legacy + * {@code new Configuration()} + each storage {@code getHadoopStorageConfig()}: the pre-computed canonical + * object-store/HDFS config ({@code storageHadoopConfig}, from fe-filesystem's + * {@code toHadoopConfigurationMap()}) plus the raw {@code fs.}/{@code dfs.}/{@code hadoop.} passthrough for + * inline user keys. The conf classloader is pinned to the plugin loader so Hadoop's + * {@code fs..impl} resolution stays in one loader (FIX-PAIMON-HADOOP-CLASSLOADER parity). PURE. + */ + public static Configuration buildHadoopConfiguration(Map props, + Map storageHadoopConfig) { + Configuration conf = new Configuration(); + conf.setClassLoader(IcebergCatalogFactory.class.getClassLoader()); + storageHadoopConfig.forEach(conf::set); + props.forEach((key, value) -> { + if (key.startsWith("fs.") || key.startsWith("dfs.") || key.startsWith("hadoop.")) { + conf.set(key, value); + } + }); + return conf; + } + + /** + * Assembles the {@link HiveConf} for the hms flavor, mirroring {@code PaimonCatalogFactory.assembleHiveConf}: + * seed the optional external hive-site.xml named by {@code hive.conf.resources} first, then layer the + * metastore-spi {@code toHiveConfOverrides} on top. Overrides still win: {@code set()} values live in the + * {@link org.apache.hadoop.conf.Configuration} overlay, which is applied after every {@code addResource}. + * The conf classloader is pinned to the plugin loader (HiveMetaStoreClient filter-hook resolution parity). + */ + public static HiveConf assembleHiveConf(String confResources, Map overrides) { + HiveConf hiveConf = new HiveConf(); + hiveConf.setClassLoader(IcebergCatalogFactory.class.getClassLoader()); + addConfResources(hiveConf, confResources); + overrides.forEach(hiveConf::set); + return hiveConf; + } + + /** + * Resolves the FE's {@code hadoop_config_dir}. Mirrors {@code fe-filesystem-hdfs}'s + * {@code HdfsConfigFileLoader.resolveHadoopConfigDir}: a connector plugin cannot import fe-core's + * {@code Config}, so the engine bridges the operator-configured value in via the + * {@code doris.hadoop.config.dir} system property ({@code FileSystemFactory.bindAllStorageProperties} + * sets it). The fallback matches {@code Config.hadoop_config_dir}'s own default. + */ + private static String resolveHadoopConfigDir() { + String fromEngine = System.getProperty("doris.hadoop.config.dir"); + if (StringUtils.isNotBlank(fromEngine)) { + return fromEngine; + } + String home = System.getenv("DORIS_HOME"); + if (StringUtils.isBlank(home)) { + home = System.getProperty("doris.home", ""); + } + return home + "/plugins/hadoop_conf/"; + } + + /** + * Adds the comma-separated {@code hive.conf.resources} files (each resolved under + * {@link #resolveHadoopConfigDir()}) onto {@code hiveConf}. Blank is a no-op; a missing file fails loud, + * byte-identically to the legacy fe-common {@code CatalogConfigFileUtils} message. + * + *

The connector resolves and parses these itself rather than receiving pre-flattened keys from the + * engine: the previous engine-side hook handed over its own {@code new HiveConf()} in full, force-setting + * ~881 hive defaults computed from the ENGINE's hive version on top of this plugin's own HiveConf. + */ + static void addConfResources(HiveConf hiveConf, String confResources) { + if (StringUtils.isBlank(confResources)) { + return; + } + String baseDir = resolveHadoopConfigDir(); + for (String resource : confResources.split(",")) { + String resourcePath = baseDir + resource.trim(); + File file = new File(resourcePath); + if (file.exists() && file.isFile()) { + hiveConf.addResource(new Path(file.toURI())); + } else { + throw new IllegalArgumentException("Config resource file does not exist: " + resourcePath); + } + } + } + + // --------------------------------------------------------------------- + // Pure helpers + // --------------------------------------------------------------------- + + /** Returns the first non-blank value among the given keys (alias priority), or {@code null} if none set. */ + public static String firstNonBlank(Map props, String... keys) { + for (String key : keys) { + String value = props.get(key); + if (StringUtils.isNotBlank(value)) { + return value; + } + } + return null; + } + + private static String firstNonBlankOrEmpty(Map props, String... keys) { + String value = firstNonBlank(props, keys); + return value == null ? "" : value; + } + + private static String firstNonBlankOr(Map props, String defaultValue, String... keys) { + String value = firstNonBlank(props, keys); + return value == null ? defaultValue : value; + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java new file mode 100644 index 00000000000000..f1b8f13cfa4887 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java @@ -0,0 +1,742 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.ddl.BranchChange; +import org.apache.doris.connector.api.ddl.ConnectorColumnPosition; +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 com.google.common.base.Splitter; +import org.apache.iceberg.ManageSnapshots; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.Table; +import org.apache.iceberg.UpdatePartitionSpec; +import org.apache.iceberg.UpdateSchema; +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.SupportsNamespaces; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.catalog.ViewCatalog; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.expressions.Term; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.view.View; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Injection seam over the remote Iceberg {@link Catalog} calls. + * + *

The default {@link CatalogBackedIcebergCatalogOps} simply delegates to a real {@code Catalog}, + * which requires a live remote catalog (REST / HMS / Glue / Hadoop / JDBC / S3Tables / DLF). By + * depending on this interface instead of {@code Catalog} directly, {@link IcebergConnectorMetadata} + * becomes unit-testable offline with a hand-written recording fake (no Mockito) — mirroring the paimon + * connector's {@link org.apache.doris.connector.iceberg.IcebergCatalogFactory} sibling seam pattern + * {@code PaimonCatalogOps}. + * + *

P6.1 (this task) declares only the read subset the metadata layer needs. Write / DDL / MVCC + * methods land in later phases, with signatures mirroring the real Iceberg {@code Catalog}. The + * {@code SupportsNamespaces}-vs-plain-{@code Catalog} branch (which the skeleton leaked into the + * metadata layer) is kept INTERNAL to the default impl. + */ +public interface IcebergCatalogOps { + + /** + * Lists the top-level database (namespace) names, or an empty list when the catalog does not + * support namespaces. Returns the LAST level of each namespace (mirrors the legacy skeleton). + */ + List listDatabaseNames(); + + /** Returns {@code true} iff the database (namespace) exists; {@code false} when no namespace support. */ + boolean databaseExists(String dbName); + + /** Lists the table names in {@code dbName}. */ + List listTableNames(String dbName); + + /** Lists the view names in {@code dbName}; empty when the catalog is not a (view-enabled) ViewCatalog. */ + List listViewNames(String dbName); + + /** Returns {@code true} iff {@code dbName.tableName} exists. */ + boolean tableExists(String dbName, String tableName); + + /** Returns {@code true} iff the view {@code dbName.viewName} exists; {@code false} when no view support. */ + boolean viewExists(String dbName, String viewName); + + /** + * Loads the SDK {@link View} for {@code dbName.viewName}, mirroring {@link #loadTable}. Requires a + * (view-enabled) {@link ViewCatalog}; otherwise fails loud. The sql/dialect/column extraction lives in + * {@code IcebergConnectorMetadata.getViewDefinition} (which holds the type-mapping flags this SDK-only + * seam does not). + */ + View loadView(String dbName, String viewName); + + /** Drops the view {@code dbName.viewName}. Requires a (view-enabled) {@link ViewCatalog}; else fails loud. */ + void dropView(String dbName, String viewName); + + /** Loads the Iceberg {@link Table} for {@code dbName.tableName}. */ + Table loadTable(String dbName, String tableName); + + // ---- DDL writes (B1) — thin delegations to the real Catalog / SupportsNamespaces ---- + + /** Creates the database (namespace) {@code dbName} with {@code properties}. */ + void createDatabase(String dbName, Map properties); + + /** Drops the (already-emptied) database (namespace) {@code dbName}. */ + void dropDatabase(String dbName); + + /** + * Creates {@code dbName.tableName} with the given Iceberg {@code schema} / {@code partitionSpec} / + * {@code properties} and, when non-null and sorted, {@code sortOrder}. + */ + void createTable(String dbName, String tableName, Schema schema, PartitionSpec partitionSpec, + SortOrder sortOrder, Map properties); + + /** Drops {@code dbName.tableName}; {@code purge} requests deletion of the underlying data + metadata. */ + void dropTable(String dbName, String tableName, boolean purge); + + /** Renames {@code dbName.oldName} to {@code dbName.newName} (same database). */ + void renameTable(String dbName, String oldName, String newName); + + /** The table's storage location, or empty when blank — read BEFORE a drop to prune empty dirs. */ + Optional loadTableLocation(String dbName, String tableName); + + /** The database (namespace)'s {@code location} metadata, or empty when absent/blank. */ + Optional loadNamespaceLocation(String dbName); + + // ---- Column evolution (B2) — build + commit an UpdateSchema; thin delegations to the real Table ---- + + /** Adds {@code column} to {@code dbName.tableName} at {@code position} (null = append at the end). */ + void addColumn(String dbName, String tableName, IcebergColumnChange column, ConnectorColumnPosition position); + + /** Adds {@code columns} to {@code dbName.tableName}, appended in order, in a single schema update. */ + void addColumns(String dbName, String tableName, List columns); + + /** Drops {@code columnName} from {@code dbName.tableName}. */ + void dropColumn(String dbName, String tableName, String columnName); + + /** Renames {@code oldName} to {@code newName} in {@code dbName.tableName}. */ + void renameColumn(String dbName, String tableName, String oldName, String newName); + + /** Modifies a primitive {@code column} (type/comment/nullable) of {@code dbName.tableName}, optional move. */ + void modifyColumn(String dbName, String tableName, IcebergColumnChange column, ConnectorColumnPosition position); + + /** Reorders the columns of {@code dbName.tableName} to match {@code newOrder} (full ordered name list). */ + void reorderColumns(String dbName, String tableName, List newOrder); + + // ---- Branch / tag refs (B4) — build + commit a ManageSnapshots; needs the live Table ---- + + /** Creates or replaces the branch described by {@code branch} on {@code dbName.tableName}. */ + void createOrReplaceBranch(String dbName, String tableName, BranchChange branch); + + /** Creates or replaces the tag described by {@code tag} on {@code dbName.tableName}. */ + void createOrReplaceTag(String dbName, String tableName, TagChange tag); + + /** Drops the branch named by {@code branch} from {@code dbName.tableName} (no-op when absent + ifExists). */ + void dropBranch(String dbName, String tableName, DropRefChange branch); + + /** Drops the tag named by {@code tag} from {@code dbName.tableName} (no-op when absent + ifExists). */ + void dropTag(String dbName, String tableName, DropRefChange tag); + + // ---- Partition evolution (B5) — build + commit an UpdatePartitionSpec; needs the live Table ---- + + /** Adds the partition field described by {@code change} to {@code dbName.tableName}'s spec. */ + void addPartitionField(String dbName, String tableName, PartitionFieldChange change); + + /** Drops the partition field described by {@code change} from {@code dbName.tableName}'s spec. */ + void dropPartitionField(String dbName, String tableName, PartitionFieldChange change); + + /** Replaces a partition field (remove old + add new) per {@code change} in {@code dbName.tableName}'s spec. */ + void replacePartitionField(String dbName, String tableName, PartitionFieldChange change); + + void close() throws IOException; + + /** + * Default implementation backing the seam with a real Iceberg {@link Catalog}. Each method is a + * thin delegation; the {@code Catalog} is the only state. Keeps the {@code SupportsNamespaces} + * branch internal. + */ + class CatalogBackedIcebergCatalogOps implements IcebergCatalogOps { + + private static final Logger LOG = LogManager.getLogger(CatalogBackedIcebergCatalogOps.class); + + // The iceberg namespace-metadata key carrying the database location (legacy NAMESPACE_LOCATION_PROP). + private static final String NAMESPACE_LOCATION_PROP = "location"; + + private final Catalog catalog; + // Explicit view catalog for the session-aware (iceberg.rest.session=user) path: a per-request + // RESTSessionCatalog.asCatalog(ctx) is a Catalog + SupportsNamespaces but NOT a ViewCatalog, so its view + // facet (asViewCatalog(ctx)) is injected here separately. null on the shared path, where the catalog + // itself is cast to ViewCatalog when it implements it (RESTCatalog does). + private final ViewCatalog viewCatalog; + // Listing-parity gating mirrored from legacy IcebergMetadataOps (threaded from IcebergConnector): + private final boolean restFlavor; + private final boolean nestedNamespaceEnabled; + private final boolean viewEnabled; + private final Optional externalCatalogName; + + public CatalogBackedIcebergCatalogOps(Catalog catalog) { + this(catalog, false, false, true, Optional.empty()); + } + + public CatalogBackedIcebergCatalogOps(Catalog catalog, boolean restFlavor, + boolean nestedNamespaceEnabled, boolean viewEnabled, Optional externalCatalogName) { + this(catalog, null, restFlavor, nestedNamespaceEnabled, viewEnabled, externalCatalogName); + } + + public CatalogBackedIcebergCatalogOps(Catalog catalog, ViewCatalog viewCatalog, boolean restFlavor, + boolean nestedNamespaceEnabled, boolean viewEnabled, Optional externalCatalogName) { + this.catalog = catalog; + this.viewCatalog = viewCatalog; + this.restFlavor = restFlavor; + this.nestedNamespaceEnabled = nestedNamespaceEnabled; + this.viewEnabled = viewEnabled; + this.externalCatalogName = externalCatalogName; + } + + @Override + public List listDatabaseNames() { + if (!(catalog instanceof SupportsNamespaces)) { + LOG.warn("Iceberg catalog does not support namespaces"); + return Collections.emptyList(); + } + return listNestedNamespaces(rootNamespace()); + } + + /** + * Lists databases under {@code parentNs}, mirroring legacy {@code IcebergMetadataOps}: for a REST + * flavor with {@code iceberg.rest.nested-namespace-enabled=true} it RECURSES, emitting each child's + * dotted {@code toString()} followed by its descendants; otherwise it returns each child's last + * level only. Assumes the catalog is a {@link SupportsNamespaces} (guarded by the callers). + */ + private List listNestedNamespaces(Namespace parentNs) { + SupportsNamespaces nsCatalog = (SupportsNamespaces) catalog; + if (restFlavor && nestedNamespaceEnabled) { + return nsCatalog.listNamespaces(parentNs).stream() + .flatMap(childNs -> Stream.concat( + Stream.of(childNs.toString()), + listNestedNamespaces(childNs).stream())) + .collect(Collectors.toList()); + } + return nsCatalog.listNamespaces(parentNs).stream() + .map(ns -> ns.level(ns.length() - 1)) + .collect(Collectors.toList()); + } + + @Override + public boolean databaseExists(String dbName) { + if (!(catalog instanceof SupportsNamespaces)) { + return false; + } + return ((SupportsNamespaces) catalog).namespaceExists(toNamespace(dbName)); + } + + @Override + public List listTableNames(String dbName) { + Namespace ns = toNamespace(dbName); + List tableNames = catalog.listTables(ns).stream() + .map(TableIdentifier::name) + .collect(Collectors.toList()); + // iceberg's listTables also returns views, so subtract the view names when the catalog is a + // (view-enabled) ViewCatalog — mirrors legacy IcebergMetadataOps.listTableNames. + if (!isViewCatalogEnabled()) { + return tableNames; + } + List views = resolveViewCatalog().listViews(ns).stream() + .map(TableIdentifier::name) + .collect(Collectors.toList()); + if (views.isEmpty()) { + return tableNames; + } + return tableNames.stream() + .filter(name -> !views.contains(name)) + .collect(Collectors.toList()); + } + + @Override + public List listViewNames(String dbName) { + // Mirrors legacy IcebergMetadataOps.listViewNames: empty unless the catalog is a + // (view-enabled) ViewCatalog. The auth wrapping / exception normalization is in + // IcebergConnectorMetadata.listViewNames, keeping this a thin catalog delegation. + if (!isViewCatalogEnabled()) { + return Collections.emptyList(); + } + return resolveViewCatalog().listViews(toNamespace(dbName)).stream() + .map(TableIdentifier::name) + .collect(Collectors.toList()); + } + + @Override + public boolean tableExists(String dbName, String tableName) { + return catalog.tableExists(toTableIdentifier(dbName, tableName)); + } + + @Override + public boolean viewExists(String dbName, String viewName) { + // Mirrors legacy IcebergMetadataOps.viewExists: false unless the catalog is a + // (view-enabled) ViewCatalog. Auth wrapping is in IcebergConnectorMetadata.viewExists. + if (!isViewCatalogEnabled()) { + return false; + } + return resolveViewCatalog().viewExists(toTableIdentifier(dbName, viewName)); + } + + @Override + public View loadView(String dbName, String viewName) { + // Mirrors loadTable: a thin SDK delegation that returns the iceberg View. Requires a (view-enabled) + // ViewCatalog; otherwise fails loud. The sql/dialect/column extraction lives in + // IcebergConnectorMetadata.getViewDefinition (which holds the type-mapping flags this SDK-only seam + // does not). Auth wrapping is in IcebergConnectorMetadata.getViewDefinition. + if (!isViewCatalogEnabled()) { + throw new DorisConnectorException("View is not supported with not view catalog."); + } + return resolveViewCatalog().loadView(toTableIdentifier(dbName, viewName)); + } + + @Override + public void dropView(String dbName, String viewName) { + // Mirrors legacy IcebergMetadataOps.performDropView: requires a (view-enabled) ViewCatalog; + // otherwise fails loud. Auth wrapping is in IcebergConnectorMetadata.dropView. + if (!isViewCatalogEnabled()) { + throw new DorisConnectorException("Drop Iceberg view is not supported with not view catalog."); + } + resolveViewCatalog().dropView(toTableIdentifier(dbName, viewName)); + } + + @Override + public Table loadTable(String dbName, String tableName) { + return catalog.loadTable(toTableIdentifier(dbName, tableName)); + } + + @Override + public void createDatabase(String dbName, Map properties) { + requireNamespaces().createNamespace(toNamespace(dbName), properties); + } + + @Override + public void dropDatabase(String dbName) { + requireNamespaces().dropNamespace(toNamespace(dbName)); + } + + @Override + public void createTable(String dbName, String tableName, Schema schema, PartitionSpec partitionSpec, + SortOrder sortOrder, Map properties) { + TableIdentifier id = toTableIdentifier(dbName, tableName); + // Mirror legacy IcebergMetadataOps.performCreateTable: the buildTable path is only needed to + // attach a sort order; otherwise the plain createTable overload is used. + if (sortOrder != null && !sortOrder.isUnsorted()) { + catalog.buildTable(id, schema) + .withPartitionSpec(partitionSpec) + .withProperties(properties) + .withSortOrder(sortOrder) + .create(); + } else { + catalog.createTable(id, schema, partitionSpec, properties); + } + } + + @Override + public void dropTable(String dbName, String tableName, boolean purge) { + catalog.dropTable(toTableIdentifier(dbName, tableName), purge); + } + + @Override + public void renameTable(String dbName, String oldName, String newName) { + catalog.renameTable(toTableIdentifier(dbName, oldName), toTableIdentifier(dbName, newName)); + } + + @Override + public Optional loadTableLocation(String dbName, String tableName) { + String location = catalog.loadTable(toTableIdentifier(dbName, tableName)).location(); + return isBlank(location) ? Optional.empty() : Optional.of(location); + } + + @Override + public Optional loadNamespaceLocation(String dbName) { + Map metadata = requireNamespaces().loadNamespaceMetadata(toNamespace(dbName)); + String location = metadata.get(NAMESPACE_LOCATION_PROP); + return isBlank(location) ? Optional.empty() : Optional.of(location); + } + + @Override + public void addColumn(String dbName, String tableName, IcebergColumnChange column, + ConnectorColumnPosition position) { + UpdateSchema updateSchema = loadTable(dbName, tableName).updateSchema(); + updateSchema.addColumn(column.getName(), column.getType(), column.getComment(), + column.getDefaultValue()); + applyPosition(updateSchema, position, column.getName()); + updateSchema.commit(); + } + + @Override + public void addColumns(String dbName, String tableName, List columns) { + UpdateSchema updateSchema = loadTable(dbName, tableName).updateSchema(); + for (IcebergColumnChange column : columns) { + updateSchema.addColumn(column.getName(), column.getType(), column.getComment(), + column.getDefaultValue()); + } + updateSchema.commit(); + } + + @Override + public void dropColumn(String dbName, String tableName, String columnName) { + UpdateSchema updateSchema = loadTable(dbName, tableName).updateSchema(); + updateSchema.deleteColumn(columnName); + updateSchema.commit(); + } + + @Override + public void renameColumn(String dbName, String tableName, String oldName, String newName) { + UpdateSchema updateSchema = loadTable(dbName, tableName).updateSchema(); + updateSchema.renameColumn(oldName, newName); + updateSchema.commit(); + } + + @Override + public void modifyColumn(String dbName, String tableName, IcebergColumnChange column, + ConnectorColumnPosition position) { + Table table = loadTable(dbName, tableName); + Types.NestedField current = table.schema().findField(column.getName()); + if (current == null) { + throw new DorisConnectorException("Column " + column.getName() + " does not exist"); + } + // Iceberg can widen required -> optional but never optional -> required (existing data may hold + // nulls), so a NOT NULL request on an already-nullable column fails loud — legacy parity + // (IcebergMetadataOps.validateForModifyColumn / validateForModifyComplexColumn). + if (current.isOptional() && !column.isNullable()) { + throw new DorisConnectorException( + "Can not change nullable column " + column.getName() + " to not null"); + } + UpdateSchema updateSchema = table.updateSchema(); + Type newType = column.getType(); + if (newType.isPrimitiveType()) { + updateSchema.updateColumn(column.getName(), newType.asPrimitiveType(), column.getComment()); + } else { + // A complex (STRUCT/ARRAY/MAP) modify diffs the new type against the current one field-by-field + // (IcebergComplexTypeDiff); the top-level column doc is updated separately, as in legacy. + if (current.type().isPrimitiveType()) { + throw new DorisConnectorException("Modify column type from non-complex to complex is not" + + " supported: " + column.getName()); + } + IcebergComplexTypeDiff.apply(updateSchema, column.getName(), current.type(), newType); + if (!Objects.equals(current.doc(), column.getComment())) { + updateSchema.updateColumnDoc(column.getName(), column.getComment()); + } + } + if (column.isNullable()) { + updateSchema.makeColumnOptional(column.getName()); + } + applyPosition(updateSchema, position, column.getName()); + updateSchema.commit(); + } + + @Override + public void reorderColumns(String dbName, String tableName, List newOrder) { + UpdateSchema updateSchema = loadTable(dbName, tableName).updateSchema(); + updateSchema.moveFirst(newOrder.get(0)); + for (int i = 1; i < newOrder.size(); i++) { + updateSchema.moveAfter(newOrder.get(i), newOrder.get(i - 1)); + } + updateSchema.commit(); + } + + @Override + public void createOrReplaceBranch(String dbName, String tableName, BranchChange branch) { + Table icebergTable = loadTable(dbName, tableName); + String branchName = branch.getName(); + if (branchName == null || branchName.trim().isEmpty()) { + throw new DorisConnectorException("Branch name cannot be empty"); + } + // null snapshotId == "use the table's current snapshot" (may itself be null for an empty table), + // mirroring legacy IcebergMetadataOps.createOrReplaceBranchImpl. + Long snapshotId = resolveSnapshotId(branch.getSnapshotId(), icebergTable); + boolean refExists = icebergTable.refs().get(branchName) != null; + ManageSnapshots manageSnapshots = icebergTable.manageSnapshots(); + if (branch.isCreate() && branch.isReplace() && !refExists) { + createBranch(manageSnapshots, branchName, snapshotId); + } else if (branch.isReplace()) { + if (snapshotId == null) { + throw new DorisConnectorException("Cannot complete replace branch operation on " + + icebergTable.name() + " , main has no snapshot"); + } + manageSnapshots.replaceBranch(branchName, snapshotId); + } else { + if (refExists && branch.isIfNotExists()) { + return; + } + createBranch(manageSnapshots, branchName, snapshotId); + } + if (branch.getMaxSnapshotAgeMs() != null) { + manageSnapshots.setMaxSnapshotAgeMs(branchName, branch.getMaxSnapshotAgeMs()); + } + if (branch.getMinSnapshotsToKeep() != null) { + manageSnapshots.setMinSnapshotsToKeep(branchName, branch.getMinSnapshotsToKeep()); + } + if (branch.getMaxRefAgeMs() != null) { + manageSnapshots.setMaxRefAgeMs(branchName, branch.getMaxRefAgeMs()); + } + manageSnapshots.commit(); + } + + @Override + public void createOrReplaceTag(String dbName, String tableName, TagChange tag) { + Table icebergTable = loadTable(dbName, tableName); + Long snapshotId = resolveSnapshotId(tag.getSnapshotId(), icebergTable); + if (snapshotId == null) { + // Creating a tag on an empty table is not allowed (legacy parity, incl. the legacy message text). + throw new DorisConnectorException("Cannot complete replace branch operation on " + + icebergTable.name() + " , main has no snapshot"); + } + String tagName = tag.getName(); + if (tagName == null || tagName.trim().isEmpty()) { + throw new DorisConnectorException("Tag name cannot be empty"); + } + boolean refExists = icebergTable.refs().get(tagName) != null; + ManageSnapshots manageSnapshots = icebergTable.manageSnapshots(); + if (tag.isCreate() && tag.isReplace() && !refExists) { + manageSnapshots.createTag(tagName, snapshotId); + } else if (tag.isReplace()) { + manageSnapshots.replaceTag(tagName, snapshotId); + } else { + if (refExists && tag.isIfNotExists()) { + return; + } + manageSnapshots.createTag(tagName, snapshotId); + } + if (tag.getMaxRefAgeMs() != null) { + manageSnapshots.setMaxRefAgeMs(tagName, tag.getMaxRefAgeMs()); + } + manageSnapshots.commit(); + } + + @Override + public void dropBranch(String dbName, String tableName, DropRefChange branch) { + Table icebergTable = loadTable(dbName, tableName); + SnapshotRef ref = icebergTable.refs().get(branch.getName()); + if (ref != null || !branch.isIfExists()) { + icebergTable.manageSnapshots().removeBranch(branch.getName()).commit(); + } + } + + @Override + public void dropTag(String dbName, String tableName, DropRefChange tag) { + Table icebergTable = loadTable(dbName, tableName); + SnapshotRef ref = icebergTable.refs().get(tag.getName()); + if (ref != null || !tag.isIfExists()) { + icebergTable.manageSnapshots().removeTag(tag.getName()).commit(); + } + } + + /** The explicit snapshot id, else the table's current snapshot id, else {@code null} (empty table). */ + private static Long resolveSnapshotId(Long explicitSnapshotId, Table icebergTable) { + if (explicitSnapshotId != null) { + return explicitSnapshotId; + } + Snapshot current = icebergTable.currentSnapshot(); + return current == null ? null : current.snapshotId(); + } + + /** {@code createBranch(name)} when no snapshot is pinned, else {@code createBranch(name, id)}. */ + private static void createBranch(ManageSnapshots manageSnapshots, String branchName, Long snapshotId) { + if (snapshotId == null) { + manageSnapshots.createBranch(branchName); + } else { + manageSnapshots.createBranch(branchName, snapshotId); + } + } + + @Override + public void addPartitionField(String dbName, String tableName, PartitionFieldChange change) { + UpdatePartitionSpec updateSpec = loadTable(dbName, tableName).updateSpec(); + Term transform = getTransform(change.getTransformName(), change.getColumnName(), + change.getTransformArg()); + // A non-null partitionFieldName is the AS alias (mirroring IcebergMetadataOps.addPartitionField). + if (change.getPartitionFieldName() != null) { + updateSpec.addField(change.getPartitionFieldName(), transform); + } else { + updateSpec.addField(transform); + } + updateSpec.commit(); + } + + @Override + public void dropPartitionField(String dbName, String tableName, PartitionFieldChange change) { + UpdatePartitionSpec updateSpec = loadTable(dbName, tableName).updateSpec(); + // Remove by field name when given, else by the transform that identifies the field (legacy parity). + if (change.getPartitionFieldName() != null) { + updateSpec.removeField(change.getPartitionFieldName()); + } else { + Term transform = getTransform(change.getTransformName(), change.getColumnName(), + change.getTransformArg()); + updateSpec.removeField(transform); + } + updateSpec.commit(); + } + + @Override + public void replacePartitionField(String dbName, String tableName, PartitionFieldChange change) { + UpdatePartitionSpec updateSpec = loadTable(dbName, tableName).updateSpec(); + // Remove the old field first, then add the new one — both in one spec update (legacy parity). + if (change.getOldPartitionFieldName() != null) { + updateSpec.removeField(change.getOldPartitionFieldName()); + } else { + Term oldTransform = getTransform(change.getOldTransformName(), change.getOldColumnName(), + change.getOldTransformArg()); + updateSpec.removeField(oldTransform); + } + Term newTransform = getTransform(change.getTransformName(), change.getColumnName(), + change.getTransformArg()); + if (change.getPartitionFieldName() != null) { + updateSpec.addField(change.getPartitionFieldName(), newTransform); + } else { + updateSpec.addField(newTransform); + } + updateSpec.commit(); + } + + /** + * Builds an iceberg partition {@link Term} from a neutral transform spec, mirroring legacy + * {@code IcebergMetadataOps.getTransform}: a {@code null} transform name is identity ({@code ref}), + * {@code bucket}/{@code truncate} require a width, and {@code year}/{@code month}/{@code day}/ + * {@code hour} take none. An unknown transform fails loud. + */ + private static Term getTransform(String transformName, String columnName, Integer transformArg) { + if (columnName == null) { + throw new DorisConnectorException("Column name is required for partition transform"); + } + if (transformName == null) { + return Expressions.ref(columnName); + } + switch (transformName.toLowerCase(Locale.ROOT)) { + case "bucket": + if (transformArg == null) { + throw new DorisConnectorException("Bucket transform requires a bucket count argument"); + } + return Expressions.bucket(columnName, transformArg); + case "truncate": + if (transformArg == null) { + throw new DorisConnectorException("Truncate transform requires a width argument"); + } + return Expressions.truncate(columnName, transformArg); + case "year": + return Expressions.year(columnName); + case "month": + return Expressions.month(columnName); + case "day": + return Expressions.day(columnName); + case "hour": + return Expressions.hour(columnName); + default: + throw new DorisConnectorException("Unsupported partition transform: " + transformName); + } + } + + /** Applies the (nullable) position to a not-yet-committed schema update: FIRST / AFTER / no-op. */ + private void applyPosition(UpdateSchema updateSchema, ConnectorColumnPosition position, + String columnName) { + if (position == null) { + return; + } + if (position.isFirst()) { + updateSchema.moveFirst(columnName); + } else { + updateSchema.moveAfter(columnName, position.getAfterColumn()); + } + } + + /** The catalog as a {@link SupportsNamespaces}, or a fail-loud error (legacy cast unconditionally). */ + private SupportsNamespaces requireNamespaces() { + if (!(catalog instanceof SupportsNamespaces)) { + throw new DorisConnectorException("Iceberg catalog does not support databases (namespaces)"); + } + return (SupportsNamespaces) catalog; + } + + /** View filtering is on iff a view catalog is resolvable and (for REST) views are enabled. */ + private boolean isViewCatalogEnabled() { + if (resolveViewCatalog() == null) { + return false; + } + return !restFlavor || viewEnabled; + } + + /** + * The view catalog to route view ops through: the explicitly-injected one (the session=user per-request + * {@code asViewCatalog(ctx)}), else the backing catalog itself when it implements {@link ViewCatalog} + * (the shared RESTCatalog path). {@code null} when the catalog has no view facet. + */ + private ViewCatalog resolveViewCatalog() { + if (viewCatalog != null) { + return viewCatalog; + } + return catalog instanceof ViewCatalog ? (ViewCatalog) catalog : null; + } + + /** The root namespace to start database listing from: the external-catalog level, else empty. */ + private Namespace rootNamespace() { + return externalCatalogName.map(Namespace::of).orElseGet(Namespace::empty); + } + + /** + * Builds the multi-level namespace for {@code dbName}, mirroring legacy {@code getNamespace}: split + * on {@code '.'} (omit empties / trim), then append the external-catalog level last when present. + */ + private Namespace toNamespace(String dbName) { + // Use the SAME Guava splitter as legacy IcebergMetadataOps.getNamespace so splitting is + // byte-faithful — including trimResults()==CharMatcher.whitespace(), which trims Unicode + // whitespace above U+0020 (e.g. U+3000) that String.trim() would leave behind. + List levels = new ArrayList<>( + Splitter.on('.').omitEmptyStrings().trimResults().splitToList(dbName)); + externalCatalogName.ifPresent(levels::add); + return Namespace.of(levels.toArray(new String[0])); + } + + private TableIdentifier toTableIdentifier(String dbName, String tableName) { + return TableIdentifier.of(toNamespace(dbName), tableName); + } + + private static boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } + + @Override + public void close() throws IOException { + if (catalog instanceof java.io.Closeable) { + ((java.io.Closeable) catalog).close(); + } + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergColumnChange.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergColumnChange.java new file mode 100644 index 00000000000000..ebd21bfce8e80d --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergColumnChange.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.iceberg; + +import org.apache.iceberg.expressions.Literal; +import org.apache.iceberg.types.Type; + +import java.util.Objects; + +/** + * The already-built iceberg artifacts for a single {@code ADD COLUMN} / {@code MODIFY COLUMN}, passed from + * {@link IcebergConnectorMetadata} to the {@link IcebergCatalogOps} seam. + * + *

Mirrors how B1's {@code createTable} hands the seam a fully-built iceberg {@code Schema}/{@code SortOrder}: + * the metadata layer turns the neutral {@code ConnectorColumn} into an iceberg {@link Type} (+ parsed default + * {@link Literal}) PURELY, outside the auth context, so the seam stays a thin delegation that only does the + * remote {@code UpdateSchema} commit.

+ * + *

{@code ADD COLUMN} uses every field; {@code MODIFY COLUMN} (scalar) uses {@code name}/{@code type}/ + * {@code comment}/{@code nullable} and ignores {@code defaultValue}.

+ */ +public final class IcebergColumnChange { + + private final String name; + private final Type type; + private final String comment; + // The parsed iceberg default literal, or null when no DEFAULT clause / not applicable (MODIFY). + private final Literal defaultValue; + private final boolean nullable; + + public IcebergColumnChange(String name, Type type, String comment, Literal defaultValue, boolean nullable) { + this.name = Objects.requireNonNull(name, "name"); + this.type = Objects.requireNonNull(type, "type"); + this.comment = comment; + this.defaultValue = defaultValue; + this.nullable = nullable; + } + + public String getName() { + return name; + } + + public Type getType() { + return type; + } + + public String getComment() { + return comment; + } + + public Literal getDefaultValue() { + return defaultValue; + } + + public boolean isNullable() { + return nullable; + } + + @Override + public String toString() { + return name + " " + type + (nullable ? " NULL" : " NOT NULL") + + (comment == null ? "" : " COMMENT '" + comment + "'"); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergColumnHandle.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergColumnHandle.java new file mode 100644 index 00000000000000..8293670ff356c2 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergColumnHandle.java @@ -0,0 +1,75 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.handle.ConnectorColumnHandle; + +import java.util.Objects; + +/** + * Iceberg {@link ConnectorColumnHandle}, mirroring the paimon connector's {@code PaimonColumnHandle}. Carries + * the (lowercased) column name and the iceberg field id. {@code IcebergConnectorMetadata.getColumnHandles} + * produces these so {@code PluginDrivenScanNode.buildColumnHandles} can hand the provider the pruned set of + * requested columns — which the field-id schema dictionary (T06) keys the {@code current_schema_id = -1} + * entry off (the CI #969249 fix: the dict's top-level names == the BE scan-slot names BY CONSTRUCTION). + * + *

Equality/hashCode are by name only (mirrors {@code PaimonColumnHandle}): a handle identifies a column + * by its (lowercased) name, the same key {@code allHandles.get(slot.getColumn().getName())} looks it up by. + */ +public class IcebergColumnHandle implements ConnectorColumnHandle { + + private static final long serialVersionUID = 1L; + + private final String name; + private final int fieldId; + + public IcebergColumnHandle(String name, int fieldId) { + this.name = Objects.requireNonNull(name, "name"); + this.fieldId = fieldId; + } + + public String getName() { + return name; + } + + public int getFieldId() { + return fieldId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof IcebergColumnHandle)) { + return false; + } + IcebergColumnHandle that = (IcebergColumnHandle) o; + return name.equals(that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + + @Override + public String toString() { + return name + "[" + fieldId + "]"; + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCommentCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCommentCache.java new file mode 100644 index 00000000000000..aa179082d3d234 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCommentCache.java @@ -0,0 +1,111 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.cache.CacheSpec; +import org.apache.doris.connector.cache.MetaCacheEntry; + +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; + +import java.util.concurrent.ForkJoinPool; +import java.util.function.Supplier; + +/** + * Per-catalog cache of an iceberg table's {@code comment} property (PERF-05), keyed by {@link TableIdentifier}. + * Kills the per-table remote {@code loadTable} that {@code information_schema.tables} / {@code SHOW TABLE STATUS} + * pays for EVERY table just to read its comment ({@code FrontendServiceImpl.listTableStatus} loops per table and + * unconditionally calls {@code getComment} -> {@link IcebergConnectorMetadata#getTableComment} -> + * {@code loadTable}). + * + *

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

No credential gate (unlike {@link IcebergTableCache}, like {@link IcebergPartitionCache} / + * {@link IcebergFormatCache}): the cached value is a bare comment {@link String} with no {@code FileIO} / + * credential. A comment changes only via external DDL, picked up through the REFRESH invalidate hooks. TTL is + * {@code meta.cache.iceberg.table.ttl-second}; {@code <= 0} disables (read live), so a no-cache catalog serves a + * fresh comment even when this object is built. Backed identically to {@link IcebergTableCache}: a contextual, + * access-TTL {@link MetaCacheEntry} with manual miss-load, so the remote load runs OUTSIDE Caffeine's compute + * lock and its exception (e.g. the view-handle {@code NoSuchTableException}) propagates verbatim and a failed + * load is not cached. Lives on the long-lived per-catalog {@link IcebergConnector}; a REFRESH CATALOG rebuilds it. + */ +final class IcebergCommentCache { + + private final MetaCacheEntry entry; + + IcebergCommentCache(long ttlSeconds, int maxSize) { + // Mirror IcebergTableCache: translate the connector's "<= 0 disables" contract to CacheSpec's ttl == 0 + // (disabled) rather than passing a negative value through, which CacheSpec reads as ttl == -1 "no + // expiration (enabled)". This ttl<=0 -> disabled mapping is load-bearing: a vended no-cache catalog builds + // this object but must NOT cache comments (operator "no meta cache" intent). + CacheSpec spec = ttlSeconds > 0 + ? CacheSpec.of(true, ttlSeconds, maxSize) + : CacheSpec.of(true, CacheSpec.CACHE_TTL_DISABLE_CACHE, maxSize); + this.entry = new MetaCacheEntry<>("iceberg-comment", null, spec, + ForkJoinPool.commonPool(), false, true, 0L, true); + } + + /** Caching is on only when the TTL is positive; ttl-second <= 0 means "always read the comment live". */ + boolean isEnabled() { + return entry.stats().isEffectiveEnabled(); + } + + /** + * Returns the cached comment for {@code identifier} if present and unexpired, else runs {@code loader} (the + * live remote {@code loadTable} + property read), caches and returns it. Disabled cache -> {@code loader} + * every call. The loader runs OUTSIDE Caffeine's compute lock (single-flight per key) and its exception + * propagates unwrapped and is NOT cached (the caller degrades a thrown comment load to {@code ""}). + */ + String getOrLoad(TableIdentifier identifier, Supplier loader) { + return entry.get(identifier, ignored -> loader.get()); + } + + /** Drops the cached comment for one table so the next read goes live (REFRESH TABLE). */ + void invalidate(TableIdentifier identifier) { + entry.invalidateKey(identifier); + } + + /** Drops every cached comment for one database (REFRESH DATABASE / DROP DATABASE); match = namespace equality. */ + void invalidateDb(String dbName) { + Namespace ns = Namespace.of(dbName); + entry.invalidateIf(id -> id.namespace().equals(ns)); + } + + /** Drops all cached comments (REFRESH CATALOG). */ + void invalidateAll() { + entry.invalidateAll(); + } + + /** Test-only: current number of cached entries (accurate map membership, not Caffeine's estimate). */ + int size() { + int[] count = {0}; + entry.forEach((key, value) -> count[0]++); + return count[0]; + } + + /** Test-only: how many times the live loader (the remote comment load) actually ran — the metric gate. */ + long loadCountForTest() { + return entry.stats().getLoadSuccessCount(); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergComplexTypeDiff.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergComplexTypeDiff.java new file mode 100644 index 00000000000000..86ea391fabfeb9 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergComplexTypeDiff.java @@ -0,0 +1,317 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; + +import org.apache.iceberg.UpdateSchema; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; + +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * Stages a complex-type {@code MODIFY COLUMN} onto an iceberg {@link UpdateSchema} by recursively diffing the + * table's CURRENT iceberg type against the requested NEW iceberg type (built by + * {@link IcebergSchemaBuilder#buildColumnType} from the neutral {@code ConnectorType}, which now carries the + * per-element nullability + per-STRUCT-field comments needed to drive the diff). + * + *

Connector-internal, pure (no remote calls — it only stages {@code UpdateSchema} operations; the seam + * commits). It is a faithful port of the legacy fe-core {@code IcebergMetadataOps.applyStruct/List/MapChange} + * (which diffed iceberg-old vs Doris-new), re-expressed as iceberg-old vs iceberg-new so the connector never + * touches a Doris {@code Type}. The structural guards that legacy ran up-front in + * {@code ColumnType.checkSupportSchemaChangeForComplexType} (struct may only grow / field names must match + * by position / added fields must be nullable + not conflict / nested primitive promotions are restricted) + * are folded into the same walk — equivalent because nothing is committed until the caller's + * {@code UpdateSchema.commit()}, so any guard throwing aborts the whole change atomically.

+ * + *

Supported shape changes (legacy parity): widen an existing nested field's primitive type (only the + * iceberg-representable safe promotions int→long, float→double, or an exact match), change a nested + * field's comment, widen a NOT NULL nested field to nullable, and append new (nullable) STRUCT fields. The + * category of every nested level must stay the same (struct/array/map); struct fields may not be renamed, + * reordered, dropped, or narrowed to NOT NULL; a MAP key type may not change.

+ */ +public final class IcebergComplexTypeDiff { + + private IcebergComplexTypeDiff() { + } + + /** + * Stages the diff of {@code newType} over {@code oldType} (both rooted at {@code path}) onto + * {@code updateSchema}. {@code oldType} must be a complex type; {@code newType} must be the SAME category. + * + * @throws DorisConnectorException for any unsupported / illegal change (the caller maps it to a DdlException) + */ + public static void apply(UpdateSchema updateSchema, String path, Type oldType, Type newType) { + switch (oldType.typeId()) { + case STRUCT: + requireSameCategory(oldType, newType); + applyStructChange(updateSchema, path, oldType.asStructType(), newType.asStructType()); + break; + case LIST: + requireSameCategory(oldType, newType); + applyListChange(updateSchema, path, (Types.ListType) oldType, (Types.ListType) newType); + break; + case MAP: + requireSameCategory(oldType, newType); + applyMapChange(updateSchema, path, (Types.MapType) oldType, (Types.MapType) newType); + break; + default: + throw new DorisConnectorException("Unsupported complex type for modify: " + oldType); + } + } + + /** + * Best-effort pre-build guard that restores the legacy {@code MODIFY COLUMN} message for a nested narrowing + * to an iceberg-unrepresentable type (e.g. {@code ARRAY -> ARRAY}). Walks the CURRENT iceberg + * {@code oldType} against the requested NEW neutral {@code newType} and, at the first nested primitive leaf + * the new type cannot map to iceberg, throws {@code "Cannot change to in nested types"} — the + * message legacy {@code ColumnType.checkSupportSchemaChangeForComplexType} produced in Doris type space + * (where the narrow target still exists) — instead of the generic {@code "Unsupported type for Iceberg: + * SMALLINT"} that {@link IcebergSchemaBuilder#buildColumnType} throws. If the structures do not align or + * every nested leaf is iceberg-representable it returns without throwing, and the caller keeps the original + * build error — so no other modify changes behavior. + */ + public static void validateNestedModifyRepresentable(Type oldType, ConnectorType newType) { + String newName = newType.getTypeName().toUpperCase(Locale.ROOT); + switch (oldType.typeId()) { + case LIST: + if ("ARRAY".equals(newName) && newType.getChildren().size() == 1) { + validateNestedModifyRepresentable(((Types.ListType) oldType).elementType(), + newType.getChildren().get(0)); + } + return; + case MAP: + if ("MAP".equals(newName) && newType.getChildren().size() == 2) { + Types.MapType oldMap = (Types.MapType) oldType; + validateNestedModifyRepresentable(oldMap.keyType(), newType.getChildren().get(0)); + validateNestedModifyRepresentable(oldMap.valueType(), newType.getChildren().get(1)); + } + return; + case STRUCT: + if ("STRUCT".equals(newName)) { + List oldFields = oldType.asStructType().fields(); + List newChildren = newType.getChildren(); + int shared = Math.min(oldFields.size(), newChildren.size()); + for (int i = 0; i < shared; i++) { + validateNestedModifyRepresentable(oldFields.get(i).type(), newChildren.get(i)); + } + } + return; + default: + // oldType is a primitive leaf: if the new leaf is a primitive iceberg cannot represent, this is + // a narrowing to an unrepresentable nested type -> restore the legacy message (lower-cased to + // match ColumnType.toSql()). + if (newType.getChildren().isEmpty() && !isIcebergRepresentable(newType)) { + throw new DorisConnectorException("Cannot change " + oldType + " to " + + newType.getTypeName().toLowerCase(Locale.ROOT) + " in nested types"); + } + } + } + + private static boolean isIcebergRepresentable(ConnectorType leaf) { + try { + IcebergTypeMapping.toIcebergPrimitive(leaf); + return true; + } catch (DorisConnectorException e) { + return false; + } + } + + private static void applyStructChange(UpdateSchema updateSchema, String path, + Types.StructType oldStruct, Types.StructType newStruct) { + List oldFields = oldStruct.fields(); + List newFields = newStruct.fields(); + + // Legacy ColumnType rule: a struct may only grow. + if (oldFields.size() > newFields.size()) { + throw new DorisConnectorException("Cannot reduce struct fields from " + oldStruct + " to " + newStruct); + } + + Set existingNames = new HashSet<>(); + for (int i = 0; i < oldFields.size(); i++) { + Types.NestedField oldField = oldFields.get(i); + Types.NestedField newField = newFields.get(i); + String fieldPath = path + "." + oldField.name(); + existingNames.add(oldField.name()); + + // Legacy ColumnType rule: existing fields are matched by position and may not be renamed. + if (!oldField.name().equals(newField.name())) { + throw new DorisConnectorException("Cannot rename struct field from '" + oldField.name() + + "' to '" + newField.name() + "'"); + } + + Type oldFieldType = oldField.type(); + Type newFieldType = newField.type(); + if (oldFieldType.isPrimitiveType()) { + boolean typeChanged = !oldFieldType.equals(newFieldType); + if (typeChanged && !isLegalNestedPrimitivePromotion(oldFieldType, newFieldType)) { + throw new DorisConnectorException("Cannot change " + oldFieldType + " to " + newFieldType + + " in nested types"); + } + requireNotNarrowed(oldField, newField, fieldPath); + boolean commentChanged = !Objects.equals(oldField.doc(), newField.doc()); + if (typeChanged || commentChanged) { + updateSchema.updateColumn(fieldPath, newFieldType.asPrimitiveType(), newField.doc()); + } + } else { + requireNotNarrowed(oldField, newField, fieldPath); + apply(updateSchema, fieldPath, oldFieldType, newFieldType); + if (!Objects.equals(oldField.doc(), newField.doc())) { + updateSchema.updateColumnDoc(fieldPath, newField.doc()); + } + } + + // Widen NOT NULL -> nullable (the reverse is rejected above by requireNotNarrowed). + if (oldField.isRequired() && newField.isOptional()) { + updateSchema.makeColumnOptional(fieldPath); + } + } + + // Append the new fields (legacy parity: must be nullable and not clash with an existing name). + for (int i = oldFields.size(); i < newFields.size(); i++) { + Types.NestedField newField = newFields.get(i); + if (existingNames.contains(newField.name())) { + throw new DorisConnectorException("Added struct field '" + newField.name() + + "' conflicts with existing field"); + } + if (newField.isRequired()) { + throw new DorisConnectorException("New struct field '" + newField.name() + "' must be nullable"); + } + updateSchema.addColumn(path, newField.name(), newField.type(), newField.doc()); + } + } + + private static void applyListChange(UpdateSchema updateSchema, String path, + Types.ListType oldList, Types.ListType newList) { + String elementPath = path + "." + oldList.field(oldList.elementId()).name(); + Type oldElement = oldList.elementType(); + Type newElement = newList.elementType(); + if (oldElement.isPrimitiveType()) { + boolean typeChanged = !oldElement.equals(newElement); + if (typeChanged && !isLegalNestedPrimitivePromotion(oldElement, newElement)) { + throw new DorisConnectorException("Cannot change " + oldElement + " to " + newElement + + " in nested types"); + } + requireElementNotNarrowed(oldList, newList, elementPath); + if (typeChanged) { + updateSchema.updateColumn(elementPath, newElement.asPrimitiveType(), null); + } + } else { + requireElementNotNarrowed(oldList, newList, elementPath); + apply(updateSchema, elementPath, oldElement, newElement); + } + if (!oldList.isElementOptional() && newList.isElementOptional()) { + updateSchema.makeColumnOptional(elementPath); + } + } + + private static void applyMapChange(UpdateSchema updateSchema, String path, + Types.MapType oldMap, Types.MapType newMap) { + // Legacy parity: a MAP key type may not change. + if (!oldMap.keyType().equals(newMap.keyType())) { + throw new DorisConnectorException("Cannot change MAP key type from " + oldMap.keyType() + + " to " + newMap.keyType()); + } + String valuePath = path + "." + oldMap.field(oldMap.valueId()).name(); + Type oldValue = oldMap.valueType(); + Type newValue = newMap.valueType(); + if (oldValue.isPrimitiveType()) { + boolean typeChanged = !oldValue.equals(newValue); + if (typeChanged && !isLegalNestedPrimitivePromotion(oldValue, newValue)) { + throw new DorisConnectorException("Cannot change " + oldValue + " to " + newValue + + " in nested types"); + } + requireValueNotNarrowed(oldMap, newMap, valuePath); + if (typeChanged) { + updateSchema.updateColumn(valuePath, newValue.asPrimitiveType(), null); + } + } else { + requireValueNotNarrowed(oldMap, newMap, valuePath); + apply(updateSchema, valuePath, oldValue, newValue); + } + if (!oldMap.isValueOptional() && newMap.isValueOptional()) { + updateSchema.makeColumnOptional(valuePath); + } + } + + /** Rejects narrowing a nullable struct field to NOT NULL (iceberg cannot prove existing rows are non-null). */ + private static void requireNotNarrowed(Types.NestedField oldField, Types.NestedField newField, String fieldPath) { + if (oldField.isOptional() && newField.isRequired()) { + throw new DorisConnectorException("Cannot change nullable column " + fieldPath + " to not null"); + } + } + + private static void requireElementNotNarrowed(Types.ListType oldList, Types.ListType newList, String elementPath) { + if (oldList.isElementOptional() && !newList.isElementOptional()) { + throw new DorisConnectorException("Cannot change nullable column " + elementPath + " to not null"); + } + } + + private static void requireValueNotNarrowed(Types.MapType oldMap, Types.MapType newMap, String valuePath) { + if (oldMap.isValueOptional() && !newMap.isValueOptional()) { + throw new DorisConnectorException("Cannot change nullable column " + valuePath + " to not null"); + } + } + + /** + * Whether changing a nested primitive {@code oldType} to {@code newType} is a legal promotion, mirroring + * legacy {@code ColumnType.checkSupportSchemaChangeForNestedPrimitive} restricted to the iceberg-representable + * cases: an exact match (covers VARCHAR length growth, which both map to iceberg STRING), INT→BIGINT + * (iceberg INTEGER→LONG), and FLOAT→DOUBLE. Everything else (e.g. a nested DECIMAL precision change, + * any narrowing, a category change) is rejected — matching legacy's restrictive nested rule. + */ + private static boolean isLegalNestedPrimitivePromotion(Type oldType, Type newType) { + if (oldType.equals(newType)) { + return true; + } + Type.TypeID oldId = oldType.typeId(); + Type.TypeID newId = newType.typeId(); + if (oldId == Type.TypeID.INTEGER && newId == Type.TypeID.LONG) { + return true; + } + return oldId == Type.TypeID.FLOAT && newId == Type.TypeID.DOUBLE; + } + + /** The iceberg type category (struct/list/map) of {@code newType} must equal {@code oldType}'s. */ + private static void requireSameCategory(Type oldType, Type newType) { + boolean ok; + switch (oldType.typeId()) { + case STRUCT: + ok = newType.isStructType(); + break; + case LIST: + ok = newType.isListType(); + break; + case MAP: + ok = newType.isMapType(); + break; + default: + ok = false; + } + if (!ok) { + throw new DorisConnectorException("Cannot change complex column type category from " + + oldType + " to " + newType); + } + } +} 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 5bdf3628c32e8d..47fb3dd5abd2ae 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java @@ -18,52 +18,797 @@ package org.apache.doris.connector.iceberg; import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorTestResult; +import org.apache.doris.connector.api.ConnectorValidationContext; import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.procedure.ConnectorProcedureOps; +import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; +import org.apache.doris.connector.metastore.HmsMetaStoreProperties; +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.filesystem.properties.S3CompatibleFileSystemProperties; +import org.apache.doris.filesystem.properties.StorageProperties; +import org.apache.doris.kerberos.HadoopAuthenticator; +import org.apache.doris.kerberos.KerberosAuthSpec; +import org.apache.doris.kerberos.KerberosAuthenticationConfig; +import org.apache.doris.thrift.TStorageBackendType; +import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.conf.Configuration; import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.catalog.BaseViewSessionCatalog; import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.catalog.SessionCatalog; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.catalog.ViewCatalog; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.rest.RESTCatalog; +import org.apache.iceberg.rest.RESTSessionCatalog; +import org.apache.iceberg.util.ThreadPools; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3tables.S3TablesClient; +import software.amazon.awssdk.services.s3tables.S3TablesClientBuilder; +import software.amazon.awssdk.services.sts.StsClient; +import software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider; +import software.amazon.s3tables.iceberg.S3TablesCatalog; +import software.amazon.s3tables.iceberg.S3TablesProperties; +import software.amazon.s3tables.iceberg.imports.HttpClientProperties; import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; import java.util.Collections; +import java.util.EnumSet; import java.util.HashMap; +import java.util.Locale; import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; /** * Iceberg connector implementation. Manages the lifecycle of an Iceberg SDK * {@link Catalog} instance for all metadata operations. * - *

Supports all Iceberg catalog backends: REST, HMS, Glue, DLF, JDBC, + *

Supports all Iceberg catalog backends: REST, HMS, Glue, JDBC, * Hadoop, and S3Tables. The backend is determined by the {@code iceberg.catalog.type} - * property, which maps to the appropriate {@code catalog-impl} class for the - * Iceberg SDK's {@link CatalogUtil#buildIcebergCatalog}.

+ * property. The per-flavor catalog-property assembly lives in the pure + * {@link IcebergCatalogFactory} (mirroring {@code PaimonCatalogFactory}); this class drives the + * live catalog creation: it resolves the chosen storage + Hadoop {@code Configuration} / {@code HiveConf} + * sinks, registers the JDBC driver, and wraps {@code CatalogUtil.buildIcebergCatalog} in the FE-injected + * authentication context with the thread-context classloader pinned to the plugin loader.

* *

Phase 1 provides read-only metadata operations (list databases, list tables, * get schema). Write operations, scan planning, actions (compaction, snapshot - * management), and transaction support remain in fe-core temporarily.

+ * management), and transaction support remain in fe-core temporarily. {@code s3tables} uses its bespoke + * 3-arg {@code S3TablesCatalog.initialize(name, opts, client)} path (P6-T06).

*/ public class IcebergConnector implements Connector { private static final Logger LOG = LogManager.getLogger(IcebergConnector.class); + /** + * Caches {@link ClassLoader}s keyed by resolved driver URL so a given JDBC driver jar is loaded at + * most once across catalogs, and tracks the (url#class) keys already registered with the + * {@link java.sql.DriverManager}. Ported verbatim from the legacy + * {@code IcebergJdbcMetaStoreProperties} (mirrors {@code PaimonConnector}). + */ + private static final Map DRIVER_CLASS_LOADER_CACHE = new ConcurrentHashMap<>(); + private static final Set REGISTERED_DRIVER_KEYS = ConcurrentHashMap.newKeySet(); + + // Guards the one-per-JVM pinning of iceberg's shared worker-pool threads to the plugin classloader (see + // pinIcebergWorkerPoolToPluginClassLoader). The iceberg connector provider is loaded once, so every iceberg + // catalog shares the single ThreadPools.getWorkerPool(); pinning it once covers them all. + private static final AtomicBoolean ICEBERG_WORKER_POOL_PINNED = new AtomicBoolean(false); + + // T08 latest-snapshot cache knobs (mirror PaimonConnector). The TTL pins a STABLE snapshot across queries; + // <= 0 means "no-cache catalog" (always live). Defaults mirror the legacy iceberg table cache + // (Config.external_cache_expire_time_seconds_after_access = 24h, Config.max_external_table_cache_num). + static final String TABLE_CACHE_TTL_SECOND = "meta.cache.iceberg.table.ttl-second"; + static final long DEFAULT_TABLE_CACHE_TTL_SECOND = 86400L; + static final int DEFAULT_TABLE_CACHE_CAPACITY = 1000; + + // Doris storage property keys (mirror StorageProperties without a fe-core dependency). + private static final String S3_ACCESS_KEY = "s3.access_key"; + private static final String S3_SECRET_KEY = "s3.secret_key"; + private static final String S3_ENDPOINT = "s3.endpoint"; + private static final String S3_REGION = "s3.region"; + // Catalog property key gating the plugin-side Kerberos authenticator (value matches AuthType.KERBEROS). + private static final String HADOOP_SECURITY_AUTHENTICATION = "hadoop.security.authentication"; + // Polaris REST catalog exposes its object-store base location under this key when the + // "warehouse" property is a catalog name rather than an s3:// location. + private static final String REST_DEFAULT_BASE_LOCATION = "default-base-location"; + + /** The key BE looks up (case-sensitively) to learn which location to probe. */ + private static final String BE_TEST_LOCATION = "test_location"; + private final Map properties; private final ConnectorContext context; private volatile Catalog icebergCatalog; + // Session-aware REST catalog, built for every REST catalog (plain or iceberg.rest.session=user). A SINGLE + // shared instance, held as the ReauthenticatingRestSessionCatalog wrapper (BaseViewSessionCatalog) that + // recovers from a 401 on the catalog's own identity by rebuilding the client (upstream #64966). For + // session=user the adapter attaches the querying user's delegated credential per request (#63068). Null for + // every non-REST catalog; sessionCatalogAdapter is null unless session=user. + private volatile BaseViewSessionCatalog restSessionCatalog; + private volatile IcebergSessionCatalogAdapter sessionCatalogAdapter; + // T08 connector-internal caches (D6, 0 SPI). Final per-catalog fields: a REFRESH CATALOG rebuilds the + // connector (PluginDrivenExternalCatalog.onClose nulls + recreates it) and thus drops both caches. The + // manifest cache is path-keyed, no-TTL, capacity-bounded; it is consumed only when + // meta.cache.iceberg.manifest.enable is set (default off → scan uses the SDK planFiles path). + // Each cross-query cache below carries an isolation-discipline marker enforced by + // tools/check-authz-cache-sharding.sh: under iceberg.rest.session=user a shared, un-partitioned cache would + // bypass the per-user loadTable authorization (a metadata disclosure), so every cache field must declare + // either 'authz-cache-session-user-disabled' (null under isUserSessionEnabled()) or 'authz-cache-exempt'. + private final IcebergLatestSnapshotCache latestSnapshotCache; // authz-cache-session-user-disabled + // PERF-01: cross-query cache of the RAW iceberg Table (restores the legacy IcebergExternalMetaCache table + // cache that the SPI cutover dropped). null when the catalog's credentials are query-dependent + // (iceberg.rest.session=user / REST vended-credentials) — see the constructor. The per-statement scope + // (ConnectorStatementScope) shares one loaded table across a statement's read/scan/write regardless of this + // field, and is what a credential-gated catalog (this field null) relies on within a statement. + private final IcebergTableCache tableCache; // authz-cache-session-user-disabled + // PERF-02: cross-query partition-view cache (the raw PARTITIONS-scan result, keyed by (table, snapshotId)). + // The value is pure metadata (no FileIO/credential), but under session=user it is an authorization-sensitive + // projection (a shared hit would disclose one user's partitions), so it is disabled there (see constructor). + private final IcebergPartitionCache partitionCache; // authz-cache-session-user-disabled + // PERF-03: cross-query inferred-file-format cache (the whole-table planFiles() fallback result, keyed by + // (table, snapshotId)). Same authorization-sensitive treatment as partitionCache: disabled under session=user. + private final IcebergFormatCache formatCache; // authz-cache-session-user-disabled + // PERF-05: cross-query table-comment cache (value = the 'comment' property string). Built ONLY for a REST + // vended-credentials catalog that is NOT session=user -- plain catalogs already reuse tableCache (PERF-01) for + // the comment path, and session=user must stay live because the loadTable itself carries per-user + // authorization a shared cache would bypass. null for every other flavor. + private final IcebergCommentCache commentCache; // authz-cache-session-user-disabled + // Manifest content cache — pure metadata, default-off (meta.cache.iceberg.manifest.enable), and consumed + // ONLY after a per-user resolveTable(ForRead). authz-cache-exempt (no read path without a per-user load). + private final IcebergManifestCache manifestCache = new IcebergManifestCache(); + + // Lazily-built plugin-side Kerberos authenticator (single-owner auth; see TcclPinningConnectorContext). + // null for a non-Kerberos catalog. Its doAs acts on the PLUGIN's UserGroupInformation copy — the one the + // plugin's HDFS FileSystem reads — not the app-loader copy the FE-injected authenticator logs in. + private volatile HadoopAuthenticator pluginAuth; + private volatile boolean pluginAuthComputed; public IcebergConnector(Map properties, ConnectorContext context) { this.properties = Collections.unmodifiableMap(properties); - this.context = context; + // Pin the thread-context classloader to the plugin loader for the duration of every + // executeAuthenticated call (see TcclPinningConnectorContext). The injected context is fanned out to + // the metadata / transaction / procedure ops below; wrapping it once here is what extends the + // "TCCL pinned to the plugin loader" guard (already applied on the scan + catalog-build paths) to the + // write/DDL/procedure commits, whose lazy iceberg-aws S3-client build otherwise ClassCasts + // ApacheHttpClientConfigurations across the app/child loader split. For a Kerberos catalog it ALSO runs + // each op under a plugin-side UGI doAs (pluginAuthenticator): the plugin's FileSystem reads the plugin's + // own UserGroupInformation copy (hadoop bundled child-first), which the FE-injected app-side + // authenticator never logs in — so without this the DDL/read hits secured HDFS as SIMPLE auth. + this.context = new TcclPinningConnectorContext(context, getClass().getClassLoader(), + this::pluginAuthenticator); + // Authorization-sensitive projection (snapshotId/schemaId). Under iceberg.rest.session=user the value is + // per-user AUTHORIZED metadata that a "can-list-cannot-load" principal must not see. beginQuerySnapshot + // reads this cache WITHOUT a preceding per-user loadTable, so a shared (table-keyed, no user dimension) + // hit would bypass the per-user authorization that lives inside loadTable (a metadata disclosure). + // Disabled (null) for session=user so beginQuerySnapshot re-loads live per-user every call (no stale-authz + // window); kept for every other flavor (single static identity, no cross-user axis). Mirrors the + // tableCache/commentCache discipline: session=user => no LIVE cross-query metadata cache. + this.latestSnapshotCache = isUserSessionEnabled() + ? null + : new IcebergLatestSnapshotCache( + resolveTableCacheTtlSecond(this.properties), DEFAULT_TABLE_CACHE_CAPACITY); + // PERF-01 cross-query RAW-table cache. Disabled (null) when the catalog's credentials are + // query-dependent, because a cached raw Table carries its FileIO's credentials: + // - iceberg.rest.session=user: per-user delegated FileIO -> sharing across users leaks credentials. + // - REST vended-credentials: the FileIO carries a server-vended token that expires within ~an hour; + // iceberg keeps it fresh by reloading the table each query, so a 24h-TTL hit would hand BE an + // expired token (403 mid-scan). Both gates are independent; either one disables this layer. + // The query-scoped fat handle stays on in all cases (its token is fresh within the one query). Same + // TTL/capacity as the snapshot cache (the single meta.cache.iceberg.table.ttl-second knob). + this.tableCache = (isUserSessionEnabled() + || IcebergScanPlanProvider.restVendedCredentialsEnabled(this.properties)) + ? null + : new IcebergTableCache( + resolveTableCacheTtlSecond(this.properties), DEFAULT_TABLE_CACHE_CAPACITY); + // PERF-02: partition-view cache. Authorization-sensitive projection: a shared (table+snapshot-keyed, no + // user dimension) hit would disclose one user's partition list. Its readers are all downstream of a + // per-user resolveTableForRead today (so a hit cannot precede authz), but that safety rests entirely on + // tableCache being null under session=user. Disabled (null) under session=user makes it safe by + // construction and holds the "session=user => no live cross-query metadata cache" invariant; kept + // otherwise (single static identity). Readers already tolerate a null cache (loadRawPartitions). + this.partitionCache = isUserSessionEnabled() + ? null + : new IcebergPartitionCache( + resolveTableCacheTtlSecond(this.properties), DEFAULT_TABLE_CACHE_CAPACITY); + // PERF-03: inferred-file-format cache. Same authorization-sensitive treatment as partitionCache (disabled + // under session=user, kept otherwise); readers already tolerate a null cache (resolveFileFormatName). + this.formatCache = isUserSessionEnabled() + ? null + : new IcebergFormatCache( + resolveTableCacheTtlSecond(this.properties), DEFAULT_TABLE_CACHE_CAPACITY); + // PERF-05: table-comment cache, built ONLY for a REST vended-credentials catalog that is NOT session=user. + // Plain catalogs (tableCache on) already serve the comment path from tableCache; session=user is excluded + // because a shared comment cache would bypass the per-user loadTable authorization (a metadata disclosure). + // Comment is pure metadata (no credential) so no gate on the VALUE -- the flavor gate is an authorization + // decision, not a credential-leak one. Same TTL/capacity (ttl<=0 still disables internally). + this.commentCache = (IcebergScanPlanProvider.restVendedCredentialsEnabled(this.properties) + && !isUserSessionEnabled()) + ? new IcebergCommentCache( + resolveTableCacheTtlSecond(this.properties), DEFAULT_TABLE_CACHE_CAPACITY) + : null; + } + + /** + * Resolves {@code meta.cache.iceberg.table.ttl-second} (default 24h); a blank/unparseable value falls back + * to the default rather than failing catalog creation (best-effort, mirrors PaimonConnector). A value + * {@code <= 0} disables caching (the no-cache catalog reads the latest snapshot live every query). + */ + static long resolveTableCacheTtlSecond(Map properties) { + String raw = properties.get(TABLE_CACHE_TTL_SECOND); + if (StringUtils.isBlank(raw)) { + return DEFAULT_TABLE_CACHE_TTL_SECOND; + } + try { + return Long.parseLong(raw.trim()); + } catch (NumberFormatException e) { + LOG.warn("Invalid {}='{}', falling back to default {}s", TABLE_CACHE_TTL_SECOND, raw, + DEFAULT_TABLE_CACHE_TTL_SECOND); + return DEFAULT_TABLE_CACHE_TTL_SECOND; + } } @Override public ConnectorMetadata getMetadata(ConnectorSession session) { - return new IcebergConnectorMetadata(getOrCreateCatalog(), properties); + return new IcebergConnectorMetadata(newCatalogBackedOps(session), properties, context, + latestSnapshotCache, tableCache, partitionCache, commentCache); + } + + /** + * True for a handle this connector produced (an {@link IcebergTableHandle}). Tested against this connector's + * OWN in-loader type, so a heterogeneous hms gateway that embeds this connector as a sibling can route a + * foreign iceberg handle here without casting it across the plugin classloader split. Returns false for any + * other connector's handle (e.g. a hudi sibling's), so the gateway keeps looking. + */ + @Override + public boolean ownsHandle(ConnectorTableHandle handle) { + return handle instanceof IcebergTableHandle; + } + + /** + * Eagerly validates connectivity during CREATE CATALOG (when {@code test_connection=true}). + * Runs two probes so bad configurations fail fast instead of at first query: + *
    + *
  • Metastore (any {@code iceberg.catalog.type}): lists namespaces, forcing a real + * round-trip that validates the URI, auth (OAuth2/SigV4, Kerberos) and warehouse config.
  • + *
  • Storage: HEADs the warehouse location with the user-declared S3 credentials, + * mirroring fe-core's S3ConnectivityTester so wrong keys are rejected up front.
  • + *
+ */ + @Override + public ConnectorTestResult testConnection(ConnectorSession session) { + String catalogType = properties.getOrDefault( + IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, ""); + + // -- Metastore probe (REST, HMS, Glue, S3Tables) -- + // Listing databases forces a real round-trip that validates the URI, auth and warehouse config. + // This used to be REST-only here, which silently dropped the HMS/Glue/S3Tables coverage the legacy + // fe-core Iceberg{HMS,Glue,S3Tables}ConnectivityTester family provided. + if (probesMetastore(catalogType)) { + try { + getMetadata(session).listDatabaseNames(session); + } catch (Exception e) { + LOG.warn("Iceberg metastore connectivity test failed for catalog '{}'", + context.getCatalogName(), e); + return ConnectorTestResult.failure(metaFailureMessage(catalogType, e)); + } + } + + // -- Storage probe (only when the user supplied S3 credentials) -- + ConnectorTestResult storageResult = probeStorage(catalogType); + if (storageResult != null) { + return storageResult; + } + return ConnectorTestResult.success(); + } + + /** + * Probes the object store with the user-declared S3 credentials. Returns a failure result if + * the store is unreachable or the credentials are rejected, or {@code null} when the check + * passes or is not applicable (no S3 credentials, or no resolvable s3:// location). + */ + private ConnectorTestResult probeStorage(String catalogType) { + String accessKey = properties.get(S3_ACCESS_KEY); + String endpoint = properties.get(S3_ENDPOINT); + if (isBlank(accessKey) || isBlank(endpoint)) { + // No S3 credentials supplied: nothing to probe. + return null; + } + String location = resolveS3TestLocation(catalogType); + if (location == null) { + // Could not determine an s3:// location to probe (e.g. a non-REST catalog whose + // warehouse is not an s3:// path). Skip rather than fail a check we cannot perform. + LOG.info("Skipping Iceberg storage connectivity probe for catalog '{}': " + + "no s3:// warehouse location resolved", context.getCatalogName()); + return null; + } + + // Map Doris s3.* keys to Iceberg S3FileIO keys and force static credentials (disable + // remote/vended signing) so the probe validates exactly what the user configured. + Map ioProps = new HashMap<>(); + ioProps.put("s3.endpoint", endpoint); + ioProps.put("s3.access-key-id", accessKey); + ioProps.put("s3.secret-access-key", properties.getOrDefault(S3_SECRET_KEY, "")); + ioProps.put("s3.path-style-access", "true"); + ioProps.put("s3.remote-signing-enabled", "false"); + String region = properties.get(S3_REGION); + if (!isBlank(region)) { + ioProps.put("client.region", region); + } + + // Load S3FileIO reflectively via CatalogUtil so this module needs no compile-time AWS SDK + // dependency; the AWS SDK is resolved from the shared runtime classpath at execution time. + // Pin the TCCL to the plugin loader for the probe: iceberg-aws builds its S3 client lazily and + // resolves org.apache.iceberg.aws.ApacheHttpClientConfigurations via DynMethods (default loader = + // TCCL). This CREATE-CATALOG thread runs under the default 'app' TCCL, which would return the + // fe-core copy of the class and ClassCast against the child-loaded plugin copy the rest of the + // iceberg-aws stack uses — the SAME split-brain TcclPinningConnectorContext guards on the commit + // path. Unlike the metastore probe (which routes through executeAuthenticated), this path is not + // pinned by the context, so it must pin here. + FileIO io = null; + ClassLoader previousTccl = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(getClass().getClassLoader()); + io = CatalogUtil.loadFileIO("org.apache.iceberg.aws.s3.S3FileIO", ioProps, null); + // exists() issues a HEAD: a 404 (missing object) returns false and is fine, but + // endpoint/credential failures (e.g. 403) throw — which is what we want to catch. + io.newInputFile(location).exists(); + } catch (Exception e) { + LOG.warn("Iceberg storage connectivity test failed for catalog '{}'", + context.getCatalogName(), e); + return ConnectorTestResult.failure(storageFailureMessage(e)); + } finally { + Thread.currentThread().setContextClassLoader(previousTccl); + if (io != null) { + try { + io.close(); + } catch (Exception ignored) { + // best-effort cleanup + } + } + } + + // FE can reach the warehouse — now make sure BE can too. FE and BE routinely sit on different + // networks, so a warehouse that only FE reaches would pass CREATE CATALOG and then fail every + // scan. The engine owns the round-trip (it needs the backend registry); we only hand it the + // BE-facing credentials and the location to try. + return probeStorageFromBackend(location); + } + + /** + * Asks a backend to reach {@code location} with the catalog's BE-facing credentials. Returns a failure + * result if the backend rejects it, or {@code null} when it passes (or when there is no backend to ask, + * 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()); + 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); + return null; + } catch (Exception e) { + LOG.warn("Iceberg storage connectivity test failed on the compute node for catalog '{}'", + context.getCatalogName(), e); + return ConnectorTestResult.failure(storageBackendFailureMessage(e)); + } + } + + /** + * Resolves an {@code s3://} location to probe. Prefers an explicit S3 {@code warehouse} in the + * catalog properties; for REST catalogs falls back to the server-merged warehouse location + * (Iceberg {@code warehouse} or Polaris {@code default-base-location}). + */ + private String resolveS3TestLocation(String catalogType) { + String location = toS3Location(properties.get(IcebergConnectorProperties.WAREHOUSE)); + if (location != null) { + return location; + } + if (IcebergConnectorProperties.TYPE_REST.equalsIgnoreCase(catalogType)) { + Catalog catalog = getOrCreateCatalog(); + // The server-merged config (Iceberg warehouse / Polaris default-base-location) lives on the REST + // session catalog we now hold (the wrapped RESTSessionCatalog; its properties() delegates to the raw + // one, exactly what RESTCatalog.properties() returned). Fall back to a bare RESTCatalog defensively. + Map merged = null; + BaseViewSessionCatalog sc = restSessionCatalog; + if (sc != null) { + merged = sc.properties(); + } else if (catalog instanceof RESTCatalog) { + merged = ((RESTCatalog) catalog).properties(); + } + if (merged != null) { + location = toS3Location(merged.get(CatalogProperties.WAREHOUSE_LOCATION)); + if (location == null) { + location = toS3Location(merged.get(REST_DEFAULT_BASE_LOCATION)); + } + } + } + return location; + } + + /** + * Builds the metastore-connectivity failure message. The wording deliberately contains both + * the catalog-type tag (e.g. {@code "Iceberg REST"}) and the phrase + * {@code "connectivity test failed"} so CREATE CATALOG surfaces a stable, actionable error. + */ + /** + * True for the catalog types that talk to a remote metastore, which is exactly the set the legacy + * fe-core coordinator built a {@code MetaConnectivityTester} for (Iceberg HMS / Glue / REST / S3Tables). + * Filesystem-backed catalogs ({@code hadoop}, and an unset type) had no tester there and get none here: + * their "metastore" is the warehouse itself, which the storage probe below covers. + */ + static boolean probesMetastore(String catalogType) { + return IcebergConnectorProperties.TYPE_REST.equalsIgnoreCase(catalogType) + || IcebergConnectorProperties.TYPE_HMS.equalsIgnoreCase(catalogType) + || IcebergConnectorProperties.TYPE_GLUE.equalsIgnoreCase(catalogType) + || IcebergConnectorProperties.TYPE_S3_TABLES.equalsIgnoreCase(catalogType); + } + + static String metaFailureMessage(String catalogType, Throwable cause) { + String tag = isBlank(catalogType) ? "Iceberg" : "Iceberg " + catalogType.toUpperCase(Locale.ROOT); + return tag + " connectivity test failed: " + rootCauseMessage(cause); + } + + static String storageFailureMessage(Throwable cause) { + return "Storage connectivity test failed: " + rootCauseMessage(cause); + } + + /** + * The compute-node variant. The {@code (compute node)} tag is what tells an operator the warehouse is + * fine from FE and the problem is BE-side (a different network or credential set) — the legacy fe-core + * coordinator drew the same distinction. + */ + static String storageBackendFailureMessage(Throwable cause) { + return "Storage connectivity test failed (compute node): " + rootCauseMessage(cause); + } + + /** Normalizes and returns {@code value} if it is an s3/s3a/s3n URI, otherwise {@code null}. */ + static String toS3Location(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + if (trimmed.matches("^(s3|s3a|s3n)://.+")) { + return trimmed.replaceFirst("^s3[an]://", "s3://"); + } + return null; + } + + /** Returns the message of the deepest cause, falling back to its simple class name. */ + static String rootCauseMessage(Throwable t) { + Throwable root = t; + while (root.getCause() != null && root.getCause() != root) { + root = root.getCause(); + } + String msg = root.getMessage(); + return msg != null ? msg : root.getClass().getSimpleName(); + } + + private static boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } + + /** + * Build the {@link IcebergCatalogOps} seam over the lazily-built live catalog, threading the listing-parity + * gating mirrored from legacy {@code IcebergMetadataOps} (a single per-catalog ops that carried this for ALL + * of metadata/scan/write/procedure): nested-namespace recursion is REST-only and flag-gated; view filtering + * is REST-flag-gated; a configured {@code external_catalog.name} roots namespaces (REST 3-level + * {@code .} living under {@code [, ]}). ALL FOUR call sites (getMetadata + the three + * provider getters) share this so they resolve namespaces identically — in particular {@code loadTable} (the + * only seam method scan/write/procedure use) must honour {@code external_catalog.name} or 3-level REST + * catalogs resolve to the wrong namespace. + */ + private IcebergCatalogOps newCatalogBackedOps() { + String flavor = IcebergCatalogFactory.resolveFlavor(properties); + boolean restFlavor = IcebergConnectorProperties.TYPE_REST.equals(flavor); + boolean nestedNamespaceEnabled = Boolean.parseBoolean(properties.getOrDefault( + IcebergConnectorProperties.REST_NESTED_NAMESPACE_ENABLED, "false")); + boolean viewEnabled = Boolean.parseBoolean(properties.getOrDefault( + IcebergConnectorProperties.REST_VIEW_ENABLED, "true")); + Optional externalCatalogName = + Optional.ofNullable(properties.get(IcebergConnectorProperties.EXTERNAL_CATALOG_NAME)); + Catalog sharedCatalog = getOrCreateCatalog(); + // Plain REST now holds the bare (wrapped) RESTSessionCatalog: its asCatalog(empty) is a Catalog + + // SupportsNamespaces but NOT a ViewCatalog, so inject the view facet asViewCatalog(empty) explicitly (what + // the all-in-one RESTCatalog used to carry inline). session=user routes views per-user elsewhere, so the + // shared path keeps its existing behavior (no injection here). + BaseViewSessionCatalog sc = restSessionCatalog; + if (sc != null && !isUserSessionEnabled()) { + ViewCatalog sharedViewCatalog = sc.asViewCatalog(SessionCatalog.SessionContext.createEmpty()); + return new IcebergCatalogOps.CatalogBackedIcebergCatalogOps(sharedCatalog, sharedViewCatalog, + restFlavor, nestedNamespaceEnabled, viewEnabled, externalCatalogName); + } + return new IcebergCatalogOps.CatalogBackedIcebergCatalogOps(sharedCatalog, + restFlavor, nestedNamespaceEnabled, viewEnabled, externalCatalogName); + } + + /** + * Session-aware variant used by {@link #getMetadata}: for a {@code iceberg.rest.session=user} catalog it + * routes through the per-request delegated catalog + view catalog (FAIL-CLOSED — a session that carries no + * delegated credential is rejected by {@code IcebergSessionCatalogAdapter.delegatedCatalog}, never served a + * shared identity), so metadata reads are authorized as the querying user. For every other catalog it is + * identical to {@link #newCatalogBackedOps()} (the shared catalog). getMetadata is invoked per operation with + * the current session, so resolving the per-user catalog here covers each metadata call (#63068 parity). + */ + private IcebergCatalogOps newCatalogBackedOps(ConnectorSession session) { + if (!isUserSessionEnabled()) { + return newCatalogBackedOps(); + } + getOrCreateCatalog(); // ensure the shared RESTSessionCatalog + adapter are built + IcebergSessionCatalogAdapter adapter = sessionCatalogAdapter; + Catalog perUserCatalog = adapter.delegatedCatalog(session); + ViewCatalog perUserViewCatalog = adapter.delegatedViewCatalog(session); + boolean nestedNamespaceEnabled = Boolean.parseBoolean(properties.getOrDefault( + IcebergConnectorProperties.REST_NESTED_NAMESPACE_ENABLED, "false")); + boolean viewEnabled = Boolean.parseBoolean(properties.getOrDefault( + IcebergConnectorProperties.REST_VIEW_ENABLED, "true")); + Optional externalCatalogName = + Optional.ofNullable(properties.get(IcebergConnectorProperties.EXTERNAL_CATALOG_NAME)); + // restFlavor is unconditionally true here (isUserSessionEnabled() ⇒ a REST catalog). + return new IcebergCatalogOps.CatalogBackedIcebergCatalogOps(perUserCatalog, perUserViewCatalog, + true, nestedNamespaceEnabled, viewEnabled, externalCatalogName); + } + + /** + * REFRESH TABLE hook: drop the cached latest snapshot for one table so the next query re-pins live + * (mirrors PaimonConnector). The names are the REMOTE db/table (RefreshManager passes remote names, the + * same form {@link IcebergConnectorMetadata#beginQuerySnapshot} keys on). The manifest cache is path-keyed + * and intentionally NOT cleared here (legacy IcebergExternalMetaCache parity — db/table invalidation keeps + * manifest entries; only a REFRESH CATALOG, i.e. connector rebuild, drops them). + */ + @Override + public void invalidateTable(String dbName, String tableName) { + if (latestSnapshotCache != null) { + latestSnapshotCache.invalidate(TableIdentifier.of(dbName, tableName)); + } + if (tableCache != null) { + tableCache.invalidate(TableIdentifier.of(dbName, tableName)); + } + if (partitionCache != null) { + partitionCache.invalidate(TableIdentifier.of(dbName, tableName)); + } + if (formatCache != null) { + formatCache.invalidate(TableIdentifier.of(dbName, tableName)); + } + if (commentCache != null) { + commentCache.invalidate(TableIdentifier.of(dbName, tableName)); + } + } + + /** + * REFRESH DATABASE hook (also reached by a Doris-issued {@code DROP DATABASE} via the generic + * {@code PluginDrivenExternalCatalog} dropDb hook, and by the hive gateway's + * {@code forEachBuiltSibling} for an iceberg-on-HMS sibling): drop the cached latest snapshot for + * EVERY table in one database so the next query re-pins live. Db-scoped analogue of + * {@link #invalidateTable}; the name is the REMOTE db name (RefreshManager / the dropDb hook pass + * remote names). Without this override iceberg inherited the SPI no-op default, so REFRESH DATABASE + * and DROP DATABASE (incl. its FORCE table cascade, which bypasses per-table invalidateTable) left + * the snapshot pins stale up to the TTL. The path-keyed manifest cache is intentionally NOT cleared + * (legacy parity — only REFRESH CATALOG drops manifests). + */ + @Override + public void invalidateDb(String dbName) { + if (latestSnapshotCache != null) { + latestSnapshotCache.invalidateDb(dbName); + } + if (tableCache != null) { + tableCache.invalidateDb(dbName); + } + if (partitionCache != null) { + partitionCache.invalidateDb(dbName); + } + if (formatCache != null) { + formatCache.invalidateDb(dbName); + } + if (commentCache != null) { + commentCache.invalidateDb(dbName); + } + } + + /** + * REFRESH CATALOG hook: drop ALL of this catalog's connector-owned caches. Clears both the latest-snapshot + * cache and the (path-keyed) manifest cache — mirroring legacy {@code IcebergExternalMetaCache}'s + * catalog-wide {@code group.invalidateAll()}, which dropped table (latest-snapshot projection) AND manifest + * entries. Unlike {@link #invalidateTable} (REFRESH TABLE, which keeps manifest entries), the catalog-level + * invalidation flushes manifests too. + */ + @Override + public void invalidateAll() { + if (latestSnapshotCache != null) { + latestSnapshotCache.invalidateAll(); + } + if (tableCache != null) { + tableCache.invalidateAll(); + } + if (partitionCache != null) { + partitionCache.invalidateAll(); + } + if (formatCache != null) { + formatCache.invalidateAll(); + } + if (commentCache != null) { + commentCache.invalidateAll(); + } + manifestCache.invalidateAll(); + } + + /** + * Restore the legacy single-knob semantics: {@code meta.cache.iceberg.table.ttl-second} also governs the FE + * schema cache (the SPI routes iceberg schema to the generic schema cache keyed by + * {@code schema.cache.ttl-second}), so a no-cache catalog ({@code ttl-second=0}) serves FRESH schema after + * external DDL (mirrors {@code PaimonConnector.schemaCacheTtlSecondOverride}). Absent -> no override (engine + * default TTL). Do NOT reuse {@link #resolveTableCacheTtlSecond}, which substitutes the 24h default for a + * blank value and would defeat the engine default. + */ + @Override + public OptionalLong schemaCacheTtlSecondOverride() { + String raw = properties.get(TABLE_CACHE_TTL_SECOND); + if (raw == null || raw.trim().isEmpty()) { + return OptionalLong.empty(); + } + try { + return OptionalLong.of(Long.parseLong(raw.trim())); + } catch (NumberFormatException e) { + return OptionalLong.empty(); + } + } + + /** Test-only: the manifest cache, so cache tests can assert REFRESH CATALOG ({@link #invalidateAll}) drops it. */ + IcebergManifestCache manifestCacheForTest() { + return manifestCache; + } + + /** Test-only: the cross-query table cache, or {@code null} when disabled by the credential gate (PERF-01). */ + IcebergTableCache tableCacheForTest() { + return tableCache; + } + + /** Test-only: the latest-snapshot cache, or {@code null} when disabled for a session=user catalog. */ + IcebergLatestSnapshotCache latestSnapshotCacheForTest() { + return latestSnapshotCache; + } + + /** Test-only: the cross-query partition-view cache (PERF-02), or {@code null} for a session=user catalog. */ + IcebergPartitionCache partitionCacheForTest() { + return partitionCache; + } + + /** Test-only: the cross-query inferred-file-format cache (PERF-03), or {@code null} for a session=user catalog. */ + IcebergFormatCache formatCacheForTest() { + return formatCache; + } + + /** Test-only: the table-comment cache (PERF-05), or {@code null} unless vended-credentials and non-session. */ + IcebergCommentCache commentCacheForTest() { + return commentCache; + } + + @Override + public ConnectorScanPlanProvider getScanPlanProvider() { + // Mirrors PaimonConnector.getScanPlanProvider: build a fresh provider per call over the lazily-built + // live catalog. Scan planning resolves the table via catalogOps.loadTable, which honours + // external_catalog.name (REST 3-level catalogs), so it must share getMetadata's fully-threaded ops + // (newCatalogBackedOps) — the listing-only flags (nested-namespace / view) are inert on this path but + // threaded for parity with the legacy single per-catalog IcebergMetadataOps. + return new IcebergScanPlanProvider(properties, + this::newCatalogBackedOps, context, manifestCache, + tableCache, formatCache); + } + + @Override + public ConnectorWritePlanProvider getWritePlanProvider() { + // Mirrors getScanPlanProvider: a fresh provider per call over the lazily-built live catalog. The + // provider builds the TIcebergTableSink and binds the write to the executor-opened + // IcebergConnectorTransaction. It resolves the target via catalogOps.loadTable, so it shares the + // fully-threaded ops (newCatalogBackedOps) — external_catalog.name must apply to INSERT/DELETE/MERGE. + return new IcebergWritePlanProvider(properties, + this::newCatalogBackedOps, context); + } + + @Override + public ConnectorProcedureOps getProcedureOps() { + // Mirrors getWritePlanProvider: a fresh provider per call over the lazily-built live catalog. The + // provider loadTable()s the target and runs the procedure body (P6.4-T03/T04). It resolves the target + // via catalogOps.loadTable, so it shares the fully-threaded ops (newCatalogBackedOps) — + // external_catalog.name must apply to ALTER TABLE ... EXECUTE on REST 3-level catalogs. + return new IcebergProcedureOps(properties, + this::newCatalogBackedOps, context); + } + + /** + * 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). + */ + @Override + public Set getCapabilities() { + // SUPPORTS_COLUMN_AUTO_ANALYZE: legacy IcebergExternalTable is in the auto-analyze whitelist and is + // forced to FULL analyze; the generic statistics collector reproduces both ONLY under this capability, + // so post-cutover iceberg keeps background per-column stats (CBO quality). Inert pre-cutover (P6.6). + // SUPPORTS_TOPN_LAZY_MATERIALIZE: legacy IcebergExternalTable.class is in MaterializeProbeVisitor's + // supported set; the generic probe reproduces that ONLY under this capability, so post-cutover iceberg + // keeps Top-N lazy materialization (query latency). The BE rowid plumbing is already generic. Inert + // pre-cutover (P6.6). + // SUPPORTS_SHOW_CREATE_DDL: legacy IcebergExternalTable rendered LOCATION + PROPERTIES + PARTITION BY + // + ORDER BY in SHOW CREATE TABLE (and IcebergExternalDatabase rendered LOCATION in SHOW CREATE + // DATABASE); the generic plugin-driven render arm reproduces that ONLY under this capability + // (the connector pre-renders the partition/sort clauses under the show.* reserved keys, and getDatabase + // surfaces the namespace location). Inert pre-cutover (P6.6). + // SUPPORTS_VIEW: legacy IcebergExternalTable resolves isView() from catalog.viewExists and + // IcebergExternalCatalog merges listViewNames back into SHOW TABLES; the generic plugin-driven path + // reproduces both ONLY under this capability (PluginDrivenExternalTable.isView() consults the connector, + // and listTableNamesFromRemote re-merges the connector's listViewNames), so post-cutover iceberg views + // remain visible/queryable/droppable. Inert pre-cutover (P6.6). + // SUPPORTS_NESTED_COLUMN_PRUNE: legacy IcebergExternalTable.class returns true from + // LogicalFileScan.supportPruneNestedColumn (and SlotTypeReplacer rewrites the nested access path to + // iceberg field-ids); the generic plugin-driven path reproduces both ONLY under this capability, so + // post-cutover iceberg keeps reading just the accessed STRUCT/ARRAY/MAP sub-fields (read-amplification + // avoidance). Correct only because the connector also carries per-field ids down its column tree + // (parseSchema withUniqueId + IcebergTypeMapping withChildrenFieldIds), which the BE field-id scan + // path matches nested leaves by; without them a nested leaf reads NULL. Inert pre-cutover (P6.6). + // SUPPORTS_METADATA_PRELOAD: legacy IcebergExternalTable.supportsExternalMetadataPreload returns true so + // the planner async pre-warms schema/snapshot before taking the read lock; the generic plugin-driven + // path reproduces this ONLY under this capability (PluginDrivenExternalTable.supportsExternalMetadataPreload), + // so post-cutover iceberg keeps async pre-load instead of degrading to synchronous bind-time load. Pure + // lock-latency optimization, opt-in via enable_preload_external_metadata. Inert pre-cutover (P6.6). + EnumSet capabilities = EnumSet.of(ConnectorCapability.SUPPORTS_MVCC_SNAPSHOT, + ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE, + ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE, + ConnectorCapability.SUPPORTS_SHOW_CREATE_DDL, + ConnectorCapability.SUPPORTS_VIEW, + ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE, + ConnectorCapability.SUPPORTS_METADATA_PRELOAD); + // SUPPORTS_USER_SESSION: only a REST catalog configured iceberg.rest.session=user projects the querying + // user's delegated credential onto a per-request Iceberg REST SessionCatalog (#63068 re-migration). This + // gates FE credential injection + shared-cache bypass; every other flavor/config authenticates with a + // single static catalog identity and must NOT declare it (least-privilege). + if (isUserSessionEnabled()) { + capabilities.add(ConnectorCapability.SUPPORTS_USER_SESSION); + } + return capabilities; + } + + /** + * Whether this catalog is a REST catalog configured {@code iceberg.rest.session=user} — the single gate for + * the per-user session machinery (capability declaration, the shared {@code RESTSessionCatalog} build, and + * the session-aware catalog routing). {@code IcebergRestMetaStoreProperties.validate} has already enforced + * that {@code session=user} implies {@code security.type=oauth2}. + */ + boolean isUserSessionEnabled() { + return IcebergConnectorProperties.SESSION_USER.equalsIgnoreCase( + properties.get(IcebergConnectorProperties.REST_SESSION)) + && IcebergConnectorProperties.TYPE_REST.equals(IcebergCatalogFactory.resolveFlavor(properties)); } private Catalog getOrCreateCatalog() { @@ -78,63 +823,604 @@ private Catalog getOrCreateCatalog() { } private Catalog createCatalog() { - String catalogType = properties.get(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE); - if (catalogType == null || catalogType.isEmpty()) { + String flavor = IcebergCatalogFactory.resolveFlavor(properties); + if (flavor == null) { throw new DorisConnectorException( "Missing '" + IcebergConnectorProperties.ICEBERG_CATALOG_TYPE + "' property"); } - Map catalogProps = new HashMap<>(properties); - String catalogImpl = resolveCatalogImpl(catalogType); - catalogProps.put(CatalogProperties.CATALOG_IMPL, catalogImpl); - // Iceberg SDK does not allow both "type" and "catalog-impl" - catalogProps.remove(CatalogUtil.ICEBERG_CATALOG_TYPE); + Optional chosenS3 = + IcebergCatalogFactory.chooseS3Compatible(context.getStorageProperties()); + String catalogName = IcebergCatalogFactory.resolveCatalogName(properties, flavor, context.getCatalogName()); + + // s3tables is bespoke: it is NOT built via CatalogUtil.buildIcebergCatalog. Legacy + // IcebergS3TablesMetaStoreProperties hand-builds an S3TablesClient and calls the 3-arg + // S3TablesCatalog.initialize(name, opts, client). Routed before the CatalogUtil flavor switch. + if (IcebergConnectorProperties.TYPE_S3_TABLES.equals(flavor)) { + return createS3TablesCatalog(catalogName, chosenS3); + } + + Map catalogProps = + IcebergCatalogFactory.buildCatalogProperties(properties, flavor, chosenS3); + Map storageHadoopConfig = buildStorageHadoopConfig(); + + Configuration conf; + switch (flavor) { + case IcebergConnectorProperties.TYPE_HMS: { + // Reuse the shared metastore-spi parser (Q2=B bindForType): iceberg passes its own flavor token + // so the metastore-spi never learns iceberg.catalog.type. Only toHiveConfOverrides is used + // (iceberg HMS does NOT call paimon's validate(); it does not require a warehouse). The external + // hive.conf.resources hive-site.xml is resolved by the connector itself (addConfResources). + HmsMetaStoreProperties hms = (HmsMetaStoreProperties) MetaStoreProviders.bindForType( + IcebergConnectorProperties.TYPE_HMS, properties, storageHadoopConfig); + conf = IcebergCatalogFactory.assembleHiveConf( + IcebergCatalogFactory.firstNonBlank(properties, "hive.conf.resources"), + hms.toHiveConfOverrides(context.getEnvironment() + .getOrDefault("hive_metastore_client_timeout_second", "10"))); + break; + } + case IcebergConnectorProperties.TYPE_GLUE: + // Legacy IcebergGlueMetaStoreProperties builds the catalog with conf=null. + conf = null; + break; + case IcebergConnectorProperties.TYPE_JDBC: + maybeRegisterJdbcDriver(); + conf = IcebergCatalogFactory.buildHadoopConfiguration(properties, storageHadoopConfig); + break; + default: + // rest / hadoop: a storage Configuration from the fe-filesystem-bound storage + raw + // fs./dfs./hadoop. passthrough. + conf = IcebergCatalogFactory.buildHadoopConfiguration(properties, storageHadoopConfig); + break; + } + + // Every REST catalog (plain or iceberg.rest.session=user) is built as a session-aware RESTSessionCatalog + // (not the all-in-one RESTCatalog) wrapped for 401 re-authentication. The default (non-delegated) catalog + // is asCatalog(empty), identical to what a RESTCatalog exposes; wrapping it recovers a catalog-identity + // token expiry (#64966), and for session=user the shared session catalog + adapter also drive per-request + // asCatalog(ctx)/asViewCatalog(ctx) delegated-credential routing (#63068). + if (IcebergConnectorProperties.TYPE_REST.equals(flavor)) { + return buildRestSessionCatalogDefault(catalogName, catalogProps, conf); + } - Configuration conf = buildHadoopConf(catalogProps); - String catalogName = context.getCatalogName(); + LOG.info("Creating Iceberg catalog '{}' flavor='{}' impl='{}'", + catalogName, flavor, catalogProps.get(CatalogProperties.CATALOG_IMPL)); + return buildCatalogAuthenticated(flavor, + () -> CatalogUtil.buildIcebergCatalog(catalogName, catalogProps, conf)); + } + + /** + * Builds the default catalog for a REST catalog (plain or {@code iceberg.rest.session=user}): a SINGLE shared + * {@link RESTSessionCatalog} (built directly — NOT via {@code CatalogUtil.buildIcebergCatalog}, which returns + * the all-in-one {@code RESTCatalog} and hides the session catalog behind a private field), wrapped in a + * {@link ReauthenticatingRestSessionCatalog} so an expired/rejected catalog-identity token (HTTP 401) rebuilds + * the client and retries once instead of wedging until the FE restarts (upstream #64966). Its + * {@code asCatalog(empty)} is the default (non-delegated) catalog; for {@code session=user} its + * {@code asCatalog(ctx)} / {@code asViewCatalog(ctx)} are used per request by {@link #sessionCatalogAdapter}. + * Memoizes {@link #restSessionCatalog} (always) and {@link #sessionCatalogAdapter} (session=user only) as a + * side effect. The optional {@code iceberg.rest.session-timeout} maps to the iceberg AuthSession timeout. + */ + private Catalog buildRestSessionCatalogDefault(String catalogName, Map catalogProps, + Configuration conf) { + Map sessionProps = new HashMap<>(catalogProps); + // Built directly via new RESTSessionCatalog(), so the CatalogUtil catalog-impl key is neither needed nor + // valid here; drop it. + sessionProps.remove(CatalogProperties.CATALOG_IMPL); + String sessionTimeout = properties.get(IcebergConnectorProperties.REST_SESSION_TIMEOUT); + if (StringUtils.isNotBlank(sessionTimeout)) { + sessionProps.put(CatalogProperties.AUTH_SESSION_TIMEOUT_MS, sessionTimeout); + } + boolean userSession = isUserSessionEnabled(); + IcebergSessionCatalogAdapter.DelegatedTokenMode tokenMode = + IcebergSessionCatalogAdapter.DelegatedTokenMode.fromString(properties.getOrDefault( + IcebergConnectorProperties.REST_DELEGATED_TOKEN_MODE, + IcebergConnectorProperties.DELEGATED_TOKEN_MODE_ACCESS_TOKEN)); + // Frozen catalog-identity properties for the 401-recovery rebuild: an unmodifiable copy so the rebuild + // always re-resolves from the catalog's OWN credential and can never capture a per-user delegated token. + Map frozenProps = Collections.unmodifiableMap(new HashMap<>(sessionProps)); + LOG.info("Creating Iceberg REST catalog '{}' (userSession={}, delegated-token-mode={})", + catalogName, userSession, tokenMode); + return buildCatalogAuthenticated(IcebergConnectorProperties.TYPE_REST, () -> { + RESTSessionCatalog rawSessionCatalog = newRestSessionCatalog(catalogName, sessionProps, conf); + // Wrap so a 401 on the catalog's own identity rebuilds the client and retries once. The wrapper is a + // thin BaseViewSessionCatalog: asCatalog(empty)/asViewCatalog(empty) and the per-user asCatalog(ctx) + // all call back into it, so every path inherits recovery; per-user requests are excluded by the + // wrapper's own request-level gate (a request carrying a delegated credential is never recovered). + ReauthenticatingRestSessionCatalog sessionCatalog = new ReauthenticatingRestSessionCatalog( + rawSessionCatalog, () -> rebuildRestSessionCatalog(catalogName, frozenProps, conf)); + Catalog defaultCatalog = sessionCatalog.asCatalog(SessionCatalog.SessionContext.createEmpty()); + this.restSessionCatalog = sessionCatalog; + if (userSession) { + this.sessionCatalogAdapter = + new IcebergSessionCatalogAdapter(defaultCatalog, sessionCatalog, tokenMode); + } + return defaultCatalog; + }); + } - LOG.info("Creating Iceberg catalog '{}' with type='{}', impl='{}'", - catalogName, catalogType, catalogImpl); + /** Builds and initializes a bare Iceberg {@link RESTSessionCatalog} (fresh REST client + OAuth2 token fetch). */ + private RESTSessionCatalog newRestSessionCatalog(String catalogName, Map props, + Configuration conf) { + RESTSessionCatalog sessionCatalog = new RESTSessionCatalog(); + CatalogUtil.configureHadoopConf(sessionCatalog, conf); + sessionCatalog.initialize(catalogName, props); + return sessionCatalog; + } - return CatalogUtil.buildIcebergCatalog(catalogName, catalogProps, conf); + /** + * Rebuilds the REST session catalog for 401 re-authentication, under the plugin classloader pin. The initial + * build runs inside {@link #buildCatalogAuthenticated} (i.e. {@code context.executeAuthenticated}); this rebuild + * fires later on whatever thread hit the 401, so it must re-apply the same pin or iceberg's reflective REST / + * iceberg-aws client build split-brains against the app loader (see {@code pinIcebergWorkerPoolToPluginClassLoader} + * and {@code TcclPinningConnectorContext}). Uses the frozen catalog-identity props, so it can never mint the + * shared client with a per-user token. + */ + private RESTSessionCatalog rebuildRestSessionCatalog(String catalogName, Map props, + Configuration conf) { + try { + return context.executeAuthenticated(() -> newRestSessionCatalog(catalogName, props, conf)); + } catch (Exception e) { + throw new DorisConnectorException("Failed to rebuild Iceberg REST client for re-authentication (catalog=" + + catalogName + "): " + e.getMessage(), e); + } } /** - * Resolve the Iceberg catalog implementation class from the catalog type string. + * Creates the bespoke {@code s3tables} catalog, mirroring legacy {@code IcebergS3TablesMetaStoreProperties}: a + * hand-built {@link S3TablesClient} (region + credentials + optional {@code s3tables.endpoint} override + the + * s3tables-SDK http config) is passed to the 3-arg {@code S3TablesCatalog.initialize(name, opts, client)} — + * NOT to {@code CatalogUtil.buildIcebergCatalog}. The 2-arg {@code initialize(name, opts)} is intentionally + * avoided: its {@code DefaultS3TablesAwsClientFactory} honors only a {@code client.credentials-provider} class + * and would silently drop static {@code s3.access-key-id}/{@code s3.secret-access-key} (falling back to the + * SDK default chain). A region is required (from the bound storage or the raw props); credentials come from + * the bound storage when present, else the SDK default chain — e.g. an EC2 instance-profile s3tables catalog + * with only region + warehouse ARN and no static creds. Only a missing region fails loud here, before any + * AWS call (legacy IcebergS3TablesMetaStoreProperties used the DefaultCredentialsProvider chain likewise). */ - private static String resolveCatalogImpl(String catalogType) { - switch (catalogType.toLowerCase()) { - case "rest": - return "org.apache.iceberg.rest.RESTCatalog"; - case "hms": - return "org.apache.iceberg.hive.HiveCatalog"; - case "glue": - return "org.apache.iceberg.aws.glue.GlueCatalog"; - case "hadoop": - return "org.apache.iceberg.hadoop.HadoopCatalog"; - case "jdbc": - return "org.apache.iceberg.jdbc.JdbcCatalog"; - case "s3tables": - return "software.amazon.s3tables.iceberg.S3TablesCatalog"; - case "dlf": - return "org.apache.doris.connector.iceberg.dlf.DLFCatalog"; - default: - throw new DorisConnectorException( - "Unknown iceberg.catalog.type: " + catalogType - + ". Supported types: rest, hms, glue, hadoop, jdbc, s3tables, dlf"); + private Catalog createS3TablesCatalog(String catalogName, Optional chosenS3) { + String region = resolveS3TablesRegion(chosenS3, properties); + Map catalogProps = + IcebergCatalogFactory.buildS3TablesCatalogProperties(properties, chosenS3); + LOG.info("Creating Iceberg s3tables catalog '{}' region='{}' boundStorage={}", + catalogName, region, chosenS3.isPresent()); + return buildCatalogAuthenticated(IcebergConnectorProperties.TYPE_S3_TABLES, () -> { + S3TablesClient client = buildS3TablesClient(chosenS3, region); + S3TablesCatalog catalog = new S3TablesCatalog(); + catalog.initialize(catalogName, catalogProps, client); + return catalog; + }); + } + + /** + * Resolves the s3tables control-plane region: the bound fe-filesystem storage's region when present, else + * the raw catalog props (the widened S3 region-alias set, via {@link IcebergCatalogFactory#resolveS3Region}). + * A region is the SOLE hard requirement for s3tables; credentials fall back to the SDK default chain when no + * storage is bound. Fails loud only when NEITHER storage nor props supply a region. Static / package-visible + * so the gate is unit-testable offline without a live {@link S3TablesClient}. + */ + static String resolveS3TablesRegion( + Optional chosenS3, Map props) { + String region = chosenS3.map(S3CompatibleFileSystemProperties::getRegion) + .filter(StringUtils::isNotBlank) + .orElseGet(() -> IcebergCatalogFactory.resolveS3Region(props)); + if (StringUtils.isBlank(region)) { + throw new DorisConnectorException( + "Iceberg s3tables catalog requires a region (set s3.region or a region-bearing endpoint)"); + } + return region; + } + + + /** + * Hand-builds the control-plane {@link S3TablesClient}, mirroring legacy + * {@code IcebergS3TablesMetaStoreProperties.buildS3TablesClient}: region + credentials provider + the optional + * {@code s3tables.endpoint} override + the s3tables-SDK http-client tuning ({@link HttpClientProperties}). The + * credentials provider is derived from the typed fe-filesystem storage by {@link #buildAwsCredentialsProvider} + * when one is bound, else the SDK default chain ({@link DefaultCredentialsProvider}); the region is the value + * already resolved by {@link #resolveS3TablesRegion}. + */ + private S3TablesClient buildS3TablesClient(Optional chosenS3, String region) { + AwsCredentialsProvider credentialsProvider = chosenS3 + .map(s3 -> buildAwsCredentialsProvider(s3, properties)) + .orElseGet(DefaultCredentialsProvider::create); + S3TablesClientBuilder builder = S3TablesClient.builder() + .region(Region.of(region)) + .credentialsProvider(credentialsProvider); + String endpoint = properties.get(S3TablesProperties.S3TABLES_ENDPOINT); + if (StringUtils.isNotBlank(endpoint)) { + builder.endpointOverride(URI.create(endpoint)); + } + new HttpClientProperties(properties).applyHttpClientConfigurations(builder); + return builder.build(); + } + + /** + * Derives the AWS SDK v2 credentials provider for the s3tables control-plane client from the typed + * fe-filesystem storage, mirroring legacy + * {@code IcebergAwsClientCredentialsProperties.createAwsCredentialsProvider}: static AK/SK -> + * {@link StaticCredentialsProvider} (with a session token when present); a role ARN -> + * {@link StsAssumeRoleCredentialsProvider} (role session name {@code aws-sdk-java-v2-fe}, optional external + * id); otherwise the SDK default chain ({@link DefaultCredentialsProvider}). + * + *

F14: the no-credential (PROVIDER_CHAIN) case resolves the non-DEFAULT provider the user selected via + * {@code s3.credentials_provider_type} through {@link AwsCredentialsProviderModes} — a self-contained twin of + * legacy {@code AwsCredentialsProviderFactory.createV2} (the connector cannot import fe-core). {@code DEFAULT} + * (and blank / unknown) still yields {@link DefaultCredentialsProvider}. The STS base credentials for the + * ASSUME_ROLE path stay on the default chain (matching the already-twinned assume-role case). + */ + private static AwsCredentialsProvider buildAwsCredentialsProvider( + S3CompatibleFileSystemProperties s3, Map props) { + if (s3.hasStaticCredentials()) { + if (StringUtils.isBlank(s3.getSessionToken())) { + return StaticCredentialsProvider.create( + AwsBasicCredentials.create(s3.getAccessKey(), s3.getSecretKey())); + } + return StaticCredentialsProvider.create( + AwsSessionCredentials.create(s3.getAccessKey(), s3.getSecretKey(), s3.getSessionToken())); + } + if (s3.hasAssumeRole()) { + StsClient stsClient = StsClient.builder() + .region(Region.of(s3.getRegion())) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + return StsAssumeRoleCredentialsProvider.builder() + .stsClient(stsClient) + .refreshRequest(b -> { + b.roleArn(s3.getRoleArn()).roleSessionName("aws-sdk-java-v2-fe"); + if (StringUtils.isNotBlank(s3.getExternalId())) { + b.externalId(s3.getExternalId()); + } + }) + .build(); + } + // F14: PROVIDER_CHAIN — the non-DEFAULT provider the user selected (DEFAULT -> DefaultCredentialsProvider). + return AwsCredentialsProviderModes.providerFor(props, AwsCredentialsProviderModes.S3_MODE_KEYS); + } + + // HDFS scheme constants for the warehouse -> fs.defaultFS bridge (inlined; the connector must not import + // fe-core's HdfsResource). Values match HdfsResource.HDFS_PREFIX / HDFS_FILE_PREFIX / HADOOP_FS_NAME. + private static final String HDFS_SCHEME_PREFIX = "hdfs:"; + private static final String HDFS_URI_PREFIX = "hdfs://"; + private static final String FS_DEFAULT_FS_KEY = "fs.defaultFS"; + + /** + * Design S8: the iceberg connector owns the {@code warehouse -> fs.defaultFS} storage derivation that used + * to live in fe-core's {@code IcebergFileSystemMetaStoreProperties.getDerivedStorageProperties}. Only the + * hadoop (filesystem) catalog flavor bridges the warehouse; the other flavors (rest/hms/glue/jdbc/ + * s3tables) contribute no storage derivation (empty), matching the legacy override which only the hadoop + * flavor carried. fe-core folds the result into its storage map as defaults, feeding both the fe-filesystem + * bind and the BE storage map identically. + */ + @Override + public Map deriveStorageProperties(Map rawCatalogProps) { + return deriveStorageDefaults(rawCatalogProps); + } + + /** + * Gate + derivation for {@link #deriveStorageProperties} (static so it is unit-testable without constructing + * a connector): only the hadoop (filesystem) catalog flavor bridges the warehouse; every other flavor + * (rest/hms/glue/jdbc/s3tables) contributes nothing. + */ + static Map deriveStorageDefaults(Map rawCatalogProps) { + if (!IcebergConnectorProperties.TYPE_HADOOP.equalsIgnoreCase( + rawCatalogProps.get(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE))) { + return Collections.emptyMap(); + } + return deriveHdfsDefaultFsFromWarehouse(rawCatalogProps.get(IcebergConnectorProperties.WAREHOUSE)); + } + + /** + * Bridges a hadoop-flavor {@code warehouse=hdfs:///path} to {@code fs.defaultFS=hdfs://} so an + * HA-nameservice catalog configured with only {@code warehouse} (relying on classpath {@code core-site.xml}/ + * {@code hdfs-site.xml} for the nameservice, no inline {@code uri}/{@code fs.defaultFS}) still binds HDFS + * storage with the warehouse nameservice. Non-hdfs and blank warehouses derive nothing; a blank nameservice + * fails loud. Verbatim port of the former {@code IcebergFileSystemMetaStoreProperties.getDerivedStorageProperties}. + */ + static Map deriveHdfsDefaultFsFromWarehouse(String warehouse) { + if (StringUtils.isBlank(warehouse) || !StringUtils.startsWith(warehouse, HDFS_SCHEME_PREFIX)) { + return Collections.emptyMap(); + } + String nameService = StringUtils.substringBetween(warehouse, HDFS_URI_PREFIX, "/"); + if (StringUtils.isEmpty(nameService)) { + throw new IllegalArgumentException("Unrecognized 'warehouse' location format" + + " because name service is required."); + } + return Collections.singletonMap(FS_DEFAULT_FS_KEY, HDFS_URI_PREFIX + nameService); + } + + /** + * Assembles the canonical storage Hadoop config from the FE-bound storage properties (P1-T03), mirroring + * {@code PaimonConnector.buildStorageHadoopConfig}: object stores contribute their fs.s3a.* / fs.oss.* / + * fs.cosn.* / fs.obs.* translation, and an HDFS-backed catalog contributes its hadoop.config.resources XML + + * HA + auth keys (C2; the defaults-free fe-filesystem HDFS map). Empty for a catalog with no typed storage. + */ + private Map buildStorageHadoopConfig() { + Map merged = new HashMap<>(); + for (StorageProperties sp : context.getStorageProperties()) { + sp.toHadoopProperties().ifPresent(h -> merged.putAll(h.toHadoopConfigurationMap())); + } + return merged; + } + + /** + * Lazily builds and memoizes the plugin-side Kerberos authenticator that {@link TcclPinningConnectorContext} + * runs each op under, so remote HDFS access uses the PLUGIN's own {@code UserGroupInformation} copy (the one + * the plugin's {@code FileSystem} reads). Returns {@code null} for a non-Kerberos catalog so the FE-injected + * auth path is preserved unchanged. The Kerberos keys ride the {@code hadoop.*} passthrough in + * {@link IcebergCatalogFactory#buildHadoopConfiguration}; {@link HadoopAuthenticator#getHadoopAuthenticator} + * resolves the plugin (child-first) copy of fe-kerberos, so its {@code doAs} logs in / acts on the plugin + * UGI. Construction is cheap — the keytab login is lazy in {@code getUGI()} on the first {@code doAs}. + */ + private HadoopAuthenticator pluginAuthenticator() { + if (!pluginAuthComputed) { + synchronized (this) { + if (!pluginAuthComputed) { + pluginAuth = buildPluginAuthenticator(properties, buildStorageHadoopConfig()); + pluginAuthComputed = true; + } + } + } + return pluginAuth; + } + + /** + * Resolves the plugin-side Kerberos authenticator for the catalog, or {@code null} for a non-Kerberos + * catalog. Two Kerberos sources are covered, in precedence order: + *

    + *
  1. Storage Kerberos — the raw {@code hadoop.security.authentication=kerberos} passthrough + * (HDFS / data-lake login), built from the storage Hadoop configuration. Unchanged prior behavior; + * when storage is Kerberos this single login also carries the HMS metastore RPC (same UGI).
  2. + *
  3. HMS-metastore Kerberos with non-Kerberos storage — a secured Hive Metastore whose data + * storage is simple (e.g. a Kerberized HMS over S3). Legacy fe-core served this from the fe-core + * {@code IcebergHMSMetaStoreProperties} HMS authenticator (delivered via {@code DefaultConnectorContext}); + * once the fe-core iceberg property cluster is deleted the connector must own it. This mirrors + * {@code HMSBaseProperties.initHadoopAuthenticator}: the HMS client principal/keytab facts + * ({@link HmsMetaStoreProperties#kerberos()}) feed a {@link KerberosAuthenticationConfig}, so the + * {@code doAs} logs in the same client identity fe-core used. The HMS service principal / + * SASL settings ride the catalog's own HiveConf ({@code hms.toHiveConfOverrides}), not the login.
  4. + *
+ * Package-visible + static for direct unit testing (mirrors the {@code metaFailureMessage} helpers). + */ + static HadoopAuthenticator buildPluginAuthenticator(Map properties, + Map storageHadoopConfig) { + if ("kerberos".equalsIgnoreCase(properties.get(HADOOP_SECURITY_AUTHENTICATION))) { + return HadoopAuthenticator.getHadoopAuthenticator( + IcebergCatalogFactory.buildHadoopConfiguration(properties, storageHadoopConfig)); + } + if (IcebergConnectorProperties.TYPE_HMS.equals(IcebergCatalogFactory.resolveFlavor(properties))) { + HmsMetaStoreProperties hms = (HmsMetaStoreProperties) MetaStoreProviders.bindForType( + IcebergConnectorProperties.TYPE_HMS, properties, storageHadoopConfig); + Optional spec = hms.kerberos(); + if (spec.isPresent() && spec.get().hasCredentials()) { + Configuration conf = + IcebergCatalogFactory.buildHadoopConfiguration(properties, storageHadoopConfig); + conf.set("hadoop.security.authentication", "kerberos"); + conf.set("hive.metastore.sasl.enabled", "true"); + return HadoopAuthenticator.getHadoopAuthenticator( + new KerberosAuthenticationConfig(spec.get().getPrincipal(), spec.get().getKeytab(), conf)); + } + } + return null; + } + + private Catalog buildCatalogAuthenticated(String flavor, Callable builder) { + // Catalog creation needs the thread-context classloader pinned to the plugin loader (Hadoop's + // FileSystem ServiceLoader + SecurityUtil static init, and iceberg-aws's reflective client build, all + // resolve helper classes through the TCCL; without the pin they read the parent 'app' loader and + // split-brain against the child-loaded classes). That pin is now applied once, for every + // executeAuthenticated call, by TcclPinningConnectorContext (which wraps the injected context in the + // constructor) — the single seam shared with the write/DDL/procedure commits — so it is not repeated + // here. PaimonConnector.createCatalogFromContext still pins inline (no such wrapper). + try { + Catalog catalog = context.executeAuthenticated(builder); + // iceberg's parallel data-manifest WRITE runs on its own shared worker pool, whose threads do NOT + // inherit the per-commit TCCL pin TcclPinningConnectorContext applies on the engine thread; pin + // those threads to the plugin loader once so that path resolves iceberg-aws on the plugin side too + // (see pinIcebergWorkerPoolToPluginClassLoader). + pinIcebergWorkerPoolToPluginClassLoader(); + return catalog; + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to create Iceberg catalog (flavor=" + flavor + "): " + e.getMessage(), e); + } + } + + /** + * Pins the thread-context classloader of iceberg's shared worker pool to this plugin's classloader, once + * per JVM. + * + *

iceberg fans its parallel data-manifest WRITE ({@code SnapshotProducer.writeManifests}, reached on + * every INSERT/UPDATE/DELETE/MERGE/REWRITE commit and the snapshot procedures) onto + * {@code ThreadPools.getWorkerPool()}. Because the iceberg connector provider is loaded once and shared by + * every iceberg catalog, that is a single JVM-wide daemon pool. {@link TcclPinningConnectorContext} pins + * only the engine thread that drives a commit; the worker-pool threads do NOT inherit that pin, so the lazy + * iceberg-aws S3-client build on a worker thread resolves {@code ApacheHttpClientConfigurations} via + * {@code DynMethods} against the parent 'app' loader and {@link ClassCastException}s the child-loaded + * plugin copy the rest of the iceberg-aws stack uses. Setting each worker thread's TCCL to the plugin + * loader (the loader the iceberg-aws classes are child-first-loaded from) keeps every reflective load on + * the plugin side — the worker-pool analogue of the scan ({@code PluginDrivenScanNode.onPluginClassLoader}) + * and commit-thread ({@link TcclPinningConnectorContext}) guards. + * + *

Set explicitly (not relying on thread-creation inheritance) so it also repins any worker thread an + * earlier unpinned use already created; a {@code ThreadPoolExecutor} never resets a worker's TCCL between + * tasks, so the pin persists. A short-lived barrier forces every thread of the fixed pool to run a primer. + * Best-effort: a failure or timeout is logged and never fails catalog creation (the write path then behaves + * as before the pin), and the guard is reset so a later catalog build retries. + */ + private void pinIcebergWorkerPoolToPluginClassLoader() { + if (!ICEBERG_WORKER_POOL_PINNED.compareAndSet(false, true)) { + return; + } + int poolSize = ThreadPools.WORKER_THREAD_POOL_SIZE; + if (poolSize <= 0) { + return; + } + try { + if (!pinPoolThreadsToClassLoader( + ThreadPools.getWorkerPool(), poolSize, getClass().getClassLoader(), 30)) { + ICEBERG_WORKER_POOL_PINNED.set(false); + LOG.warn("Timed out pinning iceberg worker pool ({} threads) to the plugin classloader; " + + "iceberg-aws writes may ClassCast until a later catalog build retries", poolSize); + } + } catch (InterruptedException e) { + ICEBERG_WORKER_POOL_PINNED.set(false); + Thread.currentThread().interrupt(); + } catch (RuntimeException e) { + ICEBERG_WORKER_POOL_PINNED.set(false); + LOG.warn("Failed to pin iceberg worker pool to the plugin classloader", e); + } + } + + /** + * Sets the thread-context classloader of EVERY thread of a fixed-size {@code pool} to {@code target}, + * returning whether all {@code poolSize} threads were reached within {@code timeoutSeconds}. A barrier holds + * each primer until all have started, forcing every distinct worker thread to run a primer and set its TCCL + * (a single fast thread could otherwise serve every submitted task, leaving the rest unpinned). + * Package-private for {@code IcebergConnectorWorkerPoolPinTest}. + */ + static boolean pinPoolThreadsToClassLoader(ExecutorService pool, int poolSize, ClassLoader target, + long timeoutSeconds) throws InterruptedException { + CountDownLatch allStarted = new CountDownLatch(poolSize); + CountDownLatch release = new CountDownLatch(1); + try { + for (int i = 0; i < poolSize; i++) { + pool.execute(() -> { + Thread.currentThread().setContextClassLoader(target); + allStarted.countDown(); + // Park so the next task is forced onto a DISTINCT worker thread, until every thread in the + // fixed pool has run a primer and set its TCCL. + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + return allStarted.await(timeoutSeconds, TimeUnit.SECONDS); + } finally { + release.countDown(); } } - private static Configuration buildHadoopConf(Map props) { - Configuration conf = new Configuration(); - for (Map.Entry entry : props.entrySet()) { - String key = entry.getKey(); - if (key.startsWith("hadoop.") || key.startsWith("fs.") - || key.startsWith("dfs.") || key.startsWith("hive.")) { - conf.set(key, entry.getValue()); + /** + * Enforces JDBC driver-url security at CREATE CATALOG (mirrors {@code PaimonConnector.preCreateValidation}): + * for the jdbc flavor a configured {@code iceberg.jdbc.driver_url} is routed through the engine's + * {@link ConnectorValidationContext#validateAndResolveDriverPath} hook (the FE format / + * {@code jdbc_driver_url_white_list} / {@code jdbc_driver_secure_path} gates), so a rejected url fails + * CREATE CATALOG before the jar is ever loaded by {@link #maybeRegisterJdbcDriver}. Non-jdbc flavors are + * a no-op. + */ + @Override + public void preCreateValidation(ConnectorValidationContext validationContext) throws Exception { + if (!IcebergConnectorProperties.TYPE_JDBC.equals(IcebergCatalogFactory.resolveFlavor(properties))) { + return; + } + String driverUrl = IcebergCatalogFactory.firstNonBlank(properties, IcebergConnectorProperties.JDBC_DRIVER_URL); + if (StringUtils.isNotBlank(driverUrl)) { + validationContext.validateAndResolveDriverPath(driverUrl); + } + } + + /** + * If an {@code iceberg.jdbc.driver_url} is configured, dynamically load + register the driver before + * creating the catalog. {@link java.sql.DriverManager#getConnection} does not consult the thread context + * class loader, so the driver must be registered globally. Ported from the legacy + * {@code IcebergJdbcMetaStoreProperties.registerJdbcDriver}, with the fe-core + * {@code JdbcResource.getFullDriverUrl} dependency replaced by the shared + * {@link JdbcDriverSupport#resolveDriverUrl} against {@code ConnectorContext.getEnvironment()}. + */ + private void maybeRegisterJdbcDriver() { + String driverUrl = IcebergCatalogFactory.firstNonBlank(properties, IcebergConnectorProperties.JDBC_DRIVER_URL); + if (StringUtils.isBlank(driverUrl)) { + return; + } + String driverClass = + IcebergCatalogFactory.firstNonBlank(properties, IcebergConnectorProperties.JDBC_DRIVER_CLASS); + registerJdbcDriver(driverUrl, driverClass); + LOG.info("Using dynamic JDBC driver for Iceberg JDBC catalog from: {}", driverUrl); + } + + private void registerJdbcDriver(String driverUrl, String driverClassName) { + try { + if (StringUtils.isBlank(driverClassName)) { + throw new IllegalArgumentException("driver_class is required when driver_url is specified"); + } + Map env = context != null ? context.getEnvironment() : Collections.emptyMap(); + String fullDriverUrl = JdbcDriverSupport.resolveDriverUrl(driverUrl, env); + URL url = new URL(fullDriverUrl); + String driverKey = fullDriverUrl + "#" + driverClassName; + if (!REGISTERED_DRIVER_KEYS.add(driverKey)) { + LOG.info("JDBC driver already registered for Iceberg catalog: {} from {}", + driverClassName, fullDriverUrl); + return; + } + try { + ClassLoader classLoader = DRIVER_CLASS_LOADER_CACHE.computeIfAbsent(url, + u -> URLClassLoader.newInstance(new URL[] {u}, getClass().getClassLoader())); + Class loadedDriverClass = Class.forName(driverClassName, true, classLoader); + java.sql.Driver driver = (java.sql.Driver) loadedDriverClass.getDeclaredConstructor().newInstance(); + java.sql.DriverManager.registerDriver(new DriverShim(driver)); + LOG.info("Successfully registered JDBC driver for Iceberg catalog: {} from {}", + driverClassName, fullDriverUrl); + } catch (ClassNotFoundException e) { + REGISTERED_DRIVER_KEYS.remove(driverKey); + throw new IllegalArgumentException("Failed to load JDBC driver class: " + driverClassName, e); + } catch (Exception e) { + REGISTERED_DRIVER_KEYS.remove(driverKey); + throw new RuntimeException("Failed to register JDBC driver: " + driverClassName, e); } + } catch (MalformedURLException e) { + throw new IllegalArgumentException("Invalid driver URL: " + driverUrl, e); + } + } + + /** + * A shim driver that wraps a driver loaded from a custom ClassLoader, because {@code DriverManager} + * refuses to use a driver not loaded by the system classloader. Ported verbatim from the legacy + * {@code IcebergJdbcMetaStoreProperties.DriverShim}. + */ + private static class DriverShim implements java.sql.Driver { + private final java.sql.Driver delegate; + + DriverShim(java.sql.Driver delegate) { + this.delegate = delegate; + } + + @Override + public java.sql.Connection connect(String url, java.util.Properties info) throws java.sql.SQLException { + return delegate.connect(url, info); + } + + @Override + public boolean acceptsURL(String url) throws java.sql.SQLException { + return delegate.acceptsURL(url); + } + + @Override + public java.sql.DriverPropertyInfo[] getPropertyInfo(String url, java.util.Properties info) + throws java.sql.SQLException { + return delegate.getPropertyInfo(url, info); + } + + @Override + public int getMajorVersion() { + return delegate.getMajorVersion(); + } + + @Override + public int getMinorVersion() { + return delegate.getMinorVersion(); + } + + @Override + public boolean jdbcCompliant() { + return delegate.jdbcCompliant(); + } + + @Override + public java.util.logging.Logger getParentLogger() throws java.sql.SQLFeatureNotSupportedException { + return delegate.getParentLogger(); } - return conf; } @Override @@ -146,5 +1432,16 @@ public void close() throws IOException { } icebergCatalog = null; } + // The default catalog (asCatalog(empty)) is a lightweight view and NOT Closeable, so close the shared + // underlying REST session catalog (its REST client + OAuth2 auth resources) explicitly here. It is the + // ReauthenticatingRestSessionCatalog wrapper, a Closeable that closes its current delegate. + BaseViewSessionCatalog sc = restSessionCatalog; + if (sc != null) { + if (sc instanceof java.io.Closeable) { + ((java.io.Closeable) sc).close(); + } + restSessionCatalog = null; + sessionCatalogAdapter = null; + } } } 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 f4a816f82b769a..70f5d0a146e056 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java @@ -18,28 +18,69 @@ package org.apache.doris.connector.iceberg; import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorDatabaseMetadata; import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorPartitionInfo; import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.ConnectorTableSchema; +import org.apache.doris.connector.api.ConnectorTableStatistics; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.ConnectorViewDefinition; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.ddl.BranchChange; +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.handle.ConnectorTransaction; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.mvcc.ConnectorMvccPartitionView; +import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; +import org.apache.doris.connector.api.mvcc.ConnectorTimeTravelSpec; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.thrift.THiveTable; +import org.apache.doris.thrift.TIcebergTable; +import org.apache.doris.thrift.TTableDescriptor; +import org.apache.doris.thrift.TTableType; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.MetadataTableUtils; +import org.apache.iceberg.PartitionField; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.RowLevelOperationMode; import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; -import org.apache.iceberg.catalog.Catalog; -import org.apache.iceberg.catalog.Namespace; -import org.apache.iceberg.catalog.SupportsNamespaces; +import org.apache.iceberg.TableProperties; import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.NoSuchNamespaceException; +import org.apache.iceberg.exceptions.NoSuchTableException; +import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.iceberg.view.SQLViewRepresentation; +import org.apache.iceberg.view.View; +import org.apache.iceberg.view.ViewVersion; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; +import java.util.Set; +import java.util.function.Predicate; /** * {@link ConnectorMetadata} implementation for Iceberg catalogs. @@ -51,58 +92,289 @@ *

  • Partition spec info in table properties
  • * * - *

    Uses the Iceberg SDK Catalog API directly. All catalog backends (REST, HMS, - * Glue, etc.) are transparent — the Iceberg Catalog interface abstracts them.

    + *

    Depends on the {@link IcebergCatalogOps} seam rather than a raw Iceberg {@code Catalog}, so it is + * unit-testable offline with a recording fake (no live REST/HMS/Glue/... catalog). All catalog + * backends are transparent behind the seam — the Iceberg {@code Catalog} interface abstracts them. */ public class IcebergConnectorMetadata implements ConnectorMetadata { private static final Logger LOG = LogManager.getLogger(IcebergConnectorMetadata.class); - private final Catalog catalog; + // Internal sentinel property carrying a tag/branch ref name from resolveTimeTravel to applySnapshot (the + // typed ConnectorMvccSnapshot has snapshotId/schemaId carriers but no ref field). NOT a BE scan option. + static final String REF_PROPERTY = "iceberg.scan.ref"; + + // Iceberg v3 row-lineage hidden columns. Local literal copies of the Doris-side constants — the + // connector cannot import fe-core. Column names mirror IcebergUtils.ICEBERG_ROW_ID_COL / + // ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL; the reserved field ids and the min format version mirror + // IcebergUtils.appendRowLineageColumnsForV3 / ICEBERG_ROW_LINEAGE_MIN_VERSION. A fe-core contract test + // (IcebergUtilsTest / PluginDrivenScanNodeClassifyColumnTest) pins these values so a change there fails + // loud, flagging that these duplicates must change too. + private static final String ICEBERG_ROW_ID_COL = "_row_id"; + private static final String ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL = "_last_updated_sequence_number"; + private static final int ICEBERG_ROW_ID_FIELD_ID = 2147483540; + private static final int ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_FIELD_ID = 2147483539; + private static final int ICEBERG_ROW_LINEAGE_MIN_VERSION = 3; + + // Snapshot-summary keys for table-level row count (getTableStatistics). Local literal copies of the + // spec-stable iceberg strings — byte-identical to legacy IcebergUtils.TOTAL_* and to the COUNT(*) + // pushdown copies in IcebergScanPlanProvider (themselves deliberately NOT org.apache.iceberg + // .SnapshotSummary.* per that file's note). Duplicated rather than shared so this fix does not touch + // the unrelated scan provider. All THREE keys are read: legacy getIcebergRowCount (via + // getCountFromSummary, upstream 32a2651f66b / #64648) nets out position deletes AND gates the count to + // UNKNOWN on any equality delete — see computeRowCount. + private static final String TOTAL_RECORDS = "total-records"; + private static final String TOTAL_POSITION_DELETES = "total-position-deletes"; + private static final String TOTAL_EQUALITY_DELETES = "total-equality-deletes"; + + // Doris-level table property carrying a user comment. Local literal copy of the fe-core constant + // IcebergExternalTable.TABLE_COMMENT_PROP ("comment") — the connector cannot import fe-core. Read by + // getTableComment (F9/F12) so the flipped iceberg table's COMMENT clause is non-empty. + private static final String TABLE_COMMENT_PROP = "comment"; + + private final IcebergCatalogOps catalogOps; private final Map properties; + // Every remote metadata READ is wrapped in context.executeAuthenticated(...) so the FE-injected + // Kerberos UGI applies — legacy IcebergMetadataOps wrapped each call in executionAuthenticator.execute, + // and the paimon mirror (PaimonConnectorMetadata) wraps the equivalent reads. The default + // executeAuthenticated is a pass-through, so simple-auth catalogs are unaffected. + private final ConnectorContext context; + // T08: per-catalog latest-snapshot cache, owned by the long-lived IcebergConnector and injected here so + // beginQuerySnapshot pins a STABLE (possibly stale) snapshot across queries within the TTL (legacy + // IcebergExternalMetaCache parity, mirrors paimon). The 3-arg ctor (direct-construction tests) passes a + // DISABLED cache so those reads stay always-live. + private final IcebergLatestSnapshotCache latestSnapshotCache; + // PERF-01: cross-query RAW-table cache (null = no cross-query layer). The 3-arg direct-construction tests + // and the credential-gated catalogs pass null; the query-scoped fat handle (IcebergTableHandle) works + // regardless. Consumed only by resolveTableForRead. + private final IcebergTableCache tableCache; + // PERF-02: cross-query partition-view cache (null = no cross-query layer; the convenience ctors used by + // direct-construction tests pass null). Consumed by getMvccPartitionView / listPartitions / listPartitionNames. + private final IcebergPartitionCache partitionCache; + // PERF-05: cross-query table-comment cache (null = no cross-query layer). Non-null only when the owning + // connector is a REST vended-credentials, non-session catalog (see IcebergConnector); the convenience ctors + // used by direct-construction tests pass null. Consumed only by getTableComment. + private final IcebergCommentCache commentCache; - public IcebergConnectorMetadata(Catalog catalog, Map properties) { - this.catalog = catalog; + public IcebergConnectorMetadata(IcebergCatalogOps catalogOps, Map properties, + ConnectorContext context) { + this(catalogOps, properties, context, new IcebergLatestSnapshotCache(0L, 1), null, null); + } + + /** Convenience ctor without a cross-query table cache (tableCache null); used by MVCC/statistics tests. */ + public IcebergConnectorMetadata(IcebergCatalogOps catalogOps, Map properties, + ConnectorContext context, IcebergLatestSnapshotCache latestSnapshotCache) { + this(catalogOps, properties, context, latestSnapshotCache, null, null); + } + + /** Convenience ctor without a partition-view cache (partitionCache null). */ + public IcebergConnectorMetadata(IcebergCatalogOps catalogOps, Map properties, + ConnectorContext context, IcebergLatestSnapshotCache latestSnapshotCache, + IcebergTableCache tableCache) { + this(catalogOps, properties, context, latestSnapshotCache, tableCache, null); + } + + public IcebergConnectorMetadata(IcebergCatalogOps catalogOps, Map properties, + ConnectorContext context, IcebergLatestSnapshotCache latestSnapshotCache, + IcebergTableCache tableCache, IcebergPartitionCache partitionCache) { + this(catalogOps, properties, context, latestSnapshotCache, tableCache, partitionCache, null); + } + + /** Full ctor used by {@link IcebergConnector#getMetadata}, adding the PERF-05 table-comment cache. */ + public IcebergConnectorMetadata(IcebergCatalogOps catalogOps, Map properties, + ConnectorContext context, IcebergLatestSnapshotCache latestSnapshotCache, + IcebergTableCache tableCache, IcebergPartitionCache partitionCache, + IcebergCommentCache commentCache) { + this.catalogOps = catalogOps; this.properties = properties; + this.context = context; + this.latestSnapshotCache = latestSnapshotCache; + this.tableCache = tableCache; + this.partitionCache = partitionCache; + this.commentCache = commentCache; } // ========== ConnectorSchemaOps ========== @Override public List listDatabaseNames(ConnectorSession session) { - if (!(catalog instanceof SupportsNamespaces)) { - LOG.warn("Iceberg catalog does not support namespaces"); - return Collections.emptyList(); + // Mirror legacy IcebergMetadataOps.listDatabaseNames: wrap in the auth context, warn + rethrow as + // RuntimeException on failure (never swallow to an empty list — that would mask a transient + // metastore failure as "zero databases"). + try { + return context.executeAuthenticated(catalogOps::listDatabaseNames); + } catch (Exception e) { + LOG.warn("failed to list database names in catalog {}", context.getCatalogName(), e); + throw new RuntimeException("Failed to list database names, error message is:" + e.getMessage(), e); } - SupportsNamespaces nsCatalog = (SupportsNamespaces) catalog; - return nsCatalog.listNamespaces(Namespace.empty()).stream() - .map(ns -> ns.level(ns.length() - 1)) - .collect(Collectors.toList()); } @Override public boolean databaseExists(ConnectorSession session, String dbName) { - if (!(catalog instanceof SupportsNamespaces)) { - return false; + // Mirror legacy IcebergMetadataOps.databaseExist: wrap in the auth context, rethrow on failure. + try { + return context.executeAuthenticated(() -> catalogOps.databaseExists(dbName)); + } catch (Exception e) { + throw new RuntimeException("Failed to check database exist, error message is:" + e.getMessage(), e); + } + } + + @Override + public ConnectorDatabaseMetadata getDatabase(ConnectorSession session, String dbName) { + // Surface the namespace base location for SHOW CREATE DATABASE under the neutral "location" + // property key (Trino-aligned properties-map model). Mirrors legacy IcebergExternalDatabase + // .getLocation (SupportsNamespaces.loadNamespaceMetadata -> "location"), wrapped in the auth + // context like the sibling reads. The location key is omitted when blank, so SHOW CREATE + // DATABASE renders no LOCATION clause rather than LOCATION '' for a location-less namespace. + try { + Optional location = + context.executeAuthenticated(() -> catalogOps.loadNamespaceLocation(dbName)); + Map props = new HashMap<>(); + location.ifPresent(loc -> props.put(ConnectorDatabaseMetadata.LOCATION_PROPERTY, loc)); + return new ConnectorDatabaseMetadata(dbName, props); + } catch (Exception e) { + throw new RuntimeException("Failed to get database metadata, error message is:" + e.getMessage(), e); } - return ((SupportsNamespaces) catalog).namespaceExists(Namespace.of(dbName)); } // ========== ConnectorTableOps ========== @Override public List listTableNames(ConnectorSession session, String dbName) { - Namespace ns = Namespace.of(dbName); - return catalog.listTables(ns).stream() - .map(TableIdentifier::name) - .collect(Collectors.toList()); + // Mirror legacy IcebergMetadataOps.listTableNames: wrap in the auth context; a RuntimeException + // (e.g. NoSuchNamespaceException — iceberg's exceptions are unchecked, so UGI.doAs does NOT wrap + // them) is rethrown verbatim, other failures are wrapped. + try { + return context.executeAuthenticated(() -> catalogOps.listTableNames(dbName)); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Failed to list table names, error message is: " + e.getMessage(), e); + } + } + + @Override + public List listViewNames(ConnectorSession session, String dbName) { + // Mirror legacy IcebergMetadataOps.listViewNames: wrap in the auth context; a RuntimeException + // (e.g. NoSuchNamespaceException) is rethrown verbatim, other failures are wrapped. + try { + return context.executeAuthenticated(() -> catalogOps.listViewNames(dbName)); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Failed to list view names, error message is: " + e.getMessage(), e); + } + } + + @Override + public boolean viewExists(ConnectorSession session, String dbName, String viewName) { + // Mirror legacy IcebergMetadataOps.viewExists (an existence check, like databaseExists / the + // getTableHandle tableExists wrapper): wrap the remote check in the auth context and normalize EVERY + // failure into a RuntimeException — unlike the listing methods (listTableNames / listViewNames), which + // rethrow a RuntimeException verbatim so NoSuchNamespaceException surfaces unwrapped. + try { + return context.executeAuthenticated(() -> catalogOps.viewExists(dbName, viewName)); + } catch (Exception e) { + throw new RuntimeException("Failed to check view exist, error message is: " + e.getMessage(), e); + } + } + + @Override + public ConnectorViewDefinition getViewDefinition(ConnectorSession session, String dbName, String viewName) { + // Mirror viewExists: wrap the remote load in the auth context and normalize EVERY failure into a + // RuntimeException (the seam's loadView already fails loud on a non-view catalog with a + // DorisConnectorException, which is a RuntimeException and surfaces wrapped here). ONE remote load + // yields both the sql/dialect (mirroring legacy IcebergExternalTable.getViewText + getSqlDialect: the + // dialect is the view-version summary's "engine-name", the SQL is that dialect's representation) AND + // the columns (parseSchema(view.schema()), mirroring legacy IcebergUtils.loadViewSchemaCacheValue — a + // view has NO partition columns and NO row-lineage). The sql/dialect/column extraction lives HERE, + // not in the SDK-only seam, because parseSchema reads the enable.mapping.* flags that only exist in + // this layer's properties (mirrors the table path: seam loadTable -> metadata buildTableSchema). + try { + return context.executeAuthenticated(() -> { + View icebergView = catalogOps.loadView(dbName, viewName); + ViewVersion viewVersion = icebergView.currentVersion(); + if (viewVersion == null) { + throw new DorisConnectorException( + String.format("Cannot get view version for view '%s'", icebergView)); + } + Map summary = viewVersion.summary(); + if (summary == null) { + throw new DorisConnectorException(String.format("Cannot get summary for view '%s'", icebergView)); + } + // "engine-name" is the iceberg view-version summary key the writing engine (e.g. spark) records. + String engineName = summary.get("engine-name"); + if (engineName == null || engineName.isEmpty()) { + throw new DorisConnectorException( + String.format("Cannot get engine-name for view '%s'", icebergView)); + } + String dialect = engineName.toLowerCase(Locale.ROOT); + SQLViewRepresentation sqlViewRepresentation = icebergView.sqlFor(dialect); + if (sqlViewRepresentation == null) { + throw new DorisConnectorException("Cannot get view text from iceberg view"); + } + List columns = parseSchema(icebergView.schema()); + return new ConnectorViewDefinition(sqlViewRepresentation.sql(), dialect, columns); + }); + } catch (Exception e) { + throw new RuntimeException("Failed to load view definition, error message is: " + e.getMessage(), e); + } + } + + @Override + public void dropView(ConnectorSession session, String dbName, String viewName) { + // Mirror legacy IcebergMetadataOps.performDropView (routed from dropTableImpl): drop the view inside + // the auth context. Like the other write ops (dropTable / dropDatabase), normalize EVERY failure into a + // DorisConnectorException so PluginDrivenExternalCatalog.dropTable rewraps it as a DdlException. + try { + context.executeAuthenticated(() -> { + catalogOps.dropView(dbName, viewName); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to drop Iceberg view " + + dbName + "." + viewName + ": " + e.getMessage(), e); + } + } + + @Override + public String getTableComment(ConnectorSession session, String dbName, String tableName) { + // Mirror legacy IcebergExternalTable.getComment: return the native iceberg table's "comment" + // property (default ""). Wrap the remote load in the auth context like the other metadata reads. + // Without this override the SPI default (ConnectorTableOps.getTableComment) returns "", so a flipped + // iceberg table's COMMENT clause, information_schema.tables.TABLE_COMMENT, and SHOW TABLE STATUS + // Comment column would all be blank even though the raw comment key still appears in the SHOW CREATE + // PROPERTIES(...) block (F9/F12). Views render their comment through getViewDefinition / the view + // SHOW CREATE arm, so a view handle here (loadTable throws) falls back to "" via the caller's catch. + // PERF-05: on a vended-credentials (non-session) catalog, memoize the comment per table across queries so + // the per-table loadTable that information_schema.tables / SHOW TABLE STATUS pays for EVERY table collapses + // on repeats. commentCache is null for every other flavor (plain catalogs reuse tableCache via loadTable; + // session=user must stay live to preserve per-user authorization) -> resolve directly. A thrown load (view + // handle) is not cached and propagates to the caller's catch (still ""), so behavior is unchanged. + if (commentCache != null) { + return commentCache.getOrLoad(TableIdentifier.of(dbName, tableName), + () -> loadTableComment(session, dbName, tableName)); + } + return loadTableComment(session, dbName, tableName); + } + + private String loadTableComment(ConnectorSession session, String dbName, String tableName) { + Table table = loadTable(session, new IcebergTableHandle(dbName, tableName)); + return table.properties().getOrDefault(TABLE_COMMENT_PROP, ""); } @Override public Optional getTableHandle( ConnectorSession session, String dbName, String tableName) { - TableIdentifier tableId = TableIdentifier.of(dbName, tableName); - if (!catalog.tableExists(tableId)) { + // Mirror legacy IcebergMetadataOps.tableExist: wrap the remote existence check in the auth context + // (the handle build below is pure — no remote call). + boolean exists; + try { + exists = context.executeAuthenticated(() -> catalogOps.tableExists(dbName, tableName)); + } catch (Exception e) { + throw new RuntimeException("Failed to check table exist, error message is:" + e.getMessage(), e); + } + if (!exists) { return Optional.empty(); } return Optional.of(new IcebergTableHandle(dbName, tableName)); @@ -112,32 +384,1414 @@ public Optional getTableHandle( public ConnectorTableSchema getTableSchema( ConnectorSession session, ConnectorTableHandle handle) { IcebergTableHandle iceHandle = (IcebergTableHandle) handle; - String dbName = iceHandle.getDbName(); - String tableName = iceHandle.getTableName(); + if (iceHandle.isSystemTable()) { + // System (metadata) table: load the base table and build the iceberg metadata-table, then + // parse ITS schema (e.g. t$snapshots -> committed_at/snapshot_id/...). Mirrors legacy + // IcebergSysExternalTable.getSysIcebergTable + getOrCreateSchemaCacheValue; the enable.mapping.* + // flags are threaded by the shared buildTableSchema -> parseSchema (deviation 5). + Table sysTable = loadSysTable(iceHandle); + return buildTableSchema(iceHandle.getTableName(), sysTable, sysTable.schema()); + } + // Mirror legacy IcebergMetadataOps.loadTable: wrap the remote load in the auth context. The schema + // + table-property assembly is pure (operates on the already-loaded Table). + Table table = loadTable(session, iceHandle); + return buildTableSchema(iceHandle.getTableName(), table, table.schema()); + } + + /** + * Returns the schema AS OF {@code snapshot.getSchemaId()} (the pinned schema version, for time-travel reads + * under schema evolution), or the LATEST schema when there is no pinned schema id (null snapshot or + * {@code schemaId < 0}). Mirrors legacy {@code IcebergUtils.getSchema}: {@code table.schemas().get(schemaId)} + * when the id is set and a current snapshot exists, else {@code table.schema()}. Shares + * {@link #buildTableSchema} with the latest path so the two cannot drift. + */ + @Override + public ConnectorTableSchema getTableSchema( + ConnectorSession session, ConnectorTableHandle handle, ConnectorMvccSnapshot snapshot) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + if (iceHandle.isSystemTable()) { + // A metadata table has a FIXED schema, independent of snapshot/schema-version (t$snapshots + // always exposes committed_at/snapshot_id/...; legacy has no schema-at-snapshot for sys + // tables). The time-travel pin (deviation 1) selects which rows the SCAN reads (T05), not the + // schema, so delegate to the latest path, which builds the metadata-table schema. + return getTableSchema(session, handle); + } + if (snapshot == null || snapshot.getSchemaId() < 0) { + return getTableSchema(session, handle); + } + Table table = loadTable(session, iceHandle); + Schema schema; + if (table.currentSnapshot() == null) { + // Empty table: legacy getSchema falls back to the latest schema (NEWEST_SCHEMA_ID path). + schema = table.schema(); + } else { + schema = table.schemas().get((int) snapshot.getSchemaId()); + if (schema == null) { + // Defensive: a pinned id absent from table.schemas() (legacy would NPE) -> latest. + // INVARIANT: this SLOT-schema fallback MUST stay identical to the DICT-schema fallback in + // IcebergScanPlanProvider.pinnedSchema (same getSchemaId() lookup + same silent -> table.schema()). + // If the two diverge, the field-id dict names and the BE scan-slot names resolve DIFFERENT + // schemas -> BE children.at() std::out_of_range-SIGABRT on a schema-evolved time-travel read + // (reverify #65185 L16). Do not harden ONE side to throw without the other. + schema = table.schema(); + } + } + return buildTableSchema(iceHandle.getTableName(), table, schema); + } - Table table = catalog.loadTable(TableIdentifier.of(dbName, tableName)); - Schema icebergSchema = table.schema(); - List columns = parseSchema(icebergSchema); + /** + * Assembles the {@link ConnectorTableSchema} for {@code table} from {@code schema} (the latest schema, or a + * historical schema for a time-travel read). The {@code iceberg.format-version} / {@code location} / + * {@code iceberg.partition-spec} properties are table-level (not schema-versioned). Factored out so the + * latest and at-snapshot paths share ONE assembly. + */ + private ConnectorTableSchema buildTableSchema(String tableName, Table table, Schema schema) { + List columns = parseSchema(schema); + + // Append the iceberg v3 row-lineage hidden columns (_row_id / _last_updated_sequence_number) for + // format-version >= 3 tables, mirroring legacy IcebergUtils.appendRowLineageColumnsForV3 — invoked + // unconditionally (format-gated) from IcebergExternalTable.getFullSchema. They are BIGINT, nullable, + // non-key, hidden, and carry a reserved Doris field id (matched BE-side); convertColumn re-applies + // setIsVisible(false)/setUniqueId. Appended AFTER the data columns (legacy append order). Metadata + // (system) tables report format-version 2 (BaseMetadataTable.properties() is empty), so the gate + // naturally excludes them — matching legacy, which injects lineage only for data tables. + if (getFormatVersion(table) >= ICEBERG_ROW_LINEAGE_MIN_VERSION) { + // reservedPassthrough() marks these as engine-recognized passthrough columns so fe-core MERGE/UPDATE + // and sink binding pass them through generically (via Column.isReservedPassthrough()) instead of + // string-matching the iceberg names — the engine no longer knows _row_id / _last_updated_sequence_number. + columns.add(new ConnectorColumn(ICEBERG_ROW_ID_COL, ConnectorType.of("BIGINT"), + "", true, null, false).invisible().withUniqueId(ICEBERG_ROW_ID_FIELD_ID).reservedPassthrough()); + columns.add(new ConnectorColumn(ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL, ConnectorType.of("BIGINT"), + "", true, null, false).invisible().withUniqueId(ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_FIELD_ID) + .reservedPassthrough()); + } Map tableProps = new HashMap<>(); tableProps.putAll(table.properties()); - tableProps.put("iceberg.format-version", - String.valueOf(table.spec().specId() >= 0 ? 2 : 1)); + // SHOW CREATE TABLE render hints under neutral reserved keys (fe-core strips them from the + // rendered PROPERTIES and emits them as LOCATION / PARTITION BY / ORDER BY). They replace the + // previously-injected location / iceberg.format-version / iceberg.partition-spec keys: those were + // never read by fe-core and would leak into the rendered PROPERTIES(...) (legacy iceberg SHOW + // CREATE dumped only the raw table.properties()). format-version stays available via the + // getFormatVersion(table) gate above for the row-lineage columns; it is not a user property. if (table.location() != null) { - tableProps.put("location", table.location()); + tableProps.put(ConnectorTableSchema.SHOW_LOCATION_KEY, table.location()); + } + String partitionClause = buildShowPartitionClause(table); + if (!partitionClause.isEmpty()) { + tableProps.put(ConnectorTableSchema.SHOW_PARTITION_CLAUSE_KEY, partitionClause); + } + String sortClause = buildShowSortClause(table); + if (!sortClause.isEmpty()) { + tableProps.put(ConnectorTableSchema.SHOW_SORT_CLAUSE_KEY, sortClause); } if (!table.spec().isUnpartitioned()) { - tableProps.put("iceberg.partition-spec", table.spec().toString()); + // Generic FE partition-column contract: post-cutover, PluginDrivenExternalTable derives the + // table's partition columns SOLELY from a "partition_columns" CSV property (toSchemaCacheValue), + // the same key MaxCompute/paimon emit. Mirror legacy IcebergUtils.loadTableSchemaCacheValue: + // walk the CURRENT spec, resolve each partition field's SOURCE column name (NO identity filter, + // NO dedupe), case-preserved to match parseSchema's case-preserved column names (#65094 read-path + // alignment; fromRemoteColumnName is identity for iceberg, so the FE consumer looks the names up + // case-sensitively). + List partitionColumns = new ArrayList<>(); + for (PartitionField field : table.spec().fields()) { + Types.NestedField source = table.schema().findField(field.sourceId()); + if (source != null) { + partitionColumns.add(source.name()); + } + } + if (!partitionColumns.isEmpty()) { + tableProps.put(ConnectorTableSchema.PARTITION_COLUMNS_KEY, String.join(",", partitionColumns)); + } } return new ConnectorTableSchema(tableName, columns, "ICEBERG", tableProps); } + /** + * Pre-renders the Doris {@code PARTITION BY LIST (...) ()} clause from the iceberg {@link PartitionSpec} + * for SHOW CREATE TABLE (the FE plugin-driven path has no live iceberg API). Mirrors legacy + * {@code IcebergExternalTable.getPartitionSpecSql}: void -> skipped, identity -> bare column, + * {@code bucket[N]}/{@code truncate[W]}/{@code year}/{@code month}/{@code day}/{@code hour} -> the + * matching Doris partition function. Returns "" for an unpartitioned table or no renderable field. + */ + private String buildShowPartitionClause(Table table) { + PartitionSpec spec = table.spec(); + if (spec == null || spec.isUnpartitioned()) { + return ""; + } + List fields = new ArrayList<>(); + for (PartitionField field : spec.fields()) { + String colName = table.schema().findColumnName(field.sourceId()); + if (colName == null) { + continue; + } + org.apache.iceberg.transforms.Transform t = field.transform(); + if (t.isVoid()) { + continue; + } + String quotedCol = "`" + colName + "`"; + if (t.isIdentity()) { + fields.add(quotedCol); + } else { + String transformStr = t.toString(); + if (transformStr.startsWith("bucket[")) { + int n = Integer.parseInt(transformStr.substring(7, transformStr.length() - 1)); + fields.add("BUCKET(" + n + ", " + quotedCol + ")"); + } else if (transformStr.startsWith("truncate[")) { + int w = Integer.parseInt(transformStr.substring(9, transformStr.length() - 1)); + fields.add("TRUNCATE(" + w + ", " + quotedCol + ")"); + } else if ("year".equals(transformStr)) { + fields.add("YEAR(" + quotedCol + ")"); + } else if ("month".equals(transformStr)) { + fields.add("MONTH(" + quotedCol + ")"); + } else if ("day".equals(transformStr)) { + fields.add("DAY(" + quotedCol + ")"); + } else if ("hour".equals(transformStr)) { + fields.add("HOUR(" + quotedCol + ")"); + } else { + LOG.warn("Unsupported Iceberg partition transform '{}' on column '{}', " + + "skipped in SHOW CREATE TABLE.", transformStr, colName); + } + } + } + if (fields.isEmpty()) { + return ""; + } + return "PARTITION BY LIST (" + String.join(", ", fields) + ") ()"; + } + + /** + * Pre-renders the Doris {@code ORDER BY (...)} clause from the iceberg {@link SortOrder} for SHOW + * CREATE TABLE. Mirrors legacy {@code IcebergExternalTable.getSortOrderSql} + {@code SortFieldInfo.toSql} + * ({@code `col` ASC|DESC NULLS FIRST|LAST}). Returns "" when the table is unsorted. + */ + static String buildShowSortClause(Table table) { + SortOrder sortOrder = table.sortOrder(); + if (sortOrder == null || sortOrder.isUnsorted() || sortOrder.fields().isEmpty()) { + return ""; + } + List sortItems = new ArrayList<>(); + for (org.apache.iceberg.SortField sortField : sortOrder.fields()) { + String columnName = table.schema().findColumnName(sortField.sourceId()); + if (columnName != null) { + boolean isAscending = sortField.direction() != org.apache.iceberg.SortDirection.DESC; + boolean isNullFirst = sortField.nullOrder() == org.apache.iceberg.NullOrder.NULLS_FIRST; + sortItems.add("`" + columnName + "`" + + (isAscending ? " ASC" : " DESC") + + " NULLS " + (isNullFirst ? "FIRST" : "LAST")); + } + } + return "ORDER BY (" + String.join(", ", sortItems) + ")"; + } + + /** + * Loads the iceberg {@link Table} for {@code handle}, wrapped in the FE-injected auth context (Kerberos + * UGI). Resolution goes through {@link #resolveTableForRead} (fat handle -> cross-query cache -> + * remote), so the many reads sharing one handle in a planning/analysis pass collapse onto a single remote + * {@code loadTable} (PERF-01); a fat-handle hit returns without any remote call. + */ + private Table loadTable(ConnectorSession session, IcebergTableHandle handle) { + try { + return context.executeAuthenticated(() -> resolveTableForRead(session, handle)); + } catch (Exception e) { + throw new RuntimeException("Failed to load table, error message is:" + e.getMessage(), e); + } + } + + /** + * Resolves the RAW iceberg {@link Table} for {@code handle}, WITHOUT opening an auth scope or wrapping + * exceptions — callers own both. The per-statement scope ({@link IcebergStatementScope#sharedTable}) comes + * first, so the statement's read metadata, scan planning and write all resolve the SAME one loaded object; + * on a scope miss the loader consults the cross-query {@link IcebergTableCache} when enabled (else a direct + * remote load). The remote loader's exception propagates verbatim (the cache re-throws it unwrapped), so a + * caller's own {@code NoSuchTableException} degradation (the partition-view readers) still fires. Callers + * needing the auth scope wrap the call in {@code executeAuthenticated} (see {@link #loadTable}). NOT used by + * the sys-table path ({@link #loadSysTable}), which takes a fresh remote base by design. + */ + private Table resolveTableForRead(ConnectorSession session, IcebergTableHandle handle) { + return IcebergStatementScope.sharedTable(session, handle.getDbName(), handle.getTableName(), + () -> tableCache != null + ? tableCache.getOrLoad(TableIdentifier.of(handle.getDbName(), handle.getTableName()), + () -> catalogOps.loadTable(handle.getDbName(), handle.getTableName())) + : catalogOps.loadTable(handle.getDbName(), handle.getTableName())); + } + + /** + * Loads the iceberg metadata (system) table for {@code handle} through the seam, wrapped in the + * FE-injected auth context (Kerberos UGI). Mirrors legacy + * {@code IcebergSysExternalTable.getSysIcebergTable}: load the base table by its BASE coordinates, + * then build the metadata table via {@code MetadataTableUtils.createMetadataTableInstance}. Both the + * base load and the (in-memory) metadata-table build run inside ONE {@code executeAuthenticated} so + * the auth scope covers the remote base load. {@code handle.getSysTableName()} is the lower-cased name + * already validated by {@code getSysTableHandle}, so {@code MetadataTableType.from} (case-insensitive) + * never returns null. + */ + private Table loadSysTable(IcebergTableHandle handle) { + try { + return context.executeAuthenticated(() -> { + Table base = catalogOps.loadTable(handle.getDbName(), handle.getTableName()); + return MetadataTableUtils.createMetadataTableInstance( + base, MetadataTableType.from(handle.getSysTableName())); + }); + } catch (Exception e) { + throw new RuntimeException("Failed to load table, error message is:" + e.getMessage(), e); + } + } + + /** + * Column handles keyed by (case-preserved) column name, mirroring {@code PaimonConnectorMetadata}. The generic + * {@code PluginDrivenScanNode.buildColumnHandles} looks each query slot up here by name, so the provider + * receives the PRUNED set of requested columns — which the T06 field-id schema dictionary keys its + * {@code current_schema_id = -1} entry off (the CI #969249 fix: the dict's top-level names == the BE + * scan-slot names BY CONSTRUCTION). The field id is the iceberg {@code NestedField.fieldId()} (a permanent + * invariant). The name is case-preserved (byte-matching {@link #parseSchema}) so the handle key == the + * Doris slot name (#65094 read-path alignment: top-level names keep their remote case). + */ + @Override + public Map getColumnHandles( + ConnectorSession session, ConnectorTableHandle handle) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + // Mirror getTableSchema: wrap the remote load in the auth context. A sys handle resolves the + // metadata-table columns (t$snapshots -> committed_at/...) so the generic scan node can look up + // its pruned sys-table slots by name; a data handle resolves the base table's columns. + Table table = iceHandle.isSystemTable() ? loadSysTable(iceHandle) : loadTable(session, iceHandle); + List fields = table.schema().columns(); + Map handles = new LinkedHashMap<>(fields.size()); + for (Types.NestedField field : fields) { + String name = field.name(); + handles.put(name, new IcebergColumnHandle(name, field.fieldId())); + } + return handles; + } + + /** + * Table-level row count, surfaced to the FE optimizer via {@code PluginDrivenExternalTable.fetchRowCount} + * (without this override the connector inherits {@code ConnectorStatisticsOps}'s {@code Optional.empty()}, + * so every iceberg table reports rowCount -1 -> CBO collapses cardinality to 1 and disables join reorder). + * Mirrors {@code PaimonConnectorMetadata.getTableStatistics} in STRUCTURE, but uses the legacy iceberg + * FORMULA ({@code IcebergUtils.getIcebergRowCount} -> {@code getCountFromSummary(summary, true)}: + * {@code total-records - total-position-deletes}, gated to UNKNOWN when equality deletes are present). + * Parity decisions: + *

      + *
    • System tables -> empty: legacy {@code IcebergSysExternalTable.fetchRowCount} is unconditionally + * UNKNOWN; a sys handle would otherwise load the BASE table and misreport its data row count for a + * metadata table. (This is a deliberate divergence from paimon, which reports sys-table counts.)
    • + *
    • {@code rowCount > 0} gate: legacy data-table consumer is {@code rowCount > 0 ? rowCount : UNKNOWN}, + * but the NEW consumer takes the value whenever {@code >= 0}, so a 0-row table would wrongly report 0. + * Collapsing {@code <= 0} to empty pins the "0 -> UNKNOWN" semantics here (matches paimon).
    • + *
    • Any failure degrades to empty (best effort): a statistics miss must never break query planning.
    • + *
    + */ + @Override + public Optional getTableStatistics( + ConnectorSession session, ConnectorTableHandle handle) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + if (iceHandle.isSystemTable()) { + return Optional.empty(); + } + long rowCount; + try { + rowCount = computeRowCount(loadTable(session, iceHandle)); + } catch (Exception e) { + LOG.warn("Failed to compute Iceberg row count for {}.{}", + iceHandle.getDbName(), iceHandle.getTableName(), e); + return Optional.empty(); + } + if (rowCount > 0) { + return Optional.of(new ConnectorTableStatistics(rowCount, -1)); + } + return Optional.empty(); + } + + /** + * Row count from the current snapshot summary, a faithful port of legacy {@code IcebergUtils + * .getIcebergRowCount} (which calls {@code getCountFromSummary(summary, true)}, upstream 32a2651f66b / + * #64648): any equality delete ({@code total-equality-deletes} absent or {@code != "0"}) -> -1 (UNKNOWN), + * since equality deletes re-project at read time and the summary cannot net them out; otherwise + * {@code total-records - total-position-deletes}. Shares the equality-delete gate with the COUNT(*) + * pushdown {@code IcebergScanPlanProvider.getCountFromSummary}, differing only in dangling-delete handling + * (table statistics always net out position deletes; the pushdown honors the dangling-delete session var). + * Empty table (no current snapshot) -> -1, which the caller maps to UNKNOWN. + */ + private static long computeRowCount(Table table) { + Snapshot snapshot = table.currentSnapshot(); + if (snapshot == null) { + return -1; + } + Map summary = snapshot.summary(); + // Equality-delete gate + null-guard, a faithful port of legacy IcebergUtils.getCountFromSummary( + // summary, true) (upstream 32a2651f66b, #64648): an absent total-* counter (compaction / replace / + // overwrite snapshots may omit one — the pre-fix Long.parseLong(null) NPE-d), or any equality delete + // (total-equality-deletes != "0"), makes the summary row count unsafe -> -1 (caller maps to UNKNOWN), + // because equality deletes re-project at read time and the summary cannot net them out. Same gate as + // the COUNT(*) pushdown IcebergScanPlanProvider.getCountFromSummary. + String equalityDeletes = summary.get(TOTAL_EQUALITY_DELETES); + String totalRecords = summary.get(TOTAL_RECORDS); + String positionDeletes = summary.get(TOTAL_POSITION_DELETES); + if (equalityDeletes == null || totalRecords == null || positionDeletes == null) { + return -1; + } + if (!equalityDeletes.equals("0")) { + return -1; + } + 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}: + * an {@code hms}-backed catalog sends {@code TTableType.HIVE_TABLE} carrying a {@link THiveTable}, every + * other flavor sends {@code TTableType.ICEBERG_TABLE} carrying a {@link TIcebergTable}. The {@code hms} + * predicate is CASE-INSENSITIVE to match legacy: legacy compares the FIXED constant + * {@code getIcebergCatalogType()} (= {@code "hms"}) while the raw user value is lower-cased for factory + * dispatch, so {@code iceberg.catalog.type="HMS"}/{@code "Hms"} still bound a HiveCatalog and emitted + * {@code HIVE_TABLE}; matching that here keeps descriptor parity (P6.5-T07). Null-safe: an absent + * {@code iceberg.catalog.type} -> the ICEBERG_TABLE branch. + * + *

    Without this override the SPI default returns {@code null}, so fe-core + * ({@code PluginDrivenExternalTable.toThrift}) falls back to {@code TTableType.SCHEMA_TABLE} and BE's + * {@code DescriptorTbl::create} builds a {@code SchemaTableDescriptor} instead of the + * {@code Hive/IcebergTableDescriptor} legacy produced. BE never consults the descriptor table type for an + * iceberg sys (JNI) scan, so this is FE-side parity (EXPLAIN/profile) + closes the latent base-table + * descriptor gap. The SPI signature carries no handle, so this single override covers BOTH base and system + * tables (legacy uses an identical fork for both), mirroring paimon's connector-level override. + */ + @Override + public TTableDescriptor buildTableDescriptor( + ConnectorSession session, + long tableId, String tableName, String dbName, + String remoteName, int numCols, long catalogId) { + if (IcebergConnectorProperties.TYPE_HMS.equalsIgnoreCase( + properties.get(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE))) { + THiveTable tHiveTable = new THiveTable(dbName, tableName, new HashMap<>()); + TTableDescriptor desc = new TTableDescriptor( + tableId, TTableType.HIVE_TABLE, numCols, 0, tableName, dbName); + desc.setHiveTable(tHiveTable); + return desc; + } + TIcebergTable tIcebergTable = new TIcebergTable(dbName, tableName, new HashMap<>()); + TTableDescriptor desc = new TTableDescriptor( + tableId, TTableType.ICEBERG_TABLE, numCols, 0, tableName, dbName); + desc.setIcebergTable(tIcebergTable); + return desc; + } + + // ========== 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 + * loud (legacy parity) — the gate is a pure local check run BEFORE the auth context, like paimon. + * Existence / IF NOT EXISTS is resolved upstream by {@code PluginDrivenExternalCatalog.createDb}. + */ + @Override + public void createDatabase(ConnectorSession session, String dbName, Map properties) { + if (!properties.isEmpty() && !isHmsCatalog()) { + throw new DorisConnectorException( + "Not supported: create database with properties for iceberg catalog type: " + catalogType()); + } + try { + context.executeAuthenticated(() -> { + catalogOps.createDatabase(dbName, properties); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to create Iceberg database " + dbName + ": " + e.getMessage(), e); + } + } + + /** + * Drops an iceberg namespace, mirroring legacy {@code IcebergMetadataOps.performDropDb}. With + * {@code force} the contained tables are dropped (purged) first so a non-empty namespace can be removed; + * the namespace location is captured BEFORE the drop and its empty directory shell pruned afterwards + * (HMS only). Existence / IF EXISTS is resolved upstream by {@code PluginDrivenExternalCatalog.dropDb}, so + * {@code ifExists} is accepted for SPI parity but not re-checked here. + * + *

    A {@code force} drop cascades the contained iceberg VIEWS as well (they live in their own namespace, + * so the table cascade alone would leave them behind and {@code dropNamespace} would fail "not empty"). + */ + @Override + public void dropDatabase(ConnectorSession session, String dbName, boolean ifExists, boolean force) { + Optional namespaceLocation; + try { + namespaceLocation = context.executeAuthenticated(() -> { + Optional location; + try { + location = isHmsCatalog() + ? catalogOps.loadNamespaceLocation(dbName) : Optional.empty(); + if (force) { + for (String table : catalogOps.listTableNames(dbName)) { + catalogOps.dropTable(dbName, table, true); + } + // Cascade the views too, mirroring legacy IcebergMetadataOps.performDropDb: iceberg + // VIEWS live in their own namespace (listTableNames subtracts them), so without this the + // dropDatabase below would fail loud ("namespace not empty") when the db still has views. + for (String view : catalogOps.listViewNames(dbName)) { + catalogOps.dropView(dbName, view); + } + } + } catch (NoSuchNamespaceException e) { + // FORCE drop of a namespace whose remote side is already gone: tolerate it as a silent + // success, mirroring legacy IcebergMetadataOps.performDropDb (which swallowed + // NoSuchNamespaceException during the force cascade). The FE cache still holds the db but + // the remote namespace vanished (e.g. dropped out-of-band) -> nothing left to drop or + // clean up. The location probe (HMS only) runs before the cascade, so the tolerant region + // covers it too. A non-force drop keeps failing loud (legacy parity: only FORCE tolerates + // a missing namespace). + if (!force) { + throw e; + } + LOG.info("drop database[{}] force which does not exist", dbName); + return Optional.empty(); + } + catalogOps.dropDatabase(dbName); + return location; + }); + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to drop Iceberg database " + dbName + ": " + e.getMessage(), e); + } + // 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())); + } + + /** + * Creates an iceberg table, mirroring legacy {@code IcebergMetadataOps.performCreateTable}: the neutral + * request is turned into an iceberg Schema / PartitionSpec / SortOrder / properties (with the Doris + * merge-on-read defaults) by {@link IcebergSchemaBuilder}, then created through the seam. The artifact + * build is pure (no remote call) and runs outside the auth context. Existence / IF NOT EXISTS is resolved + * upstream by {@code PluginDrivenExternalCatalog.createTable}. + */ + @Override + public void createTable(ConnectorSession session, ConnectorCreateTableRequest request) { + rejectReservedRowLineageColumns(request); + Schema schema = IcebergSchemaBuilder.buildSchema(request.getColumns()); + PartitionSpec partitionSpec = IcebergSchemaBuilder.buildPartitionSpec(request.getPartitionSpec(), schema); + SortOrder sortOrder = IcebergSchemaBuilder.buildSortOrder(request.getSortOrder(), schema); + // Pass the catalog properties so a catalog-level table-default/override.format-version is respected + // instead of being forced to v2 (upstream 25f291673f1, #63825). `properties` holds the raw catalog + // CREATE properties (ConnectorFactory.createConnector(catalogProperty.getProperties())). + Map tableProperties = + IcebergSchemaBuilder.buildTableProperties(request.getProperties(), properties); + try { + context.executeAuthenticated(() -> { + catalogOps.createTable(request.getDbName(), request.getTableName(), + schema, partitionSpec, sortOrder, tableProperties); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to create Iceberg table " + + request.getDbName() + "." + request.getTableName() + ": " + e.getMessage(), e); + } + } + + /** + * Rejects a user-defined column whose name collides with an iceberg v3 reserved row-lineage column + * ({@code _row_id} / {@code _last_updated_sequence_number}) on a format-version ≥ 3 table. Moved off + * fe-core {@code CreateTableInfo.validateIcebergRowLineageColumns} — the connector owns the iceberg + * column-name convention. Uses the full effective-format-version precedence (catalog + * {@code table-override} > table request > catalog {@code table-default}). Behavior differs from the + * former fe-core analysis-time check: it runs during {@code createTable} (later, and NOT reached when an + * {@code IF NOT EXISTS} hits an existing table — accepted relaxation) and throws + * {@link DorisConnectorException} rather than an engine {@code AnalysisException}; the message is unchanged. + */ + private void rejectReservedRowLineageColumns(ConnectorCreateTableRequest request) { + int formatVersion = IcebergSchemaBuilder.getEffectiveFormatVersion(request.getProperties(), properties); + if (formatVersion < ICEBERG_ROW_LINEAGE_MIN_VERSION) { + return; + } + for (ConnectorColumn column : request.getColumns()) { + String name = column.getName(); + if (ICEBERG_ROW_ID_COL.equalsIgnoreCase(name) + || ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL.equalsIgnoreCase(name)) { + throw new DorisConnectorException("Cannot create Iceberg v" + formatVersion + + " table with reserved row lineage column: " + name); + } + } + } + + /** + * Drops an iceberg table, mirroring legacy {@code IcebergMetadataOps.performDropTable}: the table location + * is captured BEFORE the drop (HMS only), the table is dropped with {@code purge=true} (iceberg deletes the + * data + metadata files), then the empty directory shell is pruned. {@code PluginDrivenExternalCatalog} + * has already resolved the handle / IF EXISTS upstream. + * + *

    This handles TABLES only: a DROP on an iceberg view is routed to {@link #dropView} by + * {@code PluginDrivenExternalCatalog.dropTable} (via {@link #viewExists}) BEFORE the handle is resolved, + * mirroring legacy {@code IcebergMetadataOps.dropTableImpl}'s viewExists -> performDropView dispatch. + */ + @Override + public void dropTable(ConnectorSession session, ConnectorTableHandle handle) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + Optional tableLocation; + try { + tableLocation = context.executeAuthenticated(() -> { + Optional location = isHmsCatalog() + ? catalogOps.loadTableLocation(iceHandle.getDbName(), iceHandle.getTableName()) + : Optional.empty(); + catalogOps.dropTable(iceHandle.getDbName(), iceHandle.getTableName(), true); + return location; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to drop Iceberg table " + + iceHandle.getDbName() + "." + iceHandle.getTableName() + ": " + e.getMessage(), e); + } + tableLocation.ifPresent(location -> + context.cleanupEmptyManagedLocation(location, IcebergSchemaBuilder.tableLocationChildDirs())); + } + + /** + * Renames a table, mirroring legacy {@code IcebergMetadataOps.renameTableImpl}: a thin seam delegation + * ({@code catalog.renameTable}) inside the auth context. {@code newName} is the rename target's name in + * the same (remote) database — kept as-is as the new remote name, mirroring how {@code createTable} names + * a new table (iceberg has no separate remote-name mapping). + */ + @Override + public void renameTable(ConnectorSession session, ConnectorTableHandle handle, String newName) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + try { + context.executeAuthenticated(() -> { + catalogOps.renameTable(iceHandle.getDbName(), iceHandle.getTableName(), newName); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to rename Iceberg table " + + iceHandle.getDbName() + "." + iceHandle.getTableName() + " to " + newName + + ": " + e.getMessage(), e); + } + } + + // ========== Column evolution (B2) — mirror legacy IcebergMetadataOps add/drop/rename/modify/reorder ========== + + /** + * Adds a column, mirroring legacy {@code IcebergMetadataOps.addColumn}/{@code addOneColumn}: the neutral + * column is turned into an iceberg type + parsed DEFAULT literal PURELY (outside auth), then committed + * through the seam at {@code position} ({@code null} = append at the end). A non-nullable column cannot be + * added to an existing iceberg table (legacy parity). + */ + @Override + public void addColumn(ConnectorSession session, ConnectorTableHandle handle, + ConnectorColumn column, ConnectorColumnPosition position) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + IcebergColumnChange change = toAddColumnChange(column); + try { + context.executeAuthenticated(() -> { + catalogOps.addColumn(iceHandle.getDbName(), iceHandle.getTableName(), change, position); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to add column " + column.getName() + " to Iceberg table " + + iceHandle.getDbName() + "." + iceHandle.getTableName() + ": " + e.getMessage(), e); + } + } + + /** Adds columns in one schema update, mirroring legacy {@code IcebergMetadataOps.addColumns}. */ + @Override + public void addColumns(ConnectorSession session, ConnectorTableHandle handle, List columns) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + List changes = new ArrayList<>(columns.size()); + for (ConnectorColumn column : columns) { + changes.add(toAddColumnChange(column)); + } + try { + context.executeAuthenticated(() -> { + catalogOps.addColumns(iceHandle.getDbName(), iceHandle.getTableName(), changes); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to add columns to Iceberg table " + + iceHandle.getDbName() + "." + iceHandle.getTableName() + ": " + e.getMessage(), e); + } + } + + /** Drops a column, mirroring legacy {@code IcebergMetadataOps.dropColumn}. */ + @Override + public void dropColumn(ConnectorSession session, ConnectorTableHandle handle, String columnName) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + try { + context.executeAuthenticated(() -> { + catalogOps.dropColumn(iceHandle.getDbName(), iceHandle.getTableName(), columnName); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to drop column " + columnName + " from Iceberg table " + + iceHandle.getDbName() + "." + iceHandle.getTableName() + ": " + e.getMessage(), e); + } + } + + /** Renames a column, mirroring legacy {@code IcebergMetadataOps.renameColumn}. */ + @Override + public void renameColumn(ConnectorSession session, ConnectorTableHandle handle, String oldName, + String newName) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + try { + context.executeAuthenticated(() -> { + catalogOps.renameColumn(iceHandle.getDbName(), iceHandle.getTableName(), oldName, newName); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to rename column " + oldName + " to " + newName + + " in Iceberg table " + iceHandle.getDbName() + "." + iceHandle.getTableName() + + ": " + e.getMessage(), e); + } + } + + /** + * Modifies a column, mirroring legacy {@code IcebergMetadataOps.modifyColumn}: the neutral column is turned + * into the full iceberg type PURELY (scalar leaf or the whole {@code STRUCT}/{@code ARRAY}/{@code MAP} tree, + * carrying nested nullability + per-field comments), then the seam validates the current column + * (exists / not optional→required) and either commits a scalar {@code updateColumn} or diffs the new + * complex type against the current one field-by-field ({@link IcebergComplexTypeDiff}), plus make-optional + + * reposition. + * + *

    A complex-type modify may only carry a {@code NULL} default (legacy + * {@code validateForModifyComplexColumn} parity), checked here before the remote call.

    + */ + @Override + public void modifyColumn(ConnectorSession session, ConnectorTableHandle handle, + ConnectorColumn column, ConnectorColumnPosition position) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + validateCommonColumnInfo(column); + if (isComplexType(column.getType()) && column.getDefaultValue() != null) { + throw new DorisConnectorException("Complex type default value only supports NULL: " + column.getName()); + } + Type icebergType; + try { + icebergType = IcebergSchemaBuilder.buildColumnType(column.getType()); + } catch (DorisConnectorException buildError) { + // A nested narrowing to an iceberg-unrepresentable type (e.g. ARRAY -> ARRAY) throws a + // generic "Unsupported type for Iceberg: SMALLINT" here. Restore the legacy parity message ("Cannot + // change int to smallint in nested types") by validating the requested nested type against the + // CURRENT type — legacy validated in Doris type space, where the narrow target still exists. + throw upgradeNestedModifyError(iceHandle, column, buildError); + } + IcebergColumnChange change = new IcebergColumnChange(column.getName(), icebergType, + column.getComment(), null, column.isNullable()); + try { + context.executeAuthenticated(() -> { + catalogOps.modifyColumn(iceHandle.getDbName(), iceHandle.getTableName(), change, position); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to modify column " + column.getName() + + " in Iceberg table " + iceHandle.getDbName() + "." + iceHandle.getTableName() + + ": " + e.getMessage(), e); + } + } + + /** + * Upgrades the generic "Unsupported type for Iceberg" error from a failed complex-type build into the legacy + * "Cannot change <old> to <new> in nested types" message, by walking the requested nested type + * against the CURRENT column type. Best-effort: a scalar modify, a load failure, or no offending nested leaf + * keeps the original build error — so no other modify path changes. + */ + private DorisConnectorException upgradeNestedModifyError(IcebergTableHandle handle, ConnectorColumn column, + DorisConnectorException buildError) { + if (!isComplexType(column.getType())) { + return buildError; + } + try { + Types.NestedField current = context.executeAuthenticated(() -> + catalogOps.loadTable(handle.getDbName(), handle.getTableName()) + .schema().findField(column.getName())); + if (current != null && !current.type().isPrimitiveType()) { + IcebergComplexTypeDiff.validateNestedModifyRepresentable(current.type(), column.getType()); + } + } catch (DorisConnectorException parityError) { + return parityError; + } catch (Exception ignored) { + // load failed / column missing -> keep the original build error + } + return buildError; + } + + /** Reorders columns, mirroring legacy {@code IcebergMetadataOps.reorderColumns}. */ + @Override + public void reorderColumns(ConnectorSession session, ConnectorTableHandle handle, List newOrder) { + if (newOrder == null || newOrder.isEmpty()) { + throw new DorisConnectorException("Reorder columns failed: the new order is empty"); + } + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + try { + context.executeAuthenticated(() -> { + catalogOps.reorderColumns(iceHandle.getDbName(), iceHandle.getTableName(), newOrder); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to reorder columns in Iceberg table " + + iceHandle.getDbName() + "." + iceHandle.getTableName() + ": " + e.getMessage(), e); + } + } + + // ========== Branch / tag refs (B4) — mirror legacy IcebergMetadataOps createOrReplace/drop Branch/Tag ========== + + /** + * Creates or replaces a branch, mirroring legacy {@code IcebergMetadataOps.createOrReplaceBranchImpl}: the + * whole {@code ManageSnapshots} build + commit (which reads the live table's current snapshot / refs) runs + * through the seam inside the auth context. The neutral {@link BranchChange} carries the SQL options; the + * iceberg {@code ManageSnapshots} logic stays in the seam. + */ + @Override + public void createOrReplaceBranch(ConnectorSession session, ConnectorTableHandle handle, BranchChange branch) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + try { + context.executeAuthenticated(() -> { + catalogOps.createOrReplaceBranch(iceHandle.getDbName(), iceHandle.getTableName(), branch); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to create or replace branch " + branch.getName() + + " on Iceberg table " + iceHandle.getDbName() + "." + iceHandle.getTableName() + + ": " + e.getMessage(), e); + } + } + + /** Creates or replaces a tag, mirroring legacy {@code IcebergMetadataOps.createOrReplaceTagImpl}. */ + @Override + public void createOrReplaceTag(ConnectorSession session, ConnectorTableHandle handle, TagChange tag) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + try { + context.executeAuthenticated(() -> { + catalogOps.createOrReplaceTag(iceHandle.getDbName(), iceHandle.getTableName(), tag); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to create or replace tag " + tag.getName() + + " on Iceberg table " + iceHandle.getDbName() + "." + iceHandle.getTableName() + + ": " + e.getMessage(), e); + } + } + + /** Drops a branch, mirroring legacy {@code IcebergMetadataOps.dropBranchImpl}. */ + @Override + public void dropBranch(ConnectorSession session, ConnectorTableHandle handle, DropRefChange branch) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + try { + context.executeAuthenticated(() -> { + catalogOps.dropBranch(iceHandle.getDbName(), iceHandle.getTableName(), branch); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to drop branch " + branch.getName() + + " from Iceberg table " + iceHandle.getDbName() + "." + iceHandle.getTableName() + + ": " + e.getMessage(), e); + } + } + + /** Drops a tag, mirroring legacy {@code IcebergMetadataOps.dropTagImpl}. */ + @Override + public void dropTag(ConnectorSession session, ConnectorTableHandle handle, DropRefChange tag) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + try { + context.executeAuthenticated(() -> { + catalogOps.dropTag(iceHandle.getDbName(), iceHandle.getTableName(), tag); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to drop tag " + tag.getName() + + " from Iceberg table " + iceHandle.getDbName() + "." + iceHandle.getTableName() + + ": " + e.getMessage(), e); + } + } + + // ===== Partition evolution (B5) — mirror legacy IcebergMetadataOps add/drop/replace PartitionField ===== + + /** + * Adds a partition field, mirroring legacy {@code IcebergMetadataOps.addPartitionField}: the whole + * {@code UpdatePartitionSpec} build + commit (which reads the live table) runs through the seam inside the + * auth context. The neutral {@link PartitionFieldChange} carries the SQL transform; the iceberg + * {@code Term}/{@code UpdatePartitionSpec} logic stays in the seam. + */ + @Override + public void addPartitionField(ConnectorSession session, ConnectorTableHandle handle, + PartitionFieldChange change) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + try { + context.executeAuthenticated(() -> { + catalogOps.addPartitionField(iceHandle.getDbName(), iceHandle.getTableName(), change); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to add partition field to Iceberg table " + + iceHandle.getDbName() + "." + iceHandle.getTableName() + ": " + e.getMessage(), e); + } + } + + /** Drops a partition field, mirroring legacy {@code IcebergMetadataOps.dropPartitionField}. */ + @Override + public void dropPartitionField(ConnectorSession session, ConnectorTableHandle handle, + PartitionFieldChange change) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + try { + context.executeAuthenticated(() -> { + catalogOps.dropPartitionField(iceHandle.getDbName(), iceHandle.getTableName(), change); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to drop partition field from Iceberg table " + + iceHandle.getDbName() + "." + iceHandle.getTableName() + ": " + e.getMessage(), e); + } + } + + /** Replaces a partition field, mirroring legacy {@code IcebergMetadataOps.replacePartitionField}. */ + @Override + public void replacePartitionField(ConnectorSession session, ConnectorTableHandle handle, + PartitionFieldChange change) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + try { + context.executeAuthenticated(() -> { + catalogOps.replacePartitionField(iceHandle.getDbName(), iceHandle.getTableName(), change); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to replace partition field in Iceberg table " + + iceHandle.getDbName() + "." + iceHandle.getTableName() + ": " + e.getMessage(), e); + } + } + + /** + * Builds the iceberg {@code ADD COLUMN} artifacts from a neutral column, mirroring legacy + * {@code addOneColumn}: reject aggregated / auto-inc columns and a non-nullable add, build the iceberg + * type, parse the DEFAULT literal. Pure (no remote call); runs outside the auth context. + */ + private IcebergColumnChange toAddColumnChange(ConnectorColumn column) { + validateCommonColumnInfo(column); + if (!column.isNullable()) { + throw new DorisConnectorException("can't add a non-nullable column to an Iceberg table: " + + column.getName()); + } + Type icebergType = IcebergSchemaBuilder.buildColumnType(column.getType()); + return new IcebergColumnChange(column.getName(), icebergType, column.getComment(), + IcebergSchemaBuilder.parseDefaultLiteral(column.getDefaultValue(), icebergType), + column.isNullable()); + } + + /** Rejects aggregated / auto-increment columns on iceberg, mirroring legacy {@code validateCommonColumnInfo}. */ + private static void validateCommonColumnInfo(ConnectorColumn column) { + if (column.isAggregated()) { + throw new DorisConnectorException("Can not specify aggregation method for iceberg table column: " + + column.getName()); + } + if (column.isAutoInc()) { + throw new DorisConnectorException("Can not specify auto incremental iceberg table column: " + + column.getName()); + } + } + + /** Whether a neutral type is a complex (STRUCT / ARRAY / MAP) type, by its type name. */ + private static boolean isComplexType(ConnectorType type) { + String name = type.getTypeName().toUpperCase(Locale.ROOT); + return "ARRAY".equals(name) || "MAP".equals(name) || "STRUCT".equals(name); + } + + /** The configured {@code iceberg.catalog.type}, or {@code null} when unset. */ + private String catalogType() { + return properties.get(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE); + } + + /** Whether this is an HMS-backed iceberg catalog (case-insensitive, matching the read-path fork). */ + private boolean isHmsCatalog() { + return IcebergConnectorProperties.TYPE_HMS.equalsIgnoreCase(catalogType()); + } + + // ========== E7: System Tables (P6.5) ========== + + /** + * Lists the system-table names iceberg exposes. Connector-global: {@code IcebergSysTable.SUPPORTED_SYS_TABLES} + * is built once from {@code MetadataTableType.values()} and applies to every iceberg table, so this returns + * the same lower-cased names for any base handle — a defensive unmodifiable copy. + * + *

    {@code POSITION_DELETES} IS exposed. The former Q2 exclusion mirrored legacy, which rejected it with + * "SysTable position_deletes is not supported yet"; upstream #65135 then implemented it natively, so + * excluding it here would be a capability regression vs master. Unlike every other entry, its scan takes + * BE's native reader rather than the JNI serialized-split path (see + * {@code IcebergScanPlanProvider.doPlanPositionDeletesSystemTableScan}). + */ + @Override + public List listSupportedSysTables(ConnectorSession session, + ConnectorTableHandle baseTableHandle) { + List names = new ArrayList<>(); + for (MetadataTableType type : MetadataTableType.values()) { + names.add(type.name().toLowerCase(Locale.ROOT)); + } + return Collections.unmodifiableList(names); + } + + /** + * Resolves a handle for the named system table of {@code baseTableHandle}, or empty when iceberg does + * not expose {@code sysName} (case-insensitive; a {@code null} name and an unknown name). + * + *

    Resolution is LAZY and pure — no catalog round-trip. Unlike paimon (whose handle stashes a + * transient SDK {@code Table}, so {@code getSysTableHandle} eagerly loads it), the iceberg handle + * carries no SDK {@code Table}; the metadata-table is built on demand in {@code getTableSchema}/scan + * via {@code MetadataTableUtils} (mirroring legacy {@code IcebergSysExternalTable.getSysIcebergTable}, + * which builds it lazily). Eager-loading here would be wasted work — the result cannot be stashed on + * the handle, so it would be rebuilt downstream — and a remote round-trip not present in legacy. The + * base table's existence has already been verified by {@code getTableHandle} (the generic + * {@code PluginDrivenSysExternalTable.resolveConnectorTableHandle} acquires the base handle first). + * + *

    Deviation 1 (time travel): the base handle's snapshot/ref/schema pin is RETAINED on the sys + * handle (the OPPOSITE of paimon's pin-clearing {@code forSystemTable}) — iceberg system tables + * legally time-travel ({@code t$snapshots FOR VERSION/TIME AS OF ...}), so a pinned sys read must + * honor the pin. + */ + @Override + public Optional getSysTableHandle(ConnectorSession session, + ConnectorTableHandle baseTableHandle, String sysName) { + // Null-safe: a null / unknown sysName is "this connector does not expose that sys table" + // (Optional.empty per the contract), NOT an NPE/exception. + if (!isSupportedSysTable(sysName)) { + return Optional.empty(); + } + // Normalize to lower case for handle-identity parity with legacy (SysTable renders the suffix as + // "$" + name.toLowerCase()), so t$SNAPSHOTS and t$snapshots are the SAME handle. The support check + // above is case-insensitive; only the canonical stored name is lower-cased. + String sys = sysName.toLowerCase(Locale.ROOT); + IcebergTableHandle base = (IcebergTableHandle) baseTableHandle; + return Optional.of(IcebergTableHandle.forSystemTable( + base.getDbName(), base.getTableName(), sys, + base.getSnapshotId(), base.getRef(), base.getSchemaId())); + } + + /** + * Whether iceberg exposes a system table named {@code sysName} (case-insensitive). Mirrors + * {@code IcebergSysTable.SUPPORTED_SYS_TABLES}: every {@code MetadataTableType}, {@code POSITION_DELETES} + * included (see {@link #listSupportedSysTables}). A {@code null} name is simply not exposed (returns + * false, not NPE). + */ + private static boolean isSupportedSysTable(String sysName) { + if (sysName == null) { + return false; + } + for (MetadataTableType type : MetadataTableType.values()) { + if (type.name().equalsIgnoreCase(sysName)) { + return true; + } + } + return false; + } + + // ========== Write / Transaction (P6.3) ========== + + /** + * Opens a connector transaction for an iceberg write statement. The transaction id is the + * engine-side id allocated through the session, so it matches the id registered in the engine + * transaction registry (by the generic {@code PluginDrivenTransactionManager}, in both the + * per-manager map and {@code GlobalExternalTransactionInfoMgr}) and stamped into the data sink — + * the BE→FE report path finds the txn by this id to feed it commit fragments. + * + *

    Gate-closed / dormant until the P6.6 cutover: nothing routes plugin-driven iceberg writes + * through this path yet. The single SDK {@code org.apache.iceberg.Transaction} that backs commit is + * opened lazily by the write plan via {@link IcebergConnectorTransaction#beginWrite}; op selection + * (T04), the commit-validation suite (T05), the sink (T06), and the {@code supportsInsert/Delete/ + * Merge} capability declarations (T06/T07) land in later tasks.

    + */ + @Override + public ConnectorTransaction beginTransaction(ConnectorSession session) { + return new IcebergConnectorTransaction(session.allocateTransactionId(), catalogOps, context); + } + + /** + * Rejects row-level DML on iceberg copy-on-write tables (Doris only supports merge-on-read deletes / + * deletion vectors). Reads the per-operation write-mode property ({@code write.delete.mode} / + * {@code write.update.mode} / {@code write.merge.mode}, defaulting to {@code merge-on-read}) and throws if + * it resolves to {@code copy-on-write}. Mirrors the legacy fe-resident + * {@code IcebergDmlCommandUtils.checkNotCopyOnWrite}, but the message — and the iceberg property knowledge — + * now lives in the connector. {@code op} values other than DELETE/UPDATE/MERGE are not row-level DML and + * return without loading the table. + */ + @Override + public void validateRowLevelDmlMode(ConnectorSession session, ConnectorTableHandle handle, WriteOperation op) { + String modeProperty; + String defaultMode; + String operationLabel; + switch (op) { + case DELETE: + modeProperty = TableProperties.DELETE_MODE; + defaultMode = TableProperties.DELETE_MODE_DEFAULT; + operationLabel = "DELETE"; + break; + case UPDATE: + modeProperty = TableProperties.UPDATE_MODE; + defaultMode = TableProperties.UPDATE_MODE_DEFAULT; + operationLabel = "UPDATE"; + break; + case MERGE: + modeProperty = TableProperties.MERGE_MODE; + defaultMode = TableProperties.MERGE_MODE_DEFAULT; + operationLabel = "MERGE INTO"; + break; + default: + return; + } + Table table = loadTable(session, (IcebergTableHandle) handle); + String mode = table.properties().getOrDefault(modeProperty, defaultMode); + if (RowLevelOperationMode.COPY_ON_WRITE.modeName().equalsIgnoreCase(mode)) { + throw new DorisConnectorException(String.format( + "Doris does not support %s on Iceberg copy-on-write tables. " + + "Set table property '%s' to 'merge-on-read'.", + operationLabel, modeProperty)); + } + } + + /** + * Rejects an illegal static-partition column on {@code INSERT [OVERWRITE] ... PARTITION (col=val)}: the + * column must be an identity partition field of this (partitioned) table. Mirrors the legacy + * fe-resident {@code BindSink.validateStaticPartition} (now dead on the connector-driven path), but the + * iceberg {@link PartitionSpec} knowledge and the messages live in the connector. The lookup is keyed by + * partition field name (e.g. {@code category_bucket} for {@code bucket(4, category)}), matching + * legacy — so a source-column name that is not itself an identity field reads as "Unknown partition column". + */ + @Override + public void validateStaticPartitionColumns(ConnectorSession session, ConnectorTableHandle handle, + List staticPartitionColumnNames) { + if (staticPartitionColumnNames == null || staticPartitionColumnNames.isEmpty()) { + return; + } + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + Table table = loadTable(session, iceHandle); + PartitionSpec spec = table.spec(); + String tableName = iceHandle.getTableName(); + if (!spec.isPartitioned()) { + throw new DorisConnectorException(String.format( + "Table %s is not partitioned, cannot use static partition syntax", tableName)); + } + Map partitionFieldMap = new HashMap<>(); + for (PartitionField field : spec.fields()) { + partitionFieldMap.put(field.name(), field); + } + for (String colName : staticPartitionColumnNames) { + PartitionField field = partitionFieldMap.get(colName); + if (field == null) { + throw new DorisConnectorException(String.format( + "Unknown partition column '%s' in table '%s'. Available partition columns: %s", + colName, tableName, partitionFieldMap.keySet())); + } + if (!field.transform().isIdentity()) { + throw new DorisConnectorException(String.format( + "Cannot use static partition syntax for non-identity partition field '%s'" + + " (transform: %s).", colName, field.transform().toString())); + } + } + } + + // ========== B-2: partition enumeration (MTMV RANGE view + SHOW PARTITIONS) ========== + + /** + * The connector-supplied RANGE partition view for an iceberg table acting as an MTMV related (base) table. + * Overrides the SPI default (empty) so the generic {@code PluginDrivenMvccExternalTable} never degrades to + * the LIST/timestamp path: iceberg time-partitioned tables are intrinsically RANGE with snapshot-id + * freshness. All connector-specific math (eligibility gate, transform-to-range, partition-evolution overlap + * merge, snapshot-id resolution) happens in {@link IcebergPartitionUtils#buildMvccPartitionView}; the + * remote PARTITIONS scan runs inside the FE-injected auth context. + * + *

    The partition set + freshness are enumerated at the handle's pinned snapshot when present + * ({@code iceHandle.getSnapshotId() >= 0}), else the table's latest snapshot. The generic model (3/3) must + * thread the query's pin onto the handle (via {@code applySnapshot} with {@code beginQuerySnapshot}'s + * snapshot) before calling this, so the MTMV partition/freshness view stays consistent with the data-scan + * pin — mirroring master, which routes enumeration, freshness and the scan through ONE snapshot cache value.

    + * + *

    Fail-loud parity (NOT a degrade): a {@code NoSuchTableException} is allowed to propagate. The common + * dropped-table case is already absorbed by the generic model at handle resolution (no handle -> empty + * pin); a not-found HERE is the narrow post-resolution concurrent-drop race, where master fails the refresh + * rather than masking a vanished base table as "unpartitioned/fresh".

    + */ + @Override + public Optional getMvccPartitionView( + ConnectorSession session, ConnectorTableHandle handle) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + try { + return context.executeAuthenticated(() -> { + Table table = resolveTableForRead(session, iceHandle); + return Optional.of(IcebergPartitionUtils.buildMvccPartitionView(table, iceHandle.getSnapshotId(), + TableIdentifier.of(iceHandle.getDbName(), iceHandle.getTableName()), partitionCache)); + }); + } catch (Exception e) { + throw new RuntimeException("Failed to build iceberg MVCC partition view, error message is:" + + e.getMessage(), e); + } + } + + /** + * The physical iceberg partition display names ({@code "f1=v1/f2=v2"}) for SHOW PARTITIONS (single-column + * form — iceberg does not declare {@code SUPPORTS_PARTITION_STATS}). Post-cutover this restores real rows + * for partitioned tables (master rejected iceberg SHOW PARTITIONS outright, so the SPI default empty list + * would otherwise return silently zero rows). The remote PARTITIONS scan runs inside the auth context; a + * concurrent drop yields an empty list. + */ + @Override + public List listPartitionNames(ConnectorSession session, ConnectorTableHandle handle) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + try { + return context.executeAuthenticated(() -> { + Table table; + try { + table = resolveTableForRead(session, iceHandle); + } catch (NoSuchTableException e) { + LOG.warn("Iceberg table not found while listing partitions: {}.{}", + iceHandle.getDbName(), iceHandle.getTableName(), e); + return Collections.emptyList(); + } + return IcebergPartitionUtils.listPartitionNames(table, + TableIdentifier.of(iceHandle.getDbName(), iceHandle.getTableName()), partitionCache); + }); + } catch (Exception e) { + throw new RuntimeException("Failed to list iceberg partition names, error message is:" + + e.getMessage(), e); + } + } + + /** + * The physical iceberg partitions of {@code handle} with per-partition value maps, so the generic + * {@code PluginDrivenExternalTable.getNameToPartitionItems} can populate {@code selectedPartitionNum} + * (EXPLAIN {@code partition=N/M} + SQL-block-rule {@code partition_num} enforcement). Mirrors + * {@link #listPartitionNames}: the remote PARTITIONS scan runs inside the auth context; a concurrent drop + * yields an empty list. The {@code filter} is ignored (iceberg planScan is predicate-driven; the reported + * partition count is display/enforcement metadata only, never the read set). Unpartitioned tables map to + * the SPI default empty list via {@link IcebergPartitionUtils#listPartitions}. + */ + @Override + public List listPartitions(ConnectorSession session, + ConnectorTableHandle handle, Optional filter) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + try { + return context.executeAuthenticated(() -> { + Table table; + try { + table = resolveTableForRead(session, iceHandle); + } catch (NoSuchTableException e) { + LOG.warn("Iceberg table not found while listing partitions: {}.{}", + iceHandle.getDbName(), iceHandle.getTableName(), e); + return Collections.emptyList(); + } + return IcebergPartitionUtils.listPartitions(table, + TableIdentifier.of(iceHandle.getDbName(), iceHandle.getTableName()), partitionCache); + }); + } catch (Exception e) { + throw new RuntimeException("Failed to list iceberg partitions, error message is:" + + e.getMessage(), e); + } + } + + // ========== E5: MVCC snapshots / time travel ========== + + /** + * The query-begin MVCC pin: the table's LATEST snapshot, used as the consistent version for every read of + * {@code handle} in this query. Mirrors legacy {@code IcebergUtils.getLatestIcebergSnapshot}: the current + * snapshot id (or {@code -1} for an empty table — iceberg DOES support MVCC, so it still pins, mirroring + * paimon), and the LATEST schema id (NOT {@code currentSnapshot().schemaId()} — a schema-only change without + * a new snapshot advances the schema while the snapshot's id lags; legacy reads {@code table.schema() + * .schemaId()}). T08 serves this through the per-catalog {@link IcebergLatestSnapshotCache}: within the TTL + * a HIT returns the cached pin without re-loading the table (saves the load I/O and keeps the snapshot + * STABLE across queries — the legacy with-cache catalog). The cached value carries BOTH ids atomically so a + * schema-only ALTER between two queries cannot skew snapshotId vs schemaId. A disabled cache + * ({@code meta.cache.iceberg.table.ttl-second <= 0}) reads live every call (the legacy no-cache catalog). + */ + @Override + public Optional beginQuerySnapshot( + ConnectorSession session, ConnectorTableHandle handle) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + TableIdentifier id = TableIdentifier.of(iceHandle.getDbName(), iceHandle.getTableName()); + // A null latestSnapshotCache (iceberg.rest.session=user, where a shared table-keyed hit would bypass the + // per-user loadTable authorization) reads live per-user every call, so authorization runs every time (no + // stale-authz window); mirrors resolveTableForRead's tableCache null-fallback. A disabled cache (ttl<=0) + // is a non-null cache that reads live internally. + IcebergLatestSnapshotCache.CachedSnapshot pin = latestSnapshotCache != null + ? latestSnapshotCache.getOrLoad(id, () -> loadLatestSnapshotPin(session, iceHandle)) + : loadLatestSnapshotPin(session, iceHandle); + return Optional.of( + ConnectorMvccSnapshot.builder().snapshotId(pin.snapshotId).schemaId(pin.schemaId).build()); + } + + /** + * Loads the table live and pins its latest snapshot id (or {@code -1} for an empty table) plus its LATEST + * schema id — the {@link #beginQuerySnapshot} loader, extracted so the per-user (null-cache) path can reuse + * the exact same pin logic without a cache round-trip. + */ + private IcebergLatestSnapshotCache.CachedSnapshot loadLatestSnapshotPin( + ConnectorSession session, IcebergTableHandle iceHandle) { + Table table = loadTable(session, iceHandle); + Snapshot current = table.currentSnapshot(); + return new IcebergLatestSnapshotCache.CachedSnapshot( + current == null ? -1L : current.snapshotId(), table.schema().schemaId()); + } + + /** + * Resolves an explicit time-travel {@code spec} into a pinned {@link ConnectorMvccSnapshot}, owning ALL + * iceberg-specific parsing. Mirrors legacy {@code IcebergUtils.getQuerySpecSnapshot}, returning + * {@link Optional#empty()} when the target is not found (the fe-core consumer renders the user-facing + * "can't find …" error); only a MALFORMED spec (an unparseable snapshot id / datetime) throws. + * + *
      + *
    • {@code SNAPSHOT_ID} — {@code table.snapshot(Long.parseLong(v))}; absent ⇒ empty.
    • + *
    • {@code TIMESTAMP} — millis (digital ⇒ {@code parseLong}, else {@link IcebergTimeUtils#datetimeToMillis} + * in the session zone) ⇒ {@code SnapshotUtil.snapshotIdAsOfTime} (throws when none ⇒ caught → empty).
    • + *
    • {@code TAG}/{@code BRANCH} — {@code table.refs().get(name)}, validated as a tag/branch; pins by REF + * (carried in {@code properties[iceberg.scan.ref]}) so a later commit to the ref is honored (legacy + * {@code createTableScan} uses {@code scan.useRef(name)}). Schema id from + * {@code SnapshotUtil.schemaFor(table, name)}.
    • + *
    • {@code VERSION_REF} — non-numeric {@code FOR VERSION AS OF ''}: resolves ANY ref (branch OR + * tag), mirroring legacy {@code IcebergUtils.getQuerySpecSnapshot}'s {@code table.refs().containsKey}. + * Unlike {@code TAG} ({@code @tag}, tag-only) it does not require the ref to be a tag.
    • + *
    • {@code INCREMENTAL} — unsupported for iceberg (legacy {@code getQuerySpecSnapshot} never dispatches + * {@code @incr}); fail loud rather than silently read latest.
    • + *
    + */ + @Override + public Optional resolveTimeTravel( + ConnectorSession session, ConnectorTableHandle handle, ConnectorTimeTravelSpec spec) { + Table table = loadTable(session, (IcebergTableHandle) handle); + switch (spec.getKind()) { + case SNAPSHOT_ID: { + long id = Long.parseLong(spec.getStringValue()); + Snapshot snapshot = table.snapshot(id); + if (snapshot == null) { + return Optional.empty(); + } + return Optional.of(ConnectorMvccSnapshot.builder() + .snapshotId(id) + .schemaId(snapshot.schemaId()) + .build()); + } + case TIMESTAMP: { + long millis = parseTimestampMillis(session, spec); + long snapshotId; + try { + snapshotId = SnapshotUtil.snapshotIdAsOfTime(table, millis); + } catch (IllegalArgumentException e) { + // No snapshot at or before the timestamp (legacy threw a UserException; the SPI contract is + // empty-if-none, and fe-core renders "can't find snapshot earlier than or equal to time"). + return Optional.empty(); + } + return Optional.of(ConnectorMvccSnapshot.builder() + .snapshotId(snapshotId) + .schemaId(table.snapshot(snapshotId).schemaId()) + .build()); + } + case TAG: + return resolveRef(table, spec.getStringValue(), SnapshotRef::isTag); + case BRANCH: + return resolveRef(table, spec.getStringValue(), SnapshotRef::isBranch); + case VERSION_REF: + // Non-numeric FOR VERSION AS OF: accept ANY ref (branch or tag), matching legacy + // getQuerySpecSnapshot's table.refs().containsKey(value). Unlike @tag/@branch it does + // not constrain the ref kind. + return resolveRef(table, spec.getStringValue(), ref -> true); + case INCREMENTAL: + default: + throw new DorisConnectorException( + "incremental read (@incr) is not supported for Iceberg tables"); + } + } + + /** + * Resolves a named ref to a pinned snapshot, accepting it only when {@code accept} passes + * ({@code SnapshotRef::isTag} for {@code @tag}, {@code SnapshotRef::isBranch} for {@code @branch}, + * {@code ref -> true} for non-numeric {@code FOR VERSION AS OF}, which takes any ref). Empty when the + * ref is absent or rejected (legacy threw "Table X does not have branch/tag named Y"; the SPI returns + * empty so fe-core renders the user-facing message). Pins the ref NAME, not its current snapshot id — + * {@code applySnapshot} routes it to {@code scan.useRef(name)} (legacy parity). + */ + private Optional resolveRef(Table table, String refName, Predicate accept) { + SnapshotRef ref = table.refs().get(refName); + if (ref == null || !accept.test(ref)) { + return Optional.empty(); + } + long schemaId = SnapshotUtil.schemaFor(table, refName).schemaId(); + return Optional.of(ConnectorMvccSnapshot.builder() + .snapshotId(ref.snapshotId()) + .schemaId(schemaId) + .property(REF_PROPERTY, refName) + .build()); + } + + /** + * Derives epoch-millis from a {@code TIMESTAMP} spec: a digital value is {@code Long.parseLong} (epoch + * millis), a datetime string is parsed in the session zone, byte-faithful to legacy + * {@code TimeUtils.timeStringToLong}. (Honoring the digital form is a benign superset — legacy iceberg always + * parsed the value as a datetime string, where a digital value would have failed.) + */ + private long parseTimestampMillis(ConnectorSession session, ConnectorTimeTravelSpec spec) { + if (spec.isDigital()) { + return Long.parseLong(spec.getStringValue()); + } + return IcebergTimeUtils.datetimeToMillis( + spec.getStringValue(), IcebergTimeUtils.resolveSessionZone(session)); + } + + /** + * Threads a resolved MVCC / time-travel pin onto the handle BEFORE the scan reads it (the generic + * {@code PluginDrivenScanNode} calls this via {@code applyMvccSnapshotPin}). Reads the typed + * {@code snapshotId}/{@code schemaId} and the {@code iceberg.scan.ref} property; an empty-table / query-begin + * latest pin ({@code snapshotId<0} and no ref) returns the handle UNCHANGED (read latest — a + * {@code useSnapshot(-1)} would be a non-existent snapshot; mirrors paimon's {@code -1} guard). + */ + @Override + public ConnectorTableHandle applySnapshot(ConnectorSession session, + ConnectorTableHandle handle, ConnectorMvccSnapshot snapshot) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + if (snapshot == null) { + return iceHandle; + } + String ref = snapshot.getProperties().get(REF_PROPERTY); + long snapshotId = snapshot.getSnapshotId(); + if (snapshotId < 0 && ref == null) { + return iceHandle; + } + return iceHandle.withSnapshot(snapshotId, ref, snapshot.getSchemaId()); + } + + /** + * Scopes a per-group rewrite scan to {@code rawDataFilePaths} by threading them onto an immutable handle + * copy ({@link IcebergTableHandle#withRewriteFileScope}); the scan provider ({@code + * IcebergScanPlanProvider.planScanInternal}) keeps only the re-enumerated tasks whose RAW {@code + * dataFile.path().toString()} is in the scope, matching the SAME raw paths {@code planRewrite} emitted. A + * {@code null}/empty set is a no-op (read the full table) — never scope to an empty set (that would scan + * nothing); {@link IcebergTableHandle#withRewriteFileScope} also rejects null elements via {@code + * ImmutableSet.copyOf}, so the empty/null guard here keeps it from being reached with a bad set. + */ + @Override + public ConnectorTableHandle applyRewriteFileScope(ConnectorSession session, + ConnectorTableHandle handle, Set rawDataFilePaths) { + if (rawDataFilePaths == null || rawDataFilePaths.isEmpty()) { + return handle; + } + return ((IcebergTableHandle) handle).withRewriteFileScope(rawDataFilePaths); + } + + /** + * Marks the handle as a Top-N lazy-materialization scan so {@code IcebergScanPlanProvider} builds the + * field-id schema dictionary over the FULL schema (BE re-fetches non-projected columns by row-id). The + * generic {@code PluginDrivenScanNode} calls this when the scan carries the synthesized + * {@code __DORIS_GLOBAL_ROWID_COL__} column — legacy {@code IcebergScanNode.createScanRangeLocations} → + * {@code initSchemaInfoForAllColumn} parity. Threads the flag onto an immutable handle copy, mirroring + * {@link #applySnapshot} / {@link #applyRewriteFileScope}. + */ + @Override + public ConnectorTableHandle applyTopnLazyMaterialization(ConnectorSession session, + ConnectorTableHandle handle) { + return ((IcebergTableHandle) handle).withTopnLazyMaterialize(true); + } + // ========== Internal helpers ========== /** @@ -154,14 +1808,64 @@ private List parseSchema(Schema schema) { IcebergConnectorProperties.ENABLE_MAPPING_TIMESTAMP_TZ, "false")); for (Types.NestedField field : fields) { - columns.add(new ConnectorColumn( + // Legacy IcebergUtils.parseSchema parity (mirrors PaimonConnectorMetadata): the column name is + // case-preserved (#65094 read-path alignment; top-level slot names keep their remote case), + // isKey is always true (external-table semantics: DESC shows Key=true), + // and isAllowNull is always true regardless of the Iceberg required/optional flag (rows can + // still read NULL under schema-evolution default-fill; do NOT propagate the NOT NULL constraint). + ConnectorColumn column = new ConnectorColumn( field.name(), IcebergTypeMapping.fromIcebergType( field.type(), enableVarbinary, enableTimestampTz), field.doc() != null ? field.doc() : "", - field.isOptional(), - null)); + true, + null, + true); + // Carry the stable iceberg field-id as the column's uniqueId (legacy + // IcebergUtils.updateIcebergColumnUniqueId set the top-level Column.uniqueId = field.fieldId()). + // fe-core's ConnectorColumnConverter re-applies it (>= 0); the BE field-id scan path keys the + // read projection / nested matching off it, so without it a renamed-or-evolved column would + // mis-match. Nested children carry their ids via the ConnectorType (IcebergTypeMapping). + column = column.withUniqueId(field.fieldId()); + // Legacy parity: a TIMESTAMP-with-zone source field carries the WITH_TIMEZONE "Extra" marker via + // Column.setWithTZExtraInfo(), keyed on the SOURCE iceberg type root and INDEPENDENT of the + // enable.mapping.timestamp_tz flag. fe-core's ConnectorColumnConverter re-applies it. + if (isTimestampWithZone(field.type())) { + column = column.withTimeZone(); + } + columns.add(column); } return columns; } + + /** A TIMESTAMP whose values are stored in UTC ({@code shouldAdjustToUTC()}); carries the WITH_TIMEZONE marker. */ + private static boolean isTimestampWithZone(Type type) { + return type.isPrimitiveType() + && type.typeId() == Type.TypeID.TIMESTAMP + && ((Types.TimestampType) type).shouldAdjustToUTC(); + } + + /** + * Reads the real table format version, mirroring legacy {@code IcebergUtils.getFormatVersion}: from a + * {@link BaseTable}'s current metadata when available, else from the {@code format-version} table + * property, defaulting to 2. NOT derived from the partition spec id (the old skeleton stamped + * {@code spec().specId() >= 0 ? 2 : 1}, which is always 2 since every spec — including unpartitioned — + * has specId >= 0). + */ + private static int getFormatVersion(Table table) { + int formatVersion = 2; + if (table instanceof BaseTable) { + formatVersion = ((BaseTable) table).operations().current().formatVersion(); + } else if (table != null && table.properties() != null) { + String version = table.properties().get(TableProperties.FORMAT_VERSION); + if (version != null) { + try { + formatVersion = Integer.parseInt(version); + } catch (NumberFormatException ignored) { + // keep the default + } + } + } + return formatVersion; + } } diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorProperties.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorProperties.java index 8e97df7e342274..f2febaf2e432ba 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorProperties.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorProperties.java @@ -34,7 +34,6 @@ private IcebergConnectorProperties() { public static final String TYPE_REST = "rest"; public static final String TYPE_HMS = "hms"; public static final String TYPE_GLUE = "glue"; - public static final String TYPE_DLF = "dlf"; public static final String TYPE_JDBC = "jdbc"; public static final String TYPE_HADOOP = "hadoop"; public static final String TYPE_S3_TABLES = "s3tables"; @@ -43,11 +42,34 @@ private IcebergConnectorProperties() { public static final String WAREHOUSE = "warehouse"; // -- Type mapping options -- - public static final String ENABLE_MAPPING_VARBINARY = "enable_mapping_varbinary"; - public static final String ENABLE_MAPPING_TIMESTAMP_TZ = "enable_mapping_timestamp_tz"; + // Dotted keys matching CatalogProperty.ENABLE_MAPPING_* — the exact spelling that real catalog + // property maps carry. The underscore spelling never matches a live catalog map and reads + // default-false (silent loss of the BINARY->VARBINARY / TIMESTAMP_TZ->TIMESTAMPTZ mapping). + public static final String ENABLE_MAPPING_VARBINARY = "enable.mapping.varbinary"; + public static final String ENABLE_MAPPING_TIMESTAMP_TZ = "enable.mapping.timestamp_tz"; // -- REST catalog options -- public static final String REST_NESTED_NAMESPACE_ENABLED = "iceberg.rest.nested-namespace-enabled"; + public static final String REST_VIEW_ENABLED = "iceberg.rest.view-enabled"; + + // -- REST per-user session (OIDC delegated credential; #63068 re-migration) -- + // iceberg.rest.session = none (default, one shared catalog identity) | user (project the querying user's + // delegated credential onto a per-request Iceberg REST SessionCatalog; requires security.type=oauth2 and + // gates the SUPPORTS_USER_SESSION capability). delegated-token-mode picks how the token is attached: + // access_token = verbatim OAuth2 bearer; token_exchange = typed token key so the REST server exchanges it. + public static final String REST_SESSION = "iceberg.rest.session"; + public static final String SESSION_NONE = "none"; + public static final String SESSION_USER = "user"; + public static final String REST_DELEGATED_TOKEN_MODE = "iceberg.rest.oauth2.delegated-token-mode"; + public static final String DELEGATED_TOKEN_MODE_ACCESS_TOKEN = "access_token"; + public static final String DELEGATED_TOKEN_MODE_TOKEN_EXCHANGE = "token_exchange"; + // Optional per-session OAuth2 AuthSession timeout (maps to CatalogProperties.AUTH_SESSION_TIMEOUT_MS). + public static final String REST_SESSION_TIMEOUT = "iceberg.rest.session-timeout"; + + // -- Namespace hierarchy (REST 3-level ..
    ) -- + // Mirrors legacy IcebergExternalCatalog.EXTERNAL_CATALOG_NAME: when present, this catalog level is + // appended to every namespace and roots database listing. + public static final String EXTERNAL_CATALOG_NAME = "external_catalog.name"; // -- Cache configuration -- public static final String TABLE_CACHE_ENABLE = "meta.cache.iceberg.table.enable"; @@ -56,4 +78,91 @@ private IcebergConnectorProperties() { public static final String MANIFEST_CACHE_ENABLE = "meta.cache.iceberg.manifest.enable"; public static final String MANIFEST_CACHE_TTL = "meta.cache.iceberg.manifest.ttl-second"; public static final String MANIFEST_CACHE_CAPACITY = "meta.cache.iceberg.manifest.capacity"; + + // ===================================================================== + // Per-flavor INPUT alias keys + non-SDK literal EMITTED keys (T05). + // Mirror the legacy fe-core Iceberg*MetaStoreProperties @ConnectorProperty aliases and the + // literal catalog-option keys they emit. Keys that ARE iceberg-SDK constants + // (CatalogProperties / S3FileIOProperties / AwsProperties / AwsClientProperties / OAuth2Properties) + // are referenced via the SDK in IcebergCatalogFactory, not duplicated here. + // ===================================================================== + + // -- REST input aliases (legacy IcebergRestProperties @ConnectorProperty names) -- + public static final String REST_URI = "iceberg.rest.uri"; + public static final String URI = "uri"; + public static final String REST_PREFIX = "iceberg.rest.prefix"; + public static final String REST_SECURITY_TYPE = "iceberg.rest.security.type"; + public static final String REST_OAUTH2_TOKEN = "iceberg.rest.oauth2.token"; + public static final String REST_OAUTH2_CREDENTIAL = "iceberg.rest.oauth2.credential"; + public static final String REST_OAUTH2_SCOPE = "iceberg.rest.oauth2.scope"; + public static final String REST_OAUTH2_SERVER_URI = "iceberg.rest.oauth2.server-uri"; + public static final String REST_OAUTH2_TOKEN_REFRESH_ENABLED = "iceberg.rest.oauth2.token-refresh-enabled"; + public static final String REST_VENDED_CREDENTIALS_ENABLED = "iceberg.rest.vended-credentials-enabled"; + public static final String REST_SIGV4_ENABLED = "iceberg.rest.sigv4-enabled"; + public static final String REST_SIGNING_NAME = "iceberg.rest.signing-name"; + public static final String REST_SIGNING_REGION = "iceberg.rest.signing-region"; + public static final String REST_ACCESS_KEY_ID = "iceberg.rest.access-key-id"; + public static final String REST_SECRET_ACCESS_KEY = "iceberg.rest.secret-access-key"; + public static final String REST_SESSION_TOKEN = "iceberg.rest.session-token"; + public static final String REST_CREDENTIALS_PROVIDER_TYPE = "iceberg.rest.credentials_provider_type"; + public static final String REST_CONNECTION_TIMEOUT_MS = "iceberg.rest.connection-timeout-ms"; + public static final String REST_SOCKET_TIMEOUT_MS = "iceberg.rest.socket-timeout-ms"; + + // -- REST emitted literal keys / values / defaults (non-SDK) -- + public static final String REST_PREFIX_KEY = "prefix"; + public static final String REST_VENDED_CREDENTIALS_HEADER = "header.X-Iceberg-Access-Delegation"; + public static final String REST_VENDED_CREDENTIALS_VALUE = "vended-credentials"; + public static final String REST_CONNECTION_TIMEOUT_MS_KEY = "rest.client.connection-timeout-ms"; + public static final String REST_SOCKET_TIMEOUT_MS_KEY = "rest.client.socket-timeout-ms"; + public static final String REST_SIGNING_NAME_KEY = "rest.signing-name"; + public static final String REST_SIGV4_ENABLED_KEY = "rest.sigv4-enabled"; + public static final String REST_SIGNING_REGION_KEY = "rest.signing-region"; + public static final String DEFAULT_REST_CONNECTION_TIMEOUT_MS = "10000"; + public static final String DEFAULT_REST_SOCKET_TIMEOUT_MS = "60000"; + public static final String SECURITY_TYPE_OAUTH2 = "oauth2"; + public static final String SIGNING_NAME_GLUE = "glue"; + public static final String SIGNING_NAME_S3TABLES = "s3tables"; + public static final String PROVIDER_MODE_DEFAULT = "DEFAULT"; + + // -- GLUE input alias arrays (legacy AWSGlueMetaStoreBaseProperties / IcebergGlueMetaStoreProperties) -- + public static final String[] GLUE_ENDPOINT = {"glue.endpoint", "aws.endpoint", "aws.glue.endpoint"}; + public static final String[] GLUE_REGION = {"glue.region", "aws.region", "aws.glue.region"}; + public static final String[] GLUE_ACCESS_KEY = { + "glue.access_key", "aws.glue.access-key", "client.credentials-provider.glue.access_key"}; + public static final String[] GLUE_SECRET_KEY = { + "glue.secret_key", "aws.glue.secret-key", "client.credentials-provider.glue.secret_key"}; + public static final String[] GLUE_SESSION_TOKEN = {"aws.glue.session-token"}; + public static final String[] GLUE_IAM_ROLE = {"glue.role_arn"}; + public static final String[] GLUE_EXTERNAL_ID = {"glue.external_id"}; + + // -- GLUE emitted literal keys / values / defaults (non-SDK) -- + public static final String GLUE_CREDENTIALS_PROVIDER_KEY = "client.credentials-provider"; + public static final String GLUE_CREDENTIALS_PROVIDER_2X = + "org.apache.doris.connector.iceberg.glue.ConfigurationAWSCredentialsProvider2x"; + public static final String GLUE_CREDENTIALS_PROVIDER_ACCESS_KEY = "client.credentials-provider.glue.access_key"; + public static final String GLUE_CREDENTIALS_PROVIDER_SECRET_KEY = "client.credentials-provider.glue.secret_key"; + public static final String GLUE_CREDENTIALS_PROVIDER_SESSION_TOKEN = + "client.credentials-provider.glue.session_token"; + public static final String AWS_REGION_KEY = "aws.region"; + public static final String GLUE_CHECKED_WAREHOUSE = "s3://doris"; + public static final String GLUE_DEFAULT_REGION = "us-east-1"; + + // -- JDBC input aliases (legacy IcebergJdbcMetaStoreProperties @ConnectorProperty names) -- + public static final String[] JDBC_URI = {"uri", "iceberg.jdbc.uri"}; + public static final String JDBC_USER = "iceberg.jdbc.user"; + public static final String JDBC_PASSWORD = "iceberg.jdbc.password"; + public static final String JDBC_INIT_CATALOG_TABLES = "iceberg.jdbc.init-catalog-tables"; + public static final String JDBC_SCHEMA_VERSION = "iceberg.jdbc.schema-version"; + public static final String JDBC_STRICT_MODE = "iceberg.jdbc.strict-mode"; + public static final String JDBC_CATALOG_NAME = "iceberg.jdbc.catalog_name"; + public static final String JDBC_DRIVER_URL = "iceberg.jdbc.driver_url"; + public static final String JDBC_DRIVER_CLASS = "iceberg.jdbc.driver_class"; + + // -- JDBC emitted literal keys (non-SDK) -- + public static final String JDBC_PREFIX = "jdbc."; + public static final String JDBC_USER_KEY = "jdbc.user"; + public static final String JDBC_PASSWORD_KEY = "jdbc.password"; + public static final String JDBC_INIT_CATALOG_TABLES_KEY = "jdbc.init-catalog-tables"; + public static final String JDBC_SCHEMA_VERSION_KEY = "jdbc.schema-version"; + public static final String JDBC_STRICT_MODE_KEY = "jdbc.strict-mode"; } 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 1775c6d6cd927b..6deb9b492fd736 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 @@ -18,9 +18,12 @@ package org.apache.doris.connector.iceberg; import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.cache.CacheSpec; +import org.apache.doris.connector.metastore.spi.MetaStoreProviders; import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorProvider; +import java.util.Collections; import java.util.Map; /** @@ -42,4 +45,49 @@ public String getType() { public Connector create(Map properties, ConnectorContext context) { return new IcebergConnector(properties, context); } + + /** + * 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 + * {@link MetaStoreProviders#bindForType} selects the iceberg backend, whose {@code validate()} enforces + * the per-flavor fail-fast rules — REST (security/creds enums, OAuth2, signing, AK/SK), Glue + * (AK/SK-together, endpoint https, at-least-one-credential), JDBC (uri/catalog_name/warehouse), and the + * shared HMS/DLF connection checks; hadoop/s3tables are no-op (their storage is validated upstream at + * fe-filesystem bind). Storage is not needed for validation, so an empty storage map is passed. A blank + * or unknown {@code iceberg.catalog.type} makes {@code bindForType} throw (no provider supports it). + * Throws {@link IllegalArgumentException}, which {@code PluginDrivenExternalCatalog.checkProperties} + * wraps into a DdlException. + * + *

    The meta-cache knobs are validated first (restoring the legacy + * {@code IcebergExternalCatalog.checkProperties} fail-fast that was dropped at the SPI cutover), so a + * bad {@code meta.cache.iceberg.*} value is rejected at CREATE/ALTER instead of being silently + * coerced to a cache-disabling default. + */ + @Override + public void validateProperties(Map properties) { + checkMetaCacheProperties(properties); + String flavor = IcebergCatalogFactory.resolveFlavor(properties); + MetaStoreProviders.bindForType(flavor, properties, Collections.emptyMap()).validate(); + } + + /** + * Byte-for-byte parity with the legacy {@code IcebergExternalCatalog.checkProperties}: table/manifest + * {@code enable} must be boolean, {@code ttl-second} must be a long ≥ -1 (the "no expiration" + * sentinel), {@code capacity} must be a long ≥ 0. Absent keys are skipped. + */ + private static void checkMetaCacheProperties(Map properties) { + CacheSpec.checkBooleanProperty(properties.get(IcebergConnectorProperties.TABLE_CACHE_ENABLE), + IcebergConnectorProperties.TABLE_CACHE_ENABLE); + CacheSpec.checkLongProperty(properties.get(IcebergConnectorProperties.TABLE_CACHE_TTL), + -1L, IcebergConnectorProperties.TABLE_CACHE_TTL); + CacheSpec.checkLongProperty(properties.get(IcebergConnectorProperties.TABLE_CACHE_CAPACITY), + 0L, IcebergConnectorProperties.TABLE_CACHE_CAPACITY); + + CacheSpec.checkBooleanProperty(properties.get(IcebergConnectorProperties.MANIFEST_CACHE_ENABLE), + IcebergConnectorProperties.MANIFEST_CACHE_ENABLE); + CacheSpec.checkLongProperty(properties.get(IcebergConnectorProperties.MANIFEST_CACHE_TTL), + -1L, IcebergConnectorProperties.MANIFEST_CACHE_TTL); + CacheSpec.checkLongProperty(properties.get(IcebergConnectorProperties.MANIFEST_CACHE_CAPACITY), + 0L, IcebergConnectorProperties.MANIFEST_CACHE_CAPACITY); + } } 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 new file mode 100644 index 00000000000000..b850f84c63fdc4 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java @@ -0,0 +1,1186 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.kerberos.HadoopAuthenticator; +import org.apache.doris.thrift.TFileContent; +import org.apache.doris.thrift.TIcebergCommitData; + +import com.google.common.base.Preconditions; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileContent; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ManifestFiles; +import org.apache.iceberg.ManifestReader; +import org.apache.iceberg.OverwriteFiles; +import org.apache.iceberg.PartitionField; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.ReplacePartitions; +import org.apache.iceberg.RewriteFiles; +import org.apache.iceberg.RowDelta; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TableScan; +import org.apache.iceberg.Transaction; +import org.apache.iceberg.Transactions; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.WriteResult; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ContentFileUtil; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.thrift.TDeserializer; +import org.apache.thrift.TException; +import org.apache.thrift.protocol.TBinaryProtocol; + +import java.io.IOException; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Iceberg connector transaction (ports the legacy + * {@code org.apache.doris.datasource.iceberg.IcebergTransaction} write lifecycle to the connector SPI). + * + *

    Holds a single SDK {@link Transaction} / {@link Table} for one SQL statement and the accumulated + * commit fragments ({@link TIcebergCommitData}, fed back from BE via {@link #addCommitData}). The SDK + * transaction is opened — through the {@link IcebergCatalogOps} seam wrapped in the auth context — by + * {@link #beginWrite}; {@link #commit()} builds the SDK operation for the {@link WriteOperation} from the + * accumulated fragments, stages it onto the transaction, then flushes with {@code commitTransaction()}.

    + * + *

    Op selection (P6.3-T04). Unlike the legacy class (which split {@code finishInsert} from + * {@code commit}), the unified {@link ConnectorTransaction} SPI exposes only {@link #commit()} — so the + * manifest build happens there (mirroring maxcompute): INSERT → AppendFiles; OVERWRITE dynamic → + * ReplacePartitions; OVERWRITE empty/unpartitioned → OverwriteFiles (clears the table); OVERWRITE static + * → OverwriteFiles.overwriteByRowFilter; DELETE → RowDelta (deletes); UPDATE/MERGE → RowDelta + * (rows + deletes). The {@code IcebergWriterHelper} equivalents (DataFile / DeleteFile / Metrics / + * PartitionData) live in {@link IcebergWriterHelper}.

    + * + *

    Conflict detection (P6.3-T05). The DELETE/MERGE {@link RowDelta} commit is guarded by the + * optimistic conflict-detection validation suite (validateFromSnapshot from the begin-time + * {@link #baseSnapshotId} / conflictDetectionFilter / serializable validateNoConflictingDataFiles / + * validateDeletedFiles / validateNoConflictingDeleteFiles / validateDataFilesExist) and, on a V3 table, the + * deletion-vector "rewrite previous delete files" {@code removeDeletes}. The conflict-detection filter is the + * 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.

    + * + *

    Gate-closed / dormant. Iceberg is not in {@code SPI_READY_TYPES} until the P6.6 cutover, so + * nothing routes plugin-driven iceberg writes through this class yet ({@link #beginWrite} is wired by T06's + * {@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 + * maxcompute.

    + */ +public class IcebergConnectorTransaction implements ConnectorTransaction { + + private static final Logger LOG = LogManager.getLogger(IcebergConnectorTransaction.class); + private static final String DELETE_ISOLATION_LEVEL = "delete_isolation_level"; + private static final String DELETE_ISOLATION_LEVEL_DEFAULT = "serializable"; + + private final long transactionId; + private final IcebergCatalogOps catalogOps; + private final ConnectorContext context; + private final List commitDataList = new ArrayList<>(); + + // The single SDK transaction / table, opened lazily by beginWrite (the write plan binds the target + // table only when the sink is planned). volatile: addCommitData / commit may run on different threads. + private volatile Transaction transaction; + private volatile Table table; + + // Begin-once guard. A normal single-statement write calls beginWrite exactly once. A distributed + // rewrite_data_files runs N per-group INSERT-SELECTs that SHARE this one transaction, and each group's + // plan-time sink planWrite calls beginWrite again — concurrently (the groups run on the transient task + // pool). Without this guard the shared SDK transaction (loaded.newTransaction()) would be rebuilt and the + // OCC anchor (startingSnapshotId) re-pinned on every call, racing across threads. The first call loads the + // table + pins the snapshot; the rest reuse it. Mirrors Trino's "begin the table-execute once at the + // coordinator, the distributed tasks only write" model. No effect on single-statement writes. + private final Object beginLock = new Object(); + private volatile boolean writeStarted = false; + + // Op context captured at begin time, consumed by commit() (the volatile transaction write at the end of + // beginWrite publishes these plain writes to the commit thread). + private WriteOperation writeOperation = WriteOperation.INSERT; + private boolean staticPartitionOverwrite; + private Map staticPartitionValues = Collections.emptyMap(); + private String branchName; + // The current snapshot pinned at begin time for a DELETE/MERGE (null for INSERT/OVERWRITE). Consumed by + // the commit validation suite (validateFromSnapshot). + private Long baseSnapshotId; + // Session zone for human-readable TIMESTAMP partition value parsing (DV-T04-f). + private ZoneId zone = ZoneOffset.UTC; + + // ── REWRITE (rewrite_data_files, P6.4-T06; dormant until the P6.6 cutover) ── + // The original data files to remove, fed by updateRewriteFiles (the rewrite execution half hands it the + // planner's RewriteDataGroup.getDataFiles() — FileScanTask.file()). The new compacted files arrive on the + // shared commitDataList channel (like INSERT) and are materialized into filesToAdd at commit time. + private final List filesToDelete = new ArrayList<>(); + private final List filesToAdd = new ArrayList<>(); + // OCC anchor: the current snapshot captured at begin time, passed verbatim to + // RewriteFiles.validateFromSnapshot (-1 when the table has no snapshot). REWRITE uses this, NOT + // baseSnapshotId (which drives the RowDelta path). Ported from legacy IcebergTransaction.startingSnapshotId. + private long startingSnapshotId = -1L; + + // O5-2: the engine-extracted target-only write constraint (neutral form), stashed by applyWriteConstraint + // at plan time and converted to an iceberg Expression lazily at commit (the table schema is only known + // after beginWrite has loaded the table). volatile: applyWriteConstraint and commit may run on different + // threads. + private volatile ConnectorPredicate writeConstraint; + + public IcebergConnectorTransaction(long transactionId, IcebergCatalogOps catalogOps, + ConnectorContext context) { + this.transactionId = transactionId; + this.catalogOps = catalogOps; + this.context = context; + } + + /** + * Opens the single SDK transaction for {@code db.tableName} and applies the op-specific begin guards, + * loading the table through the {@link IcebergCatalogOps} seam wrapped in the FE-injected auth context + * (Kerberos UGI), mirroring {@code IcebergConnectorMetadata.loadTable} and legacy + * {@code IcebergTransaction.begin{Insert,Delete,Merge}}. + * + *

    Guards: an INSERT/OVERWRITE that targets a branch validates it exists and is a branch (not a tag); a + * DELETE/MERGE requires format-version ≥ 2 (position deletes) and captures {@link #baseSnapshotId}. + * Both {@code loadTable} and {@code newTransaction()} run inside {@code executeAuthenticated}: + * {@code BaseTable.newTransaction()} issues an unconditional {@code TableOperations.refresh()} (a remote + * metastore call), so it must carry the same auth context as the load (UT-invisible offline; verified at + * P6.6 docker on a Kerberized HMS).

    + */ + public void beginWrite(ConnectorSession session, String db, String tableName, IcebergWriteContext ctx) { + synchronized (beginLock) { + if (writeStarted) { + // Already begun. A shared distributed-rewrite transaction is opened once by the first group's + // planWrite; subsequent concurrent group writes reuse the same loaded table + pinned OCC + // snapshot. Single-statement writes call beginWrite exactly once, so this is never reached + // for them (byte-identical to the pre-guard path). + return; + } + this.writeOperation = ctx.getWriteOperation(); + this.staticPartitionOverwrite = ctx.isStaticPartitionOverwrite(); + this.staticPartitionValues = ctx.getStaticPartitionValues(); + this.zone = IcebergTimeUtils.resolveSessionZone(session); + try { + context.executeAuthenticated(() -> { + // PERF-07: resolve the table through the per-statement scope so a row-level DML reuses the SAME + // one object the scan already loaded (a write-only INSERT loads once here). openTransaction still + // issues newTransaction()'s refresh, giving the commit a fresh OCC base off that shared object. + Table loaded = IcebergStatementScope.sharedTable(session, db, tableName, + () -> catalogOps.loadTable(db, tableName)); + this.table = loaded; + applyBeginGuards(ctx, tableName); + this.transaction = openTransaction(loaded); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to begin write for iceberg table " + tableName + ": " + e.getMessage(), e); + } + // Only flip after a fully successful begin, so a failed load can be retried (writeStarted stays + // false) rather than wedging the transaction in a half-begun state. + writeStarted = true; + } + } + + /** + * Opens the SDK transaction for {@code loaded}. On a Kerberos catalog the table's {@link FileIO} is wrapped + * in a plugin-side Kerberos {@code doAs} ({@link IcebergAuthenticatedFileIO}); otherwise this is byte-for-byte + * {@code loaded.newTransaction()}. + * + *

    Why (worker-pool split-brain). {@link #commit()} runs {@code transaction.commitTransaction()} under + * the caller-thread {@code doAs} ({@code context.executeAuthenticated}), but iceberg fans the parallel manifest + * WRITE ({@code SnapshotProducer.writeManifests}, every INSERT/UPDATE/DELETE/MERGE/REWRITE) onto its shared + * {@code ThreadPools.getWorkerPool()}. Those worker threads run OUTSIDE the caller {@code doAs} and — because + * Doris never sets a process-wide login user (per-instance {@code getUGIFromSubject} only) — hit secured HDFS + * with no Kerberos credentials ({@code Client cannot authenticate via:[TOKEN, KERBEROS]}). Pinning the pool's + * TCCL ({@code pinIcebergWorkerPoolToPluginClassLoader}) fixes the classloader half but not the UGI half. + * + *

    How. {@code SnapshotProducer} takes its manifest {@code OutputFile} from {@code ops.io()}. Building + * the transaction from ops whose {@code io()} is the auth-wrapping FileIO makes every manifest write capture the + * plugin Kerberos {@code FileSystem} at factory time, so the deferred stream I/O on the worker thread inherits + * it. The wrap mirrors {@code BaseTable.newTransaction()} exactly (same name + {@code MetricsReporter}); only + * {@code io()} differs. Non-Kerberos catalogs ({@code getPluginAuthenticator() == null}) keep the legacy path. + */ + private Transaction openTransaction(Table loaded) { + HadoopAuthenticator auth = context instanceof TcclPinningConnectorContext + ? ((TcclPinningConnectorContext) context).getPluginAuthenticator() + : null; + if (auth == null || !(loaded instanceof HasTableOperations)) { + return loaded.newTransaction(); + } + TableOperations realOps = ((HasTableOperations) loaded).operations(); + FileIO authIo = new IcebergAuthenticatedFileIO(realOps.io(), auth); + TableOperations authOps = new IcebergAuthenticatedTableOperations(realOps, authIo); + return loaded instanceof BaseTable + ? Transactions.newTransaction(loaded.name(), authOps, ((BaseTable) loaded).reporter()) + : Transactions.newTransaction(loaded.name(), authOps); + } + + private void applyBeginGuards(IcebergWriteContext ctx, String tableName) { + WriteOperation op = ctx.getWriteOperation(); + if (op == WriteOperation.REWRITE) { + // rewrite_data_files works directly on the main table (legacy IcebergTransaction.beginRewrite:175): + // never targets a branch, never pins baseSnapshotId; instead it captures the current snapshot as the + // OCC anchor for the commit-time RewriteFiles.validateFromSnapshot (-1 when the table has none). + this.branchName = null; + this.baseSnapshotId = null; + Long current = getSnapshotIdIfPresent(table); + this.startingSnapshotId = current != null ? current : -1L; + return; + } + if (op == WriteOperation.DELETE || op == WriteOperation.UPDATE || op == WriteOperation.MERGE) { + // RowDelta path: the merge/delete write never targets a branch (legacy beginMerge forces null). + this.branchName = null; + // [SHOULD-2] / Fix B: anchor baseSnapshotId at the statement's READ snapshot (the MVCC pin the + // scan used, S_read), threaded onto the write handle and carried on the ctx. The commit-time + // removeDeletes (option D) re-derives from baseSnapshotId, and BE unions the scan-time (S_read) + // old deletes into the new DV — anchoring both at S_read keeps supply and remove on one snapshot + // (no resurrection under a concurrent commit in the read->begin-write window). A -1 readSnapshotId + // (no pin: a caller without the threaded handle) falls back to the begin-time current snapshot. + long pinnedReadSnapshot = ctx.getReadSnapshotId(); + // Keep both ternary arms boxed (Long): getSnapshotIdIfPresent returns null for an empty table + // (no snapshot), and a primitive arm would force-unbox that null into an NPE. + this.baseSnapshotId = pinnedReadSnapshot >= 0 + ? Long.valueOf(pinnedReadSnapshot) : getSnapshotIdIfPresent(table); + if (table instanceof HasTableOperations) { + int formatVersion = ((HasTableOperations) table).operations().current().formatVersion(); + if (formatVersion < 2) { + throw new IllegalArgumentException("Iceberg table " + tableName + + " must have format version 2 or higher for position deletes"); + } + } + } else { + // INSERT / OVERWRITE (append path). + this.baseSnapshotId = null; + if (ctx.getBranchName().isPresent()) { + this.branchName = ctx.getBranchName().get(); + SnapshotRef branchRef = table.refs().get(branchName); + if (branchRef == null) { + throw new IllegalArgumentException(branchName + " is not founded in " + tableName); + } else if (!branchRef.isBranch()) { + throw new IllegalArgumentException(branchName + + " is a tag, not a branch. Tags cannot be targets for producing snapshots"); + } + } else { + this.branchName = null; + } + } + } + + @Override + public long getTransactionId() { + return transactionId; + } + + @Override + public void addCommitData(byte[] commitFragment) { + TIcebergCommitData data = new TIcebergCommitData(); + try { + new TDeserializer(new TBinaryProtocol.Factory()).deserialize(data, commitFragment); + } catch (TException e) { + throw new DorisConnectorException("failed to deserialize Iceberg commit data", e); + } + synchronized (this) { + commitDataList.add(data); + } + } + + /** + * O5-2: stashes the engine-extracted target-only write constraint (neutral form). It is converted to an + * iceberg {@link Expression} lazily at commit time ({@link #buildWriteConstraintExpression}) — the table + * schema needed by {@link IcebergPredicateConverter} is only available after {@link #beginWrite}, and the + * engine calls this at plan time, before begin. Mirrors legacy + * {@code IcebergTransaction.setConflictDetectionFilter}, except the connector receives the neutral + * predicate (not an already-converted iceberg expression). + */ + @Override + public void applyWriteConstraint(ConnectorPredicate targetOnlyFilter) { + this.writeConstraint = targetOnlyFilter; + } + + /** + * REWRITE: registers original data files to remove (the rewrite execution half feeds it one bin-packed + * group at a time — {@code RewriteDataGroup.getDataFiles()}). Accumulates across calls. Ported from legacy + * {@code IcebergTransaction.updateRewriteFiles}; package-visible because the rewrite coordinator lives in + * the connector (fe-core cannot traffic in iceberg {@code DataFile}). Dormant until the P6.6 cutover. + */ + void updateRewriteFiles(List originalFiles) { + synchronized (filesToDelete) { + filesToDelete.addAll(originalFiles); + } + } + + /** + * Neutral SPI front for {@link #updateRewriteFiles}: the engine rewrite driver registers the source data + * files to replace by their RAW paths (it holds only neutral {@code String} paths from its bin-packed + * groups — fe-core cannot hand us iceberg {@code DataFile} objects across the connector wall). We re-derive + * the matching {@code DataFile}s from the table at the pinned OCC snapshot ({@link #startingSnapshotId}, + * captured at {@link #beginWrite}), mirroring {@link #commitReplaceTxn}'s {@code planFiles()} re-scan and the + * commit-time re-derive used on the delete seam. Fails loud if any registered path is absent at the pinned + * snapshot (the plan is stale / the table moved since planning), so a rewrite never silently drops a file. + */ + @Override + public void registerRewriteSourceFiles(Set dataFilePaths) { + if (dataFilePaths == null || dataFilePaths.isEmpty()) { + return; + } + if (table == null) { + // The re-derive below scans the table at the pinned OCC snapshot, both of which beginWrite loads. + // The distributed rewrite driver must register the source files only after at least one group's + // write has begun the (shared) transaction. Fail loud rather than NPE on table.newScan(). + throw new DorisConnectorException("registerRewriteSourceFiles called before the rewrite " + + "transaction began (no group write has loaded the table yet)"); + } + Set wanted = new HashSet<>(dataFilePaths); + Map matched = new HashMap<>(); + try { + // The pinned-snapshot re-scan reads the manifest list + manifests (remote IO), so it must run + // under the FE-injected auth context (Kerberos UGI), mirroring commit()'s manifest scan. + context.executeAuthenticated(() -> { + TableScan scan = table.newScan(); + if (startingSnapshotId >= 0) { + scan = scan.useSnapshot(startingSnapshotId); + } + try (CloseableIterable tasks = scan.planFiles()) { + for (FileScanTask task : tasks) { + DataFile file = task.file(); + String path = file.path().toString(); + if (wanted.contains(path)) { + // planFiles() may split one data file across several tasks; dedupe by path. + matched.putIfAbsent(path, file); + } + } + } + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException("Failed to resolve rewrite source files: " + e.getMessage(), e); + } + if (matched.size() != wanted.size()) { + throw new DorisConnectorException("Rewrite source file resolution mismatch: registered " + + wanted.size() + " paths but matched " + matched.size() + " data files at snapshot " + + startingSnapshotId + " (the table changed since planning?)"); + } + updateRewriteFiles(new ArrayList<>(matched.values())); + } + + /** + * Affected-row count for the statement, ported verbatim from legacy + * {@code IcebergTransaction.getUpdateCnt}: prefer {@code affected_rows} over {@code row_count}, split + * data-file rows from delete-file rows (position deletes / deletion vectors), and return the data + * rows when present, else the delete rows. For UPDATE/MERGE the data rows already equal the affected + * rows; the internal position deletes must not be double-counted. + */ + @Override + public long getUpdateCnt() { + long dataRows = 0; + long deleteRows = 0; + for (TIcebergCommitData commitData : commitDataList) { + long affectedRows = commitData.isSetAffectedRows() + ? commitData.getAffectedRows() + : commitData.getRowCount(); + if (commitData.isSetFileContent() + && (commitData.getFileContent() == TFileContent.POSITION_DELETES + || commitData.getFileContent() == TFileContent.DELETION_VECTOR)) { + deleteRows += affectedRows; + } else { + dataRows += affectedRows; + } + } + return dataRows > 0 ? dataRows : deleteRows; + } + + @Override + public String profileLabel() { + return "ICEBERG"; + } + + @Override + public void commit() { + if (transaction == null) { + throw new DorisConnectorException("no active iceberg transaction to commit"); + } + try { + // Build the SDK operation (manifest scan hits the remote metastore) and flush it, both under the + // FE-injected auth context (Kerberos UGI), mirroring legacy finish*/commitTransaction. + context.executeAuthenticated(() -> { + buildPendingOperation(); + transaction.commitTransaction(); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to commit iceberg transaction: " + e.getMessage(), e); + } + } + + /** Dispatches the accumulated commit fragments onto the SDK operation for {@link #writeOperation}. */ + private void buildPendingOperation() { + switch (writeOperation) { + case INSERT: + commitAppendTxn(buildDataWriteResults()); + break; + case OVERWRITE: + if (staticPartitionOverwrite) { + commitStaticPartitionOverwrite(buildDataWriteResults()); + } else { + commitReplaceTxn(buildDataWriteResults()); + } + break; + case DELETE: + updateManifestAfterDelete(); + break; + case UPDATE: + case MERGE: + updateManifestAfterMerge(); + break; + case REWRITE: + commitRewriteTxn(); + break; + default: + throw new DorisConnectorException("Unsupported iceberg write operation: " + writeOperation); + } + } + + private List buildDataWriteResults() { + if (commitDataList.isEmpty()) { + return Collections.emptyList(); + } + WriteResult writeResult = IcebergWriterHelper.convertToWriterResult(transaction.table(), commitDataList, zone); + List results = new ArrayList<>(1); + results.add(writeResult); + return results; + } + + /** + * REWRITE ({@code rewrite_data_files}): atomically replace the original data files ({@link #filesToDelete}, + * fed by {@link #updateRewriteFiles}) with the newly written compacted files via the SDK + * {@link RewriteFiles} op, guarded by {@code validateFromSnapshot(startingSnapshotId)} (OCC). The new files + * arrive on the shared {@link #commitDataList} channel and are materialized here. Ported from legacy + * {@code IcebergTransaction.finishRewrite} → {@code updateManifestAfterRewrite}, folded into + * {@code commit()} because the unified {@link ConnectorTransaction} exposes only {@code commit()} (mirroring + * how INSERT folded {@code finishInsert} into {@code commitAppendTxn}). When there is nothing to delete and + * nothing to add, the op is skipped — the empty SDK transaction still flushes (legacy :248-251). + * + *

    Unlike legacy {@code updateManifestAfterRewrite:258}, the {@code scanManifestsWith(threadPool)} + * manifest-scan parallelism is dropped, matching the connector's append path ({@link #commitAppendTxn} + * also drops it) — a perf-only divergence registered in the deviations log (DV-T06r-scanpool).

    + */ + private void commitRewriteTxn() { + convertCommitDataToFilesToAdd(); + if (filesToDelete.isEmpty() && filesToAdd.isEmpty()) { + return; + } + RewriteFiles rewriteFiles = transaction.newRewrite(); + rewriteFiles = rewriteFiles.validateFromSnapshot(startingSnapshotId); + for (DataFile dataFile : filesToDelete) { + rewriteFiles.deleteFile(dataFile); + } + for (DataFile dataFile : filesToAdd) { + rewriteFiles.addFile(dataFile); + } + rewriteFiles.commit(); + } + + /** + * Materializes the BE-reported commit fragments into {@link #filesToAdd} through the same {@link WriteResult} + * helper INSERT uses (legacy {@code IcebergTransaction.convertCommitDataListToDataFilesToAdd}). No-op when no + * fragments were reported (an empty rewrite, or a group whose write produced nothing). + */ + private void convertCommitDataToFilesToAdd() { + if (commitDataList.isEmpty()) { + return; + } + WriteResult writeResult = + IcebergWriterHelper.convertToWriterResult(transaction.table(), commitDataList, zone); + synchronized (filesToAdd) { + filesToAdd.addAll(Arrays.asList(writeResult.dataFiles())); + } + } + + private void commitAppendTxn(List pendingResults) { + AppendFiles appendFiles = transaction.newAppend(); + if (branchName != null) { + appendFiles = appendFiles.toBranch(branchName); + } + for (WriteResult result : pendingResults) { + Preconditions.checkState(result.referencedDataFiles().length == 0, + "Should have no referenced data files for append."); + Arrays.stream(result.dataFiles()).forEach(appendFiles::appendFile); + } + appendFiles.commit(); + } + + private void commitReplaceTxn(List pendingResults) { + if (pendingResults.isEmpty()) { + // such as : insert overwrite table `dst_tb` select * from `empty_tb` + // 1. if dst_tb is a partitioned table, it returns directly. + // 2. if dst_tb is an unpartitioned table, the `dst_tb` table is emptied. + if (!transaction.table().spec().isPartitioned()) { + OverwriteFiles overwriteFiles = transaction.newOverwrite(); + if (branchName != null) { + overwriteFiles = overwriteFiles.toBranch(branchName); + } + try (CloseableIterable fileScanTasks = table.newScan().planFiles()) { + OverwriteFiles finalOverwriteFiles = overwriteFiles; + fileScanTasks.forEach(f -> finalOverwriteFiles.deleteFile(f.file())); + } catch (IOException e) { + throw new DorisConnectorException("Failed to scan files for overwrite: " + e.getMessage(), e); + } + overwriteFiles.commit(); + } + return; + } + + ReplacePartitions appendPartitionOp = transaction.newReplacePartitions(); + if (branchName != null) { + appendPartitionOp = appendPartitionOp.toBranch(branchName); + } + for (WriteResult result : pendingResults) { + Preconditions.checkState(result.referencedDataFiles().length == 0, + "Should have no referenced data files."); + Arrays.stream(result.dataFiles()).forEach(appendPartitionOp::addFile); + } + appendPartitionOp.commit(); + } + + /** + * INSERT OVERWRITE ... PARTITION(col=val, ...): overwrite only the matching partitions via + * {@code OverwriteFiles.overwriteByRowFilter}. + */ + private void commitStaticPartitionOverwrite(List pendingResults) { + Table icebergTable = transaction.table(); + PartitionSpec spec = icebergTable.spec(); + Schema schema = icebergTable.schema(); + + Expression partitionFilter = buildPartitionFilter(staticPartitionValues, spec, schema); + + OverwriteFiles overwriteFiles = transaction.newOverwrite(); + if (branchName != null) { + overwriteFiles = overwriteFiles.toBranch(branchName); + } + overwriteFiles = overwriteFiles.overwriteByRowFilter(partitionFilter); + + for (WriteResult result : pendingResults) { + Preconditions.checkState(result.referencedDataFiles().length == 0, + "Should have no referenced data files for static partition overwrite."); + Arrays.stream(result.dataFiles()).forEach(overwriteFiles::addFile); + } + overwriteFiles.commit(); + } + + /** + * Build an iceberg {@link Expression} from the static partition key-value pairs. Identity partitions + * require the SOURCE column name (not the partition field name) in the expression. + */ + private Expression buildPartitionFilter(Map staticPartitions, PartitionSpec spec, + Schema schema) { + if (staticPartitions == null || staticPartitions.isEmpty()) { + return Expressions.alwaysTrue(); + } + + List predicates = new ArrayList<>(); + for (PartitionField field : spec.fields()) { + String partitionColName = field.name(); + if (staticPartitions.containsKey(partitionColName)) { + String partitionValueStr = staticPartitions.get(partitionColName); + Types.NestedField sourceField = schema.findField(field.sourceId()); + if (sourceField == null) { + throw new DorisConnectorException(String.format( + "Source field not found for partition field: %s", partitionColName)); + } + Object partitionValue = IcebergPartitionUtils.parsePartitionValueFromString( + partitionValueStr, sourceField.type(), zone); + String sourceColName = sourceField.name(); + Expression eqExpr = partitionValue == null + ? Expressions.isNull(sourceColName) + : Expressions.equal(sourceColName, partitionValue); + predicates.add(eqExpr); + } + } + + if (predicates.isEmpty()) { + return Expressions.alwaysTrue(); + } + Expression result = predicates.get(0); + for (int i = 1; i < predicates.size(); i++) { + result = Expressions.and(result, predicates.get(i)); + } + return result; + } + + /** + * DELETE: commit position-delete files via {@link RowDelta}, guarded by the optimistic conflict-detection + * validation suite ({@link #applyRowDeltaValidations}) and — on a V3 table — the deletion-vector + * "rewrite previous delete files" {@code removeDeletes}. Ported from legacy + * {@code IcebergTransaction.updateManifestAfterDelete}. + */ + private void updateManifestAfterDelete() { + FileFormat fileFormat = IcebergWriterHelper.getFileFormat(transaction.table()); + if (commitDataList.isEmpty()) { + return; + } + List deleteFiles = convertCommitDataToDeleteFiles(fileFormat, commitDataList); + List rewrittenDeleteFiles = shouldRewritePreviousDeleteFiles() + ? collectRewrittenDeleteFiles(commitDataList) + : Collections.emptyList(); + if (deleteFiles.isEmpty()) { + return; + } + RowDelta rowDelta = transaction.newRowDelta(); + applyRowDeltaValidations(rowDelta, transaction.table(), commitDataList, + collectReferencedDataFiles(commitDataList)); + for (DeleteFile deleteFile : deleteFiles) { + rowDelta.addDeletes(deleteFile); + } + for (DeleteFile deleteFile : rewrittenDeleteFiles) { + rowDelta.removeDeletes(deleteFile); + } + rowDelta.commit(); + } + + /** + * UPDATE/MERGE: commit data + position-delete files via a single {@link RowDelta}, guarded by the + * conflict-detection validation suite and V3 deletion-vector {@code removeDeletes}. Ported from legacy + * {@code IcebergTransaction.updateManifestAfterMerge}. + */ + private void updateManifestAfterMerge() { + if (commitDataList.isEmpty()) { + return; + } + FileFormat fileFormat = IcebergWriterHelper.getFileFormat(transaction.table()); + + List dataCommitData = new ArrayList<>(); + List deleteCommitData = new ArrayList<>(); + for (TIcebergCommitData commitData : commitDataList) { + if (commitData.isSetFileContent() + && (commitData.getFileContent() == TFileContent.POSITION_DELETES + || commitData.getFileContent() == TFileContent.DELETION_VECTOR)) { + deleteCommitData.add(commitData); + } else { + dataCommitData.add(commitData); + } + } + + List dataFiles = new ArrayList<>(); + if (!dataCommitData.isEmpty()) { + WriteResult writeResult = IcebergWriterHelper.convertToWriterResult( + transaction.table(), dataCommitData, zone); + dataFiles.addAll(Arrays.asList(writeResult.dataFiles())); + } + + List deleteFiles = convertCommitDataToDeleteFiles(fileFormat, deleteCommitData); + List rewrittenDeleteFiles = shouldRewritePreviousDeleteFiles() + ? collectRewrittenDeleteFiles(deleteCommitData) + : Collections.emptyList(); + if (dataFiles.isEmpty() && deleteFiles.isEmpty()) { + return; + } + + RowDelta rowDelta = transaction.newRowDelta(); + // Conflict filter spans the whole statement (commitDataList); referenced data files come from the + // delete fragments only (legacy IcebergTransaction.updateManifestAfterMerge:490-491). + applyRowDeltaValidations(rowDelta, transaction.table(), commitDataList, + collectReferencedDataFiles(deleteCommitData)); + for (DataFile dataFile : dataFiles) { + rowDelta.addRows(dataFile); + } + for (DeleteFile deleteFile : deleteFiles) { + rowDelta.addDeletes(deleteFile); + } + for (DeleteFile deleteFile : rewrittenDeleteFiles) { + rowDelta.removeDeletes(deleteFile); + } + rowDelta.commit(); + } + + /** + * Group the delete commit fragments by their partition spec id (delete files may belong to an older spec + * after partition evolution) and convert each group with its own {@link PartitionSpec}. A fragment with no + * spec id is only valid for an unpartitioned table. + */ + private List convertCommitDataToDeleteFiles(FileFormat fileFormat, + List commitData) { + if (commitData.isEmpty()) { + return Collections.emptyList(); + } + + PartitionSpec currentSpec = transaction.table().spec(); + Map specsById = transaction.table().specs(); + Map> commitDataBySpecId = new HashMap<>(); + List missingSpecId = new ArrayList<>(); + + for (TIcebergCommitData data : commitData) { + if (data.isSetPartitionSpecId()) { + commitDataBySpecId.computeIfAbsent(data.getPartitionSpecId(), k -> new ArrayList<>()).add(data); + } else { + missingSpecId.add(data); + } + } + + if (!missingSpecId.isEmpty()) { + Preconditions.checkState(!currentSpec.isPartitioned(), + "Missing partition spec id for delete files in partitioned table %s", + transaction.table().name()); + commitDataBySpecId.computeIfAbsent(currentSpec.specId(), k -> new ArrayList<>()).addAll(missingSpecId); + } + + List deleteFiles = new ArrayList<>(); + for (Map.Entry> entry : commitDataBySpecId.entrySet()) { + int specId = entry.getKey(); + PartitionSpec spec = specsById.get(specId); + Preconditions.checkState(spec != null, + "Unknown partition spec id %s for delete files in table %s", + specId, transaction.table().name()); + deleteFiles.addAll(IcebergWriterHelper.convertToDeleteFiles(fileFormat, spec, entry.getValue(), zone)); + } + return deleteFiles; + } + + private Long getSnapshotIdIfPresent(Table icebergTable) { + if (icebergTable == null || icebergTable.currentSnapshot() == null) { + return null; + } + return icebergTable.currentSnapshot().snapshotId(); + } + + // ─────────────────── commit-time conflict-detection validation suite (legacy :655-784) ─────────────────── + + /** + * Applies the optimistic conflict-detection validation suite onto the {@link RowDelta} before it commits, + * ported verbatim from legacy {@code IcebergTransaction.applyRowDeltaValidations}: pin the base snapshot, + * set the conflict-detection filter (O5-2 write constraint AND identity-partition filter), and — at the + * serializable isolation level — validate against conflicting data/delete files and referenced data files. + */ + private void applyRowDeltaValidations(RowDelta rowDelta, Table icebergTable, + List commitData, List referencedDataFiles) { + applyBaseSnapshotValidation(rowDelta); + applyConflictDetectionFilter(rowDelta, icebergTable, commitData); + if (isSerializableIsolationLevel(icebergTable)) { + rowDelta.validateNoConflictingDataFiles(); + } + rowDelta.validateDeletedFiles(); + rowDelta.validateNoConflictingDeleteFiles(); + if (!referencedDataFiles.isEmpty()) { + rowDelta.validateDataFilesExist(referencedDataFiles); + } + } + + private void applyBaseSnapshotValidation(RowDelta rowDelta) { + if (baseSnapshotId != null) { + rowDelta.validateFromSnapshot(baseSnapshotId); + } + } + + private void applyConflictDetectionFilter(RowDelta rowDelta, Table icebergTable, + List commitData) { + Optional queryFilter = buildWriteConstraintExpression(icebergTable); + Optional partitionFilter = buildConflictDetectionFilter(icebergTable, commitData); + Optional combined = combineConflictDetectionFilters(queryFilter, partitionFilter); + combined.ifPresent(rowDelta::conflictDetectionFilter); + } + + /** + * O5-2: converts the stashed neutral {@link ConnectorPredicate} into an iceberg {@link Expression} using + * the connector's {@link IcebergPredicateConverter} (P6.2-T02). Done lazily here because the table schema + * is only available after {@link #beginWrite}. The converter flattens the top-level AND and drops any + * unconvertible conjunct — which only ever widens the conflict-detection filter (more conservative, + * never missing a real conflict), see design DV-T05-c. + */ + Optional buildWriteConstraintExpression(Table icebergTable) { + if (writeConstraint == null || writeConstraint.getExpression() == null || icebergTable == null) { + return Optional.empty(); + } + ConnectorExpression expr = writeConstraint.getExpression(); + // conflictMode=true: build the iceberg expression for write-time conflict detection (O5-2), whose + // matrix is a conservative port of legacy convertPredicateToIcebergExpression (IS NULL / BETWEEN / + // same-column OR / NOT(IS NULL); drops NE / cross-column OR) — different from scan pushdown. + List converted = + new IcebergPredicateConverter(icebergTable.schema(), zone, true).convert(expr); + if (converted.isEmpty()) { + return Optional.empty(); + } + Expression combined = converted.get(0); + for (int i = 1; i < converted.size(); i++) { + combined = Expressions.and(combined, converted.get(i)); + } + return Optional.of(combined); + } + + private Optional combineConflictDetectionFilters(Optional queryFilter, + Optional partitionFilter) { + if (queryFilter.isPresent() && partitionFilter.isPresent()) { + return Optional.of(Expressions.and(queryFilter.get(), partitionFilter.get())); + } + return queryFilter.isPresent() ? queryFilter : partitionFilter; + } + + /** + * Builds the commit-time identity-partition filter from the partition values carried by the commit + * fragments: an OR over each fragment's per-partition AND of {@code col = value} (or {@code isNull}). + * Only when every partition transform is identity and every fragment matches the current spec; otherwise + * empty (no narrowing). Ported from legacy {@code IcebergTransaction.buildConflictDetectionFilter}. + */ + private Optional buildConflictDetectionFilter(Table icebergTable, + List commitData) { + if (icebergTable == null || commitData == null || commitData.isEmpty()) { + return Optional.empty(); + } + + PartitionSpec spec = icebergTable.spec(); + if (!spec.isPartitioned()) { + return Optional.empty(); + } + if (!areAllIdentityPartitions(spec)) { + return Optional.empty(); + } + + Schema schema = icebergTable.schema(); + int currentSpecId = spec.specId(); + + Expression combined = null; + for (TIcebergCommitData data : commitData) { + if (data.isSetPartitionSpecId() && data.getPartitionSpecId() != currentSpecId) { + return Optional.empty(); + } + if (!data.isSetPartitionSpecId() && spec.isPartitioned()) { + return Optional.empty(); + } + + List partitionValues = extractPartitionValues(data); + if (partitionValues.isEmpty() || partitionValues.size() != spec.fields().size()) { + return Optional.empty(); + } + + Expression partitionExpr = buildIdentityPartitionExpression(spec, schema, partitionValues); + if (partitionExpr == null) { + return Optional.empty(); + } + combined = combined == null ? partitionExpr : Expressions.or(combined, partitionExpr); + } + return combined == null ? Optional.empty() : Optional.of(combined); + } + + private boolean areAllIdentityPartitions(PartitionSpec spec) { + for (PartitionField field : spec.fields()) { + if (!field.transform().isIdentity()) { + return false; + } + } + return true; + } + + private Expression buildIdentityPartitionExpression(PartitionSpec spec, Schema schema, + List partitionValues) { + Expression expression = null; + List fields = spec.fields(); + for (int i = 0; i < fields.size(); i++) { + PartitionField field = fields.get(i); + Types.NestedField sourceField = schema.findField(field.sourceId()); + if (sourceField == null) { + return null; + } + String valueStr = partitionValues.get(i); + if ("null".equals(valueStr)) { + valueStr = null; + } + Object value = IcebergPartitionUtils.parsePartitionValueFromString(valueStr, sourceField.type(), zone); + Expression predicate = value == null + ? Expressions.isNull(sourceField.name()) + : Expressions.equal(sourceField.name(), value); + expression = expression == null ? predicate : Expressions.and(expression, predicate); + } + return expression; + } + + private List extractPartitionValues(TIcebergCommitData commitData) { + if (commitData == null) { + return Collections.emptyList(); + } + if (commitData.getPartitionValues() != null && !commitData.getPartitionValues().isEmpty()) { + return commitData.getPartitionValues(); + } + if (commitData.getPartitionDataJson() != null && !commitData.getPartitionDataJson().isEmpty()) { + return IcebergPartitionUtils.parsePartitionValuesFromJson(commitData.getPartitionDataJson()); + } + return Collections.emptyList(); + } + + boolean isSerializableIsolationLevel(Table icebergTable) { + if (icebergTable == null) { + return true; + } + String level = icebergTable.properties() + .getOrDefault(DELETE_ISOLATION_LEVEL, DELETE_ISOLATION_LEVEL_DEFAULT); + return "serializable".equalsIgnoreCase(level); + } + + // ─────────────────── V3 deletion-vector "rewrite previous delete files" (legacy :786-851) ─────────────────── + + boolean shouldRewritePreviousDeleteFiles() { + return table != null && formatVersion(table) >= 3; + } + + /** + * Reads the real table format version, mirroring {@code IcebergConnectorMetadata.getFormatVersion} / + * legacy {@code IcebergUtils.getFormatVersion}: from a {@link BaseTable}'s current metadata when + * available, else from the {@code format-version} table property, defaulting to 2. + */ + private static int formatVersion(Table table) { + int formatVersion = 2; + if (table instanceof BaseTable) { + formatVersion = ((BaseTable) table).operations().current().formatVersion(); + } else if (table != null && table.properties() != null) { + String version = table.properties().get(TableProperties.FORMAT_VERSION); + if (version != null) { + try { + formatVersion = Integer.parseInt(version); + } catch (NumberFormatException ignored) { + // keep the default + } + } + } + return formatVersion; + } + + /** + * Collects the old file-scoped delete files to {@code removeDeletes} from the V3 deletion-vector RowDelta, + * re-derived at commit time (Trino-style, mirroring {@code DefaultDeletionVectorWriter + * .getExistingDeletesByMetadataOnly}): a metadata-only read of the base snapshot's delete manifests, keyed by + * the data-file paths this commit touched ({@link TIcebergCommitData#getReferencedDataFilePath}). The old + * file-scoped deletes (legacy file-scoped position deletes + V3 deletion vectors) referencing those data + * files are exactly the ones a V3 commit must remove so each data file keeps at most one deletion file. + * Deduped by {@link #buildDeleteFileDedupKey}. + * + *

    Unlike legacy {@code IcebergTransaction.collectRewrittenDeleteFiles} (which looked the old files up in a + * scan-time map fed from the read plan), this reads them from the write-time {@link #baseSnapshotId} the + * RowDelta validates against, so the removed deletes are snapshot-consistent with the commit by construction + * (no read-vs-write snapshot skew). Post-flip deviation DV-S2-rederive (dormant: iceberg is not yet a + * plugin-driven type, so this commit path runs only after the C5 flip).

    + */ + List collectRewrittenDeleteFiles(List deleteCommitData) { + if (deleteCommitData == null || deleteCommitData.isEmpty() || baseSnapshotId == null || table == null) { + return Collections.emptyList(); + } + Set touchedDataFilePaths = new HashSet<>(); + for (TIcebergCommitData commitData : deleteCommitData) { + if (commitData.isSetReferencedDataFilePath() + && commitData.getReferencedDataFilePath() != null + && !commitData.getReferencedDataFilePath().isEmpty()) { + touchedDataFilePaths.add(commitData.getReferencedDataFilePath()); + } + } + if (touchedDataFilePaths.isEmpty()) { + return Collections.emptyList(); + } + return readExistingFileScopedDeletes(table, baseSnapshotId, touchedDataFilePaths); + } + + /** + * Reads the base snapshot's delete manifests (metadata-only — no data-file reads) and returns the file-scoped + * position deletes / deletion vectors whose referenced data file is among {@code touchedDataFilePaths}, + * deduped by {@link #buildDeleteFileDedupKey}. Mirrors Trino + * {@code DefaultDeletionVectorWriter.getExistingDeletesByMetadataOnly} (POSITION_DELETES content, file-scoped + * only — partition-scoped deletes are never removed). + * + *

    Intentional divergence from Trino: when a data file (on a v2→v3 upgraded table) carries BOTH a + * legacy file-scoped position delete AND a deletion vector, this returns BOTH (Trino suppresses the legacy + * file once a DV exists). Doris's BE unions the old positions from both kinds into the new DV + * ({@code viceberg_delete_sink} load_rewritable_delete_rows), so both old files are fully superseded and both + * must be removed — leaving the legacy file would orphan a stale delete. + * + *

    Each {@link DeleteFile} is defensively copied so the returned list stays valid after the reader closes. + */ + private List readExistingFileScopedDeletes( + Table baseTable, long snapshotId, Set touchedDataFilePaths) { + Snapshot snapshot = baseTable.snapshot(snapshotId); + if (snapshot == null) { + return Collections.emptyList(); + } + FileIO io = baseTable.io(); + Map specsById = baseTable.specs(); + Map dedup = new LinkedHashMap<>(); + for (ManifestFile manifest : snapshot.deleteManifests(io)) { + try (ManifestReader reader = ManifestFiles.readDeleteManifest(manifest, io, specsById)) { + for (DeleteFile deleteFile : reader) { + if (deleteFile.content() != FileContent.POSITION_DELETES + || !ContentFileUtil.isFileScoped(deleteFile)) { + continue; + } + String referenced = deleteFile.referencedDataFile(); + if (referenced == null || !touchedDataFilePaths.contains(referenced)) { + continue; + } + dedup.putIfAbsent(buildDeleteFileDedupKey(deleteFile), deleteFile.copy()); + } + } catch (IOException e) { + throw new DorisConnectorException( + "Failed to read iceberg delete manifest " + manifest.path() + ": " + e.getMessage(), e); + } + } + return new ArrayList<>(dedup.values()); + } + + private String buildDeleteFileDedupKey(DeleteFile deleteFile) { + if (deleteFile.format() == FileFormat.PUFFIN) { + return deleteFile.path() + "#" + deleteFile.contentOffset() + "#" + + deleteFile.contentSizeInBytes(); + } + return deleteFile.path().toString(); + } + + /** + * Collects the referenced data-file paths for {@code validateDataFilesExist} from the delete/DV fragments + * ({@code referenced_data_files} + {@code referenced_data_file_path}). Ported from legacy + * {@code IcebergTransaction.collectReferencedDataFiles}. + */ + List collectReferencedDataFiles(List commitData) { + if (commitData == null || commitData.isEmpty()) { + return Collections.emptyList(); + } + + List referencedDataFiles = new ArrayList<>(); + for (TIcebergCommitData data : commitData) { + if (data.isSetFileContent() + && data.getFileContent() != TFileContent.POSITION_DELETES + && data.getFileContent() != TFileContent.DELETION_VECTOR) { + continue; + } + if (data.isSetReferencedDataFiles()) { + for (String dataFile : data.getReferencedDataFiles()) { + if (dataFile != null && !dataFile.isEmpty()) { + referencedDataFiles.add(dataFile); + } + } + } + if (data.isSetReferencedDataFilePath() + && data.getReferencedDataFilePath() != null + && !data.getReferencedDataFilePath().isEmpty()) { + referencedDataFiles.add(data.getReferencedDataFilePath()); + } + } + return referencedDataFiles; + } + + @Override + public void rollback() { + // Insert-mode: nothing to undo on the FE side — an uncommitted SDK transaction simply discards + // its pending manifests (legacy IcebergTransaction.rollback no-ops the insert path). The rewrite + // path's file-list cleanup is a P6.4 procedure concern, out of scope here. + LOG.info("Iceberg transaction {} rollback called; uncommitted manifests will be discarded.", + transactionId); + } + + @Override + public void close() { + // No resources to release: the SDK transaction holds no connections of its own. + } + + /** Package-visible accessors for the unit tests (and the T05 validation suite). */ + Transaction getTransaction() { + return transaction; + } + + Table getTable() { + return table; + } + + List getCommitDataList() { + return commitDataList; + } + + /** The snapshot pinned at begin time for a DELETE/MERGE (null for INSERT/OVERWRITE); consumed by T05. */ + Long getBaseSnapshotId() { + return baseSnapshotId; + } + + // ─────────────────── REWRITE accessors (P6.4-T06; consumed by the rewrite coordinator + tests) ─────────────────── + + /** REWRITE OCC anchor captured at begin time (-1 when the table had no snapshot); the value passed to + * {@code RewriteFiles.validateFromSnapshot} at commit. */ + long getStartingSnapshotId() { + return startingSnapshotId; + } + + /** Number of original data files to remove (legacy {@code getFilesToDeleteCount}); available after + * {@link #updateRewriteFiles}. Feeds the {@code rewritten_data_files_count} result column. */ + int getFilesToDeleteCount() { + synchronized (filesToDelete) { + return filesToDelete.size(); + } + } + + /** Number of new compacted data files added — populated DURING {@code commit()} + * ({@link #convertCommitDataToFilesToAdd}), so read it only after commit (legacy {@code getFilesToAddCount}, + * which the executor reads after {@code finishRewrite}). Feeds the {@code added_data_files_count} column. */ + int getFilesToAddCount() { + synchronized (filesToAdd) { + return filesToAdd.size(); + } + } + + /** Neutral SPI accessor for the rewrite driver: the post-commit added-data-files count (legacy + * {@code getFilesToAddCount}); the one rewrite-result statistic the engine cannot derive from its + * planning groups (the others come from {@code ConnectorRewriteGroup}). Read only after {@code commit()}. */ + @Override + public int getRewriteAddedDataFilesCount() { + return getFilesToAddCount(); + } + + /** Total byte size of the original data files to remove (legacy {@code getFilesToDeleteSize}). */ + long getFilesToDeleteSize() { + synchronized (filesToDelete) { + return filesToDelete.stream().mapToLong(DataFile::fileSizeInBytes).sum(); + } + } + + /** Total byte size of the new compacted data files added (legacy {@code getFilesToAddSize}); post-commit. */ + long getFilesToAddSize() { + synchronized (filesToAdd) { + return filesToAdd.stream().mapToLong(DataFile::fileSizeInBytes).sum(); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergDelegatedCredentialUtils.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergDelegatedCredentialUtils.java new file mode 100644 index 00000000000000..979749a2a6c21f --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergDelegatedCredentialUtils.java @@ -0,0 +1,49 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorDelegatedCredential; + +import org.apache.iceberg.rest.auth.OAuth2Properties; + +/** + * Maps a neutral {@link ConnectorDelegatedCredential.Type} to the Iceberg OAuth2 token-type key used when the + * connector attaches a user's delegated credential to a REST {@code SessionCatalog} request in + * {@code token_exchange} mode. Re-migrated from the pre-P6 fe-core {@code IcebergDelegatedCredentialUtils}, + * retargeted off the neutral SPI type (the connector must not import fe-core). + */ +public final class IcebergDelegatedCredentialUtils { + + private IcebergDelegatedCredentialUtils() { + } + + public static String credentialKey(ConnectorDelegatedCredential.Type type) { + switch (type) { + case ACCESS_TOKEN: + return OAuth2Properties.TOKEN; + case ID_TOKEN: + return OAuth2Properties.ID_TOKEN_TYPE; + case JWT: + return OAuth2Properties.JWT_TOKEN_TYPE; + case SAML: + return OAuth2Properties.SAML2_TOKEN_TYPE; + default: + throw new IllegalArgumentException("Unsupported delegated credential type: " + type); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergFormatCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergFormatCache.java new file mode 100644 index 00000000000000..294b29b3738e8c --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergFormatCache.java @@ -0,0 +1,145 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.cache.CacheSpec; +import org.apache.doris.connector.cache.MetaCacheEntry; + +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; + +import java.util.Objects; +import java.util.concurrent.ForkJoinPool; +import java.util.function.Supplier; + +/** + * Per-catalog cache of an iceberg table's inferred write file-format name (PERF-03), keyed by + * {@code (TableIdentifier, snapshotId)}. Kills the per-query revival of the #64134 heavy fallback: when a table + * carries neither the {@code write-format} nickname nor {@code write.format.default} (migrated tables, and any + * table whose writer never set the property — parquet is an implicit default, not stored), + * {@link IcebergScanPlanProvider#getScanNodeProperties} resolves the scan-level {@code file_format_type} through + * {@link IcebergWriterHelper#getFileFormat}, which falls back to an unfiltered {@code table.newScan().planFiles()} + * — a whole-table manifest scan (remote FileIO) — just to read the FIRST data file's format. That fallback was + * re-run on every query/EXPLAIN with no cross-query reuse; this cache collapses it to one scan per + * {@code (table, snapshot)}. + * + *

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

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

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

    Backed identically to {@link IcebergPartitionCache}: a contextual, access-TTL {@link MetaCacheEntry} with + * manual miss-load, so the inference runs OUTSIDE Caffeine's compute lock and a failed scan's exception + * propagates verbatim and is NOT cached (the next query retries — legacy parity). TTL is + * {@code meta.cache.iceberg.table.ttl-second}; {@code <= 0} disables (read live). Lives on the long-lived + * per-catalog {@link IcebergConnector}; a REFRESH CATALOG rebuilds the connector and thus the cache. + */ +final class IcebergFormatCache { + + /** Immutable composite key: a table's inferred format is distinct per pinned snapshot id. */ + static final class Key { + final TableIdentifier id; + final long snapshotId; + + Key(TableIdentifier id, long snapshotId) { + this.id = id; + this.snapshotId = snapshotId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Key)) { + return false; + } + Key that = (Key) o; + return snapshotId == that.snapshotId && Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id, snapshotId); + } + } + + private final MetaCacheEntry entry; + + IcebergFormatCache(long ttlSeconds, int maxSize) { + // Mirror IcebergPartitionCache: translate the connector's "<= 0 disables" contract to CacheSpec's ttl == 0 + // (disabled) rather than passing a negative value through (which CacheSpec reads as "no expiration"). + CacheSpec spec = ttlSeconds > 0 + ? CacheSpec.of(true, ttlSeconds, maxSize) + : CacheSpec.of(true, CacheSpec.CACHE_TTL_DISABLE_CACHE, maxSize); + this.entry = new MetaCacheEntry<>("iceberg-format", null, spec, + ForkJoinPool.commonPool(), false, true, 0L, true); + } + + /** Caching is on only when the TTL is positive; ttl-second <= 0 means "always infer live". */ + boolean isEnabled() { + return entry.stats().isEffectiveEnabled(); + } + + /** + * Returns the cached format name for {@code key} if present and unexpired, else runs {@code loader} (the live + * whole-table format inference), caches and returns it. Disabled cache -> {@code loader} every call. The + * loader runs OUTSIDE Caffeine's compute lock (single-flight per key) and its exception propagates unwrapped + * and is NOT cached. + */ + String getOrLoad(Key key, Supplier loader) { + return entry.get(key, ignored -> loader.get()); + } + + /** Drops every cached snapshot entry for one table so the next read infers live (REFRESH TABLE). */ + void invalidate(TableIdentifier id) { + entry.invalidateIf(key -> key.id.equals(id)); + } + + /** Drops every cached entry for one database (REFRESH DATABASE / DROP DATABASE); db match = namespace equality. */ + void invalidateDb(String dbName) { + Namespace ns = Namespace.of(dbName); + entry.invalidateIf(key -> key.id.namespace().equals(ns)); + } + + /** Drops all cached entries (REFRESH CATALOG). */ + void invalidateAll() { + entry.invalidateAll(); + } + + /** Test-only: current number of cached entries (accurate map membership, not Caffeine's estimate). */ + int size() { + int[] count = {0}; + entry.forEach((key, value) -> count[0]++); + return count[0]; + } + + /** Test-only: how many times the live loader (the whole-table format inference) actually ran — the metric gate. */ + long loadCountForTest() { + return entry.stats().getLoadSuccessCount(); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergLatestSnapshotCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergLatestSnapshotCache.java new file mode 100644 index 00000000000000..b953e178fbe377 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergLatestSnapshotCache.java @@ -0,0 +1,124 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.cache.CacheSpec; +import org.apache.doris.connector.cache.MetaCacheEntry; + +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; + +import java.util.concurrent.ForkJoinPool; +import java.util.function.Supplier; + +/** + * Per-catalog cache of an iceberg table's LATEST snapshot, keyed by {@link TableIdentifier} (db.table). + * + *

    Mirrors the paimon connector's {@code PaimonLatestSnapshotCache}: restores the legacy + * {@code IcebergExternalMetaCache} table-cache semantics that the SPI cutover dropped. + * Within the TTL an iceberg catalog serves a STABLE (possibly stale) latest snapshot across queries, so a + * query-begin pin ({@link IcebergConnectorMetadata#beginQuerySnapshot}) reads the SAME snapshot until the + * entry expires or is invalidated by {@code REFRESH TABLE}/{@code REFRESH CATALOG}. + * + *

    Value carries BOTH snapshotId and schemaId (the single iceberg-specific deviation from the paimon + * {@code long}-only mirror). {@code beginQuerySnapshot} pins the snapshot id and the LATEST schema + * id ({@code table.schema().schemaId()} — not {@code currentSnapshot().schemaId()}, mirroring legacy + * {@code IcebergUtils.getLatestIcebergSnapshot}). A schema-only {@code ALTER} bumps the latest schema id + * without producing a new snapshot, so the two ids must be captured atomically — otherwise two live reads + * within one pin could observe a snapshotId/schemaId skew. The value type therefore ports legacy + * {@code IcebergSnapshot}'s {@code (snapshotId, schemaId)} shape. + * + *

    Backed by the shared {@link MetaCacheEntry} framework (independent-copy meta-cache migration): a + * contextual, access-TTL entry whose per-query loader is supplied at {@link #getOrLoad}. TTL is + * {@code meta.cache.iceberg.table.ttl-second}: {@code <= 0} disables caching (every read goes live, matching + * the legacy "no-cache" catalog); a positive value is Caffeine {@code expireAfterAccess} with a + * {@code maxSize} capacity (real LRU eviction, replacing the former clear-on-overflow). Manual miss-load is + * on so the loader runs OUTSIDE Caffeine's compute lock (single-flight per key). Lives on the long-lived + * per-catalog {@link IcebergConnector}; a REFRESH CATALOG rebuilds the connector and thus the cache. + */ +final class IcebergLatestSnapshotCache { + + /** Immutable atomic pin = the latest snapshot id plus the latest schema id (port of legacy IcebergSnapshot). */ + static final class CachedSnapshot { + final long snapshotId; + final long schemaId; + + CachedSnapshot(long snapshotId, long schemaId) { + this.snapshotId = snapshotId; + this.schemaId = schemaId; + } + } + + private final MetaCacheEntry entry; + + IcebergLatestSnapshotCache(long ttlSeconds, int maxSize) { + // ttl-second <= 0 disables caching (always read live); a positive ttl is access-based expiry with the + // given capacity. CacheSpec treats ttl == -1 as "no expiration (enabled)" and ttl == 0 as "disabled", + // so translate the connector's "<= 0 disables" contract to ttl == 0 rather than passing a negative + // value straight through (which would otherwise flip -1 into a never-expiring cache). + CacheSpec spec = ttlSeconds > 0 + ? CacheSpec.of(true, ttlSeconds, maxSize) + : CacheSpec.of(true, CacheSpec.CACHE_TTL_DISABLE_CACHE, maxSize); + this.entry = new MetaCacheEntry<>("iceberg-latest-snapshot", null, spec, + ForkJoinPool.commonPool(), false, true, 0L, true); + } + + /** Caching is on only when the TTL is positive; ttl-second <= 0 means "always read live". */ + boolean isEnabled() { + return entry.stats().isEffectiveEnabled(); + } + + /** + * Returns the cached latest snapshot for {@code identifier} if present and unexpired, else runs + * {@code loader} (the live {@code currentSnapshot()} + latest-schema read), caches and returns it. When + * caching is disabled ({@link #isEnabled()} is false) {@code loader} runs every call and nothing is cached. + * A hit refreshes the entry's expiry (access-based). The loader runs OUTSIDE Caffeine's compute lock + * (single-flight per key); a disabled entry bypasses the cache entirely and always loads. + */ + CachedSnapshot getOrLoad(TableIdentifier identifier, Supplier loader) { + return entry.get(identifier, ignored -> loader.get()); + } + + /** Drops the cached entry for one table so the next read goes live (REFRESH TABLE). */ + void invalidate(TableIdentifier identifier) { + entry.invalidateKey(identifier); + } + + /** + * Drops every cached entry for one database so the next read of any of its tables goes live + * (REFRESH DATABASE / a Doris-issued DROP DATABASE). Entries are keyed by + * {@code TableIdentifier.of(db, table)} (single-level namespace = {@code [db]}, see + * {@code IcebergConnectorMetadata.beginQuerySnapshot}), so a db match is namespace equality. + */ + void invalidateDb(String dbName) { + Namespace ns = Namespace.of(dbName); + entry.invalidateIf(id -> id.namespace().equals(ns)); + } + + /** Drops all cached entries. */ + void invalidateAll() { + entry.invalidateAll(); + } + + /** Test-only: current number of cached entries (accurate map membership, not Caffeine's estimate). */ + int size() { + int[] count = {0}; + entry.forEach((key, value) -> count[0]++); + return count[0]; + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergManifestCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergManifestCache.java new file mode 100644 index 00000000000000..f3c46732fb2fc1 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergManifestCache.java @@ -0,0 +1,235 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.cache.CacheSpec; +import org.apache.doris.connector.cache.MetaCacheEntry; + +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.ManifestContent; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ManifestFiles; +import org.apache.iceberg.ManifestReader; +import org.apache.iceberg.Table; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; + +/** + * Per-catalog cache of an iceberg manifest's parsed files, keyed by {@link IcebergManifestEntryKey} + * (manifest path + content). Ported from the legacy fe-core {@code IcebergExternalMetaCache} manifest entry + + * {@code IcebergManifestCacheLoader}, now backed by the shared {@link MetaCacheEntry} framework + * (independent-copy meta-cache migration). + * + *

    Consumed by {@link IcebergScanPlanProvider}'s manifest-level planning path (gated by + * {@code meta.cache.iceberg.manifest.enable}, default off — the default scan path is the iceberg SDK + * {@code planFiles()}). The external enable-gate lives in the scan provider (which decides whether to take the + * manifest-planning path at all); this cache is unconditionally on when consulted. Within one catalog the same + * manifest file is parsed once and shared across queries (and across tables that reference it). + * + *

    No TTL; capacity-bounded; cleared on REFRESH CATALOG. This mirrors the legacy entry's + * {@code contextualOnly(CacheSpec.of(false, CACHE_NO_TTL, 100_000))} default spec: a manifest's content is + * immutable for a given path, so entries never go stale and need no TTL; a table-level invalidation (REFRESH + * TABLE) intentionally keeps them ({@code IcebergConnector.invalidateTable} does not touch this cache, legacy + * parity). It is cleared by {@link #invalidateAll()} on a REFRESH CATALOG (via + * {@link IcebergConnector#invalidateAll()}, mirroring legacy catalog-wide {@code group.invalidateAll()}) and + * dropped wholesale when the {@link IcebergConnector} is rebuilt (ADD/MODIFY CATALOG). Overflow is bounded by + * Caffeine {@code maximumSize} eviction (re-reads are harmless — the value is immutable). The parse loader runs + * OUTSIDE Caffeine's compute lock (manual miss-load, single-flight per key). + */ +final class IcebergManifestCache { + + /** Legacy effective capacity (fe-core {@code DEFAULT_MANIFEST_CACHE_CAPACITY}, asserted in its stats tests). */ + static final int DEFAULT_MANIFEST_CACHE_CAPACITY = 100_000; + + /** Leak backstop for the per-scan stats stash (see {@link #statsByQuery}); far above any live entry's life. */ + private static final long DEFAULT_STATS_TTL_SECONDS = 300L; + + private final MetaCacheEntry entry; + + // Per-scan manifest-cache access tally, keyed by the statement's stable queryId + // (ConnectorSession.getQueryId()), so VERBOSE EXPLAIN can report THIS scan's hits/misses/failures (the + // "manifest cache:" line). The provider that PLANS the scan and the (transient, fresh-per-call) provider that + // renders EXPLAIN are different instances, so per-scan state cannot live on the provider; the long-lived + // per-catalog cache is their only shared survivor (the queryId keys this per-scan tally onto it). + // {@link #takeStats} is the primary eviction (EXPLAIN drains its entry); a non-EXPLAIN query records but + // never drains, so its leaked entry is aged out by the lazy TTL sweep in {@link #touch}. + private final Map statsByQuery = new ConcurrentHashMap<>(); + private final long statsTtlNanos; + private final LongSupplier nanoClock; + + /** One in-flight statement's manifest-cache access tally, plus a touch stamp for the leak sweep. */ + private static final class ScanStats { + long hits; + long misses; + long failures; + volatile long lastTouchNanos; + + ScanStats(long nowNanos) { + this.lastTouchNanos = nowNanos; + } + } + + IcebergManifestCache() { + this(DEFAULT_MANIFEST_CACHE_CAPACITY); + } + + IcebergManifestCache(int maxSize) { + this(maxSize, DEFAULT_STATS_TTL_SECONDS, System::nanoTime); + } + + /** Visible for testing: injectable stats TTL + clock so the leak sweep is deterministic without sleeping. */ + IcebergManifestCache(int maxSize, long statsTtlSeconds, LongSupplier nanoClock) { + // Always enabled, no expiry, capacity-bounded (CACHE_NO_TTL == -1 means "no expiration", enabled). + CacheSpec spec = CacheSpec.of(true, CacheSpec.CACHE_NO_TTL, Math.max(1, maxSize)); + this.entry = new MetaCacheEntry<>("iceberg-manifest", null, spec, + ForkJoinPool.commonPool(), false, true, 0L, true); + this.statsTtlNanos = TimeUnit.SECONDS.toNanos(Math.max(1L, statsTtlSeconds)); + this.nanoClock = nanoClock; + } + + /** + * Returns the parsed files for {@code manifest}, loading (and reading from storage) only on a miss. The + * loader runs OUTSIDE Caffeine's compute lock (manual miss-load; single-flight per key), so a same-key + * miss parses at most once. + */ + ManifestCacheValue getManifestCacheValue(ManifestFile manifest, Table table) { + IcebergManifestEntryKey key = IcebergManifestEntryKey.of(manifest); + return entry.get(key, k -> loadManifestCacheValue(manifest, table, k.getContent())); + } + + /** + * Same as {@link #getManifestCacheValue(ManifestFile, Table)} but tallies this access as a hit or miss under + * {@code queryId} (a hit == the entry was already cached BEFORE this load, matching the legacy + * {@code getIfPresent(key) != null} probe). Within one statement the manifest-level plan processes manifests + * sequentially, so the per-query counters need no synchronization. A blank queryId is not recorded. + */ + ManifestCacheValue getManifestCacheValue(ManifestFile manifest, Table table, String queryId) { + IcebergManifestEntryKey key = IcebergManifestEntryKey.of(manifest); + boolean hit = entry.getIfPresent(key) != null; + if (queryId != null && !queryId.isEmpty()) { + ScanStats stats = touch(queryId); + if (hit) { + stats.hits++; + } else { + stats.misses++; + } + } + return entry.get(key, k -> loadManifestCacheValue(manifest, table, k.getContent())); + } + + /** + * Records one manifest-level planning failure for {@code queryId} (the scan provider fell back to the SDK + * scan). Mirrors the legacy {@code manifestCacheFailures} bump. A blank queryId is not recorded. + */ + void recordFailure(String queryId) { + if (queryId != null && !queryId.isEmpty()) { + touch(queryId).failures++; + } + } + + /** + * Returns and REMOVES this query's {@code {hits, misses, failures}} tally (zeros when nothing was recorded). + * The remove is the primary eviction — VERBOSE EXPLAIN drains its own entry once it has rendered the line. + */ + long[] takeStats(String queryId) { + if (queryId == null || queryId.isEmpty()) { + return new long[] {0L, 0L, 0L}; + } + ScanStats stats = statsByQuery.remove(queryId); + return stats == null + ? new long[] {0L, 0L, 0L} + : new long[] {stats.hits, stats.misses, stats.failures}; + } + + /** Fetch (or lazily create) this query's tally, opportunistically aging out leaked entries first. */ + private ScanStats touch(String queryId) { + long now = nanoClock.getAsLong(); + ScanStats stats = statsByQuery.get(queryId); + if (stats == null) { + // First access of a not-yet-seen query: age out leaked non-EXPLAIN entries. Done OUTSIDE + // computeIfAbsent (ConcurrentHashMap forbids mutating other mappings from within it). + sweepExpiredStats(now); + stats = statsByQuery.computeIfAbsent(queryId, k -> new ScanStats(now)); + } + stats.lastTouchNanos = now; + return stats; + } + + /** Drops stats entries untouched for longer than the TTL — leaked non-EXPLAIN query tallies only. */ + private void sweepExpiredStats(long nowNanos) { + statsByQuery.entrySet().removeIf(e -> nowNanos - e.getValue().lastTouchNanos >= statsTtlNanos); + } + + private static ManifestCacheValue loadManifestCacheValue(ManifestFile manifest, Table table, + ManifestContent content) { + try { + if (content == ManifestContent.DELETES) { + return ManifestCacheValue.forDeleteFiles(loadDeleteFiles(manifest, table)); + } + return ManifestCacheValue.forDataFiles(loadDataFiles(manifest, table)); + } catch (IOException e) { + throw new RuntimeException("Failed to read iceberg manifest " + manifest.path(), e); + } + } + + private static List loadDataFiles(ManifestFile manifest, Table table) throws IOException { + List dataFiles = new ArrayList<>(); + // .copy() is mandatory: the ManifestReader iterator reuses the same object across iterations. + try (ManifestReader reader = ManifestFiles.read(manifest, table.io())) { + for (DataFile dataFile : reader) { + dataFiles.add(dataFile.copy()); + } + } + return dataFiles; + } + + private static List loadDeleteFiles(ManifestFile manifest, Table table) throws IOException { + List deleteFiles = new ArrayList<>(); + try (ManifestReader reader = + ManifestFiles.readDeleteManifest(manifest, table.io(), table.specs())) { + for (DeleteFile deleteFile : reader) { + deleteFiles.add(deleteFile.copy()); + } + } + return deleteFiles; + } + + /** + * REFRESH CATALOG hook: drop every cached manifest. Called by {@link IcebergConnector#invalidateAll()} + * (catalog-wide invalidation); table-level invalidation (REFRESH TABLE) intentionally does not. + */ + void invalidateAll() { + entry.invalidateAll(); + statsByQuery.clear(); + } + + /** Test-only: current number of cached entries (accurate map membership, not Caffeine's estimate). */ + int size() { + int[] count = {0}; + entry.forEach((key, value) -> count[0]++); + return count[0]; + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergManifestEntryKey.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergManifestEntryKey.java similarity index 77% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergManifestEntryKey.java rename to fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergManifestEntryKey.java index 3d6499333be136..90552bef6d5d3a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergManifestEntryKey.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergManifestEntryKey.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource.iceberg; +package org.apache.doris.connector.iceberg; import org.apache.iceberg.ManifestContent; import org.apache.iceberg.ManifestFile; @@ -23,10 +23,14 @@ import java.util.Objects; /** - * Cache key for one iceberg manifest entry. + * Cache key for one iceberg manifest entry (T08). * - *

    This key only contains stable identity dimensions (manifest path + content type). - * Runtime loader context (manifest instance, table instance) must not be stored here. + *

    Ported verbatim from the legacy fe-core + * {@code org.apache.doris.datasource.iceberg.IcebergManifestEntryKey}. The key carries only stable identity + * dimensions — the manifest path plus its content type — so two tables that share a manifest path hit the same + * cached payload, and a table-level invalidation (REFRESH TABLE) intentionally does NOT drop it (legacy + * {@code testInvalidateTableKeepsManifestCache} parity). Runtime loader context (the manifest/table instances) + * must not be stored here. */ public class IcebergManifestEntryKey { private final String manifestPath; diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionCache.java new file mode 100644 index 00000000000000..3d68ab664cfb87 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionCache.java @@ -0,0 +1,142 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.cache.CacheSpec; +import org.apache.doris.connector.cache.MetaCacheEntry; +import org.apache.doris.connector.iceberg.IcebergPartitionUtils.IcebergRawPartition; + +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ForkJoinPool; +import java.util.function.Supplier; + +/** + * Per-catalog cache of an iceberg table's raw partition list (PERF-02), keyed by {@code (TableIdentifier, + * snapshotId)}. Restores the partition-info half of the legacy {@code IcebergExternalMetaCache} that the SPI + * cutover dropped: the analysis-phase PARTITIONS metadata-table scan + * ({@link IcebergPartitionUtils#loadRawPartitionsUncached}, which the iceberg SDK materializes by reading EVERY + * data+delete manifest of the snapshot) was re-run per query and re-run 4~6 times per MTMV refresh, with no + * cross-query reuse. The three consumers ({@code buildMvccPartitionView} for the MVCC/MTMV partition view, + * {@code listPartitions} for {@code selectedPartitionNum}, {@code listPartitionNames} for SHOW PARTITIONS) all + * funnel through it, so they share a single scan per {@code (table, snapshot)}. + * + *

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

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

    Backed identically to {@link IcebergLatestSnapshotCache}: a contextual, access-TTL {@link MetaCacheEntry} + * with manual miss-load, so the scan runs OUTSIDE Caffeine's compute lock and its exception (e.g. the + * dropped-partition-source-column {@link org.apache.iceberg.exceptions.ValidationException} that + * {@code listPartitions} degrades on) propagates verbatim and a failed scan is not cached. TTL is + * {@code meta.cache.iceberg.table.ttl-second}; {@code <= 0} disables (read live). Lives on the long-lived + * per-catalog {@link IcebergConnector}; a REFRESH CATALOG rebuilds the connector and thus the cache. + */ +final class IcebergPartitionCache { + + /** Immutable composite key: a table's partition list is distinct per pinned snapshot id. */ + static final class Key { + final TableIdentifier id; + final long snapshotId; + + Key(TableIdentifier id, long snapshotId) { + this.id = id; + this.snapshotId = snapshotId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Key)) { + return false; + } + Key that = (Key) o; + return snapshotId == that.snapshotId && Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id, snapshotId); + } + } + + private final MetaCacheEntry> entry; + + IcebergPartitionCache(long ttlSeconds, int maxSize) { + // Mirror IcebergLatestSnapshotCache: translate the connector's "<= 0 disables" contract to CacheSpec's + // ttl == 0 (disabled) rather than passing a negative value through (which CacheSpec reads as "no + // expiration"). + CacheSpec spec = ttlSeconds > 0 + ? CacheSpec.of(true, ttlSeconds, maxSize) + : CacheSpec.of(true, CacheSpec.CACHE_TTL_DISABLE_CACHE, maxSize); + this.entry = new MetaCacheEntry<>("iceberg-partition", null, spec, + ForkJoinPool.commonPool(), false, true, 0L, true); + } + + /** Caching is on only when the TTL is positive; ttl-second <= 0 means "always scan live". */ + boolean isEnabled() { + return entry.stats().isEffectiveEnabled(); + } + + /** + * Returns the cached raw partition list for {@code key} if present and unexpired, else runs {@code loader} + * (the live PARTITIONS scan), caches and returns it. Disabled cache -> {@code loader} every call. The + * loader runs OUTSIDE Caffeine's compute lock (single-flight per key) and its exception propagates unwrapped. + */ + List getOrLoad(Key key, Supplier> loader) { + return entry.get(key, ignored -> loader.get()); + } + + /** Drops every cached snapshot entry for one table so the next read scans live (REFRESH TABLE). */ + void invalidate(TableIdentifier id) { + entry.invalidateIf(key -> key.id.equals(id)); + } + + /** Drops every cached entry for one database (REFRESH DATABASE / DROP DATABASE); db match = namespace equality. */ + void invalidateDb(String dbName) { + Namespace ns = Namespace.of(dbName); + entry.invalidateIf(key -> key.id.namespace().equals(ns)); + } + + /** Drops all cached entries (REFRESH CATALOG). */ + void invalidateAll() { + entry.invalidateAll(); + } + + /** Test-only: current number of cached entries (accurate map membership, not Caffeine's estimate). */ + int size() { + int[] count = {0}; + entry.forEach((key, value) -> count[0]++); + return count[0]; + } + + /** Test-only: how many times the live loader (the PARTITIONS scan) actually ran — the metric gate. */ + long loadCountForTest() { + return entry.stats().getLoadSuccessCount(); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionUtils.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionUtils.java new file mode 100644 index 00000000000000..31767a03c4aef2 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionUtils.java @@ -0,0 +1,998 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorPartitionInfo; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.mvcc.ConnectorMvccPartition; +import org.apache.doris.connector.api.mvcc.ConnectorMvccPartitionView; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.base.Preconditions; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.MetadataTableUtils; +import org.apache.iceberg.PartitionData; +import org.apache.iceberg.PartitionField; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Type.TypeID; +import org.apache.iceberg.types.Types.NestedField; +import org.apache.iceberg.types.Types.TimestampType; +import org.apache.iceberg.util.JsonUtil; +import org.apache.iceberg.util.StructProjection; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.Month; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.ChronoField; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Self-contained port of the legacy fe-core {@code IcebergUtils} partition helpers used by the scan path + * (P6.2-T03). The connector cannot import fe-core, so {@code getIdentityPartitionColumns} / + * {@code getIdentityPartitionInfoMap} / {@code getPartitionValues} / {@code getPartitionDataJson} / + * {@code serializePartitionValue} are reproduced byte-faithfully against the iceberg SDK with two + * deliberate, documented deltas: + * + *

      + *
    • The timezone argument is a resolved {@link ZoneId} (instead of legacy's raw {@code String} + + * {@code ZoneId.of(tz)}), so a non-canonical Doris session {@code time_zone} (e.g. {@code "CST"}) + * cannot crash partition-timestamp rendering — consistent with the T02 alias-map fix + * ({@code IcebergScanPlanProvider.resolveSessionZone}).
    • + *
    • {@code getPartitionDataJson} renders the JSON array via iceberg's bundled Jackson + * ({@link JsonUtil#mapper()}) rather than fe-core {@code GsonUtils.GSON}. BE re-parses the JSON + * array back to {@code List}, so the value content is identical; only the serializer differs.
    • + *
    + */ +final class IcebergPartitionUtils { + + private static final Logger LOG = LogManager.getLogger(IcebergPartitionUtils.class); + + private IcebergPartitionUtils() { + } + + // The iceberg (>= 1.5) ValidationException message emitted by PartitionSpec.partitionType() for a partition + // field whose SOURCE column was later DROPPED (partition evolution). Both the bind-time partition listing + // (listPartitions) and the scan planning (IcebergScanPlanProvider) treat EXACTLY this failure as recoverable + // — any OTHER ValidationException propagates. (Pre-1.5 iceberg threw a raw NullPointerException here instead; + // the connector is pinned to a newer iceberg, so only the ValidationException form is handled.) + private static final String DROPPED_SOURCE_COLUMN_SIGNATURE = "Cannot find source column for partition field"; + + /** + * Whether {@code e} is the iceberg partition-evolution failure raised when a partition field's source column + * was dropped (see {@link #DROPPED_SOURCE_COLUMN_SIGNATURE}). Package-private so the scan provider shares the + * single signature definition. + */ + static boolean isDroppedPartitionSourceColumn(ValidationException e) { + String message = e.getMessage(); + return message != null && message.contains(DROPPED_SOURCE_COLUMN_SIGNATURE); + } + + /** + * Ordered, case-preserved, de-duplicated list of the identity partition column names across all + * partition specs of the table (mirrors legacy {@code IcebergUtils.getIdentityPartitionColumns}). This + * is the {@code path_partition_keys} payload: it tells FE which slots are partition columns so they are + * excluded from the file-decode set (the CI #968880 double-fill guard). Non-identity transforms + * (bucket/truncate/year/month/...) are excluded. + */ + static List getIdentityPartitionColumns(Table table) { + LinkedHashSet partitionColumns = new LinkedHashSet<>(); + for (PartitionSpec spec : table.specs().values()) { + for (PartitionField partitionField : spec.fields()) { + if (!partitionField.transform().isIdentity()) { + continue; + } + String columnName = table.schema().findColumnName(partitionField.sourceId()); + if (columnName != null) { + partitionColumns.add(columnName); + } + } + } + return new ArrayList<>(partitionColumns); + } + + /** + * Per-file map of identity partition column (case-preserved) to serialized value, skipping non-identity + * transforms and BINARY/FIXED columns (utf8 round-trip would corrupt those). Order-preserving + * (LinkedHashMap, spec field order). Mirrors legacy {@code IcebergUtils.getIdentityPartitionInfoMap}. + */ + static Map getIdentityPartitionInfoMap(PartitionData partitionData, + PartitionSpec partitionSpec, Table table, ZoneId zone) { + Map partitionInfoMap = new LinkedHashMap<>(); + List fields = partitionData.getPartitionType().asNestedType().fields(); + List partitionFields = partitionSpec.fields(); + Preconditions.checkArgument(fields.size() == partitionFields.size(), + "PartitionData fields size does not match PartitionSpec fields size"); + + for (int i = 0; i < fields.size(); i++) { + NestedField field = fields.get(i); + PartitionField partitionField = partitionFields.get(i); + if (!partitionField.transform().isIdentity()) { + continue; + } + TypeID partitionTypeId = field.type().typeId(); + if (partitionTypeId == TypeID.BINARY || partitionTypeId == TypeID.FIXED) { + continue; + } + + String columnName = table.schema().findColumnName(partitionField.sourceId()); + if (columnName == null) { + continue; + } + Object value = partitionData.get(i); + try { + partitionInfoMap.put(columnName, + serializePartitionValue(field.type(), value, zone)); + } catch (UnsupportedOperationException e) { + LOG.warn("Failed to serialize Iceberg table partition value for field {}: {}", field.name(), + e.getMessage()); + } + } + return partitionInfoMap; + } + + /** + * The serialized value for every partition field (identity + transform), in spec order, used for + * {@code partition_data_json}. Mirrors legacy {@code IcebergUtils.getPartitionValues}. A field whose + * value cannot be serialized (BINARY/FIXED) yields a {@code null} entry (not dropped) to keep positional + * alignment with the spec. + */ + static List getPartitionValues(PartitionData partitionData, PartitionSpec partitionSpec, ZoneId zone) { + List fields = partitionData.getPartitionType().asNestedType().fields(); + Preconditions.checkArgument(fields.size() == partitionSpec.fields().size(), + "PartitionData fields size does not match PartitionSpec fields size"); + + List partitionValues = new ArrayList<>(fields.size()); + for (int i = 0; i < fields.size(); i++) { + NestedField field = fields.get(i); + Object value = partitionData.get(i); + try { + partitionValues.add(serializePartitionValue(field.type(), value, zone)); + } catch (UnsupportedOperationException e) { + LOG.warn("Failed to serialize Iceberg partition value for field {}: {}", field.name(), + e.getMessage()); + partitionValues.add(null); + } + } + return partitionValues; + } + + /** + * The {@code partition_data_json} string: a JSON array of the serialized partition values. Rendered via + * iceberg's bundled Jackson (see class javadoc) instead of fe-core Gson — BE re-parses it, so the value + * content is identical. + */ + static String getPartitionDataJson(PartitionData partitionData, PartitionSpec partitionSpec, ZoneId zone) { + List partitionValues = getPartitionValues(partitionData, partitionSpec, zone); + try { + return JsonUtil.mapper().writeValueAsString(partitionValues); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + throw new RuntimeException("Failed to serialize iceberg partition data to JSON, error message is:" + + e.getMessage(), e); + } + } + + /** + * A private mapper copy whose node factory keeps {@link BigDecimal} scale EXACT. Stock Jackson's + * {@code JsonNodeFactory} has {@code bigDecimalExact=false}, so {@code valueToTree(new BigDecimal("1.50"))} + * renders {@code 1.5} and {@code valueToTree(new BigDecimal("10"))} renders {@code 1E+1} — the latter is a + * JSON syntax change, not just lost scale. Legacy fe-core rendered these through Gson, which is + * scale-exact, so without this the connector would diverge from master on DECIMAL-partitioned tables + * (verified empirically against gson 2.10.1 / jackson 2.16.0, design doc T0.1). + * + *

    MUST be a {@code copy()}: {@link JsonUtil#mapper()} is an iceberg-core process-wide static shared by + * all iceberg REST/metadata serialization, and mutating its node factory would corrupt unrelated paths. + * Hoisted to a constant because {@code copy()} allocates a mapper per call. + */ + private static final ObjectMapper EXACT_DECIMAL_MAPPER = + JsonUtil.mapper().copy().setNodeFactory(JsonNodeFactory.withExactBigDecimals(true)); + + /** + * The {@code partition_data_json} for a {@code $position_deletes} scan range. Port of legacy + * {@code IcebergScanNode.getPartitionDataObjectJson} (upstream #65135). + * + *

    Unlike {@link #getPartitionDataJson} — which emits a JSON array of strings for the data path — + * this emits a flat JSON object keyed by the {@code $position_deletes} metadata table's + * {@code partition} struct field names, with type-native values (numbers/booleans unquoted). The two + * shapes travel in the SAME thrift field ({@code TIcebergFileDesc.partition_data_json}) on different paths; + * they are not interchangeable. BE does not parse this as JSON at all — it feeds it to Doris's STRUCT text + * serde, which requires a leading '{', splits on ':'/',' and lower-cases keys before matching field names. + * Critically, {@code DataTypeNullableSerDe::from_string} SWALLOWS any parse error into a NULL partition and + * returns OK, so a wrong shape here is silent wrong data, never an error (design doc §2.2). + * + *

    Values are resolved BY ICEBERG PARTITION FIELD ID, never by position: the metadata table reassigns + * partition field ids and rebuilds each spec via {@code BaseMetadataTable.transformSpec}, so a delete + * file's own spec is a subset of the output union type. Positional mapping would silently bind the wrong + * value under partition evolution and survive a rename with a stale name. A field absent from the writing + * spec is simply not put, and BE materializes the missing key as NULL. + * + *

    Deliberate, documented divergence from legacy: Gson drops null members (so an absent value yields + * {@code {}}), Jackson renders {@code {"p":null}}. BE reaches the same NULL either way — the literal 4-byte + * {@code null} token and a missing key both hit {@code insert_default()} (design doc T0.1). + * + * @param outputPartitionFields the metadata table's {@code partition} struct fields, in output order + * @param enableMappingVarbinary the catalog's {@code enable.mapping.varbinary}; when set, UUID maps to + * VARBINARY and therefore cannot round-trip through this text transport either + * @throws DorisConnectorException on a non-null BINARY/FIXED (or UUID under varbinary mapping) partition + * value — fail loud rather than silently materialize it as NULL + */ + static String getPartitionDataObjectJson(PartitionData partitionData, PartitionSpec partitionSpec, + List outputPartitionFields, boolean enableMappingVarbinary, ZoneId zone) { + List partitionTypes = partitionData.getPartitionType().asNestedType().fields(); + for (int i = 0; i < partitionTypes.size(); i++) { + Type type = partitionTypes.get(i).type(); + if (partitionData.get(i) != null && (type.typeId() == TypeID.BINARY + || type.typeId() == TypeID.FIXED + || (type.typeId() == TypeID.UUID && enableMappingVarbinary))) { + throw new DorisConnectorException( + "Iceberg position_deletes cannot materialize non-null partition field '" + + partitionTypes.get(i).name() + "' of type " + type + + " without a binary-safe partition transport"); + } + } + List partitionValues = getPartitionValues(partitionData, partitionSpec, zone); + Map partitionValueByFieldId = new HashMap<>(); + List fields = partitionSpec.fields(); + for (int i = 0; i < fields.size(); i++) { + partitionValueByFieldId.put(fields.get(i).fieldId(), + getPartitionJsonValue(partitionTypes.get(i).type(), partitionValues.get(i))); + } + ObjectNode partitionJson = EXACT_DECIMAL_MAPPER.createObjectNode(); + for (NestedField outputPartitionField : outputPartitionFields) { + partitionJson.set(outputPartitionField.name(), + EXACT_DECIMAL_MAPPER.valueToTree( + partitionValueByFieldId.get(outputPartitionField.fieldId()))); + } + try { + return EXACT_DECIMAL_MAPPER.writeValueAsString(partitionJson); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + throw new DorisConnectorException("Failed to serialize iceberg position_deletes partition data" + + " to JSON, error message is:" + e.getMessage(), e); + } + } + + /** + * Re-type a serialized partition value for the {@code partition_data_json} object, so the emitted JSON + * carries a native number/boolean rather than a quoted string. Port of legacy + * {@code IcebergScanNode.getPartitionJsonValue}. BE's struct serde does strip quotes off values, so a + * quoted number would often work by accident — but the default format options set + * {@code converted_from_string=false}, so do not rely on it; match legacy and emit native types. + * + *

    BINARY/FIXED never reach here — {@link #getPartitionDataObjectJson} rejects them up front. + */ + private static Object getPartitionJsonValue(Type type, String partitionValue) { + if (partitionValue == null) { + return null; + } + switch (type.typeId()) { + case BOOLEAN: + return Boolean.parseBoolean(partitionValue); + case INTEGER: + return Integer.parseInt(partitionValue); + case LONG: + return Long.parseLong(partitionValue); + case FLOAT: + return Float.parseFloat(partitionValue); + case DOUBLE: + return Double.parseDouble(partitionValue); + case DECIMAL: + return new BigDecimal(partitionValue); + case STRING: + case UUID: + case DATE: + case TIME: + case TIMESTAMP: + return partitionValue; + default: + return partitionValue; + } + } + + /** + * Faithful port of legacy {@code IcebergUtils.serializePartitionValue}: render a single partition value + * to its string form, dispatching on the iceberg {@link Type}. {@code null} values pass through as + * {@code null}; BINARY/FIXED throw {@link UnsupportedOperationException} (a utf8 round-trip would corrupt + * the bytes). Package-private for direct parity testing. + */ + static String serializePartitionValue(Type type, Object value, ZoneId zone) { + switch (type.typeId()) { + case BOOLEAN: + case INTEGER: + case LONG: + case STRING: + case UUID: + case DECIMAL: + if (value == null) { + return null; + } + return value.toString(); + case FLOAT: + if (value == null) { + return null; + } + return Float.toString((Float) value); + case DOUBLE: + if (value == null) { + return null; + } + return Double.toString((Double) value); + // BINARY / FIXED are intentionally unsupported: returning a utf8 string may corrupt the data. + case DATE: + if (value == null) { + return null; + } + // Iceberg date is stored as days since epoch (1970-01-01). + return LocalDate.ofEpochDay((Integer) value).format(DateTimeFormatter.ISO_LOCAL_DATE); + case TIME: + if (value == null) { + return null; + } + // Iceberg time is stored as microseconds since midnight. + long micros = (Long) value; + return LocalTime.ofNanoOfDay(micros * 1000).format(DateTimeFormatter.ISO_LOCAL_TIME); + case TIMESTAMP: + if (value == null) { + return null; + } + // Iceberg timestamp is stored as microseconds since epoch (1970-01-01T00:00:00). + long timestampMicros = (Long) value; + LocalDateTime timestamp = LocalDateTime.ofEpochSecond( + timestampMicros / 1_000_000, (int) (timestampMicros % 1_000_000) * 1000, ZoneOffset.UTC); + // timestamptz when shouldAdjustToUTC() — render the stored UTC instant in the session zone. + if (((TimestampType) type).shouldAdjustToUTC()) { + timestamp = timestamp.atZone(ZoneOffset.UTC).withZoneSameInstant(zone).toLocalDateTime(); + } + return timestamp.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + default: + throw new UnsupportedOperationException("Unsupported type for serializePartitionValue: " + type); + } + } + + // Canonical partition-timestamp format ("yyyy-MM-dd HH:mm:ss" with an optional micro/nano fraction), + // the form BE renders human-readable partition values in. Legacy parsed via the nereids multi-format + // DateLiteral.parseDateTime (connector-forbidden); the canonical form is the only one BE emits, so this + // single formatter is equivalent in practice (DV-T04-c). Mirrors the scan-side IcebergTimeUtils tradeoff. + private static final DateTimeFormatter TIMESTAMP_PARTITION_FORMAT = new DateTimeFormatterBuilder() + .appendPattern("yyyy-MM-dd HH:mm:ss") + .optionalStart() + .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true) + .optionalEnd() + .toFormatter(); + + /** + * Faithful port of legacy {@code IcebergUtils.parsePartitionValueFromString}: the write-direction inverse + * of {@link #serializePartitionValue} — BE sends a human-readable partition string, this converts it to + * the iceberg internal partition object (DATE -> epoch-day Integer, TIMESTAMP -> epoch-micros Long, + * etc.) for {@link PartitionData}. {@code null} passes through as {@code null}. + * + *

    Two documented deltas vs legacy (DV-T04-c/-f): the TIMESTAMP case parses the canonical format with an + * explicit resolved {@code zone} (legacy used the multi-format nereids parser + a thread-local zone), and + * FLOAT/DOUBLE normalize Doris's {@code nan}/{@code inf}/{@code infinity} spellings before parsing.

    + */ + static Object parsePartitionValueFromString(String valueStr, Type icebergType, ZoneId zone) { + if (valueStr == null) { + return null; + } + try { + switch (icebergType.typeId()) { + case STRING: + return valueStr; + case INTEGER: + return Integer.parseInt(valueStr); + case LONG: + return Long.parseLong(valueStr); + case FLOAT: + return Float.parseFloat(normalizeFloatingPointPartitionValue(valueStr)); + case DOUBLE: + return Double.parseDouble(normalizeFloatingPointPartitionValue(valueStr)); + case BOOLEAN: + return Boolean.parseBoolean(valueStr); + case DATE: + // Iceberg date is days since epoch (1970-01-01). + return (int) LocalDate.parse(valueStr, DateTimeFormatter.ISO_LOCAL_DATE).toEpochDay(); + case TIMESTAMP: + return parseTimestampToMicros(valueStr, (TimestampType) icebergType, zone); + case DECIMAL: + return new BigDecimal(valueStr); + default: + throw new IllegalArgumentException("Unsupported partition value type: " + icebergType); + } + } catch (Exception e) { + throw new IllegalArgumentException(String.format( + "Failed to convert partition value '%s' to type %s", valueStr, icebergType), e); + } + } + + private static String normalizeFloatingPointPartitionValue(String valueStr) { + if ("nan".equalsIgnoreCase(valueStr)) { + return "NaN"; + } + if ("inf".equalsIgnoreCase(valueStr) || "+inf".equalsIgnoreCase(valueStr) + || "infinity".equalsIgnoreCase(valueStr) || "+infinity".equalsIgnoreCase(valueStr)) { + return "Infinity"; + } + if ("-inf".equalsIgnoreCase(valueStr) || "-infinity".equalsIgnoreCase(valueStr)) { + return "-Infinity"; + } + return valueStr; + } + + private static long parseTimestampToMicros(String valueStr, TimestampType timestampType, ZoneId sessionZone) { + LocalDateTime ldt = LocalDateTime.parse(valueStr, TIMESTAMP_PARTITION_FORMAT); + // timestamptz (shouldAdjustToUTC): interpret the wall-clock string in the session zone; plain timestamp: + // interpret it in UTC. Mirrors legacy parseTimestampToMicros (DateUtils.getTimeZone vs ZoneId.of("UTC")). + ZoneId zone = timestampType.shouldAdjustToUTC() ? sessionZone : ZoneOffset.UTC; + Instant instant = ldt.atZone(zone).toInstant(); + return instant.getEpochSecond() * 1_000_000L + instant.getNano() / 1000L; + } + + /** + * Faithful port of legacy {@code IcebergUtils.parsePartitionValuesFromJson}: parse a + * {@code partition_data_json} array (the inverse of {@link #getPartitionDataJson}) back to its list of + * serialized partition value strings. Rendered/parsed via iceberg's bundled Jackson rather than fe-core + * Gson (DV-T04-d) — the JSON array of strings is byte-identical either way. A blank input or a parse + * failure yields an empty list (legacy parity). + */ + static List parsePartitionValuesFromJson(String partitionDataJson) { + if (partitionDataJson == null || partitionDataJson.trim().isEmpty()) { + return new ArrayList<>(); + } + try { + return JsonUtil.mapper().readValue(partitionDataJson, new TypeReference>() {}); + } catch (Exception e) { + LOG.warn("Failed to parse partition data JSON: {}", partitionDataJson, e); + return new ArrayList<>(); + } + } + + // ─────────────────────────── B-2: MTMV partition enumeration (RANGE view) ─────────────────────────── + // Self-contained port of the legacy fe-core iceberg MTMV partition logic — the connector cannot import + // fe-core, so it emits a NEUTRAL ConnectorMvccPartitionView (pre-rendered string bounds + resolved + // snapshot-id freshness) and the generic PluginDrivenMvccExternalTable assembles the RangePartitionItems. + // Source: master IcebergExternalTable.isValidRelatedTable / getPartitionSnapshot + IcebergUtils + // .loadPartitionInfo / loadIcebergPartition / generateIcebergPartition / getPartitionRange / + // mergeOverlapPartitions + IcebergPartitionInfo.getLatestSnapshotId. + + private static final String YEAR = "year"; + private static final String MONTH = "month"; + private static final String DAY = "day"; + private static final String HOUR = "hour"; + + // Iceberg partition field id starts at PARTITION_DATA_ID_START (org.apache.iceberg.PartitionSpec). + private static final int PARTITION_DATA_ID_START = 1000; + // Master IcebergUtils.UNKNOWN_SNAPSHOT_ID: an empty table / a null last_updated_snapshot_id row. + private static final long UNKNOWN_SNAPSHOT_ID = -1; + + private static final DateTimeFormatter RANGE_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter RANGE_DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + // Sort by partition-range LOW ascending; ties broken by HIGH descending (larger range first), so an + // enclosing partition precedes the ones it encloses. Parity with master IcebergUtils.RangeComparator. + private static final Comparator RANGE_COMPARATOR = (p1, p2) -> { + int cmpLow = p1.lower.compareTo(p2.lower); + return cmpLow == 0 ? p2.upper.compareTo(p1.upper) : cmpLow; + }; + + /** + * Builds the connector-supplied {@link ConnectorMvccPartitionView} for an iceberg table acting as an MTMV + * related (base) table. Port of master {@code IcebergExternalTable.getPartitionType} + + * {@code IcebergUtils.loadPartitionInfo}: the eligibility gate decides RANGE vs UNPARTITIONED; the PARTITIONS + * metadata table is enumerated at {@code pinnedSnapshotId} (or the table's CURRENT snapshot when it is + * {@code < 0}); transform math yields each partition's {@code [lower, upper)} range; overlapping + * (partition-evolution) ranges are merged; per-partition freshness is the resolved iceberg snapshot id. Must + * be invoked inside {@code context.executeAuthenticated} (the PARTITIONS scan is a remote read). + * + * @param pinnedSnapshotId the query's pinned snapshot id (so the partition set + freshness stay consistent + * with the data-scan pin — the caller threads {@code beginQuerySnapshot}'s snapshot through the + * handle), or {@code < 0} to enumerate at the table's current (latest) snapshot. + */ + static ConnectorMvccPartitionView buildMvccPartitionView(Table table, long pinnedSnapshotId) { + return buildMvccPartitionView(table, pinnedSnapshotId, null, null); + } + + /** + * Cache-aware overload (PERF-02): {@code id} + {@code cache} route the PARTITIONS scan through the + * per-catalog {@link IcebergPartitionCache} keyed by {@code (id, resolvedSnapshotId)}. {@code cache == null} + * reads live (offline tests / no-cache catalog). + */ + static ConnectorMvccPartitionView buildMvccPartitionView(Table table, long pinnedSnapshotId, + TableIdentifier id, IcebergPartitionCache cache) { + if (!isValidRelatedTable(table)) { + return ConnectorMvccPartitionView.unpartitioned(); + } + long snapshotId; + if (pinnedSnapshotId >= 0) { + snapshotId = pinnedSnapshotId; + } else { + Snapshot current = table.currentSnapshot(); + if (current == null) { + // A valid related table that is still empty (no snapshot yet): RANGE on the spec alone, with no + // partitions. Parity: master getPartitionType=RANGE (spec-only) + getIcebergPartitionItems empty. + // Newest-update-time 0 mirrors master's getNewestUpdateVersionOrTime max(...).orElse(0) over an + // empty partition set. + return new ConnectorMvccPartitionView(ConnectorMvccPartitionView.Style.RANGE, + ConnectorMvccPartitionView.Freshness.SNAPSHOT_ID, Collections.emptyList(), 0L); + } + snapshotId = current.snapshotId(); + } + // The freshness fallback base = the enumeration snapshot (master uses snapshotValue.getSnapshot() + // .getSnapshotId(), which is the same snapshot the partition info is loaded at). + long tableSnapshotId = snapshotId; + // The gate guarantees a single, stable source column across all specs, so any spec's field-0 sourceId + // resolves the same source column; its iceberg type drives DATE-vs-DATETIME bound rendering. + int sourceId = table.spec().fields().get(0).sourceId(); + Type sourceType = table.schema().findField(sourceId).type(); + + // Deduplicate by partition name (last-wins) BEFORE the merge, exactly like master, which keys both + // nameToIcebergPartition and nameToPartitionItem by name (IcebergUtils.loadPartitionInfo) so a name can + // never enclose its own twin. The overlap merge below MUST run over this deduped set (not the raw rows): + // two rows rendering the same field=value name have byte-identical ranges, and feeding both to the merge + // would make the name self-enclose and be dropped (0 partitions where master keeps 1). + Map allByName = new LinkedHashMap<>(); + for (IcebergRawPartition raw : loadRawPartitions(id, table, snapshotId, cache)) { + RangeBuild rb = buildRange(raw.name, raw.values.get(0), raw.transforms.get(0), sourceType, + raw.lastUpdateTime, raw.lastSnapshotId); + allByName.put(rb.name, rb); + } + + Set survivors = new LinkedHashSet<>(allByName.keySet()); + Map> mergeMap = + mergeOverlapPartitions(new ArrayList<>(allByName.values()), survivors); + + List partitions = new ArrayList<>(survivors.size()); + for (RangeBuild rb : allByName.values()) { + if (!survivors.contains(rb.name)) { + continue; // enclosed by another partition -> merged away (master removes from originPartitions) + } + long latest = latestSnapshotId(rb.name, mergeMap, allByName); + // Parity master getPartitionSnapshot: partition snapshot id <= 0 falls back to the table snapshot + // id (always > 0 here — the empty-table case returned above), so no "table snapshot also invalid" + // throw is reachable for a non-empty table. + long freshness = latest > 0 ? latest : tableSnapshotId; + partitions.add(new ConnectorMvccPartition(rb.name, rb.lowerBound, rb.upperBound, freshness)); + } + // Deterministic order (the generic model re-keys by name; sorting only stabilizes tests/diagnostics). + partitions.sort(Comparator.comparing(ConnectorMvccPartition::getName)); + // The table's newest data-update time = max(lastUpdateTime) over the FULL deduped partition set + // (allByName, NOT just survivors — master uses getNameToIcebergPartition() which keeps enclosed + // partitions too). Byte-parity with master getNewestUpdateVersionOrTime: max(...).orElse(0). + long newestUpdateTimeMillis = allByName.values().stream() + .mapToLong(rb -> rb.lastUpdateTime).max().orElse(0L); + return new ConnectorMvccPartitionView(ConnectorMvccPartitionView.Style.RANGE, + ConnectorMvccPartitionView.Freshness.SNAPSHOT_ID, partitions, newestUpdateTimeMillis); + } + + /** + * The raw iceberg partition display names ({@code "f1=v1/f2=v2"}) of {@code table} at its CURRENT snapshot, + * for SHOW PARTITIONS (single-column form). Unlike {@link #buildMvccPartitionView} this is NOT gated on the + * MTMV eligibility rules — it lists the physical partitions of ANY partitioned iceberg table. An + * unpartitioned or empty table yields an empty list. Must run inside {@code context.executeAuthenticated}. + */ + static List listPartitionNames(Table table) { + return listPartitionNames(table, null, null); + } + + /** Cache-aware overload (PERF-02): see {@link #buildMvccPartitionView(Table, long, TableIdentifier, + * IcebergPartitionCache)}. Keyed by the table's CURRENT snapshot id. */ + static List listPartitionNames(Table table, TableIdentifier id, IcebergPartitionCache cache) { + if (table.spec().isUnpartitioned()) { + return Collections.emptyList(); + } + Snapshot current = table.currentSnapshot(); + if (current == null) { + return Collections.emptyList(); + } + List raws = loadRawPartitions(id, table, current.snapshotId(), cache); + List names = new ArrayList<>(raws.size()); + for (IcebergRawPartition raw : raws) { + names.add(raw.name); + } + return names; + } + + /** + * The physical iceberg partitions of {@code table} at its CURRENT snapshot with per-partition metadata, + * for the generic {@code ConnectorMetadata.listPartitions} SPI hook. Each {@link ConnectorPartitionInfo} + * carries the display name ({@code "f1=v1/f2=v2"}) and a value map keyed by the partition-field SOURCE + * column name (case-preserved) so {@code PluginDrivenExternalTable.getNameToPartitionItems} can index it by + * the generic partition-column remote name. Unlike the MTMV {@link #buildMvccPartitionView} this is NOT gated on + * the MTMV eligibility rules — it enumerates ANY partitioned iceberg table so the generic node can report a + * real {@code selectedPartitionNum} (EXPLAIN {@code partition=N/M} + SQL-block-rule {@code partition_num} + * enforcement). An unpartitioned or empty table yields an empty list. Must run inside + * {@code context.executeAuthenticated}. + */ + static List listPartitions(Table table) { + return listPartitions(table, null, null); + } + + /** Cache-aware overload (PERF-02): see {@link #buildMvccPartitionView(Table, long, TableIdentifier, + * IcebergPartitionCache)}. Keyed by the table's CURRENT snapshot id. */ + static List listPartitions(Table table, TableIdentifier id, IcebergPartitionCache cache) { + if (table.spec().isUnpartitioned()) { + return Collections.emptyList(); + } + Snapshot current = table.currentSnapshot(); + if (current == null) { + return Collections.emptyList(); + } + List raws; + try { + raws = loadRawPartitions(id, table, current.snapshotId(), cache); + } catch (ValidationException e) { + if (!isDroppedPartitionSourceColumn(e)) { + // A different iceberg validation error (not the dropped-partition-source-column case) — fail loud. + throw e; + } + // Partition evolution can leave a HISTORICAL spec referencing a source column that was later + // DROPPED. Building the PARTITIONS metadata table unifies the partition type across ALL specs, and + // iceberg PartitionSpec.partitionType() throws ValidationException ("Cannot find source column for + // partition field: ...") for the orphaned field. This list is DISPLAY/enforcement metadata only + // (selectedPartitionNum + partition_num block-rule), never the read set — a full-table scan must not + // fail just because the partition display cannot be computed. Degrade to UNPARTITIONED display (empty + // list); the data scan is unaffected. Only this specific class is swallowed: an auth/IO failure is a + // different exception type and still propagates from loadRawPartitions. + LOG.warn("Cannot list partitions for iceberg table {}: a partition field's source column was dropped " + + "(partition evolution); reporting UNPARTITIONED. Cause: {}", table.name(), e.getMessage()); + return Collections.emptyList(); + } + List partitions = new ArrayList<>(raws.size()); + for (IcebergRawPartition raw : raws) { + Map values = new LinkedHashMap<>(); + // Ordered values aligned to raw.name's segments; supplied so fe-core skips the name parse. + // String.valueOf keeps byte-parity with the legacy parse, which reads a null field rendered + // into the name as the literal "null" (StringBuilder append of a null Object). + List orderedValues = new ArrayList<>(raw.columnNames.size()); + for (int i = 0; i < raw.columnNames.size(); ++i) { + values.put(raw.columnNames.get(i), raw.values.get(i)); + orderedValues.add(String.valueOf(raw.values.get(i))); + } + partitions.add(new ConnectorPartitionInfo(raw.name, values, Collections.emptyMap(), + orderedValues, Collections.emptyList())); + } + return partitions; + } + + /** + * Port of master {@code IcebergExternalTable.isValidRelatedTable}: an iceberg table is a valid MTMV related + * table iff EVERY partition spec has exactly one field whose transform is {@code year}/{@code month}/{@code + * day}/{@code hour}, and the partition source column is stable across partition evolution (a single distinct + * source column over all specs). Failure -> the connector reports an UNPARTITIONED view. + */ + static boolean isValidRelatedTable(Table table) { + Set allFields = new HashSet<>(); + for (PartitionSpec spec : table.specs().values()) { + if (spec == null) { + return false; + } + List fields = spec.fields(); + if (fields.size() != 1) { + return false; + } + PartitionField partitionField = fields.get(0); + String transformName = partitionField.transform().toString(); + if (!YEAR.equals(transformName) && !MONTH.equals(transformName) + && !DAY.equals(transformName) && !HOUR.equals(transformName)) { + return false; + } + allFields.add(table.schema().findColumnName(partitionField.sourceId())); + } + return allFields.size() == 1; + } + + /** + * Port of master {@code IcebergUtils.loadIcebergPartition} + {@code generateIcebergPartition}: scan the + * PARTITIONS metadata table at {@code snapshotId} and reduce each row to an {@link IcebergRawPartition}. + * {@code last_updated_at} (row 9) / {@code last_updated_snapshot_id} (row 10) are optional, so a missing + * value (NPE on the typed getter) degrades to {@code 0} / {@code UNKNOWN_SNAPSHOT_ID}, exactly like master. + */ + /** + * The cross-query PARTITIONS-scan de-duplication seam (PERF-02): when {@code cache} is non-null the raw + * partition list is served from / populated into the per-catalog {@link IcebergPartitionCache} keyed by + * {@code (id, snapshotId)} — a snapshot is immutable, so the derived partitions are a pure function of that + * key and safe to reuse across queries (restoring the legacy IcebergExternalMetaCache partition-info cache). + * A {@code null} cache (offline unit tests / the no-cache catalog) reads live every call. The cached list is + * unmodifiable so a shared entry cannot be mutated by a concurrent reader; the loader's exception (e.g. the + * dropped-partition-source-column {@link ValidationException}) propagates verbatim so callers keep their own + * degradation, and a failed scan is not cached. + */ + private static List loadRawPartitions(TableIdentifier id, Table table, long snapshotId, + IcebergPartitionCache cache) { + if (cache == null) { + return loadRawPartitionsUncached(table, snapshotId); + } + return cache.getOrLoad(new IcebergPartitionCache.Key(id, snapshotId), + () -> Collections.unmodifiableList(loadRawPartitionsUncached(table, snapshotId))); + } + + private static List loadRawPartitionsUncached(Table table, long snapshotId) { + Table partitionsTable = MetadataTableUtils.createMetadataTableInstance(table, MetadataTableType.PARTITIONS); + List partitions = new ArrayList<>(); + try (CloseableIterable tasks = partitionsTable.newScan().useSnapshot(snapshotId).planFiles()) { + for (FileScanTask task : tasks) { + CloseableIterable rows = task.asDataTask().rows(); + for (StructLike row : rows) { + partitions.add(generateRawPartition(table, row)); + } + } + } catch (IOException e) { + LOG.warn("Failed to get Iceberg table {} partition info.", table.name(), e); + } + return partitions; + } + + private static IcebergRawPartition generateRawPartition(Table table, StructLike row) { + // PARTITIONS row layout: 0 partitionData, 1 spec_id, 2 record_count, 3 file_count, + // 4 total_data_file_size_in_bytes, 5..8 position/equality delete stats, 9 last_updated_at, + // 10 last_updated_snapshot_id. Only 0/1/9/10 are needed by the MTMV partition view. + Preconditions.checkState(!table.spec().fields().isEmpty(), table.name() + " is not a partition table."); + int specId = row.get(1, Integer.class); + PartitionSpec partitionSpec = table.specs().get(specId); + StructProjection partitionData = row.get(0, StructProjection.class); + StringBuilder sb = new StringBuilder(); + List partitionColumnNames = new ArrayList<>(); + List partitionValues = new ArrayList<>(); + List transforms = new ArrayList<>(); + for (int i = 0; i < partitionSpec.fields().size(); ++i) { + PartitionField partitionField = partitionSpec.fields().get(i); + Class fieldClass = partitionSpec.javaClasses()[i]; + int fieldId = partitionField.fieldId(); + // Iceberg partition field id starts at PARTITION_DATA_ID_START, so the index into partitionData is + // fieldId - PARTITION_DATA_ID_START. + int index = fieldId - PARTITION_DATA_ID_START; + Object o = partitionData.get(index, fieldClass); + String fieldValue = o == null ? null : o.toString(); + sb.append(partitionField.name()).append("=").append(fieldValue).append("/"); + // Resolve the partition field's SOURCE column name (case-preserved), matching the generic + // "partition_columns" contract in IcebergConnectorMetadata.buildTableSchema; fall back to the + // field name for a source-less field (e.g. void transform). #65094: these are the + // ConnectorPartitionInfo value-map keys (listPartitions) that getNameToPartitionItems looks up by + // the case-preserved partition_columns remote name, so they MUST carry the same case (else a + // mixed-case partition column's value is dropped). + NestedField source = table.schema().findField(partitionField.sourceId()); + partitionColumnNames.add(source != null ? source.name() : partitionField.name()); + partitionValues.add(fieldValue); + transforms.add(partitionField.transform().toString()); + } + if (sb.length() > 0) { + sb.delete(sb.length() - 1, sb.length()); + } + long lastUpdateTime; + long lastSnapshotId; + try { + lastUpdateTime = row.get(9, Long.class); + } catch (NullPointerException e) { + lastUpdateTime = 0; + } + try { + lastSnapshotId = row.get(10, Long.class); + } catch (NullPointerException e) { + lastSnapshotId = UNKNOWN_SNAPSHOT_ID; + } + return new IcebergRawPartition(sb.toString(), partitionColumnNames, partitionValues, transforms, + lastUpdateTime, lastSnapshotId); + } + + /** + * Port of master {@code IcebergUtils.getPartitionRange}, but emits PRE-RENDERED string bounds (fe-core owns + * {@code PartitionKey}). The {@code [lower, upper)} {@link LocalDateTime} interval is kept for the overlap + * merge; the string bounds are rendered with the partition source column's date/datetime form. A NULL + * partition value yields lower {@code "0000-01-01"} and an EMPTY upper bound — the signal for the generic + * model to derive the exclusive upper as {@code lowerKey.successor()} (which is column-type/scale aware and + * lives in fe-core), matching master's {@code nullLowKey.successor()}. + */ + static RangeBuild buildRange(String name, String value, String transform, Type sourceType, + long lastUpdateTime, long lastSnapshotId) { + if (value == null) { + LocalDateTime nullLower = LocalDateTime.of(0, 1, 1, 0, 0, 0); + return new RangeBuild(name, nullLower, nullLower.plusDays(1), + Collections.singletonList("0000-01-01"), Collections.emptyList(), + lastUpdateTime, lastSnapshotId); + } + LocalDateTime epoch = Instant.EPOCH.atZone(ZoneId.of("UTC")).toLocalDateTime(); + long longValue = Long.parseLong(value); + LocalDateTime target; + LocalDateTime lower; + LocalDateTime upper; + switch (transform) { + case HOUR: + target = epoch.plusHours(longValue); + lower = LocalDateTime.of(target.getYear(), target.getMonth(), target.getDayOfMonth(), + target.getHour(), 0, 0); + upper = lower.plusHours(1); + break; + case DAY: + target = epoch.plusDays(longValue); + lower = LocalDateTime.of(target.getYear(), target.getMonth(), target.getDayOfMonth(), 0, 0, 0); + upper = lower.plusDays(1); + break; + case MONTH: + target = epoch.plusMonths(longValue); + lower = LocalDateTime.of(target.getYear(), target.getMonth(), 1, 0, 0, 0); + upper = lower.plusMonths(1); + break; + case YEAR: + target = epoch.plusYears(longValue); + lower = LocalDateTime.of(target.getYear(), Month.JANUARY, 1, 0, 0, 0); + upper = lower.plusYears(1); + break; + default: + throw new RuntimeException("Unsupported transform " + transform); + } + // Master renders the bound with the Doris partition-column type (the source column): iceberg DATE -> + // "yyyy-MM-dd", iceberg TIMESTAMP/TIMESTAMPTZ -> "yyyy-MM-dd HH:mm:ss" (HOUR's source is always a + // timestamp). Equivalent to master's c.getType().isDate()||isDateV2() formatter switch. + DateTimeFormatter formatter = sourceType.typeId() == TypeID.DATE ? RANGE_DATE_FORMAT : RANGE_DATETIME_FORMAT; + return new RangeBuild(name, lower, upper, + Collections.singletonList(lower.format(formatter)), + Collections.singletonList(upper.format(formatter)), + lastUpdateTime, lastSnapshotId); + } + + /** + * Port of master {@code IcebergUtils.mergeOverlapPartitions}: merge an enclosed partition's range into the + * enclosing one (a partition-evolution DAY range inside a MONTH range becomes one Doris partition). Removes + * the enclosed names from {@code survivors} (mutated in place, mirroring master's {@code originPartitions + * .remove}) and returns the enclosing-name -> {enclosing + enclosed names} map used to resolve the merged + * partition's freshness. The merge is on aligned time {@link LocalDateTime} intervals, equivalent to + * master's {@code Range.encloses} (year/month/day/hour ranges never partially intersect). + */ + static Map> mergeOverlapPartitions(List builds, Set survivors) { + List entries = new ArrayList<>(builds); + entries.sort(RANGE_COMPARATOR); + Map> map = new HashMap<>(); + for (int i = 0; i < entries.size() - 1; i++) { + RangeBuild first = entries.get(i); + String firstKey = first.name; + RangeBuild second = entries.get(i + 1); + String secondKey = second.name; + while (i < entries.size() && encloses(first, second)) { + survivors.remove(secondKey); + map.putIfAbsent(firstKey, new HashSet<>(Collections.singleton(firstKey))); + final String finalSecondKey = secondKey; + map.computeIfPresent(firstKey, (key, value) -> { + value.add(finalSecondKey); + return value; + }); + i++; + if (i >= entries.size() - 1) { + break; + } + second = entries.get(i + 1); + secondKey = second.name; + } + } + return map; + } + + /** Closed-open {@code a} encloses {@code b} iff {@code a.lower <= b.lower && b.upper <= a.upper}. */ + private static boolean encloses(RangeBuild a, RangeBuild b) { + return !a.lower.isAfter(b.lower) && !b.upper.isAfter(a.upper); + } + + /** + * Port of master {@code IcebergPartitionInfo.getLatestSnapshotId}: for a merged (enclosing) partition, the + * snapshot id of the most-recently-updated iceberg partition in its merge set (skipping {@code <= 0} update + * times); for a standalone partition, its own last snapshot id. The lookup uses {@code allByName} (the FULL + * set), NOT the survivor set — master keeps {@code nameToIcebergPartition} complete and only prunes the item + * map, so an enclosed partition's snapshot id is still resolvable here. + */ + static long latestSnapshotId(String name, Map> mergeMap, + Map allByName) { + Set mergedNames = mergeMap.get(name); + if (mergedNames == null) { + return allByName.get(name).lastSnapshotId; + } + long latestSnapshotId = -1; + long latestUpdateTime = -1; + for (String mergedName : mergedNames) { + RangeBuild partition = allByName.get(mergedName); + long lastUpdateTime = partition.lastUpdateTime; + // Skip partitions with invalid update time (<= 0 means unknown/invalid). + if (lastUpdateTime <= 0) { + continue; + } + if (latestUpdateTime < lastUpdateTime) { + latestUpdateTime = lastUpdateTime; + latestSnapshotId = partition.lastSnapshotId; + } + } + return latestSnapshotId; + } + + /** One PARTITIONS-metadata-table row reduced to what the MTMV partition view needs (port of IcebergPartition). */ + static final class IcebergRawPartition { + private final String name; + // Partition-field SOURCE column names (lowercased), parallel to {@link #values}, so listPartitions can + // build a value map keyed by the generic partition-column remote name (see IcebergConnectorMetadata + // buildTableSchema's "partition_columns" derivation). + private final List columnNames; + private final List values; + private final List transforms; + private final long lastUpdateTime; + private final long lastSnapshotId; + + IcebergRawPartition(String name, List columnNames, List values, List transforms, + long lastUpdateTime, long lastSnapshotId) { + this.name = name; + this.columnNames = columnNames; + this.values = values; + this.transforms = transforms; + this.lastUpdateTime = lastUpdateTime; + this.lastSnapshotId = lastSnapshotId; + } + } + + /** A single physical partition's computed range: time interval (for the overlap merge) + pre-rendered bounds. */ + static final class RangeBuild { + private final String name; + private final LocalDateTime lower; + private final LocalDateTime upper; + private final List lowerBound; + private final List upperBound; // empty => NULL-min partition; fe-core derives lower.successor() + private final long lastUpdateTime; + private final long lastSnapshotId; + + RangeBuild(String name, LocalDateTime lower, LocalDateTime upper, List lowerBound, + List upperBound, long lastUpdateTime, long lastSnapshotId) { + this.name = name; + this.lower = lower; + this.upper = upper; + this.lowerBound = lowerBound; + this.upperBound = upperBound; + this.lastUpdateTime = lastUpdateTime; + this.lastSnapshotId = lastSnapshotId; + } + + List getLowerBound() { + return lowerBound; + } + + List getUpperBound() { + return upperBound; + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPredicateConverter.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPredicateConverter.java new file mode 100644 index 00000000000000..137c0f4a41a5f4 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPredicateConverter.java @@ -0,0 +1,900 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorBetween; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorIn; +import org.apache.doris.connector.api.pushdown.ConnectorIsNull; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.ConnectorNot; +import org.apache.doris.connector.api.pushdown.ConnectorOr; + +import org.apache.iceberg.Schema; +import org.apache.iceberg.expressions.And; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.expressions.Not; +import org.apache.iceberg.expressions.Or; +import org.apache.iceberg.expressions.Unbound; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Type.TypeID; +import org.apache.iceberg.types.Types; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Converts a {@link ConnectorExpression} tree into iceberg {@link Expression} objects for predicate + * pushdown, mirroring the paimon connector's {@code PaimonPredicateConverter}. This is a self-contained + * port of fe-core {@code IcebergUtils.convertToIcebergExpr} (the iceberg-side mapping + the + * {@code extractDorisLiteral} type matrix + the {@code checkConversion} bind-test); it consumes the + * engine-neutral {@code ConnectorExpression} (produced by fe-core {@code ExprToConnectorExpressionConverter}) + * instead of a Doris {@code Expr}, so it imports only {@code connector.api.pushdown} + {@code org.apache.iceberg}. + * + *

    Handled node set mirrors legacy {@code convertToIcebergExpr} exactly: AND/OR/NOT, comparisons + * (EQ/NE/LT/LE/GT/GE/EQ_FOR_NULL, column-op-literal only), IN/NOT-IN, and a bare boolean literal + * (alwaysTrue/alwaysFalse). {@code ConnectorIsNull}/{@code ConnectorLike}/{@code ConnectorBetween}/ + * {@code ConnectorFunctionCall} are dropped (legacy has no such case; IS NULL is still pushed via + * EQ_FOR_NULL + null literal). Anything that cannot be translated yields {@code null} and is left to BE + * residual filtering — a safe over-approximation (the filter never removes rows that should match).

    + * + *

    A second mode — selected by {@code Mode.CONFLICT} (P6.3-T07b) — builds the iceberg expression for + * write-time optimistic conflict detection (O5-2) instead of scan pushdown. It is a faithful port of + * legacy {@code IcebergConflictDetectionFilterUtils.convertPredicateToIcebergExpression}, a strictly different + * matrix: it additionally pushes {@code ConnectorIsNull} / {@code ConnectorBetween}, restricts + * {@code ConnectorNot} to {@code NOT(IS NULL)}, guards {@code ConnectorOr} to a single column, applies + * structural/UUID guards, and drops NE / bare booleans. Scan mode (the default) is unchanged.

    + * + *

    A third mode — {@code Mode.REWRITE} (P6.6-FIX-H9) — lowers the {@code WHERE} of {@code rewrite_data_files} + * (compaction file scoping). It mirrors legacy {@code IcebergNereidsUtils.convertNereidsToIcebergExpression}: + * the broad scan matrix (cross-column {@code OR}, any-child {@code NOT}, {@code NE}, {@code IN}) plus the + * node-emitted {@code IS NULL} / {@code BETWEEN} (which the rewrite-side neutral converter produces directly), + * but strictly all-or-nothing — a rewrite {@code WHERE} is a user-authored data scope with no downstream + * re-filter, so any unrepresentable sub-node collapses the whole expression to {@code null} (the rewrite planner + * then fails loud rather than silently widening the set of files rewritten). Unlike conflict mode it keeps + * cross-column {@code OR} / {@code NOT(comparison)} / {@code NE} and drops the structural/UUID narrowing.

    + */ +public class IcebergPredicateConverter { + + private static final Logger LOG = LogManager.getLogger(IcebergPredicateConverter.class); + + // v3 row-lineage metadata columns are never pushable (mirror IcebergUtils.getPushdownField). + private static final String ICEBERG_ROW_ID_COL = "_row_id"; + private static final String ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL = "_last_updated_sequence_number"; + + private final Schema schema; + private final ZoneId sessionZone; + // The conversion matrix. SCAN = scan-time pushdown (BE re-filters, so widening is safe). CONFLICT = O5-2 + // write-time optimistic conflict detection (no-missed-conflict, so widening is also safe) -- a port of + // IcebergConflictDetectionFilterUtils. REWRITE = rewrite_data_files file scoping (no downstream re-filter, + // so strictly all-or-nothing/precise) -- mirrors IcebergNereidsUtils.convertNereidsToIcebergExpression. The + // shared leaf helpers (getPushdownField / extractIcebergLiteral / toMicros / checkConversion) are reused. + private final Mode mode; + + /** Conversion matrix selector. See the field comment and the class javadoc. */ + public enum Mode { SCAN, CONFLICT, REWRITE } + + public IcebergPredicateConverter(Schema schema, ZoneId sessionZone) { + this(schema, sessionZone, Mode.SCAN); + } + + // Back-compat boolean constructor (scan vs conflict); REWRITE is selected via the Mode constructor. + public IcebergPredicateConverter(Schema schema, ZoneId sessionZone, boolean conflictMode) { + this(schema, sessionZone, conflictMode ? Mode.CONFLICT : Mode.SCAN); + } + + public IcebergPredicateConverter(Schema schema, ZoneId sessionZone, Mode mode) { + this.schema = schema; + this.sessionZone = sessionZone == null ? ZoneOffset.UTC : sessionZone; + this.mode = mode; + } + + /** + * Convert a {@link ConnectorExpression} tree into a list of iceberg {@link Expression}s. Top-level AND + * nodes are flattened into the list and each top-level conjunct is built + bind-checked independently — + * mirroring the legacy {@code IcebergScanNode.createTableScan} per-conjunct {@code scan.filter(...)} loop + * (iceberg ANDs multiple {@code filter()} calls internally). Unconvertible conjuncts are silently dropped. + */ + public List convert(ConnectorExpression expr) { + List results = new ArrayList<>(); + if (expr == null) { + return results; + } + if (expr instanceof ConnectorAnd) { + for (ConnectorExpression child : ((ConnectorAnd) expr).getConjuncts()) { + Expression e = convertSingle(child); + if (e != null) { + results.add(e); + } + } + } else { + Expression e = convertSingle(expr); + if (e != null) { + results.add(e); + } + } + return results; + } + + /** + * Build + bind-check one expression, mirroring legacy {@code convertToIcebergExpr} whose every recursive + * call returns a {@code checkConversion}'d result. Folding the bind-check into the recursion (rather than + * only at the top) is load-bearing: it makes a nested {@code (a AND b_unbindable) OR c} degrade the bad + * leaf to {@code a OR c} (legacy parity) instead of carrying an unbindable predicate into the OR. + */ + private Expression convertSingle(ConnectorExpression expr) { + return checkConversion(build(expr)); + } + + private Expression build(ConnectorExpression expr) { + if (expr == null) { + return null; + } + if (mode == Mode.CONFLICT) { + return buildConflict(expr); + } + if (mode == Mode.REWRITE) { + return buildRewrite(expr); + } + if (expr instanceof ConnectorLiteral) { + return buildBoolLiteral((ConnectorLiteral) expr); + } else if (expr instanceof ConnectorAnd) { + return buildAnd((ConnectorAnd) expr); + } else if (expr instanceof ConnectorOr) { + return buildOr((ConnectorOr) expr); + } else if (expr instanceof ConnectorNot) { + return buildNot((ConnectorNot) expr); + } else if (expr instanceof ConnectorComparison) { + return buildComparison((ConnectorComparison) expr); + } else if (expr instanceof ConnectorIn) { + return buildIn((ConnectorIn) expr); + } + return null; + } + + // A bare boolean literal -> alwaysTrue / alwaysFalse (legacy BoolLiteral path); anything else dropped. + private Expression buildBoolLiteral(ConnectorLiteral literal) { + Object value = literal.getValue(); + if (value instanceof Boolean) { + return ((Boolean) value) ? Expressions.alwaysTrue() : Expressions.alwaysFalse(); + } + return null; + } + + // AND composition. SCAN/CONFLICT degrade -- drop unbindable arms, keep the pushable subset (widening is safe + // there). REWRITE is all-or-nothing: a single unconvertible arm collapses the whole AND to null, so the + // rewrite planner's guard turns it into a hard error rather than silently widening the set of files rewritten. + private Expression buildAnd(ConnectorAnd and) { + Expression result = null; + for (ConnectorExpression child : and.getConjuncts()) { + Expression c = convertSingle(child); + if (c == null) { + if (mode == Mode.REWRITE) { + return null; + } + continue; + } + result = (result == null) ? c : Expressions.and(result, c); + } + return result; + } + + // OR is all-or-nothing: any unpushable disjunct collapses the whole OR (dropping an arm widens results). + private Expression buildOr(ConnectorOr or) { + Expression result = null; + for (ConnectorExpression child : or.getDisjuncts()) { + Expression c = convertSingle(child); + if (c == null) { + return null; + } + result = (result == null) ? c : Expressions.or(result, c); + } + return result; + } + + private Expression buildNot(ConnectorNot not) { + Expression child = convertSingle(not.getOperand()); + return child == null ? null : Expressions.not(child); + } + + private Expression buildComparison(ConnectorComparison cmp) { + ConnectorExpression left = cmp.getLeft(); + ConnectorExpression right = cmp.getRight(); + // Column-op-literal only (drop reversed `literal OP col` and col-col). Nereids normalizes comparisons + // so the column is on the left; dropping the rest is a safe over-approximation. + if (!(left instanceof ConnectorColumnRef) || !(right instanceof ConnectorLiteral)) { + return null; + } + Types.NestedField field = getPushdownField(((ConnectorColumnRef) left).getColumnName()); + if (field == null) { + return null; + } + String colName = field.name(); + ConnectorLiteral literal = (ConnectorLiteral) right; + Object value = extractIcebergLiteral(field.type(), literal); + if (value == null) { + // Only EQ_FOR_NULL (col <=> NULL) survives a null value -> IS NULL; everything else is dropped. + if (cmp.getOperator() == ConnectorComparison.Operator.EQ_FOR_NULL && literal.isNull()) { + return Expressions.isNull(colName); + } + return null; + } + switch (cmp.getOperator()) { + case EQ: + case EQ_FOR_NULL: + return Expressions.equal(colName, value); + case NE: + return Expressions.not(Expressions.equal(colName, value)); + case GE: + return Expressions.greaterThanOrEqual(colName, value); + case GT: + return Expressions.greaterThan(colName, value); + case LE: + return Expressions.lessThanOrEqual(colName, value); + case LT: + return Expressions.lessThan(colName, value); + default: + return null; + } + } + + private Expression buildIn(ConnectorIn in) { + if (!(in.getValue() instanceof ConnectorColumnRef)) { + return null; + } + Types.NestedField field = getPushdownField(((ConnectorColumnRef) in.getValue()).getColumnName()); + if (field == null) { + return null; + } + String colName = field.name(); + List values = new ArrayList<>(); + for (ConnectorExpression item : in.getInList()) { + if (!(item instanceof ConnectorLiteral)) { + return null; + } + Object value = extractIcebergLiteral(field.type(), (ConnectorLiteral) item); + if (value == null) { + // A single unconvertible element drops the whole IN/NOT-IN (legacy parity). + return null; + } + values.add(value); + } + return in.isNegated() ? Expressions.notIn(colName, values) : Expressions.in(colName, values); + } + + private Types.NestedField getPushdownField(String colName) { + if (ICEBERG_ROW_ID_COL.equalsIgnoreCase(colName) + || ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL.equalsIgnoreCase(colName)) { + return null; + } + return schema.caseInsensitiveFindField(colName); + } + + /** + * Port of legacy {@code IcebergUtils.extractDorisLiteral}: maps a literal's plain Java value (carried by + * {@link ConnectorLiteral}, produced by {@code ExprToConnectorExpressionConverter}) to the iceberg-typed + * value accepted by {@code Expressions.*}, gated by the (Doris-source-type x iceberg-type) matrix. Returns + * {@code null} when the pair is not pushable. The Doris source primitive (needed to tell int32 from int64 + * and float from double, both flattened to one Java type) is read from {@link ConnectorLiteral#getType()}. + */ + private Object extractIcebergLiteral(Type icebergType, ConnectorLiteral literal) { + if (literal.isNull()) { + return null; + } + Object value = literal.getValue(); + TypeID id = icebergType.typeId(); + if (value instanceof Boolean) { + switch (id) { + case BOOLEAN: + return value; + case STRING: + // BoolLiteral.getStringValue() == "1"/"0". + return ((Boolean) value) ? "1" : "0"; + default: + return null; + } + } else if (value instanceof LocalDate) { + LocalDate date = (LocalDate) value; + switch (id) { + case STRING: + case DATE: + return date.toString(); + case TIMESTAMP: + return toMicros(date.atStartOfDay(), icebergType); + default: + return null; + } + } else if (value instanceof LocalDateTime) { + LocalDateTime dateTime = (LocalDateTime) value; + switch (id) { + case STRING: + case DATE: + return dorisDateTimeString(dateTime); + case TIMESTAMP: + return toMicros(dateTime, icebergType); + default: + return null; + } + } else if (value instanceof BigDecimal) { + BigDecimal decimal = (BigDecimal) value; + switch (id) { + case DECIMAL: + return decimal; + case STRING: + return decimal.toString(); + case FLOAT: + // Nereids types an unsuffixed decimal literal (e.g. 90.0) as a BigDecimal, so a FLOAT-column + // predicate lands here, not in the Double/Float branch. Mirror that branch: only REWRITE may + // push it (file scoping with no downstream re-filter -> the decimal->float narrowing can only + // shrink the rewrite set, which is safe); SCAN/CONFLICT omit it to avoid wrongly pruning + // matching rows. Restores legacy IcebergNereidsUtils rewrite behaviour (column-type FLOAT). + return mode == Mode.REWRITE ? decimal.doubleValue() : null; + case DOUBLE: + return decimal.doubleValue(); + default: + return null; + } + } else if (value instanceof Double || value instanceof Float) { + double doubleValue = ((Number) value).doubleValue(); + if (isFloatType(literal)) { + switch (id) { + case FLOAT: + case DOUBLE: + case DECIMAL: + return doubleValue; + default: + return null; + } + } + switch (id) { + case FLOAT: + // Only REWRITE may push a (non-float-typed) double/decimal literal onto a FLOAT column: + // it scopes file rewriting with no downstream re-filter, so the double->float narrowing + // can only shrink the rewrite set (a file merely goes un-rewritten), which is safe. + // SCAN/CONFLICT keep omitting it -- an incorrect prune there would silently drop matching + // rows (BE re-filters a widened scan, but cannot recover a wrongly-pruned file). Restores + // legacy IcebergNereidsUtils rewrite behaviour (case FLOAT -> floatValue()). + return mode == Mode.REWRITE ? doubleValue : null; + case DOUBLE: + case DECIMAL: + return doubleValue; + default: + return null; + } + } else if (value instanceof Long || value instanceof Integer) { + long longValue = ((Number) value).longValue(); + if (isInteger32(literal)) { + switch (id) { + case INTEGER: + case LONG: + case FLOAT: + case DOUBLE: + case DATE: + case DECIMAL: + return (int) longValue; + default: + return null; + } + } + switch (id) { + case INTEGER: + case LONG: + case FLOAT: + case DOUBLE: + case TIME: + case TIMESTAMP: + case DATE: + case DECIMAL: + return longValue; + default: + return null; + } + } else if (value instanceof String) { + String string = (String) value; + switch (id) { + case TIMESTAMP: + if (mode == Mode.SCAN) { + // Legacy IcebergUtils.extractDorisLiteral returns the raw string for a TIMESTAMP column and + // lets the iceberg bind-check accept only a well-formed ISO timestamp; a date-only or + // space-separated Doris string is then dropped. Returning the raw string keeps that scan + // parity -- a VARCHAR must not push onto a timestamp column any wider than the legacy + // IcebergScanNode did. (Normal scans coerce the literal to a DateTimeLiteral -> the + // LocalDateTime branch, so this raw-string path is only the uncoerced case.) + return string; + } + // REWRITE / CONFLICT are not type-coerced, so a quoted datetime literal arrives here as a raw + // Doris-format string ("yyyy-MM-dd HH:mm:ss[.SSSSSS]", or a date-only string). Iceberg's own + // string->timestamp bind expects ISO-8601 (with 'T') and rejects the space-separated form, so + // parse it to zone-adjusted epoch micros ourselves (via toMicros, honoring timestamptz), + // mirroring legacy IcebergNereidsUtils' string branch -- which both the rewrite planner and the + // conflict-detection util (IcebergConflictDetectionFilterUtils) delegate to. Null (drop) when + // not a parseable datetime. + LocalDateTime parsedTs = parseDorisDateTime(string); + return parsedTs == null ? null : toMicros(parsedTs, icebergType); + case DATE: + case TIME: + case STRING: + case UUID: + case DECIMAL: + return string; + case INTEGER: + try { + return Integer.parseInt(string); + } catch (Exception e) { + return null; + } + case LONG: + try { + return Long.parseLong(string); + } catch (Exception e) { + return null; + } + default: + return null; + } + } + return null; + } + + private static boolean isFloatType(ConnectorLiteral literal) { + return "FLOAT".equals(literal.getType().getTypeName().toUpperCase(Locale.ROOT)); + } + + // Mirror Type.isInteger32Type(): TINYINT / SMALLINT / INT. BIGINT (and anything else) is treated as 64-bit. + private static boolean isInteger32(ConnectorLiteral literal) { + String name = literal.getType().getTypeName().toUpperCase(Locale.ROOT); + return "TINYINT".equals(name) || "SMALLINT".equals(name) || "INT".equals(name); + } + + // Epoch micros of a wall-clock datetime, interpreted in the session zone for zone-adjusted timestamps + // (timestamptz) or UTC otherwise (mirrors legacy DateLiteral.getUnixTimestampWithMicroseconds). + private long toMicros(LocalDateTime dateTime, Type icebergType) { + ZoneId zone = ((Types.TimestampType) icebergType).shouldAdjustToUTC() ? sessionZone : ZoneOffset.UTC; + Instant instant = dateTime.atZone(zone).toInstant(); + return instant.getEpochSecond() * 1_000_000L + instant.getNano() / 1000L; + } + + // Parse a Doris-format datetime string ("yyyy-MM-dd HH:mm:ss[.fraction]") -- or a date-only string, taken + // at start-of-day -- to a LocalDateTime, or null when unparseable. Normalizes the space separator to ISO + // 'T' so java.time's ISO parsers accept the Doris rendering (mirrors legacy IcebergNereidsUtils' string -> + // timestamp path, which parsed the literal itself rather than handing the raw string to iceberg). + private static LocalDateTime parseDorisDateTime(String value) { + String iso = value.trim().replace(' ', 'T'); + try { + return LocalDateTime.parse(iso); + } catch (RuntimeException ignored) { + // not a full datetime -- fall through to date-only + } + try { + return LocalDate.parse(iso).atStartOfDay(); + } catch (RuntimeException ignored) { + return null; + } + } + + // Best-effort Doris-style "yyyy-MM-dd HH:mm:ss[.SSSSSS]" (DateLiteral.getStringValue). Only reached for a + // datetime literal compared to a STRING/DATE iceberg column; the DATE case fails the bind-check and drops. + private static String dorisDateTimeString(LocalDateTime dateTime) { + StringBuilder sb = new StringBuilder(26); + appendPadded(sb, dateTime.getYear(), 4); + sb.append('-'); + appendPadded(sb, dateTime.getMonthValue(), 2); + sb.append('-'); + appendPadded(sb, dateTime.getDayOfMonth(), 2); + sb.append(' '); + appendPadded(sb, dateTime.getHour(), 2); + sb.append(':'); + appendPadded(sb, dateTime.getMinute(), 2); + sb.append(':'); + appendPadded(sb, dateTime.getSecond(), 2); + int micros = dateTime.getNano() / 1000; + if (micros > 0) { + sb.append('.'); + appendPadded(sb, micros, 6); + } + return sb.toString(); + } + + private static void appendPadded(StringBuilder sb, int value, int width) { + String s = Integer.toString(value); + for (int i = s.length(); i < width; i++) { + sb.append('0'); + } + sb.append(s); + } + + /** + * Port of legacy {@code IcebergUtils.checkConversion}: validates that an assembled (unbound) expression + * actually binds to the schema, returning the still-unbound expression on success or {@code null} on + * failure. AND keeps the bindable arm in SCAN/CONFLICT (widening is safe there) but is all-or-nothing in + * REWRITE (a half-bindable AND -- e.g. a BETWEEN whose upper bound is a bindable-but-malformed temporal + * string -- must collapse so the rewrite planner fails loud, never degrade to the surviving arm and silently + * widen the file set); OR/NOT are all-or-nothing; TRUE/FALSE always pass; leaf predicates are bound + * (case-sensitive) and dropped if the bind throws (e.g. out-of-range literal). + */ + private Expression checkConversion(Expression expression) { + if (expression == null) { + return null; + } + switch (expression.op()) { + case AND: { + And andExpr = (And) expression; + Expression left = checkConversion(andExpr.left()); + Expression right = checkConversion(andExpr.right()); + if (left != null && right != null) { + return andExpr; + } else if (mode == Mode.REWRITE) { + // all-or-nothing: a single unbindable arm fails the whole AND (no silent widen for rewrite). + return null; + } else if (left != null) { + return left; + } else if (right != null) { + return right; + } else { + return null; + } + } + case OR: { + Or orExpr = (Or) expression; + Expression left = checkConversion(orExpr.left()); + Expression right = checkConversion(orExpr.right()); + if (left == null || right == null) { + return null; + } + return orExpr; + } + case NOT: { + Not notExpr = (Not) expression; + Expression child = checkConversion(notExpr.child()); + return child == null ? null : notExpr; + } + case TRUE: + case FALSE: + return expression; + default: + if (!(expression instanceof Unbound)) { + return null; + } + try { + ((Unbound) expression).bind(schema.asStruct(), true); + return expression; + } catch (Exception e) { + LOG.debug("Failed to check expression: {}", e.getMessage()); + return null; + } + } + } + + // ==================================================================================================== + // Conflict-mode (O5-2 write-time conflict detection). Port of legacy + // IcebergConflictDetectionFilterUtils.convertPredicateToIcebergExpression. Reached only when + // conflictMode == true; the scan dispatch above is untouched. Recursive children flow back through + // convertSingle -> build -> here, so the conflict matrix applies all the way down. + // ==================================================================================================== + + private Expression buildConflict(ConnectorExpression expr) { + if (expr instanceof ConnectorAnd) { + // AND keeps the bindable subset (dropping an arm only widens the conflict filter -> safe). + return buildAnd((ConnectorAnd) expr); + } else if (expr instanceof ConnectorOr) { + return buildConflictOr((ConnectorOr) expr); + } else if (expr instanceof ConnectorNot) { + return buildConflictNot((ConnectorNot) expr); + } else if (expr instanceof ConnectorIsNull) { + ConnectorIsNull isNull = (ConnectorIsNull) expr; + return buildConflictIsNull(isNull.getOperand(), isNull.isNegated()); + } else if (expr instanceof ConnectorIn) { + return buildConflictIn((ConnectorIn) expr); + } else if (expr instanceof ConnectorBetween) { + return buildConflictBetween((ConnectorBetween) expr); + } else if (expr instanceof ConnectorComparison) { + return buildConflictComparison((ConnectorComparison) expr); + } + // bare boolean literal / LIKE / function call: not in the legacy conflict matrix -> dropped. + return null; + } + + // OR is pushed only when every disjunct binds AND all disjuncts reference the same single column (legacy + // isSameColumnPredicate). A cross-column OR is dropped: pushing it would narrow the filter (missed conflict). + private Expression buildConflictOr(ConnectorOr or) { + Expression result = null; + int commonFieldId = 0; + boolean haveField = false; + for (ConnectorExpression child : or.getDisjuncts()) { + Expression c = convertSingle(child); + if (c == null) { + return null; + } + Types.NestedField field = resolveConflictField(child); + if (field == null) { + return null; + } + if (!haveField) { + commonFieldId = field.fieldId(); + haveField = true; + } else if (commonFieldId != field.fieldId()) { + return null; + } + result = (result == null) ? c : Expressions.or(result, c); + } + return result; + } + + // NOT is pushed only for NOT(IS NULL) -> not(isNull); legacy restricts NOT to an IS NULL child. + private Expression buildConflictNot(ConnectorNot not) { + if (!(not.getOperand() instanceof ConnectorIsNull)) { + return null; + } + ConnectorIsNull inner = (ConnectorIsNull) not.getOperand(); + return buildConflictIsNull(inner.getOperand(), !inner.isNegated()); + } + + private Expression buildConflictIsNull(ConnectorExpression operand, boolean negated) { + if (!(operand instanceof ConnectorColumnRef)) { + return null; + } + Types.NestedField field = getPushdownField(((ConnectorColumnRef) operand).getColumnName()); + if (field == null || isStructural(field.type())) { + return null; + } + Expression isNull = Expressions.isNull(field.name()); + return negated ? Expressions.not(isNull) : isNull; + } + + private Expression buildConflictIn(ConnectorIn in) { + if (in.isNegated() || !(in.getValue() instanceof ConnectorColumnRef)) { + return null; + } + Types.NestedField field = getPushdownField(((ConnectorColumnRef) in.getValue()).getColumnName()); + if (field == null) { + return null; + } + Type type = field.type(); + if (isStructural(type)) { + return null; + } + boolean hasNull = false; + List values = new ArrayList<>(); + for (ConnectorExpression item : in.getInList()) { + if (!(item instanceof ConnectorLiteral)) { + return null; + } + ConnectorLiteral literal = (ConnectorLiteral) item; + if (literal.isNull()) { + hasNull = true; + continue; + } + Object value = extractIcebergLiteral(type, literal); + if (value == null) { + // a single unconvertible element drops the whole IN (legacy parity) + return null; + } + values.add(value); + } + if (isUuid(type) && !values.isEmpty()) { + return null; + } + Expression valuesExpr = values.isEmpty() ? null : Expressions.in(field.name(), values); + Expression nullExpr = hasNull ? Expressions.isNull(field.name()) : null; + return combineOr(nullExpr, valuesExpr); + } + + private Expression buildConflictBetween(ConnectorBetween between) { + if (!(between.getValue() instanceof ConnectorColumnRef)) { + return null; + } + Types.NestedField field = getPushdownField(((ConnectorColumnRef) between.getValue()).getColumnName()); + if (field == null) { + return null; + } + Type type = field.type(); + if (isStructural(type) || isUuid(type)) { + return null; + } + if (!(between.getLower() instanceof ConnectorLiteral) + || !(between.getUpper() instanceof ConnectorLiteral)) { + return null; + } + Object lo = extractIcebergLiteral(type, (ConnectorLiteral) between.getLower()); + Object hi = extractIcebergLiteral(type, (ConnectorLiteral) between.getUpper()); + if (lo == null || hi == null) { + return null; + } + String colName = field.name(); + return Expressions.and( + Expressions.greaterThanOrEqual(colName, lo), Expressions.lessThanOrEqual(colName, hi)); + } + + private Expression buildConflictComparison(ConnectorComparison cmp) { + if (!(cmp.getLeft() instanceof ConnectorColumnRef) || !(cmp.getRight() instanceof ConnectorLiteral)) { + // column-op-literal only (the neutral converter normalises the column to the left). + return null; + } + Types.NestedField field = getPushdownField(((ConnectorColumnRef) cmp.getLeft()).getColumnName()); + if (field == null) { + return null; + } + Type type = field.type(); + if (isStructural(type)) { + return null; + } + String colName = field.name(); + ConnectorLiteral literal = (ConnectorLiteral) cmp.getRight(); + if (isUuid(type)) { + // UUID is comparable only as `col = NULL` -> IS NULL; every other UUID comparison is dropped. + return cmp.getOperator() == ConnectorComparison.Operator.EQ && literal.isNull() + ? Expressions.isNull(colName) : null; + } + if (literal.isNull()) { + // mirror legacy convertNereidsBinaryPredicate: any of EQ/GT/GE/LT/LE against NULL -> IS NULL. + switch (cmp.getOperator()) { + case EQ: + case GT: + case GE: + case LT: + case LE: + return Expressions.isNull(colName); + default: + return null; + } + } + Object value = extractIcebergLiteral(type, literal); + if (value == null) { + return null; + } + switch (cmp.getOperator()) { + case EQ: + return Expressions.equal(colName, value); + case GT: + return Expressions.greaterThan(colName, value); + case GE: + return Expressions.greaterThanOrEqual(colName, value); + case LT: + return Expressions.lessThan(colName, value); + case LE: + return Expressions.lessThanOrEqual(colName, value); + default: + // NE / EQ_FOR_NULL are not part of the legacy conflict matrix -> dropped. + return null; + } + } + + // Resolve the single iceberg field a sub-expression references (legacy resolveSingleField). Returns null + // when the sub-expression references zero or multiple distinct columns, or a non-pushable column. + private Types.NestedField resolveConflictField(ConnectorExpression expr) { + Set columns = new LinkedHashSet<>(); + collectColumnNames(expr, columns); + if (columns.size() != 1) { + return null; + } + return getPushdownField(columns.iterator().next()); + } + + private void collectColumnNames(ConnectorExpression expr, Set out) { + if (expr instanceof ConnectorColumnRef) { + out.add(((ConnectorColumnRef) expr).getColumnName()); + return; + } + for (ConnectorExpression child : expr.getChildren()) { + collectColumnNames(child, out); + } + } + + // ==================================================================================================== + // Rewrite-mode (rewrite_data_files file scoping, P6.6-FIX-H9). Mirrors legacy + // IcebergNereidsUtils.convertNereidsToIcebergExpression: the broad scan matrix (cross-column OR, any-child + // NOT, NE, IN) plus the node-emitted IS NULL / BETWEEN, but strictly all-or-nothing (precise or null, never + // widen) -- a rewrite WHERE has no downstream re-filter, so a dropped node would rewrite MORE files than the + // user asked. The shared leaves (buildComparison / buildIn / getPushdownField / extractIcebergLiteral / + // checkConversion) and the already all-or-nothing buildOr / buildNot are reused; only AND (all-or-nothing via + // buildAnd) and IS NULL / BETWEEN (absent from the scan dispatch) are rewrite-specific. + // ==================================================================================================== + + private Expression buildRewrite(ConnectorExpression expr) { + if (expr instanceof ConnectorAnd) { + return buildAnd((ConnectorAnd) expr); + } else if (expr instanceof ConnectorOr) { + return buildOr((ConnectorOr) expr); + } else if (expr instanceof ConnectorNot) { + return buildNot((ConnectorNot) expr); + } else if (expr instanceof ConnectorComparison) { + return buildComparison((ConnectorComparison) expr); + } else if (expr instanceof ConnectorIn) { + return buildIn((ConnectorIn) expr); + } else if (expr instanceof ConnectorIsNull) { + return buildRewriteIsNull((ConnectorIsNull) expr); + } else if (expr instanceof ConnectorBetween) { + return buildRewriteBetween((ConnectorBetween) expr); + } + // bare boolean literal / LIKE / function call: master throws "Unsupported expression type" -> null here, + // which the planner guard turns into a hard error (never a silent widen). + return null; + } + + // IS NULL over a column (legacy convertNereidsToIcebergExpression IS NULL arm). No structural guard -- the + // bind-check drops a genuinely-unbindable form, mirroring master. IS NOT NULL arrives as Not(IsNull); a + // negated node is handled defensively all the same. + private Expression buildRewriteIsNull(ConnectorIsNull isNull) { + if (!(isNull.getOperand() instanceof ConnectorColumnRef)) { + return null; + } + Types.NestedField field = getPushdownField(((ConnectorColumnRef) isNull.getOperand()).getColumnName()); + if (field == null) { + return null; + } + Expression expr = Expressions.isNull(field.name()); + return isNull.isNegated() ? Expressions.not(expr) : expr; + } + + // BETWEEN col, lo, hi -> col >= lo AND col <= hi (legacy convertNereidsBetween). No structural/UUID guard; + // extractIcebergLiteral returning null (incl. struct/list/map columns) drops the node, mirroring master. + private Expression buildRewriteBetween(ConnectorBetween between) { + if (!(between.getValue() instanceof ConnectorColumnRef)) { + return null; + } + Types.NestedField field = getPushdownField(((ConnectorColumnRef) between.getValue()).getColumnName()); + if (field == null) { + return null; + } + if (!(between.getLower() instanceof ConnectorLiteral) + || !(between.getUpper() instanceof ConnectorLiteral)) { + return null; + } + Object lo = extractIcebergLiteral(field.type(), (ConnectorLiteral) between.getLower()); + Object hi = extractIcebergLiteral(field.type(), (ConnectorLiteral) between.getUpper()); + if (lo == null || hi == null) { + return null; + } + String colName = field.name(); + return Expressions.and( + Expressions.greaterThanOrEqual(colName, lo), Expressions.lessThanOrEqual(colName, hi)); + } + + private static Expression combineOr(Expression left, Expression right) { + if (left == null) { + return right; + } + if (right == null) { + return left; + } + return Expressions.or(left, right); + } + + private static boolean isStructural(Type type) { + return type.isStructType() || type.isListType() || type.isMapType(); + } + + private static boolean isUuid(Type type) { + return type.typeId() == TypeID.UUID; + } +} 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 new file mode 100644 index 00000000000000..ed5c1d2e2a4713 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergProcedureOps.java @@ -0,0 +1,234 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.procedure.ConnectorProcedureOps; +import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; +import org.apache.doris.connector.api.procedure.ConnectorRewriteGroup; +import org.apache.doris.connector.api.procedure.ProcedureExecutionMode; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.connector.iceberg.action.BaseIcebergAction; +import org.apache.doris.connector.iceberg.action.IcebergExecuteActionFactory; +import org.apache.doris.connector.iceberg.action.IcebergRewriteDataFilesAction; +import org.apache.doris.connector.iceberg.rewrite.RewriteDataFilePlanner; +import org.apache.doris.connector.iceberg.rewrite.RewriteDataGroup; +import org.apache.doris.connector.spi.ConnectorContext; + +import java.time.ZoneId; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Executes iceberg's {@code ALTER TABLE EXECUTE} procedures (the 9 legacy + * {@code datasource/iceberg/action/*} actions) behind the {@link ConnectorProcedureOps} SPI. + * + *

    Mirrors {@link IcebergWritePlanProvider}: a fresh instance per call over the lazily-built live + * catalog, threading the same {@code properties} / {@link IcebergCatalogOps} / {@link ConnectorContext} + * seams. The SDK table is loaded (inside {@code context.executeAuthenticated}) and the procedure body + * runs in the connector; argument validation is connector-local (the engine cannot reach + * {@code org.apache.doris.common.NamedArguments} across the import gate).

    + * + *

    T03 dispatch skeleton. {@link #getSupportedProcedures()} exports the factory's name list and + * {@link #execute} routes through {@link IcebergExecuteActionFactory} → {@link BaseIcebergAction}: validate + * arguments, load the SDK table inside {@code context.executeAuthenticated}, run the body and wrap the + * single row. The 9 procedure bodies (the factory's switch cases) are ported in T04 (the 8 pure-SDK + * procedures) / T05–T06 ({@code rewrite_data_files}); until then a known name reaches the factory's faithful + * "Unsupported Iceberg procedure" rejection. Inert pre-cutover regardless: iceberg tables are not + * {@code PluginDrivenExternalTable} until P6.6, so {@code ExecuteActionCommand} still routes them to the + * legacy fe-core actions and never reaches this class.

    + */ +public class IcebergProcedureOps implements ConnectorProcedureOps { + + // Catalog-level properties seam, retained for structural symmetry with IcebergWritePlanProvider. The + // per-procedure arguments arrive through execute()'s own {@code properties} parameter (the EXECUTE + // properties), so this field is not read here. + private final Map properties; + // Per-request catalog-ops resolver: applied with the current ConnectorSession to obtain the IcebergCatalogOps + // for that request. For a iceberg.rest.session=user catalog the connector passes this::newCatalogBackedOps so + // ALTER TABLE ... EXECUTE loads the target table through the querying user's per-request delegated REST catalog + // (fail-closed); every other catalog (and the offline-test ctor) resolves the single shared ops (s -> catalogOps). + private final Function catalogOpsResolver; + private final ConnectorContext context; + + public IcebergProcedureOps(Map properties, IcebergCatalogOps catalogOps, + ConnectorContext context) { + // Constant resolver: this ctor (offline tests + the pre-session connector path) binds a single ops that + // ignores the session, so existing behaviour/tests are byte-identical. + this(properties, session -> catalogOps, context); + } + + /** + * Session-aware ctor used by {@link IcebergConnector#getProcedureOps()}: {@code catalogOpsResolver} is applied + * per request with the current {@link ConnectorSession} so a {@code iceberg.rest.session=user} catalog resolves + * the querying user's per-request delegated catalog (the connector passes {@code this::newCatalogBackedOps}); + * every other catalog resolves the single shared ops. + */ + public IcebergProcedureOps(Map properties, + Function catalogOpsResolver, ConnectorContext context) { + this.properties = properties; + this.catalogOpsResolver = catalogOpsResolver; + this.context = context; + } + + @Override + public List getSupportedProcedures() { + return Arrays.asList(IcebergExecuteActionFactory.getSupportedActions()); + } + + /** + * {@code rewrite_data_files} is the one distributed procedure — it runs N per-group INSERT-SELECT writes + * under one shared transaction (the engine-side rewrite driver), so the engine must orchestrate it rather + * than dispatch it through {@link #execute}. Every other iceberg procedure is a synchronous SDK call + * ({@link ProcedureExecutionMode#SINGLE_CALL}). Case-insensitive to mirror the factory's + * {@code actionType.toLowerCase()} dispatch. + */ + @Override + public ProcedureExecutionMode getExecutionMode(String procedureName) { + return IcebergExecuteActionFactory.REWRITE_DATA_FILES.equalsIgnoreCase(procedureName) + ? ProcedureExecutionMode.DISTRIBUTED + : ProcedureExecutionMode.SINGLE_CALL; + } + + @Override + public ConnectorProcedureResult execute(ConnectorSession session, ConnectorTableHandle table, + String procedureName, Map properties, + ConnectorPredicate whereCondition, List partitionNames) { + IcebergTableHandle handle = (IcebergTableHandle) table; + // Build the procedure body (rejects unknown names) and validate its arguments before touching the + // catalog — the engine has already performed the ALTER privilege check (D-062 §2). + BaseIcebergAction action = IcebergExecuteActionFactory.createAction( + procedureName, properties, partitionNames, whereCondition); + action.validate(); + return runInAuthScope(handle, action, session); + } + + /** + * Plans {@code rewrite_data_files} (the one {@link ProcedureExecutionMode#DISTRIBUTED} iceberg procedure) + * into bin-packed groups for the engine rewrite driver (WS-REWRITE R3). Builds the rewrite action directly + * — the factory rejects {@code rewrite_data_files} for the single-call {@link #execute} path — validates its + * arguments, then runs the connector {@link RewriteDataFilePlanner} (the SDK-only planning half) and returns + * each group's data-file paths + stats in engine-neutral form. + */ + @Override + public List planRewrite(ConnectorSession session, ConnectorTableHandle table, + String procedureName, Map properties, + ConnectorPredicate whereCondition, List partitionNames) { + 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); + } + IcebergTableHandle handle = (IcebergTableHandle) table; + IcebergRewriteDataFilesAction action = new IcebergRewriteDataFilesAction( + properties, partitionNames, whereCondition); + action.validate(); + return planInAuthScope(handle, action, session); + } + + /** + * 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 + * catalogs), mirroring the write path — recon §7 "commit 裹 executeAuthenticated"; this is the auth fix + * the legacy fe-core actions lacked (the snapshot mutators ran their commit unauthenticated). + * + *

    The body's own {@link DorisConnectorException} (argument / procedure-body failures) surfaces + * verbatim — the engine command shell re-wraps it with the user-facing "Failed to execute action:" + * prefix when {@code ExecuteActionCommand} is rewired to this SPI at the iceberg cutover (T07). + * + *

    This method does NOT invalidate any cache. Cache invalidation is the engine's responsibility: after the + * procedure returns, {@code ConnectorExecuteAction} refreshes the mutated table through the standard + * refresh-table path — the only path that correctly drops BOTH the engine meta cache (keyed by the table's + * LOCAL names) and the connector's own per-table cache (keyed by the REMOTE names), resolving both name + * spaces from the engine's {@code ExternalTable}. The connector alone has only the REMOTE names and cannot + * reach the engine meta cache correctly, so it must not drive invalidation. {@code session} carries the time + * zone the {@code rollback_to_timestamp} body needs. + */ + private ConnectorProcedureResult runInAuthScope(IcebergTableHandle handle, BaseIcebergAction action, + ConnectorSession session) { + // Resolve the per-request ops before the auth scope so a session=user fail-closed surfaces the + // DorisConnectorException verbatim (not wrapped by executeAuthenticated's catch). + IcebergCatalogOps ops = catalogOpsResolver.apply(session); + ConnectorProcedureResult result; + if (context == null) { + result = action.execute(ops.loadTable(handle.getDbName(), handle.getTableName()), session); + } else { + try { + result = context.executeAuthenticated(() -> + action.execute(ops.loadTable(handle.getDbName(), handle.getTableName()), session)); + } catch (DorisConnectorException e) { + throw e; + } catch (Exception e) { + throw new DorisConnectorException("Failed to load iceberg table " + + handle.getDbName() + "." + handle.getTableName() + ": " + e.getMessage(), e); + } + } + return result; + } + + /** + * Loads the table and plans the rewrite groups within ONE authenticated scope (manifest reads need the + * catalog's auth context on Kerberized catalogs), mirroring {@link #runInAuthScope}. Unlike the procedure + * bodies, planning does NOT mutate the table or invalidate caches — it only reads the current snapshot's + * file scan tasks. + */ + private List planInAuthScope(IcebergTableHandle handle, + IcebergRewriteDataFilesAction action, ConnectorSession session) { + ZoneId sessionZone = IcebergTimeUtils.resolveSessionZone(session); + RewriteDataFilePlanner planner = new RewriteDataFilePlanner(action.buildRewriteParameters(), sessionZone); + // Resolve the per-request ops before the auth scope (see runInAuthScope): a session=user fail-closed + // surfaces verbatim. + IcebergCatalogOps ops = catalogOpsResolver.apply(session); + List groups; + if (context == null) { + groups = planner.planAndOrganizeTasks( + ops.loadTable(handle.getDbName(), handle.getTableName())); + } else { + try { + groups = context.executeAuthenticated(() -> planner.planAndOrganizeTasks( + ops.loadTable(handle.getDbName(), handle.getTableName()))); + } catch (DorisConnectorException e) { + throw e; + } catch (Exception e) { + throw new DorisConnectorException("Failed to plan rewrite for iceberg table " + + handle.getDbName() + "." + handle.getTableName() + ": " + e.getMessage(), e); + } + } + return groups.stream().map(IcebergProcedureOps::toConnectorRewriteGroup).collect(Collectors.toList()); + } + + /** + * Converts a connector {@link RewriteDataGroup} to the engine-neutral {@link ConnectorRewriteGroup}: the + * data files become their RAW iceberg paths ({@code dataFile.path()}) — the SAME key the scan provider + * filters a per-group file scope by (WS-REWRITE R2 [INV-M1]); the counts/size are carried verbatim so the + * engine sums them into the procedure's result row. {@code LinkedHashSet} keeps a stable iteration order. + */ + private static ConnectorRewriteGroup toConnectorRewriteGroup(RewriteDataGroup group) { + Set dataFilePaths = group.getDataFiles().stream() + .map(dataFile -> dataFile.path().toString()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + return new ConnectorRewriteGroup(dataFilePaths, group.getDataFiles().size(), + group.getTotalSize(), group.getDeleteFileCount()); + } +} 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 new file mode 100644 index 00000000000000..b53465392350e5 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java @@ -0,0 +1,2424 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.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.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.ConnectorSplitSource; +import org.apache.doris.connector.cache.CacheSpec; +import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.filesystem.properties.StorageProperties; +import org.apache.doris.kerberos.HadoopAuthenticator; +import org.apache.doris.thrift.TFileFormatType; +import org.apache.doris.thrift.TFileScanRangeParams; +import org.apache.doris.thrift.TIcebergDeleteFileDesc; +import org.apache.doris.thrift.TIcebergFileDesc; +import org.apache.doris.thrift.TTableFormatFileDesc; + +import com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.BaseFileScanTask; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.BatchScan; +import org.apache.iceberg.ContentScanTask; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.DeleteFileIndex; +import org.apache.iceberg.FileContent; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.ManifestContent; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.MetadataTableUtils; +import org.apache.iceberg.PartitionData; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.PartitionSpecParser; +import org.apache.iceberg.PositionDeletesScanTask; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SchemaParser; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SplittableScanTask; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TableScan; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.expressions.InclusiveMetricsEvaluator; +import org.apache.iceberg.expressions.ManifestEvaluator; +import org.apache.iceberg.expressions.Projections; +import org.apache.iceberg.expressions.ResidualEvaluator; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.StorageCredential; +import org.apache.iceberg.io.SupportsStorageCredentials; +import org.apache.iceberg.types.Conversions; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.types.Types.NestedField; +import org.apache.iceberg.util.ScanTaskUtil; +import org.apache.iceberg.util.SerializationUtil; +import org.apache.iceberg.util.TableScanUtil; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.UnaryOperator; + +/** + * {@link ConnectorScanPlanProvider} for Iceberg tables, mirroring the paimon connector's + * {@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). + * + *

    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.

    + */ +public class IcebergScanPlanProvider implements ConnectorScanPlanProvider { + + private static final Logger LOG = LogManager.getLogger(IcebergScanPlanProvider.class); + + // Split-size session variables, read via ConnectorSession.getSessionProperties() (the VariableMgr.toMap + // channel) since the connector cannot import fe-core SessionVariable. Keys + defaults are byte-identical to + // SessionVariable and to the paimon connector's constants. + private static final String FILE_SPLIT_SIZE = "file_split_size"; + private static final String MAX_INITIAL_FILE_SPLIT_SIZE = "max_initial_file_split_size"; + private static final String MAX_FILE_SPLIT_SIZE = "max_file_split_size"; + private static final String MAX_INITIAL_FILE_SPLIT_NUM = "max_initial_file_split_num"; + private static final String MAX_FILE_SPLIT_NUM = "max_file_split_num"; + private static final long DEFAULT_MAX_INITIAL_FILE_SPLIT_SIZE = 32L * 1024 * 1024; + private static final long DEFAULT_MAX_FILE_SPLIT_SIZE = 64L * 1024 * 1024; + private static final long DEFAULT_MAX_INITIAL_FILE_SPLIT_NUM = 200L; + private static final long DEFAULT_MAX_FILE_SPLIT_NUM = 100000L; + // FIX-M3 streaming (file-count) batch gate — keys byte-identical to fe-core SessionVariable. + private static final String ENABLE_EXTERNAL_TABLE_BATCH_MODE = "enable_external_table_batch_mode"; + private static final String NUM_FILES_IN_BATCH_MODE = "num_files_in_batch_mode"; + private static final long DEFAULT_NUM_FILES_IN_BATCH_MODE = 1024L; + + // COUNT(*) pushdown (T05). The snapshot-summary keys are the stable iceberg spec strings — byte-identical + // to legacy IcebergUtils.TOTAL_* (themselves local constants, not org.apache.iceberg.SnapshotSummary.*). + private static final String TOTAL_RECORDS = "total-records"; + private static final String TOTAL_POSITION_DELETES = "total-position-deletes"; + private static final String TOTAL_EQUALITY_DELETES = "total-equality-deletes"; + // Session var: when a table has only (dangling) position deletes, ignore them and still push count down. + private static final String IGNORE_ICEBERG_DANGLING_DELETE = "ignore_iceberg_dangling_delete"; + + // System-table (P6.5-T05) JNI split: a placeholder path matching legacy IcebergSplit.DUMMY_PATH. A sys split + // carries no real file (BE reads the serialized FileScanTask), so the path is never opened — it only keeps + // the generic split framework's non-null-path contract. + private static final String SYS_TABLE_DUMMY_PATH = "/dummyPath"; + + // Special-column names for classifyColumn (C2 WS-SYNTH-READ). The connector cannot import fe-core + // (org.apache.doris.catalog.Column / IcebergUtils are forbidden), so these literals are duplicated here + // and pinned to the fe-core constants by IcebergScanPlanProviderClassifyColumnTest (DORIS_ICEBERG_ROWID_COL + // == Column.ICEBERG_ROWID_COL) and the row-lineage names == IcebergUtils.ICEBERG_ROW_ID_COL / + // ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL. The hidden row-id column is SYNTHESIZED (never in the data + // file, materialized by IcebergParquet/OrcReader); the v3 row-lineage columns are GENERATED (read from the + // file when present, otherwise backfilled). The engine-wide __DORIS_GLOBAL_ROWID_COL__ is NOT handled here + // (a generic Doris lazy-materialization mechanism owned by the generic node). + private static final String DORIS_ICEBERG_ROWID_COL = "__DORIS_ICEBERG_ROWID_COL__"; + private static final String ICEBERG_ROW_ID_COL = "_row_id"; + private static final String ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL = "_last_updated_sequence_number"; + + // FIX-SCHEMA-EVOLUTION (T06): scan-level prop carrying the base64 TBinaryProtocol-serialized schema + // dictionary (current_schema_id + the single history_schema_info entry). getScanNodeProperties builds it + // from the live table + requested columns; populateScanLevelParams applies it to the real params. + // Transport via the props map because getScanPlanProvider() returns a fresh provider per call (no shared + // instance state between the two SPI methods). Mirrors paimon's paimon.schema_evolution. + private static final String SCHEMA_EVOLUTION_PROP = "iceberg.schema_evolution"; + + // FIX (explain gap): scan-level prop carrying the newline-joined iceberg Expression.toString() of each + // pushed-down conjunct. getScanNodeProperties serializes it (same IcebergPredicateConverter buildScan uses); + // appendExplainInfo renders it as the legacy IcebergScanNode `icebergPredicatePushdown=` EXPLAIN block. + // Transport via the props map for the same reason as SCHEMA_EVOLUTION_PROP above (the two SPI methods share + // no provider instance state). Mirrors paimon's paimon.predicate. Iceberg expression toStrings are + // single-line, so the newline is an unambiguous record separator. + private static final String PUSHDOWN_PREDICATES_PROP = "iceberg.pushdown_predicates"; + + // FE-only EXPLAIN prop carrying the statement's queryId, emitted by getScanNodeProperties ONLY when the + // manifest cache is enabled. appendExplainInfo uses it to drain THIS scan's manifest-cache + // hits/misses/failures from the shared per-catalog IcebergManifestCache and render the legacy + // IcebergScanNode `manifest cache:` VERBOSE line. Same transport rationale as PUSHDOWN_PREDICATES_PROP (the + // planning + EXPLAIN SPI methods share no provider instance); like it, this key is never consumed by + // populateScanLevelParams, so it never reaches BE. Its presence also gates the line (absent when the cache + // is disabled -> no line, matching legacy notContains). + private static final String MANIFEST_CACHE_QUERYID_PROP = "iceberg.manifest_cache_query_id"; + + // T08 manifest cache gate (ported from fe-core IcebergExternalCatalog + IcebergUtils.isManifestCacheEnabled + // + CacheSpec.isCacheEnabled). Default OFF: the default scan path stays the iceberg SDK planFiles() + // (splitFiles). When enabled, planScan re-plans at the manifest level so the per-manifest data/delete-file + // reads hit the connector-owned IcebergManifestCache. The .ttl-second/.capacity properties feed ONLY this + // enable formula (legacy quirk); the cache itself is fixed no-TTL / capacity 100000. + private static final String MANIFEST_CACHE_ENABLE = "meta.cache.iceberg.manifest.enable"; + private static final String MANIFEST_CACHE_TTL_SECOND = "meta.cache.iceberg.manifest.ttl-second"; + private static final String MANIFEST_CACHE_CAPACITY = "meta.cache.iceberg.manifest.capacity"; + private static final boolean DEFAULT_MANIFEST_CACHE_ENABLE = false; + private static final long DEFAULT_MANIFEST_CACHE_TTL_SECOND = 48L * 60 * 60; + private static final long DEFAULT_MANIFEST_CACHE_CAPACITY = 1024L; + + private final Map properties; + // Per-request catalog-ops resolver: applied with the current ConnectorSession to obtain the IcebergCatalogOps + // for that request. For a iceberg.rest.session=user catalog the connector passes this::newCatalogBackedOps so + // scan planning loads tables through the querying user's per-request delegated REST catalog (fail-closed — a + // tokenless request is rejected, #63068 parity). Every other catalog (and the offline-test ctors) resolves the + // single shared ops regardless of session (constant s -> catalogOps). + private final Function catalogOpsResolver; + // Engine seam: executeAuthenticated (Kerberos UGI), storage properties, vended credentials. Nullable — + // null in offline unit tests via the 2-arg ctor, in which case resolveTable resolves directly. + private final ConnectorContext context; + // T08: per-catalog manifest cache, owned by the long-lived IcebergConnector and injected via getScanPlanProvider. + // Nullable — null via the 2-/3-arg ctors (offline tests, default-disabled gate); when null the gate is + // forced off and planScan uses the SDK splitFiles path. + private final IcebergManifestCache manifestCache; + // PERF-01: cross-query RAW-table cache shared with the metadata layer, owned by the long-lived + // IcebergConnector and injected via getScanPlanProvider. Nullable — null via the offline-test ctors and + // when the connector's credential gate disables the cross-query layer; when null resolveTable still uses the + // per-statement scope and falls back to a direct remote load. + private final IcebergTableCache tableCache; + // PERF-03: cross-query inferred-file-format cache shared with the connector, owned by the long-lived + // IcebergConnector and injected via getScanPlanProvider. Nullable — null via the offline-test ctors; when null + // getScanNodeProperties resolves file_format_type live (matching pre-PERF-03 behaviour, node-memoized per query). + private final IcebergFormatCache formatCache; + + // FIX-SCAN-METRICS: per-query stash of the iceberg SDK scan diagnostics captured by the attached + // IcebergScanProfileReporter during planScan, keyed by session queryId. fe-core drains it + // (collectScanProfiles) right after planScan on the same thread; releaseReadTransaction reclaims any entry + // a thrown planScan left behind. Attached only on the synchronous data/count path (never streaming or + // system-table, which fe-core never drains), so the value list is appended single-threaded. + private final ConcurrentHashMap> scanProfileStash = new ConcurrentHashMap<>(); + + // Test-only gate for the PERF-11 per-file memo: how many times computePerFileInvariants actually ran across + // this provider's scans. A file split into k byte-slices must increment it ONCE (not k times), proving the + // per-slice recompute collapsed to per-file. Not reset per scan — a test uses a fresh provider. + @VisibleForTesting + int perFileInvariantComputeCount; + + public IcebergScanPlanProvider(Map properties, IcebergCatalogOps catalogOps) { + this(properties, catalogOps, null, null); + } + + public IcebergScanPlanProvider(Map properties, IcebergCatalogOps catalogOps, + ConnectorContext context) { + this(properties, catalogOps, context, null); + } + + public IcebergScanPlanProvider(Map properties, IcebergCatalogOps catalogOps, + ConnectorContext context, IcebergManifestCache manifestCache) { + // Constant resolver: these ctors (offline tests + the pre-session connector paths) bind a single ops that + // ignores the session, so existing behaviour/tests are byte-identical. No cross-query cache (tableCache + // null) — the per-statement scope still dedups within a statement. + this(properties, session -> catalogOps, context, manifestCache, null); + } + + /** + * Session-aware convenience ctor without a cross-query table cache (tableCache null); used by the offline + * session-routing tests. The per-statement scope still dedups within a statement. + */ + public IcebergScanPlanProvider(Map properties, + Function catalogOpsResolver, + ConnectorContext context, IcebergManifestCache manifestCache) { + this(properties, catalogOpsResolver, context, manifestCache, null); + } + + /** + * Session-aware ctor used by {@link IcebergConnector#getScanPlanProvider()}: {@code catalogOpsResolver} is + * applied per request with the current {@link ConnectorSession} so a {@code iceberg.rest.session=user} catalog + * resolves the querying user's per-request delegated catalog (the connector passes + * {@code this::newCatalogBackedOps}); every other catalog resolves the single shared ops. + */ + public IcebergScanPlanProvider(Map properties, + Function catalogOpsResolver, + ConnectorContext context, IcebergManifestCache manifestCache, IcebergTableCache tableCache) { + this(properties, catalogOpsResolver, context, manifestCache, tableCache, null); + } + + /** + * Full ctor used by {@link IcebergConnector#getScanPlanProvider()}, adding the PERF-03 cross-query + * inferred-file-format cache ({@code formatCache}). The 5-arg ctor delegates here with a null format cache + * (offline tests + pre-cache paths resolve {@code file_format_type} live). + */ + public IcebergScanPlanProvider(Map properties, + Function catalogOpsResolver, + ConnectorContext context, IcebergManifestCache manifestCache, IcebergTableCache tableCache, + IcebergFormatCache formatCache) { + this.properties = properties; + this.catalogOpsResolver = catalogOpsResolver; + this.context = context; + this.manifestCache = manifestCache; + this.tableCache = tableCache; + this.formatCache = formatCache; + } + + /** + * Iceberg is predicate-driven: it re-plans through its own SDK from the pushed predicate and never + * consults {@code requiredPartitions} (mirrors the legacy {@code IcebergScanNode} and paimon). The engine + * must therefore map a genuine FE prune-to-zero to scan-all instead of short-circuiting to zero rows — + * otherwise {@code WHERE col IS NULL} on a genuine-null partition rendered as a non-null sentinel would + * drop rows once T02 wires the predicate path. + */ + @Override + public boolean ignorePartitionPruneShortCircuit() { + return true; + } + + /** + * The distinct scanned partitions among the just-planned ranges (FIX-L12) — restores legacy + * {@code IcebergScanNode}'s {@code selectedPartitionNum = partitionMapInfos.size()} (keyed by + * {@code (PartitionData) file().partition()}) so EXPLAIN {@code partition=N/M} and + * {@code sql_block_rule} reflect the partitions iceberg's manifest/residual evaluation actually + * resolved — including hidden/transform partitioning ({@code days(ts)}, {@code bucket(n,id)}) that the + * engine's declared-column Nereids pruning cannot see. The identity is + * {@link IcebergScanRange#getScannedPartitionKey()} ({@code specId|partitionDataJson}), which is + * distinct-faithful for a single spec's transform partitions. Returns empty when no range carries a + * partition key (unpartitioned table), so the engine keeps its own count. Only counts this provider's + * own {@link IcebergScanRange} instances. + */ + @Override + public OptionalLong scannedPartitionCount(List scanRanges) { + Set distinctPartitions = new HashSet<>(); + for (ConnectorScanRange range : scanRanges) { + if (range instanceof IcebergScanRange) { + String key = ((IcebergScanRange) range).getScannedPartitionKey(); + if (key != null) { + distinctPartitions.add(key); + } + } + } + return distinctPartitions.isEmpty() + ? OptionalLong.empty() : OptionalLong.of(distinctPartitions.size()); + } + + @Override + public List collectScanProfiles(ConnectorSession session) { + String queryId = session.getQueryId(); + if (queryId == null || queryId.isEmpty()) { + return Collections.emptyList(); + } + List profiles = scanProfileStash.remove(queryId); + return profiles == null ? Collections.emptyList() : profiles; + } + + @Override + public void releaseReadTransaction(String queryId) { + // Iceberg opens no metastore read transaction (it inherits the SPI no-op); this override only reclaims + // the scan-metrics stash for a query whose planScan threw AFTER the reporter fired (the normal path + // drains it via collectScanProfiles). Same queryId fe-core registered the query-finish callback with. + if (queryId != null && !queryId.isEmpty()) { + scanProfileStash.remove(queryId); + } + } + + /** + * Iceberg metadata tables legally time-travel ({@code t$snapshots FOR TIME/VERSION AS OF ...}, + * {@code t$files@branch('b')}): legacy {@code IcebergScanNode.createTableScan} honors the pin via + * {@code useRef}/{@code useSnapshot} with no isSystemTable gate, and this provider retains + * ({@code getSysTableHandle}) + applies ({@code planSystemTableScan} -> {@code buildScan}) it. So the + * generic {@code PluginDrivenScanNode} sys-table guard must let pinned iceberg sys reads through + * (unlike paimon, whose binlog/audit_log sys tables keep the default {@code false} rejection). + */ + @Override + public boolean supportsSystemTableTimeTravel() { + return true; + } + + /** + * Classifies iceberg's special columns for the generic {@code PluginDrivenScanNode} (C2 WS-SYNTH-READ), + * porting the legacy {@code IcebergScanNode.classifyColumn} mapping minus the engine-wide + * {@code __DORIS_GLOBAL_ROWID_COL__} prefix (which the generic node handles itself): the hidden row-id + * column is SYNTHESIZED (a debug/DML metadata column never present in the data file), and the v3 + * row-lineage columns are GENERATED (read from the file when present, otherwise backfilled). Every other + * column returns {@code DEFAULT} so the generic node applies its own partition-key / regular classification. + */ + @Override + public ConnectorColumnCategory classifyColumn(String columnName) { + if (DORIS_ICEBERG_ROWID_COL.equalsIgnoreCase(columnName)) { + return ConnectorColumnCategory.SYNTHESIZED; + } + if (ICEBERG_ROW_ID_COL.equalsIgnoreCase(columnName) + || ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL.equalsIgnoreCase(columnName)) { + return ConnectorColumnCategory.GENERATED; + } + return ConnectorColumnCategory.DEFAULT; + } + + @Override + public List planScan( + ConnectorSession session, + ConnectorTableHandle handle, + List columns, + Optional filter) { + return planScanInternal(session, handle, columns, filter, false); + } + + /** + * 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). + */ + @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); + } + + /** + * Streaming-split decision + estimate (FIX-M3), a faithful port of legacy {@code IcebergScanNode.isBatchMode}: + * stream when the matched-manifest file count reaches {@code num_files_in_batch_mode} with + * {@code enable_external_table_batch_mode} on. Returns that file count (the BE concurrency hint) or -1 to stay + * on the synchronous {@link #planScan} path. Cheap: sums manifest metadata counts, never enumerates splits. + * + *

    Excluded from streaming (return -1): system tables (JNI serialized-split path); batch mode disabled; + * empty table (no snapshot); a servable {@code COUNT(*)} pushdown (collapsed to one range); and + * format-version ≥ 3 — v3 carries the commit-bridge rewritable-delete stash that the write side reads at + * write-plan time, which streaming would fill too late (at BE-pull time), resurrecting deleted rows. See the + * design doc §5.

    + */ + @Override + public long streamingSplitEstimate(ConnectorSession session, ConnectorTableHandle handle, + Optional filter, boolean countPushdown) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + if (iceHandle.isSystemTable() || !sessionBool(session, ENABLE_EXTERNAL_TABLE_BATCH_MODE, true)) { + return -1; + } + Table table = resolveTable(session, iceHandle); + TableScan scan = buildScan(table, iceHandle, filter, session); + Snapshot snapshot = scan.snapshot(); + if (snapshot == null) { + return -1; + } + if (getFormatVersion(table) >= 3) { + return -1; + } + if (countPushdown && getCountFromSnapshot(scan, session) >= 0) { + return -1; + } + long threshold = sessionLong(session, NUM_FILES_IN_BATCH_MODE, DEFAULT_NUM_FILES_IN_BATCH_MODE); + long fileCount = 0; + try (CloseableIterable matching = getMatchingManifest( + snapshot.dataManifests(table.io()), table.specs(), scan.filter())) { + for (ManifestFile manifest : matching) { + // Manifest metadata counts (cheap — no per-file read). Null guard for ancient manifests that + // omit the counts (legacy summed them unguarded; 0 is the safe under-count, never over-streams). + Integer added = manifest.addedFilesCount(); + Integer existing = manifest.existingFilesCount(); + fileCount += (added == null ? 0 : added) + (existing == null ? 0 : existing); + } + } catch (IOException e) { + throw new RuntimeException("Failed to count iceberg manifest files for batch decision, error message is:" + + e.getMessage(), e); + } + return fileCount >= threshold ? fileCount : -1; + } + + /** + * Lazy streaming split source (FIX-M3), mirroring legacy {@code IcebergScanNode.doStartSplit}: slice files at + * a FIXED size ({@code file_split_size} if set, else {@code max_split_size} — NOT the per-table + * {@link #determineTargetFileSplitSize} heuristic, which would force materializing every task), so + * {@code planFiles()} streams without holding the full task list — the OOM protection. Bypasses the manifest + * cache (its planning materializes; legacy's lazy batch path only ran with the manifest cache off). Only + * called after {@link #streamingSplitEstimate} returned ≥ 0, so the snapshot/non-sys/v<3 gates already hold. + */ + @Override + public ConnectorSplitSource streamSplits(ConnectorSession session, ConnectorTableHandle handle, + List columns, Optional filter, long limit) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + Table table = resolveTable(session, iceHandle); + TableScan scan = buildScan(table, iceHandle, filter, session); + int formatVersion = getFormatVersion(table); + List orderedPartitionKeys = IcebergPartitionUtils.getIdentityPartitionColumns(table); + ZoneId zone = resolveSessionZone(session); + boolean partitioned = table.spec().isPartitioned(); + Map vendedToken = context != null + ? extractVendedToken(table, restVendedCredentialsEnabled()) : Collections.emptyMap(); + UnaryOperator uriNormalizer = newUriNormalizer(vendedToken); + long fileSplitSize = sessionLong(session, FILE_SPLIT_SIZE, 0L); + long sliceSize = fileSplitSize > 0 ? fileSplitSize + : sessionLong(session, MAX_FILE_SPLIT_SIZE, DEFAULT_MAX_FILE_SPLIT_SIZE); + CloseableIterable tasks = streamingFileScanTasks(scan, session, table, filter, sliceSize); + return new IcebergStreamingSplitSource(tasks, table, formatVersion, partitioned, + orderedPartitionKeys, zone, uriNormalizer, sliceSize, iceHandle.getRewriteFileScope()); + } + + /** + * The streaming source's whole-file enumeration, byte-offset-split at {@code sliceSize}. PERF-04 (C17): when + * the manifest cache is enabled, read manifests THROUGH THE CACHE via the lazy {@link #cacheBackedFileScanTasks} + * (no-stats overload — Phase 2 iterates on the engine pump thread, see there) so a big streaming scan finally + * hits the cache while staying lazy (OOM-safe). An eager Phase-1 cache failure records it and falls back to the + * SDK {@code planFiles()} path (mirrors {@link #planFileScanTask}); a later lazy failure surfaces through the + * streaming source's {@code hasNext}. Cache disabled -> the SDK path, byte-unchanged. + */ + private CloseableIterable streamingFileScanTasks(TableScan scan, ConnectorSession session, + Table table, Optional filter, long sliceSize) { + if (isManifestCacheEnabled()) { + try { + return TableScanUtil.splitFiles( + cacheBackedFileScanTasks(scan, session, table, filter, null), sliceSize); + } catch (Exception e) { + LOG.warn("Iceberg streaming plan with manifest cache failed, falling back to SDK scan: {}", + e.getMessage(), e); + manifestCache.recordFailure(session.getQueryId()); + } + } + return TableScanUtil.splitFiles(scan.planFiles(), sliceSize); + } + + /** + * Lazy {@link ConnectorSplitSource} over an iceberg scan's byte-offset-split {@link FileScanTask}s: maps each + * task to an {@link IcebergScanRange} on demand (via {@link #buildRangeForTask}) so the engine can pump them + * into its split queue with backpressure, keeping FE heap bounded for million-file scans. v3 is gated off the + * streaming path, so the stash side-effect is inert here ({@code stashRewritableDeletes=false}). Single-pass, + * not thread-safe (the engine drives it from one background task). + */ + private final class IcebergStreamingSplitSource implements ConnectorSplitSource { + private final CloseableIterable tasks; + private final Table table; + private final int formatVersion; + private final boolean partitioned; + private final List orderedPartitionKeys; + private final ZoneId zone; + private final UnaryOperator uriNormalizer; + private final long sliceSize; + private final Set rewriteScope; + // Lazily opened on first hasNext() so the ctor never throws — iceberg's ParallelIterable submits + // manifest readers in tasks.iterator(), which can fail; opening it eagerly here would throw out of + // streamSplits() BEFORE the source is returned, leaking the planFiles() iterable (the engine pump's + // close() never receives it). Lazy open instead routes any failure through hasNext()->the engine's + // setException + finally-close, with tasks still closed. null until first use. + private CloseableIterator iterator; + // Look-ahead buffer so hasNext() can skip data files filtered out by the rewrite scope. + private IcebergScanRange buffered; + // Per-file invariant cache (PERF-11): per split-source = per scan; the pump is single-threaded, so the + // 1-entry cache stays O(1) memory (never accumulates), preserving the streaming path's OOM safety. + private final PerFileScratch scratch = new PerFileScratch(); + + IcebergStreamingSplitSource(CloseableIterable tasks, Table table, int formatVersion, + boolean partitioned, List orderedPartitionKeys, ZoneId zone, + UnaryOperator uriNormalizer, long sliceSize, Set rewriteScope) { + this.tasks = tasks; + this.table = table; + this.formatVersion = formatVersion; + this.partitioned = partitioned; + this.orderedPartitionKeys = orderedPartitionKeys; + this.zone = zone; + this.uriNormalizer = uriNormalizer; + this.sliceSize = sliceSize; + this.rewriteScope = rewriteScope; + } + + @Override + public boolean hasNext() { + if (buffered != null) { + return true; + } + if (iterator == null) { + iterator = tasks.iterator(); + } + while (iterator.hasNext()) { + IcebergScanRange range = buildRangeForTask(iterator.next(), table, formatVersion, partitioned, + orderedPartitionKeys, zone, uriNormalizer, sliceSize, rewriteScope, null, scratch); + if (range != null) { + buffered = range; + return true; + } + } + return false; + } + + @Override + public ConnectorScanRange next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + IcebergScanRange range = buffered; + buffered = null; + return range; + } + + @Override + public void close() throws IOException { + try { + if (iterator != null) { + iterator.close(); + } + } finally { + tasks.close(); + } + } + } + + private List planScanInternal( + ConnectorSession session, + ConnectorTableHandle handle, + List columns, + Optional filter, + boolean countPushdown) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + if (iceHandle.isSystemTable()) { + // System tables take a metadata-table path, never the data-file path below (no count pushdown, no + // data-file ranges) — mirrors legacy IcebergScanNode branching on isSystemTable. $position_deletes + // then splits off again inside, onto BE's NATIVE reader; every other sys table stays on JNI. + return planSystemTableScan(iceHandle, columns, filter, session); + } + Table table = resolveTable(session, iceHandle); + TableScan scan = buildScan(table, iceHandle, filter, session); + // FIX-SCAN-METRICS: attach a per-scan metrics reporter so the iceberg SDK's ScanReport (planning time, + // data/delete files, scanned vs skipped manifests) is captured into the query profile — restores the + // legacy IcebergScanNode scan-metrics profile the migration dropped. Attached HERE (the synchronous + // data/count path), NOT in buildScan, which is also reached by streamSplits/planSystemTableScan whose + // report would stash a queryId entry fe-core never drains (leak). The reporter fires on close of the + // planFiles iterable, which the data (try-with-resources) and count paths close on this thread. + // Guard a null session (offline unit tests) — production planScan always carries one. + if (session != null) { + scan = scan.metricsReporter(new IcebergScanProfileReporter(session.getQueryId(), scanProfileStash)); + } + + int formatVersion = getFormatVersion(table); + List orderedPartitionKeys = IcebergPartitionUtils.getIdentityPartitionColumns(table); + ZoneId zone = resolveSessionZone(session); + boolean partitioned = table.spec().isPartitioned(); + + // Vended credentials (T09): extract the per-table REST vended token ONCE per scan (gated on the catalog + // flag iceberg.rest.vended-credentials-enabled, mirroring legacy IcebergVendedCredentialsProvider), then + // thread it into the 2-arg URI normalization below so REST object-store data/delete paths normalize via + // the vended map (a REST catalog's static storage map is empty by design). Empty for non-vended catalogs + // / no context -> the 2-arg normalize folds to the static-map path (non-REST reads byte-unchanged). The + // BE-credential overlay is emitted separately by getScanNodeProperties. + Map vendedToken = context != null + ? extractVendedToken(table, restVendedCredentialsEnabled()) : Collections.emptyMap(); + // Derive the vended storage config ONCE per scan (the token is scan-invariant) and reuse it for every + // per-file path normalization below, instead of rebuilding it per data/delete file (C3). + UnaryOperator uriNormalizer = newUriNormalizer(vendedToken); + + // COUNT(*) pushdown (T05): when the count is servable from the snapshot summary, collapse the scan to + // a single whole-file range carrying the full count (mirrors paimon's collapse + legacy's <=10000 + // case; the legacy >10000 parallel multi-split trim is a perf-only divergence, dropped). A -1 (equality + // deletes, or dangling position deletes without the ignore flag) falls through to the normal scan so + // BE reads and counts. + if (countPushdown) { + long realCount = getCountFromSnapshot(scan, session); + if (realCount >= 0) { + return planCountPushdown(table, scan, realCount, formatVersion, partitioned, + orderedPartitionKeys, zone, uriNormalizer, session, filter); + } + } + + // Enumerate FileScanTasks via the iceberg SDK (split byte-offsets come from TableScanUtil.splitFiles) + // and emit one BE-ready IcebergScanRange per task, populating the typed iceberg carriers — incl. the + // merge-on-read delete files (T04) — mirroring legacy IcebergScanNode.createIcebergSplit. The field-id + // history dict (T06, scan-level), MVCC pin, and vended credentials (T09) land later. + // commit-bridge supply (S4 part 2): for a format-version>=3 scan, accumulate each data file's non-equality + // delete supply (old DVs + old position deletes) into the per-statement scope, so a DELETE/MERGE write on + // the same statement can fill rewritable_delete_file_sets and the BE OR-merges those old deletes into the + // new deletion vector — a missing supply silently resurrects previously-deleted rows. The scope is keyed by + // catalog id + queryId (shared across this statement's scan and write, isolated per catalog for a + // cross-catalog MERGE). Skipped pre-v3; a non-DML scan just leaves an entry GC'd with the statement, and an + // absent scope (offline) yields a throwaway map that the write seam guards against (fail loud on v3 DML). + Map> rewritableDeleteSupply = formatVersion >= 3 + ? IcebergStatementScope.rewritableDeleteSupply(session) : null; + + // WS-REWRITE R2 per-group scope: when the handle carries a rewrite file scope (the engine + // rewrite_data_files driver sets it before each group's INSERT-SELECT), keep ONLY the data files in + // that scope so the group rewrites exactly its bin-packed files. Match on the RAW iceberg path + // (dataFile.path(), the SAME value the rewrite planner records into the scope), NOT the + // scheme-normalized BE path (the range's .path()) — a normalization difference would silently scope to + // the wrong files (over-read -> a RewriteFiles commit replacing more than the group -> duplicate rows). + // null = no scope = full scan (every non-rewrite scan). Each kept task keeps its merge-on-read deletes + // (buildRange re-attaches task.deletes()), so scoping never drops a delete binding. + Set rewriteScope = iceHandle.getRewriteFileScope(); + + // Per-file invariant cache (PERF-11): ONE instance per scan, never shared across scans. Reused across + // a data file's consecutive byte-slices so partition JSON / identity map / delete carriers are computed + // once per file, not per slice. The streaming source below holds its own. + PerFileScratch scratch = new PerFileScratch(); + List ranges = new ArrayList<>(); + try (SplitPlan plan = planFileScanTask(scan, session, table, filter)) { + for (FileScanTask task : plan.tasks) { + // Shared per-task mapping (rewrite-scope skip + M-2 weight denominator + v3 stash side-effect), + // identical to the streaming path's IcebergStreamingSplitSource so both produce the same ranges. + IcebergScanRange range = buildRangeForTask(task, table, formatVersion, partitioned, + orderedPartitionKeys, zone, uriNormalizer, plan.targetSplitSize, rewriteScope, + rewritableDeleteSupply, scratch); + if (range != null) { + ranges.add(range); + } + } + } catch (IOException e) { + throw new RuntimeException("Failed to enumerate iceberg file scan tasks, error message is:" + + e.getMessage(), e); + } catch (ValidationException e) { + // Port of legacy IcebergScanNode.checkNotSupportedException: a table with a partition spec whose + // SOURCE column was later DROPPED cannot be planned — iceberg resolves the orphaned partition field + // while building the delete-file index / partition projection during planFiles() and fails. The + // legacy stack was a raw NullPointerException on iceberg 1.4.x ("Type cannot be null"); the iceberg + // version this connector links guards the null source explicitly and throws ValidationException + // ("Cannot find source column for partition field: ..."). Surface the stable legacy user-facing + // message (the partition-evolution regression asserts this substring) instead of a raw internal + // failure. Only the dropped-source-column signature is reclassified; any UNRELATED ValidationException + // (a genuine query-validation error) is rethrown untouched. NullPointerException is deliberately NOT + // caught here — on this iceberg version the dropped-column case is a ValidationException, so catching + // NPE would only mask unrelated bugs (fail loud instead). + if (!IcebergPartitionUtils.isDroppedPartitionSourceColumn(e)) { + throw e; + } + LOG.warn("Unable to plan for iceberg table {}", table.name(), e); + throw new DorisConnectorException("Unable to plan for this table. " + + "Maybe read Iceberg table with dropped old partition column. Cause: " + rootCauseMessage(e)); + } + LOG.debug("Iceberg planScan produced {} ranges for table {}", ranges.size(), table.name()); + return ranges; + } + + /** + * The class-qualified message of the ROOT cause, a self-contained port of legacy + * {@code Util.getRootCauseMessage} (the connector cannot import fe-core), used to fill the legacy + * "Cause: ..." suffix of the "Unable to plan for this table" error. + */ + private static String rootCauseMessage(Throwable t) { + if (t == null) { + return "unknown"; + } + Throwable p = t; + while (p.getCause() != null) { + p = p.getCause(); + } + String message = p.getMessage(); + return message == null ? p.getClass().getName() : p.getClass().getName() + ": " + message; + } + + /** + * Map one {@link FileScanTask} to its BE-ready {@link IcebergScanRange}, applying the rewrite-scope filter + * (returns {@code null} to skip a data file outside the scope) and the v3 commit-bridge rewritable-delete + * accumulation. Shared by the synchronous {@link #planScanInternal} loop and the streaming + * {@code IcebergStreamingSplitSource} so both paths produce byte-identical ranges and never drop a + * side-effect. The streaming path passes {@code rewritableDeleteSupply=null} (v3 is gated onto the eager + * path — see {@link #streamingSplitEstimate}), so the accumulation is inert there. + */ + private IcebergScanRange buildRangeForTask(FileScanTask task, Table table, int formatVersion, + boolean partitioned, List orderedPartitionKeys, ZoneId zone, + UnaryOperator uriNormalizer, long targetSplitSize, Set rewriteScope, + Map> rewritableDeleteSupply, PerFileScratch scratch) { + DataFile dataFile = task.file(); + if (rewriteScope != null && !rewriteScope.contains(dataFile.path().toString())) { + return null; + } + // First byte-slice of a new data file? (scratch still holds the previous file until buildRange refreshes + // it below — so capture this BEFORE the buildRange call.) The v3 rewritable-delete supply is identical + // for every slice of a file, so record it exactly ONCE per file — on the file's first slice, keyed to + // the new file, so the LAST file in the stream is never dropped. + boolean firstSliceOfFile = scratch.file != dataFile; + // targetSplitSize is the scan-level weight denominator (M-2): each data-file range carries a + // size-proportional BE scheduling weight (selfSplitWeight computed inside buildRange). + IcebergScanRange range = buildRange(table, dataFile, task, formatVersion, partitioned, + orderedPartitionKeys, zone, uriNormalizer, -1, targetSplitSize, scratch); + if (rewritableDeleteSupply != null && firstSliceOfFile) { + // Record this data file's non-equality delete supply keyed on its RAW path (the exact string the BE + // matches a rewritable set against). An empty list (no old non-eq deletes) contributes nothing. + List descs = range.rewritableDeleteDescs(); + if (range.getOriginalPath() != null && descs != null && !descs.isEmpty()) { + rewritableDeleteSupply.put(range.getOriginalPath(), descs); + } + } + return range; + } + + /** + * Plan the system-table (JNI) scan for a {@code $sys} handle, mirroring legacy + * {@code IcebergScanNode.doGetSystemTableSplits} + {@code createIcebergSysSplit} + {@code setIcebergParams}: + * resolve the metadata table ({@link #resolveSysTable}), apply the time-travel pin + predicate through the + * shared {@link #buildScan} (legacy {@code createTableScan} honors {@code useSnapshot}/{@code useRef} on the + * metadata-table scan too — iceberg system tables are legal time-travel targets), then serialize each + * metadata {@code FileScanTask} ({@code SerializationUtil.serializeToBase64}) into a JNI split carrying ONLY + * {@code serialized_split} + {@code FORMAT_JNI} (see {@link IcebergScanRange#populateRangeParams}). COUNT(*) + * pushdown does not apply (a metadata table has no snapshot-summary count). The serialized {@code + * FileScanTask} bytes are consumed verbatim by BE's {@code IcebergSysTableJniScanner} + * ({@code deserializeFromBase64(...).asDataTask().rows()}); FE unit tests cannot reach the BE classloader, so + * the cross-version byte compatibility is covered by the P6.8 docker e2e. Dormant until P6.6. + */ + private List planSystemTableScan(IcebergTableHandle handle, + List columns, Optional filter, + ConnectorSession session) { + // Thread-level auth wrap (legacy parity: preExecutionAuthenticator.execute around doGetSplits), ONE + // scope spanning the base-table load (resolveSysTable) plus the metadata-table planFiles — whose + // manifest-list read for the $files family happens on THIS thread. Deliberately NOT the + // wrapTableForScan object-level wrap — the planned FileScanTasks are Java-serialized to the BE JNI + // reader and the authenticator-bearing FileIO wrapper is not serializable. + if (context == null) { + return doPlanSystemTableScan(handle, columns, filter, session); + } + try { + return context.executeAuthenticated(() -> doPlanSystemTableScan(handle, columns, filter, session)); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Failed to plan iceberg system-table scan, error message is:" + + e.getMessage(), e); + } + } + + private List doPlanSystemTableScan(IcebergTableHandle handle, + List columns, Optional filter, + ConnectorSession session) { + Table metadataTable = resolveSysTable(session, handle); + if (isPositionDeletesSysTable(handle)) { + return doPlanPositionDeletesSystemTableScan(handle, metadataTable, columns, filter, session); + } + TableScan scan = buildScan(metadataTable, handle, filter, session); + // Project the metadata-table scan to ONLY the requested columns, in the SAME order BE lists them in + // required_fields (== the scan slot order: buildColumnHandles iterates desc.getSlots(), BE builds + // required_fields from the matching file_slot_descs). This mirrors legacy IcebergScanNode + // .getSystemTableProjectedSchema() + scan.project(...). Two reasons the projection is mandatory AND must + // be order-preserving: + // 1. Without it the iceberg SDK materialises EVERY metadata column per row for the $files/$data_files + // family -- including readable_metrics, whose bound conversion (MetricsUtil.readableMetricsStruct -> + // Conversions.fromByteBuffer) throws BufferUnderflowException on boolean/complex bound columns -- + // even when the query selects only a scalar such as file_size_in_bytes. + // 2. BE's IcebergSysTableJniScanner reads the projected StaticDataTask rows POSITIONALLY (row.get(i)), + // relying on the i-th projected field being the i-th required field. scan.select(names) would route + // the ids through a Set and re-emit them in TABLE-schema order (TypeUtil.project), breaking that + // contract; scan.project(orderedSchema) preserves the requested order, so we build the schema by + // hand from the requested columns. + List projectedColumns = requestedLowerNames(columns); + if (!projectedColumns.isEmpty()) { + Schema metadataSchema = metadataTable.schema(); + List projectedFields = new ArrayList<>(projectedColumns.size()); + Set seenFieldIds = new HashSet<>(); + for (String columnName : projectedColumns) { + NestedField field = metadataSchema.findField(columnName); + if (field == null) { + throw new RuntimeException("Column " + columnName + + " not found in iceberg system table schema " + metadataTable.name()); + } + if (seenFieldIds.add(field.fieldId())) { + projectedFields.add(field); + } + } + scan = scan.project(new Schema(projectedFields)); + } + List ranges = new ArrayList<>(); + try (CloseableIterable tasks = scan.planFiles()) { + for (FileScanTask task : tasks) { + ranges.add(new IcebergScanRange.Builder() + .path(SYS_TABLE_DUMMY_PATH) + .serializedSplit(SerializationUtil.serializeToBase64(task)) + .build()); + } + } catch (IOException e) { + throw new RuntimeException("Failed to enumerate iceberg system-table scan tasks, error message is:" + + e.getMessage(), e); + } + LOG.debug("Iceberg planScan produced {} system-table splits for {}.{}${}", ranges.size(), + handle.getDbName(), handle.getTableName(), handle.getSysTableName()); + return ranges; + } + + /** Whether {@code handle} is the {@code $position_deletes} metadata table (the one native-reader sys table). */ + private static boolean isPositionDeletesSysTable(IcebergTableHandle handle) { + return handle.isSystemTable() + && MetadataTableType.POSITION_DELETES.name().equalsIgnoreCase(handle.getSysTableName()); + } + + /** + * Plan a {@code $position_deletes} scan. Port of legacy + * {@code IcebergScanNode.doGetPositionDeletesSystemTableSplits} (upstream #65135). + * + *

    This is the ONE system table that does not ride the JNI serialized-split path: BE reads it with a + * native parquet/orc/puffin reader, so FE must emit real file ranges (see + * {@link IcebergScanRange#populateRangeParams} for the wire shape). + * + *

    Deviations from the JNI sys path above, each forced: + *

      + *
    • {@code newBatchScan()}, not {@link #buildScan}'s {@code newScan()} — + * {@code PositionDeletesTable.newScan()} THROWS {@code UnsupportedOperationException}. The + * time-travel pin and predicate conversion are therefore replicated onto the BatchScan here.
    • + *
    • Predicates convert against the METADATA table's schema (file_path/pos/partition/...), best-effort: + * an unconvertible conjunct is dropped to a BE residual, mirroring legacy (NOT the fail-loud + * all-or-nothing of the write path's WHERE lowering).
    • + *
    + * + *

    Legacy's {@code metricsReporter} + {@code planWith(threadPool)} are dropped, the same documented + * deviation {@link #buildScan} already makes for every other scan (profile-only; identical file set). + * Legacy's smooth-upgrade backend guard is deliberately NOT ported (design doc D1: implement the final + * form; the engine owns BE-compat and the SPI exposes no backends). + */ + private List doPlanPositionDeletesSystemTableScan(IcebergTableHandle handle, + Table metadataTable, List columns, Optional filter, + ConnectorSession session) { + BatchScan scan = metadataTable.newBatchScan(); + if (handle.hasSnapshotPin()) { + if (handle.getRef() != null) { + scan = scan.useRef(handle.getRef()); + } else { + scan = scan.useSnapshot(handle.getSnapshotId()); + } + } + if (filter.isPresent()) { + List predicates = new IcebergPredicateConverter( + metadataTable.schema(), resolveSessionZone(session)).convert(filter.get()); + for (Expression predicate : predicates) { + scan = scan.filter(predicate); + } + } + + List tasks = new ArrayList<>(); + try (CloseableIterable scanTasks = scan.planFiles()) { + for (ScanTask task : scanTasks) { + if (!(task instanceof PositionDeletesScanTask)) { + throw new DorisConnectorException( + "Unexpected Iceberg position_deletes scan task: " + task); + } + tasks.add((PositionDeletesScanTask) task); + } + } catch (IOException e) { + throw new DorisConnectorException( + "Failed to enumerate iceberg position_deletes scan tasks, error message is:" + + e.getMessage(), e); + } + + // Split sizing mirrors legacy determinePositionDeleteTargetSplitSize: an explicit file_split_size wins, + // else the shared per-table heuristic. PUFFIN is non-splittable in iceberg 1.10.1, so + // BaseContentScanTask.split() hands a DV task straight back — DVs are never fragmented. + long fileSplitSize = sessionLong(session, FILE_SPLIT_SIZE, 0L); + long targetSplitSize = fileSplitSize > 0 ? fileSplitSize + : determineTargetFileSplitSize(tasks, session); + + boolean partitionRequested = isPositionDeletesPartitionColumnRequested(columns); + List outputPartitionFields = partitionRequested + ? getPositionDeletesOutputPartitionFields(metadataTable) : Collections.emptyList(); + boolean enableMappingVarbinary = Boolean.parseBoolean( + properties.getOrDefault(IcebergConnectorProperties.ENABLE_MAPPING_VARBINARY, "false")); + ZoneId zone = resolveSessionZone(session); + Map vendedToken = context != null + ? extractVendedToken(metadataTable, restVendedCredentialsEnabled()) : Collections.emptyMap(); + UnaryOperator uriNormalizer = newUriNormalizer(vendedToken); + + List ranges = new ArrayList<>(); + for (PositionDeletesScanTask task : tasks) { + for (PositionDeletesScanTask splitTask : splitPositionDeleteScanTask(task, targetSplitSize)) { + ranges.add(buildPositionDeleteRange(splitTask, metadataTable, outputPartitionFields, + enableMappingVarbinary, zone, uriNormalizer)); + } + } + LOG.debug("Iceberg planScan produced {} position_deletes splits for {}.{}", ranges.size(), + handle.getDbName(), handle.getTableName()); + return ranges; + } + + @SuppressWarnings("unchecked") + private static Iterable splitPositionDeleteScanTask(PositionDeletesScanTask task, + long targetSplitSize) { + return ((SplittableScanTask) task).split(targetSplitSize); + } + + /** + * One native range per position-delete split. Port of legacy + * {@code IcebergScanNode.createIcebergPositionDeleteSysSplit}. + */ + private IcebergScanRange buildPositionDeleteRange(PositionDeletesScanTask task, Table metadataTable, + List outputPartitionFields, boolean enableMappingVarbinary, ZoneId zone, + UnaryOperator uriNormalizer) { + DeleteFile deleteFile = task.file(); + String originalPath = deleteFile.path().toString(); + IcebergScanRange.Builder builder = new IcebergScanRange.Builder() + .path(uriNormalizer.apply(originalPath)) + .start(task.start()) + .length(task.length()) + .fileSize(deleteFile.fileSizeInBytes()) + // The split's own scheduling weight, mirroring legacy newPositionDeleteSysTableSplit + // (selfSplitWeight = max(length, 1)). + .selfSplitWeight(Math.max(task.length(), 1L)) + .targetSplitSize(task.length()); + + // DV vs plain position-delete file is decided by content ALONE (BE never looks at the file format): + // a puffin DV still travels as FORMAT_PARQUET, exactly as legacy getNativePositionDeleteFileFormat does. + TFileFormatType fileFormat = getNativePositionDeleteFileFormat(deleteFile.format()); + if (deleteFile.format() == FileFormat.PUFFIN) { + Long contentOffset = deleteFile.contentOffset(); + Long contentLength = deleteFile.contentSizeInBytes(); + validateDeletionVectorMetadata( + originalPath, deleteFile.fileSizeInBytes(), contentOffset, contentLength); + builder.positionDeleteSysTableSplit( + IcebergScanRange.DeleteFile.CONTENT_DELETION_VECTOR, fileFormat, originalPath); + builder.positionDeleteDeletionVector(deleteFile.referencedDataFile(), + contentOffset, contentLength); + } else { + builder.positionDeleteSysTableSplit( + IcebergScanRange.DeleteFile.CONTENT_POSITION_DELETE, fileFormat, originalPath); + } + + builder.partitionSpecId(deleteFile.specId()); + PartitionSpec partitionSpec = metadataTable.specs().get(deleteFile.specId()); + if (partitionSpec == null) { + throw new DorisConnectorException("Partition spec with specId " + deleteFile.specId() + + " not found for table " + metadataTable.name()); + } + // Only render the partition struct when the query actually projects `partition` — legacy parity, and it + // keeps the (throwing) binary guard off queries that never asked for it. + if (partitionSpec.isPartitioned() && deleteFile.partition() != null && !outputPartitionFields.isEmpty()) { + builder.partitionDataJson(IcebergPartitionUtils.getPartitionDataObjectJson( + (PartitionData) deleteFile.partition(), partitionSpec, outputPartitionFields, + enableMappingVarbinary, zone)); + } + return builder.build(); + } + + /** + * PARQUET and PUFFIN both read through BE's parquet path; ORC through the orc one. AVRO position-delete + * files have no native reader — fail loud rather than mis-route. Message text mirrors legacy exactly. + */ + private static TFileFormatType getNativePositionDeleteFileFormat(FileFormat fileFormat) { + if (fileFormat == FileFormat.PARQUET || fileFormat == FileFormat.PUFFIN) { + return TFileFormatType.FORMAT_PARQUET; + } else if (fileFormat == FileFormat.ORC) { + return TFileFormatType.FORMAT_ORC; + } + throw new UnsupportedOperationException( + "Unsupported Iceberg position delete file format: " + fileFormat); + } + + /** + * Validate an Iceberg deletion-vector blob descriptor before it reaches BE cache lookup / memory allocation. + * Port of legacy {@code IcebergDeleteFileFilter.validateDeletionVectorMetadata} (upstream #65676): a puffin DV + * carries {@code content_offset}/{@code content_size_in_bytes} that address a blob inside the puffin file, so a + * missing, negative, overflowing, or out-of-file-bounds range is malformed metadata we must reject on the FE + * (fail loud) rather than hand BE an invalid offset/length. Shared by both DV emitters: the normal-scan + * merge-on-read path ({@link #convertDelete}) and the {@code $position_deletes} split path + * ({@link #buildPositionDeleteRange}). + */ + static void validateDeletionVectorMetadata( + String deleteFilePath, long fileSize, Long contentOffset, Long contentLength) { + if (contentOffset == null || contentLength == null) { + throw new IllegalArgumentException(String.format( + "Iceberg deletion vector metadata misses content offset or length: %s", deleteFilePath)); + } + if (fileSize < 0 || contentOffset < 0 || contentLength < 0) { + throw new IllegalArgumentException(String.format( + "Iceberg deletion vector metadata must be non-negative, file: %s, file size: %d, " + + "content offset: %d, content length: %d", + deleteFilePath, fileSize, contentOffset, contentLength)); + } + if (contentOffset > Long.MAX_VALUE - contentLength) { + throw new IllegalArgumentException(String.format( + "Iceberg deletion vector metadata range overflows, file: %s, content offset: %d, " + + "content length: %d", + deleteFilePath, contentOffset, contentLength)); + } + if (contentOffset + contentLength > fileSize) { + throw new IllegalArgumentException(String.format( + "Iceberg deletion vector metadata range exceeds file size, file: %s, file size: %d, " + + "content offset: %d, content length: %d", + deleteFilePath, fileSize, contentOffset, contentLength)); + } + } + + /** + * The {@code partition} struct's fields on the {@code $position_deletes} metadata table, in output order. + * These carry the metadata table's REASSIGNED field ids (BaseMetadataTable.transformSpec), which is why + * {@code getPartitionDataObjectJson} must map values by field id and not by spec position. + */ + private static List getPositionDeletesOutputPartitionFields(Table metadataTable) { + NestedField partitionField = metadataTable.schema().findField("partition"); + if (partitionField == null) { + throw new DorisConnectorException( + "Partition field not found in Iceberg position_deletes metadata table schema"); + } + return partitionField.type().asNestedType().fields(); + } + + /** Whether the query projects the {@code partition} column (legacy read the tuple's slots). */ + private static boolean isPositionDeletesPartitionColumnRequested(List columns) { + return requestedLowerNames(columns).stream().anyMatch("partition"::equalsIgnoreCase); + } + + /** + * Build the predicate-filtered {@link TableScan}, mirroring legacy {@code createTableScan}: translate the + * engine-neutral predicate into iceberg {@code Expression}s (self-contained, mirrors legacy + * {@code IcebergUtils.convertToIcebergExpr}; unpushable conjuncts are dropped → BE residual) and apply + * each as a separate filter (iceberg ANDs them internally). The fe-core-only {@code metricsReporter} + * (profile) and {@code planWith(threadPool)} are intentionally dropped — the iceberg SDK default worker + * pool plans, and the file set is identical (see design deviations). The MVCC / time-travel pin (T07) is + * applied here ({@code useRef} for a tag/branch, else {@code useSnapshot}), mirroring legacy + * {@code createTableScan}; {@code getCountFromSnapshot} reads {@code scan.snapshot()} so the count follows. + */ + private TableScan buildScan(Table table, IcebergTableHandle handle, Optional filter, + ConnectorSession session) { + TableScan scan = table.newScan(); + // MVCC / time-travel pin: a tag/branch pins by REF (so a later commit to the ref is honored, legacy + // parity), else by snapshot id (legacy createTableScan: useRef when info.getRef()!=null else useSnapshot). + if (handle.hasSnapshotPin()) { + if (handle.getRef() != null) { + scan = scan.useRef(handle.getRef()); + } else { + scan = scan.useSnapshot(handle.getSnapshotId()); + } + } + if (filter.isPresent()) { + // Predicate conversion uses the table's CURRENT schema, matching legacy createTableScan:589 + // (convertToIcebergExpr(conjunct, icebergTable.schema())) — NOT the pinned schema. A predicate on a + // column renamed since the pinned snapshot then resolves to no field and drops to BE residual, + // exactly like legacy; the common no-rename case is identical (the pinned name == the current name), + // and the unbound expression still binds against the pinned snapshot's schema at plan time. + List predicates = + new IcebergPredicateConverter(table.schema(), resolveSessionZone(session)).convert(filter.get()); + for (Expression predicate : predicates) { + scan = scan.filter(predicate); + } + } + return scan; + } + + /** + * The schema AS OF the handle's pinned schema id (for time-travel reads under schema evolution); the latest + * schema when there is no pinned id or it is absent from {@code table.schemas()} (defensive — legacy + * {@code IcebergUtils.getSchema} falls back to {@code table.schema()}). + * + *

    INVARIANT (do not break): this dict-schema selector MUST stay byte-identical — same + * {@code getSchemaId()} lookup, same silent fallback to {@code table.schema()} — to the SLOT-schema + * selector in {@code IcebergConnectorMetadata.getTableSchema(session, handle, snapshot)}. The field-id + * dict's top-level names must equal the BE StructNode scan-slot names; a divergence (e.g. hardening ONE + * side to throw-loud on a missing schemaId while the other silently falls back) would make them resolve + * DIFFERENT schemas → BE's unconditional {@code children.at(name)} std::out_of_range-SIGABRTs the whole BE + * on a schema-evolved time-travel read. Because {@code schemas()} is append-only and the {@code schemaId} + * is the atomic pin threaded into both sides, they resolve the same schema by construction TODAY — keep it + * that way (reverify #65185 L16).

    + */ + private static Schema pinnedSchema(Table table, IcebergTableHandle handle) { + long schemaId = handle.getSchemaId(); + if (schemaId >= 0) { + Schema pinned = table.schemas().get((int) schemaId); + if (pinned != null) { + return pinned; + } + } + return table.schema(); + } + + /** + * Emit the single collapsed COUNT(*)-pushdown range: the first whole-file {@link FileScanTask} from + * {@code scan.planFiles()} carrying the full {@code realCount} via {@code table_level_row_count} → BE's + * count reader serves it without opening the data file. Mirrors paimon's {@code buildCountRange} (one + * range bearing the summed total). Result-identical to legacy's count short-circuit even though legacy + * takes a different shape: legacy byte-splits the count file ({@code planFileScanTask} → + * {@code splitFiles} → {@code TableScanUtil.splitFiles}), keeps the first split task's byte-range for + * {@code count < 10000}, and {@code assignCountToSplits} distributes the same total — but under count + * pushdown BE's count reader never reads the file (the range's start/length are irrelevant) and sums + * {@code table_level_row_count} across ranges, so one whole-file range yields the identical total (and + * legacy's {@code >10000} parallel multi-split trim is the perf-only divergence we drop). An empty table + * (no files) yields no range, so BE gets 0 ranges and COUNT returns 0 (legacy returns empty splits too). + */ + private List planCountPushdown(Table table, TableScan scan, long realCount, + int formatVersion, boolean partitioned, List orderedPartitionKeys, ZoneId zone, + UnaryOperator uriNormalizer, ConnectorSession session, Optional filter) { + try (CloseableIterable tasks = countPushdownFileScanTasks(scan, session, table, filter)) { + for (FileScanTask task : tasks) { + // targetSplitSize = -1: the count-pushdown collapse emits a single range, so its scheduling + // weight is irrelevant → PluginDrivenSplit keeps SplitWeight.standard(). + return Collections.singletonList(buildRange(table, task.file(), task, formatVersion, + partitioned, orderedPartitionKeys, zone, uriNormalizer, realCount, -1, null)); + } + } catch (IOException e) { + throw new RuntimeException("Failed to plan iceberg count-pushdown file, error message is:" + + e.getMessage(), e); + } + return Collections.emptyList(); + } + + /** + * The COUNT(*)-pushdown placeholder enumeration: only the FIRST surviving file is consumed (BE serves the + * count from {@code table_level_row_count} and never reads the file). PERF-04 (C18): when the manifest cache is + * enabled, read through the lazy {@link #cacheBackedFileScanTasks} (stats overload — this runs on the single + * planning thread) so the manifest reads are cache hits and, being lazy, stop at the first file's manifest + * instead of the SDK {@code planFiles()}'s {@code ParallelIterable} eagerly submitting every manifest reader. + * An eager cache failure falls back to the SDK path (mirrors {@link #planFileScanTask}). The first surviving + * (pruned) file may differ from the SDK path's first file (its {@code ParallelIterable} order is + * non-deterministic), but the count is identical (from the snapshot summary) and BE ignores the file. Cache + * disabled -> the SDK path, byte-unchanged. + */ + private CloseableIterable countPushdownFileScanTasks(TableScan scan, ConnectorSession session, + Table table, Optional filter) { + if (isManifestCacheEnabled()) { + try { + return cacheBackedFileScanTasks(scan, session, table, filter, session.getQueryId()); + } catch (Exception e) { + LOG.warn("Iceberg count-pushdown plan with manifest cache failed, falling back to SDK scan: {}", + e.getMessage(), e); + manifestCache.recordFailure(session.getQueryId()); + } + } + return scan.planFiles(); + } + + /** + * Per-file scratch for {@link #buildRange}: the values identical for every byte-slice of one data file + * ({@code TableScanUtil.splitFiles} cuts a file into k slices whose {@code FileScanTask}s all return the + * SAME {@code DataFile} instance from {@code file()}, and emits them consecutively). Computed once on a + * file change and reused across the file's slices, collapsing the per-slice partition-JSON / identity-map / + * delete-carrier recompute to per-file (PERF-11 / C12); the k ranges then share the same immutable + * {@code partitionValues} / {@code deleteCarriers} instances (C15a). The eager loop and the streaming + * source each own ONE instance, never shared across scans. {@code file == null} = fresh / unused. + */ + private static final class PerFileScratch { + private DataFile file; + private Integer partitionSpecId; + private String partitionDataJson; + private Map partitionValues = Collections.emptyMap(); + private List deleteCarriers = Collections.emptyList(); + private String fileFormat; + private Long firstRowId; + private Long lastUpdatedSequenceNumber; + private String rawDataPath; + private String normalizedPath; + } + + /** + * Build the BE-ready {@link IcebergScanRange} for one {@link FileScanTask}, mirroring legacy + * {@code IcebergScanNode.createIcebergSplit} + {@code setIcebergParams}: the file path/offset/size, the + * per-file format (native parquet/orc), the table format version, the v3 row-lineage fields, and — for a + * partitioned table — the partition spec-id, the all-fields {@code partition_data_json}, and the ordered + * identity {@code partitionValues} that become columns-from-path. + * + *

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

    + */ + private IcebergScanRange buildRange(Table table, DataFile dataFile, FileScanTask task, int formatVersion, + boolean partitioned, List orderedPartitionKeys, ZoneId zone, + UnaryOperator uriNormalizer, long pushDownRowCount, long targetSplitSize, + PerFileScratch scratch) { + PerFileScratch file = (scratch != null && scratch.file == dataFile) + ? scratch + : computePerFileInvariants(table, dataFile, task, formatVersion, partitioned, + orderedPartitionKeys, zone, uriNormalizer, scratch); + // M-2 size-proportional weight numerator = this split's byte length + the byte size of every + // merge-on-read delete file applying to it, mirroring legacy IcebergSplit.selfSplitWeight (ctor sets + // = length; setDeleteFileFilters adds Σ delete fileSizeInBytes). The delete-size sum is file-invariant + // but the task.length() term is per byte-slice, so the whole weight stays per-slice (NEVER memoized). + // The denominator (targetSplitSize) is passed in; for the single count-pushdown range it is -1 → + // PluginDrivenSplit keeps SplitWeight.standard() (one range, weight irrelevant). + long selfSplitWeight = task.length(); + if (task.deletes() != null) { + for (DeleteFile delete : task.deletes()) { + selfSplitWeight += delete.fileSizeInBytes(); + } + } + return new IcebergScanRange.Builder() + .path(file.normalizedPath) + .originalPath(file.rawDataPath) + .start(task.start()) + .length(task.length()) + .fileSize(dataFile.fileSizeInBytes()) + .fileFormat(file.fileFormat) + .formatVersion(formatVersion) + .partitionSpecId(file.partitionSpecId) + .partitionDataJson(file.partitionDataJson) + .firstRowId(file.firstRowId) + .lastUpdatedSequenceNumber(file.lastUpdatedSequenceNumber) + .partitionValues(file.partitionValues) + .deleteFiles(file.deleteCarriers) + .pushDownRowCount(pushDownRowCount) + .selfSplitWeight(selfSplitWeight) + .targetSplitSize(targetSplitSize) + .build(); + } + + /** + * Compute {@link #buildRange}'s per-file invariants and, when {@code scratch} is non-null, store them into + * it so the file's remaining byte-slices reuse them. Uses {@code task.deletes()} (identical across a + * file's slices) for the delete carriers. Mirrors the former inline per-slice computation exactly — same + * partition-JSON / identity-map ordering, same fail-loud on a non-parquet/orc file, same v3 row-lineage — + * so the memoized range is byte-identical to the per-slice recompute. The scratch fields are assigned only + * after the fail-loud check, so a rejected file never leaves a half-populated scratch. + */ + private PerFileScratch computePerFileInvariants(Table table, DataFile dataFile, FileScanTask task, + int formatVersion, boolean partitioned, List orderedPartitionKeys, ZoneId zone, + UnaryOperator uriNormalizer, PerFileScratch scratch) { + perFileInvariantComputeCount++; + Integer partitionSpecId = null; + String partitionDataJson = null; + Map partitionValues = Collections.emptyMap(); + if (partitioned && dataFile.partition() instanceof PartitionData) { + PartitionData partitionData = (PartitionData) dataFile.partition(); + int specId = dataFile.specId(); + PartitionSpec spec = table.specs().get(specId); + partitionSpecId = specId; + partitionDataJson = IcebergPartitionUtils.getPartitionDataJson(partitionData, spec, zone); + // Order the identity values as the path_partition_keys list (legacy getOrderedPathPartitionKeys), + // filtered to keys this file carries — so columns-from-path matches legacy ordering exactly. + Map identityMap = + IcebergPartitionUtils.getIdentityPartitionInfoMap(partitionData, spec, table, zone); + Map ordered = new LinkedHashMap<>(); + for (String key : orderedPartitionKeys) { + if (identityMap.containsKey(key)) { + ordered.put(key, identityMap.get(key)); + } + } + partitionValues = ordered; + } + // Fail loud on a non-orc/parquet data file, mirroring legacy IcebergScanNode.getFileFormatType() (which + // throws DdlException at plan start). Without this guard the per-range format would silently stay the + // node default FORMAT_JNI and BE would route the file to its system-table JNI reader. (System tables, + // which legitimately use JNI, are the separate P6.5 path, not this normal-read path.) + String fileFormat = dataFile.format().name().toLowerCase(Locale.ROOT); + if (!"parquet".equals(fileFormat) && !"orc".equals(fileFormat)) { + throw new IllegalStateException( + String.format("Unsupported format name: %s for iceberg table.", fileFormat)); + } + Long firstRowId = null; + Long lastUpdatedSequenceNumber = null; + if (formatVersion >= 3) { + // -1 means a file carried over from a v2->v3 upgrade (no row lineage). The sequence-number guard is + // asymmetric (legacy): it also requires first_row_id to be present. + firstRowId = dataFile.firstRowId() != null ? dataFile.firstRowId() : -1L; + lastUpdatedSequenceNumber = + dataFile.fileSequenceNumber() != null && dataFile.firstRowId() != null + ? dataFile.fileSequenceNumber() : -1L; + } + // The range path BE opens is scheme-normalized (legacy createIcebergSplit:852 normalizes via the + // 2-arg LocationPath.of(path, storagePropertiesMap)); original_file_path stays raw so BE can match + // position-delete entries against the raw iceberg path (legacy setOriginalFilePath:304). + String rawDataPath = dataFile.path().toString(); + PerFileScratch file = scratch != null ? scratch : new PerFileScratch(); + file.file = dataFile; + file.partitionSpecId = partitionSpecId; + file.partitionDataJson = partitionDataJson; + file.partitionValues = partitionValues; + file.fileFormat = fileFormat; + file.firstRowId = firstRowId; + file.lastUpdatedSequenceNumber = lastUpdatedSequenceNumber; + file.rawDataPath = rawDataPath; + file.normalizedPath = uriNormalizer.apply(rawDataPath); + file.deleteCarriers = buildDeleteFiles(task, uriNormalizer); + return file; + } + + /** + * Translate a scan task's merge-on-read deletes ({@code task.deletes()}) into the typed delete carriers, + * mirroring legacy {@code IcebergScanNode.getDeleteFileFilters} + {@code IcebergDeleteFileFilter}. Empty + * for v1 / no-delete files (v1 has no delete files, so {@code task.deletes()} is always empty there). + */ + private List buildDeleteFiles(FileScanTask task, + UnaryOperator uriNormalizer) { + List deletes = task.deletes(); + if (deletes == null || deletes.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(deletes.size()); + for (DeleteFile delete : deletes) { + result.add(convertDelete(delete, uriNormalizer)); + } + return result; + } + + /** + * Convert one iceberg {@link DeleteFile} into a BE-facing carrier, a faithful port of legacy + * {@code getDeleteFileFilters} + {@code IcebergDeleteFileFilter.create*} + {@code setIcebergParams}: + *
      + *
    • {@code POSITION_DELETES} whose format is {@code PUFFIN} → a deletion vector (content 3) carrying + * the blob {@code content_offset}/{@code content_size_in_bytes} (plus any position bounds);
    • + *
    • other {@code POSITION_DELETES} → a position delete (content 1) with the [lower,upper] bounds;
    • + *
    • {@code EQUALITY_DELETES} → an equality delete (content 2) with the delete-file's equality + * field-ids (read straight from delete metadata — correct independent of the T06 data dictionary).
    • + *
    + * The delete path is normalized through the scan-scoped {@code uriNormalizer} (legacy + * {@code LocationPath.of(path,config).toStorageLocation()}), which bakes in the per-table vended token + * (empty for non-REST) so a REST object-store deletion path normalizes via the vended map (T09). + * Package-private for direct unit testing. + */ + IcebergScanRange.DeleteFile convertDelete(DeleteFile delete, UnaryOperator uriNormalizer) { + String path = uriNormalizer.apply(delete.path().toString()); + FileContent content = delete.content(); + if (content == FileContent.POSITION_DELETES) { + Long lowerBound = readPositionBound(delete.lowerBounds()); + Long upperBound = readPositionBound(delete.upperBounds()); + if (delete.format() == FileFormat.PUFFIN) { + Long contentOffset = delete.contentOffset(); + Long contentLength = delete.contentSizeInBytes(); + validateDeletionVectorMetadata( + delete.path().toString(), delete.fileSizeInBytes(), contentOffset, contentLength); + return IcebergScanRange.DeleteFile.deletionVector(path, lowerBound, upperBound, + contentOffset, contentLength); + } + return IcebergScanRange.DeleteFile.positionDelete(path, deleteFileFormat(delete.format()), + lowerBound, upperBound); + } else if (content == FileContent.EQUALITY_DELETES) { + return IcebergScanRange.DeleteFile.equalityDelete(path, deleteFileFormat(delete.format()), + delete.equalityFieldIds()); + } + // Defensive (legacy parity): delete files are only position or equality; DATA content here is a bug. + throw new IllegalStateException("Unknown delete content: " + content); + } + + /** + * Decode the position [lower|upper] bound from a delete file's bounds map, mirroring legacy + * {@code IcebergDeleteFileFilter.createPositionDelete}: read the {@code DELETE_FILE_POS} field's bytes and + * decode them. Returns {@code null} when the bound is absent, or is the {@code -1} sentinel (legacy stores + * {@code orElse(-1L)} and emits the thrift bound only when present), so {@link #convertDelete} sets the + * thrift bound only when a real one exists. + */ + private static Long readPositionBound(Map bounds) { + if (bounds == null) { + return null; + } + ByteBuffer buf = bounds.get(MetadataColumns.DELETE_FILE_POS.fieldId()); + if (buf == null) { + return null; + } + Long value = Conversions.fromByteBuffer(MetadataColumns.DELETE_FILE_POS.type(), buf); + return (value == null || value == -1L) ? null : value; + } + + /** + * Map an iceberg delete-file format to BE's file-format type, mirroring legacy {@code setDeleteFileFormat}: + * only parquet/orc are emitted; any other format (notably {@code PUFFIN} deletion vectors) leaves the + * thrift {@code file_format} unset ({@code null} here). + */ + private static TFileFormatType deleteFileFormat(FileFormat format) { + if (format == FileFormat.PARQUET) { + return TFileFormatType.FORMAT_PARQUET; + } else if (format == FileFormat.ORC) { + return TFileFormatType.FORMAT_ORC; + } + return null; + } + + /** + * Build the scan-scoped URI normalizer once (where the per-table vended token is extracted) and thread it + * through the per-file range builders, instead of re-deriving the vended storage config per data/delete + * file. Each application normalizes a raw iceberg storage path (the data file BE opens, or a delete file) + * to BE's canonical scheme via the engine seam (legacy goes through {@code LocationPath.of(path, + * storagePropertiesMap).toStorageLocation()}; the connector cannot import fe-core's {@code LocationPath}). + * BE's scheme-dispatched S3 factory only opens {@code s3://}, so an un-normalized {@code oss://}/{@code + * cos://}/{@code obs://}/{@code s3a://} path fails the native read (data file) or silently drops the deletes + * (merge-on-read wrong rows). Mirrors paimon's {@code normalizeUri} (FIX-URI-NORMALIZE), which normalizes + * both the data-file and deletion-vector paths. The {@code vendedToken} (empty for non-REST / no context) + * is the per-table vended credential map, baked into the normalizer so a REST object-store path normalizes + * via the vended map (T09); when empty the seam folds to the catalog's static storage map, byte-equivalent + * to legacy for non-vended catalogs. A {@code null} context (offline unit tests) yields an identity + * normalizer that preserves the raw path (paimon parity). + */ + UnaryOperator newUriNormalizer(Map vendedToken) { + return context != null ? context.newStorageUriNormalizer(vendedToken) : UnaryOperator.identity(); + } + + /** + * Whether this catalog requests REST vended credentials, gating {@link #extractVendedToken}. Faithfully + * reproduces legacy {@code IcebergVendedCredentialsProvider.isVendedCredentialsEnabled}, which is TWO-part: + * the metastore is REST ({@code metastoreProperties instanceof IcebergRestProperties}) AND the flag + * {@code iceberg.rest.vended-credentials-enabled} is true. The {@code instanceof} is mirrored by the flavor + * check (the flag is declared only on {@code IcebergRestProperties}, so on a non-REST flavor legacy ignores + * it and never vends) — without it a non-REST catalog that erroneously carries the flag would extract vended + * creds that legacy suppresses. Same flag T05 uses to inject the REST delegation header. + */ + private boolean restVendedCredentialsEnabled() { + return restVendedCredentialsEnabled(properties); + } + + /** + * Package-static form shared with {@link IcebergWritePlanProvider} (the write sink applies the same + * vended-credentials gate to its hadoop config / output path). Pure function of the catalog properties. + */ + static boolean restVendedCredentialsEnabled(Map properties) { + return IcebergConnectorProperties.TYPE_REST.equals(IcebergCatalogFactory.resolveFlavor(properties)) + && Boolean.parseBoolean(properties.get(IcebergConnectorProperties.REST_VENDED_CREDENTIALS_ENABLED)); + } + + /** + * Extracts the raw per-table vended credential token from a REST catalog table's {@link FileIO}, a faithful + * port of legacy {@code IcebergVendedCredentialsProvider.extractRawVendedCredentials} (iceberg SDK only, no + * fe-core import): the FileIO's own {@code properties()} plus, when the FileIO + * {@link SupportsStorageCredentials}, every server-vended {@link StorageCredential}'s {@code config()}. The + * gate is the catalog flag ({@code vendedEnabled}) checked BEFORE extraction, equivalent to legacy's + * "metastore is REST and vended enabled" guard; returns empty when disabled, the table/FileIO is null, so + * the downstream {@code vendStorageCredentials} / {@code normalizeStorageUri} overlays are no-ops for + * non-REST reads. Iceberg (unlike paimon's {@code RESTTokenFileIO.validToken()}) has no explicit token + * refresh — the credentials are fresh because the REST catalog reloads the table per query. + */ + static Map extractVendedToken(Table table, boolean vendedEnabled) { + if (!vendedEnabled || table == null || table.io() == null) { + return Collections.emptyMap(); + } + FileIO fileIO = table.io(); + Map ioProps = new HashMap<>(fileIO.properties()); + if (fileIO instanceof SupportsStorageCredentials) { + for (StorageCredential storageCredential : ((SupportsStorageCredentials) fileIO).credentials()) { + ioProps.putAll(storageCredential.config()); + } + } + return ioProps; + } + + /** + * Scan-node-level (not per-range) properties consumed by the generic {@code PluginDrivenScanNode}: + *
      + *
    • {@code file_format_type=jni} — makes the parent default the per-range format to {@code FORMAT_JNI}, + * which each native range overrides to parquet/orc in {@code populateRangeParams} (mirrors paimon).
    • + *
    • {@code path_partition_keys} — the lowercased, comma-joined identity partition columns, so FE marks + * those slots as partition columns and excludes them from the file-decode set; without it BE + * double-fills the partition columns (decode-from-file AND append-from-path) and DCHECKs (CI #968880). + * Emitted only when the table is partitioned (an empty value would split into a single "" key).
    • + *
    • {@code iceberg.schema_evolution} (T06) — the base64 field-id schema dictionary + * ({@code current_schema_id = -1} + one {@code history_schema_info} entry), built from the requested + * columns so BE field-id-matches file↔table columns across rename/reorder (see + * {@link IcebergSchemaUtils}). Emitted unconditionally (legacy {@code createScanRangeLocations} + * always sets the dict). {@link #populateScanLevelParams} applies it.
    • + *
    • {@code location.*} (T09) — the BE-canonical storage credentials: the catalog's static creds + * (all flavors) plus, for a REST vended catalog, the per-table vended overlay (legacy precedence). + * Without these BE opens the object store with no creds (403). See {@link #extractVendedToken}.
    • + *
    + * The serialized-table key (JNI system-table path) lands in a later task. + */ + @Override + public Map getScanNodeProperties( + ConnectorSession session, + ConnectorTableHandle handle, + List columns, + Optional filter) { + IcebergTableHandle iceHandle = (IcebergTableHandle) handle; + Table table = resolveTable(session, iceHandle); + Map props = new LinkedHashMap<>(); + boolean systemTable = iceHandle.isSystemTable(); + // Scan-level file_format_type is NOT merely a per-range default: BE selects FileScannerV2 vs the V1 + // FileScanner from it (FileScanLocalState::_should_use_file_scanner_v2), and that selector runs before + // any split is fetched -> FORMAT_JNI here pins the ENTIRE scan to V1 whatever IcebergScanRange sets per + // range. V1 is the only tree carrying TableSchemaChangeHelper, so pinning there also re-exposes every + // V1-only reader bug. Emit the table's real data format (legacy IcebergScanNode.getFileFormatType + // parity, same IcebergUtils.getFileFormat resolution, which throws on a non-parquet/orc table); the + // per-file format still travels per range, since one table may mix parquet and orc data files. + // PERF-03: the non-system format resolution falls back to an unfiltered whole-table planFiles() when the + // table sets neither write-format nor write.format.default; memoize that inference per (table, snapshot) + // across queries via formatCache (pure metadata, no credential gate). Null cache (offline) resolves live. + props.put("file_format_type", + systemTable ? "jni" + : IcebergWriterHelper.getFileFormat(table, + TableIdentifier.of(iceHandle.getDbName(), iceHandle.getTableName()), formatCache) + .name().toLowerCase(Locale.ROOT)); + // [D-065] System (metadata) tables ($snapshots/$files/...) read via the JNI serialized-split path + // (planSystemTableScan): the metadata-table schema travels INSIDE the serialized FileScanTask, so BE + // needs neither the base-table path_partition_keys (a metadata table is not base-spec partitioned -> + // emitting them would double-fill/DCHECK) nor the field-id schema-evolution dict. Worse, building the + // dict for a sys handle uses the BASE schema keyed off the requested META columns -> + // IcebergSchemaUtils throws ("requested column not found"). So skip BOTH for a sys handle, keeping + // file_format_type=jni and the location.* credential overlay (BE still needs creds to read the + // metadata files). Mirrors paimon, whose getScanNodeProperties skips both for a metadata table + // (empty partitionKeys + null schema-dict table). resolveTable still loads the base table here for + // the credential overlay below (the metadata table shares the base table's FileIO). + if (!systemTable) { + List partitionKeys = IcebergPartitionUtils.getIdentityPartitionColumns(table); + if (!partitionKeys.isEmpty()) { + props.put("path_partition_keys", String.join(",", partitionKeys)); + } + } + // Static storage credentials (T09, all flavors): the catalog's bound fe-filesystem StorageProperties, + // normalized to BE-canonical scan keys (AWS_* for object stores, hadoop/dfs for HDFS) and shipped under + // location.*. BLOCKER: BE's native (FILE_S3) reader understands ONLY the canonical keys, so the raw + // catalog aliases (s3.access_key, oss.access_key, …) must be translated before they leave FE — copying + // them verbatim gives BE no usable creds (403 on a private bucket). Mirrors paimon getScanNodeProperties + // + legacy IcebergScanNode.getLocationProperties (backendStorageProperties). Empty for no context + // (offline tests) or a REST-vended catalog (whose static storage map is empty by design) -> no static + // overlay, just the vended one below. + if (context != null) { + Map backendStorageProps = new HashMap<>(); + for (StorageProperties sp : context.getStorageProperties()) { + sp.toBackendProperties().ifPresent(b -> backendStorageProps.putAll(b.toMap())); + } + backendStorageProps.forEach((k, v) -> props.put("location." + 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, + // legacy IcebergVendedCredentialsProvider parity), then normalized to BE-facing AWS_* keys by the engine + // (the connector cannot import fe-core's StorageProperties). Vended overlays static (legacy precedence — + // a colliding location.* key takes the vended value). Skipped when no context (offline tests) or the + // 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)); + } + // 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 + // lands after buildColumnHandles), so a column renamed between the pinned snapshot and now would be + // dropped from `columns` -> the dict would miss that BE scan slot -> BE StructNode DCHECK crash. Build + // the dict from the FULL pinned schema (a guaranteed superset of the BE slots — iceberg projection is + // BE-tuple-driven, so `columns` only feeds the dict). See P6-T07 design §6. Without a pin, keep T06's + // pruned-by-requested-columns dict (CI #969249). + // + // Row-lineage (format-version >= 3): _row_id / _last_updated_sequence_number are GENERATED BE scan slots + // (they reach BE column_names) but are NOT in schema.columns(), so requestedLowerNames — keyed off the + // iceberg column handles — never carries them. encodeSchemaEvolutionProp(appendRowLineage=true) appends + // them to the dict root so BE's StructNode children map contains them; else the ParquetReader's + // unconditional children.at("_row_id") std::out_of_range-SIGABRTs the whole BE. + if (!systemTable) { + String dict; + boolean appendRowLineage = getFormatVersion(table) >= 3; + // #65502: the catalog's enable.mapping.timestamp_tz flag controls whether a TIMESTAMPTZ column's + // iceberg initial default keeps its trailing offset (mapping on) or is rendered as UTC wall time + // (mapping off, DATETIMEV2). Thread it into every dict branch so the default matches BE's read. + boolean enableTimestampTz = Boolean.parseBoolean( + properties.getOrDefault(IcebergConnectorProperties.ENABLE_MAPPING_TIMESTAMP_TZ, "false")); + if (iceHandle.hasSnapshotPin()) { + dict = IcebergSchemaUtils.encodeSchemaEvolutionProp( + table, pinnedSchema(table, iceHandle), Collections.emptyList(), appendRowLineage, + enableTimestampTz); + } else if (iceHandle.isTopnLazyMaterialize()) { + // Top-N lazy materialization (M-4): BE re-fetches the non-projected columns of the surviving + // rows by the synthesized row-id, so the dict must span the FULL latest schema, not just the + // pruned slots — otherwise a lazily re-fetched column on a schema-evolved table has no + // field-id entry and the native read drops/mis-reads it. An empty requested list builds the + // dict over every top-level column (legacy initSchemaInfoForAllColumn parity). + dict = IcebergSchemaUtils.encodeSchemaEvolutionProp( + table, table.schema(), Collections.emptyList(), appendRowLineage, enableTimestampTz); + } else { + dict = IcebergSchemaUtils.encodeSchemaEvolutionProp( + table, table.schema(), + withEqualityDeleteKeyColumns(table, requestedLowerNames(columns)), + appendRowLineage, enableTimestampTz); + } + props.put(SCHEMA_EVOLUTION_PROP, dict); + } else if (isPositionDeletesSysTable(iceHandle)) { + // [D-065] narrowed: $position_deletes is the ONE system table BE reads with a NATIVE reader, so + // the "schema rides inside the serialized FileScanTask" rationale above does not hold for it — no + // FileScanTask is serialized on this path. Both native readers resolve the `row` column through + // params.history_schema_info: without the dict, scanner v1 hard-errors ("Iceberg position delete + // system table row schema is missing") and scanner v2 SILENTLY degrades to name matching, which + // mis-reads a renamed column under schema evolution. Skipping the dict here would be silent wrong + // data, so emit it. + // + // Built from the METADATA table (its own schema keyed by its own requested columns) — feeding the + // BASE schema keyed off META columns is exactly what makes IcebergSchemaUtils throw "requested + // column not found", per the comment above. The metadata table delegates properties() to the base + // table, so the name mapping resolved inside is the base table's, matching legacy. + // + // appendRowLineage=false: that flag exists for the DATA-file ParquetReader's unconditional + // children.at("_row_id") lookup; the position-delete reader opens the DELETE file, whose schema + // carries no row-lineage columns. + // + // Built from the base table ALREADY resolved above, not via resolveSysTable: that helper does a + // second loadTable and — by its own contract — carries NO auth wrap, because its only other caller + // (planSystemTableScan) supplies one. This method has no such scope, so calling it here would fail + // a kerberized catalog at plan time. createMetadataTableInstance is a pure local construction over + // an already-loaded table, so this is also one fewer remote round-trip. + Table metadataTable = MetadataTableUtils.createMetadataTableInstance( + table, MetadataTableType.POSITION_DELETES); + props.put(SCHEMA_EVOLUTION_PROP, IcebergSchemaUtils.encodeSchemaEvolutionProp( + metadataTable, metadataTable.schema(), requestedLowerNames(columns), false)); + } + // Pushed-predicate EXPLAIN prop (explain gap): serialize the iceberg Expression form of each pushed + // conjunct so appendExplainInfo can re-emit the legacy `icebergPredicatePushdown=` block. Same converter + // as buildScan (current schema + session zone) → byte-identical strings. A system table has no + // base-spec pushdown path (its converter would key off the wrong schema), so skip it — mirroring the + // schema-dict gate above. Absent / no convertible conjunct → no prop → no EXPLAIN line (legacy parity: + // IcebergScanNode `if (!pushdownIcebergPredicates.isEmpty())`). + if (!systemTable && filter.isPresent()) { + List pushed = + new IcebergPredicateConverter(table.schema(), resolveSessionZone(session)).convert(filter.get()); + if (!pushed.isEmpty()) { + StringBuilder sb = new StringBuilder(); + for (Expression predicate : pushed) { + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(predicate); + } + props.put(PUSHDOWN_PREDICATES_PROP, sb.toString()); + } + } + // Carry the queryId for the per-scan `manifest cache:` VERBOSE line ONLY when the cache is enabled + // (omitted otherwise -> appendExplainInfo prints no line, legacy notContains parity). Skip system tables: + // they have no manifest-level planning path. FE-only, like PUSHDOWN_PREDICATES_PROP. + if (!systemTable && isManifestCacheEnabled()) { + props.put(MANIFEST_CACHE_QUERYID_PROP, session.getQueryId()); + } + return props; + } + + /** + * The lowercased names of the requested (pruned) columns — the authoritative Doris scan slots the field-id + * dictionary keys its {@code -1} entry off (so its top-level names == the BE scan-slot names BY + * CONSTRUCTION; CI #969249). The names come straight from the {@link IcebergColumnHandle}s + * {@code IcebergConnectorMetadata.getColumnHandles} produced (already lowercased). An empty list (count-only + * scan / no column handles) makes the dictionary fall back to all top-level columns. + */ + private static List requestedLowerNames(List columns) { + if (columns == null || columns.isEmpty()) { + return Collections.emptyList(); + } + List names = new ArrayList<>(columns.size()); + for (ConnectorColumnHandle column : columns) { + names.add(((IcebergColumnHandle) column).getName()); + } + return names; + } + + /** + * Ensure the schema-evolution dict carries the table's equality-delete KEY columns even when the query + * does not project them (#65502). Equality-delete keys are hidden scan dependencies: BE resolves a key + * that is missing from an OLD data file by looking its field id up in this dict to get the column type + + * iceberg initial default; without the entry BE materializes the key as NULL and mis-applies the delete. + * The keys are the table's declared identifier fields (what equality-delete writers key on) -> a few + * columns, DCHECK-safe superset (BE looks up only its own scan slots; the pin/top-N branches already ship + * the full schema). If the table declares NO identifier yet the scan carries equality deletes (whose + * equality_ids we cannot cheaply enumerate here), fall back to the full schema. Non-identifier / + * append-only / position-delete-only tables are unaffected (the pruned dict is returned verbatim). + */ + private List withEqualityDeleteKeyColumns(Table table, List requested) { + if (requested.isEmpty()) { + // An empty requested list already makes buildCurrentSchema fall back to the FULL schema (every + // top-level column) — a superset that covers every equality-delete key — so there is nothing to + // force-include. Returning early also preserves that all-columns fallback (a non-empty identifier + // set would otherwise prune it to identifier-only) and skips the table.schema()/currentSnapshot() + // probe when it cannot change the result. + return requested; + } + Schema schema = table.schema(); + Set identifierFieldIds = schema.identifierFieldIds(); + if (identifierFieldIds.isEmpty()) { + return hasEqualityDeletes(table) ? Collections.emptyList() : requested; + } + Set present = new HashSet<>(); + for (String name : requested) { + present.add(name.toLowerCase(Locale.ROOT)); + } + List result = new ArrayList<>(requested); + for (int fieldId : identifierFieldIds) { + Types.NestedField field = schema.findField(fieldId); + if (field == null) { + continue; + } + String lower = field.name().toLowerCase(Locale.ROOT); + if (present.add(lower)) { + result.add(lower); + } + } + return result; + } + + private static boolean hasEqualityDeletes(Table table) { + Snapshot snapshot = table.currentSnapshot(); + if (snapshot == null) { + return false; + } + String equalityDeletes = snapshot.summary().get(TOTAL_EQUALITY_DELETES); + // Absent (compaction/replace snapshots omit the counter) -> unknown -> assume present (safe superset). + return equalityDeletes == null || !equalityDeletes.equals("0"); + } + + /** + * Apply the scan-level field-id schema dictionary (T06) built in {@link #getScanNodeProperties} to the real + * {@link TFileScanRangeParams} (the same props map is round-tripped by the generic + * {@code PluginDrivenScanNode}). Delegates to {@link IcebergSchemaUtils#applySchemaEvolution}, which fails + * loud on a decode error (the prop is produced by us — silently dropping it would re-introduce the silent + * wrong-rows BLOCKER on schema-evolved native reads). + */ + @Override + public void populateScanLevelParams(TFileScanRangeParams params, Map nodeProperties) { + IcebergSchemaUtils.applySchemaEvolution(params, nodeProperties.get(SCHEMA_EVOLUTION_PROP)); + } + + /** + * Re-emit the legacy {@code IcebergScanNode} {@code icebergPredicatePushdown=} EXPLAIN block from the + * {@link #PUSHDOWN_PREDICATES_PROP} prop (the pushed iceberg {@code Expression.toString()}s, newline-joined, + * serialized by {@link #getScanNodeProperties}). Connector-specific EXPLAIN delegated by the generic + * {@code PluginDrivenScanNode} (which owns the source-agnostic FileScanNode body). Byte-faithful to legacy + * {@code IcebergScanNode.getNodeExplainString}: each predicate double-prefix indented under the header; an + * absent prop (no pushed predicate, or another connector's props map) prints nothing. + */ + @Override + public void appendExplainInfo(StringBuilder output, String prefix, Map nodeProperties) { + // Per-scan manifest cache stats (VERBOSE only — the generic node calls this at VERBOSE). Emitted iff the + // FE-only queryId prop is present (i.e. the cache is enabled), draining THIS scan's tally from the shared + // per-catalog cache. Rendered BEFORE the pushdown block AND its early-return below, so a cache-enabled + // scan with no pushed predicate still prints the line — legacy IcebergScanNode emitted it independently + // of the `icebergPredicatePushdown=` block. + String manifestCacheQueryId = nodeProperties.get(MANIFEST_CACHE_QUERYID_PROP); + if (manifestCacheQueryId != null && manifestCache != null) { + long[] stats = manifestCache.takeStats(manifestCacheQueryId); + output.append(prefix).append("manifest cache: hits=").append(stats[0]) + .append(", misses=").append(stats[1]) + .append(", failures=").append(stats[2]).append("\n"); + } + String encoded = nodeProperties.get(PUSHDOWN_PREDICATES_PROP); + if (encoded == null || encoded.isEmpty()) { + return; + } + StringBuilder sb = new StringBuilder(); + for (String predicate : encoded.split("\n")) { + sb.append(prefix).append(prefix).append(predicate).append("\n"); + } + output.append(String.format("%sicebergPredicatePushdown=\n%s\n", prefix, sb)); + } + + /** + * Read back the delete-file paths carried by one range's {@code iceberg_params}, for the VERBOSE + * per-backend EXPLAIN block ({@code deleteFileNum}/{@code deleteSplitNum}). Verbatim port of legacy + * {@code IcebergScanNode.getDeleteFiles} (and the shape of paimon's {@code getDeleteFiles}): every + * delete file's path, including equality deletes (the equality-vs-non-equality split legacy keeps in + * {@code deleteFilesByReferencedDataFile} is only for the write/rewrite path, not this count). Returns + * empty when the range carries no iceberg params or no delete files (v1 / no-delete table). + */ + @Override + public List getDeleteFiles(TTableFormatFileDesc tableFormatParams) { + List deleteFiles = new ArrayList<>(); + if (tableFormatParams == null || !tableFormatParams.isSetIcebergParams()) { + return deleteFiles; + } + TIcebergFileDesc icebergParams = tableFormatParams.getIcebergParams(); + if (icebergParams == null || !icebergParams.isSetDeleteFiles()) { + return deleteFiles; + } + List icebergDeleteFiles = icebergParams.getDeleteFiles(); + if (icebergDeleteFiles == null) { + return deleteFiles; + } + for (TIcebergDeleteFileDesc deleteFile : icebergDeleteFiles) { + if (deleteFile != null && deleteFile.isSetPath()) { + deleteFiles.add(deleteFile.getPath()); + } + } + return deleteFiles; + } + + /** + * Reads the real table format version, mirroring legacy {@code IcebergUtils.getFormatVersion} (and the + * connector's {@code IcebergConnectorMetadata.getFormatVersion}): from a {@link BaseTable}'s current + * metadata when available, else the {@code format-version} table property, defaulting to 2. + */ + private static int getFormatVersion(Table table) { + int formatVersion = 2; + if (table instanceof BaseTable) { + formatVersion = ((BaseTable) table).operations().current().formatVersion(); + } else if (table != null && table.properties() != null) { + String version = table.properties().get(TableProperties.FORMAT_VERSION); + if (version != null) { + try { + formatVersion = Integer.parseInt(version); + } catch (NumberFormatException ignored) { + // keep the default + } + } + } + return formatVersion; + } + + /** + * The byte-offset-split {@link FileScanTask}s of one scan plus the scan-level weight denominator (legacy + * {@code IcebergScanNode.targetSplitSize}). The denominator is threaded into each normal data-file range's + * {@link IcebergScanRange#getTargetSplitSize()} so {@code FederationBackendPolicy} schedules splits by byte + * size, not uniformly by count (M-2). Immutable holder (provider may be reused → no mutable field); + * {@link #close()} closes the underlying iterable so {@code planScanInternal}'s try-with-resources frees it. + */ + private static final class SplitPlan implements Closeable { + private final CloseableIterable tasks; + private final long targetSplitSize; + + SplitPlan(CloseableIterable tasks, long targetSplitSize) { + this.tasks = tasks; + this.targetSplitSize = targetSplitSize; + } + + @Override + public void close() throws IOException { + tasks.close(); + } + } + + /** + * Enumerate + byte-offset-split the data files of a built scan via the iceberg SDK + * {@code TableScanUtil.splitFiles} (the legacy {@code IcebergScanNode.splitFiles} algorithm — NOT fe-core + * {@code FileSplitter}). A positive {@code file_split_size} session var forces that granularity directly; + * otherwise the tasks are materialized once and split at the {@link #determineTargetFileSplitSize} + * heuristic. Batch mode is deferred (paimon parity). Returns the split tasks plus the weight denominator + * (M-2): the forced {@code file_split_size} (that path slices to it) or the heuristic {@code targetSplitSize} + * (legacy {@code IcebergScanNode.targetSplitSize}, the same value used to slice — legacy reuses it for both). + */ + private SplitPlan splitFiles(TableScan scan, ConnectorSession session) { + long fileSplitSize = sessionLong(session, FILE_SPLIT_SIZE, 0L); + if (fileSplitSize > 0) { + // The split granularity IS fileSplitSize, so it is the correct weight denominator. (Legacy left its + // targetSplitSize field at 0 here → divide-by-zero → weight clamped to 1.0; the generic + // PluginDrivenSplit guards target>0, so reproducing that is impossible without un-guarding division + // for all connectors. Using fileSplitSize gives proper proportional weighting — strictly better.) + return new SplitPlan(TableScanUtil.splitFiles(scan.planFiles(), fileSplitSize), fileSplitSize); + } + List fileScanTasks = new ArrayList<>(); + try (CloseableIterable planned = scan.planFiles()) { + for (FileScanTask task : planned) { + fileScanTasks.add(task); + } + } catch (IOException e) { + throw new RuntimeException("Failed to materialize iceberg file scan tasks, error message is:" + + e.getMessage(), e); + } + long targetSplitSize = determineTargetFileSplitSize(fileScanTasks, session); + return new SplitPlan( + TableScanUtil.splitFiles(CloseableIterable.withNoopClose(fileScanTasks), targetSplitSize), + targetSplitSize); + } + + /** + * Gate between the two file-enumeration paths (port of legacy {@code IcebergScanNode.planFileScanTask}). By + * default ({@code meta.cache.iceberg.manifest.enable} off) and whenever no manifest cache is wired, this is + * the iceberg SDK {@code splitFiles} path — byte-identical to T02. When the manifest cache is enabled it + * re-plans at the manifest level so the per-manifest data/delete reads hit {@link IcebergManifestCache}; + * any failure falls back to {@code splitFiles} (legacy parity), so a cache bug can never break a query. + */ + private SplitPlan planFileScanTask(TableScan scan, ConnectorSession session, Table table, + Optional filter) { + if (!isManifestCacheEnabled()) { + return splitFiles(scan, session); + } + try { + return planFileScanTaskWithManifestCache(scan, session, table, filter); + } catch (Exception e) { + LOG.warn("Iceberg plan with manifest cache failed, falling back to SDK scan: {}", e.getMessage(), e); + // Mirror the legacy manifestCacheFailures bump so VERBOSE EXPLAIN can report the fallback. + manifestCache.recordFailure(session.getQueryId()); + return splitFiles(scan, session); + } + } + + /** + * Synchronous (below-batch-threshold) manifest-cache planning: materialize the shared lazy + * {@link #cacheBackedFileScanTasks} enumeration into a list, because {@link #determineTargetFileSplitSize} + * (the per-table heuristic that gives small tables good BE parallelism) needs the whole task list. Byte + * identical to the pre-PERF-04 body; the streaming ({@link #streamSplits}) and COUNT(*) + * ({@link #planCountPushdown}) paths consume the same enumeration LAZILY instead (bounded FE heap). Uses the + * stats-tallying cache overload — this path runs on the single planning thread. The predicate / metrics / + * schema use the table's CURRENT schema (legacy parity). + */ + private SplitPlan planFileScanTaskWithManifestCache(TableScan scan, + ConnectorSession session, Table table, Optional filter) throws IOException { + // Null-safe queryId (offline tests pass a null session): a null id selects the no-stats cache overload, + // matching the pre-PERF-04 body, which read scan.snapshot() and returned early for an empty table BEFORE + // ever calling session.getQueryId(). + String statsQueryId = session != null ? session.getQueryId() : null; + List tasks = new ArrayList<>(); + try (CloseableIterable whole = + cacheBackedFileScanTasks(scan, session, table, filter, statsQueryId)) { + for (FileScanTask task : whole) { + tasks.add(task); + } + } + long targetSplitSize = determineTargetFileSplitSize(tasks, session); + return new SplitPlan( + TableScanUtil.splitFiles(CloseableIterable.withNoopClose(tasks), targetSplitSize), targetSplitSize); + } + + /** + * PERF-04: the lazy, cache-backed {@link FileScanTask} enumeration shared by the synchronous + * ({@link #planFileScanTaskWithManifestCache}), streaming ({@link #streamSplits}), and COUNT(*) + * ({@link #planCountPushdown}) paths whenever {@link #isManifestCacheEnabled()}. Ported faithfully from legacy + * {@code IcebergScanNode.planFileScanTaskWithManifestCache}: partition-prune manifests with a + * {@link ManifestEvaluator}, read each surviving manifest's data/delete files THROUGH THE CACHE, then per data + * file apply the {@link InclusiveMetricsEvaluator} (file-stats prune) + {@link ResidualEvaluator} (partition + * residual) and attach its deletes via a {@link DeleteFileIndex}. Produces WHOLE-FILE + * {@link BaseFileScanTask}s (callers byte-offset-split via {@link TableScanUtil#splitFiles}). + * + *

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

    {@code statsQueryId} is nullable: a non-null id tallies cache hits/misses under that query (the + * single-threaded synchronous + COUNT paths); {@code null} selects the no-stats overload for the STREAMING + * path, whose Phase 2 runs on the engine pump thread while Phase 1 ran on the calling thread — tallying the + * per-query {@code ScanStats} counters from two threads would race (the cache documents them as + * single-thread-per-query), and streaming reports no manifest-cache stats today anyway. + */ + private CloseableIterable cacheBackedFileScanTasks(TableScan scan, + ConnectorSession session, Table table, Optional filter, String statsQueryId) { + Snapshot snapshot = scan.snapshot(); + if (snapshot == null) { + return CloseableIterable.withNoopClose(Collections.emptyList()); + } + Expression filterExpr = combineFilter(filter, table, session); + Map specsById = table.specs(); + boolean caseSensitive = true; + + Map residualEvaluators = new HashMap<>(); + specsById.forEach((id, spec) -> residualEvaluators.put(id, + ResidualEvaluator.of(spec, filterExpr, caseSensitive))); + InclusiveMetricsEvaluator metricsEvaluator = + new InclusiveMetricsEvaluator(table.schema(), filterExpr, caseSensitive); + String schemaJson = SchemaParser.toJson(table.schema()); + + // Phase 1 (eager): partition-prune + cache-load delete manifests into the delete-file index. + List deleteFiles = new ArrayList<>(); + for (ManifestFile manifest : snapshot.deleteManifests(table.io())) { + if (manifest.content() != ManifestContent.DELETES) { + continue; + } + PartitionSpec spec = specsById.get(manifest.partitionSpecId()); + if (spec == null) { + continue; + } + if (!ManifestEvaluator.forPartitionFilter(filterExpr, spec, caseSensitive).eval(manifest)) { + continue; + } + deleteFiles.addAll(manifestCacheGet(manifest, table, statsQueryId).getDeleteFiles()); + } + DeleteFileIndex deleteIndex = DeleteFileIndex.builderFor(deleteFiles) + .specsById(specsById) + .caseSensitive(caseSensitive) + .build(); + + // Phase 2 (lazy): flat-map the matching data manifests, read through the cache on demand. + CloseableIterable dataManifests = + getMatchingManifest(snapshot.dataManifests(table.io()), specsById, filterExpr); + return new CloseableIterable() { + @Override + public CloseableIterator iterator() { + return new ManifestCacheFileScanTaskIterator(dataManifests.iterator(), table, specsById, + residualEvaluators, metricsEvaluator, deleteIndex, schemaJson, statsQueryId); + } + + @Override + public void close() throws IOException { + dataManifests.close(); + } + }; + } + + /** Dispatch a manifest read to the stats-tallying or no-stats cache overload (see cacheBackedFileScanTasks). */ + private ManifestCacheValue manifestCacheGet(ManifestFile manifest, Table table, String statsQueryId) { + return statsQueryId != null + ? manifestCache.getManifestCacheValue(manifest, table, statsQueryId) + : manifestCache.getManifestCacheValue(manifest, table); + } + + /** + * Lazy flat-map iterator behind {@link #cacheBackedFileScanTasks}: walks the matching data manifests, reads + * each through the manifest cache on demand, and yields the surviving (metrics + residual pruned) + * {@link BaseFileScanTask}s one file at a time so the consumer never holds the whole table's task list. Not + * thread-safe: single-pass, driven by ONE consumer (the sync materialize loop, the streaming pump, or the + * COUNT take-first). {@code schemaJson}/{@code currentSpecJson} are hoisted loop invariants (constant per + * table / per manifest spec), byte-identical to the pre-PERF-04 per-file computation. + */ + private final class ManifestCacheFileScanTaskIterator implements CloseableIterator { + private final CloseableIterator manifestIt; + private final Table table; + private final Map specsById; + private final Map residualEvaluators; + private final InclusiveMetricsEvaluator metricsEvaluator; + private final DeleteFileIndex deleteIndex; + private final String schemaJson; + private final String statsQueryId; + + private Iterator dataFileIt; + private ResidualEvaluator currentResidual; + private String currentSpecJson; + private FileScanTask next; + + ManifestCacheFileScanTaskIterator(CloseableIterator manifestIt, Table table, + Map specsById, Map residualEvaluators, + InclusiveMetricsEvaluator metricsEvaluator, DeleteFileIndex deleteIndex, String schemaJson, + String statsQueryId) { + this.manifestIt = manifestIt; + this.table = table; + this.specsById = specsById; + this.residualEvaluators = residualEvaluators; + this.metricsEvaluator = metricsEvaluator; + this.deleteIndex = deleteIndex; + this.schemaJson = schemaJson; + this.statsQueryId = statsQueryId; + } + + @Override + public boolean hasNext() { + advance(); + return next != null; + } + + @Override + public FileScanTask next() { + advance(); + if (next == null) { + throw new NoSuchElementException(); + } + FileScanTask result = next; + next = null; + return result; + } + + // Fill `next` with the next surviving task, draining the current data manifest's files and advancing to + // further data manifests as needed. Per-file logic mirrors the pre-PERF-04 materialized Phase 2 exactly. + private void advance() { + while (next == null) { + if (dataFileIt != null && dataFileIt.hasNext()) { + DataFile dataFile = dataFileIt.next(); + if (!metricsEvaluator.eval(dataFile)) { + continue; + } + if (currentResidual.residualFor(dataFile.partition()).equals(Expressions.alwaysFalse())) { + continue; + } + DeleteFile[] deletes = deleteIndex.forDataFile(dataFile.dataSequenceNumber(), dataFile); + next = new BaseFileScanTask(dataFile, deletes, schemaJson, currentSpecJson, currentResidual); + return; + } + if (!manifestIt.hasNext()) { + return; + } + ManifestFile manifest = manifestIt.next(); + if (manifest.content() != ManifestContent.DATA) { + dataFileIt = null; + continue; + } + PartitionSpec spec = specsById.get(manifest.partitionSpecId()); + ResidualEvaluator residual = residualEvaluators.get(manifest.partitionSpecId()); + if (spec == null || residual == null) { + dataFileIt = null; + continue; + } + currentResidual = residual; + currentSpecJson = PartitionSpecParser.toJson(spec); + dataFileIt = manifestCacheGet(manifest, table, statsQueryId).getDataFiles().iterator(); + } + } + + @Override + public void close() throws IOException { + manifestIt.close(); + } + } + + /** + * Combine the pushed predicate into one iceberg {@link Expression} for manifest-level pruning, mirroring + * legacy {@code conjuncts.stream().map(convertToIcebergExpr).filter(nonNull).reduce(alwaysTrue, and)}. Reuses + * the T02 {@link IcebergPredicateConverter} on the table's CURRENT schema; an absent filter is + * {@code alwaysTrue()} (scan everything). + */ + private Expression combineFilter(Optional filter, Table table, ConnectorSession session) { + if (!filter.isPresent()) { + return Expressions.alwaysTrue(); + } + List predicates = + new IcebergPredicateConverter(table.schema(), resolveSessionZone(session)).convert(filter.get()); + Expression combined = Expressions.alwaysTrue(); + for (Expression predicate : predicates) { + combined = Expressions.and(combined, predicate); + } + return combined; + } + + /** + * Port of legacy {@code IcebergUtils.getMatchingManifest}: keep only the data manifests whose partition + * summaries can match {@code dataFilter} (a {@link ManifestEvaluator} over the spec-projected filter) and + * that still hold added/existing files. Uses a per-call {@link HashMap} evaluator memo (single-threaded + * iteration) in place of legacy's Caffeine {@code LoadingCache} — semantically identical. + */ + private static CloseableIterable getMatchingManifest(List dataManifests, + Map specsById, Expression dataFilter) { + Map evalCache = new HashMap<>(); + CloseableIterable matching = CloseableIterable.filter( + CloseableIterable.withNoopClose(dataManifests), + manifest -> evalCache.computeIfAbsent(manifest.partitionSpecId(), specId -> { + PartitionSpec spec = specsById.get(specId); + return ManifestEvaluator.forPartitionFilter( + Expressions.and(Expressions.alwaysTrue(), + Projections.inclusive(spec, true).project(dataFilter)), + spec, true); + }).eval(manifest)); + return CloseableIterable.filter(matching, + manifest -> manifest.hasAddedFiles() || manifest.hasExistingFiles()); + } + + /** + * Port of legacy {@code IcebergUtils.isManifestCacheEnabled}: the manifest-level path is used iff the + * manifest cache is wired AND the spec is enabled ({@code enable && ttl-second != 0 && capacity != 0}). + * The {@code .ttl-second}/{@code .capacity} properties feed ONLY this formula (legacy quirk); the cache + * itself is fixed no-TTL / capacity 100000. Parsing is best-effort (blank/unparseable falls back to the + * default) via the shared {@link CacheSpec}, matching the legacy fe-core behavior. + */ + private boolean isManifestCacheEnabled() { + if (manifestCache == null) { + return false; + } + CacheSpec spec = CacheSpec.fromProperties(properties, + MANIFEST_CACHE_ENABLE, DEFAULT_MANIFEST_CACHE_ENABLE, + MANIFEST_CACHE_TTL_SECOND, DEFAULT_MANIFEST_CACHE_TTL_SECOND, + MANIFEST_CACHE_CAPACITY, DEFAULT_MANIFEST_CACHE_CAPACITY); + return CacheSpec.isCacheEnabled(spec.isEnable(), spec.getTtlSecond(), spec.getCapacity()); + } + + /** + * Port of legacy {@code IcebergScanNode.determineTargetFileSplitSize} + {@code applyMaxFileSplitNumLimit} + * (non-batch path), reading the split-size knobs from the session-property channel. Start at + * {@code max_initial_file_split_size}, escalate to {@code max_file_split_size} once total content exceeds + * {@code max_file_split_size * max_initial_file_split_num}, then raise the size so the split count stays + * under {@code max_file_split_num}. + * + *

    Accumulates {@code ScanTaskUtil.contentSizeInBytes(task.file())}, matching legacy. For a data file + * that is just {@code fileSizeInBytes()} — but for a PUFFIN deletion vector the two differ sharply + * ({@code fileSizeInBytes()} is the whole puffin file, which packs many DV blobs, so summing it would + * over-count ~N-fold and prematurely escalate the target size). Widened to {@code ContentScanTask} so the + * {@code $position_deletes} path can share it, exactly as legacy widened the same method. + */ + private long determineTargetFileSplitSize(List> tasks, + ConnectorSession session) { + long maxInitialSplitSize = sessionLong(session, MAX_INITIAL_FILE_SPLIT_SIZE, + DEFAULT_MAX_INITIAL_FILE_SPLIT_SIZE); + long maxSplitSize = sessionLong(session, MAX_FILE_SPLIT_SIZE, DEFAULT_MAX_FILE_SPLIT_SIZE); + long maxInitialSplitNum = sessionLong(session, MAX_INITIAL_FILE_SPLIT_NUM, + DEFAULT_MAX_INITIAL_FILE_SPLIT_NUM); + long maxFileSplitNum = sessionLong(session, MAX_FILE_SPLIT_NUM, DEFAULT_MAX_FILE_SPLIT_NUM); + long total = 0; + boolean exceedInitialThreshold = false; + for (ContentScanTask task : tasks) { + total += ScanTaskUtil.contentSizeInBytes(task.file()); + if (!exceedInitialThreshold && total >= maxSplitSize * maxInitialSplitNum) { + exceedInitialThreshold = true; + } + } + long result = exceedInitialThreshold ? maxSplitSize : maxInitialSplitSize; + if (maxFileSplitNum > 0 && total > 0) { + long minSplitSizeForMaxNum = (total + maxFileSplitNum - 1) / maxFileSplitNum; + result = Math.max(result, minSplitSizeForMaxNum); + } + return result; + } + + private static long sessionLong(ConnectorSession session, String key, long defaultValue) { + if (session == null) { + return defaultValue; + } + String raw = session.getSessionProperties().get(key); + if (raw == null || raw.trim().isEmpty()) { + return defaultValue; + } + try { + return Long.parseLong(raw.trim()); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + private static boolean sessionBool(ConnectorSession session, String key, boolean defaultValue) { + if (session == null) { + return defaultValue; + } + String raw = session.getSessionProperties().get(key); + if (raw == null || raw.trim().isEmpty()) { + return defaultValue; + } + return Boolean.parseBoolean(raw.trim()); + } + + /** + * Compute the COUNT(*)-pushdown row count from the scan's snapshot summary, a faithful port of legacy + * {@code IcebergScanNode.getCountFromSnapshot}. No snapshot (empty table) → {@code 0}; otherwise + * delegates to {@link #getCountFromSummary}. Reads the scan's snapshot ({@code scan.snapshot()}) so the + * count tracks the scan automatically (the current snapshot today; the pinned snapshot once MVCC + * time-travel lands) — equivalent to legacy's {@code currentSnapshot()} for every non-time-travel query. + */ + private static long getCountFromSnapshot(TableScan scan, ConnectorSession session) { + Snapshot snapshot = scan.snapshot(); + if (snapshot == null) { + return 0; + } + return getCountFromSummary(snapshot.summary(), ignoreIcebergDanglingDelete(session)); + } + + /** + * Null-safe port of fe-core {@code IcebergUtils.getCountFromSummary} (upstream 32a2651f66b, #64648). + * Returns {@code -1} — this module's "count not pushable / unknown" sentinel; the {@code planScan} gate + * and count-collapse callers both test {@code >= 0} — in two cases: + *

      + *
    • any required {@code total-*} counter is ABSENT: compaction / replace / overwrite snapshots may + * omit {@code total-records} / {@code total-position-deletes} / {@code total-equality-deletes}, and + * the pre-fix code NPE-d on {@code summary.get(...).equals(...)} / {@code Long.parseLong(null)};
    • + *
    • any equality delete ({@code total-equality-deletes != "0"}) — not pushable, since equality + * deletes re-project at read time and the summary cannot net them out.
    • + *
    + * Otherwise: no position deletes → {@code total-records}; position deletes present and + * {@code ignoreDanglingDelete} → {@code total-records - total-position-deletes}; else {@code -1}. + */ + static long getCountFromSummary(Map summary, boolean ignoreDanglingDelete) { + String equalityDeletes = summary.get(TOTAL_EQUALITY_DELETES); + String positionDeletes = summary.get(TOTAL_POSITION_DELETES); + String totalRecords = summary.get(TOTAL_RECORDS); + if (equalityDeletes == null || positionDeletes == null || totalRecords == null) { + // a summary that omits any total-* counter can't be netted safely -> fall back to a real scan + return -1; + } + if (!equalityDeletes.equals("0")) { + // has equality delete files, can not push down count + return -1; + } + long deleteCount = Long.parseLong(positionDeletes); + if (deleteCount == 0) { + // no delete files, can push down count directly + return Long.parseLong(totalRecords); + } + if (ignoreDanglingDelete) { + // has position delete files; if we ignore dangling deletes, the netted count can be pushed down + return Long.parseLong(totalRecords) - deleteCount; + } + // otherwise, can not push down count + return -1; + } + + private static boolean ignoreIcebergDanglingDelete(ConnectorSession session) { + if (session == null) { + return false; + } + String raw = session.getSessionProperties().get(IGNORE_ICEBERG_DANGLING_DELETE); + return raw != null && Boolean.parseBoolean(raw.trim()); + } + + // The session time zone drives zone-adjusted (timestamptz) literal pushdown. Delegates to the shared + // IcebergTimeUtils (Doris alias map, mirrors fe-core TimeUtils.getTimeZone()) so aliases like CST/PRC/EST + // match legacy instead of throwing; null/blank/genuinely-invalid -> UTC. Package-private for unit testing. + static ZoneId resolveSessionZone(ConnectorSession session) { + return IcebergTimeUtils.resolveSessionZone(session); + } + + /** + * Loads the live Iceberg {@link Table} through the {@link IcebergCatalogOps} seam, wrapped in the + * FE-injected authentication context when present — so the Kerberos UGI applies (mirrors + * {@code IcebergConnectorMetadata} and paimon's {@code PaimonScanPlanProvider.resolveTable}). A + * {@code null} context (offline unit tests / simple-auth) resolves directly. + */ + private Table resolveTable(ConnectorSession session, IcebergTableHandle handle) { + // Per-statement scope (PERF-07): the statement's read metadata, scan planning and write all resolve the + // SAME one loaded RAW table. The scope holds the RAW table; wrapTableForScan (the Kerberos doAs FileIO) is + // re-applied per call below so no per-request authenticator is ever frozen into the shared object. + // Resolve the per-request ops before the auth scope so a session=user fail-closed surfaces verbatim (it + // re-validates the credential even on a scope hit). + IcebergCatalogOps ops = catalogOpsResolver.apply(session); + Table raw = IcebergStatementScope.sharedTable(session, handle.getDbName(), handle.getTableName(), () -> { + if (context == null) { + return loadRawTable(ops, handle); + } + try { + return context.executeAuthenticated(() -> loadRawTable(ops, handle)); + } catch (Exception e) { + throw new RuntimeException("Failed to load table for scan, error message is:" + e.getMessage(), e); + } + }); + return wrapTableForScan(raw); + } + + /** + * Loads the RAW iceberg table for {@code handle} through the cross-query {@link IcebergTableCache} when + * enabled (the connector disables it for credential-dependent catalogs), else a direct remote + * {@code loadTable}. No wrap and no auth scope here — {@link #resolveTable} owns both. + */ + private Table loadRawTable(IcebergCatalogOps ops, IcebergTableHandle handle) { + if (tableCache != null) { + return tableCache.getOrLoad(TableIdentifier.of(handle.getDbName(), handle.getTableName()), + () -> ops.loadTable(handle.getDbName(), handle.getTableName())); + } + return ops.loadTable(handle.getDbName(), handle.getTableName()); + } + + /** + * Routes a resolved data table's {@code io()} through the plugin-side Kerberos {@code doAs} + * ({@link IcebergAuthenticatedFileIO} via {@link IcebergAuthenticatedTableOperations}) — the scan-side + * mirror of the write path ({@code IcebergConnectorTransaction.openTransaction}). Scan planning reads the + * manifest list and manifests through {@code table.io()} ({@code SnapshotScan.planFiles}, + * {@code streamingSplitEstimate}'s {@code dataManifests}, the streaming source's lazy iteration) — on the + * CALLING thread for small tables and fanned onto iceberg's shared worker pool ({@code ParallelIterable}) + * for multi-manifest tables, which never inherits a caller-thread {@code doAs}. Wrapping at the FileIO + * seam is thread-agnostic: the factory-time {@code doAs} captures the secured FileSystem, so later + * {@code newStream()} on ANY thread stays authenticated (see {@link IcebergAuthenticatedFileIO}). Legacy + * parity: {@code IcebergScanNode} wrapped {@code doGetSplits} AND its streaming callbacks in + * {@code preExecutionAuthenticator.execute}; single-UGI fe-core made thread-level cover enough there, + * while the plugin's child-first UGI copy does not (CI: SELECT after INSERT on + * test_iceberg_hadoop_catalog_kerberos failed SASL reading snap-*.avro at plan time). + * + *

    Non-Kerberos catalogs ({@code getPluginAuthenticator() == null}) and offline tests (plain context / + * non-{@link BaseTable} fakes) pass through unchanged. Kerberos and REST vended credentials are disjoint + * (the authenticator is gated on hadoop.security.authentication=kerberos), so + * {@code extractVendedToken}'s {@code instanceof SupportsStorageCredentials} probe never sees the wrapper. + * The system-table path deliberately does NOT use this wrap: its {@code FileScanTask}s are Java-serialized + * to the BE JNI reader and the wrapper (authenticator-bearing) is not serializable — it is covered by the + * thread-level wrap in {@link #planSystemTableScan} instead. Package-private for unit testing. + */ + Table wrapTableForScan(Table table) { + if (!(context instanceof TcclPinningConnectorContext) || !(table instanceof BaseTable)) { + return table; + } + HadoopAuthenticator auth = ((TcclPinningConnectorContext) context).getPluginAuthenticator(); + if (auth == null) { + return table; + } + TableOperations rawOps = ((BaseTable) table).operations(); + return new BaseTable(new IcebergAuthenticatedTableOperations( + rawOps, new IcebergAuthenticatedFileIO(rawOps.io(), auth)), table.name()); + } + + /** + * Resolve the metadata table for a system-table handle, mirroring + * {@code IcebergConnectorMetadata.loadSysTable} (and legacy {@code IcebergSysExternalTable.getSysIcebergTable}): + * load the BASE table and build the metadata-table instance ({@code MetadataTableUtils}). + * {@code getSysTableName()} is the already-validated lowercase name, so {@code MetadataTableType.from} + * never returns null. The auth scope is owned by the SOLE caller {@link #planSystemTableScan}, whose + * thread-level {@code executeAuthenticated} spans the whole sys planning (this load + {@code planFiles} + + * task serialization) in ONE scope — no nested wrap here. + */ + private Table resolveSysTable(ConnectorSession session, IcebergTableHandle handle) { + return MetadataTableUtils.createMetadataTableInstance( + catalogOpsResolver.apply(session).loadTable(handle.getDbName(), handle.getTableName()), + MetadataTableType.from(handle.getSysTableName())); + } +} 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 new file mode 100644 index 00000000000000..2d78369f8e9081 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanProfileReporter.java @@ -0,0 +1,203 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.scan.ConnectorScanProfile; + +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.MetricsContext; +import org.apache.iceberg.metrics.MetricsReport; +import org.apache.iceberg.metrics.MetricsReporter; +import org.apache.iceberg.metrics.ScanMetricsResult; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.iceberg.metrics.TimerResult; + +import java.text.DecimalFormat; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * An iceberg SDK {@link MetricsReporter} that captures a scan's {@link ScanReport} into a connector-neutral + * {@link ConnectorScanProfile}, stashed keyed by the statement queryId for fe-core to drain into the query + * profile (FIX-SCAN-METRICS). Restores the legacy {@code datasource.iceberg.profile.IcebergMetricsReporter} + * behavior in the plugin architecture: it is a self-contained port (the connector cannot import fe-core, so + * {@code DebugUtil}'s time/byte formatters are inlined and Guava is avoided). + * + *

    The SDK invokes {@link #report} on CLOSE of the {@code planFiles} iterable, which the connector performs + * synchronously on the planScan thread — so a fresh reporter is created per scan bound to that scan's queryId, + * and attached ONLY on the synchronous data/count path (never the streaming or system-table path, which fe-core + * never drains).

    + */ +public class IcebergScanProfileReporter implements MetricsReporter { + /** Profile group name — MUST equal fe-core {@code SummaryProfile.ICEBERG_SCAN_METRICS} (display ordering). */ + static final String GROUP_NAME = "Iceberg Scan Metrics"; + private static final DecimalFormat BYTES_FORMAT = new DecimalFormat("0.000"); + private static final long KB = 1024L; + private static final long MB = 1024 * KB; + private static final long GB = 1024 * MB; + private static final long TB = 1024 * GB; + private static final long SECOND_MS = 1000L; + private static final long MINUTE_MS = 60 * SECOND_MS; + private static final long HOUR_MS = 60 * MINUTE_MS; + + private final String queryId; + private final ConcurrentHashMap> stash; + + IcebergScanProfileReporter(String queryId, ConcurrentHashMap> stash) { + this.queryId = queryId; + this.stash = stash; + } + + @Override + public void report(MetricsReport report) { + if (queryId == null || queryId.isEmpty() || !(report instanceof ScanReport)) { + return; + } + ScanReport scanReport = (ScanReport) report; + ScanMetricsResult metrics = scanReport.scanMetrics(); + if (metrics == null) { + return; + } + Map rendered = new LinkedHashMap<>(); + rendered.put("table", scanReport.tableName()); + rendered.put("snapshot", String.valueOf(scanReport.snapshotId())); + String filter = sanitize(scanReport.filter() == null ? null : scanReport.filter().toString()); + if (!filter.isEmpty()) { + rendered.put("filter", filter); + } + if (scanReport.projectedFieldNames() != null && !scanReport.projectedFieldNames().isEmpty()) { + rendered.put("columns", String.join("|", scanReport.projectedFieldNames())); + } + appendTimer(rendered, "planning", metrics.totalPlanningDuration()); + appendCounter(rendered, "data_files", metrics.resultDataFiles()); + appendCounter(rendered, "delete_files", metrics.resultDeleteFiles()); + appendCounter(rendered, "skipped_data_files", metrics.skippedDataFiles()); + appendCounter(rendered, "skipped_delete_files", metrics.skippedDeleteFiles()); + appendCounter(rendered, "total_size", metrics.totalFileSizeInBytes()); + appendCounter(rendered, "total_delete_size", metrics.totalDeleteFileSizeInBytes()); + appendCounter(rendered, "scanned_manifests", metrics.scannedDataManifests()); + appendCounter(rendered, "skipped_manifests", metrics.skippedDataManifests()); + appendCounter(rendered, "scanned_delete_manifests", metrics.scannedDeleteManifests()); + appendCounter(rendered, "skipped_delete_manifests", metrics.skippedDeleteManifests()); + appendCounter(rendered, "indexed_delete_files", metrics.indexedDeleteFiles()); + appendCounter(rendered, "equality_delete_files", metrics.equalityDeleteFiles()); + appendCounter(rendered, "positional_delete_files", metrics.positionalDeleteFiles()); + appendMetadata(rendered, scanReport.metadata()); + + ConnectorScanProfile profile = new ConnectorScanProfile( + GROUP_NAME, "Table Scan (" + scanReport.tableName() + ")", rendered); + stash.computeIfAbsent(queryId, k -> new CopyOnWriteArrayList<>()).add(profile); + } + + private void appendMetadata(Map out, Map metadata) { + if (metadata == null || metadata.isEmpty()) { + return; + } + List captured = new ArrayList<>(); + for (String key : new String[] {"scan-state", "scan-id"}) { + if (metadata.containsKey(key)) { + captured.add(key + "=" + metadata.get(key)); + } + } + if (!captured.isEmpty()) { + out.put("metadata", "{" + String.join(", ", captured) + "}"); + } + } + + private void appendTimer(Map out, String name, TimerResult timerResult) { + if (timerResult == null) { + return; + } + Duration duration = timerResult.totalDuration(); + out.put(name, prettyMs(duration.toMillis()) + " (" + timerResult.count() + " ops)"); + } + + private void appendCounter(Map out, String name, CounterResult counterResult) { + if (counterResult == null) { + return; + } + long value = counterResult.value(); + out.put(name, counterResult.unit() == MetricsContext.Unit.BYTES + ? printByteWithUnit(value) : Long.toString(value)); + } + + private static String sanitize(String value) { + if (value == null || value.isEmpty()) { + return ""; + } + return value.replaceAll("\\s+", " ").trim(); + } + + /** Inlined fe-core {@code DebugUtil.printByteWithUnit}. */ + static String printByteWithUnit(long value) { + double d = value; + String unit; + if (value == 0) { + unit = ""; + } else if (value > TB) { + unit = "TB"; + d /= TB; + } else if (value > GB) { + unit = "GB"; + d /= GB; + } else if (value > MB) { + unit = "MB"; + d /= MB; + } else if (value > KB) { + unit = "KB"; + d /= KB; + } else { + unit = "B"; + } + return BYTES_FORMAT.format(d) + " " + unit; + } + + /** Inlined fe-core {@code DebugUtil.getPrettyStringMs}: {@code Nhour Nmin Nsec} / {@code Nms}. */ + static String prettyMs(long value) { + if (value == 0) { + return "0"; + } + StringBuilder builder = new StringBuilder(); + long remaining = value; + boolean hour = false; + boolean minute = false; + if (remaining >= HOUR_MS) { + builder.append(remaining / HOUR_MS).append("hour"); + remaining %= HOUR_MS; + hour = true; + } + if (remaining >= MINUTE_MS) { + builder.append(remaining / MINUTE_MS).append("min"); + remaining %= MINUTE_MS; + minute = true; + } + if (!hour && remaining >= SECOND_MS) { + builder.append(remaining / SECOND_MS).append("sec"); + remaining %= SECOND_MS; + } + if (!hour && !minute) { + builder.append(remaining).append("ms"); + } + return builder.toString(); + } +} 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 new file mode 100644 index 00000000000000..574fc9e55dc146 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanRange.java @@ -0,0 +1,706 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.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; +import org.apache.doris.thrift.TIcebergFileDesc; +import org.apache.doris.thrift.TTableFormatFileDesc; + +import org.apache.iceberg.FileContent; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * A single Iceberg scan range (split), mirroring the paimon connector's {@code PaimonScanRange}. + * + *

    P6.2-T03 makes the range BE-ready: beyond the minimal {@code FILE_SCAN} file fields (path, byte + * offset/length, size, real file format) it carries the typed iceberg per-file descriptor inputs + * ({@code formatVersion}, identity {@code partitionValues}, {@code partitionSpecId}, + * {@code partitionDataJson}, v3 {@code firstRowId}/{@code lastUpdatedSequenceNumber}) and emits them through + * {@link #populateRangeParams} into {@code TTableFormatFileDesc.iceberg_params}, mirroring the legacy + * {@code IcebergScanNode.setIcebergParams}. Unlike paimon (which stashes stringly-typed {@code paimon.*} + * 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.

    + */ +public class IcebergScanRange implements ConnectorScanRange { + + private static final long serialVersionUID = 1L; + + // The BE-facing data-file path: scheme-normalized (oss/cos/obs/s3a -> s3) so BE's S3 factory can open it. + private final String path; + // The RAW iceberg data-file path; BE matches position-delete entries against it (original_file_path). + private final String originalPath; + private final long start; + private final long length; + private final long fileSize; + private final String fileFormat; + private final int formatVersion; + private final Integer partitionSpecId; + private final String partitionDataJson; + private final Long firstRowId; + private final Long lastUpdatedSequenceNumber; + // Identity partition column (lowercased) -> serialized value, already ordered as the path_partition_keys + // list, filtered to keys this file carries. Drives columns-from-path. Never null (empty when unpartitioned). + private final Map partitionValues; + // Merge-on-read delete files applying to this data file (T04). Never null (empty when none / v1). + private final List deleteFiles; + // COUNT(*) pushdown precomputed row count (T05): -1 = no precomputed count (the normal scan path); + // >= 0 = the single collapsed count range carrying the snapshot-summary total. Drives both the generic + // node's EXPLAIN "pushdown agg=COUNT (n)" line (getPushDownRowCount) and the BE thrift + // table_level_row_count (populateRangeParams). + private final long pushDownRowCount; + // System-table (P6.5-T05) serialized FileScanTask: base64 of SerializationUtil.serializeToBase64(task). + // Null for every normal data-file range (those stay byte-unchanged). When set, this range is a JNI + // system-table split: populateRangeParams emits the legacy sys shape — ONLY serialized_split + FORMAT_JNI + // + table_level_row_count=-1 — because the BE IcebergSysTableJniScanner reads serialized_split (and feeds + // it back to SerializationUtil.deserializeFromBase64(...).asDataTask().rows()), ignoring every file-level + // field. Mirrors legacy IcebergSplit.serializedSplit / IcebergScanNode.setIcebergParams isSystemTable. + private final String serializedSplit; + // M-2 proportional BE scheduling weight (mirrors PaimonScanRange): the size-based weight numerator + // (legacy IcebergSplit.selfSplitWeight = task.length() + Σ delete file sizes) and the scan-level + // denominator (legacy IcebergScanNode.targetSplitSize). -1 = not provided (SPI sentinel) → the generic + // PluginDrivenSplit leaves the FileSplit weight unset → SplitWeight.standard() (uniform). Only normal + // data-file ranges set them; system-table / count-pushdown ranges keep -1 (legacy parity: standard()). + private final long selfSplitWeight; + private final long targetSplitSize; + // ===== $position_deletes native sys-table range (the THIRD range shape) ===== + // When true this range is a native (parquet/orc) $position_deletes metadata-table split and + // populateRangeParams emits the position-delete shape instead of the data-file or JNI ones. Unlike every + // other system table (which rides the JNI serializedSplit path), BE reads this one with its native + // iceberg_position_delete_sys_table_reader. Mirrors legacy IcebergSplit.positionDeleteSystemTableSplit / + // IcebergScanNode.setIcebergPositionDeleteSysTableParams (upstream #65135). + private final boolean positionDeleteSystemTableSplit; + // FORMAT_PARQUET or FORMAT_ORC. NOTE: a PUFFIN deletion vector also travels as FORMAT_PARQUET (legacy + // getNativePositionDeleteFileFormat) — BE distinguishes DV purely by content==3, never by file format. + // This must be set: populateRangeParams' data-path default would leave FORMAT_JNI and misroute the range + // to IcebergSysTableJniReader. + private final TFileFormatType positionDeleteFileFormat; + // 1 = POSITION_DELETES (parquet/orc delete file), 3 = DELETION_VECTOR (puffin blob). Emitted BOTH as the + // TOP-LEVEL TIcebergFileDesc.content (BE's sole routing key into the native reader) and on the single + // delete-file descriptor (which reader kind to run). Both must be set and agree. + private final int positionDeleteContent; + // The RAW delete-file path (pre storage-path normalization); surfaces as the delete_file_path output + // column. Deliberately NOT reusing `originalPath`, whose meaning on the data path is "raw DATA-file path" + // and which feeds the rewritable-delete stash. + private final String positionDeleteOriginalPath; + // DV (content==3) only: the referenced data file whose deleted positions the puffin blob encodes. BE + // expands one output row per set bit, with file_path = this path. + private final String positionDeleteReferencedDataFilePath; + // DV (content==3) only: the blob's offset/size within the puffin file. + private final Long positionDeleteContentOffset; + private final Long positionDeleteContentSizeInBytes; + + private IcebergScanRange(Builder builder) { + this.path = builder.path; + // Default the raw original path to the (possibly already-raw) path when a caller does not split them + // — keeps single-arg .path(...) callers (and the prior behavior) intact. + this.originalPath = builder.originalPath != null ? builder.originalPath : builder.path; + this.start = builder.start; + this.length = builder.length; + this.fileSize = builder.fileSize; + this.fileFormat = builder.fileFormat; + this.formatVersion = builder.formatVersion; + this.partitionSpecId = builder.partitionSpecId; + this.partitionDataJson = builder.partitionDataJson; + this.firstRowId = builder.firstRowId; + this.lastUpdatedSequenceNumber = builder.lastUpdatedSequenceNumber; + this.partitionValues = builder.partitionValues != null + ? Collections.unmodifiableMap(builder.partitionValues) + : Collections.emptyMap(); + this.deleteFiles = builder.deleteFiles != null + ? Collections.unmodifiableList(builder.deleteFiles) + : Collections.emptyList(); + this.pushDownRowCount = builder.pushDownRowCount; + this.serializedSplit = builder.serializedSplit; + this.selfSplitWeight = builder.selfSplitWeight; + this.targetSplitSize = builder.targetSplitSize; + this.positionDeleteSystemTableSplit = builder.positionDeleteSystemTableSplit; + this.positionDeleteFileFormat = builder.positionDeleteFileFormat; + this.positionDeleteContent = builder.positionDeleteContent; + this.positionDeleteOriginalPath = builder.positionDeleteOriginalPath; + this.positionDeleteReferencedDataFilePath = builder.positionDeleteReferencedDataFilePath; + this.positionDeleteContentOffset = builder.positionDeleteContentOffset; + this.positionDeleteContentSizeInBytes = builder.positionDeleteContentSizeInBytes; + } + + @Override + public ConnectorScanRangeType getRangeType() { + return ConnectorScanRangeType.FILE_SCAN; + } + + @Override + public Optional getPath() { + return Optional.ofNullable(path); + } + + @Override + public long getStart() { + return start; + } + + @Override + public long getLength() { + return length; + } + + @Override + public long getFileSize() { + return fileSize; + } + + /** + * This split's size-based weight numerator for proportional BE assignment (legacy + * {@code IcebergSplit.selfSplitWeight}), or {@code -1} when unset (system-table / count-pushdown ranges). + * Paired with {@link #getTargetSplitSize()} by the generic {@code PluginDrivenSplit} to weight + * {@code FederationBackendPolicy} by bytes instead of split count (M-2). Mirrors {@code PaimonScanRange}. + */ + @Override + public long getSelfSplitWeight() { + return selfSplitWeight; + } + + /** + * The scan-level weight denominator (legacy {@code IcebergScanNode.targetSplitSize} = + * {@code determineTargetFileSplitSize}), or {@code -1} when unset. Proportional weighting applies only when + * this is positive AND {@link #getSelfSplitWeight()} is non-negative; otherwise the engine falls back to + * {@code SplitWeight.standard()}. Mirrors {@code PaimonScanRange}. + */ + @Override + public long getTargetSplitSize() { + return targetSplitSize; + } + + @Override + public String getFileFormat() { + return fileFormat; + } + + /** + * The table-format-type string BE uses to select its Iceberg reader, mirroring paimon's + * {@code "paimon"}: the value of {@code TableFormatType.ICEBERG} (see fe-core + * {@code org.apache.doris.datasource.scan.TableFormatType}). + */ + @Override + public String getTableFormatType() { + return "iceberg"; + } + + /** + * The identity partition column values for this file. The generic {@code PluginDrivenSplit} reads this to + * route columns-from-path through {@code normalizeColumnsFromPath} (instead of path-parsing); the + * authoritative columns-from-path is then (re)written by {@link #populateRangeParams}. + */ + @Override + public Map getPartitionValues() { + return partitionValues; + } + + /** + * A distinct-faithful key for the native iceberg partition this file belongs to (FIX-L12), or + * {@code null} when the file carries no {@code PartitionData} (unpartitioned / current spec + * unpartitioned). Combines the partition-spec id with the serialized {@code PartitionData} + * ({@code partitionDataJson}) so that two files are keyed equal iff they share the same spec and + * partition tuple — mirroring legacy {@code IcebergScanNode}'s de-dup by + * {@code (PartitionData) file().partition()}, and disambiguating cross-spec value collisions + * (e.g. {@code identity(id)=2} vs {@code bucket(id)=2}) that the value-only json would merge. + * {@code IcebergScanPlanProvider} counts distinct non-null keys for {@code selectedPartitionNum}. + */ + String getScannedPartitionKey() { + if (partitionDataJson == null) { + return null; + } + return partitionSpecId + "|" + partitionDataJson; + } + + /** + * Iceberg partition values always come from table/file metadata, never from a Hive-style + * {@code key=value} directory layout, so the engine must NEVER fall back to path parsing for an iceberg + * range. Returning {@code true} unconditionally makes {@code PluginDrivenSplit} map an empty identity map + * to a non-null empty list (routed through {@code normalizeColumnsFromPath}) instead of {@code null} — + * which {@code FileQueryScanNode} reads as "parse partition values from the file path" and throws for + * iceberg's non-{@code key=value} layout. The narrow {@code partitionSpecId != null} was wrong for a + * partition-spec-evolution table now on an unpartitioned spec: {@code buildRange} sees the current spec + * (unpartitioned) so it sets no spec id on ANY file, yet {@code path_partition_keys} is still the union of + * all specs (e.g. {@code [sku]}), so the physically-unpartitioned files (no {@code sku=} segment) hit the + * path-parse throw. Mirrors legacy {@code IcebergScanNode.createIcebergSplit}, which always supplies a + * non-null empty partition list regardless of partitioning. The authoritative columns-from-path is then + * (re)written by {@link #populateRangeParams} from the identity map (empty map → none emitted). + */ + @Override + public boolean isPartitionBearing() { + return true; + } + + /** + * The precomputed COUNT(*)-pushdown row count this range carries, or {@code -1} when none (the normal + * scan path). The generic {@code PluginDrivenScanNode} reads it (via {@code resolvePushDownRowCount}) to + * render the EXPLAIN {@code pushdown agg=COUNT (n)} line; the same value drives the BE thrift + * {@code table_level_row_count} in {@link #populateRangeParams}. Mirrors paimon's {@code paimon.row_count} + * carrier (typed here since iceberg's params are numeric). Default {@code -1} keeps every normal range + * (T02/T03/T04) byte-unchanged — only the single collapsed count range (T05) carries a real count. + */ + @Override + public long getPushDownRowCount() { + return pushDownRowCount; + } + + /** + * The base64-serialized iceberg {@code FileScanTask} for a system-table (JNI) split, or {@code null} for a + * normal data-file range. When non-null this range carries no real file (its path is a dummy) — BE's + * {@code IcebergSysTableJniScanner} deserializes this and materializes the metadata rows. Mirrors legacy + * {@code IcebergSplit.getSerializedSplit}. + */ + public String getSerializedSplit() { + return serializedSplit; + } + + /** + * The RAW (un-normalized) iceberg data-file path of this range — the key the BE matches a rewritable delete + * set against (and the {@code original_file_path} it emits). The plugin write path stashes the merge-on-read + * supply keyed on this, mirroring legacy {@code IcebergScanNode.deleteFilesByReferencedDataFile} (keyed on + * {@code getOriginalPath()}). Package-private — only the connector's scan/stash wiring reads it. + */ + String getOriginalPath() { + return originalPath; + } + + /** + * This data file's merge-on-read delete files MINUS equality deletes, as BE-facing thrift descs — the + * "rewritable delete" supply a format-version≥3 DELETE/MERGE hands the BE to OR-merge old deletes into the + * new deletion vector (mirrors legacy {@code IcebergScanNode.deleteFilesDescByReferencedDataFile}, which + * filters out {@code EQUALITY_DELETES}). Empty when this range carries no (non-equality) deletes. The + * equality exclusion matches the BE contract — only position deletes and deletion vectors are OR-merged into + * the new DV; equality deletes are re-applied by the reader, not rewritten. Package-private (stash wiring). + */ + List rewritableDeleteDescs() { + if (deleteFiles.isEmpty()) { + return Collections.emptyList(); + } + List descs = new ArrayList<>(deleteFiles.size()); + for (DeleteFile delete : deleteFiles) { + if (delete.getContent() != DeleteFile.CONTENT_EQUALITY_DELETE) { + descs.add(delete.toThrift()); + } + } + return descs; + } + + @Override + public Map getProperties() { + // Iceberg carries its per-range payload as typed fields (see populateRangeParams), not as string + // properties; nothing engine-generic needs reading here. + return Collections.emptyMap(); + } + + /** + * Fills the per-file iceberg descriptor, mirroring legacy {@code IcebergScanNode.setIcebergParams}. The + * generic {@code PluginDrivenScanNode} has already set {@code formatDesc.table_format_type = "iceberg"} + * and pre-filled the {@code rangeDesc} file-level fields (and a path-parsed columns-from-path, which is + * invalid for iceberg and is overwritten below). This runs AFTER the parent, so it owns the final + * iceberg_params, per-range format type, and columns-from-path. + */ + @Override + public void populateRangeParams(TTableFormatFileDesc formatDesc, TFileRangeDesc rangeDesc) { + TIcebergFileDesc fileDesc = new TIcebergFileDesc(); + if (positionDeleteSystemTableSplit) { + // Native $position_deletes sys-table range. Mirrors legacy + // IcebergScanNode.setIcebergPositionDeleteSysTableParams (upstream #65135) field for field. + // + // BE routes into iceberg_position_delete_sys_table_reader iff table_format_type=="iceberg" AND the + // TOP-LEVEL iceberg_params.content is set to 1 or 3 AND the range format_type is PARQUET/ORC + // (file_scanner.cpp:103-113, file_scanner_v2.cpp:75-125). The top-level content field is otherwise + // deprecated and unset on ordinary data ranges — which is exactly what keeps them out of this + // reader. Consequently the data path MUST NEVER set content to 1 or 3. + rangeDesc.setFormatType(positionDeleteFileFormat); + formatDesc.setTableLevelRowCount(-1); + fileDesc.setContent(positionDeleteContent); + if (partitionSpecId != null) { + fileDesc.setPartitionSpecId(partitionSpecId); + } + if (partitionDataJson != null) { + fileDesc.setPartitionDataJson(partitionDataJson); + } + // EXACTLY one delete descriptor: BE asserts delete_files.size()==1 + // (iceberg_position_delete_sys_table_reader.cpp:179), unlike the data path's N-element list. + // path = the normalized delete file (what BE opens); original_path = the raw one (output column). + TIcebergDeleteFileDesc deleteFileDesc = new TIcebergDeleteFileDesc(); + deleteFileDesc.setPath(rangeDesc.getPath()); + deleteFileDesc.setOriginalPath(positionDeleteOriginalPath); + deleteFileDesc.setFileFormat(positionDeleteFileFormat); + deleteFileDesc.setContent(positionDeleteContent); + if (positionDeleteContentOffset != null) { + deleteFileDesc.setContentOffset(positionDeleteContentOffset); + } + if (positionDeleteContentSizeInBytes != null) { + deleteFileDesc.setContentSizeInBytes(positionDeleteContentSizeInBytes); + } + if (positionDeleteReferencedDataFilePath != null) { + deleteFileDesc.setReferencedDataFilePath(positionDeleteReferencedDataFilePath); + } + fileDesc.setDeleteFiles(Collections.singletonList(deleteFileDesc)); + formatDesc.setIcebergParams(fileDesc); + // A path-parsed "partition" key would collide with the metadata table's own `partition` slot. + rangeDesc.unsetColumnsFromPath(); + rangeDesc.unsetColumnsFromPathKeys(); + rangeDesc.unsetColumnsFromPathIsNull(); + return; + } + if (serializedSplit != null) { + // System-table (JNI) split: mirror legacy IcebergScanNode.setIcebergParams isSystemTable branch — + // emit ONLY the serialized FileScanTask + FORMAT_JNI + table_level_row_count=-1, and NONE of the + // file-level carriers (format_version / original_file_path / content / delete_files / partition). + // BE's IcebergSysTableJniScanner reads serialized_split (deserializeFromBase64 -> asDataTask().rows()) + // and ignores every other field, so emitting them would be a parity divergence. Returns early, like + // legacy (setFormatType(FORMAT_JNI):290, setTableLevelRowCount(-1):291, setSerializedSplit:292). + rangeDesc.setFormatType(TFileFormatType.FORMAT_JNI); + formatDesc.setTableLevelRowCount(-1); + fileDesc.setSerializedSplit(serializedSplit); + formatDesc.setIcebergParams(fileDesc); + return; + } + fileDesc.setFormatVersion(formatVersion); + // original_file_path = the RAW (un-normalized) data-file path; BE matches position-delete entries + // against it (legacy setOriginalFilePath:304 uses the raw originalPath, not the normalized location). + // This stays raw even though the range path (getPath) is scheme-normalized for BE to open. + fileDesc.setOriginalFilePath(originalPath); + if (partitionSpecId != null) { + fileDesc.setPartitionSpecId(partitionSpecId); + } + if (partitionDataJson != null) { + fileDesc.setPartitionDataJson(partitionDataJson); + } + if (formatVersion >= 3) { + // -1 means a file carried over from a v2->v3 upgrade (no row lineage yet). + fileDesc.setFirstRowId(firstRowId != null ? firstRowId : -1); + fileDesc.setLastUpdatedSequenceNumber( + lastUpdatedSequenceNumber != null ? lastUpdatedSequenceNumber : -1); + } + if (formatVersion < 2) { + // v1 has no delete files; legacy marks the file content as DATA. + fileDesc.setContent(FileContent.DATA.id()); + } else { + // v2+ : emit the merge-on-read delete files. Legacy setIcebergParams always calls + // setDeleteFiles(new ArrayList<>()) before the per-delete loop, so the list is set even when + // empty (a no-delete v2 table); each TIcebergDeleteFileDesc carries content/format/bounds/ + // field-ids/DV-offset exactly as legacy built them. + List deleteDescs = new ArrayList<>(deleteFiles.size()); + for (DeleteFile delete : deleteFiles) { + deleteDescs.add(delete.toThrift()); + } + fileDesc.setDeleteFiles(deleteDescs); + } + + // native reader format (JNI = system tables, P6.5). Leaves the parent's default (FORMAT_JNI) otherwise. + if ("orc".equals(fileFormat)) { + rangeDesc.setFormatType(TFileFormatType.FORMAT_ORC); + } else if ("parquet".equals(fileFormat)) { + rangeDesc.setFormatType(TFileFormatType.FORMAT_PARQUET); + } + + // table_level_row_count: the single collapsed COUNT(*)-pushdown range carries the snapshot-summary + // total (T05); every other range carries the distinct -1 sentinel (BE then counts by reading). The + // carrier defaults to -1, so all normal/T03/T04 ranges are byte-unchanged. + formatDesc.setTableLevelRowCount(pushDownRowCount); + formatDesc.setIcebergParams(fileDesc); + + // Overwrite the parent's iceberg-invalid path-parsed columns-from-path: unset, then re-set from the + // identity map (value "" + parallel is_null on a genuine null; NO __HIVE_DEFAULT_PARTITION__ sentinel). + rangeDesc.unsetColumnsFromPath(); + rangeDesc.unsetColumnsFromPathKeys(); + rangeDesc.unsetColumnsFromPathIsNull(); + if (!partitionValues.isEmpty()) { + List keys = new ArrayList<>(partitionValues.size()); + List values = new ArrayList<>(partitionValues.size()); + List isNull = new ArrayList<>(partitionValues.size()); + for (Map.Entry entry : partitionValues.entrySet()) { + String value = entry.getValue(); + keys.add(entry.getKey()); + values.add(value != null ? value : ""); + isNull.add(value == null); + } + rangeDesc.setColumnsFromPathKeys(keys); + rangeDesc.setColumnsFromPath(values); + rangeDesc.setColumnsFromPathIsNull(isNull); + } + } + + /** + * Builder for {@link IcebergScanRange}, mirroring {@code PaimonScanRange.Builder} (constructed via + * {@code new IcebergScanRange.Builder()}). + */ + public static class Builder { + private String path; + private String originalPath; + private long start; + private long length = -1; + private long fileSize = -1; + // Default empty (NOT "jni", which is not a real iceberg file format). Production callers set the real + // orc/parquet from the data file's format; mirrors PaimonScanRange.Builder. + private String fileFormat = ""; + // Default 2 (the iceberg metadata default); production callers set the real table format version. + private int formatVersion = 2; + private Integer partitionSpecId; + private String partitionDataJson; + private Long firstRowId; + private Long lastUpdatedSequenceNumber; + private Map partitionValues; + private List deleteFiles; + private long pushDownRowCount = -1; + private String serializedSplit; + // -1 = not provided (SPI sentinel) → PluginDrivenSplit keeps SplitWeight.standard(). Only the normal + // data-file path sets these (legacy IcebergSplit.selfSplitWeight / IcebergScanNode.targetSplitSize). + private long selfSplitWeight = -1; + private long targetSplitSize = -1; + private boolean positionDeleteSystemTableSplit; + private TFileFormatType positionDeleteFileFormat; + private int positionDeleteContent; + private String positionDeleteOriginalPath; + private String positionDeleteReferencedDataFilePath; + private Long positionDeleteContentOffset; + private Long positionDeleteContentSizeInBytes; + + public Builder path(String path) { + this.path = path; + return this; + } + + /** + * Marks this range as a native {@code $position_deletes} sys-table split and sets the two fields BE + * routes on. {@code content} is 1 (parquet/orc position-delete file) or 3 (puffin deletion vector); + * {@code fileFormat} is FORMAT_PARQUET or FORMAT_ORC (a DV also travels as FORMAT_PARQUET). + * {@code originalPath} is the raw delete-file path, surfaced as the delete_file_path output column. + */ + public Builder positionDeleteSysTableSplit(int content, TFileFormatType fileFormat, + String originalPath) { + this.positionDeleteSystemTableSplit = true; + this.positionDeleteContent = content; + this.positionDeleteFileFormat = fileFormat; + this.positionDeleteOriginalPath = originalPath; + return this; + } + + /** Deletion-vector (content==3) only: the puffin blob's location and the data file it refers to. */ + public Builder positionDeleteDeletionVector(String referencedDataFilePath, Long contentOffset, + Long contentSizeInBytes) { + this.positionDeleteReferencedDataFilePath = referencedDataFilePath; + this.positionDeleteContentOffset = contentOffset; + this.positionDeleteContentSizeInBytes = contentSizeInBytes; + return this; + } + + /** The RAW iceberg data-file path (for original_file_path); defaults to {@link #path} when unset. */ + public Builder originalPath(String originalPath) { + this.originalPath = originalPath; + return this; + } + + public Builder start(long start) { + this.start = start; + return this; + } + + public Builder length(long length) { + this.length = length; + return this; + } + + public Builder fileSize(long fileSize) { + this.fileSize = fileSize; + return this; + } + + public Builder fileFormat(String fileFormat) { + this.fileFormat = fileFormat; + return this; + } + + public Builder formatVersion(int formatVersion) { + this.formatVersion = formatVersion; + return this; + } + + public Builder partitionSpecId(Integer partitionSpecId) { + this.partitionSpecId = partitionSpecId; + return this; + } + + public Builder partitionDataJson(String partitionDataJson) { + this.partitionDataJson = partitionDataJson; + return this; + } + + public Builder firstRowId(Long firstRowId) { + this.firstRowId = firstRowId; + return this; + } + + public Builder lastUpdatedSequenceNumber(Long lastUpdatedSequenceNumber) { + this.lastUpdatedSequenceNumber = lastUpdatedSequenceNumber; + return this; + } + + public Builder partitionValues(Map partitionValues) { + this.partitionValues = partitionValues; + return this; + } + + public Builder deleteFiles(List deleteFiles) { + this.deleteFiles = deleteFiles; + return this; + } + + /** The precomputed COUNT(*)-pushdown row count for the collapsed count range; default -1 (no count). */ + public Builder pushDownRowCount(long pushDownRowCount) { + this.pushDownRowCount = pushDownRowCount; + return this; + } + + /** The base64 serialized iceberg {@code FileScanTask} for a system-table (JNI) split; default null. */ + public Builder serializedSplit(String serializedSplit) { + this.serializedSplit = serializedSplit; + return this; + } + + /** The size-based weight numerator (legacy {@code IcebergSplit.selfSplitWeight}); default -1 (unset). */ + public Builder selfSplitWeight(long selfSplitWeight) { + this.selfSplitWeight = selfSplitWeight; + return this; + } + + /** The scan-level weight denominator (legacy {@code IcebergScanNode.targetSplitSize}); default -1. */ + public Builder targetSplitSize(long targetSplitSize) { + this.targetSplitSize = targetSplitSize; + return this; + } + + public IcebergScanRange build() { + return new IcebergScanRange(this); + } + } + + /** + * One merge-on-read delete file applying to the data file of this range, mirroring the legacy + * {@code IcebergDeleteFileFilter} hierarchy + the {@code TIcebergDeleteFileDesc} that + * {@code IcebergScanNode.setIcebergParams} builds from it. Immutable + {@link Serializable} (the + * enclosing range is serialized into the split). The {@code content} ids are the legacy literals + * (1 = position delete, 2 = equality delete, 3 = deletion vector); only the fields relevant to a + * given kind are non-null, and {@link #toThrift()} sets only the non-null ones (legacy sets bounds + * only when present and {@code file_format} only for parquet/orc). + */ + public static final class DeleteFile implements Serializable { + + private static final long serialVersionUID = 1L; + + // Iceberg file type (TIcebergDeleteFileDesc.content): 1 = position delete, 2 = equality delete, + // 3 = deletion vector (legacy IcebergDeleteFileFilter.{PositionDelete,EqualityDelete,DeletionVector}.type()). + // Package-private: the $position_deletes scan path emits the same two values, both as the delete-file + // content and as the TOP-LEVEL routing content (see the outer populateRangeParams). + static final int CONTENT_POSITION_DELETE = 1; + private static final int CONTENT_EQUALITY_DELETE = 2; + static final int CONTENT_DELETION_VECTOR = 3; + + private final String path; + private final int content; + // null for a deletion vector (PUFFIN); legacy setDeleteFileFormat only emits parquet/orc. + private final TFileFormatType fileFormat; + // null = unset (legacy: bound absent, or the -1 sentinel which is treated as absent). + private final Long positionLowerBound; + private final Long positionUpperBound; + // equality delete only (null otherwise). + private final List fieldIds; + // deletion vector only (null otherwise). + private final Long contentOffset; + private final Long contentSizeInBytes; + + private DeleteFile(String path, int content, TFileFormatType fileFormat, Long positionLowerBound, + Long positionUpperBound, List fieldIds, Long contentOffset, Long contentSizeInBytes) { + this.path = path; + this.content = content; + this.fileFormat = fileFormat; + this.positionLowerBound = positionLowerBound; + this.positionUpperBound = positionUpperBound; + this.fieldIds = fieldIds != null ? Collections.unmodifiableList(new ArrayList<>(fieldIds)) : null; + this.contentOffset = contentOffset; + this.contentSizeInBytes = contentSizeInBytes; + } + + /** A position delete file (content 1): row positions to drop, with optional [lower,upper] bounds. */ + public static DeleteFile positionDelete(String path, TFileFormatType fileFormat, + Long positionLowerBound, Long positionUpperBound) { + return new DeleteFile(path, CONTENT_POSITION_DELETE, fileFormat, + positionLowerBound, positionUpperBound, null, null, null); + } + + /** + * A deletion vector (content 3): a PUFFIN blob referenced by {@code contentOffset}/ + * {@code contentSizeInBytes}. It is a position delete, so it also carries the optional position + * bounds (legacy {@code DeletionVector extends PositionDelete}); {@code file_format} stays unset. + */ + public static DeleteFile deletionVector(String path, Long positionLowerBound, Long positionUpperBound, + long contentOffset, long contentSizeInBytes) { + return new DeleteFile(path, CONTENT_DELETION_VECTOR, null, + positionLowerBound, positionUpperBound, null, contentOffset, contentSizeInBytes); + } + + /** An equality delete file (content 2): rows equal on {@code fieldIds} are dropped (BE re-projects). */ + public static DeleteFile equalityDelete(String path, TFileFormatType fileFormat, List fieldIds) { + return new DeleteFile(path, CONTENT_EQUALITY_DELETE, fileFormat, null, null, fieldIds, null, null); + } + + int getContent() { + return content; + } + + TIcebergDeleteFileDesc toThrift() { + TIcebergDeleteFileDesc desc = new TIcebergDeleteFileDesc(); + desc.setPath(path); + if (fileFormat != null) { + desc.setFileFormat(fileFormat); + } + if (positionLowerBound != null) { + desc.setPositionLowerBound(positionLowerBound); + } + if (positionUpperBound != null) { + desc.setPositionUpperBound(positionUpperBound); + } + if (fieldIds != null) { + desc.setFieldIds(new ArrayList<>(fieldIds)); + } + if (contentOffset != null) { + desc.setContentOffset(contentOffset); + } + if (contentSizeInBytes != null) { + desc.setContentSizeInBytes(contentSizeInBytes); + } + desc.setContent(content); + return desc; + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSchemaBuilder.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSchemaBuilder.java new file mode 100644 index 00000000000000..c7b02b6ae9cfb4 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSchemaBuilder.java @@ -0,0 +1,420 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.ddl.ConnectorPartitionField; +import org.apache.doris.connector.api.ddl.ConnectorPartitionSpec; +import org.apache.doris.connector.api.ddl.ConnectorSortField; + +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.NullOrder; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.expressions.Literal; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +/** + * Builds the Iceberg create-table artifacts (Schema / PartitionSpec / SortOrder / default properties) + * from a connector-SPI {@link org.apache.doris.connector.api.ddl.ConnectorCreateTableRequest}. + * + *

    String-driven port of the legacy fe-core iceberg create path + * ({@code DorisTypeToIcebergType} + {@code IcebergUtils.solveIcebergPartitionSpec} + + * {@code IcebergMetadataOps.buildSortOrder} + the default-property block in {@code performCreateTable}), + * reimplemented over the neutral {@link ConnectorType}/{@link ConnectorPartitionSpec}/{@link ConnectorSortField} + * carriers because the connector cannot import fe-core. Mirrors {@code PaimonSchemaBuilder}'s role for paimon.

    + * + *

    Field ids: top-level columns get id == their declaration index and nested fields draw sequential + * ids from a counter starting at the column count — byte-faithful to legacy {@code DorisTypeToIcebergType}'s + * scheme. (Iceberg reassigns fresh ids in {@code TableMetadata.newTableMetadata} on create, so only internal + * consistency + name-resolvability of the partition/sort spec matters here.)

    + * + *

    Nested nullability: the neutral {@link ConnectorType} now carries per-element nullability + + * per-STRUCT-field comments ({@link ConnectorType#isChildNullable(int)}/{@link ConnectorType#getChildComment}), + * so a NOT NULL (or a comment) declared inside a complex type — ARRAY element, MAP value, STRUCT field — is + * preserved on the iceberg side. When the neutral type does not carry them (legacy factories / the paimon + * write path), every element defaults to OPTIONAL, preserving the prior behavior.

    + */ +public final class IcebergSchemaBuilder { + + private static final List ICEBERG_TABLE_LOCATION_CHILD_DIRS = java.util.Arrays.asList("data", "metadata"); + + private IcebergSchemaBuilder() { + } + + /** The child directories under a managed iceberg table location to prune on drop (data + metadata). */ + static List tableLocationChildDirs() { + return ICEBERG_TABLE_LOCATION_CHILD_DIRS; + } + + /** + * Builds the Iceberg {@link Schema} from the neutral columns, allocating field ids exactly as legacy + * {@code DorisTypeToIcebergType} (root field id == index; nested ids from a counter at the column count). + * + * @throws DorisConnectorException if a column type cannot be represented in Iceberg + */ + public static Schema buildSchema(List columns) { + IdAllocator ids = new IdAllocator(columns.size()); + List fields = new ArrayList<>(columns.size()); + for (int i = 0; i < columns.size(); i++) { + ConnectorColumn col = columns.get(i); + Type type = convert(col.getType(), ids); + if (col.isNullable()) { + fields.add(Types.NestedField.optional(i, col.getName(), type, col.getComment())); + } else { + fields.add(Types.NestedField.required(i, col.getName(), type, col.getComment())); + } + } + return new Schema(fields); + } + + /** + * Recursively converts a neutral type to an Iceberg type, allocating ids for nested fields. + * + *

    Per-element nullability ({@link ConnectorType#isChildNullable(int)}) and per-STRUCT-field comments + * ({@link ConnectorType#getChildComment(int)}) are honored when the neutral type carries them — closing + * the former FU-nested-nullability gap so a NOT NULL declared inside a complex type survives. When unset + * (legacy factories / connectors that do not thread them) every element defaults to OPTIONAL with no doc, + * preserving the prior behavior.

    + */ + private static Type convert(ConnectorType type, IdAllocator ids) { + String name = type.getTypeName().toUpperCase(Locale.ROOT); + switch (name) { + case "ARRAY": { + // Element type/ids first, then the list's element id (post-order, matching legacy visitor). + Type element = convert(type.getChildren().get(0), ids); + int elementId = ids.next(); + return type.isChildNullable(0) + ? Types.ListType.ofOptional(elementId, element) + : Types.ListType.ofRequired(elementId, element); + } + case "MAP": { + Type key = convert(type.getChildren().get(0), ids); + Type value = convert(type.getChildren().get(1), ids); + int keyId = ids.next(); + int valueId = ids.next(); + // Iceberg map keys are always required; child index 1 (the value) carries the nullability. + return type.isChildNullable(1) + ? Types.MapType.ofOptional(keyId, valueId, key, value) + : Types.MapType.ofRequired(keyId, valueId, key, value); + } + case "STRUCT": { + List childTypes = type.getChildren(); + List fieldNames = type.getFieldNames(); + List sub = new ArrayList<>(childTypes.size()); + for (int i = 0; i < childTypes.size(); i++) { + String fieldName = i < fieldNames.size() ? fieldNames.get(i) : "col" + i; + Type fieldType = convert(childTypes.get(i), ids); + String fieldDoc = type.getChildComment(i); + sub.add(type.isChildNullable(i) + ? Types.NestedField.optional(ids.next(), fieldName, fieldType, fieldDoc) + : Types.NestedField.required(ids.next(), fieldName, fieldType, fieldDoc)); + } + return Types.StructType.of(sub); + } + default: + return IcebergTypeMapping.toIcebergPrimitive(type); + } + } + + /** + * Builds the Iceberg {@link PartitionSpec} against {@code schema} from the neutral partition spec. + * String-driven port of {@code IcebergUtils.solveIcebergPartitionSpec}: each field's transform name + + * integer args map to the {@link PartitionSpec.Builder} transform calls. An unset / empty spec yields + * an unpartitioned table. + * + * @throws DorisConnectorException for an unsupported transform or missing transform argument + */ + public static PartitionSpec buildPartitionSpec(ConnectorPartitionSpec spec, Schema schema) { + if (spec == null || spec.getFields().isEmpty()) { + return PartitionSpec.unpartitioned(); + } + PartitionSpec.Builder builder = PartitionSpec.builderFor(schema); + for (ConnectorPartitionField field : spec.getFields()) { + String transform = field.getTransform() == null + ? "identity" : field.getTransform().toLowerCase(Locale.ROOT); + // #65094: resolve the partition column back to the schema's canonical (case-preserving) + // name; the schema now keeps the original column-name case, so a case-mismatched DDL + // reference would otherwise fail Iceberg's case-sensitive PartitionSpec builder lookup. + String column = resolveColumnName(schema, field.getColumnName()); + switch (transform) { + case "identity": + builder.identity(column); + break; + case "bucket": + builder.bucket(column, intArg(transform, field.getTransformArgs())); + break; + case "year": + case "years": + builder.year(column); + break; + case "month": + case "months": + builder.month(column); + break; + case "date": + case "day": + case "days": + builder.day(column); + break; + case "date_hour": + case "hour": + case "hours": + builder.hour(column); + break; + case "truncate": + builder.truncate(column, intArg(transform, field.getTransformArgs())); + break; + default: + throw new DorisConnectorException("unsupported partition transform for iceberg: " + transform); + } + } + return builder.build(); + } + + private static int intArg(String transform, List args) { + if (args == null || args.isEmpty()) { + throw new DorisConnectorException( + "iceberg partition transform '" + transform + "' requires an integer argument"); + } + return args.get(0); + } + + /** + * Builds the Iceberg {@link SortOrder} against {@code schema} from the neutral sort fields, or + * {@code null} when there is no write order. Port of {@code IcebergMetadataOps.buildSortOrder}. + */ + public static SortOrder buildSortOrder(List sortFields, Schema schema) { + if (sortFields == null || sortFields.isEmpty()) { + return null; + } + SortOrder.Builder builder = SortOrder.builderFor(schema); + for (ConnectorSortField field : sortFields) { + NullOrder nullOrder = field.isNullFirst() ? NullOrder.NULLS_FIRST : NullOrder.NULLS_LAST; + // #65094: resolve the sort column to the schema's canonical (case-preserving) name so a + // case-mismatched DDL reference does not fail Iceberg's case-sensitive SortOrder lookup. + String column = resolveColumnName(schema, field.getColumnName()); + if (field.isAscending()) { + builder.asc(column, nullOrder); + } else { + builder.desc(column, nullOrder); + } + } + return builder.build(); + } + + /** + * Resolves an external column name to the schema's canonical (case-preserving) spelling, matching + * case-insensitively. #65094: the built schema now preserves the original column-name case + * ({@code col.getName()}), so a partition / sort column referenced in DDL with different case must be + * mapped back to the canonical name — otherwise Iceberg's case-sensitive {@code PartitionSpec} / + * {@code SortOrder} builder throws "Cannot find field". Mirrors {@code IcebergUtils.getIcebergColumnName}. + */ + private static String resolveColumnName(Schema schema, String columnName) { + Types.NestedField field = schema.caseInsensitiveFindField(columnName); + return field == null ? columnName : field.name(); + } + + /** + * Returns a mutable copy of the request properties with the Doris iceberg defaults applied (only when + * absent): {@code format-version=2} and merge-on-read for delete/update/merge. Mirrors the + * {@code putIfAbsent} block in legacy {@code performCreateTable} — the MOR modes are functionally + * required (Doris rejects row-level DML on copy-on-write iceberg tables). Uses no catalog-level + * defaults; prefer {@link #buildTableProperties(Map, Map)} when the catalog properties are available. + */ + public static Map buildTableProperties(Map requestProperties) { + return buildTableProperties(requestProperties, Collections.emptyMap()); + } + + /** + * Overload that respects a catalog-level default/override iceberg format-version (upstream 25f291673f1, + * #63825): the {@code format-version=2} default is applied ONLY when neither the table request nor the + * catalog specifies one, so a catalog {@code table-default.format-version} / + * {@code table-override.format-version} is no longer silently overridden to v2. Mirrors legacy + * {@code IcebergMetadataOps.performCreateTable} + {@code IcebergUtils.hasIcebergCatalogFormatVersion} + * (the connector cannot import fe-core IcebergUtils). The MOR modes stay unconditional (see the 1-arg + * overload). + */ + public static Map buildTableProperties(Map requestProperties, + Map catalogProperties) { + Map props = new HashMap<>(requestProperties); + if (!props.containsKey(TableProperties.FORMAT_VERSION) + && !hasIcebergCatalogFormatVersion(catalogProperties)) { + props.put(TableProperties.FORMAT_VERSION, "2"); + } + props.putIfAbsent(TableProperties.DELETE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); + props.putIfAbsent(TableProperties.UPDATE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); + props.putIfAbsent(TableProperties.MERGE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); + return props; + } + + /** + * Whether the catalog sets a table-level default/override iceberg format-version (the iceberg + * {@code table-default.*} / {@code table-override.*} namespaces applied by the underlying catalog). + * Mirrors fe-core {@code IcebergUtils.hasIcebergCatalogFormatVersion}. + */ + private static boolean hasIcebergCatalogFormatVersion(Map catalogProperties) { + return catalogProperties.containsKey(CatalogProperties.TABLE_OVERRIDE_PREFIX + TableProperties.FORMAT_VERSION) + || catalogProperties.containsKey( + CatalogProperties.TABLE_DEFAULT_PREFIX + TableProperties.FORMAT_VERSION); + } + + /** + * The effective iceberg format-version for a CREATE TABLE, applying the full precedence: catalog + * {@code table-override.format-version} > table request {@code format-version} > catalog + * {@code table-default.format-version} > default {@code 2}. Mirrors legacy fe-core + * {@code IcebergUtils.getEffectiveIcebergFormatVersion} — used by {@code IcebergConnectorMetadata.createTable} + * to gate the v3 reserved row-lineage column-name rejection (moved off fe-core CreateTableInfo). + */ + public static int getEffectiveFormatVersion(Map requestProperties, + Map catalogProperties) { + String formatVersion = catalogProperties.get( + CatalogProperties.TABLE_OVERRIDE_PREFIX + TableProperties.FORMAT_VERSION); + if (formatVersion == null) { + formatVersion = requestProperties.get(TableProperties.FORMAT_VERSION); + if (formatVersion == null) { + formatVersion = catalogProperties.get( + CatalogProperties.TABLE_DEFAULT_PREFIX + TableProperties.FORMAT_VERSION); + } + } + if (formatVersion == null) { + return 2; + } + try { + return Integer.parseInt(formatVersion); + } catch (NumberFormatException ignored) { + return 2; + } + } + + /** + * Builds the iceberg {@link Type} for a SINGLE column added/modified on an EXISTING table, reusing the + * same neutral-type conversion as {@link #buildSchema} (scalars via {@link IcebergTypeMapping}, plus + * ARRAY/MAP/STRUCT recursively). Iceberg's {@code UpdateSchema.addColumn/updateColumn} assigns the field + * ids itself, so the throwaway allocator values here are irrelevant — only the type shape matters. + * + *

    Per-element nullability + per-STRUCT-field comments are honored when the neutral type carries them + * (same as {@link #buildSchema}), so the full new complex type built here for a {@code MODIFY COLUMN} + * faithfully drives the {@link IcebergComplexTypeDiff} field-by-field diff.

    + * + * @throws DorisConnectorException if the column type cannot be represented in iceberg + */ + public static Type buildColumnType(ConnectorType type) { + return convert(type, new IdAllocator(0)); + } + + /** + * Parses a column {@code DEFAULT} value string into an iceberg {@link Literal} of {@code type}, or + * {@code null} when {@code value} is null (no DEFAULT clause). Byte-faithful port of the legacy fe-core + * {@code IcebergUtils.parseIcebergLiteral} (self-contained — only iceberg + JDK types), so that an + * {@code ADD COLUMN ... DEFAULT} keeps its initial-default after the flip. + * + * @throws IllegalArgumentException for an unparseable value or a type that cannot carry a default + */ + public static Literal parseDefaultLiteral(String value, Type type) { + if (value == null) { + return null; + } + switch (type.typeId()) { + case BOOLEAN: + try { + return Literal.of(Boolean.parseBoolean(value)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid Boolean string: " + value, e); + } + case INTEGER: + case DATE: + try { + return Literal.of(Integer.parseInt(value)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid Int string: " + value, e); + } + case LONG: + case TIME: + case TIMESTAMP: + case TIMESTAMP_NANO: + try { + return Literal.of(Long.parseLong(value)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid Long string: " + value, e); + } + case FLOAT: + try { + return Literal.of(Float.parseFloat(value)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid Float string: " + value, e); + } + case DOUBLE: + try { + return Literal.of(Double.parseDouble(value)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid Double string: " + value, e); + } + case STRING: + return Literal.of(value); + case UUID: + try { + return Literal.of(UUID.fromString(value)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid UUID string: " + value, e); + } + case FIXED: + case BINARY: + case GEOMETRY: + case GEOGRAPHY: + return Literal.of(ByteBuffer.wrap(value.getBytes())); + case DECIMAL: + try { + return Literal.of(new BigDecimal(value)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid Decimal string: " + value, e); + } + default: + throw new IllegalArgumentException("Cannot parse unknown type: " + type); + } + } + + /** Sequential Iceberg field-id allocator for nested fields (legacy {@code DorisTypeToIcebergType} scheme). */ + private static final class IdAllocator { + private int next; + + IdAllocator(int start) { + this.next = start; + } + + int next() { + return next++; + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSchemaUtils.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSchemaUtils.java new file mode 100644 index 00000000000000..0f9913e209d38c --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSchemaUtils.java @@ -0,0 +1,448 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.thrift.TColumnType; +import org.apache.doris.thrift.TFileScanRangeParams; +import org.apache.doris.thrift.TPrimitiveType; +import org.apache.doris.thrift.schema.external.TArrayField; +import org.apache.doris.thrift.schema.external.TField; +import org.apache.doris.thrift.schema.external.TFieldPtr; +import org.apache.doris.thrift.schema.external.TMapField; +import org.apache.doris.thrift.schema.external.TNestedField; +import org.apache.doris.thrift.schema.external.TSchema; +import org.apache.doris.thrift.schema.external.TStructField; + +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.mapping.MappedField; +import org.apache.iceberg.mapping.MappedFields; +import org.apache.iceberg.mapping.NameMapping; +import org.apache.iceberg.mapping.NameMappingParser; +import org.apache.iceberg.transforms.Transforms; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Type.TypeID; +import org.apache.iceberg.types.Types; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.thrift.TDeserializer; +import org.apache.thrift.TSerializer; +import org.apache.thrift.protocol.TBinaryProtocol; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +/** + * Builds the native-reader schema dictionary ({@code current_schema_id} + {@code history_schema_info}) so BE + * matches file↔table columns BY FIELD ID across schema evolution (rename/reorder), instead of falling + * back to NAME matching (which silently reads NULL/garbage for renamed columns) or DCHECK-aborting the whole + * BE on a missing column. Self-contained iceberg→thrift port of legacy {@code IcebergScanNode.create + * ScanRangeLocations} + {@code ExternalUtil.initSchemaInfoFor{All,Pruned}Column} + {@code extractNameMapping} + * (mirrors {@code IcebergPartitionUtils}/{@code IcebergPredicateConverter}; zero fe-core import). + * + *

    Iceberg vs paimon (the load-bearing divergence): iceberg emits exactly ONE schema entry + * ({@code current_schema_id = -1}). BE reads the FILE field ids straight from the parquet/orc file metadata + * ({@code iceberg_reader.cpp by_parquet_field_id} / {@code by_orc_field_id}) and matches them by equality to + * this single table-side entry. Because iceberg field ids are permanent invariants, NO per-file + * {@code schema_id} is looked up (legacy emits only the {@code -1} entry too) — unlike paimon/hudi + * ({@code by_table_field_id}), which match the FE-supplied file schema and therefore need a per-committed-id + * history. See {@code designs/P6-T06-iceberg-scan-fieldid-design.md} §0/§1.

    + * + *

    The {@code -1} entry is keyed off the REQUESTED columns (= the authoritative Doris scan slots), so its + * top-level names == the BE scan-slot names BY CONSTRUCTION — the invariant BE's {@code StructNode} + * {@code children_column_exists} DCHECK relies on (CI #969249). Per-field {@code name_mapping} (from the + * table's {@code schema.name-mapping.default}) is carried for BE's old-file fallback + * ({@code by_parquet_field_id_with_name_mapping}). Each {@code TField} carries only what BE's field-id path + * consumes — {@code id} / {@code name} / a nested-vs-scalar {@code type.type} tag (a {@code STRING} + * placeholder for every scalar; BE never inspects the scalar tag) / {@code name_mapping} — and, faithful to + * legacy {@code ExternalUtil}, an {@code id}/{@code name} at EVERY nesting level (array element, map + * key/value, struct child), unlike paimon which omits them on collection elements.

    + */ +public final class IcebergSchemaUtils { + + private static final Logger LOG = LogManager.getLogger(IcebergSchemaUtils.class); + + // Legacy parity: current_schema_id is the -1 sentinel ("latest"); the current/target schema is also + // pushed into history_schema_info under this id (IcebergScanNode.createScanRangeLocations -> -1L). + static final long CURRENT_SCHEMA_ID = -1L; + + // Iceberg v3 row-lineage metadata columns (_row_id / _last_updated_sequence_number). Names + reserved field + // ids MIRROR IcebergConnectorMetadata's constants (a fe-core contract test pins those to + // IcebergUtils.ICEBERG_ROW_ID_COL / ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL + the reserved ids). They are + // never in schema.columns(), yet are projected as GENERATED BE scan slots -> they must be appended to the + // dict for a format-version >= 3 table so BE's StructNode children map carries them (else the ParquetReader, + // which iterates column_names unconditionally, does children.at("_row_id") -> std::out_of_range and SIGABRTs + // the whole BE). See appendRowLineageFields. + static final String ICEBERG_ROW_ID_COL = "_row_id"; + static final String ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL = "_last_updated_sequence_number"; + static final int ICEBERG_ROW_ID_FIELD_ID = 2147483540; + static final int ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_FIELD_ID = 2147483539; + + private static final Base64.Encoder BASE64_ENCODER = Base64.getEncoder(); + + private IcebergSchemaUtils() { + } + + /** + * Orchestrator: build the schema dictionary for {@code table} keyed off the requested (lowercased) column + * names and serialize it for transport via the scan-node props. {@code requestedLowerNames} is the pruned + * scan-slot list ({@code PluginDrivenScanNode} hands the provider the requested columns); an empty list + * (count-only scan / no column handles) falls back to all top-level schema columns. + */ + static String encodeSchemaEvolutionProp(Table table, List requestedLowerNames) { + return encodeSchemaEvolutionProp(table, table.schema(), requestedLowerNames, false, false); + } + + /** + * Like {@link #encodeSchemaEvolutionProp(Table, List)} but builds the dictionary from an explicit + * {@code dictSchema} (the latest schema for a normal read, or a historical schema for a time-travel read — + * T07 Option A passes the PINNED schema with an empty {@code requestedLowerNames} so the dict covers every + * BE scan slot). The name mapping is still read from {@code table} (it is table-level, not schema-versioned). + * + *

    {@code appendRowLineage} (set by the caller when the table format-version >= 3) appends the iceberg + * v3 row-lineage columns ({@code _row_id} / {@code _last_updated_sequence_number}) to the dict root. They are + * GENERATED BE scan slots (so they reach BE {@code column_names}) but are NOT in {@code schema.columns()}, so + * without this the BE {@code StructNode} children map misses them and the ParquetReader's unconditional + * {@code children.at("_row_id")} {@code std::out_of_range}-SIGABRTs the whole BE. See + * {@link #appendRowLineageFields}.

    + */ + static String encodeSchemaEvolutionProp(Table table, Schema dictSchema, List requestedLowerNames, + boolean appendRowLineage) { + // Thin overload: default enableTimestampTz=false (the callers that do not thread the catalog's + // enable.mapping.timestamp_tz flag keep the pre-#65502 UTC-wall-time behaviour). + return encodeSchemaEvolutionProp(table, dictSchema, requestedLowerNames, appendRowLineage, false); + } + + /** + * The real builder. {@code enableTimestampTz} (#65502) mirrors the catalog's + * {@code enable.mapping.timestamp_tz} flag: it is threaded into {@link #buildCurrentSchema} so a + * TIMESTAMPTZ column's iceberg initial default is serialized consistently with how BE will read the + * column (keep the trailing offset when tz-mapping is on, drop it — DATETIMEV2 UTC wall time — when off). + */ + static String encodeSchemaEvolutionProp(Table table, Schema dictSchema, List requestedLowerNames, + boolean appendRowLineage, boolean enableTimestampTz) { + Map> nameMapping = extractNameMapping(table); + TSchema current = buildCurrentSchema(dictSchema, requestedLowerNames, nameMapping, enableTimestampTz); + if (appendRowLineage) { + appendRowLineageFields(current.getRootField()); + } + return encode(CURRENT_SCHEMA_ID, Collections.singletonList(current)); + } + + /** + * Decode the schema-evolution prop produced by {@link #encodeSchemaEvolutionProp} and copy + * {@code current_schema_id} + {@code history_schema_info} onto the real scan params. Fail loud on a decode + * error — this prop is produced by us, so a failure is a real bug, and silently dropping it would + * re-introduce the silent wrong-rows BLOCKER on schema-evolved native reads. + */ + static void applySchemaEvolution(TFileScanRangeParams params, String encoded) { + if (encoded == null || encoded.isEmpty()) { + return; + } + try { + byte[] bytes = Base64.getDecoder().decode(encoded); + TFileScanRangeParams carrier = new TFileScanRangeParams(); + new TDeserializer(new TBinaryProtocol.Factory()).deserialize(carrier, bytes); + if (carrier.isSetCurrentSchemaId()) { + params.setCurrentSchemaId(carrier.getCurrentSchemaId()); + } + if (carrier.isSetHistorySchemaInfo()) { + params.setHistorySchemaInfo(carrier.getHistorySchemaInfo()); + } + } catch (Exception e) { + throw new RuntimeException("Failed to apply iceberg schema-evolution info to scan params", e); + } + } + + /** + * Extract the iceberg name mapping ({@code schema.name-mapping.default}) as field-id → alternate + * names, recursing into nested mappings. Verbatim port of legacy {@code IcebergScanNode.extractNameMapping} + * + {@code extractMappingsFromNameMapping}; fail-soft (a parse error logs + yields an empty map, so a + * malformed property never breaks the scan — legacy parity). + */ + static Map> extractNameMapping(Table table) { + Map> result = new HashMap<>(); + try { + String nameMappingJson = table.properties().get(TableProperties.DEFAULT_NAME_MAPPING); + if (nameMappingJson != null && !nameMappingJson.isEmpty()) { + NameMapping mapping = NameMappingParser.fromJson(nameMappingJson); + if (mapping != null) { + collectNameMappings(mapping.asMappedFields(), result); + } + } + } catch (Exception e) { + // If name mapping parsing fails, continue without it (legacy parity). + LOG.warn("Failed to parse name mapping from Iceberg table properties", e); + } + return result; + } + + private static void collectNameMappings(MappedFields fields, Map> result) { + if (fields == null) { + return; + } + for (MappedField field : fields.fields()) { + result.put(field.id(), new ArrayList<>(field.names())); + collectNameMappings(field.nestedMapping(), result); + } + } + + /** + * Build the single {@code TSchema} (schema_id = -1) keyed off the requested column names (the CI #969249 + * fix: the top-level names == the BE scan slots so the {@code StructNode} DCHECK can never miss). Each + * requested name is matched case-insensitively to the iceberg schema; its top-level {@code TField} name is + * the requested name VERBATIM (byte-matching the Doris slot name; case-preserved post-#65094). An + * empty/{@code null} {@code requestedLowerNames} falls back to all top-level columns (lowercased). Fail + * loud if a requested column is absent from the schema (a genuine FE/connector inconsistency — not a + * silent drop). + */ + static TSchema buildCurrentSchema(Schema schema, List requestedLowerNames, + Map> nameMapping) { + // Thin overload: default enableTimestampTz=false (pre-#65502 timestamp-default behaviour). + return buildCurrentSchema(schema, requestedLowerNames, nameMapping, false); + } + + /** + * The real builder; {@code enableTimestampTz} (#65502) is threaded into every {@link #buildField} call so a + * TIMESTAMPTZ column's iceberg initial default is serialized to match BE's read of the column (see + * {@link #serializeInitialDefault}). + */ + static TSchema buildCurrentSchema(Schema schema, List requestedLowerNames, + Map> nameMapping, boolean enableTimestampTz) { + TSchema tSchema = new TSchema(); + tSchema.setSchemaId(CURRENT_SCHEMA_ID); + TStructField root = new TStructField(); + if (requestedLowerNames == null || requestedLowerNames.isEmpty()) { + for (Types.NestedField field : schema.columns()) { + addField(root, buildField(field, field.name().toLowerCase(Locale.ROOT), nameMapping, + enableTimestampTz)); + } + } else { + for (String name : requestedLowerNames) { + Types.NestedField field = schema.caseInsensitiveFindField(name); + if (field == null) { + throw new RuntimeException("iceberg schema-evolution: requested column '" + name + + "' not found in the table schema"); + } + addField(root, buildField(field, name, nameMapping, enableTimestampTz)); + } + } + tSchema.setRootField(root); + return tSchema; + } + + /** + * Recursively build a {@link TField} from an iceberg {@link Types.NestedField}. {@code nameOverride} + * replaces the field name at the top level (the Doris slot name, case-preserved post-#65094); + * {@code null} (every nested field) falls back to the iceberg field name LOWERCASED. Lowercasing is + * load-bearing for nested struct + * children: the Doris slot's {@code DataTypeStruct} child names are force-lowercased ({@code StructField} + * ctor, via {@code ConnectorColumnConverter}), and BE's {@code StructNode} looks the child up by that + * lowercase name — keeping the iceberg case (e.g. {@code DROP_AND_ADD}) makes BE's + * {@code children.at("drop_and_add")} throw {@code std::out_of_range} and SIGABRT the whole struct read. + * For array {@code element} / map {@code key}/{@code value} the lowercasing is a no-op (iceberg's canonical + * names are already lowercase; BE matches collection nodes positionally anyway). Carries the iceberg field + * id + name + nullability + + * name-mapping at EVERY level (legacy {@code ExternalUtil} parity), and a nested-vs-scalar {@code type.type} + * (a {@code STRING} placeholder for scalars — BE uses it only as a discriminator). + */ + private static TField buildField(Types.NestedField field, String nameOverride, + Map> nameMapping, boolean enableTimestampTz) { + TField tField = new TField(); + tField.setId(field.fieldId()); + tField.setName(nameOverride != null ? nameOverride : field.name().toLowerCase(Locale.ROOT)); + // is_optional is byte-matched to legacy: ExternalUtil sets it from the Doris column's isAllowNull(), + // which IcebergConnectorMetadata.parseSchema forces to true for EVERY iceberg column (a required iceberg + // field still surfaces nullable). BE does NOT read is_optional on the iceberg field-id path + // (table_schema_change_helper / iceberg_reader never reference it), so this is inert there, but we keep + // legacy parity rather than leak iceberg's required/optional flag into the dictionary. + tField.setIsOptional(true); + if (nameMapping.containsKey(field.fieldId())) { + // for iceberg set name mapping (old files without embedded field ids fall back to these names). + tField.setNameMapping(new ArrayList<>(nameMapping.get(field.fieldId()))); + } + + // #65502: carry each field's iceberg initial default so BE can materialize an equality-delete key + // (or any column) that is absent from an old data file with its typed default instead of NULL. + // Binary-like values (UUID/BINARY/FIXED) go through a lossless Base64 carrier flagged for BE, because + // their Doris type (STRING/CHAR when varbinary-mapping is off) can't tell BE to decode bytes; other + // values use the Doris FE string form (timestamp normalized to DATETIMEV2 spacing). + if (field.initialDefault() != null) { + if (isBinaryLike(field.type())) { + tField.setInitialDefaultValue(serializeBinaryInitialDefault(field.type(), field.initialDefault())); + tField.setInitialDefaultValueIsBase64(true); + } else { + tField.setInitialDefaultValue( + serializeInitialDefault(field.type(), field.initialDefault(), enableTimestampTz)); + } + } + + Type type = field.type(); + TColumnType columnType = new TColumnType(); + if (type.isPrimitiveType()) { + // Scalar: BE reads type.type only as a nested-vs-scalar discriminator (it never inspects the + // specific scalar tag in the field-id path), so a single placeholder is sufficient. + columnType.setType(TPrimitiveType.STRING); + tField.setType(columnType); + return tField; + } + + TNestedField nestedField = new TNestedField(); + switch (type.typeId()) { + case LIST: { + columnType.setType(TPrimitiveType.ARRAY); + Types.ListType listType = (Types.ListType) type; + TArrayField arrayField = new TArrayField(); + arrayField.setItemField( + fieldPtr(buildField(listType.fields().get(0), null, nameMapping, enableTimestampTz))); + nestedField.setArrayField(arrayField); + break; + } + case MAP: { + columnType.setType(TPrimitiveType.MAP); + Types.MapType mapType = (Types.MapType) type; + List kv = mapType.fields(); + TMapField mapField = new TMapField(); + mapField.setKeyField(fieldPtr(buildField(kv.get(0), null, nameMapping, enableTimestampTz))); + mapField.setValueField(fieldPtr(buildField(kv.get(1), null, nameMapping, enableTimestampTz))); + nestedField.setMapField(mapField); + break; + } + case STRUCT: { + columnType.setType(TPrimitiveType.STRUCT); + Types.StructType structType = (Types.StructType) type; + TStructField structField = new TStructField(); + for (Types.NestedField child : structType.fields()) { + addField(structField, buildField(child, null, nameMapping, enableTimestampTz)); + } + nestedField.setStructField(structField); + break; + } + default: + // Defensive: a non-primitive type id we don't model (e.g. a future iceberg nested type). Emit a + // scalar placeholder so BE treats it as a leaf rather than descending into an unset nested field. + columnType.setType(TPrimitiveType.STRING); + tField.setType(columnType); + return tField; + } + tField.setType(columnType); + tField.setNestedField(nestedField); + return tField; + } + + private static String serializeInitialDefault(Type type, Object value, boolean enableTimestampTz) { + String humanValue = Transforms.identity(type).toHumanString(type, value); + if (type.typeId() == TypeID.TIMESTAMP) { + // Iceberg prints ISO-8601 (2024-01-01T00:00:00); Doris DATETIMEV2 needs a space separator. + String dorisValue = humanValue.replace('T', ' '); + if (((Types.TimestampType) type).shouldAdjustToUTC() && !enableTimestampTz) { + // timestamptz human form carries a trailing offset; DATETIMEV2 has no offset carrier, so keep + // the displayed UTC wall time and drop the suffix (only when tz-mapping is off). + return dorisValue.replaceFirst("(Z|[+-]\\d{2}:\\d{2})$", ""); + } + return dorisValue; + } + return humanValue; + } + + private static boolean isBinaryLike(Type type) { + return type.typeId() == TypeID.UUID || type.typeId() == TypeID.BINARY || type.typeId() == TypeID.FIXED; + } + + private static String serializeBinaryInitialDefault(Type type, Object value) { + if (type.typeId() != TypeID.UUID) { + // BINARY/FIXED: iceberg's identity human form is already Base64 of the raw bytes. + return Transforms.identity(type).toHumanString(type, value); + } + UUID uuid = (UUID) value; + ByteBuffer bytes = ByteBuffer.allocate(16); + bytes.putLong(uuid.getMostSignificantBits()); + bytes.putLong(uuid.getLeastSignificantBits()); + return Base64.getEncoder().encodeToString(bytes.array()); + } + + private static void addField(TStructField structField, TField child) { + structField.addToFields(fieldPtr(child)); + } + + /** + * Append the iceberg v3 row-lineage scalar fields ({@code _row_id} / {@code _last_updated_sequence_number}) + * to the dict root so BE's {@code StructNode} children map contains them. Idempotent (skips a name already + * present — defensive against a data column literally named {@code _row_id}). Each field carries its reserved + * iceberg field id: BE matches it against the FILE field ids ({@code by_parquet_field_id_with_name_mapping}), + * registering it not-in-file for a v2 "null after upgrade" file (then backfilled by the iceberg + * generated-column handler) or reading it when a v3 file materialized it — exactly the legacy slot-driven + * behavior. A superset root (row-lineage appended even when a query does not project it) is harmless: BE only + * looks up its own {@code column_names}, mirroring the full-schema dict the snapshot-pin / top-N branches + * already emit. + */ + private static void appendRowLineageFields(TStructField root) { + appendScalarFieldIfAbsent(root, ICEBERG_ROW_ID_FIELD_ID, ICEBERG_ROW_ID_COL); + appendScalarFieldIfAbsent(root, ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_FIELD_ID, + ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL); + } + + private static void appendScalarFieldIfAbsent(TStructField root, int id, String lowerName) { + if (root.isSetFields()) { + for (TFieldPtr existing : root.getFields()) { + if (existing.isSetFieldPtr() && lowerName.equals(existing.getFieldPtr().getName())) { + return; + } + } + } + TField tField = new TField(); + tField.setId(id); + tField.setName(lowerName); + // Byte-match buildField's scalar leaf: is_optional true (inert on BE's field-id path) + a STRING + // placeholder type tag (BE reads type.type only as a nested-vs-scalar discriminator). + tField.setIsOptional(true); + TColumnType columnType = new TColumnType(); + columnType.setType(TPrimitiveType.STRING); + tField.setType(columnType); + addField(root, tField); + } + + private static TFieldPtr fieldPtr(TField field) { + TFieldPtr ptr = new TFieldPtr(); + ptr.setFieldPtr(field); + return ptr; + } + + private static String encode(long currentSchemaId, List history) { + TFileScanRangeParams carrier = new TFileScanRangeParams(); + carrier.setCurrentSchemaId(currentSchemaId); + carrier.setHistorySchemaInfo(history); + try { + byte[] bytes = new TSerializer(new TBinaryProtocol.Factory()).serialize(carrier); + return BASE64_ENCODER.encodeToString(bytes); + } catch (Exception | LinkageError e) { + // Catch LinkageError (e.g. IncompatibleClassChangeError from a thrift classloader split) too: + // wrapped as a RuntimeException it surfaces as a clean per-query failure instead of escaping the + // connection handler as an uncaught Error and killing the whole mysql session (mirrors paimon). + throw new RuntimeException("Failed to serialize iceberg schema-evolution info", e); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSessionCatalogAdapter.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSessionCatalogAdapter.java new file mode 100644 index 00000000000000..9f76d9cea8564d --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSessionCatalogAdapter.java @@ -0,0 +1,169 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorDelegatedCredential; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.DorisConnectorException; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableMap; +import org.apache.iceberg.catalog.BaseViewSessionCatalog; +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.catalog.SessionCatalog; +import org.apache.iceberg.catalog.ViewCatalog; +import org.apache.iceberg.rest.auth.OAuth2Properties; + +import java.util.Map; +import java.util.Optional; + +/** + * Bridges the querying user's neutral {@link ConnectorDelegatedCredential} (carried on the + * {@link ConnectorSession}) to Iceberg REST {@code SessionCatalog} calls, re-migrated from the pre-P6 fe-core + * {@code IcebergSessionCatalogAdapter} and retargeted off the neutral SPI (no fe-core imports). + * + *

    When {@code iceberg.rest.session=user}, the connector holds a SINGLE shared {@link BaseViewSessionCatalog} + * (never one-per-user, exactly as Trino / #63068) and this adapter mints a per-request session-bound + * {@link Catalog}/{@link ViewCatalog} from it via {@code asCatalog(ctx)} / {@code asViewCatalog(ctx)}, carrying + * the user's OAuth2/delegated credential in the {@link SessionCatalog.SessionContext}. A request without a + * credential either falls back to the plain shared catalog ({@link #catalog}/{@link #viewCatalog}) or fails + * closed ({@link #delegatedCatalog}/{@link #delegatedViewCatalog}) — the connector uses the fail-closed variants + * under {@code session=user}, so a tokenless request is rejected rather than served a shared identity. + */ +class IcebergSessionCatalogAdapter { + + // The plain (non-delegated) catalog: the shared default asCatalog(SessionContext.createEmpty()). Returned for + // credential-less requests on the graceful path; the connector never routes session=user through it. + private final Catalog catalog; + // The session-aware REST catalog (empty for a non-REST / non-session catalog). asCatalog(ctx)/asViewCatalog(ctx) + // attach the per-user delegated credential. A SINGLE shared instance — never one-per-user. Held as the + // BaseViewSessionCatalog supertype so it can be the ReauthenticatingRestSessionCatalog wrapper (401 re-auth) + // or a bare RESTSessionCatalog; per-user asCatalog(ctx) inherits the wrapper's recovery when wrapped. + private final Optional sessionCatalog; + private final DelegatedTokenMode delegatedTokenMode; + + IcebergSessionCatalogAdapter(Catalog catalog, BaseViewSessionCatalog sessionCatalog) { + this(catalog, sessionCatalog, DelegatedTokenMode.ACCESS_TOKEN); + } + + IcebergSessionCatalogAdapter(Catalog catalog, BaseViewSessionCatalog sessionCatalog, + DelegatedTokenMode delegatedTokenMode) { + this.catalog = catalog; + this.sessionCatalog = Optional.ofNullable(sessionCatalog); + this.delegatedTokenMode = delegatedTokenMode; + } + + /** Graceful table/namespace catalog: the plain shared catalog when no credential, else the per-user one. */ + Catalog catalog(ConnectorSession session) { + if (!hasDelegatedCredential(session)) { + return catalog; + } + return requireSessionCatalog().asCatalog(toIcebergSessionContext(session, delegatedTokenMode)); + } + + /** Fail-closed table/namespace catalog: requires a credential (rejects a tokenless session=user request). */ + Catalog delegatedCatalog(ConnectorSession session) { + requireDelegatedCredential(session); + return requireSessionCatalog().asCatalog(toIcebergSessionContext(session, delegatedTokenMode)); + } + + /** Graceful view catalog: the plain catalog's view facet when no credential (may be null), else per-user. */ + ViewCatalog viewCatalog(ConnectorSession session) { + if (!hasDelegatedCredential(session)) { + return catalog instanceof ViewCatalog ? (ViewCatalog) catalog : null; + } + return requireSessionCatalog().asViewCatalog(toIcebergSessionContext(session, delegatedTokenMode)); + } + + /** Fail-closed view catalog: requires a credential. asCatalog(ctx) is NOT a ViewCatalog, so views need this. */ + ViewCatalog delegatedViewCatalog(ConnectorSession session) { + requireDelegatedCredential(session); + return requireSessionCatalog().asViewCatalog(toIcebergSessionContext(session, delegatedTokenMode)); + } + + /** + * Builds the Iceberg {@link SessionCatalog.SessionContext} from a Doris {@link ConnectorSession}: the stable + * {@code sessionId} (the OAuth2 AuthSession cache key, preserved across FE forwarding) plus the credential map + * for the current {@code delegatedTokenMode}. A credential-less session yields an empty credential map. + */ + @VisibleForTesting + static SessionCatalog.SessionContext toIcebergSessionContext(ConnectorSession session, + DelegatedTokenMode delegatedTokenMode) { + Map credentials = ImmutableMap.of(); + if (session.getDelegatedCredential().isPresent()) { + credentials = toIcebergCredentials(session.getDelegatedCredential().get(), delegatedTokenMode); + } + return new SessionCatalog.SessionContext(session.getSessionId(), null, credentials, ImmutableMap.of()); + } + + private BaseViewSessionCatalog requireSessionCatalog() { + if (!sessionCatalog.isPresent()) { + throw new DorisConnectorException("Iceberg REST user session requires a session-aware Iceberg catalog"); + } + return sessionCatalog.get(); + } + + private static void requireDelegatedCredential(ConnectorSession session) { + if (!hasDelegatedCredential(session)) { + // Fail closed: a user-session catalog has no shared identity to borrow, so a request that carries no + // delegated credential is rejected rather than served another request's (or a shared) credential. + throw new DorisConnectorException("Iceberg REST user session requires a delegated credential"); + } + } + + private static Map toIcebergCredentials(ConnectorDelegatedCredential credential, + DelegatedTokenMode delegatedTokenMode) { + if (delegatedTokenMode == DelegatedTokenMode.ACCESS_TOKEN) { + // access_token: pass the token verbatim as the OAuth2 bearer. + return ImmutableMap.of(OAuth2Properties.TOKEN, credential.getToken()); + } + // token_exchange: pass the original token under its typed key so the REST server performs the exchange. + return ImmutableMap.of(IcebergDelegatedCredentialUtils.credentialKey(credential.getType()), + credential.getToken()); + } + + private static boolean hasDelegatedCredential(ConnectorSession session) { + return session != null && session.getDelegatedCredential().isPresent(); + } + + /** + * How the delegated credential is attached to the Iceberg REST session (re-migrated from the pre-P6 + * {@code IcebergRestProperties.DelegatedTokenMode}). {@code access_token} = verbatim OAuth2 bearer; + * {@code token_exchange} = the typed token key so the REST server exchanges it (RFC-8693). + */ + enum DelegatedTokenMode { + ACCESS_TOKEN("access_token"), + TOKEN_EXCHANGE("token_exchange"); + + private final String value; + + DelegatedTokenMode(String value) { + this.value = value; + } + + static DelegatedTokenMode fromString(String value) { + for (DelegatedTokenMode mode : values()) { + if (mode.value.equalsIgnoreCase(value)) { + return mode; + } + } + throw new IllegalArgumentException("Invalid delegated token mode: " + value + + ". Supported values are: access_token, token_exchange"); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java new file mode 100644 index 00000000000000..28b0e28fe41d49 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java @@ -0,0 +1,84 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.thrift.TIcebergDeleteFileDesc; + +import org.apache.iceberg.Table; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * Connector-private helpers over the neutral {@link ConnectorStatementScope} (reached via + * {@link ConnectorSession#getStatementScope()}), giving iceberg one place to key its per-statement state. + * + *

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

    + * + *

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

    + */ +final class IcebergStatementScope { + + private IcebergStatementScope() {} + + /** + * Loads the RAW iceberg {@link Table} for {@code db.tbl} once per statement and shares it across every + * resolver. The key includes the catalog id (cross-catalog MERGE isolation) and the statement's queryId + * (a reused prepared context sees each execution's own table). {@code loader} runs at most once per + * statement — callers pass the raw load (cross-query cache or direct remote) and own the auth scope + * (the caller wraps this in {@code executeAuthenticated}). + */ + static Table sharedTable(ConnectorSession session, String dbName, String tableName, Supplier
    loader) { + if (session == null) { + // No session (offline / direct-construction tests): load every time, like ConnectorStatementScope.NONE. + return loader.get(); + } + String key = "iceberg.table:" + session.getCatalogId() + ":" + dbName + ":" + tableName + + ":" + session.getQueryId(); + return session.getStatementScope().computeIfAbsent(key, loader); + } + + /** + * Returns this statement's rewritable-delete supply map (RAW data-file path → its non-equality delete + * descs), creating it empty on first use. The scan seam accumulates into it (per touched data file) and the + * write seam drains it; keyed by catalog id + queryId so a cross-catalog MERGE keeps each table's supply + * isolated. Under {@link ConnectorStatementScope#NONE} each call returns a fresh throwaway map, so scan and + * write do NOT share — the write seam guards format-version≥3 DML against that (fail loud). + */ + static Map> rewritableDeleteSupply(ConnectorSession session) { + if (session == null) { + // No session: a throwaway map that does NOT bridge scan->write (same as NONE). + return new ConcurrentHashMap<>(); + } + String key = "iceberg.rewritable-delete-supply:" + session.getCatalogId() + ":" + session.getQueryId(); + return session.getStatementScope().computeIfAbsent(key, ConcurrentHashMap::new); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java new file mode 100644 index 00000000000000..8a6b53ef599787 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java @@ -0,0 +1,117 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.cache.CacheSpec; +import org.apache.doris.connector.cache.MetaCacheEntry; + +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; + +import java.util.concurrent.ForkJoinPool; +import java.util.function.Supplier; + +/** + * Per-catalog cache of the RAW iceberg {@link Table} object, keyed by {@link TableIdentifier} (db.table) + * (PERF-01). This restores the OTHER half of the legacy {@code IcebergExternalMetaCache} that the SPI cutover + * dropped: {@link IcebergLatestSnapshotCache} kept only the {@code (snapshotId, schemaId)} pin, so every SPI + * read entry ({@code getColumnHandles}, {@code getTableStatistics}, the scan provider's {@code resolveTable}, + * ...) re-loaded the table from the remote catalog (a metastore RPC + a {@code metadata.json} read). This cache + * lets consecutive queries — and the analysis/planning phases of one query, whose handles have distinct memo + * lineages — reuse a single loaded table, exactly as the legacy with-cache catalog did. + * + *

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

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

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

    loader) { + return entry.get(identifier, ignored -> loader.get()); + } + + /** Drops the cached entry for one table so the next read goes live (REFRESH TABLE). */ + void invalidate(TableIdentifier identifier) { + entry.invalidateKey(identifier); + } + + /** + * Drops every cached entry for one database so the next read of any of its tables goes live + * (REFRESH DATABASE / a Doris-issued DROP DATABASE). Entries are keyed by + * {@code TableIdentifier.of(db, table)} (single-level namespace = {@code [db]}), so a db match is + * namespace equality — mirroring {@link IcebergLatestSnapshotCache#invalidateDb}. + */ + void invalidateDb(String dbName) { + Namespace ns = Namespace.of(dbName); + entry.invalidateIf(id -> id.namespace().equals(ns)); + } + + /** Drops all cached entries. */ + void invalidateAll() { + entry.invalidateAll(); + } + + /** Test-only: current number of cached entries (accurate map membership, not Caffeine's estimate). */ + int size() { + int[] count = {0}; + entry.forEach((key, value) -> count[0]++); + return count[0]; + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableHandle.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableHandle.java index db808c989cf5b0..295402a0f66de9 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableHandle.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableHandle.java @@ -19,20 +19,109 @@ import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import com.google.common.collect.ImmutableSet; + +import java.util.Objects; +import java.util.Set; + /** * Opaque table handle for an Iceberg table, carrying the database (namespace) - * and table name coordinates. + * and table name coordinates plus an optional MVCC / time-travel pin (T07). + * + *

    The pin is threaded in by {@code IcebergConnectorMetadata.applySnapshot} (called by the generic + * {@code PluginDrivenScanNode} before {@code planScan} / {@code getScanNodeProperties}). It mirrors the + * paimon connector's {@code PaimonTableHandle} scan options, but iceberg pins via typed carriers — the + * iceberg SDK applies time-travel through {@code TableScan.useSnapshot(id)} / {@code useRef(name)} rather + * than a {@code Table.copy(properties)} option map: + *

      + *
    • {@code snapshotId} ({@code -1} = none) — {@code FOR VERSION AS OF } / {@code FOR TIME AS OF}.
    • + *
    • {@code ref} ({@code null} = none) — a tag/branch name; the scan pins by REF ({@code useRef}) so a + * later commit to the tag/branch is honored (legacy parity).
    • + *
    • {@code schemaId} ({@code -1} = latest) — the schema version AS OF the pin, so the field-id dictionary + * and {@code getTableSchema(@snapshot)} read the historical schema.
    • + *
    + * The handle is immutable: {@link #withSnapshot} returns a NEW handle (the pin is part of the handle + * identity, so {@link #equals}/{@link #hashCode}/{@link #toString} include it). + * + *

    A handle may also represent a system table (e.g. {@code t$snapshots}); see + * {@link #forSystemTable}. For a system handle {@link #sysTableName} is the bare sys-table name (no + * {@code "$"}) and {@link #isSystemTable()} returns true. Unlike paimon's {@code forSystemTable}, + * the snapshot/ref/schema pin is RETAINED on a system handle because iceberg system tables legally + * time-travel ({@code t$snapshots FOR VERSION AS OF ...}). */ public class IcebergTableHandle implements ConnectorTableHandle { private static final long serialVersionUID = 1L; + /** Sentinel for "no snapshot / latest schema" — mirrors legacy {@code IcebergUtils.UNKNOWN_SNAPSHOT_ID}. */ + private static final long NO_PIN = -1L; + private final String dbName; private final String tableName; + private final long snapshotId; + private final String ref; + private final long schemaId; + + /** + * Bare system-table name (no {@code "$"}), lower-cased by the caller + * ({@code IcebergConnectorMetadata.getSysTableHandle}), or {@code null} for a normal data-table + * handle. Non-transient: the JNI sys-table read happens on a DESERIALIZED handle, so a deserialized + * sys handle must still know it is a sys table (and at which snapshot) — otherwise it would silently + * read the base data table at the latest version. It is part of the handle identity (a + * {@code t$snapshots} read is a different table than {@code t}), so {@link #equals}/{@link #hashCode}/ + * {@link #toString} include it. + */ + private final String sysTableName; + + /** + * Restricts the scan to ONLY these data files (by their RAW iceberg path, {@code dataFile.path()}), or + * {@code null} for a normal full scan. Set by the {@code rewrite_data_files} engine driver before each + * per-group {@code INSERT-SELECT} so the group scans exactly its bin-packed files (WS-REWRITE R2). The key + * is the raw path the rewrite planner records — NOT the scheme-normalized BE path — so a normalization + * difference can never silently scope to the wrong files. Part of the handle identity (a scoped scan is a + * different scan than the full scan), so {@link #equals}/{@link #hashCode}/{@link #toString} include it. + */ + private final Set rewriteFileScope; + + /** + * Whether this scan runs under Top-N lazy materialization: the engine-wide synthesized row-id column + * ({@code __DORIS_GLOBAL_ROWID_COL__}) is present, so BE re-fetches the non-projected columns of the + * surviving rows by row-id. Threaded in by {@code IcebergConnectorMetadata.applyTopnLazyMaterialization} + * (called by the generic {@code PluginDrivenScanNode} before {@code getScanNodeProperties}). It forces + * the field-id schema dictionary to span the FULL schema rather than the pruned scan slots, so a lazily + * re-fetched column still carries its field-id on a schema-evolved native read (legacy + * {@code IcebergScanNode.createScanRangeLocations} → {@code initSchemaInfoForAllColumn} parity). Part of + * the handle identity (it changes the BE-facing dictionary), so {@link #equals}/{@link #hashCode}/ + * {@link #toString} include it. + */ + private final boolean topnLazyMaterialize; public IcebergTableHandle(String dbName, String tableName) { + this(dbName, tableName, NO_PIN, null, NO_PIN, null, null, false); + } + + private IcebergTableHandle(String dbName, String tableName, long snapshotId, String ref, long schemaId, + String sysTableName, Set rewriteFileScope, boolean topnLazyMaterialize) { this.dbName = dbName; this.tableName = tableName; + this.snapshotId = snapshotId; + this.ref = ref; + this.schemaId = schemaId; + this.sysTableName = sysTableName; + this.rewriteFileScope = rewriteFileScope; + this.topnLazyMaterialize = topnLazyMaterialize; + } + + /** + * Builds a system-table handle for {@code db.table$sysName} (e.g. {@code t$snapshots}). Unlike + * paimon's {@code forSystemTable}, the snapshot/ref/schema pin is RETAINED and threaded straight + * through: iceberg system tables legally time-travel ({@code FOR VERSION/TIME AS OF}), so a pinned + * sys read must honor the pin (deviation 1). {@code sysName} is the bare lower-cased name (no + * {@code "$"}); the caller normalizes it. + */ + public static IcebergTableHandle forSystemTable(String dbName, String tableName, String sysName, + long snapshotId, String ref, long schemaId) { + return new IcebergTableHandle(dbName, tableName, snapshotId, ref, schemaId, sysName, null, false); } public String getDbName() { @@ -43,8 +132,124 @@ public String getTableName() { return tableName; } + /** The pinned snapshot id, or {@code -1} when there is no snapshot-id pin. */ + public long getSnapshotId() { + return snapshotId; + } + + /** The pinned tag/branch ref name, or {@code null} when there is no ref pin. */ + public String getRef() { + return ref; + } + + /** The pinned schema id, or {@code -1} (latest) when there is no pin. */ + public long getSchemaId() { + return schemaId; + } + + /** Bare system-table name (no {@code "$"}), or {@code null} for a normal data-table handle. */ + public String getSysTableName() { + return sysTableName; + } + + /** Whether this handle represents an iceberg system table (e.g. {@code t$snapshots}). */ + public boolean isSystemTable() { + return sysTableName != null; + } + + /** Whether this handle carries an explicit MVCC / time-travel pin (a snapshot id or a tag/branch ref). */ + public boolean hasSnapshotPin() { + return snapshotId >= 0 || ref != null; + } + + /** + * The rewrite file scope (raw iceberg data-file paths the scan is restricted to), or {@code null} for a + * normal full scan. See {@link #rewriteFileScope} and {@link #withRewriteFileScope}. + */ + public Set getRewriteFileScope() { + return rewriteFileScope; + } + + /** Whether this scan runs under Top-N lazy materialization (see {@link #topnLazyMaterialize}). */ + public boolean isTopnLazyMaterialize() { + return topnLazyMaterialize; + } + + /** + * Returns a copy of this handle carrying the resolved time-travel pin. Mirrors paimon's + * {@code PaimonTableHandle.withScanOptions}/{@code withBranch} but with iceberg's typed carriers. + */ + public IcebergTableHandle withSnapshot(long snapshotId, String ref, long schemaId) { + // sysTableName, rewriteFileScope and topnLazyMaterialize are preserved: threading a resolved + // time-travel pin in must not degrade a sys handle (t$snapshots) into a normal data-table handle, + // drop a rewrite scope, or drop the lazy-materialization signal. + return new IcebergTableHandle(dbName, tableName, snapshotId, ref, schemaId, sysTableName, + rewriteFileScope, topnLazyMaterialize); + } + + /** + * Returns a copy of this handle whose scan is restricted to {@code rawDataFilePaths} (the RAW iceberg + * {@code dataFile.path()} of each file in a {@code rewrite_data_files} bin-packed group). The engine + * rewrite driver applies this before a group's {@code INSERT-SELECT} so the group scans exactly its files + * (WS-REWRITE R2). The paths are matched against the raw path the iceberg SDK reports for each enumerated + * file — never the scheme-normalized BE path — so a normalization difference cannot mis-scope the scan. + * The other carriers (snapshot/ref/schema/sys) are preserved. + */ + public IcebergTableHandle withRewriteFileScope(Set rawDataFilePaths) { + return new IcebergTableHandle(dbName, tableName, snapshotId, ref, schemaId, sysTableName, + ImmutableSet.copyOf(rawDataFilePaths), topnLazyMaterialize); + } + + /** + * Returns a copy of this handle marked as a Top-N lazy-materialization scan (see + * {@link #topnLazyMaterialize}). The other carriers (snapshot/ref/schema/sys/rewriteScope) are preserved. + */ + public IcebergTableHandle withTopnLazyMaterialize(boolean topnLazyMaterialize) { + return new IcebergTableHandle(dbName, tableName, snapshotId, ref, schemaId, sysTableName, + rewriteFileScope, topnLazyMaterialize); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof IcebergTableHandle)) { + return false; + } + IcebergTableHandle that = (IcebergTableHandle) o; + return snapshotId == that.snapshotId + && schemaId == that.schemaId + && topnLazyMaterialize == that.topnLazyMaterialize + && Objects.equals(dbName, that.dbName) + && Objects.equals(tableName, that.tableName) + && Objects.equals(ref, that.ref) + && Objects.equals(sysTableName, that.sysTableName) + && Objects.equals(rewriteFileScope, that.rewriteFileScope); + } + + @Override + public int hashCode() { + return Objects.hash(dbName, tableName, snapshotId, ref, schemaId, sysTableName, rewriteFileScope, + topnLazyMaterialize); + } + @Override public String toString() { - return "IcebergTableHandle{" + dbName + "." + tableName + "}"; + StringBuilder sb = new StringBuilder("IcebergTableHandle{").append(dbName).append('.').append(tableName); + if (sysTableName != null) { + sb.append('$').append(sysTableName); + } + if (hasSnapshotPin()) { + sb.append(", snapshotId=").append(snapshotId).append(", ref=").append(ref) + .append(", schemaId=").append(schemaId); + } + if (rewriteFileScope != null) { + sb.append(", rewriteFileScope=").append(rewriteFileScope.size()).append(" files"); + } + if (topnLazyMaterialize) { + sb.append(", topnLazyMaterialize=true"); + } + return sb.append('}').toString(); } } diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTimeUtils.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTimeUtils.java new file mode 100644 index 00000000000000..de5d5a30e10aae --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTimeUtils.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.iceberg; + +import org.apache.doris.connector.api.ConnectorSession; + +import java.time.DateTimeException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Collections; +import java.util.Map; +import java.util.TreeMap; + +/** + * Self-contained session time-zone resolution + datetime parsing for the iceberg connector (the connector + * cannot import fe-core {@code TimeUtils}). Shared by {@link IcebergScanPlanProvider} (timestamptz literal + * pushdown, T02) and {@link IcebergConnectorMetadata} ({@code FOR TIME AS OF} time-travel, T07). + */ +public final class IcebergTimeUtils { + + // Self-contained mirror of fe-core TimeUtils.timeZoneAliasMap (cannot import TimeUtils). Doris stores the + // session time_zone un-canonicalized (e.g. SET time_zone='CST' keeps "CST"), and legacy resolves it via + // ZoneId.of(tz, timeZoneAliasMap). Without these aliases a plain ZoneId.of("CST") throws (CST is a + // SHORT_ID) and falls back to UTC, shifting a timestamp literal by hours -> wrong file pruning / wrong + // time-travel snapshot. The full SHORT_IDS map is required (PST/EST resolve via SHORT_IDS), plus the four + // Doris overrides (CST/PRC -> Asia/Shanghai, UTC/GMT -> UTC = TimeUtils DEFAULT/UTC_TIME_ZONE). + private static final Map TIME_ZONE_ALIAS_MAP; + + // Byte-parity with legacy TimeUtils.DATETIME_FORMAT (= DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")), + // the formatter TimeUtils.timeStringToLong uses for a non-digital FOR TIME AS OF datetime string. + private static final DateTimeFormatter DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + // Byte-parity with legacy TimeUtils.DATETIME_MS_FORMAT (= DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")), + // the formatter TimeUtils.msTimeStringToLong uses. The rollback_to_timestamp EXECUTE action parses its + // datetime argument with the millisecond-precision format (NOT the second-precision DATETIME_FORMAT above). + private static final DateTimeFormatter DATETIME_MS_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + + static { + Map aliases = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + aliases.putAll(ZoneId.SHORT_IDS); + aliases.put("CST", "Asia/Shanghai"); + aliases.put("PRC", "Asia/Shanghai"); + aliases.put("UTC", "UTC"); + aliases.put("GMT", "UTC"); + TIME_ZONE_ALIAS_MAP = Collections.unmodifiableMap(aliases); + } + + private IcebergTimeUtils() { + } + + /** + * Resolves the session time zone through the Doris alias map (mirrors fe-core {@code TimeUtils.getTimeZone}), + * so aliases like {@code CST}/{@code PRC}/{@code EST} match legacy instead of throwing; a + * {@code null}/blank/genuinely-invalid id falls back to UTC. + */ + public static ZoneId resolveSessionZone(ConnectorSession session) { + if (session == null) { + return ZoneOffset.UTC; + } + String tz = session.getTimeZone(); + if (tz == null || tz.trim().isEmpty()) { + return ZoneOffset.UTC; + } + try { + return ZoneId.of(tz.trim(), TIME_ZONE_ALIAS_MAP); + } catch (Exception e) { + return ZoneOffset.UTC; + } + } + + /** + * Parses a {@code FOR TIME AS OF} datetime string to epoch-millis in {@code zone}, byte-faithful to legacy + * {@code TimeUtils.timeStringToLong(value, sessionTZ)} (parse {@code yyyy-MM-dd HH:mm:ss} as a local + * date-time, then interpret it in the session zone). Legacy returned {@code -1} on a parse failure and the + * caller ({@code IcebergUtils.getQuerySpecSnapshot}) turned that into a {@code DateTimeException}; we throw + * it directly (fail loud — a parse error is a user mistake, not a not-found). + */ + static long datetimeToMillis(String value, ZoneId zone) { + try { + return LocalDateTime.parse(value, DATETIME_FORMAT).atZone(zone).toInstant().toEpochMilli(); + } catch (DateTimeParseException e) { + throw new DateTimeException("can't parse time: " + value); + } + } + + /** + * Parses a millisecond-precision datetime string ({@code yyyy-MM-dd HH:mm:ss.SSS}) to epoch-millis in + * {@code zone}, byte-faithful to legacy {@code TimeUtils.msTimeStringToLong(value, sessionTZ)}: parse as a + * local date-time, interpret it in the session zone, and — preserving the legacy sentinel — return + * {@code -1} on a parse failure (the caller, {@code rollback_to_timestamp}, turns {@code -1} into the + * "Invalid timestamp format" error, mirroring the legacy {@code parseTimestampMillis} contract). Used only + * by the {@code rollback_to_timestamp} EXECUTE action, which needs the millisecond format (the second + * format of {@link #datetimeToMillis} is for {@code FOR TIME AS OF}). + */ + public static long msTimeStringToLong(String value, ZoneId zone) { + LocalDateTime parsed; + try { + parsed = LocalDateTime.parse(value, DATETIME_MS_FORMAT); + } catch (DateTimeParseException e) { + return -1; + } + return parsed.atZone(zone).toInstant().toEpochMilli(); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTypeMapping.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTypeMapping.java index 9539e2547d4a01..8e4d791f037152 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTypeMapping.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTypeMapping.java @@ -18,12 +18,16 @@ package org.apache.doris.connector.iceberg; import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; +import java.util.Locale; /** * Maps Iceberg {@link Type} to Doris {@link ConnectorType}. @@ -43,7 +47,7 @@ private IcebergTypeMapping() { * * @param icebergType the Iceberg type * @param enableMappingVarbinary if true, map BINARY/UUID/FIXED to VARBINARY; otherwise STRING/CHAR - * @param enableMappingTimestampTz if true, map TIMESTAMP with TZ to TIMESTAMPTV2; otherwise DATETIMEV2 + * @param enableMappingTimestampTz if true, map TIMESTAMP with TZ to TIMESTAMPTZ; otherwise DATETIMEV2 */ public static ConnectorType fromIcebergType(Type icebergType, boolean enableMappingVarbinary, boolean enableMappingTimestampTz) { @@ -53,28 +57,43 @@ public static ConnectorType fromIcebergType(Type icebergType, } switch (icebergType.typeId()) { case LIST: + // Carry the element field-id (legacy IcebergUtils.updateIcebergColumnUniqueId recurses into + // the element with ListType.fields().get(0).fieldId()) so the BE field-id scan path matches + // a pruned array-of-struct leaf by id; a -1 leaf is skipped and returns NULL. Types.ListType list = (Types.ListType) icebergType; ConnectorType elemType = fromIcebergType( list.elementType(), enableMappingVarbinary, enableMappingTimestampTz); - return ConnectorType.arrayOf(elemType); + return ConnectorType.arrayOf(elemType) + .withChildrenFieldIds(Collections.singletonList(list.elementId())); case MAP: + // Carry key + value field-ids (legacy recurses into both via MapType.fields()). Types.MapType map = (Types.MapType) icebergType; ConnectorType keyType = fromIcebergType( map.keyType(), enableMappingVarbinary, enableMappingTimestampTz); ConnectorType valType = fromIcebergType( map.valueType(), enableMappingVarbinary, enableMappingTimestampTz); - return ConnectorType.mapOf(keyType, valType); + return ConnectorType.mapOf(keyType, valType) + .withChildrenFieldIds(Arrays.asList(map.keyId(), map.valueId())); case STRUCT: + // Carry each field's field-id, parallel to the field types (legacy recurses field-by-field). Types.StructType struct = (Types.StructType) icebergType; List names = new ArrayList<>(struct.fields().size()); List types = new ArrayList<>(struct.fields().size()); + List fieldIds = new ArrayList<>(struct.fields().size()); for (Types.NestedField f : struct.fields()) { names.add(f.name()); types.add(fromIcebergType( f.type(), enableMappingVarbinary, enableMappingTimestampTz)); + fieldIds.add(f.fieldId()); } - return ConnectorType.structOf(names, types); + return ConnectorType.structOf(names, types).withChildrenFieldIds(fieldIds); default: + // Any non-primitive iceberg type Doris cannot represent (VARIANT today; future non-primitive + // typeIds) degrades to UNSUPPORTED: the table still LOADS and only this column is + // present-but-unqueryable. This DIVERGES from legacy fe-core (IcebergUtils.icebergTypeToDorisType + // threw IllegalArgumentException at schema-load, failing the whole table). Graceful degradation + // is INTENTIONAL (user decision 2026-07-13: map every unrepresentable column uniformly to + // UNSUPPORTED so one exotic column does not make a wide table unloadable). Registered DV-051. return ConnectorType.of("UNSUPPORTED"); } } @@ -98,8 +117,12 @@ private static ConnectorType fromPrimitive(Type.PrimitiveType primitive, return enableMappingVarbinary ? ConnectorType.of("VARBINARY", 16, 0) : ConnectorType.of("STRING"); case BINARY: + // Iceberg BINARY is unbounded. Emit VARBINARY with NO explicit length so + // ConnectorColumnConverter applies ScalarType.MAX_VARBINARY_LENGTH — byte-identical to + // legacy IcebergUtils createVarbinaryType(VarBinaryType.MAX_VARBINARY_LENGTH). A + // concrete length (e.g. 65535) would render a different DESCRIBE / SHOW CREATE type. return enableMappingVarbinary - ? ConnectorType.of("VARBINARY", 65535, 0) : ConnectorType.of("STRING"); + ? ConnectorType.of("VARBINARY") : ConnectorType.of("STRING"); case FIXED: int fixedLen = ((Types.FixedType) primitive).length(); return enableMappingVarbinary @@ -113,13 +136,83 @@ private static ConnectorType fromPrimitive(Type.PrimitiveType primitive, case TIMESTAMP: if (enableMappingTimestampTz && ((Types.TimestampType) primitive).shouldAdjustToUTC()) { - return ConnectorType.of("TIMESTAMPTZV2", ICEBERG_DATETIME_SCALE_MS, 0); + // Must be "TIMESTAMPTZ" (not "TIMESTAMPTZV2"): ConnectorColumnConverter only + // recognizes TIMESTAMPTZ -> ScalarType.createTimeStampTzType(precision); an + // unrecognized name degrades the column to UNSUPPORTED. Legacy + // IcebergUtils maps this to createTimeStampTzType(ICEBERG_DATETIME_SCALE_MS). + return ConnectorType.of("TIMESTAMPTZ", ICEBERG_DATETIME_SCALE_MS, 0); } return ConnectorType.of("DATETIMEV2", ICEBERG_DATETIME_SCALE_MS, 0); case TIME: + // iceberg TIME has no Doris analogue -> UNSUPPORTED (explicit, byte-parity with legacy + // IcebergUtils which also mapped TIME to Type.UNSUPPORTED). return ConnectorType.of("UNSUPPORTED"); default: + // Any primitive iceberg type Doris cannot represent — notably the v3 types TIMESTAMP_NANO / + // GEOMETRY / GEOGRAPHY / UNKNOWN — degrades to UNSUPPORTED: the table still LOADS and only this + // column is present-but-unqueryable. This DIVERGES from legacy (IcebergUtils.icebergPrimitiveType- + // ToDorisType threw IllegalArgumentException "Cannot transform unknown type", failing the whole + // table). Graceful degradation is INTENTIONAL (user decision 2026-07-13: map every unrepresentable + // column uniformly to UNSUPPORTED so one exotic column does not make a wide table unloadable). + // Registered DV-051. (The write direction toIcebergPrimitive still throws — CREATE TABLE must not + // silently accept a type it cannot round-trip.) return ConnectorType.of("UNSUPPORTED"); } } + + /** + * Maps a SCALAR {@link ConnectorType} (a Doris column type carried across the SPI by name) to an + * Iceberg {@link Type} for CREATE TABLE. The inverse of {@link #fromPrimitive}, this is a string-driven + * port of the legacy fe-core {@code DorisTypeToIcebergType.atomic} (which walked a Doris {@code Type} + * object): the connector only has the neutral {@code typeName} (the Doris {@code PrimitiveType.toString()}) + * plus precision/scale, so the same set of supported types is matched here. + * + *

    Complex types (ARRAY / MAP / STRUCT) are NOT handled here — {@link IcebergSchemaBuilder} owns the + * recursive tree walk + field-id allocation and calls this only for scalar leaves. Any type legacy did + * not support (TINYINT / SMALLINT / LARGEINT / TIME / JSON / VARIANT / IPv*, ...) fails loud, matching + * legacy's {@code UnsupportedOperationException}.

    + */ + static Type toIcebergPrimitive(ConnectorType type) { + String name = type.getTypeName().toUpperCase(Locale.ROOT); + switch (name) { + case "BOOLEAN": + return Types.BooleanType.get(); + case "INT": + case "INTEGER": + return Types.IntegerType.get(); + case "BIGINT": + return Types.LongType.get(); + case "FLOAT": + return Types.FloatType.get(); + case "DOUBLE": + return Types.DoubleType.get(); + case "CHAR": + case "VARCHAR": + case "STRING": + // Legacy parity: every char-family Doris type maps to Iceberg STRING (declared length + // dropped) — DorisTypeToIcebergType.atomic uses primitiveType.isCharFamily(). + return Types.StringType.get(); + case "DATE": + case "DATEV2": + return Types.DateType.get(); + case "DECIMALV2": + case "DECIMALV3": + case "DECIMAL32": + case "DECIMAL64": + case "DECIMAL128": + case "DECIMAL256": + // Precision/scale carried in the ConnectorType (ConnectorColumnConverter.toConnectorType + // sets them from ScalarType.getScalarPrecision()/getScalarScale()). + return Types.DecimalType.of(type.getPrecision(), Math.max(type.getScale(), 0)); + case "DATETIME": + case "DATETIMEV2": + // Legacy parity: timestamp WITHOUT zone (datetime scale dropped — Iceberg timestamps are + // microsecond). DorisTypeToIcebergType.atomic returns TimestampType.withoutZone(). + return Types.TimestampType.withoutZone(); + case "TIMESTAMPTZ": + return Types.TimestampType.withZone(); + default: + throw new DorisConnectorException("Unsupported type for Iceberg: " + type.getTypeName()); + } + } } 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 new file mode 100644 index 00000000000000..fefcb8fe77d379 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriteContext.java @@ -0,0 +1,92 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.handle.WriteOperation; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Immutable op context for a single iceberg write, threaded into + * {@link IcebergConnectorTransaction#beginWrite}. The connector-internal equivalent of the fe-core + * {@code IcebergInsertCommandContext} (which the connector cannot import): it carries the write operation, + * the overwrite mode, the static partition spec (for {@code INSERT OVERWRITE ... PARTITION}), and the target + * branch. The transaction reads these at begin time (to pick begin-guards) and at commit time (to pick the + * SDK operation: AppendFiles / ReplacePartitions / OverwriteFiles / RowDelta). + * + *

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

    + */ +final class IcebergWriteContext { + + private final WriteOperation writeOperation; + private final boolean overwrite; + private final Map staticPartitionValues; + private final Optional branchName; + private final long readSnapshotId; + + IcebergWriteContext(WriteOperation writeOperation, boolean overwrite, + Map staticPartitionValues, Optional branchName) { + this(writeOperation, overwrite, staticPartitionValues, branchName, -1L); + } + + IcebergWriteContext(WriteOperation writeOperation, boolean overwrite, + Map staticPartitionValues, Optional branchName, long readSnapshotId) { + this.writeOperation = writeOperation; + this.overwrite = overwrite; + this.staticPartitionValues = staticPartitionValues == null + ? Collections.emptyMap() : new HashMap<>(staticPartitionValues); + this.branchName = branchName == null ? Optional.empty() : branchName; + this.readSnapshotId = readSnapshotId; + } + + WriteOperation getWriteOperation() { + return writeOperation; + } + + boolean isOverwrite() { + return overwrite; + } + + Map getStaticPartitionValues() { + return staticPartitionValues; + } + + /** An {@code INSERT OVERWRITE ... PARTITION(col=val, ...)} (a non-empty static partition spec). */ + boolean isStaticPartitionOverwrite() { + return overwrite && !staticPartitionValues.isEmpty(); + } + + Optional getBranchName() { + return branchName; + } + + /** + * The statement's READ snapshot id (the MVCC pin the scan used, S_read), threaded from the write + * handle in {@code planWrite}; {@code -1} = no pin (the legacy fresh-current behavior). The + * RowDelta path anchors {@code baseSnapshotId} at this snapshot so the commit-time removeDeletes + * (option D) and the scan-time deletes BE unions into the new DV share one snapshot — see + * {@link IcebergConnectorTransaction} [SHOULD-2] / Fix B. + */ + long getReadSnapshotId() { + return readSnapshotId; + } +} 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 new file mode 100644 index 00000000000000..4ad5d97c5fd9a6 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java @@ -0,0 +1,785 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.connector.api.handle.ConnectorWriteHandle; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.write.ConnectorSinkPlan; +import org.apache.doris.connector.api.write.ConnectorWritePartitionField; +import org.apache.doris.connector.api.write.ConnectorWritePartitionSpec; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; +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.filesystem.properties.StorageProperties; +import org.apache.doris.thrift.TDataSink; +import org.apache.doris.thrift.TDataSinkType; +import org.apache.doris.thrift.TFileCompressType; +import org.apache.doris.thrift.TFileContent; +import org.apache.doris.thrift.TFileFormatType; +import org.apache.doris.thrift.TFileType; +import org.apache.doris.thrift.TIcebergDeleteFileDesc; +import org.apache.doris.thrift.TIcebergDeleteSink; +import org.apache.doris.thrift.TIcebergMergeSink; +import org.apache.doris.thrift.TIcebergRewritableDeleteFileSet; +import org.apache.doris.thrift.TIcebergTableSink; +import org.apache.doris.thrift.TIcebergWriteType; +import org.apache.doris.thrift.TNetworkAddress; +import org.apache.doris.thrift.TSortField; + +import com.google.common.collect.Maps; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.NullOrder; +import org.apache.iceberg.PartitionField; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.PartitionSpecParser; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SchemaParser; +import org.apache.iceberg.SortDirection; +import org.apache.iceberg.SortField; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.types.Types.NestedField; +import org.apache.iceberg.util.LocationUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +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.function.Function; + +/** + * Write plan provider for iceberg INSERT / INSERT OVERWRITE. + * + *

    Builds the opaque {@link TIcebergTableSink} for a bound write and binds the write to the current + * {@link IcebergConnectorTransaction}: it opens the SDK transaction (via + * {@link IcebergConnectorTransaction#beginWrite}, which loads the table and applies the begin-guards), + * then assembles the sink Thrift from the loaded table. The Thrift is byte-identical to the legacy + * fe-core {@code planner.IcebergTableSink.bindDataSink} (C2, zero BE change), so the BE writer is + * unaffected by the migration.

    + * + *

    Scope. INSERT / OVERWRITE ({@code TIcebergTableSink}, T06), DELETE ({@code TIcebergDeleteSink}) + * and UPDATE / MERGE ({@code TIcebergMergeSink}, T07a). REWRITE (procedures, P6.4) is not built here. The + * write distribution and the vended-credentials overlay of the hadoop config are registered deviations + * (DV-T0x-vended / -broker / -materialize) closed at the P6.6 cutover. At format-version≥3 the DELETE / + * MERGE sink's {@code rewritable_delete_file_sets} is filled here from the scan-time supply the + * per-statement {@link IcebergStatementScope} carried across the scan→write seam (commit-bridge S4 part 2), + * replacing the legacy fe-resident rewritable-delete planner.

    + * + *

    Gate-closed / dormant. Iceberg is not in {@code SPI_READY_TYPES} until P6.6, so nothing + * routes iceberg writes through this provider yet; {@link #planWrite} requires the executor-bound + * connector transaction and fails loud if absent.

    + */ +public class IcebergWritePlanProvider implements ConnectorWritePlanProvider { + + // Legacy IcebergUtils compression-codec property keys (connector-local copies; iceberg SDK has no + // constant for the doris/spark-sql forms). + private static final String COMPRESSION_CODEC = "compression-codec"; + private static final String SPARK_SQL_COMPRESSION_CODEC = "spark.sql.iceberg.compression-codec"; + + // Connector-local literal copy of fe-core's Column.ICEBERG_ROWID_COL (connectors must not import + // fe-core). The fe-core ConnectorColumnConverterTest contract pin asserts that converting this exact + // declared shape yields the legacy IcebergRowId.createHiddenColumn() (name / STRUCT / invisible / + // not-null), so a drift on either side turns one of the two tests red. + private static final String DORIS_ICEBERG_ROWID_COL = "__DORIS_ICEBERG_ROWID_COL__"; + + // The single request-scoped synthetic write column iceberg declares: the row-id STRUCT carrying the + // per-row write metadata (file_path / row_position / partition_spec_id / partition_data). Same for + // every iceberg table regardless of format/partitioning (mirrors legacy IcebergRowId), so it is a + // shared immutable instance. + private static final List SYNTHETIC_WRITE_COLUMNS = + Collections.singletonList(buildRowIdColumn()); + + private static ConnectorColumn buildRowIdColumn() { + ConnectorType rowIdStruct = ConnectorType.structOf( + Arrays.asList("file_path", "row_position", "partition_spec_id", "partition_data"), + Arrays.asList(ConnectorType.of("STRING"), ConnectorType.of("BIGINT"), + ConnectorType.of("INT"), ConnectorType.of("STRING"))); + return new ConnectorColumn(DORIS_ICEBERG_ROWID_COL, rowIdStruct, + "Iceberg row position metadata", false, null, false).invisible(); + } + + private final Map properties; + // Per-request catalog-ops resolver: applied with the current ConnectorSession to obtain the IcebergCatalogOps + // for that request. For a iceberg.rest.session=user catalog the connector passes this::newCatalogBackedOps so + // the write-side read helpers (explain sort clause / write sort columns / write partitioning) resolve the + // table through the querying user's per-request delegated REST catalog (fail-closed). The actual INSERT/DELETE/ + // MERGE commit is already per-user: it loads through IcebergConnectorTransaction, opened by + // IcebergConnectorMetadata.beginTransaction over the session-aware metadata ops. Offline-test ctors resolve the + // single shared ops regardless of session (constant s -> catalogOps). + private final Function catalogOpsResolver; + private final ConnectorContext context; + + public IcebergWritePlanProvider(Map properties, IcebergCatalogOps catalogOps, + ConnectorContext context) { + // Constant resolver: these ctors (offline tests) bind a single ops that ignores the session, so existing + // behaviour/tests are byte-identical. + this(properties, session -> catalogOps, context); + } + + /** + * Session-aware ctor used by {@link IcebergConnector#getWritePlanProvider()}: {@code catalogOpsResolver} is + * applied per request with the current {@link ConnectorSession} so a {@code iceberg.rest.session=user} catalog + * resolves the querying user's per-request delegated catalog for the write-side read helpers (the connector + * passes {@code this::newCatalogBackedOps}); every other catalog resolves the single shared ops. + */ + public IcebergWritePlanProvider(Map properties, + Function catalogOpsResolver, + ConnectorContext context) { + this.properties = properties; + this.catalogOpsResolver = catalogOpsResolver; + this.context = context; + } + + @Override + public ConnectorSinkPlan planWrite(ConnectorSession session, ConnectorWriteHandle handle) { + IcebergTableHandle tableHandle = (IcebergTableHandle) handle.getTableHandle(); + IcebergConnectorTransaction transaction = currentTransaction(session); + + // Open the SDK transaction (loads the table inside the auth context and applies the begin-guards). + // The op-context is derived from the bound write handle: the generic handle carries only an + // isOverwrite() boolean, so an overwriting INSERT is promoted to the OVERWRITE operation the + // transaction switches on at commit time (Append vs ReplacePartitions / OverwriteFiles). + IcebergWriteContext writeContext = buildWriteContext(handle); + transaction.beginWrite(session, tableHandle.getDbName(), tableHandle.getTableName(), writeContext); + Table table = transaction.getTable(); + + // commit-bridge supply (S4 part 2): read the non-equality delete supply the scan seam accumulated into the + // per-statement scope. DELETE/MERGE attach it to the sink so the BE OR-merges old deletes into the new + // deletion vector (a missing supply silently resurrects deleted rows); INSERT/OVERWRITE/REWRITE ignore it + // (buildRewritableDeleteFileSets no-ops an empty map, same as the former null). + // Fail loud (never silently resurrect): a format-version>=3 row-level DML under an absent statement scope + // (ConnectorStatementScope.NONE — offline / no live statement) cannot have received the scan's supply (each + // NONE lookup is a throwaway map), so reject it rather than write a deletion vector that drops old deletes. + WriteOperation writeOp = writeContext.getWriteOperation(); + if ((writeOp == WriteOperation.DELETE || writeOp == WriteOperation.UPDATE || writeOp == WriteOperation.MERGE) + && session.getStatementScope() == ConnectorStatementScope.NONE + && IcebergWriterHelper.getFormatVersion(table) >= 3) { + throw new DorisConnectorException("Iceberg row-level " + writeOp + " on a format-version>=3 table requires " + + "a per-statement scope to carry the rewritable-delete supply from scan to write; none is present " + + "(ConnectorStatementScope.NONE). Refusing to write a deletion vector that would drop old deletes " + + "and resurrect previously-deleted rows."); + } + Map> rewritableDeletes = + IcebergStatementScope.rewritableDeleteSupply(session); + + // Dispatch on the write operation to the matching BE sink dialect (each is a distinct TDataSinkType, + // byte-identical to the legacy fe-core planner sink). OVERWRITE shares TIcebergTableSink with INSERT + // (the overwrite flag is read from the handle); UPDATE shares TIcebergMergeSink with MERGE. + switch (writeContext.getWriteOperation()) { + case INSERT: + case OVERWRITE: { + TDataSink dataSink = new TDataSink(TDataSinkType.ICEBERG_TABLE_SINK); + dataSink.setIcebergTableSink(buildSink(table, tableHandle, handle)); + return new ConnectorSinkPlan(dataSink); + } + case DELETE: { + TDataSink dataSink = new TDataSink(TDataSinkType.ICEBERG_DELETE_SINK); + dataSink.setIcebergDeleteSink(buildDeleteSink(table, tableHandle, rewritableDeletes)); + return new ConnectorSinkPlan(dataSink); + } + case UPDATE: + case MERGE: { + TDataSink dataSink = new TDataSink(TDataSinkType.ICEBERG_MERGE_SINK); + dataSink.setIcebergMergeSink(buildMergeSink(table, tableHandle, rewritableDeletes)); + return new ConnectorSinkPlan(dataSink); + } + case REWRITE: { + // Compaction rewrite (ALTER TABLE ... EXECUTE rewrite_data_files): same TIcebergTableSink + // dialect as INSERT but tagged REWRITE so the BE routes to RewriteFiles semantics. The + // rewritableDeletes supply does not apply to rewrite (it deals with no position deletes), and + // is already evicted above. + TDataSink dataSink = new TDataSink(TDataSinkType.ICEBERG_TABLE_SINK); + dataSink.setIcebergTableSink(buildRewriteSink(table, tableHandle, handle)); + return new ConnectorSinkPlan(dataSink); + } + default: + throw new DorisConnectorException( + "Unsupported iceberg write operation: " + writeContext.getWriteOperation()); + } + } + + @Override + public void appendExplainInfo(StringBuilder output, String prefix, + ConnectorSession session, ConnectorWriteHandle handle) { + // Surface the connector-specific write detail the generic plugin-driven sink line cannot (mirrors + // the legacy IcebergTableSink.getExplainString "ICEBERG TABLE SINK / Table: " block). + IcebergTableHandle tableHandle = (IcebergTableHandle) handle.getTableHandle(); + output.append(prefix).append(" ICEBERG TABLE: ") + .append(tableHandle.getDbName()).append(".").append(tableHandle.getTableName()).append("\n"); + // Legacy IcebergTableSink also rendered the table's write sort order (getSortOrderSql) when sorted; + // reuse the SHOW CREATE TABLE renderer so EXPLAIN INSERT surfaces the same "ORDER BY (...)" the BE + // write applies (getWriteSortColumns), keeping EXPLAIN and SHOW CREATE TABLE consistent. + String sortClause = IcebergConnectorMetadata.buildShowSortClause(resolveTable(session, tableHandle)); + if (!sortClause.isEmpty()) { + output.append(prefix).append(" ").append(sortClause).append("\n"); + } + } + + /** + * Declares the table's write-side sort columns (a {@code WRITE ORDERED BY} sort order) so the engine + * can build the {@code TSortInfo} from the bound sink output. Ports legacy + * {@code IcebergTableSink.bindDataSink}'s sort-order loop: only identity sort fields contribute, each + * mapped from its iceberg field id to the column's position in the table schema (1:1 with the sink + * output). An unsorted table yields an empty list (no sort). + */ + @Override + public List getWriteSortColumns(ConnectorSession session, + ConnectorTableHandle tableHandle) { + Table table = resolveTable(session, (IcebergTableHandle) tableHandle); + SortOrder sortOrder = table.sortOrder(); + if (!sortOrder.isSorted()) { + // null == "no write sort order" (legacy gates setSortInfo on isSorted()). A sorted table + // returns a (possibly empty) list so the engine still emits a TSortInfo, matching legacy's + // unconditional setSortInfo inside the isSorted() branch even when no identity column resolves. + return null; + } + List columns = table.schema().columns(); + List result = new ArrayList<>(); + for (SortField sortField : sortOrder.fields()) { + if (!sortField.transform().isIdentity()) { + continue; + } + for (int i = 0; i < columns.size(); i++) { + if (columns.get(i).fieldId() == sortField.sourceId()) { + result.add(new ConnectorWriteSortColumn(i, + sortField.direction() == SortDirection.ASC, + sortField.nullOrder() == NullOrder.NULLS_FIRST)); + break; + } + } + } + return result; + } + + @Override + public ConnectorWritePartitionSpec getWritePartitioning(ConnectorSession session, + ConnectorTableHandle tableHandle) { + Table table = resolveTable(session, (IcebergTableHandle) tableHandle); + PartitionSpec spec = table.spec(); + if (spec == null || !spec.isPartitioned()) { + // null == "unpartitioned" (legacy PhysicalIcebergMergeSink.buildInsertPartitionFields gates on + // spec().isPartitioned()) -> the engine uses its non-partitioned merge distribution. + return null; + } + Schema schema = table.schema(); + List fields = new ArrayList<>(); + for (PartitionField field : spec.fields()) { + // sourceColumnName mirrors the legacy schema.findField(field.sourceId()).name() lookup the engine + // used to map a partition field back to a bound output expr id. transform/param mirror + // field.transform().toString() + parseTransformParam (kept connector-side so fe-core never parses). + NestedField sourceField = schema.findField(field.sourceId()); + String sourceColumnName = sourceField == null ? null : sourceField.name(); + String transform = field.transform().toString(); + fields.add(new ConnectorWritePartitionField( + transform, parseTransformParam(transform), sourceColumnName, field.name(), field.sourceId())); + } + return new ConnectorWritePartitionSpec(spec.specId(), fields); + } + + @Override + public List getSyntheticWriteColumns(ConnectorSession session, + ConnectorTableHandle tableHandle) { + // The iceberg row-id hidden column is the same for every iceberg table regardless of + // format/partitioning — it mirrors legacy IcebergExternalTable.getFullSchema appending + // IcebergRowId.createHiddenColumn() whenever a DML (or show-hidden) is in flight. fe-core gates the + // actual injection request-side (show-hidden / synthetic-write-column ctx flag); here we only + // declare it, so neither the session nor the table handle is consulted. + return SYNTHETIC_WRITE_COLUMNS; + } + + @Override + public Set supportedOperations() { + return EnumSet.of(WriteOperation.INSERT, WriteOperation.OVERWRITE, + WriteOperation.DELETE, WriteOperation.MERGE, WriteOperation.REWRITE); + } + + @Override + public boolean supportsWriteBranch() { + return true; + } + + @Override + public boolean requiresParallelWrite() { + return true; + } + + @Override + public boolean requiresFullSchemaWriteOrder() { + return true; + } + + @Override + public boolean requiresMaterializeStaticPartitionValues() { + return true; + } + + /** Parses the bracket argument of an iceberg transform string ({@code bucket[16] -> 16}); null when absent. */ + private static Integer parseTransformParam(String transform) { + int start = transform.indexOf('['); + int end = transform.indexOf(']'); + if (start < 0 || end <= start) { + return null; + } + try { + return Integer.parseInt(transform.substring(start + 1, end)); + } catch (NumberFormatException e) { + return null; + } + } + + private IcebergWriteContext buildWriteContext(ConnectorWriteHandle handle) { + WriteOperation op = handle.getWriteOperation(); + if (op == WriteOperation.INSERT && handle.isOverwrite()) { + op = WriteOperation.OVERWRITE; + } + // [SHOULD-2] / Fix B: the statement's MVCC read-snapshot pin (S_read) is threaded onto the write + // table handle by the engine (PluginDrivenScanNode-style applyMvccSnapshotPin on the write path). + // Carry it on the op-context so beginWrite anchors the RowDelta baseSnapshotId at S_read, keeping + // the commit-time removeDeletes (option D) and BE's scan-time DV union on one snapshot. -1 (no pin) + // preserves the legacy begin-time current snapshot. + long readSnapshotId = handle.getTableHandle() instanceof IcebergTableHandle + ? ((IcebergTableHandle) handle.getTableHandle()).getSnapshotId() : -1L; + // 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(), + handle.getBranchName(), readSnapshotId); + } + + private TIcebergTableSink buildSink(Table table, IcebergTableHandle tableHandle, + ConnectorWriteHandle handle) { + TIcebergTableSink tSink = new TIcebergTableSink(); + tSink.setDbName(tableHandle.getDbName()); + tSink.setTbName(tableHandle.getTableName()); + + // Schema (no v3 row-lineage append — that is REWRITE/procedures, P6.4). + tSink.setSchemaJson(SchemaParser.toJson(table.schema())); + + // Partition spec (only for a partitioned table, mirroring legacy spec().isPartitioned()). + if (table.spec().isPartitioned()) { + tSink.setPartitionSpecsJson(Maps.transformValues(table.specs(), PartitionSpecParser::toJson)); + tSink.setPartitionSpecId(table.spec().specId()); + } + + // Sort info: the engine builds the TSortInfo from the connector-declared write-sort columns + // (getWriteSortColumns) and threads it back on the handle; the connector stamps it verbatim. + if (handle.getSortInfo() != null) { + tSink.setSortInfo(handle.getSortInfo()); + } + + // File format / compression. + tSink.setFileFormat(toTFileFormatType(IcebergWriterHelper.getFileFormat(table))); + tSink.setCompressionType(toTFileCompressType(getFileCompress(table))); + + // Hadoop config: BE-canonical static catalog creds (AWS_*/dfs) plus the REST per-table vended overlay + // (see buildHadoopConfig), mirroring legacy IcebergTableSink + the scan-side credential assembly. + tSink.setHadoopConfig(buildHadoopConfig(table)); + + // Output location: normalized for the BE writer, raw kept as the original; the BE file type comes + // from the engine (broker-aware). All vended-aware so a REST catalog's path still resolves. + LocationFields location = resolveLocationFields(table); + tSink.setOutputPath(location.outputPath); + tSink.setFileType(location.fileType); + if (!location.brokerAddresses.isEmpty()) { + tSink.setBrokerAddresses(location.brokerAddresses); + } + tSink.setOriginalOutputPath(location.rawLocation); + + // Overwrite + static partition values (INSERT OVERWRITE ... PARTITION). + tSink.setOverwrite(handle.isOverwrite()); + if (handle.isOverwrite() && handle.getWriteContext() != null && !handle.getWriteContext().isEmpty()) { + tSink.setStaticPartitionValues(handle.getWriteContext()); + } + return tSink; + } + + /** + * Builds the {@code TIcebergTableSink} for a compaction REWRITE. Byte-identical to legacy + * {@code planner.IcebergTableSink.bindDataSink} under {@code isRewriting}: the INSERT baseline + * ({@link #buildSink}) plus exactly two deltas — {@code write_type = REWRITE} (the marker the BE uses to + * route to RewriteFiles semantics) and, at format-version≥3, the row-lineage fields appended to the + * schema-json (the BE rewrite writer expects {@code _row_id} / {@code _last_updated_sequence_number}). + * All other fields are inherited unchanged from the INSERT path. + */ + private TIcebergTableSink buildRewriteSink(Table table, IcebergTableHandle tableHandle, + ConnectorWriteHandle handle) { + // A compaction REWRITE atomically replaces a file set; it is never a user INSERT OVERWRITE, and the + // BE writer does not accept the REWRITE write-type together with the overwrite flag. + if (handle.isOverwrite()) { + throw new DorisConnectorException("REWRITE writes cannot be overwrite operations"); + } + TIcebergTableSink tSink = buildSink(table, tableHandle, handle); + tSink.setWriteType(TIcebergWriteType.REWRITE); + if (IcebergWriterHelper.getFormatVersion(table) >= 3) { + // iceberg v3 format requires the row-lineage fields when rewriting data files. + tSink.setSchemaJson(SchemaParser.toJson( + IcebergWriterHelper.appendRowLineageFieldsForV3(table.schema()))); + } + return tSink; + } + + /** + * Builds the {@code TIcebergDeleteSink} (port of legacy {@code planner.IcebergDeleteSink.bindDataSink}). + * Iceberg delete is always a position delete. ⚠️ The delete sink carries {@code compress_type} (thrift + * field 6), NOT the table/merge sink's {@code compression_type}. The format-version≥3 + * {@code rewritable_delete_file_sets} is attached from the scan-time supply (commit-bridge S4 part 2), + * mirroring legacy {@code IcebergDeleteExecutor.finalizeSinkForDelete} + + * {@code IcebergDeleteSink.toThrift}'s {@code formatVersion>=3 && !empty} gate. + */ + private TIcebergDeleteSink buildDeleteSink(Table table, IcebergTableHandle tableHandle, + Map> rewritableDeletes) { + TIcebergDeleteSink tSink = new TIcebergDeleteSink(); + tSink.setDbName(tableHandle.getDbName()); + tSink.setTbName(tableHandle.getTableName()); + tSink.setDeleteType(TFileContent.POSITION_DELETES); + tSink.setFileFormat(toTFileFormatType(IcebergWriterHelper.getFileFormat(table))); + tSink.setCompressType(toTFileCompressType(getFileCompress(table))); + tSink.setHadoopConfig(buildHadoopConfig(table)); + + LocationFields location = resolveLocationFields(table); + tSink.setOutputPath(location.outputPath); + tSink.setTableLocation(location.rawLocation); + tSink.setFileType(location.fileType); + if (!location.brokerAddresses.isEmpty()) { + tSink.setBrokerAddresses(location.brokerAddresses); + } + + if (table.spec().isPartitioned()) { + tSink.setPartitionSpecId(table.spec().specId()); + } + int formatVersion = IcebergWriterHelper.getFormatVersion(table); + tSink.setFormatVersion(formatVersion); + List sets = + buildRewritableDeleteFileSets(formatVersion, rewritableDeletes); + if (!sets.isEmpty()) { + tSink.setRewritableDeleteFileSets(sets); + } + return tSink; + } + + /** + * Builds the {@code TIcebergMergeSink} (port of legacy {@code planner.IcebergMergeSink.bindDataSink}), + * the UPDATE / MERGE dialect. Two parity traps vs the table/delete sinks: it carries + * {@code compression_type} (field 8, NOT {@code compress_type}) and {@code sort_fields} (field 6, a + * {@code List} built directly from the iceberg sort order, NOT the INSERT path's + * {@code sort_info}). At format-version≥3 the schema-json includes the row-lineage fields. The + * {@code rewritable_delete_file_sets} is attached from the scan-time supply (commit-bridge S4 part 2), + * mirroring legacy {@code IcebergMergeExecutor.finalizeSinkForMerge} + {@code IcebergMergeSink.toThrift}'s + * {@code formatVersion>=3 && !empty} gate. + */ + private TIcebergMergeSink buildMergeSink(Table table, IcebergTableHandle tableHandle, + Map> rewritableDeletes) { + TIcebergMergeSink tSink = new TIcebergMergeSink(); + tSink.setDbName(tableHandle.getDbName()); + tSink.setTbName(tableHandle.getTableName()); + + int formatVersion = IcebergWriterHelper.getFormatVersion(table); + tSink.setFormatVersion(formatVersion); + Schema schema = formatVersion >= 3 + ? IcebergWriterHelper.appendRowLineageFieldsForV3(table.schema()) : table.schema(); + tSink.setSchemaJson(SchemaParser.toJson(schema)); + + if (table.spec().isPartitioned()) { + tSink.setPartitionSpecsJson(Maps.transformValues(table.specs(), PartitionSpecParser::toJson)); + tSink.setPartitionSpecId(table.spec().specId()); + } + + // Sort fields: identity sort-order fields whose source id is a base column, carrying the iceberg + // source field id directly (BE merge writer field 6) — distinct from the INSERT path's sort_info(16). + // A sorted table with no resolving identity column still emits an (empty) sort_fields list (legacy + // sets it unconditionally inside the isSorted() branch). + SortOrder sortOrder = table.sortOrder(); + if (sortOrder.isSorted()) { + tSink.setSortFields(buildMergeSortFields(table, sortOrder)); + } + + tSink.setFileFormat(toTFileFormatType(IcebergWriterHelper.getFileFormat(table))); + tSink.setCompressionType(toTFileCompressType(getFileCompress(table))); + tSink.setHadoopConfig(buildHadoopConfig(table)); + + LocationFields location = resolveLocationFields(table); + tSink.setOutputPath(location.outputPath); + tSink.setOriginalOutputPath(location.rawLocation); + tSink.setTableLocation(location.rawLocation); + tSink.setFileType(location.fileType); + if (!location.brokerAddresses.isEmpty()) { + tSink.setBrokerAddresses(location.brokerAddresses); + } + + // Delete side (position delete only). + tSink.setDeleteType(TFileContent.POSITION_DELETES); + if (table.spec().isPartitioned()) { + tSink.setPartitionSpecIdForDelete(table.spec().specId()); + } + List sets = + buildRewritableDeleteFileSets(formatVersion, rewritableDeletes); + if (!sets.isEmpty()) { + tSink.setRewritableDeleteFileSets(sets); + } + return tSink; + } + + /** + * Builds the format-version≥3 {@code rewritable_delete_file_sets} thrift from the scan-time supply: one + * {@code TIcebergRewritableDeleteFileSet} per touched data file (its raw path + its old non-equality delete + * descs), so the BE OR-merges those old deletes into the new deletion vector. Returns empty — so the caller + * leaves the thrift field unset, byte-identical to a no-rewrite write — for {@code formatVersion < 3} (v2 + * deletes are plain position-delete files, not DV-merged) or when there is no supply. Ports legacy + * {@code IcebergRewritableDeletePlanner} (which keys on the same raw {@code originalPath}). + */ + private static List buildRewritableDeleteFileSets( + int formatVersion, Map> rewritableDeletes) { + if (formatVersion < 3 || rewritableDeletes == null || rewritableDeletes.isEmpty()) { + return Collections.emptyList(); + } + List sets = new ArrayList<>(rewritableDeletes.size()); + for (Map.Entry> entry : rewritableDeletes.entrySet()) { + TIcebergRewritableDeleteFileSet set = new TIcebergRewritableDeleteFileSet(); + set.setReferencedDataFilePath(entry.getKey()); + set.setDeleteFiles(entry.getValue()); + sets.add(set); + } + return sets; + } + + private static List buildMergeSortFields(Table table, SortOrder sortOrder) { + Set baseColumnFieldIds = new HashSet<>(); + for (NestedField column : table.schema().columns()) { + baseColumnFieldIds.add(column.fieldId()); + } + List sortFields = new ArrayList<>(); + for (SortField sortField : sortOrder.fields()) { + if (!sortField.transform().isIdentity()) { + continue; + } + if (!baseColumnFieldIds.contains(sortField.sourceId())) { + continue; + } + TSortField tSortField = new TSortField(); + tSortField.setSourceColumnId(sortField.sourceId()); + tSortField.setAscending(sortField.direction() == SortDirection.ASC); + tSortField.setNullFirst(sortField.nullOrder() == NullOrder.NULLS_FIRST); + sortFields.add(tSortField); + } + return sortFields; + } + + /** + * Resolves the shared sink location fields (port of legacy {@code LocationPath.of(dataLocation(table))}): + * the raw data location, the normalized BE write path, and the BE file type — all vended-aware so a REST + * catalog's object-store path still resolves. Used by all three sink dialects. + */ + private LocationFields resolveLocationFields(Table table) { + String rawLocation = dataLocation(table); + Map vendedToken = IcebergScanPlanProvider.extractVendedToken( + table, IcebergScanPlanProvider.restVendedCredentialsEnabled(properties)); + if (context != null) { + TFileType fileType = TFileType.valueOf(context.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 + // never touch the broker registry). + List brokerAddresses = fileType == TFileType.FILE_BROKER + ? resolveBrokerAddresses() : Collections.emptyList(); + return new LocationFields(rawLocation, + context.normalizeStorageUri(rawLocation, vendedToken), fileType, brokerAddresses); + } + return new LocationFields(rawLocation, rawLocation, TFileType.FILE_S3, Collections.emptyList()); + } + + /** + * Resolves the broker backend addresses for a FILE_BROKER write through the neutral SPI (the engine owns + * the broker registry + the catalog's bound broker name; the connector maps the neutral host/port pairs + * to Thrift). Fails loud {@code "No alive broker."} when none is resolved — the same message legacy + * {@code BaseExternalTableDataSink.getBrokerAddresses} threw — so a broker-backed write never silently + * ships an empty broker list to BE. + */ + private List resolveBrokerAddresses() { + List addresses = context.getBrokerAddresses(); + if (addresses.isEmpty()) { + throw new DorisConnectorException("No alive broker."); + } + List result = new ArrayList<>(addresses.size()); + for (ConnectorBrokerAddress address : addresses) { + result.add(new TNetworkAddress(address.getHost(), address.getPort())); + } + return result; + } + + /** Immutable holder for the location fields shared by the sink dialects (broker addresses are populated + * only for a FILE_BROKER target — empty otherwise). */ + private static final class LocationFields { + private final String rawLocation; + private final String outputPath; + private final TFileType fileType; + private final List brokerAddresses; + + LocationFields(String rawLocation, String outputPath, TFileType fileType, + List brokerAddresses) { + this.rawLocation = rawLocation; + this.outputPath = outputPath; + this.fileType = fileType; + this.brokerAddresses = brokerAddresses; + } + } + + private Map buildHadoopConfig(Table table) { + Map merged = new HashMap<>(); + 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 + // (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()) { + 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( + IcebergScanPlanProvider.extractVendedToken( + table, IcebergScanPlanProvider.restVendedCredentialsEnabled(properties)))); + } + return merged; + } + + private IcebergConnectorTransaction currentTransaction(ConnectorSession session) { + Optional transaction = session.getCurrentTransaction(); + if (!transaction.isPresent()) { + throw new DorisConnectorException( + "Iceberg write requires an active connector transaction bound to the session; none is " + + "present. The executor must open it via beginTransaction and bind it to the " + + "session (wired at the iceberg cutover)."); + } + return (IcebergConnectorTransaction) transaction.get(); + } + + private Table resolveTable(ConnectorSession session, IcebergTableHandle handle) { + // Per-statement scope (PERF-07): the write-shaping derivations (sort columns / partitioning / explain) + // share the SAME one loaded RAW table as the statement's read + scan resolvers. For a row-level DML the + // scan has already populated the scope, so this is a hit (no write-side load); a write-only INSERT loads + // once here. Resolve the per-request ops before the auth scope so a session=user fail-closed surfaces + // verbatim (it re-validates the credential even on a scope hit). + IcebergCatalogOps ops = catalogOpsResolver.apply(session); + return IcebergStatementScope.sharedTable(session, handle.getDbName(), handle.getTableName(), () -> { + if (context == null) { + return ops.loadTable(handle.getDbName(), handle.getTableName()); + } + try { + return context.executeAuthenticated( + () -> ops.loadTable(handle.getDbName(), handle.getTableName())); + } catch (Exception e) { + throw new DorisConnectorException("Failed to load iceberg table " + + handle.getDbName() + "." + handle.getTableName() + ": " + e.getMessage(), e); + } + }); + } + + private static TFileFormatType toTFileFormatType(FileFormat format) { + switch (format) { + case ORC: + return TFileFormatType.FORMAT_ORC; + case PARQUET: + return TFileFormatType.FORMAT_PARQUET; + default: + throw new DorisConnectorException("Unsupported iceberg write file format: " + format); + } + } + + /** Port of legacy {@code BaseExternalTableDataSink.getTFileCompressType} (iceberg codecs). */ + private static TFileCompressType toTFileCompressType(String compressType) { + if ("snappy".equalsIgnoreCase(compressType)) { + return TFileCompressType.SNAPPYBLOCK; + } else if ("lz4".equalsIgnoreCase(compressType)) { + return TFileCompressType.LZ4BLOCK; + } else if ("lzo".equalsIgnoreCase(compressType)) { + return TFileCompressType.LZO; + } else if ("zlib".equalsIgnoreCase(compressType)) { + return TFileCompressType.ZLIB; + } else if ("zstd".equalsIgnoreCase(compressType)) { + return TFileCompressType.ZSTD; + } else if ("gzip".equalsIgnoreCase(compressType)) { + return TFileCompressType.GZ; + } else if ("bzip2".equalsIgnoreCase(compressType)) { + return TFileCompressType.BZ2; + } else if ("uncompressed".equalsIgnoreCase(compressType)) { + return TFileCompressType.PLAIN; + } else { + return TFileCompressType.PLAIN; + } + } + + /** Port of legacy {@code IcebergUtils.getFileCompress}. */ + private static String getFileCompress(Table table) { + Map tableProps = table.properties(); + if (tableProps.containsKey(COMPRESSION_CODEC)) { + return tableProps.get(COMPRESSION_CODEC); + } else if (tableProps.containsKey(SPARK_SQL_COMPRESSION_CODEC)) { + return tableProps.get(SPARK_SQL_COMPRESSION_CODEC); + } + FileFormat fileFormat = IcebergWriterHelper.getFileFormat(table); + if (fileFormat == FileFormat.PARQUET) { + return tableProps.getOrDefault( + TableProperties.PARQUET_COMPRESSION, TableProperties.PARQUET_COMPRESSION_DEFAULT_SINCE_1_4_0); + } else if (fileFormat == FileFormat.ORC) { + return tableProps.getOrDefault( + TableProperties.ORC_COMPRESSION, TableProperties.ORC_COMPRESSION_DEFAULT); + } + throw new DorisConnectorException("Unsupported iceberg write file format: " + fileFormat); + } + + /** Port of legacy {@code IcebergUtils.dataLocation}. */ + private static String dataLocation(Table table) { + Map tableProps = table.properties(); + if (tableProps.containsKey(TableProperties.WRITE_LOCATION_PROVIDER_IMPL)) { + throw new DorisConnectorException("Table " + table.name() + " specifies " + + tableProps.get(TableProperties.WRITE_LOCATION_PROVIDER_IMPL) + " as a location provider. " + + "Writing to Iceberg tables with custom location provider is not supported."); + } + String dataLocation = tableProps.get(TableProperties.WRITE_DATA_LOCATION); + if (dataLocation == null) { + dataLocation = Boolean.parseBoolean(tableProps.get(TableProperties.OBJECT_STORE_ENABLED)) + ? tableProps.get(TableProperties.OBJECT_STORE_PATH) : null; + if (dataLocation == null) { + dataLocation = tableProps.get(TableProperties.WRITE_FOLDER_STORAGE_LOCATION); + if (dataLocation == null) { + dataLocation = String.format("%s/data", LocationUtil.stripTrailingSlash(table.location())); + } + } + } + return dataLocation; + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriterHelper.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriterHelper.java new file mode 100644 index 00000000000000..a069a29921b610 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriterHelper.java @@ -0,0 +1,403 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.thrift.TFileContent; +import org.apache.doris.thrift.TIcebergColumnStats; +import org.apache.doris.thrift.TIcebergCommitData; + +import com.google.common.base.VerifyException; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileMetadata; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Metrics; +import org.apache.iceberg.PartitionData; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.WriteResult; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.types.Types; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.ByteBuffer; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Self-contained port of the legacy fe-core {@code IcebergWriterHelper} (P6.3-T04). The connector cannot + * import fe-core, so the conversion from BE commit fragments ({@link TIcebergCommitData}) to iceberg + * {@link DataFile}/{@link DeleteFile}/{@link Metrics}/{@link PartitionData} is reproduced byte-faithfully + * against the iceberg SDK. + * + *

    Deliberate, documented deltas vs legacy: {@code CommonStatistics} is inlined (the row count + file size + * are passed straight to {@link #genDataFile}, DV-T04-e), the partition-value time zone is a resolved + * {@link ZoneId} argument threaded from {@code IcebergConnectorTransaction.beginWrite} (legacy reads a + * thread-local, DV-T04-f), and the partition-data JSON is parsed via iceberg's bundled Jackson + * ({@link IcebergPartitionUtils#parsePartitionValuesFromJson}, DV-T04-d).

    + */ +final class IcebergWriterHelper { + + private static final Logger LOG = LogManager.getLogger(IcebergWriterHelper.class); + + private static final String WRITE_FORMAT = "write-format"; + private static final String PARQUET_NAME = "parquet"; + private static final String ORC_NAME = "orc"; + + private IcebergWriterHelper() { + } + + /** + * Converts the BE data-file commit fragments into an iceberg {@link WriteResult} of {@link DataFile}s + * (the INSERT / OVERWRITE / MERGE-data path). A partitioned table requires non-empty partition values per + * file; {@code "null"} partition tokens map to a real {@code null}. + */ + static WriteResult convertToWriterResult(Table table, List commitDataList, ZoneId zone) { + List dataFiles = new ArrayList<>(); + + PartitionSpec spec = table.spec(); + FileFormat fileFormat = getFileFormat(table); + + for (TIcebergCommitData commitData : commitDataList) { + String location = commitData.getFilePath(); + long fileSize = commitData.getFileSize(); + long recordCount = commitData.getRowCount(); + Metrics metrics = buildDataFileMetrics(fileFormat, commitData); + Optional partitionData = Optional.empty(); + if (spec.isPartitioned()) { + List partitionValues = commitData.getPartitionValues(); + if (Objects.isNull(partitionValues) || partitionValues.isEmpty()) { + throw new VerifyException("No partition data for partitioned table"); + } + partitionValues = partitionValues.stream().map(s -> s.equals("null") ? null : s) + .collect(Collectors.toList()); + partitionData = Optional.of(convertToPartitionData(partitionValues, spec, zone)); + } + DataFile dataFile = genDataFile(fileFormat, location, spec, partitionData, recordCount, fileSize, + metrics, table.sortOrder()); + dataFiles.add(dataFile); + } + return WriteResult.builder() + .addDataFiles(dataFiles) + .build(); + } + + private static DataFile genDataFile(FileFormat format, String location, PartitionSpec spec, + Optional partitionData, long recordCount, long fileSize, Metrics metrics, + SortOrder sortOrder) { + DataFiles.Builder builder = DataFiles.builder(spec) + .withPath(location) + .withFileSizeInBytes(fileSize) + .withRecordCount(recordCount) + .withMetrics(metrics) + .withSortOrder(sortOrder) + .withFormat(format); + partitionData.ifPresent(builder::withPartition); + return builder.build(); + } + + /** + * Convert human-readable partition values (from BE) to {@link PartitionData}: DATE strings like + * {@code "2025-01-25"} and DATETIME strings like {@code "2025-01-25 10:00:00"} become the iceberg internal + * partition objects. + */ + private static PartitionData convertToPartitionData(List humanReadableValues, PartitionSpec spec, + ZoneId zone) { + PartitionData partitionData = new PartitionData(spec.partitionType()); + Types.StructType partitionType = spec.partitionType(); + List partitionTypeFields = partitionType.fields(); + + for (int i = 0; i < humanReadableValues.size(); i++) { + String humanReadableValue = humanReadableValues.get(i); + if (humanReadableValue == null) { + partitionData.set(i, null); + continue; + } + Type partitionFieldType = partitionTypeFields.get(i).type(); + Object internalValue = IcebergPartitionUtils.parsePartitionValueFromString( + humanReadableValue, partitionFieldType, zone); + partitionData.set(i, internalValue); + } + return partitionData; + } + + private static Metrics buildDataFileMetrics(FileFormat fileFormat, TIcebergCommitData commitData) { + Map columnSizes = new HashMap<>(); + Map valueCounts = new HashMap<>(); + Map nullValueCounts = new HashMap<>(); + Map lowerBounds = new HashMap<>(); + Map upperBounds = new HashMap<>(); + if (commitData.isSetColumnStats()) { + TIcebergColumnStats stats = commitData.column_stats; + if (stats.isSetColumnSizes()) { + columnSizes = stats.column_sizes; + } + if (stats.isSetValueCounts()) { + valueCounts = stats.value_counts; + } + if (stats.isSetNullValueCounts()) { + nullValueCounts = stats.null_value_counts; + } + if (stats.isSetLowerBounds()) { + lowerBounds = stats.lower_bounds; + } + if (stats.isSetUpperBounds()) { + upperBounds = stats.upper_bounds; + } + } + return new Metrics(commitData.getRowCount(), columnSizes, valueCounts, + nullValueCounts, null, lowerBounds, upperBounds); + } + + /** + * Convert the BE delete-file commit fragments to iceberg {@link DeleteFile}s for the DELETE / MERGE path. + * Position deletes and deletion vectors (rendered as a {@link FileFormat#PUFFIN} position-delete with a + * content offset/size) are supported; equality deletes are rejected (Doris MOR writes position deletes). + */ + static List convertToDeleteFiles(FileFormat format, PartitionSpec spec, + List commitDataList, ZoneId zone) { + List deleteFiles = new ArrayList<>(); + + for (TIcebergCommitData commitData : commitDataList) { + if (commitData.getFileContent() == null + || commitData.getFileContent() == TFileContent.DATA) { + continue; + } + + String deleteFilePath = commitData.getFilePath(); + long fileSize = commitData.getFileSize(); + long recordCount = commitData.getRowCount(); + boolean isDeletionVector = commitData.isSetContentOffset() + && commitData.isSetContentSizeInBytes(); + FileFormat effectiveFormat = isDeletionVector ? FileFormat.PUFFIN : format; + + FileMetadata.Builder deleteBuilder = FileMetadata.deleteFileBuilder(spec) + .withPath(deleteFilePath) + .withFormat(effectiveFormat) + .withFileSizeInBytes(fileSize) + .withRecordCount(recordCount); + + if (commitData.getFileContent() == TFileContent.POSITION_DELETES) { + deleteBuilder.ofPositionDeletes(); + } else if (commitData.getFileContent() == TFileContent.DELETION_VECTOR) { + deleteBuilder.ofPositionDeletes(); + } else { + throw new VerifyException("Iceberg delete only supports position deletes, but got " + + commitData.getFileContent()); + } + + if (isDeletionVector) { + deleteBuilder.withContentOffset(commitData.getContentOffset()); + deleteBuilder.withContentSizeInBytes(commitData.getContentSizeInBytes()); + } + + if (commitData.isSetReferencedDataFilePath() + && commitData.getReferencedDataFilePath() != null + && !commitData.getReferencedDataFilePath().isEmpty()) { + deleteBuilder.withReferencedDataFile(commitData.getReferencedDataFilePath()); + } + + if (spec.isPartitioned()) { + PartitionData partitionData; + if (commitData.getPartitionValues() != null && !commitData.getPartitionValues().isEmpty()) { + List partitionValues = commitData.getPartitionValues().stream() + .map(s -> s.equals("null") ? null : s) + .collect(Collectors.toList()); + partitionData = convertToPartitionData(partitionValues, spec, zone); + } else if (commitData.getPartitionDataJson() != null + && !commitData.getPartitionDataJson().isEmpty()) { + List partitionValues = IcebergPartitionUtils.parsePartitionValuesFromJson( + commitData.getPartitionDataJson()); + if (!partitionValues.isEmpty()) { + partitionData = convertToPartitionData(partitionValues, spec, zone); + } else { + partitionData = new PartitionData(spec.partitionType()); + } + } else { + throw new VerifyException("No partition data for partitioned table"); + } + deleteBuilder.withPartition(partitionData); + } + + deleteFiles.add(deleteBuilder.build()); + } + + return deleteFiles; + } + + /** + * Resolve the table's write file format (port of legacy {@code IcebergUtils.getFileFormat}): the + * {@code write-format} nickname, then the standard {@code write.format.default} property, then an inference + * from the current snapshot's data files (defaulting to parquet). Throws on a non-orc/parquet format. + */ + static FileFormat getFileFormat(Table table) { + return toFileFormat(resolveFileFormatName(table, table.properties())); + } + + /** + * PERF-03 cache-aware overload used by the scan path ({@code IcebergScanPlanProvider.getScanNodeProperties}). + * Identical resolution to {@link #getFileFormat(Table)}, except the whole-table {@code planFiles()} inference + * fallback (fired only when the table sets neither {@code write-format} nor {@code write.format.default}) is + * memoized per {@code (id, currentSnapshotId)} through {@code cache}. The two cheap property probes stay + * uncached. A {@code null} cache/id (offline tests) or an empty table (no current snapshot) resolves live. + */ + static FileFormat getFileFormat(Table table, TableIdentifier id, IcebergFormatCache cache) { + return toFileFormat(resolveFileFormatName(table, table.properties(), id, cache)); + } + + private static FileFormat toFileFormat(String fileFormatName) { + if (fileFormatName.toLowerCase().contains(ORC_NAME)) { + return FileFormat.ORC; + } else if (fileFormatName.toLowerCase().contains(PARQUET_NAME)) { + return FileFormat.PARQUET; + } else { + throw new RuntimeException("Unsupported input format type: " + fileFormatName); + } + } + + private static String resolveFileFormatName(Table table, Map properties) { + if (properties.containsKey(WRITE_FORMAT)) { + return properties.get(WRITE_FORMAT); + } + if (properties.containsKey(TableProperties.DEFAULT_FILE_FORMAT)) { + return properties.get(TableProperties.DEFAULT_FILE_FORMAT); + } + return inferFileFormatFromDataFiles(table); + } + + /** + * PERF-03: like {@link #resolveFileFormatName(Table, Map)} but routes the inference fallback (the heavy + * unfiltered {@code planFiles()}) through the cross-query {@code (id, snapshotId)} cache. Only the inference is + * cached; the property probes are cheap. A failed inference (the loader throws) is NOT cached, so the next + * query retries — for this query it degrades to the parquet default, mirroring the swallow of the live path. + */ + private static String resolveFileFormatName(Table table, Map properties, + TableIdentifier id, IcebergFormatCache cache) { + if (properties.containsKey(WRITE_FORMAT)) { + return properties.get(WRITE_FORMAT); + } + if (properties.containsKey(TableProperties.DEFAULT_FILE_FORMAT)) { + return properties.get(TableProperties.DEFAULT_FILE_FORMAT); + } + Snapshot snapshot = table.currentSnapshot(); + if (cache == null || id == null || snapshot == null) { + return inferFileFormatFromDataFiles(table); + } + try { + return cache.getOrLoad(new IcebergFormatCache.Key(id, snapshot.snapshotId()), + () -> inferFirstDataFileFormat(table)); + } catch (RuntimeException e) { + LOG.warn("Failed to infer file format from data files for table {}, defaulting to {}", + table.name(), PARQUET_NAME, e); + return PARQUET_NAME; + } + } + + /** + * The whole-table format inference (the #64134 heavy op): reads the FIRST data file's format from the current + * snapshot via an unfiltered {@code planFiles()}. The swallowing wrapper used by the write path and the direct + * live path (a failure degrades to parquet). The cache loader instead calls {@link #inferFirstDataFileFormat} + * so a transient failure is not memoized. + */ + private static String inferFileFormatFromDataFiles(Table table) { + if (table.currentSnapshot() == null) { + return PARQUET_NAME; + } + try { + return inferFirstDataFileFormat(table); + } catch (RuntimeException e) { + LOG.warn("Failed to infer file format from data files for table {}, defaulting to {}", + table.name(), PARQUET_NAME, e); + return PARQUET_NAME; + } + } + + /** + * Raw first-data-file format inference that PROPAGATES failures (unchecked) so the cache loader does not + * memoize a transient remote-IO error. The caller guarantees a non-null current snapshot; an empty snapshot + * (no data files) yields the deterministic parquet default (cacheable). The try-with-resources + * {@code CloseableIterable.close()} checked {@link IOException} is wrapped as {@link UncheckedIOException} so + * the method stays unchecked (a {@code Supplier} loader cannot declare checked throws). + */ + private static String inferFirstDataFileFormat(Table table) { + try (CloseableIterable files = table.newScan().planFiles()) { + Iterator it = files.iterator(); + if (it.hasNext()) { + return it.next().file().format().name().toLowerCase(); + } + return PARQUET_NAME; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** + * Reads the real table format version (port of legacy {@code IcebergUtils.getFormatVersion}): from a + * {@link BaseTable}'s current metadata when available, else from the {@code format-version} table + * property, defaulting to 2. Kept here (the shared write-side helper) so the sink dialects share one + * implementation; the per-class private copies in {@code IcebergConnectorMetadata}/{@code + * IcebergConnectorTransaction} are left untouched (DV-T05-e). + */ + static int getFormatVersion(Table table) { + int formatVersion = 2; + if (table instanceof BaseTable) { + formatVersion = ((BaseTable) table).operations().current().formatVersion(); + } else if (table != null && table.properties() != null) { + String version = table.properties().get(TableProperties.FORMAT_VERSION); + if (version != null) { + try { + formatVersion = Integer.parseInt(version); + } catch (NumberFormatException ignored) { + // keep the default + } + } + } + return formatVersion; + } + + /** + * Appends the format-version 3 row-lineage fields ({@code _row_id}, {@code _last_updated_sequence_number}) + * to the schema (port of legacy {@code IcebergUtils.appendRowLineageFieldsForV3}); pure iceberg SDK. The + * merge sink's BE writer expects the row-lineage columns in the schema-json for a v3 table. + */ + static Schema appendRowLineageFieldsForV3(Schema schema) { + return TypeUtil.join(schema, new Schema( + MetadataColumns.ROW_ID, MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER)); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/cache/ManifestCacheValue.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/ManifestCacheValue.java similarity index 80% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/cache/ManifestCacheValue.java rename to fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/ManifestCacheValue.java index e98ca6b2fb2808..b9b9da68dbd3c5 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/cache/ManifestCacheValue.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/ManifestCacheValue.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource.iceberg.cache; +package org.apache.doris.connector.iceberg; import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; @@ -24,7 +24,11 @@ import java.util.List; /** - * Cached manifest payload containing parsed files. + * Cached manifest payload containing the parsed files of one iceberg manifest. + * + *

    Ported verbatim from the legacy fe-core {@code org.apache.doris.datasource.iceberg.cache.ManifestCacheValue} + * (references only iceberg-SDK types, so no fe-core dependency). A DATA manifest yields data files; a DELETES + * manifest yields delete files (T08, mirrors {@link IcebergManifestCache}). */ public class ManifestCacheValue { private final List dataFiles; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/ReauthenticatingRestSessionCatalog.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/ReauthenticatingRestSessionCatalog.java similarity index 95% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/ReauthenticatingRestSessionCatalog.java rename to fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/ReauthenticatingRestSessionCatalog.java index c39b0a73381124..d5f74edfd1bc06 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/ReauthenticatingRestSessionCatalog.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/ReauthenticatingRestSessionCatalog.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource.iceberg; +package org.apache.doris.connector.iceberg; import com.google.common.annotations.VisibleForTesting; import org.apache.commons.lang3.exception.ExceptionUtils; @@ -55,11 +55,11 @@ * error propagates unchanged. * *

    Why a wrapper at the session-catalog level. Everything Doris hands out for a REST catalog — the - * default {@code asCatalog(empty)} used by {@code IcebergMetadataOps}, the {@code asViewCatalog(empty)} view - * path, and per-user delegated sessions — is a thin view that calls back into this class (see - * {@link org.apache.iceberg.catalog.BaseSessionCatalog.AsCatalog}). Wrapping here means no holder of any of - * those references ever sees a stale client after recovery, and non-REST catalogs are untouched because only - * {@code IcebergRestProperties} constructs this class. + * default {@code asCatalog(empty)} used by the connector's catalog ops, the {@code asViewCatalog(empty)} view + * path, and per-user delegated sessions (via {@code IcebergSessionCatalogAdapter}) — is a thin view that calls + * back into this class (see {@link org.apache.iceberg.catalog.BaseSessionCatalog.AsCatalog}). Wrapping here + * means no holder of any of those references ever sees a stale client after recovery, and non-REST catalogs + * are untouched because only {@code IcebergConnector} constructs this class (for a REST flavor). * *

    What is retried. Both reads and mutations: a 401 is rejected by the server before the request is * processed, so retrying after re-authentication cannot double-apply an operation. Requests that carry a 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 new file mode 100644 index 00000000000000..380f77ec65b915 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/TcclPinningConnectorContext.java @@ -0,0 +1,211 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.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.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. + * + *

    WHY: iceberg-aws builds its S3 client lazily on the FIRST remote output of a {@code commit()} + * ({@code S3FileIO.newOutputFile} → {@code AwsClientFactories$DefaultAwsClientFactory.s3()} → + * {@code HttpClientProperties.applyHttpClientConfigurations}), which resolves + * {@code org.apache.iceberg.aws.ApacheHttpClientConfigurations} via {@code DynMethods}, whose default loader + * IS the TCCL. The engine thread that drives a DDL / DML / procedure commit runs under the default 'app' + * TCCL, so that reflective load returns the parent (fe-core) copy of the class and + * {@link ClassCastException}s against the child-loaded plugin copy the rest of the iceberg-aws stack uses. + * Pinning the TCCL to the plugin loader keeps every reflective load on the plugin side. + * + *

    This is the write/DDL/procedure-path analogue of the SAME split-brain guard already applied on the scan + * path ({@code PluginDrivenScanNode.onPluginClassLoader}) and the catalog-build path + * ({@code IcebergConnector.buildCatalogAuthenticated}). All three iceberg write seams route their remote + * {@code commit()} through {@link ConnectorContext#executeAuthenticated} — branch/tag DDL + * ({@code IcebergConnectorMetadata}), INSERT/UPDATE/DELETE/MERGE commits + * ({@code IcebergConnectorTransaction}), and the snapshot procedures/actions + * ({@code IcebergProcedureOps.runInAuthScope}) — so wrapping the single injected context once covers them all. + * + *

    The pin is harmless for pure reads (it just runs the read under the plugin loader, exactly as the + * catalog-build path already does) and idempotent when nested inside {@code buildCatalogAuthenticated}'s own + * pin, which targets the same loader. + * + *

    KERBEROS (single-owner auth): for a Kerberos catalog {@code pluginAuthenticator} supplies a plugin-side + * {@link HadoopAuthenticator} and the op runs inside its {@code doAs}. This is REQUIRED because the plugin + * bundles its own {@code hadoop-common} + {@code fe-kerberos} child-first, so the plugin's HDFS + * {@code FileSystem} reads a DIFFERENT {@code UserGroupInformation} copy than the one the FE-injected + * authenticator (built app-side by {@code IcebergFileSystemMetaStoreProperties}) logs in — the app-side + * {@code doAs} therefore never reaches the plugin FileSystem, which falls back to SIMPLE auth. The connector + * is the only party that knows which UGI copy its FileSystem uses, so it owns the auth: on the Kerberos path + * we run the plugin {@code doAs} and DELIBERATELY do NOT also call {@code delegate.executeAuthenticated} + * (which only authenticates the unused app-loader UGI — dead weight plus a redundant keytab login). The + * plugin {@code doAs} is an exact mirror of {@code HadoopExecutionAuthenticator.execute} + * ({@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 { + + 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"); + this.pluginClassLoader = Objects.requireNonNull(pluginClassLoader, "pluginClassLoader"); + this.pluginAuthenticator = Objects.requireNonNull(pluginAuthenticator, "pluginAuthenticator"); + } + + /** + * The plugin-side Kerberos authenticator this context runs ops under, or {@code null} for a non-Kerberos + * catalog. Exposed so the write path ({@code IcebergConnectorTransaction}) can wrap the iceberg table's + * {@code FileIO} in the SAME single-owner {@code doAs}: manifest writes that iceberg fans onto its shared + * worker pool run OUTSIDE this context's caller-thread {@link #executeAuthenticated} scope, so they need the + * authenticator carried into the FileIO to reach secured HDFS. + */ + HadoopAuthenticator getPluginAuthenticator() { + return pluginAuthenticator.get(); + } + + @Override + public T executeAuthenticated(Callable task) throws Exception { + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(pluginClassLoader); + HadoopAuthenticator auth = pluginAuthenticator.get(); + if (auth == null) { + // Non-Kerberos: keep the FE-injected auth path exactly as-is. + 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. + return auth.doAs(task::call); + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + } + + // ----- 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); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/BaseIcebergAction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/BaseIcebergAction.java new file mode 100644 index 00000000000000..6dac9970527093 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/BaseIcebergAction.java @@ -0,0 +1,186 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.action; + +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.pushdown.ConnectorPredicate; +import org.apache.doris.foundation.util.NamedArguments; + +import com.google.common.base.Preconditions; +import com.google.common.collect.Maps; +import org.apache.iceberg.Table; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Abstract base for iceberg {@code ALTER TABLE EXECUTE} procedure bodies, run behind + * {@link org.apache.doris.connector.iceberg.IcebergProcedureOps}. + * + *

    Standalone connector port of legacy {@code datasource/iceberg/action/BaseIcebergAction} folded + * together with the still-consumed half of {@code BaseExecuteAction}. It cannot extend the legacy base: + * the import gate forbids {@code BaseExecuteAction}, {@code PartitionNamesInfo} and the nereids + * {@code Expression}. The legacy types are therefore replaced by the SPI's engine-neutral carriers — + * {@code List} partition names, {@link ConnectorPredicate} where, and {@link ConnectorColumn} + * result columns. The argument framework ({@link NamedArguments} / {@code ArgumentParsers}) is the shared + * fe-foundation utility, so the engine and the connector validate against the same code. + * + *

    Engine/connector split (D-062 §2). The {@code ALTER} privilege check, the {@code ResultSet} + * wrapping and the edit-log refresh stay in the engine; this base owns argument validation (§4 = 4-A) and + * the single-row result contract. {@link #validate()} therefore performs no privilege check — only the + * registered-argument validation plus the procedure-specific {@link #validateIcebergAction()} hook. + * + *

    Single-row contract. {@link #execute(Table)} mirrors {@code BaseExecuteAction.execute}: the + * body returns one row whose width must equal the declared {@link #getResultSchema()} width + * ({@code Preconditions.checkState}, legacy {@code BaseExecuteAction:106-108}). + */ +public abstract class BaseIcebergAction { + + protected final String actionType; + protected final Map properties; + protected final List partitionNames; + protected final ConnectorPredicate whereCondition; + + // Named arguments for parameter validation. NamedArguments lives in fe-foundation (shared with the + // engine); the connector still owns the per-procedure argument specs and runs the validation (§4 = 4-A). + protected final NamedArguments namedArguments = new NamedArguments(); + + // Result columns, captured once at construction (mirrors BaseExecuteAction's resultSetMetaData). + private final List resultSchema; + + protected BaseIcebergAction(String actionType, Map properties, + List partitionNames, ConnectorPredicate whereCondition) { + this.actionType = actionType; + this.properties = properties != null ? properties : Maps.newHashMap(); + this.partitionNames = partitionNames; + this.whereCondition = whereCondition; + + // Register arguments specific to this action. + registerIcebergArguments(); + + // Capture the result schema once (subclasses provide constant columns). + this.resultSchema = getResultSchema(); + } + + /** + * Validates the procedure's arguments and runs its action-specific checks. The engine performs the + * {@code ALTER} privilege check before dispatch, so it is intentionally not repeated here. + */ + public final void validate() { + // NamedArguments (fe-foundation) signals failures with an unchecked IllegalArgumentException; + // re-wrap it as DorisConnectorException, keeping the message verbatim (T08 byte-parity). + try { + namedArguments.validate(properties); + } catch (IllegalArgumentException e) { + throw new DorisConnectorException(e.getMessage()); + } + validateIcebergAction(); + } + + /** + * Runs the procedure body and wraps its single row into a {@link ConnectorProcedureResult}. Enforces the + * legacy single-row contract: the row width must equal the declared schema width. + * + *

    {@code session} carries the connector execution context (most importantly the session time zone for + * {@code rollback_to_timestamp}); the seven non-time-zone procedures ignore it. The legacy + * {@code BaseExecuteAction} read the time zone from the thread-local {@code ConnectContext}; the connector + * cannot, so it threads {@link ConnectorSession} here instead — the SPI ({@code ConnectorProcedureOps}) + * and factory signatures are unchanged. + */ + public final ConnectorProcedureResult execute(Table table, ConnectorSession session) { + List resultRow = executeAction(table, session); + if (resultSchema == null || resultSchema.isEmpty() || resultRow == null) { + return new ConnectorProcedureResult( + resultSchema == null ? Collections.emptyList() : resultSchema, + Collections.emptyList()); + } + Preconditions.checkState(resultSchema.size() == resultRow.size(), + "Result row size does not match metadata column count"); + // The result is exactly one row, so we wrap it in a single-element list. + return new ConnectorProcedureResult(resultSchema, Collections.singletonList(resultRow)); + } + + /** + * Registers the arguments accepted by this procedure (into {@link #namedArguments}). Called once from + * the constructor. + */ + protected abstract void registerIcebergArguments(); + + /** + * Procedure-specific validation beyond argument parsing (e.g. partition/{@code WHERE} guards). Default + * is a no-op. + */ + protected void validateIcebergAction() { + // Default implementation does nothing. + } + + /** + * The result-column schema, or an empty list when the procedure returns no rows. Subclasses override to + * declare their columns. Captured once at construction. + */ + protected List getResultSchema() { + return Collections.emptyList(); + } + + /** + * Runs the procedure against the loaded iceberg SDK table and returns its single result row (or + * {@code null} when there is no result). {@code session} provides the execution context (e.g. the session + * time zone consumed by {@code rollback_to_timestamp}); most procedures do not use it. + */ + protected abstract List executeAction(Table table, ConnectorSession session); + + public String getActionType() { + return actionType; + } + + /** Rejects a partition specification when the procedure does not support one. */ + protected void validateNoPartitions() { + if (partitionNames != null && !partitionNames.isEmpty()) { + throw new DorisConnectorException( + String.format("Action '%s' does not support partition specification", actionType)); + } + } + + /** Rejects a {@code WHERE} condition when the procedure does not support one. */ + protected void validateNoWhereCondition() { + if (whereCondition != null) { + throw new DorisConnectorException( + String.format("Action '%s' does not support WHERE condition", actionType)); + } + } + + /** Requires a {@code WHERE} condition to be present. */ + protected void validateRequiredWhereCondition() { + if (whereCondition == null) { + throw new DorisConnectorException( + String.format("Action '%s' requires WHERE condition", actionType)); + } + } + + /** Requires a partition specification to be present. */ + protected void validateRequiredPartitions() { + if (partitionNames == null || partitionNames.isEmpty()) { + throw new DorisConnectorException( + String.format("Action '%s' requires partition specification", actionType)); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergCherrypickSnapshotAction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergCherrypickSnapshotAction.java new file mode 100644 index 00000000000000..98bc7e0f2767f8 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergCherrypickSnapshotAction.java @@ -0,0 +1,99 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.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.pushdown.ConnectorPredicate; +import org.apache.doris.foundation.util.ArgumentParsers; + +import com.google.common.collect.Lists; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; + +import java.util.List; +import java.util.Map; + +/** + * Cherry-picks the changes of a snapshot into the current table state. Connector port of legacy + * {@code IcebergCherrypickSnapshotAction}. Bug-for-bug: the not-found check is inside the try (so it + * is re-wrapped by the "Failed to cherry-pick snapshot ..." handler) and uses the generic legacy message + * "Snapshot not found in table" (no id interpolation); the post-commit {@code currentSnapshot()} is read + * without a null guard, exactly as legacy. + */ +public class IcebergCherrypickSnapshotAction extends BaseIcebergAction { + public static final String SNAPSHOT_ID = "snapshot_id"; + + public IcebergCherrypickSnapshotAction(Map properties, List partitionNames, + ConnectorPredicate whereCondition) { + super("cherrypick_snapshot", properties, partitionNames, whereCondition); + } + + @Override + protected void registerIcebergArguments() { + // Register snapshot_id as a required parameter with type-safe parsing + namedArguments.registerRequiredArgument(SNAPSHOT_ID, + "The snapshot ID to cherry-pick", + ArgumentParsers.positiveLong(SNAPSHOT_ID)); + } + + @Override + protected void validateIcebergAction() { + // Iceberg cherrypick_snapshot procedures don't support partitions or where conditions + validateNoPartitions(); + validateNoWhereCondition(); + } + + @Override + protected List executeAction(Table icebergTable, ConnectorSession session) { + Long sourceSnapshotId = namedArguments.getLong(SNAPSHOT_ID); + + try { + Snapshot targetSnapshot = icebergTable.snapshot(sourceSnapshotId); + if (targetSnapshot == null) { + throw new DorisConnectorException("Snapshot not found in table"); + } + + icebergTable.manageSnapshots().cherrypick(sourceSnapshotId).commit(); + Snapshot currentSnapshot = icebergTable.currentSnapshot(); + + return Lists.newArrayList( + String.valueOf(sourceSnapshotId), + String.valueOf(currentSnapshot.snapshotId() + ) + ); + + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to cherry-pick snapshot " + sourceSnapshotId + ": " + e.getMessage(), e); + } + } + + @Override + protected List getResultSchema() { + return Lists.newArrayList( + new ConnectorColumn("source_snapshot_id", ConnectorType.of("BIGINT"), + "ID of the snapshot whose changes were cherry-picked into the current table state", + false, null), + new ConnectorColumn("current_snapshot_id", ConnectorType.of("BIGINT"), + "ID of the new snapshot created as a result of the cherry-pick operation, " + + "now set as the current snapshot", false, null)); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactory.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactory.java new file mode 100644 index 00000000000000..c7fea971f86c7e --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactory.java @@ -0,0 +1,112 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.action; + +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; + +import java.util.List; +import java.util.Map; + +/** + * Factory for iceberg {@code ALTER TABLE EXECUTE} procedure bodies, dispatched by + * {@link org.apache.doris.connector.iceberg.IcebergProcedureOps}. + * + *

    Connector port of legacy {@code datasource/iceberg/action/IcebergExecuteActionFactory}. Two changes + * from legacy: the always-dead {@code IcebergExternalTable table} parameter is dropped, and the + * unknown-procedure rejection throws the connector's {@link DorisConnectorException} instead of + * {@code DdlException} (the message text is kept byte-identical — T08 byte-parity). The factory now + * builds {@link BaseIcebergAction} (the connector base), receiving the SPI-neutral {@code List} + * partition names and {@link ConnectorPredicate} where condition rather than the legacy + * {@code Optional} / nereids {@code Expression}. + * + *

    T03 scaffolding. The {@code createAction} switch carries only the faithful default rejection; + * the 9 procedure cases (their bodies) are ported in T04 ({@code rewrite_data_files} in T05/T06). The + * {@link #getSupportedActions()} registry — exported to {@code getSupportedProcedures()} and embedded in + * the rejection message — is complete and final. + */ +public class IcebergExecuteActionFactory { + + // Iceberg procedure names (mapped to action types) + public static final String ROLLBACK_TO_SNAPSHOT = "rollback_to_snapshot"; + public static final String ROLLBACK_TO_TIMESTAMP = "rollback_to_timestamp"; + public static final String SET_CURRENT_SNAPSHOT = "set_current_snapshot"; + public static final String CHERRYPICK_SNAPSHOT = "cherrypick_snapshot"; + public static final String FAST_FORWARD = "fast_forward"; + public static final String EXPIRE_SNAPSHOTS = "expire_snapshots"; + public static final String REWRITE_DATA_FILES = "rewrite_data_files"; + public static final String PUBLISH_CHANGES = "publish_changes"; + public static final String REWRITE_MANIFESTS = "rewrite_manifests"; + + /** + * Create an iceberg procedure body for {@code actionType}. + * + * @param actionType the procedure name (iceberg procedure / EXECUTE action name) + * @param properties the procedure arguments + * @param partitionNames the {@code PARTITION (...)} names (engine-neutral pass-through) + * @param whereCondition the engine-lowered {@code WHERE} predicate, or {@code null} + * @return the procedure body + * @throws DorisConnectorException if {@code actionType} is not a supported iceberg procedure + */ + public static BaseIcebergAction createAction(String actionType, Map properties, + List partitionNames, ConnectorPredicate whereCondition) { + + switch (actionType.toLowerCase()) { + case ROLLBACK_TO_SNAPSHOT: + return new IcebergRollbackToSnapshotAction(properties, partitionNames, whereCondition); + case ROLLBACK_TO_TIMESTAMP: + return new IcebergRollbackToTimestampAction(properties, partitionNames, whereCondition); + case SET_CURRENT_SNAPSHOT: + return new IcebergSetCurrentSnapshotAction(properties, partitionNames, whereCondition); + case CHERRYPICK_SNAPSHOT: + return new IcebergCherrypickSnapshotAction(properties, partitionNames, whereCondition); + case FAST_FORWARD: + return new IcebergFastForwardAction(properties, partitionNames, whereCondition); + case EXPIRE_SNAPSHOTS: + return new IcebergExpireSnapshotsAction(properties, partitionNames, whereCondition); + case PUBLISH_CHANGES: + return new IcebergPublishChangesAction(properties, partitionNames, whereCondition); + case REWRITE_MANIFESTS: + return new IcebergRewriteManifestsAction(properties, partitionNames, whereCondition); + // REWRITE_DATA_FILES is the distributed INSERT-SELECT procedure; its body is ported in T05/T06 and + // until then falls through to the default rejection (the whole procedure path is dormant pre-cutover). + default: + throw new DorisConnectorException("Unsupported Iceberg procedure: " + actionType + + ". Supported procedures: " + String.join(", ", getSupportedActions())); + } + } + + /** + * Get supported Iceberg procedure names. + * + * @return array of supported procedure names + */ + public static String[] getSupportedActions() { + return new String[] { + ROLLBACK_TO_SNAPSHOT, + ROLLBACK_TO_TIMESTAMP, + SET_CURRENT_SNAPSHOT, + CHERRYPICK_SNAPSHOT, + FAST_FORWARD, + EXPIRE_SNAPSHOTS, + REWRITE_DATA_FILES, + PUBLISH_CHANGES, + REWRITE_MANIFESTS + }; + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExpireSnapshotsAction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExpireSnapshotsAction.java new file mode 100644 index 00000000000000..a10363ccc4c28c --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExpireSnapshotsAction.java @@ -0,0 +1,334 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.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.pushdown.ConnectorPredicate; +import org.apache.doris.foundation.util.ArgumentParsers; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.Lists; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.ExpireSnapshots; +import org.apache.iceberg.FileContent; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ManifestFiles; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.SupportsBulkOperations; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Removes old snapshots from an iceberg table to free storage and improve metadata performance. Connector + * port of legacy {@code IcebergExpireSnapshotsAction} — the most involved of the snapshot procedures (five + * optional arguments, a custom validation pass, an FE-local fixed thread pool for concurrent deletes, a + * {@code deleteWith} callback that classifies deleted files into the six Spark-compatible counters, and the + * delete-file content map). The validation messages and the SDK call chain are verbatim; the validation + * failures throw {@link DorisConnectorException} in place of the legacy {@code AnalysisException}/ + * {@code UserException} (message-identical, T08 byte-parity). {@code parseTimestamp} keeps the legacy + * {@code ZoneId.systemDefault()} (this procedure, unlike {@code rollback_to_timestamp}, never used the + * session time zone). + */ +public class IcebergExpireSnapshotsAction extends BaseIcebergAction { + private static final Logger LOG = LogManager.getLogger(IcebergExpireSnapshotsAction.class); + public static final String OLDER_THAN = "older_than"; + public static final String RETAIN_LAST = "retain_last"; + public static final String MAX_CONCURRENT_DELETES = "max_concurrent_deletes"; + public static final String SNAPSHOT_IDS = "snapshot_ids"; + public static final String CLEAN_EXPIRED_METADATA = "clean_expired_metadata"; + + // Test-only gate for the delete-manifest dedup: the number of DISTINCT delete manifests read by the most + // recent buildDeleteFileContentMap call. Asserts each manifest is read once, not once per referencing snapshot. + @VisibleForTesting + int lastDeleteManifestReadCount; + + public IcebergExpireSnapshotsAction(Map properties, List partitionNames, + ConnectorPredicate whereCondition) { + super("expire_snapshots", properties, partitionNames, whereCondition); + } + + @Override + protected void registerIcebergArguments() { + // Register optional arguments for expire_snapshots + namedArguments.registerOptionalArgument(OLDER_THAN, + "Timestamp before which snapshots will be removed", + null, ArgumentParsers.nonEmptyString(OLDER_THAN)); + namedArguments.registerOptionalArgument(RETAIN_LAST, + "Number of ancestor snapshots to preserve regardless of older_than", + null, ArgumentParsers.positiveInt(RETAIN_LAST)); + namedArguments.registerOptionalArgument(MAX_CONCURRENT_DELETES, + "Size of the thread pool used for delete file actions (0 disables, " + + "ignored for FileIOs that support bulk deletes)", + 0, ArgumentParsers.intRange(MAX_CONCURRENT_DELETES, 0, Integer.MAX_VALUE)); + namedArguments.registerOptionalArgument(SNAPSHOT_IDS, + "Array of snapshot IDs to expire", + null, ArgumentParsers.nonEmptyString(SNAPSHOT_IDS)); + namedArguments.registerOptionalArgument(CLEAN_EXPIRED_METADATA, + "When true, cleans up metadata such as partition specs and schemas", + null, ArgumentParsers.booleanValue(CLEAN_EXPIRED_METADATA)); + } + + @Override + protected void validateIcebergAction() { + // Validate older_than parameter (timestamp) + String olderThan = namedArguments.getString(OLDER_THAN); + if (olderThan != null) { + try { + // Try to parse as ISO datetime format + LocalDateTime.parse(olderThan, DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } catch (DateTimeParseException e) { + try { + // Try to parse as timestamp (milliseconds since epoch) + long timestamp = Long.parseLong(olderThan); + if (timestamp < 0) { + throw new DorisConnectorException("older_than timestamp must be non-negative"); + } + } catch (NumberFormatException nfe) { + throw new DorisConnectorException("Invalid older_than format. Expected ISO datetime " + + "(yyyy-MM-ddTHH:mm:ss) or timestamp in milliseconds: " + olderThan); + } + } + } + + // Validate retain_last parameter + Integer retainLast = namedArguments.getInt(RETAIN_LAST); + if (retainLast != null && retainLast < 1) { + throw new DorisConnectorException("retain_last must be at least 1"); + } + + // Get snapshot_ids for validation + String snapshotIds = namedArguments.getString(SNAPSHOT_IDS); + + // Validate snapshot_ids format if provided + if (snapshotIds != null) { + for (String idStr : snapshotIds.split(",")) { + try { + Long.parseLong(idStr.trim()); + } catch (NumberFormatException e) { + throw new DorisConnectorException("Invalid snapshot_id format: " + idStr.trim()); + } + } + } + + // At least one of older_than, retain_last, or snapshot_ids must be specified + if (olderThan == null && retainLast == null && snapshotIds == null) { + throw new DorisConnectorException("At least one of 'older_than', 'retain_last', or " + + "'snapshot_ids' must be specified"); + } + + // Iceberg procedures don't support partitions or where conditions + validateNoPartitions(); + validateNoWhereCondition(); + } + + @Override + protected List executeAction(Table icebergTable, ConnectorSession session) { + // Parse parameters + String olderThan = namedArguments.getString(OLDER_THAN); + Integer retainLast = namedArguments.getInt(RETAIN_LAST); + String snapshotIdsStr = namedArguments.getString(SNAPSHOT_IDS); + Boolean cleanExpiredMetadata = namedArguments.getBoolean(CLEAN_EXPIRED_METADATA); + Integer maxConcurrentDeletes = namedArguments.getInt(MAX_CONCURRENT_DELETES); + + // Track deleted file counts using callbacks (matching Spark's 6-column schema) + AtomicLong deletedDataFilesCount = new AtomicLong(0); + AtomicLong deletedPositionDeleteFilesCount = new AtomicLong(0); + AtomicLong deletedEqualityDeleteFilesCount = new AtomicLong(0); + AtomicLong deletedManifestFilesCount = new AtomicLong(0); + AtomicLong deletedManifestListsCount = new AtomicLong(0); + AtomicLong deletedStatisticsFilesCount = new AtomicLong(0); + + ExecutorService deleteExecutor = null; + try { + Map deleteFileContentByPath = + buildDeleteFileContentMap(icebergTable); + ExpireSnapshots expireSnapshots = icebergTable.expireSnapshots(); + + // Configure older_than timestamp + // If retain_last is specified without older_than, use current time as the cutoff + // This is because Iceberg's retainLast only works in conjunction with expireOlderThan + if (olderThan != null) { + long timestampMillis = parseTimestamp(olderThan); + expireSnapshots.expireOlderThan(timestampMillis); + } else if (retainLast != null && snapshotIdsStr == null) { + // When only retain_last is specified, expire all snapshots older than now + // but keep at least retain_last snapshots + expireSnapshots.expireOlderThan(System.currentTimeMillis()); + } + + // Configure retain_last + if (retainLast != null) { + expireSnapshots.retainLast(retainLast); + } + + // Configure specific snapshot IDs to expire + if (snapshotIdsStr != null) { + for (String idStr : snapshotIdsStr.split(",")) { + expireSnapshots.expireSnapshotId(Long.parseLong(idStr.trim())); + } + } + + // Configure clean expired metadata + if (cleanExpiredMetadata != null) { + expireSnapshots.cleanExpiredMetadata(cleanExpiredMetadata); + } + + // Set up ExecutorService for concurrent deletes if specified + if (maxConcurrentDeletes > 0) { + if (icebergTable.io() instanceof SupportsBulkOperations) { + LOG.warn("max_concurrent_deletes only works with FileIOs that do not support " + + "bulk deletes. This table is currently using {} which supports bulk deletes " + + "so the parameter will be ignored.", + icebergTable.io().getClass().getName()); + } else { + deleteExecutor = Executors.newFixedThreadPool(maxConcurrentDeletes); + expireSnapshots.executeDeleteWith(deleteExecutor); + } + } + + // Set up delete callback to count files by type + expireSnapshots.deleteWith(path -> { + FileContent deleteContent = deleteFileContentByPath.get(path); + if (deleteContent == FileContent.POSITION_DELETES) { + deletedPositionDeleteFilesCount.incrementAndGet(); + } else if (deleteContent == FileContent.EQUALITY_DELETES) { + deletedEqualityDeleteFilesCount.incrementAndGet(); + } else if (path.contains("-m-") && path.endsWith(".avro")) { + deletedManifestFilesCount.incrementAndGet(); + } else if (path.contains("snap-") && path.endsWith(".avro")) { + deletedManifestListsCount.incrementAndGet(); + } else if (path.endsWith(".stats") || path.contains("statistics")) { + deletedStatisticsFilesCount.incrementAndGet(); + } else { + deletedDataFilesCount.incrementAndGet(); + } + icebergTable.io().deleteFile(path); + }); + + // Execute and commit + expireSnapshots.commit(); + + return Lists.newArrayList( + String.valueOf(deletedDataFilesCount.get()), + String.valueOf(deletedPositionDeleteFilesCount.get()), + String.valueOf(deletedEqualityDeleteFilesCount.get()), + String.valueOf(deletedManifestFilesCount.get()), + String.valueOf(deletedManifestListsCount.get()), + String.valueOf(deletedStatisticsFilesCount.get()) + ); + } catch (Exception e) { + throw new DorisConnectorException("Failed to expire snapshots: " + e.getMessage(), e); + } finally { + // Shutdown executor if created + if (deleteExecutor != null) { + deleteExecutor.shutdown(); + } + } + } + + /** + * Parse timestamp string to milliseconds since epoch. + * Supports ISO datetime format (yyyy-MM-ddTHH:mm:ss) or milliseconds. + */ + private long parseTimestamp(String timestamp) { + try { + // Try ISO datetime format + LocalDateTime dateTime = LocalDateTime.parse(timestamp, + DateTimeFormatter.ISO_LOCAL_DATE_TIME); + return dateTime.atZone(ZoneId.systemDefault()) + .toInstant().toEpochMilli(); + } catch (DateTimeParseException e) { + // Try as milliseconds + return Long.parseLong(timestamp); + } + } + + @VisibleForTesting + Map buildDeleteFileContentMap(Table icebergTable) { + Map deleteFileContentByPath = new HashMap<>(); + // Dedup delete-manifest reads across snapshots. Iceberg manifests are immutable and adjacent snapshots + // carry the same delete manifests forward unchanged, so re-reading one yields the identical DeleteFile + // set (putIfAbsent already made the re-read a no-op). Reading each DISTINCT manifest exactly once + // collapses the O(snapshots x manifests) remote reads to O(distinct manifests) with a byte-identical map. + // NOTE: visited MUST live at method scope (outside the snapshot loop) — inside it, it would reset every + // snapshot and defeat the cross-snapshot dedup this fix targets. + Set visitedDeleteManifests = new HashSet<>(); + int reads = 0; + try { + for (Snapshot snapshot : icebergTable.snapshots()) { + List deleteManifests = snapshot.deleteManifests(icebergTable.io()); + if (deleteManifests == null || deleteManifests.isEmpty()) { + continue; + } + for (ManifestFile manifest : deleteManifests) { + if (!visitedDeleteManifests.add(manifest.path())) { + continue; + } + reads++; + try (CloseableIterable deleteFiles = ManifestFiles.readDeleteManifest( + manifest, icebergTable.io(), icebergTable.specs())) { + for (DeleteFile deleteFile : deleteFiles) { + deleteFileContentByPath.putIfAbsent( + deleteFile.location(), deleteFile.content()); + } + } + } + } + } catch (Exception e) { + throw new DorisConnectorException("Failed to build delete file content map: " + e.getMessage(), e); + } + lastDeleteManifestReadCount = reads; + return deleteFileContentByPath; + } + + @Override + protected List getResultSchema() { + return Lists.newArrayList( + new ConnectorColumn("deleted_data_files_count", ConnectorType.of("BIGINT"), + "Number of data files deleted", false, null), + new ConnectorColumn("deleted_position_delete_files_count", ConnectorType.of("BIGINT"), + "Number of position delete files deleted", false, null), + new ConnectorColumn("deleted_equality_delete_files_count", ConnectorType.of("BIGINT"), + "Number of equality delete files deleted", false, null), + new ConnectorColumn("deleted_manifest_files_count", ConnectorType.of("BIGINT"), + "Number of manifest files deleted", false, null), + new ConnectorColumn("deleted_manifest_lists_count", ConnectorType.of("BIGINT"), + "Number of manifest list files deleted", false, null), + new ConnectorColumn("deleted_statistics_files_count", ConnectorType.of("BIGINT"), + "Number of statistics files deleted", false, null) + ); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergFastForwardAction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergFastForwardAction.java new file mode 100644 index 00000000000000..f4a1e5a6e85ffb --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergFastForwardAction.java @@ -0,0 +1,101 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.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.pushdown.ConnectorPredicate; +import org.apache.doris.foundation.util.ArgumentParsers; + +import com.google.common.collect.Lists; +import org.apache.iceberg.Table; + +import java.util.List; +import java.util.Map; + +/** + * Fast-forwards one branch to the latest snapshot of another. Connector port of legacy + * {@code IcebergFastForwardAction}. Bug-for-bug preserved: the {@code snapshotBefore} read is null-guarded + * but the post-commit {@code snapshotAfter} read is not; {@code sourceBranch} is trimmed only in the result + * row (not before the SDK call); and {@code previous_ref} is the one nullable result column (legacy passed + * {@code isAllowNull = true}). + */ +public class IcebergFastForwardAction extends BaseIcebergAction { + public static final String BRANCH = "branch"; + public static final String TO = "to"; + + public IcebergFastForwardAction(Map properties, List partitionNames, + ConnectorPredicate whereCondition) { + super("fast_forward", properties, partitionNames, whereCondition); + } + + @Override + protected void registerIcebergArguments() { + // Register required arguments for branch and to + namedArguments.registerRequiredArgument(BRANCH, + "Name of the branch to fast-forward to", + ArgumentParsers.nonEmptyString(BRANCH)); + namedArguments.registerRequiredArgument(TO, + "Target branch to fast-forward to", + ArgumentParsers.nonEmptyString(TO)); + } + + @Override + protected void validateIcebergAction() { + // Iceberg procedures don't support partitions or where conditions + validateNoPartitions(); + validateNoWhereCondition(); + } + + @Override + protected List executeAction(Table icebergTable, ConnectorSession session) { + String sourceBranch = namedArguments.getString(BRANCH); + String desBranch = namedArguments.getString(TO); + + try { + Long snapshotBefore = + icebergTable.snapshot(sourceBranch) != null ? icebergTable.snapshot(sourceBranch).snapshotId() + : null; + icebergTable.manageSnapshots().fastForwardBranch(sourceBranch, desBranch).commit(); + long snapshotAfter = icebergTable.snapshot(sourceBranch).snapshotId(); + return Lists.newArrayList( + sourceBranch.trim(), + String.valueOf(snapshotBefore), + String.valueOf(snapshotAfter) + ); + + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to fast-forward branch " + sourceBranch + " to snapshot " + desBranch + ": " + + e.getMessage(), e); + } + } + + @Override + protected List getResultSchema() { + return Lists.newArrayList( + new ConnectorColumn("branch_updated", ConnectorType.of("STRING"), + "Name of the branch that was fast-forwarded to match the target branch", false, null), + new ConnectorColumn("previous_ref", ConnectorType.of("BIGINT"), + "Snapshot ID that the branch was pointing to before the fast-forward operation", true, null), + new ConnectorColumn("updated_ref", ConnectorType.of("BIGINT"), + "Snapshot ID that the branch is pointing to after the fast-forward operation", false, null)); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergPublishChangesAction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergPublishChangesAction.java new file mode 100644 index 00000000000000..74963af98fc6eb --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergPublishChangesAction.java @@ -0,0 +1,117 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.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.pushdown.ConnectorPredicate; +import org.apache.doris.foundation.util.ArgumentParsers; + +import com.google.common.collect.Lists; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; + +import java.util.List; +import java.util.Map; + +/** + * Publishes a WAP (write-audit-publish) snapshot by cherry-picking the snapshot tagged with a given + * {@code wap.id} into the current table state. Connector port of legacy {@code IcebergPublishChangesAction}. + * + *

    Bug-for-bug preserved: the result columns are {@code STRING} (not {@code BIGINT} like the other + * snapshot actions), and a null snapshot id renders as the literal string {@code "null"} (not a SQL NULL); + * the WAP snapshot is found by a linear scan over {@code snapshots()}. + */ +public class IcebergPublishChangesAction extends BaseIcebergAction { + public static final String WAP_ID = "wap_id"; + private static final String WAP_ID_PROP = "wap.id"; + + public IcebergPublishChangesAction(Map properties, List partitionNames, + ConnectorPredicate whereCondition) { + super("publish_changes", properties, partitionNames, whereCondition); + } + + @Override + protected void registerIcebergArguments() { + namedArguments.registerRequiredArgument(WAP_ID, + "The WAP ID matching the snapshot to publish", + ArgumentParsers.nonEmptyString(WAP_ID)); + } + + @Override + protected void validateIcebergAction() { + validateNoPartitions(); + validateNoWhereCondition(); + } + + @Override + protected List executeAction(Table icebergTable, ConnectorSession session) { + String targetWapId = namedArguments.getString(WAP_ID); + + // Find the target WAP snapshot + Snapshot wapSnapshot = null; + for (Snapshot snapshot : icebergTable.snapshots()) { + if (targetWapId.equals(snapshot.summary().get(WAP_ID_PROP))) { + wapSnapshot = snapshot; + break; + } + } + + if (wapSnapshot == null) { + throw new DorisConnectorException("Cannot find snapshot with " + WAP_ID_PROP + " = " + targetWapId); + } + + long wapSnapshotId = wapSnapshot.snapshotId(); + + try { + // Get previous snapshot ID for result + Snapshot previousSnapshot = icebergTable.currentSnapshot(); + Long previousSnapshotId = previousSnapshot != null ? previousSnapshot.snapshotId() : null; + + // Execute Cherry-pick + icebergTable.manageSnapshots().cherrypick(wapSnapshotId).commit(); + + // Get current snapshot ID after commit + Snapshot currentSnapshot = icebergTable.currentSnapshot(); + Long currentSnapshotId = currentSnapshot != null ? currentSnapshot.snapshotId() : null; + + String previousSnapshotIdString = previousSnapshotId != null ? String.valueOf(previousSnapshotId) : "null"; + String currentSnapshotIdString = currentSnapshotId != null ? String.valueOf(currentSnapshotId) : "null"; + + return Lists.newArrayList( + previousSnapshotIdString, + currentSnapshotIdString + ); + + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to publish changes for wap.id " + targetWapId + ": " + e.getMessage(), e); + } + } + + @Override + protected List getResultSchema() { + return Lists.newArrayList( + new ConnectorColumn("previous_snapshot_id", ConnectorType.of("STRING"), + "ID of the snapshot before the publish operation", false, null), + new ConnectorColumn("current_snapshot_id", ConnectorType.of("STRING"), + "ID of the new snapshot created as a result of the publish operation", false, null)); + } +} 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 new file mode 100644 index 00000000000000..34b7a3b5b6b184 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRewriteDataFilesAction.java @@ -0,0 +1,177 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.action; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.connector.iceberg.rewrite.RewriteDataFilePlanner; +import org.apache.doris.foundation.util.ArgumentParsers; + +import org.apache.iceberg.Table; + +import java.util.List; +import java.util.Map; + +/** + * Argument spec + planning-parameter builder for iceberg {@code ALTER TABLE EXECUTE rewrite_data_files(...)}. + * + *

    Connector port of the argument half of fe-core {@code datasource/iceberg/action/IcebergRewriteDataFilesAction} + * (P6.4-T05/T06, WS-REWRITE R3). It registers and validates the ten rewrite arguments (reusing the shared + * fe-foundation {@link org.apache.doris.foundation.util.NamedArguments} framework, exactly as the engine did, + * so the error strings stay byte-identical) and turns the validated arguments into a neutral + * {@link RewriteDataFilePlanner.Parameters}.

    + * + *

    Not a {@code SINGLE_CALL} body. {@code rewrite_data_files} is a {@code DISTRIBUTED} procedure: the + * actual rewrite is N per-group {@code INSERT-SELECT} writes driven by the engine, not a synchronous SDK call. + * So this action is NOT reachable through {@link IcebergExecuteActionFactory#createAction} (which deliberately + * rejects {@code rewrite_data_files}) and {@link #executeAction} is never invoked — it is built directly by + * {@code IcebergProcedureOps.planRewrite}, which calls {@link #buildRewriteParameters()} and runs the + * connector {@link RewriteDataFilePlanner}. Dormant until the P6.6 cutover.

    + */ +public class IcebergRewriteDataFilesAction extends BaseIcebergAction { + + // File size parameters + public static final String TARGET_FILE_SIZE_BYTES = "target-file-size-bytes"; + public static final String MIN_FILE_SIZE_BYTES = "min-file-size-bytes"; + public static final String MAX_FILE_SIZE_BYTES = "max-file-size-bytes"; + + // Input files parameters + public static final String MIN_INPUT_FILES = "min-input-files"; + public static final String REWRITE_ALL = "rewrite-all"; + public static final String MAX_FILE_GROUP_SIZE_BYTES = "max-file-group-size-bytes"; + + // Delete files parameters + public static final String DELETE_FILE_THRESHOLD = "delete-file-threshold"; + public static final String DELETE_RATIO_THRESHOLD = "delete-ratio-threshold"; + + // Output specification parameter + public static final String OUTPUT_SPEC_ID = "output-spec-id"; + + // Parameters with special default handling (resolved in validateIcebergAction, read by buildRewriteParameters) + private long minFileSizeBytes; + private long maxFileSizeBytes; + + public IcebergRewriteDataFilesAction(Map properties, List partitionNames, + ConnectorPredicate whereCondition) { + super("rewrite_data_files", properties, partitionNames, whereCondition); + } + + @Override + protected void registerIcebergArguments() { + // File size arguments + namedArguments.registerOptionalArgument(TARGET_FILE_SIZE_BYTES, + "Target file size in bytes for output files", + 536870912L, + ArgumentParsers.positiveLong(TARGET_FILE_SIZE_BYTES)); + + namedArguments.registerOptionalArgument(MIN_FILE_SIZE_BYTES, + "Minimum file size in bytes for files to be rewritten", + 0L, + ArgumentParsers.positiveLong(MIN_FILE_SIZE_BYTES)); + + namedArguments.registerOptionalArgument(MAX_FILE_SIZE_BYTES, + "Maximum file size in bytes for files to be rewritten", + 0L, + ArgumentParsers.positiveLong(MAX_FILE_SIZE_BYTES)); + + // Input files arguments + namedArguments.registerOptionalArgument(MIN_INPUT_FILES, + "Minimum number of input files to rewrite together", + 5, + ArgumentParsers.intRange(MIN_INPUT_FILES, 1, 10000)); + + namedArguments.registerOptionalArgument(REWRITE_ALL, + "Whether to rewrite all files regardless of size", + false, + ArgumentParsers.booleanValue(REWRITE_ALL)); + + namedArguments.registerOptionalArgument(MAX_FILE_GROUP_SIZE_BYTES, + "Maximum size in bytes for a file group to be rewritten", + 107374182400L, + ArgumentParsers.positiveLong(MAX_FILE_GROUP_SIZE_BYTES)); + + // Delete files arguments + namedArguments.registerOptionalArgument(DELETE_FILE_THRESHOLD, + "Minimum number of delete files to trigger rewrite", + Integer.MAX_VALUE, + ArgumentParsers.intRange(DELETE_FILE_THRESHOLD, 1, Integer.MAX_VALUE)); + + namedArguments.registerOptionalArgument(DELETE_RATIO_THRESHOLD, + "Minimum ratio of delete records to total records to trigger rewrite", + 0.3, + ArgumentParsers.doubleRange(DELETE_RATIO_THRESHOLD, 0.0, 1.0)); + + // Output specification argument + namedArguments.registerOptionalArgument(OUTPUT_SPEC_ID, + "Partition specification ID for output files", + 2L, + ArgumentParsers.positiveLong(OUTPUT_SPEC_ID)); + } + + @Override + protected void validateIcebergAction() { + // Validate min and max file size parameters + long targetFileSizeBytes = namedArguments.getLong(TARGET_FILE_SIZE_BYTES); + // min-file-size-bytes default to 75% of target file size + this.minFileSizeBytes = namedArguments.getLong(MIN_FILE_SIZE_BYTES); + if (this.minFileSizeBytes == 0) { + this.minFileSizeBytes = (long) (targetFileSizeBytes * 0.75); + } + // max-file-size-bytes default to 180% of target file size + this.maxFileSizeBytes = namedArguments.getLong(MAX_FILE_SIZE_BYTES); + if (this.maxFileSizeBytes == 0) { + this.maxFileSizeBytes = (long) (targetFileSizeBytes * 1.8); + } + if (this.minFileSizeBytes > this.maxFileSizeBytes) { + throw new DorisConnectorException( + "min-file-size-bytes must be less than or equal to max-file-size-bytes"); + } + validateNoPartitions(); + } + + /** + * Builds the neutral planner parameters from the validated arguments. Must be called after + * {@link #validate()} (which resolves the min/max file-size defaults). Mirrors the legacy + * {@code buildRewriteParameters}; the engine-lowered {@link ConnectorPredicate} {@code WHERE} is threaded + * straight through (the planner lowers it to iceberg expressions). + */ + public RewriteDataFilePlanner.Parameters buildRewriteParameters() { + return new RewriteDataFilePlanner.Parameters( + namedArguments.getLong(TARGET_FILE_SIZE_BYTES), + this.minFileSizeBytes, + this.maxFileSizeBytes, + namedArguments.getInt(MIN_INPUT_FILES), + namedArguments.getBoolean(REWRITE_ALL), + namedArguments.getLong(MAX_FILE_GROUP_SIZE_BYTES), + namedArguments.getInt(DELETE_FILE_THRESHOLD), + namedArguments.getDouble(DELETE_RATIO_THRESHOLD), + namedArguments.getLong(OUTPUT_SPEC_ID), + whereCondition); + } + + @Override + protected List executeAction(Table table, ConnectorSession session) { + // rewrite_data_files is DISTRIBUTED: it is planned via buildRewriteParameters + the engine rewrite + // driver, never run as a synchronous single-call body. This is unreachable (the factory rejects the + // name); guard loudly in case a future caller wires it wrong. + throw new DorisConnectorException( + "rewrite_data_files is a distributed procedure and has no single-call body; " + + "plan it via IcebergProcedureOps.planRewrite"); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRewriteManifestsAction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRewriteManifestsAction.java new file mode 100644 index 00000000000000..9b109e7acd1aa7 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRewriteManifestsAction.java @@ -0,0 +1,98 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.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.pushdown.ConnectorPredicate; +import org.apache.doris.foundation.util.ArgumentParsers; + +import com.google.common.collect.Lists; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.List; +import java.util.Map; + +/** + * Rewrites the iceberg manifest files to optimize metadata layout. Connector port of legacy + * {@code IcebergRewriteManifestsAction}, delegating to the connector {@link RewriteManifestExecutor}. Bug-for-bug + * preserved: an empty table (no current snapshot) short-circuits to {@code ["0", "0"]}, and the executor's own + * "Failed to rewrite manifests: ..." message is double-wrapped by this body's "Rewrite manifests failed: ..." + * handler, exactly as legacy. + */ +public class IcebergRewriteManifestsAction extends BaseIcebergAction { + private static final Logger LOG = LogManager.getLogger(IcebergRewriteManifestsAction.class); + public static final String SPEC_ID = "spec_id"; + + public IcebergRewriteManifestsAction(Map properties, List partitionNames, + ConnectorPredicate whereCondition) { + super("rewrite_manifests", properties, partitionNames, whereCondition); + } + + @Override + protected void registerIcebergArguments() { + namedArguments.registerOptionalArgument(SPEC_ID, + "Spec id of the manifests to rewrite (defaults to current spec id)", + null, + ArgumentParsers.intRange(SPEC_ID, 0, Integer.MAX_VALUE)); + } + + @Override + protected void validateIcebergAction() { + validateNoPartitions(); + validateNoWhereCondition(); + } + + @Override + protected List executeAction(Table icebergTable, ConnectorSession session) { + try { + Snapshot current = icebergTable.currentSnapshot(); + if (current == null) { + // No current snapshot means the table is empty, no manifests to rewrite + return Lists.newArrayList("0", "0"); + } + + // Get optional spec_id parameter + Integer specId = namedArguments.getInt(SPEC_ID); + + // Execute rewrite operation + RewriteManifestExecutor executor = new RewriteManifestExecutor(); + RewriteManifestExecutor.Result result = executor.execute(icebergTable, specId); + + return result.toStringList(); + } catch (Exception e) { + LOG.warn("Failed to rewrite manifests for table: {}", icebergTable.name(), e); + throw new DorisConnectorException("Rewrite manifests failed: " + e.getMessage(), e); + } + } + + @Override + protected List getResultSchema() { + return Lists.newArrayList( + new ConnectorColumn("rewritten_manifests_count", ConnectorType.of("INT"), + "Number of manifests which were re-written by this command", false, null), + new ConnectorColumn("added_manifests_count", ConnectorType.of("INT"), + "Number of new manifest files which were written by this command", false, null) + ); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRollbackToSnapshotAction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRollbackToSnapshotAction.java new file mode 100644 index 00000000000000..7c091d483e783d --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRollbackToSnapshotAction.java @@ -0,0 +1,105 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.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.pushdown.ConnectorPredicate; +import org.apache.doris.foundation.util.ArgumentParsers; + +import com.google.common.collect.Lists; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; + +import java.util.List; +import java.util.Map; + +/** + * Rolls the iceberg table back to a specific snapshot id. Connector port of legacy + * {@code datasource/iceberg/action/IcebergRollbackToSnapshotAction} — the body is byte-for-byte the legacy + * SDK call chain with three mechanical changes: it reads the already-loaded SDK {@link Table} (no + * {@code IcebergExternalTable} downcast), the per-action {@code ExtMetaCacheMgr} cache invalidation moves to + * dispatch level ({@code IcebergProcedureOps}), and failures throw {@link DorisConnectorException} (unchecked) + * whose message is kept byte-identical to the legacy {@code UserException} (T08 byte-parity). + */ +public class IcebergRollbackToSnapshotAction extends BaseIcebergAction { + public static final String SNAPSHOT_ID = "snapshot_id"; + + public IcebergRollbackToSnapshotAction(Map properties, List partitionNames, + ConnectorPredicate whereCondition) { + super("rollback_to_snapshot", properties, partitionNames, whereCondition); + } + + @Override + protected void registerIcebergArguments() { + // Register snapshot_id as a required parameter + namedArguments.registerRequiredArgument(SNAPSHOT_ID, + "Snapshot ID to rollback to", + ArgumentParsers.positiveLong(SNAPSHOT_ID)); + } + + @Override + protected void validateIcebergAction() { + // Iceberg rollback_to_snapshot procedures don't support partitions or where conditions + validateNoPartitions(); + validateNoWhereCondition(); + } + + @Override + protected List executeAction(Table icebergTable, ConnectorSession session) { + Long targetSnapshotId = namedArguments.getLong(SNAPSHOT_ID); + + Snapshot targetSnapshot = icebergTable.snapshot(targetSnapshotId); + if (targetSnapshot == null) { + throw new DorisConnectorException( + "Snapshot " + targetSnapshotId + " not found in table " + icebergTable.name()); + } + + try { + Snapshot previousSnapshot = icebergTable.currentSnapshot(); + Long previousSnapshotId = previousSnapshot != null ? previousSnapshot.snapshotId() : null; + if (previousSnapshot != null && previousSnapshot.snapshotId() == targetSnapshotId) { + return Lists.newArrayList( + String.valueOf(previousSnapshotId), + String.valueOf(targetSnapshotId) + ); + } + icebergTable.manageSnapshots().rollbackTo(targetSnapshotId).commit(); + return Lists.newArrayList( + String.valueOf(previousSnapshotId), + String.valueOf(targetSnapshotId) + ); + + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to rollback to snapshot " + targetSnapshotId + ": " + e.getMessage(), e); + } + } + + @Override + protected List getResultSchema() { + return Lists.newArrayList( + new ConnectorColumn("previous_snapshot_id", ConnectorType.of("BIGINT"), + "ID of the snapshot that was current before the rollback operation", false, null), + new ConnectorColumn("current_snapshot_id", ConnectorType.of("BIGINT"), + "ID of the snapshot that is now current after rolling back to the specified snapshot", + false, null)); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRollbackToTimestampAction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRollbackToTimestampAction.java new file mode 100644 index 00000000000000..6b45b2e8311c1d --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRollbackToTimestampAction.java @@ -0,0 +1,149 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.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.pushdown.ConnectorPredicate; +import org.apache.doris.connector.iceberg.IcebergTimeUtils; + +import com.google.common.collect.Lists; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; + +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + +/** + * Rolls the iceberg table back to the snapshot current at a given timestamp. Connector port of legacy + * {@code IcebergRollbackToTimestampAction}. + * + *

    Time-zone parity (the one connector-specific change). Legacy parsed the datetime argument with + * {@code TimeUtils.msTimeStringToLong(str, TimeUtils.getTimeZone())} — the millisecond format + * {@code yyyy-MM-dd HH:mm:ss.SSS} interpreted in the FE session time zone (read from the thread-local + * {@code ConnectContext}). The connector cannot reach {@code ConnectContext}, so it reads the session time + * zone from {@link ConnectorSession} and resolves it through {@link IcebergTimeUtils#resolveSessionZone} (the + * same Doris alias map, CST -> Asia/Shanghai), then parses via {@link IcebergTimeUtils#msTimeStringToLong} + * (the millisecond-format, {@code -1}-on-failure mirror of the legacy helper). The argument validator and the + * {@link #parseTimestampMillis} structure (millis-first, then datetime, then the {@code -1} sentinel error) + * are otherwise verbatim. + */ +public class IcebergRollbackToTimestampAction extends BaseIcebergAction { + private static final DateTimeFormatter DATETIME_MS_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + public static final String TIMESTAMP = "timestamp"; + + public IcebergRollbackToTimestampAction(Map properties, List partitionNames, + ConnectorPredicate whereCondition) { + super("rollback_to_timestamp", properties, partitionNames, whereCondition); + } + + @Override + protected void registerIcebergArguments() { + // Create a custom timestamp parser that supports both ISO datetime and millisecond formats + namedArguments.registerRequiredArgument(TIMESTAMP, + "A timestamp to rollback to (formats: 'yyyy-MM-dd HH:mm:ss.SSS' or milliseconds since epoch)", + value -> { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException("timestamp cannot be empty"); + } + + String trimmed = value.trim(); + + // Try to parse as milliseconds first + try { + long timestampMs = Long.parseLong(trimmed); + if (timestampMs < 0) { + throw new IllegalArgumentException("Timestamp must be non-negative: " + timestampMs); + } + return trimmed; + } catch (NumberFormatException e) { + // Second attempt: Parse as ISO datetime format (yyyy-MM-dd HH:mm:ss.SSS) + try { + java.time.LocalDateTime.parse(trimmed, DATETIME_MS_FORMAT); + return trimmed; + } catch (java.time.format.DateTimeParseException dte) { + throw new IllegalArgumentException("Invalid timestamp format. Expected ISO datetime " + + "(yyyy-MM-dd HH:mm:ss.SSS) or timestamp in milliseconds: " + trimmed); + } + } + }); + } + + @Override + protected void validateIcebergAction() { + // Iceberg rollback_to_timestamp procedures don't support partitions or where conditions + validateNoPartitions(); + validateNoWhereCondition(); + } + + @Override + protected List executeAction(Table icebergTable, ConnectorSession session) { + String timestampStr = namedArguments.getString(TIMESTAMP); + + Snapshot previousSnapshot = icebergTable.currentSnapshot(); + Long previousSnapshotId = previousSnapshot != null ? previousSnapshot.snapshotId() : null; + + try { + long targetTimestamp = parseTimestampMillis(timestampStr, IcebergTimeUtils.resolveSessionZone(session)); + icebergTable.manageSnapshots().rollbackToTime(targetTimestamp).commit(); + + Snapshot currentSnapshot = icebergTable.currentSnapshot(); + Long currentSnapshotId = currentSnapshot != null ? currentSnapshot.snapshotId() : null; + return Lists.newArrayList( + String.valueOf(previousSnapshotId), + String.valueOf(currentSnapshotId) + ); + + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to rollback to timestamp " + timestampStr + ": " + e.getMessage(), e); + } + } + + @Override + protected List getResultSchema() { + return Lists.newArrayList( + new ConnectorColumn("previous_snapshot_id", ConnectorType.of("BIGINT"), + "ID of the snapshot that was current before the rollback operation", false, null), + new ConnectorColumn("current_snapshot_id", ConnectorType.of("BIGINT"), + "ID of the snapshot that was current at the specified timestamp and is now set as current", + false, null)); + } + + static long parseTimestampMillis(String timestampStr, ZoneId zone) { + String trimmed = timestampStr.trim(); + try { + long timestampMs = Long.parseLong(trimmed); + if (timestampMs < 0) { + throw new IllegalArgumentException("Timestamp must be non-negative: " + timestampMs); + } + return timestampMs; + } catch (NumberFormatException e) { + long parsedTimestamp = IcebergTimeUtils.msTimeStringToLong(trimmed, zone); + if (parsedTimestamp < 0) { + throw new IllegalArgumentException("Invalid timestamp format. Expected ISO datetime " + + "(yyyy-MM-dd HH:mm:ss.SSS) or timestamp in milliseconds: " + trimmed, e); + } + return parsedTimestamp; + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergSetCurrentSnapshotAction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergSetCurrentSnapshotAction.java new file mode 100644 index 00000000000000..12b989db2f9eee --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergSetCurrentSnapshotAction.java @@ -0,0 +1,148 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.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.pushdown.ConnectorPredicate; +import org.apache.doris.foundation.util.ArgumentParsers; + +import com.google.common.collect.Lists; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; + +import java.util.List; +import java.util.Map; + +/** + * Sets the current snapshot of an iceberg table to a specific snapshot id or reference (branch / tag). + * Connector port of legacy {@code IcebergSetCurrentSnapshotAction}. The mutual-exclusion validation + * ({@code snapshot_id} xor {@code ref}) throws {@link DorisConnectorException} in place of the legacy + * {@code AnalysisException}, message-identical (T08 byte-parity); the snapshot-not-found check stays + * inside the try block so it is re-wrapped by the "Failed to set current snapshot to ..." handler, + * exactly as legacy. + */ +public class IcebergSetCurrentSnapshotAction extends BaseIcebergAction { + public static final String SNAPSHOT_ID = "snapshot_id"; + public static final String REF = "ref"; + + public IcebergSetCurrentSnapshotAction(Map properties, List partitionNames, + ConnectorPredicate whereCondition) { + super("set_current_snapshot", properties, partitionNames, whereCondition); + } + + @Override + protected void registerIcebergArguments() { + // Either snapshot_id or ref must be provided but not both + namedArguments.registerOptionalArgument(SNAPSHOT_ID, + "Snapshot ID to set as current", + null, + ArgumentParsers.positiveLong(SNAPSHOT_ID)); + + namedArguments.registerOptionalArgument(REF, + "Snapshot Reference (branch or tag) to set as current", + null, + ArgumentParsers.nonEmptyString(REF)); + } + + @Override + protected void validateIcebergAction() { + // Either snapshot_id or ref must be provided but not both + Long snapshotId = namedArguments.getLong(SNAPSHOT_ID); + String ref = namedArguments.getString(REF); + + if (snapshotId == null && ref == null) { + throw new DorisConnectorException("Either snapshot_id or ref must be provided"); + } + + if (snapshotId != null && ref != null) { + throw new DorisConnectorException("snapshot_id and ref are mutually exclusive, only one can be provided"); + } + + // Iceberg procedures don't support partitions or where conditions + validateNoPartitions(); + validateNoWhereCondition(); + } + + @Override + protected List executeAction(Table icebergTable, ConnectorSession session) { + Snapshot previousSnapshot = icebergTable.currentSnapshot(); + Long previousSnapshotId = previousSnapshot != null ? previousSnapshot.snapshotId() : null; + + Long targetSnapshotId = namedArguments.getLong(SNAPSHOT_ID); + String ref = namedArguments.getString(REF); + + try { + if (targetSnapshotId != null) { + Snapshot targetSnapshot = icebergTable.snapshot(targetSnapshotId); + if (targetSnapshot == null) { + throw new DorisConnectorException( + "Snapshot " + targetSnapshotId + " not found in table " + icebergTable.name()); + } + + if (previousSnapshot != null && previousSnapshot.snapshotId() == targetSnapshotId) { + return Lists.newArrayList( + String.valueOf(previousSnapshotId), + String.valueOf(targetSnapshotId) + ); + } + + icebergTable.manageSnapshots().setCurrentSnapshot(targetSnapshotId).commit(); + + } else if (ref != null) { + Snapshot refSnapshot = icebergTable.snapshot(ref); + if (refSnapshot == null) { + throw new DorisConnectorException("Reference '" + ref + "' not found in table " + + icebergTable.name()); + } + targetSnapshotId = refSnapshot.snapshotId(); + + if (previousSnapshot != null && previousSnapshot.snapshotId() == targetSnapshotId) { + return Lists.newArrayList( + String.valueOf(previousSnapshotId), + String.valueOf(targetSnapshotId) + ); + } + + icebergTable.manageSnapshots().setCurrentSnapshot(targetSnapshotId).commit(); + } + + return Lists.newArrayList( + String.valueOf(previousSnapshotId), + String.valueOf(targetSnapshotId) + ); + + } catch (Exception e) { + String target = targetSnapshotId != null ? "snapshot " + targetSnapshotId : "reference '" + ref + "'"; + throw new DorisConnectorException( + "Failed to set current snapshot to " + target + ": " + e.getMessage(), e); + } + } + + @Override + protected List getResultSchema() { + return Lists.newArrayList( + new ConnectorColumn("previous_snapshot_id", ConnectorType.of("BIGINT"), + "ID of the snapshot that was current before setting the new current snapshot", false, null), + new ConnectorColumn("current_snapshot_id", ConnectorType.of("BIGINT"), + "ID of the snapshot that is now set as the current snapshot " + + "(from snapshot_id parameter or resolved from ref parameter)", false, null)); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteManifestExecutor.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/RewriteManifestExecutor.java similarity index 83% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteManifestExecutor.java rename to fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/RewriteManifestExecutor.java index f2e5ab77adbfde..ce8772d5ebb89f 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteManifestExecutor.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/RewriteManifestExecutor.java @@ -15,11 +15,9 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource.iceberg.rewrite; +package org.apache.doris.connector.iceberg.action; -import org.apache.doris.catalog.Env; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.connector.api.DorisConnectorException; import org.apache.iceberg.ManifestFile; import org.apache.iceberg.RewriteManifests; @@ -31,7 +29,12 @@ import java.util.List; /** - * Executor for manifest rewrite operations + * Executor for manifest rewrite operations. Connector port of legacy + * {@code datasource/iceberg/rewrite/RewriteManifestExecutor}. The fe-core couplings are dropped: there is no + * {@code ExternalTable} parameter and no {@code Env.getExtMetaCacheMgr().invalidateTableCache} call (cache + * invalidation is performed once at dispatch level by {@code IcebergProcedureOps}); the SDK call chain and + * the before/after manifest accounting are otherwise verbatim, with the failure message kept byte-identical + * to the legacy {@code UserException}. */ public class RewriteManifestExecutor { private static final Logger LOG = LogManager.getLogger(RewriteManifestExecutor.class); @@ -54,7 +57,7 @@ public java.util.List toStringList() { /** * Execute manifest rewrite using Iceberg RewriteManifests API */ - public Result execute(Table table, ExternalTable extTable, Integer specId) throws UserException { + public Result execute(Table table, Integer specId) { try { // Get current snapshot and return early if table is empty Snapshot currentSnapshot = table.currentSnapshot(); @@ -103,13 +106,10 @@ public Result execute(Table table, ExternalTable extTable, Integer specId) throw .filter(path -> !beforePaths.contains(path)) .count(); - // Invalidate table cache to ensure metadata is refreshed - Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTableCache(extTable); - return new Result(rewrittenCount, addedCount); } catch (Exception e) { - LOG.warn("Failed to execute manifest rewrite for table: {}", extTable.getName(), e); - throw new UserException("Failed to rewrite manifests: " + e.getMessage(), e); + LOG.warn("Failed to execute manifest rewrite for table: {}", table.name(), e); + throw new DorisConnectorException("Failed to rewrite manifests: " + e.getMessage(), e); } } diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/glue/ConfigurationAWSCredentialsProvider2x.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/glue/ConfigurationAWSCredentialsProvider2x.java new file mode 100644 index 00000000000000..c1779498e1a954 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/glue/ConfigurationAWSCredentialsProvider2x.java @@ -0,0 +1,65 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.glue; + +import org.apache.commons.lang3.StringUtils; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; + +import java.util.Map; + +/** + * Credentials provider for the glue flavor's static AK/SK, named by the {@code client.credentials-provider} + * property {@link org.apache.doris.connector.iceberg.IcebergCatalogFactory} emits. + * + *

    MUST live in this plugin module: iceberg's {@code AwsClientProperties} resolves the property's class name + * through the plugin's child-first loader and gates it on {@code AwsCredentialsProvider.isAssignableFrom}, + * against the plugin's own copy of that interface. A copy loaded from any other loader implements a different + * {@code AwsCredentialsProvider} and is rejected with "it does not implement ...". + */ +public class ConfigurationAWSCredentialsProvider2x implements AwsCredentialsProvider { + + private AwsCredentials credentials; + + private ConfigurationAWSCredentialsProvider2x(AwsCredentials credentials) { + this.credentials = credentials; + } + + @Override + public AwsCredentials resolveCredentials() { + return credentials; + } + + /** + * Keys here are the emitted {@code client.credentials-provider.glue.*} properties minus their prefix: + * iceberg's {@code AwsClientProperties} strips it before reflecting into this method. + */ + public static AwsCredentialsProvider create(Map config) { + String ak = config.get("glue.access_key"); + String sk = config.get("glue.secret_key"); + String sessionToken = config.get("glue.session_token"); + // Blank-check rather than null-check: AwsSessionCredentials.create accepts a blank token and only + // fails later at AWS. The emitting side guards with putIfNotBlank, so keep the two halves symmetric. + if (StringUtils.isBlank(sessionToken)) { + return new ConfigurationAWSCredentialsProvider2x(AwsBasicCredentials.create(ak, sk)); + } + return new ConfigurationAWSCredentialsProvider2x(AwsSessionCredentials.create(ak, sk, sessionToken)); + } +} 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 new file mode 100644 index 00000000000000..dbf177db075095 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/rewrite/RewriteDataFilePlanner.java @@ -0,0 +1,411 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.rewrite; + +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.connector.iceberg.IcebergPredicateConverter; + +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableScan; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.util.BinPacking; +import org.apache.iceberg.util.ContentFileUtil; +import org.apache.iceberg.util.StructLikeWrapper; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.time.ZoneId; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Planner for organizing and filtering file scan tasks into rewrite groups. + * + *

    Connector port of fe-core {@code datasource/iceberg/rewrite/RewriteDataFilePlanner} — the SDK-only + * planning half of {@code rewrite_data_files} (P6.4-T05). The bin-pack / partition-grouping / file-and-group + * filtering machinery is moved verbatim. Three fe-core couplings are replaced by their connector equivalents: + *

      + *
    • {@code UserException} → {@link DorisConnectorException} (unchecked; message kept byte-identical);
    • + *
    • the nereids {@code Optional} {@code WHERE} → the engine-neutral {@link ConnectorPredicate} + * carried by {@link Parameters};
    • + *
    • {@code IcebergNereidsUtils.convertNereidsToIcebergExpression} → {@link IcebergPredicateConverter} in + * REWRITE mode (P6.6-FIX-H9). It mirrors the legacy node set faithfully -- cross-column + * {@code OR}, {@code NOT(comparison)}, {@code NE}, {@code IN}, {@code IS NULL}, {@code BETWEEN} -- and is + * 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).

    + */ +public class RewriteDataFilePlanner { + private static final Logger LOG = LogManager.getLogger(RewriteDataFilePlanner.class); + + private final Parameters parameters; + // Session time zone, threaded into IcebergPredicateConverter for zone-adjusted (timestamptz) WHERE literals + // (mirrors IcebergScanPlanProvider.planScan; UTC when there is no zone-bearing predicate). + private final ZoneId sessionZone; + + public RewriteDataFilePlanner(Parameters parameters, ZoneId sessionZone) { + this.parameters = parameters; + this.sessionZone = sessionZone; + } + + /** + * Plan and organize file scan tasks into rewrite groups + */ + public List planAndOrganizeTasks(Table icebergTable) { + try { + // Step 1: Plan FileScanTask from Iceberg table + Iterable allTasks = planFileScanTasks(icebergTable); + + // Step 2: First layer - Group tasks by partition (without filtering files) + Map> filesByPartition = groupTasksByPartition(allTasks); + + // Step 3: Apply binPack grouping strategy within each partition and convert to + // RewriteDataGroup + Map> fileGroupsByPartition = Maps.transformValues( + filesByPartition, this::packGroupsInPartition); + + // Step 4: Flatten all groups from all partitions + return fileGroupsByPartition.values().stream() + .flatMap(List::stream) + .collect(Collectors.toList()); + } catch (Exception e) { + throw new DorisConnectorException("Failed to plan file scan tasks: " + e.getMessage(), e); + } + } + + /** + * Plan FileScanTask from Iceberg table + */ + private Iterable planFileScanTasks(Table icebergTable) { + // Create table scan with optional filters + TableScan tableScan = icebergTable.newScan(); + + // Use current snapshot if available + if (icebergTable.currentSnapshot() != null) { + tableScan = tableScan.useSnapshot(icebergTable.currentSnapshot().snapshotId()); + } + + // Apply WHERE condition if specified. The engine-neutral ConnectorPredicate is lowered to iceberg + // expressions by IcebergPredicateConverter in REWRITE mode (P6.6-FIX-H9) -- master's rewrite matrix + // (cross-column OR, NOT(comparison), NE, IN, IS NULL, BETWEEN), strictly all-or-nothing. Each pushable + // conjunct is applied as a separate scan.filter (iceberg ANDs them), mirroring IcebergScanPlanProvider. + // A rewrite WHERE is a user-authored data-scope filter with no downstream re-filter: dropping a conjunct + // would WIDEN the set of files rewritten (at the limit, rewrite the whole table). So this is FAIL-LOUD -- + // if any top-level conjunct cannot be pushed to file pruning, throw rather than silently widen (restores + // the legacy live-rewrite behaviour, which threw, with master's full matrix). + if (parameters.hasWhereCondition()) { + ConnectorExpression where = parameters.getWhereCondition().getExpression(); + List predicates = new IcebergPredicateConverter( + icebergTable.schema(), sessionZone, IcebergPredicateConverter.Mode.REWRITE).convert(where); + if (predicates.size() < countTopLevelConjuncts(where)) { + throw new DorisConnectorException( + "WHERE condition for rewrite_data_files cannot be pushed down to file pruning: " + where); + } + for (Expression predicate : predicates) { + tableScan = tableScan.filter(predicate); + } + } + + // Ignore residuals to avoid reading data files unnecessarily + tableScan = tableScan.ignoreResiduals(); + + return tableScan.planFiles(); + } + + /** + * Number of top-level conjuncts in a neutral WHERE expression — a top-level {@link ConnectorAnd}'s conjunct + * count, else 1. The fully-pushable invariant compares this against the converter's output size: + * {@link IcebergPredicateConverter#convert} flattens a top-level AND and emits one iceberg expression per + * pushable conjunct, so {@code output.size() < topLevelConjuncts} means at least one conjunct was dropped. + */ + private static int countTopLevelConjuncts(ConnectorExpression where) { + return where instanceof ConnectorAnd ? ((ConnectorAnd) where).getConjuncts().size() : 1; + } + + /** + * Filter files based on rewrite criteria + */ + private Iterable filterFiles(Iterable tasks) { + return Iterables.filter(tasks, this::shouldRewriteFile); + } + + /** + * Check if a file should be rewritten + */ + private boolean shouldRewriteFile(FileScanTask task) { + return outsideDesiredFileSizeRange(task) || tooManyDeletes(task) || tooHighDeleteRatio(task); + } + + /** + * Check if file is outside desired size range + */ + private boolean outsideDesiredFileSizeRange(FileScanTask task) { + long fileSize = task.file().fileSizeInBytes(); + return fileSize < parameters.getMinFileSizeBytes() || fileSize > parameters.getMaxFileSizeBytes(); + } + + /** + * Check if file has too many delete files + */ + private boolean tooManyDeletes(FileScanTask task) { + if (task.deletes() == null) { + return false; + } + return task.deletes().size() >= parameters.getDeleteFileThreshold(); + } + + /** + * Check if file has too high delete ratio + */ + private boolean tooHighDeleteRatio(FileScanTask task) { + if (task.deletes() == null || task.deletes().isEmpty()) { + return false; + } + + long recordCount = task.file().recordCount(); + if (recordCount == 0) { + return false; + } + + // Calculate known deleted record count (only file-scoped deletes) + long knownDeletedRecordCount = task.deletes().stream() + .filter(ContentFileUtil::isFileScoped) + .mapToLong(ContentFile::recordCount) + .sum(); + + // Calculate delete ratio + double deletedRecords = (double) Math.min(knownDeletedRecordCount, recordCount); + double deleteRatio = deletedRecords / recordCount; + + return deleteRatio >= parameters.getDeleteRatioThreshold(); + } + + /** + * Returns a map from partition to list of file scan tasks in that partition. + */ + private Map> groupTasksByPartition(Iterable allTasks) { + Map> filesByPartition = new HashMap<>(); + for (FileScanTask task : allTasks) { + PartitionSpec spec = task.spec(); + StructLikeWrapper partitionWrapper = StructLikeWrapper.forType(spec.partitionType()); + + // If a task uses an incompatible partition spec, treat it as un-partitioned + // by using an empty partition (all null values) + StructLikeWrapper partition; + if (task.file().specId() == spec.specId()) { + partition = partitionWrapper.copyFor(task.file().partition()); + } else { + // Use empty partition for incompatible spec + // Create an empty GenericRecord with all null values + org.apache.iceberg.StructLike emptyStruct = GenericRecord.create(spec.partitionType()); + partition = partitionWrapper.copyFor(emptyStruct); + } + + filesByPartition.computeIfAbsent(partition, k -> Lists.newArrayList()).add(task); + } + return filesByPartition; + } + + /** + * Pack files in a partition using bin-packing strategy. + *

    + * This method is used to group files in a partition using bin-packing strategy. + * It first filters files if not rewriteAll, then uses bin-packing to group + * files based on their size, and then converts the groups to RewriteDataGroup. + * Finally, it filters groups if not rewriteAll. + *

    + */ + private List packGroupsInPartition(List tasks) { + // Step 1: Filter files if not rewriteAll + Iterable filteredTasks = parameters.isRewriteAll() ? tasks : filterFiles(tasks); + + // Step 2: Use bin-packing to group files + BinPacking.ListPacker packer = new BinPacking.ListPacker<>( + parameters.getMaxFileGroupSizeBytes(), + 1, // lookback: number of bins to look back when packing + false // largestBinFirst: whether to prefer larger bins + ); + + // Pack files using file size as weight + List> groups = packer.pack(filteredTasks, task -> task.file().fileSizeInBytes()); + + // Step 3: Convert to RewriteDataGroup + List rewriteDataGroups = groups.stream() + .map(RewriteDataGroup::new) + .collect(Collectors.toList()); + + // Step 4: Filter groups if not rewriteAll + return parameters.isRewriteAll() ? rewriteDataGroups : filterFileGroups(rewriteDataGroups); + } + + /** + * Filter file groups based on rewrite parameters. + * Only groups that meet the rewrite criteria are kept. + */ + private List filterFileGroups(List groups) { + return groups.stream() + .filter(this::shouldRewriteFileGroup) + .collect(Collectors.toList()); + } + + /** + * Check if a file group should be rewritten based on parameters. + */ + private boolean shouldRewriteFileGroup(RewriteDataGroup group) { + return hasEnoughInputFiles(group) || hasEnoughContent(group) + || hasTooMuchContent(group) || hasDeleteIssues(group); + } + + /** + * Check if group has enough input files + */ + private boolean hasEnoughInputFiles(RewriteDataGroup group) { + return group.getTaskCount() > 1 && group.getTaskCount() >= parameters.getMinInputFiles(); + } + + /** + * Check if group has enough content + */ + private boolean hasEnoughContent(RewriteDataGroup group) { + return group.getTaskCount() > 1 && group.getTotalSize() > parameters.getTargetFileSizeBytes(); + } + + /** + * Check if group has too much content + */ + private boolean hasTooMuchContent(RewriteDataGroup group) { + return group.getTotalSize() > parameters.getMaxFileGroupSizeBytes(); + } + + /** + * Check if any file in the group has too many deletes or high delete ratio + */ + private boolean hasDeleteIssues(RewriteDataGroup group) { + return group.getTasks().stream() + .anyMatch(task -> tooManyDeletes(task) || tooHighDeleteRatio(task)); + } + + /** + * Parameters for Iceberg data file rewrite operation + */ + public static class Parameters { + private final long targetFileSizeBytes; + private final long minFileSizeBytes; + private final long maxFileSizeBytes; + private final int minInputFiles; + private final boolean rewriteAll; + private final long maxFileGroupSizeBytes; + private final int deleteFileThreshold; + private final double deleteRatioThreshold; + + // Engine-lowered WHERE predicate (over the target table's own columns), or null when none. + private final ConnectorPredicate whereCondition; + + public Parameters( + long targetFileSizeBytes, + long minFileSizeBytes, + long maxFileSizeBytes, + int minInputFiles, + boolean rewriteAll, + long maxFileGroupSizeBytes, + int deleteFileThreshold, + double deleteRatioThreshold, + long outputSpecId, + ConnectorPredicate whereCondition) { + this.targetFileSizeBytes = targetFileSizeBytes; + this.minFileSizeBytes = minFileSizeBytes; + this.maxFileSizeBytes = maxFileSizeBytes; + this.minInputFiles = minInputFiles; + this.rewriteAll = rewriteAll; + this.maxFileGroupSizeBytes = maxFileGroupSizeBytes; + this.deleteFileThreshold = deleteFileThreshold; + this.deleteRatioThreshold = deleteRatioThreshold; + // outputSpecId is accepted but unused (verbatim with legacy: the field is never stored or read). + this.whereCondition = whereCondition; + } + + public long getTargetFileSizeBytes() { + return targetFileSizeBytes; + } + + public long getMinFileSizeBytes() { + return minFileSizeBytes; + } + + public long getMaxFileSizeBytes() { + return maxFileSizeBytes; + } + + public int getMinInputFiles() { + return minInputFiles; + } + + public boolean isRewriteAll() { + return rewriteAll; + } + + public long getMaxFileGroupSizeBytes() { + return maxFileGroupSizeBytes; + } + + public int getDeleteFileThreshold() { + return deleteFileThreshold; + } + + public double getDeleteRatioThreshold() { + return deleteRatioThreshold; + } + + public boolean hasWhereCondition() { + return whereCondition != null && whereCondition.getExpression() != null; + } + + public ConnectorPredicate getWhereCondition() { + return whereCondition; + } + + @Override + public String toString() { + return "RewriteDataFilesParameters{" + + ", targetFileSizeBytes=" + targetFileSizeBytes + + ", minFileSizeBytes=" + minFileSizeBytes + + ", maxFileSizeBytes=" + maxFileSizeBytes + + ", minInputFiles=" + minInputFiles + + ", rewriteAll=" + rewriteAll + + ", maxFileGroupSizeBytes=" + maxFileGroupSizeBytes + + ", deleteFileThreshold=" + deleteFileThreshold + + ", deleteRatioThreshold=" + deleteRatioThreshold + + ", hasWhereCondition=" + hasWhereCondition() + + '}'; + } + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteDataGroup.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/rewrite/RewriteDataGroup.java similarity index 86% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteDataGroup.java rename to fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/rewrite/RewriteDataGroup.java index 26058ec8f93188..6f3d8f24fe0d20 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteDataGroup.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/rewrite/RewriteDataGroup.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource.iceberg.rewrite; +package org.apache.doris.connector.iceberg.rewrite; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileScanTask; @@ -24,7 +24,12 @@ import java.util.List; /** - * Group of file scan tasks to be rewritten together + * Group of file scan tasks to be rewritten together. + * + *

    Verbatim connector port of fe-core {@code datasource/iceberg/rewrite/RewriteDataGroup} (a dependency-free + * POJO — only the iceberg SDK + JDK). The legacy copy stays consumed by the fe-core rewrite executor until + * P6.7; this copy is the connector-side bin-pack group for the {@code rewrite_data_files} planning half + * (P6.4-T05).

    */ public class RewriteDataGroup { private final List tasks; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteResult.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/rewrite/RewriteResult.java similarity index 89% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteResult.java rename to fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/rewrite/RewriteResult.java index c47ad89fbcc615..8282a82f1caffe 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteResult.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/rewrite/RewriteResult.java @@ -15,14 +15,18 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource.iceberg.rewrite; +package org.apache.doris.connector.iceberg.rewrite; import com.google.common.collect.Lists; import java.util.List; /** - * Result of Iceberg data file rewrite operation + * Result of Iceberg data file rewrite operation. + * + *

    Verbatim connector port of fe-core {@code datasource/iceberg/rewrite/RewriteResult} (a dependency-free + * POJO — only Guava + JDK). The legacy copy stays consumed by the fe-core rewrite executor until P6.7; this + * copy is the connector-side carrier for the {@code rewrite_data_files} planning half (P6.4-T05).

    */ public class RewriteResult { private int rewrittenDataFilesCount; diff --git a/fe/fe-core/src/main/java/org/apache/iceberg/DeleteFileIndex.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/iceberg/DeleteFileIndex.java similarity index 97% rename from fe/fe-core/src/main/java/org/apache/iceberg/DeleteFileIndex.java rename to fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/iceberg/DeleteFileIndex.java index 36cf36b556d098..5f997bdfaddc79 100644 --- a/fe/fe-core/src/main/java/org/apache/iceberg/DeleteFileIndex.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/iceberg/DeleteFileIndex.java @@ -66,6 +66,16 @@ * * Copied from https://github.com/apache/iceberg/blob/apache-iceberg-1.9.1/core/src/main/java/org/apache/iceberg/DeleteFileIndex.java * Change DeleteFileIndex and some methods to public. + * + *

    P6.2-T08 (catalog-spi): VENDORED into the iceberg connector module (mirrors the identical fe-core copy) + * because iceberg 1.10.1 made {@code org.apache.iceberg.DeleteFileIndex} package-private, and the connector — + * which cannot import fe-core — needs it to drive {@code IcebergScanPlanProvider}'s manifest-level scan + * planning (the path that consumes the connector-owned {@code IcebergManifestCache}). Being declared in package + * {@code org.apache.iceberg} gives it package access to the SDK internals it depends on + * ({@code ManifestEntry}, {@code PartitionMap}, ...). The connector only invokes the + * {@link #builderFor(Iterable)} (already-loaded delete files) path; the {@code builderFor(FileIO, Iterable)} + * manifest-reading path (which uses Caffeine) is retained verbatim for the split-package shadowing of the + * SDK's own usage. */ public class DeleteFileIndex { private static final DeleteFile[] EMPTY_DELETES = new DeleteFile[0]; diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/AwsCredentialsProviderModesTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/AwsCredentialsProviderModesTest.java new file mode 100644 index 00000000000000..177d2d3aa67351 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/AwsCredentialsProviderModesTest.java @@ -0,0 +1,137 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider; +import software.amazon.awssdk.auth.credentials.ContainerCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider; +import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider; +import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider; +import software.amazon.awssdk.auth.credentials.WebIdentityTokenFileCredentialsProvider; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * F14: pins {@link AwsCredentialsProviderModes} — the connector's self-contained twin of legacy + * {@code AwsCredentialsProviderFactory.getV2ClassName / createV2}. Without it, a flipped iceberg catalog with a + * non-DEFAULT {@code s3.credentials_provider_type} (e.g. ANONYMOUS for a public bucket, or a forced + * WEB_IDENTITY) silently dropped the pin and fell back to the SDK default chain. + */ +public class AwsCredentialsProviderModesTest { + + private static Map mode(String value) { + Map props = new HashMap<>(); + props.put("s3.credentials_provider_type", value); + return props; + } + + @Test + public void classNameForMapsEachNonDefaultModeToItsAwsSdkClass() { + // MUTATION: dropping/renaming any case, or returning a wrong FQCN, -> the iceberg SDK cannot reflectively + // load the provider (or loads the wrong one) -> red. Uses .class.getName() (byte-identical to legacy + // getV2ClassName, which also uses .class.getName()). + Assertions.assertEquals(EnvironmentVariableCredentialsProvider.class.getName(), + AwsCredentialsProviderModes.classNameFor(mode("ENV"), AwsCredentialsProviderModes.S3_MODE_KEYS)); + Assertions.assertEquals(SystemPropertyCredentialsProvider.class.getName(), + AwsCredentialsProviderModes.classNameFor(mode("SYSTEM_PROPERTIES"), + AwsCredentialsProviderModes.S3_MODE_KEYS)); + Assertions.assertEquals(WebIdentityTokenFileCredentialsProvider.class.getName(), + AwsCredentialsProviderModes.classNameFor(mode("WEB_IDENTITY"), + AwsCredentialsProviderModes.S3_MODE_KEYS)); + Assertions.assertEquals(ContainerCredentialsProvider.class.getName(), + AwsCredentialsProviderModes.classNameFor(mode("CONTAINER"), + AwsCredentialsProviderModes.S3_MODE_KEYS)); + Assertions.assertEquals(InstanceProfileCredentialsProvider.class.getName(), + AwsCredentialsProviderModes.classNameFor(mode("INSTANCE_PROFILE"), + AwsCredentialsProviderModes.S3_MODE_KEYS)); + Assertions.assertEquals(AnonymousCredentialsProvider.class.getName(), + AwsCredentialsProviderModes.classNameFor(mode("ANONYMOUS"), + AwsCredentialsProviderModes.S3_MODE_KEYS)); + } + + @Test + public void classNameForYieldsNullForDefaultBlankAndAbsent() { + // DEFAULT / blank / unknown / absent -> null so the caller emits NOTHING (SDK default chain) — mirrors + // legacy putCredentialsProvider's early DEFAULT return. MUTATION: returning a class for DEFAULT -> the + // common case wrongly pins DefaultCredentialsProvider by name -> red. + Assertions.assertNull(AwsCredentialsProviderModes.classNameFor(mode("DEFAULT"), + AwsCredentialsProviderModes.S3_MODE_KEYS)); + Assertions.assertNull(AwsCredentialsProviderModes.classNameFor(mode(" "), + AwsCredentialsProviderModes.S3_MODE_KEYS)); + Assertions.assertNull(AwsCredentialsProviderModes.classNameFor(mode("bogus"), + AwsCredentialsProviderModes.S3_MODE_KEYS)); + Assertions.assertNull(AwsCredentialsProviderModes.classNameFor(Collections.emptyMap(), + AwsCredentialsProviderModes.S3_MODE_KEYS)); + Assertions.assertNull(AwsCredentialsProviderModes.classNameFor(null, + AwsCredentialsProviderModes.S3_MODE_KEYS)); + } + + @Test + public void resolveModeNormalizesCaseAndHyphenAndPicksFirstNonBlankKey() { + // Legacy AwsCredentialsProviderMode.fromString normalization: trim / toUpperCase / '-' -> '_'. + // MUTATION: dropping any normalization step -> "web-identity" / " anonymous " no longer resolve -> red. + Assertions.assertEquals(WebIdentityTokenFileCredentialsProvider.class.getName(), + AwsCredentialsProviderModes.classNameFor(mode("web-identity"), + AwsCredentialsProviderModes.S3_MODE_KEYS)); + Assertions.assertEquals(AnonymousCredentialsProvider.class.getName(), + AwsCredentialsProviderModes.classNameFor(mode(" anonymous "), + AwsCredentialsProviderModes.S3_MODE_KEYS)); + // First non-blank alias wins: blank primary key, value on the second alias. + Map props = new HashMap<>(); + props.put("s3.credentials_provider_type", ""); + props.put("glue.credentials_provider_type", "ENV"); + Assertions.assertEquals(EnvironmentVariableCredentialsProvider.class.getName(), + AwsCredentialsProviderModes.classNameFor(props, AwsCredentialsProviderModes.S3_MODE_KEYS)); + // The iceberg.rest.credentials_provider_type alias MUST be in S3_MODE_KEYS: master binds + // S3Properties.credentialsProviderType from it, so a glue/s3tables catalog can carry the mode there. + // MUTATION: dropping that alias from S3_MODE_KEYS -> the pin silently degrades to the default chain -> red. + Map restAlias = new HashMap<>(); + restAlias.put("iceberg.rest.credentials_provider_type", "ANONYMOUS"); + Assertions.assertEquals(AnonymousCredentialsProvider.class.getName(), + AwsCredentialsProviderModes.classNameFor(restAlias, AwsCredentialsProviderModes.S3_MODE_KEYS)); + } + + @Test + public void providerForReturnsTheMatchingProviderInstanceAndDefaultsOtherwise() { + // The s3tables control-plane path needs a live provider instance, not a class name. providerFor is a + // SECOND switch, independent of classFor, so cover ALL six non-DEFAULT modes. MUTATION: swapping/dropping + // any case -> the wrong provider (e.g. default where anonymous was requested) -> red. + Assertions.assertTrue(AwsCredentialsProviderModes.providerFor(mode("ENV"), + AwsCredentialsProviderModes.S3_MODE_KEYS) instanceof EnvironmentVariableCredentialsProvider); + Assertions.assertTrue(AwsCredentialsProviderModes.providerFor(mode("SYSTEM_PROPERTIES"), + AwsCredentialsProviderModes.S3_MODE_KEYS) instanceof SystemPropertyCredentialsProvider); + Assertions.assertTrue(AwsCredentialsProviderModes.providerFor(mode("WEB_IDENTITY"), + AwsCredentialsProviderModes.S3_MODE_KEYS) instanceof WebIdentityTokenFileCredentialsProvider); + Assertions.assertTrue(AwsCredentialsProviderModes.providerFor(mode("CONTAINER"), + AwsCredentialsProviderModes.S3_MODE_KEYS) instanceof ContainerCredentialsProvider); + Assertions.assertTrue(AwsCredentialsProviderModes.providerFor(mode("INSTANCE_PROFILE"), + AwsCredentialsProviderModes.S3_MODE_KEYS) instanceof InstanceProfileCredentialsProvider); + Assertions.assertTrue(AwsCredentialsProviderModes.providerFor(mode("ANONYMOUS"), + AwsCredentialsProviderModes.S3_MODE_KEYS) instanceof AnonymousCredentialsProvider); + // DEFAULT / blank / absent -> the SDK default chain. + Assertions.assertTrue(AwsCredentialsProviderModes.providerFor(mode("DEFAULT"), + AwsCredentialsProviderModes.S3_MODE_KEYS) instanceof DefaultCredentialsProvider); + Assertions.assertTrue(AwsCredentialsProviderModes.providerFor(Collections.emptyMap(), + AwsCredentialsProviderModes.S3_MODE_KEYS) instanceof DefaultCredentialsProvider); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsColumnEvolutionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsColumnEvolutionTest.java new file mode 100644 index 00000000000000..69b69c4eeec0d5 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsColumnEvolutionTest.java @@ -0,0 +1,360 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.ddl.ConnectorColumnPosition; +import org.apache.doris.connector.iceberg.IcebergCatalogOps.CatalogBackedIcebergCatalogOps; + +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +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.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * End-to-end seam tests for the B2 column-evolution methods on {@link CatalogBackedIcebergCatalogOps}, against a + * REAL iceberg {@link InMemoryCatalog} (no Mockito). Proves the {@code UpdateSchema} build+commit actually + * mutates the persisted schema (add at FIRST/AFTER/end, drop, rename, modify type/comment/nullability, reorder) + * and that the validation guards (optional→required, missing column) fail loud. + */ +public class CatalogBackedIcebergCatalogOpsColumnEvolutionTest { + + private InMemoryCatalog catalog; + private CatalogBackedIcebergCatalogOps ops; + + @BeforeEach + public void setUp() { + catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + ops = new CatalogBackedIcebergCatalogOps(catalog); + ops.createDatabase("db1", Collections.emptyMap()); + // id BIGINT (optional), val INT (optional), name VARCHAR (optional, doc "old"), req INT (required) + Schema schema = IcebergSchemaBuilder.buildSchema(Arrays.asList( + new ConnectorColumn("id", ConnectorType.of("BIGINT"), "", true, null, false), + new ConnectorColumn("val", ConnectorType.of("INT"), "", true, null, false), + new ConnectorColumn("name", ConnectorType.of("VARCHAR", 50, 0), "old", true, null, false), + new ConnectorColumn("req", ConnectorType.of("INT"), "", false, null, false))); + ops.createTable("db1", "t1", schema, PartitionSpec.unpartitioned(), null, + IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap())); + } + + @AfterEach + public void tearDown() throws Exception { + catalog.close(); + } + + private Schema reload() { + return ops.loadTable("db1", "t1").schema(); + } + + private List columnOrder() { + return reload().columns().stream().map(Types.NestedField::name).collect(Collectors.toList()); + } + + private static IcebergColumnChange change(String name, Type type, String comment, boolean nullable) { + return new IcebergColumnChange(name, type, comment, null, nullable); + } + + // ---------- addColumn ---------- + + @Test + public void testAddColumnAtEnd() { + ops.addColumn("db1", "t1", change("age", Types.IntegerType.get(), "c", true), null); + Assertions.assertNotNull(reload().findField("age")); + Assertions.assertEquals("age", columnOrder().get(columnOrder().size() - 1)); + } + + @Test + public void testAddColumnFirst() { + ops.addColumn("db1", "t1", change("age", Types.IntegerType.get(), "c", true), + ConnectorColumnPosition.FIRST); + Assertions.assertEquals("age", columnOrder().get(0)); + } + + @Test + public void testAddColumnAfter() { + ops.addColumn("db1", "t1", change("age", Types.IntegerType.get(), "c", true), + ConnectorColumnPosition.after("id")); + Assertions.assertEquals(Arrays.asList("id", "age"), columnOrder().subList(0, 2)); + } + + // ---------- addColumns ---------- + + @Test + public void testAddColumns() { + ops.addColumns("db1", "t1", Arrays.asList( + change("a", Types.IntegerType.get(), "c", true), + change("b", Types.StringType.get(), "c", true))); + Assertions.assertNotNull(reload().findField("a")); + Assertions.assertNotNull(reload().findField("b")); + } + + // ---------- dropColumn / renameColumn ---------- + + @Test + public void testDropColumn() { + ops.dropColumn("db1", "t1", "val"); + Assertions.assertNull(reload().findField("val")); + } + + @Test + public void testRenameColumn() { + ops.renameColumn("db1", "t1", "name", "full_name"); + Assertions.assertNull(reload().findField("name")); + Assertions.assertNotNull(reload().findField("full_name")); + } + + // ---------- modifyColumn ---------- + + @Test + public void testModifyColumnWidensType() { + ops.modifyColumn("db1", "t1", change("val", Types.LongType.get(), "c", true), null); + Assertions.assertEquals(Type.TypeID.LONG, reload().findField("val").type().typeId()); + } + + @Test + public void testModifyColumnCommentOnly() { + // VARCHAR maps to iceberg STRING; re-sending the same type with a new doc updates only the comment. + ops.modifyColumn("db1", "t1", change("name", Types.StringType.get(), "new comment", true), null); + Assertions.assertEquals("new comment", reload().findField("name").doc()); + Assertions.assertEquals(Type.TypeID.STRING, reload().findField("name").type().typeId()); + } + + @Test + public void testModifyColumnRequiredToOptional() { + Assertions.assertFalse(reload().findField("req").isOptional()); + ops.modifyColumn("db1", "t1", change("req", Types.IntegerType.get(), null, true), null); + Assertions.assertTrue(reload().findField("req").isOptional()); + } + + @Test + public void testModifyColumnRepositions() { + ops.modifyColumn("db1", "t1", change("name", Types.StringType.get(), "c", true), + ConnectorColumnPosition.FIRST); + Assertions.assertEquals("name", columnOrder().get(0)); + } + + @Test + public void testModifyColumnOptionalToRequiredFailsLoud() { + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> ops.modifyColumn("db1", "t1", change("id", Types.LongType.get(), null, false), null)); + Assertions.assertTrue(ex.getMessage().contains("not null")); + // schema unchanged: id stays optional. + Assertions.assertTrue(reload().findField("id").isOptional()); + } + + @Test + public void testModifyMissingColumnFailsLoud() { + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> ops.modifyColumn("db1", "t1", change("ghost", Types.IntegerType.get(), null, true), null)); + Assertions.assertTrue(ex.getMessage().contains("does not exist")); + } + + // ---------- reorderColumns ---------- + + @Test + public void testReorderColumns() { + ops.reorderColumns("db1", "t1", Arrays.asList("name", "req", "id", "val")); + Assertions.assertEquals(Arrays.asList("name", "req", "id", "val"), columnOrder()); + } + + // ---------- modifyColumn: complex types (B2b) — diffs the new type against the current one ---------- + + private Schema reload(String table) { + return ops.loadTable("db1", table).schema(); + } + + /** Creates db1.

    with a single column built from {@code col}. */ + private void createTable(String table, ConnectorColumn col) { + ops.createTable("db1", table, + IcebergSchemaBuilder.buildSchema(Collections.singletonList(col)), + PartitionSpec.unpartitioned(), null, + IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap())); + } + + /** Modifies db1.
    . to the (top-level nullable) complex {@code newType}. */ + private void modifyComplex(String table, String colName, ConnectorType newType, boolean topNullable) { + ops.modifyColumn("db1", table, + new IcebergColumnChange(colName, IcebergSchemaBuilder.buildColumnType(newType), null, null, + topNullable), null); + } + + private static ConnectorType structType(List names, List types, + List nullable, List comments) { + return ConnectorType.structOf(names, types, nullable, comments); + } + + @Test + public void testModifyStructAddsNullableField() { + createTable("s_add", new ConnectorColumn("st", + structType(Arrays.asList("a"), Arrays.asList(ConnectorType.of("INT")), + Arrays.asList(true), Arrays.asList((String) null)), "", true, null, false)); + modifyComplex("s_add", "st", + structType(Arrays.asList("a", "b"), + Arrays.asList(ConnectorType.of("INT"), ConnectorType.of("STRING")), + Arrays.asList(true, true), Arrays.asList(null, "the b")), true); + Types.StructType st = reload("s_add").findField("st").type().asStructType(); + Assertions.assertEquals(2, st.fields().size()); + Assertions.assertEquals("b", st.fields().get(1).name()); + Assertions.assertEquals(Type.TypeID.STRING, st.fields().get(1).type().typeId()); + Assertions.assertTrue(st.fields().get(1).isOptional()); + Assertions.assertEquals("the b", st.fields().get(1).doc()); + } + + @Test + public void testModifyStructWidensFieldTypeAndComment() { + createTable("s_widen", new ConnectorColumn("st", + structType(Arrays.asList("a"), Arrays.asList(ConnectorType.of("INT")), + Arrays.asList(true), Arrays.asList("old")), "", true, null, false)); + modifyComplex("s_widen", "st", + structType(Arrays.asList("a"), Arrays.asList(ConnectorType.of("BIGINT")), + Arrays.asList(true), Arrays.asList("new")), true); + Types.NestedField a = reload("s_widen").findField("st").type().asStructType().fields().get(0); + Assertions.assertEquals(Type.TypeID.LONG, a.type().typeId()); + Assertions.assertEquals("new", a.doc()); + } + + @Test + public void testModifyStructFieldWidensNotNullToNullable() { + createTable("s_null", new ConnectorColumn("st", + structType(Arrays.asList("a"), Arrays.asList(ConnectorType.of("INT")), + Arrays.asList(false), Arrays.asList((String) null)), "", true, null, false)); + Assertions.assertTrue(reload("s_null").findField("st").type().asStructType().fields().get(0).isRequired()); + modifyComplex("s_null", "st", + structType(Arrays.asList("a"), Arrays.asList(ConnectorType.of("INT")), + Arrays.asList(true), Arrays.asList((String) null)), true); + Assertions.assertTrue(reload("s_null").findField("st").type().asStructType().fields().get(0).isOptional()); + } + + @Test + public void testModifyStructNarrowToNotNullFailsLoud() { + createTable("s_narrow", new ConnectorColumn("st", + structType(Arrays.asList("a"), Arrays.asList(ConnectorType.of("INT")), + Arrays.asList(true), Arrays.asList((String) null)), "", true, null, false)); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> modifyComplex("s_narrow", "st", + structType(Arrays.asList("a"), Arrays.asList(ConnectorType.of("INT")), + Arrays.asList(false), Arrays.asList((String) null)), true)); + Assertions.assertTrue(ex.getMessage().contains("not null")); + Assertions.assertTrue(reload("s_narrow").findField("st").type().asStructType().fields().get(0).isOptional()); + } + + @Test + public void testModifyStructReduceFieldsFailsLoud() { + createTable("s_reduce", new ConnectorColumn("st", + structType(Arrays.asList("a", "b"), + Arrays.asList(ConnectorType.of("INT"), ConnectorType.of("STRING")), + Arrays.asList(true, true), Arrays.asList(null, null)), "", true, null, false)); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> modifyComplex("s_reduce", "st", + structType(Arrays.asList("a"), Arrays.asList(ConnectorType.of("INT")), + Arrays.asList(true), Arrays.asList((String) null)), true)); + Assertions.assertTrue(ex.getMessage().contains("reduce")); + Assertions.assertEquals(2, reload("s_reduce").findField("st").type().asStructType().fields().size()); + } + + @Test + public void testModifyStructRenameFieldFailsLoud() { + createTable("s_rename", new ConnectorColumn("st", + structType(Arrays.asList("a"), Arrays.asList(ConnectorType.of("INT")), + Arrays.asList(true), Arrays.asList((String) null)), "", true, null, false)); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> modifyComplex("s_rename", "st", + structType(Arrays.asList("c"), Arrays.asList(ConnectorType.of("INT")), + Arrays.asList(true), Arrays.asList((String) null)), true)); + Assertions.assertTrue(ex.getMessage().contains("rename struct field")); + } + + @Test + public void testModifyStructNewFieldNotNullableFailsLoud() { + createTable("s_newreq", new ConnectorColumn("st", + structType(Arrays.asList("a"), Arrays.asList(ConnectorType.of("INT")), + Arrays.asList(true), Arrays.asList((String) null)), "", true, null, false)); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> modifyComplex("s_newreq", "st", + structType(Arrays.asList("a", "b"), + Arrays.asList(ConnectorType.of("INT"), ConnectorType.of("STRING")), + Arrays.asList(true, false), Arrays.asList(null, null)), true)); + Assertions.assertTrue(ex.getMessage().contains("must be nullable")); + } + + @Test + public void testModifyNestedDecimalPrecisionFailsLoud() { + // Legacy parity: a nested primitive change is restricted to int->long / float->double / exact; a + // DECIMAL precision change inside a struct is rejected (checkSupportSchemaChangeForNestedPrimitive). + createTable("s_dec", new ConnectorColumn("st", + structType(Arrays.asList("a"), Arrays.asList(ConnectorType.of("DECIMALV3", 10, 2)), + Arrays.asList(true), Arrays.asList((String) null)), "", true, null, false)); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> modifyComplex("s_dec", "st", + structType(Arrays.asList("a"), Arrays.asList(ConnectorType.of("DECIMALV3", 20, 2)), + Arrays.asList(true), Arrays.asList((String) null)), true)); + Assertions.assertTrue(ex.getMessage().contains("nested")); + } + + @Test + public void testModifyArrayElementWidens() { + createTable("a_widen", new ConnectorColumn("arr", + ConnectorType.arrayOf(ConnectorType.of("INT")), "", true, null, false)); + modifyComplex("a_widen", "arr", ConnectorType.arrayOf(ConnectorType.of("BIGINT")), true); + Assertions.assertEquals(Type.TypeID.LONG, + reload("a_widen").findField("arr").type().asListType().elementType().typeId()); + } + + @Test + public void testModifyMapValueWidens() { + createTable("m_widen", new ConnectorColumn("m", + ConnectorType.mapOf(ConnectorType.of("STRING"), ConnectorType.of("INT")), "", true, null, false)); + modifyComplex("m_widen", "m", + ConnectorType.mapOf(ConnectorType.of("STRING"), ConnectorType.of("BIGINT")), true); + Assertions.assertEquals(Type.TypeID.LONG, + reload("m_widen").findField("m").type().asMapType().valueType().typeId()); + } + + @Test + public void testModifyMapKeyChangeFailsLoud() { + createTable("m_key", new ConnectorColumn("m", + ConnectorType.mapOf(ConnectorType.of("STRING"), ConnectorType.of("INT")), "", true, null, false)); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> modifyComplex("m_key", "m", + ConnectorType.mapOf(ConnectorType.of("BIGINT"), ConnectorType.of("INT")), true)); + Assertions.assertTrue(ex.getMessage().contains("MAP key")); + } + + @Test + public void testModifyComplexCategoryMismatchFailsLoud() { + createTable("c_cat", new ConnectorColumn("st", + structType(Arrays.asList("a"), Arrays.asList(ConnectorType.of("INT")), + Arrays.asList(true), Arrays.asList((String) null)), "", true, null, false)); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> modifyComplex("c_cat", "st", ConnectorType.arrayOf(ConnectorType.of("INT")), true)); + Assertions.assertTrue(ex.getMessage().contains("category")); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsDdlTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsDdlTest.java new file mode 100644 index 00000000000000..f06bd2119c782e --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsDdlTest.java @@ -0,0 +1,538 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorColumn; +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.ConnectorSortField; +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.iceberg.IcebergCatalogOps.CatalogBackedIcebergCatalogOps; + +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.PartitionField; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.types.Type; +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.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +/** + * End-to-end seam tests for the B1 DDL methods on {@link CatalogBackedIcebergCatalogOps}, exercised against a + * REAL iceberg {@link InMemoryCatalog} (no Mockito). Proves the thin delegations create/drop real namespaces + + * tables and that the location helpers read back what the catalog persisted. + */ +public class CatalogBackedIcebergCatalogOpsDdlTest { + + private InMemoryCatalog catalog; + private CatalogBackedIcebergCatalogOps ops; + + @BeforeEach + public void setUp() { + catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + ops = new CatalogBackedIcebergCatalogOps(catalog); + } + + @AfterEach + public void tearDown() throws Exception { + catalog.close(); + } + + private static Schema schema() { + return IcebergSchemaBuilder.buildSchema(Arrays.asList( + new ConnectorColumn("id", ConnectorType.of("BIGINT"), "", true, null, false), + new ConnectorColumn("name", ConnectorType.of("VARCHAR", 50, 0), "", true, null, false))); + } + + @Test + public void testCreateAndDropDatabase() { + ops.createDatabase("db1", Collections.emptyMap()); + Assertions.assertTrue(ops.databaseExists("db1")); + Assertions.assertTrue(ops.listDatabaseNames().contains("db1")); + + ops.dropDatabase("db1"); + Assertions.assertFalse(ops.databaseExists("db1")); + } + + @Test + public void testLoadNamespaceLocationReadsBackProperty() { + ops.createDatabase("db1", Collections.singletonMap("location", "s3://wh/db1")); + Optional location = ops.loadNamespaceLocation("db1"); + Assertions.assertTrue(location.isPresent()); + Assertions.assertEquals("s3://wh/db1", location.get()); + } + + @Test + public void testLoadNamespaceLocationAbsentWhenUnset() { + ops.createDatabase("db1", Collections.emptyMap()); + Assertions.assertFalse(ops.loadNamespaceLocation("db1").isPresent()); + } + + @Test + public void testCreateAndDropTable() { + ops.createDatabase("db1", Collections.emptyMap()); + Map props = IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap()); + ops.createTable("db1", "t1", schema(), PartitionSpec.unpartitioned(), null, props); + + Assertions.assertTrue(ops.tableExists("db1", "t1")); + // The created table carries our columns + the MOR defaults applied by IcebergSchemaBuilder. + Assertions.assertEquals(Type.TypeID.LONG, ops.loadTable("db1", "t1").schema().findField("id").type().typeId()); + Assertions.assertEquals("merge-on-read", ops.loadTable("db1", "t1").properties().get("write.delete.mode")); + Assertions.assertTrue(ops.loadTableLocation("db1", "t1").isPresent()); + + ops.dropTable("db1", "t1", true); + Assertions.assertFalse(ops.tableExists("db1", "t1")); + } + + @Test + public void testCreateTableWithSortOrder() { + ops.createDatabase("db1", Collections.emptyMap()); + Schema schema = schema(); + SortOrder sortOrder = IcebergSchemaBuilder.buildSortOrder( + Collections.singletonList(new ConnectorSortField("id", true, true)), schema); + ops.createTable("db1", "t1", schema, PartitionSpec.unpartitioned(), sortOrder, + IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap())); + + // The write order is persisted (the buildTable().withSortOrder() path). + Assertions.assertFalse(ops.loadTable("db1", "t1").sortOrder().isUnsorted()); + } + + @Test + public void testCreateTablePartitioned() { + ops.createDatabase("db1", Collections.emptyMap()); + Schema schema = schema(); + PartitionSpec spec = PartitionSpec.builderFor(schema).bucket("id", 8).build(); + ops.createTable("db1", "t1", schema, spec, null, + IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap())); + Assertions.assertFalse(ops.loadTable("db1", "t1").spec().isUnpartitioned()); + } + + @Test + public void testForceDropDatabaseAfterCascade() { + // Mirror the metadata layer's force path: drop the contained tables, then the namespace. + ops.createDatabase("db1", Collections.emptyMap()); + ops.createTable("db1", "t1", schema(), PartitionSpec.unpartitioned(), null, + IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap())); + for (String table : ops.listTableNames("db1")) { + ops.dropTable("db1", table, true); + } + ops.dropDatabase("db1"); + Assertions.assertFalse(ops.databaseExists("db1")); + Assertions.assertFalse(catalog.namespaceExists(Namespace.of("db1"))); + } + + @Test + public void testDropTablePurgeRemovesIdentifier() { + ops.createDatabase("db1", Collections.emptyMap()); + ops.createTable("db1", "t1", schema(), PartitionSpec.unpartitioned(), null, + IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap())); + ops.dropTable("db1", "t1", true); + Assertions.assertFalse(catalog.tableExists(TableIdentifier.of("db1", "t1"))); + } + + @Test + public void testRenameTable() { + ops.createDatabase("db1", Collections.emptyMap()); + ops.createTable("db1", "t1", schema(), PartitionSpec.unpartitioned(), null, + IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap())); + ops.renameTable("db1", "t1", "t2"); + Assertions.assertFalse(ops.tableExists("db1", "t1")); + Assertions.assertTrue(ops.tableExists("db1", "t2")); + // The renamed table keeps its schema (proves it's a real rename, not a recreate). + Assertions.assertEquals(Type.TypeID.LONG, + ops.loadTable("db1", "t2").schema().findField("id").type().typeId()); + } + + @Test + public void testRenameMissingTableFailsLoud() { + ops.createDatabase("db1", Collections.emptyMap()); + Assertions.assertThrows(Exception.class, () -> ops.renameTable("db1", "ghost", "t2")); + } + + // ---------- Branch / tag (B4): real ManageSnapshots round-trips on an InMemoryCatalog ---------- + + /** Creates db1.t1 and seeds {@code snapshots} consecutive snapshots; returns the current snapshot id. */ + private long createTableWithSnapshots(String table, int snapshots) { + ops.createDatabase("db1", Collections.emptyMap()); + ops.createTable("db1", table, schema(), PartitionSpec.unpartitioned(), null, + IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap())); + Table t = ops.loadTable("db1", table); + for (int i = 0; i < snapshots; i++) { + t.newAppend().appendFile(DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("s3://b/db1/" + table + "-" + i + ".parquet") + .withFileSizeInBytes(1024).withRecordCount(1).withFormat(FileFormat.PARQUET).build()) + .commit(); + } + return ops.loadTable("db1", table).currentSnapshot().snapshotId(); + } + + private SnapshotRef ref(String table, String name) { + return ops.loadTable("db1", table).refs().get(name); + } + + @Test + public void testCreateBranchPinsExplicitSnapshot() { + long snap = createTableWithSnapshots("t1", 1); + ops.createOrReplaceBranch("db1", "t1", + new BranchChange("b1", true, false, false, snap, null, null, null)); + SnapshotRef r = ref("t1", "b1"); + Assertions.assertNotNull(r); + Assertions.assertTrue(r.isBranch()); + Assertions.assertEquals(snap, r.snapshotId()); + } + + @Test + public void testCreateBranchNullSnapshotUsesCurrent() { + long current = createTableWithSnapshots("t1", 2); + ops.createOrReplaceBranch("db1", "t1", + new BranchChange("b1", true, false, false, null, null, null, null)); + Assertions.assertEquals(current, ref("t1", "b1").snapshotId()); + } + + @Test + public void testCreateBranchAppliesRetentionOptions() { + long snap = createTableWithSnapshots("t1", 1); + ops.createOrReplaceBranch("db1", "t1", + new BranchChange("b1", true, false, false, snap, 86400000L, 5, 172800000L)); + SnapshotRef r = ref("t1", "b1"); + // retain -> maxSnapshotAgeMs, numSnapshots -> minSnapshotsToKeep, retention -> maxRefAgeMs (legacy mapping). + Assertions.assertEquals(86400000L, r.maxSnapshotAgeMs()); + Assertions.assertEquals(5, r.minSnapshotsToKeep()); + Assertions.assertEquals(172800000L, r.maxRefAgeMs()); + } + + @Test + public void testReplaceBranchRepointsToNewSnapshot() { + long snap1 = createTableWithSnapshots("t1", 1); + ops.createOrReplaceBranch("db1", "t1", + new BranchChange("b1", true, false, false, snap1, null, null, null)); + long snap2 = appendOneSnapshot("t1"); + ops.createOrReplaceBranch("db1", "t1", + new BranchChange("b1", false, true, false, snap2, null, null, null)); + Assertions.assertEquals(snap2, ref("t1", "b1").snapshotId()); + } + + @Test + public void testReplaceBranchOnEmptyTableFailsLoud() { + ops.createDatabase("db1", Collections.emptyMap()); + ops.createTable("db1", "t1", schema(), PartitionSpec.unpartitioned(), null, + IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap())); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> ops.createOrReplaceBranch("db1", "t1", + new BranchChange("b1", false, true, false, null, null, null, null))); + Assertions.assertTrue(ex.getMessage().contains("has no snapshot"), ex.getMessage()); + } + + @Test + public void testCreateBranchIfNotExistsKeepsExistingTarget() { + long snap1 = createTableWithSnapshots("t1", 1); + ops.createOrReplaceBranch("db1", "t1", + new BranchChange("b1", true, false, false, snap1, null, null, null)); + long snap2 = appendOneSnapshot("t1"); + // create IF NOT EXISTS targeting snap2 must NO-OP: the branch keeps pointing at snap1. + ops.createOrReplaceBranch("db1", "t1", + new BranchChange("b1", true, false, true, snap2, null, null, null)); + Assertions.assertEquals(snap1, ref("t1", "b1").snapshotId()); + } + + @Test + public void testCreateBranchEmptyNameFailsLoud() { + createTableWithSnapshots("t1", 1); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> ops.createOrReplaceBranch("db1", "t1", + new BranchChange(" ", true, false, false, null, null, null, null))); + Assertions.assertTrue(ex.getMessage().contains("Branch name cannot be empty"), ex.getMessage()); + } + + @Test + public void testCreateTagPinsSnapshotAndRetention() { + long snap = createTableWithSnapshots("t1", 1); + ops.createOrReplaceTag("db1", "t1", + new TagChange("v1", true, false, false, snap, 99000L)); + SnapshotRef r = ref("t1", "v1"); + Assertions.assertNotNull(r); + Assertions.assertTrue(r.isTag()); + Assertions.assertEquals(snap, r.snapshotId()); + Assertions.assertEquals(99000L, r.maxRefAgeMs()); + } + + @Test + public void testCreateTagNullSnapshotUsesCurrent() { + long current = createTableWithSnapshots("t1", 1); + ops.createOrReplaceTag("db1", "t1", + new TagChange("v1", true, false, false, null, null)); + Assertions.assertEquals(current, ref("t1", "v1").snapshotId()); + } + + @Test + public void testCreateTagOnEmptyTableFailsLoud() { + ops.createDatabase("db1", Collections.emptyMap()); + ops.createTable("db1", "t1", schema(), PartitionSpec.unpartitioned(), null, + IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap())); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> ops.createOrReplaceTag("db1", "t1", + new TagChange("v1", true, false, false, null, null))); + Assertions.assertTrue(ex.getMessage().contains("has no snapshot"), ex.getMessage()); + } + + @Test + public void testReplaceTagRepointsToNewSnapshot() { + long snap1 = createTableWithSnapshots("t1", 1); + ops.createOrReplaceTag("db1", "t1", + new TagChange("v1", true, false, false, snap1, null)); + long snap2 = appendOneSnapshot("t1"); + ops.createOrReplaceTag("db1", "t1", + new TagChange("v1", false, true, false, snap2, null)); + Assertions.assertEquals(snap2, ref("t1", "v1").snapshotId()); + } + + @Test + public void testCreateTagEmptyNameFailsLoud() { + long snap = createTableWithSnapshots("t1", 1); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> ops.createOrReplaceTag("db1", "t1", + new TagChange(" ", true, false, false, snap, null))); + Assertions.assertTrue(ex.getMessage().contains("Tag name cannot be empty"), ex.getMessage()); + } + + @Test + public void testDropBranchRemovesRef() { + long snap = createTableWithSnapshots("t1", 1); + ops.createOrReplaceBranch("db1", "t1", + new BranchChange("b1", true, false, false, snap, null, null, null)); + ops.dropBranch("db1", "t1", new DropRefChange("b1", false)); + Assertions.assertNull(ref("t1", "b1")); + } + + @Test + public void testDropBranchIfExistsMissingIsNoOp() { + createTableWithSnapshots("t1", 1); + // No exception, and "main" (the default branch) is untouched. + ops.dropBranch("db1", "t1", new DropRefChange("ghost", true)); + Assertions.assertNotNull(ref("t1", "main")); + } + + @Test + public void testDropBranchMissingWithoutIfExistsFailsLoud() { + createTableWithSnapshots("t1", 1); + Assertions.assertThrows(Exception.class, + () -> ops.dropBranch("db1", "t1", new DropRefChange("ghost", false))); + } + + @Test + public void testDropTagRemovesRef() { + long snap = createTableWithSnapshots("t1", 1); + ops.createOrReplaceTag("db1", "t1", + new TagChange("v1", true, false, false, snap, null)); + ops.dropTag("db1", "t1", new DropRefChange("v1", false)); + Assertions.assertNull(ref("t1", "v1")); + } + + @Test + public void testDropTagIfExistsMissingIsNoOp() { + createTableWithSnapshots("t1", 1); + ops.dropTag("db1", "t1", new DropRefChange("ghost", true)); + Assertions.assertNotNull(ref("t1", "main")); + } + + /** Appends one more snapshot to an existing db1.{table} and returns the new current snapshot id. */ + private long appendOneSnapshot(String table) { + Table t = ops.loadTable("db1", table); + t.newAppend().appendFile(DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("s3://b/db1/" + table + "-extra-" + t.currentSnapshot().snapshotId() + ".parquet") + .withFileSizeInBytes(1024).withRecordCount(1).withFormat(FileFormat.PARQUET).build()) + .commit(); + return ops.loadTable("db1", table).currentSnapshot().snapshotId(); + } + + // ---------- Partition evolution (B5): real UpdatePartitionSpec round-trips on an InMemoryCatalog ---------- + + /** Creates an EMPTY (no data) unpartitioned db1.{table}. */ + private void createUnpartitionedTable(String table) { + ops.createDatabase("db1", Collections.emptyMap()); + ops.createTable("db1", table, schema(), PartitionSpec.unpartitioned(), null, + IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap())); + } + + /** A partition field is "live" if present in the current spec with a non-void transform. */ + private boolean hasLiveField(String table, String name) { + for (PartitionField f : ops.loadTable("db1", table).spec().fields()) { + if (f.name().equals(name) && !"void".equals(f.transform().toString())) { + return true; + } + } + return false; + } + + /** Whether a live (non-void) partition field whose transform string starts with {@code prefix} exists. */ + private boolean hasLiveTransform(String table, String prefix) { + for (PartitionField f : ops.loadTable("db1", table).spec().fields()) { + String t = f.transform().toString(); + if (!"void".equals(t) && t.startsWith(prefix)) { + return true; + } + } + return false; + } + + /** Number of live (non-void) partition fields in the current spec. */ + private int liveFieldCount(String table) { + int n = 0; + for (PartitionField f : ops.loadTable("db1", table).spec().fields()) { + if (!"void".equals(f.transform().toString())) { + n++; + } + } + return n; + } + + private static PartitionFieldChange add(String transformName, Integer arg, String column, String alias) { + return new PartitionFieldChange(transformName, arg, column, alias, null, null, null, null); + } + + @Test + public void testAddIdentityPartitionField() { + createUnpartitionedTable("t1"); + ops.addPartitionField("db1", "t1", add(null, null, "id", null)); + Assertions.assertTrue(hasLiveField("t1", "id")); + Assertions.assertFalse(ops.loadTable("db1", "t1").spec().isUnpartitioned()); + } + + @Test + public void testAddBucketPartitionFieldWithAlias() { + createUnpartitionedTable("t1"); + ops.addPartitionField("db1", "t1", add("bucket", 8, "id", "id_b")); + Assertions.assertTrue(hasLiveField("t1", "id_b")); + } + + @Test + public void testAddTruncatePartitionField() { + createUnpartitionedTable("t1"); + ops.addPartitionField("db1", "t1", add("truncate", 4, "name", null)); + // Auto-named by iceberg; assert on the transform type (the field carries a truncate transform). + Assertions.assertTrue(hasLiveTransform("t1", "truncate")); + } + + @Test + public void testDropPartitionFieldByName() { + createUnpartitionedTable("t1"); + ops.addPartitionField("db1", "t1", add(null, null, "id", "p_id")); + Assertions.assertTrue(hasLiveField("t1", "p_id")); + ops.dropPartitionField("db1", "t1", new PartitionFieldChange(null, null, null, "p_id", + null, null, null, null)); + Assertions.assertFalse(hasLiveField("t1", "p_id")); + } + + @Test + public void testDropPartitionFieldByTransform() { + createUnpartitionedTable("t1"); + ops.addPartitionField("db1", "t1", add("bucket", 8, "id", null)); + Assertions.assertTrue(hasLiveTransform("t1", "bucket")); + // Drop by the SAME transform that identifies the field (partitionFieldName == null path). + ops.dropPartitionField("db1", "t1", add("bucket", 8, "id", null)); + Assertions.assertFalse(hasLiveTransform("t1", "bucket")); + Assertions.assertEquals(0, liveFieldCount("t1")); + } + + @Test + public void testReplacePartitionFieldByName() { + createUnpartitionedTable("t1"); + ops.addPartitionField("db1", "t1", add(null, null, "id", "p")); + // Replace old field "p" with a NEW bucket(8) on id, aliased "p2". + ops.replacePartitionField("db1", "t1", + new PartitionFieldChange("bucket", 8, "id", "p2", "p", null, null, null)); + Assertions.assertFalse(hasLiveField("t1", "p")); + Assertions.assertTrue(hasLiveField("t1", "p2")); + } + + @Test + public void testReplacePartitionFieldByOldTransform() { + createUnpartitionedTable("t1"); + ops.addPartitionField("db1", "t1", add("bucket", 8, "id", null)); + // Old identified by transform bucket(8) on id; new is truncate(4) on name. + ops.replacePartitionField("db1", "t1", + new PartitionFieldChange("truncate", 4, "name", null, null, "bucket", 8, "id")); + Assertions.assertFalse(hasLiveTransform("t1", "bucket")); + Assertions.assertTrue(hasLiveTransform("t1", "truncate")); + } + + @Test + public void testReplacePartitionFieldByOldIdentityTransform() { + createUnpartitionedTable("t1"); + // Old field is an IDENTITY transform on id (aliased) — exercises the null-transformName old path in the + // seam's getTransform(...) -> Expressions.ref(column) for the OLD side of replace. + ops.addPartitionField("db1", "t1", add(null, null, "id", "id_part")); + Assertions.assertTrue(hasLiveTransform("t1", "identity")); + // Old identified by identity transform on id (oldTransformName == null, oldColumnName == "id"); + // new is truncate(4) on name. + ops.replacePartitionField("db1", "t1", + new PartitionFieldChange("truncate", 4, "name", null, null, null, null, "id")); + Assertions.assertFalse(hasLiveTransform("t1", "identity")); + Assertions.assertTrue(hasLiveTransform("t1", "truncate")); + } + + @Test + public void testAddUnsupportedTransformFailsLoud() { + createUnpartitionedTable("t1"); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> ops.addPartitionField("db1", "t1", add("weekly", null, "id", null))); + Assertions.assertTrue(ex.getMessage().contains("Unsupported partition transform"), ex.getMessage()); + } + + @Test + public void testAddBucketWithoutArgFailsLoud() { + createUnpartitionedTable("t1"); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> ops.addPartitionField("db1", "t1", add("bucket", null, "id", null))); + Assertions.assertTrue(ex.getMessage().contains("Bucket transform requires"), ex.getMessage()); + } + + @Test + public void testAddTruncateWithoutArgFailsLoud() { + createUnpartitionedTable("t1"); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> ops.addPartitionField("db1", "t1", add("truncate", null, "name", null))); + Assertions.assertTrue(ex.getMessage().contains("Truncate transform requires"), ex.getMessage()); + } + + @Test + public void testNullColumnFailsLoud() { + createUnpartitionedTable("t1"); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> ops.addPartitionField("db1", "t1", add(null, null, null, null))); + Assertions.assertTrue(ex.getMessage().contains("Column name is required"), ex.getMessage()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsTest.java new file mode 100644 index 00000000000000..b412e0ed305140 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsTest.java @@ -0,0 +1,406 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.iceberg.IcebergCatalogOps.CatalogBackedIcebergCatalogOps; + +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.view.View; +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; +import java.util.Optional; + +/** + * Parity tests for the listing internals of {@link CatalogBackedIcebergCatalogOps} (P6-T09): nested + * namespace recursion, view filtering, and dotted-namespace / external-catalog-name construction — + * mirroring legacy {@code IcebergMetadataOps}. Drives the real seam against the fail-loud + * {@link FakeIcebergCatalog} hierarchy (no Mockito), asserting the EXACT names returned AND the exact + * namespaces the seam constructed. + */ +public class CatalogBackedIcebergCatalogOpsTest { + + private static IcebergCatalogOps ops(Catalog catalog, boolean restFlavor, boolean nestedNamespaceEnabled, + boolean viewEnabled, Optional externalCatalogName) { + return new CatalogBackedIcebergCatalogOps( + catalog, restFlavor, nestedNamespaceEnabled, viewEnabled, externalCatalogName); + } + + // --------------------------------------------------------------------- + // listDatabaseNames — nested-namespace recursion (G3) + // --------------------------------------------------------------------- + + @Test + public void listDatabaseNamesReturnsLastLevelWhenNotNested() { + // WHY: in the default (non-nested) mode legacy maps each listed namespace to its LAST level + // (n.level(n.length()-1)). A nested namespace [a,b] surfaces only as "b". MUTATION: emitting the + // full dotted name, or recursing when nested is off -> red. + FakeIcebergCatalog catalog = new FakeIcebergCatalog(); + catalog.childNamespaces.put(Namespace.empty(), + Arrays.asList(Namespace.of("db_a"), Namespace.of("a", "b"))); + + List result = ops(catalog, false, false, true, Optional.empty()).listDatabaseNames(); + + Assertions.assertEquals(Arrays.asList("db_a", "b"), result, + "non-nested mode must return each namespace's last level only"); + } + + @Test + public void listDatabaseNamesRecursesDottedWhenRestAndNestedEnabled() { + // WHY: legacy recurses ONLY for a REST catalog with iceberg.rest.nested-namespace-enabled=true, + // emitting each child's dotted toString() then flat-mapping its descendants. Root -> [a]; a -> + // [a.b]; a.b -> []. Result must be ["a", "a.b"] in that order. MUTATION: last-level-only, or + // wrong recursion order -> red. + FakeIcebergCatalog catalog = new FakeIcebergCatalog(); + catalog.childNamespaces.put(Namespace.empty(), Collections.singletonList(Namespace.of("a"))); + catalog.childNamespaces.put(Namespace.of("a"), Collections.singletonList(Namespace.of("a", "b"))); + catalog.childNamespaces.put(Namespace.of("a", "b"), Collections.emptyList()); + + List result = ops(catalog, true, true, true, Optional.empty()).listDatabaseNames(); + + Assertions.assertEquals(Arrays.asList("a", "a.b"), result, + "REST + nested-enabled must recurse and emit dotted namespace names depth-first"); + } + + @Test + public void listDatabaseNamesDoesNotRecurseWhenNestedFlagButNotRest() { + // WHY: legacy gates recursion on `dorisCatalog instanceof IcebergRestExternalCatalog` AS WELL AS + // the flag, so a non-REST flavor with the nested flag set must STILL fall back to last-level. + // MUTATION: recursing on the flag alone (ignoring the REST gate) -> red. + FakeIcebergCatalog catalog = new FakeIcebergCatalog(); + catalog.childNamespaces.put(Namespace.empty(), Collections.singletonList(Namespace.of("a", "b"))); + + List result = ops(catalog, false, true, true, Optional.empty()).listDatabaseNames(); + + Assertions.assertEquals(Collections.singletonList("b"), result, + "nested flag without REST flavor must not recurse"); + } + + @Test + public void listDatabaseNamesUsesExternalCatalogNameAsRoot() { + // WHY: legacy roots the listing at Namespace.of(externalCatalogName) when present (3-level REST + // catalogs), else Namespace.empty(). MUTATION: always rooting at Namespace.empty() -> the seam + // lists the wrong parent -> empty result -> red. + FakeIcebergCatalog catalog = new FakeIcebergCatalog(); + catalog.childNamespaces.put(Namespace.of("cat"), + Collections.singletonList(Namespace.of("cat", "db1"))); + + List result = ops(catalog, false, false, true, Optional.of("cat")).listDatabaseNames(); + + Assertions.assertEquals(Collections.singletonList("db1"), result, + "external-catalog-name must root the namespace listing"); + Assertions.assertTrue(catalog.log.contains("listNamespaces:cat"), + "listing must start from the external-catalog-name namespace"); + } + + @Test + public void listDatabaseNamesEmptyWhenCatalogHasNoNamespaceSupport() { + // WHY: the guard for a catalog without SupportsNamespaces must be preserved — return empty, never + // throw. MUTATION: dropping the instanceof guard -> ClassCastException -> red (error). + List result = + ops(new PlainIcebergCatalog(), false, false, true, Optional.empty()).listDatabaseNames(); + + Assertions.assertTrue(result.isEmpty(), + "a catalog without namespace support must yield an empty database list"); + } + + // --------------------------------------------------------------------- + // listTableNames — view filtering (G4) + // --------------------------------------------------------------------- + + @Test + public void listTableNamesFiltersOutViewsWhenViewCatalogEnabled() { + // WHY: iceberg's listTables returns views too, so legacy subtracts the names returned by + // listViews when the catalog is a (view-enabled) ViewCatalog. MUTATION: not filtering, or + // filtering the wrong set -> red. + FakeIcebergViewCatalog catalog = new FakeIcebergViewCatalog(); + catalog.tablesByNs.put(Namespace.of("db1"), Arrays.asList("t1", "v1", "t2")); + catalog.viewsByNs.put(Namespace.of("db1"), Collections.singletonList("v1")); + + List result = ops(catalog, true, false, true, Optional.empty()).listTableNames("db1"); + + Assertions.assertEquals(Arrays.asList("t1", "t2"), result, + "view names returned by listTables must be filtered out"); + } + + @Test + public void listTableNamesDoesNotFilterWhenNotViewCatalog() { + // WHY: a plain Catalog (not a ViewCatalog) cannot list views, so legacy returns the table list + // unfiltered. MUTATION: attempting to cast to ViewCatalog / filtering anyway -> red. + FakeIcebergCatalog catalog = new FakeIcebergCatalog(); + catalog.tablesByNs.put(Namespace.of("db1"), Arrays.asList("t1", "v1")); + + List result = ops(catalog, true, false, true, Optional.empty()).listTableNames("db1"); + + Assertions.assertEquals(Arrays.asList("t1", "v1"), result, + "a non-ViewCatalog must return the table list unfiltered"); + } + + @Test + public void listTableNamesDoesNotFilterWhenRestViewDisabled() { + // WHY: even a ViewCatalog must skip view filtering when iceberg.rest.view-enabled=false on a REST + // catalog (isViewCatalogEnabled() returns false), and listViews must NOT be called at all. + // MUTATION: ignoring the view-enabled flag, or calling listViews regardless -> red. + FakeIcebergViewCatalog catalog = new FakeIcebergViewCatalog(); + catalog.tablesByNs.put(Namespace.of("db1"), Arrays.asList("t1", "v1")); + catalog.viewsByNs.put(Namespace.of("db1"), Collections.singletonList("v1")); + + List result = ops(catalog, true, false, false, Optional.empty()).listTableNames("db1"); + + Assertions.assertEquals(Arrays.asList("t1", "v1"), result, + "REST view-disabled must return the table list unfiltered"); + Assertions.assertFalse(catalog.log.contains("listViews:db1"), + "listViews must not be called when view filtering is disabled"); + } + + // --------------------------------------------------------------------- + // listViewNames / viewExists — the inverse of listTableNames' view subtraction (B0 view SPI) + // --------------------------------------------------------------------- + + @Test + public void listViewNamesReturnsViewsWhenViewCatalogEnabled() { + // WHY: the catalog re-merges these into SHOW TABLES (listTableNames subtracts them). The real impl + // must surface the ViewCatalog's listViews names. MUTATION: returning empty / not casting to + // ViewCatalog -> views vanish from SHOW TABLES -> red. + FakeIcebergViewCatalog catalog = new FakeIcebergViewCatalog(); + catalog.viewsByNs.put(Namespace.of("db1"), Arrays.asList("v1", "v2")); + + List result = ops(catalog, true, false, true, Optional.empty()).listViewNames("db1"); + + Assertions.assertEquals(Arrays.asList("v1", "v2"), result); + } + + @Test + public void listViewNamesEmptyWhenNotViewCatalog() { + // WHY: a plain Catalog (not a ViewCatalog) has no views. MUTATION: dropping the isViewCatalogEnabled + // gate -> a ClassCastException on the non-ViewCatalog -> red. + FakeIcebergCatalog catalog = new FakeIcebergCatalog(); + + List result = ops(catalog, true, false, true, Optional.empty()).listViewNames("db1"); + + Assertions.assertTrue(result.isEmpty(), "a non-ViewCatalog must report no views"); + } + + @Test + public void listViewNamesEmptyWhenRestViewDisabled() { + // WHY: even a ViewCatalog reports no views when iceberg.rest.view-enabled=false, and listViews must + // NOT be called. MUTATION: ignoring the view-enabled flag -> listViews called / views surface -> red. + FakeIcebergViewCatalog catalog = new FakeIcebergViewCatalog(); + catalog.viewsByNs.put(Namespace.of("db1"), Collections.singletonList("v1")); + + List result = ops(catalog, true, false, false, Optional.empty()).listViewNames("db1"); + + Assertions.assertTrue(result.isEmpty(), "REST view-disabled must report no views"); + Assertions.assertFalse(catalog.log.contains("listViews:db1"), + "listViews must not be called when view filtering is disabled"); + } + + @Test + public void viewExistsTrueOnlyForKnownViewWhenViewCatalogEnabled() { + // WHY: PluginDrivenExternalTable.isView() resolves from this; it must report true exactly for a view + // name. MUTATION: hard-coding true/false, or checking the wrong name -> red on one of the two cases. + FakeIcebergViewCatalog catalog = new FakeIcebergViewCatalog(); + catalog.viewsByNs.put(Namespace.of("db1"), Collections.singletonList("v1")); + + Assertions.assertTrue(ops(catalog, true, false, true, Optional.empty()).viewExists("db1", "v1")); + Assertions.assertFalse(ops(catalog, true, false, true, Optional.empty()).viewExists("db1", "t1"), + "a non-view name must not report as a view"); + } + + @Test + public void viewExistsFalseWhenNotViewCatalogOrRestDisabled() { + // WHY: the isViewCatalogEnabled gate must short-circuit viewExists to false for a plain Catalog and + // for a view-disabled REST catalog (never casting / calling viewExists on them). MUTATION: dropping + // the gate -> ClassCastException on the plain catalog, or a true on the disabled one -> red. + FakeIcebergCatalog plain = new FakeIcebergCatalog(); + Assertions.assertFalse(ops(plain, true, false, true, Optional.empty()).viewExists("db1", "v1"), + "a non-ViewCatalog reports no views"); + + FakeIcebergViewCatalog disabled = new FakeIcebergViewCatalog(); + disabled.viewsByNs.put(Namespace.of("db1"), Collections.singletonList("v1")); + Assertions.assertFalse(ops(disabled, true, false, false, Optional.empty()).viewExists("db1", "v1"), + "REST view-disabled gates viewExists to false"); + } + + // --------------------------------------------------------------------- + // loadView — SDK-only delegation: returns the iceberg View, gated like the other view ops. + // (Post-H8 the sql/dialect/column extraction moved up to IcebergConnectorMetadata, which holds the + // type-mapping flags; the negative extraction cases now live in IcebergConnectorMetadataTest.) + // --------------------------------------------------------------------- + + @Test + public void loadViewReturnsSdkViewByIdentifier() { + // WHY (post-H8): the seam is SDK-only — loadView returns the catalog's iceberg View for the (db, view) + // identifier, mirroring loadTable. The sql/dialect/column extraction lives in + // IcebergConnectorMetadata.getViewDefinition (which holds the type-mapping flags this SDK-only seam does + // not). MUTATION: returning a different/null view, or building from the wrong identifier -> red. + FakeIcebergViewCatalog catalog = new FakeIcebergViewCatalog(); + FakeIcebergViewCatalog.StubView view = new FakeIcebergViewCatalog.StubView(null); + catalog.loadableViews.put(TableIdentifier.of(Namespace.of("db1"), "v1"), view); + + View loaded = ops(catalog, true, false, true, Optional.empty()).loadView("db1", "v1"); + + Assertions.assertSame(view, loaded, "the seam must return the catalog's SDK View unchanged"); + Assertions.assertTrue(catalog.log.contains("loadView:db1.v1"), + "the seam must load the view by its (db, view) identifier"); + } + + @Test + public void loadViewThrowsWhenNotViewCatalog() { + // WHY: a plain Catalog has no views; loadView must fail loud (gate before any cast). + // MUTATION: dropping the isViewCatalogEnabled gate -> ClassCastException instead of the clear error. + FakeIcebergCatalog plain = new FakeIcebergCatalog(); + Assertions.assertThrows(DorisConnectorException.class, + () -> ops(plain, true, false, true, Optional.empty()).loadView("db1", "v1")); + } + + @Test + public void loadViewThrowsWhenRestViewDisabled() { + // WHY: even a ViewCatalog reports no views when iceberg.rest.view-enabled=false; loadView must NOT be + // called. MUTATION: ignoring the view-enabled flag -> loadView reached -> the gate's purpose is lost. + FakeIcebergViewCatalog catalog = new FakeIcebergViewCatalog(); + catalog.loadableViews.put(TableIdentifier.of(Namespace.of("db1"), "v1"), + new FakeIcebergViewCatalog.StubView(null)); + Assertions.assertThrows(DorisConnectorException.class, + () -> ops(catalog, true, false, false, Optional.empty()).loadView("db1", "v1")); + Assertions.assertFalse(catalog.log.contains("loadView:db1.v1"), + "loadView must not be called when view support is disabled"); + } + + // --------------------------------------------------------------------- + // dropView — delegate to ViewCatalog.dropView, gated like the other view ops (B2 DROP) + // --------------------------------------------------------------------- + + @Test + public void dropViewDelegatesToViewCatalogByIdentifier() { + // WHY: DROP VIEW on a flipped iceberg view must reach ViewCatalog.dropView with the (db, view) + // identifier. MUTATION: dropping the delegation / passing the wrong identifier -> the view survives -> + // the viewExists assertion below goes red. + FakeIcebergViewCatalog catalog = new FakeIcebergViewCatalog(); + catalog.viewsByNs.put(Namespace.of("db1"), new ArrayList<>(Arrays.asList("v1", "v2"))); + + ops(catalog, true, false, true, Optional.empty()).dropView("db1", "v1"); + + Assertions.assertTrue(catalog.log.contains("dropView:db1.v1"), + "the seam must drop the view by its (db, view) identifier"); + Assertions.assertFalse(catalog.viewExists(TableIdentifier.of(Namespace.of("db1"), "v1")), + "the dropped view must no longer exist"); + Assertions.assertTrue(catalog.viewExists(TableIdentifier.of(Namespace.of("db1"), "v2")), + "only the named view is dropped"); + } + + @Test + public void dropViewThrowsWhenNotViewCatalog() { + // WHY: a plain Catalog has no views; dropView must fail loud (gate before any cast), mirroring legacy + // performDropView. MUTATION: dropping the isViewCatalogEnabled gate -> ClassCastException on the plain + // catalog instead of the clear error. + FakeIcebergCatalog plain = new FakeIcebergCatalog(); + Assertions.assertThrows(DorisConnectorException.class, + () -> ops(plain, true, false, true, Optional.empty()).dropView("db1", "v1")); + } + + @Test + public void dropViewThrowsWhenRestViewDisabled() { + // WHY: even a ViewCatalog reports no views when iceberg.rest.view-enabled=false; dropView must NOT be + // called. MUTATION: ignoring the view-enabled flag -> dropView reached -> the gate's purpose is lost. + FakeIcebergViewCatalog catalog = new FakeIcebergViewCatalog(); + catalog.viewsByNs.put(Namespace.of("db1"), new ArrayList<>(Collections.singletonList("v1"))); + Assertions.assertThrows(DorisConnectorException.class, + () -> ops(catalog, true, false, false, Optional.empty()).dropView("db1", "v1")); + Assertions.assertFalse(catalog.log.contains("dropView:db1.v1"), + "dropView must not be called when view support is disabled"); + } + + // --------------------------------------------------------------------- + // namespace construction — dotted split + external-catalog-name append + // --------------------------------------------------------------------- + + @Test + public void listTableNamesSplitsDottedDbAndAppendsExternalCatalogName() { + // WHY: legacy getNamespace splits the db name on '.' (omit empties / trim) and appends the + // external-catalog-name at the end. "a.b" + cat -> Namespace.of(a, b, cat). MUTATION: treating + // "a.b" as a single level, or prepending the catalog name -> red. + FakeIcebergCatalog catalog = new FakeIcebergCatalog(); + + ops(catalog, false, false, true, Optional.of("cat")).listTableNames("a.b"); + + Assertions.assertEquals(Namespace.of("a", "b", "cat"), catalog.lastListTablesNs, + "dotted db name must split into levels with external-catalog-name appended last"); + } + + @Test + public void listTableNamesTrimsUnicodeWhitespaceLikeGuava() { + // WHY: legacy getNamespace splits via Guava Splitter.trimResults(), whose trimming is + // CharMatcher.whitespace() -- it strips the Unicode whitespace chars above U+0020 (e.g. the + // ideographic space U+3000), which plain String.trim() (only <= U+0020) does NOT. A db name + // with U+3000 edges must therefore trim to the same namespace legacy produces. MUTATION: plain + // String.trim() leaves the U+3000 -> a different Iceberg namespace -> red. (NBSP U+00A0 is + // intentionally avoided: Guava whitespace() excludes it, so it is not a divergence.) + FakeIcebergCatalog catalog = new FakeIcebergCatalog(); + + ops(catalog, false, false, true, Optional.empty()).listTableNames("\u3000db1\u3000"); + + Assertions.assertEquals(Namespace.of("db1"), catalog.lastListTablesNs, + "U+3000 whitespace edges must be trimmed, matching legacy Guava trimResults()"); + } + + @Test + public void databaseExistsSplitsDottedNamespace() { + // WHY: databaseExists must check the SPLIT multi-level namespace, not the dotted string as a + // single level. MUTATION: Namespace.of("a.b") (one level) -> miss -> red. + FakeIcebergCatalog catalog = new FakeIcebergCatalog(); + catalog.existingNamespaces.add(Namespace.of("a", "b")); + + boolean exists = ops(catalog, false, false, true, Optional.empty()).databaseExists("a.b"); + + Assertions.assertTrue(exists, "a dotted db name must resolve to the multi-level namespace"); + Assertions.assertEquals(Namespace.of("a", "b"), catalog.lastNamespaceExistsNs, + "databaseExists must build the multi-level namespace from the dotted name"); + } + + @Test + public void databaseExistsFalseWhenNoNamespaceSupport() { + // WHY: the no-namespace-support guard returns false (never throws). MUTATION: dropping the guard + // -> ClassCastException -> red. + boolean exists = + ops(new PlainIcebergCatalog(), false, false, true, Optional.empty()).databaseExists("db1"); + + Assertions.assertFalse(exists, "a catalog without namespace support reports no databases"); + } + + @Test + public void tableExistsSplitsDottedNamespace() { + // WHY: tableExists must build TableIdentifier.of(, table). MUTATION: single-level + // namespace from a dotted db -> wrong identifier -> miss -> red. + FakeIcebergCatalog catalog = new FakeIcebergCatalog(); + catalog.existingTables.add(TableIdentifier.of(Namespace.of("a", "b"), "t1")); + + boolean exists = ops(catalog, false, false, true, Optional.empty()).tableExists("a.b", "t1"); + + Assertions.assertTrue(exists, "a dotted db name must resolve to the multi-level table identifier"); + Assertions.assertEquals(TableIdentifier.of(Namespace.of("a", "b"), "t1"), catalog.lastTableExistsId, + "tableExists must build the identifier from the split namespace"); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/FakeIcebergCatalog.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/FakeIcebergCatalog.java new file mode 100644 index 00000000000000..cdb24311e44720 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/FakeIcebergCatalog.java @@ -0,0 +1,134 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.SupportsNamespaces; +import org.apache.iceberg.catalog.TableIdentifier; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Offline fail-loud double for an iceberg {@link Catalog} + {@link SupportsNamespaces}, mirroring + * {@link FakeIcebergTable}. Only the read accessors the {@code CatalogBackedIcebergCatalogOps} listing + * path exercises return controlled values; every other method throws {@link UnsupportedOperationException} + * so a future change that starts depending on (say) {@code createNamespace} blows up loudly instead of + * silently passing. + * + *

    Records the namespaces / identifiers it receives so tests can assert the EXACT namespace the seam + * constructed (dotted-name split + external-catalog-name append), not just the returned names. + * + *

    See also {@link FakeIcebergViewCatalog} (adds {@code ViewCatalog}) and {@link PlainIcebergCatalog} + * (a bare {@code Catalog} with no namespace support). + */ +class FakeIcebergCatalog implements Catalog, SupportsNamespaces { + + /** Ordered record of the calls made, e.g. {@code "listNamespaces:a"}, {@code "listViews:db1"}. */ + final List log = new ArrayList<>(); + /** parent namespace -> its immediate child namespaces (for listNamespaces / nested recursion). */ + final Map> childNamespaces = new HashMap<>(); + /** namespace -> table names returned by listTables. */ + final Map> tablesByNs = new HashMap<>(); + final Set existingNamespaces = new HashSet<>(); + final Set existingTables = new HashSet<>(); + + /** The exact namespace/identifier last received — lets tests pin namespace CONSTRUCTION. */ + Namespace lastListTablesNs; + Namespace lastNamespaceExistsNs; + TableIdentifier lastTableExistsId; + + @Override + public List listNamespaces(Namespace ns) { + log.add("listNamespaces:" + ns); + return childNamespaces.getOrDefault(ns, Collections.emptyList()); + } + + @Override + public boolean namespaceExists(Namespace ns) { + lastNamespaceExistsNs = ns; + log.add("namespaceExists:" + ns); + return existingNamespaces.contains(ns); + } + + @Override + public List listTables(Namespace ns) { + lastListTablesNs = ns; + log.add("listTables:" + ns); + return tablesByNs.getOrDefault(ns, Collections.emptyList()).stream() + .map(n -> TableIdentifier.of(ns, n)) + .collect(Collectors.toList()); + } + + @Override + public boolean tableExists(TableIdentifier identifier) { + lastTableExistsId = identifier; + log.add("tableExists:" + identifier); + return existingTables.contains(identifier); + } + + // ---- outside the listing read path: fail loud if ever called ---- + + @Override + public Table loadTable(TableIdentifier identifier) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean dropTable(TableIdentifier identifier, boolean purge) { + throw new UnsupportedOperationException(); + } + + @Override + public void renameTable(TableIdentifier from, TableIdentifier to) { + throw new UnsupportedOperationException(); + } + + @Override + public void createNamespace(Namespace namespace, Map metadata) { + throw new UnsupportedOperationException(); + } + + @Override + public Map loadNamespaceMetadata(Namespace namespace) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean dropNamespace(Namespace namespace) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean setProperties(Namespace namespace, Map properties) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean removeProperties(Namespace namespace, Set properties) { + throw new UnsupportedOperationException(); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/FakeIcebergTable.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/FakeIcebergTable.java new file mode 100644 index 00000000000000..91defb895ee3c0 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/FakeIcebergTable.java @@ -0,0 +1,292 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.DeleteFiles; +import org.apache.iceberg.ExpireSnapshots; +import org.apache.iceberg.HistoryEntry; +import org.apache.iceberg.ManageSnapshots; +import org.apache.iceberg.OverwriteFiles; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.ReplacePartitions; +import org.apache.iceberg.ReplaceSortOrder; +import org.apache.iceberg.RewriteFiles; +import org.apache.iceberg.RewriteManifests; +import org.apache.iceberg.RowDelta; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.StatisticsFile; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableScan; +import org.apache.iceberg.Transaction; +import org.apache.iceberg.UpdateLocation; +import org.apache.iceberg.UpdatePartitionSpec; +import org.apache.iceberg.UpdateProperties; +import org.apache.iceberg.UpdateSchema; +import org.apache.iceberg.encryption.EncryptionManager; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.LocationProvider; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Minimal offline {@link Table} double for unit tests, mirroring the paimon connector's + * {@code FakePaimonTable}. Only the metadata read accessors that + * {@link IcebergConnectorMetadata#getTableSchema} actually exercises — {@link #schema()}, + * {@link #spec()}, {@link #location()}, {@link #properties()} — return controlled values from the + * constructor; every other method throws {@link UnsupportedOperationException}. + * + *

    Throwing on the rest is deliberate: it documents that the metadata read path must touch + * nothing else, and a future change that starts depending on (say) {@code newScan()} in the + * read-only metadata path would blow up loudly in the test instead of silently passing. + */ +final class FakeIcebergTable implements Table { + + private final String name; + private final Schema schema; + private final PartitionSpec spec; + private final String location; + private final Map properties; + // Optional FileIO for the T09 vended-credential extraction test (extractVendedToken reads table.io()). + // Null by default -> io() keeps its fail-loud contract; only the vended test injects one. + private FileIO io; + // Optional sort order for the SHOW CREATE TABLE sort-clause read path (buildShowSortClause reads + // table.sortOrder()). Null by default -> the render path treats it as unsorted (legacy getSortOrderSql + // guards `sortOrder == null`), so unsorted tables emit no ORDER BY; only the sort-clause test injects one. + private SortOrder sortOrder; + // Optional SDK transaction for the write planWrite path (IcebergConnectorTransaction.beginWrite calls + // newTransaction()). Null by default -> newTransaction() keeps its fail-loud contract; only the write + // credential test injects one (sourced from a real catalog table). The planning path stores but never + // dereferences it (commit is out of scope for these unit tests). + private Transaction newTransaction; + + FakeIcebergTable(String name, Schema schema, PartitionSpec spec, + String location, Map properties) { + this.name = name; + this.schema = schema; + this.spec = spec; + this.location = location; + this.properties = properties; + } + + /** Inject a FileIO so {@link #io()} returns it (T09 vended-credential extraction); otherwise io() throws. */ + void setIo(FileIO io) { + this.io = io; + } + + /** Inject a sort order so {@link #sortOrder()} returns it (SHOW CREATE TABLE sort-clause test). */ + void setSortOrder(SortOrder sortOrder) { + this.sortOrder = sortOrder; + } + + /** Inject an SDK transaction so {@link #newTransaction()} returns it (write planWrite path); otherwise + * newTransaction() throws. */ + void setNewTransaction(Transaction newTransaction) { + this.newTransaction = newTransaction; + } + + @Override + public String name() { + return name; + } + + @Override + public Schema schema() { + return schema; + } + + @Override + public PartitionSpec spec() { + return spec; + } + + @Override + public String location() { + return location; + } + + @Override + public Map properties() { + return properties; + } + + // ---- everything below is outside the metadata read path: fail loud if ever called ---- + + @Override + public void refresh() { + throw new UnsupportedOperationException(); + } + + @Override + public TableScan newScan() { + throw new UnsupportedOperationException(); + } + + @Override + public Map schemas() { + throw new UnsupportedOperationException(); + } + + @Override + public Map specs() { + // The single spec keyed by its id — getScanNodeProperties' getIdentityPartitionColumns iterates this + // (T09 location tests run getScanNodeProperties against a FakeIcebergTable). + return Collections.singletonMap(spec.specId(), spec); + } + + @Override + public SortOrder sortOrder() { + return sortOrder; + } + + @Override + public Map sortOrders() { + throw new UnsupportedOperationException(); + } + + @Override + public Snapshot currentSnapshot() { + throw new UnsupportedOperationException(); + } + + @Override + public Snapshot snapshot(long snapshotId) { + throw new UnsupportedOperationException(); + } + + @Override + public Iterable snapshots() { + throw new UnsupportedOperationException(); + } + + @Override + public List history() { + throw new UnsupportedOperationException(); + } + + @Override + public UpdateSchema updateSchema() { + throw new UnsupportedOperationException(); + } + + @Override + public UpdatePartitionSpec updateSpec() { + throw new UnsupportedOperationException(); + } + + @Override + public UpdateProperties updateProperties() { + throw new UnsupportedOperationException(); + } + + @Override + public ReplaceSortOrder replaceSortOrder() { + throw new UnsupportedOperationException(); + } + + @Override + public UpdateLocation updateLocation() { + throw new UnsupportedOperationException(); + } + + @Override + public AppendFiles newAppend() { + throw new UnsupportedOperationException(); + } + + @Override + public RewriteFiles newRewrite() { + throw new UnsupportedOperationException(); + } + + @Override + public RewriteManifests rewriteManifests() { + throw new UnsupportedOperationException(); + } + + @Override + public OverwriteFiles newOverwrite() { + throw new UnsupportedOperationException(); + } + + @Override + public RowDelta newRowDelta() { + throw new UnsupportedOperationException(); + } + + @Override + public ReplacePartitions newReplacePartitions() { + throw new UnsupportedOperationException(); + } + + @Override + public DeleteFiles newDelete() { + throw new UnsupportedOperationException(); + } + + @Override + public ExpireSnapshots expireSnapshots() { + throw new UnsupportedOperationException(); + } + + @Override + public ManageSnapshots manageSnapshots() { + throw new UnsupportedOperationException(); + } + + @Override + public Transaction newTransaction() { + if (newTransaction != null) { + return newTransaction; + } + throw new UnsupportedOperationException(); + } + + @Override + public FileIO io() { + if (io != null) { + return io; + } + throw new UnsupportedOperationException(); + } + + @Override + public EncryptionManager encryption() { + throw new UnsupportedOperationException(); + } + + @Override + public LocationProvider locationProvider() { + throw new UnsupportedOperationException(); + } + + @Override + public List statisticsFiles() { + throw new UnsupportedOperationException(); + } + + @Override + public Map refs() { + throw new UnsupportedOperationException(); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/FakeIcebergViewCatalog.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/FakeIcebergViewCatalog.java new file mode 100644 index 00000000000000..16d2bba85aff9e --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/FakeIcebergViewCatalog.java @@ -0,0 +1,196 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.iceberg.Schema; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.catalog.ViewCatalog; +import org.apache.iceberg.view.SQLViewRepresentation; +import org.apache.iceberg.view.UpdateViewProperties; +import org.apache.iceberg.view.View; +import org.apache.iceberg.view.ViewBuilder; +import org.apache.iceberg.view.ViewHistoryEntry; +import org.apache.iceberg.view.ViewRepresentation; +import org.apache.iceberg.view.ViewVersion; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * A {@link FakeIcebergCatalog} that also implements {@link ViewCatalog}, for exercising the view-filtering + * branch of {@code listTableNames}. The view names returned by {@link #listViews} are subtracted from the + * table list by the seam when view filtering is enabled. + */ +class FakeIcebergViewCatalog extends FakeIcebergCatalog implements ViewCatalog { + + /** namespace -> view names returned by listViews. */ + final Map> viewsByNs = new HashMap<>(); + + /** identifier -> View returned by loadView (for exercising the view read path). */ + final Map loadableViews = new HashMap<>(); + + @Override + public String name() { + return "fake-view-catalog"; + } + + // Catalog and ViewCatalog both declare a default initialize(String, Map); a class implementing both + // must override it to resolve the diamond. Not exercised by the listing path -> fail loud. + @Override + public void initialize(String name, Map properties) { + throw new UnsupportedOperationException(); + } + + @Override + public List listViews(Namespace namespace) { + log.add("listViews:" + namespace); + return viewsByNs.getOrDefault(namespace, Collections.emptyList()).stream() + .map(n -> TableIdentifier.of(namespace, n)) + .collect(Collectors.toList()); + } + + @Override + public boolean viewExists(TableIdentifier identifier) { + log.add("viewExists:" + identifier); + return viewsByNs.getOrDefault(identifier.namespace(), Collections.emptyList()) + .contains(identifier.name()); + } + + @Override + public View loadView(TableIdentifier identifier) { + log.add("loadView:" + identifier); + View view = loadableViews.get(identifier); + if (view == null) { + throw new IllegalArgumentException("no such view: " + identifier); + } + return view; + } + + /** + * A minimal {@link View} returning a single configurable {@code currentVersion} (which may be null) and an + * optional {@code schema}; every other accessor fails loud. The {@code View.sqlFor(dialect)} default method + * resolves the SQL from {@code currentVersion().representations()}, so the version's representations + + * summary drive the sql/dialect extraction, while {@code schema} drives the column extraction (both done in + * {@code IcebergConnectorMetadata.getViewDefinition}). A null {@code schema} still fails loud on + * {@link #schema()}, matching the seam tests that never read it. + */ + static final class StubView implements View { + private final ViewVersion currentVersion; + private final Schema schema; + + StubView(ViewVersion currentVersion) { + this(currentVersion, null); + } + + StubView(ViewVersion currentVersion, Schema schema) { + this.currentVersion = currentVersion; + this.schema = schema; + } + + @Override + public String name() { + return "stub-view"; + } + + @Override + public ViewVersion currentVersion() { + return currentVersion; + } + + // The View interface's default sqlFor() throws ("Resolving a sql with a given dialect is not + // supported"); the real resolution lives in BaseView. Replicate the core resolution (exact-dialect + // match over the current version's SQL representations) so the metadata layer's sqlFor call behaves + // like a real iceberg View. + @Override + public SQLViewRepresentation sqlFor(String dialect) { + if (currentVersion == null) { + return null; + } + for (ViewRepresentation representation : currentVersion.representations()) { + if (representation instanceof SQLViewRepresentation + && ((SQLViewRepresentation) representation).dialect().equalsIgnoreCase(dialect)) { + return (SQLViewRepresentation) representation; + } + } + return null; + } + + @Override + public Schema schema() { + if (schema == null) { + throw new UnsupportedOperationException(); + } + return schema; + } + + @Override + public Map schemas() { + throw new UnsupportedOperationException(); + } + + @Override + public Iterable versions() { + throw new UnsupportedOperationException(); + } + + @Override + public ViewVersion version(int versionId) { + throw new UnsupportedOperationException(); + } + + @Override + public List history() { + throw new UnsupportedOperationException(); + } + + @Override + public Map properties() { + throw new UnsupportedOperationException(); + } + + @Override + public UpdateViewProperties updateProperties() { + throw new UnsupportedOperationException(); + } + } + + @Override + public ViewBuilder buildView(TableIdentifier identifier) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean dropView(TableIdentifier identifier) { + log.add("dropView:" + identifier); + List names = viewsByNs.get(identifier.namespace()); + if (names == null || !names.contains(identifier.name())) { + return false; + } + names.remove(identifier.name()); + return true; + } + + @Override + public void renameView(TableIdentifier from, TableIdentifier to) { + throw new UnsupportedOperationException(); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/FakeS3CompatibleStorageProperties.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/FakeS3CompatibleStorageProperties.java new file mode 100644 index 00000000000000..eab46928e514d9 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/FakeS3CompatibleStorageProperties.java @@ -0,0 +1,186 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.filesystem.FileSystemType; +import org.apache.doris.filesystem.properties.S3CompatibleFileSystemProperties; +import org.apache.doris.filesystem.properties.StorageKind; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +/** + * Hand-written {@link S3CompatibleFileSystemProperties} test double (no Mockito) for the iceberg + * S3FileIO assembly tests. Mirrors the real fe-filesystem S3-compatible providers' relevant surface: + * a configurable {@code providerName} ("S3"/"OSS"/"COS"/"OBS" — only "S3" is the generic-AWS analog of + * legacy {@code S3Properties}, which is what gates the assume-role block) and the typed S3 getters the + * connector reads. All getters default to {@code ""}; tests set only what they assert on. + */ +final class FakeS3CompatibleStorageProperties implements S3CompatibleFileSystemProperties { + + private final String providerName; + private String endpoint = ""; + private String region = ""; + private String accessKey = ""; + private String secretKey = ""; + private String sessionToken = ""; + private String roleArn = ""; + private String externalId = ""; + private String usePathStyle = ""; + + FakeS3CompatibleStorageProperties(String providerName) { + this.providerName = providerName; + } + + FakeS3CompatibleStorageProperties endpoint(String v) { + this.endpoint = v; + return this; + } + + FakeS3CompatibleStorageProperties region(String v) { + this.region = v; + return this; + } + + FakeS3CompatibleStorageProperties accessKey(String v) { + this.accessKey = v; + return this; + } + + FakeS3CompatibleStorageProperties secretKey(String v) { + this.secretKey = v; + return this; + } + + FakeS3CompatibleStorageProperties sessionToken(String v) { + this.sessionToken = v; + return this; + } + + FakeS3CompatibleStorageProperties roleArn(String v) { + this.roleArn = v; + return this; + } + + FakeS3CompatibleStorageProperties externalId(String v) { + this.externalId = v; + return this; + } + + FakeS3CompatibleStorageProperties usePathStyle(String v) { + this.usePathStyle = v; + return this; + } + + @Override + public String providerName() { + return providerName; + } + + @Override + public StorageKind kind() { + return StorageKind.OBJECT_STORAGE; + } + + @Override + public FileSystemType type() { + return FileSystemType.S3; + } + + @Override + public Map rawProperties() { + return Collections.emptyMap(); + } + + @Override + public Map matchedProperties() { + return Collections.emptyMap(); + } + + @Override + public String getEndpoint() { + return endpoint; + } + + @Override + public String getRegion() { + return region; + } + + @Override + public String getAccessKey() { + return accessKey; + } + + @Override + public String getSecretKey() { + return secretKey; + } + + @Override + public String getSessionToken() { + return sessionToken; + } + + @Override + public String getRoleArn() { + return roleArn; + } + + @Override + public String getExternalId() { + return externalId; + } + + @Override + public String getBucket() { + return ""; + } + + @Override + public String getRootPath() { + return ""; + } + + @Override + public String getMaxConnections() { + return ""; + } + + @Override + public String getRequestTimeoutMs() { + return ""; + } + + @Override + public String getConnectionTimeoutMs() { + return ""; + } + + @Override + public String getUsePathStyle() { + return usePathStyle; + } + + @Override + public Set getSupportedSchemes() { + // Mirrors the real S3 provider (this fake's type() is FileSystemType.S3); no test asserts on it. + return Set.of("s3", "s3a", "s3n"); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergAuthenticatedFileIOTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergAuthenticatedFileIOTest.java new file mode 100644 index 00000000000000..352b477f53ca31 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergAuthenticatedFileIOTest.java @@ -0,0 +1,153 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.kerberos.HadoopAuthenticator; + +import org.apache.hadoop.security.UserGroupInformation; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.security.PrivilegedExceptionAction; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Verifies the split-brain guard {@link IcebergAuthenticatedFileIO} adds to the iceberg write path: every FileIO + * factory / delete call runs INSIDE the plugin authenticator's {@code doAs}, and each still reaches the delegate. + * + *

    WHY it matters: {@code HadoopFileIO} captures the {@code FileSystem} (keyed by the current UGI) at + * factory-call time. iceberg then dereferences the returned {@code OutputFile} on an unauthenticated worker-pool + * thread, so ONLY a factory call made inside {@code doAs} captures the Kerberos FileSystem that the deferred + * write reuses. A regression that forwards to the delegate WITHOUT {@code doAs} (delegate observes + * {@code inDoAs == false}, or {@code doAsCount == 0}) hits secured HDFS as SIMPLE auth again — red here. + */ +public class IcebergAuthenticatedFileIOTest { + + @Test + public void everyFactoryCallRunsInsideDoAsAndReachesDelegate() { + RecordingAuthenticator auth = new RecordingAuthenticator(); + RecordingFileIO delegate = new RecordingFileIO(auth); + IcebergAuthenticatedFileIO io = new IcebergAuthenticatedFileIO(delegate, auth); + + io.newOutputFile("hdfs://nn/out"); + io.newInputFile("hdfs://nn/in"); + io.newInputFile("hdfs://nn/in", 123L); + io.deleteFile("hdfs://nn/del"); + + Assertions.assertEquals(4, auth.doAsCount, "every factory/delete call must be wrapped in exactly one doAs"); + Assertions.assertEquals( + Arrays.asList("newOutputFile", "newInputFile", "newInputFile", "deleteFile"), + delegate.reachedInDoAs, + "each op must reach the delegate AND run inside the authenticator's doAs"); + Assertions.assertFalse(auth.inDoAs, "doAs must have exited after each call"); + } + + @Test + public void nonAuthMethodsForwardWithoutDoAs() { + RecordingAuthenticator auth = new RecordingAuthenticator(); + RecordingFileIO delegate = new RecordingFileIO(auth); + IcebergAuthenticatedFileIO io = new IcebergAuthenticatedFileIO(delegate, auth); + + io.properties(); + io.close(); + + Assertions.assertEquals(0, auth.doAsCount, "pure delegation must not open a doAs"); + Assertions.assertTrue(delegate.closed, "close must reach the delegate"); + } + + /** Records doAs invocations and exposes whether an action is currently running inside a doAs. */ + private static final class RecordingAuthenticator implements HadoopAuthenticator { + int doAsCount; + boolean inDoAs; + + @Override + public UserGroupInformation getUGI() { + throw new UnsupportedOperationException("wiring double: getUGI is unused (doAs is overridden)"); + } + + @Override + public T doAs(PrivilegedExceptionAction action) throws IOException { + doAsCount++; + inDoAs = true; + try { + return action.run(); + } catch (IOException | RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IOException(e); + } finally { + inDoAs = false; + } + } + } + + /** FileIO double: records the name of each factory call together with whether it ran inside a doAs. */ + private static final class RecordingFileIO implements FileIO { + private final RecordingAuthenticator auth; + final List reachedInDoAs = new ArrayList<>(); + boolean closed; + + RecordingFileIO(RecordingAuthenticator auth) { + this.auth = auth; + } + + private void record(String op) { + // Only record when observed inside the authenticator's doAs — the property under test. + if (auth.inDoAs) { + reachedInDoAs.add(op); + } else { + reachedInDoAs.add(op + ":NOT-in-doAs"); + } + } + + @Override + public InputFile newInputFile(String path) { + record("newInputFile"); + return null; + } + + @Override + public OutputFile newOutputFile(String path) { + record("newOutputFile"); + return null; + } + + @Override + public void deleteFile(String path) { + record("deleteFile"); + } + + @Override + public Map properties() { + return Collections.emptyMap(); + } + + @Override + public void close() { + closed = true; + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergAuthenticatedTableOperationsTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergAuthenticatedTableOperationsTest.java new file mode 100644 index 00000000000000..0030664fb9ae85 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergAuthenticatedTableOperationsTest.java @@ -0,0 +1,194 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.Transaction; +import org.apache.iceberg.Transactions; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.LocationProvider; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.Map; + +/** + * Verifies {@link IcebergAuthenticatedTableOperations} carries the authenticated {@link FileIO} through + * iceberg's transaction plumbing — the route the Kerberos manifest writes actually take. + * + *

    WHY it matters: {@code BaseTransaction.TransactionTableOperations} does NOT use the {@code io()} of the + * ops handed to {@code Transactions.newTransaction}. Its {@code io()} returns {@code tempOps.io()} where + * {@code tempOps = ops.temp(current)} (re-created again on every intermediate commit), and + * {@code SnapshotProducer.newManifestOutputFile} — the worker-pool manifest write, the exact site of the + * {@code Client cannot authenticate via:[TOKEN, KERBEROS]} CI failure + * (test_iceberg_hadoop_catalog_kerberos INSERT) — takes its {@code OutputFile} from that {@code io()}. + * A {@code temp()} that forwards to the raw delegate (e.g. {@code HadoopTableOperations.temp()}, whose + * result's {@code io()} is the raw unauthenticated {@code HadoopFileIO}) silently bypasses the whole wrap: + * red here means the Kerberos INSERT regresses even though every direct {@code io()} call looks wrapped. + */ +public class IcebergAuthenticatedTableOperationsTest { + + @Test + public void transactionOpsExposeAuthenticatedIo() { + FileIO rawIo = new MarkerFileIO(); + FileIO authIo = new MarkerFileIO(); + FakeCatalogOps delegate = new FakeCatalogOps(newMetadata(), rawIo); + TableOperations authOps = new IcebergAuthenticatedTableOperations(delegate, authIo); + + // Mirrors IcebergConnectorTransaction.openTransaction. BaseTransaction routes its io() through + // ops.temp(current), so this is the assertion that guards the worker-pool manifest write path. + Transaction txn = Transactions.newTransaction("tbl", authOps); + + Assertions.assertSame(authIo, txn.table().io(), + "transaction io() must be the authenticated FileIO; the raw io here means temp() leaked the " + + "unauthenticated delegate into the transaction and manifest writes lose the Kerberos doAs"); + } + + @Test + public void tempWrapsDelegateTempNotTheBaseDelegate() { + FileIO rawIo = new MarkerFileIO(); + FileIO authIo = new MarkerFileIO(); + TableMetadata metadata = newMetadata(); + FakeCatalogOps delegate = new FakeCatalogOps(metadata, rawIo); + TableOperations authOps = new IcebergAuthenticatedTableOperations(delegate, authIo); + + TableOperations tempOps = authOps.temp(metadata); + + Assertions.assertSame(authIo, tempOps.io(), + "temp ops must expose the authenticated FileIO — BaseTransaction reads io() from temp ops only"); + Assertions.assertEquals("temp:m.avro", tempOps.metadataFileLocation("m.avro"), + "non-io calls must reach the DELEGATE's temp ops (uncommitted-metadata semantics), " + + "not the base delegate"); + } + + private static TableMetadata newMetadata() { + Schema schema = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); + return TableMetadata.newTableMetadata( + schema, PartitionSpec.unpartitioned(), "file:///tmp/iceberg-auth-ops-test", Collections.emptyMap()); + } + + /** Distinct no-op FileIO instances used purely as identity markers. */ + private static final class MarkerFileIO implements FileIO { + @Override + public InputFile newInputFile(String path) { + throw new UnsupportedOperationException("marker only"); + } + + @Override + public OutputFile newOutputFile(String path) { + throw new UnsupportedOperationException("marker only"); + } + + @Override + public void deleteFile(String path) { + throw new UnsupportedOperationException("marker only"); + } + + @Override + public Map properties() { + return Collections.emptyMap(); + } + } + + /** + * Catalog-ops double mirroring {@code HadoopTableOperations}: {@code temp()} returns a DISTINCT ops over the + * uncommitted metadata whose {@code io()} is the same raw (unauthenticated) FileIO — the exact shape that + * bypassed the wrap in production. + */ + private static final class FakeCatalogOps implements TableOperations { + private final TableMetadata metadata; + private final FileIO rawIo; + + FakeCatalogOps(TableMetadata metadata, FileIO rawIo) { + this.metadata = metadata; + this.rawIo = rawIo; + } + + @Override + public TableMetadata current() { + return metadata; + } + + @Override + public TableMetadata refresh() { + return metadata; + } + + @Override + public void commit(TableMetadata base, TableMetadata newMetadata) { + throw new UnsupportedOperationException("not exercised"); + } + + @Override + public FileIO io() { + return rawIo; + } + + @Override + public String metadataFileLocation(String fileName) { + return "base:" + fileName; + } + + @Override + public LocationProvider locationProvider() { + throw new UnsupportedOperationException("not exercised"); + } + + @Override + public TableOperations temp(TableMetadata uncommittedMetadata) { + return new TableOperations() { + @Override + public TableMetadata current() { + return uncommittedMetadata; + } + + @Override + public TableMetadata refresh() { + throw new UnsupportedOperationException("temp ops never refresh"); + } + + @Override + public void commit(TableMetadata base, TableMetadata newMetadata) { + throw new UnsupportedOperationException("temp ops never commit"); + } + + @Override + public FileIO io() { + return rawIo; + } + + @Override + public String metadataFileLocation(String fileName) { + return "temp:" + fileName; + } + + @Override + public LocationProvider locationProvider() { + throw new UnsupportedOperationException("not exercised"); + } + }; + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergBuildTableDescriptorTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergBuildTableDescriptorTest.java new file mode 100644 index 00000000000000..edb7ff49364a2a --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergBuildTableDescriptorTest.java @@ -0,0 +1,150 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.thrift.TTableDescriptor; +import org.apache.doris.thrift.TTableType; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Guards the iceberg read-path table-descriptor contract (P6.5-T06). + * + *

    WHY this matters: after the iceberg SPI cutover (P6.6), a SELECT (normal OR system table) routes + * through {@code PluginDrivenExternalTable.toThrift()} -> {@code metadata.buildTableDescriptor(...)}. + * Legacy iceberg ({@code IcebergExternalTable.toThrift} and {@code IcebergSysExternalTable.toThrift}) + * FORK on the catalog type: an {@code hms}-backed catalog sends {@code TTableType.HIVE_TABLE} with a + * {@code THiveTable}; every other flavor sends {@code TTableType.ICEBERG_TABLE} with a {@code TIcebergTable}. + * Without this override the SPI default returns {@code null}, fe-core falls back to + * {@code TTableType.SCHEMA_TABLE}, and BE's {@code DescriptorTbl::create} builds the WRONG descriptor type — + * a latent descriptor-parity bug for BOTH normal and system iceberg plugin tables. Iceberg is the FIRST + * connector whose {@code buildTableDescriptor} forks on the catalog type (paimon/es/maxcompute emit a single + * fixed type); each assertion encodes a legacy byte-shape requirement, not just the method's shape (Rule 9).

    + * + *

    The override reads only the {@code iceberg.catalog.type} property and its method args; it never + * dereferences catalogOps / context, so passing {@code null}/empty for them keeps the test offline.

    + */ +public class IcebergBuildTableDescriptorTest { + + private static final long TABLE_ID = 42L; + private static final String TABLE_NAME = "local_table"; + private static final String DB_NAME = "remote_db"; + private static final String REMOTE_NAME = "remote_table"; + private static final int NUM_COLS = 7; + private static final long CATALOG_ID = 100L; + + private static IcebergConnectorMetadata metadataWithCatalogType(String catalogType) { + Map props = new HashMap<>(); + if (catalogType != null) { + props.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, catalogType); + } + return new IcebergConnectorMetadata(null, props, new RecordingConnectorContext()); + } + + private static TTableDescriptor build(IcebergConnectorMetadata metadata) { + return metadata.buildTableDescriptor( + null, TABLE_ID, TABLE_NAME, DB_NAME, REMOTE_NAME, NUM_COLS, CATALOG_ID); + } + + @Test + public void buildsHiveTableDescriptorForHmsCatalog() { + // hms branch: legacy IcebergSysExternalTable.toThrift / IcebergExternalTable.toThrift send + // TTableType.HIVE_TABLE + THiveTable. MUTATION: dropping the hms fork -> ICEBERG_TABLE here -> red. + TTableDescriptor desc = build(metadataWithCatalogType(IcebergConnectorProperties.TYPE_HMS)); + + Assertions.assertNotNull(desc, + "buildTableDescriptor must return a typed descriptor, never null (null -> SCHEMA_TABLE fallback)"); + Assertions.assertEquals(TTableType.HIVE_TABLE, desc.getTableType(), + "hms-backed iceberg catalog must report HIVE_TABLE (legacy parity)"); + Assertions.assertTrue(desc.isSetHiveTable(), + "hms branch must set THiveTable (legacy IcebergSysExternalTable hms branch)"); + Assertions.assertFalse(desc.isSetIcebergTable(), + "hms branch must NOT set TIcebergTable"); + Assertions.assertEquals(TABLE_NAME, desc.getHiveTable().getTableName()); + Assertions.assertEquals(DB_NAME, desc.getHiveTable().getDbName()); + assertAddressing(desc); + } + + @Test + public void buildsIcebergTableDescriptorForRestCatalog() { + // non-hms (rest) branch: legacy sends TTableType.ICEBERG_TABLE + TIcebergTable. + // MUTATION: always emitting HIVE_TABLE (paimon-style single type) -> red here. + TTableDescriptor desc = build(metadataWithCatalogType(IcebergConnectorProperties.TYPE_REST)); + + Assertions.assertNotNull(desc, "non-hms catalog descriptor must not be null"); + Assertions.assertEquals(TTableType.ICEBERG_TABLE, desc.getTableType(), + "non-hms iceberg catalog must report ICEBERG_TABLE (legacy else-branch parity)"); + Assertions.assertTrue(desc.isSetIcebergTable(), + "non-hms branch must set TIcebergTable (legacy IcebergSysExternalTable else branch)"); + Assertions.assertFalse(desc.isSetHiveTable(), + "non-hms branch must NOT set THiveTable"); + Assertions.assertEquals(TABLE_NAME, desc.getIcebergTable().getTableName()); + Assertions.assertEquals(DB_NAME, desc.getIcebergTable().getDbName()); + assertAddressing(desc); + } + + @Test + public void forkIsCaseInsensitiveOnHmsType() { + // P6.5-T07: legacy is case-INSENSITIVE on the user's iceberg.catalog.type (it compares the fixed + // lower-cased constant getIcebergCatalogType()=="hms"; the raw value is lower-cased for factory + // dispatch). So iceberg.catalog.type="HMS" (uppercase) bound a HiveCatalog and emitted HIVE_TABLE. + // The connector reads the RAW property, so a case-SENSITIVE equals would wrongly emit ICEBERG_TABLE + // for "HMS" -> FE descriptor/EXPLAIN diverges from legacy. MUTATION: equalsIgnoreCase -> equals + // makes this assert ICEBERG_TABLE -> red. + TTableDescriptor desc = build(metadataWithCatalogType("HMS")); + + Assertions.assertEquals(TTableType.HIVE_TABLE, desc.getTableType(), + "uppercase HMS catalog type must still report HIVE_TABLE (legacy is case-insensitive)"); + Assertions.assertTrue(desc.isSetHiveTable(), + "uppercase HMS must set THiveTable, matching the lowercase hms branch"); + Assertions.assertFalse(desc.isSetIcebergTable(), + "uppercase HMS must NOT set TIcebergTable"); + } + + @Test + public void defaultsToIcebergTableWhenCatalogTypeAbsent() { + // No iceberg.catalog.type property at all: legacy predicate getIcebergCatalogType().equals("hms") + // is false for any non-"hms" value, so the else (ICEBERG_TABLE) branch is taken. MUTATION: an + // unguarded properties.get(...).equals("hms") would NPE here -> red; a wrong default -> red. + TTableDescriptor desc = build(metadataWithCatalogType(null)); + + Assertions.assertEquals(TTableType.ICEBERG_TABLE, desc.getTableType(), + "absent catalog type must default to ICEBERG_TABLE (not hms)"); + Assertions.assertTrue(desc.isSetIcebergTable()); + } + + private static void assertAddressing(TTableDescriptor desc) { + Assertions.assertEquals(TABLE_ID, desc.getId(), "descriptor id must carry the tableId"); + Assertions.assertEquals(NUM_COLS, desc.getNumCols(), "descriptor numCols must carry numCols"); + Assertions.assertEquals(0, desc.getNumClusteringCols(), + "numClusteringCols must be 0 (sys/iceberg tables have no distribution; legacy parity)"); + Assertions.assertEquals(TABLE_NAME, desc.getTableName(), "descriptor tableName must carry the tableName"); + Assertions.assertEquals(DB_NAME, desc.getDbName(), "descriptor dbName must carry the dbName"); + // Empty property map in the nested table struct (legacy sent new HashMap<>()). + Assertions.assertEquals(Collections.emptyMap(), + desc.getTableType() == TTableType.HIVE_TABLE + ? desc.getHiveTable().getProperties() + : desc.getIcebergTable().getProperties(), + "nested table struct must carry an empty properties map (legacy parity)"); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCatalogFactoryTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCatalogFactoryTest.java new file mode 100644 index 00000000000000..9ae0d70b5eee91 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCatalogFactoryTest.java @@ -0,0 +1,1013 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.filesystem.properties.S3CompatibleFileSystemProperties; +import org.apache.doris.filesystem.properties.StorageProperties; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hive.conf.HiveConf; +import org.apache.iceberg.aws.AwsClientProperties; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider; +import software.amazon.awssdk.auth.credentials.WebIdentityTokenFileCredentialsProvider; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Unit tests for {@link IcebergCatalogFactory}, the pure flavor-resolution core. Mirrors the role + * of {@code PaimonCatalogFactoryTest}: every method is a pure transform over plain Maps / Strings + * (no env, no clock, no live catalog), so the tests are entirely offline. No Mockito. + * + *

    P6.1 baseline: the per-flavor catalog-impl class names MUST mirror the legacy fe-core + * {@code IcebergConnector.resolveCatalogImpl} switch. Here we pin the impl-name routing the current + * production code performs. + */ +public class IcebergCatalogFactoryTest { + + private static Map props(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + // --------------------------------------------------------------------- + // resolveFlavor — lower-cased iceberg.catalog.type, null when absent/blank + // --------------------------------------------------------------------- + + @Test + public void resolveFlavorLowerCasesTheCatalogType() { + // WHY: the second-level dispatch keys off the flavor; the legacy code lower-cases the raw + // iceberg.catalog.type so a user who writes "REST"/"Hms" still routes correctly. MUTATION: + // returning the raw value (no toLowerCase) -> "REST" != "rest" -> red. + Assertions.assertEquals("rest", + IcebergCatalogFactory.resolveFlavor(props("iceberg.catalog.type", "REST"))); + Assertions.assertEquals("hms", + IcebergCatalogFactory.resolveFlavor(props("iceberg.catalog.type", "Hms"))); + Assertions.assertEquals("glue", + IcebergCatalogFactory.resolveFlavor(props("iceberg.catalog.type", "glue"))); + } + + @Test + public void resolveFlavorReturnsNullWhenAbsent() { + // WHY: an absent iceberg.catalog.type must resolve to null (the "no second-level flavor" + // signal), NOT throw or invent a default. MUTATION: defaulting to a flavor string -> red. + Assertions.assertNull(IcebergCatalogFactory.resolveFlavor(Collections.emptyMap())); + } + + @Test + public void resolveFlavorReturnsNullWhenBlank() { + // WHY: a present-but-empty value is treated as absent (the production guard is + // null-or-isEmpty), so it must also fold to null. MUTATION: dropping the isEmpty() guard -> + // returns "" -> red. + Assertions.assertNull(IcebergCatalogFactory.resolveFlavor(props("iceberg.catalog.type", ""))); + } + + // --------------------------------------------------------------------- + // resolveCatalogImpl — per-flavor catalog-impl class name + // --------------------------------------------------------------------- + + @Test + public void resolveCatalogImplMapsRestToRestCatalog() { + // WHY: each flavor must resolve to the EXACT catalog-impl class CatalogUtil/the bespoke path + // instantiates; a wrong class name would build the wrong catalog backend. These names are the + // parity contract with legacy IcebergConnector.resolveCatalogImpl. MUTATION: any wrong/empty + // class name -> red. + Assertions.assertEquals("org.apache.iceberg.rest.RESTCatalog", + IcebergCatalogFactory.resolveCatalogImpl("rest")); + } + + @Test + public void resolveCatalogImplMapsHmsToHiveCatalog() { + Assertions.assertEquals("org.apache.iceberg.hive.HiveCatalog", + IcebergCatalogFactory.resolveCatalogImpl("hms")); + } + + @Test + public void resolveCatalogImplMapsGlueToGlueCatalog() { + Assertions.assertEquals("org.apache.iceberg.aws.glue.GlueCatalog", + IcebergCatalogFactory.resolveCatalogImpl("glue")); + } + + @Test + public void resolveCatalogImplMapsHadoopToHadoopCatalog() { + Assertions.assertEquals("org.apache.iceberg.hadoop.HadoopCatalog", + IcebergCatalogFactory.resolveCatalogImpl("hadoop")); + } + + @Test + public void resolveCatalogImplMapsJdbcToJdbcCatalog() { + Assertions.assertEquals("org.apache.iceberg.jdbc.JdbcCatalog", + IcebergCatalogFactory.resolveCatalogImpl("jdbc")); + } + + @Test + public void resolveCatalogImplMapsS3TablesToS3TablesCatalog() { + Assertions.assertEquals("software.amazon.s3tables.iceberg.S3TablesCatalog", + IcebergCatalogFactory.resolveCatalogImpl("s3tables")); + } + + @Test + public void resolveCatalogImplRejectsRemovedDlfFlavor() { + // WHY: iceberg.catalog.type=dlf (DLF 1.0 over the vendored thrift ProxyMetaStoreClient) was removed, so + // it must now hit the default arm and fail loud like any unknown flavor — never resolve to a class that + // no longer ships. MUTATION: re-adding a dlf arm -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> IcebergCatalogFactory.resolveCatalogImpl("dlf")); + // Assert on the supported-types LIST only: the message also echoes the rejected input, so a naive + // contains("dlf") over the whole message would match the echo and never fail. + String supported = ex.getMessage().substring(ex.getMessage().indexOf("Supported types:")); + Assertions.assertFalse(supported.contains("dlf"), + "the supported-types list must no longer advertise dlf: " + supported); + Assertions.assertTrue(supported.contains("glue"), + "glue is the iceberg-native backend and must stay supported: " + supported); + } + + @Test + public void resolveCatalogImplIsCaseInsensitive() { + // WHY: the switch lower-cases its input, so mixed/upper-case flavors (e.g. from a user who + // typed "REST"/"Hms") must still resolve. MUTATION: removing the toLowerCase in the switch -> + // the default branch throws on "REST" -> red. + Assertions.assertEquals("org.apache.iceberg.rest.RESTCatalog", + IcebergCatalogFactory.resolveCatalogImpl("REST")); + Assertions.assertEquals("org.apache.iceberg.hive.HiveCatalog", + IcebergCatalogFactory.resolveCatalogImpl("Hms")); + } + + @Test + public void resolveCatalogImplThrowsOnNull() { + // WHY: a null catalogType means the required iceberg.catalog.type property is missing; the + // factory must fail fast with a DorisConnectorException rather than NPE or return null. + // MUTATION: removing the null guard -> NPE on toLowerCase -> red (wrong exception type). + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> IcebergCatalogFactory.resolveCatalogImpl(null)); + Assertions.assertTrue(ex.getMessage().contains("iceberg.catalog.type"), + "the missing-property error must name the iceberg.catalog.type key"); + } + + @Test + public void resolveCatalogImplThrowsOnUnknownType() { + // WHY: an unrecognized flavor must be rejected loudly (not silently mapped to a default + // backend), so a typo surfaces at catalog creation. MUTATION: a default branch that returns + // some impl instead of throwing -> no exception -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> IcebergCatalogFactory.resolveCatalogImpl("nosuchcatalog")); + Assertions.assertTrue(ex.getMessage().contains("nosuchcatalog"), + "the unknown-type error must echo the offending flavor value"); + } + + // --------------------------------------------------------------------- + // buildBaseCatalogProperties — copy-all + warehouse + manifest cache (common base) + // --------------------------------------------------------------------- + + @Test + public void buildBaseCopiesAllPropsAndMapsWarehouse() { + // WHY: legacy AbstractIcebergProperties.initializeCatalog seeds catalogProps from getOrigProps() + // (copy-all) so arbitrary user/iceberg keys pass through to the SDK, then maps warehouse to + // CatalogProperties.WAREHOUSE_LOCATION ("warehouse"). MUTATION: dropping the copy-all (selective + // re-key) loses "foo"; a wrong warehouse key loses "warehouse" -> red. + Map opts = IcebergCatalogFactory.buildBaseCatalogProperties( + props("iceberg.catalog.type", "hadoop", "warehouse", "s3://b/wh", "foo", "bar")); + Assertions.assertEquals("bar", opts.get("foo")); + Assertions.assertEquals("s3://b/wh", opts.get("warehouse")); + } + + @Test + public void buildBaseDoesNotEnableManifestCacheByDefault() { + // WHY: legacy DEFAULT_ICEBERG_MANIFEST_CACHE_ENABLE=false — with no explicit + // io.manifest.cache-enabled and no meta.cache.iceberg.manifest.enable, the key must stay ABSENT + // (not default-on). MUTATION: unconditionally putting "true" -> red. + Map opts = IcebergCatalogFactory.buildBaseCatalogProperties( + props("iceberg.catalog.type", "rest")); + Assertions.assertNull(opts.get("io.manifest.cache-enabled")); + } + + @Test + public void buildBaseDerivesManifestCacheEnabledFromMetaCache() { + // WHY: when the FE meta-cache is enabled (meta.cache.iceberg.manifest.enable=true) and + // io.manifest.cache-enabled is not set directly, legacy derives io.manifest.cache-enabled=true. + // The key is DOTTED ("io.manifest.cache-enabled"); the recon agent guessed a hyphenated spelling. + // MUTATION: wrong key spelling OR skipping the derivation -> red. + Map opts = IcebergCatalogFactory.buildBaseCatalogProperties( + props("iceberg.catalog.type", "rest", "meta.cache.iceberg.manifest.enable", "true")); + Assertions.assertEquals("true", opts.get("io.manifest.cache-enabled")); + } + + @Test + public void buildBaseExplicitManifestCacheDisabledWinsOverMetaCache() { + // WHY: an explicit io.manifest.cache-enabled=false must short-circuit the meta-cache derivation + // (legacy hasIoManifestCacheEnabled guard), so the user's false is preserved. MUTATION: letting + // the derivation overwrite it to "true" -> red. + Map opts = IcebergCatalogFactory.buildBaseCatalogProperties( + props("iceberg.catalog.type", "rest", "io.manifest.cache-enabled", "false", + "meta.cache.iceberg.manifest.enable", "true")); + Assertions.assertEquals("false", opts.get("io.manifest.cache-enabled")); + } + + @Test + public void buildBaseMetaCacheEnabledButZeroTtlDoesNotEnable() { + // WHY: legacy isCacheEnabled = enable && ttl != 0 && capacity != 0; an explicit ttl-second=0 + // disables the cache even when enable=true, so io.manifest.cache-enabled must NOT be derived. + // MUTATION: deriving on enable alone (ignoring ttl/capacity==0) -> "true" -> red. + Map opts = IcebergCatalogFactory.buildBaseCatalogProperties( + props("iceberg.catalog.type", "rest", "meta.cache.iceberg.manifest.enable", "true", + "meta.cache.iceberg.manifest.ttl-second", "0")); + Assertions.assertNull(opts.get("io.manifest.cache-enabled")); + } + + // --------------------------------------------------------------------- + // appendS3FileIOProperties — storage creds -> iceberg S3FileIO dialect (D-061) + // --------------------------------------------------------------------- + + @Test + public void appendS3FileIoMapsAllCredentialFields() { + // WHY: mirror legacy toS3FileIOProperties — the connector reads the fe-filesystem typed + // S3CompatibleFileSystemProperties getters and emits the iceberg S3FileIO dialect with the + // VERIFIED SDK constants (region key is client.region, NOT aws.region). MUTATION: any wrong key + // spelling -> the asserted key is absent -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendS3FileIOProperties(opts, + new FakeS3CompatibleStorageProperties("S3") + .endpoint("https://s3.us-east-1.amazonaws.com").region("us-east-1") + .accessKey("AK").secretKey("SK").sessionToken("TK").usePathStyle("true")); + Assertions.assertEquals("https://s3.us-east-1.amazonaws.com", opts.get("s3.endpoint")); + Assertions.assertEquals("true", opts.get("s3.path-style-access")); + Assertions.assertEquals("us-east-1", opts.get("client.region")); + Assertions.assertEquals("AK", opts.get("s3.access-key-id")); + Assertions.assertEquals("SK", opts.get("s3.secret-access-key")); + Assertions.assertEquals("TK", opts.get("s3.session-token")); + } + + @Test + public void appendS3FileIoOmitsBlankFields() { + // WHY: legacy guards every put with isNotBlank, so an unset credential must NOT be emitted as an + // empty value (which would override the real chain). MUTATION: unconditional put -> "" present -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendS3FileIOProperties(opts, + new FakeS3CompatibleStorageProperties("S3").region("us-east-1")); + Assertions.assertEquals("us-east-1", opts.get("client.region")); + Assertions.assertNull(opts.get("s3.access-key-id")); + Assertions.assertNull(opts.get("s3.endpoint")); + } + + @Test + public void appendS3FileIoEmitsAssumeRoleForGenericS3() { + // WHY: legacy putAssumeRoleProperties fires only for the generic S3 type (instanceof S3Properties) + // and a non-blank role ARN; keys = client.factory + aws.region + client.assume-role.{region,arn, + // external-id}. MUTATION: wrong keys / missing external-id -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendS3FileIOProperties(opts, + new FakeS3CompatibleStorageProperties("S3").region("us-west-2") + .roleArn("arn:aws:iam::1:role/r").externalId("eid")); + Assertions.assertEquals("org.apache.iceberg.aws.AssumeRoleAwsClientFactory", opts.get("client.factory")); + Assertions.assertEquals("us-west-2", opts.get("aws.region")); + Assertions.assertEquals("us-west-2", opts.get("client.assume-role.region")); + Assertions.assertEquals("arn:aws:iam::1:role/r", opts.get("client.assume-role.arn")); + Assertions.assertEquals("eid", opts.get("client.assume-role.external-id")); + } + + @Test + public void appendS3FileIoSkipsAssumeRoleForNonGenericS3() { + // WHY: legacy gates assume-role on instanceof S3Properties (generic AWS). OSS/COS/OBS + // (providerName != "S3") must NOT get the assume-role block even with a role ARN. MUTATION: + // gating on roleArn alone (ignoring provider) -> client.factory present -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendS3FileIOProperties(opts, + new FakeS3CompatibleStorageProperties("OSS").region("oss-cn").roleArn("arn:aws:iam::1:role/r")); + Assertions.assertNull(opts.get("client.factory")); + Assertions.assertNull(opts.get("client.assume-role.arn")); + } + + // --------------------------------------------------------------------- + // chooseS3Compatible — prefer explicit non-S3 subtype (mirror legacy) + // --------------------------------------------------------------------- + + @Test + public void chooseS3CompatiblePrefersNonS3Subtype() { + // WHY: legacy toFileIOProperties prefers the first NON-S3Properties S3-compatible storage (an + // explicit OSS/COS/OBS choice trumps the generic S3 fallback). MUTATION: returning the S3 + // fallback when an OSS is present -> red. + List storages = Arrays.asList( + new FakeS3CompatibleStorageProperties("S3"), + new FakeS3CompatibleStorageProperties("OSS")); + Optional chosen = IcebergCatalogFactory.chooseS3Compatible(storages); + Assertions.assertTrue(chosen.isPresent()); + Assertions.assertEquals("OSS", chosen.get().providerName()); + } + + @Test + public void chooseS3CompatibleFallsBackToGenericS3() { + // WHY: when only the generic S3 type is present it is the chosen one (fallback). MUTATION: + // returning empty when an S3 is present -> red. + Optional chosen = IcebergCatalogFactory.chooseS3Compatible( + Collections.singletonList(new FakeS3CompatibleStorageProperties("S3"))); + Assertions.assertTrue(chosen.isPresent()); + Assertions.assertEquals("S3", chosen.get().providerName()); + } + + @Test + public void chooseS3CompatibleEmptyWhenNoS3Storage() { + // WHY: a credential-less / HDFS-only catalog has no S3-compatible storage, so no S3FileIO props + // are emitted (empty Optional). MUTATION: returning a present value -> red. + Assertions.assertFalse(IcebergCatalogFactory.chooseS3Compatible(Collections.emptyList()).isPresent()); + } + + // --------------------------------------------------------------------- + // appendRestProperties — mirror IcebergRestProperties + // --------------------------------------------------------------------- + + @Test + public void appendRestEmitsUriAlwaysWithAliasPriority() { + // WHY: legacy puts CatalogProperties.URI UNCONDITIONALLY (field default ""), alias priority + // iceberg.rest.uri > uri. MUTATION: only-if-nonblank put OR wrong alias order -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(opts, + props("iceberg.rest.uri", "https://rest", "uri", "https://other"), Optional.empty()); + Assertions.assertEquals("https://rest", opts.get("uri")); + + Map empty = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(empty, props(), Optional.empty()); + Assertions.assertEquals("", empty.get("uri"), "uri must be emitted as empty string when no alias is set"); + } + + @Test + public void appendRestEmitsPrefixOnlyWhenSet() { + // WHY: legacy emits "prefix" only if iceberg.rest.prefix is non-blank. MUTATION: unconditional put -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(opts, props("iceberg.rest.prefix", "p1"), Optional.empty()); + Assertions.assertEquals("p1", opts.get("prefix")); + Map none = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(none, props(), Optional.empty()); + Assertions.assertNull(none.get("prefix")); + } + + @Test + public void appendRestEmitsVendedCredentialsHeaderWhenEnabled() { + // WHY: legacy puts header.X-Iceberg-Access-Delegation=vended-credentials iff + // Boolean.parseBoolean(iceberg.rest.vended-credentials-enabled). MUTATION: wrong header key/value or + // emitting when disabled -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(opts, + props("iceberg.rest.vended-credentials-enabled", "true"), Optional.empty()); + Assertions.assertEquals("vended-credentials", opts.get("header.X-Iceberg-Access-Delegation")); + Map off = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(off, props(), Optional.empty()); + Assertions.assertNull(off.get("header.X-Iceberg-Access-Delegation")); + } + + @Test + public void appendRestEmitsTimeoutsWithDefaults() { + // WHY: legacy fields default non-blank (10000 / 60000) and are put effectively always under the literal + // keys rest.client.connection-timeout-ms / rest.client.socket-timeout-ms. MUTATION: wrong defaults or + // wrong literal keys -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(opts, props(), Optional.empty()); + Assertions.assertEquals("10000", opts.get("rest.client.connection-timeout-ms")); + Assertions.assertEquals("60000", opts.get("rest.client.socket-timeout-ms")); + Map over = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(over, + props("iceberg.rest.connection-timeout-ms", "5000", "iceberg.rest.socket-timeout-ms", "7000"), + Optional.empty()); + Assertions.assertEquals("5000", over.get("rest.client.connection-timeout-ms")); + Assertions.assertEquals("7000", over.get("rest.client.socket-timeout-ms")); + } + + @Test + public void appendRestOAuth2CredentialBranchEmitsCredentialAndTokenRefreshDefault() { + // WHY: when security.type=oauth2 and a credential is present, legacy emits credential + optional + // server-uri/scope + token-refresh-enabled (default "true" from OAuth2Properties default). + // MUTATION: emitting token instead, wrong keys, or dropping token-refresh-enabled -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(opts, + props("iceberg.rest.security.type", "oauth2", + "iceberg.rest.oauth2.credential", "id:secret", + "iceberg.rest.oauth2.server-uri", "https://auth", + "iceberg.rest.oauth2.scope", "catalog"), + Optional.empty()); + Assertions.assertEquals("id:secret", opts.get("credential")); + Assertions.assertEquals("https://auth", opts.get("oauth2-server-uri")); + Assertions.assertEquals("catalog", opts.get("scope")); + Assertions.assertEquals("true", opts.get("token-refresh-enabled")); + Assertions.assertNull(opts.get("token"), "credential branch must NOT emit a token"); + } + + @Test + public void appendRestOAuth2TokenBranchWhenNoCredential() { + // WHY: oauth2 with no credential uses the pre-configured token flow: emit OAuth2Properties.TOKEN. + // MUTATION: emitting credential / token-refresh-enabled here -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(opts, + props("iceberg.rest.security.type", "oauth2", "iceberg.rest.oauth2.token", "tok"), Optional.empty()); + Assertions.assertEquals("tok", opts.get("token")); + Assertions.assertNull(opts.get("credential")); + Assertions.assertNull(opts.get("token-refresh-enabled")); + } + + @Test + public void appendRestOAuth2NotAppliedWhenSecurityNotOauth2() { + // WHY: the oauth2 block is gated on security.type==oauth2 (default none). MUTATION: applying it + // unconditionally would leak credential/token even for a non-oauth2 catalog -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(opts, + props("iceberg.rest.oauth2.credential", "id:secret"), Optional.empty()); + Assertions.assertNull(opts.get("credential")); + } + + @Test + public void appendRestSigningBlockEmitsSigningKeysAndS3ExplicitCredentials() { + // WHY: when signing-name is set, legacy emits rest.signing-name/sigv4-enabled/signing-region; for + // glue/s3tables the credentials come from the chosen S3 store, EXPLICIT (static AK/SK) -> rest.* creds + // (AwsProperties.REST_*). MUTATION: wrong signing keys, or sourcing creds from the wrong place -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(opts, + props("iceberg.rest.signing-name", "glue", "iceberg.rest.sigv4-enabled", "true", + "iceberg.rest.signing-region", "us-east-1"), + Optional.of(new FakeS3CompatibleStorageProperties("S3").accessKey("AK").secretKey("SK") + .sessionToken("TK"))); + Assertions.assertEquals("glue", opts.get("rest.signing-name")); + Assertions.assertEquals("true", opts.get("rest.sigv4-enabled")); + Assertions.assertEquals("us-east-1", opts.get("rest.signing-region")); + Assertions.assertEquals("AK", opts.get("rest.access-key-id")); + Assertions.assertEquals("SK", opts.get("rest.secret-access-key")); + Assertions.assertEquals("TK", opts.get("rest.session-token")); + } + + @Test + public void appendRestSigningGlueAssumeRoleWhenNoStaticCreds() { + // WHY: legacy getCredentialType precedence is EXPLICIT then ASSUME_ROLE; with no static AK/SK but a role + // ARN the glue/s3tables signing path emits the assume-role block (client.factory + client.assume-role.*). + // MUTATION: emitting rest.access-key-id from a blank AK, or skipping assume-role -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(opts, + props("iceberg.rest.signing-name", "s3tables", "iceberg.rest.sigv4-enabled", "true", + "iceberg.rest.signing-region", "us-west-2"), + Optional.of(new FakeS3CompatibleStorageProperties("S3").region("us-west-2") + .roleArn("arn:aws:iam::1:role/r"))); + Assertions.assertEquals("org.apache.iceberg.aws.AssumeRoleAwsClientFactory", opts.get("client.factory")); + Assertions.assertEquals("arn:aws:iam::1:role/r", opts.get("client.assume-role.arn")); + Assertions.assertNull(opts.get("rest.access-key-id"), "no static creds -> no explicit rest creds"); + } + + @Test + public void appendRestSigningOtherNameUsesIcebergRestCredentials() { + // WHY: a signing-name NOT in {glue,s3tables} uses the iceberg.rest.* explicit creds (not the S3 store). + // MUTATION: reading the S3 store here -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(opts, + props("iceberg.rest.signing-name", "custom", + "iceberg.rest.access-key-id", "RAK", "iceberg.rest.secret-access-key", "RSK"), + Optional.of(new FakeS3CompatibleStorageProperties("S3").accessKey("SHOULD_NOT_USE") + .secretKey("SHOULD_NOT_USE"))); + Assertions.assertEquals("RAK", opts.get("rest.access-key-id")); + Assertions.assertEquals("RSK", opts.get("rest.secret-access-key")); + } + + @Test + public void appendRestSigningGlueProviderChainPinsNonDefaultProvider() { + // F14: glue/s3tables signing with NO static creds and NO role -> PROVIDER_CHAIN. A non-DEFAULT + // s3.credentials_provider_type must pin client.credentials-provider to that provider class (was silently + // dropped). MUTATION: dropping the else branch -> the key is absent -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(opts, + props("iceberg.rest.signing-name", "glue", "s3.credentials_provider_type", "anonymous"), + Optional.of(new FakeS3CompatibleStorageProperties("S3").region("us-east-1"))); + Assertions.assertEquals(AnonymousCredentialsProvider.class.getName(), + opts.get("client.credentials-provider")); + + // DEFAULT / absent -> nothing emitted (SDK default chain, the common case). + Map defaultOpts = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(defaultOpts, + props("iceberg.rest.signing-name", "glue"), + Optional.of(new FakeS3CompatibleStorageProperties("S3").region("us-east-1"))); + Assertions.assertNull(defaultOpts.get("client.credentials-provider"), + "DEFAULT/absent provider mode must emit no client.credentials-provider"); + } + + @Test + public void appendRestSigningOtherNameProviderChainPinsNonDefaultProvider() { + // F14: a non-glue/s3tables signing-name with NO explicit iceberg.rest.* creds falls to PROVIDER_CHAIN; + // iceberg.rest.credentials_provider_type pins the provider class. MUTATION: dropping the else -> absent. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(opts, + props("iceberg.rest.signing-name", "custom", + "iceberg.rest.credentials_provider_type", "web-identity"), + Optional.of(new FakeS3CompatibleStorageProperties("S3"))); + Assertions.assertEquals(WebIdentityTokenFileCredentialsProvider.class.getName(), + opts.get("client.credentials-provider")); + Assertions.assertNull(opts.get("rest.access-key-id"), "no explicit rest creds were supplied"); + } + + @Test + public void appendRestNoSigningBlockWhenSigningNameAbsent() { + // WHY: the entire signing block is gated on a non-blank signing-name. MUTATION: emitting rest.signing-name + // (even empty) without the gate -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendRestProperties(opts, props(), Optional.empty()); + Assertions.assertNull(opts.get("rest.signing-name")); + Assertions.assertNull(opts.get("rest.sigv4-enabled")); + } + + // --------------------------------------------------------------------- + // appendGlueProperties — mirror IcebergGlueMetaStoreProperties + // --------------------------------------------------------------------- + + @Test + public void appendGlueEmitsS3KeysUnconditionallyFromChosenStore() { + // WHY: legacy appendS3Props uses PLAIN puts (no isNotBlank guard) from S3Properties.of(origProps), so an + // unset session token is written as "". MUTATION: blank-guarding these puts (like the base S3FileIO path) + // -> the empty s3.session-token would be absent -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendGlueProperties(opts, props("glue.access_key", "a", "glue.secret_key", "b", + "glue.endpoint", "https://glue.us-east-1.amazonaws.com"), + Optional.of(new FakeS3CompatibleStorageProperties("S3").accessKey("S3AK").secretKey("S3SK") + .endpoint("https://s3").usePathStyle("true"))); + Assertions.assertEquals("S3AK", opts.get("s3.access-key-id")); + Assertions.assertEquals("S3SK", opts.get("s3.secret-access-key")); + Assertions.assertEquals("https://s3", opts.get("s3.endpoint")); + Assertions.assertEquals("true", opts.get("s3.path-style-access")); + Assertions.assertEquals("", opts.get("s3.session-token"), "blank session token must be emitted as empty"); + } + + @Test + public void appendGlueAccessKeyBranchEmitsProviderKeysAndWins() { + // WHY: when glue access_key & secret_key are both set, emit the ConfigurationAWSCredentialsProvider2x + // provider keys and RETURN (mutually exclusive with the IAM-role branch). MUTATION: wrong key/value, or + // also emitting client.factory (IAM branch) -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendGlueProperties(opts, + props("glue.access_key", "GAK", "glue.secret_key", "GSK", "aws.glue.session-token", "GST", + "glue.role_arn", "arn:aws:iam::1:role/should-not-fire", + "glue.endpoint", "https://glue.us-east-1.amazonaws.com"), + Optional.empty()); + Assertions.assertEquals("org.apache.doris.connector.iceberg.glue.ConfigurationAWSCredentialsProvider2x", + opts.get("client.credentials-provider")); + Assertions.assertEquals("GAK", opts.get("client.credentials-provider.glue.access_key")); + Assertions.assertEquals("GSK", opts.get("client.credentials-provider.glue.secret_key")); + Assertions.assertEquals("GST", opts.get("client.credentials-provider.glue.session_token")); + Assertions.assertNull(opts.get("aws.catalog.credentials.provider.factory.class"), + "the factory key was only ever read by the removed thrift-generation Glue client"); + Assertions.assertNull(opts.get("client.factory"), "AK/SK branch must short-circuit the IAM-role branch"); + } + + @Test + public void glueSessionTokenSurvivesToTheResolvedCredential() { + // Drives the REAL iceberg plumbing end to end -- emit -> AwsClientProperties strips the + // "client.credentials-provider." prefix -> DynMethods reflects into create(Map) -> we read the token. + // Asserting the emission alone (see the test above) cannot catch the two halves drifting apart: the + // provider used to read only ak/sk, so a supplied token was dropped here and AWS then rejected the + // temporary credentials. MUTATION: dropping the token read in create() -> AwsBasicCredentials -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendGlueProperties(opts, + props("glue.access_key", "GAK", "glue.secret_key", "GSK", "aws.glue.session-token", "GST", + "glue.endpoint", "https://glue.us-east-1.amazonaws.com"), + Optional.empty()); + + // null ak/sk so iceberg falls back to the named client.credentials-provider (the glue client's path). + AwsCredentials resolved = new AwsClientProperties(opts) + .credentialsProvider(null, null, null) + .resolveCredentials(); + + Assertions.assertInstanceOf(AwsSessionCredentials.class, resolved, + "a supplied glue session token must yield session credentials, not basic ones"); + Assertions.assertEquals("GST", ((AwsSessionCredentials) resolved).sessionToken()); + Assertions.assertEquals("GAK", resolved.accessKeyId()); + Assertions.assertEquals("GSK", resolved.secretAccessKey()); + } + + @Test + public void glueWithoutSessionTokenStaysBasicCredentials() { + // The no-token path must keep today's behaviour. MUTATION: building session credentials unconditionally + // -> AwsSessionCredentials.create accepts a blank token silently -> this turns red instead of AWS + // rejecting it much later with a confusing 4xx. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendGlueProperties(opts, + props("glue.access_key", "GAK", "glue.secret_key", "GSK", + "glue.endpoint", "https://glue.us-east-1.amazonaws.com"), + Optional.empty()); + + AwsCredentials resolved = new AwsClientProperties(opts) + .credentialsProvider(null, null, null) + .resolveCredentials(); + + Assertions.assertInstanceOf(AwsBasicCredentials.class, resolved); + Assertions.assertEquals("GAK", resolved.accessKeyId()); + Assertions.assertEquals("GSK", resolved.secretAccessKey()); + } + + @Test + public void appendGlueIamRoleBranchWhenNoAccessKey() { + // WHY: with no glue AK/SK but a glue.role_arn, legacy emits the assume-role block (client.factory + + // aws.region + client.assume-role.arn/region + optional external-id). MUTATION: wrong keys or skipping + // external-id -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendGlueProperties(opts, + props("glue.role_arn", "arn:aws:iam::1:role/r", "glue.external_id", "eid", "glue.region", "eu-west-1", + "glue.endpoint", "https://glue.eu-west-1.amazonaws.com"), + Optional.empty()); + Assertions.assertEquals("org.apache.iceberg.aws.AssumeRoleAwsClientFactory", opts.get("client.factory")); + Assertions.assertEquals("eu-west-1", opts.get("aws.region")); + Assertions.assertEquals("arn:aws:iam::1:role/r", opts.get("client.assume-role.arn")); + Assertions.assertEquals("eu-west-1", opts.get("client.assume-role.region")); + Assertions.assertEquals("eid", opts.get("client.assume-role.external-id")); + } + + @Test + public void appendGlueEmitsEndpointAndClientRegionAlways() { + // WHY: legacy always puts glue.endpoint (AwsProperties.GLUE_CATALOG_ENDPOINT) and client.region. MUTATION: + // dropping either -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendGlueProperties(opts, + props("glue.access_key", "a", "glue.secret_key", "b", "glue.region", "ap-south-1", + "glue.endpoint", "https://glue.ap-south-1.amazonaws.com"), + Optional.empty()); + Assertions.assertEquals("https://glue.ap-south-1.amazonaws.com", opts.get("glue.endpoint")); + Assertions.assertEquals("ap-south-1", opts.get("client.region")); + } + + @Test + public void appendGlueRegionFallsBackToEndpointRegexThenUsEast1() { + // WHY: legacy resolves the region from glue.region first, else extracts it from the endpoint host via + // ENDPOINT_PATTERN, else us-east-1. MUTATION: not extracting from the endpoint, or wrong fallback -> red. + Map fromEndpoint = new HashMap<>(); + IcebergCatalogFactory.appendGlueProperties(fromEndpoint, + props("glue.access_key", "a", "glue.secret_key", "b", + "glue.endpoint", "https://glue-fips.ca-central-1.api.aws"), + Optional.empty()); + Assertions.assertEquals("ca-central-1", fromEndpoint.get("client.region")); + + Map defaulted = new HashMap<>(); + IcebergCatalogFactory.appendGlueProperties(defaulted, + props("glue.access_key", "a", "glue.secret_key", "b", "glue.endpoint", "https://not-a-glue-host"), + Optional.empty()); + Assertions.assertEquals("us-east-1", defaulted.get("client.region")); + } + + @Test + public void appendGluePutsWarehousePlaceholderOnlyWhenAbsent() { + // WHY: legacy putIfAbsent(WAREHOUSE_LOCATION, "s3://doris") — fills the placeholder only when the user + // did not supply a warehouse. MUTATION: an unconditional put would clobber the user's warehouse -> red. + Map defaulted = new HashMap<>(); + IcebergCatalogFactory.appendGlueProperties(defaulted, + props("glue.access_key", "a", "glue.secret_key", "b", "glue.endpoint", "https://glue.x.amazonaws.com"), + Optional.empty()); + Assertions.assertEquals("s3://doris", defaulted.get("warehouse")); + + Map userWh = new HashMap<>(); + userWh.put("warehouse", "s3://mybucket/wh"); + IcebergCatalogFactory.appendGlueProperties(userWh, + props("glue.access_key", "a", "glue.secret_key", "b", "glue.endpoint", "https://glue.x.amazonaws.com"), + Optional.empty()); + Assertions.assertEquals("s3://mybucket/wh", userWh.get("warehouse")); + } + + // --------------------------------------------------------------------- + // appendJdbcProperties — mirror IcebergJdbcMetaStoreProperties + // --------------------------------------------------------------------- + + @Test + public void appendJdbcEmitsUriWithAliasPriority() { + // WHY: legacy uri alias priority is {uri, iceberg.jdbc.uri} (uri wins). MUTATION: wrong order -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendJdbcProperties(opts, + props("uri", "jdbc:mysql://h/db", "iceberg.jdbc.uri", "jdbc:other")); + Assertions.assertEquals("jdbc:mysql://h/db", opts.get("uri")); + } + + @Test + public void appendJdbcAddsDottedKeysOnlyWhenSet() { + // WHY: legacy addIfNotBlank maps each iceberg.jdbc. to the dotted jdbc. only when non-blank. + // MUTATION: wrong emitted key spelling or emitting a blank value -> red. + Map opts = new HashMap<>(); + IcebergCatalogFactory.appendJdbcProperties(opts, + props("uri", "jdbc:mysql://h/db", "iceberg.jdbc.user", "u", "iceberg.jdbc.password", "p", + "iceberg.jdbc.init-catalog-tables", "true", "iceberg.jdbc.schema-version", "V1", + "iceberg.jdbc.strict-mode", "false")); + Assertions.assertEquals("u", opts.get("jdbc.user")); + Assertions.assertEquals("p", opts.get("jdbc.password")); + Assertions.assertEquals("true", opts.get("jdbc.init-catalog-tables")); + Assertions.assertEquals("V1", opts.get("jdbc.schema-version")); + Assertions.assertEquals("false", opts.get("jdbc.strict-mode")); + + Map bare = new HashMap<>(); + IcebergCatalogFactory.appendJdbcProperties(bare, props("uri", "jdbc:mysql://h/db")); + Assertions.assertNull(bare.get("jdbc.user"), "an unset jdbc.user must NOT be emitted"); + } + + // --------------------------------------------------------------------- + // buildCatalogProperties — orchestrator (impl per flavor, type removed, jdbc catalog_name removed) + // --------------------------------------------------------------------- + + @Test + public void buildCatalogPropertiesSetsImplAndRemovesType() { + // WHY: every flavor must set the correct catalog-impl and the iceberg SDK forbids both "type" and + // "catalog-impl", so "type" (= iceberg.catalog.type's SDK alias) must be removed. The Doris-side + // iceberg.catalog.type key is a separate raw key carried by copy-all and is harmless. MUTATION: not + // setting impl, or leaving "type" -> red. + Map opts = IcebergCatalogFactory.buildCatalogProperties( + props("iceberg.catalog.type", "hadoop", "warehouse", "s3://b/wh", "type", "hadoop"), + "hadoop", Optional.empty()); + Assertions.assertEquals("org.apache.iceberg.hadoop.HadoopCatalog", opts.get("catalog-impl")); + Assertions.assertNull(opts.get("type"), "the SDK 'type' key must be removed before building"); + } + + @Test + public void buildCatalogPropertiesRemovesJdbcCatalogNameFromMap() { + // WHY: iceberg.jdbc.catalog_name is the positional catalog NAME, removed from the options map by legacy + // initCatalog. MUTATION: leaving it in the map -> red (iceberg would treat it as an unknown option). + Map opts = IcebergCatalogFactory.buildCatalogProperties( + props("iceberg.catalog.type", "jdbc", "uri", "jdbc:mysql://h/db", "warehouse", "s3://b/wh", + "iceberg.jdbc.catalog_name", "mycat"), + "jdbc", Optional.empty()); + Assertions.assertEquals("org.apache.iceberg.jdbc.JdbcCatalog", opts.get("catalog-impl")); + Assertions.assertNull(opts.get("iceberg.jdbc.catalog_name"), + "the jdbc catalog_name must be consumed positionally, not left in the options map"); + } + + @Test + public void buildCatalogPropertiesHmsEmitsNoS3FileIoKeys() { + // WHY: legacy iceberg HMS does NOT call toFileIOProperties — object-store access rides the HiveConf, not + // the s3.* options. MUTATION: appending the base S3FileIO for HMS -> s3.endpoint present -> red. + Map opts = IcebergCatalogFactory.buildCatalogProperties( + props("iceberg.catalog.type", "hms"), "hms", + Optional.of(new FakeS3CompatibleStorageProperties("S3").endpoint("https://s3").accessKey("AK"))); + Assertions.assertEquals("org.apache.iceberg.hive.HiveCatalog", opts.get("catalog-impl")); + Assertions.assertNull(opts.get("s3.endpoint"), "HMS must not emit S3FileIO options"); + Assertions.assertNull(opts.get("s3.access-key-id")); + } + + @Test + public void buildCatalogPropertiesRestVendedPropagatesClientRegionWithoutBoundS3() { + // WHY: a REST catalog with vended credentials binds NO fe-filesystem S3 storage (no static AK/SK/role -> + // chosenS3 empty), yet iceberg S3FileIO still needs client.region or it falls through to the AWS SDK + // DefaultAwsRegionProviderChain and the write commit fails with "Unable to load region". The raw s3.region + // carried by copy-all is inert (iceberg reads client.region). Legacy toFileIOProperties supplied this in + // its chosen==null branch. MUTATION: dropping the empty-chosenS3 region fallback -> client.region absent -> red. + Map opts = IcebergCatalogFactory.buildCatalogProperties( + props("iceberg.catalog.type", "rest", "uri", "https://rest", + "iceberg.rest.vended-credentials-enabled", "true", "s3.endpoint", "https://minio:9000", + "s3.region", "us-east-1"), + "rest", Optional.empty()); + Assertions.assertEquals("us-east-1", opts.get("client.region"), + "vended REST (no bound S3) must still translate s3.region -> client.region"); + } + + @Test + public void buildCatalogPropertiesRestVendedResolvesRegionFromWidenedAliases() { + // WHY: the empty-chosenS3 region fallback must scan the SAME region aliases legacy getRegionFromProperties + // did (the fe-core S3Properties isRegionField set), not just {s3.region, aws.region, region, client.region}. + // A vended REST catalog whose region arrives only via AWS_REGION or iceberg.rest.signing-region would + // otherwise yield no client.region -> AWS SDK DefaultAwsRegionProviderChain -> "Unable to load region". + // RED before widening: AWS_REGION (uppercase) does not match the narrow lowercase aws.region and + // iceberg.rest.signing-region is absent from the narrow 4-alias set -> client.region null. + Map viaAwsRegion = IcebergCatalogFactory.buildCatalogProperties( + props("iceberg.catalog.type", "rest", "uri", "https://rest", + "iceberg.rest.vended-credentials-enabled", "true", "AWS_REGION", "us-east-1"), + "rest", Optional.empty()); + Assertions.assertEquals("us-east-1", viaAwsRegion.get("client.region"), + "region supplied only via AWS_REGION must translate to client.region"); + + Map viaSigningRegion = IcebergCatalogFactory.buildCatalogProperties( + props("iceberg.catalog.type", "rest", "uri", "https://rest", + "iceberg.rest.vended-credentials-enabled", "true", + "iceberg.rest.signing-region", "eu-west-1"), + "rest", Optional.empty()); + Assertions.assertEquals("eu-west-1", viaSigningRegion.get("client.region"), + "region supplied only via iceberg.rest.signing-region must translate to client.region"); + } + + // --------------------------------------------------------------------- + // buildS3TablesCatalogProperties — bespoke s3tables options (NO catalog-impl, NO type removal) + // --------------------------------------------------------------------- + + @Test + public void buildS3TablesCatalogPropertiesEmitsS3FileIoAndWarehouseNoImpl() { + // WHY: s3tables is NOT built via CatalogUtil — legacy IcebergS3TablesMetaStoreProperties hands the + // 3-arg S3TablesCatalog.initialize a props map = getOrigProps + warehouse(=table-bucket ARN) + + // manifest-cache + S3FileIO creds, and adds NEITHER "catalog-impl" NOR removes "type" (those are the + // CatalogUtil path's concern). MUTATION: adding catalog-impl, removing type, or dropping S3FileIO -> red. + Map opts = IcebergCatalogFactory.buildS3TablesCatalogProperties( + props("iceberg.catalog.type", "s3tables", "type", "iceberg", + "warehouse", "arn:aws:s3tables:us-east-1:1:bucket/b"), + Optional.of(new FakeS3CompatibleStorageProperties("S3") + .endpoint("https://s3.us-east-1.amazonaws.com").region("us-east-1") + .accessKey("AK").secretKey("SK").sessionToken("TK").usePathStyle("true"))); + Assertions.assertEquals("arn:aws:s3tables:us-east-1:1:bucket/b", opts.get("warehouse"), + "the table-bucket ARN warehouse must be carried through for the 3-arg initialize"); + Assertions.assertEquals("AK", opts.get("s3.access-key-id")); + Assertions.assertEquals("SK", opts.get("s3.secret-access-key")); + Assertions.assertEquals("TK", opts.get("s3.session-token")); + Assertions.assertEquals("https://s3.us-east-1.amazonaws.com", opts.get("s3.endpoint")); + Assertions.assertEquals("us-east-1", opts.get("client.region")); + Assertions.assertEquals("true", opts.get("s3.path-style-access")); + Assertions.assertNull(opts.get("catalog-impl"), + "bespoke s3tables initialize must not receive a catalog-impl"); + Assertions.assertEquals("iceberg", opts.get("type"), + "bespoke s3tables path does not perform the CatalogUtil 'type' removal"); + } + + @Test + public void buildS3TablesCatalogPropertiesEmitsAssumeRoleWhenNoStaticCreds() { + // WHY: with no static AK/SK but a role ARN, the FileIO credential block is the generic-S3 assume-role + // keys (client.factory + aws.region + client.assume-role.{region,arn,external-id}) — the same path the + // legacy putS3FileIOCredentialProperties ASSUME_ROLE branch emits. MUTATION: missing assume-role keys + // OR leaking static AK/SK -> red. + Map opts = IcebergCatalogFactory.buildS3TablesCatalogProperties( + props("iceberg.catalog.type", "s3tables", "warehouse", "arn:aws:s3tables:us-west-2:1:bucket/b"), + Optional.of(new FakeS3CompatibleStorageProperties("S3").region("us-west-2") + .roleArn("arn:aws:iam::1:role/r").externalId("eid"))); + Assertions.assertEquals("org.apache.iceberg.aws.AssumeRoleAwsClientFactory", opts.get("client.factory")); + Assertions.assertEquals("arn:aws:iam::1:role/r", opts.get("client.assume-role.arn")); + Assertions.assertEquals("us-west-2", opts.get("client.assume-role.region")); + Assertions.assertEquals("eid", opts.get("client.assume-role.external-id")); + Assertions.assertEquals("us-west-2", opts.get("client.region")); + Assertions.assertNull(opts.get("s3.access-key-id"), "no static creds were supplied"); + } + + @Test + public void buildS3TablesCatalogPropertiesEmitsProviderChainWhenNoStaticNoRole() { + // F14: s3tables FileIO with NO static creds and NO role -> PROVIDER_CHAIN. A non-DEFAULT + // s3.credentials_provider_type pins client.credentials-provider (mirrors legacy putCredentialsProvider). + // MUTATION: dropping the else branch in appendS3TablesFileIOProperties -> the key is absent -> red. + Map opts = IcebergCatalogFactory.buildS3TablesCatalogProperties( + props("iceberg.catalog.type", "s3tables", "warehouse", "arn:aws:s3tables:us-west-2:1:bucket/b", + "s3.credentials_provider_type", "ENV"), + Optional.of(new FakeS3CompatibleStorageProperties("S3").region("us-west-2"))); + Assertions.assertEquals(EnvironmentVariableCredentialsProvider.class.getName(), + opts.get("client.credentials-provider")); + Assertions.assertNull(opts.get("client.factory"), "no role -> no assume-role block"); + } + + @Test + public void buildS3TablesCatalogPropertiesExplicitStaticCredsSuppressAssumeRole() { + // WHY: s3tables uses legacy putS3FileIOCredentialProperties, whose getCredentialType is EXPLICIT-wins — + // static AK/SK present returns BEFORE any assume-role keys, EVEN when a role ARN is ALSO configured. This + // differs from the generic toS3FileIOProperties (rest/hadoop/jdbc) which always emits assume-role-if-role. + // The s3tables path must NOT reuse the always-emit appendS3FileIOProperties helper. MUTATION: emitting the + // assume-role block (client.factory / client.assume-role.*) when static creds are present -> red. + Map opts = IcebergCatalogFactory.buildS3TablesCatalogProperties( + props("iceberg.catalog.type", "s3tables", "warehouse", "arn:aws:s3tables:us-west-2:1:bucket/b"), + Optional.of(new FakeS3CompatibleStorageProperties("S3").region("us-west-2") + .accessKey("AK").secretKey("SK").roleArn("arn:aws:iam::1:role/r"))); + Assertions.assertEquals("AK", opts.get("s3.access-key-id")); + Assertions.assertEquals("SK", opts.get("s3.secret-access-key")); + Assertions.assertNull(opts.get("client.factory"), + "EXPLICIT static creds must suppress the assume-role block on the s3tables path"); + Assertions.assertNull(opts.get("client.assume-role.arn")); + } + + @Test + public void buildS3TablesCatalogPropertiesWithoutStorageOmitsS3FileIo() { + // WHY: with no bound S3-compatible storage AND no region alias in the props, only the base keys are present + // (warehouse + manifest-cache) — no s3.* credential keys are fabricated, and client.region stays absent + // because there is no region to propagate. (When a region IS present it is now emitted; see + // buildS3TablesCatalogPropertiesPropagatesClientRegionWithoutBoundS3.) MUTATION: fabricating any s3.* -> red. + Map opts = IcebergCatalogFactory.buildS3TablesCatalogProperties( + props("iceberg.catalog.type", "s3tables", "warehouse", "arn:aws:s3tables:us-east-1:1:bucket/b"), + Optional.empty()); + Assertions.assertEquals("arn:aws:s3tables:us-east-1:1:bucket/b", opts.get("warehouse")); + Assertions.assertNull(opts.get("s3.access-key-id")); + Assertions.assertNull(opts.get("client.region")); + Assertions.assertNull(opts.get("catalog-impl")); + } + + @Test + public void buildS3TablesCatalogPropertiesPropagatesClientRegionWithoutBoundS3() { + // WHY: an EC2 instance-profile s3tables catalog (no bound storage) must still propagate an explicit + // s3.region to the data-plane S3FileIO as client.region, or S3FileIO falls to IMDS / + // DefaultAwsRegionProviderChain. Parallels the REST vended-cred region test. RED at HEAD (the old + // ifPresent-only path emitted nothing without a storage). No s3.* credential keys (none are bound). + Map opts = IcebergCatalogFactory.buildS3TablesCatalogProperties( + props("iceberg.catalog.type", "s3tables", "warehouse", "arn:aws:s3tables:us-east-1:1:bucket/b", + "s3.region", "us-east-1"), + Optional.empty()); + Assertions.assertEquals("us-east-1", opts.get("client.region"), + "no-storage s3tables must propagate s3.region -> client.region for the data-plane S3FileIO"); + Assertions.assertNull(opts.get("s3.access-key-id"), "no credentials are bound"); + } + + // --------------------------------------------------------------------- + // resolveCatalogName — jdbc positional vs default + // --------------------------------------------------------------------- + + @Test + public void resolveCatalogNameUsesJdbcCatalogNameForJdbc() { + // WHY: legacy passes iceberg.jdbc.catalog_name as the catalog NAME (overriding the Doris catalog name). + // MUTATION: returning the default name for jdbc -> red. + Assertions.assertEquals("mycat", IcebergCatalogFactory.resolveCatalogName( + props("iceberg.jdbc.catalog_name", "mycat"), "jdbc", "doris_cat")); + } + + @Test + public void resolveCatalogNameThrowsWhenJdbcCatalogNameMissing() { + // WHY: iceberg.jdbc.catalog_name is required (legacy @ConnectorProperty required=true); a missing value + // must fail loud rather than silently fall back to the Doris catalog name. MUTATION: returning the default + // -> no exception -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> IcebergCatalogFactory.resolveCatalogName(props(), "jdbc", "doris_cat")); + Assertions.assertTrue(ex.getMessage().contains("iceberg.jdbc.catalog_name")); + } + + @Test + public void resolveCatalogNameUsesDefaultForNonJdbc() { + // WHY: every non-jdbc flavor uses the Doris catalog name. MUTATION: reading catalog_name for hms -> red. + Assertions.assertEquals("doris_cat", IcebergCatalogFactory.resolveCatalogName( + props("iceberg.jdbc.catalog_name", "ignored"), "hms", "doris_cat")); + } + + // --------------------------------------------------------------------- + // buildHadoopConfiguration / assembleHiveConf — storage + HiveConf sinks + // --------------------------------------------------------------------- + + @Test + public void buildHadoopConfigurationAppliesStorageThenRawPassthrough() { + // WHY: the conf carries the pre-computed storage config plus the raw fs./dfs./hadoop. passthrough for + // inline keys. MUTATION: dropping the passthrough or the storage map -> red. + Map storage = new HashMap<>(); + storage.put("fs.s3a.endpoint", "https://s3"); + Configuration conf = IcebergCatalogFactory.buildHadoopConfiguration( + props("dfs.nameservices", "ns1", "unrelated.key", "x"), storage); + Assertions.assertEquals("https://s3", conf.get("fs.s3a.endpoint")); + Assertions.assertEquals("ns1", conf.get("dfs.nameservices")); + Assertions.assertNull(conf.get("unrelated.key"), "non fs./dfs./hadoop. keys must not be copied into the conf"); + } + + @Test + public void assembleHiveConfLayersOverridesOverBase(@TempDir java.nio.file.Path tmp) throws Exception { + // WHY: the external hive-site.xml named by hive.conf.resources is seeded first, then the + // metastore-spi overrides win, so a connection key in the overrides correctly overrides the file. + // MUTATION: reversing the order -> the base value would win -> red. + // The connector resolves the file itself, so this drives the REAL file->HiveConf path: it writes a + // real XML and reads the values back off the HiveConf. + java.nio.file.Files.write(tmp.resolve("hive-site.xml"), + ("" + + "hive.metastore.uristhrift://from-file:9083" + + "base.onlykept" + + "").getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + String prev = System.getProperty("doris.hadoop.config.dir"); + System.setProperty("doris.hadoop.config.dir", tmp.toString() + java.io.File.separator); + try { + Map overrides = new HashMap<>(); + overrides.put("hive.metastore.uris", "thrift://override:9083"); + HiveConf conf = IcebergCatalogFactory.assembleHiveConf("hive-site.xml", overrides); + Assertions.assertEquals("thrift://override:9083", conf.get("hive.metastore.uris")); + Assertions.assertEquals("kept", conf.get("base.only")); + } finally { + if (prev == null) { + System.clearProperty("doris.hadoop.config.dir"); + } else { + System.setProperty("doris.hadoop.config.dir", prev); + } + } + } + + @Test + public void assembleHiveConfFailsLoudOnMissingFile(@TempDir java.nio.file.Path tmp) { + // WHY: the operator named a file carrying connection-critical settings; a missing file must fail + // loud, never degrade to "connect with defaults". MUTATION: swallowing the miss -> red. + String prev = System.getProperty("doris.hadoop.config.dir"); + System.setProperty("doris.hadoop.config.dir", tmp.toString() + java.io.File.separator); + try { + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, + () -> IcebergCatalogFactory.assembleHiveConf("absent.xml", Collections.emptyMap())); + Assertions.assertTrue(e.getMessage().contains("Config resource file does not exist"), + "message must name the unresolvable file; was: " + e.getMessage()); + } finally { + if (prev == null) { + System.clearProperty("doris.hadoop.config.dir"); + } else { + System.setProperty("doris.hadoop.config.dir", prev); + } + } + } + +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergColumnHandleTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergColumnHandleTest.java new file mode 100644 index 00000000000000..8d7c05c591972d --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergColumnHandleTest.java @@ -0,0 +1,43 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Tests for {@link IcebergColumnHandle} (mirrors the paimon connector's {@code PaimonColumnHandle}). */ +public class IcebergColumnHandleTest { + + @Test + public void carriesNameAndFieldId() { + IcebergColumnHandle handle = new IcebergColumnHandle("name", 42); + Assertions.assertEquals("name", handle.getName()); + Assertions.assertEquals(42, handle.getFieldId()); + } + + @Test + public void equalityIsByNameOnly() { + // WHY: PluginDrivenScanNode.buildColumnHandles looks a handle up by slot NAME, so identity is the name + // (the same key getColumnHandles keys the map by). Two handles with the same name but different field + // ids are equal. MUTATION: include fieldId in equals/hashCode -> the two below compare unequal -> red. + Assertions.assertEquals(new IcebergColumnHandle("c", 1), new IcebergColumnHandle("c", 2)); + Assertions.assertEquals( + new IcebergColumnHandle("c", 1).hashCode(), new IcebergColumnHandle("c", 2).hashCode()); + Assertions.assertNotEquals(new IcebergColumnHandle("a", 1), new IcebergColumnHandle("b", 1)); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCommentCacheTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCommentCacheTest.java new file mode 100644 index 00000000000000..e8a6f21119e95e --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCommentCacheTest.java @@ -0,0 +1,177 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.NoSuchTableException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Unit tests for {@link IcebergCommentCache} (PERF-05). Mirrors {@link IcebergTableCacheTest} but stores the + * {@code comment} string keyed by {@link TableIdentifier}. Covers within-TTL stability, the {@code ttl <= 0} + * disable (load-bearing: a vended no-cache catalog builds this object but must not cache), invalidation, and the + * not-cached-on-failure guarantee that keeps the view-handle {@code NoSuchTableException} degradation to "". + */ +public class IcebergCommentCacheTest { + + private static TableIdentifier id(String db, String tbl) { + return TableIdentifier.of(db, tbl); + } + + @Test + public void cachesWithinTtlAndServesTheSameComment() { + AtomicInteger loads = new AtomicInteger(); + IcebergCommentCache c = new IcebergCommentCache(100, 1000); + + String first = c.getOrLoad(id("db", "t"), () -> { + loads.incrementAndGet(); + return "sales fact"; + }); + // Second read of the SAME table within TTL returns the cached comment, NOT a fresh remote loadTable -> this + // is what collapses the per-table information_schema loads on repeat queries. MUTATION: loading live every + // call -> returns "other" / loads==2 -> red. + String second = c.getOrLoad(id("db", "t"), () -> { + loads.incrementAndGet(); + return "other"; + }); + Assertions.assertEquals("sales fact", first); + Assertions.assertEquals("sales fact", second, "within TTL the cached comment must be served"); + Assertions.assertEquals(1, loads.get(), "the live loadTable must run exactly once within TTL"); + Assertions.assertEquals(1, c.loadCountForTest(), "the metric-gate load count must be 1"); + Assertions.assertTrue(c.isEnabled()); + } + + @Test + public void differentTableIsADifferentKey() { + AtomicInteger loads = new AtomicInteger(); + IcebergCommentCache c = new IcebergCommentCache(100, 1000); + c.getOrLoad(id("db", "t1"), () -> { + loads.incrementAndGet(); + return "c1"; + }); + String t2 = c.getOrLoad(id("db", "t2"), () -> { + loads.incrementAndGet(); + return "c2"; + }); + Assertions.assertEquals("c2", t2); + Assertions.assertEquals(2, loads.get()); + Assertions.assertEquals(2, c.size()); + } + + @Test + public void ttlZeroDisablesCachingAlwaysLive() { + AtomicInteger loads = new AtomicInteger(); + IcebergCommentCache c = new IcebergCommentCache(0, 1000); + c.getOrLoad(id("db", "t"), () -> { + loads.incrementAndGet(); + return "a"; + }); + String second = c.getOrLoad(id("db", "t"), () -> { + loads.incrementAndGet(); + return "b"; + }); + // ttl-second=0 (a vended no-cache catalog) loads live every time -> operator "no meta cache" intent honored + // even though the connector built this object. MUTATION: caching despite ttl<=0 -> second=="a" / loads==1. + Assertions.assertEquals("b", second, "ttl-second=0 must always load live"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + Assertions.assertEquals(0, c.size(), "ttl-second=0 must not store anything"); + } + + @Test + public void negativeTtlDisablesCachingAlwaysLive() { + AtomicInteger loads = new AtomicInteger(); + IcebergCommentCache c = new IcebergCommentCache(-1, 1000); + c.getOrLoad(id("db", "t"), () -> { + loads.incrementAndGet(); + return "a"; + }); + String second = c.getOrLoad(id("db", "t"), () -> { + loads.incrementAndGet(); + return "b"; + }); + Assertions.assertEquals("b", second, "ttl-second=-1 must always load live"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + } + + @Test + public void invalidateForcesReload() { + AtomicInteger loads = new AtomicInteger(); + IcebergCommentCache c = new IcebergCommentCache(100, 1000); + c.getOrLoad(id("db", "t"), () -> { + loads.incrementAndGet(); + return "old"; + }); + // REFRESH TABLE db.t drops the entry so an external ALTER ... SET TBLPROPERTIES('comment'=...) is picked up. + // MUTATION: invalidate not clearing the key -> "old" survives -> red. + c.invalidate(id("db", "t")); + String after = c.getOrLoad(id("db", "t"), () -> { + loads.incrementAndGet(); + return "new"; + }); + Assertions.assertEquals("new", after); + Assertions.assertEquals(2, loads.get()); + } + + @Test + public void invalidateDbClearsOnlyThatDbsTables() { + IcebergCommentCache c = new IcebergCommentCache(100, 1000); + c.getOrLoad(id("db1", "t1"), () -> "a"); + c.getOrLoad(id("db1", "t2"), () -> "b"); + c.getOrLoad(id("db2", "t1"), () -> "c"); + Assertions.assertEquals(3, c.size()); + c.invalidateDb("db1"); + Assertions.assertEquals(1, c.size(), "only db2's entry must survive REFRESH DATABASE db1"); + } + + @Test + public void invalidateAllClearsEverything() { + IcebergCommentCache c = new IcebergCommentCache(100, 1000); + c.getOrLoad(id("db", "t1"), () -> "a"); + c.getOrLoad(id("db", "t2"), () -> "b"); + Assertions.assertEquals(2, c.size()); + c.invalidateAll(); + Assertions.assertEquals(0, c.size()); + } + + @Test + public void loaderFailureIsNotCachedSoNextQueryRetries() { + // A view handle (loadTable throws NoSuchTableException) must NOT be cached, so getTableComment keeps + // degrading to "" via the caller's catch on every call (and a real table appearing later loads fresh). The + // MetaCacheEntry manual-miss-load path re-throws the loader's exception verbatim and does not store it. + // MUTATION: caching on failure -> the second call would not re-run the loader / size==1 -> red. + IcebergCommentCache c = new IcebergCommentCache(100, 1000); + AtomicInteger loads = new AtomicInteger(); + Assertions.assertThrows(NoSuchTableException.class, () -> c.getOrLoad(id("db", "v"), () -> { + loads.incrementAndGet(); + throw new NoSuchTableException("not a table: db.v"); + })); + Assertions.assertEquals(0, c.size(), "a thrown comment load must not be cached"); + String ok = c.getOrLoad(id("db", "v"), () -> { + loads.incrementAndGet(); + return "now a table"; + }); + Assertions.assertEquals("now a table", ok); + Assertions.assertEquals(2, loads.get(), "the loader must re-run after a failure (no sticky failure)"); + Assertions.assertEquals(1, c.size()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorCacheTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorCacheTest.java new file mode 100644 index 00000000000000..0a22ffff2fb6e4 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorCacheTest.java @@ -0,0 +1,454 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.OptionalLong; + +/** + * Tests IcebergConnector's T08 cache knobs: the latest-snapshot cache TTL resolution + * ({@code meta.cache.iceberg.table.ttl-second}) and the REFRESH-TABLE invalidate hooks. The cache mechanics + * themselves are covered by {@link IcebergLatestSnapshotCacheTest}; end-to-end behavior is gated by docker e2e. + */ +public class IcebergConnectorCacheTest { + + private static Map props(String key, String value) { + Map m = new HashMap<>(); + if (value != null) { + m.put(key, value); + } + return m; + } + + @Test + public void tableCacheTtlDefaultsTo24hWhenUnset() { + // No meta.cache.iceberg.table.ttl-second -> the legacy with-cache catalog default (24h). + // MUTATION: defaulting to 0 (no-cache) -> red. + Assertions.assertEquals(IcebergConnector.DEFAULT_TABLE_CACHE_TTL_SECOND, + IcebergConnector.resolveTableCacheTtlSecond(Collections.emptyMap())); + } + + @Test + public void tableCacheTtlZeroDisablesCaching() { + // ttl-second=0 = the no-cache catalog (always read the latest snapshot live). MUTATION: not honoring 0 + // -> a write would not be seen until the default 24h TTL -> red. + Assertions.assertEquals(0L, + IcebergConnector.resolveTableCacheTtlSecond(props(IcebergConnector.TABLE_CACHE_TTL_SECOND, "0"))); + } + + @Test + public void tableCacheTtlPositiveIsPassedThrough() { + Assertions.assertEquals(3600L, + IcebergConnector.resolveTableCacheTtlSecond(props(IcebergConnector.TABLE_CACHE_TTL_SECOND, "3600"))); + } + + @Test + public void tableCacheTtlIgnoresUnparseableAndBlank() { + // A malformed/blank value must not break catalog creation; fall back to the default. + Assertions.assertEquals(IcebergConnector.DEFAULT_TABLE_CACHE_TTL_SECOND, + IcebergConnector.resolveTableCacheTtlSecond( + props(IcebergConnector.TABLE_CACHE_TTL_SECOND, "not-a-number"))); + Assertions.assertEquals(IcebergConnector.DEFAULT_TABLE_CACHE_TTL_SECOND, + IcebergConnector.resolveTableCacheTtlSecond(props(IcebergConnector.TABLE_CACHE_TTL_SECOND, " "))); + } + + @Test + public void schemaTtlOverrideEmptyWhenUnset() { + // No meta.cache.iceberg.table.ttl-second -> no override, so the engine-default schema-cache TTL applies + // (mirrors PaimonConnector). MUTATION: returning a concrete value would wrongly override the engine + // default for a plain (with-cache) catalog -> red. + Assertions.assertEquals(OptionalLong.empty(), + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()) + .schemaCacheTtlSecondOverride()); + } + + @Test + public void schemaTtlOverrideZeroDisablesSchemaCache() { + // The no-cache catalog (meta.cache.iceberg.table.ttl-second=0) must drive schema.cache.ttl-second=0 so a + // desc after external DDL reads FRESH schema (test_iceberg_table_cache line 251). MUTATION: not mapping + // ttl-second here -> the no-cache catalog serves stale cached schema -> red. + Assertions.assertEquals(OptionalLong.of(0L), + new IcebergConnector(props(IcebergConnector.TABLE_CACHE_TTL_SECOND, "0"), + new RecordingConnectorContext()).schemaCacheTtlSecondOverride()); + } + + @Test + public void schemaTtlOverridePositiveIsPassedThrough() { + Assertions.assertEquals(OptionalLong.of(3600L), + new IcebergConnector(props(IcebergConnector.TABLE_CACHE_TTL_SECOND, "3600"), + new RecordingConnectorContext()).schemaCacheTtlSecondOverride()); + } + + @Test + public void schemaTtlOverrideIgnoresUnparseableValue() { + // A malformed value must not break catalog schema caching; fall back to no override (engine default). + Assertions.assertEquals(OptionalLong.empty(), + new IcebergConnector(props(IcebergConnector.TABLE_CACHE_TTL_SECOND, "not-a-number"), + new RecordingConnectorContext()).schemaCacheTtlSecondOverride()); + } + + @Test + public void invalidateHooksAreNoThrowOnFreshConnector() { + // Smoke: the REFRESH TABLE / REFRESH CATALOG hooks must be safe to call (they only touch the + // connector-internal latest-snapshot cache; the actual invalidate semantics are in + // IcebergLatestSnapshotCacheTest). MUTATION: an NPE on an empty cache -> red. + IcebergConnector connector = + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); + Assertions.assertDoesNotThrow(() -> connector.invalidateTable("db1", "t1")); + Assertions.assertDoesNotThrow(() -> connector.invalidateDb("db1")); + Assertions.assertDoesNotThrow(connector::invalidateAll); + } + + @Test + public void latestSnapshotCacheDisabledForSessionUser() { + // The latest-snapshot cache is an AUTHORIZATION-sensitive projection (snapshotId/schemaId) that + // beginQuerySnapshot reads WITHOUT a preceding per-user loadTable, so a shared hit would bypass the + // per-user authorization. It is disabled (null) under iceberg.rest.session=user (kept otherwise, incl. + // vended-credentials, since a snapshot id carries no token). MUTATION: dropping the session=user gate -> + // non-null for session -> red. + Assertions.assertNotNull( + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()) + .latestSnapshotCacheForTest(), + "a plain catalog builds the latest-snapshot cache"); + Map vended = new HashMap<>(); + vended.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + vended.put(IcebergConnectorProperties.REST_VENDED_CREDENTIALS_ENABLED, "true"); + Assertions.assertNotNull( + new IcebergConnector(vended, new RecordingConnectorContext()).latestSnapshotCacheForTest(), + "a vended-credentials catalog still builds the latest-snapshot cache (an id carries no token)"); + Map session = new HashMap<>(); + session.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + session.put(IcebergConnectorProperties.REST_SESSION, IcebergConnectorProperties.SESSION_USER); + Assertions.assertNull( + new IcebergConnector(session, new RecordingConnectorContext()).latestSnapshotCacheForTest(), + "a session=user catalog must NOT build the latest-snapshot cache (per-user authz bypass)"); + } + + @Test + public void invalidateHooksAreNoThrowForSessionUserWithNulledCaches() { + // Under session=user the latest-snapshot / partition / format caches are all null. The REFRESH hooks must + // still be no-throw (the invalidate* methods null-guard each cache). MUTATION: an unguarded invalidate call + // on a nulled cache -> NPE -> red. + Map session = new HashMap<>(); + session.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + session.put(IcebergConnectorProperties.REST_SESSION, IcebergConnectorProperties.SESSION_USER); + IcebergConnector connector = new IcebergConnector(session, new RecordingConnectorContext()); + Assertions.assertNull(connector.latestSnapshotCacheForTest()); + Assertions.assertNull(connector.partitionCacheForTest()); + Assertions.assertNull(connector.formatCacheForTest()); + Assertions.assertDoesNotThrow(() -> connector.invalidateTable("db1", "t1")); + Assertions.assertDoesNotThrow(() -> connector.invalidateDb("db1")); + Assertions.assertDoesNotThrow(connector::invalidateAll); + } + + @Test + public void refreshCatalogInvalidateAllDropsManifestCache() { + // H-5: REFRESH CATALOG -> Connector.invalidateAll() must drop the connector's OWN manifest cache too + // (legacy catalog-wide group.invalidateAll parity), not just the latest-snapshot cache. REFRESH TABLE + // (invalidateTable) intentionally keeps manifest entries, so this is the catalog-level-only behavior. + IcebergConnector connector = + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); + Table table = tableWithOneManifest(); + ManifestFile manifest = table.currentSnapshot().dataManifests(table.io()).get(0); + IcebergManifestCache manifestCache = connector.manifestCacheForTest(); + manifestCache.getManifestCacheValue(manifest, table); + Assertions.assertEquals(1, manifestCache.size(), "the manifest is cached after a load"); + + // REFRESH TABLE must NOT drop the manifest cache (path-keyed immutable content; legacy parity). + // MUTATION: invalidateTable clearing the manifest cache -> size 0 here -> red. + connector.invalidateTable("db1", "t1"); + Assertions.assertEquals(1, manifestCache.size(), "REFRESH TABLE keeps manifest entries"); + + // REFRESH CATALOG drops it. MUTATION: removing manifestCache.invalidateAll() from invalidateAll -> + // size stays 1 -> red. + connector.invalidateAll(); + Assertions.assertEquals(0, manifestCache.size(), "REFRESH CATALOG flushes the manifest cache"); + } + + private static Table tableWithOneManifest() { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + Table table = catalog.createTable(TableIdentifier.of("db1", "t1"), + new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())), + PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("/data/f1.parquet").withFileSizeInBytes(100).withRecordCount(1).build()) + .commit(); + return table; + } + + // ==================== PERF-01: cross-query table cache gate + invalidation ==================== + + private static Table fakeTable(String name) { + return new FakeIcebergTable(name, + new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())), + PartitionSpec.unpartitioned(), "s3://b/" + name, Collections.emptyMap()); + } + + @Test + public void crossQueryTableCacheEnabledForPlainCatalog() { + // A plain catalog (no per-user session, no REST vended credentials) has query-independent credentials, + // so the cross-query RAW-table cache is built and enabled at the default 24h TTL — restoring the legacy + // IcebergExternalMetaCache table cache. MUTATION: leaving it null/disabled for a plain catalog -> the + // 3~7x remote loadTable amplification is not collapsed across queries -> assert below red. + IcebergTableCache cache = + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()).tableCacheForTest(); + Assertions.assertNotNull(cache, "a plain catalog must build the cross-query table cache"); + Assertions.assertTrue(cache.isEnabled(), "the default 24h TTL enables the cache"); + } + + @Test + public void crossQueryTableCacheDisabledForVendedCredentials() { + // REST vended-credentials: the cached raw table's FileIO carries a server-vended token that expires + // within the query (iceberg keeps it fresh by reloading the table each query). A 24h-TTL cross-query hit + // would hand BE an expired token (403 mid-scan), so this layer MUST be off (null); the query-scoped fat + // handle still dedups within one query. MUTATION: building the cache for a vended catalog -> non-null -> red. + Map vended = new HashMap<>(); + vended.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + vended.put(IcebergConnectorProperties.REST_VENDED_CREDENTIALS_ENABLED, "true"); + Assertions.assertNull( + new IcebergConnector(vended, new RecordingConnectorContext()).tableCacheForTest(), + "a REST vended-credentials catalog must NOT build the cross-query table cache"); + } + + @Test + public void crossQueryTableCacheDisabledForPerUserSession() { + // iceberg.rest.session=user: the cached raw table carries per-user delegated FileIO, so sharing it + // across users would leak credentials. This layer MUST be off (null) — the fat handle keeps within-query + // dedup. MUTATION: building the cache for a session=user catalog -> tableCacheForTest non-null -> red. + Map session = new HashMap<>(); + session.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + session.put(IcebergConnectorProperties.REST_SESSION, IcebergConnectorProperties.SESSION_USER); + Assertions.assertNull( + new IcebergConnector(session, new RecordingConnectorContext()).tableCacheForTest(), + "a per-user session catalog must NOT build the cross-query table cache"); + } + + @Test + public void refreshHooksInvalidateCrossQueryTableCache() { + // The REFRESH hooks must clear the cross-query table cache (else external DDL/writes would stay invisible + // beyond the pin): REFRESH TABLE drops one table, REFRESH DATABASE drops that db's tables, REFRESH + // CATALOG drops everything — mirroring the latest-snapshot cache. MUTATION: an invalidate* hook not + // touching tableCache -> a stale entry survives -> a size assert below red. + IcebergConnector connector = + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); + IcebergTableCache cache = connector.tableCacheForTest(); + Assertions.assertNotNull(cache); + + cache.getOrLoad(TableIdentifier.of("db1", "t1"), () -> fakeTable("db1.t1")); + cache.getOrLoad(TableIdentifier.of("db1", "t2"), () -> fakeTable("db1.t2")); + cache.getOrLoad(TableIdentifier.of("db2", "t1"), () -> fakeTable("db2.t1")); + Assertions.assertEquals(3, cache.size()); + + connector.invalidateTable("db1", "t1"); + Assertions.assertEquals(2, cache.size(), "REFRESH TABLE drops only that table"); + + connector.invalidateDb("db1"); + Assertions.assertEquals(1, cache.size(), "REFRESH DATABASE drops that db's remaining tables"); + + connector.invalidateAll(); + Assertions.assertEquals(0, cache.size(), "REFRESH CATALOG drops everything"); + } + + // ============ PERF-02: partition-view cache (session=user gated) + invalidation ============ + + private static IcebergPartitionCache.Key partKey(String db, String tbl, long snapshotId) { + return new IcebergPartitionCache.Key(TableIdentifier.of(db, tbl), snapshotId); + } + + @Test + public void partitionCacheBuiltUnlessSessionUser() { + // The partition-view cache stores pure metadata (no FileIO/credential), so unlike the table cache it stays + // built for a REST vended-credentials catalog (a partition list carries no token). But under + // iceberg.rest.session=user it is an AUTHORIZATION-sensitive projection -- a shared (no user dimension) hit + // would disclose one user's partitions to a "can-list-cannot-load" principal -- so it is disabled (null) + // there, holding the "session=user => no live cross-query metadata cache" invariant. + // MUTATION: dropping the session=user gate -> non-null for session -> red; gating on the vended flag -> + // null for vended -> red. + Assertions.assertNotNull( + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()).partitionCacheForTest(), + "a plain catalog builds the partition cache"); + Map vended = new HashMap<>(); + vended.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + vended.put(IcebergConnectorProperties.REST_VENDED_CREDENTIALS_ENABLED, "true"); + Assertions.assertNotNull( + new IcebergConnector(vended, new RecordingConnectorContext()).partitionCacheForTest(), + "a vended-credentials catalog still builds the partition cache (metadata carries no credentials)"); + Map session = new HashMap<>(); + session.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + session.put(IcebergConnectorProperties.REST_SESSION, IcebergConnectorProperties.SESSION_USER); + Assertions.assertNull( + new IcebergConnector(session, new RecordingConnectorContext()).partitionCacheForTest(), + "a session=user catalog must NOT build the partition cache (per-user authz must not be bypassed)"); + } + + @Test + public void refreshHooksInvalidatePartitionCache() { + // The REFRESH hooks must clear the partition-view cache too (else external DDL/writes would stay invisible + // beyond the pin): REFRESH TABLE drops that table's snapshot entries, REFRESH DATABASE that db's, REFRESH + // CATALOG everything. MUTATION: an invalidate* hook not touching partitionCache -> a stale entry survives + // -> a size assert below red. + IcebergConnector connector = + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); + IcebergPartitionCache cache = connector.partitionCacheForTest(); + Assertions.assertNotNull(cache); + cache.getOrLoad(partKey("db1", "t1", 1L), Collections::emptyList); + cache.getOrLoad(partKey("db1", "t1", 2L), Collections::emptyList); + cache.getOrLoad(partKey("db1", "t2", 1L), Collections::emptyList); + cache.getOrLoad(partKey("db2", "t1", 1L), Collections::emptyList); + Assertions.assertEquals(4, cache.size()); + + connector.invalidateTable("db1", "t1"); + Assertions.assertEquals(2, cache.size(), "REFRESH TABLE drops both snapshot entries of db1.t1"); + + connector.invalidateDb("db1"); + Assertions.assertEquals(1, cache.size(), "REFRESH DATABASE drops db1's remaining table"); + + connector.invalidateAll(); + Assertions.assertEquals(0, cache.size(), "REFRESH CATALOG drops everything"); + } + + private static IcebergFormatCache.Key fmtKey(String db, String tbl, long snapshotId) { + return new IcebergFormatCache.Key(TableIdentifier.of(db, tbl), snapshotId); + } + + @Test + public void formatCacheBuiltUnlessSessionUser() { + // The inferred-format cache stores a pure metadata format-name string (no FileIO/credential), so like the + // partition cache it stays built for a REST vended-credentials catalog. But under iceberg.rest.session=user + // it is an AUTHORIZATION-sensitive projection, so it is disabled (null) there (same treatment as the + // partition cache). MUTATION: dropping the session=user gate -> non-null for session -> red; gating on the + // vended flag -> null for vended -> red. + Assertions.assertNotNull( + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()).formatCacheForTest(), + "a plain catalog builds the format cache"); + Map vended = new HashMap<>(); + vended.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + vended.put(IcebergConnectorProperties.REST_VENDED_CREDENTIALS_ENABLED, "true"); + Assertions.assertNotNull( + new IcebergConnector(vended, new RecordingConnectorContext()).formatCacheForTest(), + "a vended-credentials catalog still builds the format cache (a format name carries no credentials)"); + Map session = new HashMap<>(); + session.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + session.put(IcebergConnectorProperties.REST_SESSION, IcebergConnectorProperties.SESSION_USER); + Assertions.assertNull( + new IcebergConnector(session, new RecordingConnectorContext()).formatCacheForTest(), + "a session=user catalog must NOT build the format cache (per-user authz must not be bypassed)"); + } + + @Test + public void refreshHooksInvalidateFormatCache() { + // The REFRESH hooks must clear the inferred-format cache too (else a rewrite that changed the write format + // would stay invisible beyond the pin): REFRESH TABLE drops that table's snapshot entries, REFRESH DATABASE + // that db's, REFRESH CATALOG everything. MUTATION: an invalidate* hook not touching formatCache -> a stale + // entry survives -> a size assert below red. + IcebergConnector connector = + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); + IcebergFormatCache cache = connector.formatCacheForTest(); + Assertions.assertNotNull(cache); + cache.getOrLoad(fmtKey("db1", "t1", 1L), () -> "parquet"); + cache.getOrLoad(fmtKey("db1", "t1", 2L), () -> "orc"); + cache.getOrLoad(fmtKey("db1", "t2", 1L), () -> "parquet"); + cache.getOrLoad(fmtKey("db2", "t1", 1L), () -> "parquet"); + Assertions.assertEquals(4, cache.size()); + + connector.invalidateTable("db1", "t1"); + Assertions.assertEquals(2, cache.size(), "REFRESH TABLE drops both snapshot entries of db1.t1"); + + connector.invalidateDb("db1"); + Assertions.assertEquals(1, cache.size(), "REFRESH DATABASE drops db1's remaining table"); + + connector.invalidateAll(); + Assertions.assertEquals(0, cache.size(), "REFRESH CATALOG drops everything"); + } + + private static Map restProps(boolean vended, boolean sessionUser) { + Map m = new HashMap<>(); + m.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + if (vended) { + m.put(IcebergConnectorProperties.REST_VENDED_CREDENTIALS_ENABLED, "true"); + } + if (sessionUser) { + m.put(IcebergConnectorProperties.REST_SESSION, IcebergConnectorProperties.SESSION_USER); + } + return m; + } + + private static IcebergCommentCache commentCacheOf(Map props) { + return new IcebergConnector(props, new RecordingConnectorContext()).commentCacheForTest(); + } + + @Test + public void commentCacheBuiltOnlyForVendedNonSessionCatalog() { + // PERF-05: the comment cache fills the gap PERF-01's tableCache leaves for vended-credentials catalogs, but + // ONLY when NOT session=user -- a session=user comment cache would serve one user's comment to another + // whose per-user loadTable authorization was never checked (a metadata disclosure). Plain catalogs already + // reuse tableCache for the comment path, so no comment cache there either. + // Plain catalog -> null (tableCache covers the comment path; no redundant cache). + Assertions.assertNull(commentCacheOf(Collections.emptyMap()), + "a plain catalog must NOT build the comment cache (tableCache already serves it)"); + // Vended, non-session -> built (the one flavor it is safe + useful for). + Assertions.assertNotNull(commentCacheOf(restProps(true, false)), + "a vended-credentials (non-session) catalog must build the comment cache"); + // session=user -> null (per-user authorization must not be bypassed by a shared cache). + Assertions.assertNull(commentCacheOf(restProps(false, true)), + "a session=user catalog must NOT build the comment cache (per-user authz)"); + // vended AND session=user -> null (session=user wins; !isUserSessionEnabled() gates it off). + Assertions.assertNull(commentCacheOf(restProps(true, true)), + "vended + session=user must NOT build the comment cache (session=user takes precedence)"); + } + + @Test + public void refreshHooksInvalidateCommentCache() { + // The REFRESH hooks must clear the comment cache too (else an external ALTER comment stays invisible beyond + // the pin): REFRESH TABLE drops that table, REFRESH DATABASE that db, REFRESH CATALOG everything. MUTATION: + // an invalidate* hook not touching commentCache -> a stale comment survives -> a size assert below red. + IcebergConnector connector = new IcebergConnector(restProps(true, false), new RecordingConnectorContext()); + IcebergCommentCache cache = connector.commentCacheForTest(); + Assertions.assertNotNull(cache); + cache.getOrLoad(TableIdentifier.of("db1", "t1"), () -> "c1"); + cache.getOrLoad(TableIdentifier.of("db1", "t2"), () -> "c2"); + cache.getOrLoad(TableIdentifier.of("db2", "t1"), () -> "c3"); + Assertions.assertEquals(3, cache.size()); + + connector.invalidateTable("db1", "t1"); + Assertions.assertEquals(2, cache.size(), "REFRESH TABLE drops db1.t1"); + + connector.invalidateDb("db1"); + Assertions.assertEquals(1, cache.size(), "REFRESH DATABASE drops db1's remaining table"); + + connector.invalidateAll(); + Assertions.assertEquals(0, cache.size(), "REFRESH CATALOG drops everything"); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorDeriveStoragePropertiesTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorDeriveStoragePropertiesTest.java new file mode 100644 index 00000000000000..8679312a5652cb --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorDeriveStoragePropertiesTest.java @@ -0,0 +1,101 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Design S8: the iceberg connector owns the hadoop-catalog {@code warehouse -> fs.defaultFS} storage derivation + * that used to live in fe-core's {@code IcebergFileSystemMetaStoreProperties.getDerivedStorageProperties}. + * Verifies verbatim parity with the former bridge (HA-nameservice / host:port warehouse -> fs.defaultFS; + * non-hdfs / blank warehouse derives nothing; a blank nameservice fails loud) AND — the parity-preserving + * addition — that ONLY the hadoop flavor derives: rest/hms/glue/... contribute nothing even with an hdfs + * warehouse (the legacy override lived only on the hadoop flavor). + */ +public class IcebergConnectorDeriveStoragePropertiesTest { + + private static Map props(String catalogType, String warehouse) { + Map m = new HashMap<>(); + if (catalogType != null) { + m.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, catalogType); + } + if (warehouse != null) { + m.put(IcebergConnectorProperties.WAREHOUSE, warehouse); + } + return m; + } + + @Test + public void hadoopHaNameserviceWarehouseBridgesToDefaultFs() { + Assertions.assertEquals(Collections.singletonMap("fs.defaultFS", "hdfs://myns"), + IcebergConnector.deriveStorageDefaults(props("hadoop", "hdfs://myns/warehouse"))); + } + + @Test + public void hadoopHostPortWarehouseBridgesToDefaultFs() { + Assertions.assertEquals(Collections.singletonMap("fs.defaultFS", "hdfs://nn-host:8020"), + IcebergConnector.deriveStorageDefaults(props("hadoop", "hdfs://nn-host:8020/warehouse"))); + } + + @Test + public void hadoopNonHdfsWarehouseDerivesNothing() { + // file:// and s3:// warehouses derive nothing: the bridge is hdfs-only (startsWith "hdfs:"). + Assertions.assertTrue( + IcebergConnector.deriveStorageDefaults(props("hadoop", "file:///tmp/wh")).isEmpty()); + Assertions.assertTrue( + IcebergConnector.deriveStorageDefaults(props("hadoop", "s3://bucket/wh")).isEmpty()); + } + + @Test + public void hadoopBlankOrAbsentWarehouseDerivesNothing() { + Assertions.assertTrue(IcebergConnector.deriveStorageDefaults(props("hadoop", null)).isEmpty()); + Assertions.assertTrue(IcebergConnector.deriveStorageDefaults(props("hadoop", "")).isEmpty()); + Assertions.assertTrue(IcebergConnector.deriveStorageDefaults(props("hadoop", " ")).isEmpty()); + } + + @Test + public void hadoopBlankNameserviceFailsLoud() { + // hdfs:///path has no nameservice authority -> fail loud rather than bind an empty fs.defaultFS. + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, + () -> IcebergConnector.deriveStorageDefaults(props("hadoop", "hdfs:///warehouse"))); + Assertions.assertTrue(e.getMessage().contains("name service is required"), e.getMessage()); + } + + @Test + public void nonHadoopFlavorNeverDerivesEvenWithHdfsWarehouse() { + // Parity with the legacy override, which only the hadoop (filesystem) flavor carried. An hdfs warehouse + // on a rest/hms/glue/dlf/jdbc/s3tables catalog must NOT synthesize fs.defaultFS. + Assertions.assertTrue( + IcebergConnector.deriveStorageDefaults(props("rest", "hdfs://myns/warehouse")).isEmpty()); + Assertions.assertTrue( + IcebergConnector.deriveStorageDefaults(props("hms", "hdfs://myns/warehouse")).isEmpty()); + Assertions.assertTrue( + IcebergConnector.deriveStorageDefaults(props("glue", "hdfs://myns/warehouse")).isEmpty()); + } + + @Test + public void missingCatalogTypeDerivesNothing() { + Assertions.assertTrue( + IcebergConnector.deriveStorageDefaults(props(null, "hdfs://myns/warehouse")).isEmpty()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataColumnEvolutionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataColumnEvolutionTest.java new file mode 100644 index 00000000000000..8d05c40354c076 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataColumnEvolutionTest.java @@ -0,0 +1,337 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.ddl.ConnectorColumnPosition; + +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Behavior tests for the B2 column-evolution overrides on {@link IcebergConnectorMetadata}, driven through the + * {@link RecordingIcebergCatalogOps} seam + {@link RecordingConnectorContext} (no live catalog, no Mockito). + * Asserts that each op builds the neutral column PURELY then runs the seam INSIDE the auth context, that the + * neutral position is forwarded, and that the pre-remote parity guards (non-nullable add, aggregated/auto-inc + * column, complex-type modify, empty reorder) fail loud BEFORE the seam runs. + */ +public class IcebergConnectorMetadataColumnEvolutionTest { + + private static final IcebergTableHandle HANDLE = new IcebergTableHandle("db1", "t1"); + + private static Map props() { + Map p = new HashMap<>(); + p.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + return p; + } + + private static IcebergConnectorMetadata metadata(RecordingIcebergCatalogOps ops, RecordingConnectorContext ctx) { + return new IcebergConnectorMetadata(ops, props(), ctx); + } + + private static ConnectorColumn col(String name, String type) { + return new ConnectorColumn(name, ConnectorType.of(type), "c", true, null, false); + } + + /** A real iceberg table {@code db1.t1} whose {@code arr} column is {@code ARRAY} (the seam load the + * nested-modify parity guard reads the current type from). */ + private static Table tableWithArrayIntColumn() { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + Schema schema = new Schema( + Types.NestedField.optional(1, "arr", Types.ListType.ofOptional(2, Types.IntegerType.get()))); + return catalog.createTable(TableIdentifier.of("db1", "t1"), schema); + } + + /** A real iceberg table {@code db1.t1} whose {@code s} column is {@code STRUCT}. */ + private static Table tableWithStructIntColumn() { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + Schema schema = new Schema( + Types.NestedField.optional(1, "s", Types.StructType.of( + Types.NestedField.optional(2, "a", Types.IntegerType.get())))); + return catalog.createTable(TableIdentifier.of("db1", "t1"), schema); + } + + // ---------- addColumn ---------- + + @Test + public void testAddColumnBuildsTypeAndIsAuthWrapped() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + metadata(ops, ctx).addColumn(null, HANDLE, col("age", "INT"), ConnectorColumnPosition.after("id")); + Assertions.assertEquals(Collections.singletonList("addColumn:db1.t1:age"), ops.log); + Assertions.assertEquals("age", ops.lastAddColumn.getName()); + Assertions.assertEquals(Type.TypeID.INTEGER, ops.lastAddColumn.getType().typeId()); + Assertions.assertEquals("c", ops.lastAddColumn.getComment()); + Assertions.assertFalse(ops.lastAddColumnPos.isFirst()); + Assertions.assertEquals("id", ops.lastAddColumnPos.getAfterColumn()); + Assertions.assertEquals(1, ctx.authCount, "addColumn must run inside executeAuthenticated"); + } + + @Test + public void testAddColumnNullPositionForwardedAsNull() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + metadata(ops, ctx).addColumn(null, HANDLE, col("age", "INT"), null); + Assertions.assertNull(ops.lastAddColumnPos); + } + + @Test + public void testAddColumnDefaultLiteralParsed() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ConnectorColumn withDefault = new ConnectorColumn("n", ConnectorType.of("INT"), "", true, "42", false); + metadata(ops, ctx).addColumn(null, HANDLE, withDefault, null); + Assertions.assertNotNull(ops.lastAddColumn.getDefaultValue()); + Assertions.assertEquals(42, ops.lastAddColumn.getDefaultValue().value()); + } + + @Test + public void testAddNonNullableColumnFailsBeforeRemote() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ConnectorColumn notNull = new ConnectorColumn("age", ConnectorType.of("INT"), "", false, null, false); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).addColumn(null, HANDLE, notNull, null)); + Assertions.assertTrue(ex.getMessage().contains("non-nullable")); + Assertions.assertTrue(ops.log.isEmpty()); + Assertions.assertEquals(0, ctx.authCount); + } + + @Test + public void testAddAggregatedColumnFailsBeforeRemote() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + // isKey=false, isAutoInc=false, isAggregated=true + ConnectorColumn agg = new ConnectorColumn("s", ConnectorType.of("INT"), "", true, null, false, false, true); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).addColumn(null, HANDLE, agg, null)); + Assertions.assertTrue(ex.getMessage().contains("aggregation")); + Assertions.assertTrue(ops.log.isEmpty()); + } + + @Test + public void testAddAutoIncColumnFailsBeforeRemote() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + // isKey=false, isAutoInc=true + ConnectorColumn autoInc = new ConnectorColumn("s", ConnectorType.of("INT"), "", true, null, false, true); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).addColumn(null, HANDLE, autoInc, null)); + Assertions.assertTrue(ex.getMessage().contains("auto incremental")); + Assertions.assertTrue(ops.log.isEmpty()); + } + + @Test + public void testAddColumnAuthFailureWraps() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).addColumn(null, HANDLE, col("age", "INT"), null)); + Assertions.assertTrue(ops.log.isEmpty()); + } + + // ---------- addColumns ---------- + + @Test + public void testAddColumnsBuildsAllAndIsAuthWrapped() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + metadata(ops, ctx).addColumns(null, HANDLE, Arrays.asList(col("a", "INT"), col("b", "STRING"))); + Assertions.assertEquals(Collections.singletonList("addColumns:db1.t1:2"), ops.log); + Assertions.assertEquals(2, ops.lastAddColumns.size()); + Assertions.assertEquals("a", ops.lastAddColumns.get(0).getName()); + Assertions.assertEquals("b", ops.lastAddColumns.get(1).getName()); + Assertions.assertEquals(1, ctx.authCount); + } + + @Test + public void testAddColumnsRejectsNonNullableMember() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ConnectorColumn notNull = new ConnectorColumn("b", ConnectorType.of("INT"), "", false, null, false); + Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).addColumns(null, HANDLE, Arrays.asList(col("a", "INT"), notNull))); + Assertions.assertTrue(ops.log.isEmpty()); + } + + // ---------- dropColumn / renameColumn ---------- + + @Test + public void testDropColumnRoutesAndIsAuthWrapped() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + metadata(ops, ctx).dropColumn(null, HANDLE, "age"); + Assertions.assertEquals(Collections.singletonList("dropColumn:db1.t1:age"), ops.log); + Assertions.assertEquals("age", ops.lastDropColumn); + Assertions.assertEquals(1, ctx.authCount); + } + + @Test + public void testRenameColumnRoutesAndIsAuthWrapped() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + metadata(ops, ctx).renameColumn(null, HANDLE, "old", "new"); + Assertions.assertEquals(Collections.singletonList("renameColumn:db1.t1:old->new"), ops.log); + Assertions.assertEquals("old", ops.lastRenameColumnOld); + Assertions.assertEquals("new", ops.lastRenameColumnNew); + Assertions.assertEquals(1, ctx.authCount); + } + + // ---------- modifyColumn ---------- + + @Test + public void testModifyScalarColumnBuildsTypeAndIsAuthWrapped() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + metadata(ops, ctx).modifyColumn(null, HANDLE, col("age", "BIGINT"), ConnectorColumnPosition.FIRST); + Assertions.assertEquals(Collections.singletonList("modifyColumn:db1.t1:age"), ops.log); + Assertions.assertEquals(Type.TypeID.LONG, ops.lastModifyColumn.getType().typeId()); + Assertions.assertTrue(ops.lastModifyColumnPos.isFirst()); + Assertions.assertEquals(1, ctx.authCount); + } + + @Test + public void testModifyComplexColumnBuildsTreeAndIsAuthWrapped() { + // B2b: a complex modify now routes to the seam carrying the FULL new complex iceberg type + // (built PURELY outside auth); the seam diffs it against the current schema. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ConnectorColumn arr = new ConnectorColumn("arr", + ConnectorType.arrayOf(ConnectorType.of("INT")), "", true, null, false); + metadata(ops, ctx).modifyColumn(null, HANDLE, arr, null); + Assertions.assertEquals(Collections.singletonList("modifyColumn:db1.t1:arr"), ops.log); + Assertions.assertEquals(Type.TypeID.LIST, ops.lastModifyColumn.getType().typeId()); + Assertions.assertEquals(Type.TypeID.INTEGER, + ops.lastModifyColumn.getType().asListType().elementType().typeId()); + Assertions.assertEquals(1, ctx.authCount, "modifyColumn must run inside executeAuthenticated"); + } + + @Test + public void testModifyComplexColumnNarrowToUnrepresentableRestoresLegacyMessage() { + // ARRAY -> ARRAY: iceberg has no SMALLINT, so the eager type build throws the generic + // "Unsupported type for Iceberg: SMALLINT". The connector must instead restore the legacy + // "Cannot change int to smallint in nested types" (validated against the current type) so the green e2e + // test_iceberg_schema_change_complex_types assertion survives the flip. MUTATION: dropping the upgrade + // -> the message reverts and this goes red. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = tableWithArrayIntColumn(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ConnectorColumn arr = new ConnectorColumn("arr", + ConnectorType.arrayOf(ConnectorType.of("SMALLINT")), "", true, null, false); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).modifyColumn(null, HANDLE, arr, null)); + Assertions.assertEquals("Cannot change int to smallint in nested types", ex.getMessage()); + // the remote modify never ran (the build failed first); only the current type was loaded for the message. + Assertions.assertFalse(ops.log.contains("modifyColumn:db1.t1:arr"), + "the seam modify must not run when the nested type is unrepresentable"); + } + + @Test + public void testModifyStructFieldNarrowToUnrepresentableRestoresLegacyMessage() { + // STRUCT -> STRUCT: the struct branch of the walk must reach the nested int->smallint + // leaf and restore the legacy message (covers the STRUCT path, not just LIST). + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = tableWithStructIntColumn(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ConnectorColumn struct = new ConnectorColumn("s", + ConnectorType.structOf(Collections.singletonList("a"), + Collections.singletonList(ConnectorType.of("SMALLINT"))), "", true, null, false); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).modifyColumn(null, HANDLE, struct, null)); + Assertions.assertEquals("Cannot change int to smallint in nested types", ex.getMessage()); + } + + @Test + public void testModifyScalarColumnToUnrepresentableKeepsBuildError() { + // A TOP-LEVEL (non-nested) modify to an iceberg-unrepresentable type keeps the generic build error: the + // nested-narrowing upgrade applies ONLY to complex types (legacy had no "in nested types" message for a + // scalar). Proves the isComplexType early-return in upgradeNestedModifyError and that no table is loaded. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).modifyColumn(null, HANDLE, col("c", "SMALLINT"), null)); + Assertions.assertEquals("Unsupported type for Iceberg: SMALLINT", ex.getMessage()); + Assertions.assertTrue(ops.log.isEmpty(), "a scalar build failure must not load the table"); + } + + @Test + public void testModifyComplexColumnWithDefaultFailsBeforeRemote() { + // Legacy parity (validateForModifyComplexColumn): a complex modify may only carry a NULL default. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ConnectorColumn arr = new ConnectorColumn("arr", + ConnectorType.arrayOf(ConnectorType.of("INT")), "", true, "1", false); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).modifyColumn(null, HANDLE, arr, null)); + Assertions.assertTrue(ex.getMessage().contains("Complex type default")); + Assertions.assertTrue(ops.log.isEmpty()); + Assertions.assertEquals(0, ctx.authCount); + } + + @Test + public void testModifyAggregatedColumnFailsBeforeRemote() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ConnectorColumn agg = new ConnectorColumn("s", ConnectorType.of("INT"), "", true, null, false, false, true); + Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).modifyColumn(null, HANDLE, agg, null)); + Assertions.assertTrue(ops.log.isEmpty()); + } + + // ---------- reorderColumns ---------- + + @Test + public void testReorderColumnsRoutesAndIsAuthWrapped() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + metadata(ops, ctx).reorderColumns(null, HANDLE, Arrays.asList("b", "a")); + Assertions.assertEquals(Collections.singletonList("reorderColumns:db1.t1:[b, a]"), ops.log); + Assertions.assertEquals(Arrays.asList("b", "a"), ops.lastReorder); + Assertions.assertEquals(1, ctx.authCount); + } + + @Test + public void testReorderColumnsEmptyFailsBeforeRemote() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).reorderColumns(null, HANDLE, Collections.emptyList())); + Assertions.assertTrue(ex.getMessage().contains("empty")); + Assertions.assertTrue(ops.log.isEmpty()); + Assertions.assertEquals(0, ctx.authCount); + } +} 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 new file mode 100644 index 00000000000000..778ba27b0981fa --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataDdlTest.java @@ -0,0 +1,582 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorColumn; +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.ConnectorCreateTableRequest; +import org.apache.doris.connector.api.ddl.ConnectorPartitionField; +import org.apache.doris.connector.api.ddl.ConnectorPartitionSpec; +import org.apache.doris.connector.api.ddl.ConnectorSortField; +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.iceberg.TableProperties; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Behavior tests for the B1 DDL overrides on {@link IcebergConnectorMetadata} — driven entirely through the + * {@link RecordingIcebergCatalogOps} seam + {@link RecordingConnectorContext} (no live catalog, no Mockito). + * Asserts: every remote op runs INSIDE the auth context, the HMS-only properties gate, the force-drop + * cascade, and that the managed-location cleanup hook is invoked (HMS only) with the location captured + * BEFORE the drop. + */ +public class IcebergConnectorMetadataDdlTest { + + private static Map props(String catalogType) { + Map p = new HashMap<>(); + if (catalogType != null) { + p.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, catalogType); + } + return p; + } + + private static IcebergConnectorMetadata metadata(RecordingIcebergCatalogOps ops, + RecordingConnectorContext ctx, String catalogType) { + return new IcebergConnectorMetadata(ops, props(catalogType), ctx); + } + + @Test + public void testSupportsCreateDatabase() { + Assertions.assertTrue(metadata(new RecordingIcebergCatalogOps(), + new RecordingConnectorContext(), IcebergConnectorProperties.TYPE_REST).supportsCreateDatabase()); + } + + // ---------- createDatabase ---------- + + @Test + public void testCreateDatabaseHmsWithPropertiesIsAuthWrapped() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + Map dbProps = Collections.singletonMap("location", "s3://wh/db"); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_HMS).createDatabase(null, "db1", dbProps); + Assertions.assertEquals("db1", ops.lastCreateDb); + Assertions.assertEquals(dbProps, ops.lastCreateDbProps); + Assertions.assertEquals(1, ctx.authCount, "createDatabase must run inside executeAuthenticated"); + } + + @Test + public void testCreateDatabaseNonHmsWithPropertiesFailsLoud() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergConnectorMetadata md = metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> md.createDatabase(null, "db1", Collections.singletonMap("k", "v"))); + Assertions.assertTrue(ex.getMessage().contains("rest")); + // The gate runs BEFORE the auth context — the seam must not be touched. + Assertions.assertTrue(ops.log.isEmpty(), ops.log.toString()); + Assertions.assertEquals(0, ctx.authCount); + } + + @Test + public void testCreateDatabaseNonHmsEmptyPropertiesSucceeds() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST) + .createDatabase(null, "db1", Collections.emptyMap()); + Assertions.assertEquals("db1", ops.lastCreateDb); + Assertions.assertEquals(1, ctx.authCount); + } + + @Test + public void testCreateDatabaseAuthFailureWraps() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + IcebergConnectorMetadata md = metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST); + Assertions.assertThrows(DorisConnectorException.class, + () -> md.createDatabase(null, "db1", Collections.emptyMap())); + // failAuth throws WITHOUT running the task -> the seam create must not have run. + Assertions.assertTrue(ops.log.isEmpty()); + } + + // ---------- dropDatabase ---------- + + @Test + public void testDropDatabaseForceCascadesAndCleansHms() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.tables = Arrays.asList("t1", "t2"); + ops.namespaceLocation = Optional.of("s3://wh/db1"); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_HMS).dropDatabase(null, "db1", false, true); + // location captured BEFORE drop, then the tables cascade-dropped, then the (empty) view list probed, + // then the namespace dropped. + Assertions.assertEquals(Arrays.asList( + "loadNamespaceLocation:db1", + "listTableNames:db1", + "dropTable:db1.t1:purge=true", + "dropTable:db1.t2:purge=true", + "listViewNames:db1", + "dropDatabase:db1"), ops.log); + // cleanup hook called once with the namespace location + empty child dirs. + Assertions.assertEquals(Collections.singletonList("s3://wh/db1"), ctx.cleanedLocations); + Assertions.assertTrue(ctx.cleanedChildDirs.get(0).isEmpty()); + } + + @Test + public void testDropDatabaseForceCascadesViewsAfterTables() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.tables = Collections.singletonList("t1"); + ops.views = Arrays.asList("v1", "v2"); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST).dropDatabase(null, "db1", false, true); + // WHY: iceberg VIEWS live in their own namespace (listTableNames subtracts them), so a force drop + // must cascade them too — AFTER the tables and BEFORE dropNamespace — or the dropDatabase below would + // fail loud "namespace not empty". MUTATION: dropping the view cascade -> the dropView entries vanish + // (the namespace would not be empty in production) -> red. + Assertions.assertEquals(Arrays.asList( + "listTableNames:db1", + "dropTable:db1.t1:purge=true", + "listViewNames:db1", + "dropView:db1.v1", + "dropView:db1.v2", + "dropDatabase:db1"), ops.log); + } + + @Test + public void testDropDatabaseNonForceNonHmsNoCascadeNoCleanup() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.tables = Arrays.asList("t1", "t2"); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST).dropDatabase(null, "db1", false, false); + // No location load (non-HMS), no cascade (non-force), just the namespace drop. + Assertions.assertEquals(Collections.singletonList("dropDatabase:db1"), ops.log); + Assertions.assertTrue(ctx.cleanedLocations.isEmpty()); + } + + @Test + public void testDropDatabaseForceToleratesAlreadyDeletedNamespaceNonHms() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.throwNoSuchNamespace = true; // remote namespace dropped out-of-band + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + // WHY (Rule 9): legacy IcebergMetadataOps.performDropDb swallowed NoSuchNamespaceException during the + // FORCE cascade, so a FORCE drop of a db whose remote namespace is already gone succeeds (an orphaned + // FE-cache db can still be cleaned up). The port collapsed the cascade into one try/catch(Exception) + // and lost that tolerance. This asserts FORCE no longer throws. MUTATION: removing the + // catch(NoSuchNamespaceException) re-surfaces it as DorisConnectorException -> red. + Assertions.assertDoesNotThrow(() -> + metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST).dropDatabase(null, "db1", false, true)); + // The missing namespace surfaces at the first cascade probe (listTableNames); the namespace drop is skipped. + Assertions.assertTrue(ops.log.contains("listTableNames:db1"), ops.log.toString()); + Assertions.assertFalse(ops.log.contains("dropDatabase:db1"), ops.log.toString()); + } + + @Test + public void testDropDatabaseForceToleratesAlreadyDeletedNamespaceHms() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.throwNoSuchNamespace = true; // remote namespace dropped out-of-band + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + // WHY: on an HMS catalog the namespace-location probe runs BEFORE the cascade, so the missing namespace + // throws there first. The tolerant region must cover that pre-step too (full legacy parity for every + // flavor), or an HMS FORCE-drop of an already-gone namespace would still fail. MUTATION: scoping the + // catch to only the cascade (excluding loadNamespaceLocation) makes this red. + Assertions.assertDoesNotThrow(() -> + metadata(ops, ctx, IcebergConnectorProperties.TYPE_HMS).dropDatabase(null, "db1", false, true)); + Assertions.assertTrue(ops.log.contains("loadNamespaceLocation:db1"), ops.log.toString()); + Assertions.assertFalse(ops.log.contains("dropDatabase:db1"), ops.log.toString()); + // Tolerated drop returns no location -> the managed-location cleanup hook must not run. + Assertions.assertTrue(ctx.cleanedLocations.isEmpty(), ctx.cleanedLocations.toString()); + } + + @Test + public void testDropDatabaseNonForceDoesNotTolerateMissingNamespace() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.throwNoSuchNamespace = true; + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + // WHY: the tolerance is FORCE-only (legacy parity). A plain DROP DATABASE of a missing namespace must + // still fail loud. MUTATION: dropping the `if (!force) throw e;` guard (always tolerate) makes this + // assertThrows red. + Assertions.assertThrows(DorisConnectorException.class, () -> + metadata(ops, ctx, IcebergConnectorProperties.TYPE_HMS).dropDatabase(null, "db1", false, false)); + } + + // ---------- createTable ---------- + + @Test + public void testCreateTableBuildsArtifactsAndCallsSeam() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ConnectorCreateTableRequest request = ConnectorCreateTableRequest.builder() + .dbName("db1").tableName("t1") + .columns(Arrays.asList( + new ConnectorColumn("id", ConnectorType.of("BIGINT"), "", true, null, false), + 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())) + .sortOrder(Collections.singletonList(new ConnectorSortField("id", true, true))) + .build(); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST).createTable(null, request); + + Assertions.assertEquals("db1", ops.lastCreateTableDb); + Assertions.assertEquals("t1", ops.lastCreateTableName); + Assertions.assertNotNull(ops.lastCreateSchema.findField("id")); + Assertions.assertNotNull(ops.lastCreateSchema.findField("name")); + Assertions.assertEquals(1, ops.lastCreateSpec.fields().size()); + Assertions.assertEquals("bucket[8]", ops.lastCreateSpec.fields().get(0).transform().toString()); + Assertions.assertNotNull(ops.lastCreateSortOrder); + Assertions.assertFalse(ops.lastCreateSortOrder.isUnsorted()); + // MOR + format-version defaults applied. + Assertions.assertEquals("2", ops.lastCreateProps.get(TableProperties.FORMAT_VERSION)); + Assertions.assertEquals("merge-on-read", ops.lastCreateProps.get(TableProperties.DELETE_MODE)); + Assertions.assertEquals(1, ctx.authCount, "createTable must run inside executeAuthenticated"); + } + + @Test + public void testCreateTableUnsupportedTypeFailsBeforeRemote() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ConnectorCreateTableRequest request = ConnectorCreateTableRequest.builder() + .dbName("db1").tableName("t1") + .columns(Collections.singletonList( + new ConnectorColumn("t", ConnectorType.of("TINYINT"), "", true, null, false))) + .build(); + IcebergConnectorMetadata md = metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST); + Assertions.assertThrows(DorisConnectorException.class, () -> md.createTable(null, request)); + // Schema build is pure + runs before the auth/remote create -> the seam never ran. + Assertions.assertTrue(ops.log.isEmpty()); + Assertions.assertEquals(0, ctx.authCount); + } + + @Test + public void testCreateTableRejectsReservedRowLineageColumnAtV3() { + // A v3 table (format-version=3 in the CREATE properties) forbids a user column named after an iceberg + // reserved row-lineage column (_row_id / _last_updated_sequence_number, case-insensitive). This check + // moved off fe-core CreateTableInfo — the connector owns the iceberg name convention. It runs BEFORE + // the auth/remote create, so the seam never fires. MUTATION: dropping the reject lets the create through. + for (String reserved : new String[] {"_row_id", "_last_updated_sequence_number", "_ROW_ID"}) { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ConnectorCreateTableRequest request = ConnectorCreateTableRequest.builder() + .dbName("db1").tableName("t1") + .columns(Arrays.asList( + new ConnectorColumn("id", ConnectorType.of("BIGINT"), "", true, null, false), + new ConnectorColumn(reserved, ConnectorType.of("BIGINT"), "", true, null, false))) + .properties(Collections.singletonMap(TableProperties.FORMAT_VERSION, "3")) + .build(); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST).createTable(null, request), + reserved + " must be rejected on a v3 table"); + Assertions.assertTrue(ex.getMessage().contains("reserved row lineage column"), ex.getMessage()); + Assertions.assertTrue(ops.log.isEmpty(), "reject must run before the remote seam"); + Assertions.assertEquals(0, ctx.authCount); + } + } + + @Test + public void testCreateTableAllowsReservedRowLineageNameBelowV3() { + // Below v3 (the default v2) row lineage does not exist, so _row_id is a legal user column name and the + // create proceeds to the seam. Version-gates the rejection. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ConnectorCreateTableRequest request = ConnectorCreateTableRequest.builder() + .dbName("db1").tableName("t1") + .columns(Collections.singletonList( + new ConnectorColumn("_row_id", ConnectorType.of("BIGINT"), "", true, null, false))) + .build(); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST).createTable(null, request); + Assertions.assertEquals("t1", ops.lastCreateTableName); + Assertions.assertNotNull(ops.lastCreateSchema.findField("_row_id")); + } + + @Test + public void testCreateTableRejectsReservedColumnViaCatalogTableDefaultV3() { + // FULL effective-format-version precedence: a catalog-level table-default.format-version=3 with NO + // table-level format-version must still trip the rejection (else the version resolves to 2 and a v3 + // table is created carrying a reserved column). Guards the getEffectiveFormatVersion precedence. + assertCatalogLevelV3Rejects("table-default.format-version"); + } + + @Test + public void testCreateTableRejectsReservedColumnViaCatalogTableOverrideV3() { + assertCatalogLevelV3Rejects("table-override.format-version"); + } + + private static void assertCatalogLevelV3Rejects(String catalogFormatVersionKey) { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + Map catalogProps = new HashMap<>(); + catalogProps.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + catalogProps.put(catalogFormatVersionKey, "3"); + IcebergConnectorMetadata md = new IcebergConnectorMetadata(ops, catalogProps, ctx); + ConnectorCreateTableRequest request = ConnectorCreateTableRequest.builder() + .dbName("db1").tableName("t1") + .columns(Collections.singletonList( + new ConnectorColumn("_row_id", ConnectorType.of("BIGINT"), "", true, null, false))) + .build(); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> md.createTable(null, request), + catalogFormatVersionKey + "=3 must trip the reserved-column rejection"); + Assertions.assertTrue(ex.getMessage().contains("reserved row lineage column"), ex.getMessage()); + Assertions.assertTrue(ops.log.isEmpty()); + Assertions.assertEquals(0, ctx.authCount); + } + + // ---------- dropTable ---------- + + @Test + public void testDropTableHmsCapturesLocationAndCleans() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.tableLocation = Optional.of("s3://wh/db1/t1"); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_HMS) + .dropTable(null, new IcebergTableHandle("db1", "t1")); + // location captured BEFORE the purge-drop. + Assertions.assertEquals(Arrays.asList( + "loadTableLocation:db1.t1", + "dropTable:db1.t1:purge=true"), ops.log); + Assertions.assertTrue(ops.lastDropPurge); + Assertions.assertEquals(Collections.singletonList("s3://wh/db1/t1"), ctx.cleanedLocations); + Assertions.assertEquals(Arrays.asList("data", "metadata"), ctx.cleanedChildDirs.get(0)); + } + + @Test + public void testDropTableNonHmsNoLocationNoCleanup() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST) + .dropTable(null, new IcebergTableHandle("db1", "t1")); + Assertions.assertEquals(Collections.singletonList("dropTable:db1.t1:purge=true"), ops.log); + Assertions.assertTrue(ctx.cleanedLocations.isEmpty()); + } + + // ---------- dropView ---------- + + @Test + public void testDropViewRoutesToSeamAndIsAuthWrapped() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST).dropView(null, "db1", "v1"); + // WHY: PluginDrivenExternalCatalog.dropTable routes a flipped iceberg view here; it must reach the seam + // with the (db, view) names verbatim, INSIDE the auth context (mirrors legacy performDropView under the + // executionAuthenticator). MUTATION: dropping the delegation / hoisting it outside the auth wrap -> red. + Assertions.assertEquals(Collections.singletonList("dropView:db1.v1"), ops.log); + Assertions.assertEquals("db1", ops.lastDropViewDb); + Assertions.assertEquals("v1", ops.lastDropViewName); + Assertions.assertEquals(1, ctx.authCount, "dropView must run inside executeAuthenticated"); + } + + @Test + public void testDropViewAuthFailureWraps() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + // WHY: like the other write ops, a remote/auth failure must surface as a DorisConnectorException so + // PluginDrivenExternalCatalog.dropTable can rewrap it as a DdlException; the seam must NOT be reached. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST).dropView(null, "db1", "v1")); + Assertions.assertTrue(ex.getMessage().contains("Failed to drop Iceberg view"), ex.getMessage()); + Assertions.assertFalse(ops.log.contains("dropView:db1.v1"), + "the seam must not be reached when the auth wrap throws"); + } + + // ---------- renameTable ---------- + + @Test + public void testRenameTableRoutesByHandleAndIsAuthWrapped() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST) + .renameTable(null, new IcebergTableHandle("db1", "t1"), "t2"); + Assertions.assertEquals(Collections.singletonList("renameTable:db1.t1->t2"), ops.log); + Assertions.assertEquals("db1", ops.lastRenameTableDb); + Assertions.assertEquals("t1", ops.lastRenameTableOld); + Assertions.assertEquals("t2", ops.lastRenameTableNew); + Assertions.assertEquals(1, ctx.authCount, "renameTable must run inside executeAuthenticated"); + } + + @Test + public void testRenameTableAuthFailureWraps() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST) + .renameTable(null, new IcebergTableHandle("db1", "t1"), "t2")); + Assertions.assertTrue(ops.log.isEmpty()); + } + + + // ---------- Branch / tag (B4): route by handle, auth-wrap, wrap auth failures ---------- + + @Test + public void testCreateOrReplaceBranchRoutesByHandleAndIsAuthWrapped() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + BranchChange branch = new BranchChange("b1", true, false, false, 7L, null, null, null); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST) + .createOrReplaceBranch(null, new IcebergTableHandle("db1", "t1"), branch); + Assertions.assertEquals(Collections.singletonList("createOrReplaceBranch:db1.t1:b1"), ops.log); + Assertions.assertEquals("db1", ops.lastBranchTagDb); + Assertions.assertEquals("t1", ops.lastBranchTagTable); + Assertions.assertSame(branch, ops.lastBranch); + Assertions.assertEquals(1, ctx.authCount, "createOrReplaceBranch must run inside executeAuthenticated"); + } + + @Test + public void testCreateOrReplaceBranchAuthFailureWraps() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST).createOrReplaceBranch( + null, new IcebergTableHandle("db1", "t1"), + new BranchChange("b1", true, false, false, null, null, null, null))); + Assertions.assertTrue(ops.log.isEmpty()); + } + + @Test + public void testCreateOrReplaceTagRoutesByHandleAndIsAuthWrapped() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + TagChange tag = new TagChange("v1", true, false, false, 7L, null); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST) + .createOrReplaceTag(null, new IcebergTableHandle("db1", "t1"), tag); + Assertions.assertEquals(Collections.singletonList("createOrReplaceTag:db1.t1:v1"), ops.log); + Assertions.assertSame(tag, ops.lastTag); + Assertions.assertEquals(1, ctx.authCount, "createOrReplaceTag must run inside executeAuthenticated"); + } + + @Test + public void testCreateOrReplaceTagAuthFailureWraps() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST).createOrReplaceTag( + null, new IcebergTableHandle("db1", "t1"), + new TagChange("v1", true, false, false, null, null))); + Assertions.assertTrue(ops.log.isEmpty()); + } + + @Test + public void testDropBranchRoutesByHandleAndIsAuthWrapped() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + DropRefChange drop = new DropRefChange("b1", true); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST) + .dropBranch(null, new IcebergTableHandle("db1", "t1"), drop); + Assertions.assertEquals(Collections.singletonList("dropBranch:db1.t1:b1"), ops.log); + Assertions.assertSame(drop, ops.lastDropBranch); + Assertions.assertEquals(1, ctx.authCount, "dropBranch must run inside executeAuthenticated"); + } + + @Test + public void testDropTagRoutesByHandleAndIsAuthWrapped() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + DropRefChange drop = new DropRefChange("v1", false); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST) + .dropTag(null, new IcebergTableHandle("db1", "t1"), drop); + Assertions.assertEquals(Collections.singletonList("dropTag:db1.t1:v1"), ops.log); + Assertions.assertSame(drop, ops.lastDropTag); + Assertions.assertEquals(1, ctx.authCount, "dropTag must run inside executeAuthenticated"); + } + + @Test + public void testDropTagAuthFailureWraps() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST).dropTag( + null, new IcebergTableHandle("db1", "t1"), new DropRefChange("v1", false))); + Assertions.assertTrue(ops.log.isEmpty()); + } + + // ---------- Partition evolution (B5): route by handle, auth-wrap, wrap auth failures ---------- + + @Test + public void testAddPartitionFieldRoutesByHandleAndIsAuthWrapped() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + PartitionFieldChange change = new PartitionFieldChange("bucket", 8, "id", "id_b", + null, null, null, null); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST) + .addPartitionField(null, new IcebergTableHandle("db1", "t1"), change); + Assertions.assertEquals(Collections.singletonList("addPartitionField:db1.t1:id"), ops.log); + Assertions.assertEquals("db1", ops.lastPartitionFieldDb); + Assertions.assertEquals("t1", ops.lastPartitionFieldTable); + Assertions.assertSame(change, ops.lastAddPartitionField); + Assertions.assertEquals(1, ctx.authCount, "addPartitionField must run inside executeAuthenticated"); + } + + @Test + public void testAddPartitionFieldAuthFailureWraps() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST).addPartitionField( + null, new IcebergTableHandle("db1", "t1"), + new PartitionFieldChange(null, null, "id", null, null, null, null, null))); + Assertions.assertTrue(ops.log.isEmpty()); + } + + @Test + public void testDropPartitionFieldRoutesByHandleAndIsAuthWrapped() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + PartitionFieldChange change = new PartitionFieldChange(null, null, null, "p_id", + null, null, null, null); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST) + .dropPartitionField(null, new IcebergTableHandle("db1", "t1"), change); + Assertions.assertEquals(Collections.singletonList("dropPartitionField:db1.t1:p_id"), ops.log); + Assertions.assertSame(change, ops.lastDropPartitionField); + Assertions.assertEquals(1, ctx.authCount, "dropPartitionField must run inside executeAuthenticated"); + } + + @Test + public void testReplacePartitionFieldRoutesByHandleAndIsAuthWrapped() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + PartitionFieldChange change = new PartitionFieldChange("bucket", 4, "id", "p2", + "p", null, null, null); + metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST) + .replacePartitionField(null, new IcebergTableHandle("db1", "t1"), change); + Assertions.assertEquals(Collections.singletonList("replacePartitionField:db1.t1:id"), ops.log); + Assertions.assertSame(change, ops.lastReplacePartitionField); + Assertions.assertEquals(1, ctx.authCount, "replacePartitionField must run inside executeAuthenticated"); + } + + @Test + public void testReplacePartitionFieldAuthFailureWraps() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST).replacePartitionField( + null, new IcebergTableHandle("db1", "t1"), + new PartitionFieldChange(null, null, "id", null, "p", null, null, null))); + Assertions.assertTrue(ops.log.isEmpty()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataMvccTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataMvccTest.java new file mode 100644 index 00000000000000..35748356013e52 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataMvccTest.java @@ -0,0 +1,538 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorTableSchema; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.mvcc.ConnectorMvccPartition; +import org.apache.doris.connector.api.mvcc.ConnectorMvccPartitionView; +import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; +import org.apache.doris.connector.api.mvcc.ConnectorTimeTravelSpec; + +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * MVCC / time-travel tests for {@link IcebergConnectorMetadata} (T07), mirroring the paimon connector's + * {@code PaimonConnectorMetadataMvccTest}. Uses a real {@link InMemoryCatalog} table (the + * {@link RecordingIcebergCatalogOps} fake serves it through the seam) carrying TWO snapshots across a column + * RENAME, plus a tag at the first snapshot and a branch at the second — so the resolution, schema-at-snapshot, + * and ref-pinning paths are exercised against genuine iceberg metadata (no Mockito). + */ +public class IcebergConnectorMetadataMvccTest { + + private static final Schema SCHEMA_V0 = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get())); + + /** A real iceberg table with two snapshots across a rename, a tag at S1, a branch at S2. */ + private static final class Fixture { + Table table; + long s1; + long s2; + long schemaIdS1; + long schemaIdS2; + long tsS2; + } + + private static Fixture fixture() { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + Table table = catalog.createTable( + TableIdentifier.of("db1", "t1"), SCHEMA_V0, PartitionSpec.unpartitioned()); + + // Snapshot S1 under schema v0 (id, name). + table.newAppend().appendFile(dataFile("s3://b/db1/t1/f1.parquet")).commit(); + Fixture f = new Fixture(); + f.s1 = table.currentSnapshot().snapshotId(); + f.schemaIdS1 = table.currentSnapshot().schemaId(); + + // Rename name -> fullname (new schema version), then snapshot S2 under it. + table.updateSchema().renameColumn("name", "fullname").commit(); + table.newAppend().appendFile(dataFile("s3://b/db1/t1/f2.parquet")).commit(); + f.s2 = table.currentSnapshot().snapshotId(); + f.schemaIdS2 = table.currentSnapshot().schemaId(); + f.tsS2 = table.currentSnapshot().timestampMillis(); + + // tag1 -> S1 (schema v0), b1 -> S2 (schema v1). + table.manageSnapshots().createTag("tag1", f.s1).commit(); + table.manageSnapshots().createBranch("b1", f.s2).commit(); + + f.table = table; + // Schema actually evolved (the rename created a NEW schema id). + Assertions.assertNotEquals(f.schemaIdS1, f.schemaIdS2, "the rename must create a new schema version"); + return f; + } + + private static DataFile dataFile(String path) { + return DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath(path).withFileSizeInBytes(100).withRecordCount(1).withFormat(FileFormat.PARQUET).build(); + } + + private static IcebergConnectorMetadata metadataFor(Table table, RecordingIcebergCatalogOps ops) { + ops.table = table; + return new IcebergConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext()); + } + + private static ConnectorTableHandle handle() { + return new IcebergTableHandle("db1", "t1"); + } + + private static List columnNames(ConnectorTableSchema schema) { + return schema.getColumns().stream().map(ConnectorColumn::getName).collect(Collectors.toList()); + } + + // --------------------------------------------------------------------- + // beginQuerySnapshot + // --------------------------------------------------------------------- + + @Test + public void beginQuerySnapshotPinsCurrentSnapshotAndLatestSchema() { + Fixture f = fixture(); + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + Optional snap = metadataFor(f.table, ops).beginQuerySnapshot(null, handle()); + // WHY: the query-begin pin is the LATEST snapshot + LATEST schema id (legacy getLatestIcebergSnapshot). + // MUTATION: pinning currentSnapshot().schemaId() instead of table.schema().schemaId() would still be + // schemaIdS2 here (same after the latest snapshot), so the load-bearing assertion is "current snapshot". + Assertions.assertTrue(snap.isPresent()); + Assertions.assertEquals(f.s2, snap.get().getSnapshotId()); + Assertions.assertEquals(f.schemaIdS2, snap.get().getSchemaId()); + // The remote load goes through the seam (auth-wrapped). + Assertions.assertTrue(ops.log.contains("loadTable:db1.t1")); + } + + @Test + public void beginQuerySnapshotEmptyTablePinsMinusOne() { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + Table empty = catalog.createTable( + TableIdentifier.of("db1", "t1"), SCHEMA_V0, PartitionSpec.unpartitioned()); + Optional snap = + metadataFor(empty, new RecordingIcebergCatalogOps()).beginQuerySnapshot(null, handle()); + // WHY: an empty table still pins (iceberg supports MVCC), at snapshot id -1 (legacy UNKNOWN_SNAPSHOT_ID). + Assertions.assertTrue(snap.isPresent()); + Assertions.assertEquals(-1L, snap.get().getSnapshotId()); + } + + @Test + public void beginQuerySnapshotEnabledCachePinsStableAndLoadsOnce() { + Fixture f = fixture(); + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = f.table; + // An ENABLED cache (TTL 100s) injected via the 4-arg ctor — the production wiring (IcebergConnector + // injects its per-catalog cache here). T08. + IcebergConnectorMetadata md = new IcebergConnectorMetadata( + ops, Collections.emptyMap(), new RecordingConnectorContext(), + new IcebergLatestSnapshotCache(100, 1000)); + Optional first = md.beginQuerySnapshot(null, handle()); + Optional second = md.beginQuerySnapshot(null, handle()); + // WHY: within the TTL the second query reuses the cached pin (same snapshot + schema) WITHOUT re-loading + // the table — the legacy with-cache catalog stability + I/O saving. MUTATION: not consulting the cache + // (live every call) -> loadTable runs twice -> red. + Assertions.assertEquals(f.s2, first.get().getSnapshotId()); + Assertions.assertEquals(f.s2, second.get().getSnapshotId()); + Assertions.assertEquals(f.schemaIdS2, second.get().getSchemaId()); + long loads = ops.log.stream().filter(s -> s.equals("loadTable:db1.t1")).count(); + Assertions.assertEquals(1, loads, "an enabled cache must load the table at most once within the TTL"); + } + + @Test + public void beginQuerySnapshotDisabledCacheLoadsEveryCall() { + Fixture f = fixture(); + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + // The default 3-arg ctor wires a DISABLED cache (ttl=0) -> always live (preserves T07 semantics for the + // direct-construction tests). MUTATION: defaulting to an enabled cache -> loads==1 -> red. + IcebergConnectorMetadata md = metadataFor(f.table, ops); + md.beginQuerySnapshot(null, handle()); + md.beginQuerySnapshot(null, handle()); + long loads = ops.log.stream().filter(s -> s.equals("loadTable:db1.t1")).count(); + Assertions.assertEquals(2, loads, "a disabled cache must read live (load) on every query"); + } + + // --------------------------------------------------------------------- + // resolveTimeTravel + // --------------------------------------------------------------------- + + @Test + public void resolveSnapshotIdResolvesAndCarriesItsSchema() { + Fixture f = fixture(); + Optional snap = metadataFor(f.table, new RecordingIcebergCatalogOps()) + .resolveTimeTravel(null, handle(), ConnectorTimeTravelSpec.snapshotId(String.valueOf(f.s1))); + Assertions.assertTrue(snap.isPresent()); + Assertions.assertEquals(f.s1, snap.get().getSnapshotId()); + // S1 was committed under schema v0 — its schemaId() is the OLD version, not the latest. + Assertions.assertEquals(f.schemaIdS1, snap.get().getSchemaId()); + } + + @Test + public void resolveSnapshotIdMissingIsEmpty() { + Fixture f = fixture(); + // WHY: a non-existent id is "not found" (empty), which fe-core renders as the user-facing error — NOT + // an exception (that is reserved for a malformed spec). + Assertions.assertFalse(metadataFor(f.table, new RecordingIcebergCatalogOps()) + .resolveTimeTravel(null, handle(), ConnectorTimeTravelSpec.snapshotId("999999")).isPresent()); + } + + @Test + public void resolveTimestampDigitalAtOrBefore() { + Fixture f = fixture(); + // Digital epoch-millis at S2's commit time -> the at-or-before snapshot is S2. + Optional snap = metadataFor(f.table, new RecordingIcebergCatalogOps()) + .resolveTimeTravel(null, handle(), + ConnectorTimeTravelSpec.timestamp(String.valueOf(f.tsS2), true)); + Assertions.assertTrue(snap.isPresent()); + Assertions.assertEquals(f.s2, snap.get().getSnapshotId()); + Assertions.assertEquals(f.schemaIdS2, snap.get().getSchemaId()); + } + + @Test + public void resolveTimestampBeforeAnySnapshotIsEmpty() { + Fixture f = fixture(); + Assertions.assertFalse(metadataFor(f.table, new RecordingIcebergCatalogOps()) + .resolveTimeTravel(null, handle(), ConnectorTimeTravelSpec.timestamp("1", true)).isPresent(), + "a time before any snapshot must resolve to empty (not found), not throw"); + } + + @Test + public void resolveTagPinsByRefAndSchema() { + Fixture f = fixture(); + Optional snap = metadataFor(f.table, new RecordingIcebergCatalogOps()) + .resolveTimeTravel(null, handle(), ConnectorTimeTravelSpec.tag("tag1")); + Assertions.assertTrue(snap.isPresent()); + Assertions.assertEquals(f.s1, snap.get().getSnapshotId()); + Assertions.assertEquals(f.schemaIdS1, snap.get().getSchemaId()); + // The ref NAME is carried so applySnapshot can scan.useRef(name) (legacy parity, not pin-by-id). + Assertions.assertEquals("tag1", snap.get().getProperties().get(IcebergConnectorMetadata.REF_PROPERTY)); + } + + @Test + public void resolveBranchPinsByRefAndSchema() { + Fixture f = fixture(); + Optional snap = metadataFor(f.table, new RecordingIcebergCatalogOps()) + .resolveTimeTravel(null, handle(), ConnectorTimeTravelSpec.branch("b1")); + Assertions.assertTrue(snap.isPresent()); + Assertions.assertEquals(f.s2, snap.get().getSnapshotId()); + Assertions.assertEquals(f.schemaIdS2, snap.get().getSchemaId()); + Assertions.assertEquals("b1", snap.get().getProperties().get(IcebergConnectorMetadata.REF_PROPERTY)); + } + + @Test + public void resolveTagRejectsABranchNameAndViceVersa() { + Fixture f = fixture(); + IcebergConnectorMetadata md = metadataFor(f.table, new RecordingIcebergCatalogOps()); + // WHY: legacy validates the ref kind (a branch used as @tag, or a tag used as @branch, is "not found"). + Assertions.assertFalse(md.resolveTimeTravel(null, handle(), + ConnectorTimeTravelSpec.tag("b1")).isPresent(), "a branch name must not resolve as a tag"); + Assertions.assertFalse(md.resolveTimeTravel(null, handle(), + ConnectorTimeTravelSpec.branch("tag1")).isPresent(), "a tag name must not resolve as a branch"); + } + + @Test + public void resolveVersionRefResolvesATag() { + Fixture f = fixture(); + // WHY: non-numeric FOR VERSION AS OF '' (VERSION_REF) accepts a TAG name (legacy + // refs().containsKey). Resolves tag1 -> S1 with schema v0, pinned by ref name. + Optional snap = metadataFor(f.table, new RecordingIcebergCatalogOps()) + .resolveTimeTravel(null, handle(), ConnectorTimeTravelSpec.versionRef("tag1")); + Assertions.assertTrue(snap.isPresent()); + Assertions.assertEquals(f.s1, snap.get().getSnapshotId()); + Assertions.assertEquals(f.schemaIdS1, snap.get().getSchemaId()); + Assertions.assertEquals("tag1", snap.get().getProperties().get(IcebergConnectorMetadata.REF_PROPERTY)); + } + + @Test + public void resolveVersionRefResolvesABranch() { + Fixture f = fixture(); + // WHY (H-7 core fix): non-numeric FOR VERSION AS OF '' (VERSION_REF) must ALSO accept a + // BRANCH name (legacy branch∪tag). Before the fix this dispatched as TAG-only and a branch ref + // was rejected ("can't find snapshot by tag"). Resolves b1 -> S2 with schema v1. + Optional snap = metadataFor(f.table, new RecordingIcebergCatalogOps()) + .resolveTimeTravel(null, handle(), ConnectorTimeTravelSpec.versionRef("b1")); + Assertions.assertTrue(snap.isPresent(), "FOR VERSION AS OF '' must resolve a branch ref"); + Assertions.assertEquals(f.s2, snap.get().getSnapshotId()); + Assertions.assertEquals(f.schemaIdS2, snap.get().getSchemaId()); + Assertions.assertEquals("b1", snap.get().getProperties().get(IcebergConnectorMetadata.REF_PROPERTY)); + } + + @Test + public void resolveVersionRefRejectsUnknownRef() { + Fixture f = fixture(); + // WHY: a name that is neither a tag nor a branch is "not found" (empty -> fe-core renders + // "can't find snapshot by tag or branch"). + Assertions.assertFalse(metadataFor(f.table, new RecordingIcebergCatalogOps()) + .resolveTimeTravel(null, handle(), ConnectorTimeTravelSpec.versionRef("no_such_ref")).isPresent()); + } + + @Test + public void resolveIncrementalFailsLoud() { + Fixture f = fixture(); + // WHY: legacy iceberg never dispatched @incr (it silently read latest); fail loud instead of a wrong + // silent read. + Assertions.assertThrows(DorisConnectorException.class, () -> + metadataFor(f.table, new RecordingIcebergCatalogOps()).resolveTimeTravel(null, handle(), + ConnectorTimeTravelSpec.incremental(Collections.singletonMap("k", "v")))); + } + + // --------------------------------------------------------------------- + // applySnapshot + // --------------------------------------------------------------------- + + @Test + public void applySnapshotThreadsIdAndSchema() { + Fixture f = fixture(); + ConnectorMvccSnapshot snap = ConnectorMvccSnapshot.builder().snapshotId(f.s1).schemaId(f.schemaIdS1).build(); + IcebergTableHandle pinned = (IcebergTableHandle) metadataFor(f.table, new RecordingIcebergCatalogOps()) + .applySnapshot(null, handle(), snap); + Assertions.assertTrue(pinned.hasSnapshotPin()); + Assertions.assertEquals(f.s1, pinned.getSnapshotId()); + Assertions.assertEquals(f.schemaIdS1, pinned.getSchemaId()); + Assertions.assertNull(pinned.getRef()); + } + + @Test + public void applySnapshotThreadsRef() { + Fixture f = fixture(); + ConnectorMvccSnapshot snap = ConnectorMvccSnapshot.builder() + .snapshotId(f.s1).schemaId(f.schemaIdS1).property(IcebergConnectorMetadata.REF_PROPERTY, "tag1") + .build(); + IcebergTableHandle pinned = (IcebergTableHandle) metadataFor(f.table, new RecordingIcebergCatalogOps()) + .applySnapshot(null, handle(), snap); + Assertions.assertEquals("tag1", pinned.getRef()); + } + + @Test + public void applySnapshotLatestPinLeavesHandleUnchanged() { + Fixture f = fixture(); + IcebergConnectorMetadata md = metadataFor(f.table, new RecordingIcebergCatalogOps()); + ConnectorTableHandle bare = handle(); + // null snapshot and an empty-table (-1, no ref) pin must both read latest (handle unchanged) — a + // useSnapshot(-1) would be a non-existent snapshot. + Assertions.assertSame(bare, md.applySnapshot(null, bare, null)); + IcebergTableHandle afterMinusOne = (IcebergTableHandle) md.applySnapshot(null, bare, + ConnectorMvccSnapshot.builder().snapshotId(-1L).build()); + Assertions.assertFalse(afterMinusOne.hasSnapshotPin()); + } + + // --------------------------------------------------------------------- + // applyTopnLazyMaterialization (M-4) + // --------------------------------------------------------------------- + + @Test + public void applyTopnLazyMaterializationMarksHandleAndPreservesCoordinates() { + Fixture f = fixture(); + IcebergTableHandle marked = (IcebergTableHandle) metadataFor(f.table, new RecordingIcebergCatalogOps()) + .applyTopnLazyMaterialization(null, handle()); + // WHY: the generic node calls this when the scan carries the synthesized row-id, so the connector must + // flag the handle (driving IcebergScanPlanProvider to build the FULL-schema field-id dict) while + // keeping the table coordinates. MUTATION: returning the handle unchanged (the default no-op) -> + // isTopnLazyMaterialize false -> red. + Assertions.assertTrue(marked.isTopnLazyMaterialize()); + Assertions.assertEquals("db1", marked.getDbName()); + Assertions.assertEquals("t1", marked.getTableName()); + } + + // --------------------------------------------------------------------- + // getTableSchema(@snapshot) + // --------------------------------------------------------------------- + + @Test + public void getTableSchemaAtSnapshotReadsTheHistoricalSchema() { + Fixture f = fixture(); + IcebergConnectorMetadata md = metadataFor(f.table, new RecordingIcebergCatalogOps()); + // schema v0 (S1) still has "name"; schema v1 (S2/latest) has "fullname". + ConnectorTableSchema atV0 = md.getTableSchema(null, handle(), + ConnectorMvccSnapshot.builder().snapshotId(f.s1).schemaId(f.schemaIdS1).build()); + ConnectorTableSchema atV1 = md.getTableSchema(null, handle(), + ConnectorMvccSnapshot.builder().snapshotId(f.s2).schemaId(f.schemaIdS2).build()); + Assertions.assertEquals(java.util.Arrays.asList("id", "name"), columnNames(atV0)); + Assertions.assertEquals(java.util.Arrays.asList("id", "fullname"), columnNames(atV1)); + } + + @Test + public void getTableSchemaNullOrUnknownSnapshotFallsBackToLatest() { + Fixture f = fixture(); + IcebergConnectorMetadata md = metadataFor(f.table, new RecordingIcebergCatalogOps()); + // null snapshot and schemaId<0 both fall back to the latest schema (fullname). + Assertions.assertEquals(java.util.Arrays.asList("id", "fullname"), + columnNames(md.getTableSchema(null, handle(), null))); + Assertions.assertEquals(java.util.Arrays.asList("id", "fullname"), columnNames(md.getTableSchema( + null, handle(), ConnectorMvccSnapshot.builder().snapshotId(f.s2).schemaId(-1L).build()))); + } + + // --------------------------------------------------------------------- + // T10 parity gap-fills (audit wf_9d88fe61-5c7: MVCC-1 schema-only-ALTER divergence, MVCC-2 datetime string) + // --------------------------------------------------------------------- + + @Test + public void beginQuerySnapshotPinsLatestSchemaAfterSchemaOnlyAlter() { + Fixture f = fixture(); + // A schema-only ALTER with NO new append: table.schema().schemaId() advances, but currentSnapshot() + // (and ITS schemaId) stays at S2 — a schema change never creates a new snapshot. The pin must carry the + // LATEST table schema id (legacy getLatestIcebergSnapshot reads table.schema().schemaId()), NOT the + // lagging current-snapshot schema id. The existing beginQuerySnapshot test cannot catch this because + // there the two ids coincide. MUTATION: pinning currentSnapshot().schemaId() -> schemaIdS2 here -> red. + f.table.updateSchema().addColumn("extra", Types.IntegerType.get()).commit(); + long latestSchemaId = f.table.schema().schemaId(); + long currentSnapshotSchemaId = f.table.currentSnapshot().schemaId(); + Assertions.assertNotEquals(currentSnapshotSchemaId, latestSchemaId, + "a schema-only ALTER must advance the schema id past the current snapshot's schema id"); + Assertions.assertEquals(f.schemaIdS2, currentSnapshotSchemaId, + "no new snapshot was committed, so the current snapshot's schema id is unchanged"); + + Optional snap = + metadataFor(f.table, new RecordingIcebergCatalogOps()).beginQuerySnapshot(null, handle()); + Assertions.assertTrue(snap.isPresent()); + // The pinned snapshot did NOT advance (still S2), but the pinned SCHEMA is the latest, not S2's. + Assertions.assertEquals(f.s2, snap.get().getSnapshotId()); + Assertions.assertEquals(latestSchemaId, snap.get().getSchemaId()); + Assertions.assertNotEquals(currentSnapshotSchemaId, snap.get().getSchemaId()); + } + + @Test + public void resolveTimestampDatetimeStringResolvesSnapshot() { + Fixture f = fixture(); + // The user-facing `FOR TIME AS OF '2024-01-02 12:34:56'` form: a NON-digital datetime string + // (isDigital == false) must route through IcebergTimeUtils.datetimeToMillis(session zone) -> + // SnapshotUtil.snapshotIdAsOfTime, distinct from the digital epoch-millis parseLong path the existing + // test drives. A null session resolves to UTC (resolveSessionZone); zone-correctness itself is pinned by + // IcebergTimeUtilsTest. Format one second AFTER S2 in UTC so the second-precision parse (which truncates + // sub-second millis) still lands at-or-after S2's commit -> resolves to S2. MUTATION: routing the + // datetime string through the digital parseLong branch -> NumberFormatException -> red; never wiring the + // datetime branch through resolveTimeTravel -> empty/wrong snapshot -> red. + String datetime = java.time.Instant.ofEpochMilli(f.tsS2 + 1000) + .atZone(java.time.ZoneOffset.UTC) + .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + Optional snap = metadataFor(f.table, new RecordingIcebergCatalogOps()) + .resolveTimeTravel(null, handle(), ConnectorTimeTravelSpec.timestamp(datetime, false)); + Assertions.assertTrue(snap.isPresent(), "datetime string at-or-after S2 must resolve"); + Assertions.assertEquals(f.s2, snap.get().getSnapshotId()); + Assertions.assertEquals(f.schemaIdS2, snap.get().getSchemaId()); + } + + // --------------------------------------------------------------------- + // B-2: getMvccPartitionView / listPartitionNames (connector level: auth wrap + not-exist degrade) + // The math/merge/gate parity is exhaustively covered by IcebergPartitionUtilsTest; these tests pin the + // connector wiring (delegation, the executeAuthenticated scope, the concurrent-drop degrade). + // --------------------------------------------------------------------- + + private static final Schema PARTITIONED_SCHEMA = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "ts", Types.TimestampType.withoutZone())); + + /** A real db1.t1 table partitioned by day(ts) with one data file at day=100 (1970-04-11). */ + private static Table dayPartitionedTable() { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + PartitionSpec spec = PartitionSpec.builderFor(PARTITIONED_SCHEMA).day("ts").build(); + Table table = catalog.createTable(TableIdentifier.of("db1", "t1"), PARTITIONED_SCHEMA, spec); + table.newAppend().appendFile(DataFiles.builder(spec) + .withPath("s3://b/db1/t1/f1.parquet").withFileSizeInBytes(100).withRecordCount(1) + .withPartitionPath("ts_day=1970-04-11").withFormat(FileFormat.PARQUET).build()).commit(); + return catalog.loadTable(TableIdentifier.of("db1", "t1")); + } + + @Test + public void getMvccPartitionViewReturnsRangeView() { + Table table = dayPartitionedTable(); + Optional view = + metadataFor(table, new RecordingIcebergCatalogOps()).getMvccPartitionView(null, handle()); + Assertions.assertTrue(view.isPresent()); + Assertions.assertEquals(ConnectorMvccPartitionView.Style.RANGE, view.get().getStyle()); + Assertions.assertEquals(ConnectorMvccPartitionView.Freshness.SNAPSHOT_ID, view.get().getFreshness()); + List names = view.get().getPartitions().stream() + .map(ConnectorMvccPartition::getName).collect(Collectors.toList()); + Assertions.assertEquals(Collections.singletonList("ts_day=100"), names); + } + + @Test + public void getMvccPartitionViewFailsLoudWhenTableMissing() { + // The MTMV partition/freshness path FAILS LOUD on a not-found base table (master parity + Rule 12): the + // common dropped-table case is already absorbed by the generic model at handle resolution, so a not-found + // HERE is the narrow concurrent-drop race, where masking a vanished base table as "unpartitioned/fresh" + // would silently under-refresh the MV. MUTATION: degrading to unpartitioned() here -> no throw -> red. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.throwNoSuchTableOnLoadTable = true; + IcebergConnectorMetadata md = + new IcebergConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext()); + Assertions.assertThrows(RuntimeException.class, () -> md.getMvccPartitionView(null, handle())); + } + + @Test + public void getMvccPartitionViewRunsInsideAuthContext() { + // failAuth throws WITHOUT invoking the task, so the remote PARTITIONS scan must sit INSIDE the wrap: + // loadTable is never reached. Proves the Kerberos UGI scope covers the metadata read. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = dayPartitionedTable(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + IcebergConnectorMetadata md = new IcebergConnectorMetadata(ops, Collections.emptyMap(), ctx); + Assertions.assertThrows(RuntimeException.class, () -> md.getMvccPartitionView(null, handle())); + Assertions.assertEquals(1, ctx.authCount); + Assertions.assertFalse(ops.log.contains("loadTable:db1.t1"), "loadTable must sit inside executeAuthenticated"); + } + + @Test + public void listPartitionNamesReturnsRawNames() { + Table table = dayPartitionedTable(); + List names = metadataFor(table, new RecordingIcebergCatalogOps()) + .listPartitionNames(null, handle()); + Assertions.assertEquals(Collections.singletonList("ts_day=100"), names); + } + + @Test + public void listPartitionNamesDegradesToEmptyWhenTableMissing() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.throwNoSuchTableOnLoadTable = true; + List names = + new IcebergConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext()) + .listPartitionNames(null, handle()); + Assertions.assertTrue(names.isEmpty()); + } + + @Test + public void listPartitionNamesRunsInsideAuthContext() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = dayPartitionedTable(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + IcebergConnectorMetadata md = new IcebergConnectorMetadata(ops, Collections.emptyMap(), ctx); + Assertions.assertThrows(RuntimeException.class, () -> md.listPartitionNames(null, handle())); + Assertions.assertEquals(1, ctx.authCount); + Assertions.assertFalse(ops.log.contains("loadTable:db1.t1"), "loadTable must sit inside executeAuthenticated"); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataStatisticsTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataStatisticsTest.java new file mode 100644 index 00000000000000..df9f030c1d6057 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataStatisticsTest.java @@ -0,0 +1,214 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorTableStatistics; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; + +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileMetadata; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.Optional; + +/** + * Unit tests for FIX-H4: {@link IcebergConnectorMetadata#getTableStatistics}. + * + *

    Without the override, IcebergConnectorMetadata inherited {@code ConnectorStatisticsOps}'s + * {@code Optional.empty()}, so every iceberg base table reported row count -1 to the FE optimizer + * (cardinality collapses to 1, join reorder disabled, SHOW TABLE STATUS = -1). The fix mirrors + * {@code PaimonConnectorMetadata.getTableStatistics} in structure but uses the legacy iceberg FORMULA + * ({@code IcebergUtils.getIcebergRowCount} -> {@code getCountFromSummary(summary, true)}: currentSnapshot + * summary {@code total-records - total-position-deletes}, gated to UNKNOWN when equality deletes are present, + * per upstream #64648). Tests run against a real {@link InMemoryCatalog} table (no Mockito), the + * {@link RecordingIcebergCatalogOps} fake serving it through the seam. + */ +public class IcebergConnectorMetadataStatisticsTest { + + private static final Schema SCHEMA = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get())); + + // --------------------------------------------------------------------- + // Fixtures + // --------------------------------------------------------------------- + + /** A fresh (empty) v2 InMemoryCatalog table db1.t1 — v2 so row-level delete files are legal. */ + private static Table newTable() { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + return catalog.createTable( + TableIdentifier.of("db1", "t1"), SCHEMA, PartitionSpec.unpartitioned(), + Collections.singletonMap("format-version", "2")); + } + + private static DataFile dataFile(String path, long records) { + return DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath(path).withFileSizeInBytes(100).withRecordCount(records) + .withFormat(FileFormat.PARQUET).build(); + } + + private static DeleteFile positionDeletes(String path, long records) { + return FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) + .ofPositionDeletes() + .withPath(path).withFileSizeInBytes(50).withRecordCount(records) + .withFormat(FileFormat.PARQUET).build(); + } + + private static DeleteFile equalityDeletes(String path, long records) { + return FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) + .ofEqualityDeletes(1) + .withPath(path).withFileSizeInBytes(50).withRecordCount(records) + .withFormat(FileFormat.PARQUET).build(); + } + + private static IcebergConnectorMetadata metadataFor(Table table, RecordingIcebergCatalogOps ops) { + ops.table = table; + return new IcebergConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext()); + } + + private static ConnectorTableHandle handle() { + return new IcebergTableHandle("db1", "t1"); + } + + // --------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------- + + @Test + public void rowCountFromTotalRecords() { + Table table = newTable(); + table.newAppend().appendFile(dataFile("/data/f1.parquet", 100)).commit(); + + Optional stats = + metadataFor(table, new RecordingIcebergCatalogOps()).getTableStatistics(null, handle()); + + // WHY: a populated table must report its real row count, not UNKNOWN, or the CBO collapses cardinality + // to 1 (the whole point of the fix). + Assertions.assertTrue(stats.isPresent(), "a positive row count must be reported, not UNKNOWN"); + Assertions.assertEquals(100L, stats.get().getRowCount()); + // Legacy getIcebergRowCount computes ONLY row count; data size stays unknown (-1). + Assertions.assertEquals(-1L, stats.get().getDataSize()); + } + + @Test + public void rowCountNetsOutPositionDeletes() { + Table table = newTable(); + table.newAppend().appendFile(dataFile("/data/f1.parquet", 100)).commit(); + table.newRowDelta().addDeletes(positionDeletes("/data/pd1.parquet", 30)).commit(); + + Optional stats = + metadataFor(table, new RecordingIcebergCatalogOps()).getTableStatistics(null, handle()); + + // WHY: legacy formula is total-records - total-position-deletes. MUTATION: dropping the subtraction + // yields 100, not 70. + Assertions.assertTrue(stats.isPresent()); + Assertions.assertEquals(70L, stats.get().getRowCount()); + } + + @Test + public void emptyTableReportsUnknown() { + Table table = newTable(); // no snapshot at all + + Optional stats = + metadataFor(table, new RecordingIcebergCatalogOps()).getTableStatistics(null, handle()); + + // WHY: legacy getIcebergRowCount returns UNKNOWN when currentSnapshot() == null; the connector maps + // that to empty so the FE keeps UNKNOWN. + Assertions.assertFalse(stats.isPresent(), "an empty table (no snapshot) must degrade to UNKNOWN"); + } + + @Test + public void zeroNetRowsReportUnknown() { + Table table = newTable(); + table.newAppend().appendFile(dataFile("/data/f1.parquet", 100)).commit(); + table.newRowDelta().addDeletes(positionDeletes("/data/pd1.parquet", 100)).commit(); + + Optional stats = + metadataFor(table, new RecordingIcebergCatalogOps()).getTableStatistics(null, handle()); + + // WHY: net 0 rows must report UNKNOWN, not 0. Legacy data-table consumer was `rowCount > 0 ? .. : UNKNOWN`, + // but the NEW consumer (PluginDrivenExternalTable.fetchRowCount) takes the value whenever >= 0 — so a 0 + // returned as Optional.of(0) would surface as 0. MUTATION: changing the `> 0` gate to `>= 0` surfaces 0. + Assertions.assertFalse(stats.isPresent(), "0 net rows must map to UNKNOWN, not 0"); + } + + @Test + public void systemTableReportsUnknownWithoutLoading() { + Table table = newTable(); + table.newAppend().appendFile(dataFile("/data/f1.parquet", 100)).commit(); + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + IcebergConnectorMetadata metadata = metadataFor(table, ops); + ConnectorTableHandle sysHandle = metadata.getSysTableHandle(null, handle(), "snapshots").get(); + + Optional stats = metadata.getTableStatistics(null, sysHandle); + + // WHY: legacy IcebergSysExternalTable.fetchRowCount is unconditionally UNKNOWN — a deliberate divergence + // from paimon, which DOES report sys-table counts. A metadata table's "rows" are not data rows, and a + // sys handle would otherwise load the BASE table and misreport its 100. MUTATION: removing the + // isSystemTable() guard loads the base table -> present(100); the empty assertion fails. The null + // lastLoadTable additionally proves the guard short-circuits BEFORE the seam load. + Assertions.assertFalse(stats.isPresent(), "system tables must report UNKNOWN (legacy parity)"); + Assertions.assertNull(ops.lastLoadTable, "the sys-table guard must short-circuit before loadTable"); + } + + @Test + public void loadFailureDegradesToUnknown() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.throwOnLoadTable = true; + IcebergConnectorMetadata metadata = + new IcebergConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext()); + + // WHY: a statistics miss must NEVER break query planning. MUTATION: letting the exception propagate + // makes assertDoesNotThrow fail. + Optional stats = Assertions.assertDoesNotThrow( + () -> metadata.getTableStatistics(null, handle())); + Assertions.assertFalse(stats.isPresent(), "a load failure must degrade to UNKNOWN, not throw"); + } + + @Test + public void equalityDeletesGateTableStatisticsToUnknown() { + Table table = newTable(); + table.newAppend().appendFile(dataFile("/data/f1.parquet", 100)).commit(); + table.newRowDelta().addDeletes(equalityDeletes("/data/ed1.parquet", 5)).commit(); + + Optional stats = + metadataFor(table, new RecordingIcebergCatalogOps()).getTableStatistics(null, handle()); + + // WHY: with equality deletes present the snapshot summary cannot net out the deleted rows, so + // total-records (100) overstates the real count. Legacy getIcebergRowCount -> getCountFromSummary( + // summary, true) (upstream #64648) gates such tables to UNKNOWN, and this connector's own COUNT(*) + // pushdown (IcebergScanPlanProvider.getCountFromSummary) does the same; the table-stats path must match + // or the two disagree and the CBO is fed an inflated count. MUTATION: dropping the + // `!equalityDeletes.equals("0")` gate surfaces present(100) and this assertion fails. + Assertions.assertFalse(stats.isPresent(), + "equality deletes must gate table statistics to UNKNOWN (summary cannot net them out)"); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataSysTableTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataSysTableTest.java new file mode 100644 index 00000000000000..d4f4f504047e76 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataSysTableTest.java @@ -0,0 +1,587 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorTableSchema; +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.iceberg.MetadataTableType; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * Tests for the iceberg E7 system-table capability (P6.5-T03/T04): {@code listSupportedSysTables} and + * {@code getSysTableHandle} (T03), plus the sys-aware schema/columns reload path + * ({@code getTableSchema}/{@code getColumnHandles} for a sys handle, T04). The scan-plane sys split path + * lands in T05; the sys-handle identity / serialization invariants are pinned by + * {@link IcebergTableHandleTest} (T02). + * + *

    Like the other metadata tests these drive a {@link RecordingIcebergCatalogOps} fake with a + * {@code null} real catalog, so they stay entirely offline (no live remote iceberg). + * + *

    KEY iceberg-vs-paimon deviations pinned here: + *

      + *
    • Deviation 1 (time travel): unlike paimon's {@code forSystemTable} (which clears the pin), an + * iceberg sys handle RETAINS the base handle's snapshot/ref/schema pin — iceberg system tables + * legally time-travel ({@code t$snapshots FOR VERSION/TIME AS OF ...}).
    • + *
    • Deviation 4 (position_deletes) is RETIRED: it used to be excluded (Q2, taken when legacy also + * rejected it with "not supported yet"), but upstream #65135 implemented it natively, so the + * connector now exposes it like any other metadata table. It is the only one whose SCAN diverges — + * BE reads it with a native reader instead of the JNI serialized-split path (see + * {@code IcebergScanPlanProvider.doPlanPositionDeletesSystemTableScan}).
    • + *
    • Lazy resolution: {@code getSysTableHandle} does NOT load the base table or build the + * metadata-table (the handle carries no SDK Table; the build happens lazily in + * {@code getTableSchema}/scan, mirroring legacy {@code IcebergSysExternalTable.getSysIcebergTable}). + * So a sys handle resolves with ZERO catalog round-trips.
    • + *
    + */ +public class IcebergConnectorMetadataSysTableTest { + + private static IcebergConnectorMetadata metadataWith(RecordingIcebergCatalogOps ops) { + return new IcebergConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext()); + } + + private static IcebergConnectorMetadata metadataWith( + RecordingIcebergCatalogOps ops, RecordingConnectorContext ctx) { + return new IcebergConnectorMetadata(ops, Collections.emptyMap(), ctx); + } + + private static IcebergTableHandle baseHandle() { + return new IcebergTableHandle("db1", "t1"); + } + + /** + * The canonical supported-sys-table list, recomputed from the SDK source of truth: every + * {@link MetadataTableType}, lower-cased. Mirrors {@code IcebergSysTable.SUPPORTED_SYS_TABLES} so the + * test pins "connector == the fe-core formula" without hardcoding the (SDK-version-dependent) count. + * {@code POSITION_DELETES} is included since the native port (upstream #65135). + */ + private static List expectedSupported() { + List names = new ArrayList<>(); + for (MetadataTableType type : MetadataTableType.values()) { + names.add(type.name().toLowerCase(Locale.ROOT)); + } + return names; + } + + // --------------------------------------------------------------------- + // listSupportedSysTables + // --------------------------------------------------------------------- + + @Test + public void listSupportedSysTablesMirrorsMetadataTableTypes() { + List result = metadataWith(new RecordingIcebergCatalogOps()) + .listSupportedSysTables(null, baseHandle()); + + // WHY: the set of selectable "$sys" tables a user sees per iceberg table IS exactly + // MetadataTableType.values(), lower-cased (fe-core IcebergSysTable.SUPPORTED_SYS_TABLES is built + // from that same formula). If this drifted, users could no longer reference e.g. mytable$snapshots. + // MUTATION: returning Collections.emptyList() (the SPI default) -> red; re-adding any filter -> + // expected (full) != actual -> red. + Assertions.assertEquals(expectedSupported(), result, + "must mirror MetadataTableType.values() (lower-cased), in order"); + Assertions.assertFalse(result.isEmpty(), "supported sys tables must be non-empty"); + // A representative spread of the metadata tables a user actually queries. + Assertions.assertTrue(result.containsAll(java.util.Arrays.asList( + "snapshots", "history", "files", "manifests", "partitions", "refs", "entries")), + "the supported list must include the common iceberg metadata tables"); + } + + @Test + public void listSupportedSysTablesIncludesPositionDeletes() { + List result = metadataWith(new RecordingIcebergCatalogOps()) + .listSupportedSysTables(null, baseHandle()); + + // WHY: upstream #65135 implemented $position_deletes natively, so it MUST be advertised — this is + // the whole point of the port. While the connector excluded it (the former Q2 decision, taken when + // legacy also rejected it), "select * from t$position_deletes" failed with "Unknown sys table" on + // this branch while working on master = a capability regression. MUTATION: restoring the + // `type != POSITION_DELETES` filter in listSupportedSysTables -> red. + Assertions.assertTrue(result.contains("position_deletes"), + "position_deletes must be advertised as a supported sys table (upstream #65135)"); + } + + @Test + public void listSupportedSysTablesIsUnmodifiable() { + List result = metadataWith(new RecordingIcebergCatalogOps()) + .listSupportedSysTables(null, baseHandle()); + + // WHY: the returned list is a defensive copy the connector hands out connector-global; a caller + // must not be able to mutate the connector's view. MUTATION: returning a bare mutable ArrayList + // (no Collections.unmodifiableList wrap) -> add() succeeds, no throw -> red. + Assertions.assertThrows(UnsupportedOperationException.class, + () -> result.add("injected"), + "the supported-sys-table list must be unmodifiable (defensive copy)"); + } + + @Test + public void listSupportedSysTablesIgnoresBaseHandle() { + // WHY: the supported set is connector-global (every iceberg table exposes the same metadata + // tables), so it must not depend on — or NPE on — the base handle. A null base handle must still + // yield the full canonical list. MUTATION: deriving the list from the base handle (e.g. reading + // base.getTableName()) -> NPE on a null handle -> red. + List result = metadataWith(new RecordingIcebergCatalogOps()) + .listSupportedSysTables(null, null); + Assertions.assertEquals(expectedSupported(), result, + "the supported list is connector-global and independent of the base handle"); + } + + // --------------------------------------------------------------------- + // getSysTableHandle — supported names + // --------------------------------------------------------------------- + + @Test + public void getSysTableHandleReturnsSysHandleForSupportedName() { + Optional opt = metadataWith(new RecordingIcebergCatalogOps()) + .getSysTableHandle(null, baseHandle(), "snapshots"); + + // WHY: a supported sys name must yield a sys handle that self-describes (isSystemTable + bare sys + // name) and carries the base table's db/table coordinates (so downstream schema/scan read the + // right table). MUTATION: returning Optional.empty() (the SPI default) -> red. + Assertions.assertTrue(opt.isPresent(), "a supported sys table must yield a handle"); + IcebergTableHandle handle = (IcebergTableHandle) opt.get(); + Assertions.assertTrue(handle.isSystemTable(), "the returned handle must be a sys handle"); + Assertions.assertEquals("snapshots", handle.getSysTableName()); + Assertions.assertEquals("db1", handle.getDbName()); + Assertions.assertEquals("t1", handle.getTableName()); + } + + @Test + public void getSysTableHandleNormalizesNameToLowercase() { + IcebergTableHandle handle = (IcebergTableHandle) metadataWith(new RecordingIcebergCatalogOps()) + .getSysTableHandle(null, baseHandle(), "SNAPSHOTS").get(); + + // WHY: the STORED canonical sys name must be lower-cased so a mixed-case input and its lower-case + // form yield the SAME handle (identical equals/hashCode/toString and the same metadata-table build + // later). NOTE on the case-insensitive accept: legacy RESOLUTION is itself case-SENSITIVE — + // TableIf.findSysTable does a plain Map.get against IcebergSysTable's lower-cased keyset and the + // suffix is taken verbatim (SysTable.getTableNameWithSysTableName does not lower-case it) — so a + // mixed-case "t$SNAPSHOTS" never resolves, and only lower-case canonical names are ever fed to + // getSysTableHandle (PluginDrivenSysExternalTable threads the matched lower-case name). The + // connector's equalsIgnoreCase support check is thus a harmless, production-unreachable superset; + // MetadataTableType.from's own case-insensitivity acts at metadata-table BUILD time (resolveSysTable), + // NOT this resolution gate. The lower-casing here is for canonical handle-identity parity. MUTATION: + // storing sysName verbatim -> getSysTableName() == "SNAPSHOTS" and toString ends "$SNAPSHOTS" -> red. + Assertions.assertEquals("snapshots", handle.getSysTableName(), + "the stored sys name must be normalized to lower case"); + Assertions.assertTrue(handle.toString().endsWith("$snapshots}"), + "toString must render the canonical lower-case suffix"); + } + + @Test + public void getSysTableHandleRetainsSnapshotPin() { + // A base handle carrying an explicit time-travel pin (snapshot id + ref + schema id). + IcebergTableHandle pinnedBase = baseHandle().withSnapshot(42L, "br", 7L); + + IcebergTableHandle sysHandle = (IcebergTableHandle) metadataWith(new RecordingIcebergCatalogOps()) + .getSysTableHandle(null, pinnedBase, "snapshots").get(); + + // WHY: deviation 1 — iceberg system tables legally time-travel (t$snapshots FOR VERSION/TIME AS + // OF ...), so the sys handle MUST carry the base handle's snapshot/ref/schema pin through. This is + // the OPPOSITE of paimon (whose forSystemTable clears the pin). Dropping the pin would silently + // read t$snapshots at the LATEST version under a time-travel query -> a correctness regression. + // MUTATION: building the sys handle without threading base.getSnapshotId()/getRef()/getSchemaId() + // (e.g. forSystemTable(db, table, sys, -1, null, -1)) -> red. + Assertions.assertTrue(sysHandle.isSystemTable()); + Assertions.assertEquals("snapshots", sysHandle.getSysTableName()); + Assertions.assertEquals(42L, sysHandle.getSnapshotId(), + "the base snapshot-id pin must be retained on the sys handle (time travel)"); + Assertions.assertEquals("br", sysHandle.getRef(), + "the base ref pin must be retained on the sys handle (time travel)"); + Assertions.assertEquals(7L, sysHandle.getSchemaId(), + "the base schema-id pin must be retained on the sys handle (time travel)"); + } + + // --------------------------------------------------------------------- + // getSysTableHandle — not exposed (empty) + // --------------------------------------------------------------------- + + @Test + public void getSysTableHandleResolvesPositionDeletes() { + Optional opt = metadataWith(new RecordingIcebergCatalogOps()) + .getSysTableHandle(null, baseHandle(), "position_deletes"); + + // WHY: name resolution is the gate the whole $position_deletes feature hangs off — an empty handle + // here means fe-core renders "Unknown sys table" and the native scan path is never reached, no + // matter how correct IcebergScanPlanProvider is. The handle must also be flagged isSystemTable so + // planScanInternal routes it to the metadata path rather than the data-file one. MUTATION: + // re-adding the POSITION_DELETES skip in isSupportedSysTable -> Optional.empty() -> red. + Assertions.assertTrue(opt.isPresent(), + "position_deletes must resolve to a sys handle (upstream #65135)"); + IcebergTableHandle sysHandle = (IcebergTableHandle) opt.get(); + Assertions.assertTrue(sysHandle.isSystemTable(), "the position_deletes handle must be a sys handle"); + Assertions.assertEquals("position_deletes", sysHandle.getSysTableName(), + "the sys name must be the canonical lower-cased one (handle identity)"); + } + + @Test + public void getSysTableHandleEmptyForUnknownName() { + Optional opt = metadataWith(new RecordingIcebergCatalogOps()) + .getSysTableHandle(null, baseHandle(), "not_a_sys_table"); + + // WHY: an unsupported name is "this connector does not expose that sys table" (empty), not an + // error. MUTATION: returning a handle for any name (dropping the isSupportedSysTable guard) -> red. + Assertions.assertFalse(opt.isPresent(), "an unknown sys name must yield Optional.empty()"); + } + + @Test + public void getSysTableHandleNullNameReturnsEmpty() { + Optional opt = metadataWith(new RecordingIcebergCatalogOps()) + .getSysTableHandle(null, baseHandle(), null); + + // WHY: the Javadoc contract is "or empty if not exposed" — a null sysName is simply not an + // exposed sys table, so it must return Optional.empty(), NOT NPE on toLowerCase/equalsIgnoreCase. + // MUTATION: removing the null-guard in isSupportedSysTable -> NPE -> the test errors (red). + Assertions.assertFalse(opt.isPresent(), "a null sys name must yield Optional.empty()"); + } + + // --------------------------------------------------------------------- + // lazy resolution — no catalog round-trip at handle-resolution time + // --------------------------------------------------------------------- + + @Test + public void getSysTableHandleDoesNotTouchCatalogSeam() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + Optional opt = + metadataWith(ops, ctx).getSysTableHandle(null, baseHandle(), "snapshots"); + + // WHY: unlike paimon (whose handle stashes a transient SDK Table, so it eagerly loads at + // resolution), the iceberg handle carries NO SDK Table — the metadata-table is built lazily in + // getTableSchema/scan (mirroring legacy IcebergSysExternalTable.getSysIcebergTable). Resolving a + // sys handle is therefore PURE: zero catalog round-trips and no auth scope. Loading the base + // table here would be wasted work (the result can't be stored on the handle, so it'd be rebuilt + // downstream) — a perf regression vs legacy. MUTATION: adding an eager + // context.executeAuthenticated(catalogOps.loadTable(...)) in getSysTableHandle -> ops.log + // non-empty and ctx.authCount == 1 -> red. + Assertions.assertTrue(opt.isPresent(), "precondition: snapshots is a supported sys table"); + Assertions.assertTrue(ops.log.isEmpty(), + "getSysTableHandle must not touch the catalog seam (lazy resolution)"); + Assertions.assertEquals(0, ctx.authCount, + "getSysTableHandle must not open an auth scope (no remote read at resolution)"); + } + + // --------------------------------------------------------------------- + // getTableSchema / getColumnHandles for a sys handle (T04) + // --------------------------------------------------------------------- + // + // These exercise the sys-aware schema/columns reload: the metadata-table is built lazily from the + // BASE table via MetadataTableUtils.createMetadataTableInstance. createMetadataTableInstance needs a + // real org.apache.iceberg.Table (HasTableOperations) — a FakeIcebergTable is NOT one — so the base is + // a real InMemoryCatalog table wired through the recording seam (ops.table). The base columns + // (id, name) are deliberately DIFFERENT from every metadata-table's columns so reading the wrong + // schema is detectable. + + /** Base data-table schema: deliberately disjoint from every metadata-table's columns. */ + private static final Schema BASE_SCHEMA = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get())); + + /** A real iceberg {@link Table} (a {@code BaseTable} with working {@code operations()}/{@code io()}). */ + private static Table inMemoryBaseTable() { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + return catalog.createTable( + TableIdentifier.of("db1", "t1"), BASE_SCHEMA, PartitionSpec.unpartitioned()); + } + + private static List columnNames(ConnectorTableSchema schema) { + List names = new ArrayList<>(); + for (ConnectorColumn c : schema.getColumns()) { + names.add(c.getName()); + } + return names; + } + + @Test + public void getTableSchemaForSysHandleBuildsColumnsFromMetadataTable() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = inMemoryBaseTable(); + IcebergConnectorMetadata md = metadataWith(ops); + + IcebergTableHandle sysHandle = (IcebergTableHandle) + md.getSysTableHandle(null, baseHandle(), "snapshots").get(); + List names = columnNames(md.getTableSchema(null, sysHandle)); + + // WHY: a sys handle's schema MUST come from the iceberg METADATA table (t$snapshots -> + // committed_at/snapshot_id/...), not the base table — mirroring legacy + // IcebergSysExternalTable.getOrCreateSchemaCacheValue (parseSchema of the sys table's schema). + // Surfacing the base columns for a sys-table query would be a silent correctness bug. MUTATION: + // dropping the isSystemTable() branch in getTableSchema -> base columns [id, name] -> red. + Assertions.assertTrue(names.containsAll(Arrays.asList( + "committed_at", "snapshot_id", "parent_id", "operation", "manifest_list", "summary")), + "sys schema must expose the snapshots metadata-table columns, got " + names); + Assertions.assertFalse(names.contains("id"), "must NOT surface the base table's columns"); + Assertions.assertFalse(names.contains("name"), "must NOT surface the base table's columns"); + } + + @Test + public void getTableSchemaForSysHandleUsesSysNameTypeNotHardcoded() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = inMemoryBaseTable(); + IcebergConnectorMetadata md = metadataWith(ops); + + IcebergTableHandle sysHandle = (IcebergTableHandle) + md.getSysTableHandle(null, baseHandle(), "history").get(); + List names = columnNames(md.getTableSchema(null, sysHandle)); + + // WHY: the metadata-table TYPE must come from the handle's sys name via + // MetadataTableType.from(getSysTableName()), NOT a hardcoded SNAPSHOTS. The history table + // exposes made_current_at/is_current_ancestor (which the snapshots table does not), and does NOT + // expose the snapshots-only committed_at/manifest_list. MUTATION: hardcoding + // MetadataTableType.SNAPSHOTS -> snapshots columns (committed_at present, is_current_ancestor + // absent) -> red. + Assertions.assertTrue(names.containsAll(Arrays.asList( + "made_current_at", "snapshot_id", "parent_id", "is_current_ancestor")), + "sys schema must expose the history metadata-table columns, got " + names); + Assertions.assertFalse(names.contains("committed_at"), + "history must not carry the snapshots-only columns (the sys type must thread through)"); + Assertions.assertFalse(names.contains("manifest_list"), + "history must not carry the snapshots-only columns (the sys type must thread through)"); + } + + @Test + public void getTableSchemaForSysHandleThreadsMappingFlags() { + // committed_at is a TIMESTAMP-with-zone column of the snapshots metadata table, so its mapped + // Doris type depends on enable.mapping.timestamp_tz -- a clean probe that the connector's + // properties flags thread into the SYS-table schema parse (deviation 5). + IcebergConnectorMetadata mdDefault = metadataWith(seamWith(inMemoryBaseTable())); + + Map tzProps = new HashMap<>(); + tzProps.put(IcebergConnectorProperties.ENABLE_MAPPING_TIMESTAMP_TZ, "true"); + RecordingIcebergCatalogOps opsTz = seamWith(inMemoryBaseTable()); + IcebergConnectorMetadata mdTz = + new IcebergConnectorMetadata(opsTz, tzProps, new RecordingConnectorContext()); + + ConnectorColumn committedDefault = committedAtColumn(mdDefault); + ConnectorColumn committedTz = committedAtColumn(mdTz); + + // WHY: deviation 5 -- the sys branch must reuse parseSchema so the per-catalog enable.mapping.* + // flags (read from the connector properties) reach the metadata-table schema. With the flag ON, + // committed_at maps to TIMESTAMPTZ; OFF (default) it does not. A sys branch that parsed the schema + // WITHOUT threading the flags would yield identical types. MUTATION: building the sys schema from + // a flag-less parse -> equal types -> red. + Assertions.assertNotEquals(committedDefault.getType(), committedTz.getType(), + "enable.mapping.timestamp_tz must change the sys-table committed_at mapping"); + Assertions.assertEquals("TIMESTAMPTZ", committedTz.getType().getTypeName(), + "with the flag on, the sys-table committed_at must map to TIMESTAMPTZ"); + } + + private static ConnectorColumn committedAtColumn(IcebergConnectorMetadata md) { + IcebergTableHandle sysHandle = (IcebergTableHandle) + md.getSysTableHandle(null, baseHandle(), "snapshots").get(); + for (ConnectorColumn c : md.getTableSchema(null, sysHandle).getColumns()) { + if (c.getName().equals("committed_at")) { + return c; + } + } + throw new AssertionError("the snapshots metadata table must expose a committed_at column"); + } + + private static RecordingIcebergCatalogOps seamWith(Table base) { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = base; + return ops; + } + + @Test + public void getTableSchemaForSysHandleLoadsBaseInsideAuthScope() { + RecordingIcebergCatalogOps ops = seamWith(inMemoryBaseTable()); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergConnectorMetadata md = metadataWith(ops, ctx); + + IcebergTableHandle sysHandle = (IcebergTableHandle) + md.getSysTableHandle(null, baseHandle(), "snapshots").get(); + md.getTableSchema(null, sysHandle); + + // WHY: the metadata-table is built from the BASE table loaded via the seam with the BASE + // coordinates (db1.t1, NOT a "$snapshots"-suffixed name), wrapped in exactly ONE auth scope (the + // Kerberos UGI must cover the remote base load; mirrors legacy + // IcebergSysExternalTable.getSysIcebergTable and the data-table getTableSchema). MUTATION: + // loading by getTableName()+"$"+sys -> lastLoadTable "t1$snapshots" -> red; double-wrapping the + // auth scope -> authCount 2 -> red. + Assertions.assertEquals("db1", ops.lastLoadDb, "the base table must be loaded by the base db"); + Assertions.assertEquals("t1", ops.lastLoadTable, + "the base table must be loaded by the BARE base name (not a $sys suffix)"); + Assertions.assertEquals(1, ctx.authCount, "exactly one auth scope must wrap the sys schema load"); + } + + @Test + public void getTableSchemaForSysHandleRunsInsideAuthenticator() { + RecordingIcebergCatalogOps ops = seamWith(inMemoryBaseTable()); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; // executeAuthenticated throws WITHOUT running the wrapped task + IcebergConnectorMetadata md = metadataWith(ops, ctx); + IcebergTableHandle sysHandle = (IcebergTableHandle) + md.getSysTableHandle(null, baseHandle(), "snapshots").get(); + + // WHY: the remote base load (and the metadata-table build) sit INSIDE + // context.executeAuthenticated -- when auth fails, the wrapped task must not run, so the catalog + // seam is never touched. This proves the load is auth-scoped (Kerberos UGI parity), not a bare + // unauthenticated catalogOps.loadTable. MUTATION: calling catalogOps.loadTable OUTSIDE + // executeAuthenticated -> ops.log records "loadTable:db1.t1" despite failAuth -> red. + Assertions.assertThrows(RuntimeException.class, + () -> md.getTableSchema(null, sysHandle), + "an auth failure must surface as a RuntimeException"); + Assertions.assertTrue(ops.log.isEmpty(), + "the catalog seam must not be touched when the auth scope fails (load is inside auth)"); + } + + @Test + public void getTableSchemaAtSnapshotForSysHandleUsesMetadataTableSchema() { + RecordingIcebergCatalogOps ops = seamWith(inMemoryBaseTable()); + IcebergConnectorMetadata md = metadataWith(ops); + IcebergTableHandle sysHandle = (IcebergTableHandle) + md.getSysTableHandle(null, baseHandle(), "snapshots").get(); + + // A pinned snapshot carrying a NON-negative schema id -- the case the data-table @snapshot path + // would route to table.schemas().get(schemaId). An iceberg sys handle legally carries a + // time-travel pin (deviation 1), so this overload IS reachable for a sys handle. + ConnectorMvccSnapshot pinned = + ConnectorMvccSnapshot.builder().snapshotId(7L).schemaId(0L).build(); + List names = columnNames(md.getTableSchema(null, sysHandle, pinned)); + + // WHY: an iceberg metadata table has a FIXED schema, independent of snapshot/schema-version + // (t$snapshots always exposes committed_at/snapshot_id/...; legacy has no schema-at-snapshot for + // sys tables). The @snapshot overload must therefore still build the metadata-table schema, NOT + // read base.schemas().get(schemaId) (which would surface the base columns). MUTATION: dropping the + // isSystemTable() short-circuit in the @snapshot overload -> base schema [id, name] -> red. + Assertions.assertTrue(names.containsAll(Arrays.asList( + "committed_at", "snapshot_id", "manifest_list")), + "the @snapshot sys schema must still be the metadata-table schema, got " + names); + Assertions.assertFalse(names.contains("id"), "must not fall back to the base table schema"); + } + + @Test + public void getColumnHandlesForSysHandleBuildsFromMetadataTable() { + RecordingIcebergCatalogOps ops = seamWith(inMemoryBaseTable()); + IcebergConnectorMetadata md = metadataWith(ops); + IcebergTableHandle sysHandle = (IcebergTableHandle) + md.getSysTableHandle(null, baseHandle(), "snapshots").get(); + + Map handles = md.getColumnHandles(null, sysHandle); + + // WHY: the generic PluginDrivenScanNode.buildColumnHandles looks up each query slot in this map by + // name, so a sys handle MUST expose the METADATA-table columns (t$snapshots), not the base table's + // -- otherwise a sys-table scan could not resolve its slots (or would resolve the wrong field). + // Pairs with the getTableSchema sys branch (same loadSysTable helper). MUTATION: dropping the + // isSystemTable() branch in getColumnHandles -> base keys [id, name] -> red. + Assertions.assertTrue(handles.keySet().containsAll(Arrays.asList( + "committed_at", "snapshot_id", "operation", "manifest_list")), + "sys column handles must be keyed by the metadata-table column names, got " + handles.keySet()); + Assertions.assertFalse(handles.containsKey("id"), "must not expose the base table's columns"); + Assertions.assertFalse(handles.containsKey("name"), "must not expose the base table's columns"); + } + + // --------------------------------------------------------------------- + // P6.5-T07 gap-fill + // --------------------------------------------------------------------- + + @Test + public void getColumnHandlesForSysHandleLoadsBaseInsideAuthScope() { + RecordingIcebergCatalogOps ops = seamWith(inMemoryBaseTable()); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergConnectorMetadata md = metadataWith(ops, ctx); + IcebergTableHandle sysHandle = (IcebergTableHandle) + md.getSysTableHandle(null, baseHandle(), "snapshots").get(); + + md.getColumnHandles(null, sysHandle); + + // WHY (T07 gap-fill): getColumnHandles shares loadSysTable with getTableSchema, so the BASE load + // must sit in exactly ONE auth scope by the BARE base coordinates -- but only getTableSchema pinned + // this. MUTATION: loading by a "$"-suffixed name -> lastLoadTable "t1$snapshots" -> red; + // double-wrapping / no auth scope -> authCount != 1 -> red. + Assertions.assertEquals("db1", ops.lastLoadDb); + Assertions.assertEquals("t1", ops.lastLoadTable, + "getColumnHandles must load the BARE base name (not a $sys suffix)"); + Assertions.assertEquals(1, ctx.authCount, + "exactly one auth scope must wrap the sys column-handle load"); + } + + @Test + public void getColumnHandlesForSysHandleRunsInsideAuthenticator() { + RecordingIcebergCatalogOps ops = seamWith(inMemoryBaseTable()); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; // executeAuthenticated throws WITHOUT running the wrapped task + IcebergConnectorMetadata md = metadataWith(ops, ctx); + IcebergTableHandle sysHandle = (IcebergTableHandle) + md.getSysTableHandle(null, baseHandle(), "snapshots").get(); + + // WHY (T07 gap-fill): like getTableSchema, the getColumnHandles base load must sit INSIDE + // executeAuthenticated, so an auth failure leaves the catalog seam untouched. MUTATION: loading + // OUTSIDE the auth scope -> ops.log records the load despite failAuth -> red. + Assertions.assertThrows(RuntimeException.class, () -> md.getColumnHandles(null, sysHandle)); + Assertions.assertTrue(ops.log.isEmpty(), + "the catalog seam must not be touched when the auth scope fails"); + } + + @Test + public void getColumnHandlesKeysetMatchesSchemaForSysHandle() { + RecordingIcebergCatalogOps ops = seamWith(inMemoryBaseTable()); + IcebergConnectorMetadata md = metadataWith(ops); + IcebergTableHandle sysHandle = (IcebergTableHandle) + md.getSysTableHandle(null, baseHandle(), "snapshots").get(); + + java.util.Set schemaNames = + new java.util.HashSet<>(columnNames(md.getTableSchema(null, sysHandle))); + java.util.Set handleKeys = md.getColumnHandles(null, sysHandle).keySet(); + + // WHY (T07 gap-fill, #969249): the BE scan-slot names (getTableSchema -> parseSchema) and the + // column-handle keys (getColumnHandles) are produced by two INDEPENDENT loops over the same + // metadata table; they match only by construction (same source + same lowercasing). + // PluginDrivenScanNode.buildColumnHandles resolves each schema slot against the handle map by name, + // so a drift (one path drops lowercasing or reads a different schema) leaves BE slots unresolvable + // -- yet each method's own containsAll test still passes. MUTATION: key getColumnHandles by + // field.name() (no lowercase) while getTableSchema keeps lowercasing -> the two sets diverge -> red. + Assertions.assertEquals(schemaNames, handleKeys, + "getColumnHandles keys must equal the getTableSchema column names by construction (#969249)"); + } + + @Test + public void getSysTableHandleEmptyForBlankName() { + // WHY (T07 gap-fill): null and unknown names each yield Optional.empty and are pinned; the + // empty-string loop-fallthrough (isSupportedSysTable's null-guard does NOT cover "") is not. + // Legacy TableIf.findSysTable also returns empty for an empty sys name (parity). MUTATION: + // special-casing "" to a present handle -> isPresent() true -> red. + Assertions.assertFalse( + metadataWith(new RecordingIcebergCatalogOps()) + .getSysTableHandle(null, baseHandle(), "").isPresent(), + "an empty sys name must yield Optional.empty()"); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java new file mode 100644 index 00000000000000..876e84d9a55a24 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java @@ -0,0 +1,1361 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorDatabaseMetadata; +import org.apache.doris.connector.api.ConnectorTableSchema; +import org.apache.doris.connector.api.ConnectorViewDefinition; +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.WriteOperation; + +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.view.ImmutableSQLViewRepresentation; +import org.apache.iceberg.view.ImmutableViewVersion; +import org.apache.iceberg.view.ViewVersion; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +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; + +/** + * Characterization tests for {@link IcebergConnectorMetadata}, pinning the read-path behavior after + * the {@link IcebergCatalogOps} seam extraction (P6.1). Mirrors the paimon connector's + * {@code PaimonConnectorMetadataTest}. + * + *

    The seam fully covers every remote {@code Catalog} call the metadata makes, so each test drives + * a {@link RecordingIcebergCatalogOps} fake and builds the metadata with a {@code null} real catalog + * — the tests are entirely offline (no live REST/HMS/Glue/... catalog), which is the whole point of + * introducing the seam. + * + *

    Behavior is FROZEN this phase: these tests pin the CURRENT production behavior (including the + * known format-version oddity documented below), NOT the future-fixed parity behavior — the parity + * fixes land in later tasks (P6-T08/T09). + */ +public class IcebergConnectorMetadataTest { + + private static IcebergConnectorMetadata metadataWith(RecordingIcebergCatalogOps ops) { + return metadataWith(ops, Collections.emptyMap()); + } + + private static IcebergConnectorMetadata metadataWith( + RecordingIcebergCatalogOps ops, Map props) { + return new IcebergConnectorMetadata(ops, props, new RecordingConnectorContext()); + } + + private static IcebergConnectorMetadata metadataWith( + RecordingIcebergCatalogOps ops, RecordingConnectorContext ctx) { + return new IcebergConnectorMetadata(ops, Collections.emptyMap(), ctx); + } + + /** A simple 2-column unpartitioned schema (id required, name optional). */ + private static Schema idNameSchema() { + return new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get())); + } + + /** + * A view version whose summary records {@code engine-name} (when non-null) and whose current version + * carries a SQL representation for {@code reprDialect} (when non-null) — driving the sql/dialect extraction + * in {@code IcebergConnectorMetadata.getViewDefinition}. Mirrors the helper that used to live in the seam + * test (the extraction moved up post-H8). + */ + private static ViewVersion viewVersionWith(String engineName, String reprDialect, String sql) { + ImmutableViewVersion.Builder builder = ImmutableViewVersion.builder() + .versionId(1) + .timestampMillis(0L) + .schemaId(0) + .defaultNamespace(Namespace.of("db1")); + if (engineName != null) { + builder.putSummary("engine-name", engineName); + } + if (reprDialect != null) { + builder.addRepresentations(ImmutableSQLViewRepresentation.builder() + .sql(sql).dialect(reprDialect).build()); + } + return builder.build(); + } + + // --------------------------------------------------------------------- + // write capabilities (row-level DML dispatch) + // --------------------------------------------------------------------- + + @Test + public void applyRewriteFileScopeThreadsRawPathsOntoHandle() { + // The distributed rewrite scan-scope pin reaches the connector through the handle: the engine calls + // applyRewriteFileScope, the iceberg override threads the RAW paths onto an immutable handle copy that + // the scan provider filters its re-enumerated tasks against. MUTATION: dropping the override (return + // handle) -> the group scans the whole table -> each group rewrites far beyond its bin-pack set. + IcebergConnectorMetadata metadata = metadataWith(new RecordingIcebergCatalogOps()); + IcebergTableHandle handle = new IcebergTableHandle("db1", "t1"); + Assertions.assertNull(handle.getRewriteFileScope(), "a fresh handle has no rewrite scope"); + + Set paths = new HashSet<>(Arrays.asList( + "s3://b/db1/t1/a.parquet", "s3://b/db1/t1/b.parquet")); + ConnectorTableHandle scoped = metadata.applyRewriteFileScope(null, handle, paths); + Assertions.assertEquals(paths, ((IcebergTableHandle) scoped).getRewriteFileScope(), + "override must thread the raw paths onto the handle's rewrite scope"); + + // null / empty -> handle unchanged (full scan), never an empty scope (which would scan nothing). + Assertions.assertSame(handle, metadata.applyRewriteFileScope(null, handle, null)); + Assertions.assertSame(handle, metadata.applyRewriteFileScope(null, handle, Collections.emptySet())); + } + + @Test + public void getTableCommentReadsCommentProperty() { + // F9/F12: the SPI default (ConnectorTableOps.getTableComment) returns "", so a flipped iceberg table's + // COMMENT clause / information_schema.tables.TABLE_COMMENT / SHOW TABLE STATUS Comment column would be + // blank. This override reads the native iceberg table's "comment" property, mirroring legacy + // IcebergExternalTable.getComment. MUTATION: dropping the override (SPI default "") -> comment always + // blank -> red. + Map props = new HashMap<>(); + props.put("comment", "sales fact"); + Assertions.assertEquals("sales fact", + metadataWithTableProps(props).getTableComment(null, "db1", "t1")); + // Absent comment -> "" via getOrDefault (byte-identical to legacy properties().getOrDefault). + Assertions.assertEquals("", + metadataWithTableProps(new HashMap<>()).getTableComment(null, "db1", "t1")); + } + + /** A metadata over a single table {@code db1.t1} carrying the given iceberg table properties. */ + private static IcebergConnectorMetadata metadataWithTableProps(Map tableProps) { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = new FakeIcebergTable( + "t1", idNameSchema(), PartitionSpec.unpartitioned(), "s3://bucket/db1/t1", tableProps); + return metadataWith(ops); + } + + @Test + public void getTableCommentIsMemoizedAcrossQueriesWhenCommentCachePresent() { + // PERF-05: information_schema.tables / SHOW TABLE STATUS calls getTableComment PER table. On a vended- + // credentials (non-session) catalog the connector attaches an IcebergCommentCache; repeated lookups of the + // SAME table across queries must collapse onto ONE remote loadTable. MUTATION: not routing getTableComment + // through the cache -> each call reloads -> loadCountForTest > 1 / >1 remote "loadTable:" -> red. + Map props = new HashMap<>(); + props.put("comment", "sales fact"); + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = new FakeIcebergTable( + "t1", idNameSchema(), PartitionSpec.unpartitioned(), "s3://bucket/db1/t1", props); + IcebergCommentCache cache = new IcebergCommentCache(100, 1000); + IcebergConnectorMetadata metadata = new IcebergConnectorMetadata(ops, Collections.emptyMap(), + new RecordingConnectorContext(), new IcebergLatestSnapshotCache(0L, 1), null, null, cache); + + Assertions.assertEquals("sales fact", metadata.getTableComment(null, "db1", "t1")); + Assertions.assertEquals("sales fact", metadata.getTableComment(null, "db1", "t1")); + Assertions.assertEquals("sales fact", metadata.getTableComment(null, "db1", "t1")); + + Assertions.assertEquals(1, cache.loadCountForTest(), + "repeated getTableComment at one table must load the comment exactly once"); + long remoteLoads = ops.log.stream().filter(s -> s.startsWith("loadTable:db1.t1")).count(); + Assertions.assertEquals(1, remoteLoads, "exactly one remote loadTable across the repeats"); + // Parity: the cached value equals a direct (uncached) read of the comment property. + Assertions.assertEquals("sales fact", + metadataWithTableProps(props).getTableComment(null, "db1", "t1")); + } + + private static void assertCopyOnWriteRejected( + IcebergConnectorMetadata md, WriteOperation op, String operationLabel, String property) { + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> md.validateRowLevelDmlMode(null, new IcebergTableHandle("db1", "t1"), op)); + Assertions.assertTrue(e.getMessage().contains(operationLabel), e.getMessage()); + Assertions.assertTrue(e.getMessage().contains("copy-on-write"), e.getMessage()); + Assertions.assertTrue(e.getMessage().contains(property), e.getMessage()); + } + + @Test + public void validateRowLevelDmlModeDefaultRejectsCopyOnWrite() { + // WHY: iceberg's DELETE/UPDATE/MERGE mode defaults to copy-on-write (TableProperties.*_MODE_DEFAULT), + // which Doris cannot execute (it does merge-on-read position deletes / DVs only). With NO mode + // property set, every row-level op must be rejected — byte-identical to the legacy fe-resident + // IcebergDmlCommandUtilsTest.testDefaultModesRejectCopyOnWriteOperations. MUTATION: swapping the + // getOrDefault fallback to merge-on-read -> default tables wrongly admitted -> red. + IcebergConnectorMetadata md = metadataWithTableProps(new HashMap<>()); + assertCopyOnWriteRejected(md, WriteOperation.DELETE, "DELETE", TableProperties.DELETE_MODE); + assertCopyOnWriteRejected(md, WriteOperation.UPDATE, "UPDATE", TableProperties.UPDATE_MODE); + assertCopyOnWriteRejected(md, WriteOperation.MERGE, "MERGE INTO", TableProperties.MERGE_MODE); + } + + @Test + public void validateRowLevelDmlModeExplicitCopyOnWriteRejects() { + Map props = new HashMap<>(); + props.put(TableProperties.DELETE_MODE, RowLevelOperationMode.COPY_ON_WRITE.modeName()); + props.put(TableProperties.UPDATE_MODE, RowLevelOperationMode.COPY_ON_WRITE.modeName()); + props.put(TableProperties.MERGE_MODE, RowLevelOperationMode.COPY_ON_WRITE.modeName()); + IcebergConnectorMetadata md = metadataWithTableProps(props); + assertCopyOnWriteRejected(md, WriteOperation.DELETE, "DELETE", TableProperties.DELETE_MODE); + assertCopyOnWriteRejected(md, WriteOperation.UPDATE, "UPDATE", TableProperties.UPDATE_MODE); + assertCopyOnWriteRejected(md, WriteOperation.MERGE, "MERGE INTO", TableProperties.MERGE_MODE); + } + + @Test + public void validateRowLevelDmlModeMergeOnReadAllows() { + Map props = new HashMap<>(); + props.put(TableProperties.DELETE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); + props.put(TableProperties.UPDATE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); + props.put(TableProperties.MERGE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); + IcebergConnectorMetadata md = metadataWithTableProps(props); + Assertions.assertDoesNotThrow(() -> + md.validateRowLevelDmlMode(null, new IcebergTableHandle("db1", "t1"), WriteOperation.DELETE)); + Assertions.assertDoesNotThrow(() -> + md.validateRowLevelDmlMode(null, new IcebergTableHandle("db1", "t1"), WriteOperation.UPDATE)); + Assertions.assertDoesNotThrow(() -> + md.validateRowLevelDmlMode(null, new IcebergTableHandle("db1", "t1"), WriteOperation.MERGE)); + } + + @Test + public void validateRowLevelDmlModeSelectsPropertyPerOperation() { + // Load-bearing: each op reads ITS OWN mode property. Only DELETE is set to merge-on-read; UPDATE and + // MERGE fall back to the copy-on-write default and must still be rejected. MUTATION: routing every op + // to DELETE_MODE -> UPDATE/MERGE wrongly admitted -> red. + Map props = new HashMap<>(); + props.put(TableProperties.DELETE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); + IcebergConnectorMetadata md = metadataWithTableProps(props); + Assertions.assertDoesNotThrow(() -> + md.validateRowLevelDmlMode(null, new IcebergTableHandle("db1", "t1"), WriteOperation.DELETE)); + assertCopyOnWriteRejected(md, WriteOperation.UPDATE, "UPDATE", TableProperties.UPDATE_MODE); + assertCopyOnWriteRejected(md, WriteOperation.MERGE, "MERGE INTO", TableProperties.MERGE_MODE); + } + + @Test + public void validateRowLevelDmlModeIsNoOpForNonRowLevelOps() { + // INSERT / OVERWRITE / REWRITE are not row-level DML: validate is a no-op and never even loads the + // table to read a mode property — so a copy-on-write table is NOT rejected for a plain append. + Map props = new HashMap<>(); + props.put(TableProperties.DELETE_MODE, RowLevelOperationMode.COPY_ON_WRITE.modeName()); + IcebergConnectorMetadata md = metadataWithTableProps(props); + Assertions.assertDoesNotThrow(() -> + md.validateRowLevelDmlMode(null, new IcebergTableHandle("db1", "t1"), WriteOperation.INSERT)); + Assertions.assertDoesNotThrow(() -> + md.validateRowLevelDmlMode(null, new IcebergTableHandle("db1", "t1"), WriteOperation.OVERWRITE)); + } + + /** A metadata over a single table {@code db1.t1} with the given schema + partition spec. */ + private static IcebergConnectorMetadata metadataWithSpec(Schema schema, PartitionSpec spec) { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = new FakeIcebergTable("t1", schema, spec, "s3://bucket/db1/t1", Collections.emptyMap()); + return metadataWith(ops); + } + + @Test + public void validateStaticPartitionColumnsAcceptsIdentityColumn() { + // WHY: static-partition overwrite (INSERT OVERWRITE ... PARTITION(col=val)) is legal only on an IDENTITY + // partition field. With the iceberg router flipped this validation moved out of the (now dead) fe-resident + // BindSink.validateStaticPartition into the connector; an identity field must be accepted so valid static + // overwrites plan. MUTATION: throwing unconditionally -> every static overwrite rejected -> red. + Schema schema = idNameSchema(); + IcebergConnectorMetadata md = metadataWithSpec(schema, + PartitionSpec.builderFor(schema).identity("name").bucket("id", 8).build()); + Assertions.assertDoesNotThrow(() -> md.validateStaticPartitionColumns( + null, new IcebergTableHandle("db1", "t1"), Collections.singletonList("name"))); + } + + @Test + public void validateStaticPartitionColumnsRejectsUnknownColumn() { + // WHY: a PARTITION(col=..) naming a column that is not a partition FIELD must fail loud at analysis time + // (byte-identical to legacy validateStaticPartition), else the unknown column is silently swallowed by the + // sink's materialize block and surfaces as an unrelated planning error ("Cannot find snapshot"). The lookup + // is keyed by partition FIELD name, so "id" (the bucket's SOURCE column, not a field) is also "unknown" — + // mirroring regression test_iceberg_static_partition_overwrite TC29 PARTITION(category) over bucket(category). + // MUTATION: dropping the containsKey/null check -> unknown column admitted -> red. + Schema schema = idNameSchema(); + IcebergConnectorMetadata md = metadataWithSpec(schema, + PartitionSpec.builderFor(schema).identity("name").bucket("id", 8).build()); + for (String bad : new String[] {"invalid_col", "id"}) { + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> md.validateStaticPartitionColumns( + null, new IcebergTableHandle("db1", "t1"), Collections.singletonList(bad))); + Assertions.assertTrue(e.getMessage().contains("Unknown partition column"), e.getMessage()); + } + } + + @Test + public void validateStaticPartitionColumnsRejectsNonIdentityField() { + // WHY: a bucket/truncate/temporal partition FIELD exists in the spec, but static overwrite of a + // non-identity transform is unsupported and must be rejected with the connector-authored message. The + // bucket field is named "id_bucket" (iceberg default). MUTATION: dropping the isIdentity check -> a + // non-identity static overwrite silently admitted -> red. + Schema schema = idNameSchema(); + IcebergConnectorMetadata md = metadataWithSpec(schema, + PartitionSpec.builderFor(schema).identity("name").bucket("id", 8).build()); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> md.validateStaticPartitionColumns( + null, new IcebergTableHandle("db1", "t1"), Collections.singletonList("id_bucket"))); + Assertions.assertTrue( + e.getMessage().contains("Cannot use static partition syntax for non-identity partition field"), + e.getMessage()); + } + + @Test + public void validateStaticPartitionColumnsRejectsUnpartitionedTable() { + // WHY: static partition syntax is meaningless on an unpartitioned table; legacy rejected it up front with + // a dedicated message. MUTATION: dropping the isPartitioned guard -> an empty partitionFieldMap makes every + // column read as "Unknown partition column" (wrong message) -> red. + IcebergConnectorMetadata md = metadataWithSpec(idNameSchema(), PartitionSpec.unpartitioned()); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> md.validateStaticPartitionColumns( + null, new IcebergTableHandle("db1", "t1"), Collections.singletonList("name"))); + Assertions.assertTrue(e.getMessage().contains("is not partitioned"), e.getMessage()); + } + + @Test + public void validateStaticPartitionColumnsEmptyIsNoOp() { + // An absent PARTITION clause is a plain (non-static) write: validate must early-return without even loading + // the table, so an unpartitioned table is NOT rejected. MUTATION: removing the empty early return -> + // unpartitioned plain writes wrongly rejected -> red. + IcebergConnectorMetadata md = metadataWithSpec(idNameSchema(), PartitionSpec.unpartitioned()); + Assertions.assertDoesNotThrow(() -> md.validateStaticPartitionColumns( + null, new IcebergTableHandle("db1", "t1"), Collections.emptyList())); + } + + // --------------------------------------------------------------------- + // list / exists delegation + // --------------------------------------------------------------------- + + @Test + public void listDatabaseNamesDelegatesToOps() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.databases = Arrays.asList("db_a", "db_b"); + + List result = metadataWith(ops).listDatabaseNames(null); + + // WHY: listDatabaseNames must return exactly what the remote catalog reports, in order; it is + // the only source of the catalog's database list shown to users. MUTATION: returning + // emptyList (dropping the delegation) -> red. + Assertions.assertEquals(Arrays.asList("db_a", "db_b"), result); + Assertions.assertEquals(Collections.singletonList("listDatabaseNames"), ops.log, + "listDatabaseNames must make exactly one listDatabaseNames() call on the seam"); + } + + @Test + public void databaseExistsDelegatesTrue() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.databaseExists = true; + + boolean exists = metadataWith(ops).databaseExists(null, "db1"); + + // WHY: databaseExists must surface the seam's existence answer verbatim. MUTATION: hardcoding + // false (or not delegating) -> red. + Assertions.assertTrue(exists); + Assertions.assertEquals(Collections.singletonList("databaseExists:db1"), ops.log); + } + + @Test + public void databaseExistsDelegatesFalse() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.databaseExists = false; + + // WHY: the false branch (e.g. a catalog without namespace support, or a genuinely absent db) + // must also pass through. MUTATION: hardcoding true -> red. + Assertions.assertFalse(metadataWith(ops).databaseExists(null, "ghost")); + } + + @Test + public void listTableNamesDelegatesToOps() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.tables = Arrays.asList("t1", "t2"); + + List result = metadataWith(ops).listTableNames(null, "db1"); + + // WHY: listTableNames must surface exactly the remote table list for the given db. MUTATION: + // returning emptyList (dropping delegation) -> red. + Assertions.assertEquals(Arrays.asList("t1", "t2"), result); + Assertions.assertEquals(Collections.singletonList("listTableNames:db1"), ops.log); + } + + @Test + public void listViewNamesDelegatesToOps() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.views = Arrays.asList("v1", "v2"); + + List result = metadataWith(ops).listViewNames(null, "db1"); + + // WHY: post-cutover the catalog re-merges the connector's view names back into SHOW TABLES (iceberg's + // listTableNames subtracts them) and cascades them on force-drop. listViewNames must surface exactly + // the remote view list for the given db. MUTATION: returning emptyList (dropping delegation) -> the + // catalog merge sees no views -> views vanish from SHOW TABLES -> red. + Assertions.assertEquals(Arrays.asList("v1", "v2"), result); + Assertions.assertEquals(Collections.singletonList("listViewNames:db1"), ops.log); + } + + @Test + public void viewExistsDelegatesToOps() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.viewExists = true; + + boolean present = metadataWith(ops).viewExists(null, "db1", "v1"); + + // WHY: PluginDrivenExternalTable.isView() resolves from this; it must report exactly what the seam + // says for the (db, view) pair. MUTATION: returning false (dropping delegation) -> a flipped iceberg + // view reports isView()==false -> scanned as a table -> red. + Assertions.assertTrue(present); + Assertions.assertEquals(Collections.singletonList("viewExists:db1.v1"), ops.log, + "viewExists must gate on exactly one viewExists() call carrying the db/view names verbatim"); + } + + @Test + public void viewExistsFalseWhenSeamReportsFalse() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.viewExists = false; + + // WHY: a plain table must NOT be reported as a view. MUTATION: hard-coding true -> every table looks + // like a view -> red. + Assertions.assertFalse(metadataWith(ops).viewExists(null, "db1", "t1")); + } + + @Test + public void getViewDefinitionReturnsSqlDialectAndColumns() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + Schema viewSchema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get())); + ops.view = new FakeIcebergViewCatalog.StubView( + viewVersionWith("Spark", "spark", "SELECT 1"), viewSchema); + + ConnectorViewDefinition def = metadataWith(ops).getViewDefinition(null, "db1", "v1"); + + // WHY (H8): ONE loadView yields the sql/dialect (BindRelation / SHOW CREATE) AND the column schema + // (DESC / SHOW COLUMNS / information_schema.columns). The dialect is the summary engine-name LOWERCASED; + // the sql is that dialect's representation; the columns are parseSchema(view.schema()). MUTATION: + // dropping toLowerCase / wrong representation / not parsing the view schema (empty columns) -> red. + Assertions.assertEquals("SELECT 1", def.getSql()); + Assertions.assertEquals("spark", def.getDialect()); + List cols = def.getColumns(); + Assertions.assertEquals(2, cols.size(), "the view columns must come from parseSchema(view.schema())"); + Assertions.assertEquals("id", cols.get(0).getName()); + Assertions.assertEquals("name", cols.get(1).getName()); + Assertions.assertEquals("db1", ops.lastLoadViewDb); + Assertions.assertEquals("v1", ops.lastLoadViewName); + Assertions.assertEquals(Collections.singletonList("loadView:db1.v1"), ops.log, + "getViewDefinition must do exactly one loadView() carrying db/view verbatim"); + } + + @Test + public void getViewDefinitionParsesColumnsHonoringMappingFlags() { + // WHY (H8): the view columns are built by the SAME parseSchema the table path uses, so the per-catalog + // enable.mapping.* flags — which live in THIS layer's properties, not the SDK-only seam — must thread + // into the view's column types and the WITH_TIMEZONE marker, exactly as for a table. This is the reason + // the column build lives in the metadata layer (not the seam). MUTATION: building columns in the seam + // (no flags) / not threading the flags -> STRING/DATETIMEV2 + no marker -> red. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + Schema viewSchema = new Schema( + Types.NestedField.optional(1, "b", Types.BinaryType.get()), + Types.NestedField.optional(2, "ts_tz", Types.TimestampType.withZone())); + ops.view = new FakeIcebergViewCatalog.StubView( + viewVersionWith("spark", "spark", "SELECT 1"), viewSchema); + Map props = new HashMap<>(); + props.put("enable.mapping.varbinary", "true"); + props.put("enable.mapping.timestamp_tz", "true"); + + List cols = + metadataWith(ops, props).getViewDefinition(null, "db1", "v1").getColumns(); + + Assertions.assertEquals("VARBINARY", cols.get(0).getType().getTypeName(), + "enable.mapping.varbinary=true must thread into the view's BINARY column"); + Assertions.assertEquals("TIMESTAMPTZ", cols.get(1).getType().getTypeName(), + "enable.mapping.timestamp_tz=true must thread into the view's TIMESTAMP-with-zone column"); + Assertions.assertTrue(cols.get(1).isWithTimeZone(), + "a with-zone timestamp view column must carry the WITH_TIMEZONE marker"); + } + + @Test + public void getViewDefinitionRunsInsideAuthContext() { + // WHY: the remote load must sit INSIDE executeAuthenticated (same as viewExists/listTableNames). With a + // context whose executeAuthenticated throws WITHOUT running the task, getViewDefinition must surface a + // (normalized) failure and NEVER call the seam. MUTATION: hoisting the call outside the auth wrap -> + // the seam is reached / no failure -> red. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.view = new FakeIcebergViewCatalog.StubView( + viewVersionWith("spark", "spark", "SELECT 1"), idNameSchema()); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + + Assertions.assertThrows(RuntimeException.class, + () -> metadataWith(ops, ctx).getViewDefinition(null, "db1", "v1")); + Assertions.assertFalse(ops.log.contains("loadView:db1.v1"), + "the seam must not be reached when the auth wrap throws"); + } + + @Test + public void getViewDefinitionThrowsWhenNoCurrentVersion() { + // WHY (moved from the seam test post-H8): a view with no current version is unusable; the metadata + // extraction must fail loud rather than NPE on summary(). MUTATION: dropping the null-version check. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.view = new FakeIcebergViewCatalog.StubView(null, idNameSchema()); + Assertions.assertThrows(RuntimeException.class, + () -> metadataWith(ops).getViewDefinition(null, "db1", "v1")); + } + + @Test + public void getViewDefinitionThrowsWhenEngineNameMissing() { + // WHY (moved from the seam test post-H8): the dialect IS the summary engine-name; without it the SQL + // representation cannot be selected. MUTATION: dropping the empty-engine-name check -> wrong behavior. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.view = new FakeIcebergViewCatalog.StubView( + viewVersionWith(null, "spark", "SELECT 1"), idNameSchema()); + Assertions.assertThrows(RuntimeException.class, + () -> metadataWith(ops).getViewDefinition(null, "db1", "v1")); + } + + @Test + public void getViewDefinitionThrowsWhenNoSqlForDialect() { + // WHY (moved from the seam test post-H8): engine-name present but no SQL representation for that dialect + // -> sqlFor returns null -> must fail loud (mirrors legacy "Cannot get view text"). MUTATION: dropping + // the null-sql check -> NPE. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.view = new FakeIcebergViewCatalog.StubView( + viewVersionWith("spark", null, null), idNameSchema()); + Assertions.assertThrows(RuntimeException.class, + () -> metadataWith(ops).getViewDefinition(null, "db1", "v1")); + } + + // --------------------------------------------------------------------- + // getTableHandle — present iff tableExists, carries db/table coordinates + // --------------------------------------------------------------------- + + @Test + public void getTableHandlePresentWhenTableExists() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.tableExists = true; + + Optional handleOpt = metadataWith(ops).getTableHandle(null, "db1", "t1"); + + // WHY: a handle is the FE-side coordinate later used to load the schema; it must be present + // exactly when the seam reports the table exists, and must carry the db/table names verbatim. + // MUTATION: returning empty on exists==true, or losing the coordinates -> red. + Assertions.assertTrue(handleOpt.isPresent()); + IcebergTableHandle handle = (IcebergTableHandle) handleOpt.get(); + Assertions.assertEquals("db1", handle.getDbName()); + Assertions.assertEquals("t1", handle.getTableName()); + Assertions.assertEquals(Collections.singletonList("tableExists:db1.t1"), ops.log, + "getTableHandle must gate on exactly one tableExists() call"); + } + + @Test + public void getTableHandleEmptyWhenTableMissing() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.tableExists = false; + + Optional handleOpt = + metadataWith(ops).getTableHandle(null, "db1", "ghost"); + + // WHY: a missing table is an absent handle (Optional.empty), not a thrown error and not a + // present handle that later fails on load. MUTATION: returning a present handle when + // tableExists==false -> red. + Assertions.assertFalse(handleOpt.isPresent()); + } + + // --------------------------------------------------------------------- + // getTableSchema — load via seam, parse columns + table props + // --------------------------------------------------------------------- + + @Test + public void getTableSchemaParsesColumnsFromLoadedTable() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = new FakeIcebergTable( + "t1", idNameSchema(), PartitionSpec.unpartitioned(), + "s3://bucket/db1/t1", Collections.emptyMap()); + + ConnectorTableHandle handle = new IcebergTableHandle("db1", "t1"); + ConnectorTableSchema schema = metadataWith(ops).getTableSchema(null, handle); + + // WHY: the schema must be derived from the table the seam LOADS for the handle's coordinates; + // the columns come from the Iceberg Schema in order. MUTATION: not loading via the seam, or + // dropping/reordering columns -> red. The recorded loadTable proves the read went through the + // seam with the handle's db/table. + Assertions.assertTrue(ops.log.contains("loadTable:db1.t1"), + "getTableSchema must load the table via the seam using the handle coordinates"); + List cols = schema.getColumns(); + Assertions.assertEquals(2, cols.size()); + Assertions.assertEquals("id", cols.get(0).getName()); + Assertions.assertEquals("INT", cols.get(0).getType().getTypeName()); + Assertions.assertEquals("name", cols.get(1).getName()); + Assertions.assertEquals("STRING", cols.get(1).getType().getTypeName()); + + // WHY: legacy IcebergUtils.parseSchema builds EVERY column with isAllowNull=true regardless of + // the Iceberg field's required/optional flag (rows can still read NULL under schema-evolution + // default-fill, and nereids must not fold null-rejecting predicates the legacy path permitted). + // So even a REQUIRED Iceberg field surfaces as nullable. MUTATION: propagating field.isOptional() + // (required -> NOT NULL) -> red. + Assertions.assertTrue(cols.get(0).isNullable(), + "a required Iceberg field must STILL surface as nullable (legacy forces isAllowNull=true)"); + Assertions.assertTrue(cols.get(1).isNullable(), + "an optional Iceberg field must surface as nullable"); + + // WHY: legacy IcebergUtils.parseSchema passes isKey=true for every column, so DESC shows Key=true + // for all iceberg columns (external-table semantics). MUTATION: the 5-arg ConnectorColumn ctor + // (default isKey=false) -> red. + Assertions.assertTrue(cols.get(0).isKey(), + "every iceberg column must be a key column (legacy parity: isKey=true)"); + Assertions.assertTrue(cols.get(1).isKey(), + "every iceberg column must be a key column (legacy parity: isKey=true)"); + + // WHY (H-10 L3): legacy IcebergUtils.updateIcebergColumnUniqueId set the top-level Column.uniqueId = + // field.fieldId(); post-flip parseSchema must carry it on ConnectorColumn.withUniqueId so the BE + // field-id scan path keys its read projection / nested matching off the stable id (rename-safe). + // idNameSchema assigns field-ids 1 and 2. MUTATION: dropping the withUniqueId(field.fieldId()) call + // leaves the uniqueId at the default -1 -> red. + Assertions.assertEquals(1, cols.get(0).getUniqueId(), "id carries iceberg field-id 1"); + Assertions.assertEquals(2, cols.get(1).getUniqueId(), "name carries iceberg field-id 2"); + + // WHY: the table-format type tag is the fixed "ICEBERG" discriminator the FE uses to route the + // schema. MUTATION: emitting a different/empty tag -> red. + Assertions.assertEquals("ICEBERG", schema.getTableFormatType()); + } + + @Test + public void getTableSchemaCarriesFieldDocAsComment() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + Schema docSchema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get(), "the primary id"), + Types.NestedField.optional(2, "name", Types.StringType.get())); + ops.table = new FakeIcebergTable( + "t1", docSchema, PartitionSpec.unpartitioned(), + "s3://bucket/db1/t1", Collections.emptyMap()); + + ConnectorTableSchema schema = + metadataWith(ops).getTableSchema(null, new IcebergTableHandle("db1", "t1")); + + // WHY: the Iceberg field doc becomes the Doris column comment; a field with no doc becomes the + // empty string (NOT null), matching the production `field.doc() != null ? field.doc() : ""`. + // MUTATION: dropping the doc->comment carry, or passing null for the empty case -> red. + Assertions.assertEquals("the primary id", schema.getColumns().get(0).getComment()); + Assertions.assertEquals("", schema.getColumns().get(1).getComment(), + "a doc-less Iceberg field must yield an empty (not null) comment"); + } + + @Test + public void getTableSchemaCopiesTablePropertiesAndLocation() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + Map tableProps = new HashMap<>(); + tableProps.put("write.format.default", "parquet"); + tableProps.put("custom.key", "custom-value"); + ops.table = new FakeIcebergTable( + "t1", idNameSchema(), PartitionSpec.unpartitioned(), + "s3://bucket/db1/t1", tableProps); + + ConnectorTableSchema schema = + metadataWith(ops).getTableSchema(null, new IcebergTableHandle("db1", "t1")); + Map props = schema.getProperties(); + + // WHY: the Iceberg table properties must be copied verbatim onto the schema (the FE relies on + // keys like write.format.default), and the table location must be surfaced under the neutral + // SHOW CREATE render-hint key show.location (rendered as the LOCATION clause, NOT mixed into the + // user PROPERTIES). MUTATION: dropping the table.properties() copy, or not emitting the location + // hint -> red. + Assertions.assertEquals("parquet", props.get("write.format.default")); + Assertions.assertEquals("custom-value", props.get("custom.key")); + Assertions.assertEquals("s3://bucket/db1/t1", props.get(ConnectorTableSchema.SHOW_LOCATION_KEY)); + // Byte-faithful PROPERTIES: legacy iceberg SHOW CREATE dumped only the raw table.properties(), never a + // bare "location" key. MUTATION: reviving the old tableProps.put("location", ...) -> a stray location + // entry leaks into the rendered PROPERTIES -> red. + Assertions.assertFalse(props.containsKey("location"), + "the table location must travel under show.location, not as a bare \"location\" property"); + } + + @Test + public void getTableSchemaUserPartitionColumnsCannotCollideWithReservedKey() { + // A user TBLPROPERTY literally named "partition_columns" (bare, e.g. ALTER TABLE ... SET + // TBLPROPERTIES('partition_columns'='id')) can NEVER be mistaken for the reserved partition marker, + // which is namespaced under __internal. (ConnectorTableSchema.PARTITION_COLUMNS_KEY). On an + // UNPARTITIONED table the connector emits NO reserved key -> fe-core sees no partition marker -> + // the table stays unpartitioned; and the user's bare property flows through unchanged (no silent + // strip). MUTATION: reverting the reserved key to bare "partition_columns" -> the user value would be + // read as the partition CSV -> the assertNull fails -> red. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + Map userProps = new HashMap<>(); + userProps.put("partition_columns", "id"); // a real column name, as a plain user property + ops.table = new FakeIcebergTable( + "t1", idNameSchema(), PartitionSpec.unpartitioned(), "s3://bucket/db1/t1", userProps); + ConnectorTableSchema schema = + metadataWith(ops).getTableSchema(null, new IcebergTableHandle("db1", "t1")); + Assertions.assertNull(schema.getProperties().get(ConnectorTableSchema.PARTITION_COLUMNS_KEY), + "an unpartitioned iceberg table must emit no reserved partition marker"); + Assertions.assertEquals("id", schema.getProperties().get("partition_columns"), + "the user's bare partition_columns property must flow through unchanged (no collision, no strip)"); + } + + @Test + public void getTableSchemaReservedKeyCoexistsWithCollidingUserProperty() { + // A genuinely partitioned table (identity on `name`) whose source properties ALSO carry a colliding + // bare "partition_columns"=id: the reserved key (__internal.partition_columns) carries the CONNECTOR's + // spec-derived value (`name`), and the user's bare property coexists independently — the two live in + // different namespaces and never overwrite each other. MUTATION: bare reserved key -> the two would + // collide -> one assertion fails -> red. + Schema schema = idNameSchema(); + PartitionSpec identitySpec = PartitionSpec.builderFor(schema).identity("name").build(); + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + Map userProps = new HashMap<>(); + userProps.put("partition_columns", "id"); // a plain user property, not the reserved key + ops.table = new FakeIcebergTable("t2", schema, identitySpec, "s3://bucket/db1/t2", userProps); + ConnectorTableSchema out = + metadataWith(ops).getTableSchema(null, new IcebergTableHandle("db1", "t2")); + Assertions.assertEquals("name", out.getProperties().get(ConnectorTableSchema.PARTITION_COLUMNS_KEY), + "the reserved key must carry the spec-derived value"); + Assertions.assertEquals("id", out.getProperties().get("partition_columns"), + "the user's bare property coexists, untouched"); + } + + @Test + public void getTableSchemaEmitsShowPartitionClauseWithTransforms() { + // Unpartitioned: no show.partition-clause (and, byte-faithful, no legacy iceberg.partition-spec). + RecordingIcebergCatalogOps unpartOps = new RecordingIcebergCatalogOps(); + unpartOps.table = new FakeIcebergTable( + "t1", idNameSchema(), PartitionSpec.unpartitioned(), + "s3://bucket/db1/t1", Collections.emptyMap()); + ConnectorTableSchema unpartSchema = + metadataWith(unpartOps).getTableSchema(null, new IcebergTableHandle("db1", "t1")); + + // WHY: an unpartitioned table renders no PARTITION BY (production guard `!spec.isUnpartitioned()`). + // MUTATION: always emitting show.partition-clause -> red. + Assertions.assertNull(unpartSchema.getProperties().get(ConnectorTableSchema.SHOW_PARTITION_CLAUSE_KEY), + "an unpartitioned table must not carry a show.partition-clause property"); + // Byte-faithful PROPERTIES: the raw spec.toString() debug string must NOT leak (legacy never showed it). + Assertions.assertFalse(unpartSchema.getProperties().containsKey("iceberg.partition-spec"), + "the raw iceberg.partition-spec debug key must not be emitted"); + + // Partitioned (identity on name): bare quoted column. + Schema schema = idNameSchema(); + PartitionSpec identitySpec = PartitionSpec.builderFor(schema).identity("name").build(); + RecordingIcebergCatalogOps identityOps = new RecordingIcebergCatalogOps(); + identityOps.table = new FakeIcebergTable( + "t2", schema, identitySpec, "s3://bucket/db1/t2", Collections.emptyMap()); + ConnectorTableSchema identitySchema = + metadataWith(identityOps).getTableSchema(null, new IcebergTableHandle("db1", "t2")); + // WHY: SHOW CREATE TABLE must render the Doris PARTITION BY LIST(...)() clause the legacy + // IcebergExternalTable.getPartitionSpecSql produced; an identity transform renders the bare column. + // MUTATION: dropping the identity branch (or the LIST(...)() wrapper) -> red. + Assertions.assertEquals("PARTITION BY LIST (`name`) ()", + identitySchema.getProperties().get(ConnectorTableSchema.SHOW_PARTITION_CLAUSE_KEY), + "an identity partition must render as the bare quoted column"); + // Also byte-faithful: the partition-columns CSV (functional, consumed by fe-core) is unchanged. + Assertions.assertEquals("name", + identitySchema.getProperties().get(ConnectorTableSchema.PARTITION_COLUMNS_KEY)); + + // Partitioned with a NON-identity transform (bucket): renders the Doris BUCKET(N, col) term. + PartitionSpec bucketSpec = PartitionSpec.builderFor(schema).bucket("id", 8).build(); + RecordingIcebergCatalogOps bucketOps = new RecordingIcebergCatalogOps(); + bucketOps.table = new FakeIcebergTable( + "t3", schema, bucketSpec, "s3://bucket/db1/t3", Collections.emptyMap()); + ConnectorTableSchema bucketSchema = + metadataWith(bucketOps).getTableSchema(null, new IcebergTableHandle("db1", "t3")); + // WHY: the transform terms (bucket[N]/truncate[W]/year/month/day/hour) must map to the matching Doris + // partition function — the connector pre-renders them because the FE plugin path has no live iceberg + // API. MUTATION: emitting the bare column instead of BUCKET(8, `id`), or a wrong arg order -> red. + Assertions.assertEquals("PARTITION BY LIST (BUCKET(8, `id`)) ()", + bucketSchema.getProperties().get(ConnectorTableSchema.SHOW_PARTITION_CLAUSE_KEY), + "a bucket transform must render as BUCKET(N, `col`)"); + + // truncate transform -> TRUNCATE(W, `col`). MUTATION: a wrong width/arg order/function name -> red. + PartitionSpec truncateSpec = PartitionSpec.builderFor(schema).truncate("name", 4).build(); + RecordingIcebergCatalogOps truncateOps = new RecordingIcebergCatalogOps(); + truncateOps.table = new FakeIcebergTable( + "t4", schema, truncateSpec, "s3://bucket/db1/t4", Collections.emptyMap()); + ConnectorTableSchema truncateSchema = + metadataWith(truncateOps).getTableSchema(null, new IcebergTableHandle("db1", "t4")); + Assertions.assertEquals("PARTITION BY LIST (TRUNCATE(4, `name`)) ()", + truncateSchema.getProperties().get(ConnectorTableSchema.SHOW_PARTITION_CLAUSE_KEY), + "a truncate transform must render as TRUNCATE(W, `col`)"); + + // a temporal transform (day) on a timestamp column -> DAY(`col`). Covers the temporal branch family + // (year/month/day/hour share the same rendering shape). MUTATION: mapping day to the wrong function + // name (e.g. DAYS) -> red. + Schema tsSchema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "ts", Types.TimestampType.withoutZone())); + PartitionSpec daySpec = PartitionSpec.builderFor(tsSchema).day("ts").build(); + RecordingIcebergCatalogOps dayOps = new RecordingIcebergCatalogOps(); + dayOps.table = new FakeIcebergTable( + "t5", tsSchema, daySpec, "s3://bucket/db1/t5", Collections.emptyMap()); + ConnectorTableSchema daySchema = + metadataWith(dayOps).getTableSchema(null, new IcebergTableHandle("db1", "t5")); + Assertions.assertEquals("PARTITION BY LIST (DAY(`ts`)) ()", + daySchema.getProperties().get(ConnectorTableSchema.SHOW_PARTITION_CLAUSE_KEY), + "a day transform must render as DAY(`col`)"); + } + + @Test + public void getTableSchemaEmitsShowSortClauseWhenSorted() { + Schema schema = idNameSchema(); + + // Unsorted (FakeIcebergTable.sortOrder() defaults to null -> render path treats as unsorted). + RecordingIcebergCatalogOps unsortedOps = new RecordingIcebergCatalogOps(); + unsortedOps.table = new FakeIcebergTable( + "t1", schema, PartitionSpec.unpartitioned(), "s3://bucket/db1/t1", Collections.emptyMap()); + ConnectorTableSchema unsortedSchema = + metadataWith(unsortedOps).getTableSchema(null, new IcebergTableHandle("db1", "t1")); + // WHY: an unsorted table renders no ORDER BY. MUTATION: always emitting show.sort-clause -> red. + Assertions.assertNull(unsortedSchema.getProperties().get(ConnectorTableSchema.SHOW_SORT_CLAUSE_KEY), + "an unsorted table must not carry a show.sort-clause property"); + + // Sorted DESC on name: the connector pre-renders the ORDER BY clause (legacy getSortOrderSql + + // SortFieldInfo.toSql: `col` ASC|DESC NULLS FIRST|LAST). desc() defaults to NULLS LAST in iceberg. + SortOrder sortOrder = SortOrder.builderFor(schema).desc("name").build(); + FakeIcebergTable sortedTable = new FakeIcebergTable( + "t2", schema, PartitionSpec.unpartitioned(), "s3://bucket/db1/t2", Collections.emptyMap()); + sortedTable.setSortOrder(sortOrder); + RecordingIcebergCatalogOps sortedOps = new RecordingIcebergCatalogOps(); + sortedOps.table = sortedTable; + ConnectorTableSchema sortedSchema = + metadataWith(sortedOps).getTableSchema(null, new IcebergTableHandle("db1", "t2")); + // WHY: SHOW CREATE TABLE must render the ORDER BY clause with direction + null order. MUTATION: + // dropping the clause, or swapping ASC/DESC or NULLS FIRST/LAST -> red. + Assertions.assertEquals("ORDER BY (`name` DESC NULLS LAST)", + sortedSchema.getProperties().get(ConnectorTableSchema.SHOW_SORT_CLAUSE_KEY), + "a sorted table must render the ORDER BY clause with direction and null order"); + + // Sorted ASC on id: ASC + NULLS FIRST (iceberg asc() default null order). Positively renders the + // ASC and NULLS FIRST output arms (the DESC case alone cannot catch a hardcode-to-DESC / + // hardcode-to-NULLS-LAST mutation). MUTATION: hardcoding direction to DESC or null order to LAST -> red. + SortOrder ascOrder = SortOrder.builderFor(schema).asc("id").build(); + FakeIcebergTable ascTable = new FakeIcebergTable( + "t3", schema, PartitionSpec.unpartitioned(), "s3://bucket/db1/t3", Collections.emptyMap()); + ascTable.setSortOrder(ascOrder); + RecordingIcebergCatalogOps ascOps = new RecordingIcebergCatalogOps(); + ascOps.table = ascTable; + ConnectorTableSchema ascSchema = + metadataWith(ascOps).getTableSchema(null, new IcebergTableHandle("db1", "t3")); + Assertions.assertEquals("ORDER BY (`id` ASC NULLS FIRST)", + ascSchema.getProperties().get(ConnectorTableSchema.SHOW_SORT_CLAUSE_KEY), + "an ascending sort must render ASC NULLS FIRST"); + } + + @Test + public void getDatabaseSurfacesNamespaceLocation() { + // WHY: SHOW CREATE DATABASE renders LOCATION from the connector's getDatabase SPI (Trino-aligned + // properties-map, the "location" key); the connector reads the namespace location through the seam, + // auth-wrapped like the sibling reads. MUTATION: not surfacing the location, or reading the wrong key + // -> red. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.namespaceLocation = Optional.of("s3://bucket/db1"); + ConnectorDatabaseMetadata metadata = metadataWith(ops).getDatabase(null, "db1"); + Assertions.assertEquals("s3://bucket/db1", + metadata.getProperties().get(ConnectorDatabaseMetadata.LOCATION_PROPERTY), + "getDatabase must surface the namespace location under the location property"); + Assertions.assertTrue(ops.log.contains("loadNamespaceLocation:db1"), + "getDatabase must read the namespace location through the seam"); + + // No namespace location -> no location key (SHOW CREATE DATABASE then renders no LOCATION clause). + RecordingIcebergCatalogOps emptyOps = new RecordingIcebergCatalogOps(); + ConnectorDatabaseMetadata emptyMetadata = metadataWith(emptyOps).getDatabase(null, "db1"); + Assertions.assertFalse( + emptyMetadata.getProperties().containsKey(ConnectorDatabaseMetadata.LOCATION_PROPERTY), + "a location-less namespace must not produce a location property"); + } + + @Test + public void getTableSchemaEmitsPartitionColumnsForIdentityPartition() { + // Unpartitioned: no partition_columns key. + RecordingIcebergCatalogOps unpartOps = new RecordingIcebergCatalogOps(); + unpartOps.table = new FakeIcebergTable( + "t1", idNameSchema(), PartitionSpec.unpartitioned(), + "s3://bucket/db1/t1", Collections.emptyMap()); + ConnectorTableSchema unpartSchema = + metadataWith(unpartOps).getTableSchema(null, new IcebergTableHandle("db1", "t1")); + + // WHY: post-cutover, PluginDrivenExternalTable derives its partition columns SOLELY from the + // generic "partition_columns" CSV property (toSchemaCacheValue), the same key MaxCompute/paimon + // emit. An unpartitioned table must NOT emit it, or isPartitionedTable() would be wrong post-flip. + // MUTATION: always emitting partition_columns -> red. + Assertions.assertNull(unpartSchema.getProperties().get(ConnectorTableSchema.PARTITION_COLUMNS_KEY), + "an unpartitioned table must not carry a partition-columns property"); + + // Partitioned (identity on name): the source column name is surfaced under partition_columns. + Schema schema = idNameSchema(); + PartitionSpec partSpec = PartitionSpec.builderFor(schema).identity("name").build(); + RecordingIcebergCatalogOps partOps = new RecordingIcebergCatalogOps(); + partOps.table = new FakeIcebergTable( + "t2", schema, partSpec, "s3://bucket/db1/t2", Collections.emptyMap()); + ConnectorTableSchema partSchema = + metadataWith(partOps).getTableSchema(null, new IcebergTableHandle("db1", "t2")); + + // WHY: post-flip getPartitionColumns() reads this CSV and must report the SAME partition columns + // legacy IcebergExternalTable did (IcebergUtils.loadTableSchemaCacheValue: spec source columns). + // MUTATION: dropping the partition_columns emission -> the key is absent -> red. + Assertions.assertEquals("name", + partSchema.getProperties().get(ConnectorTableSchema.PARTITION_COLUMNS_KEY), + "a partitioned table must surface its partition source columns as a CSV"); + } + + @Test + public void getTableSchemaEmitsNonIdentityPartitionSourceColumns() { + // bucket(id) is a NON-identity transform. Legacy IcebergUtils.loadTableSchemaCacheValue walks the + // CURRENT spec with NO identity filter, collecting the SOURCE column ("id"). The connector must + // replicate THAT (not the identity-only IcebergPartitionUtils.getIdentityPartitionColumns helper), + // or post-flip a bucket-partitioned table would report no partition columns (parity break). + Schema schema = idNameSchema(); + PartitionSpec partSpec = PartitionSpec.builderFor(schema).bucket("id", 4).build(); + RecordingIcebergCatalogOps partOps = new RecordingIcebergCatalogOps(); + partOps.table = new FakeIcebergTable( + "t3", schema, partSpec, "s3://bucket/db1/t3", Collections.emptyMap()); + ConnectorTableSchema partSchema = + metadataWith(partOps).getTableSchema(null, new IcebergTableHandle("db1", "t3")); + + // WHY: a bucket/truncate/day transform still has a source column that legacy treats as a partition + // column. MUTATION: filtering to identity transforms -> "id" absent -> red. + Assertions.assertEquals("id", + partSchema.getProperties().get(ConnectorTableSchema.PARTITION_COLUMNS_KEY), + "a non-identity transform must still surface its source column as a partition column"); + } + + @Test + public void getTableSchemaDefaultsFormatVersionBelowThreeWhenAbsent() { + // WHY: getFormatVersion (which drives the v3 row-lineage gate) defaults to 2 when the table carries + // no `format-version` property, so an absent-format-version table appends NO row-lineage columns. The + // previously-emitted iceberg.format-version property was removed (it was never read by fe-core and + // would leak into the rendered SHOW CREATE PROPERTIES), so the default is now pinned via its real + // consequence: only the data columns. MUTATION: defaulting to >= 3 (or reviving the spec-id quirk that + // yielded a higher version) -> row-lineage columns appear -> red. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = new FakeIcebergTable( + "t1", idNameSchema(), PartitionSpec.unpartitioned(), + "s3://bucket/db1/t1", Collections.emptyMap()); + + ConnectorTableSchema schema = + metadataWith(ops).getTableSchema(null, new IcebergTableHandle("db1", "t1")); + + Assertions.assertEquals(2, schema.getColumns().size(), + "an absent format-version must default below 3 (no row-lineage columns)"); + // And byte-faithful: the internal iceberg.format-version key must not leak into the rendered PROPERTIES + // (legacy iceberg SHOW CREATE dumped only the raw table.properties()). + Assertions.assertFalse(schema.getProperties().containsKey("iceberg.format-version"), + "the internal iceberg.format-version key must not leak into the rendered PROPERTIES"); + } + + @Test + public void getTableSchemaAppendsV3RowLineageColumnsWhenFormatVersionAtLeast3() { + // WHY (③-infra part2): legacy IcebergExternalTable.getFullSchema unconditionally calls + // IcebergUtils.appendRowLineageColumnsForV3, which appends the two hidden row-lineage columns + // (_row_id / _last_updated_sequence_number, BIGINT, reserved field ids 2147483540 / 2147483539, + // invisible) for format-version >= 3 tables. Post-cutover the connector owns the table schema, so it + // must declare them itself through the schema SPI — invisible() + the reserved uniqueId carried + // across the boundary, re-applied by ConnectorColumnConverter and round-tripped via the schema cache. + // MUTATION: dropping the append, the >= 3 gate, .invisible(), .withUniqueId(), or swapping the two + // field ids -> red. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + Map props = new HashMap<>(); + props.put("format-version", "3"); + ops.table = new FakeIcebergTable( + "t1", idNameSchema(), PartitionSpec.unpartitioned(), + "s3://bucket/db1/t1", props); + + List cols = + metadataWith(ops).getTableSchema(null, new IcebergTableHandle("db1", "t1")).getColumns(); + + // The two data columns (id, name) come first, then the two appended lineage columns IN ORDER + // (legacy appends _row_id before _last_updated_sequence_number, after the data columns). + Assertions.assertEquals(4, cols.size(), + "format-version >= 3 must append the two row-lineage columns after the data columns"); + Assertions.assertEquals("id", cols.get(0).getName()); + Assertions.assertEquals("name", cols.get(1).getName()); + + ConnectorColumn rowId = cols.get(2); + Assertions.assertEquals("_row_id", rowId.getName()); + Assertions.assertEquals("BIGINT", rowId.getType().getTypeName(), "_row_id is BIGINT"); + Assertions.assertFalse(rowId.isVisible(), "_row_id must be hidden"); + Assertions.assertEquals(2147483540, rowId.getUniqueId(), "_row_id reserved field id"); + Assertions.assertTrue(rowId.isReservedPassthrough(), + "_row_id must be marked reservedPassthrough so fe-core recognizes it generically"); + Assertions.assertTrue(rowId.isNullable(), "_row_id is nullable (legacy isAllowNull=true)"); + Assertions.assertFalse(rowId.isKey(), "_row_id is not a key (legacy isKey=false)"); + + ConnectorColumn seq = cols.get(3); + Assertions.assertEquals("_last_updated_sequence_number", seq.getName()); + Assertions.assertEquals("BIGINT", seq.getType().getTypeName(), + "_last_updated_sequence_number is BIGINT"); + Assertions.assertFalse(seq.isVisible(), "_last_updated_sequence_number must be hidden"); + Assertions.assertEquals(2147483539, seq.getUniqueId(), + "_last_updated_sequence_number reserved field id"); + Assertions.assertTrue(seq.isReservedPassthrough(), + "_last_updated_sequence_number must be marked reservedPassthrough"); + Assertions.assertTrue(seq.isNullable()); + Assertions.assertFalse(seq.isKey()); + } + + @Test + public void getTableSchemaAppendsV3RowLineageColumnsForFormatVersionAbove3() { + // WHY (③-infra part2): the gate is ">= 3" (inclusive lower bound, unbounded above) — every v3+ table + // gets the row-lineage columns, mirroring legacy IcebergUtils.appendRowLineageColumnsForV3's + // "< ICEBERG_ROW_LINEAGE_MIN_VERSION ? return : append". A format-version=4 table must still append. + // MUTATION: tightening the gate to "== 3" (or a defensive "> 3" miswrite) would omit v4 -> red here + // (the v3 case alone cannot catch a "== 3" narrowing). + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + Map props = new HashMap<>(); + props.put("format-version", "4"); + ops.table = new FakeIcebergTable( + "t1", idNameSchema(), PartitionSpec.unpartitioned(), + "s3://bucket/db1/t1", props); + + List cols = + metadataWith(ops).getTableSchema(null, new IcebergTableHandle("db1", "t1")).getColumns(); + + Assertions.assertEquals(4, cols.size(), + "format-version > 3 must also append the two row-lineage columns (gate is an inclusive >= 3)"); + Assertions.assertEquals("_row_id", cols.get(2).getName()); + Assertions.assertEquals("_last_updated_sequence_number", cols.get(3).getName()); + } + + @Test + public void getTableSchemaOmitsV3RowLineageColumnsBelowFormatVersion3() { + // WHY (③-infra part2): appendRowLineageColumnsForV3 is a no-op for format-version < 3 (the + // row-lineage columns exist only in v3+). A v2 table must surface ONLY its data columns — no + // _row_id / _last_updated_sequence_number. This also guards the natural exclusion of system tables, + // which report format-version 2 (BaseMetadataTable.properties() is empty), matching legacy which + // only injects lineage for data tables (IcebergSysExternalTable never does). MUTATION: dropping the + // >= 3 gate (always appending) -> the v2 schema gains lineage columns -> red. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + Map props = new HashMap<>(); + props.put("format-version", "2"); + ops.table = new FakeIcebergTable( + "t1", idNameSchema(), PartitionSpec.unpartitioned(), + "s3://bucket/db1/t1", props); + + List cols = + metadataWith(ops).getTableSchema(null, new IcebergTableHandle("db1", "t1")).getColumns(); + + Assertions.assertEquals(2, cols.size(), + "format-version < 3 must NOT append row-lineage columns"); + Assertions.assertTrue(cols.stream().noneMatch(c -> c.getName().equals("_row_id") + || c.getName().equals("_last_updated_sequence_number")), + "no row-lineage columns below format-version 3"); + } + + @Test + public void getTableSchemaPreservesColumnNameCase() { + // WHY (#65094 read-path alignment): post-cutover IcebergConnectorMetadata.parseSchema surfaces each + // column name VERBATIM (field.name(), no toLowerCase), so a mixed-case Iceberg field keeps its case as + // the Doris column name (byte-matching the case-preserving scan slots). The SPI bridge only layers user + // identifier mapping on top. MUTATION: re-lowercasing field.name() -> "id" -> red. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + Schema upperSchema = new Schema( + Types.NestedField.required(1, "ID", Types.IntegerType.get()), + Types.NestedField.optional(2, "Mixed_Name", Types.StringType.get())); + ops.table = new FakeIcebergTable( + "t1", upperSchema, PartitionSpec.unpartitioned(), + "s3://bucket/db1/t1", Collections.emptyMap()); + + ConnectorTableSchema schema = + metadataWith(ops).getTableSchema(null, new IcebergTableHandle("db1", "t1")); + + Assertions.assertEquals("ID", schema.getColumns().get(0).getName(), + "an uppercase Iceberg field name must be preserved (post-#65094, no toLowerCase)"); + Assertions.assertEquals("Mixed_Name", schema.getColumns().get(1).getName(), + "a mixed-case Iceberg field name must keep its case"); + } + + @Test + public void getTableSchemaMarksWithTimeZoneFromSourceTypeIndependentOfMappingFlag() { + // WHY: legacy IcebergUtils.parseSchema sets setWithTZExtraInfo() when the SOURCE field is a + // TIMESTAMP with shouldAdjustToUTC()==true, REGARDLESS of the enable.mapping.timestamp_tz flag + // (the marker is keyed on the source type root, not the mapped Doris type). So a with-zone + // timestamp carries the WITH_TIMEZONE marker even when mapped to plain DATETIMEV2 (flag off), + // while a without-zone timestamp never does. MUTATION: gating the marker on the mapping flag, or + // never setting it -> red. + Schema tsSchema = new Schema( + Types.NestedField.optional(1, "ts_tz", Types.TimestampType.withZone()), + Types.NestedField.optional(2, "ts_ntz", Types.TimestampType.withoutZone()), + Types.NestedField.optional(3, "id", Types.IntegerType.get())); + + // Mapping flag OFF (default): with-zone ts maps to DATETIMEV2 but STILL carries the marker. + RecordingIcebergCatalogOps offOps = new RecordingIcebergCatalogOps(); + offOps.table = new FakeIcebergTable( + "t1", tsSchema, PartitionSpec.unpartitioned(), "s3://b/t1", Collections.emptyMap()); + List offCols = + metadataWith(offOps).getTableSchema(null, new IcebergTableHandle("db1", "t1")).getColumns(); + Assertions.assertTrue(offCols.get(0).isWithTimeZone(), + "with-zone timestamp must carry the WITH_TIMEZONE marker even with mapping flag OFF"); + Assertions.assertEquals("DATETIMEV2", offCols.get(0).getType().getTypeName(), + "with mapping flag off the with-zone timestamp is still mapped to DATETIMEV2"); + Assertions.assertFalse(offCols.get(1).isWithTimeZone(), + "a without-zone timestamp must NOT carry the WITH_TIMEZONE marker"); + Assertions.assertFalse(offCols.get(2).isWithTimeZone(), + "a non-timestamp column must NOT carry the WITH_TIMEZONE marker"); + + // Mapping flag ON: with-zone ts maps to TIMESTAMPTZ and also carries the marker. + RecordingIcebergCatalogOps onOps = new RecordingIcebergCatalogOps(); + onOps.table = new FakeIcebergTable( + "t2", tsSchema, PartitionSpec.unpartitioned(), "s3://b/t2", Collections.emptyMap()); + Map onProps = new HashMap<>(); + onProps.put("enable.mapping.timestamp_tz", "true"); + List onCols = metadataWith(onOps, onProps) + .getTableSchema(null, new IcebergTableHandle("db1", "t2")).getColumns(); + Assertions.assertTrue(onCols.get(0).isWithTimeZone(), + "with-zone timestamp must carry the WITH_TIMEZONE marker with mapping flag ON too"); + Assertions.assertEquals("TIMESTAMPTZ", onCols.get(0).getType().getTypeName(), + "with mapping flag on the with-zone timestamp maps to TIMESTAMPTZ"); + } + + @Test + public void getTableSchemaOmitsLocationWhenNull() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = new FakeIcebergTable( + "t1", idNameSchema(), PartitionSpec.unpartitioned(), + null, Collections.emptyMap()); + + ConnectorTableSchema schema = + metadataWith(ops).getTableSchema(null, new IcebergTableHandle("db1", "t1")); + + // WHY: when the table reports no location, the production code guards with `if (location != + // null)`, so the show.location render-hint key must be ABSENT rather than mapped to null. MUTATION: + // removing the null guard -> a null-valued show.location entry -> red. + Assertions.assertFalse(schema.getProperties().containsKey(ConnectorTableSchema.SHOW_LOCATION_KEY), + "a null table location must not produce a show.location property"); + } + + // --------------------------------------------------------------------- + // type-mapping toggles — enable.mapping.varbinary / enable.mapping.timestamp_tz + // (dotted keys matching CatalogProperty.ENABLE_MAPPING_* — the spelling real catalog maps carry) + // --------------------------------------------------------------------- + + @Test + public void getTableSchemaHonorsVarbinaryAndTimestampTzMappingFlags() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + Schema binTsSchema = new Schema( + Types.NestedField.optional(1, "b", Types.BinaryType.get()), + Types.NestedField.optional(2, "ts_tz", Types.TimestampType.withZone())); + ops.table = new FakeIcebergTable( + "t1", binTsSchema, PartitionSpec.unpartitioned(), + "s3://bucket/db1/t1", Collections.emptyMap()); + + // Feed the LITERAL dotted keys that real catalog maps carry (CatalogProperty.ENABLE_MAPPING_*), + // NOT the connector constants — so this test pins the exact wire spelling and goes red if the + // connector ever reverts to the underscore key (which would silently read default-false). + Map props = new HashMap<>(); + props.put("enable.mapping.varbinary", "true"); + props.put("enable.mapping.timestamp_tz", "true"); + + ConnectorTableSchema schema = + metadataWith(ops, props).getTableSchema(null, new IcebergTableHandle("db1", "t1")); + + // WHY: with the mapping flags on, an Iceberg BINARY column maps to VARBINARY and a + // TIMESTAMP-with-zone column maps to TIMESTAMPTZ (via IcebergTypeMapping). The metadata layer + // must read these flags from the catalog props using the DOTTED key spelling that real catalog + // maps carry (CatalogProperty.ENABLE_MAPPING_*) and thread them to the type mapper. MUTATION: + // reading the underscore key, or not reading the flags (always default false) -> STRING / + // DATETIMEV2 -> red. + Assertions.assertEquals("VARBINARY", schema.getColumns().get(0).getType().getTypeName(), + "enable.mapping.varbinary=true must map Iceberg BINARY to VARBINARY"); + Assertions.assertEquals("TIMESTAMPTZ", schema.getColumns().get(1).getType().getTypeName(), + "enable.mapping.timestamp_tz=true must map Iceberg TIMESTAMP-with-zone to TIMESTAMPTZ"); + } + + @Test + public void getTableSchemaDefaultsMappingFlagsOff() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + Schema binTsSchema = new Schema( + Types.NestedField.optional(1, "b", Types.BinaryType.get()), + Types.NestedField.optional(2, "ts_tz", Types.TimestampType.withZone())); + ops.table = new FakeIcebergTable( + "t1", binTsSchema, PartitionSpec.unpartitioned(), + "s3://bucket/db1/t1", Collections.emptyMap()); + + // No mapping keys set: the default (mapping off) behavior. + ConnectorTableSchema schema = + metadataWith(ops).getTableSchema(null, new IcebergTableHandle("db1", "t1")); + + // WHY: with the toggles absent, BINARY must map to STRING and TIMESTAMP-with-zone to DATETIMEV2 + // (default false). This guards against a fix that accidentally flips the defaults on. MUTATION: + // defaulting either flag to true -> VARBINARY / TIMESTAMPTZ -> red. + Assertions.assertEquals("STRING", schema.getColumns().get(0).getType().getTypeName(), + "absent enable.mapping.varbinary must leave Iceberg BINARY as STRING (default off)"); + Assertions.assertEquals("DATETIMEV2", schema.getColumns().get(1).getType().getTypeName(), + "absent enable.mapping.timestamp_tz must leave Iceberg TIMESTAMP-with-zone as DATETIMEV2"); + } + + // --------------------------------------------------------------------- + // auth wrapping — every remote read runs inside ConnectorContext.executeAuthenticated + // (legacy IcebergMetadataOps + the paimon mirror wrap every list/exists/load call) + // --------------------------------------------------------------------- + + @Test + public void everyRemoteReadRunsInsideExecuteAuthenticated() { + // WHY: legacy IcebergMetadataOps wraps EVERY remote read (list/exists/load) in + // executionAuthenticator.execute so the FE-injected Kerberos UGI applies; the paimon mirror does + // the same. Each of the 8 read entry points must wrap exactly one executeAuthenticated call. + // MUTATION: calling the seam directly (no wrap) -> authCount stays 0 -> red. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.databases = Collections.singletonList("db1"); + ops.tables = Collections.singletonList("t1"); + ops.databaseExists = true; + ops.tableExists = true; + ops.table = new FakeIcebergTable( + "t1", idNameSchema(), PartitionSpec.unpartitioned(), "s3://b/t1", Collections.emptyMap()); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergConnectorMetadata md = metadataWith(ops, ctx); + + md.listDatabaseNames(null); + md.databaseExists(null, "db1"); + md.listTableNames(null, "db1"); + md.listViewNames(null, "db1"); + md.getTableHandle(null, "db1", "t1"); + md.viewExists(null, "db1", "v1"); + md.getTableSchema(null, new IcebergTableHandle("db1", "t1")); + md.getDatabase(null, "db1"); + + Assertions.assertEquals(8, ctx.authCount, + "each of the 8 remote reads must wrap exactly one executeAuthenticated call"); + } + + @Test + public void readsSitInsideAuthSoFailedAuthSkipsTheSeamCall() { + // WHY: with failAuth set, executeAuthenticated throws WITHOUT invoking the task. If a seam call sat + // OUTSIDE the wrap it would still run; it must NOT — proving the remote call is INSIDE the + // authenticator. Each read must surface the failure as a RuntimeException (legacy parity). + // MUTATION: a seam call placed outside the wrap -> ops.log records it -> red. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + IcebergConnectorMetadata md = metadataWith(ops, ctx); + + Assertions.assertThrows(RuntimeException.class, () -> md.listDatabaseNames(null)); + Assertions.assertThrows(RuntimeException.class, () -> md.databaseExists(null, "db1")); + Assertions.assertThrows(RuntimeException.class, () -> md.listTableNames(null, "db1")); + Assertions.assertThrows(RuntimeException.class, () -> md.listViewNames(null, "db1")); + Assertions.assertThrows(RuntimeException.class, () -> md.getTableHandle(null, "db1", "t1")); + Assertions.assertThrows(RuntimeException.class, () -> md.viewExists(null, "db1", "v1")); + Assertions.assertThrows(RuntimeException.class, + () -> md.getTableSchema(null, new IcebergTableHandle("db1", "t1"))); + Assertions.assertThrows(RuntimeException.class, () -> md.getDatabase(null, "db1")); + + Assertions.assertTrue(ops.log.isEmpty(), + "no seam call may run when executeAuthenticated fails before invoking the task"); + } + + // --------------------------------------------------------------------- + // getColumnHandles — pruned-column source for the T06 field-id dict + // --------------------------------------------------------------------- + + @Test + public void getColumnHandlesKeysByCasePreservedNameAndCarriesIcebergFieldId() { + // The generic PluginDrivenScanNode looks each query slot up here by name to build the pruned column + // list the T06 field-id dictionary keys its -1 entry off. Post-#65094 the map is keyed by the + // CASE-PRESERVED name (== the Doris slot name from parseSchema, which now keeps the iceberg case) and + // the handle MUST carry the iceberg field id (the permanent rename-safe join key). MUTATION: re-lowercase + // the key -> the case-preserving slot lookup misses -> empty columns -> dict falls back to all-fields. + // MUTATION: carry the ordinal not the field id -> the dict's field ids are wrong -> BE field-id match fails. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + Schema mixed = new Schema( + Types.NestedField.required(7, "ID", Types.IntegerType.get()), + Types.NestedField.optional(9, "Name", Types.StringType.get())); + ops.table = new FakeIcebergTable( + "t1", mixed, PartitionSpec.unpartitioned(), "s3://bucket/db1/t1", Collections.emptyMap()); + + Map handles = + metadataWith(ops).getColumnHandles(null, new IcebergTableHandle("db1", "t1")); + + Assertions.assertEquals(2, handles.size()); + Assertions.assertTrue(handles.containsKey("ID")); + Assertions.assertTrue(handles.containsKey("Name")); + Assertions.assertFalse(handles.containsKey("id"), "post-#65094 the handle key keeps the iceberg case"); + Assertions.assertEquals(7, ((IcebergColumnHandle) handles.get("ID")).getFieldId()); + Assertions.assertEquals(9, ((IcebergColumnHandle) handles.get("Name")).getFieldId()); + // The remote load must go through the seam (auth-wrapped), mirroring getTableSchema. + Assertions.assertTrue(ops.log.contains("loadTable:db1.t1"), + "getColumnHandles must load the table via the seam using the handle coordinates"); + } + + // --------------------------------------------------------------------- + // P6.3-T03: write transaction wiring (gate-closed / dormant) + // --------------------------------------------------------------------- + + @Test + public void beginTransactionReturnsIcebergConnectorTransactionWithEngineId() { + // beginTransaction opens a connector transaction whose id is the engine-allocated id (so the + // generic PluginDrivenTransactionManager registers it in both the per-manager map and + // GlobalExternalTransactionInfoMgr — the BE->FE report path finds the txn by this id). Dormant + // until the P6.6 cutover; the SDK transaction is opened later by the write plan via beginWrite. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + org.apache.doris.connector.api.handle.ConnectorTransaction txn = + metadataWith(ops).beginTransaction(new TxnIdSession(31337L)); + + Assertions.assertTrue(txn instanceof IcebergConnectorTransaction); + Assertions.assertEquals(31337L, txn.getTransactionId()); + Assertions.assertEquals("ICEBERG", txn.profileLabel()); + // No remote call at begin time (the table is loaded lazily in beginWrite). + Assertions.assertTrue(ops.log.isEmpty(), "beginTransaction must not touch the catalog seam"); + } + + /** Minimal {@link org.apache.doris.connector.api.ConnectorSession} that only hands out a txn id. */ + private static final class TxnIdSession implements org.apache.doris.connector.api.ConnectorSession { + private final long txnId; + + TxnIdSession(long txnId) { + this.txnId = txnId; + } + + @Override + public long allocateTransactionId() { + return txnId; + } + + @Override + public String getQueryId() { + return "q"; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public long getCatalogId() { + return 0L; + } + + @Override + public String getCatalogName() { + return "test"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorPluginAuthenticatorTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorPluginAuthenticatorTest.java new file mode 100644 index 00000000000000..c7eff9df8df061 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorPluginAuthenticatorTest.java @@ -0,0 +1,116 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.kerberos.HadoopAuthenticator; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Unit tests for {@link IcebergConnector#buildPluginAuthenticator(Map, Map)} — the connector-owned + * plugin-side Kerberos authenticator resolution. + * + *

    The load-bearing case is HMS-metastore Kerberos with simple (non-Kerberos) storage + * (e.g. a Kerberized Hive Metastore over S3). Before the fe-core iceberg property cluster is deleted + * that login was served fe-core-side by + * {@code IcebergHMSMetaStoreProperties -> HadoopExecutionAuthenticator(hmsBaseProperties.getHmsAuthenticator())} + * and delivered via {@code DefaultConnectorContext}; the connector must own it once that cluster is gone. + * These tests pin that the connector builds a plugin authenticator from the HMS client principal/keytab + * facts, and does NOT build one when the metastore is simple-auth (which would silently force SIMPLE auth). + * + *

    The actual keytab login is lazy (on first {@code doAs}), so these assertions never touch a KDC. + */ +public class IcebergConnectorPluginAuthenticatorTest { + + private static Map props(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + /** Storage-level Kerberos (raw hadoop.security.authentication) — unchanged prior behavior. */ + @Test + public void storageKerberosBuildsAuthenticator() { + HadoopAuthenticator auth = IcebergConnector.buildPluginAuthenticator( + props("iceberg.catalog.type", "hadoop", + "hadoop.security.authentication", "kerberos", + "hadoop.kerberos.principal", "doris@EXAMPLE.COM", + "hadoop.kerberos.keytab", "/etc/security/doris.keytab"), + new HashMap<>()); + Assertions.assertNotNull(auth, "storage kerberos must yield a plugin authenticator"); + } + + /** + * THE CUT-1 GAP: a Kerberized HMS whose data storage is simple. Storage auth is unset, so the storage + * gate is off; the connector must fall back to the HMS client-principal/keytab facts and still build a + * plugin authenticator (mirroring the fe-core HMS authenticator it replaces). + */ + @Test + public void hmsMetastoreKerberosWithSimpleStorageBuildsAuthenticator() { + HadoopAuthenticator auth = IcebergConnector.buildPluginAuthenticator( + props("iceberg.catalog.type", "hms", + "hive.metastore.uris", "thrift://hms:9083", + "hive.metastore.authentication.type", "kerberos", + "hive.metastore.client.principal", "doris@EXAMPLE.COM", + "hive.metastore.client.keytab", "/etc/security/doris.keytab"), + new HashMap<>()); + Assertions.assertNotNull(auth, + "HMS-metastore kerberos with simple storage must yield a plugin authenticator"); + } + + /** A simple-auth HMS builds no authenticator (a spurious one would force needless SIMPLE-vs-Kerberos churn). */ + @Test + public void hmsSimpleAuthReturnsNull() { + HadoopAuthenticator auth = IcebergConnector.buildPluginAuthenticator( + props("iceberg.catalog.type", "hms", + "hive.metastore.uris", "thrift://hms:9083", + "hive.metastore.authentication.type", "simple"), + new HashMap<>()); + Assertions.assertNull(auth, "simple-auth HMS must not build a plugin authenticator"); + } + + /** A non-HMS flavor with no storage Kerberos builds no authenticator. */ + @Test + public void nonHmsFlavorWithoutStorageKerberosReturnsNull() { + HadoopAuthenticator auth = IcebergConnector.buildPluginAuthenticator( + props("iceberg.catalog.type", "rest", + "uri", "http://rest:8181"), + new HashMap<>()); + Assertions.assertNull(auth, "rest flavor without storage kerberos must not build an authenticator"); + } + + /** + * HMS declares kerberos auth-type but the client principal/keytab are blank — the {@code hasCredentials} + * guard must reject it (an authenticator with no login pair would fail obscurely at first doAs). + */ + @Test + public void hmsKerberosWithBlankCredsReturnsNull() { + HadoopAuthenticator auth = IcebergConnector.buildPluginAuthenticator( + props("iceberg.catalog.type", "hms", + "hive.metastore.uris", "thrift://hms:9083", + "hive.metastore.authentication.type", "kerberos"), + new HashMap<>()); + Assertions.assertNull(auth, "kerberos HMS without a client principal/keytab pair must not build one"); + } +} 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 new file mode 100644 index 00000000000000..b576b0c2813a0f --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTest.java @@ -0,0 +1,370 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.connector.api.ConnectorContractValidator; +import org.apache.doris.connector.api.ConnectorSession; +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.filesystem.properties.S3CompatibleFileSystemProperties; +import org.apache.doris.filesystem.properties.StorageProperties; + +import org.apache.iceberg.Schema; +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; + +/** + * Connector-level tests for {@link IcebergConnector} that can run offline (no live catalog / no AWS call). The + * live s3tables catalog construction (hand-built {@code S3TablesClient} + {@code S3TablesCatalog.initialize}) is + * exercised only at the P6.6 docker plugin-zip gate; here we lock the FAIL-LOUD routing invariants that guard it. + * No Mockito — the {@link RecordingConnectorContext} fail-loud fake is used. + */ +public class IcebergConnectorTest { + + @Test + public void s3TablesWithoutStorageOrRegionFailsLoud() { + // WHY: a bound S3-compatible storage is NO LONGER required (M6): an EC2 instance-profile s3tables catalog + // carries only region + warehouse ARN and uses the SDK DefaultCredentialsProvider chain, mirroring legacy + // IcebergS3TablesMetaStoreProperties. The sole hard requirement is a REGION. With NEITHER a bound storage + // NOR a region alias in the props, the connector must still fail loud naming the missing region, before + // any AWS call. MUTATION: reinstating the chosenS3-presence throw -> the storage-worded message (which + // does not contain "requires a region") -> red. + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergConnector connector = new IcebergConnector( + Map.of("iceberg.catalog.type", "s3tables", + "warehouse", "arn:aws:s3tables:us-east-1:1:bucket/b"), + ctx); + DorisConnectorException ex = + Assertions.assertThrows(DorisConnectorException.class, () -> connector.getMetadata(null)); + Assertions.assertTrue(ex.getMessage().contains("requires a region"), + "expected a fail-loud message naming the missing region, got: " + ex.getMessage()); + } + + @Test + public void s3TablesRegionResolvesFromPropsWhenNoStorageBound() { + // WHY: the M6 unblock — an EC2 instance-profile s3tables catalog (no bound storage) resolves its region + // from the raw props (s3.region here) instead of hard-failing, then uses the SDK default credential chain. + // MUTATION: reinstating the storage gate / removing the props fallback -> throws instead of resolving. + Assertions.assertEquals("us-east-1", + IcebergConnector.resolveS3TablesRegion(Optional.empty(), Map.of("s3.region", "us-east-1"))); + } + + @Test + public void s3TablesRegionResolvesFromWidenedAliasWhenNoStorageBound() { + // WHY: the props fallback scans the SAME widened S3 region-alias set as the vended-cred FileIO path (M7), + // so a region supplied only via AWS_REGION resolves. MUTATION: narrowing the alias set -> null -> throws. + Assertions.assertEquals("eu-west-1", + IcebergConnector.resolveS3TablesRegion(Optional.empty(), Map.of("AWS_REGION", "eu-west-1"))); + } + + @Test + public void s3TablesRegionPrefersBoundStorageRegion() { + // WHY: when a storage IS bound its typed region wins over a conflicting raw prop (parity with the bound- + // storage path). MUTATION: reading the props first -> us-east-1 instead of eu-west-1. + Optional bound = + Optional.of(new FakeS3CompatibleStorageProperties("S3").region("eu-west-1")); + Assertions.assertEquals("eu-west-1", + IcebergConnector.resolveS3TablesRegion(bound, Map.of("s3.region", "us-east-1"))); + } + + @Test + public void s3TablesRegionFailsLoudWhenNeitherStorageNorRegion() { + // WHY: a region is the sole hard requirement; neither a bound storage nor a props alias -> fail loud + // naming the region (Region.of("") would otherwise blow up deep in the SDK). MUTATION: returning "" / + // null instead of throwing -> no exception -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> IcebergConnector.resolveS3TablesRegion(Optional.empty(), Map.of())); + Assertions.assertTrue(ex.getMessage().contains("requires a region"), + "expected a fail-loud message naming the missing region, got: " + ex.getMessage()); + } + + @Test + public void s3TablesWithoutRegionFailsLoud() { + // WHY: Region.of("") would yield an invalid AWS region that only blows up deep in the SDK; the connector + // must reject a region-less s3tables storage up front (legacy getRegion() is validated non-blank at + // property-binding time). MUTATION: passing a blank region straight to Region.of -> a cryptic SDK error + // instead of this message -> red. + RecordingConnectorContext ctx = new RecordingConnectorContext(); + List storages = Collections.singletonList( + new FakeS3CompatibleStorageProperties("S3").accessKey("AK").secretKey("SK")); + ctx.storageProperties = storages; + IcebergConnector connector = new IcebergConnector( + Map.of("iceberg.catalog.type", "s3tables", + "warehouse", "arn:aws:s3tables:us-east-1:1:bucket/b"), + ctx); + DorisConnectorException ex = + Assertions.assertThrows(DorisConnectorException.class, () -> connector.getMetadata(null)); + Assertions.assertTrue(ex.getMessage().contains("region"), + "expected a fail-loud message naming the missing region, got: " + ex.getMessage()); + } + + @Test + public void removedDlfFlavorFailsLoudAtCatalogCreation() { + // WHY: iceberg.catalog.type=dlf (DLF 1.0 over the vendored thrift ProxyMetaStoreClient) was removed. A + // catalog still carrying it — e.g. one created before the removal and replayed from the image — must + // fail loud on first use naming the supported types, never silently route somewhere else. MUTATION: + // re-adding a dlf arm to resolveCatalogImpl -> red. + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergConnector connector = new IcebergConnector( + Map.of("iceberg.catalog.type", "dlf", "warehouse", "oss://b/wh"), ctx); + DorisConnectorException ex = + Assertions.assertThrows(DorisConnectorException.class, () -> connector.getMetadata(null)); + Assertions.assertTrue(ex.getMessage().contains("Unknown iceberg.catalog.type"), + "expected the unknown-flavor rejection, got: " + ex.getMessage()); + } + + @Test + 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.) + IcebergConnector connector = new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); + Set caps = connector.getCapabilities(); + Assertions.assertTrue(caps.contains(ConnectorCapability.SUPPORTS_MVCC_SNAPSHOT)); + } + + @Test + public void ownsHandleOnlyForIcebergTableHandle() { + // WHY (hms 3-way sibling routing): a flipped hms gateway embeds this connector as a sibling and asks it + // "is this foreign handle yours?" to route a scan/metadata call, because the concrete handle type is + // invisible across the plugin classloader split. ownsHandle must be TRUE for this connector's own + // IcebergTableHandle and FALSE for any other connector's handle (e.g. a hudi sibling's), so the gateway + // routes correctly. MUTATION: returning true unconditionally -> the gateway sends hudi handles here -> red. + IcebergConnector connector = new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); + Assertions.assertTrue(connector.ownsHandle(new IcebergTableHandle("db", "t")), + "an IcebergTableHandle is owned by the iceberg connector"); + Assertions.assertFalse(connector.ownsHandle(new ConnectorTableHandle() { + }), "a foreign (non-iceberg) handle is NOT owned by the iceberg connector"); + } + + @Test + public void declaresMetadataPreloadCapability() { + // WHY (F11): legacy IcebergExternalTable.supportsExternalMetadataPreload returns true so the planner + // 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.) + IcebergConnector connector = new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); + Assertions.assertTrue( + connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_METADATA_PRELOAD), + "iceberg must declare SUPPORTS_METADATA_PRELOAD so post-flip async metadata pre-load survives"); + } + + @Test + public void declaresColumnAutoAnalyzeAndTopNLazyMaterializeCapabilities() { + // WHY: legacy IcebergExternalTable is in StatisticsUtil.supportAutoAnalyze's whitelist and is forced to + // FULL analyze, and IcebergExternalTable.class is in MaterializeProbeVisitor's lazy-top-N supported set. + // 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.) + IcebergConnector connector = new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); + Set caps = connector.getCapabilities(); + Assertions.assertTrue(caps.contains(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE), + "iceberg must declare SUPPORTS_COLUMN_AUTO_ANALYZE so post-flip background auto-analyze keeps " + + "collecting per-column stats"); + Assertions.assertTrue(caps.contains(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE), + "iceberg must declare SUPPORTS_TOPN_LAZY_MATERIALIZE so post-flip Top-N queries keep lazy " + + "materialization"); + } + + @Test + public void declaresShowCreateDdlCapability() { + // WHY: legacy IcebergExternalTable rendered LOCATION + PROPERTIES + PARTITION BY + ORDER BY in SHOW + // CREATE TABLE, and IcebergExternalDatabase rendered LOCATION in SHOW CREATE DATABASE. Post-cutover the + // generic plugin-driven render arm reproduces these ONLY when the connector declares + // SUPPORTS_SHOW_CREATE_DDL (the capability also replaces the legacy paimon-only engine-name gate that + // doubled as the JDBC/ES credential-leak guard). MUTATION: dropping the capability -> flipped iceberg + // SHOW CREATE TABLE degrades to a comment-only shell -> red. (Inert pre-cutover.) + IcebergConnector connector = new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); + Assertions.assertTrue(connector.getCapabilities() + .contains(ConnectorCapability.SUPPORTS_SHOW_CREATE_DDL), + "iceberg must declare SUPPORTS_SHOW_CREATE_DDL so post-flip SHOW CREATE TABLE/DATABASE keeps " + + "rendering LOCATION/PROPERTIES/PARTITION BY/ORDER BY"); + } + + @Test + public void declaresViewCapability() { + // WHY: legacy IcebergExternalTable resolves isView() from catalog.viewExists and IcebergExternalCatalog + // merges listViewNames back into SHOW TABLES (its listTableNames subtracts views). Post-cutover the + // generic plugin path reproduces both ONLY when the connector declares SUPPORTS_VIEW + // (PluginDrivenExternalTable.isView() consults the connector, and listTableNamesFromRemote re-merges the + // connector's listViewNames). MUTATION: dropping the capability -> flipped iceberg views vanish from + // SHOW TABLES and report isView()==false (scanned as tables) -> red. (Inert pre-cutover.) + IcebergConnector connector = new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); + Assertions.assertTrue(connector.getCapabilities() + .contains(ConnectorCapability.SUPPORTS_VIEW), + "iceberg must declare SUPPORTS_VIEW so post-flip views stay visible/queryable/droppable"); + } + + @Test + public void declaresNestedColumnPruneCapability() { + // WHY: legacy IcebergExternalTable returns true from LogicalFileScan.supportPruneNestedColumn and the + // SlotTypeReplacer rewrites the nested access path to iceberg field-ids. Post-cutover the generic + // plugin-driven path reproduces both ONLY when the connector declares SUPPORTS_NESTED_COLUMN_PRUNE; it + // 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), + "iceberg must declare SUPPORTS_NESTED_COLUMN_PRUNE so post-flip nested sub-field queries keep " + + "reading only the accessed leaves"); + } + + // ------------------------------------------------------------------------------------------------------ + // H-2: REST 3-level namespace (external_catalog.name) must reach scan/write/procedure, not just metadata. + // + // Legacy IcebergMetadataOps was a SINGLE per-catalog ops carrying external_catalog.name, so ALL of + // metadata/scan/write/procedure resolved tables under [, ]. The SPI split built the three + // provider getters with the 1-arg CatalogBackedIcebergCatalogOps (external_catalog.name dropped), so + // post-flip SELECT/INSERT/EXECUTE on a 3-level REST catalog resolved the WRONG namespace ([] only). + // Each provider resolves its table exclusively via catalogOps.loadTable, so we assert that loadTable on + // the ops the connector hands each provider resolves the 3-level table. MUTATION: revert any one provider + // to the 1-arg ctor -> that ops resolves [mydb].t (missing) -> NoSuchTableException -> red. + // ------------------------------------------------------------------------------------------------------ + + private static final Schema H2_SCHEMA = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get())); + + /** + * Build a connector whose lazily-created catalog is replaced (reflection) with an offline in-memory + * catalog holding {@code [mydb, cat].t}, and configured with {@code external_catalog.name=cat}. + */ + private static IcebergConnector connectorOver3LevelCatalog() throws Exception { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("mydb")); + catalog.createNamespace(Namespace.of("mydb", "cat")); + catalog.createTable(TableIdentifier.of(Namespace.of("mydb", "cat"), "t"), H2_SCHEMA); + return connectorWithCatalog( + Map.of("iceberg.catalog.type", "rest", "external_catalog.name", "cat"), catalog); + } + + private static IcebergConnector connectorWithCatalog(Map props, Catalog catalog) + throws Exception { + IcebergConnector connector = new IcebergConnector(props, new RecordingConnectorContext()); + Field f = IcebergConnector.class.getDeclaredField("icebergCatalog"); + f.setAccessible(true); + f.set(connector, catalog); + return connector; + } + + /** + * Reflect out the per-request ops the connector built each provider with. Since P6.6-C6 each provider holds a + * {@code Function} resolver (not a bare ops); these catalogs are NOT + * {@code iceberg.rest.session=user}, so the resolver ignores the session and yields the shared session-less + * ops (the same object the pre-resolver provider held) — apply it with a null session to obtain it. + */ + private static IcebergCatalogOps catalogOpsOf(Object provider) throws Exception { + Field f = provider.getClass().getDeclaredField("catalogOpsResolver"); + f.setAccessible(true); + @SuppressWarnings("unchecked") + Function resolver = + (Function) f.get(provider); + return resolver.apply(null); + } + + @Test + public void scanProviderThreadsExternalCatalogNameInto3LevelNamespace() throws Exception { + IcebergCatalogOps ops = catalogOpsOf(connectorOver3LevelCatalog().getScanPlanProvider()); + Assertions.assertDoesNotThrow(() -> ops.loadTable("mydb", "t"), + "scan provider must build ops that thread external_catalog.name so the REST 3-level namespace " + + "[mydb, cat] resolves; the 1-arg ops drops it and loadTable hits [mydb].t -> NoSuchTable"); + } + + @Test + public void writeProviderThreadsExternalCatalogNameInto3LevelNamespace() throws Exception { + IcebergCatalogOps ops = catalogOpsOf(connectorOver3LevelCatalog().getWritePlanProvider()); + Assertions.assertDoesNotThrow(() -> ops.loadTable("mydb", "t"), + "write provider must thread external_catalog.name so INSERT/DELETE/MERGE resolve [mydb, cat].t"); + } + + @Test + public void procedureProviderThreadsExternalCatalogNameInto3LevelNamespace() throws Exception { + IcebergCatalogOps ops = catalogOpsOf(connectorOver3LevelCatalog().getProcedureOps()); + Assertions.assertDoesNotThrow(() -> ops.loadTable("mydb", "t"), + "procedure provider must thread external_catalog.name so ALTER TABLE ... EXECUTE resolves " + + "[mydb, cat].t"); + } + + @Test + public void scanProviderResolvesTwoLevelNamespaceWithoutExternalCatalogName() throws Exception { + // Sanity / reverse-mutation guard: without external_catalog.name the 2-level namespace [mydb] must + // still resolve (no spurious extra level appended). MUTATION: unconditionally appending a level -> red. + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("mydb")); + catalog.createTable(TableIdentifier.of(Namespace.of("mydb"), "t"), H2_SCHEMA); + IcebergConnector connector = connectorWithCatalog(Map.of("iceberg.catalog.type", "rest"), catalog); + IcebergCatalogOps ops = catalogOpsOf(connector.getScanPlanProvider()); + Assertions.assertDoesNotThrow(() -> ops.loadTable("mydb", "t"), + "without external_catalog.name a plain 2-level namespace [mydb] must resolve unchanged"); + } + + // ------------------------------------------------------------------------------------------------------ + // Task 6 (write-capability unification, P2): the per-connector expected-set assertion (the pragmatic + // "declaration == implementation" check for the write-capability invariant the removed + // ConnectorContractValidator#1 runtime probe is NOT safe to make) plus the structural contract validator, + // exercised against a real IcebergConnector (not just IcebergWritePlanProvider in isolation, which + // declaresFullWriteOperationSet in IcebergWritePlanProviderTest already pins) so this also proves + // Connector's null-safe write delegators route the provider's declarations through unchanged. The catalog + // is injected offline (reflection, same seam as the H-2 tests above) so getWritePlanProvider() never + // attempts a live catalog connection. + // ------------------------------------------------------------------------------------------------------ + + @Test + public void declaredWriteCapabilitiesMatchAndPassContractValidator() throws Exception { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + IcebergConnector connector = connectorWithCatalog(Collections.emptyMap(), catalog); + + 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(), + "iceberg does NOT require partition-local sort (unlike MaxCompute)"); + Assertions.assertTrue(connector.requiresFullSchemaWriteOrder()); + Assertions.assertTrue(connector.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 new file mode 100644 index 00000000000000..66847a1d182cd0 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTestConnectionTest.java @@ -0,0 +1,221 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorTestResult; +import org.apache.doris.connector.spi.ConnectorContext; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Unit tests for {@link IcebergConnector#testConnection}. These cover the deterministic pieces + * that the CREATE CATALOG connectivity regression relies on: + *

      + *
    • the failure-message wording (must contain the exact substrings the regression matches on),
    • + *
    • the s3:// location normalization used to pick a storage probe target, and
    • + *
    • that a catalog with nothing to probe returns success without any network access.
    • + *
    + * The failing meta/storage probes themselves require a live REST/MinIO endpoint and are exercised + * by the {@code test_iceberg_rest_minio_connectivity} regression suite. + */ +public class IcebergConnectorTestConnectionTest { + + private static final ConnectorContext CTX = new ConnectorContext() { + @Override + public String getCatalogName() { + return "test_iceberg"; + } + + @Override + public long getCatalogId() { + return 1L; + } + }; + + @Test + public void metaFailureMessageContainsRequiredSubstrings() { + // The regression asserts the CREATE CATALOG error contains BOTH "Iceberg REST" and the + // lowercase phrase "connectivity test failed"; if either drops the error is unactionable. + String msg = IcebergConnector.metaFailureMessage("rest", + new RuntimeException("Connection refused")); + Assertions.assertTrue(msg.contains("Iceberg REST"), msg); + Assertions.assertTrue(msg.contains("connectivity test failed"), msg); + Assertions.assertTrue(msg.contains("Connection refused"), msg); + } + + @Test + public void storageFailureMessageContainsRequiredSubstring() { + String msg = IcebergConnector.storageFailureMessage( + new RuntimeException("Access Denied")); + Assertions.assertTrue(msg.contains("connectivity test failed"), msg); + Assertions.assertTrue(msg.contains("Access Denied"), msg); + } + + @Test + public void rootCauseMessageUnwrapsNestedCause() { + Throwable root = new IllegalStateException("181812 is out of range"); + Throwable wrapped = new RuntimeException("wrapper", new RuntimeException("mid", root)); + Assertions.assertEquals("181812 is out of range", + IcebergConnector.rootCauseMessage(wrapped)); + // Falls back to the class name when the root cause has no message. + Assertions.assertEquals("NullPointerException", + IcebergConnector.rootCauseMessage(new NullPointerException())); + } + + @Test + public void toS3LocationNormalizesAndFilters() { + Assertions.assertEquals("s3://bucket/warehouse", + IcebergConnector.toS3Location("s3a://bucket/warehouse")); + Assertions.assertEquals("s3://bucket/warehouse", + IcebergConnector.toS3Location("s3n://bucket/warehouse")); + Assertions.assertEquals("s3://bucket/warehouse", + IcebergConnector.toS3Location(" s3://bucket/warehouse ")); + // Non-s3 warehouse names (e.g. a Polaris catalog name) are not probeable. + Assertions.assertNull(IcebergConnector.toS3Location("doris_test")); + Assertions.assertNull(IcebergConnector.toS3Location(null)); + } + + /** + * Pins the metastore-probe scope to what the legacy fe-core coordinator probed: it built a + * MetaConnectivityTester for Iceberg HMS / Glue / REST / S3Tables only. Filesystem-backed catalogs + * (hadoop) got the no-op default. Widening this set would fail CREATE CATALOG for a hadoop catalog + * whose warehouse is not yet reachable — a behavior change, not a parity restore. + */ + @Test + public void probesMetastoreOnlyForRemoteMetastoreTypes() { + Assertions.assertTrue(IcebergConnector.probesMetastore(IcebergConnectorProperties.TYPE_REST)); + Assertions.assertTrue(IcebergConnector.probesMetastore(IcebergConnectorProperties.TYPE_HMS)); + Assertions.assertTrue(IcebergConnector.probesMetastore(IcebergConnectorProperties.TYPE_GLUE)); + Assertions.assertTrue(IcebergConnector.probesMetastore(IcebergConnectorProperties.TYPE_S3_TABLES)); + Assertions.assertFalse(IcebergConnector.probesMetastore(IcebergConnectorProperties.TYPE_HADOOP)); + Assertions.assertFalse(IcebergConnector.probesMetastore("")); + } + + /** An HMS-backed catalog must name HMS in the failure, which is what the CREATE CATALOG regression asserts. */ + @Test + public void metaFailureMessageTagsTheCatalogType() { + String msg = IcebergConnector.metaFailureMessage(IcebergConnectorProperties.TYPE_HMS, + new RuntimeException("connection refused")); + Assertions.assertTrue(msg.contains("Iceberg HMS"), msg); + Assertions.assertTrue(msg.contains("connectivity test failed"), msg); + // A blank type must not produce a doubled space in the tag. + Assertions.assertTrue(IcebergConnector.metaFailureMessage("", new RuntimeException("boom")) + .startsWith("Iceberg connectivity test failed")); + } + + /** + * The BE probe must always carry {@code test_location}: BE looks that key up and dereferences the + * iterator without checking it exists, so a probe that omits it is worse than no probe at all. It must + * also carry the BE-facing credentials from the engine, not the raw catalog aliases. + */ + @Test + public void backendProbeCarriesTestLocationAndBackendCredentials() throws Exception { + Map captured = new HashMap<>(); + ConnectorContext ctx = new ConnectorContext() { + @Override + public String getCatalogName() { + return "test_iceberg"; + } + + @Override + public long getCatalogId() { + return 1L; + } + + @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); + } + }; + try (IcebergConnector connector = new IcebergConnector(new HashMap<>(), ctx)) { + Assertions.assertNull(connector.probeStorageFromBackend("s3://bucket/warehouse")); + } + Assertions.assertEquals("s3://bucket/warehouse", captured.get("test_location")); + Assertions.assertEquals("ak", captured.get("AWS_ACCESS_KEY")); + } + + /** 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 { + ConnectorContext ctx = new ConnectorContext() { + @Override + public String getCatalogName() { + return "test_iceberg"; + } + + @Override + public long getCatalogId() { + return 1L; + } + + @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"); + } + }; + try (IcebergConnector connector = new IcebergConnector(new HashMap<>(), ctx)) { + ConnectorTestResult result = connector.probeStorageFromBackend("s3://bucket/warehouse"); + Assertions.assertNotNull(result); + Assertions.assertFalse(result.isSuccess()); + Assertions.assertTrue(result.getMessage().contains("compute node"), result.getMessage()); + Assertions.assertTrue(result.getMessage().contains("connectivity test failed"), result.getMessage()); + } + } + + /** No static credentials to hand BE (e.g. a REST catalog with vended ones) means no probe, not a failure. */ + @Test + public void backendProbeSkippedWhenNoBackendCredentials() throws Exception { + try (IcebergConnector connector = new IcebergConnector(new HashMap<>(), CTX)) { + Assertions.assertNull(connector.probeStorageFromBackend("s3://bucket/warehouse")); + } + } + + @Test + public void testConnectionSucceedsWhenNothingToProbe() { + // Filesystem-backed (hadoop) catalog with no S3 credentials: the meta probe is skipped (see + // probesMetastore) and the storage probe is skipped (no s3.* creds), so testConnection + // succeeds without any I/O. + Map props = new HashMap<>(); + props.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, + IcebergConnectorProperties.TYPE_HADOOP); + try (IcebergConnector connector = new IcebergConnector(props, CTX)) { + ConnectorTestResult result = connector.testConnection(null); + Assertions.assertTrue(result.isSuccess(), result.getMessage()); + } catch (Exception e) { + throw new AssertionError("close() should not fail", e); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java new file mode 100644 index 00000000000000..0ed5a711ae065a --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java @@ -0,0 +1,1608 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorBetween; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorIsNull; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.thrift.TFileContent; +import org.apache.doris.thrift.TIcebergColumnStats; +import org.apache.doris.thrift.TIcebergCommitData; + +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.DeleteFiles; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileMetadata; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.types.Types; +import org.apache.thrift.TException; +import org.apache.thrift.TSerializer; +import org.apache.thrift.protocol.TBinaryProtocol; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Pins {@link IcebergConnectorTransaction}: the T03 skeleton (single SDK transaction held through the + * {@link IcebergCatalogOps} seam, the 14-field {@link TIcebergCommitData} round-trip, the data/delete-split + * {@code getUpdateCnt}) AND the T04 op selection (begin* guards + {@code commit()} dispatch onto + * AppendFiles / ReplacePartitions / OverwriteFiles / RowDelta, ported from legacy {@code IcebergTransaction}). + * + *

    Mirrors the no-Mockito, real-{@link InMemoryCatalog} style of {@code IcebergScanPlanProviderTest}. + * The commit-validation suite (T05), sink (T06) and capability dispatch (T07) are out of scope here — + * the RowDelta built here intentionally carries no conflict-detection validation (T05 adds it).

    + */ +public class IcebergConnectorTransactionTest { + + private static final Schema SCHEMA = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get())); + private static final Schema PART_SCHEMA = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "region", Types.StringType.get())); + + private static InMemoryCatalog freshCatalog() { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + return catalog; + } + + private static Map props(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i + 1 < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + private static RecordingIcebergCatalogOps opsReturning(Table table) { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = table; + return ops; + } + + private static IcebergConnectorTransaction txnFor(RecordingIcebergCatalogOps ops, RecordingConnectorContext ctx) { + return new IcebergConnectorTransaction(42L, ops, ctx); + } + + private static final ConnectorSession SESSION = new FakeWriteSession("UTC"); + + private static IcebergWriteContext insertCtx() { + return new IcebergWriteContext(WriteOperation.INSERT, false, Collections.emptyMap(), Optional.empty()); + } + + private static IcebergWriteContext insertToBranch(String branch) { + return new IcebergWriteContext( + WriteOperation.INSERT, false, Collections.emptyMap(), Optional.of(branch)); + } + + private static IcebergWriteContext overwriteCtx() { + return new IcebergWriteContext(WriteOperation.OVERWRITE, true, Collections.emptyMap(), Optional.empty()); + } + + private static IcebergWriteContext overwriteStaticCtx(Map staticValues) { + return new IcebergWriteContext(WriteOperation.OVERWRITE, true, staticValues, Optional.empty()); + } + + private static IcebergWriteContext deleteCtx() { + return new IcebergWriteContext(WriteOperation.DELETE, false, Collections.emptyMap(), Optional.empty()); + } + + private static IcebergWriteContext mergeCtx() { + return new IcebergWriteContext(WriteOperation.MERGE, false, Collections.emptyMap(), Optional.empty()); + } + + private static IcebergWriteContext rewriteCtx() { + return new IcebergWriteContext(WriteOperation.REWRITE, false, Collections.emptyMap(), Optional.empty()); + } + + private static IcebergWriteContext deleteCtxPinned(long readSnapshotId) { + return new IcebergWriteContext( + WriteOperation.DELETE, false, Collections.emptyMap(), Optional.empty(), readSnapshotId); + } + + private static IcebergWriteContext mergeCtxPinned(long readSnapshotId) { + return new IcebergWriteContext( + WriteOperation.MERGE, false, Collections.emptyMap(), Optional.empty(), readSnapshotId); + } + + /** + * The data files of the table's current snapshot — the connector-side equivalent of the rewrite planner's + * {@code RewriteDataGroup.getDataFiles()} ({@code FileScanTask.file()}), i.e. the original files a rewrite + * group hands to {@code updateRewriteFiles} as files-to-delete. + */ + private static List currentDataFiles(Table table) { + List files = new ArrayList<>(); + try (CloseableIterable tasks = table.newScan().planFiles()) { + for (FileScanTask t : tasks) { + files.add(t.file()); + } + } catch (IOException e) { + throw new AssertionError(e); + } + return files; + } + + private static DataFile dataFile(PartitionSpec spec, String path, long records) { + return DataFiles.builder(spec) + .withPath(path) + .withFileSizeInBytes(1024) + .withRecordCount(records) + .withFormat(FileFormat.PARQUET) + .build(); + } + + /** A data file placed into a concrete partition (e.g. {@code "region=us"}) so partition-scoped + * conflict detection can discriminate it. */ + private static DataFile partitionedDataFile(PartitionSpec spec, String path, long records, String partitionPath) { + return DataFiles.builder(spec) + .withPath(path) + .withFileSizeInBytes(1024) + .withRecordCount(records) + .withFormat(FileFormat.PARQUET) + .withPartitionPath(partitionPath) + .build(); + } + + /** A data (or delete) commit fragment serialized exactly as BE would send it. */ + private static byte[] commitBytes(TIcebergCommitData data) { + try { + return new TSerializer(new TBinaryProtocol.Factory()).serialize(data); + } catch (TException e) { + throw new AssertionError(e); + } + } + + private static TIcebergCommitData dataItem(long affectedRows, long rowCount, TFileContent content) { + TIcebergCommitData d = new TIcebergCommitData(); + d.setRowCount(rowCount); + if (affectedRows >= 0) { + d.setAffectedRows(affectedRows); + } + if (content != null) { + d.setFileContent(content); + } + return d; + } + + private static TIcebergCommitData dataFileItem(String path, long rowCount, long fileSize) { + TIcebergCommitData d = new TIcebergCommitData(); + d.setFilePath(path); + d.setRowCount(rowCount); + d.setFileSize(fileSize); + d.setFileContent(TFileContent.DATA); + return d; + } + + private static TIcebergCommitData dataFileItem(String path, long rowCount, long fileSize, List partVals) { + TIcebergCommitData d = dataFileItem(path, rowCount, fileSize); + d.setPartitionValues(partVals); + return d; + } + + private static TIcebergCommitData positionDeleteItem(String path, long rowCount, String referencedDataFile) { + TIcebergCommitData d = new TIcebergCommitData(); + d.setFilePath(path); + d.setRowCount(rowCount); + d.setFileSize(512); + d.setFileContent(TFileContent.POSITION_DELETES); + d.setReferencedDataFilePath(referencedDataFile); + return d; + } + + private static Snapshot reloadCurrentSnapshot(InMemoryCatalog catalog, TableIdentifier id) { + return catalog.loadTable(id).currentSnapshot(); + } + + // ─────────────────── addCommitData: 14-field TBinaryProtocol round-trip (T03, regression) ─────────────────── + + @Test + public void addCommitDataRoundTripsAll14Fields() { + TIcebergColumnStats stats = new TIcebergColumnStats(); + stats.putToColumnSizes(1, 100L); + stats.putToValueCounts(1, 10L); + stats.putToNullValueCounts(1, 2L); + stats.putToNanValueCounts(1, 0L); + stats.putToLowerBounds(1, ByteBuffer.wrap(new byte[] {1})); + stats.putToUpperBounds(1, ByteBuffer.wrap(new byte[] {9})); + + TIcebergCommitData d = new TIcebergCommitData(); + d.setFilePath("s3://b/db/t/f.parquet"); + d.setRowCount(123L); + d.setFileSize(4096L); + d.setFileContent(TFileContent.POSITION_DELETES); + d.setPartitionValues(Arrays.asList("a", "b")); + d.setReferencedDataFiles(Arrays.asList("d1")); + d.setColumnStats(stats); + d.setEqualityFieldIds(Arrays.asList(1, 2)); + d.setReferencedDataFilePath("s3://b/db/t/d1.parquet"); + d.setPartitionSpecId(7); + d.setPartitionDataJson("{\"p\":1}"); + d.setContentOffset(64L); + d.setContentSizeInBytes(256L); + d.setAffectedRows(99L); + + IcebergConnectorTransaction txn = txnFor(opsReturning(null), new RecordingConnectorContext()); + txn.addCommitData(commitBytes(d)); + + List acc = txn.getCommitDataList(); + Assertions.assertEquals(1, acc.size()); + Assertions.assertEquals(d, acc.get(0), + "every one of the 14 TIcebergCommitData fields (and the nested TIcebergColumnStats) " + + "must survive the TBinaryProtocol round-trip into the accumulator"); + } + + @Test + public void addCommitDataAccumulatesInOrder() { + IcebergConnectorTransaction txn = txnFor(opsReturning(null), new RecordingConnectorContext()); + txn.addCommitData(commitBytes(dataItem(1L, 1L, TFileContent.DATA))); + txn.addCommitData(commitBytes(dataItem(2L, 2L, TFileContent.DATA))); + Assertions.assertEquals(2, txn.getCommitDataList().size()); + Assertions.assertEquals(1L, txn.getCommitDataList().get(0).getAffectedRows()); + Assertions.assertEquals(2L, txn.getCommitDataList().get(1).getAffectedRows()); + } + + @Test + public void addCommitDataFailsLoudOnMalformedBytes() { + IcebergConnectorTransaction txn = txnFor(opsReturning(null), new RecordingConnectorContext()); + Assertions.assertThrows(DorisConnectorException.class, + () -> txn.addCommitData(new byte[] {(byte) 0xDE, (byte) 0xAD, (byte) 0xBE, (byte) 0xEF})); + } + + // ─────────────────── getUpdateCnt: data/delete split, affectedRows priority (T03, regression) ─────────────────── + + @Test + public void getUpdateCntSumsDataRows() { + IcebergConnectorTransaction txn = txnFor(opsReturning(null), new RecordingConnectorContext()); + txn.addCommitData(commitBytes(dataItem(5L, 5L, TFileContent.DATA))); + txn.addCommitData(commitBytes(dataItem(7L, 7L, TFileContent.DATA))); + Assertions.assertEquals(12L, txn.getUpdateCnt()); + } + + @Test + public void getUpdateCntFallsBackToRowCountWhenAffectedRowsUnset() { + IcebergConnectorTransaction txn = txnFor(opsReturning(null), new RecordingConnectorContext()); + txn.addCommitData(commitBytes(dataItem(-1L, 8L, TFileContent.DATA))); + Assertions.assertEquals(8L, txn.getUpdateCnt()); + } + + @Test + public void getUpdateCntPrefersAffectedRowsOverRowCount() { + IcebergConnectorTransaction txn = txnFor(opsReturning(null), new RecordingConnectorContext()); + txn.addCommitData(commitBytes(dataItem(3L, 999L, TFileContent.DATA))); + Assertions.assertEquals(3L, txn.getUpdateCnt()); + } + + @Test + public void getUpdateCntReturnsDeleteRowsWhenNoDataRows() { + IcebergConnectorTransaction txn = txnFor(opsReturning(null), new RecordingConnectorContext()); + txn.addCommitData(commitBytes(dataItem(4L, 4L, TFileContent.POSITION_DELETES))); + txn.addCommitData(commitBytes(dataItem(6L, 6L, TFileContent.DELETION_VECTOR))); + Assertions.assertEquals(10L, txn.getUpdateCnt()); + } + + @Test + public void getUpdateCntDoesNotDoubleCountDeletesWhenDataRowsPresent() { + IcebergConnectorTransaction txn = txnFor(opsReturning(null), new RecordingConnectorContext()); + txn.addCommitData(commitBytes(dataItem(5L, 5L, TFileContent.DATA))); + txn.addCommitData(commitBytes(dataItem(5L, 5L, TFileContent.POSITION_DELETES))); + Assertions.assertEquals(5L, txn.getUpdateCnt()); + } + + @Test + public void getUpdateCntIsZeroForEmptyTransaction() { + IcebergConnectorTransaction txn = txnFor(opsReturning(null), new RecordingConnectorContext()); + Assertions.assertEquals(0L, txn.getUpdateCnt()); + } + + // ─────────────────── identity / profile (T03, regression) ─────────────────── + + @Test + public void carriesTransactionIdAndIcebergProfileLabel() { + IcebergConnectorTransaction txn = new IcebergConnectorTransaction( + 7777L, opsReturning(null), new RecordingConnectorContext()); + Assertions.assertEquals(7777L, txn.getTransactionId()); + Assertions.assertEquals("ICEBERG", txn.profileLabel()); + } + + // ─────────────────── beginWrite: SDK txn opened through seam + auth (T03, now op-aware) ─────────────────── + + @Test + public void beginWriteOpensSdkTransactionThroughAuthWrappedSeam() { + InMemoryCatalog catalog = freshCatalog(); + Table table = catalog.createTable( + TableIdentifier.of("db1", "t1"), SCHEMA, PartitionSpec.unpartitioned()); + RecordingIcebergCatalogOps ops = opsReturning(table); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergConnectorTransaction txn = txnFor(ops, ctx); + + txn.beginWrite(SESSION, "db1", "t1", insertCtx()); + + Assertions.assertEquals(1, ctx.authCount, "loadTable + newTransaction must run INSIDE executeAuthenticated"); + Assertions.assertTrue(ops.log.contains("loadTable:db1.t1")); + Assertions.assertNotNull(txn.getTransaction(), "SDK transaction must be opened"); + Assertions.assertSame(table, txn.getTable()); + } + + @Test + public void beginWriteFailsLoudWhenLoadTableThrows() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.throwOnLoadTable = true; + IcebergConnectorTransaction txn = txnFor(ops, new RecordingConnectorContext()); + Assertions.assertThrows(DorisConnectorException.class, + () -> txn.beginWrite(SESSION, "db1", "t1", insertCtx())); + } + + @Test + public void beginWriteRunsLoadTableInsideAuthenticator() { + RecordingIcebergCatalogOps ops = opsReturning(null); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + IcebergConnectorTransaction txn = txnFor(ops, ctx); + Assertions.assertThrows(DorisConnectorException.class, + () -> txn.beginWrite(SESSION, "db1", "t1", insertCtx())); + Assertions.assertFalse(ops.log.contains("loadTable:db1.t1"), + "loadTable must not run when the authenticator throws first"); + } + + // ─────────────────── begin* guards (T04) ─────────────────── + + @Test + public void beginDeleteRejectsFormatVersion1Table() { + InMemoryCatalog catalog = freshCatalog(); + Table table = catalog.createTable(TableIdentifier.of("db1", "t1"), SCHEMA, + PartitionSpec.unpartitioned(), props("format-version", "1")); + IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext()); + // DELETE needs position deletes -> format-version >= 2 (legacy IcebergTransaction.beginDelete:291). + Assertions.assertThrows(DorisConnectorException.class, + () -> txn.beginWrite(SESSION, "db1", "t1", deleteCtx())); + } + + @Test + public void beginMergeRejectsFormatVersion1Table() { + InMemoryCatalog catalog = freshCatalog(); + Table table = catalog.createTable(TableIdentifier.of("db1", "t1"), SCHEMA, + PartitionSpec.unpartitioned(), props("format-version", "1")); + IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext()); + Assertions.assertThrows(DorisConnectorException.class, + () -> txn.beginWrite(SESSION, "db1", "t1", mergeCtx())); + } + + @Test + public void beginInsertRejectsBranchThatIsATag() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned()); + // Seed a snapshot so a tag can be created, then tag it. + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/seed.parquet", 1L)).commit(); + Table reloaded = catalog.loadTable(id); + reloaded.manageSnapshots().createTag("mytag", reloaded.currentSnapshot().snapshotId()).commit(); + + IcebergConnectorTransaction txn = txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + // A tag cannot be a target for producing snapshots (legacy beginInsert:156). + Assertions.assertThrows(DorisConnectorException.class, + () -> txn.beginWrite(SESSION, "db1", "t1", insertToBranch("mytag"))); + } + + @Test + public void beginInsertRejectsUnknownBranch() { + InMemoryCatalog catalog = freshCatalog(); + Table table = catalog.createTable(TableIdentifier.of("db1", "t1"), SCHEMA, PartitionSpec.unpartitioned()); + IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext()); + Assertions.assertThrows(DorisConnectorException.class, + () -> txn.beginWrite(SESSION, "db1", "t1", insertToBranch("nope"))); + } + + @Test + public void beginDeleteCapturesBaseSnapshotId() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), props("format-version", "2")); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/seed.parquet", 3L)).commit(); + Table reloaded = catalog.loadTable(id); + long expected = reloaded.currentSnapshot().snapshotId(); + + IcebergConnectorTransaction txn = txnFor(opsReturning(reloaded), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", deleteCtx()); + + // T05 consumes baseSnapshotId for validateFromSnapshot; T04 only captures it at begin time. + Assertions.assertEquals(Long.valueOf(expected), txn.getBaseSnapshotId()); + } + + @Test + public void beginDeleteHonorsPinnedReadSnapshotOverCurrent() { + // [SHOULD-2] / Fix B: the write must anchor baseSnapshotId at the statement's READ snapshot + // (the MVCC pin the scan used, S_read), not at a fresh re-read of the current snapshot (S_write). + // WHY: option-D's commit-time removeDeletes re-derives from baseSnapshotId, while BE unions the + // scan-time (S_read) old deletes into the new DV. If baseSnapshotId drifted to a newer current + // snapshot, a concurrent delete file landing in (S_read, S_write] would be removed-but-not-unioned + // and its rows would silently resurrect (the iceberg OCC anchored at S_write cannot catch it). + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), props("format-version", "2")); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/seed.parquet", 1L)).commit(); + long readSnapshot = catalog.loadTable(id).currentSnapshot().snapshotId(); + // A concurrent writer advances the table past the read snapshot before begin-write reloads it. + Table reloaded = catalog.loadTable(id); + reloaded.newAppend().appendFile(dataFile(reloaded.spec(), "s3://b/db1/t1/concurrent.parquet", 2L)).commit(); + long current = catalog.loadTable(id).currentSnapshot().snapshotId(); + Assertions.assertNotEquals(readSnapshot, current, "test must advance the table past the read snapshot"); + + IcebergConnectorTransaction txn = txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", deleteCtxPinned(readSnapshot)); + + Assertions.assertEquals(Long.valueOf(readSnapshot), txn.getBaseSnapshotId(), + "DELETE must anchor baseSnapshotId at the pinned read snapshot, not the current snapshot"); + } + + @Test + public void beginMergeHonorsPinnedReadSnapshotOverCurrent() { + // Same as the DELETE arm for MERGE/UPDATE (RowDelta path): the OR-capability must be honored on + // both arms, so the pin is exercised independently for MERGE. + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), props("format-version", "2")); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/seed.parquet", 1L)).commit(); + long readSnapshot = catalog.loadTable(id).currentSnapshot().snapshotId(); + Table reloaded = catalog.loadTable(id); + reloaded.newAppend().appendFile(dataFile(reloaded.spec(), "s3://b/db1/t1/concurrent.parquet", 2L)).commit(); + long current = catalog.loadTable(id).currentSnapshot().snapshotId(); + Assertions.assertNotEquals(readSnapshot, current, "test must advance the table past the read snapshot"); + + IcebergConnectorTransaction txn = txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", mergeCtxPinned(readSnapshot)); + + Assertions.assertEquals(Long.valueOf(readSnapshot), txn.getBaseSnapshotId(), + "MERGE must anchor baseSnapshotId at the pinned read snapshot, not the current snapshot"); + } + + @Test + public void beginInsertDoesNotCaptureBaseSnapshotId() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/seed.parquet", 1L)).commit(); + + IcebergConnectorTransaction txn = txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", insertCtx()); + Assertions.assertNull(txn.getBaseSnapshotId(), "INSERT must not pin a base snapshot (append, not RowDelta)"); + } + + // ─────────────────── commit: op selection (T04) ─────────────────── + + @Test + public void insertAppendsDataFiles() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), + props("write.format.default", "parquet")); + IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext()); + + txn.beginWrite(SESSION, "db1", "t1", insertCtx()); + txn.addCommitData(commitBytes(dataFileItem("s3://b/db1/t1/f1.parquet", 10L, 2048L))); + Assertions.assertNull(reloadCurrentSnapshot(catalog, id), "nothing visible before commit()"); + + txn.commit(); + + Snapshot snap = reloadCurrentSnapshot(catalog, id); + Assertions.assertNotNull(snap); + Assertions.assertEquals("append", snap.operation()); + Assertions.assertEquals("1", snap.summary().get("added-data-files")); + } + + @Test + public void insertToBranchCommitsOnBranch() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/seed.parquet", 1L)).commit(); + Table reloaded = catalog.loadTable(id); + reloaded.manageSnapshots().createBranch("b1", reloaded.currentSnapshot().snapshotId()).commit(); + + IcebergConnectorTransaction txn = txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", insertToBranch("b1")); + txn.addCommitData(commitBytes(dataFileItem("s3://b/db1/t1/f1.parquet", 5L, 1024L))); + txn.commit(); + + Table after = catalog.loadTable(id); + // The branch advanced past the seed snapshot; main stayed on the seed. + long branchSnap = after.snapshot(after.refs().get("b1").snapshotId()).snapshotId(); + Assertions.assertNotEquals(after.currentSnapshot().snapshotId(), branchSnap, + "the append must land on branch b1, not on main"); + } + + @Test + public void overwriteDynamicReplacesPartitions() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("region").build(); + Table table = catalog.createTable(id, PART_SCHEMA, spec, props("write.format.default", "parquet")); + IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext()); + + txn.beginWrite(SESSION, "db1", "t1", overwriteCtx()); + txn.addCommitData(commitBytes( + dataFileItem("s3://b/db1/t1/region=us/f1.parquet", 4L, 1024L, Collections.singletonList("us")))); + txn.commit(); + + Snapshot snap = reloadCurrentSnapshot(catalog, id); + Assertions.assertNotNull(snap); + // ReplacePartitions produces an overwrite snapshot with the new data file. + Assertions.assertEquals("overwrite", snap.operation()); + Assertions.assertEquals("1", snap.summary().get("added-data-files")); + } + + @Test + public void overwriteEmptyUnpartitionedClearsTable() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), + props("write.format.default", "parquet")); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/old.parquet", 9L)).commit(); + + IcebergConnectorTransaction txn = txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + // INSERT OVERWRITE ... SELECT * FROM empty: no commit data, unpartitioned -> table is emptied. + txn.beginWrite(SESSION, "db1", "t1", overwriteCtx()); + txn.commit(); + + Snapshot snap = reloadCurrentSnapshot(catalog, id); + // An OverwriteFiles that only removes files (no adds) is labelled "delete" by iceberg, not "overwrite". + Assertions.assertEquals("delete", snap.operation()); + Assertions.assertEquals("1", snap.summary().get("deleted-data-files"), + "the existing data file must be removed (table cleared)"); + } + + @Test + public void overwriteStaticPartitionUsesRowFilter() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("region").build(); + Table table = catalog.createTable(id, PART_SCHEMA, spec, props("write.format.default", "parquet")); + IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext()); + + // INSERT OVERWRITE ... PARTITION(region='us') -> OverwriteFiles.overwriteByRowFilter(region == 'us'). + txn.beginWrite(SESSION, "db1", "t1", overwriteStaticCtx(Collections.singletonMap("region", "us"))); + txn.addCommitData(commitBytes( + dataFileItem("s3://b/db1/t1/region=us/f1.parquet", 4L, 1024L, Collections.singletonList("us")))); + txn.commit(); + + Snapshot snap = reloadCurrentSnapshot(catalog, id); + Assertions.assertEquals("overwrite", snap.operation()); + Assertions.assertEquals("1", snap.summary().get("added-data-files")); + } + + @Test + public void deleteWritesRowDeltaDeleteFiles() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), props("format-version", "2")); + IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext()); + + txn.beginWrite(SESSION, "db1", "t1", deleteCtx()); + txn.addCommitData(commitBytes( + positionDeleteItem("s3://b/db1/t1/del.parquet", 3L, "s3://b/db1/t1/data.parquet"))); + txn.commit(); + + Snapshot snap = reloadCurrentSnapshot(catalog, id); + Assertions.assertNotNull(snap); + Assertions.assertEquals("1", snap.summary().get("added-delete-files"), + "DELETE must add a position-delete file via RowDelta"); + } + + @Test + public void mergeWritesRowDeltaDataAndDeleteFiles() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), props("format-version", "2")); + IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext()); + + txn.beginWrite(SESSION, "db1", "t1", mergeCtx()); + txn.addCommitData(commitBytes(dataFileItem("s3://b/db1/t1/new.parquet", 2L, 1024L))); + txn.addCommitData(commitBytes( + positionDeleteItem("s3://b/db1/t1/del.parquet", 2L, "s3://b/db1/t1/old.parquet"))); + txn.commit(); + + Snapshot snap = reloadCurrentSnapshot(catalog, id); + Assertions.assertNotNull(snap); + Assertions.assertEquals("1", snap.summary().get("added-data-files")); + Assertions.assertEquals("1", snap.summary().get("added-delete-files")); + } + + @Test + public void emptyInsertCommitsWithoutThrowing() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned()); + IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext()); + + txn.beginWrite(SESSION, "db1", "t1", insertCtx()); + // No commit data -> empty append; legacy still commits the (empty) transaction. + Assertions.assertDoesNotThrow(txn::commit); + } + + @Test + public void emptyDeleteCommitsWithoutAddingDeleteFiles() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), props("format-version", "2")); + IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext()); + + txn.beginWrite(SESSION, "db1", "t1", deleteCtx()); + // No delete commit data -> no RowDelta is built (legacy early-return), but commit() still flushes. + Assertions.assertDoesNotThrow(txn::commit); + Assertions.assertNull(reloadCurrentSnapshot(catalog, id), "an empty delete must not create a snapshot"); + } + + @Test + public void commitWithoutBeginFailsLoud() { + IcebergConnectorTransaction txn = txnFor(opsReturning(null), new RecordingConnectorContext()); + Assertions.assertThrows(DorisConnectorException.class, txn::commit); + } + + @Test + public void rollbackAndCloseAreNoOps() { + IcebergConnectorTransaction txn = txnFor(opsReturning(null), new RecordingConnectorContext()); + Assertions.assertDoesNotThrow(() -> { + txn.rollback(); + txn.close(); + }); + } + + // ─────────────────── commit-time conflict-detection validation suite (T05) ─────────────────── + + @Test + public void deleteDetectsConcurrentDataFileConflict() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), props("format-version", "2")); + // seed snapshot S1 with one data file + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/seed.parquet", 5L)).commit(); + + IcebergConnectorTransaction txn = txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", deleteCtx()); // baseSnapshotId pinned to S1 + + // A concurrent writer appends a NEW data file -> snapshot S2, AFTER our transaction pinned S1. + catalog.loadTable(id).newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db1/t1/concurrent.parquet", 7L)).commit(); + + txn.addCommitData(commitBytes( + positionDeleteItem("s3://b/db1/t1/del.parquet", 2L, "s3://b/db1/t1/seed.parquet"))); + + // validateFromSnapshot(S1) + serializable validateNoConflictingDataFiles detect the concurrent append. + // Under T04 (no validation suite) this DELETE would silently win; the suite makes it fail loud. + Assertions.assertThrows(DorisConnectorException.class, txn::commit, + "a concurrent data-file append since the base snapshot must be detected as a conflict"); + } + + @Test + public void deletePassesValidationSuiteWhenNoConcurrentChange() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), props("format-version", "2")); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/seed.parquet", 5L)).commit(); + + IcebergConnectorTransaction txn = txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", deleteCtx()); + txn.addCommitData(commitBytes( + positionDeleteItem("s3://b/db1/t1/del.parquet", 2L, "s3://b/db1/t1/seed.parquet"))); + txn.commit(); + + Snapshot snap = reloadCurrentSnapshot(catalog, id); + Assertions.assertEquals("1", snap.summary().get("added-delete-files"), + "with no concurrent change the full validation suite passes and the delete commits"); + } + + @Test + public void deletePartitionedIdentityNarrowsConflictDetectionToTouchedPartition() { + // T05/parity: for an identity-partitioned DELETE the commit-time conflict-detection filter is narrowed to + // the touched partition (buildConflictDetectionFilter -> buildIdentityPartitionExpression -> col = value), + // so a concurrent data-file append to a DIFFERENT partition is NOT a conflict, while one in the SAME + // partition is. Every existing DELETE/MERGE conflict test is UNPARTITIONED, so this whole partition-filter + // path (areAllIdentityPartitions / extractPartitionValues / spec-id match / combine) was never exercised. + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("region").build(); + + // (a) concurrent append in a DIFFERENT partition (eu) -> narrowed out -> the delete still commits. + Assertions.assertDoesNotThrow(() -> runPartitionedDelete(spec, "us", "eu"), + "an identity-partition filter (region='us') must exclude a concurrent append to region='eu'"); + + // (b) concurrent append in the SAME partition (us) -> within the filter -> conflict detected. This also + // proves the validation actually runs, so (a) passing is narrowing — not validation being skipped. + Assertions.assertThrows(DorisConnectorException.class, () -> runPartitionedDelete(spec, "us", "us"), + "a concurrent append to the SAME partition the delete touches must be detected as a conflict"); + } + + @Test + public void deleteNonIdentityPartitionSpecDisablesConflictNarrowing() { + // parity: when not every partition transform is identity (areAllIdentityPartitions == false), no partition + // narrowing is applied, so conflict detection falls back to the whole table — a concurrent append in ANY + // bucket is a conflict (contrast the identity case above, where a different partition is excluded). + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).bucket("region", 4).build(); + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, PART_SCHEMA, spec, props("format-version", "2")); + String seedPath = "s3://b/db1/t1/region_bucket=0/seed.parquet"; + table.newAppend().appendFile(partitionedDataFile(spec, seedPath, 5L, "region_bucket=0")).commit(); + + IcebergConnectorTransaction txn = txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", deleteCtx()); + catalog.loadTable(id).newAppend().appendFile(partitionedDataFile(spec, + "s3://b/db1/t1/region_bucket=1/concurrent.parquet", 7L, "region_bucket=1")).commit(); + + TIcebergCommitData del = positionDeleteItem("s3://b/db1/t1/region_bucket=0/del.parquet", 2L, seedPath); + del.setPartitionValues(Collections.singletonList("0")); + del.setPartitionSpecId(spec.specId()); + txn.addCommitData(commitBytes(del)); + + Assertions.assertThrows(DorisConnectorException.class, txn::commit, + "a non-identity (bucket) partition spec disables narrowing -> the concurrent append still conflicts"); + } + + @Test + public void isSerializableIsolationLevelDefaultsAndReadsProperty() { + InMemoryCatalog catalog = freshCatalog(); + IcebergConnectorTransaction txn = txnFor(opsReturning(null), new RecordingConnectorContext()); + + Table dflt = catalog.createTable(TableIdentifier.of("db1", "d"), SCHEMA, PartitionSpec.unpartitioned()); + Assertions.assertTrue(txn.isSerializableIsolationLevel(dflt), "missing property defaults to serializable"); + + Table ser = catalog.createTable(TableIdentifier.of("db1", "s"), SCHEMA, PartitionSpec.unpartitioned(), + props("delete_isolation_level", "serializable")); + Assertions.assertTrue(txn.isSerializableIsolationLevel(ser)); + + Table snap = catalog.createTable(TableIdentifier.of("db1", "n"), SCHEMA, PartitionSpec.unpartitioned(), + props("delete_isolation_level", "snapshot")); + Assertions.assertFalse(txn.isSerializableIsolationLevel(snap), "snapshot isolation is not serializable"); + } + + @Test + public void deleteSnapshotIsolationSkipsConcurrentDataFileValidation() { + // parity: at delete_isolation_level=snapshot (non-serializable) validateNoConflictingDataFiles is NOT + // applied, so a concurrent data-file append since the base snapshot does NOT fail the delete — the inverse + // of deleteDetectsConcurrentDataFileConflict (which runs at the default serializable level). + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), + props("format-version", "2", "delete_isolation_level", "snapshot")); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/seed.parquet", 5L)).commit(); + + IcebergConnectorTransaction txn = txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", deleteCtx()); + catalog.loadTable(id).newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db1/t1/concurrent.parquet", 7L)).commit(); + txn.addCommitData(commitBytes( + positionDeleteItem("s3://b/db1/t1/del.parquet", 2L, "s3://b/db1/t1/seed.parquet"))); + + Assertions.assertDoesNotThrow(txn::commit, + "snapshot isolation skips validateNoConflictingDataFiles -> the concurrent append is not a conflict"); + } + + @Test + public void collectReferencedDataFilesKeepsOnlyDeleteFragments() { + IcebergConnectorTransaction txn = txnFor(opsReturning(null), new RecordingConnectorContext()); + + TIcebergCommitData dataFrag = dataFileItem("s3://b/db1/t1/data.parquet", 3L, 1024L); // DATA -> ignored + TIcebergCommitData posDelete = new TIcebergCommitData(); + posDelete.setFilePath("s3://b/db1/t1/pos.parquet"); + posDelete.setFileContent(TFileContent.POSITION_DELETES); + posDelete.setReferencedDataFilePath("s3://b/db1/t1/ref-by-path.parquet"); + posDelete.setReferencedDataFiles(Arrays.asList("s3://b/db1/t1/ref-list.parquet", "")); + + List refs = txn.collectReferencedDataFiles(Arrays.asList(dataFrag, posDelete)); + + Assertions.assertEquals( + Arrays.asList("s3://b/db1/t1/ref-list.parquet", "s3://b/db1/t1/ref-by-path.parquet"), refs, + "only POSITION_DELETES/DELETION_VECTOR fragments contribute referenced files; " + + "both referenced_data_files (non-empty) and referenced_data_file_path are kept"); + } + + @Test + public void shouldRewritePreviousDeleteFilesGatesOnFormatVersion3() { + InMemoryCatalog catalog = freshCatalog(); + + Table v2 = catalog.createTable(TableIdentifier.of("db1", "v2"), SCHEMA, + PartitionSpec.unpartitioned(), props("format-version", "2")); + IcebergConnectorTransaction t2 = txnFor(opsReturning(v2), new RecordingConnectorContext()); + t2.beginWrite(SESSION, "db1", "v2", deleteCtx()); + Assertions.assertFalse(t2.shouldRewritePreviousDeleteFiles(), "format-version 2 has no DV rewrite"); + + Table v3 = catalog.createTable(TableIdentifier.of("db1", "v3"), SCHEMA, + PartitionSpec.unpartitioned(), props("format-version", "3")); + IcebergConnectorTransaction t3 = txnFor(opsReturning(v3), new RecordingConnectorContext()); + t3.beginWrite(SESSION, "db1", "v3", deleteCtx()); + Assertions.assertTrue(t3.shouldRewritePreviousDeleteFiles(), "format-version 3 enables DV rewrite"); + } + + @Test + public void collectRewrittenDeleteFilesOnlyForTouchedDataFiles() { + // The re-derive is keyed by the data files this commit touched (referencedDataFilePath): the existing DV + // of a data file the commit did NOT touch must not be returned (nor removeDeletes-ed). + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + PartitionSpec spec = PartitionSpec.unpartitioned(); + String data1 = "s3://b/db1/t1/data-1.parquet"; + String data2 = "s3://b/db1/t1/data-2.parquet"; + Table table = catalog.createTable(id, SCHEMA, spec, props("format-version", "3")); + table.newAppend() + .appendFile(dataFile(spec, data1, 10L)) + .appendFile(dataFile(spec, data2, 10L)) + .commit(); + table.newRowDelta() + .addDeletes(deletionVector(spec, "s3://b/db1/t1/dv-1.puffin", data1, 0L, 64L)) + .addDeletes(deletionVector(spec, "s3://b/db1/t1/dv-2.puffin", data2, 0L, 64L)) + .commit(); + + IcebergConnectorTransaction txn = + txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", deleteCtx()); + + // Only data-1 is touched by this commit. + List rewritten = txn.collectRewrittenDeleteFiles( + Collections.singletonList(positionDeleteItem("s3://b/db1/t1/new-del.puffin", 1L, data1))); + + Assertions.assertEquals(1, rewritten.size(), "only the touched data file's existing delete is collected"); + Assertions.assertEquals("s3://b/db1/t1/dv-1.puffin", rewritten.get(0).path().toString()); + } + + @Test + public void collectRewrittenDeleteFilesKeepsDistinctDeletionVectorsSharingOnePuffin() { + // DV-T04 parity preserved under the re-derive: buildDeleteFileDedupKey keys PUFFIN deletion vectors by + // path#contentOffset#contentSizeInBytes, NOT the bare path. Two DVs packed into the SAME puffin file (one + // per data file, distinct offset/size) must BOTH survive — keying by bare path would silently merge them + // and drop one data file's DV from removeDeletes. + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + PartitionSpec spec = PartitionSpec.unpartitioned(); + String data1 = "s3://b/db1/t1/data-1.parquet"; + String data2 = "s3://b/db1/t1/data-2.parquet"; + String puffin = "s3://b/db1/t1/deletes.puffin"; + Table table = catalog.createTable(id, SCHEMA, spec, props("format-version", "3")); + table.newAppend() + .appendFile(dataFile(spec, data1, 10L)) + .appendFile(dataFile(spec, data2, 10L)) + .commit(); + table.newRowDelta() + .addDeletes(deletionVector(spec, puffin, data1, 0L, 64L)) + .addDeletes(deletionVector(spec, puffin, data2, 64L, 80L)) + .commit(); + + IcebergConnectorTransaction txn = + txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", deleteCtx()); + + List rewritten = txn.collectRewrittenDeleteFiles(Arrays.asList( + positionDeleteItem("s3://b/db1/t1/new-1.puffin", 1L, data1), + positionDeleteItem("s3://b/db1/t1/new-2.puffin", 1L, data2))); + + Assertions.assertEquals(2, rewritten.size(), + "two DVs in one puffin file with distinct (offset,size) are both kept (key includes offset/size)"); + } + + @Test + public void collectRewrittenDeleteFilesRemovesBothLegacyAndDeletionVectorForUpgradedTable() { + // Intentional divergence from Trino, locked in: on a v2->v3 upgraded table where one data file carries + // BOTH a legacy file-scoped position delete AND a deletion vector, BOTH must be returned (and removed). + // Doris's BE unions the old positions from both kinds into the new DV, so both old files are superseded; + // Trino instead suppresses the legacy file once a DV exists. A regression toward the Trino behavior + // (dropping the legacy file) would return 1 here. + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + PartitionSpec spec = PartitionSpec.unpartitioned(); + String data1 = "s3://b/db1/t1/data-1.parquet"; + Table table = catalog.createTable(id, SCHEMA, spec, props("format-version", "2")); + table.newAppend().appendFile(dataFile(spec, data1, 10L)).commit(); + // v2: a legacy parquet file-scoped position delete for data-1. + table.newRowDelta().addDeletes(fileScopedDelete(spec, "s3://b/db1/t1/legacy.parquet", data1)).commit(); + // upgrade to v3, then add a deletion vector for the SAME data file (both survive in the delete manifests). + catalog.loadTable(id).updateProperties().set("format-version", "3").commit(); + catalog.loadTable(id).newRowDelta() + .addDeletes(deletionVector(spec, "s3://b/db1/t1/dv.puffin", data1, 0L, 64L)) + .commit(); + + IcebergConnectorTransaction txn = + txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", deleteCtx()); + + List rewritten = txn.collectRewrittenDeleteFiles( + Collections.singletonList(positionDeleteItem("s3://b/db1/t1/new-del.puffin", 1L, data1))); + + Assertions.assertEquals(2, rewritten.size(), + "both the legacy file-scoped delete and the deletion vector for the touched data file are removed " + + "(Doris's BE unions both into the new DV; unlike Trino which suppresses the legacy file)"); + } + + @Test + public void collectRewrittenDeleteFilesEmptyWhenCommitCarriesNoReferencedDataFile() { + // The keystone is TIcebergCommitData.referencedDataFilePath; a delete fragment without it contributes no + // touched data file, so nothing is re-derived (mirrors a data-only fragment). + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + PartitionSpec spec = PartitionSpec.unpartitioned(); + String data1 = "s3://b/db1/t1/data-1.parquet"; + Table table = catalog.createTable(id, SCHEMA, spec, props("format-version", "3")); + table.newAppend().appendFile(dataFile(spec, data1, 10L)).commit(); + table.newRowDelta() + .addDeletes(deletionVector(spec, "s3://b/db1/t1/dv-1.puffin", data1, 0L, 64L)) + .commit(); + + IcebergConnectorTransaction txn = + txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", deleteCtx()); + + TIcebergCommitData noRef = new TIcebergCommitData(); + noRef.setFilePath("s3://b/db1/t1/new-del.puffin"); + noRef.setRowCount(1L); + noRef.setFileContent(TFileContent.POSITION_DELETES); + + Assertions.assertTrue(txn.collectRewrittenDeleteFiles(Collections.singletonList(noRef)).isEmpty(), + "a delete fragment with no referencedDataFilePath touches no data file -> nothing to rewrite"); + } + + @Test + public void collectRewrittenDeleteFilesReDerivesFromBaseSnapshotManifest() { + // Trino-style commit-time re-derive (option D): the old file-scoped delete files to removeDeletes are + // read from the base snapshot's delete manifests (metadata-only), keyed by the data-file paths the + // commit touched (TIcebergCommitData.referencedDataFilePath) — NOT from any scan-time map. + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + PartitionSpec spec = PartitionSpec.unpartitioned(); + String dataPath = "s3://b/db1/t1/data-1.parquet"; + Table table = catalog.createTable(id, SCHEMA, spec, props("format-version", "3")); + table.newAppend().appendFile(dataFile(spec, dataPath, 10L)).commit(); + // Seed an existing file-scoped deletion vector for the data file into the snapshot beginWrite will pin. + table.newRowDelta() + .addDeletes(deletionVector(spec, "s3://b/db1/t1/old.puffin", dataPath, 0L, 64L)) + .commit(); + + IcebergConnectorTransaction txn = + txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", deleteCtx()); + + List rewritten = txn.collectRewrittenDeleteFiles( + Collections.singletonList(positionDeleteItem("s3://b/db1/t1/new-del.puffin", 1L, dataPath))); + + Assertions.assertEquals(1, rewritten.size(), + "the existing file-scoped delete for the touched data file is re-derived from the base snapshot"); + Assertions.assertEquals("s3://b/db1/t1/old.puffin", rewritten.get(0).path().toString()); + } + + @Test + public void applyWriteConstraintConvertedLazilyAtCommit() { + InMemoryCatalog catalog = freshCatalog(); + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("region").build(); + Table table = catalog.createTable(TableIdentifier.of("db1", "t1"), PART_SCHEMA, spec); + + IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext()); + // O5-2: a target-only neutral predicate region = 'us'. + ConnectorComparison eq = new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef("region", ConnectorType.of("UNKNOWN")), + new ConnectorLiteral(ConnectorType.of("VARCHAR"), "us")); + txn.applyWriteConstraint(new ConnectorPredicate(eq)); + + Optional expr = txn.buildWriteConstraintExpression(table); + Assertions.assertTrue(expr.isPresent(), "the stashed write constraint is converted at commit time"); + Assertions.assertEquals(Expressions.equal("region", "us").toString(), expr.get().toString(), + "applyWriteConstraint converts the neutral predicate via IcebergPredicateConverter"); + } + + @Test + public void buildWriteConstraintExpressionEmptyWhenNoConstraint() { + InMemoryCatalog catalog = freshCatalog(); + Table table = catalog.createTable(TableIdentifier.of("db1", "t1"), SCHEMA, PartitionSpec.unpartitioned()); + IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext()); + + Assertions.assertFalse(txn.buildWriteConstraintExpression(table).isPresent(), + "no applyWriteConstraint -> no conflict-detection query filter"); + + txn.applyWriteConstraint(new ConnectorPredicate(null)); + Assertions.assertFalse(txn.buildWriteConstraintExpression(table).isPresent(), + "a null inner expression -> empty"); + } + + @Test + public void buildWriteConstraintUsesConflictMatrixNotScanMatrix() { + // T07b: the O5-2 path must convert in conflict mode, whose matrix differs from scan pushdown. + // IS NULL / BETWEEN are *dropped* by scan pushdown but *pushed* for conflict detection; their + // presence here proves buildWriteConstraintExpression selects conflict mode. + InMemoryCatalog catalog = freshCatalog(); + Table table = catalog.createTable( + TableIdentifier.of("db1", "t1"), PART_SCHEMA, PartitionSpec.unpartitioned()); + IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext()); + + txn.applyWriteConstraint(new ConnectorPredicate( + new ConnectorIsNull(new ConnectorColumnRef("region", ConnectorType.of("UNKNOWN")), false))); + Assertions.assertEquals(Expressions.isNull("region").toString(), + txn.buildWriteConstraintExpression(table).map(Expression::toString).orElse(null)); + + txn.applyWriteConstraint(new ConnectorPredicate(new ConnectorBetween( + new ConnectorColumnRef("id", ConnectorType.of("UNKNOWN")), + new ConnectorLiteral(ConnectorType.of("INT"), 1L), + new ConnectorLiteral(ConnectorType.of("INT"), 9L)))); + Assertions.assertEquals( + Expressions.and(Expressions.greaterThanOrEqual("id", 1), Expressions.lessThanOrEqual("id", 9)) + .toString(), + txn.buildWriteConstraintExpression(table).map(Expression::toString).orElse(null)); + } + + @Test + public void buildWriteConstraintAndsMultipleConjuncts() { + // A top-level ConnectorAnd (as the extractor produces for >1 target conjunct) is flattened and + // re-ANDed; each conjunct is converted in conflict mode (the IS NULL arm would be dropped by scan). + InMemoryCatalog catalog = freshCatalog(); + Table table = catalog.createTable( + TableIdentifier.of("db1", "t1"), PART_SCHEMA, PartitionSpec.unpartitioned()); + IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext()); + + ConnectorComparison eq = new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef("region", ConnectorType.of("UNKNOWN")), + new ConnectorLiteral(ConnectorType.of("VARCHAR"), "us")); + ConnectorIsNull isNull = new ConnectorIsNull( + new ConnectorColumnRef("id", ConnectorType.of("UNKNOWN")), false); + txn.applyWriteConstraint(new ConnectorPredicate(new ConnectorAnd(java.util.Arrays.asList(eq, isNull)))); + + Expression expected = Expressions.and(Expressions.equal("region", "us"), Expressions.isNull("id")); + Assertions.assertEquals(expected.toString(), + txn.buildWriteConstraintExpression(table).map(Expression::toString).orElse(null)); + } + + // ─────────────────── rewrite_data_files: WriteOperation.REWRITE variant (P6.4-T06, dormant) ─────────────────── + + @Test + public void rewriteCapturesStartingSnapshotIdAtBegin() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/seed.parquet", 3L)).commit(); + Table reloaded = catalog.loadTable(id); + long expected = reloaded.currentSnapshot().snapshotId(); + + IcebergConnectorTransaction txn = txnFor(opsReturning(reloaded), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); + + // legacy IcebergTransaction.beginRewrite:187-188 captures the current snapshot for the commit-time + // validateFromSnapshot OCC anchor; REWRITE does NOT pin baseSnapshotId (that drives the RowDelta path). + Assertions.assertEquals(expected, txn.getStartingSnapshotId()); + Assertions.assertNull(txn.getBaseSnapshotId(), "REWRITE uses startingSnapshotId, not baseSnapshotId"); + } + + @Test + public void rewriteOnEmptyTableCapturesSentinelStartingSnapshot() { + InMemoryCatalog catalog = freshCatalog(); + Table table = catalog.createTable(TableIdentifier.of("db1", "t1"), SCHEMA, PartitionSpec.unpartitioned()); + IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); + // No current snapshot -> -1L sentinel passed verbatim to validateFromSnapshot (legacy beginRewrite:188). + Assertions.assertEquals(-1L, txn.getStartingSnapshotId()); + } + + @Test + public void beginWriteIsBeginOnceForSharedRewriteTransaction() { + // A distributed rewrite runs N per-group writes that SHARE one transaction; each group's plan-time + // sink planWrite calls beginWrite again (concurrently). The begin-once guard must load the table + + // open the SDK transaction + pin the OCC snapshot EXACTLY ONCE; the rest are no-ops. + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/seed.parquet", 3L)).commit(); + Table reloaded = catalog.loadTable(id); + long expected = reloaded.currentSnapshot().snapshotId(); + RecordingIcebergCatalogOps ops = opsReturning(reloaded); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergConnectorTransaction txn = txnFor(ops, ctx); + + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); + Object firstSdkTxn = txn.getTransaction(); + int authAfterFirst = ctx.authCount; + long loadsAfterFirst = ops.log.stream().filter("loadTable:db1.t1"::equals).count(); + + // Subsequent concurrent group writes must reuse the shared state, not rebuild it. + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); + + Assertions.assertSame(firstSdkTxn, txn.getTransaction(), "shared SDK transaction must not be rebuilt"); + Assertions.assertEquals(authAfterFirst, ctx.authCount, "begin-once: no extra auth-wrapped begins"); + Assertions.assertEquals(loadsAfterFirst, ops.log.stream().filter("loadTable:db1.t1"::equals).count(), + "begin-once: the table must be loaded exactly once"); + Assertions.assertEquals(expected, txn.getStartingSnapshotId(), "OCC anchor must stay pinned to S1"); + } + + @Test + public void registerRewriteSourceFilesBeforeBeginFailsLoud() { + // registerRewriteSourceFiles re-derives the source files from the table at the pinned snapshot, both + // loaded by beginWrite. The driver must register only AFTER a group's write began the transaction; + // calling it before begin must fail loud, not NPE on table.newScan(). + InMemoryCatalog catalog = freshCatalog(); + Table table = catalog.createTable(TableIdentifier.of("db1", "t1"), SCHEMA, PartitionSpec.unpartitioned()); + IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext()); + + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> txn.registerRewriteSourceFiles(new HashSet<>(Arrays.asList("s3://b/db1/t1/x.parquet")))); + Assertions.assertTrue(ex.getMessage().contains("before the rewrite transaction began"), + "must fail loud with the begin-ordering message, got: " + ex.getMessage()); + } + + @Test + public void rewriteCommitsReplaceDeletingOldAddingNew() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), + props("write.format.default", "parquet")); + // Seed two small data files — the bin-packed group to compact. + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db1/t1/old1.parquet", 5L)) + .appendFile(dataFile(table.spec(), "s3://b/db1/t1/old2.parquet", 7L)) + .commit(); + Table reloaded = catalog.loadTable(id); + List oldFiles = currentDataFiles(reloaded); + Assertions.assertEquals(2, oldFiles.size()); + + IcebergConnectorTransaction txn = txnFor(opsReturning(reloaded), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); + txn.updateRewriteFiles(oldFiles); // files-to-delete (planner FileScanTask.file()) + // BE reports one new compacted data file (same commitDataList channel INSERT uses). + txn.addCommitData(commitBytes(dataFileItem("s3://b/db1/t1/compacted.parquet", 12L, 2048L))); + long beforeCommit = reloadCurrentSnapshot(catalog, id).snapshotId(); + + txn.commit(); + + Assertions.assertNotEquals(beforeCommit, reloadCurrentSnapshot(catalog, id).snapshotId(), + "commit() must produce a new snapshot"); + + Snapshot snap = reloadCurrentSnapshot(catalog, id); + Assertions.assertNotNull(snap); + Assertions.assertEquals("replace", snap.operation(), + "RewriteFiles (newRewrite) produces a replace snapshot, not append/overwrite"); + Assertions.assertEquals("2", snap.summary().get("deleted-data-files")); + Assertions.assertEquals("1", snap.summary().get("added-data-files")); + } + + @Test + public void rewriteDeleteOnlyStillCommitsReplace() { + // The both-empty skip is the AND (filesToDelete.isEmpty() && filesToAdd.isEmpty(), port :391 / + // legacy updateManifestAfterRewrite:248). A delete-only rewrite (files to delete, no new files added) + // has filesToDelete non-empty AND filesToAdd empty, so the skip MUST NOT fire — an &&->|| mutation + // would short-circuit on the empty filesToAdd and silently drop the deletes, leaving the snapshot + // unchanged. The assertNotEquals below goes RED under that mutation. + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), + props("write.format.default", "parquet")); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db1/t1/old1.parquet", 5L)) + .appendFile(dataFile(table.spec(), "s3://b/db1/t1/old2.parquet", 7L)) + .commit(); + Table reloaded = catalog.loadTable(id); + List oldFiles = currentDataFiles(reloaded); + Assertions.assertEquals(2, oldFiles.size()); + + IcebergConnectorTransaction txn = txnFor(opsReturning(reloaded), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); + txn.updateRewriteFiles(oldFiles); // files-to-delete; NO commit data -> filesToAdd empty + long beforeSnapshotId = reloadCurrentSnapshot(catalog, id).snapshotId(); + + txn.commit(); + + long afterSnapshotId = reloadCurrentSnapshot(catalog, id).snapshotId(); + Assertions.assertNotEquals(beforeSnapshotId, afterSnapshotId, + "a delete-only rewrite must still produce a new snapshot (&& skip, not ||)"); + + Snapshot snap = reloadCurrentSnapshot(catalog, id); + Assertions.assertNotNull(snap); + // RewriteFiles always reports "replace", even when only deleting. + Assertions.assertEquals("replace", snap.operation()); + Assertions.assertEquals("2", snap.summary().get("deleted-data-files")); + Assertions.assertNull(snap.summary().get("added-data-files"), + "no new files were added -> the summary carries no added-data-files key"); + } + + @Test + public void rewriteFailsLoudWhenRewrittenFileRemovedConcurrently() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), + props("write.format.default", "parquet")); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/old.parquet", 5L)).commit(); + Table reloaded = catalog.loadTable(id); + List oldFiles = currentDataFiles(reloaded); + + IcebergConnectorTransaction txn = txnFor(opsReturning(reloaded), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); // pins startingSnapshotId = S1 + txn.updateRewriteFiles(oldFiles); + + // A concurrent writer removes the very file we are about to rewrite, advancing the table past S1. + DeleteFiles concurrent = catalog.loadTable(id).newDelete(); + oldFiles.forEach(f -> concurrent.deleteFile(f.path())); + concurrent.commit(); + + txn.addCommitData(commitBytes(dataFileItem("s3://b/db1/t1/compacted.parquet", 5L, 2048L))); + + // commit() runs newRewrite().validateFromSnapshot(S1).deleteFile(old).commit(); the concurrent removal of a + // rewritten file is a conflict -> the SDK throws -> the connector wraps it as DorisConnectorException. + Assertions.assertThrows(DorisConnectorException.class, txn::commit, + "rewriting a data file removed since the pinned snapshot must fail loud"); + } + + @Test + public void rewriteDetectsConcurrentDeleteOnRewrittenFile() { + // A concurrent writer adds a position-delete file targeting the very data file we are rewriting. The + // rewrite must fail loud rather than silently drop that concurrent delete (a data-loss bug). This pins + // the conflict-detection BEHAVIOR of the REWRITE commit. NOTE (verified by mutation check): the throw is + // raised by iceberg's RewriteFiles machinery from the transaction's begin-time base snapshot, so it does + // NOT isolate the explicit validateFromSnapshot(startingSnapshotId) line — removing that line keeps this + // test green. The explicit call is a byte-faithful legacy port; its distinct cross-refresh value is not + // distinguishable in a single-process offline test (P6.6 docker/concurrent gate). See task record. + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), + props("format-version", "2", "write.format.default", "parquet")); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/old.parquet", 5L)).commit(); + Table reloaded = catalog.loadTable(id); + List oldFiles = currentDataFiles(reloaded); + + IcebergConnectorTransaction txn = txnFor(opsReturning(reloaded), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); // pins startingSnapshotId = S1 + txn.updateRewriteFiles(oldFiles); + + // Concurrent RowDelta adds a position-delete for the rewritten data file, advancing the table past S1. + // The data file itself still exists, so this is detectable ONLY via the from-S1 conflict validation. + catalog.loadTable(id).newRowDelta() + .addDeletes(fileScopedDelete(table.spec(), "s3://b/db1/t1/del.parquet", "s3://b/db1/t1/old.parquet")) + .commit(); + + txn.addCommitData(commitBytes(dataFileItem("s3://b/db1/t1/compacted.parquet", 5L, 2048L))); + + Assertions.assertThrows(DorisConnectorException.class, txn::commit, + "validateFromSnapshot must detect the new delete added to a rewritten data file since the pin"); + } + + @Test + public void rewriteWithNoFilesSkipsRewriteOpButStillCommits() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/seed.parquet", 1L)).commit(); + Table reloaded = catalog.loadTable(id); + long before = reloaded.currentSnapshot().snapshotId(); + + IcebergConnectorTransaction txn = txnFor(opsReturning(reloaded), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); + // No files to delete + no new files -> updateManifestAfterRewrite early-returns (legacy :248-251); + // the (empty) SDK transaction still commits without throwing. + Assertions.assertDoesNotThrow(txn::commit); + Assertions.assertEquals(before, reloadCurrentSnapshot(catalog, id).snapshotId(), + "an empty rewrite must not create a new snapshot"); + } + + @Test + public void rewriteCountAndSizeAccessorsReflectDeletedAndAddedFiles() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), + props("write.format.default", "parquet")); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db1/t1/old1.parquet", 5L)) + .appendFile(dataFile(table.spec(), "s3://b/db1/t1/old2.parquet", 7L)) + .commit(); + Table reloaded = catalog.loadTable(id); + List oldFiles = currentDataFiles(reloaded); + + IcebergConnectorTransaction txn = txnFor(opsReturning(reloaded), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); + txn.updateRewriteFiles(oldFiles); + // filesToDelete is populated at updateRewriteFiles time; each dataFile() helper is 1024 bytes. + Assertions.assertEquals(2, txn.getFilesToDeleteCount()); + Assertions.assertEquals(2048L, txn.getFilesToDeleteSize()); + + txn.addCommitData(commitBytes(dataFileItem("s3://b/db1/t1/compacted.parquet", 12L, 4096L))); + // filesToAdd is populated DURING commit (convertCommitDataToFilesToAdd), NOT at addCommitData: the + // fragment is buffered on commitDataList and only materialized into filesToAdd inside commitRewriteTxn. + // An early-materialization mutation (folding convertCommitDataToFilesToAdd into addCommitData) would + // make these pre-commit reads non-zero, turning these assertions RED. + Assertions.assertEquals(0, txn.getFilesToAddCount(), + "filesToAdd materialized DURING commit, not at addCommitData"); + Assertions.assertEquals(0L, txn.getFilesToAddSize()); + // filesToAdd is populated DURING commit (convertCommitDataToFilesToAdd) — the legacy executor reads + // getFilesToAddCount only AFTER finishRewrite, so these reads must be post-commit. + txn.commit(); + Assertions.assertEquals(1, txn.getFilesToAddCount()); + Assertions.assertEquals(4096L, txn.getFilesToAddSize()); + } + + @Test + public void updateRewriteFilesAccumulatesAcrossCalls() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db1/t1/a.parquet", 1L)) + .appendFile(dataFile(table.spec(), "s3://b/db1/t1/b.parquet", 1L)) + .commit(); + List files = currentDataFiles(catalog.loadTable(id)); + + IcebergConnectorTransaction txn = txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); + // legacy updateRewriteFiles is called once per bin-packed group; the connector accumulates across calls. + txn.updateRewriteFiles(Collections.singletonList(files.get(0))); + txn.updateRewriteFiles(Collections.singletonList(files.get(1))); + Assertions.assertEquals(2, txn.getFilesToDeleteCount()); + } + + @Test + public void registerRewriteSourceFilesResolvesRawPathsToFilesToDelete() { + // The neutral SPI hands the connector only RAW String paths (fe-core cannot pass DataFile); the + // connector re-derives the matching DataFiles from the table at the pinned snapshot. Register a SUBSET + // (2 of 3 committed files) to prove it matches BY PATH, not "delete everything". + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), + props("write.format.default", "parquet")); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db1/t1/old1.parquet", 5L)) + .appendFile(dataFile(table.spec(), "s3://b/db1/t1/old2.parquet", 7L)) + .appendFile(dataFile(table.spec(), "s3://b/db1/t1/keep.parquet", 9L)) + .commit(); + Table reloaded = catalog.loadTable(id); + + IcebergConnectorTransaction txn = txnFor(opsReturning(reloaded), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); + txn.registerRewriteSourceFiles(new HashSet<>(Arrays.asList( + "s3://b/db1/t1/old1.parquet", "s3://b/db1/t1/old2.parquet"))); + + // Resolved exactly the two registered files (1024 bytes each), leaving keep.parquet untouched. + Assertions.assertEquals(2, txn.getFilesToDeleteCount()); + Assertions.assertEquals(2048L, txn.getFilesToDeleteSize()); + } + + @Test + public void registerRewriteSourceFilesRunsRederiveInsideAuthenticator() { + // The pinned-snapshot re-derive reads the manifest list + manifests (remote IO on kerberized HDFS / + // lazy-client S3), so it must run under executeAuthenticated like commit()'s manifest scan — a bare + // planFiles() here reproduces the scan-planning SASL rejection on kerberized deployments. + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), + props("write.format.default", "parquet")); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/old1.parquet", 5L)).commit(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergConnectorTransaction txn = txnFor(opsReturning(catalog.loadTable(id)), ctx); + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); + int authAfterBegin = ctx.authCount; + + txn.registerRewriteSourceFiles(new HashSet<>(Arrays.asList("s3://b/db1/t1/old1.parquet"))); + + Assertions.assertEquals(authAfterBegin + 1, ctx.authCount, + "the planFiles re-derive must run INSIDE executeAuthenticated"); + Assertions.assertEquals(1, txn.getFilesToDeleteCount()); + } + + @Test + public void registerRewriteSourceFilesFailsLoudWhenAuthenticatorThrows() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), + props("write.format.default", "parquet")); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/old1.parquet", 5L)).commit(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergConnectorTransaction txn = txnFor(opsReturning(catalog.loadTable(id)), ctx); + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); + // failAuth throws WITHOUT invoking the task, so a passing test proves the re-derive sits INSIDE the + // authenticator (nothing gets registered), not merely next to it. + ctx.failAuth = true; + + Assertions.assertThrows(DorisConnectorException.class, () -> + txn.registerRewriteSourceFiles(new HashSet<>(Arrays.asList("s3://b/db1/t1/old1.parquet")))); + Assertions.assertEquals(0, txn.getFilesToDeleteCount(), + "no source files may be registered when the authenticator rejects"); + } + + @Test + public void registerRewriteSourceFilesFailsLoudOnUnmatchedPath() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), + props("write.format.default", "parquet")); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/old1.parquet", 5L)).commit(); + Table reloaded = catalog.loadTable(id); + + IcebergConnectorTransaction txn = txnFor(opsReturning(reloaded), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); + // A path absent at the pinned snapshot (stale plan / table moved since planning) must fail loud rather + // than silently delete fewer files than the engine intended. + Assertions.assertThrows(DorisConnectorException.class, () -> + txn.registerRewriteSourceFiles(new HashSet<>(Arrays.asList("s3://b/db1/t1/ghost.parquet")))); + } + + @Test + public void registerRewriteSourceFilesEmptyIsNoOp() { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), + props("write.format.default", "parquet")); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/old1.parquet", 5L)).commit(); + Table reloaded = catalog.loadTable(id); + + IcebergConnectorTransaction txn = txnFor(opsReturning(reloaded), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); + txn.registerRewriteSourceFiles(Collections.emptySet()); + Assertions.assertEquals(0, txn.getFilesToDeleteCount(), "an empty registration is a no-op"); + } + + @Test + public void registerRewriteSourceFilesThenCommitReplacesAndReportsAddedCount() { + // End-to-end via the neutral SPI: register source paths -> re-derive -> commit RewriteFiles. Mirrors + // rewriteCommitsReplaceDeletingOldAddingNew but through registerRewriteSourceFiles, and asserts the + // neutral getRewriteAddedDataFilesCount() reports the BE-added file post-commit (the one rewrite stat + // the engine driver cannot compute from its planning groups). + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), + props("write.format.default", "parquet")); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db1/t1/old1.parquet", 5L)) + .appendFile(dataFile(table.spec(), "s3://b/db1/t1/old2.parquet", 7L)) + .commit(); + Table reloaded = catalog.loadTable(id); + + IcebergConnectorTransaction txn = txnFor(opsReturning(reloaded), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", rewriteCtx()); + txn.registerRewriteSourceFiles(new HashSet<>(Arrays.asList( + "s3://b/db1/t1/old1.parquet", "s3://b/db1/t1/old2.parquet"))); + txn.addCommitData(commitBytes(dataFileItem("s3://b/db1/t1/compacted.parquet", 12L, 4096L))); + + txn.commit(); + + Snapshot snap = reloadCurrentSnapshot(catalog, id); + Assertions.assertEquals("replace", snap.operation()); + Assertions.assertEquals("2", snap.summary().get("deleted-data-files")); + Assertions.assertEquals("1", snap.summary().get("added-data-files")); + Assertions.assertEquals(1, txn.getRewriteAddedDataFilesCount(), + "the neutral SPI reports the post-commit added-data-files count for the driver's result row"); + } + + /** + * Seeds an identity-partitioned table, opens a DELETE that touches partition {@code deleteRegion}, races a + * concurrent data-file append into {@code concurrentRegion}, then commits — exercising the identity-partition + * conflict-detection narrowing end-to-end through {@code commit()}. + */ + private static void runPartitionedDelete(PartitionSpec spec, String deleteRegion, String concurrentRegion) { + InMemoryCatalog catalog = freshCatalog(); + TableIdentifier id = TableIdentifier.of("db1", "t1"); + Table table = catalog.createTable(id, PART_SCHEMA, spec, props("format-version", "2")); + String seedPath = "s3://b/db1/t1/region=" + deleteRegion + "/seed.parquet"; + table.newAppend().appendFile(partitionedDataFile(spec, seedPath, 5L, "region=" + deleteRegion)).commit(); + + IcebergConnectorTransaction txn = txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext()); + txn.beginWrite(SESSION, "db1", "t1", deleteCtx()); + + catalog.loadTable(id).newAppend().appendFile(partitionedDataFile(spec, + "s3://b/db1/t1/region=" + concurrentRegion + "/concurrent.parquet", 7L, + "region=" + concurrentRegion)).commit(); + + TIcebergCommitData del = + positionDeleteItem("s3://b/db1/t1/region=" + deleteRegion + "/del.parquet", 2L, seedPath); + del.setPartitionValues(Collections.singletonList(deleteRegion)); + del.setPartitionSpecId(spec.specId()); + txn.addCommitData(commitBytes(del)); + txn.commit(); + } + + /** Builds an old, file-scoped deletion-vector (PUFFIN) {@link DeleteFile} keyed by path#offset#size. */ + private static DeleteFile deletionVector(PartitionSpec spec, String path, String referencedDataFile, + long contentOffset, long contentSize) { + return FileMetadata.deleteFileBuilder(spec) + .ofPositionDeletes() + .withPath(path) + .withFormat(FileFormat.PUFFIN) + .withFileSizeInBytes(128L) + .withRecordCount(1L) + .withReferencedDataFile(referencedDataFile) + .withContentOffset(contentOffset) + .withContentSizeInBytes(contentSize) + .build(); + } + + /** Builds an old, file-scoped position-delete {@link DeleteFile} (referenced -> ContentFileUtil.isFileScoped). */ + private static DeleteFile fileScopedDelete(PartitionSpec spec, String path, String referencedDataFile) { + return FileMetadata.deleteFileBuilder(spec) + .ofPositionDeletes() + .withPath(path) + .withFormat(FileFormat.PARQUET) + .withFileSizeInBytes(64L) + .withRecordCount(1L) + .withReferencedDataFile(referencedDataFile) + .build(); + } + + /** Minimal {@link ConnectorSession} exposing a time zone (for partition-timestamp parsing); no Mockito. */ + private static final class FakeWriteSession implements ConnectorSession { + private final String timeZone; + + FakeWriteSession(String timeZone) { + this.timeZone = timeZone; + } + + @Override + public String getQueryId() { + return "q"; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return timeZone; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public String getCatalogName() { + return "test"; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorValidatePropertiesTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorValidatePropertiesTest.java new file mode 100644 index 00000000000000..2807b216654559 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorValidatePropertiesTest.java @@ -0,0 +1,185 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * CREATE-CATALOG property validation through the production entry point + * {@link IcebergConnectorProvider#validateProperties(Map)} (called by fe-core + * {@code PluginDrivenExternalCatalog.checkProperties}). Exercises the full path + * resolveFlavor → {@code MetaStoreProviders.bindForType(flavor)} → {@code validate()} on the iceberg + * connector's own classpath (only the iceberg metastore providers are discoverable here). The per-flavor + * verbatim messages/fire-order are pinned in the metastore-iceberg module's tests; this pins the wiring. + */ +public class IcebergConnectorValidatePropertiesTest { + + private static final IcebergConnectorProvider PROVIDER = new IcebergConnectorProvider(); + + private static Map props(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + private static String rejectMessage(Map props) { + return Assertions.assertThrows(IllegalArgumentException.class, + () -> PROVIDER.validateProperties(props)).getMessage(); + } + + @Test + public void restFlavorRulesReachableThroughProvider() { + Assertions.assertEquals("Invalid security type: bogus. Supported values are: none, oauth2", + rejectMessage(props("iceberg.catalog.type", "rest", "iceberg.rest.security.type", "bogus"))); + // valid REST (default none security) accepted. + PROVIDER.validateProperties(props("iceberg.catalog.type", "rest", "iceberg.rest.uri", "http://r")); + } + + @Test + public void glueFlavorRulesReachableThroughProvider() { + Assertions.assertEquals("At least one of glue.access_key or glue.role_arn must be set", + rejectMessage(props("iceberg.catalog.type", "glue", + "glue.endpoint", "https://glue.us-east-1.amazonaws.com"))); + } + + @Test + public void jdbcFlavorRulesReachableThroughProvider() { + Assertions.assertEquals("Property uri is required.", + rejectMessage(props("iceberg.catalog.type", "jdbc"))); + } + + @Test + public void hmsAcceptedWithoutWarehouse() { + // iceberg HMS does not require warehouse (unlike paimon); a bare uri is accepted through the provider. + PROVIDER.validateProperties(props("iceberg.catalog.type", "hms", "hive.metastore.uris", "thrift://h")); + } + + @Test + public void hadoopAndS3TablesAcceptedAsNoOp() { + PROVIDER.validateProperties(props("iceberg.catalog.type", "hadoop", "warehouse", "s3://b/wh")); + PROVIDER.validateProperties(props("iceberg.catalog.type", "s3tables", "warehouse", "arn:aws:s3tables:::bucket")); + } + + @Test + public void hadoopRejectedWithoutWarehouse() { + // M-1/L-1: restore the legacy IcebergHadoopExternalCatalog warehouse-required check at CREATE, with + // the verbatim message. MUTATION: neuter the isEmpty(warehouse) check -> accepted -> red. + Assertions.assertEquals( + "Cannot initialize Iceberg HadoopCatalog because 'warehouse' must not be null or empty", + rejectMessage(props("iceberg.catalog.type", "hadoop"))); + } + + @Test + public void s3TablesAcceptedWithoutWarehouse() { + // s3tables shares the no-op metastore class, but the warehouse gate is HADOOP-only, so a missing + // warehouse is still accepted. MUTATION: drop the "HADOOP".equals(providerName) gate -> s3tables + // starts throwing here -> red. + PROVIDER.validateProperties(props("iceberg.catalog.type", "s3tables")); + } + + @Test + public void unknownFlavorRejected() { + Assertions.assertTrue(rejectMessage(props("iceberg.catalog.type", "nessie")) + .startsWith("No MetaStoreProvider supports")); + } + + @Test + public void missingCatalogTypeRejected() { + // resolveFlavor returns null for a missing iceberg.catalog.type; bindForType(null) fails loudly + // (no iceberg provider claims null), parity with the connector's createCatalog "Missing" guard. + Assertions.assertThrows(IllegalArgumentException.class, + () -> PROVIDER.validateProperties(props("warehouse", "s3://b/wh"))); + } + + @Test + public void metaCacheKnobsRejectedThroughProvider() { + // Restored legacy IcebergExternalCatalog.checkProperties parity: table/manifest ttl-second min -1, + // capacity min 0, enable boolean. Each bad knob is paired with an otherwise-valid HMS catalog (which + // hmsAcceptedWithoutWarehouse proves passes), so the knob is the only variable — the cache check runs + // before flavor/metastore validation. MUTATION: drop checkMetaCacheProperties -> these go green->red. + Assertions.assertEquals( + "The parameter meta.cache.iceberg.table.ttl-second is wrong, value is -2", + rejectMessage(props("iceberg.catalog.type", "hms", "hive.metastore.uris", "thrift://h", + "meta.cache.iceberg.table.ttl-second", "-2"))); + Assertions.assertTrue(rejectMessage(props("iceberg.catalog.type", "hms", "hive.metastore.uris", "thrift://h", + "meta.cache.iceberg.manifest.capacity", "-1")).contains("is wrong")); + Assertions.assertTrue(rejectMessage(props("iceberg.catalog.type", "hms", "hive.metastore.uris", "thrift://h", + "meta.cache.iceberg.table.enable", "maybe")).contains("is wrong")); + } + + @Test + public void validMetaCacheKnobsAcceptedThroughProvider() { + // ttl-second=-1 (no-expiration sentinel, min is -1), 0 (disable), capacity=0, boolean enable all pass. + PROVIDER.validateProperties(props("iceberg.catalog.type", "hms", "hive.metastore.uris", "thrift://h", + "meta.cache.iceberg.table.enable", "false", + "meta.cache.iceberg.table.ttl-second", "-1", + "meta.cache.iceberg.table.capacity", "0", + "meta.cache.iceberg.manifest.enable", "false", + "meta.cache.iceberg.manifest.ttl-second", "0", + "meta.cache.iceberg.manifest.capacity", "1024")); + } + + // ───────────────────────── per-user session (iceberg.rest.session=user, #63068) ───────────────────────── + + @Test + public void userSessionRequiresOauth2SecurityType() { + // A user-session catalog projects each user's own OAuth2 token, so it must declare security.type=oauth2; + // a session=user catalog on the default (none) security is rejected up front. + Assertions.assertEquals("iceberg.rest.session=user requires iceberg.rest.security.type=oauth2", + rejectMessage(props("iceberg.catalog.type", "rest", "iceberg.rest.uri", "http://r", + "iceberg.rest.session", "user"))); + } + + @Test + public void userSessionAcceptedWithOauth2AndNoStaticCredential() { + // #63068 parity: session=user relaxes the "oauth2 requires credential or token" rule — the per-request + // user token supplies identity, so NO static bootstrap credential is required (and none must leak in). + PROVIDER.validateProperties(props("iceberg.catalog.type", "rest", "iceberg.rest.uri", "http://r", + "iceberg.rest.security.type", "oauth2", "iceberg.rest.session", "user")); + } + + @Test + public void nonUserOauth2StillRequiresCredentialOrToken() { + // The relaxation is scoped to session=user: a plain oauth2 catalog with neither credential nor token is + // still rejected (guards against the relaxation widening to shared catalogs). + Assertions.assertEquals("OAuth2 requires either credential or token", + rejectMessage(props("iceberg.catalog.type", "rest", "iceberg.rest.uri", "http://r", + "iceberg.rest.security.type", "oauth2"))); + } + + @Test + public void invalidSessionModeRejected() { + Assertions.assertEquals("Invalid iceberg.rest.session: bogus. Supported values are: none, user", + rejectMessage(props("iceberg.catalog.type", "rest", "iceberg.rest.uri", "http://r", + "iceberg.rest.session", "bogus"))); + } + + @Test + public void invalidDelegatedTokenModeRejected() { + Assertions.assertEquals("Invalid iceberg.rest.oauth2.delegated-token-mode: bogus. " + + "Supported values are: access_token, token_exchange", + rejectMessage(props("iceberg.catalog.type", "rest", "iceberg.rest.uri", "http://r", + "iceberg.rest.oauth2.delegated-token-mode", "bogus"))); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorWorkerPoolPinTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorWorkerPoolPinTest.java new file mode 100644 index 00000000000000..5b06d42a5a760b --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorWorkerPoolPinTest.java @@ -0,0 +1,103 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** + * Verifies the worker-pool split-brain guard {@link IcebergConnector#pinPoolThreadsToClassLoader}. WHY it + * exists: iceberg fans its parallel data-manifest WRITE onto its own shared worker pool, and iceberg-aws builds + * the S3 client lazily on whichever worker thread first writes a manifest — resolving + * {@code ApacheHttpClientConfigurations} via {@code DynMethods} off that thread's context classloader. Pinning + * only the engine commit thread is not enough; EVERY worker thread must carry the plugin loader or the write + * ClassCasts the parent (fe-core) copy against the child-loaded one. This test pins that contract: after the + * helper runs, every distinct thread in the pool reports the target loader as its TCCL. + */ +public class IcebergConnectorWorkerPoolPinTest { + + private static ClassLoader isolatedLoader() { + return new URLClassLoader(new URL[0], IcebergConnectorWorkerPoolPinTest.class.getClassLoader()); + } + + @Test + public void pinsEveryThreadOfThePool() throws Exception { + int poolSize = 4; + ExecutorService pool = Executors.newFixedThreadPool(poolSize); + ClassLoader target = isolatedLoader(); + try { + boolean allReached = IcebergConnector.pinPoolThreadsToClassLoader(pool, poolSize, target, 10); + Assertions.assertTrue(allReached, "every thread of the fixed pool must be reached by a primer"); + + // Sample EACH thread's TCCL: a second barrier forces all poolSize distinct threads to run, so the + // observed set covers the whole pool — the manifest-write path may land on any of them. + Set observed = ConcurrentHashMap.newKeySet(); + CountDownLatch sampled = new CountDownLatch(poolSize); + CountDownLatch hold = new CountDownLatch(1); + for (int i = 0; i < poolSize; i++) { + pool.submit(() -> { + observed.add(Thread.currentThread().getContextClassLoader()); + sampled.countDown(); + hold.await(); + return null; + }); + } + Assertions.assertTrue(sampled.await(10, TimeUnit.SECONDS), "all pool threads must run the sampler"); + hold.countDown(); + + Assertions.assertEquals(Collections.singleton(target), observed, + "every worker thread must carry the pinned plugin loader as its TCCL (none left on 'app')"); + } finally { + pool.shutdownNow(); + } + } + + @Test + public void repinsAThreadAnEarlierUnpinnedUseAlreadyCreated() throws Exception { + // A worker thread created/used before the pin keeps whatever TCCL it had (a pool never resets it between + // tasks); the helper must SET it, not rely on creation-time inheritance. Prime the single thread with a + // foreign loader first, then assert the helper overwrites it. + ExecutorService pool = Executors.newFixedThreadPool(1); + ClassLoader stale = isolatedLoader(); + ClassLoader target = isolatedLoader(); + try { + pool.submit(() -> Thread.currentThread().setContextClassLoader(stale)).get(); + + Assertions.assertTrue(IcebergConnector.pinPoolThreadsToClassLoader(pool, 1, target, 10)); + + ClassLoader[] after = new ClassLoader[1]; + pool.submit(() -> { + after[0] = Thread.currentThread().getContextClassLoader(); + return null; + }).get(); + Assertions.assertSame(target, after[0], "the helper must repin an already-poisoned worker thread"); + } finally { + pool.shutdownNow(); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCountFromSummaryTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCountFromSummaryTest.java new file mode 100644 index 00000000000000..a1e67d790194c4 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCountFromSummaryTest.java @@ -0,0 +1,114 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * FIX-COUNT-NPE (upstream 32a2651f66b, #64648) — pins that + * {@link IcebergScanPlanProvider#getCountFromSummary} is null-safe. + * + *

    WHY: the COUNT(*)-pushdown row count is read from the iceberg snapshot summary's {@code total-records} + * / {@code total-position-deletes} / {@code total-equality-deletes}. A compaction / replace / overwrite + * snapshot can OMIT one of those counters, and the pre-fix code (a faithful hand-port of the legacy, + * null-unsafe {@code IcebergScanNode.getCountFromSnapshot}) NPE-d on {@code summary.get(...).equals("0")} + * / {@code Long.parseLong(null)} — crashing the whole query instead of just declining the pushdown. The fix + * returns the {@code -1} "not pushable / unknown" sentinel (callers gate on {@code >= 0}) when any counter + * is absent. This is the connector-module analog of fe-core {@code IcebergCountPushDownTest}; the SPI + * migration copied the pre-fix logic, so fe-core carrying the fix did not protect the live path here. + */ +public class IcebergCountFromSummaryTest { + + // The three iceberg snapshot-summary counter keys (org.apache.iceberg.SnapshotSummary constants). + private static final String TOTAL_EQUALITY_DELETES = "total-equality-deletes"; + private static final String TOTAL_POSITION_DELETES = "total-position-deletes"; + private static final String TOTAL_RECORDS = "total-records"; + + /** Build a snapshot summary; a {@code null} arg OMITS that key — the exact absence the fix guards. */ + private static Map summary(String equalityDeletes, String positionDeletes, + String totalRecords) { + Map m = new HashMap<>(); + if (equalityDeletes != null) { + m.put(TOTAL_EQUALITY_DELETES, equalityDeletes); + } + if (positionDeletes != null) { + m.put(TOTAL_POSITION_DELETES, positionDeletes); + } + if (totalRecords != null) { + m.put(TOTAL_RECORDS, totalRecords); + } + return m; + } + + @Test + public void missingAnyCounterReturnsMinusOneInsteadOfNpe() { + // The regression: pre-fix each of these threw NPE (get(...).equals / parseLong(null)). Assert for + // BOTH dangling-delete flag values so the guard is proven independent of that branch. + for (boolean ignore : new boolean[] {false, true}) { + Assertions.assertEquals(-1L, + IcebergScanPlanProvider.getCountFromSummary(summary(null, "0", "100"), ignore), + "absent total-equality-deletes must decline pushdown, not NPE"); + Assertions.assertEquals(-1L, + IcebergScanPlanProvider.getCountFromSummary(summary("0", null, "100"), ignore), + "absent total-position-deletes must decline pushdown, not NPE"); + Assertions.assertEquals(-1L, + IcebergScanPlanProvider.getCountFromSummary(summary("0", "0", null), ignore), + "absent total-records must decline pushdown, not NPE"); + Assertions.assertEquals(-1L, + IcebergScanPlanProvider.getCountFromSummary(Collections.emptyMap(), ignore), + "empty summary must decline pushdown, not NPE"); + } + } + + @Test + public void noDeletesPushesTotalRecords() { + Assertions.assertEquals(100L, + IcebergScanPlanProvider.getCountFromSummary(summary("0", "0", "100"), false)); + } + + @Test + public void equalityDeletesNotPushable() { + // Equality deletes re-project at read time; the summary cannot net them out -> not pushable. + Assertions.assertEquals(-1L, + IcebergScanPlanProvider.getCountFromSummary(summary("3", "0", "100"), false)); + Assertions.assertEquals(-1L, + IcebergScanPlanProvider.getCountFromSummary(summary("3", "0", "100"), true)); + } + + @Test + public void positionDeletesHonorDanglingFlag() { + // ignore dangling deletes -> netted count (total - deletes) is pushable; otherwise not pushable. + Assertions.assertEquals(90L, + IcebergScanPlanProvider.getCountFromSummary(summary("0", "10", "100"), true)); + Assertions.assertEquals(-1L, + IcebergScanPlanProvider.getCountFromSummary(summary("0", "10", "100"), false)); + } + + @Test + public void allRowsDeletedNetsToZeroNotSentinel() { + // 100 records, 100 position deletes, ignore=true -> genuine 0. Must NOT collapse to the -1 sentinel + // (a real count of 0 is still a valid, pushable answer). + Assertions.assertEquals(0L, + IcebergScanPlanProvider.getCountFromSummary(summary("0", "100", "100"), true)); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergFormatCacheTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergFormatCacheTest.java new file mode 100644 index 00000000000000..be8483a44e11c7 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergFormatCacheTest.java @@ -0,0 +1,181 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.iceberg.catalog.TableIdentifier; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Unit tests for {@link IcebergFormatCache} (PERF-03). Mirrors {@link IcebergPartitionCacheTest} but keys by + * {@code (TableIdentifier, snapshotId)} and stores the inferred format-name String. Covers within-TTL stability, + * the {@code ttl <= 0} disable, invalidation, and the not-cached-on-failure guarantee that makes a transient + * remote-IO failure retry on the next query (legacy parity). + */ +public class IcebergFormatCacheTest { + + private static IcebergFormatCache.Key key(String db, String tbl, long snapshotId) { + return new IcebergFormatCache.Key(TableIdentifier.of(db, tbl), snapshotId); + } + + @Test + public void cachesWithinTtlAndServesTheSameValue() { + AtomicInteger loads = new AtomicInteger(); + IcebergFormatCache c = new IcebergFormatCache(100, 1000); + + String first = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return "orc"; + }); + // Second read of the SAME (table, snapshot) within TTL returns the cached format, NOT a fresh whole-table + // planFiles() inference -> this is what collapses the per-query #64134 fallback. MUTATION: inferring live + // every call -> returns "parquet" / loads==2 -> red. + String second = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return "parquet"; + }); + Assertions.assertEquals("orc", first); + Assertions.assertEquals("orc", second, "within TTL the cached format must be served"); + Assertions.assertEquals(1, loads.get(), "the live inference must run exactly once within TTL"); + Assertions.assertEquals(1, c.loadCountForTest(), "the metric-gate load count must be 1"); + Assertions.assertTrue(c.isEnabled()); + } + + @Test + public void differentSnapshotIdIsADifferentKey() { + AtomicInteger loads = new AtomicInteger(); + IcebergFormatCache c = new IcebergFormatCache(100, 1000); + c.getOrLoad(key("db", "t", 1L), () -> { + loads.incrementAndGet(); + return "parquet"; + }); + // A new commit yields a new snapshot id -> a distinct key -> a live inference (freshness across snapshots, + // e.g. a rewrite that changed the write format). MUTATION: keying by table only -> loads==1 -> red. + String s2 = c.getOrLoad(key("db", "t", 2L), () -> { + loads.incrementAndGet(); + return "orc"; + }); + Assertions.assertEquals("orc", s2); + Assertions.assertEquals(2, loads.get()); + Assertions.assertEquals(2, c.size()); + } + + @Test + public void ttlZeroDisablesCachingAlwaysLive() { + AtomicInteger loads = new AtomicInteger(); + IcebergFormatCache c = new IcebergFormatCache(0, 1000); + c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return "orc"; + }); + String second = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return "parquet"; + }); + // ttl-second=0 (the no-cache catalog) infers live every time. MUTATION: caching despite ttl<=0 -> + // second=="orc" / loads==1 -> red. + Assertions.assertEquals("parquet", second, "ttl-second=0 must always infer live"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + Assertions.assertEquals(0, c.size(), "ttl-second=0 must not store anything"); + } + + @Test + public void negativeTtlDisablesCachingAlwaysLive() { + AtomicInteger loads = new AtomicInteger(); + IcebergFormatCache c = new IcebergFormatCache(-1, 1000); + c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return "orc"; + }); + String second = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return "parquet"; + }); + Assertions.assertEquals("parquet", second, "ttl-second=-1 must always infer live"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + } + + @Test + public void invalidateDropsAllSnapshotsOfOneTable() { + AtomicInteger loads = new AtomicInteger(); + IcebergFormatCache c = new IcebergFormatCache(100, 1000); + c.getOrLoad(key("db", "t", 1L), () -> "parquet"); + c.getOrLoad(key("db", "t", 2L), () -> "orc"); + c.getOrLoad(key("db", "other", 1L), () -> "parquet"); + Assertions.assertEquals(3, c.size()); + + // REFRESH TABLE db.t must drop BOTH snapshot entries of db.t and leave db.other intact. MUTATION: + // invalidating a single (table, snapshot) key -> the other snapshot survives -> size 2 here -> red. + c.invalidate(TableIdentifier.of("db", "t")); + Assertions.assertEquals(1, c.size(), "only db.other's entry must survive"); + String reload = c.getOrLoad(key("db", "t", 1L), () -> { + loads.incrementAndGet(); + return "orc"; + }); + Assertions.assertEquals("orc", reload); + Assertions.assertEquals(1, loads.get()); + } + + @Test + public void invalidateDbClearsOnlyThatDbsTables() { + IcebergFormatCache c = new IcebergFormatCache(100, 1000); + c.getOrLoad(key("db1", "t1", 1L), () -> "parquet"); + c.getOrLoad(key("db1", "t2", 1L), () -> "orc"); + c.getOrLoad(key("db2", "t1", 1L), () -> "parquet"); + Assertions.assertEquals(3, c.size()); + c.invalidateDb("db1"); + Assertions.assertEquals(1, c.size(), "only db2's entry must survive REFRESH DATABASE db1"); + } + + @Test + public void invalidateAllClearsEverything() { + IcebergFormatCache c = new IcebergFormatCache(100, 1000); + c.getOrLoad(key("db", "t1", 1L), () -> "parquet"); + c.getOrLoad(key("db", "t2", 1L), () -> "orc"); + Assertions.assertEquals(2, c.size()); + c.invalidateAll(); + Assertions.assertEquals(0, c.size()); + } + + @Test + public void loaderFailureIsNotCachedSoNextQueryRetries() { + // A transient remote-IO failure during inference (planFiles/close) must NOT be memoized, or the parquet + // fallback would stick for the whole TTL. The MetaCacheEntry manual-miss-load path re-throws the loader's + // RuntimeException verbatim and does not store it. MUTATION: caching on failure -> the second call would + // not re-run the loader / size==1 -> red. + IcebergFormatCache c = new IcebergFormatCache(100, 1000); + AtomicInteger loads = new AtomicInteger(); + Assertions.assertThrows(RuntimeException.class, () -> c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + throw new RuntimeException("transient manifest read failure"); + })); + Assertions.assertEquals(0, c.size(), "a failed inference must not be cached"); + // The next query at the same key re-runs the loader (retry), and a success is then cached. + String ok = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return "orc"; + }); + Assertions.assertEquals("orc", ok); + Assertions.assertEquals(2, loads.get(), "the loader must re-run after a failure (no sticky failure)"); + Assertions.assertEquals(1, c.size()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergLatestSnapshotCacheTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergLatestSnapshotCacheTest.java new file mode 100644 index 00000000000000..c592472ffffb08 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergLatestSnapshotCacheTest.java @@ -0,0 +1,172 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.iceberg.catalog.TableIdentifier; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Unit tests for {@link IcebergLatestSnapshotCache} (mirrors PaimonLatestSnapshotCacheTest). The cache is now + * backed by the shared {@link org.apache.doris.connector.cache.MetaCacheEntry} framework; these tests cover the + * adapter's contract — within-TTL stability, the {@code ttl <= 0} disable, and invalidation. Timed-expiry + * mechanics are the framework's responsibility (the ttl→duration mapping is unit-tested in the framework + * module's {@code CacheSpecTest}; Caffeine {@code expireAfterAccess} itself is the library's behavior), so they + * are not re-proven here (no injectable clock). + */ +public class IcebergLatestSnapshotCacheTest { + + private static TableIdentifier id() { + return TableIdentifier.of("db", "t"); + } + + @Test + public void cachesWithinTtlAndServesStaleSnapshot() { + AtomicInteger loads = new AtomicInteger(); + IcebergLatestSnapshotCache c = new IcebergLatestSnapshotCache(100, 1000); + + IcebergLatestSnapshotCache.CachedSnapshot first = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return new IcebergLatestSnapshotCache.CachedSnapshot(1L, 11L); + }); + // Second read within TTL must return the CACHED snapshot (1/11), NOT the new live one (2/22) -> this is + // what pins a query to the old snapshot after an external write. MUTATION: serving live every call -> + // returns 2/22 -> red. + IcebergLatestSnapshotCache.CachedSnapshot second = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return new IcebergLatestSnapshotCache.CachedSnapshot(2L, 22L); + }); + Assertions.assertEquals(1L, first.snapshotId); + Assertions.assertEquals(11L, first.schemaId); + Assertions.assertEquals(1L, second.snapshotId, "within TTL the cached snapshot id must be served"); + // BOTH ids must be pinned atomically. MUTATION: caching only snapshotId and re-reading schemaId live -> + // second.schemaId == 22 -> red. This is the iceberg-specific deviation from the paimon long-only cache. + Assertions.assertEquals(11L, second.schemaId, "within TTL the cached schema id must be served too"); + Assertions.assertEquals(1, loads.get(), "the live loader must run exactly once within TTL"); + Assertions.assertTrue(c.isEnabled()); + } + + @Test + public void ttlZeroDisablesCachingAlwaysLive() { + AtomicInteger loads = new AtomicInteger(); + IcebergLatestSnapshotCache c = new IcebergLatestSnapshotCache(0, 1000); + c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return new IcebergLatestSnapshotCache.CachedSnapshot(1L, 11L); + }); + IcebergLatestSnapshotCache.CachedSnapshot second = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return new IcebergLatestSnapshotCache.CachedSnapshot(2L, 22L); + }); + // ttl-second=0 (the no-cache catalog) must read live every time. MUTATION: caching despite ttl<=0 -> + // loads==1 / second==1 -> red. + Assertions.assertEquals(2L, second.snapshotId, "ttl-second=0 must always read the live snapshot"); + Assertions.assertEquals(22L, second.schemaId); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + Assertions.assertEquals(0, c.size(), "ttl-second=0 must not store anything"); + } + + @Test + public void negativeTtlDisablesCachingAlwaysLive() { + // ttl-second=-1 (or any negative) is still the no-cache catalog. This guards the CacheSpec trap where + // ttl == -1 means "no expiration (enabled)": the adapter must translate "<= 0" to disabled, NOT pass + // -1 through. MUTATION: passing ttlSeconds straight into CacheSpec -> -1 becomes a never-expiring cache + // -> loads==1 / second==1 -> red. + AtomicInteger loads = new AtomicInteger(); + IcebergLatestSnapshotCache c = new IcebergLatestSnapshotCache(-1, 1000); + c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return new IcebergLatestSnapshotCache.CachedSnapshot(1L, 11L); + }); + IcebergLatestSnapshotCache.CachedSnapshot second = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return new IcebergLatestSnapshotCache.CachedSnapshot(2L, 22L); + }); + Assertions.assertEquals(2L, second.snapshotId, "ttl-second=-1 must always read the live snapshot"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + } + + @Test + public void invalidateForcesReload() { + AtomicInteger loads = new AtomicInteger(); + IcebergLatestSnapshotCache c = new IcebergLatestSnapshotCache(100, 1000); + c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return new IcebergLatestSnapshotCache.CachedSnapshot(1L, 11L); + }); + c.invalidate(id()); + // After REFRESH TABLE invalidation the next read goes live (sees 2). MUTATION: invalidate not clearing + // -> returns cached 1 / loads==1 -> red. + IcebergLatestSnapshotCache.CachedSnapshot after = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return new IcebergLatestSnapshotCache.CachedSnapshot(2L, 22L); + }); + Assertions.assertEquals(2L, after.snapshotId); + Assertions.assertEquals(2, loads.get()); + } + + @Test + public void invalidateAllClearsEverything() { + IcebergLatestSnapshotCache c = new IcebergLatestSnapshotCache(100, 1000); + c.getOrLoad(TableIdentifier.of("db", "t1"), + () -> new IcebergLatestSnapshotCache.CachedSnapshot(1L, 11L)); + c.getOrLoad(TableIdentifier.of("db", "t2"), + () -> new IcebergLatestSnapshotCache.CachedSnapshot(2L, 22L)); + Assertions.assertEquals(2, c.size()); + c.invalidateAll(); + Assertions.assertEquals(0, c.size()); + } + + @Test + public void invalidateDbClearsOnlyThatDbsTables() { + AtomicInteger loads = new AtomicInteger(); + IcebergLatestSnapshotCache c = new IcebergLatestSnapshotCache(100, 1000); + c.getOrLoad(TableIdentifier.of("db1", "t1"), + () -> new IcebergLatestSnapshotCache.CachedSnapshot(1L, 11L)); + c.getOrLoad(TableIdentifier.of("db1", "t2"), + () -> new IcebergLatestSnapshotCache.CachedSnapshot(2L, 22L)); + c.getOrLoad(TableIdentifier.of("db2", "t1"), + () -> new IcebergLatestSnapshotCache.CachedSnapshot(3L, 33L)); + Assertions.assertEquals(3, c.size()); + + // REFRESH DATABASE db1 (or a Doris DROP DATABASE db1) must drop BOTH db1 tables and leave db2 intact. + // MUTATION: invalidateDb a no-op (the inherited SPI default this fix replaces) -> db1.t1 still cached + // -> loads stays 0 / after.snapshotId == 1 -> red. + c.invalidateDb("db1"); + Assertions.assertEquals(1, c.size(), "only db2's single entry must survive"); + + IcebergLatestSnapshotCache.CachedSnapshot afterDb1 = c.getOrLoad(TableIdentifier.of("db1", "t1"), () -> { + loads.incrementAndGet(); + return new IcebergLatestSnapshotCache.CachedSnapshot(9L, 99L); + }); + Assertions.assertEquals(9L, afterDb1.snapshotId, "db1.t1 must reload live after invalidateDb"); + Assertions.assertEquals(1, loads.get()); + + // db2 was untouched: still the original pin, no reload. + IcebergLatestSnapshotCache.CachedSnapshot db2 = c.getOrLoad(TableIdentifier.of("db2", "t1"), () -> { + loads.incrementAndGet(); + return new IcebergLatestSnapshotCache.CachedSnapshot(7L, 77L); + }); + Assertions.assertEquals(3L, db2.snapshotId, "db2 must keep its cached pin (not dropped by invalidateDb(db1))"); + Assertions.assertEquals(1, loads.get(), "db2 read must be a hit (no extra load)"); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergLiveConnectivityTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergLiveConnectivityTest.java new file mode 100644 index 00000000000000..1c37bf56a664c8 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergLiveConnectivityTest.java @@ -0,0 +1,92 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.spi.ConnectorContext; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Live Iceberg connectivity smoke (user-run), mirroring {@code PaimonLiveConnectivityTest}. + * + *

    Complements the offline {@link IcebergConnectorMetadataTest}: this one confirms a real + * {@link org.apache.iceberg.catalog.Catalog} built from {@link IcebergConnector} can actually be reached + * and listed through the production seam. It is skipped unless {@code ICEBERG_REST_URI} is set, so + * it is inert in CI and never hard-codes an endpoint. + * + *

    + *   ICEBERG_REST_URI=http://host:8181 [ICEBERG_WAREHOUSE=s3://bucket/wh] \
    + *   mvn -pl :fe-connector-iceberg test -Dtest=IcebergLiveConnectivityTest
    + * 
    + */ +public class IcebergLiveConnectivityTest { + + /** Minimal context: simple auth (default executeAuthenticated) and an empty environment. */ + private static ConnectorContext testContext() { + return new ConnectorContext() { + @Override + public String getCatalogName() { + return "iceberg_live"; + } + + @Override + public long getCatalogId() { + return 1L; + } + + @Override + public Map getEnvironment() { + return Collections.emptyMap(); + } + }; + } + + @Test + public void liveMetadataRoundTrip() { + String restUri = System.getenv("ICEBERG_REST_URI"); + Assumptions.assumeTrue(restUri != null && !restUri.isEmpty(), + "skipped: set ICEBERG_REST_URI (and optionally ICEBERG_WAREHOUSE) to run live"); + + String warehouse = System.getenv("ICEBERG_WAREHOUSE"); + + Map props = new HashMap<>(); + props.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + props.put("iceberg.rest.uri", restUri); + if (warehouse != null && !warehouse.isEmpty()) { + props.put("warehouse", warehouse); + } + + // Exercise the full production path: IcebergConnector lazily builds a real Catalog and wires the + // CatalogBackedIcebergCatalogOps seam into the metadata. One listDatabaseNames round-trip confirms + // the catalog is reachable end to end. + try (IcebergConnector connector = new IcebergConnector(props, testContext())) { + ConnectorMetadata metadata = connector.getMetadata(null); + Assertions.assertNotNull(metadata.listDatabaseNames(null), + "a reachable Iceberg REST catalog must return a (possibly empty) database list"); + } catch (Exception e) { + throw new AssertionError("live Iceberg round-trip failed for REST uri " + restUri, e); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergManifestCacheTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergManifestCacheTest.java new file mode 100644 index 00000000000000..28fb5019decebb --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergManifestCacheTest.java @@ -0,0 +1,117 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +/** + * Unit tests for {@link IcebergManifestCache} (T08). Uses a real {@link InMemoryCatalog} table so the cache is + * exercised against genuine iceberg {@link ManifestFile}s (no I/O — InMemoryCatalog serves manifests in-memory). + */ +public class IcebergManifestCacheTest { + + private static final Schema SCHEMA = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get())); + + private static Table tableWithTwoDataFiles() { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + Table table = catalog.createTable(TableIdentifier.of("db1", "t1"), SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("/data/f1.parquet").withFileSizeInBytes(100).withRecordCount(1).build()) + .appendFile(DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("/data/f2.parquet").withFileSizeInBytes(200).withRecordCount(2).build()) + .commit(); + return table; + } + + @Test + public void loadsDataFilesAndCachesByManifestPath() { + Table table = tableWithTwoDataFiles(); + List manifests = table.currentSnapshot().dataManifests(table.io()); + Assertions.assertEquals(1, manifests.size(), "the single append produced one data manifest"); + ManifestFile manifest = manifests.get(0); + + IcebergManifestCache cache = new IcebergManifestCache(); + ManifestCacheValue first = cache.getManifestCacheValue(manifest, table); + // WHY: a DATA manifest yields the two appended data files. MUTATION: returning the delete-file branch + // (empty) -> 0 -> red. + Assertions.assertEquals(2, first.getDataFiles().size()); + Assertions.assertTrue(first.getDeleteFiles().isEmpty()); + Assertions.assertEquals(1, cache.size()); + + // A second read of the SAME manifest path returns the SAME cached value (no re-parse). MUTATION: not + // caching (re-loading) -> a different instance -> red. + ManifestCacheValue second = cache.getManifestCacheValue(manifest, table); + Assertions.assertSame(first, second, "the manifest payload must be served from the cache on a repeat"); + Assertions.assertEquals(1, cache.size()); + } + + @Test + public void capacityOverflowFlushesWholesale() { + Table table = tableWithTwoDataFiles(); + ManifestFile manifest = table.currentSnapshot().dataManifests(table.io()).get(0); + // maxSize 1: the first load fills it; a re-read still hits (same key). The wholesale-flush valve is the + // legacy behavior — re-reads are harmless since the value is immutable. Smoke that size stays bounded. + IcebergManifestCache cache = new IcebergManifestCache(1); + cache.getManifestCacheValue(manifest, table); + Assertions.assertEquals(1, cache.size()); + cache.getManifestCacheValue(manifest, table); + Assertions.assertTrue(cache.size() <= 1, "a bounded cache must not grow past its max size"); + } + + @Test + public void invalidateAllClearsEveryEntry() { + Table table = tableWithTwoDataFiles(); + ManifestFile manifest = table.currentSnapshot().dataManifests(table.io()).get(0); + IcebergManifestCache cache = new IcebergManifestCache(); + cache.getManifestCacheValue(manifest, table); + Assertions.assertEquals(1, cache.size()); + // REFRESH CATALOG hook (H-5): invalidateAll drops every cached manifest (legacy catalog-wide + // group.invalidateAll parity). MUTATION: a no-op invalidateAll -> size stays 1 -> red. + cache.invalidateAll(); + Assertions.assertEquals(0, cache.size()); + } + + @Test + public void copiesDataFilesSoReaderReuseDoesNotAlias() { + Table table = tableWithTwoDataFiles(); + ManifestFile manifest = table.currentSnapshot().dataManifests(table.io()).get(0); + List files = new IcebergManifestCache().getManifestCacheValue(manifest, table).getDataFiles(); + // WHY: ManifestReader reuses one object across iterations, so the cache must .copy() each file. Two + // distinct paths prove the entries were copied out (not the same reused instance). MUTATION: dropping + // .copy() -> both entries alias the last file -> both paths equal -> red. + Assertions.assertNotEquals(files.get(0).path().toString(), files.get(1).path().toString()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergManifestEntryKeyTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergManifestEntryKeyTest.java new file mode 100644 index 00000000000000..96371cfa606fac --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergManifestEntryKeyTest.java @@ -0,0 +1,55 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.iceberg.ManifestContent; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link IcebergManifestEntryKey} (T08). The key is path + content; two manifests that share a + * path (across tables) hit the same cache entry, which is what lets a REFRESH TABLE keep the manifest cache. + */ +public class IcebergManifestEntryKeyTest { + + @Test + public void samePathSameContentAreEqual() { + IcebergManifestEntryKey a = new IcebergManifestEntryKey("/m/shared.avro", ManifestContent.DATA); + IcebergManifestEntryKey b = new IcebergManifestEntryKey("/m/shared.avro", ManifestContent.DATA); + // WHY: the path-keyed cache must treat two references to the same manifest path as one entry (cross-table + // sharing). MUTATION: keying on identity instead of (path, content) -> not equal -> red. + Assertions.assertEquals(a, b); + Assertions.assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + public void differentContentSamePathAreNotEqual() { + IcebergManifestEntryKey data = new IcebergManifestEntryKey("/m/x.avro", ManifestContent.DATA); + IcebergManifestEntryKey deletes = new IcebergManifestEntryKey("/m/x.avro", ManifestContent.DELETES); + // A data manifest and a delete manifest are distinct cache entries even at the same path. MUTATION: + // ignoring content -> equal -> red. + Assertions.assertNotEquals(data, deletes); + } + + @Test + public void differentPathAreNotEqual() { + Assertions.assertNotEquals( + new IcebergManifestEntryKey("/m/a.avro", ManifestContent.DATA), + new IcebergManifestEntryKey("/m/b.avro", ManifestContent.DATA)); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionCacheTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionCacheTest.java new file mode 100644 index 00000000000000..576d46151c7a42 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionCacheTest.java @@ -0,0 +1,186 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.iceberg.IcebergPartitionUtils.IcebergRawPartition; + +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.ValidationException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Unit tests for {@link IcebergPartitionCache} (PERF-02). Mirrors {@link IcebergTableCacheTest} but keys by + * {@code (TableIdentifier, snapshotId)} and stores the raw partition list. Covers within-TTL stability, the + * {@code ttl <= 0} disable, invalidation, and the exception-propagation guarantee that {@code listPartitions}' + * dropped-partition-source-column degradation depends on. + */ +public class IcebergPartitionCacheTest { + + private static IcebergPartitionCache.Key key(String db, String tbl, long snapshotId) { + return new IcebergPartitionCache.Key(TableIdentifier.of(db, tbl), snapshotId); + } + + /** A raw partition list of the given size, distinguishable by size. */ + private static List raws(int n) { + List list = new java.util.ArrayList<>(); + for (int i = 0; i < n; i++) { + list.add(new IcebergRawPartition("p" + i, Collections.singletonList("c"), + Collections.singletonList("v" + i), Collections.singletonList("identity"), 0L, 0L)); + } + return list; + } + + @Test + public void cachesWithinTtlAndServesTheSameList() { + AtomicInteger loads = new AtomicInteger(); + IcebergPartitionCache c = new IcebergPartitionCache(100, 1000); + + List first = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return raws(3); + }); + // Second read of the SAME (table, snapshot) within TTL returns the cached list, NOT a fresh scan -> this + // is what collapses the per-query and per-MTMV-refresh PARTITIONS scans. MUTATION: scanning live every + // call -> returns the 7-element list / loads==2 -> red. + List second = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return raws(7); + }); + Assertions.assertEquals(3, first.size()); + Assertions.assertSame(first, second, "within TTL the cached partition list must be served"); + Assertions.assertEquals(1, loads.get(), "the live scan must run exactly once within TTL"); + Assertions.assertEquals(1, c.loadCountForTest(), "the metric-gate load count must be 1"); + Assertions.assertTrue(c.isEnabled()); + } + + @Test + public void differentSnapshotIdIsADifferentKey() { + AtomicInteger loads = new AtomicInteger(); + IcebergPartitionCache c = new IcebergPartitionCache(100, 1000); + c.getOrLoad(key("db", "t", 1L), () -> { + loads.incrementAndGet(); + return raws(1); + }); + // A new commit yields a new snapshot id -> a distinct key -> a live scan (freshness across snapshots). + // MUTATION: keying by table only (ignoring snapshotId) -> loads==1 / serves the snapshot-1 list -> red. + List s2 = c.getOrLoad(key("db", "t", 2L), () -> { + loads.incrementAndGet(); + return raws(9); + }); + Assertions.assertEquals(9, s2.size()); + Assertions.assertEquals(2, loads.get()); + Assertions.assertEquals(2, c.size()); + } + + @Test + public void ttlZeroDisablesCachingAlwaysLive() { + AtomicInteger loads = new AtomicInteger(); + IcebergPartitionCache c = new IcebergPartitionCache(0, 1000); + c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return raws(3); + }); + List second = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return raws(7); + }); + // ttl-second=0 (the no-cache catalog) scans live every time. MUTATION: caching despite ttl<=0 -> + // second.size()==3 / loads==1 -> red. + Assertions.assertEquals(7, second.size(), "ttl-second=0 must always scan live"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + Assertions.assertEquals(0, c.size(), "ttl-second=0 must not store anything"); + } + + @Test + public void negativeTtlDisablesCachingAlwaysLive() { + AtomicInteger loads = new AtomicInteger(); + IcebergPartitionCache c = new IcebergPartitionCache(-1, 1000); + c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return raws(3); + }); + List second = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return raws(7); + }); + Assertions.assertEquals(7, second.size(), "ttl-second=-1 must always scan live"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + } + + @Test + public void invalidateDropsAllSnapshotsOfOneTable() { + AtomicInteger loads = new AtomicInteger(); + IcebergPartitionCache c = new IcebergPartitionCache(100, 1000); + c.getOrLoad(key("db", "t", 1L), () -> raws(1)); + c.getOrLoad(key("db", "t", 2L), () -> raws(2)); + c.getOrLoad(key("db", "other", 1L), () -> raws(3)); + Assertions.assertEquals(3, c.size()); + + // REFRESH TABLE db.t must drop BOTH snapshot entries of db.t and leave db.other intact. MUTATION: + // invalidateKey on a single (table, snapshot) key -> the other snapshot survives -> size 2 here -> red. + c.invalidate(TableIdentifier.of("db", "t")); + Assertions.assertEquals(1, c.size(), "only db.other's entry must survive"); + List reload = c.getOrLoad(key("db", "t", 1L), () -> { + loads.incrementAndGet(); + return raws(9); + }); + Assertions.assertEquals(9, reload.size()); + Assertions.assertEquals(1, loads.get()); + } + + @Test + public void invalidateDbClearsOnlyThatDbsTables() { + IcebergPartitionCache c = new IcebergPartitionCache(100, 1000); + c.getOrLoad(key("db1", "t1", 1L), () -> raws(1)); + c.getOrLoad(key("db1", "t2", 1L), () -> raws(2)); + c.getOrLoad(key("db2", "t1", 1L), () -> raws(3)); + Assertions.assertEquals(3, c.size()); + c.invalidateDb("db1"); + Assertions.assertEquals(1, c.size(), "only db2's entry must survive REFRESH DATABASE db1"); + } + + @Test + public void invalidateAllClearsEverything() { + IcebergPartitionCache c = new IcebergPartitionCache(100, 1000); + c.getOrLoad(key("db", "t1", 1L), () -> raws(1)); + c.getOrLoad(key("db", "t2", 1L), () -> raws(2)); + Assertions.assertEquals(2, c.size()); + c.invalidateAll(); + Assertions.assertEquals(0, c.size()); + } + + @Test + public void loaderExceptionPropagatesUnwrapped() { + // listPartitions catches ValidationException (dropped partition source column) to degrade to an empty + // list. Routing the scan through this cache must NOT wrap it, or the degradation would break. The + // MetaCacheEntry manual-miss-load path re-throws the loader's RuntimeException verbatim, and a failed + // scan is not cached. MUTATION: wrapping the loader exception -> assertThrows(ValidationException) fails. + IcebergPartitionCache c = new IcebergPartitionCache(100, 1000); + Assertions.assertThrows(ValidationException.class, () -> c.getOrLoad(key("db", "t", 5L), () -> { + throw new ValidationException("Cannot find source column for partition field"); + })); + Assertions.assertEquals(0, c.size(), "a failed scan must not be cached"); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java new file mode 100644 index 00000000000000..cb42feafd95cb2 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java @@ -0,0 +1,1083 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorPartitionInfo; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.mvcc.ConnectorMvccPartition; +import org.apache.doris.connector.api.mvcc.ConnectorMvccPartitionView; + +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.MetadataTableUtils; +import org.apache.iceberg.PartitionData; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Parity oracle for {@link IcebergPartitionUtils} — the self-contained port of legacy + * {@code IcebergUtils.{getIdentityPartitionColumns,getIdentityPartitionInfoMap,getPartitionValues, + * getPartitionDataJson,serializePartitionValue}} (P6.2-T03). The value matrix mirrors legacy + * {@code serializePartitionValue} cell-by-cell; the identity/json helpers are driven against real iceberg + * {@link PartitionData}/{@link PartitionSpec}/{@link Table} objects (no Mockito). The connector cannot + * import fe-core, so these are reproduced byte-faithfully with two deliberate, documented deltas: the + * timezone argument is a resolved {@link ZoneId} (not a raw String, so a non-canonical session zone cannot + * crash), and {@code partition_data_json} is rendered via iceberg's bundled Jackson (BE re-parses the JSON + * array — value-identical to legacy Gson). + */ +public class IcebergPartitionUtilsTest { + + private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai"); + + private static Table tableWith(Schema schema, PartitionSpec spec) { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + return catalog.createTable(TableIdentifier.of("db1", "t"), schema, spec); + } + + // ---- serializePartitionValue: legacy type matrix (direct, package-private) ---- + + @Test + public void serializePrimitiveValuesUseToString() { + Assertions.assertEquals("true", + IcebergPartitionUtils.serializePartitionValue(Types.BooleanType.get(), Boolean.TRUE, ZoneOffset.UTC)); + Assertions.assertEquals("42", + IcebergPartitionUtils.serializePartitionValue(Types.IntegerType.get(), 42, ZoneOffset.UTC)); + Assertions.assertEquals("42", + IcebergPartitionUtils.serializePartitionValue(Types.LongType.get(), 42L, ZoneOffset.UTC)); + Assertions.assertEquals("abc", + IcebergPartitionUtils.serializePartitionValue(Types.StringType.get(), "abc", ZoneOffset.UTC)); + Assertions.assertEquals("1.50", + IcebergPartitionUtils.serializePartitionValue(Types.DecimalType.of(10, 2), + new BigDecimal("1.50"), ZoneOffset.UTC)); + } + + @Test + public void serializeFloatAndDoubleUseTypedToString() { + // MUTATION: value.toString() (the primitive branch) would print "1.5" for both, but legacy routes + // FLOAT/DOUBLE through Float/Double.toString explicitly. Pin the typed branch. + Assertions.assertEquals("1.5", + IcebergPartitionUtils.serializePartitionValue(Types.FloatType.get(), 1.5f, ZoneOffset.UTC)); + Assertions.assertEquals("2.5", + IcebergPartitionUtils.serializePartitionValue(Types.DoubleType.get(), 2.5d, ZoneOffset.UTC)); + } + + @Test + public void serializeDateAndTimeUseIso() { + // DATE stored as days-since-epoch (Integer); 18628 = 2021-01-01. + Assertions.assertEquals("2021-01-01", + IcebergPartitionUtils.serializePartitionValue(Types.DateType.get(), 18628, ZoneOffset.UTC)); + // TIME stored as micros-since-midnight (Long); 3661_000_000 micros = 01:01:01. + Assertions.assertEquals("01:01:01", + IcebergPartitionUtils.serializePartitionValue(Types.TimeType.get(), 3661_000_000L, ZoneOffset.UTC)); + } + + @Test + public void serializeTimestampWithoutZoneIsUtcWallClock() { + // micros since epoch; 1609459200_000_000 = 2021-01-01T00:00:00Z. No zone adjust -> UTC wall clock. + Assertions.assertEquals("2021-01-01T00:00:00", + IcebergPartitionUtils.serializePartitionValue(Types.TimestampType.withoutZone(), + 1609459200_000_000L, SHANGHAI)); + } + + @Test + public void serializeTimestamptzShiftsToSessionZone() { + // timestamptz (shouldAdjustToUTC) -> the stored UTC instant is rendered in the session zone. + // 2021-01-01T00:00:00Z in Asia/Shanghai (+08) = 2021-01-01T08:00:00. MUTATION: ignoring the zone -> red. + Assertions.assertEquals("2021-01-01T08:00:00", + IcebergPartitionUtils.serializePartitionValue(Types.TimestampType.withZone(), + 1609459200_000_000L, SHANGHAI)); + } + + @Test + public void serializeNullValueReturnsNull() { + Assertions.assertNull( + IcebergPartitionUtils.serializePartitionValue(Types.StringType.get(), null, ZoneOffset.UTC)); + Assertions.assertNull( + IcebergPartitionUtils.serializePartitionValue(Types.TimestampType.withZone(), null, SHANGHAI)); + } + + @Test + public void serializeBinaryThrowsUnsupported() { + // Legacy throws UnsupportedOperationException for BINARY/FIXED (utf8 round-trip would corrupt data); + // callers catch it and drop the field. MUTATION: silently returning a string -> red. + Assertions.assertThrows(UnsupportedOperationException.class, () -> + IcebergPartitionUtils.serializePartitionValue(Types.BinaryType.get(), + java.nio.ByteBuffer.wrap(new byte[] {1}), ZoneOffset.UTC)); + } + + // ---- getIdentityPartitionColumns ---- + + @Test + public void identityPartitionColumnsAreIdentityOnlyCasePreservedAndDeduped() { + // Schema with an UPPERCASE column to prove case preservation; spec mixes identity + a bucket transform. + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "P", Types.IntegerType.get()), + Types.NestedField.required(3, "region", Types.StringType.get())); + PartitionSpec spec = PartitionSpec.builderFor(schema) + .identity("P") + .identity("region") + .bucket("id", 4) + .build(); + Table table = tableWith(schema, spec); + + List cols = IcebergPartitionUtils.getIdentityPartitionColumns(table); + + // Only the two identity columns, CASE-PRESERVED (#65094 read-path alignment), in spec order; the + // bucket(id) transform is excluded. MUTATION: including non-identity transforms -> "id_bucket"/"id" + // leaks -> red. MUTATION: re-lowercasing -> "p" != "P" -> red. + Assertions.assertEquals(java.util.Arrays.asList("P", "region"), cols); + } + + // ---- getIdentityPartitionInfoMap ---- + + @Test + public void identityPartitionInfoMapSkipsNonIdentityAndPreservesKeyCase() { + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "P", Types.IntegerType.get())); + PartitionSpec spec = PartitionSpec.builderFor(schema) + .identity("P") + .bucket("id", 4) + .build(); + Table table = tableWith(schema, spec); + PartitionData pd = new PartitionData(spec.partitionType()); + pd.set(0, 7); // P = 7 (identity) + pd.set(1, 2); // id_bucket = 2 (non-identity -> skipped) + + Map info = + IcebergPartitionUtils.getIdentityPartitionInfoMap(pd, spec, table, ZoneOffset.UTC); + + // Only the identity column survives, key CASE-PRESERVED (#65094 read-path alignment). MUTATION: + // emitting id_bucket -> size 2 -> red. MUTATION: re-lowercasing the key -> "p" != "P" -> red. + Assertions.assertEquals(Collections.singletonMap("P", "7"), info); + } + + @Test + public void identityPartitionInfoMapKeepsNullValue() { + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "p", Types.IntegerType.get())); + PartitionSpec spec = PartitionSpec.builderFor(schema).identity("p").build(); + Table table = tableWith(schema, spec); + PartitionData pd = new PartitionData(spec.partitionType()); + pd.set(0, null); // genuine null partition value + + Map info = + IcebergPartitionUtils.getIdentityPartitionInfoMap(pd, spec, table, ZoneOffset.UTC); + + Assertions.assertTrue(info.containsKey("p")); + Assertions.assertNull(info.get("p")); + } + + // ---- getPartitionDataJson ---- + + @Test + public void partitionDataJsonIsJsonArrayOverAllFields() { + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "p", Types.IntegerType.get()), + Types.NestedField.required(3, "region", Types.StringType.get())); + PartitionSpec spec = PartitionSpec.builderFor(schema).identity("p").identity("region").build(); + PartitionData pd = new PartitionData(spec.partitionType()); + pd.set(0, 5); + pd.set(1, "cn"); + + String json = IcebergPartitionUtils.getPartitionDataJson(pd, spec, ZoneOffset.UTC); + + // A JSON array of the serialized partition values, in spec order. MUTATION: dropping a field -> red. + Assertions.assertEquals("[\"5\",\"cn\"]", json); + } + + // ---- getPartitionDataObjectJson ($position_deletes, upstream #65135) ---- + + /** The metadata table's `partition` struct fields, i.e. what {@code outputPartitionFields} carries. */ + private static List outputFields(Types.NestedField... fields) { + return Arrays.asList(fields); + } + + @Test + public void partitionDataObjectJsonIsTypeNativeObjectNotStringArray() { + // WHY: the two renderers travel in the SAME thrift field (TIcebergFileDesc.partition_data_json) on + // different paths and are NOT interchangeable. BE does not parse this as JSON — it feeds it to the + // STRUCT text serde, which requires a leading '{' and matches keys by name; and + // DataTypeNullableSerDe::from_string SWALLOWS a parse failure into a NULL partition while returning + // OK. So handing it getPartitionDataJson's `["10"]` array would be silent wrong data, never an error. + // The int must also stay UNQUOTED (the regression suite's pd_int_partitioned pins "p":10, not "p":"10"). + // MUTATION: delegating to getPartitionDataJson -> array -> red; quoting the int -> red. + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "p", Types.IntegerType.get())); + PartitionSpec spec = PartitionSpec.builderFor(schema).identity("p").build(); + PartitionData pd = new PartitionData(spec.partitionType()); + pd.set(0, 10); + Types.NestedField out = Types.NestedField.optional( + spec.fields().get(0).fieldId(), "p", Types.IntegerType.get()); + + String json = IcebergPartitionUtils.getPartitionDataObjectJson( + pd, spec, outputFields(out), false, ZoneOffset.UTC); + + Assertions.assertEquals("{\"p\":10}", json); + } + + @Test + public void partitionDataObjectJsonKeysByOutputFieldIdNotSpecPosition() { + // WHY: the $position_deletes metadata table REASSIGNS partition field ids and rebuilds each spec via + // BaseMetadataTable.transformSpec, keeping the field ID but taking the OUTPUT (metadata-table) name. + // A rename therefore shows up as: same id, new name. Rendering by spec position (or by the writing + // spec's name) would emit the stale key, BE's struct serde would find no matching field, and the + // column would come back NULL — silently. This is the FE half of the suite's pd_partition_rename case. + // MUTATION: keying the JSON off the writing spec's field name -> {"p":10} -> red. + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "p", Types.IntegerType.get())); + PartitionSpec spec = PartitionSpec.builderFor(schema).identity("p").build(); + PartitionData pd = new PartitionData(spec.partitionType()); + pd.set(0, 10); + // Same field id, renamed to p2 on the metadata table. + Types.NestedField renamed = Types.NestedField.optional( + spec.fields().get(0).fieldId(), "p2", Types.IntegerType.get()); + + Assertions.assertEquals("{\"p2\":10}", IcebergPartitionUtils.getPartitionDataObjectJson( + pd, spec, outputFields(renamed), false, ZoneOffset.UTC)); + } + + @Test + public void partitionDataObjectJsonRendersFieldMissingFromWritingSpecAsNull() { + // WHY: under partition evolution a delete file's own spec is a SUBSET of the metadata table's union + // partition type. The absent field must materialize as NULL, not shift the remaining values over. + // The suite pins exactly this: the old-spec row's JSON contains ":null", the new-spec row's does not. + // MUTATION: skipping unmatched output fields entirely and letting BE positionally bind the rest -> red. + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "p", Types.IntegerType.get())); + PartitionSpec spec = PartitionSpec.builderFor(schema).identity("p").build(); + PartitionData pd = new PartitionData(spec.partitionType()); + pd.set(0, 10); + Types.NestedField present = Types.NestedField.optional( + spec.fields().get(0).fieldId(), "p", Types.IntegerType.get()); + // A field only the LATER spec has (id 9999 is in no writing spec here). + Types.NestedField evolved = Types.NestedField.optional(9999, "id_bucket", Types.IntegerType.get()); + + String json = IcebergPartitionUtils.getPartitionDataObjectJson( + pd, spec, outputFields(present, evolved), false, ZoneOffset.UTC); + + Assertions.assertEquals("{\"p\":10,\"id_bucket\":null}", json); + } + + @Test + public void partitionDataObjectJsonKeepsDecimalScaleExact() { + // WHY: stock Jackson's JsonNodeFactory has bigDecimalExact=false, so valueToTree(new BigDecimal("10")) + // renders 1E+1 and BigDecimal("1.50") renders 1.5 — the first is a JSON *syntax* change, not just lost + // scale. Legacy fe-core rendered these through Gson, which is scale-exact. Verified empirically + // against gson 2.10.1 / jackson 2.16.0 (design doc T0.1). Whether BE's decimal text parser even + // accepts 1E+1 is untested — the point is to never emit it. + // MUTATION: using the stock JsonUtil.mapper() instead of the withExactBigDecimals copy -> red on both. + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "p", Types.DecimalType.of(10, 2))); + PartitionSpec spec = PartitionSpec.builderFor(schema).identity("p").build(); + PartitionData pd = new PartitionData(spec.partitionType()); + pd.set(0, new BigDecimal("1.50")); + Types.NestedField out = Types.NestedField.optional( + spec.fields().get(0).fieldId(), "p", Types.DecimalType.of(10, 2)); + + Assertions.assertEquals("{\"p\":1.50}", IcebergPartitionUtils.getPartitionDataObjectJson( + pd, spec, outputFields(out), false, ZoneOffset.UTC)); + + PartitionData integral = new PartitionData(spec.partitionType()); + integral.set(0, new BigDecimal("10")); + Assertions.assertEquals("{\"p\":10}", IcebergPartitionUtils.getPartitionDataObjectJson( + integral, spec, outputFields(out), false, ZoneOffset.UTC), + "an integral decimal must not degrade to scientific notation (1E+1)"); + } + + @Test + public void partitionDataObjectJsonRejectsBinaryAndFixedPartitionValues() { + // WHY: this text transport cannot round-trip raw bytes. Legacy fails loud rather than let BE + // materialize a corrupted or silently-NULL partition value — and silent is exactly what would happen, + // since the struct serde swallows parse failures. MUTATION: emitting a utf8 rendering (or letting + // getPartitionValues' null-on-unsupported through) -> no throw -> red. + for (Types.NestedField field : outputFields( + Types.NestedField.required(2, "p", Types.BinaryType.get()), + Types.NestedField.required(2, "p", Types.FixedType.ofLength(2)))) { + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), field); + PartitionSpec spec = PartitionSpec.builderFor(schema).identity("p").build(); + PartitionData pd = new PartitionData(spec.partitionType()); + pd.set(0, ByteBuffer.wrap(new byte[] {0, (byte) 0xff})); + Types.NestedField out = Types.NestedField.optional( + spec.fields().get(0).fieldId(), "p", field.type()); + + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> IcebergPartitionUtils.getPartitionDataObjectJson( + pd, spec, outputFields(out), false, ZoneOffset.UTC)); + Assertions.assertTrue(e.getMessage().contains("partition field 'p'"), e.getMessage()); + Assertions.assertTrue(e.getMessage().contains(field.type().toString()), e.getMessage()); + } + } + + @Test + public void partitionDataObjectJsonRejectsUuidOnlyWhenVarbinaryMappingIsOn() { + // WHY: enable.mapping.varbinary makes UUID a VARBINARY column, which this text transport cannot carry + // either — but with the flag OFF a UUID is a plain string and MUST still work. So the guard is + // conditional, and a test that only checks the throw would not catch over-rejection. + // MUTATION: rejecting UUID unconditionally -> the flag-off case -> red; never rejecting it -> the + // flag-on case -> red. + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "p", Types.UUIDType.get())); + PartitionSpec spec = PartitionSpec.builderFor(schema).identity("p").build(); + UUID uuid = UUID.fromString("00000000-0000-0000-0000-00000000002a"); + PartitionData pd = new PartitionData(spec.partitionType()); + pd.set(0, uuid); + Types.NestedField out = Types.NestedField.optional( + spec.fields().get(0).fieldId(), "p", Types.UUIDType.get()); + + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> IcebergPartitionUtils.getPartitionDataObjectJson( + pd, spec, outputFields(out), true, ZoneOffset.UTC)); + Assertions.assertTrue(e.getMessage().contains("partition field 'p'"), e.getMessage()); + Assertions.assertTrue(e.getMessage().contains("uuid"), e.getMessage()); + + Assertions.assertEquals("{\"p\":\"" + uuid + "\"}", IcebergPartitionUtils.getPartitionDataObjectJson( + pd, spec, outputFields(out), false, ZoneOffset.UTC), + "with varbinary mapping off a UUID partition is a plain string and must still render"); + } + + @Test + public void partitionDataJsonRendersNullAsJsonNull() { + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "p", Types.IntegerType.get())); + PartitionSpec spec = PartitionSpec.builderFor(schema).identity("p").build(); + PartitionData pd = new PartitionData(spec.partitionType()); + pd.set(0, null); + + Assertions.assertEquals("[null]", + IcebergPartitionUtils.getPartitionDataJson(pd, spec, ZoneOffset.UTC)); + } + + // ---- parsePartitionValueFromString: legacy IcebergUtils.parsePartitionValueFromString matrix (T04) ---- + // The write-direction inverse of serializePartitionValue: BE sends human-readable partition strings, the + // connector converts them to iceberg internal partition objects for PartitionData. Mirrors legacy cell-by-cell. + + @Test + public void parseNullValueReturnsNull() { + Assertions.assertNull(IcebergPartitionUtils.parsePartitionValueFromString( + null, Types.StringType.get(), ZoneOffset.UTC)); + Assertions.assertNull(IcebergPartitionUtils.parsePartitionValueFromString( + null, Types.TimestampType.withZone(), SHANGHAI)); + } + + @Test + public void parsePrimitiveValuesByType() { + Assertions.assertEquals("abc", IcebergPartitionUtils.parsePartitionValueFromString( + "abc", Types.StringType.get(), ZoneOffset.UTC)); + // INTEGER -> Integer, LONG -> Long: the typed object distinguishes int32 from int64 partitions. + Assertions.assertEquals(Integer.valueOf(42), IcebergPartitionUtils.parsePartitionValueFromString( + "42", Types.IntegerType.get(), ZoneOffset.UTC)); + Assertions.assertEquals(Long.valueOf(42L), IcebergPartitionUtils.parsePartitionValueFromString( + "42", Types.LongType.get(), ZoneOffset.UTC)); + Assertions.assertEquals(Boolean.TRUE, IcebergPartitionUtils.parsePartitionValueFromString( + "true", Types.BooleanType.get(), ZoneOffset.UTC)); + Assertions.assertEquals(new BigDecimal("1.50"), IcebergPartitionUtils.parsePartitionValueFromString( + "1.50", Types.DecimalType.of(10, 2), ZoneOffset.UTC)); + } + + @Test + public void parseFloatAndDoubleAreTyped() { + // MUTATION: returning a Double for a FLOAT partition would break the iceberg PartitionData type check. + Assertions.assertEquals(Float.valueOf(1.5f), IcebergPartitionUtils.parsePartitionValueFromString( + "1.5", Types.FloatType.get(), ZoneOffset.UTC)); + Assertions.assertEquals(Double.valueOf(2.5d), IcebergPartitionUtils.parsePartitionValueFromString( + "2.5", Types.DoubleType.get(), ZoneOffset.UTC)); + } + + @Test + public void parseFloatNormalizesNanAndInfinity() { + // Legacy normalizes Doris's "nan"/"inf"/"-inf"/"infinity" spellings to Java's NaN/Infinity tokens + // before Float/Double.parse. MUTATION: passing the raw token straight to parseFloat -> NumberFormatException. + Assertions.assertTrue(Float.isNaN((Float) IcebergPartitionUtils.parsePartitionValueFromString( + "nan", Types.FloatType.get(), ZoneOffset.UTC))); + Assertions.assertEquals(Float.POSITIVE_INFINITY, IcebergPartitionUtils.parsePartitionValueFromString( + "inf", Types.FloatType.get(), ZoneOffset.UTC)); + Assertions.assertEquals(Double.NEGATIVE_INFINITY, IcebergPartitionUtils.parsePartitionValueFromString( + "-infinity", Types.DoubleType.get(), ZoneOffset.UTC)); + } + + @Test + public void parseDateReturnsEpochDay() { + // DATE stored as days-since-epoch (Integer); 2021-01-01 = 18628. Inverse of serializeDateAndTimeUseIso. + Assertions.assertEquals(Integer.valueOf(18628), IcebergPartitionUtils.parsePartitionValueFromString( + "2021-01-01", Types.DateType.get(), ZoneOffset.UTC)); + } + + @Test + public void parseTimestampWithoutZoneIsInterpretedInUtc() { + // No zone-adjust: the wall-clock string is interpreted in UTC -> micros. Round-trips + // serializeTimestampWithoutZoneIsUtcWallClock (1609459200_000_000 = 2021-01-01T00:00:00Z). + Assertions.assertEquals(1609459200_000_000L, IcebergPartitionUtils.parsePartitionValueFromString( + "2021-01-01 00:00:00", Types.TimestampType.withoutZone(), SHANGHAI)); + } + + @Test + public void parseTimestamptzIsInterpretedInSessionZone() { + // timestamptz (shouldAdjustToUTC): the wall-clock string is read in the session zone, stored as UTC + // micros. 2021-01-01T08:00:00 Asia/Shanghai (+08) = 2021-01-01T00:00:00Z. Inverse of + // serializeTimestamptzShiftsToSessionZone. MUTATION: ignoring the zone -> 8h off -> red. + Assertions.assertEquals(1609459200_000_000L, IcebergPartitionUtils.parsePartitionValueFromString( + "2021-01-01 08:00:00", Types.TimestampType.withZone(), SHANGHAI)); + } + + @Test + public void parseTimestampKeepsMicrosecondFraction() { + // BE may send sub-second precision; the micros fraction must survive (not be truncated to seconds). + Assertions.assertEquals(1609459200_123456L, IcebergPartitionUtils.parsePartitionValueFromString( + "2021-01-01 00:00:00.123456", Types.TimestampType.withoutZone(), ZoneOffset.UTC)); + } + + @Test + public void parseUnsupportedTypeThrows() { + Assertions.assertThrows(IllegalArgumentException.class, () -> + IcebergPartitionUtils.parsePartitionValueFromString( + "x", Types.BinaryType.get(), ZoneOffset.UTC)); + } + + // ---- parsePartitionValuesFromJson: legacy IcebergUtils.parsePartitionValuesFromJson (T04) ---- + + @Test + public void parseJsonRoundTripsGetPartitionDataJson() { + // Inverse of getPartitionDataJson: ["5","cn"] -> ["5","cn"]. + Assertions.assertEquals(java.util.Arrays.asList("5", "cn"), + IcebergPartitionUtils.parsePartitionValuesFromJson("[\"5\",\"cn\"]")); + } + + @Test + public void parseJsonKeepsNullElement() { + // A genuine null partition value renders as JSON null and must parse back to a null list element. + List values = IcebergPartitionUtils.parsePartitionValuesFromJson("[null]"); + Assertions.assertEquals(1, values.size()); + Assertions.assertNull(values.get(0)); + } + + @Test + public void parseJsonBlankReturnsEmptyList() { + Assertions.assertTrue(IcebergPartitionUtils.parsePartitionValuesFromJson(null).isEmpty()); + Assertions.assertTrue(IcebergPartitionUtils.parsePartitionValuesFromJson("").isEmpty()); + Assertions.assertTrue(IcebergPartitionUtils.parsePartitionValuesFromJson(" ").isEmpty()); + } + + // ─────────── B-2: MTMV RANGE partition view — transform math (buildRange), port of getPartitionRange ─────────── + // The transform value is the iceberg partition ordinal: HOUR=hours-since-epoch, DAY=days, MONTH=months, YEAR=years. + // Bounds are pre-rendered [lower, upper); a TIMESTAMP source -> "yyyy-MM-dd HH:mm:ss", a DATE source -> "yyyy-MM-dd". + + @Test + public void buildRangeDayWithTimestampSourceRendersDatetimeBounds() { + // day ordinal 100 = 1970-01-01 + 100 days = 1970-04-11; upper = +1 day. Source TIMESTAMP -> datetime form. + IcebergPartitionUtils.RangeBuild rb = IcebergPartitionUtils.buildRange( + "ts_day=100", "100", "day", Types.TimestampType.withoutZone(), 5L, 99L); + Assertions.assertEquals(Collections.singletonList("1970-04-11 00:00:00"), rb.getLowerBound()); + Assertions.assertEquals(Collections.singletonList("1970-04-12 00:00:00"), rb.getUpperBound()); + } + + @Test + public void buildRangeDayWithDateSourceRendersDateBounds() { + // Same day ordinal, but a DATE source -> "yyyy-MM-dd". MUTATION: a single fixed formatter would render + // the datetime form here (or the date form in the timestamp test) -> red. + IcebergPartitionUtils.RangeBuild rb = IcebergPartitionUtils.buildRange( + "d_day=100", "100", "day", Types.DateType.get(), 5L, 99L); + Assertions.assertEquals(Collections.singletonList("1970-04-11"), rb.getLowerBound()); + Assertions.assertEquals(Collections.singletonList("1970-04-12"), rb.getUpperBound()); + } + + @Test + public void buildRangeHourTruncatesToHourBoundary() { + // hour ordinal 5 = 1970-01-01 05:00:00; upper = +1 hour. HOUR's source is always a timestamp. + IcebergPartitionUtils.RangeBuild rb = IcebergPartitionUtils.buildRange( + "ts_hour=5", "5", "hour", Types.TimestampType.withoutZone(), 1L, 1L); + Assertions.assertEquals(Collections.singletonList("1970-01-01 05:00:00"), rb.getLowerBound()); + Assertions.assertEquals(Collections.singletonList("1970-01-01 06:00:00"), rb.getUpperBound()); + } + + @Test + public void buildRangeMonthTruncatesToMonthBoundary() { + // month ordinal 2 = 1970-03-01; upper = +1 month = 1970-04-01. + IcebergPartitionUtils.RangeBuild rb = IcebergPartitionUtils.buildRange( + "ts_month=2", "2", "month", Types.TimestampType.withoutZone(), 1L, 1L); + Assertions.assertEquals(Collections.singletonList("1970-03-01 00:00:00"), rb.getLowerBound()); + Assertions.assertEquals(Collections.singletonList("1970-04-01 00:00:00"), rb.getUpperBound()); + } + + @Test + public void buildRangeYearTruncatesToYearBoundary() { + // year ordinal 2 = 1972; upper = 1973. DATE source -> "yyyy-MM-dd". + IcebergPartitionUtils.RangeBuild rb = IcebergPartitionUtils.buildRange( + "d_year=2", "2", "year", Types.DateType.get(), 1L, 1L); + Assertions.assertEquals(Collections.singletonList("1972-01-01"), rb.getLowerBound()); + Assertions.assertEquals(Collections.singletonList("1973-01-01"), rb.getUpperBound()); + } + + @Test + public void buildRangeNullValueEmitsSuccessorSignal() { + // A NULL partition value -> lower "0000-01-01" + EMPTY upper (the generic model derives lower.successor()). + // MUTATION: rendering a concrete upper here would not match master's nullLowKey.successor() per scale. + IcebergPartitionUtils.RangeBuild rb = IcebergPartitionUtils.buildRange( + "ts_day=null", null, "day", Types.TimestampType.withoutZone(), 1L, 1L); + Assertions.assertEquals(Collections.singletonList("0000-01-01"), rb.getLowerBound()); + Assertions.assertTrue(rb.getUpperBound().isEmpty()); + } + + @Test + public void buildRangeUnsupportedTransformThrows() { + Assertions.assertThrows(RuntimeException.class, () -> IcebergPartitionUtils.buildRange( + "id_bucket=2", "2", "bucket[4]", Types.IntegerType.get(), 1L, 1L)); + } + + // ─────────── B-2: overlap merge + snapshot-id resolution (port mergeOverlapPartitions / getLatestSnapshotId) ─────────── + + private static IcebergPartitionUtils.RangeBuild rangeBuild(String name, LocalDateTime lower, LocalDateTime upper, + long lastUpdateTime, long lastSnapshotId) { + return new IcebergPartitionUtils.RangeBuild(name, lower, upper, + Collections.singletonList(lower.toString()), Collections.singletonList(upper.toString()), + lastUpdateTime, lastSnapshotId); + } + + @Test + public void mergeEnclosedDayIntoEnclosingMonth() { + // MONTH [1970-03-01, 1970-04-01) encloses DAY [1970-03-15, 1970-03-16): the day is merged away and the + // month becomes the single surviving Doris partition (parity master mergeOverlapPartitions on aligned ranges). + IcebergPartitionUtils.RangeBuild month = rangeBuild("ts_month=2", + LocalDateTime.of(1970, 3, 1, 0, 0), LocalDateTime.of(1970, 4, 1, 0, 0), 10L, 100L); + IcebergPartitionUtils.RangeBuild day = rangeBuild("ts_day=73", + LocalDateTime.of(1970, 3, 15, 0, 0), LocalDateTime.of(1970, 3, 16, 0, 0), 20L, 200L); + + Set survivors = new LinkedHashSet<>(Arrays.asList("ts_month=2", "ts_day=73")); + Map> mergeMap = IcebergPartitionUtils.mergeOverlapPartitions( + Arrays.asList(month, day), survivors); + + Assertions.assertEquals(Collections.singleton("ts_month=2"), survivors); + Assertions.assertEquals(new HashSet<>(Arrays.asList("ts_month=2", "ts_day=73")), + mergeMap.get("ts_month=2")); + } + + @Test + public void nonOverlappingPartitionsAreNotMerged() { + // Two disjoint days: neither encloses the other, both survive, no merge map entry. MUTATION: an encloses + // that returns true for disjoint ranges would wrongly drop one. + IcebergPartitionUtils.RangeBuild d1 = rangeBuild("ts_day=1", + LocalDateTime.of(1970, 1, 2, 0, 0), LocalDateTime.of(1970, 1, 3, 0, 0), 10L, 100L); + IcebergPartitionUtils.RangeBuild d2 = rangeBuild("ts_day=2", + LocalDateTime.of(1970, 1, 3, 0, 0), LocalDateTime.of(1970, 1, 4, 0, 0), 20L, 200L); + + Set survivors = new LinkedHashSet<>(Arrays.asList("ts_day=1", "ts_day=2")); + Map> mergeMap = IcebergPartitionUtils.mergeOverlapPartitions( + Arrays.asList(d1, d2), survivors); + + Assertions.assertEquals(new HashSet<>(Arrays.asList("ts_day=1", "ts_day=2")), survivors); + Assertions.assertTrue(mergeMap.isEmpty()); + } + + @Test + public void mergeTieBreaksEqualLowerByLargerUpperFirst() { + // SAME lower bound (1970-03-01), different uppers: MONTH [03-01,04-01) and first-of-month DAY + // [03-01,03-02). The comparator's tie-break (equal lower -> LARGER upper first) must place the month + // first so it encloses the day -> one survivor. Inputs are passed day-first to prove the COMPARATOR (not + // input order) decides. MUTATION: an ascending tie-break sorts the day first, encloses() is false, both + // survive (2 where master yields 1) -> red. + IcebergPartitionUtils.RangeBuild month = rangeBuild("ts_month=2", + LocalDateTime.of(1970, 3, 1, 0, 0), LocalDateTime.of(1970, 4, 1, 0, 0), 10L, 100L); + IcebergPartitionUtils.RangeBuild firstDay = rangeBuild("ts_day=59", + LocalDateTime.of(1970, 3, 1, 0, 0), LocalDateTime.of(1970, 3, 2, 0, 0), 20L, 200L); + + Set survivors = new LinkedHashSet<>(Arrays.asList("ts_month=2", "ts_day=59")); + Map> mergeMap = IcebergPartitionUtils.mergeOverlapPartitions( + Arrays.asList(firstDay, month), survivors); + + Assertions.assertEquals(Collections.singleton("ts_month=2"), survivors); + Assertions.assertEquals(new HashSet<>(Arrays.asList("ts_month=2", "ts_day=59")), + mergeMap.get("ts_month=2")); + } + + @Test + public void mergeIdenticalNameSelfEnclosesSoCallerMustDedupeFirst() { + // Two entries with the SAME name + byte-identical range: encloses() is true on equal endpoints, so the + // merge removes the (shared) secondKey -> the name vanishes from survivors. This is exactly WHY + // buildMvccPartitionView dedupes the raw rows into allByName (last-wins) BEFORE calling this — master + // keys nameToPartitionItem by name (loadPartitionInfo), so a name can never enclose its own twin. + // Documents the invariant the merge-input-dedup fix restores. + IcebergPartitionUtils.RangeBuild a = rangeBuild("ts_day=100", + LocalDateTime.of(1970, 4, 11, 0, 0), LocalDateTime.of(1970, 4, 12, 0, 0), 10L, 100L); + IcebergPartitionUtils.RangeBuild b = rangeBuild("ts_day=100", + LocalDateTime.of(1970, 4, 11, 0, 0), LocalDateTime.of(1970, 4, 12, 0, 0), 20L, 200L); + + Set survivors = new LinkedHashSet<>(Collections.singletonList("ts_day=100")); + IcebergPartitionUtils.mergeOverlapPartitions(Arrays.asList(a, b), survivors); + + Assertions.assertTrue(survivors.isEmpty(), + "identical-name entries self-enclose; buildMvccPartitionView must dedupe by name before merging"); + } + + @Test + public void latestSnapshotIdForMergedPicksMostRecentUpdate() { + // The merged month's freshness is the snapshot id of the most-recently-updated member (the day, t=20>10). + IcebergPartitionUtils.RangeBuild month = rangeBuild("ts_month=2", + LocalDateTime.of(1970, 3, 1, 0, 0), LocalDateTime.of(1970, 4, 1, 0, 0), 10L, 100L); + IcebergPartitionUtils.RangeBuild day = rangeBuild("ts_day=73", + LocalDateTime.of(1970, 3, 15, 0, 0), LocalDateTime.of(1970, 3, 16, 0, 0), 20L, 200L); + Map all = new HashMap<>(); + all.put("ts_month=2", month); + all.put("ts_day=73", day); + Map> mergeMap = Collections.singletonMap( + "ts_month=2", new HashSet<>(Arrays.asList("ts_month=2", "ts_day=73"))); + + Assertions.assertEquals(200L, IcebergPartitionUtils.latestSnapshotId("ts_month=2", mergeMap, all)); + } + + @Test + public void latestSnapshotIdForStandalonePartitionIsOwnSnapshot() { + // A partition that encloses nothing (absent from the merge map) reports its OWN last snapshot id. + IcebergPartitionUtils.RangeBuild day = rangeBuild("ts_day=1", + LocalDateTime.of(1970, 1, 2, 0, 0), LocalDateTime.of(1970, 1, 3, 0, 0), 10L, 77L); + Map all = + Collections.singletonMap("ts_day=1", day); + + Assertions.assertEquals(77L, + IcebergPartitionUtils.latestSnapshotId("ts_day=1", Collections.emptyMap(), all)); + } + + @Test + public void latestSnapshotIdSkipsInvalidUpdateTimesAndAllInvalidReturnsMinusOne() { + IcebergPartitionUtils.RangeBuild month = rangeBuild("ts_month=2", + LocalDateTime.of(1970, 3, 1, 0, 0), LocalDateTime.of(1970, 4, 1, 0, 0), 10L, 100L); + // day has an UNKNOWN (<=0) update time -> skipped; the month (t=10) wins. + IcebergPartitionUtils.RangeBuild day = rangeBuild("ts_day=73", + LocalDateTime.of(1970, 3, 15, 0, 0), LocalDateTime.of(1970, 3, 16, 0, 0), -1L, 200L); + Map all = new HashMap<>(); + all.put("ts_month=2", month); + all.put("ts_day=73", day); + Map> mergeMap = Collections.singletonMap( + "ts_month=2", new HashSet<>(Arrays.asList("ts_month=2", "ts_day=73"))); + Assertions.assertEquals(100L, IcebergPartitionUtils.latestSnapshotId("ts_month=2", mergeMap, all)); + + // Both members have invalid update times -> no snapshot id resolvable (-1); the caller then falls back + // to the table snapshot id. + IcebergPartitionUtils.RangeBuild month0 = rangeBuild("ts_month=2", + LocalDateTime.of(1970, 3, 1, 0, 0), LocalDateTime.of(1970, 4, 1, 0, 0), 0L, 100L); + Map allInvalid = new HashMap<>(); + allInvalid.put("ts_month=2", month0); + allInvalid.put("ts_day=73", day); + Assertions.assertEquals(-1L, IcebergPartitionUtils.latestSnapshotId("ts_month=2", mergeMap, allInvalid)); + } + + // ─────────── B-2: eligibility gate (isValidRelatedTable), port of IcebergExternalTable.isValidRelatedTable ─────────── + + private static final Schema RELATED_SCHEMA = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "ts", Types.TimestampType.withoutZone()), + Types.NestedField.optional(3, "ts2", Types.TimestampType.withoutZone()), + Types.NestedField.optional(4, "region", Types.StringType.get())); + + @Test + public void validRelatedTableSingleTimeTransform() { + Table table = tableWith(RELATED_SCHEMA, PartitionSpec.builderFor(RELATED_SCHEMA).day("ts").build()); + Assertions.assertTrue(IcebergPartitionUtils.isValidRelatedTable(table)); + } + + @Test + public void invalidRelatedTableMultipleFields() { + // Two partition fields -> not a valid related table (master supports a single field only). + Table table = tableWith(RELATED_SCHEMA, + PartitionSpec.builderFor(RELATED_SCHEMA).day("ts").identity("region").build()); + Assertions.assertFalse(IcebergPartitionUtils.isValidRelatedTable(table)); + } + + @Test + public void invalidRelatedTableNonTimeTransform() { + // A non year/month/day/hour transform (bucket) -> invalid. MUTATION: accepting any transform -> red. + Table table = tableWith(RELATED_SCHEMA, PartitionSpec.builderFor(RELATED_SCHEMA).bucket("id", 4).build()); + Assertions.assertFalse(IcebergPartitionUtils.isValidRelatedTable(table)); + } + + @Test + public void invalidRelatedTableUnpartitioned() { + Table table = tableWith(RELATED_SCHEMA, PartitionSpec.unpartitioned()); + Assertions.assertFalse(IcebergPartitionUtils.isValidRelatedTable(table)); + } + + @Test + public void invalidRelatedTableEvolutionRetainsVoidFieldSoMultiField() { + // Partition evolution that moves the source (ts -> ts2) retains the removed field as a VOID transform, so + // the new spec has 2 fields and the table is not a valid related table. This documents that iceberg never + // produces "two single-field specs on different sources" — master's allFields.size()==1 source-stability + // check is a faithful but practically-unreachable defensive guard (the field-count check fires first). + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + Table table = catalog.createTable(TableIdentifier.of("db1", "t"), RELATED_SCHEMA, + PartitionSpec.builderFor(RELATED_SCHEMA).day("ts").build()); + table.updateSpec().removeField("ts_day") + .addField(org.apache.iceberg.expressions.Expressions.day("ts2")).commit(); + Table evolved = catalog.loadTable(TableIdentifier.of("db1", "t")); + Assertions.assertFalse(IcebergPartitionUtils.isValidRelatedTable(evolved)); + } + + // ─────────── B-2: end-to-end PARTITIONS-metadata scan (buildMvccPartitionView / listPartitionNames) ─────────── + // Real InMemoryCatalog tables with appended partitioned data files; the PARTITIONS metadata table is scanned + // exactly as in production (no Mockito). This covers the gate -> RANGE/UNPARTITIONED style decision, the scan, + // and the per-partition freshness wiring on top of the unit-tested math/merge above. + + // partitionPaths use the iceberg human-readable form (a DAY transform takes the DATE string, e.g. + // "ts_day=1970-04-11"); the PARTITIONS metadata table stores/returns the integer ordinal (100), which is + // what the connector reads back into the partition name "ts_day=100". + private static Table dayPartitionedTable(PartitionSpec spec, String... partitionPaths) { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + Table table = catalog.createTable(TableIdentifier.of("db1", "t"), RELATED_SCHEMA, spec); + org.apache.iceberg.AppendFiles append = table.newAppend(); + int i = 0; + for (String partitionPath : partitionPaths) { + append.appendFile(DataFiles.builder(spec) + .withPath("s3://b/db1/t/f" + (i++) + ".parquet") + .withFileSizeInBytes(100) + .withRecordCount(1) + .withPartitionPath(partitionPath) + .withFormat(FileFormat.PARQUET) + .build()); + } + append.commit(); + return catalog.loadTable(TableIdentifier.of("db1", "t")); + } + + @Test + public void buildMvccPartitionViewEnumeratesRangePartitions() { + PartitionSpec spec = PartitionSpec.builderFor(RELATED_SCHEMA).day("ts").build(); + Table table = dayPartitionedTable(spec, "ts_day=1970-04-11", "ts_day=1970-07-20"); + long snapshotId = table.currentSnapshot().snapshotId(); + + ConnectorMvccPartitionView view = IcebergPartitionUtils.buildMvccPartitionView(table, -1L); + + Assertions.assertEquals(ConnectorMvccPartitionView.Style.RANGE, view.getStyle()); + Assertions.assertEquals(ConnectorMvccPartitionView.Freshness.SNAPSHOT_ID, view.getFreshness()); + List parts = view.getPartitions(); + Assertions.assertEquals(2, parts.size()); + // Sorted by name: "ts_day=100" < "ts_day=200". + Assertions.assertEquals(Arrays.asList("ts_day=100", "ts_day=200"), + parts.stream().map(ConnectorMvccPartition::getName).collect(Collectors.toList())); + // The day=100 partition's pre-rendered datetime bounds match the unit-tested transform math. + Assertions.assertEquals(Collections.singletonList("1970-04-11 00:00:00"), parts.get(0).getLowerBound()); + Assertions.assertEquals(Collections.singletonList("1970-04-12 00:00:00"), parts.get(0).getUpperBound()); + // Freshness is a resolved iceberg snapshot id (the single commit's snapshot, whether read directly from + // last_updated_snapshot_id or fallen back to the table snapshot id). MUTATION: a 0/-1 sentinel -> red. + for (ConnectorMvccPartition part : parts) { + Assertions.assertEquals(snapshotId, part.getFreshnessValue()); + } + // The view also carries the table's newest-update-time (max last_updated_at), the MONOTONIC marker the + // generic model answers the dictionary auto-refresh probe with (snapshot ids are non-monotonic). A real + // committed table has a positive value. MUTATION: mapping lastUpdateTime->0 (or orElse over no rows) -> red. + Assertions.assertTrue(view.getNewestUpdateTimeMillis() > 0, + "a committed RANGE table must report a positive newest-update-time for dictionary refresh"); + } + + @Test + public void partitionScanIsCachedAcrossRepeatsAndConsumersAtSameSnapshot() { + // PERF-02 metric gate: buildMvccPartitionView (MTMV/RANGE) and listPartitions (selectedPartitionNum) both + // funnel through loadRawPartitions keyed by (table, currentSnapshotId). A shared IcebergPartitionCache must + // collapse repeated calls -- across queries AND across the two consumers at the same snapshot -- onto ONE + // remote PARTITIONS scan (restoring legacy cross-query partition-info caching + collapsing the MTMV 4~6x + // re-enumeration). MUTATION: not threading the cache into loadRawPartitions -> each call re-scans -> + // loadCountForTest > 1 -> red. + PartitionSpec spec = PartitionSpec.builderFor(RELATED_SCHEMA).day("ts").build(); + Table table = dayPartitionedTable(spec, "ts_day=1970-04-11", "ts_day=1970-07-20"); + TableIdentifier id = TableIdentifier.of("db1", "t"); + IcebergPartitionCache cache = new IcebergPartitionCache(100, 1000); + + ConnectorMvccPartitionView v1 = IcebergPartitionUtils.buildMvccPartitionView(table, -1L, id, cache); + IcebergPartitionUtils.buildMvccPartitionView(table, -1L, id, cache); + List parts = IcebergPartitionUtils.listPartitions(table, id, cache); + + Assertions.assertEquals(1, cache.loadCountForTest(), + "the PARTITIONS scan must run exactly once across repeated views + both consumers at one snapshot"); + Assertions.assertEquals(1, cache.size(), "one (table, snapshot) entry"); + Assertions.assertEquals(2, parts.size(), "listPartitions still returns the two physical partitions"); + // Parity: the cached view matches a fresh (uncached) enumeration. + List cachedNames = v1.getPartitions().stream() + .map(ConnectorMvccPartition::getName).collect(Collectors.toList()); + List liveNames = IcebergPartitionUtils.buildMvccPartitionView(table, -1L).getPartitions().stream() + .map(ConnectorMvccPartition::getName).collect(Collectors.toList()); + Assertions.assertEquals(liveNames, cachedNames, "cached partition view must equal a live enumeration"); + } + + @Test + public void buildMvccPartitionViewResolvesPerPartitionSnapshotId() { + // Two SEPARATE commits: ts_day=100 lands in snapshot S1, ts_day=200 in S2. Each partition's freshness is + // the snapshot that last updated IT (S1 vs S2), NOT the table's current snapshot (S2 for both). This pins + // the per-partition snapshot-id resolution AND the `latest > 0 ? latest : tableSnapshotId` branch: a + // fallback-to-table mutation would make the first partition report S2 instead of its own S1. + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + PartitionSpec spec = PartitionSpec.builderFor(RELATED_SCHEMA).day("ts").build(); + TableIdentifier id = TableIdentifier.of("db1", "t"); + Table table = catalog.createTable(id, RELATED_SCHEMA, spec); + table.newAppend().appendFile(DataFiles.builder(spec).withPath("s3://b/db1/t/f0.parquet") + .withFileSizeInBytes(100).withRecordCount(1).withPartitionPath("ts_day=1970-04-11") + .withFormat(FileFormat.PARQUET).build()).commit(); + long s1 = catalog.loadTable(id).currentSnapshot().snapshotId(); + table.newAppend().appendFile(DataFiles.builder(spec).withPath("s3://b/db1/t/f1.parquet") + .withFileSizeInBytes(100).withRecordCount(1).withPartitionPath("ts_day=1970-07-20") + .withFormat(FileFormat.PARQUET).build()).commit(); + long s2 = catalog.loadTable(id).currentSnapshot().snapshotId(); + Assertions.assertNotEquals(s1, s2, "the two appends must create distinct snapshots"); + + List parts = IcebergPartitionUtils.buildMvccPartitionView( + catalog.loadTable(id), -1L).getPartitions(); + Assertions.assertEquals(2, parts.size()); + // Sorted by name: ts_day=100 (committed in S1), ts_day=200 (committed in S2). + Assertions.assertEquals("ts_day=100", parts.get(0).getName()); + Assertions.assertEquals(s1, parts.get(0).getFreshnessValue()); + Assertions.assertEquals("ts_day=200", parts.get(1).getName()); + Assertions.assertEquals(s2, parts.get(1).getFreshnessValue()); + + // pinnedSnapshotId = S1 enumerates AT the older snapshot: only ts_day=100 existed then. This pins the + // partition set + freshness to the query's MVCC snapshot (so the generic model keeps them consistent + // with the data-scan pin) instead of always reading the live latest. MUTATION: ignoring the pin and + // using currentSnapshot() -> 2 partitions -> red. + List atS1 = IcebergPartitionUtils.buildMvccPartitionView( + catalog.loadTable(id), s1).getPartitions(); + Assertions.assertEquals(Collections.singletonList("ts_day=100"), + atS1.stream().map(ConnectorMvccPartition::getName).collect(Collectors.toList())); + Assertions.assertEquals(s1, atS1.get(0).getFreshnessValue()); + + // newest-update-time is max() (NOT min()) over the two partitions' last_updated_at. p2 was committed in + // the later snapshot S2, so the full-table marker tracks S2 and must STRICTLY EXCEED the S1-only value + // whenever the two commits landed in different clock ticks (a min() would equal the S1-only value). This + // relationally kills the max->min mutation in practice without a flaky absolute-timestamp assertion. + long s1ts = catalog.loadTable(id).snapshot(s1).timestampMillis(); + long s2ts = catalog.loadTable(id).snapshot(s2).timestampMillis(); + long fullNewest = IcebergPartitionUtils.buildMvccPartitionView(catalog.loadTable(id), -1L) + .getNewestUpdateTimeMillis(); + long s1OnlyNewest = IcebergPartitionUtils.buildMvccPartitionView(catalog.loadTable(id), s1) + .getNewestUpdateTimeMillis(); + if (s2ts > s1ts) { + Assertions.assertTrue(fullNewest > s1OnlyNewest, + "newest-update must be max (track the later snapshot S2), not min; full=" + fullNewest + + " s1Only=" + s1OnlyNewest); + } else { + Assertions.assertTrue(fullNewest >= s1OnlyNewest, + "newest-update must be monotonic (the two commits tied on the clock; max==min)"); + } + } + + @Test + public void buildMvccPartitionViewInvalidTableIsUnpartitioned() { + // A bucket-partitioned table fails the eligibility gate -> UNPARTITIONED (NOT a degraded LIST). + Table table = dayPartitionedTable( + PartitionSpec.builderFor(RELATED_SCHEMA).bucket("id", 4).build(), "id_bucket=1"); + ConnectorMvccPartitionView view = IcebergPartitionUtils.buildMvccPartitionView(table, -1L); + Assertions.assertEquals(ConnectorMvccPartitionView.Style.UNPARTITIONED, view.getStyle()); + Assertions.assertTrue(view.getPartitions().isEmpty()); + // An unpartitioned view reports newest-update-time 0 (the gate failed before any PARTITIONS scan; + // dictionary treats it as "unchanged"). MUTATION: a non-zero default -> red. + Assertions.assertEquals(0L, view.getNewestUpdateTimeMillis()); + } + + @Test + public void buildMvccPartitionViewSnapshotButNoPartitionRowsReportsZeroNewestUpdate() { + // A valid related table that HAS a snapshot but whose PARTITIONS scan yields zero rows (an empty + // append still advances the current snapshot). This is the only path that reaches the + // max(...).orElse(0L) reduction with an EMPTY stream, so it pins the orElse fallback to 0. + // MUTATION: orElse(1L) (or any non-zero) -> red. + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + PartitionSpec spec = PartitionSpec.builderFor(RELATED_SCHEMA).day("ts").build(); + TableIdentifier id = TableIdentifier.of("db1", "t"); + Table table = catalog.createTable(id, RELATED_SCHEMA, spec); + table.newAppend().commit(); // empty append: advances the snapshot with no data files + Table loaded = catalog.loadTable(id); + // Fail LOUD (not assumeTrue/skip): this is the SOLE test that reaches the max(...).orElse(0L) reduction + // with an EMPTY stream. If a future iceberg version made an empty append a no-op (no snapshot), a silent + // skip would drop the orElse(0L) mutation coverage undetected — assertNotNull surfaces that regression. + Assertions.assertNotNull(loaded.currentSnapshot(), + "empty append must create a snapshot so this case reaches the orElse(0L) empty-stream path"); + + ConnectorMvccPartitionView view = IcebergPartitionUtils.buildMvccPartitionView(loaded, -1L); + Assertions.assertEquals(ConnectorMvccPartitionView.Style.RANGE, view.getStyle()); + Assertions.assertTrue(view.getPartitions().isEmpty(), + "a snapshot with no data files has no partitions"); + Assertions.assertEquals(0L, view.getNewestUpdateTimeMillis(), + "an empty partition stream must reduce to newest-update-time 0 (orElse fallback)"); + } + + @Test + public void buildMvccPartitionViewEmptyValidTableIsRangeWithNoPartitions() { + // A valid related spec but no data yet: RANGE on the spec alone, empty partition set (parity master: + // getPartitionType=RANGE, getIcebergPartitionItems empty). MUTATION: returning UNPARTITIONED -> red. + Table table = tableWith(RELATED_SCHEMA, PartitionSpec.builderFor(RELATED_SCHEMA).day("ts").build()); + ConnectorMvccPartitionView view = IcebergPartitionUtils.buildMvccPartitionView(table, -1L); + Assertions.assertEquals(ConnectorMvccPartitionView.Style.RANGE, view.getStyle()); + Assertions.assertTrue(view.getPartitions().isEmpty()); + // No partitions yet -> newest-update-time 0 (parity master max(...).orElse(0)). MUTATION: orElse non-zero -> red. + Assertions.assertEquals(0L, view.getNewestUpdateTimeMillis()); + } + + @Test + public void listPartitionNamesReturnsRawIcebergNames() { + PartitionSpec spec = PartitionSpec.builderFor(RELATED_SCHEMA).day("ts").build(); + Table table = dayPartitionedTable(spec, "ts_day=1970-04-11", "ts_day=1970-07-20"); + List names = IcebergPartitionUtils.listPartitionNames(table); + Assertions.assertEquals(new HashSet<>(Arrays.asList("ts_day=100", "ts_day=200")), new HashSet<>(names)); + } + + @Test + public void listPartitionNamesUnpartitionedIsEmpty() { + Table table = tableWith(RELATED_SCHEMA, PartitionSpec.unpartitioned()); + Assertions.assertTrue(IcebergPartitionUtils.listPartitionNames(table).isEmpty()); + } + + @Test + public void listPartitionsDegradesToEmptyWhenPartitionSourceColumnDropped() { + // Partition-evolution regression (external_table_p0/iceberg/test_iceberg_partition_evolution): a + // HISTORICAL spec references a source column that was later DROPPED, while the CURRENT spec stays + // partitioned on a surviving column. Building the PARTITIONS metadata table unifies the partition type + // across ALL specs, so iceberg throws ValidationException ("Cannot find source column for partition + // field: ...") for the orphaned field. listPartitions is display/enforcement metadata only (never the + // read set), so it must degrade to an empty (UNPARTITIONED) list instead of failing the whole query. + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "region", Types.StringType.get())); + TableIdentifier id = TableIdentifier.of("db1", "t"); + // format-version 2 so a partition field can be removed (v1 partition specs are append-only). + Table table = catalog.createTable(id, schema, + PartitionSpec.builderFor(schema).bucket("region", 8).build(), + Collections.singletonMap("format-version", "2")); + // A data file under the original (bucket-on-region) spec so the PARTITIONS metadata scan has a row whose + // spec must be unified. + table.newAppend().appendFile(DataFiles.builder(table.spec()) + .withPath("s3://b/db1/t/f0.parquet").withFileSizeInBytes(100).withRecordCount(1) + .withPartitionPath("region_bucket=1").withFormat(FileFormat.PARQUET).build()).commit(); + // Evolve: drop the bucket(region) partition field and add identity(id) — the CURRENT spec stays + // PARTITIONED (on the surviving id column), so listPartitions passes the isUnpartitioned() early-return + // and genuinely reaches the metadata scan (guards this test against a vacuous unpartitioned pass). + table.updateSpec().removeField("region_bucket").addField("id").commit(); + table.newAppend().appendFile(DataFiles.builder(table.spec()) + .withPath("s3://b/db1/t/f1.parquet").withFileSizeInBytes(100).withRecordCount(1) + .withPartitionPath("id=5").withFormat(FileFormat.PARQUET).build()).commit(); + // Drop the source column referenced only by the historical spec, leaving that spec dangling. + table.updateSchema().deleteColumn("region").commit(); + Table evolved = catalog.loadTable(id); + Assertions.assertTrue(evolved.spec().isPartitioned(), + "current spec must stay partitioned so listPartitions reaches the metadata scan, not the " + + "unpartitioned early-return (otherwise this test would pass vacuously)"); + + // Precondition — prove the raw iceberg partition-metadata scan genuinely throws ValidationException here + // (guards against a future iceberg that tolerates the dangling spec, which would make this test vacuous). + Assertions.assertThrows(ValidationException.class, () -> { + Table partitionsTable = MetadataTableUtils.createMetadataTableInstance( + evolved, MetadataTableType.PARTITIONS); + try (CloseableIterable tasks = partitionsTable.newScan().planFiles()) { + tasks.forEach(t -> { }); + } + }); + + // The fix: listPartitions swallows exactly that failure and reports UNPARTITIONED (empty), so a + // full-table select on such a table is not blocked by uncomputable display metadata. MUTATION: + // rethrowing (or removing the catch) -> this throws instead of returning empty -> red. + Assertions.assertTrue(IcebergPartitionUtils.listPartitions(evolved).isEmpty()); + } + + @Test + public void listPartitionNamesForNonRelatedPartitionedTableStillLists() { + // SHOW PARTITIONS is NOT gated on the MTMV eligibility rules: a bucket-partitioned table still lists its + // physical partitions (M-10: master rejected iceberg SHOW PARTITIONS, so an empty default would be a + // silent-zero-rows regression). + Table table = dayPartitionedTable( + PartitionSpec.builderFor(RELATED_SCHEMA).bucket("id", 4).build(), "id_bucket=1"); + Assertions.assertEquals(Collections.singletonList("id_bucket=1"), + IcebergPartitionUtils.listPartitionNames(table)); + } + + @Test + public void listPartitionsKeepsPartitionColumnCaseForNameLookup() { + // #65094 read-path alignment regression: the ConnectorPartitionInfo value map that listPartitions + // returns is keyed by the partition-field SOURCE column name (generateRawPartition). fe-core + // PluginDrivenExternalTable.getNameToPartitionItems looks each value up by the CASE-PRESERVED + // partition_columns remote name (IcebergConnectorMetadata.buildTableSchema emits the source name + // verbatim). If the key were lower-cased, a mixed-case partition column ("Pt") would key the map "pt" + // while the lookup uses "Pt" -> the partition value is silently dropped (MTMV / partition pruning sees + // null). Pin the key to the case-preserving name. + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "Pt", Types.IntegerType.get())); + TableIdentifier id = TableIdentifier.of("db1", "t"); + Table table = catalog.createTable(id, schema, + PartitionSpec.builderFor(schema).identity("Pt").build()); + table.newAppend().appendFile(DataFiles.builder(table.spec()) + .withPath("s3://b/db1/t/f0.parquet").withFileSizeInBytes(100).withRecordCount(1) + .withPartitionPath("Pt=7").withFormat(FileFormat.PARQUET).build()).commit(); + + List parts = IcebergPartitionUtils.listPartitions(catalog.loadTable(id)); + + Assertions.assertEquals(1, parts.size()); + Map values = parts.get(0).getPartitionValues(); + // Case-preserved key so getNameToPartitionItems' "Pt" remote-name lookup hits. + Assertions.assertTrue(values.containsKey("Pt"), + "partition value map must be keyed by the case-preserved partition column name"); + Assertions.assertEquals("7", values.get("Pt")); + // MUTATION: re-lowercase generateRawPartition's key -> "pt" present, "Pt" absent -> the case-preserving + // remote-name lookup misses -> red. + Assertions.assertFalse(values.containsKey("pt")); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPredicateConverterConflictModeTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPredicateConverterConflictModeTest.java new file mode 100644 index 00000000000000..9e8d376b7029a6 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPredicateConverterConflictModeTest.java @@ -0,0 +1,233 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.pushdown.ConnectorBetween; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorIn; +import org.apache.doris.connector.api.pushdown.ConnectorIsNull; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.ConnectorNot; +import org.apache.doris.connector.api.pushdown.ConnectorOr; + +import org.apache.iceberg.Schema; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.List; + +/** + * Conflict-mode (O5-2 write-time conflict detection) tests for {@link IcebergPredicateConverter}, the + * connector consume-side of P6.3-T07b. The 3-arg {@code conflictMode=true} constructor selects a port of + * legacy {@code IcebergConflictDetectionFilterUtils.convertPredicateToIcebergExpression}: a strictly + * different matrix from scan pushdown. Assertions encode the BE-irrelevant but iceberg-wire-relevant contract + * (which forms push, which drop, and to what shape), so a behavior drift turns the test red. + * + *

    The last two tests pin that the {@code conflictMode=false} (default, 2-arg) path is unchanged — the flag + * is the only thing that flips IS NULL / BETWEEN from "dropped" (scan) to "pushed" (conflict).

    + */ +public class IcebergPredicateConverterConflictModeTest { + + private static final Schema SCHEMA = new Schema( + Types.NestedField.required(1, "c_int", Types.IntegerType.get()), + Types.NestedField.required(2, "c_long", Types.LongType.get()), + Types.NestedField.required(3, "c_str", Types.StringType.get()), + Types.NestedField.optional(4, "c_list", Types.ListType.ofOptional(5, Types.IntegerType.get())), + Types.NestedField.optional(6, "c_uuid", Types.UUIDType.get())); + + private static IcebergPredicateConverter conflict() { + return new IcebergPredicateConverter(SCHEMA, ZoneOffset.UTC, true); + } + + private static IcebergPredicateConverter scan() { + return new IcebergPredicateConverter(SCHEMA, ZoneOffset.UTC); + } + + private static ConnectorColumnRef col(String name) { + return new ConnectorColumnRef(name, ConnectorType.of("UNKNOWN")); + } + + private static ConnectorLiteral intLit(long v) { + return new ConnectorLiteral(ConnectorType.of("INT"), v); + } + + private static ConnectorComparison cmp(ConnectorComparison.Operator op, String c, ConnectorLiteral lit) { + return new ConnectorComparison(op, col(c), lit); + } + + private static String pushed(IcebergPredicateConverter conv, ConnectorExpression expr) { + List out = conv.convert(expr); + Assertions.assertEquals(1, out.size(), "expected exactly one pushed conflict expression"); + return out.get(0).toString(); + } + + private static void dropped(IcebergPredicateConverter conv, ConnectorExpression expr) { + Assertions.assertTrue(conv.convert(expr).isEmpty(), "expected the predicate to be dropped"); + } + + // ---- comparisons ---- + + @Test + public void comparisonOperatorsPushed() { + Assertions.assertEquals(Expressions.equal("c_int", 1).toString(), + pushed(conflict(), cmp(ConnectorComparison.Operator.EQ, "c_int", intLit(1)))); + Assertions.assertEquals(Expressions.greaterThan("c_int", 1).toString(), + pushed(conflict(), cmp(ConnectorComparison.Operator.GT, "c_int", intLit(1)))); + Assertions.assertEquals(Expressions.greaterThanOrEqual("c_int", 1).toString(), + pushed(conflict(), cmp(ConnectorComparison.Operator.GE, "c_int", intLit(1)))); + Assertions.assertEquals(Expressions.lessThan("c_int", 1).toString(), + pushed(conflict(), cmp(ConnectorComparison.Operator.LT, "c_int", intLit(1)))); + Assertions.assertEquals(Expressions.lessThanOrEqual("c_int", 1).toString(), + pushed(conflict(), cmp(ConnectorComparison.Operator.LE, "c_int", intLit(1)))); + } + + @Test + public void notEqualAndEqForNullOperatorsDropped() { + // legacy conflict matrix has no NE and no NullSafeEqual case + dropped(conflict(), cmp(ConnectorComparison.Operator.NE, "c_int", intLit(1))); + dropped(conflict(), cmp(ConnectorComparison.Operator.EQ_FOR_NULL, "c_int", intLit(1))); + } + + @Test + public void comparisonAgainstNullLiteralBecomesIsNull() { + // legacy convertNereidsBinaryPredicate: any of EQ/GT/GE/LT/LE against NULL -> IS NULL + ConnectorLiteral nullLit = ConnectorLiteral.ofNull(ConnectorType.of("INT")); + Assertions.assertEquals(Expressions.isNull("c_int").toString(), + pushed(conflict(), cmp(ConnectorComparison.Operator.EQ, "c_int", nullLit))); + Assertions.assertEquals(Expressions.isNull("c_int").toString(), + pushed(conflict(), cmp(ConnectorComparison.Operator.GT, "c_int", nullLit))); + } + + // ---- IS NULL / NOT(IS NULL) ---- + + @Test + public void isNullPushed() { + Assertions.assertEquals(Expressions.isNull("c_int").toString(), + pushed(conflict(), new ConnectorIsNull(col("c_int"), false))); + } + + @Test + public void notIsNullPushed() { + Assertions.assertEquals(Expressions.not(Expressions.isNull("c_int")).toString(), + pushed(conflict(), new ConnectorNot(new ConnectorIsNull(col("c_int"), false)))); + } + + @Test + public void notOfComparisonDropped() { + // legacy restricts NOT to NOT(IS NULL); NOT(comparison) is dropped + dropped(conflict(), new ConnectorNot(cmp(ConnectorComparison.Operator.EQ, "c_int", intLit(1)))); + } + + // ---- BETWEEN ---- + + @Test + public void betweenDecomposesToGreaterEqualAndLessEqual() { + Expression expected = Expressions.and( + Expressions.greaterThanOrEqual("c_int", 1), Expressions.lessThanOrEqual("c_int", 9)); + Assertions.assertEquals(expected.toString(), + pushed(conflict(), new ConnectorBetween(col("c_int"), intLit(1), intLit(9)))); + } + + // ---- OR same-column guard ---- + + @Test + public void sameColumnOrPushed() { + Expression expected = Expressions.or(Expressions.equal("c_int", 1), Expressions.equal("c_int", 2)); + Assertions.assertEquals(expected.toString(), pushed(conflict(), new ConnectorOr(Arrays.asList( + cmp(ConnectorComparison.Operator.EQ, "c_int", intLit(1)), + cmp(ConnectorComparison.Operator.EQ, "c_int", intLit(2)))))); + } + + @Test + public void crossColumnOrDropped() { + // dropping an OR arm would narrow the conflict filter -> missed conflict; legacy drops cross-column OR + dropped(conflict(), new ConnectorOr(Arrays.asList( + cmp(ConnectorComparison.Operator.EQ, "c_int", intLit(1)), + cmp(ConnectorComparison.Operator.EQ, "c_long", intLit(2))))); + } + + // ---- IN ---- + + @Test + public void inPushed() { + Expression expected = Expressions.in("c_int", Arrays.asList(1, 2)); + Assertions.assertEquals(expected.toString(), pushed(conflict(), + new ConnectorIn(col("c_int"), Arrays.asList(intLit(1), intLit(2)), false))); + } + + @Test + public void inWithNullElementAddsIsNull() { + Expression expected = Expressions.or( + Expressions.isNull("c_int"), Expressions.in("c_int", Arrays.asList(1))); + Assertions.assertEquals(expected.toString(), pushed(conflict(), new ConnectorIn(col("c_int"), + Arrays.asList(intLit(1), ConnectorLiteral.ofNull(ConnectorType.of("INT"))), false))); + } + + @Test + public void notInDropped() { + dropped(conflict(), new ConnectorIn(col("c_int"), Arrays.asList(intLit(1)), true)); + } + + // ---- structural / UUID / metadata / unknown guards ---- + + @Test + public void structuralColumnIsNullDropped() { + dropped(conflict(), new ConnectorIsNull(col("c_list"), false)); + } + + @Test + public void uuidNonNullComparisonDroppedButNullBecomesIsNull() { + dropped(conflict(), cmp(ConnectorComparison.Operator.EQ, "c_uuid", + new ConnectorLiteral(ConnectorType.of("STRING"), "00000000-0000-0000-0000-000000000001"))); + Assertions.assertEquals(Expressions.isNull("c_uuid").toString(), pushed(conflict(), + cmp(ConnectorComparison.Operator.EQ, "c_uuid", ConnectorLiteral.ofNull(ConnectorType.of("STRING"))))); + } + + @Test + public void bareBooleanLiteralDropped() { + dropped(conflict(), new ConnectorLiteral(ConnectorType.of("BOOLEAN"), true)); + } + + @Test + public void metadataAndUnknownColumnsDropped() { + dropped(conflict(), new ConnectorIsNull(col("_row_id"), false)); + dropped(conflict(), cmp(ConnectorComparison.Operator.EQ, "nope", intLit(1))); + } + + // ---- regression: the default (scan) mode is unchanged; the flag is what flips behavior ---- + + @Test + public void scanModeStillDropsIsNullAndBetween() { + dropped(scan(), new ConnectorIsNull(col("c_int"), false)); + dropped(scan(), new ConnectorBetween(col("c_int"), intLit(1), intLit(9))); + } + + @Test + public void scanModeEqualityStillPushed() { + Assertions.assertEquals(Expressions.equal("c_int", 1).toString(), + pushed(scan(), cmp(ConnectorComparison.Operator.EQ, "c_int", intLit(1)))); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPredicateConverterRewriteModeTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPredicateConverterRewriteModeTest.java new file mode 100644 index 00000000000000..2b1bdf62a255fb --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPredicateConverterRewriteModeTest.java @@ -0,0 +1,229 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorBetween; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorIn; +import org.apache.doris.connector.api.pushdown.ConnectorIsNull; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.ConnectorNot; +import org.apache.doris.connector.api.pushdown.ConnectorOr; + +import org.apache.iceberg.Schema; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.List; + +/** + * REWRITE-mode (rewrite_data_files file scoping, P6.6-FIX-H9) tests for {@link IcebergPredicateConverter}. + * REWRITE mode mirrors legacy {@code IcebergNereidsUtils.convertNereidsToIcebergExpression}: the broad node set + * (cross-column {@code OR}, any-child {@code NOT}, {@code NE}, {@code IN}, {@code IS NULL}, {@code BETWEEN}) but + * strictly all-or-nothing (precise or dropped, never widened). It differs from {@link Mode#CONFLICT}, which + * narrows cross-column OR / NOT(comparison) / NE; and from {@link Mode#SCAN}, which has no IS NULL / BETWEEN node + * case and degrades AND. The user-signed contract (2026-06-29「精确下推否则报错」): the forms the live (master) + * code pushed must push again, while a partially-unrepresentable WHERE must collapse so the rewrite planner can + * fail loud rather than silently widen the set of files compacted. + */ +public class IcebergPredicateConverterRewriteModeTest { + + private static final Schema SCHEMA = new Schema( + Types.NestedField.required(1, "c_int", Types.IntegerType.get()), + Types.NestedField.required(2, "c_long", Types.LongType.get()), + Types.NestedField.required(3, "c_str", Types.StringType.get()), + Types.NestedField.optional(4, "c_list", Types.ListType.ofOptional(5, Types.IntegerType.get())), + Types.NestedField.optional(6, "c_date", Types.DateType.get())); + + private static IcebergPredicateConverter rewrite() { + return new IcebergPredicateConverter(SCHEMA, ZoneOffset.UTC, IcebergPredicateConverter.Mode.REWRITE); + } + + private static IcebergPredicateConverter scan() { + return new IcebergPredicateConverter(SCHEMA, ZoneOffset.UTC); + } + + private static ConnectorColumnRef col(String name) { + return new ConnectorColumnRef(name, ConnectorType.of("UNKNOWN")); + } + + private static ConnectorLiteral intLit(long v) { + return new ConnectorLiteral(ConnectorType.of("INT"), v); + } + + private static ConnectorLiteral strLit(String v) { + return new ConnectorLiteral(ConnectorType.of("STRING"), v); + } + + private static ConnectorComparison cmp(ConnectorComparison.Operator op, String c, ConnectorLiteral lit) { + return new ConnectorComparison(op, col(c), lit); + } + + private static String pushed(ConnectorExpression expr) { + List out = rewrite().convert(expr); + Assertions.assertEquals(1, out.size(), "expected exactly one pushed rewrite expression"); + return out.get(0).toString(); + } + + private static void dropped(IcebergPredicateConverter conv, ConnectorExpression expr) { + Assertions.assertTrue(conv.convert(expr).isEmpty(), "expected the predicate to be dropped"); + } + + // ---- the regression forms: cross-column OR / NOT(comparison) / NE (conflict mode drops these) ---- + + @Test + public void crossColumnOrPushed() { + // The headline regression: `c_int = 1 OR c_str = 'x'` -- master pushed it, conflict mode rejects it + // (different columns). REWRITE pushes the exact disjunction so file pruning honors the user's WHERE. + ConnectorExpression crossColumnOr = new ConnectorOr(Arrays.asList( + cmp(ConnectorComparison.Operator.EQ, "c_int", intLit(1)), + cmp(ConnectorComparison.Operator.EQ, "c_str", strLit("x")))); + Assertions.assertEquals( + Expressions.or(Expressions.equal("c_int", 1), Expressions.equal("c_str", "x")).toString(), + pushed(crossColumnOr)); + } + + @Test + public void notComparisonPushed() { + // `NOT(c_int > 5)` -- conflict mode allows NOT only over IS NULL; REWRITE allows NOT over any child. + ConnectorExpression notCmp = new ConnectorNot(cmp(ConnectorComparison.Operator.GT, "c_int", intLit(5))); + Assertions.assertEquals(Expressions.not(Expressions.greaterThan("c_int", 5)).toString(), pushed(notCmp)); + } + + @Test + public void notEqualPushedViaBothForms() { + // `!=` reaches the connector either as a NE comparison or (the parser form, LogicalPlanBuilder:3030) as + // Not(EQ); both lower to not(equal). Conflict mode drops NE entirely. + String expected = Expressions.not(Expressions.equal("c_int", 1)).toString(); + Assertions.assertEquals(expected, pushed(cmp(ConnectorComparison.Operator.NE, "c_int", intLit(1)))); + Assertions.assertEquals(expected, + pushed(new ConnectorNot(cmp(ConnectorComparison.Operator.EQ, "c_int", intLit(1))))); + } + + // ---- node-emitted IS NULL / BETWEEN / IN (scan mode has no node case for the first two) ---- + + @Test + public void isNullPushed() { + Assertions.assertEquals(Expressions.isNull("c_int").toString(), + pushed(new ConnectorIsNull(col("c_int"), false))); + } + + @Test + public void isNotNullViaNegatedNodePushed() { + // A negated ConnectorIsNull (IS NOT NULL) -> not(isNull). Guards the isNegated arm of buildRewriteIsNull. + Assertions.assertEquals(Expressions.not(Expressions.isNull("c_int")).toString(), + pushed(new ConnectorIsNull(col("c_int"), true))); + } + + @Test + public void betweenPushed() { + ConnectorExpression between = new ConnectorBetween(col("c_int"), intLit(1), intLit(10)); + Assertions.assertEquals( + Expressions.and(Expressions.greaterThanOrEqual("c_int", 1), + Expressions.lessThanOrEqual("c_int", 10)).toString(), + pushed(between)); + } + + @Test + public void inPushed() { + ConnectorExpression in = new ConnectorIn(col("c_int"), Arrays.asList(intLit(1), intLit(2)), false); + Assertions.assertEquals(Expressions.in("c_int", 1, 2).toString(), pushed(in)); + } + + @Test + public void betweenWithValidDateBoundsPushed() { + // Both temporal bounds bind -> the full and(gte, lte) is pushed. + ConnectorExpression between = new ConnectorBetween(col("c_date"), strLit("2020-01-01"), strLit("2020-12-31")); + Assertions.assertEquals( + Expressions.and(Expressions.greaterThanOrEqual("c_date", "2020-01-01"), + Expressions.lessThanOrEqual("c_date", "2020-12-31")).toString(), + pushed(between)); + } + + @Test + public void betweenWithOneMalformedBoundDroppedNotWidened() { + // The silent-widen hole the fix closes. extractIcebergLiteral passes temporal STRING bounds through + // unvalidated, so `c_date BETWEEN '2020-01-01' AND '2020-12-1'` builds and(gte('2020-01-01'), + // lte('2020-12-1')) with both bounds non-null. The malformed upper ('2020-12-1', non-ISO) only fails at + // BIND time. Without the REWRITE all-or-nothing gate in checkConversion, the AND would degrade to the + // surviving gte alone -> a predicate WEAKER than the WHERE that passes the planner's count guard -> + // silently rewrite every file with c_date >= '2020-01-01'. REWRITE must DROP the whole BETWEEN so the + // planner fails loud (master hands the raw and to planFiles(), which throws on the bad bound). + ConnectorExpression between = new ConnectorBetween(col("c_date"), strLit("2020-01-01"), strLit("2020-12-1")); + dropped(rewrite(), between); + } + + // ---- all-or-nothing: never silently widen (the R7 invariant the user kept) ---- + + @Test + public void nestedUnconvertibleArmCollapsesWholeOr() { + // `c_str = 'x' OR (c_int = 1 AND c_int = 'bad')`: the inner `c_int = 'bad'` cannot bind (int column vs a + // non-numeric string literal). REWRITE must DROP the whole expression (all-or-nothing) so the planner + // fails loud; SCAN would degrade the inner AND to `c_int = 1` and WIDEN the OR -- the precise contrast + // this test pins. If REWRITE's buildAnd silently dropped the bad arm (mutation), REWRITE would push too. + ConnectorExpression badInt = cmp(ConnectorComparison.Operator.EQ, "c_int", strLit("bad")); + ConnectorExpression nested = new ConnectorOr(Arrays.asList( + cmp(ConnectorComparison.Operator.EQ, "c_str", strLit("x")), + new ConnectorAnd(Arrays.asList(cmp(ConnectorComparison.Operator.EQ, "c_int", intLit(1)), badInt)))); + + dropped(rewrite(), nested); + Assertions.assertFalse(scan().convert(nested).isEmpty(), + "scan mode degrades the inner AND and widens the OR -- the behavior REWRITE must NOT share"); + } + + @Test + public void topLevelMultiConjunctAndFlattensToList() { + // A top-level ConnectorAnd is flattened to one iceberg expression per conjunct (the planner applies each + // as a separate scan.filter). Both convertible -> size 2; the planner's size-vs-count guard then accepts. + ConnectorExpression and = new ConnectorAnd(Arrays.asList( + cmp(ConnectorComparison.Operator.GE, "c_int", intLit(1)), + cmp(ConnectorComparison.Operator.LE, "c_int", intLit(9)))); + List out = rewrite().convert(and); + Assertions.assertEquals(2, out.size()); + Assertions.assertEquals(Expressions.greaterThanOrEqual("c_int", 1).toString(), out.get(0).toString()); + Assertions.assertEquals(Expressions.lessThanOrEqual("c_int", 9).toString(), out.get(1).toString()); + } + + @Test + public void unrepresentableLeafDropped() { + // A column not in the schema and a struct/list column cannot be pushed; REWRITE drops them (the planner + // turns a dropped top-level conjunct into a hard error). + dropped(rewrite(), cmp(ConnectorComparison.Operator.EQ, "c_missing", intLit(1))); + dropped(rewrite(), cmp(ConnectorComparison.Operator.EQ, "c_list", intLit(1))); + } + + // ---- scan mode is untouched by the new REWRITE branch (regression guard) ---- + + @Test + public void scanModeStillHasNoIsNullOrBetweenNodeCase() { + // The rewrite-side neutral converter emits IS NULL / BETWEEN nodes directly; scan mode (fed pre-lowered + // comparisons) has no case for them and drops them. This proves the REWRITE additions did not leak into + // scan dispatch (mode == REWRITE gate). + dropped(scan(), new ConnectorIsNull(col("c_int"), false)); + dropped(scan(), new ConnectorBetween(col("c_int"), intLit(1), intLit(10))); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPredicateConverterTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPredicateConverterTest.java new file mode 100644 index 00000000000000..b9f4644305a3e8 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPredicateConverterTest.java @@ -0,0 +1,362 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorBetween; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorIn; +import org.apache.doris.connector.api.pushdown.ConnectorIsNull; +import org.apache.doris.connector.api.pushdown.ConnectorLike; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.ConnectorNot; +import org.apache.doris.connector.api.pushdown.ConnectorOr; + +import org.apache.iceberg.Schema; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Parity tests for {@link IcebergPredicateConverter}, the self-contained port of fe-core + * {@code IcebergUtils.convertToIcebergExpr}. The primary oracle is the 9-column x 13-literal pushability + * grid copied verbatim from fe-core {@code IcebergPredicateTest} (same schema, same literals, same expected + * grid) — translated to the {@link ConnectorExpression} input the SPI delivers. If a (column, literal) pair + * is pushable in legacy it must be pushable here, and vice versa. No Mockito; fail-loud assertions. + */ +public class IcebergPredicateConverterTest { + + // Same schema (ids/types) as fe-core IcebergPredicateTest. + private static final Schema SCHEMA = new Schema( + Types.NestedField.required(1, "c_int", Types.IntegerType.get()), + Types.NestedField.required(2, "c_long", Types.LongType.get()), + Types.NestedField.required(3, "c_bool", Types.BooleanType.get()), + Types.NestedField.required(4, "c_float", Types.FloatType.get()), + Types.NestedField.required(5, "c_double", Types.DoubleType.get()), + Types.NestedField.required(6, "c_dec", Types.DecimalType.of(20, 10)), + Types.NestedField.required(7, "c_date", Types.DateType.get()), + Types.NestedField.required(8, "c_ts", Types.TimestampType.withoutZone()), + Types.NestedField.required(10, "c_str", Types.StringType.get())); + + private static final String[] COLS = { + "c_int", "c_long", "c_bool", "c_float", "c_double", "c_dec", "c_date", "c_ts", "c_str"}; + + // The 13 literals, carrying the same Java value + Doris source type ExprToConnectorExpressionConverter emits. + private static List literals() { + return Arrays.asList( + new ConnectorLiteral(ConnectorType.of("BOOLEAN"), Boolean.TRUE), + new ConnectorLiteral(ConnectorType.of("DATEV2"), LocalDate.of(2023, 1, 2)), + new ConnectorLiteral(ConnectorType.of("DATETIMEV2"), + LocalDateTime.of(2024, 1, 2, 12, 34, 56, 123456000)), + new ConnectorLiteral(ConnectorType.of("DECIMALV3", 3, 2), new BigDecimal("1.23")), + new ConnectorLiteral(ConnectorType.of("FLOAT"), 1.23d), + new ConnectorLiteral(ConnectorType.of("DOUBLE"), 3.456d), + new ConnectorLiteral(ConnectorType.of("TINYINT"), 1L), + new ConnectorLiteral(ConnectorType.of("SMALLINT"), 1L), + new ConnectorLiteral(ConnectorType.of("INT"), 1L), + new ConnectorLiteral(ConnectorType.of("BIGINT"), 1L), + new ConnectorLiteral(ConnectorType.of("VARCHAR"), "abc"), + new ConnectorLiteral(ConnectorType.of("VARCHAR"), "2023-01-02"), + new ConnectorLiteral(ConnectorType.of("VARCHAR"), "2023-01-02 01:02:03.456789")); + } + + // Verbatim grid from fe-core IcebergPredicateTest (true == pushable). Rows follow COLS order. + private static final boolean[][] EXPECTS = { + {false, false, false, false, false, false, true, true, true, true, false, false, false}, // c_int + {false, false, false, false, false, false, true, true, true, true, false, false, false}, // c_long + {true, false, false, false, false, false, false, false, false, false, false, false, false}, // c_bool + {false, false, false, false, true, false, true, true, true, true, false, false, false}, // c_float + {false, false, false, true, true, true, true, true, true, true, false, false, false}, // c_double + {false, false, false, true, true, true, true, true, true, true, false, false, false}, // c_dec + {false, true, false, false, false, false, true, true, true, true, false, true, false}, // c_date + {false, true, true, false, false, false, false, false, false, true, false, false, false}, // c_ts + {true, true, true, true, false, false, false, false, false, false, true, true, true} // c_str + }; + + private static IcebergPredicateConverter converter() { + // Session zone UTC: the grid's only timestamp column is withoutZone (shouldAdjustToUTC == false), + // so the zone is not consulted for the grid; UTC keeps the test deterministic. + return new IcebergPredicateConverter(SCHEMA, ZoneOffset.UTC); + } + + private static ConnectorColumnRef col(String name) { + return new ConnectorColumnRef(name, ConnectorType.of("UNKNOWN")); + } + + private static ConnectorComparison eq(String colName, ConnectorLiteral lit) { + return new ConnectorComparison(ConnectorComparison.Operator.EQ, col(colName), lit); + } + + /** + * The EQ pushability grid is the canonical parity oracle: each (column, literal) pair must push iff legacy + * pushed it. This encodes WHY pushdown matters — a divergent cell means iceberg files would be pruned + * differently than the legacy IcebergScanNode, breaking partition/row-count parity. MUTATION: any + * single-cell change in extractIcebergLiteral / checkConversion flips a cell and reddens this. + */ + @Test + public void binaryEqGridMatchesLegacy() { + List lits = literals(); + for (int i = 0; i < COLS.length; i++) { + for (int j = 0; j < lits.size(); j++) { + boolean pushed = !converter().convert(eq(COLS[i], lits.get(j))).isEmpty(); + Assertions.assertEquals(EXPECTS[i][j], pushed, + "EQ grid mismatch at column " + COLS[i] + " literal#" + j + " (" + lits.get(j) + ")"); + } + } + } + + /** IN and NOT-IN use the same per-element pushability matrix as EQ (legacy parity). */ + @Test + public void inAndNotInGridMatchLegacy() { + List lits = literals(); + for (int i = 0; i < COLS.length; i++) { + for (int j = 0; j < lits.size(); j++) { + ConnectorIn in = new ConnectorIn(col(COLS[i]), + Collections.singletonList(lits.get(j)), false); + ConnectorIn notIn = new ConnectorIn(col(COLS[i]), + Collections.singletonList(lits.get(j)), true); + String where = "column " + COLS[i] + " literal#" + j; + Assertions.assertEquals(EXPECTS[i][j], !converter().convert(in).isEmpty(), "IN " + where); + Assertions.assertEquals(EXPECTS[i][j], !converter().convert(notIn).isEmpty(), "NOT IN " + where); + } + } + } + + /** A single unconvertible IN element drops the whole IN (legacy parity: all-or-nothing on the value list). */ + @Test + public void inWithOneBadElementDropsWholeIn() { + ConnectorIn in = new ConnectorIn(col("c_int"), + Arrays.asList(new ConnectorLiteral(ConnectorType.of("INT"), 1L), + new ConnectorLiteral(ConnectorType.of("VARCHAR"), "abc")), // "abc" -> INTEGER fails + false); + // MUTATION: collecting only the convertible elements (instead of dropping the whole IN) -> red. + Assertions.assertTrue(converter().convert(in).isEmpty()); + } + + /** A bare boolean literal maps to alwaysTrue / alwaysFalse (legacy BoolLiteral path). */ + @Test + public void boolLiteralMapsToAlwaysTrueFalse() { + List t = converter().convert(new ConnectorLiteral(ConnectorType.of("BOOLEAN"), Boolean.TRUE)); + List f = converter().convert(new ConnectorLiteral(ConnectorType.of("BOOLEAN"), Boolean.FALSE)); + Assertions.assertEquals(Expression.Operation.TRUE, t.get(0).op()); + Assertions.assertEquals(Expression.Operation.FALSE, f.get(0).op()); + } + + /** col {@code <=>} NULL becomes IS NULL; every other null-valued comparison is dropped (legacy parity). */ + @Test + public void eqForNullMapsToIsNull() { + ConnectorComparison cmp = new ConnectorComparison(ConnectorComparison.Operator.EQ_FOR_NULL, + col("c_int"), ConnectorLiteral.ofNull(ConnectorType.of("INT"))); + List out = converter().convert(cmp); + Assertions.assertEquals(Expression.Operation.IS_NULL, out.get(0).op()); + // A plain EQ against NULL is NOT pushable. + Assertions.assertTrue(converter().convert(new ConnectorComparison(ConnectorComparison.Operator.EQ, + col("c_int"), ConnectorLiteral.ofNull(ConnectorType.of("INT")))).isEmpty()); + } + + /** + * Top-level AND flattens into one filter per pushable conjunct; an unconvertible conjunct is dropped while + * the pushable ones survive (mirrors legacy createTableScan's per-conjunct scan.filter loop + AND keep-arm). + */ + @Test + public void topLevelAndDropsUnpushableConjunctKeepsRest() { + ConnectorComparison valid = eq("c_int", new ConnectorLiteral(ConnectorType.of("INT"), 1L)); + ConnectorComparison invalid = eq("c_int", new ConnectorLiteral(ConnectorType.of("VARCHAR"), "abc")); + List out = converter().convert(new ConnectorAnd(Arrays.asList(valid, invalid))); + Assertions.assertEquals(1, out.size()); + Assertions.assertEquals(Expression.Operation.EQ, out.get(0).op()); + } + + /** + * The load-bearing nested case: {@code (valid AND invalid) OR valid2} must degrade to {@code valid OR + * valid2} — the unbindable leaf is dropped BEFORE the OR is built, so the resulting OR binds cleanly. If + * convertSingle didn't fold checkConversion into the recursion, the OR would carry an unbindable predicate + * (a planning-time bind crash). MUTATION: build the OR from un-bind-checked children -> the assertion that + * the result binds without throwing reddens. + */ + @Test + public void nestedAndInsideOrDegradesUnbindableLeaf() { + ConnectorComparison valid = eq("c_int", new ConnectorLiteral(ConnectorType.of("INT"), 1L)); + ConnectorComparison invalid = eq("c_int", new ConnectorLiteral(ConnectorType.of("VARCHAR"), "abc")); + ConnectorComparison valid2 = eq("c_long", new ConnectorLiteral(ConnectorType.of("BIGINT"), 2L)); + ConnectorExpression expr = new ConnectorOr(Arrays.asList( + new ConnectorAnd(Arrays.asList(valid, invalid)), valid2)); + List out = converter().convert(expr); + Assertions.assertEquals(1, out.size()); + Assertions.assertEquals(Expression.Operation.OR, out.get(0).op()); + // Proves no unbindable predicate leaked into the OR: binding the whole tree must not throw. + Assertions.assertDoesNotThrow(() -> + org.apache.iceberg.expressions.Binder.bind(SCHEMA.asStruct(), out.get(0), true)); + } + + /** OR is all-or-nothing: one unpushable disjunct collapses the entire OR (dropping an arm widens results). */ + @Test + public void orWithUnpushableDisjunctIsDropped() { + ConnectorComparison valid = eq("c_int", new ConnectorLiteral(ConnectorType.of("INT"), 1L)); + ConnectorComparison invalid = eq("c_int", new ConnectorLiteral(ConnectorType.of("VARCHAR"), "abc")); + Assertions.assertTrue(converter().convert(new ConnectorOr(Arrays.asList(valid, invalid))).isEmpty()); + } + + /** NOT is pushed iff its child is pushable. */ + @Test + public void notIsPushedIffChildPushable() { + ConnectorComparison valid = eq("c_int", new ConnectorLiteral(ConnectorType.of("INT"), 1L)); + ConnectorComparison invalid = eq("c_int", new ConnectorLiteral(ConnectorType.of("VARCHAR"), "abc")); + Assertions.assertEquals(Expression.Operation.NOT, + converter().convert(new ConnectorNot(valid)).get(0).op()); + Assertions.assertTrue(converter().convert(new ConnectorNot(invalid)).isEmpty()); + } + + /** Reversed `literal OP col` and col-col comparisons are not pushed (column-op-literal only). */ + @Test + public void reversedAndColColComparisonsDropped() { + ConnectorLiteral lit = new ConnectorLiteral(ConnectorType.of("INT"), 1L); + Assertions.assertTrue(converter().convert( + new ConnectorComparison(ConnectorComparison.Operator.EQ, lit, col("c_int"))).isEmpty()); + Assertions.assertTrue(converter().convert( + new ConnectorComparison(ConnectorComparison.Operator.EQ, col("c_int"), col("c_long"))).isEmpty()); + } + + /** Column resolution is case-insensitive and rewrites to the canonical schema-cased name. */ + @Test + public void columnResolutionIsCaseInsensitive() { + List out = converter().convert( + eq("C_INT", new ConnectorLiteral(ConnectorType.of("INT"), 1L))); + Assertions.assertEquals(1, out.size()); + // The bound/unbound predicate must reference the canonical "c_int" (not "C_INT"). + Assertions.assertTrue(out.get(0).toString().contains("c_int"), out.get(0).toString()); + } + + /** v3 row-lineage metadata columns are never pushed (mirror getPushdownField block). */ + @Test + public void metadataColumnsBlocked() { + Assertions.assertTrue(converter().convert( + eq("_row_id", new ConnectorLiteral(ConnectorType.of("BIGINT"), 1L))).isEmpty()); + Assertions.assertTrue(converter().convert( + eq("_last_updated_sequence_number", + new ConnectorLiteral(ConnectorType.of("BIGINT"), 1L))).isEmpty()); + } + + /** A predicate on a column absent from the schema is dropped. */ + @Test + public void unknownColumnDropped() { + Assertions.assertTrue(converter().convert( + eq("no_such_col", new ConnectorLiteral(ConnectorType.of("INT"), 1L))).isEmpty()); + } + + /** + * Node types legacy convertToIcebergExpr has no case for are dropped (IS NULL via ConnectorIsNull, LIKE, + * BETWEEN) — BE residual-filters them. This is a deliberate divergence from paimon (which pushes IS NULL / + * startsWith); see the design's deviations. MUTATION: adding a ConnectorIsNull/Like case -> red. + */ + @Test + public void unsupportedNodeTypesDropped() { + Assertions.assertTrue(converter().convert(new ConnectorIsNull(col("c_int"), false)).isEmpty()); + Assertions.assertTrue(converter().convert(new ConnectorIsNull(col("c_int"), true)).isEmpty()); + Assertions.assertTrue(converter().convert(new ConnectorLike(ConnectorLike.Operator.LIKE, + col("c_str"), new ConnectorLiteral(ConnectorType.of("VARCHAR"), "ab%"))).isEmpty()); + Assertions.assertTrue(converter().convert(new ConnectorBetween(col("c_int"), + new ConnectorLiteral(ConnectorType.of("INT"), 1L), + new ConnectorLiteral(ConnectorType.of("INT"), 9L))).isEmpty()); + } + + /** null input yields no predicates (no NPE). */ + @Test + public void nullInputYieldsEmpty() { + Assertions.assertTrue(converter().convert(null).isEmpty()); + } + + /** + * For a zone-adjusted (timestamptz) column the wall-clock literal is interpreted in the SESSION zone, so + * the pushed epoch-micros depend on the zone (mirrors legacy getUnixTimestampWithMicroseconds(session tz)). + * The grid oracle never exercised a withZone() column — that gap is exactly why the alias-resolution + * regression was UT-invisible. MUTATION: hardcoding UTC for timestamptz -> the two micros match -> red. + */ + @Test + public void timestamptzLiteralUsesSessionZone() { + Schema tz = new Schema(Types.NestedField.required(1, "ts", Types.TimestampType.withZone())); + LocalDateTime dt = LocalDateTime.of(2024, 1, 2, 12, 0, 0); + ConnectorComparison cmp = new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef("ts", ConnectorType.of("UNKNOWN")), + new ConnectorLiteral(ConnectorType.of("DATETIMEV2"), dt)); + long cstMicros = singleMicros(new IcebergPredicateConverter(tz, ZoneId.of("Asia/Shanghai")).convert(cmp)); + long utcMicros = singleMicros(new IcebergPredicateConverter(tz, ZoneOffset.UTC).convert(cmp)); + // +08:00 is 8h ahead, so its epoch instant for the same wall clock is 8h earlier than UTC's. + Assertions.assertEquals(8L * 3600 * 1_000_000L, utcMicros - cstMicros); + } + + private static long singleMicros(List out) { + Assertions.assertEquals(1, out.size()); + Object value = ((org.apache.iceberg.expressions.UnboundPredicate) out.get(0)).literal().value(); + return ((Number) value).longValue(); + } + + /** + * T10 parity gap-fill (audit wf_9d88fe61-5c7, PRED-1): the EQ grid asserts only PUSHABILITY, so the five + * other comparison operators were never pinned to the iceberg {@link Expression.Operation} they must produce. + * Legacy {@code IcebergUtils.convertToIcebergExpr} maps GT→greaterThan, LT→lessThan, GE→greaterThanOrEqual, + * LE→lessThanOrEqual, and NE→{@code not(equal)} (a NOT wrapping an EQ, NOT a NOT_EQ); the connector's + * buildComparison reproduces this. Without this test a GT↔GE / LT↔LE transposition or a dropped NE negation + * passes the whole suite (still pushable, still one predicate) while pruning files inversely vs the legacy + * IcebergScanNode. MUTATION: any operator→Operation swap, or {@code notEqual} in place of {@code not(equal)}, + * reddens here. + */ + @Test + public void comparisonOperatorsMatchLegacyOperations() { + ConnectorLiteral one = new ConnectorLiteral(ConnectorType.of("INT"), 1L); + assertOp(ConnectorComparison.Operator.GT, one, Expression.Operation.GT); + assertOp(ConnectorComparison.Operator.LT, one, Expression.Operation.LT); + assertOp(ConnectorComparison.Operator.GE, one, Expression.Operation.GT_EQ); + assertOp(ConnectorComparison.Operator.LE, one, Expression.Operation.LT_EQ); + + // NE is the load-bearing parity case: legacy renders col != lit as not(equal), so the top op is NOT and + // its single child is the EQ predicate on the same column/literal (NOT a NOT_EQ leaf). + List ne = converter().convert( + new ConnectorComparison(ConnectorComparison.Operator.NE, col("c_int"), one)); + Assertions.assertEquals(1, ne.size()); + Assertions.assertEquals(Expression.Operation.NOT, ne.get(0).op()); + org.apache.iceberg.expressions.Not not = (org.apache.iceberg.expressions.Not) ne.get(0); + org.apache.iceberg.expressions.UnboundPredicate child = + (org.apache.iceberg.expressions.UnboundPredicate) not.child(); + Assertions.assertEquals(Expression.Operation.EQ, child.op()); + Assertions.assertTrue(child.ref().name().contains("c_int"), child.toString()); + Assertions.assertEquals(1, ((Number) child.literal().value()).longValue()); + } + + private void assertOp(ConnectorComparison.Operator op, ConnectorLiteral lit, Expression.Operation expected) { + List out = converter().convert(new ConnectorComparison(op, col("c_int"), lit)); + Assertions.assertEquals(1, out.size(), op + " should push"); + Assertions.assertEquals(expected, out.get(0).op(), op + " operation"); + // Pin the literal too, so a dropped/swapped literal value is also caught. + Object value = ((org.apache.iceberg.expressions.UnboundPredicate) out.get(0)).literal().value(); + Assertions.assertEquals(1, ((Number) value).longValue(), op + " literal"); + } +} 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 new file mode 100644 index 00000000000000..a8ee5463517849 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProcedureOpsTest.java @@ -0,0 +1,468 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; +import org.apache.doris.connector.api.procedure.ConnectorRewriteGroup; +import org.apache.doris.connector.api.procedure.ProcedureExecutionMode; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Pins the {@link IcebergProcedureOps} dispatch (P6.4-T03 skeleton + T04 bodies). + * + *

    WHY this matters: {@code getSupportedProcedures()} exports the factory's name list and + * {@code execute()} routes through the factory to a {@link org.apache.doris.connector.iceberg.action.BaseIcebergAction}. + * T04 makes a known procedure executable end-to-end: the body's SDK mutation + {@code commit()} run inside ONE + * {@code executeAuthenticated} scope (the auth fix the legacy fe-core actions lacked), and the + * {@link ConnectorSession} is threaded to the body (the {@code rollback_to_timestamp} time zone). The whole + * path is dormant pre-cutover (iceberg is not {@code PluginDrivenExternalTable} until P6.6).

    + * + *

    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.

    + */ +public class IcebergProcedureOpsTest { + + private static final ConnectorSession SESSION = new TzSession("Asia/Shanghai"); + + private static IcebergProcedureOps newOps() { + // getSupportedProcedures + the unknown-name rejection never touch the catalog/context, so they may + // be null here; the catalog-backed path is exercised below with an InMemoryCatalog. The (IcebergCatalogOps) + // cast selects the ops constructor over the session-aware resolver overload (a bare null matches both). + return new IcebergProcedureOps(Collections.emptyMap(), (IcebergCatalogOps) null, null); + } + + @Test + public void getSupportedProceduresExportsFactoryNamesInLegacyOrder() { + Assertions.assertEquals( + ImmutableList.of( + "rollback_to_snapshot", + "rollback_to_timestamp", + "set_current_snapshot", + "cherrypick_snapshot", + "fast_forward", + "expire_snapshots", + "rewrite_data_files", + "publish_changes", + "rewrite_manifests"), + newOps().getSupportedProcedures()); + } + + @Test + public void rewriteDataFilesIsTheOnlyDistributedProcedure() { + IcebergProcedureOps ops = newOps(); + // rewrite_data_files runs N per-group INSERT-SELECT writes under one shared transaction, so the engine + // must orchestrate it (DISTRIBUTED) rather than dispatch it through execute(). Every other procedure is + // a synchronous SDK call (SINGLE_CALL). This is what lets the engine route rewrite without a name literal. + Assertions.assertEquals(ProcedureExecutionMode.DISTRIBUTED, + ops.getExecutionMode("rewrite_data_files"), + "rewrite_data_files must be DISTRIBUTED so the engine drives the per-group rewrite loop"); + Assertions.assertEquals(ProcedureExecutionMode.DISTRIBUTED, + ops.getExecutionMode("REWRITE_DATA_FILES"), + "execution-mode lookup must be case-insensitive (mirrors the factory's toLowerCase dispatch)"); + for (String name : ops.getSupportedProcedures()) { + if ("rewrite_data_files".equals(name)) { + continue; + } + Assertions.assertEquals(ProcedureExecutionMode.SINGLE_CALL, ops.getExecutionMode(name), + name + " is a synchronous SDK procedure and must be SINGLE_CALL"); + } + } + + @Test + public void executeRejectsUnknownProcedureWithLegacyMessage() { + IcebergTableHandle handle = new IcebergTableHandle("db", "tbl"); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> newOps().execute(null, handle, "no_such_proc", + Collections.emptyMap(), null, Collections.emptyList())); + Assertions.assertEquals( + "Unsupported Iceberg procedure: no_such_proc. Supported procedures: rollback_to_snapshot, " + + "rollback_to_timestamp, set_current_snapshot, cherrypick_snapshot, fast_forward, " + + "expire_snapshots, rewrite_data_files, publish_changes, rewrite_manifests", + e.getMessage()); + } + + // rewrite_data_files is advertised in the name list but its body is ported in T05/T06; until then it + // reaches the factory's "Unsupported" rejection (the whole path is dormant, so this is invisible). + @Test + public void rewriteDataFilesIsNotYetExecutable() { + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> newOps().execute(null, new IcebergTableHandle("db", "tbl"), "rewrite_data_files", + Collections.emptyMap(), null, Collections.emptyList())); + Assertions.assertTrue(e.getMessage().startsWith("Unsupported Iceberg procedure: rewrite_data_files"), + e.getMessage()); + } + + // ─────────────────── WS-REWRITE R3: planRewrite (DISTRIBUTED planning half) ─────────────────── + + @Test + public void planRewriteGroupsBinPackedFilesWithRawPaths() { + InMemoryCatalog catalog = tableWithThreeSmallFiles(); + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = catalog.loadTable(TableIdentifier.of("db1", "t")); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergProcedureOps procOps = new IcebergProcedureOps(Collections.emptyMap(), ops, ctx); + + // rewrite-all packs all three small files into one group (one bin, far under max-file-group-size), so a + // group is produced without needing the default min-input-files=5. + List groups = procOps.planRewrite(SESSION, new IcebergTableHandle("db1", "t"), + "rewrite_data_files", ImmutableMap.of("rewrite-all", "true"), null, Collections.emptyList()); + + Assertions.assertEquals(1, groups.size()); + ConnectorRewriteGroup g = groups.get(0); + // WHY: the engine driver scopes each group's INSERT-SELECT scan by these RAW data-file paths, so the + // group must carry them verbatim (the same raw path the scan provider matches — R2 [INV-M1]). MUTATION: + // toConnectorRewriteGroup mapping the normalized path / wrong field -> paths mismatch -> red. + Assertions.assertEquals( + ImmutableSet.of("s3://b/db1/f1.parquet", "s3://b/db1/f2.parquet", "s3://b/db1/f3.parquet"), + g.getDataFilePaths()); + // WHY: the per-group counts/size are summed into the rewrite result row, so they must be carried from the + // planner's group verbatim. MUTATION: any stat read off the wrong RewriteDataGroup accessor -> red. + 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. + Assertions.assertEquals(1, ctx.authCount, "planning runs in one auth scope"); + Assertions.assertTrue(ctx.invalidatedTables.isEmpty(), "planning must not invalidate the table"); + } + + @Test + public void planRewriteEmptyTableReturnsNoGroups() { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + Schema schema = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); + catalog.createTable(TableIdentifier.of("db1", "t"), schema, PartitionSpec.unpartitioned()); + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = catalog.loadTable(TableIdentifier.of("db1", "t")); + IcebergProcedureOps procOps = + new IcebergProcedureOps(Collections.emptyMap(), ops, new RecordingConnectorContext()); + + List groups = procOps.planRewrite(SESSION, new IcebergTableHandle("db1", "t"), + "rewrite_data_files", Collections.emptyMap(), null, Collections.emptyList()); + + // WHY: an empty table (no current snapshot) has nothing to rewrite -> zero groups, so the engine driver + // emits the all-zero result row (the legacy short-circuit). MUTATION: planning a non-existent snapshot + // -> exception or non-empty -> red. + Assertions.assertTrue(groups.isEmpty()); + } + + @Test + public void planRewriteValidatesArgsBeforeTouchingCatalog() { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + IcebergProcedureOps procOps = new IcebergProcedureOps(Collections.emptyMap(), ops, null); + + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> procOps.planRewrite(SESSION, new IcebergTableHandle("db1", "t"), "rewrite_data_files", + ImmutableMap.of("min-file-size-bytes", "100", "max-file-size-bytes", "50"), + null, Collections.emptyList())); + // WHY: argument validation (byte-identical message) runs BEFORE the catalog is touched, mirroring the + // single-call path. MUTATION: planning before validate() -> loadTable called / wrong message -> red. + Assertions.assertEquals( + "min-file-size-bytes must be less than or equal to max-file-size-bytes", e.getMessage()); + Assertions.assertTrue(ops.log.isEmpty(), "validation must fail before the catalog is loaded"); + } + + @Test + public void planRewriteRejectsNonRewriteProcedure() { + IcebergProcedureOps procOps = newOps(); + // WHY: only rewrite_data_files is DISTRIBUTED; a miswired caller passing another name must fail loud, not + // build a rewrite action for the wrong procedure. MUTATION: dropping the name guard -> a rollback name + // would build a rewrite action -> no throw here -> red. + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> procOps.planRewrite(SESSION, new IcebergTableHandle("db1", "t"), "rollback_to_snapshot", + Collections.emptyMap(), null, Collections.emptyList())); + Assertions.assertTrue(e.getMessage().contains("Unsupported distributed iceberg procedure"), + e.getMessage()); + } + + // ─────────────────── catalog-backed dispatch (T04) ─────────────────── + + @Test + public void runsBodyInOneAuthScopeAndDoesNotInvalidateAtDispatch() { + InMemoryCatalog catalog = catalogWithTwoSnapshots(); + TableIdentifier id = TableIdentifier.of("db1", "t"); + long snap1 = catalog.loadTable(id).history().get(0).snapshotId(); + long snap2 = catalog.loadTable(id).currentSnapshot().snapshotId(); + + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = catalog.loadTable(id); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergProcedureOps procOps = new IcebergProcedureOps(Collections.emptyMap(), ops, ctx); + + ConnectorProcedureResult result = procOps.execute(SESSION, new IcebergTableHandle("db1", "t"), + "rollback_to_snapshot", ImmutableMap.of("snapshot_id", String.valueOf(snap1)), + null, Collections.emptyList()); + + 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()); + } + + @Test + public void threadsSessionToTimestampBody() { + InMemoryCatalog catalog = catalogWithTwoSnapshots(); + TableIdentifier id = TableIdentifier.of("db1", "t"); + long snap1 = catalog.loadTable(id).history().get(0).snapshotId(); + long snap2 = catalog.loadTable(id).currentSnapshot().snapshotId(); + long t2 = catalog.loadTable(id).currentSnapshot().timestampMillis(); + + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = catalog.loadTable(id); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergProcedureOps procOps = new IcebergProcedureOps(Collections.emptyMap(), ops, ctx); + + // rollback_to_timestamp consults SESSION's time zone (it must be threaded through dispatch). Rolling + // back to snap2's instant lands on snap1 (the latest snapshot strictly older than t2). Reaching this + // result without NPE proves the session reached the body. + ConnectorProcedureResult result = procOps.execute(SESSION, new IcebergTableHandle("db1", "t"), + "rollback_to_timestamp", ImmutableMap.of("timestamp", String.valueOf(t2)), + null, Collections.emptyList()); + + 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 + public void failedAuthSurfacesAndDoesNotInvalidate() { + InMemoryCatalog catalog = catalogWithTwoSnapshots(); + TableIdentifier id = TableIdentifier.of("db1", "t"); + long snap1 = catalog.loadTable(id).history().get(0).snapshotId(); + + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = catalog.loadTable(id); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + IcebergProcedureOps procOps = new IcebergProcedureOps(Collections.emptyMap(), ops, ctx); + + 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 + public void argumentValidationRunsBeforeTouchingTheCatalog() { + // A bad argument is rejected before loadTable/auth — no catalog work happens. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergProcedureOps procOps = new IcebergProcedureOps(Collections.emptyMap(), ops, ctx); + + Assertions.assertThrows(DorisConnectorException.class, () -> procOps.execute(SESSION, + new IcebergTableHandle("db1", "t"), "rollback_to_snapshot", + Collections.emptyMap(), null, Collections.emptyList())); + Assertions.assertEquals(0, ctx.authCount, "validation precedes the auth-wrapped load"); + Assertions.assertTrue(ops.log.isEmpty(), "no catalog call before validation passes"); + } + + @Test + public void wrapsLoadTableFailure() { + // loadTable runs INSIDE executeAuthenticated and throws a plain RuntimeException; runInAuthScope's + // generic catch wraps it with the "Failed to load iceberg table" prefix (the DorisConnectorException + // re-throw branch is for body failures, not load failures). The wrap happens before the dispatch-level + // invalidation, so a load failure must not invalidate the cache. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.throwOnLoadTable = true; + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergProcedureOps procOps = new IcebergProcedureOps(Collections.emptyMap(), ops, ctx); + + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> procOps.execute(SESSION, new IcebergTableHandle("db1", "t"), "rollback_to_snapshot", + 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 + public void rollbackToCurrentSnapshotShortCircuitsWithoutCommit() { + // Rolling back to the snapshot that is ALREADY current short-circuits in the body (no commit). The + // connector dispatch invalidates nothing regardless (engine owns invalidation), so the no-op short + // circuit and a real commit are indistinguishable from the connector's cache perspective. + InMemoryCatalog catalog = catalogWithTwoSnapshots(); + TableIdentifier id = TableIdentifier.of("db1", "t"); + long snap2 = catalog.loadTable(id).currentSnapshot().snapshotId(); + long historyBefore = catalog.loadTable(id).history().size(); + + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = catalog.loadTable(id); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergProcedureOps procOps = new IcebergProcedureOps(Collections.emptyMap(), ops, ctx); + + ConnectorProcedureResult result = procOps.execute(SESSION, new IcebergTableHandle("db1", "t"), + "rollback_to_snapshot", ImmutableMap.of("snapshot_id", String.valueOf(snap2)), + null, Collections.emptyList()); + + 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 + public void bodyFailureAfterSuccessfulLoadDoesNotInvalidate() { + // The load succeeds (under auth), then the body throws because the snapshot id does not exist. This is + // distinct from failedAuth (where the body never runs): authCount is 1, and the dispatch-level + // invalidation still must not fire because the body's DorisConnectorException is re-thrown before it. + InMemoryCatalog catalog = catalogWithTwoSnapshots(); + TableIdentifier id = TableIdentifier.of("db1", "t"); + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = catalog.loadTable(id); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + IcebergProcedureOps procOps = new IcebergProcedureOps(Collections.emptyMap(), ops, ctx); + + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> procOps.execute(SESSION, new IcebergTableHandle("db1", "t"), "rollback_to_snapshot", + 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() { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + Schema schema = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); + catalog.createTable(TableIdentifier.of("db1", "t"), schema, PartitionSpec.unpartitioned()); + append(catalog, "f1", 1L); + append(catalog, "f2", 1L); + append(catalog, "f3", 1L); + return catalog; + } + + private static InMemoryCatalog catalogWithTwoSnapshots() { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + Schema schema = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); + catalog.createTable(TableIdentifier.of("db1", "t"), schema, PartitionSpec.unpartitioned()); + append(catalog, "f1", 1L); + sleepForDistinctSnapshotTimestamp(); + append(catalog, "f2", 2L); + return catalog; + } + + private static void sleepForDistinctSnapshotTimestamp() { + // Ensure snap2 gets a strictly-later commit timestamp than snap1 so rollback_to_timestamp(t2) lands + // deterministically on snap1 (the latest snapshot strictly older than t2). + try { + Thread.sleep(5); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static void append(InMemoryCatalog catalog, String file, long records) { + TableIdentifier id = TableIdentifier.of("db1", "t"); + Table t = catalog.loadTable(id); + DataFile df = DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("s3://b/db1/" + file + ".parquet") + .withFileSizeInBytes(1024) + .withRecordCount(records) + .withFormat(FileFormat.PARQUET) + .build(); + t.newAppend().appendFile(df).commit(); + } + + /** Minimal {@link ConnectorSession} exposing a time zone (the only field the procedures consult). */ + private static final class TzSession implements ConnectorSession { + private final String timeZone; + + TzSession(String timeZone) { + this.timeZone = timeZone; + } + + @Override + public String getQueryId() { + return "q"; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return timeZone; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public String getCatalogName() { + return "test"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProviderSessionRoutingTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProviderSessionRoutingTest.java new file mode 100644 index 00000000000000..319bebec8600c5 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProviderSessionRoutingTest.java @@ -0,0 +1,195 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorDelegatedCredential; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.DorisConnectorException; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +/** + * Verifies the {@code iceberg.rest.session=user} per-request routing that {@link IcebergConnector} wires into the + * scan / write / procedure providers: each provider loads its table through a + * {@code Function} resolver applied with the CURRENT operation's session, so a + * session=user catalog resolves the querying user's per-request delegated catalog (fail-closed) instead of the + * session-less shared {@code asCatalog(empty)} that the REST server would reject. + * + *

    The connector passes {@code this::newCatalogBackedOps} as the resolver; these tests stand in a resolver that + * mirrors its two observable behaviours — reject a credential-less session (the fail-closed + * {@code IcebergSessionCatalogAdapter.delegatedCatalog}) and hand a credential-bearing session the per-user ops — + * and assert (a) the provider applies the resolver with the EXACT call session, and (b) the fail-closed rejection + * surfaces verbatim rather than wrapped. The legacy (session-less) constructors are covered by the existing + * provider tests, which build with a constant {@code s -> catalogOps} and stay byte-identical. + */ +public class IcebergProviderSessionRoutingTest { + + private static final ConnectorDelegatedCredential CRED = + new ConnectorDelegatedCredential(ConnectorDelegatedCredential.Type.ACCESS_TOKEN, "user-token"); + + /** + * A resolver mirroring {@code IcebergConnector.newCatalogBackedOps(session)} for a session=user catalog: a + * session with no delegated credential is rejected fail-closed (never served a shared identity), a + * credential-bearing session gets {@code perUserOps}. Records every session it is applied with so a test can + * assert the provider threaded the call session through, not some stashed/default one. + */ + private static Function failClosedResolver( + List seen, IcebergCatalogOps perUserOps) { + return session -> { + seen.add(session); + if (!session.getDelegatedCredential().isPresent()) { + throw new DorisConnectorException("Iceberg REST user session requires a delegated credential"); + } + return perUserOps; + }; + } + + @Test + public void scanProviderAppliesResolverWithCallSessionAndFailsClosed() { + List seen = new ArrayList<>(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), + failClosedResolver(seen, new RecordingIcebergCatalogOps()), null, null, null); + RoutingSession noCred = new RoutingSession(null); + + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> provider.getScanNodeProperties(noCred, new IcebergTableHandle("db1", "t1"), + Collections.emptyList(), Optional.empty())); + + Assertions.assertSame(noCred, seen.get(0), + "scan planning must resolve the ops with the exact call session (per-user routing)"); + Assertions.assertTrue(e.getMessage().contains("delegated credential"), + "the fail-closed rejection surfaces verbatim, not wrapped in a generic scan error"); + } + + @Test + public void scanProviderRoutesCredentialedSessionToPerUserOps() { + List seen = new ArrayList<>(); + RecordingIcebergCatalogOps perUserOps = new RecordingIcebergCatalogOps(); + // Record the loadTable call, then stop the method — the routing target is all this test asserts, not the + // downstream split planning (which would need a live table). + perUserOps.throwOnLoadTable = true; + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), + failClosedResolver(seen, perUserOps), null, null, null); + RoutingSession cred = new RoutingSession(CRED); + + Assertions.assertThrows(RuntimeException.class, + () -> provider.getScanNodeProperties(cred, new IcebergTableHandle("db1", "t1"), + Collections.emptyList(), Optional.empty())); + + Assertions.assertSame(cred, seen.get(0)); + Assertions.assertTrue(perUserOps.log.contains("loadTable:db1.t1"), + "the credentialed session's per-user ops (not a shared identity) loaded the table"); + } + + @Test + public void writeProviderAppliesResolverWithCallSessionAndFailsClosed() { + List seen = new ArrayList<>(); + IcebergWritePlanProvider provider = new IcebergWritePlanProvider(Collections.emptyMap(), + failClosedResolver(seen, new RecordingIcebergCatalogOps()), null); + RoutingSession noCred = new RoutingSession(null); + + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> provider.getWriteSortColumns(noCred, new IcebergTableHandle("db1", "t1"))); + + Assertions.assertSame(noCred, seen.get(0), + "the write-side table read must resolve the ops with the exact call session (per-user routing)"); + Assertions.assertTrue(e.getMessage().contains("delegated credential"), + "the fail-closed rejection surfaces verbatim"); + } + + @Test + public void procedureProviderAppliesResolverWithCallSessionAndFailsClosed() { + List seen = new ArrayList<>(); + IcebergProcedureOps procOps = new IcebergProcedureOps(Collections.emptyMap(), + failClosedResolver(seen, new RecordingIcebergCatalogOps()), null); + RoutingSession noCred = new RoutingSession(null); + + // A valid, fully-argumented procedure: createAction + validate pass, so the flow reaches the auth scope + // where the ops resolver is applied — and there the credential-less session is rejected fail-closed. + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> procOps.execute(noCred, new IcebergTableHandle("db1", "t1"), "rollback_to_snapshot", + Collections.singletonMap("snapshot_id", "1"), null, Collections.emptyList())); + + Assertions.assertSame(noCred, seen.get(0), + "ALTER TABLE ... EXECUTE must resolve the ops with the exact call session (per-user routing)"); + Assertions.assertTrue(e.getMessage().contains("delegated credential"), + "the fail-closed rejection surfaces verbatim"); + } + + /** Minimal {@link ConnectorSession} double carrying an optional delegated credential. */ + private static final class RoutingSession implements ConnectorSession { + private final ConnectorDelegatedCredential credential; + + RoutingSession(ConnectorDelegatedCredential credential) { + this.credential = credential; + } + + @Override + public Optional getDelegatedCredential() { + return Optional.ofNullable(credential); + } + + @Override + public String getQueryId() { + return "q1"; + } + + @Override + public String getUser() { + return "u1"; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public long getCatalogId() { + return 1L; + } + + @Override + public String getCatalogName() { + return "ice"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderClassifyColumnTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderClassifyColumnTest.java new file mode 100644 index 00000000000000..0f8f2a8b2640a7 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderClassifyColumnTest.java @@ -0,0 +1,81 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.scan.ConnectorColumnCategory; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +/** + * Guards {@link IcebergScanPlanProvider#classifyColumn(String)}, the iceberg half of P6.6-C2 + * (WS-SYNTH-READ): the generic {@code PluginDrivenScanNode} delegates connector-owned special columns here so + * no iceberg knowledge leaks into fe-core. + * + *

    WHY this matters: the hidden row-id column must be SYNTHESIZED (never read from the data file — the + * IcebergParquet/OrcReader materializes it) and the v3 row-lineage columns must be GENERATED (read from the + * file when present, otherwise backfilled). Misreporting either as DEFAULT demotes it to a REGULAR file slot, + * so the BE reads a column the file does not contain / loses the row-lineage backfill. The engine-wide + * {@code __DORIS_GLOBAL_ROWID_COL__} is intentionally NOT claimed here — it is a generic Doris mechanism the + * generic node owns — so this asserts the connector returns DEFAULT for it.

    + * + *

    {@code classifyColumn} is pure (no instance state), so the provider is built with an empty config and a + * null catalog seam.

    + */ +public class IcebergScanPlanProviderClassifyColumnTest { + + private static final IcebergScanPlanProvider PROVIDER = + new IcebergScanPlanProvider(Collections.emptyMap(), null); + + @Test + public void hiddenRowIdIsSynthesized() { + // MUTATION: removing the __DORIS_ICEBERG_ROWID_COL__ branch -> DEFAULT -> the generic node tags it + // REGULAR (a file slot) -> the BE reads a non-existent file column -> red. + Assertions.assertEquals(ConnectorColumnCategory.SYNTHESIZED, + PROVIDER.classifyColumn("__DORIS_ICEBERG_ROWID_COL__")); + // Case-insensitive, mirroring legacy IcebergScanNode (Column.ICEBERG_ROWID_COL.equalsIgnoreCase). + Assertions.assertEquals(ConnectorColumnCategory.SYNTHESIZED, + PROVIDER.classifyColumn("__doris_iceberg_rowid_col__")); + } + + @Test + public void rowLineageColumnsAreGenerated() { + // MUTATION: removing the row-lineage branch -> DEFAULT -> REGULAR -> loses GENERATED backfill -> red. + Assertions.assertEquals(ConnectorColumnCategory.GENERATED, PROVIDER.classifyColumn("_row_id")); + Assertions.assertEquals(ConnectorColumnCategory.GENERATED, + PROVIDER.classifyColumn("_last_updated_sequence_number")); + } + + @Test + public void globalRowIdIsNotClaimedByConnector() { + // WHY: GLOBAL_ROWID is a generic Doris lazy-mat mechanism owned by the generic node, NOT iceberg. + // The connector must return DEFAULT so the split (fe-core handles GLOBAL_ROWID) holds. MUTATION: + // adding a GLOBAL_ROWID branch here would double-own it -> this assertion -> red. + Assertions.assertEquals(ConnectorColumnCategory.DEFAULT, + PROVIDER.classifyColumn("__DORIS_GLOBAL_ROWID_COL__my_tbl")); + } + + @Test + public void regularColumnIsDefault() { + // WHY: ordinary data columns must fall through to the generic node's partition-key/regular logic. + Assertions.assertEquals(ConnectorColumnCategory.DEFAULT, PROVIDER.classifyColumn("id")); + Assertions.assertEquals(ConnectorColumnCategory.DEFAULT, PROVIDER.classifyColumn("name")); + } +} 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 new file mode 100644 index 00000000000000..43cda4adb9e9c9 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderKerberosScanIoTest.java @@ -0,0 +1,206 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.kerberos.HadoopAuthenticator; + +import org.apache.hadoop.security.UserGroupInformation; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +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 / + * write-FileIO / temp()): {@code planScan}'s manifest-list + manifest reads must route through the + * plugin-side {@code doAs}, not just the {@code loadTable} inside {@code resolveTable}. + * + *

    WHY it matters: on a Kerberos hadoop catalog the plugin bundles hadoop child-first, so its HDFS + * FileSystem reads the PLUGIN's UserGroupInformation copy — logged in only inside the plugin + * authenticator's {@code doAs}. {@code SnapshotScan.planFiles()} reads {@code snap-*.avro} through + * {@code table.io()} on the planning thread (and iceberg's shared worker pool for multi-manifest + * tables, which never inherits a caller-thread doAs) — CI proof: test_iceberg_hadoop_catalog_kerberos' + * SELECT after INSERT failed SASL ("Client cannot authenticate via:[TOKEN, KERBEROS]") at exactly this + * read once the INSERT-side loci were fixed. Red on "manifest reads escape doAs" = that regression. + * + *

    No Mockito — real {@link InMemoryCatalog} tables and recording doubles, mirroring + * {@link IcebergScanPlanProviderTest} / {@code TcclPinningConnectorContextTest}. + */ +public class IcebergScanPlanProviderKerberosScanIoTest { + + private static final Schema SCHEMA = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get())); + + private static Table oneFileTable() { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + Table table = catalog.createTable( + TableIdentifier.of("db1", "t1"), SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(DataFiles.builder(table.spec()) + .withPath("s3://b/db/t1/f1.parquet") + .withFileSizeInBytes(1024) + .withRecordCount(10) + .withFormat(FileFormat.PARQUET) + .build()) + .commit(); + return table; + } + + private static RecordingIcebergCatalogOps opsReturning(Table table) { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = table; + return ops; + } + + /** Counts plugin-side doAs invocations; runs the action inline (no real UGI/KDC involved). */ + private static final class RecordingAuthenticator implements HadoopAuthenticator { + int doAsCount; + + @Override + public UserGroupInformation getUGI() { + throw new UnsupportedOperationException("doAs is overridden; no real UGI in this test"); + } + + @Override + public T doAs(PrivilegedExceptionAction action) throws IOException { + doAsCount++; + try { + return action.run(); + } catch (IOException e) { + throw e; + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + @Test + public void kerberosPlanScanRoutesManifestReadsThroughPluginDoAs() { + Table table = oneFileTable(); + RecordingAuthenticator auth = new RecordingAuthenticator(); + TcclPinningConnectorContext context = new TcclPinningConnectorContext( + new RecordingConnectorContext(), getClass().getClassLoader(), () -> auth); + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); + + List ranges = provider.planScan( + null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + + Assertions.assertEquals(1, ranges.size()); + // MUTATION: dropping wrapTableForScan from resolveTable leaves exactly ONE doAs (the loadTable wrap) + // and planFiles' manifest-list/manifest reads escape the plugin doAs -> the CI SASL failure -> red. + Assertions.assertTrue(auth.doAsCount > 1, + "planFiles must read the manifest list/manifests through the authenticated FileIO " + + "(factory-time doAs); got only " + auth.doAsCount + + " doAs call(s), i.e. nothing beyond the resolveTable loadTable wrap"); + } + + @Test + public void nonKerberosPlanScanKeepsDelegatePathAndNoWrap() { + Table table = oneFileTable(); + RecordingConnectorContext delegate = new RecordingConnectorContext(); + // Null plugin authenticator = non-Kerberos catalog: executeAuthenticated must delegate as-is and + // the resolved table must NOT be wrapped (byte-preserved legacy behavior). + TcclPinningConnectorContext context = new TcclPinningConnectorContext( + delegate, getClass().getClassLoader(), () -> null); + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); + + List ranges = provider.planScan( + null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + + Assertions.assertEquals(1, ranges.size()); + Assertions.assertEquals(1, delegate.authCount, + "non-Kerberos must keep exactly the one delegate executeAuthenticated (loadTable)"); + Assertions.assertSame(table, provider.wrapTableForScan(table), + "non-Kerberos wrap must be an identity pass-through"); + } + + @Test + public void wrapTableForScanWrapsIoFactoryCallsInPluginDoAs() { + Table table = oneFileTable(); + RecordingAuthenticator auth = new RecordingAuthenticator(); + TcclPinningConnectorContext context = new TcclPinningConnectorContext( + new RecordingConnectorContext(), getClass().getClassLoader(), () -> auth); + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); + + Table wrapped = provider.wrapTableForScan(table); + + Assertions.assertNotSame(table, wrapped); + Assertions.assertTrue(wrapped instanceof BaseTable, "wrap must preserve BaseTable-ness " + + "(getFormatVersion and MetadataTableUtils cast to BaseTable)"); + int before = auth.doAsCount; + wrapped.io().newInputFile(table.currentSnapshot().manifestListLocation()); + Assertions.assertEquals(before + 1, auth.doAsCount, + "io() factory calls must run inside the plugin doAs — that is what captures the secured " + + "FileSystem for later newStream() on any thread (incl. iceberg's worker pool)"); + } + + @Test + public void plainContextWrapIsIdentityPassThrough() { + Table table = oneFileTable(); + // A non-TcclPinning context (offline tests / fe-core fakes) must never be wrapped. + IcebergScanPlanProvider provider = new IcebergScanPlanProvider( + Collections.emptyMap(), opsReturning(table), new RecordingConnectorContext()); + + Assertions.assertSame(table, provider.wrapTableForScan(table)); + } + + @Test + public void sysTablePlanningRunsInsideAuthScope() { + Table table = oneFileTable(); + RecordingConnectorContext context = new RecordingConnectorContext(); + 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()); + + 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 + // load AND the metadata-table planFiles — resolveSysTable carries no wrap of its own) -> authCount 0 + // -> red: the $files-family manifest-list read on the planning thread escapes the Kerberos doAs. + // (Object-level wrap is deliberately NOT used here: the planned FileScanTasks are Java-serialized + // for the BE JNI reader.) + Assertions.assertEquals(1, context.authCount, + "system-table planning (base load + planFiles + task serialization) must sit inside " + + "exactly ONE executeAuthenticated scope"); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderPartitionCountTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderPartitionCountTest.java new file mode 100644 index 00000000000000..cc37e9feeb821b --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderPartitionCountTest.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.connector.iceberg; + +import org.apache.doris.connector.api.scan.ConnectorScanRange; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.OptionalLong; + +/** + * FIX-L12 — guards {@link IcebergScanPlanProvider#scannedPartitionCount} and the + * {@link IcebergScanRange#getScannedPartitionKey()} identity it counts, which restore legacy + * {@code IcebergScanNode}'s {@code selectedPartitionNum = partitionMapInfos.size()} (keyed by + * {@code (PartitionData) file().partition()}). + * + *

    Why this matters: for a hidden/transform-partitioned iceberg table ({@code days(ts)}, + * {@code bucket(n,id)}) the engine's Nereids pruning only sees the declared source columns and cannot map + * a predicate on {@code ts} to the {@code days(ts)} partition, so it over-reports the scanned-partition + * count (feeding EXPLAIN {@code partition=N/M} and the {@code sql_block_rule} {@code partition_num} guard). + * The connector, which resolves the real partitions via manifest/residual evaluation, reports the faithful + * distinct count. The key is {@code specId|partitionDataJson} so two files are counted equal iff they share + * the same spec and partition tuple — disambiguating cross-spec value collisions the value-only json merges.

    + */ +public class IcebergScanPlanProviderPartitionCountTest { + + private static IcebergScanRange range(String path, Integer specId, String partitionDataJson) { + IcebergScanRange.Builder builder = new IcebergScanRange.Builder().path(path); + if (specId != null) { + builder.partitionSpecId(specId); + } + if (partitionDataJson != null) { + builder.partitionDataJson(partitionDataJson); + } + return builder.build(); + } + + @Test + public void scannedPartitionKeyCombinesSpecAndData() { + // The identity is specId|partitionDataJson; null when the file carries no PartitionData (unpartitioned). + Assertions.assertEquals("0|[\"1\"]", range("/t/f.parquet", 0, "[\"1\"]").getScannedPartitionKey()); + Assertions.assertNull(range("/t/f.parquet", null, null).getScannedPartitionKey()); + } + + @Test + public void scannedPartitionKeyDisambiguatesCrossSpecCollision() { + // identity(id)=2 (spec 0) vs bucket(id)=2 (spec 1) both render ["2"] but are DISTINCT partitions; + // including the spec id keeps them apart, matching legacy's PartitionData-object de-dup. + Assertions.assertNotEquals( + range("/t/a.parquet", 0, "[\"2\"]").getScannedPartitionKey(), + range("/t/b.parquet", 1, "[\"2\"]").getScannedPartitionKey()); + } + + @Test + public void scannedPartitionCountReturnsDistinctPartitions() { + // FIX-L12 THE load-bearing RED assertion: three files over TWO distinct partitions (two files in + // partition [1] + one in [2]) count 2, not 3. A mutation dropping the override (default + // OptionalLong.empty()) makes this red. + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), null); + List ranges = Arrays.asList( + range("/t/a.parquet", 0, "[\"1\"]"), + range("/t/b.parquet", 0, "[\"1\"]"), + range("/t/c.parquet", 0, "[\"2\"]")); + Assertions.assertEquals(OptionalLong.of(2L), provider.scannedPartitionCount(ranges)); + } + + @Test + public void scannedPartitionCountEmptyForUnpartitionedTable() { + // No range carries a partition key (unpartitioned) -> report nothing so the engine keeps its count. + // (Same value as the un-overridden default; documents the fall-through, not RED-able.) + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), null); + List ranges = Arrays.asList( + range("/t/a.parquet", null, null), + range("/t/b.parquet", null, null)); + Assertions.assertEquals(OptionalLong.empty(), provider.scannedPartitionCount(ranges)); + } +} 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 new file mode 100644 index 00000000000000..b10d13f0788d84 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java @@ -0,0 +1,3093 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.handle.ConnectorColumnHandle; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.api.scan.ConnectorScanRangeType; +import org.apache.doris.connector.api.scan.ConnectorSplitSource; +import org.apache.doris.filesystem.FileSystemType; +import org.apache.doris.filesystem.properties.BackendStorageKind; +import org.apache.doris.filesystem.properties.BackendStorageProperties; +import org.apache.doris.filesystem.properties.StorageKind; +import org.apache.doris.filesystem.properties.StorageProperties; +import org.apache.doris.thrift.TFileFormatType; +import org.apache.doris.thrift.TFileRangeDesc; +import org.apache.doris.thrift.TFileScanRangeParams; +import org.apache.doris.thrift.TIcebergDeleteFileDesc; +import org.apache.doris.thrift.TIcebergFileDesc; +import org.apache.doris.thrift.TTableFormatFileDesc; +import org.apache.doris.thrift.schema.external.TFieldPtr; + +import com.google.common.collect.ImmutableSet; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileMetadata; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Metrics; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.io.StorageCredential; +import org.apache.iceberg.io.SupportsStorageCredentials; +import org.apache.iceberg.types.Conversions; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.SerializationUtil; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Set; +import java.util.function.UnaryOperator; + +/** + * Tests for {@link IcebergScanPlanProvider}. T01 pinned the capability constants + that {@code planScan} + * resolves the table through the {@link IcebergCatalogOps} seam inside the auth context. T02 adds the real + * split planning: predicate pushdown ({@link IcebergPredicateConverter}), {@code createTableScan}, and + * {@code TableScanUtil}-based split enumeration. The provider is exercised against a REAL in-memory iceberg + * table ({@link InMemoryCatalog} + appended {@link DataFile} metadata — no Parquet I/O, fully offline) so + * {@code table.newScan().planFiles()} returns genuine {@code FileScanTask}s. No Mockito. + */ +public class IcebergScanPlanProviderTest { + + private static final Schema SCHEMA = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get())); + + private static final Schema PART_SCHEMA = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "p", Types.IntegerType.get())); + + // --- in-memory iceberg table helpers (offline; DataFile metadata only, no real data files) --- + + private static Table createTable(String name, Schema schema, PartitionSpec spec) { + return createTable(name, schema, spec, Collections.emptyMap()); + } + + private static Table createTable(String name, Schema schema, PartitionSpec spec, Map props) { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + return catalog.createTable(TableIdentifier.of("db1", name), schema, spec, null, props); + } + + private static DataFile dataFile(PartitionSpec spec, String path, long sizeBytes, List splitOffsets, + String partitionPath) { + return dataFile(spec, path, sizeBytes, splitOffsets, partitionPath, FileFormat.PARQUET); + } + + private static DataFile dataFile(PartitionSpec spec, String path, long sizeBytes, List splitOffsets, + String partitionPath, FileFormat format) { + DataFiles.Builder builder = DataFiles.builder(spec) + .withPath(path) + .withFileSizeInBytes(sizeBytes) + .withRecordCount(Math.max(1, sizeBytes / 100)) + .withFormat(format); + if (splitOffsets != null) { + builder.withSplitOffsets(splitOffsets); + } + if (partitionPath != null) { + builder.withPartitionPath(partitionPath); + } + return builder.build(); + } + + /** Run a range's BE-param population end-to-end (the generic node pre-sets table_format_type). */ + private static TFileRangeDesc populate(ConnectorScanRange range) { + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + formatDesc.setTableFormatType(range.getTableFormatType()); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(formatDesc, rangeDesc); + rangeDesc.setTableFormatParams(formatDesc); + return rangeDesc; + } + + private static ConnectorScanRange byPath(List ranges, String suffix) { + return ranges.stream().filter(r -> r.getPath().get().endsWith(suffix)).findFirst() + .orElseThrow(() -> new AssertionError("no range ending in " + suffix)); + } + + private static RecordingIcebergCatalogOps opsReturning(Table table) { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = table; + return ops; + } + + private static ConnectorExpression eqInt(String col, int value) { + return new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef(col, ConnectorType.of("INT")), + new ConnectorLiteral(ConnectorType.of("INT"), (long) 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 = + new IcebergScanPlanProvider(Collections.emptyMap(), new RecordingIcebergCatalogOps()); + // WHY: iceberg is predicate-driven — it re-plans through its own SDK from the pushed predicate and + // never consults requiredPartitions (same as the legacy IcebergScanNode / paimon). So a GENUINE FE + // prune-to-zero must scan-all rather than short-circuit to zero rows. MUTATION: default false -> red. + Assertions.assertTrue(provider.ignorePartitionPruneShortCircuit()); + } + + @Test + public void supportsSystemTableTimeTravelIsTrue() { + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(Collections.emptyMap(), new RecordingIcebergCatalogOps()); + // WHY: iceberg metadata tables legally time-travel (t$snapshots FOR TIME AS OF ..., t$files@branch). + // legacy IcebergScanNode.createTableScan honors useRef/useSnapshot for sys tables with no + // isSystemTable gate, and this provider retains+honors the pin. So the generic + // PluginDrivenScanNode sys-table guard must let pinned iceberg sys reads through — unlike paimon, + // whose binlog/audit_log sys tables keep the SPI default false. MUTATION: drop the override + // (inherit default false) -> the fe-core guard would reject t$snapshots FOR TIME AS OF -> red. + Assertions.assertTrue(provider.supportsSystemTableTimeTravel()); + } + + @Test + public void planScanResolvesTableViaSeamAndEmptyTableReturnsNoSplits() { + // An empty table (no snapshot) plans no files -> no ranges; proves the (db, table) coordinates were + // threaded through the seam to loadTable, and the real scan path tolerates an empty table. + 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()); + + Assertions.assertTrue(ranges.isEmpty()); + Assertions.assertEquals("db1", ops.lastLoadDb); + Assertions.assertEquals("t1", ops.lastLoadTable); + } + + @Test + public void planScanResolvesTableInsideAuthContext() { + // The remote loadTable must sit INSIDE context.executeAuthenticated so the FE-injected Kerberos UGI + // applies (mirrors IcebergConnectorMetadata + paimon's PaimonScanPlanProvider.resolveTable). + RecordingIcebergCatalogOps ops = opsReturning(createTable("t1", SCHEMA, PartitionSpec.unpartitioned())); + RecordingConnectorContext context = new RecordingConnectorContext(); + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(Collections.emptyMap(), ops, context); + + provider.planScan(null, new IcebergTableHandle("db1", "t1"), + Collections.emptyList(), Optional.empty()); + + // MUTATION: resolving the table OUTSIDE the auth wrap -> authCount stays 0 -> red. + Assertions.assertEquals(1, context.authCount); + Assertions.assertEquals("db1", ops.lastLoadDb); + } + + @Test + public void planningPassLoadsSameTableOnceViaSharedScope() { + // PERF-07 metric gate: a statement's metadata read (getColumnHandles) and scan planning (planScan) resolve + // the SAME table. Sharing ONE per-statement scope across both (same session) collapses BOTH remote reads + // onto a single loadTable RPC. This is the deterministic core claim — one remote loadTable per table per + // statement, independent of the cross-query cache (off here: disabled metadata cache, null-cache provider). + // MUTATION: not routing through the scope (each resolve re-loads) -> 2 loadTable log entries -> red. + Table empty = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + RecordingIcebergCatalogOps ops = opsReturning(empty); + IcebergConnectorMetadata metadata = + new IcebergConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext()); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), ops); + IcebergTableHandle handle = new IcebergTableHandle("db1", "t1"); + ConnectorSession session = new FakeScanSession("UTC", Collections.emptyMap()) + .withScope(new TestStatementScope()); + + metadata.getColumnHandles(session, handle); + provider.planScan(session, handle, Collections.emptyList(), Optional.empty()); + + long remoteLoads = ops.log.stream().filter("loadTable:db1.t1"::equals).count(); + Assertions.assertEquals(1, remoteLoads, + "the per-statement scope must collapse the metadata + provider reads to one remote loadTable"); + } + + @Test + public void planningPassWithoutSharedScopeLoadsEachTime() { + // Contrast to the shared-scope gate: with NONE (no live statement scope) each resolver loads independently + // (byte-identical to the pre-scope offline behavior). MUTATION: memoizing under NONE -> 1 load -> red. + Table empty = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + RecordingIcebergCatalogOps ops = opsReturning(empty); + IcebergConnectorMetadata metadata = + new IcebergConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext()); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), ops); + IcebergTableHandle handle = new IcebergTableHandle("db1", "t1"); + ConnectorSession none = new FakeScanSession("UTC", Collections.emptyMap()); + + metadata.getColumnHandles(none, handle); + provider.planScan(none, handle, Collections.emptyList(), Optional.empty()); + + long remoteLoads = ops.log.stream().filter("loadTable:db1.t1"::equals).count(); + Assertions.assertEquals(2, remoteLoads, "under NONE each resolver loads (no memo)"); + } + + // --- T02 split-enumeration + predicate-pushdown tests --- + + @Test + public void planScanEnumeratesOneRangePerDataFile() { + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)) + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 2048, null, null)) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + List ranges = provider.planScan( + null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + + // 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. + Assertions.assertEquals(2, ranges.size()); + ranges.sort((a, b) -> a.getPath().get().compareTo(b.getPath().get())); + Assertions.assertEquals("s3://b/db/t1/f1.parquet", ranges.get(0).getPath().get()); + Assertions.assertEquals(0L, ranges.get(0).getStart()); + Assertions.assertEquals(1024L, ranges.get(0).getLength()); + Assertions.assertEquals(1024L, ranges.get(0).getFileSize()); + Assertions.assertEquals(2048L, ranges.get(1).getLength()); + } + + @Test + public void planScanRewriteFileScopeKeepsOnlyRawScopedFiles() { + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + // oss:// data-file paths: the context normalizes oss:// -> s3:// for the BE-facing range path, so this + // test proves the rewrite scope matches the RAW iceberg path (oss://) and NOT the normalized BE path. + table.newAppend() + .appendFile(dataFile(table.spec(), "oss://b/db/t1/f1.parquet", 1024, null, null)) + .appendFile(dataFile(table.spec(), "oss://b/db/t1/f2.parquet", 2048, null, null)) + .appendFile(dataFile(table.spec(), "oss://b/db/t1/f3.parquet", 4096, null, null)) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider( + Collections.emptyMap(), opsReturning(table), new RecordingConnectorContext()); + + // 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()); + + // 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 + // (3 files) -> red, an over-read whose RewriteFiles commit would replace more than the group (duplicate + // rows). MUTATION (the normalization landmine): matching the normalized BE path (range .path(), s3://) + // instead of dataFile.path() (oss://) -> the oss:// scope matches NOTHING -> 0 ranges -> red. + Set keptRawPaths = ranges.stream() + .map(r -> ((IcebergScanRange) r).getOriginalPath()) + .collect(ImmutableSet.toImmutableSet()); + Assertions.assertEquals( + ImmutableSet.of("oss://b/db/t1/f1.parquet", "oss://b/db/t1/f3.parquet"), keptRawPaths, + "rewrite scope must keep ONLY its files, matched by raw path; f2 dropped"); + // The kept files are still normalized for BE (the scope filter runs on the raw path BEFORE normalize). + Assertions.assertTrue(ranges.stream().allMatch(r -> r.getPath().get().startsWith("s3://")), + "kept ranges still carry the scheme-normalized BE path"); + } + + @Test + public void planScanNullRewriteScopeReadsAllFiles() { + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)) + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 2048, null, null)) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + // 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()); + Assertions.assertEquals(2, ranges.size()); + } + + @Test + public void planScanSplitsLargeFileByFileSplitSizeSessionVar() { + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + long mb = 1024L * 1024L; + // 96MB file with row-group split offsets at 0 / 32MB / 64MB. + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/big.parquet", 96 * mb, + Arrays.asList(0L, 32 * mb, 64 * mb), null)) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + // file_split_size = 32MB forces splitting at that granularity (mirrors SessionVariable override path). + 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()); + + // 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. + Assertions.assertTrue(ranges.size() > 1, "expected the 96MB file to split, got " + ranges.size()); + long expectedStart = 0; + long totalLength = 0; + for (ConnectorScanRange r : ranges) { + Assertions.assertEquals("s3://b/db/t1/big.parquet", r.getPath().get()); + Assertions.assertEquals(96 * mb, r.getFileSize()); + Assertions.assertEquals(expectedStart, r.getStart(), "ranges must tile contiguously from 0"); + expectedStart += r.getLength(); + totalLength += r.getLength(); + } + Assertions.assertEquals(96 * mb, totalLength, "the split ranges must cover the whole file exactly"); + } + + @Test + public void planScanMemoizesPerFileInvariantsAcrossByteSlices() { + // PERF-11 (C12/C15a): a data file split into k byte-slices must compute its per-file invariants + // (partition JSON, ordered partition values, delete carriers, format, normalized path) exactly ONCE, + // not once per slice — while each slice keeps its own byte start/length. MUTATION: recomputing per slice + // (dropping the PerFileScratch reuse) -> perFileInvariantComputeCount == k -> red; memoizing start/length + // into the scratch -> the contiguous-tiling assertion -> red. + long mb = 1024L * 1024L; + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("p").build(); + Table table = createTable("pt", PART_SCHEMA, spec); + table.newAppend() + .appendFile(dataFile(spec, "s3://b/db/pt/p=7/big.parquet", 96 * mb, + Arrays.asList(0L, 32 * mb, 64 * mb), "p=7")) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + ConnectorSession session = new FakeScanSession("UTC", + Collections.singletonMap("file_split_size", Long.toString(32 * mb))); + + List ranges = provider.planScan( + session, new IcebergTableHandle("db1", "pt"), Collections.emptyList(), Optional.empty()); + + Assertions.assertTrue(ranges.size() > 1, "expected the 96MB file to split, got " + ranges.size()); + // The per-file invariants were computed ONCE for the whole file — the memo gate. + Assertions.assertEquals(1, provider.perFileInvariantComputeCount, + "per-file invariants must be computed once per file, not once per byte-slice"); + // Every slice carries byte-identical per-file params (partition values + JSON) but its own tiling range. + long expectedStart = 0; + for (ConnectorScanRange r : ranges) { + Assertions.assertEquals(expectedStart, r.getStart(), "slices must tile contiguously from 0"); + Assertions.assertEquals(Collections.singletonMap("p", "7"), r.getPartitionValues(), + "every slice of the file carries the same identity partition values"); + Assertions.assertEquals("[\"7\"]", + populate(r).getTableFormatParams().getIcebergParams().getPartitionDataJson(), + "every slice carries the same partition_data_json"); + expectedStart += r.getLength(); + } + Assertions.assertEquals(96 * mb, expectedStart, "the split ranges must cover the whole file exactly"); + } + + @Test + public void planScanComputesPerFileInvariantsOncePerDistinctFile() { + // The memo recomputes on each file change and never returns a stale file's invariants: two split files + // -> perFileInvariantComputeCount == 2 (once per DISTINCT data file), regardless of total slice count, + // and each file's slices carry that file's OWN partition values. + long mb = 1024L * 1024L; + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("p").build(); + Table table = createTable("pt", PART_SCHEMA, spec); + table.newAppend() + .appendFile(dataFile(spec, "s3://b/db/pt/p=1/a.parquet", 96 * mb, + Arrays.asList(0L, 32 * mb, 64 * mb), "p=1")) + .appendFile(dataFile(spec, "s3://b/db/pt/p=2/b.parquet", 96 * mb, + Arrays.asList(0L, 32 * mb, 64 * mb), "p=2")) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + ConnectorSession session = new FakeScanSession("UTC", + Collections.singletonMap("file_split_size", Long.toString(32 * mb))); + + List ranges = provider.planScan( + session, new IcebergTableHandle("db1", "pt"), Collections.emptyList(), Optional.empty()); + + Assertions.assertTrue(ranges.size() >= 4, + "two 96MB files at 32MB splits -> >=4 slices, got " + ranges.size()); + Assertions.assertEquals(2, provider.perFileInvariantComputeCount, + "per-file invariants must be computed once per distinct data file, not per slice"); + Assertions.assertEquals(Collections.singletonMap("p", "1"), + byPath(ranges, "p=1/a.parquet").getPartitionValues(), + "file a's slices carry p=1 (no cross-file staleness)"); + Assertions.assertEquals(Collections.singletonMap("p", "2"), + byPath(ranges, "p=2/b.parquet").getPartitionValues(), + "file b's slices carry p=2 (no cross-file staleness)"); + } + + // ── M-2: size-proportional BE scheduling weight (selfSplitWeight / targetSplitSize) ── + + @Test + public void planScanRangesCarrySizeProportionalWeight() { + // M-2: each data-file range must carry a size-based weight numerator (selfSplitWeight == the split byte + // length when there are no deletes) and a positive scan-level denominator (targetSplitSize == + // determineTargetFileSplitSize) so FederationBackendPolicy schedules by bytes, not by split count. Two + // differently-sized files -> different weights, identical denominator -> proportional (NOT standard()). + // MUTATION: dropping .selfSplitWeight / .targetSplitSize in buildRange (range stays -1/-1) -> red. + long mb = 1024L * 1024L; + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)) + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 2048, null, null)) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + List ranges = provider.planScan( + null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + + 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 + // differ -> the weight tracks bytes. MUTATION: dropping the += delete or the .selfSplitWeight set -> red. + Assertions.assertEquals(1024L, ranges.get(0).getSelfSplitWeight()); + Assertions.assertEquals(2048L, ranges.get(1).getSelfSplitWeight()); + // denominator == determineTargetFileSplitSize: a ~3KB table stays at the 32MB max_initial_file_split_size + // default, identical across ranges so the weights are comparable. A positive denominator is also what + // flips PluginDrivenSplit off SplitWeight.standard(). MUTATION: -1 (unset) -> red. + Assertions.assertEquals(32 * mb, ranges.get(0).getTargetSplitSize()); + Assertions.assertEquals(32 * mb, ranges.get(1).getTargetSplitSize()); + } + + @Test + public void planScanSelfSplitWeightIncludesDeleteFileSizes() { + // M-2 parity: legacy IcebergSplit.setDeleteFileFilters adds each merge-on-read delete file's byte size to + // selfSplitWeight (a data file carrying deletes costs more to read). data 512 + one 128-byte position + // delete -> weight 640. MUTATION: selfSplitWeight = task.length() only (drop the += delete size) -> 512. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned(), + Collections.singletonMap("format-version", "2")); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 512, null, null)) + .commit(); + table.newRowDelta() + .addDeletes(positionDeleteFile("s3://b/db/t1/pos.parquet", FileFormat.PARQUET, null, null)) + .commit(); + 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()); + + // The position delete attaches to f1's scan task (higher sequence number, same partition). + Assertions.assertEquals(1, ranges.size()); + Assertions.assertEquals(640L, ranges.get(0).getSelfSplitWeight(), + "selfSplitWeight must be data-file length (512) + delete file size (128)"); + } + + @Test + public void planScanFileSplitSizeUsesFileSplitSizeAsWeightDenominator() { + // M-2: in the file_split_size>0 path the splits are sliced to that granularity, so file_split_size is the + // weight denominator. (Legacy left targetSplitSize=0 here -> divide-by-zero -> clamp 1.0; the generic + // PluginDrivenSplit guards target>0, and file_split_size gives correct proportional weighting.) Using + // 16MB != the 32MB heuristic default pins that the path does NOT fall back to determineTargetFileSplitSize. + // MUTATION: denominator -1 (unset) or determineTargetFileSplitSize (32MB) -> red. + long mb = 1024L * 1024L; + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/big.parquet", 96 * mb, + Arrays.asList(0L, 32 * mb, 64 * mb), null)) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + 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()); + + Assertions.assertFalse(ranges.isEmpty()); + for (ConnectorScanRange r : ranges) { + Assertions.assertEquals(16 * mb, r.getTargetSplitSize(), + "file_split_size path must use file_split_size as the weight denominator"); + } + } + + @Test + public void planScanPushesPredicateAndPrunesPartition() { + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("p").build(); + Table table = createTable("pt", PART_SCHEMA, spec); + table.newAppend() + .appendFile(dataFile(spec, "s3://b/db/pt/p1.parquet", 512, null, "p=1")) + .appendFile(dataFile(spec, "s3://b/db/pt/p2.parquet", 512, null, "p=2")) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + // 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))); + 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()); + Assertions.assertEquals(2, all.size()); + } + + @Test + public void planScanUnpushablePredicateScansAllFiles() { + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + // A predicate the converter drops (id = 'abc' : string -> INTEGER fails) leaves no filter on the scan, + // so all files are scanned (safe over-approximation) rather than crashing or pruning everything. + 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)); + Assertions.assertEquals(1, ranges.size()); + } + + @Test + public void resolveSessionZoneHonorsDorisTimezoneAliases() { + // Doris stores SET time_zone='CST' un-canonicalized; legacy resolves it via the alias map to +08:00 + // (Asia/Shanghai), NOT America/Chicago. A plain ZoneId.of("CST") would throw -> UTC fallback -> + // 8h-shifted timestamptz pushdown -> wrong file pruning. MUTATION: dropping the alias map -> red. + Assertions.assertEquals(ZoneId.of("Asia/Shanghai"), + IcebergScanPlanProvider.resolveSessionZone(new FakeScanSession("CST", Collections.emptyMap()))); + Assertions.assertEquals(ZoneId.of("Asia/Shanghai"), + IcebergScanPlanProvider.resolveSessionZone(new FakeScanSession("PRC", Collections.emptyMap()))); + // A JDK SHORT_ID alias still resolves (mirrors TimeUtils putAll(ZoneId.SHORT_IDS)). + Assertions.assertEquals(ZoneId.of(ZoneId.SHORT_IDS.get("EST")), + IcebergScanPlanProvider.resolveSessionZone(new FakeScanSession("EST", Collections.emptyMap()))); + // A plain IANA name resolves as-is. + Assertions.assertEquals(ZoneId.of("America/New_York"), + IcebergScanPlanProvider.resolveSessionZone( + new FakeScanSession("America/New_York", Collections.emptyMap()))); + // null / blank / genuinely-invalid -> UTC (no crash). + Assertions.assertEquals(ZoneOffset.UTC, + IcebergScanPlanProvider.resolveSessionZone(new FakeScanSession(null, Collections.emptyMap()))); + Assertions.assertEquals(ZoneOffset.UTC, + IcebergScanPlanProvider.resolveSessionZone( + new FakeScanSession("Not/AZone", Collections.emptyMap()))); + } + + // --- T03 BE-ready range params: per-file carriers + path_partition_keys + native format --- + + @Test + public void planScanPopulatesPerFilePartitionAndFormatCarriers() { + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("p").build(); + Table table = createTable("pt", PART_SCHEMA, spec); + table.newAppend() + .appendFile(dataFile(spec, "s3://b/db/pt/p=1/a.parquet", 512, null, "p=1", FileFormat.PARQUET)) + .appendFile(dataFile(spec, "s3://b/db/pt/p=2/b.orc", 512, null, "p=2", FileFormat.ORC)) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + List ranges = provider.planScan( + null, new IcebergTableHandle("db1", "pt"), Collections.emptyList(), Optional.empty()); + Assertions.assertEquals(2, ranges.size()); + + // parquet file -> per-file FORMAT_PARQUET + its own partition spec-id/data-json/columns-from-path. + // MUTATION: T02's bare range (no carriers) -> iceberg_params unset / format JNI -> red. + TFileRangeDesc parquet = populate(byPath(ranges, "a.parquet")); + TIcebergFileDesc fdp = parquet.getTableFormatParams().getIcebergParams(); + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, parquet.getFormatType()); + Assertions.assertEquals(2, fdp.getFormatVersion()); + Assertions.assertEquals("s3://b/db/pt/p=1/a.parquet", fdp.getOriginalFilePath()); + Assertions.assertTrue(fdp.isSetPartitionSpecId()); + Assertions.assertEquals("[\"1\"]", fdp.getPartitionDataJson()); + Assertions.assertEquals(Collections.singletonList("p"), parquet.getColumnsFromPathKeys()); + Assertions.assertEquals(Collections.singletonList("1"), parquet.getColumnsFromPath()); + Assertions.assertEquals(Collections.singletonList(false), parquet.getColumnsFromPathIsNull()); + + // orc file -> per-file FORMAT_ORC (proves the format is taken per data file, not table-uniform) + + // its own partition value "2". + TFileRangeDesc orc = populate(byPath(ranges, "b.orc")); + Assertions.assertEquals(TFileFormatType.FORMAT_ORC, orc.getFormatType()); + Assertions.assertEquals("[\"2\"]", orc.getTableFormatParams().getIcebergParams().getPartitionDataJson()); + Assertions.assertEquals(Collections.singletonList("2"), orc.getColumnsFromPath()); + } + + @Test + public void getScanNodePropertiesEmitsPathPartitionKeysAndRealDataFormat() { + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "P", Types.IntegerType.get()), + Types.NestedField.required(3, "region", Types.StringType.get())); + PartitionSpec spec = PartitionSpec.builderFor(schema).identity("P").identity("region").build(); + Table table = createTable("pt", schema, spec); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + Map props = provider.getScanNodeProperties( + null, new IcebergTableHandle("db1", "pt"), Collections.emptyList(), Optional.empty()); + + // path_partition_keys = case-preserved, comma-joined identity columns (the CI #968880 double-fill guard); + // MUTATION: omitting path_partition_keys -> BE double-fills partition columns -> DCHECK -> this red. + // MUTATION: re-lowercasing "P" -> "p,region" != "P,region" -> red. + Assertions.assertEquals("P,region", props.get("path_partition_keys")); + // A base table MUST NOT report jni here: BE reads the scan-level format to pick FileScannerV2 vs V1 + // (_should_use_file_scanner_v2) before it can see any split, so "jni" would pin every iceberg scan to + // the V1 tree -- the only one carrying TableSchemaChangeHelper, whose StructNode DCHECK then SIGABRTs + // the BE on a Top-N lazy-materialization rowid slot (TeamCity 995122). No write.format.default and no + // snapshot on this fixture -> IcebergUtils.getFileFormat parity default = parquet. + // MUTATION: reverting to a hardcoded "jni" -> red here, and V2 is silently lost in production. + Assertions.assertEquals("parquet", props.get("file_format_type")); + } + + @Test + public void getScanNodePropertiesResolvesOrcTableFormat() { + Schema schema = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); + Table table = createTable("orct", schema, PartitionSpec.unpartitioned(), + Collections.singletonMap(TableProperties.DEFAULT_FILE_FORMAT, "orc")); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + Map props = provider.getScanNodeProperties( + null, new IcebergTableHandle("db1", "orct"), Collections.emptyList(), Optional.empty()); + + // Pins that the scan-level format is RESOLVED from the table, not a constant: an orc table must say orc + // so BE's genSlotToSchemaIdMapForOrc / V2 selection see the truth (legacy getFileFormatType parity). + // MUTATION: hardcoding "parquet" instead of IcebergWriterHelper.getFileFormat(table) -> red. + Assertions.assertEquals("orc", props.get("file_format_type")); + } + + @Test + public void getScanNodePropertiesOmitsPathPartitionKeysForUnpartitionedTable() { + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + Map props = provider.getScanNodeProperties( + null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + + // No identity partition columns -> the key must be absent (NOT an empty string, which the parent would + // split into a single "" key). MUTATION: always emitting the key -> red. + Assertions.assertFalse(props.containsKey("path_partition_keys")); + } + + @Test + public void getScanNodePropertiesEmitsFieldIdSchemaEvolutionDictKeyedOffRequestedColumns() throws Exception { + // T06: the provider must emit iceberg.schema_evolution (the field-id dict) UNCONDITIONALLY, keyed off the + // pruned requested columns, and populateScanLevelParams must round-trip it onto the real params. Without + // it BE name-matches schema-evolved files (NULL/garbage on rename) or DCHECK-aborts. MUTATION: not + // emitting the prop -> absent -> red. MUTATION: keying off all columns -> the entry includes "name" -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + List columns = + Collections.singletonList(new IcebergColumnHandle("id", 1)); + + Map props = provider.getScanNodeProperties( + null, new IcebergTableHandle("db1", "t1"), columns, Optional.empty()); + Assertions.assertTrue(props.containsKey("iceberg.schema_evolution")); + + // Round-trip through populateScanLevelParams (the exact path PluginDrivenScanNode drives). + TFileScanRangeParams params = new TFileScanRangeParams(); + provider.populateScanLevelParams(params, props); + Assertions.assertEquals(-1L, params.getCurrentSchemaId()); + Assertions.assertEquals(1, params.getHistorySchemaInfoSize()); + // Keyed off the requested column "id" only (CI #969249: the -1 entry == the scan slots). + Assertions.assertEquals(1, params.getHistorySchemaInfo().get(0).getRootField().getFieldsSize()); + Assertions.assertEquals("id", params.getHistorySchemaInfo().get(0).getRootField() + .getFields().get(0).getFieldPtr().getName()); + } + + @Test + public void getScanNodePropertiesForcesEqualityDeleteKeyColumnIntoDict() throws Exception { + // #65502: an equality-delete KEY column is a hidden scan dependency — BE resolves a key that is missing + // from an OLD data file via the field-id dict (to get the column type + iceberg initial default); without + // the entry it backfills the key as NULL and mis-applies the delete. So a query that does NOT project the + // key column must still ship it in the dict. Here the table declares identifier field "id" (what an + // equality-delete writer keys on); projecting ONLY "name", the emitted dict must carry BOTH "name" AND the + // unprojected "id". MUTATION: keying the normal dict off the pruned columns verbatim (dropping + // withEqualityDeleteKeyColumns) -> "id" absent -> red. + Schema schema = new Schema( + Arrays.asList( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get())), + Collections.singleton(1)); + Table table = createTable("eqdel", schema, PartitionSpec.unpartitioned(), + Collections.singletonMap("format-version", "2")); + // Sanity: the identifier field must survive table creation (iceberg reassigns ids but keeps the key). + Assertions.assertFalse(table.schema().identifierFieldIds().isEmpty(), + "the declared identifier field must be preserved on the created table"); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + // Project ONLY the non-key data column "name". + List columns = Collections.singletonList(new IcebergColumnHandle("name", 2)); + Map props = provider.getScanNodeProperties( + null, new IcebergTableHandle("db1", "eqdel"), columns, Optional.empty()); + + TFileScanRangeParams params = new TFileScanRangeParams(); + provider.populateScanLevelParams(params, props); + List top = new ArrayList<>(); + for (TFieldPtr ptr : params.getHistorySchemaInfo().get(0).getRootField().getFields()) { + top.add(ptr.getFieldPtr().getName()); + } + Assertions.assertTrue(top.contains("name"), "the projected column must be present, got " + top); + Assertions.assertTrue(top.contains("id"), + "the unprojected equality-delete key column must be force-included (#65502), got " + top); + } + + @Test + public void getScanNodePropertiesEmitsSchemaEvolutionDictForPartitionedTableToo() { + // The dict is emitted alongside path_partition_keys (it is unconditional, like legacy + // createScanRangeLocations). MUTATION: gating the dict on unpartitioned -> absent here -> red. + Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "p", Types.IntegerType.get())); + PartitionSpec spec = PartitionSpec.builderFor(schema).identity("p").build(); + Table table = createTable("pt", schema, spec); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + Map props = provider.getScanNodeProperties( + null, new IcebergTableHandle("db1", "pt"), Collections.emptyList(), Optional.empty()); + + Assertions.assertTrue(props.containsKey("iceberg.schema_evolution")); + Assertions.assertEquals("p", props.get("path_partition_keys")); + } + + // --- T06 [D-065]: getScanNodeProperties sys-handle guard (skip dict + path_partition_keys) --- + + @Test + public void getScanNodePropertiesForUnpinnedSysHandleSkipsDictAndPathKeysWithoutThrowing() { + // [D-065] A system-table handle ($snapshots/$files/...) must NOT take the base-table props path: + // its requested columns are METADATA columns (committed_at/snapshot_id/...) absent from the base + // data schema, so building the field-id dict from the base schema throws "requested column not + // found" (IcebergSchemaUtils.buildCurrentSchema). The metadata-table schema travels inside the + // serialized JNI split (planSystemTableScan), so BE needs neither the dict nor base + // path_partition_keys. MUTATION: removing the isSystemTable() guard -> the no-pin else branch + // throws here -> red. + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("p").build(); + Table table = createTable("pt", PART_SCHEMA, spec); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + // Requested columns are metadata-table columns, NOT present in the base (id, p) schema. + List metaColumns = Arrays.asList( + new IcebergColumnHandle("committed_at", 1), + new IcebergColumnHandle("snapshot_id", 2)); + + Map props = provider.getScanNodeProperties( + null, IcebergTableHandle.forSystemTable("db1", "pt", "snapshots", -1L, null, -1L), + metaColumns, Optional.empty()); + + Assertions.assertFalse(props.containsKey("iceberg.schema_evolution"), + "a sys handle must not emit the field-id schema-evolution dict (BE sys JNI reader ignores it; " + + "building it from the base schema + meta columns throws)"); + Assertions.assertFalse(props.containsKey("path_partition_keys"), + "a sys (metadata) table is not base-spec partitioned -> no path_partition_keys"); + Assertions.assertEquals("jni", props.get("file_format_type"), + "sys scan still flows through the JNI path"); + } + + @Test + public void getScanNodePropertiesForPinnedSysHandleSkipsDict() { + // A sys handle RETAINS its time-travel pin (forSystemTable), so without the guard the dict branch + // takes the hasSnapshotPin() path and silently builds a BASE-schema dict (no throw, but wrong/ + // meaningless for a metadata scan). The guard must suppress it for the pinned case too. + // MUTATION: guarding only the unpinned branch -> the pinned base-schema dict leaks here -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)).commit(); + long s1 = table.currentSnapshot().snapshotId(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + Map props = provider.getScanNodeProperties( + null, IcebergTableHandle.forSystemTable("db1", "t1", "files", s1, null, -1L), + Collections.emptyList(), Optional.empty()); + + Assertions.assertFalse(props.containsKey("iceberg.schema_evolution"), + "a pinned sys handle must also skip the schema-evolution dict (a base-schema dict is wrong " + + "for a metadata scan)"); + Assertions.assertEquals("jni", props.get("file_format_type")); + } + + @Test + public void getScanNodePropertiesForSysHandleStillEmitsLocationCreds() { + // T07 gap-fill: both existing sys getScanNodeProperties tests run context==null, so the location.* + // credential blocks (which sit OUTSIDE the two if(!systemTable) skips, D-065) never execute -> cred + // SURVIVAL for a sys handle is never positively asserted. BE still needs creds to read the metadata + // files (legacy IcebergScanNode.getLocationProperties has no isSystemTable branch). MUTATION: folding + // the location.* blocks inside if(!systemTable) (a plausible "tidy-up") strips creds from sys + // metadata scans -> BE 403 -> every existing test stays green, this one -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + RecordingConnectorContext context = new RecordingConnectorContext(); + Map beStatic = new HashMap<>(); + beStatic.put("AWS_ACCESS_KEY", "ak"); + beStatic.put("AWS_SECRET_KEY", "sk"); + context.storageProperties = Collections.singletonList(fakeBackendStorage(beStatic)); + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); + // Requested columns are metadata columns absent from the base schema (the dict-throw trap). + List metaColumns = Arrays.asList( + new IcebergColumnHandle("committed_at", 1), + new IcebergColumnHandle("snapshot_id", 2)); + + Map props = provider.getScanNodeProperties( + null, IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L), + metaColumns, Optional.empty()); + + Assertions.assertEquals("ak", props.get("location.AWS_ACCESS_KEY"), + "a sys handle must still emit location.* creds (BE reads the metadata files; legacy parity)"); + Assertions.assertEquals("sk", props.get("location.AWS_SECRET_KEY")); + Assertions.assertFalse(props.containsKey("iceberg.schema_evolution"), "sys still skips the dict"); + Assertions.assertFalse(props.containsKey("path_partition_keys"), "sys still skips path_partition_keys"); + Assertions.assertEquals("jni", props.get("file_format_type")); + } + + @Test + public void planSystemTableScanProjectsRequestedColumnsExcludingReadableMetrics() { + // WHY (regression — External Regression build 1000131, test_iceberg_system_table_projection): a + // $data_files/$files sys scan MUST be projected to only the requested columns. Without the + // scan.select(...) projection in doPlanSystemTableScan the iceberg SDK materialises EVERY metadata + // column per row — including readable_metrics, whose bound conversion + // (MetricsUtil.readableMetricsStruct -> Conversions.fromByteBuffer) throws BufferUnderflowException on + // complex/boolean bound columns — even when the query selects only a scalar such as file_size_in_bytes. + // The projected schema travels INSIDE the serialized FileScanTask that BE's IcebergSysTableJniScanner + // deserializes and iterates (asDataTask().rows()). MUTATION: dropping scan.select(projectedColumns) in + // doPlanSystemTableScan -> readable_metrics stays in the task schema -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + 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", "data_files", -1L, null, -1L), + Collections.singletonList(new IcebergColumnHandle("file_size_in_bytes", 1)), Optional.empty()); + + Assertions.assertEquals(1, ranges.size(), "one data file -> one metadata split"); + FileScanTask task = SerializationUtil.deserializeFromBase64( + ((IcebergScanRange) ranges.get(0)).getSerializedSplit()); + Assertions.assertNotNull(task.schema().findField("file_size_in_bytes"), + "the requested column must survive in the projected task schema"); + Assertions.assertNull(task.schema().findField("readable_metrics"), + "readable_metrics must NOT be in the projected task schema: its bound conversion " + + "BufferUnderflows on complex/boolean bound columns when materialised unrequested"); + } + + @Test + public void planSystemTableScanProjectionPreservesRequestedColumnOrderForPositionalBeRead() throws Exception { + // WHY (regression — External Regression build 1000283): BE's IcebergSysTableJniScanner reads the + // projected metadata rows POSITIONALLY (row.get(i) for i in 0..required_fields-1), relying on the i-th + // projected row field being the i-th BE-requested column. doPlanSystemTableScan can break that in two + // ways, both pinned here for a $snapshots scan whose requested order DIFFERS from table order: + // 1. For a metadata table whose rows() is a StructProjection ($snapshots/$refs/$history), task.schema() + // stays the FULL schema while the row is narrowed. row.size() below == the requested count proves + // the row is narrow, so a full-schema ordinal (e.g. operation at ordinal 3) would overrun it — that + // was the ArrayIndexOutOfBoundsException. BE MUST read positionally, not by scanTask.schema() index. + // 2. scan.select(names) re-emits the projection in TABLE order (ids -> Set -> TypeUtil.project), so + // row.get(0) would be snapshot_id, not operation. scan.project(orderedSchema) preserves the + // requested order. MUTATION: swapping scan.project(...) back to scan.select(...) -> row.get(0) is + // the snapshot_id, not "append" -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)) + .commit(); + long snapshotId = table.currentSnapshot().snapshotId(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + // $snapshots table order is committed_at, snapshot_id, parent_id, operation, ...; request operation + // BEFORE snapshot_id so a table-order projection would visibly disagree with the requested order. + List columns = Arrays.asList( + 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()); + + Assertions.assertEquals(1, ranges.size(), "one commit -> one $snapshots metadata split"); + FileScanTask task = SerializationUtil.deserializeFromBase64( + ((IcebergScanRange) ranges.get(0)).getSerializedSplit()); + try (CloseableIterable rows = task.asDataTask().rows()) { + Iterator it = rows.iterator(); + Assertions.assertTrue(it.hasNext(), "the $snapshots table must have one row"); + StructLike row = it.next(); + Assertions.assertEquals(2, row.size(), + "the row must be narrowed to the 2 requested columns, so BE's positional read stays in " + + "bounds (a full-schema ordinal would overrun it -- the build 1000283 AIOOBE)"); + Assertions.assertEquals("append", row.get(0, Object.class).toString(), + "field 0 must be the 1st REQUESTED column (operation), NOT the table-order column: " + + "scan.project preserves request order, scan.select would reorder to table order"); + Assertions.assertEquals(snapshotId, ((Number) row.get(1, Object.class)).longValue(), + "field 1 must be the 2nd requested column (snapshot_id)"); + } + } + + // --------------------------------------------------------------------- + // $position_deletes (upstream #65135 port): the ONE sys table BE reads with a native reader + // --------------------------------------------------------------------- + + private static Table tableWithPositionDelete(DeleteFile deleteFile) { + return tableWithPositionDelete(deleteFile, Collections.emptyMap()); + } + + /** Deletion vectors are a v3 feature — a DV commit on a v2 table is rejected by the SDK. */ + private static Table tableWithPositionDeleteV3(DeleteFile deleteFile) { + return tableWithPositionDelete(deleteFile, Collections.singletonMap("format-version", "3")); + } + + private static Table tableWithPositionDelete(DeleteFile deleteFile, Map props) { + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned(), props); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 512, null, null)) + .commit(); + table.newRowDelta().addDeletes(deleteFile).commit(); + return table; + } + + private static TFileRangeDesc positionDeleteRangeDesc(List ranges) { + Assertions.assertEquals(1, ranges.size(), "one delete file -> exactly one range"); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + rangeDesc.setPath(ranges.get(0).getPath().orElse(null)); + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + ((IcebergScanRange) ranges.get(0)).populateRangeParams(formatDesc, rangeDesc); + rangeDesc.setTableFormatParams(formatDesc); + return rangeDesc; + } + + 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()); + } + + @Test + public void planScanPositionDeletesEmitsNativeRangeMatchingTheBeRoutingContract() { + // WHY: this is THE contract. BE routes a range into iceberg_position_delete_sys_table_reader iff + // table_format_type=="iceberg" AND the TOP-LEVEL iceberg_params.content is 1 or 3 AND the range + // format_type is PARQUET/ORC (file_scanner.cpp:103-113). Miss any one and the range silently falls + // back to the ordinary iceberg reader, which parses the delete file AS A DATA FILE — wrong rows, no + // error. It also asserts delete_files.size()==1, which BE DCHECKs (reader :179). + // MUTATION: not setting the top-level content (only the delete-file one) -> BE misroutes -> red; + // leaving the range on the FORMAT_JNI default -> red; emitting serialized_split (the other sys + // tables' shape) -> red; emitting 2+ delete descriptors -> red. + Table table = tableWithPositionDelete( + positionDeleteFile("s3://b/db/t1/pos.parquet", FileFormat.PARQUET, null, null)); + + TFileRangeDesc rangeDesc = positionDeleteRangeDesc(planPositionDeletes(table, Collections.emptyList())); + TIcebergFileDesc fileDesc = rangeDesc.getTableFormatParams().getIcebergParams(); + + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, rangeDesc.getFormatType(), + "a native position-delete range must NOT stay on the FORMAT_JNI default"); + Assertions.assertTrue(fileDesc.isSetContent(), "top-level content is BE's sole routing key"); + Assertions.assertEquals(1, fileDesc.getContent(), "1 = POSITION_DELETES"); + Assertions.assertFalse(fileDesc.isSetSerializedSplit(), + "the native path must not emit the JNI serialized_split"); + Assertions.assertEquals(-1L, rangeDesc.getTableFormatParams().getTableLevelRowCount()); + Assertions.assertEquals(1, fileDesc.getDeleteFilesSize(), + "BE asserts exactly one delete descriptor"); + TIcebergDeleteFileDesc deleteDesc = fileDesc.getDeleteFiles().get(0); + Assertions.assertEquals(1, deleteDesc.getContent(), "the delete descriptor's content must agree"); + Assertions.assertEquals("s3://b/db/t1/pos.parquet", deleteDesc.getOriginalPath(), + "original_path is the RAW delete-file path (the delete_file_path output column)"); + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, deleteDesc.getFileFormat()); + // A path-parsed "partition" key would collide with the metadata table's own `partition` slot. + Assertions.assertFalse(rangeDesc.isSetColumnsFromPath(), "columns-from-path must be unset"); + Assertions.assertFalse(rangeDesc.isSetColumnsFromPathKeys()); + } + + @Test + public void planScanPositionDeletesDeletionVectorEmitsContent3AndBlobLocation() { + // WHY: a V3 deletion vector is a puffin blob, and BE distinguishes it from a plain position-delete + // file by content==3 ALONE — never by file format, which is why a DV still travels as FORMAT_PARQUET + // (legacy getNativePositionDeleteFileFormat). BE then expands one output row per set bit, reading the + // blob at content_offset/content_size_in_bytes and reporting file_path = referenced_data_file_path; + // without those three fields it cannot materialize a single row. + // MUTATION: mapping PUFFIN to a "FORMAT_PUFFIN"/JNI instead of FORMAT_PARQUET -> red; emitting + // content=1 for a DV -> BE runs the parquet position-delete branch on a puffin file -> red; + // dropping referenced_data_file_path/offset/size -> red. + Table table = tableWithPositionDeleteV3(deletionVectorFile("s3://b/db/t1/dv.puffin", 4L, 40L)); + + TFileRangeDesc rangeDesc = positionDeleteRangeDesc(planPositionDeletes(table, Collections.emptyList())); + TIcebergFileDesc fileDesc = rangeDesc.getTableFormatParams().getIcebergParams(); + + Assertions.assertEquals(3, fileDesc.getContent(), "3 = DELETION_VECTOR (top-level routing)"); + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, rangeDesc.getFormatType(), + "a puffin DV still travels as FORMAT_PARQUET; BE keys on content, not format"); + TIcebergDeleteFileDesc deleteDesc = fileDesc.getDeleteFiles().get(0); + Assertions.assertEquals(3, deleteDesc.getContent()); + Assertions.assertEquals("s3://b/db/t1/f1.parquet", deleteDesc.getReferencedDataFilePath(), + "BE reports file_path from the referenced data file for DV rows"); + Assertions.assertEquals(4L, deleteDesc.getContentOffset()); + Assertions.assertEquals(40L, deleteDesc.getContentSizeInBytes()); + } + + @Test + public void planScanPositionDeletesRejectsAvroDeleteFile() { + // WHY: there is no native AVRO position-delete reader. Failing loud beats mis-routing the range to + // the parquet reader, which would fault or return garbage. Message text is pinned by upstream's own + // unit test (assertEquals, not contains). MUTATION: defaulting an unknown format to FORMAT_PARQUET + // -> no throw -> red. + Table table = tableWithPositionDelete( + positionDeleteFile("s3://b/db/t1/pos.avro", FileFormat.AVRO, null, null)); + + UnsupportedOperationException e = Assertions.assertThrows(UnsupportedOperationException.class, + () -> planPositionDeletes(table, Collections.emptyList())); + Assertions.assertEquals("Unsupported Iceberg position delete file format: AVRO", e.getMessage()); + } + + @Test + public void getScanNodePropertiesForPositionDeletesSysHandleEmitsDict() { + // WHY (D-065 narrowed): every OTHER sys table rides the JNI serialized-split path, where the + // metadata-table schema travels inside the serialized FileScanTask — so the field-id dict is + // correctly skipped. $position_deletes has no serialized task: BOTH native readers resolve the `row` + // column through params.history_schema_info. Without the dict, scanner v1 hard-errors ("Iceberg + // position delete system table row schema is missing") and scanner v2 SILENTLY degrades to name + // matching, mis-reading renamed columns under schema evolution. The dict must also be built from the + // METADATA table's own schema — feeding the base schema keyed off meta columns is exactly what makes + // IcebergSchemaUtils throw "requested column not found". + // MUTATION: widening the guard back to `if (!systemTable)` -> no dict -> red. + Table table = tableWithPositionDelete( + positionDeleteFile("s3://b/db/t1/pos.parquet", FileFormat.PARQUET, null, null)); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + // Metadata-table columns, absent from the base (id, name) schema — the dict-throw trap. + List metaColumns = Arrays.asList( + new IcebergColumnHandle("file_path", 1), + new IcebergColumnHandle("pos", 2)); + + Map props = provider.getScanNodeProperties( + null, IcebergTableHandle.forSystemTable("db1", "t1", "position_deletes", -1L, null, -1L), + metaColumns, Optional.empty()); + + Assertions.assertTrue(props.containsKey("iceberg.schema_evolution"), + "position_deletes reads natively and needs the field-id dict to resolve `row`"); + Assertions.assertFalse(props.containsKey("path_partition_keys"), + "a metadata table is still not base-spec partitioned -> no path_partition_keys"); + } + + @Test + public void getScanNodePropertiesForPositionDeletesLoadsTheBaseTableOnlyOnce() { + // WHY: the dict branch needs the METADATA table, and the obvious way to get one is resolveSysTable(). + // That helper carries NO auth wrap on purpose — its javadoc pins that its sole caller + // (planSystemTableScan) owns the executeAuthenticated scope. getScanNodeProperties has no such scope, + // so calling it here would issue an UNAUTHENTICATED loadTable and fail a kerberized catalog at plan + // time (and pay a second round-trip). Building the metadata table from the base table this method + // already resolved — a pure local construction — avoids both. Counting loads is the observable proxy: + // a second load here means resolveSysTable (or any other fresh resolve) crept back in. + // MUTATION: reverting to resolveSysTable(session, iceHandle) -> loadCount 2 -> red. + Table table = tableWithPositionDelete( + positionDeleteFile("s3://b/db/t1/pos.parquet", FileFormat.PARQUET, null, null)); + RecordingIcebergCatalogOps ops = opsReturning(table); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), ops); + + Map props = provider.getScanNodeProperties( + null, IcebergTableHandle.forSystemTable("db1", "t1", "position_deletes", -1L, null, -1L), + Collections.singletonList(new IcebergColumnHandle("row", 1)), Optional.empty()); + + Assertions.assertTrue(props.containsKey("iceberg.schema_evolution"), "the dict must still be emitted"); + Assertions.assertEquals(1, Collections.frequency(ops.log, "loadTable:db1.t1"), + "the base table must be loaded exactly once (no unauthenticated second resolve)"); + } + + // --- T07: MVCC / time-travel scan-time pin + Option-A field-id dict --- + + @Test + public void planScanPinnedToOlderSnapshotReadsOnlyThatSnapshotsFiles() { + // S1 appends f1; S2 appends f2 (appends accumulate, so S2 sees both). A pin to S1 must read ONLY f1 + // (legacy createTableScan -> scan.useSnapshot(id)). MUTATION: ignoring the pin (reading latest) -> 2 + // ranges -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)).commit(); + long s1 = table.currentSnapshot().snapshotId(); + long schemaIdS1 = table.currentSnapshot().schemaId(); + 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()); + Assertions.assertEquals(2, latest.size()); + + List pinned = provider.planScan( + null, new IcebergTableHandle("db1", "t1").withSnapshot(s1, null, schemaIdS1), + Collections.emptyList(), Optional.empty()); + Assertions.assertEquals(1, pinned.size()); + Assertions.assertTrue(pinned.get(0).getPath().get().endsWith("f1.parquet")); + } + + @Test + public void planScanPinnedToTagReadsViaUseRefNotSnapshotId() { + // The handle carries BOTH a ref (tag1 -> S1) AND the LATEST snapshot id (s2). The scan must pin by REF + // (useRef), so it reads only f1. MUTATION: pinning by snapshotId (useSnapshot(s2)) -> reads both -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)).commit(); + long s1 = table.currentSnapshot().snapshotId(); + long schemaIdS1 = table.currentSnapshot().schemaId(); + table.manageSnapshots().createTag("tag1", s1).commit(); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 1024, null, null)).commit(); + 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()); + Assertions.assertEquals(1, pinned.size()); + Assertions.assertTrue(pinned.get(0).getPath().get().endsWith("f1.parquet")); + } + + @Test + public void countPushdownFollowsTheSnapshotPin() { + // f1=1000/100=10 records (S1); + f2=2000/100=20 -> latest total-records 30. Pinned to S1 the count is + // read from S1's summary (10), via scan.snapshot(). MUTATION: counting the latest snapshot -> 30 -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1000, null, null)).commit(); + long s1 = table.currentSnapshot().snapshotId(); + long schemaIdS1 = table.currentSnapshot().schemaId(); + 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); + Assertions.assertEquals(1, pinned.size()); + Assertions.assertEquals(10L, pinned.get(0).getPushDownRowCount()); + } + + @Test + public void getScanNodePropertiesUnderPinEmitsFullPinnedSchemaDict() throws Exception { + // T07 Option A: under a time-travel pin the field-id dict is built from the FULL pinned schema (covering + // every BE slot), NOT the pruned `columns`. The pinned schema (S1) has id+name; after a rename the latest + // has id+fullname. Even with a PRUNED columns=[id], the dict must carry the renamed slot "name" (the + // pinned name) so BE's StructNode never misses it. MUTATION: keying off `columns` under a pin -> "name" + // dropped -> dict has only "id" -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)).commit(); + long s1 = table.currentSnapshot().snapshotId(); + long schemaIdS1 = table.currentSnapshot().schemaId(); + table.updateSchema().renameColumn("name", "fullname").commit(); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 1024, null, null)).commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + Map props = provider.getScanNodeProperties( + null, new IcebergTableHandle("db1", "t1").withSnapshot(s1, null, schemaIdS1), + Collections.singletonList(new IcebergColumnHandle("id", 1)), Optional.empty()); + + TFileScanRangeParams params = new TFileScanRangeParams(); + provider.populateScanLevelParams(params, props); + Assertions.assertEquals(-1L, params.getCurrentSchemaId()); + Assertions.assertEquals(2, params.getHistorySchemaInfo().get(0).getRootField().getFieldsSize()); + Assertions.assertEquals("id", params.getHistorySchemaInfo().get(0).getRootField() + .getFields().get(0).getFieldPtr().getName()); + Assertions.assertEquals("name", params.getHistorySchemaInfo().get(0).getRootField() + .getFields().get(1).getFieldPtr().getName()); + } + + @Test + public void getScanNodePropertiesUnderTopnLazyMatEmitsFullLatestSchemaDict() throws Exception { + // M-4: under Top-N lazy materialization BE reads the sort key first, then re-fetches the OTHER + // (non-projected) columns of the surviving rows by the synthesized row-id. So the field-id dict must + // span the FULL latest schema, NOT the pruned `columns` — else a lazily re-fetched, schema-evolved + // column has no field-id entry and the native read drops/mis-reads it (legacy + // initSchemaInfoForAllColumn parity). SCHEMA = id+name; even with a PRUNED columns=[id], the topn dict + // must carry "name". MUTATION: dropping the isTopnLazyMaterialize() branch -> keyed off `columns` -> + // dict has only "id" -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + Map props = provider.getScanNodeProperties( + null, new IcebergTableHandle("db1", "t1").withTopnLazyMaterialize(true), + Collections.singletonList(new IcebergColumnHandle("id", 1)), Optional.empty()); + + TFileScanRangeParams params = new TFileScanRangeParams(); + provider.populateScanLevelParams(params, props); + Assertions.assertEquals(2, params.getHistorySchemaInfo().get(0).getRootField().getFieldsSize()); + Assertions.assertEquals("id", params.getHistorySchemaInfo().get(0).getRootField() + .getFields().get(0).getFieldPtr().getName()); + Assertions.assertEquals("name", params.getHistorySchemaInfo().get(0).getRootField() + .getFields().get(1).getFieldPtr().getName()); + } + + @Test + public void getScanNodePropertiesPinTakesPrecedenceOverTopnLazyMat() throws Exception { + // A time-travel pin + Top-N lazy mat must take the PIN branch (pinned schema, full columns), not the + // latest-schema topn branch: BE reads at the pinned snapshot, so lazily re-fetched columns resolve + // against the PINNED schema's field-ids. Pinned S1 = id+name; latest (after rename) = id+fullname. + // With pin+topn the dict must carry the PINNED "name", never the latest "fullname". MUTATION: + // ordering the topn branch before the pin branch -> "fullname" leaks -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)).commit(); + long s1 = table.currentSnapshot().snapshotId(); + long schemaIdS1 = table.currentSnapshot().schemaId(); + table.updateSchema().renameColumn("name", "fullname").commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + Map props = provider.getScanNodeProperties( + null, new IcebergTableHandle("db1", "t1").withSnapshot(s1, null, schemaIdS1) + .withTopnLazyMaterialize(true), + Collections.singletonList(new IcebergColumnHandle("id", 1)), Optional.empty()); + + TFileScanRangeParams params = new TFileScanRangeParams(); + provider.populateScanLevelParams(params, props); + Assertions.assertEquals(2, params.getHistorySchemaInfo().get(0).getRootField().getFieldsSize()); + Assertions.assertEquals("name", params.getHistorySchemaInfo().get(0).getRootField() + .getFields().get(1).getFieldPtr().getName()); + } + + @Test + public void planScanReadsRealFormatVersionAndEmitsV3RowLineage() { + Map v3 = new HashMap<>(); + v3.put("format-version", "3"); + Table table = createTable("v3t", SCHEMA, PartitionSpec.unpartitioned(), v3); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/v3t/f.parquet", 512, null, null)) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + List ranges = provider.planScan( + null, new IcebergTableHandle("db1", "v3t"), Collections.emptyList(), Optional.empty()); + Assertions.assertEquals(1, ranges.size()); + TIcebergFileDesc fd = populate(ranges.get(0)).getTableFormatParams().getIcebergParams(); + + // format_version read from real table metadata (NOT hard-coded). v3 always emits row lineage (>= -1 for + // files carried over from a v2->v3 upgrade). MUTATION: hard-coding v2 / never emitting lineage -> red. + Assertions.assertEquals(3, fd.getFormatVersion()); + Assertions.assertTrue(fd.isSetFirstRowId()); + Assertions.assertTrue(fd.isSetLastUpdatedSequenceNumber()); + } + + // ── commit-bridge supply (S4 part 2): a v3 scan stashes each data file's non-equality deletes by raw path ── + + @Test + public void planScanAccumulatesRewritableDeletesKeyedByRawDataFilePathForV3() { + // A v3 scan over a data file that already has a deletion vector must accumulate that DV into the + // per-statement scope keyed on the data file's RAW path, so a same-statement DELETE/MERGE write can hand + // it to the BE. MUTATION: not accumulating (or keying on the normalized path) -> the write supplies + // nothing -> the BE resurrects the deleted rows. + Map v3 = new HashMap<>(); + v3.put("format-version", "3"); + Table table = createTable("v3dv", SCHEMA, PartitionSpec.unpartitioned(), v3); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 512, null, null)) + .commit(); + table.newRowDelta() + .addDeletes(deletionVectorFile("s3://b/db/t1/dv.puffin", 16L, 64L)) + .commit(); + + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + ConnectorSession session = new FakeScanSession("UTC", Collections.emptyMap()) + .withScope(new TestStatementScope()); + provider.planScan(session, new IcebergTableHandle("db1", "v3dv"), Collections.emptyList(), Optional.empty()); + + Map> sets = IcebergStatementScope.rewritableDeleteSupply(session); + Assertions.assertFalse(sets.isEmpty(), "a v3 scan with a live DV must accumulate a supply into the scope"); + // Keyed on the RAW data-file path (== originalPath), the string the BE matches a rewritable set against. + Assertions.assertTrue(sets.containsKey("s3://b/db/t1/f1.parquet"), + "supply must key on the raw data-file path, got keys: " + sets.keySet()); + List descs = sets.get("s3://b/db/t1/f1.parquet"); + Assertions.assertEquals(1, descs.size()); + Assertions.assertEquals(3, descs.get(0).getContent(), "the DV is content 3"); + } + + @Test + public void planScanDoesNotAccumulateForVersionTwo() { + // v2 deletes are plain position-delete files (no DV union); the rewritable supply is a v3-only concept. + // A real position delete is committed so the assertion proves the formatVersion>=3 GATE, not an absence + // of deletes. MUTATION: dropping the v3 gate -> this v2 position delete would be accumulated -> red. + Table table = createTable("v2pd", SCHEMA, PartitionSpec.unpartitioned(), + Collections.singletonMap("format-version", "2")); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 512, null, null)) + .commit(); + table.newRowDelta() + .addDeletes(positionDeleteFile("s3://b/db/t1/pos.parquet", FileFormat.PARQUET, null, null)) + .commit(); + + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + ConnectorSession session = new FakeScanSession("UTC", Collections.emptyMap()) + .withScope(new TestStatementScope()); + provider.planScan(session, new IcebergTableHandle("db1", "v2pd"), Collections.emptyList(), Optional.empty()); + + Assertions.assertTrue(IcebergStatementScope.rewritableDeleteSupply(session).isEmpty(), + "a v2 scan must not accumulate any rewritable supply"); + } + + @Test + public void planScanUnderNoneScopeIsInert() { + // Under a NONE scope (offline / no live statement) a v3 scan must not NPE — it simply accumulates into a + // throwaway map that does not bridge to any write. MUTATION: dereferencing a missing scope -> NPE -> red. + Map v3 = new HashMap<>(); + v3.put("format-version", "3"); + Table table = createTable("v3ns", SCHEMA, PartitionSpec.unpartitioned(), v3); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 512, null, null)) + .commit(); + table.newRowDelta() + .addDeletes(deletionVectorFile("s3://b/db/t1/dv.puffin", 16L, 64L)) + .commit(); + 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()); + Assertions.assertEquals(1, ranges.size()); + } + + @Test + public void planScanRejectsUnsupportedFileFormatFailLoud() { + // Legacy IcebergScanNode.getFileFormatType() throws DdlException("Unsupported format name: ...") at plan + // start for a non-orc/parquet table; the connector must keep that fail-loud guard instead of silently + // shipping the file to BE's iceberg JNI reader (which expects a serialized system-table split). + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f.avro", 512, null, null, FileFormat.AVRO)) + .commit(); + 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())); + Assertions.assertTrue(ex.getMessage().contains("Unsupported format name: avro"), + "message should mirror legacy: " + ex.getMessage()); + } + + // --- FIX-M3 streaming (file-count) split generation --------------------------------------------------- + + /** A session that sets the file-count batch gate vars (num_files_in_batch_mode / enable). */ + private static ConnectorSession batchSession(long numFilesInBatchMode, boolean enableBatchMode) { + Map props = new HashMap<>(); + props.put("num_files_in_batch_mode", String.valueOf(numFilesInBatchMode)); + props.put("enable_external_table_batch_mode", String.valueOf(enableBatchMode)); + return new FakeScanSession("UTC", props); + } + + private static Table threeFileTable(Map tableProps) { + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned(), tableProps); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)) + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 2048, null, null)) + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f3.parquet", 4096, null, null)) + .commit(); + return table; + } + + private static Table threeFileTable() { + return threeFileTable(Collections.emptyMap()); + } + + private static IcebergScanPlanProvider providerOver(Table table) { + return new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + } + + private static ConnectorSession emptySession() { + return new FakeScanSession("UTC", Collections.emptyMap()); + } + + private static List drain(ConnectorSplitSource source) throws IOException { + List out = new ArrayList<>(); + try (ConnectorSplitSource s = source) { + while (s.hasNext()) { + out.add(s.next()); + } + } + return out; + } + + @Test + public void streamingSplitEstimateBelowThresholdStaysSynchronous() { + // 3 matched files < threshold(5) -> stay on the synchronous planScan path (small scans need no streaming). + IcebergScanPlanProvider provider = providerOver(threeFileTable()); + long estimate = provider.streamingSplitEstimate(batchSession(5, true), + new IcebergTableHandle("db1", "t1"), Optional.empty(), false); + Assertions.assertEquals(-1, estimate, "below threshold must not stream"); + } + + @Test + public void streamingSplitEstimateAtThresholdStreamsAndReturnsFileCount() { + // 3 matched files == threshold(3): the gate is INCLUSIVE (legacy >=), and the estimate is the file count + // (the BE concurrency hint). MUTATION: `>=` -> `>` drops the boundary -> -1 -> red. + IcebergScanPlanProvider provider = providerOver(threeFileTable()); + long estimate = provider.streamingSplitEstimate(batchSession(3, true), + new IcebergTableHandle("db1", "t1"), Optional.empty(), false); + Assertions.assertEquals(3, estimate, "at threshold must stream and report the matched file count"); + } + + @Test + public void streamingSplitEstimateDisabledBySessionVarStaysSynchronous() { + // enable_external_table_batch_mode=false short-circuits even though 3 >= threshold(2). MUTATION: dropping + // the enable guard -> 3 -> red. This is the session var that was silently dead pre-fix. + IcebergScanPlanProvider provider = providerOver(threeFileTable()); + long estimate = provider.streamingSplitEstimate(batchSession(2, false), + new IcebergTableHandle("db1", "t1"), Optional.empty(), false); + Assertions.assertEquals(-1, estimate, "batch mode disabled must not stream"); + } + + @Test + public void streamingSplitEstimateEmptyTableStaysSynchronous() { + // No snapshot (no append) -> nothing to stream. MUTATION: dropping the snapshot==null guard -> NPE/red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + long estimate = provider.streamingSplitEstimate(batchSession(0, true), + new IcebergTableHandle("db1", "t1"), Optional.empty(), false); + Assertions.assertEquals(-1, estimate, "empty table must not stream"); + } + + @Test + public void streamingSplitEstimateSystemTableStaysSynchronous() { + // System tables take the JNI serialized-split path, never streaming. MUTATION: dropping the isSystemTable + // guard -> attempts to count a metadata table -> wrong/red. + IcebergScanPlanProvider provider = providerOver(threeFileTable()); + long estimate = provider.streamingSplitEstimate(batchSession(2, true), + IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1, null, -1), Optional.empty(), false); + Assertions.assertEquals(-1, estimate, "system table must not stream"); + } + + @Test + public void streamingSplitEstimateV3StaysSynchronous() { + // Format-version >= 3 carries the commit-bridge rewritable-delete stash that the write side reads at + // write-plan time; streaming would fill it too late (BE-pull time) and resurrect deleted rows. So v3 is + // gated onto the eager path. MUTATION: `>= 3` -> `> 3` (or dropping the guard) -> 3 -> red (correctness). + Table table = threeFileTable(Collections.singletonMap("format-version", "3")); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + long estimate = provider.streamingSplitEstimate(batchSession(2, true), + new IcebergTableHandle("db1", "t1"), Optional.empty(), false); + Assertions.assertEquals(-1, estimate, "v3 (deletion-vector) tables must not stream"); + } + + @Test + public void streamingSplitEstimateServableCountPushdownStaysSynchronous() { + // A servable COUNT(*) collapses to one range (never streamed). 3 files, no deletes -> count servable from + // the snapshot summary. MUTATION: dropping the countPushdown short-circuit -> 3 -> red. + IcebergScanPlanProvider provider = providerOver(threeFileTable()); + long estimate = provider.streamingSplitEstimate(batchSession(2, true), + new IcebergTableHandle("db1", "t1"), Optional.empty(), true); + Assertions.assertEquals(-1, estimate, "servable count pushdown must not stream"); + } + + @Test + public void streamSplitsProducesOneLazyRangePerFile() throws IOException { + // The lazy source yields exactly one range per data file (3), with the raw paths preserved. This is the + // streamed counterpart of planScan's eager enumeration. + IcebergScanPlanProvider provider = providerOver(threeFileTable()); + List ranges = drain(provider.streamSplits(emptySession(), + new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty(), -1L)); + Set paths = ranges.stream() + .map(r -> ((IcebergScanRange) r).getOriginalPath()).collect(ImmutableSet.toImmutableSet()); + Assertions.assertEquals( + ImmutableSet.of("s3://b/db/t1/f1.parquet", "s3://b/db/t1/f2.parquet", "s3://b/db/t1/f3.parquet"), paths, + "streaming must yield one range per file"); + } + + @Test + public void streamSplitsRewriteScopeSkipsUnscopedFilesViaLookahead() throws IOException { + // A rewrite scope keeps only f1 + f3; the source's look-ahead must skip f2 in hasNext(). MUTATION: + // dropping the rewrite-scope skip -> f2 leaks (3 ranges) -> red; a broken look-ahead (no skip) would + // surface a null -> red. + Table table = threeFileTable(Collections.emptyMap()); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + IcebergTableHandle scoped = new IcebergTableHandle("db1", "t1").withRewriteFileScope( + ImmutableSet.of("s3://b/db/t1/f1.parquet", "s3://b/db/t1/f3.parquet")); + List ranges = drain(provider.streamSplits(emptySession(), + scoped, Collections.emptyList(), Optional.empty(), -1L)); + Set paths = ranges.stream() + .map(r -> ((IcebergScanRange) r).getOriginalPath()).collect(ImmutableSet.toImmutableSet()); + Assertions.assertEquals( + ImmutableSet.of("s3://b/db/t1/f1.parquet", "s3://b/db/t1/f3.parquet"), paths, + "rewrite scope must skip f2 in the streamed source"); + } + + @Test + public void streamSplitsNextThrowsWhenExhausted() throws IOException { + // next() past the end must throw (the engine pulls only while hasNext()). MUTATION: dropping the hasNext + // guard in next() -> NPE/wrong instead of NoSuchElementException -> red. + IcebergScanPlanProvider provider = providerOver(threeFileTable()); + ConnectorSplitSource source = provider.streamSplits(new FakeScanSession("UTC", Collections.emptyMap()), + new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty(), -1L); + while (source.hasNext()) { + source.next(); + } + Assertions.assertThrows(NoSuchElementException.class, source::next); + source.close(); + } + + @Test + public void streamSplitsCloseBeforeIterationDoesNotThrow() throws IOException { + // The engine may close the source without ever pulling (e.g. needMoreSplit() false from the start). With + // the lazy iterator the iterator is still null; close() must null-guard it and still release the + // underlying planFiles() iterable. MUTATION: dropping the `iterator != null` guard in close() -> NPE -> red. + IcebergScanPlanProvider provider = providerOver(threeFileTable()); + ConnectorSplitSource source = provider.streamSplits(emptySession(), + new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty(), -1L); + Assertions.assertDoesNotThrow(source::close); + } + + /** A minimal {@link ConnectorSession} exposing a time zone + session split-size properties (no Mockito). */ + private static final class FakeScanSession implements ConnectorSession { + private final String timeZone; + private final Map sessionProperties; + private ConnectorStatementScope statementScope = ConnectorStatementScope.NONE; + + FakeScanSession(String timeZone, Map sessionProperties) { + this.timeZone = timeZone; + this.sessionProperties = sessionProperties; + } + + /** Installs a memoizing per-statement scope; share one instance across sessions to mimic one statement. */ + FakeScanSession withScope(ConnectorStatementScope scope) { + this.statementScope = scope; + return this; + } + + @Override + public ConnectorStatementScope getStatementScope() { + return statementScope; + } + + @Override + public String getQueryId() { + return "q"; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return timeZone; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public String getCatalogName() { + return "c"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + + @Override + public Map getSessionProperties() { + return sessionProperties; + } + } + + // --- T04: merge-on-read delete files (convertDelete classification + path normalize + EXPLAIN read-back) --- + + private static DeleteFile positionDeleteFile(String path, FileFormat format, Long lower, Long upper) { + FileMetadata.Builder builder = FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) + .ofPositionDeletes() + .withPath(path) + .withFormat(format) + .withFileSizeInBytes(128L) + .withRecordCount(4L); + if (lower != null || upper != null) { + int posField = MetadataColumns.DELETE_FILE_POS.fieldId(); + Map lowerMap = lower == null ? null : Collections.singletonMap(posField, + Conversions.toByteBuffer(MetadataColumns.DELETE_FILE_POS.type(), lower)); + Map upperMap = upper == null ? null : Collections.singletonMap(posField, + Conversions.toByteBuffer(MetadataColumns.DELETE_FILE_POS.type(), upper)); + builder.withMetrics(new Metrics(4L, null, null, null, null, lowerMap, upperMap)); + } + return builder.build(); + } + + private static DeleteFile deletionVectorFile(String path, long offset, long size) { + return FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) + .ofPositionDeletes() + .withPath(path) + .withFormat(FileFormat.PUFFIN) + .withFileSizeInBytes(256L) + .withRecordCount(4L) + .withReferencedDataFile("s3://b/db/t1/f1.parquet") + .withContentOffset(offset) + .withContentSizeInBytes(size) + .build(); + } + + private static DeleteFile equalityDeleteFile(String path, FileFormat format, int... fieldIds) { + return FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) + .ofEqualityDeletes(fieldIds) + .withPath(path) + .withFormat(format) + .withFileSizeInBytes(128L) + .withRecordCount(4L) + .build(); + } + + private static IcebergScanPlanProvider provider() { + return new IcebergScanPlanProvider(Collections.emptyMap(), new RecordingIcebergCatalogOps()); + } + + private static TIcebergDeleteFileDesc deleteDesc(String path, int content) { + TIcebergDeleteFileDesc d = new TIcebergDeleteFileDesc(); + d.setPath(path); + d.setContent(content); + return d; + } + + @Test + public void convertDeletePositionDeleteCarriesBoundsAndFormat() { + // POSITION_DELETES (non-PUFFIN) -> content 1, parquet/orc format, [lower,upper] bounds decoded from the + // delete file's DELETE_FILE_POS bounds. MUTATION: wrong content id / dropped bounds / wrong format -> red. + DeleteFile delete = positionDeleteFile("s3://b/db/t1/pos.parquet", FileFormat.PARQUET, 3L, 17L); + TIcebergDeleteFileDesc d = provider().convertDelete(delete, UnaryOperator.identity()).toThrift(); + + Assertions.assertEquals(1, d.getContent()); + Assertions.assertEquals("s3://b/db/t1/pos.parquet", d.getPath()); + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, d.getFileFormat()); + Assertions.assertEquals(3L, d.getPositionLowerBound()); + Assertions.assertEquals(17L, d.getPositionUpperBound()); + Assertions.assertFalse(d.isSetFieldIds()); + Assertions.assertFalse(d.isSetContentOffset()); + } + + @Test + public void convertDeletePositionDeleteWithoutBoundsLeavesThemUnset() { + // No DELETE_FILE_POS bounds present -> position_lower/upper_bound stay unset (legacy emits them only + // when present; it stores a -1 sentinel and skips emission). MUTATION: emitting 0/-1 -> red. + DeleteFile delete = positionDeleteFile("s3://b/db/t1/pos.orc", FileFormat.ORC, null, null); + TIcebergDeleteFileDesc d = provider().convertDelete(delete, UnaryOperator.identity()).toThrift(); + + Assertions.assertEquals(TFileFormatType.FORMAT_ORC, d.getFileFormat()); + Assertions.assertFalse(d.isSetPositionLowerBound()); + Assertions.assertFalse(d.isSetPositionUpperBound()); + } + + @Test + public void convertDeleteDeletionVectorCarriesBlobRefAndUnsetsFormat() { + // A PUFFIN position delete is a DELETION VECTOR -> content 3, content_offset/size set, file_format UNSET + // (legacy setDeleteFileFormat skips PUFFIN). MUTATION: classifying it as content 1 / emitting a format + // for the puffin blob -> red (BE would mis-read the DV blob). + DeleteFile delete = deletionVectorFile("s3://b/db/t1/dv.puffin", 16L, 64L); + TIcebergDeleteFileDesc d = provider().convertDelete(delete, UnaryOperator.identity()).toThrift(); + + Assertions.assertEquals(3, d.getContent()); + Assertions.assertFalse(d.isSetFileFormat()); + Assertions.assertEquals(16L, d.getContentOffset()); + Assertions.assertEquals(64L, d.getContentSizeInBytes()); + } + + // --- DV metadata validation (port of upstream #65676 IcebergDeleteFileFilter.validateDeletionVectorMetadata) --- + + @Test + public void validateDeletionVectorMetadataAcceptsInBoundsBlob() { + // A well-formed DV blob (offset+length within the puffin file) passes untouched. Guards the four + // rejection branches below against firing on valid metadata. MUTATION: tightening any bound -> red. + IcebergScanPlanProvider.validateDeletionVectorMetadata("s3://b/dv.puffin", 256L, 16L, 64L); + } + + @Test + public void validateDeletionVectorMetadataRejectsMissingOffsetOrLength() { + // A puffin DV with no content_offset/size cannot be addressed inside the puffin file; rejecting on the FE + // (fail loud) beats handing BE a null offset that NPEs deep in range building. MUTATION: dropping the null + // guard -> the null reaches convertDelete/buildPositionDeleteRange and BE range params. + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, () -> + IcebergScanPlanProvider.validateDeletionVectorMetadata("s3://b/dv.puffin", 256L, null, 64L)); + Assertions.assertTrue(e.getMessage().contains("misses content offset or length")); + Assertions.assertTrue(e.getMessage().contains("s3://b/dv.puffin")); + } + + @Test + public void validateDeletionVectorMetadataRejectsNegativeValues() { + // A negative offset/length/file-size is malformed; it would become a bogus (or huge, when reinterpreted) + // read range on BE. MUTATION: dropping the sign guard -> a negative offset flows to BE. + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, () -> + IcebergScanPlanProvider.validateDeletionVectorMetadata("s3://b/dv.puffin", 256L, -1L, 64L)); + Assertions.assertTrue(e.getMessage().contains("must be non-negative")); + } + + @Test + public void validateDeletionVectorMetadataRejectsRangeOverflow() { + // offset+length must be checked for long overflow BEFORE the (offset+length > fileSize) test, or the sum + // wraps negative and silently passes the fileSize check. MUTATION: removing the overflow guard -> a + // wrapped-negative sum sneaks past the next check. + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, () -> + IcebergScanPlanProvider.validateDeletionVectorMetadata( + "s3://b/dv.puffin", Long.MAX_VALUE, Long.MAX_VALUE, 1L)); + Assertions.assertTrue(e.getMessage().contains("range overflows")); + } + + @Test + public void validateDeletionVectorMetadataRejectsRangeBeyondFileSize() { + // The blob [offset, offset+length) must lie inside the puffin file. 80+40 > 100 addresses bytes past the + // file end. MUTATION: dropping the fileSize bound -> BE reads/allocates past the file. + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, () -> + IcebergScanPlanProvider.validateDeletionVectorMetadata("s3://b/dv.puffin", 100L, 80L, 40L)); + Assertions.assertTrue(e.getMessage().contains("exceeds file size")); + } + + @Test + public void convertDeleteRejectsMalformedDeletionVector() { + // Wiring proof: the normal merge-on-read path (convertDelete) runs the validation before emitting the DV + // carrier. A puffin whose blob range (200+100) exceeds its file size (256) is rejected here, never passed + // to BE. MUTATION: removing the convertDelete call site -> this returns a carrier instead of throwing. + DeleteFile delete = deletionVectorFile("s3://b/db/t1/dv.puffin", 200L, 100L); + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, () -> + provider().convertDelete(delete, UnaryOperator.identity())); + Assertions.assertTrue(e.getMessage().contains("exceeds file size")); + Assertions.assertTrue(e.getMessage().contains("dv.puffin")); + } + + @Test + public void convertDeleteEqualityDeleteCarriesFieldIds() { + // EQUALITY_DELETES -> content 2 + the equality field-ids from delete metadata (correct independent of + // the T06 data-schema dictionary). MUTATION: wrong content id / dropped field-ids -> red (BE projects + // the wrong columns for the equality match). + DeleteFile delete = equalityDeleteFile("s3://b/db/t1/eq.parquet", FileFormat.PARQUET, 1, 2); + TIcebergDeleteFileDesc d = provider().convertDelete(delete, UnaryOperator.identity()).toThrift(); + + Assertions.assertEquals(2, d.getContent()); + Assertions.assertEquals(Arrays.asList(1, 2), d.getFieldIds()); + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, d.getFileFormat()); + Assertions.assertFalse(d.isSetPositionLowerBound()); + Assertions.assertFalse(d.isSetContentOffset()); + } + + @Test + public void convertDeleteNormalizesDeletePathViaContext() { + // Delete paths live inside iceberg_params (the parent does not normalize them), so the connector must + // route them through the engine seam (legacy LocationPath.toStorageLocation). BE's S3 factory only + // opens s3://, so an un-normalized oss:// deletion path silently drops merge-on-read deletes -> wrong + // rows. MUTATION: handing BE the raw oss:// path -> red. + RecordingConnectorContext context = new RecordingConnectorContext(); + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(Collections.emptyMap(), new RecordingIcebergCatalogOps(), context); + DeleteFile delete = positionDeleteFile("oss://bucket/db/t1/pos.parquet", FileFormat.PARQUET, null, null); + + TIcebergDeleteFileDesc d = + provider.convertDelete(delete, provider.newUriNormalizer(Collections.emptyMap())).toThrift(); + + Assertions.assertEquals("s3://bucket/db/t1/pos.parquet", d.getPath()); + Assertions.assertTrue(context.normalizedUris.contains("oss://bucket/db/t1/pos.parquet")); + } + + @Test + public void getDeleteFilesReadsBackAllPathsIncludingEquality() { + // The VERBOSE EXPLAIN read-back returns EVERY delete path (incl equality) — the equality/non-equality + // split legacy keeps in deleteFilesByReferencedDataFile is only for the write/rewrite path, not this + // deleteFileNum count. MUTATION: filtering out equality deletes here -> red. + TIcebergFileDesc fd = new TIcebergFileDesc(); + fd.setDeleteFiles(Arrays.asList( + deleteDesc("s3://b/pos.parquet", 1), + deleteDesc("s3://b/eq.parquet", 2), + deleteDesc("s3://b/dv.puffin", 3))); + TTableFormatFileDesc tf = new TTableFormatFileDesc(); + tf.setIcebergParams(fd); + + Assertions.assertEquals(Arrays.asList("s3://b/pos.parquet", "s3://b/eq.parquet", "s3://b/dv.puffin"), + provider().getDeleteFiles(tf)); + } + + @Test + public void getDeleteFilesEmptyWhenNoIcebergParamsOrNoDeletes() { + IcebergScanPlanProvider provider = provider(); + // null params / no iceberg params / iceberg params without delete_files all -> empty (legacy guards). + Assertions.assertTrue(provider.getDeleteFiles(null).isEmpty()); + Assertions.assertTrue(provider.getDeleteFiles(new TTableFormatFileDesc()).isEmpty()); + TTableFormatFileDesc tf = new TTableFormatFileDesc(); + tf.setIcebergParams(new TIcebergFileDesc()); + Assertions.assertTrue(provider.getDeleteFiles(tf).isEmpty()); + } + + @Test + public void planScanAttachesRealPositionDeleteEndToEnd() { + // End-to-end on a real v2 table: a position delete committed via RowDelta must reach delete_files, + // proving task.deletes() flows through planScan -> buildRange -> populateRangeParams. MUTATION: + // never reading task.deletes() (T03 behavior) -> delete_files empty -> red. + Map v2 = Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"); + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned(), v2); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)) + .commit(); + DeleteFile posDelete = FileMetadata.deleteFileBuilder(table.spec()) + .ofPositionDeletes() + .withPath("s3://b/db/t1/pos-delete.parquet") + .withFormat(FileFormat.PARQUET) + .withFileSizeInBytes(128L) + .withRecordCount(2L) + .withReferencedDataFile("s3://b/db/t1/f1.parquet") + .build(); + 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()); + Assertions.assertEquals(1, ranges.size()); + + TFileRangeDesc rangeDesc = populate(ranges.get(0)); + TIcebergFileDesc fd = rangeDesc.getTableFormatParams().getIcebergParams(); + Assertions.assertEquals(1, fd.getDeleteFilesSize()); + TIcebergDeleteFileDesc d = fd.getDeleteFiles().get(0); + Assertions.assertEquals("s3://b/db/t1/pos-delete.parquet", d.getPath()); + Assertions.assertEquals(1, d.getContent()); + // The EXPLAIN read-back sees the same delete path. + Assertions.assertEquals(Collections.singletonList("s3://b/db/t1/pos-delete.parquet"), + provider.getDeleteFiles(rangeDesc.getTableFormatParams())); + } + + // --- data-path normalization (the gap the T04 parity review surfaced; legacy createIcebergSplit:852) --- + + @Test + public void planScanNormalizesDataFilePathButKeepsOriginalFilePathRaw() { + // The range path BE opens MUST be scheme-normalized (oss/cos/obs/s3a -> s3), mirroring legacy + // createIcebergSplit:852 (2-arg LocationPath.of) + paimon FIX-URI-NORMALIZE — otherwise BE's s3-only + // factory cannot open an object-store data file at the P6.6 cutover. But original_file_path stays RAW: + // BE matches position-delete entries against the raw iceberg path (legacy setOriginalFilePath:304). + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(table.spec(), "oss://bucket/db/t1/f.parquet", 1024, null, null)) + .commit(); + RecordingConnectorContext context = new RecordingConnectorContext(); + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); + + List ranges = provider.planScan( + null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + Assertions.assertEquals(1, ranges.size()); + + // MUTATION: emitting the raw oss:// range path (the pre-fix behavior) -> red. + Assertions.assertEquals("s3://bucket/db/t1/f.parquet", ranges.get(0).getPath().get()); + Assertions.assertTrue(context.normalizedUris.contains("oss://bucket/db/t1/f.parquet")); + // MUTATION: normalizing original_file_path too -> BE position-delete matching breaks -> red. + TIcebergFileDesc fd = populate(ranges.get(0)).getTableFormatParams().getIcebergParams(); + Assertions.assertEquals("oss://bucket/db/t1/f.parquet", fd.getOriginalFilePath()); + } + + // --- T05: COUNT(*) pushdown (getCountFromSnapshot + collapse-to-one count range, mirrors paimon) --- + + private static List planCount(IcebergScanPlanProvider provider, ConnectorSession session, + boolean countPushdown) { + // The COUNT-pushdown-aware 7-arg overload the generic PluginDrivenScanNode invokes (limit/ + // requiredPartitions are unused by the iceberg read path). A FRESH handle per call mirrors a real + // planning pass: PluginDrivenScanNode.currentHandle is a per-query, per-scan-node handle, so the + // query-scoped fat-handle memo (PERF-01) must never bleed from one test's table to another's (a shared + // static handle would serve the first scenario's cached table to every later one). + return provider.planScan(session, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), + Optional.empty(), -1L, Collections.emptyList(), countPushdown); + } + + @Test + public void countPushdownCollapsesToSingleRangeWithTotalRecords() { + // No-delete table; record counts 1024/100 + 2048/100 + 3072/100 = 10+20+30 = total-records 60. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)) + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 2048, null, null)) + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f3.parquet", 3072, null, null)) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + List ranges = planCount(provider, null, true); + + // Collapse to ONE range carrying the full snapshot count (paimon parity; the legacy >10000 parallel + // multi-split trim is dropped). MUTATION: ignoring countPushdown (T04 behavior) -> 3 ranges, each + // count -1 -> red. + Assertions.assertEquals(1, ranges.size()); + Assertions.assertEquals(60L, ranges.get(0).getPushDownRowCount()); + // Kept whole (NOT byte-tiled): the representative is a whole-file FileScanTask (start 0). MUTATION: + // running splitFiles in the count path -> a sub-range could start != 0 / more than one range -> red. + Assertions.assertEquals(0L, ranges.get(0).getStart()); + // table_level_row_count carries the total to BE's count reader. + Assertions.assertEquals(60L, populate(ranges.get(0)).getTableFormatParams().getTableLevelRowCount()); + } + + @Test + public void countPushdownNotAppliedWithEqualityDeletesScansAll() { + Map v2 = Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"); + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned(), v2); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)) + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 1024, null, null)) + .commit(); + // An equality delete makes total-equality-deletes != "0" -> count NOT pushable. + table.newRowDelta() + .addDeletes(equalityDeleteFile("s3://b/db/t1/eq.parquet", FileFormat.PARQUET, 1)) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + List ranges = planCount(provider, null, true); + + // Equality deletes -> getCountFromSnapshot returns -1 -> fall back to the normal scan (every data file, + // each count -1 so BE reads & counts). MUTATION: pushing the count anyway -> 1 range / a count >= 0 -> red. + Assertions.assertEquals(2, ranges.size()); + for (ConnectorScanRange range : ranges) { + Assertions.assertEquals(-1L, range.getPushDownRowCount()); + } + } + + @Test + public void countPushdownWithPositionDeletesNetsOutWhenIgnoringDangling() { + Map v2 = Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"); + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned(), v2); + // 1000/100 = 10 data records. + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1000, null, null)) + .commit(); + DeleteFile posDelete = FileMetadata.deleteFileBuilder(table.spec()) + .ofPositionDeletes() + .withPath("s3://b/db/t1/pos.parquet") + .withFormat(FileFormat.PARQUET) + .withFileSizeInBytes(128L) + .withRecordCount(3L) // total-position-deletes 3 + .withReferencedDataFile("s3://b/db/t1/f1.parquet") + .build(); + table.newRowDelta().addDeletes(posDelete).commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + ConnectorSession session = new FakeScanSession("UTC", + Collections.singletonMap("ignore_iceberg_dangling_delete", "true")); + + List ranges = planCount(provider, session, true); + + // total-records(10) - total-position-deletes(3) = 7, pushable only because the session ignores dangling + // deletes. MUTATION: returning total-records (10) / not honoring the session flag -> wrong count -> red. + Assertions.assertEquals(1, ranges.size()); + Assertions.assertEquals(7L, ranges.get(0).getPushDownRowCount()); + Assertions.assertEquals(7L, populate(ranges.get(0)).getTableFormatParams().getTableLevelRowCount()); + } + + @Test + public void countPushdownWithPositionDeletesScansAllWhenNotIgnoringDangling() { + Map v2 = Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"); + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned(), v2); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1000, null, null)) + .commit(); + DeleteFile posDelete = FileMetadata.deleteFileBuilder(table.spec()) + .ofPositionDeletes() + .withPath("s3://b/db/t1/pos.parquet") + .withFormat(FileFormat.PARQUET) + .withFileSizeInBytes(128L) + .withRecordCount(3L) + .withReferencedDataFile("s3://b/db/t1/f1.parquet") + .build(); + table.newRowDelta().addDeletes(posDelete).commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + // No ignore flag (null session -> default false): position deletes present -> not pushable. + List ranges = planCount(provider, null, true); + + // MUTATION: pushing the count without the ignore flag -> a count >= 0 / single collapsed range -> red. + Assertions.assertFalse(ranges.isEmpty()); + for (ConnectorScanRange range : ranges) { + Assertions.assertEquals(-1L, range.getPushDownRowCount()); + } + } + + @Test + public void countPushdownEmptyTableProducesNoRanges() { + // Empty table (no snapshot) -> getCountFromSnapshot 0, but no representative file -> no range -> BE gets + // 0 ranges -> COUNT returns 0 (legacy returns empty splits too). MUTATION: emitting a synthetic count + // range with no path -> red (no file to build from). + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + List ranges = planCount(provider, null, true); + + Assertions.assertTrue(ranges.isEmpty()); + } + + @Test + public void countPushdownFalseDoesNormalMultiRangeScan() { + // The 7-arg overload with countPushdown=false must behave exactly like the normal scan (no collapse). + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)) + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 1024, null, null)) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + List ranges = planCount(provider, null, false); + + // MUTATION: collapsing on countPushdown=false -> 1 range -> red. + Assertions.assertEquals(2, ranges.size()); + for (ConnectorScanRange range : ranges) { + Assertions.assertEquals(-1L, range.getPushDownRowCount()); + } + } + + // --- T08: manifest-level scan planning (gated by meta.cache.iceberg.manifest.enable) --- + + private static Map manifestCacheProps() { + Map m = new HashMap<>(); + m.put("meta.cache.iceberg.manifest.enable", "true"); + return m; + } + + /** A provider whose manifest-level path (and IcebergManifestCache) is enabled. */ + private static IcebergScanPlanProvider manifestProvider(Map props, Table table, + IcebergManifestCache cache) { + return new IcebergScanPlanProvider(props, opsReturning(table), null, cache); + } + + private static List sortedPaths(List ranges) { + List paths = new ArrayList<>(); + for (ConnectorScanRange r : ranges) { + paths.add(r.getPath().get()); + } + Collections.sort(paths); + return paths; + } + + @Test + public void planScanManifestCacheEnabledMatchesSdkPathAndConsumesCache() { + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/a.parquet", 100, null, null)) + .appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/b.parquet", 200, null, null)) + .appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/c.parquet", 300, null, null)) + .commit(); + IcebergTableHandle handle = new IcebergTableHandle("db1", "t1"); + + // Gate OFF (default): the iceberg SDK splitFiles path (T02). + List sdk = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)) + .planScan(null, handle, Collections.emptyList(), Optional.empty()); + + // 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()); + + // 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. + Assertions.assertEquals(sortedPaths(sdk), sortedPaths(manifest)); + Assertions.assertEquals(3, manifest.size()); + // The cache was actually CONSUMED (the data manifest was read + stored). MUTATION: silently using the SDK + // path despite the enable flag -> cache stays empty -> red. + Assertions.assertTrue(cache.size() > 0, "the manifest cache must be populated by the gated path"); + } + + @Test + public void planScanManifestCachePrunesPartitionLikeSdk() { + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("p").build(); + Table table = createTable("pt", PART_SCHEMA, spec); + table.newAppend() + .appendFile(dataFile(spec, "/d/p1.parquet", 100, null, "p=1")) + .appendFile(dataFile(spec, "/d/p2.parquet", 100, null, "p=2")) + .commit(); + IcebergTableHandle handle = new IcebergTableHandle("db1", "pt"); + Optional wherePeq1 = Optional.of(eqInt("p", 1)); + + List sdk = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)) + .planScan(null, handle, Collections.emptyList(), wherePeq1); + IcebergManifestCache cache = new IcebergManifestCache(); + List manifest = manifestProvider(manifestCacheProps(), table, cache) + .planScan(emptySession(), handle, Collections.emptyList(), wherePeq1); + + // 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. + Assertions.assertEquals(sortedPaths(sdk), sortedPaths(manifest)); + Assertions.assertEquals(1, manifest.size()); + Assertions.assertTrue(manifest.get(0).getPath().get().endsWith("p1.parquet")); + } + + // --- PERF-04: streaming (C17) + COUNT(*) (C18) paths read through the manifest cache, LAZILY --- + // (fallback-to-SDK on a cache-read failure is not unit-tested: IcebergManifestCache is final so it cannot be + // made to throw, exactly as the pre-existing synchronous planFileScanTask fallback is untested; the streaming/ + // count catch(Exception)+recordFailure mirrors that path verbatim.) + + @Test + public void streamSplitsManifestCacheEnabledMatchesSdkPathAndConsumesCache() throws IOException { + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/a.parquet", 100, null, null)) + .appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/b.parquet", 200, null, null)) + .appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/c.parquet", 300, null, null)) + .commit(); + IcebergTableHandle handle = new IcebergTableHandle("db1", "t1"); + + // Cache OFF: the streaming SDK planFiles() path (the pre-PERF-04 behavior). + List sdk = drain(new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)) + .streamSplits(emptySession(), handle, Collections.emptyList(), Optional.empty(), -1L)); + // Cache ON: streaming must now read manifests THROUGH the cache and yield the SAME files (PERF-04 C17). + // MUTATION: streaming still bypasses the cache (scan.planFiles()) -> cache stays empty -> red. + IcebergManifestCache cache = new IcebergManifestCache(); + List cached = drain(manifestProvider(manifestCacheProps(), table, cache) + .streamSplits(emptySession(), handle, Collections.emptyList(), Optional.empty(), -1L)); + + Assertions.assertEquals(sortedPaths(sdk), sortedPaths(cached)); + Assertions.assertEquals(3, cached.size()); + Assertions.assertTrue(cache.size() > 0, "the streaming path must populate the manifest cache"); + } + + @Test + public void streamSplitsManifestCachePrunesPartitionLikeSdk() throws IOException { + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("p").build(); + Table table = createTable("pt", PART_SCHEMA, spec); + table.newAppend() + .appendFile(dataFile(spec, "/d/p1.parquet", 100, null, "p=1")) + .appendFile(dataFile(spec, "/d/p2.parquet", 100, null, "p=2")) + .commit(); + IcebergTableHandle handle = new IcebergTableHandle("db1", "pt"); + Optional wherePeq1 = Optional.of(eqInt("p", 1)); + + List sdk = drain(new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)) + .streamSplits(emptySession(), handle, Collections.emptyList(), wherePeq1, -1L)); + IcebergManifestCache cache = new IcebergManifestCache(); + List cached = drain(manifestProvider(manifestCacheProps(), table, cache) + .streamSplits(emptySession(), handle, Collections.emptyList(), wherePeq1, -1L)); + + // Partition prune (ManifestEvaluator + residual) keeps only p=1 in BOTH paths. MUTATION: the lazy iterator + // dropping the residual/metrics prune -> p=2 leaks in -> sizes differ -> red. + Assertions.assertEquals(sortedPaths(sdk), sortedPaths(cached)); + Assertions.assertEquals(1, cached.size()); + Assertions.assertTrue(cached.get(0).getPath().get().endsWith("p1.parquet")); + } + + @Test + public void streamSplitsManifestCacheFlatMapsAcrossDataManifests() throws IOException { + // Three separate appends -> three data manifests. The lazy flat-map iterator must walk ALL of them (not + // just the first) and yield every file. MUTATION: advance() not advancing past the first manifest -> < 3. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/a.parquet", 100, null, null)) + .commit(); + table.newAppend().appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/b.parquet", 200, null, null)) + .commit(); + table.newAppend().appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/c.parquet", 300, null, null)) + .commit(); + Assertions.assertTrue(table.currentSnapshot().dataManifests(table.io()).size() >= 2, + "precondition: multiple data manifests"); + IcebergTableHandle handle = new IcebergTableHandle("db1", "t1"); + + IcebergManifestCache cache = new IcebergManifestCache(); + List cached = drain(manifestProvider(manifestCacheProps(), table, cache) + .streamSplits(emptySession(), handle, Collections.emptyList(), Optional.empty(), -1L)); + Assertions.assertEquals(3, cached.size(), "the lazy iterator must flat-map across all data manifests"); + } + + @Test + public void countPushdownManifestCacheMatchesCountAndReadsLazily() { + // Three appends -> three data manifests; record counts 10+20+30 = total-records 60 (snapshot summary). + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)).commit(); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 2048, null, null)).commit(); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f3.parquet", 3072, null, null)).commit(); + int totalManifests = table.currentSnapshot().dataManifests(table.io()).size(); + Assertions.assertTrue(totalManifests >= 2, "precondition: multiple data manifests"); + + List sdk = planCount( + new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)), null, true); + IcebergManifestCache cache = new IcebergManifestCache(); + List cached = planCount(manifestProvider(manifestCacheProps(), table, cache), + emptySession(), true); + + // Same collapsed single range + same count (from the snapshot summary). The placeholder file path may + // differ (SDK planFiles' ParallelIterable order is non-deterministic), so assert count + shape, not path. + Assertions.assertEquals(1, sdk.size()); + Assertions.assertEquals(1, cached.size()); + Assertions.assertEquals(60L, cached.get(0).getPushDownRowCount()); + Assertions.assertEquals(sdk.get(0).getPushDownRowCount(), cached.get(0).getPushDownRowCount()); + // Lazy early stop: COUNT needs only the first surviving file, so it must NOT read every data manifest. + // MUTATION: routing count through the materialized cache path -> reads all manifests -> size == total -> red. + Assertions.assertTrue(cache.size() >= 1 && cache.size() < totalManifests, + "count reads lazily (stops at the first file's manifest), not the whole table"); + } + + @Test + public void countPushdownManifestCacheEmptyNullSnapshotReturnsNoRanges() { + // A never-appended table has no current snapshot; getCountFromSnapshot returns 0 (>=0) so planCountPushdown + // runs with a null-snapshot scan. cacheBackedFileScanTasks must keep the null-snapshot guard (empty + // iterable), not NPE. MUTATION: dropping the guard -> NPE on scan.snapshot() -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + IcebergManifestCache cache = new IcebergManifestCache(); + List cached = planCount(manifestProvider(manifestCacheProps(), table, cache), + emptySession(), true); + Assertions.assertTrue(cached.isEmpty(), "empty (null-snapshot) count with cache enabled yields no ranges"); + } + + @Test + public void planScanManifestCacheEmptyTableReturnsNoRanges() { + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + IcebergManifestCache cache = new IcebergManifestCache(); + List ranges = manifestProvider(manifestCacheProps(), table, cache) + .planScan(null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + // 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()); + Assertions.assertEquals(0, cache.size()); + } + + @Test + public void planScanManifestGateDisabledByTtlZeroUsesSdkPath() { + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/a.parquet", 100, null, null)) + .commit(); + Map props = manifestCacheProps(); + 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()); + // 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()); + Assertions.assertEquals(0, cache.size()); + } + + private static int deleteCount(ConnectorScanRange range) { + TFileRangeDesc d = populate(range); + if (!d.getTableFormatParams().isSetIcebergParams() + || !d.getTableFormatParams().getIcebergParams().isSetDeleteFiles()) { + return 0; + } + return d.getTableFormatParams().getIcebergParams().getDeleteFiles().size(); + } + + @Test + public void planScanManifestCacheAssociatesDeletesLikeSdk() { + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/a.parquet", 100, null, null)) + .commit(); + // The position delete is committed in a LATER snapshot (higher sequence number) so it applies to the + // earlier data file — exactly the case DeleteFileIndex.forDataFile(seq, file) resolves. + table.newRowDelta() + .addDeletes(positionDeleteFile("/d/a-pos-del.parquet", FileFormat.PARQUET, null, null)) + .commit(); + IcebergTableHandle handle = new IcebergTableHandle("db1", "t1"); + + List sdk = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)) + .planScan(null, handle, Collections.emptyList(), Optional.empty()); + IcebergManifestCache cache = new IcebergManifestCache(); + List manifest = manifestProvider(manifestCacheProps(), table, cache) + .planScan(emptySession(), handle, Collections.emptyList(), Optional.empty()); + + Assertions.assertEquals(1, sdk.size()); + Assertions.assertEquals(1, manifest.size()); + // WHY: the manifest-level path must associate the position delete with the data file via the VENDORED + // DeleteFileIndex (the whole reason it is vendored), matching the SDK path. MUTATION: the vendored + // DeleteFileIndex failing to attach the delete -> 0 -> red. + Assertions.assertEquals(1, deleteCount(sdk.get(0)), "the SDK path associates the position delete"); + Assertions.assertEquals(deleteCount(sdk.get(0)), deleteCount(manifest.get(0))); + // Both the data manifest and the delete manifest were read through the cache. + Assertions.assertTrue(cache.size() >= 2, "the data + delete manifests must both be cached"); + } + + // --- T09: vended credentials (extractVendedToken + static/vended location.* + URI threading) --- + + @Test + public void extractVendedTokenMergesIoPropsAndStorageCredentials() { + FakeIcebergTable table = fakeTable("t1"); + Map ioProps = new HashMap<>(); + ioProps.put("s3.endpoint", "ep"); + StorageCredential cred = + StorageCredential.create("s3://b", Collections.singletonMap("s3.access-key-id", "ak")); + table.setIo(new VendedFileIO(ioProps, Collections.singletonList(cred))); + + Map token = IcebergScanPlanProvider.extractVendedToken(table, true); + + // WHY: legacy IcebergVendedCredentialsProvider.extractRawVendedCredentials = io.properties() UNION every + // SupportsStorageCredentials.credentials().config(). MUTATION: dropping the credentials merge -> + // s3.access-key-id absent -> red. + Assertions.assertEquals("ep", token.get("s3.endpoint")); + Assertions.assertEquals("ak", token.get("s3.access-key-id")); + } + + @Test + public void extractVendedTokenReturnsIoPropsWhenFileIoHasNoStorageCredentials() { + FakeIcebergTable table = fakeTable("t1"); + table.setIo(new PropsOnlyFileIO(Collections.singletonMap("s3.endpoint", "ep"))); + + // WHY: a non-SupportsStorageCredentials FileIO still contributes its own properties (legacy reads + // io.properties() unconditionally) and must not crash on the absent credentials() call. MUTATION: + // unconditional cast to SupportsStorageCredentials -> ClassCastException -> red. + Assertions.assertEquals(Collections.singletonMap("s3.endpoint", "ep"), + IcebergScanPlanProvider.extractVendedToken(table, true)); + } + + @Test + public void extractVendedTokenEmptyWhenFlagDisabled() { + FakeIcebergTable table = fakeTable("t1"); + StorageCredential cred = + StorageCredential.create("s3://b", Collections.singletonMap("s3.access-key-id", "ak")); + table.setIo(new VendedFileIO(Collections.emptyMap(), Collections.singletonList(cred))); + + // WHY: the catalog flag gates extraction (legacy isVendedCredentialsEnabled) BEFORE touching io() — a + // non-REST / flag-off catalog must extract NOTHING even if the FileIO happens to vend creds. MUTATION: + // ignoring vendedEnabled -> ak extracted -> red. + Assertions.assertTrue(IcebergScanPlanProvider.extractVendedToken(table, false).isEmpty()); + } + + @Test + public void extractVendedTokenEmptyForNullTable() { + Assertions.assertTrue(IcebergScanPlanProvider.extractVendedToken(null, true).isEmpty()); + } + + @Test + public void getScanNodePropertiesEmitsStaticStorageCredsAsLocation() { + FakeIcebergTable table = fakeTable("t1"); + RecordingConnectorContext context = new RecordingConnectorContext(); + Map beStatic = new HashMap<>(); + beStatic.put("AWS_ACCESS_KEY", "ak"); + beStatic.put("AWS_SECRET_KEY", "sk"); + beStatic.put("AWS_ENDPOINT", "ep"); + context.storageProperties = Collections.singletonList(fakeBackendStorage(beStatic)); + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); + + Map props = provider.getScanNodeProperties( + null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + + // WHY (B-9): BE's native (FILE_S3) reader understands ONLY AWS_* canonical keys; the connector must ship + // the engine-normalized creds under location.*, never the raw aliases (403 on a private bucket). + // MUTATION: dropping the static-creds block (the gap this task fixes) -> location.AWS_ACCESS_KEY absent + // -> red. + Assertions.assertEquals("ak", props.get("location.AWS_ACCESS_KEY")); + Assertions.assertEquals("sk", props.get("location.AWS_SECRET_KEY")); + Assertions.assertEquals("ep", props.get("location.AWS_ENDPOINT")); + } + + @Test + public void getScanNodePropertiesOverlaysVendedCredsOverStatic() { + FakeIcebergTable table = fakeTable("t1"); + // A non-empty FileIO props map -> a non-empty vended token when the flag is on. + table.setIo(new PropsOnlyFileIO(Collections.singletonMap("s3.endpoint", "x"))); + RecordingConnectorContext context = new RecordingConnectorContext(); + Map beStatic = new HashMap<>(); + beStatic.put("AWS_ACCESS_KEY", "static-ak"); + beStatic.put("AWS_ENDPOINT", "static-ep"); + context.storageProperties = Collections.singletonList(fakeBackendStorage(beStatic)); + Map vended = new HashMap<>(); + vended.put("AWS_ACCESS_KEY", "vended-ak"); + vended.put("AWS_SECRET_KEY", "vended-sk"); + vended.put("AWS_ENDPOINT", "vended-ep"); + context.vendedBeProps = vended; + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(restVendedFlagOn(), opsReturning(table), context); + + Map props = provider.getScanNodeProperties( + null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + + // WHY: vended creds must overlay (win over) the static location key on collision (legacy precedence). + // MUTATION: overlaying static AFTER vended (or no vended overlay) -> location.AWS_ACCESS_KEY != vended-ak + // -> red. + Assertions.assertEquals("vended-ak", props.get("location.AWS_ACCESS_KEY")); + Assertions.assertEquals("vended-sk", props.get("location.AWS_SECRET_KEY")); + Assertions.assertEquals("vended-ep", props.get("location.AWS_ENDPOINT")); + } + + @Test + public void getScanNodePropertiesOmitsVendedWhenFlagDisabled() { + FakeIcebergTable table = fakeTable("t1"); + // Even a credential-bearing FileIO must yield no vended overlay when the catalog flag is off. + table.setIo(new PropsOnlyFileIO(Collections.singletonMap("s3.endpoint", "x"))); + RecordingConnectorContext context = new RecordingConnectorContext(); + context.vendedBeProps = Collections.singletonMap("AWS_ACCESS_KEY", "vended-ak"); + // No vended flag -> default false. + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); + + Map props = provider.getScanNodeProperties( + null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + + // WHY: the catalog flag gates the vended overlay (legacy isVendedCredentialsEnabled). Flag off -> empty + // token -> vendStorageCredentials returns empty -> no vended location.*. MUTATION: ignoring the flag -> + // location.AWS_ACCESS_KEY=vended-ak present -> red. + Assertions.assertFalse(props.containsKey("location.AWS_ACCESS_KEY"), "flag off -> no vended overlay"); + } + + @Test + public void getScanNodePropertiesOmitsVendedWhenFlagSetButNonRestFlavor() { + FakeIcebergTable table = fakeTable("t1"); + table.setIo(new PropsOnlyFileIO(Collections.singletonMap("s3.endpoint", "x"))); + RecordingConnectorContext context = new RecordingConnectorContext(); + context.vendedBeProps = Collections.singletonMap("AWS_ACCESS_KEY", "vended-ak"); + // The vended flag is set, but the catalog flavor is HMS (not REST). Legacy isVendedCredentialsEnabled is + // `instanceof IcebergRestProperties && flag`, so a non-REST catalog NEVER vends even with the flag set. + Map hmsWithRestFlag = new HashMap<>(); + hmsWithRestFlag.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_HMS); + hmsWithRestFlag.put(IcebergConnectorProperties.REST_VENDED_CREDENTIALS_ENABLED, "true"); + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(hmsWithRestFlag, opsReturning(table), context); + + Map props = provider.getScanNodeProperties( + null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + + // WHY: legacy gates vended on the REST metastore type (instanceof IcebergRestProperties), not just the + // flag; a misconfigured non-REST catalog carrying the flag must NOT vend (parity). MUTATION: gating on + // the flag alone -> location.AWS_ACCESS_KEY=vended-ak present -> red. + Assertions.assertFalse(props.containsKey("location.AWS_ACCESS_KEY"), + "non-REST flavor -> no vended overlay even with the flag set"); + } + + @Test + public void getScanNodePropertiesNoContextEmitsNoLocation() { + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + // 2-arg ctor -> context == null (offline harness path). + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + Map props = provider.getScanNodeProperties( + null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + + // WHY: with no context the connector cannot normalize creds, so it emits NO location.* (never raw + // aliases). MUTATION: NPE on null context, or emitting location.* -> red. + Assertions.assertTrue(props.keySet().stream().noneMatch(k -> k.startsWith("location.")), + "no context -> no location.* keys"); + } + + @Test + public void getScanNodePropertiesSkipsStorageWithoutBackendModelAndMergesRest() { + FakeIcebergTable table = fakeTable("t1"); + RecordingConnectorContext context = new RecordingConnectorContext(); + Map beMap = new HashMap<>(); + beMap.put("AWS_ACCESS_KEY", "ak"); + beMap.put("AWS_ENDPOINT", "ep"); + // A typed list mixing a backend WITHOUT a BE model (toBackendProperties() empty — the HDFS case) and a + // real object-store backend: exercises the .ifPresent skip and the multi-entry putAll merge. + context.storageProperties = Arrays.asList(fakeStorageWithoutBackend(), fakeBackendStorage(beMap)); + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); + + Map props = provider.getScanNodeProperties( + null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + + // WHY: a StorageProperties with no BE model (Optional.empty) must be SKIPPED, never crash, while a real + // object-store entry alongside it still ships its AWS_* under location.* (the merge loop). MUTATION: + // .ifPresent -> .get()/.orElseThrow() -> NoSuchElementException on the empty entry -> red. + Assertions.assertEquals("ak", props.get("location.AWS_ACCESS_KEY")); + Assertions.assertEquals("ep", props.get("location.AWS_ENDPOINT")); + } + + @Test + public void planScanThreadsVendedTokenIntoDataAndDeletePathNormalize() { + Map v2 = Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"); + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned(), v2); + table.newAppend() + .appendFile(dataFile(table.spec(), "oss://b/db/t1/f1.parquet", 1024, null, null)) + .commit(); + table.newRowDelta() + .addDeletes(positionDeleteFile("oss://b/db/t1/pos.parquet", FileFormat.PARQUET, null, null)) + .commit(); + RecordingConnectorContext context = new RecordingConnectorContext(); + // No vended flag -> the extracted token is empty; the in-memory FileIO's properties() throws (a test + // artifact, unlike a real REST FileIO), so flag-on extraction is exercised separately by the + // extractVendedToken / getScanNodeProperties overlay tests with an injected FileIO. Here we prove the + // PLUMBING: planScan routes BOTH the data and delete paths through the 2-arg normalizeStorageUri, + // passing the per-table vended token (empty here, but the MAP, not null). + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); + + List ranges = provider.planScan( + null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + Assertions.assertEquals(1, ranges.size()); + + // WHY: both the data-file path and the delete-file path must route through the 2-arg + // normalizeStorageUri carrying the per-table vended token (T09), so REST object-store paths normalize + // via the vended map. MUTATION: dropping the normalization -> the data/delete paths stay oss:// + // (un-normalized) -> red; reverting to the 1-arg normalize -> the recording fake's 1-arg form folds to + // a NULL token -> lastVendedToken == null != the extracted (empty) map -> red. + Assertions.assertEquals("s3://b/db/t1/f1.parquet", ranges.get(0).getPath().get()); + TIcebergFileDesc fd = populate(ranges.get(0)).getTableFormatParams().getIcebergParams(); + Assertions.assertEquals("s3://b/db/t1/pos.parquet", fd.getDeleteFiles().get(0).getPath()); + Assertions.assertEquals(IcebergScanPlanProvider.extractVendedToken(table, false), context.lastVendedToken); + } + + @Test + public void planScanDerivesUriNormalizerOncePerScanNotPerFile() { + // C3 PERF GUARD: the vended token is scan-invariant, so the expensive token->storage-config + // derivation must be built ONCE per scan and reused for every file path — not rebuilt per data file. + // Drive a scan over three data files and assert the connector entered newStorageUriNormalizer exactly + // once while still normalizing all three paths. MUTATION: reverting to a per-file + // context.normalizeStorageUri (re-deriving the config per file) leaves newNormalizerCount == 0 (the + // once-per-scan seam is never used) -> red; dropping a path's normalize -> normalizeCount != 3 -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(table.spec(), "oss://b/db/t1/f1.parquet", 1024, null, null)) + .appendFile(dataFile(table.spec(), "oss://b/db/t1/f2.parquet", 1024, null, null)) + .appendFile(dataFile(table.spec(), "oss://b/db/t1/f3.parquet", 1024, null, null)) + .commit(); + RecordingConnectorContext context = new RecordingConnectorContext(); + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); + + List ranges = provider.planScan( + null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + + Assertions.assertEquals(3, ranges.size()); + Assertions.assertEquals(1, context.newNormalizerCount); + Assertions.assertEquals(3, context.normalizeCount); + } + + @Test + public void convertDeleteNormalizesDeletePathViaVendedToken() { + RecordingConnectorContext context = new RecordingConnectorContext(); + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(Collections.emptyMap(), new RecordingIcebergCatalogOps(), context); + DeleteFile delete = positionDeleteFile("oss://bucket/db/t1/pos.parquet", FileFormat.PARQUET, null, null); + Map token = Collections.singletonMap("s3.access-key-id", "ak"); + + TIcebergDeleteFileDesc d = provider.convertDelete(delete, provider.newUriNormalizer(token)).toThrift(); + + // WHY: the scan-scoped normalizer must bake in the vended token so the delete path normalizes via the + // 2-arg seam (T09). MUTATION: dropping the token / the 1-arg normalize -> lastVendedToken != token -> red. + Assertions.assertEquals("s3://bucket/db/t1/pos.parquet", d.getPath()); + Assertions.assertEquals(token, context.lastVendedToken); + } + + // --- T10 parity gap-fills (audit wf_9d88fe61-5c7) --- + + @Test + public void planScanDefaultSplitHeuristicTilesAndMaxFileSplitNumCapCollapses() { + // PP-1: the DEFAULT split heuristic (determineTargetFileSplitSize, splitFiles:738) + the + // max_file_split_num cap escalation were never exercised by a range count — the existing split test + // forces the file_split_size override branch (:727), and the small-file tests never sub-split. Drive + // BOTH branches on one 96MB file WITHOUT split offsets, so the iceberg fixed-size splitter tiles it by + // the heuristic's target size directly (an offset-aware file would cut at every row group and ignore a + // larger target, making the cap's effect invisible to a count assertion). + long mb = 1024L * 1024L; + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/big.parquet", 96 * mb, null, null)) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + // (1) DEFAULT heuristic (NO file_split_size override): total 96MB << maxSplitSize*maxInitialSplitNum + // (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()); + 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; + long total = 0; + for (ConnectorScanRange r : def) { + Assertions.assertEquals(expectedStart, r.getStart(), "default-heuristic ranges must tile contiguously"); + expectedStart += r.getLength(); + total += r.getLength(); + } + Assertions.assertEquals(96 * mb, total, "the default-heuristic ranges must cover the whole file"); + + // (2) max_file_split_num=1 forces minSplitSizeForMaxNum = ceil(96MB/1) = 96MB, so the cap raises the + // 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()); + 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()); + } + + @Test + public void planScanPartitionBearingFileWithNoIdentityValuesEmitsNoColumnsFromPath() { + // NF-1: a table partitioned by a NON-identity transform (bucket) is partition-bearing (spec id set) but + // has ZERO identity columns-from-path — the T03 Bug2 shape (partition evolution / bucket-only spec). The + // range must report isPartitionBearing()==true (so the engine does NOT fall back to Hive path-parsing, + // which throws on iceberg's non-key=value layout) yet emit an EMPTY partition-values list and NO + // columns-from-path. The carrier unit test pins isPartitionBearing in isolation; this drives the empty + // path end-to-end through buildRange. MUTATION: deriving isPartitionBearing from a non-empty values list + // -> false here -> the engine path-parses -> red. + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).bucket("id", 4).build(); + Table table = createTable("ev", PART_SCHEMA, spec); + table.newAppend() + .appendFile(dataFile(spec, "s3://b/db/ev/f.parquet", 512, null, "id_bucket=0", FileFormat.PARQUET)) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + List ranges = provider.planScan( + null, new IcebergTableHandle("db1", "ev"), Collections.emptyList(), Optional.empty()); + Assertions.assertEquals(1, ranges.size()); + ConnectorScanRange range = ranges.get(0); + Assertions.assertTrue(range.isPartitionBearing(), "a bucket-partitioned file is partition-bearing"); + Assertions.assertTrue(range.getPartitionValues().isEmpty(), "no identity columns -> empty partition values"); + + TFileRangeDesc rd = populate(range); + Assertions.assertTrue(rd.getTableFormatParams().getIcebergParams().isSetPartitionSpecId(), + "partition-bearing -> spec id is emitted"); + Assertions.assertFalse(rd.isSetColumnsFromPathKeys(), "no identity values -> no columns-from-path keys"); + Assertions.assertFalse(rd.isSetColumnsFromPath()); + Assertions.assertFalse(rd.isSetColumnsFromPathIsNull()); + } + + @Test + public void convertDeletePositionDeleteTreatsStoredMinusOneBoundAsUnset() { + // G1: a genuinely-STORED -1L DELETE_FILE_POS bound (distinct from an ABSENT bounds map) must collapse to + // UNSET (readPositionBound:483 `value == -1L -> null`), mirroring legacy IcebergDeleteFileFilter's -1 + // sentinel. The existing no-bounds test passes a null map (early return), never reaching the value==-1L + // arm. MUTATION: dropping the `|| value == -1L` arm (emitting -1 as a real bound) -> red. + DeleteFile bothMinusOne = positionDeleteFile("s3://b/db/t1/pos.parquet", FileFormat.PARQUET, -1L, -1L); + TIcebergDeleteFileDesc d = provider().convertDelete(bothMinusOne, UnaryOperator.identity()).toThrift(); + Assertions.assertEquals(1, d.getContent()); + Assertions.assertFalse(d.isSetPositionLowerBound()); + Assertions.assertFalse(d.isSetPositionUpperBound()); + + // Mixed: only the -1L bound is dropped; a real lower bound still emits. + DeleteFile mixed = positionDeleteFile("s3://b/db/t1/pos2.parquet", FileFormat.PARQUET, 3L, -1L); + TIcebergDeleteFileDesc m = provider().convertDelete(mixed, UnaryOperator.identity()).toThrift(); + Assertions.assertEquals(3L, m.getPositionLowerBound()); + Assertions.assertFalse(m.isSetPositionUpperBound()); + } + + @Test + public void extractVendedTokenCredentialWinsOnKeyCollision() { + // VC-2: extractVendedToken seeds io.properties() then putAll(credential.config()), so on a DUPLICATE key + // the server-vended StorageCredential WINS (legacy IcebergVendedCredentialsProvider ordering). The + // existing merge test uses disjoint keys and cannot pin this precedence. MUTATION: seeding credentials + // first then overlaying io.properties() -> s3.access-key-id == "io-ak" -> red. + FakeIcebergTable table = fakeTable("t1"); + Map ioProps = new HashMap<>(); + ioProps.put("s3.access-key-id", "io-ak"); // colliding key, io value + ioProps.put("s3.endpoint", "io-ep"); // disjoint key, must survive + StorageCredential cred = + StorageCredential.create("s3://b", Collections.singletonMap("s3.access-key-id", "cred-ak")); + table.setIo(new VendedFileIO(ioProps, Collections.singletonList(cred))); + + Map token = IcebergScanPlanProvider.extractVendedToken(table, true); + Assertions.assertEquals("cred-ak", token.get("s3.access-key-id")); + Assertions.assertEquals("io-ep", token.get("s3.endpoint")); + } + + @Test + public void planScanCombinesPartitionPruneDeleteAndPathNormalizeOnOneRange() { + // G2 + E2E-1 + E2E-2: a real-query shape legacy builds in a single createIcebergSplit pass — a partitioned + // v2 object-store table with a position delete, scanned under WHERE p=1. The existing delete e2e tests all + // use UNPARTITIONED tables, so the co-existence of partition + delete + normalization carriers on the ONE + // range a predicate leaves was never pinned. The surviving p=1 range must carry TOGETHER: (a) a + // scheme-normalized data path with a RAW original_file_path, (b) partition columns-from-path, (c) its + // position delete (also scheme-normalized). MUTATION: a predicate path skipping delete attachment, or the + // partition block clobbering the delete block (or vice-versa), drops one of these -> red. + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("p").build(); + Map v2 = Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"); + Table table = createTable("pt", PART_SCHEMA, spec, v2); + table.newAppend() + .appendFile(dataFile(spec, "oss://b/db/pt/p=1/a.parquet", 512, null, "p=1")) + .appendFile(dataFile(spec, "oss://b/db/pt/p=2/b.parquet", 512, null, "p=2")) + .commit(); + // Position delete on the p=1 data file, committed in a LATER snapshot (higher seq) and tagged to the p=1 + // partition so DeleteFileIndex.forDataFile resolves it to a.parquet only. + DeleteFile posDelete = FileMetadata.deleteFileBuilder(spec) + .ofPositionDeletes() + .withPath("oss://b/db/pt/p=1/a-pos-del.parquet") + .withFormat(FileFormat.PARQUET) + .withFileSizeInBytes(128L) + .withRecordCount(2L) + .withPartitionPath("p=1") + .withReferencedDataFile("oss://b/db/pt/p=1/a.parquet") + .build(); + table.newRowDelta().addDeletes(posDelete).commit(); + RecordingConnectorContext context = new RecordingConnectorContext(); + 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))); + + // (a) predicate pruned p=2 -> exactly the p=1 data file survives, scheme-normalized; original raw. + Assertions.assertEquals(1, ranges.size()); + ConnectorScanRange range = ranges.get(0); + Assertions.assertEquals("s3://b/db/pt/p=1/a.parquet", range.getPath().get()); + TFileRangeDesc rd = populate(range); + TIcebergFileDesc fd = rd.getTableFormatParams().getIcebergParams(); + Assertions.assertEquals("oss://b/db/pt/p=1/a.parquet", fd.getOriginalFilePath()); + // (b) partition columns-from-path on the surviving range. + Assertions.assertEquals(Collections.singletonList("p"), rd.getColumnsFromPathKeys()); + Assertions.assertEquals(Collections.singletonList("1"), rd.getColumnsFromPath()); + Assertions.assertEquals(Collections.singletonList(false), rd.getColumnsFromPathIsNull()); + Assertions.assertEquals("[\"1\"]", fd.getPartitionDataJson()); + // (c) the position delete attached to THIS range, path scheme-normalized. + Assertions.assertEquals(1, fd.getDeleteFilesSize()); + Assertions.assertEquals("s3://b/db/pt/p=1/a-pos-del.parquet", fd.getDeleteFiles().get(0).getPath()); + Assertions.assertEquals(1, fd.getDeleteFiles().get(0).getContent()); + } + + // --- T09 helpers --- + + private static FakeIcebergTable fakeTable(String name) { + // write.format.default: getScanNodeProperties now resolves the scan-level format off the table (it + // drives BE's FileScannerV2-vs-V1 pick -- see getScanNodePropertiesEmitsPathPartitionKeysAndRealDataFormat). + // A declared format keeps these credential tests off IcebergUtils' last-resort planFiles() inference, + // which FakeIcebergTable deliberately refuses; a real table normally declares it too. + return new FakeIcebergTable(name, SCHEMA, PartitionSpec.unpartitioned(), + "s3://b/" + name, Collections.singletonMap(TableProperties.DEFAULT_FILE_FORMAT, "parquet")); + } + + /** Catalog props with BOTH the REST flavor and the vended flag on — the two-part legacy gate. */ + private static Map restVendedFlagOn() { + Map props = new HashMap<>(); + props.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + props.put(IcebergConnectorProperties.REST_VENDED_CREDENTIALS_ENABLED, "true"); + return props; + } + + /** A fe-filesystem {@link StorageProperties} whose toBackendProperties().toMap() returns the given + * BE-canonical map — mirrors how a real object-store binding hands BE creds to the connector (P1-T04). */ + private static StorageProperties fakeBackendStorage(Map beMap) { + BackendStorageProperties backend = new BackendStorageProperties() { + @Override + public BackendStorageKind backendKind() { + return BackendStorageKind.S3_COMPATIBLE; + } + + @Override + public Map toMap() { + return beMap; + } + }; + return new StorageProperties() { + @Override + public String providerName() { + return "fake"; + } + + @Override + public StorageKind kind() { + return StorageKind.OBJECT_STORAGE; + } + + @Override + public FileSystemType type() { + return FileSystemType.S3; + } + + @Override + public Map rawProperties() { + return Collections.emptyMap(); + } + + @Override + public Map matchedProperties() { + return Collections.emptyMap(); + } + + @Override + public Optional toBackendProperties() { + return Optional.of(backend); + } + }; + } + + /** A fe-filesystem {@link StorageProperties} with NO backend model — toBackendProperties() defaults to + * Optional.empty() (the HDFS case: no typed BE binding in fe-filesystem). */ + private static StorageProperties fakeStorageWithoutBackend() { + return new StorageProperties() { + @Override + public String providerName() { + return "no-be"; + } + + @Override + public StorageKind kind() { + return StorageKind.HDFS_COMPATIBLE; + } + + @Override + public FileSystemType type() { + return FileSystemType.HDFS; + } + + @Override + public Map rawProperties() { + return Collections.emptyMap(); + } + + @Override + public Map matchedProperties() { + return Collections.emptyMap(); + } + }; + } + + // --- T05: system-table (JNI) serialized-split scan path (planScan on an iceberg $sys handle) --- + + @Test + public void planScanForSystemTableSerializesEachFileScanTaskAsJniSplit() { + // A $snapshots handle plans through the metadata table (MetadataTableUtils.createMetadataTableInstance): + // each metadata FileScanTask is serialized (SerializationUtil.serializeToBase64) and emitted as a JNI + // split carrying ONLY serialized_split + FORMAT_JNI + table_level_row_count=-1, mirroring legacy + // IcebergScanNode.doGetSystemTableSplits + setIcebergParams. MUTATION: routing the sys handle through + // the normal data-file path (resolveTable + buildRange) -> the range carries the f1.parquet path and no + // serialized_split -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + 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()); + + Assertions.assertFalse(ranges.isEmpty(), "the $snapshots metadata table must plan at least one split"); + for (ConnectorScanRange range : ranges) { + String serialized = ((IcebergScanRange) range).getSerializedSplit(); + Assertions.assertNotNull(serialized, "every sys split must carry a serialized FileScanTask"); + Assertions.assertFalse(serialized.isEmpty()); + TFileRangeDesc rangeDesc = populate(range); + Assertions.assertEquals(TFileFormatType.FORMAT_JNI, rangeDesc.getFormatType()); + Assertions.assertEquals(serialized, + rangeDesc.getTableFormatParams().getIcebergParams().getSerializedSplit()); + Assertions.assertEquals(-1L, rangeDesc.getTableFormatParams().getTableLevelRowCount()); + } + } + + @Test + public void planScanForSystemTableSplitDeserializesThroughTheBeJniReaderPath() throws Exception { + // The strongest FE-reachable byte-shape parity check: the serialized_split must be consumable EXACTLY + // as BE's IcebergSysTableJniScanner consumes it — + // SerializationUtil.deserializeFromBase64(...).asDataTask().rows() — and must carry the METADATA-table + // schema ($snapshots), not the base table's. (Cross-version / classloader interop is P6.8 docker e2e.) + // MUTATION: serializing anything other than the FileScanTask (e.g. the DataFile) -> deserialize / + // asDataTask() fails or yields the wrong schema -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + 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()); + + long snapshotRows = 0; + for (ConnectorScanRange range : ranges) { + FileScanTask task = + SerializationUtil.deserializeFromBase64(((IcebergScanRange) range).getSerializedSplit()); + // the deserialized task exposes the $snapshots metadata schema, not the base table's columns. + Assertions.assertNotNull(task.schema().findField("snapshot_id"), + "the serialized split must carry the metadata-table ($snapshots) schema"); + Assertions.assertNull(task.schema().findField("name"), + "the serialized split must NOT carry the base table's columns"); + try (CloseableIterable rows = task.asDataTask().rows()) { + Iterator it = rows.iterator(); + while (it.hasNext()) { + it.next(); + snapshotRows++; + } + } + } + Assertions.assertEquals(1L, snapshotRows, "one commit -> the $snapshots table has one row"); + } + + @Test + public void planScanForSystemTableHonorsTheSnapshotPin() throws Exception { + // Iceberg system tables are legal time-travel targets (deviation (1)): the connector must apply the + // snapshot pin to the metadata-table scan (legacy createTableScan -> scan.useSnapshot). $files is the + // time-travel-observable table: it lists the data files LIVE in the pinned snapshot. S1 has one file; + // after S2 the latest $files lists two. Pinned to S1 the connector must read only S1's view (one file + // row), proving the pin flows into buildScan. MUTATION: bypassing buildScan / dropping the pin (reading + // latest) -> two rows -> red. (NB: $snapshots ignores useSnapshot — it always lists all snapshots from + // current metadata — so it is NOT observable here; legacy has the identical no-op, both apply the pin.) + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)).commit(); + long s1 = table.currentSnapshot().snapshotId(); + 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())); + + 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"); + } + + @Test + public void planScanForSystemTableLoadsMetadataInsideTheAuthScope() { + // The base-table load + metadata-table build run inside ONE context.executeAuthenticated, so the + // FE-injected Kerberos UGI covers the remote base load (mirrors IcebergConnectorMetadata.loadSysTable / + // legacy IcebergSysExternalTable.getSysIcebergTable). The base table is loaded by its BARE name, never a + // "$snapshots" suffix. MUTATION: resolving the metadata table OUTSIDE the auth wrap -> authCount 0 -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)).commit(); + RecordingIcebergCatalogOps ops = opsReturning(table); + 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()); + + Assertions.assertEquals(1, context.authCount); + Assertions.assertEquals("db1", ops.lastLoadDb); + Assertions.assertEquals("t1", ops.lastLoadTable); + } + + @Test + public void planScanForSystemTableCarriesPredicateAsResidualForBe() throws Exception { + // Predicate pushdown for a SYS table is FE-reachable as the RESIDUAL carried on the serialized + // FileScanTask: planSystemTableScan -> buildScan -> scan.filter(record_count==10) records the + // converted predicate as the metadata scan's residual, which BE's IcebergSysTableJniScanner applies + // when reading $files rows. NB: a metadata-COLUMN predicate is a residual, NOT a manifest prune, so + // the FE-visible row count is unchanged (verified: 2 vs 2) — the row-level prune happens at BE read + // time; the FE plan-time prune is the SNAPSHOT pin (see planScanForSystemTableHonorsTheSnapshotPin). + // MUTATION: dropping the `filter` arg on the sys path (planSystemTableScan ignores it) -> the + // residual stays alwaysTrue even with a predicate -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1000, null, null)) + .appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 100, null, null)) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + + // record_count is an iceberg LONG field of the $files metadata schema; the converter resolves it by + // name and pushes a BIGINT equality. + ConnectorExpression recordCountEq10 = new ConnectorComparison(ConnectorComparison.Operator.EQ, + 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))); + + // No predicate -> the metadata scan carries no residual (alwaysTrue); a pushable predicate -> the + // residual references record_count, proving the converted filter reached scan.filter. + Assertions.assertTrue(unfilteredResidual.equalsIgnoreCase("true"), + "with no predicate the sys metadata scan must carry no residual; got: " + unfilteredResidual); + Assertions.assertTrue(filteredResidual.contains("record_count"), + "a pushable $files predicate must be carried as the scan residual for BE; got: " + filteredResidual); + } + + private static String firstSysSplitResidual(List ranges) throws Exception { + Assertions.assertFalse(ranges.isEmpty(), "the metadata table must plan at least one split"); + FileScanTask task = + SerializationUtil.deserializeFromBase64(((IcebergScanRange) ranges.get(0)).getSerializedSplit()); + return task.residual().toString(); + } + + @Test + public void planScanForSystemTableSetsDummyPathOnEverySplit() { + // Every sys split's path is the sentinel "/dummyPath" (IcebergScanPlanProvider.SYS_TABLE_DUMMY_PATH): + // a metadata-table split carries its payload in serialized_split and BE never opens a real file path + // for it (mirrors legacy doGetSystemTableSplits, which sets a dummy path). The earlier T05 tests only + // assert path on data ranges they supply themselves; this pins the provider-built sys-range path. + // MUTATION: building the sys range with the real data-file path instead of SYS_TABLE_DUMMY_PATH -> + // path != "/dummyPath" -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + 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()); + + Assertions.assertFalse(ranges.isEmpty(), "the $snapshots metadata table must plan at least one split"); + for (ConnectorScanRange range : ranges) { + Assertions.assertEquals("/dummyPath", range.getPath().get(), + "every iceberg sys split must carry the sentinel dummy path"); + } + } + + private static long countSerializedSplitRows(List ranges) throws Exception { + long rows = 0; + for (ConnectorScanRange range : ranges) { + FileScanTask task = + SerializationUtil.deserializeFromBase64(((IcebergScanRange) range).getSerializedSplit()); + try (CloseableIterable closeable = task.asDataTask().rows()) { + Iterator it = closeable.iterator(); + while (it.hasNext()) { + it.next(); + rows++; + } + } + } + return rows; + } + + /** A fake FileIO carrying only its own properties (no server-vended StorageCredentials). */ + private static final class PropsOnlyFileIO implements FileIO { + private final Map props; + + PropsOnlyFileIO(Map props) { + this.props = props; + } + + @Override + public Map properties() { + return props; + } + + @Override + public InputFile newInputFile(String path) { + throw new UnsupportedOperationException(); + } + + @Override + public OutputFile newOutputFile(String path) { + throw new UnsupportedOperationException(); + } + + @Override + public void deleteFile(String path) { + throw new UnsupportedOperationException(); + } + } + + /** A fake FileIO that ALSO vends StorageCredentials (a REST catalog's delegated creds). */ + private static final class VendedFileIO implements FileIO, SupportsStorageCredentials { + private final Map props; + private final List creds; + + VendedFileIO(Map props, List creds) { + this.props = props; + this.creds = creds; + } + + @Override + public Map properties() { + return props; + } + + @Override + public List credentials() { + return creds; + } + + @Override + public void setCredentials(List credentials) { + throw new UnsupportedOperationException(); + } + + @Override + public InputFile newInputFile(String path) { + throw new UnsupportedOperationException(); + } + + @Override + public OutputFile newOutputFile(String path) { + throw new UnsupportedOperationException(); + } + + @Override + public void deleteFile(String path) { + throw new UnsupportedOperationException(); + } + } +} 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 new file mode 100644 index 00000000000000..250e42f396cead --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanProfileReporterTest.java @@ -0,0 +1,106 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.scan.ConnectorScanProfile; + +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.ImmutableScanMetricsResult; +import org.apache.iceberg.metrics.ImmutableScanReport; +import org.apache.iceberg.metrics.MetricsContext; +import org.apache.iceberg.metrics.ScanMetricsResult; +import org.apache.iceberg.metrics.ScanReport; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * FIX-SCAN-METRICS — guards {@link IcebergScanProfileReporter}, the connector-local iceberg SDK + * {@code MetricsReporter} that captures a scan's {@code ScanReport} into a connector-neutral profile stashed + * by queryId for fe-core to drain (the migration dropped this diagnostic from the profile). + */ +public class IcebergScanProfileReporterTest { + + private static ScanReport report(String table) { + ScanMetricsResult metrics = ImmutableScanMetricsResult.builder() + .resultDataFiles(CounterResult.of(MetricsContext.Unit.COUNT, 3)) + .scannedDataManifests(CounterResult.of(MetricsContext.Unit.COUNT, 5)) + .skippedDataManifests(CounterResult.of(MetricsContext.Unit.COUNT, 2)) + .totalFileSizeInBytes(CounterResult.of(MetricsContext.Unit.BYTES, 2048)) + .build(); + return ImmutableScanReport.builder() + .tableName(table) + .snapshotId(123L) + .schemaId(0) + .filter(Expressions.alwaysTrue()) + .projectedFieldIds(Collections.emptyList()) + .projectedFieldNames(Collections.emptyList()) + .scanMetrics(metrics) + .metadata(Collections.emptyMap()) + .build(); + } + + @Test + public void reportStashesProfileKeyedByQueryId() { + // THE load-bearing RED assertion: a real iceberg ScanReport is captured into the stash as one + // ConnectorScanProfile with the transcribed counter keys. A mutation that drops report() leaves the + // stash empty. + ConcurrentHashMap> stash = new ConcurrentHashMap<>(); + new IcebergScanProfileReporter("qid-1", stash).report(report("db.tbl")); + + List profiles = stash.get("qid-1"); + Assertions.assertNotNull(profiles, "report must stash under the queryId"); + Assertions.assertEquals(1, profiles.size()); + ConnectorScanProfile profile = profiles.get(0); + Assertions.assertEquals("Iceberg Scan Metrics", profile.getGroupName()); + Assertions.assertEquals("Table Scan (db.tbl)", profile.getScanLabel()); + Map m = profile.getMetrics(); + Assertions.assertEquals("3", m.get("data_files")); + Assertions.assertEquals("5", m.get("scanned_manifests")); + Assertions.assertEquals("2", m.get("skipped_manifests")); + // BYTES-unit counter is byte-formatted (self-ported DebugUtil.printByteWithUnit). + Assertions.assertEquals("2.000 KB", m.get("total_size")); + } + + @Test + public void blankQueryIdDoesNotStash() { + ConcurrentHashMap> stash = new ConcurrentHashMap<>(); + new IcebergScanProfileReporter("", stash).report(report("db.tbl")); + new IcebergScanProfileReporter(null, stash).report(report("db.tbl")); + Assertions.assertTrue(stash.isEmpty(), "a blank queryId must not accumulate an unreclaimable entry"); + } + + @Test + public void formattersMatchLegacy() { + Assertions.assertEquals("0", IcebergScanProfileReporter.prettyMs(0)); + Assertions.assertEquals("1sec234ms", IcebergScanProfileReporter.prettyMs(1234)); + Assertions.assertEquals("0.000 ", IcebergScanProfileReporter.printByteWithUnit(0)); + Assertions.assertEquals("2.000 KB", IcebergScanProfileReporter.printByteWithUnit(2048)); + Assertions.assertEquals("1.500 MB", IcebergScanProfileReporter.printByteWithUnit(1024L * 1536)); + } + + @Test + public void groupNameMatchesFeCoreConstant() { + 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 new file mode 100644 index 00000000000000..2153fa327a115b --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanRangeTest.java @@ -0,0 +1,508 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.scan.ConnectorScanRangeType; +import org.apache.doris.thrift.TFileFormatType; +import org.apache.doris.thrift.TFileRangeDesc; +import org.apache.doris.thrift.TIcebergDeleteFileDesc; +import org.apache.doris.thrift.TIcebergFileDesc; +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.List; +import java.util.Map; +import java.util.Optional; + +/** + * Skeleton tests for {@link IcebergScanRange} (P6.2-T01), mirroring the paimon connector's + * {@code PaimonScanRange} carrier. Only the minimal {@code FILE_SCAN} file fields (path/start/length/ + * size/format) exist this task; the per-range delete-file / JNI-split / schema-id / partition / COUNT + * carriers and {@code populateRangeParams} land in P6.2-T02..T09. These pin the carrier the builder + * produces today so a later task that breaks the FILE_SCAN contract fails loudly. + */ +public class IcebergScanRangeTest { + + @Test + public void builderProducesFileScanRangeWithFileFields() { + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://bucket/db/t/data/f.parquet") + .start(128L) + .length(4096L) + .fileSize(8192L) + .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()); + Assertions.assertEquals(8192L, range.getFileSize()); + Assertions.assertEquals("parquet", range.getFileFormat()); + // WHY: BE selects its iceberg reader off TTableFormatFileDesc.table_format_type, whose value for + // iceberg is "iceberg" (TableFormatType.ICEBERG). MUTATION: "paimon" / "plugin_driven" -> BE routes + // the split to the wrong reader -> red. + Assertions.assertEquals("iceberg", range.getTableFormatType()); + } + + @Test + public void builderDefaultsMatchFileScanContract() { + // A range built with only a path keeps the ConnectorScanRange contract defaults: start=0, + // length=-1 (whole file), fileSize=-1 (unknown), fileFormat="" (not-yet-known, NOT "jni"). + // Pins that the skeleton does not invent values. + IcebergScanRange range = new IcebergScanRange.Builder().path("/tmp/x").build(); + Assertions.assertEquals(0L, range.getStart()); + Assertions.assertEquals(-1L, range.getLength()); + Assertions.assertEquals(-1L, range.getFileSize()); + Assertions.assertEquals("", range.getFileFormat()); + // No connector-specific per-range properties exist yet (T03 introduces them); must be non-null. + Assertions.assertNotNull(range.getProperties()); + Assertions.assertTrue(range.getProperties().isEmpty()); + } + + // ---- M-2: size-proportional BE scheduling weight (getSelfSplitWeight / getTargetSplitSize) ---- + + @Test + public void weightGettersReflectBuilder() { + // A normal data-file range carries the connector's size-based weight numerator + denominator so the + // generic PluginDrivenSplit forms a proportional FileSplit weight (legacy IcebergSplit.selfSplitWeight / + // IcebergScanNode.targetSplitSize). MUTATION: not overriding the getters (inherit -1) -> red. + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet") + .length(4096L) + .selfSplitWeight(640L) + .targetSplitSize(33554432L) + .build(); + Assertions.assertEquals(640L, range.getSelfSplitWeight()); + Assertions.assertEquals(33554432L, range.getTargetSplitSize()); + } + + @Test + public void weightGettersDefaultToUnsetSentinel() { + // A range that does not set the weight (system-table / count-pushdown ranges) keeps the SPI -1 "not + // provided" sentinel so PluginDrivenSplit falls back to SplitWeight.standard() (uniform) — the + // no-regression guarantee. MUTATION: defaulting the builder fields to 0 -> 0 is a real weight and the + // generic split would treat a sys split as weighted (0/-1 still standard, but the contract is -1) -> red. + IcebergScanRange range = new IcebergScanRange.Builder().path("/tmp/x").build(); + Assertions.assertEquals(-1L, range.getSelfSplitWeight()); + Assertions.assertEquals(-1L, range.getTargetSplitSize()); + } + + // ---- T03: populateRangeParams -> TIcebergFileDesc (mirrors legacy IcebergScanNode.setIcebergParams) ---- + + private static TFileRangeDesc populate(IcebergScanRange range, TFileRangeDesc rangeDesc) { + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + formatDesc.setTableFormatType("iceberg"); // the generic node sets this from getTableFormatType() + range.populateRangeParams(formatDesc, rangeDesc); + rangeDesc.setTableFormatParams(formatDesc); + return rangeDesc; + } + + @Test + public void populateRangeParamsV2PartitionedDataFile() { + Map parts = new LinkedHashMap<>(); + parts.put("p", "1"); + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/p=1/f.parquet").start(0L).length(512L).fileSize(512L) + .fileFormat("parquet").formatVersion(2) + .partitionSpecId(0).partitionDataJson("[\"1\"]") + .partitionValues(parts).build(); + + TFileRangeDesc rangeDesc = populate(range, new TFileRangeDesc()); + TIcebergFileDesc fd = rangeDesc.getTableFormatParams().getIcebergParams(); + + // Core v2 carriers. MUTATION: dropping any field (T01 default populateRangeParams dumps to jdbc_params + // and never sets iceberg_params) -> red. + Assertions.assertTrue(rangeDesc.getTableFormatParams().isSetIcebergParams()); + Assertions.assertEquals(2, fd.getFormatVersion()); + Assertions.assertEquals("s3://b/db/t/p=1/f.parquet", fd.getOriginalFilePath()); + Assertions.assertTrue(fd.isSetPartitionSpecId()); + Assertions.assertEquals(0, fd.getPartitionSpecId()); + Assertions.assertEquals("[\"1\"]", fd.getPartitionDataJson()); + // v2: no v1 content, no v3 row-lineage. + Assertions.assertFalse(fd.isSetContent()); + Assertions.assertFalse(fd.isSetFirstRowId()); + Assertions.assertFalse(fd.isSetLastUpdatedSequenceNumber()); + // native parquet -> per-range FORMAT_PARQUET; non-count path -> table_level_row_count = -1. + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, rangeDesc.getFormatType()); + Assertions.assertEquals(-1L, rangeDesc.getTableFormatParams().getTableLevelRowCount()); + // identity partition columns -> columns-from-path (value present, not null). + Assertions.assertEquals(Collections.singletonList("p"), rangeDesc.getColumnsFromPathKeys()); + Assertions.assertEquals(Collections.singletonList("1"), rangeDesc.getColumnsFromPath()); + Assertions.assertEquals(Collections.singletonList(false), rangeDesc.getColumnsFromPathIsNull()); + } + + @Test + public void populateRangeParamsV1SetsDataContentAndOrcFormat() { + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.orc").fileFormat("orc").formatVersion(1).build(); + + TIcebergFileDesc fd = populate(range, new TFileRangeDesc()).getTableFormatParams().getIcebergParams(); + + // v1 only: content = FileContent.DATA.id() (== 0). MUTATION: setting content unconditionally (v2+) -> red. + Assertions.assertTrue(fd.isSetContent()); + Assertions.assertEquals(0, fd.getContent()); + Assertions.assertEquals(1, fd.getFormatVersion()); + // v1 has no delete files: delete_files stays unset (legacy only sets it for v2+). MUTATION: emitting + // an empty delete_files list for v1 -> red. + Assertions.assertFalse(fd.isSetDeleteFiles()); + // orc data file -> per-range FORMAT_ORC. + Assertions.assertEquals(TFileFormatType.FORMAT_ORC, + populate(range, new TFileRangeDesc()).getFormatType()); + } + + // ---- T04: merge-on-read delete files -> TIcebergFileDesc.delete_files ---- + + @Test + public void populateRangeParamsV2NoDeletesEmitsEmptyDeleteFilesList() { + // A v2 table with no deletes: legacy setIcebergParams calls setDeleteFiles(new ArrayList<>()) before + // the (empty) loop, so the list is SET but empty, and content is NOT the v1 DATA marker. + // MUTATION: leaving delete_files unset for v2 (the T03 behavior) -> red; setting content=DATA -> red. + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet").fileFormat("parquet").formatVersion(2).build(); + + TIcebergFileDesc fd = populate(range, new TFileRangeDesc()).getTableFormatParams().getIcebergParams(); + + Assertions.assertTrue(fd.isSetDeleteFiles()); + Assertions.assertTrue(fd.getDeleteFiles().isEmpty()); + Assertions.assertFalse(fd.isSetContent()); + } + + @Test + public void populateRangeParamsV2EmitsPositionDeleteFile() { + // A position delete (content 1) with parquet format + [lower,upper] bounds. MUTATION: dropping the + // bounds, wrong content id, or wrong format -> red. + IcebergScanRange.DeleteFile posDelete = IcebergScanRange.DeleteFile.positionDelete( + "s3://b/db/t/pos-delete.parquet", TFileFormatType.FORMAT_PARQUET, 10L, 99L); + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet").fileFormat("parquet").formatVersion(2) + .deleteFiles(Collections.singletonList(posDelete)).build(); + + TIcebergFileDesc fd = populate(range, new TFileRangeDesc()).getTableFormatParams().getIcebergParams(); + + Assertions.assertEquals(1, fd.getDeleteFilesSize()); + TIcebergDeleteFileDesc d = fd.getDeleteFiles().get(0); + Assertions.assertEquals("s3://b/db/t/pos-delete.parquet", d.getPath()); + Assertions.assertEquals(1, d.getContent()); + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, d.getFileFormat()); + Assertions.assertTrue(d.isSetPositionLowerBound()); + Assertions.assertEquals(10L, d.getPositionLowerBound()); + Assertions.assertTrue(d.isSetPositionUpperBound()); + Assertions.assertEquals(99L, d.getPositionUpperBound()); + // A position delete carries neither equality field-ids nor a deletion-vector blob ref. + Assertions.assertFalse(d.isSetFieldIds()); + Assertions.assertFalse(d.isSetContentOffset()); + Assertions.assertFalse(d.isSetContentSizeInBytes()); + } + + @Test + public void populateRangeParamsV2EmitsPositionDeleteWithoutBounds() { + // No bounds present -> position_lower/upper_bound left UNSET (legacy emits them only when present). + // MUTATION: defaulting an absent bound to 0 / -1 instead of unset -> red. + IcebergScanRange.DeleteFile posDelete = IcebergScanRange.DeleteFile.positionDelete( + "s3://b/db/t/pos-delete.orc", TFileFormatType.FORMAT_ORC, null, null); + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet").fileFormat("parquet").formatVersion(2) + .deleteFiles(Collections.singletonList(posDelete)).build(); + + TIcebergDeleteFileDesc d = populate(range, new TFileRangeDesc()) + .getTableFormatParams().getIcebergParams().getDeleteFiles().get(0); + + Assertions.assertEquals(TFileFormatType.FORMAT_ORC, d.getFileFormat()); + Assertions.assertFalse(d.isSetPositionLowerBound()); + Assertions.assertFalse(d.isSetPositionUpperBound()); + } + + @Test + public void populateRangeParamsV2EmitsDeletionVectorAndEqualityDelete() { + // A deletion vector (content 3, PUFFIN): blob content_offset/size set, file_format UNSET, bounds + // carried (it IS a position delete). An equality delete (content 2): field-ids set, no bounds/blob. + IcebergScanRange.DeleteFile dv = IcebergScanRange.DeleteFile.deletionVector( + "s3://b/db/t/dv.puffin", 5L, 42L, 16L, 64L); + IcebergScanRange.DeleteFile eq = IcebergScanRange.DeleteFile.equalityDelete( + "s3://b/db/t/eq-delete.parquet", TFileFormatType.FORMAT_PARQUET, Arrays.asList(3, 7)); + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet").fileFormat("parquet").formatVersion(2) + .deleteFiles(Arrays.asList(dv, eq)).build(); + + List deletes = populate(range, new TFileRangeDesc()) + .getTableFormatParams().getIcebergParams().getDeleteFiles(); + Assertions.assertEquals(2, deletes.size()); + + TIcebergDeleteFileDesc dvDesc = deletes.get(0); + Assertions.assertEquals(3, dvDesc.getContent()); + // MUTATION: emitting file_format for a PUFFIN DV (legacy setDeleteFileFormat skips PUFFIN) -> red. + Assertions.assertFalse(dvDesc.isSetFileFormat()); + Assertions.assertEquals(16L, dvDesc.getContentOffset()); + Assertions.assertEquals(64L, dvDesc.getContentSizeInBytes()); + Assertions.assertEquals(5L, dvDesc.getPositionLowerBound()); + Assertions.assertEquals(42L, dvDesc.getPositionUpperBound()); + + TIcebergDeleteFileDesc eqDesc = deletes.get(1); + Assertions.assertEquals(2, eqDesc.getContent()); + Assertions.assertEquals(Arrays.asList(3, 7), eqDesc.getFieldIds()); + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, eqDesc.getFileFormat()); + Assertions.assertFalse(eqDesc.isSetContentOffset()); + Assertions.assertFalse(eqDesc.isSetPositionLowerBound()); + } + + @Test + public void populateRangeParamsV3SetsRowLineage() { + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet").fileFormat("parquet").formatVersion(3) + .firstRowId(100L).lastUpdatedSequenceNumber(5L).build(); + + TIcebergFileDesc fd = populate(range, new TFileRangeDesc()).getTableFormatParams().getIcebergParams(); + + // v3 row-lineage carriers. MUTATION: gating these on v2 / never setting them -> red. + Assertions.assertTrue(fd.isSetFirstRowId()); + Assertions.assertEquals(100L, fd.getFirstRowId()); + Assertions.assertTrue(fd.isSetLastUpdatedSequenceNumber()); + Assertions.assertEquals(5L, fd.getLastUpdatedSequenceNumber()); + // v3 is NOT v1 -> no DATA content marker. + Assertions.assertFalse(fd.isSetContent()); + } + + @Test + public void populateRangeParamsV3NullRowLineageFallsBackToMinusOne() { + // The v2->v3 upgrade case: a data file added before the upgrade has no first_row_id; legacy emits -1. + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet").fileFormat("parquet").formatVersion(3) + .firstRowId(null).lastUpdatedSequenceNumber(null).build(); + + TIcebergFileDesc fd = populate(range, new TFileRangeDesc()).getTableFormatParams().getIcebergParams(); + + Assertions.assertEquals(-1L, fd.getFirstRowId()); + Assertions.assertEquals(-1L, fd.getLastUpdatedSequenceNumber()); + } + + @Test + public void populateRangeParamsUnpartitionedOmitsPartitionFields() { + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet").fileFormat("parquet").formatVersion(2).build(); + + TFileRangeDesc rangeDesc = populate(range, new TFileRangeDesc()); + TIcebergFileDesc fd = rangeDesc.getTableFormatParams().getIcebergParams(); + + // Unpartitioned: spec-id / data-json absent; no columns-from-path. MUTATION: emitting empty lists -> red. + Assertions.assertFalse(fd.isSetPartitionSpecId()); + Assertions.assertFalse(fd.isSetPartitionDataJson()); + Assertions.assertFalse(rangeDesc.isSetColumnsFromPath()); + Assertions.assertFalse(rangeDesc.isSetColumnsFromPathKeys()); + Assertions.assertFalse(rangeDesc.isSetColumnsFromPathIsNull()); + } + + @Test + public void populateRangeParamsUnsetsParentPathParsedColumnsFromPath() { + // The generic FileQueryScanNode pre-fills columns-from-path by PARSING the path (iceberg does NOT + // Hive-path-encode partitions -> garbage). populateRangeParams must UNSET those before (not) re-setting, + // exactly like legacy setIcebergParams. MUTATION: skipping the unset -> the stale parsed values survive. + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + rangeDesc.setColumnsFromPath(Arrays.asList("stale")); + rangeDesc.setColumnsFromPathKeys(Arrays.asList("stalekey")); + rangeDesc.setColumnsFromPathIsNull(Arrays.asList(false)); + + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet").fileFormat("parquet").formatVersion(2).build(); + populate(range, rangeDesc); + + Assertions.assertFalse(rangeDesc.isSetColumnsFromPath()); + Assertions.assertFalse(rangeDesc.isSetColumnsFromPathKeys()); + Assertions.assertFalse(rangeDesc.isSetColumnsFromPathIsNull()); + } + + @Test + public void populateRangeParamsNullPartitionValueUsesIsNullList() { + // A genuine-null identity partition value: value rendered as "" with the parallel is_null = true (NO + // __HIVE_DEFAULT_PARTITION__ sentinel — iceberg conveys null purely via the is_null list). + Map parts = new LinkedHashMap<>(); + parts.put("p", null); + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet").fileFormat("parquet").formatVersion(2) + .partitionSpecId(0).partitionValues(parts).build(); + + TFileRangeDesc rangeDesc = populate(range, new TFileRangeDesc()); + + Assertions.assertEquals(Collections.singletonList("p"), rangeDesc.getColumnsFromPathKeys()); + Assertions.assertEquals(Collections.singletonList(""), rangeDesc.getColumnsFromPath()); + Assertions.assertEquals(Collections.singletonList(true), rangeDesc.getColumnsFromPathIsNull()); + } + + @Test + public void isPartitionBearingIsAlwaysTrueSoIcebergNeverPathParses() { + // Iceberg partition values always come from metadata, never a Hive key=value path, so EVERY iceberg + // range must report partition-bearing == true: the engine then routes an empty partition map through + // normalizeColumnsFromPath (non-null empty list) instead of path-parsing it (which throws for + // iceberg's non-key=value layout). This MUST hold even for a file with no partition spec id -- a + // partition-spec-evolution table now on an unpartitioned spec still exposes path_partition_keys from + // its spec history (e.g. [sku]), and its physically-unpartitioned files have no sku= path segment, so + // reporting false there reintroduces the "Fail to parse columnsFromPath, expected: [sku]" throw. + // MUTATION: returning partitionSpecId != null -> unpartitioned/spec-evolved file path-parse throw -> red. + IcebergScanRange partitioned = new IcebergScanRange.Builder() + .path("x").partitionSpecId(0).partitionValues(Collections.emptyMap()).build(); + Assertions.assertTrue(partitioned.isPartitionBearing()); + IcebergScanRange unpartitioned = new IcebergScanRange.Builder().path("x").build(); + Assertions.assertTrue(unpartitioned.isPartitionBearing()); + } + + @Test + public void getPartitionValuesExposesTheIdentityMap() { + Map parts = new LinkedHashMap<>(); + parts.put("p", "1"); + IcebergScanRange range = new IcebergScanRange.Builder() + .path("x").partitionValues(parts).build(); + // The parent PluginDrivenSplit reads getPartitionValues() to route columns-from-path through + // normalizeColumnsFromPath (not path-parsing). MUTATION: returning emptyMap -> red. + Assertions.assertEquals(parts, range.getPartitionValues()); + } + + // ---- T05: COUNT(*) pushdown row count carrier (pushDownRowCount -> table_level_row_count) ---- + + @Test + public void pushDownRowCountDefaultsToMinusOne() { + // The normal scan path never sets a count: the SPI carrier defaults to -1 so the generic node renders + // the (-1) "no precomputed count" sentinel and BE counts by reading. MUTATION: defaulting to 0 (a + // valid count) -> the EXPLAIN line and BE count path would treat every range as pre-counted -> red. + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet").fileFormat("parquet").formatVersion(2).build(); + Assertions.assertEquals(-1L, range.getPushDownRowCount()); + Assertions.assertEquals(-1L, + populate(range, new TFileRangeDesc()).getTableFormatParams().getTableLevelRowCount()); + } + + @Test + public void populateRangeParamsEmitsTableLevelRowCountWhenCountPushed() { + // The single collapsed count range carries the snapshot-summary total -> BE serves COUNT from + // table_level_row_count without reading. MUTATION: still emitting the constant -1 (T03 behavior) -> + // BE re-reads and the EXPLAIN (n) is lost -> red. + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet").fileFormat("parquet").formatVersion(2) + .pushDownRowCount(4242L).build(); + Assertions.assertEquals(4242L, range.getPushDownRowCount()); + Assertions.assertEquals(4242L, + populate(range, new TFileRangeDesc()).getTableFormatParams().getTableLevelRowCount()); + } + + // ---- T05: system-table serialized-split carrier (serialized_split + FORMAT_JNI, minimal shape) ---- + + @Test + public void serializedSplitDefaultsToNullAndIsNotEmittedOnNormalRanges() { + // A normal data-file range carries no serialized_split: the carrier defaults to null and + // populateRangeParams must NOT set the thrift field, so every T02/T03/T04 range is byte-unchanged. + // MUTATION: emitting serialized_split unconditionally -> a stray sys field on every native range -> red. + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet").fileFormat("parquet").formatVersion(2).build(); + Assertions.assertNull(range.getSerializedSplit()); + TIcebergFileDesc fd = populate(range, new TFileRangeDesc()).getTableFormatParams().getIcebergParams(); + Assertions.assertFalse(fd.isSetSerializedSplit()); + } + + @Test + public void populateRangeParamsSystemTableEmitsSerializedSplitAndJniFormatOnly() { + // A system-table (JNI) range mirrors legacy IcebergScanNode.setIcebergParams isSystemTable branch: + // emit ONLY serialized_split + FORMAT_JNI + table_level_row_count=-1, and NONE of the file-level + // carriers (format_version, original_file_path, content, delete_files, partition). The BE + // IcebergSysTableJniScanner reads serialized_split and ignores every other field, so emitting them + // would be a parity divergence. MUTATION: falling through to the normal data-file shape (setting + // format_version / original_file_path) -> red; not setting FORMAT_JNI -> red; not setting + // serialized_split -> red. + IcebergScanRange range = new IcebergScanRange.Builder() + .path("/dummyPath").serializedSplit("BASE64-FILESCANTASK").build(); + Assertions.assertEquals("BASE64-FILESCANTASK", range.getSerializedSplit()); + + TFileRangeDesc rangeDesc = populate(range, new TFileRangeDesc()); + TIcebergFileDesc fd = rangeDesc.getTableFormatParams().getIcebergParams(); + + Assertions.assertTrue(rangeDesc.getTableFormatParams().isSetIcebergParams()); + Assertions.assertEquals("BASE64-FILESCANTASK", fd.getSerializedSplit()); + // FORMAT_JNI per-range (legacy setIcebergParams:290), and the -1 table-level row count (legacy :291). + Assertions.assertEquals(TFileFormatType.FORMAT_JNI, rangeDesc.getFormatType()); + Assertions.assertEquals(-1L, rangeDesc.getTableFormatParams().getTableLevelRowCount()); + // Minimal shape: NONE of the normal data-file carriers are set (legacy returns early before them). + Assertions.assertFalse(fd.isSetFormatVersion()); + Assertions.assertFalse(fd.isSetOriginalFilePath()); + Assertions.assertFalse(fd.isSetContent()); + Assertions.assertFalse(fd.isSetDeleteFiles()); + Assertions.assertFalse(fd.isSetPartitionSpecId()); + // No columns-from-path on a sys split (the metadata table is not path-partitioned). + Assertions.assertFalse(rangeDesc.isSetColumnsFromPath()); + } + + // ── commit-bridge supply (S4 part 2): getOriginalPath() key + rewritableDeleteDescs() non-equality filter ── + + @Test + public void getOriginalPathReturnsRawDataFilePathTheBeMatchesOn() { + // The stash keys on this exact string (the BE matches a rewritable delete set against it). It is the RAW + // path, distinct from the scheme-normalized open path. MUTATION: returning the normalized path here would + // make every BE lookup miss -> resurrection. + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet") + .originalPath("oss://b/db/t/f.parquet") + .build(); + Assertions.assertEquals("oss://b/db/t/f.parquet", range.getOriginalPath()); + } + + @Test + public void rewritableDeleteDescsKeepsDvAndPositionButDropsEquality() { + // The rewritable supply is OR-merged into the new DV; only position deletes (content 1) and deletion + // vectors (content 3) participate. Equality deletes (content 2) are re-applied by the reader, never + // rewritten, so they MUST be excluded (mirrors legacy deleteFilesDescByReferencedDataFile). MUTATION: + // including the equality delete -> the BE would treat equality rows as positions / over-delete. + IcebergScanRange.DeleteFile dv = IcebergScanRange.DeleteFile.deletionVector( + "s3://b/db/t/dv.puffin", 5L, 42L, 16L, 64L); + IcebergScanRange.DeleteFile pos = IcebergScanRange.DeleteFile.positionDelete( + "s3://b/db/t/pos.parquet", TFileFormatType.FORMAT_PARQUET, 1L, 9L); + IcebergScanRange.DeleteFile eq = IcebergScanRange.DeleteFile.equalityDelete( + "s3://b/db/t/eq.parquet", TFileFormatType.FORMAT_PARQUET, Arrays.asList(3, 7)); + IcebergScanRange range = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet").fileFormat("parquet").formatVersion(3) + .deleteFiles(Arrays.asList(dv, pos, eq)).build(); + + List descs = range.rewritableDeleteDescs(); + Assertions.assertEquals(2, descs.size()); + Assertions.assertEquals(3, descs.get(0).getContent()); + Assertions.assertEquals("s3://b/db/t/dv.puffin", descs.get(0).getPath()); + // DV carries the blob coordinates the BE needs to read it. + Assertions.assertEquals(16L, descs.get(0).getContentOffset()); + Assertions.assertEquals(64L, descs.get(0).getContentSizeInBytes()); + Assertions.assertEquals(1, descs.get(1).getContent()); + // Position delete carries its file_format. + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, descs.get(1).getFileFormat()); + } + + @Test + public void rewritableDeleteDescsEmptyWhenNoDeletesOrAllEquality() { + IcebergScanRange none = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet").fileFormat("parquet").formatVersion(3).build(); + Assertions.assertTrue(none.rewritableDeleteDescs().isEmpty()); + + IcebergScanRange.DeleteFile eq = IcebergScanRange.DeleteFile.equalityDelete( + "s3://b/db/t/eq.parquet", TFileFormatType.FORMAT_PARQUET, Arrays.asList(3)); + IcebergScanRange onlyEq = new IcebergScanRange.Builder() + .path("s3://b/db/t/f.parquet").fileFormat("parquet").formatVersion(3) + .deleteFiles(Collections.singletonList(eq)).build(); + Assertions.assertTrue(onlyEq.rewritableDeleteDescs().isEmpty()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaBuilderColumnTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaBuilderColumnTest.java new file mode 100644 index 00000000000000..050b7e0c60ee07 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaBuilderColumnTest.java @@ -0,0 +1,102 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; + +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +/** + * Unit tests for the B2 single-column builders on {@link IcebergSchemaBuilder}: {@link + * IcebergSchemaBuilder#buildColumnType} (one column's iceberg type) and {@link + * IcebergSchemaBuilder#parseDefaultLiteral} (a column DEFAULT string -> iceberg literal). + */ +public class IcebergSchemaBuilderColumnTest { + + // ---------- buildColumnType ---------- + + @Test + public void testBuildScalarColumnTypes() { + Assertions.assertEquals(Type.TypeID.INTEGER, + IcebergSchemaBuilder.buildColumnType(ConnectorType.of("INT")).typeId()); + Assertions.assertEquals(Type.TypeID.LONG, + IcebergSchemaBuilder.buildColumnType(ConnectorType.of("BIGINT")).typeId()); + Assertions.assertEquals(Type.TypeID.STRING, + IcebergSchemaBuilder.buildColumnType(ConnectorType.of("VARCHAR", 50, 0)).typeId()); + Type dec = IcebergSchemaBuilder.buildColumnType(ConnectorType.of("DECIMALV3", 10, 2)); + Assertions.assertEquals(Type.TypeID.DECIMAL, dec.typeId()); + Assertions.assertEquals(10, ((Types.DecimalType) dec).precision()); + Assertions.assertEquals(2, ((Types.DecimalType) dec).scale()); + } + + @Test + public void testBuildComplexColumnTypes() { + Type arr = IcebergSchemaBuilder.buildColumnType(ConnectorType.arrayOf(ConnectorType.of("INT"))); + Assertions.assertEquals(Type.TypeID.LIST, arr.typeId()); + Assertions.assertEquals(Type.TypeID.INTEGER, ((Types.ListType) arr).elementType().typeId()); + + Type map = IcebergSchemaBuilder.buildColumnType( + ConnectorType.mapOf(ConnectorType.of("STRING"), ConnectorType.of("INT"))); + Assertions.assertEquals(Type.TypeID.MAP, map.typeId()); + + Type struct = IcebergSchemaBuilder.buildColumnType(ConnectorType.structOf( + java.util.Arrays.asList("a", "b"), + java.util.Arrays.asList(ConnectorType.of("INT"), ConnectorType.of("STRING")))); + Assertions.assertEquals(Type.TypeID.STRUCT, struct.typeId()); + Assertions.assertEquals(2, ((Types.StructType) struct).fields().size()); + } + + @Test + public void testBuildUnsupportedColumnTypeFailsLoud() { + Assertions.assertThrows(DorisConnectorException.class, + () -> IcebergSchemaBuilder.buildColumnType(ConnectorType.of("TINYINT"))); + } + + // ---------- parseDefaultLiteral ---------- + + @Test + public void testParseDefaultLiteralNullReturnsNull() { + Assertions.assertNull(IcebergSchemaBuilder.parseDefaultLiteral(null, Types.IntegerType.get())); + } + + @Test + public void testParseDefaultLiteralByType() { + Assertions.assertEquals(42, + IcebergSchemaBuilder.parseDefaultLiteral("42", Types.IntegerType.get()).value()); + Assertions.assertEquals(100L, + IcebergSchemaBuilder.parseDefaultLiteral("100", Types.LongType.get()).value()); + Assertions.assertEquals("hello", + IcebergSchemaBuilder.parseDefaultLiteral("hello", Types.StringType.get()).value()); + Assertions.assertEquals(true, + IcebergSchemaBuilder.parseDefaultLiteral("true", Types.BooleanType.get()).value()); + Assertions.assertEquals(new BigDecimal("1.50"), + IcebergSchemaBuilder.parseDefaultLiteral("1.50", Types.DecimalType.of(10, 2)).value()); + } + + @Test + public void testParseDefaultLiteralBadValueFailsLoud() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> IcebergSchemaBuilder.parseDefaultLiteral("not-a-number", Types.IntegerType.get())); + } +} 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 new file mode 100644 index 00000000000000..072259bfcd4fdb --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaBuilderTest.java @@ -0,0 +1,387 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.ddl.ConnectorPartitionField; +import org.apache.doris.connector.api.ddl.ConnectorPartitionSpec; +import org.apache.doris.connector.api.ddl.ConnectorSortField; + +import org.apache.iceberg.NullOrder; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SortDirection; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Unit tests for {@link IcebergSchemaBuilder} — the string-driven port of the legacy fe-core iceberg + * create-table conversion (type mapping + partition spec + sort order + default properties). Pure: no + * catalog, no Mockito. + */ +public class IcebergSchemaBuilderTest { + + private static ConnectorColumn col(String name, ConnectorType type, boolean nullable) { + // 6-arg form: name, type, comment, nullable, defaultValue, isKey. + return new ConnectorColumn(name, type, "", nullable, null, false); + } + + // ---------- buildSchema: scalar type mapping (parity with DorisTypeToIcebergType.atomic) ---------- + + @Test + public void testScalarTypeMapping() { + Schema schema = IcebergSchemaBuilder.buildSchema(Arrays.asList( + col("b", ConnectorType.of("BOOLEAN"), true), + col("i", ConnectorType.of("INT"), true), + col("l", ConnectorType.of("BIGINT"), true), + col("f", ConnectorType.of("FLOAT"), true), + col("d", ConnectorType.of("DOUBLE"), true), + col("s", ConnectorType.of("VARCHAR", 100, 0), true), + col("dt", ConnectorType.of("DATEV2"), true), + col("ts", ConnectorType.of("DATETIMEV2", 6, 0), true))); + + Assertions.assertEquals(Type.TypeID.BOOLEAN, schema.findField("b").type().typeId()); + Assertions.assertEquals(Type.TypeID.INTEGER, schema.findField("i").type().typeId()); + Assertions.assertEquals(Type.TypeID.LONG, schema.findField("l").type().typeId()); + Assertions.assertEquals(Type.TypeID.FLOAT, schema.findField("f").type().typeId()); + Assertions.assertEquals(Type.TypeID.DOUBLE, schema.findField("d").type().typeId()); + // char family collapses to STRING (declared length 100 dropped — legacy parity). + Assertions.assertEquals(Type.TypeID.STRING, schema.findField("s").type().typeId()); + Assertions.assertEquals(Type.TypeID.DATE, schema.findField("dt").type().typeId()); + // datetime maps to timestamp WITHOUT zone. + Type tsType = schema.findField("ts").type(); + Assertions.assertEquals(Type.TypeID.TIMESTAMP, tsType.typeId()); + Assertions.assertFalse(((Types.TimestampType) tsType).shouldAdjustToUTC()); + } + + @Test + public void testDecimalCarriesPrecisionScale() { + Schema schema = IcebergSchemaBuilder.buildSchema(Collections.singletonList( + col("price", ConnectorType.of("DECIMAL128", 20, 4), true))); + Types.DecimalType decimal = (Types.DecimalType) schema.findField("price").type(); + Assertions.assertEquals(20, decimal.precision()); + Assertions.assertEquals(4, decimal.scale()); + } + + @Test + public void testTimestampTzMapsToWithZone() { + Schema schema = IcebergSchemaBuilder.buildSchema(Collections.singletonList( + col("ts", ConnectorType.of("TIMESTAMPTZ", 6, 0), true))); + Type type = schema.findField("ts").type(); + Assertions.assertEquals(Type.TypeID.TIMESTAMP, type.typeId()); + Assertions.assertTrue(((Types.TimestampType) type).shouldAdjustToUTC()); + } + + @Test + public void testNullabilityAndFieldIdAndComment() { + ConnectorColumn nullable = new ConnectorColumn("a", ConnectorType.of("INT"), "the a col", true, null, false); + ConnectorColumn required = new ConnectorColumn("b", ConnectorType.of("INT"), "", false, null, false); + Schema schema = IcebergSchemaBuilder.buildSchema(Arrays.asList(nullable, required)); + // Top-level field id == declaration index (legacy DorisTypeToIcebergType root scheme). + Assertions.assertEquals(0, schema.columns().get(0).fieldId()); + Assertions.assertEquals(1, schema.columns().get(1).fieldId()); + Assertions.assertTrue(schema.findField("a").isOptional()); + Assertions.assertFalse(schema.findField("b").isOptional()); + Assertions.assertEquals("the a col", schema.findField("a").doc()); + } + + @Test + public void testUnsupportedScalarTypeFailsLoud() { + // TINYINT is not supported by legacy DorisTypeToIcebergType.atomic -> fail loud. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> IcebergSchemaBuilder.buildSchema(Collections.singletonList( + col("t", ConnectorType.of("TINYINT"), true)))); + Assertions.assertTrue(ex.getMessage().contains("TINYINT")); + } + + // ---------- buildSchema: complex types + nested id allocation ---------- + + @Test + public void testComplexTypesAndUniqueNestedIds() { + ConnectorType arr = ConnectorType.arrayOf(ConnectorType.of("INT")); + ConnectorType map = ConnectorType.mapOf(ConnectorType.of("VARCHAR", 50, 0), ConnectorType.of("BIGINT")); + ConnectorType struct = ConnectorType.structOf( + Arrays.asList("x", "y"), Arrays.asList(ConnectorType.of("INT"), ConnectorType.of("DOUBLE"))); + Schema schema = IcebergSchemaBuilder.buildSchema(Arrays.asList( + col("arr", arr, true), col("m", map, true), col("st", struct, true))); + + Assertions.assertEquals(Type.TypeID.LIST, schema.findField("arr").type().typeId()); + Assertions.assertEquals(Type.TypeID.INTEGER, + schema.findField("arr").type().asListType().elementType().typeId()); + Assertions.assertEquals(Type.TypeID.MAP, schema.findField("m").type().typeId()); + Types.StructType st = schema.findField("st").type().asStructType(); + Assertions.assertEquals(2, st.fields().size()); + Assertions.assertEquals("x", st.fields().get(0).name()); + + // All field ids (top-level + nested) must be unique — iceberg Schema construction would otherwise + // throw; assert explicitly so a broken id allocator fails this test, not just downstream. + long distinct = schema.columns().stream() + .flatMap(f -> idsOf(f.type()).stream()) + .distinct().count(); + long total = schema.columns().stream().flatMap(f -> idsOf(f.type()).stream()).count(); + Assertions.assertEquals(total, distinct); + } + + @Test + public void testNestedNullabilityAndCommentPreserved() { + // STRUCT, ARRAY, MAP + ConnectorType struct = ConnectorType.structOf( + Arrays.asList("x", "y"), + Arrays.asList(ConnectorType.of("INT"), ConnectorType.of("DOUBLE")), + Arrays.asList(true, false), Arrays.asList("cx", null)); + ConnectorType arr = ConnectorType.arrayOf(ConnectorType.of("INT"), false); + ConnectorType map = ConnectorType.mapOf(ConnectorType.of("STRING"), ConnectorType.of("BIGINT"), false); + Schema schema = IcebergSchemaBuilder.buildSchema(Arrays.asList( + col("st", struct, true), col("arr", arr, true), col("m", map, true))); + + Types.StructType st = schema.findField("st").type().asStructType(); + Assertions.assertTrue(st.fields().get(0).isOptional()); + Assertions.assertEquals("cx", st.fields().get(0).doc()); + Assertions.assertTrue(st.fields().get(1).isRequired()); + Assertions.assertFalse(schema.findField("arr").type().asListType().isElementOptional()); + Assertions.assertFalse(schema.findField("m").type().asMapType().isValueOptional()); + } + + @Test + public void testNestedDefaultsToOptionalWhenNullabilityNotCarried() { + // Legacy factories carry no per-field nullability -> every nested element defaults OPTIONAL. + ConnectorType struct = ConnectorType.structOf( + Arrays.asList("x"), Arrays.asList(ConnectorType.of("INT"))); + Schema schema = IcebergSchemaBuilder.buildSchema(Collections.singletonList(col("st", struct, true))); + Assertions.assertTrue(schema.findField("st").type().asStructType().fields().get(0).isOptional()); + } + + private static List idsOf(Type type) { + java.util.List ids = new java.util.ArrayList<>(); + collectIds(type, ids); + return ids; + } + + private static void collectIds(Type type, List ids) { + if (type.isListType()) { + ids.add(type.asListType().elementId()); + collectIds(type.asListType().elementType(), ids); + } else if (type.isMapType()) { + ids.add(type.asMapType().keyId()); + ids.add(type.asMapType().valueId()); + collectIds(type.asMapType().keyType(), ids); + collectIds(type.asMapType().valueType(), ids); + } else if (type.isStructType()) { + for (Types.NestedField f : type.asStructType().fields()) { + ids.add(f.fieldId()); + collectIds(f.type(), ids); + } + } + } + + // ---------- buildPartitionSpec ---------- + + private static Schema partSchema() { + return IcebergSchemaBuilder.buildSchema(Arrays.asList( + col("id", ConnectorType.of("BIGINT"), true), + col("name", ConnectorType.of("VARCHAR", 50, 0), true), + col("ts", ConnectorType.of("DATETIMEV2", 6, 0), true))); + } + + private static ConnectorPartitionSpec spec(ConnectorPartitionField... fields) { + return new ConnectorPartitionSpec( + ConnectorPartitionSpec.Style.TRANSFORM, Arrays.asList(fields), Collections.emptyList()); + } + + @Test + public void testPartitionTransforms() { + Schema schema = partSchema(); + PartitionSpec result = IcebergSchemaBuilder.buildPartitionSpec(spec( + new ConnectorPartitionField("id", "bucket", Collections.singletonList(16)), + new ConnectorPartitionField("name", "truncate", Collections.singletonList(4)), + new ConnectorPartitionField("ts", "day", Collections.emptyList())), schema); + List transforms = new java.util.ArrayList<>(); + result.fields().forEach(f -> transforms.add(f.transform().toString())); + Assertions.assertTrue(transforms.contains("bucket[16]"), transforms.toString()); + Assertions.assertTrue(transforms.contains("truncate[4]"), transforms.toString()); + Assertions.assertTrue(transforms.contains("day"), transforms.toString()); + } + + @Test + public void testIdentityPartition() { + Schema schema = partSchema(); + PartitionSpec result = IcebergSchemaBuilder.buildPartitionSpec( + new ConnectorPartitionSpec(ConnectorPartitionSpec.Style.IDENTITY, + Collections.singletonList(new ConnectorPartitionField("name", "identity", + Collections.emptyList())), + Collections.emptyList()), + schema); + Assertions.assertEquals(1, result.fields().size()); + Assertions.assertEquals("identity", result.fields().get(0).transform().toString()); + } + + @Test + public void testNullOrEmptyPartitionSpecIsUnpartitioned() { + Assertions.assertTrue(IcebergSchemaBuilder.buildPartitionSpec(null, partSchema()).isUnpartitioned()); + } + + @Test + public void testUnsupportedTransformFailsLoud() { + Schema schema = partSchema(); + Assertions.assertThrows(DorisConnectorException.class, + () -> IcebergSchemaBuilder.buildPartitionSpec(spec( + new ConnectorPartitionField("name", "weird_transform", Collections.emptyList())), schema)); + } + + @Test + public void testBucketMissingArgFailsLoud() { + Schema schema = partSchema(); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> IcebergSchemaBuilder.buildPartitionSpec(spec( + new ConnectorPartitionField("id", "bucket", Collections.emptyList())), schema)); + Assertions.assertTrue(ex.getMessage().contains("bucket")); + } + + // ---------- buildSortOrder ---------- + + @Test + public void testSortOrder() { + Schema schema = partSchema(); + SortOrder order = IcebergSchemaBuilder.buildSortOrder(Arrays.asList( + new ConnectorSortField("id", true, true), + new ConnectorSortField("name", false, false)), schema); + Assertions.assertEquals(2, order.fields().size()); + Assertions.assertEquals(SortDirection.ASC, order.fields().get(0).direction()); + Assertions.assertEquals(NullOrder.NULLS_FIRST, order.fields().get(0).nullOrder()); + Assertions.assertEquals(SortDirection.DESC, order.fields().get(1).direction()); + Assertions.assertEquals(NullOrder.NULLS_LAST, order.fields().get(1).nullOrder()); + } + + @Test + public void testNullOrEmptySortOrderIsNull() { + Assertions.assertNull(IcebergSchemaBuilder.buildSortOrder(null, partSchema())); + Assertions.assertNull(IcebergSchemaBuilder.buildSortOrder(Collections.emptyList(), partSchema())); + } + + @Test + public void testPartitionColumnResolvedCaseInsensitively() { + // #65094: the schema keeps the original column case ("mIxEd_COL"); a partition column referenced + // with a different case ("mixed_col") must resolve back to the canonical name, else Iceberg's + // case-sensitive PartitionSpec.Builder lookup throws ValidationException. + // MUTATION: dropping resolveColumnName -> builder.identity("mixed_col") can't find the field -> + // buildPartitionSpec throws -> red. + Schema schema = IcebergSchemaBuilder.buildSchema(Collections.singletonList( + col("mIxEd_COL", ConnectorType.of("BIGINT"), true))); + PartitionSpec result = IcebergSchemaBuilder.buildPartitionSpec(spec( + new ConnectorPartitionField("mixed_col", "identity", Collections.emptyList())), schema); + Assertions.assertEquals(1, result.fields().size()); + Assertions.assertEquals(schema.findField("mIxEd_COL").fieldId(), result.fields().get(0).sourceId(), + "partition must bind to the canonical (case-preserving) column"); + } + + @Test + public void testSortColumnResolvedCaseInsensitively() { + // #65094: same case-insensitive resolution for a write-order (sort) column. + // MUTATION: dropping resolveColumnName -> builder.asc("mixed_col") can't find the field -> + // buildSortOrder throws -> red. + Schema schema = IcebergSchemaBuilder.buildSchema(Collections.singletonList( + col("mIxEd_COL", ConnectorType.of("BIGINT"), true))); + SortOrder order = IcebergSchemaBuilder.buildSortOrder(Collections.singletonList( + new ConnectorSortField("mixed_col", true, true)), schema); + Assertions.assertEquals(1, order.fields().size()); + Assertions.assertEquals(schema.findField("mIxEd_COL").fieldId(), order.fields().get(0).sourceId(), + "sort field must bind to the canonical (case-preserving) column"); + } + + // ---------- buildTableProperties ---------- + + @Test + public void testDefaultPropertiesAppliedWhenAbsent() { + Map props = IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap()); + Assertions.assertEquals("2", props.get(TableProperties.FORMAT_VERSION)); + Assertions.assertEquals("merge-on-read", props.get(TableProperties.DELETE_MODE)); + Assertions.assertEquals("merge-on-read", props.get(TableProperties.UPDATE_MODE)); + Assertions.assertEquals("merge-on-read", props.get(TableProperties.MERGE_MODE)); + } + + @Test + public void testUserPropertiesPreservedOverDefaults() { + Map in = new HashMap<>(); + in.put(TableProperties.FORMAT_VERSION, "3"); + in.put("custom", "v"); + Map props = IcebergSchemaBuilder.buildTableProperties(in); + Assertions.assertEquals("3", props.get(TableProperties.FORMAT_VERSION)); + Assertions.assertEquals("v", props.get("custom")); + Assertions.assertEquals("merge-on-read", props.get(TableProperties.DELETE_MODE)); + } + + // ---- format-version defaulting vs catalog-level default (upstream 25f291673f1, #63825) ---- + // Literal iceberg keys (CatalogProperties.TABLE_DEFAULT_PREFIX/TABLE_OVERRIDE_PREFIX + FORMAT_VERSION) + // are used on purpose, to pin the actual wire contract the connector reads from catalog properties. + + @Test + public void testCatalogDefaultFormatVersionNotOverriddenToV2() { + // WHY: when the catalog sets a table-default format-version and CREATE TABLE does not, the connector + // must NOT inject format-version=2 — the catalog default (e.g. v3) has to win. MUTATION: restoring + // the old unconditional putIfAbsent(FORMAT_VERSION,"2") forces v2 and silently ignores the catalog. + Map catalogProps = new HashMap<>(); + catalogProps.put("table-default.format-version", "3"); + Map props = IcebergSchemaBuilder.buildTableProperties(new HashMap<>(), catalogProps); + Assertions.assertFalse(props.containsKey(TableProperties.FORMAT_VERSION), + "catalog table-default.format-version must not be overridden by the v2 default"); + // MOR defaults are still applied unconditionally. + Assertions.assertEquals("merge-on-read", props.get(TableProperties.DELETE_MODE)); + } + + @Test + public void testCatalogOverrideFormatVersionNotOverriddenToV2() { + Map catalogProps = new HashMap<>(); + catalogProps.put("table-override.format-version", "3"); + Map props = IcebergSchemaBuilder.buildTableProperties(new HashMap<>(), catalogProps); + Assertions.assertFalse(props.containsKey(TableProperties.FORMAT_VERSION)); + } + + @Test + public void testFormatVersionDefaultsToV2WhenNoCatalogDefault() { + // Backward compat: no table-level and no catalog-level format-version -> still defaults to v2. + Map props = + IcebergSchemaBuilder.buildTableProperties(new HashMap<>(), Collections.emptyMap()); + Assertions.assertEquals("2", props.get(TableProperties.FORMAT_VERSION)); + // The 1-arg overload (used by the existing call sites) keeps the same v2 default via emptyMap. + Assertions.assertEquals("2", + IcebergSchemaBuilder.buildTableProperties(new HashMap<>()).get(TableProperties.FORMAT_VERSION)); + } + + @Test + public void testTableFormatVersionWinsOverCatalogDefault() { + // An explicit table-level format-version is always honored, regardless of any catalog default. + Map tableProps = new HashMap<>(); + tableProps.put(TableProperties.FORMAT_VERSION, "1"); + Map catalogProps = new HashMap<>(); + catalogProps.put("table-default.format-version", "3"); + Map props = IcebergSchemaBuilder.buildTableProperties(tableProps, catalogProps); + Assertions.assertEquals("1", props.get(TableProperties.FORMAT_VERSION)); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java new file mode 100644 index 00000000000000..dcd47cf704c0d4 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java @@ -0,0 +1,513 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.thrift.TFileScanRangeParams; +import org.apache.doris.thrift.TPrimitiveType; +import org.apache.doris.thrift.schema.external.TField; +import org.apache.doris.thrift.schema.external.TFieldPtr; +import org.apache.doris.thrift.schema.external.TSchema; +import org.apache.doris.thrift.schema.external.TStructField; + +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.mapping.MappingUtil; +import org.apache.iceberg.mapping.NameMappingParser; +import org.apache.iceberg.types.Types; +import org.apache.thrift.TDeserializer; +import org.apache.thrift.protocol.TBinaryProtocol; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Tests for {@link IcebergSchemaUtils} — the T06 field-id schema dictionary. The dictionary is the highest-risk + * P6.2 carrier: a wrong/missing field-id entry makes BE either silently read NULL/garbage for renamed columns + * or DCHECK-abort the whole BE on a missing column (CI #969249). These tests assert the decoded thrift dictionary + * against the legacy {@code ExternalUtil.initSchemaInfoFor{All,Pruned}Column} expectation — not class names — + * since the parity is otherwise UT-invisible (only P6.6 docker e2e exercises BE). No Mockito; real + * {@link InMemoryCatalog}. + */ +public class IcebergSchemaUtilsTest { + + private static final Schema SCHEMA = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get()), + Types.NestedField.optional(3, "extra", Types.StringType.get())); + + // --- helpers --- + + private static Table createTable(String name, Schema schema, Map props) { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + return catalog.createTable(TableIdentifier.of("db1", name), schema, null, null, props); + } + + private static Table createTable(String name, Schema schema) { + return createTable(name, schema, Collections.emptyMap()); + } + + /** Build the dictionary for the given requested column names and return the single (-1) entry. */ + private static TSchema dict(Table table, String... requestedLowerNames) { + Map> nameMapping = IcebergSchemaUtils.extractNameMapping(table); + return IcebergSchemaUtils.buildCurrentSchema(table.schema(), Arrays.asList(requestedLowerNames), + nameMapping); + } + + /** Index the top-level fields of an entry by name (preserving order for ordering assertions). */ + private static Map topFields(TSchema schema) { + Map byName = new LinkedHashMap<>(); + for (TFieldPtr ptr : schema.getRootField().getFields()) { + byName.put(ptr.getFieldPtr().getName(), ptr.getFieldPtr()); + } + return byName; + } + + private static TField childByName(TField parent, String name) { + for (TFieldPtr ptr : parent.getNestedField().getStructField().getFields()) { + if (ptr.getFieldPtr().getName().equals(name)) { + return ptr.getFieldPtr(); + } + } + throw new AssertionError("no nested field named " + name); + } + + private static TFileScanRangeParams decode(String encoded) throws Exception { + TFileScanRangeParams params = new TFileScanRangeParams(); + new TDeserializer(new TBinaryProtocol.Factory()) + .deserialize(params, Base64.getDecoder().decode(encoded)); + return params; + } + + // --- the single-entry, -1-sentinel contract (legacy parity; the iceberg-vs-paimon divergence) --- + + @Test + public void encodeProducesSingleMinusOneEntry() throws Exception { + Table table = createTable("t1", SCHEMA); + String encoded = IcebergSchemaUtils.encodeSchemaEvolutionProp(table, Arrays.asList("id", "name")); + TFileScanRangeParams params = decode(encoded); + + // WHY: legacy iceberg sets current_schema_id = -1 and emits exactly ONE history_schema_info entry + // (IcebergScanNode.createScanRangeLocations -> -1L); BE reads file field-ids from the file metadata and + // matches them to this single table-side entry. MUTATION: emit per-committed-schema-id entries (the + // paimon shape the HANDOFF planned) -> size != 1 -> red. MUTATION: a real schema id instead of -1 -> red. + Assertions.assertTrue(params.isSetCurrentSchemaId()); + Assertions.assertEquals(-1L, params.getCurrentSchemaId()); + Assertions.assertEquals(1, params.getHistorySchemaInfoSize()); + Assertions.assertEquals(-1L, params.getHistorySchemaInfo().get(0).getSchemaId()); + } + + // --- top-level: iceberg field ids + lowercased names keyed off the requested columns --- + + @Test + public void topLevelFieldsCarryIcebergFieldIdsAndLowercasedNames() { + // buildCurrentSchema echoes the REQUESTED (pruned) column names VERBATIM as the dictionary's top-level + // names so BE's StructNode keys match the scan slots; here lowercase requested names -> lowercase + // top-level names. The field id is the iceberg field id (the rename-safe join key BE matches the file's + // embedded ids against), read from table.schema() (iceberg reassigns ids on creation) proving the + // dictionary carries ACTUAL field ids, not a fabricated/positional value. See + // topLevelFieldsPreserveMixedCaseRequestedNames for the case-preserving (#65094) path. + Schema mixed = new Schema( + Types.NestedField.required(7, "ID", Types.IntegerType.get()), + Types.NestedField.optional(9, "Name", Types.StringType.get())); + Table table = createTable("mixed", mixed); + Schema actual = table.schema(); + + Map fields = topFields(dict(table, "id", "name")); + + Assertions.assertEquals(actual.caseInsensitiveFindField("id").fieldId(), fields.get("id").getId()); + Assertions.assertEquals(actual.caseInsensitiveFindField("name").fieldId(), fields.get("name").getId()); + // MUTATION: keep the iceberg case ("ID") -> the lowercase slot lookup misses -> red. + Assertions.assertFalse(fields.containsKey("ID")); + // Legacy parity (NOT the iceberg required/optional flag): ExternalUtil sets is_optional from the Doris + // column's isAllowNull(), which parseSchema forces to true for EVERY iceberg column — so even the + // REQUIRED "id" surfaces is_optional=true. MUTATION: leak field.isOptional() (required -> false) -> red. + Assertions.assertTrue(fields.get("id").isIsOptional()); + Assertions.assertTrue(fields.get("name").isIsOptional()); + } + + @Test + public void topLevelFieldsPreserveMixedCaseRequestedNames() { + // #65094 read-path alignment: post-cutover getColumnHandles (IcebergConnectorMetadata:579) and + // parseSchema (:1708) KEEP the iceberg top-level case, so the requested (pruned) names reaching the + // dictionary are case-PRESERVED. buildCurrentSchema must echo them VERBATIM (no re-lowercasing) so the + // -1 entry's top-level names byte-match the case-preserving Doris scan slots BE keys by; the field id + // stays the rename-safe iceberg field id. + Schema mixed = new Schema( + Types.NestedField.required(7, "ID", Types.IntegerType.get()), + Types.NestedField.optional(9, "Name", Types.StringType.get())); + Table table = createTable("mixed_preserve", mixed); + Schema actual = table.schema(); + + Map fields = topFields(dict(table, "ID", "Name")); + + // Case-preserved top-level names carry the ACTUAL iceberg field ids (resolved case-insensitively). + Assertions.assertEquals(actual.caseInsensitiveFindField("id").fieldId(), fields.get("ID").getId()); + Assertions.assertEquals(actual.caseInsensitiveFindField("name").fieldId(), fields.get("Name").getId()); + // MUTATION: re-lowercase the top-level name (the pre-#65094 behavior) -> "ID"/"Name" absent, the + // case-preserving Doris slot lookup misses -> red. + Assertions.assertFalse(fields.containsKey("id")); + Assertions.assertFalse(fields.containsKey("name")); + } + + @Test + public void keyedOffRequestedColumnsOnlyIncludesRequested() { + // CI #969249 invariant: the -1 entry's top-level names must equal the BE scan slots BY CONSTRUCTION. + // Keying off the requested (pruned) columns guarantees that — requesting only "name" must NOT pull in + // "id"/"extra". MUTATION: build from table.schema() (all columns) -> the entry over-covers -> red here, + // and (the real bug) UNDER-covers when the FE slots lead the resolved schema -> BE DCHECK on the file. + Table table = createTable("t1", SCHEMA); + Map fields = topFields(dict(table, "name")); + + Assertions.assertEquals(1, fields.size()); + Assertions.assertTrue(fields.containsKey("name")); + Assertions.assertEquals(2, fields.get("name").getId()); + } + + // --- iceberg v3 row-lineage columns must be in the dict root (else BE ParquetReader SIGABRTs) --- + + @Test + public void appendRowLineageAddsMetadataColumnsToDictRoot() throws Exception { + // WHY: _row_id / _last_updated_sequence_number are GENERATED BE scan slots (they reach BE column_names) + // but are NOT in schema.columns(), so a dict keyed off the requested columns omits them. BE's ParquetReader + // iterates column_names and calls StructNode.children_column_exists(name) -> children.at(name), which + // std::out_of_range-SIGABRTs the whole BE on a missing key (the exact crash on + // "select _row_id from a v2->v3 upgraded table"). With appendRowLineage=true both columns must appear in + // the dict root carrying their RESERVED iceberg field ids (BE matches them against the FILE field ids and + // registers them not-in-file for a "null after upgrade" file). MUTATION: skip appendRowLineageFields -> + // _row_id absent -> red. + Table table = createTable("v3", SCHEMA); + String encoded = IcebergSchemaUtils.encodeSchemaEvolutionProp( + table, table.schema(), Arrays.asList("id", "name"), true); + Map fields = topFields(decode(encoded).getHistorySchemaInfo().get(0)); + + Assertions.assertTrue(fields.containsKey("_row_id")); + Assertions.assertTrue(fields.containsKey("_last_updated_sequence_number")); + Assertions.assertEquals(2147483540, fields.get("_row_id").getId()); + Assertions.assertEquals(2147483539, fields.get("_last_updated_sequence_number").getId()); + // the requested data columns are still carried (row-lineage is APPENDED, not a replacement) + Assertions.assertTrue(fields.containsKey("id")); + Assertions.assertTrue(fields.containsKey("name")); + } + + @Test + public void withoutAppendRowLineageDictStaysPruned() throws Exception { + // The format-version < 3 path (appendRowLineage=false, the 2-arg overload) must NOT inject row-lineage — + // the pruned dict stays exactly the requested slots. MUTATION: always append -> _row_id present -> red. + Table table = createTable("v2", SCHEMA); + String encoded = IcebergSchemaUtils.encodeSchemaEvolutionProp(table, Arrays.asList("id", "name")); + Map fields = topFields(decode(encoded).getHistorySchemaInfo().get(0)); + + Assertions.assertFalse(fields.containsKey("_row_id")); + Assertions.assertFalse(fields.containsKey("_last_updated_sequence_number")); + Assertions.assertEquals(2, fields.size()); + } + + @Test + public void renamePreservesFieldIdAcrossEvolution() { + // The crux of "one entry suffices": iceberg field ids are permanent. Rename name(id=2) -> full_name; the + // dictionary keyed off the NEW name still carries the SAME field id 2, so BE matches an old file's + // column (written as "name", field id 2) to the renamed table column by id. MUTATION: source the id from + // the file/name rather than the stable iceberg field id -> id changes on rename -> red. + Table table = createTable("t1", SCHEMA); + table.updateSchema().renameColumn("name", "full_name").commit(); + + Map fields = topFields(dict(table, "id", "full_name")); + + Assertions.assertEquals(2, fields.get("full_name").getId()); + Assertions.assertFalse(fields.containsKey("name")); + } + + @Test + public void emptyRequestedFallsBackToAllColumns() { + // A count-only scan (no projected slots) / a table with no column handles yields an empty requested list; + // the dictionary then carries all top-level columns (lowercased) so it is still a valid superset. MUTATION: + // return an empty root struct -> BE has no table entry -> red. + Table table = createTable("t1", SCHEMA); + Map fields = topFields(dict(table /* no requested names */)); + + Assertions.assertEquals(3, fields.size()); + Assertions.assertEquals(1, fields.get("id").getId()); + Assertions.assertEquals(2, fields.get("name").getId()); + Assertions.assertEquals(3, fields.get("extra").getId()); + } + + @Test + public void failsLoudWhenRequestedColumnAbsent() { + // A requested column absent from the resolved schema is a genuine FE/connector inconsistency; fail loud + // rather than silently drop it (a dropped column would make BE's StructNode DCHECK-abort the whole BE). + Table table = createTable("t1", SCHEMA); + Assertions.assertThrows(RuntimeException.class, () -> dict(table, "id", "does_not_exist")); + } + + // --- #65502: each field's iceberg initial default is carried onto the dict TField --- + + @Test + public void initialDefaultsAreCarriedOntoDictFields() { + // #65502: BE materializes a column (notably an equality-delete KEY) that is ABSENT from an OLD data + // file with the field's iceberg initial default instead of NULL. So buildField must carry each field's + // initial default: scalar values as the Doris string form (timestamp normalized to DATETIMEV2 spacing), + // binary-like values (UUID/BINARY/FIXED) as a lossless Base64 carrier flagged is_base64 (their Doris + // STRING/CHAR type can't tell BE to decode bytes). The expected values byte-match #65502's IcebergUtils + // test. MUTATION: not setting initial_default_value -> BE backfills NULL -> mis-applied deletes -> red. + Schema schema = new Schema( + Types.NestedField.optional("added_int").withId(1).ofType(Types.IntegerType.get()) + .withInitialDefault(7).build(), + Types.NestedField.optional("added_ts").withId(2).ofType(Types.TimestampType.withoutZone()) + .withInitialDefault(1_704_067_200_123_456L).build(), + Types.NestedField.optional("added_uuid").withId(3).ofType(Types.UUIDType.get()) + .withInitialDefault(UUID.fromString("00000000-0000-0000-0000-000000000000")).build(), + Types.NestedField.optional("added_binary").withId(4).ofType(Types.BinaryType.get()) + .withInitialDefault(ByteBuffer.wrap(new byte[] {0, 1, 2, (byte) 0xFF})).build(), + Types.NestedField.optional("added_fixed").withId(5).ofType(Types.FixedType.ofLength(4)) + .withInitialDefault(ByteBuffer.wrap(new byte[] {3, 2, 1, 0})).build()); + + Map fields = topFields(IcebergSchemaUtils.buildCurrentSchema(schema, + Arrays.asList("added_int", "added_ts", "added_uuid", "added_binary", "added_fixed"), + Collections.emptyMap())); + + // INT -> plain Doris string form, NOT flagged base64. + Assertions.assertEquals("7", fields.get("added_int").getInitialDefaultValue()); + Assertions.assertFalse(fields.get("added_int").isSetInitialDefaultValueIsBase64()); + // TIMESTAMP without zone -> iceberg ISO "T" replaced by a space for DATETIMEV2 (matches #65502). + Assertions.assertEquals("2024-01-01 00:00:00.123456", fields.get("added_ts").getInitialDefaultValue()); + Assertions.assertFalse(fields.get("added_ts").isSetInitialDefaultValueIsBase64()); + // UUID -> 16 raw bytes (MSB then LSB) Base64, flagged base64 so BE decodes rather than reads the text. + Assertions.assertEquals("AAAAAAAAAAAAAAAAAAAAAA==", fields.get("added_uuid").getInitialDefaultValue()); + Assertions.assertTrue(fields.get("added_uuid").isInitialDefaultValueIsBase64()); + // BINARY / FIXED -> iceberg's identity human form is already Base64 of the raw bytes, flagged base64. + Assertions.assertEquals("AAEC/w==", fields.get("added_binary").getInitialDefaultValue()); + Assertions.assertTrue(fields.get("added_binary").isInitialDefaultValueIsBase64()); + Assertions.assertEquals("AwIBAA==", fields.get("added_fixed").getInitialDefaultValue()); + Assertions.assertTrue(fields.get("added_fixed").isInitialDefaultValueIsBase64()); + } + + @Test + public void fieldsWithoutInitialDefaultOmitTheCarrier() { + // A field with no initial default must NOT set initial_default_value (BE then backfills NULL, the legacy + // behaviour). MUTATION: unconditionally setting a default -> isSet true -> red. + Table table = createTable("t1", SCHEMA); + Map fields = topFields(dict(table, "id", "name")); + Assertions.assertFalse(fields.get("id").isSetInitialDefaultValue()); + Assertions.assertFalse(fields.get("name").isSetInitialDefaultValue()); + } + + // --- scalar placeholder + nested struct/array/map carry field ids at every level --- + + @Test + public void scalarFieldsUseStringPlaceholder() { + // BE reads type.type only as a nested-vs-scalar discriminator on the field-id path, so every scalar is a + // single STRING placeholder regardless of the real iceberg type (no full type conversion needed). + // MUTATION: map INT -> TPrimitiveType.INT -> still passes BE but diverges from the verified placeholder; + // the assertion pins the placeholder so the simplification is intentional, not accidental. + Table table = createTable("t1", SCHEMA); + Map fields = topFields(dict(table, "id")); + Assertions.assertEquals(TPrimitiveType.STRING, fields.get("id").getType().getType()); + } + + @Test + public void nestedTypesCarryFieldIdsAtEveryLevel() { + // Faithful to legacy ExternalUtil (NOT paimon, which omits ids on collection elements): every nested + // field — struct child, array element, map key/value — carries its own iceberg field id, because the + // iceberg BE reader field-id-matches nested fields too. MUTATION: drop the element/key/value ids -> red. + Schema nested = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "info", Types.StructType.of( + Types.NestedField.required(3, "a", Types.IntegerType.get()), + Types.NestedField.optional(4, "b", Types.StringType.get()))), + Types.NestedField.optional(5, "tags", + Types.ListType.ofRequired(6, Types.StringType.get())), + Types.NestedField.optional(7, "props", + Types.MapType.ofOptional(8, 9, Types.StringType.get(), Types.IntegerType.get()))); + Table table = createTable("nested", nested); + // iceberg reassigns field ids on table creation, so read the expected ids back from the table schema. + Schema actual = table.schema(); + Types.StructType infoType = (Types.StructType) actual.findField("info").type(); + Types.ListType tagsType = (Types.ListType) actual.findField("tags").type(); + Types.MapType propsType = (Types.MapType) actual.findField("props").type(); + + Map fields = topFields(dict(table, "info", "tags", "props")); + + // struct + TField info = fields.get("info"); + Assertions.assertEquals(actual.findField("info").fieldId(), info.getId()); + Assertions.assertEquals(TPrimitiveType.STRUCT, info.getType().getType()); + Assertions.assertEquals(infoType.field("a").fieldId(), childByName(info, "a").getId()); + Assertions.assertEquals(infoType.field("b").fieldId(), childByName(info, "b").getId()); + Assertions.assertEquals(TPrimitiveType.STRING, childByName(info, "a").getType().getType()); + + // array element + TField tags = fields.get("tags"); + Assertions.assertEquals(TPrimitiveType.ARRAY, tags.getType().getType()); + TField element = tags.getNestedField().getArrayField().getItemField().getFieldPtr(); + Assertions.assertEquals(tagsType.elementId(), element.getId()); + Assertions.assertEquals(TPrimitiveType.STRING, element.getType().getType()); + + // map key + value + TField props = fields.get("props"); + Assertions.assertEquals(TPrimitiveType.MAP, props.getType().getType()); + Assertions.assertEquals(propsType.keyId(), + props.getNestedField().getMapField().getKeyField().getFieldPtr().getId()); + Assertions.assertEquals(propsType.valueId(), + props.getNestedField().getMapField().getValueField().getFieldPtr().getId()); + } + + @Test + public void nestedStructChildNamesAreLowercased() { + // The crash repro (test_iceberg_struct_schema_evolution / DROP_AND_ADD): a struct child whose iceberg + // name has mixed case must be emitted LOWERCASED. The Doris slot's DataTypeStruct child names are + // force-lowercased (StructField ctor -> this.name = name.toLowerCase()), and BE's StructNode looks the + // child up by that lowercase name (children_column_exists/children.at). Keeping the iceberg case + // ("DROP_AND_ADD") makes BE's children.at("drop_and_add") throw std::out_of_range -> SIGABRT on the whole + // struct read (the DCHECK guard is compiled out in a Release BE). MUTATION (the bug): emit field.name() + // verbatim for struct children -> the lowercase key is absent / the iceberg-cased key leaks -> red. + Schema mixed = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "a_struct", Types.StructType.of( + Types.NestedField.optional(3, "keep", Types.LongType.get()), + Types.NestedField.optional(4, "DROP_AND_ADD", Types.LongType.get())))); + Table table = createTable("mixed_nested", mixed); + // iceberg reassigns field ids on create, so read the expected id back from the table schema. + Types.StructType structType = (Types.StructType) table.schema().findField("a_struct").type(); + + TField aStruct = topFields(dict(table, "a_struct")).get("a_struct"); + Map childIds = new LinkedHashMap<>(); + for (TFieldPtr ptr : aStruct.getNestedField().getStructField().getFields()) { + childIds.put(ptr.getFieldPtr().getName(), ptr.getFieldPtr().getId()); + } + + // the mixed-case child must be addressable by its LOWERCASE name (the BE StructNode lookup key) and still + // carry its (rename-safe) iceberg field id ... + Assertions.assertTrue(childIds.containsKey("drop_and_add")); + Assertions.assertEquals(structType.field("DROP_AND_ADD").fieldId(), + childIds.get("drop_and_add").intValue()); + // ... and the iceberg-cased name must NOT leak through (that is exactly what aborts BE). + Assertions.assertFalse(childIds.containsKey("DROP_AND_ADD")); + Assertions.assertTrue(childIds.containsKey("keep")); + } + + // --- name mapping (BE's fallback for old files lacking embedded field ids) --- + + @Test + public void nameMappingCarriedWhenTablePropertyPresent() { + // A table with schema.name-mapping.default makes each field carry TField.nameMapping so BE's + // by_parquet_field_id_with_name_mapping can resolve old files written before field ids were embedded. + // MUTATION: skip setting nameMapping -> isSetNameMapping false -> red. + String json = NameMappingParser.toJson(MappingUtil.create(SCHEMA)); + Table table = createTable("t1", SCHEMA, + Collections.singletonMap(TableProperties.DEFAULT_NAME_MAPPING, json)); + + Map fields = topFields(dict(table, "id", "name")); + + Assertions.assertTrue(fields.get("id").isSetNameMapping()); + Assertions.assertEquals(Collections.singletonList("id"), fields.get("id").getNameMapping()); + Assertions.assertTrue(fields.get("name").isSetNameMapping()); + Assertions.assertEquals(Collections.singletonList("name"), fields.get("name").getNameMapping()); + } + + @Test + public void noNameMappingWhenTablePropertyAbsent() { + // Without the property, no field carries a name mapping (BE then uses embedded field ids only). MUTATION: + // unconditionally set nameMapping -> isSetNameMapping true -> red. + Table table = createTable("t1", SCHEMA); + Map fields = topFields(dict(table, "id", "name")); + Assertions.assertFalse(fields.get("id").isSetNameMapping()); + Assertions.assertFalse(fields.get("name").isSetNameMapping()); + } + + @Test + public void extractNameMappingRecursesIntoNestedFields() { + // extractNameMapping must capture NESTED field ids too (legacy extractMappingsFromNameMapping recurses), + // so an old file's nested column can also fall back to name matching. MUTATION: stop recursing into + // nestedMapping -> the nested ids are absent -> red. + Schema nested = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "info", Types.StructType.of( + Types.NestedField.required(3, "a", Types.IntegerType.get())))); + String json = NameMappingParser.toJson(MappingUtil.create(nested)); + Table table = createTable("nested", nested, + Collections.singletonMap(TableProperties.DEFAULT_NAME_MAPPING, json)); + + Map> mapping = IcebergSchemaUtils.extractNameMapping(table); + + Assertions.assertEquals(Collections.singletonList("id"), mapping.get(1)); + Assertions.assertEquals(Collections.singletonList("info"), mapping.get(2)); + Assertions.assertEquals(Collections.singletonList("a"), mapping.get(3)); + } + + @Test + public void extractNameMappingFailsSoftOnMalformedProperty() { + // A malformed name-mapping property must not break the scan (legacy catches + warns). MUTATION: let the + // parse exception propagate -> the whole scan fails on a benign metadata quirk -> red. + Table table = createTable("t1", SCHEMA, + Collections.singletonMap(TableProperties.DEFAULT_NAME_MAPPING, "{not valid json")); + Map> mapping = IcebergSchemaUtils.extractNameMapping(table); + Assertions.assertTrue(mapping.isEmpty()); + } + + // --- round-trip through the prop transport (what the generic node does) --- + + @Test + public void applyRoundTripsThroughEncodedProp() { + // getScanNodeProperties encodes the dict, populateScanLevelParams applies it to the real params — the + // exact path the generic PluginDrivenScanNode round-trips. MUTATION: drop one of the two copied fields in + // applySchemaEvolution -> the params are missing it -> red. + Table table = createTable("t1", SCHEMA); + String encoded = IcebergSchemaUtils.encodeSchemaEvolutionProp(table, Arrays.asList("id", "name")); + + TFileScanRangeParams params = new TFileScanRangeParams(); + IcebergSchemaUtils.applySchemaEvolution(params, encoded); + + Assertions.assertEquals(-1L, params.getCurrentSchemaId()); + Assertions.assertEquals(1, params.getHistorySchemaInfoSize()); + TStructField root = params.getHistorySchemaInfo().get(0).getRootField(); + Assertions.assertEquals(2, root.getFieldsSize()); + } + + @Test + public void applyIsNoOpForNullOrEmpty() { + // A null/empty prop (e.g. another connector's props map) must leave the params untouched, not throw. + TFileScanRangeParams params = new TFileScanRangeParams(); + IcebergSchemaUtils.applySchemaEvolution(params, null); + IcebergSchemaUtils.applySchemaEvolution(params, ""); + Assertions.assertFalse(params.isSetCurrentSchemaId()); + Assertions.assertFalse(params.isSetHistorySchemaInfo()); + } + + @Test + public void applyFailsLoudOnCorruptProp() { + // The prop is produced by us, so a decode failure is a real bug — fail loud rather than silently drop it + // (a dropped dict re-introduces the silent wrong-rows BLOCKER on schema-evolved native reads). + TFileScanRangeParams params = new TFileScanRangeParams(); + Assertions.assertThrows(RuntimeException.class, + () -> IcebergSchemaUtils.applySchemaEvolution(params, "!!!not-base64-thrift!!!")); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSessionCatalogAdapterTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSessionCatalogAdapterTest.java new file mode 100644 index 00000000000000..ad12c1b8f57394 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSessionCatalogAdapterTest.java @@ -0,0 +1,179 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorDelegatedCredential; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.iceberg.IcebergSessionCatalogAdapter.DelegatedTokenMode; + +import com.google.common.collect.ImmutableMap; +import org.apache.iceberg.catalog.SessionCatalog; +import org.apache.iceberg.rest.auth.OAuth2Properties; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +/** + * Verifies the neutral-credential → Iceberg-REST bridge in {@link IcebergSessionCatalogAdapter}: how a user's + * {@link ConnectorDelegatedCredential} is turned into the Iceberg {@code SessionCatalog.SessionContext} credential + * map (per {@code delegated-token-mode}), that the stable session id rides through as the {@code AuthSession} key, + * and that the delegated (per-user) catalog accessors fail closed when no credential is present — the security + * contract re-migrated from #63068. The credential-mapping cases run entirely offline through the + * {@code @VisibleForTesting} static {@code toIcebergSessionContext}; the fail-closed cases only need the + * credential-presence guard, which precedes any real catalog use, so a {@code null} session catalog suffices. + */ +public class IcebergSessionCatalogAdapterTest { + + // ── delegated-token-mode = access_token: the token is the OAuth2 bearer verbatim, whatever its kind ── + + @Test + public void accessTokenModePassesTokenVerbatimAsBearerRegardlessOfType() { + // Even a JWT-kind credential is attached under the plain OAuth2 TOKEN (bearer) key in access_token mode — + // the REST server treats it as an already-minted access token (no token exchange). + SessionCatalog.SessionContext ctx = IcebergSessionCatalogAdapter.toIcebergSessionContext( + session(ConnectorDelegatedCredential.Type.JWT, "raw-token", "sess-1"), + DelegatedTokenMode.ACCESS_TOKEN); + + Assertions.assertEquals(ImmutableMap.of(OAuth2Properties.TOKEN, "raw-token"), ctx.credentials()); + } + + // ── delegated-token-mode = token_exchange: each credential kind maps to its typed OAuth2 token-type key ── + + @Test + public void tokenExchangeModeMapsEachTypeToItsTokenTypeKey() { + assertExchangeKey(ConnectorDelegatedCredential.Type.ACCESS_TOKEN, OAuth2Properties.TOKEN); + assertExchangeKey(ConnectorDelegatedCredential.Type.ID_TOKEN, OAuth2Properties.ID_TOKEN_TYPE); + assertExchangeKey(ConnectorDelegatedCredential.Type.JWT, OAuth2Properties.JWT_TOKEN_TYPE); + assertExchangeKey(ConnectorDelegatedCredential.Type.SAML, OAuth2Properties.SAML2_TOKEN_TYPE); + } + + private static void assertExchangeKey(ConnectorDelegatedCredential.Type type, String expectedKey) { + SessionCatalog.SessionContext ctx = IcebergSessionCatalogAdapter.toIcebergSessionContext( + session(type, "tok-" + type, "s"), DelegatedTokenMode.TOKEN_EXCHANGE); + Assertions.assertEquals(ImmutableMap.of(expectedKey, "tok-" + type), ctx.credentials(), + "token_exchange must key the token by its OAuth2 token-type for " + type); + } + + @Test + public void sessionIdIsCarriedAsTheAuthSessionKey() { + // The stable, FE-forward-preserved session id must become the SessionContext.sessionId() so a user's + // queries reuse one minted AuthSession (not re-authenticate per query). + SessionCatalog.SessionContext ctx = IcebergSessionCatalogAdapter.toIcebergSessionContext( + session(ConnectorDelegatedCredential.Type.ACCESS_TOKEN, "t", "stable-session-99"), + DelegatedTokenMode.ACCESS_TOKEN); + Assertions.assertEquals("stable-session-99", ctx.sessionId()); + } + + @Test + public void noCredentialYieldsEmptyCredentialMap() { + SessionCatalog.SessionContext ctx = IcebergSessionCatalogAdapter.toIcebergSessionContext( + session(null, null, "s"), DelegatedTokenMode.ACCESS_TOKEN); + Assertions.assertTrue(ctx.credentials().isEmpty(), + "a credential-less session must not attach any bearer/token to the REST request"); + } + + // ── fail-closed: the per-user accessors reject a session that carries no delegated credential ── + + @Test + public void delegatedCatalogFailsClosedWithoutCredential() { + IcebergSessionCatalogAdapter adapter = + new IcebergSessionCatalogAdapter(null, null, DelegatedTokenMode.ACCESS_TOKEN); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> adapter.delegatedCatalog(session(null, null, "s"))); + Assertions.assertTrue(e.getMessage().contains("delegated credential")); + } + + @Test + public void delegatedViewCatalogFailsClosedWithoutCredential() { + IcebergSessionCatalogAdapter adapter = + new IcebergSessionCatalogAdapter(null, null, DelegatedTokenMode.ACCESS_TOKEN); + Assertions.assertThrows(DorisConnectorException.class, + () -> adapter.delegatedViewCatalog(session(null, null, "s"))); + } + + // ── delegated-token-mode parsing ── + + @Test + public void delegatedTokenModeFromStringParsesKnownAndRejectsUnknown() { + Assertions.assertEquals(DelegatedTokenMode.ACCESS_TOKEN, DelegatedTokenMode.fromString("access_token")); + Assertions.assertEquals(DelegatedTokenMode.TOKEN_EXCHANGE, DelegatedTokenMode.fromString("token_exchange")); + // Case-insensitive (mirrors the connector-property parsing). + Assertions.assertEquals(DelegatedTokenMode.ACCESS_TOKEN, DelegatedTokenMode.fromString("ACCESS_TOKEN")); + Assertions.assertThrows(IllegalArgumentException.class, () -> DelegatedTokenMode.fromString("bogus")); + } + + /** + * A minimal {@link ConnectorSession} carrying an optional delegated credential and a fixed session id (as the + * query id, which {@link ConnectorSession#getSessionId()} falls back to). A {@code null} type yields a + * credential-less session. + */ + private static ConnectorSession session(ConnectorDelegatedCredential.Type type, String token, String sessionId) { + ConnectorDelegatedCredential credential = + type == null ? null : new ConnectorDelegatedCredential(type, token); + return new ConnectorSession() { + @Override + public Optional getDelegatedCredential() { + return Optional.ofNullable(credential); + } + + @Override + public String getQueryId() { + return sessionId; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public long getCatalogId() { + return 1L; + } + + @Override + public String getCatalogName() { + return "ice"; + } + + @Override + public T getProperty(String name, Class clazz) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + }; + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java new file mode 100644 index 00000000000000..70238ea323f82d --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java @@ -0,0 +1,179 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.thrift.TIcebergDeleteFileDesc; + +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Tests for {@link IcebergStatementScope}: the per-statement table memo keying (catalog id + db + table + + * queryId) that lets a statement's read/scan/write share ONE loaded table, and the rewritable-delete supply + * map keying (catalog id + queryId) that bridges the scan→write seam. + */ +public class IcebergStatementScopeTest { + + private static final Schema SCHEMA = + new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); + + private static Table table(String name) { + return new FakeIcebergTable(name, SCHEMA, PartitionSpec.unpartitioned(), + "s3://b/db1/" + name, Collections.emptyMap()); + } + + @Test + public void sameStatementSharesOneLoadedTable() { + // The read, scan and write resolvers of one statement resolve the same table through sharedTable; sharing + // one scope collapses them onto one load and hands each the SAME instance (read/write share). + // MUTATION: not memoizing -> two loads / two instances -> red. + ScopeSession session = new ScopeSession(7L, "q1", new TestStatementScope()); + AtomicInteger loads = new AtomicInteger(); + Table t1 = IcebergStatementScope.sharedTable(session, "db1", "t", () -> { + loads.incrementAndGet(); + return table("t"); + }); + Table t2 = IcebergStatementScope.sharedTable(session, "db1", "t", () -> { + loads.incrementAndGet(); + return table("t"); + }); + Assertions.assertSame(t1, t2, "same statement + table -> one shared instance"); + Assertions.assertEquals(1, loads.get(), "loaded once per statement"); + } + + @Test + public void differentQueryIdIsolatesTheLoad() { + // A reused prepared statement runs each EXECUTE under its own queryId, so one execution never sees + // another's table even on the same scope object. + TestStatementScope scope = new TestStatementScope(); + Table a = IcebergStatementScope.sharedTable(new ScopeSession(7L, "q1", scope), "db1", "t", () -> table("t")); + Table b = IcebergStatementScope.sharedTable(new ScopeSession(7L, "q2", scope), "db1", "t", () -> table("t")); + Assertions.assertNotSame(a, b, "different queryId -> isolated load"); + } + + @Test + public void differentCatalogIdIsolatesTheLoad() { + // A cross-catalog MERGE resolves the two catalogs' tables independently (the key carries the catalog id). + TestStatementScope scope = new TestStatementScope(); + Table a = IcebergStatementScope.sharedTable(new ScopeSession(1L, "q1", scope), "db1", "t", () -> table("t")); + Table b = IcebergStatementScope.sharedTable(new ScopeSession(2L, "q1", scope), "db1", "t", () -> table("t")); + Assertions.assertNotSame(a, b, "different catalog id -> isolated load"); + } + + @Test + public void underNoneScopeLoadsEveryTime() { + // No live statement scope: each call loads (byte-identical to the pre-scope offline behavior). + ScopeSession none = new ScopeSession(7L, "q1", ConnectorStatementScope.NONE); + AtomicInteger loads = new AtomicInteger(); + IcebergStatementScope.sharedTable(none, "db1", "t", () -> { + loads.incrementAndGet(); + return table("t"); + }); + IcebergStatementScope.sharedTable(none, "db1", "t", () -> { + loads.incrementAndGet(); + return table("t"); + }); + Assertions.assertEquals(2, loads.get(), "NONE -> load every time"); + } + + @Test + public void rewritableDeleteSupplyIsSharedPerStatementAndIsolatedPerCatalog() { + // The scan seam and the write seam of one statement (same catalog + queryId) share ONE supply map; a + // cross-catalog MERGE keeps each catalog's supply isolated. MUTATION: dropping the catalog id from the + // key -> the two catalogs collide -> red. + TestStatementScope scope = new TestStatementScope(); + Map> supplyScan = + IcebergStatementScope.rewritableDeleteSupply(new ScopeSession(1L, "q1", scope)); + Map> supplyWrite = + IcebergStatementScope.rewritableDeleteSupply(new ScopeSession(1L, "q1", scope)); + Assertions.assertSame(supplyScan, supplyWrite, "scan and write of one statement share one supply map"); + + Map> supplyOtherCatalog = + IcebergStatementScope.rewritableDeleteSupply(new ScopeSession(2L, "q1", scope)); + Assertions.assertNotSame(supplyScan, supplyOtherCatalog, "a different catalog (cross-catalog MERGE) is isolated"); + } + + /** Minimal {@link ConnectorSession} carrying a catalog id, queryId and scope for the key + memo assertions. */ + private static final class ScopeSession implements ConnectorSession { + private final long catalogId; + private final String queryId; + private final ConnectorStatementScope scope; + + ScopeSession(long catalogId, String queryId, ConnectorStatementScope scope) { + this.catalogId = catalogId; + this.queryId = queryId; + this.scope = scope; + } + + @Override + public long getCatalogId() { + return catalogId; + } + + @Override + public String getQueryId() { + return queryId; + } + + @Override + public ConnectorStatementScope getStatementScope() { + return scope; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public String getCatalogName() { + return "c"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableCacheTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableCacheTest.java new file mode 100644 index 00000000000000..9dd551c35fb483 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableCacheTest.java @@ -0,0 +1,185 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.NoSuchTableException; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Unit tests for {@link IcebergTableCache} (PERF-01). The cross-query RAW-table cache mirrors + * {@link IcebergLatestSnapshotCache} exactly (same {@link org.apache.doris.connector.cache.MetaCacheEntry} + * backing) but stores the whole {@link Table} instead of the {@code (snapshotId, schemaId)} pin, restoring the + * table-caching half of the legacy {@code IcebergExternalMetaCache}. These tests cover the adapter's contract — + * within-TTL stability, the {@code ttl <= 0} disable, invalidation, and the exception-propagation guarantee the + * partition-view readers depend on. Timed-expiry mechanics are the framework's responsibility (unit-tested in + * the framework module), so they are not re-proven here. + */ +public class IcebergTableCacheTest { + + private static TableIdentifier id() { + return TableIdentifier.of("db", "t"); + } + + /** A distinct fake table, distinguishable by {@link Table#name()}. */ + private static Table table(String name) { + return new FakeIcebergTable(name, + new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())), + PartitionSpec.unpartitioned(), "s3://b/" + name, Collections.emptyMap()); + } + + @Test + public void cachesWithinTtlAndServesTheSameTable() { + AtomicInteger loads = new AtomicInteger(); + IcebergTableCache c = new IcebergTableCache(100, 1000); + + Table first = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return table("first"); + }); + // Second read within TTL must return the CACHED table (first), NOT a freshly-loaded one -> this is what + // lets consecutive queries (and one query's analysis/planning phases) reuse a single load. MUTATION: + // loading live every call -> returns "second" / loads==2 -> red. + Table second = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return table("second"); + }); + Assertions.assertEquals("first", first.name()); + Assertions.assertSame(first, second, "within TTL the cached table instance must be served"); + Assertions.assertEquals(1, loads.get(), "the live loader must run exactly once within TTL"); + Assertions.assertTrue(c.isEnabled()); + } + + @Test + public void ttlZeroDisablesCachingAlwaysLive() { + AtomicInteger loads = new AtomicInteger(); + IcebergTableCache c = new IcebergTableCache(0, 1000); + c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return table("first"); + }); + Table second = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return table("second"); + }); + // ttl-second=0 (the no-cache catalog) reads live every time. MUTATION: caching despite ttl<=0 -> + // second=="first" / loads==1 -> red. + Assertions.assertEquals("second", second.name(), "ttl-second=0 must always read the live table"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + Assertions.assertEquals(0, c.size(), "ttl-second=0 must not store anything"); + } + + @Test + public void negativeTtlDisablesCachingAlwaysLive() { + // ttl-second=-1 (or any negative) is still the no-cache catalog. Guards the CacheSpec trap where + // ttl == -1 means "no expiration (enabled)": the adapter must translate "<= 0" to disabled. + AtomicInteger loads = new AtomicInteger(); + IcebergTableCache c = new IcebergTableCache(-1, 1000); + c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return table("first"); + }); + Table second = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return table("second"); + }); + Assertions.assertEquals("second", second.name(), "ttl-second=-1 must always read the live table"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + } + + @Test + public void invalidateForcesReload() { + AtomicInteger loads = new AtomicInteger(); + IcebergTableCache c = new IcebergTableCache(100, 1000); + c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return table("first"); + }); + c.invalidate(id()); + // After REFRESH TABLE invalidation the next read goes live. MUTATION: invalidate not clearing -> + // returns cached "first" / loads==1 -> red. + Table after = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return table("second"); + }); + Assertions.assertEquals("second", after.name()); + Assertions.assertEquals(2, loads.get()); + } + + @Test + public void invalidateAllClearsEverything() { + IcebergTableCache c = new IcebergTableCache(100, 1000); + c.getOrLoad(TableIdentifier.of("db", "t1"), () -> table("t1")); + c.getOrLoad(TableIdentifier.of("db", "t2"), () -> table("t2")); + Assertions.assertEquals(2, c.size()); + c.invalidateAll(); + Assertions.assertEquals(0, c.size()); + } + + @Test + public void invalidateDbClearsOnlyThatDbsTables() { + AtomicInteger loads = new AtomicInteger(); + IcebergTableCache c = new IcebergTableCache(100, 1000); + c.getOrLoad(TableIdentifier.of("db1", "t1"), () -> table("db1.t1")); + c.getOrLoad(TableIdentifier.of("db1", "t2"), () -> table("db1.t2")); + c.getOrLoad(TableIdentifier.of("db2", "t1"), () -> table("db2.t1")); + Assertions.assertEquals(3, c.size()); + + // REFRESH DATABASE db1 (or a Doris DROP DATABASE db1) must drop BOTH db1 tables and leave db2 intact. + // MUTATION: invalidateDb a no-op -> db1.t1 still cached -> loads stays 0 / after=="db1.t1" -> red. + c.invalidateDb("db1"); + Assertions.assertEquals(1, c.size(), "only db2's single entry must survive"); + + Table afterDb1 = c.getOrLoad(TableIdentifier.of("db1", "t1"), () -> { + loads.incrementAndGet(); + return table("db1.t1.reloaded"); + }); + Assertions.assertEquals("db1.t1.reloaded", afterDb1.name(), "db1.t1 must reload live after invalidateDb"); + Assertions.assertEquals(1, loads.get()); + + Table db2 = c.getOrLoad(TableIdentifier.of("db2", "t1"), () -> { + loads.incrementAndGet(); + return table("db2.t1.reloaded"); + }); + Assertions.assertEquals("db2.t1", db2.name(), "db2 must keep its cached table (not dropped by invalidateDb(db1))"); + Assertions.assertEquals(1, loads.get(), "db2 read must be a hit (no extra load)"); + } + + @Test + public void loaderExceptionPropagatesUnwrapped() { + // The partition-view readers (listPartitions / listPartitionNames) catch NoSuchTableException to degrade + // a concurrent-drop race to an empty list. Routing them through this cache must NOT wrap that exception, + // or the degradation would break and they'd throw instead. The MetaCacheEntry manual-miss-load path + // re-throws the loader's RuntimeException verbatim. MUTATION: wrapping the loader exception -> + // assertThrows(NoSuchTableException) fails (a different type is thrown) -> red. + IcebergTableCache c = new IcebergTableCache(100, 1000); + Assertions.assertThrows(NoSuchTableException.class, () -> c.getOrLoad(id(), () -> { + throw new NoSuchTableException("simulated concurrent drop"); + })); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableHandleTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableHandleTest.java new file mode 100644 index 00000000000000..f5e928afaaa50c --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableHandleTest.java @@ -0,0 +1,358 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import com.google.common.collect.ImmutableSet; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; + +/** + * Tests for {@link IcebergTableHandle}, including the T07 MVCC / time-travel pin carriers and the + * P6.5-T02 system-table variant ({@code sysTableName} + {@link IcebergTableHandle#forSystemTable}). + */ +public class IcebergTableHandleTest { + + @Test + public void bareHandleHasNoPin() { + IcebergTableHandle h = new IcebergTableHandle("db1", "t1"); + // WHY: a normal (latest) read must carry NO pin so the scan reads the current snapshot. MUTATION: + // defaulting snapshotId to 0 (a valid id) -> hasSnapshotPin true -> red. + Assertions.assertFalse(h.hasSnapshotPin()); + Assertions.assertEquals(-1L, h.getSnapshotId()); + Assertions.assertNull(h.getRef()); + Assertions.assertEquals(-1L, h.getSchemaId()); + Assertions.assertEquals("db1", h.getDbName()); + Assertions.assertEquals("t1", h.getTableName()); + } + + @Test + public void withSnapshotPinsByIdAndCarriesSchemaId() { + IcebergTableHandle pinned = new IcebergTableHandle("db1", "t1").withSnapshot(42L, null, 3L); + Assertions.assertTrue(pinned.hasSnapshotPin()); + Assertions.assertEquals(42L, pinned.getSnapshotId()); + Assertions.assertNull(pinned.getRef()); + Assertions.assertEquals(3L, pinned.getSchemaId()); + // The coordinates survive the pin. + Assertions.assertEquals("db1", pinned.getDbName()); + Assertions.assertEquals("t1", pinned.getTableName()); + } + + @Test + public void withSnapshotPinsByRef() { + IcebergTableHandle pinned = new IcebergTableHandle("db1", "t1").withSnapshot(7L, "b1", 2L); + // WHY: a tag/branch read pins by REF (useRef), so a ref pin alone must count as a pin even if an id is + // also present. MUTATION: hasSnapshotPin checking only snapshotId -> still true here, so also assert + // ref-only below. + Assertions.assertTrue(pinned.hasSnapshotPin()); + Assertions.assertEquals("b1", pinned.getRef()); + } + + @Test + public void refOnlyPinCountsAsPin() { + IcebergTableHandle pinned = new IcebergTableHandle("db1", "t1").withSnapshot(-1L, "tag1", 5L); + // MUTATION: hasSnapshotPin returning snapshotId>=0 only -> false here -> red (a ref pin would be lost). + Assertions.assertTrue(pinned.hasSnapshotPin()); + Assertions.assertEquals("tag1", pinned.getRef()); + Assertions.assertEquals(-1L, pinned.getSnapshotId()); + } + + @Test + public void pinIsPartOfIdentity() { + IcebergTableHandle bare = new IcebergTableHandle("db1", "t1"); + IcebergTableHandle pinned = bare.withSnapshot(42L, null, 3L); + IcebergTableHandle samePin = new IcebergTableHandle("db1", "t1").withSnapshot(42L, null, 3L); + // WHY: the pin is part of the handle identity (a query-begin handle and a time-travel handle for the + // same table are different reads). MUTATION: equals/hashCode ignoring the pin -> bare.equals(pinned) -> red. + Assertions.assertNotEquals(bare, pinned); + Assertions.assertEquals(pinned, samePin); + Assertions.assertEquals(pinned.hashCode(), samePin.hashCode()); + Assertions.assertEquals(bare, new IcebergTableHandle("db1", "t1")); + } + + // ==================== P6.5-T02: system-table variant ==================== + + @Test + public void bareHandleIsNotSystemTable() { + IcebergTableHandle h = new IcebergTableHandle("db1", "t1"); + // WHY: a normal (data) table handle must not be mistaken for a system table, or the generic + // sys-table machinery would try to build a metadata-table for it. MUTATION: isSystemTable + // returning true by default / sysTableName defaulting to non-null -> red. + Assertions.assertFalse(h.isSystemTable()); + Assertions.assertNull(h.getSysTableName()); + } + + @Test + public void forSystemTableCarriesSysNameAndCoordinates() { + IcebergTableHandle sys = IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L); + // WHY: the connector resolves the metadata-table from the bare sys name (no "$"), so the handle + // must carry it through, while keeping the base table's coordinates. MUTATION: forSystemTable + // not storing sysTableName -> isSystemTable false -> red. + Assertions.assertTrue(sys.isSystemTable()); + Assertions.assertEquals("snapshots", sys.getSysTableName()); + Assertions.assertEquals("db1", sys.getDbName()); + Assertions.assertEquals("t1", sys.getTableName()); + // An un-pinned sys handle (latest read) carries no pin. + Assertions.assertFalse(sys.hasSnapshotPin()); + } + + @Test + public void forSystemTableRetainsSnapshotPin() { + IcebergTableHandle sys = IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", 42L, null, 3L); + // WHY (deviation 1, the hard invariant of decision A): iceberg system tables legally time-travel + // (e.g. `SELECT * FROM t$snapshots FOR VERSION AS OF 42`), so forSystemTable must RETAIN the + // snapshot pin — unlike paimon's forSystemTable, which clears it. MUTATION: forSystemTable + // dropping the pin (passing NO_PIN) -> snapshotId -1 / hasSnapshotPin false -> red, time-travel + // sys-table reads would silently fall back to the latest version. + Assertions.assertTrue(sys.hasSnapshotPin()); + Assertions.assertEquals(42L, sys.getSnapshotId()); + Assertions.assertEquals(3L, sys.getSchemaId()); + } + + @Test + public void forSystemTableRetainsRefPin() { + IcebergTableHandle sys = IcebergTableHandle.forSystemTable("db1", "t1", "history", -1L, "tag1", 5L); + // WHY: a tag/branch time-travel sys read pins by REF (useRef), so a ref pin must also survive on + // a sys handle. MUTATION: forSystemTable dropping ref -> getRef null / hasSnapshotPin false -> red. + Assertions.assertTrue(sys.hasSnapshotPin()); + Assertions.assertEquals("tag1", sys.getRef()); + Assertions.assertEquals(-1L, sys.getSnapshotId()); + } + + @Test + public void sysTableNameIsPartOfIdentity() { + IcebergTableHandle base = new IcebergTableHandle("db1", "t1"); + IcebergTableHandle snapshots = IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L); + IcebergTableHandle history = IcebergTableHandle.forSystemTable("db1", "t1", "history", -1L, null, -1L); + IcebergTableHandle sameSnapshots = + IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L); + // WHY: `db.t$snapshots` is a DIFFERENT table than `db.t` and than `db.t$history` (different + // schema/rows), so sysTableName must be part of equals/hashCode. MUTATION: equals/hashCode + // ignoring sysTableName -> base.equals(snapshots) or snapshots.equals(history) -> red. + Assertions.assertNotEquals(base, snapshots); + Assertions.assertNotEquals(snapshots, history); + Assertions.assertEquals(snapshots, sameSnapshots); + Assertions.assertEquals(snapshots.hashCode(), sameSnapshots.hashCode()); + } + + @Test + public void sysHandleAtDifferentVersionsAreDifferent() { + IcebergTableHandle v1 = IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", 1L, null, -1L); + IcebergTableHandle v2 = IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", 2L, null, -1L); + // WHY: a time-travel sys read (t$snapshots FOR VERSION AS OF 1) is a different read than + // VERSION AS OF 2, so the pin composes with sysTableName in identity (consistent with the + // existing iceberg handle, where the pin is already part of identity — unlike paimon). MUTATION: + // equals collapsing the pin on a sys handle -> v1.equals(v2) -> red. + Assertions.assertNotEquals(v1, v2); + } + + @Test + public void withSnapshotPreservesSysTableName() { + IcebergTableHandle sys = IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L); + IcebergTableHandle pinned = sys.withSnapshot(99L, null, 7L); + // WHY: withSnapshot is a copy factory used to thread a resolved time-travel pin in; it must NOT + // silently drop sysTableName, or a sys handle would degrade into a normal data-table handle + // (wrong schema/rows). Mirrors paimon's withScanOptions/withBranch, which preserve sysTableName. + // MUTATION: withSnapshot rebuilding with a null sysTableName -> pinned.isSystemTable() false -> red. + Assertions.assertTrue(pinned.isSystemTable()); + Assertions.assertEquals("snapshots", pinned.getSysTableName()); + Assertions.assertEquals(99L, pinned.getSnapshotId()); + } + + @Test + public void sysTableNameSurvivesJavaSerializationRoundTrip() throws Exception { + IcebergTableHandle original = IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", 42L, null, 3L); + + // Real Java serialization round-trip (the FE/BE / plan-reuse wire mechanism). + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(original); + } + IcebergTableHandle restored; + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + restored = (IcebergTableHandle) ois.readObject(); + } + + // WHY: the JNI sys-table read happens on a DESERIALIZED handle, so sysTableName must be + // non-transient — otherwise the restored handle would forget it is a sys table (and its pin), + // silently reading the base data table at the latest version. MUTATION: marking sysTableName + // transient -> restored.isSystemTable() false -> red. + Assertions.assertTrue(restored.isSystemTable()); + Assertions.assertEquals("snapshots", restored.getSysTableName()); + Assertions.assertEquals(42L, restored.getSnapshotId()); + Assertions.assertEquals(original, restored); + } + + @Test + public void toStringIncludesSysName() { + IcebergTableHandle sys = IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L); + // WHY: toString is used in plan dumps / error messages; a sys handle must render its sys name so + // a `db.t$snapshots` read is distinguishable from `db.t`. MUTATION: toString omitting sysTableName + // -> assertion below fails. + Assertions.assertTrue(sys.toString().contains("snapshots"), + "toString must surface the sys-table name, was: " + sys); + } + + @Test + public void coordinatesArePartOfIdentity() { + // WHY (T07 gap-fill): the handle is a plan-cache map key, so the BASE coordinates (dbName / + // tableName) MUST participate in equals/hashCode — otherwise db1.t1$snapshots would collide with + // db1.t2$snapshots (or db1.t1 with db2.t1), serving one table's plan for another. Every other + // identity test fixes coords to db1/t1, so a mutation dropping dbName or tableName from + // equals/hashCode would pass green. MUTATION: equals/hashCode omitting dbName -> the db2 asserts + // below fail; omitting tableName -> the t2 asserts fail. + Assertions.assertNotEquals( + IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L), + IcebergTableHandle.forSystemTable("db1", "t2", "snapshots", -1L, null, -1L), + "a sys handle on a different base table must not be equal"); + Assertions.assertNotEquals( + IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L), + IcebergTableHandle.forSystemTable("db2", "t1", "snapshots", -1L, null, -1L), + "a sys handle in a different base db must not be equal"); + Assertions.assertNotEquals(new IcebergTableHandle("db1", "t1"), new IcebergTableHandle("db2", "t1"), + "a bare handle in a different db must not be equal"); + Assertions.assertNotEquals(new IcebergTableHandle("db1", "t1"), new IcebergTableHandle("db1", "t2"), + "a bare handle on a different table must not be equal"); + } + + @Test + public void toStringOfPinnedSysHandleRendersSeparatorAndPin() { + IcebergTableHandle sys = IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", 42L, null, 3L); + // WHY (T07 gap-fill): the only existing toString test uses an UN-pinned sys handle and a + // substring("snapshots") assertion, so neither the '$' separator nor the hasSnapshotPin() render + // branch is exercised. A pinned sys handle (time-travel) must render BOTH `t1$snapshots` and the + // pin, so a plan dump distinguishes `t1$snapshots FOR VERSION AS OF 42` from a latest read. + // MUTATION: dropping the '$' separator -> no "t1$snapshots" -> red; dropping the pin branch on a + // sys handle -> no "snapshotId=42" -> red. + String s = sys.toString(); + Assertions.assertTrue(s.contains("t1$snapshots"), "must render the '$'-joined sys name, was: " + s); + Assertions.assertTrue(s.contains("snapshotId=42"), "must render the snapshot pin, was: " + s); + } + + // ==================== WS-REWRITE R2: rewrite_data_files per-group file scope ==================== + + @Test + public void bareHandleHasNoRewriteScope() { + IcebergTableHandle h = new IcebergTableHandle("db1", "t1"); + // WHY: every non-rewrite scan must carry NO scope so it reads the whole (filtered) table. The scan + // provider treats a non-null scope as "keep ONLY these files", so a default of empty (not null) would + // make a normal scan silently return zero files. MUTATION: defaulting rewriteFileScope to an empty set + // -> getRewriteFileScope non-null -> red. + Assertions.assertNull(h.getRewriteFileScope()); + } + + @Test + public void withRewriteFileScopeCarriesRawPathsAndIsPartOfIdentity() { + IcebergTableHandle bare = new IcebergTableHandle("db1", "t1"); + IcebergTableHandle scoped = bare.withRewriteFileScope( + ImmutableSet.of("oss://b/db/t1/f1.parquet", "oss://b/db/t1/f3.parquet")); + // WHY: the scope is a rewrite group's bin-packed file set (raw iceberg paths); the getter must return + // exactly those so the scan keeps only them. MUTATION: storing null/empty -> getter wrong -> red. + Assertions.assertEquals( + ImmutableSet.of("oss://b/db/t1/f1.parquet", "oss://b/db/t1/f3.parquet"), + scoped.getRewriteFileScope()); + // WHY: a scoped scan is a DIFFERENT read than the full scan and than a differently-scoped scan, so the + // scope is part of the handle identity (consistent with the snapshot pin). MUTATION: equals/hashCode + // ignoring rewriteFileScope -> bare.equals(scoped) or the two distinct scopes equal -> red. + Assertions.assertNotEquals(bare, scoped); + IcebergTableHandle sameScope = new IcebergTableHandle("db1", "t1").withRewriteFileScope( + ImmutableSet.of("oss://b/db/t1/f1.parquet", "oss://b/db/t1/f3.parquet")); + Assertions.assertEquals(scoped, sameScope); + Assertions.assertEquals(scoped.hashCode(), sameScope.hashCode()); + Assertions.assertNotEquals(scoped, + new IcebergTableHandle("db1", "t1").withRewriteFileScope( + ImmutableSet.of("oss://b/db/t1/f1.parquet"))); + } + + @Test + public void rewriteScopeAndSnapshotPinCompose() { + // WHY: the rewrite driver pins the starting snapshot AND scopes the file set; applying one copy factory + // must not drop the other carrier, else the group would scan the wrong snapshot or the wrong files. + // MUTATION: withSnapshot rebuilding without rewriteFileScope -> scope lost -> red. + IcebergTableHandle scopedThenPinned = new IcebergTableHandle("db1", "t1") + .withRewriteFileScope(ImmutableSet.of("oss://b/db/t1/f1.parquet")) + .withSnapshot(42L, null, 3L); + Assertions.assertEquals(ImmutableSet.of("oss://b/db/t1/f1.parquet"), + scopedThenPinned.getRewriteFileScope()); + Assertions.assertEquals(42L, scopedThenPinned.getSnapshotId()); + } + + @Test + public void rewriteScopeSurvivesSerializationRoundTrip() throws Exception { + IcebergTableHandle original = new IcebergTableHandle("db1", "t1") + .withRewriteFileScope(ImmutableSet.of("oss://b/db/t1/f1.parquet", "oss://b/db/t1/f2.parquet")); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(original); + } + IcebergTableHandle restored; + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + restored = (IcebergTableHandle) ois.readObject(); + } + // WHY: the handle is the plan-reuse / FE-BE wire object, so the scope (an ImmutableSet field) must + // survive serialization or a deserialized rewrite handle would forget its scope and scan the whole + // table. MUTATION: marking rewriteFileScope transient -> restored scope null -> red. + Assertions.assertEquals(original.getRewriteFileScope(), restored.getRewriteFileScope()); + Assertions.assertEquals(original, restored); + } + + // ==================== M-4: Top-N lazy-materialization signal ==================== + + @Test + public void bareHandleIsNotTopnLazyMaterialize() { + IcebergTableHandle h = new IcebergTableHandle("db1", "t1"); + // WHY: a normal read must NOT be flagged topn, or the scan provider would build the full-schema dict + // (losing the pruned-column optimization) for every query. MUTATION: defaulting topnLazyMaterialize + // to true -> isTopnLazyMaterialize() true -> red. + Assertions.assertFalse(h.isTopnLazyMaterialize()); + } + + @Test + public void withTopnLazyMaterializeSetsFlagAndIsPartOfIdentity() { + IcebergTableHandle bare = new IcebergTableHandle("db1", "t1"); + IcebergTableHandle topn = bare.withTopnLazyMaterialize(true); + // WHY: the flag changes the BE-facing field-id dictionary (pruned vs full), so it is part of the + // handle identity (consistent with the snapshot pin / rewrite scope). MUTATION: withTopnLazyMaterialize + // not storing the flag -> isTopnLazyMaterialize false -> red; equals/hashCode ignoring it -> + // bare.equals(topn) -> red. + Assertions.assertTrue(topn.isTopnLazyMaterialize()); + Assertions.assertNotEquals(bare, topn); + IcebergTableHandle sameTopn = new IcebergTableHandle("db1", "t1").withTopnLazyMaterialize(true); + Assertions.assertEquals(topn, sameTopn); + Assertions.assertEquals(topn.hashCode(), sameTopn.hashCode()); + } + + @Test + public void topnLazyMaterializeComposesWithPinAndScope() { + // WHY: a time-travel + rewrite + topn scan applies all three copy factories; none must drop another + // carrier, else the scan reads the wrong snapshot/files or loses the full-schema dict. MUTATION: + // withSnapshot or withRewriteFileScope rebuilding without topnLazyMaterialize -> flag lost -> red. + IcebergTableHandle h = new IcebergTableHandle("db1", "t1") + .withTopnLazyMaterialize(true) + .withSnapshot(42L, null, 3L) + .withRewriteFileScope(ImmutableSet.of("oss://b/db/t1/f1.parquet")); + Assertions.assertTrue(h.isTopnLazyMaterialize()); + Assertions.assertEquals(42L, h.getSnapshotId()); + Assertions.assertEquals(ImmutableSet.of("oss://b/db/t1/f1.parquet"), h.getRewriteFileScope()); + } + +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTimeUtilsTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTimeUtilsTest.java new file mode 100644 index 00000000000000..59f1d0672a73d3 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTimeUtilsTest.java @@ -0,0 +1,124 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorSession; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.DateTimeException; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Map; + +/** + * Tests for {@link IcebergTimeUtils}, the self-contained session time-zone + datetime parsing shared by + * the scan provider (timestamptz pushdown, T02) and the metadata layer ({@code FOR TIME AS OF}, T07). + */ +public class IcebergTimeUtilsTest { + + @Test + public void datetimeToMillisInterpretsLocalTimeInSessionZone() { + // WHY: FOR TIME AS OF '2023-06-15 10:30:00' must resolve the snapshot at that wall-clock time IN THE + // SESSION ZONE; a wrong zone selects the WRONG snapshot (silently wrong rows). Asia/Shanghai is UTC+8, + // so 10:30:00 local == 02:30:00Z. Oracle is an independent ISO-instant, not the same formatter. + // MUTATION: applying UTC instead of the session zone -> 10:30:00Z != 02:30:00Z -> red. + long expected = Instant.parse("2023-06-15T02:30:00Z").toEpochMilli(); + Assertions.assertEquals(expected, + IcebergTimeUtils.datetimeToMillis("2023-06-15 10:30:00", ZoneId.of("Asia/Shanghai"))); + } + + @Test + public void datetimeToMillisUtcZone() { + long expected = Instant.parse("2023-06-15T10:30:00Z").toEpochMilli(); + Assertions.assertEquals(expected, + IcebergTimeUtils.datetimeToMillis("2023-06-15 10:30:00", ZoneOffset.UTC)); + } + + @Test + public void datetimeToMillisFailsLoudOnMalformedString() { + // WHY: a malformed datetime is a user mistake; legacy returned -1 and the caller threw + // DateTimeException("can't parse time"). Fail loud (never silently degrade to a wrong/0 snapshot). + DateTimeException e = Assertions.assertThrows(DateTimeException.class, + () -> IcebergTimeUtils.datetimeToMillis("not-a-time", ZoneOffset.UTC)); + Assertions.assertTrue(e.getMessage().contains("can't parse time"), + "must surface the legacy 'can't parse time' message, was: " + e.getMessage()); + } + + @Test + public void resolveSessionZoneHonorsCstDorisAlias() { + // WHY: Doris stores SET time_zone='CST' verbatim; a plain ZoneId.of("CST") throws (CST is a SHORT_ID). + // Legacy resolves CST -> Asia/Shanghai (NOT America/Chicago). MUTATION: dropping the alias map -> UTC + // fallback -> not Asia/Shanghai -> red. + Assertions.assertEquals(ZoneId.of("Asia/Shanghai"), + IcebergTimeUtils.resolveSessionZone(session("CST"))); + } + + @Test + public void resolveSessionZoneNullAndBlankFallBackToUtc() { + Assertions.assertEquals(ZoneOffset.UTC, IcebergTimeUtils.resolveSessionZone(null)); + Assertions.assertEquals(ZoneOffset.UTC, IcebergTimeUtils.resolveSessionZone(session(""))); + Assertions.assertEquals(ZoneOffset.UTC, IcebergTimeUtils.resolveSessionZone(session("NOPE/ZZZ"))); + } + + private static ConnectorSession session(String tz) { + return new ConnectorSession() { + @Override + public String getQueryId() { + return "q"; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return tz; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public String getCatalogName() { + return "c"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return java.util.Collections.emptyMap(); + } + }; + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTypeMappingReadTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTypeMappingReadTest.java new file mode 100644 index 00000000000000..cf229143afccc5 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTypeMappingReadTest.java @@ -0,0 +1,265 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorType; + +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; + +/** + * Read-direction parity tests for {@link IcebergTypeMapping#fromIcebergType}, pinning the + * Iceberg->Doris type mapping byte-for-byte against legacy + * {@code IcebergUtils.icebergPrimitiveTypeToDorisType} / {@code icebergTypeToDorisType} (fe-core). + * Mirrors the paimon connector's {@code PaimonTypeMappingReadTest}. + * + *

    The emitted {@link ConnectorType} type names must be ones that + * {@code ConnectorColumnConverter.convertScalarType} actually recognizes, otherwise a column silently + * degrades to UNSUPPORTED. This is exactly the {@code TIMESTAMPTZ} (P6-T08) fix: the converter has a + * {@code TIMESTAMPTZ} case (mapping to {@code ScalarType.createTimeStampTzType(precision)}) but no + * {@code TIMESTAMPTZV2} case, so the connector must emit the former. + */ +public class IcebergTypeMappingReadTest { + + private static final int MS6 = 6; + + /** Map with both mapping flags OFF (the production default). */ + private static ConnectorType mapOff(Type t) { + return IcebergTypeMapping.fromIcebergType(t, false, false); + } + + /** Map with both mapping flags ON (varbinary + timestamp-tz). */ + private static ConnectorType mapOn(Type t) { + return IcebergTypeMapping.fromIcebergType(t, true, true); + } + + private static void assertScalar(ConnectorType actual, String name, int precision, int scale) { + Assertions.assertEquals(name, actual.getTypeName()); + Assertions.assertEquals(precision, actual.getPrecision(), "precision of " + name); + Assertions.assertEquals(scale, actual.getScale(), "scale of " + name); + } + + // --------------------------------------------------------------------- + // Flag-independent primitives — must match legacy icebergPrimitiveTypeToDorisType exactly. + // --------------------------------------------------------------------- + + @Test + public void flagIndependentPrimitivesMatchLegacy() { + // WHY: these eight Iceberg primitives map to a fixed Doris type regardless of either mapping + // flag; legacy returns Type.BOOLEAN/INT/BIGINT/FLOAT/DOUBLE/STRING, DateV2, and DatetimeV2(6) + // for a no-zone TIMESTAMP. The connector must reproduce the SAME Doris type names so DESCRIBE / + // SHOW CREATE TABLE report identically. MUTATION: renaming any (e.g. INTEGER->"INTEGER") -> red. + Assertions.assertEquals("BOOLEAN", mapOff(Types.BooleanType.get()).getTypeName()); + Assertions.assertEquals("INT", mapOff(Types.IntegerType.get()).getTypeName()); + Assertions.assertEquals("BIGINT", mapOff(Types.LongType.get()).getTypeName()); + Assertions.assertEquals("FLOAT", mapOff(Types.FloatType.get()).getTypeName()); + Assertions.assertEquals("DOUBLE", mapOff(Types.DoubleType.get()).getTypeName()); + Assertions.assertEquals("STRING", mapOff(Types.StringType.get()).getTypeName()); + Assertions.assertEquals("DATEV2", mapOff(Types.DateType.get()).getTypeName()); + + // A no-zone TIMESTAMP is DATETIMEV2(6) whether or not the tz flag is on (the flag only affects + // zoned timestamps). Legacy: createDatetimeV2Type(ICEBERG_DATETIME_SCALE_MS=6). + assertScalar(mapOff(Types.TimestampType.withoutZone()), "DATETIMEV2", MS6, 0); + assertScalar(mapOn(Types.TimestampType.withoutZone()), "DATETIMEV2", MS6, 0); + + // TIME has no Doris analogue -> UNSUPPORTED (legacy Type.UNSUPPORTED). + Assertions.assertEquals("UNSUPPORTED", mapOff(Types.TimeType.get()).getTypeName()); + } + + @Test + public void unknownAndV3TypesDegradeToUnsupportedByDesign() { + // WHY (user decision 2026-07-13, DV-051): iceberg types Doris cannot represent — the v3 primitives + // TIMESTAMP_NANO / GEOMETRY / GEOGRAPHY / UNKNOWN and the non-primitive VARIANT — must map to + // UNSUPPORTED WITHOUT throwing, so the table still loads and only the exotic column is + // present-but-unqueryable. This deliberately DIVERGES from legacy fe-core, which threw + // IllegalArgumentException("Cannot transform unknown type") at schema-load and failed the whole table. + // This test PINS the graceful-degradation choice: MUTATION making either default arm throw -> red, + // surfacing that the accepted deviation was reverted. (The write direction toIcebergPrimitive still + // throws — see toIcebergPrimitiveRejectsUnrepresentableTypes if present / the connector's CREATE path.) + Assertions.assertEquals("UNSUPPORTED", mapOff(Types.TimestampNanoType.withoutZone()).getTypeName()); + Assertions.assertEquals("UNSUPPORTED", mapOff(Types.TimestampNanoType.withZone()).getTypeName()); + Assertions.assertEquals("UNSUPPORTED", mapOff(Types.GeometryType.crs84()).getTypeName()); + Assertions.assertEquals("UNSUPPORTED", mapOff(Types.GeographyType.crs84()).getTypeName()); + Assertions.assertEquals("UNSUPPORTED", mapOff(Types.UnknownType.get()).getTypeName()); + // VARIANT is NOT a primitive (falls to the nested-switch default); legacy mapped it to UNSUPPORTED + // too, so this stays parity while the primitives above are the intentional divergence. + Assertions.assertEquals("UNSUPPORTED", mapOff(Types.VariantType.get()).getTypeName()); + // The mapping flags do not rescue an unrepresentable type (both arms are flag-independent). + Assertions.assertEquals("UNSUPPORTED", mapOn(Types.GeometryType.crs84()).getTypeName()); + } + + @Test + public void decimalCarriesPrecisionAndScale() { + // WHY: Iceberg DECIMAL(p,s) maps to Doris DECIMALV3(p,s) carrying both p and s verbatim; legacy + // createDecimalV3Type(precision, scale). MUTATION: dropping scale, or emitting DECIMALV2 -> red. + assertScalar(mapOff(Types.DecimalType.of(20, 4)), "DECIMALV3", 20, 4); + } + + // --------------------------------------------------------------------- + // enable.mapping.varbinary toggle — UUID / BINARY / FIXED + // --------------------------------------------------------------------- + + @Test + public void varbinaryFlagOffMapsToStringOrChar() { + // WHY: with the varbinary flag OFF, UUID and BINARY fall back to STRING and a FIXED(n) becomes + // CHAR(n) — legacy returns Type.STRING / Type.STRING / createCharType(length). This is the + // compatibility default. MUTATION: emitting VARBINARY when the flag is off -> red. + Assertions.assertEquals("STRING", mapOff(Types.UUIDType.get()).getTypeName()); + Assertions.assertEquals("STRING", mapOff(Types.BinaryType.get()).getTypeName()); + assertScalar(mapOff(Types.FixedType.ofLength(12)), "CHAR", 12, 0); + } + + @Test + public void varbinaryFlagOnMapsToVarbinaryWithLegacyLengths() { + // WHY: with the varbinary flag ON, UUID -> VARBINARY(16) and FIXED(n) -> VARBINARY(n); the + // lengths are load-bearing — legacy createVarbinaryType(16 / fixed.length()). MUTATION: wrong + // length, or staying STRING/CHAR under the flag -> red. + assertScalar(mapOn(Types.UUIDType.get()), "VARBINARY", 16, 0); + assertScalar(mapOn(Types.FixedType.ofLength(12)), "VARBINARY", 12, 0); + + // WHY: an Iceberg BINARY is UNBOUNDED, and legacy maps it to the max-length varbinary — + // createVarbinaryType(VarBinaryType.MAX_VARBINARY_LENGTH == ScalarType.MAX_VARBINARY_LENGTH == + // 0x7fffffff). The connector must NOT stamp a concrete length like 65535 (that renders a + // different DESCRIBE / SHOW CREATE type than legacy). Emitting precision -1 (no explicit length) + // makes ConnectorColumnConverter fall to its default branch + // createVarbinaryType(ScalarType.MAX_VARBINARY_LENGTH) — byte-identical to legacy. + // MUTATION: stamping VARBINARY(65535) (the pre-fix divergence) -> precision != -1 -> red. + ConnectorType binOn = mapOn(Types.BinaryType.get()); + Assertions.assertEquals("VARBINARY", binOn.getTypeName()); + Assertions.assertEquals(-1, binOn.getPrecision(), + "unbounded BINARY must carry no explicit length (-1) so the converter applies the " + + "shared MAX_VARBINARY_LENGTH, matching legacy"); + } + + // --------------------------------------------------------------------- + // enable.mapping.timestamp_tz toggle — THE P6-T08 fix (TIMESTAMPTZ, not TIMESTAMPTZV2) + // --------------------------------------------------------------------- + + @Test + public void zonedTimestampWithFlagOnMapsToConverterRecognizedTimestamptz() { + // WHY: a zoned Iceberg TIMESTAMP (shouldAdjustToUTC) with the tz flag ON must map to a type the + // converter actually understands. ConnectorColumnConverter recognizes "TIMESTAMPTZ" (-> + // createTimeStampTzType(precision)) but NOT "TIMESTAMPTZV2"; emitting the latter silently + // degrades the column to UNSUPPORTED. Legacy createTimeStampTzType(ICEBERG_DATETIME_SCALE_MS=6) + // => the connector must emit TIMESTAMPTZ with precision 6. MUTATION: reverting to TIMESTAMPTZV2 + // (the pre-T08 bug) -> red. + assertScalar(mapOn(Types.TimestampType.withZone()), "TIMESTAMPTZ", MS6, 0); + } + + @Test + public void zonedTimestampWithFlagOffStaysDatetimev2() { + // WHY: the tz flag gates the TIMESTAMPTZ mapping; with it OFF even a zoned timestamp must stay + // DATETIMEV2(6) (legacy createDatetimeV2Type(6)). This guards a fix that accidentally promotes + // zoned timestamps unconditionally. MUTATION: emitting TIMESTAMPTZ when the flag is off -> red. + assertScalar(IcebergTypeMapping.fromIcebergType(Types.TimestampType.withZone(), false, false), + "DATETIMEV2", MS6, 0); + } + + // --------------------------------------------------------------------- + // Nested types — ARRAY / MAP / STRUCT recurse with the same flags + // --------------------------------------------------------------------- + + @Test + public void arrayRecursesElementType() { + // WHY: an Iceberg LIST maps to a Doris ARRAY whose element is the mapped element type, threading + // the flags through. Legacy ArrayType.create(icebergTypeToDorisType(element, ...)). Here the + // zoned-timestamp element + tz flag proves both recursion and flag propagation reach the leaf. + // MUTATION: not recursing (raw element), or dropping the flags on recursion -> red. + Types.ListType list = Types.ListType.ofOptional(1, Types.TimestampType.withZone()); + ConnectorType arr = mapOn(list); + Assertions.assertEquals("ARRAY", arr.getTypeName()); + Assertions.assertEquals(1, arr.getChildren().size()); + assertScalar(arr.getChildren().get(0), "TIMESTAMPTZ", MS6, 0); + } + + @Test + public void mapRecursesKeyAndValueTypes() { + // WHY: an Iceberg MAP maps to a Doris MAP with both key and value mapped (flags threaded). + // Legacy new MapType(mapped(key), mapped(value)). MUTATION: swapping/dropping a child, or not + // recursing -> red. + Types.MapType map = Types.MapType.ofOptional( + 1, 2, Types.StringType.get(), Types.BinaryType.get()); + ConnectorType m = mapOn(map); + Assertions.assertEquals("MAP", m.getTypeName()); + Assertions.assertEquals(2, m.getChildren().size()); + Assertions.assertEquals("STRING", m.getChildren().get(0).getTypeName()); + // The unbounded-BINARY value recurses to VARBINARY with no explicit length (-1 -> converter + // applies the shared MAX_VARBINARY_LENGTH, matching legacy). + Assertions.assertEquals("VARBINARY", m.getChildren().get(1).getTypeName()); + Assertions.assertEquals(-1, m.getChildren().get(1).getPrecision()); + } + + @Test + public void structRecursesFieldsPreservingNamesAndOrder() { + // WHY: an Iceberg STRUCT maps to a Doris STRUCT preserving field names, order, and mapped field + // types (flags threaded). Legacy builds StructField(name, mapped(type)) per field in order. + // MUTATION: reordering, dropping names, or not recursing field types -> red. + Types.StructType struct = Types.StructType.of( + Types.NestedField.optional(1, "a", Types.IntegerType.get()), + Types.NestedField.optional(2, "b", Types.TimestampType.withZone())); + ConnectorType s = mapOn(struct); + Assertions.assertEquals("STRUCT", s.getTypeName()); + List names = s.getFieldNames(); + Assertions.assertEquals(List.of("a", "b"), names); + Assertions.assertEquals("INT", s.getChildren().get(0).getTypeName()); + assertScalar(s.getChildren().get(1), "TIMESTAMPTZ", MS6, 0); + } + + @Test + public void nestedFieldIdsCarriedForBeFieldIdScan() { + // WHY (H-10 L3): post-flip iceberg nested-column pruning requires the per-field iceberg field-id to + // reach the Doris column tree (legacy IcebergUtils.updateIcebergColumnUniqueId set them recursively). + // fromIcebergType carries them on ConnectorType.childrenFieldIds so ConnectorColumnConverter can stamp + // the child Column uniqueIds the BE field-id scan path matches a pruned nested leaf by; an un-stamped + // (-1) leaf is skipped and reads NULL. MUTATION: dropping any withChildrenFieldIds(...) -> + // getChildFieldId returns -1 -> the e2e (iceberg_complex_type / struct schema-evolution) reds. + + // STRUCT: each field's id, parallel to the field types in order. + Types.StructType struct = Types.StructType.of( + Types.NestedField.optional(3, "a", Types.IntegerType.get()), + Types.NestedField.optional(4, "b", Types.StringType.get())); + ConnectorType s = mapOff(struct); + Assertions.assertEquals(3, s.getChildFieldId(0), "struct field a carries iceberg field-id 3"); + Assertions.assertEquals(4, s.getChildFieldId(1), "struct field b carries iceberg field-id 4"); + + // LIST: the element field-id (ListType.ofOptional(elementId, type)). + Types.ListType list = Types.ListType.ofOptional(7, Types.IntegerType.get()); + Assertions.assertEquals(7, mapOff(list).getChildFieldId(0), "array element carries iceberg field-id 7"); + + // MAP: key id then value id (MapType.ofOptional(keyId, valueId, ...)). + Types.MapType map = Types.MapType.ofOptional(8, 9, Types.StringType.get(), Types.IntegerType.get()); + ConnectorType m = mapOff(map); + Assertions.assertEquals(8, m.getChildFieldId(0), "map key carries iceberg field-id 8"); + Assertions.assertEquals(9, m.getChildFieldId(1), "map value carries iceberg field-id 9"); + + // Deep nesting: struct (id 11)>. The inner struct's own childrenFieldIds are + // set by the recursion, proving EVERY level carries ids (a renamed-or-pruned deep leaf matches by id). + Types.StructType deep = Types.StructType.of( + Types.NestedField.optional(11, "s2", Types.StructType.of( + Types.NestedField.optional(12, "c", Types.IntegerType.get())))); + ConnectorType deepCt = mapOff(deep); + Assertions.assertEquals(11, deepCt.getChildFieldId(0)); + ConnectorType innerStruct = deepCt.getChildren().get(0); + Assertions.assertEquals(12, innerStruct.getChildFieldId(0), + "deep nested field c carries iceberg field-id 12"); + } +} 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 new file mode 100644 index 00000000000000..caf52891f480b4 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java @@ -0,0 +1,1274 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.connector.api.handle.ConnectorWriteHandle; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.write.ConnectorSinkPlan; +import org.apache.doris.connector.api.write.ConnectorWritePartitionField; +import org.apache.doris.connector.api.write.ConnectorWritePartitionSpec; +import org.apache.doris.connector.api.write.ConnectorWriteSortColumn; +import org.apache.doris.connector.spi.ConnectorBrokerAddress; +import org.apache.doris.filesystem.FileSystemType; +import org.apache.doris.filesystem.properties.BackendStorageKind; +import org.apache.doris.filesystem.properties.BackendStorageProperties; +import org.apache.doris.filesystem.properties.StorageKind; +import org.apache.doris.filesystem.properties.StorageProperties; +import org.apache.doris.thrift.TDataSinkType; +import org.apache.doris.thrift.TFileCompressType; +import org.apache.doris.thrift.TFileContent; +import org.apache.doris.thrift.TFileFormatType; +import org.apache.doris.thrift.TFileType; +import org.apache.doris.thrift.TIcebergDeleteFileDesc; +import org.apache.doris.thrift.TIcebergDeleteSink; +import org.apache.doris.thrift.TIcebergMergeSink; +import org.apache.doris.thrift.TIcebergRewritableDeleteFileSet; +import org.apache.doris.thrift.TIcebergTableSink; +import org.apache.doris.thrift.TIcebergWriteType; +import org.apache.doris.thrift.TNetworkAddress; +import org.apache.doris.thrift.TSortField; +import org.apache.doris.thrift.TSortInfo; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; +import org.apache.iceberg.NullOrder; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.PartitionSpecParser; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SchemaParser; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Pins {@link IcebergWritePlanProvider#planWrite} for INSERT/OVERWRITE against legacy + * {@code planner.IcebergTableSink.bindDataSink} expected values (real {@link InMemoryCatalog}, + * no Mockito). + * + *

    WHY this matters: T06 moves the {@code TIcebergTableSink} assembly out of the fe-core + * planner into the connector. The sink Thrift goes to BE unchanged (C2, zero BE change), so every + * field must be byte-identical to the legacy sink: schema-json, partition specs, sort info, file + * format/compression, the vended-aware hadoop config, and the normalized output path. A + * parity-by-omission (a dropped field) silently corrupts writes once iceberg cuts over at P6.6.

    + */ +public class IcebergWritePlanProviderTest { + + private static final Schema SCHEMA = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get())); + + private static final Map NON_REST_PROPS = + Collections.singletonMap("iceberg.catalog.type", "hadoop"); + + private static InMemoryCatalog freshCatalog() { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + return catalog; + } + + /** A partitioned (identity id), sorted (id ASC NULLS FIRST) parquet+zstd table at a known oss:// data path. */ + private static Table partitionedSortedTable(InMemoryCatalog catalog) { + Map tableProps = new HashMap<>(); + tableProps.put("write.format.default", "parquet"); + tableProps.put("write.parquet.compression-codec", "zstd"); + tableProps.put("write.data.path", "oss://bucket/wh/db1/t1/data"); + Table table = catalog.createTable(TableIdentifier.of("db1", "t1"), SCHEMA, + PartitionSpec.builderFor(SCHEMA).identity("id").build(), tableProps); + table.replaceSortOrder().asc("id", NullOrder.NULLS_FIRST).commit(); + return catalog.loadTable(TableIdentifier.of("db1", "t1")); + } + + private static Table unpartitionedUnsortedTable(InMemoryCatalog catalog) { + Map tableProps = new HashMap<>(); + tableProps.put("write.format.default", "parquet"); + tableProps.put("write.data.path", "oss://bucket/wh/db1/t2/data"); + return catalog.createTable(TableIdentifier.of("db1", "t2"), SCHEMA, + PartitionSpec.unpartitioned(), tableProps); + } + + /** A format-version 3 table (exercises the merge sink's row-lineage schema append + v3 delete path). */ + private static Table formatVersionThreeTable(InMemoryCatalog catalog) { + Map tableProps = new HashMap<>(); + tableProps.put("write.format.default", "parquet"); + tableProps.put("write.data.path", "oss://bucket/wh/db1/tv3/data"); + tableProps.put("format-version", "3"); + return catalog.createTable(TableIdentifier.of("db1", "tv3"), SCHEMA, + PartitionSpec.unpartitioned(), tableProps); + } + + private static RecordingConnectorContext contextWithStorage() { + RecordingConnectorContext ctx = new RecordingConnectorContext(); + // Static catalog creds in BE-canonical form (AWS_*), the form the write sink ships to BE — NOT the + // fs.s3a.* hadoop form (s3_util.cpp convert_properties_to_s3_conf reads only AWS_*). Fed through the + // typed fe-filesystem seam (getStorageProperties() -> toBackendProperties().toMap()) that the write + // now derives its BE creds from (design S3), the SAME source the scan path uses. + ctx.storageProperties = Collections.singletonList( + fakeBackendStorage(Collections.singletonMap("AWS_ACCESS_KEY", "AK123"))); + ctx.backendFileType = TFileType.FILE_S3; + return ctx; + } + + /** A fe-filesystem {@link StorageProperties} whose toBackendProperties().toMap() returns the given + * BE-canonical map — mirrors how a real object-store binding hands BE creds to the connector, and how + * the write path (design S3) sources its static creds. Adapted verbatim from the scan test. */ + private static StorageProperties fakeBackendStorage(Map beMap) { + BackendStorageProperties backend = new BackendStorageProperties() { + @Override + public BackendStorageKind backendKind() { + return BackendStorageKind.S3_COMPATIBLE; + } + + @Override + public Map toMap() { + return beMap; + } + }; + return new StorageProperties() { + @Override + public String providerName() { + return "fake"; + } + + @Override + public StorageKind kind() { + return StorageKind.OBJECT_STORAGE; + } + + @Override + public FileSystemType type() { + return FileSystemType.S3; + } + + @Override + public Map rawProperties() { + return Collections.emptyMap(); + } + + @Override + public Map matchedProperties() { + return Collections.emptyMap(); + } + + @Override + public Optional toBackendProperties() { + return Optional.of(backend); + } + }; + } + + /** A context that resolves writes to a FILE_BROKER backend (ofs/gfs) with the given broker addresses. */ + private static RecordingConnectorContext contextWithBroker(List brokers) { + RecordingConnectorContext ctx = contextWithStorage(); + ctx.backendFileType = TFileType.FILE_BROKER; + ctx.brokerAddresses = brokers; + return ctx; + } + + private static IcebergWritePlanProvider providerFor(Table table, RecordingConnectorContext ctx) { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = table; + return new IcebergWritePlanProvider(NON_REST_PROPS, ops, ctx); + } + + /** A session that carries the bound iceberg connector transaction (the provider reads it). */ + private static WriteSession sessionFor(Table table, RecordingConnectorContext ctx) { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = table; + IcebergConnectorTransaction txn = new IcebergConnectorTransaction(42L, ops, ctx); + return new WriteSession(txn); + } + + private static TIcebergTableSink planSink(Table table, RecordingConnectorContext ctx, + ConnectorWriteHandle handle) { + ConnectorSinkPlan plan = providerFor(table, ctx).planWrite(sessionFor(table, ctx), handle); + Assertions.assertEquals(TDataSinkType.ICEBERG_TABLE_SINK, plan.getDataSink().getType()); + return plan.getDataSink().getIcebergTableSink(); + } + + // ───────────────────────────── INSERT: table-derived fields ───────────────────────────── + + @Test + public void planWriteBuildsInsertSinkWithTableDerivedFields() { + Table table = partitionedSortedTable(freshCatalog()); + TIcebergTableSink sink = planSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t1"))); + + Assertions.assertEquals("db1", sink.getDbName()); + Assertions.assertEquals("t1", sink.getTbName()); + Assertions.assertEquals(SchemaParser.toJson(table.schema()), sink.getSchemaJson(), + "schema-json must equal the legacy SchemaParser.toJson(table.schema()) (no v3 rewrite append)"); + Assertions.assertEquals(table.spec().specId(), sink.getPartitionSpecId()); + // WP-001: byte-equal the legacy partition-specs JSON, not just non-null. A garbled/dropped spec JSON + // silently corrupts partitioned writes once iceberg cuts over; the value is what BE reads back. + Assertions.assertEquals(Maps.transformValues(table.specs(), PartitionSpecParser::toJson), + sink.getPartitionSpecsJson(), + "partition-specs-json must byte-equal Maps.transformValues(table.specs(), PartitionSpecParser::toJson)"); + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, sink.getFileFormat()); + Assertions.assertEquals(TFileCompressType.ZSTD, sink.getCompressionType()); + Assertions.assertFalse(sink.isOverwrite()); + Assertions.assertFalse(sink.isSetStaticPartitionValues()); + } + + // ───────────────────────────── REWRITE: compaction sink (TIcebergTableSink) ───────────────────────────── + // + // WHY: post-cutover rewrite_data_files reuses the INSERT TIcebergTableSink dialect with two deltas vs + // INSERT, byte-identical to legacy planner.IcebergTableSink.bindDataSink under isRewriting: + // write_type=REWRITE and (fv>=3) the row-lineage schema append. Dormant until a connector rewrite + // producer is wired, so these pin the sink dialect directly via planWrite. + + @Test + public void planWriteRewriteSetsRewriteTypeAndKeepsInsertFields() { + // fv2 table: REWRITE reuses the INSERT baseline (db/tb/schema/partition/format) but stamps + // write_type=REWRITE; with no v3 row-lineage append the schema-json equals the plain table schema. + Table table = partitionedSortedTable(freshCatalog()); + TIcebergTableSink sink = planSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t1")).writeOperation(WriteOperation.REWRITE)); + + Assertions.assertEquals(TIcebergWriteType.REWRITE, sink.getWriteType(), + "a REWRITE write must stamp write_type=REWRITE so the BE routes to RewriteFiles semantics"); + Assertions.assertEquals("db1", sink.getDbName()); + Assertions.assertEquals("t1", sink.getTbName()); + Assertions.assertEquals(SchemaParser.toJson(table.schema()), sink.getSchemaJson(), + "a fv2 rewrite schema-json must equal the plain table schema (no v3 row-lineage append)"); + Assertions.assertEquals(table.spec().specId(), sink.getPartitionSpecId()); + Assertions.assertFalse(sink.isOverwrite()); + } + + @Test + public void planWriteRewriteFv3AppendsRowLineageSchema() { + Table table = formatVersionThreeTable(freshCatalog()); + TIcebergTableSink sink = planSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "tv3")).writeOperation(WriteOperation.REWRITE)); + + Assertions.assertEquals(TIcebergWriteType.REWRITE, sink.getWriteType()); + Assertions.assertTrue(sink.getSchemaJson().contains("_row_id"), + "fv3 rewrite schema-json must include the row-lineage _row_id field (legacy appendRowLineageFieldsForV3)"); + Assertions.assertTrue(sink.getSchemaJson().contains("_last_updated_sequence_number"), + "fv3 rewrite schema-json must include the row-lineage _last_updated_sequence_number field"); + } + + @Test + public void planWriteRewriteRejectsOverwrite() { + // REWRITE is a compaction, never a user INSERT OVERWRITE; the BE writer rejects the pairing, so the + // connector fails loud rather than emit a sink with both REWRITE write-type and overwrite=true. + Table table = partitionedSortedTable(freshCatalog()); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> planSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t1")) + .writeOperation(WriteOperation.REWRITE).overwrite(true))); + Assertions.assertTrue(ex.getMessage().contains("overwrite"), + "the rewrite-vs-overwrite rejection must name the offending overwrite flag"); + } + + @Test + public void planWriteDataLocationFallsBackToObjectStoreThenFolderLocation() { + // WP-007/parity: dataLocation cascades WRITE_DATA_LOCATION -> (OBJECT_STORE_ENABLED ? OBJECT_STORE_PATH) + // -> WRITE_FOLDER_STORAGE_LOCATION -> {table.location}/data. Every other test sets WRITE_DATA_LOCATION, so + // the two object-store / folder-storage fallbacks (which a misordered cascade would silently swap) were + // never exercised. The resolved path is scheme-normalized (oss:// -> s3://) just like WRITE_DATA_LOCATION. + + // (a) object-store path wins when enabled and no write.data.path is set. + Table objStore = unpartitionedTableWith("obj", ImmutableMap.of( + "write.format.default", "parquet", + TableProperties.OBJECT_STORE_ENABLED, "true", + TableProperties.OBJECT_STORE_PATH, "oss://bucket/wh/db1/obj/objstore")); + Assertions.assertEquals("s3://bucket/wh/db1/obj/objstore", + planSink(objStore, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "obj"))).getOutputPath()); + + // (b) folder-storage location wins when object-store is disabled and no write.data.path is set. + Table folder = unpartitionedTableWith("fold", ImmutableMap.of( + "write.format.default", "parquet", + TableProperties.WRITE_FOLDER_STORAGE_LOCATION, "oss://bucket/wh/db1/fold/folder")); + Assertions.assertEquals("s3://bucket/wh/db1/fold/folder", + planSink(folder, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "fold"))).getOutputPath()); + } + + @Test + public void planWriteMapsFileFormatAndCompressionCodecVariety() { + // WP-005 / WP-009 / parity: only parquet+zstd is otherwise exercised, yet toTFileFormatType + + // toTFileCompressType map ORC and seven other codecs. A single enum mis-map (e.g. lz4 -> LZO, or + // ORC -> PARQUET) silently corrupts every write in that format. Pin a representative ORC + non-zstd matrix. + assertFormatAndCodec("orc", TableProperties.ORC_COMPRESSION, "zlib", + TFileFormatType.FORMAT_ORC, TFileCompressType.ZLIB); + assertFormatAndCodec("parquet", TableProperties.PARQUET_COMPRESSION, "snappy", + TFileFormatType.FORMAT_PARQUET, TFileCompressType.SNAPPYBLOCK); + assertFormatAndCodec("parquet", TableProperties.PARQUET_COMPRESSION, "lz4", + TFileFormatType.FORMAT_PARQUET, TFileCompressType.LZ4BLOCK); + } + + private void assertFormatAndCodec(String format, String codecKey, String codec, + TFileFormatType expectedFormat, TFileCompressType expectedCompress) { + Table table = unpartitionedTableWith("fmt", ImmutableMap.of( + "write.format.default", format, codecKey, codec, "write.data.path", "oss://bucket/wh/db1/fmt/data")); + TIcebergTableSink sink = planSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "fmt"))); + Assertions.assertEquals(expectedFormat, sink.getFileFormat()); + Assertions.assertEquals(expectedCompress, sink.getCompressionType()); + } + + /** An unpartitioned table at a fresh catalog with the given properties. */ + private static Table unpartitionedTableWith(String name, Map props) { + return freshCatalog().createTable(TableIdentifier.of("db1", name), SCHEMA, + PartitionSpec.unpartitioned(), new HashMap<>(props)); + } + + @Test + public void planWriteNormalizesOutputPathAndKeepsOriginalRaw() { + Table table = partitionedSortedTable(freshCatalog()); + TIcebergTableSink sink = planSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t1"))); + + Assertions.assertEquals("s3://bucket/wh/db1/t1/data", sink.getOutputPath(), + "output path must be normalized through the context seam (oss -> s3) for the BE writer"); + Assertions.assertEquals("oss://bucket/wh/db1/t1/data", sink.getOriginalOutputPath(), + "original output path must stay raw (legacy setOriginalOutputPath)"); + Assertions.assertEquals(TFileType.FILE_S3, sink.getFileType()); + } + + @Test + public void planWriteMergesStorageHadoopConfig() { + Table table = partitionedSortedTable(freshCatalog()); + TIcebergTableSink sink = planSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t1"))); + + // B-1: the sink's hadoop_config must carry the BE-canonical static creds (AWS_*), NOT the fs.s3a.* + // hadoop form — BE s3_util.cpp reads only AWS_*, so fs.s3a.* would leave the writer credential-less. + Assertions.assertEquals("AK123", sink.getHadoopConfig().get("AWS_ACCESS_KEY"), + "hadoop config must carry BE-canonical static creds (legacy getBackendConfigProperties / AWS_*)"); + Assertions.assertNull(sink.getHadoopConfig().get("fs.s3a.access.key"), + "the sink must not ship the fs.s3a.* hadoop form (BE cannot read it)"); + } + + // ───────────────────────────── broker backend (ofs:// / gfs:// -> FILE_BROKER) ───────────────────────────── + // + // WHY: SchemaTypeMapper maps ofs/gfs to FILE_BROKER; the sink must then carry the catalog's broker + // addresses, or BE gets a broker sink with an empty broker list and the write fails. Legacy + // IcebergTableSink/DeleteSink/MergeSink each did `if (FILE_BROKER) setBrokerAddresses(...)`; the migration + // dropped it. The engine resolves the addresses (BrokerMgr); the connector maps the neutral host/port + // pairs to TNetworkAddress and fails loud when none. MUTATION: dropping a setBrokerAddresses -> red. + + @Test + public void planWriteBrokerBackendSetsBrokerAddressesOnEverySink() { + List brokers = Arrays.asList( + new ConnectorBrokerAddress("broker-h1", 8000), + new ConnectorBrokerAddress("broker-h2", 8001)); + List expected = Arrays.asList( + new TNetworkAddress("broker-h1", 8000), + new TNetworkAddress("broker-h2", 8001)); + Table table = partitionedSortedTable(freshCatalog()); + + TIcebergTableSink insert = planSink(table, contextWithBroker(brokers), + new WriteHandle(new IcebergTableHandle("db1", "t1"))); + Assertions.assertEquals(TFileType.FILE_BROKER, insert.getFileType()); + Assertions.assertEquals(expected, insert.getBrokerAddresses(), + "INSERT sink must carry the catalog broker addresses for a FILE_BROKER target"); + + TIcebergDeleteSink delete = planDeleteSink(table, contextWithBroker(brokers), + new WriteHandle(new IcebergTableHandle("db1", "t1")).writeOperation(WriteOperation.DELETE)); + Assertions.assertEquals(expected, delete.getBrokerAddresses(), + "DELETE sink must carry the catalog broker addresses for a FILE_BROKER target"); + + TIcebergMergeSink merge = planMergeSink(table, contextWithBroker(brokers), + new WriteHandle(new IcebergTableHandle("db1", "t1")).writeOperation(WriteOperation.UPDATE)); + Assertions.assertEquals(expected, merge.getBrokerAddresses(), + "MERGE sink must carry the catalog broker addresses for a FILE_BROKER target"); + } + + @Test + public void planWriteBrokerBackendWithNoAliveBrokerFailsLoud() { + // FILE_BROKER but the engine resolves no broker -> fail loud with the legacy message, never ship an + // empty broker list to BE. + Table table = partitionedSortedTable(freshCatalog()); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> planSink(table, contextWithBroker(Collections.emptyList()), + new WriteHandle(new IcebergTableHandle("db1", "t1")))); + Assertions.assertEquals("No alive broker.", ex.getMessage()); + } + + @Test + public void planWriteNonBrokerBackendLeavesBrokerAddressesUnset() { + // S3/HDFS/local must NOT set broker_addresses (legacy gates the setter on FILE_BROKER). + Table table = partitionedSortedTable(freshCatalog()); + TIcebergTableSink sink = planSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t1"))); + Assertions.assertEquals(TFileType.FILE_S3, sink.getFileType()); + Assertions.assertFalse(sink.isSetBrokerAddresses(), + "a non-broker write must not set broker_addresses"); + } + + @Test + public void planWriteOverlaysVendedCredentials() { + // H-1: a REST vending catalog's static storage map is empty by design; the per-table vended token (read + // from the table's FileIO) must be overlaid into the write sink's hadoop_config in BE-canonical form + // (AWS_*), winning over a colliding static key — mirroring the scan path. The token here is non-empty so + // RecordingConnectorContext.vendStorageCredentials yields the configured BE-canonical vended creds. + // + // We drive a FakeIcebergTable whose io() carries a (non-empty) vended token; beginWrite needs a live SDK + // transaction, so we inject one from a throwaway real catalog table (the planning path stores but never + // dereferences it). + InMemoryCatalog catalog = freshCatalog(); + Map tableProps = new HashMap<>(); + tableProps.put("write.format.default", "parquet"); + tableProps.put("write.data.path", "oss://bucket/wh/db1/tvend/data"); + Table real = catalog.createTable(TableIdentifier.of("db1", "tvend"), SCHEMA, + PartitionSpec.unpartitioned(), tableProps); + + FakeIcebergTable fake = new FakeIcebergTable("tvend", SCHEMA, PartitionSpec.unpartitioned(), + "oss://bucket/wh/db1/tvend", tableProps); + fake.setIo(new PropsFileIO(Collections.singletonMap("s3.access-key-id", "vended-raw"))); + fake.setNewTransaction(real.newTransaction()); + + RecordingConnectorContext ctx = new RecordingConnectorContext(); + Map staticCreds = new HashMap<>(); + staticCreds.put("AWS_ACCESS_KEY", "static-ak"); + staticCreds.put("AWS_REGION", "us-east-1"); + ctx.storageProperties = Collections.singletonList(fakeBackendStorage(staticCreds)); + Map vendedCreds = new HashMap<>(); + vendedCreds.put("AWS_ACCESS_KEY", "vended-ak"); + vendedCreds.put("AWS_TOKEN", "vended-tok"); + ctx.vendedBeProps = vendedCreds; + + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = fake; + IcebergWritePlanProvider provider = new IcebergWritePlanProvider(restVendedProps(), ops, ctx); + WriteSession session = new WriteSession(new IcebergConnectorTransaction(42L, ops, ctx)); + + ConnectorSinkPlan plan = provider.planWrite(session, + new WriteHandle(new IcebergTableHandle("db1", "tvend"))); + TIcebergTableSink sink = plan.getDataSink().getIcebergTableSink(); + + Assertions.assertEquals("vended-ak", sink.getHadoopConfig().get("AWS_ACCESS_KEY"), + "vended creds must win over a colliding static key (legacy/scan precedence)"); + Assertions.assertEquals("vended-tok", sink.getHadoopConfig().get("AWS_TOKEN"), + "vended-only key must be present in the write sink's hadoop_config (H-1 overlay)"); + Assertions.assertEquals("us-east-1", sink.getHadoopConfig().get("AWS_REGION"), + "static-only key must remain alongside the vended overlay"); + } + + private static Map restVendedProps() { + Map props = new HashMap<>(); + props.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + props.put(IcebergConnectorProperties.REST_VENDED_CREDENTIALS_ENABLED, "true"); + return props; + } + + @Test + public void planWriteNonPartitionedOmitsPartitionSpec() { + InMemoryCatalog catalog = freshCatalog(); + partitionedSortedTable(catalog); + Table table = unpartitionedUnsortedTable(catalog); + TIcebergTableSink sink = planSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t2"))); + + Assertions.assertFalse(sink.isSetPartitionSpecsJson(), + "an unpartitioned table must not emit partition specs (legacy gates on spec().isPartitioned())"); + } + + // ───────────────────────────── OVERWRITE + static partition ───────────────────────────── + + @Test + public void planWriteOverwriteSetsOverwriteFlag() { + Table table = partitionedSortedTable(freshCatalog()); + TIcebergTableSink sink = planSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t1")).overwrite(true)); + + Assertions.assertTrue(sink.isOverwrite()); + } + + @Test + public void planWriteStaticPartitionOverwriteSetsStaticValues() { + Table table = partitionedSortedTable(freshCatalog()); + Map staticValues = Collections.singletonMap("id", "7"); + TIcebergTableSink sink = planSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t1")).overwrite(true).writeContext(staticValues)); + + Assertions.assertTrue(sink.isOverwrite()); + Assertions.assertEquals(staticValues, sink.getStaticPartitionValues(), + "INSERT OVERWRITE ... PARTITION must pass the static partition values to BE"); + } + + @Test + public void planWriteThreadsBranchFromHandleToTransaction() { + // WHY: INSERT INTO t@branch threads the target branch onto the write handle; planWrite must hand it + // to IcebergConnectorTransaction.beginWrite, which validates it against the table refs and points + // the commit at the branch. A freshly created table has no "no_such_branch" ref, so a threaded + // branch surfaces as a fail-loud "not founded" — proving the branch reached beginWrite. + // MUTATION: passing Optional.empty() instead of handle.getBranchName() (the DV-T06-branch bug) drops + // the branch -> no validation -> planWrite succeeds silently (write would land on the default ref) + // -> this assertThrows turns red. + Table table = unpartitionedUnsortedTable(freshCatalog()); + // beginWrite validates the branch against the table refs and wraps the failure as a + // DorisConnectorException ("Failed to begin write ... no_such_branch is not founded"). + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> planSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t2")).branch("no_such_branch"))); + Assertions.assertTrue(ex.getMessage().contains("no_such_branch"), + "the branch threaded onto the write handle must reach beginWrite's ref validation"); + } + + // ───────────────────────────── sort info (engine-built, stamped) ───────────────────────────── + + @Test + public void planWriteStampsHandleSortInfoOntoSink() { + Table table = partitionedSortedTable(freshCatalog()); + TSortInfo engineBuilt = new TSortInfo(); + engineBuilt.setIsAscOrder(Collections.singletonList(true)); + engineBuilt.setNullsFirst(Collections.singletonList(true)); + TIcebergTableSink sink = planSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t1")).sortInfo(engineBuilt)); + + Assertions.assertEquals(engineBuilt, sink.getSortInfo(), + "the engine-built TSortInfo (from the connector's declared write-sort columns) must be " + + "stamped onto the sink verbatim"); + } + + @Test + public void planWriteWithoutHandleSortInfoLeavesSinkUnsorted() { + Table table = partitionedSortedTable(freshCatalog()); + TIcebergTableSink sink = planSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t1"))); + + Assertions.assertFalse(sink.isSetSortInfo(), + "no engine-built sort info on the handle -> no sort_info on the sink"); + } + + // ───────────────────────────── getWriteSortColumns (connector declares) ───────────────────────────── + + @Test + public void getWriteSortColumnsForSortedTableMapsIdentityFields() { + Table table = partitionedSortedTable(freshCatalog()); + List cols = providerFor(table, contextWithStorage()) + .getWriteSortColumns(sessionFor(table, contextWithStorage()), + new IcebergTableHandle("db1", "t1")); + + Assertions.assertEquals(1, cols.size()); + Assertions.assertEquals(0, cols.get(0).getColumnIndex(), "id is full-schema column 0"); + Assertions.assertTrue(cols.get(0).isAsc()); + Assertions.assertTrue(cols.get(0).isNullsFirst()); + } + + @Test + public void getWriteSortColumnsNullForUnsortedTable() { + // null == "no write sort order" (legacy gates setSortInfo on isSorted()) -> the engine emits no + // TSortInfo, keeping the unsorted-write byte-parity. + InMemoryCatalog catalog = freshCatalog(); + partitionedSortedTable(catalog); + Table table = unpartitionedUnsortedTable(catalog); + Assertions.assertNull(providerFor(table, contextWithStorage()) + .getWriteSortColumns(sessionFor(table, contextWithStorage()), + new IcebergTableHandle("db1", "t2"))); + } + + @Test + public void getWriteSortColumnsNonNullEmptyForSortOrderWithoutIdentityColumns() { + // A table sorted ONLY by a non-identity transform (e.g. bucket) still HAS a write sort order, so + // legacy unconditionally sets an (empty) sort_info inside the isSorted() branch -> BE uses the + // sort writer. The connector must signal this as a non-null EMPTY list (not null), so the engine + // emits an empty TSortInfo. MUTATION: returning null here -> BE uses the plain writer -> red. + InMemoryCatalog catalog = freshCatalog(); + Map tableProps = new HashMap<>(); + tableProps.put("write.format.default", "parquet"); + tableProps.put("write.data.path", "oss://bucket/wh/db1/t3/data"); + Table table = catalog.createTable(TableIdentifier.of("db1", "t3"), SCHEMA, + PartitionSpec.unpartitioned(), tableProps); + table.replaceSortOrder().asc(Expressions.bucket("id", 4)).commit(); + Table reloaded = catalog.loadTable(TableIdentifier.of("db1", "t3")); + + List cols = providerFor(reloaded, contextWithStorage()) + .getWriteSortColumns(sessionFor(reloaded, contextWithStorage()), + new IcebergTableHandle("db1", "t3")); + Assertions.assertNotNull(cols, "a sorted table (even by a non-identity transform) has a write sort order"); + Assertions.assertTrue(cols.isEmpty(), "no identity column resolves -> empty list -> empty TSortInfo"); + } + + // ───────────────────────────── getWritePartitioning (connector declares, ② C3b-core) ───────────────────────────── + // + // WHY: post-flip the iceberg merge-write distribution (DistributionSpecMerge) is built fe-core-side, but + // its native partition-spec walk (PhysicalIcebergMergeSink.buildInsertPartitionFields -> + // icebergTable.getIcebergTable().spec()) is DEAD once iceberg is a PluginDrivenExternalCatalog (the native + // table is unreachable across the connector's isolated classloader). The connector therefore declares the + // partitioning in an engine-neutral carrier; the engine resolves source-column names to expr ids locally. + // These pins guard byte-parity of the carried (transform, param, sourceColumnName, fieldName, sourceId, + // specId) tuple against the legacy native walk. + + /** A bucket(id, 16)-partitioned table: distinct partition field name ("id_bucket") vs source column ("id"). */ + private static Table bucketPartitionedTable(InMemoryCatalog catalog) { + Map tableProps = new HashMap<>(); + tableProps.put("write.format.default", "parquet"); + tableProps.put("write.data.path", "oss://bucket/wh/db1/tb/data"); + return catalog.createTable(TableIdentifier.of("db1", "tb"), SCHEMA, + PartitionSpec.builderFor(SCHEMA).bucket("id", 16).build(), tableProps); + } + + @Test + public void getWritePartitioningForIdentityPartitionMapsField() { + Table table = partitionedSortedTable(freshCatalog()); + ConnectorWritePartitionSpec spec = providerFor(table, contextWithStorage()) + .getWritePartitioning(sessionFor(table, contextWithStorage()), + new IcebergTableHandle("db1", "t1")); + + Assertions.assertNotNull(spec, "a partitioned table must declare its write partitioning"); + Assertions.assertEquals(table.spec().specId(), spec.getSpecId()); + Assertions.assertEquals(1, spec.getFields().size()); + ConnectorWritePartitionField f = spec.getFields().get(0); + Assertions.assertEquals("identity", f.getTransform()); + Assertions.assertNull(f.getTransformParam(), "identity has no bracket argument"); + Assertions.assertEquals("id", f.getSourceColumnName(), "source column resolved from sourceId via the schema"); + Assertions.assertEquals("id", f.getFieldName(), "identity partition field name equals the source column"); + Assertions.assertEquals(table.schema().findField("id").fieldId(), f.getSourceId()); + } + + @Test + public void getWritePartitioningNullForUnpartitionedTable() { + // null == "unpartitioned" (legacy gates on spec().isPartitioned()) -> the engine uses its + // non-partitioned merge distribution, keeping byte-parity for unpartitioned MERGE/UPDATE. + InMemoryCatalog catalog = freshCatalog(); + partitionedSortedTable(catalog); + Table table = unpartitionedUnsortedTable(catalog); + Assertions.assertNull(providerFor(table, contextWithStorage()) + .getWritePartitioning(sessionFor(table, contextWithStorage()), + new IcebergTableHandle("db1", "t2"))); + } + + @Test + public void getWritePartitioningBucketTransformCarriesParamAndDistinctNames() { + // MUTATION: this is the field that distinguishes the carrier's three name-ish bits. A walk that + // carried fieldName ("id_bucket") where sourceColumnName ("id") is needed would resolve the wrong + // (or no) expr id fe-core-side; dropping the parsed param would lose the bucket count BE needs. + Table table = bucketPartitionedTable(freshCatalog()); + ConnectorWritePartitionSpec spec = providerFor(table, contextWithStorage()) + .getWritePartitioning(sessionFor(table, contextWithStorage()), + new IcebergTableHandle("db1", "tb")); + + Assertions.assertNotNull(spec); + Assertions.assertEquals(1, spec.getFields().size()); + ConnectorWritePartitionField f = spec.getFields().get(0); + Assertions.assertEquals("bucket[16]", f.getTransform(), "transform string is the native PartitionField.transform().toString()"); + Assertions.assertEquals(Integer.valueOf(16), f.getTransformParam(), "the bracket argument [16] must be parsed out"); + Assertions.assertEquals("id", f.getSourceColumnName(), "source column is the base column, not the partition field"); + Assertions.assertEquals("id_bucket", f.getFieldName(), "partition field name is iceberg's derived 'id_bucket'"); + Assertions.assertEquals(table.schema().findField("id").fieldId(), f.getSourceId()); + } + + // ───────────────────── getSyntheticWriteColumns (connector declares the row-id STRUCT, ③ C3b-core) ───────────────────── + // + // WHY: post-flip the iceberg DML hidden column __DORIS_ICEBERG_ROWID_COL__ that legacy + // IcebergExternalTable.getFullSchema injected is unreachable — a PluginDrivenExternalTable carries no + // iceberg knowledge. The connector therefore declares it as an engine-neutral invisible ConnectorColumn; + // fe-core converts + appends it (gated request-side) while a DML over the table is in flight. This pins + // the carried STRUCT shape (name / 4 fields / types / invisible / not-null) against the legacy + // fe-core IcebergRowId.createHiddenColumn() (its mirror is the fe-core ConnectorColumnConverter contract pin). + + @Test + public void getSyntheticWriteColumnsDeclaresRowIdStruct() { + Table table = unpartitionedUnsortedTable(freshCatalog()); + List cols = providerFor(table, contextWithStorage()) + .getSyntheticWriteColumns(sessionFor(table, contextWithStorage()), + new IcebergTableHandle("db1", "t2")); + + Assertions.assertEquals(1, cols.size(), "iceberg declares exactly the row-id synthetic write column"); + ConnectorColumn rowId = cols.get(0); + Assertions.assertEquals("__DORIS_ICEBERG_ROWID_COL__", rowId.getName()); + Assertions.assertFalse(rowId.isVisible(), "the row-id column must be hidden (invisible)"); + Assertions.assertFalse(rowId.isNullable(), "matches legacy IcebergRowId not-null"); + + ConnectorType type = rowId.getType(); + Assertions.assertEquals("STRUCT", type.getTypeName()); + Assertions.assertEquals( + Arrays.asList("file_path", "row_position", "partition_spec_id", "partition_data"), + type.getFieldNames()); + List fieldTypes = type.getChildren(); + Assertions.assertEquals(4, fieldTypes.size()); + Assertions.assertEquals("STRING", fieldTypes.get(0).getTypeName()); + Assertions.assertEquals("BIGINT", fieldTypes.get(1).getTypeName()); + Assertions.assertEquals("INT", fieldTypes.get(2).getTypeName()); + Assertions.assertEquals("STRING", fieldTypes.get(3).getTypeName()); + } + + // ───────────────────────────── fail-loud ───────────────────────────── + + @Test + public void planWriteWithoutTransactionFailsLoud() { + Table table = partitionedSortedTable(freshCatalog()); + IcebergWritePlanProvider provider = providerFor(table, contextWithStorage()); + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> provider.planWrite(new WriteSession(null), + new WriteHandle(new IcebergTableHandle("db1", "t1")))); + Assertions.assertTrue(ex.getMessage().contains("transaction"), + "an iceberg write with no bound connector transaction must fail loud"); + } + + // ───────────────────────────── DELETE sink (TIcebergDeleteSink) ───────────────────────────── + // + // WHY: T07a moves the legacy planner.IcebergDeleteSink.bindDataSink Thrift assembly into the + // connector. The sink goes to BE unchanged (C2), so every field must be byte-identical to the legacy + // sink. Note the legacy delete sink uses compress_type (field 6), NOT the table/merge sink's + // compression_type — a parity-by-omission silently corrupts v2 position-delete writes at P6.6. + + private static TIcebergDeleteSink planDeleteSink(Table table, RecordingConnectorContext ctx, + ConnectorWriteHandle handle) { + ConnectorSinkPlan plan = providerFor(table, ctx).planWrite(sessionFor(table, ctx), handle); + Assertions.assertEquals(TDataSinkType.ICEBERG_DELETE_SINK, plan.getDataSink().getType(), + "a DELETE write operation must dispatch to the TIcebergDeleteSink dialect"); + return plan.getDataSink().getIcebergDeleteSink(); + } + + @Test + public void planWriteBuildsDeleteSinkWithTableDerivedFields() { + Table table = partitionedSortedTable(freshCatalog()); + TIcebergDeleteSink sink = planDeleteSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t1")).writeOperation(WriteOperation.DELETE)); + + Assertions.assertEquals("db1", sink.getDbName()); + Assertions.assertEquals("t1", sink.getTbName()); + Assertions.assertEquals(TFileContent.POSITION_DELETES, sink.getDeleteType(), + "iceberg delete is always a position delete (the only DeleteFileType)"); + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, sink.getFileFormat()); + Assertions.assertEquals(TFileCompressType.ZSTD, sink.getCompressType(), + "the delete sink carries compress_type (thrift field 6), NOT compression_type (the merge/table field)"); + Assertions.assertEquals("AK123", sink.getHadoopConfig().get("AWS_ACCESS_KEY"), + "hadoop config must carry BE-canonical static creds (AWS_*), not the fs.s3a.* hadoop form"); + Assertions.assertEquals("s3://bucket/wh/db1/t1/data", sink.getOutputPath(), + "delete output path is the normalized data location (legacy LocationPath.toStorageLocation)"); + Assertions.assertEquals("oss://bucket/wh/db1/t1/data", sink.getTableLocation(), + "table_location stays the raw data location (legacy IcebergUtils.dataLocation)"); + Assertions.assertEquals(TFileType.FILE_S3, sink.getFileType()); + Assertions.assertEquals(table.spec().specId(), sink.getPartitionSpecId()); + Assertions.assertEquals(2, sink.getFormatVersion()); + Assertions.assertFalse(sink.isSetRewritableDeleteFileSets(), + "the bindDataSink port never stamps rewritable delete file sets (post-finalize, fv3, T07c)"); + } + + @Test + public void planWriteDeleteSinkNonPartitionedOmitsSpecId() { + InMemoryCatalog catalog = freshCatalog(); + partitionedSortedTable(catalog); + Table table = unpartitionedUnsortedTable(catalog); + TIcebergDeleteSink sink = planDeleteSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t2")).writeOperation(WriteOperation.DELETE)); + + Assertions.assertFalse(sink.isSetPartitionSpecId(), + "an unpartitioned table must not emit a partition spec id (legacy gates on spec().isPartitioned())"); + } + + @Test + public void planWriteDeleteSinkFormatVersionThree() { + Table table = formatVersionThreeTable(freshCatalog()); + TIcebergDeleteSink sink = planDeleteSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "tv3")).writeOperation(WriteOperation.DELETE)); + + Assertions.assertEquals(3, sink.getFormatVersion()); + Assertions.assertFalse(sink.isSetRewritableDeleteFileSets(), + "a v3 delete with no live delete files to rewrite must not set rewritable sets (legacy gates on non-empty)"); + } + + // ── commit-bridge supply (S4 part 2): planWrite drains the per-statement scope into rewritable_delete_file_sets ── + + private static final String STASH_QID = "qid-stash"; + + private static IcebergWritePlanProvider supplyProvider(Table table, RecordingConnectorContext ctx) { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = table; + return new IcebergWritePlanProvider(NON_REST_PROPS, ops, ctx); + } + + private static WriteSession supplySession(Table table, RecordingConnectorContext ctx, String queryId) { + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = table; + IcebergConnectorTransaction txn = new IcebergConnectorTransaction(42L, ops, ctx); + return new WriteSession(txn, queryId); + } + + private static List dvDescs(String path, long offset, long size) { + TIcebergDeleteFileDesc d = new TIcebergDeleteFileDesc(); + d.setPath(path); + d.setContent(3); + d.setContentOffset(offset); + d.setContentSizeInBytes(size); + return Collections.singletonList(d); + } + + @Test + public void planWriteDeleteSinkAttachesRewritableSetsFromScope() { + // The scan accumulated this statement's old DV (referenced data file -> its non-equality deletes) into the + // per-statement scope; planWrite must drain it onto the sink so the BE OR-merges those deletes into the + // new DV. MUTATION: not reading the scope / not setting the field -> the BE writes a DV without the old + // deletes -> resurrection. + Table table = formatVersionThreeTable(freshCatalog()); + RecordingConnectorContext ctx = contextWithStorage(); + WriteSession session = supplySession(table, ctx, STASH_QID); + IcebergStatementScope.rewritableDeleteSupply(session).put("oss://bucket/wh/db1/tv3/data/f1.parquet", + dvDescs("oss://bucket/wh/db1/tv3/data/dv1.puffin", 16L, 64L)); + + ConnectorSinkPlan plan = supplyProvider(table, ctx).planWrite(session, + new WriteHandle(new IcebergTableHandle("db1", "tv3")).writeOperation(WriteOperation.DELETE)); + TIcebergDeleteSink sink = plan.getDataSink().getIcebergDeleteSink(); + + Assertions.assertTrue(sink.isSetRewritableDeleteFileSets()); + Assertions.assertEquals(1, sink.getRewritableDeleteFileSetsSize()); + TIcebergRewritableDeleteFileSet set = sink.getRewritableDeleteFileSets().get(0); + // The set is keyed on the RAW referenced data-file path the BE matches on. + Assertions.assertEquals("oss://bucket/wh/db1/tv3/data/f1.parquet", set.getReferencedDataFilePath()); + Assertions.assertEquals(1, set.getDeleteFilesSize()); + Assertions.assertEquals("oss://bucket/wh/db1/tv3/data/dv1.puffin", set.getDeleteFiles().get(0).getPath()); + Assertions.assertEquals(3, set.getDeleteFiles().get(0).getContent()); + } + + @Test + public void planWriteMergeSinkAttachesRewritableSetsFromScope() { + Table table = formatVersionThreeTable(freshCatalog()); + RecordingConnectorContext ctx = contextWithStorage(); + WriteSession session = supplySession(table, ctx, STASH_QID); + IcebergStatementScope.rewritableDeleteSupply(session).put("oss://bucket/wh/db1/tv3/data/f1.parquet", + dvDescs("oss://bucket/wh/db1/tv3/data/dv1.puffin", 8L, 32L)); + + ConnectorSinkPlan plan = supplyProvider(table, ctx).planWrite(session, + new WriteHandle(new IcebergTableHandle("db1", "tv3")).writeOperation(WriteOperation.MERGE)); + TIcebergMergeSink sink = plan.getDataSink().getIcebergMergeSink(); + + Assertions.assertTrue(sink.isSetRewritableDeleteFileSets(), + "a v3 MERGE must carry rewritable_delete_file_sets (thrift field 25) too"); + Assertions.assertEquals("oss://bucket/wh/db1/tv3/data/f1.parquet", + sink.getRewritableDeleteFileSets().get(0).getReferencedDataFilePath()); + } + + @Test + public void planWriteDeleteSinkLeavesSetsUnsetWhenScopeHasNoSupply() { + // A v3 DELETE whose scan accumulated nothing into the scope (no live deletes) leaves the field unset — + // byte-identical to legacy's empty gate. The queryId scoping means a different statement's supply is + // simply not visible here. + Table table = formatVersionThreeTable(freshCatalog()); + RecordingConnectorContext ctx = contextWithStorage(); + + ConnectorSinkPlan plan = supplyProvider(table, ctx).planWrite( + supplySession(table, ctx, STASH_QID), + new WriteHandle(new IcebergTableHandle("db1", "tv3")).writeOperation(WriteOperation.DELETE)); + + Assertions.assertFalse(plan.getDataSink().getIcebergDeleteSink().isSetRewritableDeleteFileSets()); + } + + @Test + public void planWriteVersionTwoDeleteNeverAttachesRewritableSets() { + // v2 deletes are plain position-delete files (no DV union), so even a scope-carried supply must NOT be + // emitted (the BE ignores field 15 below v3). MUTATION: dropping the formatVersion>=3 gate would emit it. + InMemoryCatalog catalog = freshCatalog(); + Table table = catalog.createTable(TableIdentifier.of("db1", "tv2"), SCHEMA, + PartitionSpec.unpartitioned(), Collections.singletonMap("format-version", "2")); + RecordingConnectorContext ctx = contextWithStorage(); + WriteSession session = supplySession(table, ctx, STASH_QID); + IcebergStatementScope.rewritableDeleteSupply(session) + .put("oss://bucket/wh/db1/tv2/data/f1.parquet", dvDescs("dv", 1L, 2L)); + + ConnectorSinkPlan plan = supplyProvider(table, ctx).planWrite(session, + new WriteHandle(new IcebergTableHandle("db1", "tv2")).writeOperation(WriteOperation.DELETE)); + + Assertions.assertFalse(plan.getDataSink().getIcebergDeleteSink().isSetRewritableDeleteFileSets()); + } + + @Test + public void planWriteInsertIgnoresScopeSupply() { + // INSERT ... SELECT FROM an iceberg source accumulates the source scan's deletes into the scope, but the + // INSERT write does not consume them; planWrite must still produce a plain table sink (the supply is + // simply ignored, not attached). MUTATION: attaching the supply to an INSERT sink -> red. + Table table = formatVersionThreeTable(freshCatalog()); + RecordingConnectorContext ctx = contextWithStorage(); + WriteSession session = supplySession(table, ctx, STASH_QID); + IcebergStatementScope.rewritableDeleteSupply(session) + .put("oss://bucket/wh/db1/tv3/data/src.parquet", dvDescs("dv", 1L, 2L)); + + ConnectorSinkPlan plan = supplyProvider(table, ctx).planWrite(session, + new WriteHandle(new IcebergTableHandle("db1", "tv3")).writeOperation(WriteOperation.INSERT)); + + Assertions.assertTrue(plan.getDataSink().isSetIcebergTableSink(), "INSERT builds a plain table sink"); + } + + @Test + public void planWriteV3RowLevelDmlUnderNoneScopeFailsLoud() { + // A format-version>=3 DELETE/MERGE under NONE (no live statement scope) cannot have received the scan's + // rewritable-delete supply, so planWrite must reject it rather than silently write a DV that drops old + // deletes. MUTATION: dropping the fail-loud -> a v3 DELETE under NONE resurrects previously-deleted rows. + Table table = formatVersionThreeTable(freshCatalog()); + RecordingConnectorContext ctx = contextWithStorage(); + WriteSession session = supplySession(table, ctx, STASH_QID).noScope(); + + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, () -> + supplyProvider(table, ctx).planWrite(session, + new WriteHandle(new IcebergTableHandle("db1", "tv3")).writeOperation(WriteOperation.DELETE))); + Assertions.assertTrue(ex.getMessage().contains("per-statement scope"), + "fail-loud message must name the missing per-statement scope, got: " + ex.getMessage()); + } + + @Test + public void planWriteThreadsPinnedReadSnapshotFromHandleToTransaction() { + // [SHOULD-2] / Fix B: planWrite must read the MVCC read-snapshot pin off the (pinned) write table + // handle and thread it into beginWrite, so the RowDelta anchors baseSnapshotId at the statement's + // read snapshot (S_read), not a fresh re-read of current (S_write). The translator threads the pin + // onto the handle (mirroring the scan); this proves the connector consumes handle.getSnapshotId(). + // A synthetic pin id is enough: beginWrite stores it (history validation is deferred to commit), and + // the empty table's current snapshot is null, so a stored pin is unambiguously the threaded value. + InMemoryCatalog catalog = freshCatalog(); + catalog.createTable(TableIdentifier.of("db1", "tv2"), SCHEMA, + PartitionSpec.unpartitioned(), Collections.singletonMap("format-version", "2")); + long pinnedReadSnapshot = 7777L; + + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = catalog.loadTable(TableIdentifier.of("db1", "tv2")); + IcebergConnectorTransaction txn = new IcebergConnectorTransaction(42L, ops, contextWithStorage()); + WriteSession session = new WriteSession(txn); + + ConnectorWriteHandle handle = new WriteHandle( + new IcebergTableHandle("db1", "tv2").withSnapshot( + pinnedReadSnapshot, null, ops.table.schema().schemaId())) + .writeOperation(WriteOperation.DELETE); + // The table has no current snapshot, so a non-threaded pin would leave baseSnapshotId null; a stored + // 7777 is unambiguously the value read off the handle. + providerFor(ops.table, contextWithStorage()).planWrite(session, handle); + + Assertions.assertEquals(Long.valueOf(pinnedReadSnapshot), txn.getBaseSnapshotId(), + "planWrite must thread the handle's pinned read snapshot into beginWrite as baseSnapshotId"); + } + + // ───────────────────────────── MERGE sink (TIcebergMergeSink) ───────────────────────────── + // + // WHY: UPDATE and MERGE both write the TIcebergMergeSink dialect. Two parity traps vs the table/delete + // sinks: (1) merge carries compression_type (field 8), NOT compress_type; (2) merge carries sort_fields + // (field 6, a List built directly from the iceberg SortOrder), NOT the INSERT path's + // sort_info(16). At fv3 the schema_json must include the row-lineage fields (legacy + // appendRowLineageFieldsForV3), else BE v3 merge writes mismatch. + + private static TIcebergMergeSink planMergeSink(Table table, RecordingConnectorContext ctx, + ConnectorWriteHandle handle) { + ConnectorSinkPlan plan = providerFor(table, ctx).planWrite(sessionFor(table, ctx), handle); + Assertions.assertEquals(TDataSinkType.ICEBERG_MERGE_SINK, plan.getDataSink().getType(), + "an UPDATE/MERGE write operation must dispatch to the TIcebergMergeSink dialect"); + return plan.getDataSink().getIcebergMergeSink(); + } + + @Test + public void planWriteBuildsMergeSinkWithTableDerivedFields() { + Table table = partitionedSortedTable(freshCatalog()); + TIcebergMergeSink sink = planMergeSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t1")).writeOperation(WriteOperation.UPDATE)); + + Assertions.assertEquals("db1", sink.getDbName()); + Assertions.assertEquals("t1", sink.getTbName()); + Assertions.assertEquals(2, sink.getFormatVersion()); + Assertions.assertEquals(SchemaParser.toJson(table.schema()), sink.getSchemaJson(), + "fv2 merge schema-json equals SchemaParser.toJson(table.schema()) (no row-lineage append below v3)"); + Assertions.assertEquals(table.spec().specId(), sink.getPartitionSpecId()); + Assertions.assertNotNull(sink.getPartitionSpecsJson()); + Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET, sink.getFileFormat()); + Assertions.assertEquals(TFileCompressType.ZSTD, sink.getCompressionType(), + "the merge sink carries compression_type (thrift field 8), NOT compress_type (the delete field)"); + Assertions.assertEquals("AK123", sink.getHadoopConfig().get("AWS_ACCESS_KEY"), + "hadoop config must carry BE-canonical static creds (AWS_*), not the fs.s3a.* hadoop form"); + Assertions.assertEquals("s3://bucket/wh/db1/t1/data", sink.getOutputPath()); + Assertions.assertEquals("oss://bucket/wh/db1/t1/data", sink.getOriginalOutputPath()); + Assertions.assertEquals("oss://bucket/wh/db1/t1/data", sink.getTableLocation()); + Assertions.assertEquals(TFileType.FILE_S3, sink.getFileType()); + // delete side + Assertions.assertEquals(TFileContent.POSITION_DELETES, sink.getDeleteType()); + Assertions.assertEquals(table.spec().specId(), sink.getPartitionSpecIdForDelete()); + Assertions.assertFalse(sink.isSetRewritableDeleteFileSets()); + } + + @Test + public void planWriteMergeSinkSortFieldsFromIdentitySortOrder() { + Table table = partitionedSortedTable(freshCatalog()); + TIcebergMergeSink sink = planMergeSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t1")).writeOperation(WriteOperation.UPDATE)); + + Assertions.assertTrue(sink.isSetSortFields()); + Assertions.assertEquals(1, sink.getSortFields().size()); + TSortField sf = sink.getSortFields().get(0); + Assertions.assertEquals(table.schema().findField("id").fieldId(), sf.getSourceColumnId(), + "merge sort_fields carry the iceberg source field id directly (legacy SortField.sourceId), not a column index"); + Assertions.assertTrue(sf.isAscending()); + Assertions.assertTrue(sf.isNullFirst()); + } + + @Test + public void planWriteMergeSinkUnsortedOmitsSortFields() { + InMemoryCatalog catalog = freshCatalog(); + partitionedSortedTable(catalog); + Table table = unpartitionedUnsortedTable(catalog); + TIcebergMergeSink sink = planMergeSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t2")).writeOperation(WriteOperation.UPDATE)); + + Assertions.assertFalse(sink.isSetSortFields(), + "an unsorted table must not emit sort_fields (legacy gates on sortOrder().isSorted())"); + } + + @Test + public void planWriteMergeSinkFv3AppendsRowLineageSchema() { + Table table = formatVersionThreeTable(freshCatalog()); + TIcebergMergeSink sink = planMergeSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "tv3")).writeOperation(WriteOperation.MERGE)); + + Assertions.assertEquals(3, sink.getFormatVersion()); + Assertions.assertTrue(sink.getSchemaJson().contains("_row_id"), + "fv3 merge schema-json must include the row-lineage _row_id field (legacy appendRowLineageFieldsForV3)"); + Assertions.assertTrue(sink.getSchemaJson().contains("_last_updated_sequence_number"), + "fv3 merge schema-json must include the row-lineage _last_updated_sequence_number field"); + } + + @Test + public void planWriteMergeOperationAlsoBuildsMergeSink() { + Table table = partitionedSortedTable(freshCatalog()); + // The MERGE write operation shares the merge sink family with UPDATE. + TIcebergMergeSink sink = planMergeSink(table, contextWithStorage(), + new WriteHandle(new IcebergTableHandle("db1", "t1")).writeOperation(WriteOperation.MERGE)); + Assertions.assertEquals("t1", sink.getTbName()); + } + + // ───────────────────────────── write capability declarations (single-source-of-truth) ───────────────────────────── + // + // WHY: the write plan provider is now the single source of truth for a connector's write capabilities + // (supportedOperations + the sink-trait defaults from ConnectorWritePlanProvider). Iceberg supports the + // full DML surface (INSERT/OVERWRITE/DELETE/MERGE/REWRITE), write-targeted branches, parallel write, + // full-schema write order, and materializing static partition values — but does NOT require + // partition-local sort (unlike e.g. MaxCompute), so that one trait stays at its interface default (false). + + @Test + public void declaresFullWriteOperationSet() { + IcebergWritePlanProvider provider = providerFor(unpartitionedUnsortedTable(freshCatalog()), contextWithStorage()); + + Assertions.assertEquals(EnumSet.of(WriteOperation.INSERT, WriteOperation.OVERWRITE, + WriteOperation.DELETE, WriteOperation.MERGE, WriteOperation.REWRITE), provider.supportedOperations()); + Assertions.assertTrue(provider.supportsWriteBranch()); + Assertions.assertTrue(provider.requiresParallelWrite()); + Assertions.assertTrue(provider.requiresFullSchemaWriteOrder()); + Assertions.assertTrue(provider.requiresMaterializeStaticPartitionValues()); + Assertions.assertFalse(provider.requiresPartitionLocalSort(), + "iceberg does NOT require partition-local sort (unlike MaxCompute)"); + } + + // ───────────────────────────── test doubles ───────────────────────────── + + /** A bound write request; mirrors the engine's PluginDrivenWriteHandle (which is fe-core-private). */ + private static final class WriteHandle implements ConnectorWriteHandle { + private final ConnectorTableHandle tableHandle; + private boolean overwrite; + private Map writeContext = Collections.emptyMap(); + private TSortInfo sortInfo; + private WriteOperation writeOperation = WriteOperation.INSERT; + private Optional branchName = Optional.empty(); + + WriteHandle(ConnectorTableHandle tableHandle) { + this.tableHandle = tableHandle; + } + + WriteHandle branch(String v) { + this.branchName = Optional.ofNullable(v); + return this; + } + + @Override + public Optional getBranchName() { + return branchName; + } + + WriteHandle overwrite(boolean v) { + this.overwrite = v; + return this; + } + + WriteHandle writeOperation(WriteOperation v) { + this.writeOperation = v; + return this; + } + + @Override + public WriteOperation getWriteOperation() { + return writeOperation; + } + + WriteHandle writeContext(Map v) { + this.writeContext = v; + return this; + } + + WriteHandle sortInfo(TSortInfo v) { + this.sortInfo = v; + return this; + } + + @Override + public ConnectorTableHandle getTableHandle() { + return tableHandle; + } + + @Override + public List getColumns() { + return Collections.emptyList(); + } + + @Override + public boolean isOverwrite() { + return overwrite; + } + + @Override + public Map getWriteContext() { + return writeContext; + } + + @Override + public TSortInfo getSortInfo() { + return sortInfo; + } + } + + /** A session that returns the bound connector transaction; the timezone feeds beginWrite. */ + private static final class WriteSession implements ConnectorSession { + private final ConnectorTransaction txn; + private final String queryId; + // Default to a live memoizing scope so a v3 row-level DML does not trip planWrite's NONE fail-loud; the + // dedicated fail-loud test forces NONE via noScope(). + private ConnectorStatementScope statementScope = new TestStatementScope(); + + WriteSession(ConnectorTransaction txn) { + this(txn, "q"); + } + + WriteSession(ConnectorTransaction txn, String queryId) { + this.txn = txn; + this.queryId = queryId; + } + + /** Shares an explicit scope (so a test can pre-fill the rewritable-delete supply the write then reads). */ + WriteSession withScope(ConnectorStatementScope scope) { + this.statementScope = scope; + return this; + } + + /** Forces ConnectorStatementScope.NONE (for the v3-DML-under-NONE fail-loud test). */ + WriteSession noScope() { + this.statementScope = ConnectorStatementScope.NONE; + return this; + } + + @Override + public ConnectorStatementScope getStatementScope() { + return statementScope; + } + + @Override + public Optional getCurrentTransaction() { + return Optional.ofNullable(txn); + } + + @Override + public String getQueryId() { + return queryId; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public String getCatalogName() { + return "test"; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + } + + /** Minimal {@link FileIO} whose {@link #properties()} yields a known (non-empty) vended token map, so + * {@link IcebergScanPlanProvider#extractVendedToken} returns a non-empty token through the write path + * (H-1). Mirrors the scan test's equivalent double; the read/write file methods are never exercised. */ + private static final class PropsFileIO implements FileIO { + private final Map props; + + PropsFileIO(Map props) { + this.props = props; + } + + @Override + public Map properties() { + return props; + } + + @Override + public InputFile newInputFile(String path) { + throw new UnsupportedOperationException(); + } + + @Override + public OutputFile newOutputFile(String path) { + throw new UnsupportedOperationException(); + } + + @Override + public void deleteFile(String path) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriterHelperTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriterHelperTest.java new file mode 100644 index 00000000000000..2fedacfb299ee0 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriterHelperTest.java @@ -0,0 +1,329 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.thrift.TFileContent; +import org.apache.doris.thrift.TIcebergColumnStats; +import org.apache.doris.thrift.TIcebergCommitData; + +import com.google.common.base.VerifyException; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.io.WriteResult; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Parity oracle for the connector-resident {@link IcebergWriterHelper} — the self-contained port of legacy + * {@code org.apache.doris.datasource.iceberg.helper.IcebergWriterHelper} (P6.3-T04). The connector cannot + * import fe-core, so the data-file / delete-file / PartitionData / Metrics conversion is reproduced + * byte-faithfully against the iceberg SDK. The delete-file cases mirror the legacy + * {@code IcebergWriterHelperTest} cell-by-cell; the data-file / getFileFormat cases are added for T04. + * + *

    Deliberate, documented deltas vs legacy: {@code CommonStatistics} is inlined (row count + file size are + * passed straight to {@code genDataFile}), and the partition-value time-zone is a resolved {@code ZoneId} + * argument (legacy reads a thread-local) — irrelevant for the unpartitioned specs used here.

    + */ +public class IcebergWriterHelperTest { + + private final Schema schema = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get()), + Types.NestedField.optional(3, "age", Types.IntegerType.get())); + private final PartitionSpec unpartitionedSpec = PartitionSpec.unpartitioned(); + private final FileFormat format = FileFormat.PARQUET; + + private Table tableWith(String... props) { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + java.util.Map properties = new java.util.HashMap<>(); + for (int i = 0; i + 1 < props.length; i += 2) { + properties.put(props[i], props[i + 1]); + } + return catalog.createTable(TableIdentifier.of("db1", "t"), schema, unpartitionedSpec, properties); + } + + // ─────────────────── convertToDeleteFiles: ported from fe-core IcebergWriterHelperTest ─────────────────── + + @Test + public void convertToDeleteFilesEmptyList() { + Assertions.assertTrue(IcebergWriterHelper.convertToDeleteFiles( + format, unpartitionedSpec, new ArrayList<>(), ZoneOffset.UTC).isEmpty()); + } + + @Test + public void convertToDeleteFilesIgnoresDataFiles() { + TIcebergCommitData d = new TIcebergCommitData(); + d.setFilePath("/path/to/data.parquet"); + d.setRowCount(100); + d.setFileSize(1024); + d.setFileContent(TFileContent.DATA); + + Assertions.assertTrue(IcebergWriterHelper.convertToDeleteFiles( + format, unpartitionedSpec, Collections.singletonList(d), ZoneOffset.UTC).isEmpty()); + } + + @Test + public void convertToDeleteFilesPositionDelete() { + TIcebergCommitData d = new TIcebergCommitData(); + d.setFilePath("/path/to/delete.parquet"); + d.setRowCount(10); + d.setFileSize(512); + d.setFileContent(TFileContent.POSITION_DELETES); + d.setReferencedDataFilePath("/path/to/data.parquet"); + + List deleteFiles = IcebergWriterHelper.convertToDeleteFiles( + format, unpartitionedSpec, Collections.singletonList(d), ZoneOffset.UTC); + + Assertions.assertEquals(1, deleteFiles.size()); + DeleteFile df = deleteFiles.get(0); + Assertions.assertEquals("/path/to/delete.parquet", df.path()); + Assertions.assertEquals(10, df.recordCount()); + Assertions.assertEquals(512, df.fileSizeInBytes()); + Assertions.assertEquals(org.apache.iceberg.FileContent.POSITION_DELETES, df.content()); + } + + @Test + public void convertToDeleteFilesDeletionVectorUsesPuffinMetadata() { + TIcebergCommitData d = new TIcebergCommitData(); + d.setFilePath("/path/to/delete.puffin"); + d.setRowCount(7); + d.setFileSize(2048); + d.setFileContent(TFileContent.DELETION_VECTOR); + d.setContentOffset(128L); + d.setContentSizeInBytes(64L); + d.setReferencedDataFilePath("/path/to/data.parquet"); + + List deleteFiles = IcebergWriterHelper.convertToDeleteFiles( + format, unpartitionedSpec, Collections.singletonList(d), ZoneOffset.UTC); + + Assertions.assertEquals(1, deleteFiles.size()); + DeleteFile df = deleteFiles.get(0); + Assertions.assertEquals(FileFormat.PUFFIN, df.format()); + Assertions.assertEquals(128L, df.contentOffset()); + Assertions.assertEquals(64L, df.contentSizeInBytes()); + Assertions.assertEquals("/path/to/data.parquet", df.referencedDataFile()); + Assertions.assertEquals(org.apache.iceberg.FileContent.POSITION_DELETES, df.content()); + } + + @Test + public void convertToDeleteFilesRejectsEqualityDelete() { + TIcebergCommitData d = new TIcebergCommitData(); + d.setFilePath("/path/to/delete.parquet"); + d.setRowCount(20); + d.setFileSize(1024); + d.setFileContent(TFileContent.EQUALITY_DELETES); + + Assertions.assertThrows(VerifyException.class, () -> IcebergWriterHelper.convertToDeleteFiles( + format, unpartitionedSpec, Collections.singletonList(d), ZoneOffset.UTC)); + } + + @Test + public void convertToDeleteFilesMultiple() { + TIcebergCommitData d1 = new TIcebergCommitData(); + d1.setFilePath("/path/to/delete1.parquet"); + d1.setRowCount(10); + d1.setFileSize(512); + d1.setFileContent(TFileContent.POSITION_DELETES); + TIcebergCommitData d2 = new TIcebergCommitData(); + d2.setFilePath("/path/to/delete2.parquet"); + d2.setRowCount(20); + d2.setFileSize(1024); + d2.setFileContent(TFileContent.POSITION_DELETES); + + Assertions.assertEquals(2, IcebergWriterHelper.convertToDeleteFiles( + format, unpartitionedSpec, Arrays.asList(d1, d2), ZoneOffset.UTC).size()); + } + + // ─────────────────── convertToWriterResult: data-file conversion (T04) ─────────────────── + + @Test + public void convertToWriterResultBuildsDataFiles() { + Table table = tableWith("write.format.default", "parquet"); + + TIcebergCommitData d = new TIcebergCommitData(); + d.setFilePath("s3://b/db1/t/f.parquet"); + d.setRowCount(100L); + d.setFileSize(4096L); + d.setFileContent(TFileContent.DATA); + + WriteResult result = IcebergWriterHelper.convertToWriterResult( + table, Collections.singletonList(d), ZoneOffset.UTC); + + Assertions.assertEquals(1, result.dataFiles().length); + DataFile df = result.dataFiles()[0]; + Assertions.assertEquals("s3://b/db1/t/f.parquet", df.path()); + Assertions.assertEquals(100L, df.recordCount()); + Assertions.assertEquals(4096L, df.fileSizeInBytes()); + Assertions.assertEquals(FileFormat.PARQUET, df.format()); + } + + @Test + public void convertToWriterResultCarriesColumnMetrics() { + Table table = tableWith("write.format.default", "parquet"); + + TIcebergColumnStats stats = new TIcebergColumnStats(); + stats.putToColumnSizes(1, 100L); + stats.putToValueCounts(1, 10L); + stats.putToNullValueCounts(1, 2L); + stats.putToLowerBounds(1, ByteBuffer.wrap(new byte[] {1})); + stats.putToUpperBounds(1, ByteBuffer.wrap(new byte[] {9})); + + TIcebergCommitData d = new TIcebergCommitData(); + d.setFilePath("s3://b/db1/t/f.parquet"); + d.setRowCount(10L); + d.setFileSize(512L); + d.setFileContent(TFileContent.DATA); + d.setColumnStats(stats); + + WriteResult result = IcebergWriterHelper.convertToWriterResult( + table, Collections.singletonList(d), ZoneOffset.UTC); + + DataFile df = result.dataFiles()[0]; + // MUTATION: dropping the TIcebergColumnStats -> Metrics maps absent -> red. + Assertions.assertEquals(Long.valueOf(100L), df.columnSizes().get(1)); + Assertions.assertEquals(Long.valueOf(10L), df.valueCounts().get(1)); + Assertions.assertEquals(Long.valueOf(2L), df.nullValueCounts().get(1)); + } + + // ─────────────────── getFileFormat: 3-tier resolution (T04) ─────────────────── + + @Test + public void getFileFormatReadsWriteFormatDefault() { + Assertions.assertEquals(FileFormat.ORC, + IcebergWriterHelper.getFileFormat(tableWith("write.format.default", "orc"))); + Assertions.assertEquals(FileFormat.PARQUET, + IcebergWriterHelper.getFileFormat(tableWith("write.format.default", "parquet"))); + } + + @Test + public void getFileFormatReadsWriteFormatNickname() { + // "write-format" (Flink/Spark nickname) wins over the standard property. + Assertions.assertEquals(FileFormat.ORC, + IcebergWriterHelper.getFileFormat(tableWith("write-format", "orc"))); + } + + @Test + public void getFileFormatDefaultsToParquetWhenUnset() { + // No format property + no data files -> infer falls back to parquet (legacy default). + Assertions.assertEquals(FileFormat.PARQUET, IcebergWriterHelper.getFileFormat(tableWith())); + } + + @Test + public void getFileFormatThrowsOnUnsupported() { + Assertions.assertThrows(RuntimeException.class, + () -> IcebergWriterHelper.getFileFormat(tableWith("write.format.default", "avro"))); + } + + // ─────────────────── getFileFormat: PERF-03 cross-query inference cache ─────────────────── + + /** Unpartitioned table carrying one data file of {@code fileFormat} and the given extra properties. */ + private Table tableWithDataFile(FileFormat fileFormat, String... props) { + Table table = tableWith(props); + table.newAppend().appendFile(DataFiles.builder(unpartitionedSpec) + .withPath("s3://b/db1/t/f0." + fileFormat.name().toLowerCase()) + .withFileSizeInBytes(100) + .withRecordCount(1) + .withFormat(fileFormat) + .build()).commit(); + return table; + } + + @Test + public void getFileFormatInferenceIsCachedAcrossQueriesAtSameSnapshot() { + // A table with NO write-format / write.format.default (migrated / engine-default table) resolves the scan + // level file_format_type via an unfiltered whole-table planFiles() inference (the #64134 heavy op). PERF-03 + // memoizes that inference per (table, currentSnapshotId): repeated getScanNodeProperties computes across + // queries collapse onto ONE remote inference. MUTATION: not threading the cache into the call -> each query + // re-scans -> loadCountForTest > 1 -> red. + Table table = tableWithDataFile(FileFormat.ORC); + TableIdentifier id = TableIdentifier.of("db1", "t"); + IcebergFormatCache cache = new IcebergFormatCache(100, 1000); + + FileFormat f1 = IcebergWriterHelper.getFileFormat(table, id, cache); + FileFormat f2 = IcebergWriterHelper.getFileFormat(table, id, cache); + FileFormat f3 = IcebergWriterHelper.getFileFormat(table, id, cache); + + Assertions.assertEquals(FileFormat.ORC, f1); + Assertions.assertEquals(FileFormat.ORC, f2); + Assertions.assertEquals(FileFormat.ORC, f3); + Assertions.assertEquals(1, cache.loadCountForTest(), + "the whole-table inference must run exactly once across repeated queries at one snapshot"); + Assertions.assertEquals(1, cache.size(), "one (table, snapshot) entry"); + // Parity: the cached resolution matches the uncached (live) resolution. + Assertions.assertEquals(IcebergWriterHelper.getFileFormat(table), f1, + "cached format must equal a live (uncached) inference"); + } + + @Test + public void getFileFormatPropertyPathIsNeverCached() { + // When the table carries a format property, resolution is a cheap property read that must NOT touch the + // cache (only the inference fallback is memoized). MUTATION: caching the property path -> size > 0 -> red. + Table table = tableWithDataFile(FileFormat.ORC, "write.format.default", "parquet"); + TableIdentifier id = TableIdentifier.of("db1", "t"); + IcebergFormatCache cache = new IcebergFormatCache(100, 1000); + + Assertions.assertEquals(FileFormat.PARQUET, IcebergWriterHelper.getFileFormat(table, id, cache)); + Assertions.assertEquals(0, cache.size(), "the property path must never populate the inference cache"); + Assertions.assertEquals(0, cache.loadCountForTest()); + } + + @Test + public void getFileFormatNullCacheResolvesLive() { + // Offline / pre-cache paths pass a null cache: resolution stays live and correct (no NPE). + Table table = tableWithDataFile(FileFormat.ORC); + Assertions.assertEquals(FileFormat.ORC, + IcebergWriterHelper.getFileFormat(table, TableIdentifier.of("db1", "t"), null)); + } + + @Test + public void getFileFormatUnsupportedFirstFileCachesInferenceButRethrowsEachCall() { + // R3: an unsupported first data-file format (e.g. avro) is a SUCCESSFUL inference (the loader returns + // "avro"); the unsupported-format throw happens in the format mapping OUTSIDE getOrLoad. So the inference is + // cached once (loadCount == 1) yet every call re-throws (parity with legacy, which inferred + threw every + // query) without re-scanning. MUTATION: caching the mapped value / caching the throw -> loadCount != 1. + Table table = tableWithDataFile(FileFormat.AVRO); + TableIdentifier id = TableIdentifier.of("db1", "t"); + IcebergFormatCache cache = new IcebergFormatCache(100, 1000); + + Assertions.assertThrows(RuntimeException.class, + () -> IcebergWriterHelper.getFileFormat(table, id, cache)); + Assertions.assertThrows(RuntimeException.class, + () -> IcebergWriterHelper.getFileFormat(table, id, cache)); + Assertions.assertEquals(1, cache.loadCountForTest(), + "the inference runs once; the unsupported-format throw is in the mapping, not the loader"); + Assertions.assertEquals(1, cache.size()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/PlainIcebergCatalog.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/PlainIcebergCatalog.java new file mode 100644 index 00000000000000..c23f8b13706a4b --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/PlainIcebergCatalog.java @@ -0,0 +1,53 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; + +import java.util.List; + +/** + * A bare {@link Catalog} with NO {@link org.apache.iceberg.catalog.SupportsNamespaces} support, for + * exercising the "catalog does not support namespaces" guard of {@code listDatabaseNames} / + * {@code databaseExists}. Every method fails loud — the guard must short-circuit before any call. + */ +class PlainIcebergCatalog implements Catalog { + + @Override + public List listTables(Namespace namespace) { + throw new UnsupportedOperationException(); + } + + @Override + public Table loadTable(TableIdentifier identifier) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean dropTable(TableIdentifier identifier, boolean purge) { + throw new UnsupportedOperationException(); + } + + @Override + public void renameTable(TableIdentifier from, TableIdentifier to) { + throw new UnsupportedOperationException(); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/ReauthenticatingRestSessionCatalogTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/ReauthenticatingRestSessionCatalogTest.java similarity index 97% rename from fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/ReauthenticatingRestSessionCatalogTest.java rename to fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/ReauthenticatingRestSessionCatalogTest.java index 2132a38febb656..9b59d4a62283a9 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/ReauthenticatingRestSessionCatalogTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/ReauthenticatingRestSessionCatalogTest.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource.iceberg; +package org.apache.doris.connector.iceberg; import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.catalog.SessionCatalog.SessionContext; @@ -164,8 +164,8 @@ public void testDelegatedUserSessionIsNotRecovered() { @Test public void testAsCatalogViewRoutesThroughRecovery() { - // The default Catalog handed to IcebergMetadataOps is asCatalog(empty); it must inherit the same - // recovery because it calls back into this session catalog. + // The default Catalog handed to the connector's catalog ops is asCatalog(empty); it must inherit the + // same recovery because it calls back into this session catalog. FakeRestSessionCatalog wedged = new FakeRestSessionCatalog("wedged", notAuthorized()); FakeRestSessionCatalog fresh = new FakeRestSessionCatalog("fresh", null); ReauthenticatingRestSessionCatalog catalog = 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 new file mode 100644 index 00000000000000..7876783a04d6c2 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/RecordingConnectorContext.java @@ -0,0 +1,187 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.Connector; +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.thrift.TFileType; + +import java.util.ArrayList; +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), adapted verbatim from the paimon + * connector's {@code RecordingConnectorContext}. + * + *

    {@link IcebergConnectorMetadata} takes a context (ctor {@code (IcebergCatalogOps, Map, + * ConnectorContext)}) and wraps every remote read in {@link #executeAuthenticated}; the read tests use + * this double to assert one wrap per op via {@link #authCount}, and that {@link #getStorageProperties} + * is threaded through. When {@link #failAuth} is set, + * {@link #executeAuthenticated} throws WITHOUT invoking the task, which proves the seam call sits INSIDE + * the authenticator. + */ +final class RecordingConnectorContext implements ConnectorContext { + + int authCount; + boolean failAuth; + + /** Storage properties the fake returns from {@link #getStorageProperties()} — the typed fe-filesystem + * seam both the scan and (design S3) the write path derive their BE-canonical static creds from via + * {@code sp.toBackendProperties().toMap()} (default: none). */ + List storageProperties = Collections.emptyList(); + + /** BE-canonical vended creds the fake returns from {@link #vendStorageCredentials} for a NON-EMPTY token + * (an empty/null token -> empty result, mirroring {@code DefaultConnectorContext} — so a test can prove the + * catalog-flag GATE end-to-end: flag off -> empty token -> no vended {@code location.*}). */ + Map vendedBeProps = Collections.emptyMap(); + + /** Raw URIs the connector routed through {@link #normalizeStorageUri} (data/delete-path normalization). */ + final List normalizedUris = new ArrayList<>(); + /** Number of times the connector invoked {@link #normalizeStorageUri} (1- or 2-arg). */ + int normalizeCount; + /** Number of times the connector built a scan-scoped normalizer via {@link #newStorageUriNormalizer} + * (should be once per scan — the perf hoist guard). */ + int newNormalizerCount; + /** The vended token the connector passed to the most recent 2-arg {@link #normalizeStorageUri} (T09). */ + Map lastVendedToken; + + /** BE file type the fake returns from {@link #getBackendFileType} (T06 iceberg write sink). */ + TFileType backendFileType = TFileType.FILE_S3; + /** The vended token the connector passed to the most recent {@link #getBackendFileType}. */ + Map lastFileTypeVendedToken; + + /** Broker addresses the fake returns from {@link #getBrokerAddresses()} (broker write sink). Default none, + * 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"; + } + + @Override + public String getBackendFileType(String rawUri, Map vendedToken) { + lastFileTypeVendedToken = vendedToken; + return backendFileType.name(); + } + + @Override + public List getBrokerAddresses() { + return brokerAddresses; + } + + @Override + public String normalizeStorageUri(String rawUri) { + // The 1-arg form folds to the 2-arg with no token (mirrors DefaultConnectorContext), so every caller + // path records identically. + return normalizeStorageUri(rawUri, null); + } + + @Override + public String normalizeStorageUri(String rawUri, Map vendedToken) { + normalizedUris.add(rawUri); + normalizeCount++; + lastVendedToken = vendedToken; + // Canonicalize the scheme the way DefaultConnectorContext does for native paths (oss/cos/obs/s3a -> + // s3), so a test can prove the connector routes data/delete paths through this seam AND (2-arg) that + // the per-table vended token is threaded to each. Identity for already-canonical s3:// paths. + return rawUri == null ? null : rawUri.replaceFirst("^(oss|cos|obs|s3a)://", "s3://"); + } + + @Override + public UnaryOperator newStorageUriNormalizer(Map vendedToken) { + // Count the once-per-scan derivation (the perf hoist) but still record each per-URI normalize by + // delegating every apply back to the recording normalizeStorageUri — so existing recording assertions + // (normalizedUris / normalizeCount / lastVendedToken) keep firing, while newNormalizerCount proves the + // token->config derivation is entered once per scan, not once per file. + newNormalizerCount++; + return rawUri -> normalizeStorageUri(rawUri, vendedToken); + } + + @Override + public List getStorageProperties() { + return storageProperties; + } + + @Override + public Map vendStorageCredentials(Map rawVendedCredentials) { + // Mirror DefaultConnectorContext: an empty/null token yields no overlay; a non-empty token yields the + // configured BE-canonical creds. The real normalization (StorageProperties.createAll -> + // getBackendPropertiesFromStorageMap) is covered by fe-core's DefaultConnectorContext tests. + return (rawVendedCredentials == null || rawVendedCredentials.isEmpty()) + ? Collections.emptyMap() : vendedBeProps; + } + + /** The type the wrapper forwarded to {@link #createSiblingConnector} (proves the decorator delegates it). */ + String lastSiblingType; + /** The properties the wrapper forwarded to {@link #createSiblingConnector}. */ + Map lastSiblingProps; + + @Override + public Connector createSiblingConnector(String catalogType, Map properties) { + lastSiblingType = catalogType; + lastSiblingProps = properties; + return null; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public T executeAuthenticated(Callable task) throws Exception { + authCount++; + if (failAuth) { + // Deliberately do NOT call task -> the wrapped seam call must not run. + throw new RuntimeException("auth failed"); + } + return task.call(); + } + + /** Locations the connector asked the engine to clean (B1 managed-location cleanup). */ + final List cleanedLocations = new ArrayList<>(); + /** The child-dirs arg paired with each {@link #cleanedLocations} entry (same index). */ + final List> cleanedChildDirs = new ArrayList<>(); + + @Override + public void cleanupEmptyManagedLocation(String location, List tableChildDirs) { + cleanedLocations.add(location); + cleanedChildDirs.add(tableChildDirs); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/RecordingIcebergCatalogOps.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/RecordingIcebergCatalogOps.java new file mode 100644 index 00000000000000..e6475608e47064 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/RecordingIcebergCatalogOps.java @@ -0,0 +1,369 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ddl.BranchChange; +import org.apache.doris.connector.api.ddl.ConnectorColumnPosition; +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.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.NoSuchNamespaceException; +import org.apache.iceberg.exceptions.NoSuchTableException; +import org.apache.iceberg.view.View; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Hand-written recording fake for {@link IcebergCatalogOps} (no Mockito), mirroring the paimon + * connector's {@code RecordingPaimonCatalogOps}. + * + *

    Records an ordered call log, returns configurable fixed data, and can be told that a table + * does not exist ({@link #tableExists} returns the canned {@link #tableExists} boolean) or that + * {@link #loadTable} should fail (via {@link #throwOnLoadTable}). Because the seam fully covers + * every remote call {@link IcebergConnectorMetadata} makes, the metadata under test is built with + * a {@code null} real Catalog — the test stays entirely offline. + */ +final class RecordingIcebergCatalogOps implements IcebergCatalogOps { + + final List log = new ArrayList<>(); + + /** Canned database (namespace) names returned by {@link #listDatabaseNames()}. */ + List databases = new ArrayList<>(); + /** Canned table names returned by {@link #listTableNames(String)}. */ + List tables = new ArrayList<>(); + /** Canned view names returned by {@link #listViewNames(String)}. */ + List views = new ArrayList<>(); + /** Canned existence answer for {@link #databaseExists(String)}. */ + boolean databaseExists; + /** Canned existence answer for {@link #tableExists(String, String)}. */ + boolean tableExists; + /** Canned existence answer for {@link #viewExists(String, String)}. */ + boolean viewExists; + /** Canned SDK view returned by {@link #loadView(String, String)}. */ + View view; + /** The (dbName, viewName) the metadata layer passed to the most recent {@link #loadView}. */ + String lastLoadViewDb; + String lastLoadViewName; + /** The (dbName, viewName) the metadata layer passed to the most recent {@link #dropView}. */ + String lastDropViewDb; + String lastDropViewName; + /** Canned table returned by {@link #loadTable(String, String)}. */ + Table table; + /** When set, {@link #loadTable(String, String)} throws instead of returning {@link #table}. */ + boolean throwOnLoadTable; + /** When set, {@link #loadTable(String, String)} throws {@link NoSuchTableException} (concurrent-drop race). */ + boolean throwNoSuchTableOnLoadTable; + /** + * When set, the namespace-scoped reads/drops ({@link #loadNamespaceLocation}, {@link #listTableNames}, + * {@link #dropDatabase}) throw {@link NoSuchNamespaceException}, simulating a namespace whose remote side + * was deleted out-of-band while the FE cache still holds it. + */ + boolean throwNoSuchNamespace; + + /** The (dbName, tableName) the metadata layer passed to the most recent {@link #loadTable}. */ + String lastLoadDb; + String lastLoadTable; + /** The (dbName, tableName) the metadata layer passed to the most recent {@link #tableExists}. */ + String lastExistsDb; + String lastExistsTable; + + // ---- DDL write recording (B1) ---- + String lastCreateDb; + Map lastCreateDbProps; + String lastDropDb; + String lastCreateTableDb; + String lastCreateTableName; + Schema lastCreateSchema; + PartitionSpec lastCreateSpec; + SortOrder lastCreateSortOrder; + Map lastCreateProps; + String lastDropTableDb; + String lastDropTableName; + boolean lastDropPurge; + String lastRenameTableDb; + String lastRenameTableOld; + String lastRenameTableNew; + /** Canned location answers for the load-before-drop helpers (default: absent). */ + Optional tableLocation = Optional.empty(); + Optional namespaceLocation = Optional.empty(); + + // ---- Column-evolution write recording (B2) ---- + IcebergColumnChange lastAddColumn; + ConnectorColumnPosition lastAddColumnPos; + List lastAddColumns; + String lastDropColumn; + String lastRenameColumnOld; + String lastRenameColumnNew; + IcebergColumnChange lastModifyColumn; + ConnectorColumnPosition lastModifyColumnPos; + List lastReorder; + + // ---- Branch / tag write recording (B4) ---- + String lastBranchTagDb; + String lastBranchTagTable; + BranchChange lastBranch; + TagChange lastTag; + DropRefChange lastDropBranch; + DropRefChange lastDropTag; + + // ---- Partition-evolution write recording (B5) ---- + String lastPartitionFieldDb; + String lastPartitionFieldTable; + PartitionFieldChange lastAddPartitionField; + PartitionFieldChange lastDropPartitionField; + PartitionFieldChange lastReplacePartitionField; + + @Override + public List listDatabaseNames() { + log.add("listDatabaseNames"); + return databases; + } + + @Override + public boolean databaseExists(String dbName) { + log.add("databaseExists:" + dbName); + return databaseExists; + } + + @Override + public List listTableNames(String dbName) { + log.add("listTableNames:" + dbName); + if (throwNoSuchNamespace) { + throw new NoSuchNamespaceException("simulated missing namespace %s", dbName); + } + return tables; + } + + @Override + public boolean tableExists(String dbName, String tableName) { + log.add("tableExists:" + dbName + "." + tableName); + lastExistsDb = dbName; + lastExistsTable = tableName; + return tableExists; + } + + @Override + public List listViewNames(String dbName) { + log.add("listViewNames:" + dbName); + return views; + } + + @Override + public boolean viewExists(String dbName, String viewName) { + log.add("viewExists:" + dbName + "." + viewName); + return viewExists; + } + + @Override + public View loadView(String dbName, String viewName) { + log.add("loadView:" + dbName + "." + viewName); + lastLoadViewDb = dbName; + lastLoadViewName = viewName; + return view; + } + + @Override + public void dropView(String dbName, String viewName) { + log.add("dropView:" + dbName + "." + viewName); + lastDropViewDb = dbName; + lastDropViewName = viewName; + } + + @Override + public Table loadTable(String dbName, String tableName) { + log.add("loadTable:" + dbName + "." + tableName); + lastLoadDb = dbName; + lastLoadTable = tableName; + if (throwNoSuchTableOnLoadTable) { + throw new NoSuchTableException("simulated missing table %s.%s", dbName, tableName); + } + if (throwOnLoadTable) { + throw new RuntimeException("simulated loadTable failure for " + dbName + "." + tableName); + } + return table; + } + + @Override + public void createDatabase(String dbName, Map properties) { + log.add("createDatabase:" + dbName); + lastCreateDb = dbName; + lastCreateDbProps = properties; + } + + @Override + public void dropDatabase(String dbName) { + log.add("dropDatabase:" + dbName); + if (throwNoSuchNamespace) { + throw new NoSuchNamespaceException("simulated missing namespace %s", dbName); + } + lastDropDb = dbName; + } + + @Override + public void createTable(String dbName, String tableName, Schema schema, PartitionSpec partitionSpec, + SortOrder sortOrder, Map properties) { + log.add("createTable:" + dbName + "." + tableName); + lastCreateTableDb = dbName; + lastCreateTableName = tableName; + lastCreateSchema = schema; + lastCreateSpec = partitionSpec; + lastCreateSortOrder = sortOrder; + lastCreateProps = properties; + } + + @Override + public void dropTable(String dbName, String tableName, boolean purge) { + log.add("dropTable:" + dbName + "." + tableName + ":purge=" + purge); + lastDropTableDb = dbName; + lastDropTableName = tableName; + lastDropPurge = purge; + } + + @Override + public void renameTable(String dbName, String oldName, String newName) { + log.add("renameTable:" + dbName + "." + oldName + "->" + newName); + lastRenameTableDb = dbName; + lastRenameTableOld = oldName; + lastRenameTableNew = newName; + } + + @Override + public Optional loadTableLocation(String dbName, String tableName) { + log.add("loadTableLocation:" + dbName + "." + tableName); + return tableLocation; + } + + @Override + public Optional loadNamespaceLocation(String dbName) { + log.add("loadNamespaceLocation:" + dbName); + if (throwNoSuchNamespace) { + throw new NoSuchNamespaceException("simulated missing namespace %s", dbName); + } + return namespaceLocation; + } + + @Override + public void addColumn(String dbName, String tableName, IcebergColumnChange column, + ConnectorColumnPosition position) { + log.add("addColumn:" + dbName + "." + tableName + ":" + column.getName()); + lastAddColumn = column; + lastAddColumnPos = position; + } + + @Override + public void addColumns(String dbName, String tableName, List columns) { + log.add("addColumns:" + dbName + "." + tableName + ":" + columns.size()); + lastAddColumns = columns; + } + + @Override + public void dropColumn(String dbName, String tableName, String columnName) { + log.add("dropColumn:" + dbName + "." + tableName + ":" + columnName); + lastDropColumn = columnName; + } + + @Override + public void renameColumn(String dbName, String tableName, String oldName, String newName) { + log.add("renameColumn:" + dbName + "." + tableName + ":" + oldName + "->" + newName); + lastRenameColumnOld = oldName; + lastRenameColumnNew = newName; + } + + @Override + public void modifyColumn(String dbName, String tableName, IcebergColumnChange column, + ConnectorColumnPosition position) { + log.add("modifyColumn:" + dbName + "." + tableName + ":" + column.getName()); + lastModifyColumn = column; + lastModifyColumnPos = position; + } + + @Override + public void reorderColumns(String dbName, String tableName, List newOrder) { + log.add("reorderColumns:" + dbName + "." + tableName + ":" + newOrder); + lastReorder = newOrder; + } + + @Override + public void createOrReplaceBranch(String dbName, String tableName, BranchChange branch) { + log.add("createOrReplaceBranch:" + dbName + "." + tableName + ":" + branch.getName()); + lastBranchTagDb = dbName; + lastBranchTagTable = tableName; + lastBranch = branch; + } + + @Override + public void createOrReplaceTag(String dbName, String tableName, TagChange tag) { + log.add("createOrReplaceTag:" + dbName + "." + tableName + ":" + tag.getName()); + lastBranchTagDb = dbName; + lastBranchTagTable = tableName; + lastTag = tag; + } + + @Override + public void dropBranch(String dbName, String tableName, DropRefChange branch) { + log.add("dropBranch:" + dbName + "." + tableName + ":" + branch.getName()); + lastBranchTagDb = dbName; + lastBranchTagTable = tableName; + lastDropBranch = branch; + } + + @Override + public void dropTag(String dbName, String tableName, DropRefChange tag) { + log.add("dropTag:" + dbName + "." + tableName + ":" + tag.getName()); + lastBranchTagDb = dbName; + lastBranchTagTable = tableName; + lastDropTag = tag; + } + + @Override + public void addPartitionField(String dbName, String tableName, PartitionFieldChange change) { + log.add("addPartitionField:" + dbName + "." + tableName + ":" + change.getColumnName()); + lastPartitionFieldDb = dbName; + lastPartitionFieldTable = tableName; + lastAddPartitionField = change; + } + + @Override + public void dropPartitionField(String dbName, String tableName, PartitionFieldChange change) { + log.add("dropPartitionField:" + dbName + "." + tableName + ":" + change.getPartitionFieldName()); + lastPartitionFieldDb = dbName; + lastPartitionFieldTable = tableName; + lastDropPartitionField = change; + } + + @Override + public void replacePartitionField(String dbName, String tableName, PartitionFieldChange change) { + log.add("replacePartitionField:" + dbName + "." + tableName + ":" + change.getColumnName()); + lastPartitionFieldDb = dbName; + lastPartitionFieldTable = tableName; + lastReplacePartitionField = change; + } + + @Override + public void close() { + log.add("close"); + } +} 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 new file mode 100644 index 00000000000000..7699f6cb188c3a --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/TcclPinningConnectorContextTest.java @@ -0,0 +1,191 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.kerberos.HadoopAuthenticator; + +import org.apache.hadoop.security.UserGroupInformation; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.security.PrivilegedExceptionAction; +import java.util.Collections; +import java.util.Map; + +/** + * Verifies the split-brain guard {@link TcclPinningConnectorContext} adds to the iceberg write/DDL/procedure + * paths: WHY it exists is that iceberg-aws resolves {@code ApacheHttpClientConfigurations} via {@code DynMethods} + * off the thread-context classloader while building the S3 client during a commit, so the commit MUST run with + * the TCCL pinned to the plugin loader or it ClassCasts the parent (fe-core) copy against the child-loaded one. + * These tests pin that contract: the task runs under the plugin loader, the caller's TCCL is always restored, + * and the wrap stays transparent (delegates to the engine context, runs the task INSIDE its auth scope). + */ +public class TcclPinningConnectorContextTest { + + private static ClassLoader isolatedLoader() { + return new URLClassLoader(new URL[0], TcclPinningConnectorContextTest.class.getClassLoader()); + } + + @Test + public void pinsPluginLoaderForTheTaskThenRestoresCallerTccl() throws Exception { + ClassLoader pluginLoader = isolatedLoader(); + ClassLoader callerLoader = isolatedLoader(); + RecordingConnectorContext delegate = new RecordingConnectorContext(); + TcclPinningConnectorContext ctx = new TcclPinningConnectorContext(delegate, pluginLoader, () -> null); + + Thread thread = Thread.currentThread(); + ClassLoader saved = thread.getContextClassLoader(); + thread.setContextClassLoader(callerLoader); + try { + ClassLoader[] seenDuringTask = new ClassLoader[1]; + String result = ctx.executeAuthenticated(() -> { + seenDuringTask[0] = Thread.currentThread().getContextClassLoader(); + return "ok"; + }); + + Assertions.assertEquals("ok", result); + Assertions.assertSame(pluginLoader, seenDuringTask[0], + "the commit body must run with the TCCL pinned to the plugin loader"); + Assertions.assertSame(callerLoader, thread.getContextClassLoader(), + "the caller's TCCL must be restored after the call"); + Assertions.assertEquals(1, delegate.authCount, + "must delegate to the wrapped engine context's executeAuthenticated (1 wrap, not bypassed)"); + } finally { + thread.setContextClassLoader(saved); + } + } + + @Test + public void restoresCallerTcclWhenTheTaskThrows() { + ClassLoader pluginLoader = isolatedLoader(); + ClassLoader callerLoader = isolatedLoader(); + TcclPinningConnectorContext ctx = + new TcclPinningConnectorContext(new RecordingConnectorContext(), pluginLoader, () -> null); + + Thread thread = Thread.currentThread(); + ClassLoader saved = thread.getContextClassLoader(); + thread.setContextClassLoader(callerLoader); + try { + Assertions.assertThrows(IllegalStateException.class, () -> + ctx.executeAuthenticated(() -> { + throw new IllegalStateException("boom"); + })); + Assertions.assertSame(callerLoader, thread.getContextClassLoader(), + "the caller's TCCL must be restored even when the commit body throws"); + } finally { + thread.setContextClassLoader(saved); + } + } + + @Test + public void runsTheTaskInsideTheDelegatesAuthScope() { + // failAuth makes the delegate throw WITHOUT invoking the task; if the task still ran, the wrap would be + // executing it OUTSIDE the auth scope. It must not. + RecordingConnectorContext delegate = new RecordingConnectorContext(); + delegate.failAuth = true; + TcclPinningConnectorContext ctx = new TcclPinningConnectorContext(delegate, isolatedLoader(), () -> null); + + boolean[] taskRan = {false}; + Assertions.assertThrows(RuntimeException.class, () -> + ctx.executeAuthenticated(() -> { + taskRan[0] = true; + return null; + })); + Assertions.assertFalse(taskRan[0], "task must run inside the delegate's auth scope, not around it"); + } + + @Test + public void delegatesNonAuthMethods() { + RecordingConnectorContext delegate = new RecordingConnectorContext(); + TcclPinningConnectorContext ctx = new TcclPinningConnectorContext(delegate, isolatedLoader(), () -> null); + + Assertions.assertEquals("test", ctx.getCatalogName()); + + // createSiblingConnector is a non-auth engine-service method: the decorator must forward it to the raw + // delegate (else a wrapped gateway context would return the SPI default null, masking a real sibling as + // "provider missing"). Assert the type + props reach the delegate unchanged. + Map siblingProps = Collections.singletonMap("iceberg.catalog.type", "hms"); + ctx.createSiblingConnector("iceberg", siblingProps); + Assertions.assertEquals("iceberg", delegate.lastSiblingType, + "createSiblingConnector type must reach the delegate (decorator is an exhaustive pass-through)"); + Assertions.assertSame(siblingProps, delegate.lastSiblingProps, + "createSiblingConnector properties must reach the delegate unchanged"); + } + + @Test + public void kerberosRunsTaskInPluginDoAsAndBypassesDelegateAuth() throws Exception { + // Single-owner auth (Option A): a Kerberos catalog runs the op under the PLUGIN authenticator's doAs and + // must NOT ALSO invoke the FE-injected app-side authenticator (delegate.executeAuthenticated), which only + // authenticates the unused app-loader UGI copy. WHY it matters: the plugin's FileSystem reads the plugin + // UGI, so the plugin doAs is the only auth that reaches secured HDFS; the delegate wrap would be a dead, + // redundant keytab login. MUTATION: nesting inside the delegate (authCount == 1) or skipping the plugin + // doAs (doAsCount == 0) -> red. + ClassLoader pluginLoader = isolatedLoader(); + ClassLoader callerLoader = isolatedLoader(); + RecordingConnectorContext delegate = new RecordingConnectorContext(); + RecordingAuthenticator auth = new RecordingAuthenticator(); + TcclPinningConnectorContext ctx = new TcclPinningConnectorContext(delegate, pluginLoader, () -> auth); + + Thread thread = Thread.currentThread(); + ClassLoader saved = thread.getContextClassLoader(); + thread.setContextClassLoader(callerLoader); + try { + ClassLoader[] seenDuringTask = new ClassLoader[1]; + String result = ctx.executeAuthenticated(() -> { + seenDuringTask[0] = Thread.currentThread().getContextClassLoader(); + return "ok"; + }); + + Assertions.assertEquals("ok", result); + Assertions.assertEquals(1, auth.doAsCount, "the op must run inside the plugin authenticator's doAs"); + Assertions.assertEquals(0, delegate.authCount, + "single-owner: the FE-injected app-side authenticator must NOT be invoked on the Kerberos path"); + Assertions.assertSame(pluginLoader, seenDuringTask[0], + "the op must still run with the TCCL pinned to the plugin loader"); + Assertions.assertSame(callerLoader, thread.getContextClassLoader(), + "the caller's TCCL must be restored"); + } finally { + thread.setContextClassLoader(saved); + } + } + + /** Wiring-only {@link HadoopAuthenticator} double: records doAs calls and runs the action WITHOUT a UGI. */ + private static final class RecordingAuthenticator implements HadoopAuthenticator { + int doAsCount; + + @Override + public UserGroupInformation getUGI() { + throw new UnsupportedOperationException("wiring double: getUGI is unused (doAs is overridden)"); + } + + @Override + public T doAs(PrivilegedExceptionAction action) throws IOException { + doAsCount++; + try { + return action.run(); + } catch (IOException | RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IOException(e); + } + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/TestStatementScope.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/TestStatementScope.java new file mode 100644 index 00000000000000..e6744a21e0948a --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/TestStatementScope.java @@ -0,0 +1,41 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorStatementScope; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * A memoizing {@link ConnectorStatementScope} for iceberg unit tests. The iceberg connector module does not + * depend on fe-core, so tests cannot use the engine's {@code ConnectorStatementScopeImpl}; this is a faithful + * copy of it. Sharing one instance across a scan session and a write session mimics a single statement's scope, + * so a test can prove the read/scan/write resolvers collapse onto one load and that the rewritable-delete supply + * bridges scan→write. + */ +final class TestStatementScope implements ConnectorStatementScope { + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + @Override + @SuppressWarnings("unchecked") + public T computeIfAbsent(String key, Supplier loader) { + return (T) cache.computeIfAbsent(key, k -> loader.get()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/ActionTestTables.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/ActionTestTables.java new file mode 100644 index 00000000000000..fd30e387646f23 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/ActionTestTables.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.iceberg.action; + +import org.apache.doris.connector.api.ConnectorSession; + +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileMetadata; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.types.Types; + +import java.util.Collections; +import java.util.Map; + +/** + * Shared no-Mockito test fixtures for the iceberg {@code ALTER TABLE EXECUTE} action bodies: a real + * {@link InMemoryCatalog}, snapshot seeding via {@code newAppend().commit()}, and a minimal + * {@link ConnectorSession}. Mirrors the {@code InMemoryCatalog} style of {@code IcebergConnectorTransactionTest} + * / {@code IcebergScanPlanProviderTest}; the action bodies operate on the loaded SDK {@link Table} exactly as + * {@code IcebergProcedureOps} hands it to them. + */ +final class ActionTestTables { + + static final Schema SCHEMA = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get())); + + private ActionTestTables() { + } + + static InMemoryCatalog freshCatalog() { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + return catalog; + } + + static TableIdentifier id(String table) { + return TableIdentifier.of("db1", table); + } + + static Table createTable(InMemoryCatalog catalog, String table, Map props) { + return catalog.createTable(id(table), SCHEMA, PartitionSpec.unpartitioned(), props); + } + + static Table createTable(InMemoryCatalog catalog, String table) { + return createTable(catalog, table, Collections.emptyMap()); + } + + /** Appends one data file as a new snapshot and returns the new current snapshot id. */ + static long appendSnapshot(InMemoryCatalog catalog, String table, String fileName, long records) { + Table t = catalog.loadTable(id(table)); + t.newAppend().appendFile(dataFile(fileName, records)).commit(); + return catalog.loadTable(id(table)).currentSnapshot().snapshotId(); + } + + static DataFile dataFile(String fileName, long records) { + return DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("s3://b/db1/" + fileName) + .withFileSizeInBytes(1024) + .withRecordCount(records) + .withFormat(FileFormat.PARQUET) + .build(); + } + + /** Commits a position-delete file as a new snapshot (creates a delete manifest carried forward by later ones). */ + static void addPositionDeleteSnapshot(InMemoryCatalog catalog, String table, String fileName, + String referencedDataFile) { + catalog.loadTable(id(table)).newRowDelta() + .addDeletes(FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) + .ofPositionDeletes() + .withPath("s3://b/db1/" + fileName) + .withFormat(FileFormat.PARQUET) + .withFileSizeInBytes(64L) + .withRecordCount(1L) + .withReferencedDataFile("s3://b/db1/" + referencedDataFile) + .build()) + .commit(); + } + + /** Commits an equality-delete file as a new snapshot (a distinct delete manifest). */ + static void addEqualityDeleteSnapshot(InMemoryCatalog catalog, String table, String fileName) { + catalog.loadTable(id(table)).newRowDelta() + .addDeletes(FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) + .ofEqualityDeletes(1) + .withPath("s3://b/db1/" + fileName) + .withFormat(FileFormat.PARQUET) + .withFileSizeInBytes(64L) + .withRecordCount(1L) + .build()) + .commit(); + } + + static ConnectorSession session(String timeZone) { + return new FakeSession(timeZone); + } + + /** Minimal {@link ConnectorSession} exposing a time zone (the only field the actions consult). */ + private static final class FakeSession implements ConnectorSession { + private final String timeZone; + + FakeSession(String timeZone) { + this.timeZone = timeZone; + } + + @Override + public String getQueryId() { + return "q"; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return timeZone; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public String getCatalogName() { + return "test"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/BaseIcebergActionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/BaseIcebergActionTest.java new file mode 100644 index 00000000000000..03c1fd0cb186f7 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/BaseIcebergActionTest.java @@ -0,0 +1,197 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.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.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.foundation.util.ArgumentParsers; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import org.apache.iceberg.Table; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Pins the connector base for iceberg {@code ALTER TABLE EXECUTE} actions, the standalone port of legacy + * {@code BaseIcebergAction} + the consumed half of {@code BaseExecuteAction}. + * + *

    WHY this matters: the base owns the machinery every procedure shares — argument validation + * delegation, the {@code validateIcebergAction()} hook, the partition/{@code WHERE} guards, and the + * single-row result contract ({@code resultSchema.size() == row.size()}, legacy + * {@code BaseExecuteAction:106-108}). It must reproduce that contract and the legacy guard messages under + * the SPI types ({@code List} partitions, {@link ConnectorPredicate} where, + * {@link ConnectorColumn} schema). The {@code ALTER} privilege check is intentionally absent — the engine + * keeps it (D-062 §2). Exercised through a fake subclass; no SDK table needed.

    + */ +public class BaseIcebergActionTest { + + private static final ConnectorPredicate ANY_WHERE = + new ConnectorPredicate(new ConnectorLiteral(ConnectorType.of("BOOLEAN"), Boolean.TRUE)); + + /** A 2-column BIGINT schema captured at construction (mirrors rollback_to_snapshot's shape). */ + private static List twoBigintSchema() { + return ImmutableList.of( + new ConnectorColumn("previous_snapshot_id", ConnectorType.of("BIGINT"), null, false, null), + new ConnectorColumn("current_snapshot_id", ConnectorType.of("BIGINT"), null, false, null)); + } + + /** + * Fake action over the base. {@code registerIcebergArguments}/{@code getResultSchema} run during the + * super constructor, so they read no instance fields; {@code validateIcebergAction}/{@code executeAction} + * run later and may. + */ + private static final class FakeAction extends BaseIcebergAction { + private final List row; + private final boolean rejectPartitions; + private final boolean rejectWhere; + private final boolean requirePartitions; + private final boolean requireWhere; + + FakeAction(Map properties, List partitionNames, ConnectorPredicate where, + List row, boolean rejectPartitions, boolean rejectWhere, + boolean requirePartitions, boolean requireWhere) { + super("fake", properties, partitionNames, where); + this.row = row; + this.rejectPartitions = rejectPartitions; + this.rejectWhere = rejectWhere; + this.requirePartitions = requirePartitions; + this.requireWhere = requireWhere; + } + + @Override + protected void registerIcebergArguments() { + namedArguments.registerRequiredArgument("snapshot_id", "Snapshot ID", + ArgumentParsers.positiveLong("snapshot_id")); + } + + @Override + protected void validateIcebergAction() { + if (rejectPartitions) { + validateNoPartitions(); + } + if (rejectWhere) { + validateNoWhereCondition(); + } + if (requirePartitions) { + validateRequiredPartitions(); + } + if (requireWhere) { + validateRequiredWhereCondition(); + } + } + + @Override + protected List getResultSchema() { + return twoBigintSchema(); + } + + @Override + protected List executeAction(Table table, ConnectorSession session) { + return row; + } + } + + private static FakeAction action(Map props, List partitions, + ConnectorPredicate where, List row) { + return new FakeAction(props, partitions, where, row, false, false, false, false); + } + + @Test + public void validateDelegatesArgumentValidationToNamedArguments() { + FakeAction a = action(ImmutableMap.of("snapshot_id", "1", "bogus", "2"), + Collections.emptyList(), null, ImmutableList.of("a", "b")); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, a::validate); + Assertions.assertEquals("Unknown argument: bogus", e.getMessage()); + } + + @Test + public void validateAcceptsValidArgumentsWithoutPartitionsOrWhere() { + FakeAction a = action(ImmutableMap.of("snapshot_id", "1"), + Collections.emptyList(), null, ImmutableList.of("a", "b")); + Assertions.assertDoesNotThrow(a::validate); + } + + @Test + public void validateNoPartitionsGuardRejectsWithLegacyMessage() { + FakeAction a = new FakeAction(ImmutableMap.of("snapshot_id", "1"), + ImmutableList.of("p1"), null, ImmutableList.of("a", "b"), + true, false, false, false); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, a::validate); + Assertions.assertEquals("Action 'fake' does not support partition specification", e.getMessage()); + } + + @Test + public void validateNoWhereGuardRejectsWithLegacyMessage() { + FakeAction a = new FakeAction(ImmutableMap.of("snapshot_id", "1"), + Collections.emptyList(), ANY_WHERE, ImmutableList.of("a", "b"), + false, true, false, false); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, a::validate); + Assertions.assertEquals("Action 'fake' does not support WHERE condition", e.getMessage()); + } + + @Test + public void validateRequiredPartitionsGuardRejectsWithLegacyMessage() { + FakeAction a = new FakeAction(ImmutableMap.of("snapshot_id", "1"), + Collections.emptyList(), null, ImmutableList.of("a", "b"), + false, false, true, false); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, a::validate); + Assertions.assertEquals("Action 'fake' requires partition specification", e.getMessage()); + } + + @Test + public void validateRequiredWhereGuardRejectsWithLegacyMessage() { + FakeAction a = new FakeAction(ImmutableMap.of("snapshot_id", "1"), + Collections.emptyList(), null, ImmutableList.of("a", "b"), + false, false, false, true); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, a::validate); + Assertions.assertEquals("Action 'fake' requires WHERE condition", e.getMessage()); + } + + @Test + public void executeWrapsExactlyOneRowMatchingSchemaWidth() { + FakeAction a = action(ImmutableMap.of("snapshot_id", "1"), + Collections.emptyList(), null, ImmutableList.of("10", "20")); + + ConnectorProcedureResult result = a.execute((Table) null, null); + + Assertions.assertEquals(2, result.getResultSchema().size()); + Assertions.assertEquals("previous_snapshot_id", result.getResultSchema().get(0).getName()); + Assertions.assertEquals(1, result.getRows().size(), "a procedure emits exactly one row"); + Assertions.assertEquals(ImmutableList.of("10", "20"), result.getRows().get(0)); + } + + @Test + public void executeFailsLoudWhenRowWidthDoesNotMatchSchema() { + // Schema is 2 columns but the body returns 1 value: the single-row contract guard must fire. + FakeAction a = action(ImmutableMap.of("snapshot_id", "1"), + Collections.emptyList(), null, ImmutableList.of("only-one")); + IllegalStateException e = Assertions.assertThrows(IllegalStateException.class, + () -> a.execute((Table) null, null)); + Assertions.assertEquals("Result row size does not match metadata column count", e.getMessage()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergCherrypickSnapshotActionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergCherrypickSnapshotActionTest.java new file mode 100644 index 00000000000000..47e5999203a957 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergCherrypickSnapshotActionTest.java @@ -0,0 +1,111 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.action; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; + +import com.google.common.collect.ImmutableMap; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +/** + * Pins {@code cherrypick_snapshot}: cherry-picking a staged (WAP-style) snapshot into the current state. + * + *

    WHY this matters: cherry-pick produces a NEW current snapshot from an existing (staged) one. The + * result reports (source id, new current id). Bug-for-bug: the not-found message is the generic + * "Snapshot not found in table" (no id) and is re-wrapped under "Failed to cherry-pick snapshot ..."; the + * post-commit current snapshot is read without a null guard.

    + */ +public class IcebergCherrypickSnapshotActionTest { + + private static IcebergCherrypickSnapshotAction action(String snapshotId) { + return new IcebergCherrypickSnapshotAction( + ImmutableMap.of("snapshot_id", snapshotId), Collections.emptyList(), null); + } + + /** Stages an append (stageOnly) and returns the staged snapshot id (the one not set as current). */ + private static long stageSnapshot(InMemoryCatalog catalog, TableIdentifier id, String file, long current) { + Table t = catalog.loadTable(id); + t.newAppend().stageOnly().appendFile(ActionTestTables.dataFile(file, 5L)).commit(); + Table reloaded = catalog.loadTable(id); + long staged = -1; + for (Snapshot s : reloaded.snapshots()) { + if (s.snapshotId() != current) { + staged = s.snapshotId(); + } + } + return staged; + } + + @Test + public void cherrypicksStagedSnapshotIntoCurrentState() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + long snap1 = ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + long staged = stageSnapshot(catalog, id, "staged.parquet", snap1); + Assertions.assertNotEquals(-1L, staged); + + IcebergCherrypickSnapshotAction action = action(String.valueOf(staged)); + action.validate(); + ConnectorProcedureResult result = action.execute(catalog.loadTable(id), ActionTestTables.session("UTC")); + + long newCurrent = catalog.loadTable(id).currentSnapshot().snapshotId(); + Assertions.assertEquals(String.valueOf(staged), result.getRows().get(0).get(0), + "source_snapshot_id is the cherry-picked snapshot"); + Assertions.assertEquals(String.valueOf(newCurrent), result.getRows().get(0).get(1), + "current_snapshot_id is the post-commit current snapshot"); + Assertions.assertNotEquals(snap1, newCurrent, "cherry-pick advanced the current snapshot"); + } + + @Test + public void unknownSnapshotIsReWrappedWithGenericNotFound() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + + IcebergCherrypickSnapshotAction action = action("999999"); + action.validate(); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> action.execute(catalog.loadTable(id), ActionTestTables.session("UTC"))); + Assertions.assertEquals( + "Failed to cherry-pick snapshot 999999: Snapshot not found in table", e.getMessage()); + } + + @Test + public void resultSchemaIsTwoBigints() { + List schema = action("1").getResultSchema(); + Assertions.assertEquals(2, schema.size()); + Assertions.assertEquals("source_snapshot_id", schema.get(0).getName()); + Assertions.assertEquals("BIGINT", schema.get(0).getType().getTypeName()); + Assertions.assertFalse(schema.get(0).isNullable()); + Assertions.assertEquals("current_snapshot_id", schema.get(1).getName()); + Assertions.assertEquals("BIGINT", schema.get(1).getType().getTypeName()); + Assertions.assertFalse(schema.get(1).isNullable()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactoryTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactoryTest.java new file mode 100644 index 00000000000000..1e0da5abff5955 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactoryTest.java @@ -0,0 +1,84 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.action; + +import org.apache.doris.connector.api.DorisConnectorException; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +/** + * Pins the connector port of legacy {@code IcebergExecuteActionFactory} (the name registry + dispatch). + * + *

    WHY this matters: the supported-name list is exported to {@code getSupportedProcedures()} and + * embedded in the unknown-procedure error, so its membership and order must match legacy byte-for-byte + * (T08 byte-parity). The {@code table} parameter is dropped (it was always dead in legacy). The 9 switch + * cases are added in T04 (the procedure bodies); T03 fixes the registry + the faithful unknown-procedure + * rejection.

    + */ +public class IcebergExecuteActionFactoryTest { + + @Test + public void getSupportedActionsReturnsNineNamesInLegacyOrder() { + Assertions.assertArrayEquals( + new String[] { + "rollback_to_snapshot", + "rollback_to_timestamp", + "set_current_snapshot", + "cherrypick_snapshot", + "fast_forward", + "expire_snapshots", + "rewrite_data_files", + "publish_changes", + "rewrite_manifests", + }, + IcebergExecuteActionFactory.getSupportedActions()); + } + + @Test + public void createActionRejectsUnknownProcedureWithLegacyMessage() { + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> IcebergExecuteActionFactory.createAction( + "no_such_proc", Collections.emptyMap(), Collections.emptyList(), null)); + Assertions.assertEquals( + "Unsupported Iceberg procedure: no_such_proc. Supported procedures: rollback_to_snapshot, " + + "rollback_to_timestamp, set_current_snapshot, cherrypick_snapshot, fast_forward, " + + "expire_snapshots, rewrite_data_files, publish_changes, rewrite_manifests", + e.getMessage()); + } + + /** + * CANARY for the dormant {@code rewrite_data_files} gap: it is advertised in {@link + * IcebergExecuteActionFactory#getSupportedActions()} (9 names) but has NO {@code createAction} switch + * case yet (8 cases), so it falls through to the faithful unknown-procedure rejection. This pins that + * dormant state and goes RED exactly when the T05/T06 body is wired in. + */ + @Test + public void rewriteDataFilesIsAdvertisedButNotYetExecutable() { + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> IcebergExecuteActionFactory.createAction( + "rewrite_data_files", Collections.emptyMap(), Collections.emptyList(), null)); + Assertions.assertTrue( + e.getMessage().startsWith("Unsupported Iceberg procedure: rewrite_data_files"), + e.getMessage()); + Assertions.assertTrue(java.util.Arrays.asList(IcebergExecuteActionFactory.getSupportedActions()) + .contains("rewrite_data_files")); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergExpireSnapshotsActionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergExpireSnapshotsActionTest.java new file mode 100644 index 00000000000000..ef81cf3fed474e --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergExpireSnapshotsActionTest.java @@ -0,0 +1,197 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.action; + +import org.apache.doris.connector.api.ConnectorColumn; +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.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; +import org.apache.iceberg.FileContent; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Pins {@code expire_snapshots}: the validation pass and the six-counter result. + * + *

    WHY this matters: this is the destructive GC procedure with the widest argument surface and a + * fixed six-column ({@code BIGINT}) result built from {@code deleteWith}-callback counters. The validation + * messages (at-least-one-required, the older_than/snapshot_ids format errors) are byte-checked. The happy + * path proves {@code retain_last} actually expires snapshots through the SDK and that the result row matches + * the schema width (the single-row contract over six counters).

    + */ +public class IcebergExpireSnapshotsActionTest { + + private static IcebergExpireSnapshotsAction action(Map props) { + return new IcebergExpireSnapshotsAction(props, Collections.emptyList(), null); + } + + @Test + public void requiresAtLeastOneOfOlderThanRetainLastSnapshotIds() { + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> action(Collections.emptyMap()).validate()); + Assertions.assertEquals("At least one of 'older_than', 'retain_last', or " + + "'snapshot_ids' must be specified", e.getMessage()); + } + + @Test + public void rejectsBadOlderThanFormat() { + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> action(ImmutableMap.of("older_than", "yesterday")).validate()); + Assertions.assertEquals("Invalid older_than format. Expected ISO datetime " + + "(yyyy-MM-ddTHH:mm:ss) or timestamp in milliseconds: yesterday", e.getMessage()); + } + + @Test + public void rejectsBadSnapshotIdsFormat() { + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> action(ImmutableMap.of("snapshot_ids", "1,abc,3")).validate()); + Assertions.assertEquals("Invalid snapshot_id format: abc", e.getMessage()); + } + + @Test + public void expiresOldSnapshotsKeepingRetainLastAndReturnsSixCounters() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + ActionTestTables.appendSnapshot(catalog, "t", "f2.parquet", 2L); + ActionTestTables.appendSnapshot(catalog, "t", "f3.parquet", 3L); + Assertions.assertEquals(3, Iterables.size(catalog.loadTable(id).snapshots())); + + IcebergExpireSnapshotsAction action = action(ImmutableMap.of("retain_last", "1")); + action.validate(); + ConnectorProcedureResult result = action.execute(catalog.loadTable(id), ActionTestTables.session("UTC")); + + List row = result.getRows().get(0); + Assertions.assertEquals(6, row.size(), "expire_snapshots returns the six-counter Spark schema"); + for (String count : row) { + Assertions.assertDoesNotThrow(() -> Long.parseLong(count), "every counter is a number: " + count); + } + // Pin the deleteWith path-classification deterministically. Expiring 2 of 3 append-only snapshots + // (retain_last=1) reclaims the 2 expired snapshots' manifest-LIST files (snap-*.avro, index 4 == 2) + // but NOT the data manifests (still referenced by the retained snapshot, index 3 == 0) nor the data + // files themselves (index 0 == 0); there are no delete/statistics files. A mutation swapping the + // manifest vs manifest-list branch in the deleteWith callback would flip indices 3/4 and go RED. + Assertions.assertEquals(ImmutableList.of("0", "0", "0", "0", "2", "0"), row, + "deleteWith classification: 2 manifest-lists reclaimed, everything else 0"); + Assertions.assertEquals(1, Iterables.size(catalog.loadTable(id).snapshots()), + "retain_last=1 must leave exactly one snapshot"); + } + + @Test + public void resultSchemaIsSixBigintCounters() { + List schema = action(ImmutableMap.of("retain_last", "1")).getResultSchema(); + String[] names = {"deleted_data_files_count", "deleted_position_delete_files_count", + "deleted_equality_delete_files_count", "deleted_manifest_files_count", + "deleted_manifest_lists_count", "deleted_statistics_files_count"}; + Assertions.assertEquals(6, schema.size()); + for (int i = 0; i < 6; i++) { + Assertions.assertEquals(names[i], schema.get(i).getName(), "column " + i + " name"); + Assertions.assertEquals("BIGINT", schema.get(i).getType().getTypeName(), "column " + i + " type"); + } + } + + @Test + public void buildDeleteFileContentMapDedupsManifestsSharedAcrossSnapshots() { + // WHY: expire_snapshots builds the delete-file -> content classification map by scanning every snapshot's + // delete manifests. Iceberg carries immutable delete manifests forward across snapshots, so the naive + // per-snapshot scan re-reads the same manifest once per referencing snapshot. Deduping by manifest path + // must (a) read each DISTINCT delete manifest exactly once — strictly fewer than the per-snapshot total — + // and (b) leave the resulting map byte-identical (every delete path still classified correctly). A + // regression that reset the visited set per snapshot (no dedup) or skipped a distinct manifest (dropping + // its delete files) is killed here. + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + // A position-delete snapshot creates delete manifest DM1... + ActionTestTables.addPositionDeleteSnapshot(catalog, "t", "pos-del.parquet", "f1.parquet"); + // ...an equality-delete snapshot creates DM2 and carries DM1 forward... + ActionTestTables.addEqualityDeleteSnapshot(catalog, "t", "eq-del.parquet"); + // ...and two more appends carry both delete manifests forward unchanged (the redundant re-read source). + ActionTestTables.appendSnapshot(catalog, "t", "f2.parquet", 1L); + ActionTestTables.appendSnapshot(catalog, "t", "f3.parquet", 1L); + + Table table = catalog.loadTable(id); + Set distinctDeleteManifests = new HashSet<>(); + int perSnapshotReads = 0; + for (Snapshot snapshot : table.snapshots()) { + for (ManifestFile manifest : snapshot.deleteManifests(table.io())) { + distinctDeleteManifests.add(manifest.path()); + perSnapshotReads++; + } + } + // Sanity: the fixture must actually reuse a delete manifest across snapshots, else the test proves nothing. + Assertions.assertTrue(perSnapshotReads > distinctDeleteManifests.size(), + "fixture must share a delete manifest across snapshots: perSnapshot=" + perSnapshotReads + + " distinct=" + distinctDeleteManifests.size()); + + IcebergExpireSnapshotsAction action = action(ImmutableMap.of("retain_last", "1")); + Map contentByPath = action.buildDeleteFileContentMap(table); + + Assertions.assertEquals(distinctDeleteManifests.size(), action.lastDeleteManifestReadCount, + "each distinct delete manifest must be read exactly once (dedup), not once per referencing snapshot"); + Assertions.assertEquals(FileContent.POSITION_DELETES, contentByPath.get("s3://b/db1/pos-del.parquet"), + "the position-delete file must stay classified as POSITION_DELETES"); + Assertions.assertEquals(FileContent.EQUALITY_DELETES, contentByPath.get("s3://b/db1/eq-del.parquet"), + "the equality-delete file must stay classified as EQUALITY_DELETES"); + } + + @Test + public void rejectsNegativeOlderThanTimestamp() { + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> action(ImmutableMap.of("older_than", "-1")).validate()); + Assertions.assertEquals("older_than timestamp must be non-negative", e.getMessage()); + } + + @Test + public void rejectsPartitionSpecification() { + IcebergExpireSnapshotsAction a = new IcebergExpireSnapshotsAction( + ImmutableMap.of("retain_last", "1"), Collections.singletonList("p1"), null); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, a::validate); + Assertions.assertEquals("Action 'expire_snapshots' does not support partition specification", + e.getMessage()); + } + + @Test + public void rejectsWhereCondition() { + ConnectorPredicate where = + new ConnectorPredicate(new ConnectorLiteral(ConnectorType.of("BOOLEAN"), Boolean.TRUE)); + IcebergExpireSnapshotsAction a = new IcebergExpireSnapshotsAction( + ImmutableMap.of("retain_last", "1"), Collections.emptyList(), where); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, a::validate); + Assertions.assertEquals("Action 'expire_snapshots' does not support WHERE condition", e.getMessage()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergFastForwardActionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergFastForwardActionTest.java new file mode 100644 index 00000000000000..4ba49a375a294a --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergFastForwardActionTest.java @@ -0,0 +1,108 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.action; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; + +import com.google.common.collect.ImmutableMap; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +/** + * Pins {@code fast_forward}: advancing one branch to the tip of another. + * + *

    WHY this matters: the result row is (branch name, previous ref id, updated ref id) and the + * schema mixes types — {@code branch_updated} is STRING, {@code previous_ref} is the one NULLABLE column + * (legacy passed {@code isAllowNull = true}), {@code updated_ref} is NOT NULL BIGINT. Bug-for-bug: the + * branch name is trimmed only in the output and the post-commit snapshot read is not null-guarded.

    + */ +public class IcebergFastForwardActionTest { + + private static IcebergFastForwardAction action(String branch, String to) { + return new IcebergFastForwardAction( + ImmutableMap.of("branch", branch, "to", to), Collections.emptyList(), null); + } + + @Test + public void fastForwardsBranchToTargetTip() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + long snap1 = ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + // Create branch b1 at snap1, then advance main to snap2 (snap1 is an ancestor of snap2). + catalog.loadTable(id).manageSnapshots().createBranch("b1", snap1).commit(); + long snap2 = ActionTestTables.appendSnapshot(catalog, "t", "f2.parquet", 2L); + + IcebergFastForwardAction action = action("b1", "main"); + action.validate(); + ConnectorProcedureResult result = action.execute(catalog.loadTable(id), ActionTestTables.session("UTC")); + + Assertions.assertEquals( + java.util.Arrays.asList("b1", String.valueOf(snap1), String.valueOf(snap2)), + result.getRows().get(0), + "b1 fast-forwards from snap1 to main's tip snap2"); + Assertions.assertEquals(snap2, catalog.loadTable(id).snapshot( + catalog.loadTable(id).refs().get("b1").snapshotId()).snapshotId()); + } + + @Test + public void unknownTargetIsWrappedUnderFailedToFastForward() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + + // fast-forward main to a non-existent source ref 'ghost' -> the SDK rejects the unknown ref, and the + // body wraps it under "Failed to fast-forward branch to snapshot : ...". + IcebergFastForwardAction action = action("main", "ghost"); + action.validate(); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> action.execute(catalog.loadTable(id), ActionTestTables.session("UTC"))); + Assertions.assertTrue(e.getMessage().startsWith("Failed to fast-forward branch main to snapshot ghost: "), + e.getMessage()); + } + + @Test + public void resultSchemaMixesTypesWithNullablePreviousRef() { + List schema = action("a", "b").getResultSchema(); + Assertions.assertEquals("branch_updated", schema.get(0).getName()); + Assertions.assertEquals("STRING", schema.get(0).getType().getTypeName()); + Assertions.assertFalse(schema.get(0).isNullable()); + Assertions.assertEquals("previous_ref", schema.get(1).getName()); + Assertions.assertEquals("BIGINT", schema.get(1).getType().getTypeName()); + Assertions.assertTrue(schema.get(1).isNullable(), "previous_ref is the one nullable result column"); + Assertions.assertEquals("updated_ref", schema.get(2).getName()); + Assertions.assertEquals("BIGINT", schema.get(2).getType().getTypeName()); + Assertions.assertFalse(schema.get(2).isNullable()); + } + + @Test + public void requiresBothBranchAndToArguments() { + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> new IcebergFastForwardAction(ImmutableMap.of("branch", "b1"), + Collections.emptyList(), null).validate()); + Assertions.assertEquals("Missing required argument: to", e.getMessage()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergPublishChangesActionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergPublishChangesActionTest.java new file mode 100644 index 00000000000000..3f2870dddba465 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergPublishChangesActionTest.java @@ -0,0 +1,138 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.action; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; + +import com.google.common.collect.ImmutableMap; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +/** + * Pins {@code publish_changes}: the WAP (write-audit-publish) pattern that finds a snapshot tagged with a + * given {@code wap.id} and cherry-picks it. + * + *

    WHY this matters: two bug-for-bug quirks drive user-visible output. The result columns are + * {@code STRING} (not {@code BIGINT}), and a null snapshot id renders as the literal {@code "null"} (not SQL + * NULL). The WAP snapshot is located by a linear scan over {@code snapshots()} comparing + * {@code summary().get("wap.id")}. A missing wap.id is rejected with the exact legacy message.

    + */ +public class IcebergPublishChangesActionTest { + + private static IcebergPublishChangesAction action(String wapId) { + return new IcebergPublishChangesAction( + ImmutableMap.of("wap_id", wapId), Collections.emptyList(), null); + } + + /** Stages a WAP append carrying {@code wap.id} in its snapshot summary. */ + private static void stageWap(InMemoryCatalog catalog, TableIdentifier id, String wapId) { + Table t = catalog.loadTable(id); + t.newAppend().stageOnly().set("wap.id", wapId) + .appendFile(ActionTestTables.dataFile("wap-" + wapId + ".parquet", 5L)).commit(); + } + + @Test + public void publishesWapSnapshotReturningStringIds() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + long snap1 = ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + stageWap(catalog, id, "wap-123"); + + IcebergPublishChangesAction action = action("wap-123"); + action.validate(); + ConnectorProcedureResult result = action.execute(catalog.loadTable(id), ActionTestTables.session("UTC")); + + long newCurrent = catalog.loadTable(id).currentSnapshot().snapshotId(); + Assertions.assertEquals(String.valueOf(snap1), result.getRows().get(0).get(0), + "previous_snapshot_id is the pre-publish current snapshot"); + Assertions.assertEquals(String.valueOf(newCurrent), result.getRows().get(0).get(1)); + Assertions.assertNotEquals(snap1, newCurrent, "publish advanced the current snapshot"); + } + + @Test + public void rendersNullPreviousSnapshotAsLiteralNull() { + // Empty table -> currentSnapshot() is null when the body reads the previous id -> the literal "null". + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + stageWap(catalog, id, "wap-empty"); + + IcebergPublishChangesAction action = action("wap-empty"); + action.validate(); + ConnectorProcedureResult result = action.execute(catalog.loadTable(id), ActionTestTables.session("UTC")); + + Assertions.assertEquals("null", result.getRows().get(0).get(0), + "a null previous snapshot id is the literal string \"null\", not SQL NULL"); + } + + @Test + public void rejectsMissingWapIdWithLegacyMessage() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + + IcebergPublishChangesAction action = action("missing"); + action.validate(); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> action.execute(catalog.loadTable(id), ActionTestTables.session("UTC"))); + Assertions.assertEquals("Cannot find snapshot with wap.id = missing", e.getMessage()); + } + + @Test + public void resultSchemaIsTwoStrings() { + List schema = action("x").getResultSchema(); + Assertions.assertEquals(2, schema.size()); + Assertions.assertEquals("previous_snapshot_id", schema.get(0).getName()); + Assertions.assertEquals("STRING", schema.get(0).getType().getTypeName()); + Assertions.assertFalse(schema.get(0).isNullable()); + Assertions.assertEquals("current_snapshot_id", schema.get(1).getName()); + Assertions.assertEquals("STRING", schema.get(1).getType().getTypeName()); + Assertions.assertFalse(schema.get(1).isNullable()); + } + + @Test + public void wrapsCherrypickFailureWithLegacyMessage() { + // A committed (non-staged) append carrying wap.id becomes the current snapshot, so cherry-picking it + // throws iceberg's "already an ancestor" ValidationException -> the body's catch wraps it with the + // exact legacy prefix. + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + catalog.loadTable(id).newAppend() + .appendFile(ActionTestTables.dataFile("f1.parquet", 1L)) + .set("wap.id", "wap-boom").commit(); + + IcebergPublishChangesAction action = action("wap-boom"); + action.validate(); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> action.execute(catalog.loadTable(id), ActionTestTables.session("UTC"))); + Assertions.assertTrue(e.getMessage().startsWith("Failed to publish changes for wap.id wap-boom: "), + e.getMessage()); + Assertions.assertNotNull(e.getCause()); + } +} 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 new file mode 100644 index 00000000000000..fc09263a143b22 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRewriteDataFilesActionTest.java @@ -0,0 +1,114 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.action; + +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.iceberg.rewrite.RewriteDataFilePlanner; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +/** + * Pins the argument spec + planning-parameter build of the connector {@link IcebergRewriteDataFilesAction} + * (WS-REWRITE R3). The arguments and the min/max-file-size defaulting are ported verbatim from the engine + * action; the validation messages must stay byte-identical (the connector reuses the shared fe-foundation + * {@code NamedArguments}). The whole path is dormant until the P6.6 cutover. + */ +public class IcebergRewriteDataFilesActionTest { + + private static IcebergRewriteDataFilesAction action(java.util.Map props, + java.util.List partitionNames) { + return new IcebergRewriteDataFilesAction(props, partitionNames, null); + } + + @Test + public void defaultsMinMaxFileSizeFromTarget() { + IcebergRewriteDataFilesAction a = action(Collections.emptyMap(), Collections.emptyList()); + a.validate(); + RewriteDataFilePlanner.Parameters p = a.buildRewriteParameters(); + // WHY: min/max-file-size are not plain defaults — when unset (0) they derive from target (75% / 180%), + // the rule that decides which files are "outside the desired size range" and thus rewritten. MUTATION: + // dropping the 0.75/1.8 defaulting -> min/max stay 0 -> nothing is ever too-small/too-large -> red. + Assertions.assertEquals(536870912L, p.getTargetFileSizeBytes()); + Assertions.assertEquals((long) (536870912L * 0.75), p.getMinFileSizeBytes()); + Assertions.assertEquals((long) (536870912L * 1.8), p.getMaxFileSizeBytes()); + Assertions.assertEquals(5, p.getMinInputFiles()); + Assertions.assertFalse(p.isRewriteAll()); + } + + @Test + public void rejectsMinFileSizeAboveMax() { + IcebergRewriteDataFilesAction a = action( + ImmutableMap.of("min-file-size-bytes", "100", "max-file-size-bytes", "50"), + Collections.emptyList()); + // WHY: an inverted min/max range would silently rewrite nothing (or everything); the action must reject + // it with the legacy byte-identical message. MUTATION: dropping the min<=max check -> no throw -> red. + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, a::validate); + Assertions.assertEquals( + "min-file-size-bytes must be less than or equal to max-file-size-bytes", e.getMessage()); + } + + @Test + public void rejectsPartitionSpecification() { + IcebergRewriteDataFilesAction a = action(Collections.emptyMap(), ImmutableList.of("p1")); + // WHY: rewrite_data_files does not accept a PARTITION (...) clause (only WHERE); it must reject one. + // MUTATION: dropping validateNoPartitions() -> no throw -> red. + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, a::validate); + Assertions.assertTrue(e.getMessage().contains("does not support partition specification"), e.getMessage()); + } + + @Test + public void buildRewriteParametersCarriesExplicitArgs() { + IcebergRewriteDataFilesAction a = action( + ImmutableMap.builder() + .put("target-file-size-bytes", "1000") + .put("min-input-files", "3") + .put("rewrite-all", "true") + .put("delete-file-threshold", "7") + .put("delete-ratio-threshold", "0.5") + .build(), + Collections.emptyList()); + a.validate(); + RewriteDataFilePlanner.Parameters p = a.buildRewriteParameters(); + // WHY: every explicit argument must reach the planner unchanged (each drives a real selection rule). + // MUTATION: buildRewriteParameters reading the wrong namedArgument -> a mismatch below -> red. + Assertions.assertEquals(1000L, p.getTargetFileSizeBytes()); + Assertions.assertEquals(3, p.getMinInputFiles()); + Assertions.assertTrue(p.isRewriteAll()); + Assertions.assertEquals(7, p.getDeleteFileThreshold()); + Assertions.assertEquals(0.5, p.getDeleteRatioThreshold()); + // min/max still derive off the explicit target (750 / 1800) since they were not given. + Assertions.assertEquals(750L, p.getMinFileSizeBytes()); + Assertions.assertEquals(1800L, p.getMaxFileSizeBytes()); + } + + @Test + public void executeActionThrowsBecauseDistributed() { + IcebergRewriteDataFilesAction a = action(Collections.emptyMap(), Collections.emptyList()); + // WHY: rewrite_data_files has no synchronous single-call body (it is the DISTRIBUTED procedure); the + // execute() path must fail loud rather than silently no-op if a future caller wires it wrong. MUTATION: + // executeAction returning a row instead of throwing -> no throw -> red. + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> a.execute(null, null)); + Assertions.assertTrue(e.getMessage().contains("distributed procedure"), e.getMessage()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRewriteManifestsActionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRewriteManifestsActionTest.java new file mode 100644 index 00000000000000..41b970ceeacac8 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRewriteManifestsActionTest.java @@ -0,0 +1,368 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.action; + +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.DeleteFiles; +import org.apache.iceberg.ExpireSnapshots; +import org.apache.iceberg.HistoryEntry; +import org.apache.iceberg.ManageSnapshots; +import org.apache.iceberg.OverwriteFiles; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.ReplacePartitions; +import org.apache.iceberg.ReplaceSortOrder; +import org.apache.iceberg.RewriteFiles; +import org.apache.iceberg.RewriteManifests; +import org.apache.iceberg.RowDelta; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.StatisticsFile; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableScan; +import org.apache.iceberg.Transaction; +import org.apache.iceberg.UpdateLocation; +import org.apache.iceberg.UpdatePartitionSpec; +import org.apache.iceberg.UpdateProperties; +import org.apache.iceberg.UpdateSchema; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.encryption.EncryptionManager; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.LocationProvider; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Pins {@code rewrite_manifests}, including the delegated connector {@link RewriteManifestExecutor}. + * + *

    WHY this matters: the body short-circuits an empty table to {@code ["0", "0"]} (no executor + * call), and otherwise reports (rewritten, added) manifest counts from the executor. The executor is the + * fe-core port stripped of its {@code ExternalTable}/{@code ExtMetaCacheMgr} couplings; this verifies it + * actually combines the per-append manifests through the SDK {@code rewriteManifests()} API.

    + */ +public class IcebergRewriteManifestsActionTest { + + private static IcebergRewriteManifestsAction action(java.util.Map props) { + return new IcebergRewriteManifestsAction(props, Collections.emptyList(), null); + } + + @Test + public void emptyTableShortCircuitsToZeroZero() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + + IcebergRewriteManifestsAction action = action(Collections.emptyMap()); + action.validate(); + ConnectorProcedureResult result = action.execute(catalog.loadTable(id), ActionTestTables.session("UTC")); + + Assertions.assertEquals(ImmutableList.of("0", "0"), result.getRows().get(0), + "an empty table (no current snapshot) returns 0/0 without touching the executor"); + } + + @Test + public void combinesPerAppendManifests() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + // Three separate appends -> three data manifests accumulate in the current snapshot. + ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + ActionTestTables.appendSnapshot(catalog, "t", "f2.parquet", 2L); + ActionTestTables.appendSnapshot(catalog, "t", "f3.parquet", 3L); + Table before = catalog.loadTable(id); + int manifestsBefore = before.currentSnapshot().dataManifests(before.io()).size(); + Assertions.assertEquals(3, manifestsBefore); + + IcebergRewriteManifestsAction action = action(Collections.emptyMap()); + action.validate(); + ConnectorProcedureResult result = action.execute(catalog.loadTable(id), ActionTestTables.session("UTC")); + + // The executor reports manifestsBefore.size() as the rewritten count (the faithful port behaviour); + // the added count is iceberg's internal combine outcome, asserted only as a valid non-negative int. + Assertions.assertEquals(String.valueOf(manifestsBefore), result.getRows().get(0).get(0), + "rewritten_manifests_count is the number of manifests targeted for rewrite"); + Assertions.assertTrue(Integer.parseInt(result.getRows().get(0).get(1)) >= 0, + "added_manifests_count is a valid non-negative count"); + } + + @Test + public void resultSchemaIsTwoInts() { + Assertions.assertEquals(2, action(Collections.emptyMap()).getResultSchema().size()); + Assertions.assertEquals("rewritten_manifests_count", + action(Collections.emptyMap()).getResultSchema().get(0).getName()); + Assertions.assertEquals("added_manifests_count", + action(Collections.emptyMap()).getResultSchema().get(1).getName()); + Assertions.assertEquals("INT", + action(Collections.emptyMap()).getResultSchema().get(0).getType().getTypeName()); + Assertions.assertEquals("INT", + action(Collections.emptyMap()).getResultSchema().get(1).getType().getTypeName()); + Assertions.assertFalse(action(Collections.emptyMap()).getResultSchema().get(0).isNullable()); + Assertions.assertFalse(action(Collections.emptyMap()).getResultSchema().get(1).isNullable()); + } + + @Test + public void specIdAcceptsZeroToMaxRange() { + // spec_id is optional with intRange(0, MAX); a negative value is rejected at parse time. + Assertions.assertDoesNotThrow(() -> action(ImmutableMap.of("spec_id", "0")).validate()); + } + + @Test + public void specIdFiltersManifestsByPartitionSpec() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + ActionTestTables.appendSnapshot(catalog, "t", "f2.parquet", 2L); + ActionTestTables.appendSnapshot(catalog, "t", "f3.parquet", 3L); + // getInt(SPEC_ID) reads parsedValues populated by validate(), so validate() MUST run before execute() + // for the spec_id filter to take effect (skipping it makes spec_id silently a no-op). Run the + // non-matching case FIRST: all manifests are spec 0, so spec_id=1 targets zero -> the executor + // short-circuits at rewrittenCount==0 to ["0","0"] WITHOUT committing, leaving the table pristine for + // the matching case below. + IcebergRewriteManifestsAction miss = action(ImmutableMap.of("spec_id", "1")); + miss.validate(); + ConnectorProcedureResult unmatched = miss.execute(catalog.loadTable(id), ActionTestTables.session("UTC")); + Assertions.assertEquals(ImmutableList.of("0", "0"), unmatched.getRows().get(0), + "spec_id=1 matches none of the (all spec-0) manifests -> rewrittenCount==0 short-circuit"); + // spec_id=0 matches the unpartitioned spec -> all three manifests are targeted for rewrite. + IcebergRewriteManifestsAction keep = action(ImmutableMap.of("spec_id", "0")); + keep.validate(); + ConnectorProcedureResult kept = keep.execute(catalog.loadTable(id), ActionTestTables.session("UTC")); + Assertions.assertEquals("3", kept.getRows().get(0).get(0), + "spec_id=0 targets every manifest's partitionSpecId()==0"); + } + + @Test + public void wrapsCurrentSnapshotFailure() { + // In the connector port, executeAction probes icebergTable.currentSnapshot() itself (before the + // executor); a throw there is caught and wrapped ONCE by the action's "Rewrite manifests failed: " + // handler. (The executor's own "Failed to rewrite manifests: " prefix is not reached on this path, + // because the action short-circuits the empty/throwing snapshot before constructing the executor.) + Table throwing = new ThrowingTable(); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> action(Collections.emptyMap()).execute(throwing, ActionTestTables.session("UTC"))); + Assertions.assertEquals("Rewrite manifests failed: boom", e.getMessage()); + } + + @Test + public void executorWrapsFailureWithInnerPrefix() { + // The other half of the double-wrap quirk: RewriteManifestExecutor.execute probes + // table.currentSnapshot() first inside its OWN try, so a throw there is wrapped with the executor's + // inner "Failed to rewrite manifests: " prefix. Composed with the action's outer "Rewrite manifests + // failed: " wrap (wrapsCurrentSnapshotFailure), this pins BOTH layers of the byte string the action + // emits when the executor itself fails ("Rewrite manifests failed: Failed to rewrite manifests: ..."). + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> new RewriteManifestExecutor().execute(new ThrowingTable(), null)); + Assertions.assertEquals("Failed to rewrite manifests: boom", e.getMessage()); + } + + /** + * Minimal {@link Table} double whose {@link #currentSnapshot()} throws — the first probe inside the + * action's {@code executeAction}. The action body wraps it as "Rewrite manifests failed: boom". + * {@link #name()} is read only by the LOG.warn call so it must not throw; every other method is outside + * this path and fails loud. + */ + private static final class ThrowingTable implements Table { + + @Override + public String name() { + return "throwing"; + } + + @Override + public Snapshot currentSnapshot() { + throw new RuntimeException("boom"); + } + + @Override + public void refresh() { + throw new UnsupportedOperationException(); + } + + @Override + public TableScan newScan() { + throw new UnsupportedOperationException(); + } + + @Override + public Schema schema() { + throw new UnsupportedOperationException(); + } + + @Override + public Map schemas() { + throw new UnsupportedOperationException(); + } + + @Override + public PartitionSpec spec() { + throw new UnsupportedOperationException(); + } + + @Override + public Map specs() { + throw new UnsupportedOperationException(); + } + + @Override + public SortOrder sortOrder() { + throw new UnsupportedOperationException(); + } + + @Override + public Map sortOrders() { + throw new UnsupportedOperationException(); + } + + @Override + public Map properties() { + throw new UnsupportedOperationException(); + } + + @Override + public String location() { + throw new UnsupportedOperationException(); + } + + @Override + public Snapshot snapshot(long snapshotId) { + throw new UnsupportedOperationException(); + } + + @Override + public Iterable snapshots() { + throw new UnsupportedOperationException(); + } + + @Override + public List history() { + throw new UnsupportedOperationException(); + } + + @Override + public UpdateSchema updateSchema() { + throw new UnsupportedOperationException(); + } + + @Override + public UpdatePartitionSpec updateSpec() { + throw new UnsupportedOperationException(); + } + + @Override + public UpdateProperties updateProperties() { + throw new UnsupportedOperationException(); + } + + @Override + public ReplaceSortOrder replaceSortOrder() { + throw new UnsupportedOperationException(); + } + + @Override + public UpdateLocation updateLocation() { + throw new UnsupportedOperationException(); + } + + @Override + public AppendFiles newAppend() { + throw new UnsupportedOperationException(); + } + + @Override + public RewriteFiles newRewrite() { + throw new UnsupportedOperationException(); + } + + @Override + public RewriteManifests rewriteManifests() { + throw new UnsupportedOperationException(); + } + + @Override + public OverwriteFiles newOverwrite() { + throw new UnsupportedOperationException(); + } + + @Override + public RowDelta newRowDelta() { + throw new UnsupportedOperationException(); + } + + @Override + public ReplacePartitions newReplacePartitions() { + throw new UnsupportedOperationException(); + } + + @Override + public DeleteFiles newDelete() { + throw new UnsupportedOperationException(); + } + + @Override + public ExpireSnapshots expireSnapshots() { + throw new UnsupportedOperationException(); + } + + @Override + public ManageSnapshots manageSnapshots() { + throw new UnsupportedOperationException(); + } + + @Override + public Transaction newTransaction() { + throw new UnsupportedOperationException(); + } + + @Override + public FileIO io() { + throw new UnsupportedOperationException(); + } + + @Override + public EncryptionManager encryption() { + throw new UnsupportedOperationException(); + } + + @Override + public LocationProvider locationProvider() { + throw new UnsupportedOperationException(); + } + + @Override + public List statisticsFiles() { + throw new UnsupportedOperationException(); + } + + @Override + public Map refs() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRollbackToSnapshotActionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRollbackToSnapshotActionTest.java new file mode 100644 index 00000000000000..1949d1e489d483 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRollbackToSnapshotActionTest.java @@ -0,0 +1,125 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.action; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +/** + * Pins {@code rollback_to_snapshot} against a real {@link InMemoryCatalog}. + * + *

    WHY this matters: this is a destructive metadata mutation. The body must roll the table back to + * the requested snapshot ({@code manageSnapshots().rollbackTo(id).commit()}), return the (previous, target) + * ids, short-circuit when already on the target (no commit), and reject an unknown id with the legacy message + * before the try (so it is NOT re-wrapped). The result schema is two NOT-NULL BIGINT columns. Any + * drift here changes user-visible {@code .out} output (T08 byte-parity).

    + */ +public class IcebergRollbackToSnapshotActionTest { + + private static IcebergRollbackToSnapshotAction action(String snapshotId) { + return new IcebergRollbackToSnapshotAction( + ImmutableMap.of("snapshot_id", snapshotId), Collections.emptyList(), null); + } + + @Test + public void rollsBackToEarlierSnapshotAndReturnsPreviousAndTarget() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + long snap1 = ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + long snap2 = ActionTestTables.appendSnapshot(catalog, "t", "f2.parquet", 2L); + Assertions.assertNotEquals(snap1, snap2); + + IcebergRollbackToSnapshotAction action = action(String.valueOf(snap1)); + action.validate(); + ConnectorProcedureResult result = action.execute(catalog.loadTable(id), ActionTestTables.session("UTC")); + + Assertions.assertEquals( + ImmutableList.of(String.valueOf(snap2), String.valueOf(snap1)), + result.getRows().get(0), + "result is (previous=snap2, target=snap1)"); + Assertions.assertEquals(snap1, catalog.loadTable(id).currentSnapshot().snapshotId(), + "the table must be rolled back to snap1"); + } + + @Test + public void shortCircuitsWhenAlreadyOnTargetWithoutCommitting() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + long snap2 = ActionTestTables.appendSnapshot(catalog, "t", "f2.parquet", 2L); + long historyBefore = catalog.loadTable(id).history().size(); + + IcebergRollbackToSnapshotAction action = action(String.valueOf(snap2)); + action.validate(); + ConnectorProcedureResult result = action.execute(catalog.loadTable(id), ActionTestTables.session("UTC")); + + Assertions.assertEquals(ImmutableList.of(String.valueOf(snap2), String.valueOf(snap2)), + result.getRows().get(0)); + Assertions.assertEquals(historyBefore, catalog.loadTable(id).history().size(), + "rolling back to the current snapshot must not append history (short-circuit, no commit)"); + } + + @Test + public void rejectsUnknownSnapshotIdWithLegacyMessageBeforeWrapping() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + + IcebergRollbackToSnapshotAction action = action("999999"); + action.validate(); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> action.execute(catalog.loadTable(id), ActionTestTables.session("UTC"))); + // Legacy throws this BEFORE the try -> it is not wrapped by "Failed to rollback to snapshot ...". + Assertions.assertTrue(e.getMessage().startsWith("Snapshot 999999 not found in table "), + "unknown id is rejected verbatim, not wrapped: " + e.getMessage()); + } + + @Test + public void requiresSnapshotIdArgument() { + IcebergRollbackToSnapshotAction action = new IcebergRollbackToSnapshotAction( + Collections.emptyMap(), Collections.emptyList(), null); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, action::validate); + Assertions.assertEquals("Missing required argument: snapshot_id", e.getMessage()); + } + + @Test + public void resultSchemaIsTwoNotNullBigints() { + List schema = action("1").getResultSchema(); + Assertions.assertEquals(2, schema.size()); + Assertions.assertEquals("previous_snapshot_id", schema.get(0).getName()); + Assertions.assertEquals("BIGINT", schema.get(0).getType().getTypeName()); + Assertions.assertFalse(schema.get(0).isNullable()); + Assertions.assertEquals("current_snapshot_id", schema.get(1).getName()); + Assertions.assertEquals("BIGINT", schema.get(1).getType().getTypeName()); + Assertions.assertFalse(schema.get(1).isNullable()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRollbackToTimestampActionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRollbackToTimestampActionTest.java new file mode 100644 index 00000000000000..28e2d0c5ef1502 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRollbackToTimestampActionTest.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.iceberg.action; + +import org.apache.doris.connector.api.ConnectorColumn; +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.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.connector.iceberg.IcebergTimeUtils; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Collections; +import java.util.List; + +/** + * Pins {@code rollback_to_timestamp}, including the connector-specific time-zone handling. + * + *

    WHY this matters: the timestamp argument is parsed in the SESSION time zone, the one piece of + * legacy behaviour the connector cannot inherit from {@code ConnectContext}. The millisecond format + * ({@code yyyy-MM-dd HH:mm:ss.SSS}, NOT the second format of {@code FOR TIME AS OF}) and the Doris alias map + * (CST -> Asia/Shanghai) must both survive, or a {@code rollback_to_timestamp 'CST datetime'} pins the wrong + * snapshot. {@link IcebergRollbackToTimestampAction#parseTimestampMillis} keeps the legacy millis-first, + * {@code -1}-sentinel contract.

    + */ +public class IcebergRollbackToTimestampActionTest { + + private static IcebergRollbackToTimestampAction action(String timestamp) { + return new IcebergRollbackToTimestampAction( + ImmutableMap.of("timestamp", timestamp), Collections.emptyList(), null); + } + + // ─────────────────── parseTimestampMillis: TZ + format parity (deterministic) ─────────────────── + + @Test + public void parsesEpochMillisDirectly() { + Assertions.assertEquals(1700000000000L, + IcebergRollbackToTimestampAction.parseTimestampMillis("1700000000000", ZoneId.of("UTC"))); + } + + @Test + public void parsesMillisDatetimeInGivenZoneNotUtc() { + long shanghai = IcebergRollbackToTimestampAction.parseTimestampMillis( + "2023-01-01 00:00:00.000", ZoneId.of("Asia/Shanghai")); + long expected = LocalDateTime.parse("2023-01-01T00:00:00") + .atZone(ZoneId.of("Asia/Shanghai")).toInstant().toEpochMilli(); + Assertions.assertEquals(expected, shanghai, "the datetime must be interpreted in the session zone"); + // The same wall-clock string in UTC is 8h later in epoch terms -> proves the zone is actually applied. + Assertions.assertNotEquals( + IcebergRollbackToTimestampAction.parseTimestampMillis("2023-01-01 00:00:00.000", ZoneId.of("UTC")), + shanghai); + } + + @Test + public void cstAliasResolvesToShanghaiThroughSessionZone() { + // The connector resolves the session time zone through the Doris alias map; CST is Asia/Shanghai + // (NOT the US Central a bare ZoneId.of would reject). This is what the action feeds parseTimestampMillis. + Assertions.assertEquals(ZoneId.of("Asia/Shanghai"), + IcebergTimeUtils.resolveSessionZone(ActionTestTables.session("CST"))); + } + + @Test + public void rejectsNegativeEpochMillis() { + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, + () -> IcebergRollbackToTimestampAction.parseTimestampMillis("-5", ZoneId.of("UTC"))); + Assertions.assertEquals("Timestamp must be non-negative: -5", e.getMessage()); + } + + @Test + public void rejectsUnparseableTimestampWithLegacyMessage() { + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, + () -> IcebergRollbackToTimestampAction.parseTimestampMillis("not-a-time", ZoneId.of("UTC"))); + Assertions.assertEquals("Invalid timestamp format. Expected ISO datetime " + + "(yyyy-MM-dd HH:mm:ss.SSS) or timestamp in milliseconds: not-a-time", e.getMessage()); + } + + // ─────────────────── argument validation (verbatim custom parser) ─────────────────── + + @Test + public void argumentValidationRejectsBadFormat() { + IcebergRollbackToTimestampAction action = action("2023/01/01"); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, action::validate); + Assertions.assertTrue(e.getMessage().contains("Invalid value for argument 'timestamp'"), e.getMessage()); + Assertions.assertTrue(e.getMessage().contains("Invalid timestamp format"), e.getMessage()); + } + + // ─────────────────── full body against InMemoryCatalog ─────────────────── + + @Test + public void rollsBackToSnapshotCurrentAtTimestamp() throws InterruptedException { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + long snap1 = ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + long t1 = catalog.loadTable(id).currentSnapshot().timestampMillis(); + Thread.sleep(5); // ensure snap2 gets a strictly-later commit timestamp + long snap2 = ActionTestTables.appendSnapshot(catalog, "t", "f2.parquet", 2L); + Assertions.assertTrue(catalog.loadTable(id).currentSnapshot().timestampMillis() > t1); + + // Roll back to a time after snap1 but before snap2 (rollbackToTime needs a snapshot strictly older than + // the target). The latest such snapshot is snap1. Result is (previous=snap2, current=snap1). + IcebergRollbackToTimestampAction action = action(String.valueOf(t1 + 1)); + action.validate(); + ConnectorProcedureResult result = action.execute(catalog.loadTable(id), ActionTestTables.session("UTC")); + + Assertions.assertEquals(ImmutableList.of(String.valueOf(snap2), String.valueOf(snap1)), + result.getRows().get(0)); + Assertions.assertEquals(snap1, catalog.loadTable(id).currentSnapshot().snapshotId()); + } + + // ─────────────────── result schema + partition/WHERE rejection (T08 byte-parity) ─────────────────── + + @Test + public void resultSchemaIsTwoNotNullBigints() { + List schema = action("1700000000000").getResultSchema(); + Assertions.assertEquals(2, schema.size()); + Assertions.assertEquals("previous_snapshot_id", schema.get(0).getName()); + Assertions.assertEquals("BIGINT", schema.get(0).getType().getTypeName()); + Assertions.assertFalse(schema.get(0).isNullable()); + Assertions.assertEquals("current_snapshot_id", schema.get(1).getName()); + Assertions.assertEquals("BIGINT", schema.get(1).getType().getTypeName()); + Assertions.assertFalse(schema.get(1).isNullable()); + } + + @Test + public void rejectsPartitionSpec() { + IcebergRollbackToTimestampAction a = new IcebergRollbackToTimestampAction( + ImmutableMap.of("timestamp", "1700000000000"), ImmutableList.of("p1"), null); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, a::validate); + Assertions.assertEquals("Action 'rollback_to_timestamp' does not support partition specification", + e.getMessage()); + } + + @Test + public void rejectsWhereCondition() { + ConnectorPredicate where = + new ConnectorPredicate(new ConnectorLiteral(ConnectorType.of("BOOLEAN"), Boolean.TRUE)); + IcebergRollbackToTimestampAction a = new IcebergRollbackToTimestampAction( + ImmutableMap.of("timestamp", "1700000000000"), Collections.emptyList(), where); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, a::validate); + Assertions.assertEquals("Action 'rollback_to_timestamp' does not support WHERE condition", + e.getMessage()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergSetCurrentSnapshotActionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergSetCurrentSnapshotActionTest.java new file mode 100644 index 00000000000000..a03570b93200be --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergSetCurrentSnapshotActionTest.java @@ -0,0 +1,191 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.action; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Pins {@code set_current_snapshot}: the {@code snapshot_id} XOR {@code ref} validation, both resolution + * branches, and the not-found errors. + * + *

    WHY this matters: the mutual-exclusion validation gates a destructive {@code setCurrentSnapshot} + * commit; the ref branch resolves a branch/tag to its snapshot id (the result always reports the resolved + * id). The not-found checks live INSIDE the try, so legacy re-wraps them under "Failed to set current + * snapshot to ..." — a parity detail that differs from {@code rollback_to_snapshot} (which checks before the + * try). All four validation/lookup messages are byte-checked (T08).

    + */ +public class IcebergSetCurrentSnapshotActionTest { + + private static IcebergSetCurrentSnapshotAction action(Map props) { + return new IcebergSetCurrentSnapshotAction(props, Collections.emptyList(), null); + } + + @Test + public void rejectsWhenNeitherSnapshotIdNorRefProvided() { + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> action(Collections.emptyMap()).validate()); + Assertions.assertEquals("Either snapshot_id or ref must be provided", e.getMessage()); + } + + @Test + public void rejectsWhenBothSnapshotIdAndRefProvided() { + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> action(ImmutableMap.of("snapshot_id", "1", "ref", "main")).validate()); + Assertions.assertEquals("snapshot_id and ref are mutually exclusive, only one can be provided", + e.getMessage()); + } + + @Test + public void setsCurrentBySnapshotId() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + long snap1 = ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + long snap2 = ActionTestTables.appendSnapshot(catalog, "t", "f2.parquet", 2L); + + IcebergSetCurrentSnapshotAction action = action(ImmutableMap.of("snapshot_id", String.valueOf(snap1))); + action.validate(); + ConnectorProcedureResult result = action.execute(catalog.loadTable(id), ActionTestTables.session("UTC")); + + Assertions.assertEquals(ImmutableList.of(String.valueOf(snap2), String.valueOf(snap1)), + result.getRows().get(0)); + Assertions.assertEquals(snap1, catalog.loadTable(id).currentSnapshot().snapshotId()); + } + + @Test + public void setsCurrentByRefResolvingToSnapshotId() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + long snap1 = ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + long snap2 = ActionTestTables.appendSnapshot(catalog, "t", "f2.parquet", 2L); + Table tagged = catalog.loadTable(id); + tagged.manageSnapshots().createTag("v1", snap1).commit(); + + IcebergSetCurrentSnapshotAction action = action(ImmutableMap.of("ref", "v1")); + action.validate(); + ConnectorProcedureResult result = action.execute(catalog.loadTable(id), ActionTestTables.session("UTC")); + + Assertions.assertEquals(ImmutableList.of(String.valueOf(snap2), String.valueOf(snap1)), + result.getRows().get(0), + "the ref resolves to snap1, which becomes current"); + Assertions.assertEquals(snap1, catalog.loadTable(id).currentSnapshot().snapshotId()); + } + + @Test + public void unknownSnapshotIdIsReWrappedUnderFailedToSetCurrent() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + + IcebergSetCurrentSnapshotAction action = action(ImmutableMap.of("snapshot_id", "999999")); + action.validate(); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> action.execute(catalog.loadTable(id), ActionTestTables.session("UTC"))); + // Inside the try -> wrapped (contrast rollback_to_snapshot, which throws before the try). + Assertions.assertTrue(e.getMessage().startsWith("Failed to set current snapshot to snapshot 999999: "), + e.getMessage()); + Assertions.assertTrue(e.getMessage().contains("Snapshot 999999 not found in table "), e.getMessage()); + } + + @Test + public void unknownRefIsReWrappedWithReferenceWording() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + + IcebergSetCurrentSnapshotAction action = action(ImmutableMap.of("ref", "nope")); + action.validate(); + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> action.execute(catalog.loadTable(id), ActionTestTables.session("UTC"))); + Assertions.assertTrue(e.getMessage().startsWith("Failed to set current snapshot to reference 'nope': "), + e.getMessage()); + Assertions.assertTrue(e.getMessage().contains("Reference 'nope' not found in table "), e.getMessage()); + } + + @Test + public void shortCircuitsWhenAlreadyOnTargetSnapshotIdWithoutCommitting() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + long snap2 = ActionTestTables.appendSnapshot(catalog, "t", "f2.parquet", 2L); + long historyBefore = catalog.loadTable(id).history().size(); + + // Target is the already-current snapshot -> the snapshot_id branch returns without setCurrentSnapshot().commit(). + IcebergSetCurrentSnapshotAction action = action(ImmutableMap.of("snapshot_id", String.valueOf(snap2))); + action.validate(); + ConnectorProcedureResult result = action.execute(catalog.loadTable(id), ActionTestTables.session("UTC")); + + Assertions.assertEquals(ImmutableList.of(String.valueOf(snap2), String.valueOf(snap2)), + result.getRows().get(0)); + Assertions.assertEquals(historyBefore, catalog.loadTable(id).history().size(), + "setting to the current snapshot must not append history (short-circuit, no commit)"); + } + + @Test + public void shortCircuitsWhenRefResolvesToCurrentSnapshotWithoutCommitting() { + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + long snap2 = ActionTestTables.appendSnapshot(catalog, "t", "f2.parquet", 2L); + // The tag points at the current snapshot; capture history AFTER the tag commit. + catalog.loadTable(id).manageSnapshots().createTag("v2", snap2).commit(); + long historyBefore = catalog.loadTable(id).history().size(); + + // The ref resolves to the already-current snapshot -> the ref branch returns without setCurrentSnapshot().commit(). + IcebergSetCurrentSnapshotAction action = action(ImmutableMap.of("ref", "v2")); + action.validate(); + ConnectorProcedureResult result = action.execute(catalog.loadTable(id), ActionTestTables.session("UTC")); + + Assertions.assertEquals(ImmutableList.of(String.valueOf(snap2), String.valueOf(snap2)), + result.getRows().get(0), + "ref resolves to the current snapshot -> (previous=snap2, target=snap2)"); + Assertions.assertEquals(historyBefore, catalog.loadTable(id).history().size(), + "resolving a ref to the current snapshot must not append history (short-circuit, no commit)"); + } + + @Test + public void resultSchemaIsTwoNotNullBigints() { + List schema = action(ImmutableMap.of("snapshot_id", "1")).getResultSchema(); + Assertions.assertEquals(2, schema.size()); + Assertions.assertEquals("previous_snapshot_id", schema.get(0).getName()); + Assertions.assertEquals("BIGINT", schema.get(0).getType().getTypeName()); + Assertions.assertFalse(schema.get(0).isNullable()); + Assertions.assertEquals("current_snapshot_id", schema.get(1).getName()); + Assertions.assertEquals("BIGINT", schema.get(1).getType().getTypeName()); + Assertions.assertFalse(schema.get(1).isNullable()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/rewrite/RewriteDataFilePlannerTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/rewrite/RewriteDataFilePlannerTest.java new file mode 100644 index 00000000000000..ac0ab808344c6a --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/rewrite/RewriteDataFilePlannerTest.java @@ -0,0 +1,503 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.rewrite; + +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorBetween; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.ConnectorNot; +import org.apache.doris.connector.api.pushdown.ConnectorOr; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; + +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileMetadata; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Offline planning-half tests for {@link RewriteDataFilePlanner} (P6.4-T05). Uses a real + * {@link InMemoryCatalog} (no Mockito) with multiple data files of controlled sizes/partitions, asserting the + * SDK planning behaviour ported from fe-core: current-snapshot pin, partition grouping, bin-pack by group + * size, the file-level and group-level rewrite filters, and the {@code WHERE} conversion through the shared + * conflict-mode {@link org.apache.doris.connector.iceberg.IcebergPredicateConverter}. The execution half + * (the distributed INSERT-SELECT) stays in fe-core and is out of scope here. + */ +public class RewriteDataFilePlannerTest { + + private static final Schema SCHEMA = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get())); + private static final PartitionSpec BY_ID = PartitionSpec.builderFor(SCHEMA).identity("id").build(); + + // Default knobs: 512MB target, no min/max bounds (every file in range), rewrite-all OFF, huge group cap, + // delete filters effectively disabled. Individual tests override what they exercise. + private static final long TARGET = 536870912L; + + private InMemoryCatalog catalog; + + @BeforeEach + public void setUp() { + catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + } + + // ---- fixtures ------------------------------------------------------------------------------------------- + + private Table createUnpartitioned(String name) { + return catalog.createTable(TableIdentifier.of("db1", name), SCHEMA, PartitionSpec.unpartitioned()); + } + + private Table createPartitioned(String name) { + return catalog.createTable(TableIdentifier.of("db1", name), SCHEMA, BY_ID); + } + + private Table createV2Unpartitioned(String name) { + return catalog.createTable(TableIdentifier.of("db1", name), SCHEMA, PartitionSpec.unpartitioned(), + Collections.singletonMap(TableProperties.FORMAT_VERSION, "2")); + } + + private static DataFile unpartFile(String path, long size) { + return dataFileWithRecords(path, size, 100); + } + + private static DataFile dataFileWithRecords(String path, long size, long records) { + return DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("s3://b/db1/" + path) + .withFileSizeInBytes(size) + .withRecordCount(records) + .withFormat(FileFormat.PARQUET) + .build(); + } + + private static DeleteFile equalityDelete(String path) { + return FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) + .ofEqualityDeletes(1) // field id 1 = "id" + .withPath("s3://b/db1/" + path) + .withFileSizeInBytes(128L) + .withRecordCount(4L) + .withFormat(FileFormat.PARQUET) + .build(); + } + + private static DeleteFile positionDelete(String path, String referencedDataFile, long records) { + return FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) + .ofPositionDeletes() + .withPath("s3://b/db1/" + path) + .withFileSizeInBytes(128L) + .withRecordCount(records) + .withReferencedDataFile(referencedDataFile) // file-scoped -> counts toward the delete ratio + .withFormat(FileFormat.PARQUET) + .build(); + } + + private static DataFile partFile(String path, long size, int idValue) { + return DataFiles.builder(BY_ID) + .withPath("s3://b/db1/" + path) + .withFileSizeInBytes(size) + .withRecordCount(100) + .withPartitionPath("id=" + idValue) + .withFormat(FileFormat.PARQUET) + .build(); + } + + private static void append(Table table, DataFile... files) { + org.apache.iceberg.AppendFiles append = table.newAppend(); + for (DataFile f : files) { + append.appendFile(f); + } + append.commit(); + } + + private static RewriteDataFilePlanner.Parameters params(long minFileSize, long maxFileSize, int minInputFiles, + boolean rewriteAll, long maxGroupSize, ConnectorPredicate where) { + return paramsFull(minFileSize, maxFileSize, minInputFiles, rewriteAll, maxGroupSize, + Integer.MAX_VALUE, 0.3, where); + } + + private static RewriteDataFilePlanner.Parameters paramsFull(long minFileSize, long maxFileSize, + int minInputFiles, boolean rewriteAll, long maxGroupSize, int deleteFileThreshold, + double deleteRatioThreshold, ConnectorPredicate where) { + return new RewriteDataFilePlanner.Parameters(TARGET, minFileSize, maxFileSize, minInputFiles, rewriteAll, + maxGroupSize, deleteFileThreshold, deleteRatioThreshold, /* outputSpecId (dead) */ 2L, where); + } + + private static RewriteDataFilePlanner.Parameters rewriteAll(long maxGroupSize, ConnectorPredicate where) { + return params(0L, Long.MAX_VALUE, 5, true, maxGroupSize, where); + } + + private static List plan(Table table, RewriteDataFilePlanner.Parameters p) { + return new RewriteDataFilePlanner(p, ZoneOffset.UTC).planAndOrganizeTasks(table); + } + + private static int totalFiles(List groups) { + return groups.stream().mapToInt(RewriteDataGroup::getTaskCount).sum(); + } + + private static Set partitionIds(List groups) { + Set ids = new HashSet<>(); + for (RewriteDataGroup g : groups) { + for (DataFile f : g.getDataFiles()) { + ids.add(f.partition().get(0, Integer.class)); + } + } + return ids; + } + + private static ConnectorPredicate where(ConnectorExpression expr) { + return new ConnectorPredicate(expr); + } + + private static ConnectorComparison idEq(int value) { + return new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef("id", ConnectorType.of("INT")), + new ConnectorLiteral(ConnectorType.of("INT"), (long) value)); + } + + // ---- planning / grouping -------------------------------------------------------------------------------- + + @Test + public void rewriteAllGroupsEveryFile() { + Table t = createUnpartitioned("t1"); + append(t, unpartFile("a", 100), unpartFile("b", 100), unpartFile("c", 100)); + + // maxGroupSize 250 -> bin-pack packs [a,b]=200 then [c]=100 (a 3rd 100 would overflow 250). + List groups = plan(t, rewriteAll(250L, null)); + + Assertions.assertEquals(2, groups.size()); + Assertions.assertEquals(3, totalFiles(groups)); + Assertions.assertTrue(groups.stream().allMatch(g -> g.getTotalSize() <= 250L)); + } + + @Test + public void binPackNeverExceedsMaxGroupSize() { + Table t = createUnpartitioned("t2"); + append(t, unpartFile("a", 100), unpartFile("b", 100), unpartFile("c", 100), unpartFile("d", 100)); + + List groups = plan(t, rewriteAll(250L, null)); + + Assertions.assertEquals(2, groups.size()); + Assertions.assertEquals(4, totalFiles(groups)); + Assertions.assertTrue(groups.stream().allMatch(g -> g.getTotalSize() == 200L)); + } + + @Test + public void groupsAreNeverMixedAcrossPartitions() { + Table t = createPartitioned("t3"); + append(t, partFile("a", 100, 1), partFile("b", 100, 1), partFile("c", 100, 2)); + + // Big cap -> each partition collapses into a single bin: id=1 -> 2 files, id=2 -> 1 file. + List groups = plan(t, rewriteAll(1_000_000L, null)); + + Assertions.assertEquals(2, groups.size()); + Assertions.assertEquals(3, totalFiles(groups)); + Assertions.assertEquals(new HashSet<>(Arrays.asList(1, 2)), partitionIds(groups)); + // Each group is single-partition. + for (RewriteDataGroup g : groups) { + Set ids = new HashSet<>(); + g.getDataFiles().forEach(f -> ids.add(f.partition().get(0, Integer.class))); + Assertions.assertEquals(1, ids.size(), "a rewrite group must not span partitions"); + } + } + + @Test + public void usesCurrentSnapshotFileSet() { + Table t = createUnpartitioned("t4"); + append(t, unpartFile("a", 100)); // snapshot 1 + append(t, unpartFile("b", 100)); // snapshot 2 -> current sees both a and b + + List groups = plan(t, rewriteAll(1_000_000L, null)); + + Assertions.assertEquals(2, totalFiles(groups)); + } + + @Test + public void emptyTableYieldsNoGroups() { + Table t = createUnpartitioned("t5"); + // No append -> no current snapshot. The planner must not NPE on the null snapshot. + List groups = plan(t, rewriteAll(1_000_000L, null)); + + Assertions.assertTrue(groups.isEmpty()); + } + + // ---- file-level and group-level filters (rewriteAll = false) -------------------------------------------- + + @Test + public void fileFilterSelectsOutOfRangeFiles() { + Table t = createUnpartitioned("t6"); + append(t, unpartFile("a", 100), unpartFile("b", 100), unpartFile("c", 100)); + + // min-file-size 200 makes every 100-byte file "too small" -> selected; minInputFiles 2 keeps the + // 3-file group via hasEnoughInputFiles. + List groups = plan(t, params(200L, 1000L, 2, false, 1_000_000L, null)); + + Assertions.assertEquals(1, groups.size()); + Assertions.assertEquals(3, totalFiles(groups)); + } + + @Test + public void fileFilterSkipsInRangeFiles() { + Table t = createUnpartitioned("t7"); + append(t, unpartFile("a", 100), unpartFile("b", 100), unpartFile("c", 100)); + + // 100 is within [50, 200] and there are no deletes -> nothing qualifies for rewrite. + List groups = plan(t, params(50L, 200L, 2, false, 1_000_000L, null)); + + Assertions.assertTrue(groups.isEmpty()); + } + + @Test + public void groupFilterDropsGroupBelowThresholds() { + Table t = createUnpartitioned("t8"); + append(t, unpartFile("a", 100), unpartFile("b", 100)); + + // Files are selected (100 < min 200) but the 2-file group fails every group predicate: + // minInputFiles 5 (count 2 < 5), content 200 <= target 512MB, 200 <= group cap, no deletes. + List groups = plan(t, params(200L, 1000L, 5, false, 1_000_000L, null)); + + Assertions.assertTrue(groups.isEmpty()); + } + + @Test + public void groupFilterKeepsGroupWithEnoughInputFiles() { + Table t = createUnpartitioned("t9"); + append(t, unpartFile("a", 100), unpartFile("b", 100)); + + // Same as above but minInputFiles 2 -> hasEnoughInputFiles (2 > 1 && 2 >= 2) keeps the group. + List groups = plan(t, params(200L, 1000L, 2, false, 1_000_000L, null)); + + Assertions.assertEquals(1, groups.size()); + Assertions.assertEquals(2, totalFiles(groups)); + } + + // ---- WHERE conversion (REWRITE-mode IcebergPredicateConverter, precise-or-error) ------------------------ + + @Test + public void whereOnPartitionColumnPrunesToMatchingPartition() { + Table t = createPartitioned("t10"); + append(t, partFile("a", 100, 1), partFile("b", 100, 2)); + + // WHERE id = 1 -> a convertible single-column comparison -> only the id=1 file survives planning. + List groups = plan(t, rewriteAll(1_000_000L, where(idEq(1)))); + + Assertions.assertEquals(1, totalFiles(groups)); + Assertions.assertEquals(new HashSet<>(Collections.singletonList(1)), partitionIds(groups)); + } + + @Test + public void whereCrossColumnOrPlans() { + // User decision (2026-06-29「精确下推否则报错」): a cross-column OR is precisely file-prunable, so it must + // be pushed (the live code pushed it) rather than rejected. REWRITE mode lowers `id = 1 OR name = 'x'` to + // a real iceberg OR -- no longer the conflict-matrix rejection that THREW. Both files survive here (the + // id=1 file matches the id arm; the id=2 file cannot be excluded by name='x' without name column stats), + // but the point is that planning SUCCEEDS. The exact OR shape is pinned in + // IcebergPredicateConverterRewriteModeTest#crossColumnOrPushed. + Table t = createPartitioned("t11"); + append(t, partFile("a", 100, 1), partFile("b", 100, 2)); + + ConnectorExpression crossColumnOr = new ConnectorOr(Arrays.asList( + idEq(1), + new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef("name", ConnectorType.of("STRING")), + new ConnectorLiteral(ConnectorType.of("STRING"), "x")))); + + List groups = plan(t, rewriteAll(1_000_000L, where(crossColumnOr))); + Assertions.assertEquals(2, totalFiles(groups)); + } + + @Test + public void whereNotComparisonPrunesToMatchingPartition() { + // NOT(comparison) is rejected by the conflict matrix (NOT only over IS NULL) but pushed by REWRITE mode. + // NOT(id > 1) -> not(id > 1): the id=2 partition is excluded (2 > 1), only id=1 survives. If NOT were + // dropped or the matrix narrowed, both partitions would survive (totalFiles == 2) -> red. + Table t = createPartitioned("t20"); + append(t, partFile("a", 100, 1), partFile("b", 100, 2)); + + ConnectorExpression notGt = new ConnectorNot(new ConnectorComparison(ConnectorComparison.Operator.GT, + new ConnectorColumnRef("id", ConnectorType.of("INT")), + new ConnectorLiteral(ConnectorType.of("INT"), 1L))); + + List groups = plan(t, rewriteAll(1_000_000L, where(notGt))); + + Assertions.assertEquals(1, totalFiles(groups)); + Assertions.assertEquals(new HashSet<>(Collections.singletonList(1)), partitionIds(groups)); + } + + @Test + public void partiallyPushableWhereThrows() { + // A top-level AND with one pushable conjunct (id=1) and one un-pushable one. Keeping only the pushable + // arm would widen the rewrite past the user's WHERE, so the planner fails when ANY top-level conjunct + // cannot be pushed -- not only when nothing pushes. The un-pushable arm is `id = 'abc'`: an INT column + // compared to a non-numeric string literal cannot bind to file pruning, so it drops and the size-vs-count + // guard fires. (Cross-column OR no longer qualifies -- REWRITE mode pushes it precisely.) + Table t = createPartitioned("t19"); + append(t, partFile("a", 100, 1), partFile("b", 100, 2)); + + ConnectorExpression unbindable = new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef("id", ConnectorType.of("INT")), + new ConnectorLiteral(ConnectorType.of("STRING"), "abc")); + ConnectorExpression partial = new ConnectorAnd(Arrays.asList(idEq(1), unbindable)); + + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> plan(t, rewriteAll(1_000_000L, where(partial)))); + Assertions.assertTrue(e.getMessage().contains("cannot be pushed down")); + } + + @Test + public void whereBetweenPrunesToMatchingPartition() { + // BETWEEN is a node the rewrite-side neutral converter emits directly; REWRITE mode pushes it + // (-> id>=1 AND id<=1) just as the live code did. Scan mode has no BETWEEN node case and would drop it, + // leaving BOTH partitions (totalFiles == 2) -- so this also pins that the planner uses REWRITE mode. + Table t = createPartitioned("t12"); + append(t, partFile("a", 100, 1), partFile("b", 100, 2)); + + ConnectorExpression between = new ConnectorBetween( + new ConnectorColumnRef("id", ConnectorType.of("INT")), + new ConnectorLiteral(ConnectorType.of("INT"), 1L), + new ConnectorLiteral(ConnectorType.of("INT"), 1L)); + + List groups = plan(t, rewriteAll(1_000_000L, where(between))); + + Assertions.assertEquals(1, totalFiles(groups)); + Assertions.assertEquals(new HashSet<>(Collections.singletonList(1)), partitionIds(groups)); + } + + @Test + public void whereTopLevelAndAppliesEveryConjunct() { + // A top-level multi-conjunct ConnectorAnd (id>=1 AND id<=1) is flattened by the planner's + // per-conjunct scan.filter loop: IcebergPredicateConverter.convert returns both convertible arms and + // each is applied as a separate iceberg filter (iceberg ANDs them). Only the intersecting partition + // id=1 survives. If the AND-flatten loop broke or dropped the lower-bound arm, the scan would widen to + // both partitions (totalFiles == 2) -> red. Distinct from whereBetweenPrunesToMatchingPartition, which + // exercises a single ConnectorBetween node rather than the multi-filter flatten loop. + Table t = createPartitioned("t18"); + append(t, partFile("a", 100, 1), partFile("b", 100, 2)); + + ConnectorColumnRef idRef = new ConnectorColumnRef("id", ConnectorType.of("INT")); + ConnectorExpression and = new ConnectorAnd(Arrays.asList( + new ConnectorComparison(ConnectorComparison.Operator.GE, idRef, + new ConnectorLiteral(ConnectorType.of("INT"), 1L)), + new ConnectorComparison(ConnectorComparison.Operator.LE, idRef, + new ConnectorLiteral(ConnectorType.of("INT"), 1L)))); + + List groups = plan(t, rewriteAll(1_000_000L, where(and))); + + Assertions.assertEquals(1, totalFiles(groups)); + Assertions.assertEquals(new HashSet<>(Collections.singletonList(1)), partitionIds(groups)); + } + + // ---- group-predicate OR-arms in isolation (rewriteAll = false) ------------------------------------------ + + @Test + public void groupFilterKeepsViaEnoughContent() { + Table t = createUnpartitioned("t13"); + append(t, unpartFile("a", 100), unpartFile("b", 100)); + + // Files selected (100 < min 200). Group: count 2, size 200. minInputFiles 100 disables hasEnoughInputFiles; + // hasEnoughContent fires alone (count > 1 && size 200 > target 150); size 200 <= group cap so hasTooMuchContent off. + List groups = new RewriteDataFilePlanner( + new RewriteDataFilePlanner.Parameters(150L, 200L, 1000L, 100, false, 1_000_000L, + Integer.MAX_VALUE, 0.3, 2L, null), ZoneOffset.UTC).planAndOrganizeTasks(t); + + Assertions.assertEquals(1, groups.size()); + Assertions.assertEquals(2, totalFiles(groups)); + } + + @Test + public void groupFilterKeepsViaTooMuchContent() { + Table t = createUnpartitioned("t14"); + append(t, unpartFile("a", 2000)); + + // One oversized file: selected (2000 > max 1000), packed alone into a 2000-byte group that exceeds the + // 1500 group cap -> hasTooMuchContent fires (the only true OR-arm; count 1 disables the count/content arms). + List groups = plan(t, params(0L, 1000L, 100, false, 1500L, null)); + + Assertions.assertEquals(1, groups.size()); + Assertions.assertEquals(1, totalFiles(groups)); + } + + @Test + public void fileAtSizeBoundariesIsInRange() { + Table t = createUnpartitioned("t15"); + append(t, unpartFile("a", 200), unpartFile("b", 1000)); + + // outsideDesiredFileSizeRange is strict (< min || > max). A file exactly at min or max is IN range, so + // with no deletes nothing qualifies. A port using <= / >= would wrongly select both -> red. + List groups = plan(t, params(200L, 1000L, 2, false, 1_000_000L, null)); + + Assertions.assertTrue(groups.isEmpty()); + } + + // ---- delete-file filters (restores the legacy testDeleteFileThreshold / testDeleteRatioThreshold parity) - + + @Test + public void deleteFileThresholdGatesSelection() { + Table t = createV2Unpartitioned("t16"); + append(t, unpartFile("a", 100)); // f1 (data sequence 1), size in [50,200] -> never size-selected + t.newRowDelta() + .addDeletes(equalityDelete("d1.parquet")) + .addDeletes(equalityDelete("d2.parquet")) + .commit(); // 2 equality deletes (sequence 2) apply to f1 + + // threshold 2 -> 2 >= 2 -> f1 selected via tooManyDeletes; group kept via hasDeleteIssues. + Assertions.assertEquals(1, plan(t, paramsFull(50L, 200L, 5, false, 1_000_000L, 2, 0.3, null)).size()); + // threshold 3 -> 2 >= 3 false; equality deletes are not file-scoped so the ratio is 0 -> nothing selected. + Assertions.assertTrue(plan(t, paramsFull(50L, 200L, 5, false, 1_000_000L, 3, 0.3, null)).isEmpty()); + } + + @Test + public void deleteRatioGatesSelection() { + Table t = createV2Unpartitioned("t17"); + DataFile f1 = dataFileWithRecords("a", 100, 10); + t.newAppend().appendFile(f1).commit(); // f1: 10 records + t.newRowDelta() + .addDeletes(positionDelete("pos.parquet", f1.path().toString(), 4L)) + .commit(); // file-scoped position delete: 4 deleted records -> ratio 0.4 + + // ratio threshold 0.3 -> 0.4 >= 0.3 -> f1 selected via tooHighDeleteRatio; threshold disables tooManyDeletes. + Assertions.assertEquals(1, + plan(t, paramsFull(50L, 200L, 5, false, 1_000_000L, Integer.MAX_VALUE, 0.3, null)).size()); + // ratio threshold 0.5 -> 0.4 >= 0.5 false -> nothing selected. + Assertions.assertTrue( + plan(t, paramsFull(50L, 200L, 5, false, 1_000_000L, Integer.MAX_VALUE, 0.5, null)).isEmpty()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/rewrite/RewriteDataGroupTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/rewrite/RewriteDataGroupTest.java new file mode 100644 index 00000000000000..87ff9f688bece0 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/rewrite/RewriteDataGroupTest.java @@ -0,0 +1,99 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg.rewrite; + +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Tests for the {@link RewriteDataGroup} carrier POJO (P6.4-T05 port), exercised with real {@link FileScanTask} + * objects planned from an {@link InMemoryCatalog} table (no Mockito). + */ +public class RewriteDataGroupTest { + + private static final Schema SCHEMA = new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get())); + + private List planTwoFiles() throws IOException { + InMemoryCatalog catalog = new InMemoryCatalog(); + catalog.initialize("test", Collections.emptyMap()); + catalog.createNamespace(Namespace.of("db1")); + Table t = catalog.createTable(TableIdentifier.of("db1", "g"), SCHEMA, PartitionSpec.unpartitioned()); + t.newAppend() + .appendFile(file("a", 100)) + .appendFile(file("b", 250)) + .commit(); + List tasks = new ArrayList<>(); + try (CloseableIterable planned = t.newScan().planFiles()) { + planned.forEach(tasks::add); + } + return tasks; + } + + private static org.apache.iceberg.DataFile file(String path, long size) { + return DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("s3://b/db1/" + path) + .withFileSizeInBytes(size) + .withRecordCount(100) + .withFormat(FileFormat.PARQUET) + .build(); + } + + @Test + public void aggregatesSizeAndDataFilesFromTasks() throws IOException { + List tasks = planTwoFiles(); + + RewriteDataGroup group = new RewriteDataGroup(tasks); + + Assertions.assertEquals(2, group.getTaskCount()); + Assertions.assertEquals(350L, group.getTotalSize()); + Assertions.assertEquals(2, group.getDataFiles().size()); + // Data files with no merge-on-read deletes contribute zero to the delete-file count. + Assertions.assertEquals(0, group.getDeleteFileCount()); + Assertions.assertFalse(group.isEmpty()); + } + + @Test + public void emptyGroupAcceptsTasksViaAddTask() throws IOException { + List tasks = planTwoFiles(); + + RewriteDataGroup group = new RewriteDataGroup(); + Assertions.assertTrue(group.isEmpty()); + group.addTask(tasks.get(0)); + + Assertions.assertEquals(1, group.getTaskCount()); + Assertions.assertEquals(tasks.get(0).file().fileSizeInBytes(), group.getTotalSize()); + Assertions.assertFalse(group.isEmpty()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/rewrite/RewriteResultTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/rewrite/RewriteResultTest.java new file mode 100644 index 00000000000000..734b459a70ad0f --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/rewrite/RewriteResultTest.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.iceberg.rewrite; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +/** + * Tests for the {@link RewriteResult} carrier POJO (P6.4-T05 port). The column order of {@link + * RewriteResult#toStringList()} is the procedure's result-row contract (rewritten / added / bytes / removed), + * so it is pinned here. + */ +public class RewriteResultTest { + + @Test + public void toStringListPreservesColumnOrder() { + RewriteResult result = new RewriteResult(3, 1, 4096L, 2); + Assertions.assertEquals(Arrays.asList("3", "1", "4096", "2"), result.toStringList()); + } + + @Test + public void rewrittenBytesIsRenderedAsLong() { + // rewrittenBytesCount is a long; a value beyond int range must round-trip as the full long string. + RewriteResult result = new RewriteResult(0, 0, 3_000_000_000L, 0); + Assertions.assertEquals("3000000000", result.toStringList().get(2)); + } + + @Test + public void mergeSumsEachField() { + RewriteResult a = new RewriteResult(1, 2, 3L, 4); + a.merge(new RewriteResult(10, 20, 30L, 40)); + Assertions.assertEquals(Arrays.asList("11", "22", "33", "44"), a.toStringList()); + } + + @Test + public void defaultResultIsAllZero() { + Assertions.assertEquals(Arrays.asList("0", "0", "0", "0"), new RewriteResult().toStringList()); + } +} diff --git a/fe/fe-connector/fe-connector-jdbc/src/main/assembly/plugin-zip.xml b/fe/fe-connector/fe-connector-jdbc/src/main/assembly/plugin-zip.xml index 13262ad745ad6d..2e141b72f15096 100644 --- a/fe/fe-connector/fe-connector-jdbc/src/main/assembly/plugin-zip.xml +++ b/fe/fe-connector/fe-connector-jdbc/src/main/assembly/plugin-zip.xml @@ -54,6 +54,9 @@ under the License. org.apache.doris:fe-filesystem-api org.apache.doris:fe-thrift org.apache.thrift:libthrift + + org.apache.logging.log4j:* + org.slf4j:* 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 176c134d29fe3d..c20a60318cf7a5 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 @@ -24,11 +24,10 @@ import org.apache.doris.connector.api.ConnectorTableStatistics; import org.apache.doris.connector.api.ConnectorType; import org.apache.doris.connector.api.handle.ConnectorColumnHandle; -import org.apache.doris.connector.api.handle.ConnectorInsertHandle; import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.connector.api.handle.NoOpConnectorTransaction; import org.apache.doris.connector.api.handle.PassthroughQueryTableHandle; -import org.apache.doris.connector.api.write.ConnectorWriteConfig; -import org.apache.doris.connector.api.write.ConnectorWriteType; import org.apache.doris.connector.jdbc.client.JdbcConnectorClient; import org.apache.doris.connector.jdbc.client.JdbcFieldInfo; @@ -36,14 +35,11 @@ import org.apache.logging.log4j.Logger; import java.util.ArrayList; -import java.util.Collection; import java.util.Collections; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; /** * {@link ConnectorMetadata} implementation for JDBC sources. @@ -281,99 +277,12 @@ public ConnectorTableSchema getColumnsFromQuery(ConnectorSession session, String // ========= ConnectorWriteOps ========= @Override - public boolean supportsInsert() { - return true; - } - - @Override - public ConnectorWriteConfig getWriteConfig( - ConnectorSession session, - ConnectorTableHandle handle, - List columns) { - JdbcTableHandle jdbcHandle = (JdbcTableHandle) handle; - String remoteDbName = jdbcHandle.getRemoteDbName(); - String remoteTableName = jdbcHandle.getRemoteTableName(); - JdbcDbType dbType = client.getDbType(); - - // Build local column name list for INSERT SQL - List columnNames = columns.stream() - .map(ConnectorColumn::getName) - .collect(Collectors.toList()); - - // Build local→remote column name mapping via column handles - Map colHandles = getColumnHandles(session, handle); - Map remoteColumnNames = new HashMap<>(); - for (Map.Entry entry : colHandles.entrySet()) { - JdbcColumnHandle ch = (JdbcColumnHandle) entry.getValue(); - remoteColumnNames.put(ch.getLocalName(), ch.getRemoteName()); - } - - String insertSql = JdbcIdentifierQuoter.buildInsertSql( - dbType, remoteDbName, remoteTableName, remoteColumnNames, columnNames); - - Map writeProps = new HashMap<>(); - writeProps.put("jdbc_url", properties.getOrDefault(JdbcConnectorProperties.JDBC_URL, "")); - writeProps.put("jdbc_user", properties.getOrDefault(JdbcConnectorProperties.USER, "")); - writeProps.put("jdbc_password", properties.getOrDefault(JdbcConnectorProperties.PASSWORD, "")); - writeProps.put("jdbc_driver_url", properties.getOrDefault(JdbcConnectorProperties.DRIVER_URL, "")); - writeProps.put("jdbc_driver_class", properties.getOrDefault(JdbcConnectorProperties.DRIVER_CLASS, "")); - writeProps.put("jdbc_driver_checksum", - properties.getOrDefault(JdbcConnectorProperties.DRIVER_CHECKSUM, "")); - writeProps.put("jdbc_table_name", remoteTableName); - writeProps.put("jdbc_resource_name", ""); - writeProps.put("jdbc_table_type", dbType.name()); - writeProps.put("jdbc_insert_sql", insertSql); - writeProps.put("jdbc_use_transaction", - session.getSessionProperties().getOrDefault("enable_odbc_transcation", "false")); - writeProps.put("jdbc_catalog_id", String.valueOf(session.getCatalogId())); - - // Connection pool settings - writeProps.put("connection_pool_min_size", String.valueOf( - JdbcConnectorProperties.getInt(properties, - JdbcConnectorProperties.CONNECTION_POOL_MIN_SIZE, - JdbcConnectorProperties.DEFAULT_POOL_MIN_SIZE))); - writeProps.put("connection_pool_max_size", String.valueOf( - JdbcConnectorProperties.getInt(properties, - JdbcConnectorProperties.CONNECTION_POOL_MAX_SIZE, - JdbcConnectorProperties.DEFAULT_POOL_MAX_SIZE))); - writeProps.put("connection_pool_max_wait_time", String.valueOf( - JdbcConnectorProperties.getInt(properties, - JdbcConnectorProperties.CONNECTION_POOL_MAX_WAIT_TIME, - JdbcConnectorProperties.DEFAULT_POOL_MAX_WAIT_TIME))); - writeProps.put("connection_pool_max_life_time", String.valueOf( - JdbcConnectorProperties.getInt(properties, - JdbcConnectorProperties.CONNECTION_POOL_MAX_LIFE_TIME, - JdbcConnectorProperties.DEFAULT_POOL_MAX_LIFE_TIME))); - writeProps.put("connection_pool_keep_alive", String.valueOf( - Boolean.parseBoolean(properties.getOrDefault( - JdbcConnectorProperties.CONNECTION_POOL_KEEP_ALIVE, "false")))); - - return ConnectorWriteConfig.builder(ConnectorWriteType.JDBC_WRITE) - .properties(writeProps) - .build(); - } - - @Override - public ConnectorInsertHandle beginInsert( - ConnectorSession session, - ConnectorTableHandle handle, - List columns) { - // JDBC writes are executed directly by BE via PreparedStatement. - // No FE-side transaction to begin — return a no-op handle. - return new JdbcInsertHandle(); - } - - @Override - public void finishInsert(ConnectorSession session, - ConnectorInsertHandle handle, - Collection fragments) { - // No-op: BE commits each row via JDBC directly. - } - - /** - * No-op insert handle for JDBC writes. - * JDBC writes don't require FE-side transaction management. - */ - private static class JdbcInsertHandle implements ConnectorInsertHandle { + public ConnectorTransaction beginTransaction(ConnectorSession session) { + // JDBC writes are auto-committed by BE per row via PreparedStatement; there is no + // FE-side transaction to coordinate. Return a degenerate no-op transaction so the + // engine's write lifecycle is uniform (single ConnectorTransaction model). Its + // getUpdateCnt() returns -1, so the executor keeps the coordinator's row counter + // (DPP_NORMAL_ALL) for affected-rows instead of overwriting it with 0. + return new NoOpConnectorTransaction(session.allocateTransactionId(), "JDBC"); } } 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 750a3bdd25084b..82410e4a283159 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 @@ -25,6 +25,7 @@ import org.apache.doris.connector.api.ConnectorValidationContext; import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; import org.apache.doris.connector.jdbc.client.JdbcConnectorClient; import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.thrift.TJdbcTable; @@ -95,9 +96,12 @@ public ConnectorMetadata getMetadata(ConnectorSession session) { @Override 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. return EnumSet.of( - ConnectorCapability.SUPPORTS_INSERT, - ConnectorCapability.SUPPORTS_PASSTHROUGH_QUERY + ConnectorCapability.SUPPORTS_PASSTHROUGH_QUERY, + ConnectorCapability.SUPPORTS_METADATA_PRELOAD ); } @@ -125,6 +129,14 @@ public ConnectorScanPlanProvider getScanPlanProvider() { return scanPlanProvider; } + @Override + public ConnectorWritePlanProvider getWritePlanProvider() { + // Returning a non-null provider routes jdbc writes through the unified plan-provider sink + // path (PhysicalPlanTranslator.visitPhysicalConnectorTableSink). The provider builds the + // TJdbcTableSink itself (P6.3-T02 / OQ-1); there is no config-bag path anymore. + return new JdbcWritePlanProvider(getOrCreateClient(), properties); + } + @Override public void preCreateValidation(ConnectorValidationContext context) throws Exception { // 1. Validate/resolve JDBC driver — format, whitelist, secure_path, file existence. diff --git a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcWritePlanProvider.java b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcWritePlanProvider.java new file mode 100644 index 00000000000000..9ed3d56df57dd3 --- /dev/null +++ b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcWritePlanProvider.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.jdbc; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.handle.ConnectorColumnHandle; +import org.apache.doris.connector.api.handle.ConnectorWriteHandle; +import org.apache.doris.connector.api.write.ConnectorSinkPlan; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; +import org.apache.doris.connector.jdbc.client.JdbcConnectorClient; +import org.apache.doris.thrift.TDataSink; +import org.apache.doris.thrift.TDataSinkType; +import org.apache.doris.thrift.TJdbcTable; +import org.apache.doris.thrift.TJdbcTableSink; +import org.apache.doris.thrift.TOdbcTableType; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Write plan provider for JDBC sources. + * + *

    Builds the opaque {@link TJdbcTableSink} for a bound INSERT: it resolves the + * remote table / column names, generates the parameterized INSERT SQL, and stamps + * the JDBC connection configuration. JDBC writes are auto-committed by BE per row, + * so there is no FE-side transaction work here (see + * {@link JdbcConnectorMetadata#beginTransaction}, which returns a degenerate no-op + * transaction).

    + * + *

    Ported byte-for-byte from the legacy config-bag write path — the deleted + * {@code JdbcConnectorMetadata.getWriteConfig} (property bag) plus + * {@code PluginDrivenTableSink.bindJdbcWriteSink} (Thrift assembly) — fused into + * this single {@code planWrite} call (P6.3-T02 / RFC OQ-1). The connection-pool + * values are taken from {@link JdbcConnectorProperties#getInt} with the + * {@code DEFAULT_POOL_*} defaults, exactly as {@code getWriteConfig} computed them.

    + */ +public class JdbcWritePlanProvider implements ConnectorWritePlanProvider { + + private final JdbcConnectorClient client; + private final Map properties; + + public JdbcWritePlanProvider(JdbcConnectorClient client, Map properties) { + this.client = client; + this.properties = properties; + } + + @Override + public ConnectorSinkPlan planWrite(ConnectorSession session, ConnectorWriteHandle handle) { + JdbcTableHandle jdbcHandle = (JdbcTableHandle) handle.getTableHandle(); + String insertSql = buildInsertSql(session, jdbcHandle, handle.getColumns()); + + TJdbcTable tJdbcTable = new TJdbcTable(); + tJdbcTable.setJdbcUrl(properties.getOrDefault(JdbcConnectorProperties.JDBC_URL, "")); + tJdbcTable.setJdbcUser(properties.getOrDefault(JdbcConnectorProperties.USER, "")); + tJdbcTable.setJdbcPassword(properties.getOrDefault(JdbcConnectorProperties.PASSWORD, "")); + tJdbcTable.setJdbcDriverUrl(properties.getOrDefault(JdbcConnectorProperties.DRIVER_URL, "")); + tJdbcTable.setJdbcDriverClass(properties.getOrDefault(JdbcConnectorProperties.DRIVER_CLASS, "")); + tJdbcTable.setJdbcDriverChecksum( + properties.getOrDefault(JdbcConnectorProperties.DRIVER_CHECKSUM, "")); + tJdbcTable.setJdbcTableName(jdbcHandle.getRemoteTableName()); + tJdbcTable.setJdbcResourceName(""); + tJdbcTable.setCatalogId(session.getCatalogId()); + tJdbcTable.setConnectionPoolMinSize(JdbcConnectorProperties.getInt(properties, + JdbcConnectorProperties.CONNECTION_POOL_MIN_SIZE, + JdbcConnectorProperties.DEFAULT_POOL_MIN_SIZE)); + tJdbcTable.setConnectionPoolMaxSize(JdbcConnectorProperties.getInt(properties, + JdbcConnectorProperties.CONNECTION_POOL_MAX_SIZE, + JdbcConnectorProperties.DEFAULT_POOL_MAX_SIZE)); + tJdbcTable.setConnectionPoolMaxWaitTime(JdbcConnectorProperties.getInt(properties, + JdbcConnectorProperties.CONNECTION_POOL_MAX_WAIT_TIME, + JdbcConnectorProperties.DEFAULT_POOL_MAX_WAIT_TIME)); + tJdbcTable.setConnectionPoolMaxLifeTime(JdbcConnectorProperties.getInt(properties, + JdbcConnectorProperties.CONNECTION_POOL_MAX_LIFE_TIME, + JdbcConnectorProperties.DEFAULT_POOL_MAX_LIFE_TIME)); + tJdbcTable.setConnectionPoolKeepAlive(Boolean.parseBoolean(properties.getOrDefault( + JdbcConnectorProperties.CONNECTION_POOL_KEEP_ALIVE, "false"))); + + TJdbcTableSink jdbcSink = new TJdbcTableSink(); + jdbcSink.setJdbcTable(tJdbcTable); + jdbcSink.setInsertSql(insertSql); + jdbcSink.setUseTransaction(useTransaction(session)); + // dbType.name() is never empty and maps onto TOdbcTableType (legacy parity: a name with + // no matching enum throws here, exactly as the legacy bindJdbcWriteSink did). + jdbcSink.setTableType(TOdbcTableType.valueOf(client.getDbType().name())); + + TDataSink dataSink = new TDataSink(TDataSinkType.JDBC_TABLE_SINK); + dataSink.setJdbcTableSink(jdbcSink); + return new ConnectorSinkPlan(dataSink); + } + + @Override + public void appendExplainInfo(StringBuilder output, String prefix, + ConnectorSession session, ConnectorWriteHandle handle) { + // Surface the connector-specific write detail the unified plugin-driven sink line cannot + // (the sink is source-agnostic). Mirrors the legacy jdbc EXPLAIN block (table type / + // generated INSERT SQL / use-transaction). + JdbcTableHandle jdbcHandle = (JdbcTableHandle) handle.getTableHandle(); + output.append(prefix).append(" TABLE TYPE: ") + .append(client.getDbType().name()).append("\n"); + output.append(prefix).append(" INSERT SQL: ") + .append(buildInsertSql(session, jdbcHandle, handle.getColumns())).append("\n"); + output.append(prefix).append(" USE TRANSACTION: ") + .append(useTransaction(session)).append("\n"); + } + + /** + * Builds the parameterized INSERT SQL for the bound write. Shared by {@link #planWrite} and + * {@link #appendExplainInfo} so the EXPLAIN SQL is identical to the one sent to BE. Resolves + * the local -> remote column name mapping via the metadata column handles (same client + + * properties as the legacy {@code getWriteConfig}, which called its own getColumnHandles). + */ + private String buildInsertSql(ConnectorSession session, JdbcTableHandle jdbcHandle, + List columns) { + List columnNames = columns.stream() + .map(ConnectorColumn::getName) + .collect(Collectors.toList()); + Map colHandles = + new JdbcConnectorMetadata(client, properties).getColumnHandles(session, jdbcHandle); + Map remoteColumnNames = new HashMap<>(); + for (Map.Entry entry : colHandles.entrySet()) { + JdbcColumnHandle ch = (JdbcColumnHandle) entry.getValue(); + remoteColumnNames.put(ch.getLocalName(), ch.getRemoteName()); + } + return JdbcIdentifierQuoter.buildInsertSql(client.getDbType(), + jdbcHandle.getRemoteDbName(), jdbcHandle.getRemoteTableName(), + remoteColumnNames, columnNames); + } + + private boolean useTransaction(ConnectorSession session) { + return Boolean.parseBoolean(session.getSessionProperties() + .getOrDefault("enable_odbc_transcation", "false")); + } +} 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 03dbaa3ab760fc..b75a0c1828c330 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 @@ -43,7 +43,6 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.function.UnaryOperator; @@ -66,20 +65,17 @@ public abstract class JdbcConnectorClient implements Closeable { protected static final int JDBC_DATETIME_SCALE = 6; protected static final int MAX_DECIMAL128_PRECISION = 38; - private static final Map CLASS_LOADER_MAP = new ConcurrentHashMap<>(); - - /** - * Pairs a ClassLoader with a reference count so the map entry can be removed - * when the last client using that driver URL is closed. - */ - static final class RefCountedClassLoader { - final ClassLoader loader; - final AtomicInteger refCount = new AtomicInteger(1); - - RefCountedClassLoader(ClassLoader loader) { - this.loader = loader; - } - } + // Keep-alive cache of driver classloaders: one per distinct driver URL, shared across all catalogs + // and never evicted. A JDBC driver self-registers into the static java.sql.DriverManager when its + // class is loaded, which strong-references the driver's classloader (and all its classes) for the + // life of the FE process. Evicting a URL's classloader while DriverManager still pins it frees NO + // Metaspace; it only forces the NEXT catalog for that URL to build a fresh, separately-pinned + // classloader -- so create/drop/recreate churn leaked one driver's worth of Metaspace per cycle + // (FE Metaspace 165MB->1565MB, external regression 986696 OOM). Keeping one loader per URL forever + // is bounded by the number of distinct driver jars (a handful) and mirrors the pre-SPI JdbcClient. + // Do NOT reintroduce per-close eviction here without first deregistering the drivers loaded by the + // classloader (java.sql.DriverManager.deregisterDriver) and closing the URLClassLoader. + private static final Map CLASS_LOADER_MAP = new ConcurrentHashMap<>(); protected final String catalogName; protected final JdbcDbType dbType; @@ -90,7 +86,6 @@ static final class RefCountedClassLoader { protected final boolean enableMappingVarbinary; protected final boolean enableMappingTimestampTz; protected ClassLoader classLoader; - private URL classLoaderUrl; protected HikariDataSource dataSource; /** @@ -223,7 +218,14 @@ private void initializeDataSource(String url, String user, String password, try { Thread.currentThread().setContextClassLoader(this.classLoader); dataSource = new HikariDataSource(); - dataSource.setDriverClassName(driverClass); + // driver_class is optional. When absent, let HikariCP resolve the driver from the JDBC URL via + // DriverManager rather than passing null to setDriverClassName — a null there NPEs deep inside + // HikariCP (loadClass(null) -> ClassLoader lock map -> ConcurrentHashMap null key), which this + // method's catch re-wraps into an opaque "Failed to initialize JDBC data source: null" that hides + // the real "driver_class not provided" cause. + if (driverClass != null && !driverClass.isEmpty()) { + dataSource.setDriverClassName(driverClass); + } dataSource.setJdbcUrl(url); dataSource.setUsername(user); dataSource.setPassword(password); @@ -242,29 +244,32 @@ private void initializeDataSource(String url, String user, String password, } } - private synchronized void initializeClassLoader(String driverUrl) { + private void initializeClassLoader(String driverUrl) { if (driverUrl == null || driverUrl.isEmpty()) { this.classLoader = getClass().getClassLoader(); return; } try { - URL[] urls = {new URL(resolveDriverUrl(driverUrl))}; - this.classLoaderUrl = urls[0]; - RefCountedClassLoader entry = CLASS_LOADER_MAP.compute(urls[0], (key, existing) -> { - if (existing != null) { - existing.refCount.incrementAndGet(); - return existing; - } - ClassLoader parent = getClass().getClassLoader(); - return new RefCountedClassLoader(URLClassLoader.newInstance(urls, parent)); - }); - this.classLoader = entry.loader; + URL url = new URL(resolveDriverUrl(driverUrl)); + this.classLoader = getOrCreateDriverClassLoader(url); } catch (MalformedURLException e) { throw new DorisConnectorException( "Failed to load JDBC driver from path: " + driverUrl, e); } } + /** + * Returns the shared driver classloader for {@code url}, creating and caching it on first use. + * The classloader is kept for the life of the process and reused by every catalog that references + * the same driver URL (see {@link #CLASS_LOADER_MAP} for why it is never evicted). + * + *

    Visible for testing.

    + */ + static ClassLoader getOrCreateDriverClassLoader(URL url) { + return CLASS_LOADER_MAP.computeIfAbsent(url, key -> + URLClassLoader.newInstance(new URL[]{key}, JdbcConnectorClient.class.getClassLoader())); + } + private static String resolveDriverUrl(String driverUrl) { if (driverUrl.startsWith("file://") || driverUrl.startsWith("http://") || driverUrl.startsWith("https://")) { @@ -279,18 +284,9 @@ public void close() { dataSource.close(); dataSource = null; } - releaseClassLoader(); - } - - private void releaseClassLoader() { - if (classLoaderUrl == null) { - return; - } - CLASS_LOADER_MAP.computeIfPresent(classLoaderUrl, (key, entry) -> { - int remaining = entry.refCount.decrementAndGet(); - return remaining <= 0 ? null : entry; - }); - classLoaderUrl = null; + // The shared driver classloader is intentionally NOT released here: it stays cached in + // CLASS_LOADER_MAP and is reused by the next catalog for the same driver URL. Releasing it + // per-close would leak Metaspace rather than free it (see CLASS_LOADER_MAP). } // Visible for testing 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 1f943350c20487..cb7c49076f52cd 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 @@ -17,7 +17,13 @@ package org.apache.doris.connector.jdbc; +import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.connector.api.ConnectorContractValidator; +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.spi.ConnectorContext; import org.junit.jupiter.api.Assertions; @@ -25,6 +31,7 @@ import java.io.IOException; import java.util.Collections; +import java.util.EnumSet; import java.util.HashMap; import java.util.Map; @@ -71,6 +78,30 @@ void testGetScanPlanProviderAfterCloseThrows() throws IOException { () -> connector.getScanPlanProvider()); } + @Test + void testGetWritePlanProviderAfterCloseThrows() throws IOException { + // getWritePlanProvider() must be wired (non-null routing premise: a non-null provider + // sends jdbc writes through the unified plan-provider sink path). It resolves the client + // lazily, so after close it fails loud just like the scan provider. + JdbcDorisConnector connector = new JdbcDorisConnector(minimalProps(), testContext()); + connector.close(); + Assertions.assertThrows(DorisConnectorException.class, + () -> connector.getWritePlanProvider()); + } + + @Test + void testDeclaresMetadataPreloadCapability() { + // F11: PluginDrivenExternalTable.supportsExternalMetadataPreload is now capability-driven (replacing + // the legacy engine-name "jdbc" gate). jdbc must keep declaring SUPPORTS_METADATA_PRELOAD so jdbc + // tables retain async metadata pre-load. MUTATION: dropping it from getCapabilities() -> jdbc loses + // async pre-load after F11 -> red. + JdbcDorisConnector connector = new JdbcDorisConnector(minimalProps(), testContext()); + Assertions.assertTrue( + connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_METADATA_PRELOAD), + "jdbc must keep SUPPORTS_METADATA_PRELOAD so async metadata pre-load survives the F11 " + + "capability conversion"); + } + @Test void testDoubleCloseNoException() throws IOException { JdbcDorisConnector connector = new JdbcDorisConnector(minimalProps(), testContext()); @@ -139,13 +170,110 @@ void testConcurrentCloseAndGetMetadataNoNpe() throws Exception { } @Test - void testJdbcMetadataSupportsInsert() { + void testJdbcConnectorSupportsInsertOnly() { + // getWritePlanProvider() eagerly resolves a real JdbcConnectorClient, whose postInitialize() + // probes the remote server for MySQL (detectDoris) — use postgresql (no such probe) plus a + // harmless instantiable driver_class (java.lang.Object; never cast to java.sql.Driver here) + // so client creation succeeds without a live database or driver jar on the test classpath. + Map props = new HashMap<>(); + 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(), + "JDBC connector should declare INSERT as its only supported write operation"); + Assertions.assertFalse(connector.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"); + } + + @Test + void testGetWritePlanProviderWithoutDriverClassDoesNotThrow() { + // Regression test: driver_class is optional (JdbcConnectorProperties.DRIVER_CLASS is read + // via a plain properties.get(), so it is null when the catalog omits it — see + // JdbcDorisConnector#createClient). initializeDataSource() must not pass that null straight + // to HikariConfig#setDriverClassName, which NPEs deep inside HikariCP (loadClass(null) -> + // ClassLoader lock map -> ConcurrentHashMap forbids a null key) instead of throwing + // ClassNotFoundException. Use postgresql (no detectDoris probe in postInitialize(), unlike + // mysql) so client creation succeeds without a live database or driver jar on the classpath; + // HikariDataSource is lazy and only resolves the driver from the jdbcUrl at first + // getConnection(), so building it here must succeed even without driver_class. + Map props = new HashMap<>(); + props.put(JdbcConnectorProperties.JDBC_URL, "jdbc:postgresql://localhost:5432/test"); + JdbcDorisConnector connector = new JdbcDorisConnector(props, testContext()); + Assertions.assertDoesNotThrow(() -> { + connector.getWritePlanProvider(); + }, "missing driver_class must not NPE inside HikariCP during client initialization"); + } + + @Test + void testBeginTransactionReturnsNoOpTransaction() { + // jdbc writes are auto-committed by BE per row; beginTransaction returns a degenerate no-op + // transaction so the engine's write lifecycle is uniform (single ConnectorTransaction model). JdbcConnectorMetadata metadata = new JdbcConnectorMetadata(null, minimalProps()); - Assertions.assertTrue(metadata.supportsInsert(), - "JDBC connector metadata should support INSERT"); - Assertions.assertFalse(metadata.supportsDelete(), - "JDBC connector metadata should not support DELETE by default"); - Assertions.assertFalse(metadata.supportsMerge(), - "JDBC connector metadata should not support MERGE by default"); + ConnectorTransaction txn = metadata.beginTransaction(new FixedIdSession(99L)); + + Assertions.assertTrue(txn instanceof NoOpConnectorTransaction, + "jdbc beginTransaction must return a no-op ConnectorTransaction"); + Assertions.assertEquals(99L, txn.getTransactionId(), + "the transaction id must come from the engine session (allocateTransactionId)"); + Assertions.assertEquals("JDBC", txn.profileLabel(), + "the profile label must map to TransactionType.JDBC"); + Assertions.assertEquals(-1L, txn.getUpdateCnt(), + "getUpdateCnt == -1 keeps the coordinator row counter (no affected-rows regression)"); + } + + /** Minimal {@link ConnectorSession} that hands back a fixed engine transaction id. */ + private static final class FixedIdSession implements ConnectorSession { + private final long txnId; + + private FixedIdSession(long txnId) { + this.txnId = txnId; + } + + @Override + public long allocateTransactionId() { + return txnId; + } + + @Override + public String getQueryId() { + return "q"; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en"; + } + + @Override + public long getCatalogId() { + return 1L; + } + + @Override + public String getCatalogName() { + return "test_catalog"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } } } diff --git a/fe/fe-connector/fe-connector-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 new file mode 100644 index 00000000000000..62a698c3f0839a --- /dev/null +++ b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcWritePlanProviderTest.java @@ -0,0 +1,271 @@ +// 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.jdbc; + +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.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.ConnectorWriteHandle; +import org.apache.doris.connector.api.write.ConnectorSinkPlan; +import org.apache.doris.connector.jdbc.client.JdbcConnectorClient; +import org.apache.doris.connector.jdbc.client.JdbcFieldInfo; +import org.apache.doris.thrift.TDataSink; +import org.apache.doris.thrift.TDataSinkType; +import org.apache.doris.thrift.TJdbcTable; +import org.apache.doris.thrift.TJdbcTableSink; +import org.apache.doris.thrift.TOdbcTableType; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Byte-parity tests for {@link JdbcWritePlanProvider} (P6.3-T02 / RFC OQ-1). + * + *

    The jdbc {@code TJdbcTableSink} assembly moved out of fe-core + * ({@code PluginDrivenTableSink.bindJdbcWriteSink} + the deleted + * {@code JdbcConnectorMetadata.getWriteConfig} property bag) into this connector + * provider. These tests lock the produced Thrift field values so the move stays + * byte-identical to the legacy write path (no BE change).

    + */ +class JdbcWritePlanProviderTest { + + /** + * Test double for {@link JdbcConnectorClient}: the protected constructor only sets + * fields (no data source), so we just feed a db type and the remote columns the + * write SQL is built from. + */ + private static final class FakeJdbcClient extends JdbcConnectorClient { + private final List fields; + + private FakeJdbcClient(JdbcDbType dbType, List fields) { + super("test_catalog", dbType, "jdbc:mysql://h:3306/test_db", + false, null, null, false, false); + this.fields = fields; + } + + @Override + public List getJdbcColumnsInfo(String remoteDbName, String remoteTableName) { + return fields; + } + + @Override + public ConnectorType jdbcTypeToConnectorType(JdbcFieldInfo fieldInfo) { + return null; + } + } + + private static JdbcFieldInfo field(String name) { + return new JdbcFieldInfo(name, Optional.empty(), 0, + Optional.empty(), Optional.empty(), Optional.empty()); + } + + private static ConnectorWriteHandle writeHandle(ConnectorTableHandle table, List cols) { + List columns = new java.util.ArrayList<>(); + for (String c : cols) { + columns.add(new ConnectorColumn(c, ConnectorType.of("INT"), null, true, null)); + } + return new ConnectorWriteHandle() { + @Override + public ConnectorTableHandle getTableHandle() { + return table; + } + + @Override + public List getColumns() { + return columns; + } + + @Override + public boolean isOverwrite() { + return false; + } + + @Override + public Map getWriteContext() { + return Collections.emptyMap(); + } + }; + } + + private static ConnectorSession session(long catalogId, Map sessionProps) { + return new ConnectorSession() { + @Override + public String getQueryId() { + return "q"; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en"; + } + + @Override + public long getCatalogId() { + return catalogId; + } + + @Override + public String getCatalogName() { + return "test_catalog"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + + @Override + public Map getSessionProperties() { + return sessionProps; + } + }; + } + + @Test + void planWriteBuildsJdbcTableSinkWithByteParityFields() { + Map props = new HashMap<>(); + props.put("jdbc_url", "jdbc:mysql://h:3306/test_db"); + props.put("user", "root"); + props.put("password", "secret"); + props.put("driver_class", "com.mysql.cj.jdbc.Driver"); + props.put("driver_url", "mysql-connector-j-8.4.0.jar"); + props.put("checksum", "abc123"); + props.put("connection_pool_min_size", "2"); + props.put("connection_pool_max_size", "20"); + props.put("connection_pool_max_wait_time", "6000"); + props.put("connection_pool_max_life_time", "900000"); + props.put("connection_pool_keep_alive", "true"); + + FakeJdbcClient client = new FakeJdbcClient( + JdbcDbType.MYSQL, Arrays.asList(field("id"), field("name"))); + JdbcWritePlanProvider provider = new JdbcWritePlanProvider(client, props); + + Map sessionProps = new HashMap<>(); + sessionProps.put("enable_odbc_transcation", "true"); + ConnectorWriteHandle handle = writeHandle( + new JdbcTableHandle("test_db", "t1"), Arrays.asList("id", "name")); + + ConnectorSinkPlan plan = provider.planWrite(session(7L, sessionProps), handle); + TDataSink dataSink = plan.getDataSink(); + + Assertions.assertEquals(TDataSinkType.JDBC_TABLE_SINK, dataSink.getType()); + TJdbcTableSink sink = dataSink.getJdbcTableSink(); + TJdbcTable t = sink.getJdbcTable(); + + Assertions.assertEquals("jdbc:mysql://h:3306/test_db", t.getJdbcUrl()); + Assertions.assertEquals("root", t.getJdbcUser()); + Assertions.assertEquals("secret", t.getJdbcPassword()); + Assertions.assertEquals("mysql-connector-j-8.4.0.jar", t.getJdbcDriverUrl()); + Assertions.assertEquals("com.mysql.cj.jdbc.Driver", t.getJdbcDriverClass()); + Assertions.assertEquals("abc123", t.getJdbcDriverChecksum()); + Assertions.assertEquals("t1", t.getJdbcTableName()); + Assertions.assertEquals("", t.getJdbcResourceName()); + Assertions.assertEquals(7L, t.getCatalogId()); + Assertions.assertEquals(2, t.getConnectionPoolMinSize()); + Assertions.assertEquals(20, t.getConnectionPoolMaxSize()); + Assertions.assertEquals(6000, t.getConnectionPoolMaxWaitTime()); + Assertions.assertEquals(900000, t.getConnectionPoolMaxLifeTime()); + Assertions.assertTrue(t.isConnectionPoolKeepAlive()); + + Assertions.assertEquals( + "INSERT INTO `test_db`.`t1`(`id`,`name`) VALUES (?, ?)", sink.getInsertSql()); + Assertions.assertTrue(sink.isUseTransaction()); + Assertions.assertEquals(TOdbcTableType.MYSQL, sink.getTableType()); + } + + @Test + void appendExplainInfoEmitsConnectorWriteDetail() { + // The unified plan-provider sink is source-agnostic; the jdbc connector restores its + // INSERT SQL / table type / use-transaction lines in EXPLAIN via this hook. + Map props = new HashMap<>(); + props.put("jdbc_url", "jdbc:mysql://h:3306/test_db"); + + FakeJdbcClient client = new FakeJdbcClient( + JdbcDbType.MYSQL, Arrays.asList(field("id"), field("name"))); + JdbcWritePlanProvider provider = new JdbcWritePlanProvider(client, props); + + Map sessionProps = new HashMap<>(); + sessionProps.put("enable_odbc_transcation", "true"); + ConnectorWriteHandle handle = writeHandle( + new JdbcTableHandle("test_db", "t1"), Arrays.asList("id", "name")); + + StringBuilder sb = new StringBuilder(); + provider.appendExplainInfo(sb, " ", session(7L, sessionProps), handle); + String explain = sb.toString(); + + Assertions.assertTrue(explain.contains("TABLE TYPE: MYSQL"), explain); + Assertions.assertTrue(explain.contains( + "INSERT SQL: INSERT INTO `test_db`.`t1`(`id`,`name`) VALUES (?, ?)"), explain); + Assertions.assertTrue(explain.contains("USE TRANSACTION: true"), explain); + } + + @Test + void planWritePoolAndTxnFieldsFallBackToLegacyDefaultsWhenAbsent() { + Map props = new HashMap<>(); + props.put("jdbc_url", "jdbc:mysql://h:3306/test_db"); + + FakeJdbcClient client = new FakeJdbcClient( + JdbcDbType.MYSQL, Collections.singletonList(field("c"))); + JdbcWritePlanProvider provider = new JdbcWritePlanProvider(client, props); + + ConnectorWriteHandle handle = writeHandle( + new JdbcTableHandle("test_db", "t1"), Collections.singletonList("c")); + ConnectorSinkPlan plan = provider.planWrite( + session(1L, Collections.emptyMap()), handle); + TJdbcTableSink sink = plan.getDataSink().getJdbcTableSink(); + TJdbcTable t = sink.getJdbcTable(); + + // Pool values come from JdbcConnectorProperties.getInt(..., DEFAULT_*), exactly as the + // legacy getWriteConfig computed them — NOT the bindJdbcWriteSink hard-coded fallbacks + // (which were unreachable because the property bag always carried the computed values). + Assertions.assertEquals( + JdbcConnectorProperties.DEFAULT_POOL_MIN_SIZE, t.getConnectionPoolMinSize()); + Assertions.assertEquals( + JdbcConnectorProperties.DEFAULT_POOL_MAX_SIZE, t.getConnectionPoolMaxSize()); + Assertions.assertEquals( + JdbcConnectorProperties.DEFAULT_POOL_MAX_WAIT_TIME, t.getConnectionPoolMaxWaitTime()); + Assertions.assertEquals( + JdbcConnectorProperties.DEFAULT_POOL_MAX_LIFE_TIME, t.getConnectionPoolMaxLifeTime()); + Assertions.assertFalse(t.isConnectionPoolKeepAlive()); + // enable_odbc_transcation absent -> false (note the legacy session-key spelling preserved). + Assertions.assertFalse(sink.isUseTransaction()); + } +} diff --git a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/client/JdbcConnectorClientClassLoaderTest.java b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/client/JdbcConnectorClientClassLoaderTest.java index 5c2a46f1a75d7d..2146d985324f38 100644 --- a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/client/JdbcConnectorClientClassLoaderTest.java +++ b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/client/JdbcConnectorClientClassLoaderTest.java @@ -20,75 +20,57 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -import java.util.concurrent.atomic.AtomicInteger; +import java.net.URL; +import java.util.UUID; /** - * Unit tests for the ClassLoader reference counting mechanism in - * {@link JdbcConnectorClient}. + * Unit tests for the driver-classloader keep-alive cache in {@link JdbcConnectorClient}. + * + *

    The cache holds exactly one classloader per distinct driver URL and never evicts it, so that + * repeated catalog create/close/recreate cycles for the same driver reuse a single classloader + * instead of building (and DriverManager-pinning) a fresh one each time. Reintroducing per-close + * eviction reopened the Metaspace leak behind external-regression OOM 986696, so these tests exist + * to lock the keep-alive semantics in place. */ class JdbcConnectorClientClassLoaderTest { - @Test - void testRefCountedClassLoaderStartsAtOne() { - JdbcConnectorClient.RefCountedClassLoader entry = - new JdbcConnectorClient.RefCountedClassLoader(getClass().getClassLoader()); - Assertions.assertEquals(1, entry.refCount.get(), - "Initial ref count should be 1"); + private static URL uniqueDriverUrl() throws Exception { + return new URL("file:///tmp/doris-test-driver-" + UUID.randomUUID() + ".jar"); } @Test - void testRefCountedClassLoaderIncrementDecrement() { - JdbcConnectorClient.RefCountedClassLoader entry = - new JdbcConnectorClient.RefCountedClassLoader(getClass().getClassLoader()); - entry.refCount.incrementAndGet(); - Assertions.assertEquals(2, entry.refCount.get(), - "Ref count should be 2 after increment"); - entry.refCount.decrementAndGet(); - Assertions.assertEquals(1, entry.refCount.get(), - "Ref count should be 1 after decrement"); - entry.refCount.decrementAndGet(); - Assertions.assertEquals(0, entry.refCount.get(), - "Ref count should be 0 after second decrement"); + void sameDriverUrlReturnsSameCachedLoader() throws Exception { + URL url = uniqueDriverUrl(); + ClassLoader first = JdbcConnectorClient.getOrCreateDriverClassLoader(url); + ClassLoader second = JdbcConnectorClient.getOrCreateDriverClassLoader(url); + Assertions.assertSame(first, second, + "The same driver URL must resolve to the one cached classloader"); } @Test - void testRefCountedClassLoaderStoresLoader() { - ClassLoader loader = getClass().getClassLoader(); - JdbcConnectorClient.RefCountedClassLoader entry = - new JdbcConnectorClient.RefCountedClassLoader(loader); - Assertions.assertSame(loader, entry.loader, - "Loader reference must be the one passed to constructor"); + void distinctDriverUrlsGetDistinctLoaders() throws Exception { + ClassLoader a = JdbcConnectorClient.getOrCreateDriverClassLoader(uniqueDriverUrl()); + ClassLoader b = JdbcConnectorClient.getOrCreateDriverClassLoader(uniqueDriverUrl()); + Assertions.assertNotSame(a, b, + "Different driver URLs must get their own classloaders"); } @Test - void testConcurrentRefCountOperations() throws InterruptedException { - JdbcConnectorClient.RefCountedClassLoader entry = - new JdbcConnectorClient.RefCountedClassLoader(getClass().getClassLoader()); - AtomicInteger errors = new AtomicInteger(0); - - int threadCount = 20; - Thread[] threads = new Thread[threadCount]; - // Each thread increments, then decrements — net effect is zero - for (int i = 0; i < threadCount; i++) { - threads[i] = new Thread(() -> { - try { - entry.refCount.incrementAndGet(); - Thread.yield(); - entry.refCount.decrementAndGet(); - } catch (Exception e) { - errors.incrementAndGet(); - } - }); - } - for (Thread t : threads) { - t.start(); + void churningSameDriverUrlReusesOneLoaderNoMetaspaceLeak() throws Exception { + URL url = uniqueDriverUrl(); + int before = JdbcConnectorClient.classLoaderCacheSize(); + // Simulate many CREATE CATALOG -> DROP CATALOG -> CREATE CATALOG cycles for the same driver: + // each cycle looks the driver classloader up again. Keep-alive means the cache (and thus the + // number of live, DriverManager-pinned driver classloaders) grows by exactly one, not one per + // cycle -- which is the whole point of the fix. + ClassLoader firstLoader = JdbcConnectorClient.getOrCreateDriverClassLoader(url); + for (int i = 0; i < 20; i++) { + ClassLoader loader = JdbcConnectorClient.getOrCreateDriverClassLoader(url); + Assertions.assertSame(firstLoader, loader, + "Every cycle for the same driver URL must reuse the first classloader"); } - for (Thread t : threads) { - t.join(); - } - - Assertions.assertEquals(0, errors.get(), "No thread errors expected"); - Assertions.assertEquals(1, entry.refCount.get(), - "Ref count should return to 1 after concurrent inc/dec"); + int after = JdbcConnectorClient.classLoaderCacheSize(); + Assertions.assertEquals(before + 1, after, + "20 create/recreate cycles for one driver URL must add exactly one cached classloader"); } } diff --git a/fe/fe-connector/fe-connector-maxcompute/pom.xml b/fe/fe-connector/fe-connector-maxcompute/pom.xml index 3ed6ac74ed0df9..37565fbc8a1140 100644 --- a/fe/fe-connector/fe-connector-maxcompute/pom.xml +++ b/fe/fe-connector/fe-connector-maxcompute/pom.xml @@ -45,6 +45,31 @@ under the License. ${project.version} + + + ${project.groupId} + fe-connector-cache + ${project.version} + + + + + com.github.ben-manes.caffeine + caffeine + 2.9.3 + + ${project.groupId} diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/assembly/plugin-zip.xml b/fe/fe-connector/fe-connector-maxcompute/src/main/assembly/plugin-zip.xml index 13262ad745ad6d..f0658344c4c9cc 100644 --- a/fe/fe-connector/fe-connector-maxcompute/src/main/assembly/plugin-zip.xml +++ b/fe/fe-connector/fe-connector-maxcompute/src/main/assembly/plugin-zip.xml @@ -54,6 +54,12 @@ under the License. org.apache.doris:fe-filesystem-api org.apache.doris:fe-thrift org.apache.thrift:libthrift + + io.netty:netty-codec-native-quic + + org.apache.logging.log4j:* + org.slf4j:* diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MCConnectorClientFactory.java b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MCConnectorClientFactory.java index 8e3ec3b1116987..1861e18a599078 100644 --- a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MCConnectorClientFactory.java +++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MCConnectorClientFactory.java @@ -38,6 +38,9 @@ private MCConnectorClientFactory() { /** * Validates that required authentication properties are present. + * Throws {@link IllegalArgumentException} so that CREATE CATALOG property + * validation ({@code MaxComputeConnectorProvider.validateProperties}) surfaces + * a clean DdlException, consistent with the other connectors' validation. */ public static void checkAuthProperties(Map properties) { String authType = properties.getOrDefault( @@ -49,7 +52,7 @@ public static void checkAuthProperties(Map properties) { if (!properties.containsKey(MCConnectorProperties.ACCESS_KEY) || !properties.containsKey( MCConnectorProperties.SECRET_KEY)) { - throw new RuntimeException( + throw new IllegalArgumentException( "Missing access key or secret key for " + "AK/SK auth type"); } @@ -60,7 +63,7 @@ public static void checkAuthProperties(Map properties) { MCConnectorProperties.SECRET_KEY) || !properties.containsKey( MCConnectorProperties.RAM_ROLE_ARN)) { - throw new RuntimeException( + throw new IllegalArgumentException( "Missing access key, secret key or role arn " + "for RAM Role ARN auth type"); } @@ -68,11 +71,11 @@ public static void checkAuthProperties(Map properties) { MCConnectorProperties.AUTH_TYPE_ECS_RAM_ROLE)) { if (!properties.containsKey( MCConnectorProperties.ECS_RAM_ROLE)) { - throw new RuntimeException( + throw new IllegalArgumentException( "Missing role name for ECS RAM Role auth type"); } } else { - throw new RuntimeException( + throw new IllegalArgumentException( "Unsupported auth type: " + authType); } } diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MCTypeMapping.java b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MCTypeMapping.java index 9a238673803929..4c8f53ded6ed58 100644 --- a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MCTypeMapping.java +++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MCTypeMapping.java @@ -18,6 +18,7 @@ package org.apache.doris.connector.maxcompute; import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; import com.aliyun.odps.OdpsType; import com.aliyun.odps.type.ArrayTypeInfo; @@ -26,10 +27,12 @@ import com.aliyun.odps.type.MapTypeInfo; import com.aliyun.odps.type.StructTypeInfo; import com.aliyun.odps.type.TypeInfo; +import com.aliyun.odps.type.TypeInfoFactory; import com.aliyun.odps.type.VarcharTypeInfo; import java.util.ArrayList; import java.util.List; +import java.util.Locale; /** * Maps MaxCompute (ODPS) type system to Doris ConnectorType. @@ -46,7 +49,10 @@ public static ConnectorType toConnectorType(TypeInfo typeInfo) { OdpsType odpsType = typeInfo.getOdpsType(); switch (odpsType) { case VOID: - return ConnectorType.of("NULL"); + // "NULL_TYPE" is the token ScalarType.createType recognizes (-> Type.NULL), + // matching legacy MaxComputeExternalTable.mcTypeToDorisType VOID -> Type.NULL. + // "NULL" is NOT recognized (createType throws, swallowed to UNSUPPORTED). + return ConnectorType.of("NULL_TYPE"); case BOOLEAN: return ConnectorType.of("BOOLEAN"); case TINYINT: @@ -94,7 +100,12 @@ public static ConnectorType toConnectorType(TypeInfo typeInfo) { case INTERVAL_YEAR_MONTH: return ConnectorType.of("UNSUPPORTED"); default: - return ConnectorType.of("UNSUPPORTED"); + // Mirror legacy MaxComputeExternalTable.mcTypeToDorisType: fail-fast on a genuinely + // unknown OdpsType rather than silently degrading it to UNSUPPORTED. Known + // unsupported types (BINARY, INTERVAL_*, JSON) have explicit cases above, so this + // default is reached only by a future/unrecognized OdpsType. + throw new DorisConnectorException( + "Cannot transform unknown MaxCompute type: " + odpsType); } } @@ -123,4 +134,84 @@ private static ConnectorType mapStructType(StructTypeInfo structType) { } return ConnectorType.structOf(names, fieldTypes); } + + /** + * Converts a {@link ConnectorType} (as produced by the CREATE TABLE request + * path) to a MaxCompute (ODPS) {@link TypeInfo}. Faithful reverse of the + * legacy {@code MaxComputeMetadataOps.dorisTypeToMcType}; the scalar type + * name is the Doris {@code PrimitiveType} name (e.g. INT, DECIMAL64, + * DATETIMEV2), with CHAR/VARCHAR length and DECIMAL precision/scale carried + * in the {@link ConnectorType} precision/scale fields. + * + * @throws DorisConnectorException if the type cannot be represented in MaxCompute + */ + public static TypeInfo toMcType(ConnectorType type) { + String name = type.getTypeName().toUpperCase(Locale.ROOT); + switch (name) { + case "ARRAY": + return TypeInfoFactory.getArrayTypeInfo( + toMcType(type.getChildren().get(0))); + case "MAP": + return TypeInfoFactory.getMapTypeInfo( + toMcType(type.getChildren().get(0)), + toMcType(type.getChildren().get(1))); + case "STRUCT": + return toMcStructType(type); + default: + return toMcScalarType(name, type); + } + } + + private static TypeInfo toMcScalarType(String name, ConnectorType type) { + switch (name) { + case "BOOLEAN": + return TypeInfoFactory.BOOLEAN; + case "TINYINT": + return TypeInfoFactory.TINYINT; + case "SMALLINT": + return TypeInfoFactory.SMALLINT; + case "INT": + return TypeInfoFactory.INT; + case "BIGINT": + return TypeInfoFactory.BIGINT; + case "FLOAT": + return TypeInfoFactory.FLOAT; + case "DOUBLE": + return TypeInfoFactory.DOUBLE; + case "CHAR": + return TypeInfoFactory.getCharTypeInfo(type.getPrecision()); + case "VARCHAR": + return TypeInfoFactory.getVarcharTypeInfo(type.getPrecision()); + case "STRING": + return TypeInfoFactory.STRING; + case "DECIMALV2": + case "DECIMAL32": + case "DECIMAL64": + case "DECIMAL128": + case "DECIMAL256": + return TypeInfoFactory.getDecimalTypeInfo( + type.getPrecision(), type.getScale()); + case "DATE": + case "DATEV2": + return TypeInfoFactory.DATE; + case "DATETIME": + case "DATETIMEV2": + return TypeInfoFactory.DATETIME; + default: + throw new DorisConnectorException( + "Unsupported type for MaxCompute: " + type); + } + } + + private static TypeInfo toMcStructType(ConnectorType type) { + List children = type.getChildren(); + List names = type.getFieldNames(); + List fieldNames = new ArrayList<>(children.size()); + List fieldTypes = new ArrayList<>(children.size()); + for (int i = 0; i < children.size(); i++) { + fieldNames.add(i < names.size() ? names.get(i) : "col" + i); + fieldTypes.add(toMcType(children.get(i))); + } + return TypeInfoFactory.getStructTypeInfo(fieldNames, fieldTypes); + } } 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 77aef9d8a9a514..f70935a73e593b 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 @@ -19,23 +19,41 @@ import org.apache.doris.connector.api.ConnectorColumn; import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorPartitionInfo; import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.ConnectorTableSchema; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.ddl.ConnectorBucketSpec; +import org.apache.doris.connector.api.ddl.ConnectorCreateTableRequest; +import org.apache.doris.connector.api.ddl.ConnectorPartitionField; +import org.apache.doris.connector.api.ddl.ConnectorPartitionSpec; import org.apache.doris.connector.api.handle.ConnectorColumnHandle; import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; import com.aliyun.odps.Column; import com.aliyun.odps.Odps; +import com.aliyun.odps.OdpsException; +import com.aliyun.odps.Partition; +import com.aliyun.odps.PartitionSpec; import com.aliyun.odps.Table; +import com.aliyun.odps.TableSchema; +import com.aliyun.odps.Tables; import com.aliyun.odps.table.TableIdentifier; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; /** * ConnectorMetadata implementation for MaxCompute. @@ -45,16 +63,35 @@ public class MaxComputeConnectorMetadata implements ConnectorMetadata { private static final Logger LOG = LogManager.getLogger( MaxComputeConnectorMetadata.class); + private static final long MAX_LIFECYCLE_DAYS = 37231; + private static final int MAX_BUCKET_NUM = 1024; + // Must stay byte-identical to the key ConnectorSessionBuilder.extractSessionProperties injects + // (GC1 / FIX-BLOCKID-CAP-CONFIG); = the legacy fe-core Config field name, surfaced via session + // properties because the connector cannot import fe-core Config. + private static final String MAX_COMPUTE_WRITE_MAX_BLOCK_COUNT = "max_compute_write_max_block_count"; + private final Odps odps; private final McStructureHelper structureHelper; private final String defaultProject; + private final String endpoint; + private final String quota; + private final Map properties; + private final MaxComputePartitionCache partitionCache; public MaxComputeConnectorMetadata(Odps odps, McStructureHelper structureHelper, - String defaultProject) { + String defaultProject, + String endpoint, + String quota, + Map properties, + MaxComputePartitionCache partitionCache) { this.odps = odps; this.structureHelper = structureHelper; this.defaultProject = defaultProject; + this.endpoint = endpoint; + this.quota = quota; + this.properties = properties; + this.partitionCache = partitionCache; } @Override @@ -106,35 +143,46 @@ public ConnectorTableSchema getTableSchema(ConnectorSession session, new ArrayList<>(dataColumns.size() + partColumns.size()); for (Column col : dataColumns) { - columns.add(new ConnectorColumn( + columns.add(buildColumn( col.getName(), MCTypeMapping.toConnectorType(col.getTypeInfo()), col.getComment(), - col.isNullable(), - null)); + col.isNullable())); } List partitionColumnNames = new ArrayList<>(partColumns.size()); for (Column partCol : partColumns) { partitionColumnNames.add(partCol.getName()); - columns.add(new ConnectorColumn( + columns.add(buildColumn( partCol.getName(), MCTypeMapping.toConnectorType(partCol.getTypeInfo()), partCol.getComment(), - true, - null)); + true)); } java.util.Map props = new java.util.HashMap<>(); if (!partitionColumnNames.isEmpty()) { - props.put("partition_columns", + props.put(ConnectorTableSchema.PARTITION_COLUMNS_KEY, String.join(",", partitionColumnNames)); } return new ConnectorTableSchema( mcHandle.getTableName(), columns, "MAX_COMPUTE", props); } + /** + * Builds a {@link ConnectorColumn} for a MaxCompute external-table column with + * {@code isKey=true}, mirroring legacy {@code MaxComputeExternalTable.initSchema} (every column + * was a Doris key column). For external (non-OLAP) tables there is no key-based storage; the + * flag drives DESCRIBE's {@code Key} display and the few non-OLAP-guarded planning/BE paths that + * read {@code Column.isKey()} (e.g. predicate inference, slot descriptors) — all of which legacy + * already fed {@code true}, so this restores exact legacy parity. {@code isAutoInc} stays false. + */ + static ConnectorColumn buildColumn(String name, ConnectorType type, String comment, + boolean nullable) { + return new ConnectorColumn(name, type, comment, nullable, null, true); + } + @Override public Map getColumnHandles( ConnectorSession session, ConnectorTableHandle handle) { @@ -152,4 +200,417 @@ public Map getColumnHandles( } return result; } + + /** + * Builds the typed MaxCompute table descriptor for the read path. The BE + * {@code file_scanner} static_casts {@code table_desc()} to + * {@code MaxComputeTableDescriptor} unconditionally for + * {@code table_format_type=="max_compute"}, so the descriptor MUST be + * {@code MAX_COMPUTE_TABLE} with {@code mcTable} set; the null / SCHEMA_TABLE + * fallback would produce type confusion in BE. Mirrors legacy + * {@code MaxComputeExternalTable.toThrift()}. + * + *

    {@code project}/{@code table} use the remote-name params: the SPI read + * session also addresses ODPS with remote names, so the descriptor must match + * (see design OQ-7). The 6th ctor arg ({@code dbName}) mirrors legacy and is + * unread by BE for MC reads. Fully-qualified thrift names match the jdbc/es + * overrides and avoid new connector imports.

    + */ + @Override + public org.apache.doris.thrift.TTableDescriptor buildTableDescriptor( + ConnectorSession session, + long tableId, String tableName, String dbName, + String remoteName, int numCols, long catalogId) { + org.apache.doris.thrift.TMCTable tMcTable = new org.apache.doris.thrift.TMCTable(); + tMcTable.setEndpoint(endpoint); + tMcTable.setQuota(quota); + tMcTable.setProject(dbName); + tMcTable.setTable(remoteName); + tMcTable.setProperties(properties); + org.apache.doris.thrift.TTableDescriptor desc = new org.apache.doris.thrift.TTableDescriptor( + tableId, org.apache.doris.thrift.TTableType.MAX_COMPUTE_TABLE, + numCols, 0, tableName, dbName); + desc.setMcTable(tMcTable); + return desc; + } + + // ==================== Partition listing ==================== + + @Override + public List listPartitionNames(ConnectorSession session, + ConnectorTableHandle handle) { + MaxComputeTableHandle mcHandle = (MaxComputeTableHandle) handle; + List partitions = partitionCache.getPartitions( + mcHandle.getDbName(), mcHandle.getTableName()); + List names = new ArrayList<>(partitions.size()); + for (Partition partition : partitions) { + names.add(partition.getPartitionSpec().toString(false, true)); + } + return names; + } + + /** + * Lists all partitions. The {@code filter} is intentionally ignored: the + * legacy SHOW PARTITIONS path ({@code MaxComputeExternalCatalog + * #listPartitionNames}) returns the full partition set without pushing + * predicates into ODPS, and this preserves that behavior. Partitions are + * served through the connector-owned {@link MaxComputePartitionCache} + * (keyed by db+table), so repeated / cross-method partition listings of the + * same table share one ODPS round trip. + */ + @Override + public List listPartitions(ConnectorSession session, + ConnectorTableHandle handle, Optional filter) { + 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(); + Map values = new LinkedHashMap<>(); + for (String key : spec.keys()) { + values.put(key, spec.get(key)); + } + result.add(new ConnectorPartitionInfo( + spec.toString(false, true), values, Collections.emptyMap())); + } + 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) ==================== + + /** + * Disables pushing predicates that contain implicit CAST expressions down to ODPS (F9 fix). + * + *

    The shared {@code ExprToConnectorExpressionConverter} unwraps CAST shells, so without this + * a predicate like {@code CAST(str_col AS INT) = 5} would be pushed to the ODPS read session as + * the source-side filter {@code str_col = "5"} (quoted by the column's STRING type), which ODPS + * evaluates as exact string equality and drops rows like {@code "05"}/{@code " 5"} at the + * source — silent data loss, because BE re-evaluation can only filter the returned rows down, + * never recover rows ODPS never returned. Returning {@code false} makes + * {@code PluginDrivenScanNode.buildRemainingFilter} strip CAST-bearing conjuncts before pushdown + * (they stay BE-only), restoring legacy parity: legacy {@code MaxComputeScanNode} likewise never + * pushed CAST predicates (its {@code convertSlotRefToColumnName} threw on a CAST operand and the + * conjunct was dropped). Mirrors {@code JdbcConnectorMetadata} and the contract documented on + * {@link org.apache.doris.connector.api.ConnectorPushdownOps#supportsCastPredicatePushdown}. + */ + @Override + public boolean supportsCastPredicatePushdown(ConnectorSession session) { + return false; + } + + /** + * Opens a connector transaction for a MaxCompute write statement. The + * transaction id is the engine-side id allocated through the session, so it + * matches the id registered in the engine transaction registry and stamped + * into the data sink (see {@link MaxComputeConnectorTransaction}). + * + *

    Gate-closed / dormant until the {@code max_compute} cutover: nothing + * routes plugin-driven MaxCompute writes through this path yet. The ODPS + * write session that backs commit / block allocation is created by the write + * plan (P4-T04), which binds it via + * {@link MaxComputeConnectorTransaction#setWriteSession}.

    + */ + @Override + public ConnectorTransaction beginTransaction(ConnectorSession session) { + long maxBlockCount = resolveMaxBlockCount(session.getSessionProperties()); + return new MaxComputeConnectorTransaction(session.allocateTransactionId(), maxBlockCount); + } + + /** + * Resolves the write block-id cap from the session properties, into which fe-core's + * {@code ConnectorSessionBuilder} surfaces the (tunable) + * {@code Config.max_compute_write_max_block_count} (the connector cannot import fe-core + * {@code Config}). Falls back to the legacy default when the value is absent or unparseable, + * so any path without the injected value keeps the current behavior. Package-private + + * map-typed for direct unit testing without a live session. + */ + static long resolveMaxBlockCount(Map sessionProperties) { + String value = sessionProperties.get(MAX_COMPUTE_WRITE_MAX_BLOCK_COUNT); + if (value == null) { + return MaxComputeConnectorTransaction.DEFAULT_MAX_BLOCK_COUNT; + } + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return MaxComputeConnectorTransaction.DEFAULT_MAX_BLOCK_COUNT; + } + } + + // ==================== DDL: Create/Drop Table ==================== + + @Override + public void createTable(ConnectorSession session, + ConnectorCreateTableRequest request) { + String dbName = request.getDbName(); + String tableName = request.getTableName(); + + if (structureHelper.tableExist(odps, dbName, tableName)) { + if (request.isIfNotExists()) { + LOG.info("create table[{}.{}] which already exists", + dbName, tableName); + return; + } + throw new DorisConnectorException("Table '" + tableName + + "' already exists in database '" + dbName + "'"); + } + + List columns = request.getColumns(); + validateColumns(columns); + List partitionColumns = + identityPartitionColumns(request.getPartitionSpec()); + TableSchema schema = buildSchema(columns, partitionColumns); + + Long lifecycle = extractLifecycle(request.getProperties()); + Map mcProperties = + extractMaxComputeProperties(request.getProperties()); + Integer bucketNum = extractBucketNum(request.getBucketSpec()); + + Tables.TableCreator creator = structureHelper.createTableCreator( + odps, dbName, tableName, schema); + if (request.isIfNotExists()) { + creator.ifNotExists(); + } + String comment = request.getComment(); + if (comment != null && !comment.isEmpty()) { + creator.withComment(comment); + } + if (lifecycle != null) { + creator.withLifeCycle(lifecycle); + } + if (!mcProperties.isEmpty()) { + creator.withTblProperties(mcProperties); + } + if (bucketNum != null) { + creator.withDeltaTableBucketNum(bucketNum); + } + + try { + creator.create(); + } catch (OdpsException e) { + throw new DorisConnectorException("Failed to create MaxCompute table '" + + tableName + "': " + e.getMessage(), e); + } + LOG.info("created MaxCompute table {}.{}", dbName, tableName); + } + + /** + * Drops the table behind {@code handle}. The SPI signature carries no + * {@code ifExists}; fe-core resolves the handle (absent when the table does + * not exist) before routing here, so the remote drop is issued idempotently. + */ + @Override + public void dropTable(ConnectorSession session, + ConnectorTableHandle handle) { + MaxComputeTableHandle mcHandle = (MaxComputeTableHandle) handle; + String dbName = mcHandle.getDbName(); + String tableName = mcHandle.getTableName(); + try { + structureHelper.dropTable(odps, dbName, tableName, true); + } catch (OdpsException e) { + throw new DorisConnectorException("Failed to drop MaxCompute table '" + + tableName + "': " + e.getMessage(), e); + } + LOG.info("dropped MaxCompute table {}.{}", dbName, tableName); + } + + // ==================== DDL: Create/Drop Database ==================== + + @Override + public boolean supportsCreateDatabase() { + return true; + } + + @Override + public void createDatabase(ConnectorSession session, String dbName, + Map properties) { + structureHelper.createDb(odps, dbName, false); + LOG.info("created MaxCompute database {}", dbName); + } + + @Override + public void dropDatabase(ConnectorSession session, String dbName, + boolean ifExists, boolean force) { + if (force) { + // ODPS schemas().delete() does NOT auto-cascade; enumerate and drop each + // table first (mirrors legacy MaxComputeMetadataOps.dropDbImpl force branch, + // whose enumerate-loop is itself proof that the schema delete won't cascade). + for (String tableName : structureHelper.listTableNames(odps, dbName)) { + try { + structureHelper.dropTable(odps, dbName, tableName, true); + } catch (OdpsException e) { + throw new DorisConnectorException("Failed to drop MaxCompute table '" + + tableName + "' during force-drop of database '" + dbName + + "': " + e.getMessage(), e); + } + } + } + structureHelper.dropDb(odps, dbName, ifExists); + LOG.info("dropped MaxCompute database {} (force={})", dbName, force); + } + + // ==================== DDL helpers ==================== + + // package-private for unit test; reached only via createTable() in production. + void validateColumns(List columns) { + if (columns == null || columns.isEmpty()) { + throw new DorisConnectorException( + "Table must have at least one column."); + } + Set seen = new HashSet<>(); + for (ConnectorColumn col : columns) { + // MaxCompute cannot store auto-increment columns; reject them with the same message + // as legacy MaxComputeMetadataOps.validateColumns (silent drop is a data-model + // regression -- the user's AUTO_INCREMENT intent would be lost without warning). + if (col.isAutoInc()) { + throw new DorisConnectorException( + "Auto-increment columns are not supported for MaxCompute tables: " + + col.getName()); + } + // MaxCompute has no aggregate-key model; reject aggregate columns (e.g. SUM/REPLACE), + // mirroring legacy MaxComputeMetadataOps.validateColumns:426-429. The nereids non-OLAP + // path does not reject these (validateKeyColumns is ENGINE_OLAP-gated), so without this + // the user's aggregate intent is silently dropped to a plain column. + if (col.isAggregated()) { + throw new DorisConnectorException( + "Aggregation columns are not supported for MaxCompute tables: " + + col.getName()); + } + if (!seen.add(col.getName().toLowerCase())) { + throw new DorisConnectorException( + "Duplicate column name: " + col.getName()); + } + // Validate the type is representable in MaxCompute (throws otherwise). + MCTypeMapping.toMcType(col.getType()); + } + } + + /** + * Extracts the identity partition column names, rejecting transform-based + * partitioning (MaxCompute supports identity partitions only). Mirrors the + * legacy {@code MaxComputeMetadataOps.validatePartitionDesc}. + */ + private List identityPartitionColumns( + ConnectorPartitionSpec partitionSpec) { + List names = new ArrayList<>(); + if (partitionSpec == null) { + return names; + } + for (ConnectorPartitionField field : partitionSpec.getFields()) { + if (!"identity".equalsIgnoreCase(field.getTransform())) { + throw new DorisConnectorException( + "MaxCompute does not support partition transform '" + + field.getTransform() + + "'. Only identity partitions are supported."); + } + names.add(field.getColumnName()); + } + return names; + } + + private TableSchema buildSchema(List columns, + List partitionColumns) { + Set partitionColLower = new HashSet<>(); + for (String name : partitionColumns) { + partitionColLower.add(name.toLowerCase()); + } + + TableSchema schema = new TableSchema(); + for (ConnectorColumn col : columns) { + if (!partitionColLower.contains(col.getName().toLowerCase())) { + schema.addColumn(new Column(col.getName(), + MCTypeMapping.toMcType(col.getType()), col.getComment())); + } + } + for (String partColName : partitionColumns) { + ConnectorColumn col = findColumnByName(columns, partColName); + if (col == null) { + throw new DorisConnectorException("Partition column '" + + partColName + "' not found in column definitions."); + } + schema.addPartitionColumn(new Column(col.getName(), + MCTypeMapping.toMcType(col.getType()), col.getComment())); + } + return schema; + } + + private ConnectorColumn findColumnByName(List columns, + String name) { + for (ConnectorColumn col : columns) { + if (col.getName().equalsIgnoreCase(name)) { + return col; + } + } + return null; + } + + private Long extractLifecycle(Map properties) { + String lifecycleStr = properties.get("mc.lifecycle"); + if (lifecycleStr == null) { + lifecycleStr = properties.get("lifecycle"); + } + if (lifecycleStr == null) { + return null; + } + try { + long lifecycle = Long.parseLong(lifecycleStr); + if (lifecycle <= 0 || lifecycle > MAX_LIFECYCLE_DAYS) { + throw new DorisConnectorException("Invalid lifecycle value: " + + lifecycle + ". Must be between 1 and " + + MAX_LIFECYCLE_DAYS + "."); + } + return lifecycle; + } catch (NumberFormatException e) { + throw new DorisConnectorException("Invalid lifecycle value: '" + + lifecycleStr + "'. Must be a positive integer."); + } + } + + private Map extractMaxComputeProperties( + Map properties) { + Map mcProperties = new HashMap<>(); + for (Map.Entry entry : properties.entrySet()) { + if (entry.getKey().startsWith("mc.tblproperty.")) { + mcProperties.put( + entry.getKey().substring("mc.tblproperty.".length()), + entry.getValue()); + } + } + return mcProperties; + } + + private Integer extractBucketNum(ConnectorBucketSpec bucketSpec) { + if (bucketSpec == null) { + return null; + } + if (!"doris_default".equals(bucketSpec.getAlgorithm())) { + throw new DorisConnectorException( + "MaxCompute only supports hash distribution. Got: " + + bucketSpec.getAlgorithm()); + } + int bucketNum = bucketSpec.getNumBuckets(); + if (bucketNum <= 0 || bucketNum > MAX_BUCKET_NUM) { + throw new DorisConnectorException("Invalid bucket number: " + + bucketNum + ". Must be between 1 and " + MAX_BUCKET_NUM + "."); + } + return bucketNum; + } } 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 f6593b9f30a7c0..07affd6a03427d 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 @@ -21,6 +21,8 @@ import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorProvider; +import java.util.Arrays; +import java.util.List; import java.util.Map; /** @@ -28,6 +30,12 @@ */ public class MaxComputeConnectorProvider implements ConnectorProvider { + private static final List REQUIRED_PROPERTIES = Arrays.asList( + MCConnectorProperties.PROJECT, + MCConnectorProperties.ENDPOINT); + + private static final long MIN_SPLIT_BYTE_SIZE = 10485760L; + @Override public String getType() { return "max_compute"; @@ -38,4 +46,105 @@ public Connector create(Map properties, ConnectorContext context) { return new MaxComputeDorisConnector(properties, context); } + + /** + * Validates catalog properties at CREATE CATALOG time, mirroring the fail-fast + * checks of the legacy {@code MaxComputeExternalCatalog.checkProperties}: required + * PROJECT/ENDPOINT, split strategy + size floor, account_format enum, positive + * connect/read timeout and retry count, and authentication completeness. Throws + * {@link IllegalArgumentException}, which the caller + * ({@code PluginDrivenExternalCatalog.checkProperties}) wraps into a DdlException. + */ + @Override + public void validateProperties(Map properties) { + // 1. Required properties: PROJECT + ENDPOINT (literal keys, mirroring legacy + // REQUIRED_PROPERTIES; region/odps_endpoint/tunnel_endpoint are replay-only + // backward-compat fallbacks, not valid for a new CREATE). + for (String required : REQUIRED_PROPERTIES) { + if (!properties.containsKey(required)) { + throw new IllegalArgumentException( + "Required property '" + required + "' is missing"); + } + } + + // 2. Split strategy and size/count floor. + String splitStrategy = properties.getOrDefault( + MCConnectorProperties.SPLIT_STRATEGY, + MCConnectorProperties.DEFAULT_SPLIT_STRATEGY); + try { + if (splitStrategy.equals( + MCConnectorProperties.SPLIT_BY_BYTE_SIZE_STRATEGY)) { + long splitByteSize = Long.parseLong(properties.getOrDefault( + MCConnectorProperties.SPLIT_BYTE_SIZE, + MCConnectorProperties.DEFAULT_SPLIT_BYTE_SIZE)); + if (splitByteSize < MIN_SPLIT_BYTE_SIZE) { + throw new IllegalArgumentException( + MCConnectorProperties.SPLIT_BYTE_SIZE + + " must be greater than or equal to " + + MIN_SPLIT_BYTE_SIZE); + } + } else if (splitStrategy.equals( + MCConnectorProperties.SPLIT_BY_ROW_COUNT_STRATEGY)) { + long splitRowCount = Long.parseLong(properties.getOrDefault( + MCConnectorProperties.SPLIT_ROW_COUNT, + MCConnectorProperties.DEFAULT_SPLIT_ROW_COUNT)); + if (splitRowCount <= 0) { + throw new IllegalArgumentException( + MCConnectorProperties.SPLIT_ROW_COUNT + + " must be greater than 0"); + } + } else { + throw new IllegalArgumentException( + "property " + MCConnectorProperties.SPLIT_STRATEGY + + " must be " + + MCConnectorProperties.SPLIT_BY_BYTE_SIZE_STRATEGY + + " or " + + MCConnectorProperties.SPLIT_BY_ROW_COUNT_STRATEGY); + } + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "property " + MCConnectorProperties.SPLIT_BYTE_SIZE + "/" + + MCConnectorProperties.SPLIT_ROW_COUNT + + " must be an integer"); + } + + // 3. Account format enum: name | id. + String accountFormat = properties.getOrDefault( + MCConnectorProperties.ACCOUNT_FORMAT, + MCConnectorProperties.DEFAULT_ACCOUNT_FORMAT); + if (!accountFormat.equals(MCConnectorProperties.ACCOUNT_FORMAT_NAME) + && !accountFormat.equals( + MCConnectorProperties.ACCOUNT_FORMAT_ID)) { + throw new IllegalArgumentException( + "property " + MCConnectorProperties.ACCOUNT_FORMAT + + " only support name and id"); + } + + // 4. Positive connect/read timeout and retry count. + checkPositiveInt(properties, MCConnectorProperties.CONNECT_TIMEOUT, + MCConnectorProperties.DEFAULT_CONNECT_TIMEOUT); + checkPositiveInt(properties, MCConnectorProperties.READ_TIMEOUT, + MCConnectorProperties.DEFAULT_READ_TIMEOUT); + checkPositiveInt(properties, MCConnectorProperties.RETRY_COUNT, + MCConnectorProperties.DEFAULT_RETRY_COUNT); + + // 5. Authentication completeness (wires the otherwise-unused + // MCConnectorClientFactory.checkAuthProperties). + MCConnectorClientFactory.checkAuthProperties(properties); + } + + private static void checkPositiveInt(Map properties, + String key, String defaultValue) { + int value; + try { + value = Integer.parseInt(properties.getOrDefault(key, defaultValue)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "property " + key + " must be an integer"); + } + if (value <= 0) { + throw new IllegalArgumentException( + key + " must be greater than 0"); + } + } } diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorTransaction.java b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorTransaction.java new file mode 100644 index 00000000000000..0d2f1c3def4dc7 --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorTransaction.java @@ -0,0 +1,236 @@ +// 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.maxcompute; + +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.thrift.TMCCommitData; + +import com.aliyun.odps.table.TableIdentifier; +import com.aliyun.odps.table.enviroment.EnvironmentSettings; +import com.aliyun.odps.table.write.TableBatchWriteSession; +import com.aliyun.odps.table.write.TableWriteSessionBuilder; +import com.aliyun.odps.table.write.WriterCommitMessage; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.thrift.TDeserializer; +import org.apache.thrift.TException; +import org.apache.thrift.protocol.TBinaryProtocol; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +/** + * MaxCompute connector transaction (ports the legacy + * {@code org.apache.doris.datasource.maxcompute.MCTransaction} write lifecycle + * to the connector SPI). + * + *

    Holds the per-statement write state: accumulated commit fragments + * ({@link TMCCommitData}, fed back from BE via {@link #addCommitData}), the + * block-id high-water mark, and — once the write plan (P4-T04) creates the ODPS + * write session — the session id / target identifier / environment settings used + * by {@link #commit()}.

    + * + *

    Gate-closed / dormant. Nothing routes plugin-driven MaxCompute writes + * through this class until the {@code max_compute} cutover: the executor wiring + * ({@code beginTransaction} → {@code PluginDrivenTransactionManager.begin}) + * and {@code GlobalExternalTransactionInfoMgr} registration are deferred to that + * step. {@link #commit()} depends on the write-session state populated by P4-T04 + * (via {@link #setWriteSession}); it is intentionally not runnable before then.

    + */ +public class MaxComputeConnectorTransaction implements ConnectorTransaction { + + private static final Logger LOG = LogManager.getLogger( + MaxComputeConnectorTransaction.class); + + /** + * Legacy default of {@code Config.max_compute_write_max_block_count} (20000); used as the + * fallback when the session does not carry the (tunable) value. The connector cannot import + * fe-core {@code Config}, so the live value is threaded in through the constructor — resolved + * from {@link org.apache.doris.connector.api.ConnectorSession#getSessionProperties()} by + * {@code MaxComputeConnectorMetadata.resolveMaxBlockCount} (GC1 / FIX-BLOCKID-CAP-CONFIG, + * restoring legacy fe.conf tunability and superseding the hardcoded cap in DV-011). + */ + static final long DEFAULT_MAX_BLOCK_COUNT = 20000L; + + private final long transactionId; + /** Upper bound on allocatable block ids; = Config.max_compute_write_max_block_count (per session). */ + private final long maxBlockCount; + private final List commitDataList = new ArrayList<>(); + private final AtomicLong nextBlockId = new AtomicLong(0); + + // Write-session state, populated by the write plan (P4-T04) before commit. + private volatile String writeSessionId; + private volatile TableIdentifier tableIdentifier; + private volatile EnvironmentSettings settings; + + public MaxComputeConnectorTransaction(long transactionId, long maxBlockCount) { + this.transactionId = transactionId; + this.maxBlockCount = maxBlockCount; + } + + /** + * Binds the ODPS write session created by the write plan (P4-T04) so that + * block allocation and {@link #commit()} can act on it. Resets the block-id + * high-water mark to the start of the new session. + */ + public void setWriteSession(String writeSessionId, TableIdentifier tableIdentifier, + EnvironmentSettings settings) { + this.writeSessionId = writeSessionId; + this.tableIdentifier = tableIdentifier; + this.settings = settings; + this.nextBlockId.set(0); + } + + public String getWriteSessionId() { + return writeSessionId; + } + + @Override + public long getTransactionId() { + return transactionId; + } + + @Override + public void addCommitData(byte[] commitFragment) { + TMCCommitData data = new TMCCommitData(); + try { + new TDeserializer(new TBinaryProtocol.Factory()).deserialize(data, commitFragment); + } catch (TException e) { + throw new DorisConnectorException("failed to deserialize MaxCompute commit data", e); + } + synchronized (this) { + commitDataList.add(data); + } + } + + @Override + public boolean supportsWriteBlockAllocation() { + return true; + } + + @Override + public long allocateWriteBlockRange(String requestWriteSessionId, long count) { + if (count <= 0) { + throw new DorisConnectorException( + "MaxCompute block_id allocation length must be positive: " + count); + } + if (writeSessionId == null || writeSessionId.isEmpty()) { + throw new DorisConnectorException("MaxCompute write session has not been initialized"); + } + if (!writeSessionId.equals(requestWriteSessionId)) { + throw new DorisConnectorException("MaxCompute write session mismatch, expected=" + + writeSessionId + ", actual=" + requestWriteSessionId); + } + + long start; + long endExclusive; + do { + start = nextBlockId.get(); + endExclusive = start + count; + if (endExclusive > maxBlockCount) { + throw new DorisConnectorException("MaxCompute block_id exceeds limit, start=" + + start + ", length=" + count + ", maxBlockCount=" + maxBlockCount); + } + } while (!nextBlockId.compareAndSet(start, endExclusive)); + + LOG.info("Allocated MaxCompute block_id range: sessionId={}, start={}, length={}", + writeSessionId, start, count); + return start; + } + + @Override + public long getUpdateCnt() { + return commitDataList.stream().mapToLong(TMCCommitData::getRowCount).sum(); + } + + @Override + public String profileLabel() { + return "MAXCOMPUTE"; + } + + @Override + public void commit() { + try { + List allMessages = new ArrayList<>(); + synchronized (this) { + for (TMCCommitData data : commitDataList) { + if (data.isSetCommitMessage() && !data.getCommitMessage().isEmpty()) { + appendCommitMessages(allMessages, data.getCommitMessage()); + } + } + } + + TableBatchWriteSession commitSession = new TableWriteSessionBuilder() + .identifier(tableIdentifier) + .withSessionId(writeSessionId) + .withSettings(settings) + .buildBatchWriteSession(); + commitSession.commit(allMessages.toArray(new WriterCommitMessage[0])); + + LOG.info("Committed MaxCompute write session {} with {} messages", + writeSessionId, allMessages.size()); + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to commit MaxCompute write session: " + e.getMessage(), e); + } + } + + @Override + public void rollback() { + // MaxCompute write sessions auto-expire if not committed; no explicit rollback needed. + LOG.info("MaxCompute transaction {} rollback called; uncommitted sessions will auto-expire.", + transactionId); + } + + @Override + public void close() { + // No resources to release: the ODPS write session auto-expires if not committed. + } + + private void appendCommitMessages(List allMessages, String encodedCommitMessage) + throws IOException, ClassNotFoundException { + byte[] bytes = Base64.getDecoder().decode(encodedCommitMessage); + Object payload; + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) { + payload = ois.readObject(); + } + + if (payload instanceof WriterCommitMessage) { + allMessages.add((WriterCommitMessage) payload); + return; + } + if (payload instanceof List) { + for (Object item : (List) payload) { + if (!(item instanceof WriterCommitMessage)) { + throw new DorisConnectorException("Unexpected MaxCompute commit payload item type: " + + (item == null ? "null" : item.getClass().getName())); + } + allMessages.add((WriterCommitMessage) item); + } + return; + } + throw new DorisConnectorException("Unexpected MaxCompute commit payload type: " + + (payload == null ? "null" : payload.getClass().getName())); + } +} diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeDorisConnector.java b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeDorisConnector.java index f7ae12ec396f6b..b435a88d0423d4 100644 --- a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeDorisConnector.java +++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeDorisConnector.java @@ -22,22 +22,30 @@ import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.ConnectorTestResult; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; import org.apache.doris.connector.spi.ConnectorContext; import com.aliyun.odps.Odps; +import com.aliyun.odps.OdpsException; import com.aliyun.odps.account.AccountFormat; +import com.aliyun.odps.table.configuration.RestOptions; +import com.aliyun.odps.table.enviroment.Credentials; +import com.aliyun.odps.table.enviroment.EnvironmentSettings; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.io.IOException; +import java.util.List; import java.util.Map; /** * Main Connector implementation for MaxCompute (ODPS). * Manages the Odps client lifecycle and provides metadata access. * - *

    Note: EnvironmentSettings and SplitOptions (from odps-sdk-table-api) - * are managed by {@link MaxComputeScanPlanProvider} which handles scan planning. + *

    Note: the shared ODPS {@link EnvironmentSettings} (from odps-sdk-table-api) + * is built here and consumed by both {@link MaxComputeScanPlanProvider} and + * {@link MaxComputeWritePlanProvider}; SplitOptions remains scan-specific and + * stays in the scan plan provider. */ public class MaxComputeDorisConnector implements Connector { private static final Logger LOG = LogManager.getLogger( @@ -46,12 +54,21 @@ public class MaxComputeDorisConnector implements Connector { private final Map properties; private final ConnectorContext context; + // Connector-owned partition-listing cache, shared by the (per-call) metadata's three partition-listing + // methods. One per connector — the metadata is rebuilt per query, so the cache must live on the long-lived + // connector to survive across queries. Its loader captures this connector and reads structureHelper/odps + // lazily at query time (always post-init, since getMetadata calls ensureInitialized before use). + private final MaxComputePartitionCache partitionCache; + private Odps odps; private String endpoint; private String defaultProject; + private boolean enableNamespaceSchema; private String quota; private McStructureHelper structureHelper; private MaxComputeScanPlanProvider scanPlanProvider; + private MaxComputeWritePlanProvider writePlanProvider; + private EnvironmentSettings settings; private volatile boolean initialized; @@ -59,6 +76,8 @@ public MaxComputeDorisConnector(Map properties, ConnectorContext context) { this.properties = properties; this.context = context; + this.partitionCache = new MaxComputePartitionCache(properties, + (db, t) -> structureHelper.getPartitions(odps, db, t)); } private void ensureInitialized() { @@ -96,21 +115,104 @@ private void doInit() { } odps.setAccountFormat(accountFormat); - boolean enableNamespaceSchema = Boolean.parseBoolean( + enableNamespaceSchema = Boolean.parseBoolean( properties.getOrDefault( MCConnectorProperties.ENABLE_NAMESPACE_SCHEMA, MCConnectorProperties .DEFAULT_ENABLE_NAMESPACE_SCHEMA)); structureHelper = McStructureHelper.getHelper( enableNamespaceSchema, defaultProject); + settings = buildSettings(); scanPlanProvider = new MaxComputeScanPlanProvider(this); + writePlanProvider = new MaxComputeWritePlanProvider(this); + } + + /** + * Builds the shared ODPS {@link EnvironmentSettings} (credentials, endpoint, + * quota, REST timeouts). Mirrors the legacy {@code MaxComputeExternalCatalog} + * which holds a single {@code settings} used by both the scan path + * ({@code MaxComputeScanNode}) and the write path ({@code MCTransaction}); + * the connector likewise shares one instance across + * {@link MaxComputeScanPlanProvider} and {@link MaxComputeWritePlanProvider}. + */ + private EnvironmentSettings buildSettings() { + int connectTimeout = Integer.parseInt(properties.getOrDefault( + MCConnectorProperties.CONNECT_TIMEOUT, + MCConnectorProperties.DEFAULT_CONNECT_TIMEOUT)); + int readTimeout = Integer.parseInt(properties.getOrDefault( + MCConnectorProperties.READ_TIMEOUT, + MCConnectorProperties.DEFAULT_READ_TIMEOUT)); + int retryTimes = Integer.parseInt(properties.getOrDefault( + MCConnectorProperties.RETRY_COUNT, + MCConnectorProperties.DEFAULT_RETRY_COUNT)); + + // Apply the same timeouts to the raw ODPS client: metadata / project / schema / DDL and the + // CREATE-time connectivity test (testConnection) go through odps.getRestClient(), not the + // Storage API. Mirrors legacy MaxComputeExternalCatalog.initLocalObjectsImpl; the RestOptions + // below cover only the Storage API EnvironmentSettings used by the scan/write paths. + odps.getRestClient().setConnectTimeout(connectTimeout); + odps.getRestClient().setReadTimeout(readTimeout); + odps.getRestClient().setRetryTimes(retryTimes); + + RestOptions restOptions = RestOptions.newBuilder() + .withConnectTimeout(connectTimeout) + .withReadTimeout(readTimeout) + .withRetryTimes(retryTimes) + .build(); + + Credentials credentials = Credentials.newBuilder() + .withAccount(odps.getAccount()) + .withAppAccount(odps.getAppAccount()) + .build(); + + return EnvironmentSettings.newBuilder() + .withCredentials(credentials) + .withServiceEndpoint(odps.getEndpoint()) + .withQuotaName(quota) + .withRestOptions(restOptions) + .build(); } @Override public ConnectorMetadata getMetadata(ConnectorSession session) { ensureInitialized(); return new MaxComputeConnectorMetadata( - odps, structureHelper, defaultProject); + odps, structureHelper, defaultProject, endpoint, quota, properties, partitionCache); + } + + /** + * REFRESH TABLE hook: drops this table's connector-owned partition listing. fe-core routes + * {@code REFRESH TABLE} to {@code connector.invalidateTable} for a plugin-driven catalog. Mirrors + * {@code HiveConnector.invalidateTable}. + */ + @Override + public void invalidateTable(String dbName, String tableName) { + partitionCache.invalidateTable(dbName, tableName); + } + + /** + * REFRESH DATABASE hook: drops the connector-owned partition listings for every table in one database. + * Mirrors {@code HiveConnector.invalidateDb}. + */ + @Override + public void invalidateDb(String dbName) { + partitionCache.invalidateDb(dbName); + } + + /** REFRESH CATALOG hook: drops the whole connector-owned partition cache. Mirrors {@code HiveConnector}. */ + @Override + public void invalidateAll() { + partitionCache.invalidateAll(); + } + + /** + * Invalidates a table's partition cache on a partition add/drop/alter. The cache is keyed by {@code (db, + * table)} and cannot target a single partition name, so this degrades to a whole-table flush (correctness + * -safe: the cache re-lists on the next miss). Mirrors {@code HiveConnector.invalidatePartition}. + */ + @Override + public void invalidatePartition(String dbName, String tableName, List partitionNames) { + partitionCache.invalidateTable(dbName, tableName); } @Override @@ -119,19 +221,74 @@ public ConnectorScanPlanProvider getScanPlanProvider() { return scanPlanProvider; } + @Override + public ConnectorWritePlanProvider getWritePlanProvider() { + ensureInitialized(); + return writePlanProvider; + } + @Override public ConnectorTestResult testConnection(ConnectorSession session) { try { ensureInitialized(); - odps.projects().exists(defaultProject); + validateMaxComputeConnection(); return ConnectorTestResult.success( "MaxCompute project '" + defaultProject + "' is accessible"); } catch (Exception e) { - return ConnectorTestResult.failure( - "MaxCompute connection test failed: " + e.getMessage()); + return ConnectorTestResult.failure(e.getMessage()); } } + /** + * Validates FE→ODPS connectivity for CREATE CATALOG (test_connection=true), mirroring + * legacy {@code MaxComputeExternalCatalog.validateMaxComputeConnection}. When namespace schema + * is enabled the project is three-tier, so the schema list must be reachable; otherwise the + * project itself must exist and be accessible. + */ + protected void validateMaxComputeConnection() { + if (enableNamespaceSchema) { + validateMaxComputeProjectAndNamespaceSchema(); + } else { + validateMaxComputeProject(); + } + } + + private void validateMaxComputeProject() { + boolean projectExists; + try { + projectExists = maxComputeProjectExists(defaultProject); + } catch (Exception e) { + throw new RuntimeException("Failed to validate MaxCompute project '" + defaultProject + + "'. Check " + MCConnectorProperties.PROJECT + ", " + MCConnectorProperties.ENDPOINT + + " and credentials. Cause: " + e.getMessage(), e); + } + if (!projectExists) { + throw new RuntimeException("Failed to validate MaxCompute project '" + defaultProject + + "'. Check " + MCConnectorProperties.PROJECT + ", " + MCConnectorProperties.ENDPOINT + + " and credentials. Cause: project does not exist or is not accessible"); + } + } + + private void validateMaxComputeProjectAndNamespaceSchema() { + try { + validateMaxComputeNamespaceSchemaAccess(defaultProject); + } catch (Exception e) { + throw new RuntimeException("Failed to validate MaxCompute project '" + defaultProject + + "' with namespace schema. Check " + MCConnectorProperties.PROJECT + ", " + + MCConnectorProperties.ENDPOINT + + ", credentials, and whether the schema list is accessible for the namespace " + + "schema configuration. Cause: " + e.getMessage(), e); + } + } + + protected boolean maxComputeProjectExists(String projectName) throws OdpsException { + return odps.projects().exists(projectName); + } + + protected void validateMaxComputeNamespaceSchemaAccess(String projectName) throws OdpsException { + odps.schemas().iterator(projectName).hasNext(); + } + public Odps getClient() { ensureInitialized(); return odps; @@ -161,6 +318,15 @@ public McStructureHelper getStructureHelper() { return structureHelper; } + /** + * Returns the shared ODPS {@link EnvironmentSettings} used by both scan and + * write planning (see {@link #buildSettings()}). + */ + public EnvironmentSettings getSettings() { + ensureInitialized(); + return settings; + } + @Override public void close() throws IOException { LOG.info("Closing MaxCompute connector for project: {}", 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 new file mode 100644 index 00000000000000..759bbeffe4ee83 --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputePartitionCache.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.connector.maxcompute; + +import org.apache.doris.connector.cache.CacheSpec; +import org.apache.doris.connector.cache.MetaCacheEntry; + +import com.aliyun.odps.Partition; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ForkJoinPool; + +/** + * The MaxCompute connector's own partition-listing cache — a structural copy of the hive connector's + * {@code HiveFileListingCache}, backed by the shared + * {@code fe-connector-cache} framework ({@link CacheSpec} + {@link MetaCacheEntry}). It memoizes the (expensive) + * per-table ODPS partition listing ({@code structureHelper.getPartitions}), keyed by {@code (db, table)} — the + * ODPS project is constant per catalog, so it is NOT part of the key. + * + *

    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 + * {@link MaxComputeDorisConnector} (the only object that outlives a single query; the metadata is rebuilt per + * call), so a partition read warms the others. + * + *

    Read-only convention. The cached {@code List} is shared by reference. The consumers only + * read local partition accessors ({@code getPartitionSpec()}, {@code spec.keys()/get()}, {@code toString()}), + * never a per-partition lazy reload, so the shared list is safe as long as callers treat it as read-only (the + * codebase-wide metadata-cache convention). + * + *

    TCCL. The entry is contextual-only + manual-miss + no auto-refresh, so the loader runs synchronously + * on the CALLING thread — keeping TCCL/classloading byte-identical to today's uncached ODPS call (a background + * refresh thread would not inherit the caller's pin). Mirrors {@code CachingHmsClient} / {@code + * HiveFileListingCache}'s entry construction. + * + *

    Failures are not cached. The loader never caches a failed load (matching {@link MetaCacheEntry}'s + * null-is-a-miss / exception-propagates contract), so a transient ODPS failure does not poison the listing for + * the whole TTL. + */ +public class MaxComputePartitionCache { + + /** Engine token for the {@code meta.cache...*} property namespace. */ + static final String ENGINE = "max_compute"; + /** {@code meta.cache.max_compute.partition.*} — cached partition listings. */ + static final String ENTRY_PARTITION = "partition"; + + // Legacy MaxCompute partition-cache Config values, mirrored locally (the connector never touches fe-core + // Config). These are the knobs the DELETED MaxComputeExternalMetaCache.partitionValuesEntry actually used — + // TTL = Config.external_cache_refresh_time_minutes * 60 (default 10 min * 60 = 600s) + // capacity = Config.max_hive_partition_table_cache_num (default 10000) + // NOT the hive file-listing cache's knobs (external_cache_expire_time_seconds_after_access = 24h / + // max_external_file_cache_num): a MaxCompute table's partition set must re-list ~10 min after last access, + // matching legacy, so a partition added directly in ODPS becomes visible without an explicit REFRESH. + static final long DEFAULT_TTL_SECOND = 600L; + static final long DEFAULT_PARTITION_CAPACITY = 10000L; + + /** + * The raw partition lister: {@code structureHelper.getPartitions(odps, db, table)} in production, a fake in + * unit tests. Injected so the cache's hit/miss/invalidation behaviour is testable without a live ODPS client + * (mirrors the hive connector's {@code HiveFileListingCache.DirectoryLister}). + */ + @FunctionalInterface + interface PartitionLister { + List list(String dbName, String tableName); + } + + private final MetaCacheEntry> cache; + private final PartitionLister lister; + + MaxComputePartitionCache(Map properties, PartitionLister lister) { + Map props = properties == null ? Collections.emptyMap() : properties; + CacheSpec spec = CacheSpec.fromProperties(props, ENGINE, ENTRY_PARTITION, + CacheSpec.of(true, DEFAULT_TTL_SECOND, DEFAULT_PARTITION_CAPACITY)); + // Contextual-only + manual-miss so the slow getPartitions runs on the caller (TCCL-pinned) thread outside + // Caffeine's sync compute lock, deduplicated by a striped lock — mirrors CachingHmsClient's entries. + this.cache = new MetaCacheEntry<>("max_compute.partition", null, spec, ForkJoinPool.commonPool(), + false, true, 0L, true); + this.lister = Objects.requireNonNull(lister, "lister can not be null"); + } + + /** + * Returns all partitions of {@code (dbName, tableName)}, served from the cache. Keyed by {@code (db, table)} + * so {@link #invalidateTable} can drop exactly one table's entry. The loader runs on the calling thread; a + * failure propagates and is NOT cached. The returned list is shared by reference — callers must treat it as + * read-only (the codebase-wide metadata-cache convention). + */ + public List getPartitions(String dbName, String tableName) { + return cache.get(new PartitionKey(dbName, tableName), + key -> lister.list(key.dbName, key.tableName)); + } + + /** Drops the cached partition listing for one table. Backs {@code REFRESH TABLE}. */ + public void invalidateTable(String dbName, String tableName) { + cache.invalidateIf(key -> key.matches(dbName, tableName)); + } + + /** Drops every cached partition listing for one database (all its tables). Backs {@code REFRESH DATABASE}. */ + public void invalidateDb(String dbName) { + cache.invalidateIf(key -> key.matchesDb(dbName)); + } + + /** Drops the whole partition cache. Backs {@code REFRESH CATALOG}. */ + public void invalidateAll() { + cache.invalidateAll(); + } + + /** Current number of cached partition listings — for unit tests only (mirrors HiveFileListingCache.size()). */ + long size() { + long[] count = {0L}; + cache.forEach((key, value) -> count[0]++); + return count[0]; + } + + /** + * Cache key: (db, table). db+table let {@link #invalidateTable} select one table's entry; db alone lets + * {@link #invalidateDb} select one database's entries. + */ + static final class PartitionKey { + private final String dbName; + private final String tableName; + + PartitionKey(String dbName, String tableName) { + this.dbName = dbName; + this.tableName = tableName; + } + + boolean matches(String db, String table) { + return Objects.equals(dbName, db) && Objects.equals(tableName, table); + } + + boolean matchesDb(String db) { + return Objects.equals(dbName, db); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof PartitionKey)) { + return false; + } + PartitionKey that = (PartitionKey) o; + return Objects.equals(dbName, that.dbName) + && Objects.equals(tableName, that.tableName); + } + + @Override + public int hashCode() { + return Objects.hash(dbName, tableName); + } + } +} diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputePredicateConverter.java b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputePredicateConverter.java index 6e6c1911ab8392..1f52b12f333aaa 100644 --- a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputePredicateConverter.java +++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputePredicateConverter.java @@ -29,6 +29,7 @@ import com.aliyun.odps.OdpsType; import com.aliyun.odps.table.optimizer.predicate.Attribute; +import com.aliyun.odps.table.optimizer.predicate.BinaryPredicate; import com.aliyun.odps.table.optimizer.predicate.CompoundPredicate; import com.aliyun.odps.table.optimizer.predicate.Predicate; import com.aliyun.odps.table.optimizer.predicate.RawPredicate; @@ -38,7 +39,6 @@ import java.time.LocalDateTime; import java.time.ZoneId; -import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; @@ -56,21 +56,29 @@ public class MaxComputePredicateConverter { DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); static final DateTimeFormatter DATETIME_6_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"); + private static final ZoneId UTC = ZoneId.of("UTC"); private final Map columnTypeMap; private final boolean dateTimePushDown; - private final ZoneId sourceTimeZone; + private final String sourceTimeZoneId; /** * @param columnTypeMap mapping from column name to ODPS type * @param dateTimePushDown whether DATETIME/TIMESTAMP predicate push down is enabled - * @param sourceTimeZone the session time zone for datetime conversion + * @param sourceTimeZoneId the session time zone id (e.g. "Asia/Shanghai"), kept as the raw + * string and parsed lazily — only when a DATETIME/TIMESTAMP literal is actually + * converted, inside {@link #convert}'s catch. This matters because Doris accepts and + * stores some zone ids verbatim that {@link ZoneId#of(String)} rejects (e.g. "CST", + * which Doris maps to +08:00 via its own alias map); parsing eagerly would throw out of + * query planning, whereas lazy parsing degrades the predicate to + * {@link Predicate#NO_PREDICATE} — mirroring legacy {@code MaxComputeScanNode}'s + * per-conjunct catch (a non-datetime predicate under such a session still pushes down). */ public MaxComputePredicateConverter(Map columnTypeMap, - boolean dateTimePushDown, ZoneId sourceTimeZone) { + boolean dateTimePushDown, String sourceTimeZoneId) { this.columnTypeMap = columnTypeMap; this.dateTimePushDown = dateTimePushDown; - this.sourceTimeZone = sourceTimeZone; + this.sourceTimeZoneId = sourceTimeZoneId; } /** @@ -81,6 +89,30 @@ public Predicate convert(ConnectorExpression expr) { if (expr == null) { return Predicate.NO_PREDICATE; } + // Top-level conjunction: convert each conjunct independently and AND the survivors, so one + // unconvertible conjunct doesn't sink the whole filter. This is only safe at the root (a positive, + // monotone position): dropping a conjunct from the root AND yields a superset that BE re-filters. + // OR (dropping a disjunct is a subset -> loses rows), NOT, and nested AND are converted whole by + // convertOne(); partially dropping inside them could change the predicate's meaning. + if (expr instanceof ConnectorAnd) { + List survivors = new ArrayList<>(); + for (ConnectorExpression conjunct : ((ConnectorAnd) expr).getConjuncts()) { + Predicate p = convertOne(conjunct); + if (p != Predicate.NO_PREDICATE) { + survivors.add(p); + } + } + if (survivors.isEmpty()) { + return Predicate.NO_PREDICATE; + } + return survivors.size() == 1 + ? survivors.get(0) + : new CompoundPredicate(CompoundPredicate.Operator.AND, survivors); + } + return convertOne(expr); + } + + private Predicate convertOne(ConnectorExpression expr) { try { return doConvert(expr); } catch (Exception e) { @@ -133,30 +165,36 @@ private Predicate convertComparison(ConnectorComparison cmp) { String columnName = extractColumnName(cmp.getLeft()); String value = formatLiteralValue(columnName, cmp.getRight()); - String opDesc; + // Source the operator symbol from the ODPS SDK's own BinaryPredicate.Operator description + // rather than hand-writing it, mirroring legacy MaxComputeScanNode.convertExprToOdpsPredicate. + // The SDK is the authority for what ODPS accepts (EQUALS -> "="); a hand-written table let the + // EQ entry drift to Java's "==", which MaxCompute (like SQL) does not accept -> pushdown lost. + // EQ_FOR_NULL ("<=>") has no ODPS BinaryPredicate equivalent, so it (and any future operator) + // falls through to default -> throw -> NO_PREDICATE (BE re-filters), matching legacy's skip. + BinaryPredicate.Operator odpsOp; switch (cmp.getOperator()) { case EQ: - opDesc = "=="; + odpsOp = BinaryPredicate.Operator.EQUALS; break; case NE: - opDesc = "!="; + odpsOp = BinaryPredicate.Operator.NOT_EQUALS; break; case LT: - opDesc = "<"; + odpsOp = BinaryPredicate.Operator.LESS_THAN; break; case LE: - opDesc = "<="; + odpsOp = BinaryPredicate.Operator.LESS_THAN_OR_EQUAL; break; case GT: - opDesc = ">"; + odpsOp = BinaryPredicate.Operator.GREATER_THAN; break; case GE: - opDesc = ">="; + odpsOp = BinaryPredicate.Operator.GREATER_THAN_OR_EQUAL; break; default: throw new UnsupportedOperationException("Unsupported operator: " + cmp.getOperator()); } - return new RawPredicate(columnName + " " + opDesc + " " + value); + return new RawPredicate(columnName + " " + odpsOp.getDescription() + " " + value); } private Predicate convertIn(ConnectorIn in) { @@ -202,7 +240,12 @@ private String formatLiteralValue(String columnName, ConnectorExpression expr) { OdpsType odpsType = columnTypeMap.get(columnName); if (odpsType == null) { - return " \"" + rawValue + "\" "; + // Column not in the table schema: mirror legacy MaxComputeScanNode's + // containsKey guard (throw AnalysisException -> caller drops the predicate). + // Throwing here degrades the filter to NO_PREDICATE via convert()'s catch, + // so we never push down a malformed predicate on an unknown column. + throw new UnsupportedOperationException( + "Cannot push down predicate on unknown column: " + columnName); } switch (odpsType) { @@ -226,21 +269,24 @@ private String formatLiteralValue(String columnName, ConnectorExpression expr) { case DATETIME: if (dateTimePushDown) { - return " \"" + convertDateTimezone( - rawValue, DATETIME_3_FORMATTER, ZoneId.of("UTC")) + "\" "; + return " \"" + formatDateTimeLiteral( + literal.getValue(), DATETIME_3_FORMATTER, true) + "\" "; } break; case TIMESTAMP: if (dateTimePushDown) { - return " \"" + convertDateTimezone( - rawValue, DATETIME_6_FORMATTER, ZoneId.of("UTC")) + "\" "; + return " \"" + formatDateTimeLiteral( + literal.getValue(), DATETIME_6_FORMATTER, true) + "\" "; } break; case TIMESTAMP_NTZ: if (dateTimePushDown) { - return " \"" + rawValue + "\" "; + // TIMESTAMP_NTZ carries no timezone: mirror legacy + // MaxComputeScanNode:585-592 (getStringValue with NO convertDateTimezone). + return " \"" + formatDateTimeLiteral( + literal.getValue(), DATETIME_6_FORMATTER, false) + "\" "; } break; @@ -251,14 +297,45 @@ private String formatLiteralValue(String columnName, ConnectorExpression expr) { "Cannot push down ODPS type: " + odpsType + " for column " + columnName); } - private String convertDateTimezone(String dateTimeStr, - DateTimeFormatter formatter, ZoneId toZone) { - if (sourceTimeZone.equals(toZone)) { - return dateTimeStr; + /** + * Formats a DATETIME/TIMESTAMP/TIMESTAMP_NTZ literal into the ODPS predicate string. + * + *

    The {@code value} is the {@link LocalDateTime} produced by fe-core's + * {@code ExprToConnectorExpressionConverter.convertDateLiteral} (already at the bound + * predicate's scale, with nanos = microsecond * 1000). It is formatted directly with + * {@code formatter} (space-separated, fixed precision: DATETIME {@code .SSS}, + * TIMESTAMP/TIMESTAMP_NTZ {@code .SSSSSS}), reproducing legacy + * {@code MaxComputeScanNode.convertLiteralToOdpsValues}'s + * {@code DateLiteral.getStringValue(DatetimeV2Type(3|6))}.

    + * + *

    Formatting the {@code LocalDateTime} directly avoids the previous defect where + * {@code String.valueOf(value)} emitted {@link LocalDateTime#toString()}'s 'T'-separated, + * variable-precision form (e.g. {@code "2023-02-02T00:00"}) — which the space-separated + * formatter could not parse (whole predicate tree dropped to {@code NO_PREDICATE}) or, on + * the UTC short-circuit, was pushed malformed to ODPS.

    + * + * @param convertTimeZone {@code true} for DATETIME/TIMESTAMP (legacy converts the session + * {@code sourceTimeZone} to UTC, short-circuiting when already UTC); {@code false} + * for TIMESTAMP_NTZ (legacy does not convert) + */ + private String formatDateTimeLiteral(Object value, DateTimeFormatter formatter, + boolean convertTimeZone) { + if (!(value instanceof LocalDateTime)) { + throw new UnsupportedOperationException( + "Expected LocalDateTime for datetime predicate, got: " + + (value == null ? "null" : value.getClass().getSimpleName())); + } + LocalDateTime localDateTime = (LocalDateTime) value; + if (convertTimeZone) { + // Parse the session zone here (inside convert()'s catch) rather than eagerly at + // construction: a Doris-valid-but-ZoneId-invalid id (e.g. "CST") then degrades this + // predicate to NO_PREDICATE instead of throwing out of query planning. + ZoneId sourceTimeZone = ZoneId.of(sourceTimeZoneId); + if (!sourceTimeZone.equals(UTC)) { + localDateTime = localDateTime.atZone(sourceTimeZone) + .withZoneSameInstant(UTC).toLocalDateTime(); + } } - LocalDateTime localDateTime = LocalDateTime.parse(dateTimeStr, formatter); - ZonedDateTime sourceZoned = localDateTime.atZone(sourceTimeZone); - ZonedDateTime targetZoned = sourceZoned.withZoneSameInstant(toZone); - return targetZoned.format(formatter); + return localDateTime.format(formatter); } } 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 e3c65f934782c2..6cf9fb69a5bad7 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 @@ -20,7 +20,12 @@ 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.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorIn; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.api.scan.ConnectorScanRange; @@ -30,9 +35,7 @@ import com.aliyun.odps.table.TableIdentifier; import com.aliyun.odps.table.configuration.ArrowOptions; import com.aliyun.odps.table.configuration.ArrowOptions.TimestampUnit; -import com.aliyun.odps.table.configuration.RestOptions; import com.aliyun.odps.table.configuration.SplitOptions; -import com.aliyun.odps.table.enviroment.Credentials; import com.aliyun.odps.table.enviroment.EnvironmentSettings; import com.aliyun.odps.table.optimizer.predicate.Predicate; import com.aliyun.odps.table.read.TableBatchReadSession; @@ -46,7 +49,6 @@ import java.io.IOException; import java.io.ObjectOutputStream; import java.io.Serializable; -import java.time.ZoneId; import java.util.ArrayList; import java.util.Base64; import java.util.Collections; @@ -71,6 +73,16 @@ public class MaxComputeScanPlanProvider implements ConnectorScanPlanProvider { private static final Logger LOG = LogManager.getLogger(MaxComputeScanPlanProvider.class); + /** + * FE session variable name gating the LIMIT-split optimization (default OFF). Hardcoded + * here because the connector must not depend on fe-core's {@code SessionVariable} constant; + * it is read from {@link ConnectorSession#getSessionProperties()} (same pattern the JDBC + * connector uses for its session vars). Must stay byte-identical to + * {@code SessionVariable.ENABLE_MC_LIMIT_SPLIT_OPTIMIZATION}. + */ + private static final String ENABLE_MC_LIMIT_SPLIT_OPTIMIZATION = + "enable_mc_limit_split_optimization"; + private final MaxComputeDorisConnector connector; // These are initialized lazily from connector properties @@ -143,23 +155,9 @@ private void initFromProperties() { .build(); } - RestOptions restOptions = RestOptions.newBuilder() - .withConnectTimeout(connectTimeout) - .withReadTimeout(readTimeout) - .withRetryTimes(retryTimes) - .build(); - - Credentials credentials = Credentials.newBuilder() - .withAccount(connector.getClient().getAccount()) - .withAppAccount(connector.getClient().getAppAccount()) - .build(); - - settings = EnvironmentSettings.newBuilder() - .withCredentials(credentials) - .withServiceEndpoint(connector.getClient().getEndpoint()) - .withQuotaName(connector.getQuota()) - .withRestOptions(restOptions) - .build(); + // EnvironmentSettings is built once on the connector and shared by both + // the scan and write plan providers (mirrors legacy catalog.getSettings()). + settings = connector.getSettings(); } @Override @@ -173,10 +171,21 @@ public List planScan(ConnectorSession session, 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) { ensureInitialized(); MaxComputeTableHandle mcHandle = (MaxComputeTableHandle) handle; Table odpsTable = mcHandle.getOdpsTable(); + // Reject external tables / logical views before any read planning (mirrors legacy + // MaxComputeScanNode.getSplits): the ODPS Storage API cannot scan them. + mcHandle.checkOperationSupported("Reading"); + if (odpsTable.getFileNum() <= 0 || columns.isEmpty()) { return Collections.emptyList(); } @@ -197,31 +206,76 @@ public List planScan(ConnectorSession session, } // Convert filter to ODPS predicate - Predicate filterPredicate = convertFilter(filter, odpsTable); - - // Check limit optimization eligibility - boolean onlyPartitionEquality = filter.isPresent() - && checkOnlyPartitionEquality(filter.get(), partitionColumnNames); - boolean useLimitOpt = limit > 0 && (onlyPartitionEquality || !filter.isPresent()); + Predicate filterPredicate = convertFilter(filter, odpsTable, session); + + // Partition pruning: restrict the read session to the pruned partitions when present. + // null/empty => not pruned => scan all (mirrors legacy MaxComputeScanNode's empty + // requiredPartitionSpecs). The "pruned to zero" case is short-circuited upstream in + // PluginDrivenScanNode.getSplits, so it never reaches here. + List requiredPartitionSpecs = toPartitionSpecs(requiredPartitions); + + // Check limit optimization eligibility. Mirrors legacy MaxComputeScanNode's three-gate + // (sessionVariable.enableMcLimitSplitOptimization && onlyPartitionEqualityPredicate + // && hasLimit()), default OFF: the optimization fires only when the user enabled the + // session var AND (there is no filter OR every conjunct is partition-column equality). + boolean limitOptEnabled = isLimitOptEnabled(session.getSessionProperties()); + boolean useLimitOpt = shouldUseLimitOptimization( + limitOptEnabled, limit, filter, partitionColumnNames); try { if (useLimitOpt) { return planScanWithLimitOptimization(mcHandle.getTableIdentifier(), requiredPartitionCols, requiredDataCols, - filterPredicate, limit, odpsTable); + filterPredicate, limit, requiredPartitionSpecs, odpsTable); } TableBatchReadSession readSession = createReadSession( mcHandle.getTableIdentifier(), requiredPartitionCols, requiredDataCols, - filterPredicate, Collections.emptyList(), splitOptions); + filterPredicate, requiredPartitionSpecs, splitOptions); return buildSplitsFromSession(readSession, odpsTable); } catch (IOException e) { throw new RuntimeException("Failed to create MaxCompute read session", e); } } - private Predicate convertFilter(Optional filter, Table odpsTable) { + /** + * Mirrors legacy {@code MaxComputeScanNode.isBatchMode()}'s {@code odpsTable.getFileNum() > 0} + * gate. The partition-count / non-empty-slots / session-var gates live in the generic scan + * node ({@code PluginDrivenScanNode.isBatchMode}); this method only answers the + * connector-specific "does this table have files to read in batches" question. + * + *

    {@code planScanForPartitionBatch} is intentionally NOT overridden: the SPI default + * delegates to the 6-arg {@link #planScan}, which already builds one read session over the + * given partition subset — exactly the per-batch behaviour legacy {@code startSplit} got from + * {@code createTableBatchReadSession}.

    + */ + @Override + public boolean supportsBatchScan(ConnectorSession session, ConnectorTableHandle handle) { + return ((MaxComputeTableHandle) handle).getOdpsTable().getFileNum() > 0; + } + + /** + * Converts pruned partition spec strings (the keys of the Nereids selected-partition map, + * e.g. {@code "pt=1,region=cn"}) into ODPS {@link com.aliyun.odps.PartitionSpec}s. + * Mirrors legacy {@code MaxComputeScanNode}'s {@code new PartitionSpec(key)} conversion. + * + *

    {@code null} or empty input returns an empty list, which the ODPS read session + * builder treats as "read all partitions" — preserving the pre-pruning behavior.

    + */ + static List toPartitionSpecs(List requiredPartitions) { + if (requiredPartitions == null || requiredPartitions.isEmpty()) { + return Collections.emptyList(); + } + List specs = new ArrayList<>(requiredPartitions.size()); + for (String name : requiredPartitions) { + specs.add(new com.aliyun.odps.PartitionSpec(name)); + } + return specs; + } + + private Predicate convertFilter(Optional filter, Table odpsTable, + ConnectorSession session) { if (!filter.isPresent()) { return Predicate.NO_PREDICATE; } @@ -234,16 +288,19 @@ private Predicate convertFilter(Optional filter, Table odps columnTypeMap.put(col.getName(), col.getType()); } - ZoneId sourceZone = resolveProjectTimeZone(); + // Source time zone = the session time zone, mirroring legacy + // MaxComputeScanNode.convertDateTimezone's DateUtils.getTimeZone() (= the session var). + // ConnectorSession.getTimeZone() is populated from ctx.getSessionVariable().getTimeZone() + // by ConnectorSessionBuilder.from(ctx), so this is the same source as legacy. (The earlier + // project-region TZ from the endpoint was wrong: Doris interprets datetime literals in the + // session TZ, so converting from any other zone shifts the pushed-down UTC literal.) The id + // is passed raw and parsed lazily inside the converter, so a Doris-valid-but-ZoneId-invalid + // value (e.g. "CST") degrades the datetime predicate instead of failing the query. MaxComputePredicateConverter converter = new MaxComputePredicateConverter( - columnTypeMap, dateTimePushDown, sourceZone); + columnTypeMap, dateTimePushDown, session.getTimeZone()); return converter.convert(filter.get()); } - private ZoneId resolveProjectTimeZone() { - return MCConnectorEndpoint.resolveProjectTimeZone(connector.getEndpoint()); - } - private TableBatchReadSession createReadSession( TableIdentifier tableId, List partitionCols, List dataCols, @@ -281,7 +338,11 @@ private List buildSplitsFromSession( for (com.aliyun.odps.table.read.split.InputSplit split : assigner.getAllSplits()) { result.add(MaxComputeScanRange.builder() .start(((IndexedInputSplit) split).getSplitIndex()) - .length(splitByteSize) + // -1 is the BE sentinel that distinguishes BYTE_SIZE from ROW_OFFSET + // splits (MaxComputeJniScanner: split_size == -1 => BYTE_SIZE). The real + // byte size lives in the session, not the range; mirrors legacy + // MaxComputeScanNode's MaxComputeSplit(..., length=-1, ...). + .length(-1L) .scanSerialize(serialized) .sessionId(split.getSessionId()) .splitType(MaxComputeScanRange.SPLIT_TYPE_BYTE_SIZE) @@ -319,6 +380,7 @@ private List planScanWithLimitOptimization( TableIdentifier tableId, List partitionCols, List dataCols, Predicate filterPredicate, long limit, + List requiredPartitions, Table odpsTable) throws IOException { long t0 = System.currentTimeMillis(); @@ -329,7 +391,7 @@ private List planScanWithLimitOptimization( TableBatchReadSession readSession = createReadSession( tableId, partitionCols, dataCols, - filterPredicate, Collections.emptyList(), rowOffsetOptions); + filterPredicate, requiredPartitions, rowOffsetOptions); String serialized = serializeSession(readSession); InputSplitAssigner assigner = readSession.getInputSplitAssigner(); @@ -362,18 +424,90 @@ private List planScanWithLimitOptimization( } /** - * Check if all filter predicates are partition-column equality predicates. - * This enables the limit optimization path. + * Gate (1): reads the {@code enable_mc_limit_split_optimization} session variable + * (default {@code false}). Map-typed for direct unit testing without a live session. + */ + static boolean isLimitOptEnabled(Map sessionProperties) { + return Boolean.parseBoolean( + sessionProperties.getOrDefault(ENABLE_MC_LIMIT_SPLIT_OPTIMIZATION, "false")); + } + + /** + * Whether the LIMIT-split optimization is eligible, mirroring legacy + * {@code MaxComputeScanNode}'s {@code enableMcLimitSplitOptimization + * && onlyPartitionEqualityPredicate && hasLimit()} (default OFF). Pure → unit-testable. + * + * @param limitOptEnabled gate (1): the session var value + * @param limit gate (3): {@code > 0} means a LIMIT is present + * @param filter the pushed-down filter; empty means no predicate + * @param partitionColumnNames the table's partition column names + */ + static boolean shouldUseLimitOptimization(boolean limitOptEnabled, long limit, + Optional filter, Set partitionColumnNames) { + if (!limitOptEnabled || limit <= 0) { + return false; + } + if (!filter.isPresent()) { + // No predicate: every row qualifies, so the first min(limit, total) rows are correct. + return true; + } + return checkOnlyPartitionEquality(filter.get(), partitionColumnNames); + } + + /** + * Gate (2): true iff every conjunct in {@code expr} is a partition-column equality + * ({@code partcol = literal}) or partition-column IN-list ({@code partcol IN (literal, ...)}). + * Mirrors legacy {@code MaxComputeScanNode.checkOnlyPartitionEqualityPredicate()}: when this + * holds, every row in the (pruned) partitions qualifies, so reading the first {@code limit} + * rows by row offset is correct. + * + *

    The empty-filter case is handled upstream in {@link #shouldUseLimitOptimization} + * (legacy treats empty conjuncts as eligible).

    */ - private boolean checkOnlyPartitionEquality(ConnectorExpression expr, + static boolean checkOnlyPartitionEquality(ConnectorExpression expr, Set partitionColumnNames) { - // Conservative: return false to disable limit optimization when filter is complex. - // The full check would walk the expression tree to verify all leaves are - // partition_col = literal or partition_col IN (literal, ...). - // For the first iteration, we keep it simple and always return false. + if (expr instanceof ConnectorAnd) { + for (ConnectorExpression conjunct : ((ConnectorAnd) expr).getConjuncts()) { + if (!isPartitionEqualityLeaf(conjunct, partitionColumnNames)) { + return false; + } + } + return true; + } + return isPartitionEqualityLeaf(expr, partitionColumnNames); + } + + private static boolean isPartitionEqualityLeaf(ConnectorExpression expr, + Set partitionColumnNames) { + // partcol = literal (mirror legacy: column on the LEFT, literal on the RIGHT, EQ only). + if (expr instanceof ConnectorComparison) { + ConnectorComparison cmp = (ConnectorComparison) expr; + return cmp.getOperator() == ConnectorComparison.Operator.EQ + && isPartitionColumnRef(cmp.getLeft(), partitionColumnNames) + && cmp.getRight() instanceof ConnectorLiteral; + } + // partcol IN (literal, ...) (not NOT-IN; all list elements must be literals). + if (expr instanceof ConnectorIn) { + ConnectorIn in = (ConnectorIn) expr; + if (in.isNegated() || !isPartitionColumnRef(in.getValue(), partitionColumnNames)) { + return false; + } + for (ConnectorExpression item : in.getInList()) { + if (!(item instanceof ConnectorLiteral)) { + return false; + } + } + return true; + } return false; } + private static boolean isPartitionColumnRef(ConnectorExpression expr, + Set partitionColumnNames) { + return expr instanceof ConnectorColumnRef + && partitionColumnNames.contains(((ConnectorColumnRef) expr).getColumnName()); + } + private static String serializeSession(Serializable object) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeTableHandle.java b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeTableHandle.java index 6d1b4f70b4ef87..d61672494803ed 100644 --- a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeTableHandle.java +++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeTableHandle.java @@ -17,6 +17,7 @@ package org.apache.doris.connector.maxcompute; +import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.handle.ConnectorTableHandle; import com.aliyun.odps.Table; @@ -59,4 +60,27 @@ public Table getOdpsTable() { public TableIdentifier getTableIdentifier() { return tableIdentifier; } + + /** + * Rejects read/write on a MaxCompute external table or logical view: the ODPS Storage API + * used by the scan ({@link MaxComputeScanPlanProvider#planScan}) and write + * ({@link MaxComputeWritePlanProvider#planWrite}) paths only handles managed/internal tables. + * Mirrors legacy {@code MaxComputeExternalTable.isUnsupportedOdpsTable} and the guards added in + * {@code MaxComputeScanNode.getSplits} / {@code MCTransaction.beginInsert}. + * + * @param operation the gerund used in the error message, "Reading" or "Writing" + */ + public void checkOperationSupported(String operation) { + checkOperationSupported(odpsTable.isExternalTable(), odpsTable.isVirtualView(), + operation, dbName, tableName); + } + + static void checkOperationSupported(boolean isExternalTable, boolean isVirtualView, + String operation, String dbName, String tableName) { + if (isExternalTable || isVirtualView) { + throw new DorisConnectorException(operation + + " MaxCompute external table or logical view is not supported: " + + dbName + "." + tableName); + } + } } 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 new file mode 100644 index 00000000000000..f8da97fed69ef7 --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeWritePlanProvider.java @@ -0,0 +1,256 @@ +// 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.maxcompute; + +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.ConnectorWriteHandle; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.write.ConnectorSinkPlan; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; +import org.apache.doris.thrift.TDataSink; +import org.apache.doris.thrift.TDataSinkType; +import org.apache.doris.thrift.TMaxComputeTableSink; + +import com.aliyun.odps.Column; +import com.aliyun.odps.PartitionSpec; +import com.aliyun.odps.Table; +import com.aliyun.odps.table.TableIdentifier; +import com.aliyun.odps.table.configuration.ArrowOptions; +import com.aliyun.odps.table.configuration.ArrowOptions.TimestampUnit; +import com.aliyun.odps.table.configuration.DynamicPartitionOptions; +import com.aliyun.odps.table.enviroment.EnvironmentSettings; +import com.aliyun.odps.table.write.TableBatchWriteSession; +import com.aliyun.odps.table.write.TableWriteSessionBuilder; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Write plan provider for MaxCompute (ODPS). + * + *

    Builds the opaque {@link TMaxComputeTableSink} for a bound DML write: it + * creates the ODPS Storage API write session, binds it to the current connector + * transaction (so commit / block allocation can act on it), and stamps the + * engine transaction id and write session id into the sink.

    + * + *

    Ported from the legacy fe-core write path — {@code MCTransaction.beginInsert()} + * (write-session creation) and {@code MaxComputeTableSink.bindDataSink()} / + * {@code setWriteContext()} (sink field population). The legacy split between + * {@code finalizeSink} (sink fields) and {@code MCInsertExecutor.beforeExec} + * (runtime {@code txn_id} / {@code write_session_id} injection) collapses into + * this single {@code planWrite} call, which runs at {@code finalizeSink} time when + * the engine transaction id already exists and the write session can be created + * in place (see P4-T04 design, OQ-2 / Approach A).

    + * + *

    Runtime block-id allocation ({@code block_id_start} / {@code block_id_count}) + * is intentionally not stamped here: BE allocates it at run time through the + * engine transaction ({@link MaxComputeConnectorTransaction#allocateWriteBlockRange}) + * keyed by {@code txn_id}.

    + * + *

    Gate-closed / dormant. Nothing routes plugin-driven MaxCompute writes + * through this provider until the {@code max_compute} cutover. In particular + * {@link #planWrite} requires the session to carry the connector transaction + * (bound by the executor wiring added at cutover); it fails loud if absent.

    + */ +public class MaxComputeWritePlanProvider implements ConnectorWritePlanProvider { + + private static final Logger LOG = LogManager.getLogger(MaxComputeWritePlanProvider.class); + + private final MaxComputeDorisConnector connector; + + public MaxComputeWritePlanProvider(MaxComputeDorisConnector connector) { + this.connector = connector; + } + + @Override + public ConnectorSinkPlan planWrite(ConnectorSession session, ConnectorWriteHandle handle) { + MaxComputeTableHandle mcHandle = (MaxComputeTableHandle) handle.getTableHandle(); + Table odpsTable = mcHandle.getOdpsTable(); + // Reject external tables / logical views before opening a write session (mirrors legacy + // MCTransaction.beginInsert): the ODPS Storage API cannot write to them. + mcHandle.checkOperationSupported("Writing"); + TableIdentifier tableId = mcHandle.getTableIdentifier(); + + boolean isOverwrite = handle.isOverwrite(); + // Static partition spec carried as a col -> val map in the write context (D-5). + Map staticPartitionSpec = handle.getWriteContext(); + boolean isStaticPartition = staticPartitionSpec != null && !staticPartitionSpec.isEmpty(); + + // Partition column names, taken from the ODPS table (DV-012: legacy reads + // the fe-core Doris columns; the values — partition column names — are identical). + List partitionColumnNames = odpsTable.getSchema().getPartitionColumns() + .stream().map(Column::getName).collect(Collectors.toList()); + boolean isDynamicPartition = !partitionColumnNames.isEmpty(); + + EnvironmentSettings settings = connector.getSettings(); + + String writeSessionId = createWriteSession( + tableId, settings, partitionColumnNames, staticPartitionSpec, + isStaticPartition, isDynamicPartition, isOverwrite, mcHandle.getTableName()); + + // Bind the write session to the current connector transaction (T03 slot), + // so block allocation and commit can act on it. + MaxComputeConnectorTransaction transaction = currentTransaction(session); + transaction.setWriteSession(writeSessionId, tableId, settings); + + TMaxComputeTableSink tSink = new TMaxComputeTableSink(); + tSink.setProperties(connector.getProperties()); + tSink.setEndpoint(connector.getEndpoint()); + tSink.setProject(connector.getDefaultProject()); + tSink.setTableName(mcHandle.getTableName()); + tSink.setQuota(connector.getQuota()); + tSink.setConnectTimeout(getConnectTimeout()); + tSink.setReadTimeout(getReadTimeout()); + tSink.setRetryCount(getRetryTimes()); + if (!partitionColumnNames.isEmpty()) { + tSink.setPartitionColumns(partitionColumnNames); + } + if (isStaticPartition) { + tSink.setStaticPartitionSpec(staticPartitionSpec); + } + tSink.setWriteSessionId(writeSessionId); + tSink.setTxnId(transaction.getTransactionId()); + // block_id_start / block_id_count are left unset: BE allocates them at run + // time via the engine transaction (keyed by txn_id). + + TDataSink dataSink = new TDataSink(TDataSinkType.MAXCOMPUTE_TABLE_SINK); + dataSink.setMaxComputeTableSink(tSink); + return new ConnectorSinkPlan(dataSink); + } + + @Override + public Set supportedOperations() { + return EnumSet.of(WriteOperation.INSERT, WriteOperation.OVERWRITE); + } + + @Override + public boolean requiresParallelWrite() { + return true; + } + + @Override + public boolean requiresFullSchemaWriteOrder() { + return true; + } + + @Override + public boolean requiresPartitionLocalSort() { + return true; + } + + /** + * Creates the ODPS Storage API batch write session and returns its id. Ports + * {@code MCTransaction.beginInsert()}: a static partition pins the target + * partition, otherwise a partitioned table uses dynamic partitioning; overwrite + * is applied when requested. Note the write path uses MILLI/MILLI Arrow units + * (the scan path differs). + */ + private String createWriteSession(TableIdentifier tableId, EnvironmentSettings settings, + List partitionColumnNames, Map staticPartitionSpec, + boolean isStaticPartition, boolean isDynamicPartition, boolean isOverwrite, + String tableName) { + try { + TableWriteSessionBuilder builder = new TableWriteSessionBuilder() + .identifier(tableId) + .withSettings(settings) + .withMaxFieldSize(getMaxFieldSize()) + .withArrowOptions(ArrowOptions.newBuilder() + .withDatetimeUnit(TimestampUnit.MILLI) + .withTimestampUnit(TimestampUnit.MILLI) + .build()); + + if (isStaticPartition) { + builder.partition(new PartitionSpec( + buildStaticPartitionSpecString(partitionColumnNames, staticPartitionSpec))); + } else if (isDynamicPartition) { + builder.withDynamicPartitionOptions(DynamicPartitionOptions.createDefault()); + } + + if (isOverwrite) { + builder.overwrite(true); + } + + TableBatchWriteSession writeSession = builder.buildBatchWriteSession(); + String writeSessionId = writeSession.getId(); + LOG.info("Created MaxCompute write session {} for table {} (overwrite={}, " + + "staticPartition={}, dynamicPartition={})", + writeSessionId, tableName, isOverwrite, isStaticPartition, isDynamicPartition); + return writeSessionId; + } catch (IOException e) { + throw new DorisConnectorException( + "Failed to create MaxCompute write session for table " + tableName + + ": " + e.getMessage(), e); + } + } + + /** + * Joins the static partition spec into {@code "col=val,col=val"} following the + * table's partition column order (mirrors {@code MCTransaction.beginInsert}). + */ + private String buildStaticPartitionSpecString(List partitionColumnNames, + Map staticPartitionSpec) { + return partitionColumnNames.stream() + .filter(staticPartitionSpec::containsKey) + .map(name -> name + "=" + staticPartitionSpec.get(name)) + .collect(Collectors.joining(",")); + } + + private MaxComputeConnectorTransaction currentTransaction(ConnectorSession session) { + Optional transaction = session.getCurrentTransaction(); + if (!transaction.isPresent()) { + throw new DorisConnectorException( + "MaxCompute write requires an active connector transaction bound to the session; " + + "none is present. The executor must open it via beginTransaction and bind " + + "it to the session (wired at the max_compute cutover)."); + } + return (MaxComputeConnectorTransaction) transaction.get(); + } + + private int getConnectTimeout() { + return Integer.parseInt(connector.getProperties().getOrDefault( + MCConnectorProperties.CONNECT_TIMEOUT, + MCConnectorProperties.DEFAULT_CONNECT_TIMEOUT)); + } + + private int getReadTimeout() { + return Integer.parseInt(connector.getProperties().getOrDefault( + MCConnectorProperties.READ_TIMEOUT, + MCConnectorProperties.DEFAULT_READ_TIMEOUT)); + } + + private int getRetryTimes() { + return Integer.parseInt(connector.getProperties().getOrDefault( + MCConnectorProperties.RETRY_COUNT, + MCConnectorProperties.DEFAULT_RETRY_COUNT)); + } + + private long getMaxFieldSize() { + return Long.parseLong(connector.getProperties().getOrDefault( + MCConnectorProperties.MAX_FIELD_SIZE, + MCConnectorProperties.DEFAULT_MAX_FIELD_SIZE)); + } +} diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MCTypeMappingTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MCTypeMappingTest.java new file mode 100644 index 00000000000000..a5fb73241acb5e --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MCTypeMappingTest.java @@ -0,0 +1,92 @@ +// 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.maxcompute; + +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; + +import com.aliyun.odps.type.TypeInfoFactory; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * G7 FIX-VOID-TYPE-MAPPING — pins the ODPS {@code TypeInfo} -> {@link ConnectorType} mapping for + * the two cases that diverged from legacy {@code MaxComputeExternalTable.mcTypeToDorisType}. + * + *

    WHY this matters: VOID must emit the {@code "NULL_TYPE"} token, which + * {@code ScalarType.createType} turns into {@code Type.NULL} (legacy parity). The prior bug emitted + * {@code "NULL"}, which {@code ScalarType.createType} does NOT recognize -> it throws -> + * {@code ConnectorColumnConverter} swallowed it to {@code Type.UNSUPPORTED}, so a VOID column + * silently became unusable. Separately, a genuinely unknown OdpsType ({@code OdpsType.UNKNOWN} or a + * future type) must fail-fast (legacy threw "Cannot transform unknown type"), not silently degrade + * to UNSUPPORTED — while the known-unsupported types (BINARY/INTERVAL) keep their explicit + * UNSUPPORTED mapping.

    + */ +public class MCTypeMappingTest { + + @Test + public void voidMapsToNullTypeToken() { + // WHY (Rule 9): VOID must emit the token that yields Type.NULL downstream. MUTATION: + // reverting to of("NULL") makes this red ("NULL" is rejected by ScalarType.createType). + ConnectorType t = MCTypeMapping.toConnectorType(TypeInfoFactory.VOID); + Assertions.assertEquals("NULL_TYPE", t.getTypeName(), + "ODPS VOID must map to the NULL_TYPE token (-> Type.NULL), not NULL"); + } + + @Test + public void arrayOfVoidMapsElementToNullType() { + // The VOID branch is shared by nested element mapping; ARRAY must carry NULL_TYPE. + ConnectorType arr = MCTypeMapping.toConnectorType( + TypeInfoFactory.getArrayTypeInfo(TypeInfoFactory.VOID)); + Assertions.assertEquals("NULL_TYPE", arr.getChildren().get(0).getTypeName(), + "ARRAY element must map to NULL_TYPE"); + } + + @Test + public void binaryStaysUnsupportedNotThrown() { + // WHY: known-unsupported types have explicit UNSUPPORTED cases; the fail-fast default + // (for unknown future types) must NOT swallow them. If BINARY fell through to the default + // it would throw instead of returning UNSUPPORTED. + ConnectorType t = MCTypeMapping.toConnectorType(TypeInfoFactory.BINARY); + Assertions.assertEquals("UNSUPPORTED", t.getTypeName(), + "BINARY is a known-unsupported type: explicit UNSUPPORTED, not a fail-fast throw"); + } + + @Test + public void unknownTypeFailsFast() { + // WHY (Rule 9): a genuinely unknown OdpsType must fail-fast, mirroring legacy + // MaxComputeExternalTable.mcTypeToDorisType:294, instead of silently becoming UNSUPPORTED + // (which masks the problem). MUTATION: reverting the default to of("UNSUPPORTED") makes + // this red (no exception). OdpsType.UNKNOWN reaches the switch default (no explicit case). + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> MCTypeMapping.toConnectorType(TypeInfoFactory.UNKNOWN)); + Assertions.assertTrue(ex.getMessage().toLowerCase().contains("unknown"), + "unknown-type rejection message should mention 'unknown'"); + } + + @Test + public void knownScalarTokensAreStable() { + // Guards against token drift for the common scalars. + Assertions.assertEquals("INT", + MCTypeMapping.toConnectorType(TypeInfoFactory.INT).getTypeName()); + Assertions.assertEquals("STRING", + MCTypeMapping.toConnectorType(TypeInfoFactory.STRING).getTypeName()); + Assertions.assertEquals("BOOLEAN", + MCTypeMapping.toConnectorType(TypeInfoFactory.BOOLEAN).getTypeName()); + } +} diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeBuildTableDescriptorTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeBuildTableDescriptorTest.java new file mode 100644 index 00000000000000..64a7381b51b7db --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeBuildTableDescriptorTest.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.connector.maxcompute; + +import org.apache.doris.thrift.TMCTable; +import org.apache.doris.thrift.TTableDescriptor; +import org.apache.doris.thrift.TTableType; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * FIX-READ-DESC (P4-T06d) — guards the MaxCompute read-path table descriptor contract. + * + *

    WHY this matters: after the {@code max_compute} cutover, a SELECT routes through + * {@code PluginDrivenExternalTable.toThrift()} → {@code metadata.buildTableDescriptor(...)}. + * BE's {@code file_scanner} static_casts {@code table_desc()} to {@code MaxComputeTableDescriptor} + * unconditionally for {@code table_format_type=="max_compute"}, and reads endpoint/quota/project/ + * table/properties as the auth + addressing contract. If this override regressed to {@code null} + * (SPI default) or a {@code SCHEMA_TABLE} descriptor with no {@code mcTable}, BE would type-confuse + * a {@code SchemaTableDescriptor} as a {@code MaxComputeTableDescriptor} → crash / garbage reads. + * Each assertion below therefore encodes a BE-side requirement, not just the method's shape + * (Rule 9): this test FAILS if the override returns null or any non-MAX_COMPUTE_TABLE descriptor.

    + * + *

    Boundary: this connector module has no fe-core dependency, so the test can only assert the + * override's OWN output. It cannot reach the fe-core {@code toThrift} call site (passing remote + * dbName/remoteName, numCols) — that half of the contract is covered by user-run e2e only.

    + * + *

    The ctor only assigns its args; {@code buildTableDescriptor} never dereferences odps / + * structureHelper, so passing {@code null} for them is safe and keeps the test offline.

    + */ +public class MaxComputeBuildTableDescriptorTest { + + @Test + public void buildsMaxComputeTableDescriptorWithAuthAndAddressing() { + String endpoint = "http://service.cn-hangzhou.maxcompute.aliyun.com/api"; + String quota = "test_quota"; + Map properties = new HashMap<>(); + properties.put("mc.access_key", "test-ak"); + properties.put("mc.secret_key", "test-sk"); + + MaxComputeConnectorMetadata metadata = new MaxComputeConnectorMetadata( + null, null, "default_project", endpoint, quota, properties, + null); // null: partition cache unused by this test + + // dbName / remoteName are already remote names at the real call site (OQ-7). + long tableId = 42L; + String tableName = "local_table"; + String dbName = "remote_project"; + String remoteName = "remote_table"; + int numCols = 7; + long catalogId = 100L; + + TTableDescriptor desc = metadata.buildTableDescriptor( + null, tableId, tableName, dbName, remoteName, numCols, catalogId); + + // (1) must not be null — null would trigger the SCHEMA_TABLE fallback in fe-core. + Assertions.assertNotNull(desc, + "buildTableDescriptor must return a typed descriptor, never null (BE expects MC type)"); + // (2) BE selects MaxComputeTableDescriptor only for MAX_COMPUTE_TABLE. + Assertions.assertEquals(TTableType.MAX_COMPUTE_TABLE, desc.getTableType(), + "table type must be MAX_COMPUTE_TABLE; SCHEMA_TABLE would crash BE's static_cast"); + // (3) BE reads mcTable for auth/addressing; it must be set. + Assertions.assertTrue(desc.isSetMcTable(), + "mcTable must be set; BE reads endpoint/quota/project/table/properties from it"); + + TMCTable mcTable = desc.getMcTable(); + Assertions.assertEquals(endpoint, mcTable.getEndpoint(), "endpoint must reach BE auth path"); + Assertions.assertEquals(quota, mcTable.getQuota(), "quota must reach BE auth path"); + // project/table must be the REMOTE names — they must match the SPI read session (OQ-7). + Assertions.assertEquals(dbName, mcTable.getProject(), + "project must be the remote dbName param, consistent with the SPI read session"); + Assertions.assertEquals(remoteName, mcTable.getTable(), + "table must be the remote remoteName param, consistent with the SPI read session"); + Assertions.assertEquals(properties, mcTable.getProperties(), + "credentials/properties must be carried through for BE auth"); + } +} 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 new file mode 100644 index 00000000000000..3995e793a44a4b --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorContractTest.java @@ -0,0 +1,68 @@ +// 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.maxcompute; + +import org.apache.doris.connector.api.ConnectorContractValidator; +import org.apache.doris.connector.api.handle.WriteOperation; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; + +/** + * Task 6 (write-capability unification, P2): the per-connector expected-set assertion (the pragmatic + * "declaration == implementation" check for the write-capability invariant the removed + * {@code ConnectorContractValidator} #1 runtime probe is NOT safe to make) plus the structural contract + * validator, exercised against a real {@link MaxComputeDorisConnector}. MaxCompute is the one connector that + * exercises the {@code requiresPartitionLocalSort} triad (local-sort implies parallel write AND full-schema + * write order) end to end, so this doubles as a positive control for {@link ConnectorContractValidator}'s + * invariant #3. Properties are the same offline-safe minimal AK/SK config {@code testConnection}-adjacent + * tests already use ({@code MaxComputeConnectorProviderTest#connectivityProps}); {@code getWritePlanProvider()} + * only builds local ODPS client/settings objects (no network) at this property shape. + */ +public class MaxComputeConnectorContractTest { + + private static Map validProps() { + Map props = new HashMap<>(); + props.put(MCConnectorProperties.PROJECT, "mc_project"); + props.put(MCConnectorProperties.ENDPOINT, + "http://service.cn-beijing.maxcompute.aliyun-inc.com/api"); + props.put(MCConnectorProperties.ACCESS_KEY, "access_key"); + props.put(MCConnectorProperties.SECRET_KEY, "secret_key"); + return props; + } + + @Test + public void declaredWriteCapabilitiesMatchAndPassContractValidator() { + MaxComputeDorisConnector connector = new MaxComputeDorisConnector(validProps(), null); + + Assertions.assertEquals(EnumSet.of(WriteOperation.INSERT, WriteOperation.OVERWRITE), + connector.supportedWriteOperations()); + Assertions.assertFalse(connector.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()); + + 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 new file mode 100644 index 00000000000000..b92c651635ea6c --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadataCapabilityTest.java @@ -0,0 +1,77 @@ +// 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.maxcompute; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +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. + * + *

    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}).

    + */ +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. + * + *

    WHY this matters: the shared converter unwraps CAST shells, so if this returned + * {@code true} (the SPI default), a predicate like {@code CAST(str_col AS INT)=5} would be pushed + * to ODPS as {@code str_col="5"} and silently drop rows like {@code "05"}/{@code " 5"} at the + * source (BE re-eval cannot recover source-dropped rows). Returning {@code false} makes + * {@code PluginDrivenScanNode.buildRemainingFilter} keep CAST conjuncts BE-only, mirroring legacy + * (which never pushed CAST predicates). MUTATION: flipping the override to {@code true} (or + * removing it, reverting to the default {@code true}) makes this red. Offline: the getter touches + * no instance field, so null odps/helper/session is fine.

    + */ + @Test + public void maxComputeDisablesCastPredicatePushdown() { + MaxComputeConnectorMetadata metadata = new MaxComputeConnectorMetadata( + null, null, "proj", "ep", "quota", Collections.emptyMap(), + null); // null: partition cache unused by this test + + Assertions.assertFalse(metadata.supportsCastPredicatePushdown(null), + "MaxCompute must disable CAST-predicate pushdown (F9): the converter unwraps CAST " + + "shells, and pushing the stripped predicate to ODPS under-matches at the " + + "source and silently drops rows BE re-eval cannot recover"); + } +} diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadataDropDbTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadataDropDbTest.java new file mode 100644 index 00000000000000..f5fb465948e46e --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadataDropDbTest.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.connector.maxcompute; + +import org.apache.doris.connector.api.DorisConnectorException; + +import com.aliyun.odps.Odps; +import com.aliyun.odps.OdpsException; +import com.aliyun.odps.Partition; +import com.aliyun.odps.Table; +import com.aliyun.odps.TableSchema; +import com.aliyun.odps.Tables; +import com.aliyun.odps.table.TableIdentifier; +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.Iterator; +import java.util.List; + +/** + * P2-5 FIX-DROP-DB-FORCE (clean-room re-review DG-3 / F22, F27) — guards that + * {@code DROP DATABASE ... FORCE} cascades the table drops in the connector. + * + *

    WHY this matters: after the SPI cutover the FE + * {@code PluginDrivenExternalCatalog.dropDb} discarded the user's {@code force} flag, + * and the connector's {@code dropDatabase} just called {@code schemas().delete()}. + * ODPS {@code schemas().delete()} does NOT auto-cascade (the legacy + * {@code MaxComputeMetadataOps.dropDbImpl} force-branch enumerate-loop is itself proof), + * so on a non-empty schema {@code DROP DB FORCE} degraded to a non-FORCE drop — + * failing outright or leaving residue, while silently ignoring FORCE (Rule 12). These + * tests pin the restored cascade: every table is dropped BEFORE the schema, only when + * FORCE is set, and a failing remote drop aborts loudly before the schema is deleted.

    + * + *

    The maxcompute connector test module has no Mockito, so a hand-written recording + * {@link McStructureHelper} captures the call order. {@code dropDatabase} never + * dereferences {@code odps} (it only passes it to the helper), so a {@code null} odps + * keeps the test offline — the same pattern as {@link MaxComputeBuildTableDescriptorTest}.

    + */ +public class MaxComputeConnectorMetadataDropDbTest { + + private MaxComputeConnectorMetadata metadataWith(RecordingStructureHelper helper) { + return new MaxComputeConnectorMetadata( + null /* odps */, helper, "proj", "ep", "quota", Collections.emptyMap(), + null); // null: partition cache unused by this test + } + + @Test + public void forceTrueCascadesAllTablesBeforeDroppingSchema() { + RecordingStructureHelper helper = new RecordingStructureHelper(Arrays.asList("t1", "t2")); + MaxComputeConnectorMetadata metadata = metadataWith(helper); + + metadata.dropDatabase(null, "db1", false, true); + + // WHY: legacy parity requires every table dropped first (ODPS won't auto-cascade), + // each with ifExists=true so a raced already-gone table does not abort the cascade. + // MUTATION: removing the `if (force) {...}` block -> log is just ["dropDb:db1"] (red); + // flipping the hardcoded dropTable(...,true) to false -> ":false" markers (red). + Assertions.assertEquals( + Arrays.asList("dropTable:t1:true", "dropTable:t2:true", "dropDb:db1"), + helper.log, + "FORCE must drop every table (in order, ifExists=true) before deleting the schema"); + } + + @Test + public void forceFalseDoesNotEnumerateOrDropTables() { + RecordingStructureHelper helper = new RecordingStructureHelper(Arrays.asList("t1", "t2")); + MaxComputeConnectorMetadata metadata = metadataWith(helper); + + metadata.dropDatabase(null, "db1", false, false); + + // WHY: a plain (non-FORCE) DROP DB must never delete tables; over-correcting into + // always-cascading would silently drop user data. MUTATION: making the gate + // unconditional records dropTable calls -> red. + Assertions.assertEquals( + Collections.singletonList("dropDb:db1"), + helper.log, + "non-FORCE must drop only the schema, never the tables"); + } + + @Test + public void forceTrueOnEmptySchemaJustDropsDb() { + RecordingStructureHelper helper = new RecordingStructureHelper(Collections.emptyList()); + MaxComputeConnectorMetadata metadata = metadataWith(helper); + + metadata.dropDatabase(null, "db1", false, true); + + // WHY: FORCE on an empty schema must behave like a plain drop (loop is a no-op). + Assertions.assertEquals( + Collections.singletonList("dropDb:db1"), + helper.log, + "FORCE on an empty schema must just drop the schema"); + } + + @Test + public void forceTrueSurfacesRemoteDropFailureAsConnectorException() { + RecordingStructureHelper helper = new RecordingStructureHelper(Arrays.asList("t1", "t2")); + helper.failOnTable = "t2"; + MaxComputeConnectorMetadata metadata = metadataWith(helper); + + // WHY (Rule 12 fail-loud): a failing remote table drop must abort the cascade BEFORE + // the schema is deleted and surface as DorisConnectorException, not be swallowed. + // MUTATION: catch+continue (swallow OdpsException) would let dropDb run -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata.dropDatabase(null, "db1", false, true)); + Assertions.assertTrue(ex.getMessage().contains("t2"), + "the failure must name the table that could not be dropped"); + Assertions.assertFalse(helper.log.contains("dropDb:db1"), + "the schema must NOT be deleted after a failed table cascade"); + } + + /** + * Recording fake: returns a fixed table list and appends an ordered marker for each + * cascade call. Only the three methods the cascade touches are meaningful; the rest + * return harmless defaults (they are never invoked by {@code dropDatabase}). + */ + private static final class RecordingStructureHelper implements McStructureHelper { + private final List tables; + private final List log = new ArrayList<>(); + private String failOnTable; + + RecordingStructureHelper(List tables) { + this.tables = tables; + } + + @Override + public List listTableNames(Odps mcClient, String dbName) { + return tables; + } + + @Override + public void dropTable(Odps mcClient, String dbName, String tableName, boolean ifExists) + throws OdpsException { + // Record ifExists too: the cascade must pass ifExists=true (legacy + // dropTableImpl(tbl, true)) so a duplicate/raced already-gone table does not + // abort the cascade. Pinning it makes a true->false mutation go red. + log.add("dropTable:" + tableName + ":" + ifExists); + if (tableName.equals(failOnTable)) { + throw new OdpsException("simulated remote drop failure for " + tableName); + } + } + + @Override + public void dropDb(Odps mcClient, String dbName, boolean ifExists) { + log.add("dropDb:" + dbName); + } + + // ---- unused by dropDatabase: harmless defaults ---- + + @Override + public List listDatabaseNames(Odps mcClient, String defaultProject) { + return Collections.emptyList(); + } + + @Override + public boolean tableExist(Odps mcClient, String dbName, String tableName) { + return false; + } + + @Override + public boolean databaseExist(Odps mcClient, String dbName) { + return false; + } + + @Override + public TableIdentifier getTableIdentifier(String dbName, String tableName) { + return null; + } + + @Override + public List getPartitions(Odps mcClient, String dbName, String tableName) { + return Collections.emptyList(); + } + + @Override + public Iterator getPartitionIterator(Odps mcClient, String dbName, String tableName) { + return Collections.emptyIterator(); + } + + @Override + public Table getOdpsTable(Odps mcClient, String dbName, String tableName) { + return null; + } + + @Override + public Tables.TableCreator createTableCreator(Odps mcClient, String dbName, + String tableName, TableSchema schema) { + return null; + } + + @Override + public void createDb(Odps mcClient, String dbName, boolean ifNotExists) { + } + } +} diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadataIsKeyTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadataIsKeyTest.java new file mode 100644 index 00000000000000..4f24940a892b7e --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadataIsKeyTest.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.maxcompute; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorType; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * FIX-ISKEY-METADATA (P4-T06e / NG-6 / F3 / F10) — guards + * {@link MaxComputeConnectorMetadata#buildColumn}, the single seam through which both + * {@code getTableSchema} column loops construct their {@link ConnectorColumn}s. + * + *

    Why this matters: legacy {@code MaxComputeExternalTable.initSchema} marked every column + * {@code isKey=true}; the cutover's 5-arg ctor defaulted it to {@code false}, regressing + * {@code DESCRIBE } to {@code Key=NO} (and silently changing the non-OLAP-guarded planning + * /BE paths that read {@code Column.isKey()}). This pins the {@code isKey=true} invariant in the + * MaxCompute module.

    + * + *

    Coverage scope: this pins the {@code buildColumn} helper invariant only. The + * {@code getTableSchema → buildColumn} wiring is NOT unit-tested here because {@code getTableSchema} + * dereferences a live {@code com.aliyun.odps.Table}, whose only constructor is package-private and + * this connector module has no Mockito (driving it offline would require a {@code com.aliyun.odps} + * -package fixture subclass overriding {@code getSchema()} — no precedent in this repo). A future + * call site that bypasses {@code buildColumn} (reverting to the 5-arg ctor) would not be caught + * here — the e2e {@code DESCRIBE} assertion is the load-bearing regression gate for the wiring + * (recorded as a deviation).

    + */ +public class MaxComputeConnectorMetadataIsKeyTest { + + @Test + public void testBuildColumnMarksKeyTrue() { + // The core regression guard: every MaxCompute column must be isKey=true (legacy parity). + ConnectorColumn col = MaxComputeConnectorMetadata.buildColumn( + "c1", ConnectorType.of("INT"), "a comment", true); + Assertions.assertTrue(col.isKey()); + } + + @Test + public void testBuildColumnPreservesOtherFields() { + // Non-vacuous: the helper must build a correct column, not just flip the key flag. + ConnectorColumn col = MaxComputeConnectorMetadata.buildColumn( + "c1", ConnectorType.of("INT"), "a comment", true); + Assertions.assertEquals("c1", col.getName()); + Assertions.assertEquals(ConnectorType.of("INT"), col.getType()); + Assertions.assertEquals("a comment", col.getComment()); + Assertions.assertTrue(col.isNullable()); + Assertions.assertNull(col.getDefaultValue()); + // External tables never carry auto-increment columns; mirrors legacy. + Assertions.assertFalse(col.isAutoInc()); + } + + @Test + public void testBuildColumnKeyIndependentOfNullable() { + // Guards against accidentally wiring isKey to the nullable arg: a non-nullable + // (e.g. partition-style) column is still a key column. + ConnectorColumn col = MaxComputeConnectorMetadata.buildColumn( + "pt", ConnectorType.of("STRING"), null, false); + Assertions.assertTrue(col.isKey()); + Assertions.assertFalse(col.isNullable()); + } +} 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 new file mode 100644 index 00000000000000..479e3c0bfc24f8 --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorProviderTest.java @@ -0,0 +1,372 @@ +// 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.maxcompute; + +import org.apache.doris.connector.api.ConnectorTestResult; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Tests for {@link MaxComputeConnectorProvider#validateProperties(Map)}. + * + *

    CREATE CATALOG must fail-fast on invalid MaxCompute properties, mirroring the + * legacy {@code MaxComputeExternalCatalog.checkProperties}. Without this validation + * the new SPI path degrades to use-time-late failures or silently accepts illegal + * values (e.g. account_format='foo' coerced to DISPLAYNAME, negative timeouts), so + * each case below pins one legacy validation branch. + */ +public class MaxComputeConnectorProviderTest { + + private final MaxComputeConnectorProvider provider = new MaxComputeConnectorProvider(); + + private Map validProps() { + Map props = new HashMap<>(); + props.put(MCConnectorProperties.PROJECT, "my_project"); + props.put(MCConnectorProperties.ENDPOINT, + "http://service.cn-beijing.maxcompute.aliyun-inc.com/api"); + // Default auth type is ak_sk; provide the keys so the minimal config is valid. + props.put(MCConnectorProperties.ACCESS_KEY, "ak"); + props.put(MCConnectorProperties.SECRET_KEY, "sk"); + return props; + } + + @Test + public void testValidPropertiesPass() { + Assertions.assertDoesNotThrow(() -> provider.validateProperties(validProps())); + } + + // --- 1. required PROJECT / ENDPOINT --- + + @Test + public void testMissingProject() { + Map props = validProps(); + props.remove(MCConnectorProperties.PROJECT); + IllegalArgumentException ex = Assertions.assertThrows( + IllegalArgumentException.class, + () -> provider.validateProperties(props)); + Assertions.assertTrue(ex.getMessage().contains(MCConnectorProperties.PROJECT)); + } + + @Test + public void testMissingEndpoint() { + Map props = validProps(); + props.remove(MCConnectorProperties.ENDPOINT); + IllegalArgumentException ex = Assertions.assertThrows( + IllegalArgumentException.class, + () -> provider.validateProperties(props)); + Assertions.assertTrue(ex.getMessage().contains(MCConnectorProperties.ENDPOINT)); + } + + // --- 2. split strategy + size/count floor --- + + @Test + public void testSplitByteSizeBelowFloor() { + Map props = validProps(); + props.put(MCConnectorProperties.SPLIT_BYTE_SIZE, "10485759"); + IllegalArgumentException ex = Assertions.assertThrows( + IllegalArgumentException.class, + () -> provider.validateProperties(props)); + Assertions.assertTrue(ex.getMessage().contains("10485760")); + } + + @Test + public void testSplitByteSizeAtFloorPasses() { + Map props = validProps(); + props.put(MCConnectorProperties.SPLIT_BYTE_SIZE, "10485760"); + Assertions.assertDoesNotThrow(() -> provider.validateProperties(props)); + } + + @Test + public void testSplitByteSizeNotInteger() { + Map props = validProps(); + props.put(MCConnectorProperties.SPLIT_BYTE_SIZE, "abc"); + IllegalArgumentException ex = Assertions.assertThrows( + IllegalArgumentException.class, + () -> provider.validateProperties(props)); + Assertions.assertTrue(ex.getMessage().contains("must be an integer")); + } + + @Test + public void testSplitStrategyInvalid() { + Map props = validProps(); + props.put(MCConnectorProperties.SPLIT_STRATEGY, "foo"); + IllegalArgumentException ex = Assertions.assertThrows( + IllegalArgumentException.class, + () -> provider.validateProperties(props)); + Assertions.assertTrue(ex.getMessage().contains( + MCConnectorProperties.SPLIT_BY_BYTE_SIZE_STRATEGY)); + } + + @Test + public void testSplitRowCountZero() { + Map props = validProps(); + props.put(MCConnectorProperties.SPLIT_STRATEGY, + MCConnectorProperties.SPLIT_BY_ROW_COUNT_STRATEGY); + props.put(MCConnectorProperties.SPLIT_ROW_COUNT, "0"); + IllegalArgumentException ex = Assertions.assertThrows( + IllegalArgumentException.class, + () -> provider.validateProperties(props)); + Assertions.assertTrue(ex.getMessage().contains("greater than 0")); + } + + @Test + public void testSplitRowCountStrategyValid() { + Map props = validProps(); + props.put(MCConnectorProperties.SPLIT_STRATEGY, + MCConnectorProperties.SPLIT_BY_ROW_COUNT_STRATEGY); + props.put(MCConnectorProperties.SPLIT_ROW_COUNT, "100000"); + Assertions.assertDoesNotThrow(() -> provider.validateProperties(props)); + } + + // --- 3. account_format enum --- + + @Test + public void testAccountFormatInvalid() { + Map props = validProps(); + props.put(MCConnectorProperties.ACCOUNT_FORMAT, "foo"); + IllegalArgumentException ex = Assertions.assertThrows( + IllegalArgumentException.class, + () -> provider.validateProperties(props)); + Assertions.assertTrue(ex.getMessage().contains("only support name and id")); + } + + @Test + public void testAccountFormatIdPasses() { + Map props = validProps(); + props.put(MCConnectorProperties.ACCOUNT_FORMAT, MCConnectorProperties.ACCOUNT_FORMAT_ID); + Assertions.assertDoesNotThrow(() -> provider.validateProperties(props)); + } + + @Test + public void testAccountFormatNamePasses() { + Map props = validProps(); + props.put(MCConnectorProperties.ACCOUNT_FORMAT, MCConnectorProperties.ACCOUNT_FORMAT_NAME); + Assertions.assertDoesNotThrow(() -> provider.validateProperties(props)); + } + + // --- 4. positive connect/read timeout + retry count --- + + @Test + public void testConnectTimeoutZero() { + Map props = validProps(); + props.put(MCConnectorProperties.CONNECT_TIMEOUT, "0"); + IllegalArgumentException ex = Assertions.assertThrows( + IllegalArgumentException.class, + () -> provider.validateProperties(props)); + Assertions.assertTrue(ex.getMessage().contains(MCConnectorProperties.CONNECT_TIMEOUT)); + Assertions.assertTrue(ex.getMessage().contains("greater than 0")); + } + + @Test + public void testConnectTimeoutNegative() { + Map props = validProps(); + props.put(MCConnectorProperties.CONNECT_TIMEOUT, "-1"); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> provider.validateProperties(props)); + } + + @Test + public void testReadTimeoutNotInteger() { + Map props = validProps(); + props.put(MCConnectorProperties.READ_TIMEOUT, "abc"); + IllegalArgumentException ex = Assertions.assertThrows( + IllegalArgumentException.class, + () -> provider.validateProperties(props)); + Assertions.assertTrue(ex.getMessage().contains("must be an integer")); + } + + @Test + public void testRetryCountZero() { + Map props = validProps(); + props.put(MCConnectorProperties.RETRY_COUNT, "0"); + IllegalArgumentException ex = Assertions.assertThrows( + IllegalArgumentException.class, + () -> provider.validateProperties(props)); + Assertions.assertTrue(ex.getMessage().contains(MCConnectorProperties.RETRY_COUNT)); + } + + // --- 5. auth completeness (wires the previously-dead checkAuthProperties, + // and verifies its exception type is now IllegalArgumentException) --- + + @Test + public void testAuthMissingSecretKey() { + Map props = validProps(); + props.remove(MCConnectorProperties.SECRET_KEY); + IllegalArgumentException ex = Assertions.assertThrows( + IllegalArgumentException.class, + () -> provider.validateProperties(props)); + Assertions.assertTrue(ex.getMessage().contains("secret key")); + } + + @Test + public void testAuthRamRoleArnMissingRoleArn() { + Map props = validProps(); + props.put(MCConnectorProperties.AUTH_TYPE, + MCConnectorProperties.AUTH_TYPE_RAM_ROLE_ARN); + // has access/secret key but no ram_role_arn + IllegalArgumentException ex = Assertions.assertThrows( + IllegalArgumentException.class, + () -> provider.validateProperties(props)); + Assertions.assertTrue(ex.getMessage().contains("role arn")); + } + + @Test + public void testAuthUnknownType() { + Map props = validProps(); + props.put(MCConnectorProperties.AUTH_TYPE, "no_such_auth"); + IllegalArgumentException ex = Assertions.assertThrows( + IllegalArgumentException.class, + () -> provider.validateProperties(props)); + Assertions.assertTrue(ex.getMessage().contains("Unsupported auth type")); + } + + // --- 6. split-byte-size error message names the byte-size property, not row-count --- + // Migrated from MaxComputeExternalCatalogTest.testSplitByteSizeErrorMessage (PR + // apache/doris#64119), which fixed a copy-paste that printed SPLIT_ROW_COUNT in the + // SPLIT_BYTE_SIZE floor error. This fork was already correct (G6); the test pins it. + + @Test + public void testSplitByteSizeErrorMessageNamesByteSizeNotRowCount() { + Map props = validProps(); + props.put(MCConnectorProperties.SPLIT_STRATEGY, + MCConnectorProperties.SPLIT_BY_BYTE_SIZE_STRATEGY); + props.put(MCConnectorProperties.SPLIT_BYTE_SIZE, "1048576"); + IllegalArgumentException ex = Assertions.assertThrows( + IllegalArgumentException.class, + () -> provider.validateProperties(props)); + Assertions.assertTrue(ex.getMessage().contains(MCConnectorProperties.SPLIT_BYTE_SIZE), + "got: " + ex.getMessage()); + Assertions.assertFalse(ex.getMessage().contains(MCConnectorProperties.SPLIT_ROW_COUNT), + "got: " + ex.getMessage()); + } + + // --- 7. CREATE CATALOG connectivity test (test_connection) — the FE->ODPS half of catalog + // validation, complementing the property half above. Migrated from + // MaxComputeExternalCatalogTest.testCheckWhenCreating* (PR apache/doris#64119): the legacy + // MaxComputeExternalCatalog.checkWhenCreating override is now MaxComputeDorisConnector + // .testConnection(), wired by PluginDrivenExternalCatalog.checkWhenCreating (TEST_CONNECTION + // gate -> testConnection -> DdlException on failure). The two ODPS calls (project-exists / + // namespace-schema-list) are overridden so the tests run offline with no Mockito, mirroring the + // PR's TestMaxComputeExternalCatalog seam subclass. --- + + @Test + public void testMaxComputeDoesNotForceConnectivityTestByDefault() { + // PR testCheckWhenCreatingSkipsValidationByDefault: MaxCompute leaves test_connection off by + // default, so PluginDrivenExternalCatalog.checkWhenCreating skips testConnection entirely. + Assertions.assertFalse( + new MaxComputeDorisConnector(connectivityProps(true), null).defaultTestConnection()); + } + + @Test + public void testConnectionValidatesProjectWhenNamespaceSchemaDisabled() { + TestMaxComputeDorisConnector connector = + new TestMaxComputeDorisConnector(connectivityProps(false)); + ConnectorTestResult result = connector.testConnection(null); + Assertions.assertTrue(result.isSuccess(), "got: " + result.getMessage()); + Assertions.assertEquals("mc_project", connector.checkedProjectName); + Assertions.assertNull(connector.checkedNamespaceSchemaProjectName); + } + + @Test + public void testConnectionValidatesSchemaWhenNamespaceSchemaEnabled() { + TestMaxComputeDorisConnector connector = + new TestMaxComputeDorisConnector(connectivityProps(true)); + ConnectorTestResult result = connector.testConnection(null); + Assertions.assertTrue(result.isSuccess(), "got: " + result.getMessage()); + Assertions.assertEquals("mc_project", connector.checkedNamespaceSchemaProjectName); + Assertions.assertNull(connector.checkedProjectName); + } + + @Test + public void testConnectionReportsInaccessibleProject() { + TestMaxComputeDorisConnector connector = + new TestMaxComputeDorisConnector(connectivityProps(false)); + connector.projectExists = false; + ConnectorTestResult result = connector.testConnection(null); + Assertions.assertFalse(result.isSuccess()); + Assertions.assertTrue( + result.getMessage().contains("Failed to validate MaxCompute project 'mc_project'"), + "got: " + result.getMessage()); + Assertions.assertTrue( + result.getMessage().contains("does not exist or is not accessible"), + "got: " + result.getMessage()); + Assertions.assertNull(connector.checkedNamespaceSchemaProjectName); + } + + @Test + public void testConnectionReportsInaccessibleNamespaceSchema() { + TestMaxComputeDorisConnector connector = + new TestMaxComputeDorisConnector(connectivityProps(true)); + connector.threeTierModel = false; + ConnectorTestResult result = connector.testConnection(null); + Assertions.assertFalse(result.isSuccess()); + Assertions.assertTrue( + result.getMessage().contains("Failed to validate MaxCompute project 'mc_project'"), + "got: " + result.getMessage()); + Assertions.assertTrue( + result.getMessage().contains("schema list is accessible"), + "got: " + result.getMessage()); + } + + private static Map connectivityProps(boolean enableNamespaceSchema) { + Map props = new HashMap<>(); + props.put(MCConnectorProperties.PROJECT, "mc_project"); + props.put(MCConnectorProperties.ENDPOINT, + "http://service.cn-beijing.maxcompute.aliyun-inc.com/api"); + props.put(MCConnectorProperties.ACCESS_KEY, "access_key"); + props.put(MCConnectorProperties.SECRET_KEY, "secret_key"); + props.put(MCConnectorProperties.ENABLE_NAMESPACE_SCHEMA, + Boolean.toString(enableNamespaceSchema)); + return props; + } + + /** + * Overrides the two ODPS-touching seams so the connectivity test runs offline, mirroring the + * PR's {@code TestMaxComputeExternalCatalog}. {@code projectExists}/{@code threeTierModel} drive + * the simulated remote state; {@code checked*ProjectName} record which validation path ran. + */ + private static final class TestMaxComputeDorisConnector extends MaxComputeDorisConnector { + private boolean projectExists = true; + private boolean threeTierModel = true; + private String checkedProjectName; + private String checkedNamespaceSchemaProjectName; + + private TestMaxComputeDorisConnector(Map props) { + super(props, null); + } + + @Override + protected boolean maxComputeProjectExists(String projectName) { + checkedProjectName = projectName; + return projectExists; + } + + @Override + protected void validateMaxComputeNamespaceSchemaAccess(String projectName) { + checkedNamespaceSchemaProjectName = projectName; + if (!threeTierModel) { + throw new RuntimeException("schema list is not accessible"); + } + } + } +} diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorTransactionTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorTransactionTest.java new file mode 100644 index 00000000000000..174eec4e0538ea --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorTransactionTest.java @@ -0,0 +1,146 @@ +// 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.maxcompute; + +import org.apache.doris.connector.api.DorisConnectorException; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Guards the write block-id cap (GC1 / FIX-BLOCKID-CAP-CONFIG). The cap mirrors legacy + * {@code MCTransaction.allocateBlockIdRange}, which reads the tunable + * {@code Config.max_compute_write_max_block_count}. The connector cannot import fe-core + * {@code Config}, so the live value is surfaced through {@code ConnectorSession.getSessionProperties()} + * (injected by fe-core's {@code ConnectorSessionBuilder}, the same channel as + * {@code lower_case_table_names}) and threaded into the transaction via its constructor. + * + *

    Why this matters. The previous hardcoded {@code MAX_BLOCK_COUNT = 20000L} (DV-011) + * silently ignored a tuned fe.conf: a deployment that raised the cap could no longer run the large + * writes legacy allowed. These tests pin that the cap is now driven by the constructor argument + * (not a constant) and that resolution falls back to the legacy default when the session carries + * no value. The transaction is fe-core-free, so it is exercised directly — no network / live ODPS.

    + */ +public class MaxComputeConnectorTransactionTest { + + private static MaxComputeConnectorTransaction txnWithCap(long maxBlockCount) { + MaxComputeConnectorTransaction txn = new MaxComputeConnectorTransaction(1L, maxBlockCount); + // Only writeSessionId is consulted by allocateWriteBlockRange; identifier/settings (commit-only) may be null. + txn.setWriteSession("sess-1", null, null); + return txn; + } + + // ---- the cap is enforced at exactly maxBlockCount ---- + + @Test + public void testAllocationUpToCapSucceedsAndBeyondThrows() { + MaxComputeConnectorTransaction txn = txnWithCap(5L); + Assertions.assertEquals(0L, txn.allocateWriteBlockRange("sess-1", 3)); // [0,3) + Assertions.assertEquals(3L, txn.allocateWriteBlockRange("sess-1", 2)); // [3,5) -> endExclusive == cap, allowed + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> txn.allocateWriteBlockRange("sess-1", 1)); // 5+1 > 5 + Assertions.assertTrue(ex.getMessage().contains("maxBlockCount=5"), + "the limit error must report the configured cap; got: " + ex.getMessage()); + } + + // ---- the limit is driven by the constructor arg, NOT a hardcoded 20000 ---- + + @Test + public void testCapIsConfigurableNotHardcoded() { + // 8 blocks: rejected under cap 5, allowed under cap 10. A hardcoded 20000 would allow both, + // so this would fail if the cap were still a constant. + MaxComputeConnectorTransaction small = txnWithCap(5L); + Assertions.assertThrows(DorisConnectorException.class, + () -> small.allocateWriteBlockRange("sess-1", 8)); + + MaxComputeConnectorTransaction large = txnWithCap(10L); + Assertions.assertEquals(0L, large.allocateWriteBlockRange("sess-1", 8)); + } + + // ---- resolveMaxBlockCount: present -> parsed; absent / unparseable -> legacy default ---- + + @Test + public void testResolveMaxBlockCountParsesInjectedValue() { + Map props = new HashMap<>(); + props.put("max_compute_write_max_block_count", "50000"); + Assertions.assertEquals(50000L, MaxComputeConnectorMetadata.resolveMaxBlockCount(props)); + } + + @Test + public void testResolveMaxBlockCountFallsBackWhenAbsent() { + Assertions.assertEquals(MaxComputeConnectorTransaction.DEFAULT_MAX_BLOCK_COUNT, + MaxComputeConnectorMetadata.resolveMaxBlockCount(new HashMap<>())); + Assertions.assertEquals(20000L, MaxComputeConnectorTransaction.DEFAULT_MAX_BLOCK_COUNT); + } + + @Test + public void testResolveMaxBlockCountFallsBackWhenUnparseable() { + Map props = new HashMap<>(); + props.put("max_compute_write_max_block_count", "not-a-number"); + Assertions.assertEquals(MaxComputeConnectorTransaction.DEFAULT_MAX_BLOCK_COUNT, + MaxComputeConnectorMetadata.resolveMaxBlockCount(props)); + } + + // ---- reject writing to ODPS external tables / logical views ---- + // Migrated from MCTransaction.beginInsert / MCTransactionTest (PR apache/doris#64119). The write + // path now gates in MaxComputeWritePlanProvider.planWrite via + // MaxComputeTableHandle.checkOperationSupported("Writing") before opening a write session; the + // ODPS Storage API cannot write to external tables or logical views. The guard is exercised + // directly here (the connector test module has no Mockito to fake an ODPS Table). + + @Test + public void testWriteRejectsOdpsExternalTable() { + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> MaxComputeTableHandle.checkOperationSupported( + true, false, "Writing", "default", "mc_external_table")); + Assertions.assertTrue(ex.getMessage().contains( + "Writing MaxCompute external table or logical view is not supported: " + + "default.mc_external_table"), + "got: " + ex.getMessage()); + } + + @Test + public void testWriteRejectsOdpsLogicalView() { + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> MaxComputeTableHandle.checkOperationSupported( + false, true, "Writing", "default", "mc_logical_view")); + Assertions.assertTrue(ex.getMessage().contains( + "Writing MaxCompute external table or logical view is not supported: " + + "default.mc_logical_view"), + "got: " + ex.getMessage()); + } + + @Test + public void testWriteAllowsManagedTable() { + // a normal (non-external, non-view) table must not be rejected (guards against over-rejection) + Assertions.assertDoesNotThrow(() -> MaxComputeTableHandle.checkOperationSupported( + false, false, "Writing", "default", "mc_managed_table")); + } + + @Test + public void testProfileLabelIsMaxCompute() { + // The insert executor maps this label to TransactionType.MAXCOMPUTE for the query profile, + // preserving the pre-unification profiling label after the usesConnectorTransaction fork + // was removed. + Assertions.assertEquals("MAXCOMPUTE", + new MaxComputeConnectorTransaction(1L, 5L).profileLabel()); + } +} diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputePartitionCacheTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputePartitionCacheTest.java new file mode 100644 index 00000000000000..b957bb7a28579a --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputePartitionCacheTest.java @@ -0,0 +1,303 @@ +// 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.maxcompute; + +import org.apache.doris.connector.api.DorisConnectorException; + +import com.aliyun.odps.Odps; +import com.aliyun.odps.OdpsException; +import com.aliyun.odps.Partition; +import com.aliyun.odps.Table; +import com.aliyun.odps.TableSchema; +import com.aliyun.odps.Tables; +import com.aliyun.odps.table.TableIdentifier; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Tests {@link MaxComputePartitionCache}: the connector-owned partition-listing cache (a structural copy of the + * hive connector's {@code HiveFileListingCache}), backed by the shared {@code fe-connector-cache} framework. + * + *

    WHY (Rule 9): after the max_compute cutover the fe-core engine-side external meta cache stops routing to a + * MaxCompute catalog, so without this connector-owned cache every {@code SHOW PARTITIONS} / partition-pruning / + * partition-values call would re-list every partition from ODPS. These tests pin the behaviours that make the + * re-homed cache correct: (1) a partition listing is cached keyed by {@code (db, table)} so it loads once; the + * db / table dimensions never collide; (2) {@code invalidateTable}/{@code invalidateDb}/{@code invalidateAll} + * drop exactly the intended scope (arming REFRESH TABLE / DATABASE / CATALOG); (3) the per-entry + * {@code meta.cache.max_compute.partition.*} knob turns it off; (4) a load failure propagates and is NOT cached. + * Two integration tests prove the metadata's partition-listing methods are served from the cache, so repeated / + * cross-method listings of the same table share ONE ODPS round trip.

    + */ +public class MaxComputePartitionCacheTest { + + // ==================== caching: hit / miss keyed by (db, table) ==================== + + @Test + public void partitionsAreCachedPerTable() { + CountingPartitionLister lister = new CountingPartitionLister(); + MaxComputePartitionCache cache = new MaxComputePartitionCache(Collections.emptyMap(), lister); + + List a = cache.getPartitions("db", "t"); + List b = cache.getPartitions("db", "t"); + // WHY: a hit must serve the cached listing without re-listing ODPS. + Assertions.assertSame(a, b); + Assertions.assertEquals(1, lister.totalCalls); + } + + @Test + public void keyScopedByDbAndTable() { + CountingPartitionLister lister = new CountingPartitionLister(); + MaxComputePartitionCache cache = new MaxComputePartitionCache(Collections.emptyMap(), lister); + + // WHY: (db, table) must both be part of the key so invalidateTable can scope one table — and so two + // tables that share a name across dbs never serve each other's listing. + cache.getPartitions("db1", "t"); + cache.getPartitions("db2", "t"); + cache.getPartitions("db1", "t2"); + Assertions.assertEquals(3, lister.totalCalls); + } + + // ==================== invalidation (arms REFRESH TABLE / DATABASE / CATALOG) ==================== + + @Test + public void invalidateTableDropsOnlyThatTable() { + CountingPartitionLister lister = new CountingPartitionLister(); + MaxComputePartitionCache cache = new MaxComputePartitionCache(Collections.emptyMap(), lister); + + cache.getPartitions("db", "t1"); + cache.getPartitions("db", "t2"); + Assertions.assertEquals(2, lister.totalCalls); + + cache.invalidateTable("db", "t1"); + + // WHY: t1 must re-list after its invalidation... + cache.getPartitions("db", "t1"); + Assertions.assertEquals(2, (int) lister.callsPerTable.get("db/t1")); + // ...while t2's entry (a different table) must survive — invalidateTable is scoped by (db, table). + cache.getPartitions("db", "t2"); + Assertions.assertEquals(1, (int) lister.callsPerTable.get("db/t2")); + } + + @Test + public void invalidateAllDropsEverything() { + CountingPartitionLister lister = new CountingPartitionLister(); + MaxComputePartitionCache cache = new MaxComputePartitionCache(Collections.emptyMap(), lister); + + cache.getPartitions("db", "t1"); + cache.getPartitions("db", "t2"); + Assertions.assertEquals(2, lister.totalCalls); + + cache.invalidateAll(); + + // WHY: every entry must reload after invalidateAll (REFRESH CATALOG). + cache.getPartitions("db", "t1"); + cache.getPartitions("db", "t2"); + Assertions.assertEquals(4, lister.totalCalls); + } + + @Test + public void invalidateDbDropsOneDb() { + CountingPartitionLister lister = new CountingPartitionLister(); + MaxComputePartitionCache cache = new MaxComputePartitionCache(Collections.emptyMap(), lister); + + cache.getPartitions("db1", "t"); + cache.getPartitions("db2", "t"); + Assertions.assertEquals(2, lister.totalCalls); + + cache.invalidateDb("db1"); + + // WHY: only db1's entries reload (REFRESH DATABASE db1)... + cache.getPartitions("db1", "t"); + Assertions.assertEquals(2, (int) lister.callsPerTable.get("db1/t")); + // ...db2's entry survives. + cache.getPartitions("db2", "t"); + Assertions.assertEquals(1, (int) lister.callsPerTable.get("db2/t")); + } + + // ==================== per-entry property knob ==================== + + @Test + public void disablingViaPropsBypassesTheCache() { + CountingPartitionLister lister = new CountingPartitionLister(); + MaxComputePartitionCache cache = new MaxComputePartitionCache( + Collections.singletonMap("meta.cache.max_compute.partition.enable", "false"), lister); + + cache.getPartitions("db", "t"); + cache.getPartitions("db", "t"); + // WHY: meta.cache.max_compute.partition.enable=false must bypass caching entirely — every call re-lists. + Assertions.assertEquals(2, lister.totalCalls); + } + + // ==================== failures are propagated, never cached ==================== + + @Test + public void loadFailureIsNotCached() { + CountingPartitionLister lister = new CountingPartitionLister(); + lister.error = new DorisConnectorException("boom"); + MaxComputePartitionCache cache = new MaxComputePartitionCache(Collections.emptyMap(), lister); + + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> cache.getPartitions("db", "t")); + Assertions.assertEquals("boom", e.getMessage()); + Assertions.assertEquals(1, lister.totalCalls); + // WHY (Rule 9): a failed load must leave NO cache entry, else a momentary ODPS blip would poison the + // listing for the whole TTL (a "catch -> return emptyList" loader mutation would cache an empty listing). + Assertions.assertEquals(0L, cache.size()); + + // WHY: after recovery the next clean call re-lists and succeeds. + lister.error = null; + cache.getPartitions("db", "t"); + Assertions.assertEquals(2, lister.totalCalls); + } + + // ==================== integration: the metadata's partition methods are cache-backed ==================== + + @Test + public void twoListPartitionsShareOneRoundTrip() { + CountingStructureHelper helper = new CountingStructureHelper(); + MaxComputePartitionCache cache = new MaxComputePartitionCache( + Collections.emptyMap(), (db, t) -> helper.getPartitions(null, db, t)); + MaxComputeConnectorMetadata md = new MaxComputeConnectorMetadata( + null, helper, "proj", "ep", "quota", Collections.emptyMap(), cache); + MaxComputeTableHandle handle = new MaxComputeTableHandle("db", "t", null, null); + + md.listPartitions(null, handle, Optional.empty()); + md.listPartitions(null, handle, Optional.empty()); + + // WHY: the second listPartitions is served from the cache — one ODPS round trip, not two. + Assertions.assertEquals(1, helper.getPartitionsCalls); + } + + @Test + public void crossMethodShareOneRoundTrip() { + CountingStructureHelper helper = new CountingStructureHelper(); + MaxComputePartitionCache cache = new MaxComputePartitionCache( + Collections.emptyMap(), (db, t) -> helper.getPartitions(null, db, t)); + MaxComputeConnectorMetadata md = new MaxComputeConnectorMetadata( + null, helper, "proj", "ep", "quota", Collections.emptyMap(), cache); + MaxComputeTableHandle handle = new MaxComputeTableHandle("db", "t", null, null); + + md.listPartitions(null, handle, Optional.empty()); + md.listPartitionNames(null, handle); + + // WHY: listPartitions and listPartitionNames of the SAME table share ONE cached listing (the §5th-cache + // coupling): a partition read warms the other method too. + Assertions.assertEquals(1, helper.getPartitionsCalls); + } + + /** + * A {@link MaxComputePartitionCache.PartitionLister} double: counts calls (total + per {@code db/table}) and + * returns a FRESH empty list per call, so reference identity distinguishes a cache hit from a reload (a real + * {@code Partition} needs a live ODPS client, which the connector test module has no Mockito to fake). Throws + * {@link #error} when set, to exercise the failure-not-cached path. + */ + private static final class CountingPartitionLister implements MaxComputePartitionCache.PartitionLister { + final Map callsPerTable = new HashMap<>(); + int totalCalls; + RuntimeException error; + + @Override + public List list(String dbName, String tableName) { + totalCalls++; + callsPerTable.merge(dbName + "/" + tableName, 1, Integer::sum); + if (error != null) { + throw error; + } + return new ArrayList<>(); + } + } + + /** + * Recording {@link McStructureHelper}: its {@code getPartitions} returns an empty list and increments a + * counter (= the SDK round trip), so an integration test can assert the metadata was served from the cache. + * Every other method returns a harmless default (none is invoked on the partition-listing path). + */ + private static final class CountingStructureHelper implements McStructureHelper { + int getPartitionsCalls; + + @Override + public List getPartitions(Odps mcClient, String dbName, String tableName) { + getPartitionsCalls++; + return Collections.emptyList(); + } + + // ---- unused on the partition-listing path: harmless defaults ---- + + @Override + public List listTableNames(Odps mcClient, String dbName) { + return Collections.emptyList(); + } + + @Override + public List listDatabaseNames(Odps mcClient, String defaultProject) { + return Collections.emptyList(); + } + + @Override + public boolean tableExist(Odps mcClient, String dbName, String tableName) { + return false; + } + + @Override + public boolean databaseExist(Odps mcClient, String dbName) { + return false; + } + + @Override + public TableIdentifier getTableIdentifier(String dbName, String tableName) { + return null; + } + + @Override + public Iterator getPartitionIterator(Odps mcClient, String dbName, String tableName) { + return Collections.emptyIterator(); + } + + @Override + public Table getOdpsTable(Odps mcClient, String dbName, String tableName) { + return null; + } + + @Override + public Tables.TableCreator createTableCreator(Odps mcClient, String dbName, + String tableName, TableSchema schema) { + return null; + } + + @Override + public void dropTable(Odps mcClient, String dbName, String tableName, boolean ifExists) + throws OdpsException { + } + + @Override + public void createDb(Odps mcClient, String dbName, boolean ifNotExists) { + } + + @Override + public void dropDb(Odps mcClient, String dbName, boolean ifExists) { + } + } +} diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputePredicateConverterTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputePredicateConverterTest.java new file mode 100644 index 00000000000000..c2a7096a7632f5 --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputePredicateConverterTest.java @@ -0,0 +1,404 @@ +// 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.maxcompute; + +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorIn; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.ConnectorOr; + +import com.aliyun.odps.OdpsType; +import com.aliyun.odps.table.optimizer.predicate.Predicate; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +/** + * Guards {@link MaxComputePredicateConverter}'s DATETIME / TIMESTAMP / TIMESTAMP_NTZ predicate + * push-down formatting (FIX-DATETIME-PUSHDOWN-FORMAT, GAP0/1). The connector module has no + * fe-core / Mockito, so the converter is exercised directly with hand-built + * {@link ConnectorExpression}s — no network or live ODPS. + * + *

    Why this matters. The literal value for a datetime column arrives as a + * {@link LocalDateTime} (from fe-core's {@code ExprToConnectorExpressionConverter.convertDateLiteral}). + * It must be pushed to ODPS as a space-separated, fixed-precision string in UTC, converted from the + * session time zone — exactly as legacy {@code MaxComputeScanNode.convertLiteralToOdpsValues} + * did. Two regressions are pinned here:

    + *
      + *
    • delta-1 (format): the previous {@code String.valueOf(value)} emitted + * {@link LocalDateTime#toString()}'s 'T'-separated, variable-precision form + * ({@code "2023-02-02T00:00"}), which the space-separated formatter could not parse — so the + * whole conjunct tree silently degraded to {@link Predicate#NO_PREDICATE} (predicate never + * pushed = full scan) on a non-UTC session, or pushed a malformed literal on a UTC session.
    • + *
    • delta-2 (timezone): the source time zone must be the session TZ + * ({@code ConnectorSession.getTimeZone()}), not the project-region TZ; using the wrong base + * shifts the pushed UTC literal and silently loses rows.
    • + *
    + */ +public class MaxComputePredicateConverterTest { + + private static final String UTC = "UTC"; + private static final String SHANGHAI = "Asia/Shanghai"; // fixed +08:00, no DST + // Doris accepts SET time_zone='CST' and stores it verbatim (mapping it to +08:00 via its own + // alias map), but java.time.ZoneId.of("CST") throws ZoneRulesException. + private static final String CST = "CST"; + + private static Map typeMap() { + Map m = new HashMap<>(); + m.put("dt", OdpsType.DATETIME); + m.put("ts", OdpsType.TIMESTAMP); + m.put("ntz", OdpsType.TIMESTAMP_NTZ); + m.put("id", OdpsType.INT); + m.put("amount", OdpsType.INT); + return m; + } + + private static MaxComputePredicateConverter converter(boolean pushDown, String sourceTzId) { + return new MaxComputePredicateConverter(typeMap(), pushDown, sourceTzId); + } + + private static ConnectorComparison eq(String colName, ConnectorLiteral value) { + return new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef(colName, ConnectorType.of("DATETIME")), value); + } + + // ---- delta-1: format the LocalDateTime directly (space-separated, fixed precision) ---- + + @Test + public void testDatetimeFormatsWithSpaceSeparatorAndMillis() { + Predicate p = converter(true, UTC) + .convert(eq("dt", ConnectorLiteral.ofDatetime(LocalDateTime.of(2023, 2, 2, 0, 0, 0)))); + Assertions.assertTrue(p.toString().contains("\"2023-02-02 00:00:00.000\""), + "DATETIME must push a space-separated, 3-digit-fraction literal; got: " + p); + } + + @Test + public void testDatetimeFractionTruncatedToMillis() { + // nanos = 123456000 (.123456); DATETIME scale 3 truncates to .123, matching legacy + // getStringValue(DatetimeV2Type(3)) = microsecond / 1000. + Predicate p = converter(true, UTC).convert( + eq("dt", ConnectorLiteral.ofDatetime(LocalDateTime.of(2023, 2, 2, 0, 0, 0, 123456000)))); + Assertions.assertTrue(p.toString().contains("\"2023-02-02 00:00:00.123\""), + "DATETIME fraction must truncate to 3 digits; got: " + p); + } + + @Test + public void testTimestampFormatsWithMicros() { + Predicate p = converter(true, UTC).convert( + eq("ts", ConnectorLiteral.ofDatetime(LocalDateTime.of(2023, 2, 2, 0, 0, 0, 123456000)))); + Assertions.assertTrue(p.toString().contains("\"2023-02-02 00:00:00.123456\""), + "TIMESTAMP must push a 6-digit fraction; got: " + p); + } + + // ---- delta-1: a non-UTC session must NOT drop the predicate (perf-regression repro) ---- + + @Test + public void testNonUtcDatetimeDoesNotDropPredicate() { + // Before the fix: String.valueOf(LocalDateTime) = "2023-02-02T08:00" -> parse with the + // space-separated formatter throws -> the whole tree degraded to NO_PREDICATE. + Predicate p = converter(true, SHANGHAI) + .convert(eq("dt", ConnectorLiteral.ofDatetime(LocalDateTime.of(2023, 2, 2, 8, 0, 0)))); + Assertions.assertNotSame(Predicate.NO_PREDICATE, p, + "a non-UTC DATETIME predicate must still be pushed down, not dropped"); + } + + // ---- delta-2: the source TZ is the session TZ (DATETIME/TIMESTAMP convert to UTC) ---- + + @Test + public void testDatetimeConvertsSessionTzToUtc() { + // Shanghai 08:00 -> UTC 00:00. Using the wrong source TZ would shift the literal and lose rows. + Predicate p = converter(true, SHANGHAI) + .convert(eq("dt", ConnectorLiteral.ofDatetime(LocalDateTime.of(2023, 2, 2, 8, 0, 0)))); + Assertions.assertTrue(p.toString().contains("\"2023-02-02 00:00:00.000\""), + "session TZ (Shanghai) 08:00 must convert to UTC 00:00; got: " + p); + } + + @Test + public void testTimestampNtzDoesNotConvertTz() { + // TIMESTAMP_NTZ has no timezone: legacy does NOT convert. Shanghai session, local 08:00 + // must stay 08:00 (only formatted), unlike DATETIME / TIMESTAMP. + Predicate p = converter(true, SHANGHAI) + .convert(eq("ntz", ConnectorLiteral.ofDatetime(LocalDateTime.of(2023, 2, 2, 8, 0, 0)))); + Assertions.assertTrue(p.toString().contains("\"2023-02-02 08:00:00.000000\""), + "TIMESTAMP_NTZ must not apply TZ conversion; got: " + p); + } + + // ---- a datetime leaf must not collapse the whole tree ---- + + @Test + public void testMixedAndTreeNotDropped() { + ConnectorComparison idEq = new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef("id", ConnectorType.of("INT")), ConnectorLiteral.ofLong(5)); + // Shanghai 08:00 -> UTC 00:00 (same kept-conjunct check as the dedicated delta-2 test). + ConnectorAnd and = new ConnectorAnd(Arrays.asList(idEq, + eq("dt", ConnectorLiteral.ofDatetime(LocalDateTime.of(2023, 2, 2, 8, 0, 0))))); + Predicate p = converter(true, SHANGHAI).convert(and); + Assertions.assertNotSame(Predicate.NO_PREDICATE, p); + Assertions.assertTrue(p.toString().contains("2023-02-02 00:00:00.000"), + "the AND tree must keep the converted datetime conjunct; got: " + p); + } + + // ---- IN-list datetime goes through the same formatting path ---- + + @Test + public void testDatetimeInListFormatsEachValue() { + // convertIn -> formatLiteralValue: each datetime element must be space-separated formatted. + ConnectorIn in = new ConnectorIn( + new ConnectorColumnRef("dt", ConnectorType.of("DATETIME")), + Arrays.asList( + ConnectorLiteral.ofDatetime(LocalDateTime.of(2023, 2, 2, 0, 0, 0)), + ConnectorLiteral.ofDatetime(LocalDateTime.of(2023, 3, 3, 0, 0, 0))), + false); + String s = converter(true, UTC).convert(in).toString(); + Assertions.assertTrue( + s.contains("\"2023-02-02 00:00:00.000\"") && s.contains("\"2023-03-03 00:00:00.000\""), + "each IN-list datetime element must be space-separated formatted; got: " + s); + } + + // ---- F1: a Doris-valid-but-ZoneId-invalid session zone (e.g. CST) must degrade the datetime + // predicate, NOT throw out of planning, and must NOT block non-datetime pushdown ---- + + @Test + public void testUnparseableSessionZoneDegradesDatetimePredicate() { + // SET time_zone='CST' is accepted by Doris and stored verbatim, but ZoneId.of("CST") throws. + // Lazy parse inside convert()'s catch -> the datetime predicate degrades to NO_PREDICATE + // (BE re-filters) instead of failing the whole query (legacy MaxComputeScanNode parity). + Predicate p = converter(true, CST) + .convert(eq("dt", ConnectorLiteral.ofDatetime(LocalDateTime.of(2023, 2, 2, 0, 0, 0)))); + Assertions.assertSame(Predicate.NO_PREDICATE, p); + } + + @Test + public void testUnparseableSessionZoneStillPushesNonDatetimePredicate() { + // A non-datetime predicate never resolves the zone, so it must still push down under a CST + // session (legacy resolves the zone only inside convertDateTimezone, for datetime literals). + ConnectorComparison idEq = new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef("id", ConnectorType.of("INT")), ConnectorLiteral.ofLong(5)); + Predicate p = converter(true, CST).convert(idEq); + Assertions.assertNotSame(Predicate.NO_PREDICATE, p); + Assertions.assertTrue(p.toString().contains("id"), + "non-datetime predicate must push under a CST session; got: " + p); + } + + @Test + public void testTimestampNtzPushesUnderUnparseableZone() { + // TIMESTAMP_NTZ does no TZ conversion -> never parses the zone -> pushes even under CST. + Predicate p = converter(true, CST) + .convert(eq("ntz", ConnectorLiteral.ofDatetime(LocalDateTime.of(2023, 2, 2, 8, 0, 0)))); + Assertions.assertTrue(p.toString().contains("\"2023-02-02 08:00:00.000000\""), + "TIMESTAMP_NTZ must push (no zone parse) even under a CST session; got: " + p); + } + + // ---- guards ---- + + @Test + public void testNonLocalDateTimeValueDropsPredicate() { + // Defensive: a non-LocalDateTime value for a datetime column -> throw -> caught -> dropped + // (mirrors legacy throwing for a non-DateLiteral, which drops the predicate). + Predicate p = converter(true, UTC).convert(eq("dt", ConnectorLiteral.ofString("2023-02-02 00:00:00"))); + Assertions.assertSame(Predicate.NO_PREDICATE, p); + } + + @Test + public void testPushDownDisabledDropsDatetimePredicate() { + // dateTimePushDown = false -> DATETIME branch falls through -> throw -> dropped (BE filters). + Predicate p = converter(false, UTC) + .convert(eq("dt", ConnectorLiteral.ofDatetime(LocalDateTime.of(2023, 2, 2, 0, 0, 0)))); + Assertions.assertSame(Predicate.NO_PREDICATE, p); + } + + // ---- G2 (FIX-PREDICATE-COLGUARD): a predicate on a column absent from the table schema must + // degrade to NO_PREDICATE (legacy MaxComputeScanNode containsKey-guard parity), NOT push a + // malformed predicate to ODPS. "ghost" is not in typeMap(). ---- + + @Test + public void testUnknownColumnComparisonDropsPredicate() { + // Before the fix, formatLiteralValue quoted the value and pushed `ghost == "5"`; now it + // throws -> convert()'s catch -> NO_PREDICATE (BE re-filters), so no malformed pushdown. + ConnectorComparison cmp = new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef("ghost", ConnectorType.of("INT")), ConnectorLiteral.ofLong(5)); + Predicate p = converter(true, UTC).convert(cmp); + Assertions.assertSame(Predicate.NO_PREDICATE, p, + "a predicate on an unknown column must be dropped, not pushed malformed"); + } + + @Test + public void testUnknownColumnInListDropsPredicate() { + ConnectorIn in = new ConnectorIn( + new ConnectorColumnRef("ghost", ConnectorType.of("INT")), + Arrays.asList(ConnectorLiteral.ofLong(1), ConnectorLiteral.ofLong(2)), + false); + Predicate p = converter(true, UTC).convert(in); + Assertions.assertSame(Predicate.NO_PREDICATE, p, + "an IN predicate on an unknown column must be dropped, not pushed malformed"); + } + + @Test + public void testKnownColumnComparisonStillPushed() { + // Regression guard: the get()!=null path is unaffected — a known column still pushes down. + ConnectorComparison cmp = new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef("id", ConnectorType.of("INT")), ConnectorLiteral.ofLong(5)); + Predicate p = converter(true, UTC).convert(cmp); + Assertions.assertNotSame(Predicate.NO_PREDICATE, p); + Assertions.assertTrue(p.toString().contains("id"), + "a known-column predicate must still push down; got: " + p); + } + + // ---- L9 (FIX-L9): a top-level AND must push its convertible conjuncts even when one conjunct is + // unconvertible, instead of dropping the whole filter (perf; BE re-filters). Only the ROOT AND + // gets per-conjunct tolerance — OR and nested AND stay all-or-nothing so no rows are lost. + // "ghost"/"ghost2" are not in typeMap(), so they are unconvertible. ---- + + private static ConnectorComparison intEq(String col, long value) { + return new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef(col, ConnectorType.of("INT")), ConnectorLiteral.ofLong(value)); + } + + @Test + public void topLevelAndKeepsConvertibleWhenOneConjunctFails() { + // Before the fix: id=5 AND ghost=3 degraded the whole tree to NO_PREDICATE (full scan); + // now id=5 still pushes down and only ghost falls back to BE. + ConnectorAnd and = new ConnectorAnd(Arrays.asList( + intEq("id", 5), intEq("ghost", 3))); + Predicate p = converter(true, UTC).convert(and); + Assertions.assertNotSame(Predicate.NO_PREDICATE, p, + "a convertible conjunct must survive an unconvertible sibling"); + String s = p.toString(); + Assertions.assertTrue(s.contains("id"), "the id conjunct must be pushed; got: " + s); + Assertions.assertFalse(s.contains("ghost"), "the unconvertible conjunct must be dropped; got: " + s); + } + + @Test + public void topLevelAndAllConjunctsFailDropsToNoPredicate() { + ConnectorAnd and = new ConnectorAnd(Arrays.asList( + intEq("ghost", 3), intEq("ghost2", 4))); + Assertions.assertSame(Predicate.NO_PREDICATE, converter(true, UTC).convert(and), + "when every conjunct is unconvertible the filter degrades to NO_PREDICATE"); + } + + @Test + public void topLevelAndSingleSurvivorReturnedWithoutWrappingAnd() { + // One survivor -> return it directly (not wrapped in CompoundPredicate(AND, [x])). + Predicate single = converter(true, UTC).convert(new ConnectorAnd( + Arrays.asList(intEq("id", 5), intEq("ghost", 3)))); + Predicate bare = converter(true, UTC).convert(intEq("id", 5)); + Assertions.assertEquals(bare.toString(), single.toString(), + "a single surviving conjunct must equal the bare predicate; got: " + single); + } + + @Test + public void nestedAndStaysAllOrNothing() { + // id=5 AND (amount=6 AND ghost=3): the nested AND is converted whole, so its failure drops the + // whole nested conjunct -- amount is lost too. Only top-level conjuncts get per-conjunct tolerance. + ConnectorAnd nested = new ConnectorAnd(Arrays.asList( + intEq("amount", 6), intEq("ghost", 3))); + ConnectorAnd and = new ConnectorAnd(Arrays.asList(intEq("id", 5), nested)); + String s = converter(true, UTC).convert(and).toString(); + Assertions.assertTrue(s.contains("id"), "top-level id conjunct must push; got: " + s); + Assertions.assertFalse(s.contains("amount"), + "a nested AND is all-or-nothing: its convertible sibling is dropped with it; got: " + s); + } + + @Test + public void topLevelOrIsNotTolerated() { + // id=5 OR ghost=3: dropping a disjunct would make the predicate a subset and lose rows, so OR is + // all-or-nothing -- one unconvertible disjunct drops the whole OR. + ConnectorOr or = new ConnectorOr(Arrays.asList( + intEq("id", 5), intEq("ghost", 3))); + Assertions.assertSame(Predicate.NO_PREDICATE, converter(true, UTC).convert(or), + "an unconvertible disjunct must drop the whole OR (no row loss)"); + } + + // ---- L20 (FIX-L20): the comparison operator symbol must come from the ODPS SDK's own + // BinaryPredicate.Operator description, not a hand-written table. EQ must push a single "=", + // never Java's "==" (which MaxCompute, like SQL, does not accept -> pushdown lost -> full + // scan). Legacy MaxComputeScanNode used odpsOp.getDescription(); the migration hand-wrote the + // symbols and EQ drifted to "==". "id" is INT in typeMap(), so numeric formatting applies. ---- + + private static String pushedComparison(ConnectorComparison.Operator op) { + ConnectorComparison cmp = new ConnectorComparison(op, + new ConnectorColumnRef("id", ConnectorType.of("INT")), ConnectorLiteral.ofLong(5)); + // RawPredicate.toString() returns the raw pushed string; normalize whitespace (formatLiteralValue + // pads values with surrounding spaces) so the assertion pins the operator, not the spacing. + return converter(true, UTC).convert(cmp).toString().trim().replaceAll("\\s+", " "); + } + + @Test + public void testEqualsEmitsSingleEqualsNotDoubleEquals() { + // RED on the pre-fix code, which emitted "id == 5". The ODPS SDK's EQUALS description is "=". + String raw = converter(true, UTC).convert(new ConnectorComparison(ConnectorComparison.Operator.EQ, + new ConnectorColumnRef("id", ConnectorType.of("INT")), ConnectorLiteral.ofLong(5))).toString(); + Assertions.assertFalse(raw.contains("=="), "EQ must push a single '=', not Java's '=='; got: " + raw); + Assertions.assertEquals("id = 5", raw.trim().replaceAll("\\s+", " "), + "EQ must push 'id = 5'; got: " + raw); + } + + @Test + public void testAllComparisonOperatorsEmitSdkSymbols() { + // Pin the whole operator set to the SDK BinaryPredicate.Operator descriptions so a future + // hand-edit cannot silently drift any symbol again. + Assertions.assertEquals("id = 5", pushedComparison(ConnectorComparison.Operator.EQ)); + Assertions.assertEquals("id != 5", pushedComparison(ConnectorComparison.Operator.NE)); + Assertions.assertEquals("id < 5", pushedComparison(ConnectorComparison.Operator.LT)); + Assertions.assertEquals("id <= 5", pushedComparison(ConnectorComparison.Operator.LE)); + Assertions.assertEquals("id > 5", pushedComparison(ConnectorComparison.Operator.GT)); + Assertions.assertEquals("id >= 5", pushedComparison(ConnectorComparison.Operator.GE)); + } + + @Test + public void testEqForNullIsNotPushedDown() { + // EQ_FOR_NULL ("<=>") has no ODPS BinaryPredicate equivalent: it must degrade to NO_PREDICATE + // (default -> throw -> caught), never be pushed as a malformed "<=>" RawPredicate. BE re-filters. + Predicate p = converter(true, UTC).convert(new ConnectorComparison( + ConnectorComparison.Operator.EQ_FOR_NULL, + new ConnectorColumnRef("id", ConnectorType.of("INT")), ConnectorLiteral.ofLong(5))); + Assertions.assertSame(Predicate.NO_PREDICATE, p, + "EQ_FOR_NULL has no ODPS equivalent and must not be pushed down"); + } + + // ---- P4-3-IN direction regression: the IN-polarity fix pushes `col IN (values)` (column first), + // not the reversed form. This had no dedicated test; pin both IN and NOT IN direction. ---- + + @Test + public void testInListEmitsColumnThenValues() { + ConnectorIn in = new ConnectorIn( + new ConnectorColumnRef("id", ConnectorType.of("INT")), + Arrays.asList(ConnectorLiteral.ofLong(1), ConnectorLiteral.ofLong(2)), + false); + String s = converter(true, UTC).convert(in).toString().trim().replaceAll("\\s+", " "); + Assertions.assertEquals("id IN ( 1 , 2 )", s, "IN must push 'col IN (values)'; got: " + s); + } + + @Test + public void testNotInListEmitsColumnThenNotIn() { + ConnectorIn in = new ConnectorIn( + new ConnectorColumnRef("id", ConnectorType.of("INT")), + Arrays.asList(ConnectorLiteral.ofLong(1), ConnectorLiteral.ofLong(2)), + true); + String s = converter(true, UTC).convert(in).toString().trim().replaceAll("\\s+", " "); + Assertions.assertEquals("id NOT IN ( 1 , 2 )", s, "NOT IN must push 'col NOT IN (values)'; got: " + s); + } +} diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeScanPlanProviderTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeScanPlanProviderTest.java new file mode 100644 index 00000000000000..90d31938e7161b --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeScanPlanProviderTest.java @@ -0,0 +1,345 @@ +// 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.maxcompute; + +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorIn; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.ConnectorOr; + +import com.aliyun.odps.PartitionSpec; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +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; + +/** + * Guards {@link MaxComputeScanPlanProvider}'s pure helpers (the connector module has no + * fe-core / Mockito, so these are exercised directly with no network or live ODPS). + * + *

    Two concerns:

    + *
      + *
    • {@code toPartitionSpecs} — FIX-PRUNE-PUSHDOWN (DG-1): the bridge that turns the engine's + * pruned partition names into ODPS {@link PartitionSpec}s fed to the read session.
    • + *
    • {@code isLimitOptEnabled} / {@code shouldUseLimitOptimization} / + * {@code checkOnlyPartitionEquality} — FIX-LIMIT-SPLIT-DEFAULT (P3-9 / NG-5): the restored + * default-OFF three-gate for the LIMIT-split optimization, mirroring legacy + * {@code MaxComputeScanNode}'s {@code enableMcLimitSplitOptimization && + * onlyPartitionEqualityPredicate && hasLimit()}. Why this matters: the optimization + * collapses the scan into a single row-offset split, so it must fire ONLY when the user + * opted in AND every row in the (pruned) partitions qualifies (no filter, or pure + * partition-column equality) — otherwise it would silently change query planning and, on a + * residual row-level filter, under-read.
    • + *
    + */ +public class MaxComputeScanPlanProviderTest { + + // Literal var-name key — intentionally NOT the prod constant, so a prod-side typo in + // MaxComputeScanPlanProvider.ENABLE_MC_LIMIT_SPLIT_OPTIMIZATION (or drift from + // SessionVariable.ENABLE_MC_LIMIT_SPLIT_OPTIMIZATION) is caught here. + private static final String VAR_KEY = "enable_mc_limit_split_optimization"; + + private static final Set PART_COLS = new HashSet<>(Arrays.asList("pt", "region")); + + private static ConnectorColumnRef col(String name) { + return new ConnectorColumnRef(name, ConnectorType.of("INT")); + } + + private static ConnectorComparison eq(ConnectorExpression left, ConnectorExpression right) { + return new ConnectorComparison(ConnectorComparison.Operator.EQ, left, right); + } + + // ---- toPartitionSpecs (FIX-PRUNE-PUSHDOWN) ---- + + @Test + public void testNullInputMeansScanAll() { + Assertions.assertTrue(MaxComputeScanPlanProvider.toPartitionSpecs(null).isEmpty()); + } + + @Test + public void testEmptyInputMeansScanAll() { + Assertions.assertTrue( + MaxComputeScanPlanProvider.toPartitionSpecs(Collections.emptyList()).isEmpty()); + } + + @Test + public void testConvertsPartitionNamesToSpecs() { + List specs = MaxComputeScanPlanProvider.toPartitionSpecs( + Arrays.asList("pt=1", "pt=2,region=cn")); + + Assertions.assertEquals(2, specs.size()); + + PartitionSpec single = specs.get(0); + Assertions.assertEquals(Collections.singleton("pt"), single.keys()); + Assertions.assertEquals("1", single.get("pt")); + + PartitionSpec multi = specs.get(1); + Assertions.assertEquals("2", multi.get("pt")); + Assertions.assertEquals("cn", multi.get("region")); + } + + // ---- isLimitOptEnabled — gate (1): session var, default OFF ---- + + @Test + public void testLimitOptDisabledWhenVarAbsent() { + // No SET → var not in the session-property map → default OFF (legacy default). + Assertions.assertFalse(MaxComputeScanPlanProvider.isLimitOptEnabled(new HashMap<>())); + } + + @Test + public void testLimitOptEnabledWhenVarTrue() { + Map props = new HashMap<>(); + props.put(VAR_KEY, "true"); + Assertions.assertTrue(MaxComputeScanPlanProvider.isLimitOptEnabled(props)); + } + + @Test + public void testLimitOptDisabledWhenVarFalse() { + Map props = new HashMap<>(); + props.put(VAR_KEY, "false"); + Assertions.assertFalse(MaxComputeScanPlanProvider.isLimitOptEnabled(props)); + } + + // ---- shouldUseLimitOptimization — gate composition ---- + + @Test + public void testGateClosedWhenVarDisabled() { + // Gate (1) off: even with a LIMIT and no filter, the opt stays off. + Assertions.assertFalse(MaxComputeScanPlanProvider.shouldUseLimitOptimization( + false, 10, Optional.empty(), PART_COLS)); + } + + @Test + public void testGateClosedWhenNoLimit() { + // Gate (3) off: enabled var but limit <= 0. + Assertions.assertFalse(MaxComputeScanPlanProvider.shouldUseLimitOptimization( + true, 0, Optional.empty(), PART_COLS)); + } + + @Test + public void testGateOpenWhenEnabledLimitAndNoFilter() { + // Enabled + LIMIT + no predicate → every row qualifies → eligible. + Assertions.assertTrue(MaxComputeScanPlanProvider.shouldUseLimitOptimization( + true, 10, Optional.empty(), PART_COLS)); + } + + @Test + public void testGateOpenWhenEnabledLimitAndPartitionEquality() { + ConnectorExpression filter = eq(col("pt"), ConnectorLiteral.ofInt(1)); + Assertions.assertTrue(MaxComputeScanPlanProvider.shouldUseLimitOptimization( + true, 10, Optional.of(filter), PART_COLS)); + } + + @Test + public void testGateClosedWhenEnabledLimitButNonPartitionFilter() { + ConnectorExpression filter = eq(col("data_col"), ConnectorLiteral.ofInt(5)); + Assertions.assertFalse(MaxComputeScanPlanProvider.shouldUseLimitOptimization( + true, 10, Optional.of(filter), PART_COLS)); + } + + // ---- checkOnlyPartitionEquality — gate (2): predicate shapes ---- + + @Test + public void testSinglePartitionEqualityEligible() { + ConnectorExpression filter = eq(col("pt"), ConnectorLiteral.ofInt(1)); + Assertions.assertTrue( + MaxComputeScanPlanProvider.checkOnlyPartitionEquality(filter, PART_COLS)); + } + + @Test + public void testPartitionInListEligible() { + ConnectorExpression filter = new ConnectorIn(col("region"), + Arrays.asList(ConnectorLiteral.ofString("cn"), ConnectorLiteral.ofString("us")), + false); + Assertions.assertTrue( + MaxComputeScanPlanProvider.checkOnlyPartitionEquality(filter, PART_COLS)); + } + + @Test + public void testAndOfPartitionEqualitiesEligible() { + ConnectorExpression filter = new ConnectorAnd(Arrays.asList( + eq(col("pt"), ConnectorLiteral.ofInt(1)), + eq(col("region"), ConnectorLiteral.ofString("cn")))); + Assertions.assertTrue( + MaxComputeScanPlanProvider.checkOnlyPartitionEquality(filter, PART_COLS)); + } + + @Test + public void testAndWithNonPartitionConjunctIneligible() { + // One conjunct on a data column → the whole AND is ineligible (legacy parity). + ConnectorExpression filter = new ConnectorAnd(Arrays.asList( + eq(col("pt"), ConnectorLiteral.ofInt(1)), + eq(col("data_col"), ConnectorLiteral.ofInt(5)))); + Assertions.assertFalse( + MaxComputeScanPlanProvider.checkOnlyPartitionEquality(filter, PART_COLS)); + } + + @Test + public void testDataColumnEqualityIneligible() { + ConnectorExpression filter = eq(col("data_col"), ConnectorLiteral.ofInt(5)); + Assertions.assertFalse( + MaxComputeScanPlanProvider.checkOnlyPartitionEquality(filter, PART_COLS)); + } + + @Test + public void testNonEqOperatorOnPartitionIneligible() { + ConnectorExpression filter = new ConnectorComparison( + ConnectorComparison.Operator.GT, col("pt"), ConnectorLiteral.ofInt(1)); + Assertions.assertFalse( + MaxComputeScanPlanProvider.checkOnlyPartitionEquality(filter, PART_COLS)); + } + + @Test + public void testNotInOnPartitionIneligible() { + ConnectorExpression filter = new ConnectorIn(col("pt"), + Arrays.asList(ConnectorLiteral.ofInt(1), ConnectorLiteral.ofInt(2)), + true); + Assertions.assertFalse( + MaxComputeScanPlanProvider.checkOnlyPartitionEquality(filter, PART_COLS)); + } + + @Test + public void testInWithNonLiteralElementIneligible() { + ConnectorExpression filter = new ConnectorIn(col("pt"), + Arrays.asList(ConnectorLiteral.ofInt(1), col("region")), + false); + Assertions.assertFalse( + MaxComputeScanPlanProvider.checkOnlyPartitionEquality(filter, PART_COLS)); + } + + @Test + public void testLiteralOnLeftIneligible() { + // Mirror legacy: only `col = literal`, not `literal = col`. + ConnectorExpression filter = eq(ConnectorLiteral.ofInt(1), col("pt")); + Assertions.assertFalse( + MaxComputeScanPlanProvider.checkOnlyPartitionEquality(filter, PART_COLS)); + } + + @Test + public void testPartitionColumnEqualsPartitionColumnIneligible() { + // `pt = region`: left is a valid partition col-ref (reaches the RHS check), but the RHS + // is a column-ref, not a literal → ineligible. Guards the right-side literal check + // (legacy MaxComputeScanNode:346 requires child(1) instanceof LiteralExpr). + ConnectorExpression filter = eq(col("pt"), col("region")); + Assertions.assertFalse( + MaxComputeScanPlanProvider.checkOnlyPartitionEquality(filter, PART_COLS)); + } + + @Test + public void testInValueDataColumnIneligible() { + // `data_col IN ('a','b')`: the IN value column is NOT a partition column → ineligible. + // Guards the IN-value partition-column check (legacy MaxComputeScanNode:358-364 requires + // child(0) be a partition-column SlotRef). Without this guard a residual data-column IN + // filter would wrongly enable the single-split row-offset path and silently under-read. + ConnectorExpression filter = new ConnectorIn(col("data_col"), + Arrays.asList(ConnectorLiteral.ofString("a"), ConnectorLiteral.ofString("b")), + false); + Assertions.assertFalse( + MaxComputeScanPlanProvider.checkOnlyPartitionEquality(filter, PART_COLS)); + } + + @Test + public void testEqForNullOnPartitionIneligible() { + // `pt <=> 1` (EQ_FOR_NULL): only plain EQ is eligible (legacy requires Operator.EQ). + ConnectorExpression filter = new ConnectorComparison( + ConnectorComparison.Operator.EQ_FOR_NULL, col("pt"), ConnectorLiteral.ofInt(1)); + Assertions.assertFalse( + MaxComputeScanPlanProvider.checkOnlyPartitionEquality(filter, PART_COLS)); + } + + @Test + public void testBothLiteralsComparisonIneligible() { + // `1 = 2`: left is not a column-ref → ineligible. + ConnectorExpression filter = eq(ConnectorLiteral.ofInt(1), ConnectorLiteral.ofInt(2)); + Assertions.assertFalse( + MaxComputeScanPlanProvider.checkOnlyPartitionEquality(filter, PART_COLS)); + } + + @Test + public void testAndContainingNonLeafConjunctIneligible() { + // `pt=1 AND (pt=1 OR region='cn')`: the OR conjunct is neither a comparison nor an IN → + // isPartitionEqualityLeaf rejects it → the whole AND is ineligible. + ConnectorExpression or = new ConnectorOr(Arrays.asList( + eq(col("pt"), ConnectorLiteral.ofInt(1)), + eq(col("region"), ConnectorLiteral.ofString("cn")))); + ConnectorExpression filter = new ConnectorAnd(Arrays.asList( + eq(col("pt"), ConnectorLiteral.ofInt(1)), or)); + Assertions.assertFalse( + MaxComputeScanPlanProvider.checkOnlyPartitionEquality(filter, PART_COLS)); + } + + @Test + public void testEmptyInListMatchesLegacyEligible() { + // `pt IN ()` on a partition column → eligible (the all-literal loop is vacuously true). + // Mirrors legacy MaxComputeScanNode:365 (its literal loop is also vacuous on an empty + // list). Unreachable in practice — Nereids folds an empty IN to FALSE before pushdown — + // and the converted filterPredicate is still applied to the read session as a backstop. + // Pinned to document the deliberate legacy-parity choice. + ConnectorExpression filter = new ConnectorIn(col("pt"), + Collections.emptyList(), false); + Assertions.assertTrue( + MaxComputeScanPlanProvider.checkOnlyPartitionEquality(filter, PART_COLS)); + } + + // ---- reject reading ODPS external tables / logical views ---- + // Migrated from MaxComputeScanNode.getSplits / MaxComputeScanNodeTest (PR apache/doris#64119). + // planScan now gates via MaxComputeTableHandle.checkOperationSupported("Reading") before any + // split generation; the ODPS Storage API cannot scan external tables or logical views. The guard + // is exercised directly here (the connector test module has no Mockito to fake an ODPS Table). + + @Test + public void testReadRejectsOdpsExternalTable() { + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> MaxComputeTableHandle.checkOperationSupported( + true, false, "Reading", "default", "mc_external_table")); + Assertions.assertTrue(ex.getMessage().contains( + "Reading MaxCompute external table or logical view is not supported: " + + "default.mc_external_table"), + "got: " + ex.getMessage()); + } + + @Test + public void testReadRejectsOdpsLogicalView() { + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> MaxComputeTableHandle.checkOperationSupported( + false, true, "Reading", "default", "mc_logical_view")); + Assertions.assertTrue(ex.getMessage().contains( + "Reading MaxCompute external table or logical view is not supported: " + + "default.mc_logical_view"), + "got: " + ex.getMessage()); + } + + @Test + public void testReadAllowsManagedTable() { + // a normal (non-external, non-view) table must not be rejected (guards against over-rejection) + Assertions.assertDoesNotThrow(() -> MaxComputeTableHandle.checkOperationSupported( + false, false, "Reading", "default", "mc_managed_table")); + } +} diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeScanRangeTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeScanRangeTest.java new file mode 100644 index 00000000000000..8c646f5f87aef7 --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeScanRangeTest.java @@ -0,0 +1,231 @@ +// 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.maxcompute; + +import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.thrift.TFileRangeDesc; +import org.apache.doris.thrift.TTableFormatFileDesc; + +import com.aliyun.odps.table.DataFormat; +import com.aliyun.odps.table.DataSchema; +import com.aliyun.odps.table.SessionStatus; +import com.aliyun.odps.table.TableIdentifier; +import com.aliyun.odps.table.read.TableBatchReadSession; +import com.aliyun.odps.table.read.split.InputSplit; +import com.aliyun.odps.table.read.split.InputSplitAssigner; +import com.aliyun.odps.table.read.split.impl.IndexedInputSplit; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; + +/** + * FIX-READ-SPLIT (P4-T06d) — guards the BYTE_SIZE split-size sentinel produced by + * {@link MaxComputeScanPlanProvider}'s byte_size branch. + * + *

    WHY this matters: BE has no {@code split_type} field on the wire — it classifies a + * MaxCompute split purely by the numeric {@code split_size} it receives. {@code MaxComputeJniScanner} + * does {@code if (splitSize == -1) BYTE_SIZE else ROW_OFFSET} (MaxComputeJniScanner.java:125-128), + * then in {@code open()} builds {@code IndexedInputSplit} (BYTE_SIZE) or + * {@code RowRangeInputSplit(sessionId, startOffset, splitSize)} (ROW_OFFSET). If a byte_size split + * carries a real byte count (e.g. 268435456) instead of {@code -1}, BE silently mis-reads it as a + * ROW_OFFSET split and returns CORRUPT data (no error). So the provider's byte_size branch MUST emit + * size {@code -1}; this mirrors legacy {@code MaxComputeScanNode}'s + * {@code MaxComputeSplit(..., length=-1, fileLength=splitByteSize, ...)}.

    + * + *

    This test drives the PROVIDER's real byte_size split-building code + * ({@code buildSplitsFromSession}) with offline fakes (no network, no live ODPS) — so it locks the + * provider's CHOICE of {@code -1}, not merely the range mechanism. Reverting the byte_size branch to + * {@code .length(splitByteSize)} makes {@code byteSizeBranchEmitsMinusOneSizeSentinel} FAIL + * (getSize() would become the real byte size). The row_offset case is the contrast that proves only + * byte_size uses the sentinel — its size is the real row count, never {@code -1}.

    + * + *

    The connector module has no fe-core / Mockito; we reach the private split-building method via + * reflection and stub the ODPS {@code TableBatchReadSession} / {@code InputSplitAssigner} with plain + * Serializable fakes ({@code serializeSession} writes the session, so it must be Serializable).

    + */ +public class MaxComputeScanRangeTest { + + private static final long SPLIT_BYTE_SIZE = 268435456L; // ODPS default byte-size split + + @Test + public void byteSizeBranchEmitsMinusOneSizeSentinel() throws Exception { + // Build via the provider's REAL byte_size branch. + ConnectorScanRange range = buildSingleRange( + MCConnectorProperties.SPLIT_BY_BYTE_SIZE_STRATEGY, + new FakeSession(new FakeAssigner(SplitKind.BYTE_SIZE))); + + TFileRangeDesc rangeDesc = populate(range); + + // The whole point of the fix: BE distinguishes BYTE_SIZE from ROW_OFFSET by size == -1. + // If the provider reverts to .length(splitByteSize) this assertion fails with 268435456, + // which is exactly the corrupt-read bug (BE would treat it as ROW_OFFSET row count). + Assertions.assertEquals(-1L, rangeDesc.getSize(), + "byte_size split must carry size == -1 sentinel; any real byte count makes BE " + + "mis-classify it as ROW_OFFSET and read corrupt data"); + // start is the split index (set by the byte_size branch), unaffected by the sentinel. + Assertions.assertEquals(7L, rangeDesc.getStartOffset(), + "byte_size split start must be the IndexedInputSplit splitIndex"); + // path mirrors legacy "[ splitIndex , -1 ]". + Assertions.assertEquals("[ 7 , -1 ]", rangeDesc.getPath(), + "byte_size split path must mirror legacy '[ splitIndex , -1 ]'"); + } + + @Test + public void rowOffsetBranchKeepsRealRowCount() throws Exception { + // Contrast: the row_offset branch must NOT use the sentinel; it sends the real row count + // so BE builds RowRangeInputSplit(sessionId, startOffset, splitSize). This locks the intent + // that ONLY byte_size uses -1 — guarding against an over-broad "set everything to -1" fix. + ConnectorScanRange range = buildSingleRange( + MCConnectorProperties.SPLIT_BY_ROW_COUNT_STRATEGY, + new FakeSession(new FakeAssigner(SplitKind.ROW_OFFSET))); + + TFileRangeDesc rangeDesc = populate(range); + + Assertions.assertEquals(FakeAssigner.ROW_COUNT, rangeDesc.getSize(), + "row_offset split must carry the real row count (BE reads it as RowRangeInputSplit " + + "size), never the -1 byte_size sentinel"); + } + + /** + * Invokes the provider's private {@code buildSplitsFromSession} (which contains the byte_size / + * row_offset branches under test) with a stubbed session, returning the single produced range. + */ + private static ConnectorScanRange buildSingleRange(String strategy, TableBatchReadSession session) + throws Exception { + MaxComputeScanPlanProvider provider = newUninitializedProvider(); + setField(provider, "splitStrategy", strategy); + setField(provider, "splitByteSize", SPLIT_BYTE_SIZE); + setField(provider, "splitRowCount", FakeAssigner.ROW_COUNT); + setField(provider, "readTimeout", 120); + setField(provider, "connectTimeout", 10); + setField(provider, "retryTimes", 4); + + Method m = MaxComputeScanPlanProvider.class.getDeclaredMethod( + "buildSplitsFromSession", TableBatchReadSession.class, com.aliyun.odps.Table.class); + m.setAccessible(true); + @SuppressWarnings("unchecked") + List ranges = + (List) m.invoke(provider, session, null); + Assertions.assertEquals(1, ranges.size(), "fake assigner yields exactly one split"); + return ranges.get(0); + } + + /** Constructs the provider without running ctor logic / property init (we set fields directly). */ + private static MaxComputeScanPlanProvider newUninitializedProvider() throws Exception { + // The ctor only stores the connector reference; buildSplitsFromSession never touches it. + return new MaxComputeScanPlanProvider(null); + } + + private static TFileRangeDesc populate(ConnectorScanRange range) { + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(formatDesc, rangeDesc); + return rangeDesc; + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field f = MaxComputeScanPlanProvider.class.getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } + + private enum SplitKind { BYTE_SIZE, ROW_OFFSET } + + /** Serializable stub session — {@code serializeSession} writes it, so it must serialize. */ + private static final class FakeSession implements TableBatchReadSession { + private static final long serialVersionUID = 1L; + private final transient InputSplitAssigner assigner; + + FakeSession(InputSplitAssigner assigner) { + this.assigner = assigner; + } + + // The only method the split-building path under test actually calls. + @Override + public InputSplitAssigner getInputSplitAssigner() { + return assigner; + } + + // Remaining abstract methods are never reached at plan time (read/reader paths only). + @Override + public DataSchema readSchema() { + throw new UnsupportedOperationException("not used in plan-time test"); + } + + @Override + public boolean supportsDataFormat(DataFormat dataFormat) { + throw new UnsupportedOperationException("not used in plan-time test"); + } + + @Override + public String toJson() { + throw new UnsupportedOperationException("not used in plan-time test"); + } + + @Override + public String getId() { + return FakeAssigner.SESSION_ID; + } + + @Override + public TableIdentifier getTableIdentifier() { + throw new UnsupportedOperationException("not used in plan-time test"); + } + + @Override + public SessionStatus getStatus() { + throw new UnsupportedOperationException("not used in plan-time test"); + } + } + + /** Stub assigner producing one split of the requested kind. */ + private static final class FakeAssigner implements InputSplitAssigner { + private static final long serialVersionUID = 1L; + static final String SESSION_ID = "fake-session"; + static final long ROW_COUNT = 1000L; + private final SplitKind kind; + + FakeAssigner(SplitKind kind) { + this.kind = kind; + } + + @Override + public int getSplitsCount() { + return 1; + } + + @Override + public InputSplit[] getAllSplits() { + // BYTE_SIZE branch casts to IndexedInputSplit and reads getSplitIndex(). + return new InputSplit[] {new IndexedInputSplit(SESSION_ID, 7)}; + } + + @Override + public long getTotalRowCount() { + return ROW_COUNT; // one split: offset 0, count ROW_COUNT + } + + @Override + public InputSplit getSplitByRowOffset(long offset, long count) { + return new IndexedInputSplit(SESSION_ID, (int) offset); + } + } +} diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeValidateColumnsTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeValidateColumnsTest.java new file mode 100644 index 00000000000000..1d9230adfdd0d6 --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeValidateColumnsTest.java @@ -0,0 +1,108 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.maxcompute; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +/** + * Pins that MaxCompute CREATE TABLE rejects columns it cannot store: AUTO_INCREMENT + * (P2-8 FIX-AUTOINC-REJECT) and aggregate columns like SUM (G5 FIX-AGG-COLUMN-REJECT), + * mirroring legacy MaxComputeMetadataOps.validateColumns:422-429. + * + *

    WHY this matters: MaxCompute cannot store auto-increment columns. Legacy + * {@code MaxComputeMetadataOps.validateColumns:422-425} threw a clear error; after the SPI + * cutover the flag was dropped silently (the {@code ConnectorColumn} carrier had no + * {@code isAutoInc} field), so {@code CREATE TABLE (id INT AUTO_INCREMENT)} silently created a + * plain column — a data-model regression where the user's intent vanishes without warning. This + * fix re-carries the flag and re-rejects it connector-side. These tests lock that in.

    + * + *

    {@code validateColumns} is package-private (reached only via {@code createTable} in + * production, which needs a live ODPS handle); this connector test module has no Mockito, so the + * test constructs the metadata offline with {@code null} odps/structureHelper and calls + * {@code validateColumns} directly — it dereferences neither (only the static + * {@code MCTypeMapping.toMcType}). Same offline idiom as {@link MaxComputeBuildTableDescriptorTest}.

    + */ +public class MaxComputeValidateColumnsTest { + + private MaxComputeConnectorMetadata metadata() { + return new MaxComputeConnectorMetadata( + null, null, "proj", "ep", "quota", Collections.emptyMap(), + null); // null: partition cache unused by this test + } + + @Test + public void autoIncColumnIsRejected() { + ConnectorColumn autoInc = new ConnectorColumn( + "id", ConnectorType.of("INT"), "", false, null, false, true); + + // WHY (Rule 9): silent acceptance drops the user's AUTO_INCREMENT intent (MaxCompute can't + // store it); legacy rejected it loudly. MUTATION: removing the `if (col.isAutoInc()) throw` + // block makes this go red (no exception). + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata().validateColumns(Collections.singletonList(autoInc))); + Assertions.assertTrue( + ex.getMessage().contains("Auto-increment columns are not supported for MaxCompute tables: id"), + "rejection message must name the offending column, mirroring legacy validateColumns"); + } + + @Test + public void nonAutoIncColumnPasses() { + ConnectorColumn plain = new ConnectorColumn( + "id", ConnectorType.of("INT"), "", false, null, false, false); + + // WHY: guards against over-rejection -- a normal column must still validate; the gate must + // key on the auto-inc flag, not reject every column. + Assertions.assertDoesNotThrow( + () -> metadata().validateColumns(Collections.singletonList(plain))); + } + + @Test + public void aggregatedColumnIsRejected() { + ConnectorColumn aggregated = new ConnectorColumn( + "c", ConnectorType.of("INT"), "", false, null, false, false, true); + + // WHY (Rule 9): MaxCompute has no aggregate-key model; legacy + // MaxComputeMetadataOps.validateColumns:426-429 rejected aggregate columns loudly. The + // nereids non-OLAP path does not (validateKeyColumns is ENGINE_OLAP-gated), so silent + // acceptance drops the user's aggregate intent to a plain column. MUTATION: removing the + // `if (col.isAggregated()) throw` block makes this go red (no exception). + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata().validateColumns(Collections.singletonList(aggregated))); + Assertions.assertTrue( + ex.getMessage().contains("Aggregation columns are not supported for MaxCompute tables: c"), + "rejection message must name the offending column, mirroring legacy validateColumns"); + } + + @Test + public void nonAggregatedColumnPasses() { + ConnectorColumn plain = new ConnectorColumn( + "c", ConnectorType.of("INT"), "", false, null, false, false, false); + + // WHY: guards against over-rejection -- a normal column must still validate; the gate must + // key on the isAggregated flag, not reject every column. + Assertions.assertDoesNotThrow( + () -> metadata().validateColumns(Collections.singletonList(plain))); + } +} diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeWritePlanProviderTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeWritePlanProviderTest.java new file mode 100644 index 00000000000000..8da2493b934904 --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeWritePlanProviderTest.java @@ -0,0 +1,58 @@ +// 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.maxcompute; + +import org.apache.doris.connector.api.handle.WriteOperation; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.EnumSet; +import java.util.HashMap; + +/** + * Pins {@link MaxComputeWritePlanProvider}'s write capability declarations (the connector module has + * no fe-core / Mockito, so the provider is constructed directly with no network or live ODPS — + * {@code planWrite} is the only method that touches the connector's initialized state). + * + *

    WHY this matters: the write plan provider is now the single source of truth for a + * connector's write capabilities (supportedOperations + the sink-trait defaults from + * {@code ConnectorWritePlanProvider}). MaxCompute supports INSERT/OVERWRITE only (no DELETE/MERGE), + * requires parallel write, full-schema write order, and partition-local sort — but does NOT support a + * write-targeted branch and does NOT require materializing static partition values, so those two + * traits stay at their interface default (false).

    + */ +public class MaxComputeWritePlanProviderTest { + + private static MaxComputeWritePlanProvider provider() { + return new MaxComputeWritePlanProvider(new MaxComputeDorisConnector(new HashMap<>(), null)); + } + + @Test + public void declaresInsertOverwriteAndSinkTraits() { + MaxComputeWritePlanProvider p = provider(); + + Assertions.assertEquals(EnumSet.of(WriteOperation.INSERT, WriteOperation.OVERWRITE), p.supportedOperations()); + Assertions.assertTrue(p.requiresParallelWrite()); + Assertions.assertTrue(p.requiresFullSchemaWriteOrder()); + Assertions.assertTrue(p.requiresPartitionLocalSort()); + Assertions.assertFalse(p.supportsWriteBranch(), "MaxCompute does not support a write-targeted branch"); + Assertions.assertFalse(p.requiresMaterializeStaticPartitionValues(), + "MaxCompute does not require materializing static partition values"); + } +} diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/OdpsClassloaderIsolationTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/OdpsClassloaderIsolationTest.java new file mode 100644 index 00000000000000..9776681008d718 --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/OdpsClassloaderIsolationTest.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.maxcompute; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * R-004 part 1 — defensive test that the ODPS SDK loads and constructs an Odps client when the + * MaxCompute connector is loaded under an isolated, child-first class loader (no credentials, no + * network, CI-runnable). + * + *

    In production the connector runs inside {@code ConnectorPluginManager}'s plugin isolation, + * where {@code org.apache.doris.connector.} / {@code org.apache.doris.filesystem.} are parent-first + * (the shared SPI) while the connector impl and its third-party deps — including the ODPS SDK + * ({@code com.aliyun.odps.*}) — load child-first, getting an isolated copy per plugin. Risk R-004 is + * that loading the ODPS SDK in such isolation breaks (NoClassDefFoundError / ClassCastException) or + * that a per-plugin SDK copy poisons a process-wide singleton.

    + * + *

    This test reproduces the risk with a deliberately stricter loader: everything outside the JDK + * is child-first, so the connector class and the whole ODPS SDK are defined by the isolated loader. + * That is a superset of production isolation for the SDK, so passing here covers the production + * policy. It asserts: (1) two isolated loaders define distinct connector classes (no shared static + * state across plugins); (2) {@code createClient} builds an {@code Odps} under isolation with no + * linkage error; (3) the SDK class is defined by the isolated loader, not leaked from the app loader; + * (4) the SDK class differs across loaders (isolated, not a shared singleton).

    + */ +public class OdpsClassloaderIsolationTest { + + private static final String FACTORY = + "org.apache.doris.connector.maxcompute.MCConnectorClientFactory"; + + @Test + public void odpsClientConstructsUnderIsolatedChildFirstLoaderWithoutLeak() throws Exception { + URL[] classpath = classpathUrls(); + // AK/SK auth builds the client fully offline (new AliyunAccount + new Odps; no network). + Map props = new HashMap<>(); + props.put(MCConnectorProperties.ACCESS_KEY, "test-ak"); + props.put(MCConnectorProperties.SECRET_KEY, "test-sk"); + + try (IsolatedChildFirstClassLoader loaderA = new IsolatedChildFirstClassLoader(classpath); + IsolatedChildFirstClassLoader loaderB = new IsolatedChildFirstClassLoader(classpath)) { + + Object odpsA = createIsolatedClient(loaderA, props); + Object odpsB = createIsolatedClient(loaderB, props); + + Class factoryA = loaderA.loadClass(FACTORY); + Assertions.assertNotSame(MCConnectorClientFactory.class, factoryA, + "the isolated loader must define its own connector class, not reuse the app one"); + Assertions.assertNotSame(factoryA, loaderB.loadClass(FACTORY), + "two isolated plugin loaders must not share connector class identity"); + + Assertions.assertEquals("com.aliyun.odps.Odps", odpsA.getClass().getName(), + "createClient must build an ODPS client even under classloader isolation"); + Assertions.assertSame(loaderA, odpsA.getClass().getClassLoader(), + "the ODPS SDK class must be defined by the isolated loader, not leaked from the app loader"); + Assertions.assertNotSame(odpsA.getClass(), odpsB.getClass(), + "the ODPS SDK must be isolated per plugin — no shared singleton class across loaders"); + } + } + + /** Loads {@code MCConnectorClientFactory} through {@code loader} and builds an Odps reflectively. */ + private static Object createIsolatedClient(ClassLoader loader, Map props) + throws Exception { + Class factory = loader.loadClass(FACTORY); + Assertions.assertSame(loader, factory.getClassLoader(), + "sanity: the connector factory must be defined by the isolated loader"); + Method createClient = factory.getMethod("createClient", Map.class); + Object odps = createClient.invoke(null, props); + Assertions.assertNotNull(odps, "createClient must return a non-null ODPS client"); + return odps; + } + + private static URL[] classpathUrls() throws Exception { + String classpath = System.getProperty("java.class.path"); + String[] entries = classpath.split(File.pathSeparator); + List urls = new ArrayList<>(entries.length); + for (String entry : entries) { + if (!entry.isEmpty()) { + urls.add(new File(entry).toURI().toURL()); + } + } + return urls.toArray(new URL[0]); + } + + /** + * Child-first loader: defines every non-JDK class from its own URLs (delegating only JDK + * packages to the parent), mirroring — and exceeding — the plugin isolation the connector runs + * under in production. + */ + private static final class IsolatedChildFirstClassLoader extends URLClassLoader { + + IsolatedChildFirstClassLoader(URL[] urls) { + // Parent is the JDK-only loader, so connector + SDK classes fall through to this loader. + super(urls, ClassLoader.getSystemClassLoader().getParent()); + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + if (loaded == null) { + if (isJdkClass(name)) { + loaded = super.loadClass(name, false); + } else { + try { + loaded = findClass(name); + } catch (ClassNotFoundException notLocal) { + loaded = super.loadClass(name, false); + } + } + } + if (resolve) { + resolveClass(loaded); + } + return loaded; + } + } + + private static boolean isJdkClass(String name) { + return name.startsWith("java.") || name.startsWith("javax.") + || name.startsWith("jdk.") || name.startsWith("sun.") + || name.startsWith("com.sun.") || name.startsWith("org.w3c.") + || name.startsWith("org.xml."); + } + } +} diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/OdpsLiveConnectivityTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/OdpsLiveConnectivityTest.java new file mode 100644 index 00000000000000..d7a2f1233d9fb2 --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/OdpsLiveConnectivityTest.java @@ -0,0 +1,66 @@ +// 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.maxcompute; + +import com.aliyun.odps.Odps; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * R-004 part 2 — live ODPS connectivity smoke (credentials required; user-run). + * + *

    Complements {@link OdpsClassloaderIsolationTest} (part 1, no-creds isolation correctness): this + * one confirms the client built by {@link MCConnectorClientFactory} can actually reach a real + * MaxCompute endpoint and authenticate. It is skipped unless all four environment variables + * below are set, so it is inert in CI and never commits credentials. The cutover is declared complete + * only after a maintainer reports this green.

    + * + *
    + *   MC_ENDPOINT=https://service.<region>.maxcompute.aliyun.com/api \
    + *   MC_PROJECT=<project> MC_ACCESS_KEY=<ak> MC_SECRET_KEY=<sk> \
    + *   mvn -pl :fe-connector-maxcompute test -Dtest=OdpsLiveConnectivityTest
    + * 
    + */ +public class OdpsLiveConnectivityTest { + + @Test + public void liveMetadataRoundTrip() { + String endpoint = System.getenv("MC_ENDPOINT"); + String project = System.getenv("MC_PROJECT"); + String accessKey = System.getenv("MC_ACCESS_KEY"); + String secretKey = System.getenv("MC_SECRET_KEY"); + Assumptions.assumeTrue( + endpoint != null && project != null && accessKey != null && secretKey != null, + "skipped: set MC_ENDPOINT / MC_PROJECT / MC_ACCESS_KEY / MC_SECRET_KEY to run live"); + + Map props = new HashMap<>(); + props.put(MCConnectorProperties.ACCESS_KEY, accessKey); + props.put(MCConnectorProperties.SECRET_KEY, secretKey); + + Odps odps = MCConnectorClientFactory.createClient(props); + odps.setEndpoint(endpoint); + odps.setDefaultProject(project); + + // One trivial metadata round-trip exercises endpoint + auth end to end. + Assertions.assertDoesNotThrow(() -> odps.projects().get(project).reload()); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-api/pom.xml b/fe/fe-connector/fe-connector-metastore-api/pom.xml new file mode 100644 index 00000000000000..947712eb0bdff4 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-api/pom.xml @@ -0,0 +1,73 @@ + + + + 4.0.0 + + + org.apache.doris + fe-connector + ${revision} + ../pom.xml + + + fe-connector-metastore-api + jar + Doris FE Connector Metastore API + + Thin, neutral contract for connector metastore connection properties + (MetaStoreProperties + HMS/DLF/REST/JDBC/FileSystem sub-interfaces). + Exposes only neutral Map/scalar facts; never leaks HiveConf/Hadoop/SDK + types. Backends are identified by providerName() + capability methods, + not a per-backend enum (D-006). Depends on fe-kerberos for the neutral + AuthType / KerberosAuthSpec facts (D-013). + + + + + ${project.groupId} + fe-kerberos + ${project.version} + + + org.junit.jupiter + junit-jupiter + test + + + + + doris-fe-connector-metastore-api + + + org.apache.maven.plugins + maven-dependency-plugin + + + + copy-plugin-deps + none + + + + + + diff --git a/fe/fe-connector/fe-connector-metastore-api/src/main/java/org/apache/doris/connector/metastore/FileSystemMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-api/src/main/java/org/apache/doris/connector/metastore/FileSystemMetaStoreProperties.java new file mode 100644 index 00000000000000..a3a9dc87a156c4 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-api/src/main/java/org/apache/doris/connector/metastore/FileSystemMetaStoreProperties.java @@ -0,0 +1,28 @@ +// 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.metastore; + +/** + * Neutral connection facts for a filesystem (warehouse-only) metastore backend. The bound storage + * config travels separately as {@code List}; {@link #needsStorage()} returns true. + */ +public interface FileSystemMetaStoreProperties extends MetaStoreProperties { + + /** The warehouse root location for the filesystem catalog. */ + String getWarehouse(); +} diff --git a/fe/fe-connector/fe-connector-metastore-api/src/main/java/org/apache/doris/connector/metastore/HmsMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-api/src/main/java/org/apache/doris/connector/metastore/HmsMetaStoreProperties.java new file mode 100644 index 00000000000000..eacc602bb74aab --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-api/src/main/java/org/apache/doris/connector/metastore/HmsMetaStoreProperties.java @@ -0,0 +1,54 @@ +// 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.metastore; + +import org.apache.doris.kerberos.AuthType; +import org.apache.doris.kerberos.KerberosAuthSpec; + +import java.util.Map; +import java.util.Optional; + +/** + * Neutral connection facts for a Hive Metastore (HMS) backend. The concrete {@code HiveConf} is + * assembled by the connector (which has the hive classes); this contract only carries neutral keys. + */ +public interface HmsMetaStoreProperties extends MetaStoreProperties { + + /** The metastore thrift URI ({@code hive.metastore.uris}). */ + String getUri(); + + /** Whether the metastore connection is {@code SIMPLE} or {@code KERBEROS} authenticated. */ + AuthType getAuthType(); + + /** + * Neutral {@code hive.*} / {@code hadoop.security.*} / SASL overrides to be layered onto the + * connector's {@code HiveConf}. Includes the HMS service principal when configured. + * + * @param defaultClientSocketTimeoutSeconds the metastore client socket-timeout (seconds) to apply when the + * user has not set {@code hive.metastore.client.socket.timeout}; the engine threads the FE + * {@code hive_metastore_client_timeout_second} config value here (C4). Blank falls back to {@code "10"}. + */ + Map toHiveConfOverrides(String defaultClientSocketTimeoutSeconds); + + /** + * The client Kerberos login facts (principal/keytab), present only for a Kerberos-secured + * metastore. The real {@code UGI.doAs} is still performed FE-side via + * {@code ConnectorContext.executeAuthenticated}; this only carries the facts. + */ + Optional kerberos(); +} diff --git a/fe/fe-connector/fe-connector-metastore-api/src/main/java/org/apache/doris/connector/metastore/JdbcMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-api/src/main/java/org/apache/doris/connector/metastore/JdbcMetaStoreProperties.java new file mode 100644 index 00000000000000..f0c2d00e034a9f --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-api/src/main/java/org/apache/doris/connector/metastore/JdbcMetaStoreProperties.java @@ -0,0 +1,45 @@ +// 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.metastore; + +/** + * Neutral connection facts for a JDBC catalog metastore backend (e.g. paimon jdbc catalog). + * The driver URL is resolved against the engine's jdbc-drivers directory during parsing. + */ +public interface JdbcMetaStoreProperties extends MetaStoreProperties { + + /** The JDBC connection URI. */ + String getUri(); + + /** The JDBC user, or empty when not configured. */ + String getUser(); + + /** The JDBC password, or empty when not configured. */ + String getPassword(); + + /** + * The configured driver jar URL (raw, alias-resolved), or empty when the engine-provided driver + * is used. Resolve it to a full, scheme-bearing URL via the spi's + * {@code JdbcDriverSupport.resolveDriverUrl(url, env)} with the engine environment (the same + * resolution the FE driver registration and the BE-bound options apply). + */ + String getDriverUrl(); + + /** The JDBC driver class name, or empty when not configured. */ + String getDriverClass(); +} diff --git a/fe/fe-connector/fe-connector-metastore-api/src/main/java/org/apache/doris/connector/metastore/MetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-api/src/main/java/org/apache/doris/connector/metastore/MetaStoreProperties.java new file mode 100644 index 00000000000000..9be66dce84c05e --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-api/src/main/java/org/apache/doris/connector/metastore/MetaStoreProperties.java @@ -0,0 +1,65 @@ +// 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.metastore; + +import java.util.Map; + +/** + * Public contract for a connector's bound-and-validated metastore connection properties — + * the metastore-side counterpart of fe-filesystem's {@code StorageProperties}. + * + *

    Following the same thin-interface principle as fe-filesystem-api, this API exposes only + * neutral {@code Map}/scalar facts and never leaks {@code HiveConf}/Hadoop/engine-SDK types; + * the concrete {@code HiveConf} (and any SDK catalog) is assembled on the connector side. + * + *

    The backend is identified by a {@link #providerName()} string and cross-cutting behaviour + * is expressed through capability methods (e.g. {@link #needsStorage()}), deliberately avoiding a + * per-backend {@code MetaStoreType} enum and the central {@code switch} statements that come with + * it (D-006). Backend discovery/dispatch is done by {@code MetaStoreProvider.supports(Map)} + + * ServiceLoader in fe-connector-metastore-spi. + */ +public interface MetaStoreProperties { + + /** Stable backend identifier, e.g. "HMS", "DLF", "REST", "JDBC", "FILESYSTEM". */ + String providerName(); + + /** + * Whether this backend needs the bound storage config supplied. HMS/DLF overlay it into the metastore + * conf during parse, in the parity-critical order (e.g. before the HMS kerberos block); FileSystem + * needs it bound for the connector to apply at catalog-build time. REST/JDBC do not. Replaces a + * per-backend enum switch. + */ + default boolean needsStorage() { + return false; + } + + /** Whether this backend uses vended credentials (replaces {@code VendedCredentialsFactory} type switch). */ + default boolean needsVendedCredentials() { + return false; + } + + /** Validates the bound facts; the default is a no-op for backends with no extra invariants. */ + default void validate() { + } + + /** The raw, unmodified properties the catalog was created with. */ + Map rawProperties(); + + /** The subset of raw properties actually matched by the backend's {@code @ConnectorProperty} aliases. */ + Map matchedProperties(); +} diff --git a/fe/fe-connector/fe-connector-metastore-api/src/main/java/org/apache/doris/connector/metastore/RestMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-api/src/main/java/org/apache/doris/connector/metastore/RestMetaStoreProperties.java new file mode 100644 index 00000000000000..11aa379d74581f --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-api/src/main/java/org/apache/doris/connector/metastore/RestMetaStoreProperties.java @@ -0,0 +1,30 @@ +// 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.metastore; + +import java.util.Map; + +/** Neutral connection facts for a REST catalog metastore backend. */ +public interface RestMetaStoreProperties extends MetaStoreProperties { + + /** The REST catalog service URI. */ + String getUri(); + + /** The neutral REST catalog option keys the connector passes through to its catalog. */ + Map toRestOptions(); +} diff --git a/fe/fe-connector/fe-connector-metastore-api/src/test/java/org/apache/doris/connector/metastore/MetaStorePropertiesContractTest.java b/fe/fe-connector/fe-connector-metastore-api/src/test/java/org/apache/doris/connector/metastore/MetaStorePropertiesContractTest.java new file mode 100644 index 00000000000000..0766f394bdbd89 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-api/src/test/java/org/apache/doris/connector/metastore/MetaStorePropertiesContractTest.java @@ -0,0 +1,133 @@ +// 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.metastore; + +import org.apache.doris.kerberos.AuthType; +import org.apache.doris.kerberos.KerberosAuthSpec; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Optional; + +/** + * Contract tests for the neutral metastore API. These pin the documented capability defaults and + * verify the HMS sub-interface carries the fe-kerberos facts — i.e. that the api compiles against + * and integrates the {@code fe-kerberos} {@link AuthType}/{@link KerberosAuthSpec} types. + */ +class MetaStorePropertiesContractTest { + + /** Minimal MetaStoreProperties that overrides nothing, to exercise the default methods. */ + private static class BareMetaStore implements MetaStoreProperties { + @Override + public String providerName() { + return "BARE"; + } + + @Override + public Map rawProperties() { + return Map.of("k", "v"); + } + + @Override + public Map matchedProperties() { + return Map.of(); + } + } + + @Test + void capabilityDefaults_areConservative() { + // Intent (D-006 / §1.4): a backend opts IN to needing storage / vended credentials; the + // safe default for both is false so HMS/REST/JDBC do not pull storage they do not use. + MetaStoreProperties ms = new BareMetaStore(); + + Assertions.assertEquals("BARE", ms.providerName()); + Assertions.assertFalse(ms.needsStorage()); + Assertions.assertFalse(ms.needsVendedCredentials()); + Assertions.assertDoesNotThrow(ms::validate); + Assertions.assertEquals("v", ms.rawProperties().get("k")); + Assertions.assertTrue(ms.matchedProperties().isEmpty()); + } + + @Test + void capabilities_canBeOverridden() { + MetaStoreProperties needsBoth = new BareMetaStore() { + @Override + public boolean needsStorage() { + return true; + } + + @Override + public boolean needsVendedCredentials() { + return true; + } + }; + + Assertions.assertTrue(needsBoth.needsStorage()); + Assertions.assertTrue(needsBoth.needsVendedCredentials()); + } + + @Test + void hmsSubInterface_carriesNeutralKerberosFacts() { + KerberosAuthSpec spec = new KerberosAuthSpec("hive/_HOST@REALM", "/etc/hive.keytab"); + HmsMetaStoreProperties hms = new HmsMetaStoreProperties() { + @Override + public String providerName() { + return "HMS"; + } + + @Override + public Map rawProperties() { + return Map.of(); + } + + @Override + public Map matchedProperties() { + return Map.of(); + } + + @Override + public String getUri() { + return "thrift://hms:9083"; + } + + @Override + public AuthType getAuthType() { + return AuthType.KERBEROS; + } + + @Override + public Map toHiveConfOverrides(String defaultClientSocketTimeoutSeconds) { + return Map.of("hive.metastore.sasl.enabled", "true"); + } + + @Override + public Optional kerberos() { + return Optional.of(spec); + } + }; + + Assertions.assertEquals("thrift://hms:9083", hms.getUri()); + Assertions.assertEquals(AuthType.KERBEROS, hms.getAuthType()); + Assertions.assertEquals("true", hms.toHiveConfOverrides("10").get("hive.metastore.sasl.enabled")); + Assertions.assertTrue(hms.kerberos().isPresent()); + Assertions.assertTrue(hms.kerberos().get().hasCredentials()); + Assertions.assertEquals("hive/_HOST@REALM", hms.kerberos().get().getPrincipal()); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-hms/pom.xml b/fe/fe-connector/fe-connector-metastore-hms/pom.xml new file mode 100644 index 00000000000000..51ebaf0b9d6f27 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-hms/pom.xml @@ -0,0 +1,105 @@ + + + + 4.0.0 + + + org.apache.doris + fe-connector + ${revision} + ../pom.xml + + + fe-connector-metastore-hms + jar + Doris FE Connector Metastore HMS + + The neutral, engine-agnostic Hive Metastore (HMS) metastore backend: the concrete + DefaultHmsMetaStoreProperties impl + its MetaStoreProvider entry (META-INF/services), discovered by + ServiceLoader at CREATE-CATALOG time. The shared extension point, framework, and engine-neutral HMS + conf base (AbstractHmsMetaStoreProperties) live in fe-connector-metastore-spi; this module supplies + only the neutral flavor of validate() — the shared HMS connection check, with no warehouse + requirement (HMS is a metastore, not a storage). Bundled child-first into the consuming connector + plugin-zip (NOT compiled into fe-core); only that plugin classpath sees this provider, so + bindForType("hms") resolves within-engine. + + + + + + ${project.groupId} + fe-connector-metastore-spi + ${project.version} + + + + ${project.groupId} + fe-connector-metastore-api + ${project.version} + + + + ${project.groupId} + fe-foundation + ${project.version} + + + + ${project.groupId} + fe-kerberos + ${project.version} + + + + org.apache.commons + commons-lang3 + + + org.junit.jupiter + junit-jupiter + test + + + + + doris-fe-connector-metastore-hms + + + org.apache.maven.plugins + maven-dependency-plugin + + + + copy-plugin-deps + none + + + + + + diff --git a/fe/fe-connector/fe-connector-metastore-hms/src/main/java/org/apache/doris/connector/metastore/hms/DefaultHmsMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-hms/src/main/java/org/apache/doris/connector/metastore/hms/DefaultHmsMetaStoreProperties.java new file mode 100644 index 00000000000000..270c8de97697ce --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-hms/src/main/java/org/apache/doris/connector/metastore/hms/DefaultHmsMetaStoreProperties.java @@ -0,0 +1,47 @@ +// 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.metastore.hms; + +import org.apache.doris.connector.metastore.spi.AbstractHmsMetaStoreProperties; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; + +import java.util.Map; + +/** + * The shared, engine-neutral Hive Metastore (HMS) metastore backend. All of the conf + * ({@code toHiveConfOverrides}), the neutral {@code hive.*}/{@code hadoop.security.*} keys, and the + * connection rules live in the shared {@link AbstractHmsMetaStoreProperties}. {@link #validate()} runs + * ONLY the connection check — an HMS is a metastore, not a storage, so it imposes no warehouse requirement. + */ +public final class DefaultHmsMetaStoreProperties extends AbstractHmsMetaStoreProperties { + + private DefaultHmsMetaStoreProperties(Map raw, Map storageHadoopConfig) { + super(raw, storageHadoopConfig); + } + + public static DefaultHmsMetaStoreProperties of(Map raw, Map storageHadoopConfig) { + DefaultHmsMetaStoreProperties props = new DefaultHmsMetaStoreProperties(raw, storageHadoopConfig); + ConnectorPropertiesUtils.bindConnectorProperties(props, raw); + return props; + } + + @Override + public void validate() { + validateConnection(); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-hms/src/main/java/org/apache/doris/connector/metastore/hms/HmsMetaStoreProvider.java b/fe/fe-connector/fe-connector-metastore-hms/src/main/java/org/apache/doris/connector/metastore/hms/HmsMetaStoreProvider.java new file mode 100644 index 00000000000000..fcb429eb9dc703 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-hms/src/main/java/org/apache/doris/connector/metastore/hms/HmsMetaStoreProvider.java @@ -0,0 +1,49 @@ +// 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.metastore.hms; + +import org.apache.doris.connector.metastore.HmsMetaStoreProperties; +import org.apache.doris.connector.metastore.spi.MetaStoreProvider; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; + +import java.util.Map; +import java.util.Set; + +/** Selects the shared, engine-neutral Hive Metastore backend ({@code catalog.type == hms}). */ +public final class HmsMetaStoreProvider implements MetaStoreProvider { + + @Override + public boolean supportsType(String catalogType) { + return "hms".equalsIgnoreCase(catalogType); + } + + @Override + public HmsMetaStoreProperties bind(Map properties, Map storageHadoopConfig) { + return DefaultHmsMetaStoreProperties.of(properties, storageHadoopConfig); + } + + @Override + public Set sensitivePropertyKeys() { + return ConnectorPropertiesUtils.getSensitiveKeys(DefaultHmsMetaStoreProperties.class); + } + + @Override + public String name() { + return "HMS"; + } +} diff --git a/fe/fe-connector/fe-connector-metastore-hms/src/main/resources/META-INF/services/org.apache.doris.connector.metastore.spi.MetaStoreProvider b/fe/fe-connector/fe-connector-metastore-hms/src/main/resources/META-INF/services/org.apache.doris.connector.metastore.spi.MetaStoreProvider new file mode 100644 index 00000000000000..d595306a04ddf2 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-hms/src/main/resources/META-INF/services/org.apache.doris.connector.metastore.spi.MetaStoreProvider @@ -0,0 +1,17 @@ +# 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. +org.apache.doris.connector.metastore.hms.HmsMetaStoreProvider diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/pom.xml b/fe/fe-connector/fe-connector-metastore-iceberg/pom.xml new file mode 100644 index 00000000000000..d9395ff94c6c69 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/pom.xml @@ -0,0 +1,107 @@ + + + + 4.0.0 + + + org.apache.doris + fe-connector + ${revision} + ../pom.xml + + + fe-connector-metastore-iceberg + jar + Doris FE Connector Metastore Iceberg + + Iceberg per-engine metastore backends: the concrete HMS/DLF/REST/JDBC/Glue/Hadoop/S3Tables + *MetaStoreProperties impls + their MetaStoreProvider entries (META-INF/services), discovered by + ServiceLoader at CREATE-CATALOG time. Mirrors fe-connector-metastore-paimon: the shared extension + point, framework, and engine-neutral HMS/DLF conf bases live in fe-connector-metastore-spi; this + module supplies only iceberg's flavor of validate() — the per-flavor CREATE-CATALOG rules (REST/ + Glue/JDBC, ported verbatim from the fe-core Iceberg*MetaStoreProperties) plus the shared HMS/DLF + connection checks; hadoop/s3tables are no-op (their storage is validated upstream at fe-filesystem + bind). Bundled child-first into the fe-connector-iceberg plugin-zip (NOT compiled into fe-core); + only iceberg's plugin classpath sees these providers, so bindForType(flavor) resolves within-engine. + + + + + + ${project.groupId} + fe-connector-metastore-spi + ${project.version} + + + + ${project.groupId} + fe-connector-metastore-api + ${project.version} + + + + ${project.groupId} + fe-foundation + ${project.version} + + + + ${project.groupId} + fe-kerberos + ${project.version} + + + + org.apache.commons + commons-lang3 + + + org.junit.jupiter + junit-jupiter + test + + + + + doris-fe-connector-metastore-iceberg + + + org.apache.maven.plugins + maven-dependency-plugin + + + + copy-plugin-deps + none + + + + + + diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/glue/IcebergGlueMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/glue/IcebergGlueMetaStoreProperties.java new file mode 100644 index 00000000000000..9c533542001b6e --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/glue/IcebergGlueMetaStoreProperties.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.metastore.iceberg.glue; + +import org.apache.doris.connector.metastore.spi.AbstractMetaStoreProperties; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; +import org.apache.doris.foundation.property.ConnectorProperty; +import org.apache.doris.foundation.property.ParamRules; + +import org.apache.commons.lang3.StringUtils; + +import java.util.Map; + +/** + * Iceberg AWS Glue catalog metastore backend — validation only (the Glue/S3 catalog conf is connector-side + * in {@code IcebergCatalogFactory}). Ports the legacy {@code AWSGlueMetaStoreBaseProperties}' + * {@code buildRules()} + {@code requireExplicitGlueCredentials()} verbatim (§4 of the P6-T10 design), in + * fire order: AK/SK-together, endpoint-required, endpoint-https, then at-least-one-credential. No + * warehouse requirement. + */ +public final class IcebergGlueMetaStoreProperties extends AbstractMetaStoreProperties { + + @ConnectorProperty(names = {"glue.access_key", "aws.glue.access-key", + "client.credentials-provider.glue.access_key"}, + required = false, description = "The access key of the AWS Glue.") + private String glueAccessKey = ""; + + @ConnectorProperty(names = {"glue.secret_key", "aws.glue.secret-key", + "client.credentials-provider.glue.secret_key"}, + required = false, sensitive = true, description = "The secret key of the AWS Glue.") + private String glueSecretKey = ""; + + @ConnectorProperty(names = {"glue.endpoint", "aws.endpoint", "aws.glue.endpoint"}, + required = false, description = "The endpoint of the AWS Glue.") + private String glueEndpoint = ""; + + @ConnectorProperty(names = {"glue.role_arn"}, required = false, + description = "The IAM role of the AWS Glue.") + private String glueIamRole = ""; + + private IcebergGlueMetaStoreProperties(Map raw) { + super(raw); + } + + public static IcebergGlueMetaStoreProperties of(Map raw) { + IcebergGlueMetaStoreProperties props = new IcebergGlueMetaStoreProperties(raw); + ConnectorPropertiesUtils.bindConnectorProperties(props, raw); + return props; + } + + @Override + public String providerName() { + return "GLUE"; + } + + @Override + public void validate() { + // Legacy AWSGlueMetaStoreBaseProperties.checkAndInit -> buildRules().validate() (rules run in + // registration order), then IcebergGlueMetaStoreProperties.initNormalizeAndCheckProps -> + // requireExplicitGlueCredentials(). + new ParamRules() + .requireTogether(new String[] {glueAccessKey, glueSecretKey}, + "glue.access_key and glue.secret_key must be set together") + .require(glueEndpoint, "glue.endpoint must be set") + .check(() -> StringUtils.isNotBlank(glueEndpoint) && !glueEndpoint.startsWith("https://"), + "glue.endpoint must use https protocol,please set glue.endpoint to https://...") + .validate(); + // requireExplicitGlueCredentials: at least one of an access key or an IAM role must be explicit + // (iceberg cannot use the default credential chain). + if (StringUtils.isNotBlank(glueAccessKey) || StringUtils.isNotBlank(glueIamRole)) { + return; + } + throw new IllegalArgumentException("At least one of glue.access_key or glue.role_arn must be set"); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/glue/IcebergGlueMetaStoreProvider.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/glue/IcebergGlueMetaStoreProvider.java new file mode 100644 index 00000000000000..92d8e9fbe313ae --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/glue/IcebergGlueMetaStoreProvider.java @@ -0,0 +1,49 @@ +// 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.metastore.iceberg.glue; + +import org.apache.doris.connector.metastore.spi.MetaStoreProvider; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; + +import java.util.Map; +import java.util.Set; + +/** Selects the iceberg AWS Glue catalog backend ({@code iceberg.catalog.type == glue}). */ +public final class IcebergGlueMetaStoreProvider implements MetaStoreProvider { + + @Override + public boolean supportsType(String catalogType) { + return "glue".equalsIgnoreCase(catalogType); + } + + @Override + public IcebergGlueMetaStoreProperties bind(Map properties, + Map storageHadoopConfig) { + return IcebergGlueMetaStoreProperties.of(properties); + } + + @Override + public Set sensitivePropertyKeys() { + return ConnectorPropertiesUtils.getSensitiveKeys(IcebergGlueMetaStoreProperties.class); + } + + @Override + public String name() { + return "GLUE"; + } +} diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/hms/IcebergHmsMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/hms/IcebergHmsMetaStoreProperties.java new file mode 100644 index 00000000000000..6342326e152f06 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/hms/IcebergHmsMetaStoreProperties.java @@ -0,0 +1,48 @@ +// 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.metastore.iceberg.hms; + +import org.apache.doris.connector.metastore.spi.AbstractHmsMetaStoreProperties; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; + +import java.util.Map; + +/** + * Iceberg's Hive Metastore (HMS) backend. Conf ({@code toHiveConfOverrides}, consumed by the connector's + * {@code IcebergCatalogFactory.assembleHiveConf} via {@code bindForType("hms")}) and the connection rules + * live in the shared {@link AbstractHmsMetaStoreProperties}. Iceberg's {@link #validate()} runs ONLY the + * connection check — iceberg HMS omits the paimon {@code requireWarehouse()} (legacy + * {@code IcebergHMSMetaStoreProperties} → {@code HMSBaseProperties.of}; §4 of the P6-T10 design). + */ +public final class IcebergHmsMetaStoreProperties extends AbstractHmsMetaStoreProperties { + + private IcebergHmsMetaStoreProperties(Map raw, Map storageHadoopConfig) { + super(raw, storageHadoopConfig); + } + + public static IcebergHmsMetaStoreProperties of(Map raw, Map storageHadoopConfig) { + IcebergHmsMetaStoreProperties props = new IcebergHmsMetaStoreProperties(raw, storageHadoopConfig); + ConnectorPropertiesUtils.bindConnectorProperties(props, raw); + return props; + } + + @Override + public void validate() { + validateConnection(); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/hms/IcebergHmsMetaStoreProvider.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/hms/IcebergHmsMetaStoreProvider.java new file mode 100644 index 00000000000000..e0229cb7f36ef1 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/hms/IcebergHmsMetaStoreProvider.java @@ -0,0 +1,49 @@ +// 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.metastore.iceberg.hms; + +import org.apache.doris.connector.metastore.HmsMetaStoreProperties; +import org.apache.doris.connector.metastore.spi.MetaStoreProvider; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; + +import java.util.Map; +import java.util.Set; + +/** Selects the iceberg Hive Metastore backend ({@code iceberg.catalog.type == hms}). */ +public final class IcebergHmsMetaStoreProvider implements MetaStoreProvider { + + @Override + public boolean supportsType(String catalogType) { + return "hms".equalsIgnoreCase(catalogType); + } + + @Override + public HmsMetaStoreProperties bind(Map properties, Map storageHadoopConfig) { + return IcebergHmsMetaStoreProperties.of(properties, storageHadoopConfig); + } + + @Override + public Set sensitivePropertyKeys() { + return ConnectorPropertiesUtils.getSensitiveKeys(IcebergHmsMetaStoreProperties.class); + } + + @Override + public String name() { + return "HMS"; + } +} diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/jdbc/IcebergJdbcMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/jdbc/IcebergJdbcMetaStoreProperties.java new file mode 100644 index 00000000000000..c57800d5cc2914 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/jdbc/IcebergJdbcMetaStoreProperties.java @@ -0,0 +1,72 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.metastore.iceberg.jdbc; + +import org.apache.doris.connector.metastore.spi.AbstractMetaStoreProperties; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; +import org.apache.doris.foundation.property.ConnectorProperty; + +import org.apache.commons.lang3.StringUtils; + +import java.util.Map; + +/** + * Iceberg JDBC catalog metastore backend — validation only (the catalog conf + dynamic driver loading are + * connector-side in {@code IcebergCatalogFactory}/{@code IcebergConnector}). Parse-time rules (legacy + * {@code IcebergJdbcMetaStoreProperties}: {@code uri}/{@code iceberg.jdbc.catalog_name} {@code required=true} + * + the warehouse check), in fire order — §4 of the P6-T10 design. The lazy driver_class/url rules run at + * initCatalog and are covered by the connector's {@code preCreateValidation}, NOT here. + */ +public final class IcebergJdbcMetaStoreProperties extends AbstractMetaStoreProperties { + + @ConnectorProperty(names = {"uri", "iceberg.jdbc.uri"}, required = false, + description = "JDBC connection URI for the Iceberg JDBC catalog.") + private String uri = ""; + + @ConnectorProperty(names = {"iceberg.jdbc.catalog_name"}, required = false, + description = "The Iceberg JDBC catalog_name used to isolate metadata in JDBC catalog tables.") + private String jdbcCatalogName = ""; + + private IcebergJdbcMetaStoreProperties(Map raw) { + super(raw); + } + + public static IcebergJdbcMetaStoreProperties of(Map raw) { + IcebergJdbcMetaStoreProperties props = new IcebergJdbcMetaStoreProperties(raw); + ConnectorPropertiesUtils.bindConnectorProperties(props, raw); + return props; + } + + @Override + public String providerName() { + return "JDBC"; + } + + @Override + public void validate() { + // Legacy: uri + iceberg.jdbc.catalog_name are required=true (checked by the base in field-declaration + // order: uri first), then IcebergJdbcMetaStoreProperties.checkRequiredProperties adds warehouse. + if (StringUtils.isBlank(uri)) { + throw new IllegalArgumentException("Property uri is required."); + } + if (StringUtils.isBlank(jdbcCatalogName)) { + throw new IllegalArgumentException("Property iceberg.jdbc.catalog_name is required."); + } + requireWarehouse(); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/jdbc/IcebergJdbcMetaStoreProvider.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/jdbc/IcebergJdbcMetaStoreProvider.java new file mode 100644 index 00000000000000..a6583d94dda424 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/jdbc/IcebergJdbcMetaStoreProvider.java @@ -0,0 +1,42 @@ +// 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.metastore.iceberg.jdbc; + +import org.apache.doris.connector.metastore.spi.MetaStoreProvider; + +import java.util.Map; + +/** Selects the iceberg JDBC catalog backend ({@code iceberg.catalog.type == jdbc}). */ +public final class IcebergJdbcMetaStoreProvider implements MetaStoreProvider { + + @Override + public boolean supportsType(String catalogType) { + return "jdbc".equalsIgnoreCase(catalogType); + } + + @Override + public IcebergJdbcMetaStoreProperties bind(Map properties, + Map storageHadoopConfig) { + return IcebergJdbcMetaStoreProperties.of(properties); + } + + @Override + public String name() { + return "JDBC"; + } +} diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/noop/IcebergHadoopMetaStoreProvider.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/noop/IcebergHadoopMetaStoreProvider.java new file mode 100644 index 00000000000000..32f318eacf035e --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/noop/IcebergHadoopMetaStoreProvider.java @@ -0,0 +1,46 @@ +// 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.metastore.iceberg.noop; + +import org.apache.doris.connector.metastore.spi.MetaStoreProvider; + +import java.util.Map; + +/** + * Selects the iceberg Hadoop (filesystem) backend ({@code iceberg.catalog.type == hadoop}). No metastore + * rules: binds a no-op-validate {@link IcebergNoOpMetaStoreProperties} so {@code bindForType("hadoop")} + * resolves instead of throwing. + */ +public final class IcebergHadoopMetaStoreProvider implements MetaStoreProvider { + + @Override + public boolean supportsType(String catalogType) { + return "hadoop".equalsIgnoreCase(catalogType); + } + + @Override + public IcebergNoOpMetaStoreProperties bind(Map properties, + Map storageHadoopConfig) { + return IcebergNoOpMetaStoreProperties.of(properties, "HADOOP"); + } + + @Override + public String name() { + return "HADOOP"; + } +} diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/noop/IcebergNoOpMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/noop/IcebergNoOpMetaStoreProperties.java new file mode 100644 index 00000000000000..4a155d8f13b13c --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/noop/IcebergNoOpMetaStoreProperties.java @@ -0,0 +1,65 @@ +// 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.metastore.iceberg.noop; + +import org.apache.doris.connector.metastore.spi.AbstractMetaStoreProperties; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; + +import org.apache.commons.lang3.StringUtils; + +import java.util.Map; + +/** + * Shared no-op metastore backend for the iceberg {@code hadoop} and {@code s3tables} flavors: they have + * NO metastore-side CREATE-CATALOG rules (legacy {@code IcebergFileSystemMetaStoreProperties} adds no + * validation; {@code IcebergS3TablesMetaStoreProperties} only parses {@code S3Properties}, whose checks + * run upstream at fe-filesystem storage bind — §4 of the P6-T10 design). {@link #validate()} is therefore + * a no-op; the provider exists only so {@code bindForType("hadoop"/"s3tables")} does not throw. + */ +public final class IcebergNoOpMetaStoreProperties extends AbstractMetaStoreProperties { + + private final String providerName; + + private IcebergNoOpMetaStoreProperties(Map raw, String providerName) { + super(raw); + this.providerName = providerName; + } + + public static IcebergNoOpMetaStoreProperties of(Map raw, String providerName) { + IcebergNoOpMetaStoreProperties props = new IcebergNoOpMetaStoreProperties(raw, providerName); + ConnectorPropertiesUtils.bindConnectorProperties(props, raw); + return props; + } + + @Override + public String providerName() { + return providerName; + } + + @Override + public void validate() { + // The hadoop flavor restores the legacy IcebergHadoopExternalCatalog constructor's warehouse-required + // check (a HadoopCatalog cannot initialize without a warehouse root). s3tables shares this class but + // has no such rule, so the check is gated on the HADOOP provider only. Other storage validation runs + // upstream at fe-filesystem bind. isEmpty (not isBlank) mirrors the legacy StringUtils.isNotEmpty. + if ("HADOOP".equals(providerName) && StringUtils.isEmpty(warehouse)) { + throw new IllegalArgumentException( + "Cannot initialize Iceberg HadoopCatalog because 'warehouse' must not be null or empty"); + } + } +} diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/noop/IcebergS3TablesMetaStoreProvider.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/noop/IcebergS3TablesMetaStoreProvider.java new file mode 100644 index 00000000000000..593e55683a8ad4 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/noop/IcebergS3TablesMetaStoreProvider.java @@ -0,0 +1,46 @@ +// 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.metastore.iceberg.noop; + +import org.apache.doris.connector.metastore.spi.MetaStoreProvider; + +import java.util.Map; + +/** + * Selects the iceberg S3 Tables backend ({@code iceberg.catalog.type == s3tables}). No metastore rules + * (storage validated upstream): binds a no-op-validate {@link IcebergNoOpMetaStoreProperties} so + * {@code bindForType("s3tables")} resolves instead of throwing. + */ +public final class IcebergS3TablesMetaStoreProvider implements MetaStoreProvider { + + @Override + public boolean supportsType(String catalogType) { + return "s3tables".equalsIgnoreCase(catalogType); + } + + @Override + public IcebergNoOpMetaStoreProperties bind(Map properties, + Map storageHadoopConfig) { + return IcebergNoOpMetaStoreProperties.of(properties, "S3TABLES"); + } + + @Override + public String name() { + return "S3TABLES"; + } +} diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/rest/IcebergRestMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/rest/IcebergRestMetaStoreProperties.java new file mode 100644 index 00000000000000..08770775853212 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/rest/IcebergRestMetaStoreProperties.java @@ -0,0 +1,233 @@ +// 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.metastore.iceberg.rest; + +import org.apache.doris.connector.metastore.spi.AbstractMetaStoreProperties; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; +import org.apache.doris.foundation.property.ConnectorProperty; +import org.apache.doris.foundation.property.ParamRules; + +import org.apache.commons.lang3.StringUtils; + +import java.util.Locale; +import java.util.Map; + +/** + * Iceberg REST catalog metastore backend — validation only (the REST catalog conf is connector-side in + * {@code IcebergCatalogFactory}). Ports the legacy {@code IcebergRestProperties.initNormalizeAndCheckProps} + * validation verbatim (§4 of the P6-T10 design), in observable fire order: + *

      + *
    1. security-type enum (none/oauth2)
    2. + *
    3. AWS credentials-provider mode enum
    4. + *
    5. OAuth2 scope-only-with-credential (eager)
    6. + *
    7. OAuth2 requires credential-or-token (eager)
    8. + *
    9. iceberg.rest.role_arn rejected (eager)
    10. + *
    11. iceberg.rest.external-id rejected (eager)
    12. + *
    13. OAuth2 credential/token mutually exclusive (ParamRules)
    14. + *
    15. signing-name=glue requires signing-region + sigv4-enabled (ParamRules)
    16. + *
    17. signing-name=s3tables requires signing-region + sigv4-enabled (ParamRules)
    18. + *
    19. access-key-id + secret-access-key set together (ParamRules)
    20. + *
    + * No uri/warehouse requirement. The {@code Security}/{@code AwsCredentialsProviderMode} enum checks are + * reproduced inline (the fe-core enums cannot be imported into a connector module). + */ +public final class IcebergRestMetaStoreProperties extends AbstractMetaStoreProperties { + + private static final String ICEBERG_REST_ROLE_ARN = "iceberg.rest.role_arn"; + private static final String ICEBERG_REST_EXTERNAL_ID = "iceberg.rest.external-id"; + + // Per-user session (#63068 re-migration). Local literal copies (this metastore module does not depend on + // fe-connector-iceberg, so IcebergConnectorProperties' constants are not importable — same rationale as the + // "none"/"oauth2" security-type literals already inlined below). + private static final String SESSION_NONE = "none"; + private static final String SESSION_USER = "user"; + private static final String TOKEN_MODE_ACCESS_TOKEN = "access_token"; + private static final String TOKEN_MODE_TOKEN_EXCHANGE = "token_exchange"; + + @ConnectorProperty(names = {"iceberg.rest.security.type"}, required = false, + description = "The security type of the iceberg rest catalog service, optional: (none, oauth2).") + private String securityType = "none"; + + @ConnectorProperty(names = {"iceberg.rest.credentials_provider_type"}, required = false, + description = "The credentials provider type for AWS authentication.") + private String credentialsProviderType = "DEFAULT"; + + @ConnectorProperty(names = {"iceberg.rest.oauth2.token"}, required = false, sensitive = true, + description = "The oauth2 token for the iceberg rest catalog service.") + private String oauth2Token; + + @ConnectorProperty(names = {"iceberg.rest.oauth2.credential"}, required = false, sensitive = true, + description = "The oauth2 credential for the iceberg rest catalog service.") + private String oauth2Credential; + + @ConnectorProperty(names = {"iceberg.rest.oauth2.scope"}, required = false, + description = "The oauth2 scope for the iceberg rest catalog service.") + private String oauth2Scope; + + @ConnectorProperty(names = {"iceberg.rest.signing-name"}, required = false, + description = "The signing name for the iceberg rest catalog service.") + private String signingName = ""; + + @ConnectorProperty(names = {"iceberg.rest.signing-region"}, required = false, + description = "The signing region for the iceberg rest catalog service.") + private String signingRegion = ""; + + @ConnectorProperty(names = {"iceberg.rest.sigv4-enabled"}, required = false, + description = "True for Glue/S3Tables Rest Catalog.") + private String sigV4Enabled = ""; + + @ConnectorProperty(names = {"iceberg.rest.access-key-id"}, required = false, + description = "The access key ID for the iceberg rest catalog service.") + private String accessKeyId = ""; + + @ConnectorProperty(names = {"iceberg.rest.secret-access-key"}, required = false, sensitive = true, + description = "The secret access key for the iceberg rest catalog service.") + private String secretAccessKey = ""; + + @ConnectorProperty(names = {"iceberg.rest.session"}, required = false, + description = "Per-user session mode of the iceberg rest catalog, optional: (none, user). " + + "user requires iceberg.rest.security.type=oauth2.") + private String session = "none"; + + @ConnectorProperty(names = {"iceberg.rest.oauth2.delegated-token-mode"}, required = false, + description = "How the user's delegated credential is attached in session=user mode, optional: " + + "(access_token, token_exchange).") + private String delegatedTokenMode = "access_token"; + + private IcebergRestMetaStoreProperties(Map raw) { + super(raw); + } + + public static IcebergRestMetaStoreProperties of(Map raw) { + IcebergRestMetaStoreProperties props = new IcebergRestMetaStoreProperties(raw); + ConnectorPropertiesUtils.bindConnectorProperties(props, raw); + return props; + } + + @Override + public String providerName() { + return "REST"; + } + + @Override + public void validate() { + // 1. security type (legacy validateSecurityType: Security.valueOf(securityType.toUpperCase())). + if (!"none".equalsIgnoreCase(securityType) && !"oauth2".equalsIgnoreCase(securityType)) { + throw new IllegalArgumentException("Invalid security type: " + securityType + + ". Supported values are: none, oauth2"); + } + // 2. AWS credentials-provider mode (legacy AwsCredentialsProviderMode.fromString). + validateCredentialsProviderMode(); + // 2b. Per-user session (#63068): session enum, delegated-token-mode enum, and session=user⇒oauth2. + validateUserSession(); + // 3-10. Legacy buildRules() structure: eager throws interleaved with ParamRules registration, then + // validate() runs the registered rules in registration order. Statement order is preserved verbatim + // so the observable fire order matches §4. + ParamRules rules = new ParamRules() + // OAuth2 credential/token mutually exclusive (registered; fires at validate()). + .mutuallyExclusive(oauth2Credential, oauth2Token, + "OAuth2 cannot have both credential and token configured"); + // OAuth2 scope must not be used with token (eager). + if (StringUtils.isNotBlank(oauth2Token) && StringUtils.isNotBlank(oauth2Scope)) { + throw new IllegalArgumentException("OAuth2 scope is only applicable when using credential, not token"); + } + // If OAuth2 is enabled, require either credential or token (eager) — EXCEPT for a user-session catalog, + // which has no static bootstrap credential (the per-request user token supplies identity), so the + // requirement is relaxed for session=user (#63068 parity). + if ("oauth2".equalsIgnoreCase(securityType)) { + boolean hasCredential = StringUtils.isNotBlank(oauth2Credential); + boolean hasToken = StringUtils.isNotBlank(oauth2Token); + if (!hasCredential && !hasToken && !isUserSession()) { + throw new IllegalArgumentException("OAuth2 requires either credential or token"); + } + } + // When signing-name is glue or s3tables: require signing-region and sigv4-enabled (registered). + rules.requireIf(signingName, "glue", new String[] {signingRegion, sigV4Enabled}, + "Rest Catalog requires signing-region and sigv4-enabled set to true when signing-name is glue"); + rules.requireIf(signingName, "s3tables", new String[] {signingRegion, sigV4Enabled}, + "Rest Catalog requires signing-region and sigv4-enabled set to true when signing-name is s3tables"); + // AWS assume-role properties are not supported for the Iceberg REST catalog (eager). + rejectUnsupportedAwsAssumeRoleProperty(ICEBERG_REST_ROLE_ARN); + rejectUnsupportedAwsAssumeRoleProperty(ICEBERG_REST_EXTERNAL_ID); + // access-key-id and secret-access-key must be set together (registered). + rules.requireTogether(new String[] {accessKeyId, secretAccessKey}, + "iceberg.rest.access-key-id and iceberg.rest.secret-access-key must be set together"); + rules.validate(); + } + + /** + * Reproduces fe-core {@code AwsCredentialsProviderMode.fromString}: blank ⇒ DEFAULT (no throw); the 7 + * known modes accepted; unknown ⇒ throw with the ORIGINAL value. Deliberate nit-deviation: legacy + * upper-cases with the JVM default locale, here {@code Locale.ROOT} — byte-identical for the ASCII mode + * names; under a non-ASCII default locale (Turkish 'i') ROOT is strictly more correct (legacy would + * wrongly reject {@code web-identity}/{@code instance-profile}). Unreachable for real ASCII inputs. + */ + private void validateCredentialsProviderMode() { + if (credentialsProviderType == null || credentialsProviderType.isEmpty()) { + return; + } + String normalized = credentialsProviderType.trim().toUpperCase(Locale.ROOT).replace('-', '_'); + switch (normalized) { + case "ENV": + case "SYSTEM_PROPERTIES": + case "WEB_IDENTITY": + case "CONTAINER": + case "INSTANCE_PROFILE": + case "ANONYMOUS": + case "DEFAULT": + return; + default: + throw new IllegalArgumentException( + "Unsupported AWS credentials provider mode: " + credentialsProviderType); + } + } + + /** + * Validates the per-user session config (#63068): the {@code iceberg.rest.session} enum (none/user), the + * {@code iceberg.rest.oauth2.delegated-token-mode} enum (access_token/token_exchange), and that a + * {@code session=user} catalog uses {@code security.type=oauth2} (user-session requires OAuth2 — it has no + * bootstrap identity of its own). Case-insensitive to match the security-type check above. + */ + private void validateUserSession() { + if (!SESSION_NONE.equalsIgnoreCase(session) && !SESSION_USER.equalsIgnoreCase(session)) { + throw new IllegalArgumentException("Invalid iceberg.rest.session: " + session + + ". Supported values are: none, user"); + } + if (!TOKEN_MODE_ACCESS_TOKEN.equalsIgnoreCase(delegatedTokenMode) + && !TOKEN_MODE_TOKEN_EXCHANGE.equalsIgnoreCase(delegatedTokenMode)) { + throw new IllegalArgumentException("Invalid iceberg.rest.oauth2.delegated-token-mode: " + + delegatedTokenMode + ". Supported values are: access_token, token_exchange"); + } + if (isUserSession() && !"oauth2".equalsIgnoreCase(securityType)) { + throw new IllegalArgumentException( + "iceberg.rest.session=user requires iceberg.rest.security.type=oauth2"); + } + } + + private boolean isUserSession() { + return SESSION_USER.equalsIgnoreCase(session); + } + + private void rejectUnsupportedAwsAssumeRoleProperty(String propertyName) { + if (StringUtils.isNotBlank(raw.get(propertyName))) { + throw new IllegalArgumentException(propertyName + " is not supported for Iceberg REST catalog. " + + "Use iceberg.rest.access-key-id and iceberg.rest.secret-access-key, " + + "or iceberg.rest.credentials_provider_type instead"); + } + } +} diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/rest/IcebergRestMetaStoreProvider.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/rest/IcebergRestMetaStoreProvider.java new file mode 100644 index 00000000000000..38a740c0b7d40a --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/rest/IcebergRestMetaStoreProvider.java @@ -0,0 +1,49 @@ +// 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.metastore.iceberg.rest; + +import org.apache.doris.connector.metastore.spi.MetaStoreProvider; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; + +import java.util.Map; +import java.util.Set; + +/** Selects the iceberg REST catalog backend ({@code iceberg.catalog.type == rest}). */ +public final class IcebergRestMetaStoreProvider implements MetaStoreProvider { + + @Override + public boolean supportsType(String catalogType) { + return "rest".equalsIgnoreCase(catalogType); + } + + @Override + public IcebergRestMetaStoreProperties bind(Map properties, + Map storageHadoopConfig) { + return IcebergRestMetaStoreProperties.of(properties); + } + + @Override + public Set sensitivePropertyKeys() { + return ConnectorPropertiesUtils.getSensitiveKeys(IcebergRestMetaStoreProperties.class); + } + + @Override + public String name() { + return "REST"; + } +} diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/main/resources/META-INF/services/org.apache.doris.connector.metastore.spi.MetaStoreProvider b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/resources/META-INF/services/org.apache.doris.connector.metastore.spi.MetaStoreProvider new file mode 100644 index 00000000000000..1ee407602782ad --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/resources/META-INF/services/org.apache.doris.connector.metastore.spi.MetaStoreProvider @@ -0,0 +1,22 @@ +# 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. +org.apache.doris.connector.metastore.iceberg.hms.IcebergHmsMetaStoreProvider +org.apache.doris.connector.metastore.iceberg.rest.IcebergRestMetaStoreProvider +org.apache.doris.connector.metastore.iceberg.jdbc.IcebergJdbcMetaStoreProvider +org.apache.doris.connector.metastore.iceberg.glue.IcebergGlueMetaStoreProvider +org.apache.doris.connector.metastore.iceberg.noop.IcebergHadoopMetaStoreProvider +org.apache.doris.connector.metastore.iceberg.noop.IcebergS3TablesMetaStoreProvider diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/IcebergMetaStoreProvidersDispatchTest.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/IcebergMetaStoreProvidersDispatchTest.java new file mode 100644 index 00000000000000..064cb36ba1fe4f --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/IcebergMetaStoreProvidersDispatchTest.java @@ -0,0 +1,137 @@ +// 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.metastore.iceberg; + +import org.apache.doris.connector.metastore.MetaStoreProperties; +import org.apache.doris.connector.metastore.iceberg.glue.IcebergGlueMetaStoreProperties; +import org.apache.doris.connector.metastore.iceberg.hms.IcebergHmsMetaStoreProperties; +import org.apache.doris.connector.metastore.iceberg.jdbc.IcebergJdbcMetaStoreProperties; +import org.apache.doris.connector.metastore.iceberg.noop.IcebergNoOpMetaStoreProperties; +import org.apache.doris.connector.metastore.iceberg.rest.IcebergRestMetaStoreProperties; +import org.apache.doris.connector.metastore.spi.MetaStoreProviders; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Verifies ServiceLoader discovery on the iceberg classpath: all 7 iceberg providers register and + * {@link MetaStoreProviders#bindForType} routes each {@code iceberg.catalog.type} flavor to its iceberg + * backend (the caller resolves the flavor from {@code iceberg.catalog.type} and passes it explicitly, so + * the metastore-spi never learns iceberg's key). Unknown / null flavors fail loudly — no iceberg provider + * claims null (unlike the paimon FileSystem default), which lives on a different classpath. + */ +public class IcebergMetaStoreProvidersDispatchTest { + + private static MetaStoreProperties bind(String flavor) { + return MetaStoreProviders.bindForType(flavor, new HashMap<>(), Collections.emptyMap()); + } + + @Test + public void bindForTypeRoutesEachFlavorToItsIcebergBackend() { + Assertions.assertTrue(bind("hms") instanceof IcebergHmsMetaStoreProperties); + Assertions.assertTrue(bind("rest") instanceof IcebergRestMetaStoreProperties); + Assertions.assertTrue(bind("jdbc") instanceof IcebergJdbcMetaStoreProperties); + Assertions.assertTrue(bind("glue") instanceof IcebergGlueMetaStoreProperties); + Assertions.assertTrue(bind("hadoop") instanceof IcebergNoOpMetaStoreProperties); + Assertions.assertTrue(bind("s3tables") instanceof IcebergNoOpMetaStoreProperties); + } + + @Test + public void bindForTypeIsCaseInsensitive() { + // resolveFlavor lowercases, but the providers also accept mixed case (equalsIgnoreCase), matching + // the paimon providers. MUTATION: a case-sensitive supportsType would make "HMS" route to nothing. + Assertions.assertTrue(bind("HMS") instanceof IcebergHmsMetaStoreProperties); + Assertions.assertTrue(bind("Rest") instanceof IcebergRestMetaStoreProperties); + } + + @Test + public void hadoopValidatesWarehouseAndS3TablesIsNoOp() { + // s3tables has NO metastore-side CREATE-CATALOG rule, so its validate() is a genuine no-op. hadoop, in + // contrast, restores the legacy IcebergHadoopExternalCatalog check (commit 935e4fb9d80): a HadoopCatalog + // cannot initialize without a warehouse root, so validate() throws when the warehouse is absent and passes + // once it is supplied. MUTATION: dropping the hadoop warehouse gate lets the missing-warehouse case pass + // -> red; a bogus s3tables rule makes the no-op case throw -> red. + bind("s3tables").validate(); + + IllegalArgumentException missing = Assertions.assertThrows(IllegalArgumentException.class, + () -> bind("hadoop").validate()); + Assertions.assertEquals( + "Cannot initialize Iceberg HadoopCatalog because 'warehouse' must not be null or empty", + missing.getMessage()); + + Map withWarehouse = new HashMap<>(); + withWarehouse.put("warehouse", "hdfs://ns/wh"); + MetaStoreProviders.bindForType("hadoop", withWarehouse, Collections.emptyMap()).validate(); + + Assertions.assertEquals("HADOOP", bind("hadoop").providerName()); + Assertions.assertEquals("S3TABLES", bind("s3tables").providerName()); + } + + @Test + public void unknownFlavorThrows() { + IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, + () -> bind("nessie")); + Assertions.assertTrue(ex.getMessage().startsWith("No MetaStoreProvider supports"), ex.getMessage()); + } + + @Test + public void nullFlavorThrows() { + // WHY: a missing iceberg.catalog.type resolves to null; no iceberg provider claims null (the + // paimon FileSystem default-on-null lives on a different classpath), so bindForType fails loudly — + // parity with the connector's "Missing 'iceberg.catalog.type'". MUTATION: an iceberg provider that + // claimed null would silently default instead of rejecting. + Assertions.assertThrows(IllegalArgumentException.class, + () -> MetaStoreProviders.bindForType(null, new HashMap<>(), Collections.emptyMap())); + } + + @Test + public void allSixProvidersRegistered() { + // Per-engine scope: assert the iceberg flavor names are all present (HMS/REST/JDBC/GLUE share + // the type token with paimon, but on the iceberg classpath only iceberg providers are loaded). + Assertions.assertTrue(MetaStoreProviders.registeredNames().containsAll( + java.util.Arrays.asList("HMS", "REST", "JDBC", "GLUE", "HADOOP", "S3TABLES")), + "registered=" + MetaStoreProviders.registeredNames()); + // WHY: dlf 1.0 was removed. Its provider must be gone from the ServiceLoader, not merely unreachable — + // a stale services entry would resurrect a backend whose thrift client no longer exists. GLUE stays: + // iceberg.catalog.type=glue is the iceberg-native backend and is NOT affected by the removal. + Assertions.assertFalse(MetaStoreProviders.registeredNames().contains("DLF"), + "the removed DLF 1.0 provider must not be registered: " + MetaStoreProviders.registeredNames()); + } + + @Test + public void removedDlfFlavorNoLongerDispatches() { + // WHY: iceberg.catalog.type=dlf (DLF 1.0 over the vendored thrift ProxyMetaStoreClient) was removed, so + // it must now fail loud like any unknown flavor. MUTATION: leaving the provider registered would route + // to a backend whose client no longer ships. + Assertions.assertThrows(IllegalArgumentException.class, () -> bind("dlf")); + } + + @Test + public void boundFlavorValidatesThroughDispatch() { + // End-to-end: a glue catalog missing credentials, routed by bindForType, surfaces the §4 message. + Map props = new HashMap<>(); + props.put("glue.endpoint", "https://glue.us-east-1.amazonaws.com"); + IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, + () -> MetaStoreProviders.bindForType("glue", props, Collections.emptyMap()).validate()); + Assertions.assertEquals("At least one of glue.access_key or glue.role_arn must be set", ex.getMessage()); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/glue/IcebergGlueMetaStorePropertiesTest.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/glue/IcebergGlueMetaStorePropertiesTest.java new file mode 100644 index 00000000000000..9dd1b59879afba --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/glue/IcebergGlueMetaStorePropertiesTest.java @@ -0,0 +1,94 @@ +// 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.metastore.iceberg.glue; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Parity for the iceberg Glue backend (legacy {@code AWSGlueMetaStoreBaseProperties.buildRules} + + * {@code requireExplicitGlueCredentials}): verbatim §4 messages, in fire order + * (AK/SK-together → endpoint-required → endpoint-https → at-least-one-credential). No warehouse rule. + */ +public class IcebergGlueMetaStorePropertiesTest { + + private static Map raw(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + private static String validateError(Map raw) { + return Assertions.assertThrows(IllegalArgumentException.class, + () -> IcebergGlueMetaStoreProperties.of(raw).validate()).getMessage(); + } + + @Test + public void rule1AccessKeyAndSecretMustBeSetTogether() { + Assertions.assertEquals("glue.access_key and glue.secret_key must be set together", + validateError(raw("glue.access_key", "ak"))); + Assertions.assertEquals("glue.access_key and glue.secret_key must be set together", + validateError(raw("glue.secret_key", "sk"))); + } + + @Test + public void rule2EndpointRequired() { + // AK+SK present (rule 1 passes), endpoint blank => rule 2. + Assertions.assertEquals("glue.endpoint must be set", + validateError(raw("glue.access_key", "ak", "glue.secret_key", "sk"))); + } + + @Test + public void rule3EndpointMustBeHttps() { + Assertions.assertEquals("glue.endpoint must use https protocol,please set glue.endpoint to https://...", + validateError(raw("glue.access_key", "ak", "glue.secret_key", "sk", + "glue.endpoint", "http://glue.us-east-1.amazonaws.com"))); + } + + @Test + public void rule4AtLeastOneCredential() { + // endpoint present + https, AK/SK both blank, role blank => requireExplicitGlueCredentials. + Assertions.assertEquals("At least one of glue.access_key or glue.role_arn must be set", + validateError(raw("glue.endpoint", "https://glue.us-east-1.amazonaws.com"))); + } + + @Test + public void validWithAccessKeyOrRole() { + Assertions.assertEquals("GLUE", IcebergGlueMetaStoreProperties.of(raw()).providerName()); + // AK + SK + https endpoint. + IcebergGlueMetaStoreProperties.of(raw("glue.access_key", "ak", "glue.secret_key", "sk", + "glue.endpoint", "https://glue.us-east-1.amazonaws.com")).validate(); + // role_arn alone (no AK/SK) + https endpoint: requireTogether passes (none present), + // requireExplicitGlueCredentials satisfied by the role. + IcebergGlueMetaStoreProperties.of(raw("glue.role_arn", "arn:aws:iam::1:role/r", + "glue.endpoint", "https://glue.us-east-1.amazonaws.com")).validate(); + } + + @Test + public void credentialAliasesResolve() { + // aws.glue.access-key / aws.glue.secret-key are aliases for glue.access_key / glue.secret_key: + // both set via aliases => requireTogether passes; the endpoint alias aws.endpoint resolves too. + IcebergGlueMetaStoreProperties.of(raw("aws.glue.access-key", "ak", "aws.glue.secret-key", "sk", + "aws.endpoint", "https://glue.us-east-1.amazonaws.com")).validate(); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/hms/IcebergHmsMetaStorePropertiesTest.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/hms/IcebergHmsMetaStorePropertiesTest.java new file mode 100644 index 00000000000000..9560348ab59926 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/hms/IcebergHmsMetaStorePropertiesTest.java @@ -0,0 +1,94 @@ +// 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.metastore.iceberg.hms; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Parity for the iceberg HMS backend: it reuses the shared {@link + * org.apache.doris.connector.metastore.spi.AbstractHmsMetaStoreProperties} connection rules but — unlike + * paimon — does NOT require {@code warehouse} (legacy {@code IcebergHMSMetaStoreProperties} → + * {@code HMSBaseProperties.of}; §4 of the P6-T10 design). Conf ({@code toHiveConfOverrides}, used by the + * connector via {@code bindForType("hms")}) comes from the shared base unchanged. + */ +public class IcebergHmsMetaStorePropertiesTest { + + private static Map raw(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + private static IcebergHmsMetaStoreProperties of(Map raw) { + return IcebergHmsMetaStoreProperties.of(raw, Collections.emptyMap()); + } + + @Test + public void validWithoutWarehouse() { + // KEY iceberg-vs-paimon difference: iceberg HMS does NOT require warehouse. A bare uri validates. + // MUTATION: if IcebergHms.validate() called requireWarehouse(), this would throw + // "Property warehouse is required.". + of(raw("hive.metastore.uris", "thrift://h:9083")).validate(); + Assertions.assertEquals("HMS", of(raw("hive.metastore.uris", "thrift://h")).providerName()); + } + + @Test + public void uriRequiredFirstWithoutWarehouseCheck() { + // No warehouse set, no uri set: the FIRST error is the uri rule (not a warehouse rule), proving + // iceberg HMS skips requireWarehouse(). + Assertions.assertEquals("hive.metastore.uris or uri is required", + Assertions.assertThrows(IllegalArgumentException.class, + () -> of(raw()).validate()).getMessage()); + } + + @Test + public void simpleAuthForbidsClientCredentials() { + Assertions.assertEquals("hive.metastore.client.principal and hive.metastore.client.keytab cannot be set when " + + "hive.metastore.authentication.type is simple", + Assertions.assertThrows(IllegalArgumentException.class, + () -> of(raw("hive.metastore.uris", "thrift://h", + "hive.metastore.authentication.type", "simple", + "hive.metastore.client.principal", "p")).validate()).getMessage()); + } + + @Test + public void kerberosAuthRequiresClientCredentials() { + Assertions.assertEquals("hive.metastore.client.principal and hive.metastore.client.keytab are required when " + + "hive.metastore.authentication.type is kerberos", + Assertions.assertThrows(IllegalArgumentException.class, + () -> of(raw("hive.metastore.uris", "thrift://h", + "hive.metastore.authentication.type", "kerberos", + "hive.metastore.client.principal", "p")).validate()).getMessage()); + } + + @Test + public void confComesFromSharedBaseUnchanged() { + // The conf the connector layers onto its HiveConf (bindForType("hms").toHiveConfOverrides) is the + // shared base's — identical to paimon's. Pin the uri + the default socket timeout. + Map conf = of(raw("hive.metastore.uris", "thrift://h:9083")).toHiveConfOverrides("10"); + Assertions.assertEquals("thrift://h:9083", conf.get("hive.metastore.uris")); + Assertions.assertEquals("10", conf.get("hive.metastore.client.socket.timeout")); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/jdbc/IcebergJdbcMetaStorePropertiesTest.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/jdbc/IcebergJdbcMetaStorePropertiesTest.java new file mode 100644 index 00000000000000..dbf80d23f73e5b --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/jdbc/IcebergJdbcMetaStorePropertiesTest.java @@ -0,0 +1,69 @@ +// 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.metastore.iceberg.jdbc; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Parity for the iceberg JDBC backend (legacy {@code IcebergJdbcMetaStoreProperties} required props + + * warehouse check): verbatim §4 messages, in fire order uri → catalog_name → warehouse. + */ +public class IcebergJdbcMetaStorePropertiesTest { + + private static Map raw(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + private static String validateError(Map raw) { + return Assertions.assertThrows(IllegalArgumentException.class, + () -> IcebergJdbcMetaStoreProperties.of(raw).validate()).getMessage(); + } + + @Test + public void requiredInOrderUriCatalogNameWarehouse() { + Assertions.assertEquals("Property uri is required.", validateError(raw())); + Assertions.assertEquals("Property iceberg.jdbc.catalog_name is required.", + validateError(raw("uri", "jdbc:postgresql://h/db"))); + Assertions.assertEquals("Property warehouse is required.", + validateError(raw("uri", "jdbc:postgresql://h/db", "iceberg.jdbc.catalog_name", "c"))); + } + + @Test + public void validWithUriCatalogNameWarehouse() { + IcebergJdbcMetaStoreProperties props = IcebergJdbcMetaStoreProperties.of(raw( + "uri", "jdbc:postgresql://h/db", "iceberg.jdbc.catalog_name", "c", "warehouse", "s3://b/wh")); + props.validate(); + Assertions.assertEquals("JDBC", props.providerName()); + } + + @Test + public void uriAliasResolvesFromIcebergJdbcUri() { + // names = {"uri", "iceberg.jdbc.uri"}: iceberg.jdbc.uri satisfies the uri requirement. + IcebergJdbcMetaStoreProperties.of(raw( + "iceberg.jdbc.uri", "jdbc:postgresql://h/db", "iceberg.jdbc.catalog_name", "c", + "warehouse", "s3://b/wh")).validate(); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/rest/IcebergRestMetaStorePropertiesTest.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/rest/IcebergRestMetaStorePropertiesTest.java new file mode 100644 index 00000000000000..8e29f5f67cc708 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/rest/IcebergRestMetaStorePropertiesTest.java @@ -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. + +package org.apache.doris.connector.metastore.iceberg.rest; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Parity for the iceberg REST backend (legacy {@code IcebergRestProperties.initNormalizeAndCheckProps}): + * verbatim §4 messages + the observable fire order (enum checks → eager body-throws → ParamRules). + */ +public class IcebergRestMetaStorePropertiesTest { + + private static Map raw(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + private static String validateError(Map raw) { + return Assertions.assertThrows(IllegalArgumentException.class, + () -> IcebergRestMetaStoreProperties.of(raw).validate()).getMessage(); + } + + @Test + public void noneSecurityWithNoCredentialsIsValid() { + // Defaults: security.type=none, credentials_provider_type=DEFAULT, no oauth2/signing/AK-SK. + IcebergRestMetaStoreProperties.of(raw()).validate(); + IcebergRestMetaStoreProperties.of(raw("iceberg.rest.uri", "http://r")).validate(); + Assertions.assertEquals("REST", IcebergRestMetaStoreProperties.of(raw()).providerName()); + } + + @Test + public void rule1InvalidSecurityType() { + Assertions.assertEquals("Invalid security type: bogus. Supported values are: none, oauth2", + validateError(raw("iceberg.rest.security.type", "bogus"))); + // case-insensitive accept (mirrors Security.valueOf(toUpperCase)). + IcebergRestMetaStoreProperties.of(raw("iceberg.rest.security.type", "OAuth2", + "iceberg.rest.oauth2.token", "t")).validate(); + } + + @Test + public void rule2UnsupportedCredentialsProviderMode() { + Assertions.assertEquals("Unsupported AWS credentials provider mode: bogus", + validateError(raw("iceberg.rest.credentials_provider_type", "bogus"))); + // blank => DEFAULT (no throw); a known mode with '-' normalization is accepted. + IcebergRestMetaStoreProperties.of(raw("iceberg.rest.credentials_provider_type", "")).validate(); + IcebergRestMetaStoreProperties.of(raw("iceberg.rest.credentials_provider_type", "instance-profile")).validate(); + } + + @Test + public void rule3OAuth2ScopeOnlyWithCredentialNotToken() { + Assertions.assertEquals("OAuth2 scope is only applicable when using credential, not token", + validateError(raw("iceberg.rest.oauth2.token", "t", "iceberg.rest.oauth2.scope", "s"))); + } + + @Test + public void rule4OAuth2RequiresCredentialOrToken() { + Assertions.assertEquals("OAuth2 requires either credential or token", + validateError(raw("iceberg.rest.security.type", "oauth2"))); + // satisfied by either credential or token. + IcebergRestMetaStoreProperties.of(raw("iceberg.rest.security.type", "oauth2", + "iceberg.rest.oauth2.credential", "c")).validate(); + } + + @Test + public void rule5RoleArnRejected() { + Assertions.assertEquals("iceberg.rest.role_arn is not supported for Iceberg REST catalog. " + + "Use iceberg.rest.access-key-id and iceberg.rest.secret-access-key, " + + "or iceberg.rest.credentials_provider_type instead", + validateError(raw("iceberg.rest.role_arn", "arn:aws:iam::1:role/r"))); + } + + @Test + public void rule6ExternalIdRejected() { + Assertions.assertEquals("iceberg.rest.external-id is not supported for Iceberg REST catalog. " + + "Use iceberg.rest.access-key-id and iceberg.rest.secret-access-key, " + + "or iceberg.rest.credentials_provider_type instead", + validateError(raw("iceberg.rest.external-id", "xyz"))); + } + + @Test + public void rule7OAuth2MutuallyExclusiveCredentialAndToken() { + // security.type defaults to none, so rule 4 does not fire; the mutuallyExclusive ParamRule does. + Assertions.assertEquals("OAuth2 cannot have both credential and token configured", + validateError(raw("iceberg.rest.oauth2.credential", "c", "iceberg.rest.oauth2.token", "t"))); + } + + @Test + public void rule8And9SigningNameRequiresRegionAndSigV4() { + Assertions.assertEquals( + "Rest Catalog requires signing-region and sigv4-enabled set to true when signing-name is glue", + validateError(raw("iceberg.rest.signing-name", "glue"))); + Assertions.assertEquals( + "Rest Catalog requires signing-region and sigv4-enabled set to true when signing-name is s3tables", + validateError(raw("iceberg.rest.signing-name", "s3tables"))); + // satisfied when both region + sigv4-enabled present. + IcebergRestMetaStoreProperties.of(raw("iceberg.rest.signing-name", "glue", + "iceberg.rest.signing-region", "us-east-1", "iceberg.rest.sigv4-enabled", "true")).validate(); + // signing-name match is case-sensitive (ParamRules.requireIf uses Objects.equals): "Glue" != "glue" + // so the rule does NOT fire. MUTATION: a case-insensitive match would throw here. + IcebergRestMetaStoreProperties.of(raw("iceberg.rest.signing-name", "Glue")).validate(); + } + + @Test + public void rule10AccessKeyAndSecretMustBeSetTogether() { + Assertions.assertEquals("iceberg.rest.access-key-id and iceberg.rest.secret-access-key must be set together", + validateError(raw("iceberg.rest.access-key-id", "ak"))); + Assertions.assertEquals("iceberg.rest.access-key-id and iceberg.rest.secret-access-key must be set together", + validateError(raw("iceberg.rest.secret-access-key", "sk"))); + // both present => OK. + IcebergRestMetaStoreProperties.of(raw("iceberg.rest.access-key-id", "ak", + "iceberg.rest.secret-access-key", "sk")).validate(); + } + + @Test + public void fireOrderSecurityTypeBeforeEverythingElse() { + // WHY: rule 1 (security type) runs first. Even with a role_arn (rule 5) and an AK-only (rule 10) + // also violated, the security-type error must surface. MUTATION: reordering checks would surface a + // different message. + Assertions.assertEquals("Invalid security type: bogus. Supported values are: none, oauth2", + validateError(raw("iceberg.rest.security.type", "bogus", + "iceberg.rest.role_arn", "arn", "iceberg.rest.access-key-id", "ak"))); + } + + @Test + public void fireOrderEagerRoleArnBeforeDeferredRequireTogether() { + // WHY: role_arn (rule 5) throws eagerly during buildRules(), before the requireTogether (rule 10) + // ParamRule runs at validate(). So with BOTH role_arn set and AK-only set, role_arn wins. + // MUTATION: if requireTogether were eager (or role_arn deferred), the AK/SK message would surface. + Assertions.assertEquals("iceberg.rest.role_arn is not supported for Iceberg REST catalog. " + + "Use iceberg.rest.access-key-id and iceberg.rest.secret-access-key, " + + "or iceberg.rest.credentials_provider_type instead", + validateError(raw("iceberg.rest.role_arn", "arn", "iceberg.rest.access-key-id", "ak"))); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-paimon/pom.xml b/fe/fe-connector/fe-connector-metastore-paimon/pom.xml new file mode 100644 index 00000000000000..0d98ac760b88d1 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-paimon/pom.xml @@ -0,0 +1,104 @@ + + + + 4.0.0 + + + org.apache.doris + fe-connector + ${revision} + ../pom.xml + + + fe-connector-metastore-paimon + jar + Doris FE Connector Metastore Paimon + + Paimon per-engine metastore backends: the concrete HMS/DLF/REST/JDBC/FileSystem + *MetaStoreProperties impls + their MetaStoreProvider entries (META-INF/services), discovered by + ServiceLoader at CREATE-CATALOG time. Mirrors fe-filesystem's per-backend modules: the shared + extension point, framework, and engine-neutral HMS/DLF conf bases live in + fe-connector-metastore-spi; this module supplies only paimon's flavor of validate() + (warehouse[+OSS for DLF] + the shared connection checks). Bundled child-first into the + fe-connector-paimon plugin-zip (NOT compiled into fe-core); only paimon's plugin classpath sees + these providers, so bindForType(flavor) resolves within-engine at runtime. + + + + + + ${project.groupId} + fe-connector-metastore-spi + ${project.version} + + + + ${project.groupId} + fe-connector-metastore-api + ${project.version} + + + + ${project.groupId} + fe-foundation + ${project.version} + + + + ${project.groupId} + fe-kerberos + ${project.version} + + + + org.apache.commons + commons-lang3 + + + org.junit.jupiter + junit-jupiter + test + + + + + doris-fe-connector-metastore-paimon + + + org.apache.maven.plugins + maven-dependency-plugin + + + + copy-plugin-deps + none + + + + + + diff --git a/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/fs/PaimonFileSystemMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/fs/PaimonFileSystemMetaStoreProperties.java new file mode 100644 index 00000000000000..aea977ec3b61d6 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/fs/PaimonFileSystemMetaStoreProperties.java @@ -0,0 +1,63 @@ +// 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.metastore.paimon.fs; + +import org.apache.doris.connector.metastore.FileSystemMetaStoreProperties; +import org.apache.doris.connector.metastore.spi.AbstractMetaStoreProperties; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; + +import java.util.Map; + +/** + * Paimon filesystem (warehouse-only) metastore backend facts. The storage config is overlaid by the + * connector at catalog-build time, so this impl carries only the warehouse and declares + * {@link #needsStorage()} true. + */ +public final class PaimonFileSystemMetaStoreProperties extends AbstractMetaStoreProperties + implements FileSystemMetaStoreProperties { + + private PaimonFileSystemMetaStoreProperties(Map raw) { + super(raw); + } + + public static PaimonFileSystemMetaStoreProperties of(Map raw) { + PaimonFileSystemMetaStoreProperties props = new PaimonFileSystemMetaStoreProperties(raw); + ConnectorPropertiesUtils.bindConnectorProperties(props, raw); + return props; + } + + @Override + public String providerName() { + return "FILESYSTEM"; + } + + @Override + public boolean needsStorage() { + return true; + } + + @Override + public void validate() { + requireWarehouse(); + } + + @Override + public String getWarehouse() { + return warehouse; + } +} diff --git a/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/fs/PaimonFileSystemMetaStoreProvider.java b/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/fs/PaimonFileSystemMetaStoreProvider.java new file mode 100644 index 00000000000000..7677d920dd935d --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/fs/PaimonFileSystemMetaStoreProvider.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.metastore.paimon.fs; + +import org.apache.doris.connector.metastore.FileSystemMetaStoreProperties; +import org.apache.doris.connector.metastore.spi.MetaStoreProvider; + +import java.util.Map; + +/** + * Selects the paimon filesystem backend: the default when {@code paimon.catalog.type} is absent/blank, or + * an explicit {@code filesystem}. + */ +public final class PaimonFileSystemMetaStoreProvider implements MetaStoreProvider { + + @Override + public boolean supportsType(String catalogType) { + // Default backend: the catalog-type token is ABSENT (null), or an explicit "filesystem". A + // present-but-other value (incl. blank/whitespace) is NOT claimed here so it falls through to the + // dispatcher's no-supporter throw, matching legacy's reject-on-unknown (no .trim(), consistent + // with the other providers' plain equalsIgnoreCase). + return catalogType == null || "filesystem".equalsIgnoreCase(catalogType); + } + + @Override + public FileSystemMetaStoreProperties bind(Map properties, + Map storageHadoopConfig) { + return PaimonFileSystemMetaStoreProperties.of(properties); + } + + @Override + public String name() { + return "FILESYSTEM"; + } +} diff --git a/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/hms/PaimonHmsMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/hms/PaimonHmsMetaStoreProperties.java new file mode 100644 index 00000000000000..137df11c6d2659 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/hms/PaimonHmsMetaStoreProperties.java @@ -0,0 +1,49 @@ +// 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.metastore.paimon.hms; + +import org.apache.doris.connector.metastore.spi.AbstractHmsMetaStoreProperties; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; + +import java.util.Map; + +/** + * Paimon's Hive Metastore (HMS) backend. The fields, {@code toHiveConfOverrides}, and the shared + * connection rules live in {@link AbstractHmsMetaStoreProperties}; paimon's {@link #validate()} adds the + * {@code requireWarehouse()} that every paimon flavor enforces (legacy {@code AbstractPaimonProperties}), + * then the shared connection check. Fire order (warehouse → uri → simple/kerberos auth) is byte-identical + * to the pre-split {@code HmsMetaStorePropertiesImpl}. + */ +public final class PaimonHmsMetaStoreProperties extends AbstractHmsMetaStoreProperties { + + private PaimonHmsMetaStoreProperties(Map raw, Map storageHadoopConfig) { + super(raw, storageHadoopConfig); + } + + public static PaimonHmsMetaStoreProperties of(Map raw, Map storageHadoopConfig) { + PaimonHmsMetaStoreProperties props = new PaimonHmsMetaStoreProperties(raw, storageHadoopConfig); + ConnectorPropertiesUtils.bindConnectorProperties(props, raw); + return props; + } + + @Override + public void validate() { + requireWarehouse(); + validateConnection(); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/hms/PaimonHmsMetaStoreProvider.java b/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/hms/PaimonHmsMetaStoreProvider.java new file mode 100644 index 00000000000000..a87dcc374332c9 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/hms/PaimonHmsMetaStoreProvider.java @@ -0,0 +1,49 @@ +// 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.metastore.paimon.hms; + +import org.apache.doris.connector.metastore.HmsMetaStoreProperties; +import org.apache.doris.connector.metastore.spi.MetaStoreProvider; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; + +import java.util.Map; +import java.util.Set; + +/** Selects the paimon Hive Metastore backend ({@code paimon.catalog.type == hms}). */ +public final class PaimonHmsMetaStoreProvider implements MetaStoreProvider { + + @Override + public boolean supportsType(String catalogType) { + return "hms".equalsIgnoreCase(catalogType); + } + + @Override + public HmsMetaStoreProperties bind(Map properties, Map storageHadoopConfig) { + return PaimonHmsMetaStoreProperties.of(properties, storageHadoopConfig); + } + + @Override + public Set sensitivePropertyKeys() { + return ConnectorPropertiesUtils.getSensitiveKeys(PaimonHmsMetaStoreProperties.class); + } + + @Override + public String name() { + return "HMS"; + } +} diff --git a/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/jdbc/PaimonJdbcMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/jdbc/PaimonJdbcMetaStoreProperties.java new file mode 100644 index 00000000000000..e43e249fabf6fb --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/jdbc/PaimonJdbcMetaStoreProperties.java @@ -0,0 +1,111 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.metastore.paimon.jdbc; + +import org.apache.doris.connector.metastore.JdbcMetaStoreProperties; +import org.apache.doris.connector.metastore.spi.AbstractMetaStoreProperties; +import org.apache.doris.connector.metastore.spi.JdbcDriverSupport; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; +import org.apache.doris.foundation.property.ConnectorProperty; + +import org.apache.commons.lang3.StringUtils; + +import java.util.Map; + +/** + * Paimon JDBC catalog metastore backend facts. The getters return the raw, alias-resolved values; the + * driver-url is resolved to a full URL by the consumer via + * {@link JdbcDriverSupport#resolveDriverUrl(String, Map)} (which needs the engine environment), + * exactly as the live FE registration and the BE-bound options do today. + */ +public final class PaimonJdbcMetaStoreProperties extends AbstractMetaStoreProperties + implements JdbcMetaStoreProperties { + + @ConnectorProperty(names = {"uri", "paimon.jdbc.uri"}, required = false, + description = "The JDBC connection URI.") + private String uri = ""; + + @ConnectorProperty(names = {"paimon.jdbc.user", "jdbc.user"}, required = false, + description = "The JDBC user.") + private String user = ""; + + @ConnectorProperty(names = {"paimon.jdbc.password", "jdbc.password"}, required = false, sensitive = true, + description = "The JDBC password.") + private String password = ""; + + @ConnectorProperty(names = {"paimon.jdbc.driver_url", "jdbc.driver_url"}, required = false, + description = "The JDBC driver jar URL.") + private String driverUrl = ""; + + @ConnectorProperty(names = {"paimon.jdbc.driver_class", "jdbc.driver_class"}, required = false, + description = "The JDBC driver class name.") + private String driverClass = ""; + + private PaimonJdbcMetaStoreProperties(Map raw) { + super(raw); + } + + public static PaimonJdbcMetaStoreProperties of(Map raw) { + PaimonJdbcMetaStoreProperties props = new PaimonJdbcMetaStoreProperties(raw); + ConnectorPropertiesUtils.bindConnectorProperties(props, raw); + return props; + } + + @Override + public String providerName() { + return "JDBC"; + } + + @Override + public void validate() { + requireWarehouse(); + if (StringUtils.isBlank(uri)) { + throw new IllegalArgumentException("uri or paimon.jdbc.uri is required"); + } + if (StringUtils.isNotBlank(driverUrl) && StringUtils.isBlank(driverClass)) { + throw new IllegalArgumentException( + "jdbc.driver_class or paimon.jdbc.driver_class is required when " + + "jdbc.driver_url or paimon.jdbc.driver_url is specified"); + } + } + + @Override + public String getUri() { + return uri; + } + + @Override + public String getUser() { + return user; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getDriverUrl() { + return driverUrl; + } + + @Override + public String getDriverClass() { + return driverClass; + } +} diff --git a/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/jdbc/PaimonJdbcMetaStoreProvider.java b/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/jdbc/PaimonJdbcMetaStoreProvider.java new file mode 100644 index 00000000000000..37a5b5a8b37f47 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/jdbc/PaimonJdbcMetaStoreProvider.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.metastore.paimon.jdbc; + +import org.apache.doris.connector.metastore.JdbcMetaStoreProperties; +import org.apache.doris.connector.metastore.spi.MetaStoreProvider; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; + +import java.util.Map; +import java.util.Set; + +/** Selects the paimon JDBC catalog backend ({@code paimon.catalog.type == jdbc}). */ +public final class PaimonJdbcMetaStoreProvider implements MetaStoreProvider { + + @Override + public boolean supportsType(String catalogType) { + return "jdbc".equalsIgnoreCase(catalogType); + } + + @Override + public JdbcMetaStoreProperties bind(Map properties, + Map storageHadoopConfig) { + return PaimonJdbcMetaStoreProperties.of(properties); + } + + @Override + public Set sensitivePropertyKeys() { + return ConnectorPropertiesUtils.getSensitiveKeys(PaimonJdbcMetaStoreProperties.class); + } + + @Override + public String name() { + return "JDBC"; + } +} diff --git a/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/rest/PaimonRestMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/rest/PaimonRestMetaStoreProperties.java new file mode 100644 index 00000000000000..1cde2739b39fe1 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/rest/PaimonRestMetaStoreProperties.java @@ -0,0 +1,111 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.metastore.paimon.rest; + +import org.apache.doris.connector.metastore.RestMetaStoreProperties; +import org.apache.doris.connector.metastore.spi.AbstractMetaStoreProperties; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; +import org.apache.doris.foundation.property.ConnectorProperty; + +import org.apache.commons.lang3.StringUtils; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Paimon REST catalog metastore backend facts. Options-only (the REST server owns storage, so + * {@link #needsStorage()} stays false): the {@code uri} plus every {@code paimon.rest.*} key + * re-keyed with the prefix stripped (legacy {@code appendRestOptions}). + */ +public final class PaimonRestMetaStoreProperties extends AbstractMetaStoreProperties + implements RestMetaStoreProperties { + + private static final String PAIMON_REST_PREFIX = "paimon.rest."; + + @ConnectorProperty(names = {"paimon.rest.uri", "uri"}, required = false, + description = "The REST catalog service URI.") + private String uri = ""; + + @ConnectorProperty(names = {"paimon.rest.token.provider"}, required = false, + description = "The REST catalog token provider (e.g. dlf).") + private String tokenProvider = ""; + + @ConnectorProperty(names = {"paimon.rest.dlf.access-key-id"}, required = false, sensitive = true, + description = "DLF access key id for the REST DLF token provider.") + private String dlfAccessKeyId = ""; + + @ConnectorProperty(names = {"paimon.rest.dlf.access-key-secret"}, required = false, sensitive = true, + description = "DLF access key secret for the REST DLF token provider.") + private String dlfAccessKeySecret = ""; + + private PaimonRestMetaStoreProperties(Map raw) { + super(raw); + } + + public static PaimonRestMetaStoreProperties of(Map raw) { + PaimonRestMetaStoreProperties props = new PaimonRestMetaStoreProperties(raw); + ConnectorPropertiesUtils.bindConnectorProperties(props, raw); + return props; + } + + @Override + public String providerName() { + return "REST"; + } + + @Override + public void validate() { + // Shared warehouse check first (legacy parity: AbstractPaimonProperties requires warehouse for + // REST too — PaimonRestMetaStoreProperties does not override it). + requireWarehouse(); + if (StringUtils.isBlank(uri)) { + throw new IllegalArgumentException("paimon.rest.uri or uri is required"); + } + // CASE-SENSITIVE match: the authoritative legacy contract is ParamRules.requireIf, which uses + // Objects.equals("dlf", tokenProvider) (PaimonRestMetaStoreProperties). The paimon hand-copy's + // equalsIgnoreCase is a latent divergence we do NOT carry forward (T2 parity = legacy). + if ("dlf".equals(tokenProvider) + && (StringUtils.isBlank(dlfAccessKeyId) || StringUtils.isBlank(dlfAccessKeySecret))) { + throw new IllegalArgumentException( + "DLF token provider requires 'paimon.rest.dlf.access-key-id' " + + "and 'paimon.rest.dlf.access-key-secret'"); + } + } + + @Override + public String getUri() { + return uri; + } + + @Override + public Map toRestOptions() { + // Mirrors legacy appendRestOptions: set "uri" then re-key every paimon.rest.* (prefix stripped). + // Legacy sets "uri" unconditionally; we guard null so the neutral map carries no null value (the + // no-uri case is already rejected by validate()). + Map options = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(uri)) { + options.put("uri", uri); + } + raw.forEach((k, v) -> { + if (k.startsWith(PAIMON_REST_PREFIX)) { + options.put(k.substring(PAIMON_REST_PREFIX.length()), v); + } + }); + return options; + } +} diff --git a/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/rest/PaimonRestMetaStoreProvider.java b/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/rest/PaimonRestMetaStoreProvider.java new file mode 100644 index 00000000000000..ad31cd4a6a53ba --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-paimon/src/main/java/org/apache/doris/connector/metastore/paimon/rest/PaimonRestMetaStoreProvider.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.metastore.paimon.rest; + +import org.apache.doris.connector.metastore.RestMetaStoreProperties; +import org.apache.doris.connector.metastore.spi.MetaStoreProvider; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; + +import java.util.Map; +import java.util.Set; + +/** Selects the paimon REST catalog backend ({@code paimon.catalog.type == rest}). */ +public final class PaimonRestMetaStoreProvider implements MetaStoreProvider { + + @Override + public boolean supportsType(String catalogType) { + return "rest".equalsIgnoreCase(catalogType); + } + + @Override + public RestMetaStoreProperties bind(Map properties, + Map storageHadoopConfig) { + return PaimonRestMetaStoreProperties.of(properties); + } + + @Override + public Set sensitivePropertyKeys() { + return ConnectorPropertiesUtils.getSensitiveKeys(PaimonRestMetaStoreProperties.class); + } + + @Override + public String name() { + return "REST"; + } +} diff --git a/fe/fe-connector/fe-connector-metastore-paimon/src/main/resources/META-INF/services/org.apache.doris.connector.metastore.spi.MetaStoreProvider b/fe/fe-connector/fe-connector-metastore-paimon/src/main/resources/META-INF/services/org.apache.doris.connector.metastore.spi.MetaStoreProvider new file mode 100644 index 00000000000000..732da9554ccc74 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-paimon/src/main/resources/META-INF/services/org.apache.doris.connector.metastore.spi.MetaStoreProvider @@ -0,0 +1,20 @@ +# 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. +org.apache.doris.connector.metastore.paimon.hms.PaimonHmsMetaStoreProvider +org.apache.doris.connector.metastore.paimon.rest.PaimonRestMetaStoreProvider +org.apache.doris.connector.metastore.paimon.jdbc.PaimonJdbcMetaStoreProvider +org.apache.doris.connector.metastore.paimon.fs.PaimonFileSystemMetaStoreProvider diff --git a/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/MetaStoreProvidersDispatchTest.java b/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/MetaStoreProvidersDispatchTest.java new file mode 100644 index 00000000000000..71c52f4ac5c024 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/MetaStoreProvidersDispatchTest.java @@ -0,0 +1,164 @@ +// 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.metastore.paimon; + +import org.apache.doris.connector.metastore.MetaStoreProperties; +import org.apache.doris.connector.metastore.paimon.fs.PaimonFileSystemMetaStoreProperties; +import org.apache.doris.connector.metastore.paimon.fs.PaimonFileSystemMetaStoreProvider; +import org.apache.doris.connector.metastore.paimon.hms.PaimonHmsMetaStoreProperties; +import org.apache.doris.connector.metastore.paimon.hms.PaimonHmsMetaStoreProvider; +import org.apache.doris.connector.metastore.paimon.jdbc.PaimonJdbcMetaStoreProperties; +import org.apache.doris.connector.metastore.paimon.rest.PaimonRestMetaStoreProperties; +import org.apache.doris.connector.metastore.spi.MetaStoreProviders; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Verifies the ServiceLoader-based discovery on the paimon classpath: all 4 paimon providers register, and + * the first-hit dispatcher selects the right backend by {@code paimon.catalog.type} (no central switch, no + * enum). After the metastore module split, the providers live in fe-connector-metastore-paimon, so this + * dispatch test lives here too (the -spi test classpath has no providers). + */ +public class MetaStoreProvidersDispatchTest { + + private static Map typed(String flavor) { + Map m = new HashMap<>(); + if (flavor != null) { + m.put("paimon.catalog.type", flavor); + } + m.put("warehouse", "wh"); + return m; + } + + private static String providerOf(String flavor) { + return MetaStoreProviders.bind(typed(flavor), Collections.emptyMap()).providerName(); + } + + @Test + public void dispatchesEachFlavorToItsBackend() { + Assertions.assertEquals("HMS", providerOf("hms")); + Assertions.assertEquals("REST", providerOf("rest")); + Assertions.assertEquals("JDBC", providerOf("jdbc")); + Assertions.assertEquals("FILESYSTEM", providerOf("filesystem")); + // case-insensitive flavor + Assertions.assertEquals("HMS", providerOf("HMS")); + } + + @Test + public void absentTypeDefaultsToFilesystem() { + Assertions.assertEquals("FILESYSTEM", providerOf(null)); + } + + @Test + public void unknownTypeHasNoSupportingProvider() { + IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, + () -> MetaStoreProviders.bind(typed("nessie"), Collections.emptyMap())); + Assertions.assertTrue(ex.getMessage().startsWith("No MetaStoreProvider supports the given properties"), + ex.getMessage()); + } + + @Test + public void removedDlfTypeNoLongerDispatches() { + // WHY: paimon.catalog.type=dlf (DLF 1.0 over the vendored thrift ProxyMetaStoreClient) was removed, so it + // must now behave exactly like any unknown flavor — fail loud at bind. MUTATION: leaving the provider + // registered (or its services entry) would silently route to a backend whose client no longer ships. + Assertions.assertThrows(IllegalArgumentException.class, + () -> MetaStoreProviders.bind(typed("dlf"), Collections.emptyMap())); + } + + @Test + public void allFourProvidersAreRegistered() { + Assertions.assertTrue(MetaStoreProviders.registeredNames() + .containsAll(java.util.Arrays.asList("HMS", "REST", "JDBC", "FILESYSTEM")), + "registered=" + MetaStoreProviders.registeredNames()); + // WHY: dlf 1.0 was removed. Its provider must be gone from the ServiceLoader, not merely unreachable — + // a stale services entry would resurrect a backend whose client no longer exists. + Assertions.assertFalse(MetaStoreProviders.registeredNames().contains("DLF"), + "the removed DLF 1.0 provider must not be registered: " + MetaStoreProviders.registeredNames()); + } + + @Test + public void boundPropertiesExposeRawAndProvider() { + MetaStoreProperties ms = MetaStoreProviders.bind(typed("hms"), Collections.emptyMap()); + Assertions.assertEquals("wh", ms.rawProperties().get("warehouse")); + } + + @Test + public void dispatchReturnsTheWiredConcreteImpl() { + // providerName() is a hardcoded literal; assert the actual bound type to catch a mis-wired bind(). + Assertions.assertTrue(MetaStoreProviders.bind(typed("hms"), Collections.emptyMap()) + instanceof PaimonHmsMetaStoreProperties); + Assertions.assertTrue(MetaStoreProviders.bind(typed("rest"), Collections.emptyMap()) + instanceof PaimonRestMetaStoreProperties); + Assertions.assertTrue(MetaStoreProviders.bind(typed("jdbc"), Collections.emptyMap()) + instanceof PaimonJdbcMetaStoreProperties); + Assertions.assertTrue(MetaStoreProviders.bind(typed(null), Collections.emptyMap()) + instanceof PaimonFileSystemMetaStoreProperties); + } + + @Test + public void bindForTypeSelectsByExplicitFlavorNotByPaimonKey() { + // WHY: iceberg carries "iceberg.catalog.type", NOT "paimon.catalog.type". The metastore-spi + // dispatch cannot sniff the hardcoded paimon key for an iceberg catalog; the caller (which has + // already resolved its flavor) passes it explicitly via bindForType. These props deliberately + // OMIT paimon.catalog.type to prove selection is driven by the flavor ARG, not the key. + // MUTATION: if bindForType read props.get("paimon.catalog.type") instead of the flavor arg, no + // provider would match -> IllegalArgumentException -> red. + Map icebergProps = new HashMap<>(); + icebergProps.put("iceberg.catalog.type", "hms"); + icebergProps.put("hive.metastore.uris", "thrift://h:9083"); + MetaStoreProperties ms = MetaStoreProviders.bindForType("hms", icebergProps, Collections.emptyMap()); + Assertions.assertTrue(ms instanceof PaimonHmsMetaStoreProperties, + "bindForType(\"hms\", ...) must select the HMS backend by the explicit flavor"); + // the real (iceberg) props reach bind(), not the flavor token: + Assertions.assertEquals("thrift://h:9083", ms.rawProperties().get("hive.metastore.uris")); + } + + @Test + public void bindForTypeIsCaseInsensitive() { + // WHY: a user who writes "HMS"/"Hms" in iceberg.catalog.type must still route to the HMS backend, + // mirroring supports(Map)'s equalsIgnoreCase. MUTATION: a case-sensitive supportsType -> "HMS" + // matches nothing -> throws -> red. + Assertions.assertTrue(MetaStoreProviders.bindForType("HMS", typed("hms"), Collections.emptyMap()) + instanceof PaimonHmsMetaStoreProperties); + } + + @Test + public void bindForTypeUnknownFlavorThrows() { + // WHY: an unrecognized flavor must fail loudly (same contract as bind(Map)), not silently fall + // through to the filesystem default. MUTATION: routing an unknown flavor to FILESYSTEM -> no + // throw -> red. + IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, + () -> MetaStoreProviders.bindForType("nessie", new HashMap<>(), Collections.emptyMap())); + Assertions.assertTrue(ex.getMessage().startsWith("No MetaStoreProvider supports"), ex.getMessage()); + } + + @Test + public void providersExposeTheirSensitiveKeys() { + // The HMS provider surfaces its sensitive=true keytab keys (for masking when wired in P2-T03); + // FileSystem has no sensitive fields -> empty (pins the default). + Assertions.assertTrue(new PaimonHmsMetaStoreProvider().sensitivePropertyKeys() + .contains("hive.metastore.client.keytab")); + Assertions.assertTrue(new PaimonFileSystemMetaStoreProvider().sensitivePropertyKeys().isEmpty()); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/fs/PaimonFileSystemMetaStorePropertiesTest.java b/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/fs/PaimonFileSystemMetaStorePropertiesTest.java new file mode 100644 index 00000000000000..291bc2a56b391e --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/fs/PaimonFileSystemMetaStorePropertiesTest.java @@ -0,0 +1,49 @@ +// 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.metastore.paimon.fs; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** T2 parity for the filesystem backend (legacy {@code PaimonFileSystemMetaStoreProperties}). */ +public class PaimonFileSystemMetaStorePropertiesTest { + + @Test + public void carriesWarehouseAndDeclaresStorageNeeded() { + Map raw = new HashMap<>(); + raw.put("warehouse", "oss://bucket/wh"); + PaimonFileSystemMetaStoreProperties props = PaimonFileSystemMetaStoreProperties.of(raw); + + Assertions.assertEquals("FILESYSTEM", props.providerName()); + Assertions.assertEquals("oss://bucket/wh", props.getWarehouse()); + Assertions.assertTrue(props.needsStorage()); + props.validate(); // no throw + Assertions.assertEquals("oss://bucket/wh", props.matchedProperties().get("warehouse")); + Assertions.assertEquals(raw, props.rawProperties()); + } + + @Test + public void validateRequiresWarehouse() { + PaimonFileSystemMetaStoreProperties props = PaimonFileSystemMetaStoreProperties.of(new HashMap<>()); + IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, props::validate); + Assertions.assertEquals("Property warehouse is required.", ex.getMessage()); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/hms/PaimonHmsMetaStorePropertiesTest.java b/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/hms/PaimonHmsMetaStorePropertiesTest.java new file mode 100644 index 00000000000000..692421370a998e --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/hms/PaimonHmsMetaStorePropertiesTest.java @@ -0,0 +1,265 @@ +// 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.metastore.paimon.hms; + +import org.apache.doris.kerberos.AuthType; +import org.apache.doris.kerberos.KerberosAuthSpec; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** T2 parity for the HMS backend (legacy {@code HMSBaseProperties}/{@code buildHmsHiveConf}). */ +public class PaimonHmsMetaStorePropertiesTest { + + private static Map raw(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + private static PaimonHmsMetaStoreProperties of(Map raw) { + return PaimonHmsMetaStoreProperties.of(raw, Collections.emptyMap()); + } + + @Test + public void simpleEmitsUriAndSocketTimeoutOnly() { + PaimonHmsMetaStoreProperties props = of(raw("hive.metastore.uris", "thrift://h:9083", "warehouse", "wh")); + + Assertions.assertEquals("HMS", props.providerName()); + Assertions.assertTrue(props.needsStorage()); + Assertions.assertEquals("thrift://h:9083", props.getUri()); + Assertions.assertEquals(AuthType.SIMPLE, props.getAuthType()); + Assertions.assertFalse(props.kerberos().isPresent()); + + Map conf = props.toHiveConfOverrides("10"); + Assertions.assertEquals("thrift://h:9083", conf.get("hive.metastore.uris")); + Assertions.assertEquals("10", conf.get("hive.metastore.client.socket.timeout")); + // No kerberos leakage on a simple catalog. + Assertions.assertFalse(conf.containsKey("hadoop.security.authentication")); + Assertions.assertFalse(conf.containsKey("hive.metastore.sasl.enabled")); + Assertions.assertEquals(2, conf.size()); + } + + @Test + public void kerberosEmitsServicePrincipalSaslAndCarriesClientFacts() { + PaimonHmsMetaStoreProperties props = of(raw( + "hive.metastore.uris", "thrift://h:9083", + "hive.metastore.authentication.type", "kerberos", + "hive.metastore.client.principal", "doris@REALM", + "hive.metastore.client.keytab", "/etc/doris.keytab", + "hive.metastore.service.principal", "hive/_HOST@REALM", + "hadoop.security.auth_to_local", "RULE:[1:$1]", + "warehouse", "wh")); + + Map conf = props.toHiveConfOverrides("10"); + Assertions.assertEquals("kerberos", conf.get("hive.metastore.authentication.type")); + Assertions.assertEquals("doris@REALM", conf.get("hive.metastore.client.principal")); + Assertions.assertEquals("/etc/doris.keytab", conf.get("hive.metastore.client.keytab")); + Assertions.assertEquals("hive/_HOST@REALM", conf.get("hive.metastore.kerberos.principal")); + Assertions.assertEquals("RULE:[1:$1]", conf.get("hadoop.security.auth_to_local")); + Assertions.assertEquals("kerberos", conf.get("hadoop.security.authentication")); + Assertions.assertEquals("true", conf.get("hive.metastore.sasl.enabled")); + + Assertions.assertEquals(AuthType.KERBEROS, props.getAuthType()); + Optional krb = props.kerberos(); + Assertions.assertTrue(krb.isPresent()); + Assertions.assertEquals("doris@REALM", krb.get().getPrincipal()); + Assertions.assertEquals("/etc/doris.keytab", krb.get().getKeytab()); + Assertions.assertTrue(krb.get().hasCredentials()); + } + + @Test + public void kerberosBlockRunsAfterStorageOverlaySoItIsNotClobbered() { + // User sets a raw hadoop.security.authentication=simple (storage passthrough); the kerberos + // block must run LAST and force it back to kerberos (legacy ordering invariant). + PaimonHmsMetaStoreProperties props = of(raw( + "hive.metastore.uris", "thrift://h:9083", + "hive.metastore.authentication.type", "kerberos", + "hive.metastore.client.principal", "p", "hive.metastore.client.keytab", "k", + "hadoop.security.authentication", "simple", + "warehouse", "wh")); + Map conf = props.toHiveConfOverrides("10"); + Assertions.assertEquals("kerberos", conf.get("hadoop.security.authentication")); + Assertions.assertEquals("true", conf.get("hive.metastore.sasl.enabled")); + } + + @Test + public void hdfsKerberosFallbackWhenMetastoreAuthIsNotSet() { + PaimonHmsMetaStoreProperties props = of(raw( + "hive.metastore.uris", "thrift://h:9083", + "hadoop.security.authentication", "kerberos", + "hadoop.kerberos.principal", "hdfs@REALM", + "hadoop.kerberos.keytab", "/etc/hdfs.keytab", + "warehouse", "wh")); + Map conf = props.toHiveConfOverrides("10"); + Assertions.assertEquals("kerberos", conf.get("hadoop.security.authentication")); + Assertions.assertEquals("true", conf.get("hive.metastore.sasl.enabled")); + // Metastore auth type itself is unset -> SIMPLE, but the effective kerberos facts come from HDFS. + Assertions.assertEquals(AuthType.SIMPLE, props.getAuthType()); + Optional krb = props.kerberos(); + Assertions.assertTrue(krb.isPresent()); + Assertions.assertEquals("hdfs@REALM", krb.get().getPrincipal()); + Assertions.assertEquals("/etc/hdfs.keytab", krb.get().getKeytab()); + } + + @Test + public void usernameAliasResolvesToHadoopUsername() { + Map conf = of(raw( + "hive.metastore.uris", "thrift://h", "hive.metastore.username", "bob", "warehouse", "wh")) + .toHiveConfOverrides("10"); + Assertions.assertEquals("bob", conf.get("hadoop.username")); + } + + @Test + public void validateChecksWarehouseThenUriThenAuthRules() { + Assertions.assertEquals("Property warehouse is required.", + Assertions.assertThrows(IllegalArgumentException.class, + () -> of(raw("hive.metastore.uris", "thrift://h")).validate()).getMessage()); + Assertions.assertEquals("hive.metastore.uris or uri is required", + Assertions.assertThrows(IllegalArgumentException.class, + () -> of(raw("warehouse", "wh")).validate()).getMessage()); + // forbidIf simple + Assertions.assertEquals("hive.metastore.client.principal and hive.metastore.client.keytab cannot be set when " + + "hive.metastore.authentication.type is simple", + Assertions.assertThrows(IllegalArgumentException.class, + () -> of(raw("warehouse", "wh", "hive.metastore.uris", "thrift://h", + "hive.metastore.authentication.type", "simple", + "hive.metastore.client.principal", "p")).validate()).getMessage()); + // requireIf kerberos (missing keytab) + Assertions.assertEquals("hive.metastore.client.principal and hive.metastore.client.keytab are required when " + + "hive.metastore.authentication.type is kerberos", + Assertions.assertThrows(IllegalArgumentException.class, + () -> of(raw("warehouse", "wh", "hive.metastore.uris", "thrift://h", + "hive.metastore.authentication.type", "kerberos", + "hive.metastore.client.principal", "p")).validate()).getMessage()); + // valid simple (no client creds) + of(raw("warehouse", "wh", "hive.metastore.uris", "thrift://h", + "hive.metastore.authentication.type", "simple")).validate(); + } + + @Test + public void authTypeMatchIsCaseSensitiveMirroringParamRules() { + // ParamRules.forbidIf uses Objects.equals (case-sensitive): "Simple" != "simple", so the + // forbid rule must NOT fire even though client.principal is set. (A mutation to equalsIgnoreCase + // would make this throw.) + of(raw("warehouse", "wh", "hive.metastore.uris", "thrift://h", + "hive.metastore.authentication.type", "Simple", + "hive.metastore.client.principal", "p")).validate(); + } + + @Test + public void storageOverlayRunsBeforeKerberosBlockViaStorageMapChannel() { + // The clobber candidate arrives ONLY via the storageHadoopConfig map (not raw), so this pins the + // DV-007 invariant through the actual storage channel: step-5 overlay (which writes simple) MUST + // run before step-6 kerberos (which forces kerberos). The marker proves the overlay ran at all. + Map storage = new HashMap<>(); + storage.put("hadoop.security.authentication", "simple"); + storage.put("fs.s3a.marker", "ran"); + Map conf = PaimonHmsMetaStoreProperties.of(raw( + "hive.metastore.uris", "thrift://h", + "hive.metastore.authentication.type", "kerberos", + "hive.metastore.client.principal", "p", "hive.metastore.client.keytab", "k", + "warehouse", "wh"), storage).toHiveConfOverrides("10"); + Assertions.assertEquals("ran", conf.get("fs.s3a.marker")); + Assertions.assertEquals("kerberos", conf.get("hadoop.security.authentication")); + } + + @Test + public void uriPrefersFirstAlias() { + // names = {"hive.metastore.uris", "uri"} -> first alias wins when both are set. + Assertions.assertEquals("thrift://first", + of(raw("hive.metastore.uris", "thrift://first", "uri", "thrift://second", "warehouse", "wh")) + .getUri()); + } + + @Test + public void usernameAliasOverwritesStorageHadoopUsername() { + // Storage overlay (step 5) writes hadoop.username from the storage map; step 7 (after the overlay) + // must overwrite it with the resolved username alias. + Map storage = new HashMap<>(); + storage.put("hadoop.username", "from-storage"); + Map conf = PaimonHmsMetaStoreProperties.of(raw( + "hive.metastore.uris", "thrift://h", "hive.metastore.username", "bob", "warehouse", "wh"), + storage).toHiveConfOverrides("10"); + Assertions.assertEquals("bob", conf.get("hadoop.username")); + } + + @Test + public void hdfsKerberosFallbackSuppressedWhenMetastoreAuthIsSimple() { + // auth type explicitly "simple" -> the HDFS-kerberos fallback must NOT fire (the !simple guard). + PaimonHmsMetaStoreProperties props = of(raw( + "hive.metastore.uris", "thrift://h", + "hive.metastore.authentication.type", "simple", + "hadoop.security.authentication", "kerberos", + "hadoop.kerberos.principal", "hdfs@REALM", "hadoop.kerberos.keytab", "/k", + "warehouse", "wh")); + Assertions.assertFalse(props.kerberos().isPresent()); + Assertions.assertFalse(props.toHiveConfOverrides("10").containsKey("hive.metastore.sasl.enabled")); + } + + @Test + public void userSuppliedSocketTimeoutSurvivesTheDefault() { + Map conf = of(raw( + "hive.metastore.uris", "thrift://h", "hive.metastore.client.socket.timeout", "30", + "warehouse", "wh")).toHiveConfOverrides("10"); + Assertions.assertEquals("30", conf.get("hive.metastore.client.socket.timeout")); + } + + @Test + public void threadedSocketTimeoutDefaultFlowsThrough() { + // C4: the FE-configured hive_metastore_client_timeout_second (threaded as the default arg) is applied + // instead of the hardcoded 10 when the user did not set hive.metastore.client.socket.timeout. + Map conf = of(raw("hive.metastore.uris", "thrift://h", "warehouse", "wh")) + .toHiveConfOverrides("60"); + Assertions.assertEquals("60", conf.get("hive.metastore.client.socket.timeout")); + } + + @Test + public void userSocketTimeoutOverridesThreadedDefault() { + // C4: a per-catalog hive.metastore.client.socket.timeout still wins over the threaded FE default. + Map conf = of(raw( + "hive.metastore.uris", "thrift://h", "hive.metastore.client.socket.timeout", "30", + "warehouse", "wh")).toHiveConfOverrides("60"); + Assertions.assertEquals("30", conf.get("hive.metastore.client.socket.timeout")); + } + + @Test + public void blankThreadedSocketTimeoutFallsBackToTen() { + // C4: defensive fallback — a blank threaded default keeps the historical 10s (legacy parity when unset). + Map conf = of(raw("hive.metastore.uris", "thrift://h", "warehouse", "wh")) + .toHiveConfOverrides(""); + Assertions.assertEquals("10", conf.get("hive.metastore.client.socket.timeout")); + } + + @Test + public void matchedPropertiesIncludesMatchedAliasesAndExcludesUnmatched() { + Map matched = of(raw( + "hive.metastore.uris", "thrift://h", "warehouse", "wh", "some.random.key", "v")) + .matchedProperties(); + Assertions.assertEquals("thrift://h", matched.get("hive.metastore.uris")); + Assertions.assertEquals("wh", matched.get("warehouse")); + Assertions.assertFalse(matched.containsKey("some.random.key")); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/jdbc/PaimonJdbcMetaStorePropertiesTest.java b/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/jdbc/PaimonJdbcMetaStorePropertiesTest.java new file mode 100644 index 00000000000000..cc6842020b4e4a --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/jdbc/PaimonJdbcMetaStorePropertiesTest.java @@ -0,0 +1,100 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.metastore.paimon.jdbc; + +import org.apache.doris.connector.metastore.spi.JdbcDriverSupport; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** T2 parity for the JDBC backend (legacy {@code PaimonJdbcMetaStoreProperties}) + driver-url resolution. */ +public class PaimonJdbcMetaStorePropertiesTest { + + private static Map raw(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + @Test + public void gettersReturnRawAliasResolvedValues() { + PaimonJdbcMetaStoreProperties props = PaimonJdbcMetaStoreProperties.of(raw( + "uri", "jdbc:mysql://h:3306/db", + "paimon.jdbc.user", "u", + "paimon.jdbc.password", "p", + "paimon.jdbc.driver_url", "mysql-connector.jar", + "paimon.jdbc.driver_class", "com.mysql.cj.jdbc.Driver", + "warehouse", "wh")); + + Assertions.assertEquals("JDBC", props.providerName()); + Assertions.assertFalse(props.needsStorage()); + Assertions.assertEquals("jdbc:mysql://h:3306/db", props.getUri()); + Assertions.assertEquals("u", props.getUser()); + Assertions.assertEquals("p", props.getPassword()); + // RAW driver url (resolution is consumer-side via JdbcDriverSupport). + Assertions.assertEquals("mysql-connector.jar", props.getDriverUrl()); + Assertions.assertEquals("com.mysql.cj.jdbc.Driver", props.getDriverClass()); + } + + @Test + public void validateChecksWarehouseThenUriThenDriverClass() { + Assertions.assertEquals("Property warehouse is required.", + Assertions.assertThrows(IllegalArgumentException.class, + () -> PaimonJdbcMetaStoreProperties.of(raw("uri", "jdbc:x")).validate()).getMessage()); + Assertions.assertEquals("uri or paimon.jdbc.uri is required", + Assertions.assertThrows(IllegalArgumentException.class, + () -> PaimonJdbcMetaStoreProperties.of(raw("warehouse", "wh")).validate()).getMessage()); + Assertions.assertEquals("jdbc.driver_class or paimon.jdbc.driver_class is required when " + + "jdbc.driver_url or paimon.jdbc.driver_url is specified", + Assertions.assertThrows(IllegalArgumentException.class, + () -> PaimonJdbcMetaStoreProperties.of(raw( + "warehouse", "wh", "uri", "jdbc:x", "paimon.jdbc.driver_url", "d.jar")).validate()) + .getMessage()); + } + + @Test + public void resolveDriverUrl() { + Map env = new HashMap<>(); + // already scheme-bearing -> as-is + Assertions.assertEquals("https://host/d.jar", JdbcDriverSupport.resolveDriverUrl("https://host/d.jar", env)); + // absolute path -> as-is (no driversDir prepend) + Assertions.assertEquals("/opt/drivers/d.jar", JdbcDriverSupport.resolveDriverUrl("/opt/drivers/d.jar", env)); + // bare jar with explicit drivers dir + env.put("jdbc_drivers_dir", "/custom/drivers"); + Assertions.assertEquals("file:///custom/drivers/d.jar", JdbcDriverSupport.resolveDriverUrl("d.jar", env)); + // bare jar falling back to doris_home/plugins/jdbc_drivers + Map env2 = new HashMap<>(); + env2.put("doris_home", "/dh"); + Assertions.assertEquals("file:///dh/plugins/jdbc_drivers/d.jar", JdbcDriverSupport.resolveDriverUrl("d.jar", env2)); + // empty env -> doris_home defaults to "." + Assertions.assertEquals("file://./plugins/jdbc_drivers/d.jar", + JdbcDriverSupport.resolveDriverUrl("d.jar", new HashMap<>())); + } + + @Test + public void uriPrefersFirstAlias() { + // names = {"uri", "paimon.jdbc.uri"} -> the plain "uri" wins when both are set. + Assertions.assertEquals("jdbc:a", PaimonJdbcMetaStoreProperties.of(raw( + "uri", "jdbc:a", "paimon.jdbc.uri", "jdbc:b", "warehouse", "wh")).getUri()); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/rest/PaimonRestMetaStorePropertiesTest.java b/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/rest/PaimonRestMetaStorePropertiesTest.java new file mode 100644 index 00000000000000..b5cd4824d3daac --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/rest/PaimonRestMetaStorePropertiesTest.java @@ -0,0 +1,95 @@ +// 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.metastore.paimon.rest; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** T2 parity for the REST backend (legacy {@code PaimonRestMetaStoreProperties} / {@code appendRestOptions}). */ +public class PaimonRestMetaStorePropertiesTest { + + private static Map raw(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + @Test + public void toRestOptionsStripsPaimonRestPrefix() { + PaimonRestMetaStoreProperties props = PaimonRestMetaStoreProperties.of(raw( + "paimon.rest.uri", "http://rest:8080", + "paimon.rest.token.provider", "dlf", + "warehouse", "wh")); + + Assertions.assertEquals("REST", props.providerName()); + Assertions.assertFalse(props.needsStorage()); + Assertions.assertEquals("http://rest:8080", props.getUri()); + + Map opts = props.toRestOptions(); + Assertions.assertEquals("http://rest:8080", opts.get("uri")); + Assertions.assertEquals("dlf", opts.get("token.provider")); + // Raw catalog keys (warehouse) are NOT REST options. + Assertions.assertFalse(opts.containsKey("warehouse")); + } + + @Test + public void uriAliasResolvesFromPlainUri() { + PaimonRestMetaStoreProperties props = + PaimonRestMetaStoreProperties.of(raw("uri", "http://plain", "warehouse", "wh")); + Assertions.assertEquals("http://plain", props.getUri()); + Assertions.assertEquals("http://plain", props.toRestOptions().get("uri")); + } + + @Test + public void validateChecksWarehouseThenUriThenDlfToken() { + // warehouse first (shared, legacy parity) + Assertions.assertEquals("Property warehouse is required.", + Assertions.assertThrows(IllegalArgumentException.class, + () -> PaimonRestMetaStoreProperties.of(raw("paimon.rest.uri", "http://r")).validate()) + .getMessage()); + // then uri + Assertions.assertEquals("paimon.rest.uri or uri is required", + Assertions.assertThrows(IllegalArgumentException.class, + () -> PaimonRestMetaStoreProperties.of(raw("warehouse", "wh")).validate()).getMessage()); + // then the DLF token-provider rule + Assertions.assertEquals("DLF token provider requires 'paimon.rest.dlf.access-key-id' " + + "and 'paimon.rest.dlf.access-key-secret'", + Assertions.assertThrows(IllegalArgumentException.class, + () -> PaimonRestMetaStoreProperties.of(raw( + "warehouse", "wh", "paimon.rest.uri", "http://r", + "paimon.rest.token.provider", "dlf")).validate()).getMessage()); + // valid (dlf token with both keys) -> no throw + PaimonRestMetaStoreProperties.of(raw( + "warehouse", "wh", "paimon.rest.uri", "http://r", "paimon.rest.token.provider", "dlf", + "paimon.rest.dlf.access-key-id", "id", "paimon.rest.dlf.access-key-secret", "secret")).validate(); + } + + @Test + public void dlfTokenRuleIsCaseSensitiveMatchingLegacyParamRules() { + // Legacy ParamRules.requireIf uses Objects.equals("dlf", tokenProvider) (case-sensitive), so an + // uppercase "DLF" does NOT trigger the dlf-keys requirement. (The paimon hand-copy's equalsIgnoreCase + // would throw here; we match the authoritative legacy contract.) + PaimonRestMetaStoreProperties.of(raw( + "warehouse", "wh", "paimon.rest.uri", "http://r", "paimon.rest.token.provider", "DLF")).validate(); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-spi/pom.xml b/fe/fe-connector/fe-connector-metastore-spi/pom.xml new file mode 100644 index 00000000000000..e262d4304745ab --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-spi/pom.xml @@ -0,0 +1,101 @@ + + + + 4.0.0 + + + org.apache.doris + fe-connector + ${revision} + ../pom.xml + + + fe-connector-metastore-spi + jar + Doris FE Connector Metastore SPI + + Shared metastore connection-fact parsers + provider discovery for the + fe-connector-metastore-api contracts. Each backend (HMS/DLF/REST/JDBC/ + FileSystem) parses the raw property map (+ a pre-computed neutral storage + Hadoop-config map) into the corresponding *MetaStoreProperties facts, and + is discovered by MetaStoreProvider.supports(Map) + ServiceLoader (D-006, + mirroring fe-filesystem's FileSystemProvider). Holds only neutral Map/ + scalar facts; never leaks HiveConf/Hadoop/SDK types (those are assembled + connector-side). Storage arrives as an opaque string map so this module + stays hadoop/fs-free (DV-007); fe-kerberos supplies the neutral AuthType / + KerberosAuthSpec value objects (DV-006, no new code there). + + + + + ${project.groupId} + fe-connector-metastore-api + ${project.version} + + + + ${project.groupId} + fe-extension-spi + ${project.version} + + + + ${project.groupId} + fe-foundation + ${project.version} + + + + ${project.groupId} + fe-kerberos + ${project.version} + + + + org.apache.commons + commons-lang3 + + + org.junit.jupiter + junit-jupiter + test + + + + + doris-fe-connector-metastore-spi + + + org.apache.maven.plugins + maven-dependency-plugin + + + + copy-plugin-deps + none + + + + + + diff --git a/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/AbstractHmsMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/AbstractHmsMetaStoreProperties.java new file mode 100644 index 00000000000000..9614ba2f086c6c --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/AbstractHmsMetaStoreProperties.java @@ -0,0 +1,216 @@ +// 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.metastore.spi; + +import org.apache.doris.connector.metastore.HmsMetaStoreProperties; +import org.apache.doris.foundation.property.ConnectorProperty; +import org.apache.doris.kerberos.AuthType; +import org.apache.doris.kerberos.KerberosAuthSpec; + +import org.apache.commons.lang3.StringUtils; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Engine-neutral Hive Metastore (HMS) backend base: the field set, the {@link #toHiveConfOverrides(String)} + * conf assembly, and the {@link #validateConnection()} connection rules shared by every engine (paimon, + * iceberg, ...). Concrete per-engine subclasses live in their own module and supply only the {@code of(...)} + * factory and {@code validate()} (paimon adds {@code requireWarehouse()}, iceberg uses the connection check + * alone). This realizes the "MetaStoreProperties capability lives in the shared metastore layer, engines as + * peers" split — mirroring fe-filesystem's interface-only spi + per-backend impls. + * + *

    {@link #toHiveConfOverrides(String)} produces the neutral key map the connector layers onto its own + * {@code HiveConf} (the connector seeds {@code new HiveConf()} + {@code hive.conf.resources} first, then + * applies these overrides). Ported faithfully from the paimon connector's {@code buildHmsHiveConf}, whose + * ordering is load-bearing: the storage overlay runs BEFORE the kerberos block so a raw + * {@code hadoop.security.authentication=simple} passthrough cannot clobber the forced {@code kerberos}. + * + *

    The real {@code UGI.doAs} is performed FE-side via {@code ConnectorContext.executeAuthenticated}; + * this base only carries facts ({@link #getAuthType()}, {@link #kerberos()}, neutral string keys), so + * no hadoop authenticator code is needed here (DV-006). + */ +public abstract class AbstractHmsMetaStoreProperties extends AbstractMetaStoreProperties + implements HmsMetaStoreProperties { + + @ConnectorProperty(names = {"hive.metastore.uris", "uri"}, required = false, + description = "The hive metastore thrift URI.") + private String uri = ""; + + // Default "" (NOT "none"): emit-when-present parity (legacy copyIfPresent only sets it when the raw + // key is present); the kerberos branch below treats blank as "none" via getOrDefault semantics. + @ConnectorProperty(names = {"hive.metastore.authentication.type"}, required = false, + description = "The hive metastore authentication type.") + private String authType = ""; + + @ConnectorProperty(names = {"hive.metastore.client.principal"}, required = false, + description = "The client principal of the hive metastore.") + private String clientPrincipal = ""; + + @ConnectorProperty(names = {"hive.metastore.client.keytab"}, required = false, sensitive = true, + description = "The client keytab of the hive metastore.") + private String clientKeytab = ""; + + @ConnectorProperty(names = {"hadoop.security.authentication"}, required = false, + description = "The HDFS authentication type (kerberos fallback).") + private String hdfsAuthType = ""; + + @ConnectorProperty(names = {"hadoop.kerberos.principal"}, required = false, + description = "The HDFS kerberos principal (kerberos fallback).") + private String hdfsKerberosPrincipal = ""; + + @ConnectorProperty(names = {"hadoop.kerberos.keytab"}, required = false, sensitive = true, + description = "The HDFS kerberos keytab (kerberos fallback).") + private String hdfsKerberosKeytab = ""; + + @ConnectorProperty(names = {"hive.metastore.service.principal", "hive.metastore.kerberos.principal"}, + required = false, description = "The hive metastore service principal.") + private String servicePrincipal = ""; + + @ConnectorProperty(names = {"hive.metastore.username", "hadoop.username"}, required = false, + description = "The user name for the hive metastore service.") + private String userName = ""; + + private final Map storageHadoopConfig; + + protected AbstractHmsMetaStoreProperties(Map raw, Map storageHadoopConfig) { + super(raw); + this.storageHadoopConfig = storageHadoopConfig; + } + + @Override + public String providerName() { + return "HMS"; + } + + @Override + public boolean needsStorage() { + return true; + } + + /** + * Engine-neutral HMS connection rules (legacy {@code HMSBaseProperties.buildRules}). Fire order: + * uri-required, then the CASE-SENSITIVE simple/kerberos auth-credential rules. Paimon prepends + * {@code requireWarehouse()}; iceberg uses this check alone (it omits warehouse) — see §4 of the + * P6-T10 design. Subclasses call this from their {@code validate()}. + */ + protected void validateConnection() { + if (StringUtils.isBlank(uri)) { + throw new IllegalArgumentException("hive.metastore.uris or uri is required"); + } + // forbidIf(authType, "simple", {clientPrincipal, clientKeytab}) — legacy HMSBaseProperties.buildRules + // uses CASE-SENSITIVE Objects.equals (the paimon hand-copy omits this rule; restored here — D-4). + if ("simple".equals(authType) + && (StringUtils.isNotBlank(clientPrincipal) || StringUtils.isNotBlank(clientKeytab))) { + throw new IllegalArgumentException( + "hive.metastore.client.principal and hive.metastore.client.keytab cannot be set when " + + "hive.metastore.authentication.type is simple"); + } + // requireIf(authType, "kerberos", {clientPrincipal, clientKeytab}) — also CASE-SENSITIVE. + if ("kerberos".equals(authType) + && (StringUtils.isBlank(clientPrincipal) || StringUtils.isBlank(clientKeytab))) { + throw new IllegalArgumentException( + "hive.metastore.client.principal and hive.metastore.client.keytab are required when " + + "hive.metastore.authentication.type is kerberos"); + } + } + + @Override + public String getUri() { + return uri; + } + + @Override + public AuthType getAuthType() { + return AuthType.fromString(authType); + } + + @Override + public Optional kerberos() { + // Mirrors HMSBaseProperties.initHadoopAuthenticator: kerberos HMS -> client creds; else the + // legacy HDFS-kerberos fallback -> hdfs creds (case-insensitive, matching the conf-build branch). + String effectiveAuthType = StringUtils.isNotBlank(authType) ? authType : "none"; + if ("kerberos".equalsIgnoreCase(effectiveAuthType)) { + return Optional.of(new KerberosAuthSpec(clientPrincipal, clientKeytab)); + } + if (!"simple".equalsIgnoreCase(effectiveAuthType) && "kerberos".equalsIgnoreCase(hdfsAuthType)) { + return Optional.of(new KerberosAuthSpec(hdfsKerberosPrincipal, hdfsKerberosKeytab)); + } + return Optional.empty(); + } + + @Override + public Map toHiveConfOverrides(String defaultClientSocketTimeoutSeconds) { + Map conf = new LinkedHashMap<>(); + // 1. All user hive.* keys verbatim (legacy initUserHiveConfig). + raw.forEach((k, v) -> { + if (k.startsWith("hive.")) { + conf.put(k, v); + } + }); + // 2. Metastore uri (legacy checkAndInit: hiveConf.set("hive.metastore.uris", uri)). + if (StringUtils.isNotBlank(uri)) { + conf.put("hive.metastore.uris", uri); + } + // 3. Present auth keys, in legacy copyIfPresent order. Single-alias fields == raw values; the + // hadoop.* ones are not covered by step 1's hive.* passthrough. + putIfNotBlank(conf, "hive.metastore.authentication.type", authType); + putIfNotBlank(conf, "hive.metastore.client.principal", clientPrincipal); + putIfNotBlank(conf, "hive.metastore.client.keytab", clientKeytab); + putIfNotBlank(conf, "hadoop.security.authentication", hdfsAuthType); + putIfNotBlank(conf, "hadoop.kerberos.principal", hdfsKerberosPrincipal); + putIfNotBlank(conf, "hadoop.kerberos.keytab", hdfsKerberosKeytab); + // 4. Metastore client socket-timeout default. Legacy checkAndInit applied + // Config.hive_metastore_client_timeout_second (default 10s) when the user had not set + // hive.metastore.client.socket.timeout. metastore-spi cannot read FE Config, so the engine threads the + // configured default in via ConnectorContext.getEnvironment() (C4); blank falls back to the legacy 10s. + if (StringUtils.isBlank(raw.get("hive.metastore.client.socket.timeout"))) { + conf.put("hive.metastore.client.socket.timeout", + StringUtils.isNotBlank(defaultClientSocketTimeoutSeconds) + ? defaultClientSocketTimeoutSeconds : "10"); + } + // 5. Storage overlay (legacy buildHiveConfiguration + appendUserHadoopConfig). BEFORE kerberos. + MetaStoreParseUtils.applyStorageConfig(storageHadoopConfig, raw, conf::put); + // 6. Kerberos-conditional metastore block (legacy initHadoopAuthenticator), LAST. + if (StringUtils.isNotBlank(servicePrincipal)) { + conf.put("hive.metastore.kerberos.principal", servicePrincipal); + } + MetaStoreParseUtils.copyIfPresent(raw, "hadoop.security.auth_to_local", conf::put); + String hmsAuthType = StringUtils.isNotBlank(authType) ? authType : "none"; + boolean hmsKerberos = "kerberos".equalsIgnoreCase(hmsAuthType); + boolean hdfsFallbackKerberos = !"simple".equalsIgnoreCase(hmsAuthType) + && !hmsKerberos + && "kerberos".equalsIgnoreCase(hdfsAuthType); + if (hmsKerberos || hdfsFallbackKerberos) { + conf.put("hadoop.security.authentication", "kerberos"); + conf.put("hive.metastore.sasl.enabled", "true"); + } + // 7. Username alias resolved to hadoop.username, after the storage overlay. + if (StringUtils.isNotBlank(userName)) { + conf.put("hadoop.username", userName); + } + return conf; + } + + private static void putIfNotBlank(Map conf, String key, String value) { + if (StringUtils.isNotBlank(value)) { + conf.put(key, value); + } + } +} diff --git a/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/AbstractMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/AbstractMetaStoreProperties.java new file mode 100644 index 00000000000000..ec311d4eaf6a14 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/AbstractMetaStoreProperties.java @@ -0,0 +1,62 @@ +// 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.metastore.spi; + +import org.apache.doris.connector.metastore.MetaStoreProperties; +import org.apache.doris.foundation.property.ConnectorProperty; + +import org.apache.commons.lang3.StringUtils; + +import java.util.Map; + +/** + * Common state for the backend property impls: the raw map, the shared {@code warehouse} property + * (legacy {@code AbstractPaimonProperties.warehouse}, required by all paimon flavors), and the + * {@code matchedProperties()} derivation. Subclasses bind their {@code @ConnectorProperty} fields via + * {@code ConnectorPropertiesUtils.bindConnectorProperties} in their {@code of(...)} factory AFTER + * construction (so subclass field initializers do not clobber the bound values). + */ +public abstract class AbstractMetaStoreProperties implements MetaStoreProperties { + + @ConnectorProperty(names = {"warehouse"}, required = false, + description = "Warehouse root location for the catalog.") + protected String warehouse = ""; + + protected final Map raw; + + protected AbstractMetaStoreProperties(Map raw) { + this.raw = raw; + } + + @Override + public Map rawProperties() { + return raw; + } + + @Override + public Map matchedProperties() { + return MetaStoreParseUtils.matchedProperties(this, raw); + } + + /** Shared fail-fast: {@code warehouse} is required by every paimon flavor (legacy parity). */ + protected void requireWarehouse() { + if (StringUtils.isBlank(warehouse)) { + throw new IllegalArgumentException("Property warehouse is required."); + } + } +} diff --git a/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/JdbcDriverSupport.java b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/JdbcDriverSupport.java new file mode 100644 index 00000000000000..176763295a3d3e --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/JdbcDriverSupport.java @@ -0,0 +1,63 @@ +// 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.metastore.spi; + +import org.apache.commons.lang3.StringUtils; + +import java.util.Map; + +/** + * Shared JDBC driver-url resolution. Only the PURE resolver lives here (a function of the raw + * {@code driver_url} + the engine environment map). The live driver REGISTRATION + * ({@code DriverManager.registerDriver} + the {@code DriverShim} + the class-loader cache) is a JVM + * side-effect with no caller until the paimon adapter cuts over (P2-T03), so it is intentionally NOT + * moved here yet (Rule 2: no speculative dead code). + */ +public final class JdbcDriverSupport { + + private JdbcDriverSupport() { + } + + /** + * Resolves a JDBC {@code driver_url} to a full, scheme-bearing URL string. A value already + * carrying a scheme ({@code "://"}) is used as-is; an absolute path (starting with {@code "/"}) is + * returned unchanged; otherwise it is treated as a bare jar file name and resolved against the + * engine's configured {@code jdbc_drivers_dir} (defaulting to + * {@code $DORIS_HOME/plugins/jdbc_drivers}). Mirrors the minimal {@code JdbcResource.getFullDriverUrl} + * resolution (no file-existence / legacy old-dir / cloud-download handling), so the FE driver + * registration and the BE-bound options resolve a given {@code driver_url} identically. + * + * @param driverUrl the raw driver_url; must be non-null and non-blank (the caller's responsibility) + * @param env the engine environment map (e.g. {@code jdbc_drivers_dir}, {@code doris_home}); never null + */ + public static String resolveDriverUrl(String driverUrl, Map env) { + if (driverUrl.contains("://")) { + return driverUrl; + } + if (driverUrl.startsWith("/")) { + // Absolute path, no scheme: legacy returns it as-is (no driversDir prepend). + return driverUrl; + } + String driversDir = env.get("jdbc_drivers_dir"); + if (StringUtils.isBlank(driversDir)) { + String dorisHome = env.getOrDefault("doris_home", "."); + driversDir = dorisHome + "/plugins/jdbc_drivers"; + } + return "file://" + driversDir + "/" + driverUrl; + } +} diff --git a/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreParseUtils.java b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreParseUtils.java new file mode 100644 index 00000000000000..c1b4fd07dbe8cb --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreParseUtils.java @@ -0,0 +1,122 @@ +// 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.metastore.spi; + +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; + +import org.apache.commons.lang3.StringUtils; + +import java.lang.reflect.Field; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.BiConsumer; + +/** + * Neutral parse helpers shared by the metastore backend parsers. All methods are pure functions of + * the input maps and hold no Hadoop/SDK state, keeping this module hadoop/fs-free (DV-007). Ported + * from the paimon connector's hand-copied helpers (the up-move source). + */ +public final class MetaStoreParseUtils { + + /** + * The backend dispatch signal each {@link MetaStoreProvider} self-identifies on. This is the + * paimon connector's key (the up-move source); the SPI is paimon-sourced for now (a generic + * hive/iceberg detector could broaden the signal later — out of P2-T02 scope). + */ + public static final String CATALOG_TYPE_KEY = "paimon.catalog.type"; + + /** Hadoop S3A standard prefix (legacy {@code AbstractPaimonProperties.FS_S3A_PREFIX}). */ + public static final String FS_S3A_PREFIX = "fs.s3a."; + + /** + * User storage prefixes re-keyed onto {@link #FS_S3A_PREFIX} during the storage overlay + * (legacy {@code AbstractPaimonProperties.userStoragePrefixes}). + */ + public static final String[] USER_STORAGE_PREFIXES = { + "paimon.s3.", "paimon.s3a.", "paimon.fs.s3.", "paimon.fs.oss."}; + + private MetaStoreParseUtils() { + } + + /** + * Returns the first non-blank value among the given keys, or {@code null} if none is set. + * Mirrors the alias-priority semantics of {@code @ConnectorProperty(names=...)}. + */ + public static String firstNonBlank(Map props, String... keys) { + for (String key : keys) { + String value = props.get(key); + if (StringUtils.isNotBlank(value)) { + return value; + } + } + return null; + } + + /** Emits {@code (key, props.get(key))} to {@code setter} when the value is present and non-blank. */ + public static void copyIfPresent(Map props, String key, BiConsumer setter) { + String value = props.get(key); + if (StringUtils.isNotBlank(value)) { + setter.accept(key, value); + } + } + + /** Returns {@code ""} for a null input, otherwise the input unchanged. */ + public static String nullToEmpty(String s) { + return s == null ? "" : s; + } + + /** + * Two-step storage overlay (legacy {@code AbstractPaimonProperties} precedence order): first the + * pre-computed canonical storage config, then the original + * {@code paimon.s3./s3a./fs.s3./fs.oss.} re-key plus raw {@code fs./dfs./hadoop.} passthrough, + * which run LAST and overlay the canonical translation (last-write-wins). An HDFS catalog's + * {@code hadoop.config.resources} XML + HA + auth keys arrive via {@code storageHadoopConfig} (C2); + * inline HDFS keys still ride the raw passthrough. + */ + public static void applyStorageConfig(Map storageHadoopConfig, + Map props, BiConsumer setter) { + storageHadoopConfig.forEach(setter); + props.forEach((key, value) -> { + for (String prefix : USER_STORAGE_PREFIXES) { + if (key.startsWith(prefix)) { + setter.accept(FS_S3A_PREFIX + key.substring(prefix.length()), value); + return; // stop after the first matching prefix (legacy normalizeS3Config) + } + } + if (key.startsWith("fs.") || key.startsWith("dfs.") || key.startsWith("hadoop.")) { + setter.accept(key, value); + } + }); + } + + /** + * The subset of {@code raw} actually matched by the {@code @ConnectorProperty} aliases declared on + * {@code holder} (first-alias-wins, preserving declaration order). Backs + * {@code MetaStoreProperties.matchedProperties()}. + */ + public static Map matchedProperties(Object holder, Map raw) { + Map matched = new LinkedHashMap<>(); + for (Field field : ConnectorPropertiesUtils.getConnectorProperties(holder.getClass())) { + String name = ConnectorPropertiesUtils.getMatchedPropertyName(field, raw); + if (name != null) { + matched.put(name, raw.get(name)); + } + } + return matched; + } +} diff --git a/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreProvider.java b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreProvider.java new file mode 100644 index 00000000000000..d60844a204d1e0 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreProvider.java @@ -0,0 +1,89 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.metastore.spi; + +import org.apache.doris.connector.metastore.MetaStoreProperties; +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.Set; + +/** + * Backend discovery SPI for metastore connection properties — the metastore-side counterpart of + * fe-filesystem's {@code FileSystemProvider}. Adding a backend = a new provider + one line in + * {@code META-INF/services}; the API/SPI need no change and there is no central {@code switch} + * (D-006). + * + *

    A provider self-identifies via {@link #supports(Map)} (reads a cheap, deterministic signal + * such as {@code paimon.catalog.type}) and, when selected, parses the raw properties into its typed + * {@link MetaStoreProperties} facts via {@link #bind(Map, Map)}. Discovery is via + * {@link java.util.ServiceLoader}; a catalog has exactly one metastore, so the dispatcher + * ({@link MetaStoreProviders#bind}) selects the FIRST supporting provider. + * + * @param

    the concrete {@link MetaStoreProperties} subtype produced + */ +public interface MetaStoreProvider

    extends PluginFactory { + + /** + * Cheap, deterministic self-identification on the catalog-type token alone (e.g. {@code "hms"}). + * This is the dispatch primitive: it depends ONLY on the resolved flavor string, NOT on which + * property key carried it, so a caller that has already resolved its flavor (paimon's + * {@code paimon.catalog.type} OR iceberg's {@code iceberg.catalog.type}) can select a backend via + * {@link MetaStoreProviders#bindForType} without this module hardcoding any one connector's key. + */ + boolean supportsType(String catalogType); + + /** + * Map-keyed self-identification, preserved for the paimon dispatch path: reads the flavor from the + * hardcoded {@link MetaStoreParseUtils#CATALOG_TYPE_KEY} and delegates to {@link #supportsType}. + * Behavior is byte-identical to the former per-provider {@code supports(Map)} overrides. + */ + default boolean supports(Map properties) { + return supportsType(properties.get(MetaStoreParseUtils.CATALOG_TYPE_KEY)); + } + + /** + * Parses the raw properties (plus a pre-computed neutral storage Hadoop-config map, used by the + * backends whose {@link MetaStoreProperties#needsStorage()} is true to overlay storage into the + * conf in the parity-critical order) into the typed facts. + * + * @param properties the raw CREATE-CATALOG properties + * @param storageHadoopConfig the canonical object-store Hadoop config (may be empty; never null), + * pre-computed by the FE/connector from the bound storage properties; + * kept neutral so this module stays hadoop/fs-free (DV-007) + */ + P bind(Map properties, Map storageHadoopConfig); + + /** Alias keys of sensitive properties (for masking in logs); empty by default. */ + default Set sensitivePropertyKeys() { + return Collections.emptySet(); + } + + @Override + default String name() { + return getClass().getSimpleName().replace("MetaStoreProvider", ""); + } + + @Override + default Plugin create() { + throw new UnsupportedOperationException( + "MetaStoreProvider does not support no-arg create(); use bind(Map, Map) instead."); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreProviders.java b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreProviders.java new file mode 100644 index 00000000000000..2962c6a544a2da --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreProviders.java @@ -0,0 +1,106 @@ +// 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.metastore.spi; + +import org.apache.doris.connector.metastore.MetaStoreProperties; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.stream.Collectors; + +/** + * Dispatches a raw property map to the first {@link MetaStoreProvider} that + * {@link MetaStoreProvider#supports(Map) supports} it (a catalog has exactly one metastore), mirroring + * the first-hit semantics of {@code FileSystemPluginManager.createFileSystem}. Providers are + * discovered via {@link ServiceLoader} (the {@code META-INF/services} entries), so there is no + * central {@code switch} and no per-backend enum (D-006). + */ +public final class MetaStoreProviders { + + private static final List PROVIDERS = load(); + + private MetaStoreProviders() { + } + + private static List load() { + List list = new ArrayList<>(); + // Use the SPI interface's own defining classloader, not the thread-context classloader. + // At CREATE CATALOG time this static initializer is first triggered from + // PaimonConnectorProvider.validateProperties, which runs on an FE worker thread whose TCCL is + // the FE app loader. fe-core does not depend on fe-connector-metastore-spi, so the providers and + // their META-INF/services file live only inside the connector plugin's (child) classloader; a + // 1-arg ServiceLoader.load (TCCL) therefore finds nothing and caches an empty list process-wide. + // MetaStoreProvider.class.getClassLoader() is the plugin loader that defined this interface, so it + // can see the service file and the impls regardless of the caller's TCCL. + ServiceLoader.load(MetaStoreProvider.class, MetaStoreProvider.class.getClassLoader()).forEach(list::add); + return list; + } + + /** + * Binds {@code properties} to the typed facts of the first supporting backend. + * + * @param properties the raw CREATE-CATALOG properties + * @param storageHadoopConfig the pre-computed neutral storage Hadoop config (may be empty; never null) + * @return the bound {@link MetaStoreProperties} + * @throws IllegalArgumentException if no registered provider supports {@code properties} + */ + public static MetaStoreProperties bind(Map properties, + Map storageHadoopConfig) { + for (MetaStoreProvider provider : PROVIDERS) { + if (provider.supports(properties)) { + return provider.bind(properties, storageHadoopConfig); + } + } + throw new IllegalArgumentException( + "No MetaStoreProvider supports the given properties; registered providers: " + registeredNames()); + } + + /** + * Binds {@code properties} to the typed facts of the backend matching an EXPLICIT catalog-type + * token, decoupled from any one connector's property key. Paimon dispatches via {@link #bind} + * (which reads the hardcoded {@code paimon.catalog.type}); iceberg — whose flavor lives under + * {@code iceberg.catalog.type} — resolves its flavor itself and passes it here, so the metastore-spi + * never has to learn iceberg's key. The selected provider's {@link MetaStoreProvider#bind} still + * receives the FULL raw {@code properties} (not the token), so the bound facts are identical to the + * map-keyed path. + * + * @param catalogType the resolved metastore flavor (e.g. {@code "hms"} / {@code "dlf"}) + * @param properties the raw CREATE-CATALOG properties + * @param storageHadoopConfig the pre-computed neutral storage Hadoop config (may be empty; never null) + * @return the bound {@link MetaStoreProperties} + * @throws IllegalArgumentException if no registered provider supports {@code catalogType} + */ + public static MetaStoreProperties bindForType(String catalogType, Map properties, + Map storageHadoopConfig) { + for (MetaStoreProvider provider : PROVIDERS) { + if (provider.supportsType(catalogType)) { + return provider.bind(properties, storageHadoopConfig); + } + } + throw new IllegalArgumentException( + "No MetaStoreProvider supports catalog type '" + catalogType + "'; registered providers: " + + registeredNames()); + } + + /** Names of the registered providers (for diagnostics). */ + public static List registeredNames() { + return PROVIDERS.stream().map(MetaStoreProvider::name).collect(Collectors.toList()); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-spi/src/test/java/org/apache/doris/connector/metastore/spi/MetaStoreParseUtilsTest.java b/fe/fe-connector/fe-connector-metastore-spi/src/test/java/org/apache/doris/connector/metastore/spi/MetaStoreParseUtilsTest.java new file mode 100644 index 00000000000000..f5498021087105 --- /dev/null +++ b/fe/fe-connector/fe-connector-metastore-spi/src/test/java/org/apache/doris/connector/metastore/spi/MetaStoreParseUtilsTest.java @@ -0,0 +1,94 @@ +// 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.metastore.spi; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Pins the shared storage-overlay + alias helpers. {@code applyStorageConfig} is the most + * parity-fragile code in the module (the {@code paimon.s3.}/{@code fs.oss.} -> {@code fs.s3a.} re-key) + * and is exercised here directly so a regression cannot slip through the backend tests. + */ +public class MetaStoreParseUtilsTest { + + private static Map raw(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + private static Map overlay(Map storage, Map props) { + Map out = new LinkedHashMap<>(); + MetaStoreParseUtils.applyStorageConfig(storage, props, out::put); + return out; + } + + @Test + public void reKeysPaimonStoragePrefixesToFsS3a() { + Map out = overlay(new HashMap<>(), raw( + "paimon.s3.access-key", "ak", + "paimon.s3a.path.style.access", "true", + "paimon.fs.s3.region", "us-east-1", + "paimon.fs.oss.endpoint", "oss-ep")); + Assertions.assertEquals("ak", out.get("fs.s3a.access-key")); + Assertions.assertEquals("true", out.get("fs.s3a.path.style.access")); + Assertions.assertEquals("us-east-1", out.get("fs.s3a.region")); + Assertions.assertEquals("oss-ep", out.get("fs.s3a.endpoint")); + // the original prefixed keys are NOT carried verbatim + Assertions.assertFalse(out.containsKey("paimon.s3.access-key")); + } + + @Test + public void passesThroughHadoopFsDfsAndDropsUnrelatedKeys() { + Map out = overlay(new HashMap<>(), raw( + "fs.defaultFS", "hdfs://nn", + "dfs.nameservices", "ns", + "hadoop.security.authentication", "kerberos", + "warehouse", "oss://b/wh", + "some.random.key", "x")); + Assertions.assertEquals("hdfs://nn", out.get("fs.defaultFS")); + Assertions.assertEquals("ns", out.get("dfs.nameservices")); + Assertions.assertEquals("kerberos", out.get("hadoop.security.authentication")); + // non-storage keys are dropped + Assertions.assertFalse(out.containsKey("warehouse")); + Assertions.assertFalse(out.containsKey("some.random.key")); + } + + @Test + public void storageConfigIsOverlaidFirstThenPropsWin() { + Map storage = raw("fs.s3a.endpoint", "from-storage", "fs.s3a.region", "us-west-2"); + // explicit fs.s3a.endpoint in props (last-write-wins) overrides the canonical storage value + Map out = overlay(storage, raw("fs.s3a.endpoint", "from-props")); + Assertions.assertEquals("from-props", out.get("fs.s3a.endpoint")); + Assertions.assertEquals("us-west-2", out.get("fs.s3a.region")); + } + + @Test + public void firstNonBlankSkipsBlanksAndHonoursAliasOrder() { + Assertions.assertEquals("x", MetaStoreParseUtils.firstNonBlank(raw("a", " ", "b", "x"), "a", "b")); + Assertions.assertEquals("y", MetaStoreParseUtils.firstNonBlank(raw("a", "y", "b", "x"), "a", "b")); + Assertions.assertNull(MetaStoreParseUtils.firstNonBlank(raw(), "a", "b")); + } +} diff --git a/fe/fe-connector/fe-connector-paimon-hive-shade/pom.xml b/fe/fe-connector/fe-connector-paimon-hive-shade/pom.xml new file mode 100644 index 00000000000000..a626920b914121 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon-hive-shade/pom.xml @@ -0,0 +1,324 @@ + + + + 4.0.0 + + + org.apache.doris + fe-connector + ${revision} + ../pom.xml + + + fe-connector-paimon-hive-shade + jar + Doris FE Connector - Paimon Hive Shade + + FIX-C (build 968994, Class C — paimon HMS metastore=hive NoClassDefFoundError on + org/apache/thrift/transport/TFramedTransport). Paimon's RetryingMetaStoreClientFactory + reflectively loads HiveMetaStoreClient's constructor signatures, which reference the + thrift-0.9.x package org.apache.thrift.transport.TFramedTransport. The host + libthrift-0.16.0 moved that class to .transport.layered, and the plugin bundles no + libthrift (RC-1 keeps org.apache.thrift parent-first so the doris-gen TSerializer/TBase + 0.16.0 path works) -> the old-package class is unsatisfiable. + + This module shades the paimon-hive + HMS-thrift metastore-client closure and relocates + org.apache.thrift -> org.apache.doris.paimon.shaded.thrift (paimon-private), so the + shaded HiveMetaStoreClient/CachedClientPool reference the relocated TFramedTransport + (supplied by the relocated libthrift 0.9.3 inside this jar) and the host 0.16.0 thrift + namespace is left completely untouched. fe-connector-paimon depends on this shaded + artifact instead of raw paimon-hive-connector-3.1 + hive-metastore + hive-common. + See plan-doc/fix-c-hms-thrift-design.md. + + + + + + org.apache.paimon + paimon-hive-connector-3.1 + ${paimon.version} + + true + + + org.apache.httpcomponents.client5 + httpclient5 + + + org.roaringbitmap + RoaringBitmap + + + org.apache.hive + hive-metastore + + + org.apache.hadoop + hadoop-common + + + org.apache.hadoop + hadoop-hdfs + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + + + + + org.apache.hive + hive-metastore + 2.3.7 + + true + + org.apache.hadoophadoop-common + org.apache.hadoophadoop-hdfs + org.apache.hadoophadoop-mapreduce-client-core + org.apache.hivehive-common + org.apache.hivehive-serde + org.apache.hivehive-shims + org.apache.thriftlibthrift + com.google.guavaguava + com.google.protobufprotobuf-java + org.datanucleusdatanucleus-api-jdo + org.datanucleusdatanucleus-core + org.datanucleusdatanucleus-rdbms + org.datanucleusjavax.jdo + javax.jdojdo-api + org.apache.derbyderby + com.jolboxbonecp + com.zaxxerHikariCP + commons-dbcpcommons-dbcp + commons-poolcommons-pool + org.apache.hbasehbase-client + co.cask.tephratephra-api + co.cask.tephratephra-core + co.cask.tephratephra-hbase-compat-1.0 + ch.qos.logbacklogback-classic + ch.qos.logbacklogback-core + org.slf4jslf4j-log4j12 + commons-loggingcommons-logging + + + + + + org.apache.hive + hive-common + ${hive.common.version} + + true + + + + + org.apache.hive + hive-serde + 2.3.7 + + true + + org.apache.parquetparquet-hadoop-bundle + javax.servletservlet-api + javax.servletjsp-api + + + + + + org.apache.thrift + libthrift + 0.9.3 + + true + + + org.apache.httpcomponents + httpcore + + + org.apache.httpcomponents + httpclient + + + org.slf4j + slf4j-api + + + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + default-jar + package + + jar + + + true + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + + shade + + package + + + + + org.apache.hadoop:* + org.apache.paimon:paimon-core + org.apache.paimon:paimon-common + org.apache.paimon:paimon-format + org.slf4j:* + org.apache.logging.log4j:* + com.google.guava:* + com.google.protobuf:* + com.fasterxml.jackson.core:* + com.fasterxml.jackson.dataformat:* + commons-logging:* + org.apache.commons:* + commons-io:* + commons-codec:* + + + + true + ${project.basedir}/target/dependency-reduced-pom.xml + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + META-INF/maven/** + + META-INF/versions/** + + + + + + org.apache.thrift + org.apache.doris.paimon.shaded.thrift + + + + it.unimi.dsi.fastutil + org.apache.doris.paimon.shaded.fastutil + + + + + + + + + diff --git a/fe/fe-connector/fe-connector-paimon/pom.xml b/fe/fe-connector/fe-connector-paimon/pom.xml index 1810e30be08e4b..7e3c6e11172966 100644 --- a/fe/fe-connector/fe-connector-paimon/pom.xml +++ b/fe/fe-connector/fe-connector-paimon/pom.xml @@ -47,6 +47,29 @@ under the License. ${project.version} + + + ${project.groupId} + fe-connector-cache + ${project.version} + + + + + com.github.ben-manes.caffeine + caffeine + 2.9.3 + + ${project.groupId} @@ -54,6 +77,30 @@ under the License. ${project.version} + + + ${project.groupId} + fe-filesystem-api + ${project.version} + + + + + ${project.groupId} + fe-connector-metastore-paimon + ${project.version} + + ${project.groupId} @@ -69,6 +116,138 @@ under the License. ${paimon.version} + + + org.apache.doris + fe-connector-paimon-hive-shade + ${project.version} + + + + + org.apache.hadoop + hadoop-common + + + + + org.apache.hadoop + hadoop-hdfs-client + ${hadoop.version} + runtime + + + org.apache.hadoop + hadoop-common + + + + + + + org.apache.hadoop + hadoop-aws + + + + + com.huaweicloud + hadoop-huaweicloud + runtime + + + + + software.amazon.awssdk + s3 + + + software.amazon.awssdk + apache-client + + + + software.amazon.awssdk + s3-transfer-manager + + org.apache.logging.log4j log4j-api @@ -79,8 +258,49 @@ under the License. junit-jupiter test + + + + org.apache.paimon + paimon-format + test + + + + + org.apache.hadoop + hadoop-mapreduce-client-core + test + + + + + huawei-obs-sdk + https://repo.huaweicloud.com/repository/maven/huaweicloudsdk/ + + + doris-fe-connector-paimon diff --git a/fe/fe-connector/fe-connector-paimon/src/main/assembly/plugin-zip.xml b/fe/fe-connector/fe-connector-paimon/src/main/assembly/plugin-zip.xml index 0d29baa55b34bf..9deeb4c7889d5f 100644 --- a/fe/fe-connector/fe-connector-paimon/src/main/assembly/plugin-zip.xml +++ b/fe/fe-connector/fe-connector-paimon/src/main/assembly/plugin-zip.xml @@ -46,6 +46,15 @@ under the License. org.apache.doris:fe-connector-spi org.apache.doris:fe-extension-spi org.apache.doris:fe-filesystem-api + + org.apache.doris:fe-thrift + org.apache.thrift:libthrift + + io.netty:netty-codec-native-quic org.apache.logging.log4j:* org.slf4j:* 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 new file mode 100644 index 00000000000000..381c950a075891 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogFactory.java @@ -0,0 +1,375 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.hive.conf.HiveConf; +import org.apache.paimon.catalog.FileSystemCatalogFactory; +import org.apache.paimon.jdbc.JdbcCatalogFactory; +import org.apache.paimon.options.CatalogOptions; +import org.apache.paimon.options.Options; + +import java.io.File; +import java.util.Locale; +import java.util.Map; +import java.util.function.BiConsumer; + +/** + * Pure, testable assembly core for the Paimon connector flavor switch — the paimon-SDK-specific bits + * that stay in the connector after the P2-T03 cutover. + * + *

    Mirrors the role of {@code MCConnectorClientFactory}: a stateless static holder that + * {@link #buildCatalogOptions(Map) builds} the Paimon {@link Options} for a flavor. The option-key + * logic ports the legacy fe-core {@code AbstractPaimonProperties} + each {@code Paimon*MetaStoreProperties} + * Options assembly. {@code buildCatalogOptions} is PURE — it reads only the supplied props (no env, no + * clock) — which is what makes it unit-testable offline. + * + *

    It also holds two PURE Hadoop config helpers: {@link #buildHadoopConfiguration} (the filesystem/jdbc + * 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 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. + * + *

    The metastore CONNECTION facts (validate rules, HMS HiveConf key sets, JDBC driver-url + * resolution, alias arrays) were moved to the shared {@code fe-connector-metastore-spi} + * ({@code MetaStoreProviders.bind} -> {@code HmsMetaStoreProperties.toHiveConfOverrides(String)}; + * {@code JdbcDriverSupport.resolveDriverUrl}) — see P2-T03. + */ +public final class PaimonCatalogFactory { + + private static final String USER_PROPERTY_PREFIX = "paimon."; + private static final String PAIMON_REST_PROPERTY_PREFIX = "paimon.rest."; + private static final String JDBC_PREFIX = "jdbc."; + + /** + * Storage-config prefixes that are intentionally excluded from the catalog Options + * passthrough — they belong in the Hadoop Configuration (see {@link #buildHadoopConfiguration}), + * mirroring legacy {@code AbstractPaimonProperties.userStoragePrefixes}. + */ + private static final String[] USER_STORAGE_PREFIXES = { + "paimon.s3.", "paimon.s3a.", "paimon.fs.s3.", "paimon.fs.oss."}; + + /** Hadoop S3A standard prefix (legacy {@code AbstractPaimonProperties.FS_S3A_PREFIX}). */ + private static final String FS_S3A_PREFIX = "fs.s3a."; + + + private PaimonCatalogFactory() { + } + + /** Resolves the lower-cased flavor, defaulting to {@code filesystem}. */ + public static String resolveFlavor(Map props) { + return props.getOrDefault( + PaimonConnectorProperties.PAIMON_CATALOG_TYPE, + PaimonConnectorProperties.DEFAULT_CATALOG_TYPE).toLowerCase(Locale.ROOT); + } + + /** + * Returns the first non-blank value among the given keys, or {@code null} if none is set. + * Mirrors the alias-priority semantics of the legacy {@code @ConnectorProperty(names=...)}. + */ + public static String firstNonBlank(Map props, String... keys) { + for (String key : keys) { + String value = props.get(key); + if (StringUtils.isNotBlank(value)) { + return value; + } + } + return null; + } + + /** + * Builds the Paimon catalog {@link Options} for the resolved flavor. PURE: depends only on + * {@code props}. Ports {@code AbstractPaimonProperties.appendCatalogOptions()} (common) plus + * each flavor's {@code appendCustomCatalogOptions()}. + */ + public static Options buildCatalogOptions(Map props) { + Options options = new Options(); + String flavor = resolveFlavor(props); + + appendCommonOptions(props, options, flavor); + + switch (flavor) { + case PaimonConnectorProperties.HMS: + appendHmsOptions(props, options); + break; + case PaimonConnectorProperties.REST: + appendRestOptions(props, options); + break; + case PaimonConnectorProperties.JDBC: + appendJdbcOptions(props, options); + break; + default: + // filesystem: nothing custom. + break; + } + return options; + } + + private static void appendCommonOptions(Map props, Options options, String flavor) { + String warehouse = props.get(PaimonConnectorProperties.WAREHOUSE); + if (StringUtils.isNotBlank(warehouse)) { + options.set(CatalogOptions.WAREHOUSE.key(), warehouse); + } + options.set(CatalogOptions.METASTORE.key(), metastoreIdentifier(flavor)); + + // FIXME(cmy): Rethink these custom properties (ported from AbstractPaimonProperties). + // Re-key generic paimon.* props by stripping the prefix, excluding storage prefixes which + // belong in the Hadoop Configuration (see buildHadoopConfiguration). + props.forEach((k, v) -> { + if (k.toLowerCase(Locale.ROOT).startsWith(USER_PROPERTY_PREFIX)) { + String newKey = k.substring(USER_PROPERTY_PREFIX.length()); + if (StringUtils.isNotBlank(newKey) && !isStoragePrefixed(k)) { + options.set(newKey, v); + } + } + }); + } + + private static String metastoreIdentifier(String flavor) { + switch (flavor) { + case PaimonConnectorProperties.FILESYSTEM: + return FileSystemCatalogFactory.IDENTIFIER; + case PaimonConnectorProperties.JDBC: + return JdbcCatalogFactory.IDENTIFIER; + case PaimonConnectorProperties.REST: + return "rest"; + case PaimonConnectorProperties.HMS: + // = org.apache.paimon.hive.HiveCatalogOptions.IDENTIFIER; kept as a literal to + // mirror the existing rest/jdbc style (this is a pure option string, not a type ref). + return "hive"; + default: + throw new IllegalArgumentException("Unknown paimon.catalog.type value: " + flavor); + } + } + + private static boolean isStoragePrefixed(String key) { + for (String prefix : USER_STORAGE_PREFIXES) { + if (key.startsWith(prefix)) { + return true; + } + } + return false; + } + + private static void appendHmsOptions(Map props, Options options) { + String pool = props.getOrDefault( + PaimonConnectorProperties.CLIENT_POOL_CACHE_EVICTION_INTERVAL_MS, + PaimonConnectorProperties.CLIENT_POOL_CACHE_EVICTION_INTERVAL_MS_DEFAULT); + String location = props.getOrDefault( + PaimonConnectorProperties.LOCATION_IN_PROPERTIES, + PaimonConnectorProperties.LOCATION_IN_PROPERTIES_DEFAULT); + options.set(PaimonConnectorProperties.CLIENT_POOL_CACHE_EVICTION_INTERVAL_MS, pool); + options.set(PaimonConnectorProperties.LOCATION_IN_PROPERTIES, location); + options.set("uri", firstNonBlank(props, PaimonConnectorProperties.HMS_URI)); + } + + private static void appendRestOptions(Map props, Options options) { + options.set("uri", firstNonBlank(props, PaimonConnectorProperties.REST_URI)); + props.forEach((k, v) -> { + if (k.startsWith(PAIMON_REST_PROPERTY_PREFIX)) { + options.set(k.substring(PAIMON_REST_PROPERTY_PREFIX.length()), v); + } + }); + } + + private static void appendJdbcOptions(Map props, Options options) { + options.set(CatalogOptions.URI.key(), firstNonBlank(props, PaimonConnectorProperties.JDBC_URI)); + String user = firstNonBlank(props, PaimonConnectorProperties.JDBC_USER); + if (StringUtils.isNotBlank(user)) { + options.set("jdbc.user", user); + } + String password = firstNonBlank(props, PaimonConnectorProperties.JDBC_PASSWORD); + if (StringUtils.isNotBlank(password)) { + options.set("jdbc.password", password); + } + // Pass through any raw jdbc.* key not already set (legacy appendRawJdbcCatalogOptions). + props.forEach((k, v) -> { + if (k != null && k.startsWith(JDBC_PREFIX) && !options.keySet().contains(k)) { + options.set(k, v); + } + }); + } + + // --------------------------------------------------------------------- + // Hadoop Configuration / HiveConf builders (PURE — functions of props only) + // --------------------------------------------------------------------- + + /** + * Builds a minimal Hadoop {@link Configuration} for the storage layer (HDFS / S3 / OSS), from the + * raw property map plus the pre-computed object-store storage config: + * + *

      + *
    • {@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 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.*} + * are normalized to the Hadoop S3A prefix {@code fs.s3a.} (strip the matched prefix, + * re-key as {@code fs.s3a.} + remainder), matching legacy {@code normalizeS3Config};
    • + *
    • raw {@code fs.*} / {@code dfs.*} / {@code hadoop.*} keys are copied verbatim (these are + * already Hadoop-recognized keys the user passed through). Inline HDFS keys ride this passthrough; + * an HDFS catalog's {@code hadoop.config.resources} XML + HA + auth keys arrive via + * {@code storageHadoopConfig} (C2; fe-filesystem's HDFS model implements {@code HadoopStorageProperties}), + * and the passthrough re-applies the inline keys last (last-write-wins).
    • + *
    + * + *

    PURE: depends only on {@code props} and {@code storageHadoopConfig}. + */ + public static Configuration buildHadoopConfiguration(Map props, + Map storageHadoopConfig) { + Configuration conf = new Configuration(); + // Pin the Configuration's classloader to the plugin loader (FIX-PAIMON-HADOOP-CLASSLOADER). + // Hadoop resolves filesystem impls via Configuration.getClass("fs..impl", ...), which + // loads through Configuration.classLoader (defaults to the thread-context CL = parent 'app'). + // With hadoop-aws (S3AFileSystem) bundled child-first, that default would still resolve + // S3AFileSystem from the parent and fail the cast to the child-loaded FileSystem. Resolving + // through the plugin loader keeps the whole FS class graph in one loader. + conf.setClassLoader(PaimonCatalogFactory.class.getClassLoader()); + applyStorageConfig(storageHadoopConfig, props, conf::set); + return conf; + } + + /** + * Applies the storage config via the given setter. Shared by {@link #buildHadoopConfiguration} and + * the HiveConf builders (which overlay the same storage config onto the HiveConf, mirroring legacy + * {@code appendUserHadoopConfig(hiveConf)} + {@code ossProps.getHadoopStorageConfig()}). Two steps, + * in legacy precedence order: + * + *

      + *
    1. the pre-computed {@code storageHadoopConfig} (canonical object-store translation, produced + * upstream from {@code ConnectorContext.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.} + * passthrough, which run LAST and overlay the canonical translation (last-write-wins = + * legacy {@code addResource(getHadoopStorageConfig())} then {@code appendUserHadoopConfig}).
    4. + *
    + */ + 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 + // 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 + // HDFS keys still ride the raw fs./dfs./hadoop. passthrough below (re-applied last, last-write-wins). + storageHadoopConfig.forEach(setter); + // Connector-specific (NOT in fe-filesystem): paimon.* prefix re-key + raw fs./dfs./hadoop. passthrough, + // run LAST so explicit fs.s3a.* keys overlay the canonical translation (last-write-wins). + props.forEach((key, value) -> { + for (String prefix : USER_STORAGE_PREFIXES) { + if (key.startsWith(prefix)) { + setter.accept(FS_S3A_PREFIX + key.substring(prefix.length()), value); + return; // stop after the first matching prefix (legacy normalizeS3Config) + } + } + if (key.startsWith("fs.") || key.startsWith("dfs.") || key.startsWith("hadoop.")) { + setter.accept(key, value); + } + }); + } + + /** + * Assembles a {@link HiveConf} for the {@code hms} flavor. + * Seeds the optional external {@code hive.conf.resources} hive-site.xml (resolved by this connector via + * {@link #addConfResources}) FIRST, then applies the shared-parser {@code overrides} on top, so the + * connection/user keys correctly OVERRIDE the file — matching the legacy precedence (file base, then + * overrides). Overrides win because {@code set()} values live in the {@link Configuration} overlay, + * which is applied after every {@code addResource}. + * + *

    The {@code overrides} are produced by the shared metastore parsers + * ({@code HmsMetaStoreProperties.toHiveConfOverrides(String)} — uri + verbatim {@code hive.*} + auth keys + * + socket-timeout default + storage overlay + kerberos block last), which owns the ordering-sensitive + * logic (storage overlay BEFORE the kerberos block). This + * method only layers the file base under those facts. The real Kerberos UGI {@code doAs} is injected + * by the FE via {@code ConnectorContext.executeAuthenticated}; the keys here only describe it. + * + *

    PURE: a function of the two maps (plus {@link HiveConf}'s own classpath defaults). + * + * @param base optional base keys (e.g. a resolved hive-site.xml); may be {@code null}/empty + * @param overrides the connection-fact overrides; never {@code null} + */ + public static HiveConf assembleHiveConf(String confResources, Map overrides) { + HiveConf hiveConf = new HiveConf(); + // Pin the conf classloader to the plugin loader, mirroring buildHadoopConfiguration (above). + // HiveMetaStoreClient.loadFilterHooks resolves metastore.filter.hook via Configuration.getClass, + // which uses the conf's OWN classLoader field (= the thread-context CL captured at new HiveConf(), + // which here is still the parent 'app' loader because assembleHiveConf runs before the TCCL pin in + // PaimonConnector.createCatalogFromContext). Under child-first plugin loading that resolves + // DefaultMetaStoreFilterHookImpl from the parent while MetaStoreFilterHook is child-loaded, giving + // "class DefaultMetaStoreFilterHookImpl not MetaStoreFilterHook". Pinning keeps the whole + // hive-metastore class graph in one loader. + hiveConf.setClassLoader(PaimonCatalogFactory.class.getClassLoader()); + addConfResources(hiveConf, confResources); + overrides.forEach(hiveConf::set); + return hiveConf; + } + + /** + * Resolves the FE's {@code hadoop_config_dir}. Mirrors {@code fe-filesystem-hdfs}'s + * {@code HdfsConfigFileLoader.resolveHadoopConfigDir}: a connector plugin cannot import fe-core's + * {@code Config}, so the engine bridges the operator-configured value in via the + * {@code doris.hadoop.config.dir} system property ({@code FileSystemFactory.bindAllStorageProperties} + * sets it). The fallback matches {@code Config.hadoop_config_dir}'s own default. + */ + private static String resolveHadoopConfigDir() { + String fromEngine = System.getProperty("doris.hadoop.config.dir"); + if (StringUtils.isNotBlank(fromEngine)) { + return fromEngine; + } + String home = System.getenv("DORIS_HOME"); + if (StringUtils.isBlank(home)) { + home = System.getProperty("doris.home", ""); + } + return home + "/plugins/hadoop_conf/"; + } + + /** + * Adds the comma-separated {@code hive.conf.resources} files (each resolved under + * {@link #resolveHadoopConfigDir()}) onto {@code hiveConf}. Blank is a no-op; a missing file fails loud, + * byte-identically to the legacy fe-common {@code CatalogConfigFileUtils} message. + * + *

    The connector resolves and parses these itself rather than receiving pre-flattened keys from the + * engine: the previous engine-side hook handed over its own {@code new HiveConf()} in full, force-setting + * ~881 hive defaults computed from the ENGINE's hive version (3.1.1) on top of this plugin's own + * hive 2.3.9 HiveConf — an unintended side effect of iterating a HiveConf, not a contract. + */ + static void addConfResources(HiveConf hiveConf, String confResources) { + if (StringUtils.isBlank(confResources)) { + return; + } + String baseDir = resolveHadoopConfigDir(); + for (String resource : confResources.split(",")) { + String resourcePath = baseDir + resource.trim(); + File file = new File(resourcePath); + if (file.exists() && file.isFile()) { + hiveConf.addResource(new Path(file.toURI())); + } else { + throw new IllegalArgumentException("Config resource file does not exist: " + resourcePath); + } + } + } + +} diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogOps.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogOps.java new file mode 100644 index 00000000000000..0fb1145d4cc31d --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogOps.java @@ -0,0 +1,383 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.paimon.Snapshot; +import org.apache.paimon.catalog.Catalog; +import org.apache.paimon.catalog.Database; +import org.apache.paimon.catalog.Identifier; +import org.apache.paimon.partition.Partition; +import org.apache.paimon.schema.Schema; +import org.apache.paimon.schema.TableSchema; +import org.apache.paimon.table.DataTable; +import org.apache.paimon.table.FileStoreTable; +import org.apache.paimon.table.Table; +import org.apache.paimon.table.source.Split; +import org.apache.paimon.tag.Tag; +import org.apache.paimon.types.DataField; + +import java.io.FileNotFoundException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * Injection seam over the remote Paimon {@link Catalog} calls. + * + *

    The default {@link CatalogBackedPaimonCatalogOps} simply delegates to a real + * {@code Catalog}, which requires a live remote catalog (filesystem / HMS / DLF / REST / + * JDBC). By depending on this interface instead of {@code Catalog} directly, + * {@link PaimonConnectorMetadata} becomes unit-testable offline with a hand-written + * recording fake (no Mockito) — mirroring the maxcompute connector's + * {@link org.apache.doris.connector.maxcompute.McStructureHelper McStructureHelper} pattern. + * + *

    The read methods landed in B0. B3 added the four DDL methods + * ({@link #createDatabase}, {@link #dropDatabase}, {@link #createTable}, {@link #dropTable}), + * whose signatures (and checked exceptions) mirror the real Paimon {@code Catalog} exactly. + * Existence is probed via the existing {@link #getTable} / {@link #getDatabase} read methods + * (plus the caught not-exist exceptions); the seam intentionally has no separate probe methods. + */ +public interface PaimonCatalogOps { + + List listDatabases(); + + Database getDatabase(String name) throws Catalog.DatabaseNotExistException; + + List listTables(String databaseName) throws Catalog.DatabaseNotExistException; + + Table getTable(Identifier identifier) throws Catalog.TableNotExistException; + + List listPartitions(Identifier identifier) throws Catalog.TableNotExistException; + + void createDatabase(String name, boolean ignoreIfExists, Map properties) + throws Catalog.DatabaseAlreadyExistException; + + void dropDatabase(String name, boolean ignoreIfNotExists, boolean cascade) + throws Catalog.DatabaseNotExistException, Catalog.DatabaseNotEmptyException; + + void createTable(Identifier identifier, Schema schema, boolean ignoreIfExists) + throws Catalog.TableAlreadyExistException, Catalog.DatabaseNotExistException; + + void dropTable(Identifier identifier, boolean ignoreIfNotExists) + throws Catalog.TableNotExistException; + + // ---- E5: MVCC snapshot lookups (T20) ---- + // These return plain {@code long}s (not paimon {@code Snapshot} objects) so the metadata + // layer's MVCC logic (sys-guard, empty->-1, found/empty mapping) is unit-testable offline with + // {@code RecordingPaimonCatalogOps} — faking a concrete paimon {@code Snapshot}/ + // {@code SnapshotManager} directly is impractical. The production impl uses the paimon SDK. + + /** + * Returns the latest snapshot id of {@code table} ({@code table.latestSnapshot().get().id()}), + * or empty when the table has no snapshot (empty table). The caller maps empty to the legacy + * {@code INVALID_SNAPSHOT_ID} (-1). + */ + OptionalLong latestSnapshotId(Table table); + + /** + * Returns the id of the latest snapshot committed at or before {@code timestampMillis} + * ({@code snapshotManager().earlierOrEqualTimeMills(ts)}), or empty when no such snapshot + * exists (the SDK returns null). + */ + OptionalLong snapshotIdAtOrBefore(Table table, long timestampMillis); + + /** + * Returns {@code true} iff a snapshot with {@code snapshotId} exists + * ({@code snapshotManager().tryGetSnapshot(id)} succeeds; a {@code FileNotFoundException} from + * the SDK means it does not exist). + */ + boolean snapshotExists(Table table, long snapshotId); + + // ---- B5b-2a: explicit time-travel resolution (SNAPSHOT_ID / TIMESTAMP / TAG) ---- + // Like the T20 lookups above, these return plain {@code long}s / small immutable structs (never + // a raw paimon {@code Snapshot} / {@code Tag} / {@code TableSchema}) so the metadata layer's + // resolution logic is unit-testable offline with {@code RecordingPaimonCatalogOps}. + + /** + * Returns the schema version (schemaId) of the snapshot with {@code snapshotId} + * ({@code snapshotManager().snapshot(id).schemaId()}), or empty when it cannot be resolved. + * Used to stamp {@link org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot#getSchemaId()} + * for snapshot-id / timestamp time-travel so schema-at-snapshot reads pick the historical schema. + */ + OptionalLong snapshotSchemaId(Table table, long snapshotId); + + /** + * Resolves the named tag to its snapshot id + schema id ({@code tagManager().get(tagName)}; + * a paimon {@code Tag} IS-A {@code Snapshot}, so {@code tag.id()} / {@code tag.schemaId()}), or + * empty when no tag with {@code tagName} exists. Legacy + * {@code PaimonUtil.getPaimonSnapshotByTag} threw on absent; this returns empty (the metadata + * layer maps empty → {@link java.util.Optional#empty()} per the SPI empty-if-none contract). + */ + Optional getSnapshotByTag(Table table, String tagName); + + /** + * Returns the schema AS OF {@code schemaId} ({@code schemaManager().schema(schemaId)}), reduced + * to the fields + partition keys + primary keys the metadata layer needs to build Doris columns. + * Mirrors legacy {@code PaimonExternalTable.initSchema(schemaId)}, which read the same + * {@code tableSchema.fields()} / {@code tableSchema.partitionKeys()} of the pinned version. + */ + PaimonSchemaSnapshot schemaAt(Table table, long schemaId); + + /** + * Returns the LATEST schema read FRESH via the table's schema manager + * ({@code ((DataTable) table).schemaManager().latest()}), reduced to the fields + partition keys + + * primary keys the metadata layer needs. Unlike {@code table.rowType()} — which a paimon + * {@code CachingCatalog} freezes at the cached {@code Table}'s load time — {@code schemaManager().latest()} + * is a LIVE read of the schema directory, so it reflects an external {@code ALTER} (which bumps the + * schema file/id WITHOUT creating a new snapshot, so the latest snapshot's schemaId stays behind). + * Mirrors legacy {@code PaimonExternalTable}, which read {@code schemaManager().latest()} for the + * latest schema (never {@code rowType()}). + * + *

    Returns {@link Optional#empty()} when {@code table} is not a {@code DataTable} (e.g. a + * {@code FormatTable} backend) or has no schema yet, so the caller falls back to {@code table.rowType()}. + */ + Optional latestSchema(Table table); + + // ---- B5b-2c: branch time-travel resolution ---- + + /** + * Returns true iff {@code branchName} exists on {@code table}. The base table must be a + * {@code FileStoreTable} (cast + {@code branchManager().branchExists(name)}, mirroring legacy + * PaimonUtil.resolvePaimonBranch); a non-FileStoreTable backend (e.g. jdbc-only) cannot have + * branches, so this returns {@code false} gracefully (the metadata layer maps that to + * Optional.empty(), which the fe-core consumer later translates to "can't find branch"). + */ + boolean branchExists(Table table, String branchName); + + /** + * Returns the total row count of {@code table} = sum of {@code split.rowCount()} over + * {@code table.newReadBuilder().newScan().plan().splits()} (legacy + * {@code PaimonExternalTable.fetchRowCount} / {@code PaimonSysExternalTable.fetchRowCount}). + * Returns a plain {@code long} (never a paimon {@code Split} list) so the metadata layer's + * >0-else-UNKNOWN logic is unit-testable offline with {@code RecordingPaimonCatalogOps} + * ({@code FakePaimonTable.newReadBuilder()} throws). + */ + long rowCount(Table table); + + void close() throws Exception; + + /** + * Immutable carrier for a resolved tag: the tag's snapshot id and schema id. Lets the metadata + * layer pin without depending on a concrete paimon {@code Tag} (impractical to fake offline). + */ + final class TagSnapshot { + private final long snapshotId; + private final long schemaId; + + public TagSnapshot(long snapshotId, long schemaId) { + this.snapshotId = snapshotId; + this.schemaId = schemaId; + } + + /** The tag's snapshot id ({@code tag.id()}). */ + public long snapshotId() { + return snapshotId; + } + + /** The tag's schema id ({@code tag.schemaId()}). */ + public long schemaId() { + return schemaId; + } + } + + /** + * Immutable carrier for a schema AS OF a schemaId: the paimon fields plus the partition-key and + * primary-key name lists. Returned by {@link #schemaAt} so the metadata layer can map columns + * offline without faking a concrete paimon {@code TableSchema}. + */ + final class PaimonSchemaSnapshot { + private final List fields; + private final List partitionKeys; + private final List primaryKeys; + + public PaimonSchemaSnapshot(List fields, List partitionKeys, + List primaryKeys) { + this.fields = fields; + this.partitionKeys = partitionKeys; + this.primaryKeys = primaryKeys; + } + + /** The schema's fields ({@code tableSchema.fields()}). */ + public List fields() { + return fields; + } + + /** The schema's partition key names ({@code tableSchema.partitionKeys()}). */ + public List partitionKeys() { + return partitionKeys; + } + + /** The schema's primary key names ({@code tableSchema.primaryKeys()}). */ + public List primaryKeys() { + return primaryKeys; + } + } + + /** + * Default implementation backing the seam with a real Paimon {@link Catalog}. + * Each method is a thin delegation; the {@code Catalog} is the only state. + */ + class CatalogBackedPaimonCatalogOps implements PaimonCatalogOps { + private final Catalog catalog; + + public CatalogBackedPaimonCatalogOps(Catalog catalog) { + this.catalog = catalog; + } + + @Override + public List listDatabases() { + return catalog.listDatabases(); + } + + @Override + public Database getDatabase(String name) throws Catalog.DatabaseNotExistException { + return catalog.getDatabase(name); + } + + @Override + public List listTables(String databaseName) throws Catalog.DatabaseNotExistException { + return catalog.listTables(databaseName); + } + + @Override + public Table getTable(Identifier identifier) throws Catalog.TableNotExistException { + return catalog.getTable(identifier); + } + + @Override + public List listPartitions(Identifier identifier) throws Catalog.TableNotExistException { + return catalog.listPartitions(identifier); + } + + @Override + public void createDatabase(String name, boolean ignoreIfExists, Map properties) + throws Catalog.DatabaseAlreadyExistException { + catalog.createDatabase(name, ignoreIfExists, properties); + } + + @Override + public void dropDatabase(String name, boolean ignoreIfNotExists, boolean cascade) + throws Catalog.DatabaseNotExistException, Catalog.DatabaseNotEmptyException { + catalog.dropDatabase(name, ignoreIfNotExists, cascade); + } + + @Override + public void createTable(Identifier identifier, Schema schema, boolean ignoreIfExists) + throws Catalog.TableAlreadyExistException, Catalog.DatabaseNotExistException { + catalog.createTable(identifier, schema, ignoreIfExists); + } + + @Override + public void dropTable(Identifier identifier, boolean ignoreIfNotExists) + throws Catalog.TableNotExistException { + catalog.dropTable(identifier, ignoreIfNotExists); + } + + @Override + public OptionalLong latestSnapshotId(Table table) { + return table.latestSnapshot() + .map(snapshot -> OptionalLong.of(snapshot.id())) + .orElseGet(OptionalLong::empty); + } + + @Override + public OptionalLong snapshotIdAtOrBefore(Table table, long timestampMillis) { + // Time-travel by wall-clock requires the snapshotManager(), which only DataTable exposes + // (legacy PaimonUtil.getPaimonSnapshotByTimestamp casts to DataTable too). + Snapshot snapshot = ((DataTable) table).snapshotManager().earlierOrEqualTimeMills(timestampMillis); + return snapshot == null ? OptionalLong.empty() : OptionalLong.of(snapshot.id()); + } + + @Override + public boolean snapshotExists(Table table, long snapshotId) { + try { + // tryGetSnapshot throws FileNotFoundException when the id does not exist (legacy + // PaimonUtil.getPaimonSnapshotBySnapshotId catches the same exception). + ((DataTable) table).snapshotManager().tryGetSnapshot(snapshotId); + return true; + } catch (FileNotFoundException e) { + return false; + } + } + + @Override + public OptionalLong snapshotSchemaId(Table table, long snapshotId) { + // snapshotManager() is only on DataTable (same cast legacy PaimonUtil uses). snapshot(id) + // returns the Snapshot whose schemaId is the version pinned for schema-at-snapshot. + Snapshot snapshot = ((DataTable) table).snapshotManager().snapshot(snapshotId); + return snapshot == null ? OptionalLong.empty() : OptionalLong.of(snapshot.schemaId()); + } + + @Override + public Optional getSnapshotByTag(Table table, String tagName) { + // tagManager() is only on DataTable. A paimon Tag IS-A Snapshot, so id()/schemaId() are + // inherited (legacy PaimonUtil.getPaimonSnapshotByTag read the Tag the same way). + Optional tag = ((DataTable) table).tagManager().get(tagName); + return tag.map(t -> new TagSnapshot(t.id(), t.schemaId())); + } + + @Override + public PaimonSchemaSnapshot schemaAt(Table table, long schemaId) { + // schemaManager() is only on DataTable. schema(schemaId) is the historical TableSchema + // (legacy PaimonExternalTable.initSchema(schemaId) reads the same accessors). + TableSchema tableSchema = ((DataTable) table).schemaManager().schema(schemaId); + return new PaimonSchemaSnapshot( + tableSchema.fields(), tableSchema.partitionKeys(), tableSchema.primaryKeys()); + } + + @Override + public Optional latestSchema(Table table) { + // schemaManager() is only on DataTable (same cast schemaAt uses). latest() is a LIVE read + // of the schema directory, so it returns the post-ALTER schema even off a CachingCatalog- + // cached Table whose rowType() is frozen at load time. A non-DataTable backend (e.g. a + // FormatTable) has no schema history -> empty -> the caller falls back to table.rowType(). + if (!(table instanceof DataTable)) { + return Optional.empty(); + } + return ((DataTable) table).schemaManager().latest() + .map(s -> new PaimonSchemaSnapshot(s.fields(), s.partitionKeys(), s.primaryKeys())); + } + + @Override + public boolean branchExists(Table table, String branchName) { + // Mirrors legacy PaimonUtil.resolvePaimonBranch: only a FileStoreTable has a + // branchManager(); a non-FileStoreTable backend (e.g. jdbc-only) cannot have branches. + if (!(table instanceof FileStoreTable)) { + return false; + } + return ((FileStoreTable) table).branchManager().branchExists(branchName); + } + + @Override + public long rowCount(Table table) { + // Legacy PaimonExternalTable.fetchRowCount / PaimonSysExternalTable.fetchRowCount: sum + // the planned-split record counts. + long rowCount = 0; + for (Split split : table.newReadBuilder().newScan().plan().splits()) { + rowCount += split.rowCount(); + } + return rowCount; + } + + @Override + public void close() throws Exception { + catalog.close(); + } + } +} 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 2ac035e20493a2..58df5e326b111d 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 @@ -18,47 +18,302 @@ package org.apache.doris.connector.paimon; import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorValidationContext; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; +import org.apache.doris.connector.metastore.HmsMetaStoreProperties; +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.filesystem.properties.StorageProperties; +import org.apache.doris.kerberos.HadoopAuthenticator; +import org.apache.doris.kerberos.KerberosAuthSpec; +import org.apache.doris.kerberos.KerberosAuthenticationConfig; +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hive.conf.HiveConf; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.paimon.catalog.Catalog; import org.apache.paimon.catalog.CatalogContext; import org.apache.paimon.catalog.CatalogFactory; +import org.apache.paimon.catalog.Identifier; import org.apache.paimon.options.Options; import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; /** * Paimon connector implementation managing the lifecycle of a * {@link org.apache.paimon.catalog.Catalog} instance. * *

    The Paimon Catalog is lazily created on first metadata access. - * It supports multiple catalog backends (filesystem, HMS, DLF, REST, JDBC) - * determined by the {@code paimon.catalog.type} property. + * It supports multiple catalog backends (filesystem, HMS, REST, JDBC) + * determined by the {@code paimon.catalog.type} property. The per-flavor option + * assembly lives in the pure {@link PaimonCatalogFactory}; this class drives the + * live catalog creation. + * + *

    B1 lands all five flavors live. filesystem/jdbc create a {@link CatalogContext} carrying a + * minimal Hadoop {@link Configuration} (HDFS/S3 storage), rest is Options-only, and hms carries a + * {@link HiveConf} (metastore=hive). All create calls are wrapped in + * {@code ConnectorContext.executeAuthenticated} so the FE-injected Kerberos UGI (if any) applies; + * the default is a no-op. The {@code Configuration}/{@code HiveConf} are assembled by the pure + * builders in {@link PaimonCatalogFactory}. */ public class PaimonConnector implements Connector { private static final Logger LOG = LogManager.getLogger(PaimonConnector.class); + /** + * Caches {@link ClassLoader}s keyed by resolved driver URL so a given JDBC driver jar is + * loaded at most once across catalogs, and tracks the (url#class) keys already registered with + * the {@link java.sql.DriverManager}. Ported verbatim from the legacy + * {@code PaimonJdbcMetaStoreProperties}. + */ + private static final Map DRIVER_CLASS_LOADER_CACHE = new ConcurrentHashMap<>(); + private static final Set REGISTERED_DRIVER_KEYS = ConcurrentHashMap.newKeySet(); + + // FIX-4 (CI 973411): the legacy paimon table cache (meta.cache.paimon.table.*) governed BOTH the data + // snapshot AND the schema; the SPI cutover dropped it (marked the keys dead). meta.cache.paimon.table.ttl-second + // is restored here: it sizes the latest-snapshot cache below (data) AND, via schemaCacheTtlSecondOverride(), + // the generic schema cache (schema). enable/capacity remain best-effort (capacity uses the legacy default). + static final String TABLE_CACHE_TTL_SECOND = "meta.cache.paimon.table.ttl-second"; + // enable/capacity are not wired on the plugin path (see PaimonConnectorProvider), but their values are + // still validated at CREATE/ALTER for legacy parity (reject non-boolean / out-of-range garbage). + static final String TABLE_CACHE_ENABLE = "meta.cache.paimon.table.enable"; + static final String TABLE_CACHE_CAPACITY = "meta.cache.paimon.table.capacity"; + // Legacy default = Config.external_cache_expire_time_seconds_after_access (24h); the connector is isolated + // from fe-core Config, so the legacy default is mirrored here (an explicit ttl-second always overrides it). + static final long DEFAULT_TABLE_CACHE_TTL_SECOND = 86400L; + // Legacy default = Config.max_external_table_cache_num. + static final int DEFAULT_TABLE_CACHE_CAPACITY = 1000; + + // Catalog property key gating the plugin-side Kerberos authenticator (value matches AuthType.KERBEROS). + private static final String HADOOP_SECURITY_AUTHENTICATION = "hadoop.security.authentication"; + private final Map properties; + private final ConnectorContext context; private volatile Catalog catalog; - public PaimonConnector(Map properties) { + // Lazily-built plugin-side Kerberos authenticator (single-owner auth; see TcclPinningConnectorContext). + // null for a non-Kerberos catalog. Its doAs acts on the PLUGIN's UserGroupInformation copy — the one the + // plugin's HDFS FileSystem reads — not the app-loader copy the FE-injected authenticator logs in. + private volatile HadoopAuthenticator pluginAuth; + private volatile boolean pluginAuthComputed; + + // FIX-4: per-catalog (long-lived) cache of each table's latest snapshot id, sized by + // meta.cache.paimon.table.ttl-second (<=0 disables -> always live, the no-cache catalog). getMetadata() + // returns a fresh metadata per query, so this lives on the connector and is injected into the metadata so + // beginQuerySnapshot pins a stable id across queries. Cleared wholesale on REFRESH CATALOG (connector rebuilt). + private final PaimonLatestSnapshotCache latestSnapshotCache; + + // FIX-B-MC2: connector-level (per-catalog, long-lived) second-level memo for the time-travel + // schema-at-snapshot read. getMetadata() returns a FRESH metadata per query, so this must live on the + // connector (not the metadata) to give the cross-query hit the legacy PaimonExternalMetaCache provided. + // Cleared wholesale on REFRESH CATALOG (the connector is rebuilt). See PaimonSchemaAtMemo. + private final PaimonSchemaAtMemo schemaAtMemo = new PaimonSchemaAtMemo(PaimonSchemaAtMemo.DEFAULT_MAX_SIZE); + + public PaimonConnector(Map properties, ConnectorContext context) { this.properties = properties; + // Wrap the FE-injected context so every executeAuthenticated pins the TCCL to the plugin loader (the + // paimon plugin bundles paimon-core + hadoop child-first) and, for a Kerberos catalog, runs the op + // under a plugin-side UGI doAs (pluginAuthenticator): the plugin's FileSystem reads the plugin's own + // UserGroupInformation copy, which the FE-injected app-side authenticator never logs in — so without + // this a DDL/read against secured HDFS negotiates SIMPLE auth. See TcclPinningConnectorContext. + this.context = new TcclPinningConnectorContext(context, getClass().getClassLoader(), + this::pluginAuthenticator); + this.latestSnapshotCache = + new PaimonLatestSnapshotCache(resolveTableCacheTtlSecond(properties), DEFAULT_TABLE_CACHE_CAPACITY); + } + + /** + * Lazily builds and memoizes the plugin-side Kerberos authenticator that {@link TcclPinningConnectorContext} + * runs each op under, so remote HDFS access uses the PLUGIN's own {@code UserGroupInformation} copy (the one + * the plugin's {@code FileSystem} reads). Returns {@code null} for a non-Kerberos catalog so the FE-injected + * auth path is preserved unchanged. Construction is cheap — the keytab login is lazy in {@code getUGI()} on + * the first {@code doAs}. + */ + private HadoopAuthenticator pluginAuthenticator() { + if (!pluginAuthComputed) { + synchronized (this) { + if (!pluginAuthComputed) { + pluginAuth = buildPluginAuthenticator(properties, buildStorageHadoopConfig()); + pluginAuthComputed = true; + } + } + } + return pluginAuth; + } + + /** + * Resolves the plugin-side Kerberos authenticator for the catalog, or {@code null} for a non-Kerberos + * catalog. Two Kerberos sources are covered, in precedence order: + *

      + *
    1. Storage Kerberos — the raw {@code hadoop.security.authentication=kerberos} passthrough + * (HDFS / data-lake login), built from the storage Hadoop configuration. Unchanged prior behavior; + * when storage is Kerberos this single login also carries the HMS metastore RPC (same UGI). The + * Kerberos keys ride the {@code hadoop.*} passthrough in + * {@link PaimonCatalogFactory#buildHadoopConfiguration}; {@link HadoopAuthenticator#getHadoopAuthenticator} + * resolves the plugin (child-first) copy of fe-kerberos, so its {@code doAs} acts on the plugin UGI.
    2. + *
    3. HMS-metastore Kerberos with non-Kerberos storage — a secured Hive Metastore whose data + * storage is simple (e.g. a Kerberized HMS over S3). Legacy fe-core served this from the fe-core + * {@code PaimonHMSMetaStoreProperties} HMS authenticator (delivered via {@code DefaultConnectorContext}); + * once the fe-core pre-execution authenticator is retired (design S6) the connector must own it, + * mirroring {@code IcebergConnector.buildPluginAuthenticator}: the HMS client principal/keytab facts + * ({@link HmsMetaStoreProperties#kerberos()}) feed a + * {@link KerberosAuthenticationConfig}, so the {@code doAs} logs in the same client identity fe-core + * used. The HMS service principal / SASL settings ride the catalog's own HiveConf, not the + * login.
    4. + *
    + * Package-visible + static for direct unit testing (mirrors {@code IcebergConnector.buildPluginAuthenticator}). + */ + static HadoopAuthenticator buildPluginAuthenticator(Map properties, + Map storageHadoopConfig) { + if ("kerberos".equalsIgnoreCase(properties.get(HADOOP_SECURITY_AUTHENTICATION))) { + return HadoopAuthenticator.getHadoopAuthenticator( + PaimonCatalogFactory.buildHadoopConfiguration(properties, storageHadoopConfig)); + } + if (PaimonConnectorProperties.HMS.equals(PaimonCatalogFactory.resolveFlavor(properties))) { + HmsMetaStoreProperties hms = + (HmsMetaStoreProperties) MetaStoreProviders.bind(properties, storageHadoopConfig); + Optional spec = hms.kerberos(); + if (spec.isPresent() && spec.get().hasCredentials()) { + Configuration conf = + PaimonCatalogFactory.buildHadoopConfiguration(properties, storageHadoopConfig); + conf.set("hadoop.security.authentication", "kerberos"); + conf.set("hive.metastore.sasl.enabled", "true"); + return HadoopAuthenticator.getHadoopAuthenticator( + new KerberosAuthenticationConfig(spec.get().getPrincipal(), spec.get().getKeytab(), conf)); + } + } + return null; + } + + /** + * Parses {@code meta.cache.paimon.table.ttl-second} (legacy default 24h; {@code <= 0} disables caching -> + * the no-cache catalog reads live). An unparseable value falls back to the default rather than failing + * catalog creation (validation of the knob is best-effort; the legacy CacheSpec check was dropped at cutover). + */ + private static long resolveTableCacheTtlSecond(Map properties) { + String raw = properties.get(TABLE_CACHE_TTL_SECOND); + if (raw == null || raw.trim().isEmpty()) { + return DEFAULT_TABLE_CACHE_TTL_SECOND; + } + try { + return Long.parseLong(raw.trim()); + } catch (NumberFormatException e) { + LOG.warn("Invalid {}={}, falling back to default {}s", + TABLE_CACHE_TTL_SECOND, raw, DEFAULT_TABLE_CACHE_TTL_SECOND); + return DEFAULT_TABLE_CACHE_TTL_SECOND; + } } @Override public ConnectorMetadata getMetadata(ConnectorSession session) { - return new PaimonConnectorMetadata(ensureCatalog(), properties); + return new PaimonConnectorMetadata( + new PaimonCatalogOps.CatalogBackedPaimonCatalogOps(ensureCatalog()), properties, context, + schemaAtMemo, latestSnapshotCache); + } + + @Override + public void invalidateTable(String dbName, String tableName) { + // REFRESH TABLE (and, via the generic PluginDrivenExternalCatalog DDL hook, a Doris-issued + // DROP/CREATE of this name): drop the cached latest snapshot id so the next read goes live. Keyed by + // the REMOTE db/table names, matching the key beginQuerySnapshot stores (PaimonTableHandle carries + // remote names). + latestSnapshotCache.invalidate(Identifier.create(dbName, tableName)); + // Also drop the time-travel schema memo for this table: unlike the snapshot cache it is keyed by + // (db,table,sysTable,branch,schemaId) and would otherwise serve a stale schema-at-snapshot after a + // drop+recreate that reuses a schemaId (the memo's narrow write-once-per-schemaId assumption breaks). + schemaAtMemo.invalidate(dbName, tableName); + } + + /** + * REFRESH DATABASE hook (also reached by a Doris-issued {@code DROP DATABASE} via the generic + * {@code PluginDrivenExternalCatalog} dropDb hook, and by the hive gateway's + * {@code forEachBuiltSibling} for a paimon sibling): drop BOTH connector-owned caches for EVERY table + * in one database — the latest-snapshot pin and the time-travel schema memo — so the next query + * re-reads live. Db-scoped analogue of {@link #invalidateTable}; the name is the REMOTE db name. + * Without this override paimon inherited the SPI no-op default, so REFRESH DATABASE and DROP DATABASE + * (incl. its FORCE table cascade, which bypasses per-table invalidateTable) left both caches stale up + * to the TTL. + */ + @Override + public void invalidateDb(String dbName) { + latestSnapshotCache.invalidateDb(dbName); + schemaAtMemo.invalidateDb(dbName); + } + + @Override + public void invalidateAll() { + latestSnapshotCache.invalidateAll(); + schemaAtMemo.invalidateAll(); + } + + @Override + public OptionalLong schemaCacheTtlSecondOverride() { + // Restore the legacy single-knob semantics: meta.cache.paimon.table.ttl-second also governs the schema + // cache (the SPI routes paimon schema to the generic schema cache keyed by schema.cache.ttl-second). So + // the no-cache catalog (ttl-second=0) serves FRESH schema. Absent -> no override (engine default TTL). + String raw = properties.get(TABLE_CACHE_TTL_SECOND); + if (raw == null || raw.trim().isEmpty()) { + return OptionalLong.empty(); + } + try { + return OptionalLong.of(Long.parseLong(raw.trim())); + } catch (NumberFormatException e) { + return OptionalLong.empty(); + } } @Override public ConnectorScanPlanProvider getScanPlanProvider() { - return new PaimonScanPlanProvider(properties); + // FIX-B-R2-be: inject the SAME per-catalog schemaAtMemo getMetadata uses, so the schema-evolution + // dict's per-schema-id reads are memoized across scans (and shared with the B-MC2 time-travel path). + return new PaimonScanPlanProvider(properties, + new PaimonCatalogOps.CatalogBackedPaimonCatalogOps(ensureCatalog()), context, schemaAtMemo); + } + + /** + * Declares the E5 read-path capabilities paimon supports: MVCC snapshot pinning. The B5 fe-core + * MvccTable wiring keys off this to call {@link PaimonConnectorMetadata#beginQuerySnapshot} / + * {@code resolveTimeTravel}. + * No write capability is declared: paimon write is not migrated. + */ + @Override + public Set getCapabilities() { + return EnumSet.of( + ConnectorCapability.SUPPORTS_MVCC_SNAPSHOT, + // Paimon exposes per-partition stats (record/size/file count) via listPartitions, + // so SHOW PARTITIONS renders the legacy 5-column result (D-045). + 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 + // the same doFull SQL path). NOT SUPPORTS_TOPN_LAZY_MATERIALIZE: paimon was never eligible for + // Top-N lazy materialization. + ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE, + // Paimon's table properties (coreOptions incl. path) are user-facing and credential-free, so + // SHOW CREATE TABLE renders LOCATION + PROPERTIES for paimon. This capability replaces the + // legacy paimon-only engine-name gate in Env.getDdlStmt (the credential-leak guard now keyed + // on a capability instead of an engine string). Paimon emits no partition/sort show.* keys, so + // it renders no PARTITION BY / ORDER BY — byte-faithful with its prior SHOW CREATE output. + ConnectorCapability.SUPPORTS_SHOW_CREATE_DDL); } private Catalog ensureCatalog() { @@ -73,12 +328,250 @@ private Catalog ensureCatalog() { } private Catalog createCatalog() { - Options options = Options.fromMap(properties); - CatalogContext context = CatalogContext.create(options); + Options options = PaimonCatalogFactory.buildCatalogOptions(properties); + String flavor = PaimonCatalogFactory.resolveFlavor(properties); + // Canonical storage config from the FE-bound fe-filesystem StorageProperties (P1-T03), replacing + // the legacy buildObjectStorageHadoopConfig path: object stores contribute their fs.s3a.*/fs.oss.* + // /fs.cosn.*/fs.obs.* translation, and an HDFS-backed catalog contributes its hadoop.config.resources + // XML + HA + auth keys (C2; the defaults-free fe-filesystem Hadoop map). Empty for REST (the server + // owns storage) and for a catalog with no typed storage at all (it reaches the conf via the raw + // fs./dfs./hadoop. passthrough). + Map storageHadoopConfig = buildStorageHadoopConfig(); + + switch (flavor) { + case PaimonConnectorProperties.FILESYSTEM: { + // filesystem carries a Hadoop Configuration for HDFS/S3 storage. + Configuration conf = PaimonCatalogFactory.buildHadoopConfiguration(properties, storageHadoopConfig); + return createCatalogFromContext(CatalogContext.create(options, conf), flavor, + "Failed to create Paimon catalog with filesystem metastore"); + } + case PaimonConnectorProperties.REST: { + // rest is Options-only (no storage Configuration; the REST server owns storage). + return createCatalogFromContext(CatalogContext.create(options), flavor, + "Failed to create Paimon catalog with REST metastore"); + } + case PaimonConnectorProperties.JDBC: { + maybeRegisterJdbcDriver(); + Configuration conf = PaimonCatalogFactory.buildHadoopConfiguration(properties, storageHadoopConfig); + return createCatalogFromContext(CatalogContext.create(options, conf), flavor, + "Failed to create Paimon catalog with JDBC metastore"); + } + case PaimonConnectorProperties.HMS: { + // NOTE (B1/cutover-blocker P5-B7): the live metastore=hive path needs the Thrift + // metastore client (org.apache.hadoop.hive.metastore.IMetaStoreClient / + // HiveMetaStoreClient), which is NOT provided by this connector's compile deps + // (paimon-hive-connector-3.1 keeps hive-exec/hive-metastore/hadoop-client at test + // scope; hive-common only carries HiveConf). At cutover it must resolve from the FE + // host's hive-catalog-shade. There is also a cross-classloader identity hazard: the + // plugin loads child-first, so the bundled hadoop-common/hive-common Configuration/ + // HiveConf can diverge from the host shade's. Live-e2e MUST verify, before cutover, + // that a real HMS-backed metastore=hive paimon catalog created through the plugin + // throws neither NoClassDefFoundError (.../IMetaStoreClient) nor a Configuration/ + // HiveConf LinkageError/ClassCastException. + // FIX-HMS-CONFRES: the external hive-site.xml (hive.conf.resources) is resolved by the + // connector itself (PaimonCatalogFactory.addConfResources) and seeded as the HiveConf BASE, + // so connection-critical settings present only in that file reach the live metastore client. + // Shared parser produces the neutral HiveConf overrides (P2-T03); the connector seeds the + // external hive-site.xml as the BASE first, then overlays the overrides (F2 ordering). + HmsMetaStoreProperties hms = (HmsMetaStoreProperties) + MetaStoreProviders.bind(properties, storageHadoopConfig); + HiveConf hc = PaimonCatalogFactory.assembleHiveConf( + PaimonCatalogFactory.firstNonBlank(properties, "hive.conf.resources"), + hms.toHiveConfOverrides(context.getEnvironment() + .getOrDefault("hive_metastore_client_timeout_second", "10"))); + return createCatalogFromContext(CatalogContext.create(options, hc), flavor, + "Failed to create Paimon catalog with HMS metastore"); + } + default: + throw new IllegalArgumentException("Unknown paimon.catalog.type value: " + flavor); + } + } + + /** + * 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 + * {@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 + * defaults-free so it never clobbers a co-bound object-store provider's tuned fs.s3a.* here). This + * replaces the legacy {@code StorageProperties.buildObjectStorageHadoopConfig(properties)} call that + * {@link PaimonCatalogFactory#buildHadoopConfiguration}/{@code buildHmsHiveConf} + * 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() + // -> toHadoopProperties() -> Configuration wiring end-to-end (visible for testing). + Map buildStorageHadoopConfig() { + Map merged = new HashMap<>(); + for (StorageProperties sp : context.getStorageProperties()) { + sp.toHadoopProperties().ifPresent(h -> merged.putAll(h.toHadoopConfigurationMap())); + } + return merged; + } + + private Catalog createCatalogFromContext(CatalogContext catalogContext, String flavor, String failureMessage) { + // Pin the thread-context classloader to the plugin loader for the duration of catalog + // creation (FIX-PAIMON-HADOOP-CLASSLOADER). Hadoop's FileSystem ServiceLoader + // (FileSystem.loadFileSystems -> ServiceLoader.load(FileSystem.class)) and SecurityUtil's + // static init resolve classes via the thread-context CL; without the pin they read the parent + // 'app' loader's service files / hadoop classes and split-brain against the child-loaded + // FileSystem (which permanently poisons SecurityUtil.). Mirrors JdbcConnectorClient / + // ThriftHmsClient. The one-time FS class resolution + SecurityUtil init happen here on the + // first FileSystem.get, so pinning creation is sufficient; later FS ops reuse loaded classes. + ClassLoader previous = Thread.currentThread().getContextClassLoader(); try { - return CatalogFactory.createCatalog(context); + Thread.currentThread().setContextClassLoader(getClass().getClassLoader()); + return context.executeAuthenticated(() -> CatalogFactory.createCatalog(catalogContext)); } catch (Exception e) { - throw new RuntimeException("Failed to create Paimon catalog: " + e.getMessage(), e); + throw new RuntimeException(failureMessage + " (flavor=" + flavor + "): " + e.getMessage(), e); + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + } + + /** + * Enforces JDBC driver-url security at CREATE CATALOG (rereview2 B-8b). For the JDBC flavor a + * configured {@code driver_url} — read from either the {@code jdbc.driver_url} or the + * {@code paimon.jdbc.driver_url} alias — is routed through the engine's + * {@link ConnectorValidationContext#validateAndResolveDriverPath} hook, which applies the FE + * format / {@code jdbc_driver_url_white_list} / {@code jdbc_driver_secure_path} gates (legacy + * {@code JdbcResource.getFullDriverUrl}). A rejected url throws here, so CREATE CATALOG fails + * before the jar is ever loaded into the FE JVM by {@link #maybeRegisterJdbcDriver}. Mirrors + * {@code JdbcDorisConnector.preCreateValidation}; non-JDBC flavors are a no-op. + */ + @Override + public void preCreateValidation(ConnectorValidationContext validationContext) throws Exception { + if (!PaimonConnectorProperties.JDBC.equals(PaimonCatalogFactory.resolveFlavor(properties))) { + return; + } + String driverUrl = PaimonCatalogFactory.firstNonBlank( + properties, PaimonConnectorProperties.JDBC_DRIVER_URL); + if (StringUtils.isNotBlank(driverUrl)) { + validationContext.validateAndResolveDriverPath(driverUrl); + } + } + + /** + * If a JDBC driver_url is configured, dynamically load + register the driver before creating + * the catalog. {@link java.sql.DriverManager#getConnection} does not consult the thread context + * class loader, so the driver must be registered globally. Ported from the legacy + * {@code PaimonJdbcMetaStoreProperties.registerJdbcDriver}, with the fe-core + * {@code JdbcResource.getFullDriverUrl} dependency replaced by connector-side resolution + * against {@code ConnectorContext.getEnvironment()}. + */ + private void maybeRegisterJdbcDriver() { + String driverUrl = PaimonCatalogFactory.firstNonBlank( + properties, PaimonConnectorProperties.JDBC_DRIVER_URL); + if (StringUtils.isBlank(driverUrl)) { + return; + } + String driverClass = PaimonCatalogFactory.firstNonBlank( + properties, PaimonConnectorProperties.JDBC_DRIVER_CLASS); + registerJdbcDriver(driverUrl, driverClass); + LOG.info("Using dynamic JDBC driver for Paimon JDBC catalog from: {}", driverUrl); + } + + /** + * Resolves a driver_url to a full, scheme-bearing URL string for FE driver registration, + * delegating to the shared {@link JdbcDriverSupport#resolveDriverUrl} so the FE registration + * path and the BE-bound scan options ({@code PaimonScanPlanProvider.getBackendPaimonOptions}) + * resolve a given driver_url identically. + * + *

    FE security validation (format / {@code jdbc_driver_url_white_list} / + * {@code jdbc_driver_secure_path}) is enforced at CREATE CATALOG by {@link #preCreateValidation} + * via the engine's {@code ConnectorValidationContext.validateAndResolveDriverPath} hook — a + * rejected url fails catalog creation before this path is ever reached. Like the JDBC reference + * connector ({@code JdbcDorisConnector}), validation is CREATE-time only; catalogs reloaded after + * an FE restart or reconfigured via ALTER CATALOG are not re-validated against a since-tightened + * allow-list (a pre-existing fe-core gap shared by all plugin connectors — see deviations-log). + */ + private String resolveFullDriverUrl(String driverUrl) { + Map env = context != null ? context.getEnvironment() : Collections.emptyMap(); + return JdbcDriverSupport.resolveDriverUrl(driverUrl, env); + } + + private void registerJdbcDriver(String driverUrl, String driverClassName) { + try { + if (StringUtils.isBlank(driverClassName)) { + throw new IllegalArgumentException( + "jdbc.driver_class or paimon.jdbc.driver_class is required when jdbc.driver_url " + + "or paimon.jdbc.driver_url is specified"); + } + + String fullDriverUrl = resolveFullDriverUrl(driverUrl); + URL url = new URL(fullDriverUrl); + String driverKey = fullDriverUrl + "#" + driverClassName; + if (!REGISTERED_DRIVER_KEYS.add(driverKey)) { + LOG.info("JDBC driver already registered for Paimon catalog: {} from {}", + driverClassName, fullDriverUrl); + return; + } + try { + ClassLoader classLoader = DRIVER_CLASS_LOADER_CACHE.computeIfAbsent(url, u -> { + ClassLoader parent = getClass().getClassLoader(); + return URLClassLoader.newInstance(new URL[] {u}, parent); + }); + Class loadedDriverClass = Class.forName(driverClassName, true, classLoader); + java.sql.Driver driver = (java.sql.Driver) loadedDriverClass.getDeclaredConstructor().newInstance(); + java.sql.DriverManager.registerDriver(new DriverShim(driver)); + LOG.info("Successfully registered JDBC driver for Paimon catalog: {} from {}", + driverClassName, fullDriverUrl); + } catch (ClassNotFoundException e) { + REGISTERED_DRIVER_KEYS.remove(driverKey); + throw new IllegalArgumentException("Failed to load JDBC driver class: " + driverClassName, e); + } catch (Exception e) { + REGISTERED_DRIVER_KEYS.remove(driverKey); + throw new RuntimeException("Failed to register JDBC driver: " + driverClassName, e); + } + } catch (MalformedURLException e) { + throw new IllegalArgumentException("Invalid driver URL: " + driverUrl, e); + } catch (IllegalArgumentException e) { + throw e; + } + } + + private static class DriverShim implements java.sql.Driver { + private final java.sql.Driver delegate; + + DriverShim(java.sql.Driver delegate) { + this.delegate = delegate; + } + + @Override + public java.sql.Connection connect(String url, java.util.Properties info) throws java.sql.SQLException { + return delegate.connect(url, info); + } + + @Override + public boolean acceptsURL(String url) throws java.sql.SQLException { + return delegate.acceptsURL(url); + } + + @Override + public java.sql.DriverPropertyInfo[] getPropertyInfo(String url, java.util.Properties info) + throws java.sql.SQLException { + return delegate.getPropertyInfo(url, info); + } + + @Override + public int getMajorVersion() { + return delegate.getMajorVersion(); + } + + @Override + public int getMinorVersion() { + return delegate.getMinorVersion(); + } + + @Override + public boolean jdbcCompliant() { + return delegate.jdbcCompliant(); + } + + @Override + public java.util.logging.Logger getParentLogger() throws java.sql.SQLFeatureNotSupportedException { + return delegate.getParentLogger(); } } 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 8f190da564381b..c2e1fe021da017 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 @@ -19,27 +19,49 @@ import org.apache.doris.connector.api.ConnectorColumn; import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorPartitionInfo; import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.ConnectorTableSchema; +import org.apache.doris.connector.api.ConnectorTableStatistics; import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.ddl.ConnectorCreateTableRequest; 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.mvcc.ConnectorTimeTravelSpec; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.scan.ConnectorPartitionValues; +import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.thrift.THiveTable; +import org.apache.doris.thrift.TTableDescriptor; +import org.apache.doris.thrift.TTableType; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.apache.paimon.CoreOptions; import org.apache.paimon.catalog.Catalog; import org.apache.paimon.catalog.Identifier; +import org.apache.paimon.partition.Partition; +import org.apache.paimon.schema.Schema; +import org.apache.paimon.table.DataTable; import org.apache.paimon.table.Table; +import org.apache.paimon.table.system.SystemTableLoader; import org.apache.paimon.types.DataField; +import org.apache.paimon.types.DataTypeRoot; import org.apache.paimon.types.RowType; +import org.apache.paimon.utils.DateTimeUtils; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; /** * {@link ConnectorMetadata} implementation for Paimon. @@ -52,41 +74,97 @@ public class PaimonConnectorMetadata implements ConnectorMetadata { private static final Logger LOG = LogManager.getLogger(PaimonConnectorMetadata.class); - private final Catalog catalog; + private final PaimonCatalogOps catalogOps; private final PaimonTypeMapping.Options typeMappingOptions; + private final ConnectorContext context; + // The connector's own injected catalog property map. Retained to resolve the catalog flavor + // for the HMS-only-props gate in createDatabase. This is the same data as + // session.getCatalogProperties() (the FE injects both from one source), but using the + // directly-injected map avoids depending on the session being populated and is simpler. + private final Map catalogProperties; - public PaimonConnectorMetadata(Catalog catalog, Map properties) { - this.catalog = catalog; + // FIX-B-MC2: time-travel schema-at-snapshot memo. Injected by PaimonConnector (the per-catalog, + // long-lived owner) so the at-snapshot resolve hits across queries. The public 3-arg ctor gives each + // metadata its OWN fresh memo (no cross-query benefit, but correct) so the ~15 existing construction + // sites compile unchanged; production goes through the 4-arg ctor with the connector-shared memo. + private final PaimonSchemaAtMemo schemaAtMemo; + + // FIX-4: per-catalog latest-snapshot-id cache (injected by PaimonConnector, the long-lived owner) so the + // query-begin pin serves a STABLE snapshot id across queries within the TTL (restores the legacy table + // cache). The 3-arg / 4-arg ctors give each metadata its OWN disabled cache (ttl<=0 => always live) so the + // existing direct-construction tests compile unchanged; production goes through the 5-arg ctor. + private final PaimonLatestSnapshotCache latestSnapshotCache; + + public PaimonConnectorMetadata(PaimonCatalogOps catalogOps, Map properties, + ConnectorContext context) { + this(catalogOps, properties, context, new PaimonSchemaAtMemo(PaimonSchemaAtMemo.DEFAULT_MAX_SIZE)); + } + + PaimonConnectorMetadata(PaimonCatalogOps catalogOps, Map properties, + ConnectorContext context, PaimonSchemaAtMemo schemaAtMemo) { + this(catalogOps, properties, context, schemaAtMemo, new PaimonLatestSnapshotCache(0L, 1)); + } + + PaimonConnectorMetadata(PaimonCatalogOps catalogOps, Map properties, + ConnectorContext context, PaimonSchemaAtMemo schemaAtMemo, + PaimonLatestSnapshotCache latestSnapshotCache) { + this.catalogOps = catalogOps; this.typeMappingOptions = buildTypeMappingOptions(properties); + this.context = context; + this.catalogProperties = properties; + this.schemaAtMemo = schemaAtMemo; + this.latestSnapshotCache = latestSnapshotCache; } @Override public List listDatabaseNames(ConnectorSession session) { + // M-11: wrap the remote read in executeAuthenticated so the FE-injected Kerberos UGI applies (legacy + // PaimonMetadataOps.listDatabaseNames wrapped it too). On failure, rethrow with the catalog name exactly + // as legacy PaimonMetadataOps did (R3) — swallowing to an empty list would mask a transient metastore + // failure as "zero databases" and diverges from every other connector (all propagate). Read-vs-DDL + // parity (D-052). try { - return catalog.listDatabases(); + return context.executeAuthenticated(() -> catalogOps.listDatabases()); } catch (Exception e) { - LOG.warn("Failed to list Paimon databases", e); - return Collections.emptyList(); + throw new RuntimeException( + "Failed to list databases names, catalog name: " + context.getCatalogName(), e); } } @Override public boolean databaseExists(ConnectorSession session, String dbName) { + // M-11: wrap the remote read in executeAuthenticated (D-052). DatabaseNotExistException is + // caught INSIDE the lambda: under Kerberos UGI.doAs would otherwise wrap the checked + // exception in UndeclaredThrowableException, so an outer catch would not match. try { - catalog.getDatabase(dbName); - return true; - } catch (Catalog.DatabaseNotExistException e) { - return false; + return context.executeAuthenticated(() -> { + try { + catalogOps.getDatabase(dbName); + return true; + } catch (Catalog.DatabaseNotExistException e) { + return false; + } + }); + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to check Paimon database existence " + dbName + ": " + e.getMessage(), e); } } @Override public List listTableNames(ConnectorSession session, String dbName) { + // M-11: wrap the remote read in executeAuthenticated (D-052). DatabaseNotExistException is + // caught INSIDE the lambda (Kerberos UGI.doAs would wrap it otherwise); other failures fall + // to the outer catch, preserving the original empty-list-on-error behavior. try { - return catalog.listTables(dbName); - } catch (Catalog.DatabaseNotExistException e) { - LOG.warn("Database does not exist: {}", dbName); - return Collections.emptyList(); + return context.executeAuthenticated(() -> { + try { + return catalogOps.listTables(dbName); + } catch (Catalog.DatabaseNotExistException e) { + LOG.warn("Database does not exist: {}", dbName); + return Collections.emptyList(); + } + }); } catch (Exception e) { LOG.warn("Failed to list tables in database: {}", dbName, e); return Collections.emptyList(); @@ -97,18 +175,26 @@ public List listTableNames(ConnectorSession session, String dbName) { public Optional getTableHandle( ConnectorSession session, String dbName, String tableName) { Identifier identifier = Identifier.create(dbName, tableName); + // M-11: wrap the remote getTable in executeAuthenticated (D-052). TableNotExistException is + // caught INSIDE the lambda (Kerberos UGI.doAs would wrap it otherwise) and yields an empty + // handle, exactly as before; the trailing handle build is pure (no remote call). try { - Table table = catalog.getTable(identifier); - List partitionKeys = table.partitionKeys(); - List primaryKeys = table.primaryKeys(); - PaimonTableHandle handle = new PaimonTableHandle( - dbName, tableName, - partitionKeys != null ? partitionKeys : Collections.emptyList(), - primaryKeys != null ? primaryKeys : Collections.emptyList()); - handle.setPaimonTable(table); - return Optional.of(handle); - } catch (Catalog.TableNotExistException e) { - return Optional.empty(); + return context.executeAuthenticated(() -> { + Table table; + try { + table = catalogOps.getTable(identifier); + } catch (Catalog.TableNotExistException e) { + return Optional.empty(); + } + List partitionKeys = table.partitionKeys(); + List primaryKeys = table.primaryKeys(); + PaimonTableHandle handle = new PaimonTableHandle( + dbName, tableName, + partitionKeys != null ? partitionKeys : Collections.emptyList(), + primaryKeys != null ? primaryKeys : Collections.emptyList()); + handle.setPaimonTable(table); + return Optional.of(handle); + }); } catch (Exception e) { LOG.warn("Failed to get Paimon table handle: {}.{}", dbName, tableName, e); return Optional.empty(); @@ -119,34 +205,212 @@ public Optional getTableHandle( public ConnectorTableSchema getTableSchema( ConnectorSession session, ConnectorTableHandle handle) { PaimonTableHandle paimonHandle = (PaimonTableHandle) handle; - Identifier identifier = Identifier.create( - paimonHandle.getDatabaseName(), paimonHandle.getTableName()); - try { - Table table = catalog.getTable(identifier); - RowType rowType = table.rowType(); - List primaryKeys = table.primaryKeys(); - List columns = mapFields(rowType, primaryKeys); - - Map schemaProps = new HashMap<>(); - if (paimonHandle.getPartitionKeys() != null - && !paimonHandle.getPartitionKeys().isEmpty()) { - schemaProps.put("partition_keys", - String.join(",", paimonHandle.getPartitionKeys())); + // resolveTable branches on isSystemTable() to pick the 4-arg sys Identifier vs the 2-arg + // base Identifier on a transient-table-null reload, so a sys handle reads its OWN rowType. + Table table = resolveTable(paimonHandle); + // For a non-system data table, read the LATEST schema FRESH via the connector's schema manager + // (schemaManager().latest()), NOT the cached Table's rowType(): paimon's CachingCatalog returns a + // Table instance whose rowType() is FROZEN at load time, while an external ALTER ADD COLUMNS bumps + // the schema file (new schema id) WITHOUT a new snapshot — so rowType() (and the latest snapshot's + // schemaId) stay behind while schemaManager().latest() advances. Reading latest restores legacy + // PaimonExternalTable parity so a no-cache catalog (meta.cache.paimon.table.ttl-second=0) — and a + // with-cache catalog after REFRESH busts the FE schema cache — reflects the external schema change. + // partitionKeys/primaryKeys also come from the resolved latest schema (parity with the at-snapshot + // path; the handle's keys were built from the stale cached table). latestSchema() is empty for a + // non-DataTable backend (e.g. FormatTable) or a schema-less table -> fall back to rowType(). System + // tables (isSystemTable()) always keep their synthetic rowType() (no schema-version history; some + // are not DataTable). Sharing buildTableSchema with the at-snapshot path keeps the two from drifting. + if (!paimonHandle.isSystemTable()) { + Optional latest = catalogOps.latestSchema(table); + if (latest.isPresent()) { + PaimonCatalogOps.PaimonSchemaSnapshot schema = latest.get(); + return buildTableSchema( + paimonHandle.getTableName(), + table, + schema.fields(), + schema.partitionKeys(), + schema.primaryKeys()); } + } + return buildTableSchema( + paimonHandle.getTableName(), + table, + table.rowType().getFields(), + paimonHandle.getPartitionKeys(), + table.primaryKeys()); + } + + /** + * Returns the schema AS OF {@code snapshot.getSchemaId()} (the pinned schema version, for + * time-travel reads under schema evolution). Falls back to the LATEST schema + * ({@link #getTableSchema(ConnectorSession, ConnectorTableHandle)}) when there is no pinned + * schema id (null snapshot or {@code schemaId < 0}), which also covers system tables (their + * synthetic rowType is their own and has no schema-version history). + * + *

    When a pinned schema id IS present, the schema at that version is resolved through the + * {@link PaimonCatalogOps#schemaAt} seam and mapped with the SAME field mapping AND the same + * {@code partition_columns}/{@code primary_keys} property emission as the latest path (via the + * shared {@link #buildTableSchema}). Unlike the latest path, the partition keys come from the + * RESOLVED historical schema (not the handle), because under schema evolution the partition set + * may itself differ at the pinned version — mirroring legacy {@code initSchema(schemaId)}, which + * read {@code tableSchema.partitionKeys()} of the pinned schema. + */ + @Override + public ConnectorTableSchema getTableSchema( + ConnectorSession session, ConnectorTableHandle handle, + ConnectorMvccSnapshot snapshot) { + if (snapshot == null || snapshot.getSchemaId() < 0) { + return getTableSchema(session, handle); + } + PaimonTableHandle paimonHandle = (PaimonTableHandle) handle; + long schemaId = snapshot.getSchemaId(); + Table table = resolveTable(paimonHandle); + // FIX-B-MC2: memoize the schemaAt schema-file read across queries. resolveTable + buildTableSchema + // still run every query (keeping the live coreOptions/properties current); only the schemaAt + // round-trip is skipped on a repeat. The memo is keyed by (handle-identity, schemaId) — a pure + // function — and owned by the per-catalog PaimonConnector. resolveTable runs ONCE, outside the + // loader, so a branch handle's getTable reload happens at most once per query (= the pre-fix path). + PaimonCatalogOps.PaimonSchemaSnapshot schema = + schemaAtMemo.getOrLoad(paimonHandle, schemaId, () -> catalogOps.schemaAt(table, schemaId)); + return buildTableSchema( + paimonHandle.getTableName(), + table, + schema.fields(), + schema.partitionKeys(), + schema.primaryKeys()); + } + + /** + * Maps paimon {@code fields} to Doris columns and emits the {@code partition_columns} / + * {@code primary_keys} schema properties exactly the way the latest path always has. Factored + * out so the latest path and the at-snapshot path ({@link #getTableSchema(ConnectorSession, + * ConnectorTableHandle, ConnectorMvccSnapshot)}) share ONE mapping and cannot drift. + */ + private ConnectorTableSchema buildTableSchema(String tableName, Table table, List fields, + List partitionKeys, List primaryKeys) { + List columns = mapFields(fields, primaryKeys); + + // LinkedHashMap so the table-options order (used by SHOW CREATE TABLE's PROPERTIES) is + // deterministic across runs. + Map schemaProps = new LinkedHashMap<>(); + // D-046: surface the paimon table options (path, file.format, write-only, ...) so SHOW + // CREATE TABLE can render LOCATION + PROPERTIES with legacy parity. Mirrors legacy + // PaimonExternalTable.getTableProperties() = coreOptions().toMap() (+ injected primary-key). + // System tables are not DataTable (legacy getTableProperties returns empty for them), so + // the coreOptions() / "path" surface is guarded the same way. "path" is already a key inside + // coreOptions().toMap(), which the fe-core LOCATION render reads. These are plain string keys + // (no fe-core dependency); the fe-core consumer filters out the schema-control keys below. + if (table instanceof DataTable) { + schemaProps.putAll(((DataTable) table).coreOptions().toMap()); if (primaryKeys != null && !primaryKeys.isEmpty()) { - schemaProps.put("primary_keys", String.join(",", primaryKeys)); + schemaProps.put(CoreOptions.PRIMARY_KEY.key(), String.join(",", primaryKeys)); } + } + if (partitionKeys != null && !partitionKeys.isEmpty()) { + // Emit "partition_columns" (NOT "partition_keys"): the generic fe-core consumer + // PluginDrivenExternalTable.initSchema reads "partition_columns" — keying it under + // "partition_keys" left the FE treating paimon as non-partitioned. Mirrors MaxCompute. + // #65094 read-path alignment: column names are case-preserved above (mapFields/getColumnHandles + // use bare .name()), and PluginDrivenExternalTable.initSchema matches each partition_columns + // entry against those column names via a case-sensitive byName lookup (paimon does not override + // fromRemoteColumnName), so the entries carry the SAME case as the columns to keep the two sides + // matchable (a mixed-case paimon partition key would otherwise be silently missed and the table + // 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( - paimonHandle.getTableName(), - columns, - "PAIMON", - schemaProps); - } catch (Catalog.TableNotExistException e) { - throw new RuntimeException("Paimon table not found: " + identifier, e); + return new ConnectorTableSchema(tableName, columns, "PAIMON", schemaProps); + } + + // ==================== E7: System Tables ==================== + + /** + * Lists the system-table names paimon exposes. Connector-global: legacy + * {@code PaimonSysTable.SUPPORTED_SYS_TABLES} is built once from + * {@code SystemTableLoader.SYSTEM_TABLES} and applies to every paimon table, so this returns + * the same SDK list for any base handle (a defensive unmodifiable copy of the bare names, + * no {@code "$"} prefix). + */ + @Override + public List listSupportedSysTables(ConnectorSession session, + ConnectorTableHandle baseTableHandle) { + return Collections.unmodifiableList(new ArrayList<>(SystemTableLoader.SYSTEM_TABLES)); + } + + /** + * Resolves a handle for the named system table of {@code baseTableHandle}, or empty when + * paimon does not expose {@code sysName} (case-insensitive, per legacy + * {@code shouldForceJniForSystemTable}'s {@code equalsIgnoreCase} use) or the base table no + * longer exists. + * + *

    The system {@link Table} is loaded through the EXISTING {@link PaimonCatalogOps#getTable} + * seam by constructing the 4-arg sys {@link Identifier} + * {@code new Identifier(db, table, "main", sysName)} — no new seam method is needed because + * {@code CatalogBackedPaimonCatalogOps.getTable} passes the Identifier through to + * {@code catalog.getTable(identifier)} unchanged, and paimon's catalog dispatches to the + * system table when the Identifier carries a system-table name. The branch is HARDCODED + * {@code "main"}: non-"main" branch system tables are unsupported (legacy parity, see + * {@code PaimonSysExternalTable#getSysPaimonTable}). + * + *

    {@code forceJni} mirrors legacy {@code PaimonScanNode.shouldForceJniForSystemTable}: only + * {@code binlog} / {@code audit_log} are NAME-forced to the JNI reader. Other sys tables ("ro", + * metadata tables) are NOT force-forced here; their JNI-vs-native routing is decided at scan + * time by split type (T19), so this must not over-force. + */ + @Override + public Optional getSysTableHandle(ConnectorSession session, + ConnectorTableHandle baseTableHandle, String sysName) { + PaimonTableHandle base = (PaimonTableHandle) baseTableHandle; + // Null-safe: a null/unknown sysName is "this connector does not expose that sys table" + // (Optional.empty per the Javadoc contract), NOT an NPE/exception. + if (!isSupportedSysTable(sysName)) { + return Optional.empty(); + } + // Normalize to lowercase for handle identity parity with legacy: SysTable renders the suffix + // as "$" + sysTableName.toLowerCase(), so t$BINLOG and t$binlog must be the SAME handle + // (identical equals/hashCode/toString and the same sys Identifier). The support check above + // stays case-insensitive; only the canonical stored name is lowercased. + String sys = sysName.toLowerCase(java.util.Locale.ROOT); + Identifier sysId = new Identifier( + base.getDatabaseName(), base.getTableName(), "main", sys); + // M-11: wrap the remote getTable in executeAuthenticated (D-052). TableNotExistException is + // caught INSIDE the lambda (Kerberos UGI.doAs would wrap it otherwise) and signalled out as a + // null Table so this method can still short-circuit to Optional.empty(). + Table sysTable; + try { + sysTable = context.executeAuthenticated(() -> { + try { + return catalogOps.getTable(sysId); + } catch (Catalog.TableNotExistException e) { + return null; + } + }); } catch (Exception e) { - throw new RuntimeException("Failed to get Paimon table schema: " + identifier, e); + throw new RuntimeException("Failed to load Paimon system table: " + sysId, e); + } + if (sysTable == null) { + return Optional.empty(); + } + boolean forceJni = "binlog".equals(sys) || "audit_log".equals(sys); + PaimonTableHandle handle = PaimonTableHandle.forSystemTable( + base.getDatabaseName(), base.getTableName(), sys, forceJni); + handle.setPaimonTable(sysTable); + return Optional.of(handle); + } + + private static boolean isSupportedSysTable(String sysName) { + if (sysName == null) { + return false; + } + for (String supported : SystemTableLoader.SYSTEM_TABLES) { + if (supported.equalsIgnoreCase(sysName)) { + return true; + } } + return false; } @Override @@ -154,45 +418,767 @@ public Map getProperties() { return Collections.emptyMap(); } + // ==================== E5: MVCC Snapshots / Time Travel ==================== + + /** + * Returns the query-begin MVCC pin: the table's LATEST snapshot, used as the consistent version + * for every read of {@code handle} in this query (mirrors legacy + * {@code PaimonExternalTable.getPaimonSnapshotCacheValue} using {@code latestSnapshot().id()}). + * + *

    System tables MUST NOT expose MVCC (they are synthetic metadata views; pinning them to a + * data snapshot is meaningless — see also the T19 scan-node fail-loud guard), so a sys handle + * returns {@link Optional#empty()}. + * + *

    An EMPTY table (no snapshot yet) returns a snapshot whose id is the legacy + * {@code INVALID_SNAPSHOT_ID} (-1), NOT {@link Optional#empty()}: empty here means "no MVCC + * support", but paimon DOES support MVCC, so the connector still pins (legacy seeded -1 and only + * overwrote it when {@code latestSnapshot().isPresent()}). + */ @Override - public Map getColumnHandles( + public Optional beginQuerySnapshot( ConnectorSession session, ConnectorTableHandle handle) { PaimonTableHandle paimonHandle = (PaimonTableHandle) handle; - Table table = paimonHandle.getPaimonTable(); - if (table == null) { - // Fallback: re-load from catalog - Identifier id = Identifier.create( - paimonHandle.getDatabaseName(), paimonHandle.getTableName()); - try { - table = catalog.getTable(id); - } catch (Exception e) { - throw new RuntimeException("Failed to load Paimon table: " + id, e); + if (paimonHandle.isSystemTable()) { + return Optional.empty(); + } + // FIX-4: serve the latest snapshot id through the per-catalog cache so the with-cache catalog pins a + // STABLE id across queries (an external write made after the pin is invisible until the entry expires + // or REFRESH TABLE/CATALOG invalidates it). The live read (resolveTable + latestSnapshotId) runs only + // on a miss; when caching is disabled (ttl-second<=0, the no-cache catalog) it runs every call. + Identifier identifier = Identifier.create(paimonHandle.getDatabaseName(), paimonHandle.getTableName()); + long id = latestSnapshotCache.getOrLoad(identifier, + () -> catalogOps.latestSnapshotId(resolveTable(paimonHandle)).orElse(-1L)); + return Optional.of(ConnectorMvccSnapshot.builder().snapshotId(id).build()); + } + + /** + * Resolves an explicit time-travel {@code spec} into a pinned {@link ConnectorMvccSnapshot}, + * owning ALL paimon-specific parsing (snapshot-id lookup, datetime parse, tag resolution). This + * is the unified seam that supersedes the retired {@code getSnapshotById}/{@code getSnapshotAt} + * (B5b). The returned snapshot carries (a) the resolved {@code snapshotId}, (b) the resolved + * {@code schemaId} so schema-at-snapshot reads pick the historical schema, and (c) the + * connector's scan-option {@code properties} (which {@link #applySnapshot} threads into the + * scan handle). + * + *

    Maps each {@link ConnectorTimeTravelSpec.Kind} to legacy + * {@code PaimonExternalTable.getPaimonSnapshotCacheValue} (lines 124-144): + *

      + *
    • {@code SNAPSHOT_ID} — {@code Long.parseLong(stringValue)}; if the snapshot does not + * exist returns {@link Optional#empty()}; pins {@code scan.snapshot-id}.
    • + *
    • {@code TIMESTAMP} — derives epoch-millis (digital ⇒ {@code Long.parseLong}; else paimon + * {@code DateTimeUtils.parseTimestampData(value, 3, sessionTZ)}, the byte-parity datetime + * parse), then the at-or-before snapshot; empty when none; pins {@code scan.snapshot-id}. + *
    • + *
    • {@code TAG} — resolves the tag's snapshot; empty when absent; pins {@code scan.tag-name} + * to the tag NAME (legacy pins the name, not the id).
    • + *
    • {@code INCREMENTAL} — {@code @incr(...)} read: validates the raw window params via + * {@link PaimonIncrementalScanParams#validate} (the ~180-line legacy validation, ported + * byte-faithfully) and pins at the LATEST snapshot (legacy {@code @incr} reads latest with + * EMPTY partition info and applies the {@code incremental-between*} options at scan time). + * The validated options are carried as {@code properties}; because that map is non-empty, + * {@link #applySnapshot} threads exactly those options and does NOT inject + * {@code scan.snapshot-id} (which would conflict with {@code incremental-between}).
    • + *
    • {@code BRANCH} — {@code @branch('name')} read: validates the branch on the BASE table via + * {@link PaimonCatalogOps#branchExists} (empty-if-absent, like snapshot/tag not-found), then + * loads the branch as its OWN table (independent schema/snapshots, via the 3-arg branch + * Identifier through {@link PaimonTableHandle#withBranch}) and pins its LATEST snapshot — + * branches have NO in-branch time-travel (legacy {@code PaimonExternalTable} reads the + * branch's {@code latestSnapshot()} only). The branch identity is carried to + * {@link #applySnapshot} via an internal sentinel ({@code CoreOptions.BRANCH} key, NOT a + * scan-copy option); no {@code scan.snapshot-id} is pinned (the branch reads its own latest). + * An empty branch (no snapshot) pins {@code snapshotId=-1} and {@code schemaId=-1}: a benign + * divergence from legacy's {@code schemaId=0L} — the resulting schema is identical (both + * resolve to the branch's current schema), mirroring the INCREMENTAL empty-table -1 note.
    • + *
    + * + *

    CONTRACT DIFFERENCE (intentional, documented): legacy {@code PaimonUtil} THREW a + * {@code UserException} when the id/timestamp/tag was not found. The SPI contract here is + * empty-if-none; the B5b-3 fe-core consumer translates {@link Optional#empty()} into the + * user-facing error. Not-found is returned as empty; only a malformed spec (e.g. a non-digital + * snapshot id) propagates as an exception, matching legacy {@code Long.parseLong}. + * + *

    System tables do not expose time-travel (same guard as {@link #beginQuerySnapshot}) → + * {@link Optional#empty()}. + */ + @Override + public Optional resolveTimeTravel( + ConnectorSession session, ConnectorTableHandle handle, + ConnectorTimeTravelSpec spec) { + PaimonTableHandle paimonHandle = (PaimonTableHandle) handle; + if (paimonHandle.isSystemTable()) { + return Optional.empty(); + } + Table table = resolveTable(paimonHandle); + switch (spec.getKind()) { + case SNAPSHOT_ID: { + long id = Long.parseLong(spec.getStringValue()); + if (!catalogOps.snapshotExists(table, id)) { + return Optional.empty(); + } + long schemaId = catalogOps.snapshotSchemaId(table, id).orElse(-1L); + return Optional.of(ConnectorMvccSnapshot.builder() + .snapshotId(id) + .schemaId(schemaId) + .property(CoreOptions.SCAN_SNAPSHOT_ID.key(), String.valueOf(id)) + .build()); + } + case TIMESTAMP: { + long millis = parseTimestampMillis(session, spec); + OptionalLong id = catalogOps.snapshotIdAtOrBefore(table, millis); + if (!id.isPresent()) { + return Optional.empty(); + } + long snapshotId = id.getAsLong(); + long schemaId = catalogOps.snapshotSchemaId(table, snapshotId).orElse(-1L); + return Optional.of(ConnectorMvccSnapshot.builder() + .snapshotId(snapshotId) + .schemaId(schemaId) + .property(CoreOptions.SCAN_SNAPSHOT_ID.key(), String.valueOf(snapshotId)) + .build()); + } + // Non-numeric FOR VERSION AS OF resolves as a TAG in paimon (legacy parity: + // PaimonExternalTable.getPaimonSnapshotCacheValue treats a non-digital FOR VERSION AS OF + // value as a tag name). Empty fall-through to the @tag resolution — same behavior. + case VERSION_REF: + case TAG: { + String tagName = spec.getStringValue(); + Optional tag = + catalogOps.getSnapshotByTag(table, tagName); + if (!tag.isPresent()) { + return Optional.empty(); + } + // Legacy pins the tag NAME (scan.tag-name=value), NOT the snapshot id + // (PaimonExternalTable.java:137), so a later schema/data change to the tag is honored. + return Optional.of(ConnectorMvccSnapshot.builder() + .snapshotId(tag.get().snapshotId()) + .schemaId(tag.get().schemaId()) + .property(CoreOptions.SCAN_TAG_NAME.key(), tagName) + .build()); + } + case INCREMENTAL: { + // Validate the raw @incr window params and produce the paimon scan options. This is + // the ~180-line legacy validation, ported byte-faithfully into the connector + // (PaimonIncrementalScanParams). The produced opts hold incremental-between* keys ONLY + // — the snapshot/handle stay null-free (shared SPI contract). The legacy null-valued + // scan.snapshot-id/scan.mode resets are NOT carried here; they are reapplied at the + // Table.copy chokepoint via PaimonIncrementalScanParams.applyResetsIfIncremental + // (FIX-INCR-SCAN-RESET), so a base table that persists a stale scan.snapshot-id cannot + // hijack incremental-between. + Map opts = PaimonIncrementalScanParams.validate(spec.getIncrementalParams()); + // Legacy @incr reads at the LATEST snapshot and applies incremental-between at scan time: + // PaimonExternalTable.getPaimonSnapshotCacheValue falls through (neither tag/branch nor + // FOR VERSION/TIME AS OF) to getLatestSnapshotCacheValue (the LATEST partition view + LATEST + // schema), and PaimonScanNode.getProcessedTable copies the incremental options onto the base + // table. fe-core (PluginDrivenMvccExternalTable.loadSnapshot) mirrors this: the INCREMENTAL + // kind lists the LATEST partitions and uses the LATEST schema, carrying these incremental scan + // options on the pin. Pin latest; an empty table (no snapshot) falls back to -1. + long snapshotId = catalogOps.latestSnapshotId(table).orElse(-1L); + long schemaId = snapshotId < 0 + ? -1L + : catalogOps.snapshotSchemaId(table, snapshotId).orElse(-1L); + // opts is NON-EMPTY, so applySnapshot threads exactly these (incremental-between*) and + // does NOT inject scan.snapshot-id (which would conflict with incremental-between). + return Optional.of(ConnectorMvccSnapshot.builder() + .snapshotId(snapshotId) + .schemaId(schemaId) + .properties(opts) + .build()); + } + case BRANCH: { + String branchName = spec.getStringValue(); + // Validate on the BASE table (legacy resolvePaimonBranch validates the branch against + // the base table's branchManager). Graceful empty-if-absent (fe-core B5b-3 translates + // to the "can't find branch" UserException), consistent with snapshot/tag not-found. + if (!catalogOps.branchExists(table, branchName)) { + return Optional.empty(); + } + // Load the branch as its OWN table (independent schema/snapshots) and pin its LATEST + // snapshot — branches do not support in-branch time-travel (legacy reads + // latestSnapshot() only). + Table branchTable = resolveTable(paimonHandle.withBranch(branchName)); + long snapshotId = catalogOps.latestSnapshotId(branchTable).orElse(-1L); + long schemaId = snapshotId < 0 + ? -1L + : catalogOps.snapshotSchemaId(branchTable, snapshotId).orElse(-1L); + // Carry the branch identity to applySnapshot via an internal sentinel + // (CoreOptions.BRANCH key). Branch is a handle-IDENTITY change, not a scan-copy + // option: applySnapshot reads this sentinel and routes it to handle.withBranch (it is + // never threaded into Table.copy). No scan.snapshot-id is pinned (the branch table + // natively reads its own latest). + return Optional.of(ConnectorMvccSnapshot.builder() + .snapshotId(snapshotId) + .schemaId(schemaId) + .property(CoreOptions.BRANCH.key(), branchName) + .build()); + } + default: + throw new UnsupportedOperationException( + "unsupported time-travel kind: " + spec.getKind()); + } + } + + /** + * Doris session time-zone alias map, replicated from fe-core + * {@code TimeUtils.timeZoneAliasMap} (TimeUtils.java:106-117). The connector cannot import + * fe-core, so the map is rebuilt here byte-for-byte: {@link java.time.ZoneId#SHORT_IDS} (the + * JDK-provided short ids, which is where "PST"/"EST" resolve) overlaid with the four Doris + * overrides (CST/PRC -> Asia/Shanghai, UTC/GMT -> UTC). Case-insensitive, exactly like + * legacy, because {@code SET time_zone} stores the alias verbatim in any case. + * + *

    NOTE (FIX-TZ-ALIAS): the full {@code SHORT_IDS} map is required, NOT just the 4 explicit + * overrides — PST and EST resolve via {@code SHORT_IDS}, so a 4-entry-only map would still + * reject them (verified by JDK harness). + */ + private static final Map SESSION_TIME_ZONE_ALIASES; + + static { + Map m = new java.util.TreeMap<>(String.CASE_INSENSITIVE_ORDER); + m.putAll(java.time.ZoneId.SHORT_IDS); + m.put("CST", "Asia/Shanghai"); + m.put("PRC", "Asia/Shanghai"); + m.put("UTC", "UTC"); + m.put("GMT", "UTC"); + SESSION_TIME_ZONE_ALIASES = Collections.unmodifiableMap(m); + } + + /** + * Derives epoch-millis from a {@code TIMESTAMP} spec, byte-faithful to legacy + * {@code PaimonUtil.getPaimonSnapshotByTimestamp}: a digital value is {@code Long.parseLong}; + * a non-digital value is parsed by paimon {@code DateTimeUtils.parseTimestampData(value, 3, TZ)} + * where TZ is the SESSION time zone. + * + *

    BYTE-PARITY TZ DECISION: legacy passed {@code TimeUtils.getTimeZone()} = + * {@code TimeZone.getTimeZone(ZoneId.of(sessionTz, dorisAliasMap))}. The connector cannot import + * the fe-core Doris alias map, so it replicates it as {@link #SESSION_TIME_ZONE_ALIASES} and + * resolves the zone via {@code ZoneId.of(tz, SESSION_TIME_ZONE_ALIASES)} — byte-identical to + * legacy {@code TimeUtils.getTimeZone()} for every id legacy accepted (standard IANA ids, + * offsets, the {@code SHORT_IDS} aliases like "PST"/"EST", and the Doris overrides + * CST/PRC/UTC/GMT). + * + *

    FAIL-LOUD on genuinely-unknown id (NOT silent degrade): an id absent from BOTH + * {@code ZoneId.of}'s native set AND the alias map (e.g. "XYZ", "NOPE/ZZZ") is rejected with a + * clear, actionable {@link DorisConnectorException}, never silently degraded to a wrong zone (a + * wrong zone resolves the WRONG snapshot -> silently wrong rows). (This deliberately does NOT + * follow the MaxComputePredicateConverter pattern of degrading to NO_PREDICATE on a bad alias: + * that is safe only because BE re-applies the predicate, whereas a mis-resolved time-travel zone + * has no such safety net.) The legacy {@code millis < 0} guard is preserved. + */ + private long parseTimestampMillis(ConnectorSession session, ConnectorTimeTravelSpec spec) { + String value = spec.getStringValue(); + if (spec.isDigital()) { + return Long.parseLong(value); + } + // Resolve the session zone ONLY inside this catch so a legitimate + // DateTimeUtils.parseTimestampData("can't parse time") below is NOT swallowed: a genuinely + // unknown zone id (absent from ZoneId.of's native set AND the replicated alias map) must + // fail loud with actionable guidance, never silently degrade to a wrong zone (a wrong zone + // selects the WRONG snapshot -> silently wrong rows). The alias map resolves every id legacy + // accepted (CST/PST/EST/... via SHORT_IDS + the 4 Doris overrides). + java.time.ZoneId zoneId; + try { + zoneId = java.time.ZoneId.of(session.getTimeZone(), SESSION_TIME_ZONE_ALIASES); + } catch (java.time.DateTimeException e) { + throw new DorisConnectorException( + "session time zone '" + session.getTimeZone() + "' is not a standard zone id and " + + "cannot be used for FOR TIME AS OF with a datetime string; use a standard " + + "IANA zone id (e.g. 'Asia/Shanghai', 'UTC'), or specify epoch " + + "milliseconds, or use FOR VERSION AS OF .", e); + } + java.util.TimeZone tz = java.util.TimeZone.getTimeZone(zoneId); + long millis = DateTimeUtils.parseTimestampData(value, 3, tz).getMillisecond(); + if (millis < 0) { + throw new java.time.DateTimeException("can't parse time: " + value); + } + return millis; + } + + /** + * Threads a pinned MVCC / time-travel {@code snapshot} into the handle BEFORE planScan: returns + * a copy of {@code handle} carrying the connector's resolved scan options so the scan path reads + * at that snapshot/tag (the scan provider applies them via {@code Table.copy}). + * + *

    Threads the FULL {@code snapshot.getProperties()} map: this may be + * {@code scan.snapshot-id=} (snapshot-id / timestamp time-travel) OR + * {@code scan.tag-name=} (tag time-travel), whichever {@link #resolveTimeTravel} pinned. + * When {@code properties} is empty (the {@link #beginQuerySnapshot} latest-pin path, which + * carries no properties) it falls back to {@code scan.snapshot-id=} for B5a parity. + * + *

    BRANCH is special: when the snapshot carries the {@code CoreOptions.BRANCH} sentinel (set by + * {@link #resolveTimeTravel}'s BRANCH case), it is a handle-IDENTITY change, not a scan option — + * it is detected FIRST and routed to {@link PaimonTableHandle#withBranch} (which clears the + * transient base Table so the branch reloads), never threaded into {@code Table.copy}. + * + *

    System tables have no MVCC (they are synthetic metadata views — same guard as + * {@link #beginQuerySnapshot}), so a sys handle is returned unchanged. + */ + @Override + public ConnectorTableHandle applySnapshot(ConnectorSession session, + ConnectorTableHandle handle, ConnectorMvccSnapshot snapshot) { + PaimonTableHandle paimonHandle = (PaimonTableHandle) handle; + if (paimonHandle.isSystemTable()) { + return paimonHandle; + } + if (snapshot != null) { + String branch = snapshot.getProperties().get(CoreOptions.BRANCH.key()); + if (branch != null) { + // Branch time-travel is a handle-identity change (a different table load), not a scan + // option: route to withBranch (which clears the transient base Table so resolveTable + // reloads the branch). The branch reads its own latest, so no scan.snapshot-id is + // pinned. Detected BEFORE the generic properties path so the branch sentinel never + // becomes a scan-copy option. + return paimonHandle.withBranch(branch); + } + if (!snapshot.getProperties().isEmpty()) { + // Explicit time-travel: the connector already resolved the exact scan options + // (scan.snapshot-id OR scan.tag-name etc.) in resolveTimeTravel — thread them verbatim. + return paimonHandle.withScanOptions(snapshot.getProperties()); } } + // Empty-properties latest-pin (beginQuerySnapshot) path. Empty-table / query-begin parity: + // beginQuerySnapshot pins INVALID_SNAPSHOT_ID (-1) for an empty table rather than + // Optional.empty(). A -1 (or a null snapshot) must NOT become scan.snapshot-id=-1, because + // Table.copy(scan.snapshot-id=-1) resolves to a non-existent snapshot in the paimon SDK + // (confusing "snapshot/file not found"). Legacy never copied an invalid id: its empty / + // query-begin path reads latest WITHOUT a copy. So return the handle UNCHANGED (read latest). + if (snapshot == null || snapshot.getSnapshotId() < 0) { + return paimonHandle; + } + Map scanOptions = Collections.singletonMap( + CoreOptions.SCAN_SNAPSHOT_ID.key(), String.valueOf(snapshot.getSnapshotId())); + return paimonHandle.withScanOptions(scanOptions); + } + + /** + * Builds the read-path Thrift descriptor for a paimon plugin table as a {@code HIVE_TABLE} + * carrying a {@link THiveTable}, mirroring legacy paimon ({@code PaimonExternalTable.toThrift} + * and {@code PaimonSysExternalTable.toThrift}, both of which send {@code TTableType.HIVE_TABLE} + * with a {@code THiveTable}) and the MaxCompute pattern + * ({@code MaxComputeConnectorMetadata.buildTableDescriptor}). + * + *

    Without this override the SPI default returns {@code null}, so fe-core falls back to + * {@code TTableType.SCHEMA_TABLE}; BE's {@code DescriptorTbl::create} then builds a + * {@code SchemaTableDescriptor} instead of the {@code HiveTableDescriptor} it builds for + * {@code HIVE_TABLE}, a descriptor-parity bug. This fix covers BOTH normal paimon plugin tables + * (closing the latent B2 descriptor gap) AND system tables, which inherit it through + * {@code PluginDrivenExternalTable.toThrift}. + */ + @Override + public TTableDescriptor buildTableDescriptor( + ConnectorSession session, + long tableId, String tableName, String dbName, + String remoteName, int numCols, long catalogId) { + THiveTable tHiveTable = new THiveTable(dbName, tableName, new HashMap<>()); + TTableDescriptor desc = new TTableDescriptor( + tableId, TTableType.HIVE_TABLE, numCols, 0, tableName, dbName); + desc.setHiveTable(tHiveTable); + return desc; + } + + // ==================== DDL: Create/Drop Table ==================== + + /** + * Creates a Paimon table from the full {@link ConnectorCreateTableRequest}. + * + *

    fe-core already pre-probes existence (via {@code getTableHandle}) and short-circuits the + * {@code IF NOT EXISTS} case, so this body has no redundant existence check — it mirrors the + * legacy {@code PaimonMetadataOps.performCreateTable}, which simply delegated to + * {@code catalog.createTable(id, schema, ignoreIfExists)}. Passing + * {@link ConnectorCreateTableRequest#isIfNotExists()} as paimon's {@code ignoreIfExists} keeps + * it idempotent: paimon no-ops when {@code ifNotExists && exists}, and throws + * {@code TableAlreadyExistException} (wrapped here as {@link DorisConnectorException}) when + * {@code !ifNotExists && exists}. + * + *

    Per D7=B (legacy parity) the remote call is wrapped in + * {@link ConnectorContext#executeAuthenticated} so the FE-injected auth context (e.g. Kerberos + * UGI) applies, exactly as legacy {@code PaimonMetadataOps} wrapped every remote DDL call. + */ + @Override + public void createTable(ConnectorSession session, ConnectorCreateTableRequest request) { + Identifier id = Identifier.create(request.getDbName(), request.getTableName()); + Schema schema = PaimonSchemaBuilder.build(request); + try { + context.executeAuthenticated(() -> { + catalogOps.createTable(id, schema, request.isIfNotExists()); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to create Paimon table " + id + ": " + e.getMessage(), e); + } + LOG.info("created Paimon table {}", id); + } + + /** + * Drops the Paimon table behind {@code handle}. + * + *

    The SPI {@code dropTable} carries no {@code ifExists} flag and is handle-based: fe-core + * pre-resolves the handle (absent => this is never reached), so the remote drop is issued + * idempotently with {@code ignoreIfNotExists = true}, mirroring + * {@code MaxComputeConnectorMetadata.dropTable}. The remote call is wrapped in + * {@link ConnectorContext#executeAuthenticated} (D7=B legacy parity). + */ + @Override + public void dropTable(ConnectorSession session, ConnectorTableHandle handle) { + PaimonTableHandle h = (PaimonTableHandle) handle; + Identifier id = Identifier.create(h.getDatabaseName(), h.getTableName()); + try { + context.executeAuthenticated(() -> { + catalogOps.dropTable(id, true); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to drop Paimon table " + id + ": " + e.getMessage(), e); + } + LOG.info("dropped Paimon table {}", id); + } + + // ==================== 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} + * 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 + * {@code DatabaseAlreadyExistException}, wrapped here as {@link DorisConnectorException}. + * + *

    The HMS-only-props gate is a pure local arg check (no remote call), so it runs BEFORE the + * authenticator — mirroring legacy {@code PaimonMetadataOps.performCreateDb}, which rejected + * non-empty properties for every catalog type except HMS. The remote create then runs inside + * {@link ConnectorContext#executeAuthenticated} (D7=B legacy parity). + */ + @Override + public void createDatabase(ConnectorSession session, String dbName, + Map properties) { + String flavor = PaimonCatalogFactory.resolveFlavor(catalogProperties); + if (!properties.isEmpty() && !PaimonConnectorProperties.HMS.equals(flavor)) { + throw new DorisConnectorException( + "Not supported: create database with properties for paimon catalog type: " + flavor); + } + try { + context.executeAuthenticated(() -> { + catalogOps.createDatabase(dbName, /*ignoreIfExists*/ false, properties); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to create Paimon database " + dbName + ": " + e.getMessage(), e); + } + LOG.info("created Paimon database {}", dbName); + } + + /** + * Drops a Paimon database, cascading to its tables when {@code force} is true. + * + *

    Mirrors legacy {@code PaimonMetadataOps.performDropDb}: when {@code force}, it enumerates + * the db's tables and drops each (idempotently) BEFORE dropping the db, AND passes {@code force} + * as paimon's native cascade flag — belt-and-suspenders, exactly like legacy (NOT enumerate-only + * like MaxCompute, whose ODPS schema delete does not cascade). When {@code !force} and the db is + * non-empty, paimon's {@code dropDatabase(dbName, ifExists, cascade=false)} throws + * {@code DatabaseNotEmptyException}, wrapped here as {@link DorisConnectorException}. + * + *

    The whole op (enumerate + per-table drops + db drop) is a single logical DDL op, so it runs + * under ONE {@link ConnectorContext#executeAuthenticated} scope (D7=B legacy parity). fe-core + * already short-circuits the {@code IF EXISTS} no-op when the db is absent from its cache. + */ + @Override + public void dropDatabase(ConnectorSession session, String dbName, + boolean ifExists, boolean force) { + try { + context.executeAuthenticated(() -> { + if (force) { + for (String table : catalogOps.listTables(dbName)) { + catalogOps.dropTable(Identifier.create(dbName, table), /*ignoreIfNotExists*/ true); + } + } + catalogOps.dropDatabase(dbName, ifExists, /*cascade*/ force); + return null; + }); + } catch (Exception e) { + throw new DorisConnectorException( + "Failed to drop Paimon database " + dbName + ": " + e.getMessage(), e); + } + LOG.info("dropped Paimon database {} (force={})", dbName, force); + } + + /** + * Disables pushing predicates that contain implicit CAST expressions down to Paimon. + * + *

    The shared {@code ExprToConnectorExpressionConverter} unwraps CAST shells, so without this + * a predicate like {@code CAST(str_col AS INT) = 5} would be pushed to the Paimon read as the + * source-side filter {@code str_col = "5"}, which Paimon evaluates as exact equality and uses + * for file/partition pruning — dropping rows like {@code "05"}/{@code " 5"} at the source, + * which BE re-evaluation can never recover. Returning {@code false} makes + * {@code PluginDrivenScanNode.buildRemainingFilter} keep CAST-bearing conjuncts BE-only. + * Mirrors {@code MaxComputeConnectorMetadata} / {@code JdbcConnectorMetadata}. + */ + @Override + public boolean supportsCastPredicatePushdown(ConnectorSession session) { + return false; + } + + @Override + public Map getColumnHandles( + ConnectorSession session, ConnectorTableHandle handle) { + PaimonTableHandle paimonHandle = (PaimonTableHandle) handle; + Table table = resolveTable(paimonHandle); RowType rowType = table.rowType(); List fields = rowType.getFields(); Map handles = new LinkedHashMap<>(fields.size()); for (int i = 0; i < fields.size(); i++) { - String name = fields.get(i).name().toLowerCase(); + String name = fields.get(i).name(); handles.put(name, new PaimonColumnHandle(name, i)); } return handles; } - private List mapFields(RowType rowType, List primaryKeys) { - List fields = rowType.getFields(); + @Override + public List listPartitionNames(ConnectorSession session, ConnectorTableHandle handle) { + List partitions = collectPartitions((PaimonTableHandle) handle); + List names = new ArrayList<>(partitions.size()); + for (ConnectorPartitionInfo partition : partitions) { + names.add(partition.getPartitionName()); + } + return names; + } + + /** + * Lists all partitions with metadata. The {@code filter} is intentionally ignored: legacy + * {@code PaimonExternalCatalog.getPaimonPartitions} returns the full partition set without + * pushing predicates into the Paimon catalog, and this preserves that behavior (mirrors + * {@code MaxComputeConnectorMetadata}). + */ + @Override + public List listPartitions(ConnectorSession session, + ConnectorTableHandle handle, Optional filter) { + return collectPartitions((PaimonTableHandle) handle); + } + + @Override + public List> listPartitionValues(ConnectorSession session, + ConnectorTableHandle handle, List partitionColumns) { + List partitions = collectPartitions((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 partition collector backing {@link #listPartitionNames}, {@link #listPartitions} and + * {@link #listPartitionValues}. Replicates the legacy fe-core display-name logic + * ({@code PaimonUtil.generatePartitionInfo} + {@code isLegacyPartitionName}) so the rendered + * partition names stay byte-identical to the pre-migration behavior. + */ + private List collectPartitions(PaimonTableHandle paimonHandle) { + List partitionKeys = paimonHandle.getPartitionKeys(); + // Legacy never lists partitions for unpartitioned tables: PaimonPartitionInfoLoader.load + // returns EMPTY when partitionColumns is empty, so guard before touching the seam. + if (partitionKeys == null || partitionKeys.isEmpty()) { + return Collections.emptyList(); + } + + // Partition enumeration is intentionally BASE-only: branch / time-travel reads carry EMPTY + // partition info (legacy PaimonPartitionInfo.EMPTY) and never reach this path, so for the + // (non-branch) handles that do, resolveTable returns the base table and the base-Identifier + // listing below is consistent. (A branch handle would otherwise mix branch schema metadata + // here with the base partition list — but that combination does not occur by design.) + Table table = resolveTable(paimonHandle); + Identifier identifier = Identifier.create( + paimonHandle.getDatabaseName(), paimonHandle.getTableName()); + // M-11: wrap the remote listPartitions in executeAuthenticated (D-052), mirroring legacy + // PaimonExternalCatalog.getPaimonPartitions which ran it inside executionAuthenticator.execute + // and swallowed TableNotExistException INSIDE the wrap (Kerberos UGI.doAs would otherwise wrap + // the checked exception, so it must be caught inside). + List paimonPartitions; + try { + paimonPartitions = context.executeAuthenticated(() -> { + try { + return catalogOps.listPartitions(identifier); + } catch (Catalog.TableNotExistException e) { + LOG.warn("Paimon table not found while listing partitions: {}", identifier, e); + return Collections.emptyList(); + } + }); + } catch (Exception e) { + throw new RuntimeException("Failed to list Paimon partitions: " + identifier, e); + } + + boolean legacyName = Boolean.parseBoolean( + table.options().getOrDefault("partition.legacy-name", "true")); + + // Paimon renders a genuine NULL partition value as its partition.default-name sentinel + // (CoreOptions.PARTITION_DEFAULT_NAME, default "__DEFAULT_PARTITION__"). Read it the same way + // as partition.legacy-name above so a table that overrides it is still honored. + String defaultPartitionName = table.options() + .getOrDefault("partition.default-name", "__DEFAULT_PARTITION__"); + + // Connector cannot import Doris Type: detect DATE partition columns straight from the + // Paimon RowType (DataTypeRoot.DATE) instead of the legacy columnNameToType.isDateV2(). + Set partitionKeyNames = new HashSet<>(partitionKeys); + Set dateColumns = new HashSet<>(); + for (DataField field : table.rowType().getFields()) { + if (partitionKeyNames.contains(field.name()) + && field.type().getTypeRoot() == DataTypeRoot.DATE) { + dateColumns.add(field.name()); + } + } + + List result = new ArrayList<>(paimonPartitions.size()); + for (Partition partition : paimonPartitions) { + Map spec = partition.spec(); + StringBuilder sb = new StringBuilder(); + // Per-value SQL-NULL flags, built in this SAME loop so flag i aligns with name segment i (which is + // how fe-core re-parses the rendered name positionally at PluginDrivenMvccExternalTable). + List nullFlags = new ArrayList<>(spec.size()); + // Ordered rendered values, collected in this SAME loop so value i == name segment i (exactly what + // fe-core used to re-parse out of the rendered name); supplied so fe-core skips the parse. + List orderedValues = new ArrayList<>(spec.size()); + for (Map.Entry entry : spec.entrySet()) { + sb.append(entry.getKey()).append("="); + String value = entry.getValue(); + boolean isNull = defaultPartitionName.equals(value); + nullFlags.add(isNull); + if (isNull) { + // Genuine NULL partition value. Supply isNull=true so the FE bridge + // (PluginDrivenMvccExternalTable.toListPartitionItem) builds a typed NullLiteral and + // `col IS NULL` selects it (MTMV refresh materializes the null rows) — aligning prune with + // the native scan path, which already materializes it as SQL NULL from the typed Java-null. + // 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__"). + orderedValues.add(ConnectorPartitionValues.HIVE_DEFAULT_PARTITION); + sb.append(ConnectorPartitionValues.HIVE_DEFAULT_PARTITION).append("/"); + } else if (legacyName && dateColumns.contains(entry.getKey())) { + // 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 + // false the value is already a human-readable date string. + String rendered = DateTimeUtils.formatDate(Integer.parseInt(value)); + orderedValues.add(rendered); + sb.append(rendered).append("/"); + } else { + orderedValues.add(value); + sb.append(value).append("/"); + } + } + if (sb.length() > 0) { + sb.deleteCharAt(sb.length() - 1); + } + String partitionName = sb.toString(); + // partitionValues = RAW spec (un-rendered): downstream indexes by raw remote keys. + result.add(new ConnectorPartitionInfo( + partitionName, + spec, + Collections.emptyMap(), + partition.recordCount(), + partition.fileSizeInBytes(), + partition.lastFileCreationTime(), + partition.fileCount(), + orderedValues, + nullFlags)); + } + return result; + } + + /** + * Returns the base-table row count = sum of planned-split row counts (legacy + * {@code PaimonExternalTable.fetchRowCount}: {@code rowCount > 0 ? rowCount : UNKNOWN}). Shared + * by normal AND system paimon tables: fe-core {@code PluginDrivenSysExternalTable} inherits + * {@code PluginDrivenExternalTable.fetchRowCount}, and {@link #resolveTable} is sys-aware, so a + * sys handle plans its OWN synthetic table's splits (closes Finding 5.1 with one override). + * Returns {@code Optional.empty()} (→ fe-core -1 / UNKNOWN) when the count is 0 (legacy parity) + * or planning fails (best-effort, like the other connector read paths — stats run in background + * analysis / SHOW and must not surface a transient remote error as a query-killing exception). + * {@code dataSize} is left UNKNOWN (-1): legacy computed no base-table dataSize here. + */ + @Override + public Optional getTableStatistics( + ConnectorSession session, ConnectorTableHandle handle) { + PaimonTableHandle paimonHandle = (PaimonTableHandle) handle; + long rowCount; + try { + rowCount = catalogOps.rowCount(resolveTable(paimonHandle)); + } catch (Exception e) { + LOG.warn("Failed to compute Paimon row count for {}", paimonHandle, e); + return Optional.empty(); + } + if (rowCount > 0) { + return Optional.of(new ConnectorTableStatistics(rowCount, -1)); + } + return Optional.empty(); // 0 rows -> UNKNOWN, legacy parity + } + + /** + * Resolves the live {@link Table} for a handle: prefer the transient reference, else re-load + * from the catalog seam. Delegates to the single sys-aware {@link PaimonTableResolver} shared + * with the scan path so there is exactly ONE reload rule (a sys handle reloads via the 4-arg + * sys {@link Identifier}; see {@link PaimonTableResolver#resolve}). This keeps every metadata + * read path ({@link #getTableSchema}, {@link #getColumnHandles}, {@link #collectPartitions}) + * sys-aware. + * + *

    Preserves this site's original wrapping of a reload failure as a {@link RuntimeException}. + */ + private Table resolveTable(PaimonTableHandle paimonHandle) { + // M-11: wrap the (possibly remote) reload in executeAuthenticated (D-052) so every metadata + // read path that resolves a table runs under the FE-injected Kerberos UGI. The transient-table + // fast path inside resolve issues no RPC, so the wrap is a no-op there. The existing catch-all + // absorbs the (under Kerberos, UGI.doAs-wrapped) reload failure exactly as before. + try { + return context.executeAuthenticated(() -> PaimonTableResolver.resolve(catalogOps, paimonHandle)); + } catch (Exception e) { + throw new RuntimeException("Failed to load Paimon table: " + paimonHandle, e); + } + } + + private List mapFields(List fields, List primaryKeys) { List columns = new ArrayList<>(fields.size()); for (DataField field : fields) { ConnectorType connectorType = PaimonTypeMapping.toConnectorType( field.type(), typeMappingOptions); String comment = field.description(); - boolean nullable = field.type().isNullable(); - columns.add(new ConnectorColumn( - field.name().toLowerCase(), + // Legacy parity (FIX-READ-NOTNULL): PaimonExternalTable / PaimonSysExternalTable always + // built each Doris column with isAllowNull=true regardless of the paimon field's NOT NULL + // flag. Paimon PK columns are always NOT NULL, so propagating that would flip nullability + // metadata for almost every PK table and let nereids fold null-rejecting predicates the + // legacy path never permitted (rows can still read as NULL under schema-evolution + // default-fill). Keep columns nullable; do not propagate the paimon NOT NULL constraint + // on the read path. + boolean nullable = true; + // Legacy DESC parity: PaimonExternalTable/PaimonSysExternalTable built every column (base AND + // system table) with isKey=true (3rd positional Column arg), so DESC shows Key=true for all + // paimon columns. The 5-arg ConnectorColumn ctor defaults isKey=false; pass true explicitly. + ConnectorColumn column = new ConnectorColumn( + field.name(), connectorType, comment, nullable, - null)); + null, + true); + // Legacy DESC parity (PaimonExternalTable.initSchema:356 / PaimonSysExternalTable:270): a + // TIMESTAMP_WITH_LOCAL_TIME_ZONE column carries the WITH_TIMEZONE "Extra" marker via + // Column.setWithTZExtraInfo(). Mark it here so fe-core's ConnectorColumnConverter re-applies it. + // The mark is driven by the SOURCE paimon type root, not the mapped Doris type, so it survives + // whether enable.mapping.timestamp_tz maps the column to TIMESTAMPTZ (on) or DATETIMEV2 (off). + if (field.type().getTypeRoot() == DataTypeRoot.TIMESTAMP_WITH_LOCAL_TIME_ZONE) { + column = column.withTimeZone(); + } + columns.add(column); } return columns; } @@ -200,7 +1186,7 @@ private List mapFields(RowType rowType, List primaryKey private static PaimonTypeMapping.Options buildTypeMappingOptions(Map props) { boolean binaryAsVarbinary = Boolean.parseBoolean( props.getOrDefault( - PaimonConnectorProperties.ENABLE_MAPPING_BINARY_AS_VARBINARY, + PaimonConnectorProperties.ENABLE_MAPPING_VARBINARY, "false")); boolean timestampTz = Boolean.parseBoolean( props.getOrDefault( diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorProperties.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorProperties.java index 8457f5200e06df..ae3fe94a25c77d 100644 --- a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorProperties.java +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorProperties.java @@ -19,24 +19,73 @@ /** * Property key constants for Paimon connector configuration. + * + *

    Pure static-constant holder (no logic), mirroring the role of + * {@code MCConnectorProperties}. Where a Doris-facing property accepts multiple + * aliases (matching the legacy fe-core {@code @ConnectorProperty(names = {...})} + * declarations), the aliases are exposed as a {@code String[]} in alias-priority + * order so {@link PaimonCatalogFactory} can resolve them with + * {@code firstNonBlank}. */ public final class PaimonConnectorProperties { - /** Paimon catalog backend type: filesystem, hms, dlf, rest, jdbc. */ + /** Paimon catalog backend type: filesystem, hms, rest, jdbc. */ public static final String PAIMON_CATALOG_TYPE = "paimon.catalog.type"; /** Warehouse location for the Paimon catalog. */ public static final String WAREHOUSE = "warehouse"; - /** Whether to map Paimon BINARY/VARBINARY to Doris VARBINARY instead of STRING. */ - public static final String ENABLE_MAPPING_BINARY_AS_VARBINARY = "enable_mapping_binary_as_varbinary"; + /** + * Whether to map Paimon BINARY/VARBINARY to Doris VARBINARY instead of STRING. + * + *

    Canonical (dotted) CREATE-CATALOG key, mirroring fe-core + * {@code CatalogProperty.ENABLE_MAPPING_VARBINARY} and the legacy paimon path. The connector + * receives the raw catalog property map ({@code catalogProperty.getProperties()}), which only + * ever carries this dotted key (fe-core {@code setDefaultPropsIfMissing} writes only it), so the + * read MUST use the dotted spelling — an underscore variant is never present and would read false. + */ + public static final String ENABLE_MAPPING_VARBINARY = "enable.mapping.varbinary"; - /** Whether to map Paimon TIMESTAMP_WITH_LOCAL_TIME_ZONE to TIMESTAMPTZ. */ - public static final String ENABLE_MAPPING_TIMESTAMP_TZ = "enable_mapping_timestamp_tz"; + /** + * Whether to map Paimon TIMESTAMP_WITH_LOCAL_TIME_ZONE to TIMESTAMPTZ. + * + *

    Canonical (dotted) CREATE-CATALOG key, mirroring fe-core + * {@code CatalogProperty.ENABLE_MAPPING_TIMESTAMP_TZ} and the legacy paimon path. + */ + public static final String ENABLE_MAPPING_TIMESTAMP_TZ = "enable.mapping.timestamp_tz"; /** Default catalog type when not specified. */ public static final String DEFAULT_CATALOG_TYPE = "filesystem"; + // ---- Flavor literals (the accepted paimon.catalog.type values) ---- + public static final String FILESYSTEM = "filesystem"; + public static final String HMS = "hms"; + public static final String REST = "rest"; + public static final String JDBC = "jdbc"; + + // ---- HMS flavor keys ---- + /** Hive metastore uri; primary key + the {@code "uri"} alias (legacy HMSBaseProperties). */ + public static final String[] HMS_URI = {"hive.metastore.uris", "uri"}; + public static final String CLIENT_POOL_CACHE_EVICTION_INTERVAL_MS = "client-pool-cache.eviction-interval-ms"; + /** Default client-pool-cache eviction interval (ms) = 5 minutes (legacy default). */ + public static final String CLIENT_POOL_CACHE_EVICTION_INTERVAL_MS_DEFAULT = "300000"; + public static final String LOCATION_IN_PROPERTIES = "location-in-properties"; + public static final String LOCATION_IN_PROPERTIES_DEFAULT = "false"; + + // ---- REST flavor keys ---- + public static final String[] REST_URI = {"paimon.rest.uri", "uri"}; + // REST_TOKEN_PROVIDER / REST_DLF_ACCESS_KEY_ID / REST_DLF_ACCESS_KEY_SECRET removed (P2-T03): the + // REST dlf-token requireIf is now owned by RestMetaStoreProperties (the @ConnectorProperty aliases + // live in fe-connector-metastore-spi); the connector no longer hand-checks them. + + // ---- JDBC flavor keys ---- + public static final String[] JDBC_URI = {"uri", "paimon.jdbc.uri"}; + public static final String[] JDBC_USER = {"paimon.jdbc.user", "jdbc.user"}; + public static final String[] JDBC_PASSWORD = {"paimon.jdbc.password", "jdbc.password"}; + public static final String[] JDBC_DRIVER_URL = {"paimon.jdbc.driver_url", "jdbc.driver_url"}; + public static final String[] JDBC_DRIVER_CLASS = {"paimon.jdbc.driver_class", "jdbc.driver_class"}; + + private PaimonConnectorProperties() { } } 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 10a96da38d5434..904f35ca3eb65b 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 @@ -18,10 +18,18 @@ package org.apache.doris.connector.paimon; import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.cache.CacheSpec; +import org.apache.doris.connector.metastore.spi.MetaStoreProviders; 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.Collections; +import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * SPI entry point for the Paimon connector. @@ -31,6 +39,15 @@ */ public class PaimonConnectorProvider implements ConnectorProvider { + private static final Logger LOG = LogManager.getLogger(PaimonConnectorProvider.class); + + // Legacy PaimonExternalCatalog.checkProperties validated the table-handle cache knobs + // (meta.cache.paimon.table.{enable,ttl-second,capacity}) via CacheSpec. FIX-4 restores ttl-second: it now + // sizes the connector latest-snapshot cache (data) AND the generic schema cache (via + // schemaCacheTtlSecondOverride). enable/capacity remain not-wired on the plugin path, so they are still + // reported as ignored (R2) — ttl-second is intentionally excluded from this set since it again takes effect. + private static final String DEAD_TABLE_CACHE_PREFIX = "meta.cache.paimon.table."; + @Override public String getType() { return "paimon"; @@ -38,6 +55,59 @@ public String getType() { @Override public Connector create(Map properties, ConnectorContext context) { - return new PaimonConnector(properties); + return new PaimonConnector(properties, context); + } + + /** + * 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 + * {@code MetaStoreProperties.validate()} enforces the per-flavor fail-fast rules (warehouse, uri, + * HMS kerberos forbidIf/requireIf, DLF AK/SK + endpoint-or-region + OSS storage, JDBC + * driver_class-when-driver_url, REST dlf-token AK/SK). These restore the true-legacy + * {@code HMSBaseProperties}/{@code AliyunDLFBaseProperties}/{@code ParamRules} rules. Storage is not + * needed for validation, so an empty storage map is passed; an unknown {@code paimon.catalog.type} + * makes {@code bind} throw (no provider supports it). Throws {@link IllegalArgumentException}, which + * the caller ({@code PluginDrivenExternalCatalog.checkProperties}) wraps into a DdlException. + * + *

    The meta-cache knobs are validated first (restoring the legacy + * {@code PaimonExternalCatalog.checkProperties} fail-fast dropped at the SPI cutover), so a bad + * {@code meta.cache.paimon.table.*} value is rejected at CREATE/ALTER. This runs before the + * dead-knob warning: an invalid value is rejected outright, while a valid-but-unwired enable/capacity + * is still reported as ignored. + */ + @Override + public void validateProperties(Map properties) { + checkMetaCacheProperties(properties); + warnIgnoredDeadTableCacheKeys(properties); + MetaStoreProviders.bind(properties, Collections.emptyMap()).validate(); + } + + /** + * Byte-for-byte parity with the (deleted) legacy {@code PaimonExternalCatalog.checkProperties}: + * {@code table.enable} must be boolean, {@code table.ttl-second} must be a long ≥ -1, {@code + * table.capacity} must be a long ≥ 0. Absent keys are skipped. + */ + private static void checkMetaCacheProperties(Map properties) { + CacheSpec.checkBooleanProperty(properties.get(PaimonConnector.TABLE_CACHE_ENABLE), + PaimonConnector.TABLE_CACHE_ENABLE); + CacheSpec.checkLongProperty(properties.get(PaimonConnector.TABLE_CACHE_TTL_SECOND), + -1L, PaimonConnector.TABLE_CACHE_TTL_SECOND); + CacheSpec.checkLongProperty(properties.get(PaimonConnector.TABLE_CACHE_CAPACITY), + 0L, PaimonConnector.TABLE_CACHE_CAPACITY); + } + + // R2: warn (do not reject, do not strip) when a CREATE/ALTER CATALOG carries the now-dead paimon + // table-cache knobs, so the operator learns their cache tuning no longer takes effect on the plugin path. + private static void warnIgnoredDeadTableCacheKeys(Map properties) { + List dead = properties.keySet().stream() + .filter(k -> k.startsWith(DEAD_TABLE_CACHE_PREFIX)) + // ttl-second is restored (FIX-4): it sizes the snapshot cache + schema cache TTL, so it is NOT dead. + .filter(k -> !k.equals(PaimonConnector.TABLE_CACHE_TTL_SECOND)) + .sorted() + .collect(Collectors.toList()); + if (!dead.isEmpty()) { + LOG.warn("Paimon catalog cache property/properties {} no longer take effect on the plugin path " + + "(the table metadata cache configuration is obsolete) and are ignored.", dead); + } } } diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonIncrementalScanParams.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonIncrementalScanParams.java new file mode 100644 index 00000000000000..9c00327d8114a1 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonIncrementalScanParams.java @@ -0,0 +1,320 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.DorisConnectorException; + +import java.util.HashMap; +import java.util.Map; + +/** + * Validates the raw Doris {@code @incr(...)} window parameters and produces the paimon SDK scan + * option map that {@code Table.copy(...)} applies for an incremental read. + * + *

    This is a BYTE-FAITHFUL port of legacy + * {@code org.apache.doris.datasource.paimon.source.PaimonScanNode#validateIncrementalReadParams} + * (lines 701-878): per design D-043/D-044 (B5b), the ~180-line validation + paimon option-key + * production MOVES INTO the connector; fe-core (B5b-3) passes only the RAW Doris param map. The two + * parameter groups — snapshot-based ({@code startSnapshotId}/{@code endSnapshotId}/ + * {@code incrementalBetweenScanMode}) and timestamp-based ({@code startTimestamp}/ + * {@code endTimestamp}) — are MUTUALLY EXCLUSIVE. Every validation rule and every error + * message string is reproduced for parity, EXCEPT the legacy {@code UserException} (fe-core type) + * is replaced by {@link DorisConnectorException} (the connector cannot import fe-core). + * + *

    NULL RESETS — WHERE THEY LIVE (FIX-INCR-SCAN-RESET): legacy seeds the result map with + * {@code put(PAIMON_SCAN_SNAPSHOT_ID, null)} and {@code put(PAIMON_SCAN_MODE, null)} (lines 842-843, + * re-asserted 846) as defensive RESETS, then applies them via {@code baseTable.copy(...)}. Those + * resets ARE required: a freshly-loaded base table's {@code tableSchema.options()} can PERSIST a + * stale {@code scan.snapshot-id}/{@code scan.mode} (legal & mutable via {@code ALTER TABLE SET}, + * {@code TBLPROPERTIES}, {@code table-default.*}); without the reset, {@code Table.copy} merges the + * stale {@code scan.snapshot-id} with {@code incremental-between} and paimon 1.3.1 either THROWS + * ({@code "[incremental-between] must be null when you set [scan.snapshot-id,scan.tag-name]"}) or + * silently resolves to {@code FROM_SNAPSHOT} at the stale id (wrong @incr rows). However, the null + * values must NOT enter the SHARED, source-agnostic {@link + * org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot} SPI type (its {@code Builder.property} + * rejects null and its {@code getProperties()} is documented "never null"). So {@link #validate} + * emits ONLY the non-null {@code incremental-between*} keys (snapshot/handle stay null-free), and the + * two legacy null resets are reintroduced LOCALLY at the {@code Table.copy} chokepoint via {@link + * #applyResetsIfIncremental}, where paimon's {@code copyInternal} (1.3.1: {@code v == null ? + * options.remove(k) : options.put(k, v)}) consumes them to clear the stale pin — the nulls are + * created and discarded at copy time, never stored, serialized, or placed in the SPI. + */ +public final class PaimonIncrementalScanParams { + + // The keys of incremental read params for the Paimon SDK (legacy PaimonScanNode lines 83-87). + private static final String PAIMON_SCAN_SNAPSHOT_ID = "scan.snapshot-id"; + private static final String PAIMON_SCAN_MODE = "scan.mode"; + private static final String PAIMON_INCREMENTAL_BETWEEN = "incremental-between"; + private static final String PAIMON_INCREMENTAL_BETWEEN_SCAN_MODE = "incremental-between-scan-mode"; + private static final String PAIMON_INCREMENTAL_BETWEEN_TIMESTAMP = "incremental-between-timestamp"; + + // The keys of incremental read params for the Doris statement (legacy PaimonScanNode lines 89-93). + private static final String DORIS_START_SNAPSHOT_ID = "startSnapshotId"; + private static final String DORIS_END_SNAPSHOT_ID = "endSnapshotId"; + private static final String DORIS_START_TIMESTAMP = "startTimestamp"; + private static final String DORIS_END_TIMESTAMP = "endTimestamp"; + private static final String DORIS_INCREMENTAL_BETWEEN_SCAN_MODE = "incrementalBetweenScanMode"; + + private PaimonIncrementalScanParams() { + } + + /** + * Validates the raw Doris {@code @incr} window {@code params} and returns the paimon SDK option + * map (the non-null {@code incremental-between*} keys). Byte-faithful to legacy + * {@code PaimonScanNode.validateIncrementalReadParams}; throws {@link DorisConnectorException} + * (in place of the legacy {@code UserException}) with the SAME message strings. + * + * @param params the raw Doris incremental-read window arguments + * @return the paimon scan option map (non-null {@code incremental-between*} keys only; the legacy + * null {@code scan.snapshot-id}/{@code scan.mode} resets are reapplied at copy time by + * {@link #applyResetsIfIncremental} — see class doc) + */ + public static Map validate(Map params) { + // Check if snapshot-based parameters exist + boolean hasStartSnapshotId = params.containsKey(DORIS_START_SNAPSHOT_ID) + && params.get(DORIS_START_SNAPSHOT_ID) != null; + boolean hasEndSnapshotId = params.containsKey(DORIS_END_SNAPSHOT_ID) + && params.get(DORIS_END_SNAPSHOT_ID) != null; + boolean hasIncrementalBetweenScanMode = params.containsKey(DORIS_INCREMENTAL_BETWEEN_SCAN_MODE) + && params.get(DORIS_INCREMENTAL_BETWEEN_SCAN_MODE) != null; + + // Check if timestamp-based parameters exist + boolean hasStartTimestamp = params.containsKey(DORIS_START_TIMESTAMP) + && params.get(DORIS_START_TIMESTAMP) != null; + boolean hasEndTimestamp = params.containsKey(DORIS_END_TIMESTAMP) && params.get(DORIS_END_TIMESTAMP) != null; + + // Check if any snapshot-based parameters are present + boolean hasSnapshotParams = hasStartSnapshotId || hasEndSnapshotId || hasIncrementalBetweenScanMode; + + // Check if any timestamp-based parameters are present + boolean hasTimestampParams = hasStartTimestamp || hasEndTimestamp; + + // Rule 2: The two groups are mutually exclusive + if (hasSnapshotParams && hasTimestampParams) { + throw new DorisConnectorException( + "Cannot specify both snapshot-based parameters" + + "(startSnapshotId, endSnapshotId, incrementalBetweenScanMode) " + + "and timestamp-based parameters (startTimestamp, endTimestamp) at the same time"); + } + + // Validate snapshot-based parameters group + if (hasSnapshotParams) { + // Rule 3.1 & 3.2: DORIS_START_SNAPSHOT_ID is required + if (!hasStartSnapshotId) { + throw new DorisConnectorException( + "startSnapshotId is required when using snapshot-based incremental read"); + } + + // Rule 3.3: DORIS_INCREMENTAL_BETWEEN_SCAN_MODE can only appear + // when both start and end snapshot IDs are specified + if (hasIncrementalBetweenScanMode && (!hasStartSnapshotId || !hasEndSnapshotId)) { + throw new DorisConnectorException( + "incrementalBetweenScanMode can only be specified when" + + " both startSnapshotId and endSnapshotId are provided"); + } + + // Validate snapshot ID values + if (hasStartSnapshotId) { + try { + long startSId = Long.parseLong(params.get(DORIS_START_SNAPSHOT_ID)); + if (startSId < 0) { + throw new DorisConnectorException("startSnapshotId must be greater than or equal to 0"); + } + } catch (NumberFormatException e) { + throw new DorisConnectorException("Invalid startSnapshotId format: " + e.getMessage()); + } + } + + if (hasEndSnapshotId) { + try { + long endSId = Long.parseLong(params.get(DORIS_END_SNAPSHOT_ID)); + if (endSId < 0) { + throw new DorisConnectorException("endSnapshotId must be greater than or equal to 0"); + } + } catch (NumberFormatException e) { + throw new DorisConnectorException("Invalid endSnapshotId format: " + e.getMessage()); + } + } + + // Check if both snapshot IDs are present and validate their relationship + if (hasStartSnapshotId && hasEndSnapshotId) { + try { + long startSId = Long.parseLong(params.get(DORIS_START_SNAPSHOT_ID)); + long endSId = Long.parseLong(params.get(DORIS_END_SNAPSHOT_ID)); + if (startSId > endSId) { + throw new DorisConnectorException( + "startSnapshotId must be less than or equal to endSnapshotId"); + } + } catch (NumberFormatException e) { + throw new DorisConnectorException("Invalid snapshot ID format: " + e.getMessage()); + } + } + + // Validate DORIS_INCREMENTAL_BETWEEN_SCAN_MODE + if (hasIncrementalBetweenScanMode) { + String scanMode = params.get(DORIS_INCREMENTAL_BETWEEN_SCAN_MODE).toLowerCase(); + if (!scanMode.equals("auto") && !scanMode.equals("diff") + && !scanMode.equals("delta") && !scanMode.equals("changelog")) { + throw new DorisConnectorException( + "incrementalBetweenScanMode must be one of: auto, diff, delta, changelog"); + } + } + } + + // Validate timestamp-based parameters group + if (hasTimestampParams) { + // Rule 4.1 & 4.2: DORIS_START_TIMESTAMP is required + if (!hasStartTimestamp) { + throw new DorisConnectorException( + "startTimestamp is required when using timestamp-based incremental read"); + } + + // Validate timestamp values + if (hasStartTimestamp) { + try { + long startTS = Long.parseLong(params.get(DORIS_START_TIMESTAMP)); + if (startTS < 0) { + throw new DorisConnectorException("startTimestamp must be greater than or equal to 0"); + } + } catch (NumberFormatException e) { + throw new DorisConnectorException("Invalid startTimestamp format: " + e.getMessage()); + } + } + + if (hasEndTimestamp) { + try { + long endTS = Long.parseLong(params.get(DORIS_END_TIMESTAMP)); + if (endTS <= 0) { + throw new DorisConnectorException("endTimestamp must be greater than 0"); + } + } catch (NumberFormatException e) { + throw new DorisConnectorException("Invalid endTimestamp format: " + e.getMessage()); + } + } + + // Check if both timestamps are present and validate their relationship + if (hasStartTimestamp && hasEndTimestamp) { + try { + long startTS = Long.parseLong(params.get(DORIS_START_TIMESTAMP)); + long endTS = Long.parseLong(params.get(DORIS_END_TIMESTAMP)); + if (startTS >= endTS) { + throw new DorisConnectorException("startTimestamp must be less than endTimestamp"); + } + } catch (NumberFormatException e) { + throw new DorisConnectorException("Invalid timestamp format: " + e.getMessage()); + } + } + } + + // If no incremental parameters are provided at all, that's also invalid in this context + if (!hasSnapshotParams && !hasTimestampParams) { + throw new DorisConnectorException( + "Invalid paimon incremental read params: at least one valid parameter group must be specified"); + } + + // Fill the result map based on parameter combinations. + // NULL RESETS (see class doc + FIX-INCR-SCAN-RESET): legacy seeds PAIMON_SCAN_SNAPSHOT_ID=null + // and PAIMON_SCAN_MODE=null here (lines 842-843) as defensive RESETS against a base Table that + // PERSISTS a stale scan.snapshot-id/scan.mode. Those resets ARE required, but the null values + // must NOT enter the shared ConnectorMvccSnapshot SPI (null-free by contract). So we emit ONLY + // the non-null incremental-between* keys here; the two null resets are reapplied at the + // Table.copy chokepoint via applyResetsIfIncremental(...). + Map paimonScanParams = new HashMap<>(); + + if (hasSnapshotParams) { + // Legacy re-seeds PAIMON_SCAN_MODE=null here (line 846); reapplied at copy time (see above). + if (hasStartSnapshotId && !hasEndSnapshotId) { + // Only startSnapshotId is specified + throw new DorisConnectorException( + "endSnapshotId is required when using snapshot-based incremental read"); + } else if (hasStartSnapshotId && hasEndSnapshotId) { + // Both start and end snapshot IDs are specified + String startSId = params.get(DORIS_START_SNAPSHOT_ID); + String endSId = params.get(DORIS_END_SNAPSHOT_ID); + paimonScanParams.put(PAIMON_INCREMENTAL_BETWEEN, startSId + "," + endSId); + } + + // Add incremental between scan mode if present. + // GOTCHA (parity): the value is validated lowercase above, but the ORIGINAL-CASE value is + // emitted (legacy line 859-860 puts params.get(...) verbatim, not the lowercased copy). + if (hasIncrementalBetweenScanMode) { + paimonScanParams.put(PAIMON_INCREMENTAL_BETWEEN_SCAN_MODE, + params.get(DORIS_INCREMENTAL_BETWEEN_SCAN_MODE)); + } + } + + if (hasTimestampParams) { + String startTS = params.get(DORIS_START_TIMESTAMP); + String endTS = params.get(DORIS_END_TIMESTAMP); + + if (hasStartTimestamp && !hasEndTimestamp) { + // Only startTimestamp is specified + paimonScanParams.put(PAIMON_INCREMENTAL_BETWEEN_TIMESTAMP, startTS + "," + Long.MAX_VALUE); + } else if (hasStartTimestamp && hasEndTimestamp) { + // Both start and end timestamps are specified + paimonScanParams.put(PAIMON_INCREMENTAL_BETWEEN_TIMESTAMP, startTS + "," + endTS); + } + } + + return paimonScanParams; + } + + /** + * Reapplies legacy's defensive null resets of {@code scan.snapshot-id}/{@code scan.mode} at the + * {@code Table.copy} chokepoint (FIX-INCR-SCAN-RESET). Legacy + * {@code PaimonScanNode.validateIncrementalReadParams:842-843,846} seeds both keys to {@code null} + * and applies them via {@code baseTable.copy(...)}; a base table that PERSISTS a stale + * {@code scan.snapshot-id}/{@code scan.mode} (via {@code ALTER TABLE SET} / {@code TBLPROPERTIES} / + * {@code table-default.*}) would otherwise collide with {@code incremental-between} — paimon + * 1.3.1 {@code Table.copy} then THROWS ({@code "[incremental-between] must be null when you set + * [scan.snapshot-id,scan.tag-name]"}) or silently downgrades the read to {@code FROM_SNAPSHOT} at + * the stale id (wrong @incr rows). + * + *

    The reset is applied here, not in {@link #validate}, so the shared {@link + * org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot} SPI type and {@code + * PaimonTableHandle.scanOptions} stay null-free; the null-valued map is created locally and handed + * straight to paimon {@code copyInternal} ({@code v == null ? options.remove(k) : options.put(k, + * v)}), which consumes the nulls to remove the stale options. + * + *

    Gated on the presence of an incremental key ({@code incremental-between} OR + * {@code incremental-between-timestamp}) — every successful {@link #validate} output carries + * exactly one, and no non-incremental scan-option producer emits either (snapshot/timestamp pins + * emit {@code scan.snapshot-id}, tag pins emit {@code scan.tag-name}). So a non-incremental pin is + * returned UNCHANGED and its legitimate {@code scan.snapshot-id} is never clobbered. Scope is + * strict legacy parity: {@code scan.snapshot-id} + {@code scan.mode} only. + * + * @param scanOptions the handle's scan options about to be passed to {@code Table.copy} + * @return for an incremental scan, a NEW map seeded with the two null resets then the original + * options; otherwise {@code scanOptions} unchanged (same reference) + */ + public static Map applyResetsIfIncremental(Map scanOptions) { + if (scanOptions == null || scanOptions.isEmpty()) { + return scanOptions; + } + if (!scanOptions.containsKey(PAIMON_INCREMENTAL_BETWEEN) + && !scanOptions.containsKey(PAIMON_INCREMENTAL_BETWEEN_TIMESTAMP)) { + return scanOptions; + } + // HashMap (not Map.of / immutable) — it must hold null VALUES (the reset markers). + Map withResets = new HashMap<>(); + withResets.put(PAIMON_SCAN_SNAPSHOT_ID, null); + withResets.put(PAIMON_SCAN_MODE, null); + withResets.putAll(scanOptions); + return withResets; + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonLatestSnapshotCache.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonLatestSnapshotCache.java new file mode 100644 index 00000000000000..33b62686d07386 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonLatestSnapshotCache.java @@ -0,0 +1,106 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.cache.CacheSpec; +import org.apache.doris.connector.cache.MetaCacheEntry; + +import org.apache.paimon.catalog.Identifier; + +import java.util.concurrent.ForkJoinPool; +import java.util.function.LongSupplier; + +/** + * Per-catalog cache of a paimon table's LATEST snapshot id, keyed by {@link Identifier} (db.table). + * + *

    Restores the legacy {@code PaimonExternalMetaCache} table-cache semantics that the SPI cutover dropped + * (test_paimon_table_meta_cache): within the TTL a paimon catalog serves a STABLE (possibly stale) + * latest-snapshot id across queries, so a query-begin pin + * ({@link PaimonConnectorMetadata#beginQuerySnapshot}) reads the SAME snapshot until the entry expires or is + * invalidated by {@code REFRESH TABLE}/{@code REFRESH CATALOG}. The id flows through + * {@code applySnapshot} -> {@code scan.snapshot-id} -> {@code Table.copy}, so an external write made + * after the pin is not visible until refresh. + * + *

    Backed by the shared {@link MetaCacheEntry} framework (independent-copy meta-cache migration): a + * contextual, access-TTL entry whose per-query loader is supplied at {@link #getOrLoad}. TTL is + * {@code meta.cache.paimon.table.ttl-second}: {@code <= 0} disables caching (every read goes live, matching + * the legacy "no-cache" catalog); a positive value is Caffeine {@code expireAfterAccess} with a + * {@code maxSize} capacity (real LRU eviction, replacing the former clear-on-overflow). Manual miss-load is + * on so the loader runs OUTSIDE Caffeine's compute lock (single-flight per key). Lives on the long-lived + * per-catalog {@link PaimonConnector}, mirroring {@link PaimonSchemaAtMemo}; a REFRESH CATALOG rebuilds the + * connector and thus the cache. + */ +final class PaimonLatestSnapshotCache { + + private final MetaCacheEntry entry; + + PaimonLatestSnapshotCache(long ttlSeconds, int maxSize) { + // ttl-second <= 0 disables caching (always read live); a positive ttl is access-based expiry with the + // given capacity. CacheSpec treats ttl == -1 as "no expiration (enabled)" and ttl == 0 as "disabled", + // so translate the connector's "<= 0 disables" contract to ttl == 0 rather than passing a negative + // value straight through (which would otherwise flip -1 into a never-expiring cache). + CacheSpec spec = ttlSeconds > 0 + ? CacheSpec.of(true, ttlSeconds, maxSize) + : CacheSpec.of(true, CacheSpec.CACHE_TTL_DISABLE_CACHE, maxSize); + this.entry = new MetaCacheEntry<>("paimon-latest-snapshot", null, spec, + ForkJoinPool.commonPool(), false, true, 0L, true); + } + + /** Caching is on only when the TTL is positive; ttl-second <= 0 means "always read live". */ + boolean isEnabled() { + return entry.stats().isEffectiveEnabled(); + } + + /** + * Returns the cached latest-snapshot id for {@code identifier} if present and unexpired, else runs + * {@code loader} (the live {@code latestSnapshotId} read), caches the result and returns it. When caching + * is disabled ({@link #isEnabled()} is false) {@code loader} runs every call and nothing is cached. A hit + * refreshes the entry's expiry (access-based). The loader runs OUTSIDE Caffeine's compute lock + * (single-flight per key); a disabled entry bypasses the cache entirely and always loads. + */ + long getOrLoad(Identifier identifier, LongSupplier loader) { + return entry.get(identifier, ignored -> loader.getAsLong()); + } + + /** Drops the cached entry for one table so the next read goes live (REFRESH TABLE). */ + void invalidate(Identifier identifier) { + entry.invalidateKey(identifier); + } + + /** + * Drops every cached entry for one database so the next read of any of its tables goes live + * (REFRESH DATABASE / a Doris-issued DROP DATABASE). Entries are keyed by + * {@code Identifier.create(db, table)} (see {@code PaimonConnectorMetadata.beginQuerySnapshot}), so a + * db match is {@code getDatabaseName()} equality. + */ + void invalidateDb(String dbName) { + entry.invalidateIf(id -> id.getDatabaseName().equals(dbName)); + } + + /** Drops all cached entries. */ + void invalidateAll() { + entry.invalidateAll(); + } + + /** Test-only: current number of cached entries (accurate map membership, not Caffeine's estimate). */ + int size() { + int[] count = {0}; + entry.forEach((key, value) -> count[0]++); + return count[0]; + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/profile/PaimonMetricRegistry.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonMetricRegistry.java similarity index 84% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/profile/PaimonMetricRegistry.java rename to fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonMetricRegistry.java index 4904c71faab926..243e8cbacc645d 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/profile/PaimonMetricRegistry.java +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonMetricRegistry.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource.paimon.profile; +package org.apache.doris.connector.paimon; import org.apache.paimon.metrics.MetricGroup; import org.apache.paimon.metrics.MetricGroupImpl; @@ -27,6 +27,12 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +/** + * A per-scan {@link MetricRegistry} handed to the paimon SDK ({@code InnerTableScan.withMetricRegistry}) + * so {@code scan.plan()} records its {@code ScanMetrics} group here for {@link PaimonScanMetrics} to harvest + * into the query profile. Self-contained port of the legacy {@code datasource.paimon.profile.PaimonMetricRegistry} + * (the connector cannot depend on fe-core); paimon-SDK-only, no fe-core imports. + */ public class PaimonMetricRegistry implements MetricRegistry { private static final Logger LOG = LoggerFactory.getLogger(PaimonMetricRegistry.class); private static final String TABLE_TAG_KEY = "table"; 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 93d7cfbfe9a58b..16f0a1df934f32 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 @@ -278,13 +278,23 @@ private Object convertLiteralValue(ConnectorLiteral literal, DataType paimonType } return null; case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + // Zone-free type: interpret the literal's wall-clock in UTC to match paimon's + // stored min/max file/partition stats (computed by reading the wall clock as UTC). + // Mirrors legacy PaimonValueConverter#visit(TimestampType), which uses a fixed + // GMT Calendar. Using the session zone here would shift the epoch-millis vs the + // stored stats and risk false file/partition pruning = silent data loss. if (value instanceof LocalDateTime) { LocalDateTime dt = (LocalDateTime) value; long millis = dt.toInstant(ZoneOffset.UTC).toEpochMilli(); return Timestamp.fromEpochMillis(millis); } return null; + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + // Do NOT push: legacy never pushed LTZ predicates (PaimonValueConverter has no + // visit(LocalZonedTimestampType), so it fell to defaultMethod -> null). Pushing + // via a fixed zone is an instant mismatch under non-UTC sessions; leave LTZ + // conjuncts to BE-side filtering (this conjunct is cleanly dropped). + return null; default: return null; } 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 new file mode 100644 index 00000000000000..d3ec508edd81c0 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanMetrics.java @@ -0,0 +1,183 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.scan.ConnectorScanProfile; + +import org.apache.paimon.metrics.Counter; +import org.apache.paimon.metrics.Gauge; +import org.apache.paimon.metrics.Histogram; +import org.apache.paimon.metrics.HistogramStatistics; +import org.apache.paimon.metrics.Metric; +import org.apache.paimon.metrics.MetricGroup; +import org.apache.paimon.operation.metrics.ScanMetrics; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * Harvests the paimon SDK {@code ScanMetrics} group recorded by {@link PaimonMetricRegistry} during + * {@code scan.plan()} into a connector-neutral {@link ConnectorScanProfile} for the query profile — a + * self-contained port of the legacy {@code datasource.paimon.profile.PaimonScanMetricsReporter} extraction, + * with fe-core's {@code DebugUtil.getPrettyStringMs} inlined (the connector cannot import fe-core). + */ +public final class PaimonScanMetrics { + private static final double P95 = 0.95d; + private static final long SECOND_MS = 1000L; + 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). */ + public static final String GROUP_NAME = "Paimon Scan Metrics"; + + private PaimonScanMetrics() { + } + + /** + * Build the scan profile for {@code paimonTableName}'s recorded metrics, or empty when the SDK recorded + * none (unpartitioned/no-op scan, unsupported scan type). {@code scanLabel} is the per-scan child name. + */ + public static Optional harvest(PaimonMetricRegistry registry, String paimonTableName, + String scanLabel) { + if (registry == null || paimonTableName == null) { + return Optional.empty(); + } + MetricGroup group = registry.getGroup(ScanMetrics.GROUP_NAME, paimonTableName); + if (group == null) { + String prefix = ScanMetrics.GROUP_NAME + ":"; + for (Map.Entry entry : registry.getAllGroupsAsMap().entrySet()) { + String key = entry.getKey(); + if (!key.startsWith(prefix)) { + continue; + } + if (group != null) { + // More than one candidate group — ambiguous, bail out (legacy parity). + group = null; + break; + } + group = entry.getValue(); + } + } + if (group == null) { + return Optional.empty(); + } + Map metrics = group.getMetrics(); + if (metrics == null || metrics.isEmpty()) { + return Optional.empty(); + } + + Map rendered = new LinkedHashMap<>(); + appendDuration(rendered, metrics, ScanMetrics.LAST_SCAN_DURATION, "last_scan_duration"); + appendHistogram(rendered, metrics, ScanMetrics.SCAN_DURATION, "scan_duration"); + appendCounter(rendered, metrics, ScanMetrics.LAST_SCANNED_MANIFESTS, "last_scanned_manifests"); + appendCounter(rendered, metrics, ScanMetrics.LAST_SCAN_SKIPPED_TABLE_FILES, + "last_scan_skipped_table_files"); + appendCounter(rendered, metrics, ScanMetrics.LAST_SCAN_RESULTED_TABLE_FILES, + "last_scan_resulted_table_files"); + appendCounter(rendered, metrics, ScanMetrics.MANIFEST_HIT_CACHE, "manifest_hit_cache"); + appendCounter(rendered, metrics, ScanMetrics.MANIFEST_MISSED_CACHE, "manifest_missed_cache"); + if (rendered.isEmpty()) { + return Optional.empty(); + } + return Optional.of(new ConnectorScanProfile(GROUP_NAME, scanLabel, rendered)); + } + + private static void appendDuration(Map out, Map metrics, String metricKey, + String profileKey) { + Long value = getLongValue(metrics.get(metricKey)); + if (value == null) { + return; + } + out.put(profileKey, formatDuration(value)); + } + + private static void appendCounter(Map out, Map metrics, String metricKey, + String profileKey) { + Long value = getLongValue(metrics.get(metricKey)); + if (value == null) { + return; + } + out.put(profileKey, Long.toString(value)); + } + + private static void appendHistogram(Map out, Map metrics, String metricKey, + String profileKey) { + Metric metric = metrics.get(metricKey); + if (!(metric instanceof Histogram)) { + return; + } + Histogram histogram = (Histogram) metric; + HistogramStatistics stats = histogram.getStatistics(); + if (stats == null) { + return; + } + String formatted = "count=" + histogram.getCount() + + ", mean=" + formatDuration(stats.getMean()) + + ", p95=" + formatDuration(stats.getQuantile(P95)) + + ", max=" + formatDuration(stats.getMax()); + out.put(profileKey, formatted); + } + + private static Long getLongValue(Metric metric) { + if (metric instanceof Counter) { + return ((Counter) metric).getCount(); + } + if (metric instanceof Gauge) { + Object value = ((Gauge) metric).getValue(); + if (value instanceof Number) { + return ((Number) value).longValue(); + } + } + return null; + } + + private static String formatDuration(double nanos) { + return prettyMs(TimeUnit.NANOSECONDS.toMillis(Math.round(nanos))); + } + + /** Inlined fe-core {@code DebugUtil.getPrettyStringMs}: {@code Nhour Nmin Nsec} / {@code Nms}. */ + static String prettyMs(long value) { + if (value == 0) { + return "0"; + } + StringBuilder builder = new StringBuilder(); + long remaining = value; + boolean hour = false; + boolean minute = false; + if (remaining >= HOUR_MS) { + builder.append(remaining / HOUR_MS).append("hour"); + remaining %= HOUR_MS; + hour = true; + } + if (remaining >= MINUTE_MS) { + builder.append(remaining / MINUTE_MS).append("min"); + remaining %= MINUTE_MS; + minute = true; + } + if (!hour && remaining >= SECOND_MS) { + builder.append(remaining / SECOND_MS).append("sec"); + remaining %= SECOND_MS; + } + if (!hour && !minute) { + builder.append(remaining).append("ms"); + } + return builder.toString(); + } +} 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 d850cdc5f948cf..7b52deb15d76f8 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 @@ -22,37 +22,82 @@ 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.ConnectorScanProfile; import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.metastore.spi.JdbcDriverSupport; +import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.filesystem.properties.StorageProperties; +import org.apache.doris.thrift.TColumnType; import org.apache.doris.thrift.TFileScanRangeParams; +import org.apache.doris.thrift.TPaimonDeletionFileDesc; +import org.apache.doris.thrift.TPaimonFileDesc; +import org.apache.doris.thrift.TPrimitiveType; +import org.apache.doris.thrift.TTableFormatFileDesc; +import org.apache.doris.thrift.schema.external.TArrayField; +import org.apache.doris.thrift.schema.external.TField; +import org.apache.doris.thrift.schema.external.TFieldPtr; +import org.apache.doris.thrift.schema.external.TMapField; +import org.apache.doris.thrift.schema.external.TNestedField; +import org.apache.doris.thrift.schema.external.TSchema; +import org.apache.doris.thrift.schema.external.TStructField; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.paimon.CoreOptions; +import org.apache.paimon.catalog.Identifier; import org.apache.paimon.data.BinaryRow; +import org.apache.paimon.data.Timestamp; +import org.apache.paimon.fs.FileIO; import org.apache.paimon.io.DataFileMeta; +import org.apache.paimon.io.DataOutputViewStreamWrapper; +import org.apache.paimon.rest.RESTToken; +import org.apache.paimon.rest.RESTTokenFileIO; +import org.apache.paimon.schema.SchemaManager; +import org.apache.paimon.schema.TableSchema; import org.apache.paimon.table.FileStoreTable; import org.apache.paimon.table.Table; import org.apache.paimon.table.source.DataSplit; import org.apache.paimon.table.source.DeletionFile; +import org.apache.paimon.table.source.InnerTableScan; import org.apache.paimon.table.source.RawFile; import org.apache.paimon.table.source.ReadBuilder; import org.apache.paimon.table.source.Split; import org.apache.paimon.table.source.TableScan; +import org.apache.paimon.table.system.ReadOptimizedTable; +import org.apache.paimon.types.ArrayType; +import org.apache.paimon.types.DataField; +import org.apache.paimon.types.DataType; +import org.apache.paimon.types.MapType; import org.apache.paimon.types.RowType; import org.apache.paimon.utils.InstantiationUtil; import org.apache.paimon.utils.RowDataToObjectArrayConverter; +import org.apache.thrift.TDeserializer; +import org.apache.thrift.TSerializer; +import org.apache.thrift.protocol.TBinaryProtocol; +import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.Collectors; /** @@ -67,6 +112,29 @@ *

  • COUNT pushdown: When the query is COUNT(*) and the split has * pre-computed merged row count.
  • * + * + *

    Partition pruning (P5-T09): pure predicate pushdown. Only the 4-arg + * {@link #planScan} is overridden; the engine's 6-arg {@code planScan(..., requiredPartitions)} + * (the Nereids-pruned partition set) is intentionally NOT overridden. Paimon prunes partitions + * and data files internally: the Doris filter is converted by + * {@link PaimonPredicateConverter} and pushed via {@code ReadBuilder.withFilter}, and the Paimon + * SDK's {@code newScan().plan().splits()} eliminates non-matching partitions/files from those + * predicates. Partition columns are ordinary columns in Paimon's {@code RowType}, so a partition + * predicate is just another pushed predicate. This differs from MaxCompute (whose ODPS read + * session needs explicit {@code PartitionSpec}s and therefore consumes {@code requiredPartitions}); + * for Paimon the engine set would be redundant with the predicate it already pushes. The SPI + * default chain (6-arg → 5-arg → 4-arg) routes correctly with {@code requiredPartitions} + * dropped. As of B5 the connector emits {@code partition_columns} (see + * {@code PaimonConnectorMetadata.buildTableSchema}), so FE now treats Paimon tables as partitioned and + * the Nereids-pruned set feeds FE EXPLAIN ({@code partition=N/M}) only. Because Paimon is fully + * predicate-driven, this provider returns {@code true} from {@link #ignorePartitionPruneShortCircuit()}: + * a GENUINE prune-to-zero (FE pruning emptied the partition set) is NOT short-circuited to zero rows but + * mapped to scan-all, so {@code planScan} re-plans from the pushed predicate. This is load-bearing once a + * genuine-null partition is rendered as a NON-null sentinel ({@code isNull=false}, master parity): {@code + * col IS NULL} prunes every partition away at FE, yet the genuine-null rows must still be returned via the + * pushed predicate (the legacy {@code PaimonScanNode} never consults the FE partition selection). The + * time-travel pin (empty partition-item map over an empty universe) was already guarded the same way in + * {@code PluginDrivenScanNode.resolveRequiredPartitions}. None of this affects read-row correctness. */ public class PaimonScanPlanProvider implements ConnectorScanPlanProvider { @@ -78,10 +146,197 @@ public class PaimonScanPlanProvider implements ConnectorScanPlanProvider { private static final TypeReference> MAP_TYPE_REF = new TypeReference>() {}; + // Session variable name (byte-identical to SessionVariable.ENABLE_PAIMON_CPP_READER) surfaced + // through ConnectorSession.getSessionProperties() (VariableMgr.toMap). When true, BE routes the + // JNI-format paimon split to PaimonCppReader, which deserializes the NATIVE paimon binary format + // (paimon::Split::Deserialize), so FE must serialize a DataSplit with that format, not Java serde. + private static final String ENABLE_PAIMON_CPP_READER = "enable_paimon_cpp_reader"; + + // Session variable name (byte-identical to SessionVariable.FORCE_JNI_SCANNER) surfaced through + // ConnectorSession.getSessionProperties() (VariableMgr.toMap), exactly like ENABLE_PAIMON_CPP_READER + // above. When true it is the user/session JNI escape hatch: every native-eligible DataSplit is routed + // to the JNI reader (legacy PaimonScanNode.getSplits gate, sessionVariable.isForceJniScanner()), + // bypassing the native ORC/Parquet readers to dodge native-reader bugs. Default false (legacy default). + private static final String FORCE_JNI_SCANNER = "force_jni_scanner"; + + // Session variable name (byte-identical to SessionVariable.IGNORE_SPLIT_TYPE) surfaced through the same + // VariableMgr.toMap channel. A debugging escape hatch to isolate reader bugs: IGNORE_JNI drops every JNI + // split, IGNORE_NATIVE drops every native split (legacy PaimonScanNode.getSplits). IGNORE_PAIMON_CPP is a + // documented option but was NEVER consulted by legacy getSplits, so it stays a no-op here (legacy parity). + // Default NONE, so normal reads are unaffected. + private static final String IGNORE_SPLIT_TYPE = "ignore_split_type"; + private static final String IGNORE_SPLIT_TYPE_JNI = "IGNORE_JNI"; + private static final String IGNORE_SPLIT_TYPE_NATIVE = "IGNORE_NATIVE"; + + // FIX-NATIVE-SUBSPLIT (M-3): file-split session vars (byte-identical to SessionVariable.{FILE_SPLIT_SIZE, + // MAX_INITIAL_FILE_SPLIT_SIZE, MAX_FILE_SPLIT_SIZE, MAX_INITIAL_FILE_SPLIT_NUM, MAX_FILE_SPLIT_NUM}), + // read via the same VariableMgr.toMap channel as ENABLE_PAIMON_CPP_READER. They drive the native + // sub-split target size, mirroring legacy PaimonScanNode.determineTargetFileSplitSize without + // importing fe-core SessionVariable/FileSplitter. Defaults below are byte-identical to SessionVariable. + private static final String FILE_SPLIT_SIZE = "file_split_size"; + private static final String MAX_INITIAL_FILE_SPLIT_SIZE = "max_initial_file_split_size"; + private static final String MAX_FILE_SPLIT_SIZE = "max_file_split_size"; + private static final String MAX_INITIAL_FILE_SPLIT_NUM = "max_initial_file_split_num"; + private static final String MAX_FILE_SPLIT_NUM = "max_file_split_num"; + private static final long DEFAULT_MAX_INITIAL_FILE_SPLIT_SIZE = 32L * 1024 * 1024; + private static final long DEFAULT_MAX_FILE_SPLIT_SIZE = 64L * 1024 * 1024; + private static final long DEFAULT_MAX_INITIAL_FILE_SPLIT_NUM = 200L; + private static final long DEFAULT_MAX_FILE_SPLIT_NUM = 100000L; + + // FIX-SCHEMA-EVOLUTION (B-1a): scan-level prop carrying the base64 TBinaryProtocol-serialized + // schema dictionary (a throwaway TFileScanRangeParams holding current_schema_id + + // history_schema_info). getScanNodeProperties builds it from the live table; populateScanLevelParams + // applies it to the real params. Transport via the props map because getScanPlanProvider() returns a + // fresh provider per call (no shared instance state between the two SPI methods). + private static final String SCHEMA_EVOLUTION_PROP = "paimon.schema_evolution"; + // Legacy parity: current_schema_id is the -1 sentinel ("latest"); the current/target schema is + // 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"; + private final Map properties; + private final PaimonCatalogOps catalogOps; + private final ConnectorContext context; + // FIX-B-R2-be: connector-level (per-catalog, long-lived) memo of the per-committed-schema-id field + // read used by the schema-evolution dict (buildSchemaEvolutionParam). Injected by PaimonConnector so + // it is the SAME instance getMetadata uses (the cached fact (handle,schemaId)->schema fields is shared + // with the B-MC2 time-travel path). The public 2/3-arg ctors give each provider its OWN fresh memo so + // the existing construction sites are unchanged (first build = direct read = pre-fix behavior). + private final PaimonSchemaAtMemo schemaAtMemo; - public PaimonScanPlanProvider(Map properties) { + // FIX-SCAN-METRICS: per-query stash of the paimon SDK scan diagnostics harvested during planScan, keyed + // by session queryId. fe-core drains it (collectScanProfiles) right after planScan on the same thread; + // releaseReadTransaction reclaims any entry a thrown planScan left behind. Bounded to the sync planScan + // path (paimon never streams), so the CopyOnWriteArrayList value is only ever appended single-threaded. + private final ConcurrentHashMap> scanProfileStash = new ConcurrentHashMap<>(); + + public PaimonScanPlanProvider(Map properties, PaimonCatalogOps catalogOps) { + this(properties, catalogOps, null); + } + + public PaimonScanPlanProvider(Map properties, PaimonCatalogOps catalogOps, + ConnectorContext context) { + this(properties, catalogOps, context, new PaimonSchemaAtMemo(PaimonSchemaAtMemo.DEFAULT_MAX_SIZE)); + } + + PaimonScanPlanProvider(Map properties, PaimonCatalogOps catalogOps, + ConnectorContext context, PaimonSchemaAtMemo schemaAtMemo) { this.properties = properties; + this.catalogOps = catalogOps; + this.context = context; + this.schemaAtMemo = schemaAtMemo; + } + + /** Test-only: the schema memo this provider was wired with (to pin the connector injection). */ + PaimonSchemaAtMemo schemaAtMemoForTest() { + return schemaAtMemo; + } + + /** + * Reads the {@code enable_paimon_cpp_reader} session flag from the SPI session properties + * (forwarded by the engine via {@code VariableMgr.toMap}). Default false (legacy default), so + * normal reads are unaffected. Package-private static for offline unit testing. + */ + static boolean isCppReaderEnabled(ConnectorSession session) { + if (session == null) { + return false; + } + return Boolean.parseBoolean(session.getSessionProperties().get(ENABLE_PAIMON_CPP_READER)); + } + + /** + * Reads the {@code force_jni_scanner} session flag from the SPI session properties (same + * {@code VariableMgr.toMap} channel as {@link #isCppReaderEnabled}). When true the JNI escape + * hatch is engaged: every native-eligible DataSplit is routed to JNI (see + * {@link #shouldUseNativeReader}), bypassing the native ORC/Parquet readers to dodge native-reader + * bugs. Default false (legacy default), so normal reads are unaffected. Package-private static for + * offline unit testing. + */ + static boolean isForceJniScannerEnabled(ConnectorSession session) { + if (session == null) { + return false; + } + return Boolean.parseBoolean(session.getSessionProperties().get(FORCE_JNI_SCANNER)); + } + + /** + * Reads the {@code ignore_split_type} session variable (same {@code VariableMgr.toMap} channel as + * {@link #isCppReaderEnabled}). Returns {@code "NONE"} when the session is absent (offline unit tests) + * or the variable is unset, matching this file's null-tolerant session-read convention. Only + * {@code IGNORE_JNI} / {@code IGNORE_NATIVE} carry behavior (skip the matching split type, legacy + * {@code PaimonScanNode.getSplits}); every other value (incl. {@code NONE} / {@code IGNORE_PAIMON_CPP}) + * is a no-op. Package-private static for offline unit testing. + */ + static String resolveIgnoreSplitType(ConnectorSession session) { + if (session == null) { + return "NONE"; + } + return session.getSessionProperties().getOrDefault(IGNORE_SPLIT_TYPE, "NONE"); + } + + /** + * Returns the handle's transient Paimon {@link Table}, reloading it from the catalog seam + * when the transient reference is null (e.g. after a serialization round-trip across the + * FE/BE boundary or plan reuse). Delegates to the single sys-aware {@link PaimonTableResolver} + * shared with the metadata path, so a deserialized SYSTEM handle reloads its own (sys) Table + * via the 4-arg sys {@link Identifier} instead of silently scanning the base table. + * Package-private for direct unit testing. + * + *

    NOTE: the reloaded Table may come from a different {@link org.apache.paimon.catalog.Catalog} + * instance than the one that produced the handle. That is acceptable for this fallback safety + * net (it is not snapshot-consistent with the handle's originating catalog). + */ + Table resolveTable(PaimonTableHandle paimonHandle) { + // M-11: wrap the (possibly remote) reload in executeAuthenticated (D-052) so the scan path's + // table resolution runs under the FE-injected Kerberos UGI, matching the metadata twin. The + // transient-table fast path issues no RPC. The FileIO split planning is wrapped separately in + // planSplits (iceberg fourth-locus parity — the un-wrapped plan-time manifest read is exactly + // what failed SASL on iceberg's kerberos CI). When there is no context (offline unit tests + // via the 2-arg ctor), resolve directly — same convention as getScanNodeProperties above. + try { + if (context == null) { + return PaimonTableResolver.resolve(catalogOps, paimonHandle); + } + return context.executeAuthenticated(() -> PaimonTableResolver.resolve(catalogOps, paimonHandle)); + } catch (Exception e) { + throw new RuntimeException("Failed to load Paimon table: " + paimonHandle, e); + } + } + + /** + * Resolves the live {@link Table} for the SCAN path and pins it to the handle's snapshot when + * the handle carries scan options (set by {@code applySnapshot}'s time-travel / MVCC pin). The + * pin is applied here (NOT in the metadata {@code resolveTable}) so BOTH the planned splits AND + * the JNI serialized-table read see the same pinned version, while schema/column/partition + * metadata reads keep resolving the latest table. + * + *

    {@code Table.copy(dynamicOptions)} layers the paimon scan options (e.g. + * {@code scan.snapshot-id}) over the resolved table — the same mechanism legacy paimon used. + */ + Table resolveScanTable(PaimonTableHandle paimonHandle) { + Table table = resolveTable(paimonHandle); + Map scanOptions = paimonHandle.getScanOptions(); + if (scanOptions != null && !scanOptions.isEmpty()) { + // FIX-INCR-SCAN-RESET: for an @incr read, reapply legacy's null reset of + // scan.snapshot-id/scan.mode here (the single Table.copy chokepoint shared by both the + // native/JNI scan path and the JNI serialized-table path) so a stale persisted pin on the + // base table cannot hijack incremental-between. Non-incremental pins pass through unchanged. + return table.copy(PaimonIncrementalScanParams.applyResetsIfIncremental(scanOptions)); + } + return table; } @Override @@ -90,9 +345,141 @@ public List planScan( ConnectorTableHandle handle, List columns, Optional filter) { + return planScanInternal(session, handle, columns, filter, false); + } + + /** + * Paimon is predicate-driven: {@code planScan} ignores {@code requiredPartitions} and re-plans through + * the SDK with the pushed predicate, so a FE prune-to-zero must scan-all rather than short-circuit to + * zero rows (required for {@code col IS NULL} parity once a genuine-null partition renders as a NON-null + * sentinel). See the class-level partition-pruning note. + */ + @Override + public boolean ignorePartitionPruneShortCircuit() { + return true; + } + + /** + * The distinct scanned partitions among the just-planned ranges (FIX-L12) — restores legacy + * {@code PaimonScanNode}'s {@code selectedPartitionNum = partitionInfoMaps.size()} (keyed by + * {@code dataSplit.partition()}) so EXPLAIN {@code partition=N/M} and {@code sql_block_rule} reflect + * the partitions the paimon SDK actually resolved after manifest/file-stat pruning, not the engine's + * declared-column Nereids count. The identity is the rendered {@link PaimonScanRange#getPartitionValues()} + * map — {@code getPartitionInfoMap(table, dataSplit.partition(), tz)}, deterministic and injective per + * partition within a scan, so distinct maps == distinct native partitions (multiple ranges of one + * partition de-dup). Returns empty for an unpartitioned table (every range's partition map is empty), + * so the engine keeps its own count. A partition column whose type is unserializable makes + * {@code getPartitionInfoMap} drop the whole map to empty too → empty here → engine keeps the (safe, + * ≥ real) Nereids count. Only counts this provider's own {@link PaimonScanRange} instances. + */ + @Override + public OptionalLong scannedPartitionCount(List scanRanges) { + Set> distinctPartitions = new HashSet<>(); + for (ConnectorScanRange range : scanRanges) { + if (range instanceof PaimonScanRange) { + Map partitionValues = range.getPartitionValues(); + if (partitionValues != null && !partitionValues.isEmpty()) { + distinctPartitions.add(partitionValues); + } + } + } + return distinctPartitions.isEmpty() + ? OptionalLong.empty() : OptionalLong.of(distinctPartitions.size()); + } + + /** + * Harvest the paimon SDK scan metrics recorded into {@code registry} by {@code scan.plan()} and stash + * them keyed by the session queryId for fe-core to drain (FIX-SCAN-METRICS). No-op for a blank queryId + * (offline/no-session) or a scan the SDK recorded no metrics for. + */ + private void stashScanProfile(ConnectorSession session, Table table, PaimonTableHandle handle, + PaimonMetricRegistry registry) { + // Guard a null session (offline unit tests) — production planScan always carries one. + if (session == null) { + return; + } + String queryId = session.getQueryId(); + if (queryId == null || queryId.isEmpty()) { + return; + } + String scanLabel = "Table Scan (" + handle.getDatabaseName() + "." + handle.getTableName() + ")"; + PaimonScanMetrics.harvest(registry, table.name(), scanLabel).ifPresent(profile -> + scanProfileStash.computeIfAbsent(queryId, k -> new CopyOnWriteArrayList<>()).add(profile)); + } + + @Override + public List collectScanProfiles(ConnectorSession session) { + String queryId = session.getQueryId(); + if (queryId == null || queryId.isEmpty()) { + return Collections.emptyList(); + } + List profiles = scanProfileStash.remove(queryId); + return profiles == null ? Collections.emptyList() : profiles; + } + + @Override + public void releaseReadTransaction(String queryId) { + // Paimon opens no metastore read transaction (it inherits the SPI no-op); this override only reclaims + // the scan-metrics stash for a query whose planScan threw AFTER harvesting (the normal path drains it + // via collectScanProfiles). Same queryId fe-core registered the query-finish callback with. + if (queryId != null && !queryId.isEmpty()) { + scanProfileStash.remove(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}). + */ + @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); + } + + /** + * Enumerate the read splits — {@code scan.plan()} reads paimon's snapshot/manifest files remotely — + * inside the auth context when present, the scan-side twin of {@link #resolveTable}'s wrap and the + * paimon mirror of iceberg's fourth Kerberos locus: on a Kerberos filesystem catalog the plugin bundles + * hadoop child-first, so the planning thread's FileIO reads the PLUGIN's UserGroupInformation copy, + * which only the plugin-side doAs ({@code TcclPinningConnectorContext}) logs in — un-wrapped, the + * plan-time manifest read hits secured HDFS as SIMPLE (iceberg CI proof: + * test_iceberg_hadoop_catalog_kerberos SELECT failing SASL at exactly this point). Paimon's shared + * manifest-reader pool reuses the FileSystem paimon cached at first (authenticated) touch — paimon's + * cache is not UGI-keyed — so the thread-level wrap carries to parallel manifest reads. No live paimon + * kerberos e2e gates this (parity/defence, same footing as the TcclPinningConnectorContext port); a + * {@code null} context (offline unit tests) plans directly. + */ + private List planSplits(TableScan scan) { + if (context == null) { + return scan.plan().splits(); + } + try { + return context.executeAuthenticated(() -> scan.plan().splits()); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Failed to plan paimon splits, error message is:" + e.getMessage(), e); + } + } + + private List planScanInternal( + ConnectorSession session, + ConnectorTableHandle handle, + List columns, + Optional filter, + boolean countPushdown) { PaimonTableHandle paimonHandle = (PaimonTableHandle) handle; - Table table = paimonHandle.getPaimonTable(); + Table table = resolveScanTable(paimonHandle); // Build predicates from filter expression RowType rowType = table.rowType(); @@ -122,7 +509,16 @@ public List planScan( readBuilder.withProjection(projected); } TableScan scan = readBuilder.newScan(); - List paimonSplits = scan.plan().splits(); + // FIX-SCAN-METRICS: attach a metric registry so scan.plan() records its ScanMetrics (manifest cache + // hit/miss, scan durations, table files skipped/resulted), then harvest them below — restores the + // legacy PaimonScanNode scan-metrics profile. InnerTableScan.withMetricRegistry is a real body on the + // AbstractDataTableScan a data table returns; other scan types keep the no-op default (no metrics). + PaimonMetricRegistry metricRegistry = new PaimonMetricRegistry(); + if (scan instanceof InnerTableScan) { + scan = ((InnerTableScan) scan).withMetricRegistry(metricRegistry); + } + List paimonSplits = planSplits(scan); + stashScanProfile(session, table, paimonHandle, metricRegistry); // Determine table location String tableLocation = getTableLocation(table); @@ -142,57 +538,203 @@ public List planScan( List ranges = new ArrayList<>(); + // Read the cpp-reader flag once: it selects the JNI split serialization format (see encodeSplit). + boolean cppReader = isCppReaderEnabled(session); + + // FIX-L14: honor the ignore_split_type debugging escape hatch (legacy PaimonScanNode.getSplits): + // IGNORE_JNI drops JNI splits (nonDataSplit + DataSplit-JNI arms), IGNORE_NATIVE drops native splits. + // The COUNT(*) arm is never dropped (legacy parity); IGNORE_PAIMON_CPP stays a no-op (legacy getSplits + // never consulted it). Read once here, null-tolerant like the flags above. + String ignoreSplitType = resolveIgnoreSplitType(session); + boolean ignoreJni = IGNORE_SPLIT_TYPE_JNI.equals(ignoreSplitType); + boolean ignoreNative = IGNORE_SPLIT_TYPE_NATIVE.equals(ignoreSplitType); + + // FIX-REST-VENDED-URI-NORMALIZE (P9-1): extract the per-table vended token ONCE per scan + // (validToken() may refresh; legacy computes its storage map once in doInitialize), threaded into + // the native-path URI normalization below so REST object-store reads normalize via the vended + // credentials (a REST catalog's static storage map is empty by design, so the static-only path + // would throw "No storage properties found for schema: oss"). Empty for non-REST tables (FileIO + // gate in extractVendedToken) and offline unit tests (no context) → the 2-arg normalize folds to + // the static-map path, leaving non-REST reads byte-unchanged. + Map vendedToken = + context != null ? extractVendedToken(table) : Collections.emptyMap(); + + // FIX-A1: the FE FileSplit proportional-weight denominator (legacy PaimonScanNode:499, set on ALL + // splits). Session-only, so compute once here (before any split is built). DISTINCT from the + // file-splitting targetSplitSize below — named weightDenominator to make a positional swap impossible. + long weightDenominator = resolveSplitWeightDenominator(session); + // Non-DataSplit → always JNI for (Split split : nonDataSplits) { + if (ignoreJni) { + // FIX-L14: ignore_split_type=IGNORE_JNI drops JNI splits (legacy getSplits:401). + continue; + } ranges.add(buildJniScanRange(split, tableLocation, defaultFileFormat, - Collections.emptyMap(), false)); + Collections.emptyMap(), false, cppReader, weightDenominator)); } + // COUNT(*) pushdown (FIX-COUNT-PUSHDOWN): collapse every split whose merged (post-merge / + // post-deletion-vector) row count is precomputed into ONE count range carrying the summed + // total, emitted after the loop — BE serves the count from table_level_row_count (CountReader) + // without reading data. Mirrors legacy PaimonScanNode's count short-circuit, which is the + // FIRST routing arm (BEFORE the native/JNI gate): a count-eligible split must NOT also emit a + // data range, or BE would re-scan and double-count against deletion vectors / PK merge. The + // collapse == legacy's <=10000 case (singletonList(first) + assignCountToSplits([one], sum) -> + // one split bearing the full total); legacy's >10000 parallel-split trim needs numBackends (an + // fe-core-only concern) and is intentionally dropped -> perf-only divergence [deviations-log]. + // Splits WITHOUT a precomputed merged count fall through to the normal native/JNI routing so + // BE still counts them from file metadata / by reading. + long countSum = 0; + DataSplit countRepresentative = null; + + // FIX-NATIVE-SUBSPLIT: target file split size for native ORC/Parquet sub-splitting, computed + // lazily ONCE on the first native split (legacy hasDeterminedTargetFileSplitSize parity). + long targetSplitSize = -1; + // Process DataSplits for (DataSplit dataSplit : dataSplits) { + if (isCountPushdownSplit(countPushdown, dataSplit)) { + countSum += dataSplit.mergedRowCount(); + if (countRepresentative == null) { + countRepresentative = dataSplit; + } + continue; + } + Map partitionValues = getPartitionInfoMap( - table, dataSplit.partition()); + table, dataSplit.partition(), session.getTimeZone()); Optional> optRawFiles = dataSplit.convertToRawFiles(); Optional> optDeletionFiles = dataSplit.deletionFiles(); - if (supportNativeReader(optRawFiles)) { - // Native reader path + if (shouldUseNativeReader(paimonHandle.isForceJni(), + isForceJniScannerEnabled(session), optRawFiles)) { + if (ignoreNative) { + // FIX-L14: ignore_split_type=IGNORE_NATIVE drops native splits (legacy getSplits:443). + continue; + } + // Native reader path: sub-split large ORC/Parquet files for read parallelism + // (FIX-NATIVE-SUBSPLIT), mirroring legacy fileSplitter.splitFile. Under COUNT(*) pushdown + // legacy passes splittable=!applyCountPushdown, so a native split that reaches this arm + // (i.e. NOT siphoned to the count arm because its merged count is not precomputed — e.g. a + // DV with null cardinality) is kept WHOLE. We mirror that by passing target size 0, which + // makes buildNativeRanges emit a single whole-file range; the target heuristic is then not + // needed (and not computed) under count pushdown. + if (!countPushdown && targetSplitSize < 0) { + targetSplitSize = resolveTargetSplitSize(session, dataSplits); + } + long effectiveSplitSize = countPushdown ? 0L : targetSplitSize; List rawFiles = optRawFiles.get(); for (int i = 0; i < rawFiles.size(); i++) { RawFile file = rawFiles.get(i); - String fileFormat = getFileFormatBySuffix(file.path()) - .orElse(defaultFileFormat); - - PaimonScanRange.Builder builder = new PaimonScanRange.Builder() - .path(file.path()) - .start(0) - .length(file.length()) - .fileSize(file.length()) - .fileFormat(fileFormat) - .partitionValues(partitionValues) - .schemaId(file.schemaId()); - - if (optDeletionFiles.isPresent() - && i < optDeletionFiles.get().size() - && optDeletionFiles.get().get(i) != null) { - DeletionFile df = optDeletionFiles.get().get(i); - builder.deletionFile(df.path(), df.offset(), df.length()); - } - - ranges.add(builder.build()); + DeletionFile deletionFile = + (optDeletionFiles.isPresent() && i < optDeletionFiles.get().size()) + ? optDeletionFiles.get().get(i) : null; + ranges.addAll(buildNativeRanges(file, deletionFile, defaultFileFormat, + partitionValues, vendedToken, effectiveSplitSize, weightDenominator)); } } else { // JNI reader path + if (ignoreJni) { + // FIX-L14: ignore_split_type=IGNORE_JNI drops JNI splits (legacy getSplits:483). + continue; + } ranges.add(buildJniScanRange( dataSplit, tableLocation, defaultFileFormat, - partitionValues, true)); + partitionValues, true, cppReader, weightDenominator)); } } + // Emit the single collapsed count range carrying the summed total (legacy's <=10000 case: one + // split bearing the full count). Skipped when no split had a precomputed merged count. + if (countRepresentative != null) { + Map partitionValues = getPartitionInfoMap( + table, countRepresentative.partition(), session.getTimeZone()); + ranges.add(buildCountRange(countRepresentative, tableLocation, defaultFileFormat, + partitionValues, cppReader, countSum, weightDenominator)); + } + return ranges; } + /** + * Builds the native-reader {@link PaimonScanRange} for one raw ORC/Parquet file plus its optional + * deletion vector. BOTH the data-file path and the deletion-vector path are routed through + * {@link #normalizeUri} so BE's scheme-dispatched S3 factory receives canonical {@code s3://} + * URIs on OSS/COS/OBS/s3a warehouses (FIX-URI-NORMALIZE; legacy {@code PaimonScanNode} normalizes + * both via the 2-arg {@code LocationPath.of}). The {@code vendedToken} (empty for non-REST) is the + * per-table vended credential map, routed into normalization so REST object-store paths normalize via + * the vended map (FIX-REST-VENDED-URI-NORMALIZE). Package-private so both normalization sites are + * unit-testable without a live deletion-vector-bearing split. + */ + PaimonScanRange buildNativeRange(RawFile file, DeletionFile deletionFile, + String defaultFileFormat, Map partitionValues, + Map vendedToken, long start, long length, long weightDenominator) { + String fileFormat = getFileFormatBySuffix(file.path()).orElse(defaultFileFormat); + // FIX-A1: native sub-split FE weight = the sub-range byte length, + the deletion-vector length when + // attached (legacy PaimonSplit(LocationPath,...).selfSplitWeight = length, setDeletionFile += DV). + // This is FE-scheduling only; the BE-thrift paimon.self_split_weight stays gated on paimonSplit (A3) + // so native ranges still do not emit it to BE. + long selfSplitWeight = length + (deletionFile != null ? deletionFile.length() : 0); + PaimonScanRange.Builder builder = new PaimonScanRange.Builder() + .path(normalizeUri(file.path(), vendedToken)) + .start(start) + .length(length) + .fileSize(file.length()) + .fileFormat(fileFormat) + .partitionValues(partitionValues) + .selfSplitWeight(selfSplitWeight) + .targetSplitSize(weightDenominator) + .schemaId(file.schemaId()); + if (deletionFile != null) { + builder.deletionFile( + normalizeUri(deletionFile.path(), vendedToken), + deletionFile.offset(), deletionFile.length()); + } + return builder.build(); + } + + /** + * Builds the native sub-range(s) for one raw ORC/Parquet file (FIX-NATIVE-SUBSPLIT): slices it at + * {@code targetSplitSize} via {@link #computeFileSplitOffsets} and emits one {@link PaimonScanRange} + * per {@code [start, length)} sub-range. The SAME per-file deletion vector is attached to EVERY + * sub-range — BE indexes the DV by GLOBAL file row position, so disjoint sub-ranges share the + * unmodified deletion file (no offset re-basing); attaching it to only some sub-ranges would let + * deleted rows reappear in the others (merge-on-read corruption). A non-positive + * {@code targetSplitSize} yields a single whole-file range (used under COUNT(*) pushdown, where + * legacy keeps the split whole via {@code splittable=!applyCountPushdown}). Package-private so the + * DV-on-every-sub-range invariant is unit-testable without a live DV-bearing split. + */ + List buildNativeRanges(RawFile file, DeletionFile deletionFile, + String defaultFileFormat, Map partitionValues, + Map vendedToken, long targetSplitSize, long weightDenominator) { + List result = new ArrayList<>(); + for (long[] offset : computeFileSplitOffsets(file.length(), targetSplitSize)) { + result.add(buildNativeRange(file, deletionFile, defaultFileFormat, + partitionValues, vendedToken, offset[0], offset[1], weightDenominator)); + } + return result; + } + + /** + * Normalizes a raw paimon-SDK storage URI (native data-file or deletion-vector path) into BE's + * canonical scheme via the engine ({@code oss://}/{@code cos://}/{@code obs://}/{@code s3a://} + * → {@code s3://}; OSS {@code bucket.endpoint} → {@code bucket}). Ports legacy + * {@code PaimonScanNode}'s 2-arg {@code LocationPath.of(path, storagePropertiesMap)} — BE's S3 + * 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 + * {@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; + } + @Override public Map getScanNodeProperties( ConnectorSession session, @@ -201,7 +743,7 @@ public Map getScanNodeProperties( Optional filter) { PaimonTableHandle paimonHandle = (PaimonTableHandle) handle; - Table table = paimonHandle.getPaimonTable(); + Table table = resolveScanTable(paimonHandle); Map props = new LinkedHashMap<>(); @@ -209,20 +751,36 @@ public Map getScanNodeProperties( props.put("file_format_type", "jni"); props.put("table_format_type", "paimon"); + // Path partition keys: declare the partition columns at the scan-node level so + // FileQueryScanNode excludes them from the file/decode column set (num_of_columns_from_file + + // classifyColumn -> PARTITION_KEY). Paimon physically stores partition columns IN the data + // file, and the per-split PaimonScanRange.populateRangeParams already emits them as + // columnsFromPath; without this declaration the BE both DECODES dt/hh from the ORC file AND + // APPENDS them from columnsFromPath -> a row-count double-fill that trips the OrcReader DCHECK + // (block rows != partition col rows). Case-preserved to match the Doris column names and the + // columnsFromPath keys (getPartitionInfoMap). Restores legacy PaimonScanNode.getPathPartitionKeys + // 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)); + } + // Serialized table for BE's JNI reader String serializedTable = encodeObjectToString(table); props.put("paimon.serialized_table", serializedTable); - // Serialized predicates for BE's JNI scanner + // 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() + // deserializes this param UNCONDITIONALLY — omitting it makes the JNI reader NPE on deserialize(null) + // ("encodedStr is null"). Mirrors legacy PaimonScanNode.createScanRangeLocations, which always called + // setPaimonPredicate(encodeObjectToString(predicates)) regardless of whether predicates was empty. + List predicates = Collections.emptyList(); if (filter.isPresent()) { RowType rowType = table.rowType(); PaimonPredicateConverter converter = new PaimonPredicateConverter(rowType); - List predicates = converter.convert(filter.get()); - if (!predicates.isEmpty()) { - String serializedPredicate = encodeObjectToString(predicates); - props.put("paimon.predicate", serializedPredicate); - } + predicates = converter.convert(filter.get()); } + props.put("paimon.predicate", encodeObjectToString(predicates)); // Paimon JDBC metastore options for BE (if applicable) Map backendOptions = getBackendPaimonOptions(); @@ -242,23 +800,135 @@ public Map getScanNodeProperties( props.put("paimon.options_json", sb.toString()); } - // Location / storage properties - for (Map.Entry entry : properties.entrySet()) { - String key = entry.getKey(); - if (key.startsWith("hadoop.") || key.startsWith("fs.") - || key.startsWith("dfs.") || key.startsWith("hive.") - || key.startsWith("s3.") || key.startsWith("cos.") - || key.startsWith("oss.") || key.startsWith("obs.")) { - props.put("location." + key, entry.getValue()); + // FIX-STATIC-CREDS-BE (B-9): static catalog-level storage credentials/config, normalized to + // BE-canonical keys (AWS_* for object stores). BE's native (FILE_S3) reader understands ONLY the + // 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() + // 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 + // context (offline unit tests) → no storage props emitted (never the broken raw aliases). + // + // HDFS (DV-004 / R-007 — CLOSED by FU-T01): fe-filesystem now has a typed HDFS BE model + // (HdfsFileSystemProperties); HdfsFileSystemProvider.bind() yields it, so an HDFS-warehouse catalog + // emits the hadoop/dfs/HA/kerberos keys here (→ THdfsParams) at parity with the legacy path + // (hadoop.config.resources resolved under the operator-configured Config.hadoop_config_dir). + // KNOWN GAP 2 (R-008): the typed OSS/COS/OBS models omit AWS_CREDENTIALS_PROVIDER_TYPE, which legacy + // emitted as ANONYMOUS for credential-less catalogs — a fe-filesystem parity gap (out of P1 whitelist), + // 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()) { + sp.toBackendProperties().ifPresent(b -> backendStorageProps.putAll(b.toMap())); + } + for (Map.Entry e : backendStorageProps.entrySet()) { + props.put("location." + e.getKey(), e.getValue()); + } + } + + // FIX-REST-VENDED: overlay per-table vended cloud-storage credentials (REST catalogs). + // The raw token is extracted from the live, snapshot-pinned table's RESTTokenFileIO (paimon + // SDK only), then normalized to BE-facing AWS_* keys by the engine (the connector cannot + // 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)); + for (Map.Entry e : vendedBeProps.entrySet()) { + props.put("location." + e.getKey(), e.getValue()); + } + } + + // FIX-SCHEMA-EVOLUTION (B-1a): emit the native-reader schema dictionary so BE matches file<->table + // columns BY FIELD ID across schema evolution (rename/reorder) instead of falling back to NAME + // matching (which silently reads NULL/garbage for renamed columns). Only meaningful when the table + // can take the native path: skip it when the handle name-forces JNI (binlog/audit_log) OR the + // session forces JNI (force_jni_scanner) — in both cases every split goes JNI and never consults + // the dict (FIX-FORCE-JNI-SCANNER: honor the same session escape hatch the native router uses). + if (!paimonHandle.isForceJni() && !isForceJniScannerEnabled(session)) { + // The schema dict must be built from a FileStoreTable. A normal data table IS one; a $ro + // (read-optimized) system table is a ReadOptimizedTable that WRAPS a FileStoreTable and reads + // its data files with its field ids, so resolve the underlying base FileStoreTable here. + Table schemaDictTable = resolveSchemaDictTable(table, paimonHandle); + if (schemaDictTable != null) { + buildSchemaEvolutionParam(paimonHandle, schemaDictTable, columns) + .ifPresent(v -> props.put(SCHEMA_EVOLUTION_PROP, v)); } } return props; } + /** + * Resolves the {@link FileStoreTable} whose schema dictionary BE needs to field-id-match the native + * data files for {@code table}. A normal data table IS the FileStoreTable. A read-optimized system + * table ({@code $ro} → {@link ReadOptimizedTable}) is NOT a {@code FileStoreTable} (it wraps one) + * but reads the BASE table's data files with the BASE field ids, so its dict must come from the base + * FileStoreTable, reloaded here via the 2-arg base {@link Identifier}. + * + *

    Restores legacy {@code PaimonScanNode} parity: legacy set {@code history_schema_info} for ANY + * paimon table (incl. {@code $ro}) in {@code doInitialize}, so BE always took the field-id path. The + * SPI connector had gated the dict on {@code instanceof FileStoreTable} and so emitted nothing for + * {@code $ro}; with no {@code history_schema_info} BE's {@code gen_table_info_node_by_field_id} fell + * into the legacy name-matching branch {@code by_parquet_name(tuple_descriptor, ...)} and dereferenced + * a still-null tuple descriptor ({@code table_schema_change_helper.cpp:94}) → a SIGSEGV that + * aborted the whole BE. + * + *

    Returns {@code null} for a table with no native data files (metadata system tables take the JNI + * path and never consult the dict), preserving the prior "emit nothing" behavior for those. + */ + private Table resolveSchemaDictTable(Table table, PaimonTableHandle handle) { + if (table instanceof FileStoreTable) { + return table; + } + if (table instanceof ReadOptimizedTable) { + return reloadBaseTable(handle); + } + return null; + } + + /** + * Reloads the BASE data table for a system handle via the 2-arg base {@link Identifier}, under the + * FE-injected authenticator (D-052) when a context is present — mirroring {@link #resolveTable}'s + * reload. Used to obtain the underlying {@link FileStoreTable} of a {@code $ro} read so its schema + * dictionary can be emitted. + */ + private Table reloadBaseTable(PaimonTableHandle handle) { + Identifier baseId = Identifier.create(handle.getDatabaseName(), handle.getTableName()); + try { + if (context == null) { + return catalogOps.getTable(baseId); + } + return context.executeAuthenticated(() -> catalogOps.getTable(baseId)); + } catch (Exception e) { + throw new RuntimeException("Failed to load Paimon base table for schema dict: " + baseId, e); + } + } + + /** + * Extracts the raw per-table vended credential token from a REST catalog table's + * {@link RESTTokenFileIO} (port of legacy {@code PaimonVendedCredentialsProvider + * .extractRawVendedCredentials}, paimon SDK only). Returns empty for a non-REST table (different + * FileIO) or when no valid token is available — the gate is the table's FileIO type, equivalent + * to legacy's "metastore is REST" check for the read path. + */ + static Map extractVendedToken(Table table) { + if (table == null) { + return Collections.emptyMap(); + } + FileIO fileIO = table.fileIO(); + if (!(fileIO instanceof RESTTokenFileIO)) { + return Collections.emptyMap(); + } + RESTToken token = ((RESTTokenFileIO) fileIO).validToken(); + Map raw = token == null ? null : token.token(); + return raw == null ? Collections.emptyMap() : new HashMap<>(raw); + } + private PaimonScanRange buildJniScanRange(Split split, String tableLocation, String defaultFileFormat, Map partitionValues, - boolean isDataSplit) { + boolean isDataSplit, boolean cppReader, long weightDenominator) { long splitWeight = 0; if (isDataSplit) { splitWeight = computeSplitWeight((DataSplit) split); @@ -266,17 +936,187 @@ private PaimonScanRange buildJniScanRange(Split split, String tableLocation, splitWeight = split.rowCount(); } - String serializedSplit = encodeObjectToString(split); + String serializedSplit = encodeSplit(split, cppReader); + // FIX-JNI-FILE-FORMAT (P7-1) + FIX-L11: emit the real data-file format (orc/parquet/avro), NOT "jni". + // JNI routing is gated by the paimon.split property (PaimonScanRange.populateRangeParams), so this + // string only feeds fileDesc.file_format, which BE's paimon_cpp_reader backfills into + // FILE_FORMAT/MANIFEST_FORMAT (an invalid "jni" breaks the manifest read). Mirrors legacy + // PaimonScanNode.setPaimonParams's fileDesc.setFileFormat(getFileFormat(getPathString())): for a + // DataSplit the format is the FIRST data-file suffix (falling back to the table default); a + // non-DataSplit has no data file and falls back to the table default (legacy DUMMY_PATH -> orElse). + String fileFormat = isDataSplit + ? dataSplitFileFormat((DataSplit) split, defaultFileFormat) + : defaultFileFormat; return new PaimonScanRange.Builder() - .fileFormat("jni") + .fileFormat(fileFormat) .paimonSplit(serializedSplit) + // FIX-READER-TYPE (3645dc94306): lockstep with encodeSplit above — PAIMON_CPP iff we + // native-serialized a DataSplit for the paimon-cpp reader, else PAIMON_JNI. + .cppReaderSplit(cppReader && isDataSplit) .tableLocation(tableLocation) .partitionValues(partitionValues) .selfSplitWeight(splitWeight) + .targetSplitSize(weightDenominator) .build(); } + /** + * Whether a {@link DataSplit} contributes a precomputed COUNT(*)-pushdown row count: true iff count + * pushdown is active for this scan AND the split's merged (post-merge / post-deletion-vector) row + * count is precomputed by the paimon SDK. Mirrors legacy {@code PaimonScanNode}'s count gate + * ({@code applyCountPushdown && dataSplit.mergedRowCountAvailable()}, the FIRST routing arm). + * Extracted as a pure static so the correctness-critical count routing decision is unit-testable + * with a real {@link DataSplit}, like {@link #shouldUseNativeReader}. + */ + static boolean isCountPushdownSplit(boolean countPushdown, DataSplit dataSplit) { + return countPushdown && dataSplit.mergedRowCountAvailable(); + } + + /** + * Builds the single collapsed COUNT(*)-pushdown range: a JNI-serialized {@link DataSplit} (legacy + * {@code new PaimonSplit(dataSplit)}) carrying the summed merged row count via {@code paimon.row_count} + * → BE's {@code table_level_row_count} → {@code CountReader}, so BE emits the count without + * reading data. The serialization format honors the cpp-reader flag, like {@link #buildJniScanRange}. + */ + private PaimonScanRange buildCountRange(DataSplit dataSplit, String tableLocation, + String defaultFileFormat, Map partitionValues, boolean cppReader, long rowCount, + long weightDenominator) { + String serializedSplit = encodeSplit(dataSplit, cppReader); + // FIX-JNI-FILE-FORMAT (P7-1) + FIX-L11: real data-file format from the first data-file suffix, not + // "jni" and not the bare table default (see buildJniScanRange / dataSplitFileFormat). + return new PaimonScanRange.Builder() + .fileFormat(dataSplitFileFormat(dataSplit, defaultFileFormat)) + .paimonSplit(serializedSplit) + // FIX-READER-TYPE (3645dc94306): dataSplit is always a DataSplit here, so cpp-reader + // serialization (hence PAIMON_CPP) is chosen iff the flag is on — matches encodeSplit above. + .cppReaderSplit(cppReader) + .tableLocation(tableLocation) + .partitionValues(partitionValues) + .selfSplitWeight(computeSplitWeight(dataSplit)) + .targetSplitSize(weightDenominator) + .rowCount(rowCount) + .build(); + } + + /** + * Slices a native data file into {@code [start, length]} sub-ranges for read parallelism + * (FIX-NATIVE-SUBSPLIT), porting the specified-size branch of legacy {@code FileSplitter.splitFile} + * (the connector has no block locations, so the block-based branch is never reached). Byte-identical + * to {@code FileSplitter.java:129-144}, including the + * {@code > 1.1D} tail guard — the LAST range absorbs a remainder of up to 1.1× the + * target instead of emitting a tiny tail split (a naive {@code ceilDiv} would differ). The ranges + * tile {@code [0, fileLength)} contiguously with no gap/overlap. A zero/negative file length yields + * no range (legacy skips empty files); a non-positive target yields a single whole-file range — + * used under COUNT(*) pushdown (see {@link #buildNativeRanges}, where legacy keeps the split whole + * via {@code splittable=!applyCountPushdown}); {@link #determineTargetSplitSize} otherwise never + * returns ≤ 0. Pure static so the offset math is unit-testable against the fe-core source it ports. + */ + static List computeFileSplitOffsets(long fileLength, long targetSplitSize) { + List result = new ArrayList<>(); + if (fileLength <= 0) { + return result; + } + if (targetSplitSize <= 0) { + result.add(new long[] {0L, fileLength}); + return result; + } + long bytesRemaining; + for (bytesRemaining = fileLength; + (double) bytesRemaining / (double) targetSplitSize > 1.1D; + bytesRemaining -= targetSplitSize) { + result.add(new long[] {fileLength - bytesRemaining, targetSplitSize}); + } + if (bytesRemaining != 0L) { + result.add(new long[] {fileLength - bytesRemaining, bytesRemaining}); + } + return result; + } + + /** + * Computes the native target file split size, porting legacy + * {@code PaimonScanNode.determineTargetFileSplitSize} + {@code FileQueryScanNode.applyMaxFileSplitNumLimit} + * with plain longs (the connector cannot import {@code SessionVariable}). The legacy + * {@code isBatchMode -> 0} branch is omitted: paimon is never batch-mode on the plugin path. Pure + * static so the heuristic is unit-testable. + */ + static long determineTargetSplitSize(long fileSplitSize, long maxInitialSplitSize, long maxSplitSize, + long maxInitialSplitNum, long maxFileSplitNum, long totalNativeFileSize) { + if (fileSplitSize > 0) { + return fileSplitSize; + } + long result = (totalNativeFileSize >= maxSplitSize * maxInitialSplitNum) + ? maxSplitSize : maxInitialSplitSize; + if (maxFileSplitNum > 0 && totalNativeFileSize > 0) { + long minSplitSizeForMaxNum = (totalNativeFileSize + maxFileSplitNum - 1L) / maxFileSplitNum; + result = Math.max(result, minSplitSizeForMaxNum); + } + return result; + } + + /** + * Reads the 5 file-split session vars (VariableMgr.toMap channel) and sums the native-eligible + * file sizes across {@code dataSplits}, then delegates to the pure-static + * {@link #determineTargetSplitSize}. Mirrors legacy {@code determineTargetFileSplitSize}'s + * once-per-scan computation (summing every {@code supportNativeReader}-eligible RawFile, like + * {@code PaimonScanNode.java:552-564}). + */ + private long resolveTargetSplitSize(ConnectorSession session, List dataSplits) { + long totalNativeFileSize = 0; + for (DataSplit dataSplit : dataSplits) { + Optional> rawFiles = dataSplit.convertToRawFiles(); + if (!supportNativeReader(rawFiles)) { + continue; + } + for (RawFile file : rawFiles.get()) { + totalNativeFileSize += file.fileSize(); + } + } + return determineTargetSplitSize( + sessionLong(session, FILE_SPLIT_SIZE, 0L), + sessionLong(session, MAX_INITIAL_FILE_SPLIT_SIZE, DEFAULT_MAX_INITIAL_FILE_SPLIT_SIZE), + sessionLong(session, MAX_FILE_SPLIT_SIZE, DEFAULT_MAX_FILE_SPLIT_SIZE), + sessionLong(session, MAX_INITIAL_FILE_SPLIT_NUM, DEFAULT_MAX_INITIAL_FILE_SPLIT_NUM), + sessionLong(session, MAX_FILE_SPLIT_NUM, DEFAULT_MAX_FILE_SPLIT_NUM), + totalNativeFileSize); + } + + /** + * The proportional-weight denominator (FIX-A1) = legacy scan-level {@code targetSplitSize} + * ({@code PaimonScanNode:497-500}): {@code file_split_size} when set ({@code > 0}), else + * {@code max_file_split_size} (default 64 MB). Exact parity with legacy + * {@code getFileSplitSize() > 0 ? getFileSplitSize() : getMaxSplitSize()}. This is DISTINCT from + * {@link #resolveTargetSplitSize} (the native file-splitting granularity); it is the divisor for the FE + * {@code FileSplit} proportional split weight and is applied to EVERY split type (native / JNI / count), + * even under COUNT(*) pushdown where the file-splitting size is 0. + */ + static long resolveSplitWeightDenominator(ConnectorSession session) { + long fileSplitSize = sessionLong(session, FILE_SPLIT_SIZE, 0L); + return fileSplitSize > 0 + ? fileSplitSize + : sessionLong(session, MAX_FILE_SPLIT_SIZE, DEFAULT_MAX_FILE_SPLIT_SIZE); + } + + /** + * Reads a long session var from the SPI session properties (VariableMgr.toMap channel), falling + * back to {@code defaultValue} when absent/blank/unparseable. Mirrors the null-tolerant + * {@link #isCppReaderEnabled} pattern. + */ + private static long sessionLong(ConnectorSession session, String key, long defaultValue) { + if (session == null) { + return defaultValue; + } + String value = session.getSessionProperties().get(key); + if (value == null || value.trim().isEmpty()) { + return defaultValue; + } + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return defaultValue; + } + } + private long computeSplitWeight(DataSplit dataSplit) { List metas = dataSplit.dataFiles(); if (metas != null && !metas.isEmpty()) { @@ -285,7 +1125,36 @@ private long computeSplitWeight(DataSplit dataSplit) { return dataSplit.rowCount(); } - private boolean supportNativeReader(Optional> optRawFiles) { + /** + * Decides whether a {@link DataSplit} may take the native (ORC/Parquet) reader path. + * + *

    The split is native-eligible iff (a) it is NOT name-forced to JNI by the handle, AND (b) it is + * NOT session-forced to JNI via {@code force_jni_scanner}, AND (c) its raw files all support the + * native reader (see {@link #supportNativeReader}). Mirrors legacy's three-boolean gate + * {@code !forceJniScanner && !forceJniForSystemTable && supportNativeReader} (PaimonScanNode.getSplits). + * + *

    {@code forceJni} is the T19 name-force: {@code binlog} / {@code audit_log} system tables are + * paimon {@code DataTable}s whose {@code DataSplit.convertToRawFiles()} may succeed, but the native + * reader cannot reproduce their read semantics (binlog pack/merge + array materialization; + * audit_log rowkind/sequence-number projection), so they would silently return wrong rows. Legacy + * forces them to JNI ({@code PaimonScanNode.shouldForceJniForSystemTable}, captured by + * {@link PaimonTableHandle#isForceJni()}). It must NOT over-force: metadata sys tables already go + * JNI via the non-DataSplit path, and a non-forced {@code DataTable} like "ro" (forceJni=false) + * must still be allowed native. + * + *

    {@code forceJniScanner} is the user/session escape hatch ({@code SET force_jni_scanner=true}, + * read via {@link #isForceJniScannerEnabled}): when set, every native-eligible split is routed to + * JNI to dodge native-reader bugs. Default false, so normal reads are unaffected. + * + *

    Extracted as a pure static so the correctness-critical routing decision is unit-testable + * with real {@link RawFile}s, without driving a full Paimon {@code ReadBuilder}/{@code TableScan}. + */ + static boolean shouldUseNativeReader(boolean forceJni, boolean forceJniScanner, + Optional> optRawFiles) { + return !forceJni && !forceJniScanner && supportNativeReader(optRawFiles); + } + + private static boolean supportNativeReader(Optional> optRawFiles) { if (!optRawFiles.isPresent() || optRawFiles.get().isEmpty()) { return false; } @@ -298,7 +1167,7 @@ private boolean supportNativeReader(Optional> optRawFiles) { return true; } - private Map getPartitionInfoMap(Table table, BinaryRow partitionValue) { + private Map getPartitionInfoMap(Table table, BinaryRow partitionValue, String timeZone) { List partitionKeys = table.partitionKeys(); if (partitionKeys == null || partitionKeys.isEmpty()) { return Collections.emptyMap(); @@ -310,13 +1179,80 @@ private Map getPartitionInfoMap(Table table, BinaryRow partition Map result = new LinkedHashMap<>(); for (int i = 0; i < partitionKeys.size(); i++) { - String key = partitionKeys.get(i); - String value = values[i] != null ? values[i].toString() : null; - result.put(key, value); + try { + String value = serializePartitionValue( + partitionType.getFields().get(i).type(), values[i], timeZone); + result.put(partitionKeys.get(i), value); + } catch (UnsupportedOperationException e) { + // Legacy parity (PaimonUtil.getPartitionInfoMap): an unsupported partition column + // type (e.g. binary/varbinary) drops the ENTIRE map — BE then materializes no + // columnsFromPath for this split, rather than emitting non-deterministic [B@hash + // garbage. Legacy returned null; the connector returns an empty map, which + // PaimonScanRange.populateRangeParams treats identically (no columnsFromPath emitted). + LOG.warn("Failed to serialize partition value for key {} of table {}: {}", + partitionKeys.get(i), table.name(), e.getMessage()); + return Collections.emptyMap(); + } } return result; } + /** + * Renders one Paimon partition value to the canonical string BE expects in columnsFromPath. + * Byte-faithful port of legacy PaimonUtil.serializePartitionValue. Pure static (no Table / + * ReadBuilder needed) so the correctness-critical per-type rendering is unit-testable offline. + * Only TIMESTAMP_WITH_LOCAL_TIME_ZONE consumes {@code timeZone} (session zone, UTC->session + * shift); all other cases ignore it. + * + *

    For native ORC/Parquet reads, partition columns are NOT stored in the data files — BE + * materializes them from this string. A raw {@code Object.toString()} corrupts several types: + * DATE renders as epoch-days ("19723"), LTZ keeps the un-shifted UTC wall clock, BINARY becomes + * a JVM-identity {@code [B@hash}. This per-type switch restores legacy correctness. + */ + static String serializePartitionValue(DataType type, Object value, String timeZone) { + switch (type.getTypeRoot()) { + case BOOLEAN: + case INTEGER: + case BIGINT: + case SMALLINT: + case TINYINT: + case DECIMAL: + case VARCHAR: + case CHAR: + return value == null ? null : value.toString(); + case FLOAT: + return value == null ? null : Float.toString((Float) value); + case DOUBLE: + return value == null ? null : Double.toString((Double) value); + // BINARY / VARBINARY intentionally unsupported (falls to default -> throws -> map + // dropped): a utf8 string render can corrupt the bytes (legacy comment). + case DATE: + return value == null ? null + : LocalDate.ofEpochDay((Integer) value).format(DateTimeFormatter.ISO_LOCAL_DATE); + case TIME_WITHOUT_TIME_ZONE: + if (value == null) { + return null; + } + return LocalTime.ofNanoOfDay(((Long) value) * 1000) + .format(DateTimeFormatter.ISO_LOCAL_TIME); + case TIMESTAMP_WITHOUT_TIME_ZONE: + return value == null ? null + : ((Timestamp) value).toLocalDateTime().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + if (value == null) { + return null; + } + return ((Timestamp) value).toLocalDateTime() + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timeZone)) + .toLocalDateTime() + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + default: + throw new UnsupportedOperationException( + "Unsupported type for serializePartitionValue: " + type); + } + } + private String getTableLocation(Table table) { if (table instanceof FileStoreTable) { return ((FileStoreTable) table).location().toString(); @@ -324,12 +1260,35 @@ private String getTableLocation(Table table) { return table.options().get("path"); } - private Map getBackendPaimonOptions() { + // #65332: JNI IOManager backend options. Paimon primary-key merge reads (the most common + // filesystem/hive-metastore case) need withIOManager to spill through the Paimon IOManager; + // BE's PaimonJniScanner enables it only when FE ships these keys. Catalog properties carry the + // connector "paimon." prefix (e.g. properties.get("paimon.catalog.type")); the prefix is stripped + // so BE receives doris.enable_jni_io_manager etc. (BE re-adds the paimon. prefix). + private static final String PAIMON_PROPERTY_PREFIX = "paimon."; + private static final List BACKEND_PAIMON_JNI_OPTIONS = Arrays.asList( + "doris.enable_jni_io_manager", + "doris.jni_io_manager.tmp_dir", + "doris.jni_io_manager.impl_class", + // #65365: user opt-out of paimon's async file reader (BE reads paimon.jni.enable_file_reader_async). + "jni.enable_file_reader_async"); + + // Package-private for direct unit testing (PaimonScanPlanProviderTest). + Map getBackendPaimonOptions() { + Map options = new HashMap<>(); + // #65332: forward the JNI IOManager options for ALL metastore flavors (mirrors upstream + // PaimonScanNode.getBackendPaimonOptions returning them before the jdbc-only branch), so + // non-jdbc catalogs are no longer silently stripped of the enable flag. + for (String option : BACKEND_PAIMON_JNI_OPTIONS) { + String prefixed = PAIMON_PROPERTY_PREFIX + option; + if (properties.containsKey(prefixed)) { + options.put(option, properties.get(prefixed)); + } + } String metastoreType = properties.get("paimon.catalog.type"); if (!"jdbc".equalsIgnoreCase(metastoreType)) { - return Collections.emptyMap(); + return options; } - Map options = new HashMap<>(); // Forward relevant JDBC catalog properties for BE's paimon-cpp reader for (Map.Entry entry : properties.entrySet()) { String key = entry.getKey(); @@ -339,15 +1298,52 @@ private Map getBackendPaimonOptions() { options.put(key, entry.getValue()); } } + // FIX-JDBC-DRIVER-URL (B-8a): the loop above forwards driver_url RAW and only matches the + // "jdbc.*" form, so a bare "jdbc.driver_url=mysql.jar" reaches BE unresolved (BE does + // new URL(value) -> MalformedURLException, JdbcDriverUtils.registerDriver) and a + // "paimon.jdbc.driver_url" alias is dropped entirely. Emit the canonical, RESOLVED keys the + // BE reader accepts (PaimonJdbcDriverUtils reads both aliases): honor either alias and resolve + // a bare jar name to a full file:// URL. Mirrors legacy + // PaimonJdbcMetaStoreProperties.getBackendPaimonOptions (getFullDriverUrl + driver_class). + String driverUrl = PaimonCatalogFactory.firstNonBlank( + properties, PaimonConnectorProperties.JDBC_DRIVER_URL); + if (driverUrl != null) { + Map env = context != null ? context.getEnvironment() : Collections.emptyMap(); + options.put("jdbc.driver_url", JdbcDriverSupport.resolveDriverUrl(driverUrl, env)); + String driverClass = PaimonCatalogFactory.firstNonBlank( + properties, PaimonConnectorProperties.JDBC_DRIVER_CLASS); + if (driverClass != null) { + options.put("jdbc.driver_class", driverClass); + } + } return options; } + /** + * The real data-file format of a {@link DataSplit}: the suffix of its FIRST data file (legacy + * {@code PaimonSplit} path = {@code "/" + dataFiles().get(0).fileName()}), falling back to the table + * default when the suffix is unrecognized or the split carries no data file. Ports legacy + * {@code PaimonScanNode.getFileFormat(getPathString())} for the JNI/COUNT arms, where HEAD had regressed + * to the bare table-level {@code file.format} default (wrong when the option differs from the on-disk + * files, e.g. an altered/mixed-format table). Package-private static so the suffix-over-default decision + * is unit-testable, like {@link #isCountPushdownSplit} / {@link #computeFileSplitOffsets}. + */ + static String dataSplitFileFormat(DataSplit dataSplit, String defaultFileFormat) { + List files = dataSplit.dataFiles(); + if (files == null || files.isEmpty()) { + return defaultFileFormat; + } + return getFileFormatBySuffix("/" + files.get(0).fileName()).orElse(defaultFileFormat); + } + private static Optional getFileFormatBySuffix(String path) { if (path == null) { return Optional.empty(); } String lower = path.toLowerCase(); - if (lower.endsWith(".orc")) { + if (lower.endsWith(".avro")) { + return Optional.of("avro"); + } else if (lower.endsWith(".orc")) { return Optional.of("orc"); } else if (lower.endsWith(".parquet") || lower.endsWith(".parq")) { return Optional.of("parquet"); @@ -373,6 +1369,394 @@ public void populateScanLevelParams(TFileScanRangeParams params, LOG.warn("Failed to parse paimon.options_json", e); } } + + // FIX-SCHEMA-EVOLUTION (B-1a): apply the schema dictionary built in getScanNodeProperties. Fail + // loud on a decode error — this prop is produced by us, so a failure is a real bug, and silently + // dropping it would re-introduce the silent wrong-rows BLOCKER on schema-evolved native reads. + String schemaEvolution = properties.get(SCHEMA_EVOLUTION_PROP); + if (schemaEvolution != null && !schemaEvolution.isEmpty()) { + applySchemaEvolutionParam(params, schemaEvolution); + } + } + + /** + * FIX-E (explain gap): re-emits the legacy {@code PaimonScanNode} EXPLAIN line + * {@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, + * 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. + */ + @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); + if (nativeSplits != null && totalSplits != null) { + output.append(prefix).append("paimonNativeReadSplits=") + .append(nativeSplits).append("/").append(totalSplits).append("\n"); + // FIX-A2 (explain gap): re-emit the legacy predicatesFromPaimon: block (the Paimon Predicate + // objects actually pushed to the SDK, or NONE) BETWEEN paimonNativeReadSplits= and the VERBOSE + // PaimonSplitStats block -- legacy order PaimonScanNode:657-671. It logically depends only on + // paimon.predicate and is nested in this native-splits block SOLELY so the legacy ordering + // holds (in a real EXPLAIN the synthetic split keys are always injected, so the gate always + // passes). The pushed list is already serialized into paimon.predicate (getScanNodeProperties: + // 579, always emitted), so deserialize+render it rather than re-converting (the filter is not + // in the seam). + String encodedPredicates = nodeProperties.get("paimon.predicate"); + if (encodedPredicates != null) { + appendPredicatesFromPaimon(output, prefix, encodedPredicates); + } + if (nodeProperties.containsKey(VERBOSE_EXPLAIN_KEY)) { + appendSplitStats(output, prefix, + Integer.parseInt(nativeSplits), Integer.parseInt(totalSplits)); + } + } + } + + /** + * FIX-A2 (explain gap): renders the legacy {@code predicatesFromPaimon:} EXPLAIN block from the + * {@code paimon.predicate} prop (the base64 {@link InstantiationUtil}-serialized + * {@code List} pushed to the SDK by {@link #getScanNodeProperties}). Lists each pushed + * predicate (double-prefix indented) or {@code NONE} when the list is empty, byte-faithful to + * {@code PaimonScanNode.java:660-668}. Diagnostic-only: surfaces a conjunct that + * {@link PaimonPredicateConverter} silently dropped (LTZ / FLOAT / unsupported CAST), so this can list + * fewer entries than the generic {@code PREDICATES:} line. A decode failure is logged and the line + * skipped -- it must never break EXPLAIN. + */ + @SuppressWarnings("unchecked") + private static void appendPredicatesFromPaimon(StringBuilder output, String prefix, String encoded) { + List predicates; + try { + // paimon.predicate is standard-Base64 by construction (encodeObjectToString -> BASE64_ENCODER + // = Base64.getEncoder()), so a standard decoder is the exact inverse. Decode with the paimon + // SDK's own classloader (the plugin CL that loaded Predicate), independent of the TCCL. + byte[] bytes = Base64.getDecoder().decode(encoded); + predicates = InstantiationUtil.deserializeObject( + bytes, org.apache.paimon.predicate.Predicate.class.getClassLoader()); + } catch (Exception e) { + // Diagnostic line only -- never break EXPLAIN. The prop is produced by us, so a decode failure + // is a real bug; log + skip rather than render a misleading NONE. + LOG.warn("Failed to decode paimon.predicate for EXPLAIN predicatesFromPaimon", e); + return; + } + if (predicates == null) { + // unexpected payload -- skip (do not render a misleading NONE), consistent with the catch path. + return; + } + output.append(prefix).append("predicatesFromPaimon:"); + if (predicates.isEmpty()) { + output.append(" NONE\n"); + } else { + output.append("\n"); + for (org.apache.paimon.predicate.Predicate predicate : predicates) { + output.append(prefix).append(prefix).append(predicate).append("\n"); + } + } + } + + /** + * FIX-E (explain gap): re-emits the legacy {@code PaimonScanNode} VERBOSE {@code PaimonSplitStats:} + * block — one {@code SplitStat [type=NATIVE|JNI]} line per split. The generic + * {@code PluginDrivenScanNode} retains only the native/total counts (not the per-split objects), and + * native files are re-split into multiple ranges on the SPI path, so exact per-{@code DataSplit} + * parity (rowCount/mergedRowCount/hasDeletionVector) is not reconstructible; the split TYPE is, which + * is what {@code paimon_data_system_table}'s assertNativePath/assertJniPath check. Lines are grouped + * NATIVE-first ({@code [0, native)} NATIVE, {@code [native, total)} JNI). Truncates beyond 4 splits + * exactly like legacy (first 3 + "... other N ..." + last) so VERBOSE output stays bounded. + */ + private void appendSplitStats(StringBuilder output, String prefix, int nativeCount, int total) { + output.append(prefix).append("PaimonSplitStats: \n"); + if (total <= 4) { + for (int i = 0; i < total; i++) { + output.append(prefix).append(" ").append(splitStatLine(i, nativeCount)).append("\n"); + } + } else { + for (int i = 0; i < 3; i++) { + output.append(prefix).append(" ").append(splitStatLine(i, nativeCount)).append("\n"); + } + output.append(prefix).append(" ... other ").append(total - 4) + .append(" paimon split stats ...\n"); + output.append(prefix).append(" ").append(splitStatLine(total - 1, nativeCount)).append("\n"); + } + } + + private static String splitStatLine(int index, int nativeCount) { + return "SplitStat [type=" + (index < nativeCount ? "NATIVE" : "JNI") + "]"; + } + + /** + * FIX-E (explain gap): reads the deletion-vector file path carried by one scan range's + * {@link TPaimonFileDesc}, for the VERBOSE per-backend EXPLAIN block + * ({@code deleteFileNum}/{@code deleteSplitNum}). Verbatim port of legacy + * {@code PaimonScanNode.getDeleteFiles} (reading {@code getPaimonParams().getDeletionFile() + * .getPath()}); the generic {@code PluginDrivenScanNode.getDeleteFiles(TFileRangeDesc)} delegates + * here. Returns empty when the range carries no paimon params or no deletion file. + */ + @Override + public List getDeleteFiles(TTableFormatFileDesc tableFormatParams) { + List deleteFiles = new ArrayList<>(); + if (tableFormatParams == null || !tableFormatParams.isSetPaimonParams()) { + return deleteFiles; + } + TPaimonFileDesc paimonParams = tableFormatParams.getPaimonParams(); + if (paimonParams == null || !paimonParams.isSetDeletionFile()) { + return deleteFiles; + } + TPaimonDeletionFileDesc deletionFile = paimonParams.getDeletionFile(); + if (deletionFile != null && deletionFile.isSetPath()) { + deleteFiles.add(deletionFile.getPath()); + } + return deleteFiles; + } + + /** + * FIX-SCHEMA-EVOLUTION (B-1a): builds the native-reader schema dictionary + * ({@code current_schema_id} + {@code history_schema_info}) for {@code table} and serializes it for + * transport via the scan-node props (see {@link #SCHEMA_EVOLUTION_PROP}). + * + *

    Returns empty for non-{@link FileStoreTable}s (paimon system tables such as {@code audit_log} / + * {@code binlog} read via JNI and never consult {@code history_schema_info}). The carrier is a + * throwaway {@link TFileScanRangeParams} (the exact thrift target), so + * {@link #applySchemaEvolutionParam} only has to copy the two fields back.

    + * + *

    Parity with legacy {@code PaimonScanNode}: {@code current_schema_id = -1} and the current/target + * schema is pushed under that sentinel. Crucially the -1 entry's top-level field set is built from the + * REQUESTED {@code columns} — the authoritative Doris slot list fe-core also turns into BE's + * {@code base_ctx->column_names} — NOT from an independent paimon-SDK schema read. This restores the + * legacy invariant ({@code PaimonScanNode.doInitialize} -> {@code ExternalUtil.initSchemaInfo(-1, + * getTargetTable().getColumns())}): the -1 entry's names == the scan-slot names BY CONSTRUCTION, so + * BE's {@code by_table_field_id} / {@code children_column_exists} lookup + * ({@code table_schema_change_helper.h:166}) can never miss when the FE-cached schema and the + * scan-time paimon schema skew. (CI 969249: a column added after the last snapshot was present in the + * FE slots but absent from the resolved {@code table.schema()} read, so the old "build the -1 entry + * from {@code table.schema()}" tripped the BE DCHECK and aborted the whole BE.) Each column's field id + * and nested type are matched BY NAME against the resolved (snapshot-pinned for time-travel, latest + * for plain) schema, with the fresh latest schema as a fallback (see + * {@link #resolveCurrentSchemaFields}). Per-schema historical entries are added for every committed + * schema id ({@link SchemaManager#listAllIds()}) so any native file's {@code schema_id} is covered (BE + * fails loud — {@code "miss table/file schema info"} — if a referenced id is absent). Schema reads + * that throw are allowed to propagate (fail loud, mirroring legacy {@code putHistorySchemaInfo}).

    + */ + private Optional buildSchemaEvolutionParam(PaimonTableHandle handle, Table table, + List columns) { + if (!(table instanceof FileStoreTable)) { + return Optional.empty(); + } + FileStoreTable fileStoreTable = (FileStoreTable) table; + SchemaManager schemaManager = fileStoreTable.schemaManager(); + + List history = new ArrayList<>(); + // Current/target schema under the -1 sentinel, keyed off the REQUESTED columns (see javadoc). Its + // top-level names are case-preserved (paimon-cased): BE keys the table-side StructNode by these names + // VERBATIM and the native reader looks them up by the case-preserved Doris slot name (#65094 read-path + // alignment — the slots from getColumnHandles now keep their paimon case). Nested + historical names + // stay paimon-cased (legacy PaimonUtil.getSchemaInfo). NOT memoized: it reads the LIVE + // table.schema()/latest() and is keyed off the requested columns, not a committed schema id. + history.add(buildSchemaInfo(CURRENT_SCHEMA_ID, + resolveCurrentSchemaFields(fileStoreTable, schemaManager, columns), false)); + // One entry per committed schema id so every native file's schema_id resolves. The EMISSION is + // unchanged (still every listAllIds() id -> the dict always covers any file's schema_id -> no + // BE-crash risk); only the per-id field READ is memoized (FIX-B-R2-be). A committed schemaId's + // schema- file is write-once, so the (handle, schemaId) cache value is immutable; the loader + // keeps the DIRECT read (not catalogOps.schemaAt) and a read that throws propagates uncached + // (fail-loud, mirroring legacy putHistorySchemaInfo). + for (Long schemaId : schemaManager.listAllIds()) { + List fields = schemaAtMemo.getOrLoad(handle, schemaId, () -> { + TableSchema ts = schemaManager.schema(schemaId); + return new PaimonCatalogOps.PaimonSchemaSnapshot( + ts.fields(), ts.partitionKeys(), ts.primaryKeys()); + }).fields(); + history.add(buildSchemaInfo(schemaId, fields, false)); + } + return Optional.of(encodeSchemaEvolution(CURRENT_SCHEMA_ID, history)); + } + + /** + * Resolves the current/target (-1 entry) field list from the requested {@code columns}, matching each + * to a paimon {@link DataField} BY NAME (case-insensitive). The resolved (snapshot-pinned) schema wins + * on a name collision so a time-travel read keys the pinned column names (and a renamed column resolves + * its pinned id before ever reaching the fallback); the fresh latest schema is consulted as a fallback + * so a column added after the last snapshot — present in the FE slots but lagging the resolved table + * instance (CI 969249) — is still carried with its real field id (an add-only column is then absent + * from older files and BE fills it NULL, the correct result). Keying off the requested columns rather + * than a paimon schema read is what guarantees the -1 entry's names equal BE's scan-slot names, the + * legacy invariant the field-id matcher relies on. When {@code columns} is empty (e.g. a count-only + * scan with no projected slots) there is nothing to mismatch, so it falls back to the resolved + * schema's fields. Fails loud if a requested column is in neither schema (a genuine FE/connector + * inconsistency) rather than silently dropping it. + */ + private static List resolveCurrentSchemaFields(FileStoreTable table, + SchemaManager schemaManager, List columns) { + List columnNames = new ArrayList<>(columns == null ? 0 : columns.size()); + if (columns != null) { + for (ConnectorColumnHandle handle : columns) { + columnNames.add(((PaimonColumnHandle) handle).getName()); + } + } + List latestFields = schemaManager.latest() + .map(TableSchema::fields).orElse(Collections.emptyList()); + return selectCurrentSchemaFields(table.schema().fields(), latestFields, columnNames); + } + + /** + * Pure field-selection core of {@link #resolveCurrentSchemaFields} (package-private for unit testing). + * Returns one {@link DataField} per requested {@code columnNames}, matched case-insensitively against + * {@code resolvedFields} first (so the snapshot-pinned schema wins, keeping time-travel + rename + * correct) then {@code latestFields} (so an add-column-after-snapshot column the resolved instance lags + * is still carried with its real field id). Empty {@code columnNames} (count-only scan) -> the resolved + * fields unchanged. Throws if a requested column is in neither schema (fail loud, not silent drop). + */ + static List selectCurrentSchemaFields(List resolvedFields, + List latestFields, List columnNames) { + if (columnNames == null || columnNames.isEmpty()) { + return resolvedFields; + } + Map byName = new HashMap<>(); + // Latest first, resolved second so the resolved (snapshot-pinned) field wins on a name collision. + for (DataField f : latestFields) { + byName.put(f.name().toLowerCase(Locale.ROOT), f); + } + for (DataField f : resolvedFields) { + byName.put(f.name().toLowerCase(Locale.ROOT), f); + } + List currentFields = new ArrayList<>(columnNames.size()); + for (String name : columnNames) { + DataField field = byName.get(name.toLowerCase(Locale.ROOT)); + if (field == null) { + throw new RuntimeException("paimon schema-evolution: requested column '" + name + + "' not found in the resolved or latest schema"); + } + currentFields.add(field); + } + return currentFields; + } + + /** + * Serializes the schema dictionary into a base64 TBinaryProtocol blob, carried by a throwaway + * {@link TFileScanRangeParams} (the exact thrift target so {@link #applySchemaEvolutionParam} only + * copies the two fields back). Package-private static for round-trip unit testing. + */ + static String encodeSchemaEvolution(long currentSchemaId, List history) { + TFileScanRangeParams carrier = new TFileScanRangeParams(); + carrier.setCurrentSchemaId(currentSchemaId); + carrier.setHistorySchemaInfo(history); + try { + byte[] bytes = new TSerializer(new TBinaryProtocol.Factory()).serialize(carrier); + return BASE64_ENCODER.encodeToString(bytes); + } catch (Exception | LinkageError e) { + // Catch LinkageError (e.g. IncompatibleClassChangeError from a thrift classloader split) too: + // wrapped as a RuntimeException it surfaces as a clean per-query failure instead of escaping + // the connection handler as an uncaught Error and killing the whole mysql session. + throw new RuntimeException("Failed to serialize paimon schema-evolution info", e); + } + } + + static void applySchemaEvolutionParam(TFileScanRangeParams params, String encoded) { + try { + byte[] bytes = Base64.getDecoder().decode(encoded); + TFileScanRangeParams carrier = new TFileScanRangeParams(); + new TDeserializer(new TBinaryProtocol.Factory()).deserialize(carrier, bytes); + if (carrier.isSetCurrentSchemaId()) { + params.setCurrentSchemaId(carrier.getCurrentSchemaId()); + } + if (carrier.isSetHistorySchemaInfo()) { + params.setHistorySchemaInfo(carrier.getHistorySchemaInfo()); + } + } catch (Exception e) { + throw new RuntimeException("Failed to apply paimon schema-evolution info to scan params", e); + } + } + + /** + * Builds one {@link TSchema} (schema id + root struct) from a paimon schema's top-level fields. + * Port of legacy {@code PaimonUtil.getSchemaInfo(TableSchema)} that emits only what BE's field-id + * matcher consumes ({@code TField.id} / {@code name} / a nested-vs-scalar {@code type.type} tag) — + * no Doris {@code Type} / {@code toColumnTypeThrift} needed (verified against + * {@code be/src/format/table/table_schema_change_helper.cpp}). + * + *

    {@code lowercaseTopLevelNames} lowercases ONLY the top-level field names (not nested struct + * fields) when set. Post-#65094 (read-path case alignment) both the current/target (-1) and historical + * entries pass {@code false}: top-level names stay case-preserved (paimon-cased) to byte-match the + * case-preserving Doris slot names BE keys by (the {@code getColumnHandles} slots + {@code parseSchema} + * now keep their remote case), while nested struct field names are always paimon-cased + * ({@code PaimonUtil.paimonTypeToDorisType} keeps them).

    + */ + static TSchema buildSchemaInfo(long schemaId, List fields, boolean lowercaseTopLevelNames) { + TSchema tSchema = new TSchema(); + tSchema.setSchemaId(schemaId); + tSchema.setRootField(buildStructField(fields, lowercaseTopLevelNames)); + return tSchema; + } + + private static TStructField buildStructField(List fields, boolean lowercaseNames) { + TStructField structField = new TStructField(); + for (DataField field : fields) { + // Field id + name are the join keys BE uses to match file<->table columns (rename-safe). + // Nested structs are always built paimon-cased (legacy parity) — only this level's names are + // optionally lowercased. + TField tField = buildField(field.type()); + // When lowercaseNames is set, lowercase the top-level name with the DEFAULT locale (NOT + // Locale.ROOT — that would diverge from the slot names under a non-ROOT JVM default locale). + // Post-#65094 production passes false, so the name is emitted case-preserved to byte-match the + // Doris slot names BE looks up (same casing PaimonConnectorMetadata column mapping produces). + tField.setName(lowercaseNames ? field.name().toLowerCase() : field.name()); + tField.setId(field.id()); + TFieldPtr fieldPtr = new TFieldPtr(); + fieldPtr.setFieldPtr(tField); + structField.addToFields(fieldPtr); + } + return structField; + } + + private static TField buildField(DataType dataType) { + TField field = new TField(); + field.setIsOptional(dataType.isNullable()); + TColumnType columnType = new TColumnType(); + TNestedField nestedField = new TNestedField(); + switch (dataType.getTypeRoot()) { + case ARRAY: { + columnType.setType(TPrimitiveType.ARRAY); + TArrayField arrayField = new TArrayField(); + TFieldPtr itemPtr = new TFieldPtr(); + itemPtr.setFieldPtr(buildField(((ArrayType) dataType).getElementType())); + arrayField.setItemField(itemPtr); + nestedField.setArrayField(arrayField); + field.setNestedField(nestedField); + break; + } + case MAP: { + columnType.setType(TPrimitiveType.MAP); + MapType mapType = (MapType) dataType; + TMapField mapField = new TMapField(); + TFieldPtr keyPtr = new TFieldPtr(); + keyPtr.setFieldPtr(buildField(mapType.getKeyType())); + mapField.setKeyField(keyPtr); + TFieldPtr valuePtr = new TFieldPtr(); + valuePtr.setFieldPtr(buildField(mapType.getValueType())); + mapField.setValueField(valuePtr); + nestedField.setMapField(mapField); + field.setNestedField(nestedField); + break; + } + case ROW: { + columnType.setType(TPrimitiveType.STRUCT); + // Nested struct field names stay paimon-cased (legacy PaimonUtil.paimonTypeToDorisType). + nestedField.setStructField(buildStructField(((RowType) dataType).getFields(), false)); + field.setNestedField(nestedField); + break; + } + default: + // Scalar: BE reads type.type only as a nested-vs-scalar discriminator (it never inspects + // the specific scalar tag in the field-id path), so a single placeholder is sufficient and + // avoids replicating the full paimon->Doris primitive mapping. + columnType.setType(TPrimitiveType.STRING); + break; + } + field.setType(columnType); + return field; } @Override @@ -380,6 +1764,30 @@ public String getSerializedTable(Map properties) { return properties.get("paimon.serialized_table"); } + /** + * Selects the split serialization that matches the BE reader the engine will use. + * When the paimon-cpp reader is enabled AND the split is a {@link DataSplit}, serialize with + * Paimon's NATIVE binary format ({@code DataSplit.serialize}) so BE's PaimonCppReader + * ({@code paimon::Split::Deserialize}) can decode it. Otherwise (flag off, or a non-DataSplit + * system split / no-raw-file fallback that has no native format) fall back to Java object + * serialization for the Java JNI reader. Mirrors legacy PaimonScanNode.setPaimonParams + + * PaimonUtil.encodeDataSplitToString; the {@code instanceof DataSplit} guard is load-bearing + * parity (non-DataSplit splits MUST stay Java-serialized even when the flag is on). + */ + static String encodeSplit(Split split, boolean cppReader) { + if (cppReader && split instanceof DataSplit) { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ((DataSplit) split).serialize(new DataOutputViewStreamWrapper(baos)); + return new String(BASE64_ENCODER.encode(baos.toByteArray()), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize Paimon DataSplit (native format): " + + e.getMessage(), e); + } + } + return encodeObjectToString(split); + } + @SuppressWarnings("unchecked") private static String encodeObjectToString(T obj) { try { 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 8c6e2b4ec98fdf..3d90f85df54914 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 @@ -17,13 +17,13 @@ package org.apache.doris.connector.paimon; -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.TPaimonDeletionFileDesc; import org.apache.doris.thrift.TPaimonFileDesc; +import org.apache.doris.thrift.TPaimonReaderType; import org.apache.doris.thrift.TTableFormatFileDesc; import java.util.ArrayList; @@ -60,6 +60,16 @@ public class PaimonScanRange implements ConnectorScanRange { private final Map partitionValues; private final Map properties; private final long selfSplitWeight; + // FIX-A1: weight denominator (legacy scan-level targetSplitSize, PaimonScanNode:499) for the FE + // FileSplit proportional weight. -1 = not provided (SPI sentinel). Separate from the file-splitting + // granularity used to slice native files. + private final long targetSplitSize; + // FIX-READER-TYPE (3645dc94306): true iff this JNI split was serialized with Paimon's native binary + // format for the paimon-cpp reader (cppReader && split instanceof DataSplit, mirrored from + // PaimonScanPlanProvider.encodeSplit). Selects PAIMON_CPP vs PAIMON_JNI in populateRangeParams's JNI + // branch; the native branch is always PAIMON_NATIVE. Defaults false (JNI) for non-cpp ranges. Cannot be + // re-derived in populateRangeParams — the serialized paimon.split string is opaque there. + private final boolean cppReaderSplit; private PaimonScanRange(Builder builder) { this.path = builder.path; @@ -68,6 +78,8 @@ private PaimonScanRange(Builder builder) { this.fileSize = builder.fileSize; this.fileFormat = builder.fileFormat; this.selfSplitWeight = builder.selfSplitWeight; + this.targetSplitSize = builder.targetSplitSize; + this.cppReaderSplit = builder.cppReaderSplit; this.partitionValues = builder.partitionValues != null ? Collections.unmodifiableMap(builder.partitionValues) : Collections.emptyMap(); @@ -90,7 +102,14 @@ private PaimonScanRange(Builder builder) { if (builder.rowCount != null) { props.put("paimon.row_count", String.valueOf(builder.rowCount)); } - if (builder.selfSplitWeight > 0) { + // FIX-A3: emit the self-split-weight for every JNI split, incl. weight 0. Legacy + // PaimonScanNode.setPaimonParams:274 sets it unconditionally on the JNI branch (never on + // native); the old `selfSplitWeight > 0` gate was a buggy is-set proxy that dropped a genuine + // weight-0 JNI split (rowCount-0 sys split / fileSize-0 DataSplit) -> BE read the -1 "unset" + // sentinel instead of 0, corrupting the _max_time_split_weight_counter profile. Gate on the + // JNI marker (paimonSplit) so native splits keep parity; this is also exactly when + // populateRangeParams reads the prop. + if (builder.paimonSplit != null) { props.put("paimon.self_split_weight", String.valueOf(builder.selfSplitWeight)); } this.properties = Collections.unmodifiableMap(props); @@ -141,10 +160,42 @@ public Map getProperties() { return properties; } + /** + * The precomputed COUNT(*) row count carried by this range (the {@code paimon.row_count} prop set + * by the count-pushdown collapse), or {@code -1} when absent. Drives the EXPLAIN + * {@code pushdown agg=COUNT (n)} line via {@code PluginDrivenScanNode}. Only the single collapsed + * count range carries it; every other range returns {@code -1}, preserving the {@code (-1)} + * no-precomputed-count sentinel (e.g. deletion-vector tables). + */ + @Override + public long getPushDownRowCount() { + String rowCountStr = properties.get("paimon.row_count"); + return rowCountStr != null ? Long.parseLong(rowCountStr) : -1; + } + + /** + * Whether this range takes BE's native (ORC/Parquet) reader: true iff it is NOT a JNI split + * (no {@code paimon.split} property — that property gates the JNI path in + * {@link #populateRangeParams}) AND it has a data-file path. Drives the native/total split + * accounting for the EXPLAIN {@code paimonNativeReadSplits=/} line. Under + * {@code force_jni_scanner=true} every range carries {@code paimon.split}, so all return false + * → native count 0. + */ + @Override + public boolean isNativeReadRange() { + return !properties.containsKey("paimon.split") && path != null; + } + + @Override public long getSelfSplitWeight() { return selfSplitWeight; } + @Override + public long getTargetSplitSize() { + return targetSplitSize; + } + @Override public String toString() { return "PaimonScanRange{path=" + path + ", format=" + fileFormat @@ -161,6 +212,10 @@ public void populateRangeParams(TTableFormatFileDesc formatDesc, if (paimonSplitVal != null) { // JNI reader path rangeDesc.setFormatType(TFileFormatType.FORMAT_JNI); + // FIX-READER-TYPE (3645dc94306): tell BE's file-scanner-v2 which paimon reader stack to use — + // the paimon-cpp reader (native binary split) vs the Java JNI reader. Mirrors legacy + // PaimonScanNode.setPaimonParams's fileDesc.setReaderType. + fileDesc.setReaderType(cppReaderSplit ? TPaimonReaderType.PAIMON_CPP : TPaimonReaderType.PAIMON_JNI); fileDesc.setPaimonSplit(paimonSplitVal); String tableLocation = props.get("paimon.table_location"); if (tableLocation != null) { @@ -172,6 +227,8 @@ public void populateRangeParams(TTableFormatFileDesc formatDesc, } } else { // Native reader path — format already set by file extension + // FIX-READER-TYPE (3645dc94306): native (ORC/Parquet) reader stack. + fileDesc.setReaderType(TPaimonReaderType.PAIMON_NATIVE); String fmt = getFileFormat(); if ("orc".equals(fmt)) { rangeDesc.setFormatType(TFileFormatType.FORMAT_ORC); @@ -214,15 +271,25 @@ 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()) { + // Paimon partition values are already TYPED: the per-type serializer + // (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. + String value = entry.getValue(); pathKeys.add(entry.getKey()); - pathValues.add(entry.getValue()); + pathValues.add(value != null ? value : ""); + pathIsNull.add(value == null); } - ConnectorPartitionValues.Normalized normalized = - ConnectorPartitionValues.normalize(pathValues); rangeDesc.setColumnsFromPathKeys(pathKeys); - rangeDesc.setColumnsFromPath(normalized.getValues()); - rangeDesc.setColumnsFromPathIsNull(normalized.getIsNull()); + rangeDesc.setColumnsFromPath(pathValues); + rangeDesc.setColumnsFromPathIsNull(pathIsNull); } } @@ -232,9 +299,18 @@ public static class Builder { private long start; private long length = -1; private long fileSize = -1; - private String fileFormat = "jni"; + // Every production caller sets fileFormat explicitly (the real orc/parquet). Default empty (NOT + // "jni", an invalid paimon format): BE's paimon_cpp_reader skips its FILE_FORMAT/MANIFEST_FORMAT + // backfill when this is empty (guarded !file_format.empty()), so a missing set can never inject an + // invalid format (FIX-JNI-FILE-FORMAT). + private String fileFormat = ""; private Map partitionValues; private long selfSplitWeight; + // -1 = not provided (SPI sentinel). NOT 0: a 0 denominator is invalid (would divide-by-zero), unlike + // selfSplitWeight whose 0 is a legitimate empty-file / 0-row weight. + private long targetSplitSize = -1; + // FIX-READER-TYPE (3645dc94306): see PaimonScanRange.cppReaderSplit. Only meaningful for JNI splits. + private boolean cppReaderSplit; // JNI reader fields private String paimonSplit; @@ -284,6 +360,16 @@ public Builder selfSplitWeight(long selfSplitWeight) { return this; } + public Builder targetSplitSize(long targetSplitSize) { + this.targetSplitSize = targetSplitSize; + return this; + } + + public Builder cppReaderSplit(boolean cppReaderSplit) { + this.cppReaderSplit = cppReaderSplit; + return this; + } + public Builder paimonSplit(String paimonSplit) { this.paimonSplit = paimonSplit; return this; diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonSchemaAtMemo.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonSchemaAtMemo.java new file mode 100644 index 00000000000000..1a48e70929ec06 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonSchemaAtMemo.java @@ -0,0 +1,175 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * Second-level memo for the time-travel schema-at-snapshot read (FIX-B-MC2). Restores the cross-query + * cache hit that the legacy catalog-level {@code PaimonExternalMetaCache} (keyed by + * {@code (NameMapping, schemaId)}) provided and that the SPI cutover dropped (review tag CACHE-P1). + * + *

    This memo lives on the long-lived per-catalog {@link PaimonConnector} (NOT on the per-query + * {@link PaimonConnectorMetadata}, which is rebuilt by {@code getMetadata} every query) and is injected + * into the metadata so the at-snapshot resolve can consult/populate it. It is cleared wholesale on + * REFRESH CATALOG (the connector is rebuilt → a fresh empty memo). + * + *

    Value = the raw {@link PaimonCatalogOps.PaimonSchemaSnapshot} (fields + partition-key + + * primary-key name lists) — the exact output of the {@link PaimonCatalogOps#schemaAt} schema-file read, + * which is a pure function of {@code (table-identity, schemaId)} because a committed paimon + * schemaId's schema content is write-once. The built {@code ConnectorTableSchema} is deliberately NOT + * cached: it embeds the live {@code coreOptions()} of the table, which are not keyed by schemaId and could + * go stale — so the metadata rebuilds it fresh per query from the live table while only the schema read is + * memoized. The single behavioral delta vs the pre-fix path is therefore "the {@code schemaAt} read is + * skipped on a repeat"; everything else is unchanged. + * + *

    No performance regression (by construction): on a miss the loader runs exactly as before plus + * an O(1) put; on a hit the {@code schemaAt} read is skipped (strictly faster); on overflow/eviction or a + * concurrent same-key double-load the value is simply re-read (= the pre-fix behavior). The value is + * immutable, so a cached entry is safe to share across queries and a flush never yields a stale read. + */ +final class PaimonSchemaAtMemo { + + /** Default best-effort bound; the keyspace (table, branch, schemaId) is naturally tiny. */ + static final int DEFAULT_MAX_SIZE = 10000; + + private final Map cache = new ConcurrentHashMap<>(); + private final int maxSize; + + PaimonSchemaAtMemo(int maxSize) { + this.maxSize = maxSize; + } + + /** + * Returns the schema-at-snapshot for {@code (handle, schemaId)}, loading it via {@code loader} (the + * {@link PaimonCatalogOps#schemaAt} read) only on a miss. + * + *

    The loader runs OUTSIDE any lock (no I/O under a lock; not {@code computeIfAbsent}). A concurrent + * same-key miss may load twice — harmless because the value is immutable and identical, and it equals + * the pre-fix per-query double load. A loader exception propagates before any insert, so failures are + * never negative-cached. + */ + PaimonCatalogOps.PaimonSchemaSnapshot getOrLoad(PaimonTableHandle handle, long schemaId, + Supplier loader) { + MemoKey key = new MemoKey(handle, schemaId); + PaimonCatalogOps.PaimonSchemaSnapshot hit = cache.get(key); + if (hit != null) { + return hit; + } + PaimonCatalogOps.PaimonSchemaSnapshot loaded = loader.get(); + // Best-effort size bound (honors the "bounded memo" requirement). The keyspace is + // (table, branch, schemaId) — naturally tiny — so this valve effectively never fires; values are + // immutable, so flushing only causes re-reads (= the pre-fix behavior), never a stale/wrong value. + if (cache.size() >= maxSize) { + cache.clear(); + } + PaimonCatalogOps.PaimonSchemaSnapshot prev = cache.putIfAbsent(key, loaded); + return prev != null ? prev : loaded; + } + + /** Test-only: current number of cached entries. */ + int size() { + return cache.size(); + } + + /** + * Drop every memoized schema for {@code (db, table)} across all schemaIds / sys-tables / branches. Wired + * onto {@code REFRESH TABLE} and — via the generic {@code PluginDrivenExternalCatalog} DDL hook — onto a + * Doris-issued DROP/CREATE of the same name, so a drop+recreate that reuses a schemaId (e.g. schema 0) + * with different content does not serve a stale time-travel schema. The memo value is immutable, so + * dropping an entry only forces a re-read (the pre-memo behavior), never a stale/wrong value. + */ + void invalidate(String databaseName, String tableName) { + cache.keySet().removeIf(key -> key.matches(databaseName, tableName)); + } + + /** + * Drop every memoized schema for database {@code databaseName} across all its tables / schemaIds / + * sys-tables / branches. Wired onto {@code REFRESH DATABASE} and — via the generic + * {@code PluginDrivenExternalCatalog} dropDb hook — onto a Doris-issued {@code DROP DATABASE} of the + * same name (incl. its FORCE table cascade), so a drop+recreate of a table in that db that reuses a + * schemaId does not serve a stale time-travel schema. Db-scoped analogue of {@link #invalidate}. + */ + void invalidateDb(String databaseName) { + cache.keySet().removeIf(key -> key.matchesDb(databaseName)); + } + + /** Drop the whole memo. Wired onto {@code REFRESH CATALOG} (alongside the connector rebuild). */ + void invalidateAll() { + cache.clear(); + } + + /** + * Cache key = the handle's identity (db, table, sysTableName, branchName) plus the pinned schemaId. + * + *

    The four identity fields MIRROR {@link PaimonTableHandle#equals}/{@link PaimonTableHandle#hashCode} + * (PaimonTableHandle:233-240). They are stored as extracted values rather than a retained + * {@link PaimonTableHandle} reference ON PURPOSE: a handle carries its loaded paimon {@code Table} + * (set via {@code setPaimonTable}), so keying on the handle would pin that {@code Table} in the cache + * for its lifetime. If {@code PaimonTableHandle}'s identity ever gains a field, mirror it here too. + */ + static final class MemoKey { + private final String databaseName; + private final String tableName; + private final String sysTableName; + private final String branchName; + private final long schemaId; + + MemoKey(PaimonTableHandle handle, long schemaId) { + this.databaseName = handle.getDatabaseName(); + this.tableName = handle.getTableName(); + this.sysTableName = handle.getSysTableName(); + this.branchName = handle.getBranchName(); + this.schemaId = schemaId; + } + + /** True if this key belongs to {@code (db, table)} (any schemaId / sys-table / branch). */ + boolean matches(String db, String table) { + return databaseName.equals(db) && tableName.equals(table); + } + + /** True if this key belongs to database {@code db} (any table / schemaId / sys-table / branch). */ + boolean matchesDb(String db) { + return databaseName.equals(db); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof MemoKey)) { + return false; + } + MemoKey that = (MemoKey) o; + return schemaId == that.schemaId + && databaseName.equals(that.databaseName) + && tableName.equals(that.tableName) + && Objects.equals(sysTableName, that.sysTableName) + && Objects.equals(branchName, that.branchName); + } + + @Override + public int hashCode() { + return Objects.hash(databaseName, tableName, sysTableName, branchName, schemaId); + } + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonSchemaBuilder.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonSchemaBuilder.java new file mode 100644 index 00000000000000..8d35fe422a2f4b --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonSchemaBuilder.java @@ -0,0 +1,167 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.ddl.ConnectorCreateTableRequest; +import org.apache.doris.connector.api.ddl.ConnectorPartitionField; +import org.apache.doris.connector.api.ddl.ConnectorPartitionSpec; + +import org.apache.paimon.CoreOptions; +import org.apache.paimon.schema.Schema; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Builds a Paimon {@link Schema} from a connector-SPI + * {@link ConnectorCreateTableRequest}. + * + *

    Functional port of the legacy fe-core + * {@code PaimonMetadataOps.toPaimonSchema}: primary keys come from + * {@code properties["primary-key"]}, partition keys come from the + * {@link ConnectorPartitionSpec} (identity transforms only), {@code "primary-key"} and + * {@code "comment"} are stripped from the option map, and {@code "location"} is re-keyed + * to {@link CoreOptions#PATH}. Bucket / distribution info is intentionally NOT consumed — + * legacy paimon ignored bucketSpec and let any {@code bucket} option ride through + * unchanged as a passthrough option.

    + * + *

    Two deliberate, safer divergences from the legacy bytes (each documented + tested at + * its call site): the table comment falls back to {@link ConnectorCreateTableRequest#getComment()} + * (the {@code COMMENT} clause) when {@code properties["comment"]} is absent — legacy read only + * the property and silently dropped the clause; and blank primary-key tokens are filtered out — + * legacy would have forwarded an empty name that Paimon rejects downstream.

    + */ +public final class PaimonSchemaBuilder { + + private static final String PRIMARY_KEY_IDENTIFIER = "primary-key"; + private static final String PROP_COMMENT = "comment"; + private static final String PROP_LOCATION = "location"; + private static final String IDENTITY_TRANSFORM = "identity"; + + private PaimonSchemaBuilder() { + } + + /** + * Convert a CREATE TABLE request into a Paimon {@link Schema}. + * + * @throws DorisConnectorException if a partition field uses a non-identity transform + * or a column type cannot be represented in Paimon + */ + public static Schema build(ConnectorCreateTableRequest request) { + Map properties = request.getProperties(); + + // primary keys: from properties["primary-key"] only (no dedicated request field), + // split on comma, trimmed, blanks dropped. Mirrors legacy toPaimonSchema. + String pkAsString = properties.get(PRIMARY_KEY_IDENTIFIER); + List primaryKeys = pkAsString == null + ? Collections.emptyList() + : Arrays.stream(pkAsString.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + + List partitionKeys = partitionKeys(request.getPartitionSpec()); + + // #65094: resolve primary-key / partition-key names back to the schema's canonical + // (case-preserving) column-name spelling, matching case-insensitively. The schema columns keep + // their original case (col.getName(), below); Paimon's Schema.Builder validates primary/partition + // keys case-sensitively, so a case-mismatched DDL key would otherwise fail table creation. + List columnNames = request.getColumns().stream() + .map(ConnectorColumn::getName) + .collect(Collectors.toList()); + primaryKeys = resolveColumnNames(columnNames, primaryKeys); + partitionKeys = resolveColumnNames(columnNames, partitionKeys); + + // options normalization: drop primary-key/comment, re-key location -> CoreOptions.PATH. + Map normalizedOptions = new HashMap<>(properties); + normalizedOptions.remove(PRIMARY_KEY_IDENTIFIER); + normalizedOptions.remove(PROP_COMMENT); + if (normalizedOptions.containsKey(PROP_LOCATION)) { + String path = normalizedOptions.remove(PROP_LOCATION); + normalizedOptions.put(CoreOptions.PATH.key(), path); + } + + // comment resolution: legacy toPaimonSchema read ONLY properties["comment"] (the nereids + // PROPERTIES("comment"=...) map); the dedicated COMMENT clause never reached it. The SPI + // converter (CreateTableInfoToConnectorRequestConverter.convert) sets request.getComment() + // from CreateTableInfo.getComment() (the COMMENT clause) and request.getProperties() from + // CreateTableInfo.getProperties() (the PROPERTIES map) — two distinct nereids fields. + // Resolution: properties["comment"] wins (preserves legacy persisted-comment behavior), + // else fall back to request.getComment() so a user's COMMENT clause is not silently dropped. + String comment = properties.containsKey(PROP_COMMENT) + ? properties.get(PROP_COMMENT) + : request.getComment(); + + Schema.Builder builder = Schema.newBuilder() + .options(normalizedOptions) + .primaryKey(primaryKeys) + .partitionKeys(partitionKeys) + .comment(comment); + for (ConnectorColumn col : request.getColumns()) { + // Column-level nullability applied here via copy(nullable), mirroring legacy + // toPaimonSchema's toPaimontype(type).copy(field.getContainsNull()). + builder.column(col.getName(), + PaimonTypeMapping.toPaimonType(col.getType()).copy(col.isNullable()), + col.getComment()); + } + return builder.build(); + } + + private static List partitionKeys(ConnectorPartitionSpec spec) { + if (spec == null) { + return Collections.emptyList(); + } + List keys = new ArrayList<>(spec.getFields().size()); + for (ConnectorPartitionField field : spec.getFields()) { + String transform = field.getTransform(); + // Paimon legacy only supported plain (identity) partition columns. Guard mirrors + // MaxComputeConnectorMetadata.identityPartitionColumns. transform is @NonNull on + // ConnectorPartitionField, so only the value matters. + if (transform != null && !IDENTITY_TRANSFORM.equalsIgnoreCase(transform)) { + throw new DorisConnectorException( + "Paimon only supports identity partition columns, got transform: " + transform); + } + keys.add(field.getColumnName()); + } + return keys; + } + + /** + * Resolves external key names (primary key / partition key) back to the canonical, case-preserving + * column-name spelling, matching case-insensitively. #65094: the schema keeps each column's original + * case ({@code col.getName()}); Paimon's {@link Schema.Builder} validates primary/partition keys + * case-sensitively, so a case-mismatched DDL key must be mapped back to the canonical name. + * Mirrors {@code PaimonMetadataOps.getPaimonColumnNames}. A key with no case-insensitive match is + * left unchanged (Paimon then reports the missing column). + */ + private static List resolveColumnNames(List columnNames, List keyNames) { + Map byLowerName = columnNames.stream() + .collect(Collectors.toMap(name -> name.toLowerCase(Locale.ROOT), name -> name, (a, b) -> a)); + return keyNames.stream() + .map(name -> byLowerName.getOrDefault(name.toLowerCase(Locale.ROOT), name)) + .collect(Collectors.toList()); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTableHandle.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTableHandle.java index e9c7c7b2d00dff..0d4738e211f538 100644 --- a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTableHandle.java +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTableHandle.java @@ -21,12 +21,30 @@ import org.apache.paimon.table.Table; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; /** * Opaque table handle for Paimon tables. * Carries database name, table name, partition key names, and the Paimon Table reference. + * + *

    A handle may also represent a system table (e.g. {@code mytable$snapshots}). For a + * system handle {@link #sysTableName} is the bare sys-table name (no {@code "$"}) and + * {@link #isSystemTable()} returns true; {@link #forceJni} carries the name-forced JNI hint + * computed by {@link PaimonConnectorMetadata#getSysTableHandle}. This class is Java + * {@link java.io.Serializable} only (there is no GSON registration for it): {@link #sysTableName}, + * {@link #forceJni}, {@link #scanOptions} and {@link #branchName} are non-transient so they survive + * a Java serialization round-trip, while the resolved {@link Table} stays {@code transient} and is + * re-loaded (a sys handle via the 4-arg sys {@code Identifier}, a branch handle via the 3-arg branch + * {@code Identifier}) when null. Normal handles keep {@code sysTableName == null}, + * {@code forceJni == false} and {@code branchName == null}. + * + *

    {@link #scanOptions} carries paimon scan options (e.g. {@code {"scan.snapshot-id": "5"}}) for + * a time-travel / MVCC-pinned read. It is empty for a normal/sys handle; a pinned handle is built + * via {@link #withScanOptions(Map)} and the scan path applies it with {@code Table.copy(options)}. */ public class PaimonTableHandle implements ConnectorTableHandle { @@ -37,15 +55,84 @@ public class PaimonTableHandle implements ConnectorTableHandle { private final List partitionKeys; private final List primaryKeys; + /** + * Bare system-table name (no {@code "$"}), or {@code null} for a normal table handle. + * Serializable: a deserialized sys handle must still reload via the 4-arg sys Identifier. + */ + private final String sysTableName; + + /** + * Branch name for a branch time-travel read ({@code @branch('name')}), or {@code null} for a + * normal/base handle. A branch is a DIFFERENT table identity than its base (independent schema + + * snapshots), so it is part of {@link #equals}/{@link #hashCode} (exactly like {@link + * #sysTableName}) and a non-null branch reloads via the 3-arg branch Identifier (see + * {@link PaimonTableResolver#resolve}). Serializable: a deserialized branch handle must still + * reload the branch table. Branch and sys are mutually exclusive in practice. + */ + private final String branchName; + + /** + * Name-forced JNI hint for system tables (legacy parity: true only for {@code binlog} / + * {@code audit_log}). Always {@code false} for a normal handle. Serializable. + */ + private final boolean forceJni; + + /** + * Paimon scan options for a time-travel / MVCC-pinned read (e.g. {@code scan.snapshot-id=5}). + * Empty for a normal/sys handle; populated only via {@link #withScanOptions(Map)} when the + * engine threads a pinned snapshot in. Serializable (survives the FE/BE round-trip) so the JNI + * serialized-table read pins to the same version as the planned splits. + */ + private final Map scanOptions; + /** Transient Paimon Table reference; not serialized. Set by PaimonConnectorMetadata. */ private transient Table paimonTable; public PaimonTableHandle(String databaseName, String tableName, List partitionKeys, List primaryKeys) { + this(databaseName, tableName, partitionKeys, primaryKeys, null, false); + } + + /** + * Full constructor including the system-table fields. Use + * {@link #forSystemTable(String, String, String, boolean)} to build a sys handle. scanOptions + * defaults to empty (a normal/sys handle is not snapshot-pinned). + */ + public PaimonTableHandle(String databaseName, String tableName, + List partitionKeys, List primaryKeys, + String sysTableName, boolean forceJni) { + this(databaseName, tableName, partitionKeys, primaryKeys, sysTableName, forceJni, + Collections.emptyMap(), null); + } + + private PaimonTableHandle(String databaseName, String tableName, + List partitionKeys, List primaryKeys, + String sysTableName, boolean forceJni, Map scanOptions, + String branchName) { this.databaseName = Objects.requireNonNull(databaseName, "databaseName"); this.tableName = Objects.requireNonNull(tableName, "tableName"); this.partitionKeys = partitionKeys; this.primaryKeys = primaryKeys; + this.sysTableName = sysTableName; + this.forceJni = forceJni; + this.branchName = branchName; + // Defensive immutable copy (codebase convention, cf. ConnectorPartitionInfo / + // ConnectorMvccSnapshot): the HashMap-backed unmodifiable map stays Serializable so the + // Java-serialization round-trip is preserved. + this.scanOptions = scanOptions == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(scanOptions)); + } + + /** + * Builds a system-table handle for {@code db.table$sysTableName}. Partition/primary keys are + * empty: system tables are scanned as their own (synthetic) tables, not as partitions of the + * base table. + */ + public static PaimonTableHandle forSystemTable(String databaseName, String tableName, + String sysTableName, boolean forceJni) { + return new PaimonTableHandle(databaseName, tableName, + Collections.emptyList(), Collections.emptyList(), sysTableName, forceJni); } public String getDatabaseName() { @@ -64,6 +151,61 @@ public List getPrimaryKeys() { return primaryKeys; } + /** Bare system-table name (no {@code "$"}), or {@code null} for a normal handle. */ + public String getSysTableName() { + return sysTableName; + } + + /** True when this handle represents a Paimon system table. */ + public boolean isSystemTable() { + return sysTableName != null; + } + + /** Branch name for a branch time-travel read, or {@code null} for a normal/base handle. */ + public String getBranchName() { + return branchName; + } + + /** Name-forced JNI hint (true only for {@code binlog} / {@code audit_log} sys tables). */ + public boolean isForceJni() { + return forceJni; + } + + /** Paimon scan options for a pinned read (empty for a normal/sys handle). */ + public Map getScanOptions() { + return scanOptions; + } + + /** + * Returns a NEW handle identical to this one (db/table/partitionKeys/primaryKeys/sysTableName/ + * forceJni/branchName and the transient {@link Table} are preserved) but carrying the given + * scanOptions — the snapshot-pinned read variant. The transient Table is copied over as-is; the + * scan path applies {@code Table.copy(scanOptions)} at resolution time. branchName is preserved + * because it is part of the handle identity. + */ + public PaimonTableHandle withScanOptions(Map options) { + PaimonTableHandle copy = new PaimonTableHandle(databaseName, tableName, + partitionKeys, primaryKeys, sysTableName, forceJni, options, branchName); + copy.paimonTable = this.paimonTable; + return copy; + } + + /** + * Returns a NEW handle identical to this one (db/table/partitionKeys/primaryKeys/sysTableName/ + * forceJni/scanOptions preserved) but carrying the given {@code branchName} — the branch + * time-travel read variant. + * + *

    CRITICAL: unlike {@link #withScanOptions(Map)}, this does NOT copy the transient + * {@link Table} over. A branch is a DIFFERENT table (independent schema + snapshots), so the + * transient reference is left {@code null} and {@link PaimonTableResolver#resolve} reloads the + * BRANCH table via the 3-arg branch Identifier. Copying the base Table over would make the + * branch read return the BASE table's rows — a silent data error. + */ + public PaimonTableHandle withBranch(String branchName) { + return new PaimonTableHandle(databaseName, tableName, + partitionKeys, primaryKeys, sysTableName, forceJni, scanOptions, branchName); + } + /** Returns the transient Paimon Table reference, or null if not set. */ public Table getPaimonTable() { return paimonTable; @@ -83,16 +225,26 @@ public boolean equals(Object o) { return false; } PaimonTableHandle that = (PaimonTableHandle) o; - return databaseName.equals(that.databaseName) && tableName.equals(that.tableName); + // sysTableName AND branchName are part of identity: a sys handle (db.table$snapshots) never + // equals its base handle (db.table), and a branch handle (db.table@branch) never equals its + // base — all are distinct tables to the engine (independent schema/snapshots). scanOptions is + // intentionally NOT part of identity: a snapshot-pinned handle is the SAME table, just read + // at a version, so it must equal/hash identically to its base handle. + return databaseName.equals(that.databaseName) && tableName.equals(that.tableName) + && Objects.equals(sysTableName, that.sysTableName) + && Objects.equals(branchName, that.branchName); } @Override public int hashCode() { - return Objects.hash(databaseName, tableName); + return Objects.hash(databaseName, tableName, sysTableName, branchName); } @Override public String toString() { - return databaseName + "." + tableName; + String base = sysTableName == null + ? databaseName + "." + tableName + : databaseName + "." + tableName + "$" + sysTableName; + return branchName == null ? base : base + "@" + branchName; } } diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTableResolver.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTableResolver.java new file mode 100644 index 00000000000000..39cf9d9575b3d2 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTableResolver.java @@ -0,0 +1,87 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.paimon.catalog.Identifier; +import org.apache.paimon.table.Table; + +/** + * Single sys-aware handle-to-{@link Table} resolver shared by the metadata read path + * ({@link PaimonConnectorMetadata}) and the scan path ({@link PaimonScanPlanProvider}). + * + *

    Both call sites used to carry their own reload-fallback. They diverged: the metadata twin was + * made sys-aware (T17) while the scan twin still reloaded the BASE table for every handle — so a + * deserialized system handle (transient {@link Table} lost) would silently resolve and scan the + * base table, returning wrong rows. Collapsing both into THIS one method removes that trap: there + * is exactly one reload rule and it is sys-aware. + * + *

    Contract: prefer the handle's transient {@link Table}; on null reload from the catalog seam — + * a {@linkplain PaimonTableHandle#isSystemTable() system handle} via the 4-arg sys + * {@link Identifier} {@code (db, table, "main", sysName)} (so the SYSTEM table is re-fetched, not + * the base table), a {@linkplain PaimonTableHandle#getBranchName() branch handle} via the 3-arg + * branch {@link Identifier} {@code (db, table, branch)} (so the BRANCH table — independent schema + + * snapshots — is fetched, not the base table), a normal handle via the 2-arg + * {@code Identifier.create(db, table)}. + * + *

    NOTE: this resolver only picks the correct (sys) Table on reload. It does NOT do + * {@code forceJni} native-vs-JNI routing or fail-loud guards — those remain T19. + */ +final class PaimonTableResolver { + + private PaimonTableResolver() { + } + + /** + * Returns the handle's transient Paimon {@link Table}, or reloads it from {@code catalogOps} + * when the transient reference is null (e.g. after a serialization round-trip across the FE/BE + * boundary or plan reuse). A system handle reloads via the 4-arg sys {@link Identifier}; a + * branch handle via the 3-arg branch {@link Identifier}; a normal handle via the 2-arg base + * {@link Identifier}. + * + *

    This method does NOT wrap the reload failure: each call site keeps its own + * exception-handling/wrapping. The only checked surface is the seam's + * {@link org.apache.paimon.catalog.Catalog.TableNotExistException}. + * + * @throws org.apache.paimon.catalog.Catalog.TableNotExistException if the seam reports the + * table is gone (callers wrap/translate as they did before). + */ + static Table resolve(PaimonCatalogOps catalogOps, PaimonTableHandle handle) + throws org.apache.paimon.catalog.Catalog.TableNotExistException { + Table table = handle.getPaimonTable(); + if (table != null) { + return table; + } + // Fallback reload. A sys handle MUST reload via the 4-arg sys Identifier so the SYSTEM + // table is re-fetched, not the base table. A branch handle MUST reload via the 3-arg branch + // Identifier so the BRANCH table (independent schema/snapshots) is fetched, not the base. + Identifier id; + if (handle.isSystemTable()) { + id = new Identifier(handle.getDatabaseName(), handle.getTableName(), + "main", handle.getSysTableName()); + } else if (handle.getBranchName() != null) { + // A branch read loads a DIFFERENT table (independent schema/snapshots) via the 3-arg + // branch Identifier, mirroring legacy + // PaimonExternalCatalog.getPaimonTable(mapping, branch, null). + id = new Identifier(handle.getDatabaseName(), handle.getTableName(), + handle.getBranchName()); + } else { + id = Identifier.create(handle.getDatabaseName(), handle.getTableName()); + } + return catalogOps.getTable(id); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTypeMapping.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTypeMapping.java index 6e47e26ca0d73f..f60a505bf20c2e 100644 --- a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTypeMapping.java +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTypeMapping.java @@ -18,22 +18,32 @@ package org.apache.doris.connector.paimon; import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; import org.apache.paimon.types.ArrayType; +import org.apache.paimon.types.BigIntType; import org.apache.paimon.types.BinaryType; +import org.apache.paimon.types.BooleanType; import org.apache.paimon.types.CharType; import org.apache.paimon.types.DataField; import org.apache.paimon.types.DataType; +import org.apache.paimon.types.DateType; import org.apache.paimon.types.DecimalType; +import org.apache.paimon.types.DoubleType; +import org.apache.paimon.types.FloatType; +import org.apache.paimon.types.IntType; import org.apache.paimon.types.LocalZonedTimestampType; import org.apache.paimon.types.MapType; import org.apache.paimon.types.RowType; import org.apache.paimon.types.TimestampType; import org.apache.paimon.types.VarBinaryType; import org.apache.paimon.types.VarCharType; +import org.apache.paimon.types.VariantType; import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; /** @@ -102,7 +112,11 @@ public static ConnectorType toConnectorType(DataType dataType, Options options) private static ConnectorType toVarcharType(VarCharType type) { int len = type.getLength(); - if (len <= 0 || len >= 65533) { + // 65533 == ScalarType.MAX_VARCHAR_LENGTH is the legal exact-fit max VARCHAR, not the STRING + // wildcard; only a length strictly greater than it overflows to STRING. Use `> 65533` to + // match legacy PaimonUtil.paimonPrimitiveTypeToDorisType byte-for-byte (the `len <= 0` guard + // is unreachable for real paimon — VarCharType min length is 1 — kept defensively). + if (len <= 0 || len > 65533) { return ConnectorType.of("STRING"); } return ConnectorType.of("VARCHAR", len, 0); @@ -177,6 +191,102 @@ private static ConnectorType toStructType(RowType rowType, Options options) { return ConnectorType.structOf(names, types); } + /** + * Convert a Doris {@link ConnectorType} (as produced by the CREATE TABLE request path) + * to a Paimon {@link DataType}. + * + *

    This is the faithful reverse of the legacy fe-core + * {@code DorisToPaimonTypeVisitor}: the scalar set is intentionally narrow (it mirrors + * the visitor's {@code atomic} branches and NOT MaxCompute's richer set), CHAR/VARCHAR/STRING + * all collapse to {@code VarChar(MAX)} (declared length dropped), DATETIME/DATETIMEV2 map to a + * plain {@code TimestampType()} (scale dropped), and the MAP key is forced non-null. Types the + * legacy visitor did not handle (TINYINT, SMALLINT, LARGEINT, TIME, IPV4/6, JSON, ...) throw, + * preserving the legacy gap.

    + * + *

    The returned type carries Paimon's default (nullable) flag; column-level nullability is + * applied by the caller via {@code .copy(nullable)} (mirroring legacy + * {@code PaimonMetadataOps.toPaimonSchema}). The map-key {@code .copy(false)} below is part of + * the type structure (not column nullability) and is kept.

    + * + * @throws DorisConnectorException if the type cannot be represented in Paimon + */ + public static DataType toPaimonType(ConnectorType type) { + String name = type.getTypeName().toUpperCase(Locale.ROOT); + switch (name) { + case "BOOLEAN": + return new BooleanType(); + case "INT": + case "INTEGER": + return new IntType(); + case "BIGINT": + return new BigIntType(); + case "FLOAT": + return new FloatType(); + case "DOUBLE": + return new DoubleType(); + case "CHAR": + case "VARCHAR": + case "STRING": + // Legacy parity: all char-family types collapse to VarChar(MAX); declared + // length is intentionally dropped (DorisToPaimonTypeVisitor.atomic isCharFamily). + return new VarCharType(VarCharType.MAX_LENGTH); + case "DATE": + case "DATEV2": + return new DateType(); + case "DECIMALV2": + case "DECIMALV3": + case "DECIMAL32": + case "DECIMAL64": + case "DECIMAL128": + case "DECIMAL256": + return new DecimalType(type.getPrecision(), type.getScale()); + case "DATETIME": + case "DATETIMEV2": + // Legacy parity: no-arg TimestampType (precision defaults to 6); the datetime + // scale is intentionally dropped to match DorisToPaimonTypeVisitor.atomic, and it + // is a plain timestamp (NOT LocalZonedTimestampType). + return new TimestampType(); + case "VARBINARY": + return new VarBinaryType(VarBinaryType.MAX_LENGTH); + case "VARIANT": + return new VariantType(); + case "ARRAY": + // FIX-L13: preserve the declared element nullability (legacy DorisToPaimonTypeVisitor + // array = elementResult.copy(array.getContainsNull())). + return new ArrayType( + toPaimonType(type.getChildren().get(0)).copy(type.isChildNullable(0))); + case "MAP": + // Legacy forces the map key non-null via .copy(false); the value preserves the declared + // nullability (FIX-L13: legacy map = valueResult.copy(map.getIsValueContainsNull())). + return new MapType( + toPaimonType(type.getChildren().get(0)).copy(false), + toPaimonType(type.getChildren().get(1)).copy(type.isChildNullable(1))); + case "STRUCT": + case "ROW": + return toPaimonRowType(type); + default: + throw new DorisConnectorException( + "Unsupported type for Paimon: " + type.getTypeName()); + } + } + + private static DataType toPaimonRowType(ConnectorType type) { + List children = type.getChildren(); + List names = type.getFieldNames(); + List fields = new ArrayList<>(children.size()); + // Legacy uses new AtomicInteger(-1).incrementAndGet() -> sequential ids 0,1,2,... + AtomicInteger fieldId = new AtomicInteger(-1); + for (int i = 0; i < children.size(); i++) { + String fieldName = i < names.size() && names.get(i) != null ? names.get(i) : "col" + i; + // FIX-L13: preserve the declared field nullability (legacy struct = + // fieldResults.get(i).copy(field.getContainsNull())). The field comment stays dropped + // (accepted display-only deviation DV-035 M10.1) and the field id stays sequential (legacy parity). + fields.add(new DataField(fieldId.incrementAndGet(), fieldName, + toPaimonType(children.get(i)).copy(type.isChildNullable(i)))); + } + return new RowType(fields); + } + /** * Type mapping options. */ 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 new file mode 100644 index 00000000000000..8dbb4e856318bd --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/TcclPinningConnectorContext.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.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.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 + * 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}). + * + *

    TCCL: the pin keeps reflective loads on the plugin side for the duration of each op. The paimon plugin + * bundles paimon-core + {@code hadoop-common}/{@code hadoop-hdfs-client} child-first, so any name-based + * reflective load that defaults to the thread-context classloader would otherwise resolve the parent (fe-core) + * copy and ClassCast against the child-loaded plugin copy — the same split-brain guard the iceberg connector + * applies. The pin is harmless for pure reads (it just runs them under the plugin loader). + * + *

    KERBEROS (single-owner auth): for a Kerberos catalog {@code pluginAuthenticator} supplies a plugin-side + * {@link HadoopAuthenticator} and the op runs inside its {@code doAs}. This is REQUIRED because the plugin + * bundles its own {@code hadoop-common} + {@code fe-kerberos} child-first, so the plugin's HDFS + * {@code FileSystem} reads a DIFFERENT {@code UserGroupInformation} copy than the one the FE-injected + * authenticator (built app-side by the fe-core {@code MetastoreProperties}) logs in — the app-side + * {@code doAs} therefore never reaches the plugin FileSystem, which falls back to SIMPLE auth. The connector + * is the only party that knows which UGI copy its FileSystem uses, so it owns the auth: on the Kerberos path + * we run the plugin {@code doAs} and DELIBERATELY do NOT also call {@code delegate.executeAuthenticated} + * (which only authenticates the unused app-loader UGI — dead weight plus a redundant keytab login). The + * plugin {@code doAs} mirrors {@code HadoopExecutionAuthenticator.execute} + * ({@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. + * + *

    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 { + + 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"); + this.pluginClassLoader = Objects.requireNonNull(pluginClassLoader, "pluginClassLoader"); + this.pluginAuthenticator = Objects.requireNonNull(pluginAuthenticator, "pluginAuthenticator"); + } + + @Override + public T executeAuthenticated(Callable task) throws Exception { + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(pluginClassLoader); + HadoopAuthenticator auth = pluginAuthenticator.get(); + if (auth == null) { + // Non-Kerberos: keep the FE-injected auth path exactly as-is. + 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. + return auth.doAs(task::call); + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + } + + // ----- 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); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/FakePaimonTable.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/FakePaimonTable.java new file mode 100644 index 00000000000000..382bd1f530ad32 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/FakePaimonTable.java @@ -0,0 +1,254 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.paimon.Snapshot; +import org.apache.paimon.fs.FileIO; +import org.apache.paimon.manifest.IndexManifestEntry; +import org.apache.paimon.manifest.ManifestEntry; +import org.apache.paimon.manifest.ManifestFileMeta; +import org.apache.paimon.stats.Statistics; +import org.apache.paimon.table.ExpireSnapshots; +import org.apache.paimon.table.Table; +import org.apache.paimon.table.sink.BatchWriteBuilder; +import org.apache.paimon.table.sink.StreamWriteBuilder; +import org.apache.paimon.table.source.ReadBuilder; +import org.apache.paimon.types.RowType; +import org.apache.paimon.utils.SimpleFileReader; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Minimal offline {@link Table} double for unit tests. Only the metadata read calls that + * {@link PaimonConnectorMetadata} actually exercises — {@link #rowType()}, + * {@link #partitionKeys()}, {@link #primaryKeys()}, {@link #options()} — return controlled + * values; every other method throws {@link UnsupportedOperationException}. + * + *

    Throwing on the rest is deliberate: it documents that the metadata read path must touch + * nothing else, and a future change that starts depending on (say) {@code newReadBuilder()} in + * the read-only metadata path would blow up loudly in the test instead of silently passing. + * + *

    P5-T08 promoted {@link #options()} out of the throwing set: the partition-listing path + * reads the {@code partition.legacy-name} option, so {@code options()} now returns a + * configurable map (default empty, settable via {@link #setOptions(Map)}). Every other method + * keeps the fail-loud contract. + */ +final class FakePaimonTable implements Table { + + private final String name; + private final RowType rowType; + private final List partitionKeys; + private final List primaryKeys; + private Map options = Collections.emptyMap(); + + /** + * The dynamic options passed to the most recent {@link #copy(Map)} call, or {@code null} if + * {@code copy} was never invoked. Lets the scan tests assert the snapshot pin was applied via + * {@code Table.copy(scanOptions)} rather than scanning the un-pinned table. + */ + Map lastCopyOptions; + /** The table returned by {@link #copy(Map)}; defaults to {@code this} when unset. */ + Table copyResult; + /** The FileIO returned by {@link #fileIO()}; {@code null} (the legacy throw) unless set. */ + FileIO fileIO; + + FakePaimonTable(String name, RowType rowType, + List partitionKeys, List primaryKeys) { + this.name = name; + this.rowType = rowType; + this.partitionKeys = partitionKeys; + this.primaryKeys = primaryKeys; + } + + /** Configures the value returned by {@link #options()}. */ + void setOptions(Map options) { + this.options = options; + } + + @Override + public String name() { + return name; + } + + @Override + public RowType rowType() { + return rowType; + } + + @Override + public List partitionKeys() { + return partitionKeys; + } + + @Override + public List primaryKeys() { + return primaryKeys; + } + + // ---- everything below is outside the metadata read path: fail loud if ever called ---- + + @Override + public Map options() { + return options; + } + + @Override + public Optional comment() { + throw new UnsupportedOperationException(); + } + + @Override + public Optional statistics() { + throw new UnsupportedOperationException(); + } + + @Override + public FileIO fileIO() { + // Settable so FIX-REST-VENDED tests can inject a non-REST FileIO double (the positive + // RESTTokenFileIO path needs a live REST stack, covered by the fe-core bridge test + E2E). + return fileIO; + } + + @Override + public Table copy(Map dynamicOptions) { + // Records the scan-pin options the scan path layers on via Table.copy(scanOptions). Returns + // a configurable result table (defaults to this) so the test can prove the COPIED table — + // not the un-pinned original — is what gets planned/serialized. + this.lastCopyOptions = dynamicOptions; + return copyResult != null ? copyResult : this; + } + + @Override + public Optional latestSnapshot() { + throw new UnsupportedOperationException(); + } + + @Override + public Snapshot snapshot(long snapshotId) { + throw new UnsupportedOperationException(); + } + + @Override + public SimpleFileReader manifestListReader() { + throw new UnsupportedOperationException(); + } + + @Override + public SimpleFileReader manifestFileReader() { + throw new UnsupportedOperationException(); + } + + @Override + public SimpleFileReader indexManifestFileReader() { + throw new UnsupportedOperationException(); + } + + @Override + public void rollbackTo(long snapshotId) { + throw new UnsupportedOperationException(); + } + + @Override + public void createTag(String tagName, long fromSnapshotId) { + throw new UnsupportedOperationException(); + } + + @Override + public void createTag(String tagName, long fromSnapshotId, Duration timeRetained) { + throw new UnsupportedOperationException(); + } + + @Override + public void createTag(String tagName) { + throw new UnsupportedOperationException(); + } + + @Override + public void createTag(String tagName, Duration timeRetained) { + throw new UnsupportedOperationException(); + } + + @Override + public void renameTag(String tagName, String targetTagName) { + throw new UnsupportedOperationException(); + } + + @Override + public void replaceTag(String tagName, Long fromSnapshotId, Duration timeRetained) { + throw new UnsupportedOperationException(); + } + + @Override + public void deleteTag(String tagName) { + throw new UnsupportedOperationException(); + } + + @Override + public void rollbackTo(String tagName) { + throw new UnsupportedOperationException(); + } + + @Override + public void createBranch(String branchName) { + throw new UnsupportedOperationException(); + } + + @Override + public void createBranch(String branchName, String tagName) { + throw new UnsupportedOperationException(); + } + + @Override + public void deleteBranch(String branchName) { + throw new UnsupportedOperationException(); + } + + @Override + public void fastForward(String branchName) { + throw new UnsupportedOperationException(); + } + + @Override + public ExpireSnapshots newExpireSnapshots() { + throw new UnsupportedOperationException(); + } + + @Override + public ExpireSnapshots newExpireChangelog() { + throw new UnsupportedOperationException(); + } + + @Override + public ReadBuilder newReadBuilder() { + throw new UnsupportedOperationException(); + } + + @Override + public BatchWriteBuilder newBatchWriteBuilder() { + throw new UnsupportedOperationException(); + } + + @Override + public StreamWriteBuilder newStreamWriteBuilder() { + throw new UnsupportedOperationException(); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonBuildTableDescriptorTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonBuildTableDescriptorTest.java new file mode 100644 index 00000000000000..453d6e54f51ce9 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonBuildTableDescriptorTest.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.paimon; + +import org.apache.doris.thrift.TTableDescriptor; +import org.apache.doris.thrift.TTableType; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +/** + * Guards the paimon read-path table descriptor contract (P5-T19 Part B). + * + *

    WHY this matters: after the paimon cutover, a SELECT (normal OR system table) routes through + * {@code PluginDrivenExternalTable.toThrift()} -> {@code metadata.buildTableDescriptor(...)}. + * Legacy paimon ({@code PaimonExternalTable.toThrift} and {@code PaimonSysExternalTable.toThrift}) + * both send {@code TTableType.HIVE_TABLE} with a {@code THiveTable}. BE's + * {@code DescriptorTbl::create} builds a {@code HiveTableDescriptor} for {@code HIVE_TABLE} but a + * {@code SchemaTableDescriptor} for {@code SCHEMA_TABLE}. Without this override the SPI default + * returns {@code null}, fe-core falls back to {@code SCHEMA_TABLE}, and BE builds the wrong + * descriptor — a latent descriptor-parity bug for BOTH normal and system paimon plugin tables. + * Each assertion below encodes a BE-side requirement, not just the method's shape (Rule 9): this + * test FAILS if the override returns null (SPI default) or any non-HIVE_TABLE descriptor.

    + * + *

    The ctor only assigns its args; {@code buildTableDescriptor} never dereferences catalogOps / + * context / properties, so passing {@code null}/empty for them is safe and keeps the test offline.

    + */ +public class PaimonBuildTableDescriptorTest { + + @Test + public void buildsHiveTableDescriptorWithAddressing() { + PaimonConnectorMetadata metadata = new PaimonConnectorMetadata( + null, Collections.emptyMap(), new RecordingConnectorContext()); + + long tableId = 42L; + String tableName = "local_table"; + String dbName = "remote_db"; + String remoteName = "remote_table"; + int numCols = 7; + long catalogId = 100L; + + TTableDescriptor desc = metadata.buildTableDescriptor( + null, tableId, tableName, dbName, remoteName, numCols, catalogId); + + // (1) must not be null — null triggers the SCHEMA_TABLE fallback in fe-core. + Assertions.assertNotNull(desc, + "buildTableDescriptor must return a typed descriptor, never null (BE expects HIVE type)"); + // (2) BE builds a HiveTableDescriptor only for HIVE_TABLE; SCHEMA_TABLE would build the + // wrong descriptor (SchemaTableDescriptor) — the descriptor-parity bug this fix closes. + Assertions.assertEquals(TTableType.HIVE_TABLE, desc.getTableType(), + "table type must be HIVE_TABLE; SCHEMA_TABLE builds the wrong BE descriptor"); + // (3) BE reads hiveTable; it must be set (legacy paimon always set a THiveTable). + Assertions.assertTrue(desc.isSetHiveTable(), + "hiveTable must be set; legacy paimon (normal + sys) always sent a THiveTable"); + // (4) addressing + column count must be carried through. + Assertions.assertEquals(tableName, desc.getHiveTable().getTableName(), + "THiveTable.tableName must carry the tableName param"); + Assertions.assertEquals(dbName, desc.getHiveTable().getDbName(), + "THiveTable.dbName must carry the dbName param"); + Assertions.assertEquals(tableId, desc.getId(), "descriptor id must carry the tableId"); + Assertions.assertEquals(numCols, desc.getNumCols(), "descriptor numCols must carry numCols"); + Assertions.assertEquals(tableName, desc.getTableName(), + "descriptor tableName must carry the tableName param"); + Assertions.assertEquals(dbName, desc.getDbName(), + "descriptor dbName must carry the dbName param"); + } +} 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 new file mode 100644 index 00000000000000..8aa147063b06b7 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCatalogFactoryTest.java @@ -0,0 +1,419 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.filesystem.FileSystemType; +import org.apache.doris.filesystem.properties.HadoopStorageProperties; +import org.apache.doris.filesystem.properties.StorageKind; +import org.apache.doris.filesystem.properties.StorageProperties; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hive.conf.HiveConf; +import org.apache.paimon.options.Options; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Unit tests for {@link PaimonCatalogFactory}, the pure flavor-assembly core. + * + *

    These tests are entirely offline: {@code buildCatalogOptions} is a pure transform + * (Map in, Paimon {@link Options} out), {@code validate} is fail-fast pre-flight, and the Hadoop + * config builders are pure (Maps in, conf out), so no live catalog or env is touched. No Mockito — + * props are plain maps. + * + *

    This is the parity baseline for B1: the per-flavor option keys MUST mirror the legacy + * fe-core {@code AbstractPaimonProperties} + each {@code Paimon*MetaStoreProperties}. + * + *

    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 + * 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 + * end-to-end new/old equivalence is gated by the docker 5-flavor run (P1-T06; see DV-003). + */ +public class PaimonCatalogFactoryTest { + + private static Map props(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + /** + * A pre-computed object-store storage Hadoop-config map — the fe-filesystem + * {@code toHadoopConfigurationMap()} output the connector now overlays. {@code storage()} with no + * args is the no-static-object-store case (HDFS-only / REST), where the map is empty. + */ + private static Map storage(String... kv) { + return props(kv); + } + + // --------------------------------------------------------------------- + // buildCatalogOptions — per-flavor metastore identifier + warehouse + // --------------------------------------------------------------------- + + @Test + public void filesystemSetsMetastoreFilesystemAndWarehouse() { + Options opts = PaimonCatalogFactory.buildCatalogOptions( + props("paimon.catalog.type", "filesystem", "warehouse", "/wh")); + + // WHY: filesystem is the default flavor; its metastore identifier selects + // FileSystemCatalogFactory and the warehouse is the on-disk root. Both are load-bearing + // for catalog creation. MUTATION: emitting "hive"/"jdbc" or dropping warehouse -> red. + Assertions.assertEquals("filesystem", opts.get("metastore")); + Assertions.assertEquals("/wh", opts.get("warehouse")); + } + + @Test + public void hmsSetsHiveMetastoreUriPoolAndLocation() { + Options opts = PaimonCatalogFactory.buildCatalogOptions(props( + "paimon.catalog.type", "hms", + "warehouse", "/wh", + "hive.metastore.uris", "thrift://nn:9083")); + + // WHY: hms maps to paimon's "hive" metastore; the legacy HMS flavor always emits the + // metastore uri plus the pool-eviction + location-in-properties defaults. Dropping any + // would change how the (B1-d2) HiveCatalog connects/caches. MUTATION: metastore!="hive", + // missing uri, or wrong defaults -> red. + Assertions.assertEquals("hive", opts.get("metastore")); + Assertions.assertEquals("thrift://nn:9083", opts.get("uri")); + Assertions.assertEquals("300000", opts.get("client-pool-cache.eviction-interval-ms")); + Assertions.assertEquals("false", opts.get("location-in-properties")); + } + + @Test + public void hmsAcceptsUriAliasAndOverrides() { + Options opts = PaimonCatalogFactory.buildCatalogOptions(props( + "paimon.catalog.type", "hms", + "warehouse", "/wh", + "uri", "thrift://alias:9083", + "client-pool-cache.eviction-interval-ms", "60000", + "location-in-properties", "true")); + + // WHY: the legacy HMS flavor accepts the bare "uri" alias for the metastore URI and lets + // the user override the pool/location defaults. MUTATION: ignoring the alias or hardcoding + // the defaults instead of reading the user value -> red. + Assertions.assertEquals("thrift://alias:9083", opts.get("uri")); + Assertions.assertEquals("60000", opts.get("client-pool-cache.eviction-interval-ms")); + Assertions.assertEquals("true", opts.get("location-in-properties")); + } + + @Test + public void restSetsMetastoreRestUriAndStripsRestPrefix() { + Options opts = PaimonCatalogFactory.buildCatalogOptions(props( + "paimon.catalog.type", "rest", + "paimon.rest.uri", "http://rest:8080", + "paimon.rest.token.provider", "bear")); + + // WHY: rest maps to the "rest" metastore; the legacy rest flavor sets "uri" from + // paimon.rest.uri and re-keys every paimon.rest.* prop by stripping the prefix (so + // token.provider becomes a paimon option). MUTATION: metastore!="rest", missing uri, or + // not stripping the paimon.rest. prefix -> red. + Assertions.assertEquals("rest", opts.get("metastore")); + Assertions.assertEquals("http://rest:8080", opts.get("uri")); + Assertions.assertEquals("bear", opts.get("token.provider")); + } + + @Test + public void jdbcSetsMetastoreUriUserAndRawJdbcKeys() { + Options opts = PaimonCatalogFactory.buildCatalogOptions(props( + "paimon.catalog.type", "jdbc", + "warehouse", "/wh", + "uri", "jdbc:mysql://db:3306/meta", + "paimon.jdbc.user", "alice", + "jdbc.password", "secret", + "jdbc.foo", "bar")); + + // WHY: jdbc maps to JdbcCatalogFactory; the legacy jdbc flavor sets the CatalogOptions URI, + // the jdbc.user/jdbc.password (read from either alias), and passes through any raw jdbc.* + // key. These are exactly the options the JdbcCatalog reads. MUTATION: metastore!="jdbc", + // missing uri/user/password, or dropping the raw jdbc.foo passthrough -> red. + Assertions.assertEquals("jdbc", opts.get("metastore")); + Assertions.assertEquals("jdbc:mysql://db:3306/meta", opts.get("uri")); + Assertions.assertEquals("alice", opts.get("jdbc.user")); + Assertions.assertEquals("secret", opts.get("jdbc.password")); + Assertions.assertEquals("bar", opts.get("jdbc.foo")); + } + + @Test + public void removedDlfCatalogTypeNoLongerBuildsOptions() { + // WHY: paimon.catalog.type=dlf was removed with the vendored thrift ProxyMetaStoreClient it adapted + // onto paimon's "hive" metastore. buildCatalogOptions must now reject it like any unknown type rather + // than emit a metastore.client.class naming a class that no longer ships. MUTATION: re-adding the dlf + // arm -> red. + IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, + () -> PaimonCatalogFactory.buildCatalogOptions(props( + "paimon.catalog.type", "dlf", + "warehouse", "/wh"))); + Assertions.assertTrue(ex.getMessage().contains("dlf"), ex.getMessage()); + } + + @Test + public void paimonPrefixPassthroughExcludesStoragePrefixes() { + Options opts = PaimonCatalogFactory.buildCatalogOptions(props( + "paimon.catalog.type", "filesystem", + "warehouse", "/wh", + "paimon.read.batch-size", "4096", + "paimon.s3.access-key", "should-not-leak")); + + // WHY: the legacy appendCatalogOptions re-keys generic paimon.* props by stripping the + // prefix, BUT deliberately excludes storage prefixes (paimon.s3./s3a./fs.s3./fs.oss.) + // because those belong in the Hadoop Configuration (B1 dispatch 2), not the catalog + // Options. MUTATION: dropping the passthrough (read.batch-size missing) or leaking the + // storage key (s3.access-key present) -> red. + Assertions.assertEquals("4096", opts.get("read.batch-size")); + Assertions.assertNull(opts.get("access-key"), + "storage-prefixed paimon.s3.* keys must NOT be promoted into catalog options"); + } + + @Test + public void restBuildOptionsOmitsBlankWarehouse() { + Options opts = PaimonCatalogFactory.buildCatalogOptions(props( + "paimon.catalog.type", "rest", + "paimon.rest.uri", "http://rest:8080")); + + // WHY: this pins option ASSEMBLY only (independent of validate, which now requires a + // warehouse for rest too): the common appender sets the warehouse option only when the + // warehouse value is non-blank, so a blank/absent warehouse produces no warehouse key + // rather than a blank one. MUTATION: emitting a (blank) warehouse key when none was given, + // or unconditionally setting warehouse -> red. + Assertions.assertNull(opts.get("warehouse"), + "buildCatalogOptions must not emit a warehouse option when the warehouse is blank"); + } + + // --------------------------------------------------------------------- + // 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) + // --------------------------------------------------------------------- + + @Test + public void buildHadoopConfigurationNormalizesS3PrefixesAndCopiesRawKeys() { + Configuration conf = PaimonCatalogFactory.buildHadoopConfiguration(props( + "paimon.s3.access-key", "ak", + "paimon.s3a.secret-key", "sk", + "paimon.fs.s3.endpoint", "s3.amazonaws.com", + "paimon.fs.oss.endpoint.region", "oss-cn.aliyuncs.com", + "fs.defaultFS", "hdfs://nn:8020", + "dfs.nameservices", "nn", + "hadoop.security.authentication", "kerberos", + "paimon.read.batch-size", "4096"), storage()); + + // WHY: the live FileIO/S3FileIO only recognizes Hadoop-prefixed keys; the connector strips each + // of the four user storage prefixes (paimon.s3./s3a./fs.s3./fs.oss.) and re-keys them under + // fs.s3a., while genuine fs.*/dfs./hadoop.* keys are passed through verbatim so HDFS/auth config + // reaches the catalog. This connector-local overlay is UNCHANGED by P1-T03 (only the canonical + // object-store translation moved out to storageHadoopConfig). MUTATION: not normalizing to + // fs.s3a. (key still under the old prefix), or dropping the raw fs./dfs./hadoop. passthrough -> red. + Assertions.assertEquals("ak", conf.get("fs.s3a.access-key")); + Assertions.assertEquals("sk", conf.get("fs.s3a.secret-key")); + Assertions.assertEquals("s3.amazonaws.com", conf.get("fs.s3a.endpoint")); + // paimon.fs.oss.* also normalizes onto the fs.s3a. prefix (all four userStoragePrefixes map to + // FS_S3A_PREFIX). Distinct suffix to avoid colliding with paimon.fs.s3.endpoint above. + Assertions.assertEquals("oss-cn.aliyuncs.com", conf.get("fs.s3a.endpoint.region")); + Assertions.assertEquals("hdfs://nn:8020", conf.get("fs.defaultFS")); + Assertions.assertEquals("nn", conf.get("dfs.nameservices")); + Assertions.assertEquals("kerberos", conf.get("hadoop.security.authentication")); + // A non-storage paimon.* key (a catalog Option) must NOT leak into the Hadoop Configuration. + Assertions.assertNull(conf.get("paimon.read.batch-size")); + Assertions.assertNull(conf.get("read.batch-size")); + } + + @Test + public void buildHadoopConfigurationAppliesStorageHadoopConfig() { + Configuration conf = PaimonCatalogFactory.buildHadoopConfiguration( + props("fs.defaultFS", "hdfs://nn:8020"), + storage("fs.s3a.access.key", "ak", + "fs.s3a.endpoint", "s3.amazonaws.com", + "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() + // 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. + Assertions.assertEquals("ak", conf.get("fs.s3a.access.key")); + Assertions.assertEquals("s3.amazonaws.com", conf.get("fs.s3a.endpoint")); + Assertions.assertEquals("org.apache.hadoop.fs.s3a.S3AFileSystem", conf.get("fs.s3a.impl")); + // the raw fs./dfs./hadoop. passthrough still applies alongside the pre-computed storage map. + Assertions.assertEquals("hdfs://nn:8020", conf.get("fs.defaultFS")); + } + + @Test + public void buildHadoopConfigurationExplicitFsS3aKeyOverridesStorageConfig() { + Configuration conf = PaimonCatalogFactory.buildHadoopConfiguration( + props("fs.s3a.access.key", "explicit"), + storage("fs.s3a.access.key", "from-storage")); + + // WHY: the raw fs.* passthrough runs AFTER the storageHadoopConfig overlay (last-write-wins = + // legacy addResource(getHadoopStorageConfig) THEN appendUserHadoopConfig ordering), so a power + // user who explicitly set fs.s3a.access.key in the catalog props still wins over the + // fe-filesystem-derived value. MUTATION: reversing precedence (storage overlays raw) -> "from-storage" -> red. + Assertions.assertEquals("explicit", conf.get("fs.s3a.access.key")); + } + + @Test + public void buildHadoopConfigurationPaimonPrefixOverridesStorageConfig() { + Configuration conf = PaimonCatalogFactory.buildHadoopConfiguration( + props("paimon.s3.endpoint", "from-paimon"), + storage("fs.s3a.endpoint", "from-storage")); + + // WHY: the paimon.* prefix re-key (paimon.s3.endpoint -> fs.s3a.endpoint) is part of the + // connector-specific overlay that runs LAST, so an explicit paimon.s3.* key wins over the + // fe-filesystem storage map (last-write-wins). MUTATION: storage overlaying the paimon.* re-key + // (fs.s3a.endpoint == "from-storage") -> red. + Assertions.assertEquals("from-paimon", conf.get("fs.s3a.endpoint")); + } + + @Test + 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 -> + // 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. + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.storageProperties = Collections.singletonList( + new StubHadoopStorageProperties(Collections.singletonMap("dfs.custom.key", "custom-value"))); + PaimonConnector connector = new PaimonConnector(props(), ctx); + + Map merged = connector.buildStorageHadoopConfig(); + Assertions.assertEquals("custom-value", merged.get("dfs.custom.key"), + "buildStorageHadoopConfig must fold in each storage prop's toHadoopConfigurationMap()"); + + // ...and that merged map flows into the actual catalog Configuration (the key is absent from props, + // so the only path by which it can land in conf is the storageHadoopConfig overlay). + Configuration conf = PaimonCatalogFactory.buildHadoopConfiguration(props(), merged); + Assertions.assertEquals("custom-value", conf.get("dfs.custom.key")); + } + + /** Minimal {@link StorageProperties} exposing a fixed Hadoop config map (C2 seam test double). */ + private static final class StubHadoopStorageProperties implements StorageProperties, HadoopStorageProperties { + private final Map hadoopConfig; + + StubHadoopStorageProperties(Map hadoopConfig) { + this.hadoopConfig = hadoopConfig; + } + + @Override + public Optional toHadoopProperties() { + return Optional.of(this); + } + + @Override + public Map toHadoopConfigurationMap() { + return hadoopConfig; + } + + @Override + public String providerName() { + return "STUB"; + } + + @Override + public StorageKind kind() { + return StorageKind.HDFS_COMPATIBLE; + } + + @Override + public FileSystemType type() { + return FileSystemType.HDFS; + } + + @Override + public Map rawProperties() { + return Collections.emptyMap(); + } + + @Override + public Map matchedProperties() { + return Collections.emptyMap(); + } + } + + // --------------------------------------------------------------------- + // assembleHiveConf — seed optional base (hive.conf.resources) THEN overlay shared-parser overrides + // (the HiveConf key CONTENT for hms/dlf is produced + parity-tested by fe-connector-metastore-spi's + // HmsMetaStorePropertiesImplTest / DlfMetaStorePropertiesImplTest; here we pin only the F2 layering) + // --------------------------------------------------------------------- + + @Test + public void assembleHiveConfAppliesOverrides() { + // WHY (F2): the connection/user overrides from the shared parser must land on the HiveConf. + // The base-vs-overrides PRECEDENCE (external hive-site.xml is the base, overrides win) is covered + // by PaimonHmsConfResWiringTest, which drives it through a REAL hive.conf.resources file now that + // the connector resolves the file itself instead of being handed pre-flattened keys. + Map overrides = new HashMap<>(); + overrides.put("hive.metastore.uris", "thrift://from-override:9083"); + overrides.put("hadoop.username", "doris"); + + HiveConf hc = PaimonCatalogFactory.assembleHiveConf(null, overrides); + + Assertions.assertEquals("thrift://from-override:9083", hc.get("hive.metastore.uris")); + Assertions.assertEquals("doris", hc.get("hadoop.username")); + } + + @Test + public void assembleHiveConfPinsPluginClassLoaderNotTccl() { + // WHY (FIX-1, CI 973411): HiveMetaStoreClient.loadFilterHooks resolves metastore.filter.hook via + // Configuration.getClass, which uses the HiveConf's OWN classLoader field. new HiveConf() captures + // the thread-context CL active AT CONSTRUCTION into that field. At runtime assembleHiveConf runs + // before the plugin TCCL pin, so the conf would capture the parent 'app' loader; under child-first + // plugin loading that resolves DefaultMetaStoreFilterHookImpl from the parent while + // MetaStoreFilterHook is child-loaded -> "class ... not ...". The conf MUST be pinned to the plugin + // loader (the one that loaded HiveMetaStoreClient/MetaStoreFilterHook), exactly as + // buildHadoopConfiguration already does. MUTATION: drop the setClassLoader -> the conf keeps the + // foreign TCCL below -> red. (A flat-classpath assertion alone cannot repro the real cross-loader + // cast, so we install a distinct TCCL to make the captured-loader bug observable offline.) + ClassLoader foreign = new URLClassLoader(new URL[0], null); + ClassLoader prev = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(foreign); + HiveConf hc = PaimonCatalogFactory.assembleHiveConf(null, Collections.emptyMap()); + Assertions.assertSame(PaimonCatalogFactory.class.getClassLoader(), hc.getClassLoader()); + Assertions.assertNotSame(foreign, hc.getClassLoader()); + } finally { + Thread.currentThread().setContextClassLoader(prev); + } + } + + @Test + public void assembleHiveConfAcceptsNullBase() { + // WHY: the dlf flavor has no hive.conf.resources base, so it passes null; assembleHiveConf must + // not NPE and must still apply the overrides. MUTATION: removing the null guard -> NPE -> red. + Map overrides = new HashMap<>(); + overrides.put("dlf.catalog.endpoint", "dlf-vpc.cn-hangzhou.aliyuncs.com"); + + HiveConf hc = PaimonCatalogFactory.assembleHiveConf(null, overrides); + + Assertions.assertEquals("dlf-vpc.cn-hangzhou.aliyuncs.com", hc.get("dlf.catalog.endpoint")); + } + +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorCacheTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorCacheTest.java new file mode 100644 index 00000000000000..7e7aa033229238 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorCacheTest.java @@ -0,0 +1,86 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.OptionalLong; + +/** + * Tests PaimonConnector's FIX-4 cache knobs (CI 973411): the {@code meta.cache.paimon.table.ttl-second} + * mapping to the generic schema-cache TTL override (Axis B). The data-snapshot cache itself is covered by + * {@link PaimonLatestSnapshotCacheTest}; the end-to-end behavior is gated by the docker e2e. + */ +public class PaimonConnectorCacheTest { + + private static PaimonConnector connector(Map props) { + return new PaimonConnector(props, new RecordingConnectorContext()); + } + + private static Map props(String ttl) { + Map m = new HashMap<>(); + if (ttl != null) { + m.put(PaimonConnector.TABLE_CACHE_TTL_SECOND, ttl); + } + return m; + } + + @Test + public void schemaTtlOverrideAbsentWhenPropertyUnset() { + // No meta.cache.paimon.table.ttl-second -> no override -> the catalog keeps the engine-default schema + // cache TTL (the with-cache catalog: schema is cached). MUTATION: returning a value -> red. + Assertions.assertEquals(OptionalLong.empty(), + connector(Collections.emptyMap()).schemaCacheTtlSecondOverride()); + } + + @Test + public void schemaTtlOverrideZeroDisablesSchemaCache() { + // The no-cache catalog (meta.cache.paimon.table.ttl-second=0) must drive schema.cache.ttl-second=0 so + // its schema is served FRESH (Test 2 / L112 of test_paimon_table_meta_cache). MUTATION: not mapping + // ttl-second -> the no-cache catalog would serve stale schema -> red. + Assertions.assertEquals(OptionalLong.of(0L), connector(props("0")).schemaCacheTtlSecondOverride()); + } + + @Test + public void schemaTtlOverridePositiveIsPassedThrough() { + Assertions.assertEquals(OptionalLong.of(3600L), connector(props("3600")).schemaCacheTtlSecondOverride()); + } + + @Test + public void schemaTtlOverrideIgnoresUnparseableValue() { + // A malformed value must not break catalog schema caching; fall back to no override (engine default). + Assertions.assertEquals(OptionalLong.empty(), connector(props("not-a-number")).schemaCacheTtlSecondOverride()); + } + + @Test + public void invalidateHooksAreNoThrowOnFreshConnector() { + // Smoke: the REFRESH TABLE / REFRESH DATABASE / REFRESH CATALOG hooks must be safe on a fresh connector + // (they only touch the connector-internal latest-snapshot cache + schema memo; the actual db-scoped + // invalidate semantics are in PaimonLatestSnapshotCacheTest / PaimonSchemaAtMemoTest). invalidateDb + // wires BOTH caches — a mutation dropping the schemaAtMemo half still passes this smoke but fails those. + // MUTATION: an NPE on an empty cache -> red. + PaimonConnector connector = connector(Collections.emptyMap()); + Assertions.assertDoesNotThrow(() -> connector.invalidateTable("db1", "t1")); + Assertions.assertDoesNotThrow(() -> connector.invalidateDb("db1")); + Assertions.assertDoesNotThrow(connector::invalidateAll); + } +} 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 new file mode 100644 index 00000000000000..846b71818095d2 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataDbDdlTest.java @@ -0,0 +1,271 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.DorisConnectorException; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * T14 database-DDL tests for {@link PaimonConnectorMetadata#supportsCreateDatabase}, + * {@link #createDatabase} and the 4-arg {@link #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 + * {@link org.apache.doris.connector.spi.ConnectorContext#executeAuthenticated}, and + * (4) the force-drop enumerate-loop + native cascade (legacy parity with + * {@code PaimonMetadataOps.performDropDb}). + * + *

    All tests run offline against the recording seam fake (null real Catalog). + */ +public class PaimonConnectorMetadataDbDdlTest { + + /** Metadata with default (filesystem) flavor: catalogProperties has no paimon.catalog.type. */ + private static PaimonConnectorMetadata filesystemMetadata(RecordingPaimonCatalogOps ops, + RecordingConnectorContext ctx) { + return new PaimonConnectorMetadata(ops, Collections.emptyMap(), ctx); + } + + /** Metadata with HMS flavor: catalogProperties carries paimon.catalog.type=hms. */ + private static PaimonConnectorMetadata hmsMetadata(RecordingPaimonCatalogOps ops, + RecordingConnectorContext ctx) { + Map catalogProps = new HashMap<>(); + catalogProps.put(PaimonConnectorProperties.PAIMON_CATALOG_TYPE, PaimonConnectorProperties.HMS); + return new PaimonConnectorMetadata(ops, catalogProps, ctx); + } + + private static Map dbProps() { + Map props = new HashMap<>(); + props.put("location", "/wh/db"); + 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 + public void createDatabaseRejectsPropsForNonHmsFlavorBeforeAuthenticator() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + // WHY (legacy performCreateDb:103-109): only the HMS catalog type accepts CREATE DATABASE + // properties; every other flavor (here: default filesystem) must reject non-empty props. + // The gate is a pure local arg check, so it must run BEFORE executeAuthenticated and before + // any seam call. MUTATION: if the gate were removed or placed AFTER executeAuthenticated, + // authCount would be 1 and the seam log would contain createDatabase -> the two assertions + // below (authCount==0, log empty) flip red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> filesystemMetadata(ops, ctx).createDatabase(null, "db1", dbProps())); + Assertions.assertTrue(ex.getMessage().contains("filesystem"), + "rejection message must name the offending catalog type"); + Assertions.assertEquals(0, ctx.authCount, + "local arg-check rejection must abort BEFORE entering the authenticator"); + Assertions.assertTrue(ops.log.isEmpty(), + "local arg-check rejection must never reach the remote catalog seam"); + } + + @Test + public void createDatabaseAllowsPropsForHmsFlavor() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + Map props = dbProps(); + + hmsMetadata(ops, ctx).createDatabase(null, "db1", props); + + // WHY: for the HMS flavor the gate must NOT fire; the props must be forwarded verbatim to + // the seam, under exactly one authenticator scope. MUTATION: if the gate fired for HMS, this + // would throw; if the props were dropped, lastCreatedDbProps would differ. + Assertions.assertEquals(Collections.singletonList("createDatabase:db1"), ops.log); + Assertions.assertEquals("db1", ops.lastCreatedDb); + Assertions.assertEquals(props, ops.lastCreatedDbProps); + Assertions.assertFalse(ops.lastCreateDbIgnoreIfExists, + "ignoreIfExists must be false: FE already did the IF NOT EXISTS short-circuit"); + Assertions.assertEquals(1, ctx.authCount); + } + + @Test + public void createDatabaseAllowsEmptyPropsForFilesystemFlavor() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + filesystemMetadata(ops, ctx).createDatabase(null, "db1", Collections.emptyMap()); + + // WHY: the gate only rejects NON-EMPTY props on non-HMS flavors; an empty-props CREATE + // DATABASE on filesystem is the common case and must succeed and reach the seam. + // MUTATION: a gate that also rejected empty props (e.g. dropped the !isEmpty() guard) would + // throw here. + Assertions.assertEquals(Collections.singletonList("createDatabase:db1"), ops.log); + Assertions.assertEquals("db1", ops.lastCreatedDb); + Assertions.assertEquals(1, ctx.authCount); + } + + // ==================== createDatabase: exception wrap + authenticator ==================== + + @Test + public void createDatabaseWrapsDatabaseAlreadyExistAsDorisConnectorException() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.throwDatabaseAlreadyExist = true; + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + // WHY: fe-core only understands DorisConnectorException; a raw paimon + // DatabaseAlreadyExistException leaking out would bypass the engine's DDL error handling. + // MUTATION: removing the try/catch wrap lets the raw paimon exception escape -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> filesystemMetadata(ops, ctx).createDatabase(null, "db1", Collections.emptyMap())); + Assertions.assertTrue(ex.getMessage().contains("db1"), + "wrapped message must name the database"); + } + + @Test + public void createDatabaseRunsSeamInsideAuthenticator() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + + // WHY (D7=B legacy parity): the remote create must run inside executeAuthenticated so the + // FE-injected auth context applies. When auth fails, the seam call must NOT have run. + // This uses the NON-gated path (filesystem + empty props) so the gate cannot mask the test. + // MUTATION: if createDatabase called catalogOps.createDatabase directly instead of inside + // context.executeAuthenticated, the log would contain "createDatabase:db1" despite the auth + // failure -> the log-empty assertion below would fail. + Assertions.assertThrows(DorisConnectorException.class, + () -> filesystemMetadata(ops, ctx).createDatabase(null, "db1", Collections.emptyMap())); + Assertions.assertTrue(ops.log.isEmpty(), + "auth failure must abort BEFORE the seam createDatabase call runs"); + Assertions.assertEquals(1, ctx.authCount, + "createDatabase must enter executeAuthenticated exactly once"); + } + + // ==================== dropDatabase ==================== + + @Test + public void dropDatabaseForceEnumeratesAndCascades() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.tables = Arrays.asList("t1", "t2"); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + filesystemMetadata(ops, ctx).dropDatabase(null, "db1", false, true); + + // WHY (legacy performDropDb:147-163): force-drop must FIRST enumerate the db's tables and + // drop each (belt), THEN drop the db passing force as paimon's native cascade (suspenders). + // The whole op runs under ONE authenticator scope. The exact log order pins both the + // enumerate-then-drop sequencing and the native cascade=true forwarding. + // MUTATION: if the enumerate-loop were skipped, the dropTable entries vanish; if force + // weren't forwarded as cascade, the last entry would read cascade=false; if each remote call + // got its own authenticator, authCount would be > 1. + Assertions.assertEquals( + Arrays.asList("listTables:db1", "dropTable:db1.t1", "dropTable:db1.t2", + "dropDatabase:db1,cascade=true"), + ops.log); + Assertions.assertEquals("db1", ops.lastDroppedDb); + Assertions.assertTrue(ops.lastDropCascade, "force must be forwarded as native cascade=true"); + Assertions.assertTrue(ops.lastDropTableIgnoreIfNotExists, + "cascaded per-table drops must be idempotent (ignoreIfNotExists=true)"); + Assertions.assertEquals(1, ctx.authCount, + "the whole force-drop op runs under exactly one authenticator scope"); + } + + @Test + public void dropDatabaseForceOnEmptyDbStillCascades() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.tables = Collections.emptyList(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + filesystemMetadata(ops, ctx).dropDatabase(null, "db1", false, true); + + // WHY: the FORCE enumerate-loop must no-op safely when the database has no tables and + // still perform the native cascade drop -- an empty db is the common force-drop case. + // MUTATION: if the loop skipped the trailing dropDatabase when tables is empty, or + // emitted a spurious dropTable, the exact-log assertion would fail. + Assertions.assertEquals( + Arrays.asList("listTables:db1", "dropDatabase:db1,cascade=true"), ops.log); + Assertions.assertTrue(ops.lastDropCascade, "force must be forwarded as native cascade=true"); + Assertions.assertEquals(1, ctx.authCount, + "the whole force-drop op runs under exactly one authenticator scope"); + } + + @Test + public void dropDatabaseNonForceSkipsEnumerateLoop() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.tables = Arrays.asList("t1", "t2"); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + filesystemMetadata(ops, ctx).dropDatabase(null, "db1", false, false); + + // WHY: without force, the enumerate-loop must NOT run (no listTables / dropTable) and the + // db drop must pass cascade=false, so paimon throws DatabaseNotEmptyException on a non-empty + // db rather than silently deleting tables. MUTATION: if force weren't honored (loop always + // runs), the log would contain listTables/dropTable; if cascade weren't false, the last + // entry would read cascade=true. + Assertions.assertEquals( + Collections.singletonList("dropDatabase:db1,cascade=false"), ops.log); + Assertions.assertFalse(ops.lastDropCascade); + Assertions.assertEquals(1, ctx.authCount); + } + + @Test + public void dropDatabaseWrapsDatabaseNotEmptyAsDorisConnectorException() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.throwDatabaseNotEmpty = true; + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + // WHY: a non-force DROP DATABASE on a non-empty db surfaces paimon's + // DatabaseNotEmptyException, which must be wrapped so fe-core's DDL error handling applies. + // MUTATION: removing the try/catch wrap lets the raw paimon exception escape -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> filesystemMetadata(ops, ctx).dropDatabase(null, "db1", false, false)); + Assertions.assertTrue(ex.getMessage().contains("db1"), + "wrapped message must name the database"); + } + + @Test + public void dropDatabaseRunsSeamInsideAuthenticator() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + + // WHY (D7=B legacy parity): the remote drop must run inside executeAuthenticated. When auth + // fails, NO seam call (neither enumerate nor db drop) must run. + // MUTATION: if dropDatabase called the seam directly instead of inside + // context.executeAuthenticated, the log would be non-empty despite the auth failure. + Assertions.assertThrows(DorisConnectorException.class, + () -> filesystemMetadata(ops, ctx).dropDatabase(null, "db1", false, true)); + Assertions.assertTrue(ops.log.isEmpty(), + "auth failure must abort BEFORE any seam drop call runs"); + Assertions.assertEquals(1, ctx.authCount); + } +} 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 new file mode 100644 index 00000000000000..d1993f705802d0 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataDdlTest.java @@ -0,0 +1,267 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.ddl.ConnectorCreateTableRequest; +import org.apache.doris.connector.api.ddl.ConnectorPartitionField; +import org.apache.doris.connector.api.ddl.ConnectorPartitionSpec; + +import org.apache.paimon.schema.Schema; +import org.apache.paimon.types.DataTypeRoot; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * T13 DDL tests for {@link PaimonConnectorMetadata#createTable} / {@link #dropTable}, + * pinning (1) the delegation to the {@link PaimonCatalogOps} seam with the correct Identifier, + * Schema and {@code ignoreIfExists}/{@code ignoreIfNotExists} flags, (2) that raw paimon checked + * exceptions are wrapped as {@link DorisConnectorException}, and (3) D7=B: every remote DDL call + * is executed INSIDE {@link org.apache.doris.connector.spi.ConnectorContext#executeAuthenticated}. + * + *

    All tests run offline against the recording seam fake (null real Catalog). + */ +public class PaimonConnectorMetadataDdlTest { + + private static PaimonConnectorMetadata metadata(RecordingPaimonCatalogOps ops, + RecordingConnectorContext ctx) { + return new PaimonConnectorMetadata(ops, Collections.emptyMap(), ctx); + } + + /** Builds a CREATE TABLE request: db1.t1, columns (id INT, name STRING), partitioned by id, ifNotExists. */ + private static ConnectorCreateTableRequest request(boolean ifNotExists) { + List columns = Arrays.asList( + new ConnectorColumn("id", ConnectorType.of("INT"), "id col", false, null), + new ConnectorColumn("name", ConnectorType.of("STRING"), null, true, null)); + ConnectorPartitionSpec partitionSpec = new ConnectorPartitionSpec( + ConnectorPartitionSpec.Style.IDENTITY, + Collections.singletonList( + new ConnectorPartitionField("id", "identity", Collections.emptyList())), + Collections.emptyList()); + Map props = new HashMap<>(); + props.put("primary-key", "id"); + return ConnectorCreateTableRequest.builder() + .dbName("db1") + .tableName("t1") + .columns(columns) + .partitionSpec(partitionSpec) + .properties(props) + .ifNotExists(ifNotExists) + .build(); + } + + /** Builds a CREATE TABLE request whose partition spec uses a NON-identity transform (bucket). */ + private static ConnectorCreateTableRequest requestWithNonIdentityPartition() { + List columns = Arrays.asList( + new ConnectorColumn("id", ConnectorType.of("INT"), "id col", false, null), + new ConnectorColumn("name", ConnectorType.of("STRING"), null, true, null)); + ConnectorPartitionSpec partitionSpec = new ConnectorPartitionSpec( + ConnectorPartitionSpec.Style.TRANSFORM, + Collections.singletonList( + new ConnectorPartitionField("id", "bucket", Collections.singletonList(16))), + Collections.emptyList()); + return ConnectorCreateTableRequest.builder() + .dbName("db1") + .tableName("t1") + .columns(columns) + .partitionSpec(partitionSpec) + .properties(new HashMap<>()) + .ifNotExists(false) + .build(); + } + + private static PaimonTableHandle handle() { + return new PaimonTableHandle("db1", "t1", + Collections.singletonList("id"), Collections.singletonList("id")); + } + + // ==================== createTable ==================== + + @Test + public void createTableDelegatesToSeamWithBuiltSchemaAndIfNotExists() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + metadata(ops, ctx).createTable(null, request(true)); + + // WHY: createTable is the only path that materializes a Doris CREATE TABLE on the remote + // Paimon catalog; it must call the seam exactly once with the request's Identifier and the + // PaimonSchemaBuilder-built Schema, and forward request.isIfNotExists() as paimon's + // ignoreIfExists so paimon's idempotency semantics (no-op vs throw) match the user's clause. + // MUTATION: dropping the createTable delegation, or passing a wrong Identifier / false + // instead of request.isIfNotExists(), flips one of these assertions red. + Assertions.assertEquals(Collections.singletonList("createTable:db1.t1"), ops.log); + Assertions.assertEquals("db1", ops.lastCreatedTableId.getDatabaseName()); + Assertions.assertEquals("t1", ops.lastCreatedTableId.getObjectName()); + Assertions.assertTrue(ops.lastCreateTableIgnoreIfExists, + "request.isIfNotExists()==true must be forwarded as paimon ignoreIfExists"); + + // Schema must reflect the request: 2 columns in order, identity partition key id, pk id. + Schema schema = ops.lastCreatedSchema; + Assertions.assertEquals(Arrays.asList("id", "name"), + Arrays.asList(schema.fields().get(0).name(), schema.fields().get(1).name())); + Assertions.assertEquals(DataTypeRoot.INTEGER, schema.fields().get(0).type().getTypeRoot()); + Assertions.assertEquals(DataTypeRoot.VARCHAR, schema.fields().get(1).type().getTypeRoot()); + Assertions.assertEquals(Collections.singletonList("id"), schema.partitionKeys()); + Assertions.assertEquals(Collections.singletonList("id"), schema.primaryKeys()); + } + + @Test + public void createTableForwardsIfNotExistsFalse() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + metadata(ops, ctx).createTable(null, request(false)); + + // WHY: when the user omits IF NOT EXISTS, paimon must be told ignoreIfExists=false so a + // pre-existing table surfaces as TableAlreadyExistException rather than being silently + // no-op'd. MUTATION: hardcoding true (always-idempotent) makes this red. + Assertions.assertFalse(ops.lastCreateTableIgnoreIfExists); + } + + @Test + public void createTableWrapsTableAlreadyExistAsDorisConnectorException() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.throwTableAlreadyExist = true; + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + // WHY: fe-core only understands DorisConnectorException; a raw paimon + // TableAlreadyExistException leaking out would bypass the engine's DDL error handling. + // MUTATION: removing the try/catch wrap lets the raw paimon exception escape (wrapped by + // executeAuthenticated as a generic Exception) -> assertThrows(DorisConnectorException) red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).createTable(null, request(false))); + Assertions.assertTrue(ex.getMessage().contains("db1.t1"), + "wrapped message must name the table"); + } + + @Test + public void createTableRunsSeamInsideAuthenticator() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + + // WHY (D7=B legacy parity): the remote create must run inside executeAuthenticated so the + // FE-injected auth context (e.g. Kerberos UGI) applies; legacy PaimonMetadataOps wrapped + // every remote DDL call. When auth fails, the seam call must NOT have run. + // MUTATION: if createTable called catalogOps.createTable directly instead of inside + // context.executeAuthenticated, the seam call would run despite the auth failure and the + // log would contain "createTable:db1.t1" -> the log-empty assertion below would fail. + Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).createTable(null, request(true))); + Assertions.assertTrue(ops.log.isEmpty(), + "auth failure must abort BEFORE the seam createTable call runs"); + Assertions.assertEquals(1, ctx.authCount, + "createTable must enter executeAuthenticated exactly once"); + } + + @Test + public void createTableEntersAuthenticatorExactlyOnceOnHappyPath() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + metadata(ops, ctx).createTable(null, request(true)); + + // WHY: confirms the happy path also goes through the authenticator (not just the failure + // path), and exactly once. MUTATION: an un-wrapped direct seam call leaves authCount==0. + Assertions.assertEquals(1, ctx.authCount); + Assertions.assertEquals(Collections.singletonList("createTable:db1.t1"), ops.log); + } + + @Test + public void createTableBuildsSchemaOutsideAuthenticatorSoSchemaFailureIsRaw() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + // WHY: createTable must build the schema OUTSIDE the authenticator try, so a schema + // validation failure surfaces its own precise message and never touches the remote + // catalog / auth context. PaimonSchemaBuilder rejects a non-identity partition transform + // (here: bucket) with a raw DorisConnectorException whose message names the transform. + // MUTATION: if PaimonSchemaBuilder.build were moved INSIDE context.executeAuthenticated's + // try, the DorisConnectorException would be re-wrapped as "Failed to create Paimon table" + // (the contains-false assertion fails) and authCount would be 1 (the ==0 assertion fails). + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).createTable(null, requestWithNonIdentityPartition())); + Assertions.assertFalse(ex.getMessage().contains("Failed to create Paimon table"), + "schema-builder failure must surface its RAW message, not the createTable wrapper"); + Assertions.assertEquals(0, ctx.authCount, + "schema build failure must abort BEFORE entering the authenticator"); + Assertions.assertTrue(ops.log.isEmpty(), + "schema build failure must never reach the remote catalog seam"); + } + + // ==================== dropTable ==================== + + @Test + public void dropTableDelegatesToSeamIdempotently() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + metadata(ops, ctx).dropTable(null, handle()); + + // WHY: the SPI dropTable is handle-based with no ifExists flag (fe-core pre-resolves the + // handle), so the remote drop must be issued idempotently (ignoreIfNotExists=true) to mirror + // MaxCompute and avoid a spurious failure on a concurrently-vanished table. + // MUTATION: passing false (or a wrong Identifier) flips one of these assertions red. + Assertions.assertEquals(Collections.singletonList("dropTable:db1.t1"), ops.log); + Assertions.assertEquals("db1", ops.lastDroppedTableId.getDatabaseName()); + Assertions.assertEquals("t1", ops.lastDroppedTableId.getObjectName()); + Assertions.assertTrue(ops.lastDropTableIgnoreIfNotExists, + "drop must be idempotent: ignoreIfNotExists=true"); + Assertions.assertEquals(1, ctx.authCount); + } + + @Test + public void dropTableWrapsTableNotExistAsDorisConnectorException() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.throwTableNotExistOnDrop = true; + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + // WHY: a raw paimon TableNotExistException must be wrapped so fe-core's DDL error handling + // applies. MUTATION: removing the try/catch wrap lets the raw exception escape -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).dropTable(null, handle())); + Assertions.assertTrue(ex.getMessage().contains("db1.t1"), + "wrapped message must name the table"); + } + + @Test + public void dropTableRunsSeamInsideAuthenticator() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + + // WHY (D7=B legacy parity): like createTable, the remote drop must run inside + // executeAuthenticated. MUTATION: if dropTable called catalogOps.dropTable directly instead + // of inside context.executeAuthenticated, the log would contain "dropTable:db1.t1" despite + // the auth failure -> the log-empty assertion below would fail. + Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).dropTable(null, handle())); + Assertions.assertTrue(ops.log.isEmpty(), + "auth failure must abort BEFORE the seam dropTable call runs"); + Assertions.assertEquals(1, ctx.authCount); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataMvccTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataMvccTest.java new file mode 100644 index 00000000000000..e884ff4a62e720 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataMvccTest.java @@ -0,0 +1,1196 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorTableSchema; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; +import org.apache.doris.connector.api.mvcc.ConnectorTimeTravelSpec; +import org.apache.doris.connector.spi.ConnectorContext; + +import org.apache.paimon.CoreOptions; +import org.apache.paimon.types.DataField; +import org.apache.paimon.types.DataTypes; +import org.apache.paimon.types.RowType; +import org.apache.paimon.utils.DateTimeUtils; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.OptionalLong; +import java.util.Set; +import java.util.TimeZone; + +/** + * Tests for the paimon E5 MVCC / time-travel SPI methods: + * {@code beginQuerySnapshot}, {@code resolveTimeTravel} (SNAPSHOT_ID / TIMESTAMP / TAG, B5b-2a; + * INCREMENTAL/@incr, B5b-2b; BRANCH, B5b-2c), {@code getTableSchema(snapshot)} (schema-at-snapshot, + * including branch-aware), {@code applySnapshot} (including the branch sentinel routing), plus the + * {@code PaimonConnector.getCapabilities()} declaration. The @incr window VALIDATION rules themselves + * live in {@link PaimonIncrementalScanParamsTest}. + * + *

    These drive a {@link RecordingPaimonCatalogOps} fake whose MVCC seam methods return plain + * {@code long}s / small structs (never a real {@code Snapshot}/{@code Tag}/{@code TableSchema}), so + * the metadata layer's LOGIC (sys-guard, empty->-1, found/empty mapping, schemaId stamping, + * tag-name pinning, TZ-aware timestamp parse) is exercised entirely offline. + */ +public class PaimonConnectorMetadataMvccTest { + + private static PaimonConnectorMetadata metadataWith(RecordingPaimonCatalogOps ops) { + return new PaimonConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext()); + } + + // Builds a metadata sharing an EXTERNAL PaimonSchemaAtMemo, modelling two queries (each a fresh + // getMetadata() with its own catalog-ops) that share the connector-owned schema-at-snapshot memo. + private static PaimonConnectorMetadata metadataWith(RecordingPaimonCatalogOps ops, + PaimonSchemaAtMemo memo) { + return new PaimonConnectorMetadata( + ops, Collections.emptyMap(), new RecordingConnectorContext(), memo); + } + + private static RowType rowType(String... columnNames) { + RowType.Builder builder = RowType.builder(); + for (String name : columnNames) { + builder.field(name, DataTypes.INT()); + } + return builder.build(); + } + + /** Minimal {@link ConnectorSession} that only carries a time zone id (for the TIMESTAMP parse). */ + private static final class TzSession implements ConnectorSession { + private final String timeZone; + + TzSession(String timeZone) { + this.timeZone = timeZone; + } + + @Override + public String getQueryId() { + return "q"; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return timeZone; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public String getCatalogName() { + return "c"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + } + + /** A normal (non-system) handle with its transient Table already set (no reload needed). */ + private static PaimonTableHandle normalHandle(RecordingPaimonCatalogOps ops) { + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + FakePaimonTable table = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + ops.table = table; + handle.setPaimonTable(table); + return handle; + } + + /** A system handle (db1.t1$snapshots) with its transient sys Table set. */ + private static PaimonTableHandle sysHandle(RecordingPaimonCatalogOps ops) { + PaimonTableHandle handle = PaimonTableHandle.forSystemTable("db1", "t1", "snapshots", false); + FakePaimonTable sys = new FakePaimonTable( + "t1$snapshots", rowType("snapshot_id"), Collections.emptyList(), Collections.emptyList()); + ops.sysTable = sys; + handle.setPaimonTable(sys); + return handle; + } + + // ==================== beginQuerySnapshot ==================== + + @Test + public void beginQuerySnapshotReturnsLatestSnapshotId() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.latestSnapshotId = OptionalLong.of(42L); + + ConnectorMvccSnapshot snap = metadataWith(ops).beginQuerySnapshot(null, handle).get(); + + // WHY: the query-begin MVCC pin must be the table's LATEST snapshot id, so all reads in the + // query see one consistent version (legacy PaimonExternalTable used latestSnapshot().id()). + // MUTATION: returning a constant / the wrong seam value -> id != 42 -> red. + Assertions.assertEquals(42L, snap.getSnapshotId(), + "the begin-query pin must carry the table's latest snapshot id"); + Assertions.assertSame(handle.getPaimonTable(), ops.lastMvccTable, + "the live resolved Table must be passed to the latest-snapshot seam"); + } + + @Test + public void beginQuerySnapshotEmptyTableReturnsInvalidSnapshotId() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.latestSnapshotId = OptionalLong.empty(); // empty table: no snapshot yet + + ConnectorMvccSnapshot snap = metadataWith(ops).beginQuerySnapshot(null, handle).get(); + + // WHY: an empty paimon table (no snapshot) must STILL pin via a snapshot whose id is the + // legacy INVALID_SNAPSHOT_ID (-1) — NOT Optional.empty(). Optional.empty() would mean "this + // connector does not support MVCC", but paimon DOES; downstream the -1 sentinel signals + // "read whatever is current / empty" while keeping the MvccTable wiring engaged. This mirrors + // legacy PaimonExternalTable, which seeded latestSnapshotId = PaimonSnapshot.INVALID_SNAPSHOT_ID + // (-1) and only overwrote it when latestSnapshot().isPresent(). + // MUTATION 1: returning Optional.empty() for an empty table -> .get() throws / assertTrue + // below red. MUTATION 2: defaulting to 0L (or any value != -1) instead of -1 -> id != -1 red. + Assertions.assertEquals(-1L, snap.getSnapshotId(), + "an empty table must pin with the legacy INVALID_SNAPSHOT_ID (-1), not Optional.empty"); + } + + @Test + public void beginQuerySnapshotSysHandleReturnsEmpty() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = sysHandle(ops); + // Even with a non-empty latest snapshot configured, a sys handle must short-circuit to empty. + ops.latestSnapshotId = OptionalLong.of(7L); + + Assertions.assertFalse(metadataWith(ops).beginQuerySnapshot(null, handle).isPresent(), + "a system table must NOT expose an MVCC begin-query pin"); + // WHY: system tables (e.g. t$snapshots) are synthetic metadata views and MUST NOT participate + // in MVCC / time-travel — pinning them to a data snapshot is meaningless and mirrors the T19 + // scan-node fail-loud guard that rejects time-travel on sys tables. MUTATION: dropping the + // isSystemTable() guard -> the seam runs and a non-empty snapshot (id 7) is returned -> the + // assertFalse above + the no-seam-call assertion below go red. + Assertions.assertTrue(ops.log.isEmpty(), + "a sys handle must short-circuit before touching the MVCC seam"); + } + + // ==================== resolveTimeTravel: SNAPSHOT_ID ==================== + + @Test + public void resolveSnapshotIdExistsPinsIdSchemaAndScanOption() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.snapshotExists = true; + ops.snapshotSchemaId = OptionalLong.of(3L); + + ConnectorMvccSnapshot snap = metadataWith(ops) + .resolveTimeTravel(null, handle, ConnectorTimeTravelSpec.snapshotId("99")).get(); + + // WHY: FOR VERSION AS OF must pin the parsed id, stamp the snapshot's schemaId (so + // schema-at-snapshot reads pick the historical schema), and emit the scan.snapshot-id option + // the scan path copies onto the table. MUTATION: dropping the schemaId stamp -> -1 != 3 red; + // wrong/missing scan option -> red; not parsing the string id -> red. + Assertions.assertEquals(99L, snap.getSnapshotId(), "the parsed snapshot id must be pinned"); + Assertions.assertEquals(3L, snap.getSchemaId(), + "the snapshot's schemaId must be stamped for schema-at-snapshot"); + Assertions.assertEquals("99", snap.getProperties().get("scan.snapshot-id"), + "scan.snapshot-id must be pinned to the resolved id"); + } + + @Test + public void resolveSnapshotIdNotExistsReturnsEmpty() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.snapshotExists = false; // SDK FileNotFoundException -> seam reports false + + // WHY: a missing id must degrade to Optional.empty (SPI empty-if-none); the B5b-3 fe-core + // consumer translates empty into the legacy "can't find snapshot by id" UserException. + // MUTATION: returning a non-empty snapshot for a missing id -> isPresent() true -> red. + Assertions.assertFalse(metadataWith(ops) + .resolveTimeTravel(null, handle, ConnectorTimeTravelSpec.snapshotId("99")).isPresent(), + "a missing snapshot id must yield Optional.empty"); + } + + @Test + public void resolveSysHandleNeverTimeTravels() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = sysHandle(ops); + ops.snapshotExists = true; // would yield a snapshot if the guard were missing + + Assertions.assertFalse(metadataWith(ops) + .resolveTimeTravel(null, handle, ConnectorTimeTravelSpec.snapshotId("5")).isPresent(), + "a system table must NOT expose time-travel"); + // WHY/MUTATION: dropping the isSystemTable() guard -> the seam runs -> non-empty -> red; the + // empty log also proves the guard short-circuits before touching the seam. + Assertions.assertTrue(ops.log.isEmpty(), + "a sys handle must short-circuit before touching the MVCC seam"); + } + + // ==================== resolveTimeTravel: TIMESTAMP ==================== + + @Test + public void resolveTimestampDigitalParsesMillisVerbatim() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.snapshotIdAtOrBefore = OptionalLong.of(17L); + ops.snapshotSchemaId = OptionalLong.of(2L); + + ConnectorMvccSnapshot snap = metadataWith(ops) + .resolveTimeTravel(null, handle, ConnectorTimeTravelSpec.timestamp("1700000000000", true)) + .get(); + + // WHY: a DIGITAL timestamp is epoch-millis (legacy Long.parseLong), fed straight to the + // at-or-before lookup. MUTATION: re-parsing the digits as a datetime string / not feeding the + // verbatim millis -> the captured arg != 1700000000000 -> red. + Assertions.assertEquals(1_700_000_000_000L, ops.snapshotIdAtOrBeforeArg, + "a digital timestamp must be fed to the at-or-before lookup as raw epoch-millis"); + Assertions.assertEquals(17L, snap.getSnapshotId(), + "the at-or-before snapshot id must be pinned"); + Assertions.assertEquals(2L, snap.getSchemaId(), "the snapshot's schemaId must be stamped"); + Assertions.assertEquals("17", snap.getProperties().get("scan.snapshot-id"), + "scan.snapshot-id must be pinned to the resolved id"); + } + + @Test + public void resolveTimestampStringParsedWithSessionTimeZone() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.snapshotIdAtOrBefore = OptionalLong.of(8L); + + String literal = "2023-11-15 00:00:00"; + // Expected millis = exactly what paimon's DateTimeUtils computes for THIS literal in THIS zone + // (the byte-parity reference: legacy parsed the same way with TimeUtils.getTimeZone()). + long expectedShanghai = DateTimeUtils.parseTimestampData(literal, 3, + TimeZone.getTimeZone("Asia/Shanghai")).getMillisecond(); + long expectedUtc = DateTimeUtils.parseTimestampData(literal, 3, + TimeZone.getTimeZone("UTC")).getMillisecond(); + // Guard the test itself: the two zones must differ, else the assertion below proves nothing. + Assertions.assertNotEquals(expectedShanghai, expectedUtc, + "test precondition: the literal must resolve to different millis in the two zones"); + + metadataWith(ops).resolveTimeTravel(new TzSession("Asia/Shanghai"), handle, + ConnectorTimeTravelSpec.timestamp(literal, false)); + + // WHY: a non-digital timestamp must be parsed with the SESSION time zone (byte-parity with + // legacy PaimonUtil.getPaimonSnapshotByTimestamp's TimeUtils.getTimeZone()), NOT a fixed UTC. + // MUTATION: parsing with a hardcoded UTC (or the JVM default) -> the captured millis equals + // expectedUtc, not expectedShanghai -> red. + Assertions.assertEquals(expectedShanghai, ops.snapshotIdAtOrBeforeArg, + "a string timestamp must be parsed with the session time zone, not UTC"); + } + + @Test + public void resolveTimestampStringWithGenuinelyUnknownZoneFailsLoud() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + + // WHY (FIX-TZ-ALIAS, no-silent-degrade invariant): after the fix the connector replicates the + // legacy alias map (SHORT_IDS + 4 Doris overrides), so CST/PST/EST now RESOLVE (see the two + // tests below). But an id absent from BOTH ZoneId.of's native set AND the alias map (e.g. + // "XYZ") must still FAIL LOUD — never silently degrade to a wrong zone (a wrong zone resolves + // the WRONG snapshot -> silently wrong rows). The fix only NARROWS the failure set to + // genuinely-unknown ids; it must not become a silent UTC fallback. + // MUTATION: catching and degrading to UTC -> assertThrows finds no exception -> red; + // a raw DateTimeException leaking (no DorisConnectorException wrap) -> wrong type -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> metadataWith(ops).resolveTimeTravel(new TzSession("XYZ"), handle, + ConnectorTimeTravelSpec.timestamp("2023-01-01 00:00:00", false)), + "a genuinely-unknown zone id must fail loud, not crash with a raw " + + "DateTimeException nor silently degrade to a wrong zone"); + Assertions.assertTrue(ex.getMessage().contains("XYZ"), + "the error must name the offending session zone id ('XYZ')"); + Assertions.assertTrue(ex.getMessage().contains("standard") + && ex.getMessage().contains("zone id"), + "the error must give actionable guidance (use a standard zone id)"); + } + + @Test + public void resolveTimestampStringResolvesCstAliasToShanghai() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.snapshotIdAtOrBefore = OptionalLong.of(8L); + + String literal = "2023-11-15 00:00:00"; + long expectedShanghai = DateTimeUtils.parseTimestampData(literal, 3, + TimeZone.getTimeZone("Asia/Shanghai")).getMillisecond(); + long expectedUtc = DateTimeUtils.parseTimestampData(literal, 3, + TimeZone.getTimeZone("UTC")).getMillisecond(); + Assertions.assertNotEquals(expectedShanghai, expectedUtc, + "test precondition: the literal must resolve to different millis in CST vs UTC"); + + metadataWith(ops).resolveTimeTravel(new TzSession("CST"), handle, + ConnectorTimeTravelSpec.timestamp(literal, false)); + + // WHY (FIX-TZ-ALIAS): CST is Doris's DEFAULT region alias for Asia/Shanghai; legacy resolved + // it via the alias map (the 4 overrides put CST -> Asia/Shanghai). Pinning the *Shanghai* + // millis (not UTC, not a throw) is the byte-parity intent. Before the fix the alias-less + // ZoneId.of threw -> FOR TIME AS OF a datetime string was broken under the DEFAULT time zone. + // MUTATION: alias-less ZoneId.of -> throws (red); a wrong override (CST->UTC) -> captures + // expectedUtc (red). + Assertions.assertEquals(expectedShanghai, ops.snapshotIdAtOrBeforeArg, + "CST must resolve to Asia/Shanghai (Doris default alias), matching legacy"); + } + + @Test + public void resolveTimestampStringResolvesPstAndEstViaShortIds() { + String literal = "2023-11-15 00:00:00"; + + // PST resolves through ZoneId.SHORT_IDS -> America/Los_Angeles (NOT one of the 4 explicit + // Doris overrides). This is the report-suggestion correction: a fix that inlined only the 4 + // entries would leave PST/EST THROWING. The full SHORT_IDS map is required. + RecordingPaimonCatalogOps pstOps = new RecordingPaimonCatalogOps(); + pstOps.snapshotIdAtOrBefore = OptionalLong.of(3L); + long expectedPst = DateTimeUtils.parseTimestampData(literal, 3, + TimeZone.getTimeZone("America/Los_Angeles")).getMillisecond(); + metadataWith(pstOps).resolveTimeTravel(new TzSession("PST"), normalHandle(pstOps), + ConnectorTimeTravelSpec.timestamp(literal, false)); + // WHY: PST must resolve via SHORT_IDS to America/Los_Angeles. MUTATION: dropping + // putAll(ZoneId.SHORT_IDS) -> PST throws -> red. + Assertions.assertEquals(expectedPst, pstOps.snapshotIdAtOrBeforeArg, + "PST must resolve via ZoneId.SHORT_IDS to America/Los_Angeles, matching legacy"); + + // EST resolves through ZoneId.SHORT_IDS -> the fixed offset "-05:00". + RecordingPaimonCatalogOps estOps = new RecordingPaimonCatalogOps(); + estOps.snapshotIdAtOrBefore = OptionalLong.of(4L); + long expectedEst = DateTimeUtils.parseTimestampData(literal, 3, + TimeZone.getTimeZone(java.time.ZoneId.of("-05:00"))).getMillisecond(); + metadataWith(estOps).resolveTimeTravel(new TzSession("EST"), normalHandle(estOps), + ConnectorTimeTravelSpec.timestamp(literal, false)); + // WHY: EST must resolve via SHORT_IDS to the -05:00 offset. MUTATION: dropping + // putAll(ZoneId.SHORT_IDS) -> EST throws -> red. + Assertions.assertEquals(expectedEst, estOps.snapshotIdAtOrBeforeArg, + "EST must resolve via ZoneId.SHORT_IDS to the -05:00 offset, matching legacy"); + } + + @Test + public void resolveTimestampDigitalUnaffectedByUnsupportedZoneAlias() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.snapshotIdAtOrBefore = OptionalLong.of(11L); + + // WHY: the zone-id catch must be scoped to the STRING path only. A DIGITAL timestamp is raw + // epoch-millis and never touches ZoneId.of, so it must succeed even under a session whose + // zone id is GENUINELY unknown (would reject a datetime string). NOTE: this uses "XYZ" (a + // truly-unknown id) deliberately — after FIX-TZ-ALIAS "CST" now resolves, so a CST session + // would no longer prove the bypass (it would parse fine for strings too). "XYZ" still throws + // on the string path, so the test keeps its discriminating power. MUTATION: dropping the + // spec.isDigital() short-circuit -> the digital value goes to parseTimestampData with zone + // "XYZ" -> throws -> red. + ConnectorMvccSnapshot snap = metadataWith(ops) + .resolveTimeTravel(new TzSession("XYZ"), handle, + ConnectorTimeTravelSpec.timestamp("1700000000000", true)) + .get(); + + Assertions.assertEquals(1_700_000_000_000L, ops.snapshotIdAtOrBeforeArg, + "a digital timestamp must be fed verbatim even under an unknown zone id"); + Assertions.assertEquals(11L, snap.getSnapshotId(), + "the digital timestamp path must resolve normally under an unknown-zone session (no zone needed)"); + } + + @Test + public void resolveTimestampNoneAtOrBeforeReturnsEmpty() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.snapshotIdAtOrBefore = OptionalLong.empty(); // no snapshot <= ts (SDK returned null) + + // WHY: no snapshot at-or-before the timestamp must degrade to Optional.empty (empty-if-none; + // the fe-core consumer surfaces the legacy earliest-snapshot-hint error). MUTATION: returning + // a snapshot for the no-match case -> isPresent() true -> red. + Assertions.assertFalse(metadataWith(ops).resolveTimeTravel(null, handle, + ConnectorTimeTravelSpec.timestamp("1700000000000", true)).isPresent(), + "no snapshot at-or-before the timestamp must yield Optional.empty"); + } + + // ==================== resolveTimeTravel: TAG ==================== + + @Test + public void resolveTagFoundPinsTagNameNotId() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.tagSnapshot = new PaimonCatalogOps.TagSnapshot(42L, 4L); + + ConnectorMvccSnapshot snap = metadataWith(ops) + .resolveTimeTravel(null, handle, ConnectorTimeTravelSpec.tag("release-1")).get(); + + // WHY: tag time-travel must pin the tag's snapshot id + schema id, but the SCAN OPTION must be + // scan.tag-name = the NAME (legacy PaimonExternalTable.java:137 pins the name, not the id, so a + // later move of the tag is honored). MUTATION: pinning scan.snapshot-id (the id) instead of + // scan.tag-name -> the tag-name assertion red; not stamping schemaId -> -1 != 4 red. + Assertions.assertEquals(42L, snap.getSnapshotId(), "the tag's snapshot id must be pinned"); + Assertions.assertEquals(4L, snap.getSchemaId(), "the tag's schemaId must be stamped"); + Assertions.assertEquals("release-1", snap.getProperties().get("scan.tag-name"), + "tag time-travel must pin scan.tag-name to the tag NAME (not the snapshot id)"); + Assertions.assertNull(snap.getProperties().get("scan.snapshot-id"), + "tag time-travel must NOT pin scan.snapshot-id"); + } + + @Test + public void resolveTagNotFoundReturnsEmpty() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.tagSnapshot = null; // no such tag + + // WHY: a missing tag must degrade to Optional.empty (legacy threw "can't find snapshot by + // tag"; the fe-core consumer now surfaces that). MUTATION: returning a snapshot for an absent + // tag -> isPresent() true -> red. + Assertions.assertFalse(metadataWith(ops) + .resolveTimeTravel(null, handle, ConnectorTimeTravelSpec.tag("missing")).isPresent(), + "a missing tag must yield Optional.empty"); + } + + @Test + public void resolveVersionRefResolvesAsTagForParity() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.tagSnapshot = new PaimonCatalogOps.TagSnapshot(42L, 4L); + + // WHY: a non-numeric FOR VERSION AS OF '' is dispatched as VERSION_REF. For paimon this must + // resolve EXACTLY as a tag (legacy parity: PaimonExternalTable treats a non-digital FOR VERSION AS + // OF value as a tag name) — the connector stacks VERSION_REF onto its TAG case. MUTATION: dropping + // the VERSION_REF fall-through (so VERSION_REF hits the default) -> UnsupportedOperationException + // instead of a tag pin -> red. + ConnectorMvccSnapshot snap = metadataWith(ops) + .resolveTimeTravel(null, handle, ConnectorTimeTravelSpec.versionRef("release-1")).get(); + + Assertions.assertEquals(42L, snap.getSnapshotId()); + Assertions.assertEquals(4L, snap.getSchemaId()); + Assertions.assertEquals("release-1", snap.getProperties().get("scan.tag-name"), + "paimon must resolve a non-numeric FOR VERSION AS OF as a tag (scan.tag-name)"); + } + + // ==================== resolveTimeTravel: BRANCH ==================== + + @Test + public void resolveBranchFoundLoadsBranchTableAndPinsItsLatestSnapshot() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); // base table is ops.table + ops.branchExists = true; + // The branch is a DIFFERENT table double than the base, with its own latest snapshot/schema. + FakePaimonTable branch = new FakePaimonTable( + "t1", rowType("id", "dt"), Collections.emptyList(), Collections.emptyList()); + ops.branchTable = branch; + ops.latestSnapshotId = OptionalLong.of(7L); + ops.snapshotSchemaId = OptionalLong.of(3L); + + ConnectorMvccSnapshot snap = metadataWith(ops) + .resolveTimeTravel(null, handle, ConnectorTimeTravelSpec.branch("b1")).get(); + + // WHY: @branch must pin the BRANCH's LATEST snapshot + its schemaId, carry the branch identity + // via the CoreOptions.BRANCH sentinel (NOT scan.snapshot-id — the branch reads its own latest), + // and validate the branch on the BASE table. Branches have no in-branch time-travel (legacy + // reads the branch's latestSnapshot() only). MUTATION: pinning scan.snapshot-id -> the no-key + // assertion red; validating against the branch table -> the BASE assertion red; loading the + // base table instead of the branch -> the lastMvccTable assertion red. + Assertions.assertEquals(7L, snap.getSnapshotId(), + "@branch must pin the BRANCH's latest snapshot id"); + Assertions.assertEquals(3L, snap.getSchemaId(), + "@branch must stamp the BRANCH's latest snapshot schemaId"); + Assertions.assertEquals("b1", snap.getProperties().get(CoreOptions.BRANCH.key()), + "@branch must carry the branch name under the CoreOptions.BRANCH sentinel key"); + Assertions.assertNull(snap.getProperties().get("scan.snapshot-id"), + "@branch must NOT pin scan.snapshot-id (the branch natively reads its own latest)"); + // The sentinel key must be the SDK key, not a drifting hardcoded string. + Assertions.assertEquals("branch", CoreOptions.BRANCH.key(), + "precondition: the CoreOptions.BRANCH key is 'branch'"); + + // The branch was loaded via a 3-arg branch Identifier (real branch name, no system-table name). + Assertions.assertEquals("b1", ops.lastGetTableId.getBranchName(), + "the branch table must be loaded via a 3-arg branch Identifier"); + Assertions.assertNull(ops.lastGetTableId.getSystemTableName(), + "a branch load must NOT carry a system-table name"); + // The latest-snapshot / schemaId lookups ran against the BRANCH table, not the base. (The last + // seam call before this assertion is snapshotSchemaId, which captured lastMvccTable.) + Assertions.assertSame(branch, ops.lastMvccTable, + "latestSnapshotId/snapshotSchemaId must run against the BRANCH table"); + // branchExists validation ran against the BASE table (legacy resolvePaimonBranch). + Assertions.assertEquals("b1", ops.lastBranchExistsArg, + "branchExists must be asked about the requested branch name"); + // ...and validation ran against the BASE table (ops.table), NOT the freshly loaded branch. + // MUTATION: reordering so branch validation runs after the branch load (or against the branch + // table) -> lastBranchExistsTable would be the branch table -> both assertions red. + Assertions.assertSame(ops.table, ops.lastBranchExistsTable, + "branchExists must validate against the BASE table (legacy resolvePaimonBranch)"); + Assertions.assertNotSame(ops.branchTable, ops.lastBranchExistsTable, + "branchExists must NOT validate against the freshly loaded branch table"); + } + + @Test + public void resolveBranchNotFoundReturnsEmptyAndNeverLoadsBranch() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.branchExists = false; // branch does not exist on the base table + + // WHY: a missing branch must degrade to Optional.empty (empty-if-none; the B5b-3 fe-core + // consumer translates empty into the legacy "can't find branch" UserException), consistent + // with snapshot/tag not-found. The branch table must NEVER be loaded (no latest-snapshot work). + // MUTATION: dropping the branchExists guard -> a snapshot is returned / the branch is loaded -> + // both assertions red. + Assertions.assertFalse(metadataWith(ops) + .resolveTimeTravel(null, handle, ConnectorTimeTravelSpec.branch("nope")).isPresent(), + "a missing branch must yield Optional.empty"); + Assertions.assertFalse(ops.log.contains("latestSnapshotId"), + "a missing branch must NOT load the branch table / look up its latest snapshot"); + } + + @Test + public void resolveBranchEmptyBranchPinsInvalidSnapshotIdAndLatestSchema() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.branchExists = true; + ops.branchTable = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + ops.latestSnapshotId = OptionalLong.empty(); // branch has no snapshot yet + + ConnectorMvccSnapshot snap = metadataWith(ops) + .resolveTimeTravel(null, handle, ConnectorTimeTravelSpec.branch("b1")).get(); + + // WHY: an EMPTY branch (no snapshot) must pin snapshotId=-1 and schemaId=-1 (latest-schema + // fallback) WITHOUT calling snapshotSchemaId(-1), while still carrying the branch sentinel. + // This mirrors the INCREMENTAL empty-table -1 handling (benign divergence from legacy's 0L — + // the resulting schema is identical). MUTATION: calling snapshotSchemaId(-1) -> the log carries + // "snapshotSchemaId:-1" -> red; not stamping -1 -> red; dropping the sentinel -> red. + Assertions.assertEquals(-1L, snap.getSnapshotId(), + "an empty branch must pin the INVALID_SNAPSHOT_ID (-1)"); + Assertions.assertEquals(-1L, snap.getSchemaId(), + "an empty branch must leave schemaId=-1 (latest-schema fallback)"); + Assertions.assertEquals("b1", snap.getProperties().get(CoreOptions.BRANCH.key()), + "an empty branch must still carry the branch sentinel"); + Assertions.assertFalse(ops.log.contains("snapshotSchemaId:-1"), + "an empty branch must NOT resolve schemaId at the invalid snapshot id (-1)"); + } + + @Test + public void getTableSchemaOnEmptyBranchFallsBackToBranchLatestSchema() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); // base table is ops.table (single column "id") + ops.branchExists = true; + // The branch table's rowType DIFFERS from the base so the fallback can be proven to resolve + // the BRANCH table (not the base) when feeding the schemaId=-1 empty-branch snapshot back in. + FakePaimonTable branch = new FakePaimonTable( + "t1", rowType("bid", "bdt"), Collections.emptyList(), Collections.emptyList()); + ops.branchTable = branch; + ops.latestSnapshotId = OptionalLong.empty(); // branch has no snapshot yet + + PaimonConnectorMetadata metadata = metadataWith(ops); + ConnectorMvccSnapshot snap = metadata + .resolveTimeTravel(null, handle, ConnectorTimeTravelSpec.branch("b1")).get(); + // The resolved empty-branch snapshot carries schemaId=-1 (the documented benign divergence). + Assertions.assertEquals(-1L, snap.getSchemaId(), + "precondition: an empty branch resolves schemaId=-1"); + + // Feed that schemaId=-1 snapshot into getTableSchema on the BRANCH handle (the B5b-3 fe-core + // consumer does exactly this after resolveTimeTravel). + PaimonTableHandle branchHandle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()).withBranch("b1"); + ConnectorTableSchema schema = metadata.getTableSchema(null, branchHandle, snap); + + // WHY: the documented schemaId=-1 divergence is BENIGN because schemaId<0 routes to the latest + // fallback, which resolves the BRANCH table (via the 3-arg branch Identifier) and maps the + // BRANCH's latest rowType — identical to what legacy's branch latestSchema would yield. The + // -1 is never passed to schemaAt/snapshotSchemaId. MUTATION: calling schemaAt(-1) (or + // snapshotSchemaId(-1)) instead of the latest fallback -> the log carries "schemaAt:-1" / + // "snapshotSchemaId:-1" -> red; resolving the base table instead of the branch -> columns are + // ["id"] not ["bid","bdt"] -> red. + Assertions.assertEquals(Arrays.asList("bid", "bdt"), columnNames(schema), + "a schemaId=-1 empty-branch snapshot must fall back to the BRANCH table's latest schema"); + Assertions.assertFalse(ops.log.contains("schemaAt:-1"), + "a -1 schemaId must NOT call schemaAt"); + Assertions.assertFalse(ops.log.contains("snapshotSchemaId:-1"), + "a -1 schemaId must NOT resolve schemaId at the invalid snapshot id (-1)"); + } + + @Test + public void resolveBranchOnSysHandleReturnsEmpty() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = sysHandle(ops); + ops.branchExists = true; // would resolve if the sys guard were missing + + // WHY: system tables expose no time-travel (same guard as beginQuerySnapshot / the T19 scan + // fail-loud) — the sys guard must short-circuit BEFORE branchExists is ever consulted. + // MUTATION: dropping the isSystemTable() guard -> branchExists runs (log non-empty) and a + // snapshot is returned -> both assertions red. + Assertions.assertFalse(metadataWith(ops) + .resolveTimeTravel(null, handle, ConnectorTimeTravelSpec.branch("b1")).isPresent(), + "a system table must NOT expose branch time-travel"); + Assertions.assertTrue(ops.log.isEmpty(), + "a sys handle must short-circuit before touching the branch seam"); + } + + // ==================== branchExists seam: graceful non-FileStoreTable path ==================== + + @Test + public void branchExistsOnNonFileStoreTableIsGracefullyFalse() { + // WHY: a non-FileStoreTable backend (e.g. jdbc-only) cannot have branches, so the seam must + // return false gracefully rather than ClassCastException-ing the cast. FakePaimonTable is a + // plain Table (NOT a FileStoreTable), so this pins the instanceof guard. MUTATION: removing + // the instanceof guard -> the cast throws ClassCastException -> red. + PaimonCatalogOps.CatalogBackedPaimonCatalogOps ops = + new PaimonCatalogOps.CatalogBackedPaimonCatalogOps(null); + FakePaimonTable notFileStore = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + + Assertions.assertFalse(ops.branchExists(notFileStore, "b"), + "a non-FileStoreTable backend cannot have branches -> graceful false"); + } + + // ==================== resolveTimeTravel: INCREMENTAL (@incr) ==================== + + @Test + public void resolveIncrementalPinsLatestSnapshotAndThreadsIncrementalBetween() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.latestSnapshotId = OptionalLong.of(9L); + ops.snapshotSchemaId = OptionalLong.of(2L); + Map params = new java.util.HashMap<>(); + params.put("startSnapshotId", "1"); + params.put("endSnapshotId", "5"); + + ConnectorMvccSnapshot snap = metadataWith(ops) + .resolveTimeTravel(null, handle, ConnectorTimeTravelSpec.incremental(params)).get(); + + // WHY: legacy @incr reads at the LATEST snapshot (getProcessedTable copies the incremental + // options onto baseTable, which reads latest) and applies incremental-between at scan time, so + // the pin must carry the latest snapshot id + its schemaId AND the incremental-between option. + // MUTATION: not pinning latest (e.g. -1) -> id != 9 red; dropping the schemaId stamp -> -1 != 2 + // red; not producing incremental-between -> the property assertion red. + Assertions.assertEquals(9L, snap.getSnapshotId(), + "@incr must pin the LATEST snapshot id (legacy reads latest + applies incremental-between)"); + Assertions.assertEquals(2L, snap.getSchemaId(), + "@incr must stamp the latest snapshot's schemaId"); + Assertions.assertEquals("1,5", snap.getProperties().get("incremental-between"), + "@incr (both snapshot ids) must produce incremental-between=start,end"); + } + + @Test + public void resolveIncrementalDoesNotEmitScanSnapshotId() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.latestSnapshotId = OptionalLong.of(9L); + Map params = new java.util.HashMap<>(); + params.put("startSnapshotId", "1"); + params.put("endSnapshotId", "5"); + + ConnectorMvccSnapshot snap = metadataWith(ops) + .resolveTimeTravel(null, handle, ConnectorTimeTravelSpec.incremental(params)).get(); + + // WHY: @incr pins LATEST but must NOT emit scan.snapshot-id — that would conflict with + // incremental-between (legacy nulls scan.snapshot-id for @incr, PaimonScanNode line 842/846). + // applySnapshot threads the NON-EMPTY incremental properties verbatim and skips the + // scan.snapshot-id fallback precisely because properties is non-empty. + // MUTATION: also adding a scan.snapshot-id property (like SNAPSHOT_ID/TIMESTAMP do) -> the + // assertNull below + the applySnapshot end-to-end test go red. + Assertions.assertNull(snap.getProperties().get("scan.snapshot-id"), + "@incr must NOT emit scan.snapshot-id (it would conflict with incremental-between)"); + // And the null-reset keys legacy seeded (scan.snapshot-id / scan.mode) must be ABSENT here, + // NOT present-with-null. WHY (FIX-INCR-SCAN-RESET): the resolved ConnectorMvccSnapshot is the + // shared, source-agnostic SPI type and is null-free by contract (Builder.property rejects null; + // getProperties() is "never null"). The legacy null resets ARE required (a base table can + // persist a stale scan.snapshot-id/scan.mode), but they are reapplied LATER at the Table.copy + // chokepoint by PaimonIncrementalScanParams.applyResetsIfIncremental — never on this snapshot. + // MUTATION: re-introducing the null seeds here -> containsKey true (or a build-time NPE) -> red. + Assertions.assertFalse(snap.getProperties().containsKey("scan.snapshot-id"), + "the resolved snapshot must stay null-free — the scan.snapshot-id reset is reapplied at copy"); + Assertions.assertFalse(snap.getProperties().containsKey("scan.mode"), + "the resolved snapshot must stay null-free — the scan.mode reset is reapplied at copy"); + } + + @Test + public void resolveIncrementalEmptyTableFallsBackToInvalidSnapshotIdAndLatestSchema() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.latestSnapshotId = OptionalLong.empty(); // empty table: no snapshot yet + Map params = new java.util.HashMap<>(); + params.put("startTimestamp", "100"); + + ConnectorMvccSnapshot snap = metadataWith(ops) + .resolveTimeTravel(null, handle, ConnectorTimeTravelSpec.incremental(params)).get(); + + // WHY: an empty table must NOT crash the @incr resolve — it falls back to the INVALID_SNAPSHOT_ID + // (-1) and schemaId=-1 (so getTableSchema resolves the LATEST schema, never schemaAt(-1)). The + // incremental window is still produced (read applies it once data exists). + // MUTATION: calling snapshotSchemaId(-1) for an empty table -> the log carries "snapshotSchemaId:-1" + // -> red; not falling back to -1 -> red. + Assertions.assertEquals(-1L, snap.getSnapshotId(), + "@incr on an empty table must fall back to the INVALID_SNAPSHOT_ID (-1)"); + Assertions.assertEquals(-1L, snap.getSchemaId(), + "@incr on an empty table must leave schemaId=-1 (latest-schema fallback)"); + Assertions.assertFalse(ops.log.contains("snapshotSchemaId:-1"), + "an empty table must NOT resolve schemaId at the invalid snapshot id (-1)"); + } + + @Test + public void resolveIncrementalEndToEndAppliesIncrementalOptionsIntoHandle() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + ops.latestSnapshotId = OptionalLong.of(9L); + Map params = new java.util.HashMap<>(); + params.put("startTimestamp", "100"); + params.put("endTimestamp", "200"); + + PaimonConnectorMetadata metadata = metadataWith(ops); + ConnectorMvccSnapshot snap = metadata + .resolveTimeTravel(null, handle, ConnectorTimeTravelSpec.incremental(params)).get(); + PaimonTableHandle pinned = (PaimonTableHandle) metadata.applySnapshot(null, handle, snap); + + // WHY: the full @incr path must end with the incremental-between-timestamp option threaded onto + // the scan handle (NOT scan.snapshot-id), so the scan reads the incremental window. This is the + // contract the B5b-3 fe-core consumer relies on. MUTATION: applySnapshot falling back to + // scan.snapshot-id (because it ignored the non-empty properties) -> the timestamp assertion red + // and the scan.snapshot-id assertion red. + Assertions.assertEquals("100,200", pinned.getScanOptions().get("incremental-between-timestamp"), + "applySnapshot must thread the @incr incremental-between-timestamp option into the handle"); + Assertions.assertFalse(pinned.getScanOptions().containsKey("scan.snapshot-id"), + "an @incr pin must NOT thread scan.snapshot-id (conflicts with the incremental window)"); + } + + // ==================== getTableSchema(snapshot): schema-at-snapshot ==================== + + @Test + public void getTableSchemaAtSchemaIdResolvesHistoricalSchema() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); // latest rowType has only "id" + // The pinned schema has DIFFERENT columns (id, dt) and a partition key — proving the schema + // came from schemaAt(schemaId), not the latest table. + List fields = rowType("id", "dt").getFields(); + ops.schemaAt = new PaimonCatalogOps.PaimonSchemaSnapshot( + fields, Arrays.asList("dt"), Collections.emptyList()); + ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder() + .snapshotId(7L).schemaId(2L).build(); + + ConnectorTableSchema schema = metadataWith(ops).getTableSchema(null, handle, snapshot); + + // WHY: under schema evolution a time-travel read must see the schema AS OF the pinned + // schemaId (legacy initSchema(schemaId)), mapping the historical fields + partition keys. + // MUTATION: ignoring the snapshot and returning the latest 1-column schema -> column count / + // names / partition_columns all red. + Assertions.assertEquals(2L, ops.lastSchemaAtArg, + "the schema must be resolved at the snapshot's schemaId"); + Assertions.assertEquals(Arrays.asList("id", "dt"), columnNames(schema), + "the at-snapshot schema's columns must be mapped (not the latest single-column schema)"); + Assertions.assertEquals("dt", schema.getProperties().get(ConnectorTableSchema.PARTITION_COLUMNS_KEY), + "the at-snapshot schema's partition keys must be emitted under the reserved partition-columns key"); + } + + @Test + public void getTableSchemaWithNegativeSchemaIdFallsBackToLatest() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); // latest rowType has only "id" + ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder() + .snapshotId(7L).build(); // schemaId defaults to -1 + + ConnectorTableSchema schema = metadataWith(ops).getTableSchema(null, handle, snapshot); + + // WHY: schemaId < 0 means "unknown schema version" -> the read must fall back to the latest + // schema, NOT call schemaAt (which would pass an invalid -1 to the SDK). MUTATION: calling + // schemaAt(-1) instead of the latest path -> the log carries "schemaAt:-1" -> red. + Assertions.assertEquals(Collections.singletonList("id"), columnNames(schema), + "a -1 schemaId must fall back to the latest schema"); + Assertions.assertFalse(ops.log.contains("schemaAt:-1"), + "a -1 schemaId must NOT call schemaAt"); + } + + private static List columnNames(ConnectorTableSchema schema) { + List names = new java.util.ArrayList<>(); + for (ConnectorColumn c : schema.getColumns()) { + names.add(c.getName()); + } + return names; + } + + // ============= getTableSchema(snapshot): cross-query schema-at-snapshot memo (FIX-B-MC2) ============= + + @Test + public void getTableSchemaAtSnapshotIsMemoizedAcrossQueries() { + // Two queries = two fresh PaimonConnectorMetadata (production builds one per query via + // getMetadata()), each with its OWN catalog-ops, sharing the connector-owned PaimonSchemaAtMemo. + PaimonSchemaAtMemo memo = new PaimonSchemaAtMemo(10000); + RecordingPaimonCatalogOps ops1 = new RecordingPaimonCatalogOps(); + RecordingPaimonCatalogOps ops2 = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + // Transient table set -> resolveTable issues no getTable; only schemaAt reaches the ops seam. + handle.setPaimonTable(new FakePaimonTable( + "t1", rowType("id", "dt"), Collections.emptyList(), Collections.emptyList())); + PaimonCatalogOps.PaimonSchemaSnapshot atSchema = new PaimonCatalogOps.PaimonSchemaSnapshot( + rowType("id", "dt").getFields(), Arrays.asList("dt"), Collections.emptyList()); + ops1.schemaAt = atSchema; + ops2.schemaAt = atSchema; + ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder().snapshotId(7L).schemaId(2L).build(); + + ConnectorTableSchema schema1 = metadataWith(ops1, memo).getTableSchema(null, handle, snapshot); + ConnectorTableSchema schema2 = metadataWith(ops2, memo).getTableSchema(null, handle, snapshot); + + // WHY: a repeated time-travel to the same (table, schemaId) must hit the connector-level memo and + // NOT re-read the schema file (restoring the legacy PaimonExternalMetaCache hit dropped by CACHE-P1). + // MUTATION: drop the memo -> the second query also calls schemaAt -> ops2.log gains "schemaAt:2". + Assertions.assertEquals(1, Collections.frequency(ops1.log, "schemaAt:2"), + "the first query must perform the schemaAt read"); + Assertions.assertFalse(ops2.log.contains("schemaAt:2"), + "the second query at the same schemaId must hit the memo and NOT re-read schemaAt"); + Assertions.assertEquals(columnNames(schema1), columnNames(schema2), + "both queries must resolve the same at-snapshot schema"); + } + + @Test + public void getTableSchemaAtSnapshotMemoIsKeyedBySchemaId() { + PaimonSchemaAtMemo memo = new PaimonSchemaAtMemo(10000); + RecordingPaimonCatalogOps ops1 = new RecordingPaimonCatalogOps(); + RecordingPaimonCatalogOps ops2 = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList())); + ops1.schemaAt = new PaimonCatalogOps.PaimonSchemaSnapshot( + rowType("id").getFields(), Collections.emptyList(), Collections.emptyList()); + ops2.schemaAt = new PaimonCatalogOps.PaimonSchemaSnapshot( + rowType("id", "v2").getFields(), Collections.emptyList(), Collections.emptyList()); + + metadataWith(ops1, memo).getTableSchema(null, handle, + ConnectorMvccSnapshot.builder().snapshotId(7L).schemaId(2L).build()); + metadataWith(ops2, memo).getTableSchema(null, handle, + ConnectorMvccSnapshot.builder().snapshotId(9L).schemaId(3L).build()); + + // WHY: a DIFFERENT schemaId is a different schema version (schema evolution), so it must NOT be + // served from schemaId=2's entry. MUTATION: drop schemaId from the key -> schemaId=3 hits + // schemaId=2's entry -> ops2 never reads -> "schemaAt:3" absent -> red. + Assertions.assertTrue(ops2.log.contains("schemaAt:3"), + "a different schemaId must miss the memo and read its own schema"); + } + + @Test + public void getTableSchemaAtSnapshotMemoIsKeyedByBranch() { + PaimonSchemaAtMemo memo = new PaimonSchemaAtMemo(10000); + // Query 1: BASE handle at schemaId=2 (transient table set -> no reload). + RecordingPaimonCatalogOps baseOps = new RecordingPaimonCatalogOps(); + PaimonTableHandle baseHandle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + baseHandle.setPaimonTable(new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList())); + baseOps.schemaAt = new PaimonCatalogOps.PaimonSchemaSnapshot( + rowType("id").getFields(), Collections.emptyList(), Collections.emptyList()); + // Query 2: BRANCH handle (db1.t1@b1) at the SAME schemaId=2; withBranch clears the transient table + // so resolveTable reloads the branch table, whose at-schemaId schema differs (bid, bdt). + RecordingPaimonCatalogOps branchOps = new RecordingPaimonCatalogOps(); + PaimonTableHandle branchHandle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()).withBranch("b1"); + branchOps.branchTable = new FakePaimonTable( + "t1", rowType("bid", "bdt"), Collections.emptyList(), Collections.emptyList()); + branchOps.schemaAt = new PaimonCatalogOps.PaimonSchemaSnapshot( + rowType("bid", "bdt").getFields(), Arrays.asList("bdt"), Collections.emptyList()); + ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder().snapshotId(7L).schemaId(2L).build(); + + ConnectorTableSchema baseSchema = + metadataWith(baseOps, memo).getTableSchema(null, baseHandle, snapshot); + ConnectorTableSchema branchSchema = + metadataWith(branchOps, memo).getTableSchema(null, branchHandle, snapshot); + + // WHY: the same schemaId on a different BRANCH is a different schema, so the branch query must miss + // the base entry and read its own. MUTATION: drop branchName from the key -> the branch query hits + // the base entry -> (a) branchOps never reads "schemaAt:2" AND (b) branch columns == base [id] -> red. + Assertions.assertTrue(branchOps.log.contains("schemaAt:2"), + "a branch handle at the same schemaId must miss the base entry and read the branch schema"); + Assertions.assertEquals(Arrays.asList("bid", "bdt"), columnNames(branchSchema), + "the branch query must return the branch schema, not a base value cached under a branch-blind key"); + Assertions.assertEquals(Collections.singletonList("id"), columnNames(baseSchema), + "sanity: the base query returns the base schema"); + } + + // ==================== applySnapshot ==================== + + @Test + public void applySnapshotEmptyPropsFallsBackToScanSnapshotId() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + // A latest-pin snapshot (from beginQuerySnapshot) carries NO properties, only an id. + ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder().snapshotId(5L).build(); + + PaimonTableHandle pinned = (PaimonTableHandle) + metadataWith(ops).applySnapshot(null, handle, snapshot); + + // WHY: when the snapshot carries no resolved scan options (the beginQuerySnapshot latest-pin + // path), applySnapshot must FALL BACK to scan.snapshot-id= for B5a parity so the scan + // reads at that exact version. MUTATION: dropping the empty-props fallback -> getScanOptions() + // is empty -> red; wrong id -> value != "5" -> red. + Assertions.assertEquals("5", pinned.getScanOptions().get("scan.snapshot-id"), + "empty-props applySnapshot must fall back to scan.snapshot-id = the snapshot id"); + Assertions.assertTrue(handle.getScanOptions().isEmpty(), + "applySnapshot must NOT mutate the input handle (returns a new pinned copy)"); + } + + @Test + public void applySnapshotThreadsFullPropertiesVerbatim() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + // A TAG time-travel snapshot pins scan.tag-name (NOT scan.snapshot-id) in its properties. + ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder() + .snapshotId(42L) + .property("scan.tag-name", "release-1") + .build(); + + PaimonTableHandle pinned = (PaimonTableHandle) + metadataWith(ops).applySnapshot(null, handle, snapshot); + + // WHY: applySnapshot must thread the FULL resolved properties map (here scan.tag-name) so a + // tag read pins the tag, NOT a snapshot id. MUTATION: the old id-only logic would set + // scan.snapshot-id=42 and drop scan.tag-name -> both assertions red. + Assertions.assertEquals("release-1", pinned.getScanOptions().get("scan.tag-name"), + "applySnapshot must thread the resolved scan.tag-name property"); + Assertions.assertNull(pinned.getScanOptions().get("scan.snapshot-id"), + "a tag-name pin must NOT also set scan.snapshot-id (the id is not the scan option)"); + } + + @Test + public void applySnapshotOnSysHandleReturnsHandleUnchanged() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = sysHandle(ops); + ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder().snapshotId(5L).build(); + + PaimonTableHandle result = (PaimonTableHandle) + metadataWith(ops).applySnapshot(null, handle, snapshot); + + // WHY: system tables (e.g. t$snapshots) are synthetic metadata views with no MVCC — pinning + // them to a data snapshot is meaningless (same guard as beginQuerySnapshot / the T19 scan + // fail-loud). The sys handle must come back unchanged, NOT carrying scan.snapshot-id. + // MUTATION: dropping the isSystemTable() guard -> getScanOptions() carries scan.snapshot-id + // -> red. + Assertions.assertSame(handle, result, + "a sys handle must be returned unchanged (sys tables have no MVCC)"); + Assertions.assertTrue(result.getScanOptions().isEmpty(), + "a sys handle must NOT be pinned with scan options"); + } + + @Test + public void applySnapshotWithInvalidSnapshotIdReturnsHandleUnchanged() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + // beginQuerySnapshot pins INVALID_SNAPSHOT_ID (-1) for an empty table (NOT Optional.empty), + // and that -1 flows straight back into applySnapshot. + ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder().snapshotId(-1L).build(); + + PaimonTableHandle result = (PaimonTableHandle) + metadataWith(ops).applySnapshot(null, handle, snapshot); + + // WHY: an empty-table pin (-1) must NOT become scan.snapshot-id=-1: Table.copy(-1) resolves to + // a non-existent snapshot in the paimon SDK (confusing "snapshot/file not found"). Legacy never + // copied an invalid id — its empty / query-begin path reads latest WITHOUT a copy. So a -1 pin + // must leave the handle UNCHANGED (no scan option -> reads latest). + // MUTATION: removing the -1 guard (pinning -1) -> getScanOptions() carries scan.snapshot-id=-1 + // -> both assertions below go red. + Assertions.assertSame(handle, result, + "an INVALID_SNAPSHOT_ID (-1) pin must return the handle unchanged (read latest)"); + Assertions.assertTrue(result.getScanOptions().isEmpty(), + "a -1 snapshot must NOT pin scan.snapshot-id (would hit a non-existent snapshot)"); + } + + @Test + public void applySnapshotWithNullSnapshotReturnsHandleUnchanged() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + + PaimonTableHandle result = (PaimonTableHandle) + metadataWith(ops).applySnapshot(null, handle, null); + + // WHY: a null snapshot must be tolerated (no NPE on snapshot.getSnapshotId()) and treated as + // "no pin" — same read-latest behavior as the -1 empty-table case. + // MUTATION: dropping the null guard -> snapshot.getSnapshotId() NPEs -> red. + Assertions.assertSame(handle, result, + "a null snapshot must return the handle unchanged (no pin, read latest)"); + Assertions.assertTrue(result.getScanOptions().isEmpty(), + "a null snapshot must NOT pin scan options"); + } + + @Test + public void applySnapshotWithBranchSentinelRoutesToWithBranch() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); // has a transient base Table set + ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder() + .snapshotId(7L) + .property(CoreOptions.BRANCH.key(), "b1") + .build(); + + PaimonTableHandle pinned = (PaimonTableHandle) + metadataWith(ops).applySnapshot(null, handle, snapshot); + + // WHY: the CoreOptions.BRANCH sentinel is a handle-IDENTITY change (a different table load), + // NOT a scan option — applySnapshot must route it to withBranch (which clears the transient + // base Table so resolveTable reloads the BRANCH table), detected BEFORE the generic + // properties->withScanOptions path. MUTATION: not special-casing the sentinel -> it falls into + // withScanOptions, so branchName stays null and scanOptions carries "branch" -> all three + // assertions red. + Assertions.assertEquals("b1", pinned.getBranchName(), + "the branch sentinel must route to withBranch (handle identity), not a scan option"); + Assertions.assertTrue(pinned.getScanOptions().isEmpty(), + "a branch pin must NOT thread the sentinel as a scan-copy option"); + Assertions.assertNull(pinned.getPaimonTable(), + "withBranch must clear the transient base Table so the branch reloads"); + } + + @Test + public void applySnapshotScanSnapshotIdStillRoutesToScanOptions() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = normalHandle(ops); + // A snapshot-id/timestamp time-travel snapshot pins scan.snapshot-id (no branch sentinel). + ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder() + .snapshotId(9L) + .property("scan.snapshot-id", "9") + .build(); + + PaimonTableHandle pinned = (PaimonTableHandle) + metadataWith(ops).applySnapshot(null, handle, snapshot); + + // WHY (regression): adding the branch sentinel branch to applySnapshot must NOT regress the + // existing scan.snapshot-id path — a non-branch property map still routes to withScanOptions + // and leaves branchName null. MUTATION: the branch detection wrongly firing for a non-branch + // map -> branchName non-null / scanOptions empty -> both assertions red. + Assertions.assertEquals("9", pinned.getScanOptions().get("scan.snapshot-id"), + "a non-branch property map must still route to withScanOptions"); + Assertions.assertNull(pinned.getBranchName(), + "a non-branch pin must leave branchName null"); + // The transient Table must be PRESERVED: withScanOptions carries it over (same table, read at + // a version). MUTATION: a mistaken withBranch route (which clears the transient Table to force + // a branch reload) -> pinned.getPaimonTable() null -> red. + Assertions.assertSame(handle.getPaimonTable(), pinned.getPaimonTable(), + "withScanOptions must preserve the transient Table (not clear it like withBranch)"); + } + + // ==================== getTableSchema(snapshot): branch-aware ==================== + + @Test + public void getTableSchemaAtSchemaIdOnBranchHandleResolvesBranchSchema() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + // A branch-aware handle with NO transient Table (forces a branch reload), built via withBranch. + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()).withBranch("b1"); + // ops.table is the BASE (single column "id"); the branch table has different fields. + ops.table = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + FakePaimonTable branch = new FakePaimonTable( + "t1", rowType("bid", "bdt"), Collections.emptyList(), Collections.emptyList()); + ops.branchTable = branch; + // The at-schemaId schema (resolved through schemaAt) carries the branch's historical fields. + ops.schemaAt = new PaimonCatalogOps.PaimonSchemaSnapshot( + rowType("bid", "bdt").getFields(), Arrays.asList("bdt"), Collections.emptyList()); + ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder() + .snapshotId(7L).schemaId(2L).build(); + + ConnectorTableSchema schema = metadataWith(ops).getTableSchema(null, handle, snapshot); + + // WHY: getTableSchema(snapshot) with schemaId>=0 resolves schemaAt(resolveTable(handle)). For a + // branch handle, resolveTable reloads the BRANCH table, so schemaAt runs against the branch's + // schemaManager — branch-correct automatically (no branch logic in getTableSchema itself). + // MUTATION: resolveTable loading the base table instead of the branch -> schemaAt ran against + // ops.table (the base) -> the lastMvccTable assertion red. + Assertions.assertEquals(2L, ops.lastSchemaAtArg, + "the schema must be resolved at the snapshot's schemaId"); + Assertions.assertEquals(Arrays.asList("bid", "bdt"), columnNames(schema), + "the at-snapshot schema's columns must come from the BRANCH schema"); + Assertions.assertSame(branch, ops.lastMvccTable, + "schemaAt must run against the BRANCH table (resolveTable loaded the branch)"); + } + + @Test + public void getTableSchemaLatestOnBranchHandleResolvesBranchRowType() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()).withBranch("b1"); + ops.table = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + FakePaimonTable branch = new FakePaimonTable( + "t1", rowType("bid", "bdt"), Collections.emptyList(), Collections.emptyList()); + ops.branchTable = branch; + // schemaId < 0 -> latest fallback (no schemaAt); the columns must be the BRANCH table's fields. + ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder().snapshotId(7L).build(); + + ConnectorTableSchema schema = metadataWith(ops).getTableSchema(null, handle, snapshot); + + // WHY: with schemaId<0 the latest fallback resolves resolveTable(handle).rowType(); for a + // branch handle that is the BRANCH table's rowType (proving resolveTable loaded the branch via + // the 3-arg branch Identifier, not the base). MUTATION: resolveTable loading the base -> + // columns are ["id"] not ["bid","bdt"] -> red; calling schemaAt(-1) -> "schemaAt:-1" in log. + Assertions.assertEquals(Arrays.asList("bid", "bdt"), columnNames(schema), + "the latest fallback on a branch handle must resolve the BRANCH table's rowType"); + Assertions.assertFalse(ops.log.contains("schemaAt:-1"), + "a -1 schemaId must NOT call schemaAt"); + } + + // ==================== capabilities ==================== + + @Test + public void connectorDeclaresMvccCapability() { + // PaimonConnector is unit-constructable: getCapabilities() does NOT touch the catalog (the + // catalog is created lazily on first getMetadata/getScanPlanProvider call), so a null-config + // connector with a recording context suffices. + ConnectorContext ctx = new RecordingConnectorContext(); + Set caps = new PaimonConnector(Collections.emptyMap(), ctx).getCapabilities(); + + // WHY: B5's fe-core MvccTable wiring keys off this capability to decide whether paimon + // tables expose MVCC pinning. If it were absent (the inherited Connector default = emptySet), + // the E5 methods above would never be called. MUTATION: leaving getCapabilities() + // unoverridden (empty set) -> assertion red. + Assertions.assertTrue(caps.contains(ConnectorCapability.SUPPORTS_MVCC_SNAPSHOT), + "paimon must declare SUPPORTS_MVCC_SNAPSHOT"); + } + + @Test + public void connectorDeclaresColumnAutoAnalyzeButNotTopNLazyMaterialize() { + ConnectorContext ctx = new RecordingConnectorContext(); + Set caps = new PaimonConnector(Collections.emptyMap(), ctx).getCapabilities(); + + // WHY: paimon tables are queryable via the generic SQL-driven ExternalAnalysisTask FULL path, so the + // flip wires them into background per-column auto-analyze (paimon was never in the legacy + // instanceof-based whitelist). MUTATION: dropping SUPPORTS_COLUMN_AUTO_ANALYZE -> paimon stays + // excluded from auto-analyze -> first assertion red. + Assertions.assertTrue(caps.contains(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE), + "paimon must declare SUPPORTS_COLUMN_AUTO_ANALYZE"); + // Paimon was NEVER eligible for Top-N lazy materialization (legacy PaimonExternalTable was never in + // MaterializeProbeVisitor's supported set), so granting it would be a new unvalidated feature, not + // parity. MUTATION: declaring SUPPORTS_TOPN_LAZY_MATERIALIZE on paimon -> this assertion red. + Assertions.assertFalse(caps.contains(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE), + "paimon must NOT declare SUPPORTS_TOPN_LAZY_MATERIALIZE (parity: it was never eligible)"); + } + + @Test + public void connectorDeclaresShowCreateDdlCapability() { + ConnectorContext ctx = new RecordingConnectorContext(); + Set caps = new PaimonConnector(Collections.emptyMap(), ctx).getCapabilities(); + + // WHY: paimon's table properties (coreOptions incl. path) are user-facing and credential-free, so + // SHOW CREATE TABLE renders LOCATION + PROPERTIES for paimon. The capability replaces the legacy + // paimon-only engine-name gate in Env.getDdlStmt (the credential-leak guard now keyed on a capability + // instead of an engine string). MUTATION: dropping SUPPORTS_SHOW_CREATE_DDL -> paimon SHOW CREATE TABLE + // regresses to a comment-only shell -> red. + Assertions.assertTrue(caps.contains(ConnectorCapability.SUPPORTS_SHOW_CREATE_DDL), + "paimon must declare SUPPORTS_SHOW_CREATE_DDL so SHOW CREATE TABLE keeps rendering " + + "LOCATION/PROPERTIES"); + } +} 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 new file mode 100644 index 00000000000000..d439800a15ddf7 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataPartitionTest.java @@ -0,0 +1,377 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.ConnectorPartitionInfo; +import org.apache.doris.connector.api.scan.ConnectorPartitionValues; + +import org.apache.paimon.partition.Partition; +import org.apache.paimon.types.DataTypes; +import org.apache.paimon.types.RowType; +import org.apache.paimon.utils.DateTimeUtils; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Partition-listing tests for {@link PaimonConnectorMetadata} (P5-T08), pinning byte-parity with + * the legacy fe-core display-name logic ({@code PaimonUtil.generatePartitionInfo}). + * + *

    Like {@link PaimonConnectorMetadataTest}, these run entirely offline against the + * {@link RecordingPaimonCatalogOps} seam fake (null real catalog). The DATE epoch-day {@code 19723} + * deliberately renders to {@code 2024-01-01} via {@link DateTimeUtils#formatDate(int)}; the expected + * string is computed from the same SDK call so the assertion can never drift from the production + * formatter. + */ +public class PaimonConnectorMetadataPartitionTest { + + private static final int DT_EPOCH_DAY = 19723; // DateTimeUtils.formatDate(19723) == 2024-01-01 + + private static PaimonConnectorMetadata metadataWith(RecordingPaimonCatalogOps ops) { + // Read-path tests ignore the context; a default RecordingConnectorContext is a no-op wrapper. + return new PaimonConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext()); + } + + /** Two-key partitioned table: dt (DATE) + region (STRING). */ + private static RowType dtRegionRowType() { + return RowType.builder() + .field("id", DataTypes.INT()) + .field("dt", DataTypes.DATE()) + .field("region", DataTypes.STRING()) + .build(); + } + + private static PaimonTableHandle dtRegionHandle(FakePaimonTable table) { + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Arrays.asList("dt", "region"), Collections.emptyList()); + handle.setPaimonTable(table); + return handle; + } + + /** Real Paimon Partition fixture via the verified public 6-arg ctor. */ + private static Partition partition(Map spec, long recordCount, + long fileSizeInBytes, long lastFileCreationTime) { + return new Partition(spec, recordCount, fileSizeInBytes, /*fileCount*/ 1, lastFileCreationTime, + /*done*/ true); + } + + @Test + public void legacyNameTrueRendersDateKeyAndCarriesStats() { + 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; + Map spec = new LinkedHashMap<>(); + spec.put("dt", String.valueOf(DT_EPOCH_DAY)); + spec.put("region", "cn"); + ops.partitions = Collections.singletonList(partition(spec, 42L, 1024L, 1700000000000L)); + + PaimonTableHandle handle = dtRegionHandle(table); + + List names = metadataWith(ops).listPartitionNames(null, handle); + List infos = metadataWith(ops).listPartitions(null, handle, Optional.empty()); + + String expectedName = "dt=" + DateTimeUtils.formatDate(DT_EPOCH_DAY) + "/region=cn"; + // WHY: with legacy-name=true, Paimon stores DATE as an epoch-day int; the display name MUST + // render it through the SAME DateTimeUtils.formatDate the legacy fe-core used (19723 -> + // 2024-01-01), or the partition name diverges from every pre-migration cache/show output. + // MUTATION: dropping the `legacyName && isDate` branch (appending the raw int "19723") + // -> name becomes "dt=19723/region=cn" -> red. + Assertions.assertEquals(Collections.singletonList(expectedName), names); + + Assertions.assertEquals(1, infos.size()); + ConnectorPartitionInfo info = infos.get(0); + Assertions.assertEquals(expectedName, info.getPartitionName()); + // WHY: lastModifiedMillis must carry Partition.lastFileCreationTime() (NOT recordCount or + // sizeBytes); the 6-arg ctor arg order is load-bearing for downstream freshness checks. + // MUTATION: swapping the lastFileCreationTime arg for any other stat -> red. + Assertions.assertEquals(1700000000000L, info.getLastModifiedMillis()); + // WHY: rowCount/sizeBytes carry the Paimon partition stats verbatim. + // MUTATION: hardcoding UNKNOWN / swapping the two -> red. + Assertions.assertEquals(42L, info.getRowCount()); + Assertions.assertEquals(1024L, info.getSizeBytes()); + // WHY: partitionValues must be the RAW spec (epoch-day int, NOT date-rendered) because + // downstream indexes partitions by raw remote keys. MUTATION: storing the rendered name + // values (e.g. "2024-01-01") -> red. + Assertions.assertEquals(String.valueOf(DT_EPOCH_DAY), info.getPartitionValues().get("dt")); + Assertions.assertEquals("cn", info.getPartitionValues().get("region")); + } + + @Test + public void listPartitionsCarriesFileCount() { + 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; + Map spec = new LinkedHashMap<>(); + spec.put("dt", String.valueOf(DT_EPOCH_DAY)); + spec.put("region", "cn"); + // Every stat is a DISTINCT value so an arg-swap mutation cannot pass by coincidence. + // Paimon Partition ctor order: (spec, recordCount, fileSizeInBytes, fileCount, + // lastFileCreationTime, done). + ops.partitions = Collections.singletonList(new Partition( + spec, /*recordCount*/ 42L, /*fileSizeInBytes*/ 1024L, /*fileCount*/ 7L, + /*lastFileCreationTime*/ 1700000000000L, /*done*/ true)); + + ConnectorPartitionInfo info = metadataWith(ops) + .listPartitions(null, dtRegionHandle(table), Optional.empty()).get(0); + + // WHY: the SHOW PARTITIONS FileCount column (D-045) reads ConnectorPartitionInfo.getFileCount(), + // which MUST carry Paimon Partition.fileCount() — the 7th ctor arg added for this feature. + // MUTATION: dropping the partition.fileCount() feed (-> UNKNOWN=-1), or passing any other stat + // (recordCount/fileSizeInBytes/lastFileCreationTime) into the fileCount slot -> red. + Assertions.assertEquals(7L, info.getFileCount()); + Assertions.assertEquals(42L, info.getRowCount()); + Assertions.assertEquals(1024L, info.getSizeBytes()); + Assertions.assertEquals(1700000000000L, info.getLastModifiedMillis()); + } + + @Test + public void legacyNameFalseDoesNotRenderDateKey() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + FakePaimonTable table = new FakePaimonTable( + "t1", dtRegionRowType(), Arrays.asList("dt", "region"), Collections.emptyList()); + table.setOptions(Collections.singletonMap("partition.legacy-name", "false")); + ops.table = table; + Map spec = new LinkedHashMap<>(); + // With legacy-name=false the remote already stores the human-readable date string. + spec.put("dt", "2024-01-01"); + spec.put("region", "cn"); + ops.partitions = Collections.singletonList(partition(spec, 1L, 1L, 1L)); + + List names = metadataWith(ops).listPartitionNames(null, dtRegionHandle(table)); + + // WHY: with legacy-name=false the DATE value is ALREADY a date string and must pass through + // unchanged; re-rendering it (formatDate would parse "2024-01-01" as an int and throw, or + // mangle the value) breaks parity. MUTATION: always taking the DATE-render branch -> red + // (NumberFormatException on "2024-01-01"). + Assertions.assertEquals(Collections.singletonList("dt=2024-01-01/region=cn"), names); + } + + @Test + public void listPartitionValuesUsesRequestedColumnOrderWithRawValues() { + 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 to carry RAW values (the epoch-day int for dt, never the rendered date). + // MUTATION: iterating spec.entrySet()/keySet() instead of partitionColumns -> [19723, cn] + // instead of [cn, 19723] -> red; rendering dt -> "2024-01-01" instead of raw -> red. + Assertions.assertEquals( + Collections.singletonList(Arrays.asList("cn", String.valueOf(DT_EPOCH_DAY))), + values); + } + + /** Single STRING partition column {@code category}. */ + private static RowType categoryRowType() { + return RowType.builder() + .field("id", DataTypes.INT()) + .field("category", DataTypes.STRING()) + .build(); + } + + private static PaimonTableHandle categoryHandle(FakePaimonTable table) { + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.singletonList("category"), Collections.emptyList()); + handle.setPaimonTable(table); + return handle; + } + + @Test + public void nullPartitionValueRendersHiveDefaultSentinel() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + FakePaimonTable table = new FakePaimonTable( + "t1", categoryRowType(), Collections.singletonList("category"), Collections.emptyList()); + // No partition.default-name override -> Paimon's default sentinel. + table.setOptions(Collections.singletonMap("partition.legacy-name", "true")); + ops.table = table; + Map spec = new LinkedHashMap<>(); + // Paimon stores a genuine NULL partition value as its partition.default-name sentinel. + spec.put("category", "__DEFAULT_PARTITION__"); + ops.partitions = Collections.singletonList(partition(spec, 1L, 729L, 1700000000000L)); + + List names = metadataWith(ops).listPartitionNames(null, categoryHandle(table)); + + // WHY: Paimon renders a genuine NULL partition value as "__DEFAULT_PARTITION__". The display + // name MUST normalize it to the Doris-canonical null sentinel so the FE prune bridge marks the + // partition isNull and `category IS NULL` selects it (otherwise it is catalogued as the literal + // 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), + names); + } + + @Test + public void customDefaultPartitionNameIsHonoredAndOtherValuesUntouched() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + FakePaimonTable table = new FakePaimonTable( + "t1", categoryRowType(), Collections.singletonList("category"), Collections.emptyList()); + Map opts = new LinkedHashMap<>(); + opts.put("partition.legacy-name", "true"); + opts.put("partition.default-name", "__MY_NULL__"); + table.setOptions(opts); + ops.table = table; + Map nullSpec = new LinkedHashMap<>(); + nullSpec.put("category", "__MY_NULL__"); + Map literalSpec = new LinkedHashMap<>(); + // A literal value equal to Paimon's DEFAULT sentinel — but NOT this table's configured + // default-name — is real data and must pass through unchanged. + literalSpec.put("category", "__DEFAULT_PARTITION__"); + ops.partitions = Arrays.asList( + partition(nullSpec, 1L, 1L, 1L), + partition(literalSpec, 1L, 1L, 1L)); + + List names = metadataWith(ops).listPartitionNames(null, categoryHandle(table)); + + // WHY: the null sentinel is read from THIS table's partition.default-name option (mirroring how + // partition.legacy-name is read), not hardcoded. The configured "__MY_NULL__" maps to the null + // sentinel; a literal "__DEFAULT_PARTITION__" (not this table's default) stays verbatim. + // MUTATION: hardcoding "__DEFAULT_PARTITION__" as the sentinel -> the two rows swap which one is + // treated as null -> red. + Assertions.assertEquals( + Arrays.asList( + "category=" + ConnectorPartitionValues.HIVE_DEFAULT_PARTITION, + "category=__DEFAULT_PARTITION__"), + names); + } + + @Test + public void nullDatePartitionRendersSentinelInsteadOfCrashing() { + 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; + Map spec = new LinkedHashMap<>(); + // A genuine NULL value for a DATE partition column is ALSO the default-name sentinel, NOT an + // epoch-day integer. + spec.put("dt", "__DEFAULT_PARTITION__"); + spec.put("region", "cn"); + ops.partitions = Collections.singletonList(partition(spec, 1L, 1L, 1L)); + + List names = metadataWith(ops).listPartitionNames(null, dtRegionHandle(table)); + + // WHY: the default-name check must run BEFORE the legacy DATE-render branch, or a null DATE + // partition hits DateTimeUtils.formatDate(Integer.parseInt("__DEFAULT_PARTITION__")) and throws + // NumberFormatException, failing the whole listPartitions call. MUTATION: ordering the DATE + // branch first -> NumberFormatException -> red. + Assertions.assertEquals( + Collections.singletonList( + "dt=" + ConnectorPartitionValues.HIVE_DEFAULT_PARTITION + "/region=cn"), + names); + } + + @Test + public void nullPartitionSuppliesNullFlagTrue() { + // Variant B: paimon adopts genuine-NULL semantics. Its genuine-NULL partition value (rendered as the + // Doris-canonical sentinel in the NAME) must ALSO carry isNull=true in the connector-supplied flag, so + // the FE bridge builds a typed NullLiteral (not a StringLiteral). This realizes the connector's stated + // intent: `category IS NULL` prunes to the null partition and an MTMV refresh materializes the null rows. + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + FakePaimonTable table = new FakePaimonTable( + "t1", categoryRowType(), Collections.singletonList("category"), Collections.emptyList()); + table.setOptions(Collections.singletonMap("partition.legacy-name", "true")); + ops.table = table; + Map nullSpec = new LinkedHashMap<>(); + nullSpec.put("category", "__DEFAULT_PARTITION__"); + Map literalSpec = new LinkedHashMap<>(); + literalSpec.put("category", "bj"); + ops.partitions = Arrays.asList( + partition(nullSpec, 1L, 1L, 1L), + partition(literalSpec, 1L, 1L, 1L)); + + List infos = + metadataWith(ops).listPartitions(null, categoryHandle(table), Optional.empty()); + + // MUTATION: leaving paimon's flag false (pre-B parity) -> the null partition stays a StringLiteral -> red. + Assertions.assertEquals(Collections.singletonList(true), + infos.get(0).getPartitionValueNullFlags(), "genuine-null partition -> isNull flag true"); + 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, + infos.get(0).getPartitionName()); + } + + @Test + public void nonPartitionedHandleReturnsEmptyWithoutSeamCall() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + FakePaimonTable table = new FakePaimonTable( + "t1", RowType.builder().field("id", DataTypes.INT()).build(), + Collections.emptyList(), Collections.emptyList()); + ops.table = table; + // Empty partitionKeys == unpartitioned table. + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(table); + + 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 + // 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"); + } + + @Test + public void tableNotExistDuringListYieldsEmpty() { + 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; + ops.throwTableNotExist = true; // seam throws TableNotExistException on listPartitions + + List names = metadataWith(ops).listPartitionNames(null, dtRegionHandle(table)); + + // WHY: legacy getPaimonPartitions swallows TableNotExistException and returns empty rather + // than failing the query; the connector must preserve that. MUTATION: removing the catch + // (letting the checked exception propagate) -> the call throws instead of returning empty + // -> red. + Assertions.assertTrue(names.isEmpty()); + Assertions.assertTrue(ops.log.contains("listPartitions:db1.t1"), + "the seam must have been reached (and thrown) before the empty result"); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataReadAuthTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataReadAuthTest.java new file mode 100644 index 00000000000000..da83b09598f557 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataReadAuthTest.java @@ -0,0 +1,252 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; + +import org.apache.paimon.partition.Partition; +import org.apache.paimon.types.DataTypes; +import org.apache.paimon.types.RowType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * M-11 (rereview2 #6) read-path Kerberos doAs tests: every remote metadata READ RPC must run INSIDE + * {@link org.apache.doris.connector.spi.ConnectorContext#executeAuthenticated}, for full legacy + * parity (D-052) with legacy {@code PaimonMetadataOps}/{@code PaimonExternalCatalog} which wrapped + * every read. Mirrors the DDL-path tests in {@link PaimonConnectorMetadataDdlTest}. + * + *

    Uses {@link RecordingConnectorContext#failAuth} (throws WITHOUT running the task) to prove each + * seam call sits INSIDE the authenticator: if a read called the {@link RecordingPaimonCatalogOps} + * seam directly, the recording fake would log the call despite the auth failure. The happy-path + * companions assert {@link RecordingConnectorContext#authCount} so dropping a wrap also goes red. + * + *

    All offline against the recording seam (null real catalog). + */ +public class PaimonConnectorMetadataReadAuthTest { + + private static PaimonConnectorMetadata metadata(RecordingPaimonCatalogOps ops, + RecordingConnectorContext ctx) { + return new PaimonConnectorMetadata(ops, Collections.emptyMap(), ctx); + } + + private static PaimonTableHandle baseHandle() { + return new PaimonTableHandle("db1", "t1", Collections.emptyList(), Collections.emptyList()); + } + + private static FakePaimonTable singleColTable(String col) { + return new FakePaimonTable("t1", + RowType.builder().field(col, DataTypes.STRING()).build(), + Collections.emptyList(), Collections.emptyList()); + } + + // ==================== listDatabaseNames ==================== + + @Test + public void listDatabaseNamesRunsSeamInsideAuthenticator() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + // R3: listDatabaseNames now RETHROWS the failure (carrying the catalog name) instead of swallowing to + // empty, matching legacy PaimonMetadataOps. The seam still NEVER ran (log empty) yet the authenticator + // was entered, so the M-11 wrap coverage holds. MUTATION: an un-wrapped direct catalogOps.listDatabases() + // would log "listDatabases" despite the auth failure -> red; reverting to swallow-to-empty makes the + // assertThrows red. + RuntimeException ex = Assertions.assertThrows(RuntimeException.class, + () -> metadata(ops, ctx).listDatabaseNames(null)); + Assertions.assertTrue(ex.getMessage().contains(ctx.getCatalogName()), + "rethrown failure must carry the catalog name (legacy parity)"); + Assertions.assertTrue(ops.log.isEmpty(), + "auth failure must abort BEFORE the listDatabases seam runs"); + Assertions.assertEquals(1, ctx.authCount); + } + + @Test + public void listDatabaseNamesEntersAuthenticatorOnHappyPath() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.databases = Arrays.asList("db1", "db2"); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + Assertions.assertEquals(Arrays.asList("db1", "db2"), metadata(ops, ctx).listDatabaseNames(null)); + Assertions.assertEquals(Collections.singletonList("listDatabases"), ops.log); + Assertions.assertEquals(1, ctx.authCount); + } + + // ==================== databaseExists ==================== + + @Test + public void databaseExistsRunsSeamInsideAuthenticator() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + // databaseExists rethrows the auth failure as DorisConnectorException; the seam never ran. + Assertions.assertThrows(DorisConnectorException.class, + () -> metadata(ops, ctx).databaseExists(null, "db1")); + Assertions.assertTrue(ops.log.isEmpty(), + "auth failure must abort BEFORE the getDatabase seam runs"); + Assertions.assertEquals(1, ctx.authCount); + } + + @Test + public void databaseExistsTrueAndFalseEnterAuthenticator() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + Assertions.assertTrue(metadata(ops, ctx).databaseExists(null, "db1")); + Assertions.assertEquals(Collections.singletonList("getDatabase:db1"), ops.log); + Assertions.assertEquals(1, ctx.authCount); + + // DatabaseNotExistException is caught INSIDE the lambda (Kerberos UGI.doAs would otherwise + // wrap the checked exception, defeating an outer catch) -> returns false, no rethrow. + RecordingPaimonCatalogOps ops2 = new RecordingPaimonCatalogOps(); + ops2.throwDatabaseNotExist = true; + RecordingConnectorContext ctx2 = new RecordingConnectorContext(); + Assertions.assertFalse(metadata(ops2, ctx2).databaseExists(null, "db1")); + Assertions.assertEquals(1, ctx2.authCount); + } + + // ==================== listTableNames ==================== + + @Test + public void listTableNamesRunsSeamInsideAuthenticator() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + Assertions.assertTrue(metadata(ops, ctx).listTableNames(null, "db1").isEmpty()); + Assertions.assertTrue(ops.log.isEmpty(), + "auth failure must abort BEFORE the listTables seam runs"); + Assertions.assertEquals(1, ctx.authCount); + } + + @Test + public void listTableNamesEntersAuthenticatorOnHappyPath() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.tables = Arrays.asList("t1", "t2"); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + Assertions.assertEquals(Arrays.asList("t1", "t2"), metadata(ops, ctx).listTableNames(null, "db1")); + Assertions.assertEquals(Collections.singletonList("listTables:db1"), ops.log); + Assertions.assertEquals(1, ctx.authCount); + } + + // ==================== getTableHandle ==================== + + @Test + public void getTableHandleRunsSeamInsideAuthenticator() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + Optional result = metadata(ops, ctx).getTableHandle(null, "db1", "t1"); + Assertions.assertFalse(result.isPresent()); + Assertions.assertTrue(ops.log.isEmpty(), + "auth failure must abort BEFORE the getTable seam runs"); + Assertions.assertEquals(1, ctx.authCount); + } + + @Test + public void getTableHandleEntersAuthenticatorOnHappyPath() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = singleColTable("id"); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + Assertions.assertTrue(metadata(ops, ctx).getTableHandle(null, "db1", "t1").isPresent()); + Assertions.assertEquals(Collections.singletonList("getTable:db1.t1"), ops.log); + Assertions.assertEquals(1, ctx.authCount); + } + + // ==================== getSysTableHandle ==================== + + @Test + public void getSysTableHandleRunsSeamInsideAuthenticator() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + // getSysTableHandle rethrows the auth failure as RuntimeException; the sys getTable never ran. + Assertions.assertThrows(RuntimeException.class, + () -> metadata(ops, ctx).getSysTableHandle(null, baseHandle(), "snapshots")); + Assertions.assertTrue(ops.log.isEmpty(), + "auth failure must abort BEFORE the sys getTable seam runs"); + Assertions.assertEquals(1, ctx.authCount); + } + + @Test + public void getSysTableHandleEntersAuthenticatorOnHappyPath() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.sysTable = new FakePaimonTable("t1$snapshots", + RowType.builder().field("snapshot_id", DataTypes.BIGINT()).build(), + Collections.emptyList(), Collections.emptyList()); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + Assertions.assertTrue(metadata(ops, ctx).getSysTableHandle(null, baseHandle(), "snapshots").isPresent()); + Assertions.assertEquals(1, ctx.authCount); + } + + // ==================== listPartitions (resolveTable + listPartitions both wrapped) ==================== + + @Test + public void listPartitionNamesEntersAuthenticatorForBothResolveAndListPartitions() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + FakePaimonTable table = new FakePaimonTable("t1", + RowType.builder().field("region", DataTypes.STRING()).build(), + Collections.singletonList("region"), Collections.emptyList()); + ops.table = table; + Map spec = new LinkedHashMap<>(); + spec.put("region", "cn"); + ops.partitions = Collections.singletonList( + new Partition(spec, 1L, 1L, /*fileCount*/ 1, 1L, /*done*/ true)); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.singletonList("region"), Collections.emptyList()); + handle.setPaimonTable(table); + + List names = metadata(ops, ctx).listPartitionNames(null, handle); + + Assertions.assertEquals(Collections.singletonList("region=cn"), names); + // WHY: BOTH the table resolution (resolveTable) AND the listPartitions RPC must each run inside + // executeAuthenticated (D-052). The handle carries a transient table so resolveTable issues no + // getTable RPC, but it STILL enters the authenticator; listPartitions then enters it again. + // MUTATION: dropping the listPartitions wrap leaves authCount==1 (only resolveTable) -> red; + // dropping the resolveTable wrap leaves authCount==1 (only listPartitions) -> red. + Assertions.assertEquals(2, ctx.authCount); + Assertions.assertEquals(Collections.singletonList("listPartitions:db1.t1"), ops.log); + } + + @Test + public void listPartitionNamesAbortsInsideAuthenticatorOnAuthFailure() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + FakePaimonTable table = singleColTable("region"); + ops.table = table; + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.singletonList("region"), Collections.emptyList()); + handle.setPaimonTable(table); + + // The FIRST wrapped op (resolveTable) aborts under failAuth, so collectPartitions throws and + // neither getTable nor listPartitions ever runs. + Assertions.assertThrows(RuntimeException.class, + () -> metadata(ops, ctx).listPartitionNames(null, handle)); + Assertions.assertTrue(ops.log.isEmpty(), + "auth failure must abort BEFORE any partition-path seam runs"); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataStatisticsTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataStatisticsTest.java new file mode 100644 index 00000000000000..b92535ee7051a5 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataStatisticsTest.java @@ -0,0 +1,139 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.ConnectorTableStatistics; + +import org.apache.paimon.types.DataTypes; +import org.apache.paimon.types.RowType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.Optional; + +/** + * Unit tests for FIX-TABLE-STATS: {@link PaimonConnectorMetadata#getTableStatistics}. + * + *

    Before the fix the connector inherited the default {@code ConnectorStatisticsOps} (returns + * {@code Optional.empty()}), so every paimon table — normal AND system — reported row count -1 + * (UNKNOWN), degrading the Nereids cost model (join-reorder force-disabled) and SHOW/info_schema. + * The fix overrides it to sum {@code split.rowCount()} via the {@code PaimonCatalogOps.rowCount} + * seam (faked here — {@code FakePaimonTable.newReadBuilder()} throws, the whole reason for the + * seam). Each test FAILS before the fix (default empty) and PASSES after, and encodes WHY. + */ +public class PaimonConnectorMetadataStatisticsTest { + + private static PaimonConnectorMetadata metadataWith(RecordingPaimonCatalogOps ops) { + return new PaimonConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext()); + } + + private static RowType rowType(String... columnNames) { + RowType.Builder builder = RowType.builder(); + for (String name : columnNames) { + builder.field(name, DataTypes.INT()); + } + return builder.build(); + } + + @Test + public void positiveRowCountReturnedAsStatistics() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.rowCount = 42; + FakePaimonTable fake = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + ops.table = fake; + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(fake); + + Optional stats = metadataWith(ops).getTableStatistics(null, handle); + + // WHY: a real positive count must reach the FE cost model (not -1), else + // StatsCalculator force-disables join-reorder for the whole query. dataSize stays UNKNOWN(-1) + // (legacy computed no base-table dataSize here). Asserting lastRowCountTable == fake proves + // the metadata layer planned the RESOLVED table the handle denotes, not some other handle. + // MUTATION: inheriting the default empty -> not present -> red. + Assertions.assertTrue(stats.isPresent(), "a positive row count must be reported, not UNKNOWN"); + Assertions.assertEquals(42L, stats.get().getRowCount()); + Assertions.assertEquals(-1L, stats.get().getDataSize()); + Assertions.assertTrue(ops.log.contains("rowCount"), "the row-count seam must be invoked"); + Assertions.assertSame(fake, ops.lastRowCountTable, + "stats must be computed from the table the handle resolves to"); + } + + @Test + public void zeroRowCountMapsToUnknownEmpty() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.rowCount = 0; + FakePaimonTable fake = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + ops.table = fake; + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(fake); + + // WHY: legacy mapped 0 -> UNKNOWN(-1) (rowCount > 0 ? rowCount : UNKNOWN); the FE treats a + // present 0 as a real cardinality, which would corrupt cost estimates. So 0 MUST surface as + // empty, not (0,-1). MUTATION: dropping the >0 gate (returning (0,-1)) -> present -> red. + Assertions.assertFalse(metadataWith(ops).getTableStatistics(null, handle).isPresent(), + "0 rows must map to UNKNOWN (empty), matching legacy"); + } + + @Test + public void planningFailureReturnsEmptyNotThrow() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.throwOnRowCount = true; + FakePaimonTable fake = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + ops.table = fake; + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(fake); + + // WHY: stats collection is best-effort (runs in background analysis / SHOW paths); a transient + // remote planning failure must NOT propagate as a query-killing exception — it must degrade to + // UNKNOWN(-1). This is the deliberate divergence from legacy's propagate-up behavior. + // MUTATION: letting the exception propagate -> the assertDoesNotThrow fails -> red. + Optional stats = Assertions.assertDoesNotThrow( + () -> metadataWith(ops).getTableStatistics(null, handle)); + Assertions.assertFalse(stats.isPresent(), "a planning failure must degrade to UNKNOWN, not throw"); + } + + @Test + public void systemTableUsesResolvedSysTable() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.rowCount = 7; + FakePaimonTable sysFake = new FakePaimonTable( + "t1$snapshots", rowType("snapshot_id"), Collections.emptyList(), Collections.emptyList()); + ops.sysTable = sysFake; + PaimonTableHandle sysHandle = PaimonTableHandle.forSystemTable("db1", "t1", "snapshots", false); + sysHandle.setPaimonTable(sysFake); + + Optional stats = metadataWith(ops).getTableStatistics(null, sysHandle); + + // WHY: PluginDrivenSysExternalTable inherits the same fetchRowCount, and resolveTable is + // sys-aware, so the single override must serve system tables too (closes Finding 5.1). A + // future refactor that special-cased only normal tables would leave sys tables at -1. + // MUTATION: not handling sys handles / planning the wrong table -> rowCount!=7 or wrong table -> red. + Assertions.assertTrue(stats.isPresent()); + Assertions.assertEquals(7L, stats.get().getRowCount()); + Assertions.assertSame(sysFake, ops.lastRowCountTable, + "a sys handle must plan its OWN synthetic table's splits"); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataSysTableTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataSysTableTest.java new file mode 100644 index 00000000000000..9bc936df111285 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataSysTableTest.java @@ -0,0 +1,365 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorTableSchema; +import org.apache.doris.connector.api.handle.ConnectorColumnHandle; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; + +import org.apache.paimon.table.system.SystemTableLoader; +import org.apache.paimon.types.DataTypes; +import org.apache.paimon.types.RowType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Tests for the paimon E7 system-table capability (P5-T17): {@code listSupportedSysTables}, + * {@code getSysTableHandle}, and the sys-aware schema/columns reload path. + * + *

    Like the other metadata tests these drive a {@link RecordingPaimonCatalogOps} fake with a + * {@code null} real catalog, so they stay entirely offline (no live remote paimon). + */ +public class PaimonConnectorMetadataSysTableTest { + + private static PaimonConnectorMetadata metadataWith(RecordingPaimonCatalogOps ops) { + return new PaimonConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext()); + } + + private static RowType rowType(String... columnNames) { + RowType.Builder builder = RowType.builder(); + for (String name : columnNames) { + builder.field(name, DataTypes.INT()); + } + return builder.build(); + } + + private static PaimonTableHandle baseHandle() { + return new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + } + + @Test + public void listSupportedSysTablesReturnsSdkSystemTables() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + + List result = metadataWith(ops).listSupportedSysTables(null, baseHandle()); + + // WHY: the set of selectable "$sys" tables a user sees per paimon table IS exactly the + // paimon SDK's SystemTableLoader.SYSTEM_TABLES (legacy PaimonSysTable.SUPPORTED_SYS_TABLES + // is built from that same list). If this drifted, users could no longer reference e.g. + // mytable$snapshots. MUTATION: returning Collections.emptyList() (the SPI default) -> red. + Assertions.assertFalse(result.isEmpty(), "supported sys tables must be non-empty"); + Assertions.assertTrue(result.contains("snapshots"), + "the SDK system-table list must include 'snapshots'"); + Assertions.assertEquals(new ArrayList<>(SystemTableLoader.SYSTEM_TABLES), result, + "must mirror the paimon SDK SystemTableLoader.SYSTEM_TABLES exactly"); + } + + @Test + public void getSysTableHandleResolvesViaFourArgSysIdentifierBranchMain() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.sysTable = new FakePaimonTable("t1$snapshots", + rowType("snapshot_id", "schema_id"), + Collections.emptyList(), Collections.emptyList()); + + Optional opt = + metadataWith(ops).getSysTableHandle(null, baseHandle(), "snapshots"); + + Assertions.assertTrue(opt.isPresent(), "a supported sys table must yield a handle"); + PaimonTableHandle handle = (PaimonTableHandle) opt.get(); + // WHY: the sys table MUST be loaded through the EXISTING getTable seam using the 4-arg sys + // Identifier (db, table, branch="main", systemTable=sysName) — that is how paimon's catalog + // dispatches to the system table rather than the base table. The branch is hardcoded + // "main" for legacy parity (PaimonSysExternalTable#getSysPaimonTable). MUTATION: building a + // 2-arg Identifier.create(db,table) (dropping the sys name) -> getSystemTableName() null, + // branch null -> red; also the wrong (base) table would be resolved. + Assertions.assertNotNull(ops.lastGetTableId, "the getTable seam must have been called"); + Assertions.assertEquals("snapshots", ops.lastGetTableId.getSystemTableName(), + "the seam must be called with a 4-arg sys Identifier carrying the sys-table name"); + Assertions.assertEquals("main", ops.lastGetTableId.getBranchName(), + "the sys Identifier branch must be hardcoded 'main' (legacy parity)"); + Assertions.assertEquals("db1", ops.lastGetTableId.getDatabaseName()); + // getTableName() is the BARE base table ("t1"); getObjectName() would be "t1$snapshots". + Assertions.assertEquals("t1", ops.lastGetTableId.getTableName()); + + // WHY: the handle must self-describe as a sys table carrying the sys name and the resolved + // sys Table; downstream schema/column reads and (T19) scan routing depend on these. + // MUTATION: PaimonTableHandle.forSystemTable not setting sysTableName -> isSystemTable() + // false -> red. + Assertions.assertTrue(handle.isSystemTable(), "the returned handle must be a sys handle"); + Assertions.assertEquals("snapshots", handle.getSysTableName()); + Assertions.assertSame(ops.sysTable, handle.getPaimonTable(), + "the resolved sys Table must be stashed as the transient ref"); + } + + @Test + public void getSysTableHandleSnapshotsIsNotForceJni() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.sysTable = new FakePaimonTable("t1$snapshots", + rowType("snapshot_id"), Collections.emptyList(), Collections.emptyList()); + + PaimonTableHandle handle = (PaimonTableHandle) metadataWith(ops) + .getSysTableHandle(null, baseHandle(), "snapshots").get(); + + // WHY: only binlog/audit_log are NAME-forced to JNI (legacy + // PaimonScanNode.shouldForceJniForSystemTable). Metadata sys tables like 'snapshots' must + // NOT be name-forced here — their reader is decided by split type at scan time (T19). Over- + // forcing would push metadata tables through JNI even when a native path applies. MUTATION: + // hardcoding forceJni=true -> red. + Assertions.assertFalse(handle.isForceJni(), + "metadata sys table 'snapshots' must not be name-forced to JNI"); + } + + @Test + public void getSysTableHandleBinlogAndAuditLogAreForceJni() { + for (String sysName : new String[] {"binlog", "audit_log"}) { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.sysTable = new FakePaimonTable("t1$" + sysName, + rowType("c0"), Collections.emptyList(), Collections.emptyList()); + + PaimonTableHandle handle = (PaimonTableHandle) metadataWith(ops) + .getSysTableHandle(null, baseHandle(), sysName).get(); + + // WHY: binlog/audit_log are the two sys tables legacy name-forces to the JNI reader + // (PaimonScanNode.shouldForceJniForSystemTable). The hint must travel on the handle so + // T19 routing can honor it. MUTATION: dropping the binlog/audit_log check (forceJni + // always false) -> red. + Assertions.assertTrue(handle.isForceJni(), + sysName + " must be name-forced to JNI (legacy parity)"); + } + } + + @Test + public void getSysTableHandleForceJniIsCaseInsensitive() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.sysTable = new FakePaimonTable("t1$BINLOG", + rowType("c0"), Collections.emptyList(), Collections.emptyList()); + + PaimonTableHandle handle = (PaimonTableHandle) metadataWith(ops) + .getSysTableHandle(null, baseHandle(), "BINLOG").get(); + + // WHY: legacy uses equalsIgnoreCase for both the supported-name check and the force-JNI + // check, so "BINLOG"/"Binlog" must behave identically to "binlog". MUTATION: switching to + // case-sensitive equals -> "BINLOG" treated as not-force-JNI (and could even be rejected) + // -> red. + Assertions.assertTrue(handle.isSystemTable(), + "a case-different supported sys name must still resolve"); + Assertions.assertTrue(handle.isForceJni(), + "force-JNI must match the sys name case-insensitively"); + // WHY: legacy SysTable renders the suffix as "$" + sysTableName.toLowerCase() + // (SysTable.java:53), so the STORED handle name must be canonical lowercase — otherwise + // t$BINLOG and t$binlog become distinct handles (distinct equals/hashCode/toString and a + // distinct sys Identifier), splitting plan/cache identity. MUTATION: storing sysName + // verbatim -> getSysTableName() == "BINLOG" -> red. + Assertions.assertEquals("binlog", handle.getSysTableName(), + "the stored sys name must be normalized to lowercase (legacy parity)"); + Assertions.assertEquals("binlog", ops.lastGetTableId.getSystemTableName(), + "the sys Identifier must carry the lowercased canonical name"); + } + + @Test + public void getSysTableHandleMixedCaseYieldsCanonicalLowercaseHandle() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.sysTable = new FakePaimonTable("t1$SnapShots", + rowType("snapshot_id"), Collections.emptyList(), Collections.emptyList()); + + PaimonTableHandle mixed = (PaimonTableHandle) metadataWith(ops) + .getSysTableHandle(null, baseHandle(), "SnapShots").get(); + + // WHY: handle identity parity — a mixed-case input must canonicalize so that the handle + // built from "SnapShots" is interchangeable with one built from "snapshots" (same name, + // toString, sys Identifier). This is what prevents two cache/plan keys for one sys table. + // MUTATION: storing sysName verbatim -> getSysTableName() == "SnapShots", toString ends in + // "$SnapShots", Identifier carries "SnapShots" -> all three asserts red. + Assertions.assertEquals("snapshots", mixed.getSysTableName(), + "mixed-case input must canonicalize to lowercase"); + Assertions.assertEquals("db1.t1$snapshots", mixed.toString(), + "toString must render the canonical lowercase suffix"); + Assertions.assertEquals("snapshots", ops.lastGetTableId.getSystemTableName(), + "the sys Identifier must carry the lowercased canonical name"); + } + + @Test + public void getSysTableHandleNullNameReturnsEmptyWithoutSeamCall() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + + Optional opt = + metadataWith(ops).getSysTableHandle(null, baseHandle(), null); + + // WHY: the Javadoc contract is "or empty if not exposed" — a null sysName is simply not an + // exposed sys table, so it must return Optional.empty() (not NPE on toLowerCase / not a + // remote seam call). MUTATION: removing the null-guard in isSupportedSysTable -> NPE in + // equalsIgnoreCase or the later toLowerCase -> red (test would error instead of pass). + Assertions.assertFalse(opt.isPresent(), "a null sys name must yield Optional.empty()"); + Assertions.assertTrue(ops.log.isEmpty(), + "a null sys name must short-circuit before touching the seam"); + } + + @Test + public void getSysTableHandleUnknownNameReturnsEmptyWithoutSeamCall() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + + Optional opt = + metadataWith(ops).getSysTableHandle(null, baseHandle(), "not_a_sys_table"); + + // WHY: an unsupported name is "this connector does not expose that sys table" (empty), not + // an error — and, per design, we must not even hit the remote seam for an unknown name (it + // would be a wasted/failing remote call). MUTATION: removing the isSupportedSysTable guard + // -> the seam is called -> log non-empty -> red. + Assertions.assertFalse(opt.isPresent(), "an unknown sys name must yield Optional.empty()"); + Assertions.assertTrue(ops.log.isEmpty(), + "an unsupported sys name must short-circuit before touching the seam"); + } + + @Test + public void getSysTableHandleTableNotExistReturnsEmpty() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.throwTableNotExist = true; + + Optional opt = + metadataWith(ops).getSysTableHandle(null, baseHandle(), "snapshots"); + + // WHY: if the base table is gone the sys table cannot exist either; this must degrade to + // Optional.empty(), NOT propagate the checked TableNotExistException to the SPI caller. + // MUTATION: removing the TableNotExistException catch -> the checked exception escapes -> + // red. This branch is only forceable with a recording fake. + Assertions.assertFalse(opt.isPresent()); + } + + @Test + public void getTableSchemaForSysHandleBuildsColumnsFromSysRowType() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + // The BASE table has different columns than the SYS table; using the base table's rowType + // for a sys handle would be a silent bug, so they are deliberately different here. + ops.table = new FakePaimonTable("t1", + rowType("id", "name"), Collections.emptyList(), Collections.emptyList()); + FakePaimonTable sys = new FakePaimonTable("t1$snapshots", + rowType("snapshot_id", "schema_id", "commit_kind"), + Collections.emptyList(), Collections.emptyList()); + + PaimonTableHandle sysHandle = PaimonTableHandle.forSystemTable( + "db1", "t1", "snapshots", false); + sysHandle.setPaimonTable(sys); + + ConnectorTableSchema schema = metadataWith(ops).getTableSchema(null, sysHandle); + + // WHY: a sys handle's schema MUST come from the SYSTEM table's rowType, not the base + // table's. MUTATION: getTableSchema hardcoding the 2-arg base Identifier / base table would + // surface ["id","name"] -> red. + List columnNames = new ArrayList<>(); + for (ConnectorColumn c : schema.getColumns()) { + columnNames.add(c.getName()); + } + Assertions.assertEquals(Arrays.asList("snapshot_id", "schema_id", "commit_kind"), columnNames, + "sys-handle schema must be built from the system table's row type"); + } + + @Test + public void getColumnHandlesForSysHandleReloadsViaFourArgSysIdentifier() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + // Base table (would be served for a 2-arg Identifier) has DIFFERENT columns than the sys + // table, so a wrong-Identifier reload is detectable. + ops.table = new FakePaimonTable("t1", + rowType("id"), Collections.emptyList(), Collections.emptyList()); + ops.sysTable = new FakePaimonTable("t1$snapshots", + rowType("snapshot_id", "schema_id"), + Collections.emptyList(), Collections.emptyList()); + + // A deserialized sys handle: sysTableName set, transient Table lost (null). + PaimonTableHandle sysHandle = PaimonTableHandle.forSystemTable( + "db1", "t1", "snapshots", false); + Assertions.assertNull(sysHandle.getPaimonTable(), "precondition: transient table is null"); + + Map handles = + metadataWith(ops).getColumnHandles(null, sysHandle); + + // WHY: when the transient sys Table is lost (serialization round-trip), the reload MUST use + // the 4-arg sys Identifier so the SYSTEM table is re-fetched, not the base table. MUTATION: + // resolveTable always using Identifier.create(db,table) for the reload -> base columns + // ["id"] -> red. The captured Identifier's sys name proves the correct reload path. + Assertions.assertEquals(Arrays.asList("snapshot_id", "schema_id"), + new ArrayList<>(handles.keySet()), + "sys-handle columns must come from the system table's row type after reload"); + Assertions.assertNotNull(ops.lastGetTableId, "reload must have hit the seam"); + Assertions.assertEquals("snapshots", ops.lastGetTableId.getSystemTableName(), + "the reload must use the 4-arg sys Identifier (not the 2-arg base Identifier)"); + } + + @Test + public void sysHandleSurvivesJavaSerializationRoundTrip() throws Exception { + // Build a sys handle WITH a transient Table set, exactly as getSysTableHandle returns it. + FakePaimonTable sys = new FakePaimonTable("t1$binlog", + rowType("c0"), Collections.emptyList(), Collections.emptyList()); + PaimonTableHandle original = PaimonTableHandle.forSystemTable("db1", "t1", "binlog", true); + original.setPaimonTable(sys); + + // Real Java serialization round-trip (the FE/BE / plan-reuse wire mechanism). + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(original); + } + PaimonTableHandle restored; + try (ObjectInputStream ois = new ObjectInputStream( + new ByteArrayInputStream(baos.toByteArray()))) { + restored = (PaimonTableHandle) ois.readObject(); + } + + // WHY: the whole sys-aware reload-fallback (FIX 1 / metadata twin) only matters BECAUSE the + // sys identity (sysTableName, forceJni) is genuinely persisted across serialization while + // the live Table is NOT. This test proves both halves: the non-transient fields survive + // (so the deserialized handle still self-describes as binlog/force-JNI and can reload its + // OWN sys Table via the 4-arg Identifier) and the transient Table is dropped (so the reload + // path is actually exercised, never serving a stale base table). MUTATION: marking + // sysTableName transient -> getSysTableName() null + isSystemTable() false after deserialize + // -> red; (separately) making paimonTable non-transient -> getPaimonTable() != null -> red. + Assertions.assertTrue(restored.isSystemTable(), + "a deserialized sys handle must still be a sys handle (sysTableName non-transient)"); + Assertions.assertEquals("binlog", restored.getSysTableName(), + "the (lowercased) sys name must survive serialization"); + Assertions.assertTrue(restored.isForceJni(), + "the forceJni hint must survive serialization (non-transient)"); + Assertions.assertNull(restored.getPaimonTable(), + "the resolved Table must be transient — dropped on deserialize, forcing a reload"); + } + + @Test + public void sysHandleNotEqualToBaseHandle() { + PaimonTableHandle base = baseHandle(); + PaimonTableHandle sys = PaimonTableHandle.forSystemTable("db1", "t1", "snapshots", false); + + // WHY: a sys handle (db1.t1$snapshots) and its base handle (db1.t1) are DISTINCT tables to + // the engine; if they compared equal, plan/cache keying could collide and serve base rows + // for a sys-table query. sysTableName is therefore part of identity. MUTATION: dropping + // sysTableName from equals/hashCode -> base.equals(sys) true -> red. + Assertions.assertNotEquals(base, sys, "a sys handle must not equal its base handle"); + Assertions.assertNotEquals(base.hashCode(), sys.hashCode(), + "distinct identities should (here) hash differently"); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataTest.java new file mode 100644 index 00000000000000..accb266f1e06c1 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataTest.java @@ -0,0 +1,509 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorTableSchema; +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.paimon.types.DataField; +import org.apache.paimon.types.DataTypes; +import org.apache.paimon.types.RowType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Characterization tests for {@link PaimonConnectorMetadata}, pinning the read-path behavior + * after the {@link PaimonCatalogOps} seam extraction (B0). + * + *

    The seam fully covers every remote {@code Catalog} call the metadata makes, so each test + * drives a {@link RecordingPaimonCatalogOps} fake and builds the metadata with a {@code null} + * real catalog — the tests are entirely offline (no live remote catalog), which is the whole + * point of introducing the seam. + */ +public class PaimonConnectorMetadataTest { + + private static PaimonConnectorMetadata metadataWith(RecordingPaimonCatalogOps ops) { + // Read-path tests ignore the context; a default RecordingConnectorContext is a no-op wrapper. + return new PaimonConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext()); + } + + private static RowType rowType(String... columnNames) { + RowType.Builder builder = RowType.builder(); + for (String name : columnNames) { + builder.field(name, DataTypes.INT()); + } + return builder.build(); + } + + @Test + public void listDatabaseNamesDelegatesToOps() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.databases = Arrays.asList("db_a", "db_b"); + + List result = metadataWith(ops).listDatabaseNames(null); + + // WHY: listDatabaseNames must return exactly what the remote catalog reports, in order; + // it is the only source of the catalog's database list shown to users. + // MUTATION: returning Collections.emptyList() (dropping the delegation) -> red. + Assertions.assertEquals(Arrays.asList("db_a", "db_b"), result); + Assertions.assertEquals(Collections.singletonList("listDatabases"), ops.log, + "listDatabaseNames must make exactly one listDatabases() call on the seam"); + } + + @Test + public void databaseExistsTrueWhenGetDatabaseSucceeds() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + + boolean exists = metadataWith(ops).databaseExists(null, "db1"); + + // WHY: existence is defined as "getDatabase did not throw NotExist". A successful + // getDatabase must map to true. MUTATION: returning false on success -> red. + Assertions.assertTrue(exists); + Assertions.assertEquals(Collections.singletonList("getDatabase:db1"), ops.log); + } + + @Test + public void databaseExistsFalseWhenGetDatabaseThrowsNotExist() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.throwDatabaseNotExist = true; + + boolean exists = metadataWith(ops).databaseExists(null, "ghost"); + + // WHY: the contract is that DatabaseNotExistException means "absent" (false), NOT a + // thrown error to the caller. MUTATION: removing the catch (letting the exception + // propagate) or returning true -> red. This is exactly the branch a recording fake can + // exercise but a live-catalog test cannot reliably force. + Assertions.assertFalse(exists); + } + + @Test + public void listTableNamesDelegatesToOps() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.tables = Arrays.asList("t1", "t2"); + + List result = metadataWith(ops).listTableNames(null, "db1"); + + // WHY: listTableNames must surface exactly the remote table list for the given db. + // MUTATION: returning emptyList (dropping delegation) -> red. + Assertions.assertEquals(Arrays.asList("t1", "t2"), result); + Assertions.assertEquals(Collections.singletonList("listTables:db1"), ops.log); + } + + @Test + public void listTableNamesReturnsEmptyWhenDatabaseMissing() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.throwDatabaseNotExist = true; + + List result = metadataWith(ops).listTableNames(null, "ghost"); + + // WHY: a missing database must degrade to an empty list, not propagate the checked + // DatabaseNotExistException to the SPI caller. MUTATION: removing that catch -> red. + Assertions.assertTrue(result.isEmpty()); + } + + @Test + public void getTableHandleCarriesPartitionAndPrimaryKeysAndSetsTransientTable() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + FakePaimonTable table = new FakePaimonTable( + "t1", + rowType("id", "dt", "region"), + Arrays.asList("dt", "region"), + Collections.singletonList("id")); + ops.table = table; + + Optional handleOpt = metadataWith(ops).getTableHandle(null, "db1", "t1"); + + Assertions.assertTrue(handleOpt.isPresent()); + PaimonTableHandle handle = (PaimonTableHandle) handleOpt.get(); + // WHY: partition/primary keys are the serializable identity the FE later relies on for + // partition pruning and bucketing; they MUST be copied from the live table onto the + // handle. MUTATION: hardcoding emptyList for either -> red. + Assertions.assertEquals(Arrays.asList("dt", "region"), handle.getPartitionKeys(), + "partition keys must be carried from the Paimon table onto the handle"); + Assertions.assertEquals(Collections.singletonList("id"), handle.getPrimaryKeys(), + "primary keys must be carried from the Paimon table onto the handle"); + // WHY: the transient Table is the fast path used by getColumnHandles; failing to set it + // would force an extra remote reload on every column lookup. MUTATION: dropping + // handle.setPaimonTable(table) -> getPaimonTable() is null -> red. + Assertions.assertSame(table, handle.getPaimonTable(), + "the resolved Paimon table must be stashed on the handle as the transient ref"); + } + + @Test + public void getTableHandleEmptyWhenTableMissing() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.throwTableNotExist = true; + + Optional handleOpt = metadataWith(ops).getTableHandle(null, "db1", "ghost"); + + // WHY: a missing table is an absent handle (Optional.empty), not a thrown error. + // MUTATION: removing the TableNotExistException catch -> red. + Assertions.assertFalse(handleOpt.isPresent()); + } + + @Test + public void getColumnHandlesReloadFallbackReloadsWhenTransientTableNull() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = new FakePaimonTable( + "t1", + rowType("id", "name"), + Collections.emptyList(), + Collections.emptyList()); + // A handle whose transient Table is null (e.g. after serialization across the FE/BE + // boundary) — the metadata must reload via the seam rather than NPE. + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + Assertions.assertNull(handle.getPaimonTable(), "precondition: transient table is null"); + + Map handles = metadataWith(ops).getColumnHandles(null, handle); + + // WHY: this is the reload-fallback safety net. With a null transient Table, the only way + // to get column handles is to re-fetch the table from the catalog seam. MUTATION: + // removing the `if (table == null) { table = ops.getTable(id); }` block -> NPE on + // table.rowType() -> red. The recorded getTable call proves the reload happened. + Assertions.assertEquals(Arrays.asList("id", "name"), new java.util.ArrayList<>(handles.keySet()), + "column handles must be derived from the reloaded table's row type, in order"); + Assertions.assertTrue(ops.log.contains("getTable:db1.t1"), + "reload-fallback must re-fetch the table from the seam when the transient ref is null"); + } + + @Test + public void getColumnHandlesUsesTransientTableWithoutReload() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + FakePaimonTable table = new FakePaimonTable( + "t1", + rowType("id", "name"), + Collections.emptyList(), + Collections.emptyList()); + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(table); + + Map handles = metadataWith(ops).getColumnHandles(null, handle); + + // WHY: the fast path — when the transient Table is already present, getColumnHandles must + // use it and NOT make a redundant remote getTable call. MUTATION: always reloading would + // record a getTable entry -> red. This pins the reload as a fallback, not the default. + Assertions.assertEquals(Arrays.asList("id", "name"), new java.util.ArrayList<>(handles.keySet())); + Assertions.assertTrue(ops.log.isEmpty(), + "with a present transient table, no remote getTable reload must happen"); + } + + @Test + public void disablesCastPredicatePushdown() { + PaimonConnectorMetadata metadata = + new PaimonConnectorMetadata(null, Collections.emptyMap(), new RecordingConnectorContext()); + + // WHY: the shared converter unwraps CAST shells, so if this returned true (the SPI + // default), a predicate like CAST(str_col AS INT)=5 would be pushed to Paimon as + // str_col="5" and used for file/partition pruning, silently dropping rows like "05"/" 5" + // at the source (BE re-eval cannot recover source-dropped rows). Returning false keeps + // CAST conjuncts BE-only, mirroring MaxCompute/Jdbc. MUTATION: removing the override (or + // flipping it to true) reverts to the default true -> red. The getter touches no instance + // field, so a null ops / null session keeps this offline. + Assertions.assertFalse(metadata.supportsCastPredicatePushdown(null), + "Paimon must disable CAST-predicate pushdown: the converter unwraps CAST shells " + + "and pushing the stripped predicate under-matches at the source, " + + "silently dropping rows BE re-eval cannot recover"); + } + + // --------------------------------------------------------------------- + // FIX-READ-NOTNULL — read-path columns forced nullable (legacy parity) + // --------------------------------------------------------------------- + + @Test + public void getTableSchemaForcesColumnsNullableForLegacyParity() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + // A paimon NOT NULL field (PK-like) mixed with a nullable field; DataTypes.INT() is nullable + // by default, .notNull() flips it. Paimon forces PK columns NOT NULL, so this is the common case. + RowType rt = RowType.builder() + .field("id", DataTypes.INT().notNull()) + .field("val", DataTypes.INT()) + .build(); + FakePaimonTable table = new FakePaimonTable( + "t1", rt, Collections.emptyList(), Collections.singletonList("id")); + ops.table = table; + + ConnectorTableHandle handle = metadataWith(ops).getTableHandle(null, "db1", "t1").get(); + ConnectorTableSchema schema = metadataWith(ops).getTableSchema(null, handle); + + // WHY: legacy PaimonExternalTable always declared paimon columns nullable (isAllowNull=true) + // regardless of the field's NOT NULL flag, so nereids cannot fold null-rejecting predicates + // on a NOT NULL external column that can still read NULL (schema-evolution default-fill). A + // paimon PK NOT NULL field MUST still surface as nullable to Doris. MUTATION: reverting + // mapFields to field.type().isNullable() -> the 'id' column becomes isNullable()==false -> red. + ConnectorColumn id = schema.getColumns().get(0); + ConnectorColumn val = schema.getColumns().get(1); + Assertions.assertEquals("id", id.getName()); + Assertions.assertTrue(id.isNullable(), + "a paimon NOT NULL (PK) column must surface as nullable to Doris (legacy parity)"); + Assertions.assertTrue(val.isNullable()); + + // WHY (RC-6 DESC Key parity): legacy PaimonExternalTable/PaimonSysExternalTable built every + // column with isKey=true (3rd positional Column arg), so DESC shows Key=true for ALL paimon + // columns (PK and non-PK alike). MUTATION: reverting mapFields to the 5-arg ConnectorColumn ctor + // (isKey defaults to false) -> both assertions red, and DESC would regress to Key=false. + Assertions.assertTrue(id.isKey(), + "every paimon column must report isKey=true for legacy DESC Key parity"); + Assertions.assertTrue(val.isKey(), + "a non-PK paimon column must also report isKey=true (legacy set isKey=true for all)"); + } + + @Test + public void getTableSchemaPreservesPartitionColumnCase() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + // A paimon table whose partition key uses mixed case ("Pt"). + RowType rt = RowType.builder() + .field("id", DataTypes.INT()) + .field("Pt", DataTypes.STRING()) + .build(); + FakePaimonTable table = new FakePaimonTable( + "t1", rt, Collections.singletonList("Pt"), Collections.emptyList()); + ops.table = table; + + ConnectorTableHandle handle = metadataWith(ops).getTableHandle(null, "db1", "t1").get(); + ConnectorTableSchema schema = metadataWith(ops).getTableSchema(null, handle); + + // WHY (#65094 read-path alignment): column names are surfaced case-preserved (mapFields/getColumnHandles + // use bare .name()), and fe-core PluginDrivenExternalTable.initSchema matches each "partition_columns" + // entry against those column names via a case-sensitive byName lookup (paimon does not override + // fromRemoteColumnName). So "partition_columns" MUST keep the paimon case "Pt" to match the "Pt" column; + // lower-casing it to "pt" would miss the "Pt" column and silently treat the table as NON-partitioned. + // MUTATION: re-lowercase partition_columns (the pre-#65094 tier2 behavior) -> "pt" != "Pt" -> red. + Assertions.assertEquals("Pt", schema.getProperties().get(ConnectorTableSchema.PARTITION_COLUMNS_KEY)); + } + + @Test + public void getTableSchemaAtSnapshotAlsoForcesNullable() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + FakePaimonTable table = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.singletonList("id")); + ops.table = table; + // The historical (at-snapshot) schema's 'id' field is NOT NULL. + ops.schemaAt = new PaimonCatalogOps.PaimonSchemaSnapshot( + Collections.singletonList(new DataField(0, "id", DataTypes.INT().notNull())), + Collections.emptyList(), + Collections.singletonList("id")); + + ConnectorTableHandle handle = metadataWith(ops).getTableHandle(null, "db1", "t1").get(); + ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder().schemaId(5).build(); + ConnectorTableSchema schema = metadataWith(ops).getTableSchema(null, handle, snapshot); + + // WHY: the latest and at-snapshot read paths share mapFields; this pins that the time-travel + // path also obeys legacy nullable parity and cannot drift from the latest path. MUTATION: + // reverting mapFields to field.type().isNullable() -> the at-snapshot 'id' becomes + // non-nullable -> red. + Assertions.assertTrue(schema.getColumns().get(0).isNullable(), + "the at-snapshot read path must also force columns nullable (legacy parity)"); + } + + // --------------------------------------------------------------------- + // no-cache meta-cache: the LATEST schema must be read fresh via schemaManager().latest(), + // not the CachingCatalog-frozen rowType() (test_paimon_table_meta_cache line 112) + // --------------------------------------------------------------------- + + @Test + public void getTableSchemaReadsLatestSchemaNotCachedRowType() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + // The handle's transient Table is the CachingCatalog-cached instance whose rowType() is + // FROZEN at load time (2 columns) — this is what the connector used to read. + ops.table = new FakePaimonTable( + "t1", rowType("id", "name"), Collections.emptyList(), Collections.emptyList()); + // After an external ALTER ADD COLUMNS, the schema manager's latest() advances to 3 fields + // (a NEW schema file, with NO new snapshot). This is the live read the latest path must use. + ops.latestSchema = Optional.of(new PaimonCatalogOps.PaimonSchemaSnapshot( + Arrays.asList( + new DataField(0, "id", DataTypes.INT()), + new DataField(1, "name", DataTypes.INT()), + new DataField(2, "new_col", DataTypes.INT())), + Collections.emptyList(), + Collections.emptyList())); + + ConnectorTableHandle handle = metadataWith(ops).getTableHandle(null, "db1", "t1").get(); + ConnectorTableSchema schema = metadataWith(ops).getTableSchema(null, handle); + + // WHY: a paimon CachingCatalog caches the Table object; its rowType() is frozen at load time + // while schemaManager().latest() reads the schema directory fresh. An external ALTER ADD + // COLUMNS bumps the schema file (new id) WITHOUT a new snapshot, so the latest snapshot's + // schemaId stays behind and only schemaManager().latest() sees the 3rd column. The latest + // path MUST read schemaManager().latest() (legacy PaimonExternalTable parity), not the cached + // rowType(). This is the no-cache meta-cache regression (test_paimon_table_meta_cache line + // 112: expected 3 but was 2). MUTATION: reading table.rowType() (the cached 2-col schema) -> red. + Assertions.assertEquals(3, schema.getColumns().size(), + "the latest schema path must read schemaManager().latest() (3 cols after external " + + "ALTER), not the CachingCatalog-frozen rowType() (2 cols)"); + Assertions.assertEquals("new_col", schema.getColumns().get(2).getName(), + "the externally-added column must surface via schemaManager().latest()"); + } + + @Test + public void getTableSchemaKeepsSyntheticRowTypeForSystemTable() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + // A system table ($snapshots-like) whose synthetic rowType() is its OWN 2-col schema. + FakePaimonTable sysTbl = new FakePaimonTable( + "t1$snapshots", rowType("snapshot_id", "schema_id"), + Collections.emptyList(), Collections.emptyList()); + // latestSchema would return a DIFFERENT (base-table) 3-col schema; it MUST be ignored for a sys table. + ops.latestSchema = Optional.of(new PaimonCatalogOps.PaimonSchemaSnapshot( + Arrays.asList( + new DataField(0, "id", DataTypes.INT()), + new DataField(1, "name", DataTypes.INT()), + new DataField(2, "new_col", DataTypes.INT())), + Collections.emptyList(), Collections.emptyList())); + PaimonTableHandle handle = PaimonTableHandle.forSystemTable("db1", "t1", "snapshots", false); + handle.setPaimonTable(sysTbl); + + ConnectorTableSchema schema = metadataWith(ops).getTableSchema(null, handle); + + // WHY: system tables ($snapshots/$manifests are NOT DataTable; $ro/$audit_log/$binlog ARE + // DataTable but their correct DESC is the SYNTHETIC sys rowType(), not the base schema). The + // latest path MUST keep table.rowType() for ALL sys handles, guarded by isSystemTable(). + // MUTATION: dropping the isSystemTable() guard (always reading latestSchema) -> 3 cols (and + // a ClassCastException for the non-DataTable sys tables in production) -> red. + Assertions.assertEquals(2, schema.getColumns().size(), + "a system table must keep its synthetic rowType(), never schemaManager().latest()"); + Assertions.assertFalse(ops.log.contains("latestSchema"), + "the latest-schema read must be skipped for a system table"); + } + + @Test + public void getTableSchemaFallsBackToRowTypeWhenLatestSchemaAbsent() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = new FakePaimonTable( + "t1", rowType("id", "name"), Collections.emptyList(), Collections.emptyList()); + // latestSchema empty: a non-DataTable (FormatTable) backend or a schema-less table. + ops.latestSchema = Optional.empty(); + + ConnectorTableHandle handle = metadataWith(ops).getTableHandle(null, "db1", "t1").get(); + ConnectorTableSchema schema = metadataWith(ops).getTableSchema(null, handle); + + // WHY: when schemaManager().latest() is unavailable (non-DataTable backend / empty table), + // the latest path must fall back to table.rowType() rather than crash. MUTATION: + // unconditionally dereferencing latestSchema().get() -> NoSuchElementException -> red. + Assertions.assertEquals(2, schema.getColumns().size(), + "an absent latest schema must fall back to the table's rowType()"); + } + + // --------------------------------------------------------------------- + // FIX-MAPPING-FLAG-KEYS — type-mapping toggles read the canonical dotted + // CREATE-CATALOG keys (enable.mapping.varbinary / enable.mapping.timestamp_tz) + // --------------------------------------------------------------------- + + private static RowType binaryAndLtzRowType() { + return RowType.builder() + .field("b", DataTypes.BINARY(16)) + .field("ts_ltz", DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3)) + .build(); + } + + @Test + public void getTableSchemaHonorsDottedMappingKeys() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = new FakePaimonTable( + "t1", binaryAndLtzRowType(), Collections.emptyList(), Collections.emptyList()); + + // The user enables both mappings via the canonical DOTTED CREATE-CATALOG keys — the only + // spelling fe-core ever writes into the catalog property map (CatalogProperty.java:50,52; + // ExternalCatalog.setDefaultPropsIfMissing). The connector receives that raw map verbatim. + Map props = new java.util.HashMap<>(); + props.put("enable.mapping.varbinary", "true"); + props.put("enable.mapping.timestamp_tz", "true"); + PaimonConnectorMetadata metadata = + new PaimonConnectorMetadata(ops, props, new RecordingConnectorContext()); + + ConnectorTableHandle handle = metadata.getTableHandle(null, "db1", "t1").get(); + ConnectorTableSchema schema = metadata.getTableSchema(null, handle); + + // WHY: when the user enables the mapping at CREATE CATALOG, a Paimon BINARY column must + // surface as VARBINARY and a TIMESTAMP_WITH_LOCAL_TIME_ZONE column as TIMESTAMPTZ — legacy + // parity (PaimonExternalTable.java:350 reads the same dotted key and honors it). MUTATION: + // reverting the connector constants to the underscore spelling (the cutover bug: + // enable_mapping_binary_as_varbinary / enable_mapping_timestamp_tz) makes getOrDefault miss + // the dotted keys the map actually carries -> both flags read false -> the column types fall + // back to STRING / DATETIMEV2 -> red. This closes critic coverage-gap #2. + Assertions.assertEquals("VARBINARY", schema.getColumns().get(0).getType().getTypeName(), + "enable.mapping.varbinary=true must map Paimon BINARY to Doris VARBINARY"); + Assertions.assertEquals("TIMESTAMPTZ", schema.getColumns().get(1).getType().getTypeName(), + "enable.mapping.timestamp_tz=true must map Paimon LTZ to Doris TIMESTAMPTZ"); + } + + @Test + public void getTableSchemaDefaultsMappingFlagsOff() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = new FakePaimonTable( + "t1", binaryAndLtzRowType(), Collections.emptyList(), Collections.emptyList()); + + // No mapping keys set — the default (legacy-compatible) behavior. + PaimonConnectorMetadata metadata = + new PaimonConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext()); + + ConnectorTableHandle handle = metadata.getTableHandle(null, "db1", "t1").get(); + ConnectorTableSchema schema = metadata.getTableSchema(null, handle); + + // WHY: with the toggles absent, BINARY must map to STRING and LTZ to DATETIMEV2 (default + // false), matching legacy. This guards against a fix that accidentally flips the defaults on + // (e.g. reading the wrong default or inverting the boolean). MUTATION: defaulting either flag + // to true -> VARBINARY / TIMESTAMPTZ -> red. Green in both the buggy and fixed states (it + // pins the default, not the key spelling), so it is a regression guard, not the bug-catcher. + Assertions.assertEquals("STRING", schema.getColumns().get(0).getType().getTypeName(), + "absent enable.mapping.varbinary must leave Paimon BINARY as STRING (default off)"); + Assertions.assertEquals("DATETIMEV2", schema.getColumns().get(1).getType().getTypeName(), + "absent enable.mapping.timestamp_tz must leave Paimon LTZ as DATETIMEV2 (default off)"); + } + + @Test + public void getTableSchemaMarksLtzColumnsWithTimeZoneRegardlessOfMapping() { + // Legacy parity (test_paimon_catalog_timestamp_tz desc_1/desc_2): PaimonExternalTable.initSchema + // and PaimonSysExternalTable.buildFullSchema call column.setWithTZExtraInfo() whenever the SOURCE + // paimon type root is TIMESTAMP_WITH_LOCAL_TIME_ZONE — independent of enable.mapping.timestamp_tz. + // That marker becomes the DESC "Extra" column = WITH_TIMEZONE (IndexSchemaProcNode reads + // Column.getExtraInfo()). Because the connector maps an LTZ field to TIMESTAMPTZ when mapping is on + // but to a plain DATETIMEV2 when off, the marker cannot be recovered from the mapped Doris type + // alone; mapFields must carry it explicitly via ConnectorColumn.withTimeZone() in BOTH states so + // fe-core's ConnectorColumnConverter can re-apply setWithTZExtraInfo(). + // MUTATION: dropping the withTimeZone() mark in mapFields -> isWithTimeZone()==false -> the + // converter never sets the extra info -> DESC Extra goes blank -> red. + for (Map props : Arrays.asList( + Collections.emptyMap(), + Collections.singletonMap("enable.mapping.timestamp_tz", "true"))) { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = new FakePaimonTable( + "t1", binaryAndLtzRowType(), Collections.emptyList(), Collections.emptyList()); + PaimonConnectorMetadata metadata = + new PaimonConnectorMetadata(ops, props, new RecordingConnectorContext()); + + ConnectorTableHandle handle = metadata.getTableHandle(null, "db1", "t1").get(); + ConnectorTableSchema schema = metadata.getTableSchema(null, handle); + + Assertions.assertFalse(schema.getColumns().get(0).isWithTimeZone(), + "non-LTZ (BINARY) column must not carry the WITH_TIMEZONE marker; mapping props=" + props); + Assertions.assertTrue(schema.getColumns().get(1).isWithTimeZone(), + "Paimon LTZ column must carry the WITH_TIMEZONE marker regardless of mapping; props=" + + props); + } + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorPluginAuthenticatorTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorPluginAuthenticatorTest.java new file mode 100644 index 00000000000000..3b3946c962a677 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorPluginAuthenticatorTest.java @@ -0,0 +1,118 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.kerberos.HadoopAuthenticator; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Unit tests for {@link PaimonConnector#buildPluginAuthenticator(Map, Map)} — the connector-owned plugin-side + * Kerberos authenticator resolution (design S6, ported from {@code IcebergConnector.buildPluginAuthenticator}). + * + *

    The load-bearing NEW case is HMS-metastore Kerberos with simple (non-Kerberos) storage (e.g. a + * Kerberized Hive Metastore over S3). Before design S6 retires the fe-core pre-execution authenticator, that + * login was served fe-core-side by {@code PaimonHMSMetaStoreProperties} and delivered via + * {@code DefaultConnectorContext}; the paimon connector must own it once that handle is a no-op — otherwise + * S6 would silently drop Kerberos for a paimon secured-HMS-with-simple-storage catalog. These tests pin that + * the connector builds a plugin authenticator from the HMS client principal/keytab facts, and does NOT build + * one when the metastore is simple-auth (which would force needless SIMPLE-vs-Kerberos churn). + * + *

    The actual keytab login is lazy (on first {@code doAs}), so these assertions never touch a KDC. + */ +public class PaimonConnectorPluginAuthenticatorTest { + + private static Map props(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + /** Storage-level Kerberos (raw hadoop.security.authentication) — unchanged prior behavior, any flavor. */ + @Test + public void storageKerberosBuildsAuthenticator() { + HadoopAuthenticator auth = PaimonConnector.buildPluginAuthenticator( + props("paimon.catalog.type", "filesystem", + "warehouse", "hdfs://ns/warehouse", + "hadoop.security.authentication", "kerberos", + "hadoop.kerberos.principal", "doris@EXAMPLE.COM", + "hadoop.kerberos.keytab", "/etc/security/doris.keytab"), + new HashMap<>()); + Assertions.assertNotNull(auth, "storage kerberos must yield a plugin authenticator"); + } + + /** + * THE S6 GAP: a Kerberized HMS whose data storage is simple. Storage auth is unset, so the storage gate is + * off; the connector must fall back to the HMS client-principal/keytab facts and still build a plugin + * authenticator (mirroring the fe-core HMS authenticator it replaces). Without this, retiring the fe-core + * handle silently drops Kerberos for this catalog. + */ + @Test + public void hmsMetastoreKerberosWithSimpleStorageBuildsAuthenticator() { + HadoopAuthenticator auth = PaimonConnector.buildPluginAuthenticator( + props("paimon.catalog.type", "hms", + "hive.metastore.uris", "thrift://hms:9083", + "hive.metastore.authentication.type", "kerberos", + "hive.metastore.client.principal", "doris@EXAMPLE.COM", + "hive.metastore.client.keytab", "/etc/security/doris.keytab"), + new HashMap<>()); + Assertions.assertNotNull(auth, + "HMS-metastore kerberos with simple storage must yield a plugin authenticator"); + } + + /** A simple-auth HMS builds no authenticator (a spurious one would force needless SIMPLE-vs-Kerberos churn). */ + @Test + public void hmsSimpleAuthReturnsNull() { + HadoopAuthenticator auth = PaimonConnector.buildPluginAuthenticator( + props("paimon.catalog.type", "hms", + "hive.metastore.uris", "thrift://hms:9083", + "hive.metastore.authentication.type", "simple"), + new HashMap<>()); + Assertions.assertNull(auth, "simple-auth HMS must not build a plugin authenticator"); + } + + /** A non-HMS flavor with no storage Kerberos builds no authenticator. */ + @Test + public void nonHmsFlavorWithoutStorageKerberosReturnsNull() { + HadoopAuthenticator auth = PaimonConnector.buildPluginAuthenticator( + props("paimon.catalog.type", "filesystem", + "warehouse", "s3://bucket/warehouse"), + new HashMap<>()); + Assertions.assertNull(auth, "filesystem flavor without storage kerberos must not build an authenticator"); + } + + /** + * HMS declares kerberos auth-type but the client principal/keytab are blank — the {@code hasCredentials} + * guard must reject it (an authenticator with no login pair would fail obscurely at first doAs). + */ + @Test + public void hmsKerberosWithBlankCredsReturnsNull() { + HadoopAuthenticator auth = PaimonConnector.buildPluginAuthenticator( + props("paimon.catalog.type", "hms", + "hive.metastore.uris", "thrift://hms:9083", + "hive.metastore.authentication.type", "kerberos"), + new HashMap<>()); + Assertions.assertNull(auth, "kerberos HMS without a client principal/keytab pair must not build one"); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorPreCreateValidationTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorPreCreateValidationTest.java new file mode 100644 index 00000000000000..7e8cfcf64e9dd2 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorPreCreateValidationTest.java @@ -0,0 +1,153 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.ConnectorValidationContext; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Tests for {@link PaimonConnector#preCreateValidation} (rereview2 B-8b): a JDBC-flavor catalog + * with a {@code driver_url} must route it through the engine's + * {@link ConnectorValidationContext#validateAndResolveDriverPath} security gate at CREATE CATALOG, + * before the jar is ever loaded into the FE JVM. Mirrors {@code JdbcDorisConnector.preCreateValidation}. + * + *

    Offline: a hand-written {@link RecordingValidationContext} fake records each validated url and + * can simulate a rejected url. {@link RecordingConnectorContext} supplies the (unused-by-this-path) + * {@code ConnectorContext}. + */ +public class PaimonConnectorPreCreateValidationTest { + + private static PaimonConnector connector(Map props) { + return new PaimonConnector(props, new RecordingConnectorContext()); + } + + @Test + public void validatesJdbcDriverUrl() throws Exception { + Map props = new HashMap<>(); + props.put("paimon.catalog.type", "jdbc"); + props.put("jdbc.driver_url", "mysql.jar"); + RecordingValidationContext ctx = new RecordingValidationContext(); + + connector(props).preCreateValidation(ctx); + + // WHY (BLOCKER B-8b): a jdbc driver_url is loaded into the FE JVM (URLClassLoader); CREATE + // CATALOG must route it through the engine's format / white-list / secure-path gate. MUTATION: + // dropping the preCreateValidation override -> validateAndResolveDriverPath never called -> red. + Assertions.assertEquals(Collections.singletonList("mysql.jar"), ctx.validatedDriverUrls); + } + + @Test + public void validatesPaimonJdbcDriverUrlAlias() throws Exception { + Map props = new HashMap<>(); + props.put("paimon.catalog.type", "jdbc"); + props.put("paimon.jdbc.driver_url", "mysql.jar"); + RecordingValidationContext ctx = new RecordingValidationContext(); + + connector(props).preCreateValidation(ctx); + + Assertions.assertEquals(Collections.singletonList("mysql.jar"), ctx.validatedDriverUrls, + "the paimon.jdbc.driver_url alias must also be validated"); + } + + @Test + public void skipsValidationForNonJdbcFlavor() throws Exception { + Map props = new HashMap<>(); + props.put("paimon.catalog.type", "filesystem"); + props.put("jdbc.driver_url", "mysql.jar"); + RecordingValidationContext ctx = new RecordingValidationContext(); + + connector(props).preCreateValidation(ctx); + + Assertions.assertTrue(ctx.validatedDriverUrls.isEmpty(), + "non-JDBC flavors must not trigger driver-url validation"); + } + + @Test + public void skipsValidationWhenNoDriverUrl() throws Exception { + Map props = new HashMap<>(); + props.put("paimon.catalog.type", "jdbc"); + RecordingValidationContext ctx = new RecordingValidationContext(); + + connector(props).preCreateValidation(ctx); + + Assertions.assertTrue(ctx.validatedDriverUrls.isEmpty(), + "a jdbc catalog without a driver_url uses the platform driver -> nothing to validate"); + } + + @Test + public void propagatesRejectedDriverUrl() { + Map props = new HashMap<>(); + props.put("paimon.catalog.type", "jdbc"); + props.put("jdbc.driver_url", "http://evil.test/x.jar"); + RecordingValidationContext ctx = new RecordingValidationContext(); + ctx.reject = true; + + // WHY (BLOCKER B-8b): a disallowed url must FAIL CREATE CATALOG — the hook throws and the + // connector must let it propagate, not swallow it. MUTATION: catching the exception -> no + // throw -> red. + Assertions.assertThrows(IllegalArgumentException.class, + () -> connector(props).preCreateValidation(ctx)); + } + + /** Hand-written {@link ConnectorValidationContext} test double (no Mockito). */ + private static final class RecordingValidationContext implements ConnectorValidationContext { + final List validatedDriverUrls = new ArrayList<>(); + boolean reject; + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public String getProperty(String key) { + return null; + } + + @Override + public void storeProperty(String key, String value) { + } + + @Override + public String validateAndResolveDriverPath(String driverUrl) throws Exception { + validatedDriverUrls.add(driverUrl); + if (reject) { + throw new IllegalArgumentException("disallowed driver url: " + driverUrl); + } + return "file:///resolved/" + driverUrl; + } + + @Override + public String computeDriverChecksum(String driverUrl) { + return "deadbeef"; + } + + @Override + public void requestBeConnectivityTest(byte[] serializedDescriptor, int connectionTypeValue, + String testQuery) { + } + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorValidatePropertiesTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorValidatePropertiesTest.java new file mode 100644 index 00000000000000..516a027870b883 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorValidatePropertiesTest.java @@ -0,0 +1,223 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * CREATE-CATALOG property validation, exercised through the production entry point + * {@link PaimonConnectorProvider#validateProperties(Map)} (called by fe-core + * {@code PluginDrivenExternalCatalog.checkProperties}). + * + *

    P2-T03: validation moved from the hand-rolled {@code PaimonCatalogFactory.validate} to the shared + * {@code MetaStoreProviders.bind(props, {}).validate()}. The shared parsers restore the TRUE-legacy + * rules the paimon hand-copy had dropped, so CREATE CATALOG is now STRICTER (user decision Q1 = + * adopt the legacy-faithful validate): HMS {@code forbidIf(simple)}/{@code requireIf(kerberos)} on + * client principal+keytab, the DLF OSS-storage requirement enforced at CREATE (not catalog build), + * and REST case-sensitive {@code "dlf".equals(token.provider)}. These three are the net-new RED tests + * here; the rest pin the required-key rules that already existed. + */ +public class PaimonConnectorValidatePropertiesTest { + + private static final PaimonConnectorProvider PROVIDER = new PaimonConnectorProvider(); + + private static Map props(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + private static void validate(Map props) { + PROVIDER.validateProperties(props); + } + + // --------------------------------------------------------------------- + // Required-key rules (pre-existing; retargeted to the provider entry point) + // --------------------------------------------------------------------- + + @Test + public void rejectsUnknownFlavor() { + // WHY: an unknown paimon.catalog.type must fail at CREATE CATALOG, not silently fall back to + // filesystem. Post-cutover the rejection is MetaStoreProviders.bind throwing (no provider + // supports it) rather than the old "Unknown paimon.catalog.type value: X" message; we assert + // only that CREATE fails (IllegalArgumentException), not the exact wording. + Assertions.assertThrows(IllegalArgumentException.class, + () -> validate(props("paimon.catalog.type", "bogus", "warehouse", "/wh"))); + } + + @Test + public void requiresWarehouseForFilesystem() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> validate(props("paimon.catalog.type", "filesystem"))); + } + + @Test + public void rejectsMalformedMetaCacheKnob() { + // Legacy parity restored (user decision, 2026-07-01): meta.cache.paimon.table.{enable,ttl-second, + // capacity} are validated again at CREATE/ALTER via the shared CacheSpec, so a malformed value is + // REJECTED (this reverses the earlier warn-only behavior for dead knobs — enable/capacity stay unwired + // on the plugin path, but an out-of-range/garbage value is still rejected, matching the deleted + // PaimonExternalCatalog.checkProperties). The catalog is otherwise well-formed, so the knob is the + // only variable. + IllegalArgumentException capacity = Assertions.assertThrows(IllegalArgumentException.class, + () -> validate(props( + "paimon.catalog.type", "filesystem", "warehouse", "/wh", + "meta.cache.paimon.table.capacity", "-5"))); + Assertions.assertTrue(capacity.getMessage().contains("is wrong")); + + IllegalArgumentException ttl = Assertions.assertThrows(IllegalArgumentException.class, + () -> validate(props( + "paimon.catalog.type", "filesystem", "warehouse", "/wh", + "meta.cache.paimon.table.ttl-second", "-2"))); + Assertions.assertEquals( + "The parameter meta.cache.paimon.table.ttl-second is wrong, value is -2", ttl.getMessage()); + + Assertions.assertThrows(IllegalArgumentException.class, + () -> validate(props( + "paimon.catalog.type", "filesystem", "warehouse", "/wh", + "meta.cache.paimon.table.enable", "maybe"))); + } + + @Test + public void acceptsValidMetaCacheKnobs() { + // Valid values must pass: ttl-second=-1 is the "no expiration" sentinel (min is -1), 0 disables, + // capacity=0 disables, enable is boolean. enable/capacity remain unwired (warn-only) but are NOT + // rejected when well-formed. + Assertions.assertDoesNotThrow(() -> validate(props( + "paimon.catalog.type", "filesystem", "warehouse", "/wh", + "meta.cache.paimon.table.enable", "false", + "meta.cache.paimon.table.ttl-second", "-1", + "meta.cache.paimon.table.capacity", "0"))); + Assertions.assertDoesNotThrow(() -> validate(props( + "paimon.catalog.type", "filesystem", "warehouse", "/wh", + "meta.cache.paimon.table.ttl-second", "0"))); + } + + @Test + public void requiresWarehouseForRest() { + // Legacy parity: AbstractPaimonProperties requires warehouse and PaimonRestMetaStoreProperties + // does NOT override it, so a REST catalog without warehouse is rejected. + Assertions.assertThrows(IllegalArgumentException.class, + () -> validate(props( + "paimon.catalog.type", "rest", + "paimon.rest.uri", "http://rest:8080"))); + } + + @Test + public void restDlfTokenProviderRequiresAkSk() { + // requireIf: token provider "dlf" (lower-case, the legacy case-sensitive value) needs the dlf + // access-key-id AND access-key-secret. warehouse supplied so this exercises the requireIf. + Assertions.assertThrows(IllegalArgumentException.class, + () -> validate(props( + "paimon.catalog.type", "rest", + "warehouse", "/wh", + "paimon.rest.uri", "http://rest:8080", + "paimon.rest.token.provider", "dlf"))); + } + + @Test + public void jdbcDriverUrlWithoutDriverClassFails() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> validate(props( + "paimon.catalog.type", "jdbc", + "warehouse", "/wh", + "uri", "jdbc:mysql://db:3306/meta", + "paimon.jdbc.driver_url", "mysql.jar"))); + } + + @Test + public void hmsRequiresUri() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> validate(props( + "paimon.catalog.type", "hms", + "warehouse", "/wh"))); + } + + @Test + public void acceptsEachWellFormedFlavor() { + Assertions.assertDoesNotThrow(() -> validate( + props("paimon.catalog.type", "filesystem", "warehouse", "/wh"))); + Assertions.assertDoesNotThrow(() -> validate(props( + "paimon.catalog.type", "hms", "warehouse", "/wh", "hive.metastore.uris", "thrift://nn:9083"))); + Assertions.assertDoesNotThrow(() -> validate(props( + "paimon.catalog.type", "rest", "warehouse", "/wh", "paimon.rest.uri", "http://rest:8080"))); + Assertions.assertDoesNotThrow(() -> validate(props( + "paimon.catalog.type", "jdbc", "warehouse", "/wh", "uri", "jdbc:mysql://db:3306/meta"))); + } + + @Test + public void defaultsToFilesystemWhenTypeAbsent() { + Assertions.assertDoesNotThrow(() -> validate(props("warehouse", "/wh"))); + Assertions.assertThrows(IllegalArgumentException.class, + () -> validate(props("not-a-type", "x"))); + } + + // --------------------------------------------------------------------- + // Net-new legacy-faithful tightening (Q1) — RED against the old loose validate + // --------------------------------------------------------------------- + + @Test + public void hmsKerberosRequiresPrincipalAndKeytab() { + // requireIf(kerberos): legacy HMSBaseProperties.buildRules mandates the client principal AND + // keytab when the HMS auth type is kerberos. The paimon hand-copy dropped this rule; the shared + // parser restores it (HmsMetaStorePropertiesImpl.validate). MUTATION: dropping requireIf -> green + // (no throw) -> test red. + Assertions.assertThrows(IllegalArgumentException.class, + () -> validate(props( + "paimon.catalog.type", "hms", + "warehouse", "/wh", + "hive.metastore.uris", "thrift://nn:9083", + "hive.metastore.authentication.type", "kerberos"))); + } + + @Test + public void hmsSimpleForbidsPrincipalAndKeytab() { + // forbidIf(simple): legacy forbids a client principal/keytab when the auth type is simple + // (case-SENSITIVE Objects.equals). Restored by the shared parser. RED against the old validate. + Assertions.assertThrows(IllegalArgumentException.class, + () -> validate(props( + "paimon.catalog.type", "hms", + "warehouse", "/wh", + "hive.metastore.uris", "thrift://nn:9083", + "hive.metastore.authentication.type", "simple", + "hive.metastore.client.principal", "hive/_HOST@REALM"))); + } + + + @Test + public void removedDlfCatalogTypeNoLongerValidates() { + // WHY: paimon.catalog.type=dlf (DLF 1.0 over the vendored thrift ProxyMetaStoreClient) was removed. It + // must now fail at CREATE CATALOG like any unknown type — never fall through to a backend whose client + // no longer ships. MUTATION: re-registering the dlf provider -> this passes validate -> red. + IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, + () -> validate(props( + "paimon.catalog.type", "dlf", + "warehouse", "/wh", + "dlf.access_key", "ak", + "dlf.secret_key", "sk", + "dlf.endpoint", "dlf.cn.aliyuncs.com"))); + Assertions.assertTrue(ex.getMessage().contains("No MetaStoreProvider supports"), + "removed dlf must be unsupported, got: " + ex.getMessage()); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonHmsConfResWiringTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonHmsConfResWiringTest.java new file mode 100644 index 00000000000000..53c6885ccd30fd --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonHmsConfResWiringTest.java @@ -0,0 +1,120 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.hadoop.hive.conf.HiveConf; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; + +/** + * FIX-HMS-CONFRES: proves an external {@code hive.conf.resources} hive-site.xml actually reaches the + * assembled {@link HiveConf} — intent: no silent drop of the operator's file (the original defect). + * + *

    The connector resolves and parses the file itself ({@code PaimonCatalogFactory.addConfResources}) + * rather than receiving pre-flattened keys from an engine hook, so this asserts the REAL file->HiveConf + * behavior instead of a mock handshake: it writes a real XML under a real directory and reads the value + * back off the HiveConf. + */ +public class PaimonHmsConfResWiringTest { + + private static final String QOP_KEY = "hive.metastore.sasl.qop"; + + /** The engine-set system property carrying {@code Config.hadoop_config_dir} into the plugin. */ + private static final String CONFIG_DIR_KEY = "doris.hadoop.config.dir"; + + private static void writeHiveSite(File dst, String key, String value) throws Exception { + Files.write(dst.toPath(), ("" + key + + "" + value + "") + .getBytes(StandardCharsets.UTF_8)); + } + + /** + * Runs {@code body} with the engine's config-dir bridge pointed at {@code dir}, restoring the previous + * value afterwards (the property is process-global). + */ + private static void withConfigDir(Path dir, ThrowingRunnable body) throws Exception { + String prev = System.getProperty(CONFIG_DIR_KEY); + System.setProperty(CONFIG_DIR_KEY, dir.toString() + File.separator); + try { + body.run(); + } finally { + if (prev == null) { + System.clearProperty(CONFIG_DIR_KEY); + } else { + System.setProperty(CONFIG_DIR_KEY, prev); + } + } + } + + private interface ThrowingRunnable { + void run() throws Exception; + } + + @Test + public void externalHiveSiteXmlReachesTheHiveConf(@TempDir Path tmp) throws Exception { + writeHiveSite(tmp.resolve("hive-site.xml").toFile(), QOP_KEY, "auth-conf"); + + withConfigDir(tmp, () -> { + HiveConf hc = PaimonCatalogFactory.assembleHiveConf("hive-site.xml", Collections.emptyMap()); + // WHY: a refactor that builds the HiveConf without consulting hive.conf.resources would + // silently drop the operator's file — the very defect this path exists to fix. + Assertions.assertEquals("auth-conf", hc.get(QOP_KEY), + "the external hive-site.xml named by hive.conf.resources must reach the HiveConf"); + }); + } + + @Test + public void overridesBeatTheExternalFile(@TempDir Path tmp) throws Exception { + writeHiveSite(tmp.resolve("hive-site.xml").toFile(), QOP_KEY, "auth-conf"); + + withConfigDir(tmp, () -> { + HiveConf hc = PaimonCatalogFactory.assembleHiveConf("hive-site.xml", + Collections.singletonMap(QOP_KEY, "auth")); + // WHY (F2 ordering): the file is the BASE, the metastore-spi overrides are the connection + // facts and must win. addResource + set() must not invert that precedence. + Assertions.assertEquals("auth", hc.get(QOP_KEY), + "toHiveConfOverrides must override the external file, not the other way round"); + }); + } + + @Test + public void missingFileFailsLoud(@TempDir Path tmp) throws Exception { + withConfigDir(tmp, () -> { + // WHY: a missing file must fail loud, never degrade to "connect with defaults" — the operator + // named a file that carries connection-critical settings. + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, + () -> PaimonCatalogFactory.assembleHiveConf("absent.xml", Collections.emptyMap())); + Assertions.assertTrue(e.getMessage().contains("Config resource file does not exist"), + "message must name the unresolvable file; was: " + e.getMessage()); + }); + } + + @Test + public void blankResourcesIsANoOp() { + // WHY: hive.conf.resources is optional; a catalog without it must not touch the filesystem or throw. + HiveConf hc = PaimonCatalogFactory.assembleHiveConf(null, Collections.singletonMap(QOP_KEY, "auth")); + Assertions.assertEquals("auth", hc.get(QOP_KEY)); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonIncrementalScanParamsTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonIncrementalScanParamsTest.java new file mode 100644 index 00000000000000..da419e3dcc4138 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonIncrementalScanParamsTest.java @@ -0,0 +1,312 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.DorisConnectorException; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Mutation-killing tests for {@link PaimonIncrementalScanParams#validate}, the byte-faithful port of + * legacy {@code PaimonScanNode.validateIncrementalReadParams} (lines 701-878). Each test encodes WHY + * a rule matters (a wrong window silently reads the WRONG incremental diff -> wrong rows), and pins + * the EXACT legacy error message so the connector's {@link DorisConnectorException} stays parity with + * the legacy {@code UserException}. The two parameter groups (snapshot-based vs timestamp-based) are + * mutually exclusive; {@link PaimonIncrementalScanParams#validate} carries ONLY the non-null + * {@code incremental-between*} keys (so the shared {@code ConnectorMvccSnapshot} SPI / handle stay + * null-free), and the legacy null {@code scan.snapshot-id}/{@code scan.mode} resets are reapplied at + * the {@code Table.copy} chokepoint by {@link PaimonIncrementalScanParams#applyResetsIfIncremental} + * (FIX-INCR-SCAN-RESET) — covered by the {@code applyResetsIfIncremental*} cases below. + */ +public class PaimonIncrementalScanParamsTest { + + private static Map params(String... kv) { + Map m = new HashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + // ==================== mutual exclusion / required-start / empty ==================== + + @Test + public void snapshotAndTimestampGroupsAreMutuallyExclusive() { + // WHY: mixing snapshot ids and timestamps is ambiguous (two contradictory window definitions); + // legacy rejects it outright. MUTATION: dropping the mutual-exclusion check -> one group wins + // silently -> no throw -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> PaimonIncrementalScanParams.validate( + params("startSnapshotId", "1", "endSnapshotId", "2", "startTimestamp", "100")), + "snapshot-based and timestamp-based params must be mutually exclusive"); + Assertions.assertTrue(ex.getMessage().contains("Cannot specify both snapshot-based parameters"), + "the mutual-exclusion error must match the legacy message verbatim"); + } + + @Test + public void emptyParamsAreInvalid() { + // WHY: @incr with no window is meaningless; legacy fails loud rather than reading everything. + // MUTATION: returning an empty map instead of throwing -> no throw -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> PaimonIncrementalScanParams.validate(params()), + "no incremental params at all must be rejected"); + Assertions.assertTrue(ex.getMessage().contains("at least one valid parameter group"), + "the empty-params error must match the legacy message"); + } + + @Test + public void snapshotGroupRequiresStart() { + // WHY: an incremental window needs a START; only endSnapshotId (no start) is invalid. This is + // the snapshot-group "start required" rule (legacy line 732-734). MUTATION: not requiring start + // -> no throw -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> PaimonIncrementalScanParams.validate(params("endSnapshotId", "5")), + "snapshot-based incremental read must require startSnapshotId"); + Assertions.assertTrue(ex.getMessage().contains( + "startSnapshotId is required when using snapshot-based incremental read"), + "the missing-start error must match the legacy message"); + } + + @Test + public void scanModeOnlyWithBothStartAndEnd() { + // WHY: incrementalBetweenScanMode describes HOW to diff a [start,end] range, so it is illegal + // without BOTH ids (legacy line 738-742; here only start + scanMode, no end). MUTATION: + // allowing scanMode with a half-open range -> no throw -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> PaimonIncrementalScanParams.validate( + params("startSnapshotId", "1", "incrementalBetweenScanMode", "diff")), + "incrementalBetweenScanMode requires both start and end snapshot ids"); + Assertions.assertTrue(ex.getMessage().contains( + "incrementalBetweenScanMode can only be specified when"), + "the scanMode-needs-both error must match the legacy message"); + } + + @Test + public void timestampGroupRequiresStart() { + // WHY: same start-required rule for the timestamp group (legacy line 793-794); only endTimestamp + // is invalid. MUTATION: not requiring startTimestamp -> no throw -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> PaimonIncrementalScanParams.validate(params("endTimestamp", "200")), + "timestamp-based incremental read must require startTimestamp"); + Assertions.assertTrue(ex.getMessage().contains( + "startTimestamp is required when using timestamp-based incremental read"), + "the missing-start-timestamp error must match the legacy message"); + } + + @Test + public void onlyStartSnapshotIdRequiresEnd() { + // WHY: a snapshot-based window with start but no end is rejected (legacy line 847-849) — the + // snapshot path has no Long.MAX_VALUE open-ended fallback (unlike timestamps). MUTATION: + // silently allowing only-start -> no throw -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> PaimonIncrementalScanParams.validate(params("startSnapshotId", "1")), + "snapshot-based incremental read with only start must require end"); + Assertions.assertTrue(ex.getMessage().contains( + "endSnapshotId is required when using snapshot-based incremental read"), + "the missing-end error must match the legacy message"); + } + + // ==================== numeric range rules ==================== + + @Test + public void snapshotIdsMustBeNonNegative() { + // WHY: snapshot ids are >= 0 (legacy line 748). MUTATION: dropping the >=0 check -> no throw. + Assertions.assertThrows(DorisConnectorException.class, + () -> PaimonIncrementalScanParams.validate(params("startSnapshotId", "-1", "endSnapshotId", "2")), + "a negative startSnapshotId must be rejected"); + } + + @Test + public void startSnapshotIdMustNotExceedEnd() { + // WHY: a window must run forward: startSId <= endSId (legacy line 772). MUTATION: dropping the + // ordering check -> an inverted window is accepted -> no throw -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> PaimonIncrementalScanParams.validate(params("startSnapshotId", "5", "endSnapshotId", "2")), + "startSnapshotId must be <= endSnapshotId"); + Assertions.assertTrue(ex.getMessage().contains( + "startSnapshotId must be less than or equal to endSnapshotId"), + "the snapshot-ordering error must match the legacy message"); + } + + @Test + public void endTimestampMustBePositive() { + // WHY: endTimestamp must be > 0 (strictly positive, legacy line 812 uses <= 0), distinct from + // startTimestamp's >= 0. MUTATION: weakening to >= 0 -> endTimestamp=0 accepted -> no throw red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> PaimonIncrementalScanParams.validate(params("startTimestamp", "0", "endTimestamp", "0")), + "endTimestamp must be strictly greater than 0"); + Assertions.assertTrue(ex.getMessage().contains("endTimestamp must be greater than 0"), + "the endTimestamp-positive error must match the legacy message"); + } + + @Test + public void startTimestampMustBeLessThanEnd() { + // WHY: timestamp window must run forward: startTS < endTS (STRICT, legacy line 825 uses >=). + // MUTATION: weakening to <= -> equal timestamps accepted -> no throw -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> PaimonIncrementalScanParams.validate(params("startTimestamp", "200", "endTimestamp", "200")), + "startTimestamp must be strictly less than endTimestamp"); + Assertions.assertTrue(ex.getMessage().contains("startTimestamp must be less than endTimestamp"), + "the timestamp-ordering error must match the legacy message"); + } + + // ==================== scanMode enum + original-case gotcha ==================== + + @Test + public void scanModeRejectsUnknownValue() { + // WHY: scanMode is a closed enum {auto,diff,delta,changelog} (legacy line 783-785). MUTATION: + // dropping the enum check -> a bogus mode reaches the SDK -> no throw here -> red. + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + () -> PaimonIncrementalScanParams.validate( + params("startSnapshotId", "1", "endSnapshotId", "2", "incrementalBetweenScanMode", "bogus")), + "an unknown incrementalBetweenScanMode must be rejected"); + Assertions.assertTrue(ex.getMessage().contains( + "incrementalBetweenScanMode must be one of: auto, diff, delta, changelog"), + "the scanMode-enum error must match the legacy message"); + } + + @Test + public void scanModeValidatedCaseInsensitivelyButEmittedOriginalCase() { + // WHY (parity gotcha): legacy validates the scan mode LOWERCASED (line 782) but emits the + // ORIGINAL-CASE value (line 859-860 puts params.get(...) verbatim, not the lowercased copy). So + // "DELTA" passes validation AND is emitted as "DELTA" (not "delta"). MUTATION: emitting the + // lowercased copy -> value == "delta" -> red; failing to accept upper-case at all -> throw red. + Map out = PaimonIncrementalScanParams.validate( + params("startSnapshotId", "1", "endSnapshotId", "2", "incrementalBetweenScanMode", "DELTA")); + Assertions.assertEquals("DELTA", out.get("incremental-between-scan-mode"), + "scanMode must be validated case-insensitively but emitted in its ORIGINAL case"); + } + + // ==================== produced-map shape ==================== + + @Test + public void bothSnapshotIdsProduceIncrementalBetween() { + // WHY: a [start,end] snapshot window emits incremental-between=start,end (legacy line 854). + // MUTATION: wrong separator/order -> value != "1,5" -> red. + Map out = PaimonIncrementalScanParams.validate( + params("startSnapshotId", "1", "endSnapshotId", "5")); + Assertions.assertEquals("1,5", out.get("incremental-between"), + "both snapshot ids must emit incremental-between=start,end"); + } + + @Test + public void onlyStartTimestampUsesLongMaxAsOpenEnd() { + // WHY: a timestamp window with only a start is OPEN-ENDED -> start,Long.MAX_VALUE (legacy line + // 870). MUTATION: using a different open-end sentinel (e.g. -1 or 0) -> value mismatch -> red. + Map out = PaimonIncrementalScanParams.validate(params("startTimestamp", "100")); + Assertions.assertEquals("100," + Long.MAX_VALUE, out.get("incremental-between-timestamp"), + "only-start timestamp must emit start,Long.MAX_VALUE (open-ended)"); + } + + @Test + public void bothTimestampsProduceIncrementalBetweenTimestamp() { + // WHY: a [start,end] timestamp window emits incremental-between-timestamp=start,end (legacy + // line 873). MUTATION: wrong key/value -> red. + Map out = PaimonIncrementalScanParams.validate( + params("startTimestamp", "100", "endTimestamp", "200")); + Assertions.assertEquals("100,200", out.get("incremental-between-timestamp"), + "both timestamps must emit incremental-between-timestamp=start,end"); + } + + @Test + public void validateKeepsTheSnapshotPropertiesNullFree() { + // WHY (FIX-INCR-SCAN-RESET): legacy SEEDS scan.snapshot-id=null and scan.mode=null (lines + // 842-843/846) as defensive resets. Those resets ARE required (a base table can persist a stale + // scan.snapshot-id/scan.mode), but the null values must NOT enter validate()'s output, because + // that map flows into the SHARED ConnectorMvccSnapshot SPI / PaimonTableHandle.scanOptions, + // which are null-free by contract (Builder.property rejects null; getProperties() is "never + // null"). So validate() emits ONLY the non-null incremental-between* keys; the two null resets + // are reapplied later at the Table.copy chokepoint by applyResetsIfIncremental (see the cases + // below). MUTATION: emitting the reset keys here (with null) -> containsValue(null) true -> red. + Map out = PaimonIncrementalScanParams.validate( + params("startSnapshotId", "1", "endSnapshotId", "5")); + Assertions.assertFalse(out.containsKey("scan.snapshot-id"), + "validate() must not emit scan.snapshot-id — the reset is reapplied at the copy chokepoint"); + Assertions.assertFalse(out.containsKey("scan.mode"), + "validate() must not emit scan.mode — the reset is reapplied at the copy chokepoint"); + Assertions.assertFalse(out.containsValue(null), + "validate() output feeds the null-free ConnectorMvccSnapshot SPI; it must contain NO nulls"); + } + + // ==================== applyResetsIfIncremental — the Table.copy-chokepoint reset (FIX-INCR-SCAN-RESET) + + @Test + public void applyResetsIfIncrementalSeedsNullResetsForSnapshotWindow() { + // WHY: an @incr scan whose options carry incremental-between must reset a stale persisted + // scan.snapshot-id/scan.mode to null BEFORE Table.copy, or paimon throws ("[incremental-between] + // must be null when you set [scan.snapshot-id,...]") or silently downgrades to FROM_SNAPSHOT + // (wrong rows). paimon copyInternal consumes a null value as options.remove(key). MUTATION: + // not seeding the nulls -> the reset keys are absent -> stale pin survives -> red. + Map out = PaimonIncrementalScanParams.applyResetsIfIncremental( + params("incremental-between", "3,5")); + Assertions.assertTrue(out.containsKey("scan.snapshot-id") && out.get("scan.snapshot-id") == null, + "incremental scan must carry scan.snapshot-id=null (the copy-time reset of a stale pin)"); + Assertions.assertTrue(out.containsKey("scan.mode") && out.get("scan.mode") == null, + "incremental scan must carry scan.mode=null (the copy-time reset of a stale pin)"); + Assertions.assertEquals("3,5", out.get("incremental-between"), + "the original incremental-between window must be preserved"); + } + + @Test + public void applyResetsIfIncrementalSeedsNullResetsForTimestampWindow() { + // WHY: the timestamp @incr group (incremental-between-timestamp) needs the SAME reset as the + // snapshot group — the detector must recognize BOTH incremental keys, else a timestamp @incr + // over a table with a persisted scan.snapshot-id breaks. MUTATION: detecting only + // incremental-between -> timestamp window gets no reset -> red. + Map out = PaimonIncrementalScanParams.applyResetsIfIncremental( + params("incremental-between-timestamp", "100,200")); + Assertions.assertTrue(out.containsKey("scan.snapshot-id") && out.get("scan.snapshot-id") == null, + "timestamp incremental scan must also carry scan.snapshot-id=null"); + Assertions.assertTrue(out.containsKey("scan.mode") && out.get("scan.mode") == null, + "timestamp incremental scan must also carry scan.mode=null"); + Assertions.assertEquals("100,200", out.get("incremental-between-timestamp"), + "the original incremental-between-timestamp window must be preserved"); + } + + @Test + public void applyResetsIfIncrementalPassesThroughNonIncrementalPins() { + // WHY (no false positive): a genuine snapshot-id / tag time-travel pin must NOT be touched — + // injecting scan.snapshot-id=null here would CLOBBER the legitimate pin and read the wrong + // version. The helper resets iff an incremental key is present. MUTATION: unconditionally + // seeding the resets -> a scan.snapshot-id pin loses its value / gains scan.mode=null -> red. + Map snapshotPin = params("scan.snapshot-id", "5"); + Assertions.assertSame(snapshotPin, PaimonIncrementalScanParams.applyResetsIfIncremental(snapshotPin), + "a scan.snapshot-id pin is non-incremental -> returned unchanged (same reference)"); + Map tagPin = params("scan.tag-name", "t"); + Map tagOut = PaimonIncrementalScanParams.applyResetsIfIncremental(tagPin); + Assertions.assertSame(tagPin, tagOut, "a scan.tag-name pin is non-incremental -> returned unchanged"); + Assertions.assertFalse(tagOut.containsKey("scan.mode"), + "a non-incremental pin must NOT gain a scan.mode reset"); + } + + @Test + public void applyResetsIfIncrementalIsNoOpForEmptyOrNull() { + // WHY: the latest-read / no-scan-options path (empty or null map) must pass through untouched — + // resolveScanTable only copies when scanOptions is non-empty, and a no-op here keeps that path + // allocation-free and reset-free. + Map empty = params(); + Assertions.assertSame(empty, PaimonIncrementalScanParams.applyResetsIfIncremental(empty), + "an empty scan-options map must be returned unchanged"); + Assertions.assertNull(PaimonIncrementalScanParams.applyResetsIfIncremental(null), + "a null scan-options map must be returned unchanged (null)"); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonLatestSnapshotCacheTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonLatestSnapshotCacheTest.java new file mode 100644 index 00000000000000..de85e2153b627a --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonLatestSnapshotCacheTest.java @@ -0,0 +1,161 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.paimon.catalog.Identifier; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Unit tests for {@link PaimonLatestSnapshotCache} (data-snapshot caching, CI 973411). The cache is now backed + * by the shared {@link org.apache.doris.connector.cache.MetaCacheEntry} framework; these tests cover the + * adapter's contract — within-TTL stability, the {@code ttl <= 0} disable, and invalidation. Timed-expiry + * mechanics are the framework's responsibility (the ttl→duration mapping is unit-tested in the framework + * module's {@code CacheSpecTest}; Caffeine {@code expireAfterAccess} itself is the library's behavior), so they + * are not re-proven here (no injectable clock). + */ +public class PaimonLatestSnapshotCacheTest { + + private static Identifier id() { + return Identifier.create("db", "t"); + } + + @Test + public void cachesWithinTtlAndServesStaleId() { + AtomicInteger loads = new AtomicInteger(); + PaimonLatestSnapshotCache c = new PaimonLatestSnapshotCache(100, 1000); + + long first = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return 1L; + }); + // Second read within TTL must return the CACHED id (1), NOT the new live id (2) -> this is what + // pins the with-cache catalog to the old snapshot after an external write. MUTATION: serving live + // every call -> returns 2 -> red. + long second = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return 2L; + }); + Assertions.assertEquals(1L, first); + Assertions.assertEquals(1L, second, "within TTL the cached snapshot id must be served"); + Assertions.assertEquals(1, loads.get(), "the live loader must run exactly once within TTL"); + Assertions.assertTrue(c.isEnabled()); + } + + @Test + public void ttlZeroDisablesCachingAlwaysLive() { + AtomicInteger loads = new AtomicInteger(); + PaimonLatestSnapshotCache c = new PaimonLatestSnapshotCache(0, 1000); + c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return 1L; + }); + long second = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return 2L; + }); + // ttl-second=0 (the no-cache catalog) must read live every time. MUTATION: caching despite ttl<=0 + // -> loads==1 / second==1 -> red. + Assertions.assertEquals(2L, second, "ttl-second=0 must always read the live id"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + Assertions.assertEquals(0, c.size(), "ttl-second=0 must not store anything"); + } + + @Test + public void negativeTtlDisablesCachingAlwaysLive() { + // ttl-second=-1 (or any negative) is still the no-cache catalog. Guards the CacheSpec trap where + // ttl == -1 means "no expiration (enabled)": the adapter must translate "<= 0" to disabled, NOT pass + // -1 through. MUTATION: passing ttlSeconds straight into CacheSpec -> -1 becomes a never-expiring cache + // -> loads==1 / second==1 -> red. + AtomicInteger loads = new AtomicInteger(); + PaimonLatestSnapshotCache c = new PaimonLatestSnapshotCache(-1, 1000); + c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return 1L; + }); + long second = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return 2L; + }); + Assertions.assertEquals(2L, second, "ttl-second=-1 must always read the live id"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + } + + @Test + public void invalidateForcesReload() { + AtomicInteger loads = new AtomicInteger(); + PaimonLatestSnapshotCache c = new PaimonLatestSnapshotCache(100, 1000); + c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return 1L; + }); + c.invalidate(id()); + // After REFRESH TABLE invalidation the next read goes live (sees 2). MUTATION: invalidate not + // clearing -> returns cached 1 / loads==1 -> red. + long after = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return 2L; + }); + Assertions.assertEquals(2L, after); + Assertions.assertEquals(2, loads.get()); + } + + @Test + public void invalidateAllClearsEverything() { + PaimonLatestSnapshotCache c = new PaimonLatestSnapshotCache(100, 1000); + c.getOrLoad(Identifier.create("db", "t1"), () -> 1L); + c.getOrLoad(Identifier.create("db", "t2"), () -> 2L); + Assertions.assertEquals(2, c.size()); + c.invalidateAll(); + Assertions.assertEquals(0, c.size()); + } + + @Test + public void invalidateDbClearsOnlyThatDbsTables() { + AtomicInteger loads = new AtomicInteger(); + PaimonLatestSnapshotCache c = new PaimonLatestSnapshotCache(100, 1000); + c.getOrLoad(Identifier.create("db1", "t1"), () -> 1L); + c.getOrLoad(Identifier.create("db1", "t2"), () -> 2L); + c.getOrLoad(Identifier.create("db2", "t1"), () -> 3L); + Assertions.assertEquals(3, c.size()); + + // REFRESH DATABASE db1 (or a Doris DROP DATABASE db1) must drop BOTH db1 tables and leave db2 intact. + // MUTATION: invalidateDb a no-op (the inherited SPI default this fix replaces) -> db1.t1 still cached + // -> loads stays 0 / after == 1 -> red. + c.invalidateDb("db1"); + Assertions.assertEquals(1, c.size(), "only db2's single entry must survive"); + + long afterDb1 = c.getOrLoad(Identifier.create("db1", "t1"), () -> { + loads.incrementAndGet(); + return 9L; + }); + Assertions.assertEquals(9L, afterDb1, "db1.t1 must reload live after invalidateDb"); + Assertions.assertEquals(1, loads.get()); + + long db2 = c.getOrLoad(Identifier.create("db2", "t1"), () -> { + loads.incrementAndGet(); + return 7L; + }); + Assertions.assertEquals(3L, db2, "db2 must keep its cached id (not dropped by invalidateDb(db1))"); + Assertions.assertEquals(1, loads.get(), "db2 read must be a hit (no extra load)"); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonLiveConnectivityTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonLiveConnectivityTest.java new file mode 100644 index 00000000000000..3b2d1812744a0c --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonLiveConnectivityTest.java @@ -0,0 +1,91 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.spi.ConnectorContext; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Live Paimon connectivity smoke (warehouse required; user-run). + * + *

    Complements the offline {@link PaimonConnectorMetadataTest}: this one confirms a real + * {@link org.apache.paimon.catalog.Catalog} built from {@link PaimonConnector} can actually + * be reached and listed through the production seam. It is skipped unless + * {@code PAIMON_WAREHOUSE} is set, so it is inert in CI and never hard-codes a warehouse. + * + *

    + *   PAIMON_WAREHOUSE=/path/to/warehouse [PAIMON_CATALOG_TYPE=filesystem] \
    + *   mvn -pl :fe-connector-paimon test -Dtest=PaimonLiveConnectivityTest
    + * 
    + */ +public class PaimonLiveConnectivityTest { + + /** Minimal context: simple auth (default executeAuthenticated) and an empty environment. */ + private static ConnectorContext testContext() { + return new ConnectorContext() { + @Override + public String getCatalogName() { + return "paimon_live"; + } + + @Override + public long getCatalogId() { + return 1L; + } + + @Override + public Map getEnvironment() { + return Collections.emptyMap(); + } + }; + } + + @Test + public void liveMetadataRoundTrip() { + String warehouse = System.getenv("PAIMON_WAREHOUSE"); + Assumptions.assumeTrue(warehouse != null && !warehouse.isEmpty(), + "skipped: set PAIMON_WAREHOUSE (and optionally PAIMON_CATALOG_TYPE) to run live"); + + String catalogType = System.getenv("PAIMON_CATALOG_TYPE"); + + Map props = new HashMap<>(); + props.put(PaimonConnectorProperties.WAREHOUSE, warehouse); + if (catalogType != null && !catalogType.isEmpty()) { + props.put(PaimonConnectorProperties.PAIMON_CATALOG_TYPE, catalogType); + } + + // Exercise the full production path: PaimonConnector lazily builds a real Catalog and + // wires the CatalogBackedPaimonCatalogOps seam into the metadata. One listDatabaseNames + // round-trip confirms the catalog is reachable end to end. + try (PaimonConnector connector = new PaimonConnector(props, testContext())) { + ConnectorMetadata metadata = connector.getMetadata(null); + Assertions.assertNotNull(metadata.listDatabaseNames(null), + "a reachable Paimon catalog must return a (possibly empty) database list"); + } catch (Exception e) { + throw new AssertionError("live Paimon round-trip failed for warehouse " + warehouse, e); + } + } +} 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 new file mode 100644 index 00000000000000..d6357f58de8b41 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPartitionValueRenderTest.java @@ -0,0 +1,127 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.paimon.data.Timestamp; +import org.apache.paimon.types.DataTypes; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * Unit tests for {@link PaimonScanPlanProvider#serializePartitionValue}, the FIX-NATIVE-PARTVAL + * per-type partition-value renderer (byte-faithful port of legacy + * {@code PaimonUtil.serializePartitionValue}). + * + *

    For native ORC/Parquet reads BE materializes partition columns from this string (columnsFromPath); + * a wrong string ⇒ wrong materialized rows. The pure static is the established testable seam (the + * map wrapper {@code getPartitionInfoMap} needs a real {@code BinaryRow}+converter, heavy offline — + * the same reason {@code shouldUseNativeReader} is tested as a pure static). Each test FAILS before + * the fix (raw {@code Object.toString()}) and PASSES after, and encodes WHY (the BE contract), not + * just WHAT. Offline, no fe-core, no Mockito — pure paimon {@code DataTypes}/{@code Timestamp}. + */ +public class PaimonPartitionValueRenderTest { + + @Test + public void dateRendersAsIsoDateNotEpochDays() { + int epochDays = (int) LocalDate.of(2024, 1, 1).toEpochDay(); + String rendered = PaimonScanPlanProvider.serializePartitionValue( + DataTypes.DATE(), Integer.valueOf(epochDays), "UTC"); + + // WHY: RowDataToObjectArrayConverter yields a boxed Integer epoch-days for DATE; a raw + // toString() emits "19723" which BE parses as a garbage date -> data corruption. The ISO + // render is the contract BE's columnsFromPath expects. MUTATION: raw toString() -> "19723" -> red. + Assertions.assertEquals("2024-01-01", rendered); + } + + @Test + public void ltzShiftsUtcToSessionZone() { + // Paimon stores LTZ as the UTC instant; build the UTC wall clock 2024-01-01T01:02:03. + Timestamp utcWallClock = Timestamp.fromLocalDateTime(LocalDateTime.of(2024, 1, 1, 1, 2, 3)); + + // Asia/Shanghai is UTC+8 -> 09:02:03. Non-zero seconds are used deliberately so the + // ISO_LOCAL_DATE_TIME formatter renders the seconds component unambiguously (it omits + // seconds when both second and nano are zero). + String shanghai = PaimonScanPlanProvider.serializePartitionValue( + DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(), utcWallClock, "Asia/Shanghai"); + String utc = PaimonScanPlanProvider.serializePartitionValue( + DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(), utcWallClock, "UTC"); + + // WHY: LTZ partition values are stored in UTC and must be shown in the SESSION zone (legacy + // PaimonUtil.serializePartitionValue applies ZoneId.of(timeZone)); pre-fix raw toString() + // renders the un-shifted UTC wall clock under every session. Asserting both the shifted + // (Shanghai) AND unshifted (UTC) values pins that the zone param is actually applied. + // MUTATION: ignoring timeZone (raw toString) -> both equal -> red. + Assertions.assertEquals("2024-01-01T09:02:03", shanghai); + Assertions.assertEquals("2024-01-01T01:02:03", utc); + } + + @Test + public void ntzRendersIsoNoZoneShift() { + Timestamp ts = Timestamp.fromLocalDateTime(LocalDateTime.of(2024, 1, 1, 1, 2, 3)); + String rendered = PaimonScanPlanProvider.serializePartitionValue( + DataTypes.TIMESTAMP(), ts, "Asia/Shanghai"); + + // WHY: TIMESTAMP_WITHOUT_TIME_ZONE is a wall clock and must NOT be zone-shifted, regardless + // of the session zone (the memory-note caveat: NTZ stays wall-clock). Guards against a future + // "shift everything" regression. MUTATION: applying the session zone to NTZ -> "...T09:02:03" -> red. + Assertions.assertEquals("2024-01-01T01:02:03", rendered); + } + + @Test + public void binaryYieldsUnsupported() { + // WHY: binary must NOT be rendered as [B@hash (non-deterministic JVM identity); the legacy + // contract is to THROW so the caller drops the whole partition map (no columnsFromPath). + // MUTATION: any render path for binary (no throw) -> red. + Assertions.assertThrows(UnsupportedOperationException.class, + () -> PaimonScanPlanProvider.serializePartitionValue( + DataTypes.BYTES(), new byte[] {1, 2}, "UTC")); + } + + @Test + public void floatDoubleUseToStringRender() { + // WHY: parity with legacy Float.toString / Double.toString. MUTATION: a different numeric + // render -> red. + Assertions.assertEquals("1.5", + PaimonScanPlanProvider.serializePartitionValue(DataTypes.FLOAT(), 1.5f, "UTC")); + Assertions.assertEquals("2.25", + PaimonScanPlanProvider.serializePartitionValue(DataTypes.DOUBLE(), 2.25d, "UTC")); + } + + @Test + public void integerRendersViaToString() { + // WHY: scalar types (the common partition case) go through value.toString(); pins the + // base-case parity. MUTATION: mis-routing INTEGER to a typed branch -> red. + Assertions.assertEquals("42", + PaimonScanPlanProvider.serializePartitionValue(DataTypes.INT(), 42, "UTC")); + } + + @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. + Assertions.assertNull( + PaimonScanPlanProvider.serializePartitionValue(DataTypes.INT(), null, "UTC")); + Assertions.assertNull( + PaimonScanPlanProvider.serializePartitionValue(DataTypes.DATE(), null, "UTC")); + Assertions.assertNull(PaimonScanPlanProvider.serializePartitionValue( + DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(), null, "Asia/Shanghai")); + } +} 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 new file mode 100644 index 00000000000000..5600fd3a93e8a1 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPredicateConverterTest.java @@ -0,0 +1,145 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +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.ConnectorLiteral; + +import org.apache.paimon.data.Timestamp; +import org.apache.paimon.predicate.LeafPredicate; +import org.apache.paimon.predicate.Predicate; +import org.apache.paimon.types.DataTypes; +import org.apache.paimon.types.RowType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; + +/** + * P5-T07 — pins the parity-correct predicate-pushdown contract of + * {@link PaimonPredicateConverter}: NTZ pushes with fixed-UTC semantics (matching legacy + * {@code PaimonValueConverter} and paimon's UTC-interpreted stored stats), while LTZ / FLOAT / + * CHAR are deliberately NOT pushed (left to BE-side filtering) to avoid source-side false pruning. + * + *

    The converter only takes a {@link RowType} — no catalog — so every case is fully offline. + * The paimon {@code DataType} of the column (not the {@link ConnectorType} on the literal) drives + * the conversion, so the literal's connector type is incidental here. + */ +public class PaimonPredicateConverterTest { + + private static final ConnectorType ANY = ConnectorType.of("INT"); + + /** Builds `col = literal` over a single-column RowType of the given paimon type. */ + private static List convertEq( + RowType rowType, String colName, Object literalValue) { + PaimonPredicateConverter converter = new PaimonPredicateConverter(rowType); + ConnectorComparison cmp = new ConnectorComparison( + ConnectorComparison.Operator.EQ, + new ConnectorColumnRef(colName, ANY), + new ConnectorLiteral(ANY, literalValue)); + return converter.convert(cmp); + } + + @Test + public void ntzPushedWithUtcSemantics() { + RowType rowType = RowType.builder().field("ts", DataTypes.TIMESTAMP()).build(); + LocalDateTime literal = LocalDateTime.of(2021, 3, 14, 1, 59, 26); + + List predicates = convertEq(rowType, "ts", literal); + + // WHY: a TIMESTAMP_WITHOUT_TIME_ZONE comparison against a wall-clock literal MUST be + // pushed — dropping it would forfeit all file/partition pruning on NTZ columns. + // MUTATION: returning null for the TIMESTAMP_WITHOUT_TIME_ZONE root -> size 0 -> red. + Assertions.assertEquals(1, predicates.size(), + "an NTZ equality predicate must be pushed (one leaf produced)"); + + // WHY: the pushed literal must be the wall clock interpreted in UTC, because paimon's + // stored min/max stats for a zone-free column are computed by reading the wall clock as + // UTC; any other zone shifts the epoch-millis vs the stored stats and false-prunes files + // (silent data loss). MUTATION: switching ZoneOffset.UTC -> a non-UTC zone (e.g. the + // session zone) shifts this value -> assertion red. + long expectedMillis = literal.toInstant(ZoneOffset.UTC).toEpochMilli(); + LeafPredicate leaf = (LeafPredicate) predicates.get(0); + Assertions.assertEquals(Timestamp.fromEpochMillis(expectedMillis), leaf.literals().get(0), + "NTZ literal must be the wall clock converted via fixed UTC (legacy GMT parity)"); + } + + @Test + public void ltzNotPushed() { + RowType rowType = RowType.builder() + .field("ts", DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE()).build(); + LocalDateTime literal = LocalDateTime.of(2021, 3, 14, 1, 59, 26); + + List predicates = convertEq(rowType, "ts", literal); + + // WHY: legacy never pushed TIMESTAMP WITH LOCAL TIME ZONE (PaimonValueConverter has no + // visit(LocalZonedTimestampType) -> defaultMethod -> null). Pushing it via a fixed zone is + // an instant mismatch under non-UTC sessions, risking false pruning, so the conjunct must + // be dropped and left to BE-side filtering. MUTATION: re-merging the LTZ case into the NTZ + // branch (so it produces a predicate) -> size 1 -> red. + Assertions.assertTrue(predicates.isEmpty(), + "an LTZ predicate must NOT be pushed (dropped to BE-side filtering)"); + } + + @Test + public void floatNotPushed() { + RowType rowType = RowType.builder().field("f", DataTypes.FLOAT()).build(); + + List predicates = convertEq(rowType, "f", 1.5d); + + // WHY: the FLOAT root deliberately returns null (not pushed) — pushing a float literal + // risks precision-mismatch false pruning at the source. MUTATION: returning a value for + // the FLOAT root -> size 1 -> red. + Assertions.assertTrue(predicates.isEmpty(), + "a FLOAT predicate must NOT be pushed"); + } + + @Test + public void charNotPushed() { + RowType rowType = RowType.builder().field("c", DataTypes.CHAR(4)).build(); + + List predicates = convertEq(rowType, "c", "abc"); + + // WHY: the CHAR root deliberately returns null (not pushed) — CHAR's blank-padding + // semantics differ from an unpadded literal, so pushing risks under-matching at the source. + // MUTATION: returning a value for the CHAR root -> size 1 -> red. + Assertions.assertTrue(predicates.isEmpty(), + "a CHAR predicate must NOT be pushed"); + } + + @Test + public void intControlIsPushed() { + RowType rowType = RowType.builder().field("id", DataTypes.INT()).build(); + + List predicates = convertEq(rowType, "id", 42); + + // WHY: control — proves the converter still pushes ordinary predicates and that the + // NTZ/LTZ/FLOAT/CHAR degrade above is type-specific, not a global "drop everything" bug. + // MUTATION: a converter change that drops all conjuncts (e.g. convert() always returning + // empty) would make this red while the negative cases stay green, distinguishing the two. + Assertions.assertEquals(1, predicates.size(), + "an INT equality predicate must still be pushed (degrade is type-specific)"); + LeafPredicate leaf = (LeafPredicate) predicates.get(0); + Assertions.assertEquals(42, leaf.literals().get(0), + "the INT literal must be carried through unchanged"); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanExplainTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanExplainTest.java new file mode 100644 index 00000000000000..ef15cbef59edbf --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanExplainTest.java @@ -0,0 +1,360 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.thrift.TFileRangeDesc; +import org.apache.doris.thrift.TTableFormatFileDesc; + +import org.apache.paimon.predicate.Predicate; +import org.apache.paimon.predicate.PredicateBuilder; +import org.apache.paimon.types.DataTypes; +import org.apache.paimon.types.RowType; +import org.apache.paimon.utils.InstantiationUtil; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * FIX-E (explain gap) — pins the connector half of the re-emitted EXPLAIN lines the legacy + * {@code PaimonScanNode} produced but the SPI scan path dropped: + * {@code paimonNativeReadSplits=/} ({@link PaimonScanPlanProvider#appendExplainInfo}) + * and the deletion-file lookup behind {@code deleteFileNum} + * ({@link PaimonScanPlanProvider#getDeleteFiles}), plus the two {@link PaimonScanRange} getters that + * feed the generic node's accounting. + * + *

    Offline: these methods touch neither the catalog nor a live table, so a 2-arg provider with a + * {@code null} catalogOps is sufficient.

    + */ +public class PaimonScanExplainTest { + + private static PaimonScanPlanProvider provider() { + return new PaimonScanPlanProvider(new HashMap<>(), null); + } + + // ==================== appendExplainInfo: paimonNativeReadSplits ==================== + + @Test + public void appendExplainInfoEmitsNativeReadSplitsFromSyntheticKeys() { + // WHY: under force_jni_scanner=true the node accumulates 0 native of 1 total and injects them + // as the synthetic __native_read_splits / __total_read_splits keys; the provider must emit + // exactly "paimonNativeReadSplits=0/1" (the assertion in test_paimon_catalog_varbinary / + // _timestamp_tz). MUTATION: dropping the override, or reading the wrong keys, makes this red. + Map props = new HashMap<>(); + props.put("__native_read_splits", "0"); + props.put("__total_read_splits", "1"); + + StringBuilder out = new StringBuilder(); + provider().appendExplainInfo(out, " ", props); + + Assertions.assertEquals(" paimonNativeReadSplits=0/1\n", out.toString()); + } + + @Test + public void appendExplainInfoEmitsNonZeroNativeOverTotal() { + // A mixed native/JNI scan: 3 native of 5 total -> "paimonNativeReadSplits=3/5". Pins that the + // raw counts pass through verbatim (numerator native, denominator total), not a recomputation. + Map props = new HashMap<>(); + props.put("__native_read_splits", "3"); + props.put("__total_read_splits", "5"); + + StringBuilder out = new StringBuilder(); + provider().appendExplainInfo(out, "", props); + + Assertions.assertEquals("paimonNativeReadSplits=3/5\n", out.toString()); + } + + @Test + public void appendExplainInfoSkipsWhenSyntheticKeysAbsent() { + // WHY: when the node has not yet accounted splits (or another connector's props map), the keys + // are absent and the line must NOT print (never a spurious "0/0"). Pins the null-guard. + StringBuilder out = new StringBuilder(); + provider().appendExplainInfo(out, " ", new HashMap<>()); + Assertions.assertEquals("", out.toString()); + } + + // ==================== appendExplainInfo: PaimonSplitStats block (VERBOSE) ==================== + + @Test + public void appendExplainInfoEmitsJniSplitStatsBlockWhenVerbose() { + // WHY: the legacy PaimonScanNode emitted a per-split "PaimonSplitStats:" block under VERBOSE; + // the SPI path dropped it, breaking paimon_data_system_table's assertJniPath + // (contains "SplitStat [type=JNI"). Under force_jni_scanner every range is JNI: 0 native of 2 + // total -> two JNI SplitStat lines. MUTATION: dropping the block, or mistyping the lines as + // NATIVE, makes this red. + Map props = new HashMap<>(); + props.put("__native_read_splits", "0"); + props.put("__total_read_splits", "2"); + props.put("__explain_verbose", "true"); + + StringBuilder out = new StringBuilder(); + provider().appendExplainInfo(out, "", props); + + Assertions.assertEquals( + "paimonNativeReadSplits=0/2\n" + + "PaimonSplitStats: \n" + + " SplitStat [type=JNI]\n" + + " SplitStat [type=JNI]\n", + out.toString()); + } + + @Test + public void appendExplainInfoOmitsSplitStatsBlockWhenNotVerbose() { + // WHY: legacy gated the block on VERBOSE; a plain (non-verbose) EXPLAIN must show only the + // paimonNativeReadSplits line, never the per-split block. Pins the verbose gate (no + // __explain_verbose key -> no block). MUTATION: emitting the block unconditionally is killed. + Map props = new HashMap<>(); + props.put("__native_read_splits", "0"); + props.put("__total_read_splits", "2"); + + StringBuilder out = new StringBuilder(); + provider().appendExplainInfo(out, "", props); + + Assertions.assertEquals("paimonNativeReadSplits=0/2\n", out.toString()); + } + + @Test + public void appendExplainInfoEmitsBothTypesForMixedNativeJniScan() { + // A mixed scan: 1 native of 2 total -> one NATIVE and one JNI SplitStat line (assertNativePath + // checks "SplitStat [type=NATIVE", assertJniPath checks "SplitStat [type=JNI"). Pins that the + // native numerator splits the lines NATIVE-first. + Map props = new HashMap<>(); + props.put("__native_read_splits", "1"); + props.put("__total_read_splits", "2"); + props.put("__explain_verbose", "true"); + + StringBuilder out = new StringBuilder(); + provider().appendExplainInfo(out, "", props); + + String text = out.toString(); + Assertions.assertTrue(text.contains("SplitStat [type=NATIVE]"), text); + Assertions.assertTrue(text.contains("SplitStat [type=JNI]"), text); + } + + @Test + public void appendExplainInfoTruncatesSplitStatsBeyondFour() { + // Legacy truncation parity: > 4 splits -> first 3 + "... other N paimon split stats ..." + last. + // 0 native of 6 -> "... other 2 paimon split stats ...". Pins the truncation so VERBOSE output + // stays bounded for large scans. + Map props = new HashMap<>(); + props.put("__native_read_splits", "0"); + props.put("__total_read_splits", "6"); + props.put("__explain_verbose", "true"); + + StringBuilder out = new StringBuilder(); + provider().appendExplainInfo(out, "", props); + + String text = out.toString(); + Assertions.assertTrue(text.contains("... other 2 paimon split stats ..."), text); + // first 3 + truncation marker + last = 5 SplitStat/marker rows, not 6 raw lines + Assertions.assertEquals(4, text.split("SplitStat \\[type=").length - 1, text); + } + + // ==================== getDeleteFiles: deletion-vector path ==================== + + /** Builds a real per-range thrift carrying a paimon deletion file at {@code path}. */ + private static TTableFormatFileDesc rangeWithDeletionFile(String path) { + PaimonScanRange range = new PaimonScanRange.Builder() + .fileFormat("orc") + .deletionFile(path, 8L, 16L) // native path: no paimon.split, with a deletion file + .build(); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + TTableFormatFileDesc tableFormat = new TTableFormatFileDesc(); + range.populateRangeParams(tableFormat, rangeDesc); + return tableFormat; + } + + @Test + public void getDeleteFilesReturnsDeletionPath() { + // WHY: the VERBOSE block counts deleteFileNum from this list; it must surface the deletion-vector + // path threaded onto the range's TPaimonFileDesc. MUTATION: returning empty (no read of + // getDeletionFile().getPath()) regresses deleteFileNum to 0. + TTableFormatFileDesc tableFormat = rangeWithDeletionFile("oss://bkt/db/tbl/index/dv-1.bin"); + + List files = provider().getDeleteFiles(tableFormat); + + Assertions.assertEquals(1, files.size()); + Assertions.assertEquals("oss://bkt/db/tbl/index/dv-1.bin", files.get(0)); + } + + @Test + public void getDeleteFilesEmptyWhenNoDeletionFile() { + // A native range with NO deletion file -> empty list (deleteFileNum contribution 0). Pins the + // isSetDeletionFile guard, mirroring legacy PaimonScanNode.getDeleteFiles. + PaimonScanRange range = new PaimonScanRange.Builder().fileFormat("orc").build(); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + TTableFormatFileDesc tableFormat = new TTableFormatFileDesc(); + range.populateRangeParams(tableFormat, rangeDesc); + + Assertions.assertTrue(provider().getDeleteFiles(tableFormat).isEmpty()); + } + + @Test + public void getDeleteFilesEmptyWhenNoPaimonParams() { + // A bare table-format desc (no paimon params) -> empty, never NPE. Pins the isSetPaimonParams + // guard so the VERBOSE loop is safe for any range shape. + Assertions.assertTrue(provider().getDeleteFiles(new TTableFormatFileDesc()).isEmpty()); + Assertions.assertTrue(provider().getDeleteFiles(null).isEmpty()); + } + + // ==================== PaimonScanRange getter permutations ==================== + + @Test + public void nativeRangeIsNativeAndCarriesNoCount() { + // A native range: a path, no paimon.split, no row count -> isNativeReadRange()=true, + // getPushDownRowCount()=-1. This is the range that increments the native numerator. + PaimonScanRange range = new PaimonScanRange.Builder() + .path("oss://bkt/db/tbl/data-1.orc") + .fileFormat("orc") + .build(); + Assertions.assertTrue(range.isNativeReadRange()); + Assertions.assertEquals(-1, range.getPushDownRowCount()); + } + + @Test + public void jniRangeIsNotNative() { + // A JNI range carries paimon.split (and no native path) -> NOT native. Under force_jni_scanner + // every range is this shape, so the native numerator stays 0 (the 0 in 0/1). + PaimonScanRange range = new PaimonScanRange.Builder() + .fileFormat("parquet") + .paimonSplit("serialized-split") + .tableLocation("oss://bkt/db/tbl") + .build(); + Assertions.assertFalse(range.isNativeReadRange()); + } + + @Test + public void countRangeCarriesRowCountAndIsNotNative() { + // The collapsed COUNT(*) range: a JNI split carrying paimon.row_count -> NOT native, and + // getPushDownRowCount() returns the summed total (12). This is what feeds "pushdown agg=COUNT + // (12)". MUTATION: a getter ignoring paimon.row_count would return -1 here. + PaimonScanRange range = new PaimonScanRange.Builder() + .fileFormat("parquet") + .paimonSplit("serialized-split") + .tableLocation("oss://bkt/db/tbl") + .rowCount(12L) + .build(); + Assertions.assertFalse(range.isNativeReadRange()); + Assertions.assertEquals(12L, range.getPushDownRowCount()); + } + + // ==================== FIX-A2: predicatesFromPaimon block ==================== + + /** Serializes a predicate list into the paimon.predicate prop EXACTLY as production does + * (PaimonScanPlanProvider.encodeObjectToString = InstantiationUtil.serializeObject + Base64). */ + private static String encodePredicates(List predicates) throws Exception { + return Base64.getEncoder().encodeToString(InstantiationUtil.serializeObject(predicates)); + } + + private static Predicate intEqPredicate() { + RowType rowType = RowType.builder().field("id", DataTypes.INT()).build(); + return new PredicateBuilder(rowType).equal(0, 5); + } + + @Test + public void appendExplainInfoEmitsPredicatesFromPaimonForPushedPredicate() throws Exception { + // WHY: legacy PaimonScanNode:660-668 listed the Paimon Predicate objects actually pushed to the + // SDK; the SPI path dropped it. The connector re-renders it by deserializing the already-present + // paimon.predicate prop. With a non-empty list, the block is "predicatesFromPaimon:\n" + one + // double-prefix-indented line per predicate. MUTATION: dropping the block (the pre-fix state) or + // mis-indenting -> red. + Predicate p = intEqPredicate(); + Map props = new HashMap<>(); + props.put("__native_read_splits", "1"); + props.put("__total_read_splits", "2"); + props.put("paimon.predicate", encodePredicates(Collections.singletonList(p))); + + StringBuilder out = new StringBuilder(); + provider().appendExplainInfo(out, " ", props); + + // paimonNativeReadSplits= first, then predicatesFromPaimon: with the predicate double-indented + // (prefix+prefix), matching legacy ordering and format byte-for-byte. + Assertions.assertEquals( + " paimonNativeReadSplits=1/2\n" + + " predicatesFromPaimon:\n" + + " " + p.toString() + "\n", + out.toString()); + } + + @Test + public void appendExplainInfoEmitsPredicatesFromPaimonNoneForEmptyList() throws Exception { + // WHY: legacy rendered " NONE" when the pushed predicate list is empty (no pushable filter). The + // empty list still serializes to a non-null paimon.predicate (getScanNodeProperties:579 always + // emits it). MUTATION: skipping the line for empty, or rendering it differently than " NONE" -> red. + Map props = new HashMap<>(); + props.put("__native_read_splits", "1"); + props.put("__total_read_splits", "2"); + props.put("paimon.predicate", encodePredicates(Collections.emptyList())); + + StringBuilder out = new StringBuilder(); + provider().appendExplainInfo(out, " ", props); + + Assertions.assertEquals( + " paimonNativeReadSplits=1/2\n" + + " predicatesFromPaimon: NONE\n", + out.toString()); + } + + @Test + public void appendExplainInfoOrdersPredicatesBetweenNativeSplitsAndVerboseStats() throws Exception { + // WHY: legacy order is paimonNativeReadSplits -> predicatesFromPaimon -> VERBOSE PaimonSplitStats + // (PaimonScanNode:657-671). Pins that the new block lands between the two. MUTATION: emitting + // predicatesFromPaimon after PaimonSplitStats (wrong placement) -> red. + Predicate p = intEqPredicate(); + Map props = new HashMap<>(); + props.put("__native_read_splits", "0"); + props.put("__total_read_splits", "2"); + props.put("__explain_verbose", "true"); + props.put("paimon.predicate", encodePredicates(Collections.singletonList(p))); + + StringBuilder out = new StringBuilder(); + provider().appendExplainInfo(out, "", props); + + String text = out.toString(); + int iNative = text.indexOf("paimonNativeReadSplits="); + int iPreds = text.indexOf("predicatesFromPaimon:"); + int iStats = text.indexOf("PaimonSplitStats:"); + Assertions.assertTrue(iNative >= 0 && iPreds >= 0 && iStats >= 0, text); + Assertions.assertTrue(iNative < iPreds && iPreds < iStats, + "order must be paimonNativeReadSplits < predicatesFromPaimon < PaimonSplitStats: " + text); + } + + @Test + public void appendExplainInfoSkipsPredicatesFromPaimonWhenPropAbsent() { + // WHY: absent paimon.predicate != empty list. When the prop is missing (another connector's props, + // or a path that did not build it) the line must be SKIPPED, not rendered as " NONE". This is why + // every existing exact-equality test above (none set paimon.predicate) stays green. Mirrors the + // sibling appendExplainInfoSkipsWhenSyntheticKeysAbsent guard. MUTATION: rendering " NONE" on a + // null prop -> red here AND breaks the existing exact-equality tests. + Map props = new HashMap<>(); + props.put("__native_read_splits", "1"); + props.put("__total_read_splits", "2"); + + StringBuilder out = new StringBuilder(); + provider().appendExplainInfo(out, " ", props); + + String text = out.toString(); + Assertions.assertTrue(text.contains("paimonNativeReadSplits=1/2"), text); + Assertions.assertFalse(text.contains("predicatesFromPaimon"), + "predicatesFromPaimon must be skipped when paimon.predicate is absent: " + text); + } +} 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 new file mode 100644 index 00000000000000..ec7c1e8d299ce0 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanMetricsTest.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.paimon; + +import org.apache.doris.connector.api.scan.ConnectorScanProfile; + +import org.apache.paimon.operation.metrics.ScanMetrics; +import org.apache.paimon.operation.metrics.ScanStats; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Optional; + +/** + * FIX-SCAN-METRICS — guards {@link PaimonScanMetrics}, which harvests the paimon SDK {@code ScanMetrics} + * recorded by {@link PaimonMetricRegistry} during {@code scan.plan()} into a connector-neutral profile (the + * migration dropped this diagnostic; restoring it needs no fe-core import — the metric API is paimon's). + */ +public class PaimonScanMetricsTest { + + @Test + public void harvestRendersRecordedScanMetrics() { + // THE load-bearing RED assertion: drive the real paimon SDK to record a scan into the registry, then + // harvest it. reportScan(duration, scannedManifests, skippedTableFiles, resultedTableFiles) populates + // the LAST_* gauges. A mutation that drops the harvest returns empty. + PaimonMetricRegistry registry = new PaimonMetricRegistry(); + ScanMetrics metrics = new ScanMetrics(registry, "mydb.mytbl"); + metrics.reportScan(new ScanStats(2_000_000L, 5L, 3L, 7L)); + + Optional profile = + PaimonScanMetrics.harvest(registry, "mydb.mytbl", "Table Scan (mydb.mytbl)"); + Assertions.assertTrue(profile.isPresent(), "recorded scan metrics must harvest"); + Assertions.assertEquals("Paimon Scan Metrics", profile.get().getGroupName()); + Assertions.assertEquals("Table Scan (mydb.mytbl)", profile.get().getScanLabel()); + Map m = profile.get().getMetrics(); + Assertions.assertEquals("5", m.get("last_scanned_manifests")); + Assertions.assertEquals("3", m.get("last_scan_skipped_table_files")); + Assertions.assertEquals("7", m.get("last_scan_resulted_table_files")); + } + + @Test + public void harvestEmptyWhenNoMetricsRecorded() { + // A registry the SDK never recorded a scan group into -> nothing to harvest (unpartitioned/no-op scan, + // unsupported scan type). Same as the un-overridden default; documents the fall-through. + Assertions.assertEquals(Optional.empty(), + PaimonScanMetrics.harvest(new PaimonMetricRegistry(), "mydb.mytbl", "Table Scan (mydb.mytbl)")); + Assertions.assertEquals(Optional.empty(), + PaimonScanMetrics.harvest(null, "mydb.mytbl", "x")); + } + + @Test + public void prettyMsMatchesLegacyFormatter() { + // Self-ported fe-core DebugUtil.getPrettyStringMs (the connector cannot import fe-core). + Assertions.assertEquals("0", PaimonScanMetrics.prettyMs(0)); + Assertions.assertEquals("500ms", PaimonScanMetrics.prettyMs(500)); + Assertions.assertEquals("1sec234ms", PaimonScanMetrics.prettyMs(1234)); + Assertions.assertEquals("1min", PaimonScanMetrics.prettyMs(60_000)); + } + + @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). + 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 new file mode 100644 index 00000000000000..f58bb1c21fbc39 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderCapabilityTest.java @@ -0,0 +1,103 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; +import org.apache.doris.connector.api.scan.ConnectorScanRange; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalLong; + +/** + * Guards the predicate-driven scan capability: paimon must opt OUT of the FE prune-to-zero short-circuit + * ({@link ConnectorScanPlanProvider#ignorePartitionPruneShortCircuit()} == true) so that, with master-parity + * {@code isNull=false} genuine-null partitions, a {@code col IS NULL} query (which prunes every partition away + * at FE) is NOT short-circuited to zero rows but re-planned from the pushed predicate — restoring the + * genuine-null row (regression test_paimon_runtime_filter_partition_pruning qt_null_partition_4). The SPI + * default stays {@code false} so partition-restricting connectors (e.g. MaxCompute) keep the short-circuit. + */ +public class PaimonScanPlanProviderCapabilityTest { + + @Test + public void paimonOptsOutOfPruneToZeroShortCircuit() { + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), null); + Assertions.assertTrue(provider.ignorePartitionPruneShortCircuit(), + "paimon is predicate-driven and must opt out of the prune-to-zero short-circuit"); + } + + @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(); + Assertions.assertFalse(defaultProvider.ignorePartitionPruneShortCircuit(), + "the SPI default must keep the prune-to-zero short-circuit"); + } + + private static Map part(String key, String value) { + Map m = new HashMap<>(); + m.put(key, value); + return m; + } + + private static PaimonScanRange rangeWithPartition(String path, Map partitionValues) { + return new PaimonScanRange.Builder() + .path(path) + .partitionValues(partitionValues) + .build(); + } + + @Test + public void scannedPartitionCountReturnsDistinctPartitions() { + // FIX-L12 THE load-bearing RED assertion: paimon restores legacy selectedPartitionNum = + // distinct dataSplit.partition() (== distinct rendered getPartitionValues() maps). Three ranges + // over TWO partitions (two ranges of dt=1 + one of dt=2) must count 2, not 3. A mutation that + // drops the override (default OptionalLong.empty()) makes this red. + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), null); + List ranges = Arrays.asList( + rangeWithPartition("/t/dt=1/a.parquet", part("dt", "1")), + rangeWithPartition("/t/dt=1/b.parquet", part("dt", "1")), + rangeWithPartition("/t/dt=2/c.parquet", part("dt", "2"))); + Assertions.assertEquals(OptionalLong.of(2L), provider.scannedPartitionCount(ranges)); + } + + @Test + public void scannedPartitionCountEmptyForUnpartitionedTable() { + // Every range's partition map is empty (unpartitioned table) -> report nothing so the engine keeps + // its own count. (Same value as the un-overridden default; documents the fall-through, not RED-able.) + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), null); + List ranges = Arrays.asList( + rangeWithPartition("/t/a.parquet", Collections.emptyMap()), + rangeWithPartition("/t/b.parquet", Collections.emptyMap())); + Assertions.assertEquals(OptionalLong.empty(), provider.scannedPartitionCount(ranges)); + } + + @Test + public void scannedPartitionCountDefaultsToEmpty() { + // The SPI default (non-overriding connector, e.g. hive/MaxCompute) reports nothing. + ConnectorScanPlanProvider defaultProvider = (session, handle, columns, filter) -> 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 new file mode 100644 index 00000000000000..b8c031eb797625 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java @@ -0,0 +1,2409 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.handle.ConnectorColumnHandle; +import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.filesystem.FileSystemType; +import org.apache.doris.filesystem.properties.BackendStorageKind; +import org.apache.doris.filesystem.properties.BackendStorageProperties; +import org.apache.doris.filesystem.properties.StorageKind; +import org.apache.doris.filesystem.properties.StorageProperties; +import org.apache.doris.thrift.TFileScanRangeParams; +import org.apache.doris.thrift.TPrimitiveType; +import org.apache.doris.thrift.schema.external.TField; +import org.apache.doris.thrift.schema.external.TFieldPtr; +import org.apache.doris.thrift.schema.external.TSchema; + +import org.apache.paimon.catalog.Catalog; +import org.apache.paimon.catalog.FileSystemCatalog; +import org.apache.paimon.catalog.Identifier; +import org.apache.paimon.data.GenericRow; +import org.apache.paimon.fs.local.LocalFileIO; +import org.apache.paimon.io.DataInputViewStreamWrapper; +import org.apache.paimon.schema.Schema; +import org.apache.paimon.table.FileStoreTable; +import org.apache.paimon.table.Table; +import org.apache.paimon.table.sink.BatchTableCommit; +import org.apache.paimon.table.sink.BatchTableWrite; +import org.apache.paimon.table.sink.BatchWriteBuilder; +import org.apache.paimon.table.sink.CommitMessage; +import org.apache.paimon.table.source.DataSplit; +import org.apache.paimon.table.source.DeletionFile; +import org.apache.paimon.table.source.RawFile; +import org.apache.paimon.table.source.Split; +import org.apache.paimon.table.system.ReadOptimizedTable; +import org.apache.paimon.types.DataField; +import org.apache.paimon.types.DataType; +import org.apache.paimon.types.DataTypes; +import org.apache.paimon.types.RowType; +import org.apache.paimon.utils.InstantiationUtil; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Tests for {@link PaimonScanPlanProvider#resolveTable}, pinning the transient-Table reload + * fallback on the scan path (P5-T06). The scan path reads the handle's transient Paimon + * {@link Table}, which becomes null after any Java serialization round-trip (cross-node / + * plan-reuse); the reload mirrors the proven fallback in + * {@link PaimonConnectorMetadata#getColumnHandles}. + * + *

    Driven directly against {@code resolveTable} (package-private) rather than {@code planScan} + * end-to-end: {@link FakePaimonTable#newReadBuilder()} throws, so the full scan cannot be driven + * offline. The seam fully covers the remote {@code getTable} call, so each test uses a + * {@link RecordingPaimonCatalogOps} fake and a {@code null} real catalog — entirely offline. + */ +public class PaimonScanPlanProviderTest { + + private static RowType rowType(String... columnNames) { + RowType.Builder builder = RowType.builder(); + for (String name : columnNames) { + builder.field(name, DataTypes.INT()); + } + return builder.build(); + } + + @Test + public void resolveTableReloadsWhenTransientTableNull() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + Table reloaded = new FakePaimonTable( + "t1", + rowType("id", "name"), + Collections.emptyList(), + Collections.emptyList()); + ops.table = reloaded; + // A handle whose transient Table is null (e.g. after serialization across the FE/BE + // boundary or plan reuse) — the scan path must reload via the seam rather than NPE. + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + Assertions.assertNull(handle.getPaimonTable(), "precondition: transient table is null"); + + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops); + Table table = provider.resolveTable(handle); + + // WHY: this is the serde-survival safety net. With a null transient Table, the scan path's + // only way to read rowType()/serialize the table for BE is to re-fetch it from the catalog + // seam. MUTATION: removing the `if (table == null) { table = catalogOps.getTable(id); }` + // block -> returns null -> downstream NPE on table.rowType() -> red. The recorded getTable + // call proves the reload happened. + Assertions.assertSame(reloaded, table, + "scan path must return the table reloaded from the seam when the transient ref is null"); + Assertions.assertTrue(ops.log.contains("getTable:db1.t1"), + "reload-fallback must re-fetch the table from the seam when the transient ref is null"); + } + + @Test + public void resolveTableForSysHandleReloadsViaFourArgSysIdentifier() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + // Base table (served for a 2-arg Identifier) has DIFFERENT columns than the sys table, so a + // wrong-Identifier reload (base table) is detectable by the captured Identifier's sys name. + ops.table = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + ops.sysTable = new FakePaimonTable( + "t1$snapshots", rowType("snapshot_id", "schema_id"), + Collections.emptyList(), Collections.emptyList()); + + // A deserialized SYSTEM handle: sysTableName set, transient Table lost (null) — exactly the + // FE/BE serialization or plan-reuse case the scan path must survive. + PaimonTableHandle sysHandle = PaimonTableHandle.forSystemTable( + "db1", "t1", "snapshots", false); + Assertions.assertNull(sysHandle.getPaimonTable(), "precondition: transient table is null"); + + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops); + Table resolved = provider.resolveTable(sysHandle); + + // WHY: BLOCKER fix — the scan path's own resolveTable used to ALWAYS reload via the 2-arg + // base Identifier, so a deserialized sys handle would silently resolve and scan the BASE + // table (wrong rows) instead of the system table. The reload must be sys-aware (4-arg sys + // Identifier), mirroring the metadata side, via the single shared PaimonTableResolver. + // MUTATION: reverting the scan resolveTable to Identifier.create(db,table) -> the base table + // is returned, the captured Identifier's sys name is null -> red. + Assertions.assertSame(ops.sysTable, resolved, + "scan path must reload the SYSTEM table (not the base table) for a sys handle"); + Assertions.assertNotNull(ops.lastGetTableId, "reload must have hit the seam"); + Assertions.assertEquals("snapshots", ops.lastGetTableId.getSystemTableName(), + "the scan reload must use the 4-arg sys Identifier carrying the sys-table name"); + Assertions.assertEquals("main", ops.lastGetTableId.getBranchName(), + "the sys Identifier branch must be hardcoded 'main' (legacy parity)"); + } + + @Test + public void resolveTableRunsInsideAuthenticatorWhenContextPresent() { + // M-11 (D-052): with a real ConnectorContext the scan-path reload must run inside + // executeAuthenticated, so the FE-injected Kerberos UGI applies. Under failAuth the wrapped + // reload aborts BEFORE the getTable seam runs. MUTATION: an un-wrapped resolveTable would call + // catalogOps.getTable directly -> "getTable:db1.t1" logged despite the auth failure -> red. + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + ctx.failAuth = true; + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops, ctx); + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + + Assertions.assertThrows(RuntimeException.class, () -> provider.resolveTable(handle)); + Assertions.assertTrue(ops.log.isEmpty(), + "auth failure must abort BEFORE the scan resolveTable getTable seam runs"); + Assertions.assertEquals(1, ctx.authCount); + } + + @Test + public void resolveTableEntersAuthenticatorOnHappyPath() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + RecordingConnectorContext ctx = new RecordingConnectorContext(); + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops, ctx); + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + + provider.resolveTable(handle); // null transient -> reload via the wrapped seam + Assertions.assertEquals(Collections.singletonList("getTable:db1.t1"), ops.log); + Assertions.assertEquals(1, ctx.authCount); + } + + @Test + public void planScanEnumeratesSplitsInsideAuthScope(@TempDir Path warehouse) throws Exception { + // scan.plan() reads paimon's snapshot/manifest files remotely, on the planning thread — on a + // Kerberos filesystem catalog that read runs on the PLUGIN's UGI copy, which only the plugin doAs + // logs in (iceberg fourth-locus parity; iceberg CI proof: SELECT after INSERT failing SASL at the + // plan-time manifest read). So planScan must wrap the enumeration in executeAuthenticated IN + // ADDITION to resolveTable's load wrap. MUTATION: dropping the planSplits wrap -> authCount stays + // 1 (load only) -> red. + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + catalog.createDatabase("db", false); + Identifier id = Identifier.create("db", "t"); + catalog.createTable(id, Schema.newBuilder() + .column("id", DataTypes.INT()) + .column("val", DataTypes.BIGINT()) + .primaryKey("id") + .option("bucket", "1") + .build(), false); + Table table = catalog.getTable(id); + BatchWriteBuilder wb = table.newBatchWriteBuilder(); + try (BatchTableWrite write = wb.newWrite()) { + write.write(GenericRow.of(1, 100L)); + List messages = write.prepareCommit(); + try (BatchTableCommit commit = wb.newCommit()) { + commit.commit(messages); + } + } + + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = table; + RecordingConnectorContext ctx = new RecordingConnectorContext(); + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops, ctx); + PaimonTableHandle handle = new PaimonTableHandle( + "db", "t", Collections.emptyList(), Collections.emptyList()); + + List ranges = provider.planScan( + sessionWithProps(Collections.emptyMap()), handle, + Collections.emptyList(), Optional.empty()); + + Assertions.assertFalse(ranges.isEmpty(), "one committed row must plan at least one split"); + Assertions.assertEquals(2, ctx.authCount, + "planScan must run BOTH the table load (resolveTable) AND the split enumeration " + + "(scan.plan(), the remote manifest read) inside executeAuthenticated"); + } + } + + /** Builds a native-eligible RawFile (parquet suffix). The numeric fields are irrelevant to the + * native-vs-JNI routing decision under test, only the path suffix matters. */ + private static RawFile parquetRawFile(String path) { + return new RawFile(path, 0L, 100L, 100L, "parquet", 0L, 0L); + } + + @Test + public void forceJniSysTableSplitDoesNotTakeNativePathEvenWithRawFiles() { + // A binlog/audit_log sys handle: forceJni=true. Its DataSplit WOULD support native (raw + // parquet files present), but the binlog/audit_log read semantics (pack/merge, rowkind/ + // sequence-number projection) are not reproducible by the native ORC/Parquet reader. + Optional> rawFiles = Optional.of( + Arrays.asList(parquetRawFile("/data/part-0.parquet"))); + + // WHY: legacy forces binlog/audit_log to JNI (PaimonScanNode.shouldForceJniForSystemTable, + // captured as handle.isForceJni()). Without the gate the native path would silently return + // wrong rows. MUTATION: dropping the `!forceJni` guard in shouldUseNativeReader -> + // returns true here (native) -> red. + Assertions.assertFalse( + PaimonScanPlanProvider.shouldUseNativeReader( + /*forceJni*/ true, /*forceJniScanner*/ false, rawFiles), + "a forceJni (binlog/audit_log) sys split must route to JNI, never native, " + + "even when its raw files would otherwise support the native reader"); + } + + @Test + public void nonForcedSplitWithRawFilesStillTakesNativePath() { + // A normal table (or a non-forced DataTable sys table like "ro"): forceJni=false. With raw + // files that support the native reader, it must still be allowed the native path. + Optional> rawFiles = Optional.of( + Arrays.asList(parquetRawFile("/data/part-0.parquet"))); + + // WHY: the gate must be the forceJni flag ONLY — over-forcing JNI for non-forced splits + // would regress the native fast path for normal tables and "ro". MUTATION: gating native on + // anything stricter (e.g. isSystemTable) -> returns false here -> red. + Assertions.assertTrue( + PaimonScanPlanProvider.shouldUseNativeReader( + /*forceJni*/ false, /*forceJniScanner*/ false, rawFiles), + "a non-forced split with native-eligible raw files must still take the native path"); + } + + @Test + public void nonForcedSplitWithoutNativeFilesTakesJni() { + // Sanity: even when not forced, a split whose raw files are absent must not go native. + // MUTATION: making shouldUseNativeReader ignore supportNativeReader -> returns true -> red. + Assertions.assertFalse( + PaimonScanPlanProvider.shouldUseNativeReader( + /*forceJni*/ false, /*forceJniScanner*/ false, Optional.empty()), + "a split without convertible raw files must route to JNI regardless of forceJni"); + } + + @Test + public void forceJniScannerRoutesNativeEligibleSplitToJni() { + // FIX-FORCE-JNI-SCANNER (M-1): a normal (non-name-forced) split whose raw files DO support the + // native reader must STILL route to JNI when the session sets force_jni_scanner=true — this is the + // user escape hatch legacy honors (PaimonScanNode.getSplits gate: !forceJniScanner && ...). Without + // it the native-reader bug the user is trying to dodge stays on the native path. + Optional> rawFiles = Optional.of( + Arrays.asList(parquetRawFile("/data/part-0.parquet"))); + + // WHY: routing-correctness — force_jni_scanner is a sibling of the handle name-force, ANDed into + // the same native gate. MUTATION: dropping the `!forceJniScanner` conjunct in shouldUseNativeReader + // -> this native-eligible split goes native despite force_jni_scanner=true -> red. + Assertions.assertFalse( + PaimonScanPlanProvider.shouldUseNativeReader( + /*forceJni*/ false, /*forceJniScanner*/ true, rawFiles), + "force_jni_scanner=true must route even native-eligible ORC/Parquet splits to JNI"); + } + + // ---- FIX-URI-NORMALIZE (B-7DF data file + B-7DV deletion vector) ---- + + @Test + public void nativeRangeNormalizesBothDataAndDeletionVectorPaths() { + RecordingConnectorContext ctx = new RecordingConnectorContext(); + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + new HashMap<>(), new RecordingPaimonCatalogOps(), ctx); + RawFile file = parquetRawFile("oss://bkt/warehouse/db/t/part-0.parquet"); + DeletionFile dv = new DeletionFile( + "oss://bkt/warehouse/db/t/index/dv-0.index", 8L, 16L, 4L); + + PaimonScanRange range = provider.buildNativeRange( + file, dv, "parquet", Collections.emptyMap(), Collections.emptyMap(), 0L, 100L, 64L * 1024 * 1024); + + // 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 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", + range.getPath().orElse(null), "data-file path must be normalized to s3://"); + Assertions.assertEquals("s3://bkt/warehouse/db/t/index/dv-0.index", + range.getProperties().get("paimon.deletion_file.path"), + "deletion-vector path must be normalized to s3://"); + Assertions.assertEquals(2, ctx.normalizeCount, + "both the data-file and the DV path must be routed through normalizeStorageUri"); + } + + @Test + public void nativeRangeWithoutDeletionVectorNormalizesOnlyDataPath() { + RecordingConnectorContext ctx = new RecordingConnectorContext(); + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + new HashMap<>(), new RecordingPaimonCatalogOps(), ctx); + + PaimonScanRange range = provider.buildNativeRange( + parquetRawFile("oss://bkt/a/part-0.parquet"), null, "parquet", + Collections.emptyMap(), Collections.emptyMap(), 0L, 100L, 64L * 1024 * 1024); + + // WHY: a DV-less native split must still normalize its data-file path and must NOT emit a DV + // descriptor. MUTATION: emitting a deletion_file for a null DV, or skipping data normalization -> red. + Assertions.assertEquals("s3://bkt/a/part-0.parquet", range.getPath().orElse(null)); + Assertions.assertFalse(range.getProperties().containsKey("paimon.deletion_file.path"), + "no deletion vector -> no deletion_file descriptor"); + Assertions.assertEquals(1, ctx.normalizeCount); + } + + @Test + public void nativeRangeWithoutContextPreservesRawPath() { + // 3-arg ctor leaves context == null (the offline harness path): no normalization machinery is + // available, so the raw path is preserved without NPE. The real oss://->s3:// rewrite is + // covered by DefaultConnectorContextNormalizeUriTest (fe-core). + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + new HashMap<>(), new RecordingPaimonCatalogOps()); + + PaimonScanRange range = provider.buildNativeRange( + parquetRawFile("oss://bkt/a/part-0.parquet"), null, "parquet", + Collections.emptyMap(), Collections.emptyMap(), 0L, 100L, 64L * 1024 * 1024); + + // MUTATION: NPE on null context, or fabricating a normalized path from nothing -> red. + Assertions.assertEquals("oss://bkt/a/part-0.parquet", range.getPath().orElse(null)); + } + + @Test + public void buildNativeRangeThreadsVendedTokenToBothPaths() { + // FIX-REST-VENDED-URI-NORMALIZE (P9-1, BLOCKER): the per-table vended token must reach the + // engine's normalize seam on BOTH the data-file AND the deletion-vector path, so a REST + // object-store read (whose catalog static storage map is empty by design) normalizes via the + // vended credentials instead of throwing "No storage properties found for schema: oss". The + // positive RESTTokenFileIO extraction needs a live REST stack (E2E-gated); here we pin that + // whatever token the scan computes is threaded VERBATIM to each normalize call. + RecordingConnectorContext ctx = new RecordingConnectorContext(); + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + new HashMap<>(), new RecordingPaimonCatalogOps(), ctx); + Map vendedToken = new HashMap<>(); + vendedToken.put("fs.oss.accessKeyId", "STS.ak"); + vendedToken.put("fs.oss.accessKeySecret", "sk"); + RawFile file = parquetRawFile("oss://bkt/warehouse/db/t/part-0.parquet"); + DeletionFile dv = new DeletionFile( + "oss://bkt/warehouse/db/t/index/dv-0.index", 8L, 16L, 4L); + + PaimonScanRange range = provider.buildNativeRange( + file, dv, "parquet", Collections.emptyMap(), vendedToken, 0L, 100L, 64L * 1024 * 1024); + + // WHY: the engine seam normalizes against the VENDED map (the REST static map is empty). If the + // connector dropped the token (reverting to the 1-arg seam) or substituted an empty map, a REST + // native read would reach BE with an un-openable oss:// (data) or a silently-dropped DV + // (merge-on-read corruption). MUTATION: 1-arg normalize (token lost -> lastVendedToken null), or + // passing Collections.emptyMap() instead of the token -> assertSame red. + Assertions.assertEquals("s3://bkt/warehouse/db/t/part-0.parquet", range.getPath().orElse(null)); + Assertions.assertEquals("s3://bkt/warehouse/db/t/index/dv-0.index", + range.getProperties().get("paimon.deletion_file.path")); + Assertions.assertEquals(2, ctx.normalizeCount, + "both the data-file and the DV path must route through the vended-aware normalize"); + Assertions.assertSame(vendedToken, ctx.lastVendedToken, + "the per-table vended token must be threaded to the normalize seam (not empty/null)"); + } + + @Test + public void resolveScanTableAppliesSnapshotPinViaCopy() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + FakePaimonTable base = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + // The pinned (copied) table is a DISTINCT instance so we can prove the scan uses the COPY, + // not the un-pinned base. + FakePaimonTable pinned = new FakePaimonTable( + "t1@5", rowType("id"), Collections.emptyList(), Collections.emptyList()); + base.copyResult = pinned; + + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(base); + // A snapshot-pinned handle: applySnapshot would have produced exactly this scanOptions map. + PaimonTableHandle pinnedHandle = handle.withScanOptions( + Collections.singletonMap("scan.snapshot-id", "5")); + + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops); + Table scanTable = provider.resolveScanTable(pinnedHandle); + + // WHY: a snapshot-pinned handle must read at the pinned version on BOTH the planned-splits + // and the JNI serialized-table paths. The scan provider applies the pin by layering the + // handle's scanOptions onto the resolved table via Table.copy(scanOptions). MUTATION: + // skipping the copy (using the un-pinned resolveTable result) -> scanTable is `base`, not + // `pinned`, and lastCopyOptions stays null -> red; passing the wrong options -> the + // scan.snapshot-id assertion below -> red. + Assertions.assertSame(pinned, scanTable, + "the scan path must use the snapshot-pinned (copied) table, not the un-pinned base"); + Assertions.assertEquals(Collections.singletonMap("scan.snapshot-id", "5"), + base.lastCopyOptions, + "the scan path must layer the handle's scanOptions via Table.copy(scanOptions)"); + } + + @Test + public void resolveScanTableWithoutScanOptionsDoesNotCopy() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + FakePaimonTable base = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + // A normal (un-pinned) handle: empty scanOptions. + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(base); + + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops); + Table scanTable = provider.resolveScanTable(handle); + + // WHY: a normal read must NOT call Table.copy at all — copying with empty options is wasted + // work and, more importantly, the un-pinned path must return the resolved table verbatim. + // MUTATION: unconditionally calling copy(scanOptions) -> lastCopyOptions becomes non-null + // (and FakePaimonTable.copy would be hit) -> red. + Assertions.assertSame(base, scanTable, + "an un-pinned handle must return the resolved table without a copy"); + Assertions.assertNull(base.lastCopyOptions, + "an un-pinned handle must NOT invoke Table.copy"); + } + + @Test + public void resolveScanTableResetsStalePinForIncrementalRead(@TempDir Path warehouse) throws Exception { + // A REAL paimon table (not FakePaimonTable, whose copy() is a no-op recorder that cannot + // reproduce paimon's merge/remove/immutability) that PERSISTS a stale scan.snapshot-id/scan.mode + // in its schema options — legal & mutable via TBLPROPERTIES / ALTER TABLE SET / table-default.*. + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + catalog.createDatabase("db", false); + Identifier id = Identifier.create("db", "t"); + catalog.createTable(id, Schema.newBuilder() + .column("id", DataTypes.INT()) + .column("val", DataTypes.BIGINT()) + .primaryKey("id") + .option("bucket", "1") + .option("scan.snapshot-id", "1") + .option("scan.mode", "from-snapshot") + .build(), false); + Table base = catalog.getTable(id); + Assertions.assertEquals("1", base.options().get("scan.snapshot-id"), + "fixture precondition: the base table must persist a stale scan.snapshot-id"); + + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = new PaimonTableHandle( + "db", "t", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(base); + // applySnapshot's INCREMENTAL pin produces exactly this scanOptions map (incremental-between + // ONLY — the null resets are NOT carried through the SPI; they are reapplied at copy time). + PaimonTableHandle incrHandle = handle.withScanOptions( + Collections.singletonMap("incremental-between", "3,5")); + + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops); + Table scanTable = provider.resolveScanTable(incrHandle); + + // WHY (FIX-INCR-SCAN-RESET): an @incr read over a base table that persists a stale + // scan.snapshot-id must reset it to null BEFORE Table.copy (the single chokepoint shared by + // the native/JNI scan path planScanInternal and the JNI serialized-table path + // getScanNodeProperties). Without the reset, paimon 1.3.1 THROWS at copy() + // ("[incremental-between] must be null when you set [scan.snapshot-id,scan.tag-name]") — so + // resolveScanTable would throw before reaching these assertions — or silently downgrades to + // FROM_SNAPSHOT at the stale id (wrong @incr rows). With the reset, the stale pin is removed + // and the incremental window survives. MUTATION: dropping applyResetsIfIncremental in + // resolveScanTable -> copy throws (or returns a table still carrying scan.snapshot-id) -> red. + Assertions.assertFalse(scanTable.options().containsKey("scan.snapshot-id"), + "the stale persisted scan.snapshot-id must be reset (removed) for an @incr read"); + Assertions.assertEquals("3,5", scanTable.options().get("incremental-between"), + "the @incr window (incremental-between) must survive the copy"); + } + } + + @Test + public void getScanNodePropertiesAlwaysEmitsPredicateForNoFilterScan(@TempDir Path warehouse) + throws Exception { + // RC-2 (CI 968828): a paimon scan with NO pushed-down filter must STILL emit the paimon.predicate + // param. PaimonJniScanner.getPredicates() deserializes it UNCONDITIONALLY, so when the key is + // absent the JNI reader NPEs ("encodedStr is null") on every no-WHERE force_jni read. Legacy + // PaimonScanNode.createScanRangeLocations always serialized the (possibly empty) predicate list. + // MUTATION: re-gating props.put("paimon.predicate", ...) on filter.isPresent() && !isEmpty (the + // pre-fix behavior) -> the key is absent for a no-filter scan -> red. + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + catalog.createDatabase("db", false); + Identifier id = Identifier.create("db", "t"); + catalog.createTable(id, Schema.newBuilder() + .column("id", DataTypes.INT()) + .column("val", DataTypes.BIGINT()) + .primaryKey("id") + .option("bucket", "1") + .build(), false); + Table base = catalog.getTable(id); + + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = new PaimonTableHandle( + "db", "t", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(base); + + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops); + Map props = provider.getScanNodeProperties( + null, handle, Collections.emptyList(), Optional.empty()); + + String encoded = props.get("paimon.predicate"); + Assertions.assertNotNull(encoded, + "a no-filter scan must still emit paimon.predicate (else the BE JNI reader NPEs on " + + "deserialize(null) -> 'encodedStr is null')"); + // Round-trips (same Base64 + paimon InstantiationUtil the BE PaimonUtils.deserialize uses) to + // an EMPTY predicate list, so ReadBuilder.withFilter(emptyList) applies no filter. + byte[] decoded = Base64.getDecoder().decode(encoded.getBytes(StandardCharsets.UTF_8)); + Object obj = InstantiationUtil.deserializeObject(decoded, getClass().getClassLoader()); + Assertions.assertTrue(obj instanceof List, "paimon.predicate must deserialize to a List"); + Assertions.assertTrue(((List) obj).isEmpty(), + "a no-filter scan's predicate list must deserialize to empty (no filter applied)"); + } + } + + @Test + public void getScanNodePropertiesEmitsPathPartitionKeysForPartitionedTable(@TempDir Path warehouse) + throws Exception { + // RC (CI 968880): a partitioned paimon table must declare path_partition_keys so + // PluginDrivenScanNode.getPathPartitionKeys excludes the partition columns from the file/decode + // set. Paimon stores partition columns IN the data file and the per-split columnsFromPath already + // appends them; without path_partition_keys the BE both DECODES dt from the ORC file AND APPENDS + // it -> a row-count double-fill that aborts the native OrcReader (DCHECK block rows != dt rows). + // MUTATION: dropping the props.put("path_partition_keys", ...) -> the key is absent -> red. + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + catalog.createDatabase("db", false); + Identifier id = Identifier.create("db", "t"); + catalog.createTable(id, Schema.newBuilder() + .column("id", DataTypes.INT()) + .column("dt", DataTypes.STRING()) + .partitionKeys("dt") + .option("bucket", "-1") + .build(), false); + Table base = catalog.getTable(id); + + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + PaimonTableHandle handle = new PaimonTableHandle( + "db", "t", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(base); + + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops); + Map props = provider.getScanNodeProperties( + null, handle, Collections.emptyList(), Optional.empty()); + + Assertions.assertEquals("dt", props.get("path_partition_keys"), + "a partitioned paimon table must declare its partition columns as path_partition_keys " + + "so the BE excludes them from the file decode set (else double-fill -> crash)"); + } + } + + @Test + public void getScanNodePropertiesEmitsSchemaEvolutionForReadOptimizedSysTable(@TempDir Path warehouse) + throws Exception { + // RC (BE SIGSEGV, CI 4b983431bda): a native read of a paimon $ro (read-optimized) system table + // must STILL emit the paimon.schema_evolution dict, so BE sets history_schema_info and matches + // file<->table columns BY FIELD ID. A $ro table resolves to a paimon ReadOptimizedTable, which + // is NOT an instanceof FileStoreTable (it WRAPS one), so buildSchemaEvolutionParam used to skip + // it and emit nothing. With no history_schema_info, BE's gen_table_info_node_by_field_id falls + // into the legacy name-matching branch by_parquet_name(tuple_descriptor, ...), where the paimon + // reader passes a still-null _tuple_descriptor (get_tuple_descriptor() is only populated later in + // _do_init_reader, after on_before_init_reader) and dereferences it + // (table_schema_change_helper.cpp:94) -> SIGSEGV that aborts the whole BE. Legacy PaimonScanNode + // set history_schema_info for ANY paimon table (incl. $ro) in doInitialize; this restores that + // parity by building the dict from the BASE FileStoreTable that $ro reads. + // MUTATION: reverting resolveSchemaDictTable to pass the $ro table straight to + // buildSchemaEvolutionParam (its instanceof FileStoreTable guard returns empty) -> the key is + // absent -> red. + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + catalog.createDatabase("db", false); + Identifier id = Identifier.create("db", "t"); + catalog.createTable(id, Schema.newBuilder() + .column("id", DataTypes.INT()) + .column("name", DataTypes.STRING()) + .primaryKey("id") + .option("bucket", "1") + .build(), false); + FileStoreTable base = (FileStoreTable) catalog.getTable(id); + // $ro resolves to a ReadOptimizedTable wrapping the base FileStoreTable. + ReadOptimizedTable roTable = new ReadOptimizedTable(base); + + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + // The sys reload (4-arg sys Identifier) serves the $ro table; the base reload the fix issues + // for the schema dict (2-arg base Identifier) serves the base FileStoreTable. + ops.sysTable = roTable; + ops.table = base; + // A deserialized $ro handle: sysTableName="ro", forceJni=false (only binlog/audit_log force + // JNI), transient Table lost so resolveScanTable reloads the ReadOptimizedTable. + PaimonTableHandle handle = PaimonTableHandle.forSystemTable("db", "t", "ro", false); + + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops); + Map props = provider.getScanNodeProperties( + null, handle, Collections.emptyList(), Optional.empty()); + + String encoded = props.get("paimon.schema_evolution"); + Assertions.assertNotNull(encoded, + "a native $ro read must emit paimon.schema_evolution so BE sets history_schema_info " + + "and uses field-id matching; without it BE falls into the name-matching " + + "branch and dereferences a null tuple descriptor -> BE SIGSEGV"); + // Decodes to current_schema_id = -1 (latest sentinel) and a non-empty history dictionary, + // exactly what BE's field-id matcher consumes. + TFileScanRangeParams params = new TFileScanRangeParams(); + PaimonScanPlanProvider.applySchemaEvolutionParam(params, encoded); + Assertions.assertTrue(params.isSetHistorySchemaInfo() && !params.getHistorySchemaInfo().isEmpty(), + "the $ro schema dict must carry history_schema_info entries"); + Assertions.assertEquals(-1L, params.getCurrentSchemaId(), + "the current/target schema id must be the -1 sentinel (legacy parity)"); + } + } + + @Test + public void resolveTableUsesTransientWithoutReload() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + FakePaimonTable table = new FakePaimonTable( + "t1", + rowType("id", "name"), + Collections.emptyList(), + Collections.emptyList()); + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(table); + + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops); + Table resolved = provider.resolveTable(handle); + + // WHY: the fast path — when the transient Table is already present, resolveTable must use it + // and NOT make a redundant remote getTable call. MUTATION: always reloading would record a + // getTable entry -> red. This pins the reload as a fallback, not the default. + Assertions.assertSame(table, resolved); + Assertions.assertTrue(ops.log.isEmpty(), + "with a present transient table, no remote getTable reload must happen"); + } + + // --------------------------------------------------------------------- + // FIX-CPP-READER — split serialization format must match the BE reader + // --------------------------------------------------------------------- + + /** FE Java-serde leg, byte-identical to PaimonScanPlanProvider.encodeObjectToString (private). */ + private static String feJavaEncode(Object obj) throws Exception { + byte[] bytes = InstantiationUtil.serializeObject(obj); + return new String(Base64.getEncoder().encode(bytes), StandardCharsets.UTF_8); + } + + /** + * Builds a REAL paimon {@link DataSplit} offline: a local FileSystemCatalog over LocalFileIO + * under the @TempDir warehouse, a real keyed table, two committed rows, then plan().splits(). + * (Same local-catalog recipe proven by PaimonTableSerdeRoundTripTest; this adds the write step.) + */ + private static DataSplit buildRealDataSplit(Path warehouse) throws Exception { + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + catalog.createDatabase("db", false); + Identifier id = Identifier.create("db", "t"); + catalog.createTable(id, Schema.newBuilder() + .column("id", DataTypes.INT()) + .column("val", DataTypes.BIGINT()) + .primaryKey("id") + .option("bucket", "1") + .build(), false); + Table table = catalog.getTable(id); + + BatchWriteBuilder wb = table.newBatchWriteBuilder(); + try (BatchTableWrite write = wb.newWrite()) { + write.write(GenericRow.of(1, 100L)); + write.write(GenericRow.of(2, 200L)); + List messages = write.prepareCommit(); + try (BatchTableCommit commit = wb.newCommit()) { + commit.commit(messages); + } + } + + for (Split s : table.newReadBuilder().newScan().plan().splits()) { + if (s instanceof DataSplit) { + return (DataSplit) s; + } + } + throw new IllegalStateException("test fixture produced no DataSplit"); + } + } + + @Test + public void cppReaderFlagSelectsNativeBinaryForDataSplit(@TempDir Path warehouse) throws Exception { + DataSplit dataSplit = buildRealDataSplit(warehouse); + + String nativeWire = PaimonScanPlanProvider.encodeSplit(dataSplit, /*cppReader*/ true); + byte[] bytes = Base64.getDecoder().decode(nativeWire.getBytes(StandardCharsets.UTF_8)); + DataSplit roundTripped = DataSplit.deserialize( + new DataInputViewStreamWrapper(new ByteArrayInputStream(bytes))); + + // WHY: when enable_paimon_cpp_reader is on, BE's PaimonCppReader runs the NATIVE + // paimon::Split::Deserialize over the blob. So FE must emit DataSplit.serialize (native + // binary), NOT Java object serde — else BE dies with "paimon-cpp deserialize split failed". + // The native wire must (a) decode back to an equal DataSplit (the format BE consumes), and + // (b) DIFFER from the Java-serde wire (proves the format actually switched). + // MUTATION: dropping the cppReader branch -> both encodings equal / native deserialize fails -> red. + Assertions.assertEquals(dataSplit, roundTripped, + "native-format wire must round-trip via DataSplit.deserialize (what BE cpp reader decodes)"); + Assertions.assertNotEquals(feJavaEncode(dataSplit), nativeWire, + "flag-on must produce the native binary format, not Java object serialization"); + } + + @Test + public void cppReaderFlagOffKeepsJavaSerialization(@TempDir Path warehouse) throws Exception { + DataSplit dataSplit = buildRealDataSplit(warehouse); + + // WHY: default reads (flag off) must be byte-for-byte the existing Java object serialization + // for the Java JNI reader — no behavior change when the cpp reader is disabled. + // MUTATION: always-native -> the encoding differs from the Java leg -> red. + Assertions.assertEquals(feJavaEncode(dataSplit), + PaimonScanPlanProvider.encodeSplit(dataSplit, /*cppReader*/ false), + "flag-off must keep the Java object serialization byte-for-byte"); + } + + /** A non-DataSplit Split (the only abstract method is rowCount(); Split is Serializable). */ + private static final class NonDataSplitStub implements Split { + private static final long serialVersionUID = 1L; + + @Override + public long rowCount() { + return 0; + } + } + + @Test + public void nonDataSplitStaysJavaSerializedEvenWithCppFlag() throws Exception { + NonDataSplitStub stub = new NonDataSplitStub(); + + // WHY: the native binary format only exists for DataSplit. System splits (the nonDataSplits + // loop) and the no-raw-file JNI fallback have no native form, so they MUST stay Java-serialized + // even when the flag is on (legacy's `split instanceof DataSplit` gate). MUTATION: removing the + // instanceof guard -> ClassCastException / wrong format applied to a non-DataSplit -> red. + Assertions.assertEquals(feJavaEncode(stub), + PaimonScanPlanProvider.encodeSplit(stub, /*cppReader*/ true), + "a non-DataSplit must never take the native format, even with the cpp flag on"); + } + + @Test + public void countPushdownSplitDetectedOnlyWhenAggCountAndMergedCountAvailable( + @TempDir Path warehouse) throws Exception { + // FIX-COUNT-PUSHDOWN (M-2): a freshly written PK-table split has a precomputed merged + // (post-merge / post-deletion-vector) row count, so a COUNT(*) over it can be served from + // metadata instead of materializing rows. + DataSplit dataSplit = buildRealDataSplit(warehouse); + Assertions.assertTrue(dataSplit.mergedRowCountAvailable(), + "precondition: a freshly written PK split has a precomputed merged row count"); + Assertions.assertEquals(2L, dataSplit.mergedRowCount(), "two rows were written"); + + // WHY: the count branch must fire ONLY when BOTH the agg is COUNT (countPushdown) AND the SDK + // precomputed the post-merge count — mirrors legacy `applyCountPushdown && + // dataSplit.mergedRowCountAvailable()`. MUTATION: dropping `countPushdown &&` (or hard-coding + // the helper to false) -> one of these two assertions flips -> red. + Assertions.assertTrue(PaimonScanPlanProvider.isCountPushdownSplit(true, dataSplit), + "a count query over a split with a precomputed merged count must push the count down"); + Assertions.assertFalse(PaimonScanPlanProvider.isCountPushdownSplit(false, dataSplit), + "without count pushdown a split must take the normal scan path, never the count branch"); + } + + @Test + public void countPushdownCollapsesMultipleSplitsToOneRangeBearingSummedTotal( + @TempDir Path warehouse) throws Exception { + // A PARTITIONED PK table with TWO partitions of DIFFERENT row counts (pt=1 -> 2 rows, pt=2 -> + // 3 rows) yields TWO count-eligible DataSplits with ASYMMETRIC mergedRowCounts (2 and 3). This + // is deliberately multi-split with asymmetric counts so the test pins BOTH halves of the fix's + // collapse-to-one (design D-054): (a) collapse N->1 — exactly ONE range despite >=2 eligible + // splits, and (b) cross-split summation — the one range carries 2+3=5, a total NOT reachable + // from any single split (so first-split-only / last-split-wins / per-split-emit all go red). + // (A single-split fixture would make these assertions degenerate — sum==first==last for N=1.) + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + catalog.createDatabase("db", false); + Identifier id = Identifier.create("db", "t"); + catalog.createTable(id, Schema.newBuilder() + .column("pt", DataTypes.INT()) + .column("id", DataTypes.INT()) + .column("val", DataTypes.BIGINT()) + .partitionKeys("pt") + .primaryKey("pt", "id") + .option("bucket", "1") + .build(), false); + Table table = catalog.getTable(id); + BatchWriteBuilder wb = table.newBatchWriteBuilder(); + try (BatchTableWrite write = wb.newWrite()) { + write.write(GenericRow.of(1, 1, 100L)); // pt=1: 2 rows + write.write(GenericRow.of(1, 2, 200L)); + write.write(GenericRow.of(2, 1, 300L)); // pt=2: 3 rows + write.write(GenericRow.of(2, 2, 400L)); + write.write(GenericRow.of(2, 3, 500L)); + List messages = write.prepareCommit(); + try (BatchTableCommit commit = wb.newCommit()) { + commit.commit(messages); + } + } + + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = table; + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops); + PaimonTableHandle handle = new PaimonTableHandle( + "db", "t", Collections.emptyList(), Collections.emptyList()); + ConnectorSession session = sessionWithProps(Collections.emptyMap()); + List noColumns = Collections.emptyList(); + + // Precondition: the read plan really does produce >=2 count-eligible DataSplits (else the + // collapse assertion below would be degenerate). This guards the fixture itself. + int eligibleSplits = 0; + for (Split s : table.newReadBuilder().newScan().plan().splits()) { + if (s instanceof DataSplit + && PaimonScanPlanProvider.isCountPushdownSplit(true, (DataSplit) s)) { + ++eligibleSplits; + } + } + Assertions.assertTrue(eligibleSplits >= 2, + "fixture precondition: two partitions must yield >=2 count-eligible splits, got " + + eligibleSplits); + + // count pushdown ON: collapse-to-one — exactly ONE range carrying the SUMMED total (5). + // 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); + int countRanges = 0; + String emittedCount = null; + for (ConnectorScanRange r : withCount) { + String v = r.getProperties().get("paimon.row_count"); + if (v != null) { + ++countRanges; + emittedCount = v; + } + } + Assertions.assertEquals(1, countRanges, + "count pushdown must collapse >=2 eligible splits into exactly ONE count range"); + Assertions.assertEquals("5", emittedCount, + "the single count range must carry the cross-split SUM (2 + 3 = 5), " + + "a total unreachable from any single split"); + + // 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); + for (ConnectorScanRange r : withoutCount) { + Assertions.assertFalse(r.getProperties().containsKey("paimon.row_count"), + "without count pushdown no range may carry a pushed-down row count"); + } + } + } + + @Test + public void jniAndCountRangesCarryRealFileFormatNotJni(@TempDir Path warehouse) throws Exception { + // FIX-JNI-FILE-FORMAT (P7-1): a JNI-serialized split (the default reader path AND the COUNT(*) + // collapse range) must emit the REAL data-file format in fileDesc.file_format, NOT "jni" — BE's + // paimon_cpp_reader backfills paimon FILE_FORMAT/MANIFEST_FORMAT from it (an invalid "jni" breaks + // the manifest read). JNI routing is gated by the paimon.split property, NOT this string, so the + // real format is safe to emit (legacy PaimonScanNode.setPaimonParams does the same). The table is + // created with explicit file.format=orc so the asserted value is the table option (distinct from + // the "parquet" fallback) — proving the real option is read, not a constant. + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + catalog.createDatabase("db", false); + Identifier id = Identifier.create("db", "t"); + catalog.createTable(id, Schema.newBuilder() + .column("pt", DataTypes.INT()) + .column("id", DataTypes.INT()) + .column("val", DataTypes.BIGINT()) + .partitionKeys("pt") + .primaryKey("pt", "id") + .option("bucket", "1") + .option("file.format", "orc") + .build(), false); + Table table = catalog.getTable(id); + BatchWriteBuilder wb = table.newBatchWriteBuilder(); + try (BatchTableWrite write = wb.newWrite()) { + write.write(GenericRow.of(1, 1, 100L)); + write.write(GenericRow.of(1, 2, 200L)); + List messages = write.prepareCommit(); + try (BatchTableCommit commit = wb.newCommit()) { + commit.commit(messages); + } + } + + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = table; + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops); + PaimonTableHandle handle = new PaimonTableHandle( + "db", "t", Collections.emptyList(), Collections.emptyList()); + List noColumns = Collections.emptyList(); + + // (a) JNI data range: force_jni_scanner=true routes the native-eligible ORC split to JNI + // (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); + Assertions.assertFalse(jniRanges.isEmpty(), "force_jni scan must emit >=1 JNI range"); + for (ConnectorScanRange r : jniRanges) { + Assertions.assertTrue(r.getProperties().containsKey("paimon.split"), + "force_jni_scanner=true must route the split to the JNI path"); + // MUTATION: buildJniScanRange .fileFormat("jni") -> not "orc" -> red. + Assertions.assertEquals("orc", ((PaimonScanRange) r).getFileFormat(), + "a JNI range must carry the real data-file format, not \"jni\""); + } + + // (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); + PaimonScanRange countRange = null; + for (ConnectorScanRange r : countRanges) { + if (r.getProperties().containsKey("paimon.row_count")) { + countRange = (PaimonScanRange) r; + } + } + Assertions.assertNotNull(countRange, "count pushdown must emit a collapsed count range"); + // MUTATION: buildCountRange .fileFormat("jni"), or dropping the threaded defaultFileFormat -> red. + Assertions.assertEquals("orc", countRange.getFileFormat(), + "the COUNT(*) collapse range must carry the real data-file format, not \"jni\""); + } + } + + @Test + public void jniAndCountRangesUseFileSuffixNotAlteredTableDefault(@TempDir Path warehouse) throws Exception { + // FIX-L11: the JNI-serialized split (default reader path) and the COUNT(*) collapse range must derive + // file_format from the split's FIRST data-file SUFFIX (legacy PaimonScanNode.getFileFormat(getPathString) + // -> dataSplitFileFormat), NOT the table-level file.format option. These DIVERGE for an altered / + // mixed-format table: the option is changed to parquet while historical data files remain .orc. HEAD + // regressed to emitting the bare table default, so BE's paimon_cpp_reader would backfill the WRONG + // format for those files. WHY it matters: unlike jniAndCountRangesCarryRealFileFormatNotJni (where the + // table default == the .orc suffix, so it cannot distinguish default from suffix), this test forces a + // mismatch and thus is the one that actually goes RED if the emission points revert to defaultFileFormat. + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + catalog.createDatabase("db", false); + Identifier id = Identifier.create("db", "t"); + catalog.createTable(id, Schema.newBuilder() + .column("pt", DataTypes.INT()) + .column("id", DataTypes.INT()) + .column("val", DataTypes.BIGINT()) + .partitionKeys("pt") + .primaryKey("pt", "id") + .option("bucket", "1") + .option("file.format", "orc") // on-disk data files are written as .orc + .build(), false); + Table table = catalog.getTable(id); + BatchWriteBuilder wb = table.newBatchWriteBuilder(); + try (BatchTableWrite write = wb.newWrite()) { + write.write(GenericRow.of(1, 1, 100L)); + write.write(GenericRow.of(1, 2, 200L)); + List messages = write.prepareCommit(); + try (BatchTableCommit commit = wb.newCommit()) { + commit.commit(messages); + } + } + + // Overlay file.format=parquet as a dynamic option: the table now REPORTS parquet as its default + // (the connector reads table.options().file.format at plan time), while the committed data files + // stay .orc. copy() overlays read-time options only; it does NOT rewrite the on-disk files, and + // reads still decode each file by its own recorded format. + Table altered = table.copy(Collections.singletonMap("file.format", "parquet")); + Assertions.assertEquals("parquet", altered.options().get("file.format"), + "precondition: the altered table default must be parquet (distinct from the .orc files)"); + + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = altered; + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops); + PaimonTableHandle handle = new PaimonTableHandle( + "db", "t", Collections.emptyList(), Collections.emptyList()); + List noColumns = Collections.emptyList(); + + // (a) JNI data range: force_jni routes the .orc split to buildJniScanRange. Its file_format must be + // "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); + Assertions.assertFalse(jniRanges.isEmpty(), "force_jni scan must emit >=1 JNI range"); + for (ConnectorScanRange r : jniRanges) { + Assertions.assertTrue(r.getProperties().containsKey("paimon.split"), + "force_jni_scanner=true must route the split to the JNI path"); + // MUTATION: buildJniScanRange .fileFormat(defaultFileFormat) -> "parquet" -> red. + Assertions.assertEquals("orc", ((PaimonScanRange) r).getFileFormat(), + "a JNI range must carry the real data-file suffix (orc), not the altered table default"); + } + + // (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); + PaimonScanRange countRange = null; + for (ConnectorScanRange r : countRanges) { + if (r.getProperties().containsKey("paimon.row_count")) { + countRange = (PaimonScanRange) r; + } + } + Assertions.assertNotNull(countRange, "count pushdown must emit a collapsed count range"); + // MUTATION: buildCountRange .fileFormat(defaultFileFormat) -> "parquet" -> red. + Assertions.assertEquals("orc", countRange.getFileFormat(), + "the COUNT(*) collapse range must carry the real data-file suffix (orc), not the altered default"); + } + } + + // ---- FIX-L14: ignore_split_type escape hatch ---- + + @Test + public void ignoreJniDropsForcedJniSplit(@TempDir Path warehouse) throws Exception { + // FIX-L14: force_jni routes the DataSplit to the JNI arm; ignore_split_type=IGNORE_JNI must then drop + // it (legacy PaimonScanNode.getSplits:483). MUTATION: not honoring IGNORE_JNI -> the JNI range is + // still emitted -> red. + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + catalog.createDatabase("db", false); + Identifier id = Identifier.create("db", "t"); + catalog.createTable(id, Schema.newBuilder() + .column("id", DataTypes.INT()) + .column("val", DataTypes.BIGINT()) + .primaryKey("id") + .option("bucket", "1") + .build(), false); + Table table = catalog.getTable(id); + BatchWriteBuilder wb = table.newBatchWriteBuilder(); + try (BatchTableWrite write = wb.newWrite()) { + write.write(GenericRow.of(1, 100L)); + write.write(GenericRow.of(2, 200L)); + List messages = write.prepareCommit(); + try (BatchTableCommit commit = wb.newCommit()) { + commit.commit(messages); + } + } + + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = table; + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops); + PaimonTableHandle handle = new PaimonTableHandle( + "db", "t", Collections.emptyList(), Collections.emptyList()); + List noColumns = Collections.emptyList(); + + // 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); + 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); + Assertions.assertTrue(ignored.isEmpty(), + "ignore_split_type=IGNORE_JNI must drop the forced-JNI DataSplit"); + } + } + + @Test + public void ignoreNativeDropsNativeSplit(@TempDir Path warehouse) throws Exception { + // FIX-L14: an append-only table's DataSplit is native-eligible; ignore_split_type=IGNORE_NATIVE must + // drop it (legacy PaimonScanNode.getSplits:443). MUTATION: not honoring IGNORE_NATIVE -> the native + // range is still emitted -> red. + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + catalog.createDatabase("db", false); + Identifier id = Identifier.create("db", "t"); + catalog.createTable(id, Schema.newBuilder() + .column("id", DataTypes.INT()) + .column("val", DataTypes.BIGINT()) + .option("bucket", "-1") // append-only (no primary key) -> raw files -> native reader + .build(), false); + Table table = catalog.getTable(id); + BatchWriteBuilder wb = table.newBatchWriteBuilder(); + try (BatchTableWrite write = wb.newWrite()) { + write.write(GenericRow.of(1, 100L)); + write.write(GenericRow.of(2, 200L)); + List messages = write.prepareCommit(); + try (BatchTableCommit commit = wb.newCommit()) { + commit.commit(messages); + } + } + + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = table; + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops); + PaimonTableHandle handle = new PaimonTableHandle( + "db", "t", Collections.emptyList(), Collections.emptyList()); + 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); + Assertions.assertFalse(baseline.isEmpty(), "append-only scan must emit >=1 range (baseline)"); + boolean anyNative = baseline.stream() + .anyMatch(r -> !r.getProperties().containsKey("paimon.split")); + Assertions.assertTrue(anyNative, + "precondition: the append-only split must take the native path (no paimon.split)"); + + // 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); + boolean anyNativeAfter = ignored.stream() + .anyMatch(r -> !r.getProperties().containsKey("paimon.split")); + Assertions.assertFalse(anyNativeAfter, + "ignore_split_type=IGNORE_NATIVE must drop the native split"); + } + } + + @Test + public void ignorePaimonCppIsNoOpParity(@TempDir Path warehouse) throws Exception { + // FIX-L14: IGNORE_PAIMON_CPP is a documented ignore_split_type value that legacy + // PaimonScanNode.getSplits NEVER consulted, so it stays a no-op (legacy parity) — the scan emits the + // same ranges as NONE. Pins that a future change does not add a half-implemented CPP arm. + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + catalog.createDatabase("db", false); + Identifier id = Identifier.create("db", "t"); + catalog.createTable(id, Schema.newBuilder() + .column("id", DataTypes.INT()) + .column("val", DataTypes.BIGINT()) + .primaryKey("id") + .option("bucket", "1") + .build(), false); + Table table = catalog.getTable(id); + BatchWriteBuilder wb = table.newBatchWriteBuilder(); + try (BatchTableWrite write = wb.newWrite()) { + write.write(GenericRow.of(1, 100L)); + write.write(GenericRow.of(2, 200L)); + List messages = write.prepareCommit(); + try (BatchTableCommit commit = wb.newCommit()) { + commit.commit(messages); + } + } + + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = table; + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops); + PaimonTableHandle handle = new PaimonTableHandle( + "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 cppCount = provider.planScan( + sessionWithProps(Collections.singletonMap("ignore_split_type", "IGNORE_PAIMON_CPP")), + handle, noColumns, Optional.empty(), -1, null, false).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"); + } + } + + // ---- FIX-NATIVE-SUBSPLIT (M-3) ---- + + private static final long MB = 1024L * 1024L; + + /** Asserts the [start,length] ranges tile [0, fileLength) with no gap/overlap and positive lengths. */ + private static void assertContiguousTiling(List ranges, long fileLength) { + long expectedStart = 0; + for (long[] r : ranges) { + Assertions.assertEquals(expectedStart, r[0], + "ranges must tile contiguously with no gap/overlap"); + Assertions.assertTrue(r[1] > 0, "every range length must be positive"); + expectedStart += r[1]; + } + Assertions.assertEquals(fileLength, expectedStart, "ranges must cover exactly [0, fileLength)"); + } + + @Test + public void computeFileSplitOffsetsTilesWithOneTenthTailGuard() { + // 250MB / 64MB: the >1.1D guard keeps the 58MB remainder in the LAST range (no tiny 5th split) — + // byte-identical to legacy FileSplitter.splitFile. MUTATION: naive ceilDiv -> a 5th 58MB-or-tiny + // split / wrong last length -> red. + List s = PaimonScanPlanProvider.computeFileSplitOffsets(250 * MB, 64 * MB); + Assertions.assertEquals(4, s.size(), + "250MB/64MB -> 4 ranges (the 1.1x tail guard absorbs the 58MB remainder)"); + assertContiguousTiling(s, 250 * MB); + Assertions.assertEquals(64 * MB, s.get(0)[1]); + Assertions.assertEquals(58 * MB, s.get(3)[1], "last range absorbs the remainder (58MB < 1.1x target)"); + + // 256MB / 64MB: exact multiple -> 4 even ranges (the last is exactly 64MB, not 0). + List even = PaimonScanPlanProvider.computeFileSplitOffsets(256 * MB, 64 * MB); + Assertions.assertEquals(4, even.size()); + assertContiguousTiling(even, 256 * MB); + Assertions.assertEquals(64 * MB, even.get(3)[1]); + } + + @Test + public void computeFileSplitOffsetsKeepsSmallOrEmptyFilesCorrect() { + // fileLen <= 1.1*target -> ONE whole-file range (the 1.1x guard avoids a tiny tail). + List small = PaimonScanPlanProvider.computeFileSplitOffsets(70 * MB, 64 * MB); + Assertions.assertEquals(1, small.size(), "70MB <= 1.1*64MB -> one whole-file range"); + Assertions.assertArrayEquals(new long[] {0L, 70 * MB}, small.get(0)); + + // zero/negative length -> no range (legacy FileSplitter skips empty files). + Assertions.assertTrue(PaimonScanPlanProvider.computeFileSplitOffsets(0L, 64L).isEmpty()); + Assertions.assertTrue(PaimonScanPlanProvider.computeFileSplitOffsets(-5L, 64L).isEmpty()); + + // non-positive target -> single whole-file range (defensive; never happens on the connector path). + List defensive = PaimonScanPlanProvider.computeFileSplitOffsets(123L, 0L); + Assertions.assertEquals(1, defensive.size()); + Assertions.assertArrayEquals(new long[] {0L, 123L}, defensive.get(0)); + } + + @Test + public void determineTargetSplitSizeMirrorsLegacyHeuristic() { + long init = 32 * MB; // max_initial_file_split_size default + long max = 64 * MB; // max_file_split_size default + long initNum = 200; // max_initial_file_split_num default + long maxNum = 100000; // max_file_split_num default + + // file_split_size > 0 wins outright (legacy: the explicit override short-circuit). + Assertions.assertEquals(7L, + PaimonScanPlanProvider.determineTargetSplitSize(7L, init, max, initNum, maxNum, 999L * MB)); + // total below max*initNum (64MB*200 = 12800MB) -> initial split size (32MB). + Assertions.assertEquals(init, + PaimonScanPlanProvider.determineTargetSplitSize(0L, init, max, initNum, maxNum, 1024L * MB)); + // total at/above max*initNum -> max split size (64MB). + Assertions.assertEquals(max, + PaimonScanPlanProvider.determineTargetSplitSize(0L, init, max, initNum, maxNum, 20000L * MB)); + // max_file_split_num floor raises the size above the heuristic: ceil(total/maxNum) > 64MB. + long hugeTotal = 10_000_000L * MB; // ceil(/100000) = 100MB > 64MB + Assertions.assertEquals((hugeTotal + maxNum - 1L) / maxNum, + PaimonScanPlanProvider.determineTargetSplitSize(0L, init, max, initNum, maxNum, hugeTotal), + "max_file_split_num floor (ceil(total/maxNum)) must raise the target above 64MB"); + } + + @Test + public void nativeFileIsSubSplitWhenFileSplitSizeForcesIt(@TempDir Path warehouse) throws Exception { + // An append-only (no-PK) table yields a native-eligible raw file; a small file_split_size forces + // that single file to slice into >=2 contiguous sub-ranges end-to-end through planScan. + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + catalog.createDatabase("db", false); + Identifier id = Identifier.create("db", "t"); + catalog.createTable(id, Schema.newBuilder() + .column("id", DataTypes.INT()) + .column("val", DataTypes.BIGINT()) + .build(), false); // no primary key -> append-only -> convertToRawFiles present + Table table = catalog.getTable(id); + BatchWriteBuilder wb = table.newBatchWriteBuilder(); + try (BatchTableWrite write = wb.newWrite()) { + for (int i = 0; i < 200; i++) { + write.write(GenericRow.of(i, (long) i * 10)); + } + List messages = write.prepareCommit(); + try (BatchTableCommit commit = wb.newCommit()) { + commit.commit(messages); + } + } + + // Precondition: exactly ONE native raw file, so the contiguous-tiling check is over one file. + List rawFiles = new ArrayList<>(); + for (Split s : table.newReadBuilder().newScan().plan().splits()) { + if (s instanceof DataSplit) { + ((DataSplit) s).convertToRawFiles().ifPresent(rawFiles::addAll); + } + } + Assertions.assertEquals(1, rawFiles.size(), + "fixture precondition: append-only commit must yield exactly one native raw file"); + long fileLength = rawFiles.get(0).length(); + Assertions.assertTrue(fileLength > 0, "fixture raw file must be non-empty"); + long splitSize = Math.max(1L, fileLength / 3); // ~3 sub-ranges + + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = table; + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(Collections.emptyMap(), ops); + PaimonTableHandle handle = new PaimonTableHandle( + "db", "t", Collections.emptyList(), Collections.emptyList()); + List noColumns = Collections.emptyList(); + + // Small file_split_size -> the single native file MUST sub-split into >=2 contiguous ranges. + // WHY: this is the whole fix — one scanner per large file becomes N parallel sub-ranges. + // 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 nativeRanges = new ArrayList<>(); + for (ConnectorScanRange r : ranges) { + if (r.getPath().isPresent()) { // native ranges carry a file path; JNI ranges do not + nativeRanges.add(r); + } + } + Assertions.assertTrue(nativeRanges.size() >= 2, + "a small file_split_size must sub-split the native file into >=2 ranges, got " + + nativeRanges.size()); + nativeRanges.sort(Comparator.comparingLong(ConnectorScanRange::getStart)); + long expectedStart = 0; + for (ConnectorScanRange r : nativeRanges) { + Assertions.assertEquals(expectedStart, r.getStart(), + "native sub-ranges must tile [0, fileLength) contiguously"); + Assertions.assertTrue(r.getLength() > 0, "every sub-range length must be positive"); + Assertions.assertEquals(fileLength, r.getFileSize(), + "every sub-range must report the WHOLE file size, not the sub-range length"); + expectedStart += r.getLength(); + } + Assertions.assertEquals(fileLength, expectedStart, + "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); + 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"); + } + } + + @Test + public void buildNativeRangesAttachesSameDeletionVectorToEverySubRange() { + RecordingConnectorContext ctx = new RecordingConnectorContext(); + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + new HashMap<>(), new RecordingPaimonCatalogOps(), ctx); + RawFile file = parquetRawFile("oss://bkt/a/part-0.parquet"); + DeletionFile dv = new DeletionFile("oss://bkt/a/index/dv-0.index", 8L, 16L, 4L); + long target = Math.max(1L, file.length() / 3); // force the file to sub-split into >=2 ranges + + List ranges = provider.buildNativeRanges( + file, dv, "parquet", Collections.emptyMap(), Collections.emptyMap(), target, 64L * 1024 * 1024); + + // WHY: the load-bearing correctness claim of FIX-NATIVE-SUBSPLIT — a paimon deletion vector is a + // bitmap of GLOBAL file row positions, so EVERY sub-range of a DV-bearing file must carry the + // same (unmodified) deletion file. If sub-ranges 2..N dropped it, their deleted rows would + // reappear (merge-on-read corruption). MUTATION: attaching the DV only to the first (or last) + // sub-range, or dropping it on sub-ranges -> a sub-range with a null/!= deletion_file.path -> red. + Assertions.assertTrue(ranges.size() >= 2, + "fixture must sub-split into >=2 ranges, got " + ranges.size()); + String expectedDv = ranges.get(0).getProperties().get("paimon.deletion_file.path"); + Assertions.assertNotNull(expectedDv, + "the DV-bearing file's sub-ranges must carry a deletion file"); + for (PaimonScanRange r : ranges) { + Assertions.assertEquals(expectedDv, r.getProperties().get("paimon.deletion_file.path"), + "every native sub-range must carry the same deletion vector (global-row-position DV)"); + } + } + + @Test + public void buildNativeRangesKeepsFileWholeWhenTargetNonPositive() { + // Under COUNT(*) pushdown the native arm passes target size 0 so a native split that was NOT + // siphoned to the count arm (no precomputed merged count) is kept WHOLE — legacy parity + // (splittable=!applyCountPushdown). MUTATION: sub-splitting under count pushdown -> >1 range -> red. + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + new HashMap<>(), new RecordingPaimonCatalogOps()); + RawFile file = parquetRawFile("oss://bkt/a/part-0.parquet"); + + List ranges = provider.buildNativeRanges( + file, null, "parquet", Collections.emptyMap(), Collections.emptyMap(), 0L, 64L * 1024 * 1024); + + Assertions.assertEquals(1, ranges.size(), + "a non-positive target (COUNT(*) pushdown) must keep the file as one whole-file range"); + Assertions.assertEquals(0L, ranges.get(0).getStart()); + Assertions.assertEquals(file.length(), ranges.get(0).getLength(), + "the whole-file range must span the entire file"); + } + + private static ConnectorSession sessionWithProps(Map sessionProps) { + return new ConnectorSession() { + @Override + public String getQueryId() { + return "q"; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public String getCatalogName() { + return "c"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + + @Override + public Map getSessionProperties() { + return sessionProps; + } + }; + } + + @Test + public void isCppReaderEnabledReadsSessionProperty() { + // WHY: pins the EXACT session key ("enable_paimon_cpp_reader", byte-identical to + // SessionVariable.ENABLE_PAIMON_CPP_READER) and the default-false semantics. The format choice + // hinges on reading this flag correctly. MUTATION: wrong key, or defaulting true -> red. + Assertions.assertTrue(PaimonScanPlanProvider.isCppReaderEnabled( + sessionWithProps(Collections.singletonMap("enable_paimon_cpp_reader", "true")))); + Assertions.assertFalse(PaimonScanPlanProvider.isCppReaderEnabled( + sessionWithProps(Collections.singletonMap("enable_paimon_cpp_reader", "false")))); + Assertions.assertFalse(PaimonScanPlanProvider.isCppReaderEnabled( + sessionWithProps(Collections.emptyMap())), "absent flag must default to false"); + Assertions.assertFalse(PaimonScanPlanProvider.isCppReaderEnabled(null), + "a null session must default to false"); + } + + @Test + public void isForceJniScannerEnabledReadsSessionProperty() { + // FIX-FORCE-JNI-SCANNER (M-1): pins the EXACT session key ("force_jni_scanner", byte-identical to + // SessionVariable.FORCE_JNI_SCANNER) and the default-false semantics. Both native sites (the split + // router and the schema-evolution emit gate) hinge on reading this flag correctly. MUTATION: wrong + // key, or defaulting true -> red. + Assertions.assertTrue(PaimonScanPlanProvider.isForceJniScannerEnabled( + sessionWithProps(Collections.singletonMap("force_jni_scanner", "true")))); + Assertions.assertFalse(PaimonScanPlanProvider.isForceJniScannerEnabled( + sessionWithProps(Collections.singletonMap("force_jni_scanner", "false")))); + Assertions.assertFalse(PaimonScanPlanProvider.isForceJniScannerEnabled( + sessionWithProps(Collections.emptyMap())), "absent flag must default to false"); + Assertions.assertFalse(PaimonScanPlanProvider.isForceJniScannerEnabled(null), + "a null session must default to false"); + } + + // --------------------------------------------------------------------- + // FIX-REST-VENDED — per-table vended credentials overlaid as location.* + // --------------------------------------------------------------------- + + @Test + public void extractVendedTokenEmptyForNullAndNonRestFileIO() { + // WHY: vended credentials must be attempted ONLY for REST tables (RESTTokenFileIO). A null + // table, a table with no FileIO, and a table with a non-REST FileIO must ALL yield nothing — + // never leak/attempt a token. MUTATION: vending for any FileIO type -> red. (The positive + // RESTTokenFileIO branch needs a live REST stack -> covered by the fe-core bridge test + E2E.) + Assertions.assertTrue(PaimonScanPlanProvider.extractVendedToken(null).isEmpty(), + "a null table yields no vended token"); + + FakePaimonTable nullFileIo = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + Assertions.assertTrue(PaimonScanPlanProvider.extractVendedToken(nullFileIo).isEmpty(), + "a table with no FileIO yields no vended token"); + + FakePaimonTable nonRest = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + nonRest.fileIO = LocalFileIO.create(); // a real, non-REST FileIO + Assertions.assertTrue(PaimonScanPlanProvider.extractVendedToken(nonRest).isEmpty(), + "a non-RESTTokenFileIO table must yield no vended token"); + } + + /** A ConnectorContext whose getStorageProperties() (typed fe-filesystem seam, P1-T04) and + * vendStorageCredentials return fixed normalized maps. The engine's real StorageProperties + * binding/normalization is exercised by the fe-core DefaultConnectorContextStoragePropsTest / + * 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) { + return new ConnectorContext() { + @Override + public String getCatalogName() { + return "c"; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public List getStorageProperties() { + return backendStatic.isEmpty() + ? Collections.emptyList() + : Collections.singletonList(fakeBackendStorage(backendStatic)); + } + + @Override + public Map vendStorageCredentials(Map raw) { + return vended; + } + }; + } + + /** + * A fe-filesystem {@link StorageProperties} whose {@code toBackendProperties().toMap()} returns the + * given BE-canonical map — mirrors how a real object-store binding (e.g. S3FileSystemProperties IS-A + * {@link BackendStorageProperties}) hands BE creds to the connector. The connector consumes ONLY this + * typed seam for static creds (P1-T04), so the fake exercises exactly that path. (HDFS has no typed BE + * model in fe-filesystem yet, so a real HDFS catalog yields no entry here — see DV-004 / R-007.) + */ + private static StorageProperties fakeBackendStorage(Map beMap) { + BackendStorageProperties backend = new BackendStorageProperties() { + @Override + public BackendStorageKind backendKind() { + return BackendStorageKind.S3_COMPATIBLE; + } + + @Override + public Map toMap() { + return beMap; + } + }; + return new StorageProperties() { + @Override + public String providerName() { + return "fake"; + } + + @Override + public StorageKind kind() { + return StorageKind.OBJECT_STORAGE; + } + + @Override + public FileSystemType type() { + return FileSystemType.S3; + } + + @Override + public Map rawProperties() { + return Collections.emptyMap(); + } + + @Override + public Map matchedProperties() { + return Collections.emptyMap(); + } + + @Override + public Optional toBackendProperties() { + return Optional.of(backend); + } + }; + } + + @Test + public void getScanNodePropertiesNormalizesStaticCreds() { + FakePaimonTable table = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(table); + + // The connector holds the RAW catalog aliases; the engine seam returns the BE-canonical map. + Map props = new HashMap<>(); + props.put("s3.access_key", "raw-ak"); + props.put("s3.secret_key", "raw-sk"); + + Map backendStatic = new HashMap<>(); + backendStatic.put("AWS_ACCESS_KEY", "ak"); + backendStatic.put("AWS_SECRET_KEY", "sk"); + backendStatic.put("AWS_ENDPOINT", "ep"); + + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + props, new RecordingPaimonCatalogOps(), + scanContext(backendStatic, Collections.emptyMap())); + + Map scanProps = provider.getScanNodeProperties( + null, handle, Collections.emptyList(), Optional.empty()); + + // WHY (BLOCKER B-9): BE's native (FILE_S3) reader understands ONLY AWS_* keys. The connector + // must ship the engine-normalized canonical creds under location.*, NOT the raw catalog aliases + // (s3.access_key/…) which BE cannot read (403 on a private bucket). MUTATION: re-introducing the + // raw passthrough -> location.s3.access_key present / location.AWS_ACCESS_KEY absent -> red. + Assertions.assertEquals("ak", scanProps.get("location.AWS_ACCESS_KEY")); + Assertions.assertEquals("sk", scanProps.get("location.AWS_SECRET_KEY")); + Assertions.assertEquals("ep", scanProps.get("location.AWS_ENDPOINT")); + Assertions.assertFalse(scanProps.containsKey("location.s3.access_key"), + "the raw catalog alias must NOT reach BE (that is the B-9 bug)"); + Assertions.assertFalse(scanProps.containsKey("location.s3.secret_key"), + "the raw catalog alias must NOT reach BE (that is the B-9 bug)"); + } + + @Test + public void getScanNodePropertiesOverlaysVendedCreds() { + FakePaimonTable table = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(table); + + // Static (engine-normalized) creds; vended (REST per-table) creds collide on AWS_ACCESS_KEY / + // AWS_ENDPOINT and must WIN (legacy precedence: vended overlays static). + Map backendStatic = new HashMap<>(); + backendStatic.put("AWS_ACCESS_KEY", "static-ak"); + backendStatic.put("AWS_ENDPOINT", "static-ep"); + + Map vended = new HashMap<>(); + vended.put("AWS_ACCESS_KEY", "vended-ak"); + vended.put("AWS_SECRET_KEY", "vended-sk"); + vended.put("AWS_TOKEN", "vended-tok"); + vended.put("AWS_ENDPOINT", "vended-ep"); + + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + new HashMap<>(), new RecordingPaimonCatalogOps(), scanContext(backendStatic, vended)); + + Map scanProps = provider.getScanNodeProperties( + null, handle, Collections.emptyList(), Optional.empty()); + + // WHY (BLOCKER): native-reader REST tables must receive normalized vended AWS_* creds under + // location.*; without them BE hits the object store with no credentials (403). Vended overlays + // static (legacy precedence). MUTATION: no overlay loop / context not threaded -> AWS_* absent + // -> red; overlaying static AFTER vended -> the colliding location.AWS_ACCESS_KEY/ENDPOINT keep + // the static value -> red. + Assertions.assertEquals("vended-ak", scanProps.get("location.AWS_ACCESS_KEY")); + Assertions.assertEquals("vended-sk", scanProps.get("location.AWS_SECRET_KEY")); + Assertions.assertEquals("vended-tok", scanProps.get("location.AWS_TOKEN")); + Assertions.assertEquals("vended-ep", scanProps.get("location.AWS_ENDPOINT"), + "vended creds must overlay (win over) the static location key on collision"); + } + + @Test + public void getScanNodePropertiesNoContextNoStorageProps() { + FakePaimonTable table = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(table); + + Map props = new HashMap<>(); + props.put("s3.access_key", "raw-ak"); + // 2-arg ctor -> context == null (the offline harness path). + PaimonScanPlanProvider provider = new PaimonScanPlanProvider(props, new RecordingPaimonCatalogOps()); + + Map scanProps = provider.getScanNodeProperties( + null, handle, Collections.emptyList(), Optional.empty()); + + // WHY: the connector cannot normalize static creds without the engine seam, so with no context + // (offline only — production always wires one) it emits NO storage props — never the broken raw + // aliases that BE cannot read. MUTATION: NPE on null context, or re-adding the raw passthrough + // -> location.s3.access_key present -> red. + Assertions.assertFalse(scanProps.containsKey("location.s3.access_key"), + "no context -> the raw alias must not be shipped to BE"); + Assertions.assertFalse(scanProps.containsKey("location.AWS_ACCESS_KEY"), + "no context -> no normalized overlay"); + } + + @Test + public void getScanNodePropertiesSkipsStoragePropsWithoutBackendMappingAndMergesRest() { + FakePaimonTable table = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(table); + + Map beMap = new HashMap<>(); + beMap.put("AWS_ACCESS_KEY", "ak"); + beMap.put("AWS_ENDPOINT", "ep"); + // A typed list mixing a backend WITHOUT a BE model (toBackendProperties() empty — the real HDFS + // case, see DV-004/R-007) and a real object-store backend. Exercises the two facets the single-entry + // tests miss: the .ifPresent skip and the multi-entry putAll merge. + List storage = + Arrays.asList(fakeStorageWithoutBackend(), fakeBackendStorage(beMap)); + + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + new HashMap<>(), new RecordingPaimonCatalogOps(), scanContextWithStorage(storage)); + + Map scanProps = provider.getScanNodeProperties( + null, handle, Collections.emptyList(), Optional.empty()); + + // WHY: a StorageProperties with no BE model (Optional.empty()) must be SKIPPED, never crash, while + // a real object-store entry alongside it still ships its AWS_* under location.* (the merge loop). + // MUTATION: .ifPresent -> .get()/.orElseThrow() -> NoSuchElementException on the empty entry -> red; + // dropping the iteration / merge -> location.AWS_ACCESS_KEY absent -> red. + Assertions.assertEquals("ak", scanProps.get("location.AWS_ACCESS_KEY")); + Assertions.assertEquals("ep", scanProps.get("location.AWS_ENDPOINT")); + } + + /** A ConnectorContext whose getStorageProperties() returns the given typed list verbatim (no vended). */ + private static ConnectorContext scanContextWithStorage(List storage) { + return new ConnectorContext() { + @Override + public String getCatalogName() { + return "c"; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public List getStorageProperties() { + return storage; + } + }; + } + + /** A fe-filesystem {@link StorageProperties} with NO backend model — toBackendProperties() defaults to + * Optional.empty() (the real HDFS case: HdfsFileSystemProvider has no typed BE binding, DV-004/R-007). */ + private static StorageProperties fakeStorageWithoutBackend() { + return new StorageProperties() { + @Override + public String providerName() { + return "no-be"; + } + + @Override + public StorageKind kind() { + return StorageKind.HDFS_COMPATIBLE; + } + + @Override + public FileSystemType type() { + return FileSystemType.HDFS; + } + + @Override + public Map rawProperties() { + return Collections.emptyMap(); + } + + @Override + public Map matchedProperties() { + return Collections.emptyMap(); + } + }; + } + + // ---- FIX-JDBC-DRIVER-URL (B-8a): BE-bound driver_url resolution + paimon.jdbc.* alias ---- + + /** A ConnectorContext whose getEnvironment() returns a fixed map (for jdbc_drivers_dir resolution). */ + private static ConnectorContext envContext(Map env) { + return new ConnectorContext() { + @Override + public String getCatalogName() { + return "c"; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public Map getEnvironment() { + return env; + } + }; + } + + @Test + public void backendOptionsResolveBareDriverUrl() { + Map props = new HashMap<>(); + props.put("paimon.catalog.type", "jdbc"); + props.put("jdbc.driver_url", "mysql.jar"); + props.put("jdbc.driver_class", "com.mysql.cj.jdbc.Driver"); + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + props, new RecordingPaimonCatalogOps(), + envContext(Collections.singletonMap("jdbc_drivers_dir", "/opt/drivers"))); + + Map opts = provider.getBackendPaimonOptions(); + + // WHY (BLOCKER B-8a): BE does new URL(value) (JdbcDriverUtils.registerDriver); a bare + // "mysql.jar" throws MalformedURLException, so FE must ship a full scheme-bearing URL. + // MUTATION: forwarding the raw value -> "mysql.jar" (no scheme) -> red. + Assertions.assertEquals("file:///opt/drivers/mysql.jar", opts.get("jdbc.driver_url")); + Assertions.assertEquals("com.mysql.cj.jdbc.Driver", opts.get("jdbc.driver_class")); + } + + @Test + public void backendOptionsHonorPaimonJdbcAlias() { + Map props = new HashMap<>(); + props.put("paimon.catalog.type", "jdbc"); + props.put("paimon.jdbc.driver_url", "mysql.jar"); + props.put("paimon.jdbc.driver_class", "com.mysql.cj.jdbc.Driver"); + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + props, new RecordingPaimonCatalogOps(), + envContext(Collections.singletonMap("jdbc_drivers_dir", "/opt/drivers"))); + + Map opts = provider.getBackendPaimonOptions(); + + // WHY (BLOCKER B-8a): the startsWith("jdbc.") filter drops the paimon.jdbc.* alias form + // entirely, so BE never receives the driver. The fix reads either alias and emits the + // canonical jdbc.* key (BE PaimonJdbcDriverUtils accepts both). MUTATION: dropping the alias + // -> driver_url/class absent -> red. + Assertions.assertEquals("file:///opt/drivers/mysql.jar", opts.get("jdbc.driver_url")); + Assertions.assertEquals("com.mysql.cj.jdbc.Driver", opts.get("jdbc.driver_class")); + Assertions.assertFalse(opts.containsKey("paimon.jdbc.driver_url"), + "the raw paimon.jdbc.* alias key must not be shipped to BE"); + } + + @Test + public void backendOptionsResolveWhenBothAliasesSet() { + Map props = new HashMap<>(); + props.put("paimon.catalog.type", "jdbc"); + // Both alias forms present with DIFFERENT values. firstNonBlank(JDBC_DRIVER_URL) order is + // {paimon.jdbc.driver_url, jdbc.driver_url} -> the paimon.jdbc.* value wins (legacy priority). + props.put("paimon.jdbc.driver_url", "postgres.jar"); + props.put("jdbc.driver_url", "mysql.jar"); + props.put("paimon.jdbc.driver_class", "org.postgresql.Driver"); + props.put("jdbc.driver_class", "com.mysql.cj.jdbc.Driver"); + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + props, new RecordingPaimonCatalogOps(), + envContext(Collections.singletonMap("jdbc_drivers_dir", "/opt/drivers"))); + + Map opts = provider.getBackendPaimonOptions(); + + // WHY: the forwarding loop copies the raw jdbc.driver_url="mysql.jar"; the explicit + // alias-aware put must OVERRIDE it with the resolved paimon.jdbc.* value (priority parity), + // and the raw mysql.jar must NOT leak through. MUTATION: dropping the override (or flipping + // the alias priority) -> jdbc.driver_url ends as the raw/wrong "mysql.jar" -> red. + Assertions.assertEquals("file:///opt/drivers/postgres.jar", opts.get("jdbc.driver_url")); + Assertions.assertEquals("org.postgresql.Driver", opts.get("jdbc.driver_class")); + Assertions.assertFalse(opts.values().contains("mysql.jar"), + "the raw lower-priority alias value must not survive in the BE options"); + } + + @Test + public void backendOptionsPreserveSchemeBearingDriverUrl() { + Map props = new HashMap<>(); + props.put("paimon.catalog.type", "jdbc"); + props.put("jdbc.driver_url", "file:///custom/path/mysql.jar"); + props.put("jdbc.driver_class", "com.mysql.cj.jdbc.Driver"); + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + props, new RecordingPaimonCatalogOps(), envContext(Collections.emptyMap())); + + // A value already carrying a scheme is shipped unchanged (no double-prefixing). + Assertions.assertEquals("file:///custom/path/mysql.jar", + provider.getBackendPaimonOptions().get("jdbc.driver_url")); + } + + @Test + public void backendOptionsEmptyForNonJdbcFlavor() { + Map props = new HashMap<>(); + props.put("paimon.catalog.type", "filesystem"); + props.put("jdbc.driver_url", "mysql.jar"); + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + props, new RecordingPaimonCatalogOps(), envContext(Collections.emptyMap())); + + // Regression guard: the driver_url logic must not leak into non-JDBC flavors. + Assertions.assertTrue(provider.getBackendPaimonOptions().isEmpty()); + } + + @Test + public void backendOptionsForwardJniIoManagerRegardlessOfMetastore() { + Map props = new HashMap<>(); + // filesystem (non-jdbc) metastore: the common Paimon primary-key merge-read case #65332 + // targets. The three JNI IOManager options MUST still reach BE. + props.put("paimon.catalog.type", "filesystem"); + props.put("paimon.doris.enable_jni_io_manager", "true"); + props.put("paimon.doris.jni_io_manager.tmp_dir", "/tmp/doris-paimon"); + props.put("paimon.doris.jni_io_manager.impl_class", "org.example.CustomIOManager"); + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + props, new RecordingPaimonCatalogOps(), envContext(Collections.emptyMap())); + + Map opts = provider.getBackendPaimonOptions(); + + // WHY (#65332): BE's PaimonJniScanner spills through the Paimon IOManager only when FE ships + // doris.enable_jni_io_manager (BE re-adds the paimon. prefix). Before the fix a non-jdbc + // catalog returned emptyMap(), so the flag never reached BE and primary-key merge reads + // could OOM. The "paimon." connector prefix must be stripped exactly once. + // MUTATION: gating the JNI collection behind the jdbc check (or dropping the prefix strip) + // -> keys absent/misnamed -> red. + Assertions.assertEquals("true", opts.get("doris.enable_jni_io_manager")); + Assertions.assertEquals("/tmp/doris-paimon", opts.get("doris.jni_io_manager.tmp_dir")); + Assertions.assertEquals("org.example.CustomIOManager", opts.get("doris.jni_io_manager.impl_class")); + Assertions.assertEquals(3, opts.size()); + } + + @Test + public void backendOptionsForwardFileReaderAsyncOptOut() { + Map props = new HashMap<>(); + props.put("paimon.catalog.type", "filesystem"); + props.put("paimon.jni.enable_file_reader_async", "false"); + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + props, new RecordingPaimonCatalogOps(), envContext(Collections.emptyMap())); + + Map opts = provider.getBackendPaimonOptions(); + + // WHY (#65365): BE's PaimonJniScanner disables paimon's async file reader only when FE ships + // jni.enable_file_reader_async=false (BE re-adds the paimon. prefix). Upstream added the key to + // the legacy PaimonScanNode forwarding list, which this branch deleted with the fe-core paimon + // subsystem — the connector list is now the only path to BE. + // MUTATION: dropping the key from BACKEND_PAIMON_JNI_OPTIONS -> flag never reaches BE -> red. + Assertions.assertEquals("false", opts.get("jni.enable_file_reader_async")); + Assertions.assertEquals(1, opts.size()); + } + + // ---- FIX-SCHEMA-EVOLUTION (B-1a): native-reader schema dictionary ---- + + @Test + public void buildSchemaInfoCarriesFieldIdsNamesAndScalarTag() { + // WHY (B-1a): BE matches file<->table columns BY paimon field id; the id+name on each top-level + // field are the join keys. Scalars carry a single placeholder tag because BE reads type.type only + // as a nested-vs-scalar discriminator. MUTATION: dropping setId/setName -> ids/names absent -> BE + // can't field-id-match -> falls back to by-name (the silent wrong-rows bug). + List fields = Arrays.asList( + new DataField(7, "id", DataTypes.INT()), + new DataField(9, "name", DataTypes.STRING())); + + TSchema schema = PaimonScanPlanProvider.buildSchemaInfo(3L, fields, false); + + Assertions.assertEquals(3L, schema.getSchemaId()); + List top = schema.getRootField().getFields(); + Assertions.assertEquals(2, top.size()); + Assertions.assertEquals(7, top.get(0).getFieldPtr().getId()); + Assertions.assertEquals("id", top.get(0).getFieldPtr().getName()); + Assertions.assertEquals(TPrimitiveType.STRING, top.get(0).getFieldPtr().getType().getType()); + Assertions.assertEquals(9, top.get(1).getFieldPtr().getId()); + Assertions.assertEquals("name", top.get(1).getFieldPtr().getName()); + } + + @Test + public void buildSchemaInfoNestedShapesAndStructChildIds() { + // WHY (B-1a): the e2e case is a struct-field rename, so STRUCT children MUST carry their own + // paimon ids/names; ARRAY/MAP/STRUCT tags must be exact (BE checks them); array element / map kv + // are matched structurally (no id). MUTATION: wrong nesting tag or missing struct-child id -> BE + // SCHEMA_ERROR or by-name fallback inside nested types. + DataType struct = DataTypes.ROW( + DataTypes.FIELD(10, "f1", DataTypes.INT()), + DataTypes.FIELD(11, "f2", DataTypes.STRING())); + List fields = Arrays.asList( + new DataField(1, "arr", DataTypes.ARRAY(DataTypes.INT())), + new DataField(2, "m", DataTypes.MAP(DataTypes.STRING(), DataTypes.INT())), + new DataField(3, "s", struct)); + + List top = PaimonScanPlanProvider.buildSchemaInfo(0L, fields, false) + .getRootField().getFields(); + + TField arr = top.get(0).getFieldPtr(); + Assertions.assertEquals(TPrimitiveType.ARRAY, arr.getType().getType()); + Assertions.assertEquals(1, arr.getId()); + TField elem = arr.getNestedField().getArrayField().getItemField().getFieldPtr(); + Assertions.assertEquals(TPrimitiveType.STRING, elem.getType().getType()); + Assertions.assertFalse(elem.isSetId(), "array element is matched structurally, not by id"); + + TField map = top.get(1).getFieldPtr(); + Assertions.assertEquals(TPrimitiveType.MAP, map.getType().getType()); + Assertions.assertNotNull(map.getNestedField().getMapField().getKeyField().getFieldPtr()); + Assertions.assertNotNull(map.getNestedField().getMapField().getValueField().getFieldPtr()); + + TField st = top.get(2).getFieldPtr(); + Assertions.assertEquals(TPrimitiveType.STRUCT, st.getType().getType()); + List sub = st.getNestedField().getStructField().getFields(); + Assertions.assertEquals(10, sub.get(0).getFieldPtr().getId()); + Assertions.assertEquals("f1", sub.get(0).getFieldPtr().getName()); + Assertions.assertEquals(11, sub.get(1).getFieldPtr().getId()); + Assertions.assertEquals("f2", sub.get(1).getFieldPtr().getName()); + } + + @Test + public void schemaEvolutionRoundTripAppliesCurrentAndHistory() { + // WHY (B-1a): end-to-end transport — getScanNodeProperties serializes the dictionary, the bridge + // hands it to populateScanLevelParams which sets current_schema_id + history_schema_info on the + // real params. The rename a->new_a keeps field id 0 stable across schema versions, so BE reads the + // renamed column instead of NULL. MUTATION: applySchemaEvolutionParam not copying the fields -> + // params unset -> BE !__isset.history_schema_info -> by-name fallback -> silent wrong rows. + TSchema current = PaimonScanPlanProvider.buildSchemaInfo( + -1L, Arrays.asList(new DataField(0, "new_a", DataTypes.INT())), true); + TSchema schema0 = PaimonScanPlanProvider.buildSchemaInfo( + 0L, Arrays.asList(new DataField(0, "a", DataTypes.INT())), false); + TSchema schema1 = PaimonScanPlanProvider.buildSchemaInfo( + 1L, Arrays.asList(new DataField(0, "new_a", DataTypes.INT())), false); + List history = new ArrayList<>(Arrays.asList(current, schema0, schema1)); + + String encoded = PaimonScanPlanProvider.encodeSchemaEvolution(-1L, history); + TFileScanRangeParams params = new TFileScanRangeParams(); + PaimonScanPlanProvider.applySchemaEvolutionParam(params, encoded); + + Assertions.assertTrue(params.isSetCurrentSchemaId()); + Assertions.assertEquals(-1L, params.getCurrentSchemaId()); + Assertions.assertEquals(3, params.getHistorySchemaInfo().size()); + // id 0 is stable across the rename -> by-id match (rename-safe), not by-name (NULL). + Assertions.assertEquals(0, params.getHistorySchemaInfo().get(1) + .getRootField().getFields().get(0).getFieldPtr().getId()); + Assertions.assertEquals("a", params.getHistorySchemaInfo().get(1) + .getRootField().getFields().get(0).getFieldPtr().getName()); + Assertions.assertEquals(0, params.getHistorySchemaInfo().get(2) + .getRootField().getFields().get(0).getFieldPtr().getId()); + Assertions.assertEquals("new_a", params.getHistorySchemaInfo().get(2) + .getRootField().getFields().get(0).getFieldPtr().getName()); + } + + @Test + public void buildSchemaInfoLowercasesTopLevelButPreservesNestedNames() { + // WHY (BLOCKER): the -1/current entry is the BE table-side StructNode key; BE keys it VERBATIM and + // the native reader looks up the LOWERCASE Doris slot name, so a mixed-case column ("MyCol") must + // be lowercased ("mycol") or BE throws std::out_of_range — regressing even never-evolved reads. + // But nested struct field names must stay paimon-cased (legacy is asymmetric: parseSchema lowers + // top-level, paimonTypeToDorisType keeps nested). MUTATION: no toLowerCase -> "MyCol" key -> crash; + // lowercasing nested too -> "innerfield" diverges from legacy. + DataType struct = DataTypes.ROW(DataTypes.FIELD(5, "InnerField", DataTypes.INT())); + List fields = Arrays.asList( + new DataField(0, "MyCol", DataTypes.INT()), + new DataField(1, "S", struct)); + + List top = PaimonScanPlanProvider.buildSchemaInfo(-1L, fields, true) + .getRootField().getFields(); + + Assertions.assertEquals("mycol", top.get(0).getFieldPtr().getName(), "top-level name lowercased"); + Assertions.assertEquals("s", top.get(1).getFieldPtr().getName(), "top-level name lowercased"); + // nested struct child keeps its paimon casing (legacy parity; matched downstream via to_lower). + Assertions.assertEquals("InnerField", top.get(1).getFieldPtr() + .getNestedField().getStructField().getFields().get(0).getFieldPtr().getName(), + "nested struct field name must stay paimon-cased"); + + // historical entries are fully paimon-cased (the file-side value, BE looks up by id then to_lowers). + List hist = PaimonScanPlanProvider.buildSchemaInfo(0L, fields, false) + .getRootField().getFields(); + Assertions.assertEquals("MyCol", hist.get(0).getFieldPtr().getName(), + "historical entry keeps paimon casing"); + } + + @Test + public void selectCurrentSchemaFieldsCarriesAddColumnAfterSnapshot() { + // WHY (CI 969249 crash): the -1/current entry MUST contain every requested scan slot, or BE's + // children_column_exists (table_schema_change_helper.h:166) DCHECK-aborts the whole BE. A paimon + // ALTER TABLE ADD COLUMN bumps the table schema WITHOUT a new snapshot, so the resolved + // (snapshot-pinned) table.schema() can lag the latest schema the FE slots come from. Building the + // -1 entry from the resolved schema alone (the old code) dropped the added column -> crash. Keying + // off the requested columns + a fresh-latest fallback carries it with its REAL field id (so newer + // files that DO have it still read data; older files fill NULL). MUTATION: drop the latest fallback + // -> "name" missing from the result -> the production DCHECK abort. + List resolved = Arrays.asList(new DataField(0, "id", DataTypes.INT())); + List latest = Arrays.asList( + new DataField(0, "id", DataTypes.INT()), + new DataField(1, "name", DataTypes.STRING())); + + List current = PaimonScanPlanProvider.selectCurrentSchemaFields( + resolved, latest, Arrays.asList("id", "name")); + + Assertions.assertEquals(2, current.size()); + Assertions.assertEquals("id", current.get(0).name()); + Assertions.assertEquals(0, current.get(0).id()); + Assertions.assertEquals("name", current.get(1).name(), "added column must be present (no crash)"); + Assertions.assertEquals(1, current.get(1).id(), "added column carries its real latest field id"); + } + + @Test + public void selectCurrentSchemaFieldsResolvedWinsOnNameCollisionForTimeTravelRename() { + // WHY: a time-travel read pins an OLD snapshot whose schema has the pre-rename name; the FE slots + // use that pinned name. The -1 entry must key by the pinned name + pinned field id so BE matches + // the file's field id. The resolved (pinned) schema must therefore WIN over the latest (renamed) + // schema on a name collision, and the pinned old name must resolve in the pinned schema BEFORE the + // latest fallback is consulted. MUTATION: prefer latest -> "full_name" keyed -> the pinned-name + // slot "fullname" misses children -> crash / NULL. + List pinned = Arrays.asList(new DataField(5, "fullname", DataTypes.STRING())); + List latest = Arrays.asList(new DataField(5, "full_name", DataTypes.STRING())); + + List current = PaimonScanPlanProvider.selectCurrentSchemaFields( + pinned, latest, Arrays.asList("fullname")); + + Assertions.assertEquals(1, current.size()); + Assertions.assertEquals("fullname", current.get(0).name(), "pinned name wins for time travel"); + Assertions.assertEquals(5, current.get(0).id()); + } + + @Test + public void selectCurrentSchemaFieldsFailsLoudOnUnknownRequestedColumn() { + // WHY (Rule 12 / fail loud): a requested slot absent from BOTH the resolved and latest schema is a + // genuine FE/connector inconsistency; surface it as a clean per-query failure rather than silently + // dropping it (which would re-create the BE children mismatch). MUTATION: silent skip -> crash. + List resolved = Arrays.asList(new DataField(0, "id", DataTypes.INT())); + Assertions.assertThrows(RuntimeException.class, () -> + PaimonScanPlanProvider.selectCurrentSchemaFields( + resolved, Collections.emptyList(), Arrays.asList("id", "ghost"))); + } + + @Test + public void selectCurrentSchemaFieldsEmptyColumnsReturnsResolved() { + // WHY: a count-only scan projects no slots, so there is nothing to mismatch; fall back to the + // resolved schema's fields verbatim (no behavior change for that path). + List resolved = Arrays.asList( + new DataField(0, "id", DataTypes.INT()), + new DataField(1, "name", DataTypes.STRING())); + + Assertions.assertSame(resolved, PaimonScanPlanProvider.selectCurrentSchemaFields( + resolved, Collections.emptyList(), Collections.emptyList())); + } + + @Test + public void getScanNodePropertiesSkipsSchemaEvolutionForNonFileStoreTable() { + // WHY: only paimon FileStoreTables take the native path; sys-tables / fakes read via JNI and never + // consult history_schema_info. The FileStoreTable guard must skip them (and not NPE / CCE). + // MUTATION: dropping the guard -> ClassCastException / a wrong dictionary emitted for a JNI scan. + 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()); + + Map scanProps = provider.getScanNodeProperties( + null, handle, Collections.emptyList(), Optional.empty()); + + Assertions.assertFalse(scanProps.containsKey("paimon.schema_evolution"), + "non-DataTable (JNI path) must not emit the native schema dictionary"); + } + + // ==================== FIX-A1: split weight (FE BE-assignment proportional weight) ==================== + + @Test + public void scanRangeBuilderDefaultsTargetSplitSizeToSentinel() { + // A range built WITHOUT a denominator reports the -1 SPI sentinel (so PluginDrivenSplit keeps + // standard()); selfSplitWeight defaults to a real 0 (a valid empty-file/sys weight). A range WITH + // both round-trips them. MUTATION: defaulting targetSplitSize to 0 -> a 0 denominator -> red. + PaimonScanRange noWeight = new PaimonScanRange.Builder().build(); + Assertions.assertEquals(-1L, noWeight.getTargetSplitSize(), + "targetSplitSize default must be the -1 sentinel, not 0 (0 is an invalid denominator)"); + + PaimonScanRange weighted = new PaimonScanRange.Builder() + .selfSplitWeight(7L).targetSplitSize(99L).build(); + Assertions.assertEquals(7L, weighted.getSelfSplitWeight()); + Assertions.assertEquals(99L, weighted.getTargetSplitSize()); + } + + @Test + public void buildNativeRangeSetsProportionalWeightFromLengthAndDv() { + // Legacy PaimonSplit(LocationPath,...).selfSplitWeight = sub-range length, += deletionFile.length() + // when a DV is attached (PaimonSplit:72,112). The native FE weight reproduces that and carries the + // scan-level denominator. MUTATION: dropping the native .selfSplitWeight(...) -> weight 0 -> red. + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + new HashMap<>(), new RecordingPaimonCatalogOps()); + RawFile file = parquetRawFile("/data/part-0.parquet"); + DeletionFile dv = new DeletionFile("/data/dv-0.index", 8L, 16L, 4L); + + PaimonScanRange withDv = provider.buildNativeRange( + file, dv, "parquet", Collections.emptyMap(), Collections.emptyMap(), 0L, 64L, 64 * MB); + Assertions.assertEquals(64L + dv.length(), withDv.getSelfSplitWeight(), + "native weight = sub-range length + the deletion-vector length"); + Assertions.assertEquals(64 * MB, withDv.getTargetSplitSize(), + "native range must carry the weight denominator"); + + PaimonScanRange noDv = provider.buildNativeRange( + file, null, "parquet", Collections.emptyMap(), Collections.emptyMap(), 0L, 70L, 64 * MB); + Assertions.assertEquals(70L, noDv.getSelfSplitWeight(), + "a DV-less native range weight is just the sub-range length"); + } + + @Test + public void buildNativeRangesThreadsDenominatorDistinctFromFileSplitTarget() { + // Positional-swap guard: the file-split target and the weight denominator are two adjacent long + // params. Splitting must follow the FILE-SPLIT target while every sub-range carries the DENOMINATOR. + // MUTATION: swapping the two args -> wrong split count AND wrong targetSplitSize -> red. + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + new HashMap<>(), new RecordingPaimonCatalogOps()); + RawFile file = parquetRawFile("/data/part-0.parquet"); // length 100 + long fileSplitTarget = Math.max(1L, file.length() / 3); // 33 -> >=2 sub-ranges + long denominator = 64 * MB; // numerically distinct from 33 + + List ranges = provider.buildNativeRanges( + file, null, "parquet", Collections.emptyMap(), Collections.emptyMap(), + fileSplitTarget, denominator); + + Assertions.assertEquals( + PaimonScanPlanProvider.computeFileSplitOffsets(file.length(), fileSplitTarget).size(), + ranges.size(), + "sub-splitting must follow the file-split target, not the denominator"); + Assertions.assertTrue(ranges.size() >= 2, "fixture must sub-split into >=2 ranges"); + for (PaimonScanRange r : ranges) { + Assertions.assertEquals(denominator, r.getTargetSplitSize(), + "every native sub-range must carry the weight denominator"); + } + } + + @Test + public void buildNativeRangesCarriesDenominatorEvenWhenFileSplitSizeZero() { + // Under COUNT(*) pushdown the file-split size is 0 (whole-file range), but the denominator is + // computed independently, so the single range still gets a positive denominator (non-standard + // weight) and the whole-file length as its weight. + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + new HashMap<>(), new RecordingPaimonCatalogOps()); + RawFile file = parquetRawFile("/data/part-0.parquet"); + + List ranges = provider.buildNativeRanges( + file, null, "parquet", Collections.emptyMap(), Collections.emptyMap(), 0L, 64 * MB); + + Assertions.assertEquals(1, ranges.size(), "a non-positive target keeps the file whole"); + Assertions.assertEquals(64 * MB, ranges.get(0).getTargetSplitSize(), + "a whole-file (count-pushdown) range still carries the weight denominator"); + Assertions.assertEquals(file.length(), ranges.get(0).getSelfSplitWeight(), + "the whole-file range weight is the full file length"); + } + + @Test + public void resolveSplitWeightDenominatorMatchesLegacyFormula() { + // Legacy getFileSplitSize()>0 ? getFileSplitSize() : getMaxSplitSize() (PaimonScanNode:499); + // getMaxSplitSize() = max_file_split_size, default 64MB. + Map withSplitSize = new HashMap<>(); + withSplitSize.put("file_split_size", "1234"); + Assertions.assertEquals(1234L, + PaimonScanPlanProvider.resolveSplitWeightDenominator(sessionWithProps(withSplitSize)), + "file_split_size>0 is used as the denominator"); + + Assertions.assertEquals(64 * MB, + PaimonScanPlanProvider.resolveSplitWeightDenominator(sessionWithProps(Collections.emptyMap())), + "unset file_split_size falls back to max_file_split_size (64MB default)"); + + Map withMax = new HashMap<>(); + withMax.put("max_file_split_size", "777"); + Assertions.assertEquals(777L, + PaimonScanPlanProvider.resolveSplitWeightDenominator(sessionWithProps(withMax)), + "unset file_split_size uses the configured max_file_split_size"); + } + + // ============= FIX-B-R2-be: memoize the schema-evolution dict's per-schema-id reads ============= + + /** A real single-schema (schema_id=0) keyed FileStoreTable under the @TempDir warehouse. */ + private static FileStoreTable createSingleSchemaTable(Catalog catalog) throws Exception { + catalog.createDatabase("db", false); + Identifier id = Identifier.create("db", "t"); + catalog.createTable(id, Schema.newBuilder() + .column("id", DataTypes.INT()) + .column("name", DataTypes.STRING()) + .primaryKey("id") + .option("bucket", "1") + .build(), false); + return (FileStoreTable) catalog.getTable(id); + } + + private static PaimonTableHandle plainHandle() { + return new PaimonTableHandle("db", "t", Collections.emptyList(), Collections.emptyList()); + } + + @Test + public void schemaEvolutionDictPopulatesSharedMemo(@TempDir Path warehouse) throws Exception { + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + FileStoreTable base = createSingleSchemaTable(catalog); + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.table = base; + PaimonTableHandle handle = plainHandle(); + PaimonSchemaAtMemo memo = new PaimonSchemaAtMemo(PaimonSchemaAtMemo.DEFAULT_MAX_SIZE); + PaimonScanPlanProvider provider = + new PaimonScanPlanProvider(Collections.emptyMap(), ops, null, memo); + + provider.getScanNodeProperties(null, handle, Collections.emptyList(), Optional.empty()); + + // WHY: the K committed-schema reads of the dict build (listAllIds loop) must go through the + // shared memo so repeated scans don't re-read the schema files (FIX-B-R2-be); the -1 current + // entry does NOT (it reads the live table). K=1 for a fresh single-schema table. MUTATION: + // reading schemaManager.schema(id) directly -> memo never populated -> size 0 -> red. + int k = base.schemaManager().listAllIds().size(); + Assertions.assertEquals(1, k, "a fresh table has exactly one committed schema (id 0)"); + Assertions.assertEquals(k, memo.size(), + "every committed-schema read in the dict build must populate the shared memo"); + } + } + + @Test + public void schemaEvolutionDictReadsFromMemoOnHit(@TempDir Path warehouse) throws Exception { + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + FileStoreTable base = createSingleSchemaTable(catalog); + PaimonTableHandle handle = plainHandle(); + + // The real (unseeded) dict. + RecordingPaimonCatalogOps opsReal = new RecordingPaimonCatalogOps(); + opsReal.table = base; + String encodedReal = new PaimonScanPlanProvider(Collections.emptyMap(), opsReal, null, + new PaimonSchemaAtMemo(PaimonSchemaAtMemo.DEFAULT_MAX_SIZE)) + .getScanNodeProperties(null, handle, Collections.emptyList(), Optional.empty()) + .get("paimon.schema_evolution"); + + // Pre-seed a SHARED memo for (handle, schema 0) with a SENTINEL whose fields differ from the + // real schema, so a cache HIT is positively observable in the emitted dict. + PaimonSchemaAtMemo seeded = new PaimonSchemaAtMemo(PaimonSchemaAtMemo.DEFAULT_MAX_SIZE); + seeded.getOrLoad(handle, 0L, () -> new PaimonCatalogOps.PaimonSchemaSnapshot( + Collections.singletonList(new DataField(0, "sentinel_from_memo", DataTypes.INT())), + Collections.emptyList(), Collections.emptyList())); + RecordingPaimonCatalogOps opsSeeded = new RecordingPaimonCatalogOps(); + opsSeeded.table = base; + String encodedSeeded = new PaimonScanPlanProvider(Collections.emptyMap(), opsSeeded, null, seeded) + .getScanNodeProperties(null, handle, Collections.emptyList(), Optional.empty()) + .get("paimon.schema_evolution"); + + // WHY: the dict build must RETURN the cached value for schema 0 (skip the real + // schemaManager.schema(0) read), so the seeded sentinel field surfaces in the dict and the + // encoded string differs from the unseeded real dict. MUTATION: reading directly (bypassing the + // memo) -> the seed is ignored -> encodedSeeded == encodedReal -> red. + Assertions.assertNotNull(encodedReal); + Assertions.assertNotEquals(encodedReal, encodedSeeded, + "a pre-seeded memo entry must surface in the dict, proving the build read from the memo"); + } + } + + @Test + public void schemaEvolutionDictByteIdenticalWithMemo(@TempDir Path warehouse) throws Exception { + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + FileStoreTable base = createSingleSchemaTable(catalog); + PaimonTableHandle handle = plainHandle(); + + RecordingPaimonCatalogOps opsA = new RecordingPaimonCatalogOps(); + opsA.table = base; + // 2-arg ctor: fresh per-instance memo => first build is a direct read => pre-fix behavior. + String encodedA = new PaimonScanPlanProvider(Collections.emptyMap(), opsA) + .getScanNodeProperties(null, handle, Collections.emptyList(), Optional.empty()) + .get("paimon.schema_evolution"); + + RecordingPaimonCatalogOps opsB = new RecordingPaimonCatalogOps(); + opsB.table = base; + // 4-arg ctor with a shared memo (first build is also a direct read, then cached). + String encodedB = new PaimonScanPlanProvider(Collections.emptyMap(), opsB, null, + new PaimonSchemaAtMemo(PaimonSchemaAtMemo.DEFAULT_MAX_SIZE)) + .getScanNodeProperties(null, handle, Collections.emptyList(), Optional.empty()) + .get("paimon.schema_evolution"); + + // WHY: the memo changes only HOW fields are read, never WHAT is emitted -> the dict must be + // byte-identical to the non-memo path (no order/dedup/membership change -> zero BE-crash + // surface). MUTATION: the memo altering the emitted entries -> encodedA != encodedB -> red. + Assertions.assertNotNull(encodedA); + Assertions.assertEquals(encodedA, encodedB, + "the memoized dict must be byte-identical to the non-memo (pre-fix) emission"); + } + } + + @Test + public void schemaEvolutionDictSkippedUnderForceJniLeavesMemoEmpty(@TempDir Path warehouse) throws Exception { + try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), + new org.apache.paimon.fs.Path(warehouse.toUri()))) { + FileStoreTable base = createSingleSchemaTable(catalog); + + // (a) a force-jni handle (binlog): the whole dict is gated off, so the memo must stay empty. + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + ops.sysTable = base; + ops.table = base; + PaimonTableHandle binlog = PaimonTableHandle.forSystemTable("db", "t", "binlog", true); + PaimonSchemaAtMemo memo = new PaimonSchemaAtMemo(PaimonSchemaAtMemo.DEFAULT_MAX_SIZE); + Map props = new PaimonScanPlanProvider(Collections.emptyMap(), ops, null, memo) + .getScanNodeProperties(null, binlog, Collections.emptyList(), Optional.empty()); + Assertions.assertFalse(props.containsKey("paimon.schema_evolution"), + "a force-jni handle skips the schema dict"); + Assertions.assertEquals(0, memo.size(), + "force-jni must not consult/populate the schema memo (guards a read moved above the gate)"); + + // (b) force_jni_scanner=true session on a plain handle: same gate. + RecordingPaimonCatalogOps ops2 = new RecordingPaimonCatalogOps(); + ops2.table = base; + PaimonSchemaAtMemo memo2 = new PaimonSchemaAtMemo(PaimonSchemaAtMemo.DEFAULT_MAX_SIZE); + Map props2 = new PaimonScanPlanProvider(Collections.emptyMap(), ops2, null, memo2) + .getScanNodeProperties( + sessionWithProps(Collections.singletonMap("force_jni_scanner", "true")), + plainHandle(), Collections.emptyList(), Optional.empty()); + Assertions.assertFalse(props2.containsKey("paimon.schema_evolution")); + Assertions.assertEquals(0, memo2.size()); + } + } + + @Test + public void getScanPlanProviderInjectsSharedSchemaMemo(@TempDir Path warehouse) { + Map props = new HashMap<>(); + props.put("warehouse", warehouse.toUri().toString()); + PaimonConnector connector = new PaimonConnector(props, new RecordingConnectorContext()); + PaimonScanPlanProvider p1 = (PaimonScanPlanProvider) connector.getScanPlanProvider(); + PaimonScanPlanProvider p2 = (PaimonScanPlanProvider) connector.getScanPlanProvider(); + + // WHY: the cross-scan memo benefit hinges on getScanPlanProvider() injecting the connector's SHARED + // memo (the 4-arg ctor). MUTATION: dropping the schemaAtMemo arg -> each provider gets a fresh memo + // -> not the same instance -> red (and the fix would silently no-op cross-scan memoization). + Assertions.assertSame(p1.schemaAtMemoForTest(), p2.schemaAtMemoForTest(), + "getScanPlanProvider() must inject the connector's shared schema memo, not a fresh one"); + } +} 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 new file mode 100644 index 00000000000000..f64ad21eb0fa9b --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanRangePartitionNullTest.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.connector.paimon; + +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.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 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}. + * + *

    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__"} + * partition value is REAL DATA and must be kept, not coerced to SQL NULL (which is correct for + * hudi's path-encoded partitions, but wrong here). + */ +public class PaimonScanRangePartitionNullTest { + + private static TFileRangeDesc populate(Map partitionValues) { + PaimonScanRange range = new PaimonScanRange.Builder() + .fileFormat("jni") + .paimonSplit("dummy-split") // JNI path; the partition block runs regardless + .partitionValues(partitionValues) + .build(); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(new TTableFormatFileDesc(), rangeDesc); + return rangeDesc; + } + + @Test + public void onlyJavaNullIsTreatedAsNullPartition() { + Map pv = new LinkedHashMap<>(); + pv.put("ordinary", "cn"); + pv.put("genuine_null", null); + pv.put("literal_slash_n", "\\N"); // 2 chars: backslash, N + pv.put("literal_hive_default", "__HIVE_DEFAULT_PARTITION__"); + + TFileRangeDesc desc = populate(pv); + List keys = desc.getColumnsFromPathKeys(); + List values = desc.getColumnsFromPath(); + List isNull = desc.getColumnsFromPathIsNull(); + + Assertions.assertEquals( + Arrays.asList("ordinary", "genuine_null", "literal_slash_n", "literal_hive_default"), keys); + + // 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 + // isNull=true (and the genuine null renders "\N" not "") -> red. + + // ordinary value -> kept, not null + Assertions.assertEquals("cn", values.get(0)); + Assertions.assertFalse(isNull.get(0)); + + // genuine Java-null -> null, rendered "" (legacy-exact; BE ignores the string when isNull) + Assertions.assertTrue(isNull.get(1)); + Assertions.assertEquals("", values.get(1)); + + // literal "\N" -> NOT null, literal kept (the fix; paimon does not reserve "\N") + Assertions.assertFalse(isNull.get(2), + "literal \\N is real data, not a null marker, on the paimon scan path"); + Assertions.assertEquals("\\N", values.get(2)); + + // literal "__HIVE_DEFAULT_PARTITION__" -> NOT null on the scan path (legacy keeps the literal) + Assertions.assertFalse(isNull.get(3), + "literal __HIVE_DEFAULT_PARTITION__ kept verbatim on the paimon scan path"); + Assertions.assertEquals("__HIVE_DEFAULT_PARTITION__", values.get(3)); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanRangeReaderTypeTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanRangeReaderTypeTest.java new file mode 100644 index 00000000000000..af6742933e906d --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanRangeReaderTypeTest.java @@ -0,0 +1,103 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.thrift.TFileRangeDesc; +import org.apache.doris.thrift.TPaimonReaderType; +import org.apache.doris.thrift.TTableFormatFileDesc; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * FIX-READER-TYPE (upstream 3645dc94306, "[feature](be) Add file scanner v2 readers") — pins that + * {@link PaimonScanRange#populateRangeParams} sets the BE thrift {@code TPaimonFileDesc.reader_type} so + * BE's file-scanner-v2 selects the matching paimon reader stack: + *

      + *
    • a cpp-serialized JNI split (Paimon native binary format) → {@link TPaimonReaderType#PAIMON_CPP};
    • + *
    • a Java-serialized JNI split → {@link TPaimonReaderType#PAIMON_JNI};
    • + *
    • a native ORC/Parquet split → {@link TPaimonReaderType#PAIMON_NATIVE}.
    • + *
    + * + *

    WHY this matters: legacy {@code PaimonScanNode.setPaimonParams} set reader_type on all three arms, + * but the SPI migration to {@code PaimonScanRange} dropped it (the thrift {@code TPaimonFileDesc} was built + * without reader_type), so BE could not distinguish the cpp reader from the Java JNI reader for a JNI split. + * The cpp-vs-jni bit is threaded through {@link PaimonScanRange.Builder#cppReaderSplit} because + * populateRangeParams only sees the opaque serialized {@code paimon.split} string and cannot re-derive it — + * it must stay in lockstep with {@code PaimonScanPlanProvider.encodeSplit}'s + * {@code cppReader && split instanceof DataSplit} serialization choice. + */ +public class PaimonScanRangeReaderTypeTest { + + private static TTableFormatFileDesc populate(PaimonScanRange range) { + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + range.populateRangeParams(formatDesc, new TFileRangeDesc()); + return formatDesc; + } + + @Test + public void cppJniSplitSetsReaderTypeCpp() { + // A JNI split serialized in Paimon's native binary format for the paimon-cpp reader + // (PaimonScanPlanProvider: cppReader && split instanceof DataSplit -> cppReaderSplit(true)). + PaimonScanRange range = new PaimonScanRange.Builder() + .fileFormat("parquet") + .paimonSplit("native-serialized-split") // JNI marker (paimon.split prop present) + .cppReaderSplit(true) + .build(); + + // MUTATION: dropping setReaderType, or wiring cpp->PAIMON_JNI, turns this red — BE would pick the + // Java JNI reader for a native-binary split it cannot decode that way. + TTableFormatFileDesc formatDesc = populate(range); + Assertions.assertTrue(formatDesc.getPaimonParams().isSetReaderType(), + "a JNI split must set reader_type so BE can pick the reader stack"); + Assertions.assertEquals(TPaimonReaderType.PAIMON_CPP, + formatDesc.getPaimonParams().getReaderType()); + } + + @Test + public void javaJniSplitSetsReaderTypeJni() { + // A JNI split serialized with Java object serialization: flag off, or a non-DataSplit system split + // (PaimonScanPlanProvider: cppReaderSplit(false)). + PaimonScanRange range = new PaimonScanRange.Builder() + .fileFormat("orc") + .paimonSplit("java-serialized-split") // JNI marker + .cppReaderSplit(false) + .build(); + + TTableFormatFileDesc formatDesc = populate(range); + Assertions.assertTrue(formatDesc.getPaimonParams().isSetReaderType()); + Assertions.assertEquals(TPaimonReaderType.PAIMON_JNI, + formatDesc.getPaimonParams().getReaderType()); + } + + @Test + public void nativeSplitSetsReaderTypeNative() { + // A native ORC/Parquet split: no paimonSplit marker -> native reader branch, always PAIMON_NATIVE + // regardless of the (defaulted false) cppReaderSplit. + PaimonScanRange range = new PaimonScanRange.Builder() + .fileFormat("orc") + .path("s3://bkt/a/part-0.orc") + .schemaId(1L) + .build(); + + TTableFormatFileDesc formatDesc = populate(range); + Assertions.assertTrue(formatDesc.getPaimonParams().isSetReaderType()); + Assertions.assertEquals(TPaimonReaderType.PAIMON_NATIVE, + formatDesc.getPaimonParams().getReaderType()); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanRangeSelfSplitWeightTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanRangeSelfSplitWeightTest.java new file mode 100644 index 00000000000000..719c71cd8b6bd1 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanRangeSelfSplitWeightTest.java @@ -0,0 +1,100 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.thrift.TFileRangeDesc; +import org.apache.doris.thrift.TTableFormatFileDesc; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * FIX-A3 (P6 deviation) — pins that {@link PaimonScanRange} emits the BE thrift profile property + * {@code paimon.self_split_weight} for every JNI split including a genuine weight of 0, + * matching legacy {@code PaimonScanNode.setPaimonParams:274} (which calls {@code setSelfSplitWeight} + * unconditionally on the JNI branch and never on the native branch). + * + *

    The pre-fix {@code selfSplitWeight > 0} gate dropped a weight-0 JNI split (a non-DataSplit + * system split with {@code rowCount()==0}, or a DataSplit whose files sum to {@code fileSize==0}), so + * BE read the {@code -1} "unset" sentinel ({@code paimon_jni_reader.cpp:95}) instead of {@code 0} and + * the {@code _max_time_split_weight_counter} profile counter was wrong. Profile-only — never touches + * rows / counts / predicates / schema / routing. + */ +public class PaimonScanRangeSelfSplitWeightTest { + + private static TFileRangeDesc populate(PaimonScanRange range) { + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(new TTableFormatFileDesc(), rangeDesc); + return rangeDesc; + } + + @Test + public void jniSplitWithZeroWeightEmitsZero() { + // A JNI split whose genuine self-split weight is 0 (e.g. a rowCount()==0 system split). + PaimonScanRange range = new PaimonScanRange.Builder() + .fileFormat("orc") + .paimonSplit("serialized-split") // JNI marker + .selfSplitWeight(0L) + .build(); + + // BE-visible (load-bearing): populateRangeParams must SET the thrift weight to 0 so BE reads 0 + // instead of its -1 unset default. MUTATION: restoring the old `selfSplitWeight > 0` gate -> + // prop absent -> setSelfSplitWeight never called -> isSetSelfSplitWeight() false -> BE -1 -> red. + TFileRangeDesc desc = populate(range); + Assertions.assertTrue(desc.isSetSelfSplitWeight(), + "a weight-0 JNI split must still set the thrift self_split_weight (legacy :274 parity)"); + Assertions.assertEquals(0L, desc.getSelfSplitWeight()); + Assertions.assertEquals("0", range.getProperties().get("paimon.self_split_weight")); + } + + @Test + public void jniSplitWithPositiveWeightEmitsWeight() { + // Positive-case coverage (NEW — no prior test asserted self_split_weight). + PaimonScanRange range = new PaimonScanRange.Builder() + .fileFormat("orc") + .paimonSplit("serialized-split") + .selfSplitWeight(4096L) + .build(); + + TFileRangeDesc desc = populate(range); + Assertions.assertTrue(desc.isSetSelfSplitWeight()); + Assertions.assertEquals(4096L, desc.getSelfSplitWeight()); + Assertions.assertEquals("4096", range.getProperties().get("paimon.self_split_weight")); + } + + @Test + public void nativeSplitNeverCarriesWeight() { + // A native (ORC/Parquet) split: no paimonSplit marker; weight defaults to 0. + PaimonScanRange range = new PaimonScanRange.Builder() + .fileFormat("parquet") + .path("s3://bkt/a/part-0.parquet") + .build(); + + // BE-visible parity: the native branch of populateRangeParams sets only the format, never the + // weight, exactly like legacy PaimonScanNode's native branch (no setSelfSplitWeight call). + TFileRangeDesc desc = populate(range); + Assertions.assertFalse(desc.isSetSelfSplitWeight(), + "native splits must not carry self_split_weight (legacy native branch never sets it)"); + + // Gate-choice pin (NOT BE-visible): the JNI-marker gate must not add the key to a native + // split's props map. MUTATION: switching the gate to `selfSplitWeight >= 0` makes a native + // split (default weight 0) gain a `paimon.self_split_weight=0` key here -> red. + Assertions.assertFalse(range.getProperties().containsKey("paimon.self_split_weight"), + "the JNI-marker gate must not emit self_split_weight for a native split"); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonSchemaAtMemoTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonSchemaAtMemoTest.java new file mode 100644 index 00000000000000..5b3aecd623c71a --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonSchemaAtMemoTest.java @@ -0,0 +1,192 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Unit tests for {@link PaimonSchemaAtMemo} (FIX-B-MC2): the bounded, immutable second-level memo of the + * time-travel schema-at-snapshot read. Verifies key dedup (the cross-query hit), that every component of + * the handle identity participates in the key (the {@code sysName} Rule-9 guard), and that the bound + * degrades to a re-read rather than ever serving a stale value (the no-regression "worst case = current"). + */ +public class PaimonSchemaAtMemoTest { + + private static PaimonTableHandle handle(String db, String table) { + return new PaimonTableHandle(db, table, Collections.emptyList(), Collections.emptyList()); + } + + private static PaimonCatalogOps.PaimonSchemaSnapshot snap() { + return new PaimonCatalogOps.PaimonSchemaSnapshot( + Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + } + + @Test + public void sameKeyLoadsOnce() { + PaimonSchemaAtMemo memo = new PaimonSchemaAtMemo(100); + PaimonTableHandle h = handle("db", "t"); + AtomicInteger loads = new AtomicInteger(); + + memo.getOrLoad(h, 5L, () -> { + loads.incrementAndGet(); + return snap(); + }); + memo.getOrLoad(h, 5L, () -> { + loads.incrementAndGet(); + return snap(); + }); + + // WHY: a repeat (handle, schemaId) must be a memo hit — the whole point of FIX-B-MC2 (restore the + // legacy cross-query schemaAt hit). MUTATION: never caching -> 2 loads -> red. + Assertions.assertEquals(1, loads.get(), "the same (handle, schemaId) must load exactly once"); + Assertions.assertEquals(1, memo.size()); + } + + @Test + public void sysTableNameDistinguishesKey() { + // Two handles equal in (db, table, branch, schemaId) but differing ONLY in sysTableName. + PaimonSchemaAtMemo memo = new PaimonSchemaAtMemo(100); + PaimonTableHandle base = handle("db", "t"); + PaimonTableHandle sys = PaimonTableHandle.forSystemTable("db", "t", "snapshots", false); + AtomicInteger loads = new AtomicInteger(); + + memo.getOrLoad(base, 5L, () -> { + loads.incrementAndGet(); + return snap(); + }); + memo.getOrLoad(sys, 5L, () -> { + loads.incrementAndGet(); + return snap(); + }); + + // WHY: sysName is part of table identity (a sys table is a distinct table with its own rowType); + // the key must not collide a base table with its system table. MUTATION: drop sysTableName from + // MemoKey -> one load -> red. + Assertions.assertEquals(2, loads.get(), "base and its system table must be distinct memo keys"); + } + + @Test + public void overflowEvictsAndReReadsNeverStale() { + // Bound = 2: inserting a 3rd distinct key flushes the map; a previously-cached key then re-loads + // (a re-read = the pre-fix behavior), proving eviction degrades to a re-read, never a stale value. + PaimonSchemaAtMemo memo = new PaimonSchemaAtMemo(2); + AtomicInteger loads = new AtomicInteger(); + + memo.getOrLoad(handle("db", "t1"), 1L, () -> { + loads.incrementAndGet(); + return snap(); + }); + memo.getOrLoad(handle("db", "t2"), 1L, () -> { + loads.incrementAndGet(); + return snap(); + }); + // size() == 2 == bound -> this insert clears, then puts t3. + memo.getOrLoad(handle("db", "t3"), 1L, () -> { + loads.incrementAndGet(); + return snap(); + }); + Assertions.assertEquals(3, loads.get()); + + // t1 was flushed by the overflow -> re-loads now (never serves a stale value). + memo.getOrLoad(handle("db", "t1"), 1L, () -> { + loads.incrementAndGet(); + return snap(); + }); + Assertions.assertEquals(4, loads.get(), "an evicted key must re-read (never serve a stale value)"); + Assertions.assertTrue(memo.size() <= 2, "the memo must stay bounded"); + } + + @Test + public void invalidateDropsAllSchemaIdsOfOneTableOnly() { + PaimonSchemaAtMemo memo = new PaimonSchemaAtMemo(100); + memo.getOrLoad(handle("db", "t"), 0L, PaimonSchemaAtMemoTest::snap); + memo.getOrLoad(handle("db", "t"), 1L, PaimonSchemaAtMemoTest::snap); // same (db,t), other schemaId + memo.getOrLoad(handle("db", "other"), 0L, PaimonSchemaAtMemoTest::snap); + memo.getOrLoad(handle("db2", "t"), 0L, PaimonSchemaAtMemoTest::snap); // same table name, other db + Assertions.assertEquals(4, memo.size()); + + memo.invalidate("db", "t"); + + // WHY (Rule 9 / the §3 drop+recreate fix): invalidate must drop EVERY schemaId of (db,t) so a recreate + // reusing schema 0 with different content cannot serve a stale schema-at memo, while leaving other + // tables/dbs intact. A mutation matching on schemaId, or matching db only, changes this count. + Assertions.assertEquals(2, memo.size(), "both (db,t) schemaIds gone; (db,other) and (db2,t) survive"); + + AtomicInteger loads = new AtomicInteger(); + memo.getOrLoad(handle("db", "other"), 0L, () -> { + loads.incrementAndGet(); + return snap(); + }); + memo.getOrLoad(handle("db2", "t"), 0L, () -> { + loads.incrementAndGet(); + return snap(); + }); + Assertions.assertEquals(0, loads.get(), "unrelated (db,other) and (db2,t) must stay cached hits"); + memo.getOrLoad(handle("db", "t"), 0L, () -> { + loads.incrementAndGet(); + return snap(); + }); + Assertions.assertEquals(1, loads.get(), "(db,t) must re-read after invalidate"); + } + + @Test + public void invalidateDbDropsEveryTableOfOneDbOnly() { + PaimonSchemaAtMemo memo = new PaimonSchemaAtMemo(100); + memo.getOrLoad(handle("db", "t"), 0L, PaimonSchemaAtMemoTest::snap); + memo.getOrLoad(handle("db", "t"), 1L, PaimonSchemaAtMemoTest::snap); // same (db,t), other schemaId + memo.getOrLoad(handle("db", "other"), 0L, PaimonSchemaAtMemoTest::snap); // same db, other table + memo.getOrLoad(handle("db2", "t"), 0L, PaimonSchemaAtMemoTest::snap); // other db + Assertions.assertEquals(4, memo.size()); + + memo.invalidateDb("db"); + + // WHY (R2 / the DROP DATABASE + REFRESH DATABASE fix): invalidateDb must drop EVERY table (and every + // schemaId) of db so a same-name recreate under that db cannot serve a stale time-travel schema, while + // leaving other dbs intact. A mutation matching (db,table) instead of db-only, or a no-op, changes this. + Assertions.assertEquals(1, memo.size(), "all of db's entries gone; only (db2,t) survives"); + + AtomicInteger loads = new AtomicInteger(); + memo.getOrLoad(handle("db2", "t"), 0L, () -> { + loads.incrementAndGet(); + return snap(); + }); + Assertions.assertEquals(0, loads.get(), "the other db's entry must stay a cached hit"); + memo.getOrLoad(handle("db", "other"), 0L, () -> { + loads.incrementAndGet(); + return snap(); + }); + Assertions.assertEquals(1, loads.get(), "a table in the invalidated db must re-read"); + } + + @Test + public void invalidateAllClearsEverything() { + PaimonSchemaAtMemo memo = new PaimonSchemaAtMemo(100); + memo.getOrLoad(handle("db", "t"), 0L, PaimonSchemaAtMemoTest::snap); + memo.getOrLoad(handle("db2", "t2"), 0L, PaimonSchemaAtMemoTest::snap); + Assertions.assertEquals(2, memo.size()); + + memo.invalidateAll(); + + // REFRESH CATALOG parity: the whole memo is dropped (the connector is rebuilt around it too). + Assertions.assertEquals(0, memo.size(), "invalidateAll must clear the whole memo"); + } +} 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 new file mode 100644 index 00000000000000..03b6b26480b63d --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonSchemaBuilderTest.java @@ -0,0 +1,231 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.ddl.ConnectorCreateTableRequest; +import org.apache.doris.connector.api.ddl.ConnectorPartitionField; +import org.apache.doris.connector.api.ddl.ConnectorPartitionSpec; + +import org.apache.paimon.CoreOptions; +import org.apache.paimon.schema.Schema; +import org.apache.paimon.types.DataField; +import org.apache.paimon.types.IntType; +import org.apache.paimon.types.VarCharType; +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; + +/** + * P5-T12 — pins {@link PaimonSchemaBuilder#build} to byte-parity with the legacy fe-core + * {@code PaimonMetadataOps.toPaimonSchema}: this is the function that turns a CREATE TABLE + * request into the Paimon {@link Schema} actually persisted, so option/key/comment drift here + * silently changes how new tables are created. + */ +public class PaimonSchemaBuilderTest { + + private static ConnectorColumn col(String name, ConnectorType type, boolean nullable) { + return new ConnectorColumn(name, type, name + " comment", nullable, null); + } + + private static ConnectorCreateTableRequest.Builder baseRequest() { + return ConnectorCreateTableRequest.builder() + .dbName("db") + .tableName("t") + .columns(Arrays.asList( + col("id", ConnectorType.of("INT"), false), + col("name", ConnectorType.of("STRING"), true))); + } + + @Test + public void columnsCarryTypeNameNullabilityAndComment() { + Schema schema = PaimonSchemaBuilder.build(baseRequest().build()); + + // WHY: column name/type/comment and per-column nullability must survive the conversion; + // nullability is applied via copy(nullable), mirroring legacy toPaimontype().copy(...). + // MUTATION: dropping .copy(col.isNullable()) (so both columns share paimon's default + // nullable) or losing the comment turns this red. + DataField id = schema.fields().get(0); + DataField name = schema.fields().get(1); + Assertions.assertEquals("id", id.name()); + Assertions.assertEquals(new IntType(false), id.type(), "non-null column must be copied non-null"); + Assertions.assertEquals("id comment", id.description()); + Assertions.assertEquals("name", name.name()); + Assertions.assertEquals(new VarCharType(VarCharType.MAX_LENGTH).copy(true), name.type(), + "nullable column must keep nullable, STRING -> VarChar(MAX)"); + } + + @Test + public void primaryKeysComeFromPropertiesOnly() { + Map props = new LinkedHashMap<>(); + props.put("primary-key", "id, name"); + Schema schema = PaimonSchemaBuilder.build(baseRequest().properties(props).build()); + + // WHY: primary keys live ONLY in properties["primary-key"], comma-split and trimmed (note + // the space after the comma above). MUTATION: not trimming (" name") or not reading the + // property at all (empty pk list) turns this red. + Assertions.assertEquals(Arrays.asList("id", "name"), schema.primaryKeys()); + } + + @Test + public void noPrimaryKeyPropertyYieldsEmpty() { + Schema schema = PaimonSchemaBuilder.build(baseRequest().build()); + // WHY: absent primary-key property must yield an empty pk list, not a NPE or a stray key. + // MUTATION: defaulting to a non-empty list turns this red. + Assertions.assertTrue(schema.primaryKeys().isEmpty()); + } + + @Test + public void identityPartitionSpecBecomesPartitionKeys() { + ConnectorPartitionSpec spec = new ConnectorPartitionSpec( + ConnectorPartitionSpec.Style.IDENTITY, + Arrays.asList( + new ConnectorPartitionField("name", "identity", Collections.emptyList()), + new ConnectorPartitionField("id", "IDENTITY", Collections.emptyList())), + 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 + // identity check is case-insensitive. MUTATION: reordering, or rejecting the upper-case + // "IDENTITY", turns this red. + Assertions.assertEquals(Arrays.asList("name", "id"), schema.partitionKeys()); + } + + @Test + public void primaryKeysResolvedToCanonicalColumnCase() { + // #65094: columns keep their original case ("Id"); a primary key referenced with a different + // case ("id") must resolve back to the canonical column name, else Paimon's case-sensitive + // Schema.Builder validation rejects the table. MUTATION: dropping resolveColumnNames -> the pk + // stays "id" while the only column is "Id" -> Schema.build() throws -> red. + ConnectorCreateTableRequest.Builder req = ConnectorCreateTableRequest.builder() + .dbName("db").tableName("t") + .columns(Arrays.asList( + col("Id", ConnectorType.of("INT"), false), + col("name", ConnectorType.of("STRING"), true))); + Map props = new LinkedHashMap<>(); + props.put("primary-key", "id"); + Schema schema = PaimonSchemaBuilder.build(req.properties(props).build()); + Assertions.assertEquals(Collections.singletonList("Id"), schema.primaryKeys()); + } + + @Test + public void partitionKeysResolvedToCanonicalColumnCase() { + // #65094: same case-insensitive resolution for partition keys ("pt" -> canonical "Pt"). + // MUTATION: dropping resolveColumnNames -> partition key "pt" not among columns {id,Pt} -> + // Schema.build() throws -> red. + ConnectorCreateTableRequest.Builder req = ConnectorCreateTableRequest.builder() + .dbName("db").tableName("t") + .columns(Arrays.asList( + col("id", ConnectorType.of("INT"), false), + col("Pt", ConnectorType.of("STRING"), false))); + ConnectorPartitionSpec spec = new ConnectorPartitionSpec( + ConnectorPartitionSpec.Style.IDENTITY, + Collections.singletonList(new ConnectorPartitionField("pt", "identity", Collections.emptyList())), + Collections.emptyList()); + Schema schema = PaimonSchemaBuilder.build(req.partitionSpec(spec).build()); + Assertions.assertEquals(Collections.singletonList("Pt"), schema.partitionKeys()); + } + + @Test + public void nullPartitionSpecYieldsNoPartitionKeys() { + Schema schema = PaimonSchemaBuilder.build(baseRequest().build()); + // WHY: a non-partitioned table (null spec) must yield no partition keys. MUTATION: NPE on + // null spec, or inventing partition keys, turns this red. + Assertions.assertTrue(schema.partitionKeys().isEmpty()); + } + + @Test + public void nonIdentityPartitionTransformThrows() { + ConnectorPartitionSpec spec = new ConnectorPartitionSpec( + ConnectorPartitionSpec.Style.TRANSFORM, + Collections.singletonList( + new ConnectorPartitionField("id", "bucket", Collections.singletonList(16))), + Collections.emptyList()); + // 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 + // the column anyway makes this green-when-it-should-throw -> caught here. + Assertions.assertThrows(DorisConnectorException.class, + () -> PaimonSchemaBuilder.build(baseRequest().partitionSpec(spec).build())); + } + + @Test + public void locationRekeyedToCorePathAndStripped() { + Map props = new LinkedHashMap<>(); + props.put("location", "s3://bucket/path"); + props.put("bucket", "4"); + Schema schema = PaimonSchemaBuilder.build(baseRequest().properties(props).build()); + + // WHY: "location" must be removed and re-added under CoreOptions.PATH; unrelated options + // (bucket) ride through unchanged as passthrough (legacy did not consume bucketSpec). + // MUTATION: leaving "location" in options, not setting CoreOptions.PATH, or dropping the + // bucket passthrough turns this red. + Assertions.assertFalse(schema.options().containsKey("location"), + "raw location key must be stripped from options"); + Assertions.assertEquals("s3://bucket/path", schema.options().get(CoreOptions.PATH.key()), + "location must be re-keyed to CoreOptions.PATH"); + Assertions.assertEquals("4", schema.options().get("bucket"), + "unrelated options must ride through as passthrough"); + } + + @Test + public void primaryKeyAndCommentStrippedFromOptions() { + Map props = new LinkedHashMap<>(); + props.put("primary-key", "id"); + props.put("comment", "from properties"); + props.put("custom", "keep"); + Schema schema = PaimonSchemaBuilder.build(baseRequest().properties(props).build()); + + // WHY: "primary-key" and "comment" are control keys consumed into dedicated Schema fields + // and MUST NOT leak into the option map; other keys remain. MUTATION: leaving either key in + // options turns this red. + Assertions.assertFalse(schema.options().containsKey("primary-key")); + Assertions.assertFalse(schema.options().containsKey("comment")); + Assertions.assertEquals("keep", schema.options().get("custom")); + } + + @Test + public void commentPrefersPropertiesOverClause() { + Map props = new LinkedHashMap<>(); + props.put("comment", "from properties"); + Schema schema = PaimonSchemaBuilder.build( + baseRequest().comment("from clause").properties(props).build()); + + // WHY: legacy toPaimonSchema read the table comment ONLY from properties["comment"]; to + // preserve that persisted-comment behavior, properties wins over the dedicated COMMENT + // clause. MUTATION: preferring request.getComment() (the clause) flips this to "from + // clause" -> red. + Assertions.assertEquals("from properties", schema.comment()); + } + + @Test + public void commentFallsBackToClauseWhenPropertyAbsent() { + Schema schema = PaimonSchemaBuilder.build(baseRequest().comment("from clause").build()); + + // WHY: when properties has no "comment", the user's COMMENT clause must not be silently + // dropped (a strictly-legacy reading would lose it). MUTATION: hardcoding null when the + // property is absent (ignoring request.getComment()) turns this red. + Assertions.assertEquals("from clause", schema.comment()); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonTableHandleScanOptionsTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonTableHandleScanOptionsTest.java new file mode 100644 index 00000000000000..ebcc7197ef0b29 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonTableHandleScanOptionsTest.java @@ -0,0 +1,329 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorTableSchema; + +import org.apache.paimon.types.DataTypes; +import org.apache.paimon.types.RowType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Tests for the B5a scan-pin / partition-key-flip wiring on {@link PaimonTableHandle} and + * {@link PaimonConnectorMetadata#getTableSchema}: the serializable {@code scanOptions} field, the + * {@link PaimonTableHandle#withScanOptions(Map)} copy factory (identity-preserving, equals/hashCode + * ignoring scanOptions), the Java-serialization survival of scanOptions, and the {@code + * partition_columns} key flip the generic fe-core consumer reads. + */ +public class PaimonTableHandleScanOptionsTest { + + private static RowType rowType(String... columnNames) { + RowType.Builder builder = RowType.builder(); + for (String name : columnNames) { + builder.field(name, DataTypes.INT()); + } + return builder.build(); + } + + @Test + public void normalHandleHasEmptyScanOptions() { + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + + // WHY: a normal (un-pinned) handle must default to empty scanOptions so the scan path takes + // the no-copy fast path. MUTATION: defaulting to null -> NPE downstream / non-empty -> the + // un-pinned path would wrongly call Table.copy -> red. + Assertions.assertTrue(handle.getScanOptions().isEmpty(), + "a normal handle must carry empty scanOptions"); + } + + @Test + public void withScanOptionsPreservesIdentityAndSetsOptions() { + PaimonTableHandle base = new PaimonTableHandle( + "db1", "t1", + Arrays.asList("dt", "region"), + Collections.singletonList("id")); + FakePaimonTable table = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + base.setPaimonTable(table); + + Map opts = Collections.singletonMap("scan.snapshot-id", "9"); + PaimonTableHandle pinned = base.withScanOptions(opts); + + // WHY: withScanOptions is a copy factory — the pinned handle is the SAME table, just read at + // a version, so every identity field AND the transient Table must carry over unchanged while + // only scanOptions is set. MUTATION: dropping any preserved field (e.g. partitionKeys) or + // not setting scanOptions -> the matching assertion -> red. + Assertions.assertEquals("db1", pinned.getDatabaseName()); + Assertions.assertEquals("t1", pinned.getTableName()); + Assertions.assertEquals(Arrays.asList("dt", "region"), pinned.getPartitionKeys(), + "withScanOptions must preserve partitionKeys"); + Assertions.assertEquals(Collections.singletonList("id"), pinned.getPrimaryKeys(), + "withScanOptions must preserve primaryKeys"); + Assertions.assertNull(pinned.getSysTableName(), + "withScanOptions must preserve sysTableName (null for a normal handle)"); + Assertions.assertFalse(pinned.isForceJni(), + "withScanOptions must preserve forceJni"); + Assertions.assertSame(table, pinned.getPaimonTable(), + "withScanOptions must carry over the transient Table"); + Assertions.assertEquals(opts, pinned.getScanOptions(), + "withScanOptions must set the given scanOptions"); + } + + @Test + public void withScanOptionsPreservesSysIdentity() { + PaimonTableHandle sys = PaimonTableHandle.forSystemTable("db1", "t1", "binlog", true); + + PaimonTableHandle pinned = sys.withScanOptions( + Collections.singletonMap("scan.snapshot-id", "9")); + + // WHY: the copy must preserve the sys identity (sysTableName + forceJni) too — a later + // dispatch may route through withScanOptions on any handle, and silently dropping the sys + // identity would turn a sys handle into a base handle (wrong rows). MUTATION: omitting + // sysTableName/forceJni from the copy ctor -> these assertions -> red. + Assertions.assertTrue(pinned.isSystemTable(), + "withScanOptions must preserve sys-table identity"); + Assertions.assertEquals("binlog", pinned.getSysTableName()); + Assertions.assertTrue(pinned.isForceJni(), + "withScanOptions must preserve the forceJni hint"); + } + + @Test + public void equalsAndHashCodeIgnoreScanOptions() { + PaimonTableHandle base = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + PaimonTableHandle pinned = base.withScanOptions( + Collections.singletonMap("scan.snapshot-id", "5")); + + // WHY: a snapshot-pinned handle is the SAME table read at a version, so it MUST equal/hash + // identically to its base — otherwise plan/cache keying would treat the pinned read as a + // different table. scanOptions therefore must NOT participate in equals/hashCode. MUTATION: + // including scanOptions in equals/hashCode -> base.equals(pinned) false / hashes differ -> + // red. + Assertions.assertEquals(base, pinned, + "a snapshot-pinned handle must equal its base handle (scanOptions ignored in equals)"); + Assertions.assertEquals(base.hashCode(), pinned.hashCode(), + "scanOptions must not affect hashCode"); + } + + @Test + public void scanOptionsSurviveJavaSerializationRoundTrip() throws Exception { + PaimonTableHandle original = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()) + .withScanOptions(Collections.singletonMap("scan.snapshot-id", "7")); + + // Real Java serialization round-trip (the FE/BE / plan-reuse wire mechanism). + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(original); + } + PaimonTableHandle restored; + try (ObjectInputStream ois = new ObjectInputStream( + new ByteArrayInputStream(baos.toByteArray()))) { + restored = (PaimonTableHandle) ois.readObject(); + } + + // WHY: the JNI serialized-table read happens on a DESERIALIZED handle (the transient Table + // is dropped and reloaded on BE/plan-reuse), so the snapshot pin must survive serialization + // — otherwise the pinned read would silently fall back to the latest version (wrong rows for + // time-travel). scanOptions must therefore be non-transient. MUTATION: marking scanOptions + // transient -> restored.getScanOptions() empty -> red. + Assertions.assertEquals("7", restored.getScanOptions().get("scan.snapshot-id"), + "scanOptions must survive serialization (non-transient) so the pinned read is preserved"); + } + + // ==================== B5b-2c: branch identity ==================== + + @Test + public void withBranchSetsBranchNameAndDoesNotCarryTransientTable() { + PaimonTableHandle base = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + FakePaimonTable table = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + base.setPaimonTable(table); + + PaimonTableHandle branched = base.withBranch("b1"); + + // WHY: a branch handle must record its branch name and stay a non-system handle. + Assertions.assertEquals("b1", branched.getBranchName(), + "withBranch must set the branch name"); + Assertions.assertFalse(branched.isSystemTable(), + "a branch handle is NOT a system handle"); + // CRITICAL TRAP: unlike withScanOptions, withBranch must NOT carry the transient base Table + // over — a branch is a DIFFERENT table (independent schema/snapshots). Carrying the base + // Table would make resolveTable return the base table's rows for the branch read (silent data + // error). MUTATION: copying this.paimonTable into the branch handle -> getPaimonTable() != null + // -> red, so resolveTable is forced to reload the BRANCH table via the 3-arg branch Identifier. + Assertions.assertNull(branched.getPaimonTable(), + "withBranch must NOT carry the transient base Table (forces a branch reload)"); + // toString must render the branch suffix ("@b1") so logs / explains distinguish a branch read + // from its base. MUTATION: dropping the branch suffix from toString -> the contains check red. + Assertions.assertTrue(branched.toString().contains("@b1"), + "a branch handle's toString must render the '@' suffix"); + } + + @Test + public void withBranchPreservesOtherFields() { + PaimonTableHandle base = new PaimonTableHandle( + "db1", "t1", + Arrays.asList("dt", "region"), + Collections.singletonList("id")) + .withScanOptions(Collections.singletonMap("scan.snapshot-id", "9")); + + PaimonTableHandle branched = base.withBranch("b1"); + + // WHY: withBranch is a copy factory that changes ONLY branchName — every other field + // (db/table/partitionKeys/primaryKeys/scanOptions) must carry over unchanged. MUTATION: + // dropping any preserved field -> the matching assertion -> red. + Assertions.assertEquals("db1", branched.getDatabaseName()); + Assertions.assertEquals("t1", branched.getTableName()); + Assertions.assertEquals(Arrays.asList("dt", "region"), branched.getPartitionKeys(), + "withBranch must preserve partitionKeys"); + Assertions.assertEquals(Collections.singletonList("id"), branched.getPrimaryKeys(), + "withBranch must preserve primaryKeys"); + Assertions.assertEquals("9", branched.getScanOptions().get("scan.snapshot-id"), + "withBranch must preserve scanOptions"); + } + + @Test + public void branchIsPartOfHandleIdentity() { + PaimonTableHandle base = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + PaimonTableHandle b1 = base.withBranch("b1"); + PaimonTableHandle b2 = base.withBranch("b2"); + + // WHY: a branch handle is a DIFFERENT table identity than its base and than another branch + // (independent schema/snapshots), exactly like sysTableName — so branchName MUST participate + // in equals/hashCode, otherwise plan/cache keying would conflate the base read with the + // branch read (wrong rows). MUTATION: leaving branchName out of equals/hashCode -> base + // equals b1 (and b1 equals b2) -> these assertions red. + Assertions.assertNotEquals(base, b1, + "a branch handle must NOT equal its base handle"); + Assertions.assertNotEquals(b1, b2, + "two different branch handles must NOT be equal"); + Assertions.assertEquals(b1, base.withBranch("b1"), + "two handles on the same branch must be equal"); + Assertions.assertEquals(b1.hashCode(), base.withBranch("b1").hashCode(), + "two handles on the same branch must have equal hashCodes"); + } + + @Test + public void scanOptionsStillIgnoredInIdentityForBranchHandle() { + PaimonTableHandle b1 = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()).withBranch("b1"); + PaimonTableHandle b1Pinned = b1.withScanOptions( + Collections.singletonMap("scan.snapshot-id", "5")); + + // WHY: scanOptions remain excluded from identity even for a branch handle — a branch read + // pinned at a version is the SAME branch table, just read at a version. MUTATION: including + // scanOptions in equals/hashCode -> these assertions red. + Assertions.assertEquals(b1, b1Pinned, + "a branch handle with vs without scanOptions must be equal (scanOptions excluded)"); + Assertions.assertEquals(b1.hashCode(), b1Pinned.hashCode(), + "scanOptions must not affect a branch handle's hashCode"); + } + + @Test + public void sysIdentityUnaffectedByBranchField() { + PaimonTableHandle base = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + PaimonTableHandle sys = PaimonTableHandle.forSystemTable("db1", "t1", "snapshots", false); + + // WHY: adding branchName to identity must not regress the existing sys identity invariant — + // a base handle (branch=null, sys=null) still must NOT equal a sys handle (branch=null, + // sys=snapshots). MUTATION: a botched equals (e.g. comparing only branchName) -> red. + Assertions.assertNotEquals(base, sys, + "a base handle must still NOT equal a sys handle after adding branchName"); + Assertions.assertNull(sys.getBranchName(), + "forSystemTable must default branchName to null"); + } + + @Test + public void branchHandleSurvivesJavaSerializationWithTransientTableNull() throws Exception { + PaimonTableHandle original = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()) + .withBranch("b1"); + + // Real Java serialization round-trip (the FE/BE / plan-reuse wire mechanism). branchName is + // non-transient and must survive so the deserialized handle still reloads the BRANCH table. + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(original); + } + PaimonTableHandle restored; + try (ObjectInputStream ois = new ObjectInputStream( + new ByteArrayInputStream(baos.toByteArray()))) { + restored = (PaimonTableHandle) ois.readObject(); + } + + // WHY: a deserialized branch handle (transient Table dropped on the BE/plan-reuse boundary) + // must still know it is a branch so resolveTable reloads the BRANCH table via the 3-arg branch + // Identifier — otherwise the read would silently fall back to the base table (wrong rows). + // MUTATION: marking branchName transient -> restored.getBranchName() null -> red. + Assertions.assertEquals("b1", restored.getBranchName(), + "branchName must survive serialization (non-transient) so the branch reload is preserved"); + Assertions.assertNull(restored.getPaimonTable(), + "the transient Table must be null after deserialize (reloaded as the branch table)"); + } + + @Test + public void getTableSchemaEmitsPartitionColumnsKeyForPartitionedHandle() { + RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); + FakePaimonTable table = new FakePaimonTable( + "t1", rowType("id", "dt", "region"), + Arrays.asList("dt", "region"), + Collections.emptyList()); + ops.table = table; + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", + Arrays.asList("dt", "region"), + Collections.emptyList()); + handle.setPaimonTable(table); + + ConnectorTableSchema schema = new PaimonConnectorMetadata( + ops, Collections.emptyMap(), new RecordingConnectorContext()) + .getTableSchema(null, handle); + Map props = schema.getProperties(); + + // WHY: the generic fe-core consumer PluginDrivenExternalTable.initSchema reads the schema + // property "partition_columns" (not "partition_keys") to learn a table is partitioned; + // keying it under "partition_keys" left the FE treating paimon as non-partitioned. MUTATION: + // emitting the old "partition_keys" key -> "partition_columns" absent + "partition_keys" + // present -> both assertions red. + Assertions.assertEquals("dt,region", props.get(ConnectorTableSchema.PARTITION_COLUMNS_KEY), + "getTableSchema must emit partition keys under the reserved partition-columns key"); + Assertions.assertNull(props.get("partition_keys"), + "the legacy 'partition_keys' key must no longer be emitted (FE reads partition_columns)"); + + // Sanity: columns still resolved (the schema build itself is unaffected by the key flip). + List columns = schema.getColumns(); + Assertions.assertEquals(3, columns.size(), + "all columns must still be mapped from the row type"); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonTableSerdeRoundTripTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonTableSerdeRoundTripTest.java new file mode 100644 index 00000000000000..285ef6784b9268 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonTableSerdeRoundTripTest.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. + +package org.apache.doris.connector.paimon; + +import org.apache.paimon.catalog.Catalog; +import org.apache.paimon.catalog.FileSystemCatalog; +import org.apache.paimon.catalog.Identifier; +import org.apache.paimon.fs.local.LocalFileIO; +import org.apache.paimon.schema.Schema; +import org.apache.paimon.table.Table; +import org.apache.paimon.types.DataTypes; +import org.apache.paimon.types.RowType; +import org.apache.paimon.utils.InstantiationUtil; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; + +/** + * Offline FE->BE serialized-{@link Table} round-trip smoke for the Paimon connector. + * + *

    This pins the exact wire mechanism the FE uses to ship a Paimon {@code Table} to BE for the + * JNI reader: {@code PaimonScanPlanProvider.encodeObjectToString} serializes the live table with + * {@link InstantiationUtil#serializeObject(Object)} and base64-encodes the bytes with the STANDARD + * {@link Base64#getEncoder()} into the {@code paimon.serialized_table} property + * ({@code PaimonScanPlanProvider} ~:213). BE reverses it ({@code PaimonUtils.deserialize}): URL-safe + * {@link Base64#getUrlDecoder()} first, STANDARD {@link Base64#getDecoder()} fallback, then + * {@link InstantiationUtil#deserializeObject(byte[], ClassLoader)}, running the IDENTICAL paimon + * 1.3.1 jar (R-007 — see fe/pom.xml {@code }). A version drift, a newly + * non-serializable field on the table, or a base64-variant mismatch would silently break BE + * deserialization at runtime; this catches it in CI. + * + *

    Why this is a faithful simulation and not a fake: it builds a REAL local-filesystem Paimon + * catalog (paimon-core ships a local {@code FileIO}, so no hadoop is needed) under a JUnit + * {@link TempDir}, creates a real database + a real partitioned/keyed table via a valid + * {@link Schema}, resolves it with {@code catalog.getTable(Identifier)} (the same call the + * connector metadata makes) to get a real {@code FileStoreTable}, then serializes/deserializes it + * through the connector's mechanism — the decode reproduces BE's {@code PaimonUtils.deserialize} + * branch (URL-safe decoder first, STANDARD fallback) to prove the object graph reconstitutes from + * raw classes on the same path BE actually runs. + * + *

    Fully offline — runs in CI, NOT env-gated (contrast {@link PaimonLiveConnectivityTest}). + */ +public class PaimonTableSerdeRoundTripTest { + + private static final String DB = "rt_db"; + private static final String TBL = "rt_tbl"; + + // --- the EXACT connector wire mechanism (PaimonScanPlanProvider.encodeObjectToString) --- + + /** FE side: serialize + STANDARD base64, identical to {@code encodeObjectToString}. */ + private static String feEncode(Object obj) throws Exception { + byte[] bytes = InstantiationUtil.serializeObject(obj); + return new String(Base64.getEncoder().encode(bytes), StandardCharsets.UTF_8); + } + + /** + * BE side: mirrors {@code PaimonUtils.deserialize} in be-java-extensions/paimon-scanner. BE + * tries the URL-safe decoder FIRST and falls back to the STANDARD decoder on + * {@link IllegalArgumentException} (the URL-safe decoder rejects the '+'/'/' a STANDARD payload + * may contain, which is exactly what triggers the fallback), then deserializes with the + * scanner's own classloader. Reproducing that branch here keeps the smoke faithful to the real + * BE decode path rather than just the STANDARD leg. + */ + private static T beDecode(String encoded) throws Exception { + byte[] enc = encoded.getBytes(StandardCharsets.UTF_8); + byte[] bytes; + try { + bytes = Base64.getUrlDecoder().decode(enc); + } catch (IllegalArgumentException urlReject) { + bytes = Base64.getDecoder().decode(enc); + } + return InstantiationUtil.deserializeObject(bytes, PaimonTableSerdeRoundTripTest.class.getClassLoader()); + } + + private static Catalog buildLocalCatalog(Path warehouse) { + // A real FileSystemCatalog over paimon's bundled LocalFileIO — this is exactly the catalog + // CatalogFactory.createCatalog builds for a file:// warehouse (the production + // PaimonConnector.createCatalog path: Options{warehouse=file://...} -> filesystem flavor -> + // FileSystemCatalog(LocalFileIO, warehousePath)). We construct it directly here only to keep + // the test classpath hadoop-free: CatalogContext.create(Options) statically references + // org.apache.hadoop.conf.Configuration, which is present in fe-core at runtime but is NOT a + // dependency of the connector test module. The resolved Table and the serde under test are + // identical either way — the catalog wrapper is not what this smoke exercises. + org.apache.paimon.fs.Path warehousePath = new org.apache.paimon.fs.Path(warehouse.toUri()); + return new FileSystemCatalog(LocalFileIO.create(), warehousePath); + } + + private static Schema partitionedKeyedSchema() { + // Partitioned + primary-keyed table. Paimon requires every partition field to also be a + // primary-key field, and a keyed table needs a fixed bucket count. + return Schema.newBuilder() + .column("id", DataTypes.INT()) + .column("dt", DataTypes.STRING()) + .column("region", DataTypes.STRING()) + .column("val", DataTypes.BIGINT()) + .partitionKeys("dt") + .primaryKey("id", "dt") + .option("bucket", "2") + .build(); + } + + @Test + public void serializedTableRoundTripsThroughConnectorMechanism(@TempDir Path warehouse) throws Exception { + Table original; + try (Catalog catalog = buildLocalCatalog(warehouse)) { + catalog.createDatabase(DB, false); + Identifier id = Identifier.create(DB, TBL); + catalog.createTable(id, partitionedKeyedSchema(), false); + // The same resolution the connector metadata does: catalog.getTable(Identifier) -> a + // real FileStoreTable instance (NOT a hand-rolled double). + original = catalog.getTable(id); + } + + // Sanity: we are exercising a genuine resolved table, not a stub. + RowType originalRowType = original.rowType(); + Assertions.assertEquals(Arrays.asList("id", "dt", "region", "val"), + originalRowType.getFieldNames(), + "precondition: resolved a real partitioned/keyed FileStoreTable to serialize"); + Assertions.assertEquals(Collections.singletonList("dt"), original.partitionKeys()); + Assertions.assertEquals(Arrays.asList("id", "dt"), original.primaryKeys()); + + // FE encodes for the wire exactly as the connector ships it to BE. + String wire = feEncode(original); + Assertions.assertFalse(wire.isEmpty(), "encoded table payload must not be empty"); + + // BE reconstitutes the table from the same payload, running the identical paimon 1.3.1. + Table roundTripped = beDecode(wire); + + // WHY rowType()/partitionKeys()/primaryKeys() are the load-bearing identity: BE's JNI + // reader rebuilds its scan + schema-projection off exactly these from the deserialized + // table. If serialization drops or mangles them (non-serializable field, version drift, + // base64 variant mismatch) the BE read silently returns wrong columns/rows. MUTATION: + // swapping Base64.getEncoder() for getUrlEncoder(), or skipping InstantiationUtil, breaks + // the decode -> red. + Assertions.assertEquals(originalRowType.getFieldNames(), + roundTripped.rowType().getFieldNames(), + "round-tripped table must preserve column names/order"); + Assertions.assertEquals(originalRowType.getFieldTypes(), + roundTripped.rowType().getFieldTypes(), + "round-tripped table must preserve column types"); + Assertions.assertEquals(original.partitionKeys(), roundTripped.partitionKeys(), + "round-tripped table must preserve partition keys (partition pruning depends on this)"); + Assertions.assertEquals(original.primaryKeys(), roundTripped.primaryKeys(), + "round-tripped table must preserve primary keys (bucketing/keyed read depends on this)"); + } + + @Test + public void standardBase64LegRoundTripsSerializedBytesVerbatim(@TempDir Path warehouse) throws Exception { + // Locks the byte-level STANDARD base64 leg in isolation: the FE encoder (Base64.getEncoder, + // STANDARD) produces a payload that a STANDARD decoder reconstitutes byte-for-byte. BE + // decodes by trying the URL-safe decoder first; getUrlDecoder() THROWS + // IllegalArgumentException on a '+'/'/' it cannot accept (it does not silently corrupt), + // which is precisely what triggers BE's STANDARD fallback (see beDecode). This test pins the + // STANDARD leg that fallback lands on; the object-level round trip above covers the full + // BE decode branch. + Table original; + try (Catalog catalog = buildLocalCatalog(warehouse)) { + catalog.createDatabase(DB, false); + Identifier id = Identifier.create(DB, TBL); + catalog.createTable(id, partitionedKeyedSchema(), false); + original = catalog.getTable(id); + } + + byte[] raw = InstantiationUtil.serializeObject(original); + String standard = new String(Base64.getEncoder().encode(raw), StandardCharsets.UTF_8); + byte[] decoded = Base64.getDecoder().decode(standard.getBytes(StandardCharsets.UTF_8)); + + // The STANDARD round-trip must reproduce the byte stream verbatim. + Assertions.assertArrayEquals(raw, decoded, + "STANDARD base64 must round-trip the serialized table bytes verbatim"); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonTypeMappingReadTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonTypeMappingReadTest.java new file mode 100644 index 00000000000000..eebaef8635f07f --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonTypeMappingReadTest.java @@ -0,0 +1,57 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.ConnectorType; + +import org.apache.paimon.types.VarCharType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * P5-fix FIX-VARCHAR-BOUNDARY (review §5 N10.1) — pins the read-direction VARCHAR length boundary + * in {@link PaimonTypeMapping#toConnectorType} to byte-parity with legacy + * {@code PaimonUtil.paimonPrimitiveTypeToDorisType}. + */ +public class PaimonTypeMappingReadTest { + + private static ConnectorType mapVarchar(int len) { + return PaimonTypeMapping.toConnectorType(new VarCharType(len), PaimonTypeMapping.Options.DEFAULT); + } + + @Test + public void varcharMaxLengthBoundaryMatchesLegacy() { + // WHY: 65533 (== ScalarType.MAX_VARCHAR_LENGTH) is the legal exact-fit max VARCHAR, NOT the + // STRING wildcard. Legacy uses `> 65533`, so 65533 must stay VARCHAR(65533); only a length + // strictly greater overflows to STRING. The plugin path must agree byte-for-byte so that + // DESCRIBE / SHOW CREATE TABLE report the same column type as legacy paimon. + // MUTATION: reverting the boundary to `>= 65533` widens the 65533 case to STRING -> red. + + ConnectorType below = mapVarchar(65532); + Assertions.assertEquals("VARCHAR", below.getTypeName()); + Assertions.assertEquals(65532, below.getPrecision()); + + ConnectorType exact = mapVarchar(65533); + Assertions.assertEquals("VARCHAR", exact.getTypeName(), + "VARCHAR(65533) is the exact-fit max VARCHAR and must not widen to STRING"); + Assertions.assertEquals(65533, exact.getPrecision()); + + ConnectorType above = mapVarchar(65534); + Assertions.assertEquals("STRING", above.getTypeName(), "length > 65533 overflows to STRING"); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonTypeMappingToPaimonTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonTypeMappingToPaimonTest.java new file mode 100644 index 00000000000000..09b71632e1fd52 --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonTypeMappingToPaimonTest.java @@ -0,0 +1,227 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.DorisConnectorException; + +import org.apache.paimon.types.ArrayType; +import org.apache.paimon.types.BigIntType; +import org.apache.paimon.types.BooleanType; +import org.apache.paimon.types.DataField; +import org.apache.paimon.types.DataType; +import org.apache.paimon.types.DateType; +import org.apache.paimon.types.DecimalType; +import org.apache.paimon.types.DoubleType; +import org.apache.paimon.types.FloatType; +import org.apache.paimon.types.IntType; +import org.apache.paimon.types.MapType; +import org.apache.paimon.types.RowType; +import org.apache.paimon.types.TimestampType; +import org.apache.paimon.types.VarBinaryType; +import org.apache.paimon.types.VarCharType; +import org.apache.paimon.types.VariantType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +/** + * P5-T11 — pins the Doris->Paimon reverse type mapping in + * {@link PaimonTypeMapping#toPaimonType} to byte-parity with the legacy fe-core + * {@code DorisToPaimonTypeVisitor}. + * + *

    The CREATE TABLE path produces these {@link ConnectorType} descriptors; this mapping is + * what decides the on-disk Paimon column type, so any drift silently changes the physical schema + * of newly created tables.

    + */ +public class PaimonTypeMappingToPaimonTest { + + @Test + public void scalarPrimitivesMapExactly() { + // WHY: the narrow scalar set is the legacy contract; each must produce the exact paimon + // no-arg type. MUTATION: swapping e.g. INT -> BigIntType, or adding precision to a no-arg + // type, changes the persisted column type and turns these red. + Assertions.assertEquals(new BooleanType(), PaimonTypeMapping.toPaimonType(ConnectorType.of("BOOLEAN"))); + Assertions.assertEquals(new IntType(), PaimonTypeMapping.toPaimonType(ConnectorType.of("INT"))); + Assertions.assertEquals(new IntType(), PaimonTypeMapping.toPaimonType(ConnectorType.of("INTEGER"))); + Assertions.assertEquals(new BigIntType(), PaimonTypeMapping.toPaimonType(ConnectorType.of("BIGINT"))); + Assertions.assertEquals(new FloatType(), PaimonTypeMapping.toPaimonType(ConnectorType.of("FLOAT"))); + Assertions.assertEquals(new DoubleType(), PaimonTypeMapping.toPaimonType(ConnectorType.of("DOUBLE"))); + Assertions.assertEquals(new DateType(), PaimonTypeMapping.toPaimonType(ConnectorType.of("DATE"))); + Assertions.assertEquals(new DateType(), PaimonTypeMapping.toPaimonType(ConnectorType.of("DATEV2"))); + Assertions.assertEquals(new VariantType(), PaimonTypeMapping.toPaimonType(ConnectorType.of("VARIANT"))); + } + + @Test + public void charFamilyCollapsesToVarcharMaxDroppingLength() { + // WHY: legacy isCharFamily -> VarCharType(MAX_LENGTH) unconditionally; the declared length + // is intentionally dropped and CHAR is NOT mapped to paimon CharType. MUTATION: honoring + // the declared length (e.g. new VarCharType(10)) or mapping CHAR -> CharType makes these red. + DataType expected = new VarCharType(VarCharType.MAX_LENGTH); + Assertions.assertEquals(expected, PaimonTypeMapping.toPaimonType(ConnectorType.of("CHAR", 10, 0))); + Assertions.assertEquals(expected, PaimonTypeMapping.toPaimonType(ConnectorType.of("VARCHAR", 20, 0))); + Assertions.assertEquals(expected, PaimonTypeMapping.toPaimonType(ConnectorType.of("STRING"))); + } + + @Test + public void datetimeDropsScaleToNoArgTimestamp() { + // WHY: legacy maps DATETIME/DATETIMEV2 -> new TimestampType() (no-arg, precision 6); the + // requested datetime scale is intentionally dropped, and it is a plain timestamp not a + // zoned one. MUTATION: propagating the scale (new TimestampType(scale)) or using + // LocalZonedTimestampType makes this red. + TimestampType expected = new TimestampType(); + Assertions.assertEquals(6, expected.getPrecision(), "no-arg TimestampType must default to precision 6"); + Assertions.assertEquals(expected, + PaimonTypeMapping.toPaimonType(ConnectorType.of("DATETIMEV2", 3, 0)), + "DATETIMEV2(scale 3) must drop the scale -> TimestampType() precision 6"); + Assertions.assertEquals(expected, PaimonTypeMapping.toPaimonType(ConnectorType.of("DATETIME"))); + } + + @Test + public void decimalCarriesPrecisionAndScale() { + // WHY: every decimal family member carries precision/scale through verbatim. MUTATION: + // hardcoding a precision/scale, or swapping the two args, turns this red. + Assertions.assertEquals(new DecimalType(18, 4), + PaimonTypeMapping.toPaimonType(ConnectorType.of("DECIMAL64", 18, 4))); + Assertions.assertEquals(new DecimalType(9, 2), + PaimonTypeMapping.toPaimonType(ConnectorType.of("DECIMAL32", 9, 2))); + Assertions.assertEquals(new DecimalType(27, 9), + PaimonTypeMapping.toPaimonType(ConnectorType.of("DECIMALV2", 27, 9))); + } + + @Test + public void varbinaryMapsToVarBinaryMax() { + // WHY: legacy isVarbinaryType -> VarBinaryType(MAX_LENGTH). MUTATION: honoring a declared + // length or mapping to BinaryType makes this red. + Assertions.assertEquals(new VarBinaryType(VarBinaryType.MAX_LENGTH), + PaimonTypeMapping.toPaimonType(ConnectorType.of("VARBINARY", 16, 0))); + } + + @Test + public void arrayRecursesElement() { + // WHY: ARRAY must wrap the recursively mapped element. MUTATION: dropping the recursion + // (e.g. wrapping a raw VarChar) or losing the element type makes this red. + Assertions.assertEquals(new ArrayType(new IntType()), + PaimonTypeMapping.toPaimonType(ConnectorType.arrayOf(ConnectorType.of("INT")))); + } + + @Test + public void mapForcesNonNullKey() { + // WHY: legacy MAP forces the key non-null via keyResult.copy(false) while the value keeps + // the paimon default (nullable). This is part of the type structure, not column nullability. + // MUTATION: dropping the .copy(false) on the key (so the key is nullable) makes this red. + DataType actual = PaimonTypeMapping.toPaimonType( + ConnectorType.mapOf(ConnectorType.of("STRING"), ConnectorType.of("INT"))); + MapType expected = new MapType( + new VarCharType(VarCharType.MAX_LENGTH).copy(false), new IntType()); + Assertions.assertEquals(expected, actual); + Assertions.assertFalse(((MapType) actual).getKeyType().isNullable(), + "the map key type must be non-null (legacy .copy(false) parity)"); + } + + @Test + public void structBuildsSequentialFieldIdsAndNames() { + // WHY: STRUCT must build DataFields with sequential ids 0,1,... (legacy AtomicInteger(-1) + // incrementAndGet) and names from getFieldNames, recursing each field type. MUTATION: a + // wrong starting id (e.g. 1), reused ids, or losing a field name turns this red. + ConnectorType struct = ConnectorType.structOf( + Arrays.asList("a", "b"), + Arrays.asList(ConnectorType.of("INT"), ConnectorType.of("STRING"))); + RowType row = (RowType) PaimonTypeMapping.toPaimonType(struct); + + DataField f0 = new DataField(0, "a", new IntType()); + DataField f1 = new DataField(1, "b", new VarCharType(VarCharType.MAX_LENGTH)); + Assertions.assertEquals(new RowType(Arrays.asList(f0, f1)), row); + Assertions.assertEquals(0, row.getFields().get(0).id(), "first struct field id must be 0"); + Assertions.assertEquals(1, row.getFields().get(1).id(), "second struct field id must be 1"); + } + + @Test + public void nestedNullabilityPreservedForArrayElement() { + // WHY (FIX-L13): a declared NOT NULL element (ARRAY) must map to a non-null paimon + // element type (legacy DorisToPaimonTypeVisitor array = elementResult.copy(array.getContainsNull())). + // MUTATION: dropping the .copy(isChildNullable(0)) on the ARRAY element leaves it nullable -> red. + DataType actual = PaimonTypeMapping.toPaimonType( + ConnectorType.arrayOf(ConnectorType.of("INT"), /*elementNullable*/ false)); + Assertions.assertFalse(((ArrayType) actual).getElementType().isNullable(), + "a NOT NULL array element must map to a non-null paimon element type"); + } + + @Test + public void nestedNullabilityPreservedForMapValue() { + // WHY (FIX-L13): a declared NOT NULL map value (MAP) must map to a non-null + // paimon value type (legacy map value = valueResult.copy(map.getIsValueContainsNull())), while the + // key stays non-null. MUTATION: dropping the .copy(isChildNullable(1)) on the MAP value -> red. + DataType actual = PaimonTypeMapping.toPaimonType(ConnectorType.mapOf( + ConnectorType.of("STRING"), ConnectorType.of("INT"), /*valueNullable*/ false)); + Assertions.assertFalse(((MapType) actual).getValueType().isNullable(), + "a NOT NULL map value must map to a non-null paimon value type"); + Assertions.assertFalse(((MapType) actual).getKeyType().isNullable(), + "the map key stays non-null regardless (legacy .copy(false))"); + } + + @Test + public void nestedNullabilityPreservedForStructField() { + // WHY (FIX-L13): declared per-field nullability (STRUCT) must survive to + // the paimon DataField types (legacy struct = fieldResults.get(i).copy(field.getContainsNull())). + // Asserted via field.type().isNullable() — NOT DataField equality: the field comment stays dropped + // (DV-035 M10.1), so a DataField carrying a description would false-differ for an unrelated reason. + // MUTATION: dropping the .copy(isChildNullable(i)) on the struct DataField -> field 0 stays nullable. + ConnectorType struct = ConnectorType.structOf( + Arrays.asList("x", "y"), + Arrays.asList(ConnectorType.of("INT"), ConnectorType.of("STRING")), + Arrays.asList(false, true), + Collections.emptyList()); + RowType row = (RowType) PaimonTypeMapping.toPaimonType(struct); + Assertions.assertFalse(row.getFields().get(0).type().isNullable(), + "a NOT NULL struct field must map to a non-null paimon field type"); + Assertions.assertTrue(row.getFields().get(1).type().isNullable(), + "a nullable struct field stays nullable"); + } + + @Test + public void unsupportedScalarTypesThrow() { + // WHY: the legacy visitor had no branch for these and threw; the connector preserves that + // gap by throwing DorisConnectorException rather than inventing a mapping. MUTATION: adding + // a TINYINT/SMALLINT/LARGEINT/TIME/TIMESTAMPTZ branch (silently widening support) would + // make the corresponding assertion red. + for (String unsupported : new String[] {"TINYINT", "SMALLINT", "LARGEINT", "TIMEV2", "TIMESTAMPTZ"}) { + Assertions.assertThrows(DorisConnectorException.class, + () -> PaimonTypeMapping.toPaimonType(ConnectorType.of(unsupported)), + unsupported + " must throw (legacy gap preserved)"); + } + } + + @Test + public void nestedUnsupportedTypePropagatesThrow() { + // WHY: an unsupported element nested inside a complex type must still fail-fast, proving the + // throw is reached through the recursion (not swallowed). MUTATION: catching/degrading the + // nested throw inside array/map/struct handling would make this red. + ConnectorType arrayOfTinyint = ConnectorType.arrayOf(ConnectorType.of("TINYINT")); + Assertions.assertThrows(DorisConnectorException.class, + () -> PaimonTypeMapping.toPaimonType(arrayOfTinyint)); + + ConnectorType structWithBadField = ConnectorType.structOf( + Collections.singletonList("x"), + Collections.singletonList(ConnectorType.of("JSON"))); + Assertions.assertThrows(DorisConnectorException.class, + () -> PaimonTypeMapping.toPaimonType(structWithBadField)); + } +} 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 new file mode 100644 index 00000000000000..04118b5ef8e85e --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/RecordingConnectorContext.java @@ -0,0 +1,111 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.filesystem.properties.StorageProperties; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; + +/** + * Hand-written {@link ConnectorContext} test double (no Mockito) used to assert that the + * Paimon DDL path wraps every remote call in {@link #executeAuthenticated}. + * + *

    Read-path tests just pass a fresh instance and ignore it. DDL tests assert on + * {@link #authCount} (one wrap per DDL op) and use {@link #failAuth} to simulate an auth + * failure: when set, {@link #executeAuthenticated} throws WITHOUT invoking the task, which + * 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 { + + int authCount; + boolean failAuth; + + // ---- sibling-connector seam hook (proves the decorator delegates createSiblingConnector) ---- + /** The type the wrapper forwarded to {@link #createSiblingConnector}. */ + String lastSiblingType; + /** The properties the wrapper forwarded to {@link #createSiblingConnector}. */ + Map lastSiblingProps; + + @Override + public Connector createSiblingConnector(String catalogType, Map properties) { + lastSiblingType = catalogType; + lastSiblingProps = properties; + return null; + } + + // ---- C2: getStorageProperties hook (FE-bound fe-filesystem storage props) ---- + /** Storage properties the fake returns from {@link #getStorageProperties()} (default: none). */ + List storageProperties = Collections.emptyList(); + + // ---- FIX-URI-NORMALIZE / FIX-REST-VENDED-URI-NORMALIZE: normalizeStorageUri hook ---- + /** Number of times the connector invoked {@link #normalizeStorageUri}. */ + int normalizeCount; + /** The vended token the connector passed to the most recent 2-arg {@link #normalizeStorageUri}. */ + Map lastVendedToken; + + @Override + public String getCatalogName() { + return "test"; + } + + @Override + public List getStorageProperties() { + return storageProperties; + } + + @Override + public String normalizeStorageUri(String rawUri) { + // The 1-arg form folds to the 2-arg with no token, so every caller path is recorded identically. + return normalizeStorageUri(rawUri, null); + } + + @Override + public String normalizeStorageUri(String rawUri, Map vendedToken) { + normalizeCount++; + lastVendedToken = vendedToken; + // Deterministic stand-in for the engine's oss://->s3:// scheme rewrite, so a connector wiring + // test can prove BOTH the data-file and DV paths were routed through this hook AND that the + // per-table vended token is threaded to each (the real normalization is covered by + // DefaultConnectorContextNormalizeUriTest in fe-core). + if (rawUri != null && rawUri.startsWith("oss://")) { + return "s3://" + rawUri.substring("oss://".length()); + } + return rawUri; + } + + @Override + public long getCatalogId() { + return 0; + } + + @Override + public T executeAuthenticated(Callable task) throws Exception { + authCount++; + if (failAuth) { + // Deliberately do NOT call task -> the wrapped seam call must not run. + throw new RuntimeException("auth failed"); + } + return task.call(); + } +} diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/RecordingPaimonCatalogOps.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/RecordingPaimonCatalogOps.java new file mode 100644 index 00000000000000..1b9915c9dec9ae --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/RecordingPaimonCatalogOps.java @@ -0,0 +1,318 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.paimon.catalog.Catalog; +import org.apache.paimon.catalog.Database; +import org.apache.paimon.catalog.Identifier; +import org.apache.paimon.partition.Partition; +import org.apache.paimon.schema.Schema; +import org.apache.paimon.table.Table; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.OptionalLong; + +/** + * Hand-written recording fake for {@link PaimonCatalogOps} (no Mockito), mirroring the + * maxcompute connector's recording {@code McStructureHelper}. + * + *

    Records an ordered call log, returns configurable fixed data, and can be told to throw + * the paimon {@code DatabaseNotExistException} / {@code TableNotExistException} (and the B3 + * DDL exceptions) that the production code catches/wraps. Because the seam fully covers every + * remote call {@link PaimonConnectorMetadata} makes, the metadata under test is built with a + * {@code null} real Catalog — the test stays entirely offline. + */ +final class RecordingPaimonCatalogOps implements PaimonCatalogOps { + + final List log = new ArrayList<>(); + + List databases = new ArrayList<>(); + List tables = new ArrayList<>(); + Table table; + List partitions = new ArrayList<>(); + + /** The Identifier the metadata layer passed to the most recent {@link #getTable} call. */ + Identifier lastGetTableId; + /** + * Optional override returned by {@link #getTable} when the requested Identifier carries a + * system-table name (4-arg sys Identifier). When unset, {@link #table} is returned for both + * base and sys lookups. + */ + Table sysTable; + /** + * Optional override returned by {@link #getTable} when the requested Identifier denotes a real + * (non-main, non-sys) branch (3-arg branch Identifier). When set, a branch load returns a + * DIFFERENT table double than the base {@link #table}, so a branch read can be proven to operate + * on the branch's own schema/snapshots. When unset, {@link #table} is returned. + */ + Table branchTable; + + boolean throwDatabaseNotExist; + boolean throwTableNotExist; + + // ---- B3 DDL capture fields (inputs the metadata layer passed to the seam) ---- + Schema lastCreatedSchema; + Identifier lastCreatedTableId; + boolean lastCreateTableIgnoreIfExists; + Identifier lastDroppedTableId; + boolean lastDropTableIgnoreIfNotExists; + String lastCreatedDb; + Map lastCreatedDbProps; + boolean lastCreateDbIgnoreIfExists; + String lastDroppedDb; + boolean lastDropCascade; + boolean lastDropDbIgnoreIfNotExists; + + // ---- B3 DDL throw flags (mirror the read-path throwDatabaseNotExist/throwTableNotExist) ---- + boolean throwTableAlreadyExist; + boolean throwTableNotExistOnDrop; + boolean throwDatabaseAlreadyExist; + boolean throwDatabaseNotEmpty; + boolean throwDatabaseNotExistOnDrop; + + // ---- T20 E5 MVCC seam: configurable lookup results (no real Snapshot/SnapshotManager) ---- + OptionalLong latestSnapshotId = OptionalLong.empty(); + OptionalLong snapshotIdAtOrBefore = OptionalLong.empty(); + boolean snapshotExists; + /** The table the metadata layer passed to the most recent MVCC seam call. */ + Table lastMvccTable; + /** The timestamp (millis) the metadata layer passed to the most recent snapshotIdAtOrBefore. */ + long snapshotIdAtOrBeforeArg; + + // ---- B5b-2a explicit time-travel seam: configurable results + call capture ---- + /** schemaId returned by snapshotSchemaId (default empty => stamps -1). */ + OptionalLong snapshotSchemaId = OptionalLong.empty(); + /** tag resolution returned by getSnapshotByTag (default null => empty => not found). */ + PaimonCatalogOps.TagSnapshot tagSnapshot; + /** schema returned by schemaAt (set per-test to drive the at-schemaId column mapping). */ + PaimonCatalogOps.PaimonSchemaSnapshot schemaAt; + /** latest schema returned by latestSchema (default empty => caller falls back to table.rowType()). */ + java.util.Optional latestSchema = java.util.Optional.empty(); + /** The table the metadata layer passed to the most recent latestSchema call. */ + Table lastLatestSchemaTable; + /** The arguments the metadata layer passed to the most recent time-travel seam call. */ + long lastSnapshotSchemaIdArg; + String lastTagNameArg; + long lastSchemaAtArg; + + // ---- B5b-2c branch time-travel seam: configurable result + call capture ---- + /** Whether the configured branch is reported to exist by {@link #branchExists}. */ + boolean branchExists; + /** The branch name the metadata layer passed to the most recent {@link #branchExists} call. */ + String lastBranchExistsArg; + /** The base table the metadata layer passed to the most recent {@link #branchExists} call. */ + Table lastBranchExistsTable; + + // ---- FIX-TABLE-STATS: row-count seam ---- + /** Configurable row count returned by {@link #rowCount}. */ + long rowCount; + /** The table the metadata layer passed to the most recent {@link #rowCount} call. */ + Table lastRowCountTable; + /** When set, {@link #rowCount} throws (drives the best-effort planning-failure path). */ + boolean throwOnRowCount; + + @Override + public List listDatabases() { + log.add("listDatabases"); + return databases; + } + + @Override + public Database getDatabase(String name) throws Catalog.DatabaseNotExistException { + log.add("getDatabase:" + name); + if (throwDatabaseNotExist) { + throw new Catalog.DatabaseNotExistException(name); + } + // databaseExists ignores the returned Database (only the throw/no-throw matters), + // so a null is sufficient and keeps the fake free of a Database double. + return null; + } + + @Override + public List listTables(String databaseName) throws Catalog.DatabaseNotExistException { + log.add("listTables:" + databaseName); + if (throwDatabaseNotExist) { + throw new Catalog.DatabaseNotExistException(databaseName); + } + return tables; + } + + @Override + public Table getTable(Identifier identifier) throws Catalog.TableNotExistException { + log.add("getTable:" + identifier.getFullName()); + lastGetTableId = identifier; + if (throwTableNotExist) { + throw new Catalog.TableNotExistException(identifier); + } + // A 4-arg sys Identifier carries a non-null system-table name; serve sysTable when set so + // sys-handle schema/columns can be built from a DIFFERENT rowType than the base table. + if (sysTable != null && identifier.getSystemTableName() != null) { + return sysTable; + } + // A 3-arg branch Identifier carries a non-"main" branch and no system-table name; serve + // branchTable when set so a branch load returns a DIFFERENT table double than the base. + // getBranchNameOrDefault() returns "main" for a base/sys identifier and the real branch name + // for a 3-arg branch identifier — robustly distinguishing the branch load. + if (branchTable != null + && identifier.getSystemTableName() == null + && !"main".equals(identifier.getBranchNameOrDefault())) { + return branchTable; + } + return table; + } + + @Override + public List listPartitions(Identifier identifier) throws Catalog.TableNotExistException { + log.add("listPartitions:" + identifier.getFullName()); + if (throwTableNotExist) { + throw new Catalog.TableNotExistException(identifier); + } + return partitions; + } + + @Override + public void createDatabase(String name, boolean ignoreIfExists, Map properties) + throws Catalog.DatabaseAlreadyExistException { + log.add("createDatabase:" + name); + lastCreatedDb = name; + lastCreateDbIgnoreIfExists = ignoreIfExists; + lastCreatedDbProps = properties; + if (throwDatabaseAlreadyExist) { + throw new Catalog.DatabaseAlreadyExistException(name); + } + } + + @Override + public void dropDatabase(String name, boolean ignoreIfNotExists, boolean cascade) + throws Catalog.DatabaseNotExistException, Catalog.DatabaseNotEmptyException { + log.add("dropDatabase:" + name + ",cascade=" + cascade); + lastDroppedDb = name; + lastDropDbIgnoreIfNotExists = ignoreIfNotExists; + lastDropCascade = cascade; + if (throwDatabaseNotExistOnDrop) { + throw new Catalog.DatabaseNotExistException(name); + } + if (throwDatabaseNotEmpty) { + throw new Catalog.DatabaseNotEmptyException(name); + } + } + + @Override + public void createTable(Identifier identifier, Schema schema, boolean ignoreIfExists) + throws Catalog.TableAlreadyExistException, Catalog.DatabaseNotExistException { + log.add("createTable:" + identifier.getFullName()); + lastCreatedTableId = identifier; + lastCreatedSchema = schema; + lastCreateTableIgnoreIfExists = ignoreIfExists; + if (throwTableAlreadyExist) { + throw new Catalog.TableAlreadyExistException(identifier); + } + } + + @Override + public void dropTable(Identifier identifier, boolean ignoreIfNotExists) + throws Catalog.TableNotExistException { + log.add("dropTable:" + identifier.getFullName()); + lastDroppedTableId = identifier; + lastDropTableIgnoreIfNotExists = ignoreIfNotExists; + if (throwTableNotExistOnDrop || throwTableNotExist) { + throw new Catalog.TableNotExistException(identifier); + } + } + + @Override + public OptionalLong latestSnapshotId(Table table) { + log.add("latestSnapshotId"); + lastMvccTable = table; + return latestSnapshotId; + } + + @Override + public OptionalLong snapshotIdAtOrBefore(Table table, long timestampMillis) { + log.add("snapshotIdAtOrBefore:" + timestampMillis); + lastMvccTable = table; + snapshotIdAtOrBeforeArg = timestampMillis; + return snapshotIdAtOrBefore; + } + + @Override + public boolean snapshotExists(Table table, long snapshotId) { + log.add("snapshotExists:" + snapshotId); + lastMvccTable = table; + return snapshotExists; + } + + @Override + public OptionalLong snapshotSchemaId(Table table, long snapshotId) { + log.add("snapshotSchemaId:" + snapshotId); + lastMvccTable = table; + lastSnapshotSchemaIdArg = snapshotId; + return snapshotSchemaId; + } + + @Override + public java.util.Optional getSnapshotByTag(Table table, String tagName) { + log.add("getSnapshotByTag:" + tagName); + lastMvccTable = table; + lastTagNameArg = tagName; + return java.util.Optional.ofNullable(tagSnapshot); + } + + @Override + public PaimonCatalogOps.PaimonSchemaSnapshot schemaAt(Table table, long schemaId) { + log.add("schemaAt:" + schemaId); + lastMvccTable = table; + lastSchemaAtArg = schemaId; + return schemaAt; + } + + @Override + public java.util.Optional latestSchema(Table table) { + log.add("latestSchema"); + lastLatestSchemaTable = table; + return latestSchema; + } + + @Override + public boolean branchExists(Table table, String branchName) { + log.add("branchExists:" + branchName); + // Capture which table validation ran against (must be the BASE table, mirroring legacy + // resolvePaimonBranch which validates the branch on the base table's branchManager). + // Kept in a DEDICATED field so lastMvccTable stays a pure MVCC-seam artifact. + lastBranchExistsTable = table; + lastBranchExistsArg = branchName; + return branchExists; + } + + @Override + public long rowCount(Table table) { + log.add("rowCount"); + lastRowCountTable = table; + if (throwOnRowCount) { + throw new RuntimeException("simulated planning failure"); + } + return rowCount; + } + + @Override + public void close() { + log.add("close"); + } +} 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 new file mode 100644 index 00000000000000..1eee7e0052ac2c --- /dev/null +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/TcclPinningConnectorContextTest.java @@ -0,0 +1,171 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.paimon; + +import org.apache.doris.kerberos.HadoopAuthenticator; + +import org.apache.hadoop.security.UserGroupInformation; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.security.PrivilegedExceptionAction; +import java.util.Collections; +import java.util.Map; + +/** + * Verifies the {@link TcclPinningConnectorContext} decorator the paimon connector wraps its context in: the op + * runs under the plugin loader, the caller's TCCL is always restored, and (single-owner auth) a Kerberos + * catalog runs the op under the plugin authenticator's {@code doAs} WITHOUT also invoking the FE-injected + * app-side authenticator. Mirrors the iceberg connector's {@code TcclPinningConnectorContextTest}; paimon has + * no live Kerberos regression suite, so this wiring test is the offline gate. + */ +public class TcclPinningConnectorContextTest { + + private static ClassLoader isolatedLoader() { + return new URLClassLoader(new URL[0], TcclPinningConnectorContextTest.class.getClassLoader()); + } + + @Test + public void nonKerberosPinsPluginLoaderThenRestoresAndDelegatesAuth() throws Exception { + ClassLoader pluginLoader = isolatedLoader(); + ClassLoader callerLoader = isolatedLoader(); + RecordingConnectorContext delegate = new RecordingConnectorContext(); + TcclPinningConnectorContext ctx = new TcclPinningConnectorContext(delegate, pluginLoader, () -> null); + + Thread thread = Thread.currentThread(); + ClassLoader saved = thread.getContextClassLoader(); + thread.setContextClassLoader(callerLoader); + try { + ClassLoader[] seenDuringTask = new ClassLoader[1]; + String result = ctx.executeAuthenticated(() -> { + seenDuringTask[0] = Thread.currentThread().getContextClassLoader(); + return "ok"; + }); + + Assertions.assertEquals("ok", result); + Assertions.assertSame(pluginLoader, seenDuringTask[0], + "the op must run with the TCCL pinned to the plugin loader"); + Assertions.assertSame(callerLoader, thread.getContextClassLoader(), + "the caller's TCCL must be restored after the call"); + Assertions.assertEquals(1, delegate.authCount, + "non-Kerberos (null authenticator) must delegate to the FE-injected executeAuthenticated"); + } finally { + thread.setContextClassLoader(saved); + } + } + + @Test + public void restoresCallerTcclWhenTheTaskThrows() { + ClassLoader pluginLoader = isolatedLoader(); + ClassLoader callerLoader = isolatedLoader(); + TcclPinningConnectorContext ctx = + new TcclPinningConnectorContext(new RecordingConnectorContext(), pluginLoader, () -> null); + + Thread thread = Thread.currentThread(); + ClassLoader saved = thread.getContextClassLoader(); + thread.setContextClassLoader(callerLoader); + try { + Assertions.assertThrows(IllegalStateException.class, () -> + ctx.executeAuthenticated(() -> { + throw new IllegalStateException("boom"); + })); + Assertions.assertSame(callerLoader, thread.getContextClassLoader(), + "the caller's TCCL must be restored even when the task throws"); + } finally { + thread.setContextClassLoader(saved); + } + } + + @Test + public void kerberosRunsTaskInPluginDoAsAndBypassesDelegateAuth() throws Exception { + // Single-owner auth (Option A): a Kerberos catalog runs the op under the PLUGIN authenticator's doAs and + // must NOT ALSO invoke the FE-injected app-side authenticator (delegate.executeAuthenticated), which only + // authenticates the unused app-loader UGI copy. WHY it matters: the plugin's FileSystem reads the plugin + // UGI, so the plugin doAs is the only auth that reaches secured HDFS. MUTATION: nesting inside the + // delegate (authCount == 1) or skipping the plugin doAs (doAsCount == 0) -> red. + ClassLoader pluginLoader = isolatedLoader(); + ClassLoader callerLoader = isolatedLoader(); + RecordingConnectorContext delegate = new RecordingConnectorContext(); + RecordingAuthenticator auth = new RecordingAuthenticator(); + TcclPinningConnectorContext ctx = new TcclPinningConnectorContext(delegate, pluginLoader, () -> auth); + + Thread thread = Thread.currentThread(); + ClassLoader saved = thread.getContextClassLoader(); + thread.setContextClassLoader(callerLoader); + try { + ClassLoader[] seenDuringTask = new ClassLoader[1]; + String result = ctx.executeAuthenticated(() -> { + seenDuringTask[0] = Thread.currentThread().getContextClassLoader(); + return "ok"; + }); + + Assertions.assertEquals("ok", result); + Assertions.assertEquals(1, auth.doAsCount, "the op must run inside the plugin authenticator's doAs"); + Assertions.assertEquals(0, delegate.authCount, + "single-owner: the FE-injected app-side authenticator must NOT be invoked on the Kerberos path"); + Assertions.assertSame(pluginLoader, seenDuringTask[0], + "the op must still run with the TCCL pinned to the plugin loader"); + Assertions.assertSame(callerLoader, thread.getContextClassLoader(), + "the caller's TCCL must be restored"); + } finally { + thread.setContextClassLoader(saved); + } + } + + @Test + public void delegatesSiblingConnectorToTheRawContext() { + // createSiblingConnector is a non-auth engine-service method: the decorator must forward it to the raw + // delegate (else a wrapped gateway context would return the SPI default null, masking a real sibling as + // "provider missing"). Assert the type + props reach the delegate unchanged. + RecordingConnectorContext delegate = new RecordingConnectorContext(); + TcclPinningConnectorContext ctx = new TcclPinningConnectorContext(delegate, isolatedLoader(), () -> null); + + Map siblingProps = Collections.singletonMap("iceberg.catalog.type", "hms"); + ctx.createSiblingConnector("iceberg", siblingProps); + + Assertions.assertEquals("iceberg", delegate.lastSiblingType, + "createSiblingConnector type must reach the delegate (decorator is an exhaustive pass-through)"); + Assertions.assertSame(siblingProps, delegate.lastSiblingProps, + "createSiblingConnector properties must reach the delegate unchanged"); + } + + /** Wiring-only {@link HadoopAuthenticator} double: records doAs calls and runs the action WITHOUT a UGI. */ + private static final class RecordingAuthenticator implements HadoopAuthenticator { + int doAsCount; + + @Override + public UserGroupInformation getUGI() { + throw new UnsupportedOperationException("wiring double: getUGI is unused (doAs is overridden)"); + } + + @Override + public T doAs(PrivilegedExceptionAction action) throws IOException { + doAsCount++; + try { + return action.run(); + } catch (IOException | RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IOException(e); + } + } + } +} diff --git a/fe/fe-connector/fe-connector-spi/pom.xml b/fe/fe-connector/fe-connector-spi/pom.xml index 670687d6a42728..1c9564ce23f6db 100644 --- a/fe/fe-connector/fe-connector-spi/pom.xml +++ b/fe/fe-connector/fe-connector-spi/pom.xml @@ -50,6 +50,14 @@ under the License. fe-extension-spi ${project.version} + + + ${project.groupId} + fe-filesystem-api + ${project.version} + org.junit.jupiter junit-jupiter 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 new file mode 100644 index 00000000000000..ee55c6cb3c3a2a --- /dev/null +++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorBrokerAddress.java @@ -0,0 +1,47 @@ +// 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; + +/** + * A neutral, immutable broker backend address (host + port) returned by + * {@link ConnectorContext#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 + * connector maps back, keeping this SPI free of Thrift dependencies. + */ +public final class ConnectorBrokerAddress { + + private final String host; + private final int port; + + public ConnectorBrokerAddress(String host, int port) { + this.host = host; + this.port = port; + } + + public String getHost() { + return host; + } + + public int getPort() { + return port; + } +} 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 6c320e2a5fca5d..c38c8a9c91044e 100644 --- a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorContext.java +++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorContext.java @@ -17,11 +17,17 @@ package org.apache.doris.connector.spi; +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. @@ -92,4 +98,318 @@ default String sanitizeJdbcUrl(String jdbcUrl) { 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 + * delegate some tables to another format's connector (e.g. a Hive-metastore catalog whose Iceberg-registered + * tables are served by the Iceberg connector). + * + *

    The engine builds the sibling through the same connector factory it uses for a top-level catalog, so the + * sibling's concrete class is loaded by that type's own plugin classloader — never co-packaged into + * the caller's plugin (a duplicate native stack, e.g. a second AWS SDK, would poison shared JVM state). The + * returned connector shares THIS context (same catalog id, authentication, and storage), so the sibling reuses + * the caller's metastore/storage/credentials without re-deriving them. + * + *

    fe-core stays connector-agnostic: this is a generic "give me a connector of type {@code catalogType} with + * these {@code properties}" factory. The caller (the gateway connector) is responsible for synthesizing the + * sibling's {@code properties} — the engine does not parse or translate them. + * + *

    Cross-plugin type safety. Because the sibling lives in a different (child-first) classloader, it is + * type-compatible with the caller ONLY through the parent-first SPI interfaces ({@link Connector}, + * {@code ConnectorMetadata}, {@code ConnectorTableHandle}, …). The caller MUST hold the result as the bare + * {@link Connector} interface and MUST NOT cast it — or any object it produces — to a concrete connector type, + * or it will {@code ClassCastException} across the loader split. + * + *

    Lifecycle. The engine tracks and closes only a catalog's primary connector; a sibling built + * here is owned by the caller, which MUST forward {@link Connector#close()} to it from its own {@code close()}. + * + *

    The default returns {@code null} (no sibling support), so every connector that is not a gateway — and the + * no-op default context — is unaffected. + * + * @param catalogType the sibling connector's type (e.g. {@code "iceberg"}); resolved by the same provider set + * the engine uses for top-level catalogs + * @param properties the sibling connector's fully-synthesized catalog properties (caller-owned) + * @return the sibling connector, or {@code null} when no provider matches {@code catalogType} (or the engine has + * no connector factory wired — e.g. the default context) + */ + default Connector createSiblingConnector(String catalogType, Map properties) { + return null; + } + + /** + * 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/ConnectorMetaInvalidator.java b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorMetaInvalidator.java new file mode 100644 index 00000000000000..3d94c3c244dc9a --- /dev/null +++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorMetaInvalidator.java @@ -0,0 +1,57 @@ +// 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/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 new file mode 100644 index 00000000000000..4153f73267678c --- /dev/null +++ b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorContextTest.java @@ -0,0 +1,81 @@ +// 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.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 { + + /** A minimal ConnectorContext implementing only the two abstract methods; everything else default. */ + private static ConnectorContext minimalContext() { + return new ConnectorContext() { + @Override + public String getCatalogName() { + return "test_catalog"; + } + + @Override + public long getCatalogId() { + return 1L; + } + }; + } + + @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)); + } + + @Test + public void createSiblingConnector_defaultsToNull() { + // The cross-plugin sibling seam: only a gateway connector's context (fe-core's DefaultConnectorContext) + // overrides this to build a real sibling; every other connector keeps the default null, so introducing + // the seam must not change their behavior -- a non-gateway connector that never calls it is unaffected. + Connector sibling = minimalContext().createSiblingConnector("iceberg", Collections.emptyMap()); + Assertions.assertNull(sibling, + "default createSiblingConnector() must return null so non-gateway connectors are unaffected"); + } +} diff --git a/fe/fe-connector/fe-connector-trino/src/main/assembly/plugin-zip.xml b/fe/fe-connector/fe-connector-trino/src/main/assembly/plugin-zip.xml index 1bc6ac28fba9e7..3323d6f5c1db15 100644 --- a/fe/fe-connector/fe-connector-trino/src/main/assembly/plugin-zip.xml +++ b/fe/fe-connector/fe-connector-trino/src/main/assembly/plugin-zip.xml @@ -52,6 +52,12 @@ under the License. org.apache.doris:fe-connector-api org.apache.doris:fe-connector-spi org.apache.doris:fe-extension-spi + + io.netty:netty-codec-native-quic + + org.apache.logging.log4j:* + org.slf4j:* diff --git a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoBootstrap.java b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoBootstrap.java index 3b7d4892a2dfbc..0329f8423199fa 100644 --- a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoBootstrap.java +++ b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoBootstrap.java @@ -69,6 +69,7 @@ import org.apache.logging.log4j.Logger; import java.io.File; +import java.io.IOException; import java.time.ZoneId; import java.util.Arrays; import java.util.HashMap; @@ -104,8 +105,12 @@ public class TrinoBootstrap { private final HandleResolver handleResolver; private final TypeRegistry typeRegistry; private final TrinoPluginManager pluginManager; + // The plugin dir this singleton was initialized with, retained so a later getInstance() with a + // different dir fails loudly instead of silently reusing the first dir's plugins. + private final String pluginDir; private TrinoBootstrap(String pluginDir) { + this.pluginDir = pluginDir; System.setProperty("jdk.attach.allowAttachSelf", "true"); String osName = System.getProperty("os.name").toLowerCase(Locale.ENGLISH); if (osName.contains("mac") || osName.contains("darwin")) { @@ -141,9 +146,46 @@ public static TrinoBootstrap getInstance(String pluginDir) { } } } + if (!Objects.equals(canonicalize(instance.pluginDir), canonicalize(pluginDir))) { + throw new IllegalStateException(String.format( + "TrinoBootstrap already initialized with plugin dir '%s'; cannot reuse it for a " + + "different plugin dir '%s'. All trino-connector catalogs in one FE must share a " + + "single plugin dir.", instance.pluginDir, pluginDir)); + } return instance; } + /** + * Best-effort canonicalization so two spellings of the same physical directory (trailing slash, + * relative vs absolute, symlink) are treated as equal and do not trip the mismatch guard above. + * Falls back to the raw string if the path cannot be resolved. + */ + private static String canonicalize(String dir) { + if (dir == null) { + return null; + } + try { + return new File(dir).getCanonicalPath(); + } catch (IOException e) { + return dir; + } + } + + /** + * Returns the already-initialized singleton. Callers that run after a catalog has been + * created (e.g. scan planning) use this instead of re-resolving the plugin directory. + * + * @throws IllegalStateException if the singleton has not been initialized yet + */ + public static TrinoBootstrap getInstance() { + TrinoBootstrap local = instance; + if (local == null) { + throw new IllegalStateException( + "TrinoBootstrap is not initialized; a catalog must be created first"); + } + return local; + } + /** * Returns the HandleResolver for JSON serialization of Trino SPI handles. */ @@ -277,30 +319,47 @@ private static void configureJulLogging() { } /** - * Resolves the Trino plugin directory from catalog properties. - * Falls back to DORIS_HOME/plugins/connectors and DORIS_HOME/connectors. + * Resolves the Trino plugin directory. + * + *

    This plugin runs in an isolated classloader and cannot read FE {@code Config} + * (it would see its own bundled copy holding default values). The FE config + * {@code trino_connector_plugin_dir} is therefore passed in through the engine + * environment map (see {@code DefaultConnectorContext}), mirroring how the JDBC + * connector receives {@code jdbc_drivers_dir}. + * + *

    Resolution order: + *

      + *
    1. the per-catalog {@code trino.plugin.dir} property, when set;
    2. + *
    3. otherwise the FE config {@code trino_connector_plugin_dir} from the environment, + * used verbatim (it defaults to {@code DORIS_HOME/plugins/trino_plugins}).
    4. + *
    + * + *

    Nothing else is consulted: the dir the config names is the dir the plugins are loaded + * from. Earlier versions probed the pre-2.1.8 {@code DORIS_HOME/connectors} and the 2.1.8 + * {@code DORIS_HOME/plugins/connectors} when the config was left at its default, which forced + * this class to duplicate the default as a literal just to tell "user set it" from "untouched". + * That compatibility path was dropped deliberately — a deployment whose plugins still sit in a + * legacy dir must move them or point {@code trino_connector_plugin_dir} at them. + * + * @param properties catalog properties (unstripped, may carry {@code trino.plugin.dir}) + * @param environment engine environment from {@code ConnectorContext.getEnvironment()} */ - public static String resolvePluginDir(Map properties) { + public static String resolvePluginDir(Map properties, Map environment) { String explicitDir = properties.get("trino.plugin.dir"); if (explicitDir != null && !explicitDir.isEmpty()) { return explicitDir; } - String dorisHome = System.getenv("DORIS_HOME"); - if (dorisHome == null) { - dorisHome = "."; - } - - String defaultDir = dorisHome + "/plugins/connectors"; - String oldDir = dorisHome + "/connectors"; - File oldDirFile = new File(oldDir); - if (oldDirFile.exists() && oldDirFile.isDirectory()) { - String[] contents = oldDirFile.list(); - if (contents != null && contents.length > 0) { - return oldDir; - } + String configuredDir = environment.get("trino_connector_plugin_dir"); + if (configuredDir == null || configuredDir.isEmpty()) { + // DefaultConnectorContext always passes the FE config, which always holds a value. Absent + // means the engine failed to deliver it; guessing a dir here would surface as "catalog + // creates fine but every query fails", so fail where the cause is still visible. + throw new IllegalStateException( + "trino_connector_plugin_dir was not delivered through the engine environment; " + + "cannot resolve the Trino plugin dir"); } - return defaultDir; + return configuredDir; } /** 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 4142f014089d44..d27e184d92b81a 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 @@ -22,14 +22,25 @@ import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.ConnectorTableSchema; 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.ConnectorColumnAssignment; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +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.ProjectionApplicationResult; import com.google.common.collect.ImmutableMap; import io.trino.Session; import io.trino.spi.connector.CatalogHandle; import io.trino.spi.connector.ColumnHandle; import io.trino.spi.connector.ColumnMetadata; +import io.trino.spi.connector.Constraint; +import io.trino.spi.connector.ConstraintApplicationResult; import io.trino.spi.connector.SchemaTableName; +import io.trino.spi.expression.Variable; +import io.trino.spi.predicate.TupleDomain; import io.trino.spi.transaction.IsolationLevel; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -37,6 +48,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -66,15 +78,34 @@ public TrinoConnectorDorisMetadata( this.trinoCatalogHandle = trinoCatalogHandle; } + /** + * Releases a Trino metadata transaction. Each read-only metadata method below opens a transaction + * that the Trino {@code Connector} contract requires be ended with exactly one of commit/rollback; + * a read-only READ_UNCOMMITTED transaction has nothing to write, so commit simply frees it from the + * connector's transaction manager. A release failure is logged, never rethrown, so it cannot mask + * the real exception a {@code finally} block runs after. + */ + private void releaseQuietly(io.trino.spi.connector.ConnectorTransactionHandle txn) { + try { + trinoConnector.commit(txn); + } catch (RuntimeException e) { + LOG.warn("Failed to release Trino metadata transaction", e); + } + } + @Override public List listDatabaseNames(ConnectorSession session) { io.trino.spi.connector.ConnectorSession connSession = trinoSession.toConnectorSession(trinoCatalogHandle); io.trino.spi.connector.ConnectorTransactionHandle txn = trinoConnector.beginTransaction(IsolationLevel.READ_UNCOMMITTED, true, true); - io.trino.spi.connector.ConnectorMetadata metadata = - trinoConnector.getMetadata(connSession, txn); - return metadata.listSchemaNames(connSession); + try { + io.trino.spi.connector.ConnectorMetadata metadata = + trinoConnector.getMetadata(connSession, txn); + return metadata.listSchemaNames(connSession); + } finally { + releaseQuietly(txn); + } } @Override @@ -88,14 +119,21 @@ public List listTableNames(ConnectorSession session, String dbName) { trinoSession.toConnectorSession(trinoCatalogHandle); io.trino.spi.connector.ConnectorTransactionHandle txn = trinoConnector.beginTransaction(IsolationLevel.READ_UNCOMMITTED, true, true); - io.trino.spi.connector.ConnectorMetadata metadata = - trinoConnector.getMetadata(connSession, txn); - - Optional schemaName = Optional.of(dbName); - List tables = metadata.listTables(connSession, schemaName); - return tables.stream() - .map(SchemaTableName::getTableName) - .collect(Collectors.toList()); + try { + io.trino.spi.connector.ConnectorMetadata metadata = + trinoConnector.getMetadata(connSession, txn); + + Optional schemaName = Optional.of(dbName); + List tables = metadata.listTables(connSession, schemaName); + // distinct() restores the legacy LinkedHashSet de-dup (order-preserving): some Trino + // connectors list the same table name more than once (tables+views, multi-source merges). + return tables.stream() + .map(SchemaTableName::getTableName) + .distinct() + .collect(Collectors.toList()); + } finally { + releaseQuietly(txn); + } } public boolean tableExists(ConnectorSession session, String dbName, String tableName) { @@ -113,33 +151,37 @@ public Optional getTableHandle( trinoSession.toConnectorSession(trinoCatalogHandle); io.trino.spi.connector.ConnectorTransactionHandle txn = trinoConnector.beginTransaction(IsolationLevel.READ_UNCOMMITTED, true, true); - io.trino.spi.connector.ConnectorMetadata metadata = - trinoConnector.getMetadata(connSession, txn); - - SchemaTableName schemaTableName = new SchemaTableName(dbName, tableName); - io.trino.spi.connector.ConnectorTableHandle trinoHandle = - metadata.getTableHandle(connSession, schemaTableName, - Optional.empty(), Optional.empty()); - if (trinoHandle == null) { - return Optional.empty(); - } + try { + io.trino.spi.connector.ConnectorMetadata metadata = + trinoConnector.getMetadata(connSession, txn); - // Eagerly resolve column handles + metadata for the table - Map handles = metadata.getColumnHandles(connSession, trinoHandle); - ImmutableMap.Builder columnHandleMapBuilder = ImmutableMap.builder(); - ImmutableMap.Builder columnMetadataMapBuilder = ImmutableMap.builder(); - for (Map.Entry entry : handles.entrySet()) { - String colName = entry.getKey().toLowerCase(Locale.ENGLISH); - columnHandleMapBuilder.put(colName, entry.getValue()); - ColumnMetadata colMeta = metadata.getColumnMetadata( - connSession, trinoHandle, entry.getValue()); - columnMetadataMapBuilder.put(colMeta.getName(), colMeta); - } + SchemaTableName schemaTableName = new SchemaTableName(dbName, tableName); + io.trino.spi.connector.ConnectorTableHandle trinoHandle = + metadata.getTableHandle(connSession, schemaTableName, + Optional.empty(), Optional.empty()); + if (trinoHandle == null) { + return Optional.empty(); + } - return Optional.of(new TrinoTableHandle( - dbName, tableName, trinoHandle, - columnHandleMapBuilder.buildOrThrow(), - columnMetadataMapBuilder.buildOrThrow())); + // Eagerly resolve column handles + metadata for the table + Map handles = metadata.getColumnHandles(connSession, trinoHandle); + ImmutableMap.Builder columnHandleMapBuilder = ImmutableMap.builder(); + ImmutableMap.Builder columnMetadataMapBuilder = ImmutableMap.builder(); + for (Map.Entry entry : handles.entrySet()) { + String colName = entry.getKey().toLowerCase(Locale.ENGLISH); + columnHandleMapBuilder.put(colName, entry.getValue()); + ColumnMetadata colMeta = metadata.getColumnMetadata( + connSession, trinoHandle, entry.getValue()); + columnMetadataMapBuilder.put(colMeta.getName(), colMeta); + } + + return Optional.of(new TrinoTableHandle( + dbName, tableName, trinoHandle, + columnHandleMapBuilder.buildOrThrow(), + columnMetadataMapBuilder.buildOrThrow())); + } finally { + releaseQuietly(txn); + } } @Override @@ -151,53 +193,184 @@ public ConnectorTableSchema getTableSchema( trinoSession.toConnectorSession(trinoCatalogHandle); io.trino.spi.connector.ConnectorTransactionHandle txn = trinoConnector.beginTransaction(IsolationLevel.READ_UNCOMMITTED, true, true); - io.trino.spi.connector.ConnectorMetadata metadata = - trinoConnector.getMetadata(connSession, txn); + try { + io.trino.spi.connector.ConnectorMetadata metadata = + trinoConnector.getMetadata(connSession, txn); - Map columnHandles = trinoHandle.getColumnHandleMap(); - if (columnHandles == null || columnHandles.isEmpty()) { - columnHandles = metadata.getColumnHandles( - connSession, trinoHandle.getTrinoTableHandle()); - } + Map columnHandles = trinoHandle.getColumnHandleMap(); + if (columnHandles == null || columnHandles.isEmpty()) { + columnHandles = metadata.getColumnHandles( + connSession, trinoHandle.getTrinoTableHandle()); + } - List columns = new ArrayList<>(); - for (ColumnHandle columnHandle : columnHandles.values()) { - ColumnMetadata colMeta = metadata.getColumnMetadata( - connSession, trinoHandle.getTrinoTableHandle(), columnHandle); - if (colMeta.isHidden()) { - continue; + List columns = new ArrayList<>(); + for (ColumnHandle columnHandle : columnHandles.values()) { + ColumnMetadata colMeta = metadata.getColumnMetadata( + connSession, trinoHandle.getTrinoTableHandle(), columnHandle); + if (colMeta.isHidden()) { + continue; + } + ConnectorType connType = TrinoTypeMapping.toConnectorType(colMeta.getType()); + // Mark every column as a key column to match the Doris external-table convention + // (legacy TrinoConnectorExternalTable.initSchema and JdbcClient do the same), so + // `desc

    ` reports Key=true for each column. + columns.add(new ConnectorColumn( + colMeta.getName(), + connType, + colMeta.getComment(), + true, + null, + true)); } - ConnectorType connType = TrinoTypeMapping.toConnectorType(colMeta.getType()); - columns.add(new ConnectorColumn( - colMeta.getName(), - connType, - colMeta.getComment(), - true, - null)); - } - Map tableProps = new HashMap<>(); - tableProps.put("trino.connector.table", "true"); + Map tableProps = new HashMap<>(); + tableProps.put("trino.connector.table", "true"); - return new ConnectorTableSchema( - trinoHandle.getTableName(), - columns, - "trino_connector", - Collections.unmodifiableMap(tableProps)); + return new ConnectorTableSchema( + trinoHandle.getTableName(), + columns, + "trino_connector", + Collections.unmodifiableMap(tableProps)); + } finally { + releaseQuietly(txn); + } } @Override - public Map getColumnHandles( + public Map getColumnHandles( ConnectorSession session, ConnectorTableHandle handle) { TrinoTableHandle trinoHandle = (TrinoTableHandle) handle; Map trinoHandles = trinoHandle.getColumnHandleMap(); if (trinoHandles == null || trinoHandles.isEmpty()) { return Collections.emptyMap(); } - Map result = new HashMap<>(); + Map result = new HashMap<>(); for (String colName : trinoHandles.keySet()) { result.put(colName, new TrinoColumnHandle(colName)); } return result; } + + @Override + public Optional> applyFilter( + ConnectorSession session, + ConnectorTableHandle handle, + ConnectorFilterConstraint constraint) { + TrinoTableHandle dorisHandle = (TrinoTableHandle) handle; + ConnectorExpression expression = constraint.getExpression(); + + TrinoPredicateConverter converter = new TrinoPredicateConverter( + dorisHandle.getColumnHandleMap(), + dorisHandle.getColumnMetadataMap()); + TupleDomain tupleDomain = converter.convert(expression); + if (tupleDomain.isAll()) { + return Optional.empty(); + } + + io.trino.spi.connector.ConnectorSession connSession = + trinoSession.toConnectorSession(trinoCatalogHandle); + io.trino.spi.connector.ConnectorTransactionHandle txn = + trinoConnector.beginTransaction(IsolationLevel.READ_UNCOMMITTED, true, true); + try { + io.trino.spi.connector.ConnectorMetadata metadata = + trinoConnector.getMetadata(connSession, txn); + + Optional> trinoResult = + metadata.applyFilter(connSession, dorisHandle.getTrinoTableHandle(), + new Constraint(tupleDomain)); + if (!trinoResult.isPresent()) { + return Optional.empty(); + } + + TrinoTableHandle newHandle = new TrinoTableHandle( + dorisHandle.getDbName(), + dorisHandle.getTableName(), + trinoResult.get().getHandle(), + dorisHandle.getColumnHandleMap(), + dorisHandle.getColumnMetadataMap()); + + // 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. A future enhancement could try to map the remaining + // TupleDomain back to a ConnectorExpression and clear fully-pushed conjuncts. + return Optional.of(new FilterApplicationResult<>(newHandle, expression, false)); + } finally { + releaseQuietly(txn); + } + } + + @Override + public Optional> applyProjection( + ConnectorSession session, + ConnectorTableHandle handle, + List projections) { + if (projections.isEmpty()) { + return Optional.empty(); + } + TrinoTableHandle dorisHandle = (TrinoTableHandle) handle; + Map colHandleMap = dorisHandle.getColumnHandleMap(); + Map colMetaMap = dorisHandle.getColumnMetadataMap(); + + // Use LinkedHashMap: Trino's JDBC applyProjection derives the pushed-down handle's + // column order from assignments.values(). A HashMap would scramble that order, and + // because the later TrinoScanPlanProvider projection short-circuits to empty once the + // column *set* matches, the scrambled order would survive and break the BE-side + // engine-vs-handle column verify. Matches the legacy TrinoConnectorScanNode. + Map assignments = new LinkedHashMap<>(); + List trinoProjections = new ArrayList<>(); + for (ConnectorColumnHandle col : projections) { + String colName = ((TrinoColumnHandle) col).getColumnName(); + ColumnHandle ch = colHandleMap.get(colName); + ColumnMetadata cm = colMetaMap.get(colName); + if (ch == null || cm == null) { + continue; + } + assignments.put(colName, ch); + trinoProjections.add(new Variable(colName, cm.getType())); + } + if (trinoProjections.isEmpty()) { + return Optional.empty(); + } + + io.trino.spi.connector.ConnectorSession connSession = + trinoSession.toConnectorSession(trinoCatalogHandle); + io.trino.spi.connector.ConnectorTransactionHandle txn = + trinoConnector.beginTransaction(IsolationLevel.READ_UNCOMMITTED, true, true); + try { + io.trino.spi.connector.ConnectorMetadata metadata = + trinoConnector.getMetadata(connSession, txn); + + Optional> trinoResult = + metadata.applyProjection(connSession, dorisHandle.getTrinoTableHandle(), + trinoProjections, assignments); + if (!trinoResult.isPresent()) { + return Optional.empty(); + } + + TrinoTableHandle newHandle = new TrinoTableHandle( + dorisHandle.getDbName(), + dorisHandle.getTableName(), + trinoResult.get().getHandle(), + colHandleMap, + colMetaMap); + + List outProjections = new ArrayList<>(projections.size()); + List outAssignments = new ArrayList<>(projections.size()); + for (ConnectorColumnHandle col : projections) { + String colName = ((TrinoColumnHandle) col).getColumnName(); + ColumnMetadata cm = colMetaMap.get(colName); + if (cm == null) { + continue; + } + ConnectorType type = TrinoTypeMapping.toConnectorType(cm.getType()); + ConnectorColumnRef ref = new ConnectorColumnRef(colName, type); + outProjections.add(ref); + outAssignments.add(new ConnectorColumnAssignment(col, ref)); + } + return Optional.of(new ProjectionApplicationResult<>(newHandle, outProjections, outAssignments)); + } finally { + releaseQuietly(txn); + } + } } diff --git a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoConnectorProvider.java b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoConnectorProvider.java index 946987fade13d5..f685261c23b231 100644 --- a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoConnectorProvider.java +++ b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoConnectorProvider.java @@ -29,6 +29,8 @@ */ public class TrinoConnectorProvider implements ConnectorProvider { + static final String TRINO_CONNECTOR_NAME = "trino.connector.name"; + @Override public String getType() { return "trino-connector"; @@ -38,4 +40,13 @@ public String getType() { public Connector create(Map properties, ConnectorContext context) { return new TrinoDorisConnector(properties, context); } + + @Override + public void validateProperties(Map properties) { + String connectorName = properties.get(TRINO_CONNECTOR_NAME); + if (connectorName == null || connectorName.isEmpty()) { + throw new IllegalArgumentException( + "Required property '" + TRINO_CONNECTOR_NAME + "' is missing"); + } + } } diff --git a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoDorisConnector.java b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoDorisConnector.java index c6f6f4ba9a88cd..64f31fd8f68196 100644 --- a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoDorisConnector.java +++ b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoDorisConnector.java @@ -20,6 +20,7 @@ import org.apache.doris.connector.api.Connector; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorValidationContext; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.spi.ConnectorContext; @@ -73,6 +74,14 @@ public ConnectorScanPlanProvider getScanPlanProvider() { return new TrinoScanPlanProvider(this); } + @Override + public void preCreateValidation(ConnectorValidationContext context) { + // Lift plugin loading + connector-factory resolution from first-query + // to CREATE CATALOG time, so misconfigured plugin dir / connector name + // surfaces immediately instead of on the first SELECT. + ensureInitialized(); + } + @Override public org.apache.doris.connector.api.ConnectorTestResult testConnection(ConnectorSession session) { ensureInitialized(); @@ -154,18 +163,25 @@ private void doInitialize() { deprecated, connectorNameStr); } - // 2. Initialize Trino plugin infrastructure (singleton) - String pluginDir = TrinoBootstrap.resolvePluginDir(properties); + // 2. Initialize Trino plugin infrastructure (singleton). + // The plugin dir comes from the FE engine environment (fe-core reads fe.conf); + // this plugin's classloader cannot see FE Config directly. + String pluginDir = TrinoBootstrap.resolvePluginDir(properties, context.getEnvironment()); TrinoBootstrap bootstrap = TrinoBootstrap.getInstance(pluginDir); // 3. Create Trino Connector + Session for this catalog TrinoBootstrap.TrinoConnectionResult result = bootstrap.createConnection( context.getCatalogName(), connectorNameStr, trinoProperties); - this.trinoConnector = result.getConnector(); + // Publish the guard field (trinoConnector) LAST. ensureInitialized() and the other readers use + // `trinoConnector != null` as the initialized flag and then read trinoSession/trinoCatalogHandle. + // Assigning the guard after its dependencies means a concurrent reader that sees it non-null is + // guaranteed (via the volatile write/read happens-before) to also see the fully-published + // session / catalog handle / name — closing the transient half-initialized NPE window. this.trinoSession = result.getSession(); this.trinoCatalogHandle = result.getCatalogHandle(); this.trinoConnectorName = result.getConnectorName(); + this.trinoConnector = result.getConnector(); LOG.info("Trino connector initialized for catalog '{}', connector: {}", context.getCatalogName(), connectorNameStr); 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 55bd8a937c22a5..5c6cfbdb35fe7d 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 @@ -53,6 +53,7 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -136,14 +137,17 @@ public List planScan( } } - // Apply projection pushdown - applyProjection(metadata, connSession, trinoHandle, currentTrinoHandle, columns); + // Apply projection pushdown. The returned handle carries the projected column + // list (e.g. JdbcTableHandle.getColumns()); it MUST be the handle serialized to BE. + // Otherwise Trino's JdbcRecordSetProvider.getRecordSet() fails its verify() because + // the handle's columns won't match the column handles passed to the scanner. + currentTrinoHandle = applyProjection(metadata, connSession, trinoHandle, currentTrinoHandle, columns); // Get splits return getSplitsFromTrino( connector, trinoSession, catalogHandle, connSession, txnHandle, metadata, currentTrinoHandle, constraint, - trinoHandle, session); + trinoHandle, columns, session); } finally { metadata.cleanupQuery(connSession); } @@ -158,7 +162,10 @@ private io.trino.spi.connector.ConnectorTableHandle applyProjection( Map colHandleMap = trinoHandle.getColumnHandleMap(); Map colMetaMap = trinoHandle.getColumnMetadataMap(); - Map assignments = new HashMap<>(); + // Preserve projection order (matches the serialized column handles below and the legacy + // TrinoConnectorScanNode). A plain HashMap would scramble JdbcTableHandle's column order + // and break the engine-vs-handle column verify on the BE scanner. + Map assignments = new LinkedHashMap<>(); List projections = new ArrayList<>(); for (ConnectorColumnHandle col : columns) { @@ -189,6 +196,7 @@ private List getSplitsFromTrino( io.trino.spi.connector.ConnectorTableHandle tableHandle, Constraint constraint, TrinoTableHandle dorisHandle, + List columns, ConnectorSession dorisSession) { ConnectorSplitManager splitManager = connector.getSplitManager(); @@ -204,17 +212,17 @@ private List getSplitsFromTrino( new BoundedExecutor(executor, 10), MIN_SCHEDULE_SPLIT_BATCH_SIZE); } - // Prepare JSON serializer - TrinoBootstrap bootstrap = TrinoBootstrap.getInstance( - TrinoBootstrap.resolvePluginDir(dorisConnector.getTrinoProperties())); + // Prepare JSON serializer. The bootstrap singleton was already initialized when the + // catalog was created, so reuse it instead of re-resolving the plugin directory. + TrinoBootstrap bootstrap = TrinoBootstrap.getInstance(); TrinoJsonSerializer serializer = new TrinoJsonSerializer( bootstrap.getHandleResolver(), bootstrap.getTypeRegistry()); // Pre-serialize shared fields (same for all splits) String tableHandleJson = serializer.toJson(tableHandle); String txnHandleJson = serializer.toJson(txnHandle); - String columnHandlesJson = serializeColumnHandles(dorisHandle, serializer); - String columnMetadataJson = serializeColumnMetadata(dorisHandle, serializer); + String columnHandlesJson = serializeColumnHandles(dorisHandle, columns, serializer); + String columnMetadataJson = serializeColumnMetadata(dorisHandle, columns, serializer); String optionsJson = serializeOptions(dorisSession); String catalogName = dorisSession.getCatalogName(); @@ -263,28 +271,55 @@ private Constraint buildConstraint(Optional filter, return new Constraint(tupleDomain); } + // Serialize only the projected columns, in the same order (and with the same filter) + // applyProjection used, so the column handles passed to the BE scanner match + // JdbcTableHandle.getColumns() exactly (Trino's getRecordSet verifies handles.equals(columns)). private String serializeColumnHandles(TrinoTableHandle handle, - TrinoJsonSerializer serializer) { - List handles = new ArrayList<>(handle.getColumnHandleMap().values()); + List columns, TrinoJsonSerializer serializer) { + Map colHandleMap = handle.getColumnHandleMap(); + Map colMetaMap = handle.getColumnMetadataMap(); + List handles = new ArrayList<>(); + for (ConnectorColumnHandle col : columns) { + String colName = ((TrinoColumnHandle) col).getColumnName(); + if (colHandleMap.containsKey(colName) && colMetaMap.containsKey(colName)) { + handles.add(colHandleMap.get(colName)); + } + } return serializer.toJson(handles); } private String serializeColumnMetadata(TrinoTableHandle handle, - TrinoJsonSerializer serializer) { - List metadataList = handle.getColumnMetadataMap().values().stream() - .map(m -> new TrinoColumnMetadata( + List columns, TrinoJsonSerializer serializer) { + Map colHandleMap = handle.getColumnHandleMap(); + Map colMetaMap = handle.getColumnMetadataMap(); + List metadataList = new ArrayList<>(); + for (ConnectorColumnHandle col : columns) { + String colName = ((TrinoColumnHandle) col).getColumnName(); + if (colHandleMap.containsKey(colName) && colMetaMap.containsKey(colName)) { + ColumnMetadata m = colMetaMap.get(colName); + metadataList.add(new TrinoColumnMetadata( m.getName(), m.getType(), m.isNullable(), m.getComment(), m.getExtraInfo(), m.isHidden(), - m.getProperties())) - .collect(Collectors.toList()); + m.getProperties())); + } + } return serializer.toJson(metadataList); } private String serializeOptions(ConnectorSession session) { - Map props = new HashMap<>(session.getCatalogProperties()); - if (!props.containsKey("create_time")) { - props.put("create_time", String.valueOf(System.currentTimeMillis() / 1000)); + // BE re-adds the "trino." prefix to every option key (TRINO_CONNECTOR_OPTION_PREFIX), + // then strips it back off and reads "connector.name" from the result. So the options + // map must carry the *stripped* trino.* properties (connector.name, .*), + // matching the legacy TrinoConnectorScanNode. Sending the full prefixed catalog + // properties here would double the prefix and make BE read a null connector.name. + Map props = new HashMap<>(dorisConnector.getTrinoProperties()); + // BE also needs create_time; it is part of the BE-side connector cache key, so + // preserve the catalog's value rather than minting a new one per scan. + String createTime = session.getCatalogProperties().get("create_time"); + if (createTime == null || createTime.isEmpty()) { + createTime = String.valueOf(System.currentTimeMillis() / 1000); } + props.put("create_time", createTime); try { return new ObjectMapper().writeValueAsString(props); } catch (JsonProcessingException e) { diff --git a/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoBootstrapTest.java b/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoBootstrapTest.java new file mode 100644 index 00000000000000..842b7b1b8449e8 --- /dev/null +++ b/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoBootstrapTest.java @@ -0,0 +1,102 @@ +// 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.trino; + +import com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.Map; + +/** + * Unit tests for {@link TrinoBootstrap#resolvePluginDir}. + * + *

    Guards the plugin-directory resolution the regression environment depends on: the + * connectors are dispatched to a custom directory and {@code fe.conf} points + * {@code trino_connector_plugin_dir} at it. Because this plugin runs in an isolated + * classloader, it cannot read FE {@code Config}; the value must arrive through the engine + * environment map. A regression once made every {@code trino-connector} catalog fail with + * "Cannot find Trino ConnectorFactory" because that override was not honored. + * + *

    Resolution is deliberately only two steps deep — per-catalog property, else the config + * verbatim. The legacy-dir fallbacks that used to sit under them were dropped, so the cases here + * are as much about what is not consulted as about what is. + */ +public class TrinoBootstrapTest { + + /** A Trino plugin is a directory of jars; a probe would only care that the dir is non-empty. */ + private static void installPluginIn(Path dorisHome, String subdir) throws IOException { + Files.createDirectories(dorisHome.resolve(subdir).resolve("trino-hive")); + } + + private static Map envAtDefault(Path dorisHome) { + return ImmutableMap.of( + "doris_home", dorisHome.toString(), + "trino_connector_plugin_dir", dorisHome + "/plugins/trino_plugins"); + } + + @Test + public void perCatalogPropertyTakesPrecedence() { + Map env = ImmutableMap.of( + "doris_home", "/opt/doris", + "trino_connector_plugin_dir", "/should/be/ignored"); + String resolved = TrinoBootstrap.resolvePluginDir( + ImmutableMap.of("trino.plugin.dir", "/custom/catalog/dir"), env); + Assertions.assertEquals("/custom/catalog/dir", resolved); + } + + @Test + public void feConfigFromEnvironmentIsHonored() { + // Exactly what the regression environment sets in fe.conf, delivered via the + // engine environment because the plugin classloader cannot read FE Config. + Map env = ImmutableMap.of( + "doris_home", "/opt/doris", + "trino_connector_plugin_dir", "/tmp/trino_connector/connectors"); + String resolved = TrinoBootstrap.resolvePluginDir(Collections.emptyMap(), env); + Assertions.assertEquals("/tmp/trino_connector/connectors", resolved); + } + + @Test + public void legacyPluginDirsAreNotConsultedEvenWhenTheyHoldPlugins(@TempDir Path dorisHome) throws IOException { + // The config names the dir; nothing probes the filesystem behind it. Both dirs that earlier + // versions fell back to are populated here and both must be ignored -- an upgrade only picks + // them up if the operator points the config at one. Asserted because the alternative is + // invisible: resolving to a legacy dir silently loads a different set of plugins, and + // re-introducing the fallback would otherwise pass every other case in this file. + installPluginIn(dorisHome, "connectors"); + installPluginIn(dorisHome, "plugins/connectors"); + + String resolved = TrinoBootstrap.resolvePluginDir(Collections.emptyMap(), envAtDefault(dorisHome)); + Assertions.assertEquals(dorisHome + "/plugins/trino_plugins", resolved); + } + + @Test + public void missingConfigInTheEnvironmentFailsLoud() { + // DefaultConnectorContext always passes the FE config, so an absent key means the engine + // failed to deliver it. Guessing a dir would surface far away as "catalog creates fine but + // every query fails"; throwing keeps the cause at the point of breakage. + Assertions.assertThrows(IllegalStateException.class, + () -> TrinoBootstrap.resolvePluginDir( + Collections.emptyMap(), ImmutableMap.of("doris_home", "/opt/doris"))); + } +} diff --git a/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoConnectorProviderTest.java b/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoConnectorProviderTest.java new file mode 100644 index 00000000000000..15f8624d030f64 --- /dev/null +++ b/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoConnectorProviderTest.java @@ -0,0 +1,61 @@ +// 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.trino; + +import com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +/** + * Unit tests for {@link TrinoConnectorProvider}: CREATE CATALOG must fail fast at + * validation time when the required {@code trino.connector.name} property is absent, + * so the error surfaces on catalog creation rather than on first query. + */ +public class TrinoConnectorProviderTest { + + private static final String NAME_PROP = "trino.connector.name"; + + private final TrinoConnectorProvider provider = new TrinoConnectorProvider(); + + @Test + public void testTypeIsTrinoConnector() { + Assertions.assertEquals("trino-connector", provider.getType()); + } + + @Test + public void testMissingConnectorNameThrows() { + IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, + () -> provider.validateProperties(Collections.emptyMap())); + Assertions.assertTrue(ex.getMessage().contains(NAME_PROP), + "error message should name the missing property"); + } + + @Test + public void testEmptyConnectorNameThrows() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> provider.validateProperties(ImmutableMap.of(NAME_PROP, ""))); + } + + @Test + public void testPresentConnectorNamePasses() { + Assertions.assertDoesNotThrow( + () -> provider.validateProperties(ImmutableMap.of(NAME_PROP, "postgresql"))); + } +} 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 new file mode 100644 index 00000000000000..772cd579f2f819 --- /dev/null +++ b/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoPredicateConverterTest.java @@ -0,0 +1,239 @@ +// 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.trino; + +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorIn; +import org.apache.doris.connector.api.pushdown.ConnectorIsNull; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.ConnectorOr; + +import com.google.common.collect.ImmutableMap; +import io.airlift.slice.Slices; +import io.trino.spi.connector.ColumnHandle; +import io.trino.spi.connector.ColumnMetadata; +import io.trino.spi.predicate.Domain; +import io.trino.spi.predicate.Range; +import io.trino.spi.predicate.TupleDomain; +import io.trino.spi.predicate.ValueSet; +import io.trino.spi.type.BigintType; +import io.trino.spi.type.BooleanType; +import io.trino.spi.type.IntegerType; +import io.trino.spi.type.Type; +import io.trino.spi.type.VarcharType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Map; + +/** + * Unit tests for {@link TrinoPredicateConverter}: Doris {@code ConnectorExpression} + * pushdown trees must convert to the exact Trino {@link TupleDomain} that preserves + * filter semantics, and must degrade to {@code TupleDomain.all()} (no pushdown, full + * scan) rather than fail when an expression cannot be represented. + */ +public class TrinoPredicateConverterTest { + + // A column handle is an opaque marker to the converter; it ends up as the key of + // the produced TupleDomain. equals/hashCode by name so expected/actual compare equal. + private static final class MockColumnHandle implements ColumnHandle { + private final String name; + + private MockColumnHandle(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + return o instanceof MockColumnHandle && name.equals(((MockColumnHandle) o).name); + } + + @Override + public int hashCode() { + return name.hashCode(); + } + } + + private static final Map HANDLES = ImmutableMap.of( + "c_int", new MockColumnHandle("c_int"), + "c_bigint", new MockColumnHandle("c_bigint"), + "c_str", new MockColumnHandle("c_str"), + "c_bool", new MockColumnHandle("c_bool")); + + private static final Map METAS = ImmutableMap.of( + "c_int", new ColumnMetadata("c_int", IntegerType.INTEGER), + "c_bigint", new ColumnMetadata("c_bigint", BigintType.BIGINT), + "c_str", new ColumnMetadata("c_str", VarcharType.createVarcharType(64)), + "c_bool", new ColumnMetadata("c_bool", BooleanType.BOOLEAN)); + + private static final TrinoPredicateConverter CONVERTER = new TrinoPredicateConverter(HANDLES, METAS); + + private static ConnectorColumnRef col(String name) { + // The Doris type here is unused by the converter; it resolves the Trino type + // from the column metadata map. A placeholder keeps the ref construction simple. + return new ConnectorColumnRef(name, ConnectorType.of("INT")); + } + + private static Type type(String name) { + return METAS.get(name).getType(); + } + + private static Domain singleValue(String colName, Object value) { + return Domain.create(ValueSet.ofRanges(Range.equal(type(colName), value)), false); + } + + private static TupleDomain expect(String colName, Domain domain) { + return TupleDomain.withColumnDomains(ImmutableMap.of(HANDLES.get(colName), domain)); + } + + @Test + public void testEqProducesSingleValueDomain() { + // c_int = 5 -> domain pinned to the single value 5 + ConnectorComparison cmp = new ConnectorComparison( + ConnectorComparison.Operator.EQ, col("c_int"), ConnectorLiteral.ofInt(5)); + Assertions.assertEquals(expect("c_int", singleValue("c_int", 5L)), CONVERTER.convert(cmp)); + } + + @Test + public void testBooleanEqKeepsBooleanValue() { + // c_bool = true -> boolean literals are passed through unchanged (not coerced to long) + ConnectorComparison cmp = new ConnectorComparison( + ConnectorComparison.Operator.EQ, col("c_bool"), ConnectorLiteral.ofBoolean(true)); + Assertions.assertEquals(expect("c_bool", singleValue("c_bool", true)), CONVERTER.convert(cmp)); + } + + @Test + public void testLessThanProducesOpenUpperRange() { + // c_int < 10 -> (-inf, 10) + ConnectorComparison cmp = new ConnectorComparison( + ConnectorComparison.Operator.LT, col("c_int"), ConnectorLiteral.ofInt(10)); + Domain expected = Domain.create(ValueSet.ofRanges(Range.lessThan(type("c_int"), 10L)), false); + Assertions.assertEquals(expect("c_int", expected), CONVERTER.convert(cmp)); + } + + @Test + public void testGreaterOrEqualProducesClosedLowerRange() { + // c_bigint >= 100 -> [100, +inf) + ConnectorComparison cmp = new ConnectorComparison( + ConnectorComparison.Operator.GE, col("c_bigint"), ConnectorLiteral.ofLong(100L)); + Domain expected = Domain.create( + ValueSet.ofRanges(Range.greaterThanOrEqual(type("c_bigint"), 100L)), false); + Assertions.assertEquals(expect("c_bigint", expected), CONVERTER.convert(cmp)); + } + + @Test + public void testNotEqualExcludesValue() { + // c_int != 7 -> everything except 7 + ConnectorComparison cmp = new ConnectorComparison( + ConnectorComparison.Operator.NE, col("c_int"), ConnectorLiteral.ofInt(7)); + Domain expected = Domain.create( + ValueSet.all(type("c_int")).subtract(ValueSet.ofRanges(Range.equal(type("c_int"), 7L))), + false); + Assertions.assertEquals(expect("c_int", expected), CONVERTER.convert(cmp)); + } + + @Test + public void testVarcharEqEncodesAsSlice() { + // c_str = 'hello' -> string literal must be encoded as a Trino Slice + ConnectorComparison cmp = new ConnectorComparison( + ConnectorComparison.Operator.EQ, col("c_str"), ConnectorLiteral.ofString("hello")); + Assertions.assertEquals( + expect("c_str", singleValue("c_str", Slices.utf8Slice("hello"))), + CONVERTER.convert(cmp)); + } + + @Test + public void testInProducesMultiValueDomain() { + // c_int IN (1, 2, 3) -> domain of the three discrete values + ConnectorIn in = new ConnectorIn(col("c_int"), + Arrays.asList(ConnectorLiteral.ofInt(1), ConnectorLiteral.ofInt(2), ConnectorLiteral.ofInt(3)), + false); + 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(in)); + } + + @Test + public void testNotInExcludesValues() { + // c_int NOT IN (1, 2) -> everything except 1 and 2 + ConnectorIn in = new ConnectorIn(col("c_int"), + Arrays.asList(ConnectorLiteral.ofInt(1), ConnectorLiteral.ofInt(2)), true); + Domain expected = Domain.create(ValueSet.all(type("c_int")).subtract(ValueSet.ofRanges( + Range.equal(type("c_int"), 1L), Range.equal(type("c_int"), 2L))), false); + Assertions.assertEquals(expect("c_int", expected), CONVERTER.convert(in)); + } + + @Test + public void testIsNullProducesOnlyNullDomain() { + // c_str IS NULL -> only-null domain + ConnectorIsNull isNull = new ConnectorIsNull(col("c_str"), false); + Assertions.assertEquals(expect("c_str", Domain.onlyNull(type("c_str"))), CONVERTER.convert(isNull)); + } + + @Test + public void testIsNotNullProducesNotNullDomain() { + // c_str IS NOT NULL -> not-null domain + ConnectorIsNull isNotNull = new ConnectorIsNull(col("c_str"), true); + Assertions.assertEquals(expect("c_str", Domain.notNull(type("c_str"))), CONVERTER.convert(isNotNull)); + } + + @Test + public void testAndIntersectsAcrossColumns() { + // c_int = 5 AND c_bigint = 100 -> both columns constrained in one TupleDomain + ConnectorAnd and = new ConnectorAnd(Arrays.asList( + new ConnectorComparison(ConnectorComparison.Operator.EQ, col("c_int"), ConnectorLiteral.ofInt(5)), + new ConnectorComparison(ConnectorComparison.Operator.EQ, col("c_bigint"), + ConnectorLiteral.ofLong(100L)))); + TupleDomain expected = TupleDomain.withColumnDomains(ImmutableMap.of( + HANDLES.get("c_int"), singleValue("c_int", 5L), + HANDLES.get("c_bigint"), singleValue("c_bigint", 100L))); + Assertions.assertEquals(expected, CONVERTER.convert(and)); + } + + @Test + public void testOrUnionsSameColumn() { + // c_int = 1 OR c_int = 2 -> union into a two-value domain on c_int + 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)))); + Domain expected = Domain.create(ValueSet.ofRanges( + Range.equal(type("c_int"), 1L), Range.equal(type("c_int"), 2L)), false); + Assertions.assertEquals(expect("c_int", expected), CONVERTER.convert(or)); + } + + @Test + public void testNullExpressionDegradesToAll() { + // A null filter must not be pushed down: scan everything. + Assertions.assertEquals(TupleDomain.all(), CONVERTER.convert(null)); + } + + @Test + public void testUnsupportedExpressionDegradesToAll() { + // A bare column reference is not a predicate; convert() must swallow the failure + // and return all() so the query still runs (just without pushdown). + ConnectorExpression unsupported = col("c_int"); + Assertions.assertEquals(TupleDomain.all(), CONVERTER.convert(unsupported)); + } +} 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 new file mode 100644 index 00000000000000..303357c828f2a7 --- /dev/null +++ b/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoTypeMappingTest.java @@ -0,0 +1,141 @@ +// 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.trino; + +import org.apache.doris.connector.api.ConnectorType; + +import io.trino.spi.type.ArrayType; +import io.trino.spi.type.BigintType; +import io.trino.spi.type.BooleanType; +import io.trino.spi.type.CharType; +import io.trino.spi.type.DateType; +import io.trino.spi.type.DecimalType; +import io.trino.spi.type.DoubleType; +import io.trino.spi.type.IntegerType; +import io.trino.spi.type.MapType; +import io.trino.spi.type.RealType; +import io.trino.spi.type.RowType; +import io.trino.spi.type.SmallintType; +import io.trino.spi.type.TimestampType; +import io.trino.spi.type.TinyintType; +import io.trino.spi.type.TypeOperators; +import io.trino.spi.type.UuidType; +import io.trino.spi.type.VarbinaryType; +import io.trino.spi.type.VarcharType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * 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 + * the bridge relies on for schema fidelity; unsupported types must fail loudly. + */ +public class TrinoTypeMappingTest { + + private static String name(io.trino.spi.type.Type type) { + return TrinoTypeMapping.toConnectorType(type).getTypeName(); + } + + @Test + public void testIntegerFamilyNames() { + Assertions.assertEquals("BOOLEAN", name(BooleanType.BOOLEAN)); + Assertions.assertEquals("TINYINT", name(TinyintType.TINYINT)); + Assertions.assertEquals("SMALLINT", name(SmallintType.SMALLINT)); + Assertions.assertEquals("INT", name(IntegerType.INTEGER)); + Assertions.assertEquals("BIGINT", name(BigintType.BIGINT)); + } + + @Test + public void testRealMapsToFloatAndDoubleToDouble() { + Assertions.assertEquals("FLOAT", name(RealType.REAL)); + Assertions.assertEquals("DOUBLE", name(DoubleType.DOUBLE)); + } + + @Test + public void testDecimalCarriesPrecisionAndScale() { + ConnectorType ct = TrinoTypeMapping.toConnectorType(DecimalType.createDecimalType(18, 4)); + Assertions.assertEquals("DECIMALV3", ct.getTypeName()); + Assertions.assertEquals(18, ct.getPrecision()); + Assertions.assertEquals(4, ct.getScale()); + } + + @Test + public void testStringFamilyNames() { + Assertions.assertEquals("CHAR", name(CharType.createCharType(10))); + Assertions.assertEquals("STRING", name(VarcharType.createVarcharType(20))); + Assertions.assertEquals("STRING", name(VarcharType.VARCHAR)); + Assertions.assertEquals("STRING", name(VarbinaryType.VARBINARY)); + } + + @Test + public void testDateMapsToDateV2() { + Assertions.assertEquals("DATEV2", name(DateType.DATE)); + } + + @Test + public void testTimestampMapsToDatetimeV2WithPrecision() { + ConnectorType ct = TrinoTypeMapping.toConnectorType(TimestampType.createTimestampType(3)); + Assertions.assertEquals("DATETIMEV2", ct.getTypeName()); + Assertions.assertEquals(3, ct.getPrecision()); + } + + @Test + public void testTimestampPrecisionClampedToSix() { + // Doris DATETIMEV2 supports at most 6 fractional digits; higher Trino precision clamps. + ConnectorType ct = TrinoTypeMapping.toConnectorType(TimestampType.createTimestampType(9)); + Assertions.assertEquals("DATETIMEV2", ct.getTypeName()); + Assertions.assertEquals(6, ct.getPrecision()); + } + + @Test + public void testArrayCarriesElementType() { + ConnectorType ct = TrinoTypeMapping.toConnectorType(new ArrayType(IntegerType.INTEGER)); + Assertions.assertEquals("ARRAY", ct.getTypeName()); + Assertions.assertEquals(1, ct.getChildren().size()); + Assertions.assertEquals("INT", ct.getChildren().get(0).getTypeName()); + } + + @Test + public void testMapCarriesKeyAndValueTypes() { + ConnectorType ct = TrinoTypeMapping.toConnectorType( + new MapType(VarcharType.VARCHAR, BigintType.BIGINT, new TypeOperators())); + Assertions.assertEquals("MAP", ct.getTypeName()); + Assertions.assertEquals(2, ct.getChildren().size()); + Assertions.assertEquals("STRING", ct.getChildren().get(0).getTypeName()); + Assertions.assertEquals("BIGINT", ct.getChildren().get(1).getTypeName()); + } + + @Test + public void testStructCarriesFieldTypes() { + RowType row = RowType.rowType( + RowType.field("a", IntegerType.INTEGER), + RowType.field("b", VarcharType.VARCHAR)); + ConnectorType ct = TrinoTypeMapping.toConnectorType(row); + Assertions.assertEquals("STRUCT", ct.getTypeName()); + Assertions.assertEquals(2, ct.getChildren().size()); + Assertions.assertEquals("INT", ct.getChildren().get(0).getTypeName()); + Assertions.assertEquals("STRING", ct.getChildren().get(1).getTypeName()); + } + + @Test + public void testUnknownTypeThrows() { + // An unmapped Trino type must fail loudly rather than silently produce a wrong type. + Assertions.assertThrows(IllegalArgumentException.class, + () -> TrinoTypeMapping.toConnectorType(UuidType.UUID)); + } +} diff --git a/fe/fe-connector/pom.xml b/fe/fe-connector/pom.xml index ffd042d2d2eb71..065e21387f2d66 100644 --- a/fe/fe-connector/pom.xml +++ b/fe/fe-connector/pom.xml @@ -40,15 +40,88 @@ under the License. fe-connector-api fe-connector-spi + fe-connector-cache + fe-connector-metastore-api + fe-connector-metastore-spi + + fe-connector-metastore-paimon + fe-connector-metastore-iceberg + fe-connector-metastore-hms fe-connector-es fe-connector-trino fe-connector-maxcompute fe-connector-jdbc + + fe-connector-hms-hive-shade fe-connector-hms fe-connector-hive + + fe-connector-paimon-hive-shade fe-connector-paimon fe-connector-hudi fe-connector-iceberg + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + false + + + check-connector-imports + validate + + exec + + + + ${project.basedir}/../../tools/check-connector-imports.sh + + ${project.basedir} + + + + + + check-authz-cache-sharding + validate + + exec + + + ${project.basedir}/../../tools/check-authz-cache-sharding.sh + + ${project.basedir} + + + + + + + + diff --git a/fe/fe-core/pom.xml b/fe/fe-core/pom.xml index ec53a24a75e23f..3d542c1acbaafe 100644 --- a/fe/fe-core/pom.xml +++ b/fe/fe-core/pom.xml @@ -64,10 +64,25 @@ under the License. ${project.groupId} fe-foundation + + ${project.groupId} + fe-kerberos + ${project.version} + ${project.groupId} fe-common ${project.version} + + + + org.eclipse.jetty.toolchain + jetty-jakarta-servlet-api + + @@ -199,6 +214,16 @@ under the License. org.apache.commons commons-lang3 + + + commons-lang + commons-lang + runtime + org.apache.commons commons-math3 @@ -260,6 +285,35 @@ under the License. + + + com.lmax + disruptor + + + + org.jetbrains + annotations + + + + com.github.ben-manes.caffeine + caffeine + + + + com.tdunning + json + io.netty netty-all @@ -314,10 +368,6 @@ under the License. com.amazonaws aws-java-sdk-s3 - - com.amazonaws - aws-java-sdk-glue - com.amazonaws aws-java-sdk-dynamodb @@ -326,10 +376,6 @@ under the License. com.amazonaws aws-java-sdk-logs - - com.amazonaws - aws-java-sdk-sts - @@ -349,26 +395,6 @@ under the License. fe-sql-parser ${project.version} - - com.aliyun.odps - odps-sdk-core - ${maxcompute.version} - - - antlr-runtime - org.antlr - - - antlr4 - org.antlr - - - - - com.aliyun.odps - odps-sdk-table-api - ${maxcompute.version} - org.springframework.boot @@ -411,7 +437,9 @@ under the License. com.ibm.icu icu4j - + org.awaitility awaitility @@ -432,9 +460,22 @@ under the License. com.google.flatbuffers flatbuffers-java - - org.apache.doris - hive-catalog-shade + + + org.apache.hive + hive-exec + core + runtime org.apache.hadoop @@ -542,29 +583,6 @@ under the License. iceberg-aws ${iceberg.version} - - org.apache.paimon - paimon-core - - - - org.apache.paimon - paimon-common - - - - org.apache.paimon - paimon-format - - - - org.apache.paimon - paimon-s3 - - - org.apache.paimon - paimon-jindo - software.amazon.awssdk @@ -576,10 +594,10 @@ under the License. - + software.amazon.awssdk s3-transfer-manager @@ -612,17 +630,6 @@ under the License. avro - - - org.apache.hudi - hudi-common - - - - - org.apache.hudi - hudi-hadoop-mr - org.apache.parquet parquet-avro @@ -736,9 +743,15 @@ under the License. org.immutables value + - io.trino - trino-main + jakarta.servlet + jakarta.servlet-api + ${jakarta.servlet-api.version} io.airlift @@ -778,20 +791,19 @@ under the License. mockito-inline test - + it.unimi.dsi fastutil-core - - - central - central maven repo https - https://repo.maven.apache.org/maven2 - huawei-obs-sdk @@ -966,6 +978,35 @@ under the License. + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + check-fecore-metadata-funnel + validate + + exec + + + ${project.basedir}/../../tools/check-fecore-metadata-funnel.sh + + ${project.basedir} + + + + + diff --git a/fe/fe-core/src/main/java/com/aliyun/datalake/metastore/hive2/ProxyMetaStoreClient.java b/fe/fe-core/src/main/java/com/aliyun/datalake/metastore/hive2/ProxyMetaStoreClient.java deleted file mode 100644 index 7c918568063ffc..00000000000000 --- a/fe/fe-core/src/main/java/com/aliyun/datalake/metastore/hive2/ProxyMetaStoreClient.java +++ /dev/null @@ -1,2193 +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. -// -// Copied from: -// https://github.com/aliyun/datalake-catalog-metastore-client/blob/master/metastore-client-hive/metastore-client-hive2/src/main/java/com/aliyun/datalake/metastore/hive2/ProxyMetaStoreClient.java -// 3c6f5905 - -package com.aliyun.datalake.metastore.hive2; - -import com.aliyun.datalake.metastore.common.DataLakeConfig; -import com.aliyun.datalake.metastore.common.ProxyMode; -import com.aliyun.datalake.metastore.common.Version; -import com.aliyun.datalake.metastore.common.functional.FunctionalUtils; -import com.aliyun.datalake.metastore.common.functional.ThrowingConsumer; -import com.aliyun.datalake.metastore.common.functional.ThrowingFunction; -import com.aliyun.datalake.metastore.common.functional.ThrowingRunnable; -import com.aliyun.datalake.metastore.common.util.DataLakeUtil; -import com.aliyun.datalake.metastore.common.util.ProxyLogUtils; -import com.aliyun.datalake.metastore.hive.common.utils.ClientUtils; -import com.aliyun.datalake.metastore.hive.common.utils.ConfigUtils; -import com.google.common.annotations.VisibleForTesting; -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.hive.common.ValidTxnList; -import org.apache.hadoop.hive.common.ValidWriteIdList; -import org.apache.hadoop.hive.conf.HiveConf; -import org.apache.hadoop.hive.metastore.HiveMetaHookLoader; -import org.apache.hadoop.hive.metastore.IMetaStoreClient; -import org.apache.hadoop.hive.metastore.PartitionDropOptions; -import org.apache.hadoop.hive.metastore.TableType; -import org.apache.hadoop.hive.metastore.api.AggrStats; -import org.apache.hadoop.hive.metastore.api.AlreadyExistsException; -import org.apache.hadoop.hive.metastore.api.Catalog; -import org.apache.hadoop.hive.metastore.api.CheckConstraintsRequest; -import org.apache.hadoop.hive.metastore.api.CmRecycleRequest; -import org.apache.hadoop.hive.metastore.api.CmRecycleResponse; -import org.apache.hadoop.hive.metastore.api.ColumnStatistics; -import org.apache.hadoop.hive.metastore.api.ColumnStatisticsDesc; -import org.apache.hadoop.hive.metastore.api.ColumnStatisticsObj; -import org.apache.hadoop.hive.metastore.api.CompactionResponse; -import org.apache.hadoop.hive.metastore.api.CompactionType; -import org.apache.hadoop.hive.metastore.api.ConfigValSecurityException; -import org.apache.hadoop.hive.metastore.api.CreationMetadata; -import org.apache.hadoop.hive.metastore.api.CurrentNotificationEventId; -import org.apache.hadoop.hive.metastore.api.DataOperationType; -import org.apache.hadoop.hive.metastore.api.Database; -import org.apache.hadoop.hive.metastore.api.DefaultConstraintsRequest; -import org.apache.hadoop.hive.metastore.api.EnvironmentContext; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.FindSchemasByColsResp; -import org.apache.hadoop.hive.metastore.api.FindSchemasByColsRqst; -import org.apache.hadoop.hive.metastore.api.FireEventRequest; -import org.apache.hadoop.hive.metastore.api.FireEventResponse; -import org.apache.hadoop.hive.metastore.api.ForeignKeysRequest; -import org.apache.hadoop.hive.metastore.api.Function; -import org.apache.hadoop.hive.metastore.api.GetAllFunctionsResponse; -import org.apache.hadoop.hive.metastore.api.GetOpenTxnsInfoResponse; -import org.apache.hadoop.hive.metastore.api.GetPrincipalsInRoleRequest; -import org.apache.hadoop.hive.metastore.api.GetPrincipalsInRoleResponse; -import org.apache.hadoop.hive.metastore.api.GetRoleGrantsForPrincipalRequest; -import org.apache.hadoop.hive.metastore.api.GetRoleGrantsForPrincipalResponse; -import org.apache.hadoop.hive.metastore.api.HeartbeatTxnRangeResponse; -import org.apache.hadoop.hive.metastore.api.HiveObjectPrivilege; -import org.apache.hadoop.hive.metastore.api.HiveObjectRef; -import org.apache.hadoop.hive.metastore.api.ISchema; -import org.apache.hadoop.hive.metastore.api.InvalidInputException; -import org.apache.hadoop.hive.metastore.api.InvalidObjectException; -import org.apache.hadoop.hive.metastore.api.InvalidOperationException; -import org.apache.hadoop.hive.metastore.api.InvalidPartitionException; -import org.apache.hadoop.hive.metastore.api.LockRequest; -import org.apache.hadoop.hive.metastore.api.LockResponse; -import org.apache.hadoop.hive.metastore.api.Materialization; -import org.apache.hadoop.hive.metastore.api.MetaException; -import org.apache.hadoop.hive.metastore.api.MetadataPpdResult; -import org.apache.hadoop.hive.metastore.api.NoSuchLockException; -import org.apache.hadoop.hive.metastore.api.NoSuchObjectException; -import org.apache.hadoop.hive.metastore.api.NoSuchTxnException; -import org.apache.hadoop.hive.metastore.api.NotNullConstraintsRequest; -import org.apache.hadoop.hive.metastore.api.NotificationEventResponse; -import org.apache.hadoop.hive.metastore.api.NotificationEventsCountRequest; -import org.apache.hadoop.hive.metastore.api.NotificationEventsCountResponse; -import org.apache.hadoop.hive.metastore.api.OpenTxnsResponse; -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.PartitionEventType; -import org.apache.hadoop.hive.metastore.api.PartitionValuesRequest; -import org.apache.hadoop.hive.metastore.api.PartitionValuesResponse; -import org.apache.hadoop.hive.metastore.api.PrimaryKeysRequest; -import org.apache.hadoop.hive.metastore.api.PrincipalPrivilegeSet; -import org.apache.hadoop.hive.metastore.api.PrincipalType; -import org.apache.hadoop.hive.metastore.api.PrivilegeBag; -import org.apache.hadoop.hive.metastore.api.Role; -import org.apache.hadoop.hive.metastore.api.RuntimeStat; -import org.apache.hadoop.hive.metastore.api.SQLCheckConstraint; -import org.apache.hadoop.hive.metastore.api.SQLDefaultConstraint; -import org.apache.hadoop.hive.metastore.api.SQLForeignKey; -import org.apache.hadoop.hive.metastore.api.SQLNotNullConstraint; -import org.apache.hadoop.hive.metastore.api.SQLPrimaryKey; -import org.apache.hadoop.hive.metastore.api.SQLUniqueConstraint; -import org.apache.hadoop.hive.metastore.api.SchemaVersion; -import org.apache.hadoop.hive.metastore.api.SchemaVersionState; -import org.apache.hadoop.hive.metastore.api.SerDeInfo; -import org.apache.hadoop.hive.metastore.api.SetPartitionsStatsRequest; -import org.apache.hadoop.hive.metastore.api.ShowCompactResponse; -import org.apache.hadoop.hive.metastore.api.ShowLocksRequest; -import org.apache.hadoop.hive.metastore.api.ShowLocksResponse; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.hadoop.hive.metastore.api.TableMeta; -import org.apache.hadoop.hive.metastore.api.TableValidWriteIds; -import org.apache.hadoop.hive.metastore.api.TxnAbortedException; -import org.apache.hadoop.hive.metastore.api.TxnOpenException; -import org.apache.hadoop.hive.metastore.api.TxnToWriteId; -import org.apache.hadoop.hive.metastore.api.UniqueConstraintsRequest; -import org.apache.hadoop.hive.metastore.api.UnknownDBException; -import org.apache.hadoop.hive.metastore.api.UnknownPartitionException; -import org.apache.hadoop.hive.metastore.api.UnknownTableException; -import org.apache.hadoop.hive.metastore.api.WMFullResourcePlan; -import org.apache.hadoop.hive.metastore.api.WMMapping; -import org.apache.hadoop.hive.metastore.api.WMNullablePool; -import org.apache.hadoop.hive.metastore.api.WMNullableResourcePlan; -import org.apache.hadoop.hive.metastore.api.WMPool; -import org.apache.hadoop.hive.metastore.api.WMResourcePlan; -import org.apache.hadoop.hive.metastore.api.WMTrigger; -import org.apache.hadoop.hive.metastore.api.WMValidateResourcePlanResponse; -import org.apache.hadoop.hive.metastore.partition.spec.PartitionSpecProxy; -import org.apache.hadoop.hive.metastore.utils.ObjectPair; -import org.apache.hadoop.hive.ql.session.SessionState; -import shade.doris.hive.org.apache.thrift.TException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; - -public class ProxyMetaStoreClient implements IMetaStoreClient { - private static final Logger logger = LoggerFactory.getLogger(ProxyMetaStoreClient.class); - private final static String HIVE_FACTORY_CLASS = "org.apache.hadoop.hive.ql.metadata.SessionHiveMetaStoreClientFactory"; - - private final ProxyMode proxyMode; - - // Dlf Client - private IMetaStoreClient dlfSessionMetaStoreClient; - - // Hive Client - private IMetaStoreClient hiveSessionMetaStoreClient; - - // ReadWrite Client - private IMetaStoreClient readWriteClient; - - // Extra Write Client - private Optional extraClient; - - // Allow failure - private boolean allowFailure = false; - - // copy Hive conf - private HiveConf hiveConf; - - private final String readWriteClientType; - - public ProxyMetaStoreClient(HiveConf hiveConf) throws MetaException { - this(hiveConf, null, false); - } - - // morningman: add this constructor to avoid NoSuchMethod exception - public ProxyMetaStoreClient(Configuration conf, HiveMetaHookLoader hiveMetaHookLoader, Boolean allowEmbedded) - throws MetaException { - this((HiveConf) conf, hiveMetaHookLoader, allowEmbedded); - } - - public ProxyMetaStoreClient(HiveConf hiveConf, HiveMetaHookLoader hiveMetaHookLoader, Boolean allowEmbedded) - throws MetaException { - long startTime = System.currentTimeMillis(); - logger.info("ProxyMetaStoreClient start, datalake-metastore-client-version:{}", - Version.DATALAKE_METASTORE_CLIENT_VERSION); - this.hiveConf = new HiveConf(hiveConf); - - proxyMode = ConfigUtils.getProxyMode(hiveConf); - - // init logging if needed - ProxyLogUtils.initLogUtils(proxyMode, hiveConf.get(DataLakeConfig.CATALOG_PROXY_LOGSTORE, - ConfigUtils.getUserId(hiveConf)), hiveConf.getBoolean(DataLakeConfig.CATALOG_ACTION_LOG_ENABLED, - DataLakeConfig.DEFAULT_CATALOG_ACTION_LOG_ENABLED), - hiveConf.getBoolean(DataLakeConfig.CATALOG_LOG_ENABLED, DataLakeConfig.DEFAULT_CATALOG_LOG_ENABLED)); - - // init Dlf Client if any - createClient(true, () -> initDlfClient(hiveConf, hiveMetaHookLoader, allowEmbedded, new ConcurrentHashMap<>())); - - // init Hive Client if any - createClient(false, () -> initHiveClient(hiveConf, hiveMetaHookLoader, allowEmbedded, new ConcurrentHashMap<>())); - - // init extraClient - initClientByProxyMode(); - - readWriteClientType = this.readWriteClient instanceof DlfSessionMetaStoreClient ? "dlf" : "hive"; - - logger.info("ProxyMetaStoreClient end, cost:{}ms", System.currentTimeMillis() - startTime); - } - - public static Map getTempTablesForDatabase(String dbName) { - return getTempTables().get(dbName); - } - - public static Map> getTempTables() { - SessionState ss = SessionState.get(); - if (ss == null) { - return Collections.emptyMap(); - } - return ss.getTempTables(); - } - - public HiveConf getHiveConf() { - return hiveConf; - } - - public void initClientByProxyMode() throws MetaException { - switch (proxyMode) { - case METASTORE_ONLY: - this.readWriteClient = hiveSessionMetaStoreClient; - this.extraClient = Optional.empty(); - break; - case METASTORE_DLF_FAILURE: - this.allowFailure = true; - this.readWriteClient = hiveSessionMetaStoreClient; - this.extraClient = Optional.ofNullable(dlfSessionMetaStoreClient); - break; - case METASTORE_DLF_SUCCESS: - this.readWriteClient = hiveSessionMetaStoreClient; - this.extraClient = Optional.of(dlfSessionMetaStoreClient); - break; - case DLF_METASTORE_SUCCESS: - this.readWriteClient = dlfSessionMetaStoreClient; - this.extraClient = Optional.of(hiveSessionMetaStoreClient); - break; - case DLF_METASTORE_FAILURE: - this.allowFailure = true; - this.readWriteClient = dlfSessionMetaStoreClient; - this.extraClient = Optional.ofNullable(hiveSessionMetaStoreClient); - break; - case DLF_ONLY: - this.readWriteClient = dlfSessionMetaStoreClient; - this.extraClient = Optional.empty(); - break; - default: - throw new IllegalStateException("Unexpected value: " + proxyMode); - } - } - - private void createClient(boolean isDlf, ThrowingRunnable createClient) throws MetaException { - try { - createClient.run(); - } catch (Exception e) { - if ((isDlf && proxyMode == ProxyMode.METASTORE_DLF_FAILURE)) { - dlfSessionMetaStoreClient = null; - } else if (!isDlf && proxyMode == ProxyMode.DLF_METASTORE_FAILURE) { - hiveSessionMetaStoreClient = null; - } else { - throw DataLakeUtil.throwException(new MetaException(e.getMessage()), e); - } - } - } - - public void initHiveClient(HiveConf hiveConf, HiveMetaHookLoader hiveMetaHookLoader, boolean allowEmbedded, - ConcurrentHashMap metaCallTimeMap) throws MetaException { - switch (proxyMode) { - case METASTORE_ONLY: - case METASTORE_DLF_FAILURE: - case METASTORE_DLF_SUCCESS: - case DLF_METASTORE_SUCCESS: - case DLF_METASTORE_FAILURE: - this.hiveSessionMetaStoreClient = ClientUtils.createMetaStoreClient(HIVE_FACTORY_CLASS, - hiveConf, hiveMetaHookLoader, allowEmbedded, metaCallTimeMap); - break; - case DLF_ONLY: - break; - default: - throw new IllegalStateException("Unexpected value: " + proxyMode); - } - } - - public void initDlfClient(HiveConf hiveConf, HiveMetaHookLoader hiveMetaHookLoader, boolean allowEmbedded, - ConcurrentHashMap metaCallTimeMap) throws MetaException { - switch (proxyMode) { - case METASTORE_ONLY: - break; - case METASTORE_DLF_FAILURE: - case METASTORE_DLF_SUCCESS: - case DLF_METASTORE_SUCCESS: - case DLF_METASTORE_FAILURE: - case DLF_ONLY: - this.dlfSessionMetaStoreClient = new DlfSessionMetaStoreClient(hiveConf, hiveMetaHookLoader, allowEmbedded); - break; - default: - throw new IllegalStateException("Unexpected value: " + proxyMode); - } - } - - @Override - public boolean isCompatibleWith(Configuration conf) { - try { - return call(this.readWriteClient, client -> client.isCompatibleWith(conf), "isCompatibleWith", conf); - } catch (TException e) { - logger.error(e.getMessage(), e); - } - return false; - } - - @Override - public void setHiveAddedJars(String s) { - try { - run(client -> client.setHiveAddedJars(s), "setHiveAddedJars", s); - } catch (TException e) { - logger.error(e.getMessage(), e); - } - } - - @Override - public boolean isLocalMetaStore() { - return !extraClient.isPresent() && readWriteClient.isLocalMetaStore(); - } - - @Override - public void reconnect() throws MetaException { - if (hiveSessionMetaStoreClient != null) { - hiveSessionMetaStoreClient.reconnect(); - } - } - - @Override - public void close() { - if (hiveSessionMetaStoreClient != null) { - hiveSessionMetaStoreClient.close(); - } - } - - @Override - public void createDatabase(Database database) - throws InvalidObjectException, AlreadyExistsException, MetaException, TException { - run(client -> client.createDatabase(database), "createDatabase", database); - } - - @Override - public Database getDatabase(String name) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.getDatabase(name), "getDatabase", name); - } - - @Override - public Database getDatabase(String catalogId, String databaseName) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.getDatabase(catalogId, databaseName), "getDatabase", catalogId, databaseName); - } - - @Override - public List getDatabases(String pattern) throws MetaException, TException { - return call(this.readWriteClient, client -> client.getDatabases(pattern), "getDatabases", pattern); - } - - @Override - public List getDatabases(String catalogId, String databasePattern) throws MetaException, TException { - return call(this.readWriteClient, client -> client.getDatabases(catalogId, databasePattern), "getDatabases", catalogId, databasePattern); - } - - @Override - public List getAllDatabases() throws MetaException, TException { - return getDatabases(".*"); - } - - @Override - public List getAllDatabases(String catalogId) throws MetaException, TException { - return getDatabases(catalogId); - } - - @Override - public void alterDatabase(String databaseName, Database database) - throws NoSuchObjectException, MetaException, TException { - run(client -> client.alterDatabase(databaseName, database), "alterDatabase", databaseName, database); - } - - @Override - public void alterDatabase(String catalogId, String databaseName, Database database) throws NoSuchObjectException, MetaException, TException { - run(client -> client.alterDatabase(catalogId, databaseName, database), "alterDatabase", catalogId, databaseName, database); - } - - @Override - public void dropDatabase(String name) - throws NoSuchObjectException, InvalidOperationException, MetaException, TException { - dropDatabase(name, true, false, false); - } - - @Override - public void dropDatabase(String name, boolean deleteData, boolean ignoreUnknownDb) - throws NoSuchObjectException, InvalidOperationException, MetaException, TException { - dropDatabase(name, deleteData, ignoreUnknownDb, false); - } - - @Override - public void dropDatabase(String name, boolean deleteData, boolean ignoreUnknownDb, boolean cascade) - throws NoSuchObjectException, InvalidOperationException, MetaException, TException { - run(client -> client.dropDatabase(name, deleteData, ignoreUnknownDb, cascade), "dropDatabase", name, deleteData, - ignoreUnknownDb, cascade); - } - - @Override - public void dropDatabase(String catalogId, String name, boolean deleteData, boolean ignoreUnknownDb, boolean cascade) throws NoSuchObjectException, InvalidOperationException, MetaException, TException { - run(client -> client.dropDatabase(catalogId, name, deleteData, ignoreUnknownDb, cascade), "dropDatabase", catalogId, name, deleteData, ignoreUnknownDb, cascade); - } - - @Override - public Partition add_partition(Partition partition) - throws InvalidObjectException, AlreadyExistsException, MetaException, TException { - return call(client -> client.add_partition(partition), "add_partition", partition); - } - - @Override - public int add_partitions(List partitions) - throws InvalidObjectException, AlreadyExistsException, MetaException, - TException { - return call(client -> client.add_partitions(partitions), "add_partitions", partitions); - } - - @Override - public List add_partitions( - List partitions, - boolean ifNotExists, - boolean needResult - ) throws TException { - return call(client -> client.add_partitions(partitions, ifNotExists, needResult), "add_partitions", partitions, ifNotExists, needResult); - } - - @Override - public int add_partitions_pspec(PartitionSpecProxy pSpec) - throws InvalidObjectException, AlreadyExistsException, MetaException, TException { - return call(client -> client.add_partitions_pspec(pSpec), "add_partitions_pspec", pSpec); - } - - @Override - public void alterFunction(String dbName, String functionName, Function newFunction) - throws InvalidObjectException, MetaException, TException { - run(client -> client.alterFunction(dbName, functionName, newFunction), "alterFunction", dbName, functionName, newFunction); - } - - @Override - public void alterFunction(String catalogId, String dbName, String functionName, Function newFunction) throws InvalidObjectException, MetaException, TException { - run(client -> client.alterFunction(catalogId, dbName, functionName, newFunction), "alterFunction", catalogId, dbName, functionName, newFunction); - } - - @Override - public void alter_partition(String dbName, String tblName, Partition partition) - throws InvalidOperationException, MetaException, TException { - run(client -> client.alter_partition(dbName, tblName, partition), "alter_partition", dbName, tblName, partition); - } - - @Override - public void alter_partition( - String dbName, - String tblName, - Partition partition, - EnvironmentContext environmentContext - ) throws InvalidOperationException, MetaException, TException { - run(client -> client.alter_partition(dbName, tblName, partition, environmentContext), "alter_partition", dbName, tblName, partition, environmentContext); - } - - @Override - public void alter_partition(String catalogId, String dbName, String tblName, Partition partition, EnvironmentContext environmentContext) throws InvalidOperationException, MetaException, TException { - run(client -> client.alter_partition(catalogId, dbName, tblName, partition, environmentContext), "alter_partition", catalogId, dbName, tblName, partition, environmentContext); - } - - @Override - public void alter_partitions( - String dbName, - String tblName, - List partitions - ) throws InvalidOperationException, MetaException, TException { - run(client -> client.alter_partitions(dbName, tblName, partitions), "alter_partitions", dbName, tblName, partitions); - } - - @Override - public void alter_partitions( - String dbName, - String tblName, - List partitions, - EnvironmentContext environmentContext - ) throws InvalidOperationException, MetaException, TException { - run(client -> client.alter_partitions(dbName, tblName, partitions, environmentContext), "alter_partitions", dbName, tblName, partitions, environmentContext); - } - - @Override - public void alter_partitions(String catalogId, String dbName, String tblName, List partitions, EnvironmentContext environmentContext) throws InvalidOperationException, MetaException, TException { - run(client -> client.alter_partitions(catalogId, dbName, tblName, partitions, environmentContext), "alter_partitions", catalogId, dbName, tblName, partitions, environmentContext); - } - - @Override - public void alter_table(String dbName, String tblName, Table table) - throws InvalidOperationException, MetaException, TException { - if (table.isTemporary()) { - run(this.readWriteClient, client -> client.alter_table(dbName, tblName, table), "alter_table", dbName, tblName, table); - } else { - run(client -> client.alter_table(dbName, tblName, table), "alter_table", dbName, tblName, table); - } - } - - @Override - public void alter_table(String catalogId, String dbName, String tblName, Table table, EnvironmentContext environmentContext) throws InvalidOperationException, MetaException, TException { - if (table.isTemporary()) { - run(this.readWriteClient, client -> client.alter_table(catalogId, dbName, tblName, table, environmentContext), "alter_table", catalogId, dbName, tblName, table, environmentContext); - } else { - run(client -> client.alter_table(catalogId, dbName, tblName, table, environmentContext), "alter_table", catalogId, dbName, tblName, table, environmentContext); - } - } - - @Override - @Deprecated - public void alter_table( - String dbName, - String tblName, - Table table, - boolean cascade - ) throws InvalidOperationException, MetaException, TException { - if (table.isTemporary()) { - run(this.readWriteClient, client -> client.alter_table(dbName, tblName, table, cascade), "alter_table", dbName, tblName, table, cascade); - } else { - run(client -> client.alter_table(dbName, tblName, table, cascade), "alter_table", dbName, tblName, table, cascade); - } - } - - @Override - public void alter_table_with_environmentContext( - String dbName, - String tblName, - Table table, - EnvironmentContext environmentContext - ) throws InvalidOperationException, MetaException, TException { - if (table.isTemporary()) { - run(this.readWriteClient, client -> client.alter_table_with_environmentContext(dbName, tblName, table, environmentContext), "alter_table_with_environmentContext", dbName, tblName, table, environmentContext); - } else { - run(client -> client.alter_table_with_environmentContext(dbName, tblName, table, environmentContext), "alter_table_with_environmentContext", dbName, - tblName, table, environmentContext); - } - } - - @Override - public Partition appendPartition(String dbName, String tblName, List values) - throws InvalidObjectException, AlreadyExistsException, MetaException, TException { - return call(client -> client.appendPartition(dbName, tblName, values), "appendPartition", dbName, tblName, values); - } - - @Override - public Partition appendPartition(String catalogId, String dbName, String tblName, List values) throws InvalidObjectException, AlreadyExistsException, MetaException, TException { - return call(client -> client.appendPartition(catalogId, dbName, tblName, values), "appendPartition", catalogId, dbName, tblName, values); - } - - @Override - public Partition appendPartition(String dbName, String tblName, String partitionName) - throws InvalidObjectException, AlreadyExistsException, MetaException, TException { - return call(client -> client.appendPartition(dbName, tblName, partitionName), "appendPartition", dbName, tblName, partitionName); - } - - @Override - public Partition appendPartition(String catalogId, String dbName, String tblName, String partitionName) throws InvalidObjectException, AlreadyExistsException, MetaException, TException { - return call(client -> client.appendPartition(catalogId, dbName, tblName, partitionName), "appendPartition", catalogId, dbName, tblName, partitionName); - } - - @Override - public boolean create_role(Role role) throws MetaException, TException { - return call(client -> client.create_role(role), "create_role", role); - } - - @Override - public boolean drop_role(String roleName) throws MetaException, TException { - return call(client -> client.drop_role(roleName), "drop_role", roleName); - } - - @Override - public List list_roles( - String principalName, PrincipalType principalType - ) throws MetaException, TException { - return call(this.readWriteClient, client -> client.list_roles(principalName, principalType), "list_roles", principalName, principalType); - } - - @Override - public List listRoleNames() throws MetaException, TException { - return call(this.readWriteClient, client -> client.listRoleNames(), "listRoleNames"); - } - - @Override - public GetPrincipalsInRoleResponse get_principals_in_role(GetPrincipalsInRoleRequest request) - throws MetaException, TException { - return call(this.readWriteClient, client -> client.get_principals_in_role(request), "get_principals_in_role", request); - } - - @Override - public GetRoleGrantsForPrincipalResponse get_role_grants_for_principal( - GetRoleGrantsForPrincipalRequest request) throws MetaException, TException { - return call(this.readWriteClient, client -> client.get_role_grants_for_principal(request), "get_role_grants_for_principal", request); - } - - @Override - public boolean grant_role( - String roleName, - String userName, - PrincipalType principalType, - String grantor, - PrincipalType grantorType, - boolean grantOption - ) throws MetaException, TException { - return call(client -> client.grant_role(roleName, userName, principalType, grantor, grantorType, grantOption) - , "grant_role", roleName, userName, principalType, grantor, grantorType); - } - - @Override - public boolean revoke_role( - String roleName, - String userName, - PrincipalType principalType, - boolean grantOption - ) throws MetaException, TException { - return call(client -> client.revoke_role(roleName, userName, principalType, grantOption), "revoke_role", roleName, userName, - principalType, grantOption); - } - - @Override - public void cancelDelegationToken(String tokenStrForm) throws MetaException, TException { - run(client -> client.cancelDelegationToken(tokenStrForm), "cancelDelegationToken", tokenStrForm); - } - - @Override - public String getTokenStrForm() throws IOException { - try { - return call(this.readWriteClient, client -> { - try { - return client.getTokenStrForm(); - } catch (IOException e) { - throw new TException(e.getMessage(), e); - } - }, "getTokenStrForm"); - } catch (TException e) { - throw new IOException(e.getMessage(), e); - } - } - - @Override - public boolean addToken(String tokenIdentifier, String delegationToken) throws TException { - return call(client -> client.addToken(tokenIdentifier, delegationToken), "addToken", tokenIdentifier, delegationToken); - } - - @Override - public boolean removeToken(String tokenIdentifier) throws TException { - return call(client -> client.removeToken(tokenIdentifier), "removeToken", tokenIdentifier); - } - - @Override - public String getToken(String tokenIdentifier) throws TException { - return call(this.readWriteClient, client -> client.getToken(tokenIdentifier), "getToken", tokenIdentifier); - } - - @Override - public List getAllTokenIdentifiers() throws TException { - return call(this.readWriteClient, client -> client.getAllTokenIdentifiers(), "getAllTokenIdentifiers"); - } - - @Override - public int addMasterKey(String key) throws MetaException, TException { - return call(client -> client.addMasterKey(key), "addMasterKey", key); - } - - @Override - public void updateMasterKey(Integer seqNo, String key) - throws NoSuchObjectException, MetaException, TException { - run(client -> client.updateMasterKey(seqNo, key), "updateMasterKey", key); - } - - @Override - public boolean removeMasterKey(Integer keySeq) throws TException { - return call(client -> client.removeMasterKey(keySeq), "removeMasterKey", keySeq); - } - - @Override - public String[] getMasterKeys() throws TException { - return call(this.readWriteClient, client -> client.getMasterKeys(), "getMasterKeys"); - } - - @Override - public LockResponse checkLock(long lockId) - throws NoSuchTxnException, TxnAbortedException, NoSuchLockException, TException { - return call(this.readWriteClient, client -> client.checkLock(lockId), "checkLock", lockId); - } - - @Override - public void commitTxn(long txnId) throws NoSuchTxnException, TxnAbortedException, TException { - run(client -> client.commitTxn(txnId), "commitTxn", txnId); - } - - @Override - public void replCommitTxn(long srcTxnId, String replPolicy) throws NoSuchTxnException, TxnAbortedException, TException { - run(client -> client.replCommitTxn(srcTxnId, replPolicy), "replCommitTxn", srcTxnId, replPolicy); - } - - @Override - public void abortTxns(List txnIds) throws TException { - run(client -> client.abortTxns(txnIds), "abortTxns", txnIds); - } - - @Override - public long allocateTableWriteId(long txnId, String dbName, String tableName) throws TException { - return call(client -> client.allocateTableWriteId(txnId, dbName, tableName), "allocateTableWriteId", txnId, dbName, tableName); - } - - @Override - public void replTableWriteIdState(String validWriteIdList, String dbName, String tableName, List partNames) throws TException { - run(client -> client.replTableWriteIdState(validWriteIdList, dbName, tableName, partNames), "replTableWriteIdState", validWriteIdList, dbName, tableName, partNames); - } - - @Override - public List allocateTableWriteIdsBatch(List txnIds, String dbName, String tableName) throws TException { - return call(client -> client.allocateTableWriteIdsBatch(txnIds, dbName, tableName), "allocateTableWriteIdsBatch", txnIds, dbName, tableName); - } - - @Override - public List replAllocateTableWriteIdsBatch(String dbName, String tableName, - String replPolicy, List srcTxnToWriteIdList) throws TException { - return call(client -> client.replAllocateTableWriteIdsBatch(dbName, tableName, replPolicy, srcTxnToWriteIdList), "replAllocateTableWriteIdsBatch", dbName, tableName, replPolicy, srcTxnToWriteIdList); - } - - @Override - @Deprecated - public void compact( - String dbName, - String tblName, - String partitionName, - CompactionType compactionType - ) throws TException { - run(client -> client.compact(dbName, tblName, partitionName, compactionType), "compact", dbName, tblName, partitionName, compactionType); - } - - @Override - @Deprecated - public void compact( - String dbName, - String tblName, - String partitionName, - CompactionType compactionType, - Map tblProperties - ) throws TException { - run(client -> client.compact(dbName, tblName, partitionName, compactionType, tblProperties), "compact", dbName, tblName, partitionName, compactionType, tblProperties); - } - - @Override - public CompactionResponse compact2( - String dbName, - String tblName, - String partitionName, - CompactionType compactionType, - Map tblProperties - ) throws TException { - return call(client -> client.compact2(dbName, tblName, partitionName, compactionType, tblProperties), "compact2", dbName, tblName, partitionName, compactionType, tblProperties); - } - - @Override - public void createFunction(Function function) throws InvalidObjectException, MetaException, TException { - run(client -> client.createFunction(function), "createFunction", function); - } - - @Override - public void createTable(Table tbl) - throws AlreadyExistsException, InvalidObjectException, MetaException, NoSuchObjectException, TException { - createTable(tbl, null); - } - - public void createTable(Table tbl, EnvironmentContext envContext) throws AlreadyExistsException, - InvalidObjectException, MetaException, NoSuchObjectException, TException { - // Subclasses can override this step (for example, for temporary tables) - if (tbl.isTemporary()) { - run(this.readWriteClient, client -> client.createTable(tbl), "createTable", tbl); - } else { - run(client -> client.createTable(tbl), "createTable", tbl); - } - } - - @Override - public boolean deletePartitionColumnStatistics( - String dbName, String tableName, String partName, String colName - ) throws NoSuchObjectException, MetaException, InvalidObjectException, TException, InvalidInputException { - return call(client -> client.deletePartitionColumnStatistics(dbName, tableName, partName, colName), "deletePartitionColumnStatistics", dbName, - tableName, partName, colName); - } - - @Override - public boolean deletePartitionColumnStatistics(String catalogId, String dbName, String tableName, String partName, String colName) throws NoSuchObjectException, MetaException, InvalidObjectException, TException, InvalidInputException { - return call(client -> client.deletePartitionColumnStatistics(catalogId, dbName, tableName, partName, colName), "deletePartitionColumnStatistics", catalogId, dbName, tableName, partName, colName); - } - - @Override - public boolean deleteTableColumnStatistics(String dbName, String tableName, String colName) - throws NoSuchObjectException, MetaException, InvalidObjectException, - TException, InvalidInputException { - if (getTempTable(dbName, tableName) != null) { - return call(this.readWriteClient, client -> client.deleteTableColumnStatistics(dbName, tableName, colName), "deleteTableColumnStatistics", dbName, tableName, colName); - } else { - return call(client -> client.deleteTableColumnStatistics(dbName, tableName, colName), "deleteTableColumnStatistics", dbName, tableName, colName); - } - } - - @Override - public boolean deleteTableColumnStatistics(String catalogId, String dbName, String tableName, String colName) throws NoSuchObjectException, MetaException, InvalidObjectException, TException, InvalidInputException { - if (getTempTable(dbName, tableName) != null) { - return call(this.readWriteClient, client -> client.deleteTableColumnStatistics(catalogId, dbName, tableName, colName), "deleteTableColumnStatistics", catalogId, dbName, tableName, colName); - } else { - return call(client -> client.deleteTableColumnStatistics(catalogId, dbName, tableName, colName), "deleteTableColumnStatistics", catalogId, dbName, tableName, colName); - } - } - - @Override - public void dropFunction(String dbName, String functionName) throws MetaException, NoSuchObjectException, - InvalidObjectException, InvalidInputException, TException { - run(client -> client.dropFunction(dbName, functionName), "dropFunction", dbName, functionName); - } - - @Override - public void dropFunction(String catalogId, String dbName, String functionName) throws MetaException, NoSuchObjectException, InvalidObjectException, InvalidInputException, TException { - run(client -> client.dropFunction(catalogId, dbName, functionName), "dropFunction", catalogId, dbName, functionName); - } - - @Override - public boolean dropPartition(String dbName, String tblName, List values, boolean deleteData) - throws NoSuchObjectException, MetaException, TException { - return call(client -> client.dropPartition(dbName, tblName, values, deleteData), "dropPartition", dbName, tblName, values, deleteData); - } - - @Override - public boolean dropPartition(String catalogId, String dbName, String tblName, List values, boolean deleteData) throws NoSuchObjectException, MetaException, TException { - return call(client -> client.dropPartition(catalogId, dbName, tblName, values, deleteData), "dropPartition", catalogId, dbName, tblName, values, deleteData); - } - - @Override - public boolean dropPartition(String dbName, String tblName, List values, PartitionDropOptions options) - throws TException { - return call(client -> client.dropPartition(dbName, tblName, values, options), "dropPartition", dbName, tblName, values, options); - } - - @Override - public boolean dropPartition(String catalogId, String dbName, String tblName, List values, PartitionDropOptions options) throws NoSuchObjectException, MetaException, TException { - return call(client -> client.dropPartition(catalogId, dbName, tblName, values, options), "dropPartition", catalogId, dbName, tblName, values, options); - } - - @Override - public List dropPartitions(String dbName, String tblName, - List> partExprs, boolean deleteData, - boolean ifExists) throws NoSuchObjectException, MetaException, TException { - return call(client -> client.dropPartitions(dbName, tblName, partExprs, deleteData, ifExists), "dropPartitions", dbName, tblName, partExprs, deleteData, ifExists); - } - - @Override - public List dropPartitions(String dbName, String tblName, - List> partExprs, boolean deleteData, - boolean ifExists, boolean needResult) throws NoSuchObjectException, MetaException, TException { - return call(client -> client.dropPartitions(dbName, tblName, partExprs, deleteData, ifExists, needResult), "dropPartitions", dbName, tblName, partExprs, deleteData, ifExists, needResult); - } - - @Override - public List dropPartitions(String dbName, String tblName, List> partExprs, PartitionDropOptions partitionDropOptions) throws NoSuchObjectException, MetaException, TException { - return call(client -> client.dropPartitions(dbName, tblName, partExprs, partitionDropOptions), "dropPartitions", dbName, tblName, partExprs, partitionDropOptions); - } - - @Override - public List dropPartitions(String catalogId, String dbName, String tblName, List> partExprs, PartitionDropOptions partitionDropOptions) throws NoSuchObjectException, MetaException, TException { - return call(client -> client.dropPartitions(catalogId, dbName, tblName, partExprs, partitionDropOptions), "dropPartitions", catalogId, dbName, tblName, partExprs, partitionDropOptions); - } - - @Override - public boolean dropPartition(String dbName, String tblName, String partitionName, boolean deleteData) - throws NoSuchObjectException, MetaException, TException { - return call(client -> client.dropPartition(dbName, tblName, partitionName, deleteData), "dropPartition", dbName, tblName, - partitionName, deleteData); - } - - @Override - public boolean dropPartition(String catName, String dbName, String tblName, String partitionName, - boolean deleteData) throws NoSuchObjectException, MetaException, TException { - return call(client -> client.dropPartition(catName, dbName, tblName, partitionName, deleteData), "dropPartition", catName, dbName, tblName, partitionName, deleteData); - } - - private Table getTempTable(String dbName, String tableName) { - Map tables = getTempTablesForDatabase(dbName.toLowerCase()); - if (tables != null) { - org.apache.hadoop.hive.ql.metadata.Table table = tables.get(tableName.toLowerCase()); - if (table != null) { - return table.getTTable(); - } - } - return null; - } - - @Override - public void dropTable(String dbname, String tableName) - throws MetaException, TException, NoSuchObjectException { - Table table = getTempTable(dbname, tableName); - if (table != null) { - run(this.readWriteClient, client -> client.dropTable(dbname, tableName), "dropTable", dbname, tableName); - } else { - run(client -> client.dropTable(dbname, tableName), "dropTable", dbname, tableName); - } - } - - @Override - public void dropTable(String catalogId, String dbname, String tableName, boolean deleteData, boolean ignoreUnknownTab, boolean ifPurge) throws MetaException, NoSuchObjectException, TException { - Table table = getTempTable(dbname, tableName); - if (table != null) { - run(this.readWriteClient, client -> client.dropTable(catalogId, dbname, tableName, deleteData, ignoreUnknownTab, ifPurge), "dropTable", catalogId, dbname, tableName, deleteData, ignoreUnknownTab, ifPurge); - } else { - run(client -> client.dropTable(catalogId, dbname, tableName, deleteData, ignoreUnknownTab, ifPurge), "dropTable", catalogId, dbname, tableName, deleteData, ignoreUnknownTab, ifPurge); - } - } - - @Override - public void truncateTable(String dbName, String tableName, List partNames) throws MetaException, TException { - run(client -> client.truncateTable(dbName, tableName, partNames), "truncateTable", dbName, tableName, partNames); - } - - @Override - public void truncateTable(String catName, String dbName, String tableName, List partNames) throws MetaException, TException { - run(client -> client.truncateTable(catName, dbName, tableName, partNames), "truncateTable", catName, dbName, tableName, partNames); - } - - @Override - public CmRecycleResponse recycleDirToCmPath(CmRecycleRequest cmRecycleRequest) throws MetaException, TException { - return call(client -> client.recycleDirToCmPath(cmRecycleRequest), "recycleDirToCmPath", cmRecycleRequest); - } - - @Override - public void dropTable( - String dbname, - String tableName, - boolean deleteData, - boolean ignoreUnknownTab - ) throws MetaException, TException, NoSuchObjectException { - Table table = getTempTable(dbname, tableName); - if (table != null) { - run(this.readWriteClient, client -> client.dropTable(dbname, tableName, deleteData, ignoreUnknownTab), "dropTable", dbname, tableName, deleteData, ignoreUnknownTab); - } else { - run(client -> client.dropTable(dbname, tableName, deleteData, ignoreUnknownTab), "dropTable", dbname, tableName, deleteData, ignoreUnknownTab); - } - } - - @Override - public void dropTable( - String dbname, - String tableName, - boolean deleteData, - boolean ignoreUnknownTable, - boolean ifPurge - ) throws MetaException, TException, NoSuchObjectException { - Table table = getTempTable(dbname, tableName); - if (table != null) { - run(this.readWriteClient, client -> client.dropTable(dbname, tableName, deleteData, ignoreUnknownTable, ifPurge), "dropTable", dbname, tableName, deleteData, ignoreUnknownTable, ifPurge); - } else { - run(client -> client.dropTable(dbname, tableName, deleteData, ignoreUnknownTable, ifPurge), "dropTable", dbname, tableName, deleteData, ignoreUnknownTable, ifPurge); - } - } - - @Override - public Partition exchange_partition( - Map partitionSpecs, - String srcDb, - String srcTbl, - String dstDb, - String dstTbl - ) throws MetaException, NoSuchObjectException, InvalidObjectException, TException { - return call(client -> client.exchange_partition(partitionSpecs, srcDb, srcTbl, dstDb, dstTbl), "exchange_partition", partitionSpecs - , srcDb, srcTbl, dstDb, dstTbl); - } - - @Override - public Partition exchange_partition(Map partitionSpecs, String srcCatalogId, String srcDb, String srcTbl, String descCatalogId, String dstDb, String dstTbl) throws MetaException, NoSuchObjectException, InvalidObjectException, TException { - return call(client -> client.exchange_partition(partitionSpecs, srcCatalogId, srcDb, srcTbl, descCatalogId, dstDb, dstTbl), "exchange_partition", partitionSpecs, srcCatalogId, srcDb, srcTbl, descCatalogId, dstDb, dstTbl); - } - - @Override - public List exchange_partitions( - Map partitionSpecs, - String sourceDb, - String sourceTbl, - String destDb, - String destTbl - ) throws MetaException, NoSuchObjectException, InvalidObjectException, TException { - return call(client -> client.exchange_partitions(partitionSpecs, sourceDb, sourceTbl, destDb, destTbl), "exchange_partitions", - partitionSpecs, sourceDb, sourceTbl, destDb, destTbl); - } - - @Override - public List exchange_partitions(Map partitionSpecs, String srcCatalogId, String sourceDb, String sourceTbl, String dstCatalogId, String destDb, String destTbl) throws MetaException, NoSuchObjectException, InvalidObjectException, TException { - return call(client -> client.exchange_partitions(partitionSpecs, srcCatalogId, sourceDb, sourceTbl, dstCatalogId, destDb, destTbl), "exchange_partitions", partitionSpecs, srcCatalogId, sourceDb, sourceTbl, dstCatalogId, destDb, destTbl); - } - - @Override - public AggrStats getAggrColStatsFor( - String dbName, - String tblName, - List colNames, - List partName - ) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.getAggrColStatsFor(dbName, tblName, colNames, partName), "getAggrColStatsFor", dbName, tblName, colNames, partName); - } - - @Override - public AggrStats getAggrColStatsFor( - String catalogId, - String dbName, - String tblName, - List colNames, - List partName - ) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.getAggrColStatsFor(catalogId, dbName, tblName, colNames, partName), "getAggrColStatsFor", catalogId, dbName, tblName, colNames, partName); - } - - @Override - public List getAllTables(String dbname) - throws MetaException, TException, UnknownDBException { - return getTables(dbname, ".*"); - } - - @Override - public List getAllTables(String catalogId, String dbName) throws MetaException, TException, UnknownDBException { - return getTables(catalogId, dbName); - } - - @Override - public String getConfigValue(String name, String defaultValue) - throws TException, ConfigValSecurityException { - return call(this.readWriteClient, client -> client.getConfigValue(name, defaultValue), "getConfigValue", name, defaultValue); - } - - @Override - public String getDelegationToken(String owner, String renewerKerberosPrincipalName) - throws MetaException, TException { - return call(this.readWriteClient, client -> client.getDelegationToken(owner, renewerKerberosPrincipalName), "getDelegationToken", owner, renewerKerberosPrincipalName); - } - - @Override - public List getFields(String db, String tableName) throws TException { - return call(this.readWriteClient, client -> client.getFields(db, tableName), "getFields", db, tableName); - } - - @Override - public List getFields(String catalogId, String db, String tableName) throws MetaException, TException, UnknownTableException, UnknownDBException { - return call(this.readWriteClient, client -> client.getFields(catalogId, db, tableName), "getFields", catalogId, db, tableName); - } - - @Override - public Function getFunction(String dbName, String functionName) throws MetaException, TException { - return call(this.readWriteClient, client -> client.getFunction(dbName, functionName), "getFunction", dbName, functionName); - } - - @Override - public Function getFunction(String catalogId, String dbName, String funcName) throws MetaException, TException { - return call(this.readWriteClient, client -> client.getFunction(catalogId, dbName, funcName), "getFunction", catalogId, dbName, funcName); - } - - @Override - public List getFunctions(String dbName, String pattern) throws MetaException, TException { - return call(this.readWriteClient, client -> client.getFunctions(dbName, pattern), "getFunctions", dbName, pattern); - } - - @Override - public List getFunctions(String catalogId, String dbName, String pattern) throws MetaException, TException { - return call(this.readWriteClient, client -> client.getFunctions(catalogId, dbName, pattern), "getFunctions", catalogId, dbName, pattern); - } - - @Override - public GetAllFunctionsResponse getAllFunctions() throws MetaException, TException { - return call(this.readWriteClient, client -> client.getAllFunctions(), "getAllFunctions"); - } - - @Override - public String getMetaConf(String key) throws MetaException, TException { - return call(this.readWriteClient, client -> client.getMetaConf(key), "getMetaConf", key); - } - - @Override - public void createCatalog(Catalog catalog) throws AlreadyExistsException, InvalidObjectException, MetaException, TException { - run(client -> client.createCatalog(catalog), "createCatalog", catalog); - } - - @Override - public void alterCatalog(String catalogName, Catalog newCatalog) throws NoSuchObjectException, InvalidObjectException, MetaException, TException { - run(client -> client.alterCatalog(catalogName, newCatalog), "alterCatalog", catalogName, newCatalog); - } - - @Override - public Catalog getCatalog(String catalogId) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.getCatalog(catalogId), "getCatalog", catalogId); - } - - @Override - public List getCatalogs() throws MetaException, TException { - return call(this.readWriteClient, client -> client.getCatalogs(), "getCatalogs"); - } - - @Override - public void dropCatalog(String catalogId) throws NoSuchObjectException, InvalidOperationException, MetaException, TException { - run(client -> client.dropCatalog(catalogId), "dropCatalog", catalogId); - } - - @Override - public Partition getPartition(String dbName, String tblName, List values) - throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.getPartition(dbName, tblName, values), "getPartition", dbName, tblName, values); - } - - @Override - public Partition getPartition(String catalogId, String dbName, String tblName, List values) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.getPartition(catalogId, dbName, tblName, values), "getPartition", catalogId, dbName, tblName, values); - } - - @Override - public Partition getPartition(String dbName, String tblName, String partitionName) - throws MetaException, UnknownTableException, NoSuchObjectException, TException { - return call(this.readWriteClient, client -> client.getPartition(dbName, tblName, partitionName), "getPartition", dbName, tblName, partitionName); - } - - @Override - public Partition getPartition(String catalogId, String dbName, String tblName, String partitionName) throws MetaException, UnknownTableException, NoSuchObjectException, TException { - return call(this.readWriteClient, client -> client.getPartition(catalogId, dbName, tblName, partitionName), "getPartition", catalogId, dbName, tblName, partitionName); - } - - @Override - public Map> getPartitionColumnStatistics( - String dbName, - String tableName, - List partitionNames, - List columnNames - ) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.getPartitionColumnStatistics(dbName, tableName, partitionNames, columnNames), "getPartitionColumnStatistics", dbName, tableName, partitionNames, columnNames); - } - - @Override - public Map> getPartitionColumnStatistics( - String catalogId, - String dbName, - String tableName, - List partitionNames, - List columnNames - ) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.getPartitionColumnStatistics(catalogId, dbName, tableName, partitionNames, columnNames), "getPartitionColumnStatistics", catalogId, dbName, tableName, partitionNames, columnNames); - } - - @Override - public Partition getPartitionWithAuthInfo( - String databaseName, - String tableName, - List values, - String userName, - List groupNames - ) throws MetaException, UnknownTableException, NoSuchObjectException, TException { - return call(this.readWriteClient, client -> client.getPartitionWithAuthInfo(databaseName, tableName, values, userName, groupNames), "getPartitionWithAuthInfo", databaseName, tableName, values, userName, groupNames); - } - - @Override - public Partition getPartitionWithAuthInfo( - String catalogId, - String databaseName, - String tableName, - List values, - String userName, - List groupNames) throws MetaException, UnknownTableException, NoSuchObjectException, TException { - return call(this.readWriteClient, client -> client.getPartitionWithAuthInfo(catalogId, databaseName, tableName, values, userName, groupNames), "getPartitionWithAuthInfo", catalogId, databaseName, tableName, values, userName, groupNames); - } - - @Override - public List getPartitionsByNames( - String databaseName, - String tableName, - List partitionNames - ) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.getPartitionsByNames(databaseName, tableName, partitionNames), "getPartitionsByNames", databaseName, tableName, partitionNames); - } - - @Override - public List getPartitionsByNames( - String catalogId, - String databaseName, - String tableName, - List partitionNames - ) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.getPartitionsByNames(catalogId, databaseName, tableName, partitionNames), "getPartitionsByNames", catalogId, databaseName, tableName, partitionNames); - } - - @Override - public List getSchema(String db, String tableName) throws TException { - return call(this.readWriteClient, client -> client.getSchema(db, tableName), "getSchema", db, tableName); - } - - @Override - public List getSchema(String catalogId, String db, String tableName) throws MetaException, TException, UnknownTableException, UnknownDBException { - return call(this.readWriteClient, client -> client.getSchema(catalogId, db, tableName), "getSchema", catalogId, db, tableName); - } - - @Override - public Table getTable(String dbName, String tableName) - throws MetaException, TException, NoSuchObjectException { - return call(this.readWriteClient, client -> client.getTable(dbName, tableName), "getTable", dbName, tableName); - } - - @Override - public Table getTable(String catalogId, String dbName, String tableName) throws MetaException, TException { - return call(this.readWriteClient, client -> client.getTable(catalogId, dbName, tableName), "getTable", catalogId, dbName, tableName); - } - - @Override - public List getTableColumnStatistics( - String dbName, - String tableName, - List colNames - ) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.getTableColumnStatistics(dbName, tableName, colNames), "getTableColumnStatistics", dbName, tableName, colNames); - } - - @Override - public List getTableColumnStatistics( - String catalogId, - String dbName, - String tableName, - List colNames - ) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.getTableColumnStatistics(catalogId, dbName, tableName, colNames), "getTableColumnStatistics", catalogId, dbName, tableName, colNames); - } - - @Override - public List

    getTableObjectsByName(String dbName, List tableNames) - throws MetaException, InvalidOperationException, UnknownDBException, TException { - return call(this.readWriteClient, client -> client.getTableObjectsByName(dbName, tableNames), "getTableObjectsByName", dbName, tableNames); - } - - @Override - public List
    getTableObjectsByName(String catalogId, String dbName, List tableNames) throws MetaException, InvalidOperationException, UnknownDBException, TException { - return call(this.readWriteClient, client -> client.getTableObjectsByName(catalogId, dbName, tableNames), "getTableObjectsByName", catalogId, dbName, tableNames); - } - - @Override - public Materialization getMaterializationInvalidationInfo(CreationMetadata creationMetadata, String validTxnList) throws MetaException, InvalidOperationException, UnknownDBException, TException { - return call(this.readWriteClient, client -> client.getMaterializationInvalidationInfo(creationMetadata, validTxnList), "getMaterializationInvalidationInfo", creationMetadata, validTxnList); - } - - @Override - public void updateCreationMetadata(String dbName, String tableName, CreationMetadata cm) throws MetaException, TException { - run(client -> client.updateCreationMetadata(dbName, tableName, cm), "updateCreationMetadata", dbName, tableName, cm); - } - - @Override - public void updateCreationMetadata(String catalogId, String dbName, String tableName, CreationMetadata cm) throws MetaException, TException { - run(client -> client.updateCreationMetadata(catalogId, dbName, tableName, cm), "updateCreationMetadata", catalogId, dbName, tableName, cm); - } - - @Override - public List getTables(String dbname, String tablePattern) - throws MetaException, TException, UnknownDBException { - return call(this.readWriteClient, client -> client.getTables(dbname, tablePattern), "getTables", dbname, tablePattern); - } - - @Override - public List getTables(String catalogId, String dbname, String tablePattern) throws MetaException, TException, UnknownDBException { - return call(this.readWriteClient, client -> client.getTables(catalogId, dbname, tablePattern), "getTables", catalogId, dbname, tablePattern); - } - - @Override - public List getTables(String dbname, String tablePattern, TableType tableType) - throws MetaException, TException, UnknownDBException { - return call(this.readWriteClient, client -> client.getTables(dbname, tablePattern, tableType), "getTables", dbname, tablePattern, tableType); - } - - @Override - public List getTables(String catalogId, String dbname, String tablePattern, TableType tableType) throws MetaException, TException, UnknownDBException { - return call(this.readWriteClient, client -> client.getTables(catalogId, dbname, tablePattern, tableType), "getTables", catalogId, dbname, tablePattern, tableType); - } - - @Override - public List getMaterializedViewsForRewriting(String dbName) throws MetaException, TException, UnknownDBException { - return call(this.readWriteClient, client -> client.getMaterializedViewsForRewriting(dbName), "getMaterializedViewsForRewriting", dbName); - } - - @Override - public List getMaterializedViewsForRewriting(String catalogId, String dbName) throws MetaException, TException, UnknownDBException { - return call(this.readWriteClient, client -> client.getMaterializedViewsForRewriting(catalogId, dbName), "getMaterializedViewsForRewriting", catalogId, dbName); - } - - @Override - public List getTableMeta( - String dbPatterns, - String tablePatterns, - List tableTypes - ) throws MetaException, TException, UnknownDBException, UnsupportedOperationException { - return call(this.readWriteClient, client -> client.getTableMeta(dbPatterns, tablePatterns, tableTypes), "getTableMeta", dbPatterns, tablePatterns, tableTypes); - } - - @Override - public List getTableMeta(String catalogId, String dbPatterns, String tablePatterns, List tableTypes) throws MetaException, TException, UnknownDBException { - return call(this.readWriteClient, client -> client.getTableMeta(catalogId, dbPatterns, tablePatterns, tableTypes), "getTableMeta", catalogId, dbPatterns, tablePatterns, tableTypes); - } - - @Override - public ValidTxnList getValidTxns() throws TException { - return call(this.readWriteClient, client -> client.getValidTxns(), "getValidTxns"); - } - - @Override - public ValidTxnList getValidTxns(long currentTxn) throws TException { - return call(this.readWriteClient, client -> client.getValidTxns(currentTxn), "getValidTxns", currentTxn); - } - - @Override - public ValidWriteIdList getValidWriteIds(String fullTableName) throws TException { - return call(this.readWriteClient, client -> client.getValidWriteIds(fullTableName), "getValidWriteIds", fullTableName); - } - - @Override - public List getValidWriteIds(List tablesList, String validTxnList) throws TException { - return call(this.readWriteClient, client -> client.getValidWriteIds(tablesList, validTxnList), "getValidWriteIds", tablesList, validTxnList); - } - - @Override - public PrincipalPrivilegeSet get_privilege_set(HiveObjectRef obj, String user, List groups) - throws MetaException, TException { - return call(this.readWriteClient, client -> client.get_privilege_set(obj, user, groups), "get_privilege_set", obj, user, groups); - } - - @Override - public boolean grant_privileges(PrivilegeBag privileges) - throws MetaException, TException { - return call(client -> client.grant_privileges(privileges), "grant_privileges", privileges); - } - - @Override - public boolean revoke_privileges(PrivilegeBag privileges, boolean grantOption) - throws MetaException, TException { - return call(client -> client.revoke_privileges(privileges, grantOption), "revoke_privileges", privileges, grantOption); - } - - @Override - public boolean refresh_privileges(HiveObjectRef objToRefresh, String authorizer, PrivilegeBag grantPrivileges) throws MetaException, TException { - return call(client -> client.refresh_privileges(objToRefresh, authorizer, grantPrivileges), "refresh_privileges", objToRefresh, authorizer, grantPrivileges); - } - - @Override - public void heartbeat(long txnId, long lockId) - throws NoSuchLockException, NoSuchTxnException, TxnAbortedException, TException { - run(client -> client.heartbeat(txnId, lockId), "heartbeat", txnId, lockId); - } - - @Override - public HeartbeatTxnRangeResponse heartbeatTxnRange(long min, long max) throws TException { - return call(client -> client.heartbeatTxnRange(min, max), "heartbeatTxnRange", min, max); - } - - @Override - public boolean isPartitionMarkedForEvent( - String dbName, - String tblName, - Map partKVs, - PartitionEventType eventType - ) throws MetaException, NoSuchObjectException, TException, UnknownTableException, UnknownDBException, - UnknownPartitionException, InvalidPartitionException { - return call(this.readWriteClient, client -> client.isPartitionMarkedForEvent(dbName, tblName, partKVs, eventType), "isPartitionMarkedForEvent", dbName, tblName, partKVs, eventType); - } - - @Override - public boolean isPartitionMarkedForEvent(String catalogId, String db_name, String tbl_name, Map partKVs, PartitionEventType eventType) throws MetaException, NoSuchObjectException, TException, UnknownTableException, UnknownDBException, UnknownPartitionException, InvalidPartitionException { - return call(this.readWriteClient, client -> client.isPartitionMarkedForEvent(catalogId, db_name, tbl_name, partKVs, eventType), "isPartitionMarkedForEvent", catalogId, db_name, tbl_name, partKVs, eventType); - } - - @Override - public List listPartitionNames(String dbName, String tblName, short max) - throws MetaException, TException { - return call(this.readWriteClient, client -> client.listPartitionNames(dbName, tblName, max), "listPartitionNames", dbName, tblName, max); - } - - @Override - public List listPartitionNames(String catalogId, String dbName, String tblName, int max) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.listPartitionNames(catalogId, dbName, tblName, max), "listPartitionNames", catalogId, dbName, tblName, max); - } - - @Override - public List listPartitionNames( - String databaseName, - String tableName, - List values, - short max - ) throws MetaException, TException, NoSuchObjectException { - return call(this.readWriteClient, client -> client.listPartitionNames(databaseName, tableName, values, max), "listPartitionNames", databaseName, tableName, values, max); - } - - @Override - public List listPartitionNames( - String catalogId, - String databaseName, - String tableName, - List values, - int max - ) throws MetaException, TException, NoSuchObjectException { - return call(this.readWriteClient, client -> client.listPartitionNames(catalogId, databaseName, tableName, values, max), "listPartitionNames", catalogId, databaseName, tableName, values, max); - } - - @Override - public PartitionValuesResponse listPartitionValues(PartitionValuesRequest partitionValuesRequest) throws MetaException, TException, NoSuchObjectException { - return call(this.readWriteClient, client -> client.listPartitionValues(partitionValuesRequest), "listPartitionValues", partitionValuesRequest); - } - - @Override - public int getNumPartitionsByFilter( - String dbName, - String tableName, - String filter - ) throws MetaException, NoSuchObjectException, TException { - return call(this.readWriteClient, client -> client.getNumPartitionsByFilter(dbName, tableName, filter), "getNumPartitionsByFilter", dbName, tableName, filter); - } - - @Override - public int getNumPartitionsByFilter( - String catalogId, - String dbName, - String tableName, - String filter - ) throws MetaException, NoSuchObjectException, TException { - return call(this.readWriteClient, client -> client.getNumPartitionsByFilter(catalogId, dbName, tableName, filter), "getNumPartitionsByFilter", catalogId, dbName, tableName, filter); - } - - @Override - public PartitionSpecProxy listPartitionSpecs( - String dbName, - String tblName, - int max - ) throws TException { - return call(this.readWriteClient, client -> client.listPartitionSpecs(dbName, tblName, max), "listPartitionSpecs", dbName, tblName, max); - } - - @Override - public PartitionSpecProxy listPartitionSpecs( - String catalogId, - String dbName, - String tblName, - int max - ) throws TException { - return call(this.readWriteClient, client -> client.listPartitionSpecs(catalogId, dbName, tblName, max), "listPartitionSpecs", catalogId, dbName, tblName, max); - } - - @Override - public PartitionSpecProxy listPartitionSpecsByFilter( - String dbName, - String tblName, - String filter, - int max - ) throws MetaException, NoSuchObjectException, TException { - return call(this.readWriteClient, client -> client.listPartitionSpecsByFilter(dbName, tblName, filter, max), "listPartitionSpecsByFilter", dbName, tblName, filter, max); - } - - @Override - public PartitionSpecProxy listPartitionSpecsByFilter( - String catalogId, - String dbName, - String tblName, - String filter, - int max - ) throws MetaException, NoSuchObjectException, TException { - return call(this.readWriteClient, client -> client.listPartitionSpecsByFilter(catalogId, dbName, tblName, filter, max), "listPartitionSpecsByFilter", catalogId, dbName, tblName, filter, max); - } - - @Override - public List listPartitions(String dbName, String tblName, short max) - throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.listPartitions(dbName, tblName, max), "listPartitions", dbName, tblName, max); - } - - @Override - public List listPartitions(String catalogId, String dbName, String tblName, int max) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.listPartitions(catalogId, dbName, tblName, max), "listPartitions", catalogId, dbName, tblName, max); - } - - @Override - public List listPartitions( - String databaseName, - String tableName, - List values, - short max - ) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.listPartitions(databaseName, tableName, values, max), "listPartitions", databaseName, tableName, values, max); - } - - @Override - public List listPartitions(String catalogId, String databaseName, String tableName, List values, int max) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.listPartitions(catalogId, databaseName, tableName, values, max), "listPartitions", catalogId, databaseName, tableName, values, max); - } - - @Override - public boolean listPartitionsByExpr( - String databaseName, - String tableName, - byte[] expr, - String defaultPartitionName, - short max, - List result - ) throws TException { - return call(this.readWriteClient, client -> client.listPartitionsByExpr(databaseName, tableName, expr, defaultPartitionName, max, result), "listPartitionsByExpr", databaseName, tableName, expr, defaultPartitionName, max, result); - } - - @Override - public boolean listPartitionsByExpr( - String catalogId, - String databaseName, - String tableName, - byte[] expr, - String defaultPartitionName, - int max, - List result - ) throws TException { - return call(this.readWriteClient, client -> client.listPartitionsByExpr(catalogId, databaseName, tableName, expr, defaultPartitionName, max, result), "listPartitionsByExpr", catalogId, databaseName, tableName, expr, defaultPartitionName, max, result); - } - - @Override - public List listPartitionsByFilter( - String databaseName, - String tableName, - String filter, - short max - ) throws MetaException, NoSuchObjectException, TException { - return call(this.readWriteClient, client -> client.listPartitionsByFilter(databaseName, tableName, filter, max), "listPartitionsByFilter", databaseName, tableName, filter, max); - } - - @Override - public List listPartitionsByFilter( - String catalogId, - String databaseName, - String tableName, - String filter, - int max - ) throws MetaException, NoSuchObjectException, TException { - return call(this.readWriteClient, client -> client.listPartitionsByFilter(catalogId, databaseName, tableName, filter, max), "listPartitionsByFilter", catalogId, databaseName, tableName, filter, max); - } - - @Override - public List listPartitionsWithAuthInfo( - String database, - String table, - short maxParts, - String user, - List groups - ) throws MetaException, TException, NoSuchObjectException { - return call(this.readWriteClient, client -> client.listPartitionsWithAuthInfo(database, table, maxParts, user, groups), "listPartitionsWithAuthInfo", database, table, maxParts, user, groups); - } - - @Override - public List listPartitionsWithAuthInfo( - String catalogId, - String database, - String table, - int maxParts, - String user, - List groups - ) throws MetaException, TException, NoSuchObjectException { - return call(this.readWriteClient, client -> client.listPartitionsWithAuthInfo(catalogId, database, table, maxParts, user, groups), "listPartitionsWithAuthInfo", catalogId, database, table, maxParts, user, groups); - } - - @Override - public List listPartitionsWithAuthInfo( - String database, - String table, - List partVals, - short maxParts, - String user, - List groups - ) throws MetaException, TException, NoSuchObjectException { - return call(this.readWriteClient, client -> client.listPartitionsWithAuthInfo(database, table, partVals, maxParts, user, groups), "listPartitionsWithAuthInfo", database, table, partVals, maxParts, user, groups); - } - - @Override - public List listPartitionsWithAuthInfo(String catalogId, String database, String table, List partVals, int maxParts, String user, List groups) throws MetaException, TException, NoSuchObjectException { - return call(this.readWriteClient, client -> client.listPartitionsWithAuthInfo(catalogId, database, table, partVals, maxParts, user, groups), "listPartitionsWithAuthInfo", catalogId, database, table, partVals, maxParts, user, groups); - } - - @Override - public List listTableNamesByFilter( - String dbName, - String filter, - short maxTables - ) throws MetaException, TException, InvalidOperationException, UnknownDBException, UnsupportedOperationException { - return call(this.readWriteClient, client -> client.listTableNamesByFilter(dbName, filter, maxTables), "listTableNamesByFilter", dbName, filter, maxTables); - } - - @Override - public List listTableNamesByFilter(String catalogId, String dbName, String filter, int maxTables) throws TException, InvalidOperationException, UnknownDBException { - return call(this.readWriteClient, client -> client.listTableNamesByFilter(catalogId, dbName, filter, maxTables), "listTableNamesByFilter", catalogId, dbName, filter, maxTables); - } - - @Override - public List list_privileges( - String principal, - PrincipalType principalType, - HiveObjectRef objectRef - ) throws MetaException, TException { - return call(this.readWriteClient, client -> client.list_privileges(principal, principalType, objectRef), "list_privileges", principal, principalType, objectRef); - } - - @Override - public LockResponse lock(LockRequest lockRequest) throws NoSuchTxnException, TxnAbortedException, TException { - return call(client -> client.lock(lockRequest), "lock", lockRequest); - } - - @Override - public void markPartitionForEvent( - String dbName, - String tblName, - Map partKVs, - PartitionEventType eventType - ) throws MetaException, NoSuchObjectException, TException, UnknownTableException, UnknownDBException, - UnknownPartitionException, InvalidPartitionException { - run(client -> client.markPartitionForEvent(dbName, tblName, partKVs, eventType), "markPartitionForEvent", dbName, tblName, partKVs, eventType); - } - - @Override - public void markPartitionForEvent( - String catalogId, - String dbName, - String tblName, - Map partKVs, - PartitionEventType eventType) throws MetaException, NoSuchObjectException, TException, UnknownTableException, UnknownDBException, UnknownPartitionException, InvalidPartitionException { - run(client -> client.markPartitionForEvent(catalogId, dbName, tblName, partKVs, eventType), "markPartitionForEvent", catalogId, dbName, tblName, partKVs, eventType); - } - - @Override - public long openTxn(String user) throws TException { - return call(client -> client.openTxn(user), "openTxn", user); - } - - @Override - public List replOpenTxn(String replPolicy, List srcTxnIds, String user) throws TException { - return call(client -> client.replOpenTxn(replPolicy, srcTxnIds, user), "replOpenTxn", replPolicy, srcTxnIds, user); - } - - @Override - public OpenTxnsResponse openTxns(String user, int numTxns) throws TException { - return call(client -> client.openTxns(user, numTxns), "openTxns", numTxns); - } - - @Override - public Map partitionNameToSpec(String name) throws MetaException, TException { - return call(this.readWriteClient, client -> client.partitionNameToSpec(name), "partitionNameToSpec", name); - } - - @Override - public List partitionNameToVals(String name) throws MetaException, TException { - return call(this.readWriteClient, client -> client.partitionNameToVals(name), "partitionNameToVals", name); - } - - @Override - public void renamePartition( - String dbName, - String tblName, - List partitionValues, - Partition newPartition - ) throws InvalidOperationException, MetaException, TException { - run(client -> client.renamePartition(dbName, tblName, partitionValues, newPartition), "renamePartition", dbName, tblName, - partitionValues, newPartition); - } - - @Override - public void renamePartition(String catalogId, String dbName, String tblName, List partitionValues, Partition newPartition) throws InvalidOperationException, MetaException, TException { - run(client -> client.renamePartition(catalogId, dbName, tblName, partitionValues, newPartition), "renamePartition", catalogId, dbName, tblName, partitionValues, newPartition); - } - - @Override - public long renewDelegationToken(String tokenStrForm) throws MetaException, TException { - return call(client -> client.renewDelegationToken(tokenStrForm), "renewDelegationToken", tokenStrForm); - } - - @Override - public void rollbackTxn(long txnId) throws NoSuchTxnException, TException { - run(client -> client.rollbackTxn(txnId), "rollbackTxn", txnId); - } - - @Override - public void replRollbackTxn(long srcTxnId, String replPolicy) throws NoSuchTxnException, TException { - run(client -> client.replRollbackTxn(srcTxnId, replPolicy), "replRollbackTxn", srcTxnId, replPolicy); - } - - @Override - public void setMetaConf(String key, String value) throws MetaException, TException { - run(client -> client.setMetaConf(key, value), "setMetaConf", key, value); - } - - @Override - public boolean setPartitionColumnStatistics(SetPartitionsStatsRequest request) - throws NoSuchObjectException, InvalidObjectException, - MetaException, TException, InvalidInputException { - if (request.getColStatsSize() == 1) { - ColumnStatistics colStats = request.getColStatsIterator().next(); - ColumnStatisticsDesc desc = colStats.getStatsDesc(); - String dbName = desc.getDbName().toLowerCase(); - String tableName = desc.getTableName().toLowerCase(); - if (getTempTable(dbName, tableName) != null) { - return call(this.readWriteClient, client -> client.setPartitionColumnStatistics(request), "setPartitionColumnStatistics", request); - } - } - SetPartitionsStatsRequest deepCopy = request.deepCopy(); - boolean result = readWriteClient.setPartitionColumnStatistics(deepCopy); - if (extraClient.isPresent()) { - try { - extraClient.get().setPartitionColumnStatistics(request); - } catch (Exception e) { - FunctionalUtils.collectLogs(e, "setPartitionColumnStatistics", request); - if (!allowFailure) { - throw e; - } - } - } - return result; - } - - @Override - public void flushCache() { - try { - run(client -> client.flushCache(), "flushCache"); - } catch (TException e) { - logger.info(e.getMessage(), e); - } - } - - @Override - public Iterable> getFileMetadata(List fileIds) throws TException { - return call(this.readWriteClient, client -> client.getFileMetadata(fileIds), "getFileMetadata", fileIds); - } - - @Override - public Iterable> getFileMetadataBySarg( - List fileIds, - ByteBuffer sarg, - boolean doGetFooters - ) throws TException { - return call(this.readWriteClient, client -> client.getFileMetadataBySarg(fileIds, sarg, doGetFooters), "getFileMetadataBySarg", fileIds, sarg, doGetFooters); - } - - @Override - public void clearFileMetadata(List fileIds) throws TException { - run(client -> client.clearFileMetadata(fileIds), "clearFileMetadata", fileIds); - } - - @Override - public void putFileMetadata(List fileIds, List metadata) throws TException { - run(client -> client.putFileMetadata(fileIds, metadata), "putFileMetadata", fileIds, metadata); - } - - @Override - public boolean isSameConfObj(Configuration conf) { - try { - return call(this.readWriteClient, client -> client.isSameConfObj(conf), "isSameConfObj", conf); - } catch (TException e) { - logger.error(e.getMessage(), e); - } - return false; - } - - @Override - public boolean cacheFileMetadata( - String dbName, - String tblName, - String partName, - boolean allParts - ) throws TException { - return call(client -> client.cacheFileMetadata(dbName, tblName, partName, allParts), "cacheFileMetadata", dbName, tblName, partName, allParts); - } - - @Override - public List getPrimaryKeys(PrimaryKeysRequest primaryKeysRequest) - throws MetaException, NoSuchObjectException, TException { - return call(this.readWriteClient, client -> client.getPrimaryKeys(primaryKeysRequest), "getPrimaryKeys", primaryKeysRequest); - } - - @Override - public List getForeignKeys(ForeignKeysRequest foreignKeysRequest) - throws MetaException, NoSuchObjectException, TException { - // PrimaryKeys are currently unsupported - //return null to allow DESCRIBE (FORMATTED | EXTENDED) - return call(this.readWriteClient, client -> client.getForeignKeys(foreignKeysRequest), "getForeignKeys", foreignKeysRequest); - } - - @Override - public List getUniqueConstraints(UniqueConstraintsRequest uniqueConstraintsRequest) throws MetaException, NoSuchObjectException, TException { - return call(this.readWriteClient, client -> client.getUniqueConstraints(uniqueConstraintsRequest), "getUniqueConstraints", uniqueConstraintsRequest); - } - - @Override - public List getNotNullConstraints(NotNullConstraintsRequest notNullConstraintsRequest) throws MetaException, NoSuchObjectException, TException { - return call(this.readWriteClient, client -> client.getNotNullConstraints(notNullConstraintsRequest), "getNotNullConstraints", notNullConstraintsRequest); - } - - @Override - public List getDefaultConstraints(DefaultConstraintsRequest defaultConstraintsRequest) throws MetaException, NoSuchObjectException, TException { - return call(this.readWriteClient, client -> client.getDefaultConstraints(defaultConstraintsRequest), "getDefaultConstraints", defaultConstraintsRequest); - } - - @Override - public List getCheckConstraints(CheckConstraintsRequest checkConstraintsRequest) throws MetaException, NoSuchObjectException, TException { - return call(this.readWriteClient, client -> client.getCheckConstraints(checkConstraintsRequest), "getCheckConstraints", checkConstraintsRequest); - } - - @Override - public void createTableWithConstraints( - Table tbl, - List primaryKeys, - List foreignKeys, - List uniqueConstraints, - List notNullConstraints, - List defaultConstraints, - List checkConstraints - ) throws AlreadyExistsException, InvalidObjectException, MetaException, NoSuchObjectException, TException { - run(client -> client.createTableWithConstraints(tbl, primaryKeys, foreignKeys, uniqueConstraints, notNullConstraints, defaultConstraints, checkConstraints), "createTableWithConstraints", tbl, primaryKeys, foreignKeys, uniqueConstraints, notNullConstraints, defaultConstraints, checkConstraints); - } - - @Override - public void dropConstraint( - String dbName, - String tblName, - String constraintName - ) throws MetaException, NoSuchObjectException, TException { - run(client -> client.dropConstraint(dbName, tblName, constraintName), "dropConstraint", dbName, tblName, constraintName); - } - - @Override - public void dropConstraint(String catalogId, String dbName, String tableName, String constraintName) throws MetaException, NoSuchObjectException, TException { - run(client -> client.dropConstraint(catalogId, dbName, tableName, constraintName), "dropConstraint", catalogId, dbName, tableName, constraintName); - } - - @Override - public void addPrimaryKey(List primaryKeyCols) - throws MetaException, NoSuchObjectException, TException { - run(client -> client.addPrimaryKey(primaryKeyCols), "addPrimaryKey", primaryKeyCols); - } - - @Override - public void addForeignKey(List foreignKeyCols) - throws MetaException, NoSuchObjectException, TException { - run(client -> client.addForeignKey(foreignKeyCols), "addForeignKey", foreignKeyCols); - } - - @Override - public void addUniqueConstraint(List uniqueConstraintCols) throws MetaException, NoSuchObjectException, TException { - run(client -> client.addUniqueConstraint(uniqueConstraintCols), "addUniqueConstraint", uniqueConstraintCols); - } - - @Override - public void addNotNullConstraint(List notNullConstraintCols) throws MetaException, NoSuchObjectException, TException { - run(client -> client.addNotNullConstraint(notNullConstraintCols), "addNotNullConstraint", notNullConstraintCols); - } - - @Override - public void addDefaultConstraint(List defaultConstraints) throws MetaException, NoSuchObjectException, TException { - run(client -> client.addDefaultConstraint(defaultConstraints), "addDefaultConstraint", defaultConstraints); - } - - @Override - public void addCheckConstraint(List checkConstraints) throws MetaException, NoSuchObjectException, TException { - run(client -> client.addCheckConstraint(checkConstraints), "addCheckConstraint", checkConstraints); - } - - @Override - public String getMetastoreDbUuid() throws MetaException, TException { - return call(this.readWriteClient, client -> client.getMetastoreDbUuid(), "getMetastoreDbUuid"); - } - - @Override - public void createResourcePlan(WMResourcePlan wmResourcePlan, String copyFromName) throws InvalidObjectException, MetaException, TException { - run(client -> client.createResourcePlan(wmResourcePlan, copyFromName), "createResourcePlan", wmResourcePlan, copyFromName); - } - - @Override - public WMFullResourcePlan getResourcePlan(String resourcePlanName) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.getResourcePlan(resourcePlanName), "getResourcePlan", resourcePlanName); - } - - @Override - public List getAllResourcePlans() throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.getAllResourcePlans(), "getAllResourcePlans"); - } - - @Override - public void dropResourcePlan(String resourcePlanName) throws NoSuchObjectException, MetaException, TException { - run(client -> client.dropResourcePlan(resourcePlanName), "dropResourcePlan", resourcePlanName); - } - - @Override - public WMFullResourcePlan alterResourcePlan(String resourcePlanName, WMNullableResourcePlan wmNullableResourcePlan, boolean canActivateDisabled, boolean isForceDeactivate, boolean isReplace) throws NoSuchObjectException, InvalidObjectException, MetaException, TException { - return call(client -> client.alterResourcePlan(resourcePlanName, wmNullableResourcePlan, canActivateDisabled, isForceDeactivate, isReplace), "alterResourcePlan", resourcePlanName, wmNullableResourcePlan, canActivateDisabled, isForceDeactivate, isReplace); - } - - @Override - public WMFullResourcePlan getActiveResourcePlan() throws MetaException, TException { - return call(this.readWriteClient, client -> client.getActiveResourcePlan(), "getActiveResourcePlan"); - } - - @Override - public WMValidateResourcePlanResponse validateResourcePlan(String resourcePlanName) throws NoSuchObjectException, InvalidObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.validateResourcePlan(resourcePlanName), "validateResourcePlan", resourcePlanName); - } - - @Override - public void createWMTrigger(WMTrigger wmTrigger) throws InvalidObjectException, MetaException, TException { - run(client -> client.createWMTrigger(wmTrigger), "createWMTrigger", wmTrigger); - } - - @Override - public void alterWMTrigger(WMTrigger wmTrigger) throws NoSuchObjectException, InvalidObjectException, MetaException, TException { - run(client -> client.alterWMTrigger(wmTrigger), "alterWMTrigger", wmTrigger); - } - - @Override - public void dropWMTrigger(String resourcePlanName, String triggerName) throws NoSuchObjectException, MetaException, TException { - run(client -> client.dropWMTrigger(resourcePlanName, triggerName), "dropWMTrigger", resourcePlanName, triggerName); - } - - @Override - public List getTriggersForResourcePlan(String resourcePlan) throws NoSuchObjectException, MetaException, TException { - return call(this.readWriteClient, client -> client.getTriggersForResourcePlan(resourcePlan), "getTriggersForResourcePlan", resourcePlan); - } - - @Override - public void createWMPool(WMPool wmPool) throws NoSuchObjectException, InvalidObjectException, MetaException, TException { - run(client -> client.createWMPool(wmPool), "createWMPool", wmPool); - } - - @Override - public void alterWMPool(WMNullablePool wmNullablePool, String poolPath) throws NoSuchObjectException, InvalidObjectException, TException { - run(client -> client.alterWMPool(wmNullablePool, poolPath), "alterWMPool", wmNullablePool, poolPath); - } - - @Override - public void dropWMPool(String resourcePlanName, String poolPath) throws TException { - run(client -> client.dropWMPool(resourcePlanName, poolPath), "dropWMPool", resourcePlanName, poolPath); - } - - @Override - public void createOrUpdateWMMapping(WMMapping wmMapping, boolean isUpdate) throws TException { - run(client -> client.createOrUpdateWMMapping(wmMapping, isUpdate), "createOrUpdateWMMapping", wmMapping, isUpdate); - } - - @Override - public void dropWMMapping(WMMapping wmMapping) throws TException { - run(client -> client.dropWMMapping(wmMapping), "dropWMMapping", wmMapping); - } - - @Override - public void createOrDropTriggerToPoolMapping(String resourcePlanName, String triggerName, String poolPath, boolean shouldDrop) throws AlreadyExistsException, NoSuchObjectException, InvalidObjectException, MetaException, TException { - run(client -> client.createOrDropTriggerToPoolMapping(resourcePlanName, triggerName, poolPath, shouldDrop), "createOrDropTriggerToPoolMapping", resourcePlanName, triggerName, poolPath, shouldDrop); - } - - @Override - public void createISchema(ISchema iSchema) throws TException { - run(client -> client.createISchema(iSchema), "createISchema", iSchema); - } - - @Override - public void alterISchema(String catName, String dbName, String schemaName, ISchema newSchema) throws TException { - run(client -> client.alterISchema(catName, dbName, schemaName, newSchema), "alterISchema", catName, dbName, schemaName, newSchema); - } - - @Override - public ISchema getISchema(String catalogId, String dbName, String name) throws TException { - return call(this.readWriteClient, client -> client.getISchema(catalogId, dbName, name), "getISchema", catalogId, dbName, name); - } - - @Override - public void dropISchema(String catalogId, String dbName, String name) throws TException { - run(client -> client.dropISchema(catalogId, dbName, name), "dropISchema", catalogId, dbName, name); - } - - @Override - public void addSchemaVersion(SchemaVersion schemaVersion) throws TException { - run(client -> client.addSchemaVersion(schemaVersion), "addSchemaVersion", schemaVersion); - } - - @Override - public SchemaVersion getSchemaVersion(String catalogId, String dbName, String schemaName, int version) throws TException { - return call(this.readWriteClient, client -> client.getSchemaVersion(catalogId, dbName, schemaName, version), "getSchemaVersion", catalogId, dbName, schemaName, version); - } - - @Override - public SchemaVersion getSchemaLatestVersion(String catalogId, String dbName, String schemaName) throws TException { - return call(this.readWriteClient, client -> client.getSchemaLatestVersion(catalogId, dbName, schemaName), "getSchemaLatestVersion", catalogId, dbName, schemaName); - } - - @Override - public List getSchemaAllVersions(String catalogId, String dbName, String schemaName) throws TException { - return call(this.readWriteClient, client -> client.getSchemaAllVersions(catalogId, dbName, schemaName), "getSchemaAllVersions", catalogId, dbName, schemaName); - } - - @Override - public void dropSchemaVersion(String catalogId, String dbName, String schemaName, int version) throws TException { - run(client -> client.dropSchemaVersion(catalogId, dbName, schemaName, version), "dropSchemaVersion", catalogId, dbName, schemaName, version); - } - - @Override - public FindSchemasByColsResp getSchemaByCols(FindSchemasByColsRqst findSchemasByColsRqst) throws TException { - return call(this.readWriteClient, client -> client.getSchemaByCols(findSchemasByColsRqst), "getSchemaByCols", findSchemasByColsRqst); - } - - @Override - public void mapSchemaVersionToSerde(String catalogId, String dbName, String schemaName, int version, String serdeName) throws TException { - run(client -> client.mapSchemaVersionToSerde(catalogId, dbName, schemaName, version, serdeName), "mapSchemaVersionToSerde", catalogId, dbName, schemaName, version, serdeName); - } - - @Override - public void setSchemaVersionState(String catalogId, String dbName, String schemaName, int version, SchemaVersionState state) throws TException { - run(client -> client.setSchemaVersionState(catalogId, dbName, schemaName, version, state), "setSchemaVersionState", catalogId, dbName, schemaName, version, state); - } - - @Override - public void addSerDe(SerDeInfo serDeInfo) throws TException { - run(client -> client.addSerDe(serDeInfo), "addSerDe", serDeInfo); - } - - @Override - public SerDeInfo getSerDe(String serDeName) throws TException { - return call(this.readWriteClient, client -> client.getSerDe(serDeName), "getSerDe", serDeName); - } - - @Override - public LockResponse lockMaterializationRebuild(String dbName, String tableName, long txnId) throws TException { - return call(client -> client.lockMaterializationRebuild(dbName, tableName, txnId), "lockMaterializationRebuild", dbName, tableName, txnId); - } - - @Override - public boolean heartbeatLockMaterializationRebuild(String dbName, String tableName, long txnId) throws TException { - return call(client -> client.heartbeatLockMaterializationRebuild(dbName, tableName, txnId), "heartbeatLockMaterializationRebuild", dbName, tableName, txnId); - } - - @Override - public void addRuntimeStat(RuntimeStat runtimeStat) throws TException { - run(client -> client.addRuntimeStat(runtimeStat), "addRuntimeStat", runtimeStat); - } - - @Override - public List getRuntimeStats(int maxWeight, int maxCreateTime) throws TException { - return call(this.readWriteClient, client -> client.getRuntimeStats(maxWeight, maxCreateTime), "getRuntimeStats", maxWeight, maxCreateTime); - } - - @Override - public ShowCompactResponse showCompactions() throws TException { - return call(this.readWriteClient, client -> client.showCompactions(), "showCompactions"); - } - - @Override - public void addDynamicPartitions(long txnId, long writeId, String dbName, String tableName, List partNames) throws TException { - run(client -> client.addDynamicPartitions(txnId, writeId, dbName, tableName, partNames), "addDynamicPartitions", txnId, writeId, dbName, tableName, partNames); - } - - @Override - public void addDynamicPartitions(long txnId, long writeId, String dbName, String tableName, List partNames, DataOperationType operationType) throws TException { - run(client -> client.addDynamicPartitions(txnId, writeId, dbName, tableName, partNames, operationType), "addDynamicPartitions", txnId, writeId, dbName, tableName, partNames, operationType); - } - - @Override - public void insertTable(Table table, boolean overwrite) throws MetaException { - try { - run(client -> client.insertTable(table, overwrite), "insertTable", table, overwrite); - } catch (TException e) { - throw DataLakeUtil.throwException(new MetaException(e.getMessage()), e); - } - } - - @Override - public NotificationEventResponse getNextNotification( - long lastEventId, - int maxEvents, - NotificationFilter notificationFilter - ) throws TException { - return call(this.readWriteClient, client -> client.getNextNotification(lastEventId, maxEvents, notificationFilter), "getNextNotification", lastEventId, maxEvents, notificationFilter); - } - - @Override - public CurrentNotificationEventId getCurrentNotificationEventId() throws TException { - return call(this.readWriteClient, client -> client.getCurrentNotificationEventId(), "getCurrentNotificationEventId"); - } - - @Override - public NotificationEventsCountResponse getNotificationEventsCount(NotificationEventsCountRequest notificationEventsCountRequest) throws TException { - return call(this.readWriteClient, client -> client.getNotificationEventsCount(notificationEventsCountRequest), "getNotificationEventsCount", notificationEventsCountRequest); - } - - @Override - public FireEventResponse fireListenerEvent(FireEventRequest fireEventRequest) throws TException { - return call(this.readWriteClient, client -> client.fireListenerEvent(fireEventRequest), "fireListenerEvent", fireEventRequest); - } - - @Override - @Deprecated - public ShowLocksResponse showLocks() throws TException { - return call(this.readWriteClient, client -> client.showLocks(), "showLocks"); - } - - @Override - public ShowLocksResponse showLocks(ShowLocksRequest showLocksRequest) throws TException { - return call(this.readWriteClient, client -> client.showLocks(showLocksRequest), "showLocks", showLocksRequest); - } - - @Override - public GetOpenTxnsInfoResponse showTxns() throws TException { - return call(this.readWriteClient, client -> client.showTxns(), "showTxns"); - } - - @Override - public boolean tableExists(String databaseName, String tableName) - throws MetaException, TException, UnknownDBException { - return call(this.readWriteClient, client -> client.tableExists(databaseName, tableName), "tableExists", databaseName, tableName); - } - - @Override - public boolean tableExists(String catalogId, String databaseName, String tableName) throws MetaException, TException, UnknownDBException { - return call(this.readWriteClient, client -> client.tableExists(catalogId, databaseName, tableName), "tableExists", catalogId, databaseName, tableName); - } - - @Override - public void unlock(long lockId) throws NoSuchLockException, TxnOpenException, TException { - run(client -> client.unlock(lockId), "unlock", lockId); - } - - @Override - public boolean updatePartitionColumnStatistics(ColumnStatistics columnStatistics) - throws NoSuchObjectException, InvalidObjectException, MetaException, TException, InvalidInputException { - return call(client -> client.updatePartitionColumnStatistics(columnStatistics), "updatePartitionColumnStatistics", columnStatistics); - } - - @Override - public boolean updateTableColumnStatistics(ColumnStatistics columnStatistics) - throws NoSuchObjectException, InvalidObjectException, MetaException, TException, InvalidInputException { - if (getTempTable(columnStatistics.getStatsDesc().getDbName(), columnStatistics.getStatsDesc().getTableName()) != null) { - return call(this.readWriteClient, client -> client.updateTableColumnStatistics(columnStatistics), "updateTableColumnStatistics", columnStatistics); - } else { - return call(client -> client.updateTableColumnStatistics(columnStatistics), "updateTableColumnStatistics", columnStatistics); - } - } - - @Override - public void validatePartitionNameCharacters(List part_vals) throws TException, MetaException { - run(this.readWriteClient, client -> client.validatePartitionNameCharacters(part_vals), "validatePartitionNameCharacters", part_vals); - } - - @VisibleForTesting - public IMetaStoreClient getDlfSessionMetaStoreClient() { - return dlfSessionMetaStoreClient; - } - - @VisibleForTesting - public IMetaStoreClient getHiveSessionMetaStoreClient() { - return hiveSessionMetaStoreClient; - } - - @VisibleForTesting - boolean isAllowFailure() { - return allowFailure; - } - - public void run(ThrowingConsumer consumer, String actionName, Object... parameters) throws TException { - FunctionalUtils.run(this.readWriteClient, extraClient, allowFailure, consumer, this.readWriteClientType, actionName, parameters); - } - - public void run(IMetaStoreClient client, ThrowingConsumer consumer, - String actionName, Object... parameters) throws TException { - FunctionalUtils.run(client, Optional.empty(), allowFailure, consumer, this.readWriteClientType, actionName, parameters); - } - - public R call(ThrowingFunction consumer, - String actionName, Object... parameters) throws TException { - return FunctionalUtils.call(this.readWriteClient, extraClient, allowFailure, consumer, - this.readWriteClientType, actionName, parameters); - } - - public R call(IMetaStoreClient client, ThrowingFunction consumer, - String actionName, Object... parameters) throws TException { - return FunctionalUtils.call(client, Optional.empty(), allowFailure, consumer, this.readWriteClientType, - actionName, parameters); - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/BaseCatalogToHiveConverter.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/BaseCatalogToHiveConverter.java deleted file mode 100644 index 573c26de9b4235..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/BaseCatalogToHiveConverter.java +++ /dev/null @@ -1,541 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.converters; - -import com.amazonaws.services.glue.model.BinaryColumnStatisticsData; -import com.amazonaws.services.glue.model.BooleanColumnStatisticsData; -import com.amazonaws.services.glue.model.ColumnStatistics; -import com.amazonaws.services.glue.model.ColumnStatisticsType; -import com.amazonaws.services.glue.model.DateColumnStatisticsData; -import com.amazonaws.services.glue.model.DecimalColumnStatisticsData; -import com.amazonaws.services.glue.model.DoubleColumnStatisticsData; -import com.amazonaws.services.glue.model.ErrorDetail; -import com.amazonaws.services.glue.model.LongColumnStatisticsData; -import com.amazonaws.services.glue.model.StringColumnStatisticsData; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import static org.apache.commons.lang3.ObjectUtils.firstNonNull; -import org.apache.hadoop.hive.metastore.api.AlreadyExistsException; -import org.apache.hadoop.hive.metastore.api.BinaryColumnStatsData; -import org.apache.hadoop.hive.metastore.api.BooleanColumnStatsData; -import org.apache.hadoop.hive.metastore.api.ColumnStatisticsData; -import org.apache.hadoop.hive.metastore.api.ColumnStatisticsObj; -import org.apache.hadoop.hive.metastore.api.Database; -import org.apache.hadoop.hive.metastore.api.DateColumnStatsData; -import org.apache.hadoop.hive.metastore.api.Decimal; -import org.apache.hadoop.hive.metastore.api.DecimalColumnStatsData; -import org.apache.hadoop.hive.metastore.api.DoubleColumnStatsData; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.Function; -import org.apache.hadoop.hive.metastore.api.FunctionType; -import org.apache.hadoop.hive.metastore.api.InvalidObjectException; -import org.apache.hadoop.hive.metastore.api.LongColumnStatsData; -import org.apache.hadoop.hive.metastore.api.MetaException; -import org.apache.hadoop.hive.metastore.api.NoSuchObjectException; -import org.apache.hadoop.hive.metastore.api.Order; -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.PrincipalType; -import org.apache.hadoop.hive.metastore.api.ResourceType; -import org.apache.hadoop.hive.metastore.api.ResourceUri; -import org.apache.hadoop.hive.metastore.api.SerDeInfo; -import org.apache.hadoop.hive.metastore.api.SkewedInfo; -import org.apache.hadoop.hive.metastore.api.StorageDescriptor; -import org.apache.hadoop.hive.metastore.api.StringColumnStatsData; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.hadoop.hive.metastore.api.TableMeta; -import org.apache.log4j.Logger; -import shade.doris.hive.org.apache.thrift.TException; - -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; - -public class BaseCatalogToHiveConverter implements CatalogToHiveConverter { - - private static final Logger logger = Logger.getLogger(BaseCatalogToHiveConverter.class); - - private static final ImmutableMap EXCEPTION_MAP = ImmutableMap.builder() - .put("AlreadyExistsException", new HiveException() { - public TException get(String msg) { - return new AlreadyExistsException(msg); - } - }) - .put("InvalidInputException", new HiveException() { - public TException get(String msg) { - return new InvalidObjectException(msg); - } - }) - .put("InternalServiceException", new HiveException() { - public TException get(String msg) { - return new MetaException(msg); - } - }) - .put("ResourceNumberLimitExceededException", new HiveException() { - public TException get(String msg) { - return new MetaException(msg); - } - }) - .put("OperationTimeoutException", new HiveException() { - public TException get(String msg) { - return new MetaException(msg); - } - }) - .put("EntityNotFoundException", new HiveException() { - public TException get(String msg) { - return new NoSuchObjectException(msg); - } - }) - .build(); - - interface HiveException { - TException get(String msg); - } - - public TException wrapInHiveException(Throwable e) { - return getHiveException(e.getClass().getSimpleName(), e.getMessage()); - } - - public TException errorDetailToHiveException(ErrorDetail errorDetail) { - return getHiveException(errorDetail.getErrorCode(), errorDetail.getErrorMessage()); - } - - private TException getHiveException(String errorName, String errorMsg) { - if (EXCEPTION_MAP.containsKey(errorName)) { - return EXCEPTION_MAP.get(errorName).get(errorMsg); - } else { - logger.warn("Hive Exception type not found for " + errorName); - return new MetaException(errorMsg); - } - } - - public Database convertDatabase(com.amazonaws.services.glue.model.Database catalogDatabase) { - Database hiveDatabase = new Database(); - hiveDatabase.setName(catalogDatabase.getName()); - hiveDatabase.setDescription(catalogDatabase.getDescription()); - String location = catalogDatabase.getLocationUri(); - hiveDatabase.setLocationUri(location == null ? "" : location); - hiveDatabase.setParameters(firstNonNull(catalogDatabase.getParameters(), Maps.newHashMap())); - return hiveDatabase; - } - - public FieldSchema convertFieldSchema(com.amazonaws.services.glue.model.Column catalogFieldSchema) { - FieldSchema hiveFieldSchema = new FieldSchema(); - hiveFieldSchema.setType(catalogFieldSchema.getType()); - hiveFieldSchema.setName(catalogFieldSchema.getName()); - hiveFieldSchema.setComment(catalogFieldSchema.getComment()); - - return hiveFieldSchema; - } - - public List convertFieldSchemaList(List catalogFieldSchemaList) { - List hiveFieldSchemaList = new ArrayList<>(); - if (catalogFieldSchemaList == null) { - return hiveFieldSchemaList; - } - for (com.amazonaws.services.glue.model.Column catalogFieldSchema : catalogFieldSchemaList){ - hiveFieldSchemaList.add(convertFieldSchema(catalogFieldSchema)); - } - - return hiveFieldSchemaList; - } - - public Table convertTable(com.amazonaws.services.glue.model.Table catalogTable, String dbname) { - Table hiveTable = new Table(); - hiveTable.setDbName(dbname); - hiveTable.setTableName(catalogTable.getName()); - Date createTime = catalogTable.getCreateTime(); - hiveTable.setCreateTime(createTime == null ? 0 : (int) (createTime.getTime() / 1000)); - hiveTable.setOwner(catalogTable.getOwner()); - Date lastAccessedTime = catalogTable.getLastAccessTime(); - hiveTable.setLastAccessTime(lastAccessedTime == null ? 0 : (int) (lastAccessedTime.getTime() / 1000)); - hiveTable.setRetention(catalogTable.getRetention()); - hiveTable.setSd(convertStorageDescriptor(catalogTable.getStorageDescriptor())); - hiveTable.setPartitionKeys(convertFieldSchemaList(catalogTable.getPartitionKeys())); - // Hive may throw a NPE during dropTable if the parameter map is null. - Map parameterMap = catalogTable.getParameters(); - if (parameterMap == null) { - parameterMap = Maps.newHashMap(); - } - hiveTable.setParameters(parameterMap); - hiveTable.setViewOriginalText(catalogTable.getViewOriginalText()); - hiveTable.setViewExpandedText(catalogTable.getViewExpandedText()); - hiveTable.setTableType(catalogTable.getTableType()); - - return hiveTable; - } - - public TableMeta convertTableMeta(com.amazonaws.services.glue.model.Table catalogTable, String dbName) { - TableMeta tableMeta = new TableMeta(); - tableMeta.setDbName(dbName); - tableMeta.setTableName(catalogTable.getName()); - tableMeta.setTableType(catalogTable.getTableType()); - if (catalogTable.getParameters().containsKey("comment")) { - tableMeta.setComments(catalogTable.getParameters().get("comment")); - } - return tableMeta; - } - - public StorageDescriptor convertStorageDescriptor(com.amazonaws.services.glue.model.StorageDescriptor catalogSd) { - StorageDescriptor hiveSd = new StorageDescriptor(); - hiveSd.setCols(convertFieldSchemaList(catalogSd.getColumns())); - hiveSd.setLocation(catalogSd.getLocation()); - hiveSd.setInputFormat(catalogSd.getInputFormat()); - hiveSd.setOutputFormat(catalogSd.getOutputFormat()); - hiveSd.setCompressed(catalogSd.getCompressed()); - hiveSd.setNumBuckets(catalogSd.getNumberOfBuckets()); - hiveSd.setSerdeInfo(convertSerDeInfo(catalogSd.getSerdeInfo())); - hiveSd.setBucketCols(firstNonNull(catalogSd.getBucketColumns(), Lists.newArrayList())); - hiveSd.setSortCols(convertOrderList(catalogSd.getSortColumns())); - hiveSd.setParameters(firstNonNull(catalogSd.getParameters(), Maps.newHashMap())); - hiveSd.setSkewedInfo(convertSkewedInfo(catalogSd.getSkewedInfo())); - hiveSd.setStoredAsSubDirectories(catalogSd.getStoredAsSubDirectories()); - - return hiveSd; - } - - public Order convertOrder(com.amazonaws.services.glue.model.Order catalogOrder) { - Order hiveOrder = new Order(); - hiveOrder.setCol(catalogOrder.getColumn()); - hiveOrder.setOrder(catalogOrder.getSortOrder()); - - return hiveOrder; - } - - public List convertOrderList(List catalogOrderList) { - List hiveOrderList = new ArrayList<>(); - if (catalogOrderList == null) { - return hiveOrderList; - } - for (com.amazonaws.services.glue.model.Order catalogOrder : catalogOrderList){ - hiveOrderList.add(convertOrder(catalogOrder)); - } - - return hiveOrderList; - } - - public SerDeInfo convertSerDeInfo(com.amazonaws.services.glue.model.SerDeInfo catalogSerDeInfo){ - SerDeInfo hiveSerDeInfo = new SerDeInfo(); - hiveSerDeInfo.setName(catalogSerDeInfo.getName()); - hiveSerDeInfo.setParameters(firstNonNull(catalogSerDeInfo.getParameters(), Maps.newHashMap())); - hiveSerDeInfo.setSerializationLib(catalogSerDeInfo.getSerializationLibrary()); - - return hiveSerDeInfo; - } - - public SkewedInfo convertSkewedInfo(com.amazonaws.services.glue.model.SkewedInfo catalogSkewedInfo) { - if (catalogSkewedInfo == null) { - return null; - } - - SkewedInfo hiveSkewedInfo = new SkewedInfo(); - hiveSkewedInfo.setSkewedColNames(firstNonNull(catalogSkewedInfo.getSkewedColumnNames(), Lists.newArrayList())); - hiveSkewedInfo.setSkewedColValues(convertSkewedValue(catalogSkewedInfo.getSkewedColumnValues())); - hiveSkewedInfo.setSkewedColValueLocationMaps(convertSkewedMap(catalogSkewedInfo.getSkewedColumnValueLocationMaps())); - return hiveSkewedInfo; - } - - public Partition convertPartition(com.amazonaws.services.glue.model.Partition src) { - Partition tgt = new Partition(); - Date createTime = src.getCreationTime(); - if (createTime != null) { - tgt.setCreateTime((int) (createTime.getTime() / 1000)); - tgt.setCreateTimeIsSet(true); - } else { - tgt.setCreateTimeIsSet(false); - } - String dbName = src.getDatabaseName(); - if (dbName != null) { - tgt.setDbName(dbName); - tgt.setDbNameIsSet(true); - } else { - tgt.setDbNameIsSet(false); - } - Date lastAccessTime = src.getLastAccessTime(); - if (lastAccessTime != null) { - tgt.setLastAccessTime((int) (lastAccessTime.getTime() / 1000)); - tgt.setLastAccessTimeIsSet(true); - } else { - tgt.setLastAccessTimeIsSet(false); - } - Map params = src.getParameters(); - - // A null parameter map causes Hive to throw a NPE - // so ensure we do not return a Partition object with a null parameter map. - if (params == null) { - params = Maps.newHashMap(); - } - - tgt.setParameters(params); - tgt.setParametersIsSet(true); - - String tableName = src.getTableName(); - if (tableName != null) { - tgt.setTableName(tableName); - tgt.setTableNameIsSet(true); - } else { - tgt.setTableNameIsSet(false); - } - - List values = src.getValues(); - if (values != null) { - tgt.setValues(values); - tgt.setValuesIsSet(true); - } else { - tgt.setValuesIsSet(false); - } - - com.amazonaws.services.glue.model.StorageDescriptor sd = src.getStorageDescriptor(); - if (sd != null) { - StorageDescriptor hiveSd = convertStorageDescriptor(sd); - tgt.setSd(hiveSd); - tgt.setSdIsSet(true); - } else { - tgt.setSdIsSet(false); - } - - return tgt; - } - - public List convertPartitions(List src) { - if (src == null) { - return null; - } - - List target = Lists.newArrayList(); - for (com.amazonaws.services.glue.model.Partition partition : src) { - target.add(convertPartition(partition)); - } - return target; - } - - public List convertStringToList(final String s) { - if (s == null) { - return null; - } - List listString = new ArrayList<>(); - for (int i = 0; i < s.length();) { - StringBuilder length = new StringBuilder(); - for (int j = i; j < s.length(); j++){ - if (s.charAt(j) != '$') { - length.append(s.charAt(j)); - } else { - int lengthOfString = Integer.valueOf(length.toString()); - listString.add(s.substring(j + 1, j + 1 + lengthOfString)); - i = j + 1 + lengthOfString; - break; - } - } - } - return listString; - } - - @Nonnull - public Map, String> convertSkewedMap(final @Nullable Map catalogSkewedMap) { - Map, String> skewedMap = new HashMap<>(); - if (catalogSkewedMap == null){ - return skewedMap; - } - - for (String coralKey : catalogSkewedMap.keySet()) { - skewedMap.put(convertStringToList(coralKey), catalogSkewedMap.get(coralKey)); - } - return skewedMap; - } - - @Nonnull - public List> convertSkewedValue(final @Nullable List catalogSkewedValue) { - List> skewedValues = new ArrayList<>(); - if (catalogSkewedValue == null){ - return skewedValues; - } - - for (String skewValue : catalogSkewedValue) { - skewedValues.add(convertStringToList(skewValue)); - } - return skewedValues; - } - - public PrincipalType convertPrincipalType(com.amazonaws.services.glue.model.PrincipalType catalogPrincipalType) { - if(catalogPrincipalType == null) { - return null; - } - - if(catalogPrincipalType == com.amazonaws.services.glue.model.PrincipalType.GROUP) { - return PrincipalType.GROUP; - } else if(catalogPrincipalType == com.amazonaws.services.glue.model.PrincipalType.USER) { - return PrincipalType.USER; - } else if(catalogPrincipalType == com.amazonaws.services.glue.model.PrincipalType.ROLE) { - return PrincipalType.ROLE; - } - throw new RuntimeException("Unknown principal type:" + catalogPrincipalType.name()); - } - - public Function convertFunction(final String dbName, - final com.amazonaws.services.glue.model.UserDefinedFunction catalogFunction) { - if (catalogFunction == null) { - return null; - } - Function hiveFunction = new Function(); - hiveFunction.setClassName(catalogFunction.getClassName()); - if (catalogFunction.getCreateTime() != null) { - //AWS Glue can return function with null create time - hiveFunction.setCreateTime((int) (catalogFunction.getCreateTime().getTime() / 1000)); - } - hiveFunction.setDbName(dbName); - hiveFunction.setFunctionName(catalogFunction.getFunctionName()); - hiveFunction.setFunctionType(FunctionType.JAVA); - hiveFunction.setOwnerName(catalogFunction.getOwnerName()); - hiveFunction.setOwnerType(convertPrincipalType(com.amazonaws.services.glue.model.PrincipalType.fromValue(catalogFunction.getOwnerType()))); - hiveFunction.setResourceUris(convertResourceUriList(catalogFunction.getResourceUris())); - return hiveFunction; - } - - public List convertResourceUriList( - final List catalogResourceUriList) { - if (catalogResourceUriList == null) { - return null; - } - List hiveResourceUriList = new ArrayList<>(); - for (com.amazonaws.services.glue.model.ResourceUri catalogResourceUri : catalogResourceUriList) { - ResourceUri hiveResourceUri = new ResourceUri(); - hiveResourceUri.setUri(catalogResourceUri.getUri()); - if (catalogResourceUri.getResourceType() != null) { - hiveResourceUri.setResourceType(ResourceType.valueOf(catalogResourceUri.getResourceType())); - } - hiveResourceUriList.add(hiveResourceUri); - } - - return hiveResourceUriList; - } - - public List convertColumnStatisticsList(List catatlogColumnStatisticsList) { - List hiveColumnStatisticsList = new ArrayList<>(); - for (ColumnStatistics catalogColumnStatistics : catatlogColumnStatisticsList) { - ColumnStatisticsObj hiveColumnStatistics = new ColumnStatisticsObj(); - hiveColumnStatistics.setColName(catalogColumnStatistics.getColumnName()); - hiveColumnStatistics.setColType(catalogColumnStatistics.getColumnType()); - hiveColumnStatistics.setStatsData(convertColumnStatisticsData(catalogColumnStatistics.getStatisticsData())); - hiveColumnStatisticsList.add(hiveColumnStatistics); - } - - return hiveColumnStatisticsList; - } - - private ColumnStatisticsData convertColumnStatisticsData( - com.amazonaws.services.glue.model.ColumnStatisticsData catalogColumnStatisticsData) { - ColumnStatisticsData hiveColumnStatisticsData = new ColumnStatisticsData(); - - ColumnStatisticsType type = ColumnStatisticsType.fromValue(catalogColumnStatisticsData.getType()); - switch (type) { - case BINARY: - BinaryColumnStatisticsData catalogBinaryData = catalogColumnStatisticsData.getBinaryColumnStatisticsData(); - BinaryColumnStatsData hiveBinaryData = new BinaryColumnStatsData(); - hiveBinaryData.setAvgColLen(catalogBinaryData.getAverageLength()); - hiveBinaryData.setMaxColLen(catalogBinaryData.getMaximumLength()); - hiveBinaryData.setNumNulls(catalogBinaryData.getNumberOfNulls()); - - hiveColumnStatisticsData.setFieldValue(ColumnStatisticsData._Fields.BINARY_STATS, hiveBinaryData); - hiveColumnStatisticsData.setBinaryStats(hiveBinaryData); - break; - - case BOOLEAN: - BooleanColumnStatisticsData catalogBooleanData = catalogColumnStatisticsData.getBooleanColumnStatisticsData(); - BooleanColumnStatsData hiveBooleanData = new BooleanColumnStatsData(); - hiveBooleanData.setNumFalses(catalogBooleanData.getNumberOfFalses()); - hiveBooleanData.setNumTrues(catalogBooleanData.getNumberOfTrues()); - hiveBooleanData.setNumNulls(catalogBooleanData.getNumberOfNulls()); - - hiveColumnStatisticsData.setBooleanStats(hiveBooleanData); - break; - - case DATE: - DateColumnStatisticsData catalogDateData = catalogColumnStatisticsData.getDateColumnStatisticsData(); - DateColumnStatsData hiveDateData = new DateColumnStatsData(); - hiveDateData.setLowValue(ConverterUtils.dateToHiveDate(catalogDateData.getMinimumValue())); - hiveDateData.setHighValue(ConverterUtils.dateToHiveDate(catalogDateData.getMaximumValue())); - hiveDateData.setNumDVs(catalogDateData.getNumberOfDistinctValues()); - hiveDateData.setNumNulls(catalogDateData.getNumberOfNulls()); - - hiveColumnStatisticsData.setDateStats(hiveDateData); - break; - - case DECIMAL: - DecimalColumnStatisticsData catalogDecimalData = catalogColumnStatisticsData.getDecimalColumnStatisticsData(); - DecimalColumnStatsData hiveDecimalData = new DecimalColumnStatsData(); - hiveDecimalData.setLowValue(convertDecimal(catalogDecimalData.getMinimumValue())); - hiveDecimalData.setHighValue(convertDecimal(catalogDecimalData.getMaximumValue())); - hiveDecimalData.setNumDVs(catalogDecimalData.getNumberOfDistinctValues()); - hiveDecimalData.setNumNulls(catalogDecimalData.getNumberOfNulls()); - - hiveColumnStatisticsData.setDecimalStats(hiveDecimalData); - break; - - case DOUBLE: - DoubleColumnStatisticsData catalogDoubleData = catalogColumnStatisticsData.getDoubleColumnStatisticsData(); - DoubleColumnStatsData hiveDoubleData = new DoubleColumnStatsData(); - hiveDoubleData.setLowValue(catalogDoubleData.getMinimumValue()); - hiveDoubleData.setHighValue(catalogDoubleData.getMaximumValue()); - hiveDoubleData.setNumDVs(catalogDoubleData.getNumberOfDistinctValues()); - hiveDoubleData.setNumNulls(catalogDoubleData.getNumberOfNulls()); - - hiveColumnStatisticsData.setDoubleStats(hiveDoubleData); - break; - - case LONG: - LongColumnStatisticsData catalogLongData = catalogColumnStatisticsData.getLongColumnStatisticsData(); - LongColumnStatsData hiveLongData = new LongColumnStatsData(); - hiveLongData.setLowValue(catalogLongData.getMinimumValue()); - hiveLongData.setHighValue(catalogLongData.getMaximumValue()); - hiveLongData.setNumDVs(catalogLongData.getNumberOfDistinctValues()); - hiveLongData.setNumNulls(catalogLongData.getNumberOfNulls()); - - hiveColumnStatisticsData.setLongStats(hiveLongData); - break; - - case STRING: - StringColumnStatisticsData catalogStringData = catalogColumnStatisticsData.getStringColumnStatisticsData(); - StringColumnStatsData hiveStringData = new StringColumnStatsData(); - hiveStringData.setAvgColLen(catalogStringData.getAverageLength()); - hiveStringData.setMaxColLen(catalogStringData.getMaximumLength()); - hiveStringData.setNumDVs(catalogStringData.getNumberOfDistinctValues()); - hiveStringData.setNumNulls(catalogStringData.getNumberOfNulls()); - - hiveColumnStatisticsData.setStringStats(hiveStringData); - break; - } - - return hiveColumnStatisticsData; - } - - private Decimal convertDecimal(com.amazonaws.services.glue.model.DecimalNumber catalogDecimal) { - Decimal hiveDecimal = new Decimal(); - hiveDecimal.setUnscaled(catalogDecimal.getUnscaledValue()); - hiveDecimal.setScale(catalogDecimal.getScale().shortValue()); - return hiveDecimal; - } - -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/CatalogToHiveConverter.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/CatalogToHiveConverter.java deleted file mode 100644 index 3ec28bde926ffc..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/CatalogToHiveConverter.java +++ /dev/null @@ -1,58 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.converters; - -import com.amazonaws.services.glue.model.ColumnStatistics; -import com.amazonaws.services.glue.model.ErrorDetail; -import org.apache.hadoop.hive.metastore.api.ColumnStatisticsObj; -import org.apache.hadoop.hive.metastore.api.Database; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.Function; -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.hadoop.hive.metastore.api.TableMeta; -import shade.doris.hive.org.apache.thrift.TException; - -import java.util.List; - -public interface CatalogToHiveConverter { - - TException wrapInHiveException(Throwable e); - - TException errorDetailToHiveException(ErrorDetail errorDetail); - - Database convertDatabase(com.amazonaws.services.glue.model.Database catalogDatabase); - - List convertFieldSchemaList(List catalogFieldSchemaList); - - Table convertTable(com.amazonaws.services.glue.model.Table catalogTable, String dbname); - - TableMeta convertTableMeta(com.amazonaws.services.glue.model.Table catalogTable, String dbName); - - Partition convertPartition(com.amazonaws.services.glue.model.Partition src); - - List convertPartitions(List src); - - Function convertFunction(String dbName, com.amazonaws.services.glue.model.UserDefinedFunction catalogFunction); - - List convertColumnStatisticsList(List catatlogColumnStatisticsList); -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/CatalogToHiveConverterFactory.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/CatalogToHiveConverterFactory.java deleted file mode 100644 index d8430ec169ec6c..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/CatalogToHiveConverterFactory.java +++ /dev/null @@ -1,54 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.converters; - -import com.google.common.annotations.VisibleForTesting; -import org.apache.hive.common.util.HiveVersionInfo; - -public class CatalogToHiveConverterFactory { - - private static final String HIVE_3_VERSION = "3."; - - private static CatalogToHiveConverter catalogToHiveConverter; - - public static CatalogToHiveConverter getCatalogToHiveConverter() { - if (catalogToHiveConverter == null) { - catalogToHiveConverter = loadConverter(); - } - return catalogToHiveConverter; - } - - private static CatalogToHiveConverter loadConverter() { - String hiveVersion = HiveVersionInfo.getShortVersion(); - - if (hiveVersion.startsWith(HIVE_3_VERSION)) { - return new Hive3CatalogToHiveConverter(); - } else { - return new BaseCatalogToHiveConverter(); - } - } - - @VisibleForTesting - static void clearConverter() { - catalogToHiveConverter = null; - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/ConverterUtils.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/ConverterUtils.java deleted file mode 100644 index b35063193101d6..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/ConverterUtils.java +++ /dev/null @@ -1,49 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.converters; - -import com.amazonaws.services.glue.model.Table; -import com.google.gson.Gson; - -import java.util.Date; -import java.util.concurrent.TimeUnit; - -public class ConverterUtils { - - private static final Gson gson = new Gson(); - - public static String catalogTableToString(final Table table) { - return gson.toJson(table); - } - - public static Table stringToCatalogTable(final String input) { - return gson.fromJson(input, Table.class); - } - - public static org.apache.hadoop.hive.metastore.api.Date dateToHiveDate(Date date) { - return new org.apache.hadoop.hive.metastore.api.Date(TimeUnit.MILLISECONDS.toDays(date.getTime())); - } - - public static Date hiveDatetoDate(org.apache.hadoop.hive.metastore.api.Date hiveDate) { - return new Date(TimeUnit.DAYS.toMillis(hiveDate.getDaysSinceEpoch())); - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/GlueInputConverter.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/GlueInputConverter.java deleted file mode 100644 index 45889e0ae6ddb1..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/GlueInputConverter.java +++ /dev/null @@ -1,116 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.converters; - -import com.amazonaws.services.glue.model.DatabaseInput; -import com.amazonaws.services.glue.model.PartitionInput; -import com.amazonaws.services.glue.model.TableInput; -import com.amazonaws.services.glue.model.UserDefinedFunctionInput; -import org.apache.hadoop.hive.metastore.api.Database; -import org.apache.hadoop.hive.metastore.api.Function; -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.Table; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -/** - * This class provides methods to convert Hive/Catalog objects to Input objects used - * for Glue API parameters - */ -public final class GlueInputConverter { - - public static DatabaseInput convertToDatabaseInput(Database hiveDatabase) { - return convertToDatabaseInput(HiveToCatalogConverter.convertDatabase(hiveDatabase)); - } - - public static DatabaseInput convertToDatabaseInput(com.amazonaws.services.glue.model.Database database) { - DatabaseInput input = new DatabaseInput(); - - input.setName(database.getName()); - input.setDescription(database.getDescription()); - input.setLocationUri(database.getLocationUri()); - input.setParameters(database.getParameters()); - - return input; - } - - public static TableInput convertToTableInput(Table hiveTable) { - return convertToTableInput(HiveToCatalogConverter.convertTable(hiveTable)); - } - - public static TableInput convertToTableInput(com.amazonaws.services.glue.model.Table table) { - TableInput tableInput = new TableInput(); - - tableInput.setRetention(table.getRetention()); - tableInput.setPartitionKeys(table.getPartitionKeys()); - tableInput.setTableType(table.getTableType()); - tableInput.setName(table.getName()); - tableInput.setOwner(table.getOwner()); - tableInput.setLastAccessTime(table.getLastAccessTime()); - tableInput.setStorageDescriptor(table.getStorageDescriptor()); - tableInput.setParameters(table.getParameters()); - tableInput.setViewExpandedText(table.getViewExpandedText()); - tableInput.setViewOriginalText(table.getViewOriginalText()); - - return tableInput; - } - - public static PartitionInput convertToPartitionInput(Partition src) { - return convertToPartitionInput(HiveToCatalogConverter.convertPartition(src)); - } - - public static PartitionInput convertToPartitionInput(com.amazonaws.services.glue.model.Partition src) { - PartitionInput partitionInput = new PartitionInput(); - - partitionInput.setLastAccessTime(src.getLastAccessTime()); - partitionInput.setParameters(src.getParameters()); - partitionInput.setStorageDescriptor(src.getStorageDescriptor()); - partitionInput.setValues(src.getValues()); - - return partitionInput; - } - - public static List convertToPartitionInputs(Collection parts) { - List inputList = new ArrayList<>(); - - for (com.amazonaws.services.glue.model.Partition part : parts) { - inputList.add(convertToPartitionInput(part)); - } - return inputList; - } - - public static UserDefinedFunctionInput convertToUserDefinedFunctionInput(Function hiveFunction) { - UserDefinedFunctionInput functionInput = new UserDefinedFunctionInput(); - - functionInput.setClassName(hiveFunction.getClassName()); - functionInput.setFunctionName(hiveFunction.getFunctionName()); - functionInput.setOwnerName(hiveFunction.getOwnerName()); - if(hiveFunction.getOwnerType() != null) { - functionInput.setOwnerType(hiveFunction.getOwnerType().name()); - } - functionInput.setResourceUris(HiveToCatalogConverter.covertResourceUriList(hiveFunction.getResourceUris())); - return functionInput; - } - -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/Hive3CatalogToHiveConverter.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/Hive3CatalogToHiveConverter.java deleted file mode 100644 index 4252ecd38a72b7..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/Hive3CatalogToHiveConverter.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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.converters; - -import static org.apache.hadoop.hive.metastore.Warehouse.DEFAULT_CATALOG_NAME; -import org.apache.hadoop.hive.metastore.api.Database; -import org.apache.hadoop.hive.metastore.api.Function; -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.hadoop.hive.metastore.api.TableMeta; - -public class Hive3CatalogToHiveConverter extends BaseCatalogToHiveConverter { - - @Override - public Database convertDatabase(com.amazonaws.services.glue.model.Database catalogDatabase) { - Database hiveDatabase = super.convertDatabase(catalogDatabase); - hiveDatabase.setCatalogName(DEFAULT_CATALOG_NAME); - return hiveDatabase; - } - - @Override - public Table convertTable(com.amazonaws.services.glue.model.Table catalogTable, String dbname) { - Table hiveTable = super.convertTable(catalogTable, dbname); - hiveTable.setCatName(DEFAULT_CATALOG_NAME); - return hiveTable; - } - - @Override - public TableMeta convertTableMeta(com.amazonaws.services.glue.model.Table catalogTable, String dbName) { - TableMeta tableMeta = super.convertTableMeta(catalogTable, dbName); - tableMeta.setCatName(DEFAULT_CATALOG_NAME); - return tableMeta; - } - - @Override - public Partition convertPartition(com.amazonaws.services.glue.model.Partition src) { - Partition hivePartition = super.convertPartition(src); - hivePartition.setCatName(DEFAULT_CATALOG_NAME); - return hivePartition; - } - - @Override - public Function convertFunction(String dbName, com.amazonaws.services.glue.model.UserDefinedFunction catalogFunction) { - Function hiveFunction = super.convertFunction(dbName, catalogFunction); - if (hiveFunction == null) { - return null; - } - hiveFunction.setCatName(DEFAULT_CATALOG_NAME); - return hiveFunction; - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/HiveToCatalogConverter.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/HiveToCatalogConverter.java deleted file mode 100644 index 48f4ca73dff666..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/HiveToCatalogConverter.java +++ /dev/null @@ -1,372 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.converters; - -import com.amazonaws.services.glue.model.BinaryColumnStatisticsData; -import com.amazonaws.services.glue.model.BooleanColumnStatisticsData; -import com.amazonaws.services.glue.model.ColumnStatisticsType; -import com.amazonaws.services.glue.model.DateColumnStatisticsData; -import com.amazonaws.services.glue.model.DecimalColumnStatisticsData; -import com.amazonaws.services.glue.model.DoubleColumnStatisticsData; -import com.amazonaws.services.glue.model.LongColumnStatisticsData; -import com.amazonaws.services.glue.model.StringColumnStatisticsData; -import org.apache.hadoop.hive.metastore.api.BinaryColumnStatsData; -import org.apache.hadoop.hive.metastore.api.BooleanColumnStatsData; -import org.apache.hadoop.hive.metastore.api.ColumnStatistics; -import org.apache.hadoop.hive.metastore.api.ColumnStatisticsData; -import org.apache.hadoop.hive.metastore.api.ColumnStatisticsDesc; -import org.apache.hadoop.hive.metastore.api.ColumnStatisticsObj; -import org.apache.hadoop.hive.metastore.api.Database; -import org.apache.hadoop.hive.metastore.api.DateColumnStatsData; -import org.apache.hadoop.hive.metastore.api.Decimal; -import org.apache.hadoop.hive.metastore.api.DecimalColumnStatsData; -import org.apache.hadoop.hive.metastore.api.DoubleColumnStatsData; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.Function; -import org.apache.hadoop.hive.metastore.api.LongColumnStatsData; -import org.apache.hadoop.hive.metastore.api.Order; -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.ResourceUri; -import org.apache.hadoop.hive.metastore.api.SerDeInfo; -import org.apache.hadoop.hive.metastore.api.SkewedInfo; -import org.apache.hadoop.hive.metastore.api.StorageDescriptor; -import org.apache.hadoop.hive.metastore.api.StringColumnStatsData; -import org.apache.hadoop.hive.metastore.api.Table; - -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; - -public class HiveToCatalogConverter { - - public static com.amazonaws.services.glue.model.Database convertDatabase(Database hiveDatabase) { - com.amazonaws.services.glue.model.Database catalogDatabase = new com.amazonaws.services.glue.model.Database(); - catalogDatabase.setName(hiveDatabase.getName()); - catalogDatabase.setDescription(hiveDatabase.getDescription()); - catalogDatabase.setLocationUri(hiveDatabase.getLocationUri()); - catalogDatabase.setParameters(hiveDatabase.getParameters()); - return catalogDatabase; - } - - public static com.amazonaws.services.glue.model.Table convertTable( - Table hiveTable) { - com.amazonaws.services.glue.model.Table catalogTable = new com.amazonaws.services.glue.model.Table(); - catalogTable.setRetention(hiveTable.getRetention()); - catalogTable.setPartitionKeys(convertFieldSchemaList(hiveTable.getPartitionKeys())); - catalogTable.setTableType(hiveTable.getTableType()); - catalogTable.setName(hiveTable.getTableName()); - catalogTable.setOwner(hiveTable.getOwner()); - catalogTable.setCreateTime(new Date((long) hiveTable.getCreateTime() * 1000)); - catalogTable.setLastAccessTime(new Date((long) hiveTable.getLastAccessTime() * 1000)); - catalogTable.setStorageDescriptor(convertStorageDescriptor(hiveTable.getSd())); - catalogTable.setParameters(hiveTable.getParameters()); - catalogTable.setViewExpandedText(hiveTable.getViewExpandedText()); - catalogTable.setViewOriginalText(hiveTable.getViewOriginalText()); - - return catalogTable; - } - - public static com.amazonaws.services.glue.model.StorageDescriptor convertStorageDescriptor( - StorageDescriptor hiveSd) { - com.amazonaws.services.glue.model.StorageDescriptor catalogSd = - new com.amazonaws.services.glue.model.StorageDescriptor(); - catalogSd.setNumberOfBuckets(hiveSd.getNumBuckets()); - catalogSd.setCompressed(hiveSd.isCompressed()); - catalogSd.setParameters(hiveSd.getParameters()); - catalogSd.setBucketColumns(hiveSd.getBucketCols()); - catalogSd.setColumns(convertFieldSchemaList(hiveSd.getCols())); - catalogSd.setInputFormat(hiveSd.getInputFormat()); - catalogSd.setLocation(hiveSd.getLocation()); - catalogSd.setOutputFormat(hiveSd.getOutputFormat()); - catalogSd.setSerdeInfo(convertSerDeInfo(hiveSd.getSerdeInfo())); - catalogSd.setSkewedInfo(convertSkewedInfo(hiveSd.getSkewedInfo())); - catalogSd.setSortColumns(convertOrderList(hiveSd.getSortCols())); - catalogSd.setStoredAsSubDirectories(hiveSd.isStoredAsSubDirectories()); - - return catalogSd; - } - - public static com.amazonaws.services.glue.model.Column convertFieldSchema( - FieldSchema hiveFieldSchema) { - com.amazonaws.services.glue.model.Column catalogFieldSchema = - new com.amazonaws.services.glue.model.Column(); - catalogFieldSchema.setComment(hiveFieldSchema.getComment()); - catalogFieldSchema.setName(hiveFieldSchema.getName()); - catalogFieldSchema.setType(hiveFieldSchema.getType()); - - return catalogFieldSchema; - } - - public static List convertFieldSchemaList( - List hiveFieldSchemaList) { - List catalogFieldSchemaList = - new ArrayList(); - for (FieldSchema hiveFs : hiveFieldSchemaList){ - catalogFieldSchemaList.add(convertFieldSchema(hiveFs)); - } - - return catalogFieldSchemaList; - } - - public static com.amazonaws.services.glue.model.SerDeInfo convertSerDeInfo( - SerDeInfo hiveSerDeInfo) { - com.amazonaws.services.glue.model.SerDeInfo catalogSerDeInfo = new com.amazonaws.services.glue.model.SerDeInfo(); - catalogSerDeInfo.setName(hiveSerDeInfo.getName()); - catalogSerDeInfo.setParameters(hiveSerDeInfo.getParameters()); - catalogSerDeInfo.setSerializationLibrary(hiveSerDeInfo.getSerializationLib()); - - return catalogSerDeInfo; - } - - public static com.amazonaws.services.glue.model.SkewedInfo convertSkewedInfo(SkewedInfo hiveSkewedInfo) { - if (hiveSkewedInfo == null) - return null; - com.amazonaws.services.glue.model.SkewedInfo catalogSkewedInfo = new com.amazonaws.services.glue.model.SkewedInfo() - .withSkewedColumnNames(hiveSkewedInfo.getSkewedColNames()) - .withSkewedColumnValues(convertSkewedValue(hiveSkewedInfo.getSkewedColValues())) - .withSkewedColumnValueLocationMaps(convertSkewedMap(hiveSkewedInfo.getSkewedColValueLocationMaps())); - return catalogSkewedInfo; - } - - public static com.amazonaws.services.glue.model.Order convertOrder(Order hiveOrder) { - com.amazonaws.services.glue.model.Order order = new com.amazonaws.services.glue.model.Order(); - order.setColumn(hiveOrder.getCol()); - order.setSortOrder(hiveOrder.getOrder()); - - return order; - } - - public static List convertOrderList(List hiveOrderList) { - if (hiveOrderList == null) { - return null; - } - List catalogOrderList = new ArrayList<>(); - for (Order hiveOrder : hiveOrderList) { - catalogOrderList.add(convertOrder(hiveOrder)); - } - - return catalogOrderList; - } - - public static com.amazonaws.services.glue.model.Partition convertPartition(Partition src) { - com.amazonaws.services.glue.model.Partition tgt = new com.amazonaws.services.glue.model.Partition(); - - tgt.setDatabaseName(src.getDbName()); - tgt.setTableName(src.getTableName()); - tgt.setCreationTime(new Date((long) src.getCreateTime() * 1000)); - tgt.setLastAccessTime(new Date((long) src.getLastAccessTime() * 1000)); - tgt.setParameters(src.getParameters()); - tgt.setStorageDescriptor(convertStorageDescriptor(src.getSd())); - tgt.setValues(src.getValues()); - - return tgt; - } - - public static String convertListToString(final List list) { - if (list == null) { - return null; - } - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < list.size(); i++) { - String currentString = list.get(i); - sb.append(currentString.length() + "$" + currentString); - } - - return sb.toString(); - } - - public static Map convertSkewedMap(final Map, String> coreSkewedMap){ - if (coreSkewedMap == null){ - return null; - } - Map catalogSkewedMap = new HashMap<>(); - for (List coreKey : coreSkewedMap.keySet()) { - catalogSkewedMap.put(convertListToString(coreKey), coreSkewedMap.get(coreKey)); - } - return catalogSkewedMap; - } - - public static List convertSkewedValue(final List> coreSkewedValue) { - if (coreSkewedValue == null) { - return null; - } - List catalogSkewedValue = new ArrayList<>(); - for (int i = 0; i < coreSkewedValue.size(); i++) { - catalogSkewedValue.add(convertListToString(coreSkewedValue.get(i))); - } - - return catalogSkewedValue; - } - - public static com.amazonaws.services.glue.model.UserDefinedFunction convertFunction(final Function hiveFunction) { - if (hiveFunction == null ){ - return null; - } - com.amazonaws.services.glue.model.UserDefinedFunction catalogFunction = new com.amazonaws.services.glue.model.UserDefinedFunction(); - catalogFunction.setClassName(hiveFunction.getClassName()); - catalogFunction.setFunctionName(hiveFunction.getFunctionName()); - catalogFunction.setCreateTime(new Date((long) (hiveFunction.getCreateTime()) * 1000)); - catalogFunction.setOwnerName(hiveFunction.getOwnerName()); - if(hiveFunction.getOwnerType() != null) { - catalogFunction.setOwnerType(hiveFunction.getOwnerType().name()); - } - catalogFunction.setResourceUris(covertResourceUriList(hiveFunction.getResourceUris())); - return catalogFunction; - } - - public static List covertResourceUriList( - final List hiveResourceUriList) { - if (hiveResourceUriList == null) { - return null; - } - List catalogResourceUriList = new ArrayList<>(); - for (ResourceUri hiveResourceUri : hiveResourceUriList) { - com.amazonaws.services.glue.model.ResourceUri catalogResourceUri = new com.amazonaws.services.glue.model.ResourceUri(); - catalogResourceUri.setUri(hiveResourceUri.getUri()); - if (hiveResourceUri.getResourceType() != null) { - catalogResourceUri.setResourceType(hiveResourceUri.getResourceType().name()); - } - catalogResourceUriList.add(catalogResourceUri); - } - return catalogResourceUriList; - } - - public static List convertColumnStatisticsObjList( - ColumnStatistics hiveColumnStatistics) { - ColumnStatisticsDesc hiveColumnStatisticsDesc = hiveColumnStatistics.getStatsDesc(); - List hiveColumnStatisticsObjs = hiveColumnStatistics.getStatsObj(); - - List catalogColumnStatisticsList = new ArrayList<>(); - for (ColumnStatisticsObj hiveColumnStatisticsObj : hiveColumnStatisticsObjs) { - com.amazonaws.services.glue.model.ColumnStatistics catalogColumnStatistics = - new com.amazonaws.services.glue.model.ColumnStatistics(); - catalogColumnStatistics.setColumnName(hiveColumnStatisticsObj.getColName()); - catalogColumnStatistics.setColumnType(hiveColumnStatisticsObj.getColType()); - // Last analyzed time in Hive is in days since Epoch, Java Date is in milliseconds - catalogColumnStatistics.setAnalyzedTime(new Date(TimeUnit.DAYS.toMillis(hiveColumnStatisticsDesc.getLastAnalyzed()))); - catalogColumnStatistics.setStatisticsData(convertColumnStatisticsData(hiveColumnStatisticsObj.getStatsData())); - catalogColumnStatisticsList.add(catalogColumnStatistics); - } - - return catalogColumnStatisticsList; - } - - private static com.amazonaws.services.glue.model.ColumnStatisticsData convertColumnStatisticsData( - ColumnStatisticsData hiveColumnStatisticsData) { - com.amazonaws.services.glue.model.ColumnStatisticsData catalogColumnStatisticsData = - new com.amazonaws.services.glue.model.ColumnStatisticsData(); - - // Hive uses the TUnion object to ensure that only one stats object is set at any time, this means that we can - // only call the get*() of a stats type if the 'setField' is set to that value - ColumnStatisticsData._Fields setField = hiveColumnStatisticsData.getSetField(); - switch (setField) { - case BINARY_STATS: - BinaryColumnStatsData hiveBinaryData = hiveColumnStatisticsData.getBinaryStats(); - BinaryColumnStatisticsData catalogBinaryData = new BinaryColumnStatisticsData(); - catalogBinaryData.setNumberOfNulls(hiveBinaryData.getNumNulls()); - catalogBinaryData.setMaximumLength(hiveBinaryData.getMaxColLen()); - catalogBinaryData.setAverageLength(hiveBinaryData.getAvgColLen()); - catalogColumnStatisticsData.setType(String.valueOf(ColumnStatisticsType.BINARY)); - catalogColumnStatisticsData.setBinaryColumnStatisticsData(catalogBinaryData); - break; - - case BOOLEAN_STATS: - BooleanColumnStatsData hiveBooleanData = hiveColumnStatisticsData.getBooleanStats(); - BooleanColumnStatisticsData catalogBooleanData = new BooleanColumnStatisticsData(); - catalogBooleanData.setNumberOfNulls(hiveBooleanData.getNumNulls()); - catalogBooleanData.setNumberOfFalses(hiveBooleanData.getNumFalses()); - catalogBooleanData.setNumberOfTrues(hiveBooleanData.getNumTrues()); - catalogColumnStatisticsData.setType(String.valueOf(ColumnStatisticsType.BOOLEAN)); - catalogColumnStatisticsData.setBooleanColumnStatisticsData(catalogBooleanData); - break; - - case DATE_STATS: - DateColumnStatsData hiveDateData = hiveColumnStatisticsData.getDateStats(); - DateColumnStatisticsData catalogDateData = new DateColumnStatisticsData(); - catalogDateData.setNumberOfNulls(hiveDateData.getNumNulls()); - catalogDateData.setNumberOfDistinctValues(hiveDateData.getNumDVs()); - catalogDateData.setMaximumValue(ConverterUtils.hiveDatetoDate(hiveDateData.getHighValue())); - catalogDateData.setMinimumValue(ConverterUtils.hiveDatetoDate(hiveDateData.getLowValue())); - catalogColumnStatisticsData.setType(String.valueOf(ColumnStatisticsType.DATE)); - catalogColumnStatisticsData.setDateColumnStatisticsData(catalogDateData); - break; - - case DECIMAL_STATS: - DecimalColumnStatsData hiveDecimalData = hiveColumnStatisticsData.getDecimalStats(); - DecimalColumnStatisticsData catalogDecimalData = new DecimalColumnStatisticsData(); - catalogDecimalData.setNumberOfNulls(hiveDecimalData.getNumNulls()); - catalogDecimalData.setNumberOfDistinctValues(hiveDecimalData.getNumDVs()); - catalogDecimalData.setMaximumValue(convertDecimal(hiveDecimalData.getHighValue())); - catalogDecimalData.setMinimumValue(convertDecimal(hiveDecimalData.getLowValue())); - catalogColumnStatisticsData.setType(String.valueOf(ColumnStatisticsType.DECIMAL)); - catalogColumnStatisticsData.setDecimalColumnStatisticsData(catalogDecimalData); - break; - - case DOUBLE_STATS: - DoubleColumnStatsData hiveDoubleData = hiveColumnStatisticsData.getDoubleStats(); - DoubleColumnStatisticsData catalogDoubleData = new DoubleColumnStatisticsData(); - catalogDoubleData.setNumberOfNulls(hiveDoubleData.getNumNulls()); - catalogDoubleData.setNumberOfDistinctValues(hiveDoubleData.getNumDVs()); - catalogDoubleData.setMaximumValue(hiveDoubleData.getHighValue()); - catalogDoubleData.setMinimumValue(hiveDoubleData.getLowValue()); - catalogColumnStatisticsData.setType(String.valueOf(ColumnStatisticsType.DOUBLE)); - catalogColumnStatisticsData.setDoubleColumnStatisticsData(catalogDoubleData); - break; - case LONG_STATS: - LongColumnStatsData hiveLongData = hiveColumnStatisticsData.getLongStats(); - LongColumnStatisticsData catalogLongData = new LongColumnStatisticsData(); - catalogLongData.setNumberOfNulls(hiveLongData.getNumNulls()); - catalogLongData.setNumberOfDistinctValues(hiveLongData.getNumDVs()); - catalogLongData.setMaximumValue(hiveLongData.getHighValue()); - catalogLongData.setMinimumValue(hiveLongData.getLowValue()); - catalogColumnStatisticsData.setType(String.valueOf(ColumnStatisticsType.LONG)); - catalogColumnStatisticsData.setLongColumnStatisticsData(catalogLongData); - break; - - case STRING_STATS: - StringColumnStatsData hiveStringData = hiveColumnStatisticsData.getStringStats(); - StringColumnStatisticsData catalogStringData = new StringColumnStatisticsData(); - catalogStringData.setNumberOfNulls(hiveStringData.getNumNulls()); - catalogStringData.setNumberOfDistinctValues(hiveStringData.getNumDVs()); - catalogStringData.setMaximumLength(hiveStringData.getMaxColLen()); - catalogStringData.setAverageLength(hiveStringData.getAvgColLen()); - catalogColumnStatisticsData.setType(String.valueOf(ColumnStatisticsType.STRING)); - catalogColumnStatisticsData.setStringColumnStatisticsData(catalogStringData); - break; - } - - return catalogColumnStatisticsData; - } - - private static com.amazonaws.services.glue.model.DecimalNumber convertDecimal(Decimal hiveDecimal) { - com.amazonaws.services.glue.model.DecimalNumber catalogDecimal = - new com.amazonaws.services.glue.model.DecimalNumber(); - catalogDecimal.setUnscaledValue(ByteBuffer.wrap(hiveDecimal.getUnscaled())); - catalogDecimal.setScale((int)hiveDecimal.getScale()); - return catalogDecimal; - } - -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/PartitionNameParser.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/PartitionNameParser.java deleted file mode 100644 index 7ecc7450577f19..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/converters/PartitionNameParser.java +++ /dev/null @@ -1,140 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.converters; - -import com.amazonaws.glue.catalog.exceptions.InvalidPartitionNameException; -import com.google.common.collect.ImmutableSet; - -import java.util.AbstractMap; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map.Entry; -import java.util.Set; - -public class PartitionNameParser { - - private static final String PARTITION_DELIMITER = "="; - private static final String PARTITION_NAME_DELIMITER = "/"; - - private static final char STORE_AS_NUMBER = 'n'; - private static final char STORE_AS_STRING = 's'; - - private static final Set NUMERIC_PARTITION_COLUMN_TYPES = ImmutableSet.of( - "tinyint", - "smallint", - "int", - "bigint" - ); - - public static String getPartitionName(List partitionColumns, List partitionValues) { - if (hasInvalidValues(partitionColumns, partitionValues) || hasInvalidSize(partitionColumns, partitionValues)) { - throw new IllegalArgumentException("Partition is not well formed. Columns and values do no match."); - } - - StringBuilder partitionName = new StringBuilder(); - partitionName.append(getPartitionColumnName(partitionColumns.get(0), partitionValues.get(0))); - - for (int i = 1; i < partitionColumns.size(); i++) { - partitionName.append(PARTITION_NAME_DELIMITER); - partitionName.append(getPartitionColumnName(partitionColumns.get(i), partitionValues.get(i))); - } - - return partitionName.toString(); - } - - private static boolean hasInvalidValues(List partitionColumns, List partitionValues) { - return partitionColumns == null || partitionValues == null; - } - - private static boolean hasInvalidSize(List partitionColumns, List partitionValues) { - return partitionColumns.size() != partitionValues.size(); - } - - private static String getPartitionColumnName(String partitionColumn, String partitionValue) { - return partitionColumn + "=" + partitionValue; - } - - public static LinkedHashMap getPartitionColumns(String partitionName) { - LinkedHashMap partitionColumns = new LinkedHashMap<>(); - String[] partitions = partitionName.split(PARTITION_NAME_DELIMITER); - for(String partition : partitions) { - Entry entry = getPartitionColumnValuePair(partition); - partitionColumns.put(entry.getKey(), entry.getValue()); - } - - return partitionColumns; - } - - /* - * Copied from https://github.com/apache/hive/blob/master/common/src/java/org/apache/hadoop/hive/common/FileUtils.java - */ - public static String unescapePathName(String path) { - int len = path.length(); - //pre-allocate sb to have enough buffer size, to avoid realloc - StringBuilder sb = new StringBuilder(len); - for (int i = 0; i < len; i++) { - char c = path.charAt(i); - if (c == '%' && i + 2 < len) { - int code = -1; - try { - code = Integer.parseInt(path.substring(i + 1, i + 3), 16); - } catch (Exception e) { - code = -1; - } - if (code >= 0) { - sb.append((char) code); - i += 2; - continue; - } - } - sb.append(c); - } - return sb.toString(); - } - - private static AbstractMap.SimpleEntry getPartitionColumnValuePair(String partition) { - String column = null; - String value = null; - String[] splitPartition = partition.split(PARTITION_DELIMITER); - if (splitPartition.length == 2) { - column = unescapePathName(splitPartition[0]); - value = unescapePathName(splitPartition[1]); - } else { - throw new InvalidPartitionNameException(partition); - } - - return new AbstractMap.SimpleEntry(column, value); - } - - public static List getPartitionValuesFromName(String partitionName) { - List partitionValues = new ArrayList<>(); - String[] partitions = partitionName.split(PARTITION_NAME_DELIMITER); - for(String partition : partitions) { - Entry entry = getPartitionColumnValuePair(partition); - partitionValues.add(entry.getValue()); - } - - return partitionValues; - } - -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/credentials/ConfigurationAWSCredentialsProvider.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/credentials/ConfigurationAWSCredentialsProvider.java deleted file mode 100644 index 293e6a099b0e7d..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/credentials/ConfigurationAWSCredentialsProvider.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 com.amazonaws.glue.catalog.credentials; - -import com.amazonaws.SdkClientException; -import com.amazonaws.auth.AWSCredentials; -import com.amazonaws.auth.AWSCredentialsProvider; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.auth.BasicSessionCredentials; -import com.amazonaws.auth.STSAssumeRoleSessionCredentialsProvider; -import com.amazonaws.glue.catalog.util.AWSGlueConfig; -import com.amazonaws.util.StringUtils; -import org.apache.doris.common.Config; -import org.apache.doris.datasource.property.common.AwsCredentialsProviderFactory; -import org.apache.doris.datasource.property.common.AwsCredentialsProviderMode; -import org.apache.hadoop.conf.Configuration; - -public class ConfigurationAWSCredentialsProvider implements AWSCredentialsProvider { - - private final Configuration conf; - - // The SDK signer invokes getCredentials() on every request, so the underlying provider must - // be built only once: providers like InstanceProfileCredentialsProvider and - // STSAssumeRoleSessionCredentialsProvider cache their temporary credentials per instance, - // and rebuilding them per call would hit IMDS/STS on every single Glue request. - private volatile AWSCredentialsProvider delegate; - - public ConfigurationAWSCredentialsProvider(Configuration conf) { - this.conf = conf; - } - - @Override - public AWSCredentials getCredentials() { - AWSCredentialsProvider provider = delegate; - if (provider == null) { - synchronized (this) { - if (delegate == null) { - delegate = buildDelegate(); - } - provider = delegate; - } - } - return provider.getCredentials(); - } - - private AWSCredentialsProvider buildDelegate() { - String accessKey = StringUtils.trim(conf.get(AWSGlueConfig.AWS_GLUE_ACCESS_KEY)); - String secretKey = StringUtils.trim(conf.get(AWSGlueConfig.AWS_GLUE_SECRET_KEY)); - String sessionToken = StringUtils.trim(conf.get(AWSGlueConfig.AWS_GLUE_SESSION_TOKEN)); - String roleArn = StringUtils.trim(conf.get(AWSGlueConfig.AWS_GLUE_ROLE_ARN)); - String externalId = StringUtils.trim(conf.get(AWSGlueConfig.AWS_GLUE_EXTERNAL_ID)); - if (!StringUtils.isNullOrEmpty(accessKey) && !StringUtils.isNullOrEmpty(secretKey)) { - AWSCredentials credentials = StringUtils.isNullOrEmpty(sessionToken) - ? new BasicAWSCredentials(accessKey, secretKey) - : new BasicSessionCredentials(accessKey, secretKey, sessionToken); - return new AWSStaticCredentialsProvider(credentials); - } - String credentialsProviderModeString = - StringUtils.lowerCase(conf.get(AWSGlueConfig.AWS_CREDENTIALS_PROVIDER_MODE)); - AwsCredentialsProviderMode credentialsProviderMode = - AwsCredentialsProviderMode.fromString(credentialsProviderModeString); - AWSCredentialsProvider longLivedProvider = AwsCredentialsProviderFactory.createV1(credentialsProviderMode); - if (!StringUtils.isNullOrEmpty(roleArn)) { - STSAssumeRoleSessionCredentialsProvider.Builder builder = - new STSAssumeRoleSessionCredentialsProvider.Builder(roleArn, "local-session") - .withLongLivedCredentialsProvider(longLivedProvider); - - if (!StringUtils.isNullOrEmpty(externalId)) { - builder.withExternalId(externalId); - } - return builder.build(); - } - if (Config.aws_credentials_provider_version.equalsIgnoreCase("v2")) { - return longLivedProvider; - } - throw new SdkClientException("Unable to load AWS credentials from any provider in the chain"); - } - - @Override - public void refresh() { - AWSCredentialsProvider provider = delegate; - if (provider != null) { - provider.refresh(); - } - } - - @Override - public String toString() { - return this.getClass().getSimpleName(); - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/credentials/ConfigurationAWSCredentialsProvider2x.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/credentials/ConfigurationAWSCredentialsProvider2x.java deleted file mode 100644 index bf50546c17ecb7..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/credentials/ConfigurationAWSCredentialsProvider2x.java +++ /dev/null @@ -1,50 +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 com.amazonaws.glue.catalog.credentials; - -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.AwsCredentials; -import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; - -import java.util.Map; - -/** - * This is credentials provider for AWS SDK 2.x, comparing to ConfigurationAWSCredentialsProvider, - * which is for AWS SDK 1.x. - * See: https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/migration-client-credentials.html - */ -public class ConfigurationAWSCredentialsProvider2x implements AwsCredentialsProvider { - - private AwsBasicCredentials awsBasicCredentials; - - private ConfigurationAWSCredentialsProvider2x(AwsBasicCredentials awsBasicCredentials) { - this.awsBasicCredentials = awsBasicCredentials; - } - - @Override - public AwsCredentials resolveCredentials() { - return awsBasicCredentials; - } - - public static AwsCredentialsProvider create(Map config) { - String ak = config.get("glue.access_key"); - String sk = config.get("glue.secret_key"); - AwsBasicCredentials awsBasicCredentials = AwsBasicCredentials.create(ak, sk); - return new ConfigurationAWSCredentialsProvider2x(awsBasicCredentials); - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/credentials/ConfigurationAWSCredentialsProviderFactory.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/credentials/ConfigurationAWSCredentialsProviderFactory.java deleted file mode 100644 index c1c526b8159347..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/credentials/ConfigurationAWSCredentialsProviderFactory.java +++ /dev/null @@ -1,29 +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 com.amazonaws.glue.catalog.credentials; - -import com.amazonaws.auth.AWSCredentialsProvider; -import com.amazonaws.glue.catalog.metastore.AWSCredentialsProviderFactory; -import org.apache.hadoop.conf.Configuration; - -public class ConfigurationAWSCredentialsProviderFactory implements AWSCredentialsProviderFactory { - @Override - public AWSCredentialsProvider buildAWSCredentialsProvider(Configuration conf) { - return new ConfigurationAWSCredentialsProvider(conf); - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/exceptions/InvalidPartitionNameException.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/exceptions/InvalidPartitionNameException.java deleted file mode 100644 index c2870dd2c1cffa..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/exceptions/InvalidPartitionNameException.java +++ /dev/null @@ -1,33 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.exceptions; - -public class InvalidPartitionNameException extends RuntimeException { - - public InvalidPartitionNameException(String message) { - super(message); - } - - public InvalidPartitionNameException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/exceptions/LakeFormationException.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/exceptions/LakeFormationException.java deleted file mode 100644 index 25fe259769857b..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/exceptions/LakeFormationException.java +++ /dev/null @@ -1,33 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.exceptions; - -public class LakeFormationException extends RuntimeException { - - public LakeFormationException(String message) { - super(message); - } - - public LakeFormationException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSCatalogMetastoreClient.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSCatalogMetastoreClient.java deleted file mode 100644 index db72f225dc7aad..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSCatalogMetastoreClient.java +++ /dev/null @@ -1,2481 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.metastore; - -import com.amazonaws.AmazonServiceException; -import com.amazonaws.glue.catalog.converters.CatalogToHiveConverter; -import com.amazonaws.glue.catalog.converters.GlueInputConverter; -import com.amazonaws.glue.catalog.converters.Hive3CatalogToHiveConverter; -import com.amazonaws.glue.catalog.util.BatchDeletePartitionsHelper; -import com.amazonaws.glue.catalog.util.ExpressionHelper; -import com.amazonaws.glue.catalog.util.LoggingHelper; -import com.amazonaws.glue.catalog.util.MetastoreClientUtils; -import static com.amazonaws.glue.catalog.util.MetastoreClientUtils.isExternalTable; -import com.amazonaws.services.glue.AWSGlue; -import com.amazonaws.services.glue.model.AlreadyExistsException; -import com.amazonaws.services.glue.model.EntityNotFoundException; -import com.amazonaws.services.glue.model.GetDatabaseRequest; -import com.amazonaws.services.glue.model.Partition; -import com.amazonaws.services.glue.model.UpdatePartitionRequest; -import com.google.common.base.MoreObjects; -import com.google.common.base.Preconditions; -import static com.google.common.base.Preconditions.checkNotNull; -import com.google.common.base.Strings; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import com.google.common.util.concurrent.ThreadFactoryBuilder; -import org.apache.commons.lang3.StringUtils; -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.fs.FileSystem; -import org.apache.hadoop.fs.Path; -import org.apache.hadoop.hive.common.FileUtils; -import org.apache.hadoop.hive.common.StatsSetupConst; -import org.apache.hadoop.hive.common.ValidTxnList; -import org.apache.hadoop.hive.common.ValidWriteIdList; -import org.apache.hadoop.hive.metastore.HiveMetaHookLoader; -import org.apache.hadoop.hive.metastore.IMetaStoreClient; -import org.apache.hadoop.hive.metastore.PartitionDropOptions; -import org.apache.hadoop.hive.metastore.TableType; -import org.apache.hadoop.hive.metastore.Warehouse; -import static org.apache.hadoop.hive.metastore.Warehouse.DEFAULT_DATABASE_COMMENT; -import static org.apache.hadoop.hive.metastore.Warehouse.DEFAULT_DATABASE_NAME; -import org.apache.hadoop.hive.metastore.api.AggrStats; -import org.apache.hadoop.hive.metastore.api.Catalog; -import org.apache.hadoop.hive.metastore.api.CheckConstraintsRequest; -import org.apache.hadoop.hive.metastore.api.CmRecycleRequest; -import org.apache.hadoop.hive.metastore.api.CmRecycleResponse; -import org.apache.hadoop.hive.metastore.api.ColumnStatisticsObj; -import org.apache.hadoop.hive.metastore.api.CompactionResponse; -import org.apache.hadoop.hive.metastore.api.CompactionType; -import org.apache.hadoop.hive.metastore.api.ConfigValSecurityException; -import org.apache.hadoop.hive.metastore.api.CreationMetadata; -import org.apache.hadoop.hive.metastore.api.CurrentNotificationEventId; -import org.apache.hadoop.hive.metastore.api.DataOperationType; -import org.apache.hadoop.hive.metastore.api.Database; -import org.apache.hadoop.hive.metastore.api.DefaultConstraintsRequest; -import org.apache.hadoop.hive.metastore.api.EnvironmentContext; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.FindSchemasByColsResp; -import org.apache.hadoop.hive.metastore.api.FindSchemasByColsRqst; -import org.apache.hadoop.hive.metastore.api.FireEventRequest; -import org.apache.hadoop.hive.metastore.api.FireEventResponse; -import org.apache.hadoop.hive.metastore.api.ForeignKeysRequest; -import org.apache.hadoop.hive.metastore.api.Function; -import org.apache.hadoop.hive.metastore.api.GetAllFunctionsResponse; -import org.apache.hadoop.hive.metastore.api.GetOpenTxnsInfoResponse; -import org.apache.hadoop.hive.metastore.api.GetRoleGrantsForPrincipalRequest; -import org.apache.hadoop.hive.metastore.api.GetRoleGrantsForPrincipalResponse; -import org.apache.hadoop.hive.metastore.api.HeartbeatTxnRangeResponse; -import org.apache.hadoop.hive.metastore.api.HiveObjectPrivilege; -import org.apache.hadoop.hive.metastore.api.HiveObjectRef; -import org.apache.hadoop.hive.metastore.api.HiveObjectType; -import org.apache.hadoop.hive.metastore.api.ISchema; -import org.apache.hadoop.hive.metastore.api.InvalidInputException; -import org.apache.hadoop.hive.metastore.api.InvalidObjectException; -import org.apache.hadoop.hive.metastore.api.InvalidOperationException; -import org.apache.hadoop.hive.metastore.api.InvalidPartitionException; -import org.apache.hadoop.hive.metastore.api.LockRequest; -import org.apache.hadoop.hive.metastore.api.LockResponse; -import org.apache.hadoop.hive.metastore.api.Materialization; -import org.apache.hadoop.hive.metastore.api.MetaException; -import org.apache.hadoop.hive.metastore.api.MetadataPpdResult; -import org.apache.hadoop.hive.metastore.api.NoSuchLockException; -import org.apache.hadoop.hive.metastore.api.NoSuchObjectException; -import org.apache.hadoop.hive.metastore.api.NoSuchTxnException; -import org.apache.hadoop.hive.metastore.api.NotNullConstraintsRequest; -import org.apache.hadoop.hive.metastore.api.NotificationEventResponse; -import org.apache.hadoop.hive.metastore.api.NotificationEventsCountRequest; -import org.apache.hadoop.hive.metastore.api.NotificationEventsCountResponse; -import org.apache.hadoop.hive.metastore.api.OpenTxnsResponse; -import org.apache.hadoop.hive.metastore.api.PartitionEventType; -import org.apache.hadoop.hive.metastore.api.PartitionValuesRequest; -import org.apache.hadoop.hive.metastore.api.PartitionValuesResponse; -import org.apache.hadoop.hive.metastore.api.PrimaryKeysRequest; -import org.apache.hadoop.hive.metastore.api.PrivilegeBag; -import org.apache.hadoop.hive.metastore.api.RuntimeStat; -import org.apache.hadoop.hive.metastore.api.SQLCheckConstraint; -import org.apache.hadoop.hive.metastore.api.SQLDefaultConstraint; -import org.apache.hadoop.hive.metastore.api.SQLForeignKey; -import org.apache.hadoop.hive.metastore.api.SQLNotNullConstraint; -import org.apache.hadoop.hive.metastore.api.SQLPrimaryKey; -import org.apache.hadoop.hive.metastore.api.SQLUniqueConstraint; -import org.apache.hadoop.hive.metastore.api.SchemaVersion; -import org.apache.hadoop.hive.metastore.api.SchemaVersionState; -import org.apache.hadoop.hive.metastore.api.SerDeInfo; -import org.apache.hadoop.hive.metastore.api.ShowCompactResponse; -import org.apache.hadoop.hive.metastore.api.ShowLocksRequest; -import org.apache.hadoop.hive.metastore.api.ShowLocksResponse; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.hadoop.hive.metastore.api.TableMeta; -import org.apache.hadoop.hive.metastore.api.TableValidWriteIds; -import org.apache.hadoop.hive.metastore.api.TxnAbortedException; -import org.apache.hadoop.hive.metastore.api.TxnOpenException; -import org.apache.hadoop.hive.metastore.api.TxnToWriteId; -import org.apache.hadoop.hive.metastore.api.UniqueConstraintsRequest; -import org.apache.hadoop.hive.metastore.api.UnknownDBException; -import org.apache.hadoop.hive.metastore.api.UnknownPartitionException; -import org.apache.hadoop.hive.metastore.api.UnknownTableException; -import org.apache.hadoop.hive.metastore.api.WMFullResourcePlan; -import org.apache.hadoop.hive.metastore.api.WMMapping; -import org.apache.hadoop.hive.metastore.api.WMNullablePool; -import org.apache.hadoop.hive.metastore.api.WMNullableResourcePlan; -import org.apache.hadoop.hive.metastore.api.WMPool; -import org.apache.hadoop.hive.metastore.api.WMResourcePlan; -import org.apache.hadoop.hive.metastore.api.WMTrigger; -import org.apache.hadoop.hive.metastore.api.WMValidateResourcePlanResponse; -import org.apache.hadoop.hive.metastore.api.hive_metastoreConstants; -import org.apache.hadoop.hive.metastore.conf.MetastoreConf; -import org.apache.hadoop.hive.metastore.partition.spec.PartitionSpecProxy; -import org.apache.hadoop.hive.metastore.utils.MetaStoreUtils; -import org.apache.hadoop.hive.metastore.utils.ObjectPair; -import org.apache.log4j.Logger; -import shade.doris.hive.org.apache.thrift.TException; - -import java.io.IOException; -import java.net.URI; -import java.nio.ByteBuffer; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.regex.Pattern; - -public class AWSCatalogMetastoreClient implements IMetaStoreClient { - - // TODO "hook" into Hive logging (hive or hive.metastore) - private static final Logger logger = Logger.getLogger(AWSCatalogMetastoreClient.class); - - private final Configuration conf; - private final AWSGlue glueClient; - private final Warehouse wh; - private final GlueMetastoreClientDelegate glueMetastoreClientDelegate; - private final String catalogId; - private final CatalogToHiveConverter catalogToHiveConverter; - - private static final int BATCH_DELETE_PARTITIONS_PAGE_SIZE = 25; - private static final int BATCH_DELETE_PARTITIONS_THREADS_COUNT = 5; - static final String BATCH_DELETE_PARTITIONS_THREAD_POOL_NAME_FORMAT = "batch-delete-partitions-%d"; - private static final ExecutorService BATCH_DELETE_PARTITIONS_THREAD_POOL = Executors.newFixedThreadPool( - BATCH_DELETE_PARTITIONS_THREADS_COUNT, - new ThreadFactoryBuilder() - .setNameFormat(BATCH_DELETE_PARTITIONS_THREAD_POOL_NAME_FORMAT) - .setDaemon(true).build() - ); - - private Map currentMetaVars; - // private final AwsGlueHiveShims hiveShims = ShimsLoader.getHiveShims(); - - public AWSCatalogMetastoreClient(Configuration conf, HiveMetaHookLoader hook, Boolean allowEmbedded) - throws MetaException { - this(conf, hook); - } - - public AWSCatalogMetastoreClient(Configuration conf, HiveMetaHookLoader hook) throws MetaException { - this.conf = conf; - glueClient = new AWSGlueClientFactory(this.conf).newClient(); - catalogToHiveConverter = new Hive3CatalogToHiveConverter(); - - // TODO preserve existing functionality for HiveMetaHook - wh = new Warehouse(this.conf); - - AWSGlueMetastore glueMetastore = new AWSGlueMetastoreFactory().newMetastore(conf); - glueMetastoreClientDelegate = new GlueMetastoreClientDelegate(this.conf, glueMetastore, wh); - - snapshotActiveConf(); - if (!doesDefaultDBExist()) { - createDefaultDatabase(); - } - catalogId = MetastoreClientUtils.getCatalogId(conf); - } - - /** - * Currently used for unit tests - */ - public static class Builder { - - private Configuration conf; - private Warehouse wh; - private GlueClientFactory clientFactory; - private AWSGlueMetastoreFactory metastoreFactory; - private boolean createDefaults = true; - private String catalogId; - private GlueMetastoreClientDelegate glueMetastoreClientDelegate; - - public Builder withConf(Configuration conf) { - this.conf = conf; - return this; - } - - public Builder withClientFactory(GlueClientFactory clientFactory) { - this.clientFactory = clientFactory; - return this; - } - - public Builder withMetastoreFactory(AWSGlueMetastoreFactory metastoreFactory) { - this.metastoreFactory = metastoreFactory; - return this; - } - - public Builder withWarehouse(Warehouse wh) { - this.wh = wh; - return this; - } - - public Builder withCatalogId(String catalogId) { - this.catalogId = catalogId; - return this; - } - - public Builder withGlueMetastoreClientDelegate(GlueMetastoreClientDelegate clientDelegate) { - this.glueMetastoreClientDelegate = clientDelegate; - return this; - } - - public AWSCatalogMetastoreClient build() throws MetaException { - return new AWSCatalogMetastoreClient(this); - } - - public Builder createDefaults(boolean createDefaultDB) { - this.createDefaults = createDefaultDB; - return this; - } - } - - private AWSCatalogMetastoreClient(Builder builder) throws MetaException { - catalogToHiveConverter = new Hive3CatalogToHiveConverter(); - conf = MoreObjects.firstNonNull(builder.conf, MetastoreConf.newMetastoreConf()); - - if (builder.wh != null) { - this.wh = builder.wh; - } else { - this.wh = new Warehouse(conf); - } - - if (builder.catalogId != null) { - this.catalogId = builder.catalogId; - } else { - this.catalogId = null; - } - - GlueClientFactory clientFactory = MoreObjects.firstNonNull(builder.clientFactory, new AWSGlueClientFactory(conf)); - AWSGlueMetastoreFactory metastoreFactory = MoreObjects.firstNonNull(builder.metastoreFactory, - new AWSGlueMetastoreFactory()); - - glueClient = clientFactory.newClient(); - AWSGlueMetastore glueMetastore = metastoreFactory.newMetastore(conf); - glueMetastoreClientDelegate = new GlueMetastoreClientDelegate(this.conf, glueMetastore, wh); - - /** - * It seems weird to create databases as part of client construction. This - * part should probably be moved to the section in hive code right after the - * metastore client is instantiated. For now, simply copying the - * functionality in the thrift server - */ - if(builder.createDefaults && !doesDefaultDBExist()) { - createDefaultDatabase(); - } - } - - private boolean doesDefaultDBExist() throws MetaException { - - try { - GetDatabaseRequest getDatabaseRequest = new GetDatabaseRequest().withName(DEFAULT_DATABASE_NAME).withCatalogId( - catalogId); - glueClient.getDatabase(getDatabaseRequest); - } catch (EntityNotFoundException e) { - return false; - } catch (AmazonServiceException e) { - String msg = "Unable to verify existence of default database: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - return true; - } - - private void createDefaultDatabase() throws MetaException { - Database defaultDB = new Database(); - defaultDB.setName(DEFAULT_DATABASE_NAME); - defaultDB.setDescription(DEFAULT_DATABASE_COMMENT); - defaultDB.setLocationUri(wh.getDefaultDatabasePath(DEFAULT_DATABASE_NAME).toString()); - - org.apache.hadoop.hive.metastore.api.PrincipalPrivilegeSet principalPrivilegeSet - = new org.apache.hadoop.hive.metastore.api.PrincipalPrivilegeSet(); - principalPrivilegeSet.setRolePrivileges(Maps.>newHashMap()); - - defaultDB.setPrivileges(principalPrivilegeSet); - - /** - * TODO: Grant access to role PUBLIC after role support is added - */ - try { - createDatabase(defaultDB); - } catch (org.apache.hadoop.hive.metastore.api.AlreadyExistsException e) { - logger.warn("database - default already exists. Ignoring.."); - } catch (Exception e) { - logger.error("Unable to create default database", e); - } - } - - @Override - public void createDatabase(Database database) throws InvalidObjectException, - org.apache.hadoop.hive.metastore.api.AlreadyExistsException, MetaException, TException { - glueMetastoreClientDelegate.createDatabase(database); - } - - @Override - public Database getDatabase(String name) throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.getDatabase(name); - } - - @Override - public Database getDatabase(String catalogName, String dbName) throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.getDatabase(dbName); - } - - @Override - public List getDatabases(String pattern) throws MetaException, TException { - return glueMetastoreClientDelegate.getDatabases(pattern); - } - - @Override - public List getDatabases(String catalogName, String dbPattern) throws MetaException, TException { - return glueMetastoreClientDelegate.getDatabases(dbPattern); - } - - @Override - public List getAllDatabases() throws MetaException, TException { - return getDatabases(".*"); - } - - @Override - public List getAllDatabases(String catalogName) throws MetaException, TException { - return getDatabases(".*"); - } - - @Override - public void alterDatabase(String databaseName, Database database) throws NoSuchObjectException, MetaException, - TException { - glueMetastoreClientDelegate.alterDatabase(databaseName, database); - } - - @Override - public void alterDatabase(String catalogName, String databaseName, Database database) throws NoSuchObjectException, MetaException, TException { - glueMetastoreClientDelegate.alterDatabase(databaseName, database); - } - - @Override - public void dropDatabase(String name) throws NoSuchObjectException, InvalidOperationException, MetaException, - TException { - dropDatabase(name, true, false, false); - } - - @Override - public void dropDatabase(String name, boolean deleteData, boolean ignoreUnknownDb) throws NoSuchObjectException, - InvalidOperationException, MetaException, TException { - dropDatabase(name, deleteData, ignoreUnknownDb, false); - } - - @Override - public void dropDatabase(String name, boolean deleteData, boolean ignoreUnknownDb, boolean cascade) - throws NoSuchObjectException, InvalidOperationException, MetaException, TException { - glueMetastoreClientDelegate.dropDatabase(name, deleteData, ignoreUnknownDb, cascade); - } - - @Override - public void dropDatabase(String catalogName, String dbName, boolean deleteData, boolean ignoreUnknownDb, boolean cascade) throws NoSuchObjectException, InvalidOperationException, MetaException, TException { - glueMetastoreClientDelegate.dropDatabase(dbName, deleteData, ignoreUnknownDb, cascade); - } - - @Override - public org.apache.hadoop.hive.metastore.api.Partition add_partition(org.apache.hadoop.hive.metastore.api.Partition partition) - throws InvalidObjectException, org.apache.hadoop.hive.metastore.api.AlreadyExistsException, MetaException, - TException { - glueMetastoreClientDelegate.addPartitions(Lists.newArrayList(partition), false, true); - return partition; - } - - @Override - public int add_partitions(List partitions) - throws InvalidObjectException, org.apache.hadoop.hive.metastore.api.AlreadyExistsException, MetaException, - TException { - return glueMetastoreClientDelegate.addPartitions(partitions, false, true).size(); - } - - @Override - public List add_partitions( - List partitions, - boolean ifNotExists, - boolean needResult - ) throws TException { - return glueMetastoreClientDelegate.addPartitions(partitions, ifNotExists, needResult); - } - - @Override - public int add_partitions_pspec( - PartitionSpecProxy pSpec - ) throws InvalidObjectException, org.apache.hadoop.hive.metastore.api.AlreadyExistsException, - MetaException, TException { - return glueMetastoreClientDelegate.addPartitionsSpecProxy(pSpec); - } - - @Override - public void alterFunction(String dbName, String functionName, org.apache.hadoop.hive.metastore.api.Function newFunction) throws InvalidObjectException, - MetaException, TException { - glueMetastoreClientDelegate.alterFunction(dbName, functionName, newFunction); - } - - @Override - public void alterFunction(String catalogName, String dbName, String functionName, Function newFunction) throws InvalidObjectException, MetaException, TException { - glueMetastoreClientDelegate.alterFunction(dbName, functionName, newFunction); - } - - @Override - public void alter_partition( - String dbName, - String tblName, - org.apache.hadoop.hive.metastore.api.Partition partition - ) throws InvalidOperationException, MetaException, TException { - glueMetastoreClientDelegate.alterPartitions(dbName, tblName, Lists.newArrayList(partition)); - } - - @Override - public void alter_partition( - String dbName, - String tblName, - org.apache.hadoop.hive.metastore.api.Partition partition, - EnvironmentContext environmentContext - ) throws InvalidOperationException, MetaException, TException { - glueMetastoreClientDelegate.alterPartitions(dbName, tblName, Lists.newArrayList(partition)); - } - - @Override - public void alter_partition( - String catalogName, - String dbName, - String tblName, - org.apache.hadoop.hive.metastore.api.Partition partition, - EnvironmentContext environmentContext - ) throws InvalidOperationException, MetaException, TException { - glueMetastoreClientDelegate.alterPartitions(dbName, tblName, Lists.newArrayList(partition)); - } - - @Override - public void alter_partitions( - String dbName, - String tblName, - List partitions - ) throws InvalidOperationException, MetaException, TException { - glueMetastoreClientDelegate.alterPartitions(dbName, tblName, partitions); - } - - @Override - public void alter_partitions( - String dbName, - String tblName, - List partitions, - EnvironmentContext environmentContext - ) throws InvalidOperationException, MetaException, TException { - glueMetastoreClientDelegate.alterPartitions(dbName, tblName, partitions); - } - - @Override - public void alter_partitions( - String catalogName, - String dbName, - String tblName, - List partitions, - EnvironmentContext environmentContext - ) throws InvalidOperationException, MetaException, TException { - glueMetastoreClientDelegate.alterPartitions(dbName, tblName, partitions); - } - - @Override - public void alter_table(String dbName, String tblName, Table table) - throws InvalidOperationException, MetaException, TException { - glueMetastoreClientDelegate.alterTable(dbName, tblName, table, null); - } - - @Override - public void alter_table(String catalogName, String dbName, String tblName, Table table, EnvironmentContext environmentContext) - throws InvalidOperationException, MetaException, TException { - glueMetastoreClientDelegate.alterTable(dbName, tblName, table, null); - } - - @Override - public void alter_table(String dbName, String tblName, Table table, boolean cascade) - throws InvalidOperationException, MetaException, TException { - EnvironmentContext environmentContext = null; - if (cascade) { - environmentContext = new EnvironmentContext(); - environmentContext.putToProperties("CASCADE", StatsSetupConst.TRUE); - } - glueMetastoreClientDelegate.alterTable(dbName, tblName, table, environmentContext); - } - - @Override - public void alter_table_with_environmentContext( - String dbName, - String tblName, - Table table, - EnvironmentContext environmentContext - ) throws InvalidOperationException, MetaException, TException { - glueMetastoreClientDelegate.alterTable(dbName, tblName, table, environmentContext); - } - - @Override - public org.apache.hadoop.hive.metastore.api.Partition appendPartition(String dbName, String tblName, List values) - throws InvalidObjectException, org.apache.hadoop.hive.metastore.api.AlreadyExistsException, MetaException, TException { - return glueMetastoreClientDelegate.appendPartition(dbName, tblName, values); - } - - @Override - public org.apache.hadoop.hive.metastore.api.Partition appendPartition(String catalogName, String dbName, String tblName, List values) - throws InvalidObjectException, org.apache.hadoop.hive.metastore.api.AlreadyExistsException, MetaException, TException { - return glueMetastoreClientDelegate.appendPartition(dbName, tblName, values); - } - - @Override - public org.apache.hadoop.hive.metastore.api.Partition appendPartition(String dbName, String tblName, String partitionName) throws InvalidObjectException, - org.apache.hadoop.hive.metastore.api.AlreadyExistsException, MetaException, TException { - List partVals = partitionNameToVals(partitionName); - return glueMetastoreClientDelegate.appendPartition(dbName, tblName, partVals); - } - - @Override - public org.apache.hadoop.hive.metastore.api.Partition appendPartition(String catalogName, String dbName, String tblName, String partitionName) - throws InvalidObjectException, org.apache.hadoop.hive.metastore.api.AlreadyExistsException, MetaException, TException { - List partVals = partitionNameToVals(partitionName); - return glueMetastoreClientDelegate.appendPartition(dbName, tblName, partVals); - } - - @Override - public boolean create_role(org.apache.hadoop.hive.metastore.api.Role role) throws MetaException, TException { - return glueMetastoreClientDelegate.createRole(role); - } - - @Override - public boolean drop_role(String roleName) throws MetaException, TException { - return glueMetastoreClientDelegate.dropRole(roleName); - } - - @Override - public List list_roles( - String principalName, org.apache.hadoop.hive.metastore.api.PrincipalType principalType - ) throws MetaException, TException { - return glueMetastoreClientDelegate.listRoles(principalName, principalType); - } - - @Override - public List listRoleNames() throws MetaException, TException { - return glueMetastoreClientDelegate.listRoleNames(); - } - - @Override - public org.apache.hadoop.hive.metastore.api.GetPrincipalsInRoleResponse get_principals_in_role( - org.apache.hadoop.hive.metastore.api.GetPrincipalsInRoleRequest request) throws MetaException, TException { - return glueMetastoreClientDelegate.getPrincipalsInRole(request); - } - - @Override - public GetRoleGrantsForPrincipalResponse get_role_grants_for_principal( - GetRoleGrantsForPrincipalRequest request) throws MetaException, TException { - return glueMetastoreClientDelegate.getRoleGrantsForPrincipal(request); - } - - @Override - public boolean grant_role( - String roleName, - String userName, - org.apache.hadoop.hive.metastore.api.PrincipalType principalType, - String grantor, org.apache.hadoop.hive.metastore.api.PrincipalType grantorType, - boolean grantOption - ) throws MetaException, TException { - return glueMetastoreClientDelegate.grantRole(roleName, userName, principalType, grantor, grantorType, grantOption); - } - - @Override - public boolean revoke_role( - String roleName, - String userName, - org.apache.hadoop.hive.metastore.api.PrincipalType principalType, - boolean grantOption - ) throws MetaException, TException { - return glueMetastoreClientDelegate.revokeRole(roleName, userName, principalType, grantOption); - } - - @Override - public void cancelDelegationToken(String tokenStrForm) throws MetaException, TException { - glueMetastoreClientDelegate.cancelDelegationToken(tokenStrForm); - } - - @Override - public String getTokenStrForm() throws IOException { - return glueMetastoreClientDelegate.getTokenStrForm(); - } - - @Override - public boolean addToken(String tokenIdentifier, String delegationToken) throws TException { - return glueMetastoreClientDelegate.addToken(tokenIdentifier, delegationToken); - } - - @Override - public boolean removeToken(String tokenIdentifier) throws TException { - return glueMetastoreClientDelegate.removeToken(tokenIdentifier); - } - - @Override - public String getToken(String tokenIdentifier) throws TException { - return glueMetastoreClientDelegate.getToken(tokenIdentifier); - } - - @Override - public List getAllTokenIdentifiers() throws TException { - return glueMetastoreClientDelegate.getAllTokenIdentifiers(); - } - - @Override - public int addMasterKey(String key) throws MetaException, TException { - return glueMetastoreClientDelegate.addMasterKey(key); - } - - @Override - public void updateMasterKey(Integer seqNo, String key) throws NoSuchObjectException, MetaException, TException { - glueMetastoreClientDelegate.updateMasterKey(seqNo, key); - } - - @Override - public boolean removeMasterKey(Integer keySeq) throws TException { - return glueMetastoreClientDelegate.removeMasterKey(keySeq); - } - - @Override - public String[] getMasterKeys() throws TException { - return glueMetastoreClientDelegate.getMasterKeys(); - } - - @Override - public LockResponse checkLock(long lockId) - throws NoSuchTxnException, TxnAbortedException, NoSuchLockException, TException { - return glueMetastoreClientDelegate.checkLock(lockId); - } - - @Override - public void close() { - currentMetaVars = null; - } - - @Override - public void commitTxn(long txnId) throws NoSuchTxnException, TxnAbortedException, TException { - glueMetastoreClientDelegate.commitTxn(txnId); - } - - @Override - public void replCommitTxn(long srcTxnid, String replPolicy) throws NoSuchTxnException, TxnAbortedException, TException { - glueMetastoreClientDelegate.replCommitTxn(srcTxnid, replPolicy); - } - - @Override - public void abortTxns(List txnIds) throws TException { - glueMetastoreClientDelegate.abortTxns(txnIds); - } - - @Override - public long allocateTableWriteId(long txnId, String dbName, String tableName) throws TException { - throw new UnsupportedOperationException("allocateTableWriteId is not supported."); - } - - @Override - public void replTableWriteIdState(String validWriteIdList, String dbName, String tableName, List partNames) throws TException { - throw new UnsupportedOperationException("replTableWriteIdState is not supported."); - } - - @Override - public List allocateTableWriteIdsBatch(List txnIds, String dbName, String tableName) throws TException { - throw new UnsupportedOperationException("allocateTableWriteIdsBatch is not supported."); - } - - @Override - public List replAllocateTableWriteIdsBatch(String dbName, String tableName, String replPolicy, - List srcTxnToWriteIdList) throws TException { - throw new UnsupportedOperationException("replAllocateTableWriteIdsBatch is not supported."); - } - - @Deprecated - public void compact( - String dbName, - String tblName, - String partitionName, - CompactionType compactionType - ) throws TException { - glueMetastoreClientDelegate.compact(dbName, tblName, partitionName, compactionType); - } - - @Deprecated - public void compact( - String dbName, - String tblName, - String partitionName, - CompactionType compactionType, - Map tblProperties - ) throws TException { - glueMetastoreClientDelegate.compact(dbName, tblName, partitionName, compactionType, tblProperties); - } - - @Override - public CompactionResponse compact2( - String dbName, - String tblName, - String partitionName, - CompactionType compactionType, - Map tblProperties - ) throws TException { - return glueMetastoreClientDelegate.compact2(dbName, tblName, partitionName, compactionType, tblProperties); - } - - @Override - public void createFunction(org.apache.hadoop.hive.metastore.api.Function function) throws InvalidObjectException, MetaException, TException { - glueMetastoreClientDelegate.createFunction(function); - } - - @Override - public void createTable(Table tbl) throws org.apache.hadoop.hive.metastore.api.AlreadyExistsException, InvalidObjectException, MetaException, - NoSuchObjectException, TException { - glueMetastoreClientDelegate.createTable(tbl); - } - - @Override - public boolean deletePartitionColumnStatistics( - String dbName, String tableName, String partName, String colName - ) throws NoSuchObjectException, MetaException, InvalidObjectException, - TException, org.apache.hadoop.hive.metastore.api.InvalidInputException { - return glueMetastoreClientDelegate.deletePartitionColumnStatistics(dbName, tableName, partName, colName); - } - - @Override - public boolean deletePartitionColumnStatistics(String catalogName, String dbName, String tableName, String partName, String colName) - throws NoSuchObjectException, MetaException, InvalidObjectException, TException, InvalidInputException { - return glueMetastoreClientDelegate.deletePartitionColumnStatistics(dbName, tableName, partName, colName); - } - - @Override - public boolean deleteTableColumnStatistics( - String dbName, String tableName, String colName - ) throws NoSuchObjectException, MetaException, InvalidObjectException, - TException, org.apache.hadoop.hive.metastore.api.InvalidInputException { - return glueMetastoreClientDelegate.deleteTableColumnStatistics(dbName, tableName, colName); - } - - @Override - public boolean deleteTableColumnStatistics(String catalogName, String dbName, String tableName, String colName) - throws NoSuchObjectException, MetaException, InvalidObjectException, TException, InvalidInputException { - return glueMetastoreClientDelegate.deleteTableColumnStatistics(dbName, tableName, colName); - } - - @Override - public void dropFunction(String dbName, String functionName) throws MetaException, NoSuchObjectException, - InvalidObjectException, org.apache.hadoop.hive.metastore.api.InvalidInputException, TException { - glueMetastoreClientDelegate.dropFunction(dbName, functionName); - } - - @Override - public void dropFunction(String catalogName, String dbName, String functionName) - throws MetaException, NoSuchObjectException, InvalidObjectException, InvalidInputException, TException { - glueMetastoreClientDelegate.dropFunction(dbName, functionName); - } - - private void deleteParentRecursive(Path parent, int depth, boolean mustPurge) throws IOException, MetaException { - if (depth > 0 && parent != null && wh.isWritable(parent) && wh.isEmpty(parent)) { - wh.deleteDir(parent, true, mustPurge, true); - deleteParentRecursive(parent.getParent(), depth - 1, mustPurge); - } - } - - // This logic is taken from HiveMetaStore#isMustPurge - private boolean isMustPurge(Table table, boolean ifPurge) { - return (ifPurge || "true".equalsIgnoreCase(table.getParameters().get("auto.purge"))); - } - - @Override - public boolean dropPartition(String dbName, String tblName, List values, boolean deleteData) - throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.dropPartition(dbName, tblName, values, false, deleteData, false); - } - - @Override - public boolean dropPartition(String catalogName, String dbName, String tblName, List values, boolean deleteData) - throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.dropPartition(dbName, tblName, values, false, deleteData, false); - } - - @Override - public boolean dropPartition(String dbName, String tblName, List values, PartitionDropOptions options) throws TException { - return glueMetastoreClientDelegate.dropPartition(dbName, tblName, values, options.ifExists, options.deleteData, options.purgeData); - } - - @Override - public boolean dropPartition(String catalogName, String dbName, String tblName, List values, PartitionDropOptions options) - throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.dropPartition(dbName, tblName, values, options.ifExists, options.deleteData, options.purgeData); - } - - @Override - public List dropPartitions( - String dbName, - String tblName, - List> partExprs, - boolean deleteData, - boolean ifExists - ) throws NoSuchObjectException, MetaException, TException { - //use defaults from PartitionDropOptions for purgeData - return dropPartitions_core(dbName, tblName, partExprs, deleteData, false); - } - - @Override - public List dropPartitions( - String dbName, - String tblName, - List> partExprs, - boolean deleteData, - boolean ifExists, - boolean needResults - ) throws NoSuchObjectException, MetaException, TException { - return dropPartitions_core(dbName, tblName, partExprs, deleteData, false); - } - - @Override - public List dropPartitions( - String dbName, - String tblName, - List> partExprs, - PartitionDropOptions options - ) throws NoSuchObjectException, MetaException, TException { - return dropPartitions_core(dbName, tblName, partExprs, options.deleteData, options.purgeData); - } - - @Override - public List dropPartitions( - String catalogName, - String dbName, - String tblName, - List> partExprs, - PartitionDropOptions options - ) throws NoSuchObjectException, MetaException, TException { - return dropPartitions_core(dbName, tblName, partExprs, options.deleteData, options.purgeData); - } - - @Override - public boolean dropPartition(String dbName, String tblName, String partitionName, boolean deleteData) - throws NoSuchObjectException, MetaException, TException { - List values = partitionNameToVals(partitionName); - return glueMetastoreClientDelegate.dropPartition(dbName, tblName, values, false, deleteData, false); - } - - @Override - public boolean dropPartition(String catalogName, String dbName, String tblName, String partitionName, boolean deleteData) - throws NoSuchObjectException, MetaException, TException { - List values = partitionNameToVals(partitionName); - return glueMetastoreClientDelegate.dropPartition(dbName, tblName, values, false, deleteData, false); - } - - private List dropPartitions_core( - String databaseName, - String tableName, - List> partExprs, - boolean deleteData, - boolean purgeData - ) throws TException { - List deleted = Lists.newArrayList(); - for (ObjectPair expr : partExprs) { - byte[] tmp = expr.getSecond(); - String exprString = ExpressionHelper.convertHiveExpressionToCatalogExpression(tmp); - List catalogPartitionsToDelete = glueMetastoreClientDelegate.getCatalogPartitions(databaseName, tableName, exprString, -1); - deleted.addAll(batchDeletePartitions(databaseName, tableName, catalogPartitionsToDelete, deleteData, purgeData)); - } - return deleted; - } - - /** - * Delete all partitions in the list provided with BatchDeletePartitions request. It doesn't use transaction, - * so the call may result in partial failure. - * @param dbName - * @param tableName - * @param partitionsToDelete - * @return the partitions successfully deleted - * @throws TException - */ - private List batchDeletePartitions( - final String dbName, final String tableName, final List partitionsToDelete, - final boolean deleteData, final boolean purgeData) throws TException { - - List deleted = Lists.newArrayList(); - if (partitionsToDelete == null) { - return deleted; - } - - validateBatchDeletePartitionsArguments(dbName, tableName, partitionsToDelete); - - List> batchDeletePartitionsFutures = Lists.newArrayList(); - - int numOfPartitionsToDelete = partitionsToDelete.size(); - for (int i = 0; i < numOfPartitionsToDelete; i += BATCH_DELETE_PARTITIONS_PAGE_SIZE) { - int j = Math.min(i + BATCH_DELETE_PARTITIONS_PAGE_SIZE, numOfPartitionsToDelete); - final List partitionsOnePage = partitionsToDelete.subList(i, j); - - batchDeletePartitionsFutures.add(BATCH_DELETE_PARTITIONS_THREAD_POOL.submit(new Callable() { - @Override - public BatchDeletePartitionsHelper call() throws Exception { - return new BatchDeletePartitionsHelper(glueClient, dbName, tableName, catalogId, partitionsOnePage).deletePartitions(); - } - })); - } - - TException tException = null; - for (Future future : batchDeletePartitionsFutures) { - try { - BatchDeletePartitionsHelper batchDeletePartitionsHelper = future.get(); - for (Partition partition : batchDeletePartitionsHelper.getPartitionsDeleted()) { - org.apache.hadoop.hive.metastore.api.Partition hivePartition = - catalogToHiveConverter.convertPartition(partition); - try { - performDropPartitionPostProcessing(dbName, tableName, hivePartition, deleteData, purgeData); - } catch (TException e) { - logger.error("Drop partition directory failed.", e); - tException = tException == null ? e : tException; - } - deleted.add(hivePartition); - } - tException = tException == null ? batchDeletePartitionsHelper.getFirstTException() : tException; - } catch (Exception e) { - logger.error("Exception thrown by BatchDeletePartitions thread pool. ", e); - } - } - - if (tException != null) { - throw tException; - } - return deleted; - } - - private void validateBatchDeletePartitionsArguments(final String dbName, final String tableName, - final List partitionsToDelete) { - - Preconditions.checkArgument(dbName != null, "Database name cannot be null"); - Preconditions.checkArgument(tableName != null, "Table name cannot be null"); - for (Partition partition : partitionsToDelete) { - Preconditions.checkArgument(dbName.equals(partition.getDatabaseName()), "Database name cannot be null"); - Preconditions.checkArgument(tableName.equals(partition.getTableName()), "Table name cannot be null"); - Preconditions.checkArgument(partition.getValues() != null, "Partition values cannot be null"); - } - } - - // Preserve the logic from Hive metastore - private void performDropPartitionPostProcessing(String dbName, String tblName, - org.apache.hadoop.hive.metastore.api.Partition partition, boolean deleteData, boolean ifPurge) - throws MetaException, NoSuchObjectException, TException { - if (deleteData && partition.getSd() != null && partition.getSd().getLocation() != null) { - Path partPath = new Path(partition.getSd().getLocation()); - Table table = getTable(dbName, tblName); - if (isExternalTable(table)){ - //Don't delete external table data - return; - } - boolean mustPurge = isMustPurge(table, ifPurge); - wh.deleteDir(partPath, true, mustPurge, true); - try { - List values = partition.getValues(); - deleteParentRecursive(partPath.getParent(), values.size() - 1, mustPurge); - } catch (IOException e) { - throw new MetaException(e.getMessage()); - } - } - } - - @Deprecated - public void dropTable(String tableName, boolean deleteData) throws MetaException, UnknownTableException, TException, - NoSuchObjectException { - dropTable(DEFAULT_DATABASE_NAME, tableName, deleteData, false); - } - - @Override - public void dropTable(String dbname, String tableName) throws MetaException, TException, NoSuchObjectException { - dropTable(dbname, tableName, true, true, false); - } - - @Override - public void dropTable( - String catName, - String dbName, - String tableName, - boolean deleteData, - boolean ignoreUnknownTable, - boolean ifPurge - ) throws MetaException, NoSuchObjectException, TException { - glueMetastoreClientDelegate.dropTable(dbName, tableName, deleteData, ignoreUnknownTable, ifPurge); - } - - @Override - public void truncateTable(String dbName, String tableName, List partNames) throws MetaException, TException { - throw new UnsupportedOperationException("truncateTable is not supported"); - } - - @Override - public void truncateTable(String catalogName, String dbName, String tableName, List partNames) throws MetaException, TException { - throw new UnsupportedOperationException("truncateTable is not supported"); - } - - @Override - public CmRecycleResponse recycleDirToCmPath(CmRecycleRequest cmRecycleRequest) throws MetaException, TException { - // Taken from HiveMetaStore#cm_recycle - wh.recycleDirToCmPath(new Path(cmRecycleRequest.getDataPath()), cmRecycleRequest.isPurge()); - return new CmRecycleResponse(); - } - - @Override - public void dropTable(String dbname, String tableName, boolean deleteData, boolean ignoreUnknownTab) - throws MetaException, TException, NoSuchObjectException { - dropTable(dbname, tableName, deleteData, ignoreUnknownTab, false); - } - - @Override - public void dropTable(String dbname, String tableName, boolean deleteData, boolean ignoreUnknownTab, boolean ifPurge) - throws MetaException, TException, NoSuchObjectException { - glueMetastoreClientDelegate.dropTable(dbname, tableName, deleteData, ignoreUnknownTab, ifPurge); - } - - @Override - public org.apache.hadoop.hive.metastore.api.Partition exchange_partition( - Map partitionSpecs, - String srcDb, - String srcTbl, - String dstDb, - String dstTbl - ) throws MetaException, NoSuchObjectException, InvalidObjectException, TException { - return glueMetastoreClientDelegate.exchangePartition(partitionSpecs, srcDb, srcTbl, dstDb, dstTbl); - } - - @Override - public org.apache.hadoop.hive.metastore.api.Partition exchange_partition( - Map partitionSpecs, - String sourceCat, - String sourceDb, - String sourceTable, - String destCat, - String destdb, - String destTableName - ) throws MetaException, NoSuchObjectException, InvalidObjectException, TException { - return glueMetastoreClientDelegate.exchangePartition(partitionSpecs, sourceDb, sourceTable, destdb, destTableName); - } - - @Override - public List exchange_partitions( - Map partitionSpecs, - String sourceDb, - String sourceTbl, - String destDb, - String destTbl - ) throws MetaException, NoSuchObjectException, InvalidObjectException, TException { - return glueMetastoreClientDelegate.exchangePartitions(partitionSpecs, sourceDb, sourceTbl, destDb, destTbl); - } - - @Override - public List exchange_partitions( - Map partitionSpecs, - String sourceCat, - String sourceDb, - String sourceTbl, - String destCat, - String destDb, - String destTbl - ) throws MetaException, NoSuchObjectException, InvalidObjectException, TException { - return glueMetastoreClientDelegate.exchangePartitions(partitionSpecs, sourceDb, sourceTbl, destDb, destTbl); - } - - @Override - public AggrStats getAggrColStatsFor(String dbName, String tblName, List colNames, List partName) - throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.getAggrColStatsFor(dbName, tblName, colNames, partName); - } - - @Override - public AggrStats getAggrColStatsFor(String catalogName, String dbName, String tblName, List colNames, List partName) - throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.getAggrColStatsFor(dbName, tblName, colNames, partName); - } - - @Override - public List getAllTables(String dbname) throws MetaException, TException, UnknownDBException { - return getTables(dbname, ".*"); - } - - @Override - public List getAllTables(String catalogName, String dbname) throws MetaException, TException, UnknownDBException { - return getTables(dbname, ".*"); - } - - @Override - public String getConfigValue(String name, String defaultValue) throws TException, ConfigValSecurityException { - if (name == null) { - return defaultValue; - } - - if(!Pattern.matches("(hive|hdfs|mapred|metastore).*", name)) { - throw new ConfigValSecurityException("For security reasons, the config key " + name + " cannot be accessed"); - } - - return conf.get(name, defaultValue); - } - - @Override - public String getDelegationToken( - String owner, String renewerKerberosPrincipalName - ) throws MetaException, TException { - return glueMetastoreClientDelegate.getDelegationToken(owner, renewerKerberosPrincipalName); - } - - @Override - public List getFields(String db, String tableName) throws MetaException, TException, - UnknownTableException, UnknownDBException { - return glueMetastoreClientDelegate.getFields(db, tableName); - } - - @Override - public List getFields(String catalogName, String db, String tableName) throws MetaException, TException, UnknownTableException, UnknownDBException { - return glueMetastoreClientDelegate.getFields(db, tableName); - } - - @Override - public org.apache.hadoop.hive.metastore.api.Function getFunction(String dbName, String functionName) throws MetaException, TException { - return glueMetastoreClientDelegate.getFunction(dbName, functionName); - } - - @Override - public Function getFunction(String catalogName, String dbName, String functionName) throws MetaException, TException { - return glueMetastoreClientDelegate.getFunction(dbName, functionName); - } - - @Override - public List getFunctions(String dbName, String pattern) throws MetaException, TException { - return glueMetastoreClientDelegate.getFunctions(dbName, pattern); - } - - @Override - public List getFunctions(String catalogName, String dbName, String pattern) throws MetaException, TException { - return glueMetastoreClientDelegate.getFunctions(dbName, pattern); - } - - @Override - public GetAllFunctionsResponse getAllFunctions() throws MetaException, TException { - return glueMetastoreClientDelegate.getAllFunctions(); - } - - @Override - public String getMetaConf(String key) throws MetaException, TException { - MetastoreConf.ConfVars metaConfVar = MetastoreConf.getMetaConf(key); - if (metaConfVar == null) { - throw new MetaException("Invalid configuration key " + key); - } - return conf.get(key, metaConfVar.getDefaultVal().toString()); - } - - @Override - public void createCatalog(Catalog catalog) throws org.apache.hadoop.hive.metastore.api.AlreadyExistsException, InvalidObjectException, MetaException, TException { - throw new UnsupportedOperationException("createCatalog is not supported"); - } - - @Override - public void alterCatalog(String s, Catalog catalog) throws NoSuchObjectException, InvalidObjectException, MetaException, TException { - throw new UnsupportedOperationException("alterCatalog is not supported"); - } - - @Override - public Catalog getCatalog(String s) throws NoSuchObjectException, MetaException, TException { - throw new UnsupportedOperationException("getCatalog is not supported"); - } - - @Override - public List getCatalogs() throws MetaException, TException { - throw new UnsupportedOperationException("getCatalogs is not supported"); - } - - @Override - public void dropCatalog(String s) throws NoSuchObjectException, InvalidOperationException, MetaException, TException { - throw new UnsupportedOperationException("dropCatalog is not supported"); - } - - @Override - public org.apache.hadoop.hive.metastore.api.Partition getPartition(String dbName, String tblName, List values) - throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.getPartition(dbName, tblName, values); - } - - @Override - public org.apache.hadoop.hive.metastore.api.Partition getPartition(String catalogName, String dbName, String tblName, List values) throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.getPartition(dbName, tblName, values); - } - - @Override - public org.apache.hadoop.hive.metastore.api.Partition getPartition(String dbName, String tblName, String partitionName) - throws MetaException, UnknownTableException, NoSuchObjectException, TException { - return glueMetastoreClientDelegate.getPartition(dbName, tblName, partitionName); - } - - @Override - public org.apache.hadoop.hive.metastore.api.Partition getPartition(String catalogName, String dbName, String tblName, String partitionName) throws MetaException, UnknownTableException, NoSuchObjectException, TException { - return glueMetastoreClientDelegate.getPartition(dbName, tblName, partitionName); - } - - @Override - public Map> getPartitionColumnStatistics( - String dbName, - String tableName, - List partitionNames, - List columnNames - ) throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.getPartitionColumnStatistics(dbName, tableName, partitionNames, columnNames); - } - - @Override - public Map> getPartitionColumnStatistics( - String catalogName, - String dbName, - String tableName, - List partitionNames, - List columnNames) throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.getPartitionColumnStatistics(dbName, tableName, partitionNames, columnNames); - } - - @Override - public org.apache.hadoop.hive.metastore.api.Partition getPartitionWithAuthInfo( - String databaseName, String tableName, List values, - String userName, List groupNames) - throws MetaException, UnknownTableException, NoSuchObjectException, TException { - - // TODO move this into the service - org.apache.hadoop.hive.metastore.api.Partition partition = getPartition(databaseName, tableName, values); - Table table = getTable(databaseName, tableName); - if ("TRUE".equalsIgnoreCase(table.getParameters().get("PARTITION_LEVEL_PRIVILEGE"))) { - String partName = Warehouse.makePartName(table.getPartitionKeys(), values); - HiveObjectRef obj = new HiveObjectRef(); - obj.setObjectType(HiveObjectType.PARTITION); - obj.setDbName(databaseName); - obj.setObjectName(tableName); - obj.setPartValues(values); - org.apache.hadoop.hive.metastore.api.PrincipalPrivilegeSet privilegeSet = - this.get_privilege_set(obj, userName, groupNames); - partition.setPrivileges(privilegeSet); - } - - return partition; - } - - @Override - public org.apache.hadoop.hive.metastore.api.Partition getPartitionWithAuthInfo( - String catalogName, - String databaseName, - String tableName, - List values, - String userName, - List groupNames) throws MetaException, UnknownTableException, NoSuchObjectException, TException { - return getPartitionWithAuthInfo(databaseName, tableName, values, userName, groupNames); - } - - @Override - public List getPartitionsByNames( - String databaseName, String tableName, List partitionNames) - throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.getPartitionsByNames(databaseName, tableName, partitionNames); - } - - @Override - public List getPartitionsByNames( - String catalogName, - String databaseName, - String tableName, - List partitionNames - ) throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.getPartitionsByNames(databaseName, tableName, partitionNames); - } - - @Override - public List getSchema(String db, String tableName) throws MetaException, TException, UnknownTableException, - UnknownDBException { - return glueMetastoreClientDelegate.getSchema(db, tableName); - } - - @Override - public List getSchema(String catalogName, String db, String tableName) throws MetaException, TException, UnknownTableException, UnknownDBException { - return glueMetastoreClientDelegate.getSchema(db, tableName); - } - - @Override - public Table getTable(String dbName, String tableName) - throws MetaException, TException, NoSuchObjectException { - return glueMetastoreClientDelegate.getTable(dbName, tableName); - } - - @Override - public Table getTable(String catalogName, String dbName, String tableName) throws MetaException, TException { - return glueMetastoreClientDelegate.getTable(dbName, tableName); - } - - @Override - public List getTableColumnStatistics(String dbName, String tableName, List colNames) - throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.getTableColumnStatistics(dbName, tableName, colNames); - } - - @Override - public List getTableColumnStatistics(String catalogName, String dbName, String tableName, List colNames) throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.getTableColumnStatistics(dbName, tableName, colNames); - } - - @Override - public List
    getTableObjectsByName(String dbName, List tableNames) throws MetaException, - InvalidOperationException, UnknownDBException, TException { - List
    hiveTables = Lists.newArrayList(); - for(String tableName : tableNames) { - hiveTables.add(getTable(dbName, tableName)); - } - - return hiveTables; - } - - @Override - public List
    getTableObjectsByName(String catalogName, String dbName, List tableNames) throws MetaException, InvalidOperationException, UnknownDBException, TException { - return getTableObjectsByName(dbName, tableNames); - } - - @Override - public Materialization getMaterializationInvalidationInfo(CreationMetadata creationMetadata, String validTxnList) throws MetaException, InvalidOperationException, UnknownDBException, TException { - throw new UnsupportedOperationException("getMaterializationInvalidationInfo is not supported"); - } - - @Override - public void updateCreationMetadata(String dbName, String tableName, CreationMetadata cm) throws MetaException, TException { - throw new UnsupportedOperationException("getMaterializationInvalidationInfo is not supported"); - } - - @Override - public void updateCreationMetadata(String catName, String dbName, String tableName, CreationMetadata cm) throws MetaException, TException { - throw new UnsupportedOperationException("getMaterializationInvalidationInfo is not supported"); - } - - @Override - public List getTables(String dbname, String tablePattern) throws MetaException, TException, UnknownDBException { - return glueMetastoreClientDelegate.getTables(dbname, tablePattern); - } - - @Override - public List getTables(String catalogName, String dbname, String tablePattern) throws MetaException, TException, UnknownDBException { - return glueMetastoreClientDelegate.getTables(dbname, tablePattern); - } - - @Override - public List getTables(String dbname, String tablePattern, TableType tableType) - throws MetaException, TException, UnknownDBException { - return glueMetastoreClientDelegate.getTables(dbname, tablePattern, tableType); - } - - @Override - public List getTables(String catalogName, String dbname, String tablePattern, TableType tableType) throws MetaException, TException, UnknownDBException { - return glueMetastoreClientDelegate.getTables(dbname, tablePattern, tableType); - } - - @Override - public List getMaterializedViewsForRewriting(String dbName) throws MetaException, TException, UnknownDBException { - // not supported - return Lists.newArrayList(); - } - - @Override - public List getMaterializedViewsForRewriting(String catalogName, String dbName) throws MetaException, TException, UnknownDBException { - // not supported - return Lists.newArrayList(); - } - - @Override - public List getTableMeta(String dbPatterns, String tablePatterns, List tableTypes) - throws MetaException, TException, UnknownDBException { - return glueMetastoreClientDelegate.getTableMeta(dbPatterns, tablePatterns, tableTypes); - } - - @Override - public List getTableMeta(String catalogName, String dbPatterns, String tablePatterns, List tableTypes) throws MetaException, TException, UnknownDBException { - return glueMetastoreClientDelegate.getTableMeta(dbPatterns, tablePatterns, tableTypes); - } - - @Override - public ValidTxnList getValidTxns() throws TException { - return glueMetastoreClientDelegate.getValidTxns(); - } - - @Override - public ValidTxnList getValidTxns(long currentTxn) throws TException { - return glueMetastoreClientDelegate.getValidTxns(currentTxn); - } - - @Override - public ValidWriteIdList getValidWriteIds(String fullTableName) throws TException { - throw new UnsupportedOperationException("getValidWriteIds is not supported"); - } - - @Override - public List getValidWriteIds(List tablesList, String validTxnList) throws TException { - throw new UnsupportedOperationException("getValidWriteIds is not supported"); - } - - @Override - public org.apache.hadoop.hive.metastore.api.PrincipalPrivilegeSet get_privilege_set( - HiveObjectRef obj, - String user, List groups - ) throws MetaException, TException { - return glueMetastoreClientDelegate.getPrivilegeSet(obj, user, groups); - } - - @Override - public boolean grant_privileges(org.apache.hadoop.hive.metastore.api.PrivilegeBag privileges) - throws MetaException, TException { - return glueMetastoreClientDelegate.grantPrivileges(privileges); - } - - @Override - public boolean revoke_privileges( - org.apache.hadoop.hive.metastore.api.PrivilegeBag privileges, - boolean grantOption - ) throws MetaException, TException { - return glueMetastoreClientDelegate.revokePrivileges(privileges, grantOption); - } - - @Override - public boolean refresh_privileges(HiveObjectRef hiveObjectRef, String s, PrivilegeBag privilegeBag) throws MetaException, TException { - throw new UnsupportedOperationException("refresh_privileges is not supported"); - } - - @Override - public void heartbeat(long txnId, long lockId) - throws NoSuchLockException, NoSuchTxnException, TxnAbortedException, TException { - glueMetastoreClientDelegate.heartbeat(txnId, lockId); - } - - @Override - public HeartbeatTxnRangeResponse heartbeatTxnRange(long min, long max) throws TException { - return glueMetastoreClientDelegate.heartbeatTxnRange(min, max); - } - - @Override - public boolean isCompatibleWith(Configuration conf) { - if (currentMetaVars == null) { - return false; // recreate - } - boolean compatible = true; - for (MetastoreConf.ConfVars oneVar : MetastoreConf.metaVars) { - // Since metaVars are all of different types, use string for comparison - String oldVar = currentMetaVars.get(oneVar.getVarname()); - String newVar = conf.get(oneVar.getVarname(), ""); - if (oldVar == null || - (oneVar.isCaseSensitive() ? !oldVar.equals(newVar) : !oldVar.equalsIgnoreCase(newVar))) { - logger.info("Mestastore configuration " + oneVar.getVarname() + - " changed from " + oldVar + " to " + newVar); - compatible = false; - } - } - return compatible; - } - - @Override - public void setHiveAddedJars(String addedJars) { - //taken from HiveMetaStoreClient - MetastoreConf.setVar(conf, MetastoreConf.ConfVars.ADDED_JARS, addedJars); - } - - @Override - public boolean isLocalMetaStore() { - return false; - } - - private void snapshotActiveConf() { - currentMetaVars = new HashMap(MetastoreConf.metaVars.length); - for (MetastoreConf.ConfVars oneVar : MetastoreConf.metaVars) { - currentMetaVars.put(oneVar.getVarname(), conf.get(oneVar.getVarname(), "")); - } - } - - @Override - public boolean isPartitionMarkedForEvent(String dbName, String tblName, Map partKVs, PartitionEventType eventType) - throws MetaException, NoSuchObjectException, TException, UnknownTableException, UnknownDBException, - UnknownPartitionException, InvalidPartitionException { - return glueMetastoreClientDelegate.isPartitionMarkedForEvent(dbName, tblName, partKVs, eventType); - } - - @Override - public boolean isPartitionMarkedForEvent(String catalogName, String dbName, String tblName, Map partKVs, PartitionEventType eventType) - throws MetaException, NoSuchObjectException, TException, UnknownTableException, UnknownDBException, UnknownPartitionException, InvalidPartitionException { - return glueMetastoreClientDelegate.isPartitionMarkedForEvent(dbName, tblName, partKVs, eventType); - } - - @Override - public List listPartitionNames(String dbName, String tblName, short max) - throws MetaException, TException { - try { - return glueMetastoreClientDelegate.listPartitionNames(dbName, tblName, null, max); - } catch (NoSuchObjectException e) { - // For compatibility with Hive 1.0.0 - return Collections.emptyList(); - } - } - - @Override - public List listPartitionNames(String catalogName, String dbName, String tblName, int maxParts) - throws NoSuchObjectException, MetaException, TException { - return listPartitionNames(dbName, tblName, (short) maxParts); - } - - @Override - public List listPartitionNames(String databaseName, String tableName, - List values, short max) - throws MetaException, TException, NoSuchObjectException { - return glueMetastoreClientDelegate.listPartitionNames(databaseName, tableName, values, max); - } - - @Override - public List listPartitionNames(String catalogName, String databaseName, String tableName, List values, int max) - throws MetaException, TException, NoSuchObjectException { - return listPartitionNames(databaseName, tableName, values, (short) max); - } - - @Override - public PartitionValuesResponse listPartitionValues(PartitionValuesRequest partitionValuesRequest) throws TException { - return glueMetastoreClientDelegate.listPartitionValues(partitionValuesRequest); - } - - @Override - public int getNumPartitionsByFilter(String dbName, String tableName, String filter) - throws MetaException, NoSuchObjectException, TException { - return glueMetastoreClientDelegate.getNumPartitionsByFilter(dbName, tableName, filter); - } - - @Override - public int getNumPartitionsByFilter(String catalogName, String dbName, String tableName, String filter) - throws MetaException, NoSuchObjectException, TException { - return glueMetastoreClientDelegate.getNumPartitionsByFilter(dbName, tableName, filter); - } - - @Override - public PartitionSpecProxy listPartitionSpecs(String dbName, String tblName, int max) throws TException { - return glueMetastoreClientDelegate.listPartitionSpecs(dbName, tblName, max); - } - - @Override - public PartitionSpecProxy listPartitionSpecs(String catalogName, String dbName, String tblName, int max) throws TException { - return glueMetastoreClientDelegate.listPartitionSpecs(dbName, tblName, max); - } - - @Override - public PartitionSpecProxy listPartitionSpecsByFilter(String dbName, String tblName, String filter, int max) - throws MetaException, NoSuchObjectException, TException { - return glueMetastoreClientDelegate.listPartitionSpecsByFilter(dbName, tblName, filter, max); - } - - @Override - public PartitionSpecProxy listPartitionSpecsByFilter(String catalogName, String dbName, String tblName, String filter, int max) - throws MetaException, NoSuchObjectException, TException { - return glueMetastoreClientDelegate.listPartitionSpecsByFilter(dbName, tblName, filter, max); - } - - @Override - public List listPartitions(String dbName, String tblName, short max) - throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.getPartitions(dbName, tblName, null, max); - } - - @Override - public List listPartitions(String catalogName, String dbName, String tblName, int max) - throws NoSuchObjectException, MetaException, TException { - return glueMetastoreClientDelegate.getPartitions(dbName, tblName, null, max); - } - - @Override - public List listPartitions( - String databaseName, - String tableName, - List values, - short max - ) throws NoSuchObjectException, MetaException, TException { - String expression = null; - if (values != null) { - Table table = getTable(databaseName, tableName); - expression = ExpressionHelper.buildExpressionFromPartialSpecification(table, values); - } - return glueMetastoreClientDelegate.getPartitions(databaseName, tableName, expression, (long) max); - } - - @Override - public List listPartitions( - String catalogName, - String databaseName, - String tableName, - List values, - int max) throws NoSuchObjectException, MetaException, TException { - return listPartitions(databaseName, tableName, values, (short) max); - } - - @Override - public boolean listPartitionsByExpr( - String databaseName, - String tableName, - byte[] expr, - String defaultPartitionName, - short max, - List result - ) throws TException { - checkNotNull(result, "The result argument cannot be null."); - - String catalogExpression = ExpressionHelper.convertHiveExpressionToCatalogExpression(expr); - List partitions = - glueMetastoreClientDelegate.getPartitions(databaseName, tableName, catalogExpression, (long) max); - result.addAll(partitions); - - return false; - } - - @Override - public boolean listPartitionsByExpr( - String catalogName, - String databaseName, - String tableName, - byte[] expr, - String defaultPartitionName, - int max, - List result) throws TException { - return listPartitionsByExpr(databaseName, tableName, expr, defaultPartitionName, (short) max, result); - } - - @Override - public List listPartitionsByFilter( - String databaseName, - String tableName, - String filter, - short max - ) throws MetaException, NoSuchObjectException, TException { - // we need to replace double quotes with single quotes in the filter expression - // since server side does not accept double quote expressions. - if (StringUtils.isNotBlank(filter)) { - filter = ExpressionHelper.replaceDoubleQuoteWithSingleQuotes(filter); - } - return glueMetastoreClientDelegate.getPartitions(databaseName, tableName, filter, (long) max); - } - - @Override - public List listPartitionsByFilter( - String catalogName, - String databaseName, - String tableName, - String filter, - int max) throws MetaException, NoSuchObjectException, TException { - return listPartitionsByFilter(databaseName, tableName, filter, (short) max); - } - - @Override - public List listPartitionsWithAuthInfo(String database, String table, short maxParts, - String user, List groups) - throws MetaException, TException, NoSuchObjectException { - List partitions = listPartitions(database, table, maxParts); - - for (org.apache.hadoop.hive.metastore.api.Partition p : partitions) { - HiveObjectRef obj = new HiveObjectRef(); - obj.setObjectType(HiveObjectType.PARTITION); - obj.setDbName(database); - obj.setObjectName(table); - obj.setPartValues(p.getValues()); - org.apache.hadoop.hive.metastore.api.PrincipalPrivilegeSet set = this.get_privilege_set(obj, user, groups); - p.setPrivileges(set); - } - - return partitions; - } - - @Override - public List listPartitionsWithAuthInfo( - String catalogName, - String database, - String table, - int maxParts, - String user, - List groups - ) throws MetaException, TException, NoSuchObjectException { - return listPartitionsWithAuthInfo(database, table, (short) maxParts, user, groups); - } - - @Override - public List listPartitionsWithAuthInfo(String database, String table, - List partVals, short maxParts, - String user, List groups) throws MetaException, TException, NoSuchObjectException { - List partitions = listPartitions(database, table, partVals, maxParts); - - for (org.apache.hadoop.hive.metastore.api.Partition p : partitions) { - HiveObjectRef obj = new HiveObjectRef(); - obj.setObjectType(HiveObjectType.PARTITION); - obj.setDbName(database); - obj.setObjectName(table); - obj.setPartValues(p.getValues()); - org.apache.hadoop.hive.metastore.api.PrincipalPrivilegeSet set; - try { - set = get_privilege_set(obj, user, groups); - } catch (MetaException e) { - logger.info(String.format("No privileges found for user: %s, " - + "groups: [%s]", user, LoggingHelper.concatCollectionToStringForLogging(groups, ","))); - set = new org.apache.hadoop.hive.metastore.api.PrincipalPrivilegeSet(); - } - p.setPrivileges(set); - } - - return partitions; - } - - @Override - public List listPartitionsWithAuthInfo( - String catalogName, - String database, - String table, - List partVals, - int maxParts, - String user, - List groups) throws MetaException, TException, NoSuchObjectException { - return listPartitionsWithAuthInfo(database, table, partVals, (short) maxParts, user, groups); - } - - @Override - public List listTableNamesByFilter(String dbName, String filter, short maxTables) throws MetaException, - TException, InvalidOperationException, UnknownDBException { - return glueMetastoreClientDelegate.listTableNamesByFilter(dbName, filter, maxTables); - } - - @Override - public List listTableNamesByFilter(String catalogName, String dbName, String filter, int maxTables) throws TException, InvalidOperationException, UnknownDBException { - return glueMetastoreClientDelegate.listTableNamesByFilter(dbName, filter, (short) maxTables); - } - - @Override - public List list_privileges( - String principal, - org.apache.hadoop.hive.metastore.api.PrincipalType principalType, - HiveObjectRef objectRef - ) throws MetaException, TException { - return glueMetastoreClientDelegate.listPrivileges(principal, principalType, objectRef); - } - - @Override - public LockResponse lock(LockRequest lockRequest) throws NoSuchTxnException, TxnAbortedException, TException { - return glueMetastoreClientDelegate.lock(lockRequest); - } - - @Override - public void markPartitionForEvent( - String dbName, - String tblName, - Map partKVs, - PartitionEventType eventType - ) throws MetaException, NoSuchObjectException, TException, UnknownTableException, UnknownDBException, - UnknownPartitionException, InvalidPartitionException { - glueMetastoreClientDelegate.markPartitionForEvent(dbName, tblName, partKVs, eventType); - } - - @Override - public void markPartitionForEvent( - String catalogName, - String dbName, - String tblName, - Map partKVs, - PartitionEventType eventType - ) throws MetaException, NoSuchObjectException, TException, UnknownTableException, UnknownDBException, UnknownPartitionException, InvalidPartitionException { - glueMetastoreClientDelegate.markPartitionForEvent(dbName, tblName, partKVs, eventType); - } - - @Override - public long openTxn(String user) throws TException { - return glueMetastoreClientDelegate.openTxn(user); - } - - @Override - public List replOpenTxn(String replPolicy, List srcTxnIds, String user) throws TException { - throw new UnsupportedOperationException("replOpenTxn is not supported"); - } - - @Override - public OpenTxnsResponse openTxns(String user, int numTxns) throws TException { - return glueMetastoreClientDelegate.openTxns(user, numTxns); - } - - @Override - public Map partitionNameToSpec(String name) throws MetaException, TException { - // Lifted from HiveMetaStore - if (name.length() == 0) { - return new HashMap(); - } - return Warehouse.makeSpecFromName(name); - } - - @Override - public List partitionNameToVals(String name) throws MetaException, TException { - return glueMetastoreClientDelegate.partitionNameToVals(name); - } - - @Override - public void reconnect() throws MetaException { - // TODO reset active Hive confs for metastore glueClient - logger.debug("reconnect() was called."); - } - - @Override - public void renamePartition(String dbName, String tblName, List partitionValues, - org.apache.hadoop.hive.metastore.api.Partition newPartition) - throws InvalidOperationException, MetaException, TException { - throw new TException("Not implement yet"); - // Commend out to avoid using shim - //// Set DDL time to now if not specified - //setDDLTime(newPartition); - //Table tbl; - //org.apache.hadoop.hive.metastore.api.Partition oldPart; - // - //try { - // tbl = getTable(dbName, tblName); - // oldPart = getPartition(dbName, tblName, partitionValues); - //} catch(NoSuchObjectException e) { - // throw new InvalidOperationException(e.getMessage()); - //} - // - //if(newPartition.getSd() == null || oldPart.getSd() == null ) { - // throw new InvalidOperationException("Storage descriptor cannot be null"); - //} - // - //// if an external partition is renamed, the location should not change - //if (!Strings.isNullOrEmpty(tbl.getTableType()) && tbl.getTableType().equals(TableType.EXTERNAL_TABLE.toString())) { - // newPartition.getSd().setLocation(oldPart.getSd().getLocation()); - // renamePartitionInCatalog(dbName, tblName, partitionValues, newPartition); - //} else { - // - // Path destPath = getDestinationPathForRename(dbName, tbl, newPartition); - // Path srcPath = new Path(oldPart.getSd().getLocation()); - // FileSystem srcFs = wh.getFs(srcPath); - // FileSystem destFs = wh.getFs(destPath); - // - // verifyDestinationLocation(srcFs, destFs, srcPath, destPath, tbl, newPartition); - // newPartition.getSd().setLocation(destPath.toString()); - // - // renamePartitionInCatalog(dbName, tblName, partitionValues, newPartition); - // boolean success = true; - // try{ - // if (srcFs.exists(srcPath)) { - // //if destPath's parent path doesn't exist, we should mkdir it - // Path destParentPath = destPath.getParent(); - // if (!hiveShims.mkdirs(wh, destParentPath)) { - // throw new IOException("Unable to create path " + destParentPath); - // } - // wh.renameDir(srcPath, destPath, true); - // } - // } catch (IOException e) { - // success = false; - // throw new InvalidOperationException("Unable to access old location " - // + srcPath + " for partition " + tbl.getDbName() + "." - // + tbl.getTableName() + " " + partitionValues); - // } finally { - // if(!success) { - // // revert metastore operation - // renamePartitionInCatalog(dbName, tblName, newPartition.getValues(), oldPart); - // } - // } - //} - } - - @Override - public void renamePartition( - String catalogName, - String dbName, - String tblName, - List partitionValues, - org.apache.hadoop.hive.metastore.api.Partition newPartition - ) throws InvalidOperationException, MetaException, TException { - renamePartition(dbName, tblName, partitionValues, newPartition); - } - - private void verifyDestinationLocation(FileSystem srcFs, FileSystem destFs, Path srcPath, Path destPath, Table tbl, org.apache.hadoop.hive.metastore.api.Partition newPartition) - throws InvalidOperationException { - String oldPartLoc = srcPath.toString(); - String newPartLoc = destPath.toString(); - - // check that src and dest are on the same file system - if (!FileUtils.equalsFileSystem(srcFs, destFs)) { - throw new InvalidOperationException("table new location " + destPath - + " is on a different file system than the old location " - + srcPath + ". This operation is not supported"); - } - try { - srcFs.exists(srcPath); // check that src exists and also checks - if (newPartLoc.compareTo(oldPartLoc) != 0 && destFs.exists(destPath)) { - throw new InvalidOperationException("New location for this partition " - + tbl.getDbName() + "." + tbl.getTableName() + "." + newPartition.getValues() - + " already exists : " + destPath); - } - } catch (IOException e) { - throw new InvalidOperationException("Unable to access new location " - + destPath + " for partition " + tbl.getDbName() + "." - + tbl.getTableName() + " " + newPartition.getValues()); - } - } - - private Path getDestinationPathForRename(String dbName, Table tbl, org.apache.hadoop.hive.metastore.api.Partition newPartition) - throws InvalidOperationException, MetaException, TException { - throw new TException("Not implement yet"); - // Commend out to avoid using shim - // try { - // Path destPath = new Path(hiveShims.getDefaultTablePath(getDatabase(dbName), tbl.getTableName(), wh), - // Warehouse.makePartName(tbl.getPartitionKeys(), newPartition.getValues())); - // return constructRenamedPath(destPath, new Path(newPartition.getSd().getLocation())); - // } catch (NoSuchObjectException e) { - // throw new InvalidOperationException( - // "Unable to change partition or table. Database " + dbName + " does not exist" - // + " Check metastore logs for detailed stack." + e.getMessage()); - // } - } - - private void setDDLTime(org.apache.hadoop.hive.metastore.api.Partition partition) { - if (partition.getParameters() == null || - partition.getParameters().get(hive_metastoreConstants.DDL_TIME) == null || - Integer.parseInt(partition.getParameters().get(hive_metastoreConstants.DDL_TIME)) == 0) { - partition.putToParameters(hive_metastoreConstants.DDL_TIME, Long.toString(System - .currentTimeMillis() / 1000)); - } - } - - private void renamePartitionInCatalog(String databaseName, String tableName, - List partitionValues, org.apache.hadoop.hive.metastore.api.Partition newPartition) - throws InvalidOperationException, MetaException, TException { - try { - glueClient.updatePartition( - new UpdatePartitionRequest() - .withDatabaseName(databaseName) - .withTableName(tableName) - .withPartitionValueList(partitionValues) - .withPartitionInput(GlueInputConverter.convertToPartitionInput(newPartition))); - } catch (AmazonServiceException e) { - throw catalogToHiveConverter.wrapInHiveException(e); - } - } - - @Override - public long renewDelegationToken(String tokenStrForm) throws MetaException, TException { - return glueMetastoreClientDelegate.renewDelegationToken(tokenStrForm); - } - - @Override - public void rollbackTxn(long txnId) throws NoSuchTxnException, TException { - glueMetastoreClientDelegate.rollbackTxn(txnId); - } - - @Override - public void replRollbackTxn(long l, String s) throws NoSuchTxnException, TException { - throw new UnsupportedOperationException("replRollbackTxn is not supported"); - } - - @Override - public void setMetaConf(String key, String value) throws MetaException, TException { - MetastoreConf.ConfVars confVar = MetastoreConf.getMetaConf(key); - if (confVar == null) { - throw new MetaException("Invalid configuration key " + key); - } - try { - confVar.validate(value); - } catch (IllegalArgumentException e) { - throw new MetaException("Invalid configuration value " + value + " for key " + key + - " by " + e.getMessage()); - } - conf.set(key, value); - } - - @Override - public boolean setPartitionColumnStatistics(org.apache.hadoop.hive.metastore.api.SetPartitionsStatsRequest request) - throws NoSuchObjectException, InvalidObjectException, - MetaException, TException, org.apache.hadoop.hive.metastore.api.InvalidInputException { - return glueMetastoreClientDelegate.setPartitionColumnStatistics(request); - } - - @Override - public void flushCache() { - //no op - } - - @Override - public Iterable> getFileMetadata(List fileIds) throws TException { - return glueMetastoreClientDelegate.getFileMetadata(fileIds); - } - - @Override - public Iterable> getFileMetadataBySarg( - List fileIds, - ByteBuffer sarg, - boolean doGetFooters - ) throws TException { - return glueMetastoreClientDelegate.getFileMetadataBySarg(fileIds, sarg, doGetFooters); - } - - @Override - public void clearFileMetadata(List fileIds) throws TException { - glueMetastoreClientDelegate.clearFileMetadata(fileIds); - } - - @Override - public void putFileMetadata(List fileIds, List metadata) throws TException { - glueMetastoreClientDelegate.putFileMetadata(fileIds, metadata); - } - - @Override - public boolean isSameConfObj(Configuration conf) { - //taken from HiveMetaStoreClient - return this.conf == conf; - } - - @Override - public boolean cacheFileMetadata(String dbName, String tblName, String partName, boolean allParts) throws TException { - return glueMetastoreClientDelegate.cacheFileMetadata(dbName, tblName, partName, allParts); - } - - @Override - public List getPrimaryKeys(PrimaryKeysRequest primaryKeysRequest) throws TException { - // PrimaryKeys are currently unsupported - return Lists.newArrayList(); - } - - @Override - public List getForeignKeys(ForeignKeysRequest foreignKeysRequest) throws TException { - // PrimaryKeys are currently unsupported - // return empty list to not break DESCRIBE (FORMATTED | EXTENDED) - return Lists.newArrayList(); - } - - @Override - public List getUniqueConstraints(UniqueConstraintsRequest uniqueConstraintsRequest) throws MetaException, NoSuchObjectException, TException { - // Not supported, called by DESCRIBE (FORMATTED | EXTENDED) - return Lists.newArrayList(); - } - - @Override - public List getNotNullConstraints(NotNullConstraintsRequest notNullConstraintsRequest) throws MetaException, NoSuchObjectException, TException { - // Not supported, called by DESCRIBE (FORMATTED | EXTENDED) - return Lists.newArrayList(); - } - - @Override - public List getDefaultConstraints(DefaultConstraintsRequest defaultConstraintsRequest) throws MetaException, NoSuchObjectException, TException { - // Not supported, called by DESCRIBE (FORMATTED | EXTENDED) - return Lists.newArrayList(); - } - - @Override - public List getCheckConstraints(CheckConstraintsRequest checkConstraintsRequest) throws MetaException, NoSuchObjectException, TException { - // Not supported, called by DESCRIBE (FORMATTED | EXTENDED) - return Lists.newArrayList(); - } - - @Override - public void createTableWithConstraints( - Table table, - List primaryKeys, - List foreignKeys, - List uniqueConstraints, - List notNullConstraints, - List defaultConstraints, - List checkConstraints - ) throws AlreadyExistsException, InvalidObjectException, MetaException, NoSuchObjectException, TException { - glueMetastoreClientDelegate.createTableWithConstraints(table, primaryKeys, foreignKeys); - } - - @Override - public void dropConstraint( - String dbName, - String tblName, - String constraintName - ) throws MetaException, NoSuchObjectException, TException { - glueMetastoreClientDelegate.dropConstraint(dbName, tblName, constraintName); - } - - @Override - public void dropConstraint(String catalogName, String dbName, String tblName, String constraintName) - throws MetaException, NoSuchObjectException, TException { - glueMetastoreClientDelegate.dropConstraint(dbName, tblName, constraintName); - } - - @Override - public void addPrimaryKey(List primaryKeyCols) - throws MetaException, NoSuchObjectException, TException { - glueMetastoreClientDelegate.addPrimaryKey(primaryKeyCols); - } - - @Override - public void addForeignKey(List foreignKeyCols) - throws MetaException, NoSuchObjectException, TException { - glueMetastoreClientDelegate.addForeignKey(foreignKeyCols); - } - - @Override - public void addUniqueConstraint(List uniqueConstraintCols) throws MetaException, NoSuchObjectException, TException { - throw new UnsupportedOperationException("addUniqueConstraint is not supported"); - } - - @Override - public void addNotNullConstraint(List notNullConstraintCols) throws MetaException, NoSuchObjectException, TException { - throw new UnsupportedOperationException("addNotNullConstraint is not supported"); - } - - @Override - public void addDefaultConstraint(List defaultConstraints) throws MetaException, NoSuchObjectException, TException { - throw new UnsupportedOperationException("addDefaultConstraint is not supported"); - } - - @Override - public void addCheckConstraint(List checkConstraints) throws MetaException, NoSuchObjectException, TException { - throw new UnsupportedOperationException("addCheckConstraint is not supported"); - } - - @Override - public String getMetastoreDbUuid() throws MetaException, TException { - throw new UnsupportedOperationException("getMetastoreDbUuid is not supported"); - } - - @Override - public void createResourcePlan(WMResourcePlan resourcePlan, String copyFromName) throws InvalidObjectException, MetaException, TException { - throw new UnsupportedOperationException("createResourcePlan is not supported"); - } - - @Override - public WMFullResourcePlan getResourcePlan(String resourcePlanName) throws NoSuchObjectException, MetaException, TException { - throw new UnsupportedOperationException("getResourcePlan is not supported"); - } - - @Override - public List getAllResourcePlans() throws NoSuchObjectException, MetaException, TException { - throw new UnsupportedOperationException("getAllResourcePlans is not supported"); - } - - @Override - public void dropResourcePlan(String resourcePlanName) throws NoSuchObjectException, MetaException, TException { - throw new UnsupportedOperationException("dropResourcePlan is not supported"); - } - - @Override - public WMFullResourcePlan alterResourcePlan( - String resourcePlanName, - WMNullableResourcePlan wmNullableResourcePlan, - boolean canActivateDisabled, - boolean isForceDeactivate, - boolean isReplace - ) throws NoSuchObjectException, InvalidObjectException, MetaException, TException { - throw new UnsupportedOperationException("alterResourcePlan is not supported"); - } - - @Override - public WMFullResourcePlan getActiveResourcePlan() throws MetaException, TException { - throw new UnsupportedOperationException("getActiveResourcePlan is not supported"); - } - - @Override - public WMValidateResourcePlanResponse validateResourcePlan(String resourcePlanName) throws NoSuchObjectException, InvalidObjectException, MetaException, TException { - throw new UnsupportedOperationException("validateResourcePlan is not supported"); - } - - @Override - public void createWMTrigger(WMTrigger wmTrigger) throws InvalidObjectException, MetaException, TException { - throw new UnsupportedOperationException("createWMTrigger is not supported"); - } - - @Override - public void alterWMTrigger(WMTrigger wmTrigger) throws NoSuchObjectException, InvalidObjectException, MetaException, TException { - throw new UnsupportedOperationException("alterWMTrigger is not supported"); - } - - @Override - public void dropWMTrigger(String resourcePlanName, String triggerName) throws NoSuchObjectException, MetaException, TException { - throw new UnsupportedOperationException("dropWMTrigger is not supported"); - } - - @Override - public List getTriggersForResourcePlan(String resourcePlan) throws NoSuchObjectException, MetaException, TException { - throw new UnsupportedOperationException("getTriggersForResourcePlan is not supported"); - } - - @Override - public void createWMPool(WMPool wmPool) throws NoSuchObjectException, InvalidObjectException, MetaException, TException { - throw new UnsupportedOperationException("createWMPool is not supported"); - } - - @Override - public void alterWMPool(WMNullablePool wmNullablePool, String poolPath) throws NoSuchObjectException, InvalidObjectException, TException { - throw new UnsupportedOperationException("alterWMPool is not supported"); - } - - @Override - public void dropWMPool(String resourcePlanName, String poolPath) throws TException { - throw new UnsupportedOperationException("dropWMPool is not supported"); - } - - @Override - public void createOrUpdateWMMapping(WMMapping wmMapping, boolean isUpdate) throws TException { - throw new UnsupportedOperationException("createOrUpdateWMMapping is not supported"); - } - - @Override - public void dropWMMapping(WMMapping wmMapping) throws TException { - throw new UnsupportedOperationException("dropWMMapping is not supported"); - } - - @Override - public void createOrDropTriggerToPoolMapping(String resourcePlanName, String triggerName, String poolPath, boolean shouldDrop) - throws org.apache.hadoop.hive.metastore.api.AlreadyExistsException, NoSuchObjectException, InvalidObjectException, MetaException, TException { - throw new UnsupportedOperationException("createOrDropTriggerToPoolMapping is not supported"); - } - - @Override - public void createISchema(ISchema iSchema) throws TException { - throw new UnsupportedOperationException("createISchema is not supported"); - } - - @Override - public void alterISchema(String catName, String dbName, String schemaName, ISchema newSchema) throws TException { - throw new UnsupportedOperationException("alterISchema is not supported"); - } - - @Override - public ISchema getISchema(String catName, String dbName, String name) throws TException { - throw new UnsupportedOperationException("getISchema is not supported"); - } - - @Override - public void dropISchema(String catName, String dbName, String name) throws TException { - throw new UnsupportedOperationException("dropISchema is not supported"); - } - - @Override - public void addSchemaVersion(SchemaVersion schemaVersion) throws TException { - throw new UnsupportedOperationException("addSchemaVersion is not supported"); - } - - @Override - public SchemaVersion getSchemaVersion(String catName, String dbName, String schemaName, int version) throws TException { - throw new UnsupportedOperationException("getSchemaVersion is not supported"); - } - - @Override - public SchemaVersion getSchemaLatestVersion(String catName, String dbName, String schemaName) throws TException { - throw new UnsupportedOperationException("getSchemaLatestVersion is not supported"); - } - - @Override - public List getSchemaAllVersions(String catName, String dbName, String schemaName) throws TException { - throw new UnsupportedOperationException("getSchemaAllVersions is not supported"); - } - - @Override - public void dropSchemaVersion(String catName, String dbName, String schemaName, int version) throws TException { - throw new UnsupportedOperationException("dropSchemaVersion is not supported"); - } - - @Override - public FindSchemasByColsResp getSchemaByCols(FindSchemasByColsRqst findSchemasByColsRqst) throws TException { - throw new UnsupportedOperationException("getSchemaByCols is not supported"); - } - - @Override - public void mapSchemaVersionToSerde(String catName, String dbName, String schemaName, int version, String serdeName) throws TException { - throw new UnsupportedOperationException("mapSchemaVersionToSerde is not supported"); - } - - @Override - public void setSchemaVersionState(String catName, String dbName, String schemaName, int version, SchemaVersionState state) throws TException { - throw new UnsupportedOperationException("setSchemaVersionState is not supported"); - } - - @Override - public void addSerDe(SerDeInfo serDeInfo) throws TException { - throw new UnsupportedOperationException("addSerDe is not supported"); - } - - @Override - public SerDeInfo getSerDe(String serDeName) throws TException { - throw new UnsupportedOperationException("getSerDe is not supported"); - } - - @Override - public LockResponse lockMaterializationRebuild(String dbName, String tableName, long txnId) throws TException { - throw new UnsupportedOperationException("lockMaterializationRebuild is not supported"); - } - - @Override - public boolean heartbeatLockMaterializationRebuild(String dbName, String tableName, long txnId) throws TException { - throw new UnsupportedOperationException("heartbeatLockMaterializationRebuild is not supported"); - } - - @Override - public void addRuntimeStat(RuntimeStat runtimeStat) throws TException { - throw new UnsupportedOperationException("addRuntimeStat is not supported"); - } - - @Override - public List getRuntimeStats(int maxWeight, int maxCreateTime) throws TException { - throw new UnsupportedOperationException("getRuntimeStats is not supported"); - } - - @Override - public ShowCompactResponse showCompactions() throws TException { - return glueMetastoreClientDelegate.showCompactions(); - } - - @Override - public void addDynamicPartitions( - long txnId, - long writeId, - String dbName, - String tblName, - List partNames - ) throws TException { - glueMetastoreClientDelegate.addDynamicPartitions(txnId, dbName, tblName, partNames); - } - - @Override - public void addDynamicPartitions( - long txnId, - long writeId, - String dbName, - String tblName, - List partNames, - DataOperationType operationType - ) throws TException { - glueMetastoreClientDelegate.addDynamicPartitions(txnId, dbName, tblName, partNames, operationType); - } - - @Override - public void insertTable(Table table, boolean overwrite) throws MetaException { - glueMetastoreClientDelegate.insertTable(table, overwrite); - } - - @Override - public NotificationEventResponse getNextNotification( - long lastEventId, int maxEvents, NotificationFilter notificationFilter) throws TException { - // Unsupported, workaround for HS2's notification poll. - return new NotificationEventResponse(); - } - - @Override - public CurrentNotificationEventId getCurrentNotificationEventId() throws TException { - // Unsupported, workaround for HS2's notification poll. - return new CurrentNotificationEventId(0); - } - - @Override - public NotificationEventsCountResponse getNotificationEventsCount(NotificationEventsCountRequest notificationEventsCountRequest) throws TException { - throw new UnsupportedOperationException("getNotificationEventsCount is not supported"); - } - - @Override - public FireEventResponse fireListenerEvent(FireEventRequest fireEventRequest) throws TException { - return glueMetastoreClientDelegate.fireListenerEvent(fireEventRequest); - } - - @Override - public ShowLocksResponse showLocks() throws TException { - return glueMetastoreClientDelegate.showLocks(); - } - - @Override - public ShowLocksResponse showLocks(ShowLocksRequest showLocksRequest) throws TException { - return glueMetastoreClientDelegate.showLocks(showLocksRequest); - } - - @Override - public GetOpenTxnsInfoResponse showTxns() throws TException { - return glueMetastoreClientDelegate.showTxns(); - } - - @Deprecated - public boolean tableExists(String tableName) throws MetaException, TException, UnknownDBException { - //this method has been deprecated; - return tableExists(DEFAULT_DATABASE_NAME, tableName); - } - - @Override - public boolean tableExists(String databaseName, String tableName) throws MetaException, TException, - UnknownDBException { - return glueMetastoreClientDelegate.tableExists(databaseName, tableName); - } - - @Override - public boolean tableExists(String catalogName, String databaseName, String tableName) - throws MetaException, TException, UnknownDBException { - return glueMetastoreClientDelegate.tableExists(databaseName, tableName); - } - - @Override - public void unlock(long lockId) throws NoSuchLockException, TxnOpenException, TException { - glueMetastoreClientDelegate.unlock(lockId); - } - - @Override - public boolean updatePartitionColumnStatistics(org.apache.hadoop.hive.metastore.api.ColumnStatistics columnStatistics) - throws NoSuchObjectException, InvalidObjectException, MetaException, TException, - org.apache.hadoop.hive.metastore.api.InvalidInputException { - return glueMetastoreClientDelegate.updatePartitionColumnStatistics(columnStatistics); - } - - @Override - public boolean updateTableColumnStatistics(org.apache.hadoop.hive.metastore.api.ColumnStatistics columnStatistics) - throws NoSuchObjectException, InvalidObjectException, MetaException, TException, - org.apache.hadoop.hive.metastore.api.InvalidInputException { - return glueMetastoreClientDelegate.updateTableColumnStatistics(columnStatistics); - } - - @Override - public void validatePartitionNameCharacters(List part_vals) throws TException, MetaException { - try { - String partitionValidationRegex = - MetastoreConf.getVar(conf, MetastoreConf.ConfVars.PARTITION_NAME_WHITELIST_PATTERN); - Pattern partitionValidationPattern = Strings.isNullOrEmpty(partitionValidationRegex) ? null - : Pattern.compile(partitionValidationRegex); - MetaStoreUtils.validatePartitionNameCharacters(part_vals, partitionValidationPattern); - } catch (Exception e){ - if (e instanceof MetaException) { - throw (MetaException) e; - } else { - throw new MetaException(e.getMessage()); - } - } - } - - private Path constructRenamedPath(Path defaultNewPath, Path currentPath) { - URI currentUri = currentPath.toUri(); - - return new Path(currentUri.getScheme(), currentUri.getAuthority(), - defaultNewPath.toUri().getPath()); - } - -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSCredentialsProviderFactory.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSCredentialsProviderFactory.java deleted file mode 100644 index 41444078e4d100..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSCredentialsProviderFactory.java +++ /dev/null @@ -1,31 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.metastore; - -import org.apache.hadoop.conf.Configuration; - -import com.amazonaws.auth.AWSCredentialsProvider; - -public interface AWSCredentialsProviderFactory { - - AWSCredentialsProvider buildAWSCredentialsProvider(Configuration conf); -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueClientFactory.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueClientFactory.java deleted file mode 100644 index 72e75a891fcfbd..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueClientFactory.java +++ /dev/null @@ -1,157 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.metastore; - -import com.amazonaws.ClientConfiguration; -import com.amazonaws.auth.AWSCredentialsProvider; -import com.amazonaws.client.builder.AwsClientBuilder; -import com.amazonaws.regions.Region; -import com.amazonaws.regions.Regions; -import com.amazonaws.services.glue.AWSGlue; -import com.amazonaws.services.glue.AWSGlueClientBuilder; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Preconditions; -import com.google.common.base.Strings; -import org.apache.commons.lang3.StringUtils; -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.hive.metastore.api.MetaException; -import org.apache.hadoop.security.UserGroupInformation; -import org.apache.hadoop.util.ReflectionUtils; -import org.apache.log4j.Logger; - -import java.io.IOException; - -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_CATALOG_CREDENTIALS_PROVIDER_FACTORY_CLASS; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_GLUE_CATALOG_SEPARATOR; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_GLUE_CONNECTION_TIMEOUT; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_GLUE_ENDPOINT; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_GLUE_MAX_CONNECTIONS; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_GLUE_MAX_RETRY; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_GLUE_SOCKET_TIMEOUT; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_REGION; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.DEFAULT_CONNECTION_TIMEOUT; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.DEFAULT_MAX_CONNECTIONS; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.DEFAULT_MAX_RETRY; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.DEFAULT_SOCKET_TIMEOUT; - -public final class AWSGlueClientFactory implements GlueClientFactory { - - private static final Logger logger = Logger.getLogger(AWSGlueClientFactory.class); - - private final Configuration conf; - - public AWSGlueClientFactory(Configuration conf) { - Preconditions.checkNotNull(conf, "Configuration cannot be null"); - this.conf = conf; - } - - @Override - public AWSGlue newClient() throws MetaException { - try { - AWSGlueClientBuilder glueClientBuilder = AWSGlueClientBuilder.standard() - .withCredentials(getAWSCredentialsProvider(conf)); - - String regionStr = getProperty(AWS_REGION, conf); - String glueEndpoint = getProperty(AWS_GLUE_ENDPOINT, conf); - - // ClientBuilder only allows one of EndpointConfiguration or Region to be set - if (StringUtils.isNotBlank(glueEndpoint)) { - logger.info("Setting glue service endpoint to " + glueEndpoint); - glueClientBuilder.setEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(glueEndpoint, null)); - } else if (StringUtils.isNotBlank(regionStr)) { - logger.info("Setting region to : " + regionStr); - glueClientBuilder.setRegion(regionStr); - } else { - Region currentRegion = Regions.getCurrentRegion(); - if (currentRegion != null) { - logger.info("Using region from ec2 metadata : " + currentRegion.getName()); - glueClientBuilder.setRegion(currentRegion.getName()); - } else { - logger.info("No region info found, using SDK default region: us-east-1"); - } - } - - glueClientBuilder.setClientConfiguration(buildClientConfiguration(conf)); - return decorateGlueClient(glueClientBuilder.build()); - } catch (Exception e) { - String message = "Unable to build AWSGlueClient: " + e; - logger.error(message); - throw new MetaException(message); - } - } - - private AWSGlue decorateGlueClient(AWSGlue originalGlueClient) { - if (Strings.isNullOrEmpty(getProperty(AWS_GLUE_CATALOG_SEPARATOR, conf))) { - return originalGlueClient; - } - return new AWSGlueMultipleCatalogDecorator( - originalGlueClient, - getProperty(AWS_GLUE_CATALOG_SEPARATOR, conf)); - } - - @VisibleForTesting - AWSCredentialsProvider getAWSCredentialsProvider(Configuration conf) { - - Class providerFactoryClass = conf - .getClass(AWS_CATALOG_CREDENTIALS_PROVIDER_FACTORY_CLASS, - DefaultAWSCredentialsProviderFactory.class).asSubclass( - AWSCredentialsProviderFactory.class); - AWSCredentialsProviderFactory provider = ReflectionUtils.newInstance( - providerFactoryClass, conf); - return provider.buildAWSCredentialsProvider(conf); - } - - private String createUserAgent() { - try { - String ugi = UserGroupInformation.getCurrentUser().getUserName(); - return "ugi=" + ugi; - } catch (IOException e) { - /* - * IOException here means that the login failed according - * to UserGroupInformation.getCurrentUser(). In this case, - * we will throw a RuntimeException the same way as - * HiveMetaStoreClient.java - * If not catching IOException, the build will fail with - * unreported exception IOExcetion. - */ - logger.error("Unable to resolve current user name " + e.getMessage()); - throw new RuntimeException(e); - } - } - - private ClientConfiguration buildClientConfiguration(Configuration conf) { - // Pass UserAgent to client configuration, which enable CloudTrail to audit UGI info - // when using Glue Catalog as metastore - ClientConfiguration clientConfiguration = new ClientConfiguration() - .withUserAgent(createUserAgent()) - .withMaxErrorRetry(conf.getInt(AWS_GLUE_MAX_RETRY, DEFAULT_MAX_RETRY)) - .withMaxConnections(conf.getInt(AWS_GLUE_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS)) - .withConnectionTimeout(conf.getInt(AWS_GLUE_CONNECTION_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT)) - .withSocketTimeout(conf.getInt(AWS_GLUE_SOCKET_TIMEOUT, DEFAULT_SOCKET_TIMEOUT)); - return clientConfiguration; - } - - private static String getProperty(String propertyName, Configuration conf) { - return Strings.isNullOrEmpty(System.getProperty(propertyName)) ? - conf.get(propertyName) : System.getProperty(propertyName); - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueDecoratorBase.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueDecoratorBase.java deleted file mode 100644 index c2eeff4f1f46f9..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueDecoratorBase.java +++ /dev/null @@ -1,1545 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.metastore; - -import com.amazonaws.AmazonWebServiceRequest; -import com.amazonaws.ResponseMetadata; -import com.amazonaws.services.glue.AWSGlue; -import com.amazonaws.services.glue.model.BatchCreatePartitionRequest; -import com.amazonaws.services.glue.model.BatchCreatePartitionResult; -import com.amazonaws.services.glue.model.BatchDeleteConnectionRequest; -import com.amazonaws.services.glue.model.BatchDeleteConnectionResult; -import com.amazonaws.services.glue.model.BatchDeletePartitionRequest; -import com.amazonaws.services.glue.model.BatchDeletePartitionResult; -import com.amazonaws.services.glue.model.BatchDeleteTableRequest; -import com.amazonaws.services.glue.model.BatchDeleteTableResult; -import com.amazonaws.services.glue.model.BatchDeleteTableVersionRequest; -import com.amazonaws.services.glue.model.BatchDeleteTableVersionResult; -import com.amazonaws.services.glue.model.BatchGetBlueprintsRequest; -import com.amazonaws.services.glue.model.BatchGetBlueprintsResult; -import com.amazonaws.services.glue.model.BatchGetCrawlersRequest; -import com.amazonaws.services.glue.model.BatchGetCrawlersResult; -import com.amazonaws.services.glue.model.BatchGetCustomEntityTypesRequest; -import com.amazonaws.services.glue.model.BatchGetCustomEntityTypesResult; -import com.amazonaws.services.glue.model.BatchGetDataQualityResultRequest; -import com.amazonaws.services.glue.model.BatchGetDataQualityResultResult; -import com.amazonaws.services.glue.model.BatchGetDevEndpointsRequest; -import com.amazonaws.services.glue.model.BatchGetDevEndpointsResult; -import com.amazonaws.services.glue.model.BatchGetJobsRequest; -import com.amazonaws.services.glue.model.BatchGetJobsResult; -import com.amazonaws.services.glue.model.BatchGetPartitionRequest; -import com.amazonaws.services.glue.model.BatchGetPartitionResult; -import com.amazonaws.services.glue.model.BatchGetTableOptimizerRequest; -import com.amazonaws.services.glue.model.BatchGetTableOptimizerResult; -import com.amazonaws.services.glue.model.BatchGetTriggersRequest; -import com.amazonaws.services.glue.model.BatchGetTriggersResult; -import com.amazonaws.services.glue.model.BatchGetWorkflowsRequest; -import com.amazonaws.services.glue.model.BatchGetWorkflowsResult; -import com.amazonaws.services.glue.model.BatchStopJobRunRequest; -import com.amazonaws.services.glue.model.BatchStopJobRunResult; -import com.amazonaws.services.glue.model.BatchUpdatePartitionRequest; -import com.amazonaws.services.glue.model.BatchUpdatePartitionResult; -import com.amazonaws.services.glue.model.CancelDataQualityRuleRecommendationRunRequest; -import com.amazonaws.services.glue.model.CancelDataQualityRuleRecommendationRunResult; -import com.amazonaws.services.glue.model.CancelDataQualityRulesetEvaluationRunRequest; -import com.amazonaws.services.glue.model.CancelDataQualityRulesetEvaluationRunResult; -import com.amazonaws.services.glue.model.CancelMLTaskRunRequest; -import com.amazonaws.services.glue.model.CancelMLTaskRunResult; -import com.amazonaws.services.glue.model.CancelStatementRequest; -import com.amazonaws.services.glue.model.CancelStatementResult; -import com.amazonaws.services.glue.model.CheckSchemaVersionValidityRequest; -import com.amazonaws.services.glue.model.CheckSchemaVersionValidityResult; -import com.amazonaws.services.glue.model.CreateBlueprintRequest; -import com.amazonaws.services.glue.model.CreateBlueprintResult; -import com.amazonaws.services.glue.model.CreateClassifierRequest; -import com.amazonaws.services.glue.model.CreateClassifierResult; -import com.amazonaws.services.glue.model.CreateConnectionRequest; -import com.amazonaws.services.glue.model.CreateConnectionResult; -import com.amazonaws.services.glue.model.CreateCrawlerRequest; -import com.amazonaws.services.glue.model.CreateCrawlerResult; -import com.amazonaws.services.glue.model.CreateCustomEntityTypeRequest; -import com.amazonaws.services.glue.model.CreateCustomEntityTypeResult; -import com.amazonaws.services.glue.model.CreateDataQualityRulesetRequest; -import com.amazonaws.services.glue.model.CreateDataQualityRulesetResult; -import com.amazonaws.services.glue.model.CreateDatabaseRequest; -import com.amazonaws.services.glue.model.CreateDatabaseResult; -import com.amazonaws.services.glue.model.CreateDevEndpointRequest; -import com.amazonaws.services.glue.model.CreateDevEndpointResult; -import com.amazonaws.services.glue.model.CreateJobRequest; -import com.amazonaws.services.glue.model.CreateJobResult; -import com.amazonaws.services.glue.model.CreateMLTransformRequest; -import com.amazonaws.services.glue.model.CreateMLTransformResult; -import com.amazonaws.services.glue.model.CreatePartitionIndexRequest; -import com.amazonaws.services.glue.model.CreatePartitionIndexResult; -import com.amazonaws.services.glue.model.CreatePartitionRequest; -import com.amazonaws.services.glue.model.CreatePartitionResult; -import com.amazonaws.services.glue.model.CreateRegistryRequest; -import com.amazonaws.services.glue.model.CreateRegistryResult; -import com.amazonaws.services.glue.model.CreateSchemaRequest; -import com.amazonaws.services.glue.model.CreateSchemaResult; -import com.amazonaws.services.glue.model.CreateScriptRequest; -import com.amazonaws.services.glue.model.CreateScriptResult; -import com.amazonaws.services.glue.model.CreateSecurityConfigurationRequest; -import com.amazonaws.services.glue.model.CreateSecurityConfigurationResult; -import com.amazonaws.services.glue.model.CreateSessionRequest; -import com.amazonaws.services.glue.model.CreateSessionResult; -import com.amazonaws.services.glue.model.CreateTableOptimizerRequest; -import com.amazonaws.services.glue.model.CreateTableOptimizerResult; -import com.amazonaws.services.glue.model.CreateTableRequest; -import com.amazonaws.services.glue.model.CreateTableResult; -import com.amazonaws.services.glue.model.CreateTriggerRequest; -import com.amazonaws.services.glue.model.CreateTriggerResult; -import com.amazonaws.services.glue.model.CreateUserDefinedFunctionRequest; -import com.amazonaws.services.glue.model.CreateUserDefinedFunctionResult; -import com.amazonaws.services.glue.model.CreateWorkflowRequest; -import com.amazonaws.services.glue.model.CreateWorkflowResult; -import com.amazonaws.services.glue.model.DeleteBlueprintRequest; -import com.amazonaws.services.glue.model.DeleteBlueprintResult; -import com.amazonaws.services.glue.model.DeleteClassifierRequest; -import com.amazonaws.services.glue.model.DeleteClassifierResult; -import com.amazonaws.services.glue.model.DeleteColumnStatisticsForPartitionRequest; -import com.amazonaws.services.glue.model.DeleteColumnStatisticsForPartitionResult; -import com.amazonaws.services.glue.model.DeleteColumnStatisticsForTableRequest; -import com.amazonaws.services.glue.model.DeleteColumnStatisticsForTableResult; -import com.amazonaws.services.glue.model.DeleteConnectionRequest; -import com.amazonaws.services.glue.model.DeleteConnectionResult; -import com.amazonaws.services.glue.model.DeleteCrawlerRequest; -import com.amazonaws.services.glue.model.DeleteCrawlerResult; -import com.amazonaws.services.glue.model.DeleteCustomEntityTypeRequest; -import com.amazonaws.services.glue.model.DeleteCustomEntityTypeResult; -import com.amazonaws.services.glue.model.DeleteDataQualityRulesetRequest; -import com.amazonaws.services.glue.model.DeleteDataQualityRulesetResult; -import com.amazonaws.services.glue.model.DeleteDatabaseRequest; -import com.amazonaws.services.glue.model.DeleteDatabaseResult; -import com.amazonaws.services.glue.model.DeleteDevEndpointRequest; -import com.amazonaws.services.glue.model.DeleteDevEndpointResult; -import com.amazonaws.services.glue.model.DeleteJobRequest; -import com.amazonaws.services.glue.model.DeleteJobResult; -import com.amazonaws.services.glue.model.DeleteMLTransformRequest; -import com.amazonaws.services.glue.model.DeleteMLTransformResult; -import com.amazonaws.services.glue.model.DeletePartitionIndexRequest; -import com.amazonaws.services.glue.model.DeletePartitionIndexResult; -import com.amazonaws.services.glue.model.DeletePartitionRequest; -import com.amazonaws.services.glue.model.DeletePartitionResult; -import com.amazonaws.services.glue.model.DeleteRegistryRequest; -import com.amazonaws.services.glue.model.DeleteRegistryResult; -import com.amazonaws.services.glue.model.DeleteResourcePolicyRequest; -import com.amazonaws.services.glue.model.DeleteResourcePolicyResult; -import com.amazonaws.services.glue.model.DeleteSchemaRequest; -import com.amazonaws.services.glue.model.DeleteSchemaResult; -import com.amazonaws.services.glue.model.DeleteSchemaVersionsRequest; -import com.amazonaws.services.glue.model.DeleteSchemaVersionsResult; -import com.amazonaws.services.glue.model.DeleteSecurityConfigurationRequest; -import com.amazonaws.services.glue.model.DeleteSecurityConfigurationResult; -import com.amazonaws.services.glue.model.DeleteSessionRequest; -import com.amazonaws.services.glue.model.DeleteSessionResult; -import com.amazonaws.services.glue.model.DeleteTableOptimizerRequest; -import com.amazonaws.services.glue.model.DeleteTableOptimizerResult; -import com.amazonaws.services.glue.model.DeleteTableRequest; -import com.amazonaws.services.glue.model.DeleteTableResult; -import com.amazonaws.services.glue.model.DeleteTableVersionRequest; -import com.amazonaws.services.glue.model.DeleteTableVersionResult; -import com.amazonaws.services.glue.model.DeleteTriggerRequest; -import com.amazonaws.services.glue.model.DeleteTriggerResult; -import com.amazonaws.services.glue.model.DeleteUserDefinedFunctionRequest; -import com.amazonaws.services.glue.model.DeleteUserDefinedFunctionResult; -import com.amazonaws.services.glue.model.DeleteWorkflowRequest; -import com.amazonaws.services.glue.model.DeleteWorkflowResult; -import com.amazonaws.services.glue.model.GetBlueprintRequest; -import com.amazonaws.services.glue.model.GetBlueprintResult; -import com.amazonaws.services.glue.model.GetBlueprintRunRequest; -import com.amazonaws.services.glue.model.GetBlueprintRunResult; -import com.amazonaws.services.glue.model.GetBlueprintRunsRequest; -import com.amazonaws.services.glue.model.GetBlueprintRunsResult; -import com.amazonaws.services.glue.model.GetCatalogImportStatusRequest; -import com.amazonaws.services.glue.model.GetCatalogImportStatusResult; -import com.amazonaws.services.glue.model.GetClassifierRequest; -import com.amazonaws.services.glue.model.GetClassifierResult; -import com.amazonaws.services.glue.model.GetClassifiersRequest; -import com.amazonaws.services.glue.model.GetClassifiersResult; -import com.amazonaws.services.glue.model.GetColumnStatisticsTaskRunRequest; -import com.amazonaws.services.glue.model.GetColumnStatisticsTaskRunResult; -import com.amazonaws.services.glue.model.GetColumnStatisticsTaskRunsRequest; -import com.amazonaws.services.glue.model.GetColumnStatisticsTaskRunsResult; -import com.amazonaws.services.glue.model.GetConnectionRequest; -import com.amazonaws.services.glue.model.GetColumnStatisticsForPartitionResult; -import com.amazonaws.services.glue.model.GetColumnStatisticsForPartitionRequest; -import com.amazonaws.services.glue.model.GetColumnStatisticsForTableResult; -import com.amazonaws.services.glue.model.GetColumnStatisticsForTableRequest; -import com.amazonaws.services.glue.model.GetConnectionResult; -import com.amazonaws.services.glue.model.GetConnectionsRequest; -import com.amazonaws.services.glue.model.GetConnectionsResult; -import com.amazonaws.services.glue.model.GetCrawlerMetricsRequest; -import com.amazonaws.services.glue.model.GetCrawlerMetricsResult; -import com.amazonaws.services.glue.model.GetCrawlerRequest; -import com.amazonaws.services.glue.model.GetCrawlerResult; -import com.amazonaws.services.glue.model.GetCrawlersRequest; -import com.amazonaws.services.glue.model.GetCrawlersResult; -import com.amazonaws.services.glue.model.GetCustomEntityTypeRequest; -import com.amazonaws.services.glue.model.GetCustomEntityTypeResult; -import com.amazonaws.services.glue.model.GetDataCatalogEncryptionSettingsRequest; -import com.amazonaws.services.glue.model.GetDataCatalogEncryptionSettingsResult; -import com.amazonaws.services.glue.model.GetDataQualityResultRequest; -import com.amazonaws.services.glue.model.GetDataQualityResultResult; -import com.amazonaws.services.glue.model.GetDataQualityRuleRecommendationRunRequest; -import com.amazonaws.services.glue.model.GetDataQualityRuleRecommendationRunResult; -import com.amazonaws.services.glue.model.GetDataQualityRulesetEvaluationRunRequest; -import com.amazonaws.services.glue.model.GetDataQualityRulesetEvaluationRunResult; -import com.amazonaws.services.glue.model.GetDataQualityRulesetRequest; -import com.amazonaws.services.glue.model.GetDataQualityRulesetResult; -import com.amazonaws.services.glue.model.GetDatabaseRequest; -import com.amazonaws.services.glue.model.GetDatabaseResult; -import com.amazonaws.services.glue.model.GetDatabasesRequest; -import com.amazonaws.services.glue.model.GetDatabasesResult; -import com.amazonaws.services.glue.model.GetDataflowGraphRequest; -import com.amazonaws.services.glue.model.GetDataflowGraphResult; -import com.amazonaws.services.glue.model.GetDevEndpointRequest; -import com.amazonaws.services.glue.model.GetDevEndpointResult; -import com.amazonaws.services.glue.model.GetDevEndpointsRequest; -import com.amazonaws.services.glue.model.GetDevEndpointsResult; -import com.amazonaws.services.glue.model.GetJobBookmarkRequest; -import com.amazonaws.services.glue.model.GetJobBookmarkResult; -import com.amazonaws.services.glue.model.GetJobRequest; -import com.amazonaws.services.glue.model.GetJobResult; -import com.amazonaws.services.glue.model.GetJobRunRequest; -import com.amazonaws.services.glue.model.GetJobRunResult; -import com.amazonaws.services.glue.model.GetJobRunsRequest; -import com.amazonaws.services.glue.model.GetJobRunsResult; -import com.amazonaws.services.glue.model.GetJobsRequest; -import com.amazonaws.services.glue.model.GetJobsResult; -import com.amazonaws.services.glue.model.GetMLTaskRunRequest; -import com.amazonaws.services.glue.model.GetMLTaskRunResult; -import com.amazonaws.services.glue.model.GetMLTaskRunsRequest; -import com.amazonaws.services.glue.model.GetMLTaskRunsResult; -import com.amazonaws.services.glue.model.GetMLTransformRequest; -import com.amazonaws.services.glue.model.GetMLTransformResult; -import com.amazonaws.services.glue.model.GetMLTransformsRequest; -import com.amazonaws.services.glue.model.GetMLTransformsResult; -import com.amazonaws.services.glue.model.GetMappingRequest; -import com.amazonaws.services.glue.model.GetMappingResult; -import com.amazonaws.services.glue.model.GetPartitionIndexesRequest; -import com.amazonaws.services.glue.model.GetPartitionIndexesResult; -import com.amazonaws.services.glue.model.GetPartitionRequest; -import com.amazonaws.services.glue.model.GetPartitionResult; -import com.amazonaws.services.glue.model.GetPartitionsRequest; -import com.amazonaws.services.glue.model.GetPartitionsResult; -import com.amazonaws.services.glue.model.GetPlanRequest; -import com.amazonaws.services.glue.model.GetPlanResult; -import com.amazonaws.services.glue.model.GetRegistryRequest; -import com.amazonaws.services.glue.model.GetRegistryResult; -import com.amazonaws.services.glue.model.GetResourcePoliciesRequest; -import com.amazonaws.services.glue.model.GetResourcePoliciesResult; -import com.amazonaws.services.glue.model.GetResourcePolicyRequest; -import com.amazonaws.services.glue.model.GetResourcePolicyResult; -import com.amazonaws.services.glue.model.GetSchemaByDefinitionRequest; -import com.amazonaws.services.glue.model.GetSchemaByDefinitionResult; -import com.amazonaws.services.glue.model.GetSchemaRequest; -import com.amazonaws.services.glue.model.GetSchemaResult; -import com.amazonaws.services.glue.model.GetSchemaVersionRequest; -import com.amazonaws.services.glue.model.GetSchemaVersionResult; -import com.amazonaws.services.glue.model.GetSchemaVersionsDiffRequest; -import com.amazonaws.services.glue.model.GetSchemaVersionsDiffResult; -import com.amazonaws.services.glue.model.GetSecurityConfigurationRequest; -import com.amazonaws.services.glue.model.GetSecurityConfigurationResult; -import com.amazonaws.services.glue.model.GetSecurityConfigurationsRequest; -import com.amazonaws.services.glue.model.GetSecurityConfigurationsResult; -import com.amazonaws.services.glue.model.GetSessionRequest; -import com.amazonaws.services.glue.model.GetSessionResult; -import com.amazonaws.services.glue.model.GetStatementRequest; -import com.amazonaws.services.glue.model.GetStatementResult; -import com.amazonaws.services.glue.model.GetTableOptimizerRequest; -import com.amazonaws.services.glue.model.GetTableOptimizerResult; -import com.amazonaws.services.glue.model.GetTableRequest; -import com.amazonaws.services.glue.model.GetTableResult; -import com.amazonaws.services.glue.model.GetTableVersionRequest; -import com.amazonaws.services.glue.model.GetTableVersionResult; -import com.amazonaws.services.glue.model.GetTableVersionsRequest; -import com.amazonaws.services.glue.model.GetTableVersionsResult; -import com.amazonaws.services.glue.model.GetTablesRequest; -import com.amazonaws.services.glue.model.GetTablesResult; -import com.amazonaws.services.glue.model.GetTagsRequest; -import com.amazonaws.services.glue.model.GetTagsResult; -import com.amazonaws.services.glue.model.GetTriggerRequest; -import com.amazonaws.services.glue.model.GetTriggerResult; -import com.amazonaws.services.glue.model.GetTriggersRequest; -import com.amazonaws.services.glue.model.GetTriggersResult; -import com.amazonaws.services.glue.model.GetUnfilteredPartitionMetadataRequest; -import com.amazonaws.services.glue.model.GetUnfilteredPartitionMetadataResult; -import com.amazonaws.services.glue.model.GetUnfilteredPartitionsMetadataRequest; -import com.amazonaws.services.glue.model.GetUnfilteredPartitionsMetadataResult; -import com.amazonaws.services.glue.model.GetUnfilteredTableMetadataRequest; -import com.amazonaws.services.glue.model.GetUnfilteredTableMetadataResult; -import com.amazonaws.services.glue.model.GetUserDefinedFunctionRequest; -import com.amazonaws.services.glue.model.GetUserDefinedFunctionResult; -import com.amazonaws.services.glue.model.GetUserDefinedFunctionsRequest; -import com.amazonaws.services.glue.model.GetUserDefinedFunctionsResult; -import com.amazonaws.services.glue.model.GetWorkflowRequest; -import com.amazonaws.services.glue.model.GetWorkflowResult; -import com.amazonaws.services.glue.model.GetWorkflowRunPropertiesRequest; -import com.amazonaws.services.glue.model.GetWorkflowRunPropertiesResult; -import com.amazonaws.services.glue.model.GetWorkflowRunRequest; -import com.amazonaws.services.glue.model.GetWorkflowRunResult; -import com.amazonaws.services.glue.model.GetWorkflowRunsRequest; -import com.amazonaws.services.glue.model.GetWorkflowRunsResult; -import com.amazonaws.services.glue.model.ImportCatalogToGlueRequest; -import com.amazonaws.services.glue.model.ImportCatalogToGlueResult; -import com.amazonaws.services.glue.model.ListBlueprintsRequest; -import com.amazonaws.services.glue.model.ListBlueprintsResult; -import com.amazonaws.services.glue.model.ListColumnStatisticsTaskRunsRequest; -import com.amazonaws.services.glue.model.ListColumnStatisticsTaskRunsResult; -import com.amazonaws.services.glue.model.ListCrawlersRequest; -import com.amazonaws.services.glue.model.ListCrawlersResult; -import com.amazonaws.services.glue.model.ListCrawlsRequest; -import com.amazonaws.services.glue.model.ListCrawlsResult; -import com.amazonaws.services.glue.model.ListCustomEntityTypesRequest; -import com.amazonaws.services.glue.model.ListCustomEntityTypesResult; -import com.amazonaws.services.glue.model.ListDataQualityResultsRequest; -import com.amazonaws.services.glue.model.ListDataQualityResultsResult; -import com.amazonaws.services.glue.model.ListDataQualityRuleRecommendationRunsRequest; -import com.amazonaws.services.glue.model.ListDataQualityRuleRecommendationRunsResult; -import com.amazonaws.services.glue.model.ListDataQualityRulesetEvaluationRunsRequest; -import com.amazonaws.services.glue.model.ListDataQualityRulesetEvaluationRunsResult; -import com.amazonaws.services.glue.model.ListDataQualityRulesetsRequest; -import com.amazonaws.services.glue.model.ListDataQualityRulesetsResult; -import com.amazonaws.services.glue.model.ListDevEndpointsRequest; -import com.amazonaws.services.glue.model.ListDevEndpointsResult; -import com.amazonaws.services.glue.model.ListJobsRequest; -import com.amazonaws.services.glue.model.ListJobsResult; -import com.amazonaws.services.glue.model.ListMLTransformsRequest; -import com.amazonaws.services.glue.model.ListMLTransformsResult; -import com.amazonaws.services.glue.model.ListRegistriesRequest; -import com.amazonaws.services.glue.model.ListRegistriesResult; -import com.amazonaws.services.glue.model.ListSchemaVersionsRequest; -import com.amazonaws.services.glue.model.ListSchemaVersionsResult; -import com.amazonaws.services.glue.model.ListSchemasRequest; -import com.amazonaws.services.glue.model.ListSchemasResult; -import com.amazonaws.services.glue.model.ListSessionsRequest; -import com.amazonaws.services.glue.model.ListSessionsResult; -import com.amazonaws.services.glue.model.ListStatementsRequest; -import com.amazonaws.services.glue.model.ListStatementsResult; -import com.amazonaws.services.glue.model.ListTableOptimizerRunsRequest; -import com.amazonaws.services.glue.model.ListTableOptimizerRunsResult; -import com.amazonaws.services.glue.model.ListTriggersRequest; -import com.amazonaws.services.glue.model.ListTriggersResult; -import com.amazonaws.services.glue.model.ListWorkflowsRequest; -import com.amazonaws.services.glue.model.ListWorkflowsResult; -import com.amazonaws.services.glue.model.PutDataCatalogEncryptionSettingsRequest; -import com.amazonaws.services.glue.model.PutDataCatalogEncryptionSettingsResult; -import com.amazonaws.services.glue.model.PutResourcePolicyRequest; -import com.amazonaws.services.glue.model.PutResourcePolicyResult; -import com.amazonaws.services.glue.model.PutSchemaVersionMetadataRequest; -import com.amazonaws.services.glue.model.PutSchemaVersionMetadataResult; -import com.amazonaws.services.glue.model.PutWorkflowRunPropertiesRequest; -import com.amazonaws.services.glue.model.PutWorkflowRunPropertiesResult; -import com.amazonaws.services.glue.model.QuerySchemaVersionMetadataRequest; -import com.amazonaws.services.glue.model.QuerySchemaVersionMetadataResult; -import com.amazonaws.services.glue.model.RegisterSchemaVersionRequest; -import com.amazonaws.services.glue.model.RegisterSchemaVersionResult; -import com.amazonaws.services.glue.model.RemoveSchemaVersionMetadataRequest; -import com.amazonaws.services.glue.model.RemoveSchemaVersionMetadataResult; -import com.amazonaws.services.glue.model.ResetJobBookmarkRequest; -import com.amazonaws.services.glue.model.ResetJobBookmarkResult; -import com.amazonaws.services.glue.model.ResumeWorkflowRunRequest; -import com.amazonaws.services.glue.model.ResumeWorkflowRunResult; -import com.amazonaws.services.glue.model.RunStatementRequest; -import com.amazonaws.services.glue.model.RunStatementResult; -import com.amazonaws.services.glue.model.SearchTablesRequest; -import com.amazonaws.services.glue.model.SearchTablesResult; -import com.amazonaws.services.glue.model.StartBlueprintRunRequest; -import com.amazonaws.services.glue.model.StartBlueprintRunResult; -import com.amazonaws.services.glue.model.StartColumnStatisticsTaskRunRequest; -import com.amazonaws.services.glue.model.StartColumnStatisticsTaskRunResult; -import com.amazonaws.services.glue.model.StartCrawlerRequest; -import com.amazonaws.services.glue.model.StartCrawlerResult; -import com.amazonaws.services.glue.model.StartCrawlerScheduleRequest; -import com.amazonaws.services.glue.model.StartCrawlerScheduleResult; -import com.amazonaws.services.glue.model.StartDataQualityRuleRecommendationRunRequest; -import com.amazonaws.services.glue.model.StartDataQualityRuleRecommendationRunResult; -import com.amazonaws.services.glue.model.StartDataQualityRulesetEvaluationRunRequest; -import com.amazonaws.services.glue.model.StartDataQualityRulesetEvaluationRunResult; -import com.amazonaws.services.glue.model.StartExportLabelsTaskRunRequest; -import com.amazonaws.services.glue.model.StartExportLabelsTaskRunResult; -import com.amazonaws.services.glue.model.StartImportLabelsTaskRunRequest; -import com.amazonaws.services.glue.model.StartImportLabelsTaskRunResult; -import com.amazonaws.services.glue.model.StartJobRunRequest; -import com.amazonaws.services.glue.model.StartJobRunResult; -import com.amazonaws.services.glue.model.StartMLEvaluationTaskRunRequest; -import com.amazonaws.services.glue.model.StartMLEvaluationTaskRunResult; -import com.amazonaws.services.glue.model.StartMLLabelingSetGenerationTaskRunRequest; -import com.amazonaws.services.glue.model.StartMLLabelingSetGenerationTaskRunResult; -import com.amazonaws.services.glue.model.StartTriggerRequest; -import com.amazonaws.services.glue.model.StartTriggerResult; -import com.amazonaws.services.glue.model.StartWorkflowRunRequest; -import com.amazonaws.services.glue.model.StartWorkflowRunResult; -import com.amazonaws.services.glue.model.StopColumnStatisticsTaskRunRequest; -import com.amazonaws.services.glue.model.StopColumnStatisticsTaskRunResult; -import com.amazonaws.services.glue.model.StopCrawlerRequest; -import com.amazonaws.services.glue.model.StopCrawlerResult; -import com.amazonaws.services.glue.model.StopCrawlerScheduleRequest; -import com.amazonaws.services.glue.model.StopCrawlerScheduleResult; -import com.amazonaws.services.glue.model.StopSessionRequest; -import com.amazonaws.services.glue.model.StopSessionResult; -import com.amazonaws.services.glue.model.StopTriggerRequest; -import com.amazonaws.services.glue.model.StopTriggerResult; -import com.amazonaws.services.glue.model.StopWorkflowRunRequest; -import com.amazonaws.services.glue.model.StopWorkflowRunResult; -import com.amazonaws.services.glue.model.TagResourceRequest; -import com.amazonaws.services.glue.model.TagResourceResult; -import com.amazonaws.services.glue.model.UntagResourceRequest; -import com.amazonaws.services.glue.model.UntagResourceResult; -import com.amazonaws.services.glue.model.UpdateBlueprintRequest; -import com.amazonaws.services.glue.model.UpdateBlueprintResult; -import com.amazonaws.services.glue.model.UpdateClassifierRequest; -import com.amazonaws.services.glue.model.UpdateClassifierResult; -import com.amazonaws.services.glue.model.UpdateColumnStatisticsForPartitionRequest; -import com.amazonaws.services.glue.model.UpdateColumnStatisticsForPartitionResult; -import com.amazonaws.services.glue.model.UpdateColumnStatisticsForTableRequest; -import com.amazonaws.services.glue.model.UpdateColumnStatisticsForTableResult; -import com.amazonaws.services.glue.model.UpdateConnectionRequest; -import com.amazonaws.services.glue.model.UpdateConnectionResult; -import com.amazonaws.services.glue.model.UpdateCrawlerRequest; -import com.amazonaws.services.glue.model.UpdateCrawlerResult; -import com.amazonaws.services.glue.model.UpdateCrawlerScheduleRequest; -import com.amazonaws.services.glue.model.UpdateCrawlerScheduleResult; -import com.amazonaws.services.glue.model.UpdateDataQualityRulesetRequest; -import com.amazonaws.services.glue.model.UpdateDataQualityRulesetResult; -import com.amazonaws.services.glue.model.UpdateDatabaseRequest; -import com.amazonaws.services.glue.model.UpdateDatabaseResult; -import com.amazonaws.services.glue.model.UpdateDevEndpointRequest; -import com.amazonaws.services.glue.model.UpdateDevEndpointResult; -import com.amazonaws.services.glue.model.UpdateJobFromSourceControlRequest; -import com.amazonaws.services.glue.model.UpdateJobFromSourceControlResult; -import com.amazonaws.services.glue.model.UpdateJobRequest; -import com.amazonaws.services.glue.model.UpdateJobResult; -import com.amazonaws.services.glue.model.UpdateMLTransformRequest; -import com.amazonaws.services.glue.model.UpdateMLTransformResult; -import com.amazonaws.services.glue.model.UpdatePartitionRequest; -import com.amazonaws.services.glue.model.UpdatePartitionResult; -import com.amazonaws.services.glue.model.UpdateRegistryRequest; -import com.amazonaws.services.glue.model.UpdateRegistryResult; -import com.amazonaws.services.glue.model.UpdateSchemaRequest; -import com.amazonaws.services.glue.model.UpdateSchemaResult; -import com.amazonaws.services.glue.model.UpdateSourceControlFromJobRequest; -import com.amazonaws.services.glue.model.UpdateSourceControlFromJobResult; -import com.amazonaws.services.glue.model.UpdateTableOptimizerRequest; -import com.amazonaws.services.glue.model.UpdateTableOptimizerResult; -import com.amazonaws.services.glue.model.UpdateTableRequest; -import com.amazonaws.services.glue.model.UpdateTableResult; -import com.amazonaws.services.glue.model.UpdateTriggerRequest; -import com.amazonaws.services.glue.model.UpdateTriggerResult; -import com.amazonaws.services.glue.model.UpdateUserDefinedFunctionRequest; -import com.amazonaws.services.glue.model.UpdateUserDefinedFunctionResult; -import com.amazonaws.services.glue.model.UpdateWorkflowRequest; -import com.amazonaws.services.glue.model.UpdateWorkflowResult; - -/** - * Base decorator for AWSGlue interface. It doesn't decorate any functionality but just proxy all methods to - * decoratedAwsGlue. It should be used as a parent for specific decorators where only necessary methods are overwritten - * and decorated. - * All @Override methods are generated by IntelliJ IDEA. - */ -public class AWSGlueDecoratorBase implements AWSGlue { - - - - private AWSGlue decoratedAwsGlue; - - public AWSGlueDecoratorBase(AWSGlue awsGlueToBeDecorated) { - this.decoratedAwsGlue = awsGlueToBeDecorated; - } - - @Override - public BatchCreatePartitionResult batchCreatePartition(BatchCreatePartitionRequest batchCreatePartitionRequest) { - return decoratedAwsGlue.batchCreatePartition(batchCreatePartitionRequest); - } - - @Override - public BatchDeleteConnectionResult batchDeleteConnection(BatchDeleteConnectionRequest batchDeleteConnectionRequest) { - return decoratedAwsGlue.batchDeleteConnection(batchDeleteConnectionRequest); - } - - @Override - public BatchDeletePartitionResult batchDeletePartition(BatchDeletePartitionRequest batchDeletePartitionRequest) { - return decoratedAwsGlue.batchDeletePartition(batchDeletePartitionRequest); - } - - @Override - public BatchDeleteTableResult batchDeleteTable(BatchDeleteTableRequest batchDeleteTableRequest) { - return decoratedAwsGlue.batchDeleteTable(batchDeleteTableRequest); - } - - @Override - public BatchDeleteTableVersionResult batchDeleteTableVersion(BatchDeleteTableVersionRequest batchDeleteTableVersionRequest) { - return decoratedAwsGlue.batchDeleteTableVersion(batchDeleteTableVersionRequest); - } - - @Override - public BatchGetCrawlersResult batchGetCrawlers(BatchGetCrawlersRequest batchGetCrawlersRequest) { - return decoratedAwsGlue.batchGetCrawlers(batchGetCrawlersRequest); - } - - @Override - public BatchGetCustomEntityTypesResult batchGetCustomEntityTypes(BatchGetCustomEntityTypesRequest batchGetCustomEntityTypesRequest) { - return decoratedAwsGlue.batchGetCustomEntityTypes(batchGetCustomEntityTypesRequest); - } - - @Override - public BatchGetDevEndpointsResult batchGetDevEndpoints(BatchGetDevEndpointsRequest batchGetDevEndpointsRequest) { - return decoratedAwsGlue.batchGetDevEndpoints(batchGetDevEndpointsRequest); - } - - @Override - public BatchGetJobsResult batchGetJobs(BatchGetJobsRequest batchGetJobsRequest) { - return decoratedAwsGlue.batchGetJobs(batchGetJobsRequest); - } - - @Override - public BatchGetPartitionResult batchGetPartition(BatchGetPartitionRequest batchGetPartitionRequest) { - return decoratedAwsGlue.batchGetPartition(batchGetPartitionRequest); - } - - @Override - public BatchGetTableOptimizerResult batchGetTableOptimizer(BatchGetTableOptimizerRequest batchGetTableOptimizerRequest) { - return null; - } - - @Override - public BatchGetTriggersResult batchGetTriggers(BatchGetTriggersRequest batchGetTriggersRequest) { - return decoratedAwsGlue.batchGetTriggers(batchGetTriggersRequest); - } - - @Override - public BatchGetWorkflowsResult batchGetWorkflows(BatchGetWorkflowsRequest batchGetWorkflowsRequest) { - return decoratedAwsGlue.batchGetWorkflows(batchGetWorkflowsRequest); - } - - @Override - public BatchStopJobRunResult batchStopJobRun(BatchStopJobRunRequest batchStopJobRunRequest) { - return decoratedAwsGlue.batchStopJobRun(batchStopJobRunRequest); - } - - @Override - public BatchUpdatePartitionResult batchUpdatePartition(BatchUpdatePartitionRequest batchUpdatePartitionRequest) { - return decoratedAwsGlue.batchUpdatePartition(batchUpdatePartitionRequest); - } - - @Override - public CancelMLTaskRunResult cancelMLTaskRun(CancelMLTaskRunRequest cancelMLTaskRunRequest) { - return decoratedAwsGlue.cancelMLTaskRun(cancelMLTaskRunRequest); - } - - @Override - public CancelStatementResult cancelStatement(CancelStatementRequest cancelStatementRequest) { - return decoratedAwsGlue.cancelStatement(cancelStatementRequest); - } - - @Override - public CheckSchemaVersionValidityResult checkSchemaVersionValidity(CheckSchemaVersionValidityRequest checkSchemaVersionValidityRequest) { - return null; - } - - @Override - public CreateBlueprintResult createBlueprint(CreateBlueprintRequest createBlueprintRequest) { - return null; - } - - @Override - public CreateClassifierResult createClassifier(CreateClassifierRequest createClassifierRequest) { - return decoratedAwsGlue.createClassifier(createClassifierRequest); - } - - @Override - public CreateConnectionResult createConnection(CreateConnectionRequest createConnectionRequest) { - return decoratedAwsGlue.createConnection(createConnectionRequest); - } - - @Override - public CreateCrawlerResult createCrawler(CreateCrawlerRequest createCrawlerRequest) { - return decoratedAwsGlue.createCrawler(createCrawlerRequest); - } - - @Override - public CreateCustomEntityTypeResult createCustomEntityType(CreateCustomEntityTypeRequest createCustomEntityTypeRequest) { - return decoratedAwsGlue.createCustomEntityType(createCustomEntityTypeRequest); - } - - @Override - public CreateDatabaseResult createDatabase(CreateDatabaseRequest createDatabaseRequest) { - return decoratedAwsGlue.createDatabase(createDatabaseRequest); - } - - @Override - public CreateDevEndpointResult createDevEndpoint(CreateDevEndpointRequest createDevEndpointRequest) { - return decoratedAwsGlue.createDevEndpoint(createDevEndpointRequest); - } - - @Override - public CreateJobResult createJob(CreateJobRequest createJobRequest) { - return decoratedAwsGlue.createJob(createJobRequest); - } - - @Override - public CreateMLTransformResult createMLTransform(CreateMLTransformRequest createMLTransformRequest) { - return decoratedAwsGlue.createMLTransform(createMLTransformRequest); - } - - @Override - public CreatePartitionResult createPartition(CreatePartitionRequest createPartitionRequest) { - return decoratedAwsGlue.createPartition(createPartitionRequest); - } - - @Override - public CreatePartitionIndexResult createPartitionIndex(CreatePartitionIndexRequest createPartitionIndexRequest) { - return null; - } - - @Override - public CreateRegistryResult createRegistry(CreateRegistryRequest createRegistryRequest) { - return null; - } - - @Override - public CreateSchemaResult createSchema(CreateSchemaRequest createSchemaRequest) { - return null; - } - - @Override - public CreateScriptResult createScript(CreateScriptRequest createScriptRequest) { - return decoratedAwsGlue.createScript(createScriptRequest); - } - - @Override - public CreateSecurityConfigurationResult createSecurityConfiguration(CreateSecurityConfigurationRequest createSecurityConfigurationRequest) { - return decoratedAwsGlue.createSecurityConfiguration(createSecurityConfigurationRequest); - } - - @Override - public CreateSessionResult createSession(CreateSessionRequest createSessionRequest) { - return decoratedAwsGlue.createSession(createSessionRequest); - } - - @Override - public CreateTableResult createTable(CreateTableRequest createTableRequest) { - return decoratedAwsGlue.createTable(createTableRequest); - } - - @Override - public CreateTableOptimizerResult createTableOptimizer(CreateTableOptimizerRequest createTableOptimizerRequest) { - return null; - } - - @Override - public CreateTriggerResult createTrigger(CreateTriggerRequest createTriggerRequest) { - return decoratedAwsGlue.createTrigger(createTriggerRequest); - } - - @Override - public CreateUserDefinedFunctionResult createUserDefinedFunction(CreateUserDefinedFunctionRequest createUserDefinedFunctionRequest) { - return decoratedAwsGlue.createUserDefinedFunction(createUserDefinedFunctionRequest); - } - - @Override - public CreateWorkflowResult createWorkflow(CreateWorkflowRequest createWorkflowRequest) { - return decoratedAwsGlue.createWorkflow(createWorkflowRequest); - } - - @Override - public DeleteBlueprintResult deleteBlueprint(DeleteBlueprintRequest deleteBlueprintRequest) { - return decoratedAwsGlue.deleteBlueprint(deleteBlueprintRequest); - } - - @Override - public DeleteClassifierResult deleteClassifier(DeleteClassifierRequest deleteClassifierRequest) { - return decoratedAwsGlue.deleteClassifier(deleteClassifierRequest); - } - - @Override - public DeleteConnectionResult deleteConnection(DeleteConnectionRequest deleteConnectionRequest) { - return decoratedAwsGlue.deleteConnection(deleteConnectionRequest); - } - - @Override - public DeleteCrawlerResult deleteCrawler(DeleteCrawlerRequest deleteCrawlerRequest) { - return decoratedAwsGlue.deleteCrawler(deleteCrawlerRequest); - } - - @Override - public DeleteCustomEntityTypeResult deleteCustomEntityType(DeleteCustomEntityTypeRequest deleteCustomEntityTypeRequest) { - return decoratedAwsGlue.deleteCustomEntityType(deleteCustomEntityTypeRequest); - } - - @Override - public DeleteDatabaseResult deleteDatabase(DeleteDatabaseRequest deleteDatabaseRequest) { - return decoratedAwsGlue.deleteDatabase(deleteDatabaseRequest); - } - - @Override - public DeleteDevEndpointResult deleteDevEndpoint(DeleteDevEndpointRequest deleteDevEndpointRequest) { - return decoratedAwsGlue.deleteDevEndpoint(deleteDevEndpointRequest); - } - - @Override - public DeleteJobResult deleteJob(DeleteJobRequest deleteJobRequest) { - return decoratedAwsGlue.deleteJob(deleteJobRequest); - } - - @Override - public DeleteMLTransformResult deleteMLTransform(DeleteMLTransformRequest deleteMLTransformRequest) { - return decoratedAwsGlue.deleteMLTransform(deleteMLTransformRequest); - } - - @Override - public DeletePartitionResult deletePartition(DeletePartitionRequest deletePartitionRequest) { - return decoratedAwsGlue.deletePartition(deletePartitionRequest); - } - - @Override - public DeletePartitionIndexResult deletePartitionIndex(DeletePartitionIndexRequest deletePartitionIndexRequest) { - return null; - } - - @Override - public DeleteRegistryResult deleteRegistry(DeleteRegistryRequest deleteRegistryRequest) { - return null; - } - - @Override - public DeleteResourcePolicyResult deleteResourcePolicy(DeleteResourcePolicyRequest deleteResourcePolicyRequest) { - return decoratedAwsGlue.deleteResourcePolicy(deleteResourcePolicyRequest); - } - - @Override - public DeleteSchemaResult deleteSchema(DeleteSchemaRequest deleteSchemaRequest) { - return null; - } - - @Override - public DeleteSchemaVersionsResult deleteSchemaVersions(DeleteSchemaVersionsRequest deleteSchemaVersionsRequest) { - return null; - } - - @Override - public DeleteSecurityConfigurationResult deleteSecurityConfiguration(DeleteSecurityConfigurationRequest deleteSecurityConfigurationRequest) { - return decoratedAwsGlue.deleteSecurityConfiguration(deleteSecurityConfigurationRequest); - } - - @Override - public DeleteSessionResult deleteSession(DeleteSessionRequest deleteSessionRequest) { - return decoratedAwsGlue.deleteSession(deleteSessionRequest); - } - - @Override - public DeleteTableResult deleteTable(DeleteTableRequest deleteTableRequest) { - return decoratedAwsGlue.deleteTable(deleteTableRequest); - } - - @Override - public DeleteTableOptimizerResult deleteTableOptimizer(DeleteTableOptimizerRequest deleteTableOptimizerRequest) { - return null; - } - - @Override - public DeleteTableVersionResult deleteTableVersion(DeleteTableVersionRequest deleteTableVersionRequest) { - return decoratedAwsGlue.deleteTableVersion(deleteTableVersionRequest); - } - - @Override - public DeleteTriggerResult deleteTrigger(DeleteTriggerRequest deleteTriggerRequest) { - return decoratedAwsGlue.deleteTrigger(deleteTriggerRequest); - } - - @Override - public DeleteUserDefinedFunctionResult deleteUserDefinedFunction(DeleteUserDefinedFunctionRequest deleteUserDefinedFunctionRequest) { - return decoratedAwsGlue.deleteUserDefinedFunction(deleteUserDefinedFunctionRequest); - } - - @Override - public DeleteWorkflowResult deleteWorkflow(DeleteWorkflowRequest deleteWorkflowRequest) { - return decoratedAwsGlue.deleteWorkflow(deleteWorkflowRequest); - } - - @Override - public GetBlueprintResult getBlueprint(GetBlueprintRequest getBlueprintRequest) { - return decoratedAwsGlue.getBlueprint(getBlueprintRequest); - } - - @Override - public GetBlueprintRunResult getBlueprintRun(GetBlueprintRunRequest getBlueprintRunRequest) { - return decoratedAwsGlue.getBlueprintRun(getBlueprintRunRequest); - } - - @Override - public GetBlueprintRunsResult getBlueprintRuns(GetBlueprintRunsRequest getBlueprintRunsRequest) { - return decoratedAwsGlue.getBlueprintRuns(getBlueprintRunsRequest); - } - - @Override - public GetCatalogImportStatusResult getCatalogImportStatus(GetCatalogImportStatusRequest getCatalogImportStatusRequest) { - return decoratedAwsGlue.getCatalogImportStatus(getCatalogImportStatusRequest); - } - - @Override - public GetClassifierResult getClassifier(GetClassifierRequest getClassifierRequest) { - return decoratedAwsGlue.getClassifier(getClassifierRequest); - } - - @Override - public GetClassifiersResult getClassifiers(GetClassifiersRequest getClassifiersRequest) { - return decoratedAwsGlue.getClassifiers(getClassifiersRequest); - } - - @Override - public GetConnectionResult getConnection(GetConnectionRequest getConnectionRequest) { - return decoratedAwsGlue.getConnection(getConnectionRequest); - } - - @Override - public GetConnectionsResult getConnections(GetConnectionsRequest getConnectionsRequest) { - return decoratedAwsGlue.getConnections(getConnectionsRequest); - } - - @Override - public GetCrawlerResult getCrawler(GetCrawlerRequest getCrawlerRequest) { - return decoratedAwsGlue.getCrawler(getCrawlerRequest); - } - - @Override - public GetCrawlerMetricsResult getCrawlerMetrics(GetCrawlerMetricsRequest getCrawlerMetricsRequest) { - return decoratedAwsGlue.getCrawlerMetrics(getCrawlerMetricsRequest); - } - - @Override - public GetCrawlersResult getCrawlers(GetCrawlersRequest getCrawlersRequest) { - return decoratedAwsGlue.getCrawlers(getCrawlersRequest); - } - - @Override - public GetCustomEntityTypeResult getCustomEntityType(GetCustomEntityTypeRequest getCustomEntityTypeRequest) { - return decoratedAwsGlue.getCustomEntityType(getCustomEntityTypeRequest); - } - - @Override - public GetDataCatalogEncryptionSettingsResult getDataCatalogEncryptionSettings(GetDataCatalogEncryptionSettingsRequest getDataCatalogEncryptionSettingsRequest) { - return decoratedAwsGlue.getDataCatalogEncryptionSettings(getDataCatalogEncryptionSettingsRequest); - } - - @Override - public GetDatabaseResult getDatabase(GetDatabaseRequest getDatabaseRequest) { - return decoratedAwsGlue.getDatabase(getDatabaseRequest); - } - - @Override - public GetDatabasesResult getDatabases(GetDatabasesRequest getDatabasesRequest) { - return decoratedAwsGlue.getDatabases(getDatabasesRequest); - } - - @Override - public GetDataflowGraphResult getDataflowGraph(GetDataflowGraphRequest getDataflowGraphRequest) { - return decoratedAwsGlue.getDataflowGraph(getDataflowGraphRequest); - } - - @Override - public GetDevEndpointResult getDevEndpoint(GetDevEndpointRequest getDevEndpointRequest) { - return decoratedAwsGlue.getDevEndpoint(getDevEndpointRequest); - } - - @Override - public GetDevEndpointsResult getDevEndpoints(GetDevEndpointsRequest getDevEndpointsRequest) { - return decoratedAwsGlue.getDevEndpoints(getDevEndpointsRequest); - } - - @Override - public GetJobResult getJob(GetJobRequest getJobRequest) { - return decoratedAwsGlue.getJob(getJobRequest); - } - - @Override - public GetJobBookmarkResult getJobBookmark(GetJobBookmarkRequest getJobBookmarkRequest) { - return decoratedAwsGlue.getJobBookmark(getJobBookmarkRequest); - } - - @Override - public GetJobRunResult getJobRun(GetJobRunRequest getJobRunRequest) { - return decoratedAwsGlue.getJobRun(getJobRunRequest); - } - - @Override - public GetJobRunsResult getJobRuns(GetJobRunsRequest getJobRunsRequest) { - return decoratedAwsGlue.getJobRuns(getJobRunsRequest); - } - - @Override - public GetJobsResult getJobs(GetJobsRequest getJobsRequest) { - return decoratedAwsGlue.getJobs(getJobsRequest); - } - - @Override - public GetMLTaskRunResult getMLTaskRun(GetMLTaskRunRequest getMLTaskRunRequest) { - return decoratedAwsGlue.getMLTaskRun(getMLTaskRunRequest); - } - - @Override - public GetMLTaskRunsResult getMLTaskRuns(GetMLTaskRunsRequest getMLTaskRunsRequest) { - return decoratedAwsGlue.getMLTaskRuns(getMLTaskRunsRequest); - } - - @Override - public GetMLTransformResult getMLTransform(GetMLTransformRequest getMLTransformRequest) { - return decoratedAwsGlue.getMLTransform(getMLTransformRequest); - } - - @Override - public GetMLTransformsResult getMLTransforms(GetMLTransformsRequest getMLTransformsRequest) { - return decoratedAwsGlue.getMLTransforms(getMLTransformsRequest); - } - - @Override - public GetMappingResult getMapping(GetMappingRequest getMappingRequest) { - return decoratedAwsGlue.getMapping(getMappingRequest); - } - - @Override - public GetPartitionIndexesResult getPartitionIndexes(GetPartitionIndexesRequest getPartitionIndexesRequest) { - return decoratedAwsGlue.getPartitionIndexes(getPartitionIndexesRequest); - } - - @Override - public GetPartitionResult getPartition(GetPartitionRequest getPartitionRequest) { - return decoratedAwsGlue.getPartition(getPartitionRequest); - } - - @Override - public GetPartitionsResult getPartitions(GetPartitionsRequest getPartitionsRequest) { - return decoratedAwsGlue.getPartitions(getPartitionsRequest); - } - - @Override - public GetPlanResult getPlan(GetPlanRequest getPlanRequest) { - return decoratedAwsGlue.getPlan(getPlanRequest); - } - - @Override - public GetRegistryResult getRegistry(GetRegistryRequest getRegistryRequest) { - return null; - } - - @Override - public GetResourcePolicyResult getResourcePolicy(GetResourcePolicyRequest getResourcePolicyRequest) { - return decoratedAwsGlue.getResourcePolicy(getResourcePolicyRequest); - } - - @Override - public GetSchemaResult getSchema(GetSchemaRequest getSchemaRequest) { - return null; - } - - @Override - public GetSchemaByDefinitionResult getSchemaByDefinition(GetSchemaByDefinitionRequest getSchemaByDefinitionRequest) { - return null; - } - - @Override - public GetSchemaVersionResult getSchemaVersion(GetSchemaVersionRequest getSchemaVersionRequest) { - return null; - } - - @Override - public GetSchemaVersionsDiffResult getSchemaVersionsDiff(GetSchemaVersionsDiffRequest getSchemaVersionsDiffRequest) { - return null; - } - - @Override - public GetSecurityConfigurationResult getSecurityConfiguration(GetSecurityConfigurationRequest getSecurityConfigurationRequest) { - return decoratedAwsGlue.getSecurityConfiguration(getSecurityConfigurationRequest); - } - - @Override - public GetSecurityConfigurationsResult getSecurityConfigurations(GetSecurityConfigurationsRequest getSecurityConfigurationsRequest) { - return decoratedAwsGlue.getSecurityConfigurations(getSecurityConfigurationsRequest); - } - - @Override - public GetSessionResult getSession(GetSessionRequest getSessionRequest) { - return decoratedAwsGlue.getSession(getSessionRequest); - } - - @Override - public GetStatementResult getStatement(GetStatementRequest getStatementRequest) { - return decoratedAwsGlue.getStatement(getStatementRequest); - } - - @Override - public GetTableResult getTable(GetTableRequest getTableRequest) { - return decoratedAwsGlue.getTable(getTableRequest); - } - - @Override - public GetTableOptimizerResult getTableOptimizer(GetTableOptimizerRequest getTableOptimizerRequest) { - return null; - } - - @Override - public GetTableVersionResult getTableVersion(GetTableVersionRequest getTableVersionRequest) { - return decoratedAwsGlue.getTableVersion(getTableVersionRequest); - } - - @Override - public GetTableVersionsResult getTableVersions(GetTableVersionsRequest getTableVersionsRequest) { - return decoratedAwsGlue.getTableVersions(getTableVersionsRequest); - } - - @Override - public GetTablesResult getTables(GetTablesRequest getTablesRequest) { - return decoratedAwsGlue.getTables(getTablesRequest); - } - - @Override - public GetTagsResult getTags(GetTagsRequest getTagsRequest) { - return decoratedAwsGlue.getTags(getTagsRequest); - } - - @Override - public GetTriggerResult getTrigger(GetTriggerRequest getTriggerRequest) { - return decoratedAwsGlue.getTrigger(getTriggerRequest); - } - - @Override - public GetTriggersResult getTriggers(GetTriggersRequest getTriggersRequest) { - return decoratedAwsGlue.getTriggers(getTriggersRequest); - } - - @Override - public GetUnfilteredPartitionMetadataResult getUnfilteredPartitionMetadata(GetUnfilteredPartitionMetadataRequest getUnfilteredPartitionMetadataRequest) { - return decoratedAwsGlue.getUnfilteredPartitionMetadata(getUnfilteredPartitionMetadataRequest); - } - - @Override - public GetUnfilteredPartitionsMetadataResult getUnfilteredPartitionsMetadata(GetUnfilteredPartitionsMetadataRequest getUnfilteredPartitionsMetadataRequest) { - return decoratedAwsGlue.getUnfilteredPartitionsMetadata(getUnfilteredPartitionsMetadataRequest); - } - - @Override - public GetUnfilteredTableMetadataResult getUnfilteredTableMetadata(GetUnfilteredTableMetadataRequest getUnfilteredTableMetadataRequest) { - return decoratedAwsGlue.getUnfilteredTableMetadata(getUnfilteredTableMetadataRequest); - } - - @Override - public GetUserDefinedFunctionResult getUserDefinedFunction(GetUserDefinedFunctionRequest getUserDefinedFunctionRequest) { - return decoratedAwsGlue.getUserDefinedFunction(getUserDefinedFunctionRequest); - } - - @Override - public GetUserDefinedFunctionsResult getUserDefinedFunctions(GetUserDefinedFunctionsRequest getUserDefinedFunctionsRequest) { - return decoratedAwsGlue.getUserDefinedFunctions(getUserDefinedFunctionsRequest); - } - - @Override - public GetWorkflowResult getWorkflow(GetWorkflowRequest getWorkflowRequest) { - return decoratedAwsGlue.getWorkflow(getWorkflowRequest); - } - - @Override - public GetWorkflowRunResult getWorkflowRun(GetWorkflowRunRequest getWorkflowRunRequest) { - return decoratedAwsGlue.getWorkflowRun(getWorkflowRunRequest); - } - - @Override - public GetWorkflowRunPropertiesResult getWorkflowRunProperties(GetWorkflowRunPropertiesRequest getWorkflowRunPropertiesRequest) { - return decoratedAwsGlue.getWorkflowRunProperties(getWorkflowRunPropertiesRequest); - } - - @Override - public GetWorkflowRunsResult getWorkflowRuns(GetWorkflowRunsRequest getWorkflowRunsRequest) { - return decoratedAwsGlue.getWorkflowRuns(getWorkflowRunsRequest); - } - - @Override - public ImportCatalogToGlueResult importCatalogToGlue(ImportCatalogToGlueRequest importCatalogToGlueRequest) { - return decoratedAwsGlue.importCatalogToGlue(importCatalogToGlueRequest); - } - - @Override - public ListBlueprintsResult listBlueprints(ListBlueprintsRequest listBlueprintsRequest) { - return decoratedAwsGlue.listBlueprints(listBlueprintsRequest); - } - - @Override - public ListColumnStatisticsTaskRunsResult listColumnStatisticsTaskRuns(ListColumnStatisticsTaskRunsRequest listColumnStatisticsTaskRunsRequest) { - return null; - } - - @Override - public ListCrawlersResult listCrawlers(ListCrawlersRequest listCrawlersRequest) { - return decoratedAwsGlue.listCrawlers(listCrawlersRequest); - } - - @Override - public ListCrawlsResult listCrawls(ListCrawlsRequest listCrawlsRequest) { - return decoratedAwsGlue.listCrawls(listCrawlsRequest); - } - - @Override - public ListCustomEntityTypesResult listCustomEntityTypes(ListCustomEntityTypesRequest listCustomEntityTypesRequest) { - return decoratedAwsGlue.listCustomEntityTypes(listCustomEntityTypesRequest); - } - - @Override - public ListDevEndpointsResult listDevEndpoints(ListDevEndpointsRequest listDevEndpointsRequest) { - return decoratedAwsGlue.listDevEndpoints(listDevEndpointsRequest); - } - - @Override - public ListJobsResult listJobs(ListJobsRequest listJobsRequest) { - return decoratedAwsGlue.listJobs(listJobsRequest); - } - - @Override - public ListMLTransformsResult listMLTransforms(ListMLTransformsRequest listMLTransformsRequest) { - return decoratedAwsGlue.listMLTransforms(listMLTransformsRequest); - } - - @Override - public ListRegistriesResult listRegistries(ListRegistriesRequest listRegistriesRequest) { - return null; - } - - @Override - public ListSchemaVersionsResult listSchemaVersions(ListSchemaVersionsRequest listSchemaVersionsRequest) { - return null; - } - - @Override - public ListSchemasResult listSchemas(ListSchemasRequest listSchemasRequest) { - return null; - } - - @Override - public ListSessionsResult listSessions(ListSessionsRequest listSessionsRequest) { - return decoratedAwsGlue.listSessions(listSessionsRequest); - } - - @Override - public ListStatementsResult listStatements(ListStatementsRequest listStatementsRequest) { - return decoratedAwsGlue.listStatements(listStatementsRequest); - } - - @Override - public ListTableOptimizerRunsResult listTableOptimizerRuns(ListTableOptimizerRunsRequest listTableOptimizerRunsRequest) { - return null; - } - - @Override - public ListTriggersResult listTriggers(ListTriggersRequest listTriggersRequest) { - return decoratedAwsGlue.listTriggers(listTriggersRequest); - } - - @Override - public ListWorkflowsResult listWorkflows(ListWorkflowsRequest listWorkflowsRequest) { - return decoratedAwsGlue.listWorkflows(listWorkflowsRequest); - } - - @Override - public PutDataCatalogEncryptionSettingsResult putDataCatalogEncryptionSettings(PutDataCatalogEncryptionSettingsRequest putDataCatalogEncryptionSettingsRequest) { - return decoratedAwsGlue.putDataCatalogEncryptionSettings(putDataCatalogEncryptionSettingsRequest); - } - - @Override - public PutResourcePolicyResult putResourcePolicy(PutResourcePolicyRequest putResourcePolicyRequest) { - return decoratedAwsGlue.putResourcePolicy(putResourcePolicyRequest); - } - - @Override - public PutSchemaVersionMetadataResult putSchemaVersionMetadata(PutSchemaVersionMetadataRequest putSchemaVersionMetadataRequest) { - return null; - } - - @Override - public PutWorkflowRunPropertiesResult putWorkflowRunProperties(PutWorkflowRunPropertiesRequest putWorkflowRunPropertiesRequest) { - return decoratedAwsGlue.putWorkflowRunProperties(putWorkflowRunPropertiesRequest); - } - - @Override - public QuerySchemaVersionMetadataResult querySchemaVersionMetadata(QuerySchemaVersionMetadataRequest querySchemaVersionMetadataRequest) { - return null; - } - - @Override - public RegisterSchemaVersionResult registerSchemaVersion(RegisterSchemaVersionRequest registerSchemaVersionRequest) { - return null; - } - - @Override - public RemoveSchemaVersionMetadataResult removeSchemaVersionMetadata(RemoveSchemaVersionMetadataRequest removeSchemaVersionMetadataRequest) { - return null; - } - - @Override - public ResetJobBookmarkResult resetJobBookmark(ResetJobBookmarkRequest resetJobBookmarkRequest) { - return decoratedAwsGlue.resetJobBookmark(resetJobBookmarkRequest); - } - - @Override - public SearchTablesResult searchTables(SearchTablesRequest searchTablesRequest) { - return decoratedAwsGlue.searchTables(searchTablesRequest); - } - - @Override - public StartBlueprintRunResult startBlueprintRun(StartBlueprintRunRequest startBlueprintRunRequest) { - return decoratedAwsGlue.startBlueprintRun(startBlueprintRunRequest); - } - - @Override - public StartColumnStatisticsTaskRunResult startColumnStatisticsTaskRun(StartColumnStatisticsTaskRunRequest startColumnStatisticsTaskRunRequest) { - return null; - } - - @Override - public StartCrawlerResult startCrawler(StartCrawlerRequest startCrawlerRequest) { - return decoratedAwsGlue.startCrawler(startCrawlerRequest); - } - - @Override - public StartCrawlerScheduleResult startCrawlerSchedule(StartCrawlerScheduleRequest startCrawlerScheduleRequest) { - return decoratedAwsGlue.startCrawlerSchedule(startCrawlerScheduleRequest); - } - - @Override - public StartExportLabelsTaskRunResult startExportLabelsTaskRun(StartExportLabelsTaskRunRequest startExportLabelsTaskRunRequest) { - return decoratedAwsGlue.startExportLabelsTaskRun(startExportLabelsTaskRunRequest); - } - - @Override - public StartImportLabelsTaskRunResult startImportLabelsTaskRun(StartImportLabelsTaskRunRequest startImportLabelsTaskRunRequest) { - return decoratedAwsGlue.startImportLabelsTaskRun(startImportLabelsTaskRunRequest); - } - - @Override - public StartJobRunResult startJobRun(StartJobRunRequest startJobRunRequest) { - return decoratedAwsGlue.startJobRun(startJobRunRequest); - } - - @Override - public StartMLEvaluationTaskRunResult startMLEvaluationTaskRun(StartMLEvaluationTaskRunRequest startMLEvaluationTaskRunRequest) { - return decoratedAwsGlue.startMLEvaluationTaskRun(startMLEvaluationTaskRunRequest); - } - - @Override - public StartMLLabelingSetGenerationTaskRunResult startMLLabelingSetGenerationTaskRun(StartMLLabelingSetGenerationTaskRunRequest startMLLabelingSetGenerationTaskRunRequest) { - return decoratedAwsGlue.startMLLabelingSetGenerationTaskRun(startMLLabelingSetGenerationTaskRunRequest); - } - - @Override - public StartTriggerResult startTrigger(StartTriggerRequest startTriggerRequest) { - return decoratedAwsGlue.startTrigger(startTriggerRequest); - } - - @Override - public StartWorkflowRunResult startWorkflowRun(StartWorkflowRunRequest startWorkflowRunRequest) { - return decoratedAwsGlue.startWorkflowRun(startWorkflowRunRequest); - } - - @Override - public StopColumnStatisticsTaskRunResult stopColumnStatisticsTaskRun(StopColumnStatisticsTaskRunRequest stopColumnStatisticsTaskRunRequest) { - return null; - } - - @Override - public StopCrawlerResult stopCrawler(StopCrawlerRequest stopCrawlerRequest) { - return decoratedAwsGlue.stopCrawler(stopCrawlerRequest); - } - - @Override - public StopCrawlerScheduleResult stopCrawlerSchedule(StopCrawlerScheduleRequest stopCrawlerScheduleRequest) { - return decoratedAwsGlue.stopCrawlerSchedule(stopCrawlerScheduleRequest); - } - - @Override - public StopSessionResult stopSession(StopSessionRequest stopSessionRequest) { - return decoratedAwsGlue.stopSession(stopSessionRequest); - } - - @Override - public StopTriggerResult stopTrigger(StopTriggerRequest stopTriggerRequest) { - return decoratedAwsGlue.stopTrigger(stopTriggerRequest); - } - - @Override - public StopWorkflowRunResult stopWorkflowRun(StopWorkflowRunRequest stopWorkflowRunRequest) { - return decoratedAwsGlue.stopWorkflowRun(stopWorkflowRunRequest); - } - - @Override - public TagResourceResult tagResource(TagResourceRequest tagResourceRequest) { - return decoratedAwsGlue.tagResource(tagResourceRequest); - } - - @Override - public UntagResourceResult untagResource(UntagResourceRequest untagResourceRequest) { - return decoratedAwsGlue.untagResource(untagResourceRequest); - } - - @Override - public UpdateBlueprintResult updateBlueprint(UpdateBlueprintRequest updateBlueprintRequest) { - return decoratedAwsGlue.updateBlueprint(updateBlueprintRequest); - } - - @Override - public UpdateClassifierResult updateClassifier(UpdateClassifierRequest updateClassifierRequest) { - return decoratedAwsGlue.updateClassifier(updateClassifierRequest); - } - - @Override - public UpdateConnectionResult updateConnection(UpdateConnectionRequest updateConnectionRequest) { - return decoratedAwsGlue.updateConnection(updateConnectionRequest); - } - - @Override - public UpdateCrawlerResult updateCrawler(UpdateCrawlerRequest updateCrawlerRequest) { - return decoratedAwsGlue.updateCrawler(updateCrawlerRequest); - } - - @Override - public UpdateCrawlerScheduleResult updateCrawlerSchedule(UpdateCrawlerScheduleRequest updateCrawlerScheduleRequest) { - return decoratedAwsGlue.updateCrawlerSchedule(updateCrawlerScheduleRequest); - } - - @Override - public UpdateDatabaseResult updateDatabase(UpdateDatabaseRequest updateDatabaseRequest) { - return decoratedAwsGlue.updateDatabase(updateDatabaseRequest); - } - - @Override - public UpdateDevEndpointResult updateDevEndpoint(UpdateDevEndpointRequest updateDevEndpointRequest) { - return decoratedAwsGlue.updateDevEndpoint(updateDevEndpointRequest); - } - - @Override - public UpdateJobResult updateJob(UpdateJobRequest updateJobRequest) { - return decoratedAwsGlue.updateJob(updateJobRequest); - } - - @Override - public UpdateMLTransformResult updateMLTransform(UpdateMLTransformRequest updateMLTransformRequest) { - return decoratedAwsGlue.updateMLTransform(updateMLTransformRequest); - } - - @Override - public UpdatePartitionResult updatePartition(UpdatePartitionRequest updatePartitionRequest) { - return decoratedAwsGlue.updatePartition(updatePartitionRequest); - } - - @Override - public UpdateRegistryResult updateRegistry(UpdateRegistryRequest updateRegistryRequest) { - return null; - } - - @Override - public UpdateSchemaResult updateSchema(UpdateSchemaRequest updateSchemaRequest) { - return null; - } - - @Override - public UpdateTableResult updateTable(UpdateTableRequest updateTableRequest) { - return decoratedAwsGlue.updateTable(updateTableRequest); - } - - @Override - public UpdateTableOptimizerResult updateTableOptimizer(UpdateTableOptimizerRequest updateTableOptimizerRequest) { - return null; - } - - @Override - public UpdateTriggerResult updateTrigger(UpdateTriggerRequest updateTriggerRequest) { - return decoratedAwsGlue.updateTrigger(updateTriggerRequest); - } - - @Override - public UpdateUserDefinedFunctionResult updateUserDefinedFunction(UpdateUserDefinedFunctionRequest updateUserDefinedFunctionRequest) { - return decoratedAwsGlue.updateUserDefinedFunction(updateUserDefinedFunctionRequest); - } - - @Override - public UpdateWorkflowResult updateWorkflow(UpdateWorkflowRequest updateWorkflowRequest) { - return decoratedAwsGlue.updateWorkflow(updateWorkflowRequest); - } - - @Override - public void shutdown() { - decoratedAwsGlue.shutdown(); - } - - @Override - public ResponseMetadata getCachedResponseMetadata(AmazonWebServiceRequest amazonWebServiceRequest) { - return decoratedAwsGlue.getCachedResponseMetadata(amazonWebServiceRequest); - } - - - @Override - public UpdateColumnStatisticsForTableResult updateColumnStatisticsForTable(UpdateColumnStatisticsForTableRequest updateColumnStatisticsForTableRequest) { - return decoratedAwsGlue.updateColumnStatisticsForTable(updateColumnStatisticsForTableRequest); - } - - @Override - public UpdateColumnStatisticsForPartitionResult updateColumnStatisticsForPartition(UpdateColumnStatisticsForPartitionRequest updateColumnStatisticsForPartitionRequest) { - return decoratedAwsGlue.updateColumnStatisticsForPartition(updateColumnStatisticsForPartitionRequest); - } - - @Override - public ResumeWorkflowRunResult resumeWorkflowRun(ResumeWorkflowRunRequest resumeWorkflowRunRequest) { - return decoratedAwsGlue.resumeWorkflowRun(resumeWorkflowRunRequest); - } - - @Override - public RunStatementResult runStatement(RunStatementRequest runStatementRequest) { - return decoratedAwsGlue.runStatement(runStatementRequest); - } - - @Override - public GetResourcePoliciesResult getResourcePolicies(GetResourcePoliciesRequest getResourcePoliciesRequest) { - return decoratedAwsGlue.getResourcePolicies(getResourcePoliciesRequest); - } - - @Override - public GetColumnStatisticsForTableResult getColumnStatisticsForTable(GetColumnStatisticsForTableRequest getColumnStatisticsForTableRequest) { - return decoratedAwsGlue.getColumnStatisticsForTable(getColumnStatisticsForTableRequest); - } - - @Override - public GetColumnStatisticsTaskRunResult getColumnStatisticsTaskRun(GetColumnStatisticsTaskRunRequest getColumnStatisticsTaskRunRequest) { - return null; - } - - @Override - public GetColumnStatisticsTaskRunsResult getColumnStatisticsTaskRuns(GetColumnStatisticsTaskRunsRequest getColumnStatisticsTaskRunsRequest) { - return null; - } - - @Override - public GetColumnStatisticsForPartitionResult getColumnStatisticsForPartition(GetColumnStatisticsForPartitionRequest getColumnStatisticsForPartitionRequest) { - return decoratedAwsGlue.getColumnStatisticsForPartition(getColumnStatisticsForPartitionRequest); - } - - @Override - public DeleteColumnStatisticsForTableResult deleteColumnStatisticsForTable(DeleteColumnStatisticsForTableRequest deleteColumnStatisticsForTableRequest) { - return decoratedAwsGlue.deleteColumnStatisticsForTable(deleteColumnStatisticsForTableRequest); - } - - @Override - public DeleteColumnStatisticsForPartitionResult deleteColumnStatisticsForPartition(DeleteColumnStatisticsForPartitionRequest deleteColumnStatisticsForPartitionRequest) { - return decoratedAwsGlue.deleteColumnStatisticsForPartition(deleteColumnStatisticsForPartitionRequest); - } - - @Override - public BatchGetBlueprintsResult batchGetBlueprints(BatchGetBlueprintsRequest batchGetBlueprintsRequest) { - return decoratedAwsGlue.batchGetBlueprints(batchGetBlueprintsRequest); - } - - @Override - public UpdateSourceControlFromJobResult updateSourceControlFromJob(UpdateSourceControlFromJobRequest updateSourceControlFromJobRequest) { - return null; - } - - @Override - public BatchGetDataQualityResultResult batchGetDataQualityResult(BatchGetDataQualityResultRequest batchGetDataQualityResultRequest) { - return null; - } - - @Override - public CancelDataQualityRuleRecommendationRunResult cancelDataQualityRuleRecommendationRun(CancelDataQualityRuleRecommendationRunRequest cancelDataQualityRuleRecommendationRunRequest) { - return null; - } - - @Override - public CancelDataQualityRulesetEvaluationRunResult cancelDataQualityRulesetEvaluationRun(CancelDataQualityRulesetEvaluationRunRequest cancelDataQualityRulesetEvaluationRunRequest) { - return null; - } - - @Override - public CreateDataQualityRulesetResult createDataQualityRuleset(CreateDataQualityRulesetRequest createDataQualityRulesetRequest) { - return null; - } - - @Override - public DeleteDataQualityRulesetResult deleteDataQualityRuleset(DeleteDataQualityRulesetRequest deleteDataQualityRulesetRequest) { - return null; - } - - @Override - public GetDataQualityResultResult getDataQualityResult(GetDataQualityResultRequest getDataQualityResultRequest) { - return null; - } - - @Override - public GetDataQualityRuleRecommendationRunResult getDataQualityRuleRecommendationRun(GetDataQualityRuleRecommendationRunRequest getDataQualityRuleRecommendationRunRequest) { - return null; - } - - @Override - public GetDataQualityRulesetResult getDataQualityRuleset(GetDataQualityRulesetRequest getDataQualityRulesetRequest) { - return null; - } - - @Override - public GetDataQualityRulesetEvaluationRunResult getDataQualityRulesetEvaluationRun(GetDataQualityRulesetEvaluationRunRequest getDataQualityRulesetEvaluationRunRequest) { - return null; - } - - @Override - public ListDataQualityResultsResult listDataQualityResults(ListDataQualityResultsRequest listDataQualityResultsRequest) { - return null; - } - - @Override - public ListDataQualityRuleRecommendationRunsResult listDataQualityRuleRecommendationRuns(ListDataQualityRuleRecommendationRunsRequest listDataQualityRuleRecommendationRunsRequest) { - return null; - } - - @Override - public ListDataQualityRulesetEvaluationRunsResult listDataQualityRulesetEvaluationRuns(ListDataQualityRulesetEvaluationRunsRequest listDataQualityRulesetEvaluationRunsRequest) { - return null; - } - - @Override - public ListDataQualityRulesetsResult listDataQualityRulesets(ListDataQualityRulesetsRequest listDataQualityRulesetsRequest) { - return null; - } - - @Override - public StartDataQualityRuleRecommendationRunResult startDataQualityRuleRecommendationRun(StartDataQualityRuleRecommendationRunRequest startDataQualityRuleRecommendationRunRequest) { - return null; - } - - @Override - public StartDataQualityRulesetEvaluationRunResult startDataQualityRulesetEvaluationRun(StartDataQualityRulesetEvaluationRunRequest startDataQualityRulesetEvaluationRunRequest) { - return null; - } - - @Override - public UpdateDataQualityRulesetResult updateDataQualityRuleset(UpdateDataQualityRulesetRequest updateDataQualityRulesetRequest) { - return null; - } - - @Override - public UpdateJobFromSourceControlResult updateJobFromSourceControl(UpdateJobFromSourceControlRequest updateJobFromSourceControlRequest) { - return null; - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueMetastore.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueMetastore.java deleted file mode 100644 index 0973576ac54f3c..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueMetastore.java +++ /dev/null @@ -1,133 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.metastore; - -import com.amazonaws.services.glue.model.ColumnStatistics; -import com.amazonaws.services.glue.model.ColumnStatisticsError; -import com.amazonaws.services.glue.model.Database; -import com.amazonaws.services.glue.model.DatabaseInput; -import com.amazonaws.services.glue.model.Partition; -import com.amazonaws.services.glue.model.PartitionError; -import com.amazonaws.services.glue.model.PartitionInput; -import com.amazonaws.services.glue.model.PartitionValueList; -import com.amazonaws.services.glue.model.Table; -import com.amazonaws.services.glue.model.TableInput; -import com.amazonaws.services.glue.model.UserDefinedFunction; -import com.amazonaws.services.glue.model.UserDefinedFunctionInput; -import org.apache.hadoop.hive.metastore.api.EnvironmentContext; -import org.apache.thrift.TException; - -import java.util.List; -import java.util.Map; - -/** - * This is the accessor interface for using AWS Glue as a metastore. - * The generic AWSGlue interface{@link com.amazonaws.services.glue.AWSGlue} - * has a number of methods that are irrelevant for clients using Glue only - * as a metastore. - * Think of this interface as a wrapper over AWSGlue. This additional layer - * of abstraction achieves the following - - * a) Hides the non-metastore related operations present in AWSGlue - * b) Hides away the batching and pagination related limitations of AWSGlue - */ -public interface AWSGlueMetastore { - - void createDatabase(DatabaseInput databaseInput); - - Database getDatabase(String dbName); - - List getAllDatabases(); - - void updateDatabase(String databaseName, DatabaseInput databaseInput); - - void deleteDatabase(String dbName); - - void createTable(String dbName, TableInput tableInput); - - Table getTable(String dbName, String tableName); - - List
    getTables(String dbname, String tablePattern); - - void updateTable(String dbName, TableInput tableInput); - - void updateTable(String dbName, TableInput tableInput, EnvironmentContext environmentContext); - - void deleteTable(String dbName, String tableName); - - Partition getPartition(String dbName, String tableName, List partitionValues); - - List getPartitionsByNames(String dbName, String tableName, - List partitionsToGet); - - List getPartitions(String dbName, String tableName, String expression, - long max) throws TException; - - void updatePartition(String dbName, String tableName, List partitionValues, - PartitionInput partitionInput); - - void deletePartition(String dbName, String tableName, List partitionValues); - - List createPartitions(String dbName, String tableName, - List partitionInputs); - - void createUserDefinedFunction(String dbName, UserDefinedFunctionInput functionInput); - - UserDefinedFunction getUserDefinedFunction(String dbName, String functionName); - - List getUserDefinedFunctions(String dbName, String pattern); - - List getUserDefinedFunctions(String pattern); - - void deleteUserDefinedFunction(String dbName, String functionName); - - void updateUserDefinedFunction(String dbName, String functionName, UserDefinedFunctionInput functionInput); - - void deletePartitionColumnStatistics(String dbName, String tableName, List partitionValues, String colName); - - void deleteTableColumnStatistics(String dbName, String tableName, String colName); - - Map> getPartitionColumnStatistics( - String dbName, - String tableName, - List partitionValues, - List columnNames - ); - - List getTableColumnStatistics( - String dbName, - String tableName, - List colNames - ); - - List updatePartitionColumnStatistics( - String dbName, - String tableName, - List partitionValues, - List columnStatistics - ); - - List updateTableColumnStatistics( - String dbName, - String tableName, - List columnStatistics - ); -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueMetastoreBaseDecorator.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueMetastoreBaseDecorator.java deleted file mode 100644 index 1494677c4b4b79..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueMetastoreBaseDecorator.java +++ /dev/null @@ -1,198 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.metastore; - -import com.amazonaws.services.glue.model.ColumnStatistics; -import com.amazonaws.services.glue.model.ColumnStatisticsError; -import com.amazonaws.services.glue.model.Database; -import com.amazonaws.services.glue.model.DatabaseInput; -import com.amazonaws.services.glue.model.Partition; -import com.amazonaws.services.glue.model.PartitionError; -import com.amazonaws.services.glue.model.PartitionInput; -import com.amazonaws.services.glue.model.PartitionValueList; -import com.amazonaws.services.glue.model.Table; -import com.amazonaws.services.glue.model.TableInput; -import com.amazonaws.services.glue.model.UserDefinedFunction; -import com.amazonaws.services.glue.model.UserDefinedFunctionInput; -import org.apache.hadoop.hive.metastore.api.EnvironmentContext; -import org.apache.thrift.TException; - -import java.util.List; -import java.util.Map; - -import static com.google.common.base.Preconditions.checkNotNull; - -public class AWSGlueMetastoreBaseDecorator implements AWSGlueMetastore { - - private final AWSGlueMetastore awsGlueMetastore; - - public AWSGlueMetastoreBaseDecorator(AWSGlueMetastore awsGlueMetastore) { - checkNotNull(awsGlueMetastore, "awsGlueMetastore can not be null"); - this.awsGlueMetastore = awsGlueMetastore; - } - - @Override - public void createDatabase(DatabaseInput databaseInput) { - awsGlueMetastore.createDatabase(databaseInput); - } - - @Override - public Database getDatabase(String dbName) { - return awsGlueMetastore.getDatabase(dbName); - } - - @Override - public List getAllDatabases() { - return awsGlueMetastore.getAllDatabases(); - } - - @Override - public void updateDatabase(String databaseName, DatabaseInput databaseInput) { - awsGlueMetastore.updateDatabase(databaseName, databaseInput); - } - - @Override - public void deleteDatabase(String dbName) { - awsGlueMetastore.deleteDatabase(dbName); - } - - @Override - public void createTable(String dbName, TableInput tableInput) { - awsGlueMetastore.createTable(dbName, tableInput); - } - - @Override - public Table getTable(String dbName, String tableName) { - return awsGlueMetastore.getTable(dbName, tableName); - } - - @Override - public List
    getTables(String dbname, String tablePattern) { - return awsGlueMetastore.getTables(dbname, tablePattern); - } - - @Override - public void updateTable(String dbName, TableInput tableInput) { - awsGlueMetastore.updateTable(dbName, tableInput); - } - - @Override - public void updateTable(String dbName, TableInput tableInput, EnvironmentContext environmentContext) { - awsGlueMetastore.updateTable(dbName, tableInput, environmentContext); - } - - @Override - public void deleteTable(String dbName, String tableName) { - awsGlueMetastore.deleteTable(dbName, tableName); - } - - @Override - public Partition getPartition(String dbName, String tableName, List partitionValues) { - return awsGlueMetastore.getPartition(dbName, tableName, partitionValues); - } - - @Override - public List getPartitionsByNames(String dbName, String tableName, List partitionsToGet) { - return awsGlueMetastore.getPartitionsByNames(dbName, tableName, partitionsToGet); - } - - @Override - public List getPartitions(String dbName, String tableName, String expression, long max) throws TException { - return awsGlueMetastore.getPartitions(dbName, tableName, expression, max); - } - - @Override - public void updatePartition(String dbName, String tableName, List partitionValues, PartitionInput partitionInput) { - awsGlueMetastore.updatePartition(dbName, tableName, partitionValues, partitionInput); - } - - @Override - public void deletePartition(String dbName, String tableName, List partitionValues) { - awsGlueMetastore.deletePartition(dbName, tableName, partitionValues); - } - - @Override - public List createPartitions(String dbName, String tableName, List partitionInputs) { - return awsGlueMetastore.createPartitions(dbName, tableName, partitionInputs); - } - - @Override - public void createUserDefinedFunction(String dbName, UserDefinedFunctionInput functionInput) { - awsGlueMetastore.createUserDefinedFunction(dbName, functionInput); - } - - @Override - public UserDefinedFunction getUserDefinedFunction(String dbName, String functionName) { - return awsGlueMetastore.getUserDefinedFunction(dbName, functionName); - } - - @Override - public List getUserDefinedFunctions(String dbName, String pattern) { - return awsGlueMetastore.getUserDefinedFunctions(dbName, pattern); - } - - @Override - public List getUserDefinedFunctions(String pattern) { - return awsGlueMetastore.getUserDefinedFunctions(pattern); - } - - @Override - public void deleteUserDefinedFunction(String dbName, String functionName) { - awsGlueMetastore.deleteUserDefinedFunction(dbName, functionName); - } - - @Override - public void updateUserDefinedFunction(String dbName, String functionName, UserDefinedFunctionInput functionInput) { - awsGlueMetastore.updateUserDefinedFunction(dbName, functionName, functionInput); - } - - @Override - public void deletePartitionColumnStatistics(String dbName, String tableName, List partitionValues, String colName) { - awsGlueMetastore.deletePartitionColumnStatistics(dbName, tableName, partitionValues, colName); - } - - @Override - public void deleteTableColumnStatistics(String dbName, String tableName, String colName) { - awsGlueMetastore.deleteTableColumnStatistics(dbName, tableName, colName); - } - - @Override - public Map> getPartitionColumnStatistics(String dbName, String tableName, List partitionValues, List columnNames) { - return awsGlueMetastore.getPartitionColumnStatistics(dbName, tableName, partitionValues, columnNames); - } - - @Override - public List getTableColumnStatistics(String dbName, String tableName, List colNames) { - return awsGlueMetastore.getTableColumnStatistics(dbName, tableName, colNames); - } - - @Override - public List updatePartitionColumnStatistics(String dbName, String tableName, List partitionValues, List columnStatistics) { - return awsGlueMetastore.updatePartitionColumnStatistics(dbName, tableName, partitionValues, columnStatistics); - } - - @Override - public List updateTableColumnStatistics(String dbName, String tableName, List columnStatistics) { - return awsGlueMetastore.updateTableColumnStatistics(dbName, tableName, columnStatistics); - } - -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueMetastoreCacheDecorator.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueMetastoreCacheDecorator.java deleted file mode 100644 index 158e34a3ac937a..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueMetastoreCacheDecorator.java +++ /dev/null @@ -1,185 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.metastore; - -import com.amazonaws.services.glue.model.Database; -import com.amazonaws.services.glue.model.Table; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; -import org.apache.hadoop.conf.Configuration; -import org.apache.log4j.Logger; - -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_GLUE_DB_CACHE_ENABLE; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_GLUE_DB_CACHE_SIZE; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_GLUE_DB_CACHE_TTL_MINS; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_GLUE_TABLE_CACHE_ENABLE; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_GLUE_TABLE_CACHE_SIZE; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_GLUE_TABLE_CACHE_TTL_MINS; - -import java.util.Objects; -import java.util.concurrent.TimeUnit; - -import static com.google.common.base.Preconditions.checkArgument; -import static com.google.common.base.Preconditions.checkNotNull; - -public class AWSGlueMetastoreCacheDecorator extends AWSGlueMetastoreBaseDecorator { - - private static final Logger logger = Logger.getLogger(AWSGlueMetastoreCacheDecorator.class); - - private final Configuration conf; - - private final boolean databaseCacheEnabled; - - private final boolean tableCacheEnabled; - - @VisibleForTesting - protected Cache databaseCache; - @VisibleForTesting - protected Cache tableCache; - - public AWSGlueMetastoreCacheDecorator(Configuration conf, AWSGlueMetastore awsGlueMetastore) { - super(awsGlueMetastore); - - checkNotNull(conf, "conf can not be null"); - this.conf = conf; - - databaseCacheEnabled = conf.getBoolean(AWS_GLUE_DB_CACHE_ENABLE, false); - if(databaseCacheEnabled) { - int dbCacheSize = conf.getInt(AWS_GLUE_DB_CACHE_SIZE, 0); - int dbCacheTtlMins = conf.getInt(AWS_GLUE_DB_CACHE_TTL_MINS, 0); - - //validate config values for size and ttl - validateConfigValueIsGreaterThanZero(AWS_GLUE_DB_CACHE_SIZE, dbCacheSize); - validateConfigValueIsGreaterThanZero(AWS_GLUE_DB_CACHE_TTL_MINS, dbCacheTtlMins); - - //initialize database cache - databaseCache = CacheBuilder.newBuilder().maximumSize(dbCacheSize) - .expireAfterWrite(dbCacheTtlMins, TimeUnit.MINUTES).build(); - } else { - databaseCache = null; - } - - tableCacheEnabled = conf.getBoolean(AWS_GLUE_TABLE_CACHE_ENABLE, false); - if(tableCacheEnabled) { - int tableCacheSize = conf.getInt(AWS_GLUE_TABLE_CACHE_SIZE, 0); - int tableCacheTtlMins = conf.getInt(AWS_GLUE_TABLE_CACHE_TTL_MINS, 0); - - //validate config values for size and ttl - validateConfigValueIsGreaterThanZero(AWS_GLUE_TABLE_CACHE_SIZE, tableCacheSize); - validateConfigValueIsGreaterThanZero(AWS_GLUE_TABLE_CACHE_TTL_MINS, tableCacheTtlMins); - - //initialize table cache - tableCache = CacheBuilder.newBuilder().maximumSize(tableCacheSize) - .expireAfterWrite(tableCacheTtlMins, TimeUnit.MINUTES).build(); - } else { - tableCache = null; - } - - logger.info("Constructed"); - } - - private void validateConfigValueIsGreaterThanZero(String configName, int value) { - checkArgument(value > 0, String.format("Invalid value for Hive Config %s. " + - "Provide a value greater than zero", configName)); - - } - - @Override - public Database getDatabase(String dbName) { - Database result; - if(databaseCacheEnabled) { - Database valueFromCache = databaseCache.getIfPresent(dbName); - if(valueFromCache != null) { - logger.info("Cache hit for operation [getDatabase] on key [" + dbName + "]"); - result = valueFromCache; - } else { - logger.info("Cache miss for operation [getDatabase] on key [" + dbName + "]"); - result = super.getDatabase(dbName); - databaseCache.put(dbName, result); - } - } else { - result = super.getDatabase(dbName); - } - return result; - } - - @Override - public Table getTable(String dbName, String tableName) { - Table result; - if(tableCacheEnabled) { - TableIdentifier key = new TableIdentifier(dbName, tableName); - Table valueFromCache = tableCache.getIfPresent(key); - if(valueFromCache != null) { - logger.info("Cache hit for operation [getTable] on key [" + key + "]"); - result = valueFromCache; - } else { - logger.info("Cache miss for operation [getTable] on key [" + key + "]"); - result = super.getTable(dbName, tableName); - tableCache.put(key, result); - } - } else { - result = super.getTable(dbName, tableName); - } - return result; - } - - static class TableIdentifier { - private final String dbName; - private final String tableName; - - public TableIdentifier(String dbName, String tableName) { - this.dbName = dbName; - this.tableName = tableName; - } - - public String getDbName() { - return dbName; - } - - public String getTableName() { - return tableName; - } - - @Override - public String toString() { - return "TableIdentifier{" + - "dbName='" + dbName + '\'' + - ", tableName='" + tableName + '\'' + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - TableIdentifier that = (TableIdentifier) o; - return Objects.equals(dbName, that.dbName) && - Objects.equals(tableName, that.tableName); - } - - @Override - public int hashCode() { - return Objects.hash(dbName, tableName); - } - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueMetastoreFactory.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueMetastoreFactory.java deleted file mode 100644 index 35220726e328c4..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueMetastoreFactory.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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.metastore; - -import com.amazonaws.services.glue.AWSGlue; -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.hive.metastore.api.MetaException; - -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_GLUE_DB_CACHE_ENABLE; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_GLUE_TABLE_CACHE_ENABLE; - -public class AWSGlueMetastoreFactory { - - public AWSGlueMetastore newMetastore(Configuration conf) throws MetaException { - AWSGlue glueClient = new AWSGlueClientFactory(conf).newClient(); - AWSGlueMetastore defaultMetastore = new DefaultAWSGlueMetastore(conf, glueClient); - if(isCacheEnabled(conf)) { - return new AWSGlueMetastoreCacheDecorator(conf, defaultMetastore); - } - return defaultMetastore; - } - - private boolean isCacheEnabled(Configuration conf) { - boolean databaseCacheEnabled = conf.getBoolean(AWS_GLUE_DB_CACHE_ENABLE, false); - boolean tableCacheEnabled = conf.getBoolean(AWS_GLUE_TABLE_CACHE_ENABLE, false); - return (databaseCacheEnabled || tableCacheEnabled); - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueMultipleCatalogDecorator.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueMultipleCatalogDecorator.java deleted file mode 100644 index c94472260dd5d4..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueMultipleCatalogDecorator.java +++ /dev/null @@ -1,370 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.metastore; - -import com.amazonaws.services.glue.AWSGlue; -import com.amazonaws.services.glue.model.BatchCreatePartitionRequest; -import com.amazonaws.services.glue.model.BatchCreatePartitionResult; -import com.amazonaws.services.glue.model.BatchDeletePartitionRequest; -import com.amazonaws.services.glue.model.BatchDeletePartitionResult; -import com.amazonaws.services.glue.model.BatchDeleteTableRequest; -import com.amazonaws.services.glue.model.BatchDeleteTableResult; -import com.amazonaws.services.glue.model.BatchGetPartitionRequest; -import com.amazonaws.services.glue.model.BatchGetPartitionResult; -import com.amazonaws.services.glue.model.CreateDatabaseRequest; -import com.amazonaws.services.glue.model.CreateDatabaseResult; -import com.amazonaws.services.glue.model.CreatePartitionRequest; -import com.amazonaws.services.glue.model.CreatePartitionResult; -import com.amazonaws.services.glue.model.CreateTableRequest; -import com.amazonaws.services.glue.model.CreateTableResult; -import com.amazonaws.services.glue.model.CreateUserDefinedFunctionRequest; -import com.amazonaws.services.glue.model.CreateUserDefinedFunctionResult; -import com.amazonaws.services.glue.model.DeleteDatabaseRequest; -import com.amazonaws.services.glue.model.DeleteDatabaseResult; -import com.amazonaws.services.glue.model.DeletePartitionRequest; -import com.amazonaws.services.glue.model.DeletePartitionResult; -import com.amazonaws.services.glue.model.DeleteTableRequest; -import com.amazonaws.services.glue.model.DeleteTableResult; -import com.amazonaws.services.glue.model.DeleteUserDefinedFunctionRequest; -import com.amazonaws.services.glue.model.DeleteUserDefinedFunctionResult; -import com.amazonaws.services.glue.model.GetDatabaseRequest; -import com.amazonaws.services.glue.model.GetDatabaseResult; -import com.amazonaws.services.glue.model.GetPartitionRequest; -import com.amazonaws.services.glue.model.GetPartitionResult; -import com.amazonaws.services.glue.model.GetPartitionsRequest; -import com.amazonaws.services.glue.model.GetPartitionsResult; -import com.amazonaws.services.glue.model.GetTableRequest; -import com.amazonaws.services.glue.model.GetTableResult; -import com.amazonaws.services.glue.model.GetTableVersionsRequest; -import com.amazonaws.services.glue.model.GetTableVersionsResult; -import com.amazonaws.services.glue.model.GetTablesRequest; -import com.amazonaws.services.glue.model.GetTablesResult; -import com.amazonaws.services.glue.model.GetUserDefinedFunctionRequest; -import com.amazonaws.services.glue.model.GetUserDefinedFunctionResult; -import com.amazonaws.services.glue.model.GetUserDefinedFunctionsRequest; -import com.amazonaws.services.glue.model.GetUserDefinedFunctionsResult; -import com.amazonaws.services.glue.model.UpdateDatabaseRequest; -import com.amazonaws.services.glue.model.UpdateDatabaseResult; -import com.amazonaws.services.glue.model.UpdatePartitionRequest; -import com.amazonaws.services.glue.model.UpdatePartitionResult; -import com.amazonaws.services.glue.model.UpdateTableRequest; -import com.amazonaws.services.glue.model.UpdateTableResult; -import com.amazonaws.services.glue.model.UpdateUserDefinedFunctionRequest; -import com.amazonaws.services.glue.model.UpdateUserDefinedFunctionResult; -import com.google.common.base.Strings; - -import java.util.function.Consumer; -import java.util.function.Supplier; - - -public class AWSGlueMultipleCatalogDecorator extends AWSGlueDecoratorBase { - - // We're not importing this from Hive's Warehouse class as the package name is changed between Hive 1.x and Hive 3.x - private static final String DEFAULT_DATABASE_NAME = "default"; - - private String catalogSeparator; - - public AWSGlueMultipleCatalogDecorator(AWSGlue awsGlueToBeDecorated, String catalogSeparator) { - super(awsGlueToBeDecorated); - this.catalogSeparator = catalogSeparator; - } - - private void configureRequest(Supplier getDatabaseFunc, - Consumer setDatabaseFunc, - Consumer setCatalogFunc) { - if (!Strings.isNullOrEmpty(this.catalogSeparator) && (getDatabaseFunc.get() != null) - && !getDatabaseFunc.get().equals(DEFAULT_DATABASE_NAME)) { - String databaseName = getDatabaseFunc.get(); - int idx = databaseName.indexOf(this.catalogSeparator); - if (idx >= 0) { - setCatalogFunc.accept(databaseName.substring(0, idx)); - setDatabaseFunc.accept(databaseName.substring(idx + this.catalogSeparator.length())); - } - } - } - - @Override - public BatchCreatePartitionResult batchCreatePartition(BatchCreatePartitionRequest batchCreatePartitionRequest) { - configureRequest( - batchCreatePartitionRequest::getDatabaseName, - batchCreatePartitionRequest::setDatabaseName, - batchCreatePartitionRequest::setCatalogId - ); - return super.batchCreatePartition(batchCreatePartitionRequest); - } - - @Override - public BatchDeletePartitionResult batchDeletePartition(BatchDeletePartitionRequest batchDeletePartitionRequest) { - configureRequest( - batchDeletePartitionRequest::getDatabaseName, - batchDeletePartitionRequest::setDatabaseName, - batchDeletePartitionRequest::setCatalogId - ); - return super.batchDeletePartition(batchDeletePartitionRequest); - } - - @Override - public BatchDeleteTableResult batchDeleteTable(BatchDeleteTableRequest batchDeleteTableRequest) { - configureRequest( - batchDeleteTableRequest::getDatabaseName, - batchDeleteTableRequest::setDatabaseName, - batchDeleteTableRequest::setCatalogId - ); - return super.batchDeleteTable(batchDeleteTableRequest); - } - - @Override - public BatchGetPartitionResult batchGetPartition(BatchGetPartitionRequest batchGetPartitionRequest) { - String originalDatabaseName = batchGetPartitionRequest.getDatabaseName(); - configureRequest( - batchGetPartitionRequest::getDatabaseName, - batchGetPartitionRequest::setDatabaseName, - batchGetPartitionRequest::setCatalogId - ); - BatchGetPartitionResult result = super.batchGetPartition(batchGetPartitionRequest); - result.getPartitions().forEach(partition -> partition.setDatabaseName(originalDatabaseName)); - return result; - } - - @Override - public CreateDatabaseResult createDatabase(CreateDatabaseRequest createDatabaseRequest) { - configureRequest( - () -> createDatabaseRequest.getDatabaseInput().getName(), - name -> createDatabaseRequest.getDatabaseInput().setName(name), - createDatabaseRequest::setCatalogId - ); - return super.createDatabase(createDatabaseRequest); - } - - @Override - public CreatePartitionResult createPartition(CreatePartitionRequest createPartitionRequest) { - configureRequest( - createPartitionRequest::getDatabaseName, - createPartitionRequest::setDatabaseName, - createPartitionRequest::setCatalogId - ); - return super.createPartition(createPartitionRequest); - } - - @Override - public CreateTableResult createTable(CreateTableRequest createTableRequest) { - configureRequest( - createTableRequest::getDatabaseName, - createTableRequest::setDatabaseName, - createTableRequest::setCatalogId - ); - return super.createTable(createTableRequest); - } - - @Override - public CreateUserDefinedFunctionResult createUserDefinedFunction(CreateUserDefinedFunctionRequest createUserDefinedFunctionRequest) { - configureRequest( - createUserDefinedFunctionRequest::getDatabaseName, - createUserDefinedFunctionRequest::setDatabaseName, - createUserDefinedFunctionRequest::setCatalogId - ); - return super.createUserDefinedFunction(createUserDefinedFunctionRequest); - } - - @Override - public DeleteDatabaseResult deleteDatabase(DeleteDatabaseRequest deleteDatabaseRequest) { - configureRequest( - deleteDatabaseRequest::getName, - deleteDatabaseRequest::setName, - deleteDatabaseRequest::setCatalogId - ); - return super.deleteDatabase(deleteDatabaseRequest); - } - - @Override - public DeletePartitionResult deletePartition(DeletePartitionRequest deletePartitionRequest) { - configureRequest( - deletePartitionRequest::getDatabaseName, - deletePartitionRequest::setDatabaseName, - deletePartitionRequest::setCatalogId - ); - return super.deletePartition(deletePartitionRequest); - } - - @Override - public DeleteTableResult deleteTable(DeleteTableRequest deleteTableRequest) { - configureRequest( - deleteTableRequest::getDatabaseName, - deleteTableRequest::setDatabaseName, - deleteTableRequest::setCatalogId - ); - return super.deleteTable(deleteTableRequest); - } - - @Override - public DeleteUserDefinedFunctionResult deleteUserDefinedFunction(DeleteUserDefinedFunctionRequest deleteUserDefinedFunctionRequest) { - configureRequest( - deleteUserDefinedFunctionRequest::getDatabaseName, - deleteUserDefinedFunctionRequest::setDatabaseName, - deleteUserDefinedFunctionRequest::setCatalogId - ); - return super.deleteUserDefinedFunction(deleteUserDefinedFunctionRequest); - } - - @Override - public GetDatabaseResult getDatabase(GetDatabaseRequest getDatabaseRequest) { - String originalDatabaseName = getDatabaseRequest.getName(); - configureRequest( - getDatabaseRequest::getName, - getDatabaseRequest::setName, - getDatabaseRequest::setCatalogId - ); - GetDatabaseResult result = super.getDatabase(getDatabaseRequest); - result.getDatabase().setName(originalDatabaseName); - return result; - } - - @Override - public GetPartitionResult getPartition(GetPartitionRequest getPartitionRequest) { - String originalDatabaseName = getPartitionRequest.getDatabaseName(); - configureRequest( - getPartitionRequest::getDatabaseName, - getPartitionRequest::setDatabaseName, - getPartitionRequest::setCatalogId - ); - GetPartitionResult result = super.getPartition(getPartitionRequest); - result.getPartition().setDatabaseName(originalDatabaseName); - return result; - } - - @Override - public GetPartitionsResult getPartitions(GetPartitionsRequest getPartitionsRequest) { - String originalDatabaseName = getPartitionsRequest.getDatabaseName(); - configureRequest( - getPartitionsRequest::getDatabaseName, - getPartitionsRequest::setDatabaseName, - getPartitionsRequest::setCatalogId - ); - GetPartitionsResult result = super.getPartitions(getPartitionsRequest); - result.getPartitions().forEach(partition -> partition.setDatabaseName(originalDatabaseName)); - return result; - } - - @Override - public GetTableResult getTable(GetTableRequest getTableRequest) { - String originalDatabaseName = getTableRequest.getDatabaseName(); - configureRequest( - getTableRequest::getDatabaseName, - getTableRequest::setDatabaseName, - getTableRequest::setCatalogId - ); - GetTableResult result = super.getTable(getTableRequest); - result.getTable().setDatabaseName(originalDatabaseName); - return result; - } - - @Override - public GetTableVersionsResult getTableVersions(GetTableVersionsRequest getTableVersionsRequest) { - String originalDatabaseName = getTableVersionsRequest.getDatabaseName(); - configureRequest( - getTableVersionsRequest::getDatabaseName, - getTableVersionsRequest::setDatabaseName, - getTableVersionsRequest::setCatalogId - ); - GetTableVersionsResult result = super.getTableVersions(getTableVersionsRequest); - result.getTableVersions().forEach(tableVersion -> tableVersion.getTable().setDatabaseName(originalDatabaseName)); - return result; - } - - @Override - public GetTablesResult getTables(GetTablesRequest getTablesRequest) { - String originalDatabaseName = getTablesRequest.getDatabaseName(); - configureRequest( - getTablesRequest::getDatabaseName, - getTablesRequest::setDatabaseName, - getTablesRequest::setCatalogId - ); - GetTablesResult result = super.getTables(getTablesRequest); - result.getTableList().forEach(table -> table.setDatabaseName(originalDatabaseName)); - return result; - } - - @Override - public GetUserDefinedFunctionResult getUserDefinedFunction(GetUserDefinedFunctionRequest getUserDefinedFunctionRequest) { - configureRequest( - getUserDefinedFunctionRequest::getDatabaseName, - getUserDefinedFunctionRequest::setDatabaseName, - getUserDefinedFunctionRequest::setCatalogId - ); - return super.getUserDefinedFunction(getUserDefinedFunctionRequest); - } - - @Override - public GetUserDefinedFunctionsResult getUserDefinedFunctions(GetUserDefinedFunctionsRequest getUserDefinedFunctionsRequest) { - configureRequest( - getUserDefinedFunctionsRequest::getDatabaseName, - getUserDefinedFunctionsRequest::setDatabaseName, - getUserDefinedFunctionsRequest::setCatalogId - ); - return super.getUserDefinedFunctions(getUserDefinedFunctionsRequest); - } - - @Override - public UpdateDatabaseResult updateDatabase(UpdateDatabaseRequest updateDatabaseRequest) { - configureRequest( - updateDatabaseRequest::getName, - updateDatabaseRequest::setName, - updateDatabaseRequest::setCatalogId - ); - configureRequest( - () -> updateDatabaseRequest.getDatabaseInput().getName(), - name -> updateDatabaseRequest.getDatabaseInput().setName(name), - catalogId -> {} - ); - return super.updateDatabase(updateDatabaseRequest); - } - - @Override - public UpdatePartitionResult updatePartition(UpdatePartitionRequest updatePartitionRequest) { - configureRequest( - updatePartitionRequest::getDatabaseName, - updatePartitionRequest::setDatabaseName, - updatePartitionRequest::setCatalogId - ); - return super.updatePartition(updatePartitionRequest); - } - - @Override - public UpdateTableResult updateTable(UpdateTableRequest updateTableRequest) { - configureRequest( - updateTableRequest::getDatabaseName, - updateTableRequest::setDatabaseName, - updateTableRequest::setCatalogId - ); - return super.updateTable(updateTableRequest); - } - - @Override - public UpdateUserDefinedFunctionResult updateUserDefinedFunction(UpdateUserDefinedFunctionRequest updateUserDefinedFunctionRequest) { - configureRequest( - updateUserDefinedFunctionRequest::getDatabaseName, - updateUserDefinedFunctionRequest::setDatabaseName, - updateUserDefinedFunctionRequest::setCatalogId - ); - return super.updateUserDefinedFunction(updateUserDefinedFunctionRequest); - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/DefaultAWSCredentialsProviderFactory.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/DefaultAWSCredentialsProviderFactory.java deleted file mode 100644 index 2f87efa38a6ca7..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/DefaultAWSCredentialsProviderFactory.java +++ /dev/null @@ -1,37 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.metastore; - -import org.apache.hadoop.conf.Configuration; - -import com.amazonaws.auth.AWSCredentialsProvider; -import com.amazonaws.auth.DefaultAWSCredentialsProviderChain; - -public class DefaultAWSCredentialsProviderFactory implements - AWSCredentialsProviderFactory { - - @Override - public AWSCredentialsProvider buildAWSCredentialsProvider(Configuration conf) { - return new DefaultAWSCredentialsProviderChain(); - } - -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/DefaultAWSGlueMetastore.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/DefaultAWSGlueMetastore.java deleted file mode 100644 index 78fa1bc3fb4625..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/DefaultAWSGlueMetastore.java +++ /dev/null @@ -1,662 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.metastore; - -import com.amazonaws.AmazonServiceException; -import com.amazonaws.glue.catalog.converters.PartitionNameParser; -import com.amazonaws.glue.catalog.util.MetastoreClientUtils; -import com.amazonaws.services.glue.AWSGlue; -import com.amazonaws.services.glue.model.BatchCreatePartitionRequest; -import com.amazonaws.services.glue.model.BatchGetPartitionRequest; -import com.amazonaws.services.glue.model.BatchGetPartitionResult; -import com.amazonaws.services.glue.model.ColumnStatistics; -import com.amazonaws.services.glue.model.ColumnStatisticsError; -import com.amazonaws.services.glue.model.CreateDatabaseRequest; -import com.amazonaws.services.glue.model.CreateTableRequest; -import com.amazonaws.services.glue.model.CreateUserDefinedFunctionRequest; -import com.amazonaws.services.glue.model.Database; -import com.amazonaws.services.glue.model.DatabaseInput; -import com.amazonaws.services.glue.model.DeleteColumnStatisticsForPartitionRequest; -import com.amazonaws.services.glue.model.DeleteColumnStatisticsForTableRequest; -import com.amazonaws.services.glue.model.DeleteDatabaseRequest; -import com.amazonaws.services.glue.model.DeletePartitionRequest; -import com.amazonaws.services.glue.model.DeleteTableRequest; -import com.amazonaws.services.glue.model.DeleteUserDefinedFunctionRequest; -import com.amazonaws.services.glue.model.GetColumnStatisticsForPartitionRequest; -import com.amazonaws.services.glue.model.GetColumnStatisticsForPartitionResult; -import com.amazonaws.services.glue.model.GetColumnStatisticsForTableRequest; -import com.amazonaws.services.glue.model.GetColumnStatisticsForTableResult; -import com.amazonaws.services.glue.model.GetDatabaseRequest; -import com.amazonaws.services.glue.model.GetDatabaseResult; -import com.amazonaws.services.glue.model.GetDatabasesRequest; -import com.amazonaws.services.glue.model.GetDatabasesResult; -import com.amazonaws.services.glue.model.GetPartitionRequest; -import com.amazonaws.services.glue.model.GetPartitionsRequest; -import com.amazonaws.services.glue.model.GetPartitionsResult; -import com.amazonaws.services.glue.model.GetTableRequest; -import com.amazonaws.services.glue.model.GetTableResult; -import com.amazonaws.services.glue.model.GetTablesRequest; -import com.amazonaws.services.glue.model.GetTablesResult; -import com.amazonaws.services.glue.model.GetUserDefinedFunctionRequest; -import com.amazonaws.services.glue.model.GetUserDefinedFunctionsRequest; -import com.amazonaws.services.glue.model.GetUserDefinedFunctionsResult; -import com.amazonaws.services.glue.model.Partition; -import com.amazonaws.services.glue.model.PartitionError; -import com.amazonaws.services.glue.model.PartitionInput; -import com.amazonaws.services.glue.model.PartitionValueList; -import com.amazonaws.services.glue.model.Segment; -import com.amazonaws.services.glue.model.Table; -import com.amazonaws.services.glue.model.TableInput; -import com.amazonaws.services.glue.model.UpdateColumnStatisticsForPartitionRequest; -import com.amazonaws.services.glue.model.UpdateColumnStatisticsForPartitionResult; -import com.amazonaws.services.glue.model.UpdateColumnStatisticsForTableRequest; -import com.amazonaws.services.glue.model.UpdateColumnStatisticsForTableResult; -import com.amazonaws.services.glue.model.UpdateDatabaseRequest; -import com.amazonaws.services.glue.model.UpdatePartitionRequest; -import com.amazonaws.services.glue.model.UpdateTableRequest; -import com.amazonaws.services.glue.model.UpdateUserDefinedFunctionRequest; -import com.amazonaws.services.glue.model.UserDefinedFunction; -import com.amazonaws.services.glue.model.UserDefinedFunctionInput; -import static com.google.common.base.Preconditions.checkArgument; -import static com.google.common.base.Preconditions.checkNotNull; -import com.google.common.base.Throwables; -import com.google.common.collect.Lists; -import com.google.common.util.concurrent.ThreadFactoryBuilder; -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.hive.common.StatsSetupConst; -import org.apache.hadoop.hive.metastore.api.EnvironmentContext; -import org.apache.hadoop.util.ReflectionUtils; -import org.apache.thrift.TException; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; - -public class DefaultAWSGlueMetastore implements AWSGlueMetastore { - - public static final int BATCH_GET_PARTITIONS_MAX_REQUEST_SIZE = 1000; - /** - * Based on the maxResults parameter at https://docs.aws.amazon.com/glue/latest/webapi/API_GetPartitions.html - */ - public static final int GET_PARTITIONS_MAX_SIZE = 1000; - /** - * Maximum number of Glue Segments. A segment defines a non-overlapping region of a table's partitions, - * allowing multiple requests to be executed in parallel. - */ - public static final int DEFAULT_NUM_PARTITION_SEGMENTS = 5; - /** - * Currently the upper limit allowed by Glue is 10. - * https://docs.aws.amazon.com/glue/latest/webapi/API_Segment.html - */ - public static final int MAX_NUM_PARTITION_SEGMENTS = 10; - public static final String NUM_PARTITION_SEGMENTS_CONF = "aws.glue.partition.num.segments"; - public static final String CUSTOM_EXECUTOR_FACTORY_CONF = "hive.metastore.executorservice.factory.class"; - - /** - * Based on the ColumnNames parameter at https://docs.aws.amazon.com/glue/latest/webapi/API_GetColumnStatisticsForPartition.html - */ - public static final int GET_COLUMNS_STAT_MAX_SIZE = 100; - public static final int UPDATE_COLUMNS_STAT_MAX_SIZE = 25; - - /** - * To be used with UpdateTable - */ - public static final String SKIP_AWS_GLUE_ARCHIVE = "skipAWSGlueArchive"; - - private static final int NUM_EXECUTOR_THREADS = 5; - static final String GLUE_METASTORE_DELEGATE_THREADPOOL_NAME_FORMAT = "glue-metastore-delegate-%d"; - private static final ExecutorService GLUE_METASTORE_DELEGATE_THREAD_POOL = Executors.newFixedThreadPool( - NUM_EXECUTOR_THREADS, - new ThreadFactoryBuilder() - .setNameFormat(GLUE_METASTORE_DELEGATE_THREADPOOL_NAME_FORMAT) - .setDaemon(true).build() - ); - - private final Configuration conf; - private final AWSGlue glueClient; - private final String catalogId; - private final ExecutorService executorService; - private final int numPartitionSegments; - - protected ExecutorService getExecutorService(Configuration conf) { - Class executorFactoryClass = conf - .getClass(CUSTOM_EXECUTOR_FACTORY_CONF, - DefaultExecutorServiceFactory.class).asSubclass( - ExecutorServiceFactory.class); - ExecutorServiceFactory factory = ReflectionUtils.newInstance( - executorFactoryClass, conf); - return factory.getExecutorService(conf); - } - - public DefaultAWSGlueMetastore(Configuration conf, AWSGlue glueClient) { - checkNotNull(conf, "Hive Config cannot be null"); - checkNotNull(glueClient, "glueClient cannot be null"); - this.numPartitionSegments = conf.getInt(NUM_PARTITION_SEGMENTS_CONF, DEFAULT_NUM_PARTITION_SEGMENTS); - checkArgument(numPartitionSegments <= MAX_NUM_PARTITION_SEGMENTS, - String.format("Hive Config [%s] can't exceed %d", NUM_PARTITION_SEGMENTS_CONF, MAX_NUM_PARTITION_SEGMENTS)); - this.conf = conf; - this.glueClient = glueClient; - this.catalogId = MetastoreClientUtils.getCatalogId(conf); - this.executorService = getExecutorService(conf); - } - - // ======================= Database ======================= - - @Override - public void createDatabase(DatabaseInput databaseInput) { - CreateDatabaseRequest createDatabaseRequest = new CreateDatabaseRequest().withDatabaseInput(databaseInput) - .withCatalogId(catalogId); - glueClient.createDatabase(createDatabaseRequest); - } - - @Override - public Database getDatabase(String dbName) { - GetDatabaseRequest getDatabaseRequest = new GetDatabaseRequest().withCatalogId(catalogId).withName(dbName); - GetDatabaseResult result = glueClient.getDatabase(getDatabaseRequest); - return result.getDatabase(); - } - - @Override - public List getAllDatabases() { - List ret = Lists.newArrayList(); - String nextToken = null; - do { - GetDatabasesRequest getDatabasesRequest = new GetDatabasesRequest().withNextToken(nextToken).withCatalogId( - catalogId); - GetDatabasesResult result = glueClient.getDatabases(getDatabasesRequest); - nextToken = result.getNextToken(); - ret.addAll(result.getDatabaseList()); - } while (nextToken != null); - return ret; - } - - @Override - public void updateDatabase(String databaseName, DatabaseInput databaseInput) { - UpdateDatabaseRequest updateDatabaseRequest = new UpdateDatabaseRequest().withName(databaseName) - .withDatabaseInput(databaseInput).withCatalogId(catalogId); - glueClient.updateDatabase(updateDatabaseRequest); - } - - @Override - public void deleteDatabase(String dbName) { - DeleteDatabaseRequest deleteDatabaseRequest = new DeleteDatabaseRequest().withName(dbName).withCatalogId( - catalogId); - glueClient.deleteDatabase(deleteDatabaseRequest); - } - - // ======================== Table ======================== - - @Override - public void createTable(String dbName, TableInput tableInput) { - CreateTableRequest createTableRequest = new CreateTableRequest().withTableInput(tableInput) - .withDatabaseName(dbName).withCatalogId(catalogId); - glueClient.createTable(createTableRequest); - } - - @Override - public Table getTable(String dbName, String tableName) { - GetTableRequest getTableRequest = new GetTableRequest().withDatabaseName(dbName).withName(tableName) - .withCatalogId(catalogId); - GetTableResult result = glueClient.getTable(getTableRequest); - return result.getTable(); - } - - @Override - public List
    getTables(String dbname, String tablePattern) { - List
    ret = new ArrayList<>(); - String nextToken = null; - do { - GetTablesRequest getTablesRequest = new GetTablesRequest().withDatabaseName(dbname) - .withExpression(tablePattern).withNextToken(nextToken).withCatalogId(catalogId); - GetTablesResult result = glueClient.getTables(getTablesRequest); - ret.addAll(result.getTableList()); - nextToken = result.getNextToken(); - } while (nextToken != null); - return ret; - } - - @Override - public void updateTable(String dbName, TableInput tableInput) { - UpdateTableRequest updateTableRequest = new UpdateTableRequest().withDatabaseName(dbName) - .withTableInput(tableInput).withCatalogId(catalogId); - glueClient.updateTable(updateTableRequest); - } - - @Override - public void updateTable(String dbName, TableInput tableInput, EnvironmentContext environmentContext) { - UpdateTableRequest updateTableRequest = new UpdateTableRequest().withDatabaseName(dbName) - .withTableInput(tableInput).withCatalogId(catalogId).withSkipArchive(skipArchive(environmentContext)); - glueClient.updateTable(updateTableRequest); - } - - private boolean skipArchive(EnvironmentContext environmentContext) { - return environmentContext != null && - environmentContext.isSetProperties() && - StatsSetupConst.TRUE.equals(environmentContext.getProperties().get(SKIP_AWS_GLUE_ARCHIVE)); - } - - @Override - public void deleteTable(String dbName, String tableName) { - DeleteTableRequest deleteTableRequest = new DeleteTableRequest().withDatabaseName(dbName).withName(tableName) - .withCatalogId(catalogId); - glueClient.deleteTable(deleteTableRequest); - } - - // =========================== Partition =========================== - - @Override - public Partition getPartition(String dbName, String tableName, List partitionValues) { - GetPartitionRequest request = new GetPartitionRequest() - .withDatabaseName(dbName) - .withTableName(tableName) - .withPartitionValues(partitionValues) - .withCatalogId(catalogId); - return glueClient.getPartition(request).getPartition(); - } - - @Override - public List getPartitionsByNames(String dbName, String tableName, - List partitionsToGet) { - - List> batchedPartitionsToGet = Lists.partition(partitionsToGet, - BATCH_GET_PARTITIONS_MAX_REQUEST_SIZE); - List> batchGetPartitionFutures = Lists.newArrayList(); - - for (List batch : batchedPartitionsToGet) { - final BatchGetPartitionRequest request = new BatchGetPartitionRequest() - .withDatabaseName(dbName) - .withTableName(tableName) - .withPartitionsToGet(batch) - .withCatalogId(catalogId); - batchGetPartitionFutures.add(this.executorService.submit(new Callable() { - @Override - public BatchGetPartitionResult call() throws Exception { - return glueClient.batchGetPartition(request); - } - })); - } - - List result = Lists.newArrayList(); - try { - for (Future future : batchGetPartitionFutures) { - result.addAll(future.get().getPartitions()); - } - } catch (ExecutionException e) { - Throwables.propagateIfInstanceOf(e.getCause(), AmazonServiceException.class); - Throwables.propagate(e.getCause()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - return result; - } - - @Override - public List getPartitions(String dbName, String tableName, String expression, - long max) throws TException { - if (max == 0) { - return Collections.emptyList(); - } - if (max < 0 || max > GET_PARTITIONS_MAX_SIZE) { - return getPartitionsParallel(dbName, tableName, expression, max); - } else { - // We don't need to get too many partitions, so just do it serially. - return getCatalogPartitions(dbName, tableName, expression, max, null); - } - } - - private List getPartitionsParallel( - final String databaseName, - final String tableName, - final String expression, - final long max) throws TException { - // Prepare the segments - List segments = Lists.newArrayList(); - for (int i = 0; i < numPartitionSegments; i++) { - segments.add(new Segment() - .withSegmentNumber(i) - .withTotalSegments(numPartitionSegments)); - } - // Submit Glue API calls in parallel using the thread pool. - // We could convert this into a parallelStream after upgrading to JDK 8 compiler base. - List>> futures = Lists.newArrayList(); - for (final Segment segment : segments) { - futures.add(this.executorService.submit(new Callable>() { - @Override - public List call() throws Exception { - return getCatalogPartitions(databaseName, tableName, expression, max, segment); - } - })); - } - - // Get the results - List partitions = Lists.newArrayList(); - try { - for (Future> future : futures) { - List segmentPartitions = future.get(); - if (partitions.size() + segmentPartitions.size() >= max && max > 0) { - // Extract the required number of partitions from the segment and we're done. - long remaining = max - partitions.size(); - partitions.addAll(segmentPartitions.subList(0, (int) remaining)); - break; - } else { - partitions.addAll(segmentPartitions); - } - } - } catch (ExecutionException e) { - Throwables.propagateIfInstanceOf(e.getCause(), AmazonServiceException.class); - Throwables.propagate(e.getCause()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - return partitions; - } - - - private List getCatalogPartitions(String databaseName, String tableName, String expression, - long max, Segment segment) { - List partitions = Lists.newArrayList(); - String nextToken = null; - do { - GetPartitionsRequest request = new GetPartitionsRequest() - .withDatabaseName(databaseName) - .withTableName(tableName) - .withExpression(expression) - .withNextToken(nextToken) - .withCatalogId(catalogId) - .withSegment(segment); - GetPartitionsResult res = glueClient.getPartitions(request); - List list = res.getPartitions(); - if ((partitions.size() + list.size()) >= max && max > 0) { - long remaining = max - partitions.size(); - partitions.addAll(list.subList(0, (int) remaining)); - break; - } - partitions.addAll(list); - nextToken = res.getNextToken(); - } while (nextToken != null); - return partitions; - } - - @Override - public void updatePartition(String dbName, String tableName, List partitionValues, - PartitionInput partitionInput) { - UpdatePartitionRequest updatePartitionRequest = new UpdatePartitionRequest().withDatabaseName(dbName) - .withTableName(tableName).withPartitionValueList(partitionValues) - .withPartitionInput(partitionInput).withCatalogId(catalogId); - glueClient.updatePartition(updatePartitionRequest); - } - - @Override - public void deletePartition(String dbName, String tableName, List partitionValues) { - DeletePartitionRequest request = new DeletePartitionRequest() - .withDatabaseName(dbName) - .withTableName(tableName) - .withPartitionValues(partitionValues) - .withCatalogId(catalogId); - glueClient.deletePartition(request); - } - - @Override - public List createPartitions(String dbName, String tableName, - List partitionInputs) { - BatchCreatePartitionRequest request = - new BatchCreatePartitionRequest().withDatabaseName(dbName) - .withTableName(tableName).withCatalogId(catalogId) - .withPartitionInputList(partitionInputs); - return glueClient.batchCreatePartition(request).getErrors(); - } - - // ====================== User Defined Function ====================== - - @Override - public void createUserDefinedFunction(String dbName, UserDefinedFunctionInput functionInput) { - CreateUserDefinedFunctionRequest createUserDefinedFunctionRequest = new CreateUserDefinedFunctionRequest() - .withDatabaseName(dbName).withFunctionInput(functionInput).withCatalogId(catalogId); - glueClient.createUserDefinedFunction(createUserDefinedFunctionRequest); - } - - @Override - public UserDefinedFunction getUserDefinedFunction(String dbName, String functionName) { - GetUserDefinedFunctionRequest getUserDefinedFunctionRequest = new GetUserDefinedFunctionRequest() - .withDatabaseName(dbName).withFunctionName(functionName).withCatalogId(catalogId); - return glueClient.getUserDefinedFunction(getUserDefinedFunctionRequest).getUserDefinedFunction(); - } - - @Override - public List getUserDefinedFunctions(String dbName, String pattern) { - List ret = Lists.newArrayList(); - String nextToken = null; - do { - GetUserDefinedFunctionsRequest getUserDefinedFunctionsRequest = new GetUserDefinedFunctionsRequest() - .withDatabaseName(dbName).withPattern(pattern).withNextToken(nextToken).withCatalogId(catalogId); - GetUserDefinedFunctionsResult result = glueClient.getUserDefinedFunctions(getUserDefinedFunctionsRequest); - nextToken = result.getNextToken(); - ret.addAll(result.getUserDefinedFunctions()); - } while (nextToken != null); - return ret; - } - - @Override - public List getUserDefinedFunctions(String pattern) { - List ret = Lists.newArrayList(); - String nextToken = null; - do { - GetUserDefinedFunctionsRequest getUserDefinedFunctionsRequest = new GetUserDefinedFunctionsRequest() - .withPattern(pattern).withNextToken(nextToken).withCatalogId(catalogId); - GetUserDefinedFunctionsResult result = glueClient.getUserDefinedFunctions(getUserDefinedFunctionsRequest); - nextToken = result.getNextToken(); - ret.addAll(result.getUserDefinedFunctions()); - } while (nextToken != null); - return ret; - } - - @Override - public void deleteUserDefinedFunction(String dbName, String functionName) { - DeleteUserDefinedFunctionRequest deleteUserDefinedFunctionRequest = new DeleteUserDefinedFunctionRequest() - .withDatabaseName(dbName).withFunctionName(functionName).withCatalogId(catalogId); - glueClient.deleteUserDefinedFunction(deleteUserDefinedFunctionRequest); - } - - @Override - public void updateUserDefinedFunction(String dbName, String functionName, UserDefinedFunctionInput functionInput) { - UpdateUserDefinedFunctionRequest updateUserDefinedFunctionRequest = new UpdateUserDefinedFunctionRequest() - .withDatabaseName(dbName).withFunctionName(functionName).withFunctionInput(functionInput) - .withCatalogId(catalogId); - glueClient.updateUserDefinedFunction(updateUserDefinedFunctionRequest); - } - - @Override - public void deletePartitionColumnStatistics(String dbName, String tableName, List partitionValues, String colName) { - DeleteColumnStatisticsForPartitionRequest request = new DeleteColumnStatisticsForPartitionRequest() - .withCatalogId(catalogId) - .withDatabaseName(dbName) - .withTableName(tableName) - .withPartitionValues(partitionValues) - .withColumnName(colName); - glueClient.deleteColumnStatisticsForPartition(request); - } - - @Override - public void deleteTableColumnStatistics(String dbName, String tableName, String colName) { - DeleteColumnStatisticsForTableRequest request = new DeleteColumnStatisticsForTableRequest() - .withCatalogId(catalogId) - .withDatabaseName(dbName) - .withTableName(tableName) - .withColumnName(colName); - glueClient.deleteColumnStatisticsForTable(request); - } - - @Override - public Map> getPartitionColumnStatistics(String dbName, String tableName, List partitionValues, List columnNames) { - Map> partitionStatistics = new HashMap<>(); - List> pagedColNames = Lists.partition(columnNames, GET_COLUMNS_STAT_MAX_SIZE); - List partValues; - for (String partName : partitionValues) { - partValues = PartitionNameParser.getPartitionValuesFromName(partName); - List> pagedResult = new ArrayList<>(); - for (List cols : pagedColNames) { - GetColumnStatisticsForPartitionRequest request = new GetColumnStatisticsForPartitionRequest() - .withCatalogId(catalogId) - .withDatabaseName(dbName) - .withTableName(tableName) - .withPartitionValues(partValues) - .withColumnNames(cols); - pagedResult.add(GLUE_METASTORE_DELEGATE_THREAD_POOL.submit(new Callable() { - @Override - public GetColumnStatisticsForPartitionResult call() throws Exception { - return glueClient.getColumnStatisticsForPartition(request); - } - })); - } - - List result = new ArrayList<>(); - for (Future page : pagedResult) { - try { - result.addAll(page.get().getColumnStatisticsList()); - } catch (ExecutionException e) { - Throwables.propagateIfInstanceOf(e.getCause(), AmazonServiceException.class); - Throwables.propagate(e.getCause()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - partitionStatistics.put(partName, result); - } - return partitionStatistics; - } - - @Override - public List getTableColumnStatistics(String dbName, String tableName, List colNames) { - List> pagedColNames = Lists.partition(colNames, GET_COLUMNS_STAT_MAX_SIZE); - List> pagedResult = new ArrayList<>(); - - for (List cols : pagedColNames) { - GetColumnStatisticsForTableRequest request = new GetColumnStatisticsForTableRequest() - .withCatalogId(catalogId) - .withDatabaseName(dbName) - .withTableName(tableName) - .withColumnNames(cols); - pagedResult.add(GLUE_METASTORE_DELEGATE_THREAD_POOL.submit(new Callable() { - @Override - public GetColumnStatisticsForTableResult call() throws Exception { - return glueClient.getColumnStatisticsForTable(request); - } - })); - } - List results = new ArrayList<>(); - - for (Future page : pagedResult) { - try { - results.addAll(page.get().getColumnStatisticsList()); - } catch (ExecutionException e) { - Throwables.propagateIfInstanceOf(e.getCause(), AmazonServiceException.class); - Throwables.propagate(e.getCause()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - return results; - } - - @Override - public List updatePartitionColumnStatistics( - String dbName, - String tableName, - List partitionValues, - List columnStatistics) { - - List> statisticsListPaged = Lists.partition(columnStatistics, UPDATE_COLUMNS_STAT_MAX_SIZE); - List> pagedResult = new ArrayList<>(); - for (List statList : statisticsListPaged) { - UpdateColumnStatisticsForPartitionRequest request = new UpdateColumnStatisticsForPartitionRequest() - .withCatalogId(catalogId) - .withDatabaseName(dbName) - .withTableName(tableName) - .withPartitionValues(partitionValues) - .withColumnStatisticsList(statList); - pagedResult.add(GLUE_METASTORE_DELEGATE_THREAD_POOL.submit(new Callable() { - @Override - public UpdateColumnStatisticsForPartitionResult call() throws Exception { - return glueClient.updateColumnStatisticsForPartition(request); - } - })); - } - // Waiting for calls to finish. Will fail the call if one of the future task fails - List columnStatisticsErrors = new ArrayList<>(); - try { - for (Future page : pagedResult) { - Optional.ofNullable(page.get().getErrors()).ifPresent(error -> columnStatisticsErrors.addAll(error)); - } - } catch (ExecutionException e) { - Throwables.propagateIfInstanceOf(e.getCause(), AmazonServiceException.class); - Throwables.propagate(e.getCause()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - return columnStatisticsErrors; - } - - @Override - public List updateTableColumnStatistics( - String dbName, - String tableName, - List columnStatistics) { - - List> statisticsListPaged = Lists.partition(columnStatistics, UPDATE_COLUMNS_STAT_MAX_SIZE); - List> pagedResult = new ArrayList<>(); - for (List statList : statisticsListPaged) { - UpdateColumnStatisticsForTableRequest request = new UpdateColumnStatisticsForTableRequest() - .withCatalogId(catalogId) - .withDatabaseName(dbName) - .withTableName(tableName) - .withColumnStatisticsList(statList); - pagedResult.add(GLUE_METASTORE_DELEGATE_THREAD_POOL.submit(new Callable() { - @Override - public UpdateColumnStatisticsForTableResult call() throws Exception { - return glueClient.updateColumnStatisticsForTable(request); - } - })); - } - - // Waiting for calls to finish. Will fail the call if one of the future task fails - List columnStatisticsErrors = new ArrayList<>(); - try { - for (Future page : pagedResult) { - Optional.ofNullable(page.get().getErrors()).ifPresent(error -> columnStatisticsErrors.addAll(error)); - } - } catch (ExecutionException e) { - Throwables.propagateIfInstanceOf(e.getCause(), AmazonServiceException.class); - Throwables.propagate(e.getCause()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - return columnStatisticsErrors; - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/DefaultExecutorServiceFactory.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/DefaultExecutorServiceFactory.java deleted file mode 100644 index 326587f1610416..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/DefaultExecutorServiceFactory.java +++ /dev/null @@ -1,43 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.metastore; - -import com.google.common.util.concurrent.ThreadFactoryBuilder; -import org.apache.hadoop.conf.Configuration; - -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -public class DefaultExecutorServiceFactory implements ExecutorServiceFactory { - private static final int NUM_EXECUTOR_THREADS = 5; - - private static final ExecutorService GLUE_METASTORE_DELEGATE_THREAD_POOL = Executors.newFixedThreadPool( - NUM_EXECUTOR_THREADS, new ThreadFactoryBuilder() - .setNameFormat(GlueMetastoreClientDelegate.GLUE_METASTORE_DELEGATE_THREADPOOL_NAME_FORMAT) - .setDaemon(true).build() - ); - - @Override - public ExecutorService getExecutorService(Configuration conf) { - return GLUE_METASTORE_DELEGATE_THREAD_POOL; - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/ExecutorServiceFactory.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/ExecutorServiceFactory.java deleted file mode 100644 index a9b53f55ad929c..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/ExecutorServiceFactory.java +++ /dev/null @@ -1,33 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.metastore; - -import org.apache.hadoop.conf.Configuration; - -import java.util.concurrent.ExecutorService; - -/* - * Interface for creating an ExecutorService - */ -public interface ExecutorServiceFactory { - public ExecutorService getExecutorService(Configuration conf); -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/GlueClientFactory.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/GlueClientFactory.java deleted file mode 100644 index 409d8863c3fee1..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/GlueClientFactory.java +++ /dev/null @@ -1,34 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.metastore; - -import com.amazonaws.services.glue.AWSGlue; -import org.apache.hadoop.hive.metastore.api.MetaException; - -/*** - * Interface for creating Glue AWS Client - */ -public interface GlueClientFactory { - - AWSGlue newClient() throws MetaException; - -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/GlueMetastoreClientDelegate.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/GlueMetastoreClientDelegate.java deleted file mode 100644 index 04f27f63064e0f..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/GlueMetastoreClientDelegate.java +++ /dev/null @@ -1,1843 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.metastore; - -import com.amazonaws.AmazonServiceException; -import com.amazonaws.glue.catalog.converters.CatalogToHiveConverter; -import com.amazonaws.glue.catalog.converters.CatalogToHiveConverterFactory; -import com.amazonaws.glue.catalog.converters.GlueInputConverter; -import com.amazonaws.glue.catalog.converters.HiveToCatalogConverter; -import com.amazonaws.glue.catalog.converters.PartitionNameParser; -import static com.amazonaws.glue.catalog.util.AWSGlueConfig.AWS_GLUE_DISABLE_UDF; -import com.amazonaws.glue.catalog.util.BatchCreatePartitionsHelper; -import com.amazonaws.glue.catalog.util.ExpressionHelper; -import com.amazonaws.glue.catalog.util.MetastoreClientUtils; -import static com.amazonaws.glue.catalog.util.MetastoreClientUtils.deepCopyMap; -import static com.amazonaws.glue.catalog.util.MetastoreClientUtils.isExternalTable; -import static com.amazonaws.glue.catalog.util.MetastoreClientUtils.makeDirs; -import static com.amazonaws.glue.catalog.util.MetastoreClientUtils.validateGlueTable; -import static com.amazonaws.glue.catalog.util.MetastoreClientUtils.validateTableObject; -import com.amazonaws.glue.catalog.util.PartitionKey; -import com.amazonaws.services.glue.model.Column; -import com.amazonaws.services.glue.model.ColumnStatistics; -import com.amazonaws.services.glue.model.ColumnStatisticsError; -import com.amazonaws.services.glue.model.Database; -import com.amazonaws.services.glue.model.DatabaseInput; -import com.amazonaws.services.glue.model.EntityNotFoundException; -import com.amazonaws.services.glue.model.Partition; -import com.amazonaws.services.glue.model.PartitionInput; -import com.amazonaws.services.glue.model.PartitionValueList; -import com.amazonaws.services.glue.model.Table; -import com.amazonaws.services.glue.model.TableInput; -import com.amazonaws.services.glue.model.UserDefinedFunction; -import com.amazonaws.services.glue.model.UserDefinedFunctionInput; -import static com.google.common.base.Preconditions.checkArgument; -import static com.google.common.base.Preconditions.checkNotNull; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import com.google.common.util.concurrent.ThreadFactoryBuilder; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.tuple.Pair; -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.fs.Path; -import org.apache.hadoop.hive.common.StatsSetupConst; -import org.apache.hadoop.hive.common.ValidTxnList; -import static org.apache.hadoop.hive.metastore.HiveMetaStore.PUBLIC; -import org.apache.hadoop.hive.metastore.IMetaStoreClient; -import org.apache.hadoop.hive.metastore.TableType; -import static org.apache.hadoop.hive.metastore.TableType.EXTERNAL_TABLE; -import static org.apache.hadoop.hive.metastore.TableType.MANAGED_TABLE; -import org.apache.hadoop.hive.metastore.Warehouse; -import org.apache.hadoop.hive.metastore.api.AggrStats; -import org.apache.hadoop.hive.metastore.api.AlreadyExistsException; -import org.apache.hadoop.hive.metastore.api.ColumnStatisticsObj; -import org.apache.hadoop.hive.metastore.api.CompactionResponse; -import org.apache.hadoop.hive.metastore.api.CompactionType; -import org.apache.hadoop.hive.metastore.api.CurrentNotificationEventId; -import org.apache.hadoop.hive.metastore.api.DataOperationType; -import org.apache.hadoop.hive.metastore.api.EnvironmentContext; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.FireEventRequest; -import org.apache.hadoop.hive.metastore.api.FireEventResponse; -import org.apache.hadoop.hive.metastore.api.GetAllFunctionsResponse; -import org.apache.hadoop.hive.metastore.api.GetOpenTxnsInfoResponse; -import org.apache.hadoop.hive.metastore.api.GetRoleGrantsForPrincipalRequest; -import org.apache.hadoop.hive.metastore.api.GetRoleGrantsForPrincipalResponse; -import org.apache.hadoop.hive.metastore.api.HeartbeatTxnRangeResponse; -import org.apache.hadoop.hive.metastore.api.HiveObjectPrivilege; -import org.apache.hadoop.hive.metastore.api.HiveObjectRef; -import org.apache.hadoop.hive.metastore.api.InvalidObjectException; -import org.apache.hadoop.hive.metastore.api.InvalidOperationException; -import org.apache.hadoop.hive.metastore.api.LockRequest; -import org.apache.hadoop.hive.metastore.api.LockResponse; -import org.apache.hadoop.hive.metastore.api.MetaException; -import org.apache.hadoop.hive.metastore.api.MetadataPpdResult; -import org.apache.hadoop.hive.metastore.api.NoSuchObjectException; -import org.apache.hadoop.hive.metastore.api.NotificationEventResponse; -import org.apache.hadoop.hive.metastore.api.OpenTxnsResponse; -import org.apache.hadoop.hive.metastore.api.PartitionEventType; -import org.apache.hadoop.hive.metastore.api.PartitionValuesRequest; -import org.apache.hadoop.hive.metastore.api.PartitionValuesResponse; -import org.apache.hadoop.hive.metastore.api.PrincipalType; -import org.apache.hadoop.hive.metastore.api.Role; -import org.apache.hadoop.hive.metastore.api.SQLForeignKey; -import org.apache.hadoop.hive.metastore.api.SQLPrimaryKey; -import org.apache.hadoop.hive.metastore.api.ShowCompactResponse; -import org.apache.hadoop.hive.metastore.api.ShowLocksRequest; -import org.apache.hadoop.hive.metastore.api.ShowLocksResponse; -import org.apache.hadoop.hive.metastore.api.TableMeta; -import org.apache.hadoop.hive.metastore.api.UnknownDBException; -import org.apache.hadoop.hive.metastore.api.UnknownTableException; -import org.apache.hadoop.hive.metastore.api.hive_metastoreConstants; -import org.apache.hadoop.hive.metastore.partition.spec.PartitionSpecProxy; -import org.apache.log4j.Logger; -import shade.doris.hive.org.apache.thrift.TException; - -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -/*** - * Delegate Class to provide all common functionality - * between Spark-hive version, Hive and Presto clients - * - */ -public class GlueMetastoreClientDelegate { - - private static final Logger logger = Logger.getLogger(GlueMetastoreClientDelegate.class); - - private static final List implicitRoles = Lists.newArrayList(new Role(PUBLIC, 0, PUBLIC)); - public static final int MILLISECOND_TO_SECOND_FACTOR = 1000; - public static final Long NO_MAX = -1L; - public static final String MATCH_ALL = ".*"; - private static final int BATCH_CREATE_PARTITIONS_MAX_REQUEST_SIZE = 100; - - private static final int NUM_EXECUTOR_THREADS = 5; - static final String GLUE_METASTORE_DELEGATE_THREADPOOL_NAME_FORMAT = "glue-metastore-delegate-%d"; - private static final ExecutorService GLUE_METASTORE_DELEGATE_THREAD_POOL = Executors.newFixedThreadPool( - NUM_EXECUTOR_THREADS, - new ThreadFactoryBuilder() - .setNameFormat(GLUE_METASTORE_DELEGATE_THREADPOOL_NAME_FORMAT) - .setDaemon(true).build() - ); - - private final AWSGlueMetastore glueMetastore; - private final Configuration conf; - private final Warehouse wh; - // private final AwsGlueHiveShims hiveShims = ShimsLoader.getHiveShims(); - private final CatalogToHiveConverter catalogToHiveConverter; - private final String catalogId; - - public static final String CATALOG_ID_CONF = "hive.metastore.glue.catalogid"; - public static final String NUM_PARTITION_SEGMENTS_CONF = "aws.glue.partition.num.segments"; - - public GlueMetastoreClientDelegate(Configuration conf, AWSGlueMetastore glueMetastore, - Warehouse wh) throws MetaException { - checkNotNull(conf, "Hive Config cannot be null"); - checkNotNull(glueMetastore, "glueMetastore cannot be null"); - checkNotNull(wh, "Warehouse cannot be null"); - - catalogToHiveConverter = CatalogToHiveConverterFactory.getCatalogToHiveConverter(); - this.conf = conf; - this.glueMetastore = glueMetastore; - this.wh = wh; - // TODO - May be validate catalogId confirms to AWS AccountId too. - catalogId = MetastoreClientUtils.getCatalogId(conf); - } - - // ======================= Database ======================= - - public void createDatabase(org.apache.hadoop.hive.metastore.api.Database database) throws TException { - checkNotNull(database, "database cannot be null"); - - if (StringUtils.isEmpty(database.getLocationUri())) { - database.setLocationUri(wh.getDefaultDatabasePath(database.getName()).toString()); - } else { - database.setLocationUri(wh.getDnsPath(new Path(database.getLocationUri())).toString()); - } - Path dbPath = new Path(database.getLocationUri()); - boolean madeDir = makeDirs(wh, dbPath); - - try { - DatabaseInput catalogDatabase = GlueInputConverter.convertToDatabaseInput(database); - glueMetastore.createDatabase(catalogDatabase); - } catch (AmazonServiceException e) { - if (madeDir) { - // hiveShims.deleteDir(wh, dbPath, true, false); - } - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to create database: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - public org.apache.hadoop.hive.metastore.api.Database getDatabase(String name) throws TException { - checkArgument(StringUtils.isNotEmpty(name), "name cannot be null or empty"); - - try { - Database catalogDatabase = glueMetastore.getDatabase(name); - return catalogToHiveConverter.convertDatabase(catalogDatabase); - } catch (AmazonServiceException e) { - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to get database object: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - public List getDatabases(String pattern) throws TException { - // Special handling for compatibility with Hue that passes "*" instead of ".*" - if (pattern == null || pattern.equals("*")) { - pattern = MATCH_ALL; - } - - try { - List ret = new ArrayList<>(); - - List allDatabases = glueMetastore.getAllDatabases(); - - //filter by pattern - for (Database db : allDatabases) { - String name = db.getName(); - if (Pattern.matches(pattern, name)) { - ret.add(name); - } - } - return ret; - } catch (AmazonServiceException e) { - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e){ - String msg = "Unable to get databases: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - public void alterDatabase(String databaseName, org.apache.hadoop.hive.metastore.api.Database database) throws TException { - checkArgument(StringUtils.isNotEmpty(databaseName), "databaseName cannot be null or empty"); - checkNotNull(database, "database cannot be null"); - - try { - DatabaseInput catalogDatabase = GlueInputConverter.convertToDatabaseInput(database); - glueMetastore.updateDatabase(databaseName, catalogDatabase); - } catch (AmazonServiceException e) { - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e){ - String msg = "Unable to alter database: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - public void dropDatabase(String name, boolean deleteData, boolean ignoreUnknownDb, boolean cascade) throws TException { - checkArgument(StringUtils.isNotEmpty(name), "name cannot be null or empty"); - - String dbLocation; - try { - List tables = getTables(name, MATCH_ALL); - boolean isEmptyDatabase = tables.isEmpty(); - - org.apache.hadoop.hive.metastore.api.Database db = getDatabase(name); - dbLocation = db.getLocationUri(); - - // TODO: handle cascade - if (isEmptyDatabase || cascade) { - glueMetastore.deleteDatabase(name); - } else { - throw new InvalidOperationException("Database " + name + " is not empty."); - } - } catch (NoSuchObjectException e) { - if (ignoreUnknownDb) { - return; - } else { - throw e; - } - } catch (AmazonServiceException e) { - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e){ - String msg = "Unable to drop database: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - - if (deleteData) { - try { - // hiveShims.deleteDir(wh, new Path(dbLocation), true, false); - } catch (Exception e) { - logger.error("Unable to remove database directory " + dbLocation, e); - } - } - } - - public boolean databaseExists(String dbName) throws TException { - checkArgument(StringUtils.isNotEmpty(dbName), "dbName cannot be null or empty"); - - try { - getDatabase(dbName); - } catch (NoSuchObjectException e) { - return false; - } catch (AmazonServiceException e) { - throw new TException(e); - } catch (Exception e) { - throw new MetaException(e.getMessage()); - } - return true; - } - - // ======================== Table ======================== - - public void createTable(org.apache.hadoop.hive.metastore.api.Table tbl) throws TException { - checkNotNull(tbl, "tbl cannot be null"); - boolean dirCreated = validateNewTableAndCreateDirectory(tbl); - try { - // Glue Server side does not set DDL_TIME. Set it here for the time being. - // TODO: Set DDL_TIME parameter in Glue service - tbl.setParameters(deepCopyMap(tbl.getParameters())); - tbl.getParameters().put(hive_metastoreConstants.DDL_TIME, - Long.toString(System.currentTimeMillis() / MILLISECOND_TO_SECOND_FACTOR)); - - TableInput tableInput = GlueInputConverter.convertToTableInput(tbl); - glueMetastore.createTable(tbl.getDbName(), tableInput); - } catch (AmazonServiceException e) { - if (dirCreated) { - Path tblPath = new Path(tbl.getSd().getLocation()); - // hiveShims.deleteDir(wh, tblPath, true, false); - } - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e){ - String msg = "Unable to create table: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - public boolean tableExists(String databaseName, String tableName) throws TException { - checkArgument(StringUtils.isNotEmpty(databaseName), "databaseName cannot be null or empty"); - checkArgument(StringUtils.isNotEmpty(tableName), "tableName cannot be null or empty"); - - if (!databaseExists(databaseName)) { - throw new UnknownDBException("Database: " + databaseName + " does not exist."); - } - try { - glueMetastore.getTable(databaseName, tableName); - return true; - } catch (EntityNotFoundException e) { - return false; - } catch (AmazonServiceException e){ - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e){ - String msg = "Unable to check table exist: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - public org.apache.hadoop.hive.metastore.api.Table getTable(String dbName, String tableName) throws TException { - checkArgument(StringUtils.isNotEmpty(dbName), "dbName cannot be null or empty"); - checkArgument(StringUtils.isNotEmpty(tableName), "tableName cannot be null or empty"); - - try { - Table table = glueMetastore.getTable(dbName, tableName); - validateGlueTable(table); - return catalogToHiveConverter.convertTable(table, dbName); - } catch (AmazonServiceException e) { - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to get table: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - public List getTables(String dbName, String tablePattern) throws TException { - return getGlueTables(dbName, tablePattern) - .stream() - .map(Table::getName) - .collect(Collectors.toList()); - } - - private List
    getGlueTables(String dbName, String tblPattern) throws TException { - checkArgument(StringUtils.isNotEmpty(dbName), "dbName cannot be null or empty"); - tblPattern = tblPattern.toLowerCase(); - try { - List
    tables = glueMetastore.getTables(dbName, tblPattern); - return tables; - } catch (AmazonServiceException e) { - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to get tables: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - public List getTableMeta( - String dbPatterns, - String tablePatterns, - List tableTypes - ) throws TException { - List tables = new ArrayList<>(); - List databases = getDatabases(dbPatterns); - for (String dbName : databases) { - String nextToken = null; - List
    dbTables = glueMetastore.getTables(dbName, tablePatterns); - for (Table catalogTable : dbTables) { - if (tableTypes == null || - tableTypes.isEmpty() || - tableTypes.contains(catalogTable.getTableType())) { - tables.add(catalogToHiveConverter.convertTableMeta(catalogTable, dbName)); - } - } - } - return tables; - } - - /* - * Hive reference: https://github.com/apache/hive/blob/rel/release-2.3.0/metastore/src/java/org/apache/hadoop/hive/metastore/HiveAlterHandler.java#L88 - */ - public void alterTable( - String dbName, - String oldTableName, - org.apache.hadoop.hive.metastore.api.Table newTable, - EnvironmentContext environmentContext - ) throws TException { - checkArgument(StringUtils.isNotEmpty(dbName), "dbName cannot be null or empty"); - checkArgument(StringUtils.isNotEmpty(oldTableName), "oldTableName cannot be null or empty"); - checkNotNull(newTable, "newTable cannot be null"); - - if (!oldTableName.equalsIgnoreCase(newTable.getTableName())) { - throw new UnsupportedOperationException("Table rename is not supported"); - } - - validateTableObject(newTable, conf); - if (!tableExists(dbName, oldTableName)) { - throw new UnknownTableException("Table: " + oldTableName + " does not exists"); - } - - // If table properties has EXTERNAL set, update table type accordinly - // mimics Hive's ObjectStore#convertToMTable, added in HIVE-1329 - boolean isExternal = Boolean.parseBoolean(newTable.getParameters().get("EXTERNAL")); - if (MANAGED_TABLE.toString().equals(newTable.getTableType()) && isExternal) { - newTable.setTableType(EXTERNAL_TABLE.toString()); - } else if (EXTERNAL_TABLE.toString().equals(newTable.getTableType()) && !isExternal) { - newTable.setTableType(MANAGED_TABLE.toString()); - } - - // if (hiveShims.requireCalStats(conf, null, null, newTable, environmentContext) && newTable.getPartitionKeys().isEmpty()) { - // //update table stats for non-partition Table - // org.apache.hadoop.hive.metastore.api.Database db = getDatabase(newTable.getDbName()); - // hiveShims.updateTableStatsFast(db, newTable, wh, false, true, environmentContext); - // } - - TableInput newTableInput = GlueInputConverter.convertToTableInput(newTable); - - try { - glueMetastore.updateTable(dbName, newTableInput, environmentContext); - } catch (AmazonServiceException e) { - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to alter table: " + oldTableName; - logger.error(msg, e); - throw new MetaException(msg + e); - } - - if (!newTable.getPartitionKeys().isEmpty() && isCascade(environmentContext)) { - logger.info("Only column related changes can be cascaded in alterTable."); - List partitions; - try { - partitions = getCatalogPartitions(dbName, oldTableName, null, -1); - } catch (TException e) { - String msg = "Failed to fetch partitions from metastore during alterTable cascade operation."; - logger.error(msg, e); - throw new MetaException(msg + e); - } - try { - partitions = partitions.parallelStream().unordered().distinct().collect(Collectors.toList()); // Remove duplicates - alterPartitionsColumnsParallel(dbName, oldTableName, partitions, newTableInput.getStorageDescriptor().getColumns()); - } catch (TException e) { - String msg = "Failed to alter partitions during alterTable cascade operation."; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - } - - private boolean isCascade(EnvironmentContext environmentContext) { - return environmentContext != null && - environmentContext.isSetProperties() && - StatsSetupConst.TRUE.equals(environmentContext.getProperties().get(StatsSetupConst.CASCADE)); - } - - public void dropTable( - String dbName, - String tableName, - boolean deleteData, - boolean ignoreUnknownTbl, - boolean ifPurge - ) throws TException { - checkArgument(StringUtils.isNotEmpty(dbName), "dbName cannot be null or empty"); - checkArgument(StringUtils.isNotEmpty(tableName), "tableName cannot be null or empty"); - - if (!tableExists(dbName, tableName)) { - if (!ignoreUnknownTbl) { - throw new UnknownTableException("Cannot find table: " + dbName + "." + tableName); - } else { - return; - } - } - - org.apache.hadoop.hive.metastore.api.Table tbl = getTable(dbName, tableName); - String tblLocation = tbl.getSd().getLocation(); - boolean isExternal = isExternalTable(tbl); - dropPartitionsForTable(dbName, tableName, deleteData && !isExternal); - - try { - glueMetastore.deleteTable(dbName, tableName); - } catch (AmazonServiceException e){ - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e){ - String msg = "Unable to drop table: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - - if (StringUtils.isNotEmpty(tblLocation) && deleteData && !isExternal) { - Path tblPath = new Path(tblLocation); - try { - // hiveShims.deleteDir(wh, tblPath, true, ifPurge); - } catch (Exception e){ - logger.error("Unable to remove table directory " + tblPath, e); - } - } - } - - private void dropPartitionsForTable(String dbName, String tableName, boolean deleteData) throws TException { - List partitionsToDelete = getPartitions(dbName, tableName, null, NO_MAX); - for (org.apache.hadoop.hive.metastore.api.Partition part : partitionsToDelete) { - dropPartition(dbName, tableName, part.getValues(), true, deleteData, false); - } - } - - public List getTables(String dbname, String tablePattern, TableType tableType) throws TException { - return getGlueTables(dbname, tablePattern) - .stream() - .filter(table -> tableType.toString().equals(table.getTableType())) - .map(Table::getName) - .collect(Collectors.toList()); - } - - public List listTableNamesByFilter(String dbName, String filter, short maxTables) throws TException { - throw new UnsupportedOperationException("listTableNamesByFilter is not supported"); - } - - /** - * @return boolean - * true -> directory created - * false -> directory not created - */ - public boolean validateNewTableAndCreateDirectory(org.apache.hadoop.hive.metastore.api.Table tbl) throws TException { - checkNotNull(tbl, "tbl cannot be null"); - if (tableExists(tbl.getDbName(), tbl.getTableName())) { - throw new AlreadyExistsException("Table " + tbl.getTableName() + " already exists."); - } - validateTableObject(tbl, conf); - - if (TableType.VIRTUAL_VIEW.toString().equals(tbl.getTableType())) { - // we don't need to create directory for virtual views - return false; - } - - // if (StringUtils.isEmpty(tbl.getSd().getLocation())) { - // org.apache.hadoop.hive.metastore.api.Database db = getDatabase(tbl.getDbName()); - // tbl.getSd().setLocation(hiveShims.getDefaultTablePath(db, tbl.getTableName(), wh).toString()); - // } else { - tbl.getSd().setLocation(wh.getDnsPath(new Path(tbl.getSd().getLocation())).toString()); - // } - - Path tblPath = new Path(tbl.getSd().getLocation()); - return makeDirs(wh, tblPath); - } - - // =========================== Partition =========================== - - public org.apache.hadoop.hive.metastore.api.Partition appendPartition( - String dbName, - String tblName, - List values - ) throws TException { - checkArgument(StringUtils.isNotEmpty(dbName), "dbName cannot be null or empty"); - checkArgument(StringUtils.isNotEmpty(tblName), "tblName cannot be null or empty"); - checkNotNull(values, "partition values cannot be null"); - org.apache.hadoop.hive.metastore.api.Table table = getTable(dbName, tblName); - checkNotNull(table.getSd(), "StorageDescriptor cannot be null for Table " + tblName); - org.apache.hadoop.hive.metastore.api.Partition partition = buildPartitionFromValues(table, values); - addPartitions(Lists.newArrayList(partition), false, true); - return partition; - } - - /** - * Taken from HiveMetaStore#append_partition_common - */ - private org.apache.hadoop.hive.metastore.api.Partition buildPartitionFromValues( - org.apache.hadoop.hive.metastore.api.Table table, List values) throws MetaException { - org.apache.hadoop.hive.metastore.api.Partition partition = new org.apache.hadoop.hive.metastore.api.Partition(); - partition.setDbName(table.getDbName()); - partition.setTableName(table.getTableName()); - partition.setValues(values); - partition.setSd(table.getSd().deepCopy()); - - Path partLocation = new Path(table.getSd().getLocation(), Warehouse.makePartName(table.getPartitionKeys(), values)); - partition.getSd().setLocation(partLocation.toString()); - - long timeInSecond = System.currentTimeMillis() / MILLISECOND_TO_SECOND_FACTOR; - partition.setCreateTime((int) timeInSecond); - partition.putToParameters(hive_metastoreConstants.DDL_TIME, Long.toString(timeInSecond)); - return partition; - } - - public List addPartitions( - List partitions, - boolean ifNotExists, - boolean needResult - ) throws TException { - checkNotNull(partitions, "partitions cannot be null"); - List partitionsCreated = batchCreatePartitions(partitions, ifNotExists); - if (!needResult) { - return null; - } - return catalogToHiveConverter.convertPartitions(partitionsCreated); - } - - private List batchCreatePartitions( - final List hivePartitions, - final boolean ifNotExists - ) throws TException { - if (hivePartitions.isEmpty()) { - return Lists.newArrayList(); - } - - final String dbName = hivePartitions.get(0).getDbName(); - final String tableName = hivePartitions.get(0).getTableName(); - org.apache.hadoop.hive.metastore.api.Table tbl = getTable(dbName, tableName); - validateInputForBatchCreatePartitions(tbl, hivePartitions); - - List catalogPartitions = Lists.newArrayList(); - Map addedPath = Maps.newHashMap(); - try { - for (org.apache.hadoop.hive.metastore.api.Partition partition : hivePartitions) { - Path location = getPartitionLocation(tbl, partition); - boolean partDirCreated = false; - if (location != null) { - partition.getSd().setLocation(location.toString()); - partDirCreated = makeDirs(wh, location); - } - Partition catalogPartition = HiveToCatalogConverter.convertPartition(partition); - catalogPartitions.add(catalogPartition); - if (partDirCreated) { - addedPath.put(new PartitionKey(catalogPartition), new Path(partition.getSd().getLocation())); - } - } - } catch (MetaException e) { - for (Path path : addedPath.values()) { - deletePath(path); - } - throw e; - } - - List> batchCreatePartitionsFutures = Lists.newArrayList(); - for (int i = 0; i < catalogPartitions.size(); i += BATCH_CREATE_PARTITIONS_MAX_REQUEST_SIZE) { - int j = Math.min(i + BATCH_CREATE_PARTITIONS_MAX_REQUEST_SIZE, catalogPartitions.size()); - final List partitionsOnePage = catalogPartitions.subList(i, j); - - batchCreatePartitionsFutures.add(GLUE_METASTORE_DELEGATE_THREAD_POOL.submit(new Callable() { - @Override - public BatchCreatePartitionsHelper call() throws Exception { - return new BatchCreatePartitionsHelper(glueMetastore, dbName, tableName, catalogId, partitionsOnePage, ifNotExists) - .createPartitions(); - } - })); - } - - TException tException = null; - List partitionsCreated = Lists.newArrayList(); - for (Future future : batchCreatePartitionsFutures) { - try { - BatchCreatePartitionsHelper batchCreatePartitionsHelper = future.get(); - partitionsCreated.addAll(batchCreatePartitionsHelper.getPartitionsCreated()); - tException = tException == null ? batchCreatePartitionsHelper.getFirstTException() : tException; - deletePathForPartitions(batchCreatePartitionsHelper.getPartitionsFailed(), addedPath); - } catch (Exception e) { - logger.error("Exception thrown by BatchCreatePartitions thread pool. ", e); - } - } - - if (tException != null) { - throw tException; - } - return partitionsCreated; - } - - private void validateInputForBatchCreatePartitions( - org.apache.hadoop.hive.metastore.api.Table tbl, - List hivePartitions) { - checkNotNull(tbl.getPartitionKeys(), "Partition keys cannot be null"); - for (org.apache.hadoop.hive.metastore.api.Partition partition : hivePartitions) { - checkArgument(tbl.getDbName().equals(partition.getDbName()), "Partitions must be in the same DB"); - checkArgument(tbl.getTableName().equals(partition.getTableName()), "Partitions must be in the same table"); - checkNotNull(partition.getValues(), "Partition values cannot be null"); - checkArgument(tbl.getPartitionKeys().size() == partition.getValues().size(), "Number of table partition keys must match number of partition values"); - } - } - - private void deletePathForPartitions(List partitions, Map addedPath) { - for (Partition partition : partitions) { - Path path = addedPath.get(new PartitionKey(partition)); - if (path != null) { - deletePath(path); - } - } - } - - private void deletePath(Path path) { - // try { - // hiveShims.deleteDir(wh, path, true, false); - // } catch (MetaException e) { - // logger.error("Warehouse delete directory failed. ", e); - // } - } - - /** - * Taken from HiveMetastore#createLocationForAddedPartition - */ - private Path getPartitionLocation( - org.apache.hadoop.hive.metastore.api.Table tbl, - org.apache.hadoop.hive.metastore.api.Partition part) throws MetaException { - Path partLocation = null; - String partLocationStr = null; - if (part.getSd() != null) { - partLocationStr = part.getSd().getLocation(); - } - - if (StringUtils.isEmpty(partLocationStr)) { - // set default location if not specified and this is - // a physical table partition (not a view) - if (tbl.getSd().getLocation() != null) { - partLocation = new Path(tbl.getSd().getLocation(), - Warehouse.makePartName(tbl.getPartitionKeys(), part.getValues())); - } - } else { - if (tbl.getSd().getLocation() == null) { - throw new MetaException("Cannot specify location for a view partition"); - } - partLocation = wh.getDnsPath(new Path(partLocationStr)); - } - return partLocation; - } - - public List listPartitionNames( - String databaseName, - String tableName, - List values, - short max - ) throws TException { - String expression = null; - org.apache.hadoop.hive.metastore.api.Table table = getTable(databaseName, tableName); - if (values != null) { - expression = ExpressionHelper.buildExpressionFromPartialSpecification(table, values); - } - - List names = Lists.newArrayList(); - List partitions = getPartitions(databaseName, tableName, expression, max); - for(org.apache.hadoop.hive.metastore.api.Partition p : partitions) { - names.add(Warehouse.makePartName(table.getPartitionKeys(), p.getValues())); - } - return names; - } - - public List getPartitionsByNames( - String databaseName, - String tableName, - List partitionNames - ) throws TException { - checkArgument(StringUtils.isNotEmpty(databaseName), "databaseName cannot be null or empty"); - checkArgument(StringUtils.isNotEmpty(tableName), "tableName cannot be null or empty"); - checkNotNull(partitionNames, "partitionNames cannot be null"); - - List partitionsToGet = Lists.newArrayList(); - for (String partitionName : partitionNames) { - partitionsToGet.add(new PartitionValueList().withValues(partitionNameToVals(partitionName))); - } - - try { - List partitions = glueMetastore.getPartitionsByNames(databaseName, tableName, partitionsToGet); - return catalogToHiveConverter.convertPartitions(partitions); - } catch (AmazonServiceException e) { - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to get partition by names: " + StringUtils.join(partitionNames, "/"); - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - public org.apache.hadoop.hive.metastore.api.Partition getPartition(String dbName, String tblName, String partitionName) - throws TException { - checkArgument(StringUtils.isNotEmpty(dbName), "dbName cannot be null or empty"); - checkArgument(StringUtils.isNotEmpty(tblName), "tblName cannot be null or empty"); - checkArgument(StringUtils.isNotEmpty(partitionName), "partitionName cannot be null or empty"); - List values = partitionNameToVals(partitionName); - return getPartition(dbName, tblName, values); - } - - public org.apache.hadoop.hive.metastore.api.Partition getPartition(String dbName, String tblName, List values) throws TException { - checkArgument(StringUtils.isNotEmpty(dbName), "dbName cannot be null or empty"); - checkArgument(StringUtils.isNotEmpty(tblName), "tblName cannot be null or empty"); - checkNotNull(values, "values cannot be null"); - - Partition partition; - try { - partition = glueMetastore.getPartition(dbName, tblName, values); - if (partition == null) { - logger.debug("No partitions were return for dbName = " + dbName + ", tblName = " + tblName + ", values = " + values); - return null; - } - } catch (AmazonServiceException e) { - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to get partition with values: " + StringUtils.join(values, "/"); - logger.error(msg, e); - throw new MetaException(msg + e); - } - return catalogToHiveConverter.convertPartition(partition); - } - - public List getPartitions( - String databaseName, - String tableName, - String filter, - long max - ) throws TException { - checkArgument(StringUtils.isNotEmpty(databaseName), "databaseName cannot be null or empty"); - checkArgument(StringUtils.isNotEmpty(tableName), "tableName cannot be null or empty"); - List partitions = getCatalogPartitions(databaseName, tableName, filter, max); - return catalogToHiveConverter.convertPartitions(partitions); - } - - public List getCatalogPartitions( - final String databaseName, - final String tableName, - final String expression, - final long max - ) throws TException { - checkArgument(StringUtils.isNotEmpty(databaseName), "databaseName cannot be null or empty"); - checkArgument(StringUtils.isNotEmpty(tableName), "tableName cannot be null or empty"); - try{ - return glueMetastore.getPartitions(databaseName, tableName, expression, max); - } catch (AmazonServiceException e) { - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to get partitions with expression: " + expression; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - private void alterPartitionsColumnsParallel( - final String databaseName, - final String tableName, - List partitions, - List newCols) throws TException { - List> partitionFuturePairs = Collections.synchronizedList(Lists.newArrayList()); - partitions.parallelStream().forEach(partition -> partitionFuturePairs.add(Pair.of(partition, - (GLUE_METASTORE_DELEGATE_THREAD_POOL.submit( - () -> alterPartitionColumns(databaseName, tableName, partition, newCols)))))); - - List> failedPartitionValues = new ArrayList<>(); - // Wait for completion results - for (Pair partitionFuturePair : partitionFuturePairs) { - try { - partitionFuturePair.getRight().get(); - } catch (ExecutionException e) { - String msg = - "Failed while attempting to alterPartition: " + partitionFuturePair.getLeft().getValues() + ". Because of: "; - logger.error(msg, e); - failedPartitionValues.add(partitionFuturePair.getLeft().getValues()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - if (!failedPartitionValues.isEmpty()) { - throw new MetaException("AlterPartitions has failed for the partitions: " + failedPartitionValues); - } - } - - private void alterPartitionColumns( - String databaseName, - String tableName, - Partition origPartition, - List newCols) { - origPartition.setParameters(deepCopyMap(origPartition.getParameters())); - if (origPartition.getParameters().get(hive_metastoreConstants.DDL_TIME) == null || - Integer.parseInt(origPartition.getParameters().get(hive_metastoreConstants.DDL_TIME)) == 0) { - origPartition.getParameters().put(hive_metastoreConstants.DDL_TIME, Long.toString(System.currentTimeMillis() / MILLISECOND_TO_SECOND_FACTOR)); - } - origPartition.getStorageDescriptor().setColumns(newCols); - PartitionInput partitionInput = GlueInputConverter.convertToPartitionInput(origPartition); - glueMetastore.updatePartition(databaseName, tableName, origPartition.getValues(), partitionInput); - } - - public boolean dropPartition( - String dbName, - String tblName, - Listvalues, - boolean ifExist, - boolean deleteData, - boolean purgeData - ) throws TException { - checkArgument(StringUtils.isNotEmpty(dbName), "dbName cannot be null or empty"); - checkArgument(StringUtils.isNotEmpty(tblName), "tblName cannot be null or empty"); - checkNotNull(values, "values cannot be null"); - - org.apache.hadoop.hive.metastore.api.Partition partition = null; - try { - partition = getPartition(dbName, tblName, values); - } catch (NoSuchObjectException e) { - if (ifExist) { - return true; - } - } - - try { - glueMetastore.deletePartition(dbName, tblName, partition.getValues()); - } catch (AmazonServiceException e) { - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to drop partition with values: " + StringUtils.join(values, "/"); - logger.error(msg, e); - throw new MetaException(msg + e); - } - - performDropPartitionPostProcessing(dbName, tblName, partition, deleteData, purgeData); - return true; - } - - private void performDropPartitionPostProcessing( - String dbName, - String tblName, - org.apache.hadoop.hive.metastore.api.Partition partition, - boolean deleteData, - boolean ifPurge - ) throws TException { - if (deleteData && partition.getSd() != null && partition.getSd().getLocation() != null) { - Path partPath = new Path(partition.getSd().getLocation()); - org.apache.hadoop.hive.metastore.api.Table table = getTable(dbName, tblName); - if (isExternalTable(table)) { - //Don't delete external table data - return; - } - boolean mustPurge = isMustPurge(table, ifPurge); - // hiveShims.deleteDir(wh, partPath, true, mustPurge); - try { - List values = partition.getValues(); - deleteParentRecursive(partPath.getParent(), values.size() - 1, mustPurge); - } catch (IOException e) { - throw new MetaException(e.getMessage()); - } - } - } - - /** - * Taken from HiveMetaStore#isMustPurge - */ - private boolean isMustPurge(org.apache.hadoop.hive.metastore.api.Table table, boolean ifPurge) { - return (ifPurge || "true".equalsIgnoreCase(table.getParameters().get("auto.purge"))); - } - - /** - * Taken from HiveMetaStore#deleteParentRecursive - */ - private void deleteParentRecursive(Path parent, int depth, boolean mustPurge) throws IOException, MetaException { - if (depth > 0 && parent != null && wh.isWritable(parent) && wh.isEmpty(parent)) { - // hiveShims.deleteDir(wh, parent, true, mustPurge); - deleteParentRecursive(parent.getParent(), depth - 1, mustPurge); - } - } - - public void alterPartitions( - String dbName, - String tblName, - List partitions - ) throws TException { - checkArgument(StringUtils.isNotEmpty(dbName), "dbName cannot be null or empty"); - checkArgument(StringUtils.isNotEmpty(tblName), "tblName cannot be null or empty"); - checkNotNull(partitions, "partitions cannot be null"); - - for (org.apache.hadoop.hive.metastore.api.Partition part : partitions) { - part.setParameters(deepCopyMap(part.getParameters())); - if (part.getParameters().get(hive_metastoreConstants.DDL_TIME) == null || - Integer.parseInt(part.getParameters().get(hive_metastoreConstants.DDL_TIME)) == 0) { - part.putToParameters(hive_metastoreConstants.DDL_TIME, Long.toString(System.currentTimeMillis() / MILLISECOND_TO_SECOND_FACTOR)); - } - - PartitionInput partitionInput = GlueInputConverter.convertToPartitionInput(part); - try { - glueMetastore.updatePartition(dbName, tblName, part.getValues(), partitionInput); - } catch (AmazonServiceException e) { - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to alter partition: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - } - - /** - * Taken from HiveMetaStore#partition_name_to_vals - */ - public List partitionNameToVals(String name) throws TException { - checkNotNull(name, "name cannot be null"); - if (name.isEmpty()) { - return Lists.newArrayList(); - } - LinkedHashMap map = Warehouse.makeSpecFromName(name); - List vals = Lists.newArrayList(); - vals.addAll(map.values()); - return vals; - } - - // ======================= Roles & Privilege ======================= - - public boolean createRole(org.apache.hadoop.hive.metastore.api.Role role) throws TException { - throw new UnsupportedOperationException("createRole is not supported"); - } - - public boolean dropRole(String roleName) throws TException { - throw new UnsupportedOperationException("dropRole is not supported"); - } - - public List listRoles( - String principalName, - org.apache.hadoop.hive.metastore.api.PrincipalType principalType - ) throws TException { - // All users belong to public role implicitly, add that role - // Bring logic from Hive's ObjectStore - if (principalType == PrincipalType.USER) { - return implicitRoles; - } else { - throw new UnsupportedOperationException( - "listRoles is only supported for " + PrincipalType.USER + " Principal type"); - } - } - - public List listRoleNames() throws TException { - // return PUBLIC role as implicit role to prevent unnecessary failure, - // even though Glue doesn't support Role API yet - return Lists.newArrayList(PUBLIC); - } - - public org.apache.hadoop.hive.metastore.api.GetPrincipalsInRoleResponse getPrincipalsInRole( - org.apache.hadoop.hive.metastore.api.GetPrincipalsInRoleRequest request - ) throws TException { - throw new UnsupportedOperationException("getPrincipalsInRole is not supported"); - } - - public GetRoleGrantsForPrincipalResponse getRoleGrantsForPrincipal( - GetRoleGrantsForPrincipalRequest request - ) throws TException { - throw new UnsupportedOperationException("getRoleGrantsForPrincipal is not supported"); - } - - public boolean grantRole( - String roleName, - String userName, - org.apache.hadoop.hive.metastore.api.PrincipalType principalType, - String grantor, org.apache.hadoop.hive.metastore.api.PrincipalType grantorType, - boolean grantOption - ) throws TException { - throw new UnsupportedOperationException("grantRole is not supported"); - } - - public boolean revokeRole( - String roleName, - String userName, - org.apache.hadoop.hive.metastore.api.PrincipalType principalType, - boolean grantOption - ) throws TException { - throw new UnsupportedOperationException("revokeRole is not supported"); - } - - public boolean revokePrivileges( - org.apache.hadoop.hive.metastore.api.PrivilegeBag privileges, - boolean grantOption - ) throws TException { - throw new UnsupportedOperationException("revokePrivileges is not supported"); - } - - public boolean grantPrivileges(org.apache.hadoop.hive.metastore.api.PrivilegeBag privileges) - throws TException { - throw new UnsupportedOperationException("grantPrivileges is not supported"); - } - - public org.apache.hadoop.hive.metastore.api.PrincipalPrivilegeSet getPrivilegeSet( - HiveObjectRef objectRef, - String user, List groups - ) throws TException { - // getPrivilegeSet is NOT yet supported. - // return null not to break due to optional info - // Hive return null when every condition fail - return null; - } - - public List listPrivileges( - String principal, - org.apache.hadoop.hive.metastore.api.PrincipalType principalType, - HiveObjectRef objectRef - ) throws TException { - throw new UnsupportedOperationException("listPrivileges is not supported"); - } - - // ========================== Statistics ========================== - - public boolean deletePartitionColumnStatistics(String dbName, String tblName, String partName, String colName) - throws TException { - checkArgument(!StringUtils.isEmpty(dbName), "Database name cannot be equal to null or empty"); - checkArgument(!StringUtils.isEmpty(tblName), "Table name cannot be equal to null or empty"); - checkArgument(!StringUtils.isEmpty(colName), "Column name cannot be equal to null or empty"); - List partValues = PartitionNameParser.getPartitionValuesFromName(partName); - for (String partitionValue : partValues) { - checkArgument(!StringUtils.isEmpty(partitionValue), "Partition name cannot be equal to null or empty"); - } - try { - glueMetastore.deletePartitionColumnStatistics(dbName, tblName, partValues, colName); - return true; - } catch (AmazonServiceException e) { - logger.error(e.getMessage(), e); - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to delete partition column statistics: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - public boolean deleteTableColumnStatistics(String dbName, String tblName, String colName) throws TException { - checkArgument(!StringUtils.isEmpty(dbName), "Database name cannot be equal to null or empty"); - checkArgument(!StringUtils.isEmpty(tblName), "Table name cannot be equal to null or empty"); - checkArgument(!StringUtils.isEmpty(colName), "Column name cannot be equal to null or empty"); - - try { - glueMetastore.deleteTableColumnStatistics(dbName, tblName, colName); - return true; - } catch (AmazonServiceException e) { - logger.error(e.getMessage(), e); - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to delete table column statistics: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - public Map> getPartitionColumnStatistics(String dbName, String tblName, - List partNames, - List colNames) throws TException { - checkArgument(!StringUtils.isEmpty(dbName), "Database name cannot be equal to null or empty"); - checkArgument(!StringUtils.isEmpty(tblName), "Table name cannot be equal to null or empty"); - for (String partitionName : partNames) { - checkArgument(!StringUtils.isEmpty(partitionName), "Partition name cannot be equal to null or empty"); - } - for (String columnName : colNames) { - checkArgument(!StringUtils.isEmpty(columnName), "Column name cannot be equal to null or empty"); - } - - Map> hivePartitionStatistics = new HashMap<>(); - List hiveResult = new ArrayList<>(); - Map> columnStatisticsMap = glueMetastore.getPartitionColumnStatistics(dbName, tblName, partNames, colNames); - columnStatisticsMap.forEach((partName, statistic) -> { - hivePartitionStatistics.put(partName, catalogToHiveConverter.convertColumnStatisticsList(statistic)); - }); - - return hivePartitionStatistics; - } - - public List getTableColumnStatistics(String dbName, String tblName, List colNames) - throws TException { - checkArgument(!StringUtils.isEmpty(dbName), "Database name cannot be equal to null or empty"); - checkArgument(!StringUtils.isEmpty(tblName), "Table name cannot be equal to null or empty"); - for (String columnName : colNames) { - checkArgument(!StringUtils.isEmpty(columnName), "Column name cannot be equal to null or empty"); - } - - List tableStats = glueMetastore.getTableColumnStatistics(dbName, tblName, colNames); - List results = catalogToHiveConverter.convertColumnStatisticsList(tableStats); - - return results; - } - - public boolean updatePartitionColumnStatistics(org.apache.hadoop.hive.metastore.api.ColumnStatistics columnStatistics) - throws TException { - String dbName = columnStatistics.getStatsDesc().getDbName(); - String tblName = columnStatistics.getStatsDesc().getTableName(); - List partValues = PartitionNameParser.getPartitionValuesFromName(columnStatistics.getStatsDesc().getPartName()); - List statisticsList = HiveToCatalogConverter.convertColumnStatisticsObjList(columnStatistics); - - checkArgument(!StringUtils.isEmpty(dbName), "Database name cannot be equal to null or empty"); - checkArgument(!StringUtils.isEmpty(tblName), "Table name cannot be equal to null or empty"); - checkArgument(statisticsList != null && !statisticsList.isEmpty(), "List of column statistics objects cannot be " + - "equal to null or empty"); - for (String partitionValue : partValues) { - checkArgument(!StringUtils.isEmpty(partitionValue), "Partition name cannot be equal to null or empty"); - } - for (ColumnStatistics statistics : statisticsList) { - checkArgument(statistics != null, "Column statistics object cannot be equal to null"); - } - - // Waiting for calls to finish. Will fail the call if one of the future task fails - List columnStatisticsErrors = - glueMetastore.updatePartitionColumnStatistics(dbName, tblName, partValues, statisticsList); - - if (columnStatisticsErrors.size() > 0) { - logger.error("Cannot update all provided column statistics. List of failures: " + columnStatisticsErrors); - return false; - } else { - return true; - } - } - - public boolean updateTableColumnStatistics(org.apache.hadoop.hive.metastore.api.ColumnStatistics columnStatistics) - throws TException { - String dbName = columnStatistics.getStatsDesc().getDbName(); - String tblName = columnStatistics.getStatsDesc().getTableName(); - List statisticsList = HiveToCatalogConverter.convertColumnStatisticsObjList(columnStatistics); - - checkArgument(!StringUtils.isEmpty(dbName), "Database name cannot be equal to null or empty"); - checkArgument(!StringUtils.isEmpty(tblName), "Table name cannot be equal to null or empty"); - checkArgument(statisticsList != null && !statisticsList.isEmpty(), "List of column statistics objects cannot be " + - "equal to null or empty"); - for (ColumnStatistics statistics : statisticsList) { - checkArgument(statistics != null, "Column statistics object cannot be equal to null"); - } - - // Waiting for calls to finish. Will fail the call if one of the future task fails - List columnStatisticsErrors = - glueMetastore.updateTableColumnStatistics(dbName, tblName, statisticsList); - if (columnStatisticsErrors.size() > 0) { - logger.error("Cannot update all provided column statistics. List of failures: " + columnStatisticsErrors.toString()); - return false; - } else { - return true; - } - } - - public AggrStats getAggrColStatsFor( - String dbName, - String tblName, - List colNames, - List partName - ) throws TException { - throw new UnsupportedOperationException("getAggrColStatsFor is not supported"); - } - - public void cancelDelegationToken(String tokenStrForm) throws TException { - throw new UnsupportedOperationException("cancelDelegationToken is not supported"); - } - - public String getTokenStrForm() throws IOException { - throw new UnsupportedOperationException("getTokenStrForm is not supported"); - } - - public boolean addToken(String tokenIdentifier, String delegationToken) throws TException { - throw new UnsupportedOperationException("addToken is not supported"); - } - - public boolean removeToken(String tokenIdentifier) throws TException { - throw new UnsupportedOperationException("removeToken is not supported"); - } - - public String getToken(String tokenIdentifier) throws TException { - throw new UnsupportedOperationException("getToken is not supported"); - } - - public List getAllTokenIdentifiers() throws TException { - throw new UnsupportedOperationException("getAllTokenIdentifiers is not supported"); - } - - public int addMasterKey(String key) throws TException { - throw new UnsupportedOperationException("addMasterKey is not supported"); - } - - public void updateMasterKey(Integer seqNo, String key) throws TException { - throw new UnsupportedOperationException("updateMasterKey is not supported"); - } - - public boolean removeMasterKey(Integer keySeq) throws TException { - throw new UnsupportedOperationException("removeMasterKey is not supported"); - } - - public String[] getMasterKeys() throws TException { - throw new UnsupportedOperationException("getMasterKeys is not supported"); - } - - public LockResponse checkLock(long lockId) throws TException { - throw new UnsupportedOperationException("checkLock is not supported"); - } - - public void commitTxn(long txnId) throws TException { - throw new UnsupportedOperationException("commitTxn is not supported"); - } - - public void replCommitTxn(long srcTxnid, String replPolicy) { - throw new UnsupportedOperationException("replCommitTxn is not supported"); - } - - public void abortTxns(List txnIds) throws TException { - throw new UnsupportedOperationException("abortTxns is not supported"); - } - - public void compact( - String dbName, - String tblName, - String partitionName, - CompactionType compactionType - ) throws TException { - throw new UnsupportedOperationException("compact is not supported"); - } - - public void compact( - String dbName, - String tblName, - String partitionName, - CompactionType compactionType, - Map tblProperties - ) throws TException { - throw new UnsupportedOperationException("compact is not supported"); - } - - public CompactionResponse compact2( - String dbName, - String tblName, - String partitionName, - CompactionType compactionType, - Map tblProperties - ) throws TException { - throw new UnsupportedOperationException("compact2 is not supported"); - } - - public ValidTxnList getValidTxns() throws TException { - throw new UnsupportedOperationException("getValidTxns is not supported"); - } - - public ValidTxnList getValidTxns(long currentTxn) throws TException { - throw new UnsupportedOperationException("getValidTxns is not supported"); - } - - public org.apache.hadoop.hive.metastore.api.Partition exchangePartition( - Map partitionSpecs, - String srcDb, - String srcTbl, - String dstDb, - String dstTbl - ) throws TException { - throw new UnsupportedOperationException("exchangePartition not yet supported."); - } - - public List exchangePartitions( - Map partitionSpecs, - String sourceDb, - String sourceTbl, - String destDb, - String destTbl - ) throws TException { - throw new UnsupportedOperationException("exchangePartitions is not yet supported"); - } - - public String getDelegationToken( - String owner, - String renewerKerberosPrincipalName - ) throws TException { - throw new UnsupportedOperationException("getDelegationToken is not supported"); - } - - public void heartbeat(long txnId, long lockId) throws TException { - throw new UnsupportedOperationException("heartbeat is not supported"); - } - - public HeartbeatTxnRangeResponse heartbeatTxnRange(long min, long max) throws TException { - throw new UnsupportedOperationException("heartbeatTxnRange is not supported"); - } - - public boolean isPartitionMarkedForEvent( - String dbName, - String tblName, - Map partKVs, - PartitionEventType eventType - ) throws TException { - throw new UnsupportedOperationException("isPartitionMarkedForEvent is not supported"); - } - - public PartitionValuesResponse listPartitionValues( - PartitionValuesRequest partitionValuesRequest - ) throws TException { - throw new UnsupportedOperationException("listPartitionValues is not yet supported"); - } - - public int getNumPartitionsByFilter( - String dbName, - String tableName, - String filter - ) throws TException { - throw new UnsupportedOperationException("getNumPartitionsByFilter is not supported."); - } - - public PartitionSpecProxy listPartitionSpecs( - String dbName, - String tblName, - int max - ) throws TException { - throw new UnsupportedOperationException("listPartitionSpecs is not supported."); - } - - public PartitionSpecProxy listPartitionSpecsByFilter( - String dbName, - String tblName, - String filter, - int max - ) throws TException { - throw new UnsupportedOperationException("listPartitionSpecsByFilter is not supported"); - } - - public LockResponse lock(LockRequest lockRequest) throws TException { - throw new UnsupportedOperationException("lock is not supported"); - } - - public void markPartitionForEvent( - String dbName, - String tblName, - Map partKeyValues, - PartitionEventType eventType - ) throws TException { - throw new UnsupportedOperationException("markPartitionForEvent is not supported"); - } - - public long openTxn(String user) throws TException { - throw new UnsupportedOperationException("openTxn is not supported"); - } - - public OpenTxnsResponse openTxns(String user, int numTxns) throws TException { - throw new UnsupportedOperationException("openTxns is not supported"); - } - - public long renewDelegationToken(String tokenStrForm) throws TException { - throw new UnsupportedOperationException("renewDelegationToken is not supported"); - } - - public void rollbackTxn(long txnId) throws TException { - throw new UnsupportedOperationException("rollbackTxn is not supported"); - } - - public void createTableWithConstraints( - org.apache.hadoop.hive.metastore.api.Table table, - List primaryKeys, - List foreignKeys - ) throws AlreadyExistsException, TException { - throw new UnsupportedOperationException("createTableWithConstraints is not supported"); - } - - public void dropConstraint( - String dbName, - String tblName, - String constraintName - ) throws TException { - throw new UnsupportedOperationException("dropConstraint is not supported"); - } - - public void addPrimaryKey(List primaryKeyCols) throws TException { - throw new UnsupportedOperationException("addPrimaryKey is not supported"); - } - - public void addForeignKey(List foreignKeyCols) throws TException { - throw new UnsupportedOperationException("addForeignKey is not supported"); - } - - public ShowCompactResponse showCompactions() throws TException { - throw new UnsupportedOperationException("showCompactions is not supported"); - } - - public void addDynamicPartitions( - long txnId, - String dbName, - String tblName, - List partNames - ) throws TException { - throw new UnsupportedOperationException("addDynamicPartitions is not supported"); - } - - public void addDynamicPartitions( - long txnId, - String dbName, - String tblName, - List partNames, - DataOperationType operationType - ) throws TException { - throw new UnsupportedOperationException("addDynamicPartitions is not supported"); - } - - public void insertTable(org.apache.hadoop.hive.metastore.api.Table table, boolean overwrite) throws MetaException { - throw new UnsupportedOperationException("insertTable is not supported"); - } - - public NotificationEventResponse getNextNotification( - long lastEventId, - int maxEvents, - IMetaStoreClient.NotificationFilter notificationFilter - ) throws TException { - throw new UnsupportedOperationException("getNextNotification is not supported"); - } - - public CurrentNotificationEventId getCurrentNotificationEventId() throws TException { - throw new UnsupportedOperationException("getCurrentNotificationEventId is not supported"); - } - - public FireEventResponse fireListenerEvent(FireEventRequest fireEventRequest) throws TException { - throw new UnsupportedOperationException("fireListenerEvent is not supported"); - } - - public ShowLocksResponse showLocks() throws TException { - throw new UnsupportedOperationException("showLocks is not supported"); - } - - public ShowLocksResponse showLocks(ShowLocksRequest showLocksRequest) throws TException { - throw new UnsupportedOperationException("showLocks is not supported"); - } - - public GetOpenTxnsInfoResponse showTxns() throws TException { - throw new UnsupportedOperationException("showTxns is not supported"); - } - - public void unlock(long lockId) throws TException { - throw new UnsupportedOperationException("unlock is not supported"); - } - - public Iterable> getFileMetadata(List fileIds) throws TException { - throw new UnsupportedOperationException("getFileMetadata is not supported"); - } - - public Iterable> getFileMetadataBySarg( - List fileIds, - ByteBuffer sarg, - boolean doGetFooters - ) throws TException { - throw new UnsupportedOperationException("getFileMetadataBySarg is not supported"); - } - - public void clearFileMetadata(List fileIds) throws TException { - throw new UnsupportedOperationException("clearFileMetadata is not supported"); - } - - public void putFileMetadata(List fileIds, List metadata) throws TException { - throw new UnsupportedOperationException("putFileMetadata is not supported"); - } - - public boolean setPartitionColumnStatistics(org.apache.hadoop.hive.metastore.api.SetPartitionsStatsRequest request) - throws TException { - for (org.apache.hadoop.hive.metastore.api.ColumnStatistics colStat : request.getColStats()) { - if (colStat.getStatsDesc().getPartName() != null) { - updatePartitionColumnStatistics(colStat); - } else { - updateTableColumnStatistics(colStat); - } - } - return true; - } - - public boolean cacheFileMetadata( - String dbName, - String tblName, - String partName, - boolean allParts - ) throws TException { - throw new UnsupportedOperationException("cacheFileMetadata is not supported"); - } - - public int addPartitionsSpecProxy(PartitionSpecProxy pSpec) throws TException { - throw new UnsupportedOperationException("addPartitionsSpecProxy is unsupported"); - } - - public void setUGI(String username) throws TException { - throw new UnsupportedOperationException("setUGI is unsupported"); - } - - /** - * Gets the user defined function in a database stored in metastore and - * converts back to Hive function. - * - * @param dbName - * @param functionName - * @return - * @throws MetaException - * @throws TException - */ - public org.apache.hadoop.hive.metastore.api.Function getFunction(String dbName, String functionName) - throws TException { - try { - UserDefinedFunction result = glueMetastore.getUserDefinedFunction(dbName, functionName); - return catalogToHiveConverter.convertFunction(dbName, result); - } catch (AmazonServiceException e) { - logger.error(e); - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to get Function: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - /** - * Gets user defined functions that match a pattern in database stored in - * metastore and converts back to Hive function. - * - * @param dbName - * @param pattern - * @return - * @throws MetaException - * @throws TException - */ - public List getFunctions(String dbName, String pattern) throws TException { - if (conf.getBoolean(AWS_GLUE_DISABLE_UDF, false)) { - return new ArrayList<>(); - } - try { - List functionNames = Lists.newArrayList(); - List functions = - glueMetastore.getUserDefinedFunctions(dbName, pattern); - for (UserDefinedFunction catalogFunction : functions) { - functionNames.add(catalogFunction.getFunctionName()); - } - return functionNames; - } catch (AmazonServiceException e) { - logger.error(e); - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to get Functions: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - /** - * Gets all the user defined functions and converts back to Hive function. - * - * @return - * @throws MetaException - * @throws TException - */ - public GetAllFunctionsResponse getAllFunctions() throws MetaException, TException { - throw new TException("Not implement yet"); - // List result = new ArrayList<>(); - - // try { - // List catalogFunctions = glueMetastore.getUserDefinedFunctions(".*"); - // - // for (UserDefinedFunction catalogFunction : catalogFunctions) { - // result.add(catalogToHiveConverter.convertFunction(catalogFunction.getDatabaseName(), catalogFunction)); - // } - // } catch (AmazonServiceException e) { - // logger.error(e); - // throw catalogToHiveConverter.wrapInHiveException(e); - // } catch (Exception e) { - // String msg = "Unable to get Functions: "; - // logger.error(msg, e); - // throw new MetaException(msg + e); - // } - - // GetAllFunctionsResponse response = new GetAllFunctionsResponse(); - // response.setFunctions(result); - // return response; - } - - /** - * Creates a new user defined function in the metastore. - * - * @param function - * @throws InvalidObjectException - * @throws MetaException - * @throws TException - */ - public void createFunction(org.apache.hadoop.hive.metastore.api.Function function) throws InvalidObjectException, - TException { - try { - UserDefinedFunctionInput functionInput = GlueInputConverter.convertToUserDefinedFunctionInput(function); - glueMetastore.createUserDefinedFunction(function.getDbName(), functionInput); - } catch (AmazonServiceException e) { - logger.error(e); - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to create Function: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - /** - * Drops a user defined function in the database stored in metastore. - * - * @param dbName - * @param functionName - * @throws MetaException - * @throws NoSuchObjectException - * @throws InvalidObjectException - * @throws org.apache.hadoop.hive.metastore.api.InvalidInputException - * @throws TException - */ - public void dropFunction(String dbName, String functionName) throws NoSuchObjectException, - InvalidObjectException, org.apache.hadoop.hive.metastore.api.InvalidInputException, TException { - try { - glueMetastore.deleteUserDefinedFunction(dbName, functionName); - } catch (AmazonServiceException e) { - logger.error(e); - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to drop Function: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - /** - * Updates a user defined function in a database stored in the metastore. - * - * @param dbName - * @param functionName - * @param newFunction - * @throws InvalidObjectException - * @throws MetaException - * @throws TException - */ - public void alterFunction(String dbName, String functionName, - org.apache.hadoop.hive.metastore.api.Function newFunction) throws InvalidObjectException, MetaException, - TException { - try { - UserDefinedFunctionInput functionInput = GlueInputConverter.convertToUserDefinedFunctionInput(newFunction); - glueMetastore.updateUserDefinedFunction(dbName, functionName, functionInput); - } catch (AmazonServiceException e) { - logger.error(e); - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to alter Function: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - /** - * Fetches the fields for a table in a database. - * - * @param db - * @param tableName - * @return - * @throws MetaException - * @throws TException - * @throws UnknownTableException - * @throws UnknownDBException - */ - public List getFields(String db, String tableName) throws MetaException, TException, - UnknownTableException, UnknownDBException { - try { - Table table = glueMetastore.getTable(db, tableName); - return catalogToHiveConverter.convertFieldSchemaList(table.getStorageDescriptor().getColumns()); - } catch (AmazonServiceException e) { - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to get field from table: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - /** - * Fetches the schema for a table in a database. - * - * @param db - * @param tableName - * @return - * @throws MetaException - * @throws TException - * @throws UnknownTableException - * @throws UnknownDBException - */ - public List getSchema(String db, String tableName) throws TException, - UnknownTableException, UnknownDBException { - try { - Table table = glueMetastore.getTable(db, tableName); - List schemas = table.getStorageDescriptor().getColumns(); - if (table.getPartitionKeys() != null && !table.getPartitionKeys().isEmpty()) { - schemas.addAll(table.getPartitionKeys()); - } - return catalogToHiveConverter.convertFieldSchemaList(schemas); - } catch (AmazonServiceException e) { - throw catalogToHiveConverter.wrapInHiveException(e); - } catch (Exception e) { - String msg = "Unable to get field from table: "; - logger.error(msg, e); - throw new MetaException(msg + e); - } - } - - /** - * Updates the partition values for a table in database stored in metastore. - * - * @param databaseName - * @param tableName - * @param partitionValues - * @param newPartition - * @throws InvalidOperationException - * @throws MetaException - * @throws TException - */ - public void renamePartitionInCatalog(String databaseName, String tableName, List partitionValues, - org.apache.hadoop.hive.metastore.api.Partition newPartition) throws InvalidOperationException, - TException { - try { - PartitionInput partitionInput = GlueInputConverter.convertToPartitionInput(newPartition); - glueMetastore.updatePartition(databaseName, tableName, partitionValues, partitionInput); - } catch (AmazonServiceException e) { - throw catalogToHiveConverter.wrapInHiveException(e); - } - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/SessionCredentialsProviderFactory.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/SessionCredentialsProviderFactory.java deleted file mode 100644 index c4672621f61189..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/metastore/SessionCredentialsProviderFactory.java +++ /dev/null @@ -1,56 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.metastore; - -import com.amazonaws.auth.AWSCredentialsProvider; -import com.amazonaws.auth.AWSSessionCredentials; -import com.amazonaws.auth.BasicSessionCredentials; -import com.amazonaws.internal.StaticCredentialsProvider; - -import org.apache.hadoop.conf.Configuration; - -import static com.google.common.base.Preconditions.checkArgument; - -public class SessionCredentialsProviderFactory implements AWSCredentialsProviderFactory { - - public final static String AWS_ACCESS_KEY_CONF_VAR = "hive.aws_session_access_id"; - public final static String AWS_SECRET_KEY_CONF_VAR = "hive.aws_session_secret_key"; - public final static String AWS_SESSION_TOKEN_CONF_VAR = "hive.aws_session_token"; - - @Override - public AWSCredentialsProvider buildAWSCredentialsProvider(Configuration conf) { - - checkArgument(conf != null, "conf cannot be null."); - - String accessKey = conf.get(AWS_ACCESS_KEY_CONF_VAR); - String secretKey = conf.get(AWS_SECRET_KEY_CONF_VAR); - String sessionToken = conf.get(AWS_SESSION_TOKEN_CONF_VAR); - - checkArgument(accessKey != null, AWS_ACCESS_KEY_CONF_VAR + " must be set."); - checkArgument(secretKey != null, AWS_SECRET_KEY_CONF_VAR + " must be set."); - checkArgument(sessionToken != null, AWS_SESSION_TOKEN_CONF_VAR + " must be set."); - - AWSSessionCredentials credentials = new BasicSessionCredentials(accessKey, secretKey, sessionToken); - - return new StaticCredentialsProvider(credentials); - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/AWSGlueConfig.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/AWSGlueConfig.java deleted file mode 100644 index 7b0bd3ef979130..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/AWSGlueConfig.java +++ /dev/null @@ -1,67 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.util; - -import com.amazonaws.ClientConfiguration; - -public final class AWSGlueConfig { - - private AWSGlueConfig() { - } - - public static final String AWS_GLUE_ENDPOINT = "aws.glue.endpoint"; - public static final String AWS_REGION = "aws.region"; - public static final String AWS_CATALOG_CREDENTIALS_PROVIDER_FACTORY_CLASS - = "aws.catalog.credentials.provider.factory.class"; - - public static final String AWS_GLUE_MAX_RETRY = "aws.glue.max-error-retries"; - public static final int DEFAULT_MAX_RETRY = 5; - - public static final String AWS_GLUE_MAX_CONNECTIONS = "aws.glue.max-connections"; - public static final int DEFAULT_MAX_CONNECTIONS = ClientConfiguration.DEFAULT_MAX_CONNECTIONS; - - public static final String AWS_GLUE_CONNECTION_TIMEOUT = "aws.glue.connection-timeout"; - public static final int DEFAULT_CONNECTION_TIMEOUT = ClientConfiguration.DEFAULT_CONNECTION_TIMEOUT; - - public static final String AWS_GLUE_SOCKET_TIMEOUT = "aws.glue.socket-timeout"; - public static final int DEFAULT_SOCKET_TIMEOUT = ClientConfiguration.DEFAULT_SOCKET_TIMEOUT; - - public static final String AWS_GLUE_CATALOG_SEPARATOR = "aws.glue.catalog.separator"; - - public static final String AWS_GLUE_DISABLE_UDF = "aws.glue.disable-udf"; - - - public static final String AWS_GLUE_DB_CACHE_ENABLE = "aws.glue.cache.db.enable"; - public static final String AWS_GLUE_DB_CACHE_SIZE = "aws.glue.cache.db.size"; - public static final String AWS_GLUE_DB_CACHE_TTL_MINS = "aws.glue.cache.db.ttl-mins"; - - public static final String AWS_GLUE_TABLE_CACHE_ENABLE = "aws.glue.cache.table.enable"; - public static final String AWS_GLUE_TABLE_CACHE_SIZE = "aws.glue.cache.table.size"; - public static final String AWS_GLUE_TABLE_CACHE_TTL_MINS = "aws.glue.cache.table.ttl-mins"; - - public static final String AWS_GLUE_ACCESS_KEY = "aws.glue.access-key"; - public static final String AWS_GLUE_SECRET_KEY = "aws.glue.secret-key"; - public static final String AWS_GLUE_SESSION_TOKEN = "aws.glue.session-token"; - public static final String AWS_GLUE_ROLE_ARN = "aws.glue.role-arn"; - public static final String AWS_GLUE_EXTERNAL_ID = "aws.glue.external-id"; - public static final String AWS_CREDENTIALS_PROVIDER_MODE = "aws.credentials.provider.mode"; -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/BatchCreatePartitionsHelper.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/BatchCreatePartitionsHelper.java deleted file mode 100644 index f5dd9872341ebf..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/BatchCreatePartitionsHelper.java +++ /dev/null @@ -1,153 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.util; - -import com.amazonaws.glue.catalog.converters.CatalogToHiveConverter; -import com.amazonaws.glue.catalog.converters.CatalogToHiveConverterFactory; -import com.amazonaws.glue.catalog.converters.GlueInputConverter; -import com.amazonaws.glue.catalog.metastore.AWSGlueMetastore; -import static com.amazonaws.glue.catalog.util.PartitionUtils.isInvalidUserInputException; -import com.amazonaws.services.glue.model.EntityNotFoundException; -import com.amazonaws.services.glue.model.Partition; -import com.amazonaws.services.glue.model.PartitionError; -import com.google.common.collect.Lists; -import org.apache.commons.lang3.StringUtils; -import org.apache.hadoop.hive.metastore.api.AlreadyExistsException; -import org.apache.log4j.Logger; -import shade.doris.hive.org.apache.thrift.TException; - -import java.util.Collection; -import java.util.List; -import java.util.Map; - -public final class BatchCreatePartitionsHelper { - - private static final Logger logger = Logger.getLogger(BatchCreatePartitionsHelper.class); - - private final AWSGlueMetastore glueClient; - private final String databaseName; - private final String tableName; - private final List partitions; - private final boolean ifNotExists; - private Map partitionMap; - private List partitionsFailed; - private TException firstTException; - private String catalogId; - private CatalogToHiveConverter catalogToHiveConverter; - - public BatchCreatePartitionsHelper(AWSGlueMetastore glueClient, String databaseName, String tableName, String catalogId, - List partitions, boolean ifNotExists) { - this.glueClient = glueClient; - this.databaseName = databaseName; - this.tableName = tableName; - this.catalogId = catalogId; - this.partitions = partitions; - this.ifNotExists = ifNotExists; - catalogToHiveConverter = CatalogToHiveConverterFactory.getCatalogToHiveConverter(); - } - - public BatchCreatePartitionsHelper createPartitions() { - partitionMap = PartitionUtils.buildPartitionMap(partitions); - partitionsFailed = Lists.newArrayList(); - - try { - List result = - glueClient.createPartitions(databaseName, tableName, - GlueInputConverter.convertToPartitionInputs(partitionMap.values())); - processResult(result); - } catch (Exception e) { - logger.error("Exception thrown while creating partitions in DataCatalog: ", e); - firstTException = catalogToHiveConverter.wrapInHiveException(e); - if (isInvalidUserInputException(e)) { - setAllFailed(); - } else { - checkIfPartitionsCreated(); - } - } - return this; - } - - private void setAllFailed() { - partitionsFailed = partitions; - partitionMap.clear(); - } - - private void processResult(List partitionErrors) { - if (partitionErrors == null || partitionErrors.isEmpty()) { - return; - } - - logger.error(String.format("BatchCreatePartitions failed to create %d out of %d partitions. \n", - partitionErrors.size(), partitionMap.size())); - - for (PartitionError partitionError : partitionErrors) { - Partition partitionFailed = partitionMap.remove(new PartitionKey(partitionError.getPartitionValues())); - - TException exception = catalogToHiveConverter.errorDetailToHiveException(partitionError.getErrorDetail()); - if (ifNotExists && exception instanceof AlreadyExistsException) { - // AlreadyExistsException is allowed, so we shouldn't add the partition to partitionsFailed list - continue; - } - logger.error(exception); - if (firstTException == null) { - firstTException = exception; - } - partitionsFailed.add(partitionFailed); - } - } - - private void checkIfPartitionsCreated() { - for (Partition partition : partitions) { - if (!partitionExists(partition)) { - partitionsFailed.add(partition); - partitionMap.remove(new PartitionKey(partition)); - } - } - } - - private boolean partitionExists(Partition partition) { - try { - Partition partitionReturned = glueClient.getPartition(databaseName, tableName, partition.getValues()); - return partitionReturned != null; //probably always true here - } catch (EntityNotFoundException e) { - // here we assume namespace and table exist. It is assured by calling "isInvalidUserInputException" method above - return false; - } catch (Exception e) { - logger.error(String.format("Get partition request %s failed. ", StringUtils.join(partition.getValues(), "/")), e); - // partition status unknown, we assume that the partition was not created - return false; - } - } - - public TException getFirstTException() { - return firstTException; - } - - public Collection getPartitionsCreated() { - return partitionMap.values(); - } - - public List getPartitionsFailed() { - return partitionsFailed; - } - -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/BatchDeletePartitionsHelper.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/BatchDeletePartitionsHelper.java deleted file mode 100644 index 1d85cd5ba0f3bb..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/BatchDeletePartitionsHelper.java +++ /dev/null @@ -1,147 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.util; - -import com.amazonaws.glue.catalog.converters.CatalogToHiveConverter; -import com.amazonaws.glue.catalog.converters.CatalogToHiveConverterFactory; -import com.amazonaws.services.glue.AWSGlue; -import com.amazonaws.services.glue.model.BatchDeletePartitionRequest; -import com.amazonaws.services.glue.model.BatchDeletePartitionResult; -import com.amazonaws.services.glue.model.EntityNotFoundException; -import com.amazonaws.services.glue.model.ErrorDetail; -import com.amazonaws.services.glue.model.GetPartitionRequest; -import com.amazonaws.services.glue.model.GetPartitionResult; -import com.amazonaws.services.glue.model.Partition; -import com.amazonaws.services.glue.model.PartitionError; -import org.apache.log4j.Logger; -import shade.doris.hive.org.apache.thrift.TException; - -import java.util.Collection; -import java.util.List; -import java.util.Map; - -public final class BatchDeletePartitionsHelper { - - private static final Logger logger = Logger.getLogger(BatchDeletePartitionsHelper.class); - - private final AWSGlue client; - private final String namespaceName; - private final String tableName; - private final String catalogId; - private final List partitions; - private Map partitionMap; - private TException firstTException; - private CatalogToHiveConverter catalogToHiveConverter; - - public BatchDeletePartitionsHelper(AWSGlue client, String namespaceName, String tableName, - String catalogId, List partitions) { - this.client = client; - this.namespaceName = namespaceName; - this.tableName = tableName; - this.catalogId = catalogId; - this.partitions = partitions; - catalogToHiveConverter = CatalogToHiveConverterFactory.getCatalogToHiveConverter(); - } - - public BatchDeletePartitionsHelper deletePartitions() { - partitionMap = PartitionUtils.buildPartitionMap(partitions); - - BatchDeletePartitionRequest request = new BatchDeletePartitionRequest().withDatabaseName(namespaceName) - .withTableName(tableName).withCatalogId(catalogId) - .withPartitionsToDelete(PartitionUtils.getPartitionValuesList(partitionMap)); - - try { - BatchDeletePartitionResult result = client.batchDeletePartition(request); - processResult(result); - } catch (Exception e) { - logger.error("Exception thrown while deleting partitions in DataCatalog: ", e); - firstTException = catalogToHiveConverter.wrapInHiveException(e); - if (PartitionUtils.isInvalidUserInputException(e)) { - setAllFailed(); - } else { - checkIfPartitionsDeleted(); - } - } - return this; - } - - private void setAllFailed() { - partitionMap.clear(); - } - - private void processResult(final BatchDeletePartitionResult batchDeletePartitionsResult) { - List partitionErrors = batchDeletePartitionsResult.getErrors(); - if (partitionErrors == null || partitionErrors.isEmpty()) { - return; - } - - logger.error(String.format("BatchDeletePartitions failed to delete %d out of %d partitions. \n", - partitionErrors.size(), partitionMap.size())); - - for (PartitionError partitionError : partitionErrors) { - partitionMap.remove(new PartitionKey(partitionError.getPartitionValues())); - ErrorDetail errorDetail = partitionError.getErrorDetail(); - logger.error(errorDetail.toString()); - if (firstTException == null) { - firstTException = catalogToHiveConverter.errorDetailToHiveException(errorDetail); - } - } - } - - private void checkIfPartitionsDeleted() { - for (Partition partition : partitions) { - if (!partitionDeleted(partition)) { - partitionMap.remove(new PartitionKey(partition)); - } - } - } - - private boolean partitionDeleted(Partition partition) { - GetPartitionRequest request = new GetPartitionRequest() - .withDatabaseName(partition.getDatabaseName()) - .withTableName(partition.getTableName()) - .withPartitionValues(partition.getValues()) - .withCatalogId(catalogId); - - try { - GetPartitionResult result = client.getPartition(request); - Partition partitionReturned = result.getPartition(); - return partitionReturned == null; //probably always false - } catch (EntityNotFoundException e) { - // here we assume namespace and table exist. It is assured by calling "isInvalidUserInputException" method above - return true; - } catch (Exception e) { - logger.error(String.format("Get partition request %s failed. ", request.toString()), e); - // Partition status unknown, we assume that the partition was not deleted - return false; - } - } - - public TException getFirstTException() { - return firstTException; - } - - public Collection getPartitionsDeleted() { - return partitionMap.values(); - } - -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/ExpressionHelper.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/ExpressionHelper.java deleted file mode 100644 index bbdcc9773ee462..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/ExpressionHelper.java +++ /dev/null @@ -1,242 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.util; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Joiner; -import com.google.common.base.Strings; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Sets; -import org.apache.hadoop.hive.metastore.api.MetaException; -import org.apache.hadoop.hive.ql.plan.ExprNodeDesc; -import org.apache.hadoop.hive.ql.plan.ExprNodeGenericFuncDesc; -import org.apache.hadoop.hive.ql.udf.generic.GenericUDFIn; -import org.apache.hadoop.hive.ql.udf.generic.GenericUDFOPNot; -import org.apache.hadoop.hive.serde2.typeinfo.PrimitiveTypeInfo; -import org.apache.log4j.Logger; - -import java.util.LinkedList; -import java.util.List; -import java.util.Set; - -/** - * Utility methods for constructing the string representation of query expressions used by Catalog service - */ -public final class ExpressionHelper { - - private final static String HIVE_STRING_TYPE_NAME = "string"; - private final static String HIVE_IN_OPERATOR = "IN"; - private final static String HIVE_NOT_IN_OPERATOR = "NOT IN"; - private final static String HIVE_NOT_OPERATOR = "not"; - - // TODO "hook" into Hive logging (hive or hive.metastore) - private final static Logger logger = Logger.getLogger(ExpressionHelper.class); - - private final static List QUOTED_TYPES = ImmutableList.of("string", "char", "varchar", "date", "datetime", "timestamp"); - private final static Joiner JOINER = Joiner.on(" AND "); - - /* - * The method below is used to rewrite the hive expression tree to quote the timestamp values. - * An example of this would be hive providing us as query as follows: - * ((strCol = 'test') and (timestamp = 1969-12-31 16:02:03.456)) - * - * this will be rewritten by the method to: - * ((strCol = 'test') and (timestamp = '1969-12-31 16:02:03.456')) - * - * Notice the way the timestamp is quoted. - * - * In order to perform this operation we recursively navigate the ExpressionTree - * given to us by hive and switch the type to 'string' whenever we encounter a node type - * of type 'timestamp' - * - * When we call the getExprTree method of the modified expression tree, the timestamp values are - * properly quoted. - * - * This method also rewrites the expression string for "NOT IN" expression. - * Hive converts the expression " NOT IN ()" to "(not () IN ())". - * But in DataCatalog service, the parsing is done based on the original expression (which contains NOT IN). - * So, we need to rewrite the expression if NOT IN was used. - * */ - public static String convertHiveExpressionToCatalogExpression(byte[] exprBytes) throws MetaException { - ExprNodeGenericFuncDesc exprTree = deserializeExpr(exprBytes); - Set columnNamesInNotInExpression = Sets.newHashSet(); - fieldEscaper(exprTree.getChildren(), exprTree, columnNamesInNotInExpression); - String expression = rewriteExpressionForNotIn(exprTree.getExprString(), columnNamesInNotInExpression); - return removeDecimalTypeSuffixIfNecessary(expression); - } - - /** - * @return expression that is compatible with Glue API due to HIVE-18797 code change in the Hive - * 3.x the ExprConstNodeDesc's getExprString put additional literal qualifier with literals. These - * additional letters cannot be recognized by Glue API. - * - * Removes the following literal qualifiers from partition expressions for compatibility with Glue - * API. - * - * - L : BigInt - * - D : Decimal - * - S : SmallInt - * - Y : TinyInt - * - BD : BigDecimal - * - * Ex: col1 > 10L -> col1 > 10 - * col1 > 10D -> col1 > 10 - * col1 > 10S -> col1 > 10 - * col1 > 10Y -> col1 > 10 - * col1 > 10BD -> col1 > 10 - */ - @VisibleForTesting - protected static String removeDecimalTypeSuffixIfNecessary(String expression) { - expression = expression.replaceAll("L\\)|D\\)|S\\)|Y\\)|BD\\)", ")"); - return expression; - } - - private static ExprNodeGenericFuncDesc deserializeExpr(byte[] exprBytes) throws MetaException { - ExprNodeGenericFuncDesc expr = null; - try { - // expr = ShimsLoader.getHiveShims().getDeserializeExpression(exprBytes); - } catch (Exception ex) { - logger.error("Failed to deserialize the expression", ex); - throw new MetaException(ex.getMessage()); - } - if (expr == null) { - throw new MetaException("Failed to deserialize expression - ExprNodeDesc not present"); - } - return expr; - } - - //Helper method that recursively switches the type of the node, this is used - //by the convertHiveExpressionToCatalogExpression - private static void fieldEscaper(List exprNodes, ExprNodeDesc parent, Set columnNamesInNotInExpression) { - if (exprNodes == null || exprNodes.isEmpty()) { - return; - } else { - for (ExprNodeDesc nodeDesc : exprNodes) { - String nodeType = nodeDesc.getTypeString().toLowerCase(); - if (QUOTED_TYPES.contains(nodeType)) { - PrimitiveTypeInfo tInfo = new PrimitiveTypeInfo(); - tInfo.setTypeName(HIVE_STRING_TYPE_NAME); - nodeDesc.setTypeInfo(tInfo); - } - addColumnNamesOfNotInExpressionToSet(nodeDesc, parent, columnNamesInNotInExpression); - fieldEscaper(nodeDesc.getChildren(), nodeDesc, columnNamesInNotInExpression); - } - } - } - - /* - * Method to extract the names of columns that are involved in NOT IN expression. Only one column is allowed to be - * used in NOT IN expression. So, ExprNodeDesc.getCols() would return only 1 column. - * - * @param childNode - * @param parentNode - * @param columnsInNotInExpression - */ - private static void addColumnNamesOfNotInExpressionToSet(ExprNodeDesc childNode, ExprNodeDesc parentNode, Set columnsInNotInExpression) { - if (parentNode != null && childNode != null && parentNode instanceof ExprNodeGenericFuncDesc && childNode instanceof ExprNodeGenericFuncDesc) { - ExprNodeGenericFuncDesc parentFuncNode = (ExprNodeGenericFuncDesc) parentNode; - ExprNodeGenericFuncDesc childFuncNode = (ExprNodeGenericFuncDesc) childNode; - if(parentFuncNode.getGenericUDF() instanceof GenericUDFOPNot && childFuncNode.getGenericUDF() instanceof GenericUDFIn) { - // The current parent child pair represents a "NOT IN" expression. Add name of the column to the set. - columnsInNotInExpression.addAll(childFuncNode.getCols()); - } - } - } - - private static String rewriteExpressionForNotIn(String expression, Set columnsInNotInExpression){ - for (String columnName : columnsInNotInExpression) { - if (columnName != null) { - String hiveExpression = getHiveCompatibleNotInExpression(columnName); - hiveExpression = escapeParentheses(hiveExpression); - String catalogExpression = getCatalogCompatibleNotInExpression(columnName); - catalogExpression = escapeParentheses(catalogExpression); - expression = expression.replaceAll(hiveExpression, catalogExpression); - } - } - return expression; - } - - // return "not () IN (" - private static String getHiveCompatibleNotInExpression(String columnName) { - return String.format("%s (%s) %s (", HIVE_NOT_OPERATOR, columnName, HIVE_IN_OPERATOR); - } - - // return "() NOT IN (" - private static String getCatalogCompatibleNotInExpression(String columnName) { - return String.format("(%s) %s (", columnName, HIVE_NOT_IN_OPERATOR); - } - - /* - * Escape the parentheses so that they are considered literally and not as part of regular expression. In the updated - * expression , we need "\\(" as the output. So, the first four '\' generate '\\' and the last two '\' generate a '(' - */ - private static String escapeParentheses(String expression) { - expression = expression.replaceAll("\\(", "\\\\\\("); - expression = expression.replaceAll("\\)", "\\\\\\)"); - return expression; - } - - public static String buildExpressionFromPartialSpecification(org.apache.hadoop.hive.metastore.api.Table table, - List partitionValues) throws MetaException { - - List partitionKeys = table.getPartitionKeys(); - - if (partitionValues == null || partitionValues.isEmpty() ) { - return null; - } - - if (partitionKeys == null || partitionValues.size() > partitionKeys.size()) { - throw new MetaException("Incorrect number of partition values: " + partitionValues); - } - - partitionKeys = partitionKeys.subList(0, partitionValues.size()); - List predicates = new LinkedList<>(); - for (int i = 0; i < partitionValues.size(); i++) { - if (!Strings.isNullOrEmpty(partitionValues.get(i))) { - predicates.add(buildPredicate(partitionKeys.get(i), partitionValues.get(i))); - } - } - - return JOINER.join(predicates); - } - - private static String buildPredicate(org.apache.hadoop.hive.metastore.api.FieldSchema schema, String value) { - if (isQuotedType(schema.getType())) { - return String.format("(%s='%s')", schema.getName(), escapeSingleQuotes(value)); - } else { - return String.format("(%s=%s)", schema.getName(), value); - } - } - - private static String escapeSingleQuotes(String s) { - return s.replaceAll("'", "\\\\'"); - } - - private static boolean isQuotedType(String type) { - return QUOTED_TYPES.contains(type); - } - - public static String replaceDoubleQuoteWithSingleQuotes(String s) { - return s.replaceAll("\"", "\'"); - } - -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/HiveTableValidator.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/HiveTableValidator.java deleted file mode 100644 index 7baff9b130ee18..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/HiveTableValidator.java +++ /dev/null @@ -1,86 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.util; - -import com.amazonaws.services.glue.model.InvalidInputException; -import com.amazonaws.services.glue.model.Table; -import static org.apache.commons.lang3.StringUtils.isNotEmpty; -import org.apache.hadoop.hive.metastore.TableType; -import static org.apache.hadoop.hive.metastore.api.hive_metastoreConstants.META_TABLE_STORAGE; - -public enum HiveTableValidator { - - REQUIRED_PROPERTIES_VALIDATOR { - public void validate(Table table) { - String missingProperty = null; - - if(notApplicableTableType(table)) { - return; - } - - if (table.getTableType() == null) { - missingProperty = "TableType"; - } else if (table.getStorageDescriptor() == null) { - missingProperty = "StorageDescriptor"; - } else if (table.getStorageDescriptor().getInputFormat() == null) { - missingProperty = "StorageDescriptor#InputFormat"; - } else if (table.getStorageDescriptor().getOutputFormat() == null) { - missingProperty = "StorageDescriptor#OutputFormat"; - } else if (table.getStorageDescriptor().getSerdeInfo() == null) { - missingProperty = "StorageDescriptor#SerdeInfo"; - } else if (table.getStorageDescriptor().getSerdeInfo().getSerializationLibrary() == null) { - missingProperty = "StorageDescriptor#SerdeInfo#SerializationLibrary"; - } - - if (missingProperty != null) { - throw new InvalidInputException(String.format("%s cannot be null for table: %s", missingProperty, table.getName())); - } - } - }; - - public abstract void validate(Table table); - - private static boolean notApplicableTableType(Table table) { - if (isNotManagedOrExternalTable(table) || - isStorageHandlerType(table)) { - return true; - } - return false; - } - - private static boolean isNotManagedOrExternalTable(Table table) { - if (table.getTableType() != null && - TableType.valueOf(table.getTableType()) != TableType.MANAGED_TABLE && - TableType.valueOf(table.getTableType()) != TableType.EXTERNAL_TABLE) { - return true; - } - return false; - } - - private static boolean isStorageHandlerType(Table table) { - if (table.getParameters() != null && table.getParameters().containsKey(META_TABLE_STORAGE) && - isNotEmpty(table.getParameters().get(META_TABLE_STORAGE))) { - return true; - } - return false; - } -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/LoggingHelper.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/LoggingHelper.java deleted file mode 100644 index d7ae83d4fdbc0f..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/LoggingHelper.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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.util; - -import java.util.Collection; - -public class LoggingHelper { - - private static final int MAX_LOG_STRING_LEN = 2000; - - private LoggingHelper() { - } - - public static String concatCollectionToStringForLogging(Collection collection, String delimiter) { - if (collection == null) { - return ""; - } - if (delimiter == null) { - delimiter = ","; - } - StringBuilder bldr = new StringBuilder(); - int totalLen = 0; - int delimiterSize = delimiter.length(); - for (String str : collection) { - if (totalLen > MAX_LOG_STRING_LEN) break; - if (str.length() + totalLen > MAX_LOG_STRING_LEN) { - bldr.append(str.subSequence(0, (MAX_LOG_STRING_LEN-totalLen))); - break; - } else { - bldr.append(str); - bldr.append(delimiter); - totalLen += str.length() + delimiterSize; - } - } - return bldr.toString(); - } - -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/MetastoreClientUtils.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/MetastoreClientUtils.java deleted file mode 100644 index 47ad717397c178..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/MetastoreClientUtils.java +++ /dev/null @@ -1,141 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.util; - -import com.amazonaws.glue.catalog.metastore.GlueMetastoreClientDelegate; -import static com.google.common.base.Preconditions.checkNotNull; -import com.google.common.collect.Maps; -import org.apache.commons.lang3.StringUtils; -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.fs.Path; -import static org.apache.hadoop.hive.metastore.TableType.EXTERNAL_TABLE; -import org.apache.hadoop.hive.metastore.Warehouse; -import org.apache.hadoop.hive.metastore.api.InvalidObjectException; -import org.apache.hadoop.hive.metastore.api.MetaException; -import org.apache.hadoop.hive.metastore.api.Table; - -import java.util.Map; - -public final class MetastoreClientUtils { - - // private static final AwsGlueHiveShims hiveShims = ShimsLoader.getHiveShims(); - - private MetastoreClientUtils() { - // static util class should not be instantiated - } - - /** - * @return boolean - * true -> if directory was able to be created. - * false -> if directory already exists. - * @throws MetaException if directory could not be created. - */ - public static boolean makeDirs(Warehouse wh, Path path) throws MetaException { - checkNotNull(wh, "Warehouse cannot be null"); - checkNotNull(path, "Path cannot be null"); - - boolean madeDir = false; - if (!wh.isDir(path)) { - // if (!hiveShims.mkdirs(wh, path)) { - throw new MetaException("Unable to create path: " + path); - // } - // madeDir = true; - } - return madeDir; - } - - /** - * Taken from HiveMetaStore#create_table_core - * https://github.com/apache/hive/blob/rel/release-2.3.0/metastore/src/java/org/apache/hadoop/hive/metastore/HiveMetaStore.java#L1370-L1383 - */ - public static void validateTableObject(Table table, Configuration conf) throws InvalidObjectException { - checkNotNull(table, "table cannot be null"); - checkNotNull(table.getSd(), "Table#StorageDescriptor cannot be null"); - - // if (!hiveShims.validateTableName(table.getTableName(), conf)) { - // throw new InvalidObjectException(table.getTableName() + " is not a valid object name"); - // } - // String validate = hiveShims.validateTblColumns(table.getSd().getCols()); - // if (validate != null) { - // throw new InvalidObjectException("Invalid column " + validate); - // } - - // if (table.getPartitionKeys() != null) { - // validate = hiveShims.validateTblColumns(table.getPartitionKeys()); - // if (validate != null) { - // throw new InvalidObjectException("Invalid partition column " + validate); - // } - // } - } - - /** - * Should be used when getting table from Glue that may have been created by - * users manually or through Crawlers. Validates that table contains properties required by Hive/Spark. - * @param table - */ - public static void validateGlueTable(com.amazonaws.services.glue.model.Table table) { - checkNotNull(table, "table cannot be null"); - - for (HiveTableValidator validator : HiveTableValidator.values()) { - validator.validate(table); - } - } - - public static Map deepCopyMap(Map originalMap) { - Map deepCopy = Maps.newHashMap(); - if (originalMap == null) { - return deepCopy; - } - - for (Map.Entry entry : originalMap.entrySet()) { - deepCopy.put(entry.getKey(), entry.getValue()); - } - return deepCopy; - } - - /** - * Mimics MetaStoreUtils.isExternalTable - * Additional logic: check Table#getTableType to see if isExternalTable - */ - public static boolean isExternalTable(org.apache.hadoop.hive.metastore.api.Table table) { - if (table == null) { - return false; - } - - Map params = table.getParameters(); - String paramsExternalStr = params == null ? null : params.get("EXTERNAL"); - if (paramsExternalStr != null) { - return "TRUE".equalsIgnoreCase(paramsExternalStr); - } - - return table.getTableType() != null && EXTERNAL_TABLE.name().equalsIgnoreCase(table.getTableType()); - } - - public static String getCatalogId(Configuration conf) { - if (StringUtils.isNotEmpty(conf.get(GlueMetastoreClientDelegate.CATALOG_ID_CONF))) { - return conf.get(GlueMetastoreClientDelegate.CATALOG_ID_CONF); - } - // This case defaults to using the caller's account Id as Catalog Id. - return null; - } - -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/PartitionKey.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/PartitionKey.java deleted file mode 100644 index 621a974b878b89..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/PartitionKey.java +++ /dev/null @@ -1,60 +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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.util; - -import com.amazonaws.services.glue.model.Partition; - -import java.util.List; - -public class PartitionKey { - - private final List partitionValues; - private final int hashCode; - - public PartitionKey(Partition partition) { - this(partition.getValues()); - } - - public PartitionKey(List partitionValues) { - if (partitionValues == null) { - throw new IllegalArgumentException("Partition values cannot be null"); - } - this.partitionValues = partitionValues; - this.hashCode = partitionValues.hashCode(); - } - - @Override - public boolean equals(Object other) { - return this == other || (other != null && other instanceof PartitionKey - && this.partitionValues.equals(((PartitionKey) other).partitionValues)); - } - - @Override - public int hashCode() { - return hashCode; - } - - List getValues() { - return partitionValues; - } - -} diff --git a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/PartitionUtils.java b/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/PartitionUtils.java deleted file mode 100644 index 019d78e632759f..00000000000000 --- a/fe/fe-core/src/main/java/com/amazonaws/glue/catalog/util/PartitionUtils.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. -// -// Copied from -// https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/branch-3.4.0/ -// - -package com.amazonaws.glue.catalog.util; - -import com.amazonaws.services.glue.model.EntityNotFoundException; -import com.amazonaws.services.glue.model.InvalidInputException; -import com.amazonaws.services.glue.model.Partition; -import com.amazonaws.services.glue.model.PartitionValueList; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; - -import java.util.List; -import java.util.Map; - -public final class PartitionUtils { - - public static Map buildPartitionMap(final List partitions) { - Map partitionValuesMap = Maps.newHashMap(); - for (Partition partition : partitions) { - partitionValuesMap.put(new PartitionKey(partition), partition); - } - return partitionValuesMap; - } - - public static List getPartitionValuesList(final Map partitionMap) { - List partitionValuesList = Lists.newArrayList(); - for (Map.Entry entry : partitionMap.entrySet()) { - partitionValuesList.add(new PartitionValueList().withValues(entry.getValue().getValues())); - } - return partitionValuesList; - } - - public static boolean isInvalidUserInputException(Exception e) { - // exceptions caused by invalid requests, in which case we know all partitions creation failed - return e instanceof EntityNotFoundException || e instanceof InvalidInputException; - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/DorisFE.java b/fe/fe-core/src/main/java/org/apache/doris/DorisFE.java index d313467b127287..2a57b69b27f51b 100755 --- a/fe/fe-core/src/main/java/org/apache/doris/DorisFE.java +++ b/fe/fe-core/src/main/java/org/apache/doris/DorisFE.java @@ -32,7 +32,7 @@ import org.apache.doris.common.util.JdkUtils; import org.apache.doris.common.util.NetUtils; import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.FileCacheAdmissionManager; +import org.apache.doris.datasource.scan.FileCacheAdmissionManager; import org.apache.doris.httpv2.HttpServer; import org.apache.doris.journal.bdbje.BDBDebugger; import org.apache.doris.journal.bdbje.BDBTool; 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 9f9eb03ceec676..b9f728576d77db 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 @@ -47,8 +47,6 @@ import org.apache.doris.common.util.PropertyAnalyzer; import org.apache.doris.common.util.PropertyAnalyzer.RewriteProperty; import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; import org.apache.doris.info.TableNameInfoUtils; import org.apache.doris.mtmv.BaseTableInfo; import org.apache.doris.nereids.trees.plans.commands.AlterSystemCommand; @@ -393,7 +391,6 @@ private void setExternalTableAutoAnalyzePolicy(ExternalTable table, List alterOps) throws UserException { - long updateTime = System.currentTimeMillis(); for (AlterOp alterOp : alterOps) { if (alterOp instanceof ModifyTablePropertiesOp) { setExternalTableAutoAnalyzePolicy(table, alterOps); @@ -433,29 +430,11 @@ private void processAlterTableForExternalTable( ReorderColumnsOp reorderColumns = (ReorderColumnsOp) alterOp; table.getCatalog().reorderColumns(table, reorderColumns.getColumnsByPos()); } else if (alterOp instanceof AddPartitionFieldOp) { - AddPartitionFieldOp addPartitionField = (AddPartitionFieldOp) alterOp; - if (table instanceof IcebergExternalTable) { - ((IcebergExternalCatalog) table.getCatalog()).addPartitionField( - (IcebergExternalTable) table, addPartitionField, updateTime); - } else { - throw new UserException("ADD PARTITION KEY is only supported for Iceberg tables"); - } + table.getCatalog().addPartitionField(table, (AddPartitionFieldOp) alterOp); } else if (alterOp instanceof DropPartitionFieldOp) { - DropPartitionFieldOp dropPartitionField = (DropPartitionFieldOp) alterOp; - if (table instanceof IcebergExternalTable) { - ((IcebergExternalCatalog) table.getCatalog()).dropPartitionField( - (IcebergExternalTable) table, dropPartitionField, updateTime); - } else { - throw new UserException("DROP PARTITION KEY is only supported for Iceberg tables"); - } + table.getCatalog().dropPartitionField(table, (DropPartitionFieldOp) alterOp); } else if (alterOp instanceof ReplacePartitionFieldOp) { - ReplacePartitionFieldOp replacePartitionField = (ReplacePartitionFieldOp) alterOp; - if (table instanceof IcebergExternalTable) { - ((IcebergExternalCatalog) table.getCatalog()).replacePartitionField( - (IcebergExternalTable) table, replacePartitionField, updateTime); - } else { - throw new UserException("REPLACE PARTITION KEY is only supported for Iceberg tables"); - } + table.getCatalog().replacePartitionField(table, (ReplacePartitionFieldOp) alterOp); } else { throw new UserException("Invalid alter operations for external table: " + alterOps); } 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 dc8cac76500b15..efc3c0b79989db 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 @@ -101,13 +101,10 @@ import org.apache.doris.datasource.ExternalMetaCacheMgr; import org.apache.doris.datasource.ExternalMetaIdMgr; import org.apache.doris.datasource.InternalCatalog; -import org.apache.doris.datasource.SplitSourceManager; -import org.apache.doris.datasource.hive.HiveTransactionMgr; -import org.apache.doris.datasource.hive.event.MetastoreEventsProcessor; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergSysExternalTable; -import org.apache.doris.datasource.paimon.PaimonExternalTable; -import org.apache.doris.datasource.paimon.PaimonSysExternalTable; +import org.apache.doris.datasource.MetastoreEventSyncDriver; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenSysExternalTable; +import org.apache.doris.datasource.split.SplitSourceManager; import org.apache.doris.deploy.DeployManager; import org.apache.doris.deploy.impl.LocalFileDeployManager; import org.apache.doris.dictionary.DictionaryManager; @@ -409,7 +406,9 @@ public class Env { private PartitionInfoCollector partitionInfoCollector; private CooldownConfHandler cooldownConfHandler; private ExternalMetaIdMgr externalMetaIdMgr; - private MetastoreEventsProcessor metastoreEventsProcessor; + // Connector-agnostic incremental metastore-event sync driver (Model B). Dormant until an HMS catalog is + // served by a PluginDrivenExternalCatalog whose connector exposes an event source. + private MetastoreEventSyncDriver metastoreEventSyncDriver; private JobManager, ?> jobManager; private LabelProcessor labelProcessor; @@ -568,8 +567,6 @@ public class Env { private FollowerColumnSender followerColumnSender; - private HiveTransactionMgr hiveTransactionMgr; - private TopicPublisherThread topicPublisherThread; private WorkloadGroupChecker workloadGroupCheckerThread; @@ -764,7 +761,7 @@ public Env(boolean isCheckpointCatalog) { this.cooldownConfHandler = new CooldownConfHandler(); } this.externalMetaIdMgr = new ExternalMetaIdMgr(); - this.metastoreEventsProcessor = new MetastoreEventsProcessor(); + this.metastoreEventSyncDriver = new MetastoreEventSyncDriver(); this.jobManager = new JobManager<>(); this.labelProcessor = new LabelProcessor(); this.transientTaskManager = new TransientTaskManager(); @@ -865,7 +862,6 @@ public Env(boolean isCheckpointCatalog) { this.workloadRuntimeStatusMgr = new WorkloadRuntimeStatusMgr(); this.admissionControl = new AdmissionControl(systemInfo); this.queryStats = new QueryStats(); - this.hiveTransactionMgr = new HiveTransactionMgr(); this.binlogManager = new BinlogManager(); this.constraintManager = new ConstraintManager(); this.binlogGcer = new BinlogGcer(); @@ -1041,8 +1037,8 @@ public ExternalMetaIdMgr getExternalMetaIdMgr() { return externalMetaIdMgr; } - public MetastoreEventsProcessor getMetastoreEventsProcessor() { - return metastoreEventsProcessor; + public MetastoreEventSyncDriver getMetastoreEventSyncDriver() { + return metastoreEventSyncDriver; } public KeyManagerStore getKeyManagerStore() { @@ -1097,14 +1093,6 @@ public Checkpoint getCheckpointer() { return checkpointer; } - public HiveTransactionMgr getHiveTransactionMgr() { - return hiveTransactionMgr; - } - - public static HiveTransactionMgr getCurrentHiveTransactionMgr() { - return getCurrentEnv().getHiveTransactionMgr(); - } - public DNSCache getDnsCache() { return dnsCache; } @@ -2100,7 +2088,8 @@ protected void startNonMasterDaemonThreads() { // fe disk updater feDiskUpdater.start(); - metastoreEventsProcessor.start(); + // Dormant pre-flip: only drives PluginDrivenExternalCatalogs whose connector exposes an event source. + metastoreEventSyncDriver.start(); dnsCache.start(); @@ -4502,36 +4491,6 @@ public static void getCreateTableLikeStmt(CreateTableLikeInfo createTableLikeInf } else if (table.getType() == TableType.JDBC) { addTableComment(table, sb); sb.append("\n-- Internal JDBC tables are deprecated. Please use JDBC Catalog instead."); - } else if (table.getType() == TableType.ICEBERG_EXTERNAL_TABLE) { - addTableComment(table, sb); - IcebergExternalTable icebergExternalTable; - if (table instanceof IcebergExternalTable) { - icebergExternalTable = (IcebergExternalTable) table; - } else if (table instanceof IcebergSysExternalTable) { - icebergExternalTable = ((IcebergSysExternalTable) table).getSourceTable(); - } else { - throw new RuntimeException("Unexpected Iceberg table type: " + table.getClass().getSimpleName()); - } - if (icebergExternalTable.hasSortOrder()) { - sb.append("\n").append(icebergExternalTable.getSortOrderSql()); - } - if (table instanceof IcebergExternalTable) { - String partitionSpecSql = icebergExternalTable.getPartitionSpecSql(); - if (!partitionSpecSql.isEmpty()) { - sb.append("\n").append(partitionSpecSql); - } - } - sb.append("\nLOCATION '").append(icebergExternalTable.location()).append("'"); - sb.append("\nPROPERTIES ("); - Iterator> iterator = icebergExternalTable.properties().entrySet().iterator(); - while (iterator.hasNext()) { - Entry prop = iterator.next(); - sb.append("\n \"").append(prop.getKey()).append("\" = \"").append(prop.getValue()).append("\""); - if (iterator.hasNext()) { - sb.append(","); - } - } - sb.append("\n)"); } else if (table.getType() == TableType.PLUGIN_EXTERNAL_TABLE) { addTableComment(table, sb); } @@ -4900,60 +4859,54 @@ public static void getDdlStmt(Command command, String dbName, TableIf table, Lis } else if (table.getType() == TableType.JDBC) { addTableComment(table, sb); sb.append("\n-- Internal JDBC tables are deprecated. Please use JDBC Catalog instead."); - } else if (table.getType() == TableType.ICEBERG_EXTERNAL_TABLE) { + } else if (table.getType() == TableType.PLUGIN_EXTERNAL_TABLE) { addTableComment(table, sb); - IcebergExternalTable icebergExternalTable; - if (table instanceof IcebergExternalTable) { - icebergExternalTable = (IcebergExternalTable) table; - } else if (table instanceof IcebergSysExternalTable) { - icebergExternalTable = ((IcebergSysExternalTable) table).getSourceTable(); + boolean isSysTable = table instanceof PluginDrivenSysExternalTable; + PluginDrivenExternalTable pluginExternalTable; + if (table instanceof PluginDrivenSysExternalTable) { + // Mirror the legacy paimon unwrap: a system table ($snapshots etc.) renders the + // DDL of its source table. Check the sys subclass FIRST (it extends + // PluginDrivenExternalTable). + pluginExternalTable = ((PluginDrivenSysExternalTable) table).getSourceTable(); + } else if (table instanceof PluginDrivenExternalTable) { + pluginExternalTable = (PluginDrivenExternalTable) table; } else { - throw new RuntimeException("Unexpected Iceberg table type: " + table.getClass().getSimpleName()); - } - if (icebergExternalTable.hasSortOrder()) { - sb.append("\n").append(icebergExternalTable.getSortOrderSql()); - } - if (table instanceof IcebergExternalTable) { - String partitionSpecSql = icebergExternalTable.getPartitionSpecSql(); - if (!partitionSpecSql.isEmpty()) { - sb.append("\n").append(partitionSpecSql); + throw new RuntimeException("Unexpected plugin table type: " + table.getClass().getSimpleName()); + } + // Render the legacy connector DDL (ORDER BY / PARTITION BY pre-rendered by the connector, then + // LOCATION + PROPERTIES) for SHOW CREATE TABLE parity, gated on the connector's + // SUPPORTS_SHOW_CREATE_DDL capability. The capability replaces the legacy paimon-only engine-name + // gate and is the credential-leak guard (FIX-SHOWCREATE-PLUGIN-PROPS): JDBC/ES/Trino connectors, + // whose getTableProperties() returns connection props including passwords, do NOT declare it and + // stay comment-only; paimon and (post-cutover) iceberg do declare it. + if (pluginExternalTable.supportsShowCreateDdl()) { + // Clause order mirrors the legacy iceberg arm: ORDER BY, then PARTITION BY, then LOCATION, + // then PROPERTIES. The sort clause renders for sys tables too (from the unwrapped source); + // PARTITION BY is suppressed for system tables ($snapshots etc.), matching the legacy gate + // that rendered partitions only for the data table. + String sortClause = pluginExternalTable.getShowSortClause(); + if (!sortClause.isEmpty()) { + sb.append("\n").append(sortClause); } - } - sb.append("\nLOCATION '").append(icebergExternalTable.location()).append("'"); - sb.append("\nPROPERTIES ("); - Iterator> iterator = icebergExternalTable.properties().entrySet().iterator(); - while (iterator.hasNext()) { - Entry prop = iterator.next(); - sb.append("\n \"").append(prop.getKey()).append("\" = \"").append(prop.getValue()).append("\""); - if (iterator.hasNext()) { - sb.append(","); + if (!isSysTable) { + String partitionClause = pluginExternalTable.getShowPartitionClause(); + if (!partitionClause.isEmpty()) { + sb.append("\n").append(partitionClause); + } } - } - sb.append("\n)"); - } else if (table.getType() == TableType.PAIMON_EXTERNAL_TABLE) { - addTableComment(table, sb); - PaimonExternalTable paimonExternalTable; - if (table instanceof PaimonExternalTable) { - paimonExternalTable = (PaimonExternalTable) table; - } else if (table instanceof PaimonSysExternalTable) { - paimonExternalTable = ((PaimonSysExternalTable) table).getSourceTable(); - } else { - throw new RuntimeException("Unexpected Paimon table type: " + table.getClass().getSimpleName()); - } - Map properties = paimonExternalTable.getTableProperties(); - sb.append("\nLOCATION '").append(properties.getOrDefault("path", "")).append("'"); - sb.append("\nPROPERTIES ("); - Iterator> iterator = properties.entrySet().iterator(); - while (iterator.hasNext()) { - Entry prop = iterator.next(); - sb.append("\n \"").append(prop.getKey()).append("\" = \"").append(prop.getValue()).append("\""); - if (iterator.hasNext()) { - sb.append(","); + sb.append("\nLOCATION '").append(pluginExternalTable.getShowLocation()).append("'"); + sb.append("\nPROPERTIES ("); + Map properties = pluginExternalTable.getTableProperties(); + Iterator> iterator = properties.entrySet().iterator(); + while (iterator.hasNext()) { + Entry prop = iterator.next(); + sb.append("\n \"").append(prop.getKey()).append("\" = \"").append(prop.getValue()).append("\""); + if (iterator.hasNext()) { + sb.append(","); + } } + sb.append("\n)"); } - sb.append("\n)"); - } else if (table.getType() == TableType.PLUGIN_EXTERNAL_TABLE) { - addTableComment(table, sb); } createTableStmt.add(sb + ";"); diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/HMSResource.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/HMSResource.java index c3167921f12a99..c034189cca166b 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/catalog/HMSResource.java +++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/HMSResource.java @@ -19,7 +19,6 @@ import org.apache.doris.common.DdlException; import org.apache.doris.common.proc.BaseProcResult; -import org.apache.doris.datasource.property.metastore.HMSBaseProperties; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; @@ -63,8 +62,8 @@ public void modifyProperties(Map properties) throws DdlException @Override protected void setProperties(ImmutableMap properties) throws DdlException { - if (!properties.containsKey(HMSBaseProperties.HIVE_METASTORE_URIS)) { - throw new DdlException("Missing [" + HMSBaseProperties.HIVE_METASTORE_URIS + "] in properties."); + if (!properties.containsKey("hive.metastore.uris")) { + throw new DdlException("Missing [hive.metastore.uris] in properties."); } this.properties.putAll(properties); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/HdfsResource.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/HdfsResource.java index e1b482bd1b352e..86ae96454c7e06 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/catalog/HdfsResource.java +++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/HdfsResource.java @@ -19,7 +19,7 @@ import org.apache.doris.common.DdlException; import org.apache.doris.common.proc.BaseProcResult; -import org.apache.doris.common.security.authentication.AuthenticationConfig; +import org.apache.doris.kerberos.AuthenticationConfig; import org.apache.doris.thrift.THdfsConf; import org.apache.doris.thrift.THdfsParams; diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/HdfsStorageVault.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/HdfsStorageVault.java index 1af18da0b9da20..7b9e3fe204909d 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/catalog/HdfsStorageVault.java +++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/HdfsStorageVault.java @@ -19,12 +19,12 @@ import org.apache.doris.cloud.proto.Cloud; import org.apache.doris.common.DdlException; -import org.apache.doris.common.security.authentication.AuthenticationConfig; import org.apache.doris.common.util.DatasourcePrintableMap; import org.apache.doris.datasource.property.storage.StorageProperties; import org.apache.doris.filesystem.FileSystem; import org.apache.doris.filesystem.Location; import org.apache.doris.fs.FileSystemFactory; +import org.apache.doris.kerberos.AuthenticationConfig; import com.google.common.base.Preconditions; import com.google.common.base.Strings; diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/HiveTable.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/HiveTable.java index 2da5a50d27bbde..3119ac24c4a5d3 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/catalog/HiveTable.java +++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/HiveTable.java @@ -18,10 +18,9 @@ package org.apache.doris.catalog; import org.apache.doris.common.DdlException; -import org.apache.doris.common.security.authentication.AuthType; -import org.apache.doris.common.security.authentication.AuthenticationConfig; -import org.apache.doris.datasource.property.metastore.HMSBaseProperties; import org.apache.doris.datasource.property.storage.S3Properties; +import org.apache.doris.kerberos.AuthType; +import org.apache.doris.kerberos.AuthenticationConfig; import org.apache.doris.thrift.THiveTable; import org.apache.doris.thrift.TTableDescriptor; import org.apache.doris.thrift.TTableType; @@ -104,19 +103,18 @@ private void validate(Map properties) throws DdlException { // check hive properties // hive.metastore.uris - String hiveMetaStoreUris = copiedProps.get(HMSBaseProperties.HIVE_METASTORE_URIS); + String hiveMetaStoreUris = copiedProps.get("hive.metastore.uris"); if (Strings.isNullOrEmpty(hiveMetaStoreUris)) { throw new DdlException(String.format( - PROPERTY_MISSING_MSG, HMSBaseProperties.HIVE_METASTORE_URIS, - HMSBaseProperties.HIVE_METASTORE_URIS)); + PROPERTY_MISSING_MSG, "hive.metastore.uris", "hive.metastore.uris")); } - copiedProps.remove(HMSBaseProperties.HIVE_METASTORE_URIS); - hiveProperties.put(HMSBaseProperties.HIVE_METASTORE_URIS, hiveMetaStoreUris); + copiedProps.remove("hive.metastore.uris"); + hiveProperties.put("hive.metastore.uris", hiveMetaStoreUris); // support multi hive version - String hiveVersion = copiedProps.get(HMSBaseProperties.HIVE_VERSION); + String hiveVersion = copiedProps.get("hive.version"); if (!Strings.isNullOrEmpty(hiveVersion)) { - copiedProps.remove(HMSBaseProperties.HIVE_VERSION); - hiveProperties.put(HMSBaseProperties.HIVE_VERSION, hiveVersion); + copiedProps.remove("hive.version"); + hiveProperties.put("hive.version", hiveVersion); } // check auth type diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/RefreshManager.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/RefreshManager.java index 8eddfdf860f577..4faf07aabea6a5 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/catalog/RefreshManager.java +++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/RefreshManager.java @@ -21,16 +21,14 @@ import org.apache.doris.common.DdlException; import org.apache.doris.common.ThreadPoolManager; import org.apache.doris.common.UserException; +import org.apache.doris.connector.api.Connector; import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.CatalogLog; import org.apache.doris.datasource.ExternalCatalog; import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.ExternalObjectLog; import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HiveExternalMetaCache; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; +import org.apache.doris.datasource.log.CatalogLog; +import org.apache.doris.datasource.log.ExternalObjectLog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.persist.OperationType; import com.google.common.base.Strings; @@ -118,6 +116,13 @@ public void replayRefreshDb(ExternalObjectLog log) { private void refreshDbInternal(ExternalDatabase db) { db.resetMetaToUninitialized(); + // Also drop any connector-side caches for every table in this db (e.g. hive's metastore + directory-listing + // caches) so a subsequent read reflects the latest external state — otherwise REFRESH DATABASE would reset + // only the engine-side meta and leave the connector serving stale partition/file listings up to its TTL. + // Connector-agnostic (generic SPI no-op default); keyed by the REMOTE db name. Mirrors refreshTableInternal. + if (db.getCatalog() instanceof PluginDrivenExternalCatalog) { + ((PluginDrivenExternalCatalog) db.getCatalog()).getConnector().invalidateDb(db.getRemoteName()); + } LOG.info("refresh database {} in catalog {}", db.getFullName(), db.getCatalog().getName()); } @@ -184,33 +189,27 @@ public void replayRefreshTable(ExternalObjectLog log) { } if (!Strings.isNullOrEmpty(log.getNewTableName())) { // this is a rename table op + // R4: propagate the coordinator renameTable's connector-cache invalidation (source + target) to + // followers/observers — the base replay below only fixes the FE name cache, so a follower kept the + // source name's snapshot pin (and paimon's schema memo) to the TTL after an atomic table swap. + // Resolve the source's REMOTE names from the still-cached table BEFORE unregister; the target keeps + // the new name (parity with the coordinator). getConnector() does not force-init here: this branch + // is reached only for an already-initialized catalog (db + table were resolved from the replay + // cache above), mirroring the else branch's refreshTableInternal -> getConnector() hook. + if (catalog instanceof PluginDrivenExternalCatalog) { + Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); + String remoteDb = db.get().getRemoteName(); + connector.invalidateTable(remoteDb, table.get().getRemoteName()); + connector.invalidateTable(remoteDb, log.getNewTableName()); + } db.get().unregisterTable(log.getTableName()); db.get().resetMetaCacheNames(); Env.getCurrentEnv().getConstraintManager().renameTable( new TableNameInfo(catalog.getName(), log.getDbName(), log.getTableName()), new TableNameInfo(catalog.getName(), log.getDbName(), log.getNewTableName())); } else { - List modifiedPartNames = log.getPartitionNames(); - List newPartNames = log.getNewPartitionNames(); - if (catalog instanceof HMSExternalCatalog - && ((modifiedPartNames != null && !modifiedPartNames.isEmpty()) - || (newPartNames != null && !newPartNames.isEmpty()))) { - // Partition-level cache invalidation, only for hive catalog - HiveExternalMetaCache cache = Env.getCurrentEnv().getExtMetaCacheMgr() - .hive(catalog.getId()); - cache.refreshAffectedPartitionsCache((HMSExternalTable) table.get(), modifiedPartNames, newPartNames); - if (table.get() instanceof HMSExternalTable && log.getLastUpdateTime() > 0) { - ((HMSExternalTable) table.get()).setUpdateTime(log.getLastUpdateTime()); - } - LOG.info("replay refresh partitions for table {}, " - + "modified partitions count: {}, " - + "new partitions count: {}", - table.get().getName(), modifiedPartNames == null ? 0 : modifiedPartNames.size(), - newPartNames == null ? 0 : newPartNames.size()); - } else { - // Full table cache invalidation - refreshTableInternal(db.get(), table.get(), log.getLastUpdateTime()); - } + // Full table cache invalidation + refreshTableInternal(db.get(), table.get(), log.getLastUpdateTime()); } } @@ -237,15 +236,17 @@ public void refreshExternalTableFromEvent(String catalogName, String dbName, Str public void refreshTableInternal(ExternalDatabase db, ExternalTable table, long updateTime) { table.unsetObjectCreated(); - // Iceberg partition evolution can change partition specs across FEs. - // Clear related-table validation cache to avoid stale partitioned/unpartitioned judgment. - if (table instanceof IcebergExternalTable) { - ((IcebergExternalTable) table).setIsValidRelatedTableCached(false); - } if (updateTime > 0) { table.setUpdateTime(updateTime); } Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTableCache(table); + // FIX-4: also drop any connector-side per-table cache (e.g. paimon's latest-snapshot cache) so the + // next read reflects the latest external state. Connector-agnostic (generic SPI no-op default); keyed + // by the REMOTE db/table names the connector uses. + if (table.getCatalog() instanceof PluginDrivenExternalCatalog) { + ((PluginDrivenExternalCatalog) table.getCatalog()).getConnector() + .invalidateTable(db.getRemoteName(), table.getRemoteName()); + } LOG.info("refresh table {}, id {} from db {} in catalog {}, update time: {}", table.getName(), table.getId(), db.getFullName(), db.getCatalog().getName(), updateTime); } @@ -281,11 +282,13 @@ public void refreshPartitions(String catalogName, String dbName, String tableNam } ExternalTable externalTable = (ExternalTable) table; - HiveExternalMetaCache cache = Env.getCurrentEnv().getExtMetaCacheMgr().hive(externalTable.getCatalog().getId()); - for (String partitionName : partitionNames) { - cache.invalidatePartitionCache(externalTable, partitionName); + if (externalTable.getCatalog() instanceof PluginDrivenExternalCatalog) { + // The connector owns the partition cache (pull-through); invalidate by name. Mirrors + // refreshTableInternal's connector hook. + ((PluginDrivenExternalCatalog) externalTable.getCatalog()).getConnector().invalidatePartition( + ((ExternalDatabase) db).getRemoteName(), externalTable.getRemoteName(), partitionNames); } - ((HMSExternalTable) table).setUpdateTime(updateTime); + externalTable.setUpdateTime(updateTime); } public void addToRefreshMap(long catalogId, Integer[] sec) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAuditHandler.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAuditHandler.java index 2b76c04f2618b8..4f5d367853164f 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAuditHandler.java +++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAuditHandler.java @@ -19,7 +19,6 @@ import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveOperationType; import org.apache.ranger.audit.model.AuthzAuditEvent; import org.apache.ranger.plugin.audit.RangerDefaultAuditHandler; import org.apache.ranger.plugin.model.RangerPolicy; @@ -33,9 +32,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.EnumSet; import java.util.HashMap; -import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -52,15 +49,6 @@ public class RangerHiveAuditHandler extends RangerDefaultAuditHandler { public static final String CONF_AUDIT_QUERY_REQUEST_SIZE = "xasecure.audit.solr.limit.query.req.size"; public static final int DEFAULT_CONF_AUDIT_QUERY_REQUEST_SIZE = Integer.MAX_VALUE; private static final Logger LOG = LoggerFactory.getLogger(RangerDefaultAuditHandler.class); - private static final Set ROLE_OPS = new HashSet<>(); - - static { - for (HiveOperationType e : EnumSet.of(HiveOperationType.CREATEROLE, HiveOperationType.DROPROLE, - HiveOperationType.SHOW_ROLES, HiveOperationType.SHOW_ROLE_GRANT, HiveOperationType.SHOW_ROLE_PRINCIPALS, - HiveOperationType.GRANT_ROLE, HiveOperationType.REVOKE_ROLE)) { - ROLE_OPS.add(e.name()); - } - } private final int requestQuerySize; private final Collection auditEvents = new ArrayList<>(); diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/ThreadPoolManager.java b/fe/fe-core/src/main/java/org/apache/doris/common/ThreadPoolManager.java index 411ba6605aa666..6cbdab0a39993e 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/common/ThreadPoolManager.java +++ b/fe/fe-core/src/main/java/org/apache/doris/common/ThreadPoolManager.java @@ -18,7 +18,7 @@ package org.apache.doris.common; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; +import org.apache.doris.kerberos.ExecutionAuthenticator; import org.apache.doris.metric.Metric; import org.apache.doris.metric.Metric.MetricUnit; import org.apache.doris.metric.MetricLabel; diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/cache/NereidsSqlCacheManager.java b/fe/fe-core/src/main/java/org/apache/doris/common/cache/NereidsSqlCacheManager.java index fe8f01b7254a3f..f965bbf0e38d88 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/common/cache/NereidsSqlCacheManager.java +++ b/fe/fe-core/src/main/java/org/apache/doris/common/cache/NereidsSqlCacheManager.java @@ -33,7 +33,7 @@ import org.apache.doris.common.util.DebugUtil; import org.apache.doris.datasource.CatalogIf; import org.apache.doris.datasource.CatalogMgr; -import org.apache.doris.datasource.hive.HMSExternalTable; +import org.apache.doris.mtmv.MTMVRelatedTableIf; import org.apache.doris.mysql.privilege.DataMaskPolicy; import org.apache.doris.mysql.privilege.RowFilterPolicy; import org.apache.doris.nereids.CascadesContext; @@ -439,7 +439,8 @@ private IsChanged tablesOrDataChanged(Env env, SqlCacheContext sqlCacheContext) for (Entry scanTable : sqlCacheContext.getUsedTables().entrySet()) { TableVersion tableVersion = scanTable.getValue(); if (tableVersion.type != TableType.OLAP && tableVersion.type != TableType.MATERIALIZED_VIEW - && tableVersion.type != TableType.HMS_EXTERNAL_TABLE) { + && tableVersion.type != TableType.HMS_EXTERNAL_TABLE + && tableVersion.type != TableType.PLUGIN_EXTERNAL_TABLE) { return IsChanged.CHANGED_AND_INVALIDATE_CACHE; } TableIf tableIf = findTableIf(env, scanTable.getKey()); @@ -472,9 +473,10 @@ private IsChanged tablesOrDataChanged(Env env, SqlCacheContext sqlCacheContext) return IsChanged.CHANGED_AND_INVALIDATE_CACHE; } } - } else if (tableIf instanceof HMSExternalTable) { - HMSExternalTable hiveTable = (HMSExternalTable) tableIf; - if (tableVersion.version != hiveTable.getUpdateTime()) { + } else if (tableIf instanceof MTMVRelatedTableIf) { + // External MVCC table (flipped hive/iceberg/paimon/hudi): compare the stored data-version + // token against the live one. OlapTable/MTMV are matched by the OlapTable arm above. + if (tableVersion.version != ((MTMVRelatedTableIf) tableIf).getNewestUpdateVersionOrTime()) { return IsChanged.CHANGED_AND_INVALIDATE_CACHE; } } else { @@ -503,7 +505,9 @@ private IsChanged tablesOrDataChanged(Env env, SqlCacheContext sqlCacheContext) return IsChanged.CHANGED_AND_INVALIDATE_CACHE; } } - } else if (!(tableIf instanceof HMSExternalTable)) { + } else if (!(tableIf instanceof MTMVRelatedTableIf)) { + // External MVCC tables skip per-partition existence tracking (an Olap-only concern); + // their freshness is fully covered by the token compare in the used-tables loop above. return IsChanged.CHANGED_AND_INVALIDATE_CACHE; } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/util/CacheBulkLoader.java b/fe/fe-core/src/main/java/org/apache/doris/common/util/CacheBulkLoader.java index eee08b872ec3fc..4d27e1317ef8ac 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/common/util/CacheBulkLoader.java +++ b/fe/fe-core/src/main/java/org/apache/doris/common/util/CacheBulkLoader.java @@ -26,6 +26,7 @@ import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; @@ -36,7 +37,7 @@ public abstract class CacheBulkLoader implements CacheLoader { protected abstract ExecutorService getExecutor(); @Override - public Map loadAll(Iterable keys) + public Map loadAll(Set keys) throws ExecutionException, InterruptedException { List>> pList = Streams.stream(keys) .map(key -> Pair.of(key, getExecutor().submit(() -> load(key)))) diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/util/DatasourcePrintableMap.java b/fe/fe-core/src/main/java/org/apache/doris/common/util/DatasourcePrintableMap.java index 09ba42359c2e6b..454f30cc7668d4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/common/util/DatasourcePrintableMap.java +++ b/fe/fe-core/src/main/java/org/apache/doris/common/util/DatasourcePrintableMap.java @@ -18,9 +18,6 @@ package org.apache.doris.common.util; import org.apache.doris.common.maxcompute.MCProperties; -import org.apache.doris.datasource.property.metastore.AWSGlueMetaStoreBaseProperties; -import org.apache.doris.datasource.property.metastore.AliyunDLFBaseProperties; -import org.apache.doris.datasource.property.metastore.IcebergRestProperties; import org.apache.doris.datasource.property.storage.AzureProperties; import org.apache.doris.datasource.property.storage.COSProperties; import org.apache.doris.datasource.property.storage.GCSProperties; @@ -58,9 +55,32 @@ public class DatasourcePrintableMap extends BasicPrintableMap { SENSITIVE_KEY.addAll(Arrays.asList( MCProperties.SECRET_KEY)); SENSITIVE_KEY.addAll(ConnectorPropertiesUtils.getSensitiveKeys(S3Properties.class)); - SENSITIVE_KEY.addAll(ConnectorPropertiesUtils.getSensitiveKeys(AliyunDLFBaseProperties.class)); - SENSITIVE_KEY.addAll(ConnectorPropertiesUtils.getSensitiveKeys(AWSGlueMetaStoreBaseProperties.class)); - SENSITIVE_KEY.addAll(ConnectorPropertiesUtils.getSensitiveKeys(IcebergRestProperties.class)); + // DLF 1.0 secret keys. Formerly reflected off AliyunDLFBaseProperties, removed with the DLF 1.0 thrift + // metastore. Masking must outlive the feature: a DLF catalog created before the removal still replays from + // the image (rejection deliberately fires at CREATE and at client creation, never during replay, so FE can + // still start), so it remains listable and SHOW CREATE CATALOG still prints its stored properties. All four + // former sensitive keys are enumerated here, byte-identical to the former reflection result (the class had + // no superclass, so the walk contributed nothing else). The overlap with OSSProperties/OSSHdfsProperties + // below is uneven and must NOT be relied on: they alias dlf.secret_key, but nothing else covers + // dlf.catalog.accessKeySecret or either session-token alias, so omitting those would silently unmask them. + SENSITIVE_KEY.add("dlf.secret_key"); + SENSITIVE_KEY.add("dlf.catalog.accessKeySecret"); + SENSITIVE_KEY.add("dlf.session_token"); + SENSITIVE_KEY.add("dlf.catalog.sessionToken"); + // Iceberg REST catalog secret keys. Formerly reflected off the fe-core IcebergRestProperties + // (getSensitiveKeys). That class is being removed with the fe-core iceberg property cluster; its + // authoritative copy now lives connector-side (fe-connector-metastore-iceberg + // IcebergRestMetaStoreProperties), which fe-core cannot depend on. SHOW CREATE CATALOG masking must + // still hide these, so all four former IcebergRestProperties sensitive keys are enumerated explicitly, + // byte-identical to the former reflection result (its AbstractIcebergProperties/MetastoreProperties + // superclass chain carries no sensitive keys). Note the overlap with S3Properties above is uneven and + // must NOT be relied on: iceberg.rest.secret-access-key aliases S3Properties' (sensitive) secret-key, + // but iceberg.rest.session-token aliases S3Properties' session-token field which is NOT sensitive, so + // omitting it here would silently unmask it. Keep in sync with the connector's sensitive REST keys. + SENSITIVE_KEY.add("iceberg.rest.oauth2.token"); + SENSITIVE_KEY.add("iceberg.rest.oauth2.credential"); + SENSITIVE_KEY.add("iceberg.rest.secret-access-key"); + SENSITIVE_KEY.add("iceberg.rest.session-token"); SENSITIVE_KEY.addAll(ConnectorPropertiesUtils.getSensitiveKeys(GCSProperties.class)); SENSITIVE_KEY.addAll(ConnectorPropertiesUtils.getSensitiveKeys(AzureProperties.class)); SENSITIVE_KEY.addAll(ConnectorPropertiesUtils.getSensitiveKeys(OSSProperties.class)); diff --git a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorMvccSnapshotAdapter.java b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorMvccSnapshotAdapter.java new file mode 100644 index 00000000000000..13453b31c80a42 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorMvccSnapshotAdapter.java @@ -0,0 +1,43 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector; + +import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; +import org.apache.doris.datasource.mvcc.MvccSnapshot; + +import java.util.Objects; + +/** + * Adapter that lets a connector-provided {@link ConnectorMvccSnapshot} flow through the + * engine's existing {@link MvccSnapshot} contract (consumed by the nereids analyzer and + * the scan plan). Constructed when {@code ConnectorMetadata.beginQuerySnapshot} returns + * a value; passed unchanged through fe-core MVCC pinning, then unwrapped on the BE + * serialization boundary via {@link #getSnapshot()}. + */ +public final class ConnectorMvccSnapshotAdapter implements MvccSnapshot { + + private final ConnectorMvccSnapshot snapshot; + + public ConnectorMvccSnapshotAdapter(ConnectorMvccSnapshot snapshot) { + this.snapshot = Objects.requireNonNull(snapshot, "snapshot"); + } + + public ConnectorMvccSnapshot getSnapshot() { + return snapshot; + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionBuilder.java b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionBuilder.java index f52bd050e57671..f26cbfe22dbf82 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionBuilder.java +++ b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionBuilder.java @@ -17,8 +17,14 @@ package org.apache.doris.connector; +import org.apache.doris.common.Config; import org.apache.doris.common.util.DebugUtil; +import org.apache.doris.connector.api.ConnectorDelegatedCredential; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.datasource.DelegatedCredential; +import org.apache.doris.datasource.SessionContext; +import org.apache.doris.nereids.StatementContext; import org.apache.doris.qe.ConnectContext; import org.apache.doris.qe.GlobalVariable; import org.apache.doris.qe.VariableMgr; @@ -42,6 +48,19 @@ public final class ConnectorSessionBuilder { private String catalogName; private Map catalogProperties = Collections.emptyMap(); private Map sessionProperties = Collections.emptyMap(); + // The originating ConnectContext (from #from), read at build() time to pull the retained per-connection + // delegated credential + session id off SessionContext — but ONLY when the target connector declares + // SUPPORTS_USER_SESSION (userSessionCapable). Kept as a reference (not eagerly extracted) so a non-opt-in + // connector never even touches the credential (least-privilege). + private ConnectContext connectContext; + private boolean userSessionCapable; + // Explicit overrides for tests / callers without a live ConnectContext; when set (and capable) they win + // over the ConnectContext extraction. + private String sessionId; + private ConnectorDelegatedCredential delegatedCredential; + // Explicit per-statement scope override for tests without a live ConnectContext; when set it wins over + // the ConnectContext capture in build(). + private ConnectorStatementScope statementScope; private ConnectorSessionBuilder() {} @@ -58,6 +77,7 @@ public static ConnectorSessionBuilder from(ConnectContext ctx) { b.timeZone = ctx.getSessionVariable().getTimeZone(); b.locale = "en_US"; // Doris doesn't have per-session locale yet b.sessionProperties = extractSessionProperties(ctx); + b.connectContext = ctx; // read for the delegated credential at build() time, gated by capability return b; } @@ -101,10 +121,97 @@ public ConnectorSessionBuilder withTimeZone(String timeZone) { return this; } + /** + * Declares whether the target connector consumes the user's delegated credential + * ({@link org.apache.doris.connector.api.ConnectorCapability#SUPPORTS_USER_SESSION}). When {@code false} + * (the default), {@link #build()} carries neither the session id nor the credential onto the session, so a + * connector that would never use the OIDC token never receives it (least-privilege). + */ + public ConnectorSessionBuilder withUserSessionCapability(boolean capable) { + this.userSessionCapable = capable; + return this; + } + + /** Sets the session id explicitly (for callers without a live {@link ConnectContext}, e.g. tests). */ + public ConnectorSessionBuilder withSessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + /** Sets the delegated credential explicitly (for callers without a live {@link ConnectContext}, e.g. tests). */ + public ConnectorSessionBuilder withDelegatedCredential(ConnectorDelegatedCredential credential) { + this.delegatedCredential = credential; + return this; + } + + /** + * Sets the per-statement scope explicitly (for callers without a live {@link ConnectContext}, e.g. tests + * that want to inject a memoizing scope). When unset, {@link #build()} captures it from the originating + * {@link ConnectContext}'s statement context, or falls back to {@link ConnectorStatementScope#NONE}. + */ + public ConnectorSessionBuilder withStatementScope(ConnectorStatementScope statementScope) { + this.statementScope = statementScope; + return this; + } + /** Builds an immutable {@link ConnectorSession} instance. */ public ConnectorSession build() { + String sid = null; + ConnectorDelegatedCredential cred = null; + // Only a SUPPORTS_USER_SESSION connector receives the credential. An explicit override (tests) wins; + // otherwise pull the retained per-connection SessionContext off the originating ConnectContext (the + // #63068 generic base re-materializes it on the executing FE, incl. after observer->master forwarding). + if (userSessionCapable) { + if (delegatedCredential != null) { + sid = sessionId; + cred = delegatedCredential; + } else if (connectContext != null) { + SessionContext sc = connectContext.getSessionContext(); + if (sc != null && sc.hasDelegatedCredential()) { + sid = sc.getSessionId(); + cred = toConnectorCredential(sc.getDelegatedCredential().get()); + } + } + } return new ConnectorSessionImpl(queryId, user, timeZone, locale, - catalogId, catalogName, catalogProperties, sessionProperties); + catalogId, catalogName, catalogProperties, sessionProperties, sid, cred, + captureStatementScope()); + } + + /** + * Captures the per-statement scope at build time (the request thread). An explicit test override wins; + * otherwise read it off the originating {@link ConnectContext}'s statement context (preferring the + * retained context from {@link #from} over the thread-local, so a session built off the request thread + * still captures correctly). Two-level null (no context / no statement context) yields + * {@link ConnectorStatementScope#NONE} -- mirrors {@code MvccUtil.getSnapshotFromContext}. Capturing the + * reference here (not reading it live) lets off-thread scan pumps that reuse this one session reach the + * same scope even though they have no ConnectContext thread-local. + */ + private ConnectorStatementScope captureStatementScope() { + if (statementScope != null) { + return statementScope; + } + ConnectContext ctx = connectContext != null ? connectContext : ConnectContext.get(); + if (ctx == null) { + return ConnectorStatementScope.NONE; + } + StatementContext sc = ctx.getStatementContext(); + if (sc == null) { + return ConnectorStatementScope.NONE; + } + return sc.getOrCreateConnectorStatementScope(); + } + + /** + * Maps the fe-core {@link DelegatedCredential} to the neutral SPI {@link ConnectorDelegatedCredential} so + * the connector never imports a fe-core type. The {@code Type} is bridged by enum name — the two enums are + * kept constant-for-constant identical ({@code ACCESS_TOKEN/ID_TOKEN/JWT/SAML}), so an added-but-unmapped + * type fails loud here rather than being silently dropped. + */ + private static ConnectorDelegatedCredential toConnectorCredential(DelegatedCredential credential) { + return new ConnectorDelegatedCredential( + ConnectorDelegatedCredential.Type.valueOf(credential.getType().name()), + credential.getToken(), credential.getExpiresAtMillis()); } /** @@ -117,6 +224,12 @@ private static Map extractSessionProperties(ConnectContext ctx) // Server-level lower_case_table_names for identifier mapping props.put("lower_case_table_names", String.valueOf(GlobalVariable.lowerCaseTableNames)); + // MaxCompute write block-id cap: the connector cannot import fe-core Config, so the tunable + // Config.max_compute_write_max_block_count is surfaced through this channel (same as + // lower_case_table_names above) and read back via ConnectorSession.getSessionProperties(). + // Key must stay byte-identical to MaxComputeConnectorMetadata.MAX_COMPUTE_WRITE_MAX_BLOCK_COUNT. + props.put("max_compute_write_max_block_count", + String.valueOf(Config.max_compute_write_max_block_count)); return props; } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionImpl.java b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionImpl.java index 959ba988683912..a273b9a9d94b2a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionImpl.java +++ b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionImpl.java @@ -17,11 +17,16 @@ package org.apache.doris.connector; +import org.apache.doris.catalog.Env; +import org.apache.doris.connector.api.ConnectorDelegatedCredential; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.handle.ConnectorTransaction; import java.util.Collections; import java.util.Map; import java.util.Objects; +import java.util.Optional; /** * Immutable implementation of {@link ConnectorSession}. @@ -38,10 +43,32 @@ public class ConnectorSessionImpl implements ConnectorSession { private final String catalogName; private final Map catalogProperties; private final Map sessionProperties; + // Per-connection session id (preserved across FE observer->master forwarding) and the user's delegated + // credential, populated by ConnectorSessionBuilder ONLY for a SUPPORTS_USER_SESSION connector (both null + // otherwise). The credential is connection-scoped, in-memory only, and never persisted (see + // ConnectorDelegatedCredential); getSessionId() falls back to the queryId when no session id was captured. + private final String sessionId; + private final ConnectorDelegatedCredential delegatedCredential; + // The per-statement scope, captured at construction (the request thread, where the statement context is + // reachable) so off-thread scan pumps that reuse this one session still reach it. NONE when there is no + // live statement context (offline planning, tests) -- then getStatementScope() memoizes nothing. + private final ConnectorStatementScope statementScope; + // Otherwise-immutable session; this is bound once by the insert executor at write time + // for connectors using the SPI transaction model (e.g. maxcompute), and read back by the + // connector's planWrite via getCurrentTransaction(). volatile for cross-thread visibility. + private volatile ConnectorTransaction currentTransaction; ConnectorSessionImpl(String queryId, String user, String timeZone, String locale, long catalogId, String catalogName, Map catalogProperties, Map sessionProperties) { + this(queryId, user, timeZone, locale, catalogId, catalogName, catalogProperties, sessionProperties, + null, null, ConnectorStatementScope.NONE); + } + + ConnectorSessionImpl(String queryId, String user, String timeZone, String locale, + long catalogId, String catalogName, Map catalogProperties, + Map sessionProperties, String sessionId, + ConnectorDelegatedCredential delegatedCredential, ConnectorStatementScope statementScope) { this.queryId = queryId != null ? queryId : ""; this.user = user != null ? user : ""; this.timeZone = timeZone != null ? timeZone : "UTC"; @@ -52,6 +79,9 @@ public class ConnectorSessionImpl implements ConnectorSession { ? Collections.unmodifiableMap(catalogProperties) : Collections.emptyMap(); this.sessionProperties = sessionProperties != null ? Collections.unmodifiableMap(sessionProperties) : Collections.emptyMap(); + this.sessionId = sessionId; + this.delegatedCredential = delegatedCredential; + this.statementScope = statementScope != null ? statementScope : ConnectorStatementScope.NONE; } @Override @@ -59,6 +89,18 @@ public String getQueryId() { return queryId; } + @Override + public String getSessionId() { + // Fall back to the queryId (the SPI default) when no per-connection session id was captured, so a + // non-user-session catalog still returns a non-null id. + return sessionId != null ? sessionId : queryId; + } + + @Override + public Optional getDelegatedCredential() { + return Optional.ofNullable(delegatedCredential); + } + @Override public String getUser() { return user; @@ -123,6 +165,26 @@ public Map getSessionProperties() { return sessionProperties; } + @Override + public long allocateTransactionId() { + return Env.getCurrentEnv().getNextId(); + } + + @Override + public void setCurrentTransaction(ConnectorTransaction txn) { + this.currentTransaction = txn; + } + + @Override + public Optional getCurrentTransaction() { + return Optional.ofNullable(currentTransaction); + } + + @Override + public ConnectorStatementScope getStatementScope() { + return statementScope; + } + @Override public String toString() { return "ConnectorSession{queryId='" + queryId diff --git a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorStatementScopeImpl.java b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorStatementScopeImpl.java new file mode 100644 index 00000000000000..56720ef42f1bb6 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorStatementScopeImpl.java @@ -0,0 +1,99 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector; + +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.datasource.plugin.CatalogStatementTransaction; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * Statement-scoped memoization arena backing {@link ConnectorStatementScope}, hung on the per-statement + * {@link org.apache.doris.nereids.StatementContext}. + * + *

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

    + */ +public class ConnectorStatementScopeImpl implements ConnectorStatementScope { + + private static final Logger LOG = LogManager.getLogger(ConnectorStatementScopeImpl.class); + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private boolean closed; + + @Override + @SuppressWarnings("unchecked") + public T computeIfAbsent(String key, Supplier loader) { + return (T) cache.computeIfAbsent(key, k -> loader.get()); + } + + /** + * Tears the statement's scoped values down at statement end, in two ordered passes. Pass 1 finalizes any + * {@link CatalogStatementTransaction} (rolling back a transaction the executor never committed — only a + * mid-flight abort leaves one active); pass 2 closes every remaining {@link AutoCloseable} value (the + * memoized metadata, etc.). Transactions are finalized BEFORE the metadata they were minted from is closed. + * Idempotent: guarded by {@code closed} so a second trigger (the query-finish callback vs. a reused prepared + * statement's per-execution reset) is a harmless no-op and no value is double-closed. Best-effort per value — + * a failure on one does not abort the rest — mirroring the isolation of the engine's query-finish callback + * registry. Runs after the scan off-thread pumps have quiesced, so it does not race a concurrent + * {@code computeIfAbsent}. + */ + @Override + public synchronized void closeAll() { + if (closed) { + return; + } + closed = true; + // Pass 1: finalize the statement's write transaction(s) first, so a transaction aborted mid-flight is + // rolled back / released before pass 2 closes the shared metadata instance it was minted from. On every + // normal path the executor already finished the transaction, so this is a no-op. + for (Object value : cache.values()) { + if (value instanceof CatalogStatementTransaction) { + try { + ((CatalogStatementTransaction) value).finalizeAtStatementEnd(); + } catch (Exception e) { + LOG.warn("failed to finalize per-statement transaction; continuing", e); + } + } + } + // Pass 2: close every remaining AutoCloseable value once (the transaction holders handled above are + // skipped here). + for (Object value : cache.values()) { + if (value instanceof CatalogStatementTransaction) { + continue; + } + if (value instanceof AutoCloseable) { + try { + ((AutoCloseable) value).close(); + } catch (Exception e) { + LOG.warn("failed to close per-statement scope value of type {}; continuing", + value.getClass().getName(), e); + } + } + } + cache.clear(); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/connector/DefaultConnectorContext.java b/fe/fe-core/src/main/java/org/apache/doris/connector/DefaultConnectorContext.java index 896174ad0b49c7..522e3782517787 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/connector/DefaultConnectorContext.java +++ b/fe/fe-core/src/main/java/org/apache/doris/connector/DefaultConnectorContext.java @@ -17,19 +17,57 @@ package org.apache.doris.connector; +import org.apache.doris.catalog.Env; +import org.apache.doris.catalog.FsBroker; import org.apache.doris.cloud.security.SecurityChecker; +import org.apache.doris.common.ClientPool; import org.apache.doris.common.Config; import org.apache.doris.common.EnvUtils; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; +import org.apache.doris.common.Version; +import org.apache.doris.common.util.LocationPath; +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.connector.spi.ConnectorBrokerAddress; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorMetaInvalidator; +import org.apache.doris.datasource.CatalogIf; +import org.apache.doris.datasource.ExternalCatalog; +import org.apache.doris.datasource.credentials.CredentialUtils; +import org.apache.doris.datasource.property.storage.StorageProperties; +import org.apache.doris.filesystem.FileEntry; +import org.apache.doris.filesystem.FileIterator; +import org.apache.doris.filesystem.FileSystem; +import org.apache.doris.filesystem.Location; +import org.apache.doris.fs.FileSystemFactory; +import org.apache.doris.fs.SpiSwitchingFileSystem; +import org.apache.doris.kerberos.ExecutionAuthenticator; +import org.apache.doris.system.Backend; +import org.apache.doris.thrift.BackendService; +import org.apache.doris.thrift.TNetworkAddress; +import org.apache.doris.thrift.TStatusCode; +import org.apache.doris.thrift.TStorageBackendType; +import org.apache.doris.thrift.TTestStorageConnectivityRequest; +import org.apache.doris.thrift.TTestStorageConnectivityResponse; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.Closeable; +import java.io.IOException; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.Callable; +import java.util.function.Function; import java.util.function.Supplier; +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; /** * Default implementation of {@link ConnectorContext}. @@ -37,7 +75,9 @@ *

    Provides the minimal catalog-level context that connector providers need * during creation. Additional context fields can be added here as the SPI evolves. */ -public class DefaultConnectorContext implements ConnectorContext { +public class DefaultConnectorContext implements ConnectorContext, Closeable { + + private static final Logger LOG = LogManager.getLogger(DefaultConnectorContext.class); private static final ExecutionAuthenticator NOOP_AUTH = new ExecutionAuthenticator() {}; @@ -45,6 +85,24 @@ public class DefaultConnectorContext implements ConnectorContext { private final long catalogId; private final Map environment; private final Supplier authSupplier; + // Lazily supplies the catalog's static storage-properties map for storage-URI normalization + // (FIX-URI-NORMALIZE). Invoked at scan time only (catalog fully initialized). Empty for ctors + // that do not wire it — those callers (non-plugin catalogs) never invoke normalizeStorageUri. + private final Supplier> storagePropertiesSupplier; + // Supplies the catalog's effective raw storage map (persisted props + derived defaults, empty when the + // connector supplies vended credentials) for direct fe-filesystem binding in getStorageProperties() + // (design S2): no fe-core StorageProperties parse on the connector storage path. Empty for ctors that do + // not wire it (non-plugin / 2-3-4-arg) — those yield an empty storage list, correct parity. + private final Supplier> rawStoragePropsSupplier; + + // 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 + // 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(); + private volatile FileSystem catalogFileSystem; + private volatile boolean closed; private final ConnectorHttpSecurityHook httpSecurityHook = new ConnectorHttpSecurityHook() { @Override @@ -64,9 +122,26 @@ public DefaultConnectorContext(String catalogName, long catalogId) { public DefaultConnectorContext(String catalogName, long catalogId, Supplier authSupplier) { + this(catalogName, catalogId, authSupplier, Collections::emptyMap); + } + + public DefaultConnectorContext(String catalogName, long catalogId, + Supplier authSupplier, + Supplier> storagePropertiesSupplier) { + this(catalogName, catalogId, authSupplier, storagePropertiesSupplier, Collections::emptyMap); + } + + public DefaultConnectorContext(String catalogName, long catalogId, + Supplier authSupplier, + Supplier> storagePropertiesSupplier, + Supplier> rawStoragePropsSupplier) { this.catalogName = Objects.requireNonNull(catalogName, "catalogName"); this.catalogId = catalogId; this.authSupplier = Objects.requireNonNull(authSupplier, "authSupplier"); + this.storagePropertiesSupplier = + Objects.requireNonNull(storagePropertiesSupplier, "storagePropertiesSupplier"); + this.rawStoragePropsSupplier = + Objects.requireNonNull(rawStoragePropsSupplier, "rawStoragePropsSupplier"); this.environment = buildEnvironment(); } @@ -90,6 +165,23 @@ 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 + // concrete class is loaded by that type's own plugin classloader (child-first) — never co-packaged into + // the gateway's plugin. Passing `this` lets the sibling reuse this catalog's id/auth/storage suppliers + // (correct for e.g. iceberg-on-HMS, which shares the HMS catalog's metastore + storage + credentials). + // Returns null when no provider matches the type (or the plugin manager is not initialized); the + // gateway caller null-checks and fails loud with its own (connector-specific) message — fe-core stays + // connector-agnostic and does no property parsing here. + return ConnectorFactory.createConnector(catalogType, properties, this); + } + @Override public String sanitizeJdbcUrl(String jdbcUrl) { try { @@ -104,6 +196,375 @@ public T executeAuthenticated(Callable task) throws Exception { return authSupplier.get().execute(task); } + @Override + public Map vendStorageCredentials(Map rawVendedCredentials) { + // Map the per-table vended token to the BE-facing AWS_* properties. Fail-soft (empty) on any + // error, matching the legacy provider, so a malformed token degrades gracefully rather than + // killing the scan. The outer try also covers getBackendPropertiesFromStorageMap so the + // fail-soft boundary is byte-identical to the pre-refactor method; buildVendedStorageMap shares + // the typed-map build with normalizeStorageUri (single source of truth — no drift). + try { + Map map = buildVendedStorageMap(rawVendedCredentials); + return map == null ? Collections.emptyMap() + : CredentialUtils.getBackendPropertiesFromStorageMap(map); + } catch (Exception e) { + LOG.warn("Failed to normalize vended credentials", e); + return Collections.emptyMap(); + } + } + + /** + * Builds the vended {@link StorageProperties} typed map from a raw per-table token: filter to + * cloud-storage props, run {@link StorageProperties#createAll} (normalizes arbitrary token key + * shapes + derives region/endpoint), then index by {@link StorageProperties.Type}. Mirrors the + * legacy vended-credentials normalization tail exactly, so the BE-credential overlay + * ({@link #vendStorageCredentials}) and the URI normalization ({@link #normalizeStorageUri(String, + * Map)}) derive the SAME credentials from the SAME token — no drift. Returns {@code null} when the + * token is null/empty, yields no cloud-storage props, or normalization throws — replicating the + * legacy "return null → fall back to the base/static map" contract. + */ + private Map buildVendedStorageMap( + Map rawVendedCredentials) { + if (rawVendedCredentials == null || rawVendedCredentials.isEmpty()) { + return null; + } + try { + Map filtered = CredentialUtils.filterCloudStorageProperties(rawVendedCredentials); + if (filtered.isEmpty()) { + return null; + } + List vended = StorageProperties.createAll(filtered); + return vended.stream() + .collect(Collectors.toMap(StorageProperties::getType, Function.identity())); + } catch (Exception e) { + LOG.warn("Failed to normalize vended credentials", e); + return null; + } + } + + @Override + public Map getBackendStorageProperties() { + // Mirror legacy PaimonScanNode.getLocationProperties(): translate the catalog's parsed + // StorageProperties map into BE-canonical scan keys (AWS_* for object stores, hadoop/dfs for + // HDFS) via the SAME CredentialUtils.getBackendPropertiesFromStorageMap legacy/iceberg/hive use + // — single source of truth, no drift. The map is already validated at catalog creation, so this + // does not throw; an empty map (non-plugin ctor / local-FS warehouse) yields an empty result + // (no overlay) — correct parity, unlike normalizeStorageUri which must fail-loud on a bad path. + return CredentialUtils.getBackendPropertiesFromStorageMap(storagePropertiesSupplier.get()); + } + + /** + * Engine side of the BE→storage probe: pick a live backend and ask it to reach the location the + * connector resolved. This is the RPC the legacy fe-core {@code StorageConnectivityTester} owned; it + * stays engine-side because it needs the backend registry and the client pool. It is payload-agnostic — + * the storage type and the property map come from the connector, so no filesystem-specific knowledge + * lives here. + * + *

    No alive backend means no probe (not a failure), matching the legacy behavior: a catalog must be + * creatable on a cluster whose backends are still starting up. + */ + @Override + public void testBackendStorageConnectivity(int storageBackendTypeValue, + Map backendProperties) throws Exception { + TStorageBackendType storageType = TStorageBackendType.findByValue(storageBackendTypeValue); + if (storageType == null) { + throw new IllegalArgumentException( + "Unknown storage backend type value: " + storageBackendTypeValue); + } + List aliveBeIds = Env.getCurrentSystemInfo().getAllBackendIds(true); + if (aliveBeIds.isEmpty()) { + LOG.info("Skipping BE storage connectivity test: no alive backend"); + return; + } + Collections.shuffle(aliveBeIds); + Backend backend = Env.getCurrentSystemInfo().getBackend(aliveBeIds.get(0)); + if (backend == null) { + LOG.info("Skipping BE storage connectivity test: backend vanished between listing and lookup"); + return; + } + + TTestStorageConnectivityRequest request = new TTestStorageConnectivityRequest(); + request.setType(storageType); + request.setProperties(backendProperties); + + TNetworkAddress address = new TNetworkAddress(backend.getHost(), backend.getBePort()); + BackendService.Client client = null; + boolean ok = false; + try { + client = ClientPool.backendPool.borrowObject(address); + TTestStorageConnectivityResponse response = client.testStorageConnectivity(request); + if (response.status.getStatusCode() != TStatusCode.OK) { + String errMsg = response.status.isSetErrorMsgs() && !response.status.getErrorMsgs().isEmpty() + ? response.status.getErrorMsgs().get(0) : "Unknown error"; + throw new Exception(errMsg); + } + ok = true; + } finally { + if (client != null) { + if (ok) { + ClientPool.backendPool.returnObject(address, client); + } else { + ClientPool.backendPool.invalidateObject(address, client); + } + } + } + } + + @Override + public List getStorageProperties() { + // Bind the catalog's raw storage map directly via fe-filesystem (design S2): hand the connector its + // storage as typed fe-filesystem StorageProperties (from which it derives its Hadoop/HiveConf config + // and BE creds without importing fe-core), sourcing the raw map straight from the catalog's raw + // storage supplier -- no fe-core StorageProperties.createAll round-trip via getOrigProps(). The raw + // supplier already merges the catalog's derived storage defaults (warehouse -> fs.defaultFS) and + // honors the vended gate (empty for a REST/vended catalog). An empty map (non-plugin ctor / + // REST-vended / credential-less warehouse) yields an empty list -- no static storage, correct parity. + Map rawCatalogProps = rawStoragePropsSupplier.get(); + if (rawCatalogProps == null || rawCatalogProps.isEmpty()) { + return Collections.emptyList(); + } + return FileSystemFactory.bindAllStorageProperties(rawCatalogProps); + } + + @Override + public FileSystem getFileSystem(ConnectorSession session) { + // Engine-owned, per-catalog scheme-routing FileSystem, lazily built and cached so repeated scans reuse + // one instance (avoids rebuilding/re-authenticating per call, mirroring the legacy per-catalog + // FileSystemCache). The session is accepted for the Trino-shaped SPI but not yet used to key a per-user + // filesystem — the current build is catalog-level. Returns null when the catalog has no storage + // machinery (empty storage map: non-plugin ctor / credential-less warehouse), matching the benign + // defaults of getBackendStorageProperties() and cleanupEmptyManagedLocation(). + FileSystem fs = catalogFileSystem; + if (fs != null) { + return fs; + } + synchronized (fsLock) { + if (closed) { + return null; + } + if (catalogFileSystem == null) { + Map storageProps = storagePropertiesSupplier.get(); + if (storageProps == null || storageProps.isEmpty()) { + return null; + } + catalogFileSystem = buildCatalogFileSystem(storageProps); + } + return catalogFileSystem; + } + } + + /** + * Builds the catalog's scheme-routing filesystem from its storage properties. Extracted so tests can + * inject a recording fake without real storage/FS wiring (mirrors the {@code @VisibleForTesting} seams + * used elsewhere in this class). + */ + @VisibleForTesting + FileSystem buildCatalogFileSystem(Map storageProps) { + return new SpiSwitchingFileSystem(storageProps); + } + + /** + * Closes the cached catalog filesystem, if one was built. Idempotent. Called by the engine when the + * catalog/context is torn down (connector replacement or catalog close); connectors must never call it. + */ + @Override + public void close() throws IOException { + FileSystem fs; + synchronized (fsLock) { + if (closed) { + return; + } + closed = true; + fs = catalogFileSystem; + catalogFileSystem = null; + } + if (fs != null) { + fs.close(); + } + } + + @Override + public String normalizeStorageUri(String rawUri) { + // No vended token → normalize against the catalog's static storage map (behavior unchanged). + return normalizeStorageUri(rawUri, null); + } + + @Override + public String normalizeStorageUri(String rawUri, Map rawVendedCredentials) { + if (Strings.isNullOrEmpty(rawUri)) { + return rawUri; + } + // Mirror legacy PaimonScanNode's 2-arg LocationPath.of(path, storagePropertiesMap): + // scheme-normalize (oss/cos/obs/s3a -> s3, OSS bucket.endpoint -> bucket) so BE's + // scheme-dispatched S3 factory can open the file. The storage map follows the vended + // precedence: when the connector supplies a per-table vended token + // (REST catalogs, whose static map is empty by design) the VENDED map REPLACES the static map; + // otherwise the catalog's static storage map is used. Fail-loud (StoragePropertiesException + // propagates) — a path that cannot be normalized would otherwise silently corrupt reads (esp. a + // deletion-vector path on merge-on-read). Single source of truth: the SAME LocationPath + // normalization legacy/iceberg/hive use, so no drift. + Map vended = buildVendedStorageMap(rawVendedCredentials); + Map effective = + vended != null ? vended : storagePropertiesSupplier.get(); + return LocationPath.of(rawUri, effective).toStorageLocation().toString(); + } + + @Override + public UnaryOperator newStorageUriNormalizer(Map rawVendedCredentials) { + // PERF: the vended token is scan-invariant, so derive the effective storage map (the expensive + // buildVendedStorageMap = StorageProperties.createAll + hadoop config build) ONCE per scan and reuse + // it for every per-file normalize, instead of rebuilding it per data/delete file. Each application is + // byte-identical to normalizeStorageUri(rawUri, token): the SAME empty-uri short-circuit, the SAME + // vended-replaces-static precedence, the SAME fail-loud LocationPath. The derivation is done LAZILY on + // the first non-empty URI (not eagerly at construction) so a scan that normalizes zero non-empty URIs + // triggers no derivation — preserving the exact exception timing of the per-call method. The returned + // normalizer is single-threaded per scan (the streaming pump drives one thread; the synchronous and + // position-delete loops are single-threaded), so the memo needs no lock. + return new UnaryOperator() { + private Map effective; + private boolean built; + + @Override + public String apply(String rawUri) { + if (Strings.isNullOrEmpty(rawUri)) { + return rawUri; + } + if (!built) { + Map vended = + buildVendedStorageMap(rawVendedCredentials); + effective = vended != null ? vended : storagePropertiesSupplier.get(); + built = true; + } + return LocationPath.of(rawUri, effective).toStorageLocation().toString(); + } + }; + } + + @Override + public String getBackendFileType(String rawUri, Map rawVendedCredentials) { + // Same LocationPath build as normalizeStorageUri (vended-aware), then read the BE file type from + // it — authoritative over the scheme-only default because it also detects a broker-backed path via + // the storage properties. Returns the TFileType enum NAME (the SPI stays Thrift-free). Mirrors + // legacy IcebergTableSink.bindDataSink's + // LocationPath.of(originalLocation, storagePropertiesMap).getTFileTypeForBE(). + Map vended = buildVendedStorageMap(rawVendedCredentials); + Map effective = + vended != null ? vended : storagePropertiesSupplier.get(); + return LocationPath.of(rawUri, effective).getTFileTypeForBE().name(); + } + + @Override + public List getBrokerAddresses() { + // Engine-side resolution of the catalog's broker backend (the connector cannot reach BrokerMgr / + // bindBrokerName). Mirrors legacy BaseExternalTableDataSink.getBrokerAddresses: the catalog's bound + // broker name -> getBrokers(name) (or getAllBrokers() when unbound) -> host/port, shuffled for + // load-balance. Returns empty when none is alive; the connector turns that into a fail-loud + // "No alive broker." for a FILE_BROKER write (this hook is only consulted for that target). + CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(catalogId); + String bindBroker = catalog instanceof ExternalCatalog + ? ((ExternalCatalog) catalog).bindBrokerName() : null; + List brokers = bindBroker != null + ? Env.getCurrentEnv().getBrokerMgr().getBrokers(bindBroker) + : Env.getCurrentEnv().getBrokerMgr().getAllBrokers(); + if (brokers == null || brokers.isEmpty()) { + return Collections.emptyList(); + } + Collections.shuffle(brokers); + List result = new ArrayList<>(brokers.size()); + for (FsBroker broker : brokers) { + result.add(new ConnectorBrokerAddress(broker.host, broker.port)); + } + return result; + } + + @Override + public void cleanupEmptyManagedLocation(String location, List tableChildDirs) { + // Engine-side companion to a connector drop: prune the empty directory shells the connector's drop + // leaves behind. The connector decides WHEN (e.g. iceberg HMS-only) and captures the location before + // the drop; here we own the fe-filesystem machinery it cannot reach (SpiSwitchingFileSystem from the + // catalog's storage properties). Best-effort: a missing storage binding or any IO failure is logged, + // never propagated — cleanup is cosmetic and must not fail the completed drop. Conservative: a + // directory is removed only when it contains no files (deleteEmptyDirectory aborts on the first file). + if (Strings.isNullOrEmpty(location)) { + return; + } + Map storageProperties = storagePropertiesSupplier.get(); + if (storageProperties == null || storageProperties.isEmpty()) { + return; + } + try (FileSystem fs = new SpiSwitchingFileSystem(storageProperties)) { + boolean deleted = (tableChildDirs == null || tableChildDirs.isEmpty()) + ? deleteEmptyDirectory(fs, Location.of(location)) + : deleteEmptyTableLocation(fs, Location.of(location), tableChildDirs); + if (deleted) { + LOG.info("Cleaned empty managed location {}", location); + } else { + LOG.info("Skip cleaning managed location {}, it still contains files", location); + } + } catch (Exception e) { + LOG.warn("Failed to clean managed location {} after drop", location, e); + } + } + + /** + * Deletes the engine-format child directories ({@code tableChildDirs}, e.g. iceberg + * {@code ["data", "metadata"]}) under {@code location} first, then {@code location} itself — each only + * when empty. Port of legacy {@code IcebergMetadataOps.deleteEmptyTableLocation}. + */ + @VisibleForTesting + static boolean deleteEmptyTableLocation(FileSystem fs, Location location, List tableChildDirs) + throws IOException { + for (String childDir : tableChildDirs) { + if (!deleteEmptyDirectory(fs, location.resolve(childDir))) { + return false; + } + } + return deleteEmptyDirectory(fs, location); + } + + /** + * Recursively removes {@code location} iff it (transitively) contains no files: it aborts (returns + * {@code false}) on the first non-directory entry, so live data is never deleted. Port of legacy + * {@code IcebergMetadataOps.deleteEmptyDirectory}. + */ + @VisibleForTesting + static boolean deleteEmptyDirectory(FileSystem fs, Location location) throws IOException { + if (!fs.exists(location)) { + return true; + } + List childDirectories = new ArrayList<>(); + try (FileIterator iterator = fs.list(location)) { + while (iterator.hasNext()) { + FileEntry entry = iterator.next(); + if (!entry.isDirectory()) { + return false; + } + childDirectories.add(entry.location()); + } + } + for (Location childDirectory : childDirectories) { + if (!deleteEmptyDirectory(fs, childDirectory)) { + return false; + } + } + return deleteEmptyDirectoryMarker(fs, location); + } + + /** Deletes the (empty) directory marker for {@code location}. Port of legacy {@code IcebergMetadataOps}. */ + private static boolean deleteEmptyDirectoryMarker(FileSystem fs, Location location) throws IOException { + Location directoryMarker = Location.of(withTrailingSlash(location.uri())); + try { + fs.delete(directoryMarker, false); + } catch (IOException e) { + return !fs.exists(location); + } + return !fs.exists(location); + } + + private static String withTrailingSlash(String uri) { + return uri.endsWith("/") ? uri : uri + "/"; + } + private static Map buildEnvironment() { Map env = new HashMap<>(); String dorisHome = EnvUtils.getDorisHome(); @@ -114,6 +575,24 @@ private static Map buildEnvironment() { env.put("force_sqlserver_jdbc_encrypt_false", String.valueOf(Config.force_sqlserver_jdbc_encrypt_false)); env.put("jdbc_driver_secure_path", Config.jdbc_driver_secure_path); + // HMS metastore client socket-timeout default (C4): the metastore-spi cannot read FE Config + // (no fe-common dependency), so the FE-configured value is threaded through the environment and + // applied by HmsMetaStoreProperties.toHiveConfOverrides when the user has not overridden it. + env.put("hive_metastore_client_timeout_second", + String.valueOf(Config.hive_metastore_client_timeout_second)); + // Hive CREATE TABLE defaults (P7.1): the fe-connector-hive plugin cannot read FE Config, so the two + // FE-global CREATE TABLE toggles are threaded through the environment (not persisted into the catalog + // property map) and applied by HiveConnectorMetadata.createTable when the user did not override them. + // Keys must stay byte-identical to the reads in HiveConnectorProperties. + env.put("hive_default_file_format", Config.hive_default_file_format); + env.put("enable_create_hive_bucket_table", String.valueOf(Config.enable_create_hive_bucket_table)); + // Build version stamped into a created Hive table's doris.version parameter (legacy + // ExternalCatalog.DORIS_VERSION_VALUE); the plugin cannot import fe-common Version. + env.put("doris_version", Version.DORIS_BUILD_VERSION + "-" + Version.DORIS_BUILD_SHORT_HASH); + // The trino-connector plugin runs in an isolated classloader and cannot read FE + // Config (it would see its own bundled copy with default values). Pass the + // configured plugin dir through the engine environment instead. + env.put("trino_connector_plugin_dir", Config.trino_connector_plugin_dir); return Collections.unmodifiableMap(env); } } 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 new file mode 100644 index 00000000000000..38fc3239d92ba2 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/connector/ExternalMetaCacheInvalidator.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; + +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 new file mode 100644 index 00000000000000..1d06a53018abd2 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/connector/ddl/CreateTableInfoToConnectorRequestConverter.java @@ -0,0 +1,242 @@ +// 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.ddl; + +import org.apache.doris.catalog.AggregateType; +import org.apache.doris.catalog.PartitionType; +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.ddl.ConnectorBucketSpec; +import org.apache.doris.connector.api.ddl.ConnectorCreateTableRequest; +import org.apache.doris.connector.api.ddl.ConnectorPartitionField; +import org.apache.doris.connector.api.ddl.ConnectorPartitionSpec; +import org.apache.doris.connector.api.ddl.ConnectorSortField; +import org.apache.doris.datasource.connector.converter.ConnectorColumnConverter; +import org.apache.doris.nereids.analyzer.UnboundFunction; +import org.apache.doris.nereids.analyzer.UnboundSlot; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.expressions.literal.IntegerLikeLiteral; +import org.apache.doris.nereids.trees.expressions.literal.Literal; +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.PartitionTableInfo; +import org.apache.doris.nereids.trees.plans.commands.info.SortFieldInfo; +import org.apache.doris.nereids.types.DataType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Converts a nereids {@link CreateTableInfo} into a connector-SPI + * {@link ConnectorCreateTableRequest}. + * + *

    Covers Hive-style {@code IDENTITY}, Iceberg-style {@code TRANSFORM}, and + * Doris {@code LIST} / {@code RANGE} partitioning, plus hash / random + * distribution.

    + */ +public final class CreateTableInfoToConnectorRequestConverter { + + private CreateTableInfoToConnectorRequestConverter() { + } + + /** + * @param info the nereids CREATE TABLE info (must be analyzed) + * @param dbName target database name (caller may normalize case) + */ + public static ConnectorCreateTableRequest convert(CreateTableInfo info, + String dbName) { + return ConnectorCreateTableRequest.builder() + .dbName(dbName) + .tableName(info.getTableName()) + .columns(convertColumns(info.getColumnDefinitions())) + .partitionSpec(convertPartition(info.getPartitionTableInfo())) + .bucketSpec(convertBucket(info.getDistribution())) + .sortOrder(convertSortOrder(info.getSortOrderFields())) + .comment(info.getComment()) + .properties(info.getProperties()) + .ifNotExists(info.isIfNotExists()) + .external(info.isExternal()) + .build(); + } + + // -------- columns -------- + + private static List convertColumns( + List defs) { + if (defs == null || defs.isEmpty()) { + return Collections.emptyList(); + } + List out = new ArrayList<>(defs.size()); + for (ColumnDefinition d : defs) { + DataType nereidsType = d.getType(); + ConnectorType type = ConnectorColumnConverter.toConnectorType( + nereidsType.toCatalogDataType()); + // Mirror Column.isAggregated(): a non-null, non-NONE aggregate type means the user + // wrote an aggregate column (e.g. SUM). The connector rejects these for engines that + // cannot store them (MaxCompute); see MaxComputeConnectorMetadata.validateColumns. + boolean isAggregated = d.getAggType() != null + && d.getAggType() != AggregateType.NONE; + // Thread the catalog-level default value string (null when the column has no DEFAULT clause). + // Hive builds metastore default constraints from it and gates its DLF catalog on per-column + // defaults; connectors that ignore create-time defaults (iceberg/paimon/maxcompute build their + // schema from name/type/nullable/comment only) are unaffected. + out.add(new ConnectorColumn( + d.getName(), type, d.getComment(), + d.isNullable(), d.getDefaultValueString(), d.isKey(), d.getAutoIncInitValue() != -1, + isAggregated)); + } + return out; + } + + // -------- partition -------- + + private static ConnectorPartitionSpec convertPartition( + PartitionTableInfo info) { + if (info == null) { + return null; + } + String pType = info.getPartitionType(); + List exprs = info.getPartitionList(); + boolean isList = PartitionType.LIST.name().equalsIgnoreCase(pType); + boolean isRange = PartitionType.RANGE.name().equalsIgnoreCase(pType); + boolean hasExprs = exprs != null && !exprs.isEmpty(); + if (!isList && !isRange && !hasExprs) { + return null; + } + + ConnectorPartitionSpec.Style style; + if (isList) { + style = ConnectorPartitionSpec.Style.LIST; + } else if (isRange) { + style = ConnectorPartitionSpec.Style.RANGE; + } else if (hasAnyTransform(exprs)) { + style = ConnectorPartitionSpec.Style.TRANSFORM; + } else { + style = ConnectorPartitionSpec.Style.IDENTITY; + } + + List fields = hasExprs + ? convertFields(exprs) + : Collections.emptyList(); + // LIST/RANGE PartitionDefinition values are not lowered here: each + // 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). + boolean hasExplicitValues = info.getPartitionDefs() != null + && !info.getPartitionDefs().isEmpty(); + return new ConnectorPartitionSpec(style, fields, Collections.emptyList(), hasExplicitValues); + } + + private static boolean hasAnyTransform(List exprs) { + for (Expression e : exprs) { + if (e instanceof UnboundFunction) { + return true; + } + } + return false; + } + + private static List convertFields( + List exprs) { + List out = new ArrayList<>(exprs.size()); + for (Expression e : exprs) { + if (e instanceof UnboundSlot) { + out.add(new ConnectorPartitionField( + ((UnboundSlot) e).getName(), "identity", + Collections.emptyList())); + } else if (e instanceof UnboundFunction) { + out.add(convertTransformField((UnboundFunction) e)); + } + // Unknown expression shapes are dropped; the connector can still + // honor the spec via its own analysis if richer info is required. + } + return out; + } + + private static ConnectorPartitionField convertTransformField( + UnboundFunction fn) { + String transform = fn.getName().toLowerCase(); + String columnName = null; + List args = new ArrayList<>(); + for (Expression child : fn.children()) { + if (child instanceof UnboundSlot && columnName == null) { + columnName = ((UnboundSlot) child).getName(); + } else if (child instanceof IntegerLikeLiteral) { + args.add(((IntegerLikeLiteral) child).getIntValue()); + } else if (child instanceof Literal) { + Object v = ((Literal) child).getValue(); + if (v instanceof Number) { + args.add(((Number) v).intValue()); + } + } + } + if (columnName == null) { + columnName = fn.toString(); + } + return new ConnectorPartitionField(columnName, transform, args); + } + + // -------- bucket -------- + + private static ConnectorBucketSpec convertBucket(DistributionDescriptor d) { + if (d == null) { + return null; + } + List cols = d.getCols() == null + ? Collections.emptyList() + : d.getCols(); + // bucketNum is private; read it off the translated catalog desc so we + // do not depend on private internals. + int numBuckets = readBucketNum(d); + String algorithm = d.isHash() ? "doris_default" : "doris_random"; + return new ConnectorBucketSpec(cols, numBuckets, algorithm); + } + + private static int readBucketNum(DistributionDescriptor d) { + try { + return d.translateToCatalogStyle().getBuckets(); + } catch (Exception ignored) { + return 0; + } + } + + // -------- sort order -------- + + /** + * 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. + */ + private static List convertSortOrder(List sortFields) { + if (sortFields == null || sortFields.isEmpty()) { + return Collections.emptyList(); + } + List out = new ArrayList<>(sortFields.size()); + for (SortFieldInfo f : sortFields) { + out.add(new ConnectorSortField(f.getColumnName(), f.isAscending(), f.isNullFirst())); + } + return out; + } +} 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 a5afd90dfc5883..2016402685470e 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 @@ -25,12 +25,9 @@ import org.apache.doris.connector.DefaultConnectorContext; import org.apache.doris.connector.api.Connector; import org.apache.doris.datasource.doris.RemoteDorisExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalogFactory; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog; -import org.apache.doris.datasource.paimon.PaimonExternalCatalogFactory; +import org.apache.doris.datasource.log.CatalogLog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.datasource.test.TestExternalCatalog; -import org.apache.doris.datasource.trinoconnector.TrinoConnectorExternalCatalogFactory; import org.apache.doris.nereids.trees.plans.commands.CreateCatalogCommand; import com.google.common.base.Strings; @@ -47,10 +44,17 @@ public class CatalogFactory { private static final Logger LOG = LogManager.getLogger(CatalogFactory.class); - // Only these catalog types are routed through the SPI connector path. - // Other types (hms, iceberg, paimon, trino-connector, hudi, max_compute) still use - // their built-in ExternalCatalog implementations until their ConnectorProviders are fully ready. - private static final Set SPI_READY_TYPES = ImmutableSet.of("jdbc", "es"); + // 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"); /** * create the catalog instance from catalog log. @@ -133,25 +137,9 @@ private static CatalogIf createCatalog(long catalogId, String name, String resou // Fallback to built-in catalog types if no SPI connector matched. if (catalog == null) { switch (catalogType) { - case "hms": - catalog = new HMSExternalCatalog(catalogId, name, resource, props, comment); - break; - case "iceberg": - catalog = IcebergExternalCatalogFactory.createCatalog( - catalogId, name, resource, props, comment); - break; - case "paimon": - catalog = PaimonExternalCatalogFactory.createCatalog( - catalogId, name, resource, props, comment); - break; - case "trino-connector": - catalog = TrinoConnectorExternalCatalogFactory.createCatalog( - catalogId, name, resource, props, comment); - break; - case "max_compute": - catalog = new MaxComputeExternalCatalog( - catalogId, name, resource, props, comment); - break; + // 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": 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 c598ef520f5ef6..568e228ad34ac2 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 @@ -31,7 +31,11 @@ import org.apache.doris.common.ErrorCode; import org.apache.doris.common.MetaNotFoundException; import org.apache.doris.common.UserException; +import org.apache.doris.datasource.log.CatalogLog; +import org.apache.doris.nereids.trees.plans.commands.info.AddPartitionFieldOp; import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo; +import org.apache.doris.nereids.trees.plans.commands.info.DropPartitionFieldOp; +import org.apache.doris.nereids.trees.plans.commands.info.ReplacePartitionFieldOp; import com.google.common.collect.Lists; import org.apache.logging.log4j.LogManager; @@ -268,4 +272,18 @@ default void modifyColumn(TableIf table, Column column, ColumnPosition columnPos default void reorderColumns(TableIf table, List newOrder) throws UserException { throw new UserException("Not support reorder columns operation"); } + + // partition evolution operations (Iceberg): add / drop / replace a partition field + + default void addPartitionField(TableIf table, AddPartitionFieldOp op) throws UserException { + throw new UserException("Not support add partition field operation"); + } + + default void dropPartitionField(TableIf table, DropPartitionFieldOp op) throws UserException { + throw new UserException("Not support drop partition field operation"); + } + + default void replacePartitionField(TableIf table, ReplacePartitionFieldOp op) throws UserException { + throw new UserException("Not support replace partition field operation"); + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogMgr.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogMgr.java index 83e778ac2f0ced..681088d47293c0 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogMgr.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogMgr.java @@ -22,7 +22,6 @@ import org.apache.doris.catalog.Env; import org.apache.doris.catalog.EnvFactory; import org.apache.doris.catalog.TableIf; -import org.apache.doris.catalog.Type; import org.apache.doris.common.AnalysisException; import org.apache.doris.common.CaseSensibility; import org.apache.doris.common.DdlException; @@ -38,13 +37,9 @@ import org.apache.doris.common.util.DatasourcePrintableMap; import org.apache.doris.common.util.TimeUtils; import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalDatabase; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HiveExternalMetaCache; -import org.apache.doris.datasource.mvcc.MvccUtil; +import org.apache.doris.datasource.log.CatalogLog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.mysql.privilege.PrivPredicate; -import org.apache.doris.nereids.exceptions.NotSupportedException; import org.apache.doris.nereids.trees.plans.commands.CreateCatalogCommand; import org.apache.doris.persist.OperationType; import org.apache.doris.persist.gson.GsonPostProcessable; @@ -738,9 +733,7 @@ public void registerExternalTableFromEvent(String dbName, String tableName, return; } - long tblId; - HMSExternalCatalog hmsCatalog = (HMSExternalCatalog) catalog; - tblId = Util.genIdByName(catalogName, dbName, tableName); + long tblId = Util.genIdByName(catalogName, dbName, tableName); // -1L means it will be dropped later, ignore if (tblId == ExternalMetaIdMgr.META_ID_FOR_NOT_EXISTS) { return; @@ -748,8 +741,12 @@ public void registerExternalTableFromEvent(String dbName, String tableName, db.writeLock(); try { - HMSExternalTable namedTable = ((HMSExternalDatabase) db) - .buildTableForInit(tableName, tableName, tblId, hmsCatalog, (HMSExternalDatabase) db, false); + // buildTableForInit is generic on ExternalDatabase and dispatches to the catalog's own table + // type (HMSExternalTable for a legacy catalog, PluginDrivenMvccExternalTable/PluginDrivenExternalTable + // for a flipped one), so the event path builds+registers the right table on both without an HMS cast. + ExternalDatabase externalDb = (ExternalDatabase) db; + ExternalTable namedTable = externalDb.buildTableForInit(tableName, tableName, tblId, + (ExternalCatalog) catalog, externalDb, false); namedTable.setUpdateTime(updateTime); db.registerTable(namedTable); } finally { @@ -766,7 +763,7 @@ public void unregisterExternalDatabase(String dbName, String catalogName) if (!(catalog instanceof ExternalCatalog)) { throw new DdlException("Only support drop ExternalCatalog databases"); } - ((HMSExternalCatalog) catalog).unregisterDatabase(dbName); + ((ExternalCatalog) catalog).unregisterDatabase(dbName); } public void registerExternalDatabaseFromEvent(String dbName, String catalogName) @@ -779,14 +776,15 @@ public void registerExternalDatabaseFromEvent(String dbName, String catalogName) throw new DdlException("Only support create ExternalCatalog databases"); } - HMSExternalCatalog hmsCatalog = (HMSExternalCatalog) catalog; long dbId = Util.genIdByName(catalogName, dbName); // -1L means it will be dropped later, ignore if (dbId == ExternalMetaIdMgr.META_ID_FOR_NOT_EXISTS) { return; } - hmsCatalog.registerDatabase(dbId, dbName); + // registerDatabase is overridden by HMSExternalCatalog (legacy) and PluginDrivenExternalCatalog + // (flipped); the generic ExternalCatalog base throws (fail-loud for catalogs that cannot register). + ((ExternalCatalog) catalog).registerDatabase(dbId, dbName); } public void addExternalPartitions(String catalogName, String dbName, String tableName, @@ -814,22 +812,14 @@ public void addExternalPartitions(String catalogName, String dbName, String tabl } return; } - if (!(table instanceof HMSExternalTable)) { - LOG.warn("only support HMSTable"); - return; - } - - HMSExternalTable hmsTable = (HMSExternalTable) table; - List partitionColumnTypes; - try { - partitionColumnTypes = hmsTable.getPartitionColumnTypes(MvccUtil.getSnapshotFromContext(hmsTable)); - } catch (NotSupportedException e) { - LOG.warn("Ignore not supported hms table, message: {} ", e.getMessage()); - return; + if (catalog instanceof PluginDrivenExternalCatalog) { + // The connector owns the partition cache (pull-through), so invalidating by name is enough for + // the added partitions to show up on the next listing. Mirrors refreshTableInternal's connector hook. + ((PluginDrivenExternalCatalog) catalog).getConnector().invalidatePartition( + ((ExternalDatabase) db).getRemoteName(), ((ExternalTable) table).getRemoteName(), + partitionNames); + ((ExternalTable) table).setUpdateTime(updateTime); } - HiveExternalMetaCache cache = Env.getCurrentEnv().getExtMetaCacheMgr().hive(catalog.getId()); - cache.addPartitionsCache(hmsTable.getOrBuildNameMapping(), partitionNames, partitionColumnTypes); - hmsTable.setUpdateTime(updateTime); } public void dropExternalPartitions(String catalogName, String dbName, String tableName, @@ -858,10 +848,13 @@ public void dropExternalPartitions(String catalogName, String dbName, String tab return; } - HMSExternalTable hmsTable = (HMSExternalTable) table; - Env.getCurrentEnv().getExtMetaCacheMgr().hive(catalog.getId()) - .dropPartitionsCache(hmsTable, partitionNames, true); - hmsTable.setUpdateTime(updateTime); + if (catalog instanceof PluginDrivenExternalCatalog) { + // The connector owns the partition cache (pull-through); invalidate by name. + ((PluginDrivenExternalCatalog) catalog).getConnector().invalidatePartition( + ((ExternalDatabase) db).getRemoteName(), ((ExternalTable) table).getRemoteName(), + partitionNames); + ((ExternalTable) table).setUpdateTime(updateTime); + } } public void registerCatalogRefreshListener(Env env) { 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 540e23e16281de..138e9f320bbd36 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 @@ -18,13 +18,10 @@ package org.apache.doris.datasource; import org.apache.doris.common.UserException; -import org.apache.doris.datasource.credentials.AbstractVendedCredentialsProvider; -import org.apache.doris.datasource.credentials.VendedCredentialsFactory; import org.apache.doris.datasource.property.metastore.MetastoreProperties; import org.apache.doris.datasource.property.storage.StorageProperties; import com.google.common.base.Preconditions; -import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.gson.annotations.SerializedName; import org.apache.commons.collections4.MapUtils; @@ -33,10 +30,12 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Function; +import java.util.function.Supplier; import java.util.stream.Collectors; /** @@ -58,21 +57,6 @@ public class CatalogProperty { @SerializedName(value = "properties") private Map properties; - /** - * An ordered list of all initialized {@link StorageProperties} instances. - *

    - * The order of this list is significant: - *

      - *
    • The default HDFSProperties (if auto-created) is always inserted at index 0.
    • - *
    • Explicitly configured storage providers follow in the order they are detected.
    • - *
    • Callers rely on this deterministic ordering for selecting or iterating through - * storage backends.
    • - *
    - *

    - * Declared as {@code volatile} to ensure visibility across threads once initialized. - */ - private volatile List orderedStoragePropertiesList; - // Lazy-loaded storage properties map, using volatile to ensure visibility private volatile Map storagePropertiesMap; @@ -85,6 +69,14 @@ public class CatalogProperty { // Lazy-loaded Hadoop properties, using volatile to ensure visibility private volatile Map hadoopProperties; + // Design S8: for a plugin catalog, the connector owns storage-property derivation (e.g. iceberg hadoop + // warehouse -> fs.defaultFS); this supplier returns the connector-derived storage defaults fe-core folds + // into the storage map, so fe-core does NOT parse metastore properties on the storage path. Null for a + // legacy catalog (Hive/Hudi/HMS-iceberg/LakeSoul), which derives from its fe-core MetastoreProperties. Set + // once by PluginDrivenExternalCatalog after the connector is created; deliberately NOT cleared by + // resetAllCaches (it is catalog wiring, not a derived cache, and an ALTER re-runs catalog init anyway). + private volatile Supplier> pluginDerivedStorageDefaultsSupplier; + public CatalogProperty(String resource, Map properties) { this.resource = resource; // Keep but not used this.properties = properties; @@ -177,20 +169,15 @@ private void initStorageProperties() { synchronized (this) { if (storagePropertiesMap == null) { try { - boolean checkStorageProperties = true; - AbstractVendedCredentialsProvider provider = - VendedCredentialsFactory.getProviderType(getMetastoreProperties()); - if (provider != null) { - checkStorageProperties = !provider.isVendedCredentialsEnabled(getMetastoreProperties()); - } - if (checkStorageProperties) { - this.orderedStoragePropertiesList = StorageProperties.createAll(getProperties()); - this.storagePropertiesMap = orderedStoragePropertiesList.stream() - .collect(Collectors.toMap(StorageProperties::getType, Function.identity())); - } else { - this.orderedStoragePropertiesList = Lists.newArrayList(); - this.storagePropertiesMap = Maps.newHashMap(); - } + // Design S4: build the static storage map unconditionally (no vended discrimination). + // For legacy catalogs (Hive/Hudi/HMS-iceberg/LakeSoul) this is exactly today's behavior; + // for a plugin REST/vended catalog the map carries no static object-store creds (the + // connector supplies vended per-table), so createAll yields only inert defaults the + // plugin storage consumers do not use. + Map storageProps = mergeDerivedStorageDefaults(); + List ordered = StorageProperties.createAll(storageProps); + this.storagePropertiesMap = ordered.stream() + .collect(Collectors.toMap(StorageProperties::getType, Function.identity())); } catch (UserException e) { LOG.warn("Failed to initialize catalog storage properties", e); throw new RuntimeException("Failed to initialize storage properties, error: " @@ -201,14 +188,78 @@ private void initStorageProperties() { } } - public Map getStoragePropertiesMap() { - initStorageProperties(); - return storagePropertiesMap; + /** + * The catalog's persisted user props merged with derived storage defaults. For a plugin catalog the + * connector supplies them (design S8: {@link #pluginDerivedStorageDefaultsSupplier} — fe-core does not + * parse metastore properties for storage); for a legacy catalog they come from its fe-core + * {@link MetastoreProperties}. Derived props are defaults (an explicit user key wins via {@code putIfAbsent}) + * and the persisted {@link #getProperties()} map is never mutated. This is the exact map + * {@link #initStorageProperties} feeds to {@link StorageProperties#createAll} and that + * {@link #getEffectiveRawStorageProperties} hands the connector for fe-filesystem binding. + */ + private Map mergeDerivedStorageDefaults() { + Map storageProps = getProperties(); + Map derived = resolveDerivedStorageDefaults(); + if (MapUtils.isNotEmpty(derived)) { + storageProps = new HashMap<>(storageProps); + derived.forEach(storageProps::putIfAbsent); + } + return storageProps; } - public List getOrderedStoragePropertiesList() { + /** + * Resolves the derived storage defaults. A plugin catalog gets them from the connector (design S8 — fe-core + * does not parse metastore properties for storage; the iceberg connector bridges its hadoop warehouse to + * {@code fs.defaultFS}). A legacy catalog derives them from its fe-core {@link MetastoreProperties}, which + * is empty for every remaining type once the iceberg cluster's sole {@code getDerivedStorageProperties} + * override moved to the connector. + */ + private Map resolveDerivedStorageDefaults() { + Supplier> pluginSupplier = pluginDerivedStorageDefaultsSupplier; + if (pluginSupplier != null) { + Map derived = pluginSupplier.get(); + return derived != null ? derived : Collections.emptyMap(); + } + MetastoreProperties msp = getMetastoreProperties(); + return msp != null ? msp.getDerivedStorageProperties() : Collections.emptyMap(); + } + + /** + * Design S8: wires the connector-owned storage-derivation source for a plugin catalog. Once set, storage + * property init folds the connector-derived defaults instead of deriving from fe-core MetastoreProperties, + * so a plugin iceberg/paimon catalog never calls {@link #getMetastoreProperties()} on the storage path. + */ + public void setPluginDerivedStorageDefaultsSupplier(Supplier> supplier) { + this.pluginDerivedStorageDefaultsSupplier = 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 + * 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 + * map unconditionally (the connector owns static+vended precedence, overlaying per-table vended creds); a + * REST/vended catalog carries no static storage keys, so binding it still yields no static storage. + * Byte-identical to {@code getStoragePropertiesMap().values().iterator().next().getOrigProps()} (createAll + * passes the map through unmutated), so the bound typed StorageProperties, and the BE {@code location.*} + * map derived from them, are unchanged. The returned map must be treated as read-only by callers. + */ + public Map getEffectiveRawStorageProperties() { + // synchronized(this) so the metastore read and the persisted-props read form a single consistent + // snapshot, matching the atomicity of the fe-core parse path (initStorageProperties builds under the + // same monitor, mutually exclusive with modifyCatalogProps/addProperty/deleteProperty). Without it a + // concurrent ALTER of a derivation-feeding key (e.g. warehouse) could tear the derived defaults against + // the user props. The critical section is a small map copy + pure derivation and takes no foreign lock, + // so it is deadlock-free and contention-free at scan-planning frequency. + synchronized (this) { + return mergeDerivedStorageDefaults(); + } + } + + public Map getStoragePropertiesMap() { initStorageProperties(); - return orderedStoragePropertiesList; + return storagePropertiesMap; } public void checkMetaStoreAndStorageProperties(Class msClass) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/ConnectorColumnConverter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/ConnectorColumnConverter.java deleted file mode 100644 index 68531a4bc6021e..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ConnectorColumnConverter.java +++ /dev/null @@ -1,220 +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.datasource; - -import org.apache.doris.catalog.ArrayType; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.MapType; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.StructField; -import org.apache.doris.catalog.StructType; -import org.apache.doris.catalog.Type; -import org.apache.doris.connector.api.ConnectorColumn; -import org.apache.doris.connector.api.ConnectorType; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.stream.Collectors; - -/** - * Converts between the connector SPI type system ({@link ConnectorColumn}/{@link ConnectorType}) - * and the Doris internal type system ({@link Column}/{@link Type}). - * - *

    This converter lives in fe-core because it depends on both the SPI API types - * (from fe-connector-api) and the internal Doris catalog types (from fe-type/fe-core).

    - */ -public final class ConnectorColumnConverter { - - private static final Logger LOG = LogManager.getLogger(ConnectorColumnConverter.class); - - private ConnectorColumnConverter() { - } - - /** - * Converts a list of {@link ConnectorColumn} to a list of Doris {@link Column}. - */ - public static List convertColumns(List connectorColumns) { - return connectorColumns.stream() - .map(ConnectorColumnConverter::convertColumn) - .collect(Collectors.toList()); - } - - /** - * Converts a single {@link ConnectorColumn} to a Doris {@link Column}. - */ - public static Column convertColumn(ConnectorColumn cc) { - Type dorisType = convertType(cc.getType()); - return new Column(cc.getName(), dorisType, cc.isKey(), null, - cc.isNullable(), cc.getDefaultValue(), - cc.getComment() != null ? cc.getComment() : ""); - } - - /** - * Converts a Doris {@link Column} to a {@link ConnectorColumn}. - * This is the inverse of {@link #convertColumn(ConnectorColumn)}. - */ - public static ConnectorColumn toConnectorColumn(Column col) { - ConnectorType connectorType = toConnectorType(col.getType()); - return new ConnectorColumn( - col.getName(), - connectorType, - col.getComment(), - col.isAllowNull(), - col.getDefaultValue()); - } - - /** - * Converts a Doris {@link Type} to a {@link ConnectorType}, handling - * complex types (ARRAY, MAP, STRUCT) recursively. - * This is the inverse of {@link #convertType(ConnectorType)}. - */ - public static ConnectorType toConnectorType(Type dorisType) { - if (dorisType instanceof ArrayType) { - ArrayType arr = (ArrayType) dorisType; - return ConnectorType.arrayOf(toConnectorType(arr.getItemType())); - } else if (dorisType instanceof MapType) { - MapType map = (MapType) dorisType; - return ConnectorType.mapOf( - toConnectorType(map.getKeyType()), - toConnectorType(map.getValueType())); - } else if (dorisType instanceof StructType) { - StructType struct = (StructType) dorisType; - List names = new ArrayList<>(); - List types = new ArrayList<>(); - for (StructField f : struct.getFields()) { - names.add(f.getName()); - types.add(toConnectorType(f.getType())); - } - return ConnectorType.structOf(names, types); - } else if (dorisType instanceof ScalarType) { - ScalarType scalar = (ScalarType) dorisType; - return ConnectorType.of( - scalar.getPrimitiveType().toString(), - scalar.getScalarPrecision(), - scalar.getScalarScale()); - } else { - return ConnectorType.of(dorisType.toString(), -1, -1); - } - } - - /** - * Converts a {@link ConnectorType} to a Doris {@link Type}, handling - * complex types (ARRAY, MAP, STRUCT) recursively. - */ - public static Type convertType(ConnectorType ct) { - String typeName = ct.getTypeName().toUpperCase(Locale.ROOT); - switch (typeName) { - case "ARRAY": - return convertArrayType(ct); - case "MAP": - return convertMapType(ct); - case "STRUCT": - return convertStructType(ct); - default: - return convertScalarType(typeName, ct.getPrecision(), ct.getScale()); - } - } - - private static Type convertArrayType(ConnectorType ct) { - List children = ct.getChildren(); - if (children.isEmpty()) { - return ArrayType.create(Type.NULL, true); - } - return ArrayType.create(convertType(children.get(0)), true); - } - - private static Type convertMapType(ConnectorType ct) { - List children = ct.getChildren(); - if (children.size() < 2) { - return new MapType(Type.NULL, Type.NULL); - } - return new MapType(convertType(children.get(0)), convertType(children.get(1))); - } - - 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; - fields.add(new StructField(fieldName, convertType(children.get(i)))); - } - return new StructType(fields); - } - - private static Type convertScalarType(String typeName, int precision, int scale) { - switch (typeName) { - case "CHAR": - if (precision > 0) { - return ScalarType.createCharType(precision); - } - return ScalarType.CHAR; - case "VARCHAR": - if (precision > 0) { - return ScalarType.createVarcharType(precision); - } - return ScalarType.createVarcharType(); - case "DECIMAL": - case "DECIMALV2": - if (precision > 0) { - return ScalarType.createDecimalType(precision, Math.max(scale, 0)); - } - return ScalarType.createDecimalType(); - case "DECIMALV3": - case "DECIMAL32": - case "DECIMAL64": - case "DECIMAL128": - case "DECIMAL256": - if (precision > 0) { - return ScalarType.createDecimalV3Type(precision, Math.max(scale, 0)); - } - return ScalarType.createDecimalV3Type(); - case "DATETIMEV2": - // Connectors encode datetime scale in the precision field of ConnectorType. - if (precision >= 0) { - return ScalarType.createDatetimeV2Type(precision); - } - return ScalarType.DATETIMEV2; - case "TIMESTAMPTZ": - if (precision >= 0) { - return ScalarType.createTimeStampTzType(precision); - } - return ScalarType.createTimeStampTzType(0); - case "VARBINARY": - if (precision > 0) { - return ScalarType.createVarbinaryType(precision); - } - return ScalarType.createVarbinaryType(ScalarType.MAX_VARBINARY_LENGTH); - case "JSONB": - return ScalarType.createType("JSON"); - case "UNSUPPORTED": - return Type.UNSUPPORTED; - default: - try { - return ScalarType.createType(typeName); - } catch (Exception e) { - LOG.warn("Unrecognized connector type '{}', marking as UNSUPPORTED", typeName); - return Type.UNSUPPORTED; - } - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/DatabaseMetadata.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/DatabaseMetadata.java deleted file mode 100644 index 97905ce40ea3c8..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/DatabaseMetadata.java +++ /dev/null @@ -1,22 +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.datasource; - -public interface DatabaseMetadata { - String getDbName(); -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/DorisTypeVisitor.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/DorisTypeVisitor.java deleted file mode 100644 index 54a35acf595a59..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/DorisTypeVisitor.java +++ /dev/null @@ -1,79 +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.datasource; - -import org.apache.doris.catalog.ArrayType; -import org.apache.doris.catalog.MapType; -import org.apache.doris.catalog.StructField; -import org.apache.doris.catalog.StructType; -import org.apache.doris.catalog.Type; - -import com.google.common.collect.Lists; - -import java.util.List; - -/** - * Utils to visit doris and iceberg type - * @param - */ -public class DorisTypeVisitor { - public static T visit(Type type, DorisTypeVisitor visitor) { - if (type instanceof StructType) { - List fields = ((StructType) type).getFields(); - List fieldResults = Lists.newArrayListWithExpectedSize(fields.size()); - - for (StructField field : fields) { - fieldResults.add(visitor.field( - field, - visit(field.getType(), visitor))); - } - - return visitor.struct((StructType) type, fieldResults); - } else if (type instanceof MapType) { - return visitor.map((MapType) type, - visit(((MapType) type).getKeyType(), visitor), - visit(((MapType) type).getValueType(), visitor)); - } else if (type instanceof ArrayType) { - return visitor.array( - (ArrayType) type, - visit(((ArrayType) type).getItemType(), visitor)); - } else { - return visitor.atomic(type); - } - } - - public T struct(StructType struct, List fieldResults) { - return null; - } - - public T field(StructField field, T typeResult) { - return null; - } - - public T array(ArrayType array, T elementResult) { - return null; - } - - public T map(MapType map, T keyResult, T valueResult) { - return null; - } - - public T atomic(Type atomic) { - return null; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java index a742a50f9a62d6..e461473eecf553 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java @@ -29,7 +29,6 @@ import org.apache.doris.catalog.info.DropBranchInfo; 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.Config; import org.apache.doris.common.DdlException; import org.apache.doris.common.FeConstants; @@ -37,29 +36,18 @@ import org.apache.doris.common.ThreadPoolManager; import org.apache.doris.common.UserException; import org.apache.doris.common.Version; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; -import org.apache.doris.common.util.DebugPointUtil; import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.connectivity.CatalogConnectivityTestCoordinator; import org.apache.doris.datasource.doris.RemoteDorisExternalDatabase; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; import org.apache.doris.datasource.infoschema.ExternalInfoSchemaDatabase; import org.apache.doris.datasource.infoschema.ExternalMysqlDatabase; -import org.apache.doris.datasource.lakesoul.LakeSoulExternalDatabase; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalDatabase; +import org.apache.doris.datasource.log.InitCatalogLog; import org.apache.doris.datasource.metacache.MetaCache; -import org.apache.doris.datasource.operations.ExternalMetadataOps; -import org.apache.doris.datasource.paimon.PaimonExternalDatabase; +import org.apache.doris.datasource.plugin.PluginDrivenExternalDatabase; +import org.apache.doris.datasource.property.storage.BrokerProperties; import org.apache.doris.datasource.test.TestExternalCatalog; import org.apache.doris.datasource.test.TestExternalDatabase; -import org.apache.doris.datasource.trinoconnector.TrinoConnectorExternalDatabase; +import org.apache.doris.kerberos.ExecutionAuthenticator; import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo; -import org.apache.doris.persist.CreateDbInfo; -import org.apache.doris.persist.DropDbInfo; -import org.apache.doris.persist.DropInfo; -import org.apache.doris.persist.TableBranchOrTagInfo; import org.apache.doris.persist.TruncateTableInfo; import org.apache.doris.persist.gson.GsonPostProcessable; import org.apache.doris.qe.GlobalVariable; @@ -134,7 +122,6 @@ public abstract class ExternalCatalog protected static final int ICEBERG_CATALOG_EXECUTOR_THREAD_NUM = Runtime.getRuntime().availableProcessors(); public static final String TEST_CONNECTION = "test_connection"; - public static final boolean DEFAULT_TEST_CONNECTION = false; public static final String INCLUDE_DATABASE_LIST = "include_database_list"; public static final String EXCLUDE_DATABASE_LIST = "exclude_database_list"; @@ -173,7 +160,6 @@ public abstract class ExternalCatalog // db name does not contains "default_cluster" protected Map dbNameToId = Maps.newConcurrentMap(); private boolean objectCreated = false; - protected ExternalMetadataOps metadataOps; protected TransactionManager transactionManager; protected MetaCache> metaCache; protected ExecutionAuthenticator executionAuthenticator; @@ -269,30 +255,7 @@ private Configuration buildConf() { * @return list of database names in this catalog */ protected List listDatabaseNames() { - if (metadataOps == null) { - throw new UnsupportedOperationException("List databases is not supported for catalog: " + getName()); - } else { - // Allow manual regression to isolate catalog-level metadata enumeration cost during collect. - if (DebugPointUtil.isEnable("ExternalCatalog.listDatabaseNames.sleep")) { - long sleepMs = DebugPointUtil.getDebugParamOrDefault( - "ExternalCatalog.listDatabaseNames.sleep", "sleepMs", 0L); - if (sleepMs > 0) { - LOG.info("debug point ExternalCatalog.listDatabaseNames.sleep hit for {}, sleep {}ms", - getName(), sleepMs); - try { - Thread.sleep(sleepMs); - } catch (InterruptedException ignore) { - Thread.currentThread().interrupt(); - } - } - } - return metadataOps.listDatabaseNames(); - } - } - - public ExternalMetadataOps getMetadataOps() { - makeSureInitialized(); - return metadataOps; + throw new UnsupportedOperationException("List databases is not supported for catalog: " + getName()); } // Will be called when creating catalog(so when as replaying) @@ -324,18 +287,11 @@ public boolean ifNotSetFallbackToSimpleAuth() { // Will be called when creating catalog(not replaying). // Subclass can override this method to do some check when creating catalog. + // Connectivity testing (test_connection=true) is connector-specific and therefore lives behind the + // connector SPI: PluginDrivenExternalCatalog overrides this and delegates to Connector#testConnection. + // The built-in catalogs that still inherit this method (type=doris, type=test) carry neither metastore + // nor storage properties, so there is nothing generic left to check here. public void checkWhenCreating() throws DdlException { - boolean testConnection = Boolean.parseBoolean( - catalogProperty.getOrDefault(TEST_CONNECTION, String.valueOf(DEFAULT_TEST_CONNECTION))); - - if (testConnection) { - CatalogConnectivityTestCoordinator testCoordinator = new CatalogConnectivityTestCoordinator( - name, - catalogProperty.getMetastoreProperties(), - catalogProperty.getStoragePropertiesMap() - ); - testCoordinator.runTests(); - } } /** @@ -377,6 +333,41 @@ protected boolean shouldBypassTableNameCache(SessionContext ctx) { return false; } + /** + * Returns whether the shared database-name cache should be skipped for the current session (the db-level + * analog of {@link #shouldBypassTableNameCache}). + * + *

    Catalogs whose remote {@code listDatabaseNames} result depends on session credentials (Iceberg REST + * {@code session=user}) must bypass the shared (catalog-wide, NOT user-keyed) db-name cache, so one user's + * visible database set is never served to another. Default {@code false} — every other catalog keeps the + * cache.

    + * + * @param ctx session context for the current request + * @return true if database names must be fetched live from the remote source for this session + */ + protected boolean shouldBypassDbNameCache(SessionContext ctx) { + return false; + } + + /** + * Returns whether the shared table-schema cache should be skipped for the current session (the schema-level + * analog of {@link #shouldBypassTableNameCache}). + * + *

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

    + * + * @param ctx session context for the current request + * @return true if the schema must be read live from the remote source for this session + */ + protected boolean shouldBypassSchemaCache(SessionContext ctx) { + return false; + } + /** * check if the specified table exist. * @@ -435,6 +426,19 @@ public final synchronized void makeSureInitialized() { } } + /** + * Records the root-cause of a deferred metadata-load failure into {@code errorMsg} so it is + * visible in {@code show catalogs}. Some connectors connect lazily on first metadata access + * (their {@link #initLocalObjectsImpl()} only constructs the client), so the initial failure + * happens inside the meta-cache loader — outside {@link #makeSureInitialized()}'s try/catch, + * which is the only other place {@code errorMsg} is written. The message is cleared again by + * {@link #makeSureInitialized()} on the next successful (re-)initialization, e.g. after + * {@code alter catalog ... set properties} triggers {@link #resetToUninitialized(boolean)}. + */ + protected void recordDeferredInitError(Throwable t) { + this.errorMsg = ExceptionUtils.getRootCauseMessage(t); + } + protected final void initLocalObjects() { if (!objectCreated) { if (LOG.isDebugEnabled()) { @@ -467,6 +471,16 @@ private void buildMetaCache() { } } + /** + * Hook for plugin/SPI catalogs to overlay DERIVED meta-cache config (e.g. a connector-provided schema-cache + * TTL) onto the EPHEMERAL property copy the engine uses to size the meta cache. Default no-op. MUST NOT + * mutate persisted catalog properties — the caller ({@code ExternalMetaCacheMgr.findCatalogProperties}) + * passes a throwaway copy, so SHOW CREATE CATALOG is unaffected. Connector-agnostic: the base does nothing; + * {@code PluginDrivenExternalCatalog} delegates to the connector SPI. + */ + public void overlayMetaCacheConfig(Map metaCacheProperties) { + } + // check if all required properties are set when creating catalog public void checkProperties() throws DdlException { // check refresh parameter of catalog @@ -549,6 +563,17 @@ public void initAccessController(boolean isDryRun) { */ @NotNull private List> getFilteredDatabaseNames() { + return getFilteredDatabaseNames(true); + } + + /** + * @param updateDbNameLookup when {@code false}, the shared {@code lowerCaseToDatabaseName} lookup is NOT + * mutated. The db-name cache-bypass path ({@link #shouldBypassDbNameCache}) passes {@code false} so a + * per-user database listing never overwrites the shared (catalog-wide) case-insensitive lookup with one + * user's visible set — mirrors {@code ExternalDatabase.loadTableNamePairs}'s {@code updateTableNameLookup}. + */ + @NotNull + private List> getFilteredDatabaseNames(boolean updateDbNameLookup) { List allDatabases = Lists.newArrayList(listDatabaseNames()); allDatabases.remove(InfoSchemaDb.DATABASE_NAME); allDatabases.add(InfoSchemaDb.DATABASE_NAME); @@ -558,7 +583,9 @@ private List> getFilteredDatabaseNames() { Map includeDatabaseMap = getIncludeDatabaseMap(); Map excludeDatabaseMap = getExcludeDatabaseMap(); - lowerCaseToDatabaseName.clear(); + if (updateDbNameLookup) { + lowerCaseToDatabaseName.clear(); + } List> remoteToLocalPairs = Lists.newArrayList(); allDatabases = allDatabases.stream().filter(dbName -> { @@ -577,7 +604,9 @@ private List> getFilteredDatabaseNames() { for (String remoteDbName : allDatabases) { String localDbName = fromRemoteDatabaseName(remoteDbName); // Populate lowercase mapping for case-insensitive lookups - lowerCaseToDatabaseName.put(remoteDbName.toLowerCase(), remoteDbName); + if (updateDbNameLookup) { + lowerCaseToDatabaseName.put(remoteDbName.toLowerCase(), remoteDbName); + } // Apply lower_case_database_names mode to local name int dbNameMode = getLowerCaseDatabaseNames(); if (dbNameMode == 1) { @@ -721,6 +750,14 @@ public String getErrorMsg() { @Override public List getDbNames() { makeSureInitialized(); + SessionContext sessionContext = SessionContext.current(); + if (shouldBypassDbNameCache(sessionContext)) { + // Per-user listing: read live (the loader's listDatabaseNames already runs under the current + // session) and DO NOT touch the shared lowerCaseToDatabaseName lookup (updateDbNameLookup=false). + return getFilteredDatabaseNames(false).stream() + .map(Pair::value) + .collect(Collectors.toList()); + } return metaCache.listNames(); } @@ -757,6 +794,11 @@ public ExternalDatabase getDbNullable(String dbName) { return null; } + SessionContext sessionContext = SessionContext.current(); + if (shouldBypassDbNameCache(sessionContext)) { + return getDbNullableWithoutCache(dbName); + } + // information_schema db name is case-insensitive. // So, we first convert it to standard database name. if (dbName.equalsIgnoreCase(InfoSchemaDb.DATABASE_NAME)) { @@ -776,6 +818,50 @@ public ExternalDatabase getDbNullable(String dbName) { return metaCache.getMetaObj(dbName, Util.genIdByName(name, dbName)).orElse(null); } + /** + * Live (no-cache) counterpart of {@link #getDbNullable(String)} for the db-name cache-bypass path + * ({@link #shouldBypassDbNameCache}). Resolves the requested db against the per-user live listing (never + * populating the shared caches) and builds the {@link ExternalDatabase} object directly. Mirrors + * {@code ExternalDatabase.getTableNullableWithoutCache} one level up. + */ + private ExternalDatabase getDbNullableWithoutCache(String dbName) { + // Normalize the case-insensitive system db names, mirroring the cached getDbNullable path. + String requestedDbName = dbName; + if (dbName.equalsIgnoreCase(InfoSchemaDb.DATABASE_NAME)) { + requestedDbName = InfoSchemaDb.DATABASE_NAME; + } else if (dbName.equalsIgnoreCase(MysqlDb.DATABASE_NAME)) { + requestedDbName = MysqlDb.DATABASE_NAME; + } + final String target = requestedDbName; + Optional> dbNamePair = getFilteredDatabaseNames(false).stream() + .filter(pair -> matchesLocalDbName(pair.value(), target)) + .findFirst(); + if (!dbNamePair.isPresent()) { + return null; + } + String remoteDbName = dbNamePair.get().key(); + String localDbName = dbNamePair.get().value(); + // checkExists=false: the pair came from the live listing, so re-listing (getDbNames) is both + // unnecessary and would recurse back through this bypass — build the db object directly. + return buildDbForInit(remoteDbName, localDbName, Util.genIdByName(name, localDbName), logType, false); + } + + /** Matches a live listing's LOCAL db name against a requested name, honoring the db-name case modes. */ + private boolean matchesLocalDbName(String localDbName, String dbName) { + // System dbs (already normalized to canonical form by the caller) match case-insensitively. + if (dbName.equals(InfoSchemaDb.DATABASE_NAME) || dbName.equals(MysqlDb.DATABASE_NAME)) { + return localDbName.equalsIgnoreCase(dbName); + } + int mode = getLowerCaseDatabaseNames(); + if (mode == 1) { + return localDbName.equals(dbName.toLowerCase()); + } + if (mode == 2 || Boolean.parseBoolean(getLowerCaseMetaNames())) { + return localDbName.equalsIgnoreCase(dbName); + } + return localDbName.equals(dbName); + } + @Nullable @Override public ExternalDatabase getDbNullable(long dbId) { @@ -959,21 +1045,37 @@ protected ExternalDatabase buildDbForInit(String remote } switch (logType) { case HMS: - return new HMSExternalDatabase(this, dbId, localDbName, remoteDbName); + // Hive (hms) is flipped to the plugin path (PluginDrivenExternalCatalog); the HMSExternalDatabase + // entity class is dead-for-hms (deleted with the legacy subsystem in the deletion phase). This + // case only fires when replaying an old InitCatalogLog persisted with Type.HMS (a base + // ExternalCatalog whose logType was serialized as HMS) — build the post-flip runtime type so the + // db (and the PluginDrivenMvccExternalTable it builds) matches the GSON remap. Keep the case + // label: deleting it would fall through to `return null` and break db init on replay. The + // Type.HMS enum is retained for old-image deserialization. + return new PluginDrivenExternalDatabase(this, dbId, localDbName, remoteDbName); case JDBC: return new PluginDrivenExternalDatabase(this, dbId, localDbName, remoteDbName); case ICEBERG: - return new IcebergExternalDatabase(this, dbId, localDbName, remoteDbName); - case MAX_COMPUTE: - return new MaxComputeExternalDatabase(this, dbId, localDbName, remoteDbName); + // Native iceberg is flipped to the plugin path (PluginDrivenExternalCatalog); the + // IcebergExternalDatabase entity class is being removed. This case only fires when + // replaying an old InitCatalogLog persisted with Type.ICEBERG (a base ExternalCatalog + // whose logType was serialized as ICEBERG) — build the post-flip runtime type so the + // db (and the PluginDrivenExternalTable it builds) matches the GSON remap. Keep the + // case label: deleting it would fall through to `return null` and break db init on + // replay. Type.ICEBERG enum is retained for old-image deserialization. + return new PluginDrivenExternalDatabase(this, dbId, localDbName, remoteDbName); case LAKESOUL: - return new LakeSoulExternalDatabase(this, dbId, localDbName, remoteDbName); + // LakeSoul is deprecated and was never migrated to the plugin path; its entity classes are + // removed. This case only fires when replaying an old InitCatalogLog persisted with + // Type.LAKESOUL — build a PluginDrivenExternalDatabase so the db matches the GSON remap + // (registerCompatibleSubtype -> PluginDrivenExternalDatabase). Keep the case label: deleting + // it would fall through to `return null` and break db init on replay. The Type.LAKESOUL enum + // is retained for old-image deserialization. + return new PluginDrivenExternalDatabase(this, dbId, localDbName, remoteDbName); case TEST: return new TestExternalDatabase(this, dbId, localDbName, remoteDbName); - case PAIMON: - return new PaimonExternalDatabase(this, dbId, localDbName, remoteDbName); case TRINO_CONNECTOR: - return new TrinoConnectorExternalDatabase(this, dbId, localDbName, remoteDbName); + return new PluginDrivenExternalDatabase(this, dbId, localDbName, remoteDbName); case REMOTE_DORIS: return new RemoteDorisExternalDatabase(this, dbId, localDbName, remoteDbName); case PLUGIN: @@ -1030,136 +1132,52 @@ public void setInitializedForTest(boolean initialized) { @Override public void createDb(String dbName, boolean ifNotExists, Map properties) throws DdlException { makeSureInitialized(); - if (metadataOps == null) { - throw new DdlException("Create database is not supported for catalog: " + getName()); - } - try { - boolean res = metadataOps.createDb(dbName, ifNotExists, properties); - if (!res) { - // we should get the db stored in Doris, and use local name in edit log. - CreateDbInfo info = new CreateDbInfo(getName(), dbName, null); - Env.getCurrentEnv().getEditLog().logCreateDb(info); - } - } catch (Exception e) { - LOG.warn("Failed to create database {} in catalog {}.", dbName, getName(), e); - throw e; - } + throw new DdlException("Create database is not supported for catalog: " + getName()); } public void replayCreateDb(String dbName) { - if (metadataOps != null) { - metadataOps.afterCreateDb(); - } + // Invalidate the FE cache directly so follower FEs reflect the create on edit-log replay. + resetMetaCacheNames(); } @Override public void dropDb(String dbName, boolean ifExists, boolean force) throws DdlException { makeSureInitialized(); - if (metadataOps == null) { - throw new DdlException("Drop database is not supported for catalog: " + getName()); - } - try { - metadataOps.dropDb(dbName, ifExists, force); - DropDbInfo info = new DropDbInfo(getName(), dbName); - Env.getCurrentEnv().getEditLog().logDropDb(info); - } catch (Exception e) { - LOG.warn("Failed to drop database {} in catalog {}", dbName, getName(), e); - throw e; - } + throw new DdlException("Drop database is not supported for catalog: " + getName()); } public void replayDropDb(String dbName) { - if (metadataOps != null) { - metadataOps.afterDropDb(dbName); - } + // Drop the db from the cache on replay. + unregisterDatabase(dbName); } @Override public boolean createTable(CreateTableInfo createTableInfo) throws UserException { makeSureInitialized(); - - if (metadataOps == null) { - throw new DdlException("Create table is not supported for catalog: " + getName()); - } - try { - boolean res = metadataOps.createTable(createTableInfo); - if (!res) { - // res == false means the table does not exist before, and we create it. - // we should get the table stored in Doris, and use local name in edit log. - org.apache.doris.persist.CreateTableInfo info = new org.apache.doris.persist.CreateTableInfo( - getName(), - createTableInfo.getDbName(), - createTableInfo.getTableName()); - Env.getCurrentEnv().getEditLog().logCreateTable(info); - LOG.info("finished to create table {}.{}.{}", getName(), createTableInfo.getDbName(), - createTableInfo.getTableName()); - } - return res; - } catch (Exception e) { - LOG.warn("Failed to create a table.", e); - throw e; - } + throw new DdlException("Create table is not supported for catalog: " + getName()); } public void replayCreateTable(String dbName, String tblName) { - if (metadataOps != null) { - metadataOps.afterCreateTable(dbName, tblName); - } + // Refresh the db's table-name cache on replay. + getDbForReplay(dbName).ifPresent(db -> db.resetMetaCacheNames()); } @Override public void renameTable(String dbName, String oldTableName, String newTableName) throws DdlException { makeSureInitialized(); - if (metadataOps == null) { - throw new DdlException("Rename table is not supported for catalog: " + getName()); - } - try { - metadataOps.renameTable(dbName, oldTableName, newTableName); - Env.getCurrentEnv().getConstraintManager().renameTable( - new TableNameInfo(getName(), dbName, oldTableName), - new TableNameInfo(getName(), dbName, newTableName)); - Env.getCurrentEnv().getEditLog() - .logRefreshExternalTable( - ExternalObjectLog.createForRenameTable(getId(), dbName, oldTableName, newTableName)); - } catch (Exception e) { - LOG.warn("Failed to rename table {} in database {}.", oldTableName, dbName, e); - throw e; - } + throw new DdlException("Rename table is not supported for catalog: " + getName()); } @Override public void dropTable(String dbName, String tableName, boolean isView, boolean isMtmv, boolean isStream, boolean ifExists, boolean mustTemporary, boolean force) throws DdlException { makeSureInitialized(); - if (metadataOps == null) { - throw new DdlException("Drop table is not supported for catalog: " + getName()); - } - // 1. get table in doris catalog first. - ExternalDatabase db = getDbNullable(dbName); - if (db == null) { - throw new DdlException("Failed to get database: '" + dbName + "' in catalog: " + getName()); - } - ExternalTable dorisTable = db.getTableNullable(tableName); - if (dorisTable == null) { - if (ifExists) { - return; - } - throw new DdlException("Failed to get table: '" + tableName + "' in database: " + dbName); - } - try { - metadataOps.dropTable(dorisTable, ifExists); - DropInfo info = new DropInfo(getName(), dbName, tableName); - Env.getCurrentEnv().getEditLog().logDropTable(info); - } catch (Exception e) { - LOG.warn("Failed to drop a table", e); - throw e; - } + throw new DdlException("Drop table is not supported for catalog: " + getName()); } public void replayDropTable(String dbName, String tblName) { - if (metadataOps != null) { - metadataOps.afterDropTable(dbName, tblName); - } + // Remove the table from the cache on replay. + getDbForReplay(dbName).ifPresent(db -> db.unregisterTable(tblName)); } /** @@ -1295,7 +1313,7 @@ private String getLocalDatabaseName(String dbName, boolean isReplay) { } public String bindBrokerName() { - return catalogProperty.getProperties().get(HMSExternalCatalog.BIND_BROKER_NAME); + return catalogProperty.getProperties().get(BrokerProperties.BIND_BROKER_NAME_KEY); } // ATTN: this method only return all cached databases. @@ -1331,30 +1349,11 @@ public boolean enableAutoAnalyze() { public void truncateTable(String dbName, String tableName, PartitionNamesInfo partitionNamesInfo, boolean forceDrop, String rawTruncateSql) throws DdlException { makeSureInitialized(); - if (metadataOps == null) { - throw new DdlException("Truncate table is not supported for catalog: " + getName()); - } - try { - // delete all table data if null - List partitions = null; - if (partitionNamesInfo != null) { - partitions = partitionNamesInfo.getPartitionNames(); - } - ExternalTable dorisTable = getDbOrDdlException(dbName).getTableOrDdlException(tableName); - long updateTime = System.currentTimeMillis(); - metadataOps.truncateTable(dorisTable, partitions, updateTime); - TruncateTableInfo info = new TruncateTableInfo(getName(), dbName, tableName, partitions, updateTime); - Env.getCurrentEnv().getEditLog().logTruncateTable(info); - } catch (Exception e) { - LOG.warn("Failed to truncate table {}.{} in catalog {}", dbName, tableName, getName(), e); - throw e; - } + throw new DdlException("Truncate table is not supported for catalog: " + getName()); } public void replayTruncateTable(TruncateTableInfo info) { - if (metadataOps != null) { - metadataOps.afterTruncateTable(info.getDb(), info.getTable(), info.getUpdateTime()); - } + // External truncate replay is a cache no-op; the table is re-read from remote on access. } public void setAutoAnalyzePolicy(String dbName, String tableName, String policy) { @@ -1432,20 +1431,7 @@ public void createOrReplaceBranch(TableIf dorisTable, CreateOrReplaceBranchInfo throws UserException { makeSureInitialized(); Preconditions.checkState(dorisTable instanceof ExternalTable, dorisTable.getName()); - ExternalTable externalTable = (ExternalTable) dorisTable; - if (metadataOps == null) { - throw new DdlException("branching operation is not supported for catalog: " + getName()); - } - try { - metadataOps.createOrReplaceBranch(externalTable, branchInfo); - TableBranchOrTagInfo info = new TableBranchOrTagInfo(getName(), externalTable.getDbName(), - externalTable.getName()); - Env.getCurrentEnv().getEditLog().logBranchOrTag(info); - } catch (Exception e) { - LOG.warn("Failed to create or replace branch for table {}.{} in catalog {}", - externalTable.getDbName(), externalTable.getName(), getName(), e); - throw e; - } + throw new DdlException("branching operation is not supported for catalog: " + getName()); } @Override @@ -1453,67 +1439,26 @@ public void createOrReplaceTag(TableIf dorisTable, CreateOrReplaceTagInfo tagInf throws UserException { makeSureInitialized(); Preconditions.checkState(dorisTable instanceof ExternalTable, dorisTable.getName()); - ExternalTable externalTable = (ExternalTable) dorisTable; - if (metadataOps == null) { - throw new DdlException("Tagging operation is not supported for catalog: " + getName()); - } - try { - metadataOps.createOrReplaceTag(externalTable, tagInfo); - TableBranchOrTagInfo info = new TableBranchOrTagInfo(getName(), externalTable.getDbName(), - externalTable.getName()); - Env.getCurrentEnv().getEditLog().logBranchOrTag(info); - } catch (Exception e) { - LOG.warn("Failed to create or replace tag for table {}.{} in catalog {}", - externalTable.getDbName(), externalTable.getName(), getName(), e); - throw e; - } + throw new DdlException("Tagging operation is not supported for catalog: " + getName()); } @Override public void replayOperateOnBranchOrTag(String dbName, String tblName) { - if (metadataOps != null) { - metadataOps.afterOperateOnBranchOrTag(dbName, tblName); - } + // External branch/tag replay is a cache no-op; the table is re-read from remote on access. } @Override public void dropBranch(TableIf dorisTable, DropBranchInfo branchInfo) throws UserException { makeSureInitialized(); Preconditions.checkState(dorisTable instanceof ExternalTable, dorisTable.getName()); - ExternalTable externalTable = (ExternalTable) dorisTable; - if (metadataOps == null) { - throw new DdlException("DropBranch operation is not supported for catalog: " + getName()); - } - try { - metadataOps.dropBranch(externalTable, branchInfo); - TableBranchOrTagInfo info = new TableBranchOrTagInfo(getName(), externalTable.getDbName(), - externalTable.getName()); - Env.getCurrentEnv().getEditLog().logBranchOrTag(info); - } catch (Exception e) { - LOG.warn("Failed to drop branch for table {}.{} in catalog {}", - externalTable.getDbName(), externalTable.getName(), getName(), e); - throw e; - } + throw new DdlException("DropBranch operation is not supported for catalog: " + getName()); } @Override public void dropTag(TableIf dorisTable, DropTagInfo tagInfo) throws UserException { makeSureInitialized(); Preconditions.checkState(dorisTable instanceof ExternalTable, dorisTable.getName()); - ExternalTable externalTable = (ExternalTable) dorisTable; - if (metadataOps == null) { - throw new DdlException("DropTag operation is not supported for catalog: " + getName()); - } - try { - metadataOps.dropTag(externalTable, tagInfo); - TableBranchOrTagInfo info = new TableBranchOrTagInfo(getName(), externalTable.getDbName(), - externalTable.getName()); - Env.getCurrentEnv().getEditLog().logBranchOrTag(info); - } catch (Exception e) { - LOG.warn("Failed to drop tag for table {}.{} in catalog {}", - externalTable.getDbName(), externalTable.getName(), getName(), e); - throw e; - } + throw new DdlException("DropTag operation is not supported for catalog: " + getName()); } /** @@ -1526,125 +1471,45 @@ public void resetMetaCacheNames() { } } - // log the refresh external table operation - private void logRefreshExternalTable(ExternalTable dorisTable, long updateTime) { - Env.getCurrentEnv().getEditLog() - .logRefreshExternalTable( - ExternalObjectLog.createForRefreshTable(dorisTable.getCatalog().getId(), - dorisTable.getDbName(), dorisTable.getName(), updateTime)); - } - @Override public void addColumn(TableIf dorisTable, Column column, ColumnPosition position) throws UserException { makeSureInitialized(); Preconditions.checkState(dorisTable instanceof ExternalTable, dorisTable.getName()); - ExternalTable externalTable = (ExternalTable) dorisTable; - if (metadataOps == null) { - throw new DdlException("Add column operation is not supported for catalog: " + getName()); - } - try { - long updateTime = System.currentTimeMillis(); - metadataOps.addColumn(externalTable, column, position, updateTime); - logRefreshExternalTable(externalTable, updateTime); - } catch (Exception e) { - LOG.warn("Failed to add column {} to table {}.{} in catalog {}", - column.getName(), externalTable.getDbName(), externalTable.getName(), getName(), e); - throw e; - } + throw new DdlException("Add column operation is not supported for catalog: " + getName()); } @Override public void addColumns(TableIf dorisTable, List columns) throws UserException { makeSureInitialized(); Preconditions.checkState(dorisTable instanceof ExternalTable, dorisTable.getName()); - ExternalTable externalTable = (ExternalTable) dorisTable; - if (metadataOps == null) { - throw new DdlException("Add columns operation is not supported for catalog: " + getName()); - } - try { - long updateTime = System.currentTimeMillis(); - metadataOps.addColumns(externalTable, columns, updateTime); - logRefreshExternalTable(externalTable, updateTime); - } catch (Exception e) { - LOG.warn("Failed to add columns to table {}.{} in catalog {}", - externalTable.getDbName(), externalTable.getName(), getName(), e); - throw e; - } + throw new DdlException("Add columns operation is not supported for catalog: " + getName()); } @Override public void dropColumn(TableIf dorisTable, String columnName) throws UserException { makeSureInitialized(); Preconditions.checkState(dorisTable instanceof ExternalTable, dorisTable.getName()); - ExternalTable externalTable = (ExternalTable) dorisTable; - if (metadataOps == null) { - throw new DdlException("Drop column operation is not supported for catalog: " + getName()); - } - try { - long updateTime = System.currentTimeMillis(); - metadataOps.dropColumn(externalTable, columnName, updateTime); - logRefreshExternalTable(externalTable, updateTime); - } catch (Exception e) { - LOG.warn("Failed to drop column {} from table {}.{} in catalog {}", - columnName, externalTable.getDbName(), externalTable.getName(), getName(), e); - throw e; - } + throw new DdlException("Drop column operation is not supported for catalog: " + getName()); } @Override public void renameColumn(TableIf dorisTable, String oldName, String newName) throws UserException { makeSureInitialized(); Preconditions.checkState(dorisTable instanceof ExternalTable, dorisTable.getName()); - ExternalTable externalTable = (ExternalTable) dorisTable; - if (metadataOps == null) { - throw new DdlException("Rename column operation is not supported for catalog: " + getName()); - } - try { - long updateTime = System.currentTimeMillis(); - metadataOps.renameColumn(externalTable, oldName, newName, updateTime); - logRefreshExternalTable(externalTable, updateTime); - } catch (Exception e) { - LOG.warn("Failed to rename column {} to {} in table {}.{} in catalog {}", - oldName, newName, externalTable.getDbName(), externalTable.getName(), getName(), e); - throw e; - } + throw new DdlException("Rename column operation is not supported for catalog: " + getName()); } @Override public void modifyColumn(TableIf dorisTable, Column column, ColumnPosition columnPosition) throws UserException { makeSureInitialized(); Preconditions.checkState(dorisTable instanceof ExternalTable, dorisTable.getName()); - ExternalTable externalTable = (ExternalTable) dorisTable; - if (metadataOps == null) { - throw new DdlException("Modify column operation is not supported for catalog: " + getName()); - } - try { - long updateTime = System.currentTimeMillis(); - metadataOps.modifyColumn(externalTable, column, columnPosition, updateTime); - logRefreshExternalTable(externalTable, updateTime); - } catch (Exception e) { - LOG.warn("Failed to modify column {} in table {}.{} in catalog {}", - column.getName(), externalTable.getDbName(), externalTable.getName(), getName(), e); - throw e; - } + throw new DdlException("Modify column operation is not supported for catalog: " + getName()); } @Override public void reorderColumns(TableIf dorisTable, List newOrder) throws UserException { makeSureInitialized(); Preconditions.checkState(dorisTable instanceof ExternalTable, dorisTable.getName()); - ExternalTable externalTable = (ExternalTable) dorisTable; - if (metadataOps == null) { - throw new DdlException("Reorder columns operation is not supported for catalog: " + getName()); - } - try { - long updateTime = System.currentTimeMillis(); - metadataOps.reorderColumns(externalTable, newOrder, updateTime); - logRefreshExternalTable(externalTable, updateTime); - } catch (Exception e) { - LOG.warn("Failed to reorder columns in table {}.{} in catalog {}", - externalTable.getDbName(), externalTable.getName(), getName(), e); - throw e; - } + throw new DdlException("Reorder columns operation is not supported for catalog: " + getName()); } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalDatabase.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalDatabase.java index 0a86f4842615fd..2b1281bdcf0cf8 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalDatabase.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalDatabase.java @@ -33,6 +33,7 @@ import org.apache.doris.common.util.Util; import org.apache.doris.datasource.infoschema.ExternalInfoSchemaDatabase; import org.apache.doris.datasource.infoschema.ExternalMysqlDatabase; +import org.apache.doris.datasource.log.InitDatabaseLog; import org.apache.doris.datasource.metacache.MetaCache; import org.apache.doris.datasource.test.TestExternalDatabase; import org.apache.doris.persist.gson.GsonPostProcessable; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaCacheMgr.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaCacheMgr.java index 007e850e54e24e..e1ff655002044e 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaCacheMgr.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaCacheMgr.java @@ -21,10 +21,6 @@ import org.apache.doris.common.Config; import org.apache.doris.common.ThreadPoolManager; import org.apache.doris.datasource.doris.DorisExternalMetaCache; -import org.apache.doris.datasource.hive.HiveExternalMetaCache; -import org.apache.doris.datasource.hudi.HudiExternalMetaCache; -import org.apache.doris.datasource.iceberg.IcebergExternalMetaCache; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalMetaCache; import org.apache.doris.datasource.metacache.AbstractExternalMetaCache; import org.apache.doris.datasource.metacache.ExternalMetaCache; import org.apache.doris.datasource.metacache.ExternalMetaCacheRegistry; @@ -33,7 +29,6 @@ import org.apache.doris.datasource.metacache.MetaCacheEntryDef; import org.apache.doris.datasource.metacache.MetaCacheEntryInvalidation; import org.apache.doris.datasource.metacache.MetaCacheEntryStats; -import org.apache.doris.datasource.paimon.PaimonExternalMetaCache; import org.apache.doris.fs.FileSystemCache; import com.github.benmanes.caffeine.cache.stats.CacheStats; @@ -61,11 +56,6 @@ public class ExternalMetaCacheMgr { private static final Logger LOG = LogManager.getLogger(ExternalMetaCacheMgr.class); private static final String ENTRY_SCHEMA = "schema"; private static final String ENGINE_DEFAULT = "default"; - private static final String ENGINE_HIVE = "hive"; - private static final String ENGINE_HUDI = "hudi"; - private static final String ENGINE_ICEBERG = "iceberg"; - private static final String ENGINE_PAIMON = "paimon"; - private static final String ENGINE_MAXCOMPUTE = "maxcompute"; private static final String ENGINE_DORIS = "doris"; /** @@ -160,31 +150,6 @@ ExternalMetaCache engine(String engine) { return cacheRegistry.resolve(engine); } - public HiveExternalMetaCache hive(long catalogId) { - prepareCatalogByEngine(catalogId, ENGINE_HIVE); - return (HiveExternalMetaCache) engine(ENGINE_HIVE); - } - - public HudiExternalMetaCache hudi(long catalogId) { - prepareCatalogByEngine(catalogId, ENGINE_HUDI); - return (HudiExternalMetaCache) engine(ENGINE_HUDI); - } - - public IcebergExternalMetaCache iceberg(long catalogId) { - prepareCatalogByEngine(catalogId, ENGINE_ICEBERG); - return (IcebergExternalMetaCache) engine(ENGINE_ICEBERG); - } - - public PaimonExternalMetaCache paimon(long catalogId) { - prepareCatalogByEngine(catalogId, ENGINE_PAIMON); - return (PaimonExternalMetaCache) engine(ENGINE_PAIMON); - } - - public MaxComputeExternalMetaCache maxCompute(long catalogId) { - prepareCatalogByEngine(catalogId, ENGINE_MAXCOMPUTE); - return (MaxComputeExternalMetaCache) engine(ENGINE_MAXCOMPUTE); - } - public DorisExternalMetaCache doris(long catalogId) { prepareCatalogByEngine(catalogId, ENGINE_DORIS); return (DorisExternalMetaCache) engine(ENGINE_DORIS); @@ -303,11 +268,6 @@ private void initEngineCaches() { private void registerBuiltinEngineCaches() { cacheRegistry.register(new DefaultExternalMetaCache(ENGINE_DEFAULT, commonRefreshExecutor)); - cacheRegistry.register(new HiveExternalMetaCache(commonRefreshExecutor, fileListingExecutor)); - cacheRegistry.register(new HudiExternalMetaCache(commonRefreshExecutor)); - cacheRegistry.register(new IcebergExternalMetaCache(commonRefreshExecutor)); - cacheRegistry.register(new PaimonExternalMetaCache(commonRefreshExecutor)); - cacheRegistry.register(new MaxComputeExternalMetaCache(commonRefreshExecutor)); cacheRegistry.register(new DorisExternalMetaCache(commonRefreshExecutor)); } @@ -342,10 +302,16 @@ private Map findCatalogProperties(long catalogId) { if (catalog == null) { return null; } - if (catalog.getProperties() == null) { - return Maps.newHashMap(); + Map props = catalog.getProperties() == null + ? Maps.newHashMap() + : Maps.newHashMap(catalog.getProperties()); + // Let a plugin/SPI catalog overlay DERIVED meta-cache config (e.g. a connector-provided schema-cache + // TTL) onto this EPHEMERAL copy used to size the cache. Connector-agnostic (virtual dispatch; the base + // ExternalCatalog is a no-op) and non-persisting (this copy is throwaway -> no SHOW CREATE leak). + if (catalog instanceof ExternalCatalog) { + ((ExternalCatalog) catalog).overlayMetaCacheConfig(props); } - return Maps.newHashMap(catalog.getProperties()); + return props; } private void logMissingCatalogSkip(long catalogId, String operation) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaIdMgr.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaIdMgr.java index 151bc93f982e2b..394447e940deb5 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaIdMgr.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaIdMgr.java @@ -18,8 +18,8 @@ package org.apache.doris.datasource; import org.apache.doris.catalog.Env; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.event.MetastoreEventsProcessor; +import org.apache.doris.datasource.log.MetaIdMappingsLog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import com.google.common.base.Preconditions; import com.google.common.collect.Maps; @@ -115,11 +115,16 @@ public void replayMetaIdMappingsLog(@NotNull MetaIdMappingsLog log) { handleMetaIdMapping(mapping, ctlMetaIdMgr); } if (log.isFromHmsEvent()) { - CatalogIf catalogIf = Env.getCurrentEnv().getCatalogMgr().getCatalog(log.getCatalogId()); - if (catalogIf != null) { - MetastoreEventsProcessor metastoreEventsProcessor = Env.getCurrentEnv().getMetastoreEventsProcessor(); - metastoreEventsProcessor.updateMasterLastSyncedEventId( - (HMSExternalCatalog) catalogIf, log.getLastSyncedEventId()); + // Propagate the master's synced-event-id cursor to this FE, keyed by catalogId only (the log + // already carries it). A flipped hms catalog is a generic PluginDrivenExternalCatalog driven by + // MetastoreEventSyncDriver, whose follower cursor map must be fed here (otherwise its + // masterUpperBound stays -1 and followers stop receiving incremental updates). Never cast to + // HMSExternalCatalog (that cast would ClassCastException for a PluginDrivenExternalCatalog and + // abort replay). + CatalogIf catalogIf = Env.getCurrentEnv().getCatalogMgr().getCatalog(catalogId); + if (catalogIf instanceof PluginDrivenExternalCatalog) { + Env.getCurrentEnv().getMetastoreEventSyncDriver() + .updateMasterLastSyncedEventId(catalogId, log.getLastSyncedEventId()); } } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalTable.java index 24de55133be39c..ade57ee4262db6 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalTable.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalTable.java @@ -174,10 +174,22 @@ public TableType getType() { @Override public List getFullSchema() { + // NOT getFullSchema(Optional.empty()): an empty snapshot means "this reference has no pin" (=> + // latest), whereas the no-arg form means "I have no reference, resolve from the ambient context". + // Collapsing the two would strip the ambient resolution from every statement-global caller. Optional schemaCacheValue = getSchemaCacheValue(); return schemaCacheValue.map(SchemaCacheValue::getSchema).orElse(null); } + /** + * The full schema AS OF {@code snapshot}. See {@link #getSchemaCacheValue(Optional)} for why the plan + * path must pass the reference's pin rather than relying on the ambient lookup. + */ + public List getFullSchema(Optional snapshot) { + Optional schemaCacheValue = getSchemaCacheValue(snapshot); + return schemaCacheValue.map(SchemaCacheValue::getSchema).orElse(null); + } + protected boolean needInternalHiddenColumns() { return false; } @@ -421,8 +433,33 @@ public List getChunkSizes() { } public Optional getSchemaCacheValue() { - return Env.getCurrentEnv().getExtMetaCacheMgr() - .getSchemaCacheValue(this, new SchemaCacheKey(getOrBuildNameMapping())); + SchemaCacheKey key = new SchemaCacheKey(getOrBuildNameMapping()); + if (catalog.shouldBypassSchemaCache(SessionContext.current())) { + // session=user + delegated credential: the remote loadTable returns PER-USER schema and authorizes + // per user, so the shared name-keyed schema cache must be bypassed (mirror the db/table-name-cache + // bypass) — read schema live under the current session's credential. This is exactly what the cache + // miss-loader would do (initSchemaAndUpdateTime); calling it directly just skips the shared cache so + // one user's schema is never served to another who could list but not load the table. + return initSchemaAndUpdateTime(key); + } + return Env.getCurrentEnv().getExtMetaCacheMgr().getSchemaCacheValue(this, key); + } + + /** + * The schema AS OF {@code snapshot}, for a caller that knows WHICH table reference it is resolving for + * (the reference's {@code @branch}/{@code @tag}/FOR-TIME selector already resolved to a pin). A table + * whose schema cannot vary by version ignores {@code snapshot} — only an MVCC table overrides this. + * + *

    Prefer this over the no-arg {@link #getSchemaCacheValue()} in the PLAN path: the no-arg form + * resolves the pin from the ambient {@code ConnectContext} WITHOUT knowing the reference, so a statement + * pinning one table at two versions cannot be disambiguated there and degrades to the LATEST schema — + * a schema no reference asked for. The no-arg form remains correct for statement-global callers (MTMV + * refresh, preload, the write sink) that have no per-reference version to pass. + * + * @param snapshot the pin resolved for THIS reference, or empty for a caller with no reference + */ + public Optional getSchemaCacheValue(Optional snapshot) { + return getSchemaCacheValue(); } @Override diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalUtil.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalUtil.java deleted file mode 100644 index 6a986da45303d6..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalUtil.java +++ /dev/null @@ -1,239 +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.datasource; - -import org.apache.doris.analysis.SlotDescriptor; -import org.apache.doris.catalog.ArrayType; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.MapType; -import org.apache.doris.catalog.StructField; -import org.apache.doris.catalog.StructType; -import org.apache.doris.catalog.Type; -import org.apache.doris.thrift.TFileScanRangeParams; -import org.apache.doris.thrift.schema.external.TArrayField; -import org.apache.doris.thrift.schema.external.TField; -import org.apache.doris.thrift.schema.external.TFieldPtr; -import org.apache.doris.thrift.schema.external.TMapField; -import org.apache.doris.thrift.schema.external.TNestedField; -import org.apache.doris.thrift.schema.external.TSchema; -import org.apache.doris.thrift.schema.external.TStructField; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class ExternalUtil { - private static TField getExternalSchema(Column column) { - TField root = new TField(); - root.setName(column.getName()); - root.setId(column.getUniqueId()); - root.setIsOptional(column.isAllowNull()); - root.setType(column.getType().toColumnTypeThrift()); - if (column.getDefaultValue() != null) { - root.setInitialDefaultValue(column.getDefaultValue()); - } - - TNestedField nestedField = new TNestedField(); - if (column.getType().isStructType()) { - nestedField.setStructField(getExternalSchema(column.getChildren())); - root.setNestedField(nestedField); - } else if (column.getType().isArrayType()) { - TArrayField listField = new TArrayField(); - TFieldPtr fieldPtr = new TFieldPtr(); - fieldPtr.setFieldPtr(getExternalSchema(column.getChildren().get(0))); - listField.setItemField(fieldPtr); - nestedField.setArrayField(listField); - root.setNestedField(nestedField); - } else if (column.getType().isMapType()) { - TMapField mapField = new TMapField(); - TFieldPtr keyPtr = new TFieldPtr(); - keyPtr.setFieldPtr(getExternalSchema(column.getChildren().get(0))); - mapField.setKeyField(keyPtr); - - TFieldPtr valuePtr = new TFieldPtr(); - valuePtr.setFieldPtr(getExternalSchema(column.getChildren().get(1))); - mapField.setValueField(valuePtr); - nestedField.setMapField(mapField); - root.setNestedField(nestedField); - } - return root; - } - - private static TStructField getExternalSchema(List columns) { - TStructField structField = new TStructField(); - for (Column child : columns) { - TFieldPtr fieldPtr = new TFieldPtr(); - fieldPtr.setFieldPtr(getExternalSchema(child)); - structField.addToFields(fieldPtr); - } - return structField; - } - - - public static void initSchemaInfo(TFileScanRangeParams params, Long schemaId, List columns) { - params.setCurrentSchemaId(schemaId); - TSchema tSchema = new TSchema(); - tSchema.setSchemaId(schemaId); - tSchema.setRootField(getExternalSchema(columns)); - params.addToHistorySchemaInfo(tSchema); - } - - - /** - * Initialize schema info based on SlotDescriptors, only including columns that are actually needed. - * For nested columns, only include sub-columns that are accessed according to pruned type. - * - * @param params TFileScanRangeParams to fill - * @param schemaId Schema ID - * @param slots List of SlotDescriptors that are actually needed - * @param nameMapping NameMapping from Iceberg table properties (can be null and empty.) - */ - public static void initSchemaInfoForPrunedColumn(TFileScanRangeParams params, Long schemaId, - List slots, Map> nameMapping) { - params.setCurrentSchemaId(schemaId); - TSchema tSchema = new TSchema(); - tSchema.setSchemaId(schemaId); - tSchema.setRootField(getExternalSchemaForPrunedColumn(slots, nameMapping)); - params.addToHistorySchemaInfo(tSchema); - } - - private static TStructField getExternalSchemaForPrunedColumn(List slots, - Map> nameMapping) { - TStructField structField = new TStructField(); - for (SlotDescriptor slot : slots) { - String colName = slot.getColumn().getName(); - if (colName.startsWith(Column.GLOBAL_ROWID_COL)) { - continue; - } - - TFieldPtr fieldPtr = new TFieldPtr(); - TField field = getExternalSchema( - slot.getType(), slot.getColumn(), nameMapping, Collections.emptyMap()); - fieldPtr.setFieldPtr(field); - structField.addToFields(fieldPtr); - } - return structField; - } - - public static void initSchemaInfoForAllColumn(TFileScanRangeParams params, Long schemaId, - List columns, Map> nameMapping) { - initSchemaInfoForAllColumn(params, schemaId, columns, nameMapping, Collections.emptyMap()); - } - - public static void initSchemaInfoForAllColumn(TFileScanRangeParams params, Long schemaId, - List columns, Map> nameMapping, - Map base64InitialDefaults) { - params.setCurrentSchemaId(schemaId); - TSchema tSchema = new TSchema(); - tSchema.setSchemaId(schemaId); - tSchema.setRootField(getExternalSchemaForAllColumn(columns, nameMapping, base64InitialDefaults)); - params.addToHistorySchemaInfo(tSchema); - } - - private static TStructField getExternalSchemaForAllColumn(List columns, - Map> nameMapping, Map base64InitialDefaults) { - TStructField structField = new TStructField(); - for (Column child : columns) { - TFieldPtr fieldPtr = new TFieldPtr(); - fieldPtr.setFieldPtr(getExternalSchema( - child.getType(), child, nameMapping, base64InitialDefaults)); - structField.addToFields(fieldPtr); - } - return structField; - } - - private static TField getExternalSchema(Type columnType, Column dorisColumn, - Map> nameMapping) { - return getExternalSchema(columnType, dorisColumn, nameMapping, Collections.emptyMap()); - } - - private static TField getExternalSchema(Type columnType, Column dorisColumn, - Map> nameMapping, Map base64InitialDefaults) { - TField root = new TField(); - root.setName(dorisColumn.getName()); - root.setId(dorisColumn.getUniqueId()); - root.setIsOptional(dorisColumn.isAllowNull()); - root.setType(dorisColumn.getType().toColumnTypeThrift()); - if (base64InitialDefaults.containsKey(dorisColumn.getUniqueId())) { - root.setInitialDefaultValue(base64InitialDefaults.get(dorisColumn.getUniqueId())); - root.setInitialDefaultValueIsBase64(true); - } else if (dorisColumn.getDefaultValue() != null) { - root.setInitialDefaultValue(dorisColumn.getDefaultValue()); - } - - if (nameMapping != null && nameMapping.containsKey(dorisColumn.getUniqueId())) { - // for iceberg set name mapping. - root.setNameMapping(new ArrayList<>(nameMapping.get(dorisColumn.getUniqueId()))); - } - - TNestedField nestedField = new TNestedField(); - - if (columnType.isStructType()) { - StructType dorisStructType = (StructType) columnType; - TStructField structField = new TStructField(); - - Map subNameToSubColumn = new HashMap<>(); - for (int i = 0; i < dorisColumn.getChildren().size(); i++) { - Column subColumn = dorisColumn.getChildren().get(i); - subNameToSubColumn.put(subColumn.getName(), subColumn); - } - - for (StructField subField : dorisStructType.getFields()) { - TFieldPtr fieldPtr = new TFieldPtr(); - Column subColumn = subNameToSubColumn.get(subField.getName()); - fieldPtr.setFieldPtr(getExternalSchema( - subField.getType(), subColumn, nameMapping, base64InitialDefaults)); - structField.addToFields(fieldPtr); - } - - nestedField.setStructField(structField); - root.setNestedField(nestedField); - } else if (columnType.isArrayType()) { - ArrayType dorisArrayType = (ArrayType) columnType; - - TArrayField listField = new TArrayField(); - TFieldPtr fieldPtr = new TFieldPtr(); - fieldPtr.setFieldPtr(getExternalSchema( - dorisArrayType.getItemType(), dorisColumn.getChildren().get(0), nameMapping, - base64InitialDefaults)); - listField.setItemField(fieldPtr); - nestedField.setArrayField(listField); - root.setNestedField(nestedField); - } else if (columnType.isMapType()) { - MapType dorisMapType = (MapType) columnType; - - TMapField mapField = new TMapField(); - TFieldPtr keyPtr = new TFieldPtr(); - keyPtr.setFieldPtr(getExternalSchema( - dorisMapType.getKeyType(), dorisColumn.getChildren().get(0), nameMapping, - base64InitialDefaults)); - mapField.setKeyField(keyPtr); - - TFieldPtr valuePtr = new TFieldPtr(); - valuePtr.setFieldPtr(getExternalSchema( - dorisMapType.getValueType(), dorisColumn.getChildren().get(1), nameMapping, - base64InitialDefaults)); - mapField.setValueField(valuePtr); - nestedField.setMapField(mapField); - root.setNestedField(nestedField); - } - return root; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileSplitStrategy.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/FileSplitStrategy.java deleted file mode 100644 index 00b69fca219d89..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileSplitStrategy.java +++ /dev/null @@ -1,45 +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.datasource; - -/** - * TODO: This class would be used later for split assignment. - */ -public class FileSplitStrategy { - private long totalSplitSize; - private int splitNum; - - FileSplitStrategy() { - this.totalSplitSize = 0; - this.splitNum = 0; - } - - public void update(FileSplit split) { - totalSplitSize += split.getLength(); - splitNum++; - } - - public boolean hasNext() { - return true; - } - - public void next() { - totalSplitSize = 0; - splitNum = 0; - } -} 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 new file mode 100644 index 00000000000000..5f5fd47f8eaf7c --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/MetastoreEventSyncDriver.java @@ -0,0 +1,339 @@ +// 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.analysis.RedirectStatus; +import org.apache.doris.analysis.UserIdentity; +import org.apache.doris.catalog.Env; +import org.apache.doris.common.Config; +import org.apache.doris.common.util.MasterDaemon; +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.datasource.log.CatalogLog; +import org.apache.doris.datasource.log.MetaIdMappingsLog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.MasterOpExecutor; +import org.apache.doris.qe.OriginStatement; + +import com.google.common.collect.Maps; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +/** + * The connector-agnostic, role-aware driver of incremental metastore-event sync. It is the fe-core half + * of the metastore-event relocation: it iterates catalogs, asks each connector that exposes a + * {@link ConnectorEventSource} for a batch of neutral {@link MetastoreChangeDescriptor}s, and applies + * them to the engine's catalog→db→table object graph and caches — the plugin never touches + * {@code CatalogMgr}, {@code EditLog}, or the HA state. + * + *

    Engine/connector split (Trino-aligned). The engine owns everything stateful and replicated: + * the per-catalog cursor, the master/follower role, the edit-log write of the synced cursor, and the + * follower→master {@code REFRESH CATALOG} forward. The connector owns only the metastore fetch + + * message parse behind {@code pollOnce}. This mirrors the legacy {@code MetastoreEventsProcessor} role + * logic exactly, but the source-specific work is now behind the SPI and the type gate + * ({@code instanceof HMSExternalCatalog}) is replaced by a capability probe ({@code getEventSource() != null}). + * + *

    Dormant until the flip. Only a {@link PluginDrivenExternalCatalog} whose connector exposes an + * event source is driven; pre-flip no such catalog exists, so this daemon is inert. At the flip the legacy + * poller's gate goes false and this driver takes over, and the {@code MetaIdMappingsLog} replay handler is + * repointed to feed THIS driver's follower cursor (see {@link #updateMasterLastSyncedEventId}). + * + *

    Classloader. {@code pollOnce} runs under a context-classloader pin to the event source's own + * plugin classloader (covering the notification RPC and the JSON/GZIP deserialization), mirroring + * {@code PluginDrivenScanNode.onPluginClassLoader}; the daemon thread does not inherit any pin. + */ +public class MetastoreEventSyncDriver extends MasterDaemon { + private static final Logger LOG = LogManager.getLogger(MetastoreEventSyncDriver.class); + + // This FE's per-catalog synced cursor. Not persisted (rebuilt as -1 on restart); single-threaded (the + // daemon thread), so a plain HashMap is fine. + private final Map lastSyncedEventIdMap = Maps.newHashMap(); + // The master's committed high-water mark per catalog, learned on followers via edit-log replay. A + // follower never fetches past it. Only meaningful on followers. + private final Map masterLastSyncedEventIdMap = Maps.newHashMap(); + + private boolean isRunning; + + public MetastoreEventSyncDriver() { + super(MetastoreEventSyncDriver.class.getName(), Config.hms_events_polling_interval_ms); + this.isRunning = false; + } + + @Override + protected void runAfterCatalogReady() { + if (isRunning) { + LOG.warn("Last metastore-event sync task not finished, ignore current task."); + return; + } + isRunning = true; + try { + realRun(); + } catch (Exception ex) { + LOG.warn("Metastore-event sync task failed", ex); + } + isRunning = false; + } + + private void realRun() { + List catalogIds = Env.getCurrentEnv().getCatalogMgr().getCatalogIds(); + for (Long catalogId : catalogIds) { + CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(catalogId); + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + 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; + } + } + ConnectorEventSource eventSource; + try { + eventSource = pluginCatalog.getConnector().getEventSource(); + } catch (RuntimeException e) { + // uninitialized / unavailable connector this cycle => skip (mirrors the legacy skip-on-throw) + continue; + } + if (eventSource == null) { + continue; + } + try { + syncCatalog(pluginCatalog, eventSource); + } catch (Exception e) { + // Self-heal (mirrors the legacy poller's onRefreshCache(true) + reset-to-(-1)): reset the + // cursor so the next cycle first-pulls -> full refresh, jumping past a deterministically-failing + // (poison) event/descriptor instead of retrying it forever and wedging the catalog's sync (and, + // on the master, freezing every follower that waits on the replicated cursor). Transient FETCH + // errors do not reach here — HmsEventSource retries them in place (ofNothing) — so this reset + // fires only on a deterministic parse/apply failure. + lastSyncedEventIdMap.put(catalogId, -1L); + LOG.warn("Failed to sync metastore events for catalog [{}]; reset cursor for a full re-sync", + pluginCatalog.getName(), e); + } + } + } + + private void syncCatalog(PluginDrivenExternalCatalog catalog, ConnectorEventSource eventSource) + throws Exception { + long catalogId = catalog.getId(); + boolean isMaster = Env.getCurrentEnv().isMaster(); + long lastSyncedEventId = lastSyncedEventIdMap.getOrDefault(catalogId, -1L); + long masterUpperBound = masterLastSyncedEventIdMap.getOrDefault(catalogId, -1L); + + EventPollRequest request = new EventPollRequest(lastSyncedEventId, isMaster, masterUpperBound); + EventPollResult result = onPluginClassLoader(eventSource, () -> eventSource.pollOnce(request)); + + if (result.isNeedsFullRefresh()) { + // first sync or an events-gap: the master invalidates the whole catalog locally; a follower + // forwards REFRESH CATALOG to the master. Then seed the cursor to the connector's current id. + if (isMaster) { + refreshCatalogForMaster(catalog); + } else { + refreshCatalogForSlave(catalog); + } + commitCursor(catalogId, result.getNewCursor(), isMaster); + return; + } + + List descriptors = result.getDescriptors(); + if (descriptors.isEmpty()) { + // nothing to apply; still advance the cursor if it moved (e.g. a batch of ignored events) + if (result.getNewCursor() != lastSyncedEventId) { + commitCursor(catalogId, result.getNewCursor(), isMaster); + } + return; + } + + // Apply in order; on failure the exception propagates and realRun's catch resets the cursor to -1 + // (self-heal), so the edit-log cursor below is NOT written (followers do not jump past a failed apply) + // and the next cycle first-pulls a clean full refresh instead of retrying the poison descriptor. + applyDescriptors(catalog, descriptors); + commitCursor(catalogId, result.getNewCursor(), isMaster); + } + + // Stores the local cursor and, on the master, replicates it to followers via the edit-log. + private void commitCursor(long catalogId, long newCursor, boolean isMaster) { + lastSyncedEventIdMap.put(catalogId, newCursor); + if (isMaster) { + writeSyncedCursorLog(catalogId, newCursor); + } + } + + private void applyDescriptors(PluginDrivenExternalCatalog catalog, + List descriptors) { + for (MetastoreChangeDescriptor descriptor : descriptors) { + try { + applyOne(catalog, descriptor); + } catch (Exception e) { + throw new RuntimeException( + "Failed to apply metastore change " + descriptor + " on catalog " + + catalog.getName(), e); + } + } + } + + // Applies one neutral descriptor via the engine's own (connector-agnostic) mutators — the same ones the + // legacy event.process() bodies called, now generalized to work on a flipped catalog. + private void applyOne(PluginDrivenExternalCatalog catalog, MetastoreChangeDescriptor descriptor) + throws Exception { + String catalogName = catalog.getName(); + CatalogMgr catalogMgr = Env.getCurrentEnv().getCatalogMgr(); + switch (descriptor.getOp()) { + case REGISTER_DATABASE: + catalogMgr.registerExternalDatabaseFromEvent(descriptor.getDbName(), catalogName); + break; + case UNREGISTER_DATABASE: + catalogMgr.unregisterExternalDatabase(descriptor.getDbName(), catalogName); + break; + case RENAME_DATABASE: + // legacy AlterDatabaseEvent.processRename: skip when the after-db already exists locally + if (catalog.getDbNullable(descriptor.getDbNameAfter()) == null) { + catalogMgr.unregisterExternalDatabase(descriptor.getDbName(), catalogName); + catalogMgr.registerExternalDatabaseFromEvent(descriptor.getDbNameAfter(), catalogName); + } + break; + case REGISTER_TABLE: + catalogMgr.registerExternalTableFromEvent(descriptor.getDbName(), descriptor.getTableName(), + catalogName, descriptor.getUpdateTime(), true); + break; + case UNREGISTER_TABLE: + catalogMgr.unregisterExternalTable(descriptor.getDbName(), descriptor.getTableName(), + catalogName, true); + break; + case RENAME_TABLE: + applyRenameTable(catalog, descriptor); + break; + case REFRESH_TABLE: + Env.getCurrentEnv().getRefreshManager().refreshExternalTableFromEvent(catalogName, + descriptor.getDbName(), descriptor.getTableName(), descriptor.getUpdateTime()); + break; + case ADD_PARTITIONS: + catalogMgr.addExternalPartitions(catalogName, descriptor.getDbName(), + descriptor.getTableName(), descriptor.getPartitionNames(), + descriptor.getUpdateTime(), true); + break; + case DROP_PARTITIONS: + catalogMgr.dropExternalPartitions(catalogName, descriptor.getDbName(), + descriptor.getTableName(), descriptor.getPartitionNames(), + descriptor.getUpdateTime(), true); + break; + case REFRESH_PARTITIONS: + Env.getCurrentEnv().getRefreshManager().refreshPartitions(catalogName, + descriptor.getDbName(), descriptor.getTableName(), + descriptor.getPartitionNames(), descriptor.getUpdateTime(), true); + break; + default: + break; + } + } + + private void applyRenameTable(PluginDrivenExternalCatalog catalog, MetastoreChangeDescriptor descriptor) + throws Exception { + String catalogName = catalog.getName(); + CatalogMgr catalogMgr = Env.getCurrentEnv().getCatalogMgr(); + // legacy AlterTableEvent: a rename to a DIFFERENT key that already exists locally is skipped + // (processRename guard); a view recreate (after == before) always proceeds (processRecreateTable). + boolean sameKey = descriptor.getDbName().equalsIgnoreCase(descriptor.getDbNameAfter()) + && descriptor.getTableName().equalsIgnoreCase(descriptor.getTableNameAfter()); + if (!sameKey && catalogMgr.externalTableExistInLocal(descriptor.getDbNameAfter(), + descriptor.getTableNameAfter(), catalogName)) { + return; + } + catalogMgr.unregisterExternalTable(descriptor.getDbName(), descriptor.getTableName(), + catalogName, true); + catalogMgr.registerExternalTableFromEvent(descriptor.getDbNameAfter(), + descriptor.getTableNameAfter(), catalogName, descriptor.getUpdateTime(), true); + } + + // Writes the synced-event-id cursor to the edit-log so followers advance to it (the log's only live + // purpose; the id-mapping payload the legacy path also wrote is vestigial — its getters have no + // production reader — so a cursor-only log is written). Opcode + neutral GSON format are unchanged. + private void writeSyncedCursorLog(long catalogId, long cursor) { + MetaIdMappingsLog log = new MetaIdMappingsLog(); + log.setCatalogId(catalogId); + log.setFromHmsEvent(true); + log.setLastSyncedEventId(cursor); + Env.getCurrentEnv().getExternalMetaIdMgr().replayMetaIdMappingsLog(log); + Env.getCurrentEnv().getEditLog().logMetaIdMappingsLog(log); + } + + private void refreshCatalogForMaster(CatalogIf catalog) { + CatalogLog log = new CatalogLog(); + log.setCatalogId(catalog.getId()); + log.setInvalidCache(true); + Env.getCurrentEnv().getRefreshManager().replayRefreshCatalog(log); + } + + private void refreshCatalogForSlave(CatalogIf catalog) throws Exception { + // A follower cannot refresh a catalog locally (that mutation must originate on the master); forward + // REFRESH CATALOG to the master, which replicates the result back. + String sql = "REFRESH CATALOG " + catalog.getName(); + OriginStatement originStmt = new OriginStatement(sql, 0); + ConnectContext ctx = new ConnectContext(); + ctx.setCurrentUserIdentity(UserIdentity.ROOT); + ctx.setEnv(Env.getCurrentEnv()); + MasterOpExecutor masterOpExecutor = new MasterOpExecutor(originStmt, ctx, + RedirectStatus.FORWARD_WITH_SYNC, false); + masterOpExecutor.execute(); + } + + /** + * Advances a follower's known master-committed cursor for a catalog. Wired from the + * {@code MetaIdMappingsLog} edit-log replay at the flip (mirrors the legacy + * {@code MetastoreEventsProcessor.updateMasterLastSyncedEventId}); keyed by catalog id only, so it never + * casts the catalog to a source-specific type. + */ + public void updateMasterLastSyncedEventId(long catalogId, long eventId) { + masterLastSyncedEventIdMap.put(catalogId, eventId); + } + + private static T onPluginClassLoader(ConnectorEventSource eventSource, Supplier body) { + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(eventSource.getClass().getClassLoader()); + return body.get(); + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenExternalCatalog.java deleted file mode 100644 index c433523d9a6e75..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenExternalCatalog.java +++ /dev/null @@ -1,311 +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.datasource; - -import org.apache.doris.common.DdlException; -import org.apache.doris.connector.ConnectorFactory; -import org.apache.doris.connector.ConnectorSessionBuilder; -import org.apache.doris.connector.DefaultConnectorContext; -import org.apache.doris.connector.DefaultConnectorValidationContext; -import org.apache.doris.connector.api.Connector; -import org.apache.doris.connector.api.ConnectorSession; -import org.apache.doris.connector.api.ConnectorTestResult; -import org.apache.doris.datasource.property.metastore.MetastoreProperties; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.transaction.PluginDrivenTransactionManager; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.IOException; -import java.util.List; -import java.util.Locale; -import java.util.Map; - -/** - * An {@link ExternalCatalog} backed by a Connector SPI plugin. - * - *

    This adapter bridges the connector SPI ({@link Connector}) with the existing - * ExternalCatalog hierarchy. Metadata operations are delegated to the connector's - * {@link org.apache.doris.connector.api.ConnectorMetadata} implementation.

    - * - *

    When created via {@link CatalogFactory}, the Connector instance is provided - * directly. After GSON deserialization (FE restart), the Connector is recreated - * from catalog properties during {@link #initLocalObjectsImpl()}.

    - */ -public class PluginDrivenExternalCatalog extends ExternalCatalog { - - private static final Logger LOG = LogManager.getLogger(PluginDrivenExternalCatalog.class); - - // Volatile for cross-thread visibility; all mutations happen under synchronized(this) - // via makeSureInitialized() → initLocalObjectsImpl(), or resetToUninitialized() → onClose(). - private transient volatile Connector connector; - - /** No-arg constructor for GSON deserialization. */ - public PluginDrivenExternalCatalog() { - } - - /** - * Creates a plugin-driven catalog with an already-created Connector. - * - * @param catalogId unique catalog id - * @param name catalog name - * @param resource optional resource name - * @param props catalog properties - * @param comment catalog comment - * @param connector the SPI connector instance - */ - public PluginDrivenExternalCatalog(long catalogId, String name, String resource, - Map props, String comment, Connector connector) { - super(catalogId, name, InitCatalogLog.Type.PLUGIN, comment); - this.catalogProperty = new CatalogProperty(resource, props); - this.connector = connector; - } - - @Override - protected void initLocalObjectsImpl() { - // Always (re-)create the connector so it gets the proper engine context, - // including the catalog's execution authenticator for Kerberos/secured HMS. - // The connector created by CatalogFactory used a lightweight context - // without auth (the catalog didn't exist yet); we replace it now. - Connector oldConnector = connector; - Connector newConnector = createConnectorFromProperties(); - if (newConnector != null) { - connector = newConnector; - // Close the old connector (e.g., the one injected by CatalogFactory during - // checkWhenCreating) to release its connection pool and classloader reference. - if (oldConnector != null && oldConnector != newConnector) { - try { - oldConnector.close(); - } catch (IOException e) { - LOG.warn("Failed to close old connector during re-initialization " - + "for catalog {}", name, e); - } - } - } - if (connector == null) { - throw new RuntimeException("No ConnectorProvider found for plugin-driven catalog: " - + name + ", type: " + getType() - + ". Ensure the connector plugin is installed."); - } - transactionManager = new PluginDrivenTransactionManager(); - initPreExecutionAuthenticator(); - } - - @Override - protected synchronized void initPreExecutionAuthenticator() { - if (executionAuthenticator != null) { - return; - } - try { - MetastoreProperties msp = catalogProperty.getMetastoreProperties(); - if (msp != null) { - executionAuthenticator = msp.getExecutionAuthenticator(); - return; - } - } catch (Exception ignored) { - // Not all catalog types have metastore properties (e.g., JDBC, ES) - } - super.initPreExecutionAuthenticator(); - } - - /** - * Creates a new Connector from catalog properties. Extracted as a protected method - * so tests can override without depending on the static ConnectorFactory registry. - */ - protected Connector createConnectorFromProperties() { - // Use getType() which falls back to logType when "type" is not in properties. - // This handles image deserialization of old resource-backed catalogs whose - // properties never contained "type" (it was derived from the Resource object). - String catalogType = getType(); - return ConnectorFactory.createConnector(catalogType, - catalogProperty.getProperties(), - new DefaultConnectorContext(name, id, this::getExecutionAuthenticator)); - } - - @Override - public void checkProperties() throws DdlException { - super.checkProperties(); - String catalogType = getType(); - try { - ConnectorFactory.validateProperties(catalogType, catalogProperty.getProperties()); - } catch (IllegalArgumentException e) { - throw new DdlException(e.getMessage()); - } - // Validate function_rules JSON if present (shared across all connector types). - String functionRules = catalogProperty.getOrDefault("function_rules", null); - ExternalFunctionRules.check(functionRules); - } - - @Override - public void checkWhenCreating() throws DdlException { - // Let the connector perform its type-specific pre-creation validation - // (e.g., JDBC driver security, checksum computation). - DefaultConnectorValidationContext validationCtx = - new DefaultConnectorValidationContext(getId(), catalogProperty); - try { - connector.preCreateValidation(validationCtx); - } catch (DdlException e) { - throw e; - } catch (Exception e) { - throw new DdlException(e.getMessage(), e); - } - - boolean testConnection = Boolean.parseBoolean( - catalogProperty.getOrDefault(ExternalCatalog.TEST_CONNECTION, - String.valueOf(connector.defaultTestConnection()))); - if (!testConnection) { - return; - } - // Delegate FE→external connectivity testing to the connector SPI. - ConnectorSession session = buildConnectorSession(); - ConnectorTestResult result = connector.testConnection(session); - if (!result.isSuccess()) { - throw new DdlException("Connectivity test failed for catalog '" - + name + "': " + result.getMessage()); - } - LOG.info("Connectivity test passed for plugin-driven catalog '{}': {}", name, result); - - // Execute any BE→external connectivity test the connector registered. - validationCtx.executePendingBeTests(); - } - - /** - * Handles catalog property updates. Delegates to the parent which resets - * caches, sets objectCreated=false, and calls onClose() to release the - * current connector. The next makeSureInitialized() call will trigger - * initLocalObjectsImpl() which creates a new connector with the updated - * properties and proper engine context (auth, etc.). - * - *

    This follows the same lifecycle pattern as all other ExternalCatalog - * subclasses: reset → lazy re-initialization on next access.

    - */ - @Override - public void notifyPropertiesUpdated(Map updatedProps) { - super.notifyPropertiesUpdated(updatedProps); - } - - @Override - protected List listDatabaseNames() { - ConnectorSession session = buildConnectorSession(); - return connector.getMetadata(session).listDatabaseNames(session); - } - - @Override - protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { - ConnectorSession session = buildConnectorSession(); - return connector.getMetadata(session).listTableNames(session, dbName); - } - - @Override - public boolean tableExist(SessionContext ctx, String dbName, String tblName) { - ConnectorSession session = buildConnectorSession(); - return connector.getMetadata(session) - .getTableHandle(session, dbName, tblName).isPresent(); - } - - @Override - public String getType() { - // Return the actual catalog type (e.g., "es", "jdbc") from properties, - // not the internal "plugin" logType. - return catalogProperty.getOrDefault(CatalogMgr.CATALOG_TYPE_PROP, super.getType()); - } - - /** Returns the underlying SPI connector. Ensures the catalog is initialized first. */ - public Connector getConnector() { - makeSureInitialized(); - return connector; - } - - @Override - public String fromRemoteDatabaseName(String remoteDatabaseName) { - ConnectorSession session = buildConnectorSession(); - return connector.getMetadata(session).fromRemoteDatabaseName(session, remoteDatabaseName); - } - - @Override - public String fromRemoteTableName(String remoteDatabaseName, String remoteTableName) { - ConnectorSession session = buildConnectorSession(); - return connector.getMetadata(session).fromRemoteTableName(session, remoteDatabaseName, remoteTableName); - } - - /** - * Builds a {@link ConnectorSession} from the current thread's {@link ConnectContext}. - */ - public ConnectorSession buildConnectorSession() { - ConnectContext ctx = ConnectContext.get(); - if (ctx != null) { - return ConnectorSessionBuilder.from(ctx) - .withCatalogId(getId()) - .withCatalogName(getName()) - .withCatalogProperties(catalogProperty.getProperties()) - .build(); - } - return ConnectorSessionBuilder.create() - .withCatalogId(getId()) - .withCatalogName(getName()) - .withCatalogProperties(catalogProperty.getProperties()) - .build(); - } - - @Override - protected ExternalDatabase buildDbForInit(String remoteDbName, String localDbName, - long dbId, InitCatalogLog.Type logType, boolean checkExists) { - // Always use PLUGIN logType regardless of what was serialized (e.g., ES from migration). - return super.buildDbForInit(remoteDbName, localDbName, dbId, InitCatalogLog.Type.PLUGIN, checkExists); - } - - @Override - public void gsonPostProcess() throws IOException { - super.gsonPostProcess(); - // For old resource-backed catalogs (e.g., ES, JDBC), the "type" property was never - // persisted — it was derived from the Resource object at runtime. After image - // deserialization with registerCompatibleSubtype, those catalogs land here as - // PluginDrivenExternalCatalog with logType still set to the original value (ES/JDBC). - // Backfill "type" from logType before we overwrite it below, so that - // createConnectorFromProperties() and getType() can resolve the catalog type. - if (logType != null && logType != InitCatalogLog.Type.PLUGIN - && logType != InitCatalogLog.Type.UNKNOWN) { - String oldType = logType.name().toLowerCase(Locale.ROOT); - if (catalogProperty.getOrDefault(CatalogMgr.CATALOG_TYPE_PROP, "").isEmpty()) { - LOG.info("Backfilling missing 'type' property for catalog '{}' from logType: {}", - name, oldType); - catalogProperty.addProperty(CatalogMgr.CATALOG_TYPE_PROP, oldType); - } - } - // After deserializing a migrated old catalog (e.g., ES → PluginDriven), fix logType - // so that buildDbForInit uses PLUGIN path. - if (logType != InitCatalogLog.Type.PLUGIN) { - LOG.info("Migrating catalog '{}' logType from {} to PLUGIN", name, logType); - logType = InitCatalogLog.Type.PLUGIN; - } - } - - @Override - public void onClose() { - super.onClose(); - if (connector != null) { - try { - connector.close(); - } catch (IOException e) { - LOG.warn("Failed to close connector for catalog {}", name, e); - } - connector = null; - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenExternalDatabase.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenExternalDatabase.java deleted file mode 100644 index 83581fef8529b6..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenExternalDatabase.java +++ /dev/null @@ -1,43 +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.datasource; - -/** - * Generic {@link ExternalDatabase} for plugin-driven catalogs. - * - *

    Provides minimal implementation that delegates table construction - * to {@link PluginDrivenExternalTable}.

    - */ -public class PluginDrivenExternalDatabase extends ExternalDatabase { - - /** No-arg constructor for GSON deserialization. */ - public PluginDrivenExternalDatabase() { - super(null, 0, null, null, InitDatabaseLog.Type.PLUGIN); - } - - public PluginDrivenExternalDatabase(ExternalCatalog extCatalog, long id, - String name, String remoteName) { - super(extCatalog, id, name, remoteName, InitDatabaseLog.Type.PLUGIN); - } - - @Override - protected PluginDrivenExternalTable buildTableInternal(String remoteTableName, - String localTableName, long tblId, ExternalCatalog catalog, ExternalDatabase db) { - return new PluginDrivenExternalTable(tblId, localTableName, remoteTableName, catalog, db); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenExternalTable.java deleted file mode 100644 index 020c3703ff07cb..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenExternalTable.java +++ /dev/null @@ -1,273 +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.datasource; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.TableIf.TableType; -import org.apache.doris.common.util.DebugPointUtil; -import org.apache.doris.connector.api.Connector; -import org.apache.doris.connector.api.ConnectorCapability; -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.ConnectorTableSchema; -import org.apache.doris.connector.api.ConnectorTableStatistics; -import org.apache.doris.connector.api.handle.ConnectorTableHandle; -import org.apache.doris.statistics.AnalysisInfo; -import org.apache.doris.statistics.BaseAnalysisTask; -import org.apache.doris.statistics.ExternalAnalysisTask; -import org.apache.doris.thrift.TTableDescriptor; -import org.apache.doris.thrift.TTableType; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -/** - * Generic {@link ExternalTable} for plugin-driven catalogs. - * - *

    Provides table implementation that fetches schema from the connector SPI. - * Connector-specific behavior is accessed through the parent catalog's - * {@link org.apache.doris.connector.api.Connector} using opaque handles.

    - */ -public class PluginDrivenExternalTable extends ExternalTable { - - private static final Logger LOG = LogManager.getLogger(PluginDrivenExternalTable.class); - - /** No-arg constructor for GSON deserialization. */ - public PluginDrivenExternalTable() { - } - - public PluginDrivenExternalTable(long id, String name, String remoteName, - ExternalCatalog catalog, ExternalDatabase db) { - super(id, name, remoteName, catalog, db, TableType.PLUGIN_EXTERNAL_TABLE); - } - - /** - * Returns whether the underlying connector supports multiple concurrent writers. - * Used by the planner to decide GATHER (single writer) vs parallel distribution. - */ - public boolean supportsParallelWrite() { - if (!(catalog instanceof PluginDrivenExternalCatalog)) { - return false; - } - Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); - return connector != null - && connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_PARALLEL_WRITE); - } - - @Override - public boolean supportsExternalMetadataPreload() { - if (!(catalog instanceof PluginDrivenExternalCatalog)) { - return false; - } - // Keep plugin-driven preload limited to JDBC until other connector types are validated. - return "jdbc".equalsIgnoreCase(((PluginDrivenExternalCatalog) catalog).getType()); - } - - @Override - public Optional initSchema() { - PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; - // Keep the JDBC schema delay debug point available for manual regression verification. - if ("jdbc".equalsIgnoreCase(pluginCatalog.getType()) - && DebugPointUtil.isEnable("PluginDrivenExternalTable.initSchema.sleep")) { - long sleepMs = DebugPointUtil.getDebugParamOrDefault( - "PluginDrivenExternalTable.initSchema.sleep", "sleepMs", 0L); - if (sleepMs > 0) { - LOG.info("debug point PluginDrivenExternalTable.initSchema.sleep hit for {}.{}, sleep {}ms", - db != null ? db.getRemoteName() : "", getRemoteName(), sleepMs); - try { - Thread.sleep(sleepMs); - } catch (InterruptedException ignore) { - Thread.currentThread().interrupt(); - } - } - } - Connector connector = pluginCatalog.getConnector(); - ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); - - String dbName = db != null ? db.getRemoteName() : ""; - String tableName = getRemoteName(); - Optional handleOpt = metadata.getTableHandle(session, dbName, tableName); - if (!handleOpt.isPresent()) { - LOG.warn("Table handle not found for plugin-driven table: {}.{}", dbName, tableName); - return Optional.empty(); - } - - ConnectorTableSchema tableSchema = metadata.getTableSchema(session, handleOpt.get()); - - // Apply identifier mapping to column names (lowercase / explicit mapping) - List mappedColumns = new ArrayList<>(tableSchema.getColumns().size()); - for (ConnectorColumn col : tableSchema.getColumns()) { - String mappedName = metadata.fromRemoteColumnName(session, dbName, tableName, col.getName()); - if (!mappedName.equals(col.getName())) { - mappedColumns.add(new ConnectorColumn(mappedName, col.getType(), - col.getComment(), col.isNullable(), col.getDefaultValue(), col.isKey())); - } else { - mappedColumns.add(col); - } - } - - List columns = ConnectorColumnConverter.convertColumns(mappedColumns); - return Optional.of(new SchemaCacheValue(columns)); - } - - @Override - protected synchronized void makeSureInitialized() { - super.makeSureInitialized(); - if (!objectCreated) { - objectCreated = true; - } - } - - @Override - public long getCachedRowCount() { - // Do NOT call makeSureInitialized() here. - // ExternalTable.getCachedRowCount() intentionally returns -1 for uninitialized tables - // so that SHOW TABLE STATUS / information_schema.tables stays non-blocking. - if (!isObjectCreated()) { - return -1; - } - return Env.getCurrentEnv().getExtMetaCacheMgr().getRowCountCache() - .getCachedRowCount(catalog.getId(), dbId, id, false); - } - - @Override - public String getComment() { - return getComment(false); - } - - @Override - public String getComment(boolean escapeQuota) { - String remoteDbName = db != null ? db.getRemoteName() : ""; - try { - PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; - Connector connector = pluginCatalog.getConnector(); - ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); - String tableName = getRemoteName(); - String comment = metadata.getTableComment(session, remoteDbName, tableName); - if (escapeQuota && comment != null) { - return comment.replace("'", "\\'"); - } - return comment != null ? comment : ""; - } catch (Exception e) { - LOG.debug("Failed to get table comment for {}.{}", remoteDbName, name, e); - return ""; - } - } - - @Override - public void gsonPostProcess() throws IOException { - super.gsonPostProcess(); - // After deserializing a migrated old table (e.g., EsExternalTable → PluginDrivenExternalTable), - // fix the table type so that BindRelation routes to LogicalFileScan (new path). - if (type != TableType.PLUGIN_EXTERNAL_TABLE) { - LOG.info("Migrating table '{}' type from {} to PLUGIN_EXTERNAL_TABLE", name, type); - type = TableType.PLUGIN_EXTERNAL_TABLE; - } - } - - @Override - public BaseAnalysisTask createAnalysisTask(AnalysisInfo info) { - makeSureInitialized(); - return new ExternalAnalysisTask(info); - } - - @Override - public long fetchRowCount() { - makeSureInitialized(); - PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; - Connector connector = pluginCatalog.getConnector(); - ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); - - String dbName = db != null ? db.getRemoteName() : ""; - String tableName = getRemoteName(); - Optional handleOpt = metadata.getTableHandle(session, dbName, tableName); - if (!handleOpt.isPresent()) { - return UNKNOWN_ROW_COUNT; - } - - Optional statsOpt = metadata.getTableStatistics(session, handleOpt.get()); - if (statsOpt.isPresent() && statsOpt.get().getRowCount() >= 0) { - return statsOpt.get().getRowCount(); - } - return UNKNOWN_ROW_COUNT; - } - - @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(); - default: - return super.getEngine(); - } - } - - @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(); - default: - return TableType.PLUGIN_EXTERNAL_TABLE.name(); - } - } - - @Override - public TTableDescriptor toThrift() { - makeSureInitialized(); - PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; - Connector connector = pluginCatalog.getConnector(); - ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); - - String dbName = db != null ? db.getRemoteName() : ""; - List schema = getFullSchema(); - TTableDescriptor desc = metadata.buildTableDescriptor(session, - getId(), getName(), dbName, getRemoteName(), - schema.size(), pluginCatalog.getId()); - if (desc != null) { - return desc; - } - LOG.warn("Connector returned null table descriptor for plugin table {}.{}, " - + "using generic fallback", dbName, getName()); - return new TTableDescriptor(getId(), TTableType.SCHEMA_TABLE, - schema.size(), 0, getName(), dbName); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenScanNode.java deleted file mode 100644 index d0875e6f32bf90..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenScanNode.java +++ /dev/null @@ -1,615 +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.datasource; - -import org.apache.doris.analysis.CastExpr; -import org.apache.doris.analysis.Expr; -import org.apache.doris.analysis.ExprToSqlVisitor; -import org.apache.doris.analysis.ToSqlParams; -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.common.UserException; -import org.apache.doris.connector.api.Connector; -import org.apache.doris.connector.api.ConnectorMetadata; -import org.apache.doris.connector.api.ConnectorSession; -import org.apache.doris.connector.api.handle.ConnectorColumnHandle; -import org.apache.doris.connector.api.handle.ConnectorTableHandle; -import org.apache.doris.connector.api.handle.PassthroughQueryTableHandle; -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.ConnectorScanPlanProvider; -import org.apache.doris.connector.api.scan.ConnectorScanRange; -import org.apache.doris.connector.api.scan.ScanNodePropertiesResult; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.spi.Split; -import org.apache.doris.thrift.TExplainLevel; -import org.apache.doris.thrift.TFileAttributes; -import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.TFileRangeDesc; -import org.apache.doris.thrift.TFileTextScanRangeParams; -import org.apache.doris.thrift.TTableFormatFileDesc; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.ArrayList; -import java.util.Arrays; -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.stream.Collectors; - -/** - * Generic scan node that delegates scan planning to the connector SPI. - * - *

    Replaces connector-specific ScanNode subclasses for plugin-driven catalogs. - * Extends {@link FileQueryScanNode} to reuse the existing split-to-Thrift - * conversion pipeline. Uses {@code FORMAT_JNI} by default, which routes - * to BE's JNI scanner framework.

    - * - *

    Scan flow:

    - *
      - *
    1. {@link #getSplits} calls {@link ConnectorScanPlanProvider#planScan} - * to obtain {@link ConnectorScanRange}s from the connector plugin
    2. - *
    3. Each range is wrapped in a {@link PluginDrivenSplit}
    4. - *
    5. {@link FileQueryScanNode#createScanRangeLocations} distributes splits - * to backends
    6. - *
    7. {@link #setScanParams} populates {@link TTableFormatFileDesc} with - * connector-specific properties for each split
    8. - *
    - */ -public class PluginDrivenScanNode extends FileQueryScanNode { - - private static final Logger LOG = LogManager.getLogger(PluginDrivenScanNode.class); - - 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."; - - private final Connector connector; - private final ConnectorSession connectorSession; - - // Set during filter pushdown; may be updated from the original table handle. - private ConnectorTableHandle currentHandle; - - // Populated from ConnectorScanPlanProvider.getScanNodePropertiesResult() - private ScanNodePropertiesResult cachedPropertiesResult; - private Map scanNodeProperties; - // Maps filtered conjunct indices (after CAST removal) back to original conjunct indices - private List filteredToOriginalIndex; - - public PluginDrivenScanNode(PlanNodeId id, TupleDescriptor desc, - boolean needCheckColumnPriv, SessionVariable sv, - ScanContext scanContext, Connector connector, - ConnectorSession connectorSession, ConnectorTableHandle tableHandle) { - super(id, desc, "PluginDrivenScanNode", scanContext, needCheckColumnPriv, sv); - this.connector = connector; - this.connectorSession = connectorSession; - this.currentHandle = tableHandle; - } - - /** - * Creates a PluginDrivenScanNode by resolving the connector, session, and table handle - * from the plugin-driven catalog and table. - */ - public static PluginDrivenScanNode create(PlanNodeId id, TupleDescriptor desc, - boolean needCheckColumnPriv, SessionVariable sv, - ScanContext scanContext, PluginDrivenExternalCatalog catalog, - PluginDrivenExternalTable table) { - Connector connector = catalog.getConnector(); - ConnectorSession session = catalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); - String dbName = table.getDb() != null ? table.getDb().getRemoteName() : ""; - String tableName = table.getRemoteName(); - ConnectorTableHandle handle = metadata.getTableHandle(session, dbName, tableName) - .orElseThrow(() -> new RuntimeException( - "Table handle not found for plugin-driven table: " + dbName + "." + tableName)); - return new PluginDrivenScanNode(id, desc, needCheckColumnPriv, sv, - scanContext, connector, session, handle); - } - - @Override - public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { - StringBuilder output = new StringBuilder(); - if (currentHandle instanceof PassthroughQueryTableHandle) { - output.append(prefix).append("TABLE VALUE FUNCTION\n"); - String query = ((PassthroughQueryTableHandle) currentHandle).getQuery(); - output.append(prefix).append("QUERY: ").append(query).append("\n"); - } else { - Map props = getOrLoadScanNodeProperties(); - String query = props.get("query"); - output.append(prefix).append("TABLE: ") - .append(desc.getTable().getNameWithFullQualifiers()).append("\n"); - if (query != null) { - output.append(prefix).append("QUERY: ").append(query).append("\n"); - } - if (!conjuncts.isEmpty()) { - Expr expr = convertConjunctsToAndCompoundPredicate(conjuncts); - output.append(prefix).append("PREDICATES: ") - .append(expr.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE)) - .append("\n"); - } - // Delegate connector-specific EXPLAIN info to the SPI - ConnectorScanPlanProvider scanProvider = connector.getScanPlanProvider(); - if (scanProvider != null) { - scanProvider.appendExplainInfo(output, prefix, props); - } - // 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(",", - topnFilterSortNodes.stream() - .map(node -> node.getId().asInt() + "").collect(Collectors.toList())); - output.append(prefix).append("TOPN OPT:").append(topnFilterSources).append("\n"); - } - return output.toString(); - } - - @Override - protected TFileFormatType getFileFormatType() throws UserException { - Map props = getOrLoadScanNodeProperties(); - String format = props.get(PROP_FILE_FORMAT_TYPE); - if (format != null) { - return mapFileFormatType(format); - } - return TFileFormatType.FORMAT_JNI; - } - - @Override - protected List getPathPartitionKeys() throws UserException { - Map props = getOrLoadScanNodeProperties(); - String keys = props.get(PROP_PATH_PARTITION_KEYS); - if (keys != null && !keys.isEmpty()) { - return Arrays.asList(keys.split(",")); - } - return Collections.emptyList(); - } - - @Override - protected TableIf getTargetTable() throws UserException { - return desc.getTable(); - } - - @Override - 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()); - locationProps.put(realKey, entry.getValue()); - } - } - return locationProps; - } - - @Override - protected TFileAttributes getFileAttributes() throws UserException { - Map props = getOrLoadScanNodeProperties(); - String serDeLib = props.get(PROP_HIVE_TEXT_PREFIX + "serde_lib"); - if (serDeLib == null || serDeLib.isEmpty()) { - return new TFileAttributes(); - } - - TFileAttributes attrs = new TFileAttributes(); - String skipLinesStr = props.get(PROP_HIVE_TEXT_PREFIX + "skip_lines"); - if (skipLinesStr != null) { - try { - attrs.setSkipLines(Integer.parseInt(skipLinesStr)); - } catch (NumberFormatException e) { - // ignore - } - } - - TFileTextScanRangeParams textParams = new TFileTextScanRangeParams(); - String colSep = props.get(PROP_HIVE_TEXT_PREFIX + "column_separator"); - if (colSep != null) { - textParams.setColumnSeparator(colSep); - } - String lineSep = props.get(PROP_HIVE_TEXT_PREFIX + "line_delimiter"); - if (lineSep != null) { - textParams.setLineDelimiter(lineSep); - } - String mapkvDelim = props.get(PROP_HIVE_TEXT_PREFIX + "mapkv_delimiter"); - if (mapkvDelim != null) { - textParams.setMapkvDelimiter(mapkvDelim); - } - String collDelim = props.get(PROP_HIVE_TEXT_PREFIX + "collection_delimiter"); - if (collDelim != null) { - textParams.setCollectionDelimiter(collDelim); - } - String escape = props.get(PROP_HIVE_TEXT_PREFIX + "escape"); - if (escape != null && !escape.isEmpty()) { - textParams.setEscape(escape.getBytes()[0]); - } - String nullFmt = props.get(PROP_HIVE_TEXT_PREFIX + "null_format"); - if (nullFmt != null) { - textParams.setNullFormat(nullFmt); - } - String enclose = props.get(PROP_HIVE_TEXT_PREFIX + "enclose"); - if (enclose != null && !enclose.isEmpty()) { - textParams.setEnclose(enclose.getBytes()[0]); - attrs.setTrimDoubleQuotes(true); - } - - attrs.setTextParams(textParams); - attrs.setHeaderType(""); - attrs.setEnableTextValidateUtf8(sessionVariable.enableTextValidateUtf8); - - String isJson = props.get(PROP_HIVE_TEXT_PREFIX + "is_json"); - if ("true".equals(isJson)) { - attrs.setReadJsonByLine(true); - attrs.setReadByColumnDef(true); - } - - return attrs; - } - - @Override - protected void convertPredicate() { - // Attempt filter pushdown via the connector SPI - if (conjuncts == null || conjuncts.isEmpty()) { - return; - } - ConnectorMetadata metadata = connector.getMetadata(connectorSession); - ConnectorFilterConstraint constraint = buildFilterConstraint(conjuncts); - Optional> result = - metadata.applyFilter(connectorSession, currentHandle, constraint); - if (result.isPresent()) { - FilterApplicationResult filterResult = result.get(); - currentHandle = filterResult.getHandle(); - - // Consume remainingFilter to avoid duplicate predicate evaluation on BE: - // - null means all predicates were fully pushed down → clear conjuncts - // - non-null means some/all predicates remain → keep conjuncts (conservative) - ConnectorExpression remaining = filterResult.getRemainingFilter(); - if (remaining == null) { - conjuncts.clear(); - LOG.debug("Filter fully pushed down for plugin-driven scan, cleared conjuncts"); - } else { - // Partial or full remaining: keep all conjuncts for BE-side evaluation. - // Fine-grained conjunct removal (matching individual remaining sub-expressions - // back to original Expr conjuncts) is deferred to a future enhancement. - LOG.debug("Filter pushdown accepted with remaining filter, keeping conjuncts"); - } - } - // Invalidate cached properties so they are rebuilt with the updated conjuncts/handle. - scanNodeProperties = null; - cachedPropertiesResult = null; - 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 = connector.getMetadata(connectorSession); - 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. - * - *

    If the connector accepts the projection, the handle is updated.

    - */ - private void tryPushDownProjection(List columns) { - if (columns.isEmpty()) { - return; - } - ConnectorMetadata metadata = connector.getMetadata(connectorSession); - Optional> result = - metadata.applyProjection(connectorSession, currentHandle, columns); - if (result.isPresent()) { - currentHandle = result.get().getHandle(); - LOG.debug("Projection pushed down via applyProjection for plugin-driven scan"); - } - } - - @Override - public List getSplits(int numBackends) throws UserException { - // Attempt limit and projection pushdown via SPI protocol - tryPushDownLimit(); - - ConnectorScanPlanProvider scanProvider = connector.getScanPlanProvider(); - if (scanProvider == null) { - LOG.warn("Connector does not provide a scan plan provider, returning empty splits"); - return Collections.emptyList(); - } - - List columns = buildColumnHandles(); - tryPushDownProjection(columns); - Optional remainingFilter = buildRemainingFilter(); - - List ranges = scanProvider.planScan( - connectorSession, currentHandle, columns, remainingFilter, limit); - - List splits = new ArrayList<>(ranges.size()); - for (ConnectorScanRange range : ranges) { - splits.add(new PluginDrivenSplit(range)); - } - return splits; - } - - @Override - protected void setScanParams(TFileRangeDesc rangeDesc, Split split) { - if (!(split instanceof PluginDrivenSplit)) { - return; - } - PluginDrivenSplit pluginSplit = (PluginDrivenSplit) split; - ConnectorScanRange scanRange = pluginSplit.getConnectorScanRange(); - - TTableFormatFileDesc tableFormatFileDesc = new TTableFormatFileDesc(); - tableFormatFileDesc.setTableFormatType(scanRange.getTableFormatType()); - - // Delegate format-specific Thrift construction to the connector SPI - scanRange.populateRangeParams(tableFormatFileDesc, rangeDesc); - - rangeDesc.setTableFormatParams(tableFormatFileDesc); - } - - - @Override - protected Optional getSerializedTable() { - ConnectorScanPlanProvider scanProvider = connector.getScanPlanProvider(); - if (scanProvider != null) { - Map props = getOrLoadScanNodeProperties(); - String serialized = scanProvider.getSerializedTable(props); - if (serialized != null) { - return Optional.of(serialized); - } - } - return Optional.empty(); - } - - @Override - public void createScanRangeLocations() throws UserException { - super.createScanRangeLocations(); - // Delegate scan-level Thrift params to the connector SPI - ConnectorScanPlanProvider scanProvider = connector.getScanPlanProvider(); - if (scanProvider != null) { - Map props = getOrLoadScanNodeProperties(); - scanProvider.populateScanLevelParams(params, props); - } - 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)); - } - } - - - /** - * 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.

    - */ - private void pruneConjunctsFromNodeProperties() { - if (conjuncts == null || conjuncts.isEmpty()) { - return; - } - ScanNodePropertiesResult result = getOrLoadPropertiesResult(); - - if (!result.hasConjunctTracking()) { - // No conjunct tracking — do not prune (keep all conjuncts for safety) - return; - } - - // notPushedSet indices are relative to the filtered conjunct list - // (after CAST expr removal). Map them back to original conjunct indices. - Set notPushedSet = result.getNotPushedConjunctIndices(); - Set originalNotPushed = new HashSet<>(); - if (filteredToOriginalIndex != null) { - for (int filteredIdx : notPushedSet) { - if (filteredIdx < filteredToOriginalIndex.size()) { - originalNotPushed.add(filteredToOriginalIndex.get(filteredIdx)); - } - } - } else { - // No CAST filtering was applied — indices map 1:1 - originalNotPushed.addAll(notPushedSet); - } - - // Also keep any conjuncts that were filtered out (CAST expressions) - // since those were never sent to the connector for pushdown - if (filteredToOriginalIndex != null) { - Set sentToConnector = new HashSet<>(filteredToOriginalIndex); - for (int i = 0; i < conjuncts.size(); i++) { - if (!sentToConnector.contains(i)) { - originalNotPushed.add(i); - } - } - } - - List remaining = new ArrayList<>(); - for (int i = 0; i < conjuncts.size(); i++) { - if (originalNotPushed.contains(i)) { - remaining.add(conjuncts.get(i)); - } - } - conjuncts.clear(); - conjuncts.addAll(remaining); - } - - /** - * Lazily loads and caches the ScanNodePropertiesResult from the connector. - * Both getOrLoadScanNodeProperties() and pruneConjunctsFromNodeProperties() - * use this to avoid redundant computation. - */ - private ScanNodePropertiesResult getOrLoadPropertiesResult() { - if (cachedPropertiesResult == null) { - ConnectorScanPlanProvider scanProvider = connector.getScanPlanProvider(); - if (scanProvider != null) { - List columns = buildColumnHandles(); - Optional filter = buildRemainingFilter(); - cachedPropertiesResult = scanProvider.getScanNodePropertiesResult( - connectorSession, currentHandle, columns, filter); - } - if (cachedPropertiesResult == null) { - cachedPropertiesResult = new ScanNodePropertiesResult(Collections.emptyMap()); - } - } - return cachedPropertiesResult; - } - - /** - * Lazily loads scan node properties from the connector's scan plan provider. - */ - private Map getOrLoadScanNodeProperties() { - if (scanNodeProperties == null) { - scanNodeProperties = getOrLoadPropertiesResult().getProperties(); - if (scanNodeProperties == null) { - scanNodeProperties = Collections.emptyMap(); - } - } - return scanNodeProperties; - } - - /** - * Maps a file format name string to the corresponding TFileFormatType. - */ - private static TFileFormatType mapFileFormatType(String format) { - switch (format.toLowerCase()) { - case "parquet": - return TFileFormatType.FORMAT_PARQUET; - case "orc": - return TFileFormatType.FORMAT_ORC; - case "text": - case "csv": - return TFileFormatType.FORMAT_CSV_PLAIN; - case "json": - return TFileFormatType.FORMAT_JSON; - case "avro": - return TFileFormatType.FORMAT_AVRO; - case "es_http": - return TFileFormatType.FORMAT_ES_HTTP; - default: - return TFileFormatType.FORMAT_JNI; - } - } - - /** - * Builds column handles from the tuple descriptor's slot descriptors. - * These tell the connector which columns are needed for the query, - * enabling optimized column selection (e.g., SELECT col1, col2 instead of SELECT *). - */ - private List buildColumnHandles() { - ConnectorMetadata metadata = connector.getMetadata(connectorSession); - Map allHandles = - metadata.getColumnHandles(connectorSession, currentHandle); - if (allHandles.isEmpty()) { - return Collections.emptyList(); - } - List selected = new ArrayList<>(); - for (org.apache.doris.analysis.SlotDescriptor slot : desc.getSlots()) { - if (slot.getColumn() != null) { - ConnectorColumnHandle ch = allHandles.get(slot.getColumn().getName()); - if (ch != null) { - selected.add(ch); - } - } - } - return selected; - } - - /** - * Builds a {@link ConnectorFilterConstraint} from the current conjuncts. - */ - private ConnectorFilterConstraint buildFilterConstraint(List exprs) { - ConnectorExpression combined = ExprToConnectorExpressionConverter.convertConjuncts(exprs); - return new ConnectorFilterConstraint(combined, Collections.emptyMap()); - } - - /** - * Builds the remaining filter expression from unconsumed conjuncts. - * If no conjuncts remain, returns {@link Optional#empty()}. - * Filters out CAST-containing predicates when the connector does not support CAST pushdown. - */ - private Optional buildRemainingFilter() { - if (conjuncts == null || conjuncts.isEmpty()) { - filteredToOriginalIndex = null; - return Optional.empty(); - } - List pushableConjuncts = conjuncts; - ConnectorMetadata metadata = connector.getMetadata(connectorSession); - if (!metadata.supportsCastPredicatePushdown(connectorSession)) { - filteredToOriginalIndex = new ArrayList<>(); - pushableConjuncts = new ArrayList<>(); - for (int i = 0; i < conjuncts.size(); i++) { - if (!containsCastExpr(conjuncts.get(i))) { - pushableConjuncts.add(conjuncts.get(i)); - filteredToOriginalIndex.add(i); - } - } - // If no filtering occurred, clear the mapping (1:1) - if (filteredToOriginalIndex.size() == conjuncts.size()) { - filteredToOriginalIndex = null; - } - } else { - filteredToOriginalIndex = null; - } - if (pushableConjuncts.isEmpty()) { - return Optional.empty(); - } - return Optional.of(ExprToConnectorExpressionConverter.convertConjuncts(pushableConjuncts)); - } - - private static boolean containsCastExpr(Expr expr) { - List castExprs = new ArrayList<>(); - expr.collect(CastExpr.class, castExprs); - return !castExprs.isEmpty(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenSplit.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenSplit.java deleted file mode 100644 index 87fd3bfc00f40f..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenSplit.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.datasource; - -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.connector.api.scan.ConnectorScanRange; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -/** - * A {@link FileSplit} that wraps a {@link ConnectorScanRange} from the SPI layer. - * - *

    Maps the connector scan range's data to the FileSplit interface so it can - * flow through the existing {@code FileQueryScanNode} pipeline. The original - * {@link ConnectorScanRange} is preserved and accessible for format-specific - * parameter extraction in {@code PluginDrivenScanNode.setScanParams()}.

    - */ -public class PluginDrivenSplit extends FileSplit { - - private final ConnectorScanRange connectorScanRange; - - public PluginDrivenSplit(ConnectorScanRange scanRange) { - super(buildPath(scanRange), - scanRange.getStart(), - scanRange.getLength(), - scanRange.getFileSize(), - scanRange.getModificationTime(), - scanRange.getHosts().toArray(new String[0]), - buildPartitionValues(scanRange)); - this.connectorScanRange = scanRange; - } - - /** Returns the underlying connector scan range for format-specific param extraction. */ - public ConnectorScanRange getConnectorScanRange() { - return connectorScanRange; - } - - @Override - public Object getInfo() { - return null; - } - - @Override - public String getPathString() { - return connectorScanRange.getPath().orElse("connector://virtual"); - } - - private static LocationPath buildPath(ConnectorScanRange scanRange) { - String pathStr = scanRange.getPath().orElse("connector://virtual"); - return LocationPath.of(pathStr); - } - - private static List buildPartitionValues(ConnectorScanRange scanRange) { - Map partValues = scanRange.getPartitionValues(); - if (partValues == null || partValues.isEmpty()) { - return null; - } - return new ArrayList<>(partValues.values()); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/TableMetadata.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/TableMetadata.java deleted file mode 100644 index e266c519e4241e..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/TableMetadata.java +++ /dev/null @@ -1,28 +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.datasource; - -import java.util.Map; - -public interface TableMetadata { - String getDbName(); - - String getTableName(); - - Map getProperties(); -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/AWSGlueMetaStoreBaseConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/AWSGlueMetaStoreBaseConnectivityTester.java deleted file mode 100644 index fc599c2e2bc1c6..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/AWSGlueMetaStoreBaseConnectivityTester.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.datasource.connectivity; - -import org.apache.doris.datasource.property.metastore.AWSGlueMetaStoreBaseProperties; - -import org.apache.commons.lang3.StringUtils; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.glue.GlueClient; -import software.amazon.awssdk.services.glue.GlueClientBuilder; -import software.amazon.awssdk.services.glue.model.GetDatabasesRequest; - -import java.net.URI; - -public class AWSGlueMetaStoreBaseConnectivityTester implements MetaConnectivityTester { - private final AWSGlueMetaStoreBaseProperties properties; - - public AWSGlueMetaStoreBaseConnectivityTester(AWSGlueMetaStoreBaseProperties properties) { - this.properties = properties; - } - - @Override - public String getTestType() { - return "AWS Glue"; - } - - @Override - public void testConnection() throws Exception { - GlueClientBuilder clientBuilder = GlueClient.builder(); - - String glueRegion = properties.getGlueRegion(); - String glueEndpoint = properties.getGlueEndpoint(); - - // Set region - if (StringUtils.isNotBlank(glueRegion)) { - clientBuilder.region(Region.of(glueRegion)); - } - - // Set endpoint if specified - if (StringUtils.isNotBlank(glueEndpoint)) { - clientBuilder.endpointOverride(URI.create(glueEndpoint)); - } - - // Set credentials using properties method - clientBuilder.credentialsProvider(properties.getAwsCredentialsProvider()); - - // Test connection by listing databases (lightweight operation) - try (GlueClient glueClient = clientBuilder.build()) { - GetDatabasesRequest request = GetDatabasesRequest.builder() - .maxResults(1) - .build(); - glueClient.getDatabases(request); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/AbstractHiveConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/AbstractHiveConnectivityTester.java deleted file mode 100644 index 468730b5b9b6e1..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/AbstractHiveConnectivityTester.java +++ /dev/null @@ -1,32 +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.datasource.connectivity; - -import org.apache.doris.datasource.property.metastore.AbstractHiveProperties; - -public abstract class AbstractHiveConnectivityTester implements MetaConnectivityTester { - protected final AbstractHiveProperties properties; - - protected AbstractHiveConnectivityTester(AbstractHiveProperties properties) { - this.properties = properties; - } - - @Override - public abstract void testConnection() throws Exception; - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/AbstractIcebergConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/AbstractIcebergConnectivityTester.java deleted file mode 100644 index e18988f6af9af2..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/AbstractIcebergConnectivityTester.java +++ /dev/null @@ -1,49 +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.datasource.connectivity; - -import org.apache.doris.datasource.property.metastore.AbstractIcebergProperties; - -import org.apache.commons.lang3.StringUtils; - -import java.util.regex.Pattern; - -public abstract class AbstractIcebergConnectivityTester implements MetaConnectivityTester { - - protected static final Pattern LOCATION_PATTERN = Pattern.compile("^(s3|s3a|s3n)://.+"); - protected final AbstractIcebergProperties properties; - - protected AbstractIcebergConnectivityTester(AbstractIcebergProperties properties) { - this.properties = properties; - } - - @Override - public abstract void testConnection() throws Exception; - - @Override - public String getTestLocation() { - return properties.getWarehouse(); - } - - protected String validateLocation(String location) { - if (StringUtils.isNotBlank(location) && LOCATION_PATTERN.matcher(location).matches()) { - return location; - } - return null; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/AbstractS3CompatibleConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/AbstractS3CompatibleConnectivityTester.java deleted file mode 100644 index 551448dc767a72..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/AbstractS3CompatibleConnectivityTester.java +++ /dev/null @@ -1,78 +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.datasource.connectivity; - -import org.apache.doris.common.util.S3Util; -import org.apache.doris.datasource.property.storage.AbstractS3CompatibleProperties; -import org.apache.doris.thrift.TStorageBackendType; - -import software.amazon.awssdk.services.s3.S3Client; - -import java.net.URI; -import java.util.HashMap; -import java.util.Map; - -public abstract class AbstractS3CompatibleConnectivityTester implements StorageConnectivityTester { - private static final String TEST_LOCATION = "test_location"; - protected final AbstractS3CompatibleProperties properties; - protected final String testLocation; - - public AbstractS3CompatibleConnectivityTester(AbstractS3CompatibleProperties properties, String testLocation) { - this.properties = properties; - // Normalize s3a:// and s3n:// schemes to s3:// - String normalized = testLocation.replaceFirst("^s3[an]://", "s3://"); - // If the path is just a bucket (e.g., s3://bucket or s3://bucket/), add a test key - // because BE's S3URI parser requires a non-empty key - if (normalized.matches("^s3://[^/]+/?$")) { - normalized = normalized.replaceFirst("/?$", "/.connectivity_test"); - } - this.testLocation = normalized; - } - - @Override - public TStorageBackendType getStorageType() { - return TStorageBackendType.S3; - } - - @Override - public Map getBackendProperties() { - Map props = new HashMap<>(properties.getBackendConfigProperties()); - props.put(TEST_LOCATION, testLocation); - return props; - } - - @Override - public void testFeConnection() throws Exception { - String bucket = URI.create(testLocation).getAuthority(); - String endpoint = properties.getEndpoint(); - - try (S3Client client = S3Util.buildS3Client( - URI.create(endpoint), - properties.getRegion(), - Boolean.parseBoolean(properties.getUsePathStyle()), - properties.getAwsCredentialsProvider())) { - client.headBucket(b -> b.bucket(bucket)); - } - } - - @Override - public String getErrorHint() { - return "Please check S3 credentials (access_key and secret_key or IAM role), " - + "region, and bucket (warehouse location) access permissions"; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/CatalogConnectivityTestCoordinator.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/CatalogConnectivityTestCoordinator.java deleted file mode 100644 index cf8c308849a936..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/CatalogConnectivityTestCoordinator.java +++ /dev/null @@ -1,334 +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.datasource.connectivity; - -import org.apache.doris.common.DdlException; -import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.property.metastore.HiveGlueMetaStoreProperties; -import org.apache.doris.datasource.property.metastore.HiveHMSProperties; -import org.apache.doris.datasource.property.metastore.IcebergGlueMetaStoreProperties; -import org.apache.doris.datasource.property.metastore.IcebergHMSMetaStoreProperties; -import org.apache.doris.datasource.property.metastore.IcebergRestProperties; -import org.apache.doris.datasource.property.metastore.IcebergS3TablesMetaStoreProperties; -import org.apache.doris.datasource.property.metastore.MetastoreProperties; -import org.apache.doris.datasource.property.storage.HdfsProperties; -import org.apache.doris.datasource.property.storage.MinioProperties; -import org.apache.doris.datasource.property.storage.S3Properties; -import org.apache.doris.datasource.property.storage.StorageProperties; - -import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.Map; - -/** - * Coordinator for catalog connectivity testing. - * This class orchestrates the testing of metadata services and storage systems - * when creating external catalogs with test_connection=true. - */ -public class CatalogConnectivityTestCoordinator { - private static final Logger LOG = LogManager.getLogger(CatalogConnectivityTestCoordinator.class); - - private final String catalogName; - private final MetastoreProperties metastoreProperties; - private final Map storagePropertiesMap; - - private String warehouseLocation; - - public CatalogConnectivityTestCoordinator( - String catalogName, - MetastoreProperties metastoreProperties, - Map storagePropertiesMap) { - this.catalogName = catalogName; - this.metastoreProperties = metastoreProperties; - this.storagePropertiesMap = storagePropertiesMap; - } - - /** - * Run all connectivity tests for the catalog. - * - * @throws DdlException if any test fails - */ - public void runTests() throws DdlException { - // 1. Test metadata service - testMetadataService(); - - // 2. Test object storage for warehouse (if applicable) - StorageProperties testObjectStorageProperties = getTestObjectStorageProperties(); - if (testObjectStorageProperties != null) { - testObjectStorageForWarehouse(testObjectStorageProperties); - } - - // 3. Test explicitly configured HDFS (if applicable) - if (shouldTestHdfs()) { - testExplicitlyConfiguredHdfs(); - } - } - - /** - * Test metadata service connectivity (HMS, Glue, REST). - * Also stores the warehouse location to class variable for later use. - * - * @throws DdlException if test fails - */ - private void testMetadataService() throws DdlException { - MetaConnectivityTester metaTester = createMetaTester(metastoreProperties); - - LOG.info("Testing {} connectivity for catalog '{}'", metaTester.getTestType(), catalogName); - - try { - metaTester.testConnection(); - } catch (Exception e) { - String hint = metaTester.getErrorHint(); - String errorMsg = metaTester.getTestType() + " connectivity test failed: " + hint - + " Root cause: " + Util.getRootCauseMessage(e); - throw new DdlException(errorMsg); - } - - // Store warehouse location for later use - this.warehouseLocation = metaTester.getTestLocation(); - if (StringUtils.isNotBlank(this.warehouseLocation)) { - LOG.debug("Got warehouse location from metadata service: {}", this.warehouseLocation); - } - } - - /** - * Check if object storage test should be performed. - * Also caches the matched storage for later use in testObjectStorageForWarehouse(). - */ - private StorageProperties getTestObjectStorageProperties() { - if (StringUtils.isBlank(this.warehouseLocation)) { - LOG.debug("Skip object storage test: no warehouse location from metadata service for catalog '{}'", - catalogName); - return null; - } - - StorageProperties matchedObjectStorage = findMatchingObjectStorage(this.warehouseLocation); - if (matchedObjectStorage == null) { - LOG.debug("Skip object storage test: no storage configured for warehouse '{}' in catalog '{}'", - this.warehouseLocation, catalogName); - return null; - } - - return matchedObjectStorage; - } - - /** - * Test object storage that matches the warehouse location from metadata service. - * Uses the cached matchedObjectStorage from shouldTestObjectStorage(). - * - * @throws DdlException if test fails - */ - private void testObjectStorageForWarehouse(StorageProperties testObjectStorageProperties) throws DdlException { - LOG.info("Testing {} connectivity for warehouse '{}' in catalog '{}'", - testObjectStorageProperties.getStorageName(), this.warehouseLocation, catalogName); - - StorageConnectivityTester tester = createStorageTester(testObjectStorageProperties, this.warehouseLocation); - - // Test FE connection - try { - tester.testFeConnection(); - } catch (Exception e) { - String hint = tester.getErrorHint(); - String errorMsg = tester.getTestType() + " connectivity test failed: " + hint - + " Root cause: " + Util.getRootCauseMessage(e); - throw new DdlException(errorMsg); - } - - // Test BE connection - try { - tester.testBeConnection(); - } catch (Exception e) { - String hint = tester.getErrorHint(); - String errorMsg = tester.getTestType() + " connectivity test failed (compute node): " + hint - + " Root cause: " + Util.getRootCauseMessage(e); - throw new DdlException(errorMsg); - } - } - - /** - * Find object storage that can handle the given warehouse location. - * - * @param warehouse warehouse location - * @return matching storage properties, or null if not found - */ - private StorageProperties findMatchingObjectStorage(String warehouse) { - // Check S3/Minio - if (warehouse.startsWith("s3://") || warehouse.startsWith("s3a://")) { - // Priority: Minio > S3 (if Minio is configured, use it for s3://) - StorageProperties minio = storagePropertiesMap.get(StorageProperties.Type.MINIO); - if (minio != null && isConfiguredStorage(minio)) { - return minio; - } - - StorageProperties s3 = storagePropertiesMap.get(StorageProperties.Type.S3); - if (s3 != null && isConfiguredStorage(s3)) { - return s3; - } - } - return null; - } - - /** - * Check if storage has credentials configured. - * Check for access key, IAM role, or other authentication methods. - */ - private boolean isConfiguredStorage(StorageProperties storage) { - // For S3: check access key or IAM role - if (storage instanceof S3Properties) { - S3Properties s3 = (S3Properties) storage; - return StringUtils.isNotBlank(s3.getAccessKey()) - || StringUtils.isNotBlank(s3.getS3IAMRole()); - } - - // For Minio: check access key - if (storage instanceof MinioProperties) { - MinioProperties minio = (MinioProperties) storage; - return StringUtils.isNotBlank(minio.getAccessKey()); - } - - // For other storage types, assume configured if exists - return true; - } - - /** - * Check if HDFS test should be performed. - */ - private boolean shouldTestHdfs() { - StorageProperties hdfsStorage = storagePropertiesMap.get(StorageProperties.Type.HDFS); - if (!(hdfsStorage instanceof HdfsProperties)) { - return false; - } - - HdfsProperties hdfs = (HdfsProperties) hdfsStorage; - - if (!hdfs.isExplicitlyConfigured()) { - LOG.debug("Skip HDFS test: not explicitly configured by user for catalog '{}'", catalogName); - return false; - } - - if (StringUtils.isBlank(hdfs.getDefaultFS())) { - LOG.debug("Skip HDFS test: fs.defaultFS not configured for catalog '{}'", catalogName); - return false; - } - - return true; - } - - /** - * Test explicitly configured HDFS. - * - * @throws DdlException if test fails - */ - private void testExplicitlyConfiguredHdfs() throws DdlException { - HdfsProperties hdfs = (HdfsProperties) storagePropertiesMap.get(StorageProperties.Type.HDFS); - String defaultFS = hdfs.getDefaultFS(); - - LOG.info("Testing HDFS connectivity for '{}' in catalog '{}'", defaultFS, catalogName); - - StorageConnectivityTester tester = createStorageTester(hdfs, defaultFS); - - // Test FE connection - try { - tester.testFeConnection(); - } catch (Exception e) { - String hint = tester.getErrorHint(); - String errorMsg = "HDFS connectivity test failed: " + hint - + " Root cause: " + Util.getRootCauseMessage(e); - throw new DdlException(errorMsg); - } - - // Test BE connection - try { - tester.testBeConnection(); - } catch (Exception e) { - String hint = tester.getErrorHint(); - String errorMsg = "HDFS connectivity test failed (compute node): " + hint - + " Root cause: " + Util.getRootCauseMessage(e); - throw new DdlException(errorMsg); - } - } - - /** - * Create metadata connectivity tester based on properties type. - */ - private MetaConnectivityTester createMetaTester(MetastoreProperties props) { - // Hive HMS - if (props instanceof HiveHMSProperties) { - HiveHMSProperties hiveProps = (HiveHMSProperties) props; - return new HiveHMSConnectivityTester(hiveProps, hiveProps.getHmsBaseProperties()); - } - - // Hive Glue - if (props instanceof HiveGlueMetaStoreProperties) { - HiveGlueMetaStoreProperties glueProps = (HiveGlueMetaStoreProperties) props; - return new HiveGlueMetaStoreConnectivityTester(glueProps, glueProps.getBaseProperties()); - } - - // Iceberg HMS - if (props instanceof IcebergHMSMetaStoreProperties) { - IcebergHMSMetaStoreProperties icebergHms = (IcebergHMSMetaStoreProperties) props; - return new IcebergHMSConnectivityTester(icebergHms, icebergHms.getHmsBaseProperties()); - } - - // Iceberg Glue - if (props instanceof IcebergGlueMetaStoreProperties) { - IcebergGlueMetaStoreProperties icebergGlue = (IcebergGlueMetaStoreProperties) props; - return new IcebergGlueMetaStoreConnectivityTester(icebergGlue, icebergGlue.getGlueProperties()); - } - - // Iceberg REST - if (props instanceof IcebergRestProperties) { - return new IcebergRestConnectivityTester((IcebergRestProperties) props); - } - - // Iceberg S3Table - if (props instanceof IcebergS3TablesMetaStoreProperties) { - return new IcebergS3TablesMetaStoreConnectivityTester((IcebergS3TablesMetaStoreProperties) props); - } - - // Default: no-op tester - return new MetaConnectivityTester() { - }; - } - - /** - * Create storage connectivity tester based on properties type and location. - */ - private StorageConnectivityTester createStorageTester(StorageProperties props, String location) { - // S3 - if (props instanceof S3Properties) { - return new S3ConnectivityTester((S3Properties) props, location); - } - - // Minio - if (props instanceof MinioProperties) { - return new MinioConnectivityTester((MinioProperties) props, location); - } - - // HDFS - if (props instanceof HdfsProperties) { - return new HdfsConnectivityTester((HdfsProperties) props); - } - - // Default: no-op tester - return new StorageConnectivityTester() { - }; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/HMSBaseConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/HMSBaseConnectivityTester.java deleted file mode 100644 index a67900eb00d9c2..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/HMSBaseConnectivityTester.java +++ /dev/null @@ -1,62 +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.datasource.connectivity; - -import org.apache.doris.datasource.property.metastore.HMSBaseProperties; - -import org.apache.hadoop.hive.conf.HiveConf; -import org.apache.hadoop.hive.metastore.HiveMetaHookLoader; -import org.apache.hadoop.hive.metastore.IMetaStoreClient; -import org.apache.hadoop.hive.metastore.RetryingMetaStoreClient; - -public class HMSBaseConnectivityTester implements MetaConnectivityTester { - private static final HiveMetaHookLoader DUMMY_HOOK_LOADER = t -> null; - private final HMSBaseProperties properties; - - public HMSBaseConnectivityTester(HMSBaseProperties properties) { - this.properties = properties; - } - - @Override - public String getTestType() { - return "HMS"; - } - - @Override - public void testConnection() throws Exception { - HiveConf hiveConf = properties.getHiveConf(); - IMetaStoreClient client = null; - try { - client = properties.getHmsAuthenticator() - .doAs(() -> RetryingMetaStoreClient.getProxy(hiveConf, DUMMY_HOOK_LOADER, - org.apache.hadoop.hive.metastore.HiveMetaStoreClient.class.getName())); - - final IMetaStoreClient finalClient = client; - properties.getHmsAuthenticator() - .doAs(finalClient::getAllDatabases); - } finally { - if (client != null) { - try { - client.close(); - } catch (Exception ignored) { - // ignore - } - } - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/HdfsCompatibleConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/HdfsCompatibleConnectivityTester.java deleted file mode 100644 index cd1be0fa9efb08..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/HdfsCompatibleConnectivityTester.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.datasource.connectivity; - -import org.apache.doris.datasource.property.storage.HdfsCompatibleProperties; -import org.apache.doris.thrift.TStorageBackendType; - -public abstract class HdfsCompatibleConnectivityTester implements StorageConnectivityTester { - protected final HdfsCompatibleProperties properties; - - public HdfsCompatibleConnectivityTester(HdfsCompatibleProperties properties) { - this.properties = properties; - } - - @Override - public TStorageBackendType getStorageType() { - return TStorageBackendType.HDFS; - } - - @Override - public String getTestType() { - return "HDFS"; - } - - @Override - public String getErrorHint() { - return "Please check HDFS namenode connectivity (fs.defaultFS), user permissions, and " - + "Kerberos configuration if applicable"; - } - - @Override - public void testFeConnection() throws Exception { - // TODO: Implement HDFS connectivity test in the future if needed - // Currently, HDFS connectivity test is not required - } - - @Override - public void testBeConnection() throws Exception { - // TODO: Implement HDFS connectivity test in the future if needed - // Currently, HDFS connectivity test is not required - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/HdfsConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/HdfsConnectivityTester.java deleted file mode 100644 index 15668b7fdd8fa6..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/HdfsConnectivityTester.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.datasource.connectivity; - -import org.apache.doris.datasource.property.storage.HdfsCompatibleProperties; - -public class HdfsConnectivityTester extends HdfsCompatibleConnectivityTester { - public HdfsConnectivityTester(HdfsCompatibleProperties properties) { - super(properties); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/HiveGlueMetaStoreConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/HiveGlueMetaStoreConnectivityTester.java deleted file mode 100644 index f6b4002fa16a86..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/HiveGlueMetaStoreConnectivityTester.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.datasource.connectivity; - -import org.apache.doris.datasource.property.metastore.AWSGlueMetaStoreBaseProperties; -import org.apache.doris.datasource.property.metastore.AbstractHiveProperties; - -public class HiveGlueMetaStoreConnectivityTester extends AbstractHiveConnectivityTester { - private final AWSGlueMetaStoreBaseConnectivityTester glueTester; - - public HiveGlueMetaStoreConnectivityTester(AbstractHiveProperties properties, - AWSGlueMetaStoreBaseProperties awsGlueMetaStoreBaseProperties) { - super(properties); - this.glueTester = new AWSGlueMetaStoreBaseConnectivityTester(awsGlueMetaStoreBaseProperties); - } - - @Override - public String getTestType() { - return "Hive Glue"; - } - - @Override - public String getErrorHint() { - return "Please check AWS Glue credentials (access_key and secret_key or IAM role), region, " - + "and endpoint"; - } - - @Override - public void testConnection() throws Exception { - glueTester.testConnection(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/HiveHMSConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/HiveHMSConnectivityTester.java deleted file mode 100644 index 855d18b2f0a3aa..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/HiveHMSConnectivityTester.java +++ /dev/null @@ -1,45 +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.datasource.connectivity; - -import org.apache.doris.datasource.property.metastore.AbstractHiveProperties; -import org.apache.doris.datasource.property.metastore.HMSBaseProperties; - -public class HiveHMSConnectivityTester extends AbstractHiveConnectivityTester { - private final HMSBaseConnectivityTester hmsTester; - - public HiveHMSConnectivityTester(AbstractHiveProperties properties, HMSBaseProperties hmsBaseProperties) { - super(properties); - this.hmsTester = new HMSBaseConnectivityTester(hmsBaseProperties); - } - - @Override - public String getTestType() { - return "Hive HMS"; - } - - @Override - public String getErrorHint() { - return "Please check Hive Metastore Server connectivity (hive metastore uris)"; - } - - @Override - public void testConnection() throws Exception { - hmsTester.testConnection(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/IcebergGlueMetaStoreConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/IcebergGlueMetaStoreConnectivityTester.java deleted file mode 100644 index 068d8aa71b7f59..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/IcebergGlueMetaStoreConnectivityTester.java +++ /dev/null @@ -1,65 +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.datasource.connectivity; - -import org.apache.doris.datasource.property.metastore.AWSGlueMetaStoreBaseProperties; -import org.apache.doris.datasource.property.metastore.AbstractIcebergProperties; - -import org.apache.commons.lang3.StringUtils; - - -public class IcebergGlueMetaStoreConnectivityTester extends AbstractIcebergConnectivityTester { - private final AWSGlueMetaStoreBaseConnectivityTester glueTester; - - public IcebergGlueMetaStoreConnectivityTester(AbstractIcebergProperties properties, - AWSGlueMetaStoreBaseProperties awsGlueMetaStoreBaseProperties) { - super(properties); - this.glueTester = new AWSGlueMetaStoreBaseConnectivityTester(awsGlueMetaStoreBaseProperties); - } - - @Override - public String getTestType() { - return "Iceberg Glue"; - } - - @Override - public String getErrorHint() { - return "Please check AWS Glue credentials (access_key and secret_key or IAM role), " - + "region, warehouse location, and endpoint"; - } - - @Override - public void testConnection() throws Exception { - glueTester.testConnection(); - } - - @Override - public String getTestLocation() { - String warehouse = properties.getWarehouse(); - if (StringUtils.isBlank(warehouse)) { - return null; - } - - String location = validateLocation(warehouse); - if (location == null) { - throw new IllegalArgumentException( - "Iceberg Glue warehouse location must be in S3 format (s3:// or s3a://), but got: " + warehouse); - } - return location; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/IcebergHMSConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/IcebergHMSConnectivityTester.java deleted file mode 100644 index 99fc6a42740df8..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/IcebergHMSConnectivityTester.java +++ /dev/null @@ -1,45 +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.datasource.connectivity; - -import org.apache.doris.datasource.property.metastore.AbstractIcebergProperties; -import org.apache.doris.datasource.property.metastore.HMSBaseProperties; - -public class IcebergHMSConnectivityTester extends AbstractIcebergConnectivityTester { - private final HMSBaseConnectivityTester hmsTester; - - public IcebergHMSConnectivityTester(AbstractIcebergProperties properties, HMSBaseProperties hmsBaseProperties) { - super(properties); - this.hmsTester = new HMSBaseConnectivityTester(hmsBaseProperties); - } - - @Override - public String getTestType() { - return "Iceberg HMS"; - } - - @Override - public String getErrorHint() { - return "Please check Hive Metastore Server connectivity (hive metastore uris)"; - } - - @Override - public void testConnection() throws Exception { - hmsTester.testConnection(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/IcebergRestConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/IcebergRestConnectivityTester.java deleted file mode 100644 index def265fea8288d..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/IcebergRestConnectivityTester.java +++ /dev/null @@ -1,80 +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.datasource.connectivity; - -import org.apache.doris.datasource.property.metastore.AbstractIcebergProperties; -import org.apache.doris.datasource.property.metastore.IcebergRestProperties; - -import org.apache.iceberg.CatalogProperties; -import org.apache.iceberg.rest.RESTCatalog; - -import java.util.Map; - -public class IcebergRestConnectivityTester extends AbstractIcebergConnectivityTester { - // For Polaris REST catalog compatibility - private static final String DEFAULT_BASE_LOCATION = "default-base-location"; - - private String warehouseLocation; - - public IcebergRestConnectivityTester(AbstractIcebergProperties properties) { - super(properties); - } - - @Override - public String getTestType() { - return "Iceberg REST"; - } - - @Override - public String getErrorHint() { - return "Please check Iceberg REST Catalog URI, authentication credentials (OAuth2 or SigV4), " - + "warehouse (location, catalog name, or S3 Tables ARN), and endpoint connectivity"; - } - - @Override - public void testConnection() throws Exception { - Map restProps = ((IcebergRestProperties) properties).getIcebergRestCatalogProperties(); - - try (RESTCatalog catalog = new RESTCatalog()) { - catalog.initialize("connectivity-test", restProps); - - // Validate connection by listing namespaces. - // This verifies authentication and warehouse configuration. - catalog.listNamespaces(); - - Map mergedProps = catalog.properties(); - String location = mergedProps.get(CatalogProperties.WAREHOUSE_LOCATION); - this.warehouseLocation = validateLocation(location); - if (this.warehouseLocation == null) { - location = mergedProps.get(DEFAULT_BASE_LOCATION); - this.warehouseLocation = validateLocation(location); - } - } - } - - @Override - public String getTestLocation() { - // First try to use configured warehouse - String location = validateLocation(properties.getWarehouse()); - if (location != null) { - return location; - } - // If configured warehouse is not valid, fallback to REST API warehouse (already validated) - return this.warehouseLocation; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/IcebergS3TablesMetaStoreConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/IcebergS3TablesMetaStoreConnectivityTester.java deleted file mode 100644 index ad587aaf77ab2e..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/IcebergS3TablesMetaStoreConnectivityTester.java +++ /dev/null @@ -1,38 +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.datasource.connectivity; - -import org.apache.doris.datasource.property.metastore.AbstractIcebergProperties; - -public class IcebergS3TablesMetaStoreConnectivityTester extends AbstractIcebergConnectivityTester { - protected IcebergS3TablesMetaStoreConnectivityTester( - AbstractIcebergProperties properties) { - super(properties); - } - - @Override - public String getErrorHint() { - return "Please check S3 credentials (access_key and secret_key or IAM role), endpoint, region, " - + "and warehouse location"; - } - - @Override - public void testConnection() throws Exception { - // TODO: Implement Iceberg S3 Tables connectivity test in the future if needed - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/MetaConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/MetaConnectivityTester.java deleted file mode 100644 index 326f6c5b20f8f0..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/MetaConnectivityTester.java +++ /dev/null @@ -1,45 +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.datasource.connectivity; - -public interface MetaConnectivityTester { - - default void testConnection() throws Exception { - // Default: test passes (no-op) - } - - default String getTestLocation() { - return null; - } - - /** - * Returns the type of meta connectivity test for error messages. - * Default implementation returns "Meta" for generic meta connectivity test. - */ - default String getTestType() { - return "Meta"; - } - - /** - * Returns error hint for this connectivity test. - * Subclasses can override to provide specific hints for troubleshooting. - */ - default String getErrorHint() { - return ""; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/MinioConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/MinioConnectivityTester.java deleted file mode 100644 index 32ce99714e052f..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/MinioConnectivityTester.java +++ /dev/null @@ -1,38 +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.datasource.connectivity; - -import org.apache.doris.datasource.property.storage.MinioProperties; - -public class MinioConnectivityTester extends AbstractS3CompatibleConnectivityTester { - - public MinioConnectivityTester(MinioProperties properties, String testLocation) { - super(properties, testLocation); - } - - @Override - public String getTestType() { - return "Minio"; - } - - @Override - public String getErrorHint() { - return "Please check Minio credentials (access_key and secret_key), " - + "endpoint, and bucket (warehouse location) access permissions"; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/S3ConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/S3ConnectivityTester.java deleted file mode 100644 index cc869d05e04d8b..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/S3ConnectivityTester.java +++ /dev/null @@ -1,32 +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.datasource.connectivity; - -import org.apache.doris.datasource.property.storage.S3Properties; - -public class S3ConnectivityTester extends AbstractS3CompatibleConnectivityTester { - - public S3ConnectivityTester(S3Properties properties, String testLocation) { - super(properties, testLocation); - } - - @Override - public String getTestType() { - return "S3"; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/StorageConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/StorageConnectivityTester.java deleted file mode 100644 index a3248691fc6e1e..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/StorageConnectivityTester.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.datasource.connectivity; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.ClientPool; -import org.apache.doris.system.Backend; -import org.apache.doris.thrift.BackendService; -import org.apache.doris.thrift.TNetworkAddress; -import org.apache.doris.thrift.TStatusCode; -import org.apache.doris.thrift.TStorageBackendType; -import org.apache.doris.thrift.TTestStorageConnectivityRequest; -import org.apache.doris.thrift.TTestStorageConnectivityResponse; - -import java.util.Collections; -import java.util.List; -import java.util.Map; - -public interface StorageConnectivityTester { - - default void testFeConnection() throws Exception { - // Default: test passes (no-op) - } - - default TStorageBackendType getStorageType() { - return null; - } - - default Map getBackendProperties() { - return Collections.emptyMap(); - } - - /** - * Returns the type of storage connectivity test for error messages. - * Default implementation returns "Storage" for generic storage connectivity test. - */ - default String getTestType() { - return "Storage"; - } - - /** - * Returns error hint for this connectivity test. - * Subclasses can override to provide specific hints for troubleshooting. - */ - default String getErrorHint() { - return ""; - } - - default void testBeConnection() throws Exception { - List aliveBeIds = Env.getCurrentSystemInfo().getAllBackendIds(true); - if (aliveBeIds.isEmpty()) { - // no alive BE, skip the test - return; - } - - Collections.shuffle(aliveBeIds); - testBeConnectionInternal(aliveBeIds.get(0)); - } - - default void testBeConnectionInternal(long backendId) throws Exception { - Backend backend = Env.getCurrentSystemInfo().getBackend(backendId); - if (backend == null) { - // backend not found, skip the test - return; - } - - TTestStorageConnectivityRequest request = new TTestStorageConnectivityRequest(); - request.setType(getStorageType()); - request.setProperties(getBackendProperties()); - - TNetworkAddress address = new TNetworkAddress(backend.getHost(), backend.getBePort()); - BackendService.Client client = null; - boolean ok = false; - try { - client = ClientPool.backendPool.borrowObject(address); - TTestStorageConnectivityResponse response = client.testStorageConnectivity(request); - - if (response.status.getStatusCode() != TStatusCode.OK) { - String errMsg = response.status.isSetErrorMsgs() && !response.status.getErrorMsgs().isEmpty() - ? response.status.getErrorMsgs().get(0) : "Unknown error"; - throw new Exception(errMsg); - } - ok = true; - } finally { - if (ok) { - ClientPool.backendPool.returnObject(address, client); - } else { - ClientPool.backendPool.invalidateObject(address, client); - } - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ConnectorBranchTagConverter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ConnectorBranchTagConverter.java new file mode 100644 index 00000000000000..aac24d92689563 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ConnectorBranchTagConverter.java @@ -0,0 +1,75 @@ +// 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.connector.converter; + +import org.apache.doris.catalog.info.BranchOptions; +import org.apache.doris.catalog.info.CreateOrReplaceBranchInfo; +import org.apache.doris.catalog.info.CreateOrReplaceTagInfo; +import org.apache.doris.catalog.info.DropBranchInfo; +import org.apache.doris.catalog.info.DropTagInfo; +import org.apache.doris.catalog.info.TagOptions; +import org.apache.doris.connector.api.ddl.BranchChange; +import org.apache.doris.connector.api.ddl.DropRefChange; +import org.apache.doris.connector.api.ddl.TagChange; + +/** + * Converts the fe-core/nereids branch/tag info types ({@link CreateOrReplaceBranchInfo} etc.) to the neutral + * connector SPI carriers ({@link BranchChange}/{@link TagChange}/{@link DropRefChange}). + * + *

    Lives in fe-core because it bridges the fe-catalog info types and the SPI DTOs. The mapping is a plain + * field copy: the {@code Optional<>} options become nullable carrier fields ({@code null} = unset), matching how + * the connector treats an unset retention knob (left untouched).

    + */ +public final class ConnectorBranchTagConverter { + + private ConnectorBranchTagConverter() { + } + + /** Maps {@code BranchOptions.retain/numSnapshots/retention} to the snapshot-management knobs they drive. */ + public static BranchChange toBranchChange(CreateOrReplaceBranchInfo info) { + BranchOptions options = info.getBranchOptions(); + return new BranchChange( + info.getBranchName(), + info.getCreate(), + info.getReplace(), + info.getIfNotExists(), + options.getSnapshotId().orElse(null), + options.getRetain().orElse(null), + options.getNumSnapshots().orElse(null), + options.getRetention().orElse(null)); + } + + public static TagChange toTagChange(CreateOrReplaceTagInfo info) { + TagOptions options = info.getTagOptions(); + return new TagChange( + info.getTagName(), + info.getCreate(), + info.getReplace(), + info.getIfNotExists(), + options.getSnapshotId().orElse(null), + options.getRetain().orElse(null)); + } + + public static DropRefChange toDropRefChange(DropBranchInfo info) { + return new DropRefChange(info.getBranchName(), info.getIfExists()); + } + + public static DropRefChange toDropRefChange(DropTagInfo info) { + return new DropRefChange(info.getTagName(), info.getIfExists()); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ConnectorColumnConverter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ConnectorColumnConverter.java new file mode 100644 index 00000000000000..3fa5c8d2dcec00 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ConnectorColumnConverter.java @@ -0,0 +1,317 @@ +// 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.connector.converter; + +import org.apache.doris.catalog.ArrayType; +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.MapType; +import org.apache.doris.catalog.PrimitiveType; +import org.apache.doris.catalog.ScalarType; +import org.apache.doris.catalog.StructField; +import org.apache.doris.catalog.StructType; +import org.apache.doris.catalog.Type; +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorType; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +/** + * Converts between the connector SPI type system ({@link ConnectorColumn}/{@link ConnectorType}) + * and the Doris internal type system ({@link Column}/{@link Type}). + * + *

    This converter lives in fe-core because it depends on both the SPI API types + * (from fe-connector-api) and the internal Doris catalog types (from fe-type/fe-core).

    + */ +public final class ConnectorColumnConverter { + + private static final Logger LOG = LogManager.getLogger(ConnectorColumnConverter.class); + + private ConnectorColumnConverter() { + } + + /** + * Converts a list of {@link ConnectorColumn} to a list of Doris {@link Column}. + */ + public static List convertColumns(List connectorColumns) { + return connectorColumns.stream() + .map(ConnectorColumnConverter::convertColumn) + .collect(Collectors.toList()); + } + + /** + * Converts a single {@link ConnectorColumn} to a Doris {@link Column}. + */ + public static Column convertColumn(ConnectorColumn cc) { + Type dorisType = convertType(cc.getType()); + Column column = new Column(cc.getName(), dorisType, cc.isKey(), null, + cc.isNullable(), cc.getDefaultValue(), + cc.getComment() != null ? cc.getComment() : ""); + // Re-apply the WITH_TIMEZONE "Extra" marker the connector carried across the SPI boundary + // (ConnectorColumn.withTimeZone()), matching legacy PaimonExternalTable/IcebergUtils which set it + // via setWithTZExtraInfo() from the source TZ type. Independent of the mapped Doris type, so it is + // shown even when the column was mapped to a plain DATETIME (timestamp_tz mapping off). + if (cc.isWithTimeZone()) { + column.setWithTZExtraInfo(); + } + // Re-apply the hidden marker the connector carried across the SPI boundary + // (ConnectorColumn.invisible()), so synthetic write columns a connector declares through the schema + // SPI (iceberg __DORIS_ICEBERG_ROWID_COL__ / v3 row-lineage) stay hidden, matching legacy + // Column.setIsVisible(false). A Doris Column defaults to visible, so only the false case is re-applied. + if (!cc.isVisible()) { + column.setIsVisible(false); + } + // Re-apply the reserved field id the connector carried across the SPI boundary + // (ConnectorColumn.withUniqueId()), so synthetic write columns whose Doris identity must equal a + // connector-reserved field id keep it (iceberg v3 row-lineage _row_id=2147483540 / + // _last_updated_sequence_number=2147483539, matched by field id BE-side). A Doris Column defaults to + // an unset (-1) uniqueId, so only a set (>= 0) id is re-applied. + if (cc.getUniqueId() >= 0) { + column.setUniqueId(cc.getUniqueId()); + } + // Re-apply the connector-reserved passthrough marker the connector carried across the SPI boundary + // (ConnectorColumn.reservedPassthrough()), so engine consumers (MERGE/UPDATE, sink binding) can + // recognize a synthetic passthrough column (iceberg v3 row-lineage) generically via + // Column.isReservedPassthrough() instead of string-matching the connector's column names. A Doris + // Column defaults to false, so only the true case is re-applied. + if (cc.isReservedPassthrough()) { + column.setReservedPassthrough(true); + } + // Stamp the nested (STRUCT/ARRAY/MAP) child column tree with the per-field ids the connector carried + // on the ConnectorType (iceberg), mirroring legacy IcebergUtils.updateIcebergColumnUniqueId's + // recursive set. The BE field-id scan path matches a pruned nested leaf by id; a -1 leaf is skipped + // and returns NULL. Inert for connectors that don't carry field ids (getChildFieldId returns -1). + applyNestedFieldIds(column, cc.getType()); + return column; + } + + /** + * Recursively stamps {@code column}'s child tree (STRUCT fields / ARRAY element / MAP key+value) with the + * per-child field ids carried on {@code type} ({@link ConnectorType#getChildFieldId(int)}). The Doris + * child column order built by {@code Column.createChildrenColumn} matches the {@link ConnectorType} + * children order (array element / map key,value / struct fields-in-order), so a parallel walk aligns them. + * Only sets a child whose carried id is {@code >= 0}, leaving others at the default -1. + */ + private static void applyNestedFieldIds(Column column, ConnectorType type) { + List childColumns = column.getChildren(); + if (childColumns == null || childColumns.isEmpty()) { + return; + } + List childTypes = type.getChildren(); + int n = Math.min(childColumns.size(), childTypes.size()); + for (int i = 0; i < n; i++) { + Column childColumn = childColumns.get(i); + int childFieldId = type.getChildFieldId(i); + if (childFieldId >= 0) { + childColumn.setUniqueId(childFieldId); + } + applyNestedFieldIds(childColumn, childTypes.get(i)); + } + } + + /** + * Converts a list of Doris {@link Column} to a list of {@link ConnectorColumn}. + */ + public static List toConnectorColumns(List columns) { + return columns.stream() + .map(ConnectorColumnConverter::toConnectorColumn) + .collect(Collectors.toList()); + } + + /** + * Converts a Doris {@link Column} to a {@link ConnectorColumn}. + * This is the inverse of {@link #convertColumn(ConnectorColumn)}. + * + *

    The {@code isKey}/{@code isAutoInc}/{@code isAggregated} flags are carried so a connector can + * re-enforce its column-validation parity (e.g. iceberg rejects aggregated / auto-increment columns + * in {@code ALTER TABLE ADD/MODIFY COLUMN}); without them those flags would default to {@code false} + * and the connector could not tell an aggregated/auto-inc column apart from a plain one.

    + */ + public static ConnectorColumn toConnectorColumn(Column col) { + ConnectorType connectorType = toConnectorType(col.getType()); + return new ConnectorColumn( + col.getName(), + connectorType, + col.getComment(), + col.isAllowNull(), + col.getDefaultValue(), + col.isKey(), + col.isAutoInc(), + col.isAggregated()); + } + + /** + * Converts a Doris {@link Type} to a {@link ConnectorType}, handling + * complex types (ARRAY, MAP, STRUCT) recursively. + * This is the inverse of {@link #convertType(ConnectorType)}. + */ + public static ConnectorType toConnectorType(Type dorisType) { + if (dorisType instanceof ArrayType) { + ArrayType arr = (ArrayType) dorisType; + // Carry the element's nullability so a connector can preserve a NOT NULL ARRAY element + // (e.g. iceberg CREATE TABLE / complex MODIFY COLUMN); legacy lost it (defaulted optional). + return ConnectorType.arrayOf(toConnectorType(arr.getItemType()), arr.getContainsNull()); + } else if (dorisType instanceof MapType) { + MapType map = (MapType) dorisType; + // Map keys are always required; only the value nullability is carried. + return ConnectorType.mapOf( + toConnectorType(map.getKeyType()), + toConnectorType(map.getValueType()), + map.getIsValueContainsNull()); + } else if (dorisType instanceof StructType) { + StructType struct = (StructType) dorisType; + List names = new ArrayList<>(); + List types = new ArrayList<>(); + List nullables = new ArrayList<>(); + List comments = new ArrayList<>(); + // Carry each field's nullability + comment so a connector can preserve a NOT NULL / commented + // STRUCT field and diff a complex MODIFY COLUMN field-by-field; legacy carried neither. + for (StructField f : struct.getFields()) { + names.add(f.getName()); + types.add(toConnectorType(f.getType())); + nullables.add(f.getContainsNull()); + comments.add(f.getComment()); + } + return ConnectorType.structOf(names, types, nullables, comments); + } else if (dorisType instanceof ScalarType) { + ScalarType scalar = (ScalarType) dorisType; + PrimitiveType primitiveType = scalar.getPrimitiveType(); + // CHAR/VARCHAR store their length in `len`, not `precision`; encode it + // into the ConnectorType precision field (matching convertScalarType and + // the connector type convention) so CREATE TABLE requests keep the length. + if (primitiveType == PrimitiveType.CHAR + || primitiveType == PrimitiveType.VARCHAR) { + return ConnectorType.of(primitiveType.toString(), + scalar.getLength(), 0); + } + return ConnectorType.of( + primitiveType.toString(), + scalar.getScalarPrecision(), + scalar.getScalarScale()); + } else { + return ConnectorType.of(dorisType.toString(), -1, -1); + } + } + + /** + * Converts a {@link ConnectorType} to a Doris {@link Type}, handling + * complex types (ARRAY, MAP, STRUCT) recursively. + */ + public static Type convertType(ConnectorType ct) { + String typeName = ct.getTypeName().toUpperCase(Locale.ROOT); + switch (typeName) { + case "ARRAY": + return convertArrayType(ct); + case "MAP": + return convertMapType(ct); + case "STRUCT": + return convertStructType(ct); + default: + return convertScalarType(typeName, ct.getPrecision(), ct.getScale()); + } + } + + private static Type convertArrayType(ConnectorType ct) { + List children = ct.getChildren(); + if (children.isEmpty()) { + return ArrayType.create(Type.NULL, true); + } + return ArrayType.create(convertType(children.get(0)), true); + } + + private static Type convertMapType(ConnectorType ct) { + List children = ct.getChildren(); + if (children.size() < 2) { + return new MapType(Type.NULL, Type.NULL); + } + return new MapType(convertType(children.get(0)), convertType(children.get(1))); + } + + 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; + fields.add(new StructField(fieldName, convertType(children.get(i)))); + } + return new StructType(fields); + } + + private static Type convertScalarType(String typeName, int precision, int scale) { + switch (typeName) { + case "CHAR": + if (precision > 0) { + return ScalarType.createCharType(precision); + } + return ScalarType.CHAR; + case "VARCHAR": + if (precision > 0) { + return ScalarType.createVarcharType(precision); + } + return ScalarType.createVarcharType(); + case "DECIMAL": + case "DECIMALV2": + if (precision > 0) { + return ScalarType.createDecimalType(precision, Math.max(scale, 0)); + } + return ScalarType.createDecimalType(); + case "DECIMALV3": + case "DECIMAL32": + case "DECIMAL64": + case "DECIMAL128": + case "DECIMAL256": + if (precision > 0) { + return ScalarType.createDecimalV3Type(precision, Math.max(scale, 0)); + } + return ScalarType.createDecimalV3Type(); + case "DATETIMEV2": + // Connectors encode datetime scale in the precision field of ConnectorType. + if (precision >= 0) { + return ScalarType.createDatetimeV2Type(precision); + } + return ScalarType.DATETIMEV2; + case "TIMESTAMPTZ": + if (precision >= 0) { + return ScalarType.createTimeStampTzType(precision); + } + return ScalarType.createTimeStampTzType(0); + case "VARBINARY": + if (precision > 0) { + return ScalarType.createVarbinaryType(precision); + } + return ScalarType.createVarbinaryType(ScalarType.MAX_VARBINARY_LENGTH); + case "JSONB": + return ScalarType.createType("JSON"); + case "UNSUPPORTED": + return Type.UNSUPPORTED; + default: + try { + return ScalarType.createType(typeName); + } catch (Exception e) { + LOG.warn("Unrecognized connector type '{}', marking as UNSUPPORTED", typeName); + return Type.UNSUPPORTED; + } + } + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ConnectorExpressionToNereidsConverter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ConnectorExpressionToNereidsConverter.java new file mode 100644 index 00000000000000..d509463f895ca4 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ConnectorExpressionToNereidsConverter.java @@ -0,0 +1,163 @@ +// 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.connector.converter; + +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.nereids.exceptions.AnalysisException; +import org.apache.doris.nereids.trees.expressions.And; +import org.apache.doris.nereids.trees.expressions.EqualTo; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.expressions.GreaterThan; +import org.apache.doris.nereids.trees.expressions.GreaterThanEqual; +import org.apache.doris.nereids.trees.expressions.LessThan; +import org.apache.doris.nereids.trees.expressions.LessThanEqual; +import org.apache.doris.nereids.trees.expressions.SlotReference; +import org.apache.doris.nereids.trees.expressions.literal.StringLiteral; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Converts a connector-neutral {@link ConnectorExpression} residual predicate (as returned by + * {@code ConnectorMetadata.getSyntheticScanPredicates}) BACK into a bound Nereids {@link Expression}. It is the + * reverse of the forward converters {@link NereidsToConnectorExpressionConverter} / + * {@link ExprToConnectorExpressionConverter} (same package) and is connector-agnostic: it only maps + * {@code ConnectorExpression} node types to Nereids nodes — the column names and literal values come entirely + * from the connector's payload, so there is ZERO source-specific logic here. + * + *

    The analysis-time rule that injects a connector's synthetic scan predicate (e.g. hudi's incremental + * {@code _hoodie_commit_time} window) calls this to turn each returned {@code ConnectorExpression} into a + * {@code LogicalFilter} conjunct, binding {@link ConnectorColumnRef}s to the scan-output {@link SlotReference}s + * by name.

    + * + *

    TOTAL and FAIL-LOUD — the deliberate INVERSE of the forward converter's drop-on-unknown contract. + * The forward converter returns {@code null} for unrepresentable nodes because dropping a conjunct only WIDENS + * a write-conflict filter (safe). Here the opposite holds: dropping a conjunct from a residual SCAN predicate + * UNDER-filters and leaks rows the connector meant to exclude (a correctness bug). So this throws on any node + * outside the supported grammar, on any column reference that does not resolve to a scan-output slot, and on + * any non-string literal — never silently returning null.

    + * + *

    Supported grammar (what a connector residual predicate uses today): {@link ConnectorAnd}, + * {@link ConnectorComparison} with an order/equality operator ({@code EQ/LT/LE/GT/GE}), {@link ConnectorColumnRef} + * bound by name, and STRING {@link ConnectorLiteral}s. Extend the dispatch (and add a + * {@code ConnectorType -> Nereids DataType} mapper for typed literals) when a connector needs a wider shape.

    + */ +public final class ConnectorExpressionToNereidsConverter { + + private ConnectorExpressionToNereidsConverter() { + } + + /** + * Converts {@code expr} into a bound Nereids {@link Expression}, resolving {@link ConnectorColumnRef}s + * against {@code boundSlots} (scan-output slots keyed by column name). + * + * @throws AnalysisException on an unsupported node, an unresolvable column reference, or a non-string literal + */ + public static Expression convert(ConnectorExpression expr, Map boundSlots) { + if (expr instanceof ConnectorAnd) { + return convertAnd((ConnectorAnd) expr, boundSlots); + } + if (expr instanceof ConnectorComparison) { + return convertComparison((ConnectorComparison) expr, boundSlots); + } + throw new AnalysisException( + "cannot reverse-convert connector residual predicate node: " + describe(expr)); + } + + private static Expression convertAnd(ConnectorAnd and, Map boundSlots) { + List conjuncts = and.getConjuncts(); + if (conjuncts.isEmpty()) { + throw new AnalysisException("cannot reverse-convert an empty ConnectorAnd"); + } + List converted = new ArrayList<>(conjuncts.size()); + for (ConnectorExpression conjunct : conjuncts) { + converted.add(convert(conjunct, boundSlots)); + } + // Nereids And(List) requires >= 2 children; a single conjunct is returned bare (mirrors the forward + // convertAnd's single-conjunct unwrap). + return converted.size() == 1 ? converted.get(0) : new And(converted); + } + + private static Expression convertComparison(ConnectorComparison cmp, Map boundSlots) { + Expression left = convertLeaf(cmp.getLeft(), boundSlots); + Expression right = convertLeaf(cmp.getRight(), boundSlots); + switch (cmp.getOperator()) { + case EQ: + return new EqualTo(left, right); + case LT: + return new LessThan(left, right); + case LE: + return new LessThanEqual(left, right); + case GT: + return new GreaterThan(left, right); + case GE: + return new GreaterThanEqual(left, right); + default: + // NE / EQ_FOR_NULL have no single-node Nereids inverse and no residual predicate emits them + // today. Fail loud rather than approximate (an approximation could change filtered rows). + throw new AnalysisException( + "unsupported comparison operator in connector residual predicate: " + cmp.getOperator()); + } + } + + private static Expression convertLeaf(ConnectorExpression operand, Map boundSlots) { + if (operand instanceof ConnectorColumnRef) { + String columnName = ((ConnectorColumnRef) operand).getColumnName(); + SlotReference slot = boundSlots.get(columnName); + if (slot == null) { + // Fail loud: silently dropping a predicate that references a column the scan does not output would + // UNDER-filter and leak rows the connector meant to exclude. Post visible-meta-column exposure this + // is unreachable for a correct hudi table; if it fires it surfaces a real binding bug, not wrong + // results. + throw new AnalysisException("connector residual predicate references column '" + columnName + + "' which is not in the scan output"); + } + return slot; + } + if (operand instanceof ConnectorLiteral) { + return convertLiteral((ConnectorLiteral) operand); + } + throw new AnalysisException( + "cannot reverse-convert connector comparison operand: " + describe(operand)); + } + + private static Expression convertLiteral(ConnectorLiteral literal) { + // Only STRING literals are supported today (residual predicates compare fixed-width instant strings + // lexicographically). Generalizing to typed literals needs a ConnectorType -> Nereids DataType mapper (the + // forward path only maps DataType -> ConnectorType); until then fail loud on any other type so a numeric + // coercion can never silently change compare semantics. + if (!"STRING".equalsIgnoreCase(literal.getType().getTypeName())) { + throw new AnalysisException("unsupported connector residual-predicate literal type: " + + literal.getType().getTypeName()); + } + Object value = literal.getValue(); + if (value == null) { + throw new AnalysisException("a null connector residual-predicate literal is not supported"); + } + return new StringLiteral(value.toString()); + } + + private static String describe(ConnectorExpression expr) { + return expr == null ? "null" : expr.getClass().getSimpleName(); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ConnectorPartitionFieldConverter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ConnectorPartitionFieldConverter.java new file mode 100644 index 00000000000000..ce1b2e07e656d4 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ConnectorPartitionFieldConverter.java @@ -0,0 +1,58 @@ +// 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.connector.converter; + +import org.apache.doris.connector.api.ddl.PartitionFieldChange; +import org.apache.doris.nereids.trees.plans.commands.info.AddPartitionFieldOp; +import org.apache.doris.nereids.trees.plans.commands.info.DropPartitionFieldOp; +import org.apache.doris.nereids.trees.plans.commands.info.ReplacePartitionFieldOp; + +/** + * Converts the fe-core/nereids partition-field op types ({@link AddPartitionFieldOp} etc.) to the neutral + * connector SPI carrier ({@link PartitionFieldChange}). + * + *

    Lives in fe-core because it bridges the nereids ALTER op types and the SPI DTO. The mapping is a plain + * field copy: add/drop populate only the "new/primary" field; replace also fills the {@code old*} side. The + * iceberg transform interpretation ({@code Term}/{@code UpdatePartitionSpec}) lives entirely in the connector.

    + */ +public final class ConnectorPartitionFieldConverter { + + private ConnectorPartitionFieldConverter() { + } + + public static PartitionFieldChange toAddChange(AddPartitionFieldOp op) { + return new PartitionFieldChange( + op.getTransformName(), op.getTransformArg(), op.getColumnName(), op.getPartitionFieldName(), + null, null, null, null); + } + + public static PartitionFieldChange toDropChange(DropPartitionFieldOp op) { + return new PartitionFieldChange( + op.getTransformName(), op.getTransformArg(), op.getColumnName(), op.getPartitionFieldName(), + null, null, null, null); + } + + /** Maps the op's {@code new*} side to the primary field and its {@code old*} side to the old field. */ + public static PartitionFieldChange toReplaceChange(ReplacePartitionFieldOp op) { + return new PartitionFieldChange( + op.getNewTransformName(), op.getNewTransformArg(), op.getNewColumnName(), + op.getNewPartitionFieldName(), + op.getOldPartitionFieldName(), op.getOldTransformName(), op.getOldTransformArg(), + op.getOldColumnName()); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExprToConnectorExpressionConverter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ExprToConnectorExpressionConverter.java similarity index 99% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/ExprToConnectorExpressionConverter.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ExprToConnectorExpressionConverter.java index 021782e0e53058..365c127f3c18c7 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExprToConnectorExpressionConverter.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ExprToConnectorExpressionConverter.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.connector.converter; import org.apache.doris.analysis.ArithmeticExpr; import org.apache.doris.analysis.BetweenPredicate; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/NereidsToConnectorExpressionConverter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/NereidsToConnectorExpressionConverter.java new file mode 100644 index 00000000000000..f85f046f7500b0 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/NereidsToConnectorExpressionConverter.java @@ -0,0 +1,240 @@ +// 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.connector.converter; + +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorBetween; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorIn; +import org.apache.doris.connector.api.pushdown.ConnectorIsNull; +import org.apache.doris.connector.api.pushdown.ConnectorNot; +import org.apache.doris.connector.api.pushdown.ConnectorOr; +import org.apache.doris.nereids.trees.expressions.And; +import org.apache.doris.nereids.trees.expressions.Between; +import org.apache.doris.nereids.trees.expressions.EqualTo; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.expressions.GreaterThan; +import org.apache.doris.nereids.trees.expressions.GreaterThanEqual; +import org.apache.doris.nereids.trees.expressions.InPredicate; +import org.apache.doris.nereids.trees.expressions.IsNull; +import org.apache.doris.nereids.trees.expressions.LessThan; +import org.apache.doris.nereids.trees.expressions.LessThanEqual; +import org.apache.doris.nereids.trees.expressions.Not; +import org.apache.doris.nereids.trees.expressions.Or; +import org.apache.doris.nereids.trees.expressions.SlotReference; +import org.apache.doris.nereids.trees.expressions.literal.Literal; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts a Nereids {@link Expression} tree into an engine-neutral {@link ConnectorExpression} tree for the + * O5-2 write-time conflict-detection path (P6.3-T07b). It is the Nereids twin of the analyzed-plan-side + * {@link ExprToConnectorExpressionConverter} (same package), produced from the analyzed DELETE/UPDATE/MERGE + * plan rather than a legacy {@code Expr}. + * + *

    Node matrix = the legacy iceberg conflict matrix ({@code + * IcebergNereidsUtils.convertNereidsToIcebergExpression}): {@code And}/{@code Or}/{@code Not}, the five + * comparisons ({@code EqualTo}/{@code GreaterThan}/{@code GreaterThanEqual}/{@code LessThan}/{@code + * LessThanEqual}), {@code InPredicate}, {@code IsNull}, {@code Between}. Comparisons require a bare + * {@link SlotReference} on one side and a {@link Literal} on the other (operands are normalised to + * column-on-left, the operator unchanged — mirroring legacy {@code convertNereidsBinaryPredicate}).

    + * + *

    Anything else yields {@code null} — {@code NullSafeEqual}, {@code Cast}-wrapped columns, + * column-to-column comparisons, bare literals, etc. The legacy conflict path drops exactly these. Dropping a + * conjunct only ever widens the resulting conflict-detection filter (more conservative, never missing + * a real concurrent-write conflict); pushing a form legacy drops would narrow it and risk a missed + * conflict. AND drops unconvertible conjuncts (fewer ANDed predicates = wider); OR is all-or-nothing (a + * dropped disjunct would narrow it). See deviations-log DV-T07b-matrix.

    + * + *

    Literal encoding routes through {@link ExprToConnectorExpressionConverter#convert} on + * {@link Literal#toLegacyLiteral()}, so the neutral {@link org.apache.doris.connector.api.ConnectorType} + * tokens (uppercase {@code INT}/{@code BIGINT}/... + decimal precision/scale) are byte-identical to the scan + * side — the connector's shared {@code IcebergPredicateConverter} type matrix then behaves the same for both + * paths. Column types likewise go through {@link ExprToConnectorExpressionConverter#typeToConnectorType}.

    + */ +public final class NereidsToConnectorExpressionConverter { + + private static final Logger LOG = LogManager.getLogger(NereidsToConnectorExpressionConverter.class); + + private NereidsToConnectorExpressionConverter() { + } + + /** Convert a Nereids predicate to a neutral {@link ConnectorExpression}, or {@code null} if not + * representable in the legacy iceberg conflict matrix (a safe over-approximation). */ + public static ConnectorExpression convert(Expression expr) { + if (expr == null) { + return null; + } + if (expr instanceof And) { + return convertAnd((And) expr); + } else if (expr instanceof Or) { + return convertOr((Or) expr); + } else if (expr instanceof Not) { + ConnectorExpression child = convert(((Not) expr).child()); + return child == null ? null : new ConnectorNot(child); + } else if (expr instanceof EqualTo) { + return convertComparison(expr, ConnectorComparison.Operator.EQ); + } else if (expr instanceof GreaterThan) { + return convertComparison(expr, ConnectorComparison.Operator.GT); + } else if (expr instanceof GreaterThanEqual) { + return convertComparison(expr, ConnectorComparison.Operator.GE); + } else if (expr instanceof LessThan) { + return convertComparison(expr, ConnectorComparison.Operator.LT); + } else if (expr instanceof LessThanEqual) { + return convertComparison(expr, ConnectorComparison.Operator.LE); + } else if (expr instanceof InPredicate) { + return convertIn((InPredicate) expr); + } else if (expr instanceof IsNull) { + return convertIsNull((IsNull) expr); + } else if (expr instanceof Between) { + return convertBetween((Between) expr); + } + // NullSafeEqual / Cast-wrapped column / bare literal / etc.: not in the legacy conflict matrix -> drop. + return null; + } + + private static ConnectorExpression convertAnd(And and) { + List conjuncts = new ArrayList<>(); + flattenAnd(and, conjuncts); + conjuncts.removeIf(c -> c == null); + if (conjuncts.isEmpty()) { + return null; + } + return conjuncts.size() == 1 ? conjuncts.get(0) : new ConnectorAnd(conjuncts); + } + + private static void flattenAnd(Expression expr, List out) { + if (expr instanceof And) { + for (Expression child : expr.children()) { + flattenAnd(child, out); + } + } else { + out.add(convert(expr)); + } + } + + private static ConnectorExpression convertOr(Or or) { + List disjuncts = new ArrayList<>(); + if (!flattenOr(or, disjuncts) || disjuncts.isEmpty()) { + return null; + } + return disjuncts.size() == 1 ? disjuncts.get(0) : new ConnectorOr(disjuncts); + } + + private static boolean flattenOr(Expression expr, List out) { + if (expr instanceof Or) { + for (Expression child : expr.children()) { + if (!flattenOr(child, out)) { + return false; + } + } + return true; + } + ConnectorExpression c = convert(expr); + if (c == null) { + return false; + } + out.add(c); + return true; + } + + private static ConnectorExpression convertComparison(Expression cmp, ConnectorComparison.Operator op) { + Expression left = cmp.child(0); + Expression right = cmp.child(1); + SlotReference slot; + Literal literal; + if (left instanceof SlotReference && right instanceof Literal) { + slot = (SlotReference) left; + literal = (Literal) right; + } else if (left instanceof Literal && right instanceof SlotReference) { + slot = (SlotReference) right; + literal = (Literal) left; + } else { + return null; + } + ConnectorExpression litExpr = convertLiteral(literal); + if (litExpr == null) { + return null; + } + return new ConnectorComparison(op, columnRef(slot), litExpr); + } + + private static ConnectorExpression convertIn(InPredicate in) { + if (!(in.child(0) instanceof SlotReference)) { + return null; + } + List inList = new ArrayList<>(); + for (int i = 1; i < in.children().size(); i++) { + Expression child = in.child(i); + if (!(child instanceof Literal)) { + return null; + } + ConnectorExpression lit = convertLiteral((Literal) child); + if (lit == null) { + return null; + } + inList.add(lit); + } + return new ConnectorIn(columnRef((SlotReference) in.child(0)), inList, false); + } + + private static ConnectorExpression convertIsNull(IsNull isNull) { + if (!(isNull.child() instanceof SlotReference)) { + return null; + } + return new ConnectorIsNull(columnRef((SlotReference) isNull.child()), false); + } + + private static ConnectorExpression convertBetween(Between between) { + Expression compareExpr = between.getCompareExpr(); + Expression lower = between.getLowerBound(); + Expression upper = between.getUpperBound(); + if (!(compareExpr instanceof SlotReference) + || !(lower instanceof Literal) || !(upper instanceof Literal)) { + return null; + } + ConnectorExpression lo = convertLiteral((Literal) lower); + ConnectorExpression hi = convertLiteral((Literal) upper); + if (lo == null || hi == null) { + return null; + } + return new ConnectorBetween(columnRef((SlotReference) compareExpr), lo, hi); + } + + private static ConnectorColumnRef columnRef(SlotReference slot) { + return new ConnectorColumnRef(slot.getName(), + ExprToConnectorExpressionConverter.typeToConnectorType(slot.getDataType().toCatalogDataType())); + } + + // Route literals through the analyzed-plan-side converter (Expr -> ConnectorExpression) so the neutral + // type token + value are byte-identical to the scan path. toLegacyLiteral() is a pure transformation. + private static ConnectorExpression convertLiteral(Literal literal) { + try { + return ExprToConnectorExpressionConverter.convert(literal.toLegacyLiteral()); + } catch (Exception e) { + LOG.debug("cannot convert nereids literal {} to a connector literal: {}", literal, e.getMessage()); + return null; + } + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/UnboundExpressionToConnectorPredicateConverter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/UnboundExpressionToConnectorPredicateConverter.java new file mode 100644 index 00000000000000..ebc9d2c35176bf --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/UnboundExpressionToConnectorPredicateConverter.java @@ -0,0 +1,295 @@ +// 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.connector.converter; + +import org.apache.doris.catalog.Column; +import org.apache.doris.common.AnalysisException; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorBetween; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorIn; +import org.apache.doris.connector.api.pushdown.ConnectorIsNull; +import org.apache.doris.connector.api.pushdown.ConnectorNot; +import org.apache.doris.connector.api.pushdown.ConnectorOr; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.nereids.analyzer.UnboundSlot; +import org.apache.doris.nereids.trees.expressions.And; +import org.apache.doris.nereids.trees.expressions.Between; +import org.apache.doris.nereids.trees.expressions.EqualTo; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.expressions.GreaterThan; +import org.apache.doris.nereids.trees.expressions.GreaterThanEqual; +import org.apache.doris.nereids.trees.expressions.InPredicate; +import org.apache.doris.nereids.trees.expressions.IsNull; +import org.apache.doris.nereids.trees.expressions.LessThan; +import org.apache.doris.nereids.trees.expressions.LessThanEqual; +import org.apache.doris.nereids.trees.expressions.Not; +import org.apache.doris.nereids.trees.expressions.Or; +import org.apache.doris.nereids.trees.expressions.Slot; +import org.apache.doris.nereids.trees.expressions.SlotReference; +import org.apache.doris.nereids.trees.expressions.literal.Literal; + +import java.util.ArrayList; +import java.util.List; + +/** + * Lowers an {@code ALTER TABLE t EXECUTE proc(...) WHERE } predicate into an engine-neutral + * {@link ConnectorPredicate} for a {@code DISTRIBUTED} connector procedure (today: iceberg + * {@code rewrite_data_files}), so the connector can scope the rewrite to the files matching the {@code WHERE}. + * + *

    This is the procedure-side analogue of {@link WriteConstraintExtractor} (the DELETE/MERGE write-time + * conflict path), but with two essential differences driven by the {@code EXECUTE} grammar and the rewrite + * semantics:

    + * + *
      + *
    1. The WHERE arrives UNBOUND. {@code ExecuteActionCommand} never runs the Nereids analyzer over + * its {@code WHERE} (only the table name is analysed), so column references are {@link UnboundSlot}s + * (a bare name from the parser), not bound {@link SlotReference}s. {@link WriteConstraintExtractor} / + * {@link NereidsToConnectorExpressionConverter} require bound slots and would silently drop every + * unbound leaf — yielding an empty predicate and a whole-table rewrite. Here each leaf column name is + * resolved directly against the target table's schema ({@link ExternalTable#getColumn}), mirroring the + * legacy {@code IcebergNereidsUtils.extractColumnName} which also accepted the unbound parser form.
    2. + *
    3. Fail-loud, never widen. For a write-time conflict filter, dropping an unconvertible conjunct + * only widens the filter (safe). For a user-authored rewrite {@code WHERE}, dropping a conjunct + * widens the set of files rewritten — at the limit, dropping the whole {@code WHERE} rewrites the + * entire table. So this converter is strictly all-or-nothing: if any part of the {@code WHERE} cannot be + * represented neutrally it throws (restoring the legacy live-rewrite behaviour, which threw), rather than + * producing a partial/empty predicate. The connector enforces the symmetric invariant on its side + * (a conjunct it cannot push to file pruning is also a hard error, not a silent drop).
    4. + *
    + * + *

    Node matrix mirrors {@link NereidsToConnectorExpressionConverter} (the legacy iceberg WHERE node + * set): {@code And}/{@code Or}/{@code Not}, the five comparisons ({@code EQ}/{@code GT}/{@code GE}/{@code LT}/ + * {@code LE}, column-op-literal), {@code In}, {@code IsNull}, {@code Between}. Literals route through + * {@link ExprToConnectorExpressionConverter} so the neutral type tokens are byte-identical to the scan / + * conflict paths; the connector then maps them to its own dialect. The {@link ConnectorColumnRef} carries the + * column's real type (resolved from the table schema) — accurate rather than a placeholder — even though the + * iceberg connector resolves the column by name and does not read it.

    + * + *

    Engine-neutral by construction (no {@code instanceof Iceberg}, no iceberg imports): it speaks only the + * neutral {@code connector.api.pushdown} vocabulary plus generic Nereids nodes.

    + */ +public final class UnboundExpressionToConnectorPredicateConverter { + + private UnboundExpressionToConnectorPredicateConverter() { + } + + /** + * Lowers the {@code WHERE} predicate to a {@link ConnectorPredicate} over {@code table}'s columns. + * + * @throws AnalysisException if any part of the {@code WHERE} cannot be represented neutrally, or references + * a column not in the table (fail-loud — never returns a partial predicate that would widen the + * rewrite scope). + */ + public static ConnectorPredicate convert(Expression where, ExternalTable table) throws AnalysisException { + ConnectorExpression expr = convertNode(where, table); + if (expr == null) { + throw new AnalysisException("WHERE condition is not supported for this procedure: " + where.toSql()); + } + return new ConnectorPredicate(expr); + } + + // Returns null when the node cannot be represented; every parent treats a null child as "the whole node is + // unrepresentable" (all-or-nothing), so a single unconvertible leaf fails the entire WHERE at convert(). + private static ConnectorExpression convertNode(Expression expr, ExternalTable table) throws AnalysisException { + if (expr == null) { + return null; + } + if (expr instanceof And) { + return convertAnd((And) expr, table); + } else if (expr instanceof Or) { + return convertOr((Or) expr, table); + } else if (expr instanceof Not) { + ConnectorExpression child = convertNode(((Not) expr).child(), table); + return child == null ? null : new ConnectorNot(child); + } else if (expr instanceof EqualTo) { + return convertComparison(expr, ConnectorComparison.Operator.EQ, table); + } else if (expr instanceof GreaterThan) { + return convertComparison(expr, ConnectorComparison.Operator.GT, table); + } else if (expr instanceof GreaterThanEqual) { + return convertComparison(expr, ConnectorComparison.Operator.GE, table); + } else if (expr instanceof LessThan) { + return convertComparison(expr, ConnectorComparison.Operator.LT, table); + } else if (expr instanceof LessThanEqual) { + return convertComparison(expr, ConnectorComparison.Operator.LE, table); + } else if (expr instanceof InPredicate) { + return convertIn((InPredicate) expr, table); + } else if (expr instanceof IsNull) { + return convertIsNull((IsNull) expr, table); + } else if (expr instanceof Between) { + return convertBetween((Between) expr, table); + } + return null; + } + + // AND/OR are all-or-nothing: a single unconvertible child collapses the whole node to null (fail-loud). + private static ConnectorExpression convertAnd(And and, ExternalTable table) throws AnalysisException { + List conjuncts = new ArrayList<>(); + if (!flattenAnd(and, table, conjuncts)) { + return null; + } + return conjuncts.size() == 1 ? conjuncts.get(0) : new ConnectorAnd(conjuncts); + } + + private static boolean flattenAnd(Expression expr, ExternalTable table, List out) + throws AnalysisException { + if (expr instanceof And) { + for (Expression child : expr.children()) { + if (!flattenAnd(child, table, out)) { + return false; + } + } + return true; + } + ConnectorExpression c = convertNode(expr, table); + if (c == null) { + return false; + } + out.add(c); + return true; + } + + private static ConnectorExpression convertOr(Or or, ExternalTable table) throws AnalysisException { + List disjuncts = new ArrayList<>(); + if (!flattenOr(or, table, disjuncts)) { + return null; + } + return disjuncts.size() == 1 ? disjuncts.get(0) : new ConnectorOr(disjuncts); + } + + private static boolean flattenOr(Expression expr, ExternalTable table, List out) + throws AnalysisException { + if (expr instanceof Or) { + for (Expression child : expr.children()) { + if (!flattenOr(child, table, out)) { + return false; + } + } + return true; + } + ConnectorExpression c = convertNode(expr, table); + if (c == null) { + return false; + } + out.add(c); + return true; + } + + private static ConnectorExpression convertComparison(Expression cmp, ConnectorComparison.Operator op, + ExternalTable table) throws AnalysisException { + Expression left = cmp.child(0); + Expression right = cmp.child(1); + Slot slot; + Literal literal; + if (left instanceof Slot && right instanceof Literal) { + slot = (Slot) left; + literal = (Literal) right; + } else if (left instanceof Literal && right instanceof Slot) { + slot = (Slot) right; + literal = (Literal) left; + } else { + return null; + } + ConnectorExpression litExpr = convertLiteral(literal); + if (litExpr == null) { + return null; + } + return new ConnectorComparison(op, columnRef(slot, table), litExpr); + } + + private static ConnectorExpression convertIn(InPredicate in, ExternalTable table) throws AnalysisException { + if (!(in.child(0) instanceof Slot)) { + return null; + } + List inList = new ArrayList<>(); + for (int i = 1; i < in.children().size(); i++) { + Expression child = in.child(i); + if (!(child instanceof Literal)) { + return null; + } + ConnectorExpression lit = convertLiteral((Literal) child); + if (lit == null) { + return null; + } + inList.add(lit); + } + return new ConnectorIn(columnRef((Slot) in.child(0), table), inList, false); + } + + private static ConnectorExpression convertIsNull(IsNull isNull, ExternalTable table) throws AnalysisException { + if (!(isNull.child() instanceof Slot)) { + return null; + } + return new ConnectorIsNull(columnRef((Slot) isNull.child(), table), false); + } + + private static ConnectorExpression convertBetween(Between between, ExternalTable table) + throws AnalysisException { + Expression compareExpr = between.getCompareExpr(); + Expression lower = between.getLowerBound(); + Expression upper = between.getUpperBound(); + if (!(compareExpr instanceof Slot) || !(lower instanceof Literal) || !(upper instanceof Literal)) { + return null; + } + ConnectorExpression lo = convertLiteral((Literal) lower); + ConnectorExpression hi = convertLiteral((Literal) upper); + if (lo == null || hi == null) { + return null; + } + return new ConnectorBetween(columnRef((Slot) compareExpr, table), lo, hi); + } + + // Resolve the column name (handling the unbound parser form: a single name-part UnboundSlot, like the + // legacy IcebergNereidsUtils.extractColumnName) and its type from the table schema. Fail-loud on a + // multi-part reference or an unknown column (a name-based silent drop would widen the rewrite). + private static ConnectorColumnRef columnRef(Slot slot, ExternalTable table) throws AnalysisException { + String name; + if (slot instanceof SlotReference) { + name = ((SlotReference) slot).getName(); + } else if (slot instanceof UnboundSlot) { + List parts = ((UnboundSlot) slot).getNameParts(); + if (parts.size() != 1) { + throw new AnalysisException( + "WHERE column reference must be a single column name, but got: " + parts); + } + name = parts.get(0); + } else { + throw new AnalysisException("Unsupported column reference in WHERE: " + slot.getClass().getName()); + } + Column column = table.getColumn(name); + if (column == null) { + throw new AnalysisException("Column not found in table " + table.getName() + ": " + name); + } + ConnectorType type = ExprToConnectorExpressionConverter.typeToConnectorType(column.getType()); + return new ConnectorColumnRef(column.getName(), type); + } + + // Route literals through the analyzed-plan-side converter (Expr -> ConnectorExpression) so the neutral type + // token + value are byte-identical to the scan / conflict paths. toLegacyLiteral() is a pure transformation. + private static ConnectorExpression convertLiteral(Literal literal) { + try { + return ExprToConnectorExpressionConverter.convert(literal.toLegacyLiteral()); + } catch (Exception e) { + return null; + } + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/WriteConstraintExtractor.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/WriteConstraintExtractor.java new file mode 100644 index 00000000000000..2267e1f104f440 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/WriteConstraintExtractor.java @@ -0,0 +1,129 @@ +// 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.connector.converter; + +import org.apache.doris.catalog.TableIf; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.expressions.Slot; +import org.apache.doris.nereids.trees.expressions.SlotReference; +import org.apache.doris.nereids.trees.plans.Plan; +import org.apache.doris.nereids.trees.plans.logical.LogicalFilter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; + +/** + * Engine-side (fe-core) production half of O5-2 (P6.3-T07b): extracts, from an analyzed DELETE/UPDATE/MERGE + * plan, the conjuncts that reference only the target table's own columns and hands the connector a + * neutral {@link ConnectorPredicate} for write-time optimistic conflict detection (via + * {@code ConnectorTransaction.applyWriteConstraint}). It is the connector-agnostic generalization of legacy + * {@code IcebergConflictDetectionFilterUtils}'s collection half: the {@code IcebergExternalTable} parameter + * becomes a plain {@code long targetTableId}, the inline {@code $row_id}/metadata-column exclusion becomes an + * injected {@link Predicate} (the row-level DML transform supplies the connector-specific predicate in T07c), + * and the per-conjunct iceberg lowering moves to the connector — here each surviving conjunct is converted to + * a neutral {@link ConnectorExpression} by {@link NereidsToConnectorExpressionConverter}. + * + *

    Conjuncts the converter cannot represent are dropped: dropping a conjunct only ever widens the + * resulting conflict-detection filter (more conservative, never missing a real concurrent-write conflict). + * The wiring of the extracted predicate into {@code applyWriteConstraint} is done by the T07c command shell; + * this class is independently unit-testable.

    + */ +public final class WriteConstraintExtractor { + + private WriteConstraintExtractor() { + } + + /** + * Extracts the target-only write constraint from an analyzed plan. + * + * @param analyzedPlan the analyzed DELETE/UPDATE/MERGE plan (may be {@code null}) + * @param targetTableId the id of the target table whose own-column conjuncts are kept + * @param exclusion a predicate marking slots to exclude (synthetic {@code $row_id} / metadata columns); + * a conjunct referencing any excluded slot is dropped. May be {@code null} (no exclusion). + * @return the neutral predicate over the target table's own columns, or empty when none survive + */ + public static Optional extract(Plan analyzedPlan, long targetTableId, + Predicate exclusion) { + if (analyzedPlan == null) { + return Optional.empty(); + } + List targetConjuncts = new ArrayList<>(); + collectTargetConjuncts(analyzedPlan, targetTableId, exclusion, targetConjuncts); + if (targetConjuncts.isEmpty()) { + return Optional.empty(); + } + List converted = new ArrayList<>(); + for (Expression conjunct : targetConjuncts) { + ConnectorExpression neutral = NereidsToConnectorExpressionConverter.convert(conjunct); + if (neutral != null) { + converted.add(neutral); + } + } + if (converted.isEmpty()) { + return Optional.empty(); + } + ConnectorExpression combined = converted.size() == 1 ? converted.get(0) : new ConnectorAnd(converted); + return Optional.of(new ConnectorPredicate(combined)); + } + + private static void collectTargetConjuncts(Plan plan, long targetTableId, + Predicate exclusion, List output) { + if (plan instanceof LogicalFilter) { + LogicalFilter filter = (LogicalFilter) plan; + for (Expression conjunct : filter.getConjuncts()) { + if (isTargetOnlyPredicate(conjunct, targetTableId, exclusion)) { + output.add(conjunct); + } + } + } + for (Plan child : plan.children()) { + collectTargetConjuncts(child, targetTableId, exclusion, output); + } + } + + private static boolean isTargetOnlyPredicate(Expression predicate, long targetTableId, + Predicate exclusion) { + if (predicate == null) { + return false; + } + Set slots = predicate.getInputSlots(); + if (slots.isEmpty()) { + return false; + } + for (Slot slot : slots) { + if (!(slot instanceof SlotReference)) { + return false; + } + SlotReference slotReference = (SlotReference) slot; + if (exclusion != null && exclusion.test(slotReference)) { + return false; + } + Optional table = slotReference.getOriginalTable(); + if (!table.isPresent() || table.get().getId() != targetTableId) { + return false; + } + } + return true; + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/credentials/AbstractVendedCredentialsProvider.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/credentials/AbstractVendedCredentialsProvider.java deleted file mode 100644 index 105339f1292b1e..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/credentials/AbstractVendedCredentialsProvider.java +++ /dev/null @@ -1,103 +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.datasource.credentials; - -import org.apache.doris.datasource.property.metastore.MetastoreProperties; -import org.apache.doris.datasource.property.storage.StorageProperties; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; - -public abstract class AbstractVendedCredentialsProvider { - - private static final Logger LOG = LogManager.getLogger(AbstractVendedCredentialsProvider.class); - - /** - * Get temporary storage attribute maps containing vendor credentials - * This is the core method: format conversion via StorageProperties.createAll() - * - * @param metastoreProperties Metastore properties - * @param tableObject Table object (generics, support different data sources) - * @return Storage attribute mapping containing temporary credentials - */ - public final Map getStoragePropertiesMapWithVendedCredentials( - MetastoreProperties metastoreProperties, - T tableObject) { - - try { - if (!isVendedCredentialsEnabled(metastoreProperties) || tableObject == null) { - return null; - } - - // 1. Extract original vendored credentials from table objects (such as oss.xxx, s3.xxx format) - Map rawVendedCredentials = extractRawVendedCredentials(tableObject); - if (rawVendedCredentials == null || rawVendedCredentials.isEmpty()) { - return null; - } - - // 2. Filter cloud storage properties before format conversion - Map filteredCredentials = - CredentialUtils.filterCloudStorageProperties(rawVendedCredentials); - if (filteredCredentials.isEmpty()) { - return null; - } - - // 3. Key steps: Format conversion via StorageProperties.createAll() - // This avoids writing duplicate transformation logic in the VendedCredentials class - List vendedStorageProperties = StorageProperties.createAll(filteredCredentials); - - // 4. Convert to Map format - Map vendedPropertiesMap = vendedStorageProperties.stream() - .collect(Collectors.toMap(StorageProperties::getType, Function.identity())); - - if (LOG.isDebugEnabled()) { - LOG.debug("Successfully applied vended credentials for table: {}", getTableName(tableObject)); - } - return vendedPropertiesMap; - - } catch (Exception e) { - LOG.warn("Failed to get vended credentials, returning null", e); - // Return null on failure, Fallback is handled by Factory - return null; - } - } - - /** - * Check whether to enable vendor credentials (subclass implementation) - */ - public abstract boolean isVendedCredentialsEnabled(MetastoreProperties metastoreProperties); - - /** - * Extract original vendored credentials from table objects (subclass implementation) - * Returns the original format attribute, which is responsible for the conversion by StorageProperties.createAll() - */ - protected abstract Map extractRawVendedCredentials(T tableObject); - - /** - * Get the table name (used for logs, subclasses can be rewritable) - */ - protected String getTableName(T tableObject) { - return tableObject != null ? tableObject.toString() : "null"; - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/credentials/VendedCredentialsFactory.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/credentials/VendedCredentialsFactory.java deleted file mode 100644 index 6528fdb89294ac..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/credentials/VendedCredentialsFactory.java +++ /dev/null @@ -1,72 +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.datasource.credentials; - -import org.apache.doris.datasource.iceberg.IcebergVendedCredentialsProvider; -import org.apache.doris.datasource.paimon.PaimonVendedCredentialsProvider; -import org.apache.doris.datasource.property.metastore.MetastoreProperties; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.datasource.property.storage.StorageProperties.Type; - -import java.util.Map; - -public class VendedCredentialsFactory { - /** - * Core method: Get temporary storage attribute maps containing vendored credentials for Scan/Sink Node - * This method is called in Scan/Sink Node.doInitialize() to ensure internal consistency - */ - public static Map getStoragePropertiesMapWithVendedCredentials( - MetastoreProperties metastoreProperties, - Map baseStoragePropertiesMap, - T tableObject) { - - AbstractVendedCredentialsProvider provider = getProviderType(metastoreProperties); - if (provider != null) { - try { - Map result = provider.getStoragePropertiesMapWithVendedCredentials( - metastoreProperties, tableObject); - return result != null ? result : baseStoragePropertiesMap; - } catch (Exception e) { - return baseStoragePropertiesMap; - } - } - - // Fallback to basic properties - return baseStoragePropertiesMap; - } - - /** - * Select the right provider according to the MetastoreProperties type - */ - public static AbstractVendedCredentialsProvider getProviderType(MetastoreProperties metastoreProperties) { - if (metastoreProperties == null) { - return null; - } - - MetastoreProperties.Type type = metastoreProperties.getType(); - switch (type) { - case ICEBERG: - return IcebergVendedCredentialsProvider.getInstance(); - case PAIMON: - return PaimonVendedCredentialsProvider.getInstance(); - default: - // Other types do not support vendor credentials - return null; - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/RemoteDorisExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/RemoteDorisExternalCatalog.java index 40dec9e5795877..e48c07303728e2 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/RemoteDorisExternalCatalog.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/RemoteDorisExternalCatalog.java @@ -21,8 +21,8 @@ import org.apache.doris.common.DdlException; import org.apache.doris.datasource.CatalogProperty; import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.InitCatalogLog; import org.apache.doris.datasource.SessionContext; +import org.apache.doris.datasource.log.InitCatalogLog; import org.apache.doris.datasource.property.constants.RemoteDorisProperties; import org.apache.doris.thrift.TNetworkAddress; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/RemoteDorisExternalDatabase.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/RemoteDorisExternalDatabase.java index 5e5fd347ed1f3e..bd28a1408076e3 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/RemoteDorisExternalDatabase.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/RemoteDorisExternalDatabase.java @@ -19,7 +19,7 @@ import org.apache.doris.datasource.ExternalCatalog; import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.InitDatabaseLog; +import org.apache.doris.datasource.log.InitDatabaseLog; public class RemoteDorisExternalDatabase extends ExternalDatabase { public RemoteDorisExternalDatabase(ExternalCatalog extCatalog, long id, String name, String remoteName) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/source/RemoteDorisScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/source/RemoteDorisScanNode.java index 644c00028e3c97..729eb7f91ee853 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/source/RemoteDorisScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/source/RemoteDorisScanNode.java @@ -32,7 +32,7 @@ import org.apache.doris.catalog.TableIf; import org.apache.doris.common.Pair; import org.apache.doris.common.UserException; -import org.apache.doris.datasource.FileQueryScanNode; +import org.apache.doris.datasource.scan.FileQueryScanNode; import org.apache.doris.planner.PlanNodeId; import org.apache.doris.planner.ScanContext; import org.apache.doris.qe.ConnectContext; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/source/RemoteDorisSplit.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/source/RemoteDorisSplit.java index 86921231cdd236..3cccf8821f9257 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/source/RemoteDorisSplit.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/source/RemoteDorisSplit.java @@ -18,8 +18,8 @@ package org.apache.doris.datasource.doris.source; import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.FileSplit; -import org.apache.doris.datasource.TableFormatType; +import org.apache.doris.datasource.scan.TableFormatType; +import org.apache.doris.datasource.split.FileSplit; import java.nio.ByteBuffer; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/AcidInfo.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/AcidInfo.java deleted file mode 100644 index c49855fbd1aa2e..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/AcidInfo.java +++ /dev/null @@ -1,114 +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.datasource.hive; - -import java.util.List; -import java.util.Objects; - -/** - * Stores information about Acid properties of a partition. - */ -public class AcidInfo { - private final String partitionLocation; - private final List deleteDeltas; - - public AcidInfo(String partitionLocation, List deleteDeltas) { - this.partitionLocation = partitionLocation; - this.deleteDeltas = deleteDeltas; - } - - public String getPartitionLocation() { - return partitionLocation; - } - - public List getDeleteDeltas() { - return deleteDeltas; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - AcidInfo acidInfo = (AcidInfo) o; - return Objects.equals(partitionLocation, acidInfo.partitionLocation) && Objects.equals( - deleteDeltas, acidInfo.deleteDeltas); - } - - @Override - public int hashCode() { - return Objects.hash(partitionLocation, deleteDeltas); - } - - @Override - public String toString() { - final StringBuilder sb = new StringBuilder("AcidInfo{"); - sb.append("partitionLocation='").append(partitionLocation).append('\''); - sb.append(", deleteDeltas=").append(deleteDeltas); - sb.append('}'); - return sb.toString(); - } - - public static class DeleteDeltaInfo { - private String directoryLocation; - private List fileNames; - - public DeleteDeltaInfo(String location, List filenames) { - this.directoryLocation = location; - this.fileNames = filenames; - } - - public String getDirectoryLocation() { - return directoryLocation; - } - - public List getFileNames() { - return fileNames; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - DeleteDeltaInfo that = (DeleteDeltaInfo) o; - return Objects.equals(directoryLocation, that.directoryLocation) - && Objects.equals(fileNames, that.fileNames); - } - - @Override - public int hashCode() { - return Objects.hash(directoryLocation, fileNames); - } - - @Override - public String toString() { - final StringBuilder sb = new StringBuilder("DeleteDeltaInfo{"); - sb.append("directoryLocation='").append(directoryLocation).append('\''); - sb.append(", fileNames=").append(fileNames); - sb.append('}'); - return sb.toString(); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/AcidUtil.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/AcidUtil.java deleted file mode 100644 index 49d9a0de90f866..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/AcidUtil.java +++ /dev/null @@ -1,441 +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.datasource.hive; - -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.hive.AcidInfo.DeleteDeltaInfo; -import org.apache.doris.datasource.hive.HiveExternalMetaCache.FileCacheValue; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.filesystem.FileEntry; -import org.apache.doris.filesystem.FileSystem; -import org.apache.doris.filesystem.FileSystemTransferUtil; -import org.apache.doris.filesystem.Location; - -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NonNull; -import lombok.ToString; -import org.apache.hadoop.hive.common.ValidReadTxnList; -import org.apache.hadoop.hive.common.ValidReaderWriteIdList; -import org.apache.hadoop.hive.common.ValidTxnList; -import org.apache.hadoop.hive.common.ValidWriteIdList; -import org.apache.hadoop.hive.common.ValidWriteIdList.RangeResponse; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -public class AcidUtil { - private static final Logger LOG = LogManager.getLogger(AcidUtil.class); - - public static final String VALID_TXNS_KEY = "hive.txn.valid.txns"; - public static final String VALID_WRITEIDS_KEY = "hive.txn.valid.writeids"; - - private static final String HIVE_TRANSACTIONAL_ORC_BUCKET_PREFIX = "bucket_"; - private static final String DELTA_SIDE_FILE_SUFFIX = "_flush_length"; - - // An `_orc_acid_version` file is written to each base/delta/delete_delta dir written by a full acid write - // or compaction. This is the primary mechanism for versioning acid data. - // Each individual ORC file written stores the current version as a user property in ORC footer. All data files - // produced by Acid write should have this (starting with Hive 3.0), including those written by compactor.This is - // more for sanity checking in case someone moved the files around or something like that. - // In hive, methods for getting/reading the version from files were moved to test which is the only place they are - // used (after HIVE-23506), in order to keep devs out of temptation, since they access the FileSystem which - // is expensive. - // After `HIVE-23825: Create a flag to turn off _orc_acid_version file creation`, introduce variables to - // control whether to generate `_orc_acid_version` file. So don't need check this file exist. - private static final String HIVE_ORC_ACID_VERSION_FILE = "_orc_acid_version"; - - @Getter - @ToString - private static class ParsedBase { - private final long writeId; - private final long visibilityId; - - public ParsedBase(long writeId, long visibilityId) { - this.writeId = writeId; - this.visibilityId = visibilityId; - } - } - - private static ParsedBase parseBase(String name) { - //format1 : base_writeId - //format2 : base_writeId_visibilityId detail: https://issues.apache.org/jira/browse/HIVE-20823 - name = name.substring("base_".length()); - int index = name.indexOf("_v"); - if (index == -1) { - return new ParsedBase(Long.parseLong(name), 0); - } - return new ParsedBase( - Long.parseLong(name.substring(0, index)), - Long.parseLong(name.substring(index + 2))); - } - - @Getter - @ToString - @EqualsAndHashCode - private static class ParsedDelta implements Comparable { - private final long min; - private final long max; - private final String path; - private final int statementId; - private final boolean deleteDelta; - private final long visibilityId; - - public ParsedDelta(long min, long max, @NonNull String path, int statementId, - boolean deleteDelta, long visibilityId) { - this.min = min; - this.max = max; - this.path = path; - this.statementId = statementId; - this.deleteDelta = deleteDelta; - this.visibilityId = visibilityId; - } - - /* - * Smaller minWID orders first; - * If minWID is the same, larger maxWID orders first; - * Otherwise, sort by stmtID; files w/o stmtID orders first. - * - * Compactions (Major/Minor) merge deltas/bases but delete of old files - * happens in a different process; thus it's possible to have bases/deltas with - * overlapping writeId boundaries. The sort order helps figure out the "best" set of files - * to use to get data. - * This sorts "wider" delta before "narrower" i.e. delta_5_20 sorts before delta_5_10 (and delta_11_20) - */ - @Override - public int compareTo(ParsedDelta other) { - return min != other.min ? Long.compare(min, other.min) : - other.max != max ? Long.compare(other.max, max) : - statementId != other.statementId - ? Integer.compare(statementId, other.statementId) : - path.compareTo(other.path); - } - } - - - private static boolean isValidMetaDataFile(FileSystem fileSystem, String baseDir) - throws IOException { - String fileLocation = baseDir + "_metadata_acid"; - try { - return fileSystem.exists(Location.of(fileLocation)); - } catch (IOException e) { - return false; - } - } - - private static boolean isValidBase(FileSystem fileSystem, String baseDir, - ParsedBase base, ValidWriteIdList writeIdList) throws IOException { - if (base.writeId == Long.MIN_VALUE) { - //Ref: https://issues.apache.org/jira/browse/HIVE-13369 - //such base is created by 1st compaction in case of non-acid to acid table conversion.(you - //will get dir: `base_-9223372036854775808`) - //By definition there are no open txns with id < 1. - //After this: https://issues.apache.org/jira/browse/HIVE-18192, txns(global transaction ID) => writeId. - return true; - } - - // hive 4 : just check "_v" suffix, before hive 4 : check `_metadata_acid` file in baseDir. - if ((base.visibilityId > 0) || isValidMetaDataFile(fileSystem, baseDir)) { - return writeIdList.isValidBase(base.writeId); - } - - // if here, it's a result of IOW - return writeIdList.isWriteIdValid(base.writeId); - } - - private static ParsedDelta parseDelta(String fileName, String deltaPrefix, String path) { - // format1: delta_min_max_statementId_visibilityId, delete_delta_min_max_statementId_visibilityId - // _visibilityId maybe not exists. - // detail: https://issues.apache.org/jira/browse/HIVE-20823 - // format2: delta_min_max_visibilityId, delete_delta_min_visibilityId - // when minor compaction runs, we collapse per statement delta files inside a single - // transaction so we no longer need a statementId in the file name - - // String fileName = fileName.substring(name.lastIndexOf('/') + 1); - // checkArgument(fileName.startsWith(deltaPrefix), "File does not start with '%s': %s", deltaPrefix, path); - - long visibilityId = 0; - int visibilityIdx = fileName.indexOf("_v"); - if (visibilityIdx != -1) { - visibilityId = Long.parseLong(fileName.substring(visibilityIdx + 2)); - fileName = fileName.substring(0, visibilityIdx); - } - - boolean deleteDelta = deltaPrefix.equals("delete_delta_"); - - String rest = fileName.substring(deltaPrefix.length()); - int split = rest.indexOf('_'); - int split2 = rest.indexOf('_', split + 1); - long min = Long.parseLong(rest.substring(0, split)); - - if (split2 == -1) { - long max = Long.parseLong(rest.substring(split + 1)); - return new ParsedDelta(min, max, path, -1, deleteDelta, visibilityId); - } - - long max = Long.parseLong(rest.substring(split + 1, split2)); - int statementId = Integer.parseInt(rest.substring(split2 + 1)); - return new ParsedDelta(min, max, path, statementId, deleteDelta, visibilityId); - } - - public interface FileFilter { - public boolean accept(String fileName); - } - - public static final class FullAcidFileFilter implements FileFilter { - @Override - public boolean accept(String fileName) { - return fileName.startsWith(HIVE_TRANSACTIONAL_ORC_BUCKET_PREFIX) - && !fileName.endsWith(DELTA_SIDE_FILE_SUFFIX); - } - } - - public static final class InsertOnlyFileFilter implements FileFilter { - @Override - public boolean accept(String fileName) { - return true; - } - } - - //Since the hive3 library cannot read the hive4 transaction table normally, and there are many problems - // when using the Hive 4 library directly, this method is implemented. - //Ref: hive/ql/src/java/org/apache/hadoop/hive/ql/io/AcidUtils.java#getAcidState - public static FileCacheValue getAcidState(FileSystem fileSystem, HivePartition partition, - Map txnValidIds, Map storagePropertiesMap, - boolean isFullAcid) throws Exception { - return getAcidState(fileSystem, partition, txnValidIds, storagePropertiesMap, isFullAcid, - partition.getPath()); - } - - /** - * Variant taking the partition location already normalized by the same {@code LocationPath} - * resolution that selected {@code fileSystem}. Concrete filesystems only accept their native - * schemes, so when the legacy cross-scheme fallback fires (e.g. a {@code cos://} HMS location - * served by s3.* storage properties) the raw partition path must not be globbed directly; - * using the normalized path keeps base/delta listing and the BE-facing AcidInfo locations - * consistent with the non-transactional listing path. - */ - public static FileCacheValue getAcidState(FileSystem fileSystem, HivePartition partition, - Map txnValidIds, Map storagePropertiesMap, - boolean isFullAcid, String partitionPath) throws Exception { - - // Ref: https://issues.apache.org/jira/browse/HIVE-18192 - // Readers should use the combination of ValidTxnList and ValidWriteIdList(Table) for snapshot isolation. - // ValidReadTxnList implements ValidTxnList - // ValidReaderWriteIdList implements ValidWriteIdList - ValidTxnList validTxnList = null; - if (txnValidIds.containsKey(VALID_TXNS_KEY)) { - validTxnList = new ValidReadTxnList(); - validTxnList.readFromString( - txnValidIds.get(VALID_TXNS_KEY) - ); - } else { - throw new RuntimeException("Miss ValidTxnList"); - } - - ValidWriteIdList validWriteIdList = null; - if (txnValidIds.containsKey(VALID_WRITEIDS_KEY)) { - validWriteIdList = new ValidReaderWriteIdList(); - validWriteIdList.readFromString( - txnValidIds.get(VALID_WRITEIDS_KEY) - ); - } else { - throw new RuntimeException("Miss ValidWriteIdList"); - } - - //partitionPath eg: hdfs://xxxxx/user/hive/warehouse/username/data_id=200103 - - List lsPartitionPath = - FileSystemTransferUtil.globList(fileSystem, partitionPath + "/*", false); - // List all files and folders, without recursion. - - String oldestBase = null; - long oldestBaseWriteId = Long.MAX_VALUE; - String bestBasePath = null; - long bestBaseWriteId = 0; - boolean haveOriginalFiles = false; - List workingDeltas = new ArrayList<>(); - - for (FileEntry entry : lsPartitionPath) { - if (entry.isDirectory()) { - String dirName = locationName(entry.location()); //dirName: base_xxx,delta_xxx,... - String dirPath = partitionPath + "/" + dirName; - - if (dirName.startsWith("base_")) { - ParsedBase base = parseBase(dirName); - if (!validTxnList.isTxnValid(base.visibilityId)) { - //checks visibilityTxnId to see if it is committed in current snapshot. - continue; - } - - long writeId = base.writeId; - if (oldestBaseWriteId > writeId) { - oldestBase = dirPath; - oldestBaseWriteId = writeId; - } - - if (((bestBasePath == null) || (bestBaseWriteId < writeId)) - && isValidBase(fileSystem, dirPath, base, validWriteIdList)) { - //IOW will generator a base_N/ directory: https://issues.apache.org/jira/browse/HIVE-14988 - //So maybe need consider: https://issues.apache.org/jira/browse/HIVE-25777 - - bestBasePath = dirPath; - bestBaseWriteId = writeId; - } - } else if (dirName.startsWith("delta_") || dirName.startsWith("delete_delta_")) { - String deltaPrefix = dirName.startsWith("delta_") ? "delta_" : "delete_delta_"; - ParsedDelta delta = parseDelta(dirName, deltaPrefix, dirPath); - - if (!validTxnList.isTxnValid(delta.visibilityId)) { - continue; - } - - // No need check (validWriteIdList.isWriteIdRangeAborted(min,max) != RangeResponse.ALL) - // It is a subset of (validWriteIdList.isWriteIdRangeValid(min, max) != RangeResponse.NONE) - if (validWriteIdList.isWriteIdRangeValid(delta.min, delta.max) != RangeResponse.NONE) { - workingDeltas.add(delta); - } - } else { - //Sometimes hive will generate temporary directories(`.hive-staging_hive_xxx` ), - // which do not need to be read. - LOG.warn("Read Hive Acid Table ignore the contents of this folder:" + dirName); - } - } else { - haveOriginalFiles = true; - } - } - - if (bestBasePath == null && haveOriginalFiles) { - // ALTER TABLE nonAcidTbl SET TBLPROPERTIES ('transactional'='true'); - throw new UnsupportedOperationException("For no acid table convert to acid, please COMPACT 'major'."); - } - - if ((oldestBase != null) && (bestBasePath == null)) { - /* - * If here, it means there was a base_x (> 1 perhaps) but none were suitable for given - * {@link writeIdList}. Note that 'original' files are logically a base_Long.MIN_VALUE and thus - * cannot have any data for an open txn. We could check {@link deltas} has files to cover - * [1,n] w/o gaps but this would almost never happen... - * - * We only throw for base_x produced by Compactor since that base erases all history and - * cannot be used for a client that has a snapshot in which something inside this base is - * open. (Nor can we ignore this base of course) But base_x which is a result of IOW, - * contains all history so we treat it just like delta wrt visibility. Imagine, IOW which - * aborts. It creates a base_x, which can and should just be ignored.*/ - long[] exceptions = validWriteIdList.getInvalidWriteIds(); - String minOpenWriteId = ((exceptions != null) - && (exceptions.length > 0)) ? String.valueOf(exceptions[0]) : "x"; - throw new IOException( - String.format("Not enough history available for ({},{}). Oldest available base: {}", - validWriteIdList.getHighWatermark(), minOpenWriteId, oldestBase)); - } - - workingDeltas.sort(null); - - List deltas = new ArrayList<>(); - long current = bestBaseWriteId; - int lastStatementId = -1; - ParsedDelta prev = null; - // find need read delta/delete_delta file. - for (ParsedDelta next : workingDeltas) { - if (next.max > current) { - if (validWriteIdList.isWriteIdRangeValid(current + 1, next.max) != RangeResponse.NONE) { - deltas.add(next); - current = next.max; - lastStatementId = next.statementId; - prev = next; - } - } else if ((next.max == current) && (lastStatementId >= 0)) { - //make sure to get all deltas within a single transaction; multi-statement txn - //generate multiple delta files with the same txnId range - //of course, if maxWriteId has already been minor compacted, - // all per statement deltas are obsolete - - deltas.add(next); - prev = next; - } else if ((prev != null) - && (next.max == prev.max) - && (next.min == prev.min) - && (next.statementId == prev.statementId)) { - // The 'next' parsedDelta may have everything equal to the 'prev' parsedDelta, except - // the path. This may happen when we have split update and we have two types of delta - // directories- 'delta_x_y' and 'delete_delta_x_y' for the SAME txn range. - - // Also note that any delete_deltas in between a given delta_x_y range would be made - // obsolete. For example, a delta_30_50 would make delete_delta_40_40 obsolete. - // This is valid because minor compaction always compacts the normal deltas and the delete - // deltas for the same range. That is, if we had 3 directories, delta_30_30, - // delete_delta_40_40 and delta_50_50, then running minor compaction would produce - // delta_30_50 and delete_delta_30_50. - deltas.add(next); - prev = next; - } - } - - FileCacheValue fileCacheValue = new FileCacheValue(); - List deleteDeltas = new ArrayList<>(); - - FileFilter fileFilter = isFullAcid ? new FullAcidFileFilter() : new InsertOnlyFileFilter(); - - // delta directories - for (ParsedDelta delta : deltas) { - String location = delta.getPath(); - - List entries = FileSystemTransferUtil.globList(fileSystem, location, false); - if (delta.isDeleteDelta()) { - List deleteDeltaFileNames = entries.stream() - .map(e -> locationName(e.location())).filter(fileFilter::accept) - .collect(Collectors.toList()); - deleteDeltas.add(new DeleteDeltaInfo(location, deleteDeltaFileNames)); - continue; - } - entries.stream().filter(e -> fileFilter.accept(locationName(e.location()))).forEach(entry -> { - LocationPath path = LocationPath.of(entry.location().uri(), storagePropertiesMap); - fileCacheValue.addFile(entry, path); - }); - } - - // base - if (bestBasePath != null) { - List entries = FileSystemTransferUtil.globList(fileSystem, bestBasePath, false); - entries.stream().filter(e -> fileFilter.accept(locationName(e.location()))) - .forEach(entry -> { - LocationPath path = LocationPath.of(entry.location().uri(), storagePropertiesMap); - fileCacheValue.addFile(entry, path); - }); - } - - if (isFullAcid) { - fileCacheValue.setAcidInfo(new AcidInfo(partitionPath, deleteDeltas)); - } else if (!deleteDeltas.isEmpty()) { - throw new RuntimeException("No Hive Full Acid Table have delete_delta_* Dir."); - } - return fileCacheValue; - } - - private static String locationName(org.apache.doris.filesystem.Location location) { - String uri = location.uri(); - int idx = uri.lastIndexOf('/'); - return idx >= 0 ? uri.substring(idx + 1) : uri; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSCachedClient.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSCachedClient.java deleted file mode 100644 index ffd51f63ab781d..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSCachedClient.java +++ /dev/null @@ -1,124 +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.datasource.hive; - -import org.apache.doris.catalog.info.TableNameInfo; -import org.apache.doris.common.security.authentication.HadoopAuthenticator; -import org.apache.doris.datasource.DatabaseMetadata; -import org.apache.doris.datasource.TableMetadata; -import org.apache.doris.datasource.hive.event.MetastoreNotificationFetchException; - -import org.apache.hadoop.hive.metastore.IMetaStoreClient; -import org.apache.hadoop.hive.metastore.api.ColumnStatisticsObj; -import org.apache.hadoop.hive.metastore.api.CurrentNotificationEventId; -import org.apache.hadoop.hive.metastore.api.Database; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.NotificationEventResponse; -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.Table; - -import java.util.List; -import java.util.Map; -import java.util.function.Function; - -/** - * A hive metastore client pool for a specific catalog with hive configuration. - * Currently, we support obtain hive metadata from thrift protocol and JDBC protocol. - */ -public interface HMSCachedClient { - Database getDatabase(String dbName); - - List getAllDatabases(); - - List getAllTables(String dbName); - - boolean tableExists(String dbName, String tblName); - - List listPartitionNames(String dbName, String tblName); - - List listPartitions(String dbName, String tblName); - - List listPartitionNames(String dbName, String tblName, long maxListPartitionNum); - - Partition getPartition(String dbName, String tblName, List partitionValues); - - List getPartitions(String dbName, String tblName, List partitionNames); - - Table getTable(String dbName, String tblName); - - List getSchema(String dbName, String tblName); - - Map getDefaultColumnValues(String dbName, String tblName); - - List getTableColumnStatistics(String dbName, String tblName, - List columns); - - Map> getPartitionColumnStatistics( - String dbName, String tblName, List partNames, List columns); - - CurrentNotificationEventId getCurrentNotificationEventId(); - - NotificationEventResponse getNextNotification(long lastEventId, - int maxEvents, - IMetaStoreClient.NotificationFilter filter) throws MetastoreNotificationFetchException; - - long openTxn(String user); - - void commitTxn(long txnId); - - Map getValidWriteIds(String fullTableName, long currentTransactionId); - - void acquireSharedLock(String queryId, long txnId, String user, TableNameInfo tblName, - List partitionNames, long timeoutMs); - - String getCatalogLocation(String catalogName); - - void createDatabase(DatabaseMetadata catalogDatabase); - - void dropDatabase(String dbName); - - void dropTable(String dbName, String tableName); - - void truncateTable(String dbName, String tblName, List partitions); - - void createTable(TableMetadata catalogTable, boolean ignoreIfExists); - - void updateTableStatistics( - String dbName, - String tableName, - Function update); - - void updatePartitionStatistics( - String dbName, - String tableName, - String partitionName, - Function update); - - void addPartitions(String dbName, String tableName, List partitions); - - void dropPartition(String dbName, String tableName, List partitionValues, boolean deleteData); - - default void setHadoopAuthenticator(HadoopAuthenticator hadoopAuthenticator) { - // Ignored by default - } - - /** - * close the connection, eg, to hms - */ - void close(); -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSClientException.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSClientException.java deleted file mode 100644 index 6e714e20a9b780..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSClientException.java +++ /dev/null @@ -1,31 +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.datasource.hive; - -import org.apache.doris.common.util.Util; - -public class HMSClientException extends RuntimeException { - public HMSClientException(String format, Throwable cause, Object... msg) { - super(String.format(format, msg) + (cause == null ? "" : ". reason: " + Util.getRootCauseMessage(cause)), - cause); - } - - public HMSClientException(String format, Object... msg) { - super(String.format(format, msg)); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSDlaTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSDlaTable.java deleted file mode 100644 index bee9a1a7cafbc6..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSDlaTable.java +++ /dev/null @@ -1,87 +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.datasource.hive; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.MTMV; -import org.apache.doris.catalog.PartitionItem; -import org.apache.doris.catalog.PartitionType; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.DdlException; -import org.apache.doris.datasource.mvcc.MvccSnapshot; -import org.apache.doris.mtmv.MTMVBaseTableIf; -import org.apache.doris.mtmv.MTMVRefreshContext; -import org.apache.doris.mtmv.MTMVSnapshotIf; - -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; - -/** - * This abstract class represents a Hive Metastore (HMS) Dla Table and provides a blueprint for - * various operations related to metastore tables in Doris. - * - * Purpose: - * - To encapsulate common functionalities that HMS Dla tables should have for implementing other interfaces - * - * Why needed: - * - To provide a unified way to manage and interact with different kinds of Dla Table - * - To facilitate the implementation of multi-table materialized views (MTMV) by providing necessary - * methods for snapshot and partition management. - * - To abstract out the specific details of HMS table operations, making the code more modular and maintainable. - */ -public abstract class HMSDlaTable implements MTMVBaseTableIf { - protected HMSExternalTable hmsTable; - - public HMSDlaTable(HMSExternalTable table) { - this.hmsTable = table; - } - - abstract Map getAndCopyPartitionItems(Optional snapshot) - throws AnalysisException; - - abstract PartitionType getPartitionType(Optional snapshot); - - abstract Set getPartitionColumnNames(Optional snapshot); - - abstract List getPartitionColumns(Optional snapshot); - - abstract MTMVSnapshotIf getPartitionSnapshot(String partitionName, MTMVRefreshContext context, - Optional snapshot) throws AnalysisException; - - abstract MTMVSnapshotIf getTableSnapshot(MTMVRefreshContext context, Optional snapshot) - throws AnalysisException; - - abstract MTMVSnapshotIf getTableSnapshot(Optional snapshot) throws AnalysisException; - - abstract boolean isPartitionColumnAllowNull(); - - @Override - public void beforeMTMVRefresh(MTMV mtmv) throws DdlException { - } - - /** - * If the table is supported as related table. - * For example, an Iceberg table may become unsupported after partition revolution. - * @return - */ - protected boolean isValidRelatedTable() { - return true; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSExternalCatalog.java deleted file mode 100644 index e4157cf72ddadf..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSExternalCatalog.java +++ /dev/null @@ -1,250 +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.datasource.hive; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.ThreadPoolManager; -import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.CatalogProperty; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.InitCatalogLog; -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.datasource.hudi.HudiExternalMetaCache; -import org.apache.doris.datasource.iceberg.IcebergMetadataOps; -import org.apache.doris.datasource.iceberg.IcebergUtils; -import org.apache.doris.datasource.metacache.CacheSpec; -import org.apache.doris.datasource.property.metastore.AbstractHiveProperties; -import org.apache.doris.fs.SpiSwitchingFileSystem; -import org.apache.doris.transaction.TransactionManagerFactory; - -import com.google.common.annotations.VisibleForTesting; -import org.apache.commons.lang3.math.NumberUtils; -import org.apache.iceberg.hive.HiveCatalog; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ThreadPoolExecutor; - -/** - * External catalog for hive metastore compatible data sources. - */ -public class HMSExternalCatalog extends ExternalCatalog { - private static final Logger LOG = LogManager.getLogger(HMSExternalCatalog.class); - - public static final String FILE_META_CACHE_TTL_SECOND = "file.meta.cache.ttl-second"; - public static final String PARTITION_CACHE_TTL_SECOND = "partition.cache.ttl-second"; - public static final String HIVE_STAGING_DIR = "hive.staging_dir"; - public static final String DEFAULT_STAGING_BASE_DIR = "/tmp/.doris_staging"; - // broker name for file split and query scan. - public static final String BIND_BROKER_NAME = "broker.name"; - // Default is false, if set to true, will get table schema from "remoteTable" instead of from hive metastore. - // This is because for some forward compatibility issue of hive metastore, there maybe - // "storage schema reading not support" error being thrown. - // set this to true can avoid this error. - // But notice that if set to true, the default value of column will be ignored because we cannot get default value - // from remoteTable object. - public static final String GET_SCHEMA_FROM_TABLE = "get_schema_from_table"; - - private static final int FILE_SYSTEM_EXECUTOR_THREAD_NUM = 16; - private ThreadPoolExecutor fileSystemExecutor; - private SpiSwitchingFileSystem spiFileSystem; - - //for "type" = "hms" , but is iceberg table. - private IcebergMetadataOps icebergMetadataOps; - - private volatile AbstractHiveProperties hmsProperties; - - /** - * Lazily initializes HMSProperties from catalog properties. - * This method is thread-safe using double-checked locking. - *

    - * TODO: After all metastore integrations are completed, - * consider moving this initialization logic into the superclass constructor - * for unified management. - * NOTE: Alter operations are temporarily not handled here. - * We will consider a unified solution for alter support later, - * as it's currently not feasible to handle it in a common/shared location. - */ - public AbstractHiveProperties getHmsProperties() { - makeSureInitialized(); - return hmsProperties; - } - - @VisibleForTesting - public HMSExternalCatalog() { - catalogProperty = new CatalogProperty(null, null); - } - - /** - * Default constructor for HMSExternalCatalog. - */ - public HMSExternalCatalog(long catalogId, String name, String resource, Map props, - String comment) { - super(catalogId, name, InitCatalogLog.Type.HMS, comment); - catalogProperty = new CatalogProperty(resource, props); - } - - @Override - public void checkProperties() throws DdlException { - super.checkProperties(); - // check file.meta.cache.ttl-second parameter - String fileMetaCacheTtlSecond = catalogProperty.getOrDefault(FILE_META_CACHE_TTL_SECOND, null); - if (Objects.nonNull(fileMetaCacheTtlSecond) && NumberUtils.toInt(fileMetaCacheTtlSecond, CACHE_NO_TTL) - < CACHE_TTL_DISABLE_CACHE) { - throw new DdlException( - "The parameter " + FILE_META_CACHE_TTL_SECOND + " is wrong, value is " + fileMetaCacheTtlSecond); - } - - // check partition.cache.ttl-second parameter - String partitionCacheTtlSecond = catalogProperty.getOrDefault(PARTITION_CACHE_TTL_SECOND, null); - if (Objects.nonNull(partitionCacheTtlSecond) && NumberUtils.toInt(partitionCacheTtlSecond, CACHE_NO_TTL) - < CACHE_TTL_DISABLE_CACHE) { - throw new DdlException( - "The parameter " + PARTITION_CACHE_TTL_SECOND + " is wrong, value is " + partitionCacheTtlSecond); - } - catalogProperty.checkMetaStoreAndStorageProperties(AbstractHiveProperties.class); - } - - @Override - protected synchronized void initPreExecutionAuthenticator() { - if (executionAuthenticator == null) { - executionAuthenticator = hmsProperties.getExecutionAuthenticator(); - } - } - - @Override - protected void initLocalObjectsImpl() { - this.hmsProperties = (AbstractHiveProperties) catalogProperty.getMetastoreProperties(); - initPreExecutionAuthenticator(); - HiveMetadataOps hiveOps = new HiveMetadataOps(hmsProperties.getHiveConf(), this); - threadPoolWithPreAuth = ThreadPoolManager.newDaemonFixedThreadPoolWithPreAuth( - ICEBERG_CATALOG_EXECUTOR_THREAD_NUM, - Integer.MAX_VALUE, - String.format("hms_iceberg_catalog_%s_executor_pool", name), - true, - executionAuthenticator); - SpiSwitchingFileSystem spiFileSystem = - new SpiSwitchingFileSystem(this.catalogProperty.getStoragePropertiesMap()); - this.spiFileSystem = spiFileSystem; - this.fileSystemExecutor = ThreadPoolManager.newDaemonFixedThreadPool(FILE_SYSTEM_EXECUTOR_THREAD_NUM, - Integer.MAX_VALUE, String.format("hms_committer_%s_file_system_executor_pool", name), true); - transactionManager = TransactionManagerFactory.createHiveTransactionManager(hiveOps, spiFileSystem, - fileSystemExecutor); - metadataOps = hiveOps; - } - - @Override - public void onClose() { - super.onClose(); - if (null != spiFileSystem) { - try { - spiFileSystem.close(); - } catch (Exception e) { - LOG.warn("Failed to close SpiSwitchingFileSystem for catalog: {}", name, e); - } - spiFileSystem = null; - } - if (null != fileSystemExecutor) { - ThreadPoolManager.shutdownExecutorService(fileSystemExecutor); - } - if (null != metadataOps) { - metadataOps.close(); - metadataOps = null; - } - if (null != icebergMetadataOps) { - icebergMetadataOps.close(); - icebergMetadataOps = null; - } - } - - @Override - protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { - return metadataOps.listTableNames(dbName); - } - - @Override - public boolean tableExist(SessionContext ctx, String dbName, String tblName) { - return metadataOps.tableExist(dbName, tblName); - } - - @Override - public boolean tableExistInLocal(String dbName, String tblName) { - makeSureInitialized(); - HMSExternalDatabase hmsExternalDatabase = (HMSExternalDatabase) getDbNullable(dbName); - if (hmsExternalDatabase == null) { - return false; - } - return hmsExternalDatabase.getTable(tblName).isPresent(); - } - - public HMSCachedClient getClient() { - makeSureInitialized(); - return ((HiveMetadataOps) metadataOps).getClient(); - } - - @Override - public void registerDatabase(long dbId, String dbName) { - if (LOG.isDebugEnabled()) { - LOG.debug("create database [{}]", dbName); - } - - ExternalDatabase db = buildDbForInit(dbName, null, dbId, logType, false); - if (isInitialized()) { - metaCache.updateCache(db.getRemoteName(), db.getFullName(), db, - Util.genIdByName(name, db.getFullName())); - } - } - - @Override - public void notifyPropertiesUpdated(Map updatedProps) { - super.notifyPropertiesUpdated(updatedProps); - String fileMetaCacheTtl = updatedProps.getOrDefault(FILE_META_CACHE_TTL_SECOND, null); - String partitionCacheTtl = updatedProps.getOrDefault(PARTITION_CACHE_TTL_SECOND, null); - if (Objects.nonNull(fileMetaCacheTtl) || Objects.nonNull(partitionCacheTtl)) { - Env.getCurrentEnv().getExtMetaCacheMgr().removeCatalogByEngine(getId(), HiveExternalMetaCache.ENGINE); - } - if (updatedProps.keySet().stream() - .anyMatch(key -> CacheSpec.isMetaCacheKeyForEngine(key, HudiExternalMetaCache.ENGINE))) { - Env.getCurrentEnv().getExtMetaCacheMgr().removeCatalogByEngine(getId(), HudiExternalMetaCache.ENGINE); - } - } - - @Override - public void setDefaultPropsIfMissing(boolean isReplay) { - super.setDefaultPropsIfMissing(isReplay); - if (ifNotSetFallbackToSimpleAuth()) { - // always allow fallback to simple auth, so to support both kerberos and simple auth - catalogProperty.addProperty("ipc.client.fallback-to-simple-auth-allowed", "true"); - } - } - - public IcebergMetadataOps getIcebergMetadataOps() { - makeSureInitialized(); - if (icebergMetadataOps == null) { - HiveCatalog icebergHiveCatalog = IcebergUtils.createIcebergHiveCatalog(this, getName()); - icebergMetadataOps = new IcebergMetadataOps(this, icebergHiveCatalog); - } - return icebergMetadataOps; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSExternalDatabase.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSExternalDatabase.java deleted file mode 100644 index ab0884d0ce1637..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSExternalDatabase.java +++ /dev/null @@ -1,58 +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.datasource.hive; - -import org.apache.doris.catalog.TableIf; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.InitDatabaseLog; - -/** - * Hive metastore external database. - */ -public class HMSExternalDatabase extends ExternalDatabase { - /** - * Create HMS external database. - * - * @param extCatalog External catalog this database belongs to. - * @param id database id. - * @param name database name. - * @param remoteName remote database name. - */ - public HMSExternalDatabase(ExternalCatalog extCatalog, long id, String name, String remoteName) { - super(extCatalog, id, name, remoteName, InitDatabaseLog.Type.HMS); - } - - @Override - public HMSExternalTable buildTableInternal(String remoteTableName, String localTableName, long tblId, - ExternalCatalog catalog, - ExternalDatabase db) { - return new HMSExternalTable(tblId, localTableName, remoteTableName, (HMSExternalCatalog) extCatalog, - (HMSExternalDatabase) db); - } - - @Override - public boolean registerTable(TableIf tableIf) { - super.registerTable(tableIf); - HMSExternalTable table = getTableNullable(tableIf.getName()); - if (table != null) { - table.setUpdateTime(tableIf.getUpdateTime()); - } - return true; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSExternalTable.java deleted file mode 100644 index e1311237a603d5..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSExternalTable.java +++ /dev/null @@ -1,1332 +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.datasource.hive; - -import org.apache.doris.analysis.TableScanParams; -import org.apache.doris.analysis.TableSnapshot; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.ListPartitionItem; -import org.apache.doris.catalog.MTMV; -import org.apache.doris.catalog.PartitionItem; -import org.apache.doris.catalog.PartitionType; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.Type; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.Config; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.UserException; -import org.apache.doris.common.profile.SummaryProfile; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.SchemaCacheKey; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.TablePartitionValues; -import org.apache.doris.datasource.hudi.HudiExternalMetaCache; -import org.apache.doris.datasource.hudi.HudiSchemaCacheKey; -import org.apache.doris.datasource.hudi.HudiSchemaCacheValue; -import org.apache.doris.datasource.hudi.HudiUtils; -import org.apache.doris.datasource.iceberg.IcebergExternalMetaCache; -import org.apache.doris.datasource.iceberg.IcebergMvccSnapshot; -import org.apache.doris.datasource.iceberg.IcebergSchemaCacheKey; -import org.apache.doris.datasource.iceberg.IcebergSnapshotCacheValue; -import org.apache.doris.datasource.iceberg.IcebergUtils; -import org.apache.doris.datasource.mvcc.EmptyMvccSnapshot; -import org.apache.doris.datasource.mvcc.MvccSnapshot; -import org.apache.doris.datasource.mvcc.MvccTable; -import org.apache.doris.datasource.mvcc.MvccUtil; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.datasource.systable.IcebergSysTable; -import org.apache.doris.datasource.systable.PartitionsSysTable; -import org.apache.doris.datasource.systable.SysTable; -import org.apache.doris.fs.FileSystemDirectoryLister; -import org.apache.doris.mtmv.MTMVBaseTableIf; -import org.apache.doris.mtmv.MTMVRefreshContext; -import org.apache.doris.mtmv.MTMVRelatedTableIf; -import org.apache.doris.mtmv.MTMVSnapshotIf; -import org.apache.doris.nereids.exceptions.NotSupportedException; -import org.apache.doris.nereids.rules.expression.rules.SortedPartitionRanges; -import org.apache.doris.nereids.trees.plans.algebra.CatalogRelation; -import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan.SelectedPartitions; -import org.apache.doris.qe.GlobalVariable; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.statistics.AnalysisInfo; -import org.apache.doris.statistics.BaseAnalysisTask; -import org.apache.doris.statistics.ColumnStatistic; -import org.apache.doris.statistics.ColumnStatisticBuilder; -import org.apache.doris.statistics.HMSAnalysisTask; -import org.apache.doris.statistics.StatsType; -import org.apache.doris.statistics.util.StatisticsUtil; -import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.THiveTable; -import org.apache.doris.thrift.TTableDescriptor; -import org.apache.doris.thrift.TTableType; - -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import com.google.common.collect.Sets; -import com.google.gson.Gson; -import com.google.gson.JsonObject; -import org.apache.commons.collections4.CollectionUtils; -import org.apache.commons.collections4.MapUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.hadoop.hive.metastore.api.ColumnStatisticsData; -import org.apache.hadoop.hive.metastore.api.ColumnStatisticsObj; -import org.apache.hadoop.hive.metastore.api.DateColumnStatsData; -import org.apache.hadoop.hive.metastore.api.DecimalColumnStatsData; -import org.apache.hadoop.hive.metastore.api.DoubleColumnStatsData; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.LongColumnStatsData; -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.StringColumnStatsData; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.hadoop.hive.ql.io.AcidUtils; -import org.apache.hudi.common.table.HoodieTableMetaClient; -import org.apache.hudi.internal.schema.InternalSchema; -import org.apache.hudi.internal.schema.Types; -import org.apache.hudi.internal.schema.convert.AvroInternalSchemaConverter; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Base64; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * Hive metastore external table. - */ -public class HMSExternalTable extends ExternalTable implements MTMVRelatedTableIf, MTMVBaseTableIf, MvccTable { - private static final Logger LOG = LogManager.getLogger(HMSExternalTable.class); - - public static final Set SUPPORTED_HIVE_FILE_FORMATS; - public static final Set SUPPORTED_HIVE_TOPN_LAZY_FILE_FORMATS; - - public static final Set SUPPORTED_HIVE_TRANSACTIONAL_FILE_FORMATS; - public static final Set SUPPORTED_HUDI_FILE_FORMATS; - - private static final Map MAP_SPARK_STATS_TO_DORIS; - - private static final String TBL_PROP_TRANSIENT_LAST_DDL_TIME = "transient_lastDdlTime"; - - private static final String NUM_ROWS = "numRows"; - - private static final String SPARK_COL_STATS = "spark.sql.statistics.colStats."; - private static final String SPARK_STATS_MAX = ".max"; - private static final String SPARK_STATS_MIN = ".min"; - private static final String SPARK_STATS_NDV = ".distinctCount"; - private static final String SPARK_STATS_NULLS = ".nullCount"; - private static final String SPARK_STATS_AVG_LEN = ".avgLen"; - private static final String SPARK_STATS_MAX_LEN = ".avgLen"; - private static final String SPARK_STATS_HISTOGRAM = ".histogram"; - - private static final String USE_HIVE_SYNC_PARTITION = "use_hive_sync_partition"; - - static { - SUPPORTED_HIVE_FILE_FORMATS = Sets.newHashSet(); - SUPPORTED_HIVE_FILE_FORMATS.add("org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat"); - SUPPORTED_HIVE_FILE_FORMATS.add("org.apache.hadoop.hive.ql.io.orc.OrcInputFormat"); - SUPPORTED_HIVE_FILE_FORMATS.add("org.apache.hadoop.mapred.TextInputFormat"); - // Some hudi table use HoodieParquetInputFormatBase as input format - // But we can't treat it as hudi table. - // So add to SUPPORTED_HIVE_FILE_FORMATS and treat is as a hive table. - // Then Doris will just list the files from location and read parquet files directly. - SUPPORTED_HIVE_FILE_FORMATS.add("org.apache.hudi.hadoop.HoodieParquetInputFormatBase"); - // LZO compressed text formats (hadoop-lzo / lzo-hadoop), treat as text input and use LZOP decompressor. - // LZO text InputFormats (read-only; INSERT INTO these tables is explicitly blocked at planning time). - // All three class names contain "text", so HiveFileFormat.getFormat() correctly resolves - // them to TEXT_FILE, which combined with LazySimpleSerDe yields FORMAT_TEXT for reading. - // isSplittable() recognises all three via isLzoInputFormat() and returns false. - // File listing filters *.lzo files only (*.lzo.index sidecars are excluded). - // com.hadoop.compression.lzo.LzoTextInputFormat - twitter hadoop-lzo (GPL) - // com.hadoop.mapreduce.LzoTextInputFormat - lzo-hadoop mapreduce API (org.anarres) - // com.hadoop.mapred.DeprecatedLzoTextInputFormat - lzo-hadoop legacy mapred API (org.anarres) - SUPPORTED_HIVE_FILE_FORMATS.add("com.hadoop.compression.lzo.LzoTextInputFormat"); - SUPPORTED_HIVE_FILE_FORMATS.add("com.hadoop.mapreduce.LzoTextInputFormat"); - SUPPORTED_HIVE_FILE_FORMATS.add("com.hadoop.mapred.DeprecatedLzoTextInputFormat"); - - SUPPORTED_HIVE_TRANSACTIONAL_FILE_FORMATS = Sets.newHashSet(); - SUPPORTED_HIVE_TRANSACTIONAL_FILE_FORMATS.add("org.apache.hadoop.hive.ql.io.orc.OrcInputFormat"); - - SUPPORTED_HIVE_TOPN_LAZY_FILE_FORMATS = Sets.newHashSet(); - SUPPORTED_HIVE_TOPN_LAZY_FILE_FORMATS.add("org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat"); - SUPPORTED_HIVE_TOPN_LAZY_FILE_FORMATS.add("org.apache.hadoop.hive.ql.io.orc.OrcInputFormat"); - } - - static { - SUPPORTED_HUDI_FILE_FORMATS = Sets.newHashSet(); - SUPPORTED_HUDI_FILE_FORMATS.add("org.apache.hudi.hadoop.HoodieParquetInputFormat"); - SUPPORTED_HUDI_FILE_FORMATS.add("com.uber.hoodie.hadoop.HoodieInputFormat"); - SUPPORTED_HUDI_FILE_FORMATS.add("org.apache.hudi.hadoop.realtime.HoodieParquetRealtimeInputFormat"); - SUPPORTED_HUDI_FILE_FORMATS.add("com.uber.hoodie.hadoop.realtime.HoodieRealtimeInputFormat"); - } - - static { - MAP_SPARK_STATS_TO_DORIS = Maps.newHashMap(); - MAP_SPARK_STATS_TO_DORIS.put(StatsType.NDV, SPARK_STATS_NDV); - MAP_SPARK_STATS_TO_DORIS.put(StatsType.AVG_SIZE, SPARK_STATS_AVG_LEN); - MAP_SPARK_STATS_TO_DORIS.put(StatsType.MAX_SIZE, SPARK_STATS_MAX_LEN); - MAP_SPARK_STATS_TO_DORIS.put(StatsType.NUM_NULLS, SPARK_STATS_NULLS); - MAP_SPARK_STATS_TO_DORIS.put(StatsType.MIN_VALUE, SPARK_STATS_MIN); - MAP_SPARK_STATS_TO_DORIS.put(StatsType.MAX_VALUE, SPARK_STATS_MAX); - MAP_SPARK_STATS_TO_DORIS.put(StatsType.HISTOGRAM, SPARK_STATS_HISTOGRAM); - } - - private volatile org.apache.hadoop.hive.metastore.api.Table remoteTable = null; - - private DLAType dlaType = DLAType.UNKNOWN; - - private HMSDlaTable dlaTable; - - public enum DLAType { - UNKNOWN, HIVE, HUDI, ICEBERG - } - - /** - * Create hive metastore external table. - * - * @param id Table id. - * @param name Table name. - * @param remoteName Remote table name. - * @param catalog HMSExternalDataSource. - * @param db Database. - */ - public HMSExternalTable(long id, String name, String remoteName, HMSExternalCatalog catalog, - HMSExternalDatabase db) { - super(id, name, remoteName, catalog, db, TableType.HMS_EXTERNAL_TABLE); - } - - @Override - public String getMetaCacheEngine() { - switch (getDlaType()) { - case HIVE: - return HiveExternalMetaCache.ENGINE; - case HUDI: - return HudiExternalMetaCache.ENGINE; - case ICEBERG: - return IcebergExternalMetaCache.ENGINE; - case UNKNOWN: - default: - throw new IllegalArgumentException( - String.format("unsupported HMS DLA type '%s' for table %s.%s.%s in catalog %d", - getDlaType(), getCatalog().getName(), getDbName(), getName(), getCatalog().getId())); - } - } - - // Will throw NotSupportedException if not supported hms table. - // Otherwise, return true. - public boolean isSupportedHmsTable() { - makeSureInitialized(); - return true; - } - - protected synchronized void makeSureInitialized() { - super.makeSureInitialized(); - if (!objectCreated) { - long startTime = System.currentTimeMillis(); - try { - remoteTable = loadHiveTable(); - } finally { - SummaryProfile summaryProfile = SummaryProfile.getSummaryProfile(null); - if (summaryProfile != null) { - summaryProfile.addExternalTableGetTableMetaTime(System.currentTimeMillis() - startTime); - } - } - if (remoteTable == null) { - throw new IllegalArgumentException("Hms table not exists, table: " + getNameWithFullQualifiers()); - } else { - if (supportedIcebergTable()) { - dlaType = DLAType.ICEBERG; - dlaTable = new IcebergDlaTable(this); - } else if (supportedHoodieTable()) { - dlaType = DLAType.HUDI; - dlaTable = new HudiDlaTable(this); - } else if (supportedHiveTable()) { - dlaType = DLAType.HIVE; - dlaTable = new HiveDlaTable(this); - } else { - // Should not reach here. Because `supportedHiveTable` will throw exception if not return true. - throw new NotSupportedException("Unsupported dlaType for table: " + getNameWithFullQualifiers()); - } - } - objectCreated = true; - } - } - - /** - * Now we only support cow table in iceberg. - */ - private boolean supportedIcebergTable() { - Map paras = remoteTable.getParameters(); - if (paras == null) { - return false; - } - return paras.containsKey("table_type") && paras.get("table_type").equalsIgnoreCase("ICEBERG"); - } - - /** - * `HoodieParquetInputFormat`: `Snapshot Queries` on cow and mor table and `Read Optimized Queries` on cow table - */ - private boolean supportedHoodieTable() { - if (remoteTable.getSd() == null) { - return false; - } - Map paras = remoteTable.getParameters(); - String inputFormatName = remoteTable.getSd().getInputFormat(); - // compatible with flink hive catalog - return (paras != null && "hudi".equalsIgnoreCase(paras.get("flink.connector"))) - || (inputFormatName != null && SUPPORTED_HUDI_FILE_FORMATS.contains(inputFormatName)); - } - - public boolean isHoodieCowTable() { - if (remoteTable.getSd() == null) { - return false; - } - String inputFormatName = remoteTable.getSd().getInputFormat(); - Map params = remoteTable.getParameters(); - return "org.apache.hudi.hadoop.HoodieParquetInputFormat".equals(inputFormatName) - || "skip_merge".equals(getCatalogProperties().get("hoodie.datasource.merge.type")) - || (params != null && "COPY_ON_WRITE".equalsIgnoreCase(params.get("flink.table.type"))); - } - - /** - * Some data lakes (such as Hudi) will synchronize their partition information to HMS, - * then we can quickly obtain the partition information of the table from HMS. - */ - public boolean useHiveSyncPartition() { - return Boolean.parseBoolean(catalog.getProperties().getOrDefault(USE_HIVE_SYNC_PARTITION, "false")); - } - - /** - * Now we only support three file input format hive tables: parquet/orc/text. - * Support managed_table and external_table. - */ - private boolean supportedHiveTable() { - // we will return false if null, which means that the table type maybe unsupported. - if (remoteTable.getSd() == null) { - throw new NotSupportedException("remote table's storage descriptor is null"); - } - // If this is hive view, no need to check file format. - if (remoteTable.isSetViewExpandedText() || remoteTable.isSetViewOriginalText()) { - return true; - } - String inputFileFormat = remoteTable.getSd().getInputFormat(); - if (inputFileFormat == null) { - throw new NotSupportedException("remote table's storage input format is null"); - } - boolean supportedFileFormat = SUPPORTED_HIVE_FILE_FORMATS.contains(inputFileFormat); - if (!supportedFileFormat) { - // for easier debugging, need return error message if unsupported input format is used. - // NotSupportedException is required by some operation. - throw new NotSupportedException("Unsupported hive input format: " + inputFileFormat); - } - if (LOG.isDebugEnabled()) { - LOG.debug("hms table {} is {} with file format: {}", name, remoteTable.getTableType(), inputFileFormat); - } - return true; - } - - /** - * Only support /orc/orc transactional/parquet table. - */ - public boolean supportedHiveTopNLazyTable() { - if (remoteTable.getSd() == null) { - return false; - } - - if (remoteTable.isSetViewExpandedText() || remoteTable.isSetViewOriginalText()) { - return false; - } - - String inputFileFormat = remoteTable.getSd().getInputFormat(); - if (inputFileFormat == null) { - return false; - } - return SUPPORTED_HIVE_TOPN_LAZY_FILE_FORMATS.contains(inputFileFormat); - } - - /** - * Get the related remote hive metastore table. - */ - public org.apache.hadoop.hive.metastore.api.Table getRemoteTable() { - makeSureInitialized(); - return remoteTable; - } - - @Override - public List getFullSchema() { - makeSureInitialized(); - if (getDlaType() == DLAType.HUDI) { - return ((HudiDlaTable) dlaTable).getHudiSchemaCacheValue(MvccUtil.getSnapshotFromContext(this)) - .getSchema(); - } else if (getDlaType() == DLAType.ICEBERG) { - return IcebergUtils.getIcebergSchema(this); - } - Optional schemaCacheValue = getSchemaCacheValue(); - return schemaCacheValue.map(SchemaCacheValue::getSchema).orElse(null); - } - - @Override - public Optional getSchemaCacheValue() { - makeSureInitialized(); - if (dlaType == DLAType.HUDI) { - return Optional.of( - ((HudiDlaTable) dlaTable).getHudiSchemaCacheValue(MvccUtil.getSnapshotFromContext(this))); - } else if (dlaType == DLAType.ICEBERG) { - IcebergSnapshotCacheValue snapshotValue = IcebergUtils.getSnapshotCacheValue( - MvccUtil.getSnapshotFromContext(this), this); - return Optional.of(IcebergUtils.getSchemaCacheValue(this, snapshotValue)); - } - return super.getSchemaCacheValue(); - } - - public List getPartitionColumnTypes(Optional snapshot) { - makeSureInitialized(); - if (getDlaType() == DLAType.HUDI) { - return ((HudiDlaTable) dlaTable).getHudiSchemaCacheValue(snapshot).getPartitionColTypes(); - } - Optional schemaCacheValue = getSchemaCacheValue(); - return schemaCacheValue.map(value -> ((HMSSchemaCacheValue) value).getPartitionColTypes()) - .orElse(Collections.emptyList()); - } - - public List getHudiPartitionColumnTypes(long timestamp) { - makeSureInitialized(); - Optional schemaCacheValue = Env.getCurrentEnv().getExtMetaCacheMgr() - .getSchemaCacheValue(this, new HudiSchemaCacheKey(getOrBuildNameMapping(), timestamp)); - return schemaCacheValue.map(value -> ((HMSSchemaCacheValue) value).getPartitionColTypes()) - .orElse(Collections.emptyList()); - } - - public List getPartitionColumns() { - return getPartitionColumns(MvccUtil.getSnapshotFromContext(this)); - } - - @Override - public List getPartitionColumns(Optional snapshot) { - makeSureInitialized(); - return dlaTable.getPartitionColumns(snapshot); - } - - @Override - public boolean supportInternalPartitionPruned() { - return getDlaType() == DLAType.HIVE || getDlaType() == DLAType.HUDI; - } - - @Override - public boolean supportsExternalMetadataPreload() { - return true; - } - - @Override - public boolean supportsLatestSnapshotPreload() { - // HMSExternalTable may represent Hive, Hudi, or Iceberg tables. - // Only snapshot-aware table types should preload latest snapshot metadata. - return getDlaType() == DLAType.HUDI || getDlaType() == DLAType.ICEBERG; - } - - @Override - public Optional> getSortedPartitionRanges(CatalogRelation scan) { - if (getDlaType() != DLAType.HIVE) { - return Optional.empty(); - } - if (CollectionUtils.isEmpty(this.getPartitionColumns())) { - return Optional.empty(); - } - HiveExternalMetaCache.HivePartitionValues hivePartitionValues = getHivePartitionValues( - MvccUtil.getSnapshotFromContext(this)); - return hivePartitionValues.getSortedPartitionRanges(); - } - - public SelectedPartitions initHudiSelectedPartitions(Optional tableSnapshot) { - if (getDlaType() != DLAType.HUDI) { - return SelectedPartitions.NOT_PRUNED; - } - - if (getPartitionColumns().isEmpty()) { - return SelectedPartitions.NOT_PRUNED; - } - TablePartitionValues tablePartitionValues = HudiUtils.getPartitionValues(tableSnapshot, this); - - Map idToPartitionItem = tablePartitionValues.getIdToPartitionItem(); - Map idToNameMap = tablePartitionValues.getPartitionIdToNameMap(); - - Map nameToPartitionItems = Maps.newHashMapWithExpectedSize(idToPartitionItem.size()); - for (Entry entry : idToPartitionItem.entrySet()) { - nameToPartitionItems.put(idToNameMap.get(entry.getKey()), entry.getValue()); - } - - return new SelectedPartitions(nameToPartitionItems.size(), nameToPartitionItems, false); - } - - @Override - public Map getNameToPartitionItems(Optional snapshot) { - return getNameToPartitionItems(); - } - - public Map getNameToPartitionItems() { - if (CollectionUtils.isEmpty(this.getPartitionColumns())) { - return Collections.emptyMap(); - } - HiveExternalMetaCache.HivePartitionValues hivePartitionValues = getHivePartitionValues( - MvccUtil.getSnapshotFromContext(this)); - return Maps.newHashMap(hivePartitionValues.getNameToPartitionItem()); - } - - public boolean isHiveTransactionalTable() { - return dlaType == DLAType.HIVE && AcidUtils.isTransactionalTable(remoteTable); - } - - private boolean isSupportedFullAcidTransactionalFileFormat() { - // Sometimes we meet "transactional" = "true" but format is parquet, which is not supported. - // So we need to check the input format for transactional table. - String inputFormatName = remoteTable.getSd().getInputFormat(); - return inputFormatName != null && SUPPORTED_HIVE_TRANSACTIONAL_FILE_FORMATS.contains(inputFormatName); - } - - public boolean isFullAcidTable() throws UserException { - if (dlaType == DLAType.HIVE && AcidUtils.isFullAcidTable(remoteTable)) { - if (!isSupportedFullAcidTransactionalFileFormat()) { - throw new UserException("This table is full Acid Table, but no Orc Format."); - } - return true; - } - return false; - } - - @Override - public boolean isView() { - makeSureInitialized(); - return remoteTable.isSetViewOriginalText() || remoteTable.isSetViewExpandedText(); - } - - @Override - public String getComment() { - return ""; - } - - @Override - public long getCreateTime() { - return 0; - } - - private long getRowCountFromExternalSource() { - long rowCount = UNKNOWN_ROW_COUNT; - try { - switch (dlaType) { - case HIVE: - rowCount = StatisticsUtil.getHiveRowCount(this); - break; - case ICEBERG: - rowCount = IcebergUtils.getIcebergRowCount(this); - break; - default: - if (LOG.isDebugEnabled()) { - LOG.debug("getRowCount for dlaType {} is not supported.", dlaType); - } - } - } catch (Exception e) { - LOG.info("Failed to get row count for table {}.{}.{}", getCatalog().getName(), getDbName(), getName(), e); - } - return rowCount > 0 ? rowCount : UNKNOWN_ROW_COUNT; - } - - @Override - public long getDataLength() { - return 0; - } - - @Override - public long getAvgRowLength() { - return 0; - } - - public long getLastCheckTime() { - return 0; - } - - /** - * get the dla type for scan node to get right information. - */ - public DLAType getDlaType() { - makeSureInitialized(); - return dlaType; - } - - @Override - public TTableDescriptor toThrift() { - List schema = getFullSchema(); - THiveTable tHiveTable = new THiveTable(dbName, name, new HashMap<>()); - TTableDescriptor tTableDescriptor = new TTableDescriptor(getId(), TTableType.HIVE_TABLE, schema.size(), 0, - getName(), dbName); - tTableDescriptor.setHiveTable(tHiveTable); - return tTableDescriptor; - } - - @Override - public BaseAnalysisTask createAnalysisTask(AnalysisInfo info) { - makeSureInitialized(); - return new HMSAnalysisTask(info); - } - - public String getViewText() { - String viewText = getViewExpandedText(); - if (StringUtils.isNotEmpty(viewText)) { - if (!viewText.equals("/* Presto View */")) { - return viewText; - } - } - - String originalText = getViewOriginalText(); - return parseTrinoViewDefinition(originalText); - } - - /** - * Parse Trino/Presto view definition from the original text. - * The definition is stored in the format: /* Presto View: * / - * - * The base64 encoded JSON contains the following fields: - * { - * "originalSql": "SELECT * FROM employees", // The original SQL statement - * "catalog": "hive", // The data catalog name - * "schema": "mmc_hive", // The schema name - * ... - * } - * - * @param originalText The original view definition text - * @return The parsed SQL statement, or original text if parsing fails - */ - private String parseTrinoViewDefinition(String originalText) { - if (originalText == null || !originalText.contains("/* Presto View: ")) { - return originalText; - } - - try { - String base64String = originalText.substring( - originalText.indexOf("/* Presto View: ") + "/* Presto View: ".length(), - originalText.lastIndexOf(" */") - ).trim(); - byte[] decodedBytes = Base64.getDecoder().decode(base64String); - String decodedString = new String(decodedBytes, StandardCharsets.UTF_8); - JsonObject jsonObject = new Gson().fromJson(decodedString, JsonObject.class); - - if (jsonObject.has("originalSql")) { - return jsonObject.get("originalSql").getAsString(); - } - } catch (Exception e) { - LOG.warn("Decoding Presto view definition failed", e); - } - return originalText; - } - - public String getViewExpandedText() { - if (LOG.isDebugEnabled()) { - LOG.debug("View expanded text of hms table [{}.{}.{}] : {}", - this.getCatalog().getName(), this.getDbName(), this.getName(), remoteTable.getViewExpandedText()); - } - return remoteTable.getViewExpandedText(); - } - - public String getViewOriginalText() { - if (LOG.isDebugEnabled()) { - LOG.debug("View original text of hms table [{}.{}.{}] : {}", - this.getCatalog().getName(), this.getDbName(), this.getName(), remoteTable.getViewOriginalText()); - } - return remoteTable.getViewOriginalText(); - } - - public Map getCatalogProperties() { - return catalog.getProperties(); - } - - public Map getStoragePropertiesMap() { - return catalog.getCatalogProperty().getStoragePropertiesMap(); - } - - public Map getBackendStorageProperties() { - return catalog.getCatalogProperty().getBackendStorageProperties(); - } - - public List getHiveTableColumnStats(List columns) { - HMSCachedClient client = ((HMSExternalCatalog) catalog).getClient(); - return client.getTableColumnStatistics(getRemoteDbName(), remoteName, columns); - } - - public Map> getHivePartitionColumnStats( - List partNames, List columns) { - HMSCachedClient client = ((HMSExternalCatalog) catalog).getClient(); - return client.getPartitionColumnStatistics(getRemoteDbName(), remoteName, partNames, columns); - } - - public Partition getPartition(List partitionValues) { - HMSCachedClient client = ((HMSExternalCatalog) catalog).getClient(); - return client.getPartition(getRemoteDbName(), remoteName, partitionValues); - } - - @Override - public Set getPartitionNames() { - makeSureInitialized(); - HMSCachedClient client = ((HMSExternalCatalog) catalog).getClient(); - List names = client.listPartitionNames(getRemoteDbName(), getRemoteName()); - return new HashSet<>(names); - } - - @Override - public Optional initSchemaAndUpdateTime(SchemaCacheKey key) { - Table table = loadHiveTable(); - // try to use transient_lastDdlTime from hms client - setUpdateTime(MapUtils.isNotEmpty(table.getParameters()) - && table.getParameters().containsKey(TBL_PROP_TRANSIENT_LAST_DDL_TIME) - ? Long.parseLong(table.getParameters().get(TBL_PROP_TRANSIENT_LAST_DDL_TIME)) * 1000 - // use current timestamp if lastDdlTime does not exist (hive views don't have this prop) - : System.currentTimeMillis()); - return initSchema(key); - } - - public long getLastDdlTime() { - makeSureInitialized(); - Map parameters = remoteTable.getParameters(); - if (parameters == null || !parameters.containsKey(TBL_PROP_TRANSIENT_LAST_DDL_TIME)) { - return 0L; - } - return Long.parseLong(parameters.get(TBL_PROP_TRANSIENT_LAST_DDL_TIME)) * 1000; - } - - @Override - public Optional initSchema(SchemaCacheKey key) { - makeSureInitialized(); - if (dlaType.equals(DLAType.ICEBERG)) { - return initIcebergSchema(key); - } else if (dlaType.equals(DLAType.HUDI)) { - return initHudiSchema(key); - } else { - return initHiveSchema(); - } - } - - private Optional initIcebergSchema(SchemaCacheKey key) { - return IcebergUtils.loadSchemaCacheValue( - this, ((IcebergSchemaCacheKey) key).getSchemaId(), isView()); - } - - private Optional initHudiSchema(SchemaCacheKey key) { - boolean[] enableSchemaEvolution = {false}; - HudiSchemaCacheKey hudiSchemaCacheKey = (HudiSchemaCacheKey) key; - InternalSchema hudiInternalSchema = HiveMetaStoreClientHelper.getHudiTableSchema(this, enableSchemaEvolution, - Long.toString(hudiSchemaCacheKey.getTimestamp())); - org.apache.avro.Schema hudiSchema = AvroInternalSchemaConverter.convert(hudiInternalSchema, name); - List tmpSchema = Lists.newArrayListWithCapacity(hudiSchema.getFields().size()); - List colTypes = Lists.newArrayList(); - for (int i = 0; i < hudiSchema.getFields().size(); i++) { - Types.Field hudiInternalfield = hudiInternalSchema.getRecord().fields().get(i); - org.apache.avro.Schema.Field hudiAvroField = hudiSchema.getFields().get(i); - String columnName = hudiAvroField.name().toLowerCase(Locale.ROOT); - Column column = new Column(columnName, HudiUtils.fromAvroHudiTypeToDorisType(hudiAvroField.schema()), - true, null, true, null, "", true, null, - -1, null); - HudiUtils.updateHudiColumnUniqueId(column, hudiInternalfield); - tmpSchema.add(column); - - colTypes.add(HudiUtils.convertAvroToHiveType(hudiAvroField.schema())); - } - List partitionColumns = initPartitionColumns(tmpSchema); - HudiSchemaCacheValue hudiSchemaCacheValue = - new HudiSchemaCacheValue(tmpSchema, partitionColumns, enableSchemaEvolution[0]); - hudiSchemaCacheValue.setColTypes(colTypes); - return Optional.of(hudiSchemaCacheValue); - } - - private Optional initHiveSchema() { - boolean getFromTable = catalog.getCatalogProperty() - .getOrDefault(HMSExternalCatalog.GET_SCHEMA_FROM_TABLE, "false") - .equalsIgnoreCase("true"); - List schema = null; - Map colDefaultValues = Maps.newHashMap(); - if (getFromTable) { - schema = getSchemaFromRemoteTable(); - } else { - HMSCachedClient client = ((HMSExternalCatalog) catalog).getClient(); - schema = client.getSchema(getRemoteDbName(), remoteName); - colDefaultValues = client.getDefaultColumnValues(getRemoteDbName(), remoteName); - } - List columns = Lists.newArrayListWithCapacity(schema.size()); - for (FieldSchema field : schema) { - String fieldName = field.getName().toLowerCase(Locale.ROOT); - String defaultValue = colDefaultValues.getOrDefault(fieldName, null); - columns.add(new Column(fieldName, - HiveMetaStoreClientHelper.hiveTypeToDorisType(field.getType(), catalog.getEnableMappingVarbinary(), - catalog.getEnableMappingTimestampTz()), - true, null, - true, defaultValue, field.getComment(), true, -1)); - } - List partitionColumns = initPartitionColumns(columns); - return Optional.of(new HMSSchemaCacheValue(columns, partitionColumns)); - } - - private List getSchemaFromRemoteTable() { - // Here we should get a new remote table instead of using this.remoteTable - // Because we need to get the latest schema from HMS. - Table newTable = loadHiveTable(); - List schema = Lists.newArrayList(); - schema.addAll(newTable.getSd().getCols()); - schema.addAll(newTable.getPartitionKeys()); - return schema; - } - - @Override - public long fetchRowCount() { - return fetchRowCountInternal(false); - } - - @Override - public long fetchRowCountWithMetaCache(boolean fillMetaCache) { - return fetchRowCountInternal(fillMetaCache); - } - - private long fetchRowCountInternal(boolean fillMetaCache) { - makeSureInitialized(); - // Get row count from hive metastore property. - long rowCount = getRowCountFromExternalSource(); - // Only hive table supports estimate row count by listing file. - if (rowCount == UNKNOWN_ROW_COUNT && dlaType.equals(DLAType.HIVE)) { - LOG.info("Will estimate row count for table {} from file list.", name); - rowCount = getRowCountFromFileList(fillMetaCache); - } - return rowCount; - } - - private List initPartitionColumns(List schema) { - // get table from remote, do not use `remoteTable` directly, - // because here we need to get schema from latest table info. - Table newTable = ((HMSExternalCatalog) catalog).getClient().getTable(dbName, name); - List partitionKeys = newTable.getPartitionKeys().stream().map(FieldSchema::getName) - .collect(Collectors.toList()); - List partitionColumns = Lists.newArrayListWithCapacity(partitionKeys.size()); - for (String partitionKey : partitionKeys) { - // Do not use "getColumn()", which will cause dead loop - for (Column column : schema) { - if (partitionKey.equalsIgnoreCase(column.getName())) { - // For partition column, if it is string type, change it to varchar(65535) - // to be same as doris managed table. - // This is to avoid some unexpected behavior such as different partition pruning result - // between doris managed table and external table. - if (column.getType().getPrimitiveType() == PrimitiveType.STRING) { - column.setType(ScalarType.createVarcharType(ScalarType.MAX_VARCHAR_LENGTH)); - } - partitionColumns.add(column); - break; - } - } - } - if (LOG.isDebugEnabled()) { - LOG.debug("get {} partition columns for table: {}", partitionColumns.size(), name); - } - return partitionColumns; - } - - public boolean hasColumnStatistics(String colName) { - Map parameters = remoteTable.getParameters(); - return parameters.keySet().stream().anyMatch(k -> k.startsWith(SPARK_COL_STATS + colName + ".")); - } - - public boolean fillColumnStatistics(String colName, Map statsTypes, Map stats) { - makeSureInitialized(); - if (!hasColumnStatistics(colName)) { - return false; - } - - Map parameters = remoteTable.getParameters(); - for (StatsType type : statsTypes.keySet()) { - String key = SPARK_COL_STATS + colName + MAP_SPARK_STATS_TO_DORIS.getOrDefault(type, "-"); - // 'NULL' should not happen, spark would have all type (except histogram) - stats.put(statsTypes.get(type), parameters.getOrDefault(key, "NULL")); - } - return true; - } - - @Override - public Optional getColumnStatistic(String colName) { - makeSureInitialized(); - switch (dlaType) { - case HIVE: - return getHiveColumnStats(colName); - case ICEBERG: - if (GlobalVariable.enableFetchIcebergStats) { - return StatisticsUtil.getIcebergColumnStats(colName, - IcebergUtils.getIcebergTable(this)); - } else { - break; - } - default: - LOG.warn("get column stats for dlaType {} is not supported.", dlaType); - } - return Optional.empty(); - } - - private Optional getHiveColumnStats(String colName) { - List tableStats = getHiveTableColumnStats(Lists.newArrayList(colName)); - if (tableStats == null || tableStats.isEmpty()) { - if (LOG.isDebugEnabled()) { - LOG.debug(String.format("No table stats found in Hive metastore for column %s in table %s.", - colName, name)); - } - return Optional.empty(); - } - - Column column = getColumn(colName); - if (column == null) { - LOG.warn(String.format("No column %s in table %s.", colName, name)); - return Optional.empty(); - } - Map parameters = remoteTable.getParameters(); - if (!parameters.containsKey(NUM_ROWS) || Long.parseLong(parameters.get(NUM_ROWS)) == 0) { - return Optional.empty(); - } - long count = Long.parseLong(parameters.get(NUM_ROWS)); - ColumnStatisticBuilder columnStatisticBuilder = new ColumnStatisticBuilder(count); - // The tableStats length is at most 1. - for (ColumnStatisticsObj tableStat : tableStats) { - if (!tableStat.isSetStatsData()) { - continue; - } - ColumnStatisticsData data = tableStat.getStatsData(); - setStatData(column, data, columnStatisticBuilder, count); - } - - return Optional.of(columnStatisticBuilder.build()); - } - - private void setStatData(Column col, ColumnStatisticsData data, ColumnStatisticBuilder builder, long count) { - long ndv = 0; - long nulls = 0; - double colSize = 0; - if (!data.isSetStringStats()) { - colSize = count * col.getType().getSlotSize(); - } - // Collect ndv, nulls, min and max for different data type. - if (data.isSetLongStats()) { - LongColumnStatsData longStats = data.getLongStats(); - ndv = longStats.getNumDVs(); - nulls = longStats.getNumNulls(); - } else if (data.isSetStringStats()) { - StringColumnStatsData stringStats = data.getStringStats(); - ndv = stringStats.getNumDVs(); - nulls = stringStats.getNumNulls(); - double avgColLen = stringStats.getAvgColLen(); - colSize = Math.round(avgColLen * count); - } else if (data.isSetDecimalStats()) { - DecimalColumnStatsData decimalStats = data.getDecimalStats(); - ndv = decimalStats.getNumDVs(); - nulls = decimalStats.getNumNulls(); - } else if (data.isSetDoubleStats()) { - DoubleColumnStatsData doubleStats = data.getDoubleStats(); - ndv = doubleStats.getNumDVs(); - nulls = doubleStats.getNumNulls(); - } else if (data.isSetDateStats()) { - DateColumnStatsData dateStats = data.getDateStats(); - ndv = dateStats.getNumDVs(); - nulls = dateStats.getNumNulls(); - } else { - LOG.warn(String.format("Not suitable data type for column %s", col.getName())); - } - builder.setNdv(ndv); - builder.setNumNulls(nulls); - builder.setDataSize(colSize); - builder.setAvgSizeByte(colSize / count); - builder.setMinValue(Double.NEGATIVE_INFINITY); - builder.setMaxValue(Double.POSITIVE_INFINITY); - } - - @Override - public void gsonPostProcess() throws IOException { - super.gsonPostProcess(); - } - - @Override - public List getChunkSizes() { - HiveExternalMetaCache.HivePartitionValues partitionValues = getAllPartitionValues(); - List filesByPartitions = getFilesForPartitions(partitionValues, 0, false); - List result = Lists.newArrayList(); - for (HiveExternalMetaCache.FileCacheValue files : filesByPartitions) { - for (HiveExternalMetaCache.HiveFileStatus file : files.getFiles()) { - result.add(file.getLength()); - } - } - return result; - } - - @Override - public long getDataSize(boolean singleReplica) { - long totalSize = StatisticsUtil.getTotalSizeFromHMS(this); - // Usually, we can get total size from HMS parameter. - if (totalSize > 0) { - return totalSize; - } - // If not found the size in HMS, calculate it by sum all files' size in table. - List chunkSizes = getChunkSizes(); - long total = 0; - for (long size : chunkSizes) { - total += size; - } - return total; - } - - @Override - public Set getDistributionColumnNames() { - return getRemoteTable().getSd().getBucketCols().stream().map(String::toLowerCase) - .collect(Collectors.toSet()); - } - - @Override - public PartitionType getPartitionType(Optional snapshot) { - makeSureInitialized(); - return dlaTable.getPartitionType(snapshot); - } - - public Set getPartitionColumnNames() { - return getPartitionColumnNames(MvccUtil.getSnapshotFromContext(this)); - } - - @Override - public Set getPartitionColumnNames(Optional snapshot) { - makeSureInitialized(); - return dlaTable.getPartitionColumnNames(snapshot); - } - - @Override - public Map getAndCopyPartitionItems(Optional snapshot) - throws AnalysisException { - makeSureInitialized(); - return dlaTable.getAndCopyPartitionItems(snapshot); - } - - @Override - public MTMVSnapshotIf getPartitionSnapshot(String partitionName, MTMVRefreshContext context, - Optional snapshot) throws AnalysisException { - makeSureInitialized(); - return dlaTable.getPartitionSnapshot(partitionName, context, snapshot); - } - - @Override - public MTMVSnapshotIf getTableSnapshot(MTMVRefreshContext context, Optional snapshot) - throws AnalysisException { - makeSureInitialized(); - return dlaTable.getTableSnapshot(context, snapshot); - } - - @Override - public MTMVSnapshotIf getTableSnapshot(Optional snapshot) throws AnalysisException { - makeSureInitialized(); - return dlaTable.getTableSnapshot(snapshot); - } - - @Override - public long getNewestUpdateVersionOrTime() { - HiveExternalMetaCache.HivePartitionValues hivePartitionValues = getHivePartitionValues( - MvccUtil.getSnapshotFromContext(this)); - HiveExternalMetaCache cache = Env.getCurrentEnv().getExtMetaCacheMgr() - .hive(getCatalog().getId()); - List partitionList = cache.getAllPartitionsWithCache(this, - Lists.newArrayList(hivePartitionValues.getNameToPartitionValues().values())); - if (CollectionUtils.isEmpty(partitionList)) { - return 0; - } - return partitionList.stream().mapToLong(HivePartition::getLastModifiedTime).max().orElse(0); - } - - @Override - public boolean isPartitionColumnAllowNull() { - makeSureInitialized(); - return dlaTable.isPartitionColumnAllowNull(); - } - - /** - * Estimate hive table row count : totalFileSize/estimatedRowSize - */ - private long getRowCountFromFileList(boolean fillMetaCache) { - if (!GlobalVariable.enable_get_row_count_from_file_list) { - return UNKNOWN_ROW_COUNT; - } - if (isView()) { - LOG.info("Table {} is view, return -1.", name); - return UNKNOWN_ROW_COUNT; - } - long rows = UNKNOWN_ROW_COUNT; - try { - HiveExternalMetaCache.HivePartitionValues partitionValues = getAllPartitionValues(); - // Get files for all partitions. - int samplePartitionSize = Config.hive_stats_partition_sample_size; - List filesByPartitions = - getFilesForPartitions(partitionValues, samplePartitionSize, fillMetaCache); - LOG.info("Number of files selected for hive table {} is {}", name, filesByPartitions.size()); - long totalSize = 0; - // Calculate the total file size. - for (HiveExternalMetaCache.FileCacheValue files : filesByPartitions) { - for (HiveExternalMetaCache.HiveFileStatus file : files.getFiles()) { - totalSize += file.getLength(); - } - } - // Estimate row count: totalSize/estimatedRowSize - long estimatedRowSize = 0; - List partitionColumns = getPartitionColumns(); - for (Column column : getFullSchema()) { - // Partition column shouldn't count to the row size, because it is not in the data file. - if (partitionColumns != null && partitionColumns.contains(column)) { - continue; - } - estimatedRowSize += column.getDataType().getSlotSize(); - } - if (estimatedRowSize == 0) { - LOG.warn("Table {} estimated size is 0, return -1.", name); - return UNKNOWN_ROW_COUNT; - } - - int totalPartitionSize = partitionValues == null ? 1 : partitionValues.getNameToPartitionItem().size(); - if (samplePartitionSize != 0 && samplePartitionSize < totalPartitionSize) { - LOG.info("Table {} sampled {} of {} partitions, sampled size is {}", - name, samplePartitionSize, totalPartitionSize, totalSize); - totalSize = totalSize * totalPartitionSize / samplePartitionSize; - } - rows = totalSize / estimatedRowSize; - LOG.info("Table {} rows {}, total size is {}, estimatedRowSize is {}", - name, rows, totalSize, estimatedRowSize); - } catch (Exception e) { - LOG.info("Failed to get row count for table {}.{}.{}", getCatalog().getName(), getDbName(), getName(), e); - } - return rows > 0 ? rows : UNKNOWN_ROW_COUNT; - } - - // Get all partition values from cache. - private HiveExternalMetaCache.HivePartitionValues getAllPartitionValues() { - if (isView()) { - return null; - } - Optional snapshot = MvccUtil.getSnapshotFromContext(this); - List partitionColumnTypes = getPartitionColumnTypes(snapshot); - HiveExternalMetaCache.HivePartitionValues partitionValues = null; - // Get table partitions from cache. - if (!partitionColumnTypes.isEmpty()) { - // It is ok to get partition values from cache, - // no need to worry that this call will invalid or refresh the cache. - // because it has enough space to keep partition info of all tables in cache. - partitionValues = getHivePartitionValues(snapshot); - if (partitionValues == null || partitionValues.getNameToPartitionItem() == null) { - LOG.warn("Partition values for hive table {} is null", name); - } else { - LOG.info("Partition values size for hive table {} is {}", - name, partitionValues.getNameToPartitionItem().size()); - } - } - return partitionValues; - } - - // Get all files related to given partition values - // If sampleSize > 0, randomly choose part of partitions of the whole table. - private List getFilesForPartitions( - HiveExternalMetaCache.HivePartitionValues partitionValues, int sampleSize, boolean fillMetaCache) { - if (isView()) { - return Lists.newArrayList(); - } - HiveExternalMetaCache cache = Env.getCurrentEnv().getExtMetaCacheMgr() - .hive(getCatalog().getId()); - List hivePartitions = Lists.newArrayList(); - if (partitionValues != null) { - Map nameToPartitionItem = partitionValues.getNameToPartitionItem(); - int totalPartitionSize = nameToPartitionItem.size(); - Collection partitionItems; - List> partitionValuesList; - // If partition number is too large, randomly choose part of them to estimate the whole table. - if (sampleSize > 0 && sampleSize < totalPartitionSize) { - List items = new ArrayList<>(nameToPartitionItem.values()); - Collections.shuffle(items); - partitionItems = items.subList(0, sampleSize); - partitionValuesList = Lists.newArrayListWithCapacity(sampleSize); - } else { - partitionItems = nameToPartitionItem.values(); - partitionValuesList = Lists.newArrayListWithCapacity(totalPartitionSize); - } - for (PartitionItem item : partitionItems) { - partitionValuesList.add(((ListPartitionItem) item).getItems().get(0).getPartitionValuesAsStringList()); - } - // Non-query requests such as `show table status` should not fill heavy metadata caches. - hivePartitions = fillMetaCache - ? cache.getAllPartitionsWithCache(this, partitionValuesList) - : cache.getAllPartitionsWithoutCache(this, partitionValuesList); - LOG.info("Partition list size for hive partition table {} is {}", name, hivePartitions.size()); - } else { - hivePartitions.add(new HivePartition(getOrBuildNameMapping(), true, - getRemoteTable().getSd().getInputFormat(), - getRemoteTable().getSd().getLocation(), null, Maps.newHashMap())); - } - // Get files for all partitions. - if (LOG.isDebugEnabled()) { - for (HivePartition partition : hivePartitions) { - LOG.debug("Chosen partition for table {}. [{}]", name, partition.toString()); - } - } - return cache.getFilesByPartitions(hivePartitions, fillMetaCache, true, new FileSystemDirectoryLister(), null); - } - - @Override - public boolean isPartitionedTable() { - makeSureInitialized(); - return !isView() && remoteTable.getPartitionKeysSize() > 0; - } - - @Override - public void beforeMTMVRefresh(MTMV mtmv) throws DdlException { - } - - @Override - public MvccSnapshot loadSnapshot(Optional tableSnapshot, Optional scanParams) { - if (getDlaType() == DLAType.HUDI) { - return HudiUtils.getHudiMvccSnapshot(tableSnapshot, this); - } else if (getDlaType() == DLAType.ICEBERG) { - return new IcebergMvccSnapshot( - IcebergUtils.getSnapshotCacheValue(tableSnapshot, this, scanParams)); - } else { - return new EmptyMvccSnapshot(); - } - } - - public boolean firstColumnIsString() { - List columns = getColumns(); - if (columns == null || columns.isEmpty()) { - return false; - } - return columns.get(0).getType().isScalarType(PrimitiveType.STRING); - } - - public HoodieTableMetaClient getHudiClient() { - return Env.getCurrentEnv() - .getExtMetaCacheMgr() - .hudi(getCatalog().getId()) - .getHoodieTableMetaClient(getOrBuildNameMapping()); - } - - public boolean isValidRelatedTable() { - makeSureInitialized(); - return dlaTable.isValidRelatedTable(); - } - - @Override - public Map getSupportedSysTables() { - makeSureInitialized(); - switch (dlaType) { - case HIVE: - return PartitionsSysTable.HIVE_SUPPORTED_SYS_TABLES; - case ICEBERG: - return IcebergSysTable.SUPPORTED_SYS_TABLES; - case HUDI: - return Collections.emptyMap(); - default: - return Collections.emptyMap(); - } - } - - public TFileFormatType getFileFormatType(SessionVariable sessionVariable) throws UserException { - TFileFormatType type = null; - Table table = getRemoteTable(); - // now hive self only support mixed with orc/parquet files in table and different partitions - // But if mixed with orc/parquet files in table and same partition, will failed when read. - // now here hive used table format, so BE will regrard all files in table is same format. - String inputFormatName = table.getSd().getInputFormat(); - String hiveFormat = HiveMetaStoreClientHelper.HiveFileFormat.getFormat(inputFormatName); - if (hiveFormat.equals(HiveMetaStoreClientHelper.HiveFileFormat.PARQUET.getDesc())) { - type = TFileFormatType.FORMAT_PARQUET; - } else if (hiveFormat.equals(HiveMetaStoreClientHelper.HiveFileFormat.ORC.getDesc())) { - type = TFileFormatType.FORMAT_ORC; - } else if (hiveFormat.equals(HiveMetaStoreClientHelper.HiveFileFormat.TEXT_FILE.getDesc())) { - String serDeLib = table.getSd().getSerdeInfo().getSerializationLib(); - if (serDeLib.equals(HiveMetaStoreClientHelper.HIVE_JSON_SERDE) - || serDeLib.equals(HiveMetaStoreClientHelper.LEGACY_HIVE_JSON_SERDE)) { - type = TFileFormatType.FORMAT_JSON; - } else if (serDeLib.equals(HiveMetaStoreClientHelper.OPENX_JSON_SERDE)) { - if (!sessionVariable.isReadHiveJsonInOneColumn()) { - type = TFileFormatType.FORMAT_JSON; - } else if (sessionVariable.isReadHiveJsonInOneColumn() && firstColumnIsString()) { - type = TFileFormatType.FORMAT_CSV_PLAIN; - } else { - throw new UserException("You set read_hive_json_in_one_column = true, but the first column of " - + "table " + getName() - + " is not a string column."); - } - } else if (serDeLib.equals(HiveMetaStoreClientHelper.HIVE_TEXT_SERDE)) { - type = TFileFormatType.FORMAT_TEXT; - } else if (serDeLib.equals(HiveMetaStoreClientHelper.HIVE_OPEN_CSV_SERDE)) { - type = TFileFormatType.FORMAT_CSV_PLAIN; - } else if (serDeLib.equals(HiveMetaStoreClientHelper.HIVE_MULTI_DELIMIT_SERDE)) { - type = TFileFormatType.FORMAT_TEXT; - } else { - throw new UserException("Unsupported hive table serde: " + serDeLib); - } - } - return type; - } - - - private Table loadHiveTable() { - HMSCachedClient client = ((HMSExternalCatalog) catalog).getClient(); - return client.getTable(getRemoteDbName(), remoteName); - } - - public HiveExternalMetaCache.HivePartitionValues getHivePartitionValues(Optional snapshot) { - long startTime = System.currentTimeMillis(); - HiveExternalMetaCache cache = Env.getCurrentEnv().getExtMetaCacheMgr() - .hive(getCatalog().getId()); - try { - List partitionColumnTypes = this.getPartitionColumnTypes(snapshot); - HiveExternalMetaCache.HivePartitionValues partitionValues = cache.getPartitionValues(this, - partitionColumnTypes); - SummaryProfile summaryProfile = SummaryProfile.getSummaryProfile(null); - if (summaryProfile != null) { - summaryProfile.addExternalTableGetPartitionValuesTime(System.currentTimeMillis() - startTime); - } - return partitionValues; - } catch (Exception e) { - if (e.getMessage().contains(HiveExternalMetaCache.ERR_CACHE_INCONSISTENCY)) { - LOG.warn("Hive metastore cache inconsistency detected for table: {}.{}.{}. " - + "Clearing cache and retrying to get partition values.", - this.getCatalog().getName(), this.getDbName(), this.getName(), e); - Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTableByEngine( - getCatalog().getId(), getMetaCacheEngine(), getDbName(), getName()); - List partitionColumnTypes = this.getPartitionColumnTypes(snapshot); - HiveExternalMetaCache.HivePartitionValues partitionValues = cache.getPartitionValues(this, - partitionColumnTypes); - SummaryProfile summaryProfile = SummaryProfile.getSummaryProfile(null); - if (summaryProfile != null) { - summaryProfile.addExternalTableGetPartitionValuesTime(System.currentTimeMillis() - startTime); - } - return partitionValues; - } else { - throw e; - } - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSSchemaCacheValue.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSSchemaCacheValue.java deleted file mode 100644 index 79631e90db0989..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSSchemaCacheValue.java +++ /dev/null @@ -1,43 +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.datasource.hive; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Type; -import org.apache.doris.datasource.SchemaCacheValue; - -import java.util.List; -import java.util.stream.Collectors; - -public class HMSSchemaCacheValue extends SchemaCacheValue { - - private List partitionColumns; - - public HMSSchemaCacheValue(List schema, List partitionColumns) { - super(schema); - this.partitionColumns = partitionColumns; - } - - public List getPartitionColumns() { - return partitionColumns; - } - - public List getPartitionColTypes() { - return partitionColumns.stream().map(Column::getType).collect(Collectors.toList()); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSTransaction.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSTransaction.java deleted file mode 100644 index 0fa6a21f59efc3..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSTransaction.java +++ /dev/null @@ -1,1880 +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. -// This file is copied from -// https://github.com/trinodb/trino/blob/438/plugin/trino-hive/src/main/java/io/trino/plugin/hive/metastore/SemiTransactionalHiveMetastore.java -// https://github.com/trinodb/trino/blob/438/plugin/trino-hive/src/main/java/io/trino/plugin/hive/HiveMetadata.java -// and modified by Doris - -package org.apache.doris.datasource.hive; - -import org.apache.doris.common.Pair; -import org.apache.doris.common.profile.SummaryProfile; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.statistics.CommonStatistics; -import org.apache.doris.filesystem.FileEntry; -import org.apache.doris.filesystem.FileSystem; -import org.apache.doris.filesystem.FileSystemUtil; -import org.apache.doris.filesystem.Location; -import org.apache.doris.filesystem.spi.ObjFileSystem; -import org.apache.doris.foundation.util.PathUtils; -import org.apache.doris.fs.SpiSwitchingFileSystem; -import org.apache.doris.nereids.trees.plans.commands.insert.HiveInsertCommandContext; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.thrift.TFileType; -import org.apache.doris.thrift.THiveLocationParams; -import org.apache.doris.thrift.THivePartitionUpdate; -import org.apache.doris.thrift.TS3MPUPendingUpload; -import org.apache.doris.thrift.TUpdateMode; -import org.apache.doris.transaction.Transaction; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Joiner; -import com.google.common.base.MoreObjects; -import com.google.common.base.Preconditions; -import com.google.common.base.Strings; -import com.google.common.base.Verify; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Iterables; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import io.airlift.concurrent.MoreFutures; -import org.apache.hadoop.fs.Path; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.StorageDescriptor; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.net.URI; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Queue; -import java.util.Set; -import java.util.StringJoiner; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.Executor; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.stream.Collectors; - -public class HMSTransaction implements Transaction { - private static final Logger LOG = LogManager.getLogger(HMSTransaction.class); - private final HiveMetadataOps hiveOps; - private final FileSystem fs; - private Optional summaryProfile = Optional.empty(); - private String queryId; - private boolean isOverwrite = false; - TFileType fileType; - - private final Map> tableActions = new HashMap<>(); - private final Map, Action>> - partitionActions = new HashMap<>(); - private final Map> tableColumns = new HashMap<>(); - - private final Executor fileSystemExecutor; - private HmsCommitter hmsCommitter; - private List hivePartitionUpdates = Lists.newArrayList(); - private Optional stagingDirectory; - private boolean isMockedPartitionUpdate = false; - - private static class UncompletedMpuPendingUpload { - - private final TS3MPUPendingUpload s3MPUPendingUpload; - private final String path; - - public UncompletedMpuPendingUpload(TS3MPUPendingUpload s3MPUPendingUpload, String path) { - this.s3MPUPendingUpload = s3MPUPendingUpload; - this.path = path; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - UncompletedMpuPendingUpload that = (UncompletedMpuPendingUpload) o; - return Objects.equals(s3MPUPendingUpload, that.s3MPUPendingUpload) && Objects.equals(path, - that.path); - } - - @Override - public int hashCode() { - return Objects.hash(s3MPUPendingUpload, path); - } - } - - private final Set uncompletedMpuPendingUploads = new HashSet<>(); - - public HMSTransaction(HiveMetadataOps hiveOps, SpiSwitchingFileSystem fileSystem, - Executor fileSystemExecutor) { - this.hiveOps = hiveOps; - this.fs = fileSystem; - if (ConnectContext.get().getExecutor() != null) { - summaryProfile = Optional.of(ConnectContext.get().getExecutor().getSummaryProfile()); - } - this.fileSystemExecutor = fileSystemExecutor; - } - - @Override - public void commit() { - doCommit(); - } - - public List mergePartitions(List hivePUs) { - Map mm = new HashMap<>(); - for (THivePartitionUpdate pu : hivePUs) { - if (mm.containsKey(pu.getName())) { - THivePartitionUpdate old = mm.get(pu.getName()); - old.setFileSize(old.getFileSize() + pu.getFileSize()); - old.setRowCount(old.getRowCount() + pu.getRowCount()); - if (old.getS3MpuPendingUploads() != null && pu.getS3MpuPendingUploads() != null) { - old.getS3MpuPendingUploads().addAll(pu.getS3MpuPendingUploads()); - } - old.getFileNames().addAll(pu.getFileNames()); - } else { - mm.put(pu.getName(), pu); - } - } - return new ArrayList<>(mm.values()); - } - - private void collectUncompletedMpuPendingUploads(List hivePUs) { - for (THivePartitionUpdate pu : hivePUs) { - if (pu.getS3MpuPendingUploads() != null) { - for (TS3MPUPendingUpload s3MPUPendingUpload : pu.getS3MpuPendingUploads()) { - uncompletedMpuPendingUploads.add( - new UncompletedMpuPendingUpload(s3MPUPendingUpload, pu.getLocation().getWritePath())); - } - } - } - } - - @Override - public void rollback() { - if (hmsCommitter == null) { - collectUncompletedMpuPendingUploads(hivePartitionUpdates); - if (uncompletedMpuPendingUploads.isEmpty()) { - return; - } - hmsCommitter = new HmsCommitter(); - try { - hmsCommitter.rollback(); - } finally { - hmsCommitter.shutdownExecutorService(); - } - return; - } - try { - hmsCommitter.abort(); - hmsCommitter.rollback(); - } finally { - hmsCommitter.shutdownExecutorService(); - } - } - - public void beginInsertTable(HiveInsertCommandContext ctx) { - queryId = ctx.getQueryId(); - isOverwrite = ctx.isOverwrite(); - fileType = ctx.getFileType(); - if (fileType == TFileType.FILE_S3) { - stagingDirectory = Optional.empty(); - } else { - stagingDirectory = Optional.of(ctx.getWritePath()); - } - } - - public void finishInsertTable(NameMapping nameMapping) { - Table table = getTable(nameMapping); - if (hivePartitionUpdates.isEmpty() && isOverwrite && table.getPartitionKeysSize() == 0) { - // use an empty hivePartitionUpdate to clean source table - isMockedPartitionUpdate = true; - THivePartitionUpdate emptyUpdate = new THivePartitionUpdate() {{ - setUpdateMode(TUpdateMode.OVERWRITE); - setFileSize(0); - setRowCount(0); - setFileNames(Collections.emptyList()); - if (fileType == TFileType.FILE_S3) { - setS3MpuPendingUploads(Lists.newArrayList(new TS3MPUPendingUpload())); - setLocation(new THiveLocationParams() {{ - setWritePath(table.getSd().getLocation()); - } - }); - } else { - stagingDirectory.ifPresent((v) -> { - try { - fs.mkdirs(Location.of(v)); - } catch (java.io.IOException e) { - throw new RuntimeException("Failed to create staging directory: " + v, e); - } - setLocation(new THiveLocationParams() {{ - setWritePath(v); - } - }); - }); - } - } - }; - hivePartitionUpdates = Lists.newArrayList(emptyUpdate); - } - - List mergedPUs = mergePartitions(hivePartitionUpdates); - collectUncompletedMpuPendingUploads(mergedPUs); - List> insertExistsPartitions = new ArrayList<>(); - for (THivePartitionUpdate pu : mergedPUs) { - TUpdateMode updateMode = pu.getUpdateMode(); - HivePartitionStatistics hivePartitionStatistics = HivePartitionStatistics.fromCommonStatistics( - pu.getRowCount(), - pu.getFileNamesSize(), - pu.getFileSize()); - String writePath = pu.getLocation().getWritePath(); - if (table.getPartitionKeysSize() == 0) { - Preconditions.checkArgument(mergedPUs.size() == 1, - "When updating a non-partitioned table, multiple partitions should not be written"); - switch (updateMode) { - case APPEND: - finishChangingExistingTable( - ActionType.INSERT_EXISTING, - nameMapping, - writePath, - pu.getFileNames(), - hivePartitionStatistics, - pu); - break; - case OVERWRITE: - dropTable(nameMapping); - createTable(nameMapping, table, writePath, pu.getFileNames(), hivePartitionStatistics, pu); - break; - default: - throw new RuntimeException("Not support mode:[" + updateMode + "] in unPartitioned table"); - } - } else { - switch (updateMode) { - case APPEND: - // insert into existing partition - insertExistsPartitions.add(Pair.of(pu, hivePartitionStatistics)); - break; - case NEW: - // Check if partition really exists in HMS (may be cache miss in Doris) - String partitionName = pu.getName(); - if (Strings.isNullOrEmpty(partitionName)) { - // This should not happen for partitioned tables - LOG.warn("Partition name is null/empty for NEW mode in partitioned table, skipping"); - break; - } - List partitionValues = HiveUtil.toPartitionValues(partitionName); - boolean existsInHMS = false; - try { - Partition hmsPartition = hiveOps.getClient().getPartition( - nameMapping.getRemoteDbName(), - nameMapping.getRemoteTblName(), - partitionValues); - existsInHMS = (hmsPartition != null); - } catch (Exception e) { - // Partition not found in HMS, treat as truly new - if (LOG.isDebugEnabled()) { - LOG.debug("Partition {} not found in HMS, will create it", pu.getName()); - } - } - - if (existsInHMS) { - // Partition exists in HMS but not in Doris cache - // Treat as APPEND instead of NEW to avoid creation error - LOG.info("Partition {} already exists in HMS (Doris cache miss), treating as APPEND", - pu.getName()); - insertExistsPartitions.add(Pair.of(pu, hivePartitionStatistics)); - } else { - // Truly new partition, create it - createAndAddPartition(nameMapping, table, partitionValues, writePath, - pu, hivePartitionStatistics, false); - } - break; - case OVERWRITE: - String overwritePartitionName = pu.getName(); - if (Strings.isNullOrEmpty(overwritePartitionName)) { - LOG.warn("Partition name is null/empty for OVERWRITE mode in partitioned table, skipping"); - break; - } - createAndAddPartition(nameMapping, table, HiveUtil.toPartitionValues(overwritePartitionName), - writePath, pu, hivePartitionStatistics, true); - break; - default: - throw new RuntimeException("Not support mode:[" + updateMode + "] in partitioned table"); - } - } - } - - if (!insertExistsPartitions.isEmpty()) { - convertToInsertExistingPartitionAction(nameMapping, insertExistsPartitions); - } - } - - public void doCommit() { - hmsCommitter = new HmsCommitter(); - - try { - for (Map.Entry> entry : tableActions.entrySet()) { - NameMapping nameMapping = entry.getKey(); - Action action = entry.getValue(); - switch (action.getType()) { - case INSERT_EXISTING: - hmsCommitter.prepareInsertExistingTable(nameMapping, action.getData()); - break; - case ALTER: - hmsCommitter.prepareAlterTable(nameMapping, action.getData()); - break; - default: - throw new UnsupportedOperationException("Unsupported table action type: " + action.getType()); - } - } - - for (Map.Entry, Action>> tableEntry - : partitionActions.entrySet()) { - NameMapping nameMapping = tableEntry.getKey(); - for (Map.Entry, Action> partitionEntry : - tableEntry.getValue().entrySet()) { - Action action = partitionEntry.getValue(); - switch (action.getType()) { - case INSERT_EXISTING: - hmsCommitter.prepareInsertExistPartition(nameMapping, action.getData()); - break; - case ADD: - hmsCommitter.prepareAddPartition(nameMapping, action.getData()); - break; - case ALTER: - hmsCommitter.prepareAlterPartition(nameMapping, action.getData()); - break; - default: - throw new UnsupportedOperationException( - "Unsupported partition action type: " + action.getType()); - } - } - } - - hmsCommitter.doCommit(); - } catch (Throwable t) { - LOG.warn("Failed to commit for {}, abort it.", queryId); - try { - hmsCommitter.abort(); - hmsCommitter.rollback(); - } catch (RuntimeException e) { - t.addSuppressed(new Exception("Failed to roll back after commit failure", e)); - } - throw t; - } finally { - hmsCommitter.runClearPathsForFinish(); - hmsCommitter.shutdownExecutorService(); - } - } - - public void updateHivePartitionUpdates(List pus) { - synchronized (this) { - hivePartitionUpdates.addAll(pus); - } - } - - // for test - public void setHivePartitionUpdates(List hivePartitionUpdates) { - this.hivePartitionUpdates = hivePartitionUpdates; - } - - public long getUpdateCnt() { - return hivePartitionUpdates.stream().mapToLong(THivePartitionUpdate::getRowCount).sum(); - } - - public List getHivePartitionUpdates() { - return hivePartitionUpdates; - } - - private void convertToInsertExistingPartitionAction( - NameMapping nameMapping, - List> partitions) { - Map, Action> partitionActionsForTable = - partitionActions.computeIfAbsent(nameMapping, k -> new HashMap<>()); - - for (List> partitionBatch : - Iterables.partition(partitions, 100)) { - - List partitionNames = partitionBatch - .stream() - .map(pair -> pair.first.getName()) - .collect(Collectors.toList()); - - // check in partitionAction - Action oldPartitionAction = partitionActionsForTable.get(partitionNames); - if (oldPartitionAction != null) { - switch (oldPartitionAction.getType()) { - case DROP: - case DROP_PRESERVE_DATA: - throw new RuntimeException( - "Not found partition from partition actions" - + "for " + nameMapping.getFullLocalName() + ", partitions: " + partitionNames); - case ADD: - case ALTER: - case INSERT_EXISTING: - case MERGE: - throw new UnsupportedOperationException( - "Inserting into a partition that were added, altered," - + "or inserted into in the same transaction is not supported"); - default: - throw new IllegalStateException("Unknown action type: " + oldPartitionAction.getType()); - } - } - - Map partitionsByNamesMap = HiveUtil.convertToNamePartitionMap( - partitionNames, - hiveOps.getClient().getPartitions(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), - partitionNames)); - - for (int i = 0; i < partitionsByNamesMap.size(); i++) { - String partitionName = partitionNames.get(i); - // check from hms - Partition partition = partitionsByNamesMap.get(partitionName); - if (partition == null) { - // Prevent this partition from being deleted by other engines - throw new RuntimeException( - "Not found partition from hms for " + nameMapping.getFullLocalName() - + ", partitions: " + partitionNames); - } - THivePartitionUpdate pu = partitionBatch.get(i).first; - HivePartitionStatistics updateStats = partitionBatch.get(i).second; - - StorageDescriptor sd = partition.getSd(); - List partitionValues = HiveUtil.toPartitionValues(pu.getName()); - - HivePartition hivePartition = new HivePartition( - nameMapping, - false, - sd.getInputFormat(), - partition.getSd().getLocation(), - partitionValues, - partition.getParameters(), - sd.getOutputFormat(), - sd.getSerdeInfo().getSerializationLib(), - sd.getCols() - ); - - partitionActionsForTable.put( - partitionValues, - new Action<>( - ActionType.INSERT_EXISTING, - new PartitionAndMore( - hivePartition, - pu.getLocation().getWritePath(), - pu.getName(), - pu.getFileNames(), - updateStats, - pu - )) - ); - } - } - } - - private static void addSuppressedExceptions( - List suppressedExceptions, - Throwable t, - List descriptions, - String description) { - descriptions.add(description); - // A limit is needed to avoid having a huge exception object. 5 was chosen arbitrarily. - if (suppressedExceptions.size() < 5) { - suppressedExceptions.add(t); - } - } - - public static class UpdateStatisticsTask { - private final NameMapping nameMapping; - private final Optional partitionName; - private final HivePartitionStatistics updatePartitionStat; - private final boolean merge; - - private boolean done; - - public UpdateStatisticsTask(NameMapping nameMapping, Optional partitionName, - HivePartitionStatistics statistics, boolean merge) { - this.nameMapping = Objects.requireNonNull(nameMapping, "nameMapping is null"); - this.partitionName = Objects.requireNonNull(partitionName, "partitionName is null"); - this.updatePartitionStat = Objects.requireNonNull(statistics, "statistics is null"); - this.merge = merge; - } - - public void run(HiveMetadataOps hiveOps) { - if (partitionName.isPresent()) { - hiveOps.updatePartitionStatistics(nameMapping, partitionName.get(), this::updateStatistics); - } else { - hiveOps.updateTableStatistics(nameMapping, this::updateStatistics); - } - done = true; - } - - public void undo(HiveMetadataOps hmsOps) { - if (!done) { - return; - } - if (partitionName.isPresent()) { - hmsOps.updatePartitionStatistics(nameMapping, partitionName.get(), this::resetStatistics); - } else { - hmsOps.updateTableStatistics(nameMapping, this::resetStatistics); - } - } - - public String getDescription() { - if (partitionName.isPresent()) { - return "alter partition parameters " + nameMapping.getFullLocalName() + " " + partitionName.get(); - } else { - return "alter table parameters " + nameMapping.getFullRemoteName(); - } - } - - private HivePartitionStatistics updateStatistics(HivePartitionStatistics currentStats) { - return merge ? HivePartitionStatistics.merge(currentStats, updatePartitionStat) : updatePartitionStat; - } - - private HivePartitionStatistics resetStatistics(HivePartitionStatistics currentStatistics) { - return HivePartitionStatistics - .reduce(currentStatistics, updatePartitionStat, CommonStatistics.ReduceOperator.SUBTRACT); - } - } - - public static class AddPartitionsTask { - private final List partitions = new ArrayList<>(); - private final List> createdPartitionValues = new ArrayList<>(); - - public boolean isEmpty() { - return partitions.isEmpty(); - } - - public List getPartitions() { - return partitions; - } - - public void clear() { - partitions.clear(); - createdPartitionValues.clear(); - } - - public void addPartition(HivePartitionWithStatistics partition) { - partitions.add(partition); - } - - public void run(HiveMetadataOps hiveOps) { - HivePartition firstPartition = partitions.get(0).getPartition(); - NameMapping nameMapping = firstPartition.getNameMapping(); - List> batchedPartitions = Lists.partition(partitions, 20); - for (List batch : batchedPartitions) { - try { - hiveOps.addPartitions(nameMapping, batch); - for (HivePartitionWithStatistics partition : batch) { - createdPartitionValues.add(partition.getPartition().getPartitionValues()); - } - } catch (Throwable t) { - LOG.warn("Failed to add partition", t); - throw t; - } - } - } - - public List> rollback(HiveMetadataOps hiveOps) { - HivePartition firstPartition = partitions.get(0).getPartition(); - NameMapping nameMapping = firstPartition.getNameMapping(); - List> rollbackFailedPartitions = new ArrayList<>(); - for (List createdPartitionValue : createdPartitionValues) { - try { - hiveOps.dropPartition(nameMapping, createdPartitionValue, false); - } catch (Throwable t) { - LOG.warn("Failed to drop partition on {}.{} when rollback", - nameMapping.getFullLocalName(), rollbackFailedPartitions); - rollbackFailedPartitions.add(createdPartitionValue); - } - } - return rollbackFailedPartitions; - } - } - - private static class DirectoryCleanUpTask { - private final Path path; - private final boolean deleteEmptyDir; - - public DirectoryCleanUpTask(String path, boolean deleteEmptyDir) { - this.path = new Path(path); - this.deleteEmptyDir = deleteEmptyDir; - } - - public Path getPath() { - return path; - } - - public boolean isDeleteEmptyDir() { - return deleteEmptyDir; - } - - @Override - public String toString() { - return new StringJoiner(", ", DirectoryCleanUpTask.class.getSimpleName() + "[", "]") - .add("path=" + path) - .add("deleteEmptyDir=" + deleteEmptyDir) - .toString(); - } - } - - private static class DeleteRecursivelyResult { - private final boolean dirNoLongerExists; - private final List notDeletedEligibleItems; - - public DeleteRecursivelyResult(boolean dirNoLongerExists, List notDeletedEligibleItems) { - this.dirNoLongerExists = dirNoLongerExists; - this.notDeletedEligibleItems = notDeletedEligibleItems; - } - - public boolean dirNotExists() { - return dirNoLongerExists; - } - - public List getNotDeletedEligibleItems() { - return notDeletedEligibleItems; - } - } - - private static class RenameDirectoryTask { - private final String renameFrom; - private final String renameTo; - - public RenameDirectoryTask(String renameFrom, String renameTo) { - this.renameFrom = renameFrom; - this.renameTo = renameTo; - } - - public String getRenameFrom() { - return renameFrom; - } - - public String getRenameTo() { - return renameTo; - } - - @Override - public String toString() { - return new StringJoiner(", ", RenameDirectoryTask.class.getSimpleName() + "[", "]") - .add("renameFrom:" + renameFrom) - .add("renameTo:" + renameTo) - .toString(); - } - } - - - private void recursiveDeleteItems(Path directory, boolean deleteEmptyDir, boolean reverse) { - DeleteRecursivelyResult deleteResult = recursiveDeleteFiles(directory, deleteEmptyDir, reverse); - - if (!deleteResult.getNotDeletedEligibleItems().isEmpty()) { - LOG.warn("Failed to delete directory {}. Some eligible items can't be deleted: {}.", - directory.toString(), deleteResult.getNotDeletedEligibleItems()); - throw new RuntimeException( - "Failed to delete directory for files: " + deleteResult.getNotDeletedEligibleItems()); - } else if (deleteEmptyDir && !deleteResult.dirNotExists()) { - LOG.warn("Failed to delete directory {} due to dir isn't empty", directory.toString()); - throw new RuntimeException("Failed to delete directory for empty dir: " + directory.toString()); - } - } - - private DeleteRecursivelyResult recursiveDeleteFiles(Path directory, boolean deleteEmptyDir, boolean reverse) { - try { - boolean dirExists = fs.exists(Location.of(directory.toString())); - if (!dirExists) { - return new DeleteRecursivelyResult(true, ImmutableList.of()); - } - } catch (java.io.IOException e) { - ImmutableList.Builder notDeletedEligibleItems = ImmutableList.builder(); - notDeletedEligibleItems.add(directory.toString() + "/*"); - return new DeleteRecursivelyResult(false, notDeletedEligibleItems.build()); - } - - return doRecursiveDeleteFiles(directory, deleteEmptyDir, queryId, reverse); - } - - private DeleteRecursivelyResult doRecursiveDeleteFiles(Path directory, boolean deleteEmptyDir, - String queryId, boolean reverse) { - List allFiles; - Set allDirs; - try { - allFiles = fs.listFilesRecursive(Location.of(directory.toString())); - allDirs = fs.listDirectories(Location.of(directory.toString())); - } catch (java.io.IOException e) { - ImmutableList.Builder notDeletedEligibleItems = ImmutableList.builder(); - notDeletedEligibleItems.add(directory + "/*"); - return new DeleteRecursivelyResult(false, notDeletedEligibleItems.build()); - } - - boolean allDescendentsDeleted = true; - ImmutableList.Builder notDeletedEligibleItems = ImmutableList.builder(); - for (FileEntry file : allFiles) { - String fileName = new Path(file.location().uri()).getName(); - String filePath = file.location().uri(); - if (reverse ^ fileName.startsWith(queryId)) { - if (!deleteIfExists(new Path(filePath))) { - allDescendentsDeleted = false; - notDeletedEligibleItems.add(filePath); - } - } else { - allDescendentsDeleted = false; - } - } - - for (String dir : allDirs) { - DeleteRecursivelyResult subResult = doRecursiveDeleteFiles(new Path(dir), deleteEmptyDir, queryId, reverse); - if (!subResult.dirNotExists()) { - allDescendentsDeleted = false; - } - if (!subResult.getNotDeletedEligibleItems().isEmpty()) { - notDeletedEligibleItems.addAll(subResult.getNotDeletedEligibleItems()); - } - } - - if (allDescendentsDeleted && deleteEmptyDir) { - Verify.verify(notDeletedEligibleItems.build().isEmpty()); - if (!deleteDirectoryIfExists(directory)) { - return new DeleteRecursivelyResult(false, ImmutableList.of(directory + "/")); - } - // all items of the location have been deleted. - return new DeleteRecursivelyResult(true, ImmutableList.of()); - } - return new DeleteRecursivelyResult(false, notDeletedEligibleItems.build()); - } - - public boolean deleteIfExists(Path path) { - wrapperDeleteWithProfileSummary(path.toString()); - try { - return !fs.exists(Location.of(path.toString())); - } catch (java.io.IOException e) { - return false; - } - } - - public boolean deleteDirectoryIfExists(Path path) { - wrapperDeleteDirWithProfileSummary(path.toString()); - try { - return !fs.exists(Location.of(path.toString())); - } catch (java.io.IOException e) { - return false; - } - } - - private static class TableAndMore { - private final Table table; - private final String currentLocation; - private final List fileNames; - private final HivePartitionStatistics statisticsUpdate; - - private final THivePartitionUpdate hivePartitionUpdate; - - public TableAndMore( - Table table, - String currentLocation, - List fileNames, - HivePartitionStatistics statisticsUpdate, - THivePartitionUpdate hivePartitionUpdate) { - this.table = Objects.requireNonNull(table, "table is null"); - this.currentLocation = Objects.requireNonNull(currentLocation); - this.fileNames = Objects.requireNonNull(fileNames); - this.statisticsUpdate = Objects.requireNonNull(statisticsUpdate, "statisticsUpdate is null"); - this.hivePartitionUpdate = Objects.requireNonNull(hivePartitionUpdate, "hivePartitionUpdate is null"); - } - - public Table getTable() { - return table; - } - - public String getCurrentLocation() { - return currentLocation; - } - - public List getFileNames() { - return fileNames; - } - - public HivePartitionStatistics getStatisticsUpdate() { - return statisticsUpdate; - } - - public THivePartitionUpdate getHivePartitionUpdate() { - return hivePartitionUpdate; - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(this) - .add("table", table) - .add("statisticsUpdate", statisticsUpdate) - .toString(); - } - } - - private static class PartitionAndMore { - private final HivePartition partition; - private final String currentLocation; - private final String partitionName; - private final List fileNames; - private final HivePartitionStatistics statisticsUpdate; - - private final THivePartitionUpdate hivePartitionUpdate; - - - public PartitionAndMore( - HivePartition partition, - String currentLocation, - String partitionName, - List fileNames, - HivePartitionStatistics statisticsUpdate, - THivePartitionUpdate hivePartitionUpdate) { - this.partition = Objects.requireNonNull(partition, "partition is null"); - this.currentLocation = Objects.requireNonNull(currentLocation, "currentLocation is null"); - this.partitionName = Objects.requireNonNull(partitionName, "partition is null"); - this.fileNames = Objects.requireNonNull(fileNames, "fileNames is null"); - this.statisticsUpdate = Objects.requireNonNull(statisticsUpdate, "statisticsUpdate is null"); - this.hivePartitionUpdate = Objects.requireNonNull(hivePartitionUpdate, "hivePartitionUpdate is null"); - } - - public HivePartition getPartition() { - return partition; - } - - public String getCurrentLocation() { - return currentLocation; - } - - public String getPartitionName() { - return partitionName; - } - - public List getFileNames() { - return fileNames; - } - - public HivePartitionStatistics getStatisticsUpdate() { - return statisticsUpdate; - } - - public THivePartitionUpdate getHivePartitionUpdate() { - return hivePartitionUpdate; - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(this) - .add("partition", partition) - .add("currentLocation", currentLocation) - .add("fileNames", fileNames) - .toString(); - } - } - - private enum ActionType { - // drop a table/partition - DROP, - // drop a table/partition but will preserve data - DROP_PRESERVE_DATA, - // add a table/partition - ADD, - // drop then add a table/partition, like overwrite - ALTER, - // insert into an existing table/partition - INSERT_EXISTING, - // merger into an existing table/partition - MERGE, - } - - public static class Action { - private final ActionType type; - private final T data; - - public Action(ActionType type, T data) { - this.type = Objects.requireNonNull(type, "type is null"); - if (type == ActionType.DROP || type == ActionType.DROP_PRESERVE_DATA) { - Preconditions.checkArgument(data == null, "data is not null"); - } else { - Objects.requireNonNull(data, "data is null"); - } - this.data = data; - } - - public ActionType getType() { - return type; - } - - public T getData() { - Preconditions.checkState(type != ActionType.DROP); - return data; - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(this) - .add("type", type) - .add("data", data) - .toString(); - } - } - - private synchronized Table getTable(NameMapping nameMapping) { - Action tableAction = tableActions.get(nameMapping); - if (tableAction == null) { - return hiveOps.getClient().getTable(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName()); - } - switch (tableAction.getType()) { - case ADD: - case ALTER: - case INSERT_EXISTING: - case MERGE: - return tableAction.getData().getTable(); - case DROP: - case DROP_PRESERVE_DATA: - break; - default: - throw new IllegalStateException("Unknown action type: " + tableAction.getType()); - } - throw new RuntimeException("Not Found table: " + nameMapping); - } - - public synchronized void finishChangingExistingTable( - ActionType actionType, - NameMapping nameMapping, - String location, - List fileNames, - HivePartitionStatistics statisticsUpdate, - THivePartitionUpdate hivePartitionUpdate) { - Action oldTableAction = tableActions.get(nameMapping); - if (oldTableAction == null) { - Table table = hiveOps.getClient().getTable(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName()); - tableActions.put( - nameMapping, - new Action<>( - actionType, - new TableAndMore( - table, - location, - fileNames, - statisticsUpdate, - hivePartitionUpdate))); - return; - } - - switch (oldTableAction.getType()) { - case DROP: - throw new RuntimeException("Not found table: " + nameMapping.getFullLocalName()); - case ADD: - case ALTER: - case INSERT_EXISTING: - case MERGE: - throw new UnsupportedOperationException( - "Inserting into an unpartitioned table that were added, altered," - + "or inserted into in the same transaction is not supported"); - case DROP_PRESERVE_DATA: - break; - default: - throw new IllegalStateException("Unknown action type: " + oldTableAction.getType()); - } - } - - public synchronized void createTable( - NameMapping nameMapping, - Table table, String location, List fileNames, - HivePartitionStatistics statistics, - THivePartitionUpdate hivePartitionUpdate) { - // When creating a table, it should never have partition actions. This is just a sanity check. - checkNoPartitionAction(nameMapping); - Action oldTableAction = tableActions.get(nameMapping); - TableAndMore tableAndMore = new TableAndMore(table, location, fileNames, statistics, hivePartitionUpdate); - if (oldTableAction == null) { - tableActions.put(nameMapping, new Action<>(ActionType.ADD, tableAndMore)); - return; - } - switch (oldTableAction.getType()) { - case DROP: - tableActions.put(nameMapping, new Action<>(ActionType.ALTER, tableAndMore)); - return; - - case ADD: - case ALTER: - case INSERT_EXISTING: - case MERGE: - throw new RuntimeException("Table already exists: " + nameMapping.getFullLocalName()); - case DROP_PRESERVE_DATA: - break; - default: - throw new IllegalStateException("Unknown action type: " + oldTableAction.getType()); - } - } - - - public synchronized void dropTable(NameMapping nameMapping) { - // Dropping table with partition actions requires cleaning up staging data, which is not implemented yet. - checkNoPartitionAction(nameMapping); - Action oldTableAction = tableActions.get(nameMapping); - if (oldTableAction == null || oldTableAction.getType() == ActionType.ALTER) { - tableActions.put(nameMapping, new Action<>(ActionType.DROP, null)); - return; - } - switch (oldTableAction.getType()) { - case DROP: - throw new RuntimeException("Not found table: " + nameMapping.getFullLocalName()); - case ADD: - case ALTER: - case INSERT_EXISTING: - case MERGE: - throw new RuntimeException("Dropping a table added/modified in the same transaction is not supported"); - case DROP_PRESERVE_DATA: - break; - default: - throw new IllegalStateException("Unknown action type: " + oldTableAction.getType()); - } - } - - - private void checkNoPartitionAction(NameMapping nameMapping) { - Map, Action> partitionActionsForTable = - partitionActions.get(nameMapping); - if (partitionActionsForTable != null && !partitionActionsForTable.isEmpty()) { - throw new RuntimeException( - "Cannot make schema changes to a table with modified partitions in the same transaction"); - } - } - - private void createAndAddPartition( - NameMapping nameMapping, - Table table, - List partitionValues, - String writePath, - THivePartitionUpdate pu, - HivePartitionStatistics hivePartitionStatistics, - boolean dropFirst) { - StorageDescriptor sd = table.getSd(); - String pathForHMS = this.fileType == TFileType.FILE_S3 - ? writePath - : pu.getLocation().getTargetPath(); - HivePartition hivePartition = new HivePartition( - nameMapping, - false, - sd.getInputFormat(), - pathForHMS, - partitionValues, - Maps.newHashMap(), - sd.getOutputFormat(), - sd.getSerdeInfo().getSerializationLib(), - sd.getCols() - ); - if (dropFirst) { - dropPartition(nameMapping, hivePartition.getPartitionValues(), true); - } - addPartition( - nameMapping, hivePartition, writePath, - pu.getName(), pu.getFileNames(), hivePartitionStatistics, pu); - } - - public synchronized void addPartition( - NameMapping nameMapping, - HivePartition partition, - String currentLocation, - String partitionName, - List files, - HivePartitionStatistics statistics, - THivePartitionUpdate hivePartitionUpdate) { - Map, Action> partitionActionsForTable = - partitionActions.computeIfAbsent(nameMapping, k -> new HashMap<>()); - Action oldPartitionAction = partitionActionsForTable.get(partition.getPartitionValues()); - if (oldPartitionAction == null) { - partitionActionsForTable.put( - partition.getPartitionValues(), - new Action<>( - ActionType.ADD, - new PartitionAndMore(partition, currentLocation, partitionName, files, statistics, - hivePartitionUpdate)) - ); - return; - } - switch (oldPartitionAction.getType()) { - case DROP: - case DROP_PRESERVE_DATA: - partitionActionsForTable.put( - partition.getPartitionValues(), - new Action<>( - ActionType.ALTER, - new PartitionAndMore(partition, currentLocation, partitionName, files, statistics, - hivePartitionUpdate)) - ); - return; - case ADD: - case ALTER: - case INSERT_EXISTING: - case MERGE: - throw new RuntimeException( - "Partition already exists for table: " - + nameMapping.getFullLocalName() + ", partition values: " + partition - .getPartitionValues()); - default: - throw new IllegalStateException("Unknown action type: " + oldPartitionAction.getType()); - } - } - - private synchronized void dropPartition( - NameMapping nameMapping, - List partitionValues, - boolean deleteData) { - Map, Action> partitionActionsForTable = - partitionActions.computeIfAbsent(nameMapping, k -> new HashMap<>()); - Action oldPartitionAction = partitionActionsForTable.get(partitionValues); - if (oldPartitionAction == null) { - if (deleteData) { - partitionActionsForTable.put(partitionValues, new Action<>(ActionType.DROP, null)); - } else { - partitionActionsForTable.put(partitionValues, new Action<>(ActionType.DROP_PRESERVE_DATA, null)); - } - return; - } - switch (oldPartitionAction.getType()) { - case DROP: - case DROP_PRESERVE_DATA: - throw new RuntimeException( - "Not found partition from partition actions for " + nameMapping.getFullLocalName() - + ", partitions: " + partitionValues); - case ADD: - case ALTER: - case INSERT_EXISTING: - case MERGE: - throw new RuntimeException( - "Dropping a partition added in the same transaction is not supported: " - + nameMapping.getFullLocalName() + ", partition values: " + partitionValues); - default: - throw new IllegalStateException("Unknown action type: " + oldPartitionAction.getType()); - } - } - - class HmsCommitter { - - // update statistics for unPartitioned table or existed partition - private final List updateStatisticsTasks = new ArrayList<>(); - ExecutorService updateStatisticsExecutor = Executors.newFixedThreadPool(16); - - // add new partition - private final AddPartitionsTask addPartitionsTask = new AddPartitionsTask(); - - // for file system rename operation - // whether to cancel the file system tasks - private final AtomicBoolean fileSystemTaskCancelled = new AtomicBoolean(false); - // file system tasks that are executed asynchronously, including rename_file, rename_dir - private final List> asyncFileSystemTaskFutures = new ArrayList<>(); - // when aborted, we need to delete all files under this path, even the current directory - private final Queue directoryCleanUpTasksForAbort = new ConcurrentLinkedQueue<>(); - // when aborted, we need restore directory - private final List renameDirectoryTasksForAbort = new ArrayList<>(); - // when finished, we need clear some directories - private final List clearDirsForFinish = new ArrayList<>(); - - private final List s3cleanWhenSuccess = new ArrayList<>(); - - public void cancelUnStartedAsyncFileSystemTask() { - fileSystemTaskCancelled.set(true); - } - - private void undoUpdateStatisticsTasks() { - ImmutableList.Builder> undoUpdateFutures = ImmutableList.builder(); - for (UpdateStatisticsTask task : updateStatisticsTasks) { - undoUpdateFutures.add(CompletableFuture.runAsync(() -> { - try { - task.undo(hiveOps); - } catch (Throwable throwable) { - LOG.warn("Failed to rollback: {}", task.getDescription(), throwable); - } - }, updateStatisticsExecutor)); - } - - for (CompletableFuture undoUpdateFuture : undoUpdateFutures.build()) { - MoreFutures.getFutureValue(undoUpdateFuture); - } - updateStatisticsTasks.clear(); - } - - private void undoAddPartitionsTask() { - if (addPartitionsTask.isEmpty()) { - return; - } - - HivePartition firstPartition = addPartitionsTask.getPartitions().get(0).getPartition(); - NameMapping nameMapping = firstPartition.getNameMapping(); - List> rollbackFailedPartitions = addPartitionsTask.rollback(hiveOps); - if (!rollbackFailedPartitions.isEmpty()) { - LOG.warn("Failed to rollback: add_partition for partition values {}.{}", - nameMapping.getFullLocalName(), rollbackFailedPartitions); - } - addPartitionsTask.clear(); - } - - private void waitForAsyncFileSystemTaskSuppressThrowable() { - for (CompletableFuture future : asyncFileSystemTaskFutures) { - try { - future.get(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } catch (Throwable t) { - // ignore - } - } - asyncFileSystemTaskFutures.clear(); - } - - public void prepareInsertExistingTable(NameMapping nameMapping, TableAndMore tableAndMore) { - Table table = tableAndMore.getTable(); - String targetPath = table.getSd().getLocation(); - String writePath = tableAndMore.getCurrentLocation(); - // Determine if a rename operation is required for the output file. - // In the BE (Backend) implementation, all object storage systems (e.g., AWS S3, MinIO, OSS, COS) - // are unified under the "s3" URI scheme, even if the actual underlying storage uses a different protocol. - // The method PathUtils.equalsIgnoreSchemeIfOneIsS3(...) compares two paths by ignoring the scheme - // if one of them uses the "s3" scheme, and only checks whether the bucket name and object key match. - // This prevents unnecessary rename operations when the scheme differs (e.g., "s3://" vs. "oss://") - // but the actual storage location is identical. If the paths differ after ignoring the scheme, - // a rename operation will be performed. - boolean needRename = !PathUtils.equalsIgnoreSchemeIfOneIsS3(targetPath, writePath); - if (needRename) { - wrapperAsyncRenameWithProfileSummary( - fileSystemExecutor, - asyncFileSystemTaskFutures, - fileSystemTaskCancelled, - writePath, - targetPath, - tableAndMore.getFileNames()); - } else { - if (!tableAndMore.hivePartitionUpdate.s3_mpu_pending_uploads.isEmpty()) { - objCommit(fileSystemExecutor, asyncFileSystemTaskFutures, fileSystemTaskCancelled, - tableAndMore.hivePartitionUpdate, targetPath); - } - } - directoryCleanUpTasksForAbort.add(new DirectoryCleanUpTask(targetPath, false)); - updateStatisticsTasks.add( - new UpdateStatisticsTask( - nameMapping, - Optional.empty(), - tableAndMore.getStatisticsUpdate(), - true - )); - } - - public void prepareAlterTable(NameMapping nameMapping, TableAndMore tableAndMore) { - Table table = tableAndMore.getTable(); - String targetPath = table.getSd().getLocation(); - String writePath = tableAndMore.getCurrentLocation(); - if (!targetPath.equals(writePath)) { - if (isSubDirectory(targetPath, writePath)) { - String stagingRoot = getImmediateChildPath(targetPath, writePath); - deleteTargetPathContents(targetPath, stagingRoot); - ensureDirectory(targetPath); - wrapperAsyncRenameWithProfileSummary( - fileSystemExecutor, - asyncFileSystemTaskFutures, - fileSystemTaskCancelled, - writePath, - targetPath, - tableAndMore.getFileNames()); - } else { - Path path = new Path(targetPath); - String oldTablePath = new Path( - path.getParent(), "_temp_" + queryId + "_" + path.getName()).toString(); - renameDirectoryTasksForAbort.add(new RenameDirectoryTask(oldTablePath, targetPath)); - wrapperRenameDirWithProfileSummary( - targetPath, - oldTablePath, - () -> {}); - clearDirsForFinish.add(oldTablePath); - - directoryCleanUpTasksForAbort.add(new DirectoryCleanUpTask(targetPath, true)); - wrapperRenameDirWithProfileSummary( - writePath, - targetPath, - () -> {}); - } - } else { - if (!tableAndMore.hivePartitionUpdate.s3_mpu_pending_uploads.isEmpty()) { - s3cleanWhenSuccess.add(targetPath); - objCommit(fileSystemExecutor, asyncFileSystemTaskFutures, fileSystemTaskCancelled, - tableAndMore.hivePartitionUpdate, targetPath); - } - } - updateStatisticsTasks.add( - new UpdateStatisticsTask( - nameMapping, - Optional.empty(), - tableAndMore.getStatisticsUpdate(), - false - )); - } - - public void prepareAddPartition(NameMapping nameMapping, PartitionAndMore partitionAndMore) { - - HivePartition partition = partitionAndMore.getPartition(); - String targetPath = partition.getPath(); - String writePath = partitionAndMore.getCurrentLocation(); - - if (!targetPath.equals(writePath)) { - directoryCleanUpTasksForAbort.add(new DirectoryCleanUpTask(targetPath, true)); - wrapperAsyncRenameDirWithProfileSummary( - fileSystemExecutor, - asyncFileSystemTaskFutures, - fileSystemTaskCancelled, - writePath, - targetPath, - () -> {}); - } else { - if (!partitionAndMore.hivePartitionUpdate.s3_mpu_pending_uploads.isEmpty()) { - objCommit(fileSystemExecutor, asyncFileSystemTaskFutures, fileSystemTaskCancelled, - partitionAndMore.hivePartitionUpdate, targetPath); - } - } - - StorageDescriptor sd = getTable(nameMapping).getSd(); - - HivePartition hivePartition = new HivePartition( - nameMapping, - false, - sd.getInputFormat(), - targetPath, - partition.getPartitionValues(), - Maps.newHashMap(), - sd.getOutputFormat(), - sd.getSerdeInfo().getSerializationLib(), - sd.getCols() - ); - - HivePartitionWithStatistics partitionWithStats = - new HivePartitionWithStatistics( - partitionAndMore.getPartitionName(), - hivePartition, - partitionAndMore.getStatisticsUpdate()); - addPartitionsTask.addPartition(partitionWithStats); - } - - public void prepareInsertExistPartition(NameMapping nameMapping, PartitionAndMore partitionAndMore) { - - HivePartition partition = partitionAndMore.getPartition(); - String targetPath = partition.getPath(); - String writePath = partitionAndMore.getCurrentLocation(); - directoryCleanUpTasksForAbort.add(new DirectoryCleanUpTask(targetPath, false)); - - if (!targetPath.equals(writePath)) { - wrapperAsyncRenameWithProfileSummary( - fileSystemExecutor, - asyncFileSystemTaskFutures, - fileSystemTaskCancelled, - writePath, - targetPath, - partitionAndMore.getFileNames()); - } else { - if (!partitionAndMore.hivePartitionUpdate.s3_mpu_pending_uploads.isEmpty()) { - objCommit(fileSystemExecutor, asyncFileSystemTaskFutures, fileSystemTaskCancelled, - partitionAndMore.hivePartitionUpdate, targetPath); - } - } - - updateStatisticsTasks.add( - new UpdateStatisticsTask( - nameMapping, - Optional.of(partitionAndMore.getPartitionName()), - partitionAndMore.getStatisticsUpdate(), - true)); - } - - private void runDirectoryClearUpTasksForAbort() { - for (DirectoryCleanUpTask cleanUpTask : directoryCleanUpTasksForAbort) { - recursiveDeleteItems(cleanUpTask.getPath(), cleanUpTask.isDeleteEmptyDir(), false); - } - directoryCleanUpTasksForAbort.clear(); - } - - private void runRenameDirTasksForAbort() { - for (RenameDirectoryTask task : renameDirectoryTasksForAbort) { - try { - boolean srcExists = fs.exists(Location.of(task.getRenameFrom())); - if (srcExists) { - wrapperRenameDirWithProfileSummary(task.getRenameFrom(), task.getRenameTo(), () -> { - }); - } - } catch (java.io.IOException e) { - LOG.warn("Failed to abort rename dir from {} to {}: {}", - task.getRenameFrom(), task.getRenameTo(), e.getMessage()); - } - } - renameDirectoryTasksForAbort.clear(); - } - - private void runClearPathsForFinish() { - for (String path : clearDirsForFinish) { - wrapperDeleteDirWithProfileSummary(path); - } - } - - private void runS3cleanWhenSuccess() { - for (String path : s3cleanWhenSuccess) { - recursiveDeleteItems(new Path(path), false, true); - } - } - - public void prepareAlterPartition(NameMapping nameMapping, PartitionAndMore partitionAndMore) { - HivePartition partition = partitionAndMore.getPartition(); - String targetPath = partition.getPath(); - String writePath = partitionAndMore.getCurrentLocation(); - - if (!targetPath.equals(writePath)) { - Path path = new Path(targetPath); - String oldPartitionPath = new Path( - path.getParent(), "_temp_" + queryId + "_" + path.getName()).toString(); - renameDirectoryTasksForAbort.add(new RenameDirectoryTask(oldPartitionPath, targetPath)); - wrapperRenameDirWithProfileSummary( - targetPath, - oldPartitionPath, - () -> {}); - clearDirsForFinish.add(oldPartitionPath); - - directoryCleanUpTasksForAbort.add(new DirectoryCleanUpTask(targetPath, true)); - wrapperRenameDirWithProfileSummary( - writePath, - targetPath, - () -> {}); - } else { - if (!partitionAndMore.hivePartitionUpdate.s3_mpu_pending_uploads.isEmpty()) { - s3cleanWhenSuccess.add(targetPath); - objCommit(fileSystemExecutor, asyncFileSystemTaskFutures, fileSystemTaskCancelled, - partitionAndMore.hivePartitionUpdate, targetPath); - } - } - - updateStatisticsTasks.add( - new UpdateStatisticsTask( - nameMapping, - Optional.of(partitionAndMore.getPartitionName()), - partitionAndMore.getStatisticsUpdate(), - false - )); - } - - - private void waitForAsyncFileSystemTasks() { - summaryProfile.ifPresent(SummaryProfile::setTempStartTime); - - for (CompletableFuture future : asyncFileSystemTaskFutures) { - MoreFutures.getFutureValue(future, RuntimeException.class); - } - - summaryProfile.ifPresent(SummaryProfile::freshFilesystemOptTime); - } - - private void doAddPartitionsTask() { - - summaryProfile.ifPresent(profile -> { - profile.setTempStartTime(); - profile.addHmsAddPartitionCnt(addPartitionsTask.getPartitions().size()); - }); - - if (!addPartitionsTask.isEmpty()) { - addPartitionsTask.run(hiveOps); - } - - summaryProfile.ifPresent(SummaryProfile::setHmsAddPartitionTime); - } - - private void doUpdateStatisticsTasks() { - summaryProfile.ifPresent(profile -> { - profile.setTempStartTime(); - profile.addHmsUpdatePartitionCnt(updateStatisticsTasks.size()); - }); - - ImmutableList.Builder> updateStatsFutures = ImmutableList.builder(); - List failedTaskDescriptions = new ArrayList<>(); - List suppressedExceptions = new ArrayList<>(); - for (UpdateStatisticsTask task : updateStatisticsTasks) { - updateStatsFutures.add(CompletableFuture.runAsync(() -> { - try { - task.run(hiveOps); - } catch (Throwable t) { - synchronized (suppressedExceptions) { - addSuppressedExceptions( - suppressedExceptions, t, failedTaskDescriptions, task.getDescription()); - } - } - }, updateStatisticsExecutor)); - } - - for (CompletableFuture executeUpdateFuture : updateStatsFutures.build()) { - MoreFutures.getFutureValue(executeUpdateFuture); - } - if (!suppressedExceptions.isEmpty()) { - StringBuilder message = new StringBuilder(); - message.append("Failed to execute some updating statistics tasks: "); - Joiner.on("; ").appendTo(message, failedTaskDescriptions); - RuntimeException exception = new RuntimeException(message.toString()); - suppressedExceptions.forEach(exception::addSuppressed); - throw exception; - } - - summaryProfile.ifPresent(SummaryProfile::setHmsUpdatePartitionTime); - } - - private void pruneAndDeleteStagingDirectories() { - stagingDirectory.ifPresent((v) -> recursiveDeleteItems(new Path(v), true, false)); - } - - private void abortMultiUploads() { - if (uncompletedMpuPendingUploads.isEmpty()) { - return; - } - for (UncompletedMpuPendingUpload uncompletedMpuPendingUpload : uncompletedMpuPendingUploads) { - ObjFileSystem objFs; - try { - org.apache.doris.filesystem.FileSystem resolved = ((SpiSwitchingFileSystem) fs) - .forPath(uncompletedMpuPendingUpload.path); - if (!(resolved instanceof ObjFileSystem)) { - LOG.warn("Path '{}' uses non-object-storage backend ({}), skipping MPU abort", - uncompletedMpuPendingUpload.path, resolved.getClass().getSimpleName()); - continue; - } - objFs = (ObjFileSystem) resolved; - } catch (java.io.IOException e) { - throw new RuntimeException("Failed to resolve filesystem for abort MPU: " - + uncompletedMpuPendingUpload.path, e); - } - TS3MPUPendingUpload mpu = uncompletedMpuPendingUpload.s3MPUPendingUpload; - String remotePath = "s3://" + mpu.getBucket() + "/" + mpu.getKey(); - asyncFileSystemTaskFutures.add(CompletableFuture.runAsync(() -> { - try { - objFs.getObjStorage().abortMultipartUpload(remotePath, mpu.getUploadId()); - } catch (java.io.IOException e) { - LOG.warn("Failed to abort MPU for {}: {}", remotePath, e.getMessage()); - } - }, fileSystemExecutor)); - } - uncompletedMpuPendingUploads.clear(); - } - - public void doNothing() { - // do nothing - // only for regression test and unit test to throw exception - } - - public void doCommit() { - waitForAsyncFileSystemTasks(); - runS3cleanWhenSuccess(); - doAddPartitionsTask(); - doUpdateStatisticsTasks(); - //delete write path - pruneAndDeleteStagingDirectories(); - doNothing(); - } - - public void abort() { - cancelUnStartedAsyncFileSystemTask(); - undoUpdateStatisticsTasks(); - undoAddPartitionsTask(); - waitForAsyncFileSystemTaskSuppressThrowable(); - runDirectoryClearUpTasksForAbort(); - runRenameDirTasksForAbort(); - } - - public void rollback() { - //delete write path - pruneAndDeleteStagingDirectories(); - // abort the in-progress multipart uploads - abortMultiUploads(); - for (CompletableFuture future : asyncFileSystemTaskFutures) { - MoreFutures.getFutureValue(future, RuntimeException.class); - } - asyncFileSystemTaskFutures.clear(); - } - - public void shutdownExecutorService() { - // Disable new tasks from being submitted - updateStatisticsExecutor.shutdown(); - try { - // Wait a while for existing tasks to terminate - if (!updateStatisticsExecutor.awaitTermination(60, TimeUnit.SECONDS)) { - // Cancel currently executing tasks - updateStatisticsExecutor.shutdownNow(); - // Wait a while for tasks to respond to being cancelled - if (!updateStatisticsExecutor.awaitTermination(60, TimeUnit.SECONDS)) { - LOG.warn("Pool did not terminate"); - } - } - } catch (InterruptedException e) { - // (Re-)Cancel if current thread also interrupted - updateStatisticsExecutor.shutdownNow(); - // Preserve interrupt status - Thread.currentThread().interrupt(); - } - } - } - - @VisibleForTesting - static boolean isSubDirectory(String parent, String child) { - if (parent == null || child == null) { - return false; - } - Path parentPath = new Path(parent); - Path childPath = new Path(child); - URI parentUri = parentPath.toUri(); - URI childUri = childPath.toUri(); - if (!sameFileSystem(parentUri, childUri)) { - return false; - } - String parentPathValue = normalizePath(parentUri.getPath()); - String childPathValue = normalizePath(childUri.getPath()); - if (parentPathValue.isEmpty() || childPathValue.isEmpty()) { - return false; - } - return !parentPathValue.equals(childPathValue) - && childPathValue.startsWith(parentPathValue + "/"); - } - - /** - * Returns the first-level child path of {@code parent} that contains {@code child}, - * or null if {@code child} is not a subdirectory of {@code parent}. - * Example: parent=/warehouse/table, child=/warehouse/table/.doris_staging/user/uuid - * returns /warehouse/table/.doris_staging. - */ - @VisibleForTesting - static String getImmediateChildPath(String parent, String child) { - if (!isSubDirectory(parent, child)) { - return null; - } - Path parentPath = new Path(parent); - URI parentUri = parentPath.toUri(); - URI childUri = new Path(child).toUri(); - String parentPathValue = normalizePath(parentUri.getPath()); - String childPathValue = normalizePath(childUri.getPath()); - String relative = childPathValue.substring(parentPathValue.length() + 1); - int slashIndex = relative.indexOf("/"); - String firstComponent = slashIndex == -1 ? relative : relative.substring(0, slashIndex); - return new Path(parentPath, firstComponent).toString(); - } - - private static boolean sameFileSystem(URI left, URI right) { - String leftScheme = normalizeUriPart(left.getScheme()); - String rightScheme = normalizeUriPart(right.getScheme()); - if (!leftScheme.isEmpty() && !rightScheme.isEmpty() - && !leftScheme.equalsIgnoreCase(rightScheme)) { - return false; - } - String leftAuthority = normalizeUriPart(left.getAuthority()); - String rightAuthority = normalizeUriPart(right.getAuthority()); - if (!leftAuthority.isEmpty() && !rightAuthority.isEmpty() - && !leftAuthority.equalsIgnoreCase(rightAuthority)) { - return false; - } - return true; - } - - private static String normalizeUriPart(String value) { - return value == null ? "" : value; - } - - private static String normalizePath(String path) { - if (path == null || path.isEmpty()) { - return ""; - } - int end = path.length(); - while (end > 1 && path.charAt(end - 1) == '/') { - end--; - } - return path.substring(0, end); - } - - private static boolean pathsEqual(String left, String right) { - if (left == null || right == null) { - return left == null && right == null; - } - URI leftUri = new Path(left).toUri(); - URI rightUri = new Path(right).toUri(); - if (!sameFileSystem(leftUri, rightUri)) { - return false; - } - return normalizePath(leftUri.getPath()).equals(normalizePath(rightUri.getPath())); - } - - @VisibleForTesting - void deleteTargetPathContents(String targetPath, String excludedChildPath) { - try { - Set dirs = fs.listDirectories(Location.of(targetPath)); - for (String dir : dirs) { - if (excludedChildPath != null && pathsEqual(dir, excludedChildPath)) { - continue; - } - wrapperDeleteDirWithProfileSummary(dir); - } - - List files = fs.listFiles(Location.of(targetPath)); - for (FileEntry file : files) { - wrapperDeleteWithProfileSummary(file.location().uri()); - } - } catch (java.io.IOException e) { - throw new RuntimeException("Failed to list/delete contents under " + targetPath, e); - } - } - - @VisibleForTesting - void ensureDirectory(String path) { - try { - fs.mkdirs(Location.of(path)); - } catch (java.io.IOException e) { - throw new RuntimeException("Failed to create directory " + path + ": " + e.getMessage(), e); - } - } - - public void wrapperRenameDirWithProfileSummary(String origFilePath, - String destFilePath, - Runnable runWhenPathNotExist) { - summaryProfile.ifPresent(profile -> { - profile.setTempStartTime(); - profile.incRenameDirCnt(); - }); - - try { - fs.renameDirectory(Location.of(origFilePath), Location.of(destFilePath), runWhenPathNotExist); - } catch (java.io.IOException e) { - throw new RuntimeException("Failed to rename directory from " + origFilePath - + " to " + destFilePath + ": " + e.getMessage(), e); - } - - summaryProfile.ifPresent(SummaryProfile::freshFilesystemOptTime); - } - - public void wrapperDeleteWithProfileSummary(String remotePath) { - summaryProfile.ifPresent(profile -> { - profile.setTempStartTime(); - profile.incDeleteFileCnt(); - }); - - try { - fs.delete(Location.of(remotePath), false); - } catch (java.io.IOException e) { - LOG.warn("Failed to delete {}: {}", remotePath, e.getMessage()); - } - - summaryProfile.ifPresent(SummaryProfile::freshFilesystemOptTime); - } - - public void wrapperDeleteDirWithProfileSummary(String remotePath) { - summaryProfile.ifPresent(profile -> { - profile.setTempStartTime(); - profile.incDeleteDirRecursiveCnt(); - }); - - try { - fs.delete(Location.of(remotePath), true); - } catch (java.io.IOException e) { - LOG.warn("Failed to delete directory {}: {}", remotePath, e.getMessage()); - } - - summaryProfile.ifPresent(SummaryProfile::freshFilesystemOptTime); - } - - public void wrapperAsyncRenameWithProfileSummary(Executor executor, - List> renameFileFutures, - AtomicBoolean cancelled, - String origFilePath, - String destFilePath, - List fileNames) { - FileSystemUtil.asyncRenameFiles( - fs, executor, renameFileFutures, cancelled, origFilePath, destFilePath, fileNames); - summaryProfile.ifPresent(profile -> profile.addRenameFileCnt(fileNames.size())); - } - - public void wrapperAsyncRenameDirWithProfileSummary(Executor executor, - List> renameFileFutures, - AtomicBoolean cancelled, - String origFilePath, - String destFilePath, - Runnable runWhenPathNotExist) { - FileSystemUtil.asyncRenameDir( - fs, executor, renameFileFutures, cancelled, origFilePath, destFilePath, runWhenPathNotExist); - summaryProfile.ifPresent(SummaryProfile::incRenameDirCnt); - } - - /** - * Commits object storage partition updates (e.g., for S3, Azure Blob, etc.). - * - *

    In object storage systems, the write workflow is typically divided into two stages: - *

      - *
    • Upload (Stage) Phase – Performed by the BE (Backend). - * During this phase, data parts (for S3) or staged blocks (for Azure) are uploaded to - * the storage system.
    • - *
    • Commit Phase – Performed by the FE (Frontend). - * The FE is responsible for finalizing the uploads initiated by the BE: - *
        - *
      • For S3: the FE calls {@code completeMultipartUpload} to merge all uploaded parts into a - * single object.
      • - *
      • For Azure Blob: the BE stages blocks, and the FE performs the final commit to seal - * the blob.
      • - *
      - *
    • - *
    - * - *

    This method is executed by the FE and ensures that all uploads initiated by the BE - * are properly committed and finalized on the object storage side. - */ - private void objCommit(Executor fileSystemExecutor, List> asyncFileSystemTaskFutures, - AtomicBoolean fileSystemTaskCancelled, THivePartitionUpdate hivePartitionUpdate, String path) { - List s3MpuPendingUploads = hivePartitionUpdate.getS3MpuPendingUploads(); - if (isMockedPartitionUpdate) { - return; - } - ObjFileSystem objFs; - try { - org.apache.doris.filesystem.FileSystem resolved = ((SpiSwitchingFileSystem) fs).forPath(path); - if (!(resolved instanceof ObjFileSystem)) { - throw new RuntimeException("Expected ObjFileSystem for MPU commit at path '" + path - + "', got: " + resolved.getClass().getSimpleName() - + ". This path does not point to an object-storage backend."); - } - objFs = (ObjFileSystem) resolved; - } catch (java.io.IOException e) { - throw new RuntimeException("Failed to resolve filesystem for MPU commit at path: " + path, e); - } - for (TS3MPUPendingUpload s3MPUPendingUpload : s3MpuPendingUploads) { - asyncFileSystemTaskFutures.add(CompletableFuture.runAsync(() -> { - if (fileSystemTaskCancelled.get()) { - return; - } - String remotePath = "s3://" + s3MPUPendingUpload.getBucket() - + "/" + s3MPUPendingUpload.getKey(); - try { - objFs.completeMultipartUpload(remotePath, s3MPUPendingUpload.getUploadId(), - s3MPUPendingUpload.getEtags()); - } catch (java.io.IOException e) { - throw new RuntimeException("Failed to complete MPU for " + remotePath, e); - } - uncompletedMpuPendingUploads.remove(new UncompletedMpuPendingUpload(s3MPUPendingUpload, path)); - }, fileSystemExecutor)); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveBucketUtil.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveBucketUtil.java deleted file mode 100644 index 12de1cf3401267..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveBucketUtil.java +++ /dev/null @@ -1,406 +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.datasource.hive; - -import org.apache.doris.analysis.BinaryPredicate; -import org.apache.doris.analysis.CompoundPredicate; -import org.apache.doris.analysis.Expr; -import org.apache.doris.analysis.InPredicate; -import org.apache.doris.analysis.LiteralExpr; -import org.apache.doris.analysis.SlotRef; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.common.DdlException; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; -import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspector; -import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspector.Category; -import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspectorConverters; -import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspectorConverters.Converter; -import org.apache.hadoop.hive.serde2.objectinspector.PrimitiveObjectInspector; -import org.apache.hadoop.hive.serde2.objectinspector.primitive.BooleanObjectInspector; -import org.apache.hadoop.hive.serde2.objectinspector.primitive.ByteObjectInspector; -import org.apache.hadoop.hive.serde2.objectinspector.primitive.IntObjectInspector; -import org.apache.hadoop.hive.serde2.objectinspector.primitive.LongObjectInspector; -import org.apache.hadoop.hive.serde2.objectinspector.primitive.PrimitiveObjectInspectorFactory; -import org.apache.hadoop.hive.serde2.objectinspector.primitive.ShortObjectInspector; -import org.apache.hadoop.hive.serde2.objectinspector.primitive.StringObjectInspector; -import org.apache.hadoop.hive.serde2.typeinfo.PrimitiveTypeInfo; -import org.apache.hadoop.hive.serde2.typeinfo.TypeInfoFactory; -import org.apache.hadoop.io.Text; -import org.apache.hadoop.mapred.FileSplit; -import org.apache.hadoop.mapred.InputSplit; -import org.apache.hive.common.util.Murmur3; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.nio.ByteBuffer; -import java.util.Collections; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.OptionalInt; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public class HiveBucketUtil { - private static final Logger LOG = LogManager.getLogger(HiveBucketUtil.class); - - private static final Set SUPPORTED_TYPES_FOR_BUCKET_FILTER = ImmutableSet.of( - PrimitiveType.BOOLEAN, - PrimitiveType.TINYINT, - PrimitiveType.SMALLINT, - PrimitiveType.INT, - PrimitiveType.BIGINT, - PrimitiveType.STRING); - - private static PrimitiveTypeInfo convertToHiveColType(PrimitiveType dorisType) throws DdlException { - switch (dorisType) { - case BOOLEAN: - return TypeInfoFactory.booleanTypeInfo; - case TINYINT: - return TypeInfoFactory.byteTypeInfo; - case SMALLINT: - return TypeInfoFactory.shortTypeInfo; - case INT: - return TypeInfoFactory.intTypeInfo; - case BIGINT: - return TypeInfoFactory.longTypeInfo; - case STRING: - return TypeInfoFactory.stringTypeInfo; - default: - throw new DdlException("Unsupported pruning bucket column type: " + dorisType); - } - } - - private static final Pattern BUCKET_WITH_OPTIONAL_ATTEMPT_ID_PATTERN = - Pattern.compile("bucket_(\\d+)(_\\d+)?$"); - - private static final Iterable BUCKET_PATTERNS = ImmutableList.of( - // legacy Presto naming pattern (current version matches Hive) - Pattern.compile("\\d{8}_\\d{6}_\\d{5}_[a-z0-9]{5}_bucket-(\\d+)(?:[-_.].*)?"), - // Hive naming pattern per `org.apache.hadoop.hive.ql.exec.Utilities#getBucketIdFromFile()` - Pattern.compile("(\\d+)_\\d+.*"), - // Hive ACID with optional direct insert attempt id - BUCKET_WITH_OPTIONAL_ATTEMPT_ID_PATTERN); - - public static List getPrunedSplitsByBuckets( - List splits, - String tableName, - List conjuncts, - List bucketCols, - int numBuckets, - Map parameters) throws DdlException { - Optional> prunedBuckets = HiveBucketUtil.getPrunedBuckets( - conjuncts, bucketCols, numBuckets, parameters); - if (!prunedBuckets.isPresent()) { - return splits; - } - Set buckets = prunedBuckets.get(); - if (buckets.size() == 0) { - return Collections.emptyList(); - } - List result = new LinkedList<>(); - boolean valid = true; - for (InputSplit split : splits) { - String fileName = ((FileSplit) split).getPath().getName(); - OptionalInt bucket = getBucketNumberFromPath(fileName); - if (bucket.isPresent()) { - int bucketId = bucket.getAsInt(); - if (bucketId >= numBuckets) { - valid = false; - if (LOG.isDebugEnabled()) { - LOG.debug("Hive table {} is corrupt for file {}(bucketId={}), skip bucket pruning.", - tableName, fileName, bucketId); - } - break; - } - if (buckets.contains(bucketId)) { - result.add(split); - } - } else { - valid = false; - if (LOG.isDebugEnabled()) { - LOG.debug("File {} is not a bucket file in hive table {}, skip bucket pruning.", - fileName, tableName); - } - break; - } - } - if (valid) { - if (LOG.isDebugEnabled()) { - LOG.debug("{} / {} input splits in hive table {} after bucket pruning.", - result.size(), splits.size(), tableName); - } - return result; - } else { - return splits; - } - } - - public static Optional> getPrunedBuckets( - List conjuncts, List bucketCols, int numBuckets, Map parameters) - throws DdlException { - if (parameters.containsKey("spark.sql.sources.provider")) { - // spark currently does not populate bucketed output which is compatible with Hive. - return Optional.empty(); - } - int bucketVersion = Integer.parseInt(parameters.getOrDefault("bucketing_version", "1")); - Optional> result = Optional.empty(); - for (Expr conjunct : conjuncts) { - Optional> buckets = getPrunedBuckets(conjunct, bucketCols, bucketVersion, numBuckets); - if (buckets.isPresent()) { - if (!result.isPresent()) { - result = Optional.of(new HashSet<>(buckets.get())); - } else { - result.get().retainAll(buckets.get()); - } - } - } - return result; - } - - public static Optional> getPrunedBuckets( - Expr dorisExpr, List bucketCols, int bucketVersion, int numBuckets) throws DdlException { - // TODO(gaoxin): support multiple bucket columns - if (dorisExpr == null || bucketCols == null || bucketCols.size() != 1) { - return Optional.empty(); - } - String bucketCol = bucketCols.get(0); - if (dorisExpr instanceof CompoundPredicate) { - CompoundPredicate compoundPredicate = (CompoundPredicate) dorisExpr; - Optional> result = Optional.empty(); - Optional> left = getPrunedBuckets( - compoundPredicate.getChild(0), bucketCols, bucketVersion, numBuckets); - Optional> right = getPrunedBuckets( - compoundPredicate.getChild(1), bucketCols, bucketVersion, numBuckets); - if (left.isPresent()) { - result = Optional.of(new HashSet<>(left.get())); - } - switch (compoundPredicate.getOp()) { - case AND: { - if (right.isPresent()) { - if (result.isPresent()) { - result.get().retainAll(right.get()); - } else { - result = Optional.of(new HashSet<>(right.get())); - } - } - break; - } - case OR: { - if (right.isPresent()) { - if (result.isPresent()) { - result.get().addAll(right.get()); - } - } else { - result = Optional.empty(); - } - break; - } - default: - result = Optional.empty(); - } - return result; - } else if (dorisExpr instanceof BinaryPredicate || dorisExpr instanceof InPredicate) { - return pruneBucketsFromPredicate(dorisExpr, bucketCol, bucketVersion, numBuckets); - } else { - return Optional.empty(); - } - } - - private static Optional> getPrunedBucketsFromLiteral( - SlotRef slotRef, LiteralExpr literalExpr, String bucketCol, int bucketVersion, int numBuckets) - throws DdlException { - if (slotRef == null || literalExpr == null) { - return Optional.empty(); - } - String colName = slotRef.getColumnName(); - // check whether colName is bucket column or not - if (!bucketCol.equals(colName)) { - return Optional.empty(); - } - PrimitiveType dorisPrimitiveType = slotRef.getType().getPrimitiveType(); - if (!SUPPORTED_TYPES_FOR_BUCKET_FILTER.contains(dorisPrimitiveType)) { - return Optional.empty(); - } - Object value = HiveMetaStoreClientHelper.extractDorisLiteral(literalExpr); - if (value == null) { - return Optional.empty(); - } - PrimitiveObjectInspector constOI = PrimitiveObjectInspectorFactory.getPrimitiveWritableObjectInspector( - convertToHiveColType(dorisPrimitiveType).getPrimitiveCategory()); - PrimitiveObjectInspector origOI = - PrimitiveObjectInspectorFactory.getPrimitiveObjectInspectorFromClass(value.getClass()); - Converter conv = ObjectInspectorConverters.getConverter(origOI, constOI); - if (conv == null) { - return Optional.empty(); - } - Object[] convCols = new Object[] {conv.convert(value)}; - int bucketId = getBucketNumber(convCols, new ObjectInspector[]{constOI}, bucketVersion, numBuckets); - return Optional.of(ImmutableSet.of(bucketId)); - } - - private static Optional> pruneBucketsFromPredicate( - Expr dorisExpr, String bucketCol, int bucketVersion, int numBuckets) throws DdlException { - if (dorisExpr instanceof BinaryPredicate - && ((BinaryPredicate) dorisExpr).getOp() == BinaryPredicate.Operator.EQ) { - // Make sure the col slot is always first - SlotRef slotRef = HiveMetaStoreClientHelper.convertDorisExprToSlotRef(dorisExpr.getChild(0)); - LiteralExpr literalExpr = - HiveMetaStoreClientHelper.convertDorisExprToLiteralExpr(dorisExpr.getChild(1)); - return getPrunedBucketsFromLiteral(slotRef, literalExpr, bucketCol, bucketVersion, numBuckets); - } else if (dorisExpr instanceof InPredicate && !((InPredicate) dorisExpr).isNotIn()) { - SlotRef slotRef = HiveMetaStoreClientHelper.convertDorisExprToSlotRef(dorisExpr.getChild(0)); - Optional> result = Optional.empty(); - for (int i = 1; i < dorisExpr.getChildren().size(); i++) { - LiteralExpr literalExpr = - HiveMetaStoreClientHelper.convertDorisExprToLiteralExpr(dorisExpr.getChild(i)); - Optional> childBucket = - getPrunedBucketsFromLiteral(slotRef, literalExpr, bucketCol, bucketVersion, numBuckets); - if (childBucket.isPresent()) { - if (result.isPresent()) { - result.get().addAll(childBucket.get()); - } else { - result = Optional.of(new HashSet<>(childBucket.get())); - } - } else { - return Optional.empty(); - } - } - return result; - } else { - return Optional.empty(); - } - } - - private static int getBucketNumber( - Object[] bucketFields, ObjectInspector[] bucketFieldInspectors, int bucketVersion, int numBuckets) - throws DdlException { - int hashCode = bucketVersion == 2 ? getBucketHashCodeV2(bucketFields, bucketFieldInspectors) - : getBucketHashCodeV1(bucketFields, bucketFieldInspectors); - return (hashCode & Integer.MAX_VALUE) % numBuckets; - } - - private static int getBucketHashCodeV1(Object[] bucketFields, ObjectInspector[] bucketFieldInspectors) - throws DdlException { - int hashCode = 0; - for (int i = 0; i < bucketFields.length; i++) { - int fieldHash = hashCodeV1(bucketFields[i], bucketFieldInspectors[i]); - hashCode = 31 * hashCode + fieldHash; - } - return hashCode; - } - - private static int getBucketHashCodeV2(Object[] bucketFields, ObjectInspector[] bucketFieldInspectors) - throws DdlException { - int hashCode = 0; - ByteBuffer b = ByteBuffer.allocate(8); // To be used with primitive types - for (int i = 0; i < bucketFields.length; i++) { - int fieldHash = hashCodeV2(bucketFields[i], bucketFieldInspectors[i], b); - hashCode = 31 * hashCode + fieldHash; - } - return hashCode; - } - - private static int hashCodeV1(Object o, ObjectInspector objIns) throws DdlException { - if (o == null) { - return 0; - } - if (objIns.getCategory() == Category.PRIMITIVE) { - PrimitiveObjectInspector poi = ((PrimitiveObjectInspector) objIns); - switch (poi.getPrimitiveCategory()) { - case BOOLEAN: - return ((BooleanObjectInspector) poi).get(o) ? 1 : 0; - case BYTE: - return ((ByteObjectInspector) poi).get(o); - case SHORT: - return ((ShortObjectInspector) poi).get(o); - case INT: - return ((IntObjectInspector) poi).get(o); - case LONG: { - long a = ((LongObjectInspector) poi).get(o); - return (int) ((a >>> 32) ^ a); - } - case STRING: { - // This hash function returns the same result as String.hashCode() when - // all characters are ASCII, while Text.hashCode() always returns a - // different result. - Text t = ((StringObjectInspector) poi).getPrimitiveWritableObject(o); - int r = 0; - for (int i = 0; i < t.getLength(); i++) { - r = r * 31 + t.getBytes()[i]; - } - return r; - } - default: - throw new DdlException("Unknown type: " + poi.getPrimitiveCategory()); - } - } - throw new DdlException("Unknown type: " + objIns.getTypeName()); - } - - private static int hashCodeV2(Object o, ObjectInspector objIns, ByteBuffer byteBuffer) throws DdlException { - // Reset the bytebuffer - byteBuffer.clear(); - if (objIns.getCategory() == Category.PRIMITIVE) { - PrimitiveObjectInspector poi = ((PrimitiveObjectInspector) objIns); - switch (poi.getPrimitiveCategory()) { - case BOOLEAN: - return (((BooleanObjectInspector) poi).get(o) ? 1 : 0); - case BYTE: - return ((ByteObjectInspector) poi).get(o); - case SHORT: { - byteBuffer.putShort(((ShortObjectInspector) poi).get(o)); - return Murmur3.hash32(byteBuffer.array(), 2, 104729); - } - case INT: { - byteBuffer.putInt(((IntObjectInspector) poi).get(o)); - return Murmur3.hash32(byteBuffer.array(), 4, 104729); - } - case LONG: { - byteBuffer.putLong(((LongObjectInspector) poi).get(o)); - return Murmur3.hash32(byteBuffer.array(), 8, 104729); - } - case STRING: { - // This hash function returns the same result as String.hashCode() when - // all characters are ASCII, while Text.hashCode() always returns a - // different result. - Text text = ((StringObjectInspector) poi).getPrimitiveWritableObject(o); - return Murmur3.hash32(text.getBytes(), text.getLength(), 104729); - } - default: - throw new DdlException("Unknown type: " + poi.getPrimitiveCategory()); - } - } - throw new DdlException("Unknown type: " + objIns.getTypeName()); - } - - private static OptionalInt getBucketNumberFromPath(String name) { - for (Pattern pattern : BUCKET_PATTERNS) { - Matcher matcher = pattern.matcher(name); - if (matcher.matches()) { - return OptionalInt.of(Integer.parseInt(matcher.group(1))); - } - } - return OptionalInt.empty(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveColumnStatistics.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveColumnStatistics.java deleted file mode 100644 index 96cb6b5728bd36..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveColumnStatistics.java +++ /dev/null @@ -1,30 +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.datasource.hive; - - -public class HiveColumnStatistics { - - private long totalSizeBytes; - private long numNulls; - private long ndv; - private final double min = Double.NEGATIVE_INFINITY; - private final double max = Double.POSITIVE_INFINITY; - - // TODO add hive column statistics -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveDatabaseMetadata.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveDatabaseMetadata.java deleted file mode 100644 index a1a383e0d07fea..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveDatabaseMetadata.java +++ /dev/null @@ -1,41 +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.datasource.hive; - -import org.apache.doris.datasource.DatabaseMetadata; - -import lombok.Data; - -import java.util.HashMap; -import java.util.Map; - -@Data -public class HiveDatabaseMetadata implements DatabaseMetadata { - private String dbName; - private String locationUri; - private Map properties; - private String comment; - - public Map getProperties() { - return properties == null ? new HashMap<>() : properties; - } - - public String getComment() { - return comment == null ? "" : comment; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveDlaTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveDlaTable.java deleted file mode 100644 index 6b0a4c8e65f8e8..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveDlaTable.java +++ /dev/null @@ -1,141 +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.datasource.hive; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.PartitionItem; -import org.apache.doris.catalog.PartitionType; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.mvcc.MvccSnapshot; -import org.apache.doris.mtmv.MTMVMaxTimestampSnapshot; -import org.apache.doris.mtmv.MTMVRefreshContext; -import org.apache.doris.mtmv.MTMVSnapshotIf; -import org.apache.doris.mtmv.MTMVTimestampSnapshot; - -import com.google.common.collect.Lists; -import org.apache.commons.collections4.CollectionUtils; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -public class HiveDlaTable extends HMSDlaTable { - - public HiveDlaTable(HMSExternalTable table) { - super(table); - } - - @Override - public PartitionType getPartitionType(Optional snapshot) { - return getPartitionColumns(snapshot).size() > 0 ? PartitionType.LIST : PartitionType.UNPARTITIONED; - } - - @Override - public Set getPartitionColumnNames(Optional snapshot) { - return getPartitionColumns(snapshot).stream() - .map(c -> c.getName().toLowerCase()).collect(Collectors.toSet()); - } - - @Override - public List getPartitionColumns(Optional snapshot) { - Optional schemaCacheValue = hmsTable.getSchemaCacheValue(); - return schemaCacheValue.map(value -> ((HMSSchemaCacheValue) value).getPartitionColumns()) - .orElse(Collections.emptyList()); - } - - @Override - public Map getAndCopyPartitionItems(Optional snapshot) { - return hmsTable.getNameToPartitionItems(); - } - - @Override - public MTMVSnapshotIf getPartitionSnapshot(String partitionName, MTMVRefreshContext context, - Optional snapshot) throws AnalysisException { - HiveExternalMetaCache.HivePartitionValues hivePartitionValues = hmsTable.getHivePartitionValues(snapshot); - checkPartitionExists(partitionName, hivePartitionValues); - HiveExternalMetaCache cache = Env.getCurrentEnv().getExtMetaCacheMgr() - .hive(hmsTable.getCatalog().getId()); - HivePartition hivePartition = getHivePartitionByNameOrAnalysisException(partitionName, - hivePartitionValues, cache); - return new MTMVTimestampSnapshot(hivePartition.getLastModifiedTime()); - } - - @Override - public MTMVSnapshotIf getTableSnapshot(MTMVRefreshContext context, Optional snapshot) - throws AnalysisException { - return getTableSnapshot(snapshot); - } - - @Override - public MTMVSnapshotIf getTableSnapshot(Optional snapshot) throws AnalysisException { - if (hmsTable.getPartitionType(snapshot) == PartitionType.UNPARTITIONED) { - return new MTMVMaxTimestampSnapshot(hmsTable.getName(), hmsTable.getLastDdlTime()); - } - HivePartition maxPartition = null; - long maxVersionTime = 0L; - long visibleVersionTime; - HiveExternalMetaCache.HivePartitionValues hivePartitionValues = hmsTable.getHivePartitionValues(snapshot); - HiveExternalMetaCache cache = Env.getCurrentEnv().getExtMetaCacheMgr() - .hive(hmsTable.getCatalog().getId()); - List partitionList = cache.getAllPartitionsWithCache(hmsTable, - Lists.newArrayList(hivePartitionValues.getNameToPartitionValues().values())); - if (CollectionUtils.isEmpty(partitionList)) { - return new MTMVMaxTimestampSnapshot(hmsTable.getName(), 0L); - } - for (HivePartition hivePartition : partitionList) { - visibleVersionTime = hivePartition.getLastModifiedTime(); - if (visibleVersionTime > maxVersionTime) { - maxVersionTime = visibleVersionTime; - maxPartition = hivePartition; - } - } - return new MTMVMaxTimestampSnapshot(maxPartition.getPartitionName( - hmsTable.getPartitionColumns()), maxVersionTime); - } - - private void checkPartitionExists(String partitionName, - HiveExternalMetaCache.HivePartitionValues hivePartitionValues) throws AnalysisException { - if (!hivePartitionValues.getNameToPartitionValues().containsKey(partitionName)) { - throw new AnalysisException("can not find partition: " + partitionName); - } - } - - private HivePartition getHivePartitionByNameOrAnalysisException(String partitionName, - HiveExternalMetaCache.HivePartitionValues hivePartitionValues, - HiveExternalMetaCache cache) throws AnalysisException { - List partitionValues = hivePartitionValues.getNameToPartitionValues().get(partitionName); - if (CollectionUtils.isEmpty(partitionValues)) { - throw new AnalysisException("can not find partitionValues: " + partitionName); - } - HivePartition partition = cache.getHivePartition(hmsTable, partitionValues); - if (partition == null) { - throw new AnalysisException("can not find partition: " + partitionName); - } - return partition; - } - - @Override - public boolean isPartitionColumnAllowNull() { - return true; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveExternalMetaCache.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveExternalMetaCache.java deleted file mode 100644 index 28e8b248620f79..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveExternalMetaCache.java +++ /dev/null @@ -1,1088 +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.datasource.hive; - -import org.apache.doris.analysis.PartitionValue; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.ListPartitionItem; -import org.apache.doris.catalog.PartitionItem; -import org.apache.doris.catalog.PartitionKey; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.catalog.Type; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.Config; -import org.apache.doris.common.UserException; -import org.apache.doris.common.security.authentication.AuthenticationConfig; -import org.apache.doris.common.security.authentication.HadoopAuthenticator; -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.CacheException; -import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.SchemaCacheKey; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.metacache.AbstractExternalMetaCache; -import org.apache.doris.datasource.metacache.CacheSpec; -import org.apache.doris.datasource.metacache.MetaCacheEntry; -import org.apache.doris.datasource.metacache.MetaCacheEntryDef; -import org.apache.doris.filesystem.BlockInfo; -import org.apache.doris.filesystem.FileEntry; -import org.apache.doris.filesystem.FileSystem; -import org.apache.doris.filesystem.FileSystemIOException; -import org.apache.doris.filesystem.RemoteIterator; -import org.apache.doris.fs.DirectoryLister; -import org.apache.doris.fs.FileSystemCache; -import org.apache.doris.fs.FileSystemDirectoryLister; -import org.apache.doris.nereids.rules.expression.rules.SortedPartitionRanges; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Preconditions; -import com.google.common.base.Strings; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import com.google.common.collect.Streams; -import lombok.Data; -import org.apache.hadoop.fs.BlockLocation; -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.StorageDescriptor; -import org.apache.hadoop.hive.metastore.utils.FileUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; -import java.util.stream.Collectors; - -/** - * Hive engine implementation of {@link AbstractExternalMetaCache}. - * - *

    This cache consolidates schema metadata and Hive Metastore-derived runtime metadata - * under one engine so callers can use a unified invalidation path. - * - *

    Registered entries: - *

      - *
    • {@code schema}: table schema cache keyed by {@link SchemaCacheKey}
    • - *
    • {@code partition_values}: partition value/index structures per table
    • - *
    • {@code partition}: single partition metadata keyed by partition values
    • - *
    • {@code file}: file listing cache for partition/table locations
    • - *
    - * - *

    Invalidation behavior: - *

      - *
    • {@link #invalidateDb(long, String)} and {@link #invalidateTable(long, String, String)} - * clear all related entries with table/db granularity.
    • - *
    • {@link #invalidatePartitions(long, String, String, List)} supports partition-level - * invalidation when specific partition names are provided, and falls back to table-level - * invalidation for empty input or unresolved table metadata.
    • - *
    - */ -public class HiveExternalMetaCache extends AbstractExternalMetaCache { - private static final Logger LOG = LogManager.getLogger(HiveExternalMetaCache.class); - - public static final String ENGINE = "hive"; - public static final String ENTRY_SCHEMA = "schema"; - public static final String ENTRY_PARTITION_VALUES = "partition_values"; - public static final String ENTRY_PARTITION = "partition"; - public static final String ENTRY_FILE = "file"; - - public static final String HIVE_DEFAULT_PARTITION = "__HIVE_DEFAULT_PARTITION__"; - public static final String ERR_CACHE_INCONSISTENCY = "ERR_CACHE_INCONSISTENCY: "; - - private final ExecutorService fileListingExecutor; - - private final EntryHandle schemaEntry; - private final EntryHandle partitionValuesEntry; - private final EntryHandle partitionEntry; - private final EntryHandle fileEntry; - private final PartitionCacheCoordinator partitionCacheCoordinator = new PartitionCacheCoordinator(); - - public HiveExternalMetaCache(ExecutorService refreshExecutor, ExecutorService fileListingExecutor) { - super(ENGINE, refreshExecutor); - this.fileListingExecutor = fileListingExecutor; - - schemaEntry = registerEntry(MetaCacheEntryDef.of( - ENTRY_SCHEMA, - SchemaCacheKey.class, - SchemaCacheValue.class, - this::loadSchemaCacheValue, - defaultSchemaCacheSpec())); - partitionValuesEntry = registerEntry(MetaCacheEntryDef.of( - ENTRY_PARTITION_VALUES, - PartitionValueCacheKey.class, - HivePartitionValues.class, - this::loadPartitionValuesCacheValue, - CacheSpec.of( - true, - Config.external_cache_expire_time_seconds_after_access, - Config.max_hive_partition_table_cache_num))); - partitionEntry = registerEntry(MetaCacheEntryDef.of( - ENTRY_PARTITION, - PartitionCacheKey.class, - HivePartition.class, - this::loadPartitionCacheValue, - CacheSpec.of( - true, - Config.external_cache_expire_time_seconds_after_access, - Config.max_hive_partition_cache_num))); - fileEntry = registerEntry(MetaCacheEntryDef.of( - ENTRY_FILE, - FileCacheKey.class, - FileCacheValue.class, - this::loadFileCacheValue, - CacheSpec.of( - true, - Config.external_cache_expire_time_seconds_after_access, - Config.max_external_file_cache_num))); - } - - @Override - public Collection aliases() { - return Collections.singleton("hms"); - } - - public void refreshCatalog(long catalogId) { - invalidateCatalog(catalogId); - CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(catalogId); - Map catalogProperties = catalog == null || catalog.getProperties() == null - ? Maps.newHashMap() - : Maps.newHashMap(catalog.getProperties()); - initCatalog(catalogId, catalogProperties); - } - - @Override - public void invalidateDb(long catalogId, String dbName) { - schemaEntry.get(catalogId).invalidateIf(key -> matchDb(key.getNameMapping(), dbName)); - partitionValuesEntry.get(catalogId).invalidateIf(key -> matchDb(key.getNameMapping(), dbName)); - partitionEntry.get(catalogId).invalidateIf(key -> matchDb(key.getNameMapping(), dbName)); - fileEntry.get(catalogId).invalidateAll(); - } - - @Override - public void invalidateTable(long catalogId, String dbName, String tableName) { - schemaEntry.get(catalogId).invalidateIf(key -> matchTable(key.getNameMapping(), dbName, tableName)); - partitionValuesEntry.get(catalogId).invalidateIf(key -> matchTable(key.getNameMapping(), dbName, tableName)); - partitionEntry.get(catalogId).invalidateIf(key -> matchTable(key.getNameMapping(), dbName, tableName)); - long tableId = Util.genIdByName(dbName, tableName); - fileEntry.get(catalogId).invalidateIf(key -> key.isSameTable(tableId)); - } - - @Override - public void invalidatePartitions(long catalogId, String dbName, String tableName, List partitions) { - if (partitions == null || partitions.isEmpty()) { - invalidateTable(catalogId, dbName, tableName); - return; - } - - CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(catalogId); - if (!(catalog instanceof HMSExternalCatalog)) { - return; - } - - HMSExternalCatalog hmsCatalog = (HMSExternalCatalog) catalog; - if (hmsCatalog.getDbNullable(dbName) == null - || !(hmsCatalog.getDbNullable(dbName).getTableNullable(tableName) instanceof HMSExternalTable)) { - invalidateTable(catalogId, dbName, tableName); - return; - } - HMSExternalTable hmsTable = (HMSExternalTable) hmsCatalog.getDbNullable(dbName).getTableNullable(tableName); - - for (String partition : partitions) { - invalidatePartitionCache(hmsTable, partition); - } - } - - @Override - protected Map catalogPropertyCompatibilityMap() { - Map compatibilityMap = Maps.newHashMap(); - compatibilityMap.put(ExternalCatalog.SCHEMA_CACHE_TTL_SECOND, metaCacheTtlKey(ENTRY_SCHEMA)); - compatibilityMap.put(HMSExternalCatalog.PARTITION_CACHE_TTL_SECOND, metaCacheTtlKey(ENTRY_PARTITION_VALUES)); - compatibilityMap.put(HMSExternalCatalog.FILE_META_CACHE_TTL_SECOND, metaCacheTtlKey(ENTRY_FILE)); - return compatibilityMap; - } - - private MetaCacheEntry schemaEntryIfInitialized(long catalogId) { - return schemaEntry.getIfInitialized(catalogId); - } - - private MetaCacheEntry partitionValuesEntryIfInitialized( - long catalogId) { - return partitionValuesEntry.getIfInitialized(catalogId); - } - - private MetaCacheEntry partitionEntryIfInitialized(long catalogId) { - return partitionEntry.getIfInitialized(catalogId); - } - - private MetaCacheEntry fileEntryIfInitialized(long catalogId) { - return fileEntry.getIfInitialized(catalogId); - } - - private SchemaCacheValue loadSchemaCacheValue(SchemaCacheKey key) { - CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(key.getNameMapping().getCtlId()); - if (!(catalog instanceof ExternalCatalog)) { - throw new CacheException("catalog %s is not external when loading hive schema cache", - null, key.getNameMapping().getCtlId()); - } - ExternalCatalog externalCatalog = (ExternalCatalog) catalog; - return externalCatalog.getSchema(key).orElseThrow(() -> new CacheException( - "failed to load hive schema cache value for: %s.%s.%s", - null, key.getNameMapping().getCtlId(), - key.getNameMapping().getLocalDbName(), - key.getNameMapping().getLocalTblName())); - } - - private HivePartitionValues loadPartitionValuesCacheValue(PartitionValueCacheKey key) { - return loadPartitionValues(key); - } - - private HivePartition loadPartitionCacheValue(PartitionCacheKey key) { - return loadPartition(key); - } - - private FileCacheValue loadFileCacheValue(FileCacheKey key) { - return loadFiles(key, new FileSystemDirectoryLister(), null); - } - - private HMSExternalCatalog hmsCatalog(long catalogId) { - CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(catalogId); - if (!(catalog instanceof HMSExternalCatalog)) { - throw new CacheException("catalog %s is not hms when loading hive metastore cache", null, catalogId); - } - return (HMSExternalCatalog) catalog; - } - - private HivePartitionValues loadPartitionValues(PartitionValueCacheKey key) { - NameMapping nameMapping = key.nameMapping; - HMSExternalCatalog catalog = hmsCatalog(nameMapping.getCtlId()); - List partitionNames = catalog.getClient() - .listPartitionNames(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName()); - if (LOG.isDebugEnabled()) { - LOG.debug("load #{} partitions for {} in catalog {}", partitionNames.size(), key, catalog.getName()); - } - - Map nameToPartitionItem = Maps.newHashMapWithExpectedSize(partitionNames.size()); - Map> nameToPartitionValues = Maps.newHashMapWithExpectedSize(partitionNames.size()); - for (String partitionName : partitionNames) { - ListPartitionItem listPartitionItem = toListPartitionItem(partitionName, key.types, catalog.getName()); - nameToPartitionItem.put(partitionName, listPartitionItem); - nameToPartitionValues.put(partitionName, HiveUtil.toPartitionValues(partitionName)); - } - - return new HivePartitionValues(nameToPartitionItem, nameToPartitionValues); - } - - private ListPartitionItem toListPartitionItem(String partitionName, List types, String catalogName) { - List partitionValues = HiveUtil.toPartitionValues(partitionName); - Preconditions.checkState(types != null, - ERR_CACHE_INCONSISTENCY + "partition types is null for partition " + partitionName); - Preconditions.checkState(partitionValues.size() == types.size(), - ERR_CACHE_INCONSISTENCY + partitionName + " vs. " + types); - - List values = Lists.newArrayListWithExpectedSize(types.size()); - for (String partitionValue : partitionValues) { - values.add(new PartitionValue(partitionValue, HIVE_DEFAULT_PARTITION.equals(partitionValue))); - } - try { - PartitionKey partitionKey = PartitionKey.createListPartitionKeyWithTypes(values, types, true); - return new ListPartitionItem(Lists.newArrayList(partitionKey)); - } catch (AnalysisException e) { - throw new CacheException("failed to convert hive partition %s to list partition in catalog %s", - e, partitionName, catalogName); - } - } - - private HivePartition loadPartition(PartitionCacheKey key) { - NameMapping nameMapping = key.nameMapping; - HMSExternalCatalog catalog = hmsCatalog(nameMapping.getCtlId()); - Partition partition = catalog.getClient() - .getPartition(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), key.values); - StorageDescriptor sd = partition.getSd(); - if (LOG.isDebugEnabled()) { - LOG.debug("load partition format: {}, location: {} for {} in catalog {}", - sd.getInputFormat(), sd.getLocation(), key, catalog.getName()); - } - return new HivePartition(nameMapping, false, sd.getInputFormat(), sd.getLocation(), key.values, - partition.getParameters()); - } - - private Map loadPartitions(Iterable keys) { - Map result = new HashMap<>(); - if (keys == null) { - return result; - } - - List keyList = Streams.stream(keys).collect(Collectors.toList()); - if (keyList.isEmpty()) { - return result; - } - - PartitionCacheKey oneKey = keyList.get(0); - NameMapping nameMapping = oneKey.nameMapping; - HMSExternalCatalog catalog = hmsCatalog(nameMapping.getCtlId()); - - String localDbName = nameMapping.getLocalDbName(); - String localTblName = nameMapping.getLocalTblName(); - List partitionColumns = ((HMSExternalTable) catalog.getDbNullable(localDbName) - .getTableNullable(localTblName)).getPartitionColumns(); - - List partitionNames = keyList.stream().map(key -> { - StringBuilder sb = new StringBuilder(); - Preconditions.checkState(key.getValues().size() == partitionColumns.size()); - for (int i = 0; i < partitionColumns.size(); i++) { - sb.append(FileUtils.escapePathName(partitionColumns.get(i).getName())); - sb.append("="); - sb.append(FileUtils.escapePathName(key.getValues().get(i))); - sb.append("/"); - } - sb.delete(sb.length() - 1, sb.length()); - return sb.toString(); - }).collect(Collectors.toList()); - - List partitions = catalog.getClient().getPartitions( - nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), partitionNames); - for (Partition partition : partitions) { - StorageDescriptor sd = partition.getSd(); - result.put(new PartitionCacheKey(nameMapping, partition.getValues()), - new HivePartition(nameMapping, false, - sd.getInputFormat(), sd.getLocation(), partition.getValues(), - partition.getParameters())); - } - return result; - } - - private FileCacheValue getFileCache(HMSExternalCatalog catalog, - LocationPath path, - String inputFormat, - List partitionValues, - DirectoryLister directoryLister, - TableIf table) throws UserException { - FileCacheValue result = new FileCacheValue(); - - FileSystemCache.FileSystemCacheKey fileSystemCacheKey = new FileSystemCache.FileSystemCacheKey( - path.getFsIdentifier(), path.getStorageProperties()); - - boolean isRecursiveDirectories = Boolean.valueOf( - catalog.getProperties().getOrDefault("hive.recursive_directories", "true")); - try (FileSystemCache.FileSystemLease fileSystemLease = Env.getCurrentEnv().getExtMetaCacheMgr().getFsCache() - .getFileSystem(fileSystemCacheKey)) { - FileSystem fs = fileSystemLease.fileSystem(); - result.setSplittable(HiveUtil.isSplittable(fs, inputFormat, path.getNormalizedLocation())); - RemoteIterator iterator = directoryLister.listFiles(fs, isRecursiveDirectories, - table, path.getNormalizedLocation()); - boolean isLzoInputFormat = HiveUtil.isLzoInputFormat(inputFormat); - while (iterator.hasNext()) { - FileEntry entry = iterator.next(); - String srcPath = entry.location().uri().toString(); - // For LZO text InputFormats, only include *.lzo data files. - // *.lzo.index sidecar files and any other non-*.lzo files must be excluded, - // mirroring the file filtering semantics of Hive's LzoTextInputFormat.listStatus(). - if (isLzoInputFormat && !HiveUtil.isLzoDataFile(srcPath)) { - continue; - } - LocationPath locationPath = LocationPath.of(srcPath, path.getStorageProperties()); - result.addFile(entry, locationPath); - } - } catch (FileSystemIOException e) { - if (e.getCause() instanceof java.io.FileNotFoundException) { - LOG.warn("Partition location {} does not exist.", path.getNormalizedLocation()); - if (!Boolean.parseBoolean(catalog.getProperties() - .getOrDefault("hive.ignore_absent_partitions", "true"))) { - throw new UserException( - "Partition location does not exist: " + path.getNormalizedLocation()); - } - // hive.ignore_absent_partitions=true: fall through with empty file list - } else { - throw new RuntimeException(e); - } - } - - result.setPartitionValues(Lists.newArrayList(partitionValues)); - return result; - } - - private FileCacheValue loadFiles(FileCacheKey key, DirectoryLister directoryLister, TableIf table) { - ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); - HMSExternalCatalog catalog = hmsCatalog(key.catalogId); - try { - Thread.currentThread().setContextClassLoader(ClassLoader.getSystemClassLoader()); - LocationPath finalLocation = LocationPath.of( - key.getLocation(), catalog.getCatalogProperty().getStoragePropertiesMap()); - try { - FileCacheValue result = getFileCache(catalog, finalLocation, key.inputFormat, - key.getPartitionValues(), directoryLister, table); - for (int i = 0; i < result.getValuesSize(); i++) { - if (HIVE_DEFAULT_PARTITION.equals(result.getPartitionValues().get(i))) { - result.getPartitionValues().set(i, null); - } - } - - if (LOG.isDebugEnabled()) { - LOG.debug("load #{} splits for {} in catalog {}", - result.getFiles().size(), key, catalog.getName()); - } - return result; - } catch (Exception e) { - throw new CacheException("failed to get input splits for %s in catalog %s", - e, key, catalog.getName()); - } - } finally { - Thread.currentThread().setContextClassLoader(classLoader); - } - } - - public HivePartitionValues getPartitionValues(ExternalTable dorisTable, List types) { - PartitionValueCacheKey key = new PartitionValueCacheKey(dorisTable.getOrBuildNameMapping(), types); - return getPartitionValues(key); - } - - @VisibleForTesting - public HivePartitionValues getPartitionValues(PartitionValueCacheKey key) { - return partitionValuesEntry.get(key.nameMapping.getCtlId()).get(key); - } - - public List getFilesByPartitions(List partitions, - boolean withCache, - boolean concurrent, - DirectoryLister directoryLister, - TableIf table) { - long start = System.currentTimeMillis(); - if (partitions.isEmpty()) { - return Lists.newArrayList(); - } - - HivePartition firstPartition = partitions.get(0); - long catalogId = firstPartition.getNameMapping().getCtlId(); - long fileId = Util.genIdByName(firstPartition.getNameMapping().getLocalDbName(), - firstPartition.getNameMapping().getLocalTblName()); - List keys = partitions.stream().map(p -> p.isDummyPartition() - ? FileCacheKey.createDummyCacheKey(catalogId, fileId, p.getPath(), p.getInputFormat()) - : new FileCacheKey(catalogId, fileId, p.getPath(), p.getInputFormat(), p.getPartitionValues())) - .collect(Collectors.toList()); - - List fileLists; - try { - if (withCache) { - MetaCacheEntry fileEntry = this.fileEntry.get(catalogId); - fileLists = keys.stream().map(fileEntry::get).collect(Collectors.toList()); - } else if (concurrent) { - List> futures = keys.stream().map( - key -> fileListingExecutor.submit(() -> loadFiles(key, directoryLister, table))) - .collect(Collectors.toList()); - fileLists = Lists.newArrayListWithExpectedSize(keys.size()); - for (Future future : futures) { - fileLists.add(future.get()); - } - } else { - fileLists = keys.stream() - .map(key -> loadFiles(key, directoryLister, table)) - .collect(Collectors.toList()); - } - } catch (ExecutionException e) { - throw new CacheException("failed to get files from partitions in catalog %s", - e, hmsCatalog(catalogId).getName()); - } catch (InterruptedException e) { - throw new CacheException("failed to get files from partitions in catalog %s with interrupted exception", - e, hmsCatalog(catalogId).getName()); - } - - if (LOG.isDebugEnabled()) { - LOG.debug("get #{} files from #{} partitions in catalog {} cost: {} ms", - fileLists.stream().mapToInt(l -> l.getFiles() == null ? 0 : l.getFiles().size()).sum(), - partitions.size(), hmsCatalog(catalogId).getName(), (System.currentTimeMillis() - start)); - } - return fileLists; - } - - public HivePartition getHivePartition(ExternalTable dorisTable, List partitionValues) { - NameMapping nameMapping = dorisTable.getOrBuildNameMapping(); - return partitionEntry.get(nameMapping.getCtlId()).get(new PartitionCacheKey(nameMapping, partitionValues)); - } - - public List getAllPartitionsWithCache(ExternalTable dorisTable, - List> partitionValuesList) { - return getAllPartitions(dorisTable, partitionValuesList, true); - } - - public List getAllPartitionsWithoutCache(ExternalTable dorisTable, - List> partitionValuesList) { - return getAllPartitions(dorisTable, partitionValuesList, false); - } - - private List getAllPartitions(ExternalTable dorisTable, - List> partitionValuesList, - boolean withCache) { - long start = System.currentTimeMillis(); - NameMapping nameMapping = dorisTable.getOrBuildNameMapping(); - long catalogId = nameMapping.getCtlId(); - List keys = partitionValuesList.stream() - .map(p -> new PartitionCacheKey(nameMapping, p)) - .collect(Collectors.toList()); - - List partitions; - if (withCache) { - MetaCacheEntry partitionEntry = this.partitionEntry.get(catalogId); - partitions = keys.stream().map(partitionEntry::get).collect(Collectors.toList()); - } else { - partitions = new ArrayList<>(loadPartitions(keys).values()); - } - - if (LOG.isDebugEnabled()) { - LOG.debug("get #{} partitions in catalog {} cost: {} ms", partitions.size(), - hmsCatalog(catalogId).getName(), (System.currentTimeMillis() - start)); - } - return partitions; - } - - public void invalidateTableCache(NameMapping nameMapping) { - long catalogId = nameMapping.getCtlId(); - - MetaCacheEntry partitionValuesEntry = - partitionValuesEntryIfInitialized(catalogId); - if (partitionValuesEntry != null) { - partitionValuesEntry.invalidateKey(new PartitionValueCacheKey(nameMapping, null)); - } - - MetaCacheEntry partitionEntry = partitionEntryIfInitialized(catalogId); - if (partitionEntry != null) { - partitionEntry.invalidateIf(k -> k.isSameTable( - nameMapping.getLocalDbName(), nameMapping.getLocalTblName())); - } - - MetaCacheEntry fileEntry = fileEntryIfInitialized(catalogId); - if (fileEntry != null) { - long tableId = Util.genIdByName(nameMapping.getLocalDbName(), nameMapping.getLocalTblName()); - fileEntry.invalidateIf(k -> k.isSameTable(tableId)); - } - } - - public void invalidatePartitionCache(ExternalTable dorisTable, String partitionName) { - invalidatePartitionCache(dorisTable.getOrBuildNameMapping(), partitionName); - } - - public void invalidatePartitionCache(NameMapping nameMapping, String partitionName) { - partitionCacheCoordinator.invalidatePartitionCache(nameMapping, partitionName); - } - - /** - * Selectively refreshes cache for affected partitions based on update information from BE. - */ - public void refreshAffectedPartitions(HMSExternalTable table, - List partitionUpdates, - List modifiedPartNames, - List newPartNames) { - partitionCacheCoordinator.refreshAffectedPartitions(table, partitionUpdates, modifiedPartNames, newPartNames); - } - - public void refreshAffectedPartitionsCache(HMSExternalTable table, - List modifiedPartNames, - List newPartNames) { - partitionCacheCoordinator.refreshAffectedPartitionsCache(table, modifiedPartNames, newPartNames); - } - - public void addPartitionsCache(NameMapping nameMapping, - List partitionNames, - List partitionColumnTypes) { - partitionCacheCoordinator.addPartitionsCache(nameMapping, partitionNames, partitionColumnTypes); - } - - public void dropPartitionsCache(ExternalTable dorisTable, - List partitionNames, - boolean invalidPartitionCache) { - partitionCacheCoordinator.dropPartitionsCache(dorisTable, partitionNames, invalidPartitionCache); - } - - private final class PartitionCacheCoordinator { - private void invalidatePartitionCache(NameMapping nameMapping, String partitionName) { - long catalogId = nameMapping.getCtlId(); - - MetaCacheEntry partitionEntry = partitionEntryIfInitialized(catalogId); - MetaCacheEntry fileEntry = fileEntryIfInitialized(catalogId); - if (partitionEntry == null || fileEntry == null) { - return; - } - - long tableId = Util.genIdByName(nameMapping.getLocalDbName(), nameMapping.getLocalTblName()); - // Derive the partition values directly from the partition name (the partition-values cache is - // populated the same way, via HiveUtil.toPartitionValues) instead of reading them from that - // cache, so file cache invalidation still runs when the table's partition-values cache entry has - // been evicted while its file listings are still cached. - List values = HiveUtil.toPartitionValues(partitionName); - - PartitionCacheKey partKey = new PartitionCacheKey(nameMapping, values); - HivePartition partition = partitionEntry.getIfPresent(partKey); - if (partition == null) { - // Partition metadata cache miss: the exact FileCacheKey cannot be rebuilt here because it - // needs the partition path and input format carried by HivePartition. Invalidate this - // table's cached file listings for the partition by (table id + partition values). Scoping - // by table id is intentional: matching partition values alone would also drop other tables' - // listings that merely share the same partition value names (e.g. dt=...) at a different - // location, forcing needless re-listing. The exact-key path below (taken when the partition - // is cached) already clears a listing regardless of which table id populated it. - fileEntry.invalidateIf(k -> k.isSameTable(tableId) && Objects.equals(k.getPartitionValues(), values)); - partitionEntry.invalidateKey(partKey); - return; - } - - fileEntry.invalidateKey(new FileCacheKey(nameMapping.getCtlId(), tableId, partition.getPath(), - partition.getInputFormat(), partition.getPartitionValues())); - partitionEntry.invalidateKey(partKey); - } - - private void refreshAffectedPartitions(HMSExternalTable table, - List partitionUpdates, - List modifiedPartNames, - List newPartNames) { - if (partitionUpdates == null || partitionUpdates.isEmpty()) { - return; - } - - for (org.apache.doris.thrift.THivePartitionUpdate update : partitionUpdates) { - String partitionName = update.getName(); - if (Strings.isNullOrEmpty(partitionName)) { - continue; - } - - switch (update.getUpdateMode()) { - case APPEND: - case OVERWRITE: - modifiedPartNames.add(partitionName); - break; - case NEW: - newPartNames.add(partitionName); - break; - default: - LOG.warn("Unknown update mode {} for partition {}", - update.getUpdateMode(), partitionName); - break; - } - } - - refreshAffectedPartitionsCache(table, modifiedPartNames, newPartNames); - } - - private void refreshAffectedPartitionsCache(HMSExternalTable table, - List modifiedPartNames, - List newPartNames) { - for (String partitionName : modifiedPartNames) { - invalidatePartitionCache(table.getOrBuildNameMapping(), partitionName); - } - - List mergedPartNames = Lists.newArrayList(modifiedPartNames); - mergedPartNames.addAll(newPartNames); - if (!mergedPartNames.isEmpty()) { - addPartitionsCache(table.getOrBuildNameMapping(), mergedPartNames, - table.getPartitionColumnTypes(java.util.Optional.empty())); - } - - LOG.info("Refreshed cache for table {}: {} modified partitions, {} new partitions", - table.getName(), modifiedPartNames.size(), newPartNames.size()); - } - - private void addPartitionsCache(NameMapping nameMapping, - List partitionNames, - List partitionColumnTypes) { - long catalogId = nameMapping.getCtlId(); - MetaCacheEntry partitionValuesEntry = - partitionValuesEntryIfInitialized(catalogId); - if (partitionValuesEntry == null) { - return; - } - - PartitionValueCacheKey key = new PartitionValueCacheKey(nameMapping, partitionColumnTypes); - HivePartitionValues partitionValues = partitionValuesEntry.getIfPresent(key); - if (partitionValues == null) { - return; - } - - HivePartitionValues copy = partitionValues.copy(); - Map nameToPartitionItem = copy.getNameToPartitionItem(); - Map> nameToPartitionValues = copy.getNameToPartitionValues(); - - HMSExternalCatalog catalog = hmsCatalog(catalogId); - String localTblName = nameMapping.getLocalTblName(); - for (String partitionName : partitionNames) { - if (nameToPartitionItem.containsKey(partitionName)) { - LOG.info("addPartitionsCache partitionName:[{}] has exist in table:[{}]", - partitionName, localTblName); - continue; - } - ListPartitionItem listPartitionItem = toListPartitionItem(partitionName, key.types, catalog.getName()); - nameToPartitionItem.put(partitionName, listPartitionItem); - nameToPartitionValues.put(partitionName, HiveUtil.toPartitionValues(partitionName)); - } - - copy.rebuildSortedPartitionRanges(); - - HivePartitionValues partitionValuesCur = partitionValuesEntry.getIfPresent(key); - if (partitionValuesCur == partitionValues) { - partitionValuesEntry.put(key, copy); - } - } - - private void dropPartitionsCache(ExternalTable dorisTable, - List partitionNames, - boolean invalidPartitionCache) { - NameMapping nameMapping = dorisTable.getOrBuildNameMapping(); - long catalogId = nameMapping.getCtlId(); - - MetaCacheEntry partitionValuesEntry = - partitionValuesEntryIfInitialized(catalogId); - if (partitionValuesEntry == null) { - return; - } - - PartitionValueCacheKey key = new PartitionValueCacheKey(nameMapping, null); - HivePartitionValues partitionValues = partitionValuesEntry.getIfPresent(key); - if (partitionValues == null) { - return; - } - - HivePartitionValues copy = partitionValues.copy(); - Map nameToPartitionItem = copy.getNameToPartitionItem(); - Map> nameToPartitionValues = copy.getNameToPartitionValues(); - - for (String partitionName : partitionNames) { - if (!nameToPartitionItem.containsKey(partitionName)) { - LOG.info("dropPartitionsCache partitionName:[{}] not exist in table:[{}]", - partitionName, nameMapping.getFullLocalName()); - continue; - } - nameToPartitionItem.remove(partitionName); - nameToPartitionValues.remove(partitionName); - - if (invalidPartitionCache) { - invalidatePartitionCache(nameMapping, partitionName); - } - } - - copy.rebuildSortedPartitionRanges(); - HivePartitionValues partitionValuesCur = partitionValuesEntry.getIfPresent(key); - if (partitionValuesCur == partitionValues) { - partitionValuesEntry.put(key, copy); - } - } - } - - @VisibleForTesting - public void putPartitionValuesCacheForTest(PartitionValueCacheKey key, HivePartitionValues values) { - partitionValuesEntry.get(key.getNameMapping().getCtlId()).put(key, values); - } - - public List getFilesByTransaction(List partitions, - Map txnValidIds, - boolean isFullAcid, - String bindBrokerName) { - List fileCacheValues = Lists.newArrayList(); - try { - if (partitions.isEmpty()) { - return fileCacheValues; - } - for (HivePartition partition : partitions) { - HMSExternalCatalog catalog = hmsCatalog(partition.getNameMapping().getCtlId()); - LocationPath locationPath = LocationPath.of(partition.getPath(), - catalog.getCatalogProperty().getStoragePropertiesMap()); - FileSystemCache.FileSystemCacheKey fileSystemCacheKey = new FileSystemCache.FileSystemCacheKey( - locationPath.getNormalizedLocation(), locationPath.getStorageProperties()); - AuthenticationConfig authenticationConfig = AuthenticationConfig - .getKerberosConfig(locationPath.getStorageProperties().getBackendConfigProperties()); - HadoopAuthenticator hadoopAuthenticator = - HadoopAuthenticator.getHadoopAuthenticator(authenticationConfig); - - try (FileSystemCache.FileSystemLease fileSystemLease = - Env.getCurrentEnv().getExtMetaCacheMgr().getFsCache().getFileSystem(fileSystemCacheKey)) { - FileSystem fileSystem = fileSystemLease.fileSystem(); - fileCacheValues.add( - hadoopAuthenticator.doAs(() -> AcidUtil.getAcidState( - fileSystem, - partition, - txnValidIds, - catalog.getCatalogProperty().getStoragePropertiesMap(), - isFullAcid, - locationPath.getNormalizedLocation()))); - } - } - } catch (Exception e) { - throw new CacheException("Failed to get input splits %s", e, txnValidIds.toString()); - } - return fileCacheValues; - } - - /** - * The key of hive partition values cache. - */ - @Data - public static class PartitionValueCacheKey { - private NameMapping nameMapping; - // Not part of cache identity. - private List types; - - public PartitionValueCacheKey(NameMapping nameMapping, List types) { - this.nameMapping = nameMapping; - this.types = types; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (!(obj instanceof PartitionValueCacheKey)) { - return false; - } - return nameMapping.equals(((PartitionValueCacheKey) obj).nameMapping); - } - - @Override - public int hashCode() { - return nameMapping.hashCode(); - } - - @Override - public String toString() { - return "PartitionValueCacheKey{" + "dbName='" + nameMapping.getLocalDbName() + '\'' - + ", tblName='" + nameMapping.getLocalTblName() + '\'' + '}'; - } - } - - @Data - public static class PartitionCacheKey { - private NameMapping nameMapping; - private List values; - - public PartitionCacheKey(NameMapping nameMapping, List values) { - this.nameMapping = nameMapping; - this.values = values; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (!(obj instanceof PartitionCacheKey)) { - return false; - } - return nameMapping.equals(((PartitionCacheKey) obj).nameMapping) - && Objects.equals(values, ((PartitionCacheKey) obj).values); - } - - boolean isSameTable(String dbName, String tblName) { - return this.nameMapping.getLocalDbName().equals(dbName) - && this.nameMapping.getLocalTblName().equals(tblName); - } - - @Override - public int hashCode() { - return Objects.hash(nameMapping, values); - } - - @Override - public String toString() { - return "PartitionCacheKey{" + "dbName='" + nameMapping.getLocalDbName() + '\'' - + ", tblName='" + nameMapping.getLocalTblName() + '\'' + ", values=" + values + '}'; - } - } - - @Data - public static class FileCacheKey { - private long dummyKey = 0; - private long catalogId; - private String location; - // inputFormat is part of cache identity because the cached FileCacheValue is - // format-dependent: isSplittable() and file-filtering (e.g. LZO *.lzo.index - // exclusion) both depend on the InputFormat class name. Two Hive tables that - // share the same partition location but declare different InputFormats must - // therefore get independent cache entries. - private String inputFormat; - // The values of partitions. - protected List partitionValues; - private long id; - - public FileCacheKey(long catalogId, long id, String location, String inputFormat, - List partitionValues) { - this.catalogId = catalogId; - this.location = location; - this.inputFormat = inputFormat; - this.partitionValues = partitionValues == null ? Lists.newArrayList() : partitionValues; - this.id = id; - } - - public static FileCacheKey createDummyCacheKey(long catalogId, long id, String location, - String inputFormat) { - FileCacheKey fileCacheKey = new FileCacheKey(catalogId, id, location, inputFormat, null); - fileCacheKey.dummyKey = Objects.hash(catalogId, id); - return fileCacheKey; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (!(obj instanceof FileCacheKey)) { - return false; - } - if (dummyKey != 0) { - return dummyKey == ((FileCacheKey) obj).dummyKey; - } - return catalogId == ((FileCacheKey) obj).catalogId - && location.equals(((FileCacheKey) obj).location) - && Objects.equals(inputFormat, ((FileCacheKey) obj).inputFormat) - && Objects.equals(partitionValues, ((FileCacheKey) obj).partitionValues); - } - - boolean isSameTable(long id) { - return this.id == id; - } - - @Override - public int hashCode() { - if (dummyKey != 0) { - return Objects.hash(dummyKey); - } - return Objects.hash(catalogId, location, inputFormat, partitionValues); - } - - @Override - public String toString() { - return "FileCacheKey{" + "catalogId=" + catalogId + ", location='" + location + '\'' - + ", inputFormat='" + inputFormat + '\'' + '}'; - } - } - - @Data - public static class FileCacheValue { - private final List files = Lists.newArrayList(); - private boolean isSplittable; - protected List partitionValues; - private AcidInfo acidInfo; - - public void addFile(FileEntry entry, LocationPath locationPath) { - if (isFileVisible(entry.location().uri().toString())) { - HiveFileStatus status = new HiveFileStatus(); - List blocks = entry.blocks(); - if (!blocks.isEmpty()) { - BlockLocation[] blockLocations = new BlockLocation[blocks.size()]; - for (int i = 0; i < blocks.size(); i++) { - BlockInfo b = blocks.get(i); - blockLocations[i] = new BlockLocation(null, b.hosts(), b.offset(), b.length()); - } - status.setBlockLocations(blockLocations); - } - status.setPath(locationPath); - status.length = entry.length(); - status.blockSize = blocks.isEmpty() ? 0 : blocks.get(0).length(); - status.modificationTime = entry.modificationTime(); - files.add(status); - } - } - - public int getValuesSize() { - return partitionValues == null ? 0 : partitionValues.size(); - } - - @VisibleForTesting - public static boolean isFileVisible(String pathStr) { - if (pathStr == null) { - return false; - } - if (containsHiddenPath(pathStr)) { - return false; - } - return true; - } - - private static boolean containsHiddenPath(String path) { - if (path.startsWith(".") || path.startsWith("_")) { - return true; - } - for (int i = 0; i < path.length() - 1; i++) { - if (path.charAt(i) == '/' && (path.charAt(i + 1) == '.' || path.charAt(i + 1) == '_')) { - return true; - } - } - return false; - } - } - - @Data - public static class HiveFileStatus { - BlockLocation[] blockLocations; - LocationPath path; - long length; - long blockSize; - long modificationTime; - boolean splittable; - List partitionValues; - AcidInfo acidInfo; - } - - @Data - public static class HivePartitionValues { - private Map nameToPartitionItem; - private Map> nameToPartitionValues; - - // Sorted partition ranges for binary search filtering. - private SortedPartitionRanges sortedPartitionRanges; - - public HivePartitionValues() { - } - - public HivePartitionValues(Map nameToPartitionItem, - Map> nameToPartitionValues) { - this.nameToPartitionItem = nameToPartitionItem; - this.nameToPartitionValues = nameToPartitionValues; - this.sortedPartitionRanges = buildSortedPartitionRanges(); - } - - public HivePartitionValues copy() { - HivePartitionValues copy = new HivePartitionValues(); - copy.setNameToPartitionItem(nameToPartitionItem == null ? null : Maps.newHashMap(nameToPartitionItem)); - copy.setNameToPartitionValues( - nameToPartitionValues == null ? null : Maps.newHashMap(nameToPartitionValues)); - return copy; - } - - public void rebuildSortedPartitionRanges() { - this.sortedPartitionRanges = buildSortedPartitionRanges(); - } - - public java.util.Optional> getSortedPartitionRanges() { - return java.util.Optional.ofNullable(sortedPartitionRanges); - } - - private SortedPartitionRanges buildSortedPartitionRanges() { - if (nameToPartitionItem == null || nameToPartitionItem.isEmpty()) { - return null; - } - - return SortedPartitionRanges.build(nameToPartitionItem); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveMetaStoreClientHelper.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveMetaStoreClientHelper.java deleted file mode 100644 index 349e64cb58c100..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveMetaStoreClientHelper.java +++ /dev/null @@ -1,917 +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.datasource.hive; - -import org.apache.doris.analysis.BinaryPredicate; -import org.apache.doris.analysis.BoolLiteral; -import org.apache.doris.analysis.CastExpr; -import org.apache.doris.analysis.CompoundPredicate; -import org.apache.doris.analysis.DateLiteral; -import org.apache.doris.analysis.DecimalLiteral; -import org.apache.doris.analysis.Expr; -import org.apache.doris.analysis.FloatLiteral; -import org.apache.doris.analysis.IntLiteral; -import org.apache.doris.analysis.LiteralExpr; -import org.apache.doris.analysis.NullLiteral; -import org.apache.doris.analysis.SlotRef; -import org.apache.doris.analysis.StringLiteral; -import org.apache.doris.catalog.ArrayType; -import org.apache.doris.catalog.MapType; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.StructField; -import org.apache.doris.catalog.StructType; -import org.apache.doris.catalog.Type; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.security.authentication.AuthenticationConfig; -import org.apache.doris.common.security.authentication.HadoopAuthenticator; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.nereids.types.VarBinaryType; - -import com.google.common.base.Strings; -import com.google.common.collect.Maps; -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.StorageDescriptor; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.hadoop.hive.ql.exec.FunctionRegistry; -import org.apache.hadoop.hive.ql.parse.SemanticException; -import org.apache.hadoop.hive.ql.plan.ExprNodeColumnDesc; -import org.apache.hadoop.hive.ql.plan.ExprNodeConstantDesc; -import org.apache.hadoop.hive.ql.plan.ExprNodeDesc; -import org.apache.hadoop.hive.ql.plan.ExprNodeGenericFuncDesc; -import org.apache.hadoop.hive.serde2.typeinfo.PrimitiveTypeInfo; -import org.apache.hadoop.hive.serde2.typeinfo.TypeInfo; -import org.apache.hadoop.hive.serde2.typeinfo.TypeInfoFactory; -import org.apache.hudi.common.table.HoodieTableMetaClient; -import org.apache.hudi.common.table.TableSchemaResolver; -import org.apache.hudi.common.util.Option; -import org.apache.hudi.internal.schema.InternalSchema; -import org.apache.hudi.internal.schema.convert.AvroInternalSchemaConverter; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.IOException; -import java.security.PrivilegedExceptionAction; -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.Date; -import java.util.Deque; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -/** - * Helper class for HiveMetaStoreClient - */ -public class HiveMetaStoreClientHelper { - private static final Logger LOG = LogManager.getLogger(HiveMetaStoreClientHelper.class); - - public static final String COMMENT = "comment"; - - private static final Pattern digitPattern = Pattern.compile("(\\d+)"); - - public static final String HIVE_JSON_SERDE = "org.apache.hive.hcatalog.data.JsonSerDe"; - public static final String LEGACY_HIVE_JSON_SERDE = "org.apache.hadoop.hive.serde2.JsonSerDe"; - public static final String OPENX_JSON_SERDE = "org.openx.data.jsonserde.JsonSerDe"; - public static final String HIVE_TEXT_SERDE = "org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe"; - public static final String HIVE_OPEN_CSV_SERDE = "org.apache.hadoop.hive.serde2.OpenCSVSerde"; - public static final String HIVE_MULTI_DELIMIT_SERDE = "org.apache.hadoop.hive.serde2.MultiDelimitSerDe"; - - public enum HiveFileFormat { - TEXT_FILE(0, "text"), - PARQUET(1, "parquet"), - ORC(2, "orc"); - - private int index; - private String desc; - - HiveFileFormat(int index, String desc) { - this.index = index; - this.desc = desc; - } - - public int getIndex() { - return index; - } - - public String getDesc() { - return desc; - } - - /** - * convert Hive table inputFormat to file format - * @param input inputFormat of Hive file - * @return - * @throws DdlException - */ - public static String getFormat(String input) throws DdlException { - String formatDesc = ""; - for (HiveFileFormat format : HiveFileFormat.values()) { - String lowerCaseInput = input.toLowerCase(); - if (lowerCaseInput.contains(format.getDesc())) { - formatDesc = format.getDesc(); - break; - } - } - if (Strings.isNullOrEmpty(formatDesc)) { - LOG.warn("Not supported Hive file format [{}].", input); - throw new DdlException("Not supported Hive file format " + input); - } - return formatDesc; - } - } - - /** - * Convert Doris expr to Hive expr, only for partition column - * @param tblName - * @return - * @throws DdlException - * @throws SemanticException - */ - public static ExprNodeGenericFuncDesc convertToHivePartitionExpr(List conjuncts, - List partitionKeys, String tblName) throws DdlException { - List hivePredicates = new ArrayList<>(); - - for (Expr conjunct : conjuncts) { - ExprNodeGenericFuncDesc hiveExpr = HiveMetaStoreClientHelper.convertToHivePartitionExpr( - conjunct, partitionKeys, tblName).getFuncDesc(); - if (hiveExpr != null) { - hivePredicates.add(hiveExpr); - } - } - int count = hivePredicates.size(); - // combine all predicate by `and` - // compoundExprs must have at least 2 predicates - if (count >= 2) { - return HiveMetaStoreClientHelper.getCompoundExpr(hivePredicates, "and"); - } else if (count == 1) { - // only one predicate - return (ExprNodeGenericFuncDesc) hivePredicates.get(0); - } else { - return genAlwaysTrueExpr(tblName); - } - } - - private static ExprNodeGenericFuncDesc genAlwaysTrueExpr(String tblName) throws DdlException { - // have no predicate, make a dummy predicate "1=1" to get all partitions - HiveMetaStoreClientHelper.ExprBuilder exprBuilder = - new HiveMetaStoreClientHelper.ExprBuilder(tblName); - return exprBuilder.val(TypeInfoFactory.intTypeInfo, 1) - .val(TypeInfoFactory.intTypeInfo, 1) - .pred("=", 2).build(); - } - - private static class ExprNodeGenericFuncDescContext { - private static final ExprNodeGenericFuncDescContext BAD_CONTEXT = new ExprNodeGenericFuncDescContext(); - - private ExprNodeGenericFuncDesc funcDesc = null; - private boolean eligible = false; - - public ExprNodeGenericFuncDescContext(ExprNodeGenericFuncDesc funcDesc) { - this.funcDesc = funcDesc; - this.eligible = true; - } - - private ExprNodeGenericFuncDescContext() { - } - - /** - * Check eligible before use the expr in CompoundPredicate for `and` and `or` . - */ - public boolean isEligible() { - return eligible; - } - - public ExprNodeGenericFuncDesc getFuncDesc() { - return funcDesc; - } - } - - private static ExprNodeGenericFuncDescContext convertToHivePartitionExpr(Expr dorisExpr, - List partitionKeys, String tblName) throws DdlException { - if (dorisExpr == null) { - return ExprNodeGenericFuncDescContext.BAD_CONTEXT; - } - - if (dorisExpr instanceof CompoundPredicate) { - CompoundPredicate compoundPredicate = (CompoundPredicate) dorisExpr; - ExprNodeGenericFuncDescContext left = convertToHivePartitionExpr( - compoundPredicate.getChild(0), partitionKeys, tblName); - ExprNodeGenericFuncDescContext right = convertToHivePartitionExpr( - compoundPredicate.getChild(1), partitionKeys, tblName); - - switch (compoundPredicate.getOp()) { - case AND: { - if (left.isEligible() && right.isEligible()) { - List andArgs = new ArrayList<>(); - andArgs.add(left.getFuncDesc()); - andArgs.add(right.getFuncDesc()); - return new ExprNodeGenericFuncDescContext(getCompoundExpr(andArgs, "and")); - } else if (left.isEligible()) { - return left; - } else if (right.isEligible()) { - return right; - } else { - return ExprNodeGenericFuncDescContext.BAD_CONTEXT; - } - } - case OR: { - if (left.isEligible() && right.isEligible()) { - List andArgs = new ArrayList<>(); - andArgs.add(left.getFuncDesc()); - andArgs.add(right.getFuncDesc()); - return new ExprNodeGenericFuncDescContext(getCompoundExpr(andArgs, "or")); - } else { - // If it is not a partition key, this is an always true expr. - // Or if is a partition key and also is a not supportedOp, this is an always true expr. - return ExprNodeGenericFuncDescContext.BAD_CONTEXT; - } - } - default: - // TODO: support NOT predicate for CompoundPredicate - return ExprNodeGenericFuncDescContext.BAD_CONTEXT; - } - } - return binaryExprDesc(dorisExpr, partitionKeys, tblName); - } - - private static ExprNodeGenericFuncDescContext binaryExprDesc(Expr dorisExpr, - List partitionKeys, String tblName) throws DdlException { - if (!(dorisExpr instanceof BinaryPredicate)) { - return ExprNodeGenericFuncDescContext.BAD_CONTEXT; - } - BinaryPredicate.Operator op = ((BinaryPredicate) dorisExpr).getOp(); - switch (op) { - case EQ: - case NE: - case GE: - case GT: - case LE: - case LT: - case EQ_FOR_NULL: - BinaryPredicate eq = (BinaryPredicate) dorisExpr; - // Make sure the col slot is always first - SlotRef slotRef = convertDorisExprToSlotRef(eq.getChild(0)); - LiteralExpr literalExpr = convertDorisExprToLiteralExpr(eq.getChild(1)); - if (slotRef == null || literalExpr == null) { - return ExprNodeGenericFuncDescContext.BAD_CONTEXT; - } - String colName = slotRef.getColumnName(); - // check whether colName is partition column or not - if (!partitionKeys.contains(colName)) { - return ExprNodeGenericFuncDescContext.BAD_CONTEXT; - } - PrimitiveType dorisPrimitiveType = slotRef.getType().getPrimitiveType(); - PrimitiveTypeInfo hivePrimitiveType = convertToHiveColType(dorisPrimitiveType); - Object value = extractDorisLiteral(literalExpr); - if (value == null) { - if (op == BinaryPredicate.Operator.EQ_FOR_NULL && literalExpr instanceof NullLiteral) { - return genExprDesc(tblName, hivePrimitiveType, colName, "NULL", "="); - } else { - return ExprNodeGenericFuncDescContext.BAD_CONTEXT; - } - } - switch (op) { - case EQ: - case EQ_FOR_NULL: - return genExprDesc(tblName, hivePrimitiveType, colName, value, "="); - case NE: - return genExprDesc(tblName, hivePrimitiveType, colName, value, "!="); - case GE: - return genExprDesc(tblName, hivePrimitiveType, colName, value, ">="); - case GT: - return genExprDesc(tblName, hivePrimitiveType, colName, value, ">"); - case LE: - return genExprDesc(tblName, hivePrimitiveType, colName, value, "<="); - case LT: - return genExprDesc(tblName, hivePrimitiveType, colName, value, "<"); - default: - return ExprNodeGenericFuncDescContext.BAD_CONTEXT; - } - default: - // TODO: support in predicate - return ExprNodeGenericFuncDescContext.BAD_CONTEXT; - } - } - - private static ExprNodeGenericFuncDescContext genExprDesc( - String tblName, - PrimitiveTypeInfo hivePrimitiveType, - String colName, - Object value, - String op) throws DdlException { - ExprBuilder exprBuilder = new ExprBuilder(tblName); - exprBuilder.col(hivePrimitiveType, colName).val(hivePrimitiveType, value); - return new ExprNodeGenericFuncDescContext(exprBuilder.pred(op, 2).build()); - } - - public static ExprNodeGenericFuncDesc getCompoundExpr(List args, String op) throws DdlException { - ExprNodeGenericFuncDesc compoundExpr; - try { - compoundExpr = ExprNodeGenericFuncDesc.newInstance( - FunctionRegistry.getFunctionInfo(op).getGenericUDF(), args); - } catch (SemanticException e) { - LOG.warn("Convert to Hive expr failed: {}", e.getMessage()); - throw new DdlException("Convert to Hive expr failed. Error: " + e.getMessage()); - } - return compoundExpr; - } - - public static SlotRef convertDorisExprToSlotRef(Expr expr) { - SlotRef slotRef = null; - if (expr instanceof SlotRef) { - slotRef = (SlotRef) expr; - } else if (expr instanceof CastExpr) { - if (expr.getChild(0) instanceof SlotRef) { - slotRef = (SlotRef) expr.getChild(0); - } - } - return slotRef; - } - - public static LiteralExpr convertDorisExprToLiteralExpr(Expr expr) { - LiteralExpr literalExpr = null; - if (expr instanceof LiteralExpr) { - literalExpr = (LiteralExpr) expr; - } else if (expr instanceof CastExpr) { - if (expr.getChild(0) instanceof LiteralExpr) { - literalExpr = (LiteralExpr) expr.getChild(0); - } - } - return literalExpr; - } - - public static Object extractDorisLiteral(Expr expr) { - if (!expr.isLiteral()) { - return null; - } - if (expr instanceof BoolLiteral) { - BoolLiteral boolLiteral = (BoolLiteral) expr; - return boolLiteral.getValue(); - } else if (expr instanceof DateLiteral) { - DateLiteral dateLiteral = (DateLiteral) expr; - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") - .withZone(ZoneId.systemDefault()); - StringBuilder sb = new StringBuilder(); - sb.append(dateLiteral.getYear()) - .append(dateLiteral.getMonth()) - .append(dateLiteral.getDay()) - .append(dateLiteral.getHour()) - .append(dateLiteral.getMinute()) - .append(dateLiteral.getSecond()); - Date date; - try { - date = Date.from( - LocalDateTime.parse(sb.toString(), formatter).atZone(ZoneId.systemDefault()).toInstant()); - } catch (DateTimeParseException e) { - return null; - } - return date.getTime(); - } else if (expr instanceof DecimalLiteral) { - DecimalLiteral decimalLiteral = (DecimalLiteral) expr; - return decimalLiteral.getValue(); - } else if (expr instanceof FloatLiteral) { - FloatLiteral floatLiteral = (FloatLiteral) expr; - return floatLiteral.getValue(); - } else if (expr instanceof IntLiteral) { - IntLiteral intLiteral = (IntLiteral) expr; - return intLiteral.getValue(); - } else if (expr instanceof StringLiteral) { - StringLiteral stringLiteral = (StringLiteral) expr; - return stringLiteral.getStringValue(); - } - return null; - } - - /** - * Convert from Doris column type to Hive column type - * @param dorisType - * @return hive primitive type info - * @throws DdlException - */ - private static PrimitiveTypeInfo convertToHiveColType(PrimitiveType dorisType) throws DdlException { - switch (dorisType) { - case BOOLEAN: - return TypeInfoFactory.booleanTypeInfo; - case TINYINT: - return TypeInfoFactory.byteTypeInfo; - case SMALLINT: - return TypeInfoFactory.shortTypeInfo; - case INT: - return TypeInfoFactory.intTypeInfo; - case BIGINT: - return TypeInfoFactory.longTypeInfo; - case FLOAT: - return TypeInfoFactory.floatTypeInfo; - case DOUBLE: - return TypeInfoFactory.doubleTypeInfo; - case DECIMAL32: - case DECIMAL64: - case DECIMAL128: - case DECIMALV2: - return TypeInfoFactory.decimalTypeInfo; - case DATE: - case DATEV2: - return TypeInfoFactory.dateTypeInfo; - case DATETIME: - case DATETIMEV2: - return TypeInfoFactory.timestampTypeInfo; - case CHAR: - return TypeInfoFactory.charTypeInfo; - case VARCHAR: - case STRING: - return TypeInfoFactory.varcharTypeInfo; - default: - throw new DdlException("Unsupported column type: " + dorisType); - } - } - - /** - * Helper class for building a Hive expression. - */ - public static class ExprBuilder { - private final String tblName; - private final Deque queue = new LinkedList<>(); - - public ExprBuilder(String tblName) { - this.tblName = tblName; - } - - public ExprNodeGenericFuncDesc build() throws DdlException { - if (queue.size() != 1) { - throw new DdlException("Build Hive expression Failed: " + queue.size()); - } - return (ExprNodeGenericFuncDesc) queue.pollFirst(); - } - - public ExprBuilder pred(String name, int args) throws DdlException { - return fn(name, TypeInfoFactory.booleanTypeInfo, args); - } - - private ExprBuilder fn(String name, TypeInfo ti, int args) throws DdlException { - List children = new ArrayList<>(); - for (int i = 0; i < args; ++i) { - children.add(queue.pollFirst()); - } - try { - queue.offerLast(new ExprNodeGenericFuncDesc(ti, - FunctionRegistry.getFunctionInfo(name).getGenericUDF(), children)); - } catch (SemanticException e) { - LOG.warn("Build Hive expression failed: semantic analyze exception: {}", e.getMessage()); - throw new DdlException("Build Hive expression Failed. Error: " + e.getMessage()); - } - return this; - } - - public ExprBuilder col(TypeInfo ti, String col) { - queue.offerLast(new ExprNodeColumnDesc(ti, col, tblName, true)); - return this; - } - - public ExprBuilder val(TypeInfo ti, Object val) { - queue.offerLast(new ExprNodeConstantDesc(ti, val)); - return this; - } - } - - /** - * The nested column has inner columns, and each column is separated a comma. The inner column maybe a nested - * column too, so we cannot simply split by the comma. We need to match the angle brackets, - * and deal with the inner column recursively. - */ - private static int findNextNestedField(String commaSplitFields) { - int numLess = 0; - int numBracket = 0; - for (int i = 0; i < commaSplitFields.length(); i++) { - char c = commaSplitFields.charAt(i); - if (c == '<') { - numLess++; - } else if (c == '>') { - numLess--; - } else if (c == '(') { - numBracket++; - } else if (c == ')') { - numBracket--; - } else if (c == ',' && numLess == 0 && numBracket == 0) { - return i; - } - } - return commaSplitFields.length(); - } - - /** - * Convert doris type to hive type. - */ - public static String dorisTypeToHiveType(Type dorisType) { - if (dorisType.isScalarType()) { - PrimitiveType primitiveType = dorisType.getPrimitiveType(); - switch (primitiveType) { - case BOOLEAN: - return "boolean"; - case TINYINT: - return "tinyint"; - case SMALLINT: - return "smallint"; - case INT: - return "int"; - case BIGINT: - return "bigint"; - case DATEV2: - case DATE: - return "date"; - case DATETIMEV2: - case DATETIME: - return "timestamp"; - case FLOAT: - return "float"; - case DOUBLE: - return "double"; - case CHAR: { - ScalarType scalarType = (ScalarType) dorisType; - return "char(" + scalarType.getLength() + ")"; - } - case VARCHAR: - case STRING: - return "string"; - case DECIMAL32: - case DECIMAL64: - case DECIMAL128: - case DECIMAL256: - case DECIMALV2: { - StringBuilder decimalType = new StringBuilder(); - decimalType.append("decimal"); - ScalarType scalarType = (ScalarType) dorisType; - int precision = scalarType.getScalarPrecision(); - if (precision == 0) { - precision = ScalarType.DEFAULT_PRECISION; - } - // decimal(precision, scale) - int scale = scalarType.getScalarScale(); - decimalType.append("("); - decimalType.append(precision); - decimalType.append(","); - decimalType.append(scale); - decimalType.append(")"); - return decimalType.toString(); - } - default: - throw new HMSClientException("Unsupported primitive type conversion of " + dorisType.toSql()); - } - } else if (dorisType.isArrayType()) { - ArrayType dorisArray = (ArrayType) dorisType; - Type itemType = dorisArray.getItemType(); - return "array<" + dorisTypeToHiveType(itemType) + ">"; - } else if (dorisType.isMapType()) { - MapType dorisMap = (MapType) dorisType; - Type keyType = dorisMap.getKeyType(); - Type valueType = dorisMap.getValueType(); - return "map<" - + dorisTypeToHiveType(keyType) - + "," - + dorisTypeToHiveType(valueType) - + ">"; - } else if (dorisType.isStructType()) { - StructType dorisStruct = (StructType) dorisType; - StringBuilder structType = new StringBuilder(); - structType.append("struct<"); - ArrayList fields = dorisStruct.getFields(); - for (int i = 0; i < fields.size(); i++) { - StructField field = fields.get(i); - structType.append(field.getName()); - structType.append(":"); - structType.append(dorisTypeToHiveType(field.getType())); - if (i != fields.size() - 1) { - structType.append(","); - } - } - structType.append(">"); - return structType.toString(); - } - throw new HMSClientException("Unsupported type conversion of " + dorisType.toSql()); - } - - /** - * Convert hive type to doris type. - */ - public static Type hiveTypeToDorisType(String hiveType, boolean enableMappingVarbinary, - boolean enableMappingTimeStampTz) { - // use the largest scale as default time scale. - return hiveTypeToDorisType(hiveType, 6, enableMappingVarbinary, enableMappingTimeStampTz); - } - - /** - * Convert hive type to doris type with timescale. - */ - public static Type hiveTypeToDorisType(String hiveType, int timeScale, boolean enableMappingVarbinary, - boolean enableMappingTimeStampTz) { - String lowerCaseType = hiveType.toLowerCase(); - switch (lowerCaseType) { - case "boolean": - return Type.BOOLEAN; - case "tinyint": - return Type.TINYINT; - case "smallint": - return Type.SMALLINT; - case "int": - return Type.INT; - case "bigint": - return Type.BIGINT; - case "date": - return ScalarType.createDateV2Type(); - case "timestamp": - return ScalarType.createDatetimeV2Type(timeScale); - case "float": - return Type.FLOAT; - case "double": - return Type.DOUBLE; - case "string": - return ScalarType.createStringType(); - case "binary": - return enableMappingVarbinary ? ScalarType.createVarbinaryType(VarBinaryType.MAX_VARBINARY_LENGTH) - : ScalarType.createStringType(); - default: - break; - } - // resolve schema like array - if (lowerCaseType.startsWith("array")) { - if (lowerCaseType.indexOf("<") == 5 && lowerCaseType.lastIndexOf(">") == lowerCaseType.length() - 1) { - Type innerType = hiveTypeToDorisType(lowerCaseType.substring(6, lowerCaseType.length() - 1), - enableMappingVarbinary, enableMappingTimeStampTz); - return ArrayType.create(innerType, true); - } - } - // resolve schema like map - if (lowerCaseType.startsWith("map")) { - if (lowerCaseType.indexOf("<") == 3 && lowerCaseType.lastIndexOf(">") == lowerCaseType.length() - 1) { - String keyValue = lowerCaseType.substring(4, lowerCaseType.length() - 1); - int index = findNextNestedField(keyValue); - if (index != keyValue.length() && index != 0) { - return new MapType( - hiveTypeToDorisType(keyValue.substring(0, index), enableMappingVarbinary, - enableMappingTimeStampTz), - hiveTypeToDorisType(keyValue.substring(index + 1), enableMappingVarbinary, - enableMappingTimeStampTz)); - } - } - } - // resolve schema like struct - if (lowerCaseType.startsWith("struct")) { - if (lowerCaseType.indexOf("<") == 6 && lowerCaseType.lastIndexOf(">") == lowerCaseType.length() - 1) { - String listFields = lowerCaseType.substring(7, lowerCaseType.length() - 1); - ArrayList fields = new ArrayList<>(); - while (listFields.length() > 0) { - int index = findNextNestedField(listFields); - int pivot = listFields.indexOf(':'); - if (pivot > 0 && pivot < listFields.length() - 1) { - fields.add(new StructField(listFields.substring(0, pivot), - hiveTypeToDorisType(listFields.substring(pivot + 1, index), enableMappingVarbinary, - enableMappingTimeStampTz))); - listFields = listFields.substring(Math.min(index + 1, listFields.length())); - } else { - break; - } - } - if (listFields.isEmpty()) { - return new StructType(fields); - } - } - } - if (lowerCaseType.startsWith("char")) { - Matcher match = digitPattern.matcher(lowerCaseType); - if (match.find()) { - return ScalarType.createType(PrimitiveType.CHAR, Integer.parseInt(match.group(1)), 0, 0); - } - return ScalarType.createType(PrimitiveType.CHAR); - } - if (lowerCaseType.startsWith("varchar")) { - Matcher match = digitPattern.matcher(lowerCaseType); - if (match.find()) { - return ScalarType.createType(PrimitiveType.VARCHAR, Integer.parseInt(match.group(1)), 0, 0); - } - return ScalarType.createType(PrimitiveType.VARCHAR); - } - if (lowerCaseType.startsWith("decimal")) { - Matcher match = digitPattern.matcher(lowerCaseType); - int precision = ScalarType.DEFAULT_PRECISION; - int scale = ScalarType.DEFAULT_SCALE; - if (match.find()) { - precision = Integer.parseInt(match.group(1)); - } - if (match.find()) { - scale = Integer.parseInt(match.group(1)); - } - return ScalarType.createDecimalV3Type(precision, scale); - } - if (lowerCaseType.startsWith("timestamp with local time zone")) { - return enableMappingTimeStampTz ? ScalarType.createTimeStampTzType(timeScale) - : ScalarType.createDatetimeV2Type(timeScale); - } - return Type.UNSUPPORTED; - } - - public static String showCreateTable(HMSExternalTable hmsTable) { - // Always use the latest schema - HMSExternalCatalog catalog = (HMSExternalCatalog) hmsTable.getCatalog(); - Table remoteTable = catalog.getClient().getTable(hmsTable.getRemoteDbName(), hmsTable.getRemoteName()); - StringBuilder output = new StringBuilder(); - if (remoteTable.isSetViewOriginalText() || remoteTable.isSetViewExpandedText()) { - output.append(String.format("CREATE VIEW `%s` AS ", remoteTable.getTableName())); - if (remoteTable.getViewExpandedText() != null) { - output.append(remoteTable.getViewExpandedText()); - } else { - output.append(remoteTable.getViewOriginalText()); - } - } else { - output.append(String.format("CREATE TABLE `%s`(\n", remoteTable.getTableName())); - Iterator fields = remoteTable.getSd().getCols().iterator(); - while (fields.hasNext()) { - FieldSchema field = fields.next(); - output.append(String.format(" `%s` %s", field.getName(), field.getType())); - if (field.getComment() != null) { - output.append(String.format(" COMMENT '%s'", field.getComment())); - } - if (fields.hasNext()) { - output.append(",\n"); - } - } - output.append(")\n"); - if (remoteTable.getParameters().containsKey(COMMENT)) { - output.append(String.format("COMMENT '%s'", remoteTable.getParameters().get(COMMENT))).append("\n"); - } - if (remoteTable.getPartitionKeys().size() > 0) { - output.append("PARTITIONED BY (\n") - .append(remoteTable.getPartitionKeys().stream().map( - partition -> - String.format(" `%s` %s", partition.getName(), partition.getType())) - .collect(Collectors.joining(",\n"))) - .append(")\n"); - } - StorageDescriptor descriptor = remoteTable.getSd(); - List bucketCols = descriptor.getBucketCols(); - if (bucketCols != null && bucketCols.size() > 0) { - output.append("CLUSTERED BY (\n") - .append(bucketCols.stream().map( - bucketCol -> " " + bucketCol).collect(Collectors.joining(",\n"))) - .append(")\n") - .append(String.format("INTO %d BUCKETS\n", descriptor.getNumBuckets())); - } - if (descriptor.getSerdeInfo().isSetSerializationLib()) { - output.append("ROW FORMAT SERDE\n") - .append(String.format(" '%s'\n", descriptor.getSerdeInfo().getSerializationLib())); - } - if (descriptor.getSerdeInfo().isSetParameters()) { - output.append("WITH SERDEPROPERTIES (\n") - .append(descriptor.getSerdeInfo().getParameters().entrySet().stream() - .map(entry -> String.format(" '%s' = '%s'", entry.getKey(), entry.getValue())) - .collect(Collectors.joining(",\n"))) - .append(")\n"); - } - if (descriptor.isSetInputFormat()) { - output.append("STORED AS INPUTFORMAT\n") - .append(String.format(" '%s'\n", descriptor.getInputFormat())); - } - if (descriptor.isSetOutputFormat()) { - output.append("OUTPUTFORMAT\n") - .append(String.format(" '%s'\n", descriptor.getOutputFormat())); - } - if (descriptor.isSetLocation()) { - output.append("LOCATION\n") - .append(String.format(" '%s'\n", descriptor.getLocation())); - } - if (remoteTable.isSetParameters()) { - output.append("TBLPROPERTIES (\n"); - Map parameters = Maps.newHashMap(); - // Copy the parameters to a new Map to keep them unchanged. - parameters.putAll(remoteTable.getParameters()); - if (parameters.containsKey(COMMENT)) { - // Comment is always added to the end of remote table parameters. - // It has already showed above in COMMENT section, so remove it here. - parameters.remove(COMMENT); - } - Iterator> params = parameters.entrySet().iterator(); - while (params.hasNext()) { - Map.Entry param = params.next(); - output.append(String.format(" '%s'='%s'", param.getKey(), param.getValue())); - if (params.hasNext()) { - output.append(",\n"); - } - } - output.append(")"); - } - } - return output.toString(); - } - - public static InternalSchema getHudiTableSchema(HMSExternalTable table, boolean[] enableSchemaEvolution, - String timestamp) { - HoodieTableMetaClient metaClient = table.getHudiClient(); - TableSchemaResolver schemaUtil = new TableSchemaResolver(metaClient); - - // Here, the timestamp should be reloaded again. - // Because when hudi obtains the schema in `getTableAvroSchema`, it needs to read the specified commit file, - // which is saved in the `metaClient`. - // But the `metaClient` is obtained from cache, so the file obtained may be an old file. - // This file may be deleted by hudi clean task, and an error will be reported. - // So, we should reload timeline so that we can read the latest commit files. - metaClient.reloadActiveTimeline(); - - Option internalSchemaOption = schemaUtil.getTableInternalSchemaFromCommitMetadata(timestamp); - - if (internalSchemaOption.isPresent()) { - enableSchemaEvolution[0] = true; - return internalSchemaOption.get(); - } else { - try { - // schema evolution is not enabled. (hoodie.schema.on.read.enable = false). - enableSchemaEvolution[0] = false; - // AvroInternalSchemaConverter.convert() will generator field id. - return AvroInternalSchemaConverter.convert(schemaUtil.getTableAvroSchema(true)); - } catch (Exception e) { - throw new RuntimeException("Cannot get hudi table schema.", e); - } - } - } - - public static T ugiDoAs(Configuration conf, PrivilegedExceptionAction action) { - // if hive config is not ready, then use hadoop kerberos to login - AuthenticationConfig authenticationConfig = AuthenticationConfig.getKerberosConfig(conf); - HadoopAuthenticator hadoopAuthenticator = HadoopAuthenticator.getHadoopAuthenticator(authenticationConfig); - try { - return hadoopAuthenticator.doAs(action); - } catch (IOException e) { - LOG.warn("HiveMetaStoreClientHelper ugiDoAs failed.", e); - throw new RuntimeException(e); - } - } - - public static Configuration getConfiguration(HMSExternalTable table) { - return ExternalCatalog.buildHadoopConfiguration(table.getCatalog().getHadoopProperties()); - } - - public static Optional getSerdeProperty(Table table, String key) { - String valueFromSd = table.getSd().getSerdeInfo().getParameters().get(key); - String valueFromTbl = table.getParameters().get(key); - return firstNonNullable(valueFromTbl, valueFromSd); - } - - private static Optional firstNonNullable(String... values) { - for (String value : values) { - if (value != null) { - return Optional.of(value); - } - } - return Optional.empty(); - } - - public static String firstPresentOrDefault(String defaultValue, Optional... values) { - for (Optional value : values) { - if (value.isPresent()) { - return value.get(); - } - } - return defaultValue; - } - - /** - * Return the byte value of the number string. - * - * @param altValue - * The string containing a number. - * @param defValue - * The default value to return if altValue is invalid. - */ - public static String getByte(String altValue, String defValue) { - if (altValue != null && altValue.length() > 0) { - try { - return Character.toString((char) ((Byte.parseByte(altValue) + 256) % 256)); - } catch (NumberFormatException e) { - return altValue.substring(0, 1); - } - } - return defValue; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveMetadataOps.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveMetadataOps.java deleted file mode 100644 index af3ecd8f3376c6..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveMetadataOps.java +++ /dev/null @@ -1,425 +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.datasource.hive; - -import org.apache.doris.analysis.DistributionDesc; -import org.apache.doris.analysis.HashDistributionDesc; -import org.apache.doris.analysis.PartitionDesc; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.PartitionType; -import org.apache.doris.catalog.info.CreateOrReplaceBranchInfo; -import org.apache.doris.catalog.info.CreateOrReplaceTagInfo; -import org.apache.doris.catalog.info.DropBranchInfo; -import org.apache.doris.catalog.info.DropTagInfo; -import org.apache.doris.common.Config; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.ErrorCode; -import org.apache.doris.common.ErrorReport; -import org.apache.doris.common.UserException; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; -import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.operations.ExternalMetadataOps; -import org.apache.doris.datasource.property.metastore.HMSBaseProperties; -import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo; -import org.apache.doris.qe.ConnectContext; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableSet; -import org.apache.hadoop.hive.conf.HiveConf; -import org.apache.hadoop.hive.ql.io.AcidUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; - -public class HiveMetadataOps implements ExternalMetadataOps { - private static final Logger LOG = LogManager.getLogger(HiveMetadataOps.class); - - public static final String LOCATION_URI_KEY = "location"; - public static final String FILE_FORMAT_KEY = "file_format"; - public static final Set DORIS_HIVE_KEYS = ImmutableSet.of(FILE_FORMAT_KEY, LOCATION_URI_KEY); - private static final int MIN_CLIENT_POOL_SIZE = 8; - private final HMSCachedClient client; - private final HMSExternalCatalog catalog; - - public HiveMetadataOps(HiveConf hiveConf, HMSExternalCatalog catalog) { - this(catalog, createCachedClient(hiveConf, - Math.max(MIN_CLIENT_POOL_SIZE, Config.max_external_cache_loader_thread_pool_size), - catalog.getExecutionAuthenticator())); - } - - @VisibleForTesting - public HiveMetadataOps(HMSExternalCatalog catalog, HMSCachedClient client) { - this.catalog = catalog; - this.client = client; - } - - public HMSCachedClient getClient() { - return client; - } - - public HMSExternalCatalog getCatalog() { - return catalog; - } - - private static HMSCachedClient createCachedClient(HiveConf hiveConf, int thriftClientPoolSize, - ExecutionAuthenticator executionAuthenticator) { - Preconditions.checkNotNull(hiveConf, "HiveConf cannot be null"); - return new ThriftHMSCachedClient(hiveConf, thriftClientPoolSize, executionAuthenticator); - } - - @Override - public boolean createDbImpl(String dbName, boolean ifNotExists, Map properties) - throws DdlException { - ExternalDatabase dorisDb = catalog.getDbNullable(dbName); - boolean exists = databaseExist(dbName); - if (dorisDb != null || exists) { - if (ifNotExists) { - LOG.info("create database[{}] which already exists", dbName); - return true; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_DB_CREATE_EXISTS, dbName); - } - } - try { - HiveDatabaseMetadata catalogDatabase = new HiveDatabaseMetadata(); - catalogDatabase.setDbName(dbName); - if (properties.containsKey(LOCATION_URI_KEY)) { - catalogDatabase.setLocationUri(properties.get(LOCATION_URI_KEY)); - } - // remove it when set - properties.remove(LOCATION_URI_KEY); - catalogDatabase.setProperties(properties); - catalogDatabase.setComment(properties.getOrDefault("comment", "")); - client.createDatabase(catalogDatabase); - LOG.info("successfully create hive database: {}", dbName); - return false; - } catch (Exception e) { - throw new RuntimeException(e.getMessage(), e); - } - } - - @Override - public void afterCreateDb() { - catalog.resetMetaCacheNames(); - } - - @Override - public void dropDbImpl(String dbName, boolean ifExists, boolean force) throws DdlException { - ExternalDatabase dorisDb = catalog.getDbNullable(dbName); - if (dorisDb == null) { - if (ifExists) { - LOG.info("drop database[{}] which does not exist", dbName); - return; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_DB_DROP_EXISTS, dbName); - } - } - try { - if (force) { - // try to drop all tables in the database - List remoteTableNames = listTableNames(dorisDb.getRemoteName()); - for (String remoteTableName : remoteTableNames) { - ExternalTable tbl = null; - try { - tbl = (ExternalTable) dorisDb.getTableOrDdlException(remoteTableName); - } catch (DdlException e) { - LOG.warn("failed to get table when force drop database [{}], table[{}], error: {}", - dbName, remoteTableName, e.getMessage()); - continue; - } - dropTableImpl(tbl, true); - } - if (!remoteTableNames.isEmpty()) { - LOG.info("drop database[{}] with force, drop all tables, num: {}", dbName, remoteTableNames.size()); - } - } - client.dropDatabase(dorisDb.getRemoteName()); - } catch (Exception e) { - throw new RuntimeException(e.getMessage(), e); - } - } - - @Override - public void afterDropDb(String dbName) { - catalog.unregisterDatabase(dbName); - } - - @Override - public boolean createTableImpl(CreateTableInfo createTableInfo) throws UserException { - String dbName = createTableInfo.getDbName(); - String tblName = createTableInfo.getTableName(); - ExternalDatabase db = catalog.getDbNullable(dbName); - if (db == null) { - throw new UserException("Failed to get database: '" + dbName + "' in catalog: " + catalog.getName()); - } - if (tableExist(db.getRemoteName(), tblName)) { - if (createTableInfo.isIfNotExists()) { - LOG.info("create table[{}] which already exists", tblName); - return true; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_TABLE_EXISTS_ERROR, tblName); - } - } - try { - Map props = createTableInfo.getProperties(); - // set default owner - if (!props.containsKey("owner")) { - if (ConnectContext.get() != null) { - props.put("owner", ConnectContext.get().getCurrentUserIdentity().getUser()); - } - } - - if (props.containsKey("transactional") && props.get("transactional").equalsIgnoreCase("true")) { - throw new UserException("Not support create hive transactional table."); - /* - CREATE TABLE trans6( - `col1` int, - `col2` int - ) ENGINE=hive - PROPERTIES ( - 'file_format'='orc', - 'compression'='zlib', - 'bucketing_version'='2', - 'transactional'='true', - 'transactional_properties'='default' - ); - In hive, this table only can insert not update(not report error,but not actually updated). - */ - } - - String fileFormat = props.getOrDefault(FILE_FORMAT_KEY, Config.hive_default_file_format); - Map ddlProps = new HashMap<>(); - for (Map.Entry entry : props.entrySet()) { - String key = entry.getKey().toLowerCase(); - if (DORIS_HIVE_KEYS.contains(entry.getKey().toLowerCase())) { - ddlProps.put("doris." + key, entry.getValue()); - } else { - ddlProps.put(key, entry.getValue()); - } - } - List partitionColNames = new ArrayList<>(); - PartitionDesc partitionDesc = createTableInfo.getPartitionDesc(); - if (partitionDesc != null) { - if (partitionDesc.getType() == PartitionType.RANGE) { - throw new UserException("Only support 'LIST' partition type in hive catalog."); - } - partitionColNames.addAll(partitionDesc.getPartitionColNames()); - if (!partitionDesc.getSinglePartitionDescs().isEmpty()) { - throw new UserException("Partition values expressions is not supported in hive catalog."); - } - - } - Map properties = catalog.getProperties(); - if (properties.containsKey(HMSBaseProperties.HIVE_METASTORE_TYPE) - && properties.get(HMSBaseProperties.HIVE_METASTORE_TYPE).equals(HMSBaseProperties.DLF_TYPE)) { - for (Column column : createTableInfo.getColumns()) { - if (column.hasDefaultValue()) { - throw new UserException("Default values are not supported with `DLF` catalog."); - } - } - } - String comment = createTableInfo.getComment(); - Optional location = Optional.ofNullable(props.getOrDefault(LOCATION_URI_KEY, null)); - HiveTableMetadata hiveTableMeta; - DistributionDesc bucketInfo = createTableInfo.getDistributionDesc(); - if (bucketInfo != null) { - if (Config.enable_create_hive_bucket_table) { - if (bucketInfo instanceof HashDistributionDesc) { - hiveTableMeta = HiveTableMetadata.of(db.getRemoteName(), - tblName, - location, - createTableInfo.getColumns(), - partitionColNames, - bucketInfo.getDistributionColumnNames(), - bucketInfo.getBuckets(), - ddlProps, - fileFormat, - comment); - } else { - throw new UserException("External hive table only supports hash bucketing"); - } - } else { - throw new UserException("Create hive bucket table need" - + " set enable_create_hive_bucket_table to true"); - } - } else { - hiveTableMeta = HiveTableMetadata.of(db.getRemoteName(), - tblName, - location, - createTableInfo.getColumns(), - partitionColNames, - ddlProps, - fileFormat, - comment); - } - client.createTable(hiveTableMeta, createTableInfo.isIfNotExists()); - return false; - } catch (Exception e) { - throw new UserException(e.getMessage(), e); - } - } - - public void afterCreateTable(String dbName, String tblName) { - Optional> db = catalog.getDbForReplay(dbName); - if (db.isPresent()) { - db.get().resetMetaCacheNames(); - } - LOG.info("after create table {}.{}.{}, is db exists: {}", - getCatalog().getName(), dbName, tblName, db.isPresent()); - } - - @Override - public void dropTableImpl(ExternalTable dorisTable, boolean ifExists) throws DdlException { - if (!tableExist(dorisTable.getRemoteDbName(), dorisTable.getRemoteName())) { - if (ifExists) { - LOG.info("drop table[{}] which does not exist", dorisTable.getRemoteDbName()); - return; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_UNKNOWN_TABLE, - dorisTable.getRemoteName(), dorisTable.getRemoteDbName()); - } - } - if (AcidUtils.isTransactionalTable(client.getTable(dorisTable.getRemoteDbName(), dorisTable.getRemoteName()))) { - throw new DdlException("Not support drop hive transactional table."); - } - - try { - client.dropTable(dorisTable.getRemoteDbName(), dorisTable.getRemoteName()); - } catch (Exception e) { - throw new DdlException(e.getMessage(), e); - } - } - - @Override - public void afterDropTable(String dbName, String tblName) { - Optional> db = catalog.getDbForReplay(dbName); - if (db.isPresent()) { - db.get().unregisterTable(tblName); - } - LOG.info("after drop table {}.{}.{}, is db exists: {}", - getCatalog().getName(), dbName, tblName, db.isPresent()); - } - - @Override - public void truncateTableImpl(ExternalTable dorisTable, List partitions) - throws DdlException { - try { - client.truncateTable(dorisTable.getRemoteDbName(), dorisTable.getRemoteName(), partitions); - } catch (Exception e) { - throw new DdlException(e.getMessage(), e); - } - } - - @Override - public void afterTruncateTable(String dbName, String tblName, long updateTime) { - try { - // Invalidate cache. - Optional> db = catalog.getDbForReplay(dbName); - if (db.isPresent()) { - Optional tbl = db.get().getTableForReplay(tblName); - if (tbl.isPresent()) { - Env.getCurrentEnv().getRefreshManager() - .refreshTableInternal(db.get(), (ExternalTable) tbl.get(), updateTime); - } - } - } catch (Exception e) { - LOG.warn("exception when calling afterTruncateTable for db: {}, table: {}, error: {}", - dbName, tblName, e.getMessage(), e); - } - } - - @Override - public void createOrReplaceBranchImpl(ExternalTable dorisTable, CreateOrReplaceBranchInfo branchInfo) - throws UserException { - throw new UserException("Not support create or replace branch in hive catalog."); - } - - @Override - public void createOrReplaceTagImpl(ExternalTable dorisTable, CreateOrReplaceTagInfo tagInfo) - throws UserException { - throw new UserException("Not support create or replace tag in hive catalog."); - } - - @Override - public void dropTagImpl(ExternalTable dorisTable, DropTagInfo tagInfo) throws UserException { - throw new UserException("Not support drop tag in hive catalog."); - } - - @Override - public void dropBranchImpl(ExternalTable dorisTable, DropBranchInfo branchInfo) throws UserException { - throw new UserException("Not support drop branch in hive catalog."); - } - - @Override - public List listTableNames(String dbName) { - return client.getAllTables(dbName); - } - - @Override - public boolean tableExist(String dbName, String tblName) { - return client.tableExists(dbName, tblName); - } - - @Override - public boolean databaseExist(String dbName) { - return listDatabaseNames().contains(dbName.toLowerCase()); - } - - @Override - public void close() { - client.close(); - } - - public List listDatabaseNames() { - return client.getAllDatabases(); - } - - public void updateTableStatistics( - NameMapping nameMapping, - Function update) { - client.updateTableStatistics(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), update); - } - - void updatePartitionStatistics( - NameMapping nameMapping, - String partitionName, - Function update) { - client.updatePartitionStatistics(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), partitionName, - update); - } - - public void addPartitions(NameMapping nameMapping, List partitions) { - client.addPartitions(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), partitions); - } - - public void dropPartition(NameMapping nameMapping, List partitionValues, boolean deleteData) { - client.dropPartition(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), partitionValues, - deleteData); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HivePartition.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HivePartition.java deleted file mode 100644 index 59f5879ad5a18c..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HivePartition.java +++ /dev/null @@ -1,132 +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.datasource.hive; - -import org.apache.doris.catalog.Column; -import org.apache.doris.datasource.NameMapping; - -import com.google.common.base.Preconditions; -import lombok.Data; -import org.apache.hadoop.hive.metastore.api.FieldSchema; - -import java.util.List; -import java.util.Map; - -@Data -public class HivePartition { - public static final String LAST_MODIFY_TIME_KEY = "transient_lastDdlTime"; - public static final String FILE_NUM_KEY = "numFiles"; - - private NameMapping nameMapping; - private String inputFormat; - private String path; - private List partitionValues; - private boolean isDummyPartition; - private Map parameters; - private String outputFormat; - private String serde; - private List columns; - - // If you want to read the data under a partition, you can use this constructor - public HivePartition(NameMapping nameMapping, boolean isDummyPartition, - String inputFormat, String path, List partitionValues, Map parameters) { - this.nameMapping = nameMapping; - this.isDummyPartition = isDummyPartition; - // eg: org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat - this.inputFormat = inputFormat; - // eg: hdfs://hk-dev01:8121/user/doris/parquet/partition_table/nation=cn/city=beijing - this.path = path; - // eg: cn, beijing - this.partitionValues = partitionValues; - this.parameters = parameters; - } - - // If you want to update hms with partition, then you can use this constructor, - // as updating hms requires some additional information, such as outputFormat and so on - public HivePartition(NameMapping nameMapping, boolean isDummyPartition, - String inputFormat, String path, List partitionValues, Map parameters, - String outputFormat, String serde, List columns) { - this(nameMapping, isDummyPartition, inputFormat, path, partitionValues, parameters); - this.outputFormat = outputFormat; - this.serde = serde; - this.columns = columns; - } - - public NameMapping getNameMapping() { - return nameMapping; - } - - // return partition name like: nation=cn/city=beijing - public String getPartitionName(List partColumns) { - Preconditions.checkState(partColumns.size() == partitionValues.size()); - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < partColumns.size(); ++i) { - if (i != 0) { - sb.append("/"); - } - sb.append(partColumns.get(i).getName()).append("=").append(partitionValues.get(i)); - } - return sb.toString(); - } - - public boolean isDummyPartition() { - return this.isDummyPartition; - } - - public long getLastModifiedTime() { - if (parameters == null || !parameters.containsKey(LAST_MODIFY_TIME_KEY)) { - return 0L; - } - return Long.parseLong(parameters.get(LAST_MODIFY_TIME_KEY)) * 1000; - } - - /** - * If there are no files, it proves that there is no data under the partition, we return 0 - * - * @return - */ - public long getLastModifiedTimeIgnoreInit() { - if (getFileNum() == 0) { - return 0L; - } - return getLastModifiedTime(); - } - - public long getFileNum() { - if (parameters == null || !parameters.containsKey(FILE_NUM_KEY)) { - return 0L; - } - return Long.parseLong(parameters.get(FILE_NUM_KEY)); - } - - @Override - public String toString() { - final StringBuilder sb = new StringBuilder("HivePartition{"); - sb.append("nameMapping=").append(nameMapping); - sb.append(", inputFormat='").append(inputFormat).append('\''); - sb.append(", path='").append(path).append('\''); - sb.append(", partitionValues=").append(partitionValues); - sb.append(", isDummyPartition=").append(isDummyPartition); - sb.append(", parameters=").append(parameters); - sb.append(", outputFormat='").append(outputFormat).append('\''); - sb.append(", serde='").append(serde).append('\''); - sb.append(", columns=").append(columns); - sb.append('}'); - return sb.toString(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HivePartitionStatistics.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HivePartitionStatistics.java deleted file mode 100644 index 8173f1dea3a514..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HivePartitionStatistics.java +++ /dev/null @@ -1,96 +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. -// This file is copied from -// https://github.com/trinodb/trino/blob/438/plugin/trino-hive/src/main/java/io/trino/plugin/hive/util/Statistics.java -// and modified by Doris - -package org.apache.doris.datasource.hive; - -import org.apache.doris.datasource.statistics.CommonStatistics; -import org.apache.doris.datasource.statistics.CommonStatistics.ReduceOperator; - -import com.google.common.collect.ImmutableMap; - -import java.util.Map; - -public class HivePartitionStatistics { - public static final HivePartitionStatistics EMPTY = - new HivePartitionStatistics(CommonStatistics.EMPTY, ImmutableMap.of()); - - private final CommonStatistics commonStatistics; - private final Map columnStatisticsMap; - - public HivePartitionStatistics( - CommonStatistics commonStatistics, - Map columnStatisticsMap) { - this.commonStatistics = commonStatistics; - this.columnStatisticsMap = columnStatisticsMap; - } - - public CommonStatistics getCommonStatistics() { - return commonStatistics; - } - - public Map getColumnStatisticsMap() { - return columnStatisticsMap; - } - - public static HivePartitionStatistics fromCommonStatistics(long rowCount, long fileCount, long totalFileBytes) { - return new HivePartitionStatistics( - new CommonStatistics(rowCount, fileCount, totalFileBytes), - ImmutableMap.of() - ); - } - - // only used to update the parameters of partition or table. - public static HivePartitionStatistics merge(HivePartitionStatistics current, HivePartitionStatistics update) { - if (current.getCommonStatistics().getRowCount() <= 0) { - return update; - } else if (update.getCommonStatistics().getRowCount() <= 0) { - return current; - } - - return new HivePartitionStatistics( - CommonStatistics - .reduce(current.getCommonStatistics(), update.getCommonStatistics(), ReduceOperator.ADD), - // TODO merge columnStatisticsMap - current.getColumnStatisticsMap()); - } - - public static HivePartitionStatistics reduce( - HivePartitionStatistics first, - HivePartitionStatistics second, - ReduceOperator operator) { - CommonStatistics left = first.getCommonStatistics(); - CommonStatistics right = second.getCommonStatistics(); - return HivePartitionStatistics.fromCommonStatistics( - CommonStatistics.reduce(left.getRowCount(), right.getRowCount(), operator), - CommonStatistics.reduce(left.getFileCount(), right.getFileCount(), operator), - CommonStatistics.reduce(left.getTotalFileBytes(), right.getTotalFileBytes(), operator)); - } - - public static CommonStatistics reduce( - CommonStatistics current, - CommonStatistics update, - ReduceOperator operator) { - return new CommonStatistics( - CommonStatistics.reduce(current.getRowCount(), update.getRowCount(), operator), - CommonStatistics.reduce(current.getFileCount(), update.getFileCount(), operator), - CommonStatistics.reduce(current.getTotalFileBytes(), update.getTotalFileBytes(), operator)); - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HivePartitionWithStatistics.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HivePartitionWithStatistics.java deleted file mode 100644 index e72374aa5f2118..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HivePartitionWithStatistics.java +++ /dev/null @@ -1,42 +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.datasource.hive; - -public class HivePartitionWithStatistics { - private final String name; - private final HivePartition partition; - private final HivePartitionStatistics statistics; - - public HivePartitionWithStatistics(String name, HivePartition partition, HivePartitionStatistics statistics) { - this.name = name; - this.partition = partition; - this.statistics = statistics; - } - - public String getName() { - return name; - } - - public HivePartition getPartition() { - return partition; - } - - public HivePartitionStatistics getStatistics() { - return statistics; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveProperties.java deleted file mode 100644 index 36c147da142e75..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveProperties.java +++ /dev/null @@ -1,189 +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.datasource.hive; - -import com.google.common.collect.ImmutableSet; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.hadoop.hive.serde2.OpenCSVSerde; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.Set; - -public class HiveProperties { - public static final String PROP_FIELD_DELIMITER = "field.delim"; - public static final String PROP_SERIALIZATION_FORMAT = "serialization.format"; - public static final String DEFAULT_FIELD_DELIMITER = "\1"; // "\x01" - - public static final String PROP_LINE_DELIMITER = "line.delim"; - public static final String DEFAULT_LINE_DELIMITER = "\n"; - - public static final String PROP_COLLECTION_DELIMITER_HIVE2 = "colelction.delim"; - public static final String PROP_COLLECTION_DELIMITER_HIVE3 = "collection.delim"; - public static final String DEFAULT_COLLECTION_DELIMITER = "\2"; - - public static final String PROP_MAP_KV_DELIMITER = "mapkey.delim"; - public static final String DEFAULT_MAP_KV_DELIMITER = "\003"; - - public static final String PROP_ESCAPE_DELIMITER = "escape.delim"; - public static final String DEFAULT_ESCAPE_DELIMIER = "\\"; - - public static final String PROP_NULL_FORMAT = "serialization.null.format"; - public static final String DEFAULT_NULL_FORMAT = "\\N"; - - public static final String PROP_SKIP_HEADER_COUNT = "skip.header.line.count"; - public static final String DEFAULT_SKIP_HEADER_COUNT = "0"; - - public static final String PROP_SKIP_FOOTER_COUNT = "skip.footer.line.count"; - public static final String DEFAULT_SKIP_FOOTER_COUNT = "0"; - - // The following properties are used for OpenCsvSerde. - public static final String PROP_SEPARATOR_CHAR = OpenCSVSerde.SEPARATORCHAR; - public static final String DEFAULT_SEPARATOR_CHAR = ","; - public static final String PROP_QUOTE_CHAR = OpenCSVSerde.QUOTECHAR; - public static final String DEFAULT_QUOTE_CHAR = "\""; - public static final String PROP_ESCAPE_CHAR = OpenCSVSerde.ESCAPECHAR; - public static final String DEFAULT_ESCAPE_CHAR = "\\"; - - // org.openx.data.jsonserde.JsonSerDe - public static final String PROP_OPENX_IGNORE_MALFORMED_JSON = "ignore.malformed.json"; - public static final String DEFAULT_OPENX_IGNORE_MALFORMED_JSON = "false"; - - public static final Set HIVE_SERDE_PROPERTIES = ImmutableSet.of( - PROP_FIELD_DELIMITER, - PROP_COLLECTION_DELIMITER_HIVE2, - PROP_COLLECTION_DELIMITER_HIVE3, - PROP_SEPARATOR_CHAR, - PROP_SERIALIZATION_FORMAT, - PROP_LINE_DELIMITER, - PROP_QUOTE_CHAR, - PROP_MAP_KV_DELIMITER, - PROP_ESCAPE_DELIMITER, - PROP_ESCAPE_CHAR, - PROP_NULL_FORMAT, - PROP_SKIP_HEADER_COUNT, - PROP_SKIP_FOOTER_COUNT); - - public static String getFieldDelimiter(Table table) { - return getFieldDelimiter(table, false); - } - - public static String getFieldDelimiter(Table table, boolean supportMultiChar) { - // This method is used for text format. - Optional fieldDelim = HiveMetaStoreClientHelper.getSerdeProperty(table, PROP_FIELD_DELIMITER); - Optional serFormat = HiveMetaStoreClientHelper.getSerdeProperty(table, PROP_SERIALIZATION_FORMAT); - String delimiter = HiveMetaStoreClientHelper.firstPresentOrDefault( - "", fieldDelim, serFormat); - return supportMultiChar ? delimiter : HiveMetaStoreClientHelper.getByte(delimiter, DEFAULT_FIELD_DELIMITER); - } - - public static String getSeparatorChar(Table table) { - Optional separatorChar = HiveMetaStoreClientHelper.getSerdeProperty(table, PROP_SEPARATOR_CHAR); - return HiveMetaStoreClientHelper.firstPresentOrDefault( - DEFAULT_SEPARATOR_CHAR, separatorChar); - } - - public static String getLineDelimiter(Table table) { - Optional lineDelim = HiveMetaStoreClientHelper.getSerdeProperty(table, PROP_LINE_DELIMITER); - return HiveMetaStoreClientHelper.getByte(HiveMetaStoreClientHelper.firstPresentOrDefault( - "", lineDelim), DEFAULT_LINE_DELIMITER); - } - - public static String getMapKvDelimiter(Table table) { - Optional mapkvDelim = HiveMetaStoreClientHelper.getSerdeProperty(table, PROP_MAP_KV_DELIMITER); - return HiveMetaStoreClientHelper.getByte(HiveMetaStoreClientHelper.firstPresentOrDefault( - "", mapkvDelim), DEFAULT_MAP_KV_DELIMITER); - } - - public static String getCollectionDelimiter(Table table) { - Optional collectionDelimHive2 = HiveMetaStoreClientHelper.getSerdeProperty(table, - PROP_COLLECTION_DELIMITER_HIVE2); - Optional collectionDelimHive3 = HiveMetaStoreClientHelper.getSerdeProperty(table, - PROP_COLLECTION_DELIMITER_HIVE3); - return HiveMetaStoreClientHelper.getByte(HiveMetaStoreClientHelper.firstPresentOrDefault( - "", collectionDelimHive2, collectionDelimHive3), DEFAULT_COLLECTION_DELIMITER); - } - - public static Optional getEscapeDelimiter(Table table) { - Optional escapeDelim = HiveMetaStoreClientHelper.getSerdeProperty(table, PROP_ESCAPE_DELIMITER); - if (escapeDelim.isPresent()) { - return Optional.of(HiveMetaStoreClientHelper.getByte(escapeDelim.get(), DEFAULT_ESCAPE_DELIMIER)); - } - return Optional.empty(); - } - - public static String getNullFormat(Table table) { - Optional nullFormat = HiveMetaStoreClientHelper.getSerdeProperty(table, PROP_NULL_FORMAT); - return HiveMetaStoreClientHelper.firstPresentOrDefault(DEFAULT_NULL_FORMAT, nullFormat); - } - - public static String getQuoteChar(Table table) { - Optional quoteChar = HiveMetaStoreClientHelper.getSerdeProperty(table, PROP_QUOTE_CHAR); - return HiveMetaStoreClientHelper.firstPresentOrDefault(DEFAULT_QUOTE_CHAR, quoteChar); - } - - public static String getEscapeChar(Table table) { - Optional escapeChar = HiveMetaStoreClientHelper.getSerdeProperty(table, PROP_ESCAPE_CHAR); - return HiveMetaStoreClientHelper.firstPresentOrDefault(DEFAULT_ESCAPE_CHAR, escapeChar); - } - - public static int getSkipHeaderCount(Table table) { - Optional skipHeaderCount = HiveMetaStoreClientHelper.getSerdeProperty(table, PROP_SKIP_HEADER_COUNT); - return Integer - .parseInt(HiveMetaStoreClientHelper.firstPresentOrDefault(DEFAULT_SKIP_HEADER_COUNT, skipHeaderCount)); - } - - public static int getSkipFooterCount(Table table) { - Optional skipFooterCount = HiveMetaStoreClientHelper.getSerdeProperty(table, PROP_SKIP_FOOTER_COUNT); - return Integer - .parseInt(HiveMetaStoreClientHelper.firstPresentOrDefault(DEFAULT_SKIP_FOOTER_COUNT, skipFooterCount)); - } - - public static String getOpenxJsonIgnoreMalformed(Table table) { - Optional escapeChar = HiveMetaStoreClientHelper.getSerdeProperty(table, - PROP_OPENX_IGNORE_MALFORMED_JSON); - return HiveMetaStoreClientHelper.firstPresentOrDefault(DEFAULT_OPENX_IGNORE_MALFORMED_JSON, escapeChar); - } - - // Set properties to table - public static void setTableProperties(Table table, Map properties) { - HashMap serdeProps = new HashMap<>(); - HashMap tblProps = new HashMap<>(); - - for (String k : properties.keySet()) { - if (HIVE_SERDE_PROPERTIES.contains(k)) { - serdeProps.put(k, properties.get(k)); - } else { - tblProps.put(k, properties.get(k)); - } - } - - if (table.getParameters() == null) { - table.setParameters(tblProps); - } else { - table.getParameters().putAll(tblProps); - } - - if (table.getSd().getSerdeInfo().getParameters() == null) { - table.getSd().getSerdeInfo().setParameters(serdeProps); - } else { - table.getSd().getSerdeInfo().getParameters().putAll(serdeProps); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveTableMetadata.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveTableMetadata.java deleted file mode 100644 index 7f7a1ef72737c9..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveTableMetadata.java +++ /dev/null @@ -1,140 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -package org.apache.doris.datasource.hive; - -import org.apache.doris.catalog.Column; -import org.apache.doris.datasource.TableMetadata; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -public class HiveTableMetadata implements TableMetadata { - private final String dbName; - private final String tableName; - private final Optional location; - private final List columns; - private final List partitionKeys; - private final String fileFormat; - private final String comment; - private final Map properties; - private List bucketCols; - private int numBuckets; - // private String viewSql; - - public HiveTableMetadata(String dbName, - String tblName, - Optional location, - List columns, - List partitionKeys, - Map props, - String fileFormat, - String comment) { - this(dbName, tblName, location, columns, partitionKeys, new ArrayList<>(), 0, props, fileFormat, comment); - } - - public HiveTableMetadata(String dbName, String tableName, - Optional location, - List columns, - List partitionKeys, - List bucketCols, - int numBuckets, - Map props, - String fileFormat, - String comment) { - this.dbName = dbName; - this.tableName = tableName; - this.columns = columns; - this.partitionKeys = partitionKeys; - this.bucketCols = bucketCols; - this.numBuckets = numBuckets; - this.properties = props; - this.fileFormat = fileFormat; - this.location = location; - this.comment = comment; - } - - @Override - public String getDbName() { - return dbName; - } - - @Override - public String getTableName() { - return tableName; - } - - public Optional getLocation() { - return location; - } - - public String getComment() { - return comment == null ? "" : comment; - } - - @Override - public Map getProperties() { - return properties; - } - - public List getColumns() { - return columns; - } - - public List getPartitionKeys() { - return partitionKeys; - } - - public String getFileFormat() { - return fileFormat; - } - - public List getBucketCols() { - return bucketCols; - } - - public int getNumBuckets() { - return numBuckets; - } - - public static HiveTableMetadata of(String dbName, - String tblName, - Optional location, - List columns, - List partitionKeys, - Map props, - String fileFormat, - String comment) { - return new HiveTableMetadata(dbName, tblName, location, columns, partitionKeys, props, fileFormat, comment); - } - - public static HiveTableMetadata of(String dbName, - String tblName, - Optional location, - List columns, - List partitionKeys, - List bucketCols, - int numBuckets, - Map props, - String fileFormat, - String comment) { - return new HiveTableMetadata(dbName, tblName, location, columns, partitionKeys, - bucketCols, numBuckets, props, fileFormat, comment); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveTransaction.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveTransaction.java deleted file mode 100644 index aea0ce45fdfa44..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveTransaction.java +++ /dev/null @@ -1,89 +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.datasource.hive; - -import org.apache.doris.catalog.info.TableNameInfo; -import org.apache.doris.common.UserException; - -import com.google.common.collect.Lists; - -import java.util.List; -import java.util.Map; - -/** - * HiveTransaction is used to save info of a hive transaction. - * Used when reading hive transactional table. - * Each HiveTransaction is bound to a query. - */ -public class HiveTransaction { - private final String queryId; - private final String user; - private final HMSExternalTable hiveTable; - - private final boolean isFullAcid; - - private long txnId; - private List partitionNames = Lists.newArrayList(); - - Map txnValidIds = null; - - public HiveTransaction(String queryId, String user, HMSExternalTable hiveTable, boolean isFullAcid) { - this.queryId = queryId; - this.user = user; - this.hiveTable = hiveTable; - this.isFullAcid = isFullAcid; - } - - public String getQueryId() { - return queryId; - } - - public void addPartition(String partitionName) { - this.partitionNames.add(partitionName); - } - - public boolean isFullAcid() { - return isFullAcid; - } - - public Map getValidWriteIds(HMSCachedClient client) { - if (txnValidIds == null) { - TableNameInfo tableName = new TableNameInfo(hiveTable.getCatalog().getName(), hiveTable.getRemoteDbName(), - hiveTable.getRemoteName()); - client.acquireSharedLock(queryId, txnId, user, tableName, partitionNames, 5000); - txnValidIds = client.getValidWriteIds(tableName.getDb() + "." + tableName.getTbl(), txnId); - } - return txnValidIds; - } - - public void begin() throws UserException { - try { - this.txnId = ((HMSExternalCatalog) hiveTable.getCatalog()).getClient().openTxn(user); - } catch (RuntimeException e) { - throw new UserException(e.getMessage(), e); - } - } - - public void commit() throws UserException { - try { - ((HMSExternalCatalog) hiveTable.getCatalog()).getClient().commitTxn(txnId); - } catch (RuntimeException e) { - throw new UserException(e.getMessage(), e); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveTransactionMgr.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveTransactionMgr.java deleted file mode 100644 index e70452b859133e..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveTransactionMgr.java +++ /dev/null @@ -1,55 +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.datasource.hive; - -import org.apache.doris.common.UserException; - -import com.google.common.collect.Maps; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.Map; - -/** - * HiveTransactionMgr is used to manage hive transaction. - * For each query, it will register a HiveTransaction. - * When query is finished, it will deregister the HiveTransaction. - */ -public class HiveTransactionMgr { - private static final Logger LOG = LogManager.getLogger(HiveTransactionMgr.class); - private Map txnMap = Maps.newConcurrentMap(); - - public HiveTransactionMgr() { - } - - public void register(HiveTransaction txn) throws UserException { - txn.begin(); - txnMap.put(txn.getQueryId(), txn); - } - - public void deregister(String queryId) { - HiveTransaction txn = txnMap.remove(queryId); - if (txn != null) { - try { - txn.commit(); - } catch (UserException e) { - LOG.warn("failed to commit hive txn: " + queryId, e); - } - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveUtil.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveUtil.java deleted file mode 100644 index 45e26a084f5ef3..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveUtil.java +++ /dev/null @@ -1,420 +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.datasource.hive; - -import org.apache.doris.catalog.Column; -import org.apache.doris.common.Pair; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.statistics.CommonStatistics; -import org.apache.doris.filesystem.FileSystem; -import org.apache.doris.nereids.exceptions.AnalysisException; -import org.apache.doris.qe.ConnectContext; - -import com.google.common.base.Preconditions; -import com.google.common.base.Strings; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ImmutableSet; -import org.apache.commons.lang3.StringUtils; -import org.apache.hadoop.hive.common.FileUtils; -import org.apache.hadoop.hive.common.StatsSetupConst; -import org.apache.hadoop.hive.metastore.api.Database; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.PrincipalType; -import org.apache.hadoop.hive.metastore.api.SerDeInfo; -import org.apache.hadoop.hive.metastore.api.StorageDescriptor; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.hadoop.hive.ql.io.SymlinkTextInputFormat; -import org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat; -import org.apache.hadoop.mapred.InputFormat; -import org.apache.hadoop.mapred.JobConf; -import org.apache.hadoop.mapred.TextInputFormat; -import org.apache.hadoop.util.ReflectionUtils; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * Hive util for create or query hive table. - */ -public final class HiveUtil { - - public static final String COMPRESSION_KEY = "compression"; - public static final Set SUPPORTED_ORC_COMPRESSIONS = - ImmutableSet.of("plain", "zlib", "snappy", "zstd", "lz4"); - public static final Set SUPPORTED_PARQUET_COMPRESSIONS = - ImmutableSet.of("plain", "snappy", "zstd", "lz4"); - public static final Set SUPPORTED_TEXT_COMPRESSIONS = - ImmutableSet.of("plain", "gzip", "zstd", "bzip2", "lz4", "snappy"); - - private HiveUtil() { - } - - /** - * get input format class from inputFormatName. - * - * @param jobConf jobConf used when getInputFormatClass - * @param inputFormatName inputFormat class name - * @param symlinkTarget use target inputFormat class when inputFormat is SymlinkTextInputFormat - * @return a class of inputFormat. - * @throws UserException when class not found. - */ - public static InputFormat getInputFormat(JobConf jobConf, - String inputFormatName, boolean symlinkTarget) throws UserException { - try { - Class> inputFormatClass = getInputFormatClass(jobConf, inputFormatName); - if (symlinkTarget && (inputFormatClass == SymlinkTextInputFormat.class)) { - // symlink targets are always TextInputFormat - inputFormatClass = TextInputFormat.class; - } - - return ReflectionUtils.newInstance(inputFormatClass, jobConf); - } catch (ClassNotFoundException | RuntimeException e) { - throw new UserException("Unable to create input format " + inputFormatName, e); - } - } - - @SuppressWarnings({"unchecked", "RedundantCast"}) - private static Class> getInputFormatClass(JobConf conf, String inputFormatName) - throws ClassNotFoundException { - // CDH uses different names for Parquet - if ("parquet.hive.DeprecatedParquetInputFormat".equals(inputFormatName) - || "parquet.hive.MapredParquetInputFormat".equals(inputFormatName)) { - return MapredParquetInputFormat.class; - } - - Class clazz = conf.getClassByName(inputFormatName); - return (Class>) clazz.asSubclass(InputFormat.class); - } - - public static boolean isSplittable(FileSystem remoteFileSystem, String inputFormat, - String location) throws UserException { - // LZO files are not splittable (unless .lzo.index exists, but Doris does not support indexed split). - // Use contains("lzo") to cover LzoTextInputFormat, DeprecatedLzoTextInputFormat, and future variants. - if (isLzoInputFormat(inputFormat)) { - return false; - } - // All other supported hive input formats are splittable - return HMSExternalTable.SUPPORTED_HIVE_FILE_FORMATS.contains(inputFormat); - } - - /** - * Returns true if the given InputFormat class name is one of the hadoop-lzo text InputFormat variants. - * These formats only read *.lzo data files and must exclude *.lzo.index sidecar files. - */ - public static boolean isLzoInputFormat(String inputFormat) { - return inputFormat != null && inputFormat.toLowerCase().contains("lzo"); - } - - /** - * For LZO text InputFormats, only *.lzo files are data files. - * *.lzo.index and any other non-*.lzo files are sidecar/metadata files that must be excluded. - * This mirrors the filtering semantics of Hive's LzoTextInputFormat.listStatus(). - * - * @param filePath the full path of the file entry - * @return true if the file should be included in the scan set for an LZO InputFormat - */ - public static boolean isLzoDataFile(String filePath) { - // Normalise: strip query-string/fragment if any - String path = filePath; - int q = path.indexOf('?'); - if (q >= 0) { - path = path.substring(0, q); - } - // Only include files whose name ends with ".lzo" - // Files ending with ".lzo.index" or any other extension are excluded. - String lower = path.toLowerCase(); - return lower.endsWith(".lzo"); - } - - // "c1=a/c2=b/c3=c" ---> List(["c1","a"], ["c2","b"], ["c3","c"]) - // Similar to the `toPartitionValues` method, except that it adds the partition column name. - public static List toPartitionColNameAndValues(String partitionName) { - - String[] parts = partitionName.split("/"); - List result = new ArrayList<>(parts.length); - for (String part : parts) { - String[] kv = part.split("="); - Preconditions.checkState(kv.length == 2, String.format("Malformed partition name %s", part)); - - result.add(new String[] { - FileUtils.unescapePathName(kv[0]), - FileUtils.unescapePathName(kv[1]) - }); - } - return result; - } - - // "c1=a/c2=b/c3=c" ---> List("a","b","c") - public static List toPartitionValues(String partitionName) { - ImmutableList.Builder resultBuilder = ImmutableList.builder(); - int start = 0; - while (true) { - while (start < partitionName.length() && partitionName.charAt(start) != '=') { - start++; - } - start++; - int end = start; - while (end < partitionName.length() && partitionName.charAt(end) != '/') { - end++; - } - if (start > partitionName.length()) { - break; - } - //Ref: common/src/java/org/apache/hadoop/hive/common/FileUtils.java - //makePartName(List partCols, List vals,String defaultStr) - resultBuilder.add(FileUtils.unescapePathName(partitionName.substring(start, end))); - start = end + 1; - } - return resultBuilder.build(); - } - - // List("c1=a/c2=b/c3=c", "c1=a/c2=b/c3=d") - // | - // | - // v - // Map( - // key:"c1=a/c2=b/c3=c", value:Partition(values=List(a,b,c)) - // key:"c1=a/c2=b/c3=d", value:Partition(values=List(a,b,d)) - // ) - public static Map convertToNamePartitionMap( - List partitionNames, - List partitions) { - - Map> partitionNameToPartitionValues = - partitionNames - .stream() - .collect(Collectors.toMap(partitionName -> partitionName, HiveUtil::toPartitionValues)); - - Map, Partition> partitionValuesToPartition = - partitions.stream() - .collect(Collectors.toMap(Partition::getValues, partition -> partition)); - - ImmutableMap.Builder resultBuilder = ImmutableMap.builder(); - for (Map.Entry> entry : partitionNameToPartitionValues.entrySet()) { - Partition partition = partitionValuesToPartition.get(entry.getValue()); - if (partition != null) { - resultBuilder.put(entry.getKey(), partition); - } - } - return resultBuilder.build(); - } - - public static Table toHiveTable(HiveTableMetadata hiveTable) { - Objects.requireNonNull(hiveTable.getDbName(), "Hive database name should be not null"); - Objects.requireNonNull(hiveTable.getTableName(), "Hive table name should be not null"); - Table table = new Table(); - table.setDbName(hiveTable.getDbName()); - table.setTableName(hiveTable.getTableName()); - int createTime = (int) System.currentTimeMillis() * 1000; - table.setCreateTime(createTime); - table.setLastAccessTime(createTime); - // table.setRetention(0); - Set partitionSet = new HashSet<>(hiveTable.getPartitionKeys()); - Pair, List> hiveSchema = toHiveSchema(hiveTable.getColumns(), partitionSet); - - table.setSd(toHiveStorageDesc(hiveSchema.first, hiveTable.getBucketCols(), hiveTable.getNumBuckets(), - hiveTable.getFileFormat(), hiveTable.getLocation())); - table.setPartitionKeys(hiveSchema.second); - - // table.setViewOriginalText(hiveTable.getViewSql()); - // table.setViewExpandedText(hiveTable.getViewSql()); - table.setTableType("MANAGED_TABLE"); - Map props = new HashMap<>(hiveTable.getProperties()); - props.put(ExternalCatalog.DORIS_VERSION, ExternalCatalog.DORIS_VERSION_VALUE); - setCompressType(hiveTable, props); - // set hive table comment by table properties - props.put("comment", hiveTable.getComment()); - if (props.containsKey("owner")) { - table.setOwner(props.get("owner")); - } - HiveProperties.setTableProperties(table, props); - return table; - } - - private static void setCompressType(HiveTableMetadata hiveTable, Map props) { - String fileFormat = hiveTable.getFileFormat(); - String compression = props.get(COMPRESSION_KEY); - // on HMS, default orc compression type is zlib and default parquet compression type is snappy. - if (fileFormat.equalsIgnoreCase("parquet")) { - if (StringUtils.isNotEmpty(compression) && !SUPPORTED_PARQUET_COMPRESSIONS.contains(compression)) { - throw new AnalysisException("Unsupported parquet compression type " + compression); - } - props.putIfAbsent("parquet.compression", StringUtils.isEmpty(compression) ? "snappy" : compression); - } else if (fileFormat.equalsIgnoreCase("orc")) { - if (StringUtils.isNotEmpty(compression) && !SUPPORTED_ORC_COMPRESSIONS.contains(compression)) { - throw new AnalysisException("Unsupported orc compression type " + compression); - } - props.putIfAbsent("orc.compress", StringUtils.isEmpty(compression) ? "zlib" : compression); - } else if (fileFormat.equalsIgnoreCase("text")) { - if (StringUtils.isNotEmpty(compression) && !SUPPORTED_TEXT_COMPRESSIONS.contains(compression)) { - throw new AnalysisException("Unsupported text compression type " + compression); - } - props.putIfAbsent("text.compression", StringUtils.isEmpty(compression) - ? ConnectContext.get().getSessionVariable().hiveTextCompression() : compression); - } else { - throw new IllegalArgumentException("Compression is not supported on " + fileFormat); - } - // remove if exists - props.remove(COMPRESSION_KEY); - } - - private static StorageDescriptor toHiveStorageDesc(List columns, - List bucketCols, int numBuckets, String fileFormat, Optional location) { - StorageDescriptor sd = new StorageDescriptor(); - sd.setCols(columns); - setFileFormat(fileFormat, sd); - location.ifPresent(sd::setLocation); - - sd.setBucketCols(bucketCols); - sd.setNumBuckets(numBuckets); - Map parameters = new HashMap<>(); - parameters.put("tag", "doris external hive table"); - sd.setParameters(parameters); - return sd; - } - - private static void setFileFormat(String fileFormat, StorageDescriptor sd) { - String inputFormat; - String outputFormat; - String serDe; - if (fileFormat.equalsIgnoreCase("orc")) { - inputFormat = "org.apache.hadoop.hive.ql.io.orc.OrcInputFormat"; - outputFormat = "org.apache.hadoop.hive.ql.io.orc.OrcOutputFormat"; - serDe = "org.apache.hadoop.hive.ql.io.orc.OrcSerde"; - } else if (fileFormat.equalsIgnoreCase("parquet")) { - inputFormat = "org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat"; - outputFormat = "org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat"; - serDe = "org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe"; - } else if (fileFormat.equalsIgnoreCase("text")) { - inputFormat = "org.apache.hadoop.mapred.TextInputFormat"; - outputFormat = "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat"; - serDe = "org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe"; - } else { - throw new IllegalArgumentException("Creating table with an unsupported file format: " + fileFormat); - } - SerDeInfo serDeInfo = new SerDeInfo(); - serDeInfo.setSerializationLib(serDe); - sd.setSerdeInfo(serDeInfo); - sd.setInputFormat(inputFormat); - sd.setOutputFormat(outputFormat); - } - - private static Pair, List> toHiveSchema(List columns, - Set partitionSet) { - List hiveCols = new ArrayList<>(); - List hiveParts = new ArrayList<>(); - for (Column column : columns) { - FieldSchema hiveFieldSchema = new FieldSchema(); - // TODO: add doc, just support doris type - hiveFieldSchema.setType(HiveMetaStoreClientHelper.dorisTypeToHiveType(column.getType())); - hiveFieldSchema.setName(column.getName()); - hiveFieldSchema.setComment(column.getComment()); - if (partitionSet.contains(column.getName())) { - hiveParts.add(hiveFieldSchema); - } else { - hiveCols.add(hiveFieldSchema); - } - } - return Pair.of(hiveCols, hiveParts); - } - - public static Database toHiveDatabase(HiveDatabaseMetadata hiveDb) { - Database database = new Database(); - database.setName(hiveDb.getDbName()); - if (StringUtils.isNotEmpty(hiveDb.getLocationUri())) { - database.setLocationUri(hiveDb.getLocationUri()); - } - Map props = hiveDb.getProperties(); - database.setParameters(props); - database.setDescription(hiveDb.getComment()); - if (props.containsKey("owner")) { - database.setOwnerName(props.get("owner")); - database.setOwnerType(PrincipalType.USER); - } - return database; - } - - public static Map updateStatisticsParameters( - Map parameters, - CommonStatistics statistics) { - HashMap result = new HashMap<>(parameters); - - result.put(StatsSetupConst.NUM_FILES, String.valueOf(statistics.getFileCount())); - result.put(StatsSetupConst.ROW_COUNT, String.valueOf(statistics.getRowCount())); - result.put(StatsSetupConst.TOTAL_SIZE, String.valueOf(statistics.getTotalFileBytes())); - - // CDH 5.16 metastore ignores stats unless STATS_GENERATED_VIA_STATS_TASK is set - // https://github.com/cloudera/hive/blob/cdh5.16.2-release/metastore/src/java/org/apache/hadoop/hive/metastore/MetaStoreUtils.java#L227-L231 - if (!parameters.containsKey("STATS_GENERATED_VIA_STATS_TASK")) { - result.put("STATS_GENERATED_VIA_STATS_TASK", "workaround for potential lack of HIVE-12730"); - } - - return result; - } - - public static HivePartitionStatistics toHivePartitionStatistics(Map params) { - long rowCount = Long.parseLong(params.getOrDefault(StatsSetupConst.ROW_COUNT, "-1")); - long totalSize = Long.parseLong(params.getOrDefault(StatsSetupConst.TOTAL_SIZE, "-1")); - long numFiles = Long.parseLong(params.getOrDefault(StatsSetupConst.NUM_FILES, "-1")); - return HivePartitionStatistics.fromCommonStatistics(rowCount, numFiles, totalSize); - } - - public static Partition toMetastoreApiPartition(HivePartitionWithStatistics partitionWithStatistics) { - Partition partition = - toMetastoreApiPartition(partitionWithStatistics.getPartition()); - partition.setParameters(updateStatisticsParameters( - partition.getParameters(), partitionWithStatistics.getStatistics().getCommonStatistics())); - return partition; - } - - public static Partition toMetastoreApiPartition(HivePartition hivePartition) { - Partition result = new Partition(); - result.setDbName(hivePartition.getNameMapping().getRemoteDbName()); - result.setTableName(hivePartition.getNameMapping().getRemoteTblName()); - result.setValues(hivePartition.getPartitionValues()); - result.setSd(makeStorageDescriptorFromHivePartition(hivePartition)); - result.setParameters(hivePartition.getParameters()); - return result; - } - - public static StorageDescriptor makeStorageDescriptorFromHivePartition(HivePartition partition) { - SerDeInfo serdeInfo = new SerDeInfo(); - serdeInfo.setName(partition.getNameMapping().getRemoteTblName()); - serdeInfo.setSerializationLib(partition.getSerde()); - - StorageDescriptor sd = new StorageDescriptor(); - sd.setLocation(Strings.emptyToNull(partition.getPath())); - sd.setCols(partition.getColumns()); - sd.setSerdeInfo(serdeInfo); - sd.setInputFormat(partition.getInputFormat()); - sd.setOutputFormat(partition.getOutputFormat()); - sd.setParameters(ImmutableMap.of()); - - return sd; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HudiDlaTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HudiDlaTable.java deleted file mode 100644 index ebc374fa32c2fc..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HudiDlaTable.java +++ /dev/null @@ -1,132 +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.datasource.hive; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.PartitionItem; -import org.apache.doris.catalog.PartitionType; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.datasource.CacheException; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.TablePartitionValues; -import org.apache.doris.datasource.hudi.HudiMvccSnapshot; -import org.apache.doris.datasource.hudi.HudiSchemaCacheKey; -import org.apache.doris.datasource.hudi.HudiUtils; -import org.apache.doris.datasource.mvcc.MvccSnapshot; -import org.apache.doris.mtmv.MTMVRefreshContext; -import org.apache.doris.mtmv.MTMVSnapshotIf; -import org.apache.doris.mtmv.MTMVTimestampSnapshot; - -import com.google.common.collect.Maps; - -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -public class HudiDlaTable extends HMSDlaTable { - - public HudiDlaTable(HMSExternalTable table) { - super(table); - } - - @Override - public PartitionType getPartitionType(Optional snapshot) { - return getPartitionColumns(snapshot).size() > 0 ? PartitionType.LIST : PartitionType.UNPARTITIONED; - } - - @Override - public Set getPartitionColumnNames(Optional snapshot) { - return getPartitionColumns(snapshot).stream() - .map(c -> c.getName().toLowerCase()).collect(Collectors.toSet()); - } - - @Override - public List getPartitionColumns(Optional snapshot) { - return getHudiSchemaCacheValue(snapshot).getPartitionColumns(); - } - - @Override - public Map getAndCopyPartitionItems(Optional snapshot) { - TablePartitionValues tablePartitionValues = getOrFetchHudiSnapshotCacheValue(snapshot); - Map idToPartitionItem = tablePartitionValues.getIdToPartitionItem(); - Map partitionIdToNameMap = tablePartitionValues.getPartitionIdToNameMap(); - Map copiedPartitionItems = Maps.newHashMap(); - for (Long key : partitionIdToNameMap.keySet()) { - copiedPartitionItems.put(partitionIdToNameMap.get(key), idToPartitionItem.get(key)); - } - return copiedPartitionItems; - } - - @Override - public MTMVSnapshotIf getPartitionSnapshot(String partitionName, MTMVRefreshContext context, - Optional snapshot) throws AnalysisException { - // Map partitionNameToLastModifiedMap = getOrFetchHudiSnapshotCacheValue( - // snapshot).getPartitionNameToLastModifiedMap(); - // return new MTMVTimestampSnapshot(partitionNameToLastModifiedMap.get(partitionName)); - return new MTMVTimestampSnapshot(0L); - } - - @Override - public MTMVSnapshotIf getTableSnapshot(MTMVRefreshContext context, Optional snapshot) - throws AnalysisException { - return getTableSnapshot(snapshot); - } - - @Override - public MTMVSnapshotIf getTableSnapshot(Optional snapshot) throws AnalysisException { - // return new MTMVTimestampSnapshot(getOrFetchHudiSnapshotCacheValue(snapshot).getLastUpdateTimestamp()); - return new MTMVTimestampSnapshot(0L); - } - - @Override - public boolean isPartitionColumnAllowNull() { - return true; - } - - private TablePartitionValues getOrFetchHudiSnapshotCacheValue(Optional snapshot) { - if (snapshot.isPresent()) { - return ((HudiMvccSnapshot) snapshot.get()).getTablePartitionValues(); - } else { - return HudiUtils.getPartitionValues(Optional.empty(), hmsTable); - } - } - - public HMSSchemaCacheValue getHudiSchemaCacheValue(Optional snapshot) { - long timestamp = 0L; - if (snapshot.isPresent()) { - timestamp = ((HudiMvccSnapshot) snapshot.get()).getTimestamp(); - } else { - timestamp = HudiUtils.getLastTimeStamp(hmsTable); - } - return getHudiSchemaCacheValue(timestamp); - } - - private HMSSchemaCacheValue getHudiSchemaCacheValue(long timestamp) { - Optional schemaCacheValue = Env.getCurrentEnv().getExtMetaCacheMgr() - .getSchemaCacheValue(hmsTable, - new HudiSchemaCacheKey(hmsTable.getOrBuildNameMapping(), timestamp)); - if (!schemaCacheValue.isPresent()) { - throw new CacheException("failed to getSchema for: %s.%s.%s.%s", - null, hmsTable.getCatalog().getName(), hmsTable.getDbName(), hmsTable.getName(), timestamp); - } - return (HMSSchemaCacheValue) schemaCacheValue.get(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/IcebergDlaTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/IcebergDlaTable.java deleted file mode 100644 index 4868e0a58410b0..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/IcebergDlaTable.java +++ /dev/null @@ -1,145 +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.datasource.hive; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.PartitionItem; -import org.apache.doris.catalog.PartitionType; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.datasource.iceberg.IcebergSnapshotCacheValue; -import org.apache.doris.datasource.iceberg.IcebergUtils; -import org.apache.doris.datasource.mvcc.MvccSnapshot; -import org.apache.doris.mtmv.MTMVRefreshContext; -import org.apache.doris.mtmv.MTMVSnapshotIdSnapshot; -import org.apache.doris.mtmv.MTMVSnapshotIf; - -import com.google.common.collect.Maps; -import com.google.common.collect.Sets; -import org.apache.iceberg.PartitionField; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Table; - -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -public class IcebergDlaTable extends HMSDlaTable { - - private boolean isValidRelatedTableCached = false; - private boolean isValidRelatedTable = false; - - public IcebergDlaTable(HMSExternalTable table) { - super(table); - } - - @Override - public Map getAndCopyPartitionItems(Optional snapshot) { - return Maps.newHashMap(IcebergUtils.getIcebergPartitionItems(snapshot, hmsTable)); - } - - @Override - public PartitionType getPartitionType(Optional snapshot) { - return isValidRelatedTable() ? PartitionType.RANGE : PartitionType.UNPARTITIONED; - } - - @Override - public Set getPartitionColumnNames(Optional snapshot) { - return getPartitionColumns(snapshot).stream().map(Column::getName).collect(Collectors.toSet()); - } - - @Override - public List getPartitionColumns(Optional snapshot) { - return IcebergUtils.getIcebergPartitionColumns(snapshot, hmsTable); - } - - @Override - public MTMVSnapshotIf getPartitionSnapshot(String partitionName, MTMVRefreshContext context, - Optional snapshot) throws AnalysisException { - IcebergSnapshotCacheValue snapshotValue = IcebergUtils.getSnapshotCacheValue(snapshot, hmsTable); - long latestSnapshotId = snapshotValue.getPartitionInfo().getLatestSnapshotId(partitionName); - // If partition snapshot ID is unavailable (<= 0), fallback to table snapshot ID - // This can happen when last_updated_snapshot_id is null in Iceberg metadata - if (latestSnapshotId <= 0) { - long tableSnapshotId = snapshotValue.getSnapshot().getSnapshotId(); - // If table snapshot ID is also invalid, it means empty table - if (tableSnapshotId <= 0) { - throw new AnalysisException("can not find partition: " + partitionName - + ", and table snapshot ID is also invalid"); - } - // Use table snapshot ID as fallback when partition snapshot ID is unavailable - return new MTMVSnapshotIdSnapshot(tableSnapshotId); - } - return new MTMVSnapshotIdSnapshot(latestSnapshotId); - } - - @Override - public MTMVSnapshotIf getTableSnapshot(MTMVRefreshContext context, Optional snapshot) - throws AnalysisException { - hmsTable.makeSureInitialized(); - IcebergSnapshotCacheValue snapshotValue = IcebergUtils.getSnapshotCacheValue(snapshot, hmsTable); - return new MTMVSnapshotIdSnapshot(snapshotValue.getSnapshot().getSnapshotId()); - } - - @Override - public MTMVSnapshotIf getTableSnapshot(Optional snapshot) throws AnalysisException { - hmsTable.makeSureInitialized(); - IcebergSnapshotCacheValue snapshotValue = IcebergUtils.getSnapshotCacheValue(snapshot, hmsTable); - return new MTMVSnapshotIdSnapshot(snapshotValue.getSnapshot().getSnapshotId()); - } - - @Override - boolean isPartitionColumnAllowNull() { - return true; - } - - @Override - protected boolean isValidRelatedTable() { - if (isValidRelatedTableCached) { - return isValidRelatedTable; - } - isValidRelatedTable = false; - Set allFields = Sets.newHashSet(); - Table table = IcebergUtils.getIcebergTable(hmsTable); - for (PartitionSpec spec : table.specs().values()) { - if (spec == null) { - isValidRelatedTableCached = true; - return false; - } - List fields = spec.fields(); - if (fields.size() != 1) { - isValidRelatedTableCached = true; - return false; - } - PartitionField partitionField = spec.fields().get(0); - String transformName = partitionField.transform().toString(); - if (!IcebergUtils.YEAR.equals(transformName) - && !IcebergUtils.MONTH.equals(transformName) - && !IcebergUtils.DAY.equals(transformName) - && !IcebergUtils.HOUR.equals(transformName)) { - isValidRelatedTableCached = true; - return false; - } - allFields.add(table.schema().findColumnName(partitionField.sourceId())); - } - isValidRelatedTableCached = true; - isValidRelatedTable = allFields.size() == 1; - return isValidRelatedTable; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/ThriftHMSCachedClient.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/ThriftHMSCachedClient.java deleted file mode 100644 index 57e3e7a884d9ff..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/ThriftHMSCachedClient.java +++ /dev/null @@ -1,923 +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.datasource.hive; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.info.TableNameInfo; -import org.apache.doris.common.Config; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; -import org.apache.doris.datasource.DatabaseMetadata; -import org.apache.doris.datasource.TableMetadata; -import org.apache.doris.datasource.hive.event.MetastoreNotificationFetchException; -import org.apache.doris.datasource.property.metastore.HMSBaseProperties; - -import com.aliyun.datalake.metastore.hive2.ProxyMetaStoreClient; -import com.amazonaws.glue.catalog.metastore.AWSCatalogMetastoreClient; -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Lists; -import org.apache.commons.pool2.BasePooledObjectFactory; -import org.apache.commons.pool2.PooledObject; -import org.apache.commons.pool2.impl.DefaultPooledObject; -import org.apache.commons.pool2.impl.GenericObjectPool; -import org.apache.commons.pool2.impl.GenericObjectPoolConfig; -import org.apache.hadoop.hive.common.ValidReadTxnList; -import org.apache.hadoop.hive.common.ValidReaderWriteIdList; -import org.apache.hadoop.hive.common.ValidTxnList; -import org.apache.hadoop.hive.common.ValidTxnWriteIdList; -import org.apache.hadoop.hive.common.ValidWriteIdList; -import org.apache.hadoop.hive.conf.HiveConf; -import org.apache.hadoop.hive.metastore.HiveMetaHookLoader; -import org.apache.hadoop.hive.metastore.HiveMetaStoreClient; -import org.apache.hadoop.hive.metastore.IMetaStoreClient; -import org.apache.hadoop.hive.metastore.LockComponentBuilder; -import org.apache.hadoop.hive.metastore.LockRequestBuilder; -import org.apache.hadoop.hive.metastore.RetryingMetaStoreClient; -import org.apache.hadoop.hive.metastore.api.Catalog; -import org.apache.hadoop.hive.metastore.api.ColumnStatisticsObj; -import org.apache.hadoop.hive.metastore.api.CurrentNotificationEventId; -import org.apache.hadoop.hive.metastore.api.DataOperationType; -import org.apache.hadoop.hive.metastore.api.Database; -import org.apache.hadoop.hive.metastore.api.DefaultConstraintsRequest; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.LockComponent; -import org.apache.hadoop.hive.metastore.api.LockResponse; -import org.apache.hadoop.hive.metastore.api.LockState; -import org.apache.hadoop.hive.metastore.api.MetaException; -import org.apache.hadoop.hive.metastore.api.NotificationEventResponse; -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.SQLDefaultConstraint; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.hadoop.hive.metastore.api.TableValidWriteIds; -import org.apache.hadoop.hive.metastore.txn.TxnUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import shade.doris.hive.org.apache.thrift.TApplicationException; - -import java.security.PrivilegedExceptionAction; -import java.util.ArrayList; -import java.util.BitSet; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; -import java.util.stream.Collectors; - -/** - * This class uses the thrift protocol to directly access the HiveMetaStore service - * to obtain Hive metadata information - */ -public class ThriftHMSCachedClient implements HMSCachedClient { - private static final Logger LOG = LogManager.getLogger(ThriftHMSCachedClient.class); - - private static final HiveMetaHookLoader DUMMY_HOOK_LOADER = t -> null; - // -1 means no limit on the partitions returned. - private static final short MAX_LIST_PARTITION_NUM = Config.max_hive_list_partition_num; - private static final long CLIENT_POOL_BORROW_TIMEOUT_MS = 60_000L; - - private final GenericObjectPool clientPool; - private volatile boolean isClosed = false; - private final HiveConf hiveConf; - private final ExecutionAuthenticator executionAuthenticator; - private final MetaStoreClientProvider metaStoreClientProvider; - - public ThriftHMSCachedClient(HiveConf hiveConf, int poolSize, ExecutionAuthenticator executionAuthenticator) { - this(hiveConf, poolSize, executionAuthenticator, new DefaultMetaStoreClientProvider()); - } - - ThriftHMSCachedClient(HiveConf hiveConf, int poolSize, ExecutionAuthenticator executionAuthenticator, - MetaStoreClientProvider metaStoreClientProvider) { - Preconditions.checkArgument(poolSize >= 0, poolSize); - this.hiveConf = hiveConf; - this.executionAuthenticator = executionAuthenticator; - this.metaStoreClientProvider = Preconditions.checkNotNull(metaStoreClientProvider, "metaStoreClientProvider"); - this.clientPool = poolSize == 0 ? null - : new GenericObjectPool<>(new ThriftHMSClientFactory(), createPoolConfig(poolSize)); - } - - @Override - public void close() { - if (isClosed) { - return; - } - isClosed = true; - if (clientPool == null) { - return; - } - try { - clientPool.close(); - } catch (Exception e) { - LOG.warn("failed to close thrift client pool", e); - } - } - - @Override - public List getAllDatabases() { - try (ThriftHMSClient client = getClient()) { - try { - return ugiDoAs(client.client::getAllDatabases); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new HMSClientException("failed to get all database from hms client", e); - } - } - - @Override - public List getAllTables(String dbName) { - try (ThriftHMSClient client = getClient()) { - try { - return ugiDoAs(() -> client.client.getAllTables(dbName)); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new HMSClientException("failed to get all tables for db %s", e, dbName); - } - } - - @Override - public void createDatabase(DatabaseMetadata db) { - try (ThriftHMSClient client = getClient()) { - try { - if (db instanceof HiveDatabaseMetadata) { - HiveDatabaseMetadata hiveDb = (HiveDatabaseMetadata) db; - ugiDoAs(() -> { - client.client.createDatabase(HiveUtil.toHiveDatabase(hiveDb)); - return null; - }); - } - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new HMSClientException("failed to create database from hms client", e); - } - } - - @Override - public void createTable(TableMetadata tbl, boolean ignoreIfExists) { - if (tableExists(tbl.getDbName(), tbl.getTableName())) { - throw new HMSClientException("Table '" + tbl.getTableName() - + "' has existed in '" + tbl.getDbName() + "'."); - } - try (ThriftHMSClient client = getClient()) { - try { - // String location, - if (tbl instanceof HiveTableMetadata) { - Table hiveTable = HiveUtil.toHiveTable((HiveTableMetadata) tbl); - List tableColumns = ((HiveTableMetadata) tbl).getColumns(); - List dvs = new ArrayList<>(tableColumns.size()); - for (Column tableColumn : tableColumns) { - if (tableColumn.hasDefaultValue()) { - SQLDefaultConstraint dv = new SQLDefaultConstraint(); - dv.setTable_db(tbl.getDbName()); - dv.setTable_name(tbl.getTableName()); - dv.setColumn_name(tableColumn.getName()); - dv.setDefault_value(tableColumn.getDefaultValue()); - dv.setDc_name(tableColumn.getName() + "_dv_constraint"); - dvs.add(dv); - } - } - ugiDoAs(() -> { - if (!dvs.isEmpty()) { - // foreignKeys, uniqueConstraints, notNullConstraints, defaultConstraints, checkConstraints - client.client.createTableWithConstraints(hiveTable, null, - null, null, null, dvs, null); - return null; - } - client.client.createTable(hiveTable); - return null; - }); - } - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new HMSClientException("failed to create table from hms client", e); - } - } - - @Override - public void dropDatabase(String dbName) { - try (ThriftHMSClient client = getClient()) { - try { - ugiDoAs(() -> { - client.client.dropDatabase(dbName); - return null; - }); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new HMSClientException("failed to drop database from hms client", e); - } - } - - @Override - public void dropTable(String dbName, String tblName) { - try (ThriftHMSClient client = getClient()) { - try { - ugiDoAs(() -> { - client.client.dropTable(dbName, tblName); - return null; - }); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new HMSClientException("failed to drop database from hms client", e); - } - } - - @Override - public void truncateTable(String dbName, String tblName, List partitions) { - try (ThriftHMSClient client = getClient()) { - try { - ugiDoAs(() -> { - client.client.truncateTable(dbName, tblName, partitions); - return null; - }); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new HMSClientException("failed to truncate table %s in db %s.", e, tblName, dbName); - } - } - - @Override - public boolean tableExists(String dbName, String tblName) { - try (ThriftHMSClient client = getClient()) { - try { - return ugiDoAs(() -> client.client.tableExists(dbName, tblName)); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new HMSClientException("failed to check if table %s in db %s exists", e, tblName, dbName); - } - } - - @Override - public List listPartitionNames(String dbName, String tblName) { - return listPartitionNames(dbName, tblName, MAX_LIST_PARTITION_NUM); - } - - public List listPartitions(String dbName, String tblName) { - try (ThriftHMSClient client = getClient()) { - try { - return ugiDoAs(() -> client.client.listPartitions(dbName, tblName, MAX_LIST_PARTITION_NUM)); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new HMSClientException("failed to list partitions in table '%s.%s'.", e, dbName, tblName); - } - } - - @Override - public List listPartitionNames(String dbName, String tblName, long maxListPartitionNum) { - // list all parts when the limit is greater than the short maximum - short limited = maxListPartitionNum <= Short.MAX_VALUE ? (short) maxListPartitionNum : MAX_LIST_PARTITION_NUM; - try (ThriftHMSClient client = getClient()) { - try { - return ugiDoAs(() -> client.client.listPartitionNames(dbName, tblName, limited)); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new HMSClientException("failed to list partition names for table %s in db %s", e, tblName, dbName); - } - } - - @Override - public Partition getPartition(String dbName, String tblName, List partitionValues) { - try (ThriftHMSClient client = getClient()) { - try { - return ugiDoAs(() -> client.client.getPartition(dbName, tblName, partitionValues)); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - // Avoid printing too much log - String partitionValuesMsg; - if (partitionValues.size() <= 3) { - partitionValuesMsg = partitionValues.toString(); - } else { - partitionValuesMsg = partitionValues.subList(0, 3) + "... total: " + partitionValues.size(); - } - throw new HMSClientException("failed to get partition for table %s in db %s with value [%s]", e, tblName, - dbName, partitionValuesMsg); - } - } - - @Override - public List getPartitions(String dbName, String tblName, List partitionNames) { - try (ThriftHMSClient client = getClient()) { - try { - return ugiDoAs(() -> client.client.getPartitionsByNames(dbName, tblName, partitionNames)); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - // Avoid printing too much log - String partitionNamesMsg; - if (partitionNames.size() <= 3) { - partitionNamesMsg = partitionNames.toString(); - } else { - partitionNamesMsg = partitionNames.subList(0, 3) + "... total: " + partitionNames.size(); - } - throw new HMSClientException("failed to get partitions for table %s in db %s with value [%s]", e, tblName, - dbName, partitionNamesMsg); - } - } - - @Override - public Database getDatabase(String dbName) { - try (ThriftHMSClient client = getClient()) { - try { - return ugiDoAs(() -> client.client.getDatabase(dbName)); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new HMSClientException("failed to get database %s from hms client", e, dbName); - } - } - - public Map getDefaultColumnValues(String dbName, String tblName) { - Map res = new HashMap<>(); - try (ThriftHMSClient client = getClient()) { - try { - DefaultConstraintsRequest req = new DefaultConstraintsRequest(); - req.setDb_name(dbName); - req.setTbl_name(tblName); - List dvcs = ugiDoAs(() -> { - try { - return client.client.getDefaultConstraints(req); - } catch (TApplicationException e) { - if (e.getMessage().contains("Invalid method name: 'get_default_constraints'")) { - // the getDefaultConstraints method only supported on hive3 - return ImmutableList.of(); - } - throw e; - } - }); - for (SQLDefaultConstraint dvc : dvcs) { - res.put(dvc.getColumn_name().toLowerCase(Locale.ROOT), dvc.getDefault_value()); - } - return res; - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new HMSClientException("failed to get table %s in db %s from hms client", e, tblName, dbName); - } - } - - @Override - public Table getTable(String dbName, String tblName) { - try (ThriftHMSClient client = getClient()) { - try { - return ugiDoAs(() -> client.client.getTable(dbName, tblName)); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new HMSClientException("failed to get table %s in db %s from hms client", e, tblName, dbName); - } - } - - @Override - public List getSchema(String dbName, String tblName) { - try (ThriftHMSClient client = getClient()) { - try { - return ugiDoAs(() -> client.client.getSchema(dbName, tblName)); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new HMSClientException("failed to get schema for table %s in db %s", e, tblName, dbName); - } - } - - @Override - public List getTableColumnStatistics(String dbName, String tblName, List columns) { - try (ThriftHMSClient client = getClient()) { - try { - return ugiDoAs(() -> client.client.getTableColumnStatistics(dbName, tblName, columns)); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Override - public Map> getPartitionColumnStatistics( - String dbName, String tblName, List partNames, List columns) { - try (ThriftHMSClient client = getClient()) { - try { - return ugiDoAs(() -> client.client.getPartitionColumnStatistics(dbName, tblName, partNames, columns)); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Override - public CurrentNotificationEventId getCurrentNotificationEventId() { - try (ThriftHMSClient client = getClient()) { - try { - return ugiDoAs(client.client::getCurrentNotificationEventId); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - LOG.warn("Failed to fetch current notification event id", e); - throw new MetastoreNotificationFetchException( - "Failed to get current notification event id. msg: " + e.getMessage()); - } - } - - @Override - public NotificationEventResponse getNextNotification(long lastEventId, - int maxEvents, - IMetaStoreClient.NotificationFilter filter) - throws MetastoreNotificationFetchException { - try (ThriftHMSClient client = getClient()) { - try { - return ugiDoAs(() -> client.client.getNextNotification(lastEventId, maxEvents, filter)); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - LOG.warn("Failed to get next notification based on last event id {}", lastEventId, e); - throw new MetastoreNotificationFetchException( - "Failed to get next notification based on last event id: " + lastEventId + ". msg: " + e - .getMessage()); - } - } - - @Override - public long openTxn(String user) { - try (ThriftHMSClient client = getClient()) { - try { - return ugiDoAs(() -> client.client.openTxn(user)); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new RuntimeException("failed to open transaction", e); - } - } - - @Override - public void commitTxn(long txnId) { - try (ThriftHMSClient client = getClient()) { - try { - ugiDoAs(() -> { - client.client.commitTxn(txnId); - return null; - }); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new RuntimeException("failed to commit transaction " + txnId, e); - } - } - - @Override - public void acquireSharedLock(String queryId, long txnId, String user, TableNameInfo tblName, - List partitionNames, long timeoutMs) { - LockRequestBuilder request = new LockRequestBuilder(queryId).setTransactionId(txnId).setUser(user); - List lockComponents = createLockComponentsForRead(tblName, partitionNames); - for (LockComponent component : lockComponents) { - request.addLockComponent(component); - } - try (ThriftHMSClient client = getClient()) { - LockResponse response; - try { - response = ugiDoAs(() -> client.client.lock(request.build())); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - long start = System.currentTimeMillis(); - while (response.getState() == LockState.WAITING) { - long lockId = response.getLockid(); - if (System.currentTimeMillis() - start > timeoutMs) { - throw new RuntimeException( - "acquire lock timeout for txn " + txnId + " of query " + queryId + ", timeout(ms): " - + timeoutMs); - } - response = checkLock(client, lockId); - } - - if (response.getState() != LockState.ACQUIRED) { - throw new RuntimeException("failed to acquire lock, lock in state " + response.getState()); - } - } catch (Exception e) { - throw new RuntimeException("failed to commit transaction " + txnId, e); - } - } - - @Override - public Map getValidWriteIds(String fullTableName, long currentTransactionId) { - Map conf = new HashMap<>(); - try (ThriftHMSClient client = getClient()) { - try { - return ugiDoAs(() -> { - // Pass currentTxn as 0L to get the recent snapshot of valid transactions in Hive - // Do not pass currentTransactionId instead as - // it will break Hive's listing of delta directories if major compaction - // deletes delta directories for valid transactions that existed at the time transaction is opened - ValidTxnList validTransactions = client.client.getValidTxns(); - List tableValidWriteIdsList = client.client.getValidWriteIds( - Collections.singletonList(fullTableName), validTransactions.toString()); - if (tableValidWriteIdsList.size() != 1) { - throw new Exception("tableValidWriteIdsList's size should be 1"); - } - ValidTxnWriteIdList validTxnWriteIdList = TxnUtils.createValidTxnWriteIdList(currentTransactionId, - tableValidWriteIdsList); - ValidWriteIdList writeIdList = validTxnWriteIdList.getTableValidWriteIdList(fullTableName); - - conf.put(AcidUtil.VALID_TXNS_KEY, validTransactions.writeToString()); - conf.put(AcidUtil.VALID_WRITEIDS_KEY, writeIdList.writeToString()); - return conf; - }); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - // Ignore this exception when the version of hive is not compatible with these apis. - // Currently, the workaround is using a max watermark. - LOG.warn("failed to get valid write ids for {}, transaction {}", fullTableName, currentTransactionId, e); - - ValidTxnList validTransactions = new ValidReadTxnList( - new long[0], new BitSet(), Long.MAX_VALUE, Long.MAX_VALUE); - ValidWriteIdList writeIdList = new ValidReaderWriteIdList( - fullTableName, new long[0], new BitSet(), Long.MAX_VALUE); - conf.put(AcidUtil.VALID_TXNS_KEY, validTransactions.writeToString()); - conf.put(AcidUtil.VALID_WRITEIDS_KEY, writeIdList.writeToString()); - return conf; - } - } - - private LockResponse checkLock(ThriftHMSClient client, long lockId) { - try { - return ugiDoAs(() -> client.client.checkLock(lockId)); - } catch (Exception e) { - client.setThrowable(e); - throw new RuntimeException("failed to check lock " + lockId, e); - } - } - - private static List createLockComponentsForRead(TableNameInfo tblName, List partitionNames) { - List components = Lists.newArrayListWithCapacity( - partitionNames.isEmpty() ? 1 : partitionNames.size()); - if (partitionNames.isEmpty()) { - components.add(createLockComponentForRead(tblName, Optional.empty())); - } else { - for (String partitionName : partitionNames) { - components.add(createLockComponentForRead(tblName, Optional.of(partitionName))); - } - } - return components; - } - - private static LockComponent createLockComponentForRead(TableNameInfo tblName, Optional partitionName) { - LockComponentBuilder builder = new LockComponentBuilder(); - builder.setShared(); - builder.setOperationType(DataOperationType.SELECT); - builder.setDbName(tblName.getDb()); - builder.setTableName(tblName.getTbl()); - partitionName.ifPresent(builder::setPartitionName); - builder.setIsTransactional(true); - return builder.build(); - } - - /** - * The Doris HMS pool only manages client object lifecycle in FE: - * 1. Create clients. - * 2. Borrow and return clients. - * 3. Invalidate borrowers that have already failed. - * 4. Destroy clients when the pool is closed. - * - * The pool does not manage Hive-side socket lifetime or reconnect: - * 1. RetryingMetaStoreClient handles hive.metastore.client.socket.lifetime itself. - * 2. The pool does not interpret that config. - * 3. The pool does not probe remote socket health. - */ - private GenericObjectPoolConfig createPoolConfig(int poolSize) { - GenericObjectPoolConfig config = new GenericObjectPoolConfig(); - config.setMaxTotal(poolSize); - config.setMaxIdle(poolSize); - config.setMinIdle(0); - config.setBlockWhenExhausted(true); - config.setMaxWaitMillis(CLIENT_POOL_BORROW_TIMEOUT_MS); - config.setTestOnBorrow(false); - config.setTestOnReturn(false); - config.setTestWhileIdle(false); - config.setTimeBetweenEvictionRunsMillis(-1L); - return config; - } - - static String getMetastoreClientClassName(HiveConf hiveConf) { - String type = hiveConf.get(HMSBaseProperties.HIVE_METASTORE_TYPE); - if (HMSBaseProperties.DLF_TYPE.equalsIgnoreCase(type)) { - return ProxyMetaStoreClient.class.getName(); - } else if (HMSBaseProperties.GLUE_TYPE.equalsIgnoreCase(type)) { - return AWSCatalogMetastoreClient.class.getName(); - } else { - return HiveMetaStoreClient.class.getName(); - } - } - - private T withSystemClassLoader(PrivilegedExceptionAction action) throws Exception { - ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); - try { - Thread.currentThread().setContextClassLoader(ClassLoader.getSystemClassLoader()); - return action.run(); - } finally { - Thread.currentThread().setContextClassLoader(classLoader); - } - } - - private class ThriftHMSClientFactory extends BasePooledObjectFactory { - @Override - public ThriftHMSClient create() throws Exception { - return createClient(); - } - - @Override - public PooledObject wrap(ThriftHMSClient client) { - return new DefaultPooledObject<>(client); - } - - @Override - public boolean validateObject(PooledObject pooledObject) { - return !isClosed && pooledObject.getObject().isValid(); - } - - @Override - public void destroyObject(PooledObject pooledObject) throws Exception { - pooledObject.getObject().destroy(); - } - } - - private class ThriftHMSClient implements AutoCloseable { - private final IMetaStoreClient client; - private volatile boolean destroyed; - private volatile Throwable throwable; - - private ThriftHMSClient(IMetaStoreClient client) { - this.client = client; - } - - public void setThrowable(Throwable throwable) { - this.throwable = throwable; - } - - private boolean isValid() { - return !destroyed && throwable == null; - } - - private void destroy() { - if (destroyed) { - return; - } - destroyed = true; - client.close(); - } - - @Override - public void close() throws Exception { - if (clientPool == null) { - destroy(); - return; - } - if (isClosed) { - destroy(); - return; - } - try { - if (throwable != null) { - clientPool.invalidateObject(this); - } else { - clientPool.returnObject(this); - } - } catch (IllegalStateException e) { - destroy(); - } catch (Exception e) { - destroy(); - throw e; - } - } - } - - private ThriftHMSClient getClient() { - try { - Preconditions.checkState(!isClosed, "HMS client pool is closed"); - if (clientPool == null) { - return createClient(); - } - return clientPool.borrowObject(); - } catch (RuntimeException e) { - throw e; - } catch (Exception e) { - throw new HMSClientException("failed to borrow hms client from pool", e); - } - } - - private ThriftHMSClient createClient() throws Exception { - return withSystemClassLoader(() -> ugiDoAs( - () -> new ThriftHMSClient(metaStoreClientProvider.create(hiveConf)))); - } - - // Keep the HMS client creation behind an injectable seam so unit tests can verify - // Doris-side pool behavior without relying on Hive static construction internals. - interface MetaStoreClientProvider { - IMetaStoreClient create(HiveConf hiveConf) throws MetaException; - } - - private static class DefaultMetaStoreClientProvider implements MetaStoreClientProvider { - @Override - public IMetaStoreClient create(HiveConf hiveConf) throws MetaException { - return RetryingMetaStoreClient.getProxy(hiveConf, DUMMY_HOOK_LOADER, - getMetastoreClientClassName(hiveConf)); - } - } - - @Override - public String getCatalogLocation(String catalogName) { - try (ThriftHMSClient client = getClient()) { - try { - Catalog catalog = ugiDoAs(() -> client.client.getCatalog(catalogName)); - return catalog.getLocationUri(); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new HMSClientException("failed to get location for %s from hms client", e, catalogName); - } - } - - private T ugiDoAs(PrivilegedExceptionAction action) { - try { - return executionAuthenticator.execute(action::run); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Override - public void updateTableStatistics( - String dbName, - String tableName, - Function update) { - try (ThriftHMSClient client = getClient()) { - try { - Table originTable = ugiDoAs(() -> client.client.getTable(dbName, tableName)); - Map originParams = originTable.getParameters(); - HivePartitionStatistics updatedStats = update.apply(HiveUtil.toHivePartitionStatistics(originParams)); - - Table newTable = originTable.deepCopy(); - Map newParams = - HiveUtil.updateStatisticsParameters(originParams, updatedStats.getCommonStatistics()); - newParams.put("transient_lastDdlTime", String.valueOf(System.currentTimeMillis() / 1000)); - newTable.setParameters(newParams); - ugiDoAs(() -> { - client.client.alter_table(dbName, tableName, newTable); - return null; - }); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new RuntimeException("failed to update table statistics for " + dbName + "." + tableName, e); - } - } - - @Override - public void updatePartitionStatistics( - String dbName, - String tableName, - String partitionName, - Function update) { - try (ThriftHMSClient client = getClient()) { - try { - List partitions = ugiDoAs(() -> client.client.getPartitionsByNames( - dbName, tableName, ImmutableList.of(partitionName))); - if (partitions.size() != 1) { - throw new RuntimeException("Metastore returned multiple partitions for name: " + partitionName); - } - - Partition originPartition = partitions.get(0); - Map originParams = originPartition.getParameters(); - HivePartitionStatistics updatedStats = update.apply(HiveUtil.toHivePartitionStatistics(originParams)); - - Partition modifiedPartition = originPartition.deepCopy(); - Map newParams = - HiveUtil.updateStatisticsParameters(originParams, updatedStats.getCommonStatistics()); - newParams.put("transient_lastDdlTime", String.valueOf(System.currentTimeMillis() / 1000)); - modifiedPartition.setParameters(newParams); - ugiDoAs(() -> { - client.client.alter_partition(dbName, tableName, modifiedPartition); - return null; - }); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new RuntimeException("failed to update table statistics for " + dbName + "." + tableName, e); - } - } - - @Override - public void addPartitions(String dbName, String tableName, List partitions) { - try (ThriftHMSClient client = getClient()) { - try { - List hivePartitions = partitions.stream() - .map(HiveUtil::toMetastoreApiPartition) - .collect(Collectors.toList()); - ugiDoAs(() -> { - client.client.add_partitions(hivePartitions); - return null; - }); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new RuntimeException("failed to add partitions for " + dbName + "." + tableName, e); - } - } - - @Override - public void dropPartition(String dbName, String tableName, List partitionValues, boolean deleteData) { - try (ThriftHMSClient client = getClient()) { - try { - ugiDoAs(() -> { - client.client.dropPartition(dbName, tableName, partitionValues, deleteData); - return null; - }); - } catch (Exception e) { - client.setThrowable(e); - throw e; - } - } catch (Exception e) { - throw new RuntimeException("failed to drop partition for " + dbName + "." + tableName, e); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/AddPartitionEvent.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/AddPartitionEvent.java deleted file mode 100644 index e582c3d2662bfc..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/AddPartitionEvent.java +++ /dev/null @@ -1,128 +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.datasource.hive.event; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.DdlException; -import org.apache.doris.datasource.ExternalMetaIdMgr; -import org.apache.doris.datasource.MetaIdMappingsLog; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Lists; -import org.apache.hadoop.hive.common.FileUtils; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.NotificationEvent; -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.hadoop.hive.metastore.messaging.AddPartitionMessage; - -import java.util.ArrayList; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * MetastoreEvent for ADD_PARTITION event type - */ -public class AddPartitionEvent extends MetastorePartitionEvent { - private final Table hmsTbl; - private final List partitionNames; - - // for test - public AddPartitionEvent(long eventId, String catalogName, String dbName, - String tblName, List partitionNames) { - super(eventId, catalogName, dbName, tblName, MetastoreEventType.ADD_PARTITION); - this.partitionNames = partitionNames; - this.hmsTbl = null; - } - - private AddPartitionEvent(NotificationEvent event, - String catalogName) { - super(event, catalogName); - Preconditions.checkArgument(getEventType().equals(MetastoreEventType.ADD_PARTITION)); - Preconditions - .checkNotNull(event.getMessage(), getMsgWithEventInfo("Event message is null")); - try { - AddPartitionMessage addPartitionMessage = - MetastoreEventsProcessor.getMessageDeserializer(event.getMessageFormat()) - .getAddPartitionMessage(event.getMessage()); - hmsTbl = Preconditions.checkNotNull(addPartitionMessage.getTableObj()); - Iterable addedPartitions = addPartitionMessage.getPartitionObjs(); - partitionNames = new ArrayList<>(); - List partitionColNames = hmsTbl.getPartitionKeys().stream() - .map(FieldSchema::getName).collect(Collectors.toList()); - addedPartitions.forEach(partition -> partitionNames.add( - FileUtils.makePartName(partitionColNames, partition.getValues()))); - } catch (Exception ex) { - throw new MetastoreNotificationException(ex); - } - } - - @Override - protected boolean willChangePartitionName() { - return false; - } - - @Override - public Set getAllPartitionNames() { - return ImmutableSet.copyOf(partitionNames); - } - - public void removePartition(String partitionName) { - partitionNames.remove(partitionName); - } - - protected static List getEvents(NotificationEvent event, - String catalogName) { - return Lists.newArrayList(new AddPartitionEvent(event, catalogName)); - } - - @Override - protected void process() throws MetastoreNotificationException { - try { - logInfo("catalogName:[{}],dbName:[{}],tableName:[{}],partitionNames:[{}]", catalogName, dbName, tblName, - partitionNames.toString()); - // bail out early if there are not partitions to process - if (partitionNames.isEmpty()) { - logInfo("Partition list is empty. Ignoring this event."); - return; - } - Env.getCurrentEnv().getCatalogMgr() - .addExternalPartitions(catalogName, dbName, hmsTbl.getTableName(), partitionNames, eventTime, true); - } catch (DdlException e) { - throw new MetastoreNotificationException( - getMsgWithEventInfo("Failed to process event"), e); - } - } - - @Override - protected List transferToMetaIdMappings() { - List metaIdMappings = Lists.newArrayList(); - for (String partitionName : this.getAllPartitionNames()) { - MetaIdMappingsLog.MetaIdMapping metaIdMapping = new MetaIdMappingsLog.MetaIdMapping( - MetaIdMappingsLog.OPERATION_TYPE_ADD, - MetaIdMappingsLog.META_OBJECT_TYPE_PARTITION, - dbName, tblName, partitionName, ExternalMetaIdMgr.nextMetaId()); - metaIdMappings.add(metaIdMapping); - } - return ImmutableList.copyOf(metaIdMappings); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/AlterDatabaseEvent.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/AlterDatabaseEvent.java deleted file mode 100644 index 8f2932600585e2..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/AlterDatabaseEvent.java +++ /dev/null @@ -1,122 +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.datasource.hive.event; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.DdlException; -import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.ExternalCatalog; - -import com.google.common.base.Preconditions; -import com.google.common.collect.Lists; -import org.apache.hadoop.hive.metastore.api.Database; -import org.apache.hadoop.hive.metastore.api.NotificationEvent; -import org.apache.hadoop.hive.metastore.messaging.json.JSONAlterDatabaseMessage; - -import java.security.SecureRandom; -import java.util.List; - -/** - * MetastoreEvent for ALTER_DATABASE event type - */ -public class AlterDatabaseEvent extends MetastoreEvent { - - private final Database dbBefore; - private final Database dbAfter; - - // true if this alter event was due to a rename operation - private final boolean isRename; - private final String dbNameAfter; - - // for test - public AlterDatabaseEvent(long eventId, String catalogName, String dbName, boolean isRename) { - super(eventId, catalogName, dbName, null, MetastoreEventType.ALTER_DATABASE); - this.isRename = isRename; - this.dbBefore = null; - this.dbAfter = null; - this.dbNameAfter = isRename ? (dbName + new SecureRandom().nextInt(10)) : dbName; - } - - private AlterDatabaseEvent(NotificationEvent event, - String catalogName) { - super(event, catalogName); - Preconditions.checkArgument(getEventType().equals(MetastoreEventType.ALTER_DATABASE)); - - try { - JSONAlterDatabaseMessage alterDatabaseMessage = - (JSONAlterDatabaseMessage) MetastoreEventsProcessor.getMessageDeserializer(event.getMessageFormat()) - .getAlterDatabaseMessage(event.getMessage()); - dbBefore = Preconditions.checkNotNull(alterDatabaseMessage.getDbObjBefore()); - dbAfter = Preconditions.checkNotNull(alterDatabaseMessage.getDbObjAfter()); - dbNameAfter = dbAfter.getName(); - } catch (Exception e) { - throw new MetastoreNotificationException( - getMsgWithEventInfo("Unable to parse the alter database message"), e); - } - // this is a rename event if either dbName of before and after object changed - isRename = !dbBefore.getName().equalsIgnoreCase(dbAfter.getName()); - } - - private void processRename() throws DdlException { - CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(catalogName); - if (catalog == null) { - throw new DdlException("No catalog found with name: " + catalogName); - } - if (!(catalog instanceof ExternalCatalog)) { - throw new DdlException("Only support ExternalCatalog Databases"); - } - if (catalog.getDbNullable(dbAfter.getName()) != null) { - logInfo("AlterExternalDatabase canceled, because dbAfter has exist, " - + "catalogName:[{}],dbName:[{}]", - catalogName, dbAfter.getName()); - return; - } - Env.getCurrentEnv().getCatalogMgr().unregisterExternalDatabase(dbBefore.getName(), catalogName); - Env.getCurrentEnv().getCatalogMgr().registerExternalDatabaseFromEvent(dbAfter.getName(), catalogName); - - } - - protected static List getEvents(NotificationEvent event, - String catalogName) { - return Lists.newArrayList(new AlterDatabaseEvent(event, catalogName)); - } - - public boolean isRename() { - return isRename; - } - - public String getDbNameAfter() { - return dbNameAfter; - } - - @Override - protected void process() throws MetastoreNotificationException { - try { - if (isRename) { - processRename(); - return; - } - // only can change properties,we do nothing - logInfo("catalogName:[{}],dbName:[{}]", catalogName, dbName); - } catch (Exception e) { - throw new MetastoreNotificationException( - getMsgWithEventInfo("Failed to process event"), e); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/AlterPartitionEvent.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/AlterPartitionEvent.java deleted file mode 100644 index d9898f68d982f7..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/AlterPartitionEvent.java +++ /dev/null @@ -1,155 +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.datasource.hive.event; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.DdlException; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Lists; -import org.apache.hadoop.hive.common.FileUtils; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.NotificationEvent; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.hadoop.hive.metastore.messaging.AlterPartitionMessage; - -import java.security.SecureRandom; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * MetastoreEvent for ALTER_PARTITION event type - */ -public class AlterPartitionEvent extends MetastorePartitionEvent { - private final Table hmsTbl; - private final org.apache.hadoop.hive.metastore.api.Partition partitionAfter; - private final org.apache.hadoop.hive.metastore.api.Partition partitionBefore; - private final String partitionNameBefore; - private final String partitionNameAfter; - // true if this alter event was due to a rename operation - private final boolean isRename; - - // for test - public AlterPartitionEvent(long eventId, String catalogName, String dbName, String tblName, - String partitionNameBefore, boolean isRename) { - super(eventId, catalogName, dbName, tblName, MetastoreEventType.ALTER_PARTITION); - this.partitionNameBefore = partitionNameBefore; - this.partitionNameAfter = isRename ? (partitionNameBefore + new SecureRandom().nextInt(100)) - : partitionNameBefore; - this.hmsTbl = null; - this.partitionAfter = null; - this.partitionBefore = null; - this.isRename = isRename; - } - - private AlterPartitionEvent(NotificationEvent event, - String catalogName) { - super(event, catalogName); - Preconditions.checkArgument(getEventType().equals(MetastoreEventType.ALTER_PARTITION)); - Preconditions - .checkNotNull(event.getMessage(), getMsgWithEventInfo("Event message is null")); - try { - AlterPartitionMessage alterPartitionMessage = - MetastoreEventsProcessor.getMessageDeserializer(event.getMessageFormat()) - .getAlterPartitionMessage(event.getMessage()); - hmsTbl = Preconditions.checkNotNull(alterPartitionMessage.getTableObj()); - partitionBefore = Preconditions.checkNotNull(alterPartitionMessage.getPtnObjBefore()); - partitionAfter = Preconditions.checkNotNull(alterPartitionMessage.getPtnObjAfter()); - List partitionColNames = hmsTbl.getPartitionKeys().stream() - .map(FieldSchema::getName).collect(Collectors.toList()); - partitionNameBefore = FileUtils.makePartName(partitionColNames, partitionBefore.getValues()); - partitionNameAfter = FileUtils.makePartName(partitionColNames, partitionAfter.getValues()); - isRename = !partitionNameBefore.equalsIgnoreCase(partitionNameAfter); - } catch (Exception ex) { - throw new MetastoreNotificationException(ex); - } - } - - @Override - protected boolean willChangePartitionName() { - return isRename; - } - - @Override - public Set getAllPartitionNames() { - return ImmutableSet.of(partitionNameBefore); - } - - public String getPartitionNameAfter() { - return partitionNameAfter; - } - - public boolean isRename() { - return isRename; - } - - protected static List getEvents(NotificationEvent event, - String catalogName) { - return Lists.newArrayList(new AlterPartitionEvent(event, catalogName)); - } - - @Override - protected void process() throws MetastoreNotificationException { - try { - logInfo("catalogName:[{}],dbName:[{}],tableName:[{}],partitionNameBefore:[{}],partitionNameAfter:[{}]", - catalogName, dbName, tblName, partitionNameBefore, partitionNameAfter); - if (isRename) { - Env.getCurrentEnv().getCatalogMgr() - .dropExternalPartitions(catalogName, dbName, tblName, - Lists.newArrayList(partitionNameBefore), eventTime, true); - Env.getCurrentEnv().getCatalogMgr() - .addExternalPartitions(catalogName, dbName, tblName, - Lists.newArrayList(partitionNameAfter), eventTime, true); - } else { - Env.getCurrentEnv().getRefreshManager() - .refreshPartitions(catalogName, dbName, hmsTbl.getTableName(), - Lists.newArrayList(partitionNameAfter), eventTime, true); - } - } catch (DdlException e) { - throw new MetastoreNotificationException( - getMsgWithEventInfo("Failed to process event"), e); - } - } - - @Override - protected boolean canBeBatched(MetastoreEvent that) { - if (!isSameTable(that) || !(that instanceof MetastorePartitionEvent)) { - return false; - } - - // Check if `that` event is a rename event, a rename event can not be batched - // because the process of `that` event will change the reference relation of this partition - MetastorePartitionEvent thatPartitionEvent = (MetastorePartitionEvent) that; - if (thatPartitionEvent.willChangePartitionName()) { - return false; - } - - // `that` event can be batched if this event's partitions contains all of the partitions which `that` event has - // else just remove `that` event's relevant partitions - for (String partitionName : getAllPartitionNames()) { - if (thatPartitionEvent instanceof DropPartitionEvent) { - ((DropPartitionEvent) thatPartitionEvent).removePartition(partitionName); - } - } - - return getAllPartitionNames().containsAll(thatPartitionEvent.getAllPartitionNames()); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/AlterTableEvent.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/AlterTableEvent.java deleted file mode 100644 index 43999fac8ce392..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/AlterTableEvent.java +++ /dev/null @@ -1,192 +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.datasource.hive.event; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.DdlException; - -import com.google.common.base.Preconditions; -import com.google.common.collect.Lists; -import org.apache.hadoop.hive.metastore.api.NotificationEvent; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.hadoop.hive.metastore.messaging.json.JSONAlterTableMessage; - -import java.security.SecureRandom; -import java.util.List; -import java.util.Locale; - -/** - * MetastoreEvent for ALTER_TABLE event type - */ -public class AlterTableEvent extends MetastoreTableEvent { - // the table object before alter operation - private final Table tableBefore; - // the table object after alter operation - private final Table tableAfter; - - // true if this alter event was due to a rename operation - private final boolean isRename; - private final boolean isView; - private final String tblNameAfter; - - // for test - public AlterTableEvent(long eventId, String catalogName, String dbName, - String tblName, boolean isRename, boolean isView) { - super(eventId, catalogName, dbName, tblName, MetastoreEventType.ALTER_TABLE); - this.isRename = isRename; - this.isView = isView; - this.tableBefore = null; - this.tableAfter = null; - this.tblNameAfter = isRename ? (tblName + new SecureRandom().nextInt(10)) : tblName; - } - - private AlterTableEvent(NotificationEvent event, String catalogName) { - super(event, catalogName); - Preconditions.checkArgument(MetastoreEventType.ALTER_TABLE.equals(getEventType())); - Preconditions - .checkNotNull(event.getMessage(), getMsgWithEventInfo("Event message is null")); - try { - JSONAlterTableMessage alterTableMessage = - (JSONAlterTableMessage) MetastoreEventsProcessor.getMessageDeserializer(event.getMessageFormat()) - .getAlterTableMessage(event.getMessage()); - tableAfter = Preconditions.checkNotNull(alterTableMessage.getTableObjAfter()); - tableAfter.setTableName(tableAfter.getTableName().toLowerCase(Locale.ROOT)); - tableBefore = Preconditions.checkNotNull(alterTableMessage.getTableObjBefore()); - tblNameAfter = tableAfter.getTableName(); - } catch (Exception e) { - throw new MetastoreNotificationException( - getMsgWithEventInfo("Unable to parse the alter table message"), e); - } - // this is a rename event if either dbName or tblName of before and after object changed - isRename = !tableBefore.getDbName().equalsIgnoreCase(tableAfter.getDbName()) - || !tableBefore.getTableName().equalsIgnoreCase(tableAfter.getTableName()); - isView = tableBefore.isSetViewExpandedText() || tableBefore.isSetViewOriginalText(); - } - - public static List getEvents(NotificationEvent event, - String catalogName) { - return Lists.newArrayList(new AlterTableEvent(event, catalogName)); - } - - @Override - protected boolean willCreateOrDropTable() { - return isRename || isView; - } - - @Override - protected boolean willChangeTableName() { - return isRename; - } - - private void processRecreateTable() throws DdlException { - if (!isView) { - return; - } - Env.getCurrentEnv().getCatalogMgr() - .unregisterExternalTable(tableBefore.getDbName(), tableBefore.getTableName(), catalogName, true); - Env.getCurrentEnv().getCatalogMgr() - .registerExternalTableFromEvent( - tableAfter.getDbName(), tableAfter.getTableName(), catalogName, eventTime, true); - } - - private void processRename() throws DdlException { - if (!isRename) { - return; - } - boolean hasExist = Env.getCurrentEnv().getCatalogMgr() - .externalTableExistInLocal(tableAfter.getDbName(), tableAfter.getTableName(), catalogName); - if (hasExist) { - logInfo("AlterExternalTable canceled,because tableAfter has exist, " - + "catalogName:[{}],dbName:[{}],tableName:[{}]", - catalogName, dbName, tableAfter.getTableName()); - return; - } - Env.getCurrentEnv().getCatalogMgr() - .unregisterExternalTable(tableBefore.getDbName(), tableBefore.getTableName(), catalogName, true); - Env.getCurrentEnv().getCatalogMgr() - .registerExternalTableFromEvent( - tableAfter.getDbName(), tableAfter.getTableName(), catalogName, eventTime, true); - - } - - public boolean isRename() { - return isRename; - } - - public boolean isView() { - return isView; - } - - public String getTblNameAfter() { - return tblNameAfter; - } - - /** - * If the ALTER_TABLE event is due a table rename, this method removes the old table - * and creates a new table with the new name. Else, we just refresh table - */ - @Override - protected void process() throws MetastoreNotificationException { - try { - logInfo("catalogName:[{}],dbName:[{}],tableBefore:[{}],tableAfter:[{}]", catalogName, dbName, - tableBefore.getTableName(), tableAfter.getTableName()); - if (isRename) { - processRename(); - return; - } - if (isView) { - // if this table is a view, `viewExpandedText/viewOriginalText` of this table may be changed, - // so we need to recreate the table to make sure `remoteTable` will be rebuild - processRecreateTable(); - return; - } - //The scope of refresh can be narrowed in the future - Env.getCurrentEnv().getRefreshManager() - .refreshExternalTableFromEvent(catalogName, tableBefore.getDbName(), tableBefore.getTableName(), - eventTime); - } catch (Exception e) { - throw new MetastoreNotificationException( - getMsgWithEventInfo("Failed to process event"), e); - } - } - - @Override - protected boolean canBeBatched(MetastoreEvent that) { - if (!isSameTable(that)) { - return false; - } - - // First check if `that` event is a rename event, a rename event can not be batched - // because the process of `that` event will change the reference relation of this table - // `that` event must be a MetastoreTableEvent event otherwise `isSameTable` will return false - MetastoreTableEvent thatTblEvent = (MetastoreTableEvent) that; - if (thatTblEvent.willChangeTableName()) { - return false; - } - - // Then check if the process of this event will create or drop this table, - // if true then `that` event can be batched - if (willCreateOrDropTable()) { - return true; - } - - // Last, check if the process of `that` event will create or drop this table - // if false then `that` event can be batched - return !thatTblEvent.willCreateOrDropTable(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/CreateDatabaseEvent.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/CreateDatabaseEvent.java deleted file mode 100644 index 2d81377f4b6749..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/CreateDatabaseEvent.java +++ /dev/null @@ -1,73 +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.datasource.hive.event; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.DdlException; -import org.apache.doris.datasource.ExternalMetaIdMgr; -import org.apache.doris.datasource.MetaIdMappingsLog; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Lists; -import org.apache.hadoop.hive.metastore.api.NotificationEvent; - -import java.util.List; - -/** - * MetastoreEvent for CREATE_DATABASE event type - */ -public class CreateDatabaseEvent extends MetastoreEvent { - - // for test - public CreateDatabaseEvent(long eventId, String catalogName, String dbName) { - super(eventId, catalogName, dbName, null, MetastoreEventType.CREATE_DATABASE); - } - - private CreateDatabaseEvent(NotificationEvent event, - String catalogName) { - super(event, catalogName); - Preconditions.checkArgument(getEventType().equals(MetastoreEventType.CREATE_DATABASE)); - } - - protected static List getEvents(NotificationEvent event, - String catalogName) { - return Lists.newArrayList(new CreateDatabaseEvent(event, catalogName)); - } - - @Override - protected void process() throws MetastoreNotificationException { - try { - logInfo("catalogName:[{}],dbName:[{}]", catalogName, dbName); - Env.getCurrentEnv().getCatalogMgr().registerExternalDatabaseFromEvent(dbName, catalogName); - } catch (DdlException e) { - throw new MetastoreNotificationException( - getMsgWithEventInfo("Failed to process event"), e); - } - } - - @Override - protected List transferToMetaIdMappings() { - MetaIdMappingsLog.MetaIdMapping metaIdMapping = new MetaIdMappingsLog.MetaIdMapping( - MetaIdMappingsLog.OPERATION_TYPE_ADD, - MetaIdMappingsLog.META_OBJECT_TYPE_DATABASE, - dbName, ExternalMetaIdMgr.nextMetaId()); - return ImmutableList.of(metaIdMapping); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/CreateTableEvent.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/CreateTableEvent.java deleted file mode 100644 index 8a22a479ad7e84..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/CreateTableEvent.java +++ /dev/null @@ -1,98 +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.datasource.hive.event; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.DdlException; -import org.apache.doris.datasource.ExternalMetaIdMgr; -import org.apache.doris.datasource.MetaIdMappingsLog; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Lists; -import org.apache.hadoop.hive.metastore.api.NotificationEvent; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.hadoop.hive.metastore.messaging.CreateTableMessage; - -import java.util.List; -import java.util.Locale; - -/** - * MetastoreEvent for CREATE_TABLE event type - */ -public class CreateTableEvent extends MetastoreTableEvent { - private final Table hmsTbl; - - // for test - public CreateTableEvent(long eventId, String catalogName, String dbName, String tblName) { - super(eventId, catalogName, dbName, tblName, MetastoreEventType.CREATE_TABLE); - this.hmsTbl = null; - } - - private CreateTableEvent(NotificationEvent event, String catalogName) throws MetastoreNotificationException { - super(event, catalogName); - Preconditions.checkArgument(MetastoreEventType.CREATE_TABLE.equals(getEventType())); - Preconditions - .checkNotNull(event.getMessage(), getMsgWithEventInfo("Event message is null")); - try { - CreateTableMessage createTableMessage = - MetastoreEventsProcessor.getMessageDeserializer(event.getMessageFormat()) - .getCreateTableMessage(event.getMessage()); - hmsTbl = Preconditions.checkNotNull(createTableMessage.getTableObj()); - hmsTbl.setTableName(hmsTbl.getTableName().toLowerCase(Locale.ROOT)); - } catch (Exception e) { - throw new MetastoreNotificationException( - getMsgWithEventInfo("Unable to deserialize the event message"), e); - } - } - - public static List getEvents(NotificationEvent event, String catalogName) { - return Lists.newArrayList(new CreateTableEvent(event, catalogName)); - } - - @Override - protected boolean willCreateOrDropTable() { - return true; - } - - @Override - protected boolean willChangeTableName() { - return false; - } - - @Override - protected void process() throws MetastoreNotificationException { - try { - logInfo("catalogName:[{}],dbName:[{}],tableName:[{}]", catalogName, dbName, tblName); - Env.getCurrentEnv().getCatalogMgr() - .registerExternalTableFromEvent(dbName, hmsTbl.getTableName(), catalogName, eventTime, true); - } catch (DdlException e) { - throw new MetastoreNotificationException( - getMsgWithEventInfo("Failed to process event"), e); - } - } - - @Override - protected List transferToMetaIdMappings() { - MetaIdMappingsLog.MetaIdMapping metaIdMapping = new MetaIdMappingsLog.MetaIdMapping( - MetaIdMappingsLog.OPERATION_TYPE_ADD, - MetaIdMappingsLog.META_OBJECT_TYPE_TABLE, - dbName, tblName, ExternalMetaIdMgr.nextMetaId()); - return ImmutableList.of(metaIdMapping); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/DropDatabaseEvent.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/DropDatabaseEvent.java deleted file mode 100644 index 6ab089232b98c1..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/DropDatabaseEvent.java +++ /dev/null @@ -1,73 +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.datasource.hive.event; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.DdlException; -import org.apache.doris.datasource.MetaIdMappingsLog; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Lists; -import org.apache.hadoop.hive.metastore.api.NotificationEvent; - -import java.util.List; - -/** - * MetastoreEvent for DROP_DATABASE event type - */ -public class DropDatabaseEvent extends MetastoreEvent { - - // for test - public DropDatabaseEvent(long eventId, String catalogName, String dbName) { - super(eventId, catalogName, dbName, null, MetastoreEventType.DROP_DATABASE); - } - - private DropDatabaseEvent(NotificationEvent event, - String catalogName) { - super(event, catalogName); - Preconditions.checkArgument(getEventType().equals(MetastoreEventType.DROP_DATABASE)); - } - - protected static List getEvents(NotificationEvent event, - String catalogName) { - return Lists.newArrayList(new DropDatabaseEvent(event, catalogName)); - } - - @Override - protected void process() throws MetastoreNotificationException { - try { - logInfo("catalogName:[{}],dbName:[{}]", catalogName, dbName); - Env.getCurrentEnv().getCatalogMgr() - .unregisterExternalDatabase(dbName, catalogName); - } catch (DdlException e) { - throw new MetastoreNotificationException( - getMsgWithEventInfo("Failed to process event"), e); - } - } - - @Override - protected List transferToMetaIdMappings() { - MetaIdMappingsLog.MetaIdMapping metaIdMapping = new MetaIdMappingsLog.MetaIdMapping( - MetaIdMappingsLog.OPERATION_TYPE_DELETE, - MetaIdMappingsLog.META_OBJECT_TYPE_DATABASE, - dbName); - return ImmutableList.of(metaIdMapping); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/DropPartitionEvent.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/DropPartitionEvent.java deleted file mode 100644 index 737ad8f28b9051..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/DropPartitionEvent.java +++ /dev/null @@ -1,154 +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.datasource.hive.event; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.DdlException; -import org.apache.doris.datasource.MetaIdMappingsLog; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Lists; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.NotificationEvent; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.hadoop.hive.metastore.messaging.DropPartitionMessage; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * MetastoreEvent for DROP_PARTITION event type - */ -public class DropPartitionEvent extends MetastorePartitionEvent { - private final Table hmsTbl; - private final List partitionNames; - - // for test - public DropPartitionEvent(long eventId, String catalogName, String dbName, - String tblName, List partitionNames) { - super(eventId, catalogName, dbName, tblName, MetastoreEventType.DROP_PARTITION); - this.partitionNames = partitionNames; - this.hmsTbl = null; - } - - private DropPartitionEvent(NotificationEvent event, - String catalogName) { - super(event, catalogName); - Preconditions.checkArgument(getEventType().equals(MetastoreEventType.DROP_PARTITION)); - Preconditions - .checkNotNull(event.getMessage(), getMsgWithEventInfo("Event message is null")); - try { - DropPartitionMessage dropPartitionMessage = - MetastoreEventsProcessor.getMessageDeserializer(event.getMessageFormat()) - .getDropPartitionMessage(event.getMessage()); - hmsTbl = Preconditions.checkNotNull(dropPartitionMessage.getTableObj()); - List> droppedPartitions = dropPartitionMessage.getPartitions(); - partitionNames = new ArrayList<>(); - List partitionColNames = hmsTbl.getPartitionKeys().stream() - .map(FieldSchema::getName).collect(Collectors.toList()); - droppedPartitions.forEach(partition -> partitionNames.add( - getPartitionName(partition, partitionColNames))); - } catch (Exception ex) { - throw new MetastoreNotificationException(ex); - } - } - - @Override - protected boolean willChangePartitionName() { - return false; - } - - @Override - public Set getAllPartitionNames() { - return ImmutableSet.copyOf(partitionNames); - } - - public void removePartition(String partitionName) { - partitionNames.remove(partitionName); - } - - protected static List getEvents(NotificationEvent event, - String catalogName) { - return Lists.newArrayList( - new DropPartitionEvent(event, catalogName)); - } - - @Override - protected void process() throws MetastoreNotificationException { - try { - logInfo("catalogName:[{}],dbName:[{}],tableName:[{}],partitionNames:[{}]", catalogName, dbName, tblName, - partitionNames.toString()); - // bail out early if there are not partitions to process - if (partitionNames.isEmpty()) { - logInfo("Partition list is empty. Ignoring this event."); - return; - } - Env.getCurrentEnv().getCatalogMgr() - .dropExternalPartitions(catalogName, dbName, hmsTbl.getTableName(), - partitionNames, eventTime, true); - } catch (DdlException e) { - throw new MetastoreNotificationException( - getMsgWithEventInfo("Failed to process event"), e); - } - } - - @Override - protected boolean canBeBatched(MetastoreEvent that) { - if (!isSameTable(that) || !(that instanceof MetastorePartitionEvent)) { - return false; - } - - MetastorePartitionEvent thatPartitionEvent = (MetastorePartitionEvent) that; - // Check if `that` event is a rename event, a rename event can not be batched - // because the process of `that` event will change the reference relation of this partition - if (thatPartitionEvent.willChangePartitionName()) { - return false; - } - - // `that` event can be batched if this event's partitions contains all the partitions which `that` event has - // else just remove `that` event's relevant partitions - for (String partitionName : getAllPartitionNames()) { - if (thatPartitionEvent instanceof AddPartitionEvent) { - ((AddPartitionEvent) thatPartitionEvent).removePartition(partitionName); - } else if (thatPartitionEvent instanceof DropPartitionEvent) { - ((DropPartitionEvent) thatPartitionEvent).removePartition(partitionName); - } - } - - return getAllPartitionNames().containsAll(thatPartitionEvent.getAllPartitionNames()); - } - - @Override - protected List transferToMetaIdMappings() { - List metaIdMappings = Lists.newArrayList(); - for (String partitionName : this.getAllPartitionNames()) { - MetaIdMappingsLog.MetaIdMapping metaIdMapping = new MetaIdMappingsLog.MetaIdMapping( - MetaIdMappingsLog.OPERATION_TYPE_DELETE, - MetaIdMappingsLog.META_OBJECT_TYPE_DATABASE, - dbName, tblName, partitionName); - metaIdMappings.add(metaIdMapping); - } - return ImmutableList.copyOf(metaIdMappings); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/DropTableEvent.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/DropTableEvent.java deleted file mode 100644 index dd67d6605274f4..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/DropTableEvent.java +++ /dev/null @@ -1,113 +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.datasource.hive.event; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.DdlException; -import org.apache.doris.datasource.MetaIdMappingsLog; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Lists; -import org.apache.hadoop.hive.metastore.api.NotificationEvent; -import org.apache.hadoop.hive.metastore.messaging.json.JSONDropTableMessage; - -import java.util.List; - -/** - * MetastoreEvent for DROP_TABLE event type - */ -public class DropTableEvent extends MetastoreTableEvent { - private final String tableName; - - // for test - public DropTableEvent(long eventId, String catalogName, String dbName, - String tblName) { - super(eventId, catalogName, dbName, tblName, MetastoreEventType.DROP_TABLE); - this.tableName = tblName; - } - - private DropTableEvent(NotificationEvent event, - String catalogName) { - super(event, catalogName); - Preconditions.checkArgument(MetastoreEventType.DROP_TABLE.equals(getEventType())); - Preconditions - .checkNotNull(event.getMessage(), getMsgWithEventInfo("Event message is null")); - try { - JSONDropTableMessage dropTableMessage = - (JSONDropTableMessage) MetastoreEventsProcessor.getMessageDeserializer(event.getMessageFormat()) - .getDropTableMessage(event.getMessage()); - tableName = dropTableMessage.getTable(); - } catch (Exception e) { - throw new MetastoreNotificationException(e); - } - } - - public static List getEvents(NotificationEvent event, - String catalogName) { - return Lists.newArrayList(new DropTableEvent(event, catalogName)); - } - - @Override - protected boolean willCreateOrDropTable() { - return true; - } - - @Override - protected boolean willChangeTableName() { - return false; - } - - @Override - protected void process() throws MetastoreNotificationException { - try { - logInfo("catalogName:[{}],dbName:[{}],tableName:[{}]", catalogName, dbName, tableName); - Env.getCurrentEnv().getCatalogMgr().unregisterExternalTable(dbName, tableName, catalogName, true); - } catch (DdlException e) { - throw new MetastoreNotificationException( - getMsgWithEventInfo("Failed to process event"), e); - } - } - - @Override - protected boolean canBeBatched(MetastoreEvent that) { - if (!isSameTable(that)) { - return false; - } - - /* - * Check if `that` event is a rename event, a rename event can not be batched - * because the process of `that` event will change the reference relation of this table, - * otherwise it can be batched because this event is a drop-table event - * and the process of this event will drop the whole table, - * and `that` event must be a MetastoreTableEvent event otherwise `isSameTable` will return false - */ - MetastoreTableEvent thatTblEvent = (MetastoreTableEvent) that; - return !thatTblEvent.willChangeTableName(); - } - - @Override - protected List transferToMetaIdMappings() { - MetaIdMappingsLog.MetaIdMapping metaIdMapping = new MetaIdMappingsLog.MetaIdMapping( - MetaIdMappingsLog.OPERATION_TYPE_DELETE, - MetaIdMappingsLog.META_OBJECT_TYPE_TABLE, - dbName, tblName); - return ImmutableList.of(metaIdMapping); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/EventFactory.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/EventFactory.java deleted file mode 100644 index 333687e2ab384d..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/EventFactory.java +++ /dev/null @@ -1,32 +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.datasource.hive.event; - -import org.apache.hadoop.hive.metastore.api.NotificationEvent; - -import java.util.List; - -/** - * Factory interface to generate a {@link MetastoreEvent} from a {@link NotificationEvent} object. - */ -public interface EventFactory { - - List transferNotificationEventToMetastoreEvents(NotificationEvent hmsEvent, - String catalogName) throws MetastoreNotificationException; -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/GzipJSONMessageDeserializer.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/GzipJSONMessageDeserializer.java deleted file mode 100644 index 0606f4e44df21d..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/GzipJSONMessageDeserializer.java +++ /dev/null @@ -1,172 +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.datasource.hive.event; - -import org.apache.commons.io.IOUtils; -import org.apache.hadoop.hive.metastore.messaging.AbortTxnMessage; -import org.apache.hadoop.hive.metastore.messaging.AddForeignKeyMessage; -import org.apache.hadoop.hive.metastore.messaging.AddNotNullConstraintMessage; -import org.apache.hadoop.hive.metastore.messaging.AddPartitionMessage; -import org.apache.hadoop.hive.metastore.messaging.AddPrimaryKeyMessage; -import org.apache.hadoop.hive.metastore.messaging.AddUniqueConstraintMessage; -import org.apache.hadoop.hive.metastore.messaging.AllocWriteIdMessage; -import org.apache.hadoop.hive.metastore.messaging.AlterDatabaseMessage; -import org.apache.hadoop.hive.metastore.messaging.AlterPartitionMessage; -import org.apache.hadoop.hive.metastore.messaging.AlterTableMessage; -import org.apache.hadoop.hive.metastore.messaging.CommitTxnMessage; -import org.apache.hadoop.hive.metastore.messaging.CreateDatabaseMessage; -import org.apache.hadoop.hive.metastore.messaging.CreateFunctionMessage; -import org.apache.hadoop.hive.metastore.messaging.CreateTableMessage; -import org.apache.hadoop.hive.metastore.messaging.DropConstraintMessage; -import org.apache.hadoop.hive.metastore.messaging.DropDatabaseMessage; -import org.apache.hadoop.hive.metastore.messaging.DropFunctionMessage; -import org.apache.hadoop.hive.metastore.messaging.DropPartitionMessage; -import org.apache.hadoop.hive.metastore.messaging.DropTableMessage; -import org.apache.hadoop.hive.metastore.messaging.InsertMessage; -import org.apache.hadoop.hive.metastore.messaging.OpenTxnMessage; -import org.apache.hadoop.hive.metastore.messaging.json.JSONMessageDeserializer; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.ByteArrayInputStream; -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.zip.GZIPInputStream; - -public class GzipJSONMessageDeserializer extends JSONMessageDeserializer { - private static final Logger LOG = LoggerFactory.getLogger(GzipJSONMessageDeserializer.class.getName()); - - public GzipJSONMessageDeserializer() { - } - - private static String deCompress(String messageBody) { - try { - byte[] decodedBytes = Base64.getDecoder().decode(messageBody.getBytes(StandardCharsets.UTF_8)); - String body; - ByteArrayInputStream in = new ByteArrayInputStream(decodedBytes); - try { - GZIPInputStream is = new GZIPInputStream(in); - try { - byte[] bytes = IOUtils.toByteArray(is); - body = new String(bytes, StandardCharsets.UTF_8); - } finally { - try { - is.close(); - } catch (Throwable ignore) { - LOG.warn("close GZIPInputStream failed", ignore); - } - } - } finally { - try { - in.close(); - } catch (Throwable ignore) { - LOG.warn("close ByteArrayInputStream failed", ignore); - } - } - return body; - } catch (Exception e) { - LOG.error("cannot decode the stream", e); - throw new RuntimeException("cannot decode the stream ", e); - } - } - - public CreateDatabaseMessage getCreateDatabaseMessage(String messageBody) { - return super.getCreateDatabaseMessage(deCompress(messageBody)); - } - - public AlterDatabaseMessage getAlterDatabaseMessage(String messageBody) { - return super.getAlterDatabaseMessage(deCompress(messageBody)); - } - - public DropDatabaseMessage getDropDatabaseMessage(String messageBody) { - return super.getDropDatabaseMessage(deCompress(messageBody)); - } - - public CreateTableMessage getCreateTableMessage(String messageBody) { - return super.getCreateTableMessage(deCompress(messageBody)); - } - - public AlterTableMessage getAlterTableMessage(String messageBody) { - return super.getAlterTableMessage(deCompress(messageBody)); - } - - public DropTableMessage getDropTableMessage(String messageBody) { - return super.getDropTableMessage(deCompress(messageBody)); - } - - public AddPartitionMessage getAddPartitionMessage(String messageBody) { - return super.getAddPartitionMessage(deCompress(messageBody)); - } - - public AlterPartitionMessage getAlterPartitionMessage(String messageBody) { - return super.getAlterPartitionMessage(deCompress(messageBody)); - } - - public DropPartitionMessage getDropPartitionMessage(String messageBody) { - return super.getDropPartitionMessage(deCompress(messageBody)); - } - - public CreateFunctionMessage getCreateFunctionMessage(String messageBody) { - return super.getCreateFunctionMessage(deCompress(messageBody)); - } - - public DropFunctionMessage getDropFunctionMessage(String messageBody) { - return super.getDropFunctionMessage(deCompress(messageBody)); - } - - public InsertMessage getInsertMessage(String messageBody) { - return super.getInsertMessage(deCompress(messageBody)); - } - - public AddPrimaryKeyMessage getAddPrimaryKeyMessage(String messageBody) { - return super.getAddPrimaryKeyMessage(deCompress(messageBody)); - } - - public AddForeignKeyMessage getAddForeignKeyMessage(String messageBody) { - return super.getAddForeignKeyMessage(deCompress(messageBody)); - } - - public AddUniqueConstraintMessage getAddUniqueConstraintMessage(String messageBody) { - return super.getAddUniqueConstraintMessage(deCompress(messageBody)); - } - - public AddNotNullConstraintMessage getAddNotNullConstraintMessage(String messageBody) { - return super.getAddNotNullConstraintMessage(deCompress(messageBody)); - } - - public DropConstraintMessage getDropConstraintMessage(String messageBody) { - return super.getDropConstraintMessage(deCompress(messageBody)); - } - - public OpenTxnMessage getOpenTxnMessage(String messageBody) { - return super.getOpenTxnMessage(deCompress(messageBody)); - } - - public CommitTxnMessage getCommitTxnMessage(String messageBody) { - return super.getCommitTxnMessage(deCompress(messageBody)); - } - - public AbortTxnMessage getAbortTxnMessage(String messageBody) { - return super.getAbortTxnMessage(deCompress(messageBody)); - } - - public AllocWriteIdMessage getAllocWriteIdMessage(String messageBody) { - return super.getAllocWriteIdMessage(deCompress(messageBody)); - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/IgnoredEvent.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/IgnoredEvent.java deleted file mode 100644 index bebfc8c2384f16..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/IgnoredEvent.java +++ /dev/null @@ -1,43 +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.datasource.hive.event; - -import com.google.common.collect.Lists; -import org.apache.hadoop.hive.metastore.api.NotificationEvent; - -import java.util.List; - -/** - * An event type which is ignored. Useful for unsupported metastore event types - */ -public class IgnoredEvent extends MetastoreEvent { - private IgnoredEvent(NotificationEvent event, String catalogName) { - super(event); - } - - protected static List getEvents(NotificationEvent event, - String catalogName) { - return Lists.newArrayList(new IgnoredEvent(event, catalogName)); - } - - @Override - public void process() { - logInfo("Ignoring unknown event type " + metastoreNotificationEvent.getEventType()); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/InsertEvent.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/InsertEvent.java deleted file mode 100644 index 4b4abd0264d53d..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/InsertEvent.java +++ /dev/null @@ -1,95 +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.datasource.hive.event; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.DdlException; - -import com.google.common.base.Preconditions; -import com.google.common.collect.Lists; -import org.apache.hadoop.hive.metastore.api.NotificationEvent; - -import java.util.List; - -/** - * MetastoreEvent for INSERT event type - */ -public class InsertEvent extends MetastoreTableEvent { - - // for test - public InsertEvent(long eventId, String catalogName, String dbName, - String tblName) { - super(eventId, catalogName, dbName, tblName, MetastoreEventType.INSERT); - } - - private InsertEvent(NotificationEvent event, String catalogName) { - super(event, catalogName); - Preconditions.checkArgument(getEventType().equals(MetastoreEventType.INSERT)); - Preconditions - .checkNotNull(event.getMessage(), getMsgWithEventInfo("Event message is null")); - } - - protected static List getEvents(NotificationEvent event, String catalogName) { - return Lists.newArrayList(new InsertEvent(event, catalogName)); - } - - @Override - protected boolean willCreateOrDropTable() { - return false; - } - - @Override - protected boolean willChangeTableName() { - return false; - } - - @Override - protected void process() throws MetastoreNotificationException { - try { - logInfo("catalogName:[{}],dbName:[{}],tableName:[{}]", catalogName, dbName, tblName); - /** - * Only when we use hive client to execute a `INSERT INTO TBL SELECT * ...` or `INSERT INTO TBL ...` sql - * to a non-partitioned table then the hms will generate an insert event, and there is not - * any partition event occurs, but the file cache may has been changed, so we need handle this. - * Currently {@link org.apache.doris.catalog.RefreshManager#refreshTable()} do not invalidate - * the file cache of this table, - * but this PR has fixed it. - */ - Env.getCurrentEnv().getRefreshManager().refreshExternalTableFromEvent(catalogName, dbName, tblName, - eventTime); - } catch (DdlException e) { - throw new MetastoreNotificationException( - getMsgWithEventInfo("Failed to process event"), e); - } - } - - @Override - protected boolean canBeBatched(MetastoreEvent that) { - if (!isSameTable(that)) { - return false; - } - - /** - * Because the cache of this table will be cleared when handling `InsertEvent`, - * so `that` event can be batched if `that` event will not create or drop this table, - * and `that` event must be a MetastoreTableEvent event otherwise `isSameTable` will return false - */ - return !((MetastoreTableEvent) that).willCreateOrDropTable(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreEvent.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreEvent.java deleted file mode 100644 index b0ec23b0661021..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreEvent.java +++ /dev/null @@ -1,183 +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.datasource.hive.event; - -import org.apache.doris.datasource.MetaIdMappingsLog; - -import com.google.common.collect.ImmutableList; -import org.apache.commons.lang3.StringUtils; -import org.apache.hadoop.hive.metastore.api.NotificationEvent; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.List; -import java.util.Locale; -import java.util.Map; - -/** - * The wrapper parent class of the NotificationEvent class - */ -public abstract class MetastoreEvent { - private static final Logger LOG = LogManager.getLogger(MetastoreEvent.class); - - protected final String catalogName; - - protected final String dbName; - - protected final String tblName; - - protected final long eventId; - - protected final long eventTime; - - protected final MetastoreEventType eventType; - - protected final NotificationEvent event; - protected final NotificationEvent metastoreNotificationEvent; - - // for test - protected MetastoreEvent(long eventId, String catalogName, String dbName, - String tblName, MetastoreEventType eventType) { - this.eventId = eventId; - this.eventTime = -1L; - this.catalogName = catalogName; - this.dbName = dbName; - this.tblName = tblName; - this.eventType = eventType; - this.metastoreNotificationEvent = null; - this.event = null; - } - - // for IgnoredEvent - protected MetastoreEvent(NotificationEvent event) { - this.event = event; - this.metastoreNotificationEvent = event; - this.eventId = -1; - this.eventTime = -1L; - this.catalogName = null; - this.dbName = null; - this.tblName = null; - this.eventType = null; - } - - protected MetastoreEvent(NotificationEvent event, String catalogName) { - this.event = event; - // Some events that we don't care about, dbName may be empty - String eventDbName = event.getDbName(); - this.dbName = StringUtils.isEmpty(eventDbName) ? eventDbName : eventDbName.toLowerCase(Locale.ROOT); - this.tblName = event.getTableName(); - this.eventId = event.getEventId(); - this.eventTime = event.getEventTime() * 1000L; - this.eventType = MetastoreEventType.from(event.getEventType()); - this.metastoreNotificationEvent = event; - this.catalogName = catalogName; - } - - /** - * Can batch processing be performed to improve processing performance - * - * @param event - * @return - */ - protected boolean canBeBatched(MetastoreEvent event) { - return false; - } - - protected abstract void process() throws MetastoreNotificationException; - - protected String getMsgWithEventInfo(String formatSuffix, Object... args) { - String format = "EventId: %d EventType: %s " + formatSuffix; - Object[] argsWithEventInfo = getArgsWithEventInfo(args); - return String.format(format, argsWithEventInfo); - } - - protected void logInfo(String formatSuffix, Object... args) { - if (!LOG.isInfoEnabled()) { - return; - } - String format = "EventId: {} EventType: {} " + formatSuffix; - Object[] argsWithEventInfo = getArgsWithEventInfo(args); - LOG.info(format, argsWithEventInfo); - } - - /** - * Add event information to the parameters - */ - private Object[] getArgsWithEventInfo(Object[] args) { - Object[] res = new Object[args.length + 2]; - res[0] = eventId; - res[1] = eventType; - int i = 2; - for (Object arg : args) { - res[i] = arg; - i++; - } - return res; - } - - protected String getPartitionName(Map part, List partitionColNames) { - if (part.size() == 0) { - return ""; - } - if (partitionColNames.size() != part.size()) { - return ""; - } - StringBuilder name = new StringBuilder(); - int i = 0; - for (String colName : partitionColNames) { - if (i++ > 0) { - name.append("/"); - } - name.append(colName); - name.append("="); - name.append(part.get(colName)); - } - return name.toString(); - } - - /** - * Create a MetaIdMapping list from the event if the event is a create/add/drop event - */ - protected List transferToMetaIdMappings() { - return ImmutableList.of(); - } - - public String getDbName() { - return dbName; - } - - public String getTblName() { - return tblName; - } - - public long getEventId() { - return eventId; - } - - public MetastoreEventType getEventType() { - return eventType; - } - - @Override - public String toString() { - return "MetastoreEvent{" - + "eventId=" + eventId - + ", eventType=" + eventType - + '}'; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreEventFactory.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreEventFactory.java deleted file mode 100644 index 7f697cf9738e13..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreEventFactory.java +++ /dev/null @@ -1,167 +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.datasource.hive.event; - -import org.apache.doris.catalog.Env; -import org.apache.doris.datasource.MetaIdMappingsLog; -import org.apache.doris.datasource.hive.HMSExternalCatalog; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import org.apache.hadoop.hive.metastore.api.NotificationEvent; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.stream.Collectors; - -/** - * Factory class to create various MetastoreEvents. - */ -public class MetastoreEventFactory implements EventFactory { - private static final Logger LOG = LogManager.getLogger(MetastoreEventFactory.class); - - @Override - public List transferNotificationEventToMetastoreEvents(NotificationEvent event, - String catalogName) { - Preconditions.checkNotNull(event.getEventType()); - MetastoreEventType metastoreEventType = MetastoreEventType.from(event.getEventType()); - if (LOG.isDebugEnabled()) { - LOG.debug("catalogName = {}, Event = {}", catalogName, event.toString()); - } - switch (metastoreEventType) { - case CREATE_TABLE: - return CreateTableEvent.getEvents(event, catalogName); - case DROP_TABLE: - return DropTableEvent.getEvents(event, catalogName); - case ALTER_TABLE: - return AlterTableEvent.getEvents(event, catalogName); - case CREATE_DATABASE: - return CreateDatabaseEvent.getEvents(event, catalogName); - case DROP_DATABASE: - return DropDatabaseEvent.getEvents(event, catalogName); - case ALTER_DATABASE: - return AlterDatabaseEvent.getEvents(event, catalogName); - case ADD_PARTITION: - return AddPartitionEvent.getEvents(event, catalogName); - case DROP_PARTITION: - return DropPartitionEvent.getEvents(event, catalogName); - case ALTER_PARTITION: - return AlterPartitionEvent.getEvents(event, catalogName); - case INSERT: - return InsertEvent.getEvents(event, catalogName); - default: - // ignore all the unknown events by creating a IgnoredEvent - return IgnoredEvent.getEvents(event, catalogName); - } - } - - List getMetastoreEvents(List events, HMSExternalCatalog hmsExternalCatalog) { - List metastoreEvents = Lists.newArrayList(); - String catalogName = hmsExternalCatalog.getName(); - for (NotificationEvent event : events) { - metastoreEvents.addAll(transferNotificationEventToMetastoreEvents(event, catalogName)); - } - List mergedEvents = mergeEvents(catalogName, metastoreEvents); - if (Env.getCurrentEnv().isMaster()) { - logMetaIdMappings(hmsExternalCatalog.getId(), events.get(events.size() - 1).getEventId(), mergedEvents); - } - return mergedEvents; - } - - private void logMetaIdMappings(long catalogId, long lastSyncedEventId, List mergedEvents) { - MetaIdMappingsLog log = new MetaIdMappingsLog(); - log.setCatalogId(catalogId); - log.setFromHmsEvent(true); - log.setLastSyncedEventId(lastSyncedEventId); - for (MetastoreEvent event : mergedEvents) { - log.addMetaIdMappings(event.transferToMetaIdMappings()); - } - Env.getCurrentEnv().getExternalMetaIdMgr().replayMetaIdMappingsLog(log); - Env.getCurrentEnv().getEditLog().logMetaIdMappingsLog(log); - } - - /** - * Merge events to reduce the cost time on event processing, currently mainly handles MetastoreTableEvent - * because merge MetastoreTableEvent is simple and cost-effective. - * For example, consider there are some events as following: - *
    -     *    event1: alter table db1.t1 add partition p1;
    -     *    event2: alter table db1.t1 drop partition p2;
    -     *    event3: alter table db1.t2 add partition p3;
    -     *    event4: alter table db2.t3 rename to t4;
    -     *    event5: drop table db1.t1;
    -     * 
    - * Only `event3 event4 event5` will be reserved and other events will be skipped. - * */ - public List mergeEvents(String catalogName, List events) { - List eventsCopy = Lists.newArrayList(events); - Map> indexMap = Maps.newLinkedHashMap(); - for (int i = 0; i < events.size(); i++) { - MetastoreEvent event = events.get(i); - - // if the event is a rename db event, just clear indexMap - // to make sure the table references of these events in indexMap will not change - if (event instanceof AlterDatabaseEvent && ((AlterDatabaseEvent) event).isRename()) { - indexMap.clear(); - continue; - } - - // Only check MetastoreTableEvent - if (!(event instanceof MetastoreTableEvent)) { - continue; - } - - // Divide events into multi groups to reduce check count - MetastoreTableEvent.TableKey groupKey = ((MetastoreTableEvent) event).getTableKey(); - if (!indexMap.containsKey(groupKey)) { - List indexList = Lists.newLinkedList(); - indexList.add(i); - indexMap.put(groupKey, indexList); - continue; - } - - List indexList = indexMap.get(groupKey); - for (int j = 0; j < indexList.size(); j++) { - int candidateIndex = indexList.get(j); - if (candidateIndex == -1) { - continue; - } - if (event.canBeBatched(events.get(candidateIndex))) { - eventsCopy.set(candidateIndex, null); - indexList.set(j, -1); - } - } - indexList = indexList.stream().filter(index -> index != -1) - .collect(Collectors.toList()); - indexList.add(i); - indexMap.put(groupKey, indexList); - } - - List filteredEvents = eventsCopy.stream().filter(Objects::nonNull) - .collect(Collectors.toList()); - LOG.info("Event size on catalog [{}] before merge is [{}], after merge is [{}]", - catalogName, events.size(), filteredEvents.size()); - return ImmutableList.copyOf(filteredEvents); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreEventType.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreEventType.java deleted file mode 100644 index 31dce2936645fc..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreEventType.java +++ /dev/null @@ -1,68 +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.datasource.hive.event; - -/** - * Currently we only support handling some events. - */ -public enum MetastoreEventType { - CREATE_TABLE("CREATE_TABLE"), - DROP_TABLE("DROP_TABLE"), - ALTER_TABLE("ALTER_TABLE"), - CREATE_DATABASE("CREATE_DATABASE"), - DROP_DATABASE("DROP_DATABASE"), - ALTER_DATABASE("ALTER_DATABASE"), - ADD_PARTITION("ADD_PARTITION"), - ALTER_PARTITION("ALTER_PARTITION"), - ALTER_PARTITIONS("ALTER_PARTITIONS"), - DROP_PARTITION("DROP_PARTITION"), - INSERT("INSERT"), - INSERT_PARTITIONS("INSERT_PARTITIONS"), - ALLOC_WRITE_ID_EVENT("ALLOC_WRITE_ID_EVENT"), - COMMIT_TXN("COMMIT_TXN"), - ABORT_TXN("ABORT_TXN"), - OTHER("OTHER"); - - private final String eventType; - - MetastoreEventType(String msEventType) { - this.eventType = msEventType; - } - - @Override - public String toString() { - return eventType; - } - - /** - * Returns the MetastoreEventType from a given string value of event from Metastore's - * NotificationEvent.eventType. If none of the supported MetastoreEventTypes match, - * return OTHER - * - * @param eventType EventType value from the {@link org.apache.hadoop.hive.metastore.api.NotificationEvent} - */ - public static MetastoreEventType from(String eventType) { - for (MetastoreEventType metastoreEventType : values()) { - if (metastoreEventType.eventType.equalsIgnoreCase(eventType)) { - return metastoreEventType; - } - } - return OTHER; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreEventsProcessor.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreEventsProcessor.java deleted file mode 100644 index 3d3a94eb4e19cf..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreEventsProcessor.java +++ /dev/null @@ -1,357 +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.datasource.hive.event; - -import org.apache.doris.analysis.RedirectStatus; -import org.apache.doris.analysis.UserIdentity; -import org.apache.doris.catalog.Env; -import org.apache.doris.common.Config; -import org.apache.doris.common.util.MasterDaemon; -import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.CatalogLog; -import org.apache.doris.datasource.hive.HMSClientException; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.qe.MasterOpExecutor; -import org.apache.doris.qe.OriginStatement; - -import com.google.common.collect.Maps; -import org.apache.commons.lang3.StringUtils; -import org.apache.hadoop.hive.metastore.HiveMetaStoreClient; -import org.apache.hadoop.hive.metastore.api.CurrentNotificationEventId; -import org.apache.hadoop.hive.metastore.api.NoSuchObjectException; -import org.apache.hadoop.hive.metastore.api.NotificationEvent; -import org.apache.hadoop.hive.metastore.api.NotificationEventResponse; -import org.apache.hadoop.hive.metastore.messaging.MessageDeserializer; -import org.apache.hadoop.hive.metastore.messaging.json.JSONMessageDeserializer; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.Collections; -import java.util.List; -import java.util.Map; - -/** - * A metastore event is a instance of the class - * {@link NotificationEvent}. Metastore can be - * configured, to work with Listeners which are called on various DDL operations like - * create/alter/drop operations on database, table, partition etc. Each event has a unique - * incremental id and the generated events are be fetched from Metastore to get - * incremental updates to the metadata stored in Hive metastore using the the public API - * get_next_notification These events could be generated by external - * Metastore clients like Apache Hive or Apache Spark configured to talk with the same metastore. - *

    - * This class is used to poll metastore for such events at a given frequency. By observing - * such events, we can take appropriate action on the {@link org.apache.doris.datasource.hive.HiveExternalMetaCache} - * (refresh/invalidate/add/remove) so that represents the latest information - * available in metastore. We keep track of the last synced event id in each polling - * iteration so the next batch can be requested appropriately. The current batch size is - * constant and set to {@link org.apache.doris.common.Config#hms_events_batch_size_per_rpc}. - */ -public class MetastoreEventsProcessor extends MasterDaemon { - private static final Logger LOG = LogManager.getLogger(MetastoreEventsProcessor.class); - public static final String HMS_ADD_THRIFT_OBJECTS_IN_EVENTS_CONFIG_KEY = - "hive.metastore.notifications.add.thrift.objects"; - - // for deserializing from JSON strings from metastore event - private static final MessageDeserializer JSON_MESSAGE_DESERIALIZER = new JSONMessageDeserializer(); - // for deserializing from GZIP JSON strings from metastore event - // (some HDP Hive and CDH Hive versions use this format) - private static final MessageDeserializer GZIP_JSON_MESSAGE_DESERIALIZER = new GzipJSONMessageDeserializer(); - private static final String GZIP_JSON_FORMAT_PREFIX = "gzip"; - - // event factory which is used to get or create MetastoreEvents - private final MetastoreEventFactory metastoreEventFactory; - - // manager the lastSyncedEventId of hms catalogs - // use HashMap is fine because all operations are in one thread - private final Map lastSyncedEventIdMap = Maps.newHashMap(); - - // manager the masterLastSyncedEventId of hms catalogs - private final Map masterLastSyncedEventIdMap = Maps.newHashMap(); - - private boolean isRunning; - - public MetastoreEventsProcessor() { - super(MetastoreEventsProcessor.class.getName(), Config.hms_events_polling_interval_ms); - this.metastoreEventFactory = new MetastoreEventFactory(); - this.isRunning = false; - } - - @Override - protected void runAfterCatalogReady() { - if (isRunning) { - LOG.warn("Last task not finished,ignore current task."); - return; - } - isRunning = true; - try { - realRun(); - } catch (Exception ex) { - LOG.warn("Task failed", ex); - } - isRunning = false; - } - - private void realRun() { - List catalogIds = Env.getCurrentEnv().getCatalogMgr().getCatalogIds(); - for (Long catalogId : catalogIds) { - CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(catalogId); - if (catalog instanceof HMSExternalCatalog) { - HMSExternalCatalog hmsExternalCatalog = (HMSExternalCatalog) catalog; - try { - // Check if HMS incremental events synchronization is enabled. - // In the past, this value was a constant and always available. - // Now it is retrieved from HmsProperties, which requires initialization. - // In some scenarios, essential HMS parameters may be missing. - // If so, isHmsEventsIncrementalSyncEnabled() may throw Exception. - if (!hmsExternalCatalog.getHmsProperties().isHmsEventsIncrementalSyncEnabled()) { - continue; - } - } catch (RuntimeException e) { - //ignore - continue; - } - - try { - List events = getNextHMSEvents(hmsExternalCatalog); - if (!events.isEmpty()) { - LOG.info("Events size are {} on catalog [{}]", events.size(), - hmsExternalCatalog.getName()); - processEvents(events, hmsExternalCatalog); - } - } catch (MetastoreNotificationFetchException e) { - LOG.warn("Failed to fetch hms events on {}. msg: ", hmsExternalCatalog.getName(), e); - } catch (Exception ex) { - hmsExternalCatalog.onRefreshCache(true); - updateLastSyncedEventId(hmsExternalCatalog, -1); - LOG.warn("Failed to process hive metastore [{}] events .", - hmsExternalCatalog.getName(), ex); - } - } - } - } - - /** - * Fetch the next batch of NotificationEvents from metastore. The default batch size is - * {@link Config#hms_events_batch_size_per_rpc} - */ - private List getNextHMSEvents(HMSExternalCatalog hmsExternalCatalog) throws Exception { - if (LOG.isDebugEnabled()) { - LOG.debug("Start to pull events on catalog [{}]", hmsExternalCatalog.getName()); - } - NotificationEventResponse response; - if (Env.getCurrentEnv().isMaster()) { - response = getNextEventResponseForMaster(hmsExternalCatalog); - } else { - response = getNextEventResponseForSlave(hmsExternalCatalog); - } - - if (response == null || response.getEventsSize() == 0) { - return Collections.emptyList(); - } - return response.getEvents(); - } - - private void doExecute(List events, HMSExternalCatalog hmsExternalCatalog) { - for (MetastoreEvent event : events) { - try { - event.process(); - } catch (HMSClientException hmsClientException) { - if (hmsClientException.getCause() != null - && hmsClientException.getCause() instanceof NoSuchObjectException) { - LOG.warn(event.getMsgWithEventInfo("Failed to process event and skip"), hmsClientException); - } else { - updateLastSyncedEventId(hmsExternalCatalog, event.getEventId() - 1); - throw hmsClientException; - } - } catch (Exception e) { - updateLastSyncedEventId(hmsExternalCatalog, event.getEventId() - 1); - throw e; - } - } - } - - /** - * Process the given list of notification events. Useful for tests which provide a list of events - */ - private void processEvents(List events, HMSExternalCatalog hmsExternalCatalog) { - //transfer - List metastoreEvents = metastoreEventFactory.getMetastoreEvents(events, hmsExternalCatalog); - doExecute(metastoreEvents, hmsExternalCatalog); - updateLastSyncedEventId(hmsExternalCatalog, events.get(events.size() - 1).getEventId()); - } - - private NotificationEventResponse getNextEventResponseForMaster(HMSExternalCatalog hmsExternalCatalog) - throws MetastoreNotificationFetchException { - long lastSyncedEventId = getLastSyncedEventId(hmsExternalCatalog); - long currentEventId = getCurrentHmsEventId(hmsExternalCatalog); - if (lastSyncedEventId < 0) { - refreshCatalogForMaster(hmsExternalCatalog); - // invoke getCurrentEventId() and save the event id before refresh catalog to avoid missing events - // but set lastSyncedEventId to currentEventId only if there is not any problems when refreshing catalog - updateLastSyncedEventId(hmsExternalCatalog, currentEventId); - LOG.info( - "First pulling events on catalog [{}],refreshCatalog and init lastSyncedEventId," - + "lastSyncedEventId is [{}]", - hmsExternalCatalog.getName(), lastSyncedEventId); - return null; - } - - if (LOG.isDebugEnabled()) { - LOG.debug("Catalog [{}] getNextEventResponse, currentEventId is {}, lastSyncedEventId is {}", - hmsExternalCatalog.getName(), currentEventId, lastSyncedEventId); - } - if (currentEventId == lastSyncedEventId) { - LOG.info("Event id not updated when pulling events on catalog [{}]", hmsExternalCatalog.getName()); - return null; - } - - int batchSize = hmsExternalCatalog.getHmsProperties().getHmsEventsBatchSizePerRpc(); - try { - NotificationEventResponse notificationEventResponse = - hmsExternalCatalog.getClient().getNextNotification(lastSyncedEventId, batchSize, null); - LOG.info("CatalogName = {}, lastSyncedEventId = {}, currentEventId = {}," - + "batchSize = {}, getEventsSize = {}", hmsExternalCatalog.getName(), lastSyncedEventId, - currentEventId, batchSize, notificationEventResponse.getEvents().size()); - - return notificationEventResponse; - } catch (MetastoreNotificationFetchException e) { - // Need a fallback to handle this because this error state can not be recovered until restarting FE - if (StringUtils.isNotEmpty(e.getMessage()) - && e.getMessage().contains(HiveMetaStoreClient.REPL_EVENTS_MISSING_IN_METASTORE)) { - refreshCatalogForMaster(hmsExternalCatalog); - // set lastSyncedEventId to currentEventId after refresh catalog successfully - updateLastSyncedEventId(hmsExternalCatalog, currentEventId); - LOG.warn("Notification events are missing, maybe an event can not be handled " - + "or processing rate is too low, fallback to refresh the catalog"); - return null; - } - throw e; - } - } - - private NotificationEventResponse getNextEventResponseForSlave(HMSExternalCatalog hmsExternalCatalog) - throws Exception { - long lastSyncedEventId = getLastSyncedEventId(hmsExternalCatalog); - long masterLastSyncedEventId = getMasterLastSyncedEventId(hmsExternalCatalog); - // do nothing if masterLastSyncedEventId has not been synced - if (masterLastSyncedEventId == -1L) { - LOG.info("LastSyncedEventId of master has not been synced on catalog [{}]", hmsExternalCatalog.getName()); - return null; - } - // do nothing if lastSyncedEventId is equals to masterLastSyncedEventId - if (lastSyncedEventId == masterLastSyncedEventId) { - LOG.info("Event id not updated when pulling events on catalog [{}]", hmsExternalCatalog.getName()); - return null; - } - - if (lastSyncedEventId < 0) { - refreshCatalogForSlave(hmsExternalCatalog); - // Use masterLastSyncedEventId to avoid missing events - updateLastSyncedEventId(hmsExternalCatalog, masterLastSyncedEventId); - LOG.info( - "First pulling events on catalog [{}],refreshCatalog and init lastSyncedEventId," - + "lastSyncedEventId is [{}]", - hmsExternalCatalog.getName(), lastSyncedEventId); - return null; - } - - if (LOG.isDebugEnabled()) { - LOG.debug("Catalog [{}] getNextEventResponse, masterLastSyncedEventId is {}, lastSyncedEventId is {}", - hmsExternalCatalog.getName(), masterLastSyncedEventId, lastSyncedEventId); - } - - // For slave FE nodes, only fetch events which id is lower than masterLastSyncedEventId - int maxEventSize = Math.min((int) (masterLastSyncedEventId - lastSyncedEventId), - hmsExternalCatalog.getHmsProperties().getHmsEventsBatchSizePerRpc()); - try { - return hmsExternalCatalog.getClient().getNextNotification(lastSyncedEventId, maxEventSize, null); - } catch (MetastoreNotificationFetchException e) { - // Need a fallback to handle this because this error state can not be recovered until restarting FE - if (StringUtils.isNotEmpty(e.getMessage()) - && e.getMessage().contains(HiveMetaStoreClient.REPL_EVENTS_MISSING_IN_METASTORE)) { - refreshCatalogForSlave(hmsExternalCatalog); - // set masterLastSyncedEventId to lastSyncedEventId after refresh catalog successfully - updateLastSyncedEventId(hmsExternalCatalog, masterLastSyncedEventId); - LOG.warn("Notification events are missing, maybe an event can not be handled " - + "or processing rate is too low, fallback to refresh the catalog"); - return null; - } - throw e; - } - } - - private long getCurrentHmsEventId(HMSExternalCatalog hmsExternalCatalog) { - CurrentNotificationEventId currentNotificationEventId = hmsExternalCatalog.getClient() - .getCurrentNotificationEventId(); - if (currentNotificationEventId == null) { - LOG.warn("Get currentNotificationEventId is null"); - return -1L; - } - return currentNotificationEventId.getEventId(); - } - - private long getLastSyncedEventId(HMSExternalCatalog hmsExternalCatalog) { - // Returns to -1 if not exists, otherwise client.getNextNotification will throw exception - // Reference to https://github.com/apDdlache/doris/issues/18251 - return lastSyncedEventIdMap.getOrDefault(hmsExternalCatalog.getId(), -1L); - } - - private void updateLastSyncedEventId(HMSExternalCatalog hmsExternalCatalog, long eventId) { - lastSyncedEventIdMap.put(hmsExternalCatalog.getId(), eventId); - } - - private long getMasterLastSyncedEventId(HMSExternalCatalog hmsExternalCatalog) { - return masterLastSyncedEventIdMap.getOrDefault(hmsExternalCatalog.getId(), -1L); - } - - public void updateMasterLastSyncedEventId(HMSExternalCatalog hmsExternalCatalog, long eventId) { - masterLastSyncedEventIdMap.put(hmsExternalCatalog.getId(), eventId); - } - - private void refreshCatalogForMaster(HMSExternalCatalog hmsExternalCatalog) { - CatalogLog log = new CatalogLog(); - log.setCatalogId(hmsExternalCatalog.getId()); - log.setInvalidCache(true); - Env.getCurrentEnv().getRefreshManager().replayRefreshCatalog(log); - } - - private void refreshCatalogForSlave(HMSExternalCatalog hmsExternalCatalog) throws Exception { - // Transfer to master to refresh catalog - String sql = "REFRESH CATALOG " + hmsExternalCatalog.getName(); - OriginStatement originStmt = new OriginStatement(sql, 0); - ConnectContext ctx = new ConnectContext(); - ctx.setCurrentUserIdentity(UserIdentity.ROOT); - ctx.setEnv(Env.getCurrentEnv()); - MasterOpExecutor masterOpExecutor = new MasterOpExecutor(originStmt, ctx, - RedirectStatus.FORWARD_WITH_SYNC, false); - if (LOG.isDebugEnabled()) { - LOG.debug("Transfer to master to refresh catalog, stmt: {}", sql); - } - masterOpExecutor.execute(); - } - - public static MessageDeserializer getMessageDeserializer(String messageFormat) { - if (messageFormat != null && messageFormat.startsWith(GZIP_JSON_FORMAT_PREFIX)) { - return GZIP_JSON_MESSAGE_DESERIALIZER; - } - return JSON_MESSAGE_DESERIALIZER; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreNotificationException.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreNotificationException.java deleted file mode 100644 index 2bd5c4c40c904e..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreNotificationException.java +++ /dev/null @@ -1,37 +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.datasource.hive.event; - -/** - * Utility exception class to be thrown for errors during event processing - */ -public class MetastoreNotificationException extends RuntimeException { - - public MetastoreNotificationException(String msg, Throwable cause) { - super(msg, cause); - } - - public MetastoreNotificationException(String msg) { - super(msg); - } - - public MetastoreNotificationException(Exception e) { - super(e); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreNotificationFetchException.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreNotificationFetchException.java deleted file mode 100644 index 487165eeca25cf..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreNotificationFetchException.java +++ /dev/null @@ -1,37 +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.datasource.hive.event; - -/** - * Utility exception class to be thrown for errors during event processing - */ -public class MetastoreNotificationFetchException extends MetastoreNotificationException { - - public MetastoreNotificationFetchException(String msg, Throwable cause) { - super(msg, cause); - } - - public MetastoreNotificationFetchException(String msg) { - super(msg); - } - - public MetastoreNotificationFetchException(Exception e) { - super(e); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastorePartitionEvent.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastorePartitionEvent.java deleted file mode 100644 index 8a66dd1d6f954b..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastorePartitionEvent.java +++ /dev/null @@ -1,54 +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.datasource.hive.event; - -import org.apache.hadoop.hive.metastore.api.NotificationEvent; - -import java.util.Set; - -/** - * Base class for all the partition events - */ -public abstract class MetastorePartitionEvent extends MetastoreTableEvent { - - // for test - protected MetastorePartitionEvent(long eventId, String catalogName, String dbName, - String tblName, MetastoreEventType eventType) { - super(eventId, catalogName, dbName, tblName, eventType); - } - - protected MetastorePartitionEvent(NotificationEvent event, String catalogName) { - super(event, catalogName); - } - - protected boolean willCreateOrDropTable() { - return false; - } - - protected boolean willChangeTableName() { - return false; - } - - /** - * Returns if the process of this event will rename this partition. - */ - protected abstract boolean willChangePartitionName(); - - public abstract Set getAllPartitionNames(); -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreTableEvent.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreTableEvent.java deleted file mode 100644 index 4e9713a2758b3f..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreTableEvent.java +++ /dev/null @@ -1,106 +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.datasource.hive.event; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; -import org.apache.hadoop.hive.metastore.api.NotificationEvent; - -import java.util.List; -import java.util.Objects; - -/** - * Base class for all the table events - */ -public abstract class MetastoreTableEvent extends MetastoreEvent { - - // for test - protected MetastoreTableEvent(long eventId, String catalogName, String dbName, - String tblName, MetastoreEventType eventType) { - super(eventId, catalogName, dbName, tblName, eventType); - } - - protected MetastoreTableEvent(NotificationEvent event, String catalogName) { - super(event, catalogName); - Preconditions.checkNotNull(dbName, "Database name cannot be null"); - Preconditions.checkNotNull(tblName, "Table name cannot be null"); - } - - /** - * Returns a list of parameters that are set by Hive for tables/partitions that can be - * ignored to determine if the alter table/partition event is a trivial one. - */ - private static final List PARAMETERS_TO_IGNORE = - new ImmutableList.Builder() - .add("transient_lastDdlTime") - .add("numFilesErasureCoded") - .add("numFiles") - .add("comment") - .build(); - - protected boolean isSameTable(MetastoreEvent that) { - if (!(that instanceof MetastoreTableEvent)) { - return false; - } - TableKey thisKey = getTableKey(); - TableKey thatKey = ((MetastoreTableEvent) that).getTableKey(); - return Objects.equals(thisKey, thatKey); - } - - /** - * Returns if the process of this event will create or drop this table. - */ - protected abstract boolean willCreateOrDropTable(); - - /** - * Returns if the process of this event will rename this table. - */ - protected abstract boolean willChangeTableName(); - - public TableKey getTableKey() { - return new TableKey(catalogName, dbName, tblName); - } - - static class TableKey { - private final String catalogName; - private final String dbName; - private final String tblName; - - private TableKey(String catalogName, String dbName, String tblName) { - this.catalogName = catalogName; - this.dbName = dbName; - this.tblName = tblName; - } - - @Override - public boolean equals(Object that) { - if (!(that instanceof TableKey)) { - return false; - } - return Objects.equals(catalogName, ((TableKey) that).catalogName) - && Objects.equals(dbName, ((TableKey) that).dbName) - && Objects.equals(tblName, ((TableKey) that).tblName); - } - - @Override - public int hashCode() { - return Objects.hash(catalogName, dbName, tblName); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/source/HiveScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/source/HiveScanNode.java deleted file mode 100644 index cae6728c5f467f..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/source/HiveScanNode.java +++ /dev/null @@ -1,685 +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.datasource.hive.source; - -import org.apache.doris.analysis.SlotDescriptor; -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.ListPartitionItem; -import org.apache.doris.catalog.PartitionItem; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.catalog.Type; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.Config; -import org.apache.doris.common.UserException; -import org.apache.doris.common.util.DebugUtil; -import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.FileQueryScanNode; -import org.apache.doris.datasource.FileSplit; -import org.apache.doris.datasource.FileSplitter; -import org.apache.doris.datasource.TableFormatType; -import org.apache.doris.datasource.hive.AcidInfo; -import org.apache.doris.datasource.hive.AcidInfo.DeleteDeltaInfo; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HiveExternalMetaCache; -import org.apache.doris.datasource.hive.HiveExternalMetaCache.FileCacheValue; -import org.apache.doris.datasource.hive.HiveMetaStoreClientHelper; -import org.apache.doris.datasource.hive.HivePartition; -import org.apache.doris.datasource.hive.HiveProperties; -import org.apache.doris.datasource.hive.HiveTransaction; -import org.apache.doris.datasource.hive.source.HiveSplit.HiveSplitCreator; -import org.apache.doris.datasource.mvcc.MvccUtil; -import org.apache.doris.fs.DirectoryLister; -import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan.SelectedPartitions; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.spi.Split; -import org.apache.doris.thrift.TColumnCategory; -import org.apache.doris.thrift.TFileAttributes; -import org.apache.doris.thrift.TFileCompressType; -import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.TFileRangeDesc; -import org.apache.doris.thrift.TFileScanRangeParams; -import org.apache.doris.thrift.TFileTextScanRangeParams; -import org.apache.doris.thrift.TTableFormatFileDesc; -import org.apache.doris.thrift.TTransactionalHiveDeleteDeltaDesc; -import org.apache.doris.thrift.TTransactionalHiveDesc; - -import com.google.common.base.Preconditions; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Random; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import java.util.concurrent.Semaphore; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Collectors; - -public class HiveScanNode extends FileQueryScanNode { - private static final Logger LOG = LogManager.getLogger(HiveScanNode.class); - - protected final HMSExternalTable hmsTable; - private HiveTransaction hiveTransaction = null; - - // will only be set in Nereids, for lagency planner, it should be null - protected SelectedPartitions selectedPartitions = null; - - private DirectoryLister directoryLister; - - private boolean partitionInit = false; - private final AtomicReference batchException = new AtomicReference<>(null); - private List prunedPartitions; - private final Semaphore splittersOnFlight = new Semaphore(NUM_SPLITTERS_ON_FLIGHT); - private final AtomicInteger numSplitsPerPartition = new AtomicInteger(NUM_SPLITS_PER_PARTITION); - - private boolean skipCheckingAcidVersionFile = false; - - /** - * * External file scan node for Query Hive table - * needCheckColumnPriv: Some of ExternalFileScanNode do not need to check column priv - * eg: s3 tvf - * These scan nodes do not have corresponding catalog/database/table info, so no need to do priv check - */ - public HiveScanNode(PlanNodeId id, TupleDescriptor desc, boolean needCheckColumnPriv, SessionVariable sv, - DirectoryLister directoryLister, ScanContext scanContext) { - this(id, desc, "HIVE_SCAN_NODE", needCheckColumnPriv, sv, directoryLister, scanContext); - } - - public HiveScanNode(PlanNodeId id, TupleDescriptor desc, String planNodeName, - boolean needCheckColumnPriv, SessionVariable sv, - DirectoryLister directoryLister, ScanContext scanContext) { - super(id, desc, planNodeName, scanContext, needCheckColumnPriv, sv); - hmsTable = (HMSExternalTable) desc.getTable(); - brokerName = hmsTable.getCatalog().bindBrokerName(); - this.directoryLister = directoryLister; - } - - public void setSelectedPartitions(SelectedPartitions selectedPartitions) { - this.selectedPartitions = selectedPartitions; - setHasPartitionPredicate(selectedPartitions != null && selectedPartitions.hasPartitionPredicate); - } - - @Override - protected void doInitialize() throws UserException { - super.doInitialize(); - - if (hmsTable.isHiveTransactionalTable()) { - markTransactionalHiveScanParams(params); - this.hiveTransaction = new HiveTransaction(DebugUtil.printId(ConnectContext.get().queryId()), - ConnectContext.get().getQualifiedUser(), hmsTable, hmsTable.isFullAcidTable()); - Env.getCurrentHiveTransactionMgr().register(hiveTransaction); - skipCheckingAcidVersionFile = sessionVariable.skipCheckingAcidVersionFile; - } - } - - static void markTransactionalHiveScanParams(TFileScanRangeParams scanParams) { - // BE selects the scanner before remote batch splits are fetched, so expose the table format - // in scan-level params as well as in each range. - TTableFormatFileDesc tableFormatParams = new TTableFormatFileDesc(); - tableFormatParams.setTableFormatType(TableFormatType.TRANSACTIONAL_HIVE.value()); - scanParams.setTableFormatParams(tableFormatParams); - } - - protected List getPartitions() throws AnalysisException { - long startTime = System.currentTimeMillis(); - List resPartitions = Lists.newArrayList(); - try { - HiveExternalMetaCache cache = Env.getCurrentEnv().getExtMetaCacheMgr() - .hive(hmsTable.getCatalog().getId()); - List partitionColumnTypes = - hmsTable.getPartitionColumnTypes(MvccUtil.getSnapshotFromContext(hmsTable)); - if (!partitionColumnTypes.isEmpty()) { - // partitioned table - Collection partitionItems; - // partitions has benn pruned by Nereids, in PruneFileScanPartition, - // so just use the selected partitions. - this.totalPartitionNum = selectedPartitions.totalPartitionNum; - partitionItems = selectedPartitions.selectedPartitions.values(); - Preconditions.checkNotNull(partitionItems); - this.selectedPartitionNum = partitionItems.size(); - - // get partitions from cache - List> partitionValuesList = Lists.newArrayListWithCapacity(partitionItems.size()); - for (PartitionItem item : partitionItems) { - partitionValuesList.add( - ((ListPartitionItem) item).getItems().get(0).getPartitionValuesAsStringListForHive()); - } - resPartitions = cache.getAllPartitionsWithCache(hmsTable, partitionValuesList); - } else { - // non partitioned table, create a dummy partition to save location and inputformat, - // so that we can unify the interface. - HivePartition dummyPartition = new HivePartition(hmsTable.getOrBuildNameMapping(), true, - hmsTable.getRemoteTable().getSd().getInputFormat(), - hmsTable.getRemoteTable().getSd().getLocation(), null, Maps.newHashMap()); - this.totalPartitionNum = 1; - this.selectedPartitionNum = 1; - resPartitions.add(dummyPartition); - } - if (ConnectContext.get().getExecutor() != null) { - getSummaryProfile().addExternalTableGetPartitionsTime(System.currentTimeMillis() - startTime); - getSummaryProfile().setGetPartitionsFinishTime(); - } - return resPartitions; - } catch (RuntimeException e) { - if (getSummaryProfile() != null) { - getSummaryProfile().addExternalTableGetPartitionsTime(System.currentTimeMillis() - startTime); - } - throw e; - } - } - - @Override - public List getSplits(int numBackends) throws UserException { - long start = System.currentTimeMillis(); - try { - if (!partitionInit) { - prunedPartitions = getPartitions(); - partitionInit = true; - } - HiveExternalMetaCache cache = Env.getCurrentEnv().getExtMetaCacheMgr() - .hive(hmsTable.getCatalog().getId()); - String bindBrokerName = hmsTable.getCatalog().bindBrokerName(); - List allFiles = Lists.newArrayList(); - getFileSplitByPartitions(cache, prunedPartitions, allFiles, bindBrokerName, numBackends, false); - if (ConnectContext.get().getExecutor() != null) { - ConnectContext.get().getExecutor().getSummaryProfile().setGetPartitionFilesFinishTime(); - } - if (LOG.isDebugEnabled()) { - LOG.debug("get #{} files for table: {}.{}, cost: {} ms", - allFiles.size(), hmsTable.getDbName(), hmsTable.getName(), - (System.currentTimeMillis() - start)); - } - return allFiles; - } catch (Throwable t) { - LOG.warn("get file split failed for table: {}", hmsTable.getName(), t); - throw new UserException( - "get file split failed for table: " + hmsTable.getName() + ", err: " + Util.getRootCauseMessage(t), - t); - } - } - - @Override - public void startSplit(int numBackends) { - if (prunedPartitions.isEmpty()) { - splitAssignment.finishSchedule(); - return; - } - HiveExternalMetaCache cache = Env.getCurrentEnv().getExtMetaCacheMgr() - .hive(hmsTable.getCatalog().getId()); - Executor scheduleExecutor = Env.getCurrentEnv().getExtMetaCacheMgr().getScheduleExecutor(); - String bindBrokerName = hmsTable.getCatalog().bindBrokerName(); - AtomicInteger numFinishedPartitions = new AtomicInteger(0); - long startTime = System.currentTimeMillis(); - CompletableFuture.runAsync(() -> { - for (HivePartition partition : prunedPartitions) { - if (batchException.get() != null || splitAssignment.isStop()) { - break; - } - try { - splittersOnFlight.acquire(); - CompletableFuture.runAsync(() -> { - try { - List allFiles = Lists.newArrayList(); - getFileSplitByPartitions( - cache, Collections.singletonList(partition), allFiles, bindBrokerName, - numBackends, true); - if (allFiles.size() > numSplitsPerPartition.get()) { - numSplitsPerPartition.set(allFiles.size()); - } - if (splitAssignment.needMoreSplit()) { - splitAssignment.addToQueue(allFiles); - } - } catch (Exception e) { - batchException.set(new UserException(e.getMessage(), e)); - } finally { - splittersOnFlight.release(); - if (batchException.get() != null) { - splitAssignment.setException(batchException.get()); - } - if (numFinishedPartitions.incrementAndGet() == prunedPartitions.size()) { - if (getSummaryProfile() != null) { - getSummaryProfile().addExternalTableGetPartitionFilesTime( - System.currentTimeMillis() - startTime); - } - splitAssignment.finishSchedule(); - } - } - }, scheduleExecutor); - } catch (Exception e) { - // When submitting a task, an exception will be thrown if the task pool(scheduleExecutor) is full - batchException.set(new UserException(e.getMessage(), e)); - break; - } - } - if (batchException.get() != null) { - splitAssignment.setException(batchException.get()); - } - }); - } - - @Override - public boolean isBatchMode() { - if (!partitionInit) { - try { - prunedPartitions = getPartitions(); - } catch (Exception e) { - return false; - } - partitionInit = true; - } - int numPartitions = sessionVariable.getNumPartitionsInBatchMode(); - return numPartitions >= 0 && prunedPartitions.size() >= numPartitions; - } - - @Override - public int numApproximateSplits() { - return numSplitsPerPartition.get() * prunedPartitions.size(); - } - - private void getFileSplitByPartitions(HiveExternalMetaCache cache, List partitions, - List allFiles, String bindBrokerName, int numBackends, - boolean isBatchMode) throws IOException, UserException { - List fileCaches; - long startTime = System.currentTimeMillis(); - if (hiveTransaction != null) { - try { - fileCaches = getFileSplitByTransaction(cache, partitions, bindBrokerName); - } catch (Exception e) { - // Release shared load (getValidWriteIds acquire Lock). - // If no exception is throw, the lock will be released when `finalizeQuery()`. - Env.getCurrentHiveTransactionMgr().deregister(hiveTransaction.getQueryId()); - throw e; - } - } else { - boolean withCache = Config.max_external_file_cache_num > 0; - fileCaches = cache.getFilesByPartitions(partitions, withCache, partitions.size() > 1, - directoryLister, hmsTable); - } - if (!isBatchMode && getSummaryProfile() != null) { - getSummaryProfile().addExternalTableGetPartitionFilesTime(System.currentTimeMillis() - startTime); - } - - long targetFileSplitSize = determineTargetFileSplitSize(fileCaches, isBatchMode); - if (tableSample != null) { - List hiveFileStatuses = selectFiles(fileCaches); - splitAllFiles(allFiles, hiveFileStatuses, targetFileSplitSize); - return; - } - - /** - * If a table-level COUNT(*) is pushed down, - * we don't need to split the file because for parquet/orc format, only metadata is read. - * If we split the file, we will read metadata of a file multiple times, which is not efficient. - * - * - Hive Full Acid Transactional Table may need merge on read, so do not apply this optimization. - * - If the file format is not parquet/orc, eg, text, we need to split the file to increase the parallelism. - */ - boolean needSplit = true; - if (isTableLevelCountStarPushdown() - && !(hmsTable.isHiveTransactionalTable() && hmsTable.isFullAcidTable())) { - int totalFileNum = 0; - for (FileCacheValue fileCacheValue : fileCaches) { - if (fileCacheValue.getFiles() != null) { - totalFileNum += fileCacheValue.getFiles().size(); - } - } - int parallelNum = sessionVariable.getParallelExecInstanceNum(scanContext.getClusterName()); - needSplit = FileSplitter.needSplitForCountPushdown(parallelNum, numBackends, totalFileNum); - } - - for (HiveExternalMetaCache.FileCacheValue fileCacheValue : fileCaches) { - if (fileCacheValue.getFiles() == null) { - continue; - } - boolean isSplittable = fileCacheValue.isSplittable(); - - for (HiveExternalMetaCache.HiveFileStatus status : fileCacheValue.getFiles()) { - allFiles.addAll(fileSplitter.splitFile( - status.getPath(), - targetFileSplitSize, - status.getBlockLocations(), - status.getLength(), - status.getModificationTime(), - isSplittable && needSplit, - fileCacheValue.getPartitionValues(), - new HiveSplitCreator(fileCacheValue.getAcidInfo()))); - } - } - } - - @Override - protected TColumnCategory classifyColumn(SlotDescriptor slot, List partitionKeys) { - if (slot.getColumn().getName().startsWith(Column.GLOBAL_ROWID_COL)) { - return TColumnCategory.SYNTHESIZED; - } - return super.classifyColumn(slot, partitionKeys); - } - - private long determineTargetFileSplitSize(List fileCaches, - boolean isBatchMode) { - if (sessionVariable.getFileSplitSize() > 0) { - return sessionVariable.getFileSplitSize(); - } - /** Hive batch split mode will return 0. and FileSplitter - * will determine file split size. - */ - if (isBatchMode) { - return 0; - } - long result = sessionVariable.getMaxInitialSplitSize(); - long totalFileSize = 0; - boolean exceedInitialThreshold = false; - for (HiveExternalMetaCache.FileCacheValue fileCacheValue : fileCaches) { - if (fileCacheValue.getFiles() == null) { - continue; - } - for (HiveExternalMetaCache.HiveFileStatus status : fileCacheValue.getFiles()) { - totalFileSize += status.getLength(); - if (!exceedInitialThreshold - && totalFileSize >= sessionVariable.getMaxSplitSize() - * sessionVariable.getMaxInitialSplitNum()) { - exceedInitialThreshold = true; - } - } - } - result = exceedInitialThreshold ? sessionVariable.getMaxSplitSize() : result; - result = applyMaxFileSplitNumLimit(result, totalFileSize); - return result; - } - - private void splitAllFiles(List allFiles, - List hiveFileStatuses, - long realFileSplitSize) throws IOException { - for (HiveExternalMetaCache.HiveFileStatus status : hiveFileStatuses) { - allFiles.addAll(fileSplitter.splitFile( - status.getPath(), - realFileSplitSize, - status.getBlockLocations(), - status.getLength(), - status.getModificationTime(), - status.isSplittable(), - status.getPartitionValues(), - new HiveSplitCreator(status.getAcidInfo()))); - } - } - - private List selectFiles(List inputCacheValue) { - List fileList = Lists.newArrayList(); - long totalSize = 0; - for (FileCacheValue value : inputCacheValue) { - for (HiveExternalMetaCache.HiveFileStatus file : value.getFiles()) { - file.setSplittable(value.isSplittable()); - file.setPartitionValues(value.getPartitionValues()); - file.setAcidInfo(value.getAcidInfo()); - fileList.add(file); - totalSize += file.getLength(); - } - } - long sampleSize = 0; - if (tableSample.isPercent()) { - sampleSize = totalSize * tableSample.getSampleValue() / 100; - } else { - long estimatedRowSize = 0; - for (Column column : hmsTable.getFullSchema()) { - estimatedRowSize += column.getDataType().getSlotSize(); - } - sampleSize = estimatedRowSize * tableSample.getSampleValue(); - } - long selectedSize = 0; - Collections.shuffle(fileList, new Random(tableSample.getSeek())); - int index = 0; - for (HiveExternalMetaCache.HiveFileStatus file : fileList) { - selectedSize += file.getLength(); - index += 1; - if (selectedSize >= sampleSize) { - break; - } - } - return fileList.subList(0, index); - } - - private List getFileSplitByTransaction(HiveExternalMetaCache cache, List partitions, - String bindBrokerName) { - for (HivePartition partition : partitions) { - if (partition.getPartitionValues() == null || partition.getPartitionValues().isEmpty()) { - // this is unpartitioned table. - continue; - } - hiveTransaction.addPartition(partition.getPartitionName(hmsTable.getPartitionColumns())); - } - Map txnValidIds = hiveTransaction.getValidWriteIds( - ((HMSExternalCatalog) hmsTable.getCatalog()).getClient()); - - return cache.getFilesByTransaction(partitions, txnValidIds, hiveTransaction.isFullAcid(), bindBrokerName); - } - - @Override - public List getPathPartitionKeys() { - return hmsTable.getRemoteTable().getPartitionKeys().stream() - .map(FieldSchema::getName).filter(partitionKey -> !"".equals(partitionKey)) - .map(String::toLowerCase).collect(Collectors.toList()); - } - - @Override - public TableIf getTargetTable() { - return hmsTable; - } - - @Override - public TFileFormatType getFileFormatType() throws UserException { - return hmsTable.getFileFormatType(sessionVariable); - } - - @Override - protected void setScanParams(TFileRangeDesc rangeDesc, Split split) { - if (split instanceof HiveSplit) { - HiveSplit hiveSplit = (HiveSplit) split; - if (hiveSplit.isACID()) { - hiveSplit.setTableFormatType(TableFormatType.TRANSACTIONAL_HIVE); - TTableFormatFileDesc tableFormatFileDesc = new TTableFormatFileDesc(); - tableFormatFileDesc.setTableFormatType(hiveSplit.getTableFormatType().value()); - AcidInfo acidInfo = (AcidInfo) hiveSplit.getInfo(); - TTransactionalHiveDesc transactionalHiveDesc = new TTransactionalHiveDesc(); - transactionalHiveDesc.setPartition(acidInfo.getPartitionLocation()); - List deleteDeltaDescs = new ArrayList<>(); - for (DeleteDeltaInfo deleteDeltaInfo : acidInfo.getDeleteDeltas()) { - TTransactionalHiveDeleteDeltaDesc deleteDeltaDesc = new TTransactionalHiveDeleteDeltaDesc(); - deleteDeltaDesc.setDirectoryLocation(deleteDeltaInfo.getDirectoryLocation()); - deleteDeltaDesc.setFileNames(deleteDeltaInfo.getFileNames()); - deleteDeltaDescs.add(deleteDeltaDesc); - } - transactionalHiveDesc.setDeleteDeltas(deleteDeltaDescs); - tableFormatFileDesc.setTransactionalHiveParams(transactionalHiveDesc); - tableFormatFileDesc.setTableLevelRowCount(-1); - rangeDesc.setTableFormatParams(tableFormatFileDesc); - } else { - TTableFormatFileDesc tableFormatFileDesc = new TTableFormatFileDesc(); - tableFormatFileDesc.setTableFormatType(TableFormatType.HIVE.value()); - tableFormatFileDesc.setTableLevelRowCount(-1); - rangeDesc.setTableFormatParams(tableFormatFileDesc); - } - } - } - - @Override - protected List getDeleteFiles(TFileRangeDesc rangeDesc) { - List deleteFiles = new ArrayList<>(); - if (rangeDesc == null || !rangeDesc.isSetTableFormatParams()) { - return deleteFiles; - } - TTableFormatFileDesc tableFormatParams = rangeDesc.getTableFormatParams(); - if (tableFormatParams == null || !tableFormatParams.isSetTransactionalHiveParams()) { - return deleteFiles; - } - TTransactionalHiveDesc hiveParams = tableFormatParams.getTransactionalHiveParams(); - if (hiveParams == null || !hiveParams.isSetDeleteDeltas()) { - return deleteFiles; - } - List deleteDeltas = hiveParams.getDeleteDeltas(); - if (deleteDeltas == null) { - return deleteFiles; - } - // Format: {directory_location}/{file_name} - for (TTransactionalHiveDeleteDeltaDesc deleteDelta : deleteDeltas) { - if (deleteDelta != null && deleteDelta.isSetDirectoryLocation() - && deleteDelta.isSetFileNames() && deleteDelta.getFileNames() != null) { - String directoryLocation = deleteDelta.getDirectoryLocation(); - for (String fileName : deleteDelta.getFileNames()) { - deleteFiles.add(directoryLocation + "/" + fileName); - } - } - } - return deleteFiles; - } - - @Override - protected Map getLocationProperties() { - return hmsTable.getBackendStorageProperties(); - } - - @Override - protected TFileAttributes getFileAttributes() throws UserException { - TFileAttributes fileAttributes = new TFileAttributes(); - Table table = hmsTable.getRemoteTable(); - // set skip header count - // TODO: support skip footer count - fileAttributes.setSkipLines(HiveProperties.getSkipHeaderCount(table)); - String serDeLib = table.getSd().getSerdeInfo().getSerializationLib(); - if (serDeLib.equals(HiveMetaStoreClientHelper.HIVE_TEXT_SERDE) - || serDeLib.equals(HiveMetaStoreClientHelper.HIVE_MULTI_DELIMIT_SERDE)) { - TFileTextScanRangeParams textParams = new TFileTextScanRangeParams(); - // set properties of LazySimpleSerDe and MultiDelimitSerDe - // 1. set column separator (MultiDelimitSerDe supports multi-character delimiters) - boolean supportMultiChar = serDeLib.equals(HiveMetaStoreClientHelper.HIVE_MULTI_DELIMIT_SERDE); - textParams.setColumnSeparator(HiveProperties.getFieldDelimiter(table, supportMultiChar)); - // 2. set line delimiter - textParams.setLineDelimiter(HiveProperties.getLineDelimiter(table)); - // 3. set mapkv delimiter - textParams.setMapkvDelimiter(HiveProperties.getMapKvDelimiter(table)); - // 4. set collection delimiter - textParams.setCollectionDelimiter(HiveProperties.getCollectionDelimiter(table)); - // 5. set escape delimiter - HiveProperties.getEscapeDelimiter(table).ifPresent(d -> textParams.setEscape(d.getBytes()[0])); - // 6. set null format - textParams.setNullFormat(HiveProperties.getNullFormat(table)); - fileAttributes.setTextParams(textParams); - fileAttributes.setHeaderType(""); - fileAttributes.setEnableTextValidateUtf8( - sessionVariable.enableTextValidateUtf8); - } else if (serDeLib.equals(HiveMetaStoreClientHelper.HIVE_OPEN_CSV_SERDE)) { - TFileTextScanRangeParams textParams = new TFileTextScanRangeParams(); - // set set properties of OpenCSVSerde - // 1. set column separator - textParams.setColumnSeparator(HiveProperties.getSeparatorChar(table)); - // 2. set line delimiter - textParams.setLineDelimiter(HiveProperties.getLineDelimiter(table)); - // 3. set enclose char - textParams.setEnclose(HiveProperties.getQuoteChar(table).getBytes()[0]); - // 4. set escape char - textParams.setEscape(HiveProperties.getEscapeChar(table).getBytes()[0]); - // 5. set null format with empty string to make csv reader not use "\\N" to represent null - textParams.setNullFormat(""); - fileAttributes.setTextParams(textParams); - fileAttributes.setHeaderType(""); - if (shouldTrimDoubleQuotes(textParams)) { - fileAttributes.setTrimDoubleQuotes(true); - } - fileAttributes.setEnableTextValidateUtf8( - sessionVariable.enableTextValidateUtf8); - } else if (serDeLib.equals(HiveMetaStoreClientHelper.HIVE_JSON_SERDE)) { - TFileTextScanRangeParams textParams = new TFileTextScanRangeParams(); - textParams.setColumnSeparator("\t"); - textParams.setLineDelimiter("\n"); - fileAttributes.setTextParams(textParams); - - fileAttributes.setJsonpaths(""); - fileAttributes.setJsonRoot(""); - fileAttributes.setNumAsString(true); - fileAttributes.setFuzzyParse(false); - fileAttributes.setReadJsonByLine(true); - fileAttributes.setStripOuterArray(false); - fileAttributes.setHeaderType(""); - } else if (serDeLib.equals(HiveMetaStoreClientHelper.OPENX_JSON_SERDE)) { - if (!sessionVariable.isReadHiveJsonInOneColumn()) { - TFileTextScanRangeParams textParams = new TFileTextScanRangeParams(); - textParams.setColumnSeparator("\t"); - textParams.setLineDelimiter("\n"); - fileAttributes.setTextParams(textParams); - - fileAttributes.setJsonpaths(""); - fileAttributes.setJsonRoot(""); - fileAttributes.setNumAsString(true); - fileAttributes.setFuzzyParse(false); - fileAttributes.setReadJsonByLine(true); - fileAttributes.setStripOuterArray(false); - fileAttributes.setHeaderType(""); - - fileAttributes.setOpenxJsonIgnoreMalformed( - Boolean.parseBoolean(HiveProperties.getOpenxJsonIgnoreMalformed(table))); - } else if (sessionVariable.isReadHiveJsonInOneColumn() - && hmsTable.firstColumnIsString()) { - TFileTextScanRangeParams textParams = new TFileTextScanRangeParams(); - textParams.setLineDelimiter("\n"); - textParams.setColumnSeparator("\n"); - //First, perform row splitting according to `\n`. When performing column splitting, - // since there is no `\n`, only one column of data will be generated. - fileAttributes.setTextParams(textParams); - fileAttributes.setHeaderType(""); - } else { - throw new UserException("You set read_hive_json_in_one_column = true, but the first column of table " - + hmsTable.getName() - + " is not a string column."); - } - } else { - throw new UserException( - "unsupported hive table serde: " + serDeLib); - } - - return fileAttributes; - } - - static boolean shouldTrimDoubleQuotes(TFileTextScanRangeParams textParams) { - return textParams.isSetEnclose() && textParams.getEnclose() == (byte) '"'; - } - - @Override - protected TFileCompressType getFileCompressType(FileSplit fileSplit) throws UserException { - TFileCompressType compressType = super.getFileCompressType(fileSplit); - // hadoop use lz4 blocked codec - if (compressType == TFileCompressType.LZ4FRAME) { - compressType = TFileCompressType.LZ4BLOCK; - } - return compressType; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/source/HiveSplit.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/source/HiveSplit.java deleted file mode 100644 index 58bfb32e617e34..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/source/HiveSplit.java +++ /dev/null @@ -1,66 +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.datasource.hive.source; - -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.FileSplit; -import org.apache.doris.datasource.SplitCreator; -import org.apache.doris.datasource.hive.AcidInfo; -import org.apache.doris.spi.Split; - -import java.util.List; - -public class HiveSplit extends FileSplit { - - private HiveSplit(LocationPath path, long start, long length, long fileLength, - long modificationTime, String[] hosts, List partitionValues, AcidInfo acidInfo) { - super(path, start, length, fileLength, modificationTime, hosts, partitionValues); - this.acidInfo = acidInfo; - } - - @Override - public Object getInfo() { - return acidInfo; - } - - private AcidInfo acidInfo; - - public boolean isACID() { - return acidInfo != null; - } - - public static class HiveSplitCreator implements SplitCreator { - - private AcidInfo acidInfo; - - public HiveSplitCreator(AcidInfo acidInfo) { - this.acidInfo = acidInfo; - } - - @Override - public Split create(LocationPath path, long start, long length, long fileLength, - long fileSplitSize, - long modificationTime, String[] hosts, - List partitionValues) { - HiveSplit split = new HiveSplit(path, start, length, fileLength, modificationTime, - hosts, partitionValues, acidInfo); - split.setTargetSplitSize(fileSplitSize); - return split; - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiExternalMetaCache.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiExternalMetaCache.java deleted file mode 100644 index 258838711f0890..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiExternalMetaCache.java +++ /dev/null @@ -1,240 +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.datasource.hudi; - -import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.CacheException; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.TablePartitionValues; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HiveMetaStoreClientHelper; -import org.apache.doris.datasource.metacache.AbstractExternalMetaCache; -import org.apache.doris.datasource.metacache.MetaCacheEntryDef; -import org.apache.doris.datasource.metacache.MetaCacheEntryInvalidation; - -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.hive.common.FileUtils; -import org.apache.hudi.common.config.HoodieMetadataConfig; -import org.apache.hudi.common.engine.HoodieLocalEngineContext; -import org.apache.hudi.common.table.HoodieTableMetaClient; -import org.apache.hudi.common.table.timeline.HoodieInstant; -import org.apache.hudi.common.table.timeline.HoodieTimeline; -import org.apache.hudi.common.table.view.FileSystemViewManager; -import org.apache.hudi.common.table.view.HoodieTableFileSystemView; -import org.apache.hudi.common.util.Option; -import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.stream.Collectors; - -/** - * Hudi engine implementation of {@link AbstractExternalMetaCache}. - * - *

    Registered entries: - *

      - *
    • {@code partition}: partition metadata keyed by table identity + snapshot timestamp + mode
    • - *
    • {@code fs_view}: {@link HoodieTableFileSystemView} keyed by {@link NameMapping}
    • - *
    • {@code meta_client}: {@link HoodieTableMetaClient} keyed by {@link NameMapping}
    • - *
    • {@code schema}: Hudi schema cache keyed by table identity + timestamp
    • - *
    - * - *

    Invalidation behavior: - *

      - *
    • db/table invalidation clears all four entries for matching keys
    • - *
    • partition-level invalidation currently falls back to table-level invalidation
    • - *
    - */ -public class HudiExternalMetaCache extends AbstractExternalMetaCache { - private static final Logger LOG = LogManager.getLogger(HudiExternalMetaCache.class); - - public static final String ENGINE = "hudi"; - public static final String ENTRY_PARTITION = "partition"; - public static final String ENTRY_FS_VIEW = "fs_view"; - public static final String ENTRY_META_CLIENT = "meta_client"; - public static final String ENTRY_SCHEMA = "schema"; - - private final EntryHandle partitionEntry; - private final EntryHandle fsViewEntry; - private final EntryHandle metaClientEntry; - private final EntryHandle schemaEntry; - - public HudiExternalMetaCache(ExecutorService refreshExecutor) { - super(ENGINE, refreshExecutor); - partitionEntry = registerEntry(MetaCacheEntryDef.of(ENTRY_PARTITION, HudiPartitionCacheKey.class, - TablePartitionValues.class, this::loadPartitionValuesCacheValue, defaultEntryCacheSpec(), - MetaCacheEntryInvalidation.forNameMapping(HudiPartitionCacheKey::getNameMapping))); - fsViewEntry = registerEntry(MetaCacheEntryDef.of(ENTRY_FS_VIEW, HudiFsViewCacheKey.class, - HoodieTableFileSystemView.class, this::createFsView, defaultEntryCacheSpec(), - MetaCacheEntryInvalidation.forNameMapping(HudiFsViewCacheKey::getNameMapping))); - metaClientEntry = registerEntry(MetaCacheEntryDef.of(ENTRY_META_CLIENT, HudiMetaClientCacheKey.class, - HoodieTableMetaClient.class, this::createHoodieTableMetaClient, defaultEntryCacheSpec(), - MetaCacheEntryInvalidation.forNameMapping(HudiMetaClientCacheKey::getNameMapping))); - schemaEntry = registerEntry(MetaCacheEntryDef.of(ENTRY_SCHEMA, HudiSchemaCacheKey.class, - SchemaCacheValue.class, this::loadSchemaCacheValue, defaultSchemaCacheSpec(), - MetaCacheEntryInvalidation.forNameMapping(HudiSchemaCacheKey::getNameMapping))); - } - - public HoodieTableMetaClient getHoodieTableMetaClient(NameMapping nameMapping) { - return metaClientEntry.get(nameMapping.getCtlId()).get(HudiMetaClientCacheKey.of(nameMapping)); - } - - public HoodieTableFileSystemView getFsView(NameMapping nameMapping) { - return fsViewEntry.get(nameMapping.getCtlId()).get(HudiFsViewCacheKey.of(nameMapping)); - } - - public HudiSchemaCacheValue getHudiSchemaCacheValue(NameMapping nameMapping, long timestamp) { - SchemaCacheValue schemaCacheValue = schemaEntry.get(nameMapping.getCtlId()) - .get(new HudiSchemaCacheKey(nameMapping, timestamp)); - return (HudiSchemaCacheValue) schemaCacheValue; - } - - public TablePartitionValues getSnapshotPartitionValues(HMSExternalTable table, - String timestamp, boolean useHiveSyncPartition) { - return partitionEntry.get(table.getCatalog().getId()).get( - HudiPartitionCacheKey.of(table.getOrBuildNameMapping(), Long.parseLong(timestamp), - useHiveSyncPartition)); - } - - public TablePartitionValues getPartitionValues(HMSExternalTable table, boolean useHiveSyncPartition) - throws CacheException { - HoodieTableMetaClient tableMetaClient = getHoodieTableMetaClient(table.getOrBuildNameMapping()); - TablePartitionValues emptyPartitionValues = new TablePartitionValues(); - Option partitionColumns = tableMetaClient.getTableConfig().getPartitionFields(); - if (!partitionColumns.isPresent() || partitionColumns.get().length == 0) { - return emptyPartitionValues; - } - HoodieTimeline timeline = tableMetaClient.getCommitsAndCompactionTimeline().filterCompletedInstants(); - Option lastInstant = timeline.lastInstant(); - if (!lastInstant.isPresent()) { - return emptyPartitionValues; - } - long lastTimestamp = Long.parseLong(lastInstant.get().requestedTime()); - return partitionEntry.get(table.getCatalog().getId()).get( - HudiPartitionCacheKey.of(table.getOrBuildNameMapping(), lastTimestamp, useHiveSyncPartition)); - } - - private HoodieTableFileSystemView createFsView(HudiFsViewCacheKey key) { - HoodieTableMetaClient tableMetaClient = metaClientEntry.get(key.getNameMapping().getCtlId()) - .get(HudiMetaClientCacheKey.of(key.getNameMapping())); - HoodieMetadataConfig metadataConfig = HoodieMetadataConfig.newBuilder().build(); - HoodieLocalEngineContext ctx = new HoodieLocalEngineContext(tableMetaClient.getStorageConf()); - return FileSystemViewManager.createInMemoryFileSystemView(ctx, tableMetaClient, metadataConfig); - } - - private HoodieTableMetaClient createHoodieTableMetaClient(HudiMetaClientCacheKey key) { - LOG.debug("create hudi table meta client for {}", key.getNameMapping().getFullLocalName()); - HMSExternalTable hudiTable = findHudiTable(key.getNameMapping()); - Configuration conf = ExternalCatalog.buildHadoopConfiguration( - hudiTable.getCatalog().getHadoopProperties()); - HadoopStorageConfiguration hadoopStorageConfiguration = new HadoopStorageConfiguration(conf); - return HiveMetaStoreClientHelper.ugiDoAs( - conf, - () -> HoodieTableMetaClient.builder() - .setConf(hadoopStorageConfiguration) - .setBasePath(hudiTable.getRemoteTable().getSd().getLocation()) - .build()); - } - - private TablePartitionValues loadPartitionValuesCacheValue(HudiPartitionCacheKey key) { - HMSExternalTable hudiTable = findHudiTable(key.getNameMapping()); - HoodieTableMetaClient tableMetaClient = getHoodieTableMetaClient(key.getNameMapping()); - return loadPartitionValues(hudiTable, tableMetaClient, key.getTimestamp(), key.isUseHiveSyncPartition()); - } - - private TablePartitionValues loadPartitionValues(HMSExternalTable table, HoodieTableMetaClient tableMetaClient, - long timestamp, boolean useHiveSyncPartition) { - try { - TablePartitionValues partitionValues = new TablePartitionValues(); - Option partitionColumns = tableMetaClient.getTableConfig().getPartitionFields(); - if (!partitionColumns.isPresent() || partitionColumns.get().length == 0) { - return partitionValues; - } - HoodieTimeline timeline = tableMetaClient.getCommitsAndCompactionTimeline().filterCompletedInstants(); - List partitionNames = loadPartitionNames(table, tableMetaClient, timeline, timestamp, - useHiveSyncPartition); - List partitionColumnsList = Arrays.asList(partitionColumns.get()); - partitionValues.addPartitions(partitionNames, - partitionNames.stream() - .map(partition -> HudiPartitionUtils.parsePartitionValues(partitionColumnsList, partition)) - .collect(Collectors.toList()), - table.getHudiPartitionColumnTypes(timestamp), - Collections.nCopies(partitionNames.size(), 0L)); - partitionValues.setLastUpdateTimestamp(timestamp); - return partitionValues; - } catch (Exception e) { - LOG.warn("Failed to get hudi partitions", e); - throw new CacheException("Failed to get hudi partitions: " + Util.getRootCauseMessage(e), e); - } - } - - private List loadPartitionNames(HMSExternalTable table, HoodieTableMetaClient tableMetaClient, - HoodieTimeline timeline, long timestamp, boolean useHiveSyncPartition) throws Exception { - Option lastInstant = timeline.lastInstant(); - if (!lastInstant.isPresent()) { - return Collections.emptyList(); - } - long lastTimestamp = Long.parseLong(lastInstant.get().requestedTime()); - if (timestamp != lastTimestamp) { - return HudiPartitionUtils.getPartitionNamesBeforeOrEquals(timeline, String.valueOf(timestamp)); - } - if (!useHiveSyncPartition) { - return HudiPartitionUtils.getAllPartitionNames(tableMetaClient); - } - HMSExternalCatalog catalog = (HMSExternalCatalog) table.getCatalog(); - List partitionNames = catalog.getClient() - .listPartitionNames(table.getRemoteDbName(), table.getRemoteName()); - partitionNames = partitionNames.stream().map(FileUtils::unescapePathName).collect(Collectors.toList()); - if (partitionNames.isEmpty()) { - LOG.warn("Failed to get partitions from hms api, switch it from hudi api."); - return HudiPartitionUtils.getAllPartitionNames(tableMetaClient); - } - return partitionNames; - } - - private HMSExternalTable findHudiTable(NameMapping nameMapping) { - ExternalTable dorisTable = findExternalTable(nameMapping, ENGINE); - if (!(dorisTable instanceof HMSExternalTable)) { - throw new CacheException("table %s.%s.%s is not hms external table when loading hudi cache", - null, nameMapping.getCtlId(), nameMapping.getLocalDbName(), nameMapping.getLocalTblName()); - } - return (HMSExternalTable) dorisTable; - } - - private SchemaCacheValue loadSchemaCacheValue(HudiSchemaCacheKey key) { - ExternalTable dorisTable = findExternalTable(key.getNameMapping(), ENGINE); - return dorisTable.initSchemaAndUpdateTime(key).orElseThrow(() -> - new CacheException("failed to load hudi schema cache value for: %s.%s.%s, timestamp: %s", - null, key.getNameMapping().getCtlId(), key.getNameMapping().getLocalDbName(), - key.getNameMapping().getLocalTblName(), key.getTimestamp())); - } - - @Override - protected Map catalogPropertyCompatibilityMap() { - return singleCompatibilityMap(ExternalCatalog.SCHEMA_CACHE_TTL_SECOND, ENTRY_SCHEMA); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiFsViewCacheKey.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiFsViewCacheKey.java deleted file mode 100644 index 385cb6878893eb..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiFsViewCacheKey.java +++ /dev/null @@ -1,58 +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.datasource.hudi; - -import org.apache.doris.datasource.NameMapping; - -import java.util.Objects; - -/** - * Cache key for hudi fs view entry. - */ -public final class HudiFsViewCacheKey { - private final NameMapping nameMapping; - - private HudiFsViewCacheKey(NameMapping nameMapping) { - this.nameMapping = Objects.requireNonNull(nameMapping, "nameMapping can not be null"); - } - - public static HudiFsViewCacheKey of(NameMapping nameMapping) { - return new HudiFsViewCacheKey(nameMapping); - } - - public NameMapping getNameMapping() { - return nameMapping; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - HudiFsViewCacheKey that = (HudiFsViewCacheKey) o; - return Objects.equals(nameMapping, that.nameMapping); - } - - @Override - public int hashCode() { - return Objects.hash(nameMapping); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiMetaClientCacheKey.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiMetaClientCacheKey.java deleted file mode 100644 index 2f2ba0e032da9e..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiMetaClientCacheKey.java +++ /dev/null @@ -1,58 +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.datasource.hudi; - -import org.apache.doris.datasource.NameMapping; - -import java.util.Objects; - -/** - * Cache key for hudi meta client entry. - */ -public final class HudiMetaClientCacheKey { - private final NameMapping nameMapping; - - private HudiMetaClientCacheKey(NameMapping nameMapping) { - this.nameMapping = Objects.requireNonNull(nameMapping, "nameMapping can not be null"); - } - - public static HudiMetaClientCacheKey of(NameMapping nameMapping) { - return new HudiMetaClientCacheKey(nameMapping); - } - - public NameMapping getNameMapping() { - return nameMapping; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - HudiMetaClientCacheKey that = (HudiMetaClientCacheKey) o; - return nameMapping.equals(that.nameMapping); - } - - @Override - public int hashCode() { - return Objects.hash(nameMapping); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiMvccSnapshot.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiMvccSnapshot.java deleted file mode 100644 index 8821d113a7f5ea..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiMvccSnapshot.java +++ /dev/null @@ -1,80 +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.datasource.hudi; - -import org.apache.doris.datasource.TablePartitionValues; -import org.apache.doris.datasource.mvcc.MvccSnapshot; - -/** - * Implementation of MvccSnapshot for Hudi tables that maintains partition values - * for MVCC (Multiversion Concurrency Control) operations. - * This class is immutable to ensure thread safety. - */ -public class HudiMvccSnapshot implements MvccSnapshot { - private final TablePartitionValues tablePartitionValues; - private final long timestamp; - - /** - * Creates a new HudiMvccSnapshot with the specified partition values. - * - * @param tablePartitionValues The partition values for the snapshot - * @throws IllegalArgumentException if tablePartitionValues is null - */ - public HudiMvccSnapshot(TablePartitionValues tablePartitionValues, Long timeStamp) { - if (tablePartitionValues == null) { - throw new IllegalArgumentException("TablePartitionValues cannot be null"); - } - this.timestamp = timeStamp; - this.tablePartitionValues = tablePartitionValues; - } - - public long getTimestamp() { - return timestamp; - } - - /** - * Gets the table partition values associated with this snapshot. - * - * @return The immutable TablePartitionValues object - */ - public TablePartitionValues getTablePartitionValues() { - return tablePartitionValues; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - HudiMvccSnapshot that = (HudiMvccSnapshot) o; - return tablePartitionValues.equals(that.tablePartitionValues); - } - - @Override - public int hashCode() { - return tablePartitionValues.hashCode(); - } - - @Override - public String toString() { - return String.format("HudiMvccSnapshot{tablePartitionValues=%s}", tablePartitionValues); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiPartitionCacheKey.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiPartitionCacheKey.java deleted file mode 100644 index b39688acaf5b47..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiPartitionCacheKey.java +++ /dev/null @@ -1,72 +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.datasource.hudi; - -import org.apache.doris.datasource.NameMapping; - -import java.util.Objects; - -/** - * Cache key for Hudi partition metadata by table, snapshot timestamp and load mode. - */ -public final class HudiPartitionCacheKey { - private final NameMapping nameMapping; - private final long timestamp; - private final boolean useHiveSyncPartition; - - private HudiPartitionCacheKey(NameMapping nameMapping, long timestamp, boolean useHiveSyncPartition) { - this.nameMapping = Objects.requireNonNull(nameMapping, "nameMapping can not be null"); - this.timestamp = timestamp; - this.useHiveSyncPartition = useHiveSyncPartition; - } - - public static HudiPartitionCacheKey of(NameMapping nameMapping, long timestamp, boolean useHiveSyncPartition) { - return new HudiPartitionCacheKey(nameMapping, timestamp, useHiveSyncPartition); - } - - public NameMapping getNameMapping() { - return nameMapping; - } - - public long getTimestamp() { - return timestamp; - } - - public boolean isUseHiveSyncPartition() { - return useHiveSyncPartition; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof HudiPartitionCacheKey)) { - return false; - } - HudiPartitionCacheKey that = (HudiPartitionCacheKey) o; - return timestamp == that.timestamp - && useHiveSyncPartition == that.useHiveSyncPartition - && Objects.equals(nameMapping, that.nameMapping); - } - - @Override - public int hashCode() { - return Objects.hash(nameMapping, timestamp, useHiveSyncPartition); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiPartitionUtils.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiPartitionUtils.java deleted file mode 100644 index 4a22f7eae8d165..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiPartitionUtils.java +++ /dev/null @@ -1,90 +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.datasource.hudi; - -import org.apache.hadoop.hive.common.FileUtils; -import org.apache.hudi.common.config.HoodieMetadataConfig; -import org.apache.hudi.common.engine.HoodieLocalEngineContext; -import org.apache.hudi.common.table.HoodieTableMetaClient; -import org.apache.hudi.common.table.timeline.HoodieTimeline; -import org.apache.hudi.common.table.timeline.TimelineUtils; -import org.apache.hudi.metadata.HoodieTableMetadata; -import org.apache.hudi.metadata.HoodieTableMetadataUtil; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -/** - * Hudi partition utility helpers shared by scan/planner/meta cache components. - */ -public final class HudiPartitionUtils { - private HudiPartitionUtils() { - } - - public static List getAllPartitionNames(HoodieTableMetaClient tableMetaClient) throws IOException { - HoodieMetadataConfig metadataConfig = HoodieMetadataConfig.newBuilder() - .enable(HoodieTableMetadataUtil.isFilesPartitionAvailable(tableMetaClient)) - .build(); - HoodieTableMetadata newTableMetadata = HoodieTableMetadata.create( - new HoodieLocalEngineContext(tableMetaClient.getStorageConf()), tableMetaClient.getStorage(), - metadataConfig, tableMetaClient.getBasePath().toString(), true); - return newTableMetadata.getAllPartitionPaths(); - } - - public static List getPartitionNamesBeforeOrEquals(HoodieTimeline timeline, String timestamp) { - return new ArrayList<>(HoodieTableMetadataUtil.getWritePartitionPaths( - timeline.findInstantsBeforeOrEquals(timestamp).getInstants().stream().map(instant -> { - try { - return TimelineUtils.getCommitMetadata(instant, timeline); - } catch (IOException e) { - throw new RuntimeException(e.getMessage(), e); - } - }).collect(Collectors.toList()))); - } - - public static List parsePartitionValues(List partitionColumns, String partitionPath) { - if (partitionColumns.size() == 0) { - // This is a non-partitioned table. - return Collections.emptyList(); - } - String[] partitionFragments = partitionPath.split("/"); - if (partitionFragments.length != partitionColumns.size()) { - if (partitionColumns.size() == 1) { - // If partition column size is 1, map whole partition path to this single partition column. - String prefix = partitionColumns.get(0) + "="; - String partitionValue = partitionPath.startsWith(prefix) - ? partitionPath.substring(prefix.length()) : partitionPath; - return Collections.singletonList(FileUtils.unescapePathName(partitionValue)); - } - throw new RuntimeException("Failed to parse partition values of path: " + partitionPath); - } - List partitionValues = new ArrayList<>(partitionFragments.length); - for (int i = 0; i < partitionFragments.length; i++) { - String prefix = partitionColumns.get(i) + "="; - if (partitionFragments[i].startsWith(prefix)) { - partitionValues.add(FileUtils.unescapePathName(partitionFragments[i].substring(prefix.length()))); - } else { - partitionValues.add(FileUtils.unescapePathName(partitionFragments[i])); - } - } - return partitionValues; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiSchemaCacheKey.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiSchemaCacheKey.java deleted file mode 100644 index 3ab8409858de14..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiSchemaCacheKey.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.datasource.hudi; - -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.SchemaCacheKey; - -import com.google.common.base.Objects; - -/** - * Cache key for Hudi table schemas that includes timestamp information. - * This allows for time-travel queries and ensures proper schema versioning. - */ -public class HudiSchemaCacheKey extends SchemaCacheKey { - private final long timestamp; - - /** - * Creates a new cache key for Hudi table schemas. - * - * @param nameMapping - * @param timestamp The timestamp for schema version - * @throws IllegalArgumentException if dbName or tableName is null or empty - */ - public HudiSchemaCacheKey(NameMapping nameMapping, long timestamp) { - super(nameMapping); - if (timestamp < 0) { - throw new IllegalArgumentException("Timestamp cannot be negative"); - } - this.timestamp = timestamp; - } - - /** - * Gets the timestamp associated with this schema version. - * - * @return the timestamp value - */ - public long getTimestamp() { - return timestamp; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - if (!super.equals(o)) { - return false; - } - - HudiSchemaCacheKey that = (HudiSchemaCacheKey) o; - return timestamp == that.timestamp; - } - - @Override - public int hashCode() { - return Objects.hashCode(super.hashCode(), timestamp); - } - - @Override - public String toString() { - return String.format("HudiSchemaCacheKey{dbName='%s', tableName='%s', timestamp=%d}", - getNameMapping().getLocalDbName(), getNameMapping().getLocalTblName(), timestamp); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiSchemaCacheValue.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiSchemaCacheValue.java deleted file mode 100644 index b0b39f4b85e001..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiSchemaCacheValue.java +++ /dev/null @@ -1,55 +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.datasource.hudi; - -import org.apache.doris.catalog.Column; -import org.apache.doris.datasource.hive.HMSSchemaCacheValue; - -import org.apache.hudi.common.table.HoodieTableMetaClient; -import org.apache.hudi.common.util.InternalSchemaCache; -import org.apache.hudi.internal.schema.InternalSchema; - -import java.util.List; - -public class HudiSchemaCacheValue extends HMSSchemaCacheValue { - - private List colTypes; - boolean enableSchemaEvolution; - - public HudiSchemaCacheValue(List schema, List partitionColumns, boolean enableSchemaEvolution) { - super(schema, partitionColumns); - this.enableSchemaEvolution = enableSchemaEvolution; - } - - public List getColTypes() { - return colTypes; - } - - public void setColTypes(List colTypes) { - this.colTypes = colTypes; - } - - public InternalSchema getCommitInstantInternalSchema(HoodieTableMetaClient metaClient, Long commitInstantTime) { - return InternalSchemaCache.searchSchemaAndCache(commitInstantTime, metaClient); - } - - public boolean isEnableSchemaEvolution() { - return enableSchemaEvolution; - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiUtils.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiUtils.java deleted file mode 100644 index 6c622e371c319e..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/HudiUtils.java +++ /dev/null @@ -1,433 +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.datasource.hudi; - -import org.apache.doris.analysis.TableSnapshot; -import org.apache.doris.catalog.ArrayType; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.MapType; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.StructField; -import org.apache.doris.catalog.StructType; -import org.apache.doris.catalog.Type; -import org.apache.doris.common.profile.SummaryProfile; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.TablePartitionValues; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HiveMetaStoreClientHelper; -import org.apache.doris.datasource.hive.HivePartition; -import org.apache.doris.thrift.TColumnType; -import org.apache.doris.thrift.TPrimitiveType; -import org.apache.doris.thrift.schema.external.TArrayField; -import org.apache.doris.thrift.schema.external.TField; -import org.apache.doris.thrift.schema.external.TFieldPtr; -import org.apache.doris.thrift.schema.external.TMapField; -import org.apache.doris.thrift.schema.external.TNestedField; -import org.apache.doris.thrift.schema.external.TSchema; -import org.apache.doris.thrift.schema.external.TStructField; - -import org.apache.avro.LogicalType; -import org.apache.avro.LogicalTypes; -import org.apache.avro.Schema; -import org.apache.avro.Schema.Field; -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.apache.hadoop.conf.Configuration; -import org.apache.hudi.common.table.HoodieTableMetaClient; -import org.apache.hudi.common.table.timeline.HoodieInstant; -import org.apache.hudi.common.table.timeline.HoodieTimeline; -import org.apache.hudi.common.util.Option; -import org.apache.hudi.internal.schema.InternalSchema; -import org.apache.hudi.internal.schema.Types; -import org.apache.hudi.internal.schema.convert.AvroInternalSchemaConverter; -import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -public class HudiUtils { - public static String convertAvroToHiveType(Schema schema) { - Schema.Type type = schema.getType(); - LogicalType logicalType = schema.getLogicalType(); - - switch (type) { - case BOOLEAN: - return "boolean"; - case INT: - if (logicalType instanceof LogicalTypes.Date) { - return "date"; - } - if (logicalType instanceof LogicalTypes.TimeMillis) { - return handleUnsupportedType(schema); - } - return "int"; - case LONG: - if (logicalType instanceof LogicalTypes.TimestampMillis - || logicalType instanceof LogicalTypes.TimestampMicros) { - return "timestamp"; - } - if (logicalType instanceof LogicalTypes.TimeMicros) { - return handleUnsupportedType(schema); - } - return "bigint"; - case FLOAT: - return "float"; - case DOUBLE: - return "double"; - case STRING: - return "string"; - case FIXED: - case BYTES: - if (logicalType instanceof LogicalTypes.Decimal) { - LogicalTypes.Decimal decimalType = (LogicalTypes.Decimal) logicalType; - return String.format("decimal(%d,%d)", decimalType.getPrecision(), decimalType.getScale()); - } - return "string"; - case ARRAY: - String arrayElementType = convertAvroToHiveType(schema.getElementType()); - return String.format("array<%s>", arrayElementType); - case RECORD: - List recordFields = schema.getFields(); - if (recordFields.isEmpty()) { - throw new IllegalArgumentException("Record must have fields"); - } - String structFields = recordFields.stream() - .map(field -> String.format("%s:%s", field.name(), convertAvroToHiveType(field.schema()))) - .collect(Collectors.joining(",")); - return String.format("struct<%s>", structFields); - case MAP: - Schema mapValueType = schema.getValueType(); - String mapValueTypeString = convertAvroToHiveType(mapValueType); - return String.format("map", mapValueTypeString); - case UNION: - List unionTypes = schema.getTypes().stream() - .filter(s -> s.getType() != Schema.Type.NULL) - .collect(Collectors.toList()); - if (unionTypes.size() == 1) { - return convertAvroToHiveType(unionTypes.get(0)); - } - break; - default: - break; - } - - throw new IllegalArgumentException( - String.format("Unsupported type: %s for column: %s", type.getName(), schema.getName())); - } - - private static String handleUnsupportedType(Schema schema) { - throw new IllegalArgumentException(String.format("Unsupported logical type: %s", schema.getLogicalType())); - } - - public static void updateHudiColumnUniqueId(Column column, Types.Field hudiInternalfield) { - column.setUniqueId(hudiInternalfield.fieldId()); - - List hudiInternalfields = new ArrayList<>(); - switch (hudiInternalfield.type().typeId()) { - case ARRAY: - hudiInternalfields = ((Types.ArrayType) hudiInternalfield.type()).fields(); - break; - case MAP: - hudiInternalfields = ((Types.MapType) hudiInternalfield.type()).fields(); - break; - case RECORD: - hudiInternalfields = ((Types.RecordType) hudiInternalfield.type()).fields(); - break; - default: - return; - } - - if (column.getChildren() != null) { - List childColumns = column.getChildren(); - for (int idx = 0; idx < childColumns.size(); idx++) { - updateHudiColumnUniqueId(childColumns.get(idx), hudiInternalfields.get(idx)); - } - } - } - - public static Type fromAvroHudiTypeToDorisType(Schema avroSchema) { - Schema.Type columnType = avroSchema.getType(); - LogicalType logicalType = avroSchema.getLogicalType(); - - switch (columnType) { - case BOOLEAN: - return Type.BOOLEAN; - case INT: - return handleIntType(logicalType); - case LONG: - return handleLongType(logicalType); - case FLOAT: - return Type.FLOAT; - case DOUBLE: - return Type.DOUBLE; - case STRING: - return Type.STRING; - case FIXED: - case BYTES: - return handleFixedOrBytesType(logicalType); - case ARRAY: - return handleArrayType(avroSchema); - case RECORD: - return handleRecordType(avroSchema); - case MAP: - return handleMapType(avroSchema); - case UNION: - return handleUnionType(avroSchema); - default: - return Type.UNSUPPORTED; - } - } - - private static Type handleIntType(LogicalType logicalType) { - if (logicalType instanceof LogicalTypes.Date) { - return ScalarType.createDateV2Type(); - } - if (logicalType instanceof LogicalTypes.TimeMillis) { - return ScalarType.createTimeV2Type(3); - } - return Type.INT; - } - - private static Type handleLongType(LogicalType logicalType) { - if (logicalType instanceof LogicalTypes.TimeMicros) { - return ScalarType.createTimeV2Type(6); - } - if (logicalType instanceof LogicalTypes.TimestampMillis) { - return ScalarType.createDatetimeV2Type(3); - } - if (logicalType instanceof LogicalTypes.TimestampMicros) { - return ScalarType.createDatetimeV2Type(6); - } - return Type.BIGINT; - } - - private static Type handleFixedOrBytesType(LogicalType logicalType) { - if (logicalType instanceof LogicalTypes.Decimal) { - int precision = ((LogicalTypes.Decimal) logicalType).getPrecision(); - int scale = ((LogicalTypes.Decimal) logicalType).getScale(); - return ScalarType.createDecimalV3Type(precision, scale); - } - return Type.STRING; - } - - private static Type handleArrayType(Schema avroSchema) { - Type innerType = fromAvroHudiTypeToDorisType(avroSchema.getElementType()); - return ArrayType.create(innerType, true); - } - - private static Type handleRecordType(Schema avroSchema) { - ArrayList fields = new ArrayList<>(); - avroSchema.getFields().forEach( - f -> fields.add(new StructField(f.name(), fromAvroHudiTypeToDorisType(f.schema())))); - return new StructType(fields); - } - - private static Type handleMapType(Schema avroSchema) { - return new MapType(Type.STRING, fromAvroHudiTypeToDorisType(avroSchema.getValueType())); - } - - private static Type handleUnionType(Schema avroSchema) { - List nonNullMembers = avroSchema.getTypes().stream() - .filter(schema -> !Schema.Type.NULL.equals(schema.getType())) - .collect(Collectors.toList()); - if (nonNullMembers.size() == 1) { - return fromAvroHudiTypeToDorisType(nonNullMembers.get(0)); - } - return Type.UNSUPPORTED; - } - - public static HudiMvccSnapshot getHudiMvccSnapshot(Optional tableSnapshot, - HMSExternalTable hmsTable) { - long timestamp = 0L; - if (tableSnapshot.isPresent()) { - String queryInstant = tableSnapshot.get().getValue().replaceAll("[-: ]", ""); - timestamp = Long.parseLong(queryInstant); - } else { - timestamp = getLastTimeStamp(hmsTable); - } - - return new HudiMvccSnapshot(HudiUtils.getPartitionValues(tableSnapshot, hmsTable), timestamp); - } - - public static long getLastTimeStamp(HMSExternalTable hmsTable) { - long startTime = System.currentTimeMillis(); - HoodieTableMetaClient hudiClient = hmsTable.getHudiClient(); - HoodieTimeline timeline = hudiClient.getCommitsAndCompactionTimeline().filterCompletedInstants(); - Option snapshotInstant = timeline.lastInstant(); - SummaryProfile summaryProfile = SummaryProfile.getSummaryProfile(null); - if (summaryProfile != null) { - summaryProfile.addExternalTableGetTableMetaTime(System.currentTimeMillis() - startTime); - } - if (!snapshotInstant.isPresent()) { - return 0L; - } - return Long.parseLong(snapshotInstant.get().requestedTime()); - } - - public static TablePartitionValues getPartitionValues(Optional tableSnapshot, - HMSExternalTable hmsTable) { - long startTime = System.currentTimeMillis(); - try { - TablePartitionValues partitionValues = new TablePartitionValues(); - - HoodieTableMetaClient hudiClient = hmsTable.getHudiClient(); - HudiExternalMetaCache hudiExternalMetaCache = - Env.getCurrentEnv().getExtMetaCacheMgr() - .hudi(hmsTable.getCatalog().getId()); - boolean useHiveSyncPartition = hmsTable.useHiveSyncPartition(); - - if (tableSnapshot.isPresent()) { - if (tableSnapshot.get().getType() == TableSnapshot.VersionType.VERSION) { - // Hudi does not support `FOR VERSION AS OF`, please use `FOR TIME AS OF`"; - return partitionValues; - } - String queryInstant = tableSnapshot.get().getValue().replaceAll("[-: ]", ""); - try { - partitionValues = hmsTable.getCatalog().getExecutionAuthenticator().execute(() -> - hudiExternalMetaCache.getSnapshotPartitionValues( - hmsTable, queryInstant, useHiveSyncPartition)); - } catch (Exception e) { - throw new RuntimeException(ExceptionUtils.getRootCauseMessage(e), e); - } - } else { - HoodieTimeline timeline = hudiClient.getCommitsAndCompactionTimeline().filterCompletedInstants(); - Option snapshotInstant = timeline.lastInstant(); - if (!snapshotInstant.isPresent()) { - return partitionValues; - } - try { - partitionValues = hmsTable.getCatalog().getExecutionAuthenticator().execute(() - -> hudiExternalMetaCache.getPartitionValues(hmsTable, useHiveSyncPartition)); - } catch (Exception e) { - throw new RuntimeException(ExceptionUtils.getRootCauseMessage(e), e); - } - } - return partitionValues; - } finally { - SummaryProfile summaryProfile = SummaryProfile.getSummaryProfile(null); - if (summaryProfile != null) { - summaryProfile.addExternalTableGetPartitionValuesTime(System.currentTimeMillis() - startTime); - } - } - } - - public static HoodieTableMetaClient buildHudiTableMetaClient(String hudiBasePath, Configuration conf) { - HadoopStorageConfiguration hadoopStorageConfiguration = new HadoopStorageConfiguration(conf); - return HiveMetaStoreClientHelper.ugiDoAs( - conf, - () -> HoodieTableMetaClient.builder() - .setConf(hadoopStorageConfiguration).setBasePath(hudiBasePath).build()); - } - - - - public static HudiSchemaCacheValue getSchemaCacheValue(HMSExternalTable hmsTable, String queryInstant) { - Optional schemaCacheValue = Env.getCurrentEnv().getExtMetaCacheMgr() - .getSchemaCacheValue(hmsTable, - new HudiSchemaCacheKey(hmsTable.getOrBuildNameMapping(), Long.parseLong(queryInstant))); - return (HudiSchemaCacheValue) schemaCacheValue.get(); - } - - public static TStructField getSchemaInfo(List hudiFields) { - TStructField structField = new TStructField(); - for (Types.Field field : hudiFields) { - TFieldPtr fieldPtr = new TFieldPtr(); - fieldPtr.setFieldPtr(getSchemaInfo(field)); - structField.addToFields(fieldPtr); - } - return structField; - } - - - public static TField getSchemaInfo(Types.Field hudiInternalField) { - TField root = new TField(); - root.setName(hudiInternalField.name()); - root.setId(hudiInternalField.fieldId()); - root.setIsOptional(hudiInternalField.isOptional()); - - TNestedField nestedField = new TNestedField(); - switch (hudiInternalField.type().typeId()) { - case ARRAY: { - TColumnType tColumnType = new TColumnType(); - tColumnType.setType(TPrimitiveType.ARRAY); - root.setType(tColumnType); - - TArrayField listField = new TArrayField(); - List hudiFields = ((Types.ArrayType) hudiInternalField.type()).fields(); - TFieldPtr fieldPtr = new TFieldPtr(); - fieldPtr.setFieldPtr(getSchemaInfo(hudiFields.get(0))); - listField.setItemField(fieldPtr); - nestedField.setArrayField(listField); - root.setNestedField(nestedField); - break; - } case MAP: { - TColumnType tColumnType = new TColumnType(); - tColumnType.setType(TPrimitiveType.MAP); - root.setType(tColumnType); - - TMapField mapField = new TMapField(); - List hudiFields = ((Types.MapType) hudiInternalField.type()).fields(); - TFieldPtr keyPtr = new TFieldPtr(); - keyPtr.setFieldPtr(getSchemaInfo(hudiFields.get(0))); - mapField.setKeyField(keyPtr); - TFieldPtr valuePtr = new TFieldPtr(); - valuePtr.setFieldPtr(getSchemaInfo(hudiFields.get(1))); - mapField.setValueField(valuePtr); - nestedField.setMapField(mapField); - root.setNestedField(nestedField); - break; - } case RECORD: { - TColumnType tColumnType = new TColumnType(); - tColumnType.setType(TPrimitiveType.STRUCT); - root.setType(tColumnType); - - List hudiFields = ((Types.RecordType) hudiInternalField.type()).fields(); - nestedField.setStructField(getSchemaInfo(hudiFields)); - root.setNestedField(nestedField); - break; - } default: { - root.setType(fromAvroHudiTypeToDorisType(AvroInternalSchemaConverter.convert( - hudiInternalField.type(), hudiInternalField.name())).toColumnTypeThrift()); - break; - } - } - return root; - } - - public static TSchema getSchemaInfo(InternalSchema hudiInternalSchema) { - TSchema tschema = new TSchema(); - tschema.setSchemaId(hudiInternalSchema.schemaId()); - tschema.setRootField(getSchemaInfo(hudiInternalSchema.getRecord().fields())); - return tschema; - } - - public static Map getPartitionInfoMap(HMSExternalTable table, HivePartition partition) { - Map partitionInfoMap = new HashMap<>(); - List partitionColumns = table.getPartitionColumns(); - for (int i = 0; i < partitionColumns.size(); i++) { - String partitionName = partitionColumns.get(i).getName(); - String partitionValue = partition.getPartitionValues().get(i); - partitionInfoMap.put(partitionName, partitionValue); - } - return partitionInfoMap; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/COWIncrementalRelation.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/COWIncrementalRelation.java deleted file mode 100644 index fbe9cd4e417a8c..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/COWIncrementalRelation.java +++ /dev/null @@ -1,256 +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.datasource.hudi.source; - -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.TableFormatType; -import org.apache.doris.datasource.hudi.HudiPartitionUtils; -import org.apache.doris.spi.Split; - -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.fs.FileSystem; -import org.apache.hadoop.fs.GlobPattern; -import org.apache.hadoop.fs.Path; -import org.apache.hudi.common.fs.FSUtils; -import org.apache.hudi.common.model.FileSlice; -import org.apache.hudi.common.model.HoodieCommitMetadata; -import org.apache.hudi.common.model.HoodieReplaceCommitMetadata; -import org.apache.hudi.common.model.HoodieWriteStat; -import org.apache.hudi.common.table.HoodieTableMetaClient; -import org.apache.hudi.common.table.timeline.HoodieInstant; -import org.apache.hudi.common.table.timeline.HoodieTimeline; -import org.apache.hudi.common.table.timeline.TimelineUtils; -import org.apache.hudi.common.table.timeline.TimelineUtils.HollowCommitHandling; -import org.apache.hudi.common.util.Option; -import org.apache.hudi.exception.HoodieException; -import org.apache.hudi.storage.StoragePath; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; -import java.util.stream.Collectors; - -public class COWIncrementalRelation implements IncrementalRelation { - private final Map optParams; - private final HoodieTableMetaClient metaClient; - private final HollowCommitHandling hollowCommitHandling; - private final boolean startInstantArchived; - private final boolean endInstantArchived; - private final boolean fullTableScan; - private final FileSystem fs; - private final Map fileToWriteStat; - private final Collection filteredRegularFullPaths; - private final Collection filteredMetaBootstrapFullPaths; - - private final String startTs; - private final String endTs; - - public COWIncrementalRelation(Map optParams, Configuration configuration, - HoodieTableMetaClient metaClient) - throws HoodieException, IOException { - this.optParams = optParams; - this.metaClient = metaClient; - hollowCommitHandling = HollowCommitHandling.valueOf( - optParams.getOrDefault("hoodie.read.timeline.holes.resolution.policy", "FAIL")); - HoodieTimeline commitTimeline = TimelineUtils.handleHollowCommitIfNeeded( - metaClient.getCommitTimeline().filterCompletedInstants(), metaClient, hollowCommitHandling); - if (commitTimeline.empty()) { - throw new HoodieException("No instants to incrementally pull"); - } - if (!metaClient.getTableConfig().populateMetaFields()) { - throw new HoodieException("Incremental queries are not supported when meta fields are disabled"); - } - - String startInstantTime = optParams.get("hoodie.datasource.read.begin.instanttime"); - if (startInstantTime == null) { - throw new HoodieException("Specify the begin instant time to pull from using " - + "option hoodie.datasource.read.begin.instanttime"); - } - if (EARLIEST_TIME.equals(startInstantTime)) { - startInstantTime = "000"; - } - - String latestTime = hollowCommitHandling == HollowCommitHandling.USE_TRANSITION_TIME - ? commitTimeline.lastInstant().get().getCompletionTime() - : commitTimeline.lastInstant().get().requestedTime(); - String endInstantTime = optParams.getOrDefault("hoodie.datasource.read.end.instanttime", latestTime); - if (LATEST_TIME.equals(endInstantTime)) { - endInstantTime = latestTime; - } - - startInstantArchived = commitTimeline.isBeforeTimelineStarts(startInstantTime); - endInstantArchived = commitTimeline.isBeforeTimelineStarts(endInstantTime); - - HoodieTimeline commitsTimelineToReturn; - if (hollowCommitHandling == HollowCommitHandling.USE_TRANSITION_TIME) { - commitsTimelineToReturn = commitTimeline.findInstantsInRangeByCompletionTime(startInstantTime, - endInstantTime); - } else { - commitsTimelineToReturn = commitTimeline.findInstantsInRange(startInstantTime, endInstantTime); - } - List commitsToReturn = commitsTimelineToReturn.getInstants(); - - // todo: support configuration hoodie.datasource.read.incr.filters - StoragePath basePath = metaClient.getBasePath(); - Map regularFileIdToFullPath = new HashMap<>(); - Map metaBootstrapFileIdToFullPath = new HashMap<>(); - HoodieTimeline replacedTimeline = commitsTimelineToReturn.getCompletedReplaceTimeline(); - Map replacedFile = new HashMap<>(); - for (HoodieInstant instant : replacedTimeline.getInstants()) { - HoodieReplaceCommitMetadata metadata = metaClient.getActiveTimeline() - .readReplaceCommitMetadata(instant); - metadata.getPartitionToReplaceFileIds().forEach( - (key, value) -> value.forEach( - e -> replacedFile.put(e, FSUtils.constructAbsolutePath(basePath, key).toString()))); - } - - fileToWriteStat = new HashMap<>(); - for (HoodieInstant commit : commitsToReturn) { - HoodieCommitMetadata metadata = metaClient.getActiveTimeline().readCommitMetadata(commit); - metadata.getPartitionToWriteStats().forEach((partition, stats) -> { - for (HoodieWriteStat stat : stats) { - fileToWriteStat.put(FSUtils.constructAbsolutePath(basePath, stat.getPath()).toString(), stat); - } - }); - if (HoodieTimeline.METADATA_BOOTSTRAP_INSTANT_TS.equals(commit.requestedTime())) { - metadata.getFileIdAndFullPaths(basePath).forEach((k, v) -> { - if (!(replacedFile.containsKey(k) && v.startsWith(replacedFile.get(k)))) { - metaBootstrapFileIdToFullPath.put(k, v); - } - }); - } else { - metadata.getFileIdAndFullPaths(basePath).forEach((k, v) -> { - if (!(replacedFile.containsKey(k) && v.startsWith(replacedFile.get(k)))) { - regularFileIdToFullPath.put(k, v); - } - }); - } - } - - if (!metaBootstrapFileIdToFullPath.isEmpty()) { - // filer out meta bootstrap files that have had more commits since metadata bootstrap - metaBootstrapFileIdToFullPath.entrySet().removeIf(e -> regularFileIdToFullPath.containsKey(e.getKey())); - } - String pathGlobPattern = optParams.getOrDefault("hoodie.datasource.read.incr.path.glob", ""); - if ("".equals(pathGlobPattern)) { - filteredRegularFullPaths = regularFileIdToFullPath.values(); - filteredMetaBootstrapFullPaths = metaBootstrapFileIdToFullPath.values(); - } else { - GlobPattern globMatcher = new GlobPattern("*" + pathGlobPattern); - filteredRegularFullPaths = regularFileIdToFullPath.values().stream().filter(globMatcher::matches) - .collect(Collectors.toList()); - filteredMetaBootstrapFullPaths = metaBootstrapFileIdToFullPath.values().stream() - .filter(globMatcher::matches).collect(Collectors.toList()); - - } - - fs = new Path(basePath.toUri().getPath()).getFileSystem(configuration); - fullTableScan = shouldFullTableScan(); - startTs = startInstantTime; - endTs = endInstantTime; - } - - private boolean shouldFullTableScan() throws HoodieException, IOException { - boolean fallbackToFullTableScan = Boolean.parseBoolean( - optParams.getOrDefault("hoodie.datasource.read.incr.fallback.fulltablescan.enable", "false")); - if (fallbackToFullTableScan && (startInstantArchived || endInstantArchived)) { - if (hollowCommitHandling == HollowCommitHandling.USE_TRANSITION_TIME) { - throw new HoodieException("Cannot use stateTransitionTime while enables full table scan"); - } - return true; - } - if (fallbackToFullTableScan) { - for (String path : filteredMetaBootstrapFullPaths) { - if (!fs.exists(new Path(path))) { - return true; - } - } - for (String path : filteredRegularFullPaths) { - if (!fs.exists(new Path(path))) { - return true; - } - } - } - return false; - } - - @Override - public List collectFileSlices() throws HoodieException { - throw new UnsupportedOperationException(); - } - - @Override - public List collectSplits() throws HoodieException { - if (fullTableScan) { - throw new HoodieException("Fallback to full table scan"); - } - if (filteredRegularFullPaths.isEmpty() && filteredMetaBootstrapFullPaths.isEmpty()) { - return Collections.emptyList(); - } - List splits = new ArrayList<>(); - Option partitionColumns = metaClient.getTableConfig().getPartitionFields(); - List partitionNames = partitionColumns.isPresent() ? Arrays.asList(partitionColumns.get()) - : Collections.emptyList(); - - Consumer generatorSplit = baseFile -> { - HoodieWriteStat stat = fileToWriteStat.get(baseFile); - LocationPath locationPath = LocationPath.of(baseFile); - HudiSplit hudiSplit = new HudiSplit(locationPath, 0, - stat.getFileSizeInBytes(), stat.getFileSizeInBytes(), new String[0], - HudiPartitionUtils.parsePartitionValues(partitionNames, stat.getPartitionPath())); - hudiSplit.setTableFormatType(TableFormatType.HUDI); - splits.add(hudiSplit); - }; - - for (String baseFile : filteredMetaBootstrapFullPaths) { - generatorSplit.accept(baseFile); - } - for (String baseFile : filteredRegularFullPaths) { - generatorSplit.accept(baseFile); - } - return splits; - } - - @Override - public Map getHoodieParams() { - optParams.put("hoodie.datasource.read.begin.instanttime", startTs); - optParams.put("hoodie.datasource.read.end.instanttime", endTs); - return optParams; - } - - @Override - public boolean fallbackFullTableScan() { - return fullTableScan; - } - - @Override - public String getStartTs() { - return startTs; - } - - @Override - public String getEndTs() { - return endTs; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/EmptyIncrementalRelation.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/EmptyIncrementalRelation.java deleted file mode 100644 index 666db958eeee6f..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/EmptyIncrementalRelation.java +++ /dev/null @@ -1,71 +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.datasource.hudi.source; - -import org.apache.doris.spi.Split; - -import org.apache.hudi.common.model.FileSlice; -import org.apache.hudi.exception.HoodieException; - -import java.util.Collections; -import java.util.List; -import java.util.Map; - -public class EmptyIncrementalRelation implements IncrementalRelation { - private static final String EMPTY_TS = "000"; - - private final Map optParams; - - public EmptyIncrementalRelation(Map optParams) { - this.optParams = optParams; - } - - @Override - public List collectFileSlices() throws HoodieException { - return Collections.emptyList(); - } - - @Override - public List collectSplits() throws HoodieException { - return Collections.emptyList(); - } - - @Override - public Map getHoodieParams() { - optParams.put("hoodie.datasource.read.incr.operation", "true"); - optParams.put("hoodie.datasource.read.begin.instanttime", EMPTY_TS); - optParams.put("hoodie.datasource.read.end.instanttime", EMPTY_TS); - optParams.put("hoodie.datasource.read.incr.includeStartTime", "true"); - return optParams; - } - - @Override - public boolean fallbackFullTableScan() { - return false; - } - - @Override - public String getStartTs() { - return EMPTY_TS; - } - - @Override - public String getEndTs() { - return EMPTY_TS; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/HudiScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/HudiScanNode.java deleted file mode 100644 index e7c6f9a64a0920..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/HudiScanNode.java +++ /dev/null @@ -1,614 +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.datasource.hudi.source; - -import org.apache.doris.analysis.TableScanParams; -import org.apache.doris.analysis.TableSnapshot; -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.ListPartitionItem; -import org.apache.doris.catalog.PartitionItem; -import org.apache.doris.catalog.Type; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.UserException; -import org.apache.doris.common.util.FileFormatUtils; -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.ExternalUtil; -import org.apache.doris.datasource.FilePartitionUtils; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.TableFormatType; -import org.apache.doris.datasource.hive.HivePartition; -import org.apache.doris.datasource.hive.source.HiveScanNode; -import org.apache.doris.datasource.hudi.HudiPartitionUtils; -import org.apache.doris.datasource.hudi.HudiSchemaCacheValue; -import org.apache.doris.datasource.hudi.HudiUtils; -import org.apache.doris.datasource.mvcc.MvccUtil; -import org.apache.doris.fs.DirectoryLister; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.spi.Split; -import org.apache.doris.thrift.TExplainLevel; -import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.TFileRangeDesc; -import org.apache.doris.thrift.THudiFileDesc; -import org.apache.doris.thrift.TTableFormatFileDesc; - -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.apache.hudi.common.fs.FSUtils; -import org.apache.hudi.common.model.BaseFile; -import org.apache.hudi.common.model.FileSlice; -import org.apache.hudi.common.model.HoodieBaseFile; -import org.apache.hudi.common.model.HoodieLogFile; -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.HoodieTimeline; -import org.apache.hudi.common.table.view.HoodieTableFileSystemView; -import org.apache.hudi.common.util.Option; -import org.apache.hudi.internal.schema.InternalSchema; -import org.apache.hudi.internal.schema.convert.AvroInternalSchemaConverter; -import org.apache.hudi.storage.StoragePath; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Executor; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Semaphore; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Collectors; - -public class HudiScanNode extends HiveScanNode { - - private static final Logger LOG = LogManager.getLogger(HudiScanNode.class); - - private boolean isCowTable; - - private final AtomicLong noLogsSplitNum = new AtomicLong(0); - - private HoodieTableMetaClient hudiClient; - private String basePath; - private String inputFormat; - private String serdeLib; - private List columnNames; - private List columnTypes; - - private boolean partitionInit = false; - private HoodieTimeline timeline; - private String queryInstant; - - private final AtomicReference batchException = new AtomicReference<>(null); - private List prunedPartitions; - private final Semaphore splittersOnFlight = new Semaphore(NUM_SPLITTERS_ON_FLIGHT); - private final AtomicInteger numSplitsPerPartition = new AtomicInteger(NUM_SPLITS_PER_PARTITION); - - private boolean incrementalRead = false; - private TableScanParams scanParams; - private IncrementalRelation incrementalRelation; - private HoodieTableFileSystemView fsView; - - // The schema information involved in the current query process (including historical schema). - protected ConcurrentHashMap currentQuerySchema = new ConcurrentHashMap<>(); - - /** - * External file scan node for Query Hudi table - * needCheckColumnPriv: Some of ExternalFileScanNode do not need to check column - * priv - * eg: s3 tvf - * These scan nodes do not have corresponding catalog/database/table info, so no - * need to do priv check - */ - public HudiScanNode(PlanNodeId id, TupleDescriptor desc, boolean needCheckColumnPriv, - Optional scanParams, Optional incrementalRelation, - SessionVariable sv, DirectoryLister directoryLister, ScanContext scanContext) { - super(id, desc, "HUDI_SCAN_NODE", needCheckColumnPriv, sv, directoryLister, scanContext); - isCowTable = hmsTable.isHoodieCowTable(); - if (LOG.isDebugEnabled()) { - if (isCowTable) { - LOG.debug("Hudi table {} can read as cow/read optimize table", hmsTable.getFullQualifiers()); - } else { - LOG.debug("Hudi table {} is a mor table, and will use JNI to read data in BE", - hmsTable.getFullQualifiers()); - } - } - this.scanParams = scanParams.orElse(null); - this.incrementalRelation = incrementalRelation.orElse(null); - this.incrementalRead = (this.scanParams != null && this.scanParams.incrementalRead()); - } - - @Override - public TFileFormatType getFileFormatType() throws UserException { - if (canUseNativeReader()) { - return super.getFileFormatType(); - } else { - // Use jni to read hudi table in BE - return TFileFormatType.FORMAT_JNI; - } - } - - @Override - protected void doInitialize() throws UserException { - ExternalTable table = (ExternalTable) desc.getTable(); - if (table.isView()) { - throw new AnalysisException( - String.format("Querying external view '%s.%s' is not supported", table.getDbName(), - table.getName())); - } - computeColumnsFilter(); - initBackendPolicy(); - initSchemaParams(); - - long tableMetaStartTime = System.currentTimeMillis(); - try { - hudiClient = hmsTable.getHudiClient(); - hudiClient.reloadActiveTimeline(); - basePath = hmsTable.getRemoteTable().getSd().getLocation(); - inputFormat = hmsTable.getRemoteTable().getSd().getInputFormat(); - serdeLib = hmsTable.getRemoteTable().getSd().getSerdeInfo().getSerializationLib(); - - if (scanParams != null && !scanParams.incrementalRead()) { - // Only support incremental read - throw new UserException("Not support function '" + scanParams.getParamType() + "' in hudi table"); - } - if (incrementalRead) { - if (isCowTable) { - try { - Map serd = hmsTable.getRemoteTable().getSd().getSerdeInfo().getParameters(); - if ("true".equals(serd.get("hoodie.query.as.ro.table")) - && hmsTable.getRemoteTable().getTableName().endsWith("_ro")) { - // Incremental read RO table as RT table, I don't know why? - isCowTable = false; - LOG.warn("Execute incremental read on RO table: {}", hmsTable.getFullQualifiers()); - } - } catch (Exception e) { - // ignore - } - } - if (incrementalRelation == null) { - throw new UserException("Failed to create incremental relation"); - } - } - - timeline = hudiClient.getCommitsAndCompactionTimeline().filterCompletedInstants(); - TableSnapshot tableSnapshot = getQueryTableSnapshot(); - if (tableSnapshot != null) { - if (tableSnapshot.getType() == TableSnapshot.VersionType.VERSION) { - throw new UserException("Hudi does not support `FOR VERSION AS OF`, please use `FOR TIME AS OF`"); - } - queryInstant = tableSnapshot.getValue().replaceAll("[-: ]", ""); - } else { - Option snapshotInstant = timeline.lastInstant(); - if (!snapshotInstant.isPresent()) { - prunedPartitions = Collections.emptyList(); - partitionInit = true; - return; - } - queryInstant = snapshotInstant.get().requestedTime(); - } - - HudiSchemaCacheValue hudiSchemaCacheValue = HudiUtils.getSchemaCacheValue(hmsTable, queryInstant); - columnNames = hudiSchemaCacheValue.getSchema().stream().map(Column::getName).collect(Collectors.toList()); - columnTypes = hudiSchemaCacheValue.getColTypes(); - - fsView = Env.getCurrentEnv() - .getExtMetaCacheMgr() - .hudi(hmsTable.getCatalog().getId()) - .getFsView(hmsTable.getOrBuildNameMapping()); - } finally { - if (getSummaryProfile() != null) { - getSummaryProfile().addExternalTableGetTableMetaTime(System.currentTimeMillis() - tableMetaStartTime); - } - } - // Todo: Get the current schema id of the table, instead of using -1. - // In Be Parquet/Rrc reader, if `current table schema id == current file schema id`, then its - // `table_info_node_ptr` will be `TableSchemaChangeHelper::ConstNode`. When using `ConstNode`, - // you need to pay special attention to the `case difference` between the `table column name` - // and `the file column name`. - ExternalUtil.initSchemaInfo(params, -1L, table.getColumns()); - } - - @Override - protected Map getLocationProperties() { - if (incrementalRead) { - return incrementalRelation.getHoodieParams(); - } else { - // Merge both BE format (AWS_*) and Hadoop format (fs.s3a.*) properties - // Native reader needs BE format, JNI reader needs Hadoop format - Map properties = new HashMap<>(); - // Add BE format properties for native reader - properties.putAll(hmsTable.getBackendStorageProperties()); - // Add Hadoop format properties for JNI reader - properties.putAll(hmsTable.getCatalog().getCatalogProperty().getHadoopProperties()); - return properties; - } - } - - @Override - protected void setScanParams(TFileRangeDesc rangeDesc, Split split) { - if (split instanceof HudiSplit) { - HudiSplit hudiSplit = (HudiSplit) split; - if (rangeDesc.getFormatType() == TFileFormatType.FORMAT_JNI - && !sessionVariable.isForceJniScanner() - && hudiSplit.getHudiDeltaLogs().isEmpty()) { - // no logs, is read optimize table, fallback to use native reader - String fileFormat = FileFormatUtils.getFileFormatBySuffix(hudiSplit.getDataFilePath()) - .orElse("Unknown"); - if (fileFormat.equals("parquet")) { - rangeDesc.setFormatType(TFileFormatType.FORMAT_PARQUET); - } else if (fileFormat.equals("orc")) { - rangeDesc.setFormatType(TFileFormatType.FORMAT_ORC); - } else { - throw new RuntimeException("Unsupported file format: " + fileFormat); - } - } - setHudiParams(rangeDesc, hudiSplit); - } - } - - - private void putHistorySchemaInfo(InternalSchema internalSchema) { - if (currentQuerySchema.putIfAbsent(internalSchema.schemaId(), Boolean.TRUE) == null) { - params.addToHistorySchemaInfo(HudiUtils.getSchemaInfo(internalSchema)); - } - } - - private void setHudiParams(TFileRangeDesc rangeDesc, HudiSplit hudiSplit) { - TTableFormatFileDesc tableFormatFileDesc = new TTableFormatFileDesc(); - tableFormatFileDesc.setTableFormatType(hudiSplit.getTableFormatType().value()); - THudiFileDesc fileDesc = new THudiFileDesc(); - if (rangeDesc.getFormatType() == TFileFormatType.FORMAT_JNI) { - fileDesc.setInstantTime(hudiSplit.getInstantTime()); - fileDesc.setSerde(hudiSplit.getSerde()); - fileDesc.setInputFormat(hudiSplit.getInputFormat()); - fileDesc.setBasePath(hudiSplit.getBasePath()); - fileDesc.setDataFilePath(hudiSplit.getDataFilePath()); - fileDesc.setDataFileLength(hudiSplit.getFileLength()); - fileDesc.setDeltaLogs(hudiSplit.getHudiDeltaLogs()); - fileDesc.setColumnNames(hudiSplit.getHudiColumnNames()); - fileDesc.setColumnTypes(hudiSplit.getHudiColumnTypes()); - // TODO(gaoxin): support complex types - // fileDesc.setNestedFields(hudiSplit.getNestedFields()); - } else { - HudiSchemaCacheValue hudiSchemaCacheValue = HudiUtils.getSchemaCacheValue(hmsTable, queryInstant); - if (hudiSchemaCacheValue.isEnableSchemaEvolution()) { - long commitInstantTime = Long.parseLong(FSUtils.getCommitTime( - new File(hudiSplit.getPath().getNormalizedLocation()).getName())); - InternalSchema internalSchema = hudiSchemaCacheValue - .getCommitInstantInternalSchema(hudiClient, commitInstantTime); - putHistorySchemaInfo(internalSchema); //for schema change. (native reader) - fileDesc.setSchemaId(internalSchema.schemaId()); - } else { - try { - TableSchemaResolver schemaUtil = new TableSchemaResolver(hudiClient); - InternalSchema internalSchema = - AvroInternalSchemaConverter.convert(schemaUtil.getTableAvroSchema(true)); - putHistorySchemaInfo(internalSchema); //Handle column name case for BE - fileDesc.setSchemaId(internalSchema.schemaId()); - } catch (Exception e) { - throw new RuntimeException("Cannot get hudi table schema.", e); - } - } - } - tableFormatFileDesc.setHudiParams(fileDesc); - Map partitionValues = hudiSplit.getHudiPartitionValues(); - if (partitionValues != null) { - List formPathKeys = new ArrayList<>(); - List formPathValues = new ArrayList<>(); - for (Map.Entry entry : partitionValues.entrySet()) { - formPathKeys.add(entry.getKey()); - formPathValues.add(entry.getValue()); - } - FilePartitionUtils.ParsedColumnsFromPath parsedColumnsFromPath = - FilePartitionUtils.normalizeColumnsFromPath(formPathValues); - rangeDesc.setColumnsFromPathKeys(formPathKeys); - rangeDesc.setColumnsFromPath(parsedColumnsFromPath.getValues()); - rangeDesc.setColumnsFromPathIsNull(parsedColumnsFromPath.getIsNull()); - } - rangeDesc.setTableFormatParams(tableFormatFileDesc); - } - - private boolean canUseNativeReader() { - return !sessionVariable.isForceJniScanner() && isCowTable; - } - - private List getPrunedPartitions(HoodieTableMetaClient metaClient) { - NameMapping nameMapping = hmsTable.getOrBuildNameMapping(); - List partitionColumnTypes = hmsTable.getPartitionColumnTypes(MvccUtil.getSnapshotFromContext(hmsTable)); - if (!partitionColumnTypes.isEmpty()) { - this.totalPartitionNum = selectedPartitions.totalPartitionNum; - Map prunedPartitions = selectedPartitions.selectedPartitions; - this.selectedPartitionNum = prunedPartitions.size(); - - String inputFormat = hmsTable.getRemoteTable().getSd().getInputFormat(); - String basePath = metaClient.getBasePath().toString(); - - List hivePartitions = Lists.newArrayList(); - prunedPartitions.forEach( - (key, value) -> { - String path = basePath + "/" + key; - hivePartitions.add(new HivePartition( - nameMapping, false, inputFormat, path, - ((ListPartitionItem) value).getItems().get(0).getPartitionValuesAsStringList(), - Maps.newHashMap())); - } - ); - return hivePartitions; - } - // unpartitioned table, create a dummy partition to save location and - // inputformat, - // so that we can unify the interface. - HivePartition dummyPartition = new HivePartition(nameMapping, true, - hmsTable.getRemoteTable().getSd().getInputFormat(), - hmsTable.getRemoteTable().getSd().getLocation(), null, Maps.newHashMap()); - this.totalPartitionNum = 1; - this.selectedPartitionNum = 1; - return Lists.newArrayList(dummyPartition); - } - - private List getIncrementalSplits() { - long startTime = System.currentTimeMillis(); - if (canUseNativeReader()) { - List splits = incrementalRelation.collectSplits(); - noLogsSplitNum.addAndGet(splits.size()); - if (getSummaryProfile() != null) { - getSummaryProfile().addExternalTableGetFileScanTasksTime(System.currentTimeMillis() - startTime); - } - return splits; - } - Option partitionColumns = hudiClient.getTableConfig().getPartitionFields(); - List partitionNames = partitionColumns.isPresent() ? Arrays.asList(partitionColumns.get()) - : Collections.emptyList(); - List splits = incrementalRelation.collectFileSlices().stream() - .map(fileSlice -> generateHudiSplit(fileSlice, - HudiPartitionUtils.parsePartitionValues(partitionNames, fileSlice.getPartitionPath()), - incrementalRelation.getEndTs())) - .collect(Collectors.toList()); - if (getSummaryProfile() != null) { - getSummaryProfile().addExternalTableGetFileScanTasksTime(System.currentTimeMillis() - startTime); - } - return splits; - } - - private void getPartitionSplits(HivePartition partition, List splits) throws IOException { - String partitionName; - if (partition.isDummyPartition()) { - partitionName = ""; - } else { - partitionName = FSUtils.getRelativePartitionPath(hudiClient.getBasePath(), - new StoragePath(partition.getPath())); - } - - final Map partitionValues = sessionVariable.isEnableRuntimeFilterPartitionPrune() - ? HudiUtils.getPartitionInfoMap(hmsTable, partition) - : null; - - if (canUseNativeReader()) { - fsView.getLatestBaseFilesBeforeOrOn(partitionName, queryInstant).forEach(baseFile -> { - noLogsSplitNum.incrementAndGet(); - String filePath = baseFile.getPath(); - - long fileSize = baseFile.getFileSize(); - // Need add hdfs host to location - LocationPath locationPath = LocationPath.of(filePath, hmsTable.getStoragePropertiesMap()); - HudiSplit hudiSplit = new HudiSplit(locationPath, 0, fileSize, fileSize, - new String[0], partition.getPartitionValues()); - hudiSplit.setTableFormatType(TableFormatType.HUDI); - if (partitionValues != null) { - hudiSplit.setHudiPartitionValues(partitionValues); - } - splits.add(hudiSplit); - }); - } else { - fsView.getLatestMergedFileSlicesBeforeOrOn(partitionName, queryInstant) - .forEach(fileSlice -> splits.add( - generateHudiSplit(fileSlice, partition.getPartitionValues(), queryInstant))); - } - } - - private void getPartitionsSplits(List partitions, List splits) { - Executor executor = Env.getCurrentEnv().getExtMetaCacheMgr().getFileListingExecutor(); - CountDownLatch countDownLatch = new CountDownLatch(partitions.size()); - AtomicReference throwable = new AtomicReference<>(); - long startTime = System.currentTimeMillis(); - partitions.forEach(partition -> executor.execute(() -> { - try { - getPartitionSplits(partition, splits); - } catch (Throwable t) { - throwable.set(t); - } finally { - countDownLatch.countDown(); - } - })); - try { - countDownLatch.await(); - } catch (InterruptedException e) { - throw new RuntimeException(e.getMessage(), e); - } - if (throwable.get() != null) { - throw new RuntimeException(throwable.get().getMessage(), throwable.get()); - } - if (getSummaryProfile() != null) { - getSummaryProfile().addExternalTableGetFileScanTasksTime(System.currentTimeMillis() - startTime); - } - } - - @Override - public List getSplits(int numBackends) throws UserException { - if (incrementalRead && !incrementalRelation.fallbackFullTableScan()) { - return getIncrementalSplits(); - } - initPrunedPartitions(); - List splits = Collections.synchronizedList(new ArrayList<>()); - try { - hmsTable.getCatalog().getExecutionAuthenticator().execute(() -> { - getPartitionsSplits(prunedPartitions, splits); - return null; - }); - } catch (Exception e) { - throw new UserException(ExceptionUtils.getRootCauseMessage(e), e); - } - return splits; - } - - private void initPrunedPartitions() throws UserException { - if (partitionInit) { - return; - } - long startTime = System.currentTimeMillis(); - try { - prunedPartitions = hmsTable.getCatalog().getExecutionAuthenticator().execute(() - -> getPrunedPartitions(hudiClient)); - if (getSummaryProfile() != null) { - getSummaryProfile().addExternalTableGetPartitionsTime(System.currentTimeMillis() - startTime); - } - } catch (Exception e) { - throw new UserException(ExceptionUtils.getRootCauseMessage(e), e); - } - partitionInit = true; - } - - @Override - public void startSplit(int numBackends) { - if (prunedPartitions.isEmpty()) { - splitAssignment.finishSchedule(); - return; - } - AtomicInteger numFinishedPartitions = new AtomicInteger(0); - ExecutorService scheduleExecutor = Env.getCurrentEnv().getExtMetaCacheMgr().getScheduleExecutor(); - long startTime = System.currentTimeMillis(); - CompletableFuture.runAsync(() -> { - for (HivePartition partition : prunedPartitions) { - if (batchException.get() != null || splitAssignment.isStop()) { - break; - } - try { - splittersOnFlight.acquire(); - } catch (InterruptedException e) { - batchException.set(new UserException(e.getMessage(), e)); - break; - } - CompletableFuture.runAsync(() -> { - try { - List allFiles = Lists.newArrayList(); - getPartitionSplits(partition, allFiles); - if (allFiles.size() > numSplitsPerPartition.get()) { - numSplitsPerPartition.set(allFiles.size()); - } - if (splitAssignment.needMoreSplit()) { - splitAssignment.addToQueue(allFiles); - } - } catch (Exception e) { - batchException.set(new UserException(e.getMessage(), e)); - } finally { - splittersOnFlight.release(); - if (batchException.get() != null) { - splitAssignment.setException(batchException.get()); - } - if (numFinishedPartitions.incrementAndGet() == prunedPartitions.size()) { - if (getSummaryProfile() != null) { - getSummaryProfile().addExternalTableGetFileScanTasksTime( - System.currentTimeMillis() - startTime); - } - splitAssignment.finishSchedule(); - } - } - }, scheduleExecutor); - } - if (batchException.get() != null) { - splitAssignment.setException(batchException.get()); - } - }, scheduleExecutor); - } - - @Override - public boolean isBatchMode() { - if (incrementalRead && !incrementalRelation.fallbackFullTableScan()) { - return false; - } - try { - initPrunedPartitions(); - } catch (UserException e) { - throw new RuntimeException(e.getMessage(), e); - } - int numPartitions = sessionVariable.getNumPartitionsInBatchMode(); - return numPartitions >= 0 && prunedPartitions.size() >= numPartitions; - } - - @Override - public int numApproximateSplits() { - return numSplitsPerPartition.get() * prunedPartitions.size(); - } - - private HudiSplit generateHudiSplit(FileSlice fileSlice, List partitionValues, String queryInstant) { - Optional baseFile = fileSlice.getBaseFile().toJavaOptional(); - String filePath = baseFile.map(BaseFile::getPath).orElse(""); - long fileSize = baseFile.map(BaseFile::getFileSize).orElse(0L); - fileSlice.getPartitionPath(); - - List logs = fileSlice.getLogFiles().map(HoodieLogFile::getPath) - .map(StoragePath::toString) - .collect(Collectors.toList()); - if (logs.isEmpty() && !sessionVariable.isForceJniScanner()) { - noLogsSplitNum.incrementAndGet(); - } - - // no base file, use log file to parse file type - String agencyPath = filePath.isEmpty() ? logs.get(0) : filePath; - LocationPath locationPath = LocationPath.of(agencyPath, hmsTable.getStoragePropertiesMap()); - HudiSplit split = new HudiSplit(locationPath, - 0, fileSize, fileSize, new String[0], partitionValues); - split.setTableFormatType(TableFormatType.HUDI); - split.setDataFilePath(filePath); - split.setHudiDeltaLogs(logs); - split.setInputFormat(inputFormat); - split.setSerde(serdeLib); - split.setBasePath(basePath); - split.setHudiColumnNames(columnNames); - split.setHudiColumnTypes(columnTypes); - split.setInstantTime(queryInstant); - return split; - } - - @Override - public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { - if (isBatchMode()) { - return super.getNodeExplainString(prefix, detailLevel); - } else { - return super.getNodeExplainString(prefix, detailLevel) - + String.format("%shudiNativeReadSplits=%d/%d\n", prefix, noLogsSplitNum.get(), selectedSplitNum); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/HudiSplit.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/HudiSplit.java deleted file mode 100644 index 9235bdde7a8836..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/HudiSplit.java +++ /dev/null @@ -1,45 +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.datasource.hudi.source; - -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.FileSplit; - -import lombok.Data; - -import java.util.List; -import java.util.Map; - -@Data -public class HudiSplit extends FileSplit { - public HudiSplit(LocationPath file, long start, long length, long fileLength, String[] hosts, - List partitionValues) { - super(file, start, length, fileLength, 0, hosts, partitionValues); - } - - private String instantTime; - private String serde; - private String inputFormat; - private String basePath; - private String dataFilePath; - private List hudiDeltaLogs; - private List hudiColumnNames; - private List hudiColumnTypes; - private List nestedFields; - private Map hudiPartitionValues; -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/IncrementalRelation.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/IncrementalRelation.java deleted file mode 100644 index cf7f47c722e489..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/IncrementalRelation.java +++ /dev/null @@ -1,43 +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.datasource.hudi.source; - -import org.apache.doris.spi.Split; - -import org.apache.hudi.common.model.FileSlice; -import org.apache.hudi.exception.HoodieException; - -import java.util.List; -import java.util.Map; - -public interface IncrementalRelation { - public static String EARLIEST_TIME = "earliest"; - public static String LATEST_TIME = "latest"; - - List collectFileSlices() throws HoodieException; - - List collectSplits() throws HoodieException; - - Map getHoodieParams(); - - boolean fallbackFullTableScan(); - - String getStartTs(); - - String getEndTs(); -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/MORIncrementalRelation.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/MORIncrementalRelation.java deleted file mode 100644 index 5b16186e10de57..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/MORIncrementalRelation.java +++ /dev/null @@ -1,206 +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.datasource.hudi.source; - -import org.apache.doris.spi.Split; - -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.fs.GlobPattern; -import org.apache.hudi.common.model.BaseFile; -import org.apache.hudi.common.model.FileSlice; -import org.apache.hudi.common.model.HoodieCommitMetadata; -import org.apache.hudi.common.table.HoodieTableMetaClient; -import org.apache.hudi.common.table.timeline.HoodieInstant; -import org.apache.hudi.common.table.timeline.HoodieTimeline; -import org.apache.hudi.common.table.timeline.TimelineUtils; -import org.apache.hudi.common.table.timeline.TimelineUtils.HollowCommitHandling; -import org.apache.hudi.common.table.view.HoodieTableFileSystemView; -import org.apache.hudi.exception.HoodieException; -import org.apache.hudi.hadoop.utils.HoodieInputFormatUtils; -import org.apache.hudi.metadata.HoodieTableMetadataUtil; -import org.apache.hudi.storage.StoragePathInfo; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -public class MORIncrementalRelation implements IncrementalRelation { - private final Map optParams; - private final HoodieTableMetaClient metaClient; - private final HoodieTimeline timeline; - private final HollowCommitHandling hollowCommitHandling; - private String startTimestamp; - private String endTimestamp; - private final boolean startInstantArchived; - private final boolean endInstantArchived; - private final List includedCommits; - private final List commitsMetadata; - private final List affectedFilesInCommits; - private final boolean fullTableScan; - private final String globPattern; - private final String startTs; - private final String endTs; - - - public MORIncrementalRelation(Map optParams, Configuration configuration, - HoodieTableMetaClient metaClient) - throws HoodieException, IOException { - this.optParams = optParams; - this.metaClient = metaClient; - timeline = metaClient.getCommitsAndCompactionTimeline().filterCompletedInstants(); - if (timeline.empty()) { - throw new HoodieException("No instants to incrementally pull"); - } - if (!metaClient.getTableConfig().populateMetaFields()) { - throw new HoodieException("Incremental queries are not supported when meta fields are disabled"); - } - hollowCommitHandling = HollowCommitHandling.valueOf( - optParams.getOrDefault("hoodie.read.timeline.holes.resolution.policy", "FAIL")); - - startTimestamp = optParams.get("hoodie.datasource.read.begin.instanttime"); - if (startTimestamp == null) { - throw new HoodieException("Specify the begin instant time to pull from using " - + "option hoodie.datasource.read.begin.instanttime"); - } - if (EARLIEST_TIME.equals(startTimestamp)) { - startTimestamp = "000"; - } - - String latestTime = hollowCommitHandling == HollowCommitHandling.USE_TRANSITION_TIME - ? timeline.lastInstant().get().getCompletionTime() - : timeline.lastInstant().get().requestedTime(); - endTimestamp = optParams.getOrDefault("hoodie.datasource.read.end.instanttime", latestTime); - if (LATEST_TIME.equals(latestTime)) { - endTimestamp = latestTime; - } - - startInstantArchived = timeline.isBeforeTimelineStarts(startTimestamp); - endInstantArchived = timeline.isBeforeTimelineStarts(endTimestamp); - - includedCommits = getIncludedCommits(); - commitsMetadata = getCommitsMetadata(); - affectedFilesInCommits = HoodieInputFormatUtils.listAffectedFilesForCommits(configuration, - metaClient.getBasePath(), commitsMetadata); - fullTableScan = shouldFullTableScan(); - if (hollowCommitHandling == HollowCommitHandling.USE_TRANSITION_TIME && fullTableScan) { - throw new HoodieException("Cannot use stateTransitionTime while enables full table scan"); - } - globPattern = optParams.getOrDefault("hoodie.datasource.read.incr.path.glob", ""); - - startTs = startTimestamp; - endTs = endTimestamp; - } - - @Override - public Map getHoodieParams() { - optParams.put("hoodie.datasource.read.begin.instanttime", startTs); - optParams.put("hoodie.datasource.read.end.instanttime", endTs); - return optParams; - } - - private List getIncludedCommits() { - if (!startInstantArchived || !endInstantArchived) { - // If endTimestamp commit is not archived, will filter instants - // before endTimestamp. - if (hollowCommitHandling == HollowCommitHandling.USE_TRANSITION_TIME) { - return timeline.findInstantsInRangeByCompletionTime(startTimestamp, endTimestamp).getInstants(); - } else { - return timeline.findInstantsInRange(startTimestamp, endTimestamp).getInstants(); - } - } else { - return timeline.getInstants(); - } - } - - private List getCommitsMetadata() throws IOException { - List result = new ArrayList<>(); - for (HoodieInstant commit : includedCommits) { - result.add(TimelineUtils.getCommitMetadata(commit, timeline)); - } - return result; - } - - private boolean shouldFullTableScan() throws IOException { - boolean should = Boolean.parseBoolean( - optParams.getOrDefault("hoodie.datasource.read.incr.fallback.fulltablescan.enable", "false")) && ( - startInstantArchived || endInstantArchived); - if (should) { - return true; - } - for (StoragePathInfo fileStatus : affectedFilesInCommits) { - if (!metaClient.getStorage().exists(fileStatus.getPath())) { - return true; - } - } - return false; - } - - @Override - public boolean fallbackFullTableScan() { - return fullTableScan; - } - - @Override - public String getStartTs() { - return startTs; - } - - @Override - public String getEndTs() { - return endTs; - } - - @Override - public List collectFileSlices() throws HoodieException { - if (includedCommits.isEmpty()) { - return Collections.emptyList(); - } else if (fullTableScan) { - throw new HoodieException("Fallback to full table scan"); - } - HoodieTimeline scanTimeline; - if (hollowCommitHandling == HollowCommitHandling.USE_TRANSITION_TIME) { - scanTimeline = metaClient.getCommitsAndCompactionTimeline() - .findInstantsInRangeByCompletionTime(startTimestamp, endTimestamp); - } else { - scanTimeline = TimelineUtils.handleHollowCommitIfNeeded( - metaClient.getCommitsAndCompactionTimeline(), metaClient, hollowCommitHandling) - .findInstantsInRange(startTimestamp, endTimestamp); - } - String latestCommit = includedCommits.get(includedCommits.size() - 1).requestedTime(); - HoodieTableFileSystemView fsView = new HoodieTableFileSystemView(metaClient, scanTimeline, - affectedFilesInCommits); - Stream fileSlices = HoodieTableMetadataUtil.getWritePartitionPaths(commitsMetadata) - .stream().flatMap(relativePartitionPath -> - fsView.getLatestMergedFileSlicesBeforeOrOn(relativePartitionPath, latestCommit)); - if ("".equals(globPattern)) { - return fileSlices.collect(Collectors.toList()); - } - GlobPattern globMatcher = new GlobPattern("*" + globPattern); - return fileSlices.filter(fileSlice -> globMatcher.matches(fileSlice.getBaseFile().map(BaseFile::getPath) - .or(fileSlice.getLatestLogFile().map(f -> f.getPath().toString())).get())).collect(Collectors.toList()); - } - - @Override - public List collectSplits() throws HoodieException { - throw new UnsupportedOperationException(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/DorisTypeToIcebergType.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/DorisTypeToIcebergType.java deleted file mode 100644 index de19d90728d606..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/DorisTypeToIcebergType.java +++ /dev/null @@ -1,143 +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.datasource.iceberg; - -import org.apache.doris.catalog.ArrayType; -import org.apache.doris.catalog.MapType; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.StructField; -import org.apache.doris.catalog.StructType; -import org.apache.doris.datasource.DorisTypeVisitor; - -import com.google.common.collect.Lists; -import org.apache.iceberg.types.Type; -import org.apache.iceberg.types.Types; - -import java.util.Collections; -import java.util.List; - - -/** - * Convert Doris type to Iceberg type - */ -public class DorisTypeToIcebergType extends DorisTypeVisitor { - private final StructType root; - private final List rootFieldNames; - private int nextId = 0; - - public DorisTypeToIcebergType() { - this.root = null; - this.rootFieldNames = Collections.emptyList(); - } - - public DorisTypeToIcebergType(StructType root) { - this(root, Collections.emptyList()); - } - - public DorisTypeToIcebergType(StructType root, List rootFieldNames) { - this.root = root; - this.rootFieldNames = rootFieldNames; - // the root struct's fields use the first ids - this.nextId = root.getFields().size(); - } - - private int getNextId() { - int next = nextId; - nextId += 1; - return next; - } - - @Override - public Type struct(StructType struct, List types) { - List fields = struct.getFields(); - List newFields = Lists.newArrayListWithExpectedSize(fields.size()); - boolean isRoot = root == struct; - for (int i = 0; i < fields.size(); i++) { - StructField field = fields.get(i); - Type type = types.get(i); - - int id = isRoot ? i : getNextId(); - String fieldName = isRoot && !rootFieldNames.isEmpty() ? rootFieldNames.get(i) : field.getName(); - if (field.getContainsNull()) { - newFields.add(Types.NestedField.optional(id, fieldName, type, field.getComment())); - } else { - newFields.add(Types.NestedField.required(id, fieldName, type, field.getComment())); - } - } - return Types.StructType.of(newFields); - } - - @Override - public Type field(StructField field, Type typeResult) { - return typeResult; - } - - @Override - public Type array(ArrayType array, Type elementType) { - if (array.getContainsNull()) { - return Types.ListType.ofOptional(getNextId(), elementType); - } else { - return Types.ListType.ofRequired(getNextId(), elementType); - } - } - - @Override - public Type map(MapType map, Type keyType, Type valueType) { - if (map.getIsValueContainsNull()) { - return Types.MapType.ofOptional(getNextId(), getNextId(), keyType, valueType); - } else { - return Types.MapType.ofRequired(getNextId(), getNextId(), keyType, valueType); - } - } - - @Override - public Type atomic(org.apache.doris.catalog.Type atomic) { - PrimitiveType primitiveType = atomic.getPrimitiveType(); - if (primitiveType.equals(PrimitiveType.BOOLEAN)) { - return Types.BooleanType.get(); - } else if (primitiveType.equals(PrimitiveType.INT)) { - return Types.IntegerType.get(); - } else if (primitiveType.equals(PrimitiveType.BIGINT)) { - return Types.LongType.get(); - } else if (primitiveType.equals(PrimitiveType.FLOAT)) { - return Types.FloatType.get(); - } else if (primitiveType.equals(PrimitiveType.DOUBLE)) { - return Types.DoubleType.get(); - } else if (primitiveType.isCharFamily()) { - return Types.StringType.get(); - } else if (primitiveType.equals(PrimitiveType.DATE) - || primitiveType.equals(PrimitiveType.DATEV2)) { - return Types.DateType.get(); - } else if (primitiveType.equals(PrimitiveType.DECIMALV2) - || primitiveType.isDecimalV3Type()) { - return Types.DecimalType.of( - ((ScalarType) atomic).getScalarPrecision(), - ((ScalarType) atomic).getScalarScale()); - } else if (primitiveType.equals(PrimitiveType.DATETIME) - || primitiveType.equals(PrimitiveType.DATETIMEV2)) { - return Types.TimestampType.withoutZone(); - } else if (primitiveType.equals(PrimitiveType.TIMESTAMPTZ)) { - return Types.TimestampType.withZone(); - } - // unsupported type: PrimitiveType.HLL BITMAP BINARY - - throw new UnsupportedOperationException( - "Not a supported type: " + primitiveType); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/HiveCompatibleCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/HiveCompatibleCatalog.java deleted file mode 100644 index 49123d2b8f463c..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/HiveCompatibleCatalog.java +++ /dev/null @@ -1,181 +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.datasource.iceberg; - -import org.apache.hadoop.conf.Configurable; -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.hive.metastore.IMetaStoreClient; -import org.apache.iceberg.BaseMetastoreCatalog; -import org.apache.iceberg.CatalogProperties; -import org.apache.iceberg.CatalogUtil; -import org.apache.iceberg.ClientPool; -import org.apache.iceberg.catalog.Namespace; -import org.apache.iceberg.catalog.SupportsNamespaces; -import org.apache.iceberg.catalog.TableIdentifier; -import org.apache.iceberg.exceptions.NamespaceNotEmptyException; -import org.apache.iceberg.exceptions.NoSuchNamespaceException; -import org.apache.iceberg.exceptions.NoSuchTableException; -import org.apache.iceberg.hadoop.HadoopFileIO; -import org.apache.iceberg.io.FileIO; -import shade.doris.hive.org.apache.thrift.TException; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -public abstract class HiveCompatibleCatalog extends BaseMetastoreCatalog implements SupportsNamespaces, Configurable { - - protected Configuration conf; - protected ClientPool clients; - protected FileIO fileIO; - protected String catalogName; - - public void initialize(String name, FileIO fileIO, - ClientPool clients) { - this.catalogName = name; - this.fileIO = fileIO; - this.clients = clients; - } - - protected FileIO initializeFileIO(Map properties, Configuration hadoopConf) { - String fileIOImpl = properties.get(CatalogProperties.FILE_IO_IMPL); - if (fileIOImpl == null) { - /* when use the S3FileIO, we need some custom configurations, - * so HadoopFileIO is used in the superclass by default - * we can add better implementations to derived class just like the implementation in DLFCatalog. - */ - FileIO io = new HadoopFileIO(hadoopConf); - io.initialize(properties); - return io; - } else { - return CatalogUtil.loadFileIO(fileIOImpl, properties, hadoopConf); - } - } - - @Override - protected String defaultWarehouseLocation(TableIdentifier tableIdentifier) { - return null; - } - - @Override - protected boolean isValidIdentifier(TableIdentifier tableIdentifier) { - return tableIdentifier.namespace().levels().length == 1; - } - - protected boolean isValidNamespace(Namespace namespace) { - return namespace.levels().length != 1; - } - - @Override - public List listTables(Namespace namespace) { - if (isValidNamespace(namespace)) { - throw new NoSuchTableException("Invalid namespace: %s", namespace); - } - String dbName = namespace.level(0); - try { - return clients.run(client -> client.getAllTables(dbName)) - .stream() - .map(tbl -> TableIdentifier.of(dbName, tbl)) - .collect(Collectors.toList()); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Override - public boolean dropTable(TableIdentifier tableIdentifier, boolean purge) { - throw new UnsupportedOperationException( - "Cannot drop table " + tableIdentifier + " : dropTable is not supported"); - } - - @Override - public void renameTable(TableIdentifier sourceTbl, TableIdentifier targetTbl) { - throw new UnsupportedOperationException( - "Cannot rename table " + sourceTbl + " : renameTable is not supported"); - } - - @Override - public void createNamespace(Namespace namespace, Map props) { - throw new UnsupportedOperationException( - "Cannot create namespace " + namespace + " : createNamespace is not supported"); - } - - @Override - public List listNamespaces(Namespace namespace) throws NoSuchNamespaceException { - if (isValidNamespace(namespace) && !namespace.isEmpty()) { - throw new NoSuchNamespaceException("Namespace does not exist: %s", namespace); - } - if (!namespace.isEmpty()) { - return new ArrayList<>(); - } - List namespaces = new ArrayList<>(); - List databases; - try { - databases = clients.run(client -> client.getAllDatabases()); - for (String database : databases) { - namespaces.add(Namespace.of(database)); - } - } catch (Exception e) { - throw new RuntimeException(e); - } - return namespaces; - } - - @Override - public Map loadNamespaceMetadata(Namespace namespace) throws NoSuchNamespaceException { - if (isValidNamespace(namespace)) { - throw new NoSuchTableException("Invalid namespace: %s", namespace); - } - String dbName = namespace.level(0); - try { - return clients.run(client -> client.getDatabase(dbName)).getParameters(); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Override - public boolean dropNamespace(Namespace namespace) throws NamespaceNotEmptyException { - throw new UnsupportedOperationException( - "Cannot drop namespace " + namespace + " : dropNamespace is not supported"); - } - - @Override - public boolean setProperties(Namespace namespace, Map props) throws NoSuchNamespaceException { - throw new UnsupportedOperationException( - "Cannot set namespace properties " + namespace + " : setProperties is not supported"); - } - - @Override - public boolean removeProperties(Namespace namespace, Set pNames) throws NoSuchNamespaceException { - throw new UnsupportedOperationException( - "Cannot remove properties " + namespace + " : removeProperties is not supported"); - } - - @Override - public void setConf(Configuration conf) { - this.conf = conf; - } - - @Override - public Configuration getConf() { - return conf; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergConflictDetectionFilterUtils.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergConflictDetectionFilterUtils.java deleted file mode 100644 index 5423e21895de2b..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergConflictDetectionFilterUtils.java +++ /dev/null @@ -1,336 +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.datasource.iceberg; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.common.UserException; -import org.apache.doris.nereids.trees.expressions.And; -import org.apache.doris.nereids.trees.expressions.Between; -import org.apache.doris.nereids.trees.expressions.EqualTo; -import org.apache.doris.nereids.trees.expressions.Expression; -import org.apache.doris.nereids.trees.expressions.GreaterThan; -import org.apache.doris.nereids.trees.expressions.GreaterThanEqual; -import org.apache.doris.nereids.trees.expressions.InPredicate; -import org.apache.doris.nereids.trees.expressions.IsNull; -import org.apache.doris.nereids.trees.expressions.LessThan; -import org.apache.doris.nereids.trees.expressions.LessThanEqual; -import org.apache.doris.nereids.trees.expressions.Not; -import org.apache.doris.nereids.trees.expressions.Or; -import org.apache.doris.nereids.trees.expressions.Slot; -import org.apache.doris.nereids.trees.expressions.SlotReference; -import org.apache.doris.nereids.trees.expressions.literal.Literal; -import org.apache.doris.nereids.trees.expressions.literal.NullLiteral; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.logical.LogicalFilter; - -import org.apache.iceberg.Schema; -import org.apache.iceberg.expressions.Expressions; -import org.apache.iceberg.types.Type; -import org.apache.iceberg.types.Types.NestedField; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.Set; - -/** - * Utilities for building Iceberg RowDelta conflict detection filters from Nereids plans. - */ -public final class IcebergConflictDetectionFilterUtils { - - private IcebergConflictDetectionFilterUtils() { - } - - public static Optional buildConflictDetectionFilter( - Plan analyzedPlan, IcebergExternalTable targetTable) { - if (analyzedPlan == null || targetTable == null) { - return Optional.empty(); - } - List targetConjuncts = new ArrayList<>(); - collectTargetConjuncts(analyzedPlan, targetTable, targetConjuncts); - if (targetConjuncts.isEmpty()) { - return Optional.empty(); - } - Schema schema = targetTable.getIcebergTable().schema(); - org.apache.iceberg.expressions.Expression combined = null; - for (Expression predicate : targetConjuncts) { - Optional icebergExpr = - convertPredicateToIcebergExpression(predicate, schema); - if (!icebergExpr.isPresent()) { - continue; - } - combined = combined == null ? icebergExpr.get() : Expressions.and(combined, icebergExpr.get()); - } - return combined == null ? Optional.empty() : Optional.of(combined); - } - - private static void collectTargetConjuncts(Plan plan, IcebergExternalTable targetTable, - List output) { - if (plan instanceof LogicalFilter) { - LogicalFilter filter = (LogicalFilter) plan; - for (Expression conjunct : filter.getConjuncts()) { - if (isTargetOnlyPredicate(conjunct, targetTable)) { - output.add(conjunct); - } - } - } - for (Plan child : plan.children()) { - collectTargetConjuncts(child, targetTable, output); - } - } - - private static boolean isTargetOnlyPredicate(Expression predicate, IcebergExternalTable targetTable) { - if (predicate == null) { - return false; - } - Set slots = predicate.getInputSlots(); - if (slots.isEmpty()) { - return false; - } - for (Slot slot : slots) { - if (!(slot instanceof SlotReference)) { - return false; - } - SlotReference slotReference = (SlotReference) slot; - if (Column.ICEBERG_ROWID_COL.equalsIgnoreCase(slotReference.getName())) { - return false; - } - if (IcebergMetadataColumn.isMetadataColumn(slotReference.getName())) { - return false; - } - Optional table = slotReference.getOriginalTable(); - if (!table.isPresent() || table.get().getId() != targetTable.getId()) { - return false; - } - } - return true; - } - - private static Optional convertPredicateToIcebergExpression( - Expression predicate, Schema schema) { - if (predicate == null) { - return Optional.empty(); - } - if (predicate instanceof And) { - And andExpr = (And) predicate; - Optional left = - convertPredicateToIcebergExpression(andExpr.child(0), schema); - Optional right = - convertPredicateToIcebergExpression(andExpr.child(1), schema); - return combineAnd(left, right); - } - if (predicate instanceof Or) { - Or orExpr = (Or) predicate; - Optional left = - convertPredicateToIcebergExpression(orExpr.child(0), schema); - Optional right = - convertPredicateToIcebergExpression(orExpr.child(1), schema); - if (!left.isPresent() || !right.isPresent()) { - return Optional.empty(); - } - if (!isSameColumnPredicate(orExpr.child(0), orExpr.child(1), schema)) { - return Optional.empty(); - } - return Optional.of(Expressions.or(left.get(), right.get())); - } - if (predicate instanceof Not) { - Not notExpr = (Not) predicate; - if (!(notExpr.child() instanceof IsNull)) { - return Optional.empty(); - } - return convertIsNullPredicate((IsNull) notExpr.child(), schema, true); - } - if (predicate instanceof IsNull) { - return convertIsNullPredicate((IsNull) predicate, schema, false); - } - if (predicate instanceof InPredicate) { - return convertInPredicate((InPredicate) predicate, schema); - } - if (predicate instanceof Between) { - return convertComparablePredicate((Between) predicate, schema); - } - if (predicate instanceof EqualTo - || predicate instanceof GreaterThan - || predicate instanceof GreaterThanEqual - || predicate instanceof LessThan - || predicate instanceof LessThanEqual) { - return convertComparablePredicate(predicate, schema); - } - return Optional.empty(); - } - - private static Optional convertIsNullPredicate( - IsNull predicate, Schema schema, boolean negated) { - Optional nestedField = resolveSingleField(predicate, schema); - if (!nestedField.isPresent()) { - return Optional.empty(); - } - if (isStructuralType(nestedField.get().type())) { - return Optional.empty(); - } - org.apache.iceberg.expressions.Expression isNullExpr = Expressions.isNull(nestedField.get().name()); - return Optional.of(negated ? Expressions.not(isNullExpr) : isNullExpr); - } - - private static Optional convertInPredicate( - InPredicate predicate, Schema schema) { - if (!(predicate.child(0) instanceof Slot)) { - return Optional.empty(); - } - Optional nestedField = resolveSingleField(predicate, schema); - if (!nestedField.isPresent()) { - return Optional.empty(); - } - Type type = nestedField.get().type(); - if (isStructuralType(type)) { - return Optional.empty(); - } - - boolean hasNull = false; - List values = new ArrayList<>(); - for (int i = 1; i < predicate.children().size(); i++) { - Expression child = predicate.child(i); - if (!(child instanceof Literal)) { - return Optional.empty(); - } - Literal literal = (Literal) child; - if (literal instanceof NullLiteral) { - hasNull = true; - continue; - } - try { - Object value = IcebergNereidsUtils.extractNereidsLiteralValue(literal, type); - if (value == null) { - return Optional.empty(); - } - values.add(value); - } catch (UserException ignored) { - return Optional.empty(); - } - } - - if (isUuidType(type) && !values.isEmpty()) { - return Optional.empty(); - } - - org.apache.iceberg.expressions.Expression valuesExpr = values.isEmpty() - ? null - : Expressions.in(nestedField.get().name(), values); - org.apache.iceberg.expressions.Expression nullExpr = hasNull - ? Expressions.isNull(nestedField.get().name()) - : null; - return combineOr(nullExpr, valuesExpr); - } - - private static Optional convertComparablePredicate( - Expression predicate, Schema schema) { - Optional nestedField = resolveSingleField(predicate, schema); - if (!nestedField.isPresent()) { - return Optional.empty(); - } - Type type = nestedField.get().type(); - if (isStructuralType(type)) { - return Optional.empty(); - } - if (isUuidType(type)) { - return isNullComparison(predicate) - ? Optional.of(Expressions.isNull(nestedField.get().name())) - : Optional.empty(); - } - try { - return Optional.of(IcebergNereidsUtils.convertNereidsToIcebergExpression(predicate, schema)); - } catch (UserException ignored) { - return Optional.empty(); - } - } - - private static boolean isNullComparison(Expression predicate) { - if (!(predicate instanceof EqualTo)) { - return false; - } - Expression left = predicate.child(0); - Expression right = predicate.child(1); - return (left instanceof Slot && right instanceof NullLiteral) - || (right instanceof Slot && left instanceof NullLiteral); - } - - private static Optional resolveSingleField(Expression predicate, Schema schema) { - Set slots = predicate.getInputSlots(); - if (slots.size() != 1) { - return Optional.empty(); - } - Slot slot = slots.iterator().next(); - if (!(slot instanceof SlotReference)) { - return Optional.empty(); - } - String columnName = ((SlotReference) slot).getName(); - if (Column.ICEBERG_ROWID_COL.equalsIgnoreCase(columnName)) { - return Optional.empty(); - } - if (IcebergMetadataColumn.isMetadataColumn(columnName)) { - return Optional.empty(); - } - NestedField nestedField = schema.caseInsensitiveFindField(columnName); - return Optional.ofNullable(nestedField); - } - - private static boolean isSameColumnPredicate(Expression left, Expression right, Schema schema) { - Optional leftField = resolveSingleField(left, schema); - if (!leftField.isPresent()) { - return false; - } - Optional rightField = resolveSingleField(right, schema); - if (!rightField.isPresent()) { - return false; - } - return leftField.get().fieldId() == rightField.get().fieldId(); - } - - private static Optional combineAnd( - Optional left, - Optional right) { - if (!left.isPresent()) { - return right; - } - if (!right.isPresent()) { - return left; - } - return Optional.of(Expressions.and(left.get(), right.get())); - } - - private static Optional combineOr( - org.apache.iceberg.expressions.Expression left, - org.apache.iceberg.expressions.Expression right) { - if (left == null) { - return Optional.ofNullable(right); - } - if (right == null) { - return Optional.of(left); - } - return Optional.of(Expressions.or(left, right)); - } - - private static boolean isStructuralType(Type type) { - return type.isStructType() || type.isListType() || type.isMapType(); - } - - private static boolean isUuidType(Type type) { - return type.typeId() == Type.TypeID.UUID; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergDLFExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergDLFExternalCatalog.java deleted file mode 100644 index 1eecf032c68812..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergDLFExternalCatalog.java +++ /dev/null @@ -1,65 +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.datasource.iceberg; - -import org.apache.doris.catalog.info.PartitionNamesInfo; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.CatalogProperty; -import org.apache.doris.datasource.property.metastore.HMSBaseProperties; -import org.apache.doris.nereids.exceptions.NotSupportedException; -import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo; - -import java.util.Map; - -public class IcebergDLFExternalCatalog extends IcebergExternalCatalog { - - public IcebergDLFExternalCatalog(long catalogId, String name, String resource, Map props, - String comment) { - super(catalogId, name, comment); - props.put(HMSBaseProperties.HIVE_METASTORE_TYPE, "dlf"); - catalogProperty = new CatalogProperty(resource, props); - } - - @Override - public void createDb(String dbName, boolean ifNotExists, Map properties) throws DdlException { - throw new NotSupportedException("iceberg catalog with dlf type not supports 'create database'"); - } - - @Override - public void dropDb(String dbName, boolean ifExists, boolean force) throws DdlException { - throw new NotSupportedException("iceberg catalog with dlf type not supports 'drop database'"); - } - - @Override - public boolean createTable(CreateTableInfo createTableInfo) throws UserException { - throw new NotSupportedException("iceberg catalog with dlf type not supports 'create table'"); - } - - @Override - public void dropTable(String dbName, String tableName, boolean isView, boolean isMtmv, boolean isStream, - boolean ifExists, boolean mustTemporary, boolean force) throws DdlException { - throw new NotSupportedException("iceberg catalog with dlf type not supports 'drop table'"); - } - - @Override - public void truncateTable(String dbName, String tableName, PartitionNamesInfo partitionNamesInfo, boolean forceDrop, - String rawTruncateSql) throws DdlException { - throw new NotSupportedException("iceberg catalog with dlf type not supports 'truncate table'"); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergDelegatedCredentialUtils.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergDelegatedCredentialUtils.java deleted file mode 100644 index 0f4ea72ad4b105..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergDelegatedCredentialUtils.java +++ /dev/null @@ -1,43 +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.datasource.iceberg; - -import org.apache.doris.datasource.DelegatedCredential; - -import org.apache.iceberg.rest.auth.OAuth2Properties; - -public final class IcebergDelegatedCredentialUtils { - - private IcebergDelegatedCredentialUtils() { - } - - public static String credentialKey(DelegatedCredential.Type type) { - switch (type) { - case ACCESS_TOKEN: - return OAuth2Properties.TOKEN; - case ID_TOKEN: - return OAuth2Properties.ID_TOKEN_TYPE; - case JWT: - return OAuth2Properties.JWT_TOKEN_TYPE; - case SAML: - return OAuth2Properties.SAML2_TOKEN_TYPE; - default: - throw new IllegalArgumentException("Unsupported delegated credential type: " + type); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalCatalog.java deleted file mode 100644 index 9fb1acc235acf9..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalCatalog.java +++ /dev/null @@ -1,251 +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.datasource.iceberg; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.ThreadPoolManager; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalObjectLog; -import org.apache.doris.datasource.InitCatalogLog; -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.datasource.metacache.CacheSpec; -import org.apache.doris.datasource.property.metastore.AbstractIcebergProperties; -import org.apache.doris.nereids.trees.plans.commands.info.AddPartitionFieldOp; -import org.apache.doris.nereids.trees.plans.commands.info.DropPartitionFieldOp; -import org.apache.doris.nereids.trees.plans.commands.info.ReplacePartitionFieldOp; -import org.apache.doris.transaction.TransactionManagerFactory; - -import org.apache.iceberg.catalog.Catalog; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.List; -import java.util.Map; - -public abstract class IcebergExternalCatalog extends ExternalCatalog { - - private static final Logger LOG = LogManager.getLogger(IcebergExternalCatalog.class); - public static final String ICEBERG_CATALOG_TYPE = "iceberg.catalog.type"; - public static final String ICEBERG_REST = "rest"; - public static final String ICEBERG_HMS = "hms"; - public static final String ICEBERG_HADOOP = "hadoop"; - public static final String ICEBERG_GLUE = "glue"; - public static final String ICEBERG_DLF = "dlf"; - public static final String ICEBERG_JDBC = "jdbc"; - public static final String ICEBERG_S3_TABLES = "s3tables"; - public static final String EXTERNAL_CATALOG_NAME = "external_catalog.name"; - public static final String ICEBERG_TABLE_CACHE_ENABLE = "meta.cache.iceberg.table.enable"; - public static final String ICEBERG_TABLE_CACHE_TTL_SECOND = "meta.cache.iceberg.table.ttl-second"; - public static final String ICEBERG_TABLE_CACHE_CAPACITY = "meta.cache.iceberg.table.capacity"; - public static final String ICEBERG_MANIFEST_CACHE_ENABLE = "meta.cache.iceberg.manifest.enable"; - public static final String ICEBERG_MANIFEST_CACHE_TTL_SECOND = "meta.cache.iceberg.manifest.ttl-second"; - public static final String ICEBERG_MANIFEST_CACHE_CAPACITY = "meta.cache.iceberg.manifest.capacity"; - public static final boolean DEFAULT_ICEBERG_MANIFEST_CACHE_ENABLE = false; - public static final long DEFAULT_ICEBERG_MANIFEST_CACHE_CAPACITY = 1024; - public static final long DEFAULT_ICEBERG_MANIFEST_CACHE_TTL_SECOND = 48 * 60 * 60; - protected String icebergCatalogType; - protected Catalog catalog; - - private AbstractIcebergProperties msProperties; - - public IcebergExternalCatalog(long catalogId, String name, String comment) { - super(catalogId, name, InitCatalogLog.Type.ICEBERG, comment); - } - - // Create catalog based on catalog type - protected void initCatalog() { - try { - msProperties = (AbstractIcebergProperties) catalogProperty.getMetastoreProperties(); - this.catalog = msProperties.initializeCatalog(getName(), catalogProperty.getOrderedStoragePropertiesList(), - getCatalogInitializationSessionContext()); - this.icebergCatalogType = msProperties.getIcebergCatalogType(); - } catch (ClassCastException e) { - throw new RuntimeException("Invalid properties for Iceberg catalog: " + getProperties(), e); - } catch (Exception e) { - throw new RuntimeException("Unexpected error while initializing Iceberg catalog: " + e.getMessage(), e); - } - } - - protected SessionContext getCatalogInitializationSessionContext() { - return SessionContext.empty(); - } - - @Override - public void checkProperties() throws DdlException { - super.checkProperties(); - CacheSpec.checkBooleanProperty(catalogProperty.getOrDefault(ICEBERG_TABLE_CACHE_ENABLE, null), - ICEBERG_TABLE_CACHE_ENABLE); - CacheSpec.checkLongProperty(catalogProperty.getOrDefault(ICEBERG_TABLE_CACHE_TTL_SECOND, null), - -1L, ICEBERG_TABLE_CACHE_TTL_SECOND); - CacheSpec.checkLongProperty(catalogProperty.getOrDefault(ICEBERG_TABLE_CACHE_CAPACITY, null), - 0L, ICEBERG_TABLE_CACHE_CAPACITY); - - CacheSpec.checkBooleanProperty(catalogProperty.getOrDefault(ICEBERG_MANIFEST_CACHE_ENABLE, null), - ICEBERG_MANIFEST_CACHE_ENABLE); - CacheSpec.checkLongProperty(catalogProperty.getOrDefault(ICEBERG_MANIFEST_CACHE_TTL_SECOND, null), - -1L, ICEBERG_MANIFEST_CACHE_TTL_SECOND); - CacheSpec.checkLongProperty(catalogProperty.getOrDefault(ICEBERG_MANIFEST_CACHE_CAPACITY, null), - 0L, ICEBERG_MANIFEST_CACHE_CAPACITY); - catalogProperty.checkMetaStoreAndStorageProperties(AbstractIcebergProperties.class); - } - - @Override - public void notifyPropertiesUpdated(Map updatedProps) { - super.notifyPropertiesUpdated(updatedProps); - if (updatedProps.keySet().stream() - .anyMatch(key -> CacheSpec.isMetaCacheKeyForEngine(key, IcebergExternalMetaCache.ENGINE))) { - Env.getCurrentEnv().getExtMetaCacheMgr() - .removeCatalogByEngine(getId(), IcebergExternalMetaCache.ENGINE); - } - } - - @Override - protected synchronized void initPreExecutionAuthenticator() { - if (executionAuthenticator == null) { - executionAuthenticator = msProperties.getExecutionAuthenticator(); - } - } - - @Override - protected void initLocalObjectsImpl() { - initCatalog(); - initPreExecutionAuthenticator(); - IcebergMetadataOps ops = new IcebergMetadataOps(this, catalog); - transactionManager = TransactionManagerFactory.createIcebergTransactionManager(ops); - threadPoolWithPreAuth = ThreadPoolManager.newDaemonFixedThreadPoolWithPreAuth( - ICEBERG_CATALOG_EXECUTOR_THREAD_NUM, - Integer.MAX_VALUE, - String.format("iceberg_catalog_%s_executor_pool", name), - true, - executionAuthenticator); - metadataOps = ops; - } - - /** - * Returns the underlying {@link Catalog} instance used by this external catalog. - * - *

    Warning: This method does not handle any authentication logic. If the - * returned catalog implementation relies on external systems - * that require authentication — especially in environments where Kerberos is enabled — the caller is - * fully responsible for ensuring the appropriate authentication has been performed before - * invoking this method. - *

    Failing to authenticate beforehand may result in authorization errors or IO failures. - * - * @return the underlying catalog instance - */ - public Catalog getCatalog() { - makeSureInitialized(); - return ((IcebergMetadataOps) metadataOps).getCatalog(); - } - - public String getIcebergCatalogType() { - makeSureInitialized(); - return icebergCatalogType; - } - - @Override - public boolean tableExist(SessionContext ctx, String dbName, String tblName) { - makeSureInitialized(); - return ((IcebergMetadataOps) metadataOps).tableExist(ctx, dbName, tblName); - } - - @Override - protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { - // On the Doris side, the result of SHOW TABLES for Iceberg external tables includes both tables and views, - // so the combined set of tables and views is used here. - IcebergMetadataOps ops = (IcebergMetadataOps) metadataOps; - List tableNames = ops.listTableNames(ctx, dbName); - List viewNames = ops.listViewNames(ctx, dbName); - tableNames.addAll(viewNames); - return tableNames; - } - - @Override - public void onClose() { - super.onClose(); - if (null != catalog) { - try { - if (catalog instanceof AutoCloseable) { - ((AutoCloseable) catalog).close(); - } - catalog = null; - } catch (Exception e) { - LOG.warn("Failed to close iceberg catalog: {}", getName(), e); - } - } - } - - @Override - public boolean viewExists(String dbName, String viewName) { - return metadataOps.viewExists(dbName, viewName); - } - - public boolean viewExists(SessionContext ctx, String dbName, String viewName) { - return ((IcebergMetadataOps) metadataOps).viewExists(ctx, dbName, viewName); - } - - /** - * Add partition field to Iceberg table for partition evolution - */ - public void addPartitionField(IcebergExternalTable table, AddPartitionFieldOp op, long updateTime) - throws UserException { - makeSureInitialized(); - if (metadataOps == null) { - throw new UserException("Add partition field operation is not supported for catalog: " + getName()); - } - ((IcebergMetadataOps) metadataOps).addPartitionField(table, op, updateTime); - Env.getCurrentEnv().getEditLog() - .logRefreshExternalTable( - ExternalObjectLog.createForRefreshTable(table.getCatalog().getId(), - table.getDbName(), table.getName(), updateTime)); - } - - /** - * Drop partition field from Iceberg table for partition evolution - */ - public void dropPartitionField(IcebergExternalTable table, DropPartitionFieldOp op, long updateTime) - throws UserException { - makeSureInitialized(); - if (metadataOps == null) { - throw new UserException("Drop partition field operation is not supported for catalog: " + getName()); - } - ((IcebergMetadataOps) metadataOps).dropPartitionField(table, op, updateTime); - Env.getCurrentEnv().getEditLog() - .logRefreshExternalTable( - ExternalObjectLog.createForRefreshTable(table.getCatalog().getId(), - table.getDbName(), table.getName(), updateTime)); - } - - /** - * Replace partition field in Iceberg table for partition evolution - */ - public void replacePartitionField(IcebergExternalTable table, - ReplacePartitionFieldOp op, long updateTime) throws UserException { - makeSureInitialized(); - if (metadataOps == null) { - throw new UserException("Replace partition field operation is not supported for catalog: " + getName()); - } - ((IcebergMetadataOps) metadataOps).replacePartitionField(table, op, updateTime); - Env.getCurrentEnv().getEditLog() - .logRefreshExternalTable( - ExternalObjectLog.createForRefreshTable(table.getCatalog().getId(), - table.getDbName(), table.getName(), updateTime)); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalCatalogFactory.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalCatalogFactory.java deleted file mode 100644 index 824d20e70007ba..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalCatalogFactory.java +++ /dev/null @@ -1,53 +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.datasource.iceberg; - -import org.apache.doris.common.DdlException; -import org.apache.doris.datasource.ExternalCatalog; - -import java.util.Map; - -public class IcebergExternalCatalogFactory { - - public static ExternalCatalog createCatalog(long catalogId, String name, String resource, Map props, - String comment) throws DdlException { - String catalogType = props.get(IcebergExternalCatalog.ICEBERG_CATALOG_TYPE); - if (catalogType == null) { - throw new DdlException("Missing " + IcebergExternalCatalog.ICEBERG_CATALOG_TYPE + " property"); - } - switch (catalogType) { - case IcebergExternalCatalog.ICEBERG_REST: - return new IcebergRestExternalCatalog(catalogId, name, resource, props, comment); - case IcebergExternalCatalog.ICEBERG_HMS: - return new IcebergHMSExternalCatalog(catalogId, name, resource, props, comment); - case IcebergExternalCatalog.ICEBERG_GLUE: - return new IcebergGlueExternalCatalog(catalogId, name, resource, props, comment); - case IcebergExternalCatalog.ICEBERG_DLF: - return new IcebergDLFExternalCatalog(catalogId, name, resource, props, comment); - case IcebergExternalCatalog.ICEBERG_JDBC: - return new IcebergJdbcExternalCatalog(catalogId, name, resource, props, comment); - case IcebergExternalCatalog.ICEBERG_HADOOP: - return new IcebergHadoopExternalCatalog(catalogId, name, resource, props, comment); - case IcebergExternalCatalog.ICEBERG_S3_TABLES: - return new IcebergS3TablesExternalCatalog(catalogId, name, resource, props, comment); - default: - throw new DdlException("Unknown " + IcebergExternalCatalog.ICEBERG_CATALOG_TYPE - + " value: " + catalogType); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalDatabase.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalDatabase.java deleted file mode 100644 index cfc50f3222b1f5..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalDatabase.java +++ /dev/null @@ -1,54 +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.datasource.iceberg; - -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.InitDatabaseLog; - -import org.apache.iceberg.catalog.Namespace; -import org.apache.iceberg.catalog.SupportsNamespaces; - -import java.util.Map; - -public class IcebergExternalDatabase extends ExternalDatabase { - - public IcebergExternalDatabase(ExternalCatalog extCatalog, Long id, String name, String remoteName) { - super(extCatalog, id, name, remoteName, InitDatabaseLog.Type.ICEBERG); - } - - @Override - public IcebergExternalTable buildTableInternal(String remoteTableName, String localTableName, long tblId, - ExternalCatalog catalog, - ExternalDatabase db) { - return new IcebergExternalTable(tblId, localTableName, remoteTableName, (IcebergExternalCatalog) extCatalog, - (IcebergExternalDatabase) db); - } - - public String getLocation() { - try { - return extCatalog.getExecutionAuthenticator().execute(() -> { - Map props = ((SupportsNamespaces) ((IcebergExternalCatalog) getCatalog()).getCatalog()) - .loadNamespaceMetadata(Namespace.of(name)); - return props.getOrDefault("location", ""); - }); - } catch (Exception e) { - throw new RuntimeException("Failed to get location for Iceberg database: " + name, e); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalMetaCache.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalMetaCache.java deleted file mode 100644 index bd16057dfd9569..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalMetaCache.java +++ /dev/null @@ -1,289 +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.datasource.iceberg; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.datasource.CacheException; -import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.iceberg.cache.ManifestCacheValue; -import org.apache.doris.datasource.metacache.AbstractExternalMetaCache; -import org.apache.doris.datasource.metacache.CacheSpec; -import org.apache.doris.datasource.metacache.MetaCacheEntry; -import org.apache.doris.datasource.metacache.MetaCacheEntryDef; -import org.apache.doris.datasource.metacache.MetaCacheEntryInvalidation; -import org.apache.doris.mtmv.MTMVRelatedTableIf; - -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.apache.iceberg.ManifestContent; -import org.apache.iceberg.ManifestFiles; -import org.apache.iceberg.ManifestReader; -import org.apache.iceberg.Snapshot; -import org.apache.iceberg.Table; -import org.apache.iceberg.view.View; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.IOException; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.function.Consumer; - -/** - * Iceberg engine implementation of {@link AbstractExternalMetaCache}. - * - *

    Registered entries: - *

      - *
    • {@code table}: loaded Iceberg {@link Table} instances per Doris table mapping, each - * memoizing its latest snapshot runtime projection
    • - *
    • {@code view}: loaded Iceberg {@link View} instances
    • - *
    • {@code manifest}: parsed manifest payload ({@link ManifestCacheValue}) keyed by - * manifest path and content type
    • - *
    • {@code schema}: schema cache keyed by table identity + schema id
    • - *
    - * - *

    Manifest entry keys are path-based and intentionally not table-scoped. This allows - * shared manifests to reuse one cache entry across tables in the same catalog. - * - *

    Invalidation behavior: - *

      - *
    • catalog invalidation clears all entries and drops Iceberg {@link ManifestFiles} IO cache
    • - *
    • db/table invalidation clears table/view/schema entries, while keeping manifest entries
    • - *
    • partition-level invalidation falls back to table-level invalidation
    • - *
    - */ -public class IcebergExternalMetaCache extends AbstractExternalMetaCache { - private static final Logger LOG = LogManager.getLogger(IcebergExternalMetaCache.class); - - public static final String ENGINE = "iceberg"; - public static final String ENTRY_TABLE = "table"; - public static final String ENTRY_VIEW = "view"; - public static final String ENTRY_MANIFEST = "manifest"; - public static final String ENTRY_SCHEMA = "schema"; - private static final long DEFAULT_MANIFEST_CACHE_CAPACITY = 100_000L; - - private final EntryHandle tableEntry; - private final EntryHandle viewEntry; - private final EntryHandle manifestEntry; - private final EntryHandle schemaEntry; - - public IcebergExternalMetaCache(ExecutorService refreshExecutor) { - super(ENGINE, refreshExecutor); - tableEntry = registerEntry(MetaCacheEntryDef.of(ENTRY_TABLE, NameMapping.class, IcebergTableCacheValue.class, - this::loadTableCacheValue, defaultEntryCacheSpec(), - MetaCacheEntryInvalidation.forNameMapping(nameMapping -> nameMapping))); - viewEntry = registerEntry(MetaCacheEntryDef.of(ENTRY_VIEW, NameMapping.class, View.class, this::loadView, - defaultEntryCacheSpec(), MetaCacheEntryInvalidation.forNameMapping(nameMapping -> nameMapping))); - manifestEntry = registerEntry(MetaCacheEntryDef.contextualOnly(ENTRY_MANIFEST, IcebergManifestEntryKey.class, - ManifestCacheValue.class, - CacheSpec.of(false, CacheSpec.CACHE_NO_TTL, DEFAULT_MANIFEST_CACHE_CAPACITY))); - schemaEntry = registerEntry(MetaCacheEntryDef.of(ENTRY_SCHEMA, IcebergSchemaCacheKey.class, - SchemaCacheValue.class, this::loadSchemaCacheValue, defaultSchemaCacheSpec(), - MetaCacheEntryInvalidation.forNameMapping(IcebergSchemaCacheKey::getNameMapping))); - } - - public Table getIcebergTable(ExternalTable dorisTable) { - NameMapping nameMapping = dorisTable.getOrBuildNameMapping(); - return tableEntry.get(nameMapping.getCtlId()).get(nameMapping).getIcebergTable(); - } - - public IcebergSnapshotCacheValue getSnapshotCache(ExternalTable dorisTable) { - NameMapping nameMapping = dorisTable.getOrBuildNameMapping(); - return tableEntry.get(nameMapping.getCtlId()).get(nameMapping).getLatestSnapshotCacheValue(); - } - - public List getSnapshotList(ExternalTable dorisTable) { - Table icebergTable = getIcebergTable(dorisTable); - List snapshots = com.google.common.collect.Lists.newArrayList(); - com.google.common.collect.Iterables.addAll(snapshots, icebergTable.snapshots()); - return snapshots; - } - - public View getIcebergView(ExternalTable dorisTable) { - NameMapping nameMapping = dorisTable.getOrBuildNameMapping(); - return viewEntry.get(nameMapping.getCtlId()).get(nameMapping); - } - - public IcebergSchemaCacheValue getIcebergSchemaCacheValue(NameMapping nameMapping, long schemaId) { - IcebergSchemaCacheKey key = new IcebergSchemaCacheKey(nameMapping, schemaId); - SchemaCacheValue schemaCacheValue = schemaEntry.get(nameMapping.getCtlId()).get(key); - return (IcebergSchemaCacheValue) schemaCacheValue; - } - - public ManifestCacheValue getManifestCacheValue(ExternalTable dorisTable, - org.apache.iceberg.ManifestFile manifest, - Table icebergTable, - Consumer cacheHitRecorder) { - NameMapping nameMapping = dorisTable.getOrBuildNameMapping(); - MetaCacheEntry manifestEntry = - this.manifestEntry.get(nameMapping.getCtlId()); - IcebergManifestEntryKey key = IcebergManifestEntryKey.of(manifest); - boolean hit = manifestEntry.getIfPresent(key) != null; - if (cacheHitRecorder != null) { - cacheHitRecorder.accept(hit); - } - return manifestEntry.get(key, ignored -> loadManifestCacheValue(manifest, icebergTable, key.getContent())); - } - - @Override - public void invalidateCatalog(long catalogId) { - dropManifestFileIoCacheForCatalog(catalogId); - super.invalidateCatalog(catalogId); - } - - @Override - public void invalidateCatalogEntries(long catalogId) { - dropManifestFileIoCacheForCatalog(catalogId); - super.invalidateCatalogEntries(catalogId); - } - - private IcebergTableCacheValue loadTableCacheValue(NameMapping nameMapping) { - CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(nameMapping.getCtlId()); - if (catalog == null) { - throw new RuntimeException(String.format("Cannot find catalog %d when loading table %s/%s.", - nameMapping.getCtlId(), nameMapping.getLocalDbName(), nameMapping.getLocalTblName())); - } - - IcebergMetadataOps ops = resolveMetadataOps(catalog); - try { - Table table = ((ExternalCatalog) catalog).getExecutionAuthenticator() - .execute(() -> ops.loadTable(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName())); - ExternalTable dorisTable = findExternalTable(nameMapping, ENGINE); - return new IcebergTableCacheValue(table, () -> loadSnapshotProjection(dorisTable, table)); - } catch (Exception e) { - throw new RuntimeException(ExceptionUtils.getRootCauseMessage(e), e); - } - } - - private View loadView(NameMapping nameMapping) { - CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(nameMapping.getCtlId()); - if (!(catalog instanceof IcebergExternalCatalog)) { - return null; - } - IcebergMetadataOps ops = (IcebergMetadataOps) (((IcebergExternalCatalog) catalog).getMetadataOps()); - try { - return (View) ((ExternalCatalog) catalog).getExecutionAuthenticator().execute( - () -> ops.loadView(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName())); - } catch (Exception e) { - throw new RuntimeException(ExceptionUtils.getRootCauseMessage(e), e); - } - } - - private ManifestCacheValue loadManifestCacheValue(org.apache.iceberg.ManifestFile manifest, Table icebergTable, - ManifestContent content) { - if (manifest == null || icebergTable == null) { - String manifestPath = manifest == null ? "null" : manifest.path(); - throw new CacheException("Manifest cache loader context is missing for %s", - null, manifestPath); - } - try { - if (content == ManifestContent.DELETES) { - return ManifestCacheValue.forDeleteFiles( - loadDeleteFiles(manifest, icebergTable)); - } - return ManifestCacheValue.forDataFiles(loadDataFiles(manifest, icebergTable)); - } catch (IOException e) { - throw new CacheException("Failed to read manifest %s", e, manifest.path()); - } - } - - private SchemaCacheValue loadSchemaCacheValue(IcebergSchemaCacheKey key) { - ExternalTable dorisTable = findExternalTable(key.getNameMapping(), ENGINE); - return dorisTable.initSchemaAndUpdateTime(key).orElseThrow(() -> - new CacheException("failed to load iceberg schema cache value for: %s.%s.%s, schemaId: %s", - null, key.getNameMapping().getCtlId(), key.getNameMapping().getLocalDbName(), - key.getNameMapping().getLocalTblName(), key.getSchemaId())); - } - - private IcebergSnapshotCacheValue loadSnapshotProjection(ExternalTable dorisTable, Table icebergTable) { - if (!(dorisTable instanceof MTMVRelatedTableIf)) { - throw new RuntimeException(String.format("Table %s.%s is not a valid MTMV related table.", - dorisTable.getDbName(), dorisTable.getName())); - } - try { - MTMVRelatedTableIf table = (MTMVRelatedTableIf) dorisTable; - IcebergSnapshot latestIcebergSnapshot = IcebergUtils.getLatestIcebergSnapshot(icebergTable); - IcebergPartitionInfo icebergPartitionInfo; - if (!table.isValidRelatedTable()) { - icebergPartitionInfo = IcebergPartitionInfo.empty(); - } else { - icebergPartitionInfo = IcebergUtils.loadPartitionInfo(dorisTable, icebergTable, - latestIcebergSnapshot.getSnapshotId(), latestIcebergSnapshot.getSchemaId()); - } - return new IcebergSnapshotCacheValue(icebergPartitionInfo, latestIcebergSnapshot); - } catch (AnalysisException e) { - throw new RuntimeException(ExceptionUtils.getRootCauseMessage(e), e); - } - } - - private IcebergMetadataOps resolveMetadataOps(CatalogIf catalog) { - if (catalog instanceof HMSExternalCatalog) { - return ((HMSExternalCatalog) catalog).getIcebergMetadataOps(); - } else if (catalog instanceof IcebergExternalCatalog) { - return (IcebergMetadataOps) (((IcebergExternalCatalog) catalog).getMetadataOps()); - } - throw new RuntimeException("Only support 'hms' and 'iceberg' type for iceberg table"); - } - - @Override - protected Map catalogPropertyCompatibilityMap() { - return singleCompatibilityMap(ExternalCatalog.SCHEMA_CACHE_TTL_SECOND, ENTRY_SCHEMA); - } - - private List loadDataFiles(org.apache.iceberg.ManifestFile manifest, Table table) - throws IOException { - List dataFiles = com.google.common.collect.Lists.newArrayList(); - try (ManifestReader reader = ManifestFiles.read(manifest, table.io())) { - for (org.apache.iceberg.DataFile dataFile : reader) { - dataFiles.add(dataFile.copy()); - } - } - return dataFiles; - } - - private List loadDeleteFiles(org.apache.iceberg.ManifestFile manifest, Table table) - throws IOException { - List deleteFiles = com.google.common.collect.Lists.newArrayList(); - try (ManifestReader reader = ManifestFiles.readDeleteManifest(manifest, - table.io(), table.specs())) { - for (org.apache.iceberg.DeleteFile deleteFile : reader) { - deleteFiles.add(deleteFile.copy()); - } - } - return deleteFiles; - } - - private void dropManifestFileIoCacheForCatalog(long catalogId) { - tableEntry.get(catalogId).forEach((key, value) -> dropManifestFileIoCache(value)); - } - - private void dropManifestFileIoCache(IcebergTableCacheValue tableCacheValue) { - try { - ManifestFiles.dropCache(tableCacheValue.getIcebergTable().io()); - } catch (Exception e) { - LOG.warn("Failed to drop iceberg manifest files cache", e); - } - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalTable.java deleted file mode 100644 index 008c12582d78c5..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalTable.java +++ /dev/null @@ -1,524 +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.datasource.iceberg; - -import org.apache.doris.analysis.TableScanParams; -import org.apache.doris.analysis.TableSnapshot; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.MTMV; -import org.apache.doris.catalog.PartitionItem; -import org.apache.doris.catalog.PartitionType; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.util.SqlUtils; -import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.SchemaCacheKey; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.datasource.mvcc.EmptyMvccSnapshot; -import org.apache.doris.datasource.mvcc.MvccSnapshot; -import org.apache.doris.datasource.mvcc.MvccTable; -import org.apache.doris.datasource.mvcc.MvccUtil; -import org.apache.doris.datasource.systable.IcebergSysTable; -import org.apache.doris.datasource.systable.SysTable; -import org.apache.doris.mtmv.MTMVBaseTableIf; -import org.apache.doris.mtmv.MTMVRefreshContext; -import org.apache.doris.mtmv.MTMVRelatedTableIf; -import org.apache.doris.mtmv.MTMVSnapshotIdSnapshot; -import org.apache.doris.mtmv.MTMVSnapshotIf; -import org.apache.doris.nereids.trees.plans.commands.info.SortFieldInfo; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.statistics.AnalysisInfo; -import org.apache.doris.statistics.BaseAnalysisTask; -import org.apache.doris.statistics.ExternalAnalysisTask; -import org.apache.doris.thrift.THiveTable; -import org.apache.doris.thrift.TIcebergTable; -import org.apache.doris.thrift.TTableDescriptor; -import org.apache.doris.thrift.TTableType; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.collect.Maps; -import com.google.common.collect.Sets; -import org.apache.commons.lang3.StringUtils; -import org.apache.iceberg.PartitionField; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Table; -import org.apache.iceberg.view.SQLViewRepresentation; -import org.apache.iceberg.view.View; -import org.apache.iceberg.view.ViewVersion; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -public class IcebergExternalTable extends ExternalTable implements MTMVRelatedTableIf, MTMVBaseTableIf, MvccTable { - - private boolean isValidRelatedTableCached = false; - private boolean isValidRelatedTable = false; - private boolean isView; - private static final String ENGINE_PROP_NAME = "engine-name"; - private static final String TABLE_COMMENT_PROP = "comment"; - - public IcebergExternalTable(long id, String name, String remoteName, IcebergExternalCatalog catalog, - IcebergExternalDatabase db) { - super(id, name, remoteName, catalog, db, TableType.ICEBERG_EXTERNAL_TABLE); - } - - @Override - public String getMetaCacheEngine() { - return IcebergExternalMetaCache.ENGINE; - } - - public String getIcebergCatalogType() { - return ((IcebergExternalCatalog) catalog).getIcebergCatalogType(); - } - - protected synchronized void makeSureInitialized() { - super.makeSureInitialized(); - if (!objectCreated) { - objectCreated = true; - isView = ((IcebergExternalCatalog) catalog) - .viewExists(SessionContext.current(), getRemoteDbName(), getRemoteName()); - } - } - - @Override - public Optional initSchema(SchemaCacheKey key) { - boolean isView = isView(); - return IcebergUtils.loadSchemaCacheValue(this, ((IcebergSchemaCacheKey) key).getSchemaId(), isView); - } - - @Override - public Optional getSchemaCacheValue() { - IcebergSnapshotCacheValue snapshotValue = IcebergUtils.getSnapshotCacheValue( - MvccUtil.getSnapshotFromContext(this), this); - return Optional.of(IcebergUtils.getSchemaCacheValue(this, snapshotValue)); - } - - @Override - public TTableDescriptor toThrift() { - List schema = getFullSchema(); - if (getIcebergCatalogType().equals("hms")) { - THiveTable tHiveTable = new THiveTable(getDbName(), getName(), new HashMap<>()); - TTableDescriptor tTableDescriptor = new TTableDescriptor(getId(), TTableType.HIVE_TABLE, schema.size(), 0, - getName(), getDbName()); - tTableDescriptor.setHiveTable(tHiveTable); - return tTableDescriptor; - } else { - TIcebergTable icebergTable = new TIcebergTable(getDbName(), getName(), new HashMap<>()); - TTableDescriptor tTableDescriptor = new TTableDescriptor(getId(), TTableType.ICEBERG_TABLE, - schema.size(), 0, getName(), getDbName()); - tTableDescriptor.setIcebergTable(icebergTable); - return tTableDescriptor; - } - } - - @Override - public BaseAnalysisTask createAnalysisTask(AnalysisInfo info) { - makeSureInitialized(); - return new ExternalAnalysisTask(info); - } - - @Override - public long fetchRowCount() { - makeSureInitialized(); - long rowCount = IcebergUtils.getIcebergRowCount(this); - return rowCount > 0 ? rowCount : UNKNOWN_ROW_COUNT; - } - - public Table getIcebergTable() { - return IcebergUtils.getIcebergTable(this); - } - - @Override - public String getComment() { - return properties().getOrDefault(TABLE_COMMENT_PROP, ""); - } - - @Override - public String getComment(boolean escapeQuota) { - String comment = getComment(); - return escapeQuota ? SqlUtils.escapeQuota(comment) : comment; - } - - @Override - public void beforeMTMVRefresh(MTMV mtmv) throws DdlException { - } - - @Override - public Map getAndCopyPartitionItems(Optional snapshot) { - return Maps.newHashMap(IcebergUtils.getIcebergPartitionItems(snapshot, this)); - } - - @Override - public Map getNameToPartitionItems(Optional snapshot) { - return IcebergUtils.getIcebergPartitionItems(snapshot, this); - } - - @Override - public PartitionType getPartitionType(Optional snapshot) { - return isValidRelatedTable() ? PartitionType.RANGE : PartitionType.UNPARTITIONED; - } - - @Override - public Set getPartitionColumnNames(Optional snapshot) throws DdlException { - return getPartitionColumns(snapshot).stream().map(Column::getName).collect(Collectors.toSet()); - } - - @Override - public List getPartitionColumns(Optional snapshot) { - return IcebergUtils.getIcebergPartitionColumns(snapshot, this); - } - - @Override - public MTMVSnapshotIf getPartitionSnapshot(String partitionName, MTMVRefreshContext context, - Optional snapshot) throws AnalysisException { - IcebergSnapshotCacheValue snapshotValue = IcebergUtils.getSnapshotCacheValue(snapshot, this); - long latestSnapshotId = snapshotValue.getPartitionInfo().getLatestSnapshotId(partitionName); - // If partition snapshot ID is unavailable (<= 0), fallback to table snapshot ID - // This can happen when last_updated_snapshot_id is null in Iceberg metadata - if (latestSnapshotId <= 0) { - long tableSnapshotId = snapshotValue.getSnapshot().getSnapshotId(); - // If table snapshot ID is also invalid, it means empty table - if (tableSnapshotId <= 0) { - throw new AnalysisException("can not find partition: " + partitionName - + ", and table snapshot ID is also invalid"); - } - // Use table snapshot ID as fallback when partition snapshot ID is unavailable - return new MTMVSnapshotIdSnapshot(tableSnapshotId); - } - return new MTMVSnapshotIdSnapshot(latestSnapshotId); - } - - @Override - public MTMVSnapshotIf getTableSnapshot(MTMVRefreshContext context, Optional snapshot) - throws AnalysisException { - return getTableSnapshot(snapshot); - } - - @Override - public MTMVSnapshotIf getTableSnapshot(Optional snapshot) throws AnalysisException { - makeSureInitialized(); - IcebergSnapshotCacheValue snapshotValue = IcebergUtils.getSnapshotCacheValue(snapshot, this); - return new MTMVSnapshotIdSnapshot(snapshotValue.getSnapshot().getSnapshotId()); - } - - @Override - public long getNewestUpdateVersionOrTime() { - return IcebergUtils.getLatestSnapshotCacheValue(this) - .getPartitionInfo().getNameToIcebergPartition().values().stream() - .mapToLong(IcebergPartition::getLastUpdateTime).max().orElse(0); - } - - @Override - public boolean isPartitionColumnAllowNull() { - return true; - } - - /** - * For now, we only support single partition column Iceberg table as related table. - * The supported transforms now are YEAR, MONTH, DAY and HOUR. - * And the column couldn't change to another column during partition evolution. - */ - @Override - public boolean isValidRelatedTable() { - makeSureInitialized(); - if (isValidRelatedTableCached) { - return isValidRelatedTable; - } - isValidRelatedTable = false; - Set allFields = Sets.newHashSet(); - Table table = getIcebergTable(); - for (PartitionSpec spec : table.specs().values()) { - if (spec == null) { - isValidRelatedTableCached = true; - return false; - } - List fields = spec.fields(); - if (fields.size() != 1) { - isValidRelatedTableCached = true; - return false; - } - PartitionField partitionField = spec.fields().get(0); - String transformName = partitionField.transform().toString(); - if (!IcebergUtils.YEAR.equals(transformName) - && !IcebergUtils.MONTH.equals(transformName) - && !IcebergUtils.DAY.equals(transformName) - && !IcebergUtils.HOUR.equals(transformName)) { - isValidRelatedTableCached = true; - return false; - } - allFields.add(table.schema().findColumnName(partitionField.sourceId())); - } - isValidRelatedTableCached = true; - isValidRelatedTable = allFields.size() == 1; - return isValidRelatedTable; - } - - @Override - public MvccSnapshot loadSnapshot(Optional tableSnapshot, Optional scanParams) { - if (isView()) { - return new EmptyMvccSnapshot(); - } else { - return new IcebergMvccSnapshot(IcebergUtils.getSnapshotCacheValue( - tableSnapshot, this, scanParams)); - } - } - - @Override - protected boolean needInternalHiddenColumns() { - ConnectContext ctx = ConnectContext.get(); - return ctx != null && ctx.needIcebergRowIdForTable(this.getId()); - } - - @Override - public List getFullSchema() { - List schema = IcebergUtils.getIcebergSchema(this); - schema = new ArrayList<>(schema); - - if (Util.showHiddenColumns() || needInternalHiddenColumns()) { - schema.add(createIcebergRowIdColumn()); - } - - schema = IcebergUtils.appendRowLineageColumnsForV3(schema, getIcebergTable()); - return schema; - } - - private Column createIcebergRowIdColumn() { - return IcebergRowId.createHiddenColumn(); - } - - @Override - public boolean supportInternalPartitionPruned() { - return true; - } - - @Override - public boolean supportsExternalMetadataPreload() { - return true; - } - - @Override - public boolean supportsLatestSnapshotPreload() { - return true; - } - - @VisibleForTesting - public boolean isValidRelatedTableCached() { - return isValidRelatedTableCached; - } - - @VisibleForTesting - public boolean validRelatedTableCache() { - return isValidRelatedTable; - } - - public void setIsValidRelatedTableCached(boolean isCached) { - this.isValidRelatedTableCached = isCached; - } - - @Override - public Map getSupportedSysTables() { - makeSureInitialized(); - return IcebergSysTable.SUPPORTED_SYS_TABLES; - } - - @Override - public boolean isView() { - makeSureInitialized(); - return isView; - } - - public String getViewText() { - try { - return catalog.getExecutionAuthenticator().execute(() -> { - View icebergView = IcebergUtils.getIcebergView(this); - ViewVersion viewVersion = icebergView.currentVersion(); - if (viewVersion == null) { - throw new RuntimeException(String.format("Cannot get view version for view '%s'", icebergView)); - } - Map summary = viewVersion.summary(); - if (summary == null) { - throw new RuntimeException(String.format("Cannot get summary for view '%s'", icebergView)); - } - String engineName = summary.get(ENGINE_PROP_NAME); - if (StringUtils.isEmpty(engineName)) { - throw new RuntimeException(String.format("Cannot get engine-name for view '%s'", icebergView)); - } - SQLViewRepresentation sqlViewRepresentation = icebergView.sqlFor(engineName.toLowerCase()); - if (sqlViewRepresentation == null) { - throw new UnsupportedOperationException("Cannot get view text from iceberg view"); - } - return sqlViewRepresentation.sql(); - }); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - public String getSqlDialect() { - try { - return catalog.getExecutionAuthenticator().execute(() -> { - View icebergView = IcebergUtils.getIcebergView(this); - ViewVersion viewVersion = icebergView.currentVersion(); - if (viewVersion == null) { - throw new RuntimeException(String.format("Cannot get view version for view '%s'", icebergView)); - } - Map summary = viewVersion.summary(); - if (summary == null) { - throw new RuntimeException(String.format("Cannot get summary for view '%s'", icebergView)); - } - String engineName = summary.get(ENGINE_PROP_NAME); - if (StringUtils.isEmpty(engineName)) { - throw new RuntimeException(String.format("Cannot get engine-name for view '%s'", icebergView)); - } - return engineName.toLowerCase(); - }); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - public View getIcebergView() { - return IcebergUtils.getIcebergView(this); - } - - /** - * get location of an iceberg table or view - * @return - */ - public String location() { - if (isView()) { - View icebergView = getIcebergView(); - return icebergView.location(); - } else { - Table icebergTable = getIcebergTable(); - return icebergTable.location(); - } - } - - /** - * get properties of an iceberg table or view - * @return - */ - public Map properties() { - if (isView()) { - View icebergView = getIcebergView(); - return icebergView.properties(); - } else { - Table icebergTable = getIcebergTable(); - return icebergTable.properties(); - } - } - - @Override - public boolean isPartitionedTable() { - makeSureInitialized(); - Table table = getIcebergTable(); - return table.spec().isPartitioned(); - } - - /** - * Get sort order SQL clause from iceberg table - * @return SQL string representing ORDER BY clause, or empty string if no sort order - */ - public String getSortOrderSql() { - Table table = getIcebergTable(); - org.apache.iceberg.SortOrder sortOrder = table.sortOrder(); - if (sortOrder == null || sortOrder.isUnsorted() || sortOrder.fields().isEmpty()) { - return ""; - } - - List sortItems = new java.util.ArrayList<>(); - for (org.apache.iceberg.SortField sortField : sortOrder.fields()) { - String columnName = table.schema().findColumnName(sortField.sourceId()); - if (columnName != null) { - boolean isAscending = sortField.direction() != org.apache.iceberg.SortDirection.DESC; - boolean isNullFirst = sortField.nullOrder() == org.apache.iceberg.NullOrder.NULLS_FIRST; - SortFieldInfo sortFieldInfo = new SortFieldInfo(columnName, isAscending, isNullFirst); - sortItems.add(sortFieldInfo.toSql()); - } - } - return "ORDER BY (" + String.join(", ", sortItems) + ")"; - } - - /** - * Check if table has sort order defined - * @return true if table has sort order - */ - public boolean hasSortOrder() { - Table table = getIcebergTable(); - org.apache.iceberg.SortOrder sortOrder = table.sortOrder(); - return sortOrder != null && !sortOrder.isUnsorted(); - } - - /** Reconstructs PARTITION BY LIST (...) () from the Iceberg PartitionSpec for SHOW CREATE TABLE. */ - public String getPartitionSpecSql() { - makeSureInitialized(); - Table table = getIcebergTable(); - PartitionSpec spec = table.spec(); - if (spec == null || spec.isUnpartitioned()) { - return ""; - } - List fields = new ArrayList<>(); - for (PartitionField field : spec.fields()) { - String colName = table.schema().findColumnName(field.sourceId()); - if (colName == null) { - continue; - } - org.apache.iceberg.transforms.Transform t = field.transform(); - // isVoid/isIdentity: public interface methods; toString(): canonical spec-defined names. - if (t.isVoid()) { - continue; - } - String quotedCol = "`" + colName + "`"; - if (t.isIdentity()) { - fields.add(quotedCol); - } else { - String transformStr = t.toString(); - if (transformStr.startsWith("bucket[")) { - int n = Integer.parseInt(transformStr.substring(7, transformStr.length() - 1)); - fields.add("BUCKET(" + n + ", " + quotedCol + ")"); - } else if (transformStr.startsWith("truncate[")) { - int w = Integer.parseInt(transformStr.substring(9, transformStr.length() - 1)); - fields.add("TRUNCATE(" + w + ", " + quotedCol + ")"); - } else if ("year".equals(transformStr)) { - fields.add("YEAR(" + quotedCol + ")"); - } else if ("month".equals(transformStr)) { - fields.add("MONTH(" + quotedCol + ")"); - } else if ("day".equals(transformStr)) { - fields.add("DAY(" + quotedCol + ")"); - } else if ("hour".equals(transformStr)) { - fields.add("HOUR(" + quotedCol + ")"); - } else { - LOG.warn("Unsupported Iceberg partition transform '{}' on column '{}', " - + "skipped in SHOW CREATE TABLE.", transformStr, colName); - } - } - } - if (fields.isEmpty()) { - return ""; - } - return "PARTITION BY LIST (" + String.join(", ", fields) + ") ()"; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergGlueExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergGlueExternalCatalog.java deleted file mode 100644 index a5c4260a2f146b..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergGlueExternalCatalog.java +++ /dev/null @@ -1,31 +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.datasource.iceberg; - -import org.apache.doris.datasource.CatalogProperty; - -import java.util.Map; - -public class IcebergGlueExternalCatalog extends IcebergExternalCatalog { - - public IcebergGlueExternalCatalog(long catalogId, String name, String resource, Map props, - String comment) { - super(catalogId, name, comment); - catalogProperty = new CatalogProperty(resource, props); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergHMSExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergHMSExternalCatalog.java deleted file mode 100644 index dd46088818e97a..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergHMSExternalCatalog.java +++ /dev/null @@ -1,32 +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.datasource.iceberg; - -import org.apache.doris.datasource.CatalogProperty; - -import java.util.Map; - -public class IcebergHMSExternalCatalog extends IcebergExternalCatalog { - - public IcebergHMSExternalCatalog(long catalogId, String name, String resource, Map props, - String comment) { - super(catalogId, name, comment); - catalogProperty = new CatalogProperty(resource, props); - } -} - diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergHadoopExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergHadoopExternalCatalog.java deleted file mode 100644 index 86f2dd46ead3cd..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergHadoopExternalCatalog.java +++ /dev/null @@ -1,48 +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.datasource.iceberg; - -import org.apache.doris.catalog.HdfsResource; -import org.apache.doris.datasource.CatalogProperty; - -import com.google.common.base.Preconditions; -import org.apache.commons.lang3.StringUtils; -import org.apache.iceberg.CatalogProperties; - -import java.util.Map; - -public class IcebergHadoopExternalCatalog extends IcebergExternalCatalog { - - public IcebergHadoopExternalCatalog(long catalogId, String name, String resource, Map props, - String comment) { - super(catalogId, name, comment); - String warehouse = props.get(CatalogProperties.WAREHOUSE_LOCATION); - Preconditions.checkArgument(StringUtils.isNotEmpty(warehouse), - "Cannot initialize Iceberg HadoopCatalog because 'warehouse' must not be null or empty"); - catalogProperty = new CatalogProperty(resource, props); - if (StringUtils.startsWith(warehouse, HdfsResource.HDFS_PREFIX)) { - String nameService = StringUtils.substringBetween(warehouse, HdfsResource.HDFS_FILE_PREFIX, "/"); - if (StringUtils.isEmpty(nameService)) { - throw new IllegalArgumentException("Unrecognized 'warehouse' location format" - + " because name service is required."); - } - catalogProperty.addProperty(HdfsResource.HADOOP_FS_NAME, HdfsResource.HDFS_FILE_PREFIX + nameService); - } - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergJdbcExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergJdbcExternalCatalog.java deleted file mode 100644 index aeb2fd9deec18e..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergJdbcExternalCatalog.java +++ /dev/null @@ -1,31 +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.datasource.iceberg; - -import org.apache.doris.datasource.CatalogProperty; - -import java.util.Map; - -public class IcebergJdbcExternalCatalog extends IcebergExternalCatalog { - - public IcebergJdbcExternalCatalog(long catalogId, String name, String resource, Map props, - String comment) { - super(catalogId, name, comment); - catalogProperty = new CatalogProperty(resource, props); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMergeOperation.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMergeOperation.java deleted file mode 100644 index 94073c700072b0..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMergeOperation.java +++ /dev/null @@ -1,39 +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.datasource.iceberg; - -/** - * Operation codes used for merge-style DML routing. - */ -public final class IcebergMergeOperation { - public static final String OPERATION_COLUMN = "operation"; - - // Merge sink routing: - // 1 (INSERT): only insert writer - // 2 (DELETE): only delete writer - // 3 (UPDATE): update rows (merge sink writes delete + insert) - // 4 (UPDATE_INSERT): pre-split update insert rows - // 5 (UPDATE_DELETE): pre-split update delete rows - public static final byte INSERT_OPERATION_NUMBER = 1; - public static final byte DELETE_OPERATION_NUMBER = 2; - public static final byte UPDATE_OPERATION_NUMBER = 3; - public static final byte UPDATE_INSERT_OPERATION_NUMBER = 4; - public static final byte UPDATE_DELETE_OPERATION_NUMBER = 5; - - private IcebergMergeOperation() {} -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java deleted file mode 100644 index c8d9c50c21da6e..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java +++ /dev/null @@ -1,1480 +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.datasource.iceberg; - -import org.apache.doris.catalog.ArrayType; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.ColumnType; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.MapType; -import org.apache.doris.catalog.StructField; -import org.apache.doris.catalog.StructType; -import org.apache.doris.catalog.info.BranchOptions; -import org.apache.doris.catalog.info.ColumnPosition; -import org.apache.doris.catalog.info.CreateOrReplaceBranchInfo; -import org.apache.doris.catalog.info.CreateOrReplaceTagInfo; -import org.apache.doris.catalog.info.DropBranchInfo; -import org.apache.doris.catalog.info.DropTagInfo; -import org.apache.doris.catalog.info.TagOptions; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.ErrorCode; -import org.apache.doris.common.ErrorReport; -import org.apache.doris.common.UserException; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; -import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.DorisTypeVisitor; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.datasource.operations.ExternalMetadataOps; -import org.apache.doris.datasource.property.metastore.IcebergRestProperties; -import org.apache.doris.filesystem.FileEntry; -import org.apache.doris.filesystem.FileIterator; -import org.apache.doris.filesystem.FileSystem; -import org.apache.doris.filesystem.Location; -import org.apache.doris.fs.SpiSwitchingFileSystem; -import org.apache.doris.nereids.trees.plans.commands.info.AddPartitionFieldOp; -import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo; -import org.apache.doris.nereids.trees.plans.commands.info.DropPartitionFieldOp; -import org.apache.doris.nereids.trees.plans.commands.info.ReplacePartitionFieldOp; -import org.apache.doris.nereids.trees.plans.commands.info.SortFieldInfo; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Splitter; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.apache.iceberg.ManageSnapshots; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.RowLevelOperationMode; -import org.apache.iceberg.Schema; -import org.apache.iceberg.Snapshot; -import org.apache.iceberg.SnapshotRef; -import org.apache.iceberg.Table; -import org.apache.iceberg.TableProperties; -import org.apache.iceberg.UpdatePartitionSpec; -import org.apache.iceberg.UpdateSchema; -import org.apache.iceberg.catalog.BaseViewSessionCatalog; -import org.apache.iceberg.catalog.Catalog; -import org.apache.iceberg.catalog.Namespace; -import org.apache.iceberg.catalog.SessionCatalog; -import org.apache.iceberg.catalog.SupportsNamespaces; -import org.apache.iceberg.catalog.TableIdentifier; -import org.apache.iceberg.catalog.ViewCatalog; -import org.apache.iceberg.exceptions.NoSuchNamespaceException; -import org.apache.iceberg.expressions.Expressions; -import org.apache.iceberg.expressions.Literal; -import org.apache.iceberg.expressions.Term; -import org.apache.iceberg.types.Type; -import org.apache.iceberg.types.Types; -import org.apache.iceberg.types.Types.NestedField; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.NotNull; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -public class IcebergMetadataOps implements ExternalMetadataOps { - - private static final Logger LOG = LogManager.getLogger(IcebergMetadataOps.class); - private static final String NAMESPACE_LOCATION_PROP = "location"; - private static final List ICEBERG_TABLE_LOCATION_CHILD_DIRS = Arrays.asList("data", "metadata"); - protected Catalog catalog; - protected ExternalCatalog dorisCatalog; - protected SupportsNamespaces nsCatalog; - // The view catalog used by the non-delegated (default) path. For REST this is the session catalog's - // asViewCatalog(empty) (the default Catalog from asCatalog() is not itself a ViewCatalog); for other - // catalog types it is the catalog itself when it implements ViewCatalog. Empty when views are unsupported - // or disabled. - private final Optional defaultViewCatalog; - // Non-null only when the backing catalog supports per-user dynamic session (an Iceberg REST catalog with - // iceberg.rest.session=user). Captured once at construction; the per-request decision is delegated to it so - // this class never re-checks the catalog type or reads REST properties directly. - private final IcebergUserSessionCatalog userSessionCatalog; - private IcebergSessionCatalogAdapter sessionCatalogAdapter; - private ExecutionAuthenticator executionAuthenticator; - // Generally, there should be only two levels under the catalog, namely .
    , - // but the REST type catalog is obtained from an external server, - // and the level provided by the external server may be three levels, ..
    . - // Therefore, if the external server provides a catalog, - // the catalog needs to be recorded here to ensure semantic consistency. - private Optional externalCatalogName = Optional.empty(); - - public IcebergMetadataOps(ExternalCatalog dorisCatalog, Catalog catalog) { - this.dorisCatalog = dorisCatalog; - this.catalog = catalog; - // Session-aware behavior exists only when the backing catalog advertises the IcebergUserSessionCatalog - // capability (an Iceberg REST catalog with dynamic identity). Capture it once and read what we need - // from the capability, so no call-time path has to know about the concrete REST catalog or its - // properties. For any other catalog type this is null (session disabled). - this.userSessionCatalog = dorisCatalog instanceof IcebergUserSessionCatalog - ? (IcebergUserSessionCatalog) dorisCatalog : null; - BaseViewSessionCatalog restSessionCatalog = - userSessionCatalog == null ? null : userSessionCatalog.getRestSessionCatalog(); - IcebergRestProperties.DelegatedTokenMode delegatedTokenMode = - userSessionCatalog == null ? IcebergRestProperties.DelegatedTokenMode.ACCESS_TOKEN - : userSessionCatalog.getDelegatedTokenMode(); - boolean viewEnabled = userSessionCatalog != null && userSessionCatalog.isViewEnabled(); - - this.sessionCatalogAdapter = - new IcebergSessionCatalogAdapter(catalog, restSessionCatalog, delegatedTokenMode); - nsCatalog = (SupportsNamespaces) catalog; - this.defaultViewCatalog = resolveDefaultViewCatalog(catalog, restSessionCatalog, viewEnabled); - this.executionAuthenticator = dorisCatalog.getExecutionAuthenticator(); - - if (dorisCatalog.getProperties().containsKey(IcebergExternalCatalog.EXTERNAL_CATALOG_NAME)) { - externalCatalogName = - Optional.of(dorisCatalog.getProperties().get(IcebergExternalCatalog.EXTERNAL_CATALOG_NAME)); - } - } - - public Catalog getCatalog() { - return catalog; - } - - public ExternalCatalog getExternalCatalog() { - return dorisCatalog; - } - - @Override - public void close() { - if (catalog != null) { - catalog = null; - } - } - - @Override - public boolean tableExist(String dbName, String tblName) { - return tableExist(SessionContext.empty(), dbName, tblName); - } - - public boolean tableExist(SessionContext ctx, String dbName, String tblName) { - try { - Catalog activeCatalog = catalog(ctx); - return executionAuthenticator.execute(() -> activeCatalog.tableExists(getTableIdentifier(dbName, tblName))); - } catch (Exception e) { - throw new RuntimeException("Failed to check table exist, error message is:" + e.getMessage(), e); - } - } - - public boolean databaseExist(String dbName) { - return databaseExist(SessionContext.empty(), dbName); - } - - public boolean databaseExist(SessionContext ctx, String dbName) { - try { - SupportsNamespaces activeNsCatalog = namespaces(ctx); - return executionAuthenticator.execute(() -> activeNsCatalog.namespaceExists(getNamespace(dbName))); - } catch (Exception e) { - throw new RuntimeException("Failed to check database exist, error message is:" + e.getMessage(), e); - } - } - - public List listDatabaseNames() { - return listDatabaseNames(SessionContext.empty()); - } - - public List listDatabaseNames(SessionContext ctx) { - try { - SupportsNamespaces activeNsCatalog = namespaces(ctx); - return executionAuthenticator.execute(() -> listNestedNamespaces(activeNsCatalog, getNamespace())); - } catch (Exception e) { - LOG.warn("failed to list database names in catalog {}, root cause: {}", - dorisCatalog.getName(), Util.getRootCauseMessage(e), e); - throw new RuntimeException("Failed to list database names, error message is:" + e.getMessage(), e); - } - } - - @NotNull - private List listNestedNamespaces(SupportsNamespaces activeNsCatalog, Namespace parentNs) { - // Handle nested namespaces for Iceberg REST catalog, - // only if "iceberg.rest.nested-namespace-enabled" is true. - if (userSessionCatalog != null && userSessionCatalog.isNestedNamespaceEnabled()) { - return activeNsCatalog.listNamespaces(parentNs) - .stream() - .flatMap(childNs -> Stream.concat( - Stream.of(childNs.toString()), - listNestedNamespaces(activeNsCatalog, childNs).stream() - )).collect(Collectors.toList()); - } - - return activeNsCatalog.listNamespaces(parentNs) - .stream() - .map(n -> n.level(n.length() - 1)) - .collect(Collectors.toList()); - } - - @Override - public List listTableNames(String dbName) { - return listTableNames(SessionContext.empty(), dbName); - } - - public List listTableNames(SessionContext ctx, String dbName) { - try { - return executionAuthenticator.execute(() -> { - Catalog activeCatalog = catalog(ctx); - List tableIdentifiers = activeCatalog.listTables(getNamespace(dbName)); - List views; - // Our original intention was simply to clearly define the responsibilities of ViewCatalog and Catalog. - // IcebergMetadataOps handles listTableNames and listViewNames separately. - // listTableNames should only focus on the table type, - // but in reality, Iceberg's return includes views. Therefore, we added a filter to exclude views. - views = viewCatalog(ctx).map(viewCatalog -> viewCatalog.listViews(getNamespace(dbName)) - .stream().map(TableIdentifier::name).collect(Collectors.toList())) - .orElseGet(Collections::emptyList); - if (views.isEmpty()) { - return tableIdentifiers.stream().map(TableIdentifier::name).collect(Collectors.toList()); - } else { - return tableIdentifiers.stream() - .map(TableIdentifier::name) - .filter(name -> !views.contains(name)).collect(Collectors.toList()); - } - }); - } catch (RuntimeException e) { - // We want to catch real exception like NoSuchNamespaceException and throw it directly - throw e; - } catch (Exception e) { - throw new RuntimeException("Failed to list table names, error message is: " + e.getMessage(), e); - } - } - - @Override - public boolean createDbImpl(String dbName, boolean ifNotExists, Map properties) - throws DdlException { - SessionContext sessionContext = SessionContext.current(); - try { - return executionAuthenticator.execute( - () -> performCreateDb(sessionContext, dbName, ifNotExists, properties)); - } catch (Exception e) { - throw new DdlException("Failed to create database: " - + dbName + ": " + Util.getRootCauseMessage(e), e); - } - } - - @Override - public void afterCreateDb() { - dorisCatalog.resetMetaCacheNames(); - } - - private boolean performCreateDb(SessionContext ctx, String dbName, boolean ifNotExists, - Map properties) throws DdlException { - SupportsNamespaces activeNsCatalog = namespaces(ctx); - if (databaseExist(ctx, dbName)) { - if (ifNotExists) { - LOG.info("create database[{}] which already exists", dbName); - return true; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_DB_CREATE_EXISTS, dbName); - } - } - if (!properties.isEmpty() && dorisCatalog instanceof IcebergExternalCatalog) { - String icebergCatalogType = ((IcebergExternalCatalog) dorisCatalog).getIcebergCatalogType(); - if (!IcebergExternalCatalog.ICEBERG_HMS.equals(icebergCatalogType)) { - throw new DdlException( - "Not supported: create database with properties for iceberg catalog type: " + icebergCatalogType); - } - } - activeNsCatalog.createNamespace(getNamespace(dbName), properties); - return false; - } - - @Override - public void dropDbImpl(String dbName, boolean ifExists, boolean force) throws DdlException { - SessionContext sessionContext = SessionContext.current(); - try { - executionAuthenticator.execute(() -> { - performDropDb(sessionContext, dbName, ifExists, force); - return null; - }); - } catch (Exception e) { - throw new DdlException( - "Failed to drop database: " + dbName + ", error message is:" + e.getMessage(), e); - } - } - - private void performDropDb(SessionContext ctx, String dbName, boolean ifExists, boolean force) throws DdlException { - ExternalDatabase dorisDb = dorisCatalog.getDbNullable(dbName); - if (dorisDb == null) { - if (ifExists) { - LOG.info("drop database[{}] which does not exist", dbName); - return; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_DB_DROP_EXISTS, dbName); - } - } - if (force) { - try { - // try to drop all tables in the database - List remoteTableNames = listTableNames(ctx, dorisDb.getRemoteName()); - for (String remoteTableName : remoteTableNames) { - performDropTable(ctx, dorisDb.getRemoteName(), remoteTableName, true); - } - if (!remoteTableNames.isEmpty()) { - LOG.info("drop database[{}] with force, drop all tables, num: {}", dbName, remoteTableNames.size()); - } - // try to drop all views in the database - List remoteViewNames = listViewNames(ctx, dorisDb.getRemoteName()); - for (String remoteViewName : remoteViewNames) { - performDropView(ctx, dorisDb.getRemoteName(), remoteViewName); - } - if (!remoteViewNames.isEmpty()) { - LOG.info("drop database[{}] with force, drop all views, num: {}", dbName, remoteViewNames.size()); - } - } catch (NoSuchNamespaceException e) { - // just ignore - LOG.info("drop database[{}] force which does not exist", dbName); - return; - } - } - Namespace namespace = getNamespace(dorisDb.getRemoteName()); - Optional namespaceLocation = shouldCleanupManagedLocation() - ? loadNamespaceLocation(namespace) : Optional.empty(); - namespaces(ctx).dropNamespace(namespace); - namespaceLocation.ifPresent(location -> cleanupEmptyLocation(location, "database", dbName, false)); - } - - @Override - public void afterDropDb(String dbName) { - dorisCatalog.unregisterDatabase(dbName); - } - - @Override - public boolean createTableImpl(CreateTableInfo createTableInfo) throws UserException { - SessionContext sessionContext = SessionContext.current(); - try { - return executionAuthenticator.execute(() -> performCreateTable(sessionContext, createTableInfo)); - } catch (Exception e) { - throw new DdlException( - "Failed to create table: " + createTableInfo.getTableName() + ", error message is:" + e.getMessage(), - e); - } - } - - public boolean performCreateTable(CreateTableInfo createTableInfo) throws UserException { - return performCreateTable(SessionContext.current(), createTableInfo); - } - - private boolean performCreateTable(SessionContext ctx, CreateTableInfo createTableInfo) throws UserException { - String dbName = createTableInfo.getDbName(); - ExternalDatabase db = dorisCatalog.getDbNullable(dbName); - if (db == null) { - throw new UserException("Failed to get database: '" + dbName + "' in catalog: " + dorisCatalog.getName()); - } - String tableName = createTableInfo.getTableName(); - // 1. first, check if table exist in remote - if (tableExist(ctx, db.getRemoteName(), tableName)) { - if (createTableInfo.isIfNotExists()) { - LOG.info("create table[{}] which already exists", tableName); - return true; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_TABLE_EXISTS_ERROR, tableName); - } - } - // 2. second, check fi table exist in local. - // This is because case sensibility issue, eg: - // 1. lower_case_table_name = 1 - // 2. create table tbl1; - // 3. create table TBL1; TBL1 does not exist in remote because the remote system is case-sensitive. - // but because lower_case_table_name = 1, the table can not be created in Doris because it is conflict with - // tbl1 - ExternalTable dorisTable = db.getTableNullable(tableName); - if (dorisTable != null) { - if (createTableInfo.isIfNotExists()) { - LOG.info("create table[{}] which already exists", tableName); - return true; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_TABLE_EXISTS_ERROR, tableName); - } - } - List columns = createTableInfo.getColumns(); - List collect = columns.stream() - .map(col -> new StructField(col.getName(), col.getType(), col.getComment(), col.isAllowNull())) - .collect(Collectors.toList()); - StructType structType = new StructType(new ArrayList<>(collect)); - List rootFieldNames = columns.stream().map(Column::getName).collect(Collectors.toList()); - Type visit = DorisTypeVisitor.visit(structType, new DorisTypeToIcebergType(structType, rootFieldNames)); - Schema schema = new Schema(visit.asNestedType().asStructType().fields()); - Map properties = createTableInfo.getProperties(); - properties.put(ExternalCatalog.DORIS_VERSION, ExternalCatalog.DORIS_VERSION_VALUE); - Map catalogProperties = dorisCatalog.getProperties(); - if (!properties.containsKey(TableProperties.FORMAT_VERSION) - && !IcebergUtils.hasIcebergCatalogFormatVersion(catalogProperties)) { - properties.put(TableProperties.FORMAT_VERSION, "2"); - } - properties.putIfAbsent(TableProperties.DELETE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); - properties.putIfAbsent(TableProperties.UPDATE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); - properties.putIfAbsent(TableProperties.MERGE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); - createTableInfo.validateIcebergRowLineageColumns( - IcebergUtils.getEffectiveIcebergFormatVersion(properties, catalogProperties)); - PartitionSpec partitionSpec = IcebergUtils.solveIcebergPartitionSpec(createTableInfo.getPartitionDesc(), - schema); - // Build and create table with optional sort order - org.apache.iceberg.SortOrder sortOrder = buildSortOrder(createTableInfo.getSortOrderFields(), schema); - Catalog activeCatalog = catalog(ctx); - if (sortOrder != null && !sortOrder.isUnsorted()) { - activeCatalog.buildTable(getTableIdentifier(dbName, tableName), schema) - .withPartitionSpec(partitionSpec) - .withProperties(properties) - .withSortOrder(sortOrder) - .create(); - } else { - activeCatalog.createTable(getTableIdentifier(dbName, tableName), schema, partitionSpec, properties); - } - return false; - } - - @Override - public void afterCreateTable(String dbName, String tblName) { - Optional> db = dorisCatalog.getDbForReplay(dbName); - if (db.isPresent()) { - db.get().resetMetaCacheNames(); - } - LOG.info("after create table {}.{}.{}, is db exists: {}", - dorisCatalog.getName(), dbName, tblName, db.isPresent()); - } - - @Override - public void dropTableImpl(ExternalTable dorisTable, boolean ifExists) throws DdlException { - SessionContext sessionContext = SessionContext.current(); - try { - executionAuthenticator.execute(() -> { - if (viewExists(sessionContext, dorisTable.getRemoteDbName(), dorisTable.getRemoteName())) { - performDropView(sessionContext, dorisTable.getRemoteDbName(), dorisTable.getRemoteName()); - } else { - performDropTable(sessionContext, dorisTable.getRemoteDbName(), - dorisTable.getRemoteName(), ifExists); - } - return null; - }); - } catch (Exception e) { - throw new DdlException( - "Failed to drop table: " + dorisTable.getName() + ", error message is:" + e.getMessage(), e); - } - } - - @Override - public void afterDropTable(String dbName, String tblName) { - Optional> db = dorisCatalog.getDbForReplay(dbName); - if (db.isPresent()) { - db.get().unregisterTable(tblName); - } - LOG.info("after drop table {}.{}.{}. is db exists: {}", - dorisCatalog.getName(), dbName, tblName, db.isPresent()); - } - - private void performDropTable(SessionContext ctx, String remoteDbName, String remoteTblName, boolean ifExists) - throws DdlException { - if (!tableExist(ctx, remoteDbName, remoteTblName)) { - if (ifExists) { - LOG.info("drop table[{}] which does not exist", remoteTblName); - return; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_UNKNOWN_TABLE, remoteTblName, remoteDbName); - } - } - TableIdentifier tableIdentifier = getTableIdentifier(remoteDbName, remoteTblName); - Optional tableLocation = shouldCleanupManagedLocation() - ? loadTableLocation(tableIdentifier) : Optional.empty(); - catalog(ctx).dropTable(tableIdentifier, true); - tableLocation.ifPresent(location -> - cleanupEmptyLocation(location, "table", remoteDbName + "." + remoteTblName, true)); - } - - private Optional loadNamespaceLocation(Namespace namespace) { - Map namespaceMetadata = nsCatalog.loadNamespaceMetadata(namespace); - String location = namespaceMetadata.get(NAMESPACE_LOCATION_PROP); - return StringUtils.isBlank(location) ? Optional.empty() : Optional.of(location); - } - - private Optional loadTableLocation(TableIdentifier tableIdentifier) { - String location = catalog.loadTable(tableIdentifier).location(); - return StringUtils.isBlank(location) ? Optional.empty() : Optional.of(location); - } - - private void cleanupEmptyLocation( - String location, String objectType, String objectName, boolean cleanupIcebergTableChildren) { - if (!shouldCleanupManagedLocation() || StringUtils.isBlank(location)) { - return; - } - try (FileSystem fs = createCleanupFileSystem()) { - boolean deleted = cleanupIcebergTableChildren - ? deleteEmptyTableLocation(fs, Location.of(location)) - : deleteEmptyDirectory(fs, Location.of(location)); - if (deleted) { - LOG.info("Cleaned empty Iceberg {} location {} for {}", objectType, location, objectName); - } else { - LOG.info("Skip cleaning Iceberg {} location {} for {}, because it still contains files", - objectType, location, objectName); - } - } catch (Exception e) { - LOG.warn("Failed to clean Iceberg {} location {} for {} after drop", - objectType, location, objectName, e); - } - } - - private boolean shouldCleanupManagedLocation() { - // Only cleanup HMS-Iceberg location - return dorisCatalog instanceof IcebergExternalCatalog - && IcebergExternalCatalog.ICEBERG_HMS.equals( - ((IcebergExternalCatalog) dorisCatalog).getIcebergCatalogType()); - } - - @VisibleForTesting - protected FileSystem createCleanupFileSystem() { - return new SpiSwitchingFileSystem(dorisCatalog.getCatalogProperty().getStoragePropertiesMap()); - } - - @VisibleForTesting - static boolean deleteEmptyDirectory(FileSystem fs, Location location) throws IOException { - if (!fs.exists(location)) { - return true; - } - List childDirectories = new ArrayList<>(); - try (FileIterator iterator = fs.list(location)) { - while (iterator.hasNext()) { - FileEntry entry = iterator.next(); - if (!entry.isDirectory()) { - return false; - } - childDirectories.add(entry.location()); - } - } - for (Location childDirectory : childDirectories) { - if (!deleteEmptyDirectory(fs, childDirectory)) { - return false; - } - } - return deleteEmptyDirectoryMarker(fs, location); - } - - @VisibleForTesting - static boolean deleteEmptyTableLocation(FileSystem fs, Location location) throws IOException { - for (String childDir : ICEBERG_TABLE_LOCATION_CHILD_DIRS) { - if (!deleteEmptyDirectory(fs, location.resolve(childDir))) { - return false; - } - } - return deleteEmptyDirectory(fs, location); - } - - private static boolean deleteEmptyDirectoryMarker(FileSystem fs, Location location) throws IOException { - Location directoryMarker = Location.of(withTrailingSlash(location.uri())); - try { - fs.delete(directoryMarker, false); - } catch (IOException e) { - return !fs.exists(location); - } - return !fs.exists(location); - } - - private static String withTrailingSlash(String uri) { - return uri.endsWith("/") ? uri : uri + "/"; - } - - public void renameTableImpl(String dbName, String tblName, String newTblName) throws DdlException { - SessionContext sessionContext = SessionContext.current(); - try { - executionAuthenticator.execute(() -> { - catalog(sessionContext).renameTable( - getTableIdentifier(dbName, tblName), getTableIdentifier(dbName, newTblName)); - return null; - }); - } catch (Exception e) { - throw new DdlException( - "Failed to rename table: " + tblName + " to " + newTblName + ", error message is:" + e.getMessage(), - e); - } - } - - @Override - public void afterRenameTable(String dbName, String oldName, String newName) { - Optional> db = dorisCatalog.getDbForReplay(dbName); - if (db.isPresent()) { - db.get().unregisterTable(oldName); - db.get().resetMetaCacheNames(); - } - LOG.info("after rename table {}.{}.{} to {}, is db exists: {}", - dorisCatalog.getName(), dbName, oldName, newName, db.isPresent()); - } - - @Override - public void truncateTableImpl(ExternalTable dorisTable, List partitions) { - throw new UnsupportedOperationException("Truncate Iceberg table is not supported."); - } - - @Override - public void createOrReplaceBranchImpl(ExternalTable dorisTable, CreateOrReplaceBranchInfo branchInfo) - throws UserException { - Table icebergTable = IcebergUtils.getIcebergTable(dorisTable); - BranchOptions branchOptions = branchInfo.getBranchOptions(); - - Long snapshotId = branchOptions.getSnapshotId() - .orElse( - // use current snapshot - Optional.ofNullable(icebergTable.currentSnapshot()).map(Snapshot::snapshotId).orElse(null)); - - ManageSnapshots manageSnapshots; - try { - manageSnapshots = executionAuthenticator.execute(icebergTable::manageSnapshots); - } catch (Exception e) { - throw new RuntimeException( - "Failed to create ManageSnapshots for table: " + icebergTable.name() - + ", error message is: {} " + ExceptionUtils.getRootCauseMessage(e), e); - } - String branchName = branchInfo.getBranchName(); - - if (branchName == null || branchName.trim().isEmpty()) { - throw new UserException("Branch name cannot be empty"); - } - - boolean refExists = null != icebergTable.refs().get(branchName); - boolean create = branchInfo.getCreate(); - boolean replace = branchInfo.getReplace(); - boolean ifNotExists = branchInfo.getIfNotExists(); - Runnable safeCreateBranch = () -> { - try { - executionAuthenticator.execute(() -> { - if (snapshotId == null) { - manageSnapshots.createBranch(branchName); - } else { - manageSnapshots.createBranch(branchName, snapshotId); - } - }); - } catch (Exception e) { - throw new RuntimeException( - "Failed to create branch: " + branchName + " in table: " + icebergTable.name() - + ", error message is: {} " + ExceptionUtils.getRootCauseMessage(e), e); - } - }; - - if (create && replace && !refExists) { - safeCreateBranch.run(); - } else if (replace) { - if (snapshotId == null) { - // Cannot perform a replace operation on an empty table - throw new UserException( - "Cannot complete replace branch operation on " + icebergTable.name() - + " , main has no snapshot"); - } - manageSnapshots.replaceBranch(branchName, snapshotId); - } else { - if (refExists && ifNotExists) { - return; - } - safeCreateBranch.run(); - } - - branchOptions.getRetain().ifPresent(n -> manageSnapshots.setMaxSnapshotAgeMs(branchName, n)); - branchOptions.getNumSnapshots().ifPresent(n -> manageSnapshots.setMinSnapshotsToKeep(branchName, n)); - branchOptions.getRetention().ifPresent(n -> manageSnapshots.setMaxRefAgeMs(branchName, n)); - - try { - executionAuthenticator.execute(manageSnapshots::commit); - } catch (Exception e) { - throw new RuntimeException( - "Failed to create or replace branch: " + branchName + " in table: " + icebergTable.name() - + ", error message is: " + e.getMessage(), e); - } - } - - @Override - public void afterOperateOnBranchOrTag(String dbName, String tblName) { - Optional> db = dorisCatalog.getDbForReplay(dbName); - if (db.isPresent()) { - Optional tbl = db.get().getTableForReplay(tblName); - if (tbl.isPresent()) { - Env.getCurrentEnv().getRefreshManager() - .refreshTableInternal(db.get(), (ExternalTable) tbl.get(), - System.currentTimeMillis()); - } - } - } - - @Override - public void createOrReplaceTagImpl(ExternalTable dorisTable, CreateOrReplaceTagInfo tagInfo) - throws UserException { - Table icebergTable = IcebergUtils.getIcebergTable(dorisTable); - TagOptions tagOptions = tagInfo.getTagOptions(); - Long snapshotId = tagOptions.getSnapshotId() - .orElse( - // use current snapshot - Optional.ofNullable(icebergTable.currentSnapshot()).map(Snapshot::snapshotId).orElse(null)); - - if (snapshotId == null) { - // Creating tag for empty tables is not allowed - throw new UserException( - "Cannot complete replace branch operation on " + icebergTable.name() + " , main has no snapshot"); - } - - String tagName = tagInfo.getTagName(); - - if (tagName == null || tagName.trim().isEmpty()) { - throw new UserException("Tag name cannot be empty"); - } - - boolean create = tagInfo.getCreate(); - boolean replace = tagInfo.getReplace(); - boolean ifNotExists = tagInfo.getIfNotExists(); - boolean refExists = null != icebergTable.refs().get(tagName); - - - try { - executionAuthenticator.execute(() -> { - ManageSnapshots manageSnapshots = icebergTable.manageSnapshots(); - if (create && replace && !refExists) { - manageSnapshots.createTag(tagName, snapshotId); - } else if (replace) { - manageSnapshots.replaceTag(tagName, snapshotId); - } else { - if (refExists && ifNotExists) { - return; - } - manageSnapshots.createTag(tagName, snapshotId); - } - tagOptions.getRetain().ifPresent(n -> manageSnapshots.setMaxRefAgeMs(tagName, n)); - manageSnapshots.commit(); - }); - } catch (Exception e) { - throw new RuntimeException( - "Failed to create or replace tag: " + tagName + " in table: " + icebergTable.name() - + ", error message is: " + e.getMessage(), e); - } - } - - @Override - public void dropTagImpl(ExternalTable dorisTable, DropTagInfo tagInfo) throws UserException { - String tagName = tagInfo.getTagName(); - boolean ifExists = tagInfo.getIfExists(); - Table icebergTable = IcebergUtils.getIcebergTable(dorisTable); - SnapshotRef snapshotRef = icebergTable.refs().get(tagName); - - if (snapshotRef != null || !ifExists) { - try { - executionAuthenticator.execute(() -> { - ManageSnapshots manageSnapshots = icebergTable.manageSnapshots(); - manageSnapshots.removeTag(tagName).commit(); - }); - } catch (Exception e) { - throw new RuntimeException( - "Failed to drop tag: " + tagName + " in table: " + icebergTable.name() - + ", error message is: " + e.getMessage(), e); - } - } - } - - @Override - public void dropBranchImpl(ExternalTable dorisTable, DropBranchInfo branchInfo) throws UserException { - String branchName = branchInfo.getBranchName(); - boolean ifExists = branchInfo.getIfExists(); - Table icebergTable = IcebergUtils.getIcebergTable(dorisTable); - SnapshotRef snapshotRef = icebergTable.refs().get(branchName); - - if (snapshotRef != null || !ifExists) { - try { - executionAuthenticator.execute(() -> { - ManageSnapshots manageSnapshots = icebergTable.manageSnapshots(); - manageSnapshots.removeBranch(branchName).commit(); - }); - } catch (Exception e) { - throw new RuntimeException( - "Failed to drop branch: " + branchName + " in table: " + icebergTable.name() - + ", error message is: " + e.getMessage(), e); - } - } - } - - private void addOneColumn(UpdateSchema updateSchema, Column column) throws UserException { - if (!column.isAllowNull()) { - throw new UserException("can't add a non-nullable column to an Iceberg table"); - } - org.apache.iceberg.types.Type dorisType = IcebergUtils.dorisTypeToIcebergType(column.getType()); - Literal defaultValue = IcebergUtils.parseIcebergLiteral(column.getDefaultValue(), dorisType); - updateSchema.addColumn(column.getName(), dorisType, column.getComment(), defaultValue); - } - - private void applyPosition(UpdateSchema updateSchema, ColumnPosition position, String columnName) { - if (position.isFirst()) { - updateSchema.moveFirst(columnName); - } else { - updateSchema.moveAfter(columnName, position.getLastCol()); - } - } - - private void refreshTable(ExternalTable dorisTable, long updateTime) { - Optional> db = dorisCatalog.getDbForReplay(dorisTable.getRemoteDbName()); - if (db.isPresent()) { - Optional tbl = db.get().getTableForReplay(dorisTable.getRemoteName()); - if (tbl.isPresent()) { - Env.getCurrentEnv().getRefreshManager() - .refreshTableInternal(db.get(), (ExternalTable) tbl.get(), updateTime); - } - } - } - - @Override - public void addColumn(ExternalTable dorisTable, Column column, ColumnPosition position, long updateTime) - throws UserException { - validateCommonColumnInfo(column); - Table icebergTable = IcebergUtils.getIcebergTable(dorisTable); - UpdateSchema updateSchema = icebergTable.updateSchema(); - addOneColumn(updateSchema, column); - if (position != null) { - applyPosition(updateSchema, position, column.getName()); - } - try { - executionAuthenticator.execute(() -> updateSchema.commit()); - } catch (Exception e) { - throw new UserException("Failed to add column: " + column.getName() + " to table: " - + icebergTable.name() + ", error message is: " + e.getMessage(), e); - } - refreshTable(dorisTable, updateTime); - } - - @Override - public void addColumns(ExternalTable dorisTable, List columns, long updateTime) throws UserException { - Table icebergTable = IcebergUtils.getIcebergTable(dorisTable); - UpdateSchema updateSchema = icebergTable.updateSchema(); - for (Column column : columns) { - validateCommonColumnInfo(column); - addOneColumn(updateSchema, column); - } - try { - executionAuthenticator.execute(() -> updateSchema.commit()); - } catch (Exception e) { - throw new UserException("Failed to add columns to table: " + icebergTable.name() - + ", error message is: " + e.getMessage(), e); - } - refreshTable(dorisTable, updateTime); - } - - @Override - public void dropColumn(ExternalTable dorisTable, String columnName, long updateTime) throws UserException { - Table icebergTable = IcebergUtils.getIcebergTable(dorisTable); - UpdateSchema updateSchema = icebergTable.updateSchema(); - updateSchema.deleteColumn(columnName); - try { - executionAuthenticator.execute(() -> updateSchema.commit()); - } catch (Exception e) { - throw new UserException("Failed to drop column: " + columnName + " from table: " - + icebergTable.name() + ", error message is: " + e.getMessage(), e); - } - refreshTable(dorisTable, updateTime); - } - - @Override - public void renameColumn(ExternalTable dorisTable, String oldName, String newName, long updateTime) - throws UserException { - Table icebergTable = IcebergUtils.getIcebergTable(dorisTable); - UpdateSchema updateSchema = icebergTable.updateSchema(); - updateSchema.renameColumn(oldName, newName); - try { - executionAuthenticator.execute(() -> updateSchema.commit()); - } catch (Exception e) { - throw new UserException("Failed to rename column: " + oldName + " to " + newName - + " in table: " + icebergTable.name() + ", error message is: " + e.getMessage(), e); - } - refreshTable(dorisTable, updateTime); - } - - @Override - public void modifyColumn(ExternalTable dorisTable, Column column, ColumnPosition position, long updateTime) - throws UserException { - Table icebergTable = IcebergUtils.getIcebergTable(dorisTable); - NestedField currentCol = icebergTable.schema().findField(column.getName()); - if (currentCol == null) { - throw new UserException("Column " + column.getName() + " does not exist"); - } - - validateCommonColumnInfo(column); - UpdateSchema updateSchema = icebergTable.updateSchema(); - - if (column.getType().isComplexType()) { - // Complex type processing branch - validateForModifyComplexColumn(column, currentCol); - applyComplexTypeChange(updateSchema, column.getName(), currentCol.type(), column.getType()); - if (column.isAllowNull()) { - updateSchema.makeColumnOptional(column.getName()); - } - if (!Objects.equals(currentCol.doc(), column.getComment())) { - updateSchema.updateColumnDoc(column.getName(), column.getComment()); - } - } else { - // Primitive type processing (existing logic) - validateForModifyColumn(column, currentCol); - Type icebergType = IcebergUtils.dorisTypeToIcebergType(column.getType()); - updateSchema.updateColumn(column.getName(), icebergType.asPrimitiveType(), column.getComment()); - if (column.isAllowNull()) { - // we can change a required column to optional, but not the other way around - // because we don't know whether there is existing data with null values. - updateSchema.makeColumnOptional(column.getName()); - } - } - - if (position != null) { - applyPosition(updateSchema, position, column.getName()); - } - try { - executionAuthenticator.execute(() -> updateSchema.commit()); - } catch (Exception e) { - throw new UserException("Failed to modify column: " + column.getName() + " in table: " - + icebergTable.name() + ", error message is: " + e.getMessage(), e); - } - refreshTable(dorisTable, updateTime); - } - - private void validateForModifyColumn(Column column, NestedField currentCol) throws UserException { - // check complex type - if (column.getType().isComplexType()) { - throw new UserException("Modify column type to non-primitive type is not supported: " + column.getType()); - } - // check nullable - if (currentCol.isOptional() && !column.isAllowNull()) { - throw new UserException("Can not change nullable column " + column.getName() + " to not null"); - } - } - - private void validateForModifyComplexColumn(Column column, NestedField currentCol) throws UserException { - if (!column.getType().isComplexType()) { - throw new UserException("Modify column type to non-complex type is not supported: " + column.getType()); - } - Type oldIcebergType = currentCol.type(); - if (oldIcebergType.isPrimitiveType()) { - throw new UserException("Modify column type from non-complex to complex is not supported: " - + column.getName()); - } - - org.apache.doris.catalog.Type oldDorisType = IcebergUtils.icebergTypeToDorisType(oldIcebergType, false, false); - org.apache.doris.catalog.Type newDorisType = column.getType(); - if (!isSameComplexCategory(oldIcebergType, newDorisType)) { - throw new UserException("Cannot change complex column type category from " - + oldDorisType.toSql() + " to " + newDorisType.toSql()); - } - try { - ColumnType.checkSupportIcebergSchemaChangeForComplexType(oldDorisType, newDorisType, false); - } catch (DdlException e) { - throw new UserException(e.getMessage(), e); - } - if (currentCol.isOptional() && !column.isAllowNull()) { - throw new UserException("Cannot change nullable column " + column.getName() + " to not null"); - } - if (column.getDefaultValue() != null || column.getDefaultValueExprDef() != null) { - throw new UserException("Complex type default value only supports NULL"); - } - } - - private boolean isSameComplexCategory(Type oldIcebergType, org.apache.doris.catalog.Type newDorisType) { - switch (oldIcebergType.typeId()) { - case STRUCT: - return newDorisType.isStructType(); - case LIST: - return newDorisType.isArrayType(); - case MAP: - return newDorisType.isMapType(); - default: - return false; - } - } - - private void applyComplexTypeChange(UpdateSchema updateSchema, String path, - org.apache.iceberg.types.Type oldIcebergType, - org.apache.doris.catalog.Type newDorisType) throws UserException { - switch (oldIcebergType.typeId()) { - case STRUCT: - applyStructChange(updateSchema, path, oldIcebergType.asStructType(), (StructType) newDorisType); - break; - case LIST: - applyListChange(updateSchema, path, (Types.ListType) oldIcebergType, (ArrayType) newDorisType); - break; - case MAP: - applyMapChange(updateSchema, path, (Types.MapType) oldIcebergType, (MapType) newDorisType); - break; - default: - throw new UserException("Unsupported complex type for modify: " + oldIcebergType); - } - } - - private void applyStructChange(UpdateSchema updateSchema, String path, - Types.StructType oldStructType, StructType newStructType) throws UserException { - List oldFields = oldStructType.fields(); - List newFields = newStructType.getFields(); - - for (int i = 0; i < oldFields.size(); i++) { - NestedField oldField = oldFields.get(i); - StructField newField = newFields.get(i); - String fieldPath = path + "." + oldField.name(); - - if (oldField.isOptional() && !newField.getContainsNull()) { - throw new UserException("Cannot change nullable column " + fieldPath + " to not null"); - } - - org.apache.iceberg.types.Type oldFieldType = oldField.type(); - org.apache.doris.catalog.Type newFieldType = newField.getType(); - if (oldFieldType.isPrimitiveType()) { - org.apache.doris.catalog.Type oldDorisFieldType = - IcebergUtils.icebergTypeToDorisType(oldFieldType, false, false); - boolean typeChanged = !oldDorisFieldType.equals(newFieldType); - boolean commentChanged = !Objects.equals(oldField.doc(), newField.getComment()); - if (typeChanged || commentChanged) { - org.apache.iceberg.types.Type newIcebergFieldType = - IcebergUtils.dorisTypeToIcebergType(newFieldType); - updateSchema.updateColumn(fieldPath, newIcebergFieldType.asPrimitiveType(), - newField.getComment()); - } - } else { - applyComplexTypeChange(updateSchema, fieldPath, oldFieldType, newFieldType); - if (!Objects.equals(oldField.doc(), newField.getComment())) { - updateSchema.updateColumnDoc(fieldPath, newField.getComment()); - } - } - - if (!oldField.isOptional() && newField.getContainsNull()) { - updateSchema.makeColumnOptional(fieldPath); - } - } - - for (int i = oldFields.size(); i < newFields.size(); i++) { - StructField newField = newFields.get(i); - if (!newField.getContainsNull()) { - throw new UserException("New struct field '" + newField.getName() + "' must be nullable"); - } - org.apache.iceberg.types.Type newFieldIcebergType = - IcebergUtils.dorisTypeToIcebergType(newField.getType()); - updateSchema.addColumn(path, newField.getName(), newFieldIcebergType, newField.getComment()); - } - } - - private void applyListChange(UpdateSchema updateSchema, String path, - Types.ListType oldListType, ArrayType newArrayType) throws UserException { - String elementPath = path + "." + oldListType.field(oldListType.elementId()).name(); - if (oldListType.isElementOptional() && !newArrayType.getContainsNull()) { - throw new UserException("Cannot change nullable column " + elementPath + " to not null"); - } - org.apache.iceberg.types.Type oldElementType = oldListType.elementType(); - org.apache.doris.catalog.Type newElementType = newArrayType.getItemType(); - if (oldElementType.isPrimitiveType()) { - org.apache.doris.catalog.Type oldDorisElementType = - IcebergUtils.icebergTypeToDorisType(oldElementType, false, false); - if (!oldDorisElementType.equals(newElementType)) { - org.apache.iceberg.types.Type newIcebergElementType = - IcebergUtils.dorisTypeToIcebergType(newElementType); - updateSchema.updateColumn(elementPath, newIcebergElementType.asPrimitiveType(), null); - } - } else { - applyComplexTypeChange(updateSchema, elementPath, oldElementType, newElementType); - } - if (!oldListType.isElementOptional() && newArrayType.getContainsNull()) { - updateSchema.makeColumnOptional(elementPath); - } - } - - private void applyMapChange(UpdateSchema updateSchema, String path, - Types.MapType oldMapType, MapType newMapType) throws UserException { - org.apache.iceberg.types.Type oldKeyType = oldMapType.keyType(); - org.apache.doris.catalog.Type newKeyType = newMapType.getKeyType(); - org.apache.doris.catalog.Type oldDorisKeyType = - IcebergUtils.icebergTypeToDorisType(oldKeyType, false, false); - if (!oldDorisKeyType.equals(newKeyType)) { - throw new UserException("Cannot change MAP key type from " - + oldDorisKeyType.toSql() + " to " + newKeyType.toSql()); - } - - String valuePath = path + "." + oldMapType.field(oldMapType.valueId()).name(); - if (oldMapType.isValueOptional() && !newMapType.getIsValueContainsNull()) { - throw new UserException("Cannot change nullable column " + valuePath + " to not null"); - } - org.apache.iceberg.types.Type oldValueType = oldMapType.valueType(); - org.apache.doris.catalog.Type newValueType = newMapType.getValueType(); - if (oldValueType.isPrimitiveType()) { - org.apache.doris.catalog.Type oldDorisValueType = - IcebergUtils.icebergTypeToDorisType(oldValueType, false, false); - if (!oldDorisValueType.equals(newValueType)) { - org.apache.iceberg.types.Type newIcebergValueType = - IcebergUtils.dorisTypeToIcebergType(newValueType); - updateSchema.updateColumn(valuePath, newIcebergValueType.asPrimitiveType(), null); - } - } else { - applyComplexTypeChange(updateSchema, valuePath, oldValueType, newValueType); - } - if (!oldMapType.isValueOptional() && newMapType.getIsValueContainsNull()) { - updateSchema.makeColumnOptional(valuePath); - } - } - - private void validateCommonColumnInfo(Column column) throws UserException { - // check aggregation method - if (column.isAggregated()) { - throw new UserException("Can not specify aggregation method for iceberg table column"); - } - // check auto inc - if (column.isAutoInc()) { - throw new UserException("Can not specify auto incremental iceberg table column"); - } - } - - @Override - public void reorderColumns(ExternalTable dorisTable, List newOrder, long updateTime) throws UserException { - if (newOrder == null || newOrder.isEmpty()) { - throw new UserException("Reorder column failed, new order is empty."); - } - Table icebergTable = IcebergUtils.getIcebergTable(dorisTable); - UpdateSchema updateSchema = icebergTable.updateSchema(); - updateSchema.moveFirst(newOrder.get(0)); - for (int i = 1; i < newOrder.size(); i++) { - updateSchema.moveAfter(newOrder.get(i), newOrder.get(i - 1)); - } - try { - executionAuthenticator.execute(() -> updateSchema.commit()); - } catch (Exception e) { - throw new UserException("Failed to reorder columns in table: " + icebergTable.name() - + ", error message is: " + e.getMessage(), e); - } - refreshTable(dorisTable, updateTime); - } - - public ExecutionAuthenticator getExecutionAuthenticator() { - return executionAuthenticator; - } - - private Term getTransform(String transformName, String columnName, Integer transformArg) throws UserException { - if (columnName == null) { - throw new UserException("Column name is required for partition transform"); - } - if (transformName == null) { - // identity transform - return Expressions.ref(columnName); - } - switch (transformName.toLowerCase()) { - case "bucket": - if (transformArg == null) { - throw new UserException("Bucket transform requires a bucket count argument"); - } - return Expressions.bucket(columnName, transformArg); - case "truncate": - if (transformArg == null) { - throw new UserException("Truncate transform requires a width argument"); - } - return Expressions.truncate(columnName, transformArg); - case "year": - return Expressions.year(columnName); - case "month": - return Expressions.month(columnName); - case "day": - return Expressions.day(columnName); - case "hour": - return Expressions.hour(columnName); - default: - throw new UserException("Unsupported partition transform: " + transformName); - } - } - - /** - * Add partition field to Iceberg table for partition evolution - */ - public void addPartitionField(ExternalTable dorisTable, AddPartitionFieldOp op, long updateTime) - throws UserException { - Table icebergTable = IcebergUtils.getIcebergTable(dorisTable); - UpdatePartitionSpec updateSpec = icebergTable.updateSpec(); - - String transformName = op.getTransformName(); - Integer transformArg = op.getTransformArg(); - String columnName = op.getColumnName(); - String partitionFieldName = op.getPartitionFieldName(); - Term transform = getTransform(transformName, columnName, transformArg); - - if (partitionFieldName != null) { - updateSpec.addField(partitionFieldName, transform); - } else { - updateSpec.addField(transform); - } - - try { - executionAuthenticator.execute(() -> updateSpec.commit()); - } catch (Exception e) { - throw new UserException("Failed to add partition field to table: " + icebergTable.name() - + ", error message is: " + ExceptionUtils.getRootCauseMessage(e), e); - } - refreshTable(dorisTable, updateTime); - } - - /** - * Drop partition field from Iceberg table for partition evolution - */ - public void dropPartitionField(ExternalTable dorisTable, DropPartitionFieldOp op, long updateTime) - throws UserException { - Table icebergTable = IcebergUtils.getIcebergTable(dorisTable); - UpdatePartitionSpec updateSpec = icebergTable.updateSpec(); - - if (op.getPartitionFieldName() != null) { - updateSpec.removeField(op.getPartitionFieldName()); - } else { - String transformName = op.getTransformName(); - Integer transformArg = op.getTransformArg(); - String columnName = op.getColumnName(); - Term transform = getTransform(transformName, columnName, transformArg); - updateSpec.removeField(transform); - } - - try { - executionAuthenticator.execute(() -> updateSpec.commit()); - } catch (Exception e) { - throw new UserException("Failed to drop partition field from table: " + icebergTable.name() - + ", error message is: " + ExceptionUtils.getRootCauseMessage(e), e); - } - refreshTable(dorisTable, updateTime); - } - - /** - * Replace partition field in Iceberg table for partition evolution - */ - public void replacePartitionField(ExternalTable dorisTable, ReplacePartitionFieldOp op, long updateTime) - throws UserException { - Table icebergTable = IcebergUtils.getIcebergTable(dorisTable); - UpdatePartitionSpec updateSpec = icebergTable.updateSpec(); - - // remove old partition field - if (op.getOldPartitionFieldName() != null) { - updateSpec.removeField(op.getOldPartitionFieldName()); - } else { - String oldTransformName = op.getOldTransformName(); - Integer oldTransformArg = op.getOldTransformArg(); - String oldColumnName = op.getOldColumnName(); - Term oldTransform = getTransform(oldTransformName, oldColumnName, oldTransformArg); - updateSpec.removeField(oldTransform); - } - - // add new partition field - String newPartitionFieldName = op.getNewPartitionFieldName(); - String newTransformName = op.getNewTransformName(); - Integer newTransformArg = op.getNewTransformArg(); - String newColumnName = op.getNewColumnName(); - Term newTransform = getTransform(newTransformName, newColumnName, newTransformArg); - - if (newPartitionFieldName != null) { - updateSpec.addField(newPartitionFieldName, newTransform); - } else { - updateSpec.addField(newTransform); - } - - try { - executionAuthenticator.execute(() -> updateSpec.commit()); - } catch (Exception e) { - throw new UserException("Failed to replace partition field in table: " + icebergTable.name() - + ", error message is: " + ExceptionUtils.getRootCauseMessage(e), e); - } - refreshTable(dorisTable, updateTime); - } - - @Override - public Table loadTable(String dbName, String tblName) { - return loadTable(SessionContext.empty(), dbName, tblName); - } - - public Table loadTable(SessionContext ctx, String dbName, String tblName) { - try { - Catalog activeCatalog = catalog(ctx); - return executionAuthenticator.execute(() -> activeCatalog.loadTable(getTableIdentifier(dbName, tblName))); - } catch (Exception e) { - throw new RuntimeException("Failed to load table, error message is:" + e.getMessage(), e); - } - } - - @Override - public boolean viewExists(String remoteDbName, String remoteViewName) { - return viewExists(SessionContext.empty(), remoteDbName, remoteViewName); - } - - public boolean viewExists(SessionContext ctx, String remoteDbName, String remoteViewName) { - Optional viewCatalog = viewCatalog(ctx); - try { - return viewCatalog.isPresent() && executionAuthenticator.execute(() -> - viewCatalog.get().viewExists(getTableIdentifier(remoteDbName, remoteViewName))); - } catch (Exception e) { - throw new RuntimeException("Failed to check view exist, error message is:" + e.getMessage(), e); - - } - } - - @Override - public Object loadView(String dbName, String tblName) { - return loadView(SessionContext.empty(), dbName, tblName); - } - - public Object loadView(SessionContext ctx, String dbName, String tblName) { - Optional viewCatalog = viewCatalog(ctx); - if (!viewCatalog.isPresent()) { - return null; - } - try { - return executionAuthenticator.execute( - () -> viewCatalog.get().loadView(TableIdentifier.of(getNamespace(dbName), tblName))); - } catch (Exception e) { - throw new RuntimeException("Failed to load view, error message is:" + e.getMessage(), e); - } - } - - @Override - public List listViewNames(String db) { - return listViewNames(SessionContext.empty(), db); - } - - public List listViewNames(SessionContext ctx, String db) { - Optional viewCatalog = viewCatalog(ctx); - if (!viewCatalog.isPresent()) { - return Collections.emptyList(); - } - try { - return executionAuthenticator.execute(() -> - viewCatalog.get().listViews(getNamespace(db)) - .stream().map(TableIdentifier::name).collect(Collectors.toList())); - } catch (RuntimeException e) { - // We want to catch real exception like NoSuchNamespaceException and throw it directly - throw e; - } catch (Exception e) { - throw new RuntimeException("Failed to list view names, error message is:" + e.getMessage(), e); - } - } - - private Catalog catalog(SessionContext ctx) { - if (useSessionCatalog(ctx)) { - return sessionCatalogAdapter.delegatedCatalog(ctx); - } - return catalog; - } - - private SupportsNamespaces namespaces(SessionContext ctx) { - if (useSessionCatalog(ctx)) { - return sessionCatalogAdapter.delegatedNamespaces(ctx); - } - return nsCatalog; - } - - private Optional viewCatalog(SessionContext ctx) { - if (!isViewCatalogEnabled()) { - return Optional.empty(); - } - if (useSessionCatalog(ctx)) { - return sessionCatalogAdapter.delegatedViewCatalog(ctx); - } - return defaultViewCatalog; - } - - private Optional resolveDefaultViewCatalog(Catalog catalog, BaseViewSessionCatalog restSessionCatalog, - boolean viewEnabled) { - // Branch on whether this is a REST (session-aware) catalog, not on whether restSessionCatalog happens to - // be built: for REST the default Catalog (asCatalog) is not a ViewCatalog, so views must come from the - // session catalog's asViewCatalog, gated on the REST view-enabled flag. - if (userSessionCatalog != null) { - if (!viewEnabled || restSessionCatalog == null) { - return Optional.empty(); - } - return Optional.of(restSessionCatalog.asViewCatalog(SessionCatalog.SessionContext.createEmpty())); - } - return catalog instanceof ViewCatalog ? Optional.of((ViewCatalog) catalog) : Optional.empty(); - } - - /** - * Whether the current request should use a per-user session catalog. The decision (delegated credential - * present + dynamic identity enabled) is owned by the catalog via {@link IcebergUserSessionCatalog}; non - * session-aware catalogs never take this path. - */ - private boolean useSessionCatalog(SessionContext ctx) { - return userSessionCatalog != null && userSessionCatalog.useSessionCatalog(ctx); - } - - private TableIdentifier getTableIdentifier(String dbName, String tblName) { - Namespace ns = getNamespace(dbName); - return TableIdentifier.of(ns, tblName); - } - - private Namespace getNamespace(String dbName) { - return getNamespace(externalCatalogName, dbName); - } - - @VisibleForTesting - public static Namespace getNamespace(Optional catalogName, String dbName) { - String[] splits = Splitter.on(".").omitEmptyStrings().trimResults().splitToList(dbName).toArray(new String[0]); - if (catalogName.isPresent()) { - splits = Arrays.copyOf(splits, splits.length + 1); - splits[splits.length - 1] = catalogName.get(); - } - return Namespace.of(splits); - } - - private Namespace getNamespace() { - return externalCatalogName.map(Namespace::of).orElseGet(() -> Namespace.empty()); - } - - private boolean isViewCatalogEnabled() { - return defaultViewCatalog.isPresent(); - } - - public ThreadPoolExecutor getThreadPoolWithPreAuth() { - return dorisCatalog.getThreadPoolWithPreAuth(); - } - - private void performDropView(SessionContext ctx, String remoteDbName, String remoteViewName) throws DdlException { - Optional activeViewCatalog = viewCatalog(ctx); - if (!activeViewCatalog.isPresent()) { - throw new DdlException("Drop Iceberg view is not supported with not view catalog."); - } - activeViewCatalog.get().dropView(getTableIdentifier(remoteDbName, remoteViewName)); - } - - /** - * Build Iceberg SortOrder from SortFieldInfo list - */ - private org.apache.iceberg.SortOrder buildSortOrder(List sortFields, Schema schema) { - if (sortFields == null || sortFields.isEmpty()) { - return null; - } - - org.apache.iceberg.SortOrder.Builder builder = org.apache.iceberg.SortOrder.builderFor(schema); - for (SortFieldInfo sortField : sortFields) { - String columnName = getIcebergColumnName(schema, sortField.getColumnName()); - if (sortField.isAscending()) { - if (sortField.isNullFirst()) { - builder.asc(columnName, org.apache.iceberg.NullOrder.NULLS_FIRST); - } else { - builder.asc(columnName, org.apache.iceberg.NullOrder.NULLS_LAST); - } - } else { - if (sortField.isNullFirst()) { - builder.desc(columnName, org.apache.iceberg.NullOrder.NULLS_FIRST); - } else { - builder.desc(columnName, org.apache.iceberg.NullOrder.NULLS_LAST); - } - } - } - return builder.build(); - } - - private static String getIcebergColumnName(Schema schema, String columnName) { - NestedField field = schema.caseInsensitiveFindField(columnName); - return field == null ? columnName : field.name(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMvccSnapshot.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMvccSnapshot.java deleted file mode 100644 index 2c0155a71cd389..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMvccSnapshot.java +++ /dev/null @@ -1,32 +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.datasource.iceberg; - -import org.apache.doris.datasource.mvcc.MvccSnapshot; - -public class IcebergMvccSnapshot implements MvccSnapshot { - private final IcebergSnapshotCacheValue snapshotCacheValue; - - public IcebergMvccSnapshot(IcebergSnapshotCacheValue snapshotCacheValue) { - this.snapshotCacheValue = snapshotCacheValue; - } - - public IcebergSnapshotCacheValue getSnapshotCacheValue() { - return snapshotCacheValue; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergNereidsUtils.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergNereidsUtils.java deleted file mode 100644 index 2308ee0b86de90..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergNereidsUtils.java +++ /dev/null @@ -1,608 +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.datasource.iceberg; - -import org.apache.doris.catalog.Column; -import org.apache.doris.common.UserException; -import org.apache.doris.nereids.analyzer.Unbound; -import org.apache.doris.nereids.analyzer.UnboundSlot; -import org.apache.doris.nereids.trees.expressions.And; -import org.apache.doris.nereids.trees.expressions.Between; -import org.apache.doris.nereids.trees.expressions.EqualTo; -import org.apache.doris.nereids.trees.expressions.Expression; -import org.apache.doris.nereids.trees.expressions.GreaterThan; -import org.apache.doris.nereids.trees.expressions.GreaterThanEqual; -import org.apache.doris.nereids.trees.expressions.InPredicate; -import org.apache.doris.nereids.trees.expressions.IsNull; -import org.apache.doris.nereids.trees.expressions.LessThan; -import org.apache.doris.nereids.trees.expressions.LessThanEqual; -import org.apache.doris.nereids.trees.expressions.NamedExpression; -import org.apache.doris.nereids.trees.expressions.Not; -import org.apache.doris.nereids.trees.expressions.Or; -import org.apache.doris.nereids.trees.expressions.Slot; -import org.apache.doris.nereids.trees.expressions.SlotReference; -import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator; -import org.apache.doris.nereids.trees.expressions.literal.BooleanLiteral; -import org.apache.doris.nereids.trees.expressions.literal.DateLiteral; -import org.apache.doris.nereids.trees.expressions.literal.DateTimeLiteral; -import org.apache.doris.nereids.trees.expressions.literal.DecimalLiteral; -import org.apache.doris.nereids.trees.expressions.literal.DecimalV3Literal; -import org.apache.doris.nereids.trees.expressions.literal.Literal; -import org.apache.doris.nereids.trees.expressions.literal.NullLiteral; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan; -import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; -import org.apache.doris.nereids.trees.plans.logical.LogicalProject; -import org.apache.doris.nereids.trees.plans.visitor.DefaultPlanRewriter; -import org.apache.doris.nereids.util.DateUtils; - -import org.apache.iceberg.Schema; -import org.apache.iceberg.expressions.Expressions; -import org.apache.iceberg.types.Type; -import org.apache.iceberg.types.Types.NestedField; -import org.apache.iceberg.types.Types.TimestampType; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.function.BiFunction; -import javax.annotation.Nullable; - -/** - * Utility class for Iceberg + Nereids integration. - * Provides shared helpers for row-id injection and expression conversion. - */ -public class IcebergNereidsUtils { - - // ==================== Row-ID Injection Utilities ==================== - - /** - * Inject $row_id column into the plan for any Iceberg table scan. - * Used by DELETE and UPDATE commands (single-table, no ambiguity). - */ - public static LogicalPlan injectRowIdColumn(LogicalPlan plan) { - if (hasUnboundPlan(plan)) { - return plan; - } - return (LogicalPlan) plan.accept(new IcebergRowIdInjector(null), null); - } - - /** - * Inject $row_id column only for the specified target table. - * Used by MERGE INTO where source may also be an Iceberg table. - */ - public static LogicalPlan injectRowIdColumn(LogicalPlan plan, IcebergExternalTable targetTable) { - if (hasUnboundPlan(plan)) { - return plan; - } - return (LogicalPlan) plan.accept(new IcebergRowIdInjector(targetTable), null); - } - - /** Check if any slot in the list is the row-id column. */ - public static boolean hasRowIdSlot(List slots) { - return findRowIdSlot(slots).isPresent(); - } - - /** Find the row-id slot in the list, if present. */ - public static Optional findRowIdSlot(List slots) { - for (Slot slot : slots) { - if (Column.ICEBERG_ROWID_COL.equalsIgnoreCase(slot.getName())) { - return Optional.of(slot); - } - } - return Optional.empty(); - } - - /** Check if any project expression is the row-id column. */ - public static boolean hasRowIdProject(List projects) { - for (NamedExpression project : projects) { - if (project instanceof Slot - && Column.ICEBERG_ROWID_COL.equalsIgnoreCase(((Slot) project).getName())) { - return true; - } - } - return false; - } - - /** Resolve the row-id Column definition from the table's full schema. */ - public static Column getRowIdColumn(IcebergExternalTable table) { - List fullSchema = table.getFullSchema(); - if (fullSchema != null) { - for (Column column : fullSchema) { - if (Column.ICEBERG_ROWID_COL.equalsIgnoreCase(column.getName())) { - return column; - } - } - } - return IcebergRowId.createHiddenColumn(); - } - - /** Check if a plan tree contains any unbound nodes or expressions. */ - public static boolean hasUnboundPlan(Plan plan) { - return plan.anyMatch(node -> node instanceof Unbound || ((Plan) node).hasUnboundExpression()); - } - - /** - * Plan rewriter that injects the $row_id hidden column into Iceberg scans and projects. - * - *

    When {@code targetTable} is null, injects on ALL Iceberg scans (DELETE/UPDATE). - * When non-null, only injects on the scan whose table ID matches (MERGE INTO). - */ - private static class IcebergRowIdInjector extends DefaultPlanRewriter { - @Nullable - private final IcebergExternalTable targetTable; - - IcebergRowIdInjector(@Nullable IcebergExternalTable targetTable) { - this.targetTable = targetTable; - } - - @Override - public Plan visitLogicalFileScan(LogicalFileScan scan, Void context) { - if (!(scan.getTable() instanceof IcebergExternalTable)) { - return scan; - } - if (targetTable != null - && ((IcebergExternalTable) scan.getTable()).getId() != targetTable.getId()) { - return scan; - } - if (hasRowIdSlot(scan.getOutput())) { - return scan; - } - IcebergExternalTable table = (IcebergExternalTable) scan.getTable(); - Column rowIdColumn = getRowIdColumn(table); - SlotReference rowIdSlot = SlotReference.fromColumn( - StatementScopeIdGenerator.newExprId(), table, rowIdColumn, scan.getQualifier()); - List outputs = new ArrayList<>(scan.getOutput()); - outputs.add(rowIdSlot); - return scan.withCachedOutput(outputs); - } - - @Override - public Plan visitLogicalProject(LogicalProject project, Void context) { - project = (LogicalProject) visitChildren(this, project, context); - Optional rowIdSlot = findRowIdSlot(project.child().getOutput()); - if (!rowIdSlot.isPresent() || hasRowIdProject(project.getProjects())) { - return project; - } - List newProjects = new ArrayList<>(project.getProjects()); - newProjects.add((NamedExpression) rowIdSlot.get()); - return project.withProjects(newProjects); - } - } - - // ==================== Expression Conversion Utilities ==================== - - /** - * Convert Nereids Expression to Iceberg Expression - */ - public static org.apache.iceberg.expressions.Expression convertNereidsToIcebergExpression( - Expression nereidsExpr, Schema schema) throws UserException { - if (nereidsExpr == null) { - throw new UserException("Nereids expression is null"); - } - - // Handle logical operators - if (nereidsExpr instanceof And) { - And andExpr = (And) nereidsExpr; - org.apache.iceberg.expressions.Expression left = convertNereidsToIcebergExpression(andExpr.child(0), - schema); - org.apache.iceberg.expressions.Expression right = convertNereidsToIcebergExpression(andExpr.child(1), - schema); - if (left != null && right != null) { - return Expressions.and(left, right); - } - throw new UserException("Failed to convert AND expression: one or both children are unsupported"); - } - - if (nereidsExpr instanceof Or) { - Or orExpr = (Or) nereidsExpr; - org.apache.iceberg.expressions.Expression left = convertNereidsToIcebergExpression(orExpr.child(0), - schema); - org.apache.iceberg.expressions.Expression right = convertNereidsToIcebergExpression(orExpr.child(1), - schema); - if (left != null && right != null) { - return Expressions.or(left, right); - } - throw new UserException("Failed to convert OR expression: one or both children are unsupported"); - } - - if (nereidsExpr instanceof Not) { - Not notExpr = (Not) nereidsExpr; - org.apache.iceberg.expressions.Expression child = convertNereidsToIcebergExpression(notExpr.child(), - schema); - if (child != null) { - return Expressions.not(child); - } - throw new UserException("Failed to convert NOT expression: child is unsupported"); - } - - // Handle comparison operators - if (nereidsExpr instanceof EqualTo) { - return convertNereidsBinaryPredicate((EqualTo) nereidsExpr, - schema, Expressions::equal); - } - - if (nereidsExpr instanceof GreaterThan) { - return convertNereidsBinaryPredicate( - (GreaterThan) nereidsExpr, schema, - Expressions::greaterThan); - } - - if (nereidsExpr instanceof GreaterThanEqual) { - return convertNereidsBinaryPredicate( - (GreaterThanEqual) nereidsExpr, schema, - Expressions::greaterThanOrEqual); - } - - if (nereidsExpr instanceof LessThan) { - return convertNereidsBinaryPredicate((LessThan) nereidsExpr, - schema, Expressions::lessThan); - } - - if (nereidsExpr instanceof LessThanEqual) { - return convertNereidsBinaryPredicate( - (LessThanEqual) nereidsExpr, schema, - Expressions::lessThanOrEqual); - } - - // Handle IN predicates - if (nereidsExpr instanceof InPredicate) { - return convertNereidsInPredicate((InPredicate) nereidsExpr, - schema); - } - - // Handle IS NULL - if (nereidsExpr instanceof IsNull) { - Expression child = ((IsNull) nereidsExpr).child(); - if (child instanceof Slot) { - String colName = extractColumnName((Slot) child); - NestedField nestedField = schema.caseInsensitiveFindField(colName); - if (nestedField == null) { - throw new UserException("Column not found in Iceberg schema: " + colName); - } - return Expressions.isNull(nestedField.name()); - } - throw new UserException("IS NULL requires a column reference"); - } - - // Handle BETWEEN predicates - if (nereidsExpr instanceof Between) { - return convertNereidsBetween((Between) nereidsExpr, - schema); - } - - throw new UserException("Unsupported expression type: " + nereidsExpr.getClass().getName()); - } - - /** - * Convert Nereids binary predicate (comparison operators) - */ - private static org.apache.iceberg.expressions.Expression convertNereidsBinaryPredicate( - Expression nereidsExpr, Schema schema, - BiFunction converter) throws UserException { - - // Extract slot and literal from the binary predicate - Slot slot = null; - Literal literal = null; - - if (nereidsExpr.children().size() == 2) { - Expression left = nereidsExpr.child(0); - Expression right = nereidsExpr.child(1); - - if (left instanceof Slot && right instanceof Literal) { - slot = (Slot) left; - literal = (Literal) right; - } else if (left instanceof Literal && right instanceof Slot) { - slot = (Slot) right; - literal = (Literal) left; - } - } - - if (slot == null || literal == null) { - throw new UserException("Binary predicate must be between a column and a literal"); - } - - String colName = extractColumnName(slot); - NestedField nestedField = schema.caseInsensitiveFindField(colName); - if (nestedField == null) { - throw new UserException("Column not found in Iceberg schema: " + colName); - } - - colName = nestedField.name(); - Object value = extractNereidsLiteralValue(literal, nestedField.type()); - - if (value == null) { - if (literal instanceof NullLiteral) { - return Expressions.isNull(colName); - } - throw new UserException("Unsupported or null literal value for column: " + colName); - } - - return converter.apply(colName, value); - } - - /** - * Convert Nereids IN predicate - */ - private static org.apache.iceberg.expressions.Expression convertNereidsInPredicate( - InPredicate inPredicate, Schema schema) throws UserException { - if (inPredicate.children().size() < 2) { - throw new UserException("IN predicate requires at least one value"); - } - - org.apache.doris.nereids.trees.expressions.Expression left = inPredicate.child(0); - if (!(left instanceof Slot)) { - throw new UserException("Left side of IN predicate must be a slot"); - } - - Slot slot = (Slot) left; - String colName = extractColumnName(slot); - NestedField nestedField = schema.caseInsensitiveFindField(colName); - if (nestedField == null) { - throw new UserException("Column not found in Iceberg schema: " + colName); - } - - colName = nestedField.name(); - List values = new ArrayList<>(); - - for (int i = 1; i < inPredicate.children().size(); i++) { - Expression child = inPredicate.child(i); - if (!(child instanceof Literal)) { - throw new UserException("IN predicate values must be literals"); - } - - Object value = extractNereidsLiteralValue( - (Literal) child, nestedField.type()); - if (value == null) { - throw new UserException("Null or unsupported value in IN predicate for column: " + colName); - } - values.add(value); - } - - return Expressions.in(colName, values); - } - - /** - * Convert Nereids BETWEEN predicate - * BETWEEN a AND b is equivalent to: a <= col <= b - */ - private static org.apache.iceberg.expressions.Expression convertNereidsBetween( - Between between, Schema schema) throws UserException { - if (between.children().size() != 3) { - throw new UserException("BETWEEN predicate must have exactly 3 children"); - } - - Expression compareExpr = between.getCompareExpr(); - Expression lowerBound = between.getLowerBound(); - Expression upperBound = between.getUpperBound(); - - // Validate that compareExpr is a slot - if (!(compareExpr instanceof Slot)) { - throw new UserException("Left side of BETWEEN predicate must be a slot"); - } - - // Validate that lowerBound and upperBound are literals - if (!(lowerBound instanceof Literal)) { - throw new UserException("Lower bound of BETWEEN predicate must be a literal"); - } - if (!(upperBound instanceof Literal)) { - throw new UserException("Upper bound of BETWEEN predicate must be a literal"); - } - - Slot slot = (Slot) compareExpr; - String colName = extractColumnName(slot); - NestedField nestedField = schema.caseInsensitiveFindField(colName); - if (nestedField == null) { - throw new UserException("Column not found in Iceberg schema: " + colName); - } - - colName = nestedField.name(); - - // Extract values - Object lowerValue = extractNereidsLiteralValue((Literal) lowerBound, nestedField.type()); - Object upperValue = extractNereidsLiteralValue((Literal) upperBound, nestedField.type()); - - if (lowerValue == null || upperValue == null) { - throw new UserException("BETWEEN predicate bounds cannot be null for column: " + colName); - } - - // BETWEEN a AND b is equivalent to: a <= col AND col <= b - org.apache.iceberg.expressions.Expression lowerBoundExpr = Expressions.greaterThanOrEqual(colName, lowerValue); - org.apache.iceberg.expressions.Expression upperBoundExpr = Expressions.lessThanOrEqual(colName, upperValue); - - return Expressions.and(lowerBoundExpr, upperBoundExpr); - } - - /** - * Extract column name from Slot (SlotReference or UnboundSlot). - * For UnboundSlot, validates that nameParts is a singleton list (single column - * name). - * - * @param slot the slot to extract column name from - * @return the column name - * @throws UserException if UnboundSlot has multiple nameParts or if slot type - * is unsupported - */ - private static String extractColumnName(Slot slot) throws UserException { - if (slot instanceof SlotReference) { - return ((SlotReference) slot).getName(); - } else if (slot instanceof UnboundSlot) { - UnboundSlot unboundSlot = (UnboundSlot) slot; - // Validate that nameParts is a singleton list (simple column name) - if (unboundSlot.getNameParts().size() != 1) { - throw new UserException( - "UnboundSlot must have a single name part, but got: " + unboundSlot.getNameParts()); - } - return unboundSlot.getNameParts().get(0); - } else { - throw new UserException("Unsupported slot type: " + slot.getClass().getName()); - } - } - - /** - * Extract literal value from Nereids Literal expression - */ - static Object extractNereidsLiteralValue( - Literal literal, - Type icebergType) throws UserException { - try { - Object raw = literal.getValue(); - if (raw == null) { - if (literal instanceof NullLiteral) { - return null; - } - throw new UserException("Literal value is null: " + literal); - } - - switch (icebergType.typeId()) { - case BOOLEAN: - if (literal instanceof BooleanLiteral) { - return ((BooleanLiteral) literal).getValue(); - } - // try to convert to boolean - return Boolean.valueOf(raw.toString()); - case STRING: - return literal.getStringValue(); - case INTEGER: - if (raw instanceof Number) { - return ((Number) raw).intValue(); - } - // try to convert to integer - return Integer.parseInt(literal.getStringValue()); - - case LONG: - case TIME: - if (raw instanceof Number) { - return ((Number) raw).longValue(); - } - // try to convert to long - return Long.parseLong(literal.getStringValue()); - case FLOAT: - if (raw instanceof Number) { - return ((Number) raw).floatValue(); - } - // try to convert to float - return Float.parseFloat(literal.getStringValue()); - case DOUBLE: - if (raw instanceof Number) { - return ((Number) raw).doubleValue(); - } - // try to convert to double - return Double.parseDouble(literal.getStringValue()); - case DECIMAL: - if (literal instanceof DecimalV3Literal) { - return ((DecimalV3Literal) literal) - .getValue(); - } - if (literal instanceof DecimalLiteral) { - return ((DecimalLiteral) literal).getValue(); - } - // try parse from string/number - return new BigDecimal(literal.getStringValue()); - case DATE: - if (literal instanceof DateLiteral) { - return ((DateLiteral) literal) - .getStringValue(); - } - // accept string value for date - return literal.getStringValue(); - case TIMESTAMP: - case TIMESTAMP_NANO: { - // Iceberg expects microseconds since epoch. Honor with/without zone semantics. - if (literal instanceof DateTimeLiteral - || literal instanceof DateLiteral) { - LocalDateTime ldt; - long microSecond = 0L; - if (literal instanceof DateTimeLiteral) { - DateTimeLiteral dt = (DateTimeLiteral) literal; - ldt = dt.toJavaDateType(); - microSecond = dt.getMicroSecond(); - } else { - DateLiteral d = (DateLiteral) literal; - ldt = d.toJavaDateType(); - microSecond = 0L; - } - TimestampType ts = (TimestampType) icebergType; - ZoneId zone = ts.shouldAdjustToUTC() - ? DateUtils.getTimeZone() - : ZoneId.of("UTC"); - long epochMicros = ldt.atZone(zone).toInstant().toEpochMilli() * 1000L + microSecond; - return epochMicros; - } - // String literal: try to parse using Doris's built-in datetime parser - // which supports multiple formats including 'yyyy-MM-dd HH:mm:ss' - if (raw instanceof String) { - String value = (String) raw; - // 1) If numeric, treat as epoch micros directly - try { - return Long.parseLong(value); - } catch (NumberFormatException ignored) { - // not a pure number, fall through to datetime parsing - } - - // 2) Try to parse using Doris's DateLiteral.parseDateTime() which supports - // various formats: 'yyyy-MM-dd', 'yyyy-MM-dd HH:mm:ss', ISO formats, etc. - try { - java.time.temporal.TemporalAccessor temporal = DateLiteral.parseDateTime(value).get(); - TimestampType ts = (TimestampType) icebergType; - ZoneId zone = ts.shouldAdjustToUTC() - ? DateUtils.getTimeZone() - : ZoneId.of("UTC"); - - // Build LocalDateTime from TemporalAccessor using DateUtils helper methods - LocalDateTime ldt = LocalDateTime.of( - DateUtils.getOrDefault(temporal, java.time.temporal.ChronoField.YEAR), - DateUtils.getOrDefault(temporal, java.time.temporal.ChronoField.MONTH_OF_YEAR), - DateUtils.getOrDefault(temporal, java.time.temporal.ChronoField.DAY_OF_MONTH), - DateUtils.getHourOrDefault(temporal), - DateUtils.getOrDefault(temporal, java.time.temporal.ChronoField.MINUTE_OF_HOUR), - DateUtils.getOrDefault(temporal, java.time.temporal.ChronoField.SECOND_OF_MINUTE), - DateUtils.getOrDefault(temporal, java.time.temporal.ChronoField.NANO_OF_SECOND)); - - long microSecond = DateUtils.getOrDefault(temporal, - java.time.temporal.ChronoField.NANO_OF_SECOND) / 1000L; - return ldt.atZone(zone).toInstant().toEpochMilli() * 1000L + microSecond; - } catch (Exception ignored) { - // If Doris parser fails, fall back to passing as string for Iceberg to try - } - - return literal.getStringValue(); - } - if (raw instanceof Number) { - return ((Number) raw).longValue(); - } - throw new UserException("Failed to convert timestamp literal to long: " + raw); - } - case UUID: - case FIXED: - case BINARY: - case GEOMETRY: - case GEOGRAPHY: - // Pass through as bytes/strings where possible - return raw; - default: - throw new UserException("Unsupported literal type: " + icebergType.typeId()); - } - } catch (Exception e) { - throw new UserException("Failed to extract literal value: " + e.getMessage()); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergPartition.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergPartition.java deleted file mode 100644 index cccc6244a0d0cc..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergPartition.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.datasource.iceberg; - -import java.util.List; - -public class IcebergPartition { - private final String partitionName; - private final List partitionValues; - private final int specId; - private final long recordCount; - private final long fileSizeInBytes; - private final long fileCount; - private final long lastUpdateTime; - private final long lastSnapshotId; - private final List transforms; - - public IcebergPartition(String partitionName, int specId, long recordCount, long fileSizeInBytes, long fileCount, - long lastUpdateTime, long lastSnapshotId, List partitionValues, - List transforms) { - this.partitionName = partitionName; - this.specId = specId; - this.recordCount = recordCount; - this.fileSizeInBytes = fileSizeInBytes; - this.fileCount = fileCount; - this.lastUpdateTime = lastUpdateTime; - this.lastSnapshotId = lastSnapshotId; - this.partitionValues = partitionValues; - this.transforms = transforms; - } - - public String getPartitionName() { - return partitionName; - } - - public int getSpecId() { - return specId; - } - - public long getRecordCount() { - return recordCount; - } - - public long getFileSizeInBytes() { - return fileSizeInBytes; - } - - public long getFileCount() { - return fileCount; - } - - public long getLastUpdateTime() { - return lastUpdateTime; - } - - public long getLastSnapshotId() { - return lastSnapshotId; - } - - public List getPartitionValues() { - return partitionValues; - } - - public List getTransforms() { - return transforms; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergPartitionInfo.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergPartitionInfo.java deleted file mode 100644 index de36f0855ddd14..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergPartitionInfo.java +++ /dev/null @@ -1,81 +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.datasource.iceberg; - -import org.apache.doris.catalog.PartitionItem; - -import com.google.common.collect.Maps; - -import java.util.Map; -import java.util.Set; - -public class IcebergPartitionInfo { - private final Map nameToPartitionItem; - private final Map nameToIcebergPartition; - private final Map> nameToIcebergPartitionNames; - - private static final IcebergPartitionInfo EMPTY = new IcebergPartitionInfo(); - - private IcebergPartitionInfo() { - this.nameToPartitionItem = Maps.newHashMap(); - this.nameToIcebergPartition = Maps.newHashMap(); - this.nameToIcebergPartitionNames = Maps.newHashMap(); - } - - public IcebergPartitionInfo(Map nameToPartitionItem, - Map nameToIcebergPartition, - Map> nameToIcebergPartitionNames) { - this.nameToPartitionItem = nameToPartitionItem; - this.nameToIcebergPartition = nameToIcebergPartition; - this.nameToIcebergPartitionNames = nameToIcebergPartitionNames; - } - - static IcebergPartitionInfo empty() { - return EMPTY; - } - - public Map getNameToPartitionItem() { - return nameToPartitionItem; - } - - public Map getNameToIcebergPartition() { - return nameToIcebergPartition; - } - - public long getLatestSnapshotId(String partitionName) { - Set icebergPartitionNames = nameToIcebergPartitionNames.get(partitionName); - if (icebergPartitionNames == null) { - return nameToIcebergPartition.get(partitionName).getLastSnapshotId(); - } - long latestSnapshotId = -1; - long latestUpdateTime = -1; - for (String name : icebergPartitionNames) { - IcebergPartition partition = nameToIcebergPartition.get(name); - long lastUpdateTime = partition.getLastUpdateTime(); - // Skip partitions with invalid update time (<= 0 means unknown/invalid) - if (lastUpdateTime <= 0) { - continue; - } - if (latestUpdateTime < lastUpdateTime) { - latestUpdateTime = lastUpdateTime; - latestSnapshotId = partition.getLastSnapshotId(); - } - } - return latestSnapshotId; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergRestExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergRestExternalCatalog.java deleted file mode 100644 index 44f194a0bf7511..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergRestExternalCatalog.java +++ /dev/null @@ -1,271 +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.datasource.iceberg; - -import org.apache.doris.catalog.InfoSchemaDb; -import org.apache.doris.catalog.MysqlDb; -import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.CatalogProperty; -import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.InitCatalogLog; -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.datasource.property.metastore.IcebergRestProperties; -import org.apache.doris.datasource.property.metastore.MetastoreProperties; - -import com.google.common.collect.Lists; -import org.apache.iceberg.catalog.BaseViewSessionCatalog; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -public class IcebergRestExternalCatalog extends IcebergExternalCatalog implements IcebergUserSessionCatalog { - - private static final Logger LOG = LogManager.getLogger(IcebergRestExternalCatalog.class); - - public IcebergRestExternalCatalog(long catalogId, String name, String resource, Map props, - String comment) { - super(catalogId, name, comment); - catalogProperty = new CatalogProperty(resource, props); - } - - @Override - public void onClose() { - super.onClose(); - // The default Catalog is RESTSessionCatalog.asCatalog(empty), which is not itself Closeable; the - // closeable REST client/auth lives on the RESTSessionCatalog owned by IcebergRestProperties, so close - // it here. For a REST catalog the metastore properties are always IcebergRestProperties; they are only - // null before any properties have been parsed (e.g. closing a never-initialized catalog). - MetastoreProperties metaProps = catalogProperty.getMetastoreProperties(); - if (metaProps != null) { - ((IcebergRestProperties) metaProps).closeRestSessionCatalog(); - } - } - - @Override - public List getDbNames() { - SessionContext ctx = SessionContext.current(); - if (!shouldBypassDatabaseCache(ctx)) { - return super.getDbNames(); - } - makeSureInitialized(); - return listLocalDatabaseNamesWithoutCache(ctx); - } - - @Override - public List getDbNamesOrEmpty() { - SessionContext ctx = SessionContext.current(); - if (!shouldBypassDatabaseCache(ctx)) { - return super.getDbNamesOrEmpty(); - } - try { - return getDbNames(); - } catch (Exception e) { - LOG.warn("failed to get db names in catalog {}", getName(), e); - return Collections.emptyList(); - } - } - - @Override - public ExternalDatabase getDbNullable(String dbName) { - if (dbName == null || dbName.isEmpty() || isSystemDatabase(dbName)) { - return super.getDbNullable(dbName); - } - SessionContext ctx = SessionContext.current(); - if (!shouldBypassDatabaseCache(ctx)) { - return super.getDbNullable(dbName); - } - try { - makeSureInitialized(); - } catch (Exception e) { - LOG.warn("failed to get db {} in catalog {}", dbName, getName(), e); - return null; - } - return getDbNullableWithoutCache(ctx, dbName); - } - - @Override - protected boolean shouldBypassTableNameCache(SessionContext ctx) { - return shouldBypassDatabaseCache(ctx); - } - - @Override - protected SessionContext getCatalogInitializationSessionContext() { - SessionContext ctx = SessionContext.current(); - return shouldBypassDatabaseCache(ctx) ? ctx : SessionContext.empty(); - } - - protected List listDatabaseNames(SessionContext ctx) { - return ((IcebergMetadataOps) metadataOps).listDatabaseNames(ctx); - } - - private ExternalDatabase getDbNullableWithoutCache(SessionContext ctx, - String requestedLocalDbName) { - Optional remoteDbName = resolveRemoteDatabaseName(ctx, requestedLocalDbName); - if (!remoteDbName.isPresent()) { - return null; - } - String localDbName = localDatabaseName(remoteDbName.get()); - return buildDbForInit(remoteDbName.get(), localDbName, Util.genIdByName(getName(), localDbName), - InitCatalogLog.Type.ICEBERG, false); - } - - private Optional resolveRemoteDatabaseName(SessionContext ctx, String requestedLocalDbName) { - return listFilteredRemoteDatabaseNames(ctx).stream() - .filter(remoteDbName -> localDatabaseNameMatches(localDatabaseName(remoteDbName), requestedLocalDbName)) - .findFirst(); - } - - private List listLocalDatabaseNamesWithoutCache(SessionContext ctx) { - List localDbNames = listFilteredRemoteDatabaseNames(ctx).stream() - .map(this::localDatabaseName) - .collect(Collectors.toCollection(Lists::newArrayList)); - // System databases are Doris-internal and always visible. The cached path - // (ExternalCatalog#getFilteredDatabaseNames) appends them unconditionally, but that path is - // bypassed for user-session metadata, so mirror the same injection here. - localDbNames.remove(InfoSchemaDb.DATABASE_NAME); - localDbNames.add(InfoSchemaDb.DATABASE_NAME); - localDbNames.remove(MysqlDb.DATABASE_NAME); - localDbNames.add(MysqlDb.DATABASE_NAME); - return localDbNames; - } - - private List listFilteredRemoteDatabaseNames(SessionContext ctx) { - Map includeDatabaseMap = getIncludeDatabaseMap(); - Map excludeDatabaseMap = getExcludeDatabaseMap(); - return Lists.newArrayList(listDatabaseNames(ctx)).stream() - .filter(dbName -> isDatabaseVisible(dbName, includeDatabaseMap, excludeDatabaseMap)) - .collect(Collectors.toList()); - } - - private boolean isDatabaseVisible(String dbName, Map includeDatabaseMap, - Map excludeDatabaseMap) { - if (excludeDatabaseMap.containsKey(dbName)) { - return false; - } - return includeDatabaseMap.isEmpty() || includeDatabaseMap.containsKey(dbName); - } - - private boolean localDatabaseNameMatches(String localDbName, String requestedLocalDbName) { - if (getLowerCaseDatabaseNames() == 1) { - return localDbName.equals(requestedLocalDbName.toLowerCase()); - } - if (getLowerCaseDatabaseNames() == 2) { - return localDbName.equalsIgnoreCase(requestedLocalDbName); - } - return localDbName.equals(requestedLocalDbName); - } - - private String localDatabaseName(String remoteDbName) { - String localDbName = fromRemoteDatabaseName(remoteDbName); - if (getLowerCaseDatabaseNames() == 1) { - return localDbName.toLowerCase(); - } - if (getLowerCaseDatabaseNames() == 2) { - return remoteDbName; - } - return localDbName; - } - - private boolean isSystemDatabase(String dbName) { - return dbName.equalsIgnoreCase(InfoSchemaDb.DATABASE_NAME) || dbName.equalsIgnoreCase(MysqlDb.DATABASE_NAME); - } - - private boolean shouldBypassDatabaseCache(SessionContext ctx) { - // Bypassing the shared cache and using a per-user session catalog are the same condition. - return useSessionCatalog(ctx); - } - - /** - * Whether the given request should use a per-user session catalog. This is the single source of truth for - * that decision, shared by the cache-bypass logic above and by {@link IcebergMetadataOps} via - * {@link IcebergUserSessionCatalog}. - * - *

    Three outcomes: - *

      - *
    • dynamic identity disabled: {@code false} — use the shared default path;
    • - *
    • dynamic identity enabled and the request carries a delegated credential: {@code true} — use the - * per-user session catalog;
    • - *
    • dynamic identity enabled but the request has no delegated credential: throw. A catalog - * configured with {@code iceberg.rest.session=user} has no shared/default identity to fall back on, - * and silently borrowing another request's token would leak credentials across users. So a session - * without a token (e.g. a password login) is rejected here rather than served by the default path.
    • - *
    - */ - @Override - public boolean useSessionCatalog(SessionContext ctx) { - if (!isIcebergRestUserSessionEnabled()) { - return false; - } - if (ctx == null || !ctx.hasDelegatedCredential()) { - throw new IllegalStateException("Catalog " + getName() + " is configured with dynamic identity " - + "(iceberg.rest.session=user) but the current session has no delegated credential. " - + "Access requires a token-based identity (e.g. OAuth/OIDC/JWT)."); - } - return true; - } - - @Override - public BaseViewSessionCatalog getRestSessionCatalog() { - IcebergRestProperties props = restProperties(); - return props == null ? null : props.getRestSessionCatalog(); - } - - @Override - public IcebergRestProperties.DelegatedTokenMode getDelegatedTokenMode() { - IcebergRestProperties props = restProperties(); - return props == null ? IcebergRestProperties.DelegatedTokenMode.ACCESS_TOKEN : props.getDelegatedTokenMode(); - } - - @Override - public boolean isViewEnabled() { - IcebergRestProperties props = restProperties(); - return props != null && props.isIcebergRestViewEnabled(); - } - - @Override - public boolean isNestedNamespaceEnabled() { - IcebergRestProperties props = restProperties(); - return props != null && props.isIcebergRestNestedNamespaceEnabled(); - } - - /** - * Whether REST user-session mode is enabled for this catalog. - * - *

    This is REST-specific behavior, so it lives on {@link IcebergRestExternalCatalog} rather than the - * generic {@link IcebergExternalCatalog} base class. The decision is made purely from catalog - * properties via {@code CatalogProperty#getMetastoreProperties()}, which lazily parses and never - * returns null. It therefore does not force {@code makeSureInitialized()}, and is safe to - * call before catalog initialization, e.g. from the cache-bypass decision in {@link #getDbNames()} / - * {@link #getDbNullable(String)} which runs before initialization. - */ - public boolean isIcebergRestUserSessionEnabled() { - IcebergRestProperties props = restProperties(); - return props != null && props.isIcebergRestUserSessionEnabled(); - } - - private IcebergRestProperties restProperties() { - MetastoreProperties metaProps = catalogProperty.getMetastoreProperties(); - return metaProps instanceof IcebergRestProperties ? (IcebergRestProperties) metaProps : null; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergS3TablesExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergS3TablesExternalCatalog.java deleted file mode 100644 index 37ad664b91ee0d..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergS3TablesExternalCatalog.java +++ /dev/null @@ -1,31 +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.datasource.iceberg; - -import org.apache.doris.datasource.CatalogProperty; - -import java.util.Map; - -public class IcebergS3TablesExternalCatalog extends IcebergExternalCatalog { - - public IcebergS3TablesExternalCatalog(long catalogId, String name, String resource, Map props, - String comment) { - super(catalogId, name, comment); - catalogProperty = new CatalogProperty(resource, props); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSchemaCacheKey.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSchemaCacheKey.java deleted file mode 100644 index 7c2d09511a2c93..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSchemaCacheKey.java +++ /dev/null @@ -1,56 +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.datasource.iceberg; - -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.SchemaCacheKey; - -import com.google.common.base.Objects; - -public class IcebergSchemaCacheKey extends SchemaCacheKey { - private final long schemaId; - - public IcebergSchemaCacheKey(NameMapping nameMapping, long schemaId) { - super(nameMapping); - this.schemaId = schemaId; - } - - public long getSchemaId() { - return schemaId; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof IcebergSchemaCacheKey)) { - return false; - } - if (!super.equals(o)) { - return false; - } - IcebergSchemaCacheKey that = (IcebergSchemaCacheKey) o; - return schemaId == that.schemaId; - } - - @Override - public int hashCode() { - return Objects.hashCode(super.hashCode(), schemaId); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSchemaCacheValue.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSchemaCacheValue.java deleted file mode 100644 index ccfcaab0c7261d..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSchemaCacheValue.java +++ /dev/null @@ -1,37 +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.datasource.iceberg; - -import org.apache.doris.catalog.Column; -import org.apache.doris.datasource.SchemaCacheValue; - -import java.util.List; - -public class IcebergSchemaCacheValue extends SchemaCacheValue { - - private final List partitionColumns; - - public IcebergSchemaCacheValue(List schema, List partitionColumns) { - super(schema); - this.partitionColumns = partitionColumns; - } - - public List getPartitionColumns() { - return partitionColumns; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSessionCatalogAdapter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSessionCatalogAdapter.java deleted file mode 100644 index 4e15e91af7a51a..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSessionCatalogAdapter.java +++ /dev/null @@ -1,150 +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.datasource.iceberg; - -import org.apache.doris.datasource.DelegatedCredential; -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.datasource.property.metastore.IcebergRestProperties.DelegatedTokenMode; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.collect.ImmutableMap; -import org.apache.iceberg.catalog.BaseSessionCatalog; -import org.apache.iceberg.catalog.BaseViewSessionCatalog; -import org.apache.iceberg.catalog.Catalog; -import org.apache.iceberg.catalog.SupportsNamespaces; -import org.apache.iceberg.catalog.ViewCatalog; -import org.apache.iceberg.rest.auth.OAuth2Properties; - -import java.util.Map; -import java.util.Optional; - -/** - * Adapts Doris session-scoped delegated credentials to Iceberg REST {@link BaseSessionCatalog} calls. - * - *

    When Doris has a delegated credential in {@link SessionContext}, Iceberg REST user-session mode requires the - * request to use a session-bound {@link Catalog} or {@link ViewCatalog}. This adapter keeps the plain catalog path for - * requests without delegated credentials and switches to Iceberg's session catalog only for user-session requests. - * - *

    The session catalog is injected directly (it is the {@code RESTSessionCatalog} built by - * {@code IcebergRestProperties}) rather than reflected out of {@code RESTCatalog}'s private field. Non-REST catalogs - * have no session catalog, so it is {@link Optional#empty()} and only the plain-catalog path is ever taken. - */ -class IcebergSessionCatalogAdapter { - - private final Catalog catalog; - private final Optional sessionCatalog; - private final DelegatedTokenMode delegatedTokenMode; - - IcebergSessionCatalogAdapter(Catalog catalog, BaseSessionCatalog sessionCatalog) { - this(catalog, sessionCatalog, DelegatedTokenMode.ACCESS_TOKEN); - } - - IcebergSessionCatalogAdapter(Catalog catalog, BaseSessionCatalog sessionCatalog, - DelegatedTokenMode delegatedTokenMode) { - this.catalog = catalog; - this.sessionCatalog = Optional.ofNullable(sessionCatalog); - this.delegatedTokenMode = delegatedTokenMode; - } - - Catalog catalog(SessionContext context) { - if (!hasDelegatedCredential(context)) { - return catalog; - } - BaseSessionCatalog activeSessionCatalog = requireSessionCatalog(); - return activeSessionCatalog.asCatalog(toIcebergSessionContext(context, delegatedTokenMode)); - } - - SupportsNamespaces namespaces(SessionContext context) { - return (SupportsNamespaces) catalog(context); - } - - Catalog delegatedCatalog(SessionContext context) { - return requireSessionCatalog().asCatalog(toIcebergSessionContext( - requireDelegatedCredential(context), delegatedTokenMode)); - } - - SupportsNamespaces delegatedNamespaces(SessionContext context) { - return (SupportsNamespaces) delegatedCatalog(context); - } - - Optional delegatedViewCatalog(SessionContext context) { - BaseSessionCatalog activeSessionCatalog = requireSessionCatalog(); - if (activeSessionCatalog instanceof BaseViewSessionCatalog) { - return Optional.of(((BaseViewSessionCatalog) activeSessionCatalog) - .asViewCatalog(toIcebergSessionContext(requireDelegatedCredential(context), delegatedTokenMode))); - } - requireDelegatedCredential(context); - return Optional.empty(); - } - - Optional viewCatalog(SessionContext context) { - if (!hasDelegatedCredential(context)) { - return catalog instanceof ViewCatalog ? Optional.of((ViewCatalog) catalog) : Optional.empty(); - } - BaseSessionCatalog sessionCatalog = requireSessionCatalog(); - if (sessionCatalog instanceof BaseViewSessionCatalog) { - return Optional.of(((BaseViewSessionCatalog) sessionCatalog) - .asViewCatalog(toIcebergSessionContext(context, delegatedTokenMode))); - } - return Optional.empty(); - } - - @VisibleForTesting - static org.apache.iceberg.catalog.SessionCatalog.SessionContext toIcebergSessionContext( - SessionContext context) { - return toIcebergSessionContext(context, DelegatedTokenMode.ACCESS_TOKEN); - } - - @VisibleForTesting - static org.apache.iceberg.catalog.SessionCatalog.SessionContext toIcebergSessionContext( - SessionContext context, DelegatedTokenMode delegatedTokenMode) { - Map credentials = ImmutableMap.of(); - if (context.getDelegatedCredential().isPresent()) { - credentials = toIcebergCredentials(context.getDelegatedCredential().get(), delegatedTokenMode); - } - return new org.apache.iceberg.catalog.SessionCatalog.SessionContext( - context.getSessionId(), null, credentials, ImmutableMap.of()); - } - - private BaseSessionCatalog requireSessionCatalog() { - if (!sessionCatalog.isPresent()) { - throw new IllegalStateException("Iceberg REST user session requires a session-aware Iceberg catalog"); - } - return sessionCatalog.get(); - } - - private static SessionContext requireDelegatedCredential(SessionContext context) { - if (!hasDelegatedCredential(context)) { - throw new IllegalStateException("Iceberg REST user session requires delegated credential"); - } - return context; - } - - private static Map toIcebergCredentials( - DelegatedCredential credential, DelegatedTokenMode delegatedTokenMode) { - if (delegatedTokenMode == DelegatedTokenMode.ACCESS_TOKEN) { - return ImmutableMap.of(OAuth2Properties.TOKEN, credential.getToken()); - } - return ImmutableMap.of(IcebergDelegatedCredentialUtils.credentialKey(credential.getType()), - credential.getToken()); - } - - private static boolean hasDelegatedCredential(SessionContext context) { - return context != null && context.hasDelegatedCredential(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSnapshot.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSnapshot.java deleted file mode 100644 index 5903c362d7434e..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSnapshot.java +++ /dev/null @@ -1,36 +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.datasource.iceberg; - -public class IcebergSnapshot { - private final long snapshotId; - private final long schemaId; - - public IcebergSnapshot(long snapshotId, long schemaId) { - this.snapshotId = snapshotId; - this.schemaId = schemaId; - } - - public long getSnapshotId() { - return snapshotId; - } - - public long getSchemaId() { - return schemaId; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSnapshotCacheValue.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSnapshotCacheValue.java deleted file mode 100644 index 95c9a6f26cc5c5..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSnapshotCacheValue.java +++ /dev/null @@ -1,37 +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.datasource.iceberg; - -public class IcebergSnapshotCacheValue { - - private final IcebergPartitionInfo partitionInfo; - private final IcebergSnapshot snapshot; - - public IcebergSnapshotCacheValue(IcebergPartitionInfo partitionInfo, IcebergSnapshot snapshot) { - this.partitionInfo = partitionInfo; - this.snapshot = snapshot; - } - - public IcebergPartitionInfo getPartitionInfo() { - return partitionInfo; - } - - public IcebergSnapshot getSnapshot() { - return snapshot; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSysExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSysExternalTable.java deleted file mode 100644 index 14047dd44ff26b..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSysExternalTable.java +++ /dev/null @@ -1,181 +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.datasource.iceberg; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.SchemaCacheKey; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.systable.SysTable; -import org.apache.doris.statistics.AnalysisInfo; -import org.apache.doris.statistics.BaseAnalysisTask; -import org.apache.doris.statistics.ExternalAnalysisTask; -import org.apache.doris.thrift.THiveTable; -import org.apache.doris.thrift.TIcebergTable; -import org.apache.doris.thrift.TTableDescriptor; -import org.apache.doris.thrift.TTableType; - -import org.apache.iceberg.MetadataTableType; -import org.apache.iceberg.MetadataTableUtils; -import org.apache.iceberg.Table; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -public class IcebergSysExternalTable extends ExternalTable { - private final IcebergExternalTable sourceTable; - private final String sysTableType; - private volatile Table sysIcebergTable; - private volatile List fullSchema; - private volatile SchemaCacheValue schemaCacheValue; - - public IcebergSysExternalTable(IcebergExternalTable sourceTable, String sysTableType) { - super(generateSysTableId(sourceTable.getId(), sysTableType), - sourceTable.getName() + "$" + sysTableType, - sourceTable.getRemoteName() + "$" + sysTableType, - (IcebergExternalCatalog) sourceTable.getCatalog(), - (IcebergExternalDatabase) sourceTable.getDatabase(), - TableIf.TableType.ICEBERG_EXTERNAL_TABLE); - this.sourceTable = sourceTable; - this.sysTableType = sysTableType; - } - - @Override - public String getMetaCacheEngine() { - return sourceTable.getMetaCacheEngine(); - } - - @Override - protected synchronized void makeSureInitialized() { - super.makeSureInitialized(); - if (!objectCreated) { - objectCreated = true; - } - } - - public IcebergExternalTable getSourceTable() { - return sourceTable; - } - - public String getSysTableType() { - return sysTableType; - } - - public boolean isPositionDeletesTable() { - return MetadataTableType.POSITION_DELETES.name().equalsIgnoreCase(sysTableType); - } - - public Table getSysIcebergTable() { - if (sysIcebergTable == null) { - synchronized (this) { - if (sysIcebergTable == null) { - Table baseTable = sourceTable.getIcebergTable(); - MetadataTableType tableType = MetadataTableType.from(sysTableType); - if (tableType == null) { - throw new IllegalArgumentException("Unknown iceberg system table type: " + sysTableType); - } - sysIcebergTable = MetadataTableUtils.createMetadataTableInstance(baseTable, tableType); - } - } - } - return sysIcebergTable; - } - - @Override - public List getFullSchema() { - return getOrCreateSchemaCacheValue().getSchema(); - } - - @Override - public NameMapping getOrBuildNameMapping() { - return sourceTable.getOrBuildNameMapping(); - } - - @Override - public BaseAnalysisTask createAnalysisTask(AnalysisInfo info) { - makeSureInitialized(); - return new ExternalAnalysisTask(info); - } - - @Override - public TTableDescriptor toThrift() { - List schema = getFullSchema(); - if (sourceTable.getIcebergCatalogType().equals("hms")) { - THiveTable tHiveTable = new THiveTable(getDbName(), getName(), new HashMap<>()); - TTableDescriptor tTableDescriptor = new TTableDescriptor(getId(), TTableType.HIVE_TABLE, schema.size(), 0, - getName(), getDbName()); - tTableDescriptor.setHiveTable(tHiveTable); - return tTableDescriptor; - } else { - TIcebergTable icebergTable = new TIcebergTable(getDbName(), getName(), new HashMap<>()); - TTableDescriptor tTableDescriptor = new TTableDescriptor(getId(), TTableType.ICEBERG_TABLE, - schema.size(), 0, getName(), getDbName()); - tTableDescriptor.setIcebergTable(icebergTable); - return tTableDescriptor; - } - } - - @Override - public long fetchRowCount() { - return UNKNOWN_ROW_COUNT; - } - - @Override - public Optional initSchema(SchemaCacheKey key) { - return Optional.of(getOrCreateSchemaCacheValue()); - } - - @Override - public Optional getSchemaCacheValue() { - return Optional.of(getOrCreateSchemaCacheValue()); - } - - @Override - public Map getSupportedSysTables() { - return sourceTable.getSupportedSysTables(); - } - - @Override - public String getComment() { - return "Iceberg system table: " + sysTableType + " for " + sourceTable.getName(); - } - - private static long generateSysTableId(long sourceTableId, String sysTableType) { - return sourceTableId ^ (sysTableType.hashCode() * 31L); - } - - private SchemaCacheValue getOrCreateSchemaCacheValue() { - if (schemaCacheValue == null) { - synchronized (this) { - if (schemaCacheValue == null) { - if (fullSchema == null) { - fullSchema = IcebergUtils.parseSchema(getSysIcebergTable().schema(), - getCatalog().getEnableMappingVarbinary(), - getCatalog().getEnableMappingTimestampTz()); - } - schemaCacheValue = new SchemaCacheValue(fullSchema); - } - } - } - return schemaCacheValue; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergTableCacheValue.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergTableCacheValue.java deleted file mode 100644 index cdef77346ade27..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergTableCacheValue.java +++ /dev/null @@ -1,41 +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.datasource.iceberg; - -import com.google.common.base.Suppliers; -import org.apache.iceberg.Table; - -import java.util.function.Supplier; - -public class IcebergTableCacheValue { - private final Table icebergTable; - private final Supplier latestSnapshotCacheValue; - - public IcebergTableCacheValue(Table icebergTable, Supplier latestSnapshotCacheValue) { - this.icebergTable = icebergTable; - this.latestSnapshotCacheValue = Suppliers.memoize(latestSnapshotCacheValue::get); - } - - public Table getIcebergTable() { - return icebergTable; - } - - public IcebergSnapshotCacheValue getLatestSnapshotCacheValue() { - return latestSnapshotCacheValue.get(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergTransaction.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergTransaction.java deleted file mode 100644 index 1325df321c37ee..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergTransaction.java +++ /dev/null @@ -1,966 +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. -// This file is copied from -// https://github.com/trinodb/trino/blob/438/plugin/trino-iceberg/src/main/java/io/trino/plugin/iceberg/IcebergMetadata.java -// and modified by Doris - -package org.apache.doris.datasource.iceberg; - -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.iceberg.helper.IcebergWriterHelper; -import org.apache.doris.nereids.trees.plans.commands.insert.IcebergInsertCommandContext; -import org.apache.doris.nereids.trees.plans.commands.insert.InsertCommandContext; -import org.apache.doris.thrift.TFileContent; -import org.apache.doris.thrift.TIcebergCommitData; -import org.apache.doris.thrift.TUpdateMode; -import org.apache.doris.transaction.Transaction; - -import com.google.common.base.Preconditions; -import com.google.common.collect.Lists; -import org.apache.iceberg.AppendFiles; -import org.apache.iceberg.DataFile; -import org.apache.iceberg.DeleteFile; -import org.apache.iceberg.FileFormat; -import org.apache.iceberg.FileScanTask; -import org.apache.iceberg.OverwriteFiles; -import org.apache.iceberg.PartitionField; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.ReplacePartitions; -import org.apache.iceberg.RewriteFiles; -import org.apache.iceberg.RowDelta; -import org.apache.iceberg.Schema; -import org.apache.iceberg.SnapshotRef; -import org.apache.iceberg.Table; -import org.apache.iceberg.expressions.Expression; -import org.apache.iceberg.expressions.Expressions; -import org.apache.iceberg.io.CloseableIterable; -import org.apache.iceberg.io.WriteResult; -import org.apache.iceberg.types.Types; -import org.apache.iceberg.util.ContentFileUtil; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -public class IcebergTransaction implements Transaction { - - private static final Logger LOG = LogManager.getLogger(IcebergTransaction.class); - private static final String DELETE_ISOLATION_LEVEL = "delete_isolation_level"; - private static final String DELETE_ISOLATION_LEVEL_DEFAULT = "serializable"; - - private final IcebergMetadataOps ops; - private Table table; - - private org.apache.iceberg.Transaction transaction; - private final List commitDataList = Lists.newArrayList(); - private Optional conflictDetectionFilter = Optional.empty(); - - private IcebergInsertCommandContext insertCtx; - private String branchName; - private Long baseSnapshotId; - private Map> rewrittenDeleteFilesByReferencedDataFile = Collections.emptyMap(); - - // Rewrite operation support - long startingSnapshotId = -1L; // Track the starting snapshot ID for rewrite operations - private final List filesToDelete = Lists.newArrayList(); - private final List filesToAdd = Lists.newArrayList(); - private boolean isRewriteMode = false; - - public IcebergTransaction(IcebergMetadataOps ops) { - this.ops = ops; - } - - public void updateIcebergCommitData(List commitDataList) { - synchronized (this) { - this.commitDataList.addAll(commitDataList); - } - } - - public void setConflictDetectionFilter(Expression filter) { - conflictDetectionFilter = Optional.ofNullable(filter); - } - - public void clearConflictDetectionFilter() { - conflictDetectionFilter = Optional.empty(); - } - - public void setRewrittenDeleteFilesByReferencedDataFile( - Map> rewrittenDeleteFilesByReferencedDataFile) { - this.rewrittenDeleteFilesByReferencedDataFile = - rewrittenDeleteFilesByReferencedDataFile == null - ? Collections.emptyMap() - : rewrittenDeleteFilesByReferencedDataFile; - } - - public List getCommitDataList() { - return commitDataList; - } - - public void updateRewriteFiles(List filesToDelete) { - synchronized (this) { - this.filesToDelete.addAll(filesToDelete); - } - } - - public void beginInsert(ExternalTable dorisTable, Optional ctx) throws UserException { - ctx.ifPresent(c -> this.insertCtx = (IcebergInsertCommandContext) c); - try { - ops.getExecutionAuthenticator().execute(() -> { - // create and start the iceberg transaction - this.table = IcebergUtils.getIcebergTable(dorisTable); - this.baseSnapshotId = null; - // check branch - if (insertCtx != null && insertCtx.getBranchName().isPresent()) { - this.branchName = insertCtx.getBranchName().get(); - SnapshotRef branchRef = table.refs().get(branchName); - if (branchRef == null) { - throw new RuntimeException(branchName + " is not founded in " + dorisTable.getName()); - } else if (!branchRef.isBranch()) { - throw new RuntimeException( - branchName - + " is a tag, not a branch. Tags cannot be targets for producing snapshots"); - } - } - this.transaction = table.newTransaction(); - this.rewrittenDeleteFilesByReferencedDataFile = Collections.emptyMap(); - }); - } catch (Exception e) { - throw new UserException("Failed to begin insert for iceberg table " + dorisTable.getName() - + "because: " + e.getMessage(), e); - } - - } - - /** - * Begin rewrite transaction for data file rewrite operations - */ - public void beginRewrite(ExternalTable dorisTable) throws UserException { - // For rewrite operations, we work directly on the main table - this.branchName = null; - this.isRewriteMode = true; - - try { - ops.getExecutionAuthenticator().execute(() -> { - // create and start the iceberg transaction - this.table = IcebergUtils.getIcebergTable(dorisTable); - this.baseSnapshotId = null; - - // Capture the starting snapshot ID for validation during rewrite commit - Long snapshotId = getSnapshotIdIfPresent(table); - this.startingSnapshotId = snapshotId != null ? snapshotId : -1L; - - // For rewrite operations, we work directly on the main table - // No branch information needed - this.transaction = table.newTransaction(); - LOG.info("Started rewrite transaction for table: {} (main table)", - dorisTable.getName()); - return null; - }); - } catch (Exception e) { - throw new UserException("Failed to begin rewrite for iceberg table " + dorisTable.getName() - + " because: " + e.getMessage(), e); - } - } - - /** - * Finish rewrite operation by committing all file changes using RewriteFiles - * API - */ - public void finishRewrite() { - // TODO: refactor IcebergTransaction to make code cleaner - convertCommitDataListToDataFilesToAdd(); - - if (LOG.isDebugEnabled()) { - LOG.debug("Finishing rewrite with {} files to delete and {} files to add", - filesToDelete.size(), filesToAdd.size()); - } - - try { - ops.getExecutionAuthenticator().execute(() -> { - updateManifestAfterRewrite(); - return null; - }); - } catch (Exception e) { - LOG.error("Failed to finish rewrite transaction", e); - throw new RuntimeException(e); - } - } - - private void convertCommitDataListToDataFilesToAdd() { - if (commitDataList.isEmpty()) { - LOG.debug("No commit data to convert for rewrite operation"); - return; - } - - // Convert commit data to DataFile objects using the same logic as insert - WriteResult writeResult = IcebergWriterHelper.convertToWriterResult(transaction.table(), commitDataList); - - // Add the generated DataFiles to filesToAdd list - synchronized (filesToAdd) { - for (DataFile dataFile : writeResult.dataFiles()) { - filesToAdd.add(dataFile); - } - } - - LOG.info("Converted {} commit data entries to {} DataFiles for rewrite operation", - commitDataList.size(), writeResult.dataFiles().length); - } - - private void updateManifestAfterRewrite() { - if (filesToDelete.isEmpty() && filesToAdd.isEmpty()) { - LOG.info("No files to rewrite, skipping commit"); - return; - } - - RewriteFiles rewriteFiles = transaction.newRewrite(); - - rewriteFiles = rewriteFiles.validateFromSnapshot(startingSnapshotId); - - // For rewrite operations, we work directly on the main table - rewriteFiles = rewriteFiles.scanManifestsWith(ops.getThreadPoolWithPreAuth()); - - // Add files to delete - for (DataFile dataFile : filesToDelete) { - rewriteFiles.deleteFile(dataFile); - } - - // Add files to add - for (DataFile dataFile : filesToAdd) { - rewriteFiles.addFile(dataFile); - } - - // Commit the rewrite operation - rewriteFiles.commit(); - - if (LOG.isDebugEnabled()) { - LOG.debug("Rewrite committed with {} files deleted and {} files added", - filesToDelete.size(), filesToAdd.size()); - } - } - - /** - * Begin delete operation for Iceberg table - */ - public void beginDelete(ExternalTable dorisTable) throws UserException { - try { - ops.getExecutionAuthenticator().execute(() -> { - // create and start the iceberg transaction - this.table = IcebergUtils.getIcebergTable(dorisTable); - this.baseSnapshotId = getSnapshotIdIfPresent(table); - if (table instanceof org.apache.iceberg.HasTableOperations) { - int formatVersion = ((org.apache.iceberg.HasTableOperations) table).operations() - .current().formatVersion(); - if (formatVersion < 2) { - throw new IllegalArgumentException("Iceberg table " + dorisTable.getName() - + " must have format version 2 or higher for position deletes"); - } - } - this.transaction = table.newTransaction(); - this.rewrittenDeleteFilesByReferencedDataFile = Collections.emptyMap(); - LOG.info("Started delete transaction for table: {}", dorisTable.getName()); - }); - } catch (Exception e) { - throw new UserException("Failed to begin delete for iceberg table " + dorisTable.getName() - + " because: " + e.getMessage(), e); - } - } - - /** - * Begin merge operation for Iceberg UPDATE (single scan RowDelta). - */ - public void beginMerge(ExternalTable dorisTable) throws UserException { - try { - ops.getExecutionAuthenticator().execute(() -> { - this.branchName = null; - this.table = IcebergUtils.getIcebergTable(dorisTable); - this.baseSnapshotId = getSnapshotIdIfPresent(table); - if (table instanceof org.apache.iceberg.HasTableOperations) { - int formatVersion = ((org.apache.iceberg.HasTableOperations) table).operations() - .current().formatVersion(); - if (formatVersion < 2) { - throw new IllegalArgumentException("Iceberg table " + dorisTable.getName() - + " must have format version 2 or higher for position deletes"); - } - } - this.transaction = table.newTransaction(); - this.rewrittenDeleteFilesByReferencedDataFile = Collections.emptyMap(); - LOG.info("Started merge transaction for table: {}", dorisTable.getName()); - return null; - }); - } catch (Exception e) { - throw new UserException("Failed to begin merge for iceberg table " + dorisTable.getName() - + " because: " + e.getMessage(), e); - } - } - - /** - * Finish delete operation by committing DeleteFiles using RowDelta API - */ - public void finishDelete(NameMapping nameMapping) { - if (LOG.isDebugEnabled()) { - LOG.debug("iceberg table {} delete operation finished!", nameMapping.getFullLocalName()); - } - try { - ops.getExecutionAuthenticator().execute(() -> { - updateManifestAfterDelete(); - }); - } catch (Exception e) { - LOG.warn("Failed to finish delete for iceberg table {}.", nameMapping.getFullLocalName(), e); - throw new RuntimeException(e); - } - } - - /** - * Finish merge operation by committing data and delete files using RowDelta. - */ - public void finishMerge(NameMapping nameMapping) { - if (LOG.isDebugEnabled()) { - LOG.debug("iceberg table {} merge operation finished!", nameMapping.getFullLocalName()); - } - try { - ops.getExecutionAuthenticator().execute(() -> { - updateManifestAfterMerge(); - }); - } catch (Exception e) { - LOG.warn("Failed to finish merge for iceberg table {}.", nameMapping.getFullLocalName(), e); - throw new RuntimeException(e); - } - } - - /** - * Update manifest after delete operation using RowDelta API - */ - private void updateManifestAfterDelete() { - FileFormat fileFormat = IcebergUtils.getFileFormat(transaction.table()); - - if (commitDataList.isEmpty()) { - LOG.info("No delete files to commit"); - return; - } - List deleteFiles = convertCommitDataToDeleteFiles(fileFormat, commitDataList); - List rewrittenDeleteFiles = shouldRewritePreviousDeleteFiles() - ? collectRewrittenDeleteFiles(commitDataList) - : Collections.emptyList(); - - if (deleteFiles.isEmpty()) { - LOG.info("No delete files generated from commit data"); - return; - } - - // Create RowDelta operation - RowDelta rowDelta = transaction.newRowDelta(); - applyRowDeltaValidations(rowDelta, transaction.table(), commitDataList, - collectReferencedDataFiles(commitDataList)); - rowDelta.scanManifestsWith(ops.getThreadPoolWithPreAuth()); - - // Add all delete files - for (DeleteFile deleteFile : deleteFiles) { - rowDelta.addDeletes(deleteFile); - } - - for (DeleteFile deleteFile : rewrittenDeleteFiles) { - rowDelta.removeDeletes(deleteFile); - } - - // Commit the delete operation - rowDelta.commit(); - - LOG.info("Committed {} delete files and removed {} previous delete files", - deleteFiles.size(), rewrittenDeleteFiles.size()); - } - - private List convertCommitDataToDeleteFiles(FileFormat fileFormat, - List commitDataList) { - if (commitDataList.isEmpty()) { - return Collections.emptyList(); - } - - PartitionSpec currentSpec = transaction.table().spec(); - Map specsById = transaction.table().specs(); - Map> commitDataBySpecId = new HashMap<>(); - List missingSpecId = new ArrayList<>(); - - for (TIcebergCommitData commitData : commitDataList) { - if (commitData.isSetPartitionSpecId()) { - commitDataBySpecId.computeIfAbsent(commitData.getPartitionSpecId(), k -> new ArrayList<>()) - .add(commitData); - } else { - missingSpecId.add(commitData); - } - } - - if (!missingSpecId.isEmpty()) { - Preconditions.checkState(!currentSpec.isPartitioned(), - "Missing partition spec id for delete files in partitioned table %s", - transaction.table().name()); - commitDataBySpecId.computeIfAbsent(currentSpec.specId(), k -> new ArrayList<>()) - .addAll(missingSpecId); - } - - List deleteFiles = new ArrayList<>(); - for (Map.Entry> entry : commitDataBySpecId.entrySet()) { - int specId = entry.getKey(); - PartitionSpec spec = specsById.get(specId); - Preconditions.checkState(spec != null, - "Unknown partition spec id %s for delete files in table %s", - specId, transaction.table().name()); - deleteFiles.addAll(IcebergWriterHelper.convertToDeleteFiles(fileFormat, spec, entry.getValue())); - } - - return deleteFiles; - } - - private void updateManifestAfterMerge() { - if (commitDataList.isEmpty()) { - LOG.info("No commit data for merge operation"); - return; - } - - FileFormat fileFormat = IcebergUtils.getFileFormat(transaction.table()); - - List dataCommitData = new ArrayList<>(); - List deleteCommitData = new ArrayList<>(); - - for (TIcebergCommitData commitData : commitDataList) { - if (commitData.isSetFileContent() - && (commitData.getFileContent() == TFileContent.POSITION_DELETES - || commitData.getFileContent() == TFileContent.DELETION_VECTOR)) { - deleteCommitData.add(commitData); - } else { - dataCommitData.add(commitData); - } - } - - List dataFiles = new ArrayList<>(); - if (!dataCommitData.isEmpty()) { - WriteResult writeResult = IcebergWriterHelper.convertToWriterResult( - transaction.table(), dataCommitData); - dataFiles.addAll(Arrays.asList(writeResult.dataFiles())); - } - - List deleteFiles = convertCommitDataToDeleteFiles(fileFormat, deleteCommitData); - List rewrittenDeleteFiles = shouldRewritePreviousDeleteFiles() - ? collectRewrittenDeleteFiles(deleteCommitData) - : Collections.emptyList(); - - if (dataFiles.isEmpty() && deleteFiles.isEmpty()) { - LOG.info("No data or delete files generated from commit data"); - return; - } - - RowDelta rowDelta = transaction.newRowDelta(); - applyRowDeltaValidations(rowDelta, transaction.table(), commitDataList, - collectReferencedDataFiles(deleteCommitData)); - rowDelta.scanManifestsWith(ops.getThreadPoolWithPreAuth()); - - for (DataFile dataFile : dataFiles) { - rowDelta.addRows(dataFile); - } - for (DeleteFile deleteFile : deleteFiles) { - rowDelta.addDeletes(deleteFile); - } - for (DeleteFile deleteFile : rewrittenDeleteFiles) { - rowDelta.removeDeletes(deleteFile); - } - - rowDelta.commit(); - LOG.info("Committed merge with {} data files, {} delete files and removed {} previous delete files", - dataFiles.size(), deleteFiles.size(), rewrittenDeleteFiles.size()); - } - - public void finishInsert(NameMapping nameMapping) { - if (LOG.isDebugEnabled()) { - LOG.info("iceberg table {} insert table finished!", nameMapping.getFullLocalName()); - } - try { - ops.getExecutionAuthenticator().execute(() -> { - //create and start the iceberg transaction - TUpdateMode updateMode = TUpdateMode.APPEND; - if (insertCtx != null) { - updateMode = insertCtx.isOverwrite() - ? TUpdateMode.OVERWRITE - : TUpdateMode.APPEND; - } - updateManifestAfterInsert(updateMode); - return null; - }); - } catch (Exception e) { - LOG.warn("Failed to finish insert for iceberg table {}.", nameMapping.getFullLocalName(), e); - throw new RuntimeException(e); - } - - } - - private void updateManifestAfterInsert(TUpdateMode updateMode) { - List pendingResults; - if (commitDataList.isEmpty()) { - pendingResults = Collections.emptyList(); - } else { - //convert commitDataList to writeResult - WriteResult writeResult = IcebergWriterHelper - .convertToWriterResult(transaction.table(), commitDataList); - pendingResults = Lists.newArrayList(writeResult); - } - - if (updateMode == TUpdateMode.APPEND) { - commitAppendTxn(pendingResults); - } else { - // Check if this is a static partition overwrite - if (insertCtx != null && insertCtx.isStaticPartitionOverwrite()) { - commitStaticPartitionOverwrite(pendingResults); - } else { - commitReplaceTxn(pendingResults); - } - } - } - - @Override - public void commit() throws UserException { - // commit the iceberg transaction - transaction.commitTransaction(); - } - - @Override - public void rollback() { - if (isRewriteMode) { - // Clear the collected files for rewrite mode - synchronized (filesToDelete) { - filesToDelete.clear(); - } - synchronized (filesToAdd) { - filesToAdd.clear(); - } - LOG.info("Rewrite transaction rolled back"); - } - // For insert mode, do nothing as original implementation - } - - public long getUpdateCnt() { - long dataRows = 0; - long deleteRows = 0; - for (TIcebergCommitData commitData : commitDataList) { - long affectedRows = commitData.isSetAffectedRows() - ? commitData.getAffectedRows() - : commitData.getRowCount(); - if (commitData.isSetFileContent() - && (commitData.getFileContent() == TFileContent.POSITION_DELETES - || commitData.getFileContent() == TFileContent.DELETION_VECTOR)) { - deleteRows += affectedRows; - } else { - dataRows += affectedRows; - } - } - // For UPDATE/MERGE, dataRows includes both inserted and update-inserted rows, - // which equals the number of rows affected. Position deletes are internal - // implementation details and should not be double-counted. - return dataRows > 0 ? dataRows : deleteRows; - } - - /** - * Get the number of files that will be deleted in rewrite operation - */ - public int getFilesToDeleteCount() { - synchronized (filesToDelete) { - return filesToDelete.size(); - } - } - - /** - * Get the number of files that will be added in rewrite operation - */ - public int getFilesToAddCount() { - synchronized (filesToAdd) { - return filesToAdd.size(); - } - } - - /** - * Get the total size of files to be deleted in rewrite operation - */ - public long getFilesToDeleteSize() { - synchronized (filesToDelete) { - return filesToDelete.stream().mapToLong(DataFile::fileSizeInBytes).sum(); - } - } - - /** - * Get the total size of files to be added in rewrite operation - */ - public long getFilesToAddSize() { - synchronized (filesToAdd) { - return filesToAdd.stream().mapToLong(DataFile::fileSizeInBytes).sum(); - } - } - - private void commitAppendTxn(List pendingResults) { - // commit append files. - AppendFiles appendFiles = transaction.newAppend().scanManifestsWith(ops.getThreadPoolWithPreAuth()); - if (branchName != null) { - appendFiles = appendFiles.toBranch(branchName); - } - for (WriteResult result : pendingResults) { - Preconditions.checkState(result.referencedDataFiles().length == 0, - "Should have no referenced data files for append."); - Arrays.stream(result.dataFiles()).forEach(appendFiles::appendFile); - } - appendFiles.commit(); - } - - private Long getSnapshotIdIfPresent(Table icebergTable) { - if (icebergTable == null || icebergTable.currentSnapshot() == null) { - return null; - } - return icebergTable.currentSnapshot().snapshotId(); - } - - private void applyBaseSnapshotValidation(RowDelta rowDelta) { - if (baseSnapshotId != null) { - rowDelta.validateFromSnapshot(baseSnapshotId); - } - } - - private void applyRowDeltaValidations(RowDelta rowDelta, Table icebergTable, - List commitDataList, List referencedDataFiles) { - applyBaseSnapshotValidation(rowDelta); - applyConflictDetectionFilter(rowDelta, icebergTable, commitDataList); - if (isSerializableIsolationLevel(icebergTable)) { - rowDelta.validateNoConflictingDataFiles(); - } - rowDelta.validateDeletedFiles(); - rowDelta.validateNoConflictingDeleteFiles(); - if (!referencedDataFiles.isEmpty()) { - rowDelta.validateDataFilesExist(referencedDataFiles); - } - } - - private void applyConflictDetectionFilter(RowDelta rowDelta, Table icebergTable, - List commitDataList) { - Optional partitionFilter = buildConflictDetectionFilter(icebergTable, commitDataList); - Optional combined = - combineConflictDetectionFilters(conflictDetectionFilter, partitionFilter); - combined.ifPresent(rowDelta::conflictDetectionFilter); - } - - private Optional combineConflictDetectionFilters(Optional queryFilter, - Optional partitionFilter) { - if (queryFilter.isPresent() && partitionFilter.isPresent()) { - return Optional.of(Expressions.and(queryFilter.get(), partitionFilter.get())); - } - return queryFilter.isPresent() ? queryFilter : partitionFilter; - } - - private Optional buildConflictDetectionFilter(Table icebergTable, - List commitDataList) { - if (icebergTable == null || commitDataList == null || commitDataList.isEmpty()) { - return Optional.empty(); - } - - PartitionSpec spec = icebergTable.spec(); - if (!spec.isPartitioned()) { - return Optional.empty(); - } - if (!areAllIdentityPartitions(spec)) { - return Optional.empty(); - } - - Schema schema = icebergTable.schema(); - int currentSpecId = spec.specId(); - - Expression combined = null; - for (TIcebergCommitData commitData : commitDataList) { - if (commitData.isSetPartitionSpecId() - && commitData.getPartitionSpecId() != currentSpecId) { - return Optional.empty(); - } - if (!commitData.isSetPartitionSpecId() && spec.isPartitioned()) { - return Optional.empty(); - } - - List partitionValues = extractPartitionValues(commitData); - if (partitionValues.isEmpty() || partitionValues.size() != spec.fields().size()) { - return Optional.empty(); - } - - Expression partitionExpr = buildIdentityPartitionExpression(spec, schema, partitionValues); - if (partitionExpr == null) { - return Optional.empty(); - } - combined = combined == null ? partitionExpr : Expressions.or(combined, partitionExpr); - } - return combined == null ? Optional.empty() : Optional.of(combined); - } - - private boolean areAllIdentityPartitions(PartitionSpec spec) { - for (PartitionField field : spec.fields()) { - if (!field.transform().isIdentity()) { - return false; - } - } - return true; - } - - private Expression buildIdentityPartitionExpression(PartitionSpec spec, Schema schema, - List partitionValues) { - Expression expression = null; - List fields = spec.fields(); - for (int i = 0; i < fields.size(); i++) { - PartitionField field = fields.get(i); - Types.NestedField sourceField = schema.findField(field.sourceId()); - if (sourceField == null) { - return null; - } - String valueStr = partitionValues.get(i); - if ("null".equals(valueStr)) { - valueStr = null; - } - Object value = IcebergUtils.parsePartitionValueFromString(valueStr, sourceField.type()); - Expression predicate = value == null - ? Expressions.isNull(sourceField.name()) - : Expressions.equal(sourceField.name(), value); - expression = expression == null ? predicate : Expressions.and(expression, predicate); - } - return expression; - } - - private List extractPartitionValues(TIcebergCommitData commitData) { - if (commitData == null) { - return Collections.emptyList(); - } - if (commitData.getPartitionValues() != null && !commitData.getPartitionValues().isEmpty()) { - return commitData.getPartitionValues(); - } - if (commitData.getPartitionDataJson() != null && !commitData.getPartitionDataJson().isEmpty()) { - return IcebergUtils.parsePartitionValuesFromJson(commitData.getPartitionDataJson()); - } - return Collections.emptyList(); - } - - private boolean isSerializableIsolationLevel(Table icebergTable) { - if (icebergTable == null) { - return true; - } - String level = icebergTable.properties() - .getOrDefault(DELETE_ISOLATION_LEVEL, DELETE_ISOLATION_LEVEL_DEFAULT); - return "serializable".equalsIgnoreCase(level); - } - - private boolean shouldRewritePreviousDeleteFiles() { - return table != null && IcebergUtils.getFormatVersion(table) >= 3; - } - - private List collectRewrittenDeleteFiles(List deleteCommitData) { - if (deleteCommitData == null || deleteCommitData.isEmpty() - || rewrittenDeleteFilesByReferencedDataFile.isEmpty()) { - return Collections.emptyList(); - } - - Map dedup = new LinkedHashMap<>(); - for (TIcebergCommitData commitData : deleteCommitData) { - if (!commitData.isSetReferencedDataFilePath() - || commitData.getReferencedDataFilePath() == null - || commitData.getReferencedDataFilePath().isEmpty()) { - continue; - } - List oldDeleteFiles = - rewrittenDeleteFilesByReferencedDataFile.get(commitData.getReferencedDataFilePath()); - if (oldDeleteFiles == null) { - continue; - } - for (DeleteFile deleteFile : oldDeleteFiles) { - if (deleteFile != null && ContentFileUtil.isFileScoped(deleteFile)) { - dedup.putIfAbsent(buildDeleteFileDedupKey(deleteFile), deleteFile); - } - } - } - return new ArrayList<>(dedup.values()); - } - - private String buildDeleteFileDedupKey(DeleteFile deleteFile) { - if (deleteFile.format() == FileFormat.PUFFIN) { - return deleteFile.path() + "#" + deleteFile.contentOffset() + "#" - + deleteFile.contentSizeInBytes(); - } - return deleteFile.path().toString(); - } - - private List collectReferencedDataFiles(List commitDataList) { - if (commitDataList == null || commitDataList.isEmpty()) { - return Collections.emptyList(); - } - - List referencedDataFiles = new ArrayList<>(); - for (TIcebergCommitData commitData : commitDataList) { - if (commitData.isSetFileContent() - && commitData.getFileContent() != TFileContent.POSITION_DELETES - && commitData.getFileContent() != TFileContent.DELETION_VECTOR) { - continue; - } - if (commitData.isSetReferencedDataFiles()) { - for (String dataFile : commitData.getReferencedDataFiles()) { - if (dataFile != null && !dataFile.isEmpty()) { - referencedDataFiles.add(dataFile); - } - } - } - if (commitData.isSetReferencedDataFilePath() - && commitData.getReferencedDataFilePath() != null - && !commitData.getReferencedDataFilePath().isEmpty()) { - referencedDataFiles.add(commitData.getReferencedDataFilePath()); - } - } - return referencedDataFiles; - } - - private void commitReplaceTxn(List pendingResults) { - if (pendingResults.isEmpty()) { - // such as : insert overwrite table `dst_tb` select * from `empty_tb` - // 1. if dst_tb is a partitioned table, it will return directly. - // 2. if dst_tb is an unpartitioned table, the `dst_tb` table will be emptied. - if (!transaction.table().spec().isPartitioned()) { - OverwriteFiles overwriteFiles = transaction.newOverwrite(); - if (branchName != null) { - overwriteFiles = overwriteFiles.toBranch(branchName); - } - overwriteFiles = overwriteFiles.scanManifestsWith(ops.getThreadPoolWithPreAuth()); - try (CloseableIterable fileScanTasks = table.newScan().planFiles()) { - OverwriteFiles finalOverwriteFiles = overwriteFiles; - fileScanTasks.forEach(f -> finalOverwriteFiles.deleteFile(f.file())); - } catch (IOException e) { - throw new RuntimeException(e); - } - overwriteFiles.commit(); - } - return; - } - - // commit replace partitions - ReplacePartitions appendPartitionOp = transaction.newReplacePartitions(); - if (branchName != null) { - appendPartitionOp = appendPartitionOp.toBranch(branchName); - } - appendPartitionOp = appendPartitionOp.scanManifestsWith(ops.getThreadPoolWithPreAuth()); - for (WriteResult result : pendingResults) { - Preconditions.checkState(result.referencedDataFiles().length == 0, - "Should have no referenced data files."); - Arrays.stream(result.dataFiles()).forEach(appendPartitionOp::addFile); - } - appendPartitionOp.commit(); - } - - /** - * Commit static partition overwrite operation - * This method uses OverwriteFiles.overwriteByRowFilter() to overwrite only the specified partitions - */ - private void commitStaticPartitionOverwrite(List pendingResults) { - Table icebergTable = transaction.table(); - PartitionSpec spec = icebergTable.spec(); - Schema schema = icebergTable.schema(); - - // Build partition filter expression from static partition values - Expression partitionFilter = buildPartitionFilter( - insertCtx.getStaticPartitionValues(), spec, schema); - - // Create OverwriteFiles operation - OverwriteFiles overwriteFiles = transaction.newOverwrite(); - if (branchName != null) { - overwriteFiles = overwriteFiles.toBranch(branchName); - } - overwriteFiles = overwriteFiles.scanManifestsWith(ops.getThreadPoolWithPreAuth()); - - // Set partition filter to overwrite only matching partitions - overwriteFiles = overwriteFiles.overwriteByRowFilter(partitionFilter); - - // Add new data files - for (WriteResult result : pendingResults) { - Preconditions.checkState(result.referencedDataFiles().length == 0, - "Should have no referenced data files for static partition overwrite."); - Arrays.stream(result.dataFiles()).forEach(overwriteFiles::addFile); - } - - // Commit the overwrite operation - overwriteFiles.commit(); - } - - /** - * Build partition filter expression from static partition key-value pairs - * - * @param staticPartitions Map of partition column name to partition value (as String) - * @param spec PartitionSpec of the table - * @param schema Schema of the table - * @return Iceberg Expression for partition filtering - */ - private Expression buildPartitionFilter( - Map staticPartitions, - PartitionSpec spec, - Schema schema) { - if (staticPartitions == null || staticPartitions.isEmpty()) { - return Expressions.alwaysTrue(); - } - - List predicates = new ArrayList<>(); - - for (PartitionField field : spec.fields()) { - String partitionColName = field.name(); - if (staticPartitions.containsKey(partitionColName)) { - String partitionValueStr = staticPartitions.get(partitionColName); - - // Get source field to determine the type - Types.NestedField sourceField = schema.findField(field.sourceId()); - if (sourceField == null) { - throw new RuntimeException(String.format("Source field not found for partition field: %s", - partitionColName)); - } - - // Convert partition value string to appropriate type - Object partitionValue = IcebergUtils.parsePartitionValueFromString( - partitionValueStr, sourceField.type()); - - // Build equality expression using source field name (not partition field name) - // For identity partitions, Iceberg requires the source column name in expressions - String sourceColName = sourceField.name(); - Expression eqExpr; - if (partitionValue == null) { - eqExpr = Expressions.isNull(sourceColName); - } else { - eqExpr = Expressions.equal(sourceColName, partitionValue); - } - predicates.add(eqExpr); - } - } - - if (predicates.isEmpty()) { - return Expressions.alwaysTrue(); - } - - // Combine all predicates with AND - Expression result = predicates.get(0); - for (int i = 1; i < predicates.size(); i++) { - result = Expressions.and(result, predicates.get(i)); - } - return result; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUserSessionCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUserSessionCatalog.java deleted file mode 100644 index e0d8b1c5b39d28..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUserSessionCatalog.java +++ /dev/null @@ -1,60 +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.datasource.iceberg; - -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.datasource.property.metastore.IcebergRestProperties; - -import org.apache.iceberg.catalog.BaseViewSessionCatalog; - -/** - * Capability interface for an Iceberg catalog that supports per-user dynamic session - * (i.e. {@code iceberg.rest.session=user}). Only {@link IcebergRestExternalCatalog} implements it; every other - * Iceberg catalog type is not session-aware. - * - *

    {@link IcebergMetadataOps} depends on this capability rather than on the concrete REST catalog class or its - * {@link IcebergRestProperties}, so it never has to {@code instanceof}-and-dig for the REST-specific behaviors it - * needs (dynamic session, views, nested namespaces). This mirrors how Iceberg itself models optional capabilities - * (e.g. {@code SupportsNamespaces}, {@code ViewCatalog}). - */ -public interface IcebergUserSessionCatalog { - - /** - * Whether the given request should use a per-user session catalog. Single source of truth for the decision, - * used both for cache bypass and for routing metadata calls. Returns {@code false} when dynamic identity is - * disabled (use the shared default path) and {@code true} when it is enabled and the request carries a - * delegated credential. - * - * @throws IllegalStateException when dynamic identity is enabled but the request has no delegated credential. - * Such a catalog has no shared identity to fall back on, so a tokenless session is rejected rather than - * served by borrowing another request's credential. - */ - boolean useSessionCatalog(SessionContext ctx); - - /** The session-aware Iceberg REST catalog backing this catalog (may be null before initialization). */ - BaseViewSessionCatalog getRestSessionCatalog(); - - /** The delegated-token mode used when attaching the user's credential to session requests. */ - IcebergRestProperties.DelegatedTokenMode getDelegatedTokenMode(); - - /** Whether Iceberg view endpoints are enabled for this catalog. */ - boolean isViewEnabled(); - - /** Whether nested namespaces are enabled for this catalog. */ - boolean isNestedNamespaceEnabled(); -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java deleted file mode 100644 index b6f1d22046d76e..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java +++ /dev/null @@ -1,1999 +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.datasource.iceberg; - -import org.apache.doris.analysis.BinaryPredicate; -import org.apache.doris.analysis.BoolLiteral; -import org.apache.doris.analysis.CastExpr; -import org.apache.doris.analysis.CompoundPredicate; -import org.apache.doris.analysis.DateLiteral; -import org.apache.doris.analysis.DecimalLiteral; -import org.apache.doris.analysis.Expr; -import org.apache.doris.analysis.ExprToExprNameVisitor; -import org.apache.doris.analysis.FloatLiteral; -import org.apache.doris.analysis.FunctionCallExpr; -import org.apache.doris.analysis.InPredicate; -import org.apache.doris.analysis.IntLiteral; -import org.apache.doris.analysis.LiteralExpr; -import org.apache.doris.analysis.NullLiteral; -import org.apache.doris.analysis.PartitionDesc; -import org.apache.doris.analysis.PartitionValue; -import org.apache.doris.analysis.SlotRef; -import org.apache.doris.analysis.StringLiteral; -import org.apache.doris.analysis.TableScanParams; -import org.apache.doris.analysis.TableSnapshot; -import org.apache.doris.catalog.ArrayType; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.MapType; -import org.apache.doris.catalog.PartitionItem; -import org.apache.doris.catalog.PartitionKey; -import org.apache.doris.catalog.RangePartitionItem; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.StructField; -import org.apache.doris.catalog.StructType; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.catalog.Type; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.UserException; -import org.apache.doris.common.util.TimeUtils; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.datasource.iceberg.source.IcebergTableQueryInfo; -import org.apache.doris.datasource.metacache.CacheSpec; -import org.apache.doris.datasource.mvcc.MvccSnapshot; -import org.apache.doris.datasource.mvcc.MvccUtil; -import org.apache.doris.datasource.property.metastore.HMSBaseProperties; -import org.apache.doris.mtmv.MTMVRelatedTableIf; -import org.apache.doris.nereids.exceptions.NotSupportedException; -import org.apache.doris.nereids.trees.expressions.literal.Result; -import org.apache.doris.nereids.types.VarBinaryType; -import org.apache.doris.nereids.util.DateUtils; -import org.apache.doris.persist.gson.GsonUtils; - -import com.github.benmanes.caffeine.cache.Caffeine; -import com.github.benmanes.caffeine.cache.LoadingCache; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Preconditions; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import com.google.common.collect.Range; -import com.google.common.collect.Sets; -import com.google.gson.reflect.TypeToken; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.apache.iceberg.BaseTable; -import org.apache.iceberg.CatalogProperties; -import org.apache.iceberg.FileFormat; -import org.apache.iceberg.FileScanTask; -import org.apache.iceberg.ManifestFile; -import org.apache.iceberg.MetadataColumns; -import org.apache.iceberg.MetadataTableType; -import org.apache.iceberg.MetadataTableUtils; -import org.apache.iceberg.PartitionData; -import org.apache.iceberg.PartitionField; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.PartitionsTable; -import org.apache.iceberg.Schema; -import org.apache.iceberg.Snapshot; -import org.apache.iceberg.SnapshotRef; -import org.apache.iceberg.StructLike; -import org.apache.iceberg.Table; -import org.apache.iceberg.TableProperties; -import org.apache.iceberg.expressions.And; -import org.apache.iceberg.expressions.Expression; -import org.apache.iceberg.expressions.Expressions; -import org.apache.iceberg.expressions.Literal; -import org.apache.iceberg.expressions.ManifestEvaluator; -import org.apache.iceberg.expressions.Not; -import org.apache.iceberg.expressions.Or; -import org.apache.iceberg.expressions.Projections; -import org.apache.iceberg.expressions.Unbound; -import org.apache.iceberg.hive.HiveCatalog; -import org.apache.iceberg.io.CloseableIterable; -import org.apache.iceberg.transforms.Transforms; -import org.apache.iceberg.types.Type.TypeID; -import org.apache.iceberg.types.TypeUtil; -import org.apache.iceberg.types.Types; -import org.apache.iceberg.types.Types.NestedField; -import org.apache.iceberg.types.Types.TimestampType; -import org.apache.iceberg.util.LocationUtil; -import org.apache.iceberg.util.SnapshotUtil; -import org.apache.iceberg.util.StructProjection; -import org.apache.iceberg.view.View; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.IOException; -import java.math.BigDecimal; -import java.nio.ByteBuffer; -import java.time.DateTimeException; -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.Month; -import java.time.ZoneId; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.temporal.ChronoField; -import java.time.temporal.TemporalAccessor; -import java.util.ArrayList; -import java.util.Base64; -import java.util.Comparator; -import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -/** - * Iceberg utils - */ -public class IcebergUtils { - private static final Logger LOG = LogManager.getLogger(IcebergUtils.class); - private static ThreadLocal columnIdThreadLocal = new ThreadLocal() { - @Override - public Integer initialValue() { - return 0; - } - }; - // https://iceberg.apache.org/spec/#schemas-and-data-types - // All time and timestamp values are stored with microsecond precision - private static final int ICEBERG_DATETIME_SCALE_MS = 6; - private static final String PARQUET_NAME = "parquet"; - private static final String ORC_NAME = "orc"; - - public static final String TOTAL_RECORDS = "total-records"; - public static final String TOTAL_POSITION_DELETES = "total-position-deletes"; - public static final String TOTAL_EQUALITY_DELETES = "total-equality-deletes"; - - // nickname in flink and spark - public static final String WRITE_FORMAT = "write-format"; - public static final String COMPRESSION_CODEC = "compression-codec"; - - // nickname in spark - public static final String SPARK_SQL_COMPRESSION_CODEC = "spark.sql.iceberg.compression-codec"; - - public static final long UNKNOWN_SNAPSHOT_ID = -1; // means an empty table - public static final long NEWEST_SCHEMA_ID = -1; - - public static final String YEAR = "year"; - public static final String MONTH = "month"; - public static final String DAY = "day"; - public static final String HOUR = "hour"; - public static final String IDENTITY = "identity"; - public static final int PARTITION_DATA_ID_START = 1000; // org.apache.iceberg.PartitionSpec - - public static final int ICEBERG_ROW_LINEAGE_MIN_VERSION = 3; - public static final String ICEBERG_ROW_ID_COL = "_row_id"; - public static final String ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL = "_last_updated_sequence_number"; - - private static final Pattern SNAPSHOT_ID = Pattern.compile("\\d+"); - - public static boolean hasIcebergCatalogFormatVersion(Map catalogProperties) { - return catalogProperties.containsKey(CatalogProperties.TABLE_OVERRIDE_PREFIX + TableProperties.FORMAT_VERSION) - || catalogProperties.containsKey(CatalogProperties.TABLE_DEFAULT_PREFIX - + TableProperties.FORMAT_VERSION); - } - - public static int getEffectiveIcebergFormatVersion(Map tableProperties, - Map catalogProperties) { - String formatVersion = catalogProperties.get(CatalogProperties.TABLE_OVERRIDE_PREFIX - + TableProperties.FORMAT_VERSION); - if (formatVersion == null) { - formatVersion = tableProperties.get(TableProperties.FORMAT_VERSION); - if (formatVersion == null) { - formatVersion = catalogProperties.get(CatalogProperties.TABLE_DEFAULT_PREFIX - + TableProperties.FORMAT_VERSION); - } - } - if (formatVersion == null) { - return 2; - } - try { - return Integer.parseInt(formatVersion); - } catch (NumberFormatException ignored) { - return 2; - } - } - - /** - * Decide whether a row count can be read from an Iceberg snapshot summary. - * Returns {@link TableIf#UNKNOWN_ROW_COUNT} when required counters are absent - * or when delete semantics make the summary count unsafe to use. - */ - @VisibleForTesting - public static long getCountFromSummary(Map summary, boolean ignoreDanglingDelete) { - String equalityDeletes = summary.get(TOTAL_EQUALITY_DELETES); - String positionDeletes = summary.get(TOTAL_POSITION_DELETES); - String totalRecords = summary.get(TOTAL_RECORDS); - if (equalityDeletes == null || positionDeletes == null || totalRecords == null) { - return TableIf.UNKNOWN_ROW_COUNT; - } - if (!equalityDeletes.equals("0")) { - return TableIf.UNKNOWN_ROW_COUNT; - } - - long deleteCount = Long.parseLong(positionDeletes); - if (deleteCount == 0) { - return Long.parseLong(totalRecords); - } - if (ignoreDanglingDelete) { - return Long.parseLong(totalRecords) - deleteCount; - } else { - return TableIf.UNKNOWN_ROW_COUNT; - } - } - - public static Expression convertToIcebergExpr(Expr expr, Schema schema) { - if (expr == null) { - return null; - } - - Expression expression = null; - // BoolLiteral - if (expr instanceof BoolLiteral) { - BoolLiteral boolLiteral = (BoolLiteral) expr; - boolean value = boolLiteral.getValue(); - if (value) { - expression = Expressions.alwaysTrue(); - } else { - expression = Expressions.alwaysFalse(); - } - } else if (expr instanceof CompoundPredicate) { - CompoundPredicate compoundPredicate = (CompoundPredicate) expr; - switch (compoundPredicate.getOp()) { - case AND: { - Expression left = convertToIcebergExpr(compoundPredicate.getChild(0), schema); - Expression right = convertToIcebergExpr(compoundPredicate.getChild(1), schema); - if (left != null && right != null) { - expression = Expressions.and(left, right); - } else if (left != null) { - return left; - } else if (right != null) { - return right; - } - break; - } - case OR: { - Expression left = convertToIcebergExpr(compoundPredicate.getChild(0), schema); - Expression right = convertToIcebergExpr(compoundPredicate.getChild(1), schema); - if (left != null && right != null) { - expression = Expressions.or(left, right); - } - break; - } - case NOT: { - Expression child = convertToIcebergExpr(compoundPredicate.getChild(0), schema); - if (child != null) { - expression = Expressions.not(child); - } - break; - } - default: - return null; - } - } else if (expr instanceof BinaryPredicate) { - BinaryPredicate eq = (BinaryPredicate) expr; - BinaryPredicate.Operator opCode = eq.getOp(); - SlotRef slotRef = convertDorisExprToSlotRef(eq.getChild(0)); - LiteralExpr literalExpr = null; - if (slotRef == null && eq.getChild(0).isLiteral()) { - literalExpr = (LiteralExpr) eq.getChild(0); - slotRef = convertDorisExprToSlotRef(eq.getChild(1)); - } else if (eq.getChild(1).isLiteral()) { - literalExpr = (LiteralExpr) eq.getChild(1); - } - if (slotRef == null || literalExpr == null) { - return null; - } - String colName = slotRef.getColumnName(); - Types.NestedField nestedField = getPushdownField(schema, colName); - if (nestedField == null) { - return null; - } - colName = nestedField.name(); - Object value = extractDorisLiteral(nestedField.type(), literalExpr); - if (value == null) { - if (opCode == BinaryPredicate.Operator.EQ_FOR_NULL && literalExpr instanceof NullLiteral) { - expression = Expressions.isNull(colName); - } else { - return null; - } - } else { - switch (opCode) { - case EQ: - case EQ_FOR_NULL: - expression = Expressions.equal(colName, value); - break; - case NE: - expression = Expressions.not(Expressions.equal(colName, value)); - break; - case GE: - expression = Expressions.greaterThanOrEqual(colName, value); - break; - case GT: - expression = Expressions.greaterThan(colName, value); - break; - case LE: - expression = Expressions.lessThanOrEqual(colName, value); - break; - case LT: - expression = Expressions.lessThan(colName, value); - break; - default: - return null; - } - } - } else if (expr instanceof InPredicate) { - // InPredicate, only support a in (1,2,3) - InPredicate inExpr = (InPredicate) expr; - SlotRef slotRef = convertDorisExprToSlotRef(inExpr.getChild(0)); - if (slotRef == null) { - return null; - } - String colName = slotRef.getColumnName(); - Types.NestedField nestedField = getPushdownField(schema, colName); - if (nestedField == null) { - return null; - } - colName = nestedField.name(); - List valueList = new ArrayList<>(); - for (int i = 1; i < inExpr.getChildren().size(); ++i) { - if (!(inExpr.getChild(i) instanceof LiteralExpr)) { - return null; - } - LiteralExpr literalExpr = (LiteralExpr) inExpr.getChild(i); - Object value = extractDorisLiteral(nestedField.type(), literalExpr); - if (value == null) { - return null; - } - valueList.add(value); - } - if (inExpr.isNotIn()) { - // not in - expression = Expressions.notIn(colName, valueList); - } else { - // in - expression = Expressions.in(colName, valueList); - } - } - - return checkConversion(expression, schema); - } - - private static Types.NestedField getPushdownField(Schema schema, String colName) { - if (ICEBERG_ROW_ID_COL.equalsIgnoreCase(colName) - || ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL.equalsIgnoreCase(colName)) { - return null; - } - return schema.caseInsensitiveFindField(colName); - } - - private static Expression checkConversion(Expression expression, Schema schema) { - if (expression == null) { - return null; - } - switch (expression.op()) { - case AND: { - And andExpr = (And) expression; - Expression left = checkConversion(andExpr.left(), schema); - Expression right = checkConversion(andExpr.right(), schema); - if (left != null && right != null) { - return andExpr; - } else if (left != null) { - return left; - } else if (right != null) { - return right; - } else { - return null; - } - } - case OR: { - Or orExpr = (Or) expression; - Expression left = checkConversion(orExpr.left(), schema); - Expression right = checkConversion(orExpr.right(), schema); - if (left == null || right == null) { - return null; - } else { - return orExpr; - } - } - case NOT: { - Not notExpr = (Not) expression; - Expression child = checkConversion(notExpr.child(), schema); - if (child == null) { - return null; - } else { - return notExpr; - } - } - case TRUE: - case FALSE: - return expression; - default: - if (!(expression instanceof Unbound)) { - return null; - } - try { - ((Unbound) expression).bind(schema.asStruct(), true); - return expression; - } catch (Exception e) { - LOG.debug("Failed to check expression: {}", e.getMessage()); - return null; - } - } - } - - public static Object extractDorisLiteral(org.apache.iceberg.types.Type icebergType, Expr expr) { - TypeID icebergTypeID = icebergType.typeId(); - if (expr instanceof BoolLiteral) { - BoolLiteral boolLiteral = (BoolLiteral) expr; - switch (icebergTypeID) { - case BOOLEAN: - return boolLiteral.getValue(); - case STRING: - return boolLiteral.getStringValue(); - default: - return null; - } - } else if (expr instanceof DateLiteral) { - DateLiteral dateLiteral = (DateLiteral) expr; - switch (icebergTypeID) { - case STRING: - case DATE: - return dateLiteral.getStringValue(); - case TIMESTAMP: - if (((Types.TimestampType) icebergType).shouldAdjustToUTC()) { - return dateLiteral.getUnixTimestampWithMicroseconds(TimeUtils.getTimeZone()); - } else { - return dateLiteral.getUnixTimestampWithMicroseconds(TimeUtils.getUTCTimeZone()); - } - default: - return null; - } - } else if (expr instanceof DecimalLiteral) { - DecimalLiteral decimalLiteral = (DecimalLiteral) expr; - switch (icebergTypeID) { - case DECIMAL: - return decimalLiteral.getValue(); - case STRING: - return decimalLiteral.getStringValue(); - case DOUBLE: - return decimalLiteral.getDoubleValue(); - default: - return null; - } - } else if (expr instanceof FloatLiteral) { - FloatLiteral floatLiteral = (FloatLiteral) expr; - if (floatLiteral.getType() == Type.FLOAT) { - switch (icebergTypeID) { - case FLOAT: - case DOUBLE: - case DECIMAL: - return floatLiteral.getValue(); - default: - return null; - } - } else { - switch (icebergTypeID) { - case DOUBLE: - case DECIMAL: - return floatLiteral.getValue(); - default: - return null; - } - } - } else if (expr instanceof IntLiteral) { - IntLiteral intLiteral = (IntLiteral) expr; - Type type = intLiteral.getType(); - if (type.isInteger32Type()) { - switch (icebergTypeID) { - case INTEGER: - case LONG: - case FLOAT: - case DOUBLE: - case DATE: - case DECIMAL: - return (int) intLiteral.getValue(); - default: - return null; - } - } else { - // only PrimitiveType.BIGINT - switch (icebergTypeID) { - case INTEGER: - case LONG: - case FLOAT: - case DOUBLE: - case TIME: - case TIMESTAMP: - case DATE: - case DECIMAL: - return intLiteral.getValue(); - default: - return null; - } - } - } else if (expr instanceof StringLiteral) { - String value = expr.getStringValue(); - switch (icebergTypeID) { - case DATE: - case TIME: - case TIMESTAMP: - case STRING: - case UUID: - case DECIMAL: - return value; - case INTEGER: - try { - return Integer.parseInt(value); - } catch (Exception e) { - return null; - } - case LONG: - try { - return Long.parseLong(value); - } catch (Exception e) { - return null; - } - default: - return null; - } - } - return null; - } - - private static SlotRef convertDorisExprToSlotRef(Expr expr) { - SlotRef slotRef = null; - if (expr instanceof SlotRef) { - slotRef = (SlotRef) expr; - } else if (expr instanceof CastExpr) { - if (expr.getChild(0) instanceof SlotRef) { - slotRef = (SlotRef) expr.getChild(0); - } - } - return slotRef; - } - - public static PartitionSpec solveIcebergPartitionSpec(PartitionDesc partitionDesc, Schema schema) - throws UserException { - if (partitionDesc == null) { - return PartitionSpec.unpartitioned(); - } - - ArrayList partitionExprs = partitionDesc.getPartitionExprs(); - PartitionSpec.Builder builder = PartitionSpec.builderFor(schema); - for (Expr expr : partitionExprs) { - if (expr instanceof SlotRef) { - builder.identity(getIcebergColumnName(schema, ((SlotRef) expr).getColumnName())); - } else if (expr instanceof FunctionCallExpr) { - String exprName = expr.accept(ExprToExprNameVisitor.INSTANCE, null); - List params = ((FunctionCallExpr) expr).getParams().exprs(); - switch (exprName.toLowerCase()) { - case "bucket": - builder.bucket( - getIcebergColumnName(schema, - params.get(1).accept(ExprToExprNameVisitor.INSTANCE, null)), - Integer.parseInt(params.get(0).getStringValue())); - break; - case "year": - case "years": - builder.year(getIcebergColumnName(schema, - params.get(0).accept(ExprToExprNameVisitor.INSTANCE, null))); - break; - case "month": - case "months": - builder.month(getIcebergColumnName(schema, - params.get(0).accept(ExprToExprNameVisitor.INSTANCE, null))); - break; - case "date": - case "day": - case "days": - builder.day(getIcebergColumnName(schema, - params.get(0).accept(ExprToExprNameVisitor.INSTANCE, null))); - break; - case "date_hour": - case "hour": - case "hours": - builder.hour(getIcebergColumnName(schema, - params.get(0).accept(ExprToExprNameVisitor.INSTANCE, null))); - break; - case "truncate": - builder.truncate( - getIcebergColumnName(schema, - params.get(1).accept(ExprToExprNameVisitor.INSTANCE, null)), - Integer.parseInt(params.get(0).getStringValue())); - break; - default: - throw new UserException("unsupported partition for " + exprName); - } - } - } - return builder.build(); - } - - private static String getIcebergColumnName(Schema schema, String columnName) { - Types.NestedField field = schema.caseInsensitiveFindField(columnName); - return field == null ? columnName : field.name(); - } - - private static Type icebergPrimitiveTypeToDorisType(org.apache.iceberg.types.Type.PrimitiveType primitive, - boolean enableMappingVarbinary, boolean enableMappingTimestampTz) { - switch (primitive.typeId()) { - case BOOLEAN: - return Type.BOOLEAN; - case INTEGER: - return Type.INT; - case LONG: - return Type.BIGINT; - case FLOAT: - return Type.FLOAT; - case DOUBLE: - return Type.DOUBLE; - case STRING: - return Type.STRING; - case UUID: - return enableMappingVarbinary ? ScalarType.createVarbinaryType(16) : Type.STRING; - case BINARY: - return enableMappingVarbinary ? ScalarType.createVarbinaryType(VarBinaryType.MAX_VARBINARY_LENGTH) - : Type.STRING; - case FIXED: - Types.FixedType fixed = (Types.FixedType) primitive; - return enableMappingVarbinary ? ScalarType.createVarbinaryType(fixed.length()) - : ScalarType.createCharType(fixed.length()); - case DECIMAL: - Types.DecimalType decimal = (Types.DecimalType) primitive; - return ScalarType.createDecimalV3Type(decimal.precision(), decimal.scale()); - case DATE: - return ScalarType.createDateV2Type(); - case TIMESTAMP: - if (enableMappingTimestampTz && ((TimestampType) primitive).shouldAdjustToUTC()) { - return ScalarType.createTimeStampTzType(ICEBERG_DATETIME_SCALE_MS); - } - return ScalarType.createDatetimeV2Type(ICEBERG_DATETIME_SCALE_MS); - case TIME: - return Type.UNSUPPORTED; - default: - throw new IllegalArgumentException("Cannot transform unknown type: " + primitive); - } - } - - public static Type icebergTypeToDorisType(org.apache.iceberg.types.Type type, boolean enableMappingVarbinary, - boolean enableMappingTimestampTz) { - if (type.isPrimitiveType()) { - return icebergPrimitiveTypeToDorisType((org.apache.iceberg.types.Type.PrimitiveType) type, - enableMappingVarbinary, enableMappingTimestampTz); - } - switch (type.typeId()) { - case LIST: - Types.ListType list = (Types.ListType) type; - return ArrayType.create( - icebergTypeToDorisType(list.elementType(), enableMappingVarbinary, enableMappingTimestampTz), - true); - case MAP: - Types.MapType map = (Types.MapType) type; - return new MapType( - icebergTypeToDorisType(map.keyType(), enableMappingVarbinary, enableMappingTimestampTz), - icebergTypeToDorisType(map.valueType(), enableMappingVarbinary, enableMappingTimestampTz)); - case STRUCT: - Types.StructType struct = (Types.StructType) type; - ArrayList nestedTypes = struct.fields().stream().map( - x -> new StructField(x.name(), - icebergTypeToDorisType(x.type(), enableMappingVarbinary, enableMappingTimestampTz))) - .collect(Collectors.toCollection(ArrayList::new)); - return new StructType(nestedTypes); - case VARIANT: - return Type.UNSUPPORTED; - default: - throw new IllegalArgumentException("Cannot transform unknown type: " + type); - } - } - - /** - * Get partition info map for identity partitions only, considering partition - * evolution. - * For non-identity partitions (e.g., day, bucket, truncate), returns null to - * skip - * dynamic partition pruning. - * - * @param partitionData The partition data from the file - * @param partitionSpec The partition spec corresponding to the file's specId - * (required) - * @param timeZone The time zone for timestamp serialization - * @return Map of partition field name to partition value string, or null if - * there are non-identity partitions - */ - public static Map getPartitionInfoMap(PartitionData partitionData, PartitionSpec partitionSpec, - String timeZone) { - Map partitionInfoMap = new HashMap<>(); - List fields = partitionData.getPartitionType().asNestedType().fields(); - - // Check if all partition fields are identity transform - // If any field is not identity, return null to skip dynamic partition pruning - List partitionFields = partitionSpec.fields(); - Preconditions.checkArgument(fields.size() == partitionFields.size(), - "PartitionData fields size does not match PartitionSpec fields size"); - - for (int i = 0; i < fields.size(); i++) { - NestedField field = fields.get(i); - PartitionField partitionField = partitionFields.get(i); - - // Only process identity transform partitions - // For other transforms (day, bucket, truncate, etc.), skip dynamic partition - // pruning - if (!partitionField.transform().isIdentity()) { - if (LOG.isDebugEnabled()) { - LOG.debug( - "Skip dynamic partition pruning for non-identity partition field: {} with transform: {}", - field.name(), partitionField.transform().toString()); - } - return null; - } - - TypeID partitionTypeId = field.type().typeId(); - if (partitionTypeId == TypeID.BINARY || partitionTypeId == TypeID.FIXED) { - if (LOG.isDebugEnabled()) { - LOG.debug( - "Skip dynamic partition pruning for binary partition field: {}", - field.name()); - } - return null; - } - - Object value = partitionData.get(i); - try { - String partitionString = serializePartitionValue(field.type(), value, timeZone); - partitionInfoMap.put(field.name(), partitionString); - } catch (UnsupportedOperationException e) { - LOG.warn("Failed to serialize Iceberg table partition value for field {}: {}", field.name(), - e.getMessage()); - return null; - } - } - return partitionInfoMap; - } - - public static List getIdentityPartitionColumns(Table table) { - LinkedHashSet partitionColumns = new LinkedHashSet<>(); - for (PartitionSpec spec : table.specs().values()) { - for (PartitionField partitionField : spec.fields()) { - if (!partitionField.transform().isIdentity()) { - continue; - } - String columnName = table.schema().findColumnName(partitionField.sourceId()); - if (columnName != null) { - partitionColumns.add(columnName); - } - } - } - return new ArrayList<>(partitionColumns); - } - - public static Map getIdentityPartitionInfoMap(PartitionData partitionData, - PartitionSpec partitionSpec, Table table, String timeZone) { - Map partitionInfoMap = Maps.newLinkedHashMap(); - List fields = partitionData.getPartitionType().asNestedType().fields(); - List partitionFields = partitionSpec.fields(); - Preconditions.checkArgument(fields.size() == partitionFields.size(), - "PartitionData fields size does not match PartitionSpec fields size"); - - for (int i = 0; i < fields.size(); i++) { - NestedField field = fields.get(i); - PartitionField partitionField = partitionFields.get(i); - if (!partitionField.transform().isIdentity()) { - continue; - } - TypeID partitionTypeId = field.type().typeId(); - if (partitionTypeId == TypeID.BINARY || partitionTypeId == TypeID.FIXED) { - continue; - } - - String columnName = table.schema().findColumnName(partitionField.sourceId()); - if (columnName == null) { - continue; - } - Object value = partitionData.get(i); - try { - partitionInfoMap.put(columnName, serializePartitionValue(field.type(), value, timeZone)); - } catch (UnsupportedOperationException e) { - LOG.warn("Failed to serialize Iceberg table partition value for field {}: {}", field.name(), - e.getMessage()); - } - } - return partitionInfoMap; - } - - public static List getPartitionValues(PartitionData partitionData, PartitionSpec partitionSpec, - String timeZone) { - List fields = partitionData.getPartitionType().asNestedType().fields(); - Preconditions.checkArgument(fields.size() == partitionSpec.fields().size(), - "PartitionData fields size does not match PartitionSpec fields size"); - - List partitionValues = new ArrayList<>(fields.size()); - for (int i = 0; i < fields.size(); i++) { - NestedField field = fields.get(i); - Object value = partitionData.get(i); - try { - partitionValues.add(serializePartitionValue(field.type(), value, timeZone)); - } catch (UnsupportedOperationException e) { - LOG.warn("Failed to serialize Iceberg partition value for field {}: {}", field.name(), - e.getMessage()); - partitionValues.add(null); - } - } - return partitionValues; - } - - public static String getPartitionDataJson(PartitionData partitionData, PartitionSpec partitionSpec, - String timeZone) { - List partitionValues = getPartitionValues(partitionData, partitionSpec, timeZone); - return GsonUtils.GSON.toJson(partitionValues); - } - - public static List parsePartitionValuesFromJson(String partitionDataJson) { - if (StringUtils.isBlank(partitionDataJson)) { - return Lists.newArrayList(); - } - try { - java.lang.reflect.Type listType = new TypeToken>() {}.getType(); - return GsonUtils.GSON.fromJson(partitionDataJson, listType); - } catch (Exception e) { - LOG.warn("Failed to parse partition data JSON: {}", partitionDataJson, e); - return Lists.newArrayList(); - } - } - - private static String serializePartitionValue(org.apache.iceberg.types.Type type, Object value, String timeZone) { - switch (type.typeId()) { - case BOOLEAN: - case INTEGER: - case LONG: - case STRING: - case UUID: - case DECIMAL: - if (value == null) { - return null; - } - return value.toString(); - case FLOAT: - if (value == null) { - return null; - } - return Float.toString((Float) value); - case DOUBLE: - if (value == null) { - return null; - } - return Double.toString((Double) value); - // case binary, fixed should not supported, because if return string with utf8, - // the data maybe be corrupted - case DATE: - if (value == null) { - return null; - } - // Iceberg date is stored as days since epoch (1970-01-01) - LocalDate date = LocalDate.ofEpochDay((Integer) value); - return date.format(DateTimeFormatter.ISO_LOCAL_DATE); - case TIME: - if (value == null) { - return null; - } - // Iceberg time is stored as microseconds since midnight - long micros = (Long) value; - LocalTime time = LocalTime.ofNanoOfDay(micros * 1000); - return time.format(DateTimeFormatter.ISO_LOCAL_TIME); - case TIMESTAMP: - if (value == null) { - return null; - } - // Iceberg timestamp is stored as microseconds since epoch - // (1970-01-01T00:00:00) - long timestampMicros = (Long) value; - TimestampType timestampType = (TimestampType) type; - LocalDateTime timestamp = LocalDateTime.ofEpochSecond( - timestampMicros / 1_000_000, (int) (timestampMicros % 1_000_000) * 1000, - ZoneOffset.UTC); - // type is timestamptz if timestampType.shouldAdjustToUTC() is true - if (timestampType.shouldAdjustToUTC()) { - timestamp = timestamp.atZone(ZoneId.of("UTC")).withZoneSameInstant(ZoneId.of(timeZone)) - .toLocalDateTime(); - } - return timestamp.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); - default: - throw new UnsupportedOperationException("Unsupported type for serializePartitionValue: " + type); - } - } - - public static Table getIcebergTable(ExternalTable dorisTable) { - if (useSessionCatalog(dorisTable)) { - return loadIcebergTableWithSession(dorisTable); - } - return icebergExternalMetaCache(dorisTable).getIcebergTable(dorisTable); - } - - private static IcebergExternalMetaCache icebergExternalMetaCache(ExternalCatalog catalog) { - Preconditions.checkNotNull(catalog, "catalog can not be null"); - return Env.getCurrentEnv().getExtMetaCacheMgr().iceberg(catalog.getId()); - } - - private static IcebergExternalMetaCache icebergExternalMetaCache(ExternalTable table) { - return icebergExternalMetaCache(table.getCatalog()); - } - - public static org.apache.iceberg.types.Type dorisTypeToIcebergType(Type type) { - DorisTypeToIcebergType visitor = type.isStructType() ? new DorisTypeToIcebergType((StructType) type) - : new DorisTypeToIcebergType(); - return DorisTypeToIcebergType.visit(type, visitor); - } - - public static Literal parseIcebergLiteral(String value, org.apache.iceberg.types.Type type) { - if (value == null) { - return null; - } - switch (type.typeId()) { - case BOOLEAN: - try { - return Literal.of(Boolean.parseBoolean(value)); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Invalid Boolean string: " + value, e); - } - case INTEGER: - case DATE: - try { - return Literal.of(Integer.parseInt(value)); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Invalid Int string: " + value, e); - } - case LONG: - case TIME: - case TIMESTAMP: - case TIMESTAMP_NANO: - try { - return Literal.of(Long.parseLong(value)); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Invalid Long string: " + value, e); - } - case FLOAT: - try { - return Literal.of(Float.parseFloat(value)); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Invalid Float string: " + value, e); - } - case DOUBLE: - try { - return Literal.of(Double.parseDouble(value)); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Invalid Double string: " + value, e); - } - case STRING: - return Literal.of(value); - case UUID: - try { - return Literal.of(UUID.fromString(value)); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Invalid UUID string: " + value, e); - } - case FIXED: - case BINARY: - case GEOMETRY: - case GEOGRAPHY: - return Literal.of(ByteBuffer.wrap(value.getBytes())); - case DECIMAL: - try { - return Literal.of(new BigDecimal(value)); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Invalid Decimal string: " + value, e); - } - default: - throw new IllegalArgumentException("Cannot parse unknown type: " + type); - } - } - - /** - * Convert human-readable partition value string to appropriate Java type for - * Iceberg expression. - * This is used for static partition overwrite where user specifies partition - * values like PARTITION (dt='2025-01-01', region='bj'). - * - * @param valueStr Partition value as human-readable string (e.g., - * "2025-01-01" for date) - * @param icebergType Iceberg type of the partition field - * @return Converted value object suitable for Iceberg Expression, or null if - * value is null - */ - public static Object parsePartitionValueFromString(String valueStr, org.apache.iceberg.types.Type icebergType) { - if (valueStr == null) { - return null; - } - - try { - switch (icebergType.typeId()) { - case STRING: - return valueStr; - case INTEGER: - return Integer.parseInt(valueStr); - case LONG: - return Long.parseLong(valueStr); - case FLOAT: - return Float.parseFloat(normalizeFloatingPointPartitionValue(valueStr)); - case DOUBLE: - return Double.parseDouble(normalizeFloatingPointPartitionValue(valueStr)); - case BOOLEAN: - return Boolean.parseBoolean(valueStr); - case DATE: - // Parse date string (format: yyyy-MM-dd) to epoch day - return (int) LocalDate.parse(valueStr, DateTimeFormatter.ISO_LOCAL_DATE).toEpochDay(); - case TIMESTAMP: - return parseTimestampToMicros(valueStr, (TimestampType) icebergType); - case DECIMAL: - return new BigDecimal(valueStr); - default: - throw new IllegalArgumentException("Unsupported partition value type: " + icebergType); - } - } catch (Exception e) { - throw new IllegalArgumentException(String.format("Failed to convert partition value '%s' to type %s", - valueStr, icebergType), e); - } - } - - private static String normalizeFloatingPointPartitionValue(String valueStr) { - if ("nan".equalsIgnoreCase(valueStr)) { - return "NaN"; - } - if ("inf".equalsIgnoreCase(valueStr) || "+inf".equalsIgnoreCase(valueStr) - || "infinity".equalsIgnoreCase(valueStr) || "+infinity".equalsIgnoreCase(valueStr)) { - return "Infinity"; - } - if ("-inf".equalsIgnoreCase(valueStr) || "-infinity".equalsIgnoreCase(valueStr)) { - return "-Infinity"; - } - return valueStr; - } - - /** - * Parse timestamp string to microseconds using Doris's built-in datetime - * parser. - * - * @param valueStr Timestamp string in various formats - * @param timestampType Iceberg timestamp type (with or without timezone) - * @return Microseconds since epoch - * @throws IllegalArgumentException if the timestamp string cannot be parsed - */ - private static long parseTimestampToMicros(String valueStr, TimestampType timestampType) { - // Use Doris's built-in DateLiteral.parseDateTime() which supports multiple formats - Result parseResult = - org.apache.doris.nereids.trees.expressions.literal.DateLiteral.parseDateTime(valueStr); - - if (parseResult.isError()) { - throw new IllegalArgumentException( - String.format("Failed to parse timestamp string '%s'", valueStr)); - } - - TemporalAccessor temporal = parseResult.get(); - - // Build LocalDateTime from TemporalAccessor using DateUtils helper methods - LocalDateTime ldt = LocalDateTime.of( - DateUtils.getOrDefault(temporal, ChronoField.YEAR), - DateUtils.getOrDefault(temporal, ChronoField.MONTH_OF_YEAR), - DateUtils.getOrDefault(temporal, ChronoField.DAY_OF_MONTH), - DateUtils.getHourOrDefault(temporal), - DateUtils.getOrDefault(temporal, ChronoField.MINUTE_OF_HOUR), - DateUtils.getOrDefault(temporal, ChronoField.SECOND_OF_MINUTE), - DateUtils.getOrDefault(temporal, ChronoField.NANO_OF_SECOND)); - - // Convert to microseconds - ZoneId zone = timestampType.shouldAdjustToUTC() - ? DateUtils.getTimeZone() - : ZoneId.of("UTC"); - - long epochSecond = ldt.atZone(zone).toInstant().getEpochSecond(); - long microSecond = DateUtils.getOrDefault(temporal, ChronoField.NANO_OF_SECOND) / 1_000L; - - return epochSecond * 1_000_000L + microSecond; - } - - private static void updateIcebergColumnUniqueId(Column column, Types.NestedField icebergField) { - column.setUniqueId(icebergField.fieldId()); - List icebergFields = Lists.newArrayList(); - switch (icebergField.type().typeId()) { - case LIST: - icebergFields = ((Types.ListType) icebergField.type()).fields(); - break; - case MAP: - icebergFields = ((Types.MapType) icebergField.type()).fields(); - break; - case STRUCT: - icebergFields = ((Types.StructType) icebergField.type()).fields(); - break; - default: - return; - } - - if (column.getChildren() != null) { - List childColumns = column.getChildren(); - for (int idx = 0; idx < childColumns.size(); idx++) { - updateIcebergColumnUniqueId(childColumns.get(idx), icebergFields.get(idx)); - } - } - } - - /** - * Get iceberg schema from catalog and convert them to doris schema - */ - private static List getSchema(ExternalTable dorisTable, long schemaId, boolean isView, - Table icebergTable) { - try { - return dorisTable.getCatalog().getExecutionAuthenticator().execute(() -> { - Schema schema; - if (isView) { - View icebergView = getIcebergView(dorisTable); - if (schemaId == NEWEST_SCHEMA_ID) { - schema = icebergView.schema(); - } else { - schema = icebergView.schemas().get((int) schemaId); - } - } else { - Table table = icebergTable != null ? icebergTable : getIcebergTable(dorisTable); - if (schemaId == NEWEST_SCHEMA_ID || table.currentSnapshot() == null) { - schema = table.schema(); - } else { - schema = table.schemas().get((int) schemaId); - } - } - String type = isView ? "view" : "table"; - Preconditions.checkNotNull(schema, - "Schema for " + type + " " + dorisTable.getCatalog().getName() - + "." + dorisTable.getDbName() + "." + dorisTable.getName() + " is null"); - return parseSchema(schema, dorisTable.getCatalog().getEnableMappingVarbinary(), - dorisTable.getCatalog().getEnableMappingTimestampTz()); - }); - } catch (Exception e) { - throw new RuntimeException(ExceptionUtils.getRootCauseMessage(e), e); - } - - } - - /** - * Parse iceberg schema to doris schema - */ - public static List parseSchema(Schema schema, boolean enableMappingVarbinary, - boolean enableMappingTimestampTz) { - List columns = schema.columns(); - List resSchema = Lists.newArrayListWithCapacity(columns.size()); - for (Types.NestedField field : columns) { - String initialDefault = null; - if (field.initialDefault() != null) { - initialDefault = serializeInitialDefault(field.type(), field.initialDefault(), - enableMappingTimestampTz); - } - Column column = new Column(field.name(), - IcebergUtils.icebergTypeToDorisType(field.type(), enableMappingVarbinary, enableMappingTimestampTz), - true, null, true, initialDefault, field.doc(), true, -1); - updateIcebergColumnUniqueId(column, field); - if (field.type().isPrimitiveType() && field.type().typeId() == TypeID.TIMESTAMP) { - Types.TimestampType timestampType = (Types.TimestampType) field.type(); - if (timestampType.shouldAdjustToUTC()) { - column.setWithTZExtraInfo(); - } - } - resSchema.add(column); - } - return resSchema; - } - - private static String serializeInitialDefault(org.apache.iceberg.types.Type type, Object value, - boolean enableMappingTimestampTz) { - String humanValue = Transforms.identity(type).toHumanString(type, value); - if (type.typeId() == TypeID.TIMESTAMP) { - // Iceberg formats timestamps as ISO-8601 (for example 2024-01-01T00:00:00), while - // Doris' DATETIMEV2 default parser requires a space between the date and time. - String dorisValue = humanValue.replace('T', ' '); - Types.TimestampType timestampType = (Types.TimestampType) type; - if (timestampType.shouldAdjustToUTC() && !enableMappingTimestampTz) { - // Iceberg timestamptz human values carry a trailing offset. DATETIMEV2 has no - // offset carrier, so retain the displayed UTC wall time and remove the suffix. - return dorisValue.replaceFirst("(Z|[+-]\\d{2}:\\d{2})$", ""); - } - return dorisValue; - } - if (isBinaryLike(type)) { - // Always use the lossless Base64 carrier. Binary-like Iceberg fields may map to either - // VARBINARY or STRING/CHAR, and the scan schema marker tells BE to decode both forms - // back to the raw bytes stored in equality-delete files. - return serializeBinaryInitialDefault(type, value); - } - return humanValue; - } - - /** - * Return binary-like initial defaults in a lossless transport representation. These defaults - * cannot be carried as raw Java strings and their Doris type is insufficient to identify them - * when varbinary mapping is disabled, because UUID/BINARY/FIXED then map to STRING/CHAR. - */ - public static Map getBase64EncodedInitialDefaults(Schema schema) { - Map result = Maps.newHashMap(); - for (Types.NestedField field : TypeUtil.indexById(schema.asStruct()).values()) { - if (field.initialDefault() == null || !isBinaryLike(field.type())) { - continue; - } - result.put(field.fieldId(), serializeBinaryInitialDefault(field.type(), field.initialDefault())); - } - return result; - } - - private static boolean isBinaryLike(org.apache.iceberg.types.Type type) { - return type.typeId() == TypeID.UUID || type.typeId() == TypeID.BINARY - || type.typeId() == TypeID.FIXED; - } - - private static String serializeBinaryInitialDefault(org.apache.iceberg.types.Type type, Object value) { - if (type.typeId() != TypeID.UUID) { - return Transforms.identity(type).toHumanString(type, value); - } - UUID uuid = (UUID) value; - ByteBuffer bytes = ByteBuffer.allocate(16); - bytes.putLong(uuid.getMostSignificantBits()); - bytes.putLong(uuid.getLeastSignificantBits()); - return Base64.getEncoder().encodeToString(bytes.array()); - } - - /** - * Estimate iceberg table row count. - * Get the row count by adding all task file recordCount. - * - * @return estimated row count - */ - public static long getIcebergRowCount(ExternalTable tbl) { - // the table may be null when the iceberg metadata cache is not loaded.But I don't think it's a problem, - // because the NPE would be caught in the caller and return the default value -1. - // Meanwhile, it will trigger iceberg metadata cache to load the table, so we can get it next time. - Table icebergTable = getIcebergTable(tbl); - Snapshot snapshot = icebergTable.currentSnapshot(); - if (snapshot == null) { - LOG.info("Iceberg table {}.{}.{} is empty, return -1.", - tbl.getCatalog().getName(), tbl.getDbName(), tbl.getName()); - // empty table - return TableIf.UNKNOWN_ROW_COUNT; - } - Map summary = snapshot.summary(); - long rows = getCountFromSummary(summary, true); - if (rows == TableIf.UNKNOWN_ROW_COUNT) { - LOG.info("Iceberg table {}.{}.{} row count in summary is unknown, return -1.", - tbl.getCatalog().getName(), tbl.getDbName(), tbl.getName()); - return TableIf.UNKNOWN_ROW_COUNT; - } - LOG.info("Iceberg table {}.{}.{} row count in summary is {}", - tbl.getCatalog().getName(), tbl.getDbName(), tbl.getName(), rows); - return rows; - } - - - public static FileFormat getFileFormat(Table icebergTable) { - Map properties = icebergTable.properties(); - String fileFormatName = resolveFileFormatName(properties); - FileFormat fileFormat; - if (fileFormatName.toLowerCase().contains(ORC_NAME)) { - fileFormat = FileFormat.ORC; - } else if (fileFormatName.toLowerCase().contains(PARQUET_NAME)) { - fileFormat = FileFormat.PARQUET; - } else { - throw new RuntimeException("Unsupported input format type: " + fileFormatName); - } - return fileFormat; - } - - private static String resolveFileFormatName(Map properties) { - // 1. Check "write-format" (nickname in Flink and Spark) - if (properties.containsKey(WRITE_FORMAT)) { - return properties.get(WRITE_FORMAT); - } - // 2. Check "write.format.default" (standard Iceberg property) - if (properties.containsKey(TableProperties.DEFAULT_FILE_FORMAT)) { - return properties.get(TableProperties.DEFAULT_FILE_FORMAT); - } - // Iceberg defaults the write format to Parquet when the table does not declare one. - return PARQUET_NAME; - } - - - public static String getFileCompress(Table table) { - Map properties = table.properties(); - if (properties.containsKey(COMPRESSION_CODEC)) { - return properties.get(COMPRESSION_CODEC); - } else if (properties.containsKey(SPARK_SQL_COMPRESSION_CODEC)) { - return properties.get(SPARK_SQL_COMPRESSION_CODEC); - } - FileFormat fileFormat = getFileFormat(table); - if (fileFormat == FileFormat.PARQUET) { - return properties.getOrDefault( - TableProperties.PARQUET_COMPRESSION, TableProperties.PARQUET_COMPRESSION_DEFAULT_SINCE_1_4_0); - } else if (fileFormat == FileFormat.ORC) { - return properties.getOrDefault( - TableProperties.ORC_COMPRESSION, TableProperties.ORC_COMPRESSION_DEFAULT); - } - throw new NotSupportedException("Unsupported file format: " + fileFormat); - } - - public static String dataLocation(Table table) { - Map properties = table.properties(); - if (properties.containsKey(TableProperties.WRITE_LOCATION_PROVIDER_IMPL)) { - throw new NotSupportedException( - "Table " + table.name() + " specifies " + properties - .get(TableProperties.WRITE_LOCATION_PROVIDER_IMPL) - + " as a location provider. " - + "Writing to Iceberg tables with custom location provider is not supported."); - } - String dataLocation = properties.get(TableProperties.WRITE_DATA_LOCATION); - if (dataLocation == null) { - dataLocation = Boolean.parseBoolean(properties.get(TableProperties.OBJECT_STORE_ENABLED)) - ? properties.get(TableProperties.OBJECT_STORE_PATH) : null; - if (dataLocation == null) { - dataLocation = properties.get(TableProperties.WRITE_FOLDER_STORAGE_LOCATION); - if (dataLocation == null) { - dataLocation = String.format("%s/data", LocationUtil.stripTrailingSlash(table.location())); - } - } - } - return dataLocation; - } - - public static HiveCatalog createIcebergHiveCatalog(ExternalCatalog externalCatalog, String name) { - HiveCatalog hiveCatalog = new HiveCatalog(); - hiveCatalog.setConf(ExternalCatalog.buildHadoopConfiguration(externalCatalog.getHadoopProperties())); - - Map catalogProperties = externalCatalog.getProperties(); - if (!catalogProperties.containsKey(HiveCatalog.LIST_ALL_TABLES)) { - // This configuration will display all tables (including non-Iceberg type tables), - // which can save the time of obtaining table objects. - // Later, type checks will be performed when loading the table. - catalogProperties.put(HiveCatalog.LIST_ALL_TABLES, "true"); - } - String metastoreUris = catalogProperties.getOrDefault(HMSBaseProperties.HIVE_METASTORE_URIS, ""); - catalogProperties.put(CatalogProperties.URI, metastoreUris); - hiveCatalog.initialize(name, catalogProperties); - return hiveCatalog; - } - - // Retrieve the manifest files that match the query based on partitions in filter - public static CloseableIterable getMatchingManifest( - List dataManifests, - Map specsById, - Expression dataFilter) { - LoadingCache evalCache = Caffeine.newBuilder() - .build( - specId -> { - PartitionSpec spec = specsById.get(specId); - return ManifestEvaluator.forPartitionFilter( - Expressions.and( - Expressions.alwaysTrue(), - Projections.inclusive(spec, true).project(dataFilter)), - spec, - true); - }); - - CloseableIterable matchingManifests = CloseableIterable.filter( - CloseableIterable.withNoopClose(dataManifests), - manifest -> evalCache.get(manifest.partitionSpecId()).eval(manifest)); - - matchingManifests = - CloseableIterable.filter( - matchingManifests, - manifest -> manifest.hasAddedFiles() || manifest.hasExistingFiles()); - - return matchingManifests; - } - - // get snapshot id from query like 'for version/time as of' or '@branch/@tag' - public static IcebergTableQueryInfo getQuerySpecSnapshot( - Table table, - Optional queryTableSnapshot, - Optional scanParams) throws UserException { - - Preconditions.checkArgument( - queryTableSnapshot.isPresent() || isIcebergBranchOrTag(scanParams), - "should spec version or time or branch or tag"); - - // not support `select * from tb@branch/tag(b) for version/time as of ...` - Preconditions.checkArgument( - !(queryTableSnapshot.isPresent() && isIcebergBranchOrTag(scanParams)), - "could not spec a version/time with tag/branch"); - - // solve @branch/@tag - if (scanParams.isPresent()) { - String refName; - TableScanParams params = scanParams.get(); - if (!params.getMapParams().isEmpty()) { - refName = params.getMapParams().get("name"); - } else { - refName = params.getListParams().get(0); - } - SnapshotRef snapshotRef = table.refs().get(refName); - LOG.info("[BranchDebug] getQuerySpecSnapshot: refName={}, snapshotId={}, " - + "currentSnapshotId={}, allRefs={}", - refName, - snapshotRef != null ? snapshotRef.snapshotId() : "null", - table.currentSnapshot() != null ? table.currentSnapshot().snapshotId() : "null", - table.refs()); - if (params.isBranch()) { - if (snapshotRef == null || !snapshotRef.isBranch()) { - throw new UserException("Table " + table.name() + " does not have branch named " + refName); - } - } else { - if (snapshotRef == null || !snapshotRef.isTag()) { - throw new UserException("Table " + table.name() + " does not have tag named " + refName); - } - } - return new IcebergTableQueryInfo( - snapshotRef.snapshotId(), - refName, - SnapshotUtil.schemaFor(table, refName).schemaId()); - } - - // solve version/time as of - String value = queryTableSnapshot.get().getValue(); - TableSnapshot.VersionType type = queryTableSnapshot.get().getType(); - if (type == TableSnapshot.VersionType.VERSION) { - if (SNAPSHOT_ID.matcher(value).matches()) { - long snapshotId = Long.parseLong(value); - Snapshot snapshot = table.snapshot(snapshotId); - if (snapshot == null) { - throw new UserException("Table " + table.name() + " does not have snapshotId " + value); - } - return new IcebergTableQueryInfo( - snapshotId, - null, - snapshot.schemaId() - ); - } - - if (!table.refs().containsKey(value)) { - throw new UserException("Table " + table.name() + " does not have tag or branch named " + value); - } - return new IcebergTableQueryInfo( - table.refs().get(value).snapshotId(), - value, - SnapshotUtil.schemaFor(table, value).schemaId() - ); - } else { - long timestamp = TimeUtils.timeStringToLong(value, TimeUtils.getTimeZone()); - if (timestamp < 0) { - throw new DateTimeException("can't parse time: " + value); - } - long snapshotId = SnapshotUtil.snapshotIdAsOfTime(table, timestamp); - return new IcebergTableQueryInfo( - snapshotId, - null, - table.snapshot(snapshotId).schemaId() - ); - } - } - - public static boolean isIcebergBranchOrTag(Optional scanParams) { - if (scanParams == null || !scanParams.isPresent()) { - return false; - } - TableScanParams params = scanParams.get(); - if (params.isBranch() || params.isTag()) { - if (!params.getMapParams().isEmpty()) { - Preconditions.checkArgument( - params.getMapParams().containsKey("name"), - "must contain key 'name' in params" - ); - } else { - Preconditions.checkArgument( - params.getListParams().size() == 1 - && params.getListParams().get(0) != null, - "must contain a branch/tag name in params" - ); - } - return true; - } - return false; - } - - // read schema from iceberg.schema entry - public static IcebergSchemaCacheValue getSchemaCacheValue(ExternalTable dorisTable, long schemaId) { - return icebergExternalMetaCache(dorisTable) - .getIcebergSchemaCacheValue(dorisTable.getOrBuildNameMapping(), schemaId); - } - - public static IcebergSnapshot getLatestIcebergSnapshot(Table table) { - Snapshot snapshot = table.currentSnapshot(); - long snapshotId = snapshot == null ? IcebergUtils.UNKNOWN_SNAPSHOT_ID : snapshot.snapshotId(); - // Use the latest table schema even if the current snapshot doesn't advance its schemaId, - // e.g. schema-only changes without a new snapshot. - long schemaId = table.schema().schemaId(); - return new IcebergSnapshot(snapshotId, schemaId); - } - - public static IcebergPartitionInfo loadPartitionInfo(ExternalTable dorisTable, Table table, long snapshotId) - throws AnalysisException { - // snapshotId == UNKNOWN_SNAPSHOT_ID means this is an empty table, haven't contained any snapshot yet. - if (snapshotId == IcebergUtils.UNKNOWN_SNAPSHOT_ID) { - return IcebergPartitionInfo.empty(); - } - return loadPartitionInfo(dorisTable, table, snapshotId, table.snapshot(snapshotId).schemaId()); - } - - public static IcebergPartitionInfo loadPartitionInfo(ExternalTable dorisTable, Table table, long snapshotId, - long schemaId) throws AnalysisException { - if (snapshotId == IcebergUtils.UNKNOWN_SNAPSHOT_ID) { - return IcebergPartitionInfo.empty(); - } - List icebergPartitions; - try { - icebergPartitions = dorisTable.getCatalog().getExecutionAuthenticator() - .execute(() -> loadIcebergPartition(table, snapshotId)); - } catch (Exception e) { - String errorMsg = String.format("Failed to get iceberg partition info, table: %s.%s.%s, snapshotId: %s", - dorisTable.getCatalog().getName(), dorisTable.getDbName(), dorisTable.getName(), snapshotId); - LOG.warn(errorMsg, e); - throw new AnalysisException(errorMsg, e); - } - Map nameToPartition = Maps.newHashMap(); - Map nameToPartitionItem = Maps.newHashMap(); - - List partitionColumns = IcebergUtils.getSchemaCacheValue(dorisTable, schemaId).getPartitionColumns(); - for (IcebergPartition partition : icebergPartitions) { - nameToPartition.put(partition.getPartitionName(), partition); - String transform = table.specs().get(partition.getSpecId()).fields().get(0).transform().toString(); - Range partitionRange = getPartitionRange( - partition.getPartitionValues().get(0), transform, partitionColumns); - PartitionItem item = new RangePartitionItem(partitionRange); - nameToPartitionItem.put(partition.getPartitionName(), item); - } - Map> partitionNameMap = mergeOverlapPartitions(nameToPartitionItem); - return new IcebergPartitionInfo(nameToPartitionItem, nameToPartition, partitionNameMap); - } - - private static List loadIcebergPartition(Table table, long snapshotId) { - PartitionsTable partitionsTable = (PartitionsTable) MetadataTableUtils - .createMetadataTableInstance(table, MetadataTableType.PARTITIONS); - List partitions = Lists.newArrayList(); - try (CloseableIterable tasks = partitionsTable.newScan().useSnapshot(snapshotId).planFiles()) { - for (FileScanTask task : tasks) { - CloseableIterable rows = task.asDataTask().rows(); - for (StructLike row : rows) { - partitions.add(generateIcebergPartition(table, row)); - } - } - } catch (IOException e) { - LOG.warn("Failed to get Iceberg table {} partition info.", table.name(), e); - } - return partitions; - } - - private static IcebergPartition generateIcebergPartition(Table table, StructLike row) { - // row format : - // 0. partitionData, - // 1. spec_id, - // 2. record_count, - // 3. file_count, - // 4. total_data_file_size_in_bytes, - // 5. position_delete_record_count, - // 6. position_delete_file_count, - // 7. equality_delete_record_count, - // 8. equality_delete_file_count, - // 9. last_updated_at, - // 10. last_updated_snapshot_id - Preconditions.checkState(!table.spec().fields().isEmpty(), table.name() + " is not a partition table."); - int specId = row.get(1, Integer.class); - PartitionSpec partitionSpec = table.specs().get(specId); - StructProjection partitionData = row.get(0, StructProjection.class); - StringBuilder sb = new StringBuilder(); - List partitionValues = Lists.newArrayList(); - List transforms = Lists.newArrayList(); - for (int i = 0; i < partitionSpec.fields().size(); ++i) { - PartitionField partitionField = partitionSpec.fields().get(i); - Class fieldClass = partitionSpec.javaClasses()[i]; - int fieldId = partitionField.fieldId(); - // Iceberg partition field id starts at PARTITION_DATA_ID_START, - // So we can get the field index in partitionData using fieldId - PARTITION_DATA_ID_START - int index = fieldId - PARTITION_DATA_ID_START; - Object o = partitionData.get(index, fieldClass); - String fieldValue = o == null ? null : o.toString(); - String fieldName = partitionField.name(); - sb.append(fieldName); - sb.append("="); - sb.append(fieldValue); - sb.append("/"); - partitionValues.add(fieldValue); - transforms.add(partitionField.transform().toString()); - } - if (sb.length() > 0) { - sb.delete(sb.length() - 1, sb.length()); - } - String partitionName = sb.toString(); - long recordCount = row.get(2, Long.class); - long fileCount = row.get(3, Integer.class); - long fileSizeInBytes = row.get(4, Long.class); - // last_updated_at and last_updated_snapshot_id are optional, so we need to - // handle the null case. - long lastUpdateTime; - long lastUpdateSnapShotId; - try { - lastUpdateTime = row.get(9, Long.class); - } catch (NullPointerException e) { - lastUpdateTime = 0; - } - try { - lastUpdateSnapShotId = row.get(10, Long.class); - } catch (NullPointerException e) { - lastUpdateSnapShotId = UNKNOWN_SNAPSHOT_ID; - } - return new IcebergPartition(partitionName, specId, recordCount, fileSizeInBytes, fileCount, - lastUpdateTime, lastUpdateSnapShotId, partitionValues, transforms); - } - - @VisibleForTesting - public static Range getPartitionRange(String value, String transform, List partitionColumns) - throws AnalysisException { - // For NULL value, create a minimum partition for it. - if (value == null) { - PartitionKey nullLowKey = PartitionKey.createPartitionKey( - Lists.newArrayList(new PartitionValue("0000-01-01")), partitionColumns); - PartitionKey nullUpKey = nullLowKey.successor(); - return Range.closedOpen(nullLowKey, nullUpKey); - } - LocalDateTime epoch = Instant.EPOCH.atZone(ZoneId.of("UTC")).toLocalDateTime(); - LocalDateTime target; - LocalDateTime lower; - LocalDateTime upper; - long longValue = Long.parseLong(value); - switch (transform) { - case HOUR: - target = epoch.plusHours(longValue); - lower = LocalDateTime.of(target.getYear(), target.getMonth(), target.getDayOfMonth(), - target.getHour(), 0, 0); - upper = lower.plusHours(1); - break; - case DAY: - target = epoch.plusDays(longValue); - lower = LocalDateTime.of(target.getYear(), target.getMonth(), target.getDayOfMonth(), 0, 0, 0); - upper = lower.plusDays(1); - break; - case MONTH: - target = epoch.plusMonths(longValue); - lower = LocalDateTime.of(target.getYear(), target.getMonth(), 1, 0, 0, 0); - upper = lower.plusMonths(1); - break; - case YEAR: - target = epoch.plusYears(longValue); - lower = LocalDateTime.of(target.getYear(), Month.JANUARY, 1, 0, 0, 0); - upper = lower.plusYears(1); - break; - default: - throw new RuntimeException("Unsupported transform " + transform); - } - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - Column c = partitionColumns.get(0); - Preconditions.checkState(c.getDataType().isDateLikeType(), "Only support date type partition column"); - if (c.getType().isDate() || c.getType().isDateV2()) { - formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); - } - PartitionValue lowerValue = new PartitionValue(lower.format(formatter)); - PartitionValue upperValue = new PartitionValue(upper.format(formatter)); - PartitionKey lowKey = PartitionKey.createPartitionKey(Lists.newArrayList(lowerValue), partitionColumns); - PartitionKey upperKey = PartitionKey.createPartitionKey(Lists.newArrayList(upperValue), partitionColumns); - return Range.closedOpen(lowKey, upperKey); - } - - /** - * Merge overlapped iceberg partitions into one Doris partition. - */ - @VisibleForTesting - public static Map> mergeOverlapPartitions(Map originPartitions) { - List> entries = sortPartitionMap(originPartitions); - Map> map = Maps.newHashMap(); - for (int i = 0; i < entries.size() - 1; i++) { - Range firstValue = entries.get(i).getValue().getItems(); - String firstKey = entries.get(i).getKey(); - Range secondValue = entries.get(i + 1).getValue().getItems(); - String secondKey = entries.get(i + 1).getKey(); - // If the first entry enclose the second one, remove the second entry and keep a record in the return map. - // So we can track the iceberg partitions those contained by one Doris partition. - while (i < entries.size() && firstValue.encloses(secondValue)) { - originPartitions.remove(secondKey); - map.putIfAbsent(firstKey, Sets.newHashSet(firstKey)); - String finalSecondKey = secondKey; - map.computeIfPresent(firstKey, (key, value) -> { - value.add(finalSecondKey); - return value; - }); - i++; - if (i >= entries.size() - 1) { - break; - } - secondValue = entries.get(i + 1).getValue().getItems(); - secondKey = entries.get(i + 1).getKey(); - } - } - return map; - } - - /** - * Sort the given map entries by PartitionItem Range(LOW, HIGH) - * When comparing two ranges, the one with smaller LOW value is smaller than the other one. - * If two ranges have same values of LOW, the one with larger HIGH value is smaller. - * - * For now, we only support year, month, day and hour, - * so it is impossible to have two partially intersect partitions. - * One range is either enclosed by another or has no intersection at all with another. - * - * - * For example, we have these 4 ranges: - * [10, 20), [30, 40), [0, 30), [10, 15) - * - * After sort, they become: - * [0, 30), [10, 20), [10, 15), [30, 40) - */ - @VisibleForTesting - public static List> sortPartitionMap(Map originPartitions) { - List> entries = new ArrayList<>(originPartitions.entrySet()); - entries.sort(new RangeComparator()); - return entries; - } - - public static class RangeComparator implements Comparator> { - @Override - public int compare(Map.Entry p1, Map.Entry p2) { - PartitionItem value1 = p1.getValue(); - PartitionItem value2 = p2.getValue(); - if (value1 instanceof RangePartitionItem && value2 instanceof RangePartitionItem) { - Range items1 = value1.getItems(); - Range items2 = value2.getItems(); - if (!items1.hasLowerBound()) { - return -1; - } - if (!items2.hasLowerBound()) { - return 1; - } - PartitionKey upper1 = items1.upperEndpoint(); - PartitionKey lower1 = items1.lowerEndpoint(); - PartitionKey upper2 = items2.upperEndpoint(); - PartitionKey lower2 = items2.lowerEndpoint(); - int compareLow = lower1.compareTo(lower2); - return compareLow == 0 ? upper2.compareTo(upper1) : compareLow; - } - return 0; - } - } - - public static IcebergSchemaCacheValue getSchemaCacheValue(ExternalTable dorisTable, IcebergSnapshotCacheValue sv) { - if (useSessionCatalog(dorisTable)) { - return buildTableSchemaCacheValue(dorisTable, sv.getSnapshot().getSchemaId(), getIcebergTable(dorisTable)); - } - return getSchemaCacheValue(dorisTable, sv.getSnapshot().getSchemaId()); - } - - public static IcebergSnapshotCacheValue getLatestSnapshotCacheValue(ExternalTable dorisTable) { - if (useSessionCatalog(dorisTable)) { - return loadSnapshotCacheValue(dorisTable, getIcebergTable(dorisTable)); - } - return icebergExternalMetaCache(dorisTable).getSnapshotCache(dorisTable); - } - - public static IcebergSnapshotCacheValue getSnapshotCacheValue(Optional snapshot, - ExternalTable dorisTable) { - if (snapshot.isPresent() && snapshot.get() instanceof IcebergMvccSnapshot) { - return ((IcebergMvccSnapshot) snapshot.get()).getSnapshotCacheValue(); - } - return getLatestSnapshotCacheValue(dorisTable); - } - - public static IcebergSnapshotCacheValue getSnapshotCacheValue( - Optional tableSnapshot, - ExternalTable dorisTable, - Optional scanParams) { - if (tableSnapshot.isPresent() || IcebergUtils.isIcebergBranchOrTag(scanParams)) { - // If a snapshot is specified, use the specified snapshot and the corresponding schema (not latest). - Table icebergTable = getIcebergTable(dorisTable); - IcebergTableQueryInfo info; - try { - info = getQuerySpecSnapshot(icebergTable, tableSnapshot, scanParams); - } catch (UserException e) { - throw new RuntimeException(e); - } - return new IcebergSnapshotCacheValue( - IcebergPartitionInfo.empty(), - new IcebergSnapshot(info.getSnapshotId(), info.getSchemaId())); - } - return getLatestSnapshotCacheValue(dorisTable); - } - - public static List getIcebergSchema(ExternalTable dorisTable) { - if (useSessionCatalog(dorisTable) && dorisTable.isView()) { - return loadViewSchemaCacheValue(dorisTable, NEWEST_SCHEMA_ID).get().getSchema(); - } - Optional snapshotFromContext = MvccUtil.getSnapshotFromContext(dorisTable); - IcebergSnapshotCacheValue cacheValue = IcebergUtils.getSnapshotCacheValue(snapshotFromContext, dorisTable); - return IcebergUtils.getSchemaCacheValue(dorisTable, cacheValue).getSchema(); - } - - public static List getIcebergPartitionColumns(Optional snapshot, ExternalTable dorisTable) { - IcebergSnapshotCacheValue snapshotValue = getSnapshotCacheValue(snapshot, dorisTable); - return getSchemaCacheValue(dorisTable, snapshotValue).getPartitionColumns(); - } - - public static Map getIcebergPartitionItems(Optional snapshot, - ExternalTable dorisTable) { - return getSnapshotCacheValue(snapshot, dorisTable).getPartitionInfo().getNameToPartitionItem(); - } - - public static View getIcebergView(ExternalTable dorisTable) { - if (useSessionCatalog(dorisTable)) { - return loadIcebergViewWithSession(dorisTable); - } - return icebergExternalMetaCache(dorisTable).getIcebergView(dorisTable); - } - - public static Optional loadSchemaCacheValue( - ExternalTable dorisTable, long schemaId, boolean isView) { - return isView - ? loadViewSchemaCacheValue(dorisTable, schemaId) - : loadTableSchemaCacheValue(dorisTable, schemaId); - } - - private static Optional loadViewSchemaCacheValue(ExternalTable dorisTable, long schemaId) { - List schema = IcebergUtils.getSchema(dorisTable, schemaId, true, null); - return Optional.of(new IcebergSchemaCacheValue(schema, Lists.newArrayList())); - } - - private static Optional loadTableSchemaCacheValue(ExternalTable dorisTable, long schemaId) { - Table icebergTable = IcebergUtils.getIcebergTable(dorisTable); - return Optional.of(buildTableSchemaCacheValue(dorisTable, schemaId, icebergTable)); - } - - private static IcebergSchemaCacheValue buildTableSchemaCacheValue(ExternalTable dorisTable, long schemaId, - Table icebergTable) { - List schema = IcebergUtils.getSchema(dorisTable, schemaId, false, icebergTable); - // get table partition column info - List tmpColumns = Lists.newArrayList(); - PartitionSpec spec = icebergTable.spec(); - for (PartitionField field : spec.fields()) { - Types.NestedField col = icebergTable.schema().findField(field.sourceId()); - for (Column c : schema) { - if (c.getName().equalsIgnoreCase(col.name())) { - tmpColumns.add(c); - break; - } - } - } - return new IcebergSchemaCacheValue(schema, tmpColumns); - } - - private static IcebergSnapshotCacheValue loadSnapshotCacheValue(ExternalTable dorisTable, Table icebergTable) { - if (!(dorisTable instanceof MTMVRelatedTableIf)) { - throw new RuntimeException(String.format("Table %s.%s is not a valid MTMV related table.", - dorisTable.getDbName(), dorisTable.getName())); - } - try { - MTMVRelatedTableIf table = (MTMVRelatedTableIf) dorisTable; - IcebergSnapshot latestIcebergSnapshot = IcebergUtils.getLatestIcebergSnapshot(icebergTable); - IcebergPartitionInfo icebergPartitionInfo; - if (!table.isValidRelatedTable()) { - icebergPartitionInfo = IcebergPartitionInfo.empty(); - } else { - icebergPartitionInfo = IcebergUtils.loadPartitionInfo(dorisTable, icebergTable, - latestIcebergSnapshot.getSnapshotId(), latestIcebergSnapshot.getSchemaId()); - } - return new IcebergSnapshotCacheValue(icebergPartitionInfo, latestIcebergSnapshot); - } catch (AnalysisException e) { - throw new RuntimeException(ExceptionUtils.getRootCauseMessage(e), e); - } - } - - private static Table loadIcebergTableWithSession(ExternalTable dorisTable) { - IcebergExternalCatalog catalog = (IcebergExternalCatalog) dorisTable.getCatalog(); - IcebergMetadataOps ops = (IcebergMetadataOps) catalog.getMetadataOps(); - return ops.loadTable(SessionContext.current(), dorisTable.getRemoteDbName(), dorisTable.getRemoteName()); - } - - private static View loadIcebergViewWithSession(ExternalTable dorisTable) { - IcebergExternalCatalog catalog = (IcebergExternalCatalog) dorisTable.getCatalog(); - IcebergMetadataOps ops = (IcebergMetadataOps) catalog.getMetadataOps(); - return (View) ops.loadView(SessionContext.current(), dorisTable.getRemoteDbName(), dorisTable.getRemoteName()); - } - - private static boolean useSessionCatalog(ExternalTable dorisTable) { - // Defer to the catalog's single session decision (IcebergUserSessionCatalog): false when dynamic - // identity is off, true when on and the request carries a delegated credential, and it throws when - // dynamic identity is on but the request has no credential (no shared identity to borrow). - return dorisTable.getCatalog() instanceof IcebergUserSessionCatalog - && ((IcebergUserSessionCatalog) dorisTable.getCatalog()).useSessionCatalog(SessionContext.current()); - } - - public static boolean isIcebergRowLineageColumn(Column column) { - return column.nameEquals(IcebergUtils.ICEBERG_ROW_ID_COL, false) - || column.nameEquals(IcebergUtils.ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL, false); - } - - public static boolean isIcebergRowLineageColumn(String columnName) { - return IcebergUtils.ICEBERG_ROW_ID_COL.equalsIgnoreCase(columnName) - || IcebergUtils.ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL.equalsIgnoreCase(columnName); - } - - public static List appendRowLineageColumnsForV3(List schema, Table table) { - if (getFormatVersion(table) < ICEBERG_ROW_LINEAGE_MIN_VERSION) { - return schema; - } - List newSchema = Lists.newArrayList(schema); - - Column rowIdColumn = new Column(ICEBERG_ROW_ID_COL, Type.BIGINT, true); - rowIdColumn.setUniqueId(2147483540); - rowIdColumn.setIsVisible(false); - newSchema.add(rowIdColumn); - - Column lastUpdatedSequenceNumberColumn = - new Column(ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL, Type.BIGINT, true); - lastUpdatedSequenceNumberColumn.setUniqueId(2147483539); - lastUpdatedSequenceNumberColumn.setIsVisible(false); - newSchema.add(lastUpdatedSequenceNumberColumn); - - return newSchema; - } - - public static Schema appendRowLineageFieldsForV3(Schema schema) { - return TypeUtil.join(schema, new Schema( - MetadataColumns.ROW_ID, MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER)); - } - - public static int getFormatVersion(Table table) { - int formatVersion = 2; // default format version : 2 - if (table instanceof BaseTable) { - formatVersion = ((BaseTable) table).operations().current().formatVersion(); - } else if (table != null && table.properties() != null) { - String version = table.properties().get(TableProperties.FORMAT_VERSION); - if (version != null) { - try { - formatVersion = Integer.parseInt(version); - } catch (NumberFormatException ignored) { - // keep default value - } - } - } - return formatVersion; - } - - public static String showCreateView(IcebergExternalTable icebergExternalTable) { - return String.format("CREATE VIEW `%s` AS ", icebergExternalTable.getName()) - + - icebergExternalTable.getViewText(); - } - - public static boolean isManifestCacheEnabled(ExternalCatalog catalog) { - CacheSpec spec = CacheSpec.fromProperties(catalog.getProperties(), CacheSpec.propertySpecBuilder() - .enable(IcebergExternalCatalog.ICEBERG_MANIFEST_CACHE_ENABLE, - IcebergExternalCatalog.DEFAULT_ICEBERG_MANIFEST_CACHE_ENABLE) - .ttl(IcebergExternalCatalog.ICEBERG_MANIFEST_CACHE_TTL_SECOND, - IcebergExternalCatalog.DEFAULT_ICEBERG_MANIFEST_CACHE_TTL_SECOND) - .capacity(IcebergExternalCatalog.ICEBERG_MANIFEST_CACHE_CAPACITY, - IcebergExternalCatalog.DEFAULT_ICEBERG_MANIFEST_CACHE_CAPACITY) - .build()); - return CacheSpec.isCacheEnabled(spec.isEnable(), spec.getTtlSecond(), spec.getCapacity()); - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergVendedCredentialsProvider.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergVendedCredentialsProvider.java deleted file mode 100644 index 399a0408fbec3d..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergVendedCredentialsProvider.java +++ /dev/null @@ -1,81 +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.datasource.iceberg; - -import org.apache.doris.datasource.credentials.AbstractVendedCredentialsProvider; -import org.apache.doris.datasource.property.metastore.IcebergRestProperties; -import org.apache.doris.datasource.property.metastore.MetastoreProperties; - -import com.google.common.collect.Maps; -import org.apache.iceberg.Table; -import org.apache.iceberg.io.FileIO; -import org.apache.iceberg.io.StorageCredential; -import org.apache.iceberg.io.SupportsStorageCredentials; - -import java.util.Map; - -public class IcebergVendedCredentialsProvider extends AbstractVendedCredentialsProvider { - private static final IcebergVendedCredentialsProvider INSTANCE = new IcebergVendedCredentialsProvider(); - - private IcebergVendedCredentialsProvider() { - // Singleton pattern - } - - public static IcebergVendedCredentialsProvider getInstance() { - return INSTANCE; - } - - @Override - public boolean isVendedCredentialsEnabled(MetastoreProperties metastoreProperties) { - if (metastoreProperties instanceof IcebergRestProperties) { - return ((IcebergRestProperties) metastoreProperties).isIcebergRestVendedCredentialsEnabled(); - } - return false; - } - - @Override - protected Map extractRawVendedCredentials(T tableObject) { - if (!(tableObject instanceof Table)) { - return Maps.newHashMap(); - } - - Table table = (Table) tableObject; - if (table.io() == null) { - return Maps.newHashMap(); - } - - // Return table.io().properties() directly, and let StorageProperties.createAll() to convert the format - FileIO fileIO = table.io(); - Map ioProps = Maps.newHashMap(fileIO.properties()); - if (fileIO instanceof SupportsStorageCredentials) { - SupportsStorageCredentials ssc = (SupportsStorageCredentials) fileIO; - for (StorageCredential storageCredential : ssc.credentials()) { - ioProps.putAll(storageCredential.config()); - } - } - return ioProps; - } - - @Override - protected String getTableName(T tableObject) { - if (tableObject instanceof Table) { - return ((Table) tableObject).name(); - } - return super.getTableName(tableObject); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/BaseIcebergAction.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/BaseIcebergAction.java deleted file mode 100644 index ebf0578b5e39af..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/BaseIcebergAction.java +++ /dev/null @@ -1,74 +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.datasource.iceberg.action; - -import org.apache.doris.catalog.TableIf; -import org.apache.doris.catalog.info.PartitionNamesInfo; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.nereids.trees.expressions.Expression; -import org.apache.doris.nereids.trees.plans.commands.execute.BaseExecuteAction; - -import java.util.Map; -import java.util.Optional; - -/** - * Abstract base class for Iceberg-specific EXECUTE TABLE actions. - * This class extends BaseExecuteAction and provides Iceberg-specific - * functionality while inheriting common execution action behavior. - */ -public abstract class BaseIcebergAction extends BaseExecuteAction { - - protected BaseIcebergAction(String actionType, Map properties, - Optional partitionNamesInfo, - Optional whereCondition) { - super(actionType, properties, partitionNamesInfo, whereCondition); - } - - @Override - public final boolean isSupported(TableIf table) { - return table instanceof IcebergExternalTable; - } - - @Override - protected final void registerArguments() { - registerIcebergArguments(); - } - - @Override - protected final void validateAction() throws UserException { - validateIcebergAction(); - } - - /** - * Iceberg-specific argument registration. - * Subclasses should override this method to register their specific - * arguments. - */ - protected abstract void registerIcebergArguments(); - - /** - * Iceberg-specific validation logic. - * Subclasses should override this method to implement their specific - * validation. - */ - protected void validateIcebergAction() throws UserException { - // Default implementation does nothing. - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergCherrypickSnapshotAction.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergCherrypickSnapshotAction.java deleted file mode 100644 index f205abe3f7beda..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergCherrypickSnapshotAction.java +++ /dev/null @@ -1,111 +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.datasource.iceberg.action; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.catalog.Type; -import org.apache.doris.catalog.info.PartitionNamesInfo; -import org.apache.doris.common.ArgumentParsers; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.nereids.trees.expressions.Expression; - -import com.google.common.collect.Lists; -import org.apache.iceberg.Snapshot; -import org.apache.iceberg.Table; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -/** - * Implementation for Iceberg cherrypick_snapshot action. - * This action cherry-picks changes from a snapshot into the current table - * state. - * Cherry-picking creates a new snapshot from an existing snapshot without - * altering - * or removing the original. - */ -public class IcebergCherrypickSnapshotAction extends BaseIcebergAction { - public static final String SNAPSHOT_ID = "snapshot_id"; - - public IcebergCherrypickSnapshotAction(Map properties, - Optional partitionNamesInfo, Optional whereCondition) { - super("cherrypick_snapshot", properties, partitionNamesInfo, whereCondition); - } - - @Override - protected void registerIcebergArguments() { - // Register snapshot_id as a required parameter with type-safe parsing - namedArguments.registerRequiredArgument(SNAPSHOT_ID, - "The snapshot ID to cherry-pick", - ArgumentParsers.positiveLong(SNAPSHOT_ID)); - } - - @Override - protected void validateIcebergAction() throws UserException { - // Iceberg cherrypick_snapshot procedures don't support partitions or where - // conditions - validateNoPartitions(); - validateNoWhereCondition(); - } - - @Override - protected List executeAction(TableIf table) throws UserException { - Table icebergTable = ((IcebergExternalTable) table).getIcebergTable(); - Long sourceSnapshotId = namedArguments.getLong(SNAPSHOT_ID); - - try { - Snapshot targetSnapshot = icebergTable.snapshot(sourceSnapshotId); - if (targetSnapshot == null) { - throw new UserException("Snapshot not found in table"); - } - - icebergTable.manageSnapshots().cherrypick(sourceSnapshotId).commit(); - Snapshot currentSnapshot = icebergTable.currentSnapshot(); - - // invalid iceberg catalog table cache. - Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTableCache((ExternalTable) table); - return Lists.newArrayList( - String.valueOf(sourceSnapshotId), - String.valueOf(currentSnapshot.snapshotId() - ) - ); - - } catch (Exception e) { - throw new UserException("Failed to cherry-pick snapshot " + sourceSnapshotId + ": " + e.getMessage(), e); - } - } - - @Override - protected List getResultSchema() { - return Lists.newArrayList(new Column("source_snapshot_id", Type.BIGINT, false, - "ID of the snapshot whose changes were cherry-picked into the current table state"), - new Column("current_snapshot_id", Type.BIGINT, false, - "ID of the new snapshot created as a result of the cherry-pick operation, " - + "now set as the current snapshot")); - } - - @Override - public String getDescription() { - return "Cherry-pick changes from a specific snapshot in Iceberg table"; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergExecuteActionFactory.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergExecuteActionFactory.java deleted file mode 100644 index 1bb56ebc65b9c9..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergExecuteActionFactory.java +++ /dev/null @@ -1,115 +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.datasource.iceberg.action; - -import org.apache.doris.catalog.info.PartitionNamesInfo; -import org.apache.doris.common.DdlException; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.nereids.trees.expressions.Expression; -import org.apache.doris.nereids.trees.plans.commands.execute.ExecuteAction; - -import java.util.Map; -import java.util.Optional; - -/** - * Factory for creating Iceberg-specific EXECUTE TABLE actions. - */ -public class IcebergExecuteActionFactory { - - // Iceberg procedure names (mapped to action types) - public static final String ROLLBACK_TO_SNAPSHOT = "rollback_to_snapshot"; - public static final String ROLLBACK_TO_TIMESTAMP = "rollback_to_timestamp"; - public static final String SET_CURRENT_SNAPSHOT = "set_current_snapshot"; - public static final String CHERRYPICK_SNAPSHOT = "cherrypick_snapshot"; - public static final String FAST_FORWARD = "fast_forward"; - public static final String EXPIRE_SNAPSHOTS = "expire_snapshots"; - public static final String REWRITE_DATA_FILES = "rewrite_data_files"; - public static final String PUBLISH_CHANGES = "publish_changes"; - public static final String REWRITE_MANIFESTS = "rewrite_manifests"; - - /** - * Create an Iceberg-specific ExecuteAction instance. - * - * @param actionType the type of action to create (corresponds to - * Iceberg procedure name) - * @param properties action properties (will be passed to Iceberg - * procedures) - * @param partitionNamesInfo partition information - * @param whereCondition where condition for filtering - * @param table the Iceberg table to operate on - * @return ExecuteAction instance that wraps Iceberg procedure calls - * @throws DdlException if action creation fails - */ - public static ExecuteAction createAction(String actionType, Map properties, - Optional partitionNamesInfo, - Optional whereCondition, - IcebergExternalTable table) throws DdlException { - - switch (actionType.toLowerCase()) { - case ROLLBACK_TO_SNAPSHOT: - return new IcebergRollbackToSnapshotAction(properties, partitionNamesInfo, - whereCondition); - case ROLLBACK_TO_TIMESTAMP: - return new IcebergRollbackToTimestampAction(properties, partitionNamesInfo, - whereCondition); - case SET_CURRENT_SNAPSHOT: - return new IcebergSetCurrentSnapshotAction(properties, partitionNamesInfo, - whereCondition); - case CHERRYPICK_SNAPSHOT: - return new IcebergCherrypickSnapshotAction(properties, partitionNamesInfo, - whereCondition); - case FAST_FORWARD: - return new IcebergFastForwardAction(properties, partitionNamesInfo, - whereCondition); - case EXPIRE_SNAPSHOTS: - return new IcebergExpireSnapshotsAction(properties, partitionNamesInfo, - whereCondition); - case REWRITE_DATA_FILES: - return new IcebergRewriteDataFilesAction(properties, partitionNamesInfo, - whereCondition); - case PUBLISH_CHANGES: - return new IcebergPublishChangesAction(properties, partitionNamesInfo, - whereCondition); - case REWRITE_MANIFESTS: - return new IcebergRewriteManifestsAction(properties, partitionNamesInfo, - whereCondition); - default: - throw new DdlException("Unsupported Iceberg procedure: " + actionType - + ". Supported procedures: " + String.join(", ", getSupportedActions())); - } - } - - /** - * Get supported Iceberg procedure names. - * - * @return array of supported procedure names - */ - public static String[] getSupportedActions() { - return new String[] { - ROLLBACK_TO_SNAPSHOT, - ROLLBACK_TO_TIMESTAMP, - SET_CURRENT_SNAPSHOT, - CHERRYPICK_SNAPSHOT, - FAST_FORWARD, - EXPIRE_SNAPSHOTS, - REWRITE_DATA_FILES, - PUBLISH_CHANGES, - REWRITE_MANIFESTS - }; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergExpireSnapshotsAction.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergExpireSnapshotsAction.java deleted file mode 100644 index 5c74d0b4160be1..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergExpireSnapshotsAction.java +++ /dev/null @@ -1,324 +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.datasource.iceberg.action; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.catalog.Type; -import org.apache.doris.catalog.info.PartitionNamesInfo; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.ArgumentParsers; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.nereids.trees.expressions.Expression; - -import com.google.common.collect.Lists; -import org.apache.iceberg.DeleteFile; -import org.apache.iceberg.ExpireSnapshots; -import org.apache.iceberg.FileContent; -import org.apache.iceberg.ManifestFile; -import org.apache.iceberg.ManifestFiles; -import org.apache.iceberg.Table; -import org.apache.iceberg.io.CloseableIterable; -import org.apache.iceberg.io.SupportsBulkOperations; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Iceberg expire snapshots action implementation. - * This action removes old snapshots from Iceberg tables to free up storage - * space - * and improve metadata performance. - */ -public class IcebergExpireSnapshotsAction extends BaseIcebergAction { - private static final Logger LOG = LogManager.getLogger(IcebergExpireSnapshotsAction.class); - public static final String OLDER_THAN = "older_than"; - public static final String RETAIN_LAST = "retain_last"; - public static final String MAX_CONCURRENT_DELETES = "max_concurrent_deletes"; - public static final String SNAPSHOT_IDS = "snapshot_ids"; - public static final String CLEAN_EXPIRED_METADATA = "clean_expired_metadata"; - - public IcebergExpireSnapshotsAction(Map properties, - Optional partitionNamesInfo, - Optional whereCondition) { - super("expire_snapshots", properties, partitionNamesInfo, whereCondition); - } - - @Override - protected void registerIcebergArguments() { - // Register optional arguments for expire_snapshots - namedArguments.registerOptionalArgument(OLDER_THAN, - "Timestamp before which snapshots will be removed", - null, ArgumentParsers.nonEmptyString(OLDER_THAN)); - namedArguments.registerOptionalArgument(RETAIN_LAST, - "Number of ancestor snapshots to preserve regardless of older_than", - null, ArgumentParsers.positiveInt(RETAIN_LAST)); - namedArguments.registerOptionalArgument(MAX_CONCURRENT_DELETES, - "Size of the thread pool used for delete file actions (0 disables, " - + "ignored for FileIOs that support bulk deletes)", - 0, ArgumentParsers.intRange(MAX_CONCURRENT_DELETES, 0, Integer.MAX_VALUE)); - namedArguments.registerOptionalArgument(SNAPSHOT_IDS, - "Array of snapshot IDs to expire", - null, ArgumentParsers.nonEmptyString(SNAPSHOT_IDS)); - namedArguments.registerOptionalArgument(CLEAN_EXPIRED_METADATA, - "When true, cleans up metadata such as partition specs and schemas", - null, ArgumentParsers.booleanValue(CLEAN_EXPIRED_METADATA)); - } - - @Override - protected void validateIcebergAction() throws UserException { - // Validate older_than parameter (timestamp) - String olderThan = namedArguments.getString(OLDER_THAN); - if (olderThan != null) { - try { - // Try to parse as ISO datetime format - LocalDateTime.parse(olderThan, DateTimeFormatter.ISO_LOCAL_DATE_TIME); - } catch (DateTimeParseException e) { - try { - // Try to parse as timestamp (milliseconds since epoch) - long timestamp = Long.parseLong(olderThan); - if (timestamp < 0) { - throw new AnalysisException("older_than timestamp must be non-negative"); - } - } catch (NumberFormatException nfe) { - throw new AnalysisException("Invalid older_than format. Expected ISO datetime " - + "(yyyy-MM-ddTHH:mm:ss) or timestamp in milliseconds: " + olderThan); - } - } - } - - // Validate retain_last parameter - Integer retainLast = namedArguments.getInt(RETAIN_LAST); - if (retainLast != null && retainLast < 1) { - throw new AnalysisException("retain_last must be at least 1"); - } - - // Get snapshot_ids for validation - String snapshotIds = namedArguments.getString(SNAPSHOT_IDS); - - // Validate snapshot_ids format if provided - if (snapshotIds != null) { - for (String idStr : snapshotIds.split(",")) { - try { - Long.parseLong(idStr.trim()); - } catch (NumberFormatException e) { - throw new AnalysisException("Invalid snapshot_id format: " + idStr.trim()); - } - } - } - - // At least one of older_than, retain_last, or snapshot_ids must be specified - if (olderThan == null && retainLast == null && snapshotIds == null) { - throw new AnalysisException("At least one of 'older_than', 'retain_last', or " - + "'snapshot_ids' must be specified"); - } - - // Iceberg procedures don't support partitions or where conditions - validateNoPartitions(); - validateNoWhereCondition(); - } - - @Override - protected List executeAction(TableIf table) throws UserException { - Table icebergTable = ((IcebergExternalTable) table).getIcebergTable(); - - // Parse parameters - String olderThan = namedArguments.getString(OLDER_THAN); - Integer retainLast = namedArguments.getInt(RETAIN_LAST); - String snapshotIdsStr = namedArguments.getString(SNAPSHOT_IDS); - Boolean cleanExpiredMetadata = namedArguments.getBoolean(CLEAN_EXPIRED_METADATA); - Integer maxConcurrentDeletes = namedArguments.getInt(MAX_CONCURRENT_DELETES); - - // Track deleted file counts using callbacks (matching Spark's 6-column schema) - AtomicLong deletedDataFilesCount = new AtomicLong(0); - AtomicLong deletedPositionDeleteFilesCount = new AtomicLong(0); - AtomicLong deletedEqualityDeleteFilesCount = new AtomicLong(0); - AtomicLong deletedManifestFilesCount = new AtomicLong(0); - AtomicLong deletedManifestListsCount = new AtomicLong(0); - AtomicLong deletedStatisticsFilesCount = new AtomicLong(0); - - ExecutorService deleteExecutor = null; - try { - Map deleteFileContentByPath = - buildDeleteFileContentMap(icebergTable); - ExpireSnapshots expireSnapshots = icebergTable.expireSnapshots(); - - // Configure older_than timestamp - // If retain_last is specified without older_than, use current time as the cutoff - // This is because Iceberg's retainLast only works in conjunction with expireOlderThan - if (olderThan != null) { - long timestampMillis = parseTimestamp(olderThan); - expireSnapshots.expireOlderThan(timestampMillis); - } else if (retainLast != null && snapshotIdsStr == null) { - // When only retain_last is specified, expire all snapshots older than now - // but keep at least retain_last snapshots - expireSnapshots.expireOlderThan(System.currentTimeMillis()); - } - - // Configure retain_last - if (retainLast != null) { - expireSnapshots.retainLast(retainLast); - } - - // Configure specific snapshot IDs to expire - if (snapshotIdsStr != null) { - for (String idStr : snapshotIdsStr.split(",")) { - expireSnapshots.expireSnapshotId(Long.parseLong(idStr.trim())); - } - } - - // Configure clean expired metadata - if (cleanExpiredMetadata != null) { - expireSnapshots.cleanExpiredMetadata(cleanExpiredMetadata); - } - - // Set up ExecutorService for concurrent deletes if specified - if (maxConcurrentDeletes > 0) { - if (icebergTable.io() instanceof SupportsBulkOperations) { - LOG.warn("max_concurrent_deletes only works with FileIOs that do not support " - + "bulk deletes. This table is currently using {} which supports bulk deletes " - + "so the parameter will be ignored.", - icebergTable.io().getClass().getName()); - } else { - deleteExecutor = Executors.newFixedThreadPool(maxConcurrentDeletes); - expireSnapshots.executeDeleteWith(deleteExecutor); - } - } - - // Set up delete callback to count files by type - expireSnapshots.deleteWith(path -> { - FileContent deleteContent = deleteFileContentByPath.get(path); - if (deleteContent == FileContent.POSITION_DELETES) { - deletedPositionDeleteFilesCount.incrementAndGet(); - } else if (deleteContent == FileContent.EQUALITY_DELETES) { - deletedEqualityDeleteFilesCount.incrementAndGet(); - } else if (path.contains("-m-") && path.endsWith(".avro")) { - deletedManifestFilesCount.incrementAndGet(); - } else if (path.contains("snap-") && path.endsWith(".avro")) { - deletedManifestListsCount.incrementAndGet(); - } else if (path.endsWith(".stats") || path.contains("statistics")) { - deletedStatisticsFilesCount.incrementAndGet(); - } else { - deletedDataFilesCount.incrementAndGet(); - } - icebergTable.io().deleteFile(path); - }); - - // Execute and commit - expireSnapshots.commit(); - - // Invalidate cache - Env.getCurrentEnv().getExtMetaCacheMgr() - .invalidateTableCache((ExternalTable) table); - - return Lists.newArrayList( - String.valueOf(deletedDataFilesCount.get()), - String.valueOf(deletedPositionDeleteFilesCount.get()), - String.valueOf(deletedEqualityDeleteFilesCount.get()), - String.valueOf(deletedManifestFilesCount.get()), - String.valueOf(deletedManifestListsCount.get()), - String.valueOf(deletedStatisticsFilesCount.get()) - ); - } catch (Exception e) { - throw new UserException("Failed to expire snapshots: " + e.getMessage(), e); - } finally { - // Shutdown executor if created - if (deleteExecutor != null) { - deleteExecutor.shutdown(); - } - } - } - - /** - * Parse timestamp string to milliseconds since epoch. - * Supports ISO datetime format (yyyy-MM-ddTHH:mm:ss) or milliseconds. - */ - private long parseTimestamp(String timestamp) { - try { - // Try ISO datetime format - LocalDateTime dateTime = LocalDateTime.parse(timestamp, - DateTimeFormatter.ISO_LOCAL_DATE_TIME); - return dateTime.atZone(ZoneId.systemDefault()) - .toInstant().toEpochMilli(); - } catch (DateTimeParseException e) { - // Try as milliseconds - return Long.parseLong(timestamp); - } - } - - private Map buildDeleteFileContentMap(Table icebergTable) throws UserException { - Map deleteFileContentByPath = new HashMap<>(); - try { - for (org.apache.iceberg.Snapshot snapshot : icebergTable.snapshots()) { - List deleteManifests = snapshot.deleteManifests(icebergTable.io()); - if (deleteManifests == null || deleteManifests.isEmpty()) { - continue; - } - for (ManifestFile manifest : deleteManifests) { - try (CloseableIterable deleteFiles = ManifestFiles.readDeleteManifest( - manifest, icebergTable.io(), icebergTable.specs())) { - for (DeleteFile deleteFile : deleteFiles) { - deleteFileContentByPath.putIfAbsent( - deleteFile.location(), deleteFile.content()); - } - } - } - } - } catch (Exception e) { - throw new UserException("Failed to build delete file content map: " + e.getMessage(), e); - } - return deleteFileContentByPath; - } - - @Override - protected List getResultSchema() { - return Lists.newArrayList( - new Column("deleted_data_files_count", Type.BIGINT, false, - "Number of data files deleted"), - new Column("deleted_position_delete_files_count", Type.BIGINT, false, - "Number of position delete files deleted"), - new Column("deleted_equality_delete_files_count", Type.BIGINT, false, - "Number of equality delete files deleted"), - new Column("deleted_manifest_files_count", Type.BIGINT, false, - "Number of manifest files deleted"), - new Column("deleted_manifest_lists_count", Type.BIGINT, false, - "Number of manifest list files deleted"), - new Column("deleted_statistics_files_count", Type.BIGINT, false, - "Number of statistics files deleted") - ); - } - - @Override - public String getDescription() { - return "Expire old Iceberg snapshots to free up storage space and improve metadata performance"; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergFastForwardAction.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergFastForwardAction.java deleted file mode 100644 index 069f0feb92a49a..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergFastForwardAction.java +++ /dev/null @@ -1,114 +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.datasource.iceberg.action; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.catalog.Type; -import org.apache.doris.catalog.info.PartitionNamesInfo; -import org.apache.doris.common.ArgumentParsers; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.nereids.trees.expressions.Expression; - -import com.google.common.collect.Lists; -import org.apache.iceberg.Table; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -/** - * Iceberg fast forward action implementation. - * Fast-forward the current snapshot of one branch to the latest snapshot of - * another. - */ -public class IcebergFastForwardAction extends BaseIcebergAction { - public static final String BRANCH = "branch"; - public static final String TO = "to"; - - public IcebergFastForwardAction(Map properties, - Optional partitionNamesInfo, - Optional whereCondition) { - super("fast_forward", properties, partitionNamesInfo, whereCondition); - } - - @Override - protected void registerIcebergArguments() { - // Register required arguments for branch and to - namedArguments.registerRequiredArgument(BRANCH, - "Name of the branch to fast-forward to", - ArgumentParsers.nonEmptyString(BRANCH)); - namedArguments.registerRequiredArgument(TO, - "Target branch to fast-forward to", - ArgumentParsers.nonEmptyString(TO)); - } - - @Override - protected void validateIcebergAction() throws UserException { - // Iceberg procedures don't support partitions or where conditions - validateNoPartitions(); - validateNoWhereCondition(); - } - - @Override - protected List executeAction(TableIf table) throws UserException { - Table icebergTable = ((IcebergExternalTable) table).getIcebergTable(); - - String sourceBranch = namedArguments.getString(BRANCH); - String desBranch = namedArguments.getString(TO); - - try { - Long snapshotBefore = - icebergTable.snapshot(sourceBranch) != null ? icebergTable.snapshot(sourceBranch).snapshotId() - : null; - icebergTable.manageSnapshots().fastForwardBranch(sourceBranch, desBranch).commit(); - long snapshotAfter = icebergTable.snapshot(sourceBranch).snapshotId(); - // invalid iceberg catalog table cache. - Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTableCache((ExternalTable) table); - return Lists.newArrayList( - sourceBranch.trim(), - String.valueOf(snapshotBefore), - String.valueOf(snapshotAfter) - ); - - } catch (Exception e) { - throw new UserException( - "Failed to fast-forward branch " + sourceBranch + " to snapshot " + desBranch + ": " - + e.getMessage(), e); - } - } - - @Override - protected List getResultSchema() { - return Lists.newArrayList( - new Column("branch_updated", Type.STRING, false, - "Name of the branch that was fast-forwarded to match the target branch"), - new Column("previous_ref", Type.BIGINT, true, - "Snapshot ID that the branch was pointing to before the fast-forward operation"), - new Column("updated_ref", Type.BIGINT, false, - "Snapshot ID that the branch is pointing to after the fast-forward operation")); - } - - @Override - public String getDescription() { - return "Fast-forward the current snapshot of one branch to the latest snapshot of another."; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergPublishChangesAction.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergPublishChangesAction.java deleted file mode 100644 index 0a4187febe3990..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergPublishChangesAction.java +++ /dev/null @@ -1,128 +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.datasource.iceberg.action; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.catalog.Type; -import org.apache.doris.catalog.info.PartitionNamesInfo; -import org.apache.doris.common.ArgumentParsers; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.nereids.trees.expressions.Expression; - -import com.google.common.collect.Lists; -import org.apache.iceberg.Snapshot; -import org.apache.iceberg.Table; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -/** - * Implements Iceberg's publish_changes action (Core of the WAP pattern). - * This action finds a snapshot tagged with a specific 'wap.id' and cherry-picks it - * into the current table state. - * Corresponds to Spark syntax: CALL catalog.system.publish_changes('table', 'wap_id_123') - */ -public class IcebergPublishChangesAction extends BaseIcebergAction { - public static final String WAP_ID = "wap_id"; - private static final String WAP_ID_PROP = "wap.id"; - - public IcebergPublishChangesAction(Map properties, - Optional partitionNamesInfo, Optional whereCondition) { - super("publish_changes", properties, partitionNamesInfo, whereCondition); - } - - @Override - protected void registerIcebergArguments() { - namedArguments.registerRequiredArgument(WAP_ID, - "The WAP ID matching the snapshot to publish", - ArgumentParsers.nonEmptyString(WAP_ID)); - } - - @Override - protected void validateIcebergAction() throws UserException { - validateNoPartitions(); - validateNoWhereCondition(); - } - - @Override - protected List executeAction(TableIf table) throws UserException { - Table icebergTable = ((IcebergExternalTable) table).getIcebergTable(); - String targetWapId = namedArguments.getString(WAP_ID); - - // Find the target WAP snapshot - Snapshot wapSnapshot = null; - for (Snapshot snapshot : icebergTable.snapshots()) { - if (targetWapId.equals(snapshot.summary().get(WAP_ID_PROP))) { - wapSnapshot = snapshot; - break; - } - } - - if (wapSnapshot == null) { - throw new UserException("Cannot find snapshot with " + WAP_ID_PROP + " = " + targetWapId); - } - - long wapSnapshotId = wapSnapshot.snapshotId(); - - try { - // Get previous snapshot ID for result - Snapshot previousSnapshot = icebergTable.currentSnapshot(); - Long previousSnapshotId = previousSnapshot != null ? previousSnapshot.snapshotId() : null; - - // Execute Cherry-pick - icebergTable.manageSnapshots().cherrypick(wapSnapshotId).commit(); - - // Get current snapshot ID after commit - Snapshot currentSnapshot = icebergTable.currentSnapshot(); - Long currentSnapshotId = currentSnapshot != null ? currentSnapshot.snapshotId() : null; - - // Invalidate iceberg catalog table cache - Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTableCache((ExternalTable) table); - - String previousSnapshotIdString = previousSnapshotId != null ? String.valueOf(previousSnapshotId) : "null"; - String currentSnapshotIdString = currentSnapshotId != null ? String.valueOf(currentSnapshotId) : "null"; - - return Lists.newArrayList( - previousSnapshotIdString, - currentSnapshotIdString - ); - - } catch (Exception e) { - throw new UserException("Failed to publish changes for wap.id " + targetWapId + ": " + e.getMessage(), e); - } - } - - @Override - protected List getResultSchema() { - return Lists.newArrayList( - new Column("previous_snapshot_id", Type.STRING, false, - "ID of the snapshot before the publish operation"), - new Column("current_snapshot_id", Type.STRING, false, - "ID of the new snapshot created as a result of the publish operation")); - } - - @Override - public String getDescription() { - return "Publish a WAP snapshot by cherry-picking it to the current table state"; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergRewriteDataFilesAction.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergRewriteDataFilesAction.java deleted file mode 100644 index eb34eab0217d64..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergRewriteDataFilesAction.java +++ /dev/null @@ -1,225 +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.datasource.iceberg.action; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.catalog.Type; -import org.apache.doris.catalog.info.PartitionNamesInfo; -import org.apache.doris.common.ArgumentParsers; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergUtils; -import org.apache.doris.datasource.iceberg.rewrite.RewriteDataFileExecutor; -import org.apache.doris.datasource.iceberg.rewrite.RewriteDataFilePlanner; -import org.apache.doris.datasource.iceberg.rewrite.RewriteDataGroup; -import org.apache.doris.datasource.iceberg.rewrite.RewriteResult; -import org.apache.doris.nereids.trees.expressions.Expression; -import org.apache.doris.qe.ConnectContext; - -import com.google.common.collect.Lists; -import org.apache.iceberg.Table; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -/** - * Action for rewriting Iceberg data files to compact and optimize table data - * - * Execution Flow: - * 1. Validate rewrite parameters and get Iceberg table - * 2. Use RewriteDataFilePlanner to plan and organize file scan tasks into rewrite groups - * 3. Create and start rewrite job concurrently - * 4. Wait for job completion and return result - */ -public class IcebergRewriteDataFilesAction extends BaseIcebergAction { - private static final Logger LOG = LogManager.getLogger(IcebergRewriteDataFilesAction.class); - // File size parameters - public static final String TARGET_FILE_SIZE_BYTES = "target-file-size-bytes"; - public static final String MIN_FILE_SIZE_BYTES = "min-file-size-bytes"; - public static final String MAX_FILE_SIZE_BYTES = "max-file-size-bytes"; - - // Input files parameters - public static final String MIN_INPUT_FILES = "min-input-files"; - public static final String REWRITE_ALL = "rewrite-all"; - public static final String MAX_FILE_GROUP_SIZE_BYTES = "max-file-group-size-bytes"; - - // Delete files parameters - public static final String DELETE_FILE_THRESHOLD = "delete-file-threshold"; - public static final String DELETE_RATIO_THRESHOLD = "delete-ratio-threshold"; - - // Output specification parameter - public static final String OUTPUT_SPEC_ID = "output-spec-id"; - - // Parameters with special default handling - private long minFileSizeBytes; - private long maxFileSizeBytes; - - public IcebergRewriteDataFilesAction(Map properties, - Optional partitionNamesInfo, - Optional whereCondition) { - super("rewrite_data_files", properties, partitionNamesInfo, whereCondition); - } - - /** - * Register all arguments supported by rewrite_data_files action. - */ - @Override - protected void registerIcebergArguments() { - // File size arguments - namedArguments.registerOptionalArgument(TARGET_FILE_SIZE_BYTES, - "Target file size in bytes for output files", - 536870912L, - ArgumentParsers.positiveLong(TARGET_FILE_SIZE_BYTES)); - - namedArguments.registerOptionalArgument(MIN_FILE_SIZE_BYTES, - "Minimum file size in bytes for files to be rewritten", - 0L, - ArgumentParsers.positiveLong(MIN_FILE_SIZE_BYTES)); - - namedArguments.registerOptionalArgument(MAX_FILE_SIZE_BYTES, - "Maximum file size in bytes for files to be rewritten", - 0L, - ArgumentParsers.positiveLong(MAX_FILE_SIZE_BYTES)); - - // Input files arguments - namedArguments.registerOptionalArgument(MIN_INPUT_FILES, - "Minimum number of input files to rewrite together", - 5, - ArgumentParsers.intRange(MIN_INPUT_FILES, 1, 10000)); - - namedArguments.registerOptionalArgument(REWRITE_ALL, - "Whether to rewrite all files regardless of size", - false, - ArgumentParsers.booleanValue(REWRITE_ALL)); - - namedArguments.registerOptionalArgument(MAX_FILE_GROUP_SIZE_BYTES, - "Maximum size in bytes for a file group to be rewritten", - 107374182400L, - ArgumentParsers.positiveLong(MAX_FILE_GROUP_SIZE_BYTES)); - - // Delete files arguments - namedArguments.registerOptionalArgument(DELETE_FILE_THRESHOLD, - "Minimum number of delete files to trigger rewrite", - Integer.MAX_VALUE, - ArgumentParsers.intRange(DELETE_FILE_THRESHOLD, 1, Integer.MAX_VALUE)); - - namedArguments.registerOptionalArgument(DELETE_RATIO_THRESHOLD, - "Minimum ratio of delete records to total records to trigger rewrite", - 0.3, - ArgumentParsers.doubleRange(DELETE_RATIO_THRESHOLD, 0.0, 1.0)); - - // Output specification argument - namedArguments.registerOptionalArgument(OUTPUT_SPEC_ID, - "Partition specification ID for output files", - 2L, - ArgumentParsers.positiveLong(OUTPUT_SPEC_ID)); - } - - @Override - protected List getResultSchema() { - return Lists.newArrayList( - new Column("rewritten_data_files_count", Type.INT, false, - "Number of data which were re-written by this command"), - new Column("added_data_files_count", Type.INT, false, - "Number of new data files which were written by this command"), - new Column("rewritten_bytes_count", Type.INT, false, - "Number of bytes which were written by this command"), - new Column("removed_delete_files_count", Type.BIGINT, false, - "Number of delete files removed by this command")); - } - - @Override - protected void validateIcebergAction() throws UserException { - // Validate min and max file size parameters - long targetFileSizeBytes = namedArguments.getLong(TARGET_FILE_SIZE_BYTES); - // min-file-size-bytes default to 75% of target file size - this.minFileSizeBytes = namedArguments.getLong(MIN_FILE_SIZE_BYTES); - if (this.minFileSizeBytes == 0) { - this.minFileSizeBytes = (long) (targetFileSizeBytes * 0.75); - } - // max-file-size-bytes default to 180% of target file size - this.maxFileSizeBytes = namedArguments.getLong(MAX_FILE_SIZE_BYTES); - if (this.maxFileSizeBytes == 0) { - this.maxFileSizeBytes = (long) (targetFileSizeBytes * 1.8); - } - if (this.minFileSizeBytes > this.maxFileSizeBytes) { - throw new UserException("min-file-size-bytes must be less than or equal to max-file-size-bytes"); - } - validateNoPartitions(); - } - - @Override - protected List executeAction(TableIf table) throws UserException { - try { - Table icebergTable = IcebergUtils.getIcebergTable((IcebergExternalTable) table); - - if (icebergTable.currentSnapshot() == null) { - LOG.info("Table {} has no data, skipping rewrite", table.getName()); - // return empty result - return Lists.newArrayList("0", "0", "0", "0"); - } - - RewriteDataFilePlanner.Parameters parameters = buildRewriteParameters(); - - ConnectContext connectContext = ConnectContext.get(); - if (connectContext == null) { - throw new UserException("No active connection context found"); - } - - // Step 1: Plan and organize file scan tasks into groups - RewriteDataFilePlanner fileManager = new RewriteDataFilePlanner(parameters); - Iterable allGroups = fileManager.planAndOrganizeTasks(icebergTable); - - // Step 2: Execute rewrite groups concurrently - List groupsList = Lists.newArrayList(allGroups); - - // Create executor and execute groups concurrently - RewriteDataFileExecutor executor = new RewriteDataFileExecutor( - (IcebergExternalTable) table, connectContext); - long targetFileSizeBytes = namedArguments.getLong(TARGET_FILE_SIZE_BYTES); - RewriteResult totalResult = executor.executeGroupsConcurrently(groupsList, targetFileSizeBytes); - return totalResult.toStringList(); - } catch (Exception e) { - LOG.warn("Failed to rewrite data files for table: " + table.getName(), e); - throw new UserException("Rewrite data files failed: " + e.getMessage()); - } - } - - private RewriteDataFilePlanner.Parameters buildRewriteParameters() { - return new RewriteDataFilePlanner.Parameters( - namedArguments.getLong(TARGET_FILE_SIZE_BYTES), - this.minFileSizeBytes, - this.maxFileSizeBytes, - namedArguments.getInt(MIN_INPUT_FILES), - namedArguments.getBoolean(REWRITE_ALL), - namedArguments.getLong(MAX_FILE_GROUP_SIZE_BYTES), - namedArguments.getInt(DELETE_FILE_THRESHOLD), - namedArguments.getDouble(DELETE_RATIO_THRESHOLD), - namedArguments.getLong(OUTPUT_SPEC_ID), - whereCondition); - } - - @Override - public String getDescription() { - return "Rewrite Iceberg data files to optimize file sizes and improve query performance"; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergRewriteManifestsAction.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergRewriteManifestsAction.java deleted file mode 100644 index 5e8f5db109f157..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergRewriteManifestsAction.java +++ /dev/null @@ -1,109 +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.datasource.iceberg.action; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.catalog.Type; -import org.apache.doris.catalog.info.PartitionNamesInfo; -import org.apache.doris.common.ArgumentParsers; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.rewrite.RewriteManifestExecutor; -import org.apache.doris.nereids.trees.expressions.Expression; - -import com.google.common.collect.Lists; -import org.apache.iceberg.Snapshot; -import org.apache.iceberg.Table; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -/** - * Action for rewriting Iceberg manifest files to optimize metadata layout - */ -public class IcebergRewriteManifestsAction extends BaseIcebergAction { - private static final Logger LOG = LogManager.getLogger(IcebergRewriteManifestsAction.class); - public static final String SPEC_ID = "spec_id"; - - public IcebergRewriteManifestsAction(Map properties, - Optional partitionNamesInfo, - Optional whereCondition) { - super("rewrite_manifests", properties, partitionNamesInfo, whereCondition); - } - - @Override - protected void registerIcebergArguments() { - namedArguments.registerOptionalArgument(SPEC_ID, - "Spec id of the manifests to rewrite (defaults to current spec id)", - null, - ArgumentParsers.intRange(SPEC_ID, 0, Integer.MAX_VALUE)); - } - - @Override - protected void validateIcebergAction() throws UserException { - validateNoPartitions(); - validateNoWhereCondition(); - } - - @Override - protected List executeAction(TableIf table) throws UserException { - try { - Table icebergTable = ((IcebergExternalTable) table).getIcebergTable(); - Snapshot current = icebergTable.currentSnapshot(); - if (current == null) { - // No current snapshot means the table is empty, no manifests to rewrite - return Lists.newArrayList("0", "0"); - } - - // Get optional spec_id parameter - Integer specId = namedArguments.getInt(SPEC_ID); - - // Execute rewrite operation - RewriteManifestExecutor executor = new RewriteManifestExecutor(); - RewriteManifestExecutor.Result result = executor.execute( - icebergTable, - (ExternalTable) table, - specId); - - return result.toStringList(); - } catch (Exception e) { - LOG.warn("Failed to rewrite manifests for table: {}", table.getName(), e); - throw new UserException("Rewrite manifests failed: " + e.getMessage(), e); - } - } - - @Override - protected List getResultSchema() { - return Lists.newArrayList( - new Column("rewritten_manifests_count", Type.INT, false, - "Number of manifests which were re-written by this command"), - new Column("added_manifests_count", Type.INT, false, - "Number of new manifest files which were written by this command") - ); - } - - @Override - public String getDescription() { - return "Rewrite Iceberg manifest files to optimize metadata layout"; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergRollbackToSnapshotAction.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergRollbackToSnapshotAction.java deleted file mode 100644 index 4019a2b9b4a585..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergRollbackToSnapshotAction.java +++ /dev/null @@ -1,114 +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.datasource.iceberg.action; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.catalog.Type; -import org.apache.doris.catalog.info.PartitionNamesInfo; -import org.apache.doris.common.ArgumentParsers; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.nereids.trees.expressions.Expression; - -import com.google.common.collect.Lists; -import org.apache.iceberg.Snapshot; -import org.apache.iceberg.Table; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -/** - * Iceberg rollback to snapshot action implementation. - * This action rolls back the Iceberg table to a specific snapshot identified by - * snapshot ID. - */ -public class IcebergRollbackToSnapshotAction extends BaseIcebergAction { - public static final String SNAPSHOT_ID = "snapshot_id"; - - public IcebergRollbackToSnapshotAction(Map properties, - Optional partitionNamesInfo, - Optional whereCondition) { - super("rollback_to_snapshot", properties, partitionNamesInfo, whereCondition); - } - - @Override - protected void registerIcebergArguments() { - // Register snapshot_id as a required parameter - namedArguments.registerRequiredArgument(SNAPSHOT_ID, - "Snapshot ID to rollback to", - ArgumentParsers.positiveLong(SNAPSHOT_ID)); - } - - @Override - protected void validateIcebergAction() throws UserException { - // Iceberg rollback_to_snapshot procedures don't support partitions or where - // conditions - validateNoPartitions(); - validateNoWhereCondition(); - } - - @Override - protected List executeAction(TableIf table) throws UserException { - Table icebergTable = ((IcebergExternalTable) table).getIcebergTable(); - Long targetSnapshotId = namedArguments.getLong(SNAPSHOT_ID); - - Snapshot targetSnapshot = icebergTable.snapshot(targetSnapshotId); - if (targetSnapshot == null) { - throw new UserException("Snapshot " + targetSnapshotId + " not found in table " + icebergTable.name()); - } - - try { - Snapshot previousSnapshot = icebergTable.currentSnapshot(); - Long previousSnapshotId = previousSnapshot != null ? previousSnapshot.snapshotId() : null; - if (previousSnapshot != null && previousSnapshot.snapshotId() == targetSnapshotId) { - return Lists.newArrayList( - String.valueOf(previousSnapshotId), - String.valueOf(targetSnapshotId) - ); - } - icebergTable.manageSnapshots().rollbackTo(targetSnapshotId).commit(); - // invalid iceberg catalog table cache. - Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTableCache((ExternalTable) table); - return Lists.newArrayList( - String.valueOf(previousSnapshotId), - String.valueOf(targetSnapshotId) - ); - - } catch (Exception e) { - throw new UserException("Failed to rollback to snapshot " + targetSnapshotId + ": " + e.getMessage(), e); - } - } - - @Override - protected List getResultSchema() { - return Lists.newArrayList( - new Column("previous_snapshot_id", Type.BIGINT, false, - "ID of the snapshot that was current before the rollback operation"), - new Column("current_snapshot_id", Type.BIGINT, false, - "ID of the snapshot that is now current after rolling back to the specified snapshot")); - } - - @Override - public String getDescription() { - return "Rollback Iceberg table to a specific snapshot"; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergRollbackToTimestampAction.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergRollbackToTimestampAction.java deleted file mode 100644 index f01367a85dc896..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergRollbackToTimestampAction.java +++ /dev/null @@ -1,155 +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.datasource.iceberg.action; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.catalog.Type; -import org.apache.doris.catalog.info.PartitionNamesInfo; -import org.apache.doris.common.UserException; -import org.apache.doris.common.util.TimeUtils; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.nereids.trees.expressions.Expression; - -import com.google.common.collect.Lists; -import org.apache.iceberg.Snapshot; -import org.apache.iceberg.Table; - -import java.time.format.DateTimeFormatter; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.TimeZone; - -/** - * Iceberg rollback to timestamp action implementation. - * This action rolls back the Iceberg table to the snapshot that was current - * at a specific timestamp. - */ -public class IcebergRollbackToTimestampAction extends BaseIcebergAction { - private static final DateTimeFormatter DATETIME_MS_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); - public static final String TIMESTAMP = "timestamp"; - - public IcebergRollbackToTimestampAction(Map properties, - Optional partitionNamesInfo, - Optional whereCondition) { - super("rollback_to_timestamp", properties, partitionNamesInfo, whereCondition); - } - - @Override - protected void registerIcebergArguments() { - // Create a custom timestamp parser that supports both ISO datetime and - // millisecond formats - namedArguments.registerRequiredArgument(TIMESTAMP, - "A timestamp to rollback to (formats: 'yyyy-MM-dd HH:mm:ss.SSS' or milliseconds since epoch)", - value -> { - if (value == null || value.trim().isEmpty()) { - throw new IllegalArgumentException("timestamp cannot be empty"); - } - - String trimmed = value.trim(); - - // Try to parse as milliseconds first - try { - long timestampMs = Long.parseLong(trimmed); - if (timestampMs < 0) { - throw new IllegalArgumentException("Timestamp must be non-negative: " + timestampMs); - } - return trimmed; - } catch (NumberFormatException e) { - // Second attempt: Parse as ISO datetime format (yyyy-MM-dd HH:mm:ss.SSS) - try { - java.time.LocalDateTime.parse(trimmed, DATETIME_MS_FORMAT); - return trimmed; - } catch (java.time.format.DateTimeParseException dte) { - throw new IllegalArgumentException("Invalid timestamp format. Expected ISO datetime " - + "(yyyy-MM-dd HH:mm:ss.SSS) or timestamp in milliseconds: " + trimmed); - } - } - }); - } - - @Override - protected void validateIcebergAction() throws UserException { - // Iceberg rollback_to_timestamp procedures don't support partitions or where - // conditions - validateNoPartitions(); - validateNoWhereCondition(); - } - - @Override - protected List executeAction(TableIf table) throws UserException { - Table icebergTable = ((IcebergExternalTable) table).getIcebergTable(); - - String timestampStr = namedArguments.getString(TIMESTAMP); - - Snapshot previousSnapshot = icebergTable.currentSnapshot(); - Long previousSnapshotId = previousSnapshot != null ? previousSnapshot.snapshotId() : null; - - try { - long targetTimestamp = parseTimestampMillis(timestampStr, TimeUtils.getTimeZone()); - icebergTable.manageSnapshots().rollbackToTime(targetTimestamp).commit(); - - Snapshot currentSnapshot = icebergTable.currentSnapshot(); - Long currentSnapshotId = currentSnapshot != null ? currentSnapshot.snapshotId() : null; - // invalid iceberg catalog table cache. - Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTableCache((ExternalTable) table); - return Lists.newArrayList( - String.valueOf(previousSnapshotId), - String.valueOf(currentSnapshotId) - ); - - } catch (Exception e) { - throw new UserException("Failed to rollback to timestamp " + timestampStr + ": " + e.getMessage(), e); - } - } - - @Override - protected List getResultSchema() { - return Lists.newArrayList( - new Column("previous_snapshot_id", Type.BIGINT, false, - "ID of the snapshot that was current before the rollback operation"), - new Column("current_snapshot_id", Type.BIGINT, false, - "ID of the snapshot that was current at the specified timestamp and is now set as current")); - } - - @Override - public String getDescription() { - return "Rollback Iceberg table to the snapshot that was current at a specific timestamp"; - } - - static long parseTimestampMillis(String timestampStr, TimeZone timeZone) { - String trimmed = timestampStr.trim(); - try { - long timestampMs = Long.parseLong(trimmed); - if (timestampMs < 0) { - throw new IllegalArgumentException("Timestamp must be non-negative: " + timestampMs); - } - return timestampMs; - } catch (NumberFormatException e) { - long parsedTimestamp = TimeUtils.msTimeStringToLong(trimmed, timeZone); - if (parsedTimestamp < 0) { - throw new IllegalArgumentException("Invalid timestamp format. Expected ISO datetime " - + "(yyyy-MM-dd HH:mm:ss.SSS) or timestamp in milliseconds: " + trimmed, e); - } - return parsedTimestamp; - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergSetCurrentSnapshotAction.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergSetCurrentSnapshotAction.java deleted file mode 100644 index 54c0ba662fe332..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/IcebergSetCurrentSnapshotAction.java +++ /dev/null @@ -1,159 +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.datasource.iceberg.action; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.catalog.Type; -import org.apache.doris.catalog.info.PartitionNamesInfo; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.ArgumentParsers; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.nereids.trees.expressions.Expression; - -import com.google.common.collect.Lists; -import org.apache.iceberg.Snapshot; -import org.apache.iceberg.Table; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -/** - * Iceberg set current snapshot action implementation. - * This action sets the current snapshot of an Iceberg table to a specific - * snapshot ID or reference (branch or tag). - */ -public class IcebergSetCurrentSnapshotAction extends BaseIcebergAction { - public static final String SNAPSHOT_ID = "snapshot_id"; - public static final String REF = "ref"; - - public IcebergSetCurrentSnapshotAction(Map properties, - Optional partitionNamesInfo, - Optional whereCondition) { - super("set_current_snapshot", properties, partitionNamesInfo, whereCondition); - } - - @Override - protected void registerIcebergArguments() { - // Either snapshot_id or ref must be provided but not both - namedArguments.registerOptionalArgument(SNAPSHOT_ID, - "Snapshot ID to set as current", - null, - ArgumentParsers.positiveLong(SNAPSHOT_ID)); - - namedArguments.registerOptionalArgument(REF, - "Snapshot Reference (branch or tag) to set as current", - null, - ArgumentParsers.nonEmptyString(REF)); - } - - @Override - protected void validateIcebergAction() throws UserException { - // Either snapshot_id or ref must be provided but not both - Long snapshotId = namedArguments.getLong(SNAPSHOT_ID); - String ref = namedArguments.getString(REF); - - if (snapshotId == null && ref == null) { - throw new AnalysisException("Either snapshot_id or ref must be provided"); - } - - if (snapshotId != null && ref != null) { - throw new AnalysisException("snapshot_id and ref are mutually exclusive, only one can be provided"); - } - - // Iceberg procedures don't support partitions or where conditions - validateNoPartitions(); - validateNoWhereCondition(); - } - - @Override - protected List executeAction(TableIf table) throws UserException { - Table icebergTable = ((IcebergExternalTable) table).getIcebergTable(); - - Snapshot previousSnapshot = icebergTable.currentSnapshot(); - Long previousSnapshotId = previousSnapshot != null ? previousSnapshot.snapshotId() : null; - - Long targetSnapshotId = namedArguments.getLong(SNAPSHOT_ID); - String ref = namedArguments.getString(REF); - - try { - if (targetSnapshotId != null) { - Snapshot targetSnapshot = icebergTable.snapshot(targetSnapshotId); - if (targetSnapshot == null) { - throw new UserException( - "Snapshot " + targetSnapshotId + " not found in table " + icebergTable.name()); - } - - if (previousSnapshot != null && previousSnapshot.snapshotId() == targetSnapshotId) { - return Lists.newArrayList( - String.valueOf(previousSnapshotId), - String.valueOf(targetSnapshotId) - ); - } - - icebergTable.manageSnapshots().setCurrentSnapshot(targetSnapshotId).commit(); - - } else if (ref != null) { - Snapshot refSnapshot = icebergTable.snapshot(ref); - if (refSnapshot == null) { - throw new UserException("Reference '" + ref + "' not found in table " + icebergTable.name()); - } - targetSnapshotId = refSnapshot.snapshotId(); - - if (previousSnapshot != null && previousSnapshot.snapshotId() == targetSnapshotId) { - return Lists.newArrayList( - String.valueOf(previousSnapshotId), - String.valueOf(targetSnapshotId) - ); - } - - icebergTable.manageSnapshots().setCurrentSnapshot(targetSnapshotId).commit(); - } - - // invalid iceberg catalog table cache. - Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTableCache((ExternalTable) table); - return Lists.newArrayList( - String.valueOf(previousSnapshotId), - String.valueOf(targetSnapshotId) - ); - - } catch (Exception e) { - String target = targetSnapshotId != null ? "snapshot " + targetSnapshotId : "reference '" + ref + "'"; - throw new UserException("Failed to set current snapshot to " + target + ": " + e.getMessage(), e); - } - } - - @Override - protected List getResultSchema() { - return Lists.newArrayList( - new Column("previous_snapshot_id", Type.BIGINT, false, - "ID of the snapshot that was current before setting the new current snapshot"), - new Column("current_snapshot_id", Type.BIGINT, false, - "ID of the snapshot that is now set as the current snapshot " - + "(from snapshot_id parameter or resolved from ref parameter)")); - } - - @Override - public String getDescription() { - return "Set current snapshot of Iceberg table to a specific snapshot ID or reference"; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/broker/BrokerInputFile.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/broker/BrokerInputFile.java deleted file mode 100644 index 529df6c0af10a2..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/broker/BrokerInputFile.java +++ /dev/null @@ -1,74 +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.datasource.iceberg.broker; - -import org.apache.doris.analysis.BrokerDesc; -import org.apache.doris.common.util.BrokerReader; -import org.apache.doris.thrift.TBrokerFD; - -import org.apache.iceberg.io.InputFile; -import org.apache.iceberg.io.SeekableInputStream; - -import java.io.IOException; - -public class BrokerInputFile implements InputFile { - - private Long fileLength = null; - - private final String filePath; - private final BrokerDesc brokerDesc; - private BrokerReader reader; - private TBrokerFD fd; - - private BrokerInputFile(String filePath, BrokerDesc brokerDesc) { - this.filePath = filePath; - this.brokerDesc = brokerDesc; - } - - private void init() throws IOException { - this.reader = BrokerReader.create(this.brokerDesc); - this.fileLength = this.reader.getFileLength(filePath); - this.fd = this.reader.open(filePath); - } - - public static BrokerInputFile create(String filePath, BrokerDesc brokerDesc) throws IOException { - BrokerInputFile inputFile = new BrokerInputFile(filePath, brokerDesc); - inputFile.init(); - return inputFile; - } - - @Override - public long getLength() { - return fileLength; - } - - @Override - public SeekableInputStream newStream() { - return new BrokerInputStream(this.reader, this.fd, this.fileLength); - } - - @Override - public String location() { - return filePath; - } - - @Override - public boolean exists() { - return fileLength != null; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/broker/BrokerInputStream.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/broker/BrokerInputStream.java deleted file mode 100644 index b9f6d30aed4fd3..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/broker/BrokerInputStream.java +++ /dev/null @@ -1,169 +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.datasource.iceberg.broker; - -import org.apache.doris.common.util.BrokerReader; -import org.apache.doris.thrift.TBrokerFD; - -import org.apache.iceberg.io.SeekableInputStream; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.IOException; - -public class BrokerInputStream extends SeekableInputStream { - private static final Logger LOG = LogManager.getLogger(BrokerInputStream.class); - private static final int COPY_BUFFER_SIZE = 1024 * 1024; // 1MB - - private final byte[] tmpBuf = new byte[COPY_BUFFER_SIZE]; - private long currentPos = 0; - private long markPos = 0; - - private long bufferOffset = 0; - private long bufferLimit = 0; - private final BrokerReader reader; - private final TBrokerFD fd; - private final long fileLength; - - public BrokerInputStream(BrokerReader reader, TBrokerFD fd, long fileLength) { - this.fd = fd; - this.reader = reader; - this.fileLength = fileLength; - } - - @Override - public long getPos() throws IOException { - return currentPos; - } - - @Override - public void seek(long newPos) throws IOException { - currentPos = newPos; - } - - @Override - public int read() throws IOException { - try { - if (currentPos < bufferOffset || currentPos > bufferLimit || bufferOffset >= bufferLimit) { - bufferOffset = currentPos; - fill(); - } - if (currentPos > bufferLimit) { - LOG.warn("current pos {} is larger than buffer limit {}." - + " should not happen.", currentPos, bufferLimit); - return -1; - } - - int pos = (int) (currentPos - bufferOffset); - int res = Byte.toUnsignedInt(tmpBuf[pos]); - ++currentPos; - return res; - } catch (BrokerReader.EOFException e) { - return -1; - } - } - - @SuppressWarnings("NullableProblems") - @Override - public int read(byte[] b) throws IOException { - try { - byte[] data = reader.pread(fd, currentPos, b.length); - System.arraycopy(data, 0, b, 0, data.length); - currentPos += data.length; - return data.length; - } catch (BrokerReader.EOFException e) { - return -1; - } - } - - @SuppressWarnings("NullableProblems") - @Override - public int read(byte[] b, int off, int len) throws IOException { - try { - if (currentPos < bufferOffset || currentPos > bufferLimit || currentPos + len > bufferLimit) { - if (len > COPY_BUFFER_SIZE) { - // the data to be read is larger then max size of buffer. - // read it directly. - byte[] data = reader.pread(fd, currentPos, len); - System.arraycopy(data, 0, b, off, data.length); - currentPos += data.length; - return data.length; - } - // fill the buffer first - bufferOffset = currentPos; - fill(); - } - - if (currentPos > bufferLimit) { - LOG.warn("current pos {} is larger than buffer limit {}." - + " should not happen.", currentPos, bufferLimit); - return -1; - } - - int start = (int) (currentPos - bufferOffset); - int readLen = Math.min(len, (int) (bufferLimit - bufferOffset)); - System.arraycopy(tmpBuf, start, b, off, readLen); - currentPos += readLen; - return readLen; - } catch (BrokerReader.EOFException e) { - return -1; - } - } - - private void fill() throws IOException, BrokerReader.EOFException { - if (bufferOffset == this.fileLength) { - throw new BrokerReader.EOFException(); - } - byte[] data = reader.pread(fd, bufferOffset, COPY_BUFFER_SIZE); - System.arraycopy(data, 0, tmpBuf, 0, data.length); - bufferLimit = bufferOffset + data.length; - } - - @Override - public long skip(long n) throws IOException { - final long left = fileLength - currentPos; - long min = Math.min(n, left); - currentPos += min; - return min; - } - - @Override - public int available() throws IOException { - return 0; - } - - @Override - public void close() throws IOException { - reader.close(fd); - } - - @Override - public synchronized void mark(int readlimit) { - markPos = currentPos; - } - - @Override - public synchronized void reset() throws IOException { - currentPos = markPos; - } - - @Override - public boolean markSupported() { - return true; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/broker/IcebergBrokerIO.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/broker/IcebergBrokerIO.java deleted file mode 100644 index a79ce4619f9f15..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/broker/IcebergBrokerIO.java +++ /dev/null @@ -1,80 +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.datasource.iceberg.broker; - -import org.apache.doris.analysis.BrokerDesc; -import org.apache.doris.datasource.hive.HMSExternalCatalog; - -import com.google.common.collect.ImmutableMap; -import org.apache.iceberg.io.FileIO; -import org.apache.iceberg.io.InputFile; -import org.apache.iceberg.io.OutputFile; -import org.apache.iceberg.util.SerializableMap; - -import java.io.IOException; -import java.util.Map; - -/** - * FileIO implementation that uses broker to execute Iceberg files IO operation. - */ -public class IcebergBrokerIO implements FileIO { - - private SerializableMap properties = SerializableMap.copyOf(ImmutableMap.of()); - private BrokerDesc brokerDesc = null; - - @Override - public void initialize(Map props) { - this.properties = SerializableMap.copyOf(props); - if (!properties.containsKey(HMSExternalCatalog.BIND_BROKER_NAME)) { - throw new UnsupportedOperationException(String.format("No broker is specified, " - + "try to set '%s' in HMS Catalog", HMSExternalCatalog.BIND_BROKER_NAME)); - } - String brokerName = properties.get(HMSExternalCatalog.BIND_BROKER_NAME); - this.brokerDesc = new BrokerDesc(brokerName, properties.immutableMap()); - } - - @Override - public Map properties() { - return properties.immutableMap(); - } - - @Override - public InputFile newInputFile(String path) { - if (brokerDesc == null) { - throw new UnsupportedOperationException("IcebergBrokerIO should be initialized first"); - } - try { - return BrokerInputFile.create(path, brokerDesc); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - @Override - public OutputFile newOutputFile(String path) { - throw new UnsupportedOperationException("IcebergBrokerIO does not support writing files"); - } - - @Override - public void deleteFile(String path) { - throw new UnsupportedOperationException("IcebergBrokerIO does not support deleting files"); - } - - @Override - public void close() { } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/cache/IcebergManifestCacheLoader.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/cache/IcebergManifestCacheLoader.java deleted file mode 100644 index bd76e7e6424908..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/cache/IcebergManifestCacheLoader.java +++ /dev/null @@ -1,54 +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.datasource.iceberg.cache; - -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalMetaCache; - -import org.apache.iceberg.ManifestFile; -import org.apache.iceberg.Table; - -import java.util.function.Consumer; - -/** - * Helper to load manifest content and populate the manifest cache. - */ -public class IcebergManifestCacheLoader { - private IcebergManifestCacheLoader() { - } - - public static ManifestCacheValue loadDataFilesWithCache(IcebergExternalMetaCache cache, ExternalTable dorisTable, - ManifestFile manifest, Table table) { - return loadDataFilesWithCache(cache, dorisTable, manifest, table, null); - } - - public static ManifestCacheValue loadDataFilesWithCache(IcebergExternalMetaCache cache, ExternalTable dorisTable, - ManifestFile manifest, Table table, Consumer cacheHitRecorder) { - return cache.getManifestCacheValue(dorisTable, manifest, table, cacheHitRecorder); - } - - public static ManifestCacheValue loadDeleteFilesWithCache(IcebergExternalMetaCache cache, - ExternalTable dorisTable, ManifestFile manifest, Table table) { - return loadDeleteFilesWithCache(cache, dorisTable, manifest, table, null); - } - - public static ManifestCacheValue loadDeleteFilesWithCache(IcebergExternalMetaCache cache, - ExternalTable dorisTable, ManifestFile manifest, Table table, Consumer cacheHitRecorder) { - return cache.getManifestCacheValue(dorisTable, manifest, table, cacheHitRecorder); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/dlf/DLFCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/dlf/DLFCatalog.java deleted file mode 100644 index fb90260f09aeae..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/dlf/DLFCatalog.java +++ /dev/null @@ -1,72 +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.datasource.iceberg.dlf; - -import org.apache.doris.common.credentials.CloudCredential; -import org.apache.doris.common.util.S3Util; -import org.apache.doris.datasource.iceberg.HiveCompatibleCatalog; -import org.apache.doris.datasource.iceberg.dlf.client.DLFCachedClientPool; -import org.apache.doris.datasource.property.storage.OSSProperties; - -import org.apache.commons.lang3.StringUtils; -import org.apache.hadoop.conf.Configuration; -import org.apache.iceberg.TableOperations; -import org.apache.iceberg.aws.s3.S3FileIO; -import org.apache.iceberg.catalog.TableIdentifier; -import org.apache.iceberg.io.FileIO; - -import java.net.URI; -import java.util.Map; - -public class DLFCatalog extends HiveCompatibleCatalog { - - @Override - public void initialize(String name, Map properties) { - super.initialize(name, initializeFileIO(properties, conf), new DLFCachedClientPool(this.conf, properties)); - } - - @Override - protected TableOperations newTableOps(TableIdentifier tableIdentifier) { - String dbName = tableIdentifier.namespace().level(0); - String tableName = tableIdentifier.name(); - return new DLFTableOperations(this.conf, this.clients, this.fileIO, this.catalogName, dbName, tableName); - } - - protected FileIO initializeFileIO(Map properties, Configuration hadoopConf) { - // read from converted properties or default by old s3 aws properties - OSSProperties ossProperties = OSSProperties.of(properties); - String endpoint = ossProperties.getEndpoint(); - CloudCredential credential = new CloudCredential(); - credential.setAccessKey(ossProperties.getAccessKey()); - credential.setSecretKey(ossProperties.getSecretKey()); - if (StringUtils.isNotBlank(ossProperties.getSessionToken())) { - credential.setSessionToken(ossProperties.getSessionToken()); - } - String region = ossProperties.getRegion(); - boolean isUsePathStyle = Boolean.parseBoolean(ossProperties.getUsePathStyle()); - // s3 file io just supports s3-like endpoint - String s3Endpoint = endpoint.replace("oss-" + region, "s3.oss-" + region); - if (!s3Endpoint.contains("://")) { - s3Endpoint = "http://" + s3Endpoint; - } - URI endpointUri = URI.create(s3Endpoint); - FileIO io = new S3FileIO(() -> S3Util.buildS3Client(endpointUri, region, credential, isUsePathStyle)); - io.initialize(properties); - return io; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/dlf/DLFTableOperations.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/dlf/DLFTableOperations.java deleted file mode 100644 index 2aab8e754ca2ea..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/dlf/DLFTableOperations.java +++ /dev/null @@ -1,37 +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.datasource.iceberg.dlf; - -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.hive.metastore.IMetaStoreClient; -import org.apache.iceberg.ClientPool; -import org.apache.iceberg.hive.HiveTableOperations; -import org.apache.iceberg.io.FileIO; -import shade.doris.hive.org.apache.thrift.TException; - -public class DLFTableOperations extends HiveTableOperations { - - public DLFTableOperations(Configuration conf, - ClientPool metaClients, - FileIO fileIO, - String catalogName, - String database, - String table) { - super(conf, metaClients, fileIO, catalogName, database, table); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/dlf/client/DLFCachedClientPool.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/dlf/client/DLFCachedClientPool.java deleted file mode 100644 index 9de0981e9809ce..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/dlf/client/DLFCachedClientPool.java +++ /dev/null @@ -1,88 +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.datasource.iceberg.dlf.client; - -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.hive.metastore.IMetaStoreClient; -import org.apache.iceberg.CatalogProperties; -import org.apache.iceberg.ClientPool; -import org.apache.iceberg.util.PropertyUtil; -import shade.doris.hive.org.apache.thrift.TException; - -import java.util.Map; -import java.util.concurrent.TimeUnit; - -public class DLFCachedClientPool implements ClientPool { - - private Cache clientPoolCache; - private final Configuration conf; - private final String endpoint; - private final int clientPoolSize; - private final long evictionInterval; - - // This cached client pool should belong to the catalog level, - // each catalog has its own pool - public DLFCachedClientPool(Configuration conf, Map properties) { - this.conf = conf; - this.endpoint = conf.get("", ""); - this.clientPoolSize = getClientPoolSize(properties); - this.evictionInterval = getEvictionInterval(properties); - initializeClientPoolCache(); - } - - private int getClientPoolSize(Map properties) { - return PropertyUtil.propertyAsInt( - properties, - CatalogProperties.CLIENT_POOL_SIZE, - CatalogProperties.CLIENT_POOL_SIZE_DEFAULT - ); - } - - private long getEvictionInterval(Map properties) { - return PropertyUtil.propertyAsLong( - properties, - CatalogProperties.CLIENT_POOL_CACHE_EVICTION_INTERVAL_MS, - CatalogProperties.CLIENT_POOL_CACHE_EVICTION_INTERVAL_MS_DEFAULT - ); - } - - private void initializeClientPoolCache() { - clientPoolCache = Caffeine.newBuilder() - .expireAfterAccess(evictionInterval, TimeUnit.MILLISECONDS) - .removalListener((key, value, cause) -> ((DLFClientPool) value).close()) - .build(); - } - - protected DLFClientPool clientPool() { - return clientPoolCache.get(endpoint, k -> new DLFClientPool(clientPoolSize, conf)); - } - - @Override - public R run(Action action) throws TException, InterruptedException { - return clientPool().run(action); - } - - @Override - public R run(Action action, boolean retry) - throws TException, InterruptedException { - return clientPool().run(action, retry); - } -} - diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/dlf/client/DLFClientPool.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/dlf/client/DLFClientPool.java deleted file mode 100644 index 827a831f6edf3f..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/dlf/client/DLFClientPool.java +++ /dev/null @@ -1,46 +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.datasource.iceberg.dlf.client; - -import com.aliyun.datalake.metastore.hive2.ProxyMetaStoreClient; -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.hive.conf.HiveConf; -import org.apache.hadoop.hive.metastore.IMetaStoreClient; -import org.apache.hadoop.hive.metastore.RetryingMetaStoreClient; -import org.apache.iceberg.hive.HiveClientPool; -import org.apache.iceberg.hive.RuntimeMetaException; - -public class DLFClientPool extends HiveClientPool { - - private final HiveConf hiveConf; - - public DLFClientPool(int poolSize, Configuration conf) { - super(poolSize, conf); - this.hiveConf = new HiveConf(conf, DLFClientPool.class); - this.hiveConf.addResource(conf); - } - - @Override - protected IMetaStoreClient newClient() { - try { - return RetryingMetaStoreClient.getProxy(hiveConf, tbl -> null, ProxyMetaStoreClient.class.getName()); - } catch (Exception e) { - throw new RuntimeMetaException(e, "Failed to connect to Hive Metastore"); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/fileio/DelegateFileIO.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/fileio/DelegateFileIO.java deleted file mode 100644 index 8d42726421d491..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/fileio/DelegateFileIO.java +++ /dev/null @@ -1,243 +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.datasource.iceberg.fileio; - -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.filesystem.FileSystem; -import org.apache.doris.filesystem.Location; -import org.apache.doris.fs.FileSystemFactory; - -import org.apache.iceberg.DataFile; -import org.apache.iceberg.DeleteFile; -import org.apache.iceberg.ManifestFile; -import org.apache.iceberg.io.BulkDeletionFailureException; -import org.apache.iceberg.io.InputFile; -import org.apache.iceberg.io.OutputFile; -import org.apache.iceberg.io.SupportsBulkOperations; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.util.Map; -import java.util.Objects; - -/** - * DelegateFileIO is an implementation of the Iceberg SupportsBulkOperations interface. - * It delegates file operations (such as input, output, and deletion) to the underlying Doris FileSystem. - * This class is responsible for bridging Doris file system operations with Iceberg's file IO abstraction. - */ -public class DelegateFileIO implements SupportsBulkOperations { - /** - * Properties used to initialize the file system. - */ - private Map properties; - /** - * The underlying SPI filesystem used for file operations. - */ - private FileSystem fileSystem; - - /** - * Default constructor. - */ - public DelegateFileIO() { - - } - - /** - * Constructor with a specified SPI FileSystem. - * - * @param fileSystem the SPI filesystem to delegate operations to - */ - public DelegateFileIO(FileSystem fileSystem) { - this.fileSystem = Objects.requireNonNull(fileSystem, "fileSystem is null"); - } - - // ===================== File Creation Methods ===================== - - /** - * Creates a new InputFile for the given path. - * - * @param path the file path - * @return an InputFile instance - */ - @Override - public InputFile newInputFile(String path) { - try { - return new DelegateInputFile(fileSystem.newInputFile(Location.of(path))); - } catch (IOException e) { - throw new UncheckedIOException("Failed to create InputFile for: " + path, e); - } - } - - /** - * Creates a new InputFile for the given path and length. - * - * @param path the file path - * @param length the file length - * @return an InputFile instance - */ - @Override - public InputFile newInputFile(String path, long length) { - try { - return new DelegateInputFile(fileSystem.newInputFile(Location.of(path), length)); - } catch (IOException e) { - throw new UncheckedIOException("Failed to create InputFile for: " + path, e); - } - } - - /** - * Creates a new OutputFile for the given path. - * - * @param path the file path - * @return an OutputFile instance - */ - @Override - public OutputFile newOutputFile(String path) { - try { - return new DelegateOutputFile(fileSystem, Location.of(path)); - } catch (IOException e) { - throw new UncheckedIOException("Failed to create OutputFile for: " + path, e); - } - } - - // ===================== File Deletion Methods ===================== - - /** - * Deletes a file at the specified path. - * Throws UncheckedIOException if deletion fails. - * - * @param path the file path to delete - */ - @Override - public void deleteFile(String path) { - try { - fileSystem.delete(Location.of(path), false); - } catch (IOException e) { - throw new UncheckedIOException("Failed to delete file: " + path, e); - } - } - - /** - * Deletes a file represented by an InputFile. - * Delegates to the default implementation in SupportsBulkOperations. - * - * @param file the InputFile to delete - */ - @Override - public void deleteFile(InputFile file) { - SupportsBulkOperations.super.deleteFile(file); - } - - /** - * Deletes a file represented by an OutputFile. - * Delegates to the default implementation in SupportsBulkOperations. - * - * @param file the OutputFile to delete - */ - @Override - public void deleteFile(OutputFile file) { - SupportsBulkOperations.super.deleteFile(file); - } - - /** - * Deletes multiple files in batches. - * Throws BulkDeletionFailureException if any batch fails. - * - * @param pathsToDelete iterable of file paths to delete - * @throws BulkDeletionFailureException if deletion fails for any batch - */ - @Override - public void deleteFiles(Iterable pathsToDelete) throws BulkDeletionFailureException { - for (String path : pathsToDelete) { - deleteFile(path); - } - } - - // ===================== Manifest/Data/Delete File Methods ===================== - - /** - * Creates a new InputFile from a ManifestFile. - * Delegates to the default implementation in SupportsBulkOperations. - * - * @param manifest the ManifestFile - * @return an InputFile instance - */ - @Override - public InputFile newInputFile(ManifestFile manifest) { - return SupportsBulkOperations.super.newInputFile(manifest); - } - - /** - * Creates a new InputFile from a DataFile. - * Delegates to the default implementation in SupportsBulkOperations. - * - * @param file the DataFile - * @return an InputFile instance - */ - @Override - public InputFile newInputFile(DataFile file) { - return SupportsBulkOperations.super.newInputFile(file); - } - - /** - * Creates a new InputFile from a DeleteFile. - * Delegates to the default implementation in SupportsBulkOperations. - * - * @param file the DeleteFile - * @return an InputFile instance - */ - @Override - public InputFile newInputFile(DeleteFile file) { - return SupportsBulkOperations.super.newInputFile(file); - } - - // ===================== Properties and Initialization ===================== - - /** - * Returns the properties used to initialize the file system. - * - * @return the properties map - */ - @Override - public Map properties() { - return properties; - } - - /** - * Initializes the file system with the given properties. - * - * @param properties the properties map - */ - @Override - public void initialize(Map properties) { - StorageProperties storageProperties = StorageProperties.createPrimary(properties); - try { - this.fileSystem = FileSystemFactory.getFileSystem(storageProperties); - } catch (IOException e) { - throw new UncheckedIOException("Failed to create FileSystem for Iceberg FileIO", e); - } - this.properties = properties; - } - - /** - * Closes the file IO and releases any resources if necessary. - * No-op in this implementation. - */ - @Override - public void close() { - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/fileio/DelegateInputFile.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/fileio/DelegateInputFile.java deleted file mode 100644 index ea5f36bb6e4a50..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/fileio/DelegateInputFile.java +++ /dev/null @@ -1,117 +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.datasource.iceberg.fileio; - -import org.apache.doris.filesystem.DorisInputFile; - -import org.apache.iceberg.exceptions.NotFoundException; -import org.apache.iceberg.io.InputFile; -import org.apache.iceberg.io.SeekableInputStream; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.util.Objects; - -/** - * DelegateInputFile is an implementation of the Iceberg InputFile interface. - * It wraps a DorisInputFile and delegates file input operations to it, providing - * integration between Doris file system and Iceberg's file IO abstraction. - */ -public class DelegateInputFile implements InputFile { - /** - * The underlying DorisInputFile used for file operations. - */ - private final DorisInputFile inputFile; - - /** - * Constructs a DelegateInputFile with the specified DorisInputFile. - * @param inputFile the DorisInputFile to delegate operations to - */ - public DelegateInputFile(DorisInputFile inputFile) { - this.inputFile = Objects.requireNonNull(inputFile, "inputFile is null"); - } - - // ===================== File Information Methods ===================== - - /** - * Returns the length of the file in bytes. - * Throws UncheckedIOException if the file status cannot be retrieved. - * @return the file length in bytes - */ - @Override - public long getLength() { - try { - return inputFile.length(); - } catch (IOException e) { - throw new UncheckedIOException("Failed to get status for file: " + location(), e); - } - } - - /** - * Returns the location (path) of the file as a string. - * @return the file location - */ - @Override - public String location() { - return inputFile.location().toString(); - } - - /** - * Checks if the file exists. - * Throws UncheckedIOException if the existence check fails. - * @return true if the file exists, false otherwise - */ - @Override - public boolean exists() { - try { - return inputFile.exists(); - } catch (IOException e) { - throw new UncheckedIOException("Failed to check existence for file: " + location(), e); - } - } - - // ===================== File Stream Methods ===================== - - /** - * Opens a new SeekableInputStream for reading the file. - * Throws NotFoundException if the file is not found, or UncheckedIOException for other IO errors. - * @return a SeekableInputStream for the file - */ - @Override - public SeekableInputStream newStream() { - try { - return new DelegateSeekableInputStream(inputFile.newStream()); - } catch (FileNotFoundException e) { - throw new NotFoundException(e, "Failed to open input stream for file: %s", location()); - } catch (IOException e) { - throw new UncheckedIOException("Failed to open input stream for file: " + location(), e); - } - } - - // ===================== Object Methods ===================== - - /** - * Returns a string representation of this DelegateInputFile. - * @return string representation - */ - @Override - public String toString() { - return inputFile.toString(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/fileio/DelegateOutputFile.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/fileio/DelegateOutputFile.java deleted file mode 100644 index ab3315d6e3f981..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/fileio/DelegateOutputFile.java +++ /dev/null @@ -1,197 +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.datasource.iceberg.fileio; - -import org.apache.doris.filesystem.DorisOutputFile; -import org.apache.doris.filesystem.FileSystem; -import org.apache.doris.filesystem.Location; - -import com.google.common.io.CountingOutputStream; -import org.apache.iceberg.io.InputFile; -import org.apache.iceberg.io.OutputFile; -import org.apache.iceberg.io.PositionOutputStream; - -import java.io.IOException; -import java.io.OutputStream; -import java.io.UncheckedIOException; -import java.util.Objects; - -/** - * DelegateOutputFile is an implementation of the Iceberg OutputFile interface. - * It wraps a DorisOutputFile and delegates file output operations to it, providing - * integration between Doris file system and Iceberg's file IO abstraction. - */ -public class DelegateOutputFile implements OutputFile { - /** The SPI filesystem used to create input files for {@link #toInputFile()}. */ - private final FileSystem fileSystem; - /** The underlying Doris output file. */ - private final DorisOutputFile outputFile; - - /** - * Constructs a DelegateOutputFile with the specified SPI FileSystem and Location. - * - * @param fileSystem the SPI filesystem to delegate operations to - * @param location the file location - */ - public DelegateOutputFile(FileSystem fileSystem, Location location) throws IOException { - this.fileSystem = Objects.requireNonNull(fileSystem, "fileSystem is null"); - this.outputFile = fileSystem.newOutputFile(location); - } - - // ===================== File Creation Methods ===================== - - /** - * Creates a new file for writing. Throws UncheckedIOException if creation fails. - * - * @return a PositionOutputStream for writing to the file - */ - @Override - public PositionOutputStream create() { - try { - return new CountingPositionOutputStream(outputFile.create()); - } catch (IOException e) { - throw new UncheckedIOException("Failed to create file: " + location(), e); - } - } - - /** - * Creates or overwrites a file for writing. Throws UncheckedIOException if creation fails. - * - * @return a PositionOutputStream for writing to the file - */ - @Override - public PositionOutputStream createOrOverwrite() { - try { - return new CountingPositionOutputStream(outputFile.createOrOverwrite()); - } catch (IOException e) { - throw new UncheckedIOException("Failed to create file: " + location(), e); - } - } - - // ===================== File Information Methods ===================== - - /** - * Returns the location (path) of the file as a string. - * - * @return the file location - */ - @Override - public String location() { - return outputFile.location().toString(); - } - - /** - * Converts this output file to an InputFile for reading. - * - * @return an InputFile instance for the same file - */ - @Override - public InputFile toInputFile() { - try { - return new DelegateInputFile(fileSystem.newInputFile(outputFile.location())); - } catch (IOException e) { - throw new UncheckedIOException("Failed to create InputFile for: " + location(), e); - } - } - - // ===================== Object Methods ===================== - - /** - * Returns a string representation of this DelegateOutputFile. - * - * @return string representation - */ - @Override - public String toString() { - return outputFile.toString(); - } - - /** - * CountingPositionOutputStream is a wrapper around OutputStream that tracks the number of bytes written. - * It extends PositionOutputStream to provide position tracking for Iceberg. - */ - private static class CountingPositionOutputStream extends PositionOutputStream { - /** - * The underlying CountingOutputStream that wraps the actual OutputStream. - */ - private final CountingOutputStream stream; - - /** - * Constructs a CountingPositionOutputStream with the specified OutputStream. - * - * @param stream the OutputStream to wrap - */ - private CountingPositionOutputStream(OutputStream stream) { - this.stream = new CountingOutputStream(stream); - } - - /** - * Returns the current position (number of bytes written). - * - * @return the number of bytes written - */ - @Override - public long getPos() { - return stream.getCount(); - } - - /** - * Writes a single byte to the output stream. - * - * @param b the byte to write - * @throws IOException if an I/O error occurs - */ - @Override - public void write(int b) throws IOException { - stream.write(b); - } - - /** - * Writes a portion of a byte array to the output stream. - * - * @param b the byte array - * @param off the start offset - * @param len the number of bytes to write - * @throws IOException if an I/O error occurs - */ - @Override - public void write(byte[] b, int off, int len) throws IOException { - stream.write(b, off, len); - } - - /** - * Flushes the output stream. - * - * @throws IOException if an I/O error occurs - */ - @Override - public void flush() throws IOException { - stream.flush(); - } - - /** - * Closes the output stream. - * - * @throws IOException if an I/O error occurs - */ - @Override - public void close() throws IOException { - stream.close(); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/fileio/DelegateSeekableInputStream.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/fileio/DelegateSeekableInputStream.java deleted file mode 100644 index b44ac1b1c8057b..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/fileio/DelegateSeekableInputStream.java +++ /dev/null @@ -1,164 +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.datasource.iceberg.fileio; - -import org.apache.doris.filesystem.DorisInputStream; - -import org.apache.iceberg.io.SeekableInputStream; - -import java.io.IOException; -import java.util.Objects; - -/** - * DelegateSeekableInputStream is an implementation of Iceberg's SeekableInputStream. - * It wraps a DorisInputStream and delegates all stream and seek operations to it, - * providing integration between Doris file system and Iceberg's seekable input abstraction. - */ -public class DelegateSeekableInputStream extends SeekableInputStream { - /** - * The underlying DorisInputStream used for all operations. - */ - private final DorisInputStream stream; - - /** - * Constructs a DelegateSeekableInputStream with the specified DorisInputStream. - * @param stream the DorisInputStream to delegate operations to - */ - public DelegateSeekableInputStream(DorisInputStream stream) { - this.stream = Objects.requireNonNull(stream, "stream is null"); - } - - // ===================== Position and Seek Methods ===================== - - /** - * Returns the current position in the stream. - * @return the current byte position - * @throws IOException if an I/O error occurs - */ - @Override - public long getPos() throws IOException { - return stream.getPos(); - } - - /** - * Seeks to the specified position in the stream. - * @param pos the position to seek to - * @throws IOException if an I/O error occurs - */ - @Override - public void seek(long pos) throws IOException { - stream.seek(pos); - } - - // ===================== Read Methods ===================== - - /** - * Reads a single byte from the stream. - * @return the byte read, or -1 if end of stream - * @throws IOException if an I/O error occurs - */ - @Override - public int read() throws IOException { - return stream.read(); - } - - /** - * Reads bytes into the specified array. - * @param b the buffer into which the data is read - * @return the number of bytes read, or -1 if end of stream - * @throws IOException if an I/O error occurs - */ - @Override - public int read(byte[] b) throws IOException { - return stream.read(b); - } - - /** - * Reads up to len bytes of data from the stream into an array of bytes. - * @param b the buffer into which the data is read - * @param off the start offset in array b at which the data is written - * @param len the maximum number of bytes to read - * @return the number of bytes read, or -1 if end of stream - * @throws IOException if an I/O error occurs - */ - @Override - public int read(byte[] b, int off, int len) throws IOException { - return stream.read(b, off, len); - } - - // ===================== Skip and Availability Methods ===================== - - /** - * Skips over and discards n bytes of data from the stream. - * @param n the number of bytes to skip - * @return the actual number of bytes skipped - * @throws IOException if an I/O error occurs - */ - @Override - public long skip(long n) throws IOException { - return stream.skip(n); - } - - /** - * Returns an estimate of the number of bytes that can be read from the stream. - * @return the number of bytes that can be read - * @throws IOException if an I/O error occurs - */ - @Override - public int available() throws IOException { - return stream.available(); - } - - // ===================== Mark, Reset, and Close Methods ===================== - - /** - * Closes the stream and releases any system resources associated with it. - * @throws IOException if an I/O error occurs - */ - @Override - public void close() throws IOException { - stream.close(); - } - - /** - * Marks the current position in the stream. - * @param readlimit the maximum limit of bytes that can be read before the mark position becomes invalid - */ - @Override - public void mark(int readlimit) { - stream.mark(readlimit); - } - - /** - * Resets the stream to the most recent mark. - * @throws IOException if the stream has not been marked or the mark has been invalidated - */ - @Override - public void reset() throws IOException { - stream.reset(); - } - - /** - * Tests if this input stream supports the mark and reset methods. - * @return true if mark and reset are supported; false otherwise - */ - @Override - public boolean markSupported() { - return stream.markSupported(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/helper/IcebergRewritableDeletePlan.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/helper/IcebergRewritableDeletePlan.java deleted file mode 100644 index 92aec74da11dcb..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/helper/IcebergRewritableDeletePlan.java +++ /dev/null @@ -1,54 +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.datasource.iceberg.helper; - -import org.apache.doris.thrift.TIcebergRewritableDeleteFileSet; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import org.apache.iceberg.DeleteFile; - -import java.util.List; -import java.util.Map; - -public final class IcebergRewritableDeletePlan { - private static final IcebergRewritableDeletePlan EMPTY = - new IcebergRewritableDeletePlan(ImmutableList.of(), ImmutableMap.of()); - - private final List thriftDeleteFileSets; - private final Map> deleteFilesByReferencedDataFile; - - public IcebergRewritableDeletePlan( - List thriftDeleteFileSets, - Map> deleteFilesByReferencedDataFile) { - this.thriftDeleteFileSets = thriftDeleteFileSets; - this.deleteFilesByReferencedDataFile = deleteFilesByReferencedDataFile; - } - - public static IcebergRewritableDeletePlan empty() { - return EMPTY; - } - - public List getThriftDeleteFileSets() { - return thriftDeleteFileSets; - } - - public Map> getDeleteFilesByReferencedDataFile() { - return deleteFilesByReferencedDataFile; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/helper/IcebergRewritableDeletePlanner.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/helper/IcebergRewritableDeletePlanner.java deleted file mode 100644 index b1a5867c1ceb9b..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/helper/IcebergRewritableDeletePlanner.java +++ /dev/null @@ -1,87 +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.datasource.iceberg.helper; - -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergUtils; -import org.apache.doris.datasource.iceberg.source.IcebergScanNode; -import org.apache.doris.nereids.NereidsPlanner; -import org.apache.doris.planner.ScanNode; -import org.apache.doris.thrift.TIcebergRewritableDeleteFileSet; - -import org.apache.iceberg.DeleteFile; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -public final class IcebergRewritableDeletePlanner { - private static final int ICEBERG_DELETION_VECTOR_MIN_VERSION = 3; - - private IcebergRewritableDeletePlanner() { - } - - public static IcebergRewritableDeletePlan collectForDelete( - IcebergExternalTable table, NereidsPlanner planner) throws UserException { - return collect(table, planner); - } - - public static IcebergRewritableDeletePlan collectForMerge( - IcebergExternalTable table, NereidsPlanner planner) throws UserException { - return collect(table, planner); - } - - private static IcebergRewritableDeletePlan collect( - IcebergExternalTable table, NereidsPlanner planner) { - if (table == null - || planner == null - || IcebergUtils.getFormatVersion(table.getIcebergTable()) < ICEBERG_DELETION_VECTOR_MIN_VERSION) { - return IcebergRewritableDeletePlan.empty(); - } - - List thriftDeleteFileSets = new ArrayList<>(); - Map> deleteFilesByReferencedDataFile = new LinkedHashMap<>(); - - for (ScanNode scanNode : planner.getScanNodes()) { - if (!(scanNode instanceof IcebergScanNode)) { - continue; - } - IcebergScanNode icebergScanNode = (IcebergScanNode) scanNode; - - deleteFilesByReferencedDataFile.putAll(icebergScanNode.deleteFilesByReferencedDataFile); - icebergScanNode.deleteFilesDescByReferencedDataFile.forEach( - (key, value) -> { - TIcebergRewritableDeleteFileSet deleteFileSet = new TIcebergRewritableDeleteFileSet(); - deleteFileSet.setReferencedDataFilePath(key); - deleteFileSet.setDeleteFiles(value); - thriftDeleteFileSets.add(deleteFileSet); - } - ); - } - - if (thriftDeleteFileSets.isEmpty()) { - return IcebergRewritableDeletePlan.empty(); - } - return new IcebergRewritableDeletePlan( - Collections.unmodifiableList(thriftDeleteFileSets), - Collections.unmodifiableMap(deleteFilesByReferencedDataFile)); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/helper/IcebergWriterHelper.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/helper/IcebergWriterHelper.java deleted file mode 100644 index b67a5911b64384..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/helper/IcebergWriterHelper.java +++ /dev/null @@ -1,270 +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.datasource.iceberg.helper; - -import org.apache.doris.datasource.iceberg.IcebergUtils; -import org.apache.doris.datasource.statistics.CommonStatistics; -import org.apache.doris.thrift.TFileContent; -import org.apache.doris.thrift.TIcebergColumnStats; -import org.apache.doris.thrift.TIcebergCommitData; - -import com.google.common.base.VerifyException; -import org.apache.iceberg.DataFile; -import org.apache.iceberg.DataFiles; -import org.apache.iceberg.DeleteFile; -import org.apache.iceberg.FileFormat; -import org.apache.iceberg.FileMetadata; -import org.apache.iceberg.Metrics; -import org.apache.iceberg.PartitionData; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.SortOrder; -import org.apache.iceberg.Table; -import org.apache.iceberg.io.WriteResult; -import org.apache.iceberg.types.Types; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; - -public class IcebergWriterHelper { - private static final Logger LOG = LogManager.getLogger(IcebergWriterHelper.class); - - private static final int DEFAULT_FILE_COUNT = 1; - - public static WriteResult convertToWriterResult( - Table table, - List commitDataList) { - List dataFiles = new ArrayList<>(); - - // Get table specification information - PartitionSpec spec = table.spec(); - FileFormat fileFormat = IcebergUtils.getFileFormat(table); - - for (TIcebergCommitData commitData : commitDataList) { - //get the files path - String location = commitData.getFilePath(); - - //get the commit file statistics - long fileSize = commitData.getFileSize(); - long recordCount = commitData.getRowCount(); - CommonStatistics stat = new CommonStatistics(recordCount, DEFAULT_FILE_COUNT, fileSize); - Metrics metrics = buildDataFileMetrics(table, fileFormat, commitData); - Optional partitionData = Optional.empty(); - //get and check partitionValues when table is partitionedTable - if (spec.isPartitioned()) { - List partitionValues = commitData.getPartitionValues(); - if (Objects.isNull(partitionValues) || partitionValues.isEmpty()) { - throw new VerifyException("No partition data for partitioned table"); - } - partitionValues = partitionValues.stream().map(s -> s.equals("null") ? null : s) - .collect(Collectors.toList()); - - // Convert human-readable partition values to PartitionData - partitionData = Optional.of(convertToPartitionData(partitionValues, spec)); - } - DataFile dataFile = genDataFile(fileFormat, location, spec, partitionData, stat, metrics, - table.sortOrder()); - dataFiles.add(dataFile); - } - return WriteResult.builder() - .addDataFiles(dataFiles) - .build(); - - } - - public static DataFile genDataFile( - FileFormat format, - String location, - PartitionSpec spec, - Optional partitionData, - CommonStatistics statistics, Metrics metrics, SortOrder sortOrder) { - - DataFiles.Builder builder = DataFiles.builder(spec) - .withPath(location) - .withFileSizeInBytes(statistics.getTotalFileBytes()) - .withRecordCount(statistics.getRowCount()) - .withMetrics(metrics) - .withSortOrder(sortOrder) - .withFormat(format); - - partitionData.ifPresent(builder::withPartition); - - return builder.build(); - } - - /** - * Convert human-readable partition values (from Backend) to PartitionData. - * - * Backend sends partition values as human-readable strings: - * - DATE: "2025-01-25" - * - DATETIME: "2025-01-25 10:00:00" - */ - private static PartitionData convertToPartitionData( - List humanReadableValues, PartitionSpec spec) { - // Create PartitionData instance using the partition type from spec - PartitionData partitionData = new PartitionData(spec.partitionType()); - - // Get partition type fields to determine the result type of each partition field - Types.StructType partitionType = spec.partitionType(); - List partitionTypeFields = partitionType.fields(); - - for (int i = 0; i < humanReadableValues.size(); i++) { - String humanReadableValue = humanReadableValues.get(i); - - if (humanReadableValue == null) { - partitionData.set(i, null); - continue; - } - - // Get the partition field's result type - Types.NestedField partitionTypeField = partitionTypeFields.get(i); - org.apache.iceberg.types.Type partitionFieldType = partitionTypeField.type(); - - // Convert the human-readable value to internal format object - Object internalValue = IcebergUtils.parsePartitionValueFromString( - humanReadableValue, partitionFieldType); - - // Set the value in PartitionData - partitionData.set(i, internalValue); - } - - return partitionData; - } - - private static Metrics buildDataFileMetrics(Table table, FileFormat fileFormat, TIcebergCommitData commitData) { - Map columnSizes = new HashMap<>(); - Map valueCounts = new HashMap<>(); - Map nullValueCounts = new HashMap<>(); - Map lowerBounds = new HashMap<>(); - Map upperBounds = new HashMap<>(); - if (commitData.isSetColumnStats()) { - TIcebergColumnStats stats = commitData.column_stats; - if (stats.isSetColumnSizes()) { - columnSizes = stats.column_sizes; - } - if (stats.isSetValueCounts()) { - valueCounts = stats.value_counts; - } - if (stats.isSetNullValueCounts()) { - nullValueCounts = stats.null_value_counts; - } - if (stats.isSetLowerBounds()) { - lowerBounds = stats.lower_bounds; - } - if (stats.isSetUpperBounds()) { - upperBounds = stats.upper_bounds; - } - } - - return new Metrics(commitData.getRowCount(), columnSizes, valueCounts, - nullValueCounts, null, lowerBounds, upperBounds); - } - - /** - * Convert TIcebergCommitData list to DeleteFile list for delete operations. - * - * @param format File format (Parquet/ORC) - * @param spec Partition specification - * @param commitDataList List of commit data from BE - * @return List of DeleteFile objects ready to be committed - */ - public static List convertToDeleteFiles( - FileFormat format, - PartitionSpec spec, - List commitDataList) { - List deleteFiles = new ArrayList<>(); - - for (TIcebergCommitData commitData : commitDataList) { - // Only process delete files - if (commitData.getFileContent() == null - || commitData.getFileContent() == TFileContent.DATA) { - continue; - } - - String deleteFilePath = commitData.getFilePath(); - long fileSize = commitData.getFileSize(); - long recordCount = commitData.getRowCount(); - boolean isDeletionVector = commitData.isSetContentOffset() - && commitData.isSetContentSizeInBytes(); - FileFormat effectiveFormat = isDeletionVector ? FileFormat.PUFFIN : format; - - // Build delete file metadata - FileMetadata.Builder deleteBuilder = FileMetadata.deleteFileBuilder(spec) - .withPath(deleteFilePath) - .withFormat(effectiveFormat) - .withFileSizeInBytes(fileSize) - .withRecordCount(recordCount); - - // Set delete file content type - if (commitData.getFileContent() == TFileContent.POSITION_DELETES) { - deleteBuilder.ofPositionDeletes(); - } else if (commitData.getFileContent() == TFileContent.DELETION_VECTOR) { - deleteBuilder.ofPositionDeletes(); - } else { - throw new VerifyException("Iceberg delete only supports position deletes, but got " - + commitData.getFileContent()); - } - - if (isDeletionVector) { - deleteBuilder.withContentOffset(commitData.getContentOffset()); - deleteBuilder.withContentSizeInBytes(commitData.getContentSizeInBytes()); - } - - if (commitData.isSetReferencedDataFilePath() - && commitData.getReferencedDataFilePath() != null - && !commitData.getReferencedDataFilePath().isEmpty()) { - deleteBuilder.withReferencedDataFile(commitData.getReferencedDataFilePath()); - } - - // Add partition information if table is partitioned - if (spec.isPartitioned()) { - PartitionData partitionData; - if (commitData.getPartitionValues() != null && !commitData.getPartitionValues().isEmpty()) { - // Convert partition values to PartitionData - List partitionValues = commitData.getPartitionValues().stream() - .map(s -> s.equals("null") ? null : s) - .collect(Collectors.toList()); - partitionData = convertToPartitionData(partitionValues, spec); - } else if (commitData.getPartitionDataJson() != null && !commitData.getPartitionDataJson().isEmpty()) { - List partitionValues = IcebergUtils.parsePartitionValuesFromJson( - commitData.getPartitionDataJson()); - if (!partitionValues.isEmpty()) { - partitionData = convertToPartitionData(partitionValues, spec); - } else { - partitionData = new PartitionData(spec.partitionType()); - } - } else { - throw new VerifyException("No partition data for partitioned table"); - } - deleteBuilder.withPartition(partitionData); - } - - DeleteFile deleteFile = deleteBuilder.build(); - deleteFiles.add(deleteFile); - } - - return deleteFiles; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/profile/IcebergMetricsReporter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/profile/IcebergMetricsReporter.java deleted file mode 100644 index 47629424226391..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/profile/IcebergMetricsReporter.java +++ /dev/null @@ -1,167 +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.datasource.iceberg.profile; - -import org.apache.doris.common.profile.RuntimeProfile; -import org.apache.doris.common.profile.SummaryProfile; -import org.apache.doris.common.util.DebugUtil; -import org.apache.doris.qe.ConnectContext; - -import com.google.common.base.Joiner; -import com.google.common.base.Strings; -import com.google.common.collect.ImmutableList; -import org.apache.iceberg.metrics.CounterResult; -import org.apache.iceberg.metrics.MetricsContext; -import org.apache.iceberg.metrics.MetricsReport; -import org.apache.iceberg.metrics.MetricsReporter; -import org.apache.iceberg.metrics.ScanMetricsResult; -import org.apache.iceberg.metrics.ScanReport; -import org.apache.iceberg.metrics.TimerResult; - -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.regex.Pattern; - -/** - * MetricsReporter implementation that forwards Iceberg scan metrics into Doris - * profiles. - */ -public class IcebergMetricsReporter implements MetricsReporter { - - private static final Pattern WHITESPACE = Pattern.compile("\\s+"); - - @Override - public void report(MetricsReport report) { - if (!(report instanceof ScanReport)) { - return; - } - - SummaryProfile summaryProfile = SummaryProfile.getSummaryProfile(ConnectContext.get()); - if (summaryProfile == null) { - return; - } - - RuntimeProfile executionSummary = summaryProfile.getExecutionSummary(); - if (executionSummary == null) { - return; - } - - ScanReport scanReport = (ScanReport) report; - ScanMetricsResult metrics = scanReport.scanMetrics(); - if (metrics == null) { - return; - } - - RuntimeProfile icebergGroup = executionSummary.getChildMap().get(SummaryProfile.ICEBERG_SCAN_METRICS); - if (icebergGroup == null) { - icebergGroup = new RuntimeProfile(SummaryProfile.ICEBERG_SCAN_METRICS); - executionSummary.addChild(icebergGroup, true); - } - - RuntimeProfile scanProfile = new RuntimeProfile(buildScanProfileName(scanReport)); - appendScanDetails(scanProfile, scanReport, metrics); - icebergGroup.addChild(scanProfile, true); - } - - private String sanitize(String value) { - if (Strings.isNullOrEmpty(value)) { - return ""; - } - return WHITESPACE.matcher(value).replaceAll(" ").trim(); - } - - private String buildScanProfileName(ScanReport report) { - return "Table Scan (" + report.tableName() + ")"; - } - - private void appendScanDetails(RuntimeProfile scanProfile, ScanReport report, ScanMetricsResult metrics) { - scanProfile.addInfoString("table", report.tableName()); - scanProfile.addInfoString("snapshot", String.valueOf(report.snapshotId())); - String filter = sanitize(report.filter() == null ? null : report.filter().toString()); - if (!Strings.isNullOrEmpty(filter)) { - scanProfile.addInfoString("filter", filter); - } - if (!report.projectedFieldNames().isEmpty()) { - scanProfile.addInfoString("columns", Joiner.on('|').join(report.projectedFieldNames())); - } - - appendTimer(scanProfile, "planning", metrics.totalPlanningDuration()); - appendCounter(scanProfile, "data_files", metrics.resultDataFiles()); - appendCounter(scanProfile, "delete_files", metrics.resultDeleteFiles()); - appendCounter(scanProfile, "skipped_data_files", metrics.skippedDataFiles()); - appendCounter(scanProfile, "skipped_delete_files", metrics.skippedDeleteFiles()); - appendCounter(scanProfile, "total_size", metrics.totalFileSizeInBytes()); - appendCounter(scanProfile, "total_delete_size", metrics.totalDeleteFileSizeInBytes()); - appendCounter(scanProfile, "scanned_manifests", metrics.scannedDataManifests()); - appendCounter(scanProfile, "skipped_manifests", metrics.skippedDataManifests()); - appendCounter(scanProfile, "scanned_delete_manifests", metrics.scannedDeleteManifests()); - appendCounter(scanProfile, "skipped_delete_manifests", metrics.skippedDeleteManifests()); - appendCounter(scanProfile, "indexed_delete_files", metrics.indexedDeleteFiles()); - appendCounter(scanProfile, "equality_delete_files", metrics.equalityDeleteFiles()); - appendCounter(scanProfile, "positional_delete_files", metrics.positionalDeleteFiles()); - - appendMetadata(scanProfile, report.metadata()); - } - - private void appendMetadata(RuntimeProfile scanProfile, Map metadata) { - if (metadata == null || metadata.isEmpty()) { - return; - } - List importantKeys = ImmutableList.of("scan-state", "scan-id"); - List captured = new ArrayList<>(); - for (String key : importantKeys) { - if (metadata.containsKey(key)) { - captured.add(key + "=" + metadata.get(key)); - } - } - if (!captured.isEmpty()) { - scanProfile.addInfoString("metadata", "{" + String.join(", ", captured) + "}"); - } - } - - private void appendTimer(RuntimeProfile scanProfile, String name, TimerResult timerResult) { - if (timerResult == null) { - return; - } - scanProfile.addInfoString(name, formatTimer(timerResult)); - } - - private void appendCounter(RuntimeProfile scanProfile, String name, CounterResult counterResult) { - if (counterResult == null) { - return; - } - scanProfile.addInfoString(name, formatCounter(counterResult)); - } - - private String formatCounter(CounterResult counterResult) { - long value = counterResult.value(); - if (counterResult.unit() == MetricsContext.Unit.BYTES) { - return DebugUtil.printByteWithUnit(value); - } - return Long.toString(value); - } - - private String formatTimer(TimerResult timerResult) { - Duration duration = timerResult.totalDuration(); - long millis = duration.toMillis(); - String pretty = DebugUtil.getPrettyStringMs(millis); - return pretty + " (" + timerResult.count() + " ops)"; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteDataFileExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteDataFileExecutor.java deleted file mode 100644 index 74afd0052b4721..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteDataFileExecutor.java +++ /dev/null @@ -1,233 +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.datasource.iceberg.rewrite; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergTransaction; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.resource.computegroup.ComputeGroup; -import org.apache.doris.scheduler.exception.JobException; -import org.apache.doris.scheduler.executor.TransientTaskExecutor; -import org.apache.doris.system.Backend; - -import com.google.common.collect.Lists; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * Executes INSERT-SELECT statements for Iceberg data file rewriting. - */ -public class RewriteDataFileExecutor { - private static final Logger LOG = LogManager.getLogger(RewriteDataFileExecutor.class); - - private final IcebergExternalTable dorisTable; - private final ConnectContext connectContext; - - public RewriteDataFileExecutor(IcebergExternalTable dorisTable, - ConnectContext connectContext) { - this.dorisTable = dorisTable; - this.connectContext = connectContext; - } - - /** - * Execute rewrite for multiple groups concurrently - */ - public RewriteResult executeGroupsConcurrently(List groups, long targetFileSizeBytes) - throws UserException { - // Begin transaction - long transactionId = dorisTable.getCatalog().getTransactionManager().begin(); - IcebergTransaction transaction = (IcebergTransaction) dorisTable.getCatalog().getTransactionManager() - .getTransaction(transactionId); - transaction.beginRewrite(dorisTable); - - // Register files to delete - for (RewriteDataGroup group : groups) { - transaction.updateRewriteFiles(Lists.newArrayList(group.getDataFiles())); - } - - // Create result collector and tasks - List tasks = Lists.newArrayList(); - RewriteResultCollector resultCollector = new RewriteResultCollector(groups.size(), tasks); - - // Get available BE count once before creating tasks - // This avoids calling getBackendsNumber() in each task during multi-threaded execution. - // Use compute group from connect context to align with actual BE selection for queries. - int availableBeCount = getAvailableBeCount(); - - // Create tasks with callbacks - for (RewriteDataGroup group : groups) { - RewriteGroupTask task = new RewriteGroupTask( - group, - transactionId, - dorisTable, - connectContext, - targetFileSizeBytes, - availableBeCount, - new RewriteGroupTask.RewriteResultCallback() { - @Override - public void onTaskCompleted(Long taskId) { - resultCollector.onTaskCompleted(taskId); - } - - @Override - public void onTaskFailed(Long taskId, Exception error) { - resultCollector.onTaskFailed(taskId, error); - } - }); - tasks.add(task); - } - - // Submit tasks to TransientTaskManager - try { - for (TransientTaskExecutor task : tasks) { - Env.getCurrentEnv().getTransientTaskManager().addMemoryTask(task); - } - } catch (JobException e) { - throw new UserException("Failed to submit rewrite tasks: " + e.getMessage(), e); - } - - // Wait for all tasks to complete - waitForTasksCompletion(resultCollector, groups.size()); - - // Finish rewrite operation - transaction.finishRewrite(); - - // Collect statistics from transaction after all tasks are completed - int rewrittenDataFilesCount = groups.stream().mapToInt(group -> group.getDataFiles().size()).sum(); - // this should after finishRewrite - int addedDataFilesCount = transaction.getFilesToAddCount(); - long rewrittenBytesCount = groups.stream().mapToLong(group -> group.getTotalSize()).sum(); - int removedDeleteFilesCount = groups.stream().mapToInt(group -> group.getDeleteFileCount()).sum(); - - // Commit transaction - transaction.commit(); - - return new RewriteResult(rewrittenDataFilesCount, addedDataFilesCount, - rewrittenBytesCount, removedDeleteFilesCount); - } - - /** - * Wait for all tasks to complete using notification mechanism - */ - private void waitForTasksCompletion(RewriteResultCollector collector, int totalTasks) - throws UserException { - LOG.info("Waiting for {} rewrite tasks to complete using notification mechanism", totalTasks); - - int maxWaitTime = connectContext.getSessionVariable().getInsertTimeoutS(); - - try { - boolean completed = collector.await(maxWaitTime, TimeUnit.SECONDS); - - if (!completed) { - LOG.warn("Rewrite tasks did not complete within timeout of {} seconds", maxWaitTime); - throw new UserException("Rewrite tasks did not complete within timeout"); - } - - // Check if any task failed - if (collector.getFirstError() != null) { - LOG.warn("Rewrite tasks failed: {}", collector.getFirstError().getMessage()); - throw new UserException("Some rewrite tasks failed: " + collector.getFirstError().getMessage(), - collector.getFirstError()); - } - - LOG.info("All {} rewrite tasks completed successfully", totalTasks); - - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - LOG.warn("Wait for tasks completion was interrupted", e); - throw new UserException("Wait for tasks completion was interrupted", e); - } - } - - private int getAvailableBeCount() throws UserException { - ComputeGroup computeGroup = connectContext.getComputeGroup(); - List backends = computeGroup.getBackendList(); - int availableBeCount = 0; - for (Backend backend : backends) { - if (backend.isAlive()) { - availableBeCount++; - } - } - return availableBeCount; - } - - /** - * Result collector for concurrent rewrite tasks - */ - private static class RewriteResultCollector { - private final int expectedTasks; - private final AtomicInteger completedTasks = new AtomicInteger(0); - private final AtomicInteger failedTasks = new AtomicInteger(0); - private volatile Exception firstError = null; - private final CountDownLatch completionLatch; - private final List allTasks; - - public RewriteResultCollector(int expectedTasks, List tasks) { - this.expectedTasks = expectedTasks; - this.completionLatch = new CountDownLatch(expectedTasks); - this.allTasks = tasks; - } - - public synchronized void onTaskCompleted(Long taskId) { - int completed = completedTasks.incrementAndGet(); - LOG.info("Task {} completed ({}/{})", taskId, completed, expectedTasks); - completionLatch.countDown(); - } - - public synchronized void onTaskFailed(Long taskId, Exception error) { - int failed = failedTasks.incrementAndGet(); - if (firstError == null) { - firstError = error; - - // Cancel all other tasks immediately when first failure occurs - LOG.warn("Task {} failed, cancelling all other tasks", taskId); - cancelAllOtherTasks(taskId); - } - LOG.warn("Task {} failed ({}/{}): {}", taskId, failed, expectedTasks, error.getMessage()); - completionLatch.countDown(); - } - - private void cancelAllOtherTasks(Long failedTaskId) { - for (RewriteGroupTask task : allTasks) { - if (!task.getId().equals(failedTaskId)) { - try { - task.cancel(); - LOG.info("Cancelled task {}", task.getId()); - } catch (Exception e) { - LOG.warn("Failed to cancel task {}: {}", task.getId(), e.getMessage()); - } - } - } - } - - public boolean await(long timeout, TimeUnit unit) throws InterruptedException { - return completionLatch.await(timeout, unit); - } - - public Exception getFirstError() { - return firstError; - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteDataFilePlanner.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteDataFilePlanner.java deleted file mode 100644 index 42a969f56251ff..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteDataFilePlanner.java +++ /dev/null @@ -1,362 +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.datasource.iceberg.rewrite; - -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.iceberg.IcebergNereidsUtils; -import org.apache.doris.nereids.trees.expressions.Expression; - -import com.google.common.collect.Iterables; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import org.apache.iceberg.ContentFile; -import org.apache.iceberg.FileScanTask; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Table; -import org.apache.iceberg.TableScan; -import org.apache.iceberg.data.GenericRecord; -import org.apache.iceberg.util.BinPacking; -import org.apache.iceberg.util.ContentFileUtil; -import org.apache.iceberg.util.StructLikeWrapper; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -/** - * Planner for organizing and filtering file scan tasks into rewrite groups. - */ -public class RewriteDataFilePlanner { - private static final Logger LOG = LogManager.getLogger(RewriteDataFilePlanner.class); - - private final Parameters parameters; - - public RewriteDataFilePlanner(Parameters parameters) { - this.parameters = parameters; - } - - /** - * Plan and organize file scan tasks into rewrite groups - */ - public List planAndOrganizeTasks(Table icebergTable) throws UserException { - try { - // Step 1: Plan FileScanTask from Iceberg table - Iterable allTasks = planFileScanTasks(icebergTable); - - // Step 2: First layer - Group tasks by partition (without filtering files) - Map> filesByPartition = groupTasksByPartition(allTasks); - - // Step 3: Apply binPack grouping strategy within each partition and convert to - // RewriteDataGroup - Map> fileGroupsByPartition = Maps.transformValues( - filesByPartition, this::packGroupsInPartition); - - // Step 4: Flatten all groups from all partitions - return fileGroupsByPartition.values().stream() - .flatMap(List::stream) - .collect(Collectors.toList()); - } catch (Exception e) { - throw new UserException("Failed to plan file scan tasks: " + e.getMessage(), e); - } - } - - /** - * Plan FileScanTask from Iceberg table - */ - private Iterable planFileScanTasks(Table icebergTable) throws UserException { - // Create table scan with optional filters - TableScan tableScan = icebergTable.newScan(); - - // Use current snapshot if available - if (icebergTable.currentSnapshot() != null) { - tableScan = tableScan.useSnapshot(icebergTable.currentSnapshot().snapshotId()); - } - - // Apply WHERE condition if specified - if (parameters.hasWhereCondition()) { - org.apache.iceberg.expressions.Expression icebergExpression = IcebergNereidsUtils - .convertNereidsToIcebergExpression(parameters.getWhereCondition().get(), icebergTable.schema()); - tableScan = tableScan.filter(icebergExpression); - } - - // Ignore residuals to avoid reading data files unnecessarily - tableScan = tableScan.ignoreResiduals(); - - return tableScan.planFiles(); - } - - /** - * Filter files based on rewrite criteria - */ - private Iterable filterFiles(Iterable tasks) { - return Iterables.filter(tasks, this::shouldRewriteFile); - } - - /** - * Check if a file should be rewritten - */ - private boolean shouldRewriteFile(FileScanTask task) { - return outsideDesiredFileSizeRange(task) || tooManyDeletes(task) || tooHighDeleteRatio(task); - } - - /** - * Check if file is outside desired size range - */ - private boolean outsideDesiredFileSizeRange(FileScanTask task) { - long fileSize = task.file().fileSizeInBytes(); - return fileSize < parameters.getMinFileSizeBytes() || fileSize > parameters.getMaxFileSizeBytes(); - } - - /** - * Check if file has too many delete files - */ - private boolean tooManyDeletes(FileScanTask task) { - if (task.deletes() == null) { - return false; - } - return task.deletes().size() >= parameters.getDeleteFileThreshold(); - } - - /** - * Check if file has too high delete ratio - */ - private boolean tooHighDeleteRatio(FileScanTask task) { - if (task.deletes() == null || task.deletes().isEmpty()) { - return false; - } - - long recordCount = task.file().recordCount(); - if (recordCount == 0) { - return false; - } - - // Calculate known deleted record count (only file-scoped deletes) - long knownDeletedRecordCount = task.deletes().stream() - .filter(ContentFileUtil::isFileScoped) - .mapToLong(ContentFile::recordCount) - .sum(); - - // Calculate delete ratio - double deletedRecords = (double) Math.min(knownDeletedRecordCount, recordCount); - double deleteRatio = deletedRecords / recordCount; - - return deleteRatio >= parameters.getDeleteRatioThreshold(); - } - - /** - * Returns a map from partition to list of file scan tasks in that partition. - */ - private Map> groupTasksByPartition(Iterable allTasks) { - Map> filesByPartition = new HashMap<>(); - for (FileScanTask task : allTasks) { - PartitionSpec spec = task.spec(); - StructLikeWrapper partitionWrapper = StructLikeWrapper.forType(spec.partitionType()); - - // If a task uses an incompatible partition spec, treat it as un-partitioned - // by using an empty partition (all null values) - StructLikeWrapper partition; - if (task.file().specId() == spec.specId()) { - partition = partitionWrapper.copyFor(task.file().partition()); - } else { - // Use empty partition for incompatible spec - // Create an empty GenericRecord with all null values - org.apache.iceberg.StructLike emptyStruct = GenericRecord.create(spec.partitionType()); - partition = partitionWrapper.copyFor(emptyStruct); - } - - filesByPartition.computeIfAbsent(partition, k -> Lists.newArrayList()).add(task); - } - return filesByPartition; - } - - /** - * Pack files in a partition using bin-packing strategy. - *

    - * This method is used to group files in a partition using bin-packing strategy. - * It first filters files if not rewriteAll, then uses bin-packing to group - * files based on their size, and then converts the groups to RewriteDataGroup. - * Finally, it filters groups if not rewriteAll. - *

    - */ - private List packGroupsInPartition(List tasks) { - // Step 1: Filter files if not rewriteAll - Iterable filteredTasks = parameters.isRewriteAll() ? tasks : filterFiles(tasks); - - // Step 2: Use bin-packing to group files - BinPacking.ListPacker packer = new BinPacking.ListPacker<>( - parameters.getMaxFileGroupSizeBytes(), - 1, // lookback: number of bins to look back when packing - false // largestBinFirst: whether to prefer larger bins - ); - - // Pack files using file size as weight - List> groups = packer.pack(filteredTasks, task -> task.file().fileSizeInBytes()); - - // Step 3: Convert to RewriteDataGroup - List rewriteDataGroups = groups.stream() - .map(RewriteDataGroup::new) - .collect(Collectors.toList()); - - // Step 4: Filter groups if not rewriteAll - return parameters.isRewriteAll() ? rewriteDataGroups : filterFileGroups(rewriteDataGroups); - } - - /** - * Filter file groups based on rewrite parameters. - * Only groups that meet the rewrite criteria are kept. - */ - private List filterFileGroups(List groups) { - return groups.stream() - .filter(this::shouldRewriteFileGroup) - .collect(Collectors.toList()); - } - - /** - * Check if a file group should be rewritten based on parameters. - */ - private boolean shouldRewriteFileGroup(RewriteDataGroup group) { - return hasEnoughInputFiles(group) || hasEnoughContent(group) - || hasTooMuchContent(group) || hasDeleteIssues(group); - } - - /** - * Check if group has enough input files - */ - private boolean hasEnoughInputFiles(RewriteDataGroup group) { - return group.getTaskCount() > 1 && group.getTaskCount() >= parameters.getMinInputFiles(); - } - - /** - * Check if group has enough content - */ - private boolean hasEnoughContent(RewriteDataGroup group) { - return group.getTaskCount() > 1 && group.getTotalSize() > parameters.getTargetFileSizeBytes(); - } - - /** - * Check if group has too much content - */ - private boolean hasTooMuchContent(RewriteDataGroup group) { - return group.getTotalSize() > parameters.getMaxFileGroupSizeBytes(); - } - - /** - * Check if any file in the group has too many deletes or high delete ratio - */ - private boolean hasDeleteIssues(RewriteDataGroup group) { - return group.getTasks().stream() - .anyMatch(task -> tooManyDeletes(task) || tooHighDeleteRatio(task)); - } - - /** - * Parameters for Iceberg data file rewrite operation - */ - public static class Parameters { - private final long targetFileSizeBytes; - private final long minFileSizeBytes; - private final long maxFileSizeBytes; - private final int minInputFiles; - private final boolean rewriteAll; - private final long maxFileGroupSizeBytes; - private final int deleteFileThreshold; - private final double deleteRatioThreshold; - - private final Optional whereCondition; - - public Parameters( - long targetFileSizeBytes, - long minFileSizeBytes, - long maxFileSizeBytes, - int minInputFiles, - boolean rewriteAll, - long maxFileGroupSizeBytes, - int deleteFileThreshold, - double deleteRatioThreshold, - long outputSpecId, - Optional whereCondition) { - this.targetFileSizeBytes = targetFileSizeBytes; - this.minFileSizeBytes = minFileSizeBytes; - this.maxFileSizeBytes = maxFileSizeBytes; - this.minInputFiles = minInputFiles; - this.rewriteAll = rewriteAll; - this.maxFileGroupSizeBytes = maxFileGroupSizeBytes; - this.deleteFileThreshold = deleteFileThreshold; - this.deleteRatioThreshold = deleteRatioThreshold; - this.whereCondition = whereCondition; - } - - public long getTargetFileSizeBytes() { - return targetFileSizeBytes; - } - - public long getMinFileSizeBytes() { - return minFileSizeBytes; - } - - public long getMaxFileSizeBytes() { - return maxFileSizeBytes; - } - - public int getMinInputFiles() { - return minInputFiles; - } - - public boolean isRewriteAll() { - return rewriteAll; - } - - public long getMaxFileGroupSizeBytes() { - return maxFileGroupSizeBytes; - } - - public int getDeleteFileThreshold() { - return deleteFileThreshold; - } - - public double getDeleteRatioThreshold() { - return deleteRatioThreshold; - } - - public boolean hasWhereCondition() { - return whereCondition.isPresent(); - } - - public Optional getWhereCondition() { - return whereCondition; - } - - @Override - public String toString() { - return "RewriteDataFilesParameters{" - + ", targetFileSizeBytes=" + targetFileSizeBytes - + ", minFileSizeBytes=" + minFileSizeBytes - + ", maxFileSizeBytes=" + maxFileSizeBytes - + ", minInputFiles=" + minInputFiles - + ", rewriteAll=" + rewriteAll - + ", maxFileGroupSizeBytes=" + maxFileGroupSizeBytes - + ", deleteFileThreshold=" + deleteFileThreshold - + ", deleteRatioThreshold=" + deleteRatioThreshold - + ", hasWhereCondition=" + hasWhereCondition() - + '}'; - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteGroupTask.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteGroupTask.java deleted file mode 100644 index eee1a7eb60256b..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/rewrite/RewriteGroupTask.java +++ /dev/null @@ -1,339 +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.datasource.iceberg.rewrite; - -import org.apache.doris.analysis.StatementBase; -import org.apache.doris.catalog.Env; -import org.apache.doris.common.Status; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.nereids.StatementContext; -import org.apache.doris.nereids.analyzer.UnboundIcebergTableSink; -import org.apache.doris.nereids.analyzer.UnboundRelation; -import org.apache.doris.nereids.glue.LogicalPlanAdapter; -import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator; -import org.apache.doris.nereids.trees.plans.commands.info.DMLCommandType; -import org.apache.doris.nereids.trees.plans.commands.insert.AbstractInsertExecutor; -import org.apache.doris.nereids.trees.plans.commands.insert.IcebergRewriteExecutor; -import org.apache.doris.nereids.trees.plans.commands.insert.RewriteTableCommand; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.qe.OriginStatement; -import org.apache.doris.qe.StmtExecutor; -import org.apache.doris.qe.VariableMgr; -import org.apache.doris.scheduler.exception.JobException; -import org.apache.doris.scheduler.executor.TransientTaskExecutor; -import org.apache.doris.thrift.TStatusCode; -import org.apache.doris.thrift.TUniqueId; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * Independent task executor for processing a single rewrite group. - */ -public class RewriteGroupTask implements TransientTaskExecutor { - private static final Logger LOG = LogManager.getLogger(RewriteGroupTask.class); - - private final RewriteDataGroup group; - private final long transactionId; - private final IcebergExternalTable dorisTable; - private final ConnectContext connectContext; - private final long targetFileSizeBytes; - private final RewriteResultCallback resultCallback; - private final Long taskId; - private final AtomicBoolean isCanceled; - private final AtomicBoolean isFinished; - private final int availableBeCount; - - // for canceling the task - private StmtExecutor stmtExecutor; - - public RewriteGroupTask(RewriteDataGroup group, - long transactionId, - IcebergExternalTable dorisTable, - ConnectContext connectContext, - long targetFileSizeBytes, - int availableBeCount, - RewriteResultCallback resultCallback) { - this.group = group; - this.transactionId = transactionId; - this.dorisTable = dorisTable; - this.connectContext = connectContext; - this.targetFileSizeBytes = targetFileSizeBytes; - this.availableBeCount = availableBeCount; - this.resultCallback = resultCallback; - this.taskId = UUID.randomUUID().getMostSignificantBits(); - this.isCanceled = new AtomicBoolean(false); - this.isFinished = new AtomicBoolean(false); - } - - @Override - public Long getId() { - return taskId; - } - - @Override - public void execute() throws JobException { - LOG.debug("[Rewrite Task] taskId: {} starting execution for group with {} tasks", - taskId, group.getTaskCount()); - - if (isCanceled.get()) { - LOG.debug("[Rewrite Task] taskId: {} was already canceled before execution", taskId); - throw new JobException("Rewrite task has been canceled, task id: " + taskId); - } - - if (isFinished.get()) { - LOG.debug("[Rewrite Task] taskId: {} was already finished", taskId); - return; - } - - try { - // Step 1: Create and customize a new ConnectContext for this task - ConnectContext taskConnectContext = buildConnectContext(); - // Set target file size for Iceberg write - taskConnectContext.getSessionVariable().setIcebergWriteTargetFileSizeBytes(targetFileSizeBytes); - // Custom file scan tasks for rewrite operations - taskConnectContext.getStatementContext().setIcebergRewriteFileScanTasks(group.getTasks()); - - // Step 2: Build logical plan for this task - RewriteTableCommand taskLogicalPlan = buildRewriteLogicalPlan(); - LogicalPlanAdapter taskParsedStmt = new LogicalPlanAdapter( - taskLogicalPlan, - taskConnectContext.getStatementContext()); - taskParsedStmt.setOrigStmt(new OriginStatement(taskLogicalPlan.toString(), 0)); - - // Step 3: Execute the rewrite operation for this group - executeGroup(taskConnectContext, taskLogicalPlan, taskParsedStmt); - - // Notify result callback - if (resultCallback != null) { - resultCallback.onTaskCompleted(taskId); - } - - LOG.debug("[Rewrite Task] taskId: {} execution completed successfully", taskId); - - } catch (Exception e) { - LOG.warn("Failed to execute rewrite group: {}", e.getMessage(), e); - - // Notify error callback - if (resultCallback != null) { - resultCallback.onTaskFailed(taskId, e); - } - - throw new JobException("Rewrite group execution failed: " + e.getMessage(), e); - } finally { - isFinished.set(true); - } - } - - @Override - public void cancel() throws JobException { - if (isFinished.get()) { - LOG.debug("[Rewrite Task] taskId: {} already finished, cannot cancel", taskId); - return; - } - - isCanceled.set(true); - if (stmtExecutor != null) { - stmtExecutor.cancel(new Status(TStatusCode.CANCELLED, "rewrite task cancelled")); - } - LOG.info("[Rewrite Task] taskId: {} cancelled", taskId); - } - - /** - * Execute rewrite group with task-specific logical plan and parsed statement - */ - private void executeGroup(ConnectContext taskConnectContext, - RewriteTableCommand taskLogicalPlan, - StatementBase taskParsedStmt) throws Exception { - // Step 1: Create stmt executor - stmtExecutor = new StmtExecutor(taskConnectContext, taskParsedStmt); - - // Step 2: Create insert executor - AbstractInsertExecutor insertExecutor = taskLogicalPlan.initPlan(taskConnectContext, stmtExecutor); - Preconditions.checkState(insertExecutor instanceof IcebergRewriteExecutor, - "Expected IcebergRewriteExecutor, got: " + insertExecutor.getClass()); - - // Step 3: Set transaction id for updating CommitData - insertExecutor.getCoordinator().setTxnId(transactionId); - - // Step 4: Execute insert operation - insertExecutor.executeSingleInsert(stmtExecutor); - - LOG.debug("[Rewrite Task] taskId: {} completed execution successfully", taskId); - } - - /** - * Build logical plan for rewrite operation (INSERT INTO ... SELECT ...) - * Each task creates its own independent InsertIntoTableCommand instance - */ - private RewriteTableCommand buildRewriteLogicalPlan() { - // Build table name parts - List tableNameParts = ImmutableList.of( - dorisTable.getCatalog().getName(), - dorisTable.getDbName(), - dorisTable.getName()); - - // Create UnboundRelation for SELECT part (source table) - UnboundRelation sourceRelation = new UnboundRelation( - StatementScopeIdGenerator.newRelationId(), - tableNameParts, - ImmutableList.of(), // partitions - false, // isTemporary - ImmutableList.of(), // tabletIds - ImmutableList.of(), // hints - Optional.empty(), // orderKeys - Optional.empty() // limit - ); - - // Create UnboundIcebergTableSink for INSERT part (target table) - UnboundIcebergTableSink tableSink = new UnboundIcebergTableSink<>( - tableNameParts, - ImmutableList.of(), // colNames (empty means all columns) - ImmutableList.of(), // hints - ImmutableList.of(), // partitions - DMLCommandType.INSERT, - Optional.empty(), // labelName - Optional.empty(), // branchName - sourceRelation, true); - // Create RewriteTableCommand for rewrite operation - return new RewriteTableCommand( - tableSink, - Optional.empty(), // labelName - Optional.empty(), // insertCtx - Optional.empty(), // cte - Optional.empty() // branchName - ); - } - - /** - * Build ConnectContext for this task - */ - private ConnectContext buildConnectContext() { - ConnectContext taskContext = new ConnectContext(); - - // Clone session variables from parent - taskContext.setSessionVariable(VariableMgr.cloneSessionVariable(connectContext.getSessionVariable())); - - // Calculate optimal parallelism and determine distribution strategy - RewriteStrategy strategy = calculateRewriteStrategy(); - // Pipeline engine uses parallelPipelineTaskNum to control instance parallelism. - taskContext.getSessionVariable().parallelPipelineTaskNum = strategy.parallelism; - - // Set env and basic identities - taskContext.setEnv(Env.getCurrentEnv()); - taskContext.setDatabase(connectContext.getDatabase()); - taskContext.setCurrentUserIdentity(connectContext.getCurrentUserIdentity()); - taskContext.setRemoteIP(connectContext.getRemoteIP()); - - // Assign unique query id and start time - UUID uuid = UUID.randomUUID(); - TUniqueId queryId = new TUniqueId(uuid.getMostSignificantBits(), uuid.getLeastSignificantBits()); - taskContext.setQueryId(queryId); - taskContext.setThreadLocalInfo(); - taskContext.setStartTime(); - - // Initialize StatementContext for this task - StatementContext statementContext = new StatementContext(); - statementContext.setConnectContext(taskContext); - taskContext.setStatementContext(statementContext); - - // Set GATHER distribution flag if needed (for small data rewrite) - statementContext.setUseGatherForIcebergRewrite(strategy.useGather); - - return taskContext; - } - - /** - * Calculate optimal rewrite strategy including parallelism and distribution mode. - * - * The core idea is to precisely control the number of output files: - * 1. Calculate expected file count based on data size and target file size - * 2. If expected file count is less than available BE count, use GATHER - * to collect data to a single node, avoiding excessive writers - * 3. Otherwise, limit per-BE parallelism so total writers <= expected files - * - * @return RewriteStrategy containing parallelism and distribution settings - */ - private RewriteStrategy calculateRewriteStrategy() { - // 1. Calculate expected output file count based on data size - long totalSize = group.getTotalSize(); - int expectedFileCount = (int) Math.ceil((double) totalSize / targetFileSizeBytes); - - // 2. Use available BE count passed from constructor - int availableBeCount = this.availableBeCount; - Preconditions.checkState(availableBeCount > 0, - "availableBeCount must be greater than 0 for rewrite task"); - - // 3. Get default parallelism from session variable (pipeline task num) - String clusterName = connectContext.getSessionVariable().resolveCloudClusterName(connectContext); - int defaultParallelism = connectContext.getSessionVariable().getParallelExecInstanceNum(clusterName); - - // 4. Determine strategy based on expected file count - boolean useGather = false; - int optimalParallelism; - - // When expected files < available BEs, collect all data to single node - if (expectedFileCount < availableBeCount) { - // Small data volume: use GATHER to write to single node - // Keep parallelism <= expected files to avoid extra output files - useGather = true; - optimalParallelism = Math.max(1, Math.min(defaultParallelism, expectedFileCount)); - } else { - // Larger data volume: limit per-BE parallelism so total writers <= expected files - int maxParallelismByFileCount = Math.max(1, expectedFileCount / availableBeCount); - optimalParallelism = Math.max(1, Math.min(defaultParallelism, maxParallelismByFileCount)); - } - - LOG.info("[Rewrite Task] taskId: {}, totalSize: {} bytes, targetFileSize: {} bytes, " - + "expectedFileCount: {}, availableBeCount: {}, defaultParallelism: {}, " - + "optimalParallelism: {}, useGather: {}", - taskId, totalSize, targetFileSizeBytes, expectedFileCount, - availableBeCount, defaultParallelism, optimalParallelism, useGather); - - return new RewriteStrategy(optimalParallelism, useGather); - } - - /** - * Strategy for rewrite operation containing parallelism and distribution settings. - */ - private static class RewriteStrategy { - final int parallelism; - final boolean useGather; - - RewriteStrategy(int parallelism, boolean useGather) { - this.parallelism = parallelism; - this.useGather = useGather; - } - } - - /** - * Callback interface for task completion - */ - public interface RewriteResultCallback { - void onTaskCompleted(Long taskId); - - void onTaskFailed(Long taskId, Exception error); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergApiSource.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergApiSource.java deleted file mode 100644 index 65de2f9249df30..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergApiSource.java +++ /dev/null @@ -1,91 +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.datasource.iceberg.source; - -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.common.MetaNotFoundException; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergSysExternalTable; -import org.apache.doris.datasource.iceberg.IcebergUtils; -import org.apache.doris.planner.ColumnRange; - -import org.apache.iceberg.Table; - -import java.util.Map; - -/** - * Get metadata from iceberg api (all iceberg table like hive, rest, glue...) - */ -public class IcebergApiSource implements IcebergSource { - - private final ExternalTable targetTable; - private final TupleDescriptor desc; - private Table originTable; - - public IcebergApiSource(ExternalTable table, TupleDescriptor desc, - Map columnNameToRange) { - if (!(table instanceof IcebergExternalTable) && !(table instanceof IcebergSysExternalTable)) { - throw new IllegalArgumentException( - "Expected Iceberg table but got " + table.getClass().getSimpleName()); - } - if (table instanceof IcebergExternalTable) { - IcebergExternalTable icebergExtTable = (IcebergExternalTable) table; - if (icebergExtTable.isView()) { - throw new UnsupportedOperationException("IcebergApiSource does not support view"); - } - } - this.targetTable = table; - this.desc = desc; - } - - @Override - public TupleDescriptor getDesc() { - return desc; - } - - @Override - public String getFileFormat() throws MetaNotFoundException { - return IcebergUtils.getFileFormat(getIcebergTable()).name(); - } - - @Override - public synchronized Table getIcebergTable() throws MetaNotFoundException { - if (originTable == null) { - if (targetTable instanceof IcebergExternalTable) { - originTable = IcebergUtils.getIcebergTable((IcebergExternalTable) targetTable); - } else { - originTable = ((IcebergSysExternalTable) targetTable).getSysIcebergTable(); - } - } - return originTable; - } - - @Override - public TableIf getTargetTable() { - return targetTable; - } - - @Override - public ExternalCatalog getCatalog() { - return targetTable.getCatalog(); - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergDeleteFileFilter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergDeleteFileFilter.java deleted file mode 100644 index 083409adda136c..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergDeleteFileFilter.java +++ /dev/null @@ -1,176 +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.datasource.iceberg.source; - -import lombok.Data; -import org.apache.iceberg.DeleteFile; -import org.apache.iceberg.FileFormat; -import org.apache.iceberg.MetadataColumns; -import org.apache.iceberg.types.Conversions; - -import java.util.List; -import java.util.Optional; -import java.util.OptionalLong; - -@Data -public class IcebergDeleteFileFilter { - private String deleteFilePath; - private long filesize; - private FileFormat fileformat; - - - public static int type() { - return 0; - } - - public IcebergDeleteFileFilter(String deleteFilePath, long filesize, FileFormat fileformat) { - this.deleteFilePath = deleteFilePath; - this.filesize = filesize; - this.fileformat = fileformat; - } - - public static PositionDelete createPositionDelete(DeleteFile deleteFile) { - Optional positionLowerBound = Optional.ofNullable(deleteFile.lowerBounds()) - .map(m -> m.get(MetadataColumns.DELETE_FILE_POS.fieldId())) - .map(bytes -> Conversions.fromByteBuffer(MetadataColumns.DELETE_FILE_POS.type(), bytes)); - Optional positionUpperBound = Optional.ofNullable(deleteFile.upperBounds()) - .map(m -> m.get(MetadataColumns.DELETE_FILE_POS.fieldId())) - .map(bytes -> Conversions.fromByteBuffer(MetadataColumns.DELETE_FILE_POS.type(), bytes)); - String deleteFilePath = deleteFile.path().toString(); - - if (deleteFile.format() == FileFormat.PUFFIN) { - long fileSize = deleteFile.fileSizeInBytes(); - Long contentOffset = deleteFile.contentOffset(); - Long contentLength = deleteFile.contentSizeInBytes(); - validateDeletionVectorMetadata(deleteFilePath, fileSize, contentOffset, contentLength); - // The content_offset and content_size_in_bytes fields are used to reference - // a specific blob for direct access to a deletion vector. - return new DeletionVector(deleteFilePath, positionLowerBound.orElse(-1L), positionUpperBound.orElse(-1L), - fileSize, contentOffset, contentLength); - } else { - return new PositionDelete(deleteFilePath, positionLowerBound.orElse(-1L), positionUpperBound.orElse(-1L), - deleteFile.fileSizeInBytes(), deleteFile.format()); - } - } - - static void validateDeletionVectorMetadata( - String deleteFilePath, long fileSize, Long contentOffset, Long contentLength) { - if (contentOffset == null || contentLength == null) { - throw new IllegalArgumentException(String.format( - "Iceberg deletion vector metadata misses content offset or length: %s", deleteFilePath)); - } - if (fileSize < 0 || contentOffset < 0 || contentLength < 0) { - throw new IllegalArgumentException(String.format( - "Iceberg deletion vector metadata must be non-negative, file: %s, file size: %d, " - + "content offset: %d, content length: %d", - deleteFilePath, fileSize, contentOffset, contentLength)); - } - if (contentOffset > Long.MAX_VALUE - contentLength) { - throw new IllegalArgumentException(String.format( - "Iceberg deletion vector metadata range overflows, file: %s, content offset: %d, " - + "content length: %d", - deleteFilePath, contentOffset, contentLength)); - } - if (contentOffset + contentLength > fileSize) { - throw new IllegalArgumentException(String.format( - "Iceberg deletion vector metadata range exceeds file size, file: %s, file size: %d, " - + "content offset: %d, content length: %d", - deleteFilePath, fileSize, contentOffset, contentLength)); - } - } - - public static EqualityDelete createEqualityDelete(String deleteFilePath, List fieldIds, - long fileSize, FileFormat fileformat) { - // todo: - // Schema deleteSchema = TypeUtil.select(scan.schema(), new HashSet<>(fieldIds)); - // StructLikeSet deleteSet = StructLikeSet.create(deleteSchema.asStruct()); - // pass deleteSet to BE - // compare two StructLike value, if equals, filtered - return new EqualityDelete(deleteFilePath, fieldIds, fileSize, fileformat); - } - - static class PositionDelete extends IcebergDeleteFileFilter { - private final Long positionLowerBound; - private final Long positionUpperBound; - - public PositionDelete(String deleteFilePath, Long positionLowerBound, - Long positionUpperBound, long fileSize, FileFormat fileformat) { - super(deleteFilePath, fileSize, fileformat); - this.positionLowerBound = positionLowerBound; - this.positionUpperBound = positionUpperBound; - } - - public OptionalLong getPositionLowerBound() { - return positionLowerBound == -1L ? OptionalLong.empty() : OptionalLong.of(positionLowerBound); - } - - public OptionalLong getPositionUpperBound() { - return positionUpperBound == -1L ? OptionalLong.empty() : OptionalLong.of(positionUpperBound); - } - - public static int type() { - return 1; - } - } - - static class DeletionVector extends PositionDelete { - private final long contentOffset; - private final long contentLength; - - public DeletionVector(String deleteFilePath, Long positionLowerBound, - Long positionUpperBound, long fileSize, long contentOffset, long contentLength) { - super(deleteFilePath, positionLowerBound, positionUpperBound, fileSize, FileFormat.PUFFIN); - this.contentOffset = contentOffset; - this.contentLength = contentLength; - } - - public long getContentOffset() { - return contentOffset; - } - - public long getContentLength() { - return contentLength; - } - - public static int type() { - return 3; - } - } - - static class EqualityDelete extends IcebergDeleteFileFilter { - private List fieldIds; - - public EqualityDelete(String deleteFilePath, List fieldIds, long fileSize, FileFormat fileFormat) { - super(deleteFilePath, fileSize, fileFormat); - this.fieldIds = fieldIds; - } - - public List getFieldIds() { - return fieldIds; - } - - public void setFieldIds(List fieldIds) { - this.fieldIds = fieldIds; - } - - - public static int type() { - return 2; - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergHMSSource.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergHMSSource.java deleted file mode 100644 index cf33119cd60f11..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergHMSSource.java +++ /dev/null @@ -1,65 +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.datasource.iceberg.source; - -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.MetaNotFoundException; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.iceberg.IcebergUtils; - -public class IcebergHMSSource implements IcebergSource { - - private final HMSExternalTable hmsTable; - private final TupleDescriptor desc; - private org.apache.iceberg.Table icebergTable; - - public IcebergHMSSource(HMSExternalTable hmsTable, TupleDescriptor desc) { - this.hmsTable = hmsTable; - this.desc = desc; - } - - @Override - public TupleDescriptor getDesc() { - return desc; - } - - @Override - public String getFileFormat() throws DdlException, MetaNotFoundException { - return IcebergUtils.getFileFormat(getIcebergTable()).name(); - } - - public synchronized org.apache.iceberg.Table getIcebergTable() throws MetaNotFoundException { - if (icebergTable == null) { - icebergTable = IcebergUtils.getIcebergTable(hmsTable); - } - return icebergTable; - } - - @Override - public TableIf getTargetTable() { - return hmsTable; - } - - @Override - public ExternalCatalog getCatalog() { - return hmsTable.getCatalog(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java deleted file mode 100644 index eee7bc40a920bb..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java +++ /dev/null @@ -1,1580 +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.datasource.iceberg.source; - -import org.apache.doris.analysis.Expr; -import org.apache.doris.analysis.SlotDescriptor; -import org.apache.doris.analysis.TableScanParams; -import org.apache.doris.analysis.TableSnapshot; -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.common.UserException; -import org.apache.doris.common.profile.SummaryProfile; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.ExternalUtil; -import org.apache.doris.datasource.FileQueryScanNode; -import org.apache.doris.datasource.TableFormatType; -import org.apache.doris.datasource.credentials.CredentialUtils; -import org.apache.doris.datasource.credentials.VendedCredentialsFactory; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalMetaCache; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergSysExternalTable; -import org.apache.doris.datasource.iceberg.IcebergUtils; -import org.apache.doris.datasource.iceberg.cache.IcebergManifestCacheLoader; -import org.apache.doris.datasource.iceberg.cache.ManifestCacheValue; -import org.apache.doris.datasource.iceberg.profile.IcebergMetricsReporter; -import org.apache.doris.datasource.iceberg.source.IcebergDeleteFileFilter.EqualityDelete; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.nereids.exceptions.NotSupportedException; -import org.apache.doris.persist.gson.GsonUtils; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.spi.Split; -import org.apache.doris.system.Backend; -import org.apache.doris.thrift.TColumnCategory; -import org.apache.doris.thrift.TExplainLevel; -import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.TFileRangeDesc; -import org.apache.doris.thrift.TIcebergDeleteFileDesc; -import org.apache.doris.thrift.TIcebergFileDesc; -import org.apache.doris.thrift.TPlanNode; -import org.apache.doris.thrift.TTableFormatFileDesc; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Preconditions; -import com.google.common.collect.Lists; -import com.google.gson.JsonObject; -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.apache.iceberg.BaseFileScanTask; -import org.apache.iceberg.BaseTable; -import org.apache.iceberg.BatchScan; -import org.apache.iceberg.ContentScanTask; -import org.apache.iceberg.DataFile; -import org.apache.iceberg.DeleteFile; -import org.apache.iceberg.DeleteFileIndex; -import org.apache.iceberg.FileContent; -import org.apache.iceberg.FileFormat; -import org.apache.iceberg.FileScanTask; -import org.apache.iceberg.ManifestContent; -import org.apache.iceberg.ManifestFile; -import org.apache.iceberg.PartitionData; -import org.apache.iceberg.PartitionField; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.PartitionSpecParser; -import org.apache.iceberg.PositionDeletesScanTask; -import org.apache.iceberg.ScanTask; -import org.apache.iceberg.Schema; -import org.apache.iceberg.SchemaParser; -import org.apache.iceberg.Snapshot; -import org.apache.iceberg.SplittableScanTask; -import org.apache.iceberg.Table; -import org.apache.iceberg.TableProperties; -import org.apache.iceberg.TableScan; -import org.apache.iceberg.expressions.Binder; -import org.apache.iceberg.expressions.Expression; -import org.apache.iceberg.expressions.Expressions; -import org.apache.iceberg.expressions.InclusiveMetricsEvaluator; -import org.apache.iceberg.expressions.ManifestEvaluator; -import org.apache.iceberg.expressions.ResidualEvaluator; -import org.apache.iceberg.io.CloseableIterable; -import org.apache.iceberg.io.CloseableIterator; -import org.apache.iceberg.mapping.MappedField; -import org.apache.iceberg.mapping.MappedFields; -import org.apache.iceberg.mapping.NameMapping; -import org.apache.iceberg.mapping.NameMappingParser; -import org.apache.iceberg.types.Type; -import org.apache.iceberg.types.TypeUtil; -import org.apache.iceberg.types.Types.NestedField; -import org.apache.iceberg.util.ScanTaskUtil; -import org.apache.iceberg.util.SerializationUtil; -import org.apache.iceberg.util.TableScanUtil; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.IOException; -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.OptionalLong; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.atomic.AtomicReference; - -public class IcebergScanNode extends FileQueryScanNode { - - public static final int MIN_DELETE_FILE_SUPPORT_VERSION = 2; - private static final Logger LOG = LogManager.getLogger(IcebergScanNode.class); - - private IcebergSource source; - private Table icebergTable; - private List pushdownIcebergPredicates = Lists.newArrayList(); - // If tableLevelPushDownCount is true, means we can do count push down opt at table level. - // which means all splits have no position/equality delete files, - // so for query like "select count(*) from ice_tbl", we can get count from snapshot row count info directly. - // If tableLevelPushDownCount is false, means we can't do count push down opt at table level, - // But for part of splits which have no position/equality delete files, we can still do count push down opt. - // And for split level count push down opt, the flag is set in each split. - private boolean tableLevelPushDownCount = false; - private long countFromSnapshot; - private static final long COUNT_WITH_PARALLEL_SPLITS = 10000; - private long targetSplitSize = 0; - // This is used to avoid repeatedly calculating partition info map for the same partition data. - private Map> partitionMapInfos; - private boolean isPartitionedTable; - private int formatVersion; - private ExecutionAuthenticator preExecutionAuthenticator; - private TableScan icebergTableScan; - // Store PropertiesMap, including vended credentials or static credentials - // get them in doInitialize() to ensure internal consistency of ScanNode - private Map storagePropertiesMap; - private Map backendStorageProperties; - private long manifestCacheHits; - private long manifestCacheMisses; - private long manifestCacheFailures; - - // Cached values for LocationPath creation optimization - // These are lazily initialized on first use to avoid parsing overhead for each file - private boolean locationPathCacheInitialized = false; - private StorageProperties cachedStorageProperties; - private String cachedSchema; - private String cachedFsIdPrefix; - // Cache for path prefix transformation to avoid repeated S3URI parsing - // Maps original path prefix (e.g., "https://bucket.s3.amazonaws.com/") to normalized prefix (e.g., "s3://bucket/") - private String cachedOriginalPathPrefix; - private String cachedNormalizedPathPrefix; - private String cachedFsIdentifier; - - private Boolean isBatchMode = null; - private boolean isSystemTable = false; - - // ReferencedDataFile path -> List / List (exclude equal delete) - public Map> deleteFilesByReferencedDataFile = new HashMap<>(); - public Map> deleteFilesDescByReferencedDataFile = new HashMap<>(); - - // for test - @VisibleForTesting - public IcebergScanNode(PlanNodeId id, TupleDescriptor desc, SessionVariable sv, ScanContext scanContext) { - super(id, desc, "ICEBERG_SCAN_NODE", scanContext, false, sv); - } - - /** - * External file scan node for Query iceberg table - * needCheckColumnPriv: Some of ExternalFileScanNode do not need to check column priv - * eg: s3 tvf - * These scan nodes do not have corresponding catalog/database/table info, so no need to do priv check - */ - public IcebergScanNode(PlanNodeId id, TupleDescriptor desc, boolean needCheckColumnPriv, SessionVariable sv, - ScanContext scanContext) { - super(id, desc, "ICEBERG_SCAN_NODE", scanContext, needCheckColumnPriv, sv); - - ExternalTable table = (ExternalTable) desc.getTable(); - initIcebergSource(table); - } - - public IcebergScanNode(PlanNodeId id, TupleDescriptor desc, IcebergSysExternalTable sysExternalTable, - SessionVariable sv, ScanContext scanContext) { - super(id, desc, "ICEBERG_SCAN_NODE", scanContext, false, sv); - isSystemTable = true; - initIcebergSource(sysExternalTable); - } - - private void initIcebergSource(ExternalTable table) { - if (table instanceof HMSExternalTable) { - source = new IcebergHMSSource((HMSExternalTable) table, desc); - } else if (table instanceof IcebergExternalTable || table instanceof IcebergSysExternalTable) { - if (table instanceof IcebergSysExternalTable) { - isSystemTable = true; - } - String catalogType = table instanceof IcebergExternalTable - ? ((IcebergExternalTable) table).getIcebergCatalogType() - : ((IcebergSysExternalTable) table).getSourceTable().getIcebergCatalogType(); - switch (catalogType) { - case IcebergExternalCatalog.ICEBERG_HMS: - case IcebergExternalCatalog.ICEBERG_REST: - case IcebergExternalCatalog.ICEBERG_DLF: - case IcebergExternalCatalog.ICEBERG_GLUE: - case IcebergExternalCatalog.ICEBERG_HADOOP: - case IcebergExternalCatalog.ICEBERG_JDBC: - case IcebergExternalCatalog.ICEBERG_S3_TABLES: - source = new IcebergApiSource(table, desc, columnNameToRange); - break; - default: - Preconditions.checkState(false, "Unknown iceberg catalog type: " + catalogType); - break; - } - } - Preconditions.checkNotNull(source); - } - - @Override - protected void doInitialize() throws UserException { - long startTime = System.currentTimeMillis(); - try { - icebergTable = source.getIcebergTable(); - partitionMapInfos = new HashMap<>(); - isPartitionedTable = icebergTable.spec().isPartitioned(); - // Metadata tables (system tables) are not BaseTable instances, so we need to handle this case - if (icebergTable instanceof BaseTable) { - formatVersion = ((BaseTable) icebergTable).operations().current().formatVersion(); - } else { - // For metadata tables (e.g., snapshots, history), use a default format version - // These tables are always readable regardless of format version - formatVersion = MIN_DELETE_FILE_SUPPORT_VERSION; - } - preExecutionAuthenticator = source.getCatalog().getExecutionAuthenticator(); - storagePropertiesMap = VendedCredentialsFactory.getStoragePropertiesMapWithVendedCredentials( - source.getCatalog().getCatalogProperty().getMetastoreProperties(), - source.getCatalog().getCatalogProperty().getStoragePropertiesMap(), - icebergTable - ); - backendStorageProperties = CredentialUtils.getBackendPropertiesFromStorageMap(storagePropertiesMap); - } finally { - if (getSummaryProfile() != null) { - getSummaryProfile().addExternalTableGetTableMetaTime(System.currentTimeMillis() - startTime); - } - } - super.doInitialize(); - } - - /** - * Extract name mapping from Iceberg table properties. - * Returns a map from field ID to list of mapped names. - */ - private Map> extractNameMapping() { - Map> result = new HashMap<>(); - try { - String nameMappingJson = icebergTable.properties().get(TableProperties.DEFAULT_NAME_MAPPING); - if (nameMappingJson != null && !nameMappingJson.isEmpty()) { - NameMapping mapping = NameMappingParser.fromJson(nameMappingJson); - if (mapping != null) { - // Extract mappings from NameMapping - // NameMapping contains field mappings, we need to convert them to our format - extractMappingsFromNameMapping(mapping.asMappedFields(), result); - } - } - } catch (Exception e) { - // If name mapping parsing fails, continue without it - LOG.warn("Failed to parse name mapping from Iceberg table properties", e); - } - return result; - } - - private void extractMappingsFromNameMapping(MappedFields mappingFields, Map> result) { - if (mappingFields == null) { - return; - } - for (MappedField mappedField : mappingFields.fields()) { - result.put(mappedField.id(), new ArrayList<>(mappedField.names())); - extractMappingsFromNameMapping(mappedField.nestedMapping(), result); - } - - } - - @Override - protected void setScanParams(TFileRangeDesc rangeDesc, Split split) { - if (split instanceof IcebergSplit) { - setIcebergParams(rangeDesc, (IcebergSplit) split); - } - } - - private void setIcebergParams(TFileRangeDesc rangeDesc, IcebergSplit icebergSplit) { - TTableFormatFileDesc tableFormatFileDesc = new TTableFormatFileDesc(); - tableFormatFileDesc.setTableFormatType(icebergSplit.getTableFormatType().value()); - TIcebergFileDesc fileDesc = new TIcebergFileDesc(); - if (isSystemTable && icebergSplit.isPositionDeleteSystemTableSplit()) { - setIcebergPositionDeleteSysTableParams(rangeDesc, icebergSplit, tableFormatFileDesc, fileDesc); - return; - } - if (isSystemTable) { - rangeDesc.setFormatType(TFileFormatType.FORMAT_JNI); - tableFormatFileDesc.setTableLevelRowCount(-1); - fileDesc.setSerializedSplit(icebergSplit.getSerializedSplit()); - tableFormatFileDesc.setIcebergParams(fileDesc); - rangeDesc.setTableFormatParams(tableFormatFileDesc); - rangeDesc.unsetColumnsFromPath(); - rangeDesc.unsetColumnsFromPathKeys(); - rangeDesc.unsetColumnsFromPathIsNull(); - return; - } - // update for every split file format - rangeDesc.setFormatType(toTFileFormatType(icebergSplit.getSplitFileFormat())); - if (tableLevelPushDownCount) { - tableFormatFileDesc.setTableLevelRowCount(icebergSplit.getTableLevelRowCount()); - } else { - // MUST explicitly set to -1, to be distinct from valid row count >= 0 - tableFormatFileDesc.setTableLevelRowCount(-1); - } - fileDesc.setFormatVersion(formatVersion); - fileDesc.setOriginalFilePath(icebergSplit.getOriginalPath()); - if (icebergSplit.getPartitionSpecId() != null) { - fileDesc.setPartitionSpecId(icebergSplit.getPartitionSpecId()); - } - if (icebergSplit.getPartitionDataJson() != null) { - fileDesc.setPartitionDataJson(icebergSplit.getPartitionDataJson()); - } - if (formatVersion >= 3) { - fileDesc.setFirstRowId(icebergSplit.getFirstRowId()); - fileDesc.setLastUpdatedSequenceNumber(icebergSplit.getLastUpdatedSequenceNumber()); - } - if (formatVersion < MIN_DELETE_FILE_SUPPORT_VERSION) { - fileDesc.setContent(FileContent.DATA.id()); - } else { - fileDesc.setDeleteFiles(new ArrayList<>()); - for (IcebergDeleteFileFilter filter : icebergSplit.getDeleteFileFilters()) { - TIcebergDeleteFileDesc deleteFileDesc = new TIcebergDeleteFileDesc(); - String deleteFilePath = filter.getDeleteFilePath(); - LocationPath locationPath = LocationPath.of(deleteFilePath, icebergSplit.getConfig()); - deleteFileDesc.setPath(locationPath.toStorageLocation().toString()); - setDeleteFileFormat(deleteFileDesc, filter.getFileformat()); - if (filter instanceof IcebergDeleteFileFilter.PositionDelete) { - IcebergDeleteFileFilter.PositionDelete positionDelete = - (IcebergDeleteFileFilter.PositionDelete) filter; - OptionalLong lowerBound = positionDelete.getPositionLowerBound(); - OptionalLong upperBound = positionDelete.getPositionUpperBound(); - if (lowerBound.isPresent()) { - deleteFileDesc.setPositionLowerBound(lowerBound.getAsLong()); - } - if (upperBound.isPresent()) { - deleteFileDesc.setPositionUpperBound(upperBound.getAsLong()); - } - deleteFileDesc.setContent(IcebergDeleteFileFilter.PositionDelete.type()); - - if (filter instanceof IcebergDeleteFileFilter.DeletionVector) { - IcebergDeleteFileFilter.DeletionVector dv = (IcebergDeleteFileFilter.DeletionVector) filter; - deleteFileDesc.setContentOffset(dv.getContentOffset()); - deleteFileDesc.setContentSizeInBytes(dv.getContentLength()); - deleteFileDesc.setContent(IcebergDeleteFileFilter.DeletionVector.type()); - } - } else { - IcebergDeleteFileFilter.EqualityDelete equalityDelete = - (IcebergDeleteFileFilter.EqualityDelete) filter; - deleteFileDesc.setFieldIds(equalityDelete.getFieldIds()); - deleteFileDesc.setContent(EqualityDelete.type()); - } - fileDesc.addToDeleteFiles(deleteFileDesc); - } - - // Filter out equality delete files from deleteFilesByReferencedDataFile as well. - List nonEqualityDeleteFiles = new ArrayList<>(); - for (DeleteFile df : icebergSplit.getDeleteFiles()) { - if (df.content() != FileContent.EQUALITY_DELETES) { - nonEqualityDeleteFiles.add(df); - } - } - deleteFilesByReferencedDataFile.put(icebergSplit.getOriginalPath(), nonEqualityDeleteFiles); - List nonEqualityDeleteFileDesc = new ArrayList<>(); - for (TIcebergDeleteFileDesc df : fileDesc.getDeleteFiles()) { - if (df.getContent() != EqualityDelete.type()) { - nonEqualityDeleteFileDesc.add(df); - } - } - deleteFilesDescByReferencedDataFile.put(icebergSplit.getOriginalPath(), nonEqualityDeleteFileDesc); - } - tableFormatFileDesc.setIcebergParams(fileDesc); - rangeDesc.unsetColumnsFromPath(); - rangeDesc.unsetColumnsFromPathKeys(); - rangeDesc.unsetColumnsFromPathIsNull(); - Map partitionValues = icebergSplit.getIcebergPartitionValues(); - List orderedPartitionKeys = getOrderedPathPartitionKeys(); - if (partitionValues != null && !orderedPartitionKeys.isEmpty()) { - List fromPathKeys = new ArrayList<>(); - List fromPathValues = new ArrayList<>(); - List fromPathIsNull = new ArrayList<>(); - for (String partitionKey : orderedPartitionKeys) { - if (!partitionValues.containsKey(partitionKey)) { - continue; - } - String partitionValue = partitionValues.get(partitionKey); - fromPathKeys.add(partitionKey); - fromPathValues.add(partitionValue != null ? partitionValue : ""); - fromPathIsNull.add(partitionValue == null); - } - if (!fromPathKeys.isEmpty()) { - rangeDesc.setColumnsFromPathKeys(fromPathKeys); - rangeDesc.setColumnsFromPath(fromPathValues); - rangeDesc.setColumnsFromPathIsNull(fromPathIsNull); - } - } - rangeDesc.setTableFormatParams(tableFormatFileDesc); - } - - private void setIcebergPositionDeleteSysTableParams(TFileRangeDesc rangeDesc, IcebergSplit icebergSplit, - TTableFormatFileDesc tableFormatFileDesc, TIcebergFileDesc fileDesc) { - rangeDesc.setFormatType(icebergSplit.getPositionDeleteFileFormat()); - tableFormatFileDesc.setTableLevelRowCount(-1); - fileDesc.setContent(icebergSplit.getPositionDeleteContent()); - - if (icebergSplit.getPartitionSpecId() != null) { - fileDesc.setPartitionSpecId(icebergSplit.getPartitionSpecId()); - } - if (icebergSplit.getPartitionDataJson() != null) { - fileDesc.setPartitionDataJson(icebergSplit.getPartitionDataJson()); - } - - TIcebergDeleteFileDesc deleteFileDesc = new TIcebergDeleteFileDesc(); - deleteFileDesc.setPath(rangeDesc.getPath()); - deleteFileDesc.setOriginalPath(icebergSplit.getPositionDeleteOriginalPath()); - deleteFileDesc.setFileFormat(icebergSplit.getPositionDeleteFileFormat()); - deleteFileDesc.setContent(icebergSplit.getPositionDeleteContent()); - if (icebergSplit.getPositionDeleteContentOffset() != null) { - deleteFileDesc.setContentOffset(icebergSplit.getPositionDeleteContentOffset()); - } - if (icebergSplit.getPositionDeleteContentSizeInBytes() != null) { - deleteFileDesc.setContentSizeInBytes(icebergSplit.getPositionDeleteContentSizeInBytes()); - } - if (icebergSplit.getPositionDeleteReferencedDataFilePath() != null) { - deleteFileDesc.setReferencedDataFilePath(icebergSplit.getPositionDeleteReferencedDataFilePath()); - } - fileDesc.setDeleteFiles(Lists.newArrayList(deleteFileDesc)); - tableFormatFileDesc.setIcebergParams(fileDesc); - rangeDesc.setTableFormatParams(tableFormatFileDesc); - rangeDesc.unsetColumnsFromPath(); - rangeDesc.unsetColumnsFromPathKeys(); - rangeDesc.unsetColumnsFromPathIsNull(); - } - - @Override - protected List getDeleteFiles(TFileRangeDesc rangeDesc) { - List deleteFiles = new ArrayList<>(); - if (rangeDesc == null || !rangeDesc.isSetTableFormatParams()) { - return deleteFiles; - } - TTableFormatFileDesc tableFormatParams = rangeDesc.getTableFormatParams(); - if (tableFormatParams == null || !tableFormatParams.isSetIcebergParams()) { - return deleteFiles; - } - TIcebergFileDesc icebergParams = tableFormatParams.getIcebergParams(); - if (icebergParams == null || !icebergParams.isSetDeleteFiles()) { - return deleteFiles; - } - List icebergDeleteFiles = icebergParams.getDeleteFiles(); - if (icebergDeleteFiles == null) { - return deleteFiles; - } - for (TIcebergDeleteFileDesc deleteFile : icebergDeleteFiles) { - if (deleteFile != null && deleteFile.isSetPath()) { - deleteFiles.add(deleteFile.getPath()); - } - } - return deleteFiles; - } - - private void setDeleteFileFormat(TIcebergDeleteFileDesc deleteFileDesc, FileFormat fileFormat) { - if (fileFormat == FileFormat.PARQUET) { - deleteFileDesc.setFileFormat(TFileFormatType.FORMAT_PARQUET); - } else if (fileFormat == FileFormat.ORC) { - deleteFileDesc.setFileFormat(TFileFormatType.FORMAT_ORC); - } - } - - private TFileFormatType toTFileFormatType(FileFormat fileFormat) { - if (fileFormat == FileFormat.PARQUET) { - return TFileFormatType.FORMAT_PARQUET; - } else if (fileFormat == FileFormat.ORC) { - return TFileFormatType.FORMAT_ORC; - } - throw new UnsupportedOperationException("Unsupported Iceberg data file format: " + fileFormat); - } - - private String getDeleteFileContentType(int content) { - // Iceberg file type: 0: data, 1: position delete, 2: equality delete, 3: deletion vector - switch (content) { - case 1: - return "position_delete"; - case 2: - return "equality_delete"; - case 3: - return "deletion_vector"; - default: - return "unknown"; - } - } - - private List getOrderedPathPartitionKeys() { - if (icebergTable == null) { - return Collections.emptyList(); - } - return IcebergUtils.getIdentityPartitionColumns(icebergTable); - } - - public void createScanRangeLocations() throws UserException { - super.createScanRangeLocations(); - // Extract name mapping from Iceberg table properties - Map> nameMapping = extractNameMapping(); - - // Equality-delete keys are hidden scan dependencies and need not appear in the query - // projection. Both scanners need the complete current schema to resolve field ids, - // historical names, types, and initial defaults when an old data file lacks such a key. - ExternalUtil.initSchemaInfoForAllColumn(params, -1L, source.getTargetTable().getColumns(), - nameMapping, getBase64EncodedInitialDefaultsForScan()); - } - - @VisibleForTesting - Map getBase64EncodedInitialDefaultsForScan() throws UserException { - if (isSystemTable) { - // System-table columns are derived from the metadata table schema. Some metadata - // tables, such as position_deletes, do not support Table.newScan(). Use the same - // schema that produced source.getTargetTable().getColumns() to keep defaults aligned. - return IcebergUtils.getBase64EncodedInitialDefaults(icebergTable.schema()); - } - TableScan tableScan = createTableScan(); - Snapshot snapshot = tableScan.snapshot(); - // TableScan.schema() starts from the table's current schema even for useSnapshot/useRef. - // Resolve the selected snapshot's schema id explicitly so this metadata describes the same - // snapshot as source.getTargetTable().getColumns(). Otherwise a later type change can make - // BE decode a historical non-binary default as Base64, or fail to decode a binary default. - Schema scanSchema = snapshot == null - ? tableScan.schema() - : tableScan.table().schemas().get(snapshot.schemaId()); - return IcebergUtils.getBase64EncodedInitialDefaults( - Preconditions.checkNotNull(scanSchema, "Schema for Iceberg scan snapshot is null")); - } - - @Override - public List getSplits(int numBackends) throws UserException { - - try { - return preExecutionAuthenticator.execute(() -> doGetSplits(numBackends)); - } catch (Exception e) { - Optional opt = checkNotSupportedException(e); - if (opt.isPresent()) { - throw opt.get(); - } else { - throw new RuntimeException(ExceptionUtils.getRootCauseMessage(e), e); - } - } - } - - /** - * Get FileScanTasks from StatementContext for rewrite operations. - * This allows setting file scan tasks before the plan is generated. - */ - private List getFileScanTasksFromContext() { - ConnectContext ctx = ConnectContext.get(); - Preconditions.checkNotNull(ctx); - Preconditions.checkNotNull(ctx.getStatementContext()); - - // Get the rewrite file scan tasks from statement context - List tasks = ctx.getStatementContext().getAndClearIcebergRewriteFileScanTasks(); - if (tasks != null && !tasks.isEmpty()) { - LOG.info("Retrieved {} file scan tasks from context for table {}", - tasks.size(), icebergTable.name()); - return new ArrayList<>(tasks); - } - return null; - } - - @Override - public void startSplit(int numBackends) throws UserException { - try { - preExecutionAuthenticator.execute(() -> { - doStartSplit(); - return null; - }); - } catch (Exception e) { - throw new UserException(e.getMessage(), e); - } - } - - public void doStartSplit() throws UserException { - TableScan scan = createTableScan(); - CompletableFuture.runAsync(() -> { - AtomicReference> taskRef = new AtomicReference<>(); - try { - preExecutionAuthenticator.execute( - () -> { - long startTime = System.currentTimeMillis(); - try { - CloseableIterable fileScanTasks = planFileScanTask(scan); - taskRef.set(fileScanTasks); - CloseableIterator iterator = fileScanTasks.iterator(); - while (splitAssignment.needMoreSplit() && iterator.hasNext()) { - try { - splitAssignment.addToQueue( - Lists.newArrayList(createIcebergSplit(iterator.next()))); - } catch (UserException e) { - throw new RuntimeException(e); - } - } - } finally { - if (getSummaryProfile() != null) { - getSummaryProfile().addExternalTableGetFileScanTasksTime( - System.currentTimeMillis() - startTime); - } - } - } - ); - splitAssignment.finishSchedule(); - recordManifestCacheProfile(); - } catch (Exception e) { - Optional opt = checkNotSupportedException(e); - if (opt.isPresent()) { - splitAssignment.setException(new UserException(opt.get().getMessage(), opt.get())); - } else { - splitAssignment.setException(new UserException(e.getMessage(), e)); - } - } finally { - if (taskRef.get() != null) { - try { - taskRef.get().close(); - } catch (IOException e) { - // ignore - } - } - } - }, Env.getCurrentEnv().getExtMetaCacheMgr().getScheduleExecutor()); - } - - @VisibleForTesting - public TableScan createTableScan() throws UserException { - if (icebergTableScan != null) { - return icebergTableScan; - } - - TableScan scan = icebergTable.newScan().metricsReporter(new IcebergMetricsReporter()); - - // set snapshot - IcebergTableQueryInfo info = getSpecifiedSnapshot(); - if (info != null) { - if (info.getRef() != null) { - scan = scan.useRef(info.getRef()); - } else { - scan = scan.useSnapshot(info.getSnapshotId()); - } - } - - // set filter - List expressions = new ArrayList<>(); - for (Expr conjunct : conjuncts) { - Expression expression = IcebergUtils.convertToIcebergExpr(conjunct, icebergTable.schema()); - if (expression != null) { - expressions.add(expression); - } - } - for (Expression predicate : expressions) { - scan = scan.filter(predicate); - this.pushdownIcebergPredicates.add(predicate.toString()); - } - - // Doris reads normal Iceberg table files in BE and applies column pruning through scan range params. - // System tables are different: Iceberg SDK DataTask materializes rows using the projected scan - // schema. Keep Doris file slots in the same order as the JNI reader's required fields. - if (isSystemTable) { - Schema projectedSchema = getSystemTableProjectedSchema(expressions, scan.isCaseSensitive()); - Preconditions.checkState(!projectedSchema.columns().isEmpty(), - "Iceberg system table scan must materialize at least one file slot"); - scan = scan.project(projectedSchema); - } - - icebergTableScan = scan.planWith(source.getCatalog().getThreadPoolWithPreAuth()); - - return icebergTableScan; - } - - @VisibleForTesting - Schema getSystemTableProjectedSchema(List expressions, boolean caseSensitive) - throws UserException { - List projectedFields = new ArrayList<>(); - Set projectedFieldIds = new HashSet<>(); - List partitionKeys = getPathPartitionKeys(); - for (SlotDescriptor slot : desc.getSlots()) { - Column column = slot.getColumn(); - String columnName = column.getName(); - if (!isFileSlot(classifyColumn(slot, partitionKeys))) { - continue; - } - - NestedField field = caseSensitive - ? icebergTable.schema().findField(columnName) - : icebergTable.schema().caseInsensitiveFindField(columnName); - if (field == null) { - throw new UserException("Column " + columnName + " not found in Iceberg system table schema"); - } - if (projectedFieldIds.add(field.fieldId())) { - projectedFields.add(field); - } - } - - Set filterFieldIds = Binder.boundReferences( - icebergTable.schema().asStruct(), expressions, caseSensitive); - for (Integer fieldId : filterFieldIds) { - NestedField field = getTopLevelSystemTableField(fieldId); - if (field == null) { - throw new UserException( - "Column with field id " + fieldId + " not found in Iceberg system table schema"); - } - if (!projectedFieldIds.contains(field.fieldId())) { - throw new UserException("Iceberg system table filter column " + field.name() - + " is not materialized by the planner"); - } - } - return new Schema(projectedFields); - } - - private NestedField getTopLevelSystemTableField(int fieldId) { - for (NestedField field : icebergTable.schema().columns()) { - if (field.fieldId() == fieldId || TypeUtil.getProjectedIds(field.type()).contains(fieldId)) { - return field; - } - } - return null; - } - - private CloseableIterable planFileScanTask(TableScan scan) { - if (!IcebergUtils.isManifestCacheEnabled(source.getCatalog())) { - return splitFiles(scan); - } - try { - return planFileScanTaskWithManifestCache(scan); - } catch (Exception e) { - manifestCacheFailures++; - LOG.warn("Plan with manifest cache failed, fallback to original scan: " + e.getMessage(), e); - return splitFiles(scan); - } - } - - private CloseableIterable splitFiles(TableScan scan) { - if (sessionVariable.getFileSplitSize() > 0) { - return TableScanUtil.splitFiles(scan.planFiles(), - sessionVariable.getFileSplitSize()); - } - if (isBatchMode()) { - // Currently iceberg batch split mode will use max split size. - // TODO: dynamic split size in batch split mode need to customize iceberg splitter. - return TableScanUtil.splitFiles(scan.planFiles(), sessionVariable.getMaxSplitSize()); - } - - // Non Batch Mode - // Materialize planFiles() into a list to avoid iterating the CloseableIterable twice. - // RISK: It will cost memory if the table is large. - List fileScanTaskList = new ArrayList<>(); - try (CloseableIterable scanTasksIter = scan.planFiles()) { - for (FileScanTask task : scanTasksIter) { - fileScanTaskList.add(task); - } - } catch (Exception e) { - throw new RuntimeException("Failed to materialize file scan tasks", e); - } - - targetSplitSize = determineTargetFileSplitSize(fileScanTaskList); - return TableScanUtil.splitFiles(CloseableIterable.withNoopClose(fileScanTaskList), targetSplitSize); - } - - private long determineTargetFileSplitSize(Iterable> tasks) { - long result = sessionVariable.getMaxInitialSplitSize(); - long accumulatedTotalFileSize = 0; - boolean exceedInitialThreshold = false; - for (ContentScanTask task : tasks) { - accumulatedTotalFileSize += ScanTaskUtil.contentSizeInBytes(task.file()); - if (!exceedInitialThreshold && accumulatedTotalFileSize - >= sessionVariable.getMaxSplitSize() * sessionVariable.getMaxInitialSplitNum()) { - exceedInitialThreshold = true; - } - } - result = exceedInitialThreshold ? sessionVariable.getMaxSplitSize() : result; - if (!isBatchMode()) { - result = applyMaxFileSplitNumLimit(result, accumulatedTotalFileSize); - } - return result; - } - - private long determinePositionDeleteTargetSplitSize(Iterable tasks) { - if (sessionVariable.getFileSplitSize() > 0) { - return sessionVariable.getFileSplitSize(); - } - return determineTargetFileSplitSize(tasks); - } - - private CloseableIterable planFileScanTaskWithManifestCache(TableScan scan) throws IOException { - // Get the snapshot from the scan; return empty if no snapshot exists - Snapshot snapshot = scan.snapshot(); - if (snapshot == null) { - return CloseableIterable.withNoopClose(Collections.emptyList()); - } - - // Initialize manifest cache for efficient manifest file access - IcebergExternalMetaCache cache = Env.getCurrentEnv().getExtMetaCacheMgr().iceberg(source.getCatalog().getId()); - if (!(source.getTargetTable() instanceof ExternalTable)) { - throw new RuntimeException("Iceberg scan target table is not an external table"); - } - ExternalTable targetExternalTable = (ExternalTable) source.getTargetTable(); - - // Convert query conjuncts to Iceberg filter expression - // This combines all predicates with AND logic for partition/file pruning - Expression filterExpr = conjuncts.stream() - .map(conjunct -> IcebergUtils.convertToIcebergExpr(conjunct, icebergTable.schema())) - .filter(Objects::nonNull) - .reduce(Expressions.alwaysTrue(), Expressions::and); - - // Get all partition specs by their IDs for later use - Map specsById = icebergTable.specs(); - boolean caseSensitive = true; - - // Create residual evaluators for each partition spec - // Residual evaluators compute the remaining filter expression after partition pruning - Map residualEvaluators = new HashMap<>(); - specsById.forEach((id, spec) -> residualEvaluators.put(id, - ResidualEvaluator.of(spec, filterExpr, caseSensitive))); - - // Create metrics evaluator for file-level pruning based on column statistics - InclusiveMetricsEvaluator metricsEvaluator = - new InclusiveMetricsEvaluator(icebergTable.schema(), filterExpr, caseSensitive); - - // ========== Phase 1: Load delete files from delete manifests ========== - List deleteFiles = new ArrayList<>(); - List deleteManifests = snapshot.deleteManifests(icebergTable.io()); - for (ManifestFile manifest : deleteManifests) { - // Skip non-delete manifests - if (manifest.content() != ManifestContent.DELETES) { - continue; - } - // Get the partition spec for this manifest - PartitionSpec spec = specsById.get(manifest.partitionSpecId()); - if (spec == null) { - continue; - } - // Create manifest evaluator for partition-level pruning - ManifestEvaluator evaluator = - ManifestEvaluator.forPartitionFilter(filterExpr, spec, caseSensitive); - // Skip manifest if it doesn't match the filter expression (partition pruning) - if (!evaluator.eval(manifest)) { - continue; - } - // Load delete files from cache (or from storage if not cached) - ManifestCacheValue value = IcebergManifestCacheLoader.loadDeleteFilesWithCache(cache, - targetExternalTable, manifest, icebergTable, this::recordManifestCacheAccess); - deleteFiles.addAll(value.getDeleteFiles()); - } - - // Build delete file index for efficient lookup of deletes applicable to each data file - DeleteFileIndex deleteIndex = DeleteFileIndex.builderFor(deleteFiles) - .specsById(specsById) - .caseSensitive(caseSensitive) - .build(); - - // ========== Phase 2: Load data files and create scan tasks ========== - List tasks = new ArrayList<>(); - try (CloseableIterable dataManifests = - IcebergUtils.getMatchingManifest(snapshot.dataManifests(icebergTable.io()), - specsById, filterExpr)) { - for (ManifestFile manifest : dataManifests) { - // Skip non-data manifests - if (manifest.content() != ManifestContent.DATA) { - continue; - } - // Get the partition spec for this manifest - PartitionSpec spec = specsById.get(manifest.partitionSpecId()); - if (spec == null) { - continue; - } - // Get the residual evaluator for this partition spec - ResidualEvaluator residualEvaluator = residualEvaluators.get(manifest.partitionSpecId()); - if (residualEvaluator == null) { - continue; - } - - // Load data files from cache (or from storage if not cached) - ManifestCacheValue value = IcebergManifestCacheLoader.loadDataFilesWithCache(cache, - targetExternalTable, manifest, icebergTable, this::recordManifestCacheAccess); - - // Process each data file in the manifest - for (org.apache.iceberg.DataFile dataFile : value.getDataFiles()) { - // Skip file if column statistics indicate no matching rows (metrics-based pruning) - if (metricsEvaluator != null && !metricsEvaluator.eval(dataFile)) { - continue; - } - // Skip file if partition values don't match the residual filter - if (residualEvaluator.residualFor(dataFile.partition()).equals(Expressions.alwaysFalse())) { - continue; - } - // Find all delete files that apply to this data file based on sequence number - List deletes = Arrays.asList( - deleteIndex.forDataFile(dataFile.dataSequenceNumber(), dataFile)); - - // Create a FileScanTask containing the data file, associated deletes, and metadata - tasks.add(new BaseFileScanTask( - dataFile, - deletes.toArray(new DeleteFile[0]), - SchemaParser.toJson(icebergTable.schema()), - PartitionSpecParser.toJson(spec), - residualEvaluator)); - } - } - } - - // Split tasks into smaller chunks based on target split size for parallel processing - targetSplitSize = determineTargetFileSplitSize(tasks); - return TableScanUtil.splitFiles(CloseableIterable.withNoopClose(tasks), targetSplitSize); - } - - /** - * Initialize cached values for LocationPath creation on first use. - * This avoids repeated StorageProperties lookup, scheme parsing, and S3URI regex parsing for each file. - */ - private void initLocationPathCache(String samplePath) { - try { - // Create a LocationPath using the full method to get all cached values - LocationPath sampleLocationPath = LocationPath.of(samplePath, storagePropertiesMap); - cachedStorageProperties = sampleLocationPath.getStorageProperties(); - cachedSchema = sampleLocationPath.getSchema(); - cachedFsIdentifier = sampleLocationPath.getFsIdentifier(); - - // Extract fsIdPrefix like "s3://" from fsIdentifier like "s3://bucket" - int schemeEnd = cachedFsIdentifier.indexOf("://"); - if (schemeEnd > 0) { - cachedFsIdPrefix = cachedFsIdentifier.substring(0, schemeEnd + 3); - } - - // Cache path prefix mapping for fast transformation - // This allows subsequent files to skip S3URI regex parsing entirely - String normalizedPath = sampleLocationPath.getNormalizedLocation(); - - // Find the common prefix by looking for the last '/' before the filename - int lastSlashInOriginal = samplePath.lastIndexOf('/'); - int lastSlashInNormalized = normalizedPath.lastIndexOf('/'); - - if (lastSlashInOriginal > 0 && lastSlashInNormalized > 0) { - cachedOriginalPathPrefix = samplePath.substring(0, lastSlashInOriginal + 1); - cachedNormalizedPathPrefix = normalizedPath.substring(0, lastSlashInNormalized + 1); - } - - locationPathCacheInitialized = true; - } catch (Exception e) { - // If caching fails, try to initialize again on next use - LOG.warn("Failed to initialize LocationPath cache, will use full parsing", e); - } - } - - /** - * Create a LocationPath with cached values for better performance. - * Uses cached path prefix mapping to completely bypass S3URI regex parsing for most files. - * Falls back to full parsing if cache is not available or path doesn't match cached prefix. - */ - private LocationPath createLocationPathWithCache(String path) { - // Initialize cache on first call - if (!locationPathCacheInitialized) { - initLocationPathCache(path); - } - - // Fast path: if path starts with cached original prefix, directly transform without any parsing - if (cachedOriginalPathPrefix != null && path.startsWith(cachedOriginalPathPrefix)) { - // Transform: replace original prefix with normalized prefix - String normalizedPath = cachedNormalizedPathPrefix + path.substring(cachedOriginalPathPrefix.length()); - return LocationPath.ofDirect(normalizedPath, cachedSchema, cachedFsIdentifier, cachedStorageProperties); - } - - // Medium path: use cached StorageProperties but still need validateAndNormalizeUri - if (cachedStorageProperties != null) { - return LocationPath.ofWithCache(path, cachedStorageProperties, cachedSchema, cachedFsIdPrefix); - } - - // Fallback to full parsing - return LocationPath.of(path, storagePropertiesMap); - } - - private Split createIcebergSplit(FileScanTask fileScanTask) { - DataFile dataFile = fileScanTask.file(); - String originalPath = dataFile.path().toString(); - LocationPath locationPath = createLocationPathWithCache(originalPath); - IcebergSplit split = new IcebergSplit( - locationPath, - fileScanTask.start(), - fileScanTask.length(), - dataFile.fileSizeInBytes(), - new String[0], - formatVersion, - storagePropertiesMap, - new ArrayList<>(), - originalPath); - split.setSplitFileFormat(dataFile.format()); - if (formatVersion >= 3) { - // -1 means that this table was just upgraded from v2 to v3. - // _row_id and _last_updated_sequence_number column is NULL. - split.setFirstRowId(dataFile.firstRowId() != null ? dataFile.firstRowId() : -1); - split.setLastUpdatedSequenceNumber( - dataFile.fileSequenceNumber() != null && dataFile.firstRowId() != null - ? dataFile.fileSequenceNumber() : -1); - } - if (!fileScanTask.deletes().isEmpty()) { - split.setDeleteFileFilters(fileScanTask.deletes(), getDeleteFileFilters(fileScanTask)); - } - split.setTableFormatType(TableFormatType.ICEBERG); - split.setTargetSplitSize(targetSplitSize); - if (isPartitionedTable) { - int specId = fileScanTask.file().specId(); - PartitionSpec partitionSpec = icebergTable.specs().get(specId); - Preconditions.checkNotNull(partitionSpec, "Partition spec with specId %s not found for table %s", - specId, icebergTable.name()); - PartitionData partitionData = (PartitionData) fileScanTask.file().partition(); - if (partitionData != null) { - split.setPartitionSpecId(specId); - split.setPartitionDataJson(IcebergUtils.getPartitionDataJson( - partitionData, partitionSpec, sessionVariable.getTimeZone())); - Map partitionInfoMap = partitionMapInfos.computeIfAbsent( - partitionData, k -> IcebergUtils.getIdentityPartitionInfoMap( - partitionData, partitionSpec, icebergTable, sessionVariable.getTimeZone())); - if (!partitionInfoMap.isEmpty()) { - split.setIcebergPartitionValues(partitionInfoMap); - } - } else { - partitionMapInfos.put(null, Collections.emptyMap()); - } - } - return split; - } - - private Split createIcebergSysSplit(ScanTask scanTask) { - long rowCount = Math.max(scanTask.estimatedRowsCount(), 1L); - if (scanTask.isFileScanTask() && scanTask.asFileScanTask().file() != null) { - rowCount = Math.max(scanTask.asFileScanTask().file().recordCount(), 1L); - } - IcebergSplit split = IcebergSplit.newSysTableSplit( - SerializationUtil.serializeToBase64(scanTask), rowCount); - split.setTableFormatType(TableFormatType.ICEBERG); - return split; - } - - private Split createIcebergPositionDeleteSysSplit(PositionDeletesScanTask task) throws UserException { - DeleteFile deleteFile = task.file(); - String originalPath = deleteFile.path().toString(); - LocationPath locationPath = createLocationPathWithCache(originalPath); - IcebergSplit split = IcebergSplit.newPositionDeleteSysTableSplit( - locationPath, task.start(), task.length(), deleteFile.fileSizeInBytes(), - storagePropertiesMap, originalPath); - split.setTableFormatType(TableFormatType.ICEBERG); - split.setPositionDeleteFileFormat(getNativePositionDeleteFileFormat(deleteFile.format())); - split.setPositionDeleteOriginalPath(originalPath); - if (deleteFile.format() == FileFormat.PUFFIN) { - Long contentOffset = deleteFile.contentOffset(); - Long contentLength = deleteFile.contentSizeInBytes(); - IcebergDeleteFileFilter.validateDeletionVectorMetadata( - originalPath, deleteFile.fileSizeInBytes(), contentOffset, contentLength); - split.setPositionDeleteContent(IcebergDeleteFileFilter.DeletionVector.type()); - split.setPositionDeleteReferencedDataFilePath(deleteFile.referencedDataFile()); - split.setPositionDeleteContentOffset(contentOffset); - split.setPositionDeleteContentSizeInBytes(contentLength); - } else { - split.setPositionDeleteContent(IcebergDeleteFileFilter.PositionDelete.type()); - } - - split.setPartitionSpecId(deleteFile.specId()); - PartitionSpec partitionSpec = icebergTable.specs().get(deleteFile.specId()); - Preconditions.checkNotNull(partitionSpec, "Partition spec with specId %s not found for table %s", - deleteFile.specId(), icebergTable.name()); - if (partitionSpec.isPartitioned() && deleteFile.partition() != null - && isPositionDeletesPartitionColumnRequested()) { - split.setPartitionDataJson(getPartitionDataObjectJson( - (PartitionData) deleteFile.partition(), partitionSpec, - getPositionDeletesOutputPartitionFields())); - } - return split; - } - - @SuppressWarnings("unchecked") - private Iterable splitPositionDeleteScanTask(PositionDeletesScanTask task) { - return ((SplittableScanTask) task).split(targetSplitSize); - } - - private TFileFormatType getNativePositionDeleteFileFormat(FileFormat fileFormat) { - if (fileFormat == FileFormat.PARQUET || fileFormat == FileFormat.PUFFIN) { - return TFileFormatType.FORMAT_PARQUET; - } else if (fileFormat == FileFormat.ORC) { - return TFileFormatType.FORMAT_ORC; - } - throw new UnsupportedOperationException( - "Unsupported Iceberg position delete file format: " + fileFormat); - } - - private List getPositionDeletesOutputPartitionFields() { - NestedField partitionField = icebergTable.schema().findField("partition"); - Preconditions.checkNotNull(partitionField, - "Partition field not found in Iceberg position_deletes metadata table schema"); - return partitionField.type().asNestedType().fields(); - } - - private boolean isPositionDeletesPartitionColumnRequested() { - return desc.getSlots().stream() - .anyMatch(slot -> "partition".equalsIgnoreCase(slot.getColumn().getName())); - } - - private String getPartitionDataObjectJson(PartitionData partitionData, PartitionSpec partitionSpec, - List outputPartitionFields) throws UserException { - List partitionTypes = partitionData.getPartitionType().asNestedType().fields(); - boolean enableMappingVarbinary = getEnableMappingVarbinary(); - for (int i = 0; i < partitionTypes.size(); i++) { - Type type = partitionTypes.get(i).type(); - if (partitionData.get(i) != null && (type.typeId() == Type.TypeID.BINARY - || type.typeId() == Type.TypeID.FIXED - || (type.typeId() == Type.TypeID.UUID && enableMappingVarbinary))) { - throw new UserException("Iceberg position_deletes cannot materialize non-null partition field '" - + partitionTypes.get(i).name() + "' of type " + type - + " without a binary-safe partition transport"); - } - } - List partitionValues = IcebergUtils.getPartitionValues( - partitionData, partitionSpec, sessionVariable.getTimeZone()); - Map partitionValueByFieldId = new HashMap<>(); - List fields = partitionSpec.fields(); - for (int i = 0; i < fields.size(); i++) { - partitionValueByFieldId.put(fields.get(i).fieldId(), - getPartitionJsonValue(partitionTypes.get(i).type(), partitionValues.get(i))); - } - JsonObject partitionJson = new JsonObject(); - for (NestedField outputPartitionField : outputPartitionFields) { - partitionJson.add(outputPartitionField.name(), - GsonUtils.GSON.toJsonTree(partitionValueByFieldId.get(outputPartitionField.fieldId()))); - } - return GsonUtils.GSON.toJson(partitionJson); - } - - private static Object getPartitionJsonValue(Type type, String partitionValue) { - if (partitionValue == null) { - return null; - } - switch (type.typeId()) { - case BOOLEAN: - return Boolean.parseBoolean(partitionValue); - case INTEGER: - return Integer.parseInt(partitionValue); - case LONG: - return Long.parseLong(partitionValue); - case FLOAT: - return Float.parseFloat(partitionValue); - case DOUBLE: - return Double.parseDouble(partitionValue); - case DECIMAL: - return new BigDecimal(partitionValue); - case STRING: - case UUID: - case DATE: - case TIME: - case TIMESTAMP: - return partitionValue; - default: - return partitionValue; - } - } - - @Override - protected TColumnCategory classifyColumn(SlotDescriptor slot, List partitionKeys) { - if (Column.ICEBERG_ROWID_COL.equalsIgnoreCase(slot.getColumn().getName())) { - return TColumnCategory.SYNTHESIZED; - } - if (slot.getColumn().getName().startsWith(Column.GLOBAL_ROWID_COL)) { - return TColumnCategory.SYNTHESIZED; - } - if (IcebergUtils.isIcebergRowLineageColumn(slot.getColumn())) { - return TColumnCategory.GENERATED; - } - return super.classifyColumn(slot, partitionKeys); - } - - private List doGetSplits(int numBackends) throws UserException { - if (isSystemTable) { - return doGetSystemTableSplits(); - } - - List splits = new ArrayList<>(); - - // Use custom file scan tasks if available (for rewrite operations) - List customFileScanTasks = getFileScanTasksFromContext(); - if (customFileScanTasks != null) { - for (FileScanTask task : customFileScanTasks) { - splits.add(createIcebergSplit(task)); - } - selectedPartitionNum = partitionMapInfos.size(); - recordManifestCacheProfile(); - return splits; - } - - // Normal table scan planning - TableScan scan = createTableScan(); - - long startTime = System.currentTimeMillis(); - try (CloseableIterable fileScanTasks = planFileScanTask(scan)) { - if (tableLevelPushDownCount) { - int needSplitCnt = countFromSnapshot < COUNT_WITH_PARALLEL_SPLITS - ? 1 : sessionVariable.getParallelExecInstanceNum(scanContext.getClusterName()) - * numBackends; - for (FileScanTask next : fileScanTasks) { - splits.add(createIcebergSplit(next)); - if (splits.size() >= needSplitCnt) { - break; - } - } - setPushDownCount(countFromSnapshot); - assignCountToSplits(splits, countFromSnapshot); - recordManifestCacheProfile(); - return splits; - } else { - fileScanTasks.forEach(taskGrp -> splits.add(createIcebergSplit(taskGrp))); - } - } catch (IOException e) { - throw new UserException(e.getMessage(), e.getCause()); - } finally { - if (getSummaryProfile() != null) { - getSummaryProfile().addExternalTableGetFileScanTasksTime(System.currentTimeMillis() - startTime); - } - } - - selectedPartitionNum = partitionMapInfos.size(); - recordManifestCacheProfile(); - return splits; - } - - private List doGetSystemTableSplits() throws UserException { - if (isPositionDeletesSystemTable()) { - return doGetPositionDeletesSystemTableSplits(); - } - List splits = new ArrayList<>(); - TableScan scan = createTableScan(); - long startTime = System.currentTimeMillis(); - try (CloseableIterable fileScanTasks = scan.planFiles()) { - fileScanTasks.forEach(task -> splits.add(createIcebergSysSplit(task))); - } catch (IOException e) { - throw new UserException(e.getMessage(), e); - } finally { - if (getSummaryProfile() != null) { - getSummaryProfile().addExternalTableGetFileScanTasksTime(System.currentTimeMillis() - startTime); - } - } - selectedPartitionNum = 0; - return splits; - } - - private boolean isPositionDeletesSystemTable() { - TableIf targetTable = source.getTargetTable(); - return targetTable instanceof IcebergSysExternalTable - && ((IcebergSysExternalTable) targetTable).isPositionDeletesTable(); - } - - private List doGetPositionDeletesSystemTableSplits() throws UserException { - checkPositionDeletesBackendCompatibility(backendPolicy.getBackends()); - List splits = new ArrayList<>(); - List positionDeleteTasks = new ArrayList<>(); - BatchScan scan = icebergTable.newBatchScan().metricsReporter(new IcebergMetricsReporter()); - - IcebergTableQueryInfo info = getSpecifiedSnapshot(); - if (info != null) { - if (info.getRef() != null) { - scan = scan.useRef(info.getRef()); - } else { - scan = scan.useSnapshot(info.getSnapshotId()); - } - } - - List expressions = new ArrayList<>(); - for (Expr conjunct : conjuncts) { - Expression expression = IcebergUtils.convertToIcebergExpr(conjunct, icebergTable.schema()); - if (expression != null) { - expressions.add(expression); - } - } - for (Expression predicate : expressions) { - scan = scan.filter(predicate); - this.pushdownIcebergPredicates.add(predicate.toString()); - } - - long startTime = System.currentTimeMillis(); - scan = scan.planWith(source.getCatalog().getThreadPoolWithPreAuth()); - try (CloseableIterable scanTasks = scan.planFiles()) { - for (ScanTask task : scanTasks) { - if (!(task instanceof PositionDeletesScanTask)) { - throw new UserException("Unexpected Iceberg position_deletes scan task: " + task); - } - positionDeleteTasks.add((PositionDeletesScanTask) task); - } - } catch (IOException e) { - throw new UserException(e.getMessage(), e); - } finally { - if (getSummaryProfile() != null) { - getSummaryProfile().addExternalTableGetFileScanTasksTime(System.currentTimeMillis() - startTime); - } - } - targetSplitSize = determinePositionDeleteTargetSplitSize(positionDeleteTasks); - for (PositionDeletesScanTask task : positionDeleteTasks) { - for (PositionDeletesScanTask splitTask : splitPositionDeleteScanTask(task)) { - splits.add(createIcebergPositionDeleteSysSplit(splitTask)); - } - } - selectedPartitionNum = 0; - return splits; - } - - @VisibleForTesting - static void checkPositionDeletesBackendCompatibility(Iterable backends) throws UserException { - for (Backend backend : backends) { - if (backend.isSmoothUpgradeSrc()) { - throw new UserException("Iceberg position_deletes system table is unavailable while backend " - + backend.getId() + " is a smooth upgrade source"); - } - } - } - - @Override - public boolean isBatchMode() { - if (isSystemTable) { - isBatchMode = false; - return false; - } - Boolean cached = isBatchMode; - if (cached != null) { - return cached; - } - if (isTableLevelCountStarPushdown()) { - try { - countFromSnapshot = getCountFromSnapshot(); - } catch (UserException e) { - throw new RuntimeException(e); - } - if (countFromSnapshot >= 0) { - tableLevelPushDownCount = true; - isBatchMode = false; - return false; - } - } - - try { - if (createTableScan().snapshot() == null) { - isBatchMode = false; - return false; - } - } catch (UserException e) { - throw new RuntimeException(e); - } - - if (!sessionVariable.getEnableExternalTableBatchMode()) { - isBatchMode = false; - return false; - } - - try { - return preExecutionAuthenticator.execute(() -> { - try (CloseableIterator matchingManifest = - IcebergUtils.getMatchingManifest( - createTableScan().snapshot().dataManifests(icebergTable.io()), - icebergTable.specs(), - createTableScan().filter()).iterator()) { - int cnt = 0; - while (matchingManifest.hasNext()) { - ManifestFile next = matchingManifest.next(); - cnt += next.addedFilesCount() + next.existingFilesCount(); - if (cnt >= sessionVariable.getNumFilesInBatchMode()) { - isBatchMode = true; - return true; - } - } - } - isBatchMode = false; - return false; - }); - } catch (Exception e) { - Optional opt = checkNotSupportedException(e); - if (opt.isPresent()) { - throw opt.get(); - } else { - throw new RuntimeException(ExceptionUtils.getRootCauseMessage(e), e); - } - } - } - - public IcebergTableQueryInfo getSpecifiedSnapshot() throws UserException { - TableSnapshot tableSnapshot = getQueryTableSnapshot(); - TableScanParams scanParams = getScanParams(); - Optional params = Optional.ofNullable(scanParams); - if (tableSnapshot != null || IcebergUtils.isIcebergBranchOrTag(params)) { - return IcebergUtils.getQuerySpecSnapshot( - icebergTable, - Optional.ofNullable(tableSnapshot), - params); - } - return null; - } - - private List getDeleteFileFilters(FileScanTask spitTask) { - List filters = new ArrayList<>(); - for (DeleteFile delete : spitTask.deletes()) { - if (delete.content() == FileContent.POSITION_DELETES) { - filters.add(IcebergDeleteFileFilter.createPositionDelete(delete)); - } else if (delete.content() == FileContent.EQUALITY_DELETES) { - filters.add(IcebergDeleteFileFilter.createEqualityDelete( - delete.path().toString(), delete.equalityFieldIds(), - delete.fileSizeInBytes(), delete.format())); - } else { - throw new IllegalStateException("Unknown delete content: " + delete.content()); - } - } - return filters; - } - - @Override - public TFileFormatType getFileFormatType() throws UserException { - if (isSystemTable) { - return TFileFormatType.FORMAT_JNI; - } - // for table level file format - return toTFileFormatType(IcebergUtils.getFileFormat(icebergTable)); - } - - @Override - public List getPathPartitionKeys() throws UserException { - return getOrderedPathPartitionKeys(); - } - - private void recordManifestCacheAccess(boolean cacheHit) { - if (cacheHit) { - manifestCacheHits++; - } else { - manifestCacheMisses++; - } - } - - private void recordManifestCacheProfile() { - if (!IcebergUtils.isManifestCacheEnabled(source.getCatalog())) { - return; - } - SummaryProfile summaryProfile = SummaryProfile.getSummaryProfile(ConnectContext.get()); - if (summaryProfile == null || summaryProfile.getExecutionSummary() == null) { - return; - } - summaryProfile.getExecutionSummary().addInfoString("Manifest Cache", - String.format("hits=%d, misses=%d, failures=%d", - manifestCacheHits, manifestCacheMisses, manifestCacheFailures)); - } - - @Override - public TableIf getTargetTable() { - return source.getTargetTable(); - } - - @Override - public Map getLocationProperties() throws UserException { - return backendStorageProperties; - } - - @VisibleForTesting - public long getCountFromSnapshot() throws UserException { - IcebergTableQueryInfo info = getSpecifiedSnapshot(); - - Snapshot snapshot = info == null - ? icebergTable.currentSnapshot() : icebergTable.snapshot(info.getSnapshotId()); - - // empty table - if (snapshot == null) { - return 0; - } - - return IcebergUtils.getCountFromSummary(snapshot.summary(), sessionVariable.ignoreIcebergDanglingDelete); - } - - @Override - protected void toThrift(TPlanNode planNode) { - super.toThrift(planNode); - } - - @Override - public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { - String base = super.getNodeExplainString(prefix, detailLevel); - StringBuilder builder = new StringBuilder(base); - - if (detailLevel == TExplainLevel.VERBOSE && IcebergUtils.isManifestCacheEnabled(source.getCatalog())) { - builder.append(prefix).append("manifest cache: hits=").append(manifestCacheHits) - .append(", misses=").append(manifestCacheMisses) - .append(", failures=").append(manifestCacheFailures).append("\n"); - } - - if (!pushdownIcebergPredicates.isEmpty()) { - StringBuilder sb = new StringBuilder(); - for (String predicate : pushdownIcebergPredicates) { - sb.append(prefix).append(prefix).append(predicate).append("\n"); - } - builder.append(String.format("%sicebergPredicatePushdown=\n%s\n", prefix, sb)); - } - return builder.toString(); - } - - private void assignCountToSplits(List splits, long totalCount) { - if (splits.isEmpty()) { - return; - } - int size = splits.size(); - long countPerSplit = totalCount / size; - for (int i = 0; i < size - 1; i++) { - ((IcebergSplit) splits.get(i)).setTableLevelRowCount(countPerSplit); - } - ((IcebergSplit) splits.get(size - 1)).setTableLevelRowCount(countPerSplit + totalCount % size); - } - - @Override - public int numApproximateSplits() { - return NUM_SPLITS_PER_PARTITION * partitionMapInfos.size() > 0 ? partitionMapInfos.size() : 1; - } - - private Optional checkNotSupportedException(Exception e) { - if (e instanceof NullPointerException) { - /* - Caused by: java.lang.NullPointerException: Type cannot be null - at org.apache.iceberg.relocated.com.google.common.base.Preconditions.checkNotNull - (Preconditions.java:921) ~[iceberg-bundled-guava-1.4.3.jar:?] - at org.apache.iceberg.types.Types$NestedField.(Types.java:447) ~[iceberg-api-1.4.3.jar:?] - at org.apache.iceberg.types.Types$NestedField.optional(Types.java:416) ~[iceberg-api-1.4.3.jar:?] - at org.apache.iceberg.PartitionSpec.partitionType(PartitionSpec.java:132) ~[iceberg-api-1.4.3.jar:?] - at org.apache.iceberg.DeleteFileIndex.lambda$new$0(DeleteFileIndex.java:97) ~[iceberg-core-1.4.3.jar:?] - at org.apache.iceberg.relocated.com.google.common.collect.RegularImmutableMap.forEach - (RegularImmutableMap.java:297) ~[iceberg-bundled-guava-1.4.3.jar:?] - at org.apache.iceberg.DeleteFileIndex.(DeleteFileIndex.java:97) ~[iceberg-core-1.4.3.jar:?] - at org.apache.iceberg.DeleteFileIndex.(DeleteFileIndex.java:71) ~[iceberg-core-1.4.3.jar:?] - at org.apache.iceberg.DeleteFileIndex$Builder.build(DeleteFileIndex.java:578) ~[iceberg-core-1.4.3.jar:?] - at org.apache.iceberg.ManifestGroup.plan(ManifestGroup.java:183) ~[iceberg-core-1.4.3.jar:?] - at org.apache.iceberg.ManifestGroup.planFiles(ManifestGroup.java:170) ~[iceberg-core-1.4.3.jar:?] - at org.apache.iceberg.DataTableScan.doPlanFiles(DataTableScan.java:89) ~[iceberg-core-1.4.3.jar:?] - at org.apache.iceberg.SnapshotScan.planFiles(SnapshotScan.java:139) ~[iceberg-core-1.4.3.jar:?] - at org.apache.doris.datasource.iceberg.source.IcebergScanNode.doGetSplits - (IcebergScanNode.java:209) ~[doris-fe.jar:1.2-SNAPSHOT] - EXAMPLE: - CREATE TABLE iceberg_tb(col1 INT,col2 STRING) USING ICEBERG PARTITIONED BY (bucket(10,col2)); - INSERT INTO iceberg_tb VALUES( ... ); - ALTER TABLE iceberg_tb DROP PARTITION FIELD bucket(10,col2); - ALTER TABLE iceberg_tb DROP COLUMNS col2; - Link: https://github.com/apache/iceberg/pull/10755 - */ - LOG.warn("Unable to plan for iceberg table {}", this.desc.getTable().getName(), e); - return Optional.of( - new NotSupportedException("Unable to plan for this table. " - + "Maybe read Iceberg table with dropped old partition column. Cause: " - + Util.getRootCauseMessage(e))); - } - return Optional.empty(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergSource.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergSource.java deleted file mode 100644 index be1ce7521061d8..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergSource.java +++ /dev/null @@ -1,37 +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.datasource.iceberg.source; - -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.MetaNotFoundException; -import org.apache.doris.datasource.ExternalCatalog; - -public interface IcebergSource { - - TupleDescriptor getDesc(); - - org.apache.iceberg.Table getIcebergTable() throws MetaNotFoundException; - - TableIf getTargetTable(); - - ExternalCatalog getCatalog(); - - String getFileFormat() throws DdlException, MetaNotFoundException; -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergSplit.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergSplit.java deleted file mode 100644 index eeeff694b8ebce..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergSplit.java +++ /dev/null @@ -1,101 +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.datasource.iceberg.source; - -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.FileSplit; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.thrift.TFileFormatType; - -import lombok.Data; -import org.apache.iceberg.DeleteFile; -import org.apache.iceberg.FileFormat; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -@Data -public class IcebergSplit extends FileSplit { - private static final LocationPath DUMMY_PATH = LocationPath.of("/dummyPath"); - - // Doris will convert the schema in FileSystem to achieve the function of natively reading files. - // For example, s3a:// will be converted to s3://. - // The position delete file of iceberg will record the full path of the datafile, which includes the schema. - // When comparing datafile with position delete, the converted path cannot be used, - // but the original datafile path must be used. - private final String originalPath; - private Integer formatVersion; - private List deleteFiles = new ArrayList<>(); - private List deleteFileFilters = new ArrayList<>(); - private Map config; - // tableLevelRowCount will be set only table-level count push down opt is available. - private long tableLevelRowCount = -1; - // Partition values are used to do runtime filter partition pruning. - private Map icebergPartitionValues = null; - private Integer partitionSpecId = null; - private String partitionDataJson = null; - private Long firstRowId = null; - private Long lastUpdatedSequenceNumber = null; - private String serializedSplit; - // maybe mixed file format type in one table. so need record it for every split - private FileFormat splitFileFormat; - private boolean positionDeleteSystemTableSplit = false; - private TFileFormatType positionDeleteFileFormat; - private int positionDeleteContent; - private String positionDeleteOriginalPath; - private String positionDeleteReferencedDataFilePath; - private Long positionDeleteContentOffset; - private Long positionDeleteContentSizeInBytes; - - // File path will be changed if the file is modified, so there's no need to get modification time. - public IcebergSplit(LocationPath file, long start, long length, long fileLength, String[] hosts, - Integer formatVersion, Map config, - List partitionList, String originalPath) { - super(file, start, length, fileLength, 0, hosts, partitionList); - this.formatVersion = formatVersion; - this.config = config; - this.originalPath = originalPath; - this.selfSplitWeight = length; - } - - public void setDeleteFileFilters(List deleteFiles, List deleteFileFilters) { - this.deleteFiles = deleteFiles; - this.deleteFileFilters = deleteFileFilters; - this.selfSplitWeight += deleteFileFilters.stream().mapToLong(IcebergDeleteFileFilter::getFilesize).sum(); - } - - public static IcebergSplit newSysTableSplit(String serializedSplit, long rowCount) { - IcebergSplit split = new IcebergSplit(DUMMY_PATH, 0, 0, 0, null, null, - Collections.emptyMap(), - Collections.emptyList(), DUMMY_PATH.toStorageLocation().toString()); - split.setSerializedSplit(serializedSplit); - split.setSelfSplitWeight(Math.max(rowCount, 1L)); - return split; - } - - public static IcebergSplit newPositionDeleteSysTableSplit(LocationPath file, long start, long length, - long fileLength, Map config, String originalPath) { - IcebergSplit split = new IcebergSplit(file, start, length, fileLength, null, null, config, - Collections.emptyList(), originalPath); - split.setPositionDeleteSystemTableSplit(true); - split.setSelfSplitWeight(Math.max(length, 1L)); - return split; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergTableQueryInfo.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergTableQueryInfo.java deleted file mode 100644 index 0300d1e9017b27..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergTableQueryInfo.java +++ /dev/null @@ -1,42 +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.datasource.iceberg.source; - -public class IcebergTableQueryInfo { - private long snapshotId; - private String ref; - private int schemaId; - - public IcebergTableQueryInfo(long snapshotId, String ref, int schemaId) { - this.snapshotId = snapshotId; - this.ref = ref; - this.schemaId = schemaId; - } - - public long getSnapshotId() { - return snapshotId; - } - - public String getRef() { - return ref; - } - - public int getSchemaId() { - return schemaId; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/infoschema/ExternalInfoSchemaDatabase.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/infoschema/ExternalInfoSchemaDatabase.java index e8ab0690e17c7f..05425dc6eaace0 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/infoschema/ExternalInfoSchemaDatabase.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/infoschema/ExternalInfoSchemaDatabase.java @@ -22,7 +22,7 @@ import org.apache.doris.datasource.ExternalCatalog; import org.apache.doris.datasource.ExternalDatabase; import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.InitDatabaseLog.Type; +import org.apache.doris.datasource.log.InitDatabaseLog.Type; import com.google.common.collect.Lists; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/infoschema/ExternalMysqlDatabase.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/infoschema/ExternalMysqlDatabase.java index da40dc34c604ee..20ef7c65149931 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/infoschema/ExternalMysqlDatabase.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/infoschema/ExternalMysqlDatabase.java @@ -22,7 +22,7 @@ import org.apache.doris.datasource.ExternalCatalog; import org.apache.doris.datasource.ExternalDatabase; import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.InitDatabaseLog.Type; +import org.apache.doris.datasource.log.InitDatabaseLog.Type; import com.google.common.collect.Lists; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/LakeSoulExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/LakeSoulExternalCatalog.java deleted file mode 100644 index c374d75d0ea374..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/LakeSoulExternalCatalog.java +++ /dev/null @@ -1,109 +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.datasource.lakesoul; - -import org.apache.doris.datasource.CatalogProperty; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.InitCatalogLog; -import org.apache.doris.datasource.SessionContext; - -import com.dmetasoul.lakesoul.meta.DBUtil; -import com.dmetasoul.lakesoul.meta.entity.PartitionInfo; -import com.dmetasoul.lakesoul.meta.entity.TableInfo; -import com.google.common.collect.Lists; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.List; -import java.util.Map; - -/** - * @deprecated LakeSoul catalog support has been deprecated and will be removed in a future version. - */ -@Deprecated -public class LakeSoulExternalCatalog extends ExternalCatalog { - - private static final Logger LOG = LogManager.getLogger(LakeSoulExternalCatalog.class); - - // private transient DBManager lakesoulMetadataManager; - - private final Map props; - - public LakeSoulExternalCatalog(long catalogId, String name, String resource, Map props, - String comment) { - super(catalogId, name, InitCatalogLog.Type.LAKESOUL, comment); - this.props = props; - catalogProperty = new CatalogProperty(resource, props); - initLocalObjectsImpl(); - } - - @Override - protected List listDatabaseNames() { - initLocalObjectsImpl(); - // return lakesoulMetadataManager.listNamespaces(); - return Lists.newArrayList(); - } - - @Override - protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { - // makeSureInitialized(); - // List tifs = lakesoulMetadataManager.getTableInfosByNamespace(dbName); - // List tableNames = Lists.newArrayList(); - // for (TableInfo item : tifs) { - // tableNames.add(item.getTableName()); - // } - // return tableNames; - return Lists.newArrayList(); - } - - @Override - public boolean tableExist(SessionContext ctx, String dbName, String tblName) { - // makeSureInitialized(); - // TableInfo tableInfo = lakesoulMetadataManager.getTableInfoByNameAndNamespace(tblName, dbName); - // return null != tableInfo; - return false; - } - - @Override - protected void initLocalObjectsImpl() { - if (props != null) { - if (props.containsKey(DBUtil.urlKey)) { - System.setProperty(DBUtil.urlKey, props.get(DBUtil.urlKey)); - } - if (props.containsKey(DBUtil.usernameKey)) { - System.setProperty(DBUtil.usernameKey, props.get(DBUtil.usernameKey)); - } - if (props.containsKey(DBUtil.passwordKey)) { - System.setProperty(DBUtil.passwordKey, props.get(DBUtil.passwordKey)); - } - } - // lakesoulMetadataManager = new DBManager(); - } - - public TableInfo getLakeSoulTable(String dbName, String tblName) { - makeSureInitialized(); - // return lakesoulMetadataManager.getTableInfoByNameAndNamespace(tblName, dbName); - return null; - } - - public List listPartitionInfo(String tableId) { - makeSureInitialized(); - // return lakesoulMetadataManager.getAllPartitionInfo(tableId); - return Lists.newArrayList(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/LakeSoulExternalDatabase.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/LakeSoulExternalDatabase.java deleted file mode 100644 index c753397417cce7..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/LakeSoulExternalDatabase.java +++ /dev/null @@ -1,42 +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.datasource.lakesoul; - -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.InitDatabaseLog; - -/** - * @deprecated LakeSoul catalog support has been deprecated and will be removed in a future version. - */ -@Deprecated -public class LakeSoulExternalDatabase extends ExternalDatabase { - - public LakeSoulExternalDatabase(ExternalCatalog extCatalog, long id, String name, String remoteName) { - super(extCatalog, id, name, remoteName, InitDatabaseLog.Type.LAKESOUL); - } - - @Override - public LakeSoulExternalTable buildTableInternal(String remoteTableName, String localTableName, long tblId, - ExternalCatalog catalog, - ExternalDatabase db) { - return new LakeSoulExternalTable(tblId, localTableName, remoteTableName, (LakeSoulExternalCatalog) extCatalog, - (LakeSoulExternalDatabase) db); - } -} - diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/LakeSoulExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/LakeSoulExternalTable.java deleted file mode 100644 index 22d2a94c092f50..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/LakeSoulExternalTable.java +++ /dev/null @@ -1,217 +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.datasource.lakesoul; - -import org.apache.doris.catalog.ArrayType; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.StructType; -import org.apache.doris.catalog.Type; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.statistics.AnalysisInfo; -import org.apache.doris.statistics.BaseAnalysisTask; -import org.apache.doris.statistics.ExternalAnalysisTask; -import org.apache.doris.thrift.TLakeSoulTable; -import org.apache.doris.thrift.TTableDescriptor; -import org.apache.doris.thrift.TTableType; - -import com.dmetasoul.lakesoul.meta.DBUtil; -import com.dmetasoul.lakesoul.meta.entity.PartitionInfo; -import com.dmetasoul.lakesoul.meta.entity.TableInfo; -import com.google.common.collect.Lists; -import org.apache.arrow.util.Preconditions; -import org.apache.arrow.vector.types.pojo.ArrowType; -import org.apache.arrow.vector.types.pojo.Field; -import org.apache.arrow.vector.types.pojo.Schema; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.IOException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -/** - * @deprecated LakeSoul catalog support has been deprecated and will be removed in a future version. - */ -@Deprecated -public class LakeSoulExternalTable extends ExternalTable { - private static final Logger LOG = LogManager.getLogger(LakeSoulExternalTable.class); - public static final int LAKESOUL_TIMESTAMP_SCALE_MS = 6; - - public final String tableId; - - public LakeSoulExternalTable(long id, String name, String remoteName, LakeSoulExternalCatalog catalog, - LakeSoulExternalDatabase db) { - super(id, name, remoteName, catalog, db, TableType.LAKESOUl_EXTERNAL_TABLE); - TableInfo tableInfo = getLakeSoulTableInfo(); - if (tableInfo == null) { - throw new RuntimeException(String.format("LakeSoul table %s.%s does not exist", dbName, name)); - } - tableId = tableInfo.getTableId(); - } - - @Override - public BaseAnalysisTask createAnalysisTask(AnalysisInfo info) { - makeSureInitialized(); - return new ExternalAnalysisTask(info); - } - - private Type arrowFiledToDorisType(Field field) { - ArrowType dt = field.getType(); - if (dt instanceof ArrowType.Bool) { - return Type.BOOLEAN; - } else if (dt instanceof ArrowType.Int) { - ArrowType.Int type = (ArrowType.Int) dt; - switch (type.getBitWidth()) { - case 8: - return Type.TINYINT; - case 16: - return Type.SMALLINT; - case 32: - return Type.INT; - case 64: - return Type.BIGINT; - default: - throw new IllegalArgumentException("Invalid integer bit width: " - + type.getBitWidth() - + " for LakeSoul table: " - + getTableIdentifier()); - } - } else if (dt instanceof ArrowType.FloatingPoint) { - ArrowType.FloatingPoint type = (ArrowType.FloatingPoint) dt; - switch (type.getPrecision()) { - case SINGLE: - return Type.FLOAT; - case DOUBLE: - return Type.DOUBLE; - default: - throw new IllegalArgumentException("Invalid floating point precision: " - + type.getPrecision() - + " for LakeSoul table: " - + getTableIdentifier()); - } - } else if (dt instanceof ArrowType.Utf8) { - return Type.STRING; - } else if (dt instanceof ArrowType.Decimal) { - ArrowType.Decimal decimalType = (ArrowType.Decimal) dt; - return ScalarType.createDecimalType(PrimitiveType.DECIMAL64, decimalType.getPrecision(), - decimalType.getScale()); - } else if (dt instanceof ArrowType.Date) { - return ScalarType.createDateV2Type(); - } else if (dt instanceof ArrowType.Timestamp) { - ArrowType.Timestamp tsType = (ArrowType.Timestamp) dt; - int scale = LAKESOUL_TIMESTAMP_SCALE_MS; - switch (tsType.getUnit()) { - case SECOND: - scale = 0; - break; - case MILLISECOND: - scale = 3; - break; - case MICROSECOND: - scale = 6; - break; - case NANOSECOND: - scale = 9; - break; - default: - break; - } - return ScalarType.createDatetimeV2Type(scale); - } else if (dt instanceof ArrowType.List) { - List children = field.getChildren(); - Preconditions.checkArgument(children.size() == 1, - "Lists have one child Field. Found: %s", children.isEmpty() ? "none" : children); - return ArrayType.create(arrowFiledToDorisType(children.get(0)), children.get(0).isNullable()); - } else if (dt instanceof ArrowType.Struct) { - List children = field.getChildren(); - return new StructType(children.stream().map(this::arrowFiledToDorisType).collect(Collectors.toList())); - } - throw new IllegalArgumentException("Cannot transform type " - + dt - + " to doris type" - + " for LakeSoul table " - + getTableIdentifier()); - } - - @Override - public TTableDescriptor toThrift() { - List schema = getFullSchema(); - TLakeSoulTable tLakeSoulTable = new TLakeSoulTable(); - tLakeSoulTable.setDbName(dbName); - tLakeSoulTable.setTableName(name); - tLakeSoulTable.setProperties(new HashMap<>()); - TTableDescriptor tTableDescriptor = new TTableDescriptor(getId(), TTableType.HIVE_TABLE, schema.size(), 0, - getName(), dbName); - tTableDescriptor.setLakesoulTable(tLakeSoulTable); - return tTableDescriptor; - - } - - @Override - public Optional initSchema() { - TableInfo tableInfo = ((LakeSoulExternalCatalog) catalog).getLakeSoulTable(dbName, name); - String tableSchema = tableInfo.getTableSchema(); - DBUtil.TablePartitionKeys partitionKeys = DBUtil.parseTableInfoPartitions(tableInfo.getPartitions()); - Schema schema; - LOG.info("tableSchema={}", tableSchema); - try { - schema = Schema.fromJSON(tableSchema); - } catch (IOException e) { - throw new RuntimeException(e); - } - - List tmpSchema = Lists.newArrayListWithCapacity(schema.getFields().size()); - for (Field field : schema.getFields()) { - boolean isKey = - partitionKeys.primaryKeys.contains(field.getName()) - || partitionKeys.rangeKeys.contains(field.getName()); - tmpSchema.add(new Column(field.getName(), arrowFiledToDorisType(field), - isKey, - null, field.isNullable(), - field.getMetadata().getOrDefault("comment", null), - true, schema.getFields().indexOf(field))); - } - return Optional.of(new SchemaCacheValue(tmpSchema)); - } - - public TableInfo getLakeSoulTableInfo() { - return ((LakeSoulExternalCatalog) catalog).getLakeSoulTable(dbName, name); - } - - public List listPartitionInfo() { - return ((LakeSoulExternalCatalog) catalog).listPartitionInfo(tableId); - } - - public String tablePath() { - return ((LakeSoulExternalCatalog) catalog).getLakeSoulTable(dbName, name).getTablePath(); - } - - public Map getHadoopProperties() { - return catalog.getCatalogProperty().getHadoopProperties(); - } - - public String getTableIdentifier() { - return dbName + "." + name; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/LakeSoulUtils.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/LakeSoulUtils.java deleted file mode 100644 index 8644a175ebc407..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/LakeSoulUtils.java +++ /dev/null @@ -1,528 +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.datasource.lakesoul; - -import org.apache.doris.analysis.BinaryPredicate; -import org.apache.doris.analysis.BoolLiteral; -import org.apache.doris.analysis.CastExpr; -import org.apache.doris.analysis.CompoundPredicate; -import org.apache.doris.analysis.DateLiteral; -import org.apache.doris.analysis.DecimalLiteral; -import org.apache.doris.analysis.Expr; -import org.apache.doris.analysis.FloatLiteral; -import org.apache.doris.analysis.InPredicate; -import org.apache.doris.analysis.IntLiteral; -import org.apache.doris.analysis.IsNullPredicate; -import org.apache.doris.analysis.LiteralExpr; -import org.apache.doris.analysis.NullLiteral; -import org.apache.doris.analysis.SlotRef; -import org.apache.doris.analysis.StringLiteral; -import org.apache.doris.planner.ColumnBound; -import org.apache.doris.planner.ColumnRange; - -import com.dmetasoul.lakesoul.lakesoul.io.substrait.SubstraitUtil; -import com.dmetasoul.lakesoul.meta.entity.PartitionInfo; -import com.google.common.collect.BoundType; -import com.google.common.collect.Range; -import com.google.common.collect.RangeSet; -import io.substrait.expression.Expression; -import io.substrait.extension.DefaultExtensionCatalog; -import io.substrait.type.Type; -import io.substrait.type.TypeCreator; -import org.apache.arrow.vector.types.pojo.Field; -import org.apache.arrow.vector.types.pojo.Schema; - -import java.io.IOException; -import java.time.Instant; -import java.time.LocalDate; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * @deprecated LakeSoul catalog support has been deprecated and will be removed in a future version. - */ -@Deprecated -public class LakeSoulUtils { - - public static final String FILE_NAMES = "file_paths"; - public static final String PRIMARY_KEYS = "primary_keys"; - public static final String SCHEMA_JSON = "table_schema"; - public static final String PARTITION_DESC = "partition_descs"; - public static final String REQUIRED_FIELDS = "required_fields"; - public static final String OPTIONS = "options"; - public static final String SUBSTRAIT_PREDICATE = "substrait_predicate"; - public static final String CDC_COLUMN = "lakesoul_cdc_change_column"; - public static final String LIST_DELIM = ";"; - public static final String PARTITIONS_KV_DELIM = "="; - public static final String FS_S3A_ACCESS_KEY = "fs.s3a.access.key"; - public static final String FS_S3A_SECRET_KEY = "fs.s3a.secret.key"; - public static final String FS_S3A_ENDPOINT = "fs.s3a.endpoint"; - public static final String FS_S3A_REGION = "fs.s3a.endpoint.region"; - public static final String FS_S3A_PATH_STYLE_ACCESS = "fs.s3a.path.style.access"; - - private static final OffsetDateTime EPOCH; - private static final LocalDate EPOCH_DAY; - - static { - EPOCH = Instant.ofEpochSecond(0L).atOffset(ZoneOffset.UTC); - EPOCH_DAY = EPOCH.toLocalDate(); - } - - public static List applyPartitionFilters( - List allPartitionInfo, - String tableName, - Schema partitionArrowSchema, - Map columnNameToRange - ) throws IOException { - - Expression conjunctionFilter = null; - for (Field field : partitionArrowSchema.getFields()) { - ColumnRange columnRange = columnNameToRange.get(field.getName()); - if (columnRange != null) { - Expression expr = columnRangeToSubstraitFilter(field, columnRange); - if (expr != null) { - if (conjunctionFilter == null) { - conjunctionFilter = expr; - } else { - conjunctionFilter = SubstraitUtil.and(conjunctionFilter, expr); - } - } - } - } - return SubstraitUtil.applyPartitionFilters( - allPartitionInfo, - partitionArrowSchema, - SubstraitUtil.substraitExprToProto(conjunctionFilter, tableName) - ); - } - - public static Expression columnRangeToSubstraitFilter( - Field columnField, - ColumnRange columnRange - ) throws IOException { - Optional> rangeSetOpt = columnRange.getRangeSet(); - if (columnRange.hasConjunctiveIsNull() || !rangeSetOpt.isPresent()) { - return SubstraitUtil.CONST_TRUE; - } else { - RangeSet rangeSet = rangeSetOpt.get(); - if (rangeSet.isEmpty()) { - return SubstraitUtil.CONST_TRUE; - } else { - Expression conjunctionFilter = null; - for (Range range : rangeSet.asRanges()) { - Expression expr = rangeToSubstraitFilter(columnField, range); - if (expr != null) { - if (conjunctionFilter == null) { - conjunctionFilter = expr; - } else { - conjunctionFilter = SubstraitUtil.or(conjunctionFilter, expr); - } - } - } - return conjunctionFilter; - } - } - } - - public static Expression rangeToSubstraitFilter(Field columnField, Range range) throws IOException { - if (!range.hasLowerBound() && !range.hasUpperBound()) { - // Range.all() - return SubstraitUtil.CONST_TRUE; - } else { - Expression upper = SubstraitUtil.CONST_TRUE; - if (range.hasUpperBound()) { - String func = range.upperBoundType() == BoundType.OPEN ? "lt:any_any" : "lte:any_any"; - Expression left = SubstraitUtil.arrowFieldToSubstraitField(columnField); - Expression right = SubstraitUtil.anyToSubstraitLiteral( - SubstraitUtil.arrowFieldToSubstraitType(columnField), - ((ColumnBound) range.upperEndpoint()).getValue().getRealValue()); - upper = SubstraitUtil.makeBinary( - left, - right, - DefaultExtensionCatalog.FUNCTIONS_COMPARISON, - func, - TypeCreator.NULLABLE.BOOLEAN - ); - } - Expression lower = SubstraitUtil.CONST_TRUE; - if (range.hasLowerBound()) { - String func = range.lowerBoundType() == BoundType.OPEN ? "gt:any_any" : "gte:any_any"; - Expression left = SubstraitUtil.arrowFieldToSubstraitField(columnField); - Expression right = SubstraitUtil.anyToSubstraitLiteral( - SubstraitUtil.arrowFieldToSubstraitType(columnField), - ((ColumnBound) range.lowerEndpoint()).getValue().getRealValue()); - lower = SubstraitUtil.makeBinary( - left, - right, - DefaultExtensionCatalog.FUNCTIONS_COMPARISON, - func, - TypeCreator.NULLABLE.BOOLEAN - ); - } - return SubstraitUtil.and(upper, lower); - } - } - - public static io.substrait.proto.Plan getPushPredicate( - List conjuncts, - String tableName, - Schema tableSchema, - Schema partitionArrowSchema, - Map properties, - boolean incRead - ) throws IOException { - - Set partitionColumn = - partitionArrowSchema - .getFields() - .stream() - .map(Field::getName) - .collect(Collectors.toSet()); - Expression conjunctionFilter = null; - String cdcColumn = properties.get(CDC_COLUMN); - if (cdcColumn != null && !incRead) { - conjunctionFilter = SubstraitUtil.cdcColumnMergeOnReadFilter(tableSchema.findField(cdcColumn)); - } - for (Expr expr : conjuncts) { - if (!isAllPartitionPredicate(expr, partitionColumn)) { - Expression predicate = convertToSubstraitExpr(expr, tableSchema); - if (predicate != null) { - if (conjunctionFilter == null) { - conjunctionFilter = predicate; - } else { - conjunctionFilter = SubstraitUtil.and(conjunctionFilter, predicate); - } - } - } - } - if (conjunctionFilter == null) { - return null; - } - return SubstraitUtil.substraitExprToProto(conjunctionFilter, tableName); - } - - public static boolean isAllPartitionPredicate(Expr predicate, Set partitionColumns) { - if (predicate == null) { - return false; - } - if (predicate instanceof CompoundPredicate) { - CompoundPredicate compoundPredicate = (CompoundPredicate) predicate; - return isAllPartitionPredicate(compoundPredicate.getChild(0), partitionColumns) - && isAllPartitionPredicate(compoundPredicate.getChild(1), partitionColumns); - } - // Make sure the col slot is always first - SlotRef slotRef = convertDorisExprToSlotRef(predicate.getChild(0)); - LiteralExpr literalExpr = convertDorisExprToLiteralExpr(predicate.getChild(1)); - if (slotRef == null || literalExpr == null) { - return false; - } - String colName = slotRef.getColumnName(); - return partitionColumns.contains(colName); - - } - - public static SlotRef convertDorisExprToSlotRef(Expr expr) { - SlotRef slotRef = null; - if (expr instanceof SlotRef) { - slotRef = (SlotRef) expr; - } else if (expr instanceof CastExpr) { - if (expr.getChild(0) instanceof SlotRef) { - slotRef = (SlotRef) expr.getChild(0); - } - } - return slotRef; - } - - public static LiteralExpr convertDorisExprToLiteralExpr(Expr expr) { - LiteralExpr literalExpr = null; - if (expr instanceof LiteralExpr) { - literalExpr = (LiteralExpr) expr; - } else if (expr instanceof CastExpr) { - if (expr.getChild(0) instanceof LiteralExpr) { - literalExpr = (LiteralExpr) expr.getChild(0); - } - } - return literalExpr; - } - - public static Expression convertToSubstraitExpr(Expr predicate, Schema tableSchema) throws IOException { - if (predicate == null) { - return null; - } - if (predicate instanceof BoolLiteral) { - BoolLiteral boolLiteral = (BoolLiteral) predicate; - boolean value = boolLiteral.getValue(); - if (value) { - return SubstraitUtil.CONST_TRUE; - } else { - return SubstraitUtil.CONST_FALSE; - } - } - if (predicate instanceof CompoundPredicate) { - CompoundPredicate compoundPredicate = (CompoundPredicate) predicate; - switch (compoundPredicate.getOp()) { - case AND: { - Expression left = convertToSubstraitExpr(compoundPredicate.getChild(0), tableSchema); - Expression right = convertToSubstraitExpr(compoundPredicate.getChild(1), tableSchema); - if (left != null && right != null) { - return SubstraitUtil.and(left, right); - } else if (left != null) { - return left; - } else { - return right; - } - } - case OR: { - Expression left = convertToSubstraitExpr(compoundPredicate.getChild(0), tableSchema); - Expression right = convertToSubstraitExpr(compoundPredicate.getChild(1), tableSchema); - if (left != null && right != null) { - return SubstraitUtil.or(left, right); - } - return null; - } - case NOT: { - Expression child = convertToSubstraitExpr(compoundPredicate.getChild(0), tableSchema); - if (child != null) { - return SubstraitUtil.not(child); - } - return null; - } - default: - return null; - } - } else if (predicate instanceof InPredicate) { - InPredicate inExpr = (InPredicate) predicate; - SlotRef slotRef = convertDorisExprToSlotRef(inExpr.getChild(0)); - if (slotRef == null) { - return null; - } - String colName = slotRef.getColumnName(); - Field field = tableSchema.findField(colName); - Expression fieldRef = SubstraitUtil.arrowFieldToSubstraitField(field); - - colName = field.getName(); - Type type = field.getType().accept( - new SubstraitUtil.ArrowTypeToSubstraitTypeConverter(field.isNullable()) - ); - List valueList = new ArrayList<>(); - for (int i = 1; i < inExpr.getChildren().size(); ++i) { - if (!(inExpr.getChild(i) instanceof LiteralExpr)) { - return null; - } - LiteralExpr literalExpr = (LiteralExpr) inExpr.getChild(i); - Object value = extractDorisLiteral(type, literalExpr); - if (value == null) { - return null; - } - valueList.add(SubstraitUtil.anyToSubstraitLiteral(type, value)); - } - if (inExpr.isNotIn()) { - // not in - return SubstraitUtil.notIn(fieldRef, valueList); - } else { - // in - return SubstraitUtil.in(fieldRef, valueList); - } - } - return convertBinaryExpr(predicate, tableSchema); - } - - private static Expression convertBinaryExpr(Expr dorisExpr, Schema tableSchema) throws IOException { - // Make sure the col slot is always first - SlotRef slotRef = convertDorisExprToSlotRef(dorisExpr.getChild(0)); - LiteralExpr literalExpr = convertDorisExprToLiteralExpr(dorisExpr.getChild(1)); - if (slotRef == null || literalExpr == null) { - return null; - } - String colName = slotRef.getColumnName(); - Field field = tableSchema.findField(colName); - Expression fieldRef = SubstraitUtil.arrowFieldToSubstraitField(field); - - Type type = field.getType().accept( - new SubstraitUtil.ArrowTypeToSubstraitTypeConverter(field.isNullable()) - ); - Object value = extractDorisLiteral(type, literalExpr); - if (value == null) { - if (dorisExpr instanceof BinaryPredicate - && ((BinaryPredicate) dorisExpr).getOp() == BinaryPredicate.Operator.EQ_FOR_NULL - && literalExpr instanceof NullLiteral) { - return SubstraitUtil.makeUnary( - fieldRef, - DefaultExtensionCatalog.FUNCTIONS_COMPARISON, - "is_null:any", - TypeCreator.NULLABLE.BOOLEAN); - } else { - return null; - } - } - Expression literal = SubstraitUtil.anyToSubstraitLiteral( - type, - value - ); - - String namespace; - String func; - if (dorisExpr instanceof BinaryPredicate) { - BinaryPredicate.Operator op = ((BinaryPredicate) dorisExpr).getOp(); - switch (op) { - case EQ: - namespace = DefaultExtensionCatalog.FUNCTIONS_COMPARISON; - func = "equal:any_any"; - break; - case EQ_FOR_NULL: - namespace = DefaultExtensionCatalog.FUNCTIONS_COMPARISON; - func = "is_null:any"; - break; - case NE: - namespace = DefaultExtensionCatalog.FUNCTIONS_COMPARISON; - func = "not_equal:any_any"; - break; - case GE: - namespace = DefaultExtensionCatalog.FUNCTIONS_COMPARISON; - func = "gte:any_any"; - break; - case GT: - namespace = DefaultExtensionCatalog.FUNCTIONS_COMPARISON; - func = "gt:any_any"; - break; - case LE: - namespace = DefaultExtensionCatalog.FUNCTIONS_COMPARISON; - func = "lte:any_any"; - break; - case LT: - namespace = DefaultExtensionCatalog.FUNCTIONS_COMPARISON; - func = "lt:any_any"; - break; - default: - return null; - } - } else if (dorisExpr instanceof IsNullPredicate) { - if (((IsNullPredicate) dorisExpr).isNotNull()) { - namespace = DefaultExtensionCatalog.FUNCTIONS_COMPARISON; - func = "is_not_null:any"; - } else { - namespace = DefaultExtensionCatalog.FUNCTIONS_COMPARISON; - func = "is_null:any"; - } - } else { - return null; - } - return SubstraitUtil.makeBinary(fieldRef, literal, namespace, func, TypeCreator.NULLABLE.BOOLEAN); - } - - public static Object extractDorisLiteral(Type type, LiteralExpr expr) { - - if (expr instanceof BoolLiteral) { - if (type instanceof Type.Bool) { - return ((BoolLiteral) expr).getValue(); - } - if (type instanceof Type.Str) { - return expr.getStringValue(); - } - } else if (expr instanceof DateLiteral) { - DateLiteral dateLiteral = (DateLiteral) expr; - if (type instanceof Type.Date) { - if (dateLiteral.getType().isDatetimeV2() || dateLiteral.getType().isDatetime()) { - return null; - } - return (int) LocalDate.of((int) dateLiteral.getYear(), - (int) dateLiteral.getMonth(), - (int) dateLiteral.getDay()).toEpochDay(); - } - if (type instanceof Type.TimestampTZ || type instanceof Type.Timestamp) { - return dateLiteral.getLongValue(); - } - if (type instanceof Type.Str) { - return expr.getStringValue(); - } - } else if (expr instanceof DecimalLiteral) { - DecimalLiteral decimalLiteral = (DecimalLiteral) expr; - if (type instanceof Type.Decimal) { - return decimalLiteral.getValue(); - } else if (type instanceof Type.FP64) { - return decimalLiteral.getDoubleValue(); - } - if (type instanceof Type.Str) { - return expr.getStringValue(); - } - } else if (expr instanceof FloatLiteral) { - FloatLiteral floatLiteral = (FloatLiteral) expr; - - if (floatLiteral.getType() == org.apache.doris.catalog.Type.FLOAT) { - return type instanceof Type.FP32 - || type instanceof Type.FP64 - || type instanceof Type.Decimal ? ((FloatLiteral) expr).getValue() : null; - } else { - return type instanceof Type.FP64 - || type instanceof Type.Decimal ? ((FloatLiteral) expr).getValue() : null; - } - } else if (expr instanceof IntLiteral) { - if (type instanceof Type.I8 - || type instanceof Type.I16 - || type instanceof Type.I32 - || type instanceof Type.I64 - || type instanceof Type.FP32 - || type instanceof Type.FP64 - || type instanceof Type.Decimal - || type instanceof Type.Date - ) { - return expr.getRealValue(); - } - if (!expr.getType().isInteger32Type()) { - if (type instanceof Type.Time || type instanceof Type.Timestamp || type instanceof Type.TimestampTZ) { - return expr.getLongValue(); - } - } - - } else if (expr instanceof StringLiteral) { - String value = expr.getStringValue(); - if (type instanceof Type.Str) { - return value; - } - if (type instanceof Type.Date) { - try { - return (int) ChronoUnit.DAYS.between( - EPOCH_DAY, - LocalDate.parse(value, DateTimeFormatter.ISO_LOCAL_DATE)); - } catch (DateTimeParseException e) { - return null; - } - } - if (type instanceof Type.Timestamp || type instanceof Type.TimestampTZ) { - try { - return ChronoUnit.MICROS.between( - EPOCH, - OffsetDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME)); - } catch (DateTimeParseException e) { - return null; - } - } - } - return null; - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/source/LakeSoulScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/source/LakeSoulScanNode.java deleted file mode 100644 index 2662dc55ff7fd5..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/source/LakeSoulScanNode.java +++ /dev/null @@ -1,288 +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.datasource.lakesoul.source; - -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.FileQueryScanNode; -import org.apache.doris.datasource.TableFormatType; -import org.apache.doris.datasource.lakesoul.LakeSoulExternalTable; -import org.apache.doris.datasource.lakesoul.LakeSoulUtils; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.spi.Split; -import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.TFileRangeDesc; -import org.apache.doris.thrift.TLakeSoulFileDesc; -import org.apache.doris.thrift.TTableFormatFileDesc; - -import com.alibaba.fastjson.JSON; -import com.alibaba.fastjson.JSONObject; -import com.dmetasoul.lakesoul.lakesoul.io.substrait.SubstraitUtil; -import com.dmetasoul.lakesoul.meta.DBUtil; -import com.dmetasoul.lakesoul.meta.DataFileInfo; -import com.dmetasoul.lakesoul.meta.DataOperation; -import com.dmetasoul.lakesoul.meta.LakeSoulOptions; -import com.dmetasoul.lakesoul.meta.entity.PartitionInfo; -import com.dmetasoul.lakesoul.meta.entity.TableInfo; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.collect.Lists; -import io.substrait.proto.Plan; -import lombok.SneakyThrows; -import org.apache.arrow.vector.types.pojo.Field; -import org.apache.arrow.vector.types.pojo.Schema; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * @deprecated LakeSoul catalog support has been deprecated and will be removed in a future version. - */ -@Deprecated -public class LakeSoulScanNode extends FileQueryScanNode { - - private static final Logger LOG = LogManager.getLogger(LakeSoulScanNode.class); - - protected LakeSoulExternalTable lakeSoulExternalTable; - - String tableName; - - String location; - - String partitions; - - Schema tableArrowSchema; - - Schema partitionArrowSchema; - private Map tableProperties; - - String readType; - - public LakeSoulScanNode(PlanNodeId id, TupleDescriptor desc, boolean needCheckColumnPriv, SessionVariable sv, - ScanContext scanContext) { - super(id, desc, "planNodeName", scanContext, needCheckColumnPriv, sv); - } - - @Override - protected void doInitialize() throws UserException { - super.doInitialize(); - lakeSoulExternalTable = (LakeSoulExternalTable) desc.getTable(); - TableInfo tableInfo = lakeSoulExternalTable.getLakeSoulTableInfo(); - location = tableInfo.getTablePath(); - tableName = tableInfo.getTableName(); - partitions = tableInfo.getPartitions(); - readType = LakeSoulOptions.ReadType$.MODULE$.FULL_READ(); - try { - tableProperties = new ObjectMapper().readValue( - tableInfo.getProperties(), - new TypeReference>() {} - ); - tableArrowSchema = Schema.fromJSON(tableInfo.getTableSchema()); - List partitionFields = - DBUtil.parseTableInfoPartitions(partitions) - .rangeKeys - .stream() - .map(tableArrowSchema::findField).collect(Collectors.toList()); - partitionArrowSchema = new Schema(partitionFields); - } catch (IOException e) { - throw new UserException(e); - } - } - - @Override - protected TFileFormatType getFileFormatType() throws UserException { - return TFileFormatType.FORMAT_JNI; - } - - @Override - protected List getPathPartitionKeys() throws UserException { - return new ArrayList<>(DBUtil.parseTableInfoPartitions(partitions).rangeKeys); - } - - @Override - protected TableIf getTargetTable() throws UserException { - return desc.getTable(); - } - - @Override - protected Map getLocationProperties() throws UserException { - return lakeSoulExternalTable.getHadoopProperties(); - } - - @SneakyThrows - @Override - protected void setScanParams(TFileRangeDesc rangeDesc, Split split) { - if (LOG.isDebugEnabled()) { - LOG.debug("{}", rangeDesc); - } - if (split instanceof LakeSoulSplit) { - setLakeSoulParams(rangeDesc, (LakeSoulSplit) split); - } - } - - public ExternalCatalog getCatalog() { - return lakeSoulExternalTable.getCatalog(); - } - - public static boolean isExistHashPartition(TableInfo tif) { - JSONObject tableProperties = JSON.parseObject(tif.getProperties()); - if (tableProperties.containsKey(LakeSoulOptions.HASH_BUCKET_NUM()) - && tableProperties.getString(LakeSoulOptions.HASH_BUCKET_NUM()).equals("-1")) { - return false; - } else { - return tableProperties.containsKey(LakeSoulOptions.HASH_BUCKET_NUM()); - } - } - - private void setLakeSoulParams(TFileRangeDesc rangeDesc, LakeSoulSplit lakeSoulSplit) throws IOException { - TTableFormatFileDesc tableFormatFileDesc = new TTableFormatFileDesc(); - tableFormatFileDesc.setTableFormatType(lakeSoulSplit.getTableFormatType().value()); - TLakeSoulFileDesc fileDesc = new TLakeSoulFileDesc(); - fileDesc.setFilePaths(lakeSoulSplit.getPaths()); - fileDesc.setPrimaryKeys(lakeSoulSplit.getPrimaryKeys()); - fileDesc.setTableSchema(lakeSoulSplit.getTableSchema()); - - - JSONObject options = new JSONObject(); - Plan predicate = LakeSoulUtils.getPushPredicate( - conjuncts, - tableName, - tableArrowSchema, - partitionArrowSchema, - tableProperties, - readType.equals(LakeSoulOptions.ReadType$.MODULE$.INCREMENTAL_READ())); - if (predicate != null) { - options.put(LakeSoulUtils.SUBSTRAIT_PREDICATE, SubstraitUtil.encodeBase64String(predicate)); - } - Map catalogProps = getCatalog().getProperties(); - if (LOG.isDebugEnabled()) { - LOG.debug("{}", catalogProps); - } - - if (catalogProps.get("AWS_ENDPOINT") != null) { - options.put(LakeSoulUtils.FS_S3A_ENDPOINT, catalogProps.get("AWS_ENDPOINT")); - if (!options.containsKey("oss.endpoint")) { - // Aliyun OSS requires virtual host style access - options.put(LakeSoulUtils.FS_S3A_PATH_STYLE_ACCESS, "false"); - } else { - // use path style access for all other s3 compatible storage services - options.put(LakeSoulUtils.FS_S3A_PATH_STYLE_ACCESS, "true"); - } - if (catalogProps.get("AWS_ACCESS_KEY") != null) { - options.put(LakeSoulUtils.FS_S3A_ACCESS_KEY, catalogProps.get("AWS_ACCESS_KEY")); - } - if (catalogProps.get("AWS_SECRET_KEY") != null) { - options.put(LakeSoulUtils.FS_S3A_SECRET_KEY, catalogProps.get("AWS_SECRET_KEY")); - } - if (catalogProps.get("AWS_REGION") != null) { - options.put(LakeSoulUtils.FS_S3A_REGION, catalogProps.get("AWS_REGION")); - } - } - - fileDesc.setOptions(JSON.toJSONString(options)); - - fileDesc.setPartitionDescs(lakeSoulSplit.getPartitionDesc() - .entrySet().stream().map(entry -> - String.format("%s=%s", entry.getKey(), entry.getValue())).collect(Collectors.toList())); - tableFormatFileDesc.setLakesoulParams(fileDesc); - rangeDesc.setTableFormatParams(tableFormatFileDesc); - } - - public List getSplits(int numBackends) throws UserException { - if (LOG.isDebugEnabled()) { - LOG.debug("getSplits with columnFilters={}", columnFilters); - LOG.debug("getSplits with columnNameToRange={}", columnNameToRange); - LOG.debug("getSplits with conjuncts={}", conjuncts); - } - - List allPartitionInfo = lakeSoulExternalTable.listPartitionInfo(); - if (LOG.isDebugEnabled()) { - LOG.debug("allPartitionInfo={}", allPartitionInfo); - } - List filteredPartitionInfo = allPartitionInfo; - try { - filteredPartitionInfo = - LakeSoulUtils.applyPartitionFilters( - allPartitionInfo, - tableName, - partitionArrowSchema, - columnNameToRange - ); - } catch (IOException e) { - throw new UserException(e); - } - if (LOG.isDebugEnabled()) { - LOG.debug("filteredPartitionInfo={}", filteredPartitionInfo); - } - DataFileInfo[] dataFileInfos = DataOperation.getTableDataInfo(filteredPartitionInfo); - - List splits = new ArrayList<>(); - Map>> splitByRangeAndHashPartition = new LinkedHashMap<>(); - TableInfo tableInfo = lakeSoulExternalTable.getLakeSoulTableInfo(); - - for (DataFileInfo fileInfo : dataFileInfos) { - if (isExistHashPartition(tableInfo) && fileInfo.file_bucket_id() != -1) { - splitByRangeAndHashPartition.computeIfAbsent(fileInfo.range_partitions(), k -> new LinkedHashMap<>()) - .computeIfAbsent(fileInfo.file_bucket_id(), v -> new ArrayList<>()) - .add(fileInfo.path()); - } else { - splitByRangeAndHashPartition.computeIfAbsent(fileInfo.range_partitions(), k -> new LinkedHashMap<>()) - .computeIfAbsent(-1, v -> new ArrayList<>()) - .add(fileInfo.path()); - } - } - List pkKeys = null; - if (!tableInfo.getPartitions().equals(";")) { - pkKeys = Lists.newArrayList(tableInfo.getPartitions().split(";")[1].split(",")); - } - - for (Map.Entry>> entry : splitByRangeAndHashPartition.entrySet()) { - String rangeKey = entry.getKey(); - LinkedHashMap rangeDesc = new LinkedHashMap<>(); - if (!rangeKey.equals("-5")) { - String[] keys = rangeKey.split(","); - for (String item : keys) { - String[] kv = item.split("="); - rangeDesc.put(kv[0], kv[1]); - } - } - for (Map.Entry> split : entry.getValue().entrySet()) { - LakeSoulSplit lakeSoulSplit = new LakeSoulSplit( - split.getValue(), - pkKeys, - rangeDesc, - tableInfo.getTableSchema(), - 0, 0, 0, - new String[0], null); - lakeSoulSplit.setTableFormatType(TableFormatType.LAKESOUL); - splits.add(lakeSoulSplit); - } - } - return splits; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/source/LakeSoulSplit.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/source/LakeSoulSplit.java deleted file mode 100644 index 404ca218620433..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/lakesoul/source/LakeSoulSplit.java +++ /dev/null @@ -1,61 +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.datasource.lakesoul.source; - -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.FileSplit; - -import lombok.Data; - -import java.util.List; -import java.util.Map; - -/** - * @deprecated LakeSoul catalog support has been deprecated and will be removed in a future version. - */ -@Deprecated -@Data -public class LakeSoulSplit extends FileSplit { - - private final List paths; - - private final List primaryKeys; - - private final Map partitionDesc; - - private final String tableSchema; - - - public LakeSoulSplit(List paths, - List primaryKeys, - Map partitionDesc, - String tableSchema, - long start, - long length, - long fileLength, - String[] hosts, - List partitionValues) { - super(LocationPath.of(paths.get(0)), start, length, fileLength, - 0, hosts, partitionValues); - this.paths = paths; - this.primaryKeys = primaryKeys; - this.partitionDesc = partitionDesc; - this.tableSchema = tableSchema; - } -} - diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogLog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/log/CatalogLog.java similarity index 98% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogLog.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/log/CatalogLog.java index 08a08d49d848d1..acd3f37c6da982 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogLog.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/log/CatalogLog.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.log; import org.apache.doris.common.io.Text; import org.apache.doris.common.io.Writable; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalObjectLog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/log/ExternalObjectLog.java similarity index 99% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalObjectLog.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/log/ExternalObjectLog.java index 84f0605b8d8557..30464457e60a80 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalObjectLog.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/log/ExternalObjectLog.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.log; import org.apache.doris.common.io.Text; import org.apache.doris.common.io.Writable; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/InitCatalogLog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/log/InitCatalogLog.java similarity index 98% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/InitCatalogLog.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/log/InitCatalogLog.java index 13838bc2a2904a..c7154c6413de9d 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/InitCatalogLog.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/log/InitCatalogLog.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.log; import org.apache.doris.common.io.Text; import org.apache.doris.common.io.Writable; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/InitDatabaseLog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/log/InitDatabaseLog.java similarity index 98% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/InitDatabaseLog.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/log/InitDatabaseLog.java index 1a8789aeb1043a..1bfb4b7507bf77 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/InitDatabaseLog.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/log/InitDatabaseLog.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.log; import org.apache.doris.common.io.Text; import org.apache.doris.common.io.Writable; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/MetaIdMappingsLog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/log/MetaIdMappingsLog.java similarity index 99% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/MetaIdMappingsLog.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/log/MetaIdMappingsLog.java index 629b4d13a4041b..ee5b697a7cf595 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/MetaIdMappingsLog.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/log/MetaIdMappingsLog.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.log; import org.apache.doris.common.io.Text; import org.apache.doris.common.io.Writable; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MCTransaction.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MCTransaction.java deleted file mode 100644 index 6d5a6c9112f940..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MCTransaction.java +++ /dev/null @@ -1,240 +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.datasource.maxcompute; - -import org.apache.doris.common.Config; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.nereids.trees.plans.commands.insert.InsertCommandContext; -import org.apache.doris.nereids.trees.plans.commands.insert.MCInsertCommandContext; -import org.apache.doris.thrift.TMCCommitData; -import org.apache.doris.transaction.Transaction; - -import com.aliyun.odps.PartitionSpec; -import com.aliyun.odps.table.TableIdentifier; -import com.aliyun.odps.table.configuration.ArrowOptions; -import com.aliyun.odps.table.configuration.ArrowOptions.TimestampUnit; -import com.aliyun.odps.table.configuration.DynamicPartitionOptions; -import com.aliyun.odps.table.write.TableBatchWriteSession; -import com.aliyun.odps.table.write.TableWriteSessionBuilder; -import com.aliyun.odps.table.write.WriterCommitMessage; -import com.google.common.collect.Lists; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.ByteArrayInputStream; -import java.io.ObjectInputStream; -import java.util.ArrayList; -import java.util.Base64; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicLong; -import java.util.stream.Collectors; - -public class MCTransaction implements Transaction { - - private static final Logger LOG = LogManager.getLogger(MCTransaction.class); - - private final MaxComputeExternalCatalog catalog; - private MaxComputeExternalTable table; - private final List commitDataList = Lists.newArrayList(); - - // Storage API write session ID (created in beginInsert, used in finishInsert) - private String writeSessionId; - private final AtomicLong nextBlockId = new AtomicLong(0); - - public MCTransaction(MaxComputeExternalCatalog catalog) { - this.catalog = catalog; - } - - public void updateMCCommitData(List commitDataList) { - synchronized (this) { - this.commitDataList.addAll(commitDataList); - } - } - - public void beginInsert(ExternalTable dorisTable, Optional ctx) throws UserException { - this.table = (MaxComputeExternalTable) dorisTable; - if (table.isUnsupportedOdpsTable()) { - throw new UserException("Writing MaxCompute external table or logical view is not supported: " - + table.getDbName() + "." + table.getName()); - } - - try { - TableIdentifier tableId = catalog.getOdpsTableIdentifier(table.getDbName(), table.getName()); - - boolean isDynamicPartition = !table.getPartitionColumns().isEmpty(); - boolean isStaticPartition = false; - String staticPartitionSpecStr = null; - - boolean isOverwrite = false; - if (ctx.isPresent() && ctx.get() instanceof MCInsertCommandContext) { - MCInsertCommandContext mcCtx = (MCInsertCommandContext) ctx.get(); - Map staticSpec = mcCtx.getStaticPartitionSpec(); - if (staticSpec != null && !staticSpec.isEmpty()) { - isStaticPartition = true; - // Must follow table's partition column order - staticPartitionSpecStr = table.getPartitionColumns().stream() - .map(col -> col.getName()) - .filter(staticSpec::containsKey) - .map(name -> name + "=" + staticSpec.get(name)) - .collect(Collectors.joining(",")); - } - isOverwrite = mcCtx.isOverwrite(); - } - - TableWriteSessionBuilder builder = new TableWriteSessionBuilder() - .identifier(tableId) - .withSettings(catalog.getSettings()) - .withMaxFieldSize(catalog.getMaxFieldSize()) - .withArrowOptions(ArrowOptions.newBuilder() - .withDatetimeUnit(TimestampUnit.MILLI) - .withTimestampUnit(TimestampUnit.MILLI) - .build()); - - if (isStaticPartition) { - builder.partition(new PartitionSpec(staticPartitionSpecStr)); - } else if (isDynamicPartition) { - builder.withDynamicPartitionOptions(DynamicPartitionOptions.createDefault()); - } - - if (isOverwrite) { - builder.overwrite(true); - } - - TableBatchWriteSession writeSession = builder.buildBatchWriteSession(); - writeSessionId = writeSession.getId(); - nextBlockId.set(0); - - LOG.info("Created MC Storage API write session: {} for table {}.{}", - writeSessionId, catalog.getDefaultProject(), table.getName()); - } catch (Exception e) { - throw new UserException("Failed to begin insert for MaxCompute table " - + dorisTable.getName() + ": " + e.getMessage(), e); - } - } - - public String getWriteSessionId() { - return writeSessionId; - } - - public long allocateBlockIdRange(String requestWriteSessionId, long length) throws UserException { - if (length <= 0) { - throw new UserException("MaxCompute block_id allocation length must be positive: " + length); - } - if (writeSessionId == null || writeSessionId.isEmpty()) { - throw new UserException("MaxCompute write session has not been initialized"); - } - if (!writeSessionId.equals(requestWriteSessionId)) { - throw new UserException("MaxCompute write session mismatch, expected=" + writeSessionId - + ", actual=" + requestWriteSessionId); - } - - long start; - long endExclusive; - do { - start = nextBlockId.get(); - endExclusive = start + length; - if (endExclusive > Config.max_compute_write_max_block_count) { - throw new UserException("MaxCompute block_id exceeds limit, start=" - + start + ", length=" + length + ", maxBlockCount=" - + Config.max_compute_write_max_block_count); - } - } while (!nextBlockId.compareAndSet(start, endExclusive)); - - LOG.info("Allocated MaxCompute block_id range: sessionId={}, start={}, length={}", - writeSessionId, start, length); - return start; - } - - private void appendCommitMessages(List allMessages, String encodedCommitMessage) - throws Exception { - byte[] bytes = Base64.getDecoder().decode(encodedCommitMessage); - ByteArrayInputStream bais = new ByteArrayInputStream(bytes); - ObjectInputStream ois = new ObjectInputStream(bais); - Object payload = ois.readObject(); - ois.close(); - - if (payload instanceof WriterCommitMessage) { - allMessages.add((WriterCommitMessage) payload); - return; - } - if (payload instanceof List) { - for (Object item : (List) payload) { - if (!(item instanceof WriterCommitMessage)) { - throw new UserException("Unexpected MaxCompute commit payload item type: " - + (item == null ? "null" : item.getClass().getName())); - } - allMessages.add((WriterCommitMessage) item); - } - return; - } - throw new UserException("Unexpected MaxCompute commit payload type: " - + (payload == null ? "null" : payload.getClass().getName())); - } - - public void finishInsert() throws UserException { - try { - long t0 = System.currentTimeMillis(); - // Collect all WriterCommitMessages from BEs - List allMessages = new ArrayList<>(); - synchronized (this) { - for (TMCCommitData data : commitDataList) { - if (data.isSetCommitMessage() && !data.getCommitMessage().isEmpty()) { - appendCommitMessages(allMessages, data.getCommitMessage()); - } - } - } - long t1 = System.currentTimeMillis(); - - // Restore session and commit all messages - TableIdentifier tableId = catalog.getOdpsTableIdentifier(table.getDbName(), table.getName()); - TableBatchWriteSession commitSession = new TableWriteSessionBuilder() - .identifier(tableId) - .withSessionId(writeSessionId) - .withSettings(catalog.getSettings()) - .buildBatchWriteSession(); - long t2 = System.currentTimeMillis(); - - commitSession.commit(allMessages.toArray(new WriterCommitMessage[0])); - long t3 = System.currentTimeMillis(); - LOG.info("Committed MC write session {} with {} messages for table {}.{}" - + " Breakdown: deserialize={}ms, restoreSession={}ms, commit={}ms, total={}ms", - writeSessionId, allMessages.size(), catalog.getDefaultProject(), table.getName(), - t1 - t0, t2 - t1, t3 - t2, t3 - t0); - } catch (Exception e) { - throw new UserException("Failed to commit MaxCompute write session: " + e.getMessage(), e); - } - } - - @Override - public void commit() throws UserException { - // commit is handled in finishInsert() - } - - @Override - public void rollback() { - // MC sessions auto-expire if not committed; no explicit rollback needed - LOG.info("MCTransaction rollback called; uncommitted sessions will auto-expire."); - } - - public long getUpdateCnt() { - return commitDataList.stream().mapToLong(TMCCommitData::getRowCount).sum(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalCatalog.java deleted file mode 100644 index 75a6190d6960c5..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalCatalog.java +++ /dev/null @@ -1,524 +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.datasource.maxcompute; - - -import org.apache.doris.common.DdlException; -import org.apache.doris.common.maxcompute.MCProperties; -import org.apache.doris.common.maxcompute.MCUtils; -import org.apache.doris.datasource.CatalogProperty; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.InitCatalogLog; -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.transaction.TransactionManagerFactory; - -import com.aliyun.odps.Odps; -import com.aliyun.odps.OdpsException; -import com.aliyun.odps.Partition; -import com.aliyun.odps.account.AccountFormat; -import com.aliyun.odps.table.TableIdentifier; -import com.aliyun.odps.table.configuration.RestOptions; -import com.aliyun.odps.table.configuration.SplitOptions; -import com.aliyun.odps.table.enviroment.Credentials; -import com.aliyun.odps.table.enviroment.EnvironmentSettings; -import com.google.common.collect.ImmutableList; -import org.apache.log4j.Logger; - -import java.time.ZoneId; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -public class MaxComputeExternalCatalog extends ExternalCatalog { - private static final Logger LOG = Logger.getLogger(MaxComputeExternalCatalog.class); - - // you can ref : https://help.aliyun.com/zh/maxcompute/user-guide/endpoints - private static final String endpointTemplate = "http://service.{}.maxcompute.aliyun-inc.com/api"; - private Map props; - private Odps odps; - private String endpoint; - private String defaultProject; - private String quota; - private EnvironmentSettings settings; - - private String splitStrategy; - private SplitOptions splitOptions; - private long splitRowCount; - private long splitByteSize; - - private int connectTimeout; - private int readTimeout; - private int retryTimes; - private long maxFieldSize; - - public boolean dateTimePredicatePushDown; - - AccountFormat accountFormat = AccountFormat.DISPLAYNAME; - - private McStructureHelper mcStructureHelper = null; - - private static final Map REGION_ZONE_MAP; - private static final List REQUIRED_PROPERTIES = ImmutableList.of( - MCProperties.PROJECT, - MCProperties.ENDPOINT - ); - - static { - Map map = new HashMap<>(); - - map.put("cn-hangzhou", ZoneId.of("Asia/Shanghai")); - map.put("cn-shanghai", ZoneId.of("Asia/Shanghai")); - map.put("cn-shanghai-finance-1", ZoneId.of("Asia/Shanghai")); - map.put("cn-beijing", ZoneId.of("Asia/Shanghai")); - map.put("cn-north-2-gov-1", ZoneId.of("Asia/Shanghai")); - map.put("cn-zhangjiakou", ZoneId.of("Asia/Shanghai")); - map.put("cn-wulanchabu", ZoneId.of("Asia/Shanghai")); - map.put("cn-shenzhen", ZoneId.of("Asia/Shanghai")); - map.put("cn-shenzhen-finance-1", ZoneId.of("Asia/Shanghai")); - map.put("cn-chengdu", ZoneId.of("Asia/Shanghai")); - map.put("cn-hongkong", ZoneId.of("Asia/Shanghai")); - map.put("ap-southeast-1", ZoneId.of("Asia/Singapore")); - map.put("ap-southeast-2", ZoneId.of("Australia/Sydney")); - map.put("ap-southeast-3", ZoneId.of("Asia/Kuala_Lumpur")); - map.put("ap-southeast-5", ZoneId.of("Asia/Jakarta")); - map.put("ap-northeast-1", ZoneId.of("Asia/Tokyo")); - map.put("eu-central-1", ZoneId.of("Europe/Berlin")); - map.put("eu-west-1", ZoneId.of("Europe/London")); - map.put("us-west-1", ZoneId.of("America/Los_Angeles")); - map.put("us-east-1", ZoneId.of("America/New_York")); - map.put("me-east-1", ZoneId.of("Asia/Dubai")); - - REGION_ZONE_MAP = Collections.unmodifiableMap(map); - } - - - public MaxComputeExternalCatalog(long catalogId, String name, String resource, Map props, - String comment) { - super(catalogId, name, InitCatalogLog.Type.MAX_COMPUTE, comment); - catalogProperty = new CatalogProperty(resource, props); - } - - //Compatible with existing catalogs in previous versions. - protected void generatorEndpoint() { - Map props = catalogProperty.getProperties(); - - if (props.containsKey(MCProperties.ENDPOINT)) { - // This is a new version of the property, so no parsing conversion is required. - endpoint = props.get(MCProperties.ENDPOINT); - } else if (props.containsKey(MCProperties.TUNNEL_SDK_ENDPOINT)) { - // If customized `mc.tunnel_endpoint` before, - // need to convert the value of this property because used the `tunnel API` before. - String tunnelEndpoint = props.get(MCProperties.TUNNEL_SDK_ENDPOINT); - endpoint = tunnelEndpoint.replace("//dt", "//service") + "/api"; - } else if (props.containsKey(MCProperties.ODPS_ENDPOINT)) { - // If you customized `mc.odps_endpoint` before, - // this value is equivalent to the new version of `mc.endpoint`, so you can use it directly - endpoint = props.get(MCProperties.ODPS_ENDPOINT); - } else if (props.containsKey(MCProperties.REGION)) { - //Copied from original logic. - String region = props.get(MCProperties.REGION); - if (region.startsWith("oss-")) { - // may use oss-cn-beijing, ensure compatible - region = region.replace("oss-", ""); - } - boolean enablePublicAccess = Boolean.parseBoolean(props.getOrDefault(MCProperties.PUBLIC_ACCESS, - MCProperties.DEFAULT_PUBLIC_ACCESS)); - endpoint = endpointTemplate.replace("{}", region); - if (enablePublicAccess) { - endpoint = endpoint.replace("-inc", ""); - } - } - /* - Since MCProperties.REGION is a REQUIRED_PROPERTIES in previous versions - and MCProperties.ENDPOINT is a REQUIRED_PROPERTIES in current versions, - `else {}` is not needed here. - */ - } - - - @Override - protected void initLocalObjectsImpl() { - props = catalogProperty.getProperties(); - - generatorEndpoint(); - - defaultProject = props.get(MCProperties.PROJECT); - quota = props.getOrDefault(MCProperties.QUOTA, MCProperties.DEFAULT_QUOTA); - - boolean splitCrossPartition = - Boolean.parseBoolean(props.getOrDefault(MCProperties.SPLIT_CROSS_PARTITION, - MCProperties.DEFAULT_SPLIT_CROSS_PARTITION)); - - splitStrategy = props.getOrDefault(MCProperties.SPLIT_STRATEGY, MCProperties.DEFAULT_SPLIT_STRATEGY); - if (splitStrategy.equals(MCProperties.SPLIT_BY_BYTE_SIZE_STRATEGY)) { - splitByteSize = Long.parseLong(props.getOrDefault(MCProperties.SPLIT_BYTE_SIZE, - MCProperties.DEFAULT_SPLIT_BYTE_SIZE)); - splitOptions = SplitOptions.newBuilder() - .SplitByByteSize(splitByteSize) - .withCrossPartition(splitCrossPartition) - .build(); - } else { - splitRowCount = Long.parseLong(props.getOrDefault(MCProperties.SPLIT_ROW_COUNT, - MCProperties.DEFAULT_SPLIT_ROW_COUNT)); - splitOptions = SplitOptions.newBuilder() - .SplitByRowOffset() - .withCrossPartition(splitCrossPartition) - .build(); - } - - connectTimeout = Integer.parseInt( - props.getOrDefault(MCProperties.CONNECT_TIMEOUT, MCProperties.DEFAULT_CONNECT_TIMEOUT)); - readTimeout = Integer.parseInt( - props.getOrDefault(MCProperties.READ_TIMEOUT, MCProperties.DEFAULT_READ_TIMEOUT)); - retryTimes = Integer.parseInt( - props.getOrDefault(MCProperties.RETRY_COUNT, MCProperties.DEFAULT_RETRY_COUNT)); - maxFieldSize = Long.parseLong( - props.getOrDefault(MCProperties.MAX_FIELD_SIZE, MCProperties.DEFAULT_MAX_FIELD_SIZE)); - - RestOptions restOptions = RestOptions.newBuilder() - .withConnectTimeout(connectTimeout) - .withReadTimeout(readTimeout) - .withRetryTimes(retryTimes).build(); - - dateTimePredicatePushDown = Boolean.parseBoolean( - props.getOrDefault(MCProperties.DATETIME_PREDICATE_PUSH_DOWN, - MCProperties.DEFAULT_DATETIME_PREDICATE_PUSH_DOWN)); - - odps = MCUtils.createMcClient(props); - odps.setDefaultProject(defaultProject); - odps.setEndpoint(endpoint); - odps.getRestClient().setConnectTimeout(connectTimeout); - odps.getRestClient().setReadTimeout(readTimeout); - odps.getRestClient().setRetryTimes(retryTimes); - - String accountFormatProp = props.getOrDefault(MCProperties.ACCOUNT_FORMAT, MCProperties.DEFAULT_ACCOUNT_FORMAT); - if (accountFormatProp.equals(MCProperties.ACCOUNT_FORMAT_NAME)) { - accountFormat = AccountFormat.DISPLAYNAME; - } else if (accountFormatProp.equals(MCProperties.ACCOUNT_FORMAT_ID)) { - accountFormat = AccountFormat.ID; - } - odps.setAccountFormat(accountFormat); - Credentials credentials = Credentials.newBuilder().withAccount(odps.getAccount()) - .withAppAccount(odps.getAppAccount()).build(); - - settings = EnvironmentSettings.newBuilder() - .withCredentials(credentials) - .withServiceEndpoint(odps.getEndpoint()) - .withQuotaName(quota) - .withRestOptions(restOptions) - .build(); - - boolean enableNamespaceSchema = Boolean.parseBoolean( - props.getOrDefault(MCProperties.ENABLE_NAMESPACE_SCHEMA, MCProperties.DEFAULT_ENABLE_NAMESPACE_SCHEMA)); - mcStructureHelper = McStructureHelper.getHelper(enableNamespaceSchema, defaultProject); - - initPreExecutionAuthenticator(); - metadataOps = new MaxComputeMetadataOps(this, odps); - transactionManager = TransactionManagerFactory.createMCTransactionManager(this); - } - - @Override - public void checkWhenCreating() throws DdlException { - boolean testConnection = Boolean.parseBoolean(catalogProperty.getOrDefault(TEST_CONNECTION, - String.valueOf(DEFAULT_TEST_CONNECTION))); - if (!testConnection) { - return; - } - // MaxCompute has no MetastoreProperties-backed connectivity tester yet, - // so run its catalog-specific test directly under the common test_connection switch. - boolean enableNamespaceSchema = Boolean.parseBoolean( - catalogProperty.getOrDefault(MCProperties.ENABLE_NAMESPACE_SCHEMA, - MCProperties.DEFAULT_ENABLE_NAMESPACE_SCHEMA)); - try { - initLocalObjects(); - validateMaxComputeConnection(enableNamespaceSchema); - } catch (Exception e) { - throw new DdlException(e.getMessage(), e); - } - } - - protected void validateMaxComputeConnection(boolean enableNamespaceSchema) { - if (enableNamespaceSchema) { - validateMaxComputeProjectAndNamespaceSchema(); - } else { - validateMaxComputeProject(); - } - } - - private void validateMaxComputeProject() { - boolean projectExists; - try { - projectExists = maxComputeProjectExists(defaultProject); - } catch (Exception e) { - throw new RuntimeException("Failed to validate MaxCompute project '" + defaultProject - + "'. Check " + MCProperties.PROJECT + ", " + MCProperties.ENDPOINT - + " and credentials. Cause: " + e.getMessage(), e); - } - if (!projectExists) { - throw new RuntimeException("Failed to validate MaxCompute project '" + defaultProject - + "'. Check " + MCProperties.PROJECT + ", " + MCProperties.ENDPOINT - + " and credentials. Cause: project does not exist or is not accessible"); - } - } - - private void validateMaxComputeProjectAndNamespaceSchema() { - try { - validateMaxComputeNamespaceSchemaAccess(defaultProject); - } catch (Exception e) { - throw new RuntimeException("Failed to validate MaxCompute project '" + defaultProject - + "' with namespace schema. Check " + MCProperties.PROJECT + ", " + MCProperties.ENDPOINT - + ", credentials, and whether the schema list is accessible for the namespace schema " - + "configuration. Cause: " + e.getMessage(), e); - } - } - - protected boolean maxComputeProjectExists(String projectName) throws OdpsException { - return odps.projects().exists(projectName); - } - - protected void validateMaxComputeNamespaceSchemaAccess(String projectName) throws OdpsException { - odps.schemas().iterator(projectName).hasNext(); - } - - public Odps getClient() { - makeSureInitialized(); - return odps; - } - - public McStructureHelper getMcStructureHelper() { - makeSureInitialized(); - return mcStructureHelper; - } - - protected List listDatabaseNames() { - makeSureInitialized(); - return mcStructureHelper.listDatabaseNames(getClient(), getDefaultProject()); - } - - @Override - public boolean tableExist(SessionContext ctx, String dbName, String tblName) { - makeSureInitialized(); - return mcStructureHelper.tableExist(getClient(), dbName, tblName); - - } - - public List listPartitionNames(String dbName, String tbl) { - return listPartitionNames(dbName, tbl, 0, -1); - } - - public List listPartitionNames(String dbName, String tbl, long skip, long limit) { - if (mcStructureHelper.databaseExist(getClient(), dbName)) { - List parts; - if (limit < 0) { - parts = mcStructureHelper.getPartitions(getClient(), dbName, tbl); - } else { - skip = skip < 0 ? 0 : skip; - parts = new ArrayList<>(); - Iterator it = mcStructureHelper.getPartitionIterator(getClient(), dbName, tbl); - int count = 0; - while (it.hasNext()) { - if (count < skip) { - count++; - it.next(); - } else if (parts.size() >= limit) { - break; - } else { - parts.add(it.next()); - } - } - } - return parts.stream().map(p -> p.getPartitionSpec().toString(false, true)) - .collect(Collectors.toList()); - } else { - throw new RuntimeException("MaxCompute schema/project: " + dbName + " not exists."); - } - } - - @Override - protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { - return mcStructureHelper.listTableNames(getClient(), dbName); - } - - public Map getProperties() { - makeSureInitialized(); - return props; - } - - public String getEndpoint() { - makeSureInitialized(); - return endpoint; - } - - public String getDefaultProject() { - makeSureInitialized(); - return defaultProject; - } - - public int getRetryTimes() { - makeSureInitialized(); - return retryTimes; - } - - public int getConnectTimeout() { - makeSureInitialized(); - return connectTimeout; - } - - public int getReadTimeout() { - makeSureInitialized(); - return readTimeout; - } - - public long getMaxFieldSize() { - makeSureInitialized(); - return maxFieldSize; - } - - public boolean getDateTimePredicatePushDown() { - return dateTimePredicatePushDown; - } - - public ZoneId getProjectDateTimeZone() { - makeSureInitialized(); - - String[] endpointSplit = endpoint.split("\\."); - if (endpointSplit.length >= 2) { - // http://service.cn-hangzhou-vpc.maxcompute.aliyun-inc.com/api => cn-hangzhou-vpc - String regionAndSuffix = endpointSplit[1]; - - //remove `-vpc` and `-intranet` suffix. - String region = regionAndSuffix.replace("-vpc", "").replace("-intranet", ""); - if (REGION_ZONE_MAP.containsKey(region)) { - return REGION_ZONE_MAP.get(region); - } - LOG.warn("Not exist region. region = " + region + ". endpoint = " + endpoint + ". use systemDefault."); - return ZoneId.systemDefault(); - } - LOG.warn("Split EndPoint " + endpoint + "fill. use systemDefault."); - return ZoneId.systemDefault(); - } - - public String getQuota() { - return quota; - } - - public SplitOptions getSplitOption() { - return splitOptions; - } - - public EnvironmentSettings getSettings() { - return settings; - } - - public String getSplitStrategy() { - return splitStrategy; - } - - public long getSplitRowCount() { - return splitRowCount; - } - - - public long getSplitByteSize() { - return splitByteSize; - } - - public com.aliyun.odps.Table getOdpsTable(String dbName, String tableName) { - return mcStructureHelper.getOdpsTable(getClient(), dbName, tableName); - } - - public TableIdentifier getOdpsTableIdentifier(String dbName, String tableName) { - return mcStructureHelper.getTableIdentifier(dbName, tableName); - } - - @Override - public void checkProperties() throws DdlException { - super.checkProperties(); - Map props = catalogProperty.getProperties(); - for (String requiredProperty : REQUIRED_PROPERTIES) { - if (!props.containsKey(requiredProperty)) { - throw new DdlException("Required property '" + requiredProperty + "' is missing"); - } - } - - try { - splitStrategy = props.getOrDefault(MCProperties.SPLIT_STRATEGY, MCProperties.DEFAULT_SPLIT_STRATEGY); - if (splitStrategy.equals(MCProperties.SPLIT_BY_BYTE_SIZE_STRATEGY)) { - splitByteSize = Long.parseLong(props.getOrDefault(MCProperties.SPLIT_BYTE_SIZE, - MCProperties.DEFAULT_SPLIT_BYTE_SIZE)); - - if (splitByteSize < 10485760L) { - throw new DdlException(MCProperties.SPLIT_BYTE_SIZE + " must be greater than or equal to 10485760"); - } - - } else if (splitStrategy.equals(MCProperties.SPLIT_BY_ROW_COUNT_STRATEGY)) { - splitRowCount = Long.parseLong(props.getOrDefault(MCProperties.SPLIT_ROW_COUNT, - MCProperties.DEFAULT_SPLIT_ROW_COUNT)); - if (splitRowCount <= 0) { - throw new DdlException(MCProperties.SPLIT_ROW_COUNT + " must be greater than 0"); - } - - } else { - throw new DdlException("property " + MCProperties.SPLIT_STRATEGY + "must is " - + MCProperties.SPLIT_BY_BYTE_SIZE_STRATEGY + " or " + MCProperties.SPLIT_BY_ROW_COUNT_STRATEGY); - } - } catch (NumberFormatException e) { - throw new DdlException("property " + MCProperties.SPLIT_BYTE_SIZE + "/" - + MCProperties.SPLIT_ROW_COUNT + "must be an integer"); - } - - String accountFormatProp = props.getOrDefault(MCProperties.ACCOUNT_FORMAT, MCProperties.DEFAULT_ACCOUNT_FORMAT); - if (accountFormatProp.equals(MCProperties.ACCOUNT_FORMAT_NAME)) { - accountFormat = AccountFormat.DISPLAYNAME; - } else if (accountFormatProp.equals(MCProperties.ACCOUNT_FORMAT_ID)) { - accountFormat = AccountFormat.ID; - } else { - throw new DdlException("property " + MCProperties.ACCOUNT_FORMAT + "only support name and id"); - } - - try { - connectTimeout = Integer.parseInt( - props.getOrDefault(MCProperties.CONNECT_TIMEOUT, MCProperties.DEFAULT_CONNECT_TIMEOUT)); - readTimeout = Integer.parseInt( - props.getOrDefault(MCProperties.READ_TIMEOUT, MCProperties.DEFAULT_READ_TIMEOUT)); - retryTimes = Integer.parseInt( - props.getOrDefault(MCProperties.RETRY_COUNT, MCProperties.DEFAULT_RETRY_COUNT)); - if (connectTimeout <= 0) { - throw new DdlException(MCProperties.CONNECT_TIMEOUT + " must be greater than 0"); - } - - if (readTimeout <= 0) { - throw new DdlException(MCProperties.READ_TIMEOUT + " must be greater than 0"); - } - - if (retryTimes <= 0) { - throw new DdlException(MCProperties.RETRY_COUNT + " must be greater than 0"); - } - - } catch (NumberFormatException e) { - throw new DdlException("property " + MCProperties.CONNECT_TIMEOUT + "/" - + MCProperties.READ_TIMEOUT + "/" + MCProperties.RETRY_COUNT + "must be an integer"); - } - - MCUtils.checkAuthProperties(props); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalDatabase.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalDatabase.java deleted file mode 100644 index 7cd38b9d13a007..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalDatabase.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.datasource.maxcompute; - -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.InitDatabaseLog; - -/** - * MaxCompute external database. - */ -public class MaxComputeExternalDatabase extends ExternalDatabase { - /** - * Create MaxCompute external database. - * - * @param extCatalog External catalog this database belongs to. - * @param id database id. - * @param name database name. - */ - public MaxComputeExternalDatabase(ExternalCatalog extCatalog, long id, String name, String remoteName) { - super(extCatalog, id, name, remoteName, InitDatabaseLog.Type.MAX_COMPUTE); - } - - @Override - public MaxComputeExternalTable buildTableInternal(String remoteTableName, String localTableName, long tblId, - ExternalCatalog catalog, - ExternalDatabase db) { - return new MaxComputeExternalTable(tblId, localTableName, remoteTableName, - (MaxComputeExternalCatalog) extCatalog, - (MaxComputeExternalDatabase) db); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalMetaCache.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalMetaCache.java deleted file mode 100644 index 05bf7e51e300d2..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalMetaCache.java +++ /dev/null @@ -1,115 +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.datasource.maxcompute; - -import org.apache.doris.common.Config; -import org.apache.doris.datasource.CacheException; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.SchemaCacheKey; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.TablePartitionValues; -import org.apache.doris.datasource.metacache.AbstractExternalMetaCache; -import org.apache.doris.datasource.metacache.CacheSpec; -import org.apache.doris.datasource.metacache.MetaCacheEntryDef; -import org.apache.doris.datasource.metacache.MetaCacheEntryInvalidation; - -import java.util.Collection; -import java.util.Collections; -import java.util.Map; -import java.util.concurrent.ExecutorService; - -/** - * MaxCompute engine implementation of {@link AbstractExternalMetaCache}. - * - *

    Registered entries: - *

      - *
    • {@code partition_values}: partition value/index structures per table
    • - *
    • {@code schema}: schema cache keyed by {@link SchemaCacheKey}
    • - *
    - */ -public class MaxComputeExternalMetaCache extends AbstractExternalMetaCache { - public static final String ENGINE = "maxcompute"; - public static final String ENTRY_PARTITION_VALUES = "partition_values"; - public static final String ENTRY_SCHEMA = "schema"; - private final EntryHandle partitionValuesEntry; - private final EntryHandle schemaEntry; - - public MaxComputeExternalMetaCache(ExecutorService refreshExecutor) { - super(ENGINE, refreshExecutor); - partitionValuesEntry = registerEntry(MetaCacheEntryDef.contextualOnly( - ENTRY_PARTITION_VALUES, - NameMapping.class, - TablePartitionValues.class, - CacheSpec.of( - true, - Config.external_cache_refresh_time_minutes * 60L, - Config.max_hive_partition_table_cache_num), - MetaCacheEntryInvalidation.forNameMapping(nameMapping -> nameMapping))); - schemaEntry = registerEntry(MetaCacheEntryDef.of( - ENTRY_SCHEMA, - SchemaCacheKey.class, - SchemaCacheValue.class, - this::loadSchemaCacheValue, - defaultSchemaCacheSpec(), - MetaCacheEntryInvalidation.forNameMapping(SchemaCacheKey::getNameMapping))); - } - - @Override - public Collection aliases() { - return Collections.singleton("max_compute"); - } - - public TablePartitionValues getPartitionValues(NameMapping nameMapping) { - return partitionValuesEntry.get(nameMapping.getCtlId()).get(nameMapping, this::loadPartitionValues); - } - - public MaxComputeSchemaCacheValue getMaxComputeSchemaCacheValue(long catalogId, SchemaCacheKey key) { - SchemaCacheValue schemaCacheValue = schemaEntry.get(catalogId).get(key); - return (MaxComputeSchemaCacheValue) schemaCacheValue; - } - - private SchemaCacheValue loadSchemaCacheValue(SchemaCacheKey key) { - ExternalTable dorisTable = findExternalTable(key.getNameMapping(), ENGINE); - return dorisTable.initSchemaAndUpdateTime(key).orElseThrow(() -> - new CacheException("failed to load maxcompute schema cache value for: %s.%s.%s", - null, key.getNameMapping().getCtlId(), key.getNameMapping().getLocalDbName(), - key.getNameMapping().getLocalTblName())); - } - - private TablePartitionValues loadPartitionValues(NameMapping nameMapping) { - MaxComputeSchemaCacheValue schemaCacheValue = - getMaxComputeSchemaCacheValue(nameMapping.getCtlId(), new SchemaCacheKey(nameMapping)); - TablePartitionValues partitionValues = new TablePartitionValues(); - partitionValues.addPartitions( - schemaCacheValue.getPartitionSpecs(), - schemaCacheValue.getPartitionSpecs().stream() - .map(spec -> MaxComputeExternalTable.parsePartitionValues( - schemaCacheValue.getPartitionColumnNames(), spec)) - .collect(java.util.stream.Collectors.toList()), - schemaCacheValue.getPartitionTypes(), - Collections.nCopies(schemaCacheValue.getPartitionSpecs().size(), 0L)); - return partitionValues; - } - - @Override - protected Map catalogPropertyCompatibilityMap() { - return singleCompatibilityMap(ExternalCatalog.SCHEMA_CACHE_TTL_SECOND, ENTRY_SCHEMA); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalTable.java deleted file mode 100644 index 1ca1e42b0c7ac2..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalTable.java +++ /dev/null @@ -1,366 +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.datasource.maxcompute; - -import org.apache.doris.catalog.ArrayType; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.MapType; -import org.apache.doris.catalog.PartitionItem; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.StructField; -import org.apache.doris.catalog.StructType; -import org.apache.doris.catalog.Type; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.TablePartitionValues; -import org.apache.doris.datasource.mvcc.MvccSnapshot; -import org.apache.doris.thrift.TMCTable; -import org.apache.doris.thrift.TTableDescriptor; -import org.apache.doris.thrift.TTableType; - -import com.aliyun.odps.OdpsType; -import com.aliyun.odps.Table; -import com.aliyun.odps.table.TableIdentifier; -import com.aliyun.odps.type.ArrayTypeInfo; -import com.aliyun.odps.type.CharTypeInfo; -import com.aliyun.odps.type.DecimalTypeInfo; -import com.aliyun.odps.type.MapTypeInfo; -import com.aliyun.odps.type.StructTypeInfo; -import com.aliyun.odps.type.TypeInfo; -import com.aliyun.odps.type.VarcharTypeInfo; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; - -/** - * MaxCompute external table. - */ -public class MaxComputeExternalTable extends ExternalTable { - public MaxComputeExternalTable(long id, String name, String remoteName, MaxComputeExternalCatalog catalog, - MaxComputeExternalDatabase db) { - super(id, name, remoteName, catalog, db, TableType.MAX_COMPUTE_EXTERNAL_TABLE); - } - - @Override - public String getMetaCacheEngine() { - return MaxComputeExternalMetaCache.ENGINE; - } - - @Override - protected synchronized void makeSureInitialized() { - super.makeSureInitialized(); - if (!objectCreated) { - objectCreated = true; - } - } - - @Override - public boolean supportInternalPartitionPruned() { - return true; - } - - @Override - public List getPartitionColumns(Optional snapshot) { - return getPartitionColumns(); - } - - public List getPartitionColumns() { - makeSureInitialized(); - Optional schemaCacheValue = getSchemaCacheValue(); - return schemaCacheValue.map(value -> ((MaxComputeSchemaCacheValue) value).getPartitionColumns()) - .orElse(Collections.emptyList()); - } - - @Override - public Map getNameToPartitionItems(Optional snapshot) { - if (getPartitionColumns().isEmpty()) { - return Collections.emptyMap(); - } - - TablePartitionValues tablePartitionValues = getPartitionValues(); - Map idToPartitionItem = tablePartitionValues.getIdToPartitionItem(); - Map idToNameMap = tablePartitionValues.getPartitionIdToNameMap(); - - Map nameToPartitionItem = Maps.newHashMapWithExpectedSize(idToPartitionItem.size()); - for (Entry entry : idToPartitionItem.entrySet()) { - nameToPartitionItem.put(idToNameMap.get(entry.getKey()), entry.getValue()); - } - return nameToPartitionItem; - } - - private TablePartitionValues getPartitionValues() { - makeSureInitialized(); - Optional schemaCacheValue = getSchemaCacheValue(); - if (!schemaCacheValue.isPresent()) { - return new TablePartitionValues(); - } - MaxComputeExternalMetaCache metadataCache = Env.getCurrentEnv().getExtMetaCacheMgr() - .maxCompute(getCatalog().getId()); - return metadataCache.getPartitionValues(getOrBuildNameMapping()); - } - - /** - * parse all values from partitionPath to a single list. - * In MaxCompute : Support special characters : _$#.!@ - * Ref : MaxCompute Error Code: ODPS-0130071 Invalid partition value. - * - * @param partitionColumns partitionColumns can contain the part1,part2,part3... - * @param partitionPath partitionPath format is like the 'part1=123/part2=abc/part3=1bc' - * @return all values of partitionPath - */ - static List parsePartitionValues(List partitionColumns, String partitionPath) { - String[] partitionFragments = partitionPath.split("/", -1); - if (partitionFragments.length != partitionColumns.size()) { - throw new RuntimeException("Failed to parse partition values of path: " + partitionPath); - } - Map partitionNameToValue = Maps.newHashMapWithExpectedSize(partitionFragments.length); - for (String partitionFragment : partitionFragments) { - int separatorIndex = partitionFragment.indexOf('='); - if (separatorIndex <= 0) { - throw new RuntimeException("Failed to parse partition values of path: " + partitionPath); - } - - String partitionName = partitionFragment.substring(0, separatorIndex); - if (!partitionColumns.contains(partitionName)) { - throw new RuntimeException("Unexpected partition column " + partitionName + " in path: " - + partitionPath); - } - - String partitionValue = partitionFragment.substring(separatorIndex + 1); - if (partitionNameToValue.put(partitionName, partitionValue) != null) { - throw new RuntimeException("Duplicate partition column " + partitionName + " in path: " - + partitionPath); - } - } - - List partitionValues = new ArrayList<>(partitionColumns.size()); - for (String partitionColumn : partitionColumns) { - if (!partitionNameToValue.containsKey(partitionColumn)) { - throw new RuntimeException("Missing partition column " + partitionColumn + " in path: " - + partitionPath); - } - partitionValues.add(partitionNameToValue.get(partitionColumn)); - } - return partitionValues; - } - - public Map getColumnNameToOdpsColumn() { - makeSureInitialized(); - Optional schemaCacheValue = getSchemaCacheValue(); - return schemaCacheValue.map(value -> ((MaxComputeSchemaCacheValue) value).getColumnNameToOdpsColumn()) - .orElse(Collections.emptyMap()); - } - - @Override - public Optional initSchema() { - // this method will be called at semantic parsing. - makeSureInitialized(); - MaxComputeExternalCatalog mcCatalog = (MaxComputeExternalCatalog) catalog; - - Table odpsTable = mcCatalog.getOdpsTable(dbName, name); - TableIdentifier tableIdentifier = mcCatalog.getOdpsTableIdentifier(dbName, name); - - List columns = odpsTable.getSchema().getColumns(); - Map columnNameToOdpsColumn = new HashMap<>(); - for (com.aliyun.odps.Column column : columns) { - columnNameToOdpsColumn.put(column.getName(), column); - } - - List schema = Lists.newArrayListWithCapacity(columns.size()); - for (com.aliyun.odps.Column field : columns) { - schema.add(new Column(field.getName(), mcTypeToDorisType(field.getTypeInfo()), true, null, - field.isNullable(), field.getComment(), true, -1)); - } - - List partitionColumns = odpsTable.getSchema().getPartitionColumns(); - List partitionColumnNames = new ArrayList<>(partitionColumns.size()); - List partitionTypes = new ArrayList<>(partitionColumns.size()); - - // sort partition columns to align partitionTypes and partitionName. - List partitionDorisColumns = new ArrayList<>(); - for (com.aliyun.odps.Column partColumn : partitionColumns) { - Type partitionType = mcTypeToDorisType(partColumn.getTypeInfo()); - Column dorisCol = new Column(partColumn.getName(), partitionType, true, null, - true, partColumn.getComment(), true, -1); - - columnNameToOdpsColumn.put(partColumn.getName(), partColumn); - partitionColumnNames.add(partColumn.getName()); - partitionDorisColumns.add(dorisCol); - partitionTypes.add(partitionType); - schema.add(dorisCol); - } - - List partitionSpecs; - if (!partitionColumns.isEmpty()) { - partitionSpecs = odpsTable.getPartitions().stream() - .map(e -> e.getPartitionSpec().toString(false, true)) - .collect(Collectors.toList()); - } else { - partitionSpecs = ImmutableList.of(); - } - - return Optional.of(new MaxComputeSchemaCacheValue(schema, odpsTable, tableIdentifier, - partitionColumnNames, partitionSpecs, partitionDorisColumns, partitionTypes, columnNameToOdpsColumn)); - } - - private Type mcTypeToDorisType(TypeInfo typeInfo) { - OdpsType odpsType = typeInfo.getOdpsType(); - switch (odpsType) { - case VOID: { - return Type.NULL; - } - case BOOLEAN: { - return Type.BOOLEAN; - } - case TINYINT: { - return Type.TINYINT; - } - case SMALLINT: { - return Type.SMALLINT; - } - case INT: { - return Type.INT; - } - case BIGINT: { - return Type.BIGINT; - } - case CHAR: { - CharTypeInfo charType = (CharTypeInfo) typeInfo; - return ScalarType.createChar(charType.getLength()); - } - case STRING: { - return ScalarType.createStringType(); - } - case VARCHAR: { - VarcharTypeInfo varcharType = (VarcharTypeInfo) typeInfo; - return ScalarType.createVarchar(varcharType.getLength()); - } - case JSON: { - return Type.UNSUPPORTED; - // return Type.JSONB; - } - case FLOAT: { - return Type.FLOAT; - } - case DOUBLE: { - return Type.DOUBLE; - } - case DECIMAL: { - DecimalTypeInfo decimal = (DecimalTypeInfo) typeInfo; - return ScalarType.createDecimalV3Type(decimal.getPrecision(), decimal.getScale()); - } - case DATE: { - return ScalarType.createDateV2Type(); - } - case DATETIME: { - return ScalarType.createDatetimeV2Type(3); - } - case TIMESTAMP: - case TIMESTAMP_NTZ: { - return ScalarType.createDatetimeV2Type(6); - } - case ARRAY: { - ArrayTypeInfo arrayType = (ArrayTypeInfo) typeInfo; - Type innerType = mcTypeToDorisType(arrayType.getElementTypeInfo()); - return ArrayType.create(innerType, true); - } - case MAP: { - MapTypeInfo mapType = (MapTypeInfo) typeInfo; - return new MapType(mcTypeToDorisType(mapType.getKeyTypeInfo()), - mcTypeToDorisType(mapType.getValueTypeInfo())); - } - case STRUCT: { - ArrayList fields = new ArrayList<>(); - StructTypeInfo structType = (StructTypeInfo) typeInfo; - List fieldNames = structType.getFieldNames(); - List fieldTypeInfos = structType.getFieldTypeInfos(); - for (int i = 0; i < structType.getFieldCount(); i++) { - Type innerType = mcTypeToDorisType(fieldTypeInfos.get(i)); - fields.add(new StructField(fieldNames.get(i), innerType)); - } - return new StructType(fields); - } - case BINARY: - case INTERVAL_DAY_TIME: - case INTERVAL_YEAR_MONTH: - return Type.UNSUPPORTED; - default: - throw new IllegalArgumentException("Cannot transform unknown type: " + odpsType); - } - } - - public TableIdentifier getTableIdentifier() { - makeSureInitialized(); - Optional schemaCacheValue = getSchemaCacheValue(); - return schemaCacheValue.map(value -> ((MaxComputeSchemaCacheValue) value).getTableIdentifier()) - .orElse(null); - } - - @Override - public TTableDescriptor toThrift() { - // ak sk endpoint project quota - List schema = getFullSchema(); - TMCTable tMcTable = new TMCTable(); - MaxComputeExternalCatalog mcCatalog = ((MaxComputeExternalCatalog) catalog); - - tMcTable.setProperties(mcCatalog.getProperties()); - tMcTable.setEndpoint(mcCatalog.getEndpoint()); - // use mc project as dbName - tMcTable.setProject(dbName); - tMcTable.setQuota(mcCatalog.getQuota()); - tMcTable.setTable(name); - TTableDescriptor tTableDescriptor = new TTableDescriptor(getId(), TTableType.MAX_COMPUTE_TABLE, - schema.size(), 0, getName(), dbName); - tTableDescriptor.setMcTable(tMcTable); - return tTableDescriptor; - } - - public Table getOdpsTable() { - makeSureInitialized(); - Optional schemaCacheValue = getSchemaCacheValue(); - return schemaCacheValue.map(value -> ((MaxComputeSchemaCacheValue) value).getOdpsTable()) - .orElse(null); - } - - public boolean isUnsupportedOdpsTable() { - Table odpsTable = getOdpsTable(); - return isUnsupportedOdpsTable(odpsTable); - } - - public static boolean isUnsupportedOdpsTable(Table odpsTable) { - Objects.requireNonNull(odpsTable, "MaxCompute table metadata is not initialized"); - return odpsTable.isExternalTable() || odpsTable.isVirtualView(); - } - - @Override - public boolean isPartitionedTable() { - makeSureInitialized(); - return getOdpsTable().isPartitioned(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeMetadataOps.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeMetadataOps.java deleted file mode 100644 index f9bda6936c9a40..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeMetadataOps.java +++ /dev/null @@ -1,565 +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.datasource.maxcompute; - -import org.apache.doris.analysis.DistributionDesc; -import org.apache.doris.analysis.Expr; -import org.apache.doris.analysis.ExprToSqlVisitor; -import org.apache.doris.analysis.FunctionCallExpr; -import org.apache.doris.analysis.HashDistributionDesc; -import org.apache.doris.analysis.PartitionDesc; -import org.apache.doris.analysis.SlotRef; -import org.apache.doris.analysis.ToSqlParams; -import org.apache.doris.catalog.ArrayType; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.MapType; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.StructField; -import org.apache.doris.catalog.StructType; -import org.apache.doris.catalog.Type; -import org.apache.doris.catalog.info.CreateOrReplaceBranchInfo; -import org.apache.doris.catalog.info.CreateOrReplaceTagInfo; -import org.apache.doris.catalog.info.DropBranchInfo; -import org.apache.doris.catalog.info.DropTagInfo; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.ErrorCode; -import org.apache.doris.common.ErrorReport; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.operations.ExternalMetadataOps; -import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo; - -import com.aliyun.odps.Odps; -import com.aliyun.odps.OdpsException; -import com.aliyun.odps.TableSchema; -import com.aliyun.odps.Tables; -import com.aliyun.odps.type.TypeInfo; -import com.aliyun.odps.type.TypeInfoFactory; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; - -/** - * MaxCompute metadata operations for DDL support (CREATE TABLE, etc.) - */ -public class MaxComputeMetadataOps implements ExternalMetadataOps { - private static final Logger LOG = LogManager.getLogger(MaxComputeMetadataOps.class); - - private static final long MAX_LIFECYCLE_DAYS = 37231; - private static final int MAX_BUCKET_NUM = 1024; - - private final MaxComputeExternalCatalog dorisCatalog; - private final Odps odps; - - public MaxComputeMetadataOps(MaxComputeExternalCatalog dorisCatalog, Odps odps) { - this.dorisCatalog = dorisCatalog; - this.odps = odps; - } - - @Override - public void close() { - } - - @Override - public boolean tableExist(String dbName, String tblName) { - return dorisCatalog.tableExist(null, dbName, tblName); - } - - @Override - public boolean databaseExist(String dbName) { - return dorisCatalog.getMcStructureHelper().databaseExist(dorisCatalog.getClient(), dbName); - } - - @Override - public List listDatabaseNames() { - return dorisCatalog.listDatabaseNames(); - } - - @Override - public List listTableNames(String dbName) { - return dorisCatalog.listTableNames(null, dbName); - } - - // ==================== Create/Drop Database ==================== - - @Override - public boolean createDbImpl(String dbName, boolean ifNotExists, Map properties) - throws DdlException { - ExternalDatabase dorisDb = dorisCatalog.getDbNullable(dbName); - boolean exists = databaseExist(dbName); - if (dorisDb != null || exists) { - if (ifNotExists) { - LOG.info("create database[{}] which already exists", dbName); - return true; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_DB_CREATE_EXISTS, dbName); - } - } - dorisCatalog.getMcStructureHelper().createDb(odps, dbName, ifNotExists); - return false; - } - - @Override - public void afterCreateDb() { - dorisCatalog.resetMetaCacheNames(); - } - - @Override - public void dropDbImpl(String dbName, boolean ifExists, boolean force) throws DdlException { - ExternalDatabase dorisDb = dorisCatalog.getDbNullable(dbName); - if (dorisDb == null) { - if (ifExists) { - LOG.info("drop database[{}] which does not exist", dbName); - return; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_DB_DROP_EXISTS, dbName); - } - } - if (force) { - List remoteTableNames = listTableNames(dorisDb.getRemoteName()); - for (String remoteTableName : remoteTableNames) { - ExternalTable tbl = null; - try { - tbl = (ExternalTable) dorisDb.getTableOrDdlException(remoteTableName); - } catch (DdlException e) { - LOG.warn("failed to get table when force drop database [{}], table[{}], error: {}", - dbName, remoteTableName, e.getMessage()); - continue; - } - dropTableImpl(tbl, true); - } - } - dorisCatalog.getMcStructureHelper().dropDb(odps, dbName, ifExists); - } - - @Override - public void afterDropDb(String dbName) { - dorisCatalog.unregisterDatabase(dbName); - } - - // ==================== Create Table ==================== - - @Override - public boolean createTableImpl(CreateTableInfo createTableInfo) throws UserException { - String dbName = createTableInfo.getDbName(); - String tableName = createTableInfo.getTableName(); - - // 1. Validate database existence - ExternalDatabase db = dorisCatalog.getDbNullable(dbName); - if (db == null) { - throw new UserException( - "Failed to get database: '" + dbName + "' in catalog: " + dorisCatalog.getName()); - } - - // 2. Check if table exists in remote - if (tableExist(db.getRemoteName(), tableName)) { - if (createTableInfo.isIfNotExists()) { - LOG.info("create table[{}] which already exists", tableName); - return true; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_TABLE_EXISTS_ERROR, tableName); - } - } - - // 3. Check if table exists in local (case sensitivity issue) - ExternalTable dorisTable = db.getTableNullable(tableName); - if (dorisTable != null) { - if (createTableInfo.isIfNotExists()) { - LOG.info("create table[{}] which already exists", tableName); - return true; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_TABLE_EXISTS_ERROR, tableName); - } - } - - // 4. Validate columns - List columns = createTableInfo.getColumns(); - validateColumns(columns); - - // 5. Validate partition description - PartitionDesc partitionDesc = createTableInfo.getPartitionDesc(); - validatePartitionDesc(partitionDesc); - - // 6. Build MaxCompute TableSchema - TableSchema schema = buildMaxComputeTableSchema(columns, partitionDesc); - - // 7. Extract properties - Map properties = createTableInfo.getProperties(); - Long lifecycle = extractLifecycle(properties); - Map mcProperties = extractMaxComputeProperties(properties); - Integer bucketNum = extractBucketNum(createTableInfo); - - // 8. Create table via MaxCompute SDK - McStructureHelper structureHelper = dorisCatalog.getMcStructureHelper(); - Tables.TableCreator creator = structureHelper.createTableCreator( - odps, db.getRemoteName(), tableName, schema); - - if (createTableInfo.isIfNotExists()) { - creator.ifNotExists(); - } - - String comment = createTableInfo.getComment(); - if (comment != null && !comment.isEmpty()) { - creator.withComment(comment); - } - - if (lifecycle != null) { - creator.withLifeCycle(lifecycle); - } - - if (!mcProperties.isEmpty()) { - creator.withTblProperties(mcProperties); - } - - if (bucketNum != null) { - creator.withDeltaTableBucketNum(bucketNum); - } - - try { - creator.create(); - } catch (OdpsException e) { - throw new DdlException("Failed to create MaxCompute table '" + tableName + "': " + e.getMessage(), e); - } - - return false; - } - - @Override - public void afterCreateTable(String dbName, String tblName) { - Optional> db = dorisCatalog.getDbForReplay(dbName); - if (db.isPresent()) { - db.get().resetMetaCacheNames(); - } - LOG.info("after create table {}.{}.{}, is db exists: {}", - dorisCatalog.getName(), dbName, tblName, db.isPresent()); - } - - // ==================== Drop Table (not supported yet) ==================== - - @Override - public void dropTableImpl(ExternalTable dorisTable, boolean ifExists) throws DdlException { - // Get remote names (handles case-sensitivity) - String remoteDbName = dorisTable.getRemoteDbName(); - String remoteTblName = dorisTable.getRemoteName(); - - // Check table existence - if (!tableExist(remoteDbName, remoteTblName)) { - if (ifExists) { - LOG.info("drop table[{}.{}] which does not exist", remoteDbName, remoteTblName); - return; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_UNKNOWN_TABLE, - remoteTblName, remoteDbName); - } - } - - // Drop table via McStructureHelper - try { - McStructureHelper structureHelper = dorisCatalog.getMcStructureHelper(); - structureHelper.dropTable(odps, remoteDbName, remoteTblName, ifExists); - LOG.info("Successfully dropped MaxCompute table: {}.{}", remoteDbName, remoteTblName); - } catch (OdpsException e) { - throw new DdlException("Failed to drop MaxCompute table '" - + remoteTblName + "': " + e.getMessage(), e); - } - } - - @Override - public void afterDropTable(String dbName, String tblName) { - Optional> db = dorisCatalog.getDbForReplay(dbName); - if (db.isPresent()) { - db.get().unregisterTable(tblName); - } - LOG.info("after drop table {}.{}.{}, is db exists: {}", - dorisCatalog.getName(), dbName, tblName, db.isPresent()); - } - - @Override - public void truncateTableImpl(ExternalTable dorisTable, List partitions) throws DdlException { - throw new DdlException("Truncate table is not supported for MaxCompute catalog."); - } - - // ==================== Branch/Tag (not supported) ==================== - - @Override - public void createOrReplaceBranchImpl(ExternalTable dorisTable, CreateOrReplaceBranchInfo branchInfo) - throws UserException { - throw new UserException("Branch operations are not supported for MaxCompute catalog."); - } - - @Override - public void createOrReplaceTagImpl(ExternalTable dorisTable, CreateOrReplaceTagInfo tagInfo) - throws UserException { - throw new UserException("Tag operations are not supported for MaxCompute catalog."); - } - - @Override - public void dropTagImpl(ExternalTable dorisTable, DropTagInfo tagInfo) throws UserException { - throw new UserException("Tag operations are not supported for MaxCompute catalog."); - } - - @Override - public void dropBranchImpl(ExternalTable dorisTable, DropBranchInfo branchInfo) throws UserException { - throw new UserException("Branch operations are not supported for MaxCompute catalog."); - } - - // ==================== Type Conversion ==================== - - /** - * Convert Doris type to MaxCompute TypeInfo. - */ - public static TypeInfo dorisTypeToMcType(Type dorisType) throws UserException { - if (dorisType.isScalarType()) { - return dorisScalarTypeToMcType(dorisType); - } else if (dorisType.isArrayType()) { - ArrayType arrayType = (ArrayType) dorisType; - TypeInfo elementType = dorisTypeToMcType(arrayType.getItemType()); - return TypeInfoFactory.getArrayTypeInfo(elementType); - } else if (dorisType.isMapType()) { - MapType mapType = (MapType) dorisType; - TypeInfo keyType = dorisTypeToMcType(mapType.getKeyType()); - TypeInfo valueType = dorisTypeToMcType(mapType.getValueType()); - return TypeInfoFactory.getMapTypeInfo(keyType, valueType); - } else if (dorisType.isStructType()) { - StructType structType = (StructType) dorisType; - List fields = structType.getFields(); - List fieldNames = new ArrayList<>(fields.size()); - List fieldTypes = new ArrayList<>(fields.size()); - for (StructField field : fields) { - fieldNames.add(field.getName()); - fieldTypes.add(dorisTypeToMcType(field.getType())); - } - return TypeInfoFactory.getStructTypeInfo(fieldNames, fieldTypes); - } else { - throw new UserException("Unsupported Doris type for MaxCompute: " + dorisType); - } - } - - private static TypeInfo dorisScalarTypeToMcType(Type dorisType) throws UserException { - PrimitiveType primitiveType = dorisType.getPrimitiveType(); - switch (primitiveType) { - case BOOLEAN: - return TypeInfoFactory.BOOLEAN; - case TINYINT: - return TypeInfoFactory.TINYINT; - case SMALLINT: - return TypeInfoFactory.SMALLINT; - case INT: - return TypeInfoFactory.INT; - case BIGINT: - return TypeInfoFactory.BIGINT; - case FLOAT: - return TypeInfoFactory.FLOAT; - case DOUBLE: - return TypeInfoFactory.DOUBLE; - case CHAR: - return TypeInfoFactory.getCharTypeInfo(((ScalarType) dorisType).getLength()); - case VARCHAR: - return TypeInfoFactory.getVarcharTypeInfo(((ScalarType) dorisType).getLength()); - case STRING: - return TypeInfoFactory.STRING; - case DECIMALV2: - case DECIMAL32: - case DECIMAL64: - case DECIMAL128: - case DECIMAL256: - return TypeInfoFactory.getDecimalTypeInfo( - ((ScalarType) dorisType).getScalarPrecision(), - ((ScalarType) dorisType).getScalarScale()); - case DATE: - case DATEV2: - return TypeInfoFactory.DATE; - case DATETIME: - case DATETIMEV2: - return TypeInfoFactory.DATETIME; - case LARGEINT: - case HLL: - case BITMAP: - case QUANTILE_STATE: - case AGG_STATE: - case JSONB: - case VARIANT: - case IPV4: - case IPV6: - default: - throw new UserException( - "Unsupported Doris type for MaxCompute: " + primitiveType); - } - } - - // ==================== Validation ==================== - - private void validateColumns(List columns) throws UserException { - if (columns == null || columns.isEmpty()) { - throw new UserException("Table must have at least one column."); - } - Set columnNames = new HashSet<>(); - for (Column col : columns) { - if (col.isAutoInc()) { - throw new UserException( - "Auto-increment columns are not supported for MaxCompute tables: " + col.getName()); - } - if (col.isAggregated()) { - throw new UserException( - "Aggregation columns are not supported for MaxCompute tables: " + col.getName()); - } - String lowerName = col.getName().toLowerCase(); - if (!columnNames.add(lowerName)) { - throw new UserException("Duplicate column name: " + col.getName()); - } - // Validate that the type is convertible - dorisTypeToMcType(col.getType()); - } - } - - private void validatePartitionDesc(PartitionDesc partitionDesc) throws UserException { - if (partitionDesc == null) { - return; - } - ArrayList exprs = partitionDesc.getPartitionExprs(); - if (exprs == null || exprs.isEmpty()) { - return; - } - for (Expr expr : exprs) { - if (expr instanceof SlotRef) { - // Identity partition - OK - } else if (expr instanceof FunctionCallExpr) { - String funcName = ((FunctionCallExpr) expr).getFnName().getFunction(); - throw new UserException( - "MaxCompute does not support partition transform '" + funcName - + "'. Only identity partitions are supported."); - } else { - throw new UserException("Invalid partition expression: " - + expr.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE)); - } - } - } - - // ==================== Schema Building ==================== - - private TableSchema buildMaxComputeTableSchema(List columns, PartitionDesc partitionDesc) - throws UserException { - Set partitionColNames = new HashSet<>(); - if (partitionDesc != null && partitionDesc.getPartitionColNames() != null) { - for (String name : partitionDesc.getPartitionColNames()) { - partitionColNames.add(name.toLowerCase()); - } - } - - TableSchema schema = new TableSchema(); - - // Add regular columns (non-partition) - for (Column col : columns) { - if (!partitionColNames.contains(col.getName().toLowerCase())) { - TypeInfo mcType = dorisTypeToMcType(col.getType()); - com.aliyun.odps.Column mcCol = new com.aliyun.odps.Column( - col.getName(), mcType, col.getComment()); - schema.addColumn(mcCol); - } - } - - // Add partition columns in the order specified by partitionDesc - if (partitionDesc != null && partitionDesc.getPartitionColNames() != null) { - for (String partColName : partitionDesc.getPartitionColNames()) { - Column col = findColumnByName(columns, partColName); - if (col == null) { - throw new UserException("Partition column '" + partColName + "' not found in column definitions."); - } - TypeInfo mcType = dorisTypeToMcType(col.getType()); - com.aliyun.odps.Column mcCol = new com.aliyun.odps.Column( - col.getName(), mcType, col.getComment()); - schema.addPartitionColumn(mcCol); - } - } - - return schema; - } - - private Column findColumnByName(List columns, String name) { - for (Column col : columns) { - if (col.getName().equalsIgnoreCase(name)) { - return col; - } - } - return null; - } - - // ==================== Property Extraction ==================== - - private Long extractLifecycle(Map properties) throws UserException { - String lifecycleStr = properties.get("mc.lifecycle"); - if (lifecycleStr == null) { - lifecycleStr = properties.get("lifecycle"); - } - if (lifecycleStr != null) { - try { - long lifecycle = Long.parseLong(lifecycleStr); - if (lifecycle <= 0 || lifecycle > MAX_LIFECYCLE_DAYS) { - throw new UserException( - "Invalid lifecycle value: " + lifecycle - + ". Must be between 1 and " + MAX_LIFECYCLE_DAYS + "."); - } - return lifecycle; - } catch (NumberFormatException e) { - throw new UserException("Invalid lifecycle value: '" + lifecycleStr + "'. Must be a positive integer."); - } - } - return null; - } - - private Map extractMaxComputeProperties(Map properties) { - Map mcProperties = new HashMap<>(); - for (Map.Entry entry : properties.entrySet()) { - if (entry.getKey().startsWith("mc.tblproperty.")) { - String mcKey = entry.getKey().substring("mc.tblproperty.".length()); - mcProperties.put(mcKey, entry.getValue()); - } - } - return mcProperties; - } - - private Integer extractBucketNum(CreateTableInfo createTableInfo) throws UserException { - DistributionDesc distributionDesc = createTableInfo.getDistributionDesc(); - if (distributionDesc == null) { - return null; - } - if (!(distributionDesc instanceof HashDistributionDesc)) { - throw new UserException( - "MaxCompute only supports hash distribution. Got: " + distributionDesc.getClass().getSimpleName()); - } - - HashDistributionDesc hashDist = (HashDistributionDesc) distributionDesc; - int bucketNum = hashDist.getBuckets(); - - if (bucketNum <= 0 || bucketNum > MAX_BUCKET_NUM) { - throw new UserException( - "Invalid bucket number: " + bucketNum + ". Must be between 1 and " + MAX_BUCKET_NUM + "."); - } - - return bucketNum; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeSchemaCacheValue.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeSchemaCacheValue.java deleted file mode 100644 index cd734985e6e92b..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/MaxComputeSchemaCacheValue.java +++ /dev/null @@ -1,67 +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.datasource.maxcompute; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Type; -import org.apache.doris.datasource.SchemaCacheValue; - -import com.aliyun.odps.Table; -import com.aliyun.odps.table.TableIdentifier; -import lombok.Getter; -import lombok.Setter; - -import java.util.List; -import java.util.Map; - -@Getter -@Setter -public class MaxComputeSchemaCacheValue extends SchemaCacheValue { - private Table odpsTable; - private TableIdentifier tableIdentifier; - private List partitionColumnNames; - private List partitionSpecs; - private List partitionColumns; - private List partitionTypes; - private Map columnNameToOdpsColumn; - - public MaxComputeSchemaCacheValue(List schema, Table odpsTable, TableIdentifier tableIdentifier, - List partitionColumnNames, List partitionSpecs, List partitionColumns, - List partitionTypes, Map columnNameToOdpsColumn) { - super(schema); - this.odpsTable = odpsTable; - this.tableIdentifier = tableIdentifier; - this.partitionSpecs = partitionSpecs; - this.partitionColumnNames = partitionColumnNames; - this.partitionColumns = partitionColumns; - this.partitionTypes = partitionTypes; - this.columnNameToOdpsColumn = columnNameToOdpsColumn; - } - - public List getPartitionColumns() { - return partitionColumns; - } - - public List getPartitionColumnNames() { - return partitionColumnNames; - } - - public Map getColumnNameToOdpsColumn() { - return columnNameToOdpsColumn; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/McStructureHelper.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/McStructureHelper.java deleted file mode 100644 index 82fad60f3da014..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/McStructureHelper.java +++ /dev/null @@ -1,298 +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.datasource.maxcompute; - - -import org.apache.doris.common.DdlException; - -import com.aliyun.odps.Odps; -import com.aliyun.odps.OdpsException; -import com.aliyun.odps.Partition; -import com.aliyun.odps.Project; -import com.aliyun.odps.Schema; -import com.aliyun.odps.Table; -import com.aliyun.odps.TableSchema; -import com.aliyun.odps.Tables; -import com.aliyun.odps.security.SecurityManager; -import com.aliyun.odps.table.TableIdentifier; -import com.aliyun.odps.utils.StringUtils; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; - - -/** - * Due to the introduction of the `mc.enable.namespace.schema` property, most interfaces using the - * ODPS client have changed, and the mapping structure between Doris and MaxCompute has also changed. - * Different property values correspond to different implementation class. - * It's important to note that when external functions are called through the interface, the structure - * mapped by Doris (database/table) is used, and the MaxCompute concept does not need to be considered. - */ -public interface McStructureHelper { - List listTableNames(Odps mcClient, String dbName); - - List listDatabaseNames(Odps mcClient, String defaultProject); - - boolean tableExist(Odps mcClient, String dbName, String tableName) throws RuntimeException; - - boolean databaseExist(Odps mcClient, String dbName); - - TableIdentifier getTableIdentifier(String dbName, String tableName); - - List getPartitions(Odps mcClient, String dbName, String tableName); - - Iterator getPartitionIterator(Odps mcClient, String dbName, String tableName); - - Table getOdpsTable(Odps mcClient, String dbName, String tableName); - - Tables.TableCreator createTableCreator(Odps mcClient, String dbName, String tableName, TableSchema schema); - - void dropTable(Odps mcClient, String dbName, String tableName, boolean ifExists) throws OdpsException; - - void createDb(Odps mcClient, String dbName, boolean ifNotExists) throws DdlException; - - void dropDb(Odps mcClient, String dbName, boolean ifExists) throws DdlException; - - /** - * `mc.enable.namespace.schema` = true. - * mapping structure between Doris and MaxCompute: - * Doris : catalog, dbName, tableName - * MaxCompute: project, schema, table - */ - class ProjectSchemaTableHelper implements McStructureHelper { - private String defaultProjectName = null; - - public ProjectSchemaTableHelper(String defaultProjectName) { - this.defaultProjectName = defaultProjectName; - } - - @Override - public List listTableNames(Odps mcClient, String dbName) { - List result = new ArrayList<>(); - mcClient.tables().iterable(defaultProjectName, dbName, null, false) - .forEach(e -> result.add(e.getName())); - return result; - } - - @Override - public List listDatabaseNames(Odps mcClient, String defaultProject) { - List result = new ArrayList<>(); - Iterator iterator = mcClient.schemas().iterator(defaultProjectName); - while (iterator.hasNext()) { - Schema schema = iterator.next(); - result.add(schema.getName()); - } - return result; - } - - @Override - public List getPartitions(Odps mcClient, String dbName, String tableName) { - return mcClient.tables().get(defaultProjectName, dbName, tableName).getPartitions(); - } - - @Override - public Iterator getPartitionIterator(Odps mcClient, String dbName, String tableName) { - return mcClient.tables().get(defaultProjectName, dbName, tableName).getPartitions().iterator(); - } - - @Override - public boolean tableExist(Odps mcClient, String dbName, String tableName) throws RuntimeException { - try { - return mcClient.tables().exists(defaultProjectName, dbName, tableName); - } catch (OdpsException e) { - throw new RuntimeException(e); - } - } - - @Override - public boolean databaseExist(Odps mcClient, String dbName) throws RuntimeException { - try { - return mcClient.schemas().exists(dbName); - } catch (OdpsException e) { - throw new RuntimeException(e); - } - } - - @Override - public TableIdentifier getTableIdentifier(String dbName, String tableName) { - return TableIdentifier.of(defaultProjectName, dbName, tableName); - } - - @Override - public Table getOdpsTable(Odps mcClient, String dbName, String tableName) { - return mcClient.tables().get(defaultProjectName, dbName, tableName); - } - - @Override - public Tables.TableCreator createTableCreator(Odps mcClient, String dbName, String tableName, - TableSchema schema) { - // dbName is the schema name, defaultProjectName is the project - return mcClient.tables().newTableCreator(defaultProjectName, tableName, schema) - .withSchemaName(dbName); - } - - @Override - public void dropTable(Odps mcClient, String dbName, String tableName, boolean ifExists) - throws OdpsException { - // dbName is the schema name, defaultProjectName is the project - mcClient.tables().delete(defaultProjectName, dbName, tableName, ifExists); - } - - @Override - public void createDb(Odps mcClient, String dbName, boolean ifNotExists) throws DdlException { - try { - if (ifNotExists && mcClient.schemas().exists(dbName)) { - return; - } - mcClient.schemas().create(defaultProjectName, dbName); - } catch (OdpsException e) { - throw new DdlException("Failed to create schema '" + dbName + "': " + e.getMessage(), e); - } - } - - @Override - public void dropDb(Odps mcClient, String dbName, boolean ifExists) throws DdlException { - try { - if (ifExists && !mcClient.schemas().exists(dbName)) { - return; - } - mcClient.schemas().delete(defaultProjectName, dbName); - } catch (OdpsException e) { - throw new DdlException("Failed to drop schema '" + dbName + "': " + e.getMessage(), e); - } - } - } - - /** - * `mc.enable.namespace.schema` = false. - * mapping structure between Doris and MaxCompute: - * Doris : dbName, tableName - * MaxCompute: project, table - */ - class ProjectTableHelper implements McStructureHelper { - private String catalogOwner = null; - - @Override - public boolean tableExist(Odps mcClient, String dbName, String tableName) throws RuntimeException { - try { - return mcClient.tables().exists(dbName, tableName); - } catch (OdpsException e) { - throw new RuntimeException(e); - } - } - - - @Override - public List listTableNames(Odps mcClient, String dbName) { - List result = new ArrayList<>(); - mcClient.tables().iterable(dbName).forEach(e -> result.add(e.getName())); - return result; - } - - @Override - public List listDatabaseNames(Odps mcClient, String defaultProject) { - List result = new ArrayList<>(); - result.add(defaultProject); - try { - result.add(defaultProject); - if (StringUtils.isNullOrEmpty(catalogOwner)) { - SecurityManager sm = mcClient.projects().get().getSecurityManager(); - String whoami = sm.runQuery("whoami", false); - - JsonObject js = JsonParser.parseString(whoami).getAsJsonObject(); - catalogOwner = js.get("DisplayName").getAsString(); - } - Iterator iterator = mcClient.projects().iterator(catalogOwner); - while (iterator.hasNext()) { - Project project = iterator.next(); - if (!project.getName().equals(defaultProject)) { - result.add(project.getName()); - } - } - } catch (OdpsException e) { - throw new RuntimeException(e); - } - return result; - } - - @Override - public List getPartitions(Odps mcClient, String dbName, String tableName) { - return mcClient.tables().get(dbName, tableName).getPartitions(); - } - - @Override - public Iterator getPartitionIterator(Odps mcClient, String dbName, String tableName) { - return mcClient.tables().get(dbName, tableName).getPartitions().iterator(); - } - - @Override - public boolean databaseExist(Odps mcClient, String dbName) throws RuntimeException { - try { - return mcClient.projects().exists(dbName); - } catch (OdpsException e) { - throw new RuntimeException(e); - } - } - - @Override - public TableIdentifier getTableIdentifier(String dbName, String tableName) { - return TableIdentifier.of(dbName, tableName); - } - - - @Override - public Table getOdpsTable(Odps mcClient, String dbName, String tableName) { - return mcClient.tables().get(dbName, tableName); - } - - @Override - public Tables.TableCreator createTableCreator(Odps mcClient, String dbName, String tableName, - TableSchema schema) { - // dbName is the project name - return mcClient.tables().newTableCreator(dbName, tableName, schema); - } - - @Override - public void dropTable(Odps mcClient, String dbName, String tableName, boolean ifExists) - throws OdpsException { - // dbName is the project name - mcClient.tables().delete(dbName, tableName, ifExists); - } - - @Override - public void createDb(Odps mcClient, String dbName, boolean ifNotExists) throws DdlException { - throw new DdlException( - "Create database is not supported when mc.enable.namespace.schema is false."); - } - - @Override - public void dropDb(Odps mcClient, String dbName, boolean ifExists) throws DdlException { - throw new DdlException( - "Drop database is not supported when mc.enable.namespace.schema is false."); - } - } - - static McStructureHelper getHelper(boolean isEnableNamespaceSchema, String defaultProjectName) { - return isEnableNamespaceSchema - ? new ProjectSchemaTableHelper(defaultProjectName) - : new ProjectTableHelper(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/source/MaxComputeScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/source/MaxComputeScanNode.java deleted file mode 100644 index 7df4203988f0c8..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/source/MaxComputeScanNode.java +++ /dev/null @@ -1,814 +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.datasource.maxcompute.source; - -import org.apache.doris.analysis.BinaryPredicate; -import org.apache.doris.analysis.CompoundPredicate; -import org.apache.doris.analysis.CompoundPredicate.Operator; -import org.apache.doris.analysis.DateLiteral; -import org.apache.doris.analysis.Expr; -import org.apache.doris.analysis.ExprToExprNameVisitor; -import org.apache.doris.analysis.InPredicate; -import org.apache.doris.analysis.IsNullPredicate; -import org.apache.doris.analysis.LiteralExpr; -import org.apache.doris.analysis.SlotRef; -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.UserException; -import org.apache.doris.common.maxcompute.MCProperties; -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.FileQueryScanNode; -import org.apache.doris.datasource.TableFormatType; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalTable; -import org.apache.doris.datasource.maxcompute.source.MaxComputeSplit.SplitType; -import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan.SelectedPartitions; -import org.apache.doris.nereids.util.DateUtils; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.spi.Split; -import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.TFileRangeDesc; -import org.apache.doris.thrift.TMaxComputeFileDesc; -import org.apache.doris.thrift.TTableFormatFileDesc; - -import com.aliyun.odps.OdpsType; -import com.aliyun.odps.PartitionSpec; -import com.aliyun.odps.table.configuration.ArrowOptions; -import com.aliyun.odps.table.configuration.ArrowOptions.TimestampUnit; -import com.aliyun.odps.table.configuration.SplitOptions; -import com.aliyun.odps.table.optimizer.predicate.Predicate; -import com.aliyun.odps.table.read.TableBatchReadSession; -import com.aliyun.odps.table.read.TableReadSessionBuilder; -import com.aliyun.odps.table.read.split.InputSplitAssigner; -import com.aliyun.odps.table.read.split.impl.IndexedInputSplit; -import jline.internal.Log; -import lombok.Setter; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.ObjectOutputStream; -import java.io.Serializable; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Base64; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Collectors; - -public class MaxComputeScanNode extends FileQueryScanNode { - static final DateTimeFormatter dateTime3Formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); - static final DateTimeFormatter dateTime6Formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"); - - private static final Logger LOG = LogManager.getLogger(MaxComputeScanNode.class); - - private final MaxComputeExternalTable table; - private Predicate filterPredicate; - List requiredPartitionColumns = new ArrayList<>(); - List orderedRequiredDataColumns = new ArrayList<>(); - - private int connectTimeout; - private int readTimeout; - private int retryTimes; - - private boolean onlyPartitionEqualityPredicate = false; - - @Setter - private SelectedPartitions selectedPartitions = null; - - private static final LocationPath ROW_OFFSET_PATH = LocationPath.of("/row_offset"); - private static final LocationPath BYTE_SIZE_PATH = LocationPath.of("/byte_size"); - - - // For new planner - public MaxComputeScanNode(PlanNodeId id, TupleDescriptor desc, - SelectedPartitions selectedPartitions, boolean needCheckColumnPriv, - SessionVariable sv, ScanContext scanContext) { - this(id, desc, "MCScanNode", selectedPartitions, needCheckColumnPriv, sv, scanContext); - } - - private MaxComputeScanNode(PlanNodeId id, TupleDescriptor desc, String planNodeName, - SelectedPartitions selectedPartitions, boolean needCheckColumnPriv, SessionVariable sv, - ScanContext scanContext) { - super(id, desc, planNodeName, scanContext, needCheckColumnPriv, sv); - table = (MaxComputeExternalTable) desc.getTable(); - this.selectedPartitions = selectedPartitions; - } - - @Override - protected void setScanParams(TFileRangeDesc rangeDesc, Split split) { - if (split instanceof MaxComputeSplit) { - setScanParams(rangeDesc, (MaxComputeSplit) split); - } - } - - private void setScanParams(TFileRangeDesc rangeDesc, MaxComputeSplit maxComputeSplit) { - TTableFormatFileDesc tableFormatFileDesc = new TTableFormatFileDesc(); - tableFormatFileDesc.setTableFormatType(TableFormatType.MAX_COMPUTE.value()); - TMaxComputeFileDesc fileDesc = new TMaxComputeFileDesc(); - fileDesc.setPartitionSpec("deprecated"); - fileDesc.setTableBatchReadSession(maxComputeSplit.scanSerialize); - fileDesc.setSessionId(maxComputeSplit.getSessionId()); - - fileDesc.setReadTimeout(readTimeout); - fileDesc.setConnectTimeout(connectTimeout); - fileDesc.setRetryTimes(retryTimes); - - tableFormatFileDesc.setMaxComputeParams(fileDesc); - rangeDesc.setTableFormatParams(tableFormatFileDesc); - rangeDesc.setPath("[ " + maxComputeSplit.getStart() + " , " + maxComputeSplit.getLength() + " ]"); - rangeDesc.setStartOffset(maxComputeSplit.getStart()); - rangeDesc.setSize(maxComputeSplit.getLength()); - } - - - private void createRequiredColumns() { - Set requiredSlots = - desc.getSlots().stream().map(e -> e.getColumn().getName()).collect(Collectors.toSet()); - - Set partitionColumns = - table.getPartitionColumns().stream().map(Column::getName).collect(Collectors.toSet()); - - requiredPartitionColumns.clear(); - orderedRequiredDataColumns.clear(); - - for (Column column : table.getColumns()) { - String columnName = column.getName(); - if (!requiredSlots.contains(columnName)) { - continue; - } - if (partitionColumns.contains(columnName)) { - requiredPartitionColumns.add(columnName); - } else { - orderedRequiredDataColumns.add(columnName); - } - } - } - - /** - * For no partition table: request requiredPartitionSpecs is empty - * For partition table: if requiredPartitionSpecs is empty, get all partition data. - */ - TableBatchReadSession createTableBatchReadSession(List requiredPartitionSpecs) throws IOException { - MaxComputeExternalCatalog mcCatalog = (MaxComputeExternalCatalog) table.getCatalog(); - return createTableBatchReadSession(requiredPartitionSpecs, mcCatalog.getSplitOption()); - } - - TableBatchReadSession createTableBatchReadSession( - List requiredPartitionSpecs, SplitOptions splitOptions) throws IOException { - MaxComputeExternalCatalog mcCatalog = (MaxComputeExternalCatalog) table.getCatalog(); - - readTimeout = mcCatalog.getReadTimeout(); - connectTimeout = mcCatalog.getConnectTimeout(); - retryTimes = mcCatalog.getRetryTimes(); - - TableReadSessionBuilder scanBuilder = new TableReadSessionBuilder(); - - return scanBuilder.identifier(table.getTableIdentifier()) - .withSettings(mcCatalog.getSettings()) - .withSplitOptions(splitOptions) - .requiredPartitionColumns(requiredPartitionColumns) - .requiredDataColumns(orderedRequiredDataColumns) - .withFilterPredicate(filterPredicate) - .requiredPartitions(requiredPartitionSpecs) - .withArrowOptions( - ArrowOptions.newBuilder() - .withDatetimeUnit(TimestampUnit.MILLI) - .withTimestampUnit(TimestampUnit.MICRO) - .build() - ).buildBatchReadSession(); - } - - @Override - public boolean isBatchMode() { - if (table.getPartitionColumns().isEmpty()) { - return false; - } - - com.aliyun.odps.Table odpsTable = table.getOdpsTable(); - if (desc.getSlots().isEmpty() || odpsTable.getFileNum() <= 0) { - return false; - } - - int numPartitions = sessionVariable.getNumPartitionsInBatchMode(); - return numPartitions > 0 - && selectedPartitions != SelectedPartitions.NOT_PRUNED - && selectedPartitions.selectedPartitions.size() >= numPartitions; - } - - @Override - public int numApproximateSplits() { - return selectedPartitions.selectedPartitions.size(); - } - - @Override - public void startSplit(int numBackends) { - this.totalPartitionNum = selectedPartitions.totalPartitionNum; - this.selectedPartitionNum = selectedPartitions.selectedPartitions.size(); - - if (selectedPartitions.selectedPartitions.isEmpty()) { - //no need read any partition data. - return; - } - - createRequiredColumns(); - List requiredPartitionSpecs = new ArrayList<>(); - selectedPartitions.selectedPartitions.forEach( - (key, value) -> requiredPartitionSpecs.add(new PartitionSpec(key)) - ); - - int batchNumPartitions = sessionVariable.getNumPartitionsInBatchMode(); - - Executor scheduleExecutor = Env.getCurrentEnv().getExtMetaCacheMgr().getScheduleExecutor(); - AtomicReference batchException = new AtomicReference<>(null); - AtomicInteger numFinishedPartitions = new AtomicInteger(0); - - CompletableFuture.runAsync(() -> { - for (int beginIndex = 0; beginIndex < requiredPartitionSpecs.size(); beginIndex += batchNumPartitions) { - int endIndex = Math.min(beginIndex + batchNumPartitions, requiredPartitionSpecs.size()); - if (batchException.get() != null || splitAssignment.isStop()) { - break; - } - List requiredBatchPartitionSpecs = requiredPartitionSpecs.subList(beginIndex, endIndex); - int curBatchSize = endIndex - beginIndex; - - try { - CompletableFuture.runAsync(() -> { - try { - TableBatchReadSession tableBatchReadSession = - createTableBatchReadSession(requiredBatchPartitionSpecs); - List batchSplit = getSplitByTableSession(tableBatchReadSession); - - if (splitAssignment.needMoreSplit()) { - splitAssignment.addToQueue(batchSplit); - } - } catch (Exception e) { - batchException.set(new UserException(e.getMessage(), e)); - } finally { - if (batchException.get() != null) { - splitAssignment.setException(batchException.get()); - } - - if (numFinishedPartitions.addAndGet(curBatchSize) == requiredPartitionSpecs.size()) { - splitAssignment.finishSchedule(); - } - } - }, scheduleExecutor); - } catch (Exception e) { - batchException.set(new UserException(e.getMessage(), e)); - } - - if (batchException.get() != null) { - splitAssignment.setException(batchException.get()); - } - } - }, scheduleExecutor); - } - - @Override - protected void convertPredicate() { - if (conjuncts.isEmpty()) { - this.filterPredicate = Predicate.NO_PREDICATE; - } - - List odpsPredicates = new ArrayList<>(); - for (Expr dorisPredicate : conjuncts) { - try { - odpsPredicates.add(convertExprToOdpsPredicate(dorisPredicate)); - } catch (Exception e) { - Log.warn("Failed to convert predicate " + dorisPredicate.toString() + "Reason: " - + e.getMessage()); - } - } - - if (odpsPredicates.isEmpty()) { - this.filterPredicate = Predicate.NO_PREDICATE; - } else if (odpsPredicates.size() == 1) { - this.filterPredicate = odpsPredicates.get(0); - } else { - com.aliyun.odps.table.optimizer.predicate.CompoundPredicate - filterPredicate = new com.aliyun.odps.table.optimizer.predicate.CompoundPredicate( - com.aliyun.odps.table.optimizer.predicate.CompoundPredicate.Operator.AND); - - for (Predicate odpsPredicate : odpsPredicates) { - filterPredicate.addPredicate(odpsPredicate); - } - this.filterPredicate = filterPredicate; - } - - this.onlyPartitionEqualityPredicate = checkOnlyPartitionEqualityPredicate(); - } - - private boolean checkOnlyPartitionEqualityPredicate() { - if (conjuncts.isEmpty()) { - return true; - } - Set partitionColumns = - table.getPartitionColumns().stream().map(Column::getName).collect(Collectors.toSet()); - for (Expr expr : conjuncts) { - if (expr instanceof BinaryPredicate) { - BinaryPredicate bp = (BinaryPredicate) expr; - if (bp.getOp() != BinaryPredicate.Operator.EQ) { - return false; - } - if (!(bp.getChild(0) instanceof SlotRef) || !(bp.getChild(1) instanceof LiteralExpr)) { - return false; - } - String colName = ((SlotRef) bp.getChild(0)).getColumnName(); - if (!partitionColumns.contains(colName)) { - return false; - } - } else if (expr instanceof InPredicate) { - InPredicate inPredicate = (InPredicate) expr; - if (inPredicate.isNotIn()) { - return false; - } - if (!(inPredicate.getChild(0) instanceof SlotRef)) { - return false; - } - String colName = ((SlotRef) inPredicate.getChild(0)).getColumnName(); - if (!partitionColumns.contains(colName)) { - return false; - } - for (int i = 1; i < inPredicate.getChildren().size(); i++) { - if (!(inPredicate.getChild(i) instanceof LiteralExpr)) { - return false; - } - } - } else { - return false; - } - } - return true; - } - - private Predicate convertExprToOdpsPredicate(Expr expr) throws AnalysisException { - Predicate odpsPredicate = null; - if (expr instanceof CompoundPredicate) { - CompoundPredicate compoundPredicate = (CompoundPredicate) expr; - - com.aliyun.odps.table.optimizer.predicate.CompoundPredicate.Operator odpsOp; - switch (compoundPredicate.getOp()) { - case AND: - odpsOp = com.aliyun.odps.table.optimizer.predicate.CompoundPredicate.Operator.AND; - break; - case OR: - odpsOp = com.aliyun.odps.table.optimizer.predicate.CompoundPredicate.Operator.OR; - break; - case NOT: - odpsOp = com.aliyun.odps.table.optimizer.predicate.CompoundPredicate.Operator.NOT; - break; - default: - throw new AnalysisException("Unknown operator: " + compoundPredicate.getOp()); - } - - List odpsPredicates = new ArrayList<>(); - - odpsPredicates.add(convertExprToOdpsPredicate(expr.getChild(0))); - - if (compoundPredicate.getOp() != Operator.NOT) { - odpsPredicates.add(convertExprToOdpsPredicate(expr.getChild(1))); - } - odpsPredicate = new com.aliyun.odps.table.optimizer.predicate.CompoundPredicate(odpsOp, odpsPredicates); - - } else if (expr instanceof InPredicate) { - - InPredicate inPredicate = (InPredicate) expr; - com.aliyun.odps.table.optimizer.predicate.InPredicate.Operator odpsOp = - inPredicate.isNotIn() - ? com.aliyun.odps.table.optimizer.predicate.InPredicate.Operator.NOT_IN - : com.aliyun.odps.table.optimizer.predicate.InPredicate.Operator.IN; - - String columnName = convertSlotRefToColumnName(expr.getChild(0)); - if (!table.getColumnNameToOdpsColumn().containsKey(columnName)) { - Map columnMap = table.getColumnNameToOdpsColumn(); - LOG.warn("ColumnNameToOdpsColumn size=" + columnMap.size() - + ", keys=[" + String.join(", ", columnMap.keySet()) + "]"); - throw new AnalysisException("Column " + columnName + " not found in table, can not push " - + "down predicate to MaxCompute " + table.getName()); - } - com.aliyun.odps.OdpsType odpsType = table.getColumnNameToOdpsColumn().get(columnName).getType(); - - StringBuilder stringBuilder = new StringBuilder(); - stringBuilder.append(columnName); - stringBuilder.append(" "); - stringBuilder.append(odpsOp.getDescription()); - stringBuilder.append(" ("); - - for (int i = 1; i < inPredicate.getChildren().size(); i++) { - stringBuilder.append(convertLiteralToOdpsValues(odpsType, expr.getChild(i))); - if (i < inPredicate.getChildren().size() - 1) { - stringBuilder.append(", "); - } - } - stringBuilder.append(" )"); - - odpsPredicate = new com.aliyun.odps.table.optimizer.predicate.RawPredicate(stringBuilder.toString()); - - } else if (expr instanceof BinaryPredicate) { - BinaryPredicate binaryPredicate = (BinaryPredicate) expr; - - - com.aliyun.odps.table.optimizer.predicate.BinaryPredicate.Operator odpsOp; - switch (binaryPredicate.getOp()) { - case EQ: { - odpsOp = com.aliyun.odps.table.optimizer.predicate.BinaryPredicate.Operator.EQUALS; - break; - } - case NE: { - odpsOp = com.aliyun.odps.table.optimizer.predicate.BinaryPredicate.Operator.NOT_EQUALS; - break; - } - case GE: { - odpsOp = com.aliyun.odps.table.optimizer.predicate.BinaryPredicate.Operator.GREATER_THAN_OR_EQUAL; - break; - } - case LE: { - odpsOp = com.aliyun.odps.table.optimizer.predicate.BinaryPredicate.Operator.LESS_THAN_OR_EQUAL; - break; - } - case LT: { - odpsOp = com.aliyun.odps.table.optimizer.predicate.BinaryPredicate.Operator.LESS_THAN; - break; - } - case GT: { - odpsOp = com.aliyun.odps.table.optimizer.predicate.BinaryPredicate.Operator.GREATER_THAN; - break; - } - default: { - odpsOp = null; - break; - } - } - - if (odpsOp != null) { - String columnName = convertSlotRefToColumnName(expr.getChild(0)); - if (!table.getColumnNameToOdpsColumn().containsKey(columnName)) { - Map columnMap = table.getColumnNameToOdpsColumn(); - LOG.warn("ColumnNameToOdpsColumn size=" + columnMap.size() - + ", keys=[" + String.join(", ", columnMap.keySet()) + "]"); - throw new AnalysisException("Column " + columnName + " not found in table, can not push " - + "down predicate to MaxCompute " + table.getName()); - } - com.aliyun.odps.OdpsType odpsType = table.getColumnNameToOdpsColumn().get(columnName).getType(); - StringBuilder stringBuilder = new StringBuilder(); - stringBuilder.append(columnName); - stringBuilder.append(" "); - stringBuilder.append(odpsOp.getDescription()); - stringBuilder.append(" "); - stringBuilder.append(convertLiteralToOdpsValues(odpsType, expr.getChild(1))); - - odpsPredicate = new com.aliyun.odps.table.optimizer.predicate.RawPredicate(stringBuilder.toString()); - } - } else if (expr instanceof IsNullPredicate) { - IsNullPredicate isNullPredicate = (IsNullPredicate) expr; - com.aliyun.odps.table.optimizer.predicate.UnaryPredicate.Operator odpsOp = - isNullPredicate.isNotNull() - ? com.aliyun.odps.table.optimizer.predicate.UnaryPredicate.Operator.NOT_NULL - : com.aliyun.odps.table.optimizer.predicate.UnaryPredicate.Operator.IS_NULL; - - odpsPredicate = new com.aliyun.odps.table.optimizer.predicate.UnaryPredicate(odpsOp, - new com.aliyun.odps.table.optimizer.predicate.Attribute( - convertSlotRefToColumnName(expr.getChild(0)) - ) - ); - } - - - if (odpsPredicate == null) { - throw new AnalysisException("Do not support convert [" - + expr.accept(ExprToExprNameVisitor.INSTANCE, null) - + "] in convertExprToOdpsPredicate."); - } - return odpsPredicate; - } - - private String convertSlotRefToColumnName(Expr expr) throws AnalysisException { - if (expr instanceof SlotRef) { - return ((SlotRef) expr).getColumnName(); - } - - throw new AnalysisException("Do not support convert [" - + expr.accept(ExprToExprNameVisitor.INSTANCE, null) - + "] in convertSlotRefToAttribute."); - - } - - private String convertLiteralToOdpsValues(OdpsType odpsType, Expr expr) throws AnalysisException { - if (!(expr instanceof LiteralExpr)) { - throw new AnalysisException("Do not support convert [" - + expr.accept(ExprToExprNameVisitor.INSTANCE, null) - + "] in convertSlotRefToAttribute."); - } - LiteralExpr literalExpr = (LiteralExpr) expr; - - switch (odpsType) { - case BOOLEAN: - case TINYINT: - case SMALLINT: - case INT: - case BIGINT: - case DECIMAL: - case FLOAT: - case DOUBLE: { - return " " + literalExpr.toString() + " "; - } - case STRING: - case CHAR: - case VARCHAR: { - return " \"" + literalExpr.toString() + "\" "; - } - case DATE: { - DateLiteral dateLiteral = (DateLiteral) literalExpr; - ScalarType dstType = ScalarType.createDateV2Type(); - return " \"" + dateLiteral.getStringValue(dstType) + "\" "; - } - case DATETIME: { - MaxComputeExternalCatalog mcCatalog = (MaxComputeExternalCatalog) table.getCatalog(); - if (mcCatalog.getDateTimePredicatePushDown()) { - DateLiteral dateLiteral = (DateLiteral) literalExpr; - ScalarType dstType = ScalarType.createDatetimeV2Type(3); - - return " \"" + convertDateTimezone(dateLiteral.getStringValue(dstType), dateTime3Formatter, - ZoneId.of("UTC")) + "\" "; - } - break; - } - /** - * Disable the predicate pushdown to the odps API because the timestamp precision of odps is 9 and the - * mapping precision of Doris is 6. If we insert `2023-02-02 00:00:00.123456789` into odps, doris reads - * it as `2023-02-02 00:00:00.123456`. Since "789" is missing, we cannot push it down correctly. - */ - case TIMESTAMP: { - MaxComputeExternalCatalog mcCatalog = (MaxComputeExternalCatalog) table.getCatalog(); - if (mcCatalog.getDateTimePredicatePushDown()) { - DateLiteral dateLiteral = (DateLiteral) literalExpr; - ScalarType dstType = ScalarType.createDatetimeV2Type(6); - - return " \"" + convertDateTimezone(dateLiteral.getStringValue(dstType), dateTime6Formatter, - ZoneId.of("UTC")) + "\" "; - } - break; - } - case TIMESTAMP_NTZ: { - MaxComputeExternalCatalog mcCatalog = (MaxComputeExternalCatalog) table.getCatalog(); - if (mcCatalog.getDateTimePredicatePushDown()) { - DateLiteral dateLiteral = (DateLiteral) literalExpr; - ScalarType dstType = ScalarType.createDatetimeV2Type(6); - return " \"" + dateLiteral.getStringValue(dstType) + "\" "; - } - break; - } - default: { - break; - } - } - throw new AnalysisException("Do not support convert odps type [" + odpsType + "] to odps values."); - } - - - public static String convertDateTimezone(String dateTimeStr, DateTimeFormatter formatter, ZoneId toZone) { - if (DateUtils.getTimeZone().equals(toZone)) { - return dateTimeStr; - } - - LocalDateTime localDateTime = LocalDateTime.parse(dateTimeStr, formatter); - - ZonedDateTime sourceZonedDateTime = localDateTime.atZone(DateUtils.getTimeZone()); - ZonedDateTime targetZonedDateTime = sourceZonedDateTime.withZoneSameInstant(toZone); - - return targetZonedDateTime.format(formatter); - } - - - - @Override - public TFileFormatType getFileFormatType() { - return TFileFormatType.FORMAT_JNI; - } - - @Override - public List getPathPartitionKeys() { - return Collections.emptyList(); - } - - @Override - protected TableIf getTargetTable() throws UserException { - return table; - } - - @Override - protected Map getLocationProperties() throws UserException { - return new HashMap<>(); - } - - private List getSplitByTableSession(TableBatchReadSession tableBatchReadSession) throws IOException { - List result = new ArrayList<>(); - - long t0 = System.currentTimeMillis(); - String scanSessionSerialize = serializeSession(tableBatchReadSession); - long t1 = System.currentTimeMillis(); - LOG.info("MaxComputeScanNode getSplitByTableSession: serializeSession cost {} ms, " - + "serialized size: {} bytes", t1 - t0, scanSessionSerialize.length()); - - InputSplitAssigner assigner = tableBatchReadSession.getInputSplitAssigner(); - long t2 = System.currentTimeMillis(); - LOG.info("MaxComputeScanNode getSplitByTableSession: getInputSplitAssigner cost {} ms", t2 - t1); - - long modificationTime = table.getOdpsTable().getLastDataModifiedTime().getTime(); - - MaxComputeExternalCatalog mcCatalog = (MaxComputeExternalCatalog) table.getCatalog(); - - if (mcCatalog.getSplitStrategy().equals(MCProperties.SPLIT_BY_BYTE_SIZE_STRATEGY)) { - long t3 = System.currentTimeMillis(); - for (com.aliyun.odps.table.read.split.InputSplit split : assigner.getAllSplits()) { - MaxComputeSplit maxComputeSplit = - new MaxComputeSplit(BYTE_SIZE_PATH, - ((IndexedInputSplit) split).getSplitIndex(), -1, - mcCatalog.getSplitByteSize(), - modificationTime, null, - Collections.emptyList()); - - - maxComputeSplit.scanSerialize = scanSessionSerialize; - maxComputeSplit.splitType = SplitType.BYTE_SIZE; - maxComputeSplit.sessionId = split.getSessionId(); - - result.add(maxComputeSplit); - } - LOG.info("MaxComputeScanNode getSplitByTableSession: byte_size getAllSplits+build cost {} ms, " - + "splits size: {}", System.currentTimeMillis() - t3, result.size()); - } else { - long t3 = System.currentTimeMillis(); - long totalRowCount = assigner.getTotalRowCount(); - - long recordsPerSplit = mcCatalog.getSplitRowCount(); - for (long offset = 0; offset < totalRowCount; offset += recordsPerSplit) { - recordsPerSplit = Math.min(recordsPerSplit, totalRowCount - offset); - com.aliyun.odps.table.read.split.InputSplit split = - assigner.getSplitByRowOffset(offset, recordsPerSplit); - - MaxComputeSplit maxComputeSplit = - new MaxComputeSplit(ROW_OFFSET_PATH, - offset, recordsPerSplit, totalRowCount, modificationTime, null, - Collections.emptyList()); - - maxComputeSplit.scanSerialize = scanSessionSerialize; - maxComputeSplit.splitType = SplitType.ROW_OFFSET; - maxComputeSplit.sessionId = split.getSessionId(); - - result.add(maxComputeSplit); - } - LOG.info("MaxComputeScanNode getSplitByTableSession: row_offset getSplitByRowOffset+build cost {} ms, " - + "splits size: {}, totalRowCount: {}", System.currentTimeMillis() - t3, result.size(), - totalRowCount); - } - - return result; - } - - @Override - public List getSplits(int numBackends) throws UserException { - long startTime = System.currentTimeMillis(); - List result = new ArrayList<>(); - com.aliyun.odps.Table odpsTable = table.getOdpsTable(); - long getOdpsTableTime = System.currentTimeMillis(); - LOG.info("MaxComputeScanNode getSplits: getOdpsTable cost {} ms", getOdpsTableTime - startTime); - - if (MaxComputeExternalTable.isUnsupportedOdpsTable(odpsTable)) { - throw new UserException("Reading MaxCompute external table or logical view is not supported: " - + table.getDbName() + "." + table.getName()); - } - - if (desc.getSlots().isEmpty() || odpsTable.getFileNum() <= 0) { - return result; - } - long getFileNumTime = System.currentTimeMillis(); - LOG.info("MaxComputeScanNode getSplits: getFileNum cost {} ms", getFileNumTime - getOdpsTableTime); - - createRequiredColumns(); - - List requiredPartitionSpecs = new ArrayList<>(); - //if requiredPartitionSpecs is empty, get all partition data. - if (!table.getPartitionColumns().isEmpty() && selectedPartitions != SelectedPartitions.NOT_PRUNED) { - this.totalPartitionNum = selectedPartitions.totalPartitionNum; - this.selectedPartitionNum = selectedPartitions.selectedPartitions.size(); - - if (selectedPartitions.selectedPartitions.isEmpty()) { - //no need read any partition data. - return result; - } - selectedPartitions.selectedPartitions.forEach( - (key, value) -> requiredPartitionSpecs.add(new PartitionSpec(key)) - ); - } - - try { - long beforeSession = System.currentTimeMillis(); - if (sessionVariable.enableMcLimitSplitOptimization - && onlyPartitionEqualityPredicate && hasLimit()) { - result = getSplitsWithLimitOptimization(requiredPartitionSpecs); - } else { - TableBatchReadSession tableBatchReadSession = createTableBatchReadSession(requiredPartitionSpecs); - long afterSession = System.currentTimeMillis(); - LOG.info("MaxComputeScanNode getSplits: createTableBatchReadSession cost {} ms, " - + "partitionSpecs size: {}", afterSession - beforeSession, requiredPartitionSpecs.size()); - - result = getSplitByTableSession(tableBatchReadSession); - long afterSplit = System.currentTimeMillis(); - LOG.info("MaxComputeScanNode getSplits: getSplitByTableSession cost {} ms, " - + "splits size: {}", afterSplit - afterSession, result.size()); - } - } catch (IOException e) { - throw new RuntimeException(e); - } - LOG.info("MaxComputeScanNode getSplits: total cost {} ms", System.currentTimeMillis() - startTime); - return result; - } - - private List getSplitsWithLimitOptimization( - List requiredPartitionSpecs) throws IOException { - long startTime = System.currentTimeMillis(); - - SplitOptions rowOffsetOptions = SplitOptions.newBuilder() - .SplitByRowOffset() - .withCrossPartition(false) - .build(); - - TableBatchReadSession tableBatchReadSession = - createTableBatchReadSession(requiredPartitionSpecs, rowOffsetOptions); - long afterSession = System.currentTimeMillis(); - LOG.info("MaxComputeScanNode getSplitsWithLimitOptimization: " - + "createTableBatchReadSession cost {} ms", afterSession - startTime); - - String scanSessionSerialize = serializeSession(tableBatchReadSession); - InputSplitAssigner assigner = tableBatchReadSession.getInputSplitAssigner(); - long totalRowCount = assigner.getTotalRowCount(); - - LOG.info("MaxComputeScanNode getSplitsWithLimitOptimization: " - + "totalRowCount={}, limit={}", totalRowCount, getLimit()); - - List result = new ArrayList<>(); - if (totalRowCount <= 0) { - return result; - } - - long rowsToRead = Math.min(getLimit(), totalRowCount); - long modificationTime = table.getOdpsTable().getLastDataModifiedTime().getTime(); - com.aliyun.odps.table.read.split.InputSplit split = - assigner.getSplitByRowOffset(0, rowsToRead); - - MaxComputeSplit maxComputeSplit = new MaxComputeSplit( - ROW_OFFSET_PATH, 0, rowsToRead, totalRowCount, - modificationTime, null, Collections.emptyList()); - maxComputeSplit.scanSerialize = scanSessionSerialize; - maxComputeSplit.splitType = SplitType.ROW_OFFSET; - maxComputeSplit.sessionId = split.getSessionId(); - result.add(maxComputeSplit); - - LOG.info("MaxComputeScanNode getSplitsWithLimitOptimization: " - + "total cost {} ms, 1 split with {} rows", - System.currentTimeMillis() - startTime, rowsToRead); - return result; - } - - private static String serializeSession(Serializable object) throws IOException { - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream); - objectOutputStream.writeObject(object); - byte[] serializedBytes = byteArrayOutputStream.toByteArray(); - return Base64.getEncoder().encodeToString(serializedBytes); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/source/MaxComputeSplit.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/source/MaxComputeSplit.java deleted file mode 100644 index 0fc9fbcbfd5f63..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/source/MaxComputeSplit.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.datasource.maxcompute.source; - -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.FileSplit; -import org.apache.doris.thrift.TFileType; - -import lombok.Getter; - -import java.util.List; - -@Getter -public class MaxComputeSplit extends FileSplit { - public String scanSerialize; - public String sessionId; - - public enum SplitType { - ROW_OFFSET, - BYTE_SIZE - } - - public SplitType splitType; - - public MaxComputeSplit(LocationPath path, long start, long length, long fileLength, - long modificationTime, String[] hosts, List partitionValues) { - super(path, start, length, fileLength, modificationTime, hosts, partitionValues); - // MC always use FILE_NET type - this.locationType = TFileType.FILE_NET; - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/ExternalMetaCacheRouteResolver.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/ExternalMetaCacheRouteResolver.java index 48bde1ab99311f..203e75ad4d85df 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/ExternalMetaCacheRouteResolver.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/ExternalMetaCacheRouteResolver.java @@ -20,10 +20,6 @@ import org.apache.doris.datasource.CatalogIf; import org.apache.doris.datasource.ExternalCatalog; import org.apache.doris.datasource.doris.RemoteDorisExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog; -import org.apache.doris.datasource.paimon.PaimonExternalCatalog; import java.util.ArrayList; import java.util.LinkedHashSet; @@ -36,11 +32,6 @@ */ public class ExternalMetaCacheRouteResolver { private static final String ENGINE_DEFAULT = "default"; - private static final String ENGINE_HIVE = "hive"; - private static final String ENGINE_HUDI = "hudi"; - private static final String ENGINE_ICEBERG = "iceberg"; - private static final String ENGINE_PAIMON = "paimon"; - private static final String ENGINE_MAXCOMPUTE = "maxcompute"; private static final String ENGINE_DORIS = "doris"; private final ExternalMetaCacheRegistry registry; @@ -64,28 +55,10 @@ public List resolveCatalogCaches(long catalogId, @Nullable Ca } private void addBuiltinRoutes(Set resolved, CatalogIf catalog) { - if (catalog instanceof IcebergExternalCatalog) { - resolved.add(registry.resolve(ENGINE_ICEBERG)); - return; - } - if (catalog instanceof PaimonExternalCatalog) { - resolved.add(registry.resolve(ENGINE_PAIMON)); - return; - } - if (catalog instanceof MaxComputeExternalCatalog) { - resolved.add(registry.resolve(ENGINE_MAXCOMPUTE)); - return; - } if (catalog instanceof RemoteDorisExternalCatalog) { resolved.add(registry.resolve(ENGINE_DORIS)); return; } - if (catalog instanceof HMSExternalCatalog) { - resolved.add(registry.resolve(ENGINE_HIVE)); - resolved.add(registry.resolve(ENGINE_HUDI)); - resolved.add(registry.resolve(ENGINE_ICEBERG)); - return; - } if (catalog instanceof ExternalCatalog) { resolved.add(registry.resolve(ENGINE_DEFAULT)); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/paimon/PaimonLatestSnapshotProjectionLoader.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/paimon/PaimonLatestSnapshotProjectionLoader.java deleted file mode 100644 index 8e2c7a73b33901..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/paimon/PaimonLatestSnapshotProjectionLoader.java +++ /dev/null @@ -1,83 +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.datasource.metacache.paimon; - -import org.apache.doris.catalog.Column; -import org.apache.doris.datasource.CacheException; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.paimon.PaimonPartitionInfo; -import org.apache.doris.datasource.paimon.PaimonSchemaCacheValue; -import org.apache.doris.datasource.paimon.PaimonSnapshot; -import org.apache.doris.datasource.paimon.PaimonSnapshotCacheValue; - -import org.apache.paimon.CoreOptions; -import org.apache.paimon.Snapshot; -import org.apache.paimon.schema.TableSchema; -import org.apache.paimon.table.DataTable; -import org.apache.paimon.table.Table; - -import java.util.Collections; -import java.util.List; -import java.util.Optional; - -/** - * Resolves the latest snapshot runtime projection from the base table entry. - */ -public final class PaimonLatestSnapshotProjectionLoader { - @FunctionalInterface - public interface SchemaValueLoader { - PaimonSchemaCacheValue load(NameMapping nameMapping, long schemaId); - } - - private final PaimonPartitionInfoLoader partitionInfoLoader; - private final SchemaValueLoader schemaValueLoader; - - public PaimonLatestSnapshotProjectionLoader(PaimonPartitionInfoLoader partitionInfoLoader, - SchemaValueLoader schemaValueLoader) { - this.partitionInfoLoader = partitionInfoLoader; - this.schemaValueLoader = schemaValueLoader; - } - - public PaimonSnapshotCacheValue load(NameMapping nameMapping, Table paimonTable) { - try { - PaimonSnapshot latestSnapshot = resolveLatestSnapshot(paimonTable); - List partitionColumns = schemaValueLoader.load(nameMapping, latestSnapshot.getSchemaId()) - .getPartitionColumns(); - PaimonPartitionInfo partitionInfo = partitionInfoLoader.load(nameMapping, paimonTable, partitionColumns); - return new PaimonSnapshotCacheValue(partitionInfo, latestSnapshot); - } catch (Exception e) { - throw new CacheException("failed to load paimon snapshot %s.%s.%s: %s", - e, nameMapping.getCtlId(), nameMapping.getLocalDbName(), nameMapping.getLocalTblName(), - e.getMessage()); - } - } - - private PaimonSnapshot resolveLatestSnapshot(Table paimonTable) { - Table snapshotTable = paimonTable; - long latestSnapshotId = PaimonSnapshot.INVALID_SNAPSHOT_ID; - Optional optionalSnapshot = paimonTable.latestSnapshot(); - if (optionalSnapshot.isPresent()) { - latestSnapshotId = optionalSnapshot.get().id(); - snapshotTable = paimonTable.copy( - Collections.singletonMap(CoreOptions.SCAN_SNAPSHOT_ID.key(), String.valueOf(latestSnapshotId))); - } - DataTable dataTable = (DataTable) paimonTable; - long latestSchemaId = dataTable.schemaManager().latest().map(TableSchema::id).orElse(0L); - return new PaimonSnapshot(latestSnapshotId, latestSchemaId, snapshotTable); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/paimon/PaimonPartitionInfoLoader.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/paimon/PaimonPartitionInfoLoader.java deleted file mode 100644 index c29a359b9592d1..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/paimon/PaimonPartitionInfoLoader.java +++ /dev/null @@ -1,58 +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.datasource.metacache.paimon; - -import org.apache.doris.catalog.Column; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.datasource.CacheException; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.paimon.PaimonPartitionInfo; -import org.apache.doris.datasource.paimon.PaimonUtil; - -import org.apache.commons.collections4.CollectionUtils; -import org.apache.paimon.partition.Partition; -import org.apache.paimon.table.Table; - -import java.util.List; - -/** - * Loads partition info for a snapshot projection from the base Paimon table and catalog metadata. - */ -public final class PaimonPartitionInfoLoader { - private final PaimonTableLoader tableLoader; - - public PaimonPartitionInfoLoader(PaimonTableLoader tableLoader) { - this.tableLoader = tableLoader; - } - - public PaimonPartitionInfo load(NameMapping nameMapping, Table paimonTable, List partitionColumns) - throws AnalysisException { - if (CollectionUtils.isEmpty(partitionColumns)) { - return PaimonPartitionInfo.EMPTY; - } - try { - List paimonPartitions = tableLoader.catalog(nameMapping).getPaimonPartitions(nameMapping); - boolean legacyPartitionName = PaimonUtil.isLegacyPartitionName(paimonTable); - return PaimonUtil.generatePartitionInfo(partitionColumns, paimonPartitions, legacyPartitionName); - } catch (Exception e) { - throw new CacheException("failed to load paimon partition info %s.%s.%s: %s", - e, nameMapping.getCtlId(), nameMapping.getLocalDbName(), nameMapping.getLocalTblName(), - e.getMessage()); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/paimon/PaimonTableLoader.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/paimon/PaimonTableLoader.java deleted file mode 100644 index 0a134cfd7d7d32..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/paimon/PaimonTableLoader.java +++ /dev/null @@ -1,48 +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.datasource.metacache.paimon; - -import org.apache.doris.catalog.Env; -import org.apache.doris.datasource.CacheException; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.paimon.PaimonExternalCatalog; - -import org.apache.paimon.table.Table; - -import java.io.IOException; - -/** - * Loads the base Paimon table handle used by cache entries and runtime projections. - */ -public final class PaimonTableLoader { - - public Table load(NameMapping nameMapping) { - try { - return catalog(nameMapping).getPaimonTable(nameMapping); - } catch (Exception e) { - throw new CacheException("failed to load paimon table %s.%s.%s: %s", - e, nameMapping.getCtlId(), nameMapping.getLocalDbName(), nameMapping.getLocalTblName(), - e.getMessage()); - } - } - - public PaimonExternalCatalog catalog(NameMapping nameMapping) throws IOException { - return (PaimonExternalCatalog) Env.getCurrentEnv().getCatalogMgr() - .getCatalogOrException(nameMapping.getCtlId(), id -> new IOException("Catalog not found: " + id)); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/EmptyMvccSnapshot.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/EmptyMvccSnapshot.java deleted file mode 100644 index 35f63291a258c8..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/EmptyMvccSnapshot.java +++ /dev/null @@ -1,21 +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.datasource.mvcc; - -public class EmptyMvccSnapshot implements MvccSnapshot { -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/MvccTableInfo.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/MvccTableInfo.java index 0d865f837c8c4e..a302102184670b 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/MvccTableInfo.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/MvccTableInfo.java @@ -31,8 +31,17 @@ public class MvccTableInfo { private String tableName; private String dbName; private String ctlName; + // Version selector distinguishing references to the SAME table at different snapshots within one + // statement (e.g. main vs @branch/@tag/FOR-TIME-AS-OF). Empty string ("") is the default/latest read. + // Without it a statement mixing main and @branch of one table collapses to a single map entry and the + // @branch reference reuses main's pinned snapshot. Derived by StatementContext.versionKeyOf. + private final String version; public MvccTableInfo(TableIf table) { + this(table, ""); + } + + public MvccTableInfo(TableIf table, String version) { java.util.Objects.requireNonNull(table, "table is null"); DatabaseIf database = table.getDatabase(); java.util.Objects.requireNonNull(database, "database is null"); @@ -41,6 +50,7 @@ public MvccTableInfo(TableIf table) { this.tableName = table.getName(); this.dbName = database.getFullName(); this.ctlName = catalog.getName(); + this.version = version == null ? "" : version; } public String getTableName() { @@ -55,6 +65,24 @@ public String getCtlName() { return ctlName; } + public String getVersion() { + return version; + } + + /** + * Whether {@code other} refers to the same (catalog, db, table) as this, IGNORING the version + * selector. Used by the version-blind {@code StatementContext.getSnapshot(TableIf)} to recognise a + * lone pinned snapshot for a table when no default ("") entry exists (e.g. a standalone @branch read). + */ + public boolean isSameTable(MvccTableInfo other) { + if (other == null) { + return false; + } + return Objects.equal(tableName, other.tableName) + && Objects.equal(dbName, other.dbName) + && Objects.equal(ctlName, other.ctlName); + } + @Override public boolean equals(Object o) { if (this == o) { @@ -65,12 +93,13 @@ public boolean equals(Object o) { } MvccTableInfo that = (MvccTableInfo) o; return Objects.equal(tableName, that.tableName) && Objects.equal( - dbName, that.dbName) && Objects.equal(ctlName, that.ctlName); + dbName, that.dbName) && Objects.equal(ctlName, that.ctlName) + && Objects.equal(version, that.version); } @Override public int hashCode() { - return Objects.hashCode(tableName, dbName, ctlName); + return Objects.hashCode(tableName, dbName, ctlName, version); } @Override @@ -79,6 +108,7 @@ public String toString() { + "tableName='" + tableName + '\'' + ", dbName='" + dbName + '\'' + ", ctlName='" + ctlName + '\'' + + ", version='" + version + '\'' + '}'; } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/MvccUtil.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/MvccUtil.java index ffdaff770e21a3..f5f3d9500c7d25 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/MvccUtil.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/MvccUtil.java @@ -17,6 +17,8 @@ package org.apache.doris.datasource.mvcc; +import org.apache.doris.analysis.TableScanParams; +import org.apache.doris.analysis.TableSnapshot; import org.apache.doris.catalog.TableIf; import org.apache.doris.nereids.StatementContext; import org.apache.doris.qe.ConnectContext; @@ -41,4 +43,28 @@ public static Optional getSnapshotFromContext(TableIf tableIf) { } return statementContext.getSnapshot(tableIf); } + + /** + * Get the version-aware Snapshot from the StatementContext: resolves the snapshot pinned for the SAME + * table reference (same {@code @branch}/{@code @tag}/FOR-TIME selector) the scan carries, so a statement + * mixing main and {@code @branch} of one table reads each at its own snapshot. The version-blind + * {@link #getSnapshotFromContext(TableIf)} cannot disambiguate once more than one snapshot is pinned. + * + * @param tableIf tableIf + * @param tableSnapshot the reference's FOR VERSION/TIME AS OF selector (if any) + * @param scanParams the reference's {@code @branch}/{@code @tag} selector (if any) + * @return MvccSnapshot + */ + public static Optional getSnapshotFromContext(TableIf tableIf, + Optional tableSnapshot, Optional scanParams) { + ConnectContext connectContext = ConnectContext.get(); + if (connectContext == null) { + return Optional.empty(); + } + StatementContext statementContext = connectContext.getStatementContext(); + if (statementContext == null) { + return Optional.empty(); + } + return statementContext.getSnapshot(tableIf, tableSnapshot, scanParams); + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTable.java new file mode 100644 index 00000000000000..bb3edc19f44fad --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTable.java @@ -0,0 +1,811 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.mvcc; + +import org.apache.doris.analysis.PartitionValue; +import org.apache.doris.analysis.TableScanParams; +import org.apache.doris.analysis.TableSnapshot; +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.ListPartitionItem; +import org.apache.doris.catalog.MTMV; +import org.apache.doris.catalog.PartitionItem; +import org.apache.doris.catalog.PartitionKey; +import org.apache.doris.catalog.PartitionType; +import org.apache.doris.catalog.RangePartitionItem; +import org.apache.doris.catalog.Type; +import org.apache.doris.common.AnalysisException; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorPartitionInfo; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorTableSchema; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.mvcc.ConnectorMvccPartition; +import org.apache.doris.connector.api.mvcc.ConnectorMvccPartitionView; +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.datasource.ExternalCatalog; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.SchemaCacheValue; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; +import org.apache.doris.datasource.plugin.PluginDrivenSchemaCacheValue; +import org.apache.doris.mtmv.MTMVBaseTableIf; +import org.apache.doris.mtmv.MTMVMaxTimestampSnapshot; +import org.apache.doris.mtmv.MTMVRefreshContext; +import org.apache.doris.mtmv.MTMVRelatedTableIf; +import org.apache.doris.mtmv.MTMVSnapshotIdSnapshot; +import org.apache.doris.mtmv.MTMVSnapshotIf; +import org.apache.doris.mtmv.MTMVTimestampSnapshot; + +import com.google.common.base.Preconditions; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.collect.Range; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Generic MVCC/MTMV-capable {@link PluginDrivenExternalTable} for connectors that expose a + * point-in-time snapshot (e.g. Paimon, Iceberg, Hudi). All behavior is source-agnostic and driven + * through the connector SPI; the data-source-specific rendering of partition names/dates happens in + * the connector, so this class never parses raw values or imports any data-source library. + * + *

    Selected by a capability factory and wired into the scan node in a later dispatch; until then + * it has no production caller and is exercised only by direct-construction unit tests.

    + * + *

    MVCC/MTMV contract: a connector that advertises this capability MUST supply a real + * per-partition {@code lastModifiedMillis}. An {@link ConnectorPartitionInfo#UNKNOWN}(-1) is not a + * valid timestamp: it pins {@code MTMVTimestampSnapshot(-1)} in {@link #getPartitionSnapshot}, which + * degrades MTMV to conservative over-refresh (the partition never matches its prior snapshot).

    + */ +public class PluginDrivenMvccExternalTable extends PluginDrivenExternalTable + implements MTMVRelatedTableIf, MTMVBaseTableIf, MvccTable { + + private static final Logger LOG = LogManager.getLogger(PluginDrivenMvccExternalTable.class); + + /** Matches an all-digits string (epoch millis / snapshot id). Parity with {@code PaimonUtil.isDigitalString}. */ + private static final Pattern DIGITAL_REGEX = Pattern.compile("\\d+"); + + /** No-arg constructor for GSON deserialization. */ + public PluginDrivenMvccExternalTable() { + super(); + } + + public PluginDrivenMvccExternalTable(long id, String name, String remoteName, + ExternalCatalog catalog, ExternalDatabase db) { + super(id, name, remoteName, catalog, db); + } + + // ──────────────────── snapshot materialization ──────────────────── + + /** + * Returns the pinned snapshot if the caller supplied one (read the PIN, do NOT re-list), else + * materializes the LATEST snapshot from the connector. The query-begin pin path goes through + * {@link #loadSnapshot} which calls {@link #materializeLatest()} once; subsequent accessors + * receive that pin here and never re-query the connector (single-pin invariant). + */ + private PluginDrivenMvccSnapshot getOrMaterialize(Optional snapshot) { + if (snapshot.isPresent()) { + return (PluginDrivenMvccSnapshot) snapshot.get(); + } + return materializeLatest(); + } + + /** + * Lists the partition set at LATEST and pins the connector snapshot. The per-partition build is + * delegated to {@link #listLatestPartitions} (shared with the @incr path, which legacy also reads + * at LATEST). + */ + private PluginDrivenMvccSnapshot materializeLatest() { + makeSureInitialized(); + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + Connector connector = pluginCatalog.getConnector(); + if (connector == null) { + // The backing catalog was concurrently DROPPED: onClose() nulled the (transient) connector but + // left objectCreated=true, so makeSureInitialized() does not re-create it and getConnector() + // returns null. A stale metadata-table access (e.g. an mv_infos()/jobs() scan over all MTMVs -> + // isMTMVSync -> a related table here) must DEGRADE to a valid empty pin so it yields an empty + // partition view instead of NPE-ing and aborting the whole metadata query. Mirrors the + // table-dropped (no-handle) branch below. On a HEALTHY catalog the connector is never null after + // makeSureInitialized() (initLocalObjectsImpl throws if it cannot create one), so this guard only + // covers the dropped-catalog race and cannot mask a genuine init failure. + return new PluginDrivenMvccSnapshot(emptySnapshot(), + Collections.emptyMap(), Collections.emptyMap()); + } + ConnectorSession session = pluginCatalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + + Optional handleOpt = resolveConnectorTableHandle(session, metadata); + if (!handleOpt.isPresent()) { + // No handle (e.g. table dropped): still return a valid empty pin so callers degrade to + // UNPARTITIONED / snapshot id -1 instead of NPE-ing. + return new PluginDrivenMvccSnapshot(emptySnapshot(), + Collections.emptyMap(), Collections.emptyMap()); + } + ConnectorTableHandle handle = handleOpt.get(); + + // An empty (no-snapshot) connector still pins: fall back to a snapshot id of -1. + ConnectorMvccSnapshot connectorSnapshot = + metadata.beginQuerySnapshot(session, handle).orElseGet(this::emptySnapshot); + + // Range-view path (e.g. iceberg): thread the query's pin onto the handle FIRST (applySnapshot), so + // the partition/freshness enumeration stays consistent with the data-scan pin, then ask the connector + // for its range-aware view. A connector without a range view returns empty -> fall through to the + // legacy listPartitions/LIST/timestamp path below (byte-unchanged; the no-op applySnapshot for the + // latest pin is side-effect-free for both paimon and iceberg). + ConnectorTableHandle pinnedHandle = metadata.applySnapshot(session, handle, connectorSnapshot); + Optional viewOpt = + metadata.getMvccPartitionView(session, pinnedHandle); + if (viewOpt.isPresent()) { + ConnectorMvccPartitionView view = viewOpt.get(); + // A non-RANGE (UNPARTITIONED) view is the connector's "not RANGE / not MTMV-range-eligible" verdict + // (iceberg's range view only covers single time-transform specs). If the table nonetheless declares + // partition columns (e.g. iceberg identity / bucket / truncate partitions), enumerate them via the + // generic LIST path so partition pruning / selectedPartitionNum / SQL-block-rule enforcement see the + // real partition set — WITHOUT which they wrongly report 0 partitions. The connector's UNPARTITIONED + // verdict is preserved on the pin (partitionType), so getPartitionType() and isValidRelatedTable() + // (MTMV eligibility) stay byte-unchanged; only getNameToPartitionItem() gains the LIST partitions. + // A RANGE view (range items) and a genuinely unpartitioned table (no partition columns) are + // unaffected. Freshness matches the legacy LIST path (timestamps / 0), harmless here because the + // UNPARTITIONED verdict keeps this table out of the snapshot-id MTMV path. + if (view.getStyle() != ConnectorMvccPartitionView.Style.RANGE && !getPartitionColumns().isEmpty()) { + Map listItems = Maps.newHashMap(); + Map listLastModifiedMillis = Maps.newHashMap(); + listLatestPartitions(metadata, session, handle, listItems, listLastModifiedMillis); + return new PluginDrivenMvccSnapshot(connectorSnapshot, listItems, listLastModifiedMillis, + null, PartitionType.UNPARTITIONED, false, 0L); + } + return buildFromRangeView(connectorSnapshot, view); + } + + Map nameToPartitionItem = Maps.newHashMap(); + Map nameToLastModifiedMillis = Maps.newHashMap(); + listLatestPartitions(metadata, session, handle, nameToPartitionItem, nameToLastModifiedMillis); + return new PluginDrivenMvccSnapshot(connectorSnapshot, nameToPartitionItem, + nameToLastModifiedMillis); + } + + /** + * Builds a pin from a connector-supplied {@link ConnectorMvccPartitionView} (range-view path). The + * connector has already done ALL source-specific math (transform-to-range, partition-evolution overlap + * merge, per-partition snapshot-id resolution); this turns the pre-rendered bounds into generic + * {@link RangePartitionItem}s and stores the partition type / freshness kind / newest-update-time the + * accessors then read. A partition that fails to build FAILS LOUD (parity with legacy + * {@code IcebergUtils.loadPartitionInfo}, which does not swallow a bad partition — unlike the LIST path's + * per-partition log-and-skip). + */ + private PluginDrivenMvccSnapshot buildFromRangeView(ConnectorMvccSnapshot connectorSnapshot, + ConnectorMvccPartitionView view) { + PartitionType partitionType = view.getStyle() == ConnectorMvccPartitionView.Style.RANGE + ? PartitionType.RANGE : PartitionType.UNPARTITIONED; + boolean snapshotIdFreshness = + view.getFreshness() == ConnectorMvccPartitionView.Freshness.SNAPSHOT_ID; + Map nameToPartitionItem = Maps.newHashMap(); + Map nameToFreshnessValue = Maps.newHashMap(); + if (view.getStyle() == ConnectorMvccPartitionView.Style.RANGE) { + List partitionColumns = getPartitionColumns(); + for (ConnectorMvccPartition partition : view.getPartitions()) { + try { + nameToPartitionItem.put(partition.getName(), + toRangePartitionItem(partition, partitionColumns)); + } catch (AnalysisException e) { + // Fail loud: a range partition that cannot build is a real metadata error, not a + // skippable row (legacy loadPartitionInfo lets getPartitionRange throw). + throw new RuntimeException("Failed to build range partition item for " + + partition.getName() + ", partitionColumns: " + partitionColumns, e); + } + nameToFreshnessValue.put(partition.getName(), partition.getFreshnessValue()); + } + } + return new PluginDrivenMvccSnapshot(connectorSnapshot, nameToPartitionItem, nameToFreshnessValue, + null, partitionType, snapshotIdFreshness, view.getNewestUpdateTimeMillis()); + } + + /** + * Assembles a {@link RangePartitionItem} from the connector's pre-rendered {@code [lower, upper)} bounds + * and the table's partition column types. An EMPTY upper-bound tuple denotes the NULL-min partition: the + * exclusive upper is the column-type/scale-aware {@code lowerKey.successor()} (only fe-core owns the Doris + * {@code Column}/{@code PartitionKey}), matching the source's null-partition behavior (e.g. iceberg's + * {@code nullLowKey.successor()}). Mirrors {@code IcebergUtils.getPartitionRange}'s key building. + */ + private static RangePartitionItem toRangePartitionItem(ConnectorMvccPartition partition, + List partitionColumns) throws AnalysisException { + PartitionKey lowerKey = PartitionKey.createPartitionKey( + toPartitionValues(partition.getLowerBound()), partitionColumns); + PartitionKey upperKey = partition.getUpperBound().isEmpty() + ? lowerKey.successor() + : PartitionKey.createPartitionKey(toPartitionValues(partition.getUpperBound()), partitionColumns); + return new RangePartitionItem(Range.closedOpen(lowerKey, upperKey)); + } + + /** Wraps each pre-rendered bound string into a (non-null) {@link PartitionValue}. */ + private static List toPartitionValues(List bound) { + List values = Lists.newArrayListWithExpectedSize(bound.size()); + for (String v : bound) { + values.add(new PartitionValue(v)); + } + return values; + } + + /** + * Lists the partition set at LATEST into the two supplied maps (rendered name -> built + * {@link PartitionItem} / -> last-modified epoch millis). Mirrors legacy + * {@code PaimonUtil.generatePartitionInfo}: per-partition build is wrapped in try/catch so a single + * un-parseable name is logged and skipped (leaving the listed-name set larger than the built-item + * set, which {@link PluginDrivenMvccSnapshot#isPartitionInvalid} then treats as UNPARTITIONED) + * rather than failing the whole query. + */ + private void listLatestPartitions(ConnectorMetadata metadata, ConnectorSession session, + ConnectorTableHandle handle, Map nameToPartitionItem, + Map nameToLastModifiedMillis) { + List partitionColumns = getPartitionColumns(); + List types = partitionColumns.stream().map(Column::getType).collect(Collectors.toList()); + List parts = metadata.listPartitions(session, handle, Optional.empty()); + for (ConnectorPartitionInfo part : parts) { + String partitionName = part.getPartitionName(); + nameToLastModifiedMillis.put(partitionName, part.getLastModifiedMillis()); + try { + // The connector supplies the parsed values in name-segment order; building from them is + // byte-parity with legacy. Two shapes are tolerated by skipping the partition rather than + // failing the whole query (parity PaimonUtil.generatePartitionInfo): + // - a value that is un-representable in its column type; + // - a value/column count mismatch, which is LEGITIMATE under iceberg partition spec + // evolution: the column list comes from the CURRENT spec while each row's values come + // from the spec its data file was written under, so rows written before an + // ADD/DROP PARTITION FIELD carry fewer values (a table that began life unpartitioned + // carries none). Skipping leaves the built-item set short of the listed-name set, + // which isPartitionInvalid turns into UNPARTITIONED: the query still returns correct + // rows, it only loses partition pruning. Do NOT hoist this check out of the catch to + // "fail loud" — that was tried (cfb0958e607) and every real-world hit was a legitimate + // spec evolution, not a mis-wired connector, taking down 6 suites (CI 996541). + nameToPartitionItem.put(partitionName, + toListPartitionItem(partitionName, types, + part.getOrderedPartitionValues(), part.getPartitionValueNullFlags())); + } catch (Exception e) { + LOG.warn("toListPartitionItem failed, partitionColumns: {}, partitionName: {}", + partitionColumns, partitionName, e); + } + } + } + + private ConnectorMvccSnapshot emptySnapshot() { + return ConnectorMvccSnapshot.builder().snapshotId(-1L).build(); + } + + /** + * Builds a {@link ListPartitionItem} from a RENDERED partition name (e.g. {@code "dt=2024-01-01"}) and the + * connector-supplied per-value SQL-NULL flags. Source-agnostic: the connector — not fe-core — decides which + * values are genuine NULL. + * + *

    Each parsed value {@code i} builds {@code new PartitionValue(value, nullFlags.get(i))}. A connector that + * renders a genuine-NULL partition value (hive's {@code __HIVE_DEFAULT_PARTITION__}, paimon's + * {@code partition.default-name}) supplies {@code true} for that position, so + * {@link PartitionKey#createListPartitionKeyWithTypes} emits a typed {@code NullLiteral} instead of parsing + * the sentinel string into the column type — which for a non-string column (INT/DATE/...) would throw and + * silently drop the partition (table then mis-reported UNPARTITIONED, {@code partition=0/0}). The genuine-null + * partition then prunes on {@code col IS NULL} and an MTMV refresh materializes its null rows. A connector + * that supplies no flags ({@code nullFlags} empty) treats every value as non-null — unchanged behavior for + * connectors that do not opt in. {@code fe-core} never string-compares a sentinel (iron rule): hive and paimon + * render the identical {@code __HIVE_DEFAULT_PARTITION__} string with connector-specific NULL semantics, so + * nullness must be connector-supplied. + */ + private static ListPartitionItem toListPartitionItem(String partitionName, List types, + List connectorValues, List nullFlags) throws AnalysisException { + // The connector supplies the already-parsed values in name-segment order (hive/paimon/iceberg/hudi). + // There is no name-parsing fallback anymore. This size check is LOAD-BEARING, not defensive: it is + // what turns a heterogeneous-arity partition (legitimate under iceberg partition spec evolution) + // into the skip -> UNPARTITIONED degrade. The caller relies on it throwing INSIDE its try/catch. + List partitionValues = connectorValues; + Preconditions.checkState(partitionValues.size() == types.size(), partitionName + " vs. " + types); + // Fail loud: a connector that opts in MUST supply one flag per value; a short list would silently + // default the tail to isNull=false and re-introduce the drop bug. Empty = not opted in = OK. + Preconditions.checkState(nullFlags.isEmpty() || nullFlags.size() == types.size(), + "nullFlags " + nullFlags + " vs. " + types); + List values = Lists.newArrayListWithExpectedSize(types.size()); + for (int i = 0; i < partitionValues.size(); i++) { + boolean isNull = i < nullFlags.size() && nullFlags.get(i); + values.add(new PartitionValue(partitionValues.get(i), isNull)); + } + PartitionKey key = PartitionKey.createListPartitionKeyWithTypes(values, types, true); + return new ListPartitionItem(Lists.newArrayList(key)); + } + + // ──────────────────── MvccTable ──────────────────── + + @Override + public MvccSnapshot loadSnapshot(Optional tableSnapshot, Optional scanParams) { + if (!tableSnapshot.isPresent() && !scanParams.isPresent()) { + // B5a implicit query-begin (latest) pin. + return materializeLatest(); + } + // Mutual exclusion — parity with legacy PaimonScanNode.getProcessedTable:891-892. + if (tableSnapshot.isPresent() && scanParams.isPresent()) { + throw new RuntimeException("Can not specify scan params and table snapshot at same time."); + } + ConnectorTimeTravelSpec spec = toTimeTravelSpec(tableSnapshot, scanParams); + + makeSureInitialized(); + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + Connector connector = pluginCatalog.getConnector(); + ConnectorSession session = pluginCatalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + String dbName = db != null ? db.getRemoteName() : ""; + String tableName = getRemoteName(); + Optional handleOpt = resolveConnectorTableHandle(session, metadata); + if (!handleOpt.isPresent()) { + throw new RuntimeException("can not find table for time travel: " + dbName + "." + tableName); + } + ConnectorTableHandle handle = handleOpt.get(); + + // The connector owns all provider-specific resolution (snapshot-id lookup, datetime parsing, + // tag/branch resolution, incremental-window validation). It returns empty when the target is + // not found; a DorisConnectorException (TZ-alias / incremental-validation) propagates as-is + // (fail-loud — a degraded result would read wrong rows for a time-travel query). + Optional resolved = metadata.resolveTimeTravel(session, handle, spec); + if (!resolved.isPresent()) { + throw new RuntimeException(notFoundMessage(spec)); + } + ConnectorMvccSnapshot connectorSnapshot = resolved.get(); + + if (spec.getKind() == ConnectorTimeTravelSpec.Kind.INCREMENTAL) { + // @incr is NOT a point-in-time snapshot pin. Legacy PaimonExternalTable.getPaimonSnapshotCacheValue + // falls through (it is neither tag/branch nor FOR VERSION/TIME AS OF) to getLatestSnapshotCacheValue + // — i.e. the LATEST partition view + LATEST schema — and applies the incremental-between window at + // SCAN time. Mirror that: list the LATEST partitions and use the LATEST schema (pinnedSchema == null + // so getSchemaCacheValue() falls back to latest), while carrying the incremental scan options on the + // pin (connectorSnapshot.getProperties()); the scan node's applySnapshot threads them onto the handle. + // Partitions are listed on the BASE handle at latest — the full latest set, identical to the + // normal-read materializeLatest path — NOT a snapshot-pinned handle. + Map nameToPartitionItem = Maps.newHashMap(); + Map nameToLastModifiedMillis = Maps.newHashMap(); + listLatestPartitions(metadata, session, handle, nameToPartitionItem, nameToLastModifiedMillis); + return new PluginDrivenMvccSnapshot(connectorSnapshot, nameToPartitionItem, + nameToLastModifiedMillis, null); + } + + // Schema-at-snapshot: thread the pin onto the handle FIRST (applySnapshot), so a BRANCH pin + // yields a branch-aware handle whose schemaManager resolves the branch schema; then resolve + // the schema AT the pinned schemaId. For non-branch specs applySnapshot only adds scan options + // that getTableSchema ignores, so passing the pinned handle is harmless. (Apply-before- + // getTableSchema is REQUIRED for branch — using the base handle would resolve the branch + // schemaId against the base table's schemaManager = wrong schema.) + ConnectorTableHandle pinnedHandle = metadata.applySnapshot(session, handle, connectorSnapshot); + ConnectorTableSchema atSchema = metadata.getTableSchema(session, pinnedHandle, connectorSnapshot); + PluginDrivenSchemaCacheValue pinnedSchema = + toSchemaCacheValue(metadata, session, dbName, tableName, atSchema); + + // Explicit point-in-time time-travel (snapshot id / tag / timestamp / branch) does NOT list + // partitions (EMPTY partition maps) — parity with legacy PaimonPartitionInfo.EMPTY. The empty + // maps make isPartitionInvalid() == (0!=0) == false, so getPartitionColumns(snapshot) flows + // through super -> the schema-aware getSchemaCacheValue() below -> the pinned schema's partition + // columns. Partition pruning is deferred to the connector's predicate pushdown (the generic scan + // node's resolveRequiredPartitions treats this empty-universe pin as scan-all). + return new PluginDrivenMvccSnapshot(connectorSnapshot, + Collections.emptyMap(), Collections.emptyMap(), pinnedSchema); + } + + /** + * Source-agnostic dispatch of the analyzer's {@code FOR VERSION/TIME AS OF} ({@link TableSnapshot}) + * or {@code @tag/@branch/@incr} ({@link TableScanParams}) into a {@link ConnectorTimeTravelSpec}. + * A digital {@code FOR VERSION AS OF} is a snapshot id; a non-digital one is a source-resolved ref + * ({@link ConnectorTimeTravelSpec.Kind#VERSION_REF}) — fe-core does NOT pre-decide tag-vs-branch + * (the connector owns that: iceberg accepts a branch or a tag, paimon resolves it as a tag). + */ + private ConnectorTimeTravelSpec toTimeTravelSpec(Optional ts, Optional sp) { + if (ts.isPresent()) { + TableSnapshot snap = ts.get(); + String value = snap.getValue(); + if (snap.getType() == TableSnapshot.VersionType.TIME) { + return ConnectorTimeTravelSpec.timestamp(value, isDigital(value)); // FOR TIME AS OF + } + // FOR VERSION AS OF: digital -> snapshot id, non-digital -> source-resolved ref (branch/tag). + return isDigital(value) + ? ConnectorTimeTravelSpec.snapshotId(value) + : ConnectorTimeTravelSpec.versionRef(value); + } + TableScanParams params = sp.get(); + if (params.isTag()) { + return ConnectorTimeTravelSpec.tag(extractBranchOrTagName(params)); + } + if (params.isBranch()) { + return ConnectorTimeTravelSpec.branch(extractBranchOrTagName(params)); + } + if (params.incrementalRead()) { + return ConnectorTimeTravelSpec.incremental(params.getMapParams()); + } + throw new RuntimeException("unsupported scan params: " + params.getParamType()); + } + + /** Parity: {@code PaimonUtil.isDigitalString}. */ + private static boolean isDigital(String value) { + return value != null && DIGITAL_REGEX.matcher(value).matches(); + } + + /** Parity: {@code PaimonUtil.extractBranchOrTagName} (uses {@code TableScanParams.PARAMS_NAME == "name"}). */ + private static String extractBranchOrTagName(TableScanParams params) { + if (!params.getMapParams().isEmpty()) { + if (!params.getMapParams().containsKey(TableScanParams.PARAMS_NAME)) { + throw new IllegalArgumentException("must contain key 'name' in params"); + } + return params.getMapParams().get(TableScanParams.PARAMS_NAME); + } + if (params.getListParams().isEmpty() || params.getListParams().get(0) == null) { + throw new IllegalArgumentException("must contain a branch/tag name in params"); + } + return params.getListParams().get(0); + } + + /** Translates a {@code resolveTimeTravel}-returned empty into a kind-specific user error message. */ + private static String notFoundMessage(ConnectorTimeTravelSpec spec) { + switch (spec.getKind()) { + case SNAPSHOT_ID: + return "can't find snapshot by id: " + spec.getStringValue(); // parity PaimonUtil:687 + case VERSION_REF: + // Non-numeric FOR VERSION AS OF may name a branch OR a tag (iceberg resolves both), but the + // source-agnostic wording must NOT claim a branch lookup a tag-only source never performed + // (paimon: FOR VERSION AS OF == tag), and "no such tag" is never false. Empty fall-through to + // TAG keeps the message byte-identical to legacy paimon. (iceberg legacy's more precise "tag + // or branch" wording is a pre-existing cosmetic gap, out of this functional fix's scope.) + case TAG: + return "can't find snapshot by tag: " + spec.getStringValue(); // parity PaimonUtil:694 + case BRANCH: + return "can't find branch: " + spec.getStringValue(); // parity PaimonUtil:707 + case TIMESTAMP: + // Best-effort: the connector returns empty (it owns the parsed millis + earliest + // snapshot, which fe-core cannot see), so this diverges from legacy's detailed + // "...the earliest snapshot's timestamp is [...]" message in TEXT ONLY (same error + // condition). Documented divergence. + return "can't find snapshot earlier than or equal to time: " + spec.getStringValue(); + default: + return "can't resolve time travel: " + spec; + } + } + + // ──────────────────── schema (snapshot-aware) ──────────────────── + + /** + * Returns the schema AS OF the snapshot resolved from the AMBIENT context, else the latest schema. + * + *

    Version-BLIND: it asks the statement "what is pinned for this table?" without saying WHICH + * reference is asking, so a statement pinning this table at two versions (e.g. a self-join of two + * {@code @tag} references) cannot be disambiguated and degrades to LATEST here — a schema no reference + * asked for. Correct only for statement-global callers (MTMV refresh, preload, the write sink) that + * genuinely have no reference to speak of. The plan path must call {@link #getSchemaCacheValue( + * Optional)} with the reference's own pin instead. + */ + @Override + public Optional getSchemaCacheValue() { + return schemaAt(MvccUtil.getSnapshotFromContext(this)); + } + + /** + * Returns the schema AS OF {@code snapshot} — the pin resolved for ONE specific table reference — when + * it carries a pinned schema (schema-at-snapshot under schema evolution), else the latest schema. + * Parity with legacy {@code PaimonExternalTable.getSchemaCacheValue}, which returns the schema of the + * context-pinned snapshot. + * + *

    Deliberately NOT delegated to/from the no-arg override: an empty {@code snapshot} means "this + * reference has no pin" (=> latest), whereas the no-arg form means "resolve from the ambient context". + * They are siblings; collapsing them would strip ambient resolution from the statement-global callers. + */ + @Override + public Optional getSchemaCacheValue(Optional snapshot) { + return schemaAt(snapshot); + } + + private Optional schemaAt(Optional snapshot) { + if (snapshot.isPresent() && snapshot.get() instanceof PluginDrivenMvccSnapshot) { + SchemaCacheValue pinned = ((PluginDrivenMvccSnapshot) snapshot.get()).getPinnedSchema(); + if (pinned != null) { + return Optional.of(pinned); // time-travel: schema AS OF the pinned snapshot + } + } + return getLatestSchemaCacheValue(); // latest (B5a pin has pinnedSchema==null, or no pin) + } + + /** + * The LATEST (non-pinned) schema. For a no-cache catalog (the connector's {@code schemaCacheTtlSecondOverride} + * is {@code <= 0}, e.g. {@code meta.cache.paimon.table.ttl-second=0}) the schema is read FRESH via + * {@link #initSchema()}, bypassing the generic name-keyed schema cache. + * + *

    Why bypass rather than rely on the cache TTL: the SPI routes the latest schema through the generic + * {@code DefaultExternalMetaCache} schema entry keyed by table NAME only (no schemaId, unlike master's + * {@code PaimonSchemaCacheKey(nameMapping, schemaId)}), and that entry's TTL spec is frozen at first build + * ({@code AbstractExternalMetaCache.initCatalog} computeIfAbsent), so a {@code ttl-second=0} cannot reliably + * bust it after an external schema change. Reading fresh restores master's single-knob semantics + * ({@code meta.cache.paimon.table.ttl-second=0} -> always-fresh schema) and is cheap at ttl=0 by definition; + * {@code initSchema()} reloads via the connector's live {@code catalog.getTable} (master parity). The cached + * catalog (override absent or {@code > 0}) keeps the cached path; {@code REFRESH TABLE} still busts it. + */ + protected Optional getLatestSchemaCacheValue() { + Connector localConnector = ((PluginDrivenExternalCatalog) catalog).getConnector(); + if (schemaCacheDisabled(localConnector)) { + return initSchema(); + } + return cachedSchemaCacheValue(); + } + + /** + * The generic name-keyed schema cache read ({@code super.getSchemaCacheValue()}). Isolated as a seam so + * {@link #getLatestSchemaCacheValue()} can bypass it for a no-cache catalog and so tests can stub it. + */ + protected Optional cachedSchemaCacheValue() { + return super.getSchemaCacheValue(); + } + + /** + * Whether the connector disables its schema cache (its {@code schemaCacheTtlSecondOverride()} is present + * and {@code <= 0} — the no-cache catalog, {@code meta.cache.paimon.table.ttl-second=0}). Such a catalog + * must serve a FRESH schema on every read, restoring master's single-knob semantics. A null/empty/positive + * override keeps the cached path. + */ + static boolean schemaCacheDisabled(Connector connector) { + if (connector == null) { + return false; + } + OptionalLong override = connector.schemaCacheTtlSecondOverride(); + return override != null && override.isPresent() && override.getAsLong() <= 0; + } + + // ──────────────────── partition view (snapshot-aware) ──────────────────── + + @Override + public Map getNameToPartitionItems(Optional snapshot) { + return getOrMaterialize(snapshot).getNameToPartitionItem(); + } + + @Override + public Map getAndCopyPartitionItems(Optional snapshot) { + return new HashMap<>(getNameToPartitionItems(snapshot)); + } + + @Override + public PartitionType getPartitionType(Optional snapshot) { + PluginDrivenMvccSnapshot pin = getOrMaterialize(snapshot); + if (pin.getPartitionType() != null) { + // Range-view path: the connector already decided RANGE vs UNPARTITIONED (its eligibility gate). + return pin.getPartitionType(); + } + if (pin.isPartitionInvalid()) { + return PartitionType.UNPARTITIONED; + } + return getPartitionColumns(snapshot).size() > 0 ? PartitionType.LIST : PartitionType.UNPARTITIONED; + } + + @Override + public List getPartitionColumns(Optional snapshot) { + PluginDrivenMvccSnapshot pin = getOrMaterialize(snapshot); + if (pin.getPartitionType() != null) { + // Range-view path: do NOT empty the columns here (parity master getIcebergPartitionColumns, which + // always returns the spec columns); UNPARTITIONED is enforced via getPartitionType above. + return super.getPartitionColumns(snapshot); + } + // Legacy empties the partition columns on an invalid partition set so the table is treated + // as UNPARTITIONED everywhere downstream. + return pin.isPartitionInvalid() ? Collections.emptyList() : super.getPartitionColumns(snapshot); + } + + @Override + public Set getPartitionColumnNames(Optional snapshot) { + return getPartitionColumns(snapshot).stream() + .map(c -> c.getName().toLowerCase()).collect(Collectors.toSet()); + } + + // ──────────────────── MTMV snapshots ──────────────────── + + @Override + public MTMVSnapshotIf getPartitionSnapshot(String partitionName, MTMVRefreshContext context, + Optional snapshot) throws AnalysisException { + PluginDrivenMvccSnapshot pin = getOrMaterialize(snapshot); + Long value = pin.getNameToLastModifiedMillis().get(partitionName); + if (value == null) { + throw new AnalysisException("can not find partition: " + partitionName); + } + if (pin.isSnapshotIdFreshness()) { + // Range-view path with snapshot-id freshness pins the per-partition snapshot id (parity master + // IcebergExternalTable.getPartitionSnapshot -> MTMVSnapshotIdSnapshot). The connector pre-resolved + // the `<= 0 -> table snapshot id` fallback, so a non-empty table never carries a non-positive value. + return new MTMVSnapshotIdSnapshot(value); + } + if (pin.getConnectorSnapshot().isLastModifiedFreshness()) { + // Last-modified connector (e.g. hive): the REAL per-partition modify time is not in the pin — the + // connector's listPartitions is names-only on the scan hot path (pin value is the -1 sentinel) — so + // fetch it on demand here, on the MTMV refresh path only (parity legacy HiveDlaTable + // .getPartitionSnapshot -> MTMVTimestampSnapshot(partition.getLastModifiedTime())). The pin flag + // gates this, so a snapshot-id connector (paimon/iceberg) NEVER reaches the probe. An empty return + // means the partition vanished after the materialize-time existence check above (a refresh-time + // race): raise the legacy "can not find partition" (parity checkPartitionExists). + OptionalLong onDemand = queryPartitionFreshnessMillis(partitionName); + if (!onDemand.isPresent()) { + throw new AnalysisException("can not find partition: " + partitionName); + } + return new MTMVTimestampSnapshot(onDemand.getAsLong()); + } + // Pin-timestamp connector (paimon): the pin's per-partition last-modified millis is authoritative + // (byte-unchanged — no probe). + return new MTMVTimestampSnapshot(value); + } + + @Override + public MTMVSnapshotIf getTableSnapshot(MTMVRefreshContext context, Optional snapshot) + throws AnalysisException { + return getTableSnapshot(snapshot); + } + + @Override + public MTMVSnapshotIf getTableSnapshot(Optional snapshot) throws AnalysisException { + // Freshness-kind-aware (mirrors getPartitionSnapshot), gated by the pin fe-core already holds so a + // snapshot-id connector pays ZERO extra metadata calls. A last-modified connector (e.g. hive, whose + // whole-table change signal is transient_lastDdlTime / the max partition modify time, NOT a snapshot + // id) flags the query-begin pin; fe-core then wraps getTableFreshness into an MTMVMaxTimestampSnapshot + // (byte-parity with legacy HiveDlaTable.getTableSnapshot). WITHOUT this a plain-hive empty pin's + // snapshot id is a constant -1, so an MV over a hive base table would never detect change. A snapshot-id + // connector (paimon/iceberg) leaves the flag false and keeps the snapshot-id table snapshot, taking the + // EXACT pre-change path (getOrMaterialize was already required for the id — no added round-trip). + PluginDrivenMvccSnapshot pin = getOrMaterialize(snapshot); + if (pin.getConnectorSnapshot().isLastModifiedFreshness()) { + Optional tableFreshness = queryTableFreshness(); + if (tableFreshness.isPresent()) { + return new MTMVMaxTimestampSnapshot(tableFreshness.get().getName(), + tableFreshness.get().getTimestampMillis()); + } + } + return new MTMVSnapshotIdSnapshot(pin.getConnectorSnapshot().getSnapshotId()); + } + + // ──────────────────── on-demand freshness (last-modified connectors) ──────────────────── + + /** + * Whole-table freshness from a last-modified connector (present only for e.g. hive), or empty for a + * snapshot-id connector / a dropped catalog/table. See {@link ConnectorMetadata#getTableFreshness}. + */ + private Optional queryTableFreshness() { + Optional probe = resolveFreshnessProbe(); + if (!probe.isPresent()) { + return Optional.empty(); + } + FreshnessProbe p = probe.get(); + return p.metadata.getTableFreshness(p.session, p.handle); + } + + /** + * Per-partition last-modified millis from a last-modified connector (present only for e.g. hive), or + * empty for a snapshot-id connector / a dropped catalog/table. See + * {@link ConnectorMetadata#getPartitionFreshnessMillis}. + */ + private OptionalLong queryPartitionFreshnessMillis(String partitionName) { + Optional probe = resolveFreshnessProbe(); + if (!probe.isPresent()) { + return OptionalLong.empty(); + } + FreshnessProbe p = probe.get(); + return p.metadata.getPartitionFreshnessMillis(p.session, p.handle, partitionName); + } + + /** + * Resolves (session, metadata, handle) for an on-demand freshness probe, or empty when the catalog/table + * is gone (degrade to the snapshot-id / pin path). Unlike {@link #materializeLatest} this lists NOTHING + * and pins NOTHING — a last-modified connector fetches only the freshness it needs, off the scan hot path. + */ + private Optional resolveFreshnessProbe() { + makeSureInitialized(); + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + Connector connector = pluginCatalog.getConnector(); + if (connector == null) { + return Optional.empty(); + } + ConnectorSession session = pluginCatalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + Optional handleOpt = resolveConnectorTableHandle(session, metadata); + return handleOpt.map(handle -> new FreshnessProbe(session, metadata, handle)); + } + + /** Bundles the (session, metadata, handle) an on-demand freshness probe needs. */ + private static final class FreshnessProbe { + private final ConnectorSession session; + private final ConnectorMetadata metadata; + private final ConnectorTableHandle handle; + + private FreshnessProbe(ConnectorSession session, ConnectorMetadata metadata, + ConnectorTableHandle handle) { + this.session = session; + this.metadata = metadata; + this.handle = handle; + } + } + + @Override + public long getNewestUpdateVersionOrTime() { + // Dictionary-update path: always probe LATEST (bypass any context pin), mirroring legacy + // which passes empty/empty to force a fresh listing. + PluginDrivenMvccSnapshot pin = materializeLatest(); + // Last-modified connector (e.g. hive): the whole-table newest-change signal is a modify TIMESTAMP + // (transient_lastDdlTime / the max partition modify time), NOT the partition listing — which for hive is + // names-only, so every nameToLastModifiedMillis below is -1, gets filtered, and collapses to a CONSTANT 0. + // That constant would make Dictionary.hasNewerSourceVersion compare equal forever, so a SQL dictionary / MV + // over a hive base table would NEVER auto-refresh. Mirror getTableSnapshot: when the pin flags last-modified + // freshness, return the connector's whole-table freshness millis (cache-backed getTableFreshness, so the + // periodic dictionary poll stays cheap), else 0 (dropped catalog/table or a genuinely empty partition set — + // parity legacy). A snapshot-id connector (paimon/iceberg) leaves the flag false and takes the EXACT + // pre-change path below: a single boolean read, zero added metadata calls — byte- and cost-neutral. + if (pin.getConnectorSnapshot().isLastModifiedFreshness()) { + return queryTableFreshness().map(ConnectorTableFreshness::getTimestampMillis).orElse(0L); + } + if (pin.getPartitionType() != null) { + // Range-view path: nameToLastModifiedMillis holds (non-monotonic) snapshot ids, NOT a usable + // change marker. Use the connector-supplied newest-update-time, which IS monotonic (parity master + // IcebergExternalTable.getNewestUpdateVersionOrTime = max(partition.lastUpdateTime)). The dictionary + // requires a monotonically non-decreasing value or it throws (Dictionary.hasNewerSourceVersion). + return pin.getNewestUpdateTimeMillis(); + } + // Skip the UNKNOWN(-1) sentinel (a connector that did not collect a modified time): legacy + // used Paimon's lastFileCreationTime() which has no -1 sentinel, so feeding -1 into max() + // would let the sentinel win on an all-unknown table (returning -1 instead of the legacy 0). + return pin.getNameToLastModifiedMillis().values().stream() + .mapToLong(Long::longValue).filter(v -> v >= 0).max().orElse(0L); + } + + @Override + public boolean isValidRelatedTable() { + // MTMV refresh safety gate (MTMVTask): a base table that evolved into an unsupported partitioning + // (e.g. a single time transform changed to bucket, or gained a second partition column) must stop the + // refresh loud (parity master IcebergExternalTable.isValidRelatedTable). The connector encodes its + // eligibility verdict in the range view's style: a valid related table is RANGE, an ineligible one is + // UNPARTITIONED. The legacy path (no range view) keeps the interface default (always valid; paimon does + // not override isValidRelatedTable either). Probe LATEST, bypassing any context pin, like the gate does. + // Cost note: unlike master's cached spec-only check, this materializes (a remote partition enumeration for + // a valid table; an invalid one early-returns before the scan). Bounded — the only generic caller is + // MTMVTask, once per refresh — so the extra listing is acceptable. A cheap specs-only eligibility SPI is a + // possible future optimization if it ever matters. + PluginDrivenMvccSnapshot pin = materializeLatest(); + if (pin.getPartitionType() != null) { + return pin.getPartitionType() == PartitionType.RANGE; + } + return true; + } + + @Override + public boolean isPartitionColumnAllowNull() { + // Returns true so MTMV creation over a snapshot connector is not blocked: a source may write a + // physical "null" partition that does not match Doris' empty-partition semantics (e.g. paimon + // writes both null and the literal 'null' to the 'null' partition). Returning false would + // reject the MV; the connector owns the null-partition semantics, so we allow it. Parity with + // legacy PaimonExternalTable.isPartitionColumnAllowNull. + return true; + } + + // ──────────────────── MTMVBaseTableIf ──────────────────── + + @Override + public void beforeMTMVRefresh(MTMV mtmv) { + // No-op: parity with legacy PaimonExternalTable.beforeMTMVRefresh. + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccSnapshot.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccSnapshot.java new file mode 100644 index 00000000000000..ea22c81f98a5f0 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccSnapshot.java @@ -0,0 +1,188 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.mvcc; + +import org.apache.doris.catalog.PartitionItem; +import org.apache.doris.catalog.PartitionType; +import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; +import org.apache.doris.datasource.SchemaCacheValue; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Generic MVCC snapshot for plugin-driven (connector SPI) tables. + * + *

    Pins a single point-in-time view of an MVCC-capable connector table for the whole duration of + * a query: the scalar {@link ConnectorMvccSnapshot} (the snapshot-id pin used for reads) plus the + * materialized partition view listed at that moment. Holding the materialized view here means every + * MTMV/MvccTable accessor that receives this snapshot reads the SAME partition set with NO extra + * connector round-trip (single-pin invariant).

    + * + *

    Source-agnostic: it carries already-rendered partition names/items and per-partition staleness + * timestamps, so no data-source-specific logic lives in fe-core. Parity with the legacy + * {@code PaimonMvccSnapshot}/{@code PaimonPartitionInfo} pair.

    + * + *

    For an explicit time-travel pin it ALSO carries the schema AS OF the pinned snapshot + * ({@code pinnedSchema}), so reads under schema evolution see the historical columns; a {@code null} + * pinnedSchema means "use the latest schema" (parity with legacy + * {@code PaimonExternalTable.getSchemaCacheValue} reading the context-pinned snapshot's schema).

    + * + *

    Two paths. On the legacy (Paimon-style {@code listPartitions}) path {@code partitionType} is + * {@code null}: the caller derives LIST/UNPARTITIONED from {@link #isPartitionInvalid()} and treats + * {@code nameToLastModifiedMillis} as last-modified timestamps. On the connector-supplied range-view path + * (e.g. iceberg) {@code partitionType} is non-null (RANGE/UNPARTITIONED), {@code nameToLastModifiedMillis} + * holds the per-partition FRESHNESS values (snapshot ids when {@link #isSnapshotIdFreshness()}), and + * {@code newestUpdateTimeMillis} carries the table's monotonic dictionary-refresh marker.

    + */ +public class PluginDrivenMvccSnapshot implements MvccSnapshot { + + private final ConnectorMvccSnapshot connectorSnapshot; + private final Map nameToPartitionItem; + private final Map nameToLastModifiedMillis; + private final SchemaCacheValue pinnedSchema; + // Range-view path (connector-supplied); null/false/0 on the legacy path so its behavior is byte-unchanged. + private final PartitionType partitionType; // null => legacy LIST/UNPARTITIONED computed from isPartitionInvalid + private final boolean snapshotIdFreshness; // true => getPartitionSnapshot wraps a snapshot id, else a timestamp + private final long newestUpdateTimeMillis; // range-view table newest-update-time (dictionary refresh marker) + + /** + * @param connectorSnapshot the scalar snapshot pin (snapshot id used for reads) + * @param nameToPartitionItem rendered partition name -> built {@link PartitionItem} + * @param nameToLastModifiedMillis rendered partition name -> last-modified epoch millis (one + * entry per listed partition, BEFORE any per-partition item build + * failure dropped a name from {@code nameToPartitionItem}) + */ + public PluginDrivenMvccSnapshot(ConnectorMvccSnapshot connectorSnapshot, + Map nameToPartitionItem, + Map nameToLastModifiedMillis) { + this(connectorSnapshot, nameToPartitionItem, nameToLastModifiedMillis, null); + } + + /** + * @param connectorSnapshot the scalar snapshot pin (snapshot id used for reads) + * @param nameToPartitionItem rendered partition name -> built {@link PartitionItem} + * @param nameToLastModifiedMillis rendered partition name -> last-modified epoch millis + * @param pinnedSchema the schema AS OF the pinned snapshot (schema-at-snapshot under + * schema evolution); {@code null} = use the latest schema + */ + public PluginDrivenMvccSnapshot(ConnectorMvccSnapshot connectorSnapshot, + Map nameToPartitionItem, + Map nameToLastModifiedMillis, + SchemaCacheValue pinnedSchema) { + // Legacy (Paimon-style) path: partitionType null => caller computes LIST/UNPARTITIONED; timestamp freshness. + this(connectorSnapshot, nameToPartitionItem, nameToLastModifiedMillis, pinnedSchema, null, false, 0L); + } + + /** + * Range-view path constructor (connector-supplied {@code ConnectorMvccPartitionView}). + * + * @param connectorSnapshot the scalar snapshot pin (snapshot id used for reads) + * @param nameToPartitionItem partition name -> built {@code RangePartitionItem} + * @param nameToFreshnessValue partition name -> per-partition freshness value (a snapshot id when + * {@code snapshotIdFreshness}, else last-modified epoch millis) + * @param pinnedSchema schema AS OF the pinned snapshot, or {@code null} for the latest schema + * @param partitionType the connector-decided partition type (RANGE / UNPARTITIONED); {@code null} + * only on the legacy path (then LIST/UNPARTITIONED is computed) + * @param snapshotIdFreshness whether {@code nameToFreshnessValue} holds snapshot ids (vs timestamps) + * @param newestUpdateTimeMillis the table's monotonic newest-update-time (dictionary refresh marker) + */ + public PluginDrivenMvccSnapshot(ConnectorMvccSnapshot connectorSnapshot, + Map nameToPartitionItem, + Map nameToFreshnessValue, + SchemaCacheValue pinnedSchema, + PartitionType partitionType, + boolean snapshotIdFreshness, + long newestUpdateTimeMillis) { + this.connectorSnapshot = connectorSnapshot; + this.nameToPartitionItem = nameToPartitionItem == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(nameToPartitionItem)); + this.nameToLastModifiedMillis = nameToFreshnessValue == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(nameToFreshnessValue)); + this.pinnedSchema = pinnedSchema; + this.partitionType = partitionType; + this.snapshotIdFreshness = snapshotIdFreshness; + this.newestUpdateTimeMillis = newestUpdateTimeMillis; + } + + public ConnectorMvccSnapshot getConnectorSnapshot() { + return connectorSnapshot; + } + + /** + * The schema AS OF the pinned snapshot for time-travel under schema evolution; {@code null} for + * the latest pin (B5a query-begin) or the no-handle path, meaning the caller uses the latest + * schema. + */ + public SchemaCacheValue getPinnedSchema() { + return pinnedSchema; + } + + /** Convenience: the schema version of the pinned connector snapshot ({@code -1} = unknown). */ + public long getSchemaId() { + return connectorSnapshot.getSchemaId(); + } + + public Map getNameToPartitionItem() { + return nameToPartitionItem; + } + + public Map getNameToLastModifiedMillis() { + return nameToLastModifiedMillis; + } + + /** + * The connector-decided partition type (RANGE / UNPARTITIONED) on the range-view path, or {@code null} + * on the legacy path (then the caller computes LIST/UNPARTITIONED from {@link #isPartitionInvalid()}). + */ + public PartitionType getPartitionType() { + return partitionType; + } + + /** + * Whether {@link #getNameToLastModifiedMillis()} holds per-partition SNAPSHOT IDS (range-view path) rather + * than last-modified timestamps (legacy path); selects {@code MTMVSnapshotIdSnapshot} vs + * {@code MTMVTimestampSnapshot} in {@code getPartitionSnapshot}. + */ + public boolean isSnapshotIdFreshness() { + return snapshotIdFreshness; + } + + /** + * The table's newest-update-time (epoch millis) on the range-view path — the monotonic marker the + * dictionary auto-refresh probe compares. Only meaningful when {@link #getPartitionType()} is non-null. + */ + public long getNewestUpdateTimeMillis() { + return newestUpdateTimeMillis; + } + + /** + * True when at least one listed partition failed to build into a {@link PartitionItem} (its + * rendered name could not be parsed), i.e. the built item map is short of the listed partition + * set, so the caller falls back to UNPARTITIONED rather than silently pruning to a partial set. + * Both maps are keyed by the rendered partition name, so this compares like-for-like: a connector + * emitting two partitions that render to the same name collapses both maps equally and is NOT + * flagged invalid. Parity with legacy {@code PaimonPartitionInfo.isPartitionInvalid}. + */ + public boolean isPartitionInvalid() { + return nameToLastModifiedMillis.size() != nameToPartitionItem.size(); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/odbc/sink/OdbcTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/odbc/sink/OdbcTableSink.java deleted file mode 100644 index 2fc480fc5f8142..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/odbc/sink/OdbcTableSink.java +++ /dev/null @@ -1,59 +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.datasource.odbc.sink; - -import org.apache.doris.catalog.OdbcTable; -import org.apache.doris.planner.DataPartition; -import org.apache.doris.planner.DataSink; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.thrift.TDataSink; -import org.apache.doris.thrift.TExplainLevel; - -/** - * @deprecated ODBC tables are no longer supported. This class is retained only for - * compilation compatibility. It will throw {@link UnsupportedOperationException} - * if any attempt is made to use it at runtime. - */ -@Deprecated -public class OdbcTableSink extends DataSink { - - public OdbcTableSink(OdbcTable odbcTable) { - throw new UnsupportedOperationException( - "ODBC tables are no longer supported. Please use JDBC Catalog instead."); - } - - @Override - public String getExplainString(String prefix, TExplainLevel explainLevel) { - return prefix + "ODBC TABLE SINK: deprecated\n"; - } - - @Override - protected TDataSink toThrift() { - throw new UnsupportedOperationException("ODBC tables are no longer supported."); - } - - @Override - public PlanNodeId getExchNodeId() { - return null; - } - - @Override - public DataPartition getOutputPartition() { - return null; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/odbc/source/OdbcScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/odbc/source/OdbcScanNode.java deleted file mode 100644 index 6d85fa89750460..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/odbc/source/OdbcScanNode.java +++ /dev/null @@ -1,72 +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.datasource.odbc.source; - -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.catalog.OdbcTable; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalScanNode; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.thrift.TExplainLevel; -import org.apache.doris.thrift.TPlanNode; - -/** - * @deprecated ODBC tables are no longer supported. This class is retained only for - * compilation compatibility. It will throw {@link UnsupportedOperationException} - * if any attempt is made to use it at runtime. - */ -@Deprecated -public class OdbcScanNode extends ExternalScanNode { - - public OdbcScanNode(PlanNodeId id, TupleDescriptor desc, OdbcTable tbl, ScanContext scanContext) { - super(id, desc, "SCAN ODBC", scanContext, false); - throw new UnsupportedOperationException( - "ODBC tables are no longer supported. Please use JDBC Catalog instead."); - } - - @Override - public void init() throws UserException { - throw new UnsupportedOperationException("ODBC tables are no longer supported."); - } - - @Override - public void finalizeForNereids() throws UserException { - throw new UnsupportedOperationException("ODBC tables are no longer supported."); - } - - @Override - protected void createScanRangeLocations() throws UserException { - throw new UnsupportedOperationException("ODBC tables are no longer supported."); - } - - @Override - public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { - return prefix + "ODBC tables are deprecated.\n"; - } - - @Override - protected void toThrift(TPlanNode msg) { - throw new UnsupportedOperationException("ODBC tables are no longer supported."); - } - - @Override - public int getNumInstances() { - return 1; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/operations/ExternalMetadataOps.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/operations/ExternalMetadataOps.java deleted file mode 100644 index b7f6331306861c..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/operations/ExternalMetadataOps.java +++ /dev/null @@ -1,352 +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.datasource.operations; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.info.ColumnPosition; -import org.apache.doris.catalog.info.CreateOrReplaceBranchInfo; -import org.apache.doris.catalog.info.CreateOrReplaceTagInfo; -import org.apache.doris.catalog.info.DropBranchInfo; -import org.apache.doris.catalog.info.DropTagInfo; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo; - -import java.util.Collections; -import java.util.List; -import java.util.Map; - -/** - * all external metadata operations use this interface - */ -public interface ExternalMetadataOps { - - /** - * create db in external metastore - * @param dbName - * @param properties - * @return false means db does not exist and is created this time - * @throws DdlException - */ - default boolean createDb(String dbName, boolean ifNotExists, Map properties) throws DdlException { - boolean res = createDbImpl(dbName, ifNotExists, properties); - if (!res) { - afterCreateDb(); - } - return res; - } - - /** - * create db in external metastore for nereids - * - * @param dbName the remote name that will be created in remote metastore - * @param ifNotExists - * @param properties - * @return false means db does not exist and is created this time - * @throws DdlException - */ - boolean createDbImpl(String dbName, boolean ifNotExists, Map properties) throws DdlException; - - default void afterCreateDb() { - } - - - /** - * drop db in external metastore - * - * @param dbName the local db name in Doris - * @param ifExists - * @param force - * @throws DdlException - */ - default void dropDb(String dbName, boolean ifExists, boolean force) throws DdlException { - dropDbImpl(dbName, ifExists, force); - afterDropDb(dbName); - } - - void dropDbImpl(String dbName, boolean ifExists, boolean force) throws DdlException; - - void afterDropDb(String dbName); - - /** - * @param createTableInfo - * @return return false means table does not exist and is created this time - * @throws UserException - */ - default boolean createTable(CreateTableInfo createTableInfo) throws UserException { - boolean res = createTableImpl(createTableInfo); - if (!res) { - afterCreateTable(createTableInfo.getDbName(), createTableInfo.getTableName()); - } - return res; - } - - boolean createTableImpl(CreateTableInfo createTableInfo) throws UserException; - - default void afterCreateTable(String dbName, String tblName) { - } - - default void dropTable(ExternalTable dorisTable, boolean ifExists) throws DdlException { - dropTableImpl(dorisTable, ifExists); - afterDropTable(dorisTable.getDbName(), dorisTable.getName()); - } - - void dropTableImpl(ExternalTable dorisTable, boolean ifExists) throws DdlException; - - default void afterDropTable(String dbName, String tblName) { - } - - /** - * rename table in external metastore - * @param dbName - * @param oldName - * @param newName - * @throws DdlException - */ - default void renameTable(String dbName, String oldName, String newName) throws DdlException { - renameTableImpl(dbName, oldName, newName); - afterRenameTable(dbName, oldName, newName); - } - - default void renameTableImpl(String dbName, String oldName, String newName) throws DdlException { - throw new UnsupportedOperationException("Rename table operation is not supported for this table type."); - } - - default void afterRenameTable(String dbName, String oldName, String newName) { - throw new UnsupportedOperationException("After rename table operation is not supported for this table type."); - } - - /** - * truncate table in external metastore - * - * @param dorisTable - * @param partitions - */ - default void truncateTable(ExternalTable dorisTable, List partitions, long updateTime) throws DdlException { - truncateTableImpl(dorisTable, partitions); - afterTruncateTable(dorisTable.getDbName(), dorisTable.getName(), updateTime); - } - - void truncateTableImpl(ExternalTable dorisTable, List partitions) throws DdlException; - - default void afterTruncateTable(String dbName, String tblName, long updateTime) { - } - - /** - * create or replace branch in external metastore - * - * @param dorisTable - * @param branchInfo - * @throws UserException - */ - default void createOrReplaceBranch(ExternalTable dorisTable, CreateOrReplaceBranchInfo branchInfo) - throws UserException { - createOrReplaceBranchImpl(dorisTable, branchInfo); - afterOperateOnBranchOrTag(dorisTable.getDbName(), dorisTable.getName()); - } - - void createOrReplaceBranchImpl(ExternalTable dorisTable, CreateOrReplaceBranchInfo branchInfo) - throws UserException; - - default void afterOperateOnBranchOrTag(String dbName, String tblName) { - } - - /** - * create or replace tag in external metastore - * - * @param dorisTable - * @param tagInfo - * @throws UserException - */ - default void createOrReplaceTag(ExternalTable dorisTable, CreateOrReplaceTagInfo tagInfo) - throws UserException { - createOrReplaceTagImpl(dorisTable, tagInfo); - afterOperateOnBranchOrTag(dorisTable.getDbName(), dorisTable.getName()); - } - - void createOrReplaceTagImpl(ExternalTable dorisTable, CreateOrReplaceTagInfo tagInfo) - throws UserException; - - /** - * drop tag in external metastore - * - * @param dorisTable - * @param tagInfo - * @throws UserException - */ - default void dropTag(ExternalTable dorisTable, DropTagInfo tagInfo) - throws UserException { - dropTagImpl(dorisTable, tagInfo); - afterOperateOnBranchOrTag(dorisTable.getDbName(), dorisTable.getName()); - } - - void dropTagImpl(ExternalTable dorisTable, DropTagInfo tagInfo) throws UserException; - - /** - * drop branch in external metastore - * - * @param dorisTable - * @param branchInfo - * @throws UserException - */ - default void dropBranch(ExternalTable dorisTable, DropBranchInfo branchInfo) - throws UserException { - dropBranchImpl(dorisTable, branchInfo); - afterOperateOnBranchOrTag(dorisTable.getDbName(), dorisTable.getName()); - } - - void dropBranchImpl(ExternalTable dorisTable, DropBranchInfo branchInfo) throws UserException; - - /** - * add column for external table - * - * @param dorisTable - * @param column - * @param position - * @throws UserException - */ - default void addColumn(ExternalTable dorisTable, Column column, ColumnPosition position, long updateTime) - throws UserException { - throw new UnsupportedOperationException("Add column operation is not supported for this table type."); - } - - /** - * add columns for external table - * - * @param dorisTable - * @param columns - * @throws UserException - */ - default void addColumns(ExternalTable dorisTable, List columns, long updateTime) - throws UserException { - throw new UnsupportedOperationException("Add columns operation is not supported for this table type."); - } - - /** - * drop column for external table - * - * @param dorisTable - * @param columnName - * @throws UserException - */ - default void dropColumn(ExternalTable dorisTable, String columnName, long updateTime) - throws UserException { - throw new UnsupportedOperationException("Drop column operation is not supported for this table type."); - } - - /** - * rename column for external table - * - * @param dorisTable - * @param oldName - * @param newName - * @throws UserException - */ - default void renameColumn(ExternalTable dorisTable, String oldName, String newName, long updateTime) - throws UserException { - throw new UnsupportedOperationException("Rename column operation is not supported for this table type."); - } - - /** - * update column for external table - * - * @param dorisTable - * @param column - * @param position - * @throws UserException - */ - default void modifyColumn(ExternalTable dorisTable, Column column, ColumnPosition position, long updateTime) - throws UserException { - throw new UnsupportedOperationException("Modify column operation is not supported for this table type."); - } - - /** - * reorder columns for external table - * - * @param dorisTable - * @param newOrder - * @throws UserException - */ - default void reorderColumns(ExternalTable dorisTable, List newOrder, long updateTime) - throws UserException { - throw new UnsupportedOperationException("Reorder columns operation is not supported for this table type."); - } - - /** - * - * @return - */ - List listDatabaseNames(); - - /** - * - * @param db - * @return - */ - List listTableNames(String db); - - /** - * - * @param dbName - * @param tblName - * @return - */ - boolean tableExist(String dbName, String tblName); - - boolean databaseExist(String dbName); - - default Object loadTable(String dbName, String tblName) { - throw new UnsupportedOperationException("Load table is not supported."); - } - - /** - * close the connection, eg, to hms - */ - void close(); - - /** - * load an external view. - * @param dbName - * @param viewName - * @return an opaque view object, connector-specific - */ - default Object loadView(String dbName, String viewName) { - throw new UnsupportedOperationException("Load view is not supported."); - } - - /** - * Check if an Iceberg view exists. - * @param dbName - * @param viewName - * @return - */ - default boolean viewExists(String dbName, String viewName) { - throw new UnsupportedOperationException("View is not supported."); - } - - /** - * List all views under a specific database. - * @param db - * @return - */ - default List listViewNames(String db) { - return Collections.emptyList(); - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/DorisToPaimonTypeVisitor.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/DorisToPaimonTypeVisitor.java deleted file mode 100644 index aad8106563b4f4..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/DorisToPaimonTypeVisitor.java +++ /dev/null @@ -1,109 +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.datasource.paimon; - -import org.apache.doris.catalog.ArrayType; -import org.apache.doris.catalog.MapType; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.StructField; -import org.apache.doris.catalog.StructType; -import org.apache.doris.catalog.Type; -import org.apache.doris.datasource.DorisTypeVisitor; - -import org.apache.paimon.types.BigIntType; -import org.apache.paimon.types.BooleanType; -import org.apache.paimon.types.DataField; -import org.apache.paimon.types.DataType; -import org.apache.paimon.types.DateType; -import org.apache.paimon.types.DecimalType; -import org.apache.paimon.types.DoubleType; -import org.apache.paimon.types.FloatType; -import org.apache.paimon.types.IntType; -import org.apache.paimon.types.RowType; -import org.apache.paimon.types.TimestampType; -import org.apache.paimon.types.VarBinaryType; -import org.apache.paimon.types.VarCharType; -import org.apache.paimon.types.VariantType; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -public class DorisToPaimonTypeVisitor extends DorisTypeVisitor { - - @Override - public DataType struct(StructType struct, List fieldResults) { - List fields = struct.getFields(); - List newFields = new ArrayList<>(fields.size()); - AtomicInteger atomicInteger = new AtomicInteger(-1); - for (int i = 0; i < fields.size(); i++) { - StructField field = fields.get(i); - DataType fieldType = fieldResults.get(i).copy(field.getContainsNull()); - String comment = field.getComment(); - DataField dataField = new DataField(atomicInteger.incrementAndGet(), field.getName(), fieldType, comment); - newFields.add(dataField); - } - return new RowType(newFields); - } - - @Override - public DataType field(StructField field, DataType typeResult) { - return typeResult; - } - - @Override - public DataType array(ArrayType array, DataType elementResult) { - return new org.apache.paimon.types.ArrayType(elementResult.copy(array.getContainsNull())); - } - - @Override - public DataType map(MapType map, DataType keyResult, DataType valueResult) { - return new org.apache.paimon.types.MapType(keyResult.copy(false), - valueResult.copy(map.getIsValueContainsNull())); - } - - @Override - public DataType atomic(Type atomic) { - PrimitiveType primitiveType = atomic.getPrimitiveType(); - if (primitiveType.equals(PrimitiveType.BOOLEAN)) { - return new BooleanType(); - } else if (primitiveType.equals(PrimitiveType.INT)) { - return new IntType(); - } else if (primitiveType.equals(PrimitiveType.BIGINT)) { - return new BigIntType(); - } else if (primitiveType.equals(PrimitiveType.FLOAT)) { - return new FloatType(); - } else if (primitiveType.equals(PrimitiveType.DOUBLE)) { - return new DoubleType(); - } else if (primitiveType.isCharFamily()) { - return new VarCharType(VarCharType.MAX_LENGTH); - } else if (primitiveType.equals(PrimitiveType.DATE) || primitiveType.equals(PrimitiveType.DATEV2)) { - return new DateType(); - } else if (primitiveType.equals(PrimitiveType.DECIMALV2) || primitiveType.isDecimalV3Type()) { - return new DecimalType(((ScalarType) atomic).getScalarPrecision(), ((ScalarType) atomic).getScalarScale()); - } else if (primitiveType.equals(PrimitiveType.DATETIME) || primitiveType.equals(PrimitiveType.DATETIMEV2)) { - return new TimestampType(); - } else if (primitiveType.isVarbinaryType()) { - return new VarBinaryType(VarBinaryType.MAX_LENGTH); - } else if (primitiveType.isVariantType()) { - return new VariantType(); - } - throw new UnsupportedOperationException("Not a supported type: " + primitiveType); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonDLFExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonDLFExternalCatalog.java deleted file mode 100644 index a982abe5b017f8..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonDLFExternalCatalog.java +++ /dev/null @@ -1,29 +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.datasource.paimon; - -import java.util.Map; - -@Deprecated -public class PaimonDLFExternalCatalog extends PaimonExternalCatalog { - - public PaimonDLFExternalCatalog(long catalogId, String name, String resource, - Map props, String comment) { - super(catalogId, name, resource, props, comment); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalCatalog.java deleted file mode 100644 index 75e86769cd980a..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalCatalog.java +++ /dev/null @@ -1,192 +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.datasource.paimon; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.DdlException; -import org.apache.doris.datasource.CatalogProperty; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.InitCatalogLog; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.datasource.metacache.CacheSpec; -import org.apache.doris.datasource.property.metastore.AbstractPaimonProperties; - -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.paimon.catalog.Catalog; -import org.apache.paimon.catalog.Identifier; -import org.apache.paimon.partition.Partition; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -// The subclasses of this class are all deprecated, only for meta persistence compatibility. -public class PaimonExternalCatalog extends ExternalCatalog { - private static final Logger LOG = LogManager.getLogger(PaimonExternalCatalog.class); - public static final String PAIMON_CATALOG_TYPE = "paimon.catalog.type"; - public static final String PAIMON_FILESYSTEM = "filesystem"; - public static final String PAIMON_HMS = "hms"; - public static final String PAIMON_DLF = "dlf"; - public static final String PAIMON_REST = "rest"; - public static final String PAIMON_JDBC = "jdbc"; - public static final String PAIMON_TABLE_CACHE_ENABLE = "meta.cache.paimon.table.enable"; - public static final String PAIMON_TABLE_CACHE_TTL_SECOND = "meta.cache.paimon.table.ttl-second"; - public static final String PAIMON_TABLE_CACHE_CAPACITY = "meta.cache.paimon.table.capacity"; - protected String catalogType; - protected Catalog catalog; - - private AbstractPaimonProperties paimonProperties; - - public PaimonExternalCatalog(long catalogId, String name, String resource, Map props, - String comment) { - super(catalogId, name, InitCatalogLog.Type.PAIMON, comment); - catalogProperty = new CatalogProperty(resource, props); - } - - @Override - protected void initLocalObjectsImpl() { - paimonProperties = (AbstractPaimonProperties) catalogProperty.getMetastoreProperties(); - catalogType = paimonProperties.getPaimonCatalogType(); - catalog = createCatalog(); - initPreExecutionAuthenticator(); - metadataOps = new PaimonMetadataOps(this, catalog); - } - - @Override - protected synchronized void initPreExecutionAuthenticator() { - if (executionAuthenticator == null) { - executionAuthenticator = paimonProperties.getExecutionAuthenticator(); - } - } - - public String getCatalogType() { - makeSureInitialized(); - return catalogType; - } - - @Override - public boolean tableExist(SessionContext ctx, String dbName, String tblName) { - makeSureInitialized(); - return metadataOps.tableExist(dbName, tblName); - } - - @Override - protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { - return metadataOps.listTableNames(dbName); - } - - public List getPaimonPartitions(NameMapping nameMapping) { - makeSureInitialized(); - try { - return executionAuthenticator.execute(() -> { - List partitions = new ArrayList<>(); - try { - partitions = catalog.listPartitions(Identifier.create(nameMapping.getRemoteDbName(), - nameMapping.getRemoteTblName())); - } catch (Catalog.TableNotExistException e) { - LOG.warn("TableNotExistException", e); - } - return partitions; - }); - } catch (Exception e) { - throw new RuntimeException("Failed to get Paimon table partitions:" + getName() + "." - + nameMapping.getRemoteDbName() + "." + nameMapping.getRemoteTblName() + ", because " - + ExceptionUtils.getRootCauseMessage(e), e); - } - } - - public org.apache.paimon.table.Table getPaimonTable(NameMapping nameMapping) { - return getPaimonTable(nameMapping, null, null); - } - - public org.apache.paimon.table.Table getPaimonTable(NameMapping nameMapping, String branch, - String queryType) { - makeSureInitialized(); - try { - Identifier identifier; - if (branch != null && queryType != null) { - identifier = new Identifier(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), - branch, queryType); - } else if (branch != null) { - identifier = new Identifier(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), - branch); - } else if (queryType != null) { - identifier = new Identifier(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), - "main", queryType); - } else { - identifier = new Identifier(nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName()); - } - return executionAuthenticator.execute(() -> catalog.getTable(identifier)); - } catch (Exception e) { - throw new RuntimeException("Failed to get Paimon table:" + getName() + "." - + nameMapping.getRemoteDbName() + "." + nameMapping.getRemoteTblName() + "$" + queryType - + ", because " + ExceptionUtils.getRootCauseMessage(e), e); - } - } - - protected Catalog createCatalog() { - try { - return paimonProperties.initializeCatalog(getName(), new ArrayList<>(catalogProperty - .getOrderedStoragePropertiesList())); - } catch (Exception e) { - throw new RuntimeException("Failed to create catalog, catalog name: " + getName() + ", exception: " - + ExceptionUtils.getRootCauseMessage(e), e); - } - } - - public Map getPaimonOptionsMap() { - makeSureInitialized(); - return paimonProperties.getCatalogOptionsMap(); - } - - @Override - public void checkProperties() throws DdlException { - super.checkProperties(); - CacheSpec.checkBooleanProperty(catalogProperty.getOrDefault(PAIMON_TABLE_CACHE_ENABLE, null), - PAIMON_TABLE_CACHE_ENABLE); - CacheSpec.checkLongProperty(catalogProperty.getOrDefault(PAIMON_TABLE_CACHE_TTL_SECOND, null), - -1L, PAIMON_TABLE_CACHE_TTL_SECOND); - CacheSpec.checkLongProperty(catalogProperty.getOrDefault(PAIMON_TABLE_CACHE_CAPACITY, null), - 0L, PAIMON_TABLE_CACHE_CAPACITY); - catalogProperty.checkMetaStoreAndStorageProperties(AbstractPaimonProperties.class); - } - - @Override - public void notifyPropertiesUpdated(Map updatedProps) { - super.notifyPropertiesUpdated(updatedProps); - if (updatedProps.keySet().stream() - .anyMatch(key -> CacheSpec.isMetaCacheKeyForEngine(key, PaimonExternalMetaCache.ENGINE))) { - Env.getCurrentEnv().getExtMetaCacheMgr().removeCatalogByEngine(getId(), PaimonExternalMetaCache.ENGINE); - } - } - - @Override - public void onClose() { - super.onClose(); - if (null != catalog) { - try { - catalog.close(); - } catch (Exception e) { - LOG.warn("Failed to close paimon catalog: {}", getName(), e); - } - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalCatalogFactory.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalCatalogFactory.java deleted file mode 100644 index affe4995f107c2..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalCatalogFactory.java +++ /dev/null @@ -1,48 +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.datasource.paimon; - -import org.apache.doris.common.DdlException; -import org.apache.doris.datasource.ExternalCatalog; - -import org.apache.commons.lang3.StringUtils; - -import java.util.Map; - -public class PaimonExternalCatalogFactory { - - public static ExternalCatalog createCatalog(long catalogId, String name, String resource, Map props, - String comment) throws DdlException { - String metastoreType = props.get(PaimonExternalCatalog.PAIMON_CATALOG_TYPE); - if (StringUtils.isEmpty(metastoreType)) { - metastoreType = PaimonExternalCatalog.PAIMON_FILESYSTEM; - } - metastoreType = metastoreType.toLowerCase(); - switch (metastoreType) { - case PaimonExternalCatalog.PAIMON_HMS: - case PaimonExternalCatalog.PAIMON_FILESYSTEM: - case PaimonExternalCatalog.PAIMON_DLF: - case PaimonExternalCatalog.PAIMON_REST: - case PaimonExternalCatalog.PAIMON_JDBC: - return new PaimonExternalCatalog(catalogId, name, resource, props, comment); - default: - throw new DdlException("Unknown " + PaimonExternalCatalog.PAIMON_CATALOG_TYPE - + " value: " + metastoreType); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalDatabase.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalDatabase.java deleted file mode 100644 index fdbad45c5d0af9..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalDatabase.java +++ /dev/null @@ -1,37 +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.datasource.paimon; - -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.InitDatabaseLog; - -public class PaimonExternalDatabase extends ExternalDatabase { - - public PaimonExternalDatabase(ExternalCatalog extCatalog, Long id, String name, String remoteName) { - super(extCatalog, id, name, remoteName, InitDatabaseLog.Type.PAIMON); - } - - @Override - public PaimonExternalTable buildTableInternal(String remoteTableName, String localTableName, long tblId, - ExternalCatalog catalog, - ExternalDatabase db) { - return new PaimonExternalTable(tblId, localTableName, remoteTableName, (PaimonExternalCatalog) extCatalog, - (PaimonExternalDatabase) db); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalMetaCache.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalMetaCache.java deleted file mode 100644 index 1d08ba1274e256..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalMetaCache.java +++ /dev/null @@ -1,116 +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.datasource.paimon; - -import org.apache.doris.datasource.CacheException; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.metacache.AbstractExternalMetaCache; -import org.apache.doris.datasource.metacache.MetaCacheEntryDef; -import org.apache.doris.datasource.metacache.MetaCacheEntryInvalidation; -import org.apache.doris.datasource.metacache.paimon.PaimonLatestSnapshotProjectionLoader; -import org.apache.doris.datasource.metacache.paimon.PaimonPartitionInfoLoader; -import org.apache.doris.datasource.metacache.paimon.PaimonTableLoader; - -import org.apache.paimon.table.Table; - -import java.util.Map; -import java.util.concurrent.ExecutorService; - -/** - * Paimon engine implementation of {@link AbstractExternalMetaCache}. - * - *

    Registered entries: - *

      - *
    • {@code table}: loaded Paimon table handle per table mapping
    • - *
    • {@code schema}: schema cache keyed by table identity + schema id
    • - *
    - * - *

    Latest snapshot metadata is modeled as a runtime projection memoized inside the table cache - * value instead of as an independent cache entry. - * - *

    Invalidation behavior: - *

      - *
    • db/table invalidation clears table and schema entries by matching local names
    • - *
    • partition-level invalidation falls back to table-level invalidation
    • - *
    - */ -public class PaimonExternalMetaCache extends AbstractExternalMetaCache { - public static final String ENGINE = "paimon"; - public static final String ENTRY_TABLE = "table"; - public static final String ENTRY_SCHEMA = "schema"; - - private final EntryHandle tableEntry; - private final EntryHandle schemaEntry; - private final PaimonTableLoader tableLoader; - private final PaimonLatestSnapshotProjectionLoader latestSnapshotProjectionLoader; - - public PaimonExternalMetaCache(ExecutorService refreshExecutor) { - super(ENGINE, refreshExecutor); - tableLoader = new PaimonTableLoader(); - latestSnapshotProjectionLoader = new PaimonLatestSnapshotProjectionLoader( - new PaimonPartitionInfoLoader(tableLoader), this::getPaimonSchemaCacheValue); - tableEntry = registerEntry(MetaCacheEntryDef.of(ENTRY_TABLE, NameMapping.class, PaimonTableCacheValue.class, - this::loadTableCacheValue, defaultEntryCacheSpec(), - MetaCacheEntryInvalidation.forNameMapping(nameMapping -> nameMapping))); - schemaEntry = registerEntry(MetaCacheEntryDef.of(ENTRY_SCHEMA, PaimonSchemaCacheKey.class, - SchemaCacheValue.class, this::loadSchemaCacheValue, defaultSchemaCacheSpec(), - MetaCacheEntryInvalidation.forNameMapping(PaimonSchemaCacheKey::getNameMapping))); - } - - public Table getPaimonTable(ExternalTable dorisTable) { - NameMapping nameMapping = dorisTable.getOrBuildNameMapping(); - return tableEntry.get(nameMapping.getCtlId()).get(nameMapping).getPaimonTable(); - } - - public Table getPaimonTable(NameMapping nameMapping) { - return tableEntry.get(nameMapping.getCtlId()).get(nameMapping).getPaimonTable(); - } - - public PaimonSnapshotCacheValue getSnapshotCache(ExternalTable dorisTable) { - NameMapping nameMapping = dorisTable.getOrBuildNameMapping(); - return tableEntry.get(nameMapping.getCtlId()).get(nameMapping).getLatestSnapshotCacheValue(); - } - - public PaimonSchemaCacheValue getPaimonSchemaCacheValue(NameMapping nameMapping, long schemaId) { - SchemaCacheValue schemaCacheValue = schemaEntry.get(nameMapping.getCtlId()) - .get(new PaimonSchemaCacheKey(nameMapping, schemaId)); - return (PaimonSchemaCacheValue) schemaCacheValue; - } - - private PaimonTableCacheValue loadTableCacheValue(NameMapping nameMapping) { - Table paimonTable = tableLoader.load(nameMapping); - return new PaimonTableCacheValue(paimonTable, - () -> latestSnapshotProjectionLoader.load(nameMapping, paimonTable)); - } - - private SchemaCacheValue loadSchemaCacheValue(PaimonSchemaCacheKey key) { - ExternalTable dorisTable = findExternalTable(key.getNameMapping(), ENGINE); - return dorisTable.initSchemaAndUpdateTime(key).orElseThrow(() -> - new CacheException("failed to load paimon schema cache value for: %s.%s.%s, schemaId: %s", - null, key.getNameMapping().getCtlId(), key.getNameMapping().getLocalDbName(), - key.getNameMapping().getLocalTblName(), key.getSchemaId())); - } - - @Override - protected Map catalogPropertyCompatibilityMap() { - return singleCompatibilityMap(ExternalCatalog.SCHEMA_CACHE_TTL_SECOND, ENTRY_SCHEMA); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalTable.java deleted file mode 100644 index 6a744f765e8e2f..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalTable.java +++ /dev/null @@ -1,429 +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.datasource.paimon; - -import org.apache.doris.analysis.TableScanParams; -import org.apache.doris.analysis.TableSnapshot; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.MTMV; -import org.apache.doris.catalog.PartitionItem; -import org.apache.doris.catalog.PartitionType; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.DdlException; -import org.apache.doris.datasource.CacheException; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.SchemaCacheKey; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.mvcc.MvccSnapshot; -import org.apache.doris.datasource.mvcc.MvccTable; -import org.apache.doris.datasource.mvcc.MvccUtil; -import org.apache.doris.datasource.systable.PaimonSysTable; -import org.apache.doris.datasource.systable.SysTable; -import org.apache.doris.mtmv.MTMVBaseTableIf; -import org.apache.doris.mtmv.MTMVRefreshContext; -import org.apache.doris.mtmv.MTMVRelatedTableIf; -import org.apache.doris.mtmv.MTMVSnapshotIdSnapshot; -import org.apache.doris.mtmv.MTMVSnapshotIf; -import org.apache.doris.mtmv.MTMVTimestampSnapshot; -import org.apache.doris.statistics.AnalysisInfo; -import org.apache.doris.statistics.BaseAnalysisTask; -import org.apache.doris.statistics.ExternalAnalysisTask; -import org.apache.doris.thrift.THiveTable; -import org.apache.doris.thrift.TTableDescriptor; -import org.apache.doris.thrift.TTableType; - -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import com.google.common.collect.Sets; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.paimon.CoreOptions; -import org.apache.paimon.Snapshot; -import org.apache.paimon.partition.Partition; -import org.apache.paimon.schema.TableSchema; -import org.apache.paimon.table.DataTable; -import org.apache.paimon.table.Table; -import org.apache.paimon.table.source.Split; -import org.apache.paimon.types.DataField; -import org.apache.paimon.types.DataTypeRoot; - -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -public class PaimonExternalTable extends ExternalTable implements MTMVRelatedTableIf, MTMVBaseTableIf, MvccTable { - - private static final Logger LOG = LogManager.getLogger(PaimonExternalTable.class); - - public PaimonExternalTable(long id, String name, String remoteName, PaimonExternalCatalog catalog, - PaimonExternalDatabase db) { - super(id, name, remoteName, catalog, db, TableType.PAIMON_EXTERNAL_TABLE); - } - - @Override - public String getMetaCacheEngine() { - return PaimonExternalMetaCache.ENGINE; - } - - public String getPaimonCatalogType() { - return ((PaimonExternalCatalog) catalog).getCatalogType(); - } - - protected synchronized void makeSureInitialized() { - super.makeSureInitialized(); - if (!objectCreated) { - objectCreated = true; - } - } - - public Table getPaimonTable(Optional snapshot) { - if (snapshot.isPresent()) { - // MTMV scenario: get from snapshot cache - return getOrFetchSnapshotCacheValue(snapshot).getSnapshot().getTable(); - } else { - // Normal query scenario: get directly from table cache - return PaimonUtils.getPaimonTable(this); - } - } - - private PaimonSnapshotCacheValue getPaimonSnapshotCacheValue(Optional tableSnapshot, - Optional scanParams) { - makeSureInitialized(); - - // Current limitation: cannot specify both table snapshot and scan parameters simultaneously. - if (tableSnapshot.isPresent() || (scanParams.isPresent() && scanParams.get().isTag())) { - // If a snapshot is specified, - // use the specified snapshot and the corresponding schema(not the latest - // schema). - try { - Table baseTable = getBasePaimonTable(); - DataTable dataTable = (DataTable) baseTable; - Snapshot snapshot; - Map scanOptions = new HashMap<>(); - - if (tableSnapshot.isPresent()) { - TableSnapshot snapshotOpt = tableSnapshot.get(); - String value = snapshotOpt.getValue(); - if (snapshotOpt.getType() == TableSnapshot.VersionType.TIME) { - snapshot = PaimonUtil.getPaimonSnapshotByTimestamp( - dataTable, value, PaimonUtil.isDigitalString(value)); - scanOptions.put(CoreOptions.SCAN_SNAPSHOT_ID.key(), String.valueOf(snapshot.id())); - } else { - if (PaimonUtil.isDigitalString(value)) { - snapshot = PaimonUtil.getPaimonSnapshotBySnapshotId(dataTable, value); - scanOptions.put(CoreOptions.SCAN_SNAPSHOT_ID.key(), String.valueOf(snapshot.id())); - } else { - snapshot = PaimonUtil.getPaimonSnapshotByTag(dataTable, value); - scanOptions.put(CoreOptions.SCAN_TAG_NAME.key(), value); - } - } - } else { - String tagName = PaimonUtil.extractBranchOrTagName(scanParams.get()); - snapshot = PaimonUtil.getPaimonSnapshotByTag(dataTable, tagName); - scanOptions.put(CoreOptions.SCAN_TAG_NAME.key(), tagName); - } - - Table scanTable = baseTable.copy(scanOptions); - return new PaimonSnapshotCacheValue(PaimonPartitionInfo.EMPTY, - new PaimonSnapshot(snapshot.id(), snapshot.schemaId(), scanTable)); - } catch (Exception e) { - LOG.warn("Failed to get Paimon snapshot for table {}", getOrBuildNameMapping().getFullLocalName(), e); - throw new RuntimeException( - "Failed to get Paimon snapshot: " + (e.getMessage() == null ? "unknown cause" : e.getMessage()), - e); - } - } else if (scanParams.isPresent() && scanParams.get().isBranch()) { - try { - Table baseTable = getBasePaimonTable(); - String branch = PaimonUtil.resolvePaimonBranch(scanParams.get(), baseTable); - Table table = ((PaimonExternalCatalog) catalog).getPaimonTable(getOrBuildNameMapping(), branch, null); - Optional latestSnapshot = table.latestSnapshot(); - long latestSnapshotId = PaimonSnapshot.INVALID_SNAPSHOT_ID; - if (latestSnapshot.isPresent()) { - latestSnapshotId = latestSnapshot.get().id(); - } - // Branches in Paimon can have independent schemas and snapshots. - // TODO: Add time travel support for paimon branch tables. - DataTable dataTable = (DataTable) table; - Long schemaId = dataTable.schemaManager().latest().map(TableSchema::id).orElse(0L); - return new PaimonSnapshotCacheValue(PaimonPartitionInfo.EMPTY, - new PaimonSnapshot(latestSnapshotId, schemaId, dataTable)); - } catch (Exception e) { - LOG.warn("Failed to get Paimon branch for table {}", getOrBuildNameMapping().getFullLocalName(), e); - throw new RuntimeException( - "Failed to get Paimon branch: " + (e.getMessage() == null ? "unknown cause" : e.getMessage()), - e); - } - } else { - // Otherwise, use the latest snapshot and the latest schema. - return PaimonUtils.getLatestSnapshotCacheValue(this); - } - } - - @Override - public TTableDescriptor toThrift() { - List schema = getFullSchema(); - if (PaimonExternalCatalog.PAIMON_HMS.equals(getPaimonCatalogType()) - || PaimonExternalCatalog.PAIMON_FILESYSTEM.equals(getPaimonCatalogType()) - || PaimonExternalCatalog.PAIMON_DLF.equals(getPaimonCatalogType()) - || PaimonExternalCatalog.PAIMON_REST.equals(getPaimonCatalogType()) - || PaimonExternalCatalog.PAIMON_JDBC.equals(getPaimonCatalogType())) { - THiveTable tHiveTable = new THiveTable(dbName, name, new HashMap<>()); - TTableDescriptor tTableDescriptor = new TTableDescriptor(getId(), TTableType.HIVE_TABLE, schema.size(), 0, - getName(), dbName); - tTableDescriptor.setHiveTable(tHiveTable); - return tTableDescriptor; - } else { - throw new IllegalArgumentException( - "Currently only supports hms/dlf/rest/filesystem/jdbc catalog, do not support: " - + getPaimonCatalogType()); - } - } - - @Override - public BaseAnalysisTask createAnalysisTask(AnalysisInfo info) { - makeSureInitialized(); - return new ExternalAnalysisTask(info); - } - - @Override - public long fetchRowCount() { - makeSureInitialized(); - long rowCount = 0; - List splits = getBasePaimonTable().newReadBuilder().newScan().plan().splits(); - for (Split split : splits) { - rowCount += split.rowCount(); - } - if (rowCount == 0) { - LOG.info("Paimon table {} row count is 0, return -1", name); - } - return rowCount > 0 ? rowCount : UNKNOWN_ROW_COUNT; - } - - @Override - public void beforeMTMVRefresh(MTMV mtmv) throws DdlException { - } - - @Override - public Map getAndCopyPartitionItems(Optional snapshot) { - return Maps.newHashMap(getNameToPartitionItems(snapshot)); - } - - @Override - public PartitionType getPartitionType(Optional snapshot) { - if (isPartitionInvalid(snapshot)) { - return PartitionType.UNPARTITIONED; - } - return getPartitionColumns(snapshot).size() > 0 ? PartitionType.LIST : PartitionType.UNPARTITIONED; - } - - @Override - public Set getPartitionColumnNames(Optional snapshot) { - return getPartitionColumns(snapshot).stream() - .map(c -> c.getName().toLowerCase()).collect(Collectors.toSet()); - } - - @Override - public List getPartitionColumns(Optional snapshot) { - if (isPartitionInvalid(snapshot)) { - return Collections.emptyList(); - } - return getPaimonSchemaCacheValue(snapshot).getPartitionColumns(); - } - - public boolean isPartitionInvalid(Optional snapshot) { - PaimonSnapshotCacheValue paimonSnapshotCacheValue = getOrFetchSnapshotCacheValue(snapshot); - return paimonSnapshotCacheValue.getPartitionInfo().isPartitionInvalid(); - } - - @Override - public MTMVSnapshotIf getPartitionSnapshot(String partitionName, MTMVRefreshContext context, - Optional snapshot) - throws AnalysisException { - Partition paimonPartition = getOrFetchSnapshotCacheValue(snapshot).getPartitionInfo().getNameToPartition() - .get(partitionName); - if (paimonPartition == null) { - throw new AnalysisException("can not find partition: " + partitionName); - } - return new MTMVTimestampSnapshot(paimonPartition.lastFileCreationTime()); - } - - @Override - public MTMVSnapshotIf getTableSnapshot(MTMVRefreshContext context, Optional snapshot) - throws AnalysisException { - return getTableSnapshot(snapshot); - } - - public Map getPartitionSnapshot( - Optional snapshot) { - - return getOrFetchSnapshotCacheValue(snapshot).getPartitionInfo() - .getNameToPartition(); - } - - @Override - public MTMVSnapshotIf getTableSnapshot(Optional snapshot) throws AnalysisException { - PaimonSnapshotCacheValue paimonSnapshot = getOrFetchSnapshotCacheValue(snapshot); - return new MTMVSnapshotIdSnapshot(paimonSnapshot.getSnapshot().getSnapshotId()); - } - - @Override - public long getNewestUpdateVersionOrTime() { - return getPaimonSnapshotCacheValue(Optional.empty(), Optional.empty()).getPartitionInfo().getNameToPartition() - .values().stream() - .mapToLong(Partition::lastFileCreationTime).max().orElse(0); - } - - @Override - public boolean isPartitionColumnAllowNull() { - // Paimon will write to the 'null' partition regardless of whether it is' null or 'null'. - // The logic is inconsistent with Doris' empty partition logic, so it needs to return false. - // However, when Spark creates Paimon tables, specifying 'not null' does not take effect. - // In order to successfully create the materialized view, false is returned here. - // The cost is that Paimon partition writes a null value, and the materialized view cannot detect this data. - return true; - } - - @Override - public MvccSnapshot loadSnapshot(Optional tableSnapshot, Optional scanParams) { - return new PaimonMvccSnapshot(getPaimonSnapshotCacheValue(tableSnapshot, scanParams)); - } - - @Override - public Map getNameToPartitionItems(Optional snapshot) { - return getOrFetchSnapshotCacheValue(snapshot).getPartitionInfo().getNameToPartitionItem(); - } - - @Override - public boolean supportInternalPartitionPruned() { - return true; - } - - @Override - public boolean supportsExternalMetadataPreload() { - return true; - } - - @Override - public boolean supportsLatestSnapshotPreload() { - return true; - } - - @Override - public List getFullSchema() { - return getPaimonSchemaCacheValue(MvccUtil.getSnapshotFromContext(this)).getSchema(); - } - - @Override - public Optional initSchema(SchemaCacheKey key) { - makeSureInitialized(); - PaimonSchemaCacheKey paimonSchemaCacheKey = (PaimonSchemaCacheKey) key; - try { - Table table = getBasePaimonTable(); - TableSchema tableSchema = ((DataTable) table).schemaManager().schema(paimonSchemaCacheKey.getSchemaId()); - List columns = tableSchema.fields(); - List dorisColumns = Lists.newArrayListWithCapacity(columns.size()); - Set partitionColumnNames = Sets.newHashSet(tableSchema.partitionKeys()); - List partitionColumns = Lists.newArrayList(); - for (DataField field : columns) { - Column column = new Column(field.name(), - PaimonUtil.paimonTypeToDorisType(field.type(), getCatalog().getEnableMappingVarbinary(), - getCatalog().getEnableMappingTimestampTz()), - true, - null, true, field.description(), true, - -1); - PaimonUtil.updatePaimonColumnUniqueId(column, field); - if (field.type().getTypeRoot() == DataTypeRoot.TIMESTAMP_WITH_LOCAL_TIME_ZONE) { - column.setWithTZExtraInfo(); - } - dorisColumns.add(column); - if (partitionColumnNames.contains(field.name())) { - partitionColumns.add(column); - } - } - return Optional.of(new PaimonSchemaCacheValue(dorisColumns, partitionColumns, tableSchema)); - } catch (Exception e) { - throw new CacheException("failed to initSchema for: %s.%s.%s.%s", - null, getCatalog().getName(), key.getNameMapping().getLocalDbName(), - key.getNameMapping().getLocalTblName(), - paimonSchemaCacheKey.getSchemaId()); - } - } - - @Override - public Optional getSchemaCacheValue() { - return Optional.of(getPaimonSchemaCacheValue(MvccUtil.getSnapshotFromContext(this))); - } - - private PaimonSchemaCacheValue getPaimonSchemaCacheValue(Optional snapshot) { - PaimonSnapshotCacheValue snapshotCacheValue = getOrFetchSnapshotCacheValue(snapshot); - return PaimonUtils.getSchemaCacheValue(this, snapshotCacheValue); - } - - private PaimonSnapshotCacheValue getOrFetchSnapshotCacheValue(Optional snapshot) { - if (snapshot.isPresent()) { - return ((PaimonMvccSnapshot) snapshot.get()).getSnapshotCacheValue(); - } else { - // Use new lazy-loading snapshot cache API - return PaimonUtils.getSnapshotCacheValue(snapshot, this); - } - } - - @Override - public Map getSupportedSysTables() { - makeSureInitialized(); - return PaimonSysTable.SUPPORTED_SYS_TABLES; - } - - @Override - public String getComment() { - Table table = getBasePaimonTable(); - return table.comment().isPresent() ? table.comment().get() : ""; - } - - public Map getTableProperties() { - Table table = getBasePaimonTable(); - if (table instanceof DataTable) { - DataTable dataTable = (DataTable) table; - Map properties = new LinkedHashMap<>(dataTable.coreOptions().toMap()); - - if (!dataTable.primaryKeys().isEmpty()) { - properties.put(CoreOptions.PRIMARY_KEY.key(), String.join(",", dataTable.primaryKeys())); - } - - return properties; - } else { - return Collections.emptyMap(); - } - } - - @Override - public boolean isPartitionedTable() { - makeSureInitialized(); - return !getBasePaimonTable().partitionKeys().isEmpty(); - } - - private Table getBasePaimonTable() { - return PaimonUtils.getPaimonTable(this); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonFileExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonFileExternalCatalog.java deleted file mode 100644 index 9bc233f4b05f15..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonFileExternalCatalog.java +++ /dev/null @@ -1,29 +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.datasource.paimon; - -import java.util.Map; - -@Deprecated -public class PaimonFileExternalCatalog extends PaimonExternalCatalog { - - public PaimonFileExternalCatalog(long catalogId, String name, String resource, - Map props, String comment) { - super(catalogId, name, resource, props, comment); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonHMSExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonHMSExternalCatalog.java deleted file mode 100644 index 0a4702ab4f1cd1..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonHMSExternalCatalog.java +++ /dev/null @@ -1,29 +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.datasource.paimon; - -import java.util.Map; - -@Deprecated -public class PaimonHMSExternalCatalog extends PaimonExternalCatalog { - - public PaimonHMSExternalCatalog(long catalogId, String name, String resource, - Map props, String comment) { - super(catalogId, name, resource, props, comment); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMetadataOps.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMetadataOps.java deleted file mode 100644 index 2f0be3bc3054f0..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMetadataOps.java +++ /dev/null @@ -1,421 +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.datasource.paimon; - -import org.apache.doris.analysis.PartitionDesc; -import org.apache.doris.catalog.StructField; -import org.apache.doris.catalog.StructType; -import org.apache.doris.catalog.Type; -import org.apache.doris.catalog.info.CreateOrReplaceBranchInfo; -import org.apache.doris.catalog.info.CreateOrReplaceTagInfo; -import org.apache.doris.catalog.info.DropBranchInfo; -import org.apache.doris.catalog.info.DropTagInfo; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.ErrorCode; -import org.apache.doris.common.ErrorReport; -import org.apache.doris.common.UserException; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; -import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.DorisTypeVisitor; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.operations.ExternalMetadataOps; -import org.apache.doris.nereids.trees.plans.commands.info.ColumnDefinition; -import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo; - -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.paimon.CoreOptions; -import org.apache.paimon.catalog.Catalog; -import org.apache.paimon.catalog.Catalog.DatabaseNotEmptyException; -import org.apache.paimon.catalog.Catalog.DatabaseNotExistException; -import org.apache.paimon.catalog.Catalog.TableAlreadyExistException; -import org.apache.paimon.catalog.Catalog.TableNotExistException; -import org.apache.paimon.catalog.Identifier; -import org.apache.paimon.schema.Schema; -import org.apache.paimon.types.DataType; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -public class PaimonMetadataOps implements ExternalMetadataOps { - - private static final Logger LOG = LogManager.getLogger(PaimonMetadataOps.class); - protected Catalog catalog; - protected ExternalCatalog dorisCatalog; - private ExecutionAuthenticator executionAuthenticator; - private static final String PRIMARY_KEY_IDENTIFIER = "primary-key"; - private static final String PROP_COMMENT = "comment"; - private static final String PROP_LOCATION = "location"; - - public PaimonMetadataOps(ExternalCatalog dorisCatalog, Catalog catalog) { - this.dorisCatalog = dorisCatalog; - this.catalog = catalog; - this.executionAuthenticator = dorisCatalog.getExecutionAuthenticator(); - } - - - @Override - public boolean createDbImpl(String dbName, boolean ifNotExists, Map properties) - throws DdlException { - try { - return executionAuthenticator.execute(() -> performCreateDb(dbName, ifNotExists, properties)); - } catch (Exception e) { - throw new DdlException("Failed to create database: " - + dbName + ": " + Util.getRootCauseMessage(e), e); - } - } - - private boolean performCreateDb(String dbName, boolean ifNotExists, Map properties) - throws DdlException, Catalog.DatabaseAlreadyExistException { - if (databaseExist(dbName)) { - if (ifNotExists) { - LOG.info("create database[{}] which already exists", dbName); - return true; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_DB_CREATE_EXISTS, dbName); - } - } - - if (!properties.isEmpty() && dorisCatalog instanceof PaimonExternalCatalog) { - String catalogType = ((PaimonExternalCatalog) dorisCatalog).getCatalogType(); - if (!PaimonExternalCatalog.PAIMON_HMS.equals(catalogType)) { - throw new DdlException( - "Not supported: create database with properties for paimon catalog type: " + catalogType); - } - } - - catalog.createDatabase(dbName, ifNotExists, properties); - return false; - } - - @Override - public void afterCreateDb() { - dorisCatalog.resetMetaCacheNames(); - } - - @Override - public void dropDbImpl(String dbName, boolean ifExists, boolean force) throws DdlException { - try { - executionAuthenticator.execute(() -> { - performDropDb(dbName, ifExists, force); - return null; - }); - } catch (Exception e) { - throw new DdlException( - "Failed to drop database: " + dbName + ", error message is:" + e.getMessage(), e); - } - } - - private void performDropDb(String dbName, boolean ifExists, boolean force) throws DdlException { - ExternalDatabase dorisDb = dorisCatalog.getDbNullable(dbName); - if (dorisDb == null) { - if (ifExists) { - LOG.info("drop database[{}] which does not exist", dbName); - // Database does not exist and IF EXISTS is specified; treat as no-op. - return; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_DB_DROP_EXISTS, dbName); - // ErrorReport.reportDdlException is expected to throw DdlException. - return; - } - } - - if (force) { - List tableNames = listTableNames(dbName); - if (!tableNames.isEmpty()) { - LOG.info("drop database[{}] with force, drop all tables, num: {}", dbName, tableNames.size()); - } - for (String tableName : tableNames) { - performDropTable(dbName, tableName, true); - } - } - - try { - catalog.dropDatabase(dbName, ifExists, force); - } catch (DatabaseNotExistException e) { - throw new RuntimeException("database " + dbName + " does not exist!"); - } catch (DatabaseNotEmptyException e) { - throw new RuntimeException("database " + dbName + " is not empty! please check!"); - } - } - - @Override - public void afterDropDb(String dbName) { - dorisCatalog.unregisterDatabase(dbName); - } - - @Override - public boolean createTableImpl(CreateTableInfo createTableInfo) throws UserException { - try { - return executionAuthenticator.execute(() -> performCreateTable(createTableInfo)); - } catch (Exception e) { - throw new DdlException( - "Failed to create table: " + createTableInfo.getTableName() + ", error message is:" + e.getMessage(), - e); - } - } - - public boolean performCreateTable(CreateTableInfo createTableInfo) throws UserException { - String dbName = createTableInfo.getDbName(); - ExternalDatabase db = dorisCatalog.getDbNullable(dbName); - if (db == null) { - throw new UserException("Failed to get database: '" + dbName + "' in catalog: " + dorisCatalog.getName()); - } - String tableName = createTableInfo.getTableName(); - // 1. first, check if table exist in remote - if (tableExist(db.getRemoteName(), tableName)) { - if (createTableInfo.isIfNotExists()) { - LOG.info("create table[{}] which already exists", tableName); - return true; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_TABLE_EXISTS_ERROR, tableName); - } - } - - // 2. second, check if table exist in local. - // This is because case sensibility issue, eg: - // 1. lower_case_table_name = 1 - // 2. create table tbl1; - // 3. create table TBL1; TBL1 does not exist in remote because the remote system is case-sensitive. - // but because lower_case_table_name = 1, the table can not be created in Doris because it is conflict with - // tbl1 - ExternalTable dorisTable = db.getTableNullable(tableName); - if (dorisTable != null) { - if (createTableInfo.isIfNotExists()) { - LOG.info("create table[{}] which already exists", tableName); - return true; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_TABLE_EXISTS_ERROR, tableName); - } - } - List columns = createTableInfo.getColumnDefinitions(); - List collect = columns.stream() - .map(col -> new StructField(col.getName(), col.getType().toCatalogDataType(), - col.getComment(), col.isNullable())) - .collect(Collectors.toList()); - StructType structType = new StructType(new ArrayList<>(collect)); - List rootFieldNames = columns.stream().map(ColumnDefinition::getName).collect(Collectors.toList()); - Schema schema = toPaimonSchema(structType, rootFieldNames, createTableInfo.getPartitionDesc(), - createTableInfo.getProperties()); - try { - catalog.createTable(new Identifier(createTableInfo.getDbName(), createTableInfo.getTableName()), - schema, createTableInfo.isIfNotExists()); - } catch (TableAlreadyExistException | DatabaseNotExistException e) { - throw new RuntimeException(e); - } - return false; - } - - private Schema toPaimonSchema(StructType structType, List rootFieldNames, PartitionDesc partitionDesc, - Map properties) { - Map normalizedProperties = new HashMap<>(properties); - normalizedProperties.remove(PRIMARY_KEY_IDENTIFIER); - normalizedProperties.remove(PROP_COMMENT); - if (normalizedProperties.containsKey(PROP_LOCATION)) { - String path = normalizedProperties.remove(PROP_LOCATION); - normalizedProperties.put(CoreOptions.PATH.key(), path); - } - - String pkAsString = properties.get(PRIMARY_KEY_IDENTIFIER); - List primaryKeys = pkAsString == null ? Collections.emptyList() : Arrays.stream(pkAsString.split(",")) - .map(String::trim) - .collect(Collectors.toList()); - List partitionKeys = partitionDesc == null ? new ArrayList<>() : partitionDesc.getPartitionColNames(); - primaryKeys = getPaimonColumnNames(rootFieldNames, primaryKeys); - partitionKeys = getPaimonColumnNames(rootFieldNames, partitionKeys); - Schema.Builder schemaBuilder = Schema.newBuilder() - .options(normalizedProperties) - .primaryKey(primaryKeys) - .partitionKeys(partitionKeys) - .comment(properties.getOrDefault(PROP_COMMENT, null)); - List fields = structType.getFields(); - for (int i = 0; i < fields.size(); i++) { - StructField field = fields.get(i); - schemaBuilder.column(rootFieldNames.get(i), - toPaimontype(field.getType()).copy(field.getContainsNull()), - field.getComment()); - } - return schemaBuilder.build(); - } - - private List getPaimonColumnNames(List paimonColumnNames, List dorisColumnNames) { - Map paimonColumnNameMap = paimonColumnNames.stream() - .collect(Collectors.toMap(name -> name.toLowerCase(Locale.ROOT), name -> name)); - return dorisColumnNames.stream() - .map(name -> paimonColumnNameMap.getOrDefault(name.toLowerCase(Locale.ROOT), name)) - .collect(Collectors.toList()); - } - - private DataType toPaimontype(Type type) { - return DorisTypeVisitor.visit(type, new DorisToPaimonTypeVisitor()); - } - - @Override - public void afterCreateTable(String dbName, String tblName) { - Optional> db = dorisCatalog.getDbForReplay(dbName); - if (db.isPresent()) { - db.get().resetMetaCacheNames(); - } - LOG.info("after create table {}.{}.{}, is db exists: {}", - dorisCatalog.getName(), dbName, tblName, db.isPresent()); - } - - @Override - public void dropTableImpl(ExternalTable dorisTable, boolean ifExists) throws DdlException { - try { - executionAuthenticator.execute(() -> { - performDropTable(dorisTable.getRemoteDbName(), dorisTable.getRemoteName(), ifExists); - return null; - }); - } catch (Exception e) { - throw new DdlException( - "Failed to drop table: " + dorisTable.getName() + ", error message is:" + e.getMessage(), e); - } - } - - private void performDropTable(String dBName, String tableName, boolean ifExists) throws DdlException { - if (!tableExist(dBName, tableName)) { - if (ifExists) { - LOG.info("drop table[{}] which does not exist", tableName); - return; - } else { - ErrorReport.reportDdlException(ErrorCode.ERR_UNKNOWN_TABLE, tableName, dBName); - } - } - try { - catalog.dropTable(Identifier.create(dBName, tableName), ifExists); - } catch (TableNotExistException e) { - throw new RuntimeException("table " + tableName + " does not exist"); - } - } - - @Override - public void afterDropTable(String dbName, String tblName) { - Optional> db = dorisCatalog.getDbForReplay(dbName); - db.ifPresent(externalDatabase -> externalDatabase.unregisterTable(tblName)); - LOG.info("after drop table {}.{}.{}. is db exists: {}", - dorisCatalog.getName(), dbName, tblName, db.isPresent()); - } - - @Override - public void truncateTableImpl(ExternalTable dorisTable, List partitions) throws DdlException { - throw new UnsupportedOperationException("truncate table is not a supported operation!"); - } - - @Override - public void createOrReplaceBranchImpl(ExternalTable dorisTable, CreateOrReplaceBranchInfo branchInfo) - throws UserException { - throw new UnsupportedOperationException("create or replace branch is not a supported operation!"); - } - - @Override - public void createOrReplaceTagImpl(ExternalTable dorisTable, CreateOrReplaceTagInfo tagInfo) throws UserException { - throw new UnsupportedOperationException("create or replace tag is not a supported operation!"); - } - - @Override - public void dropTagImpl(ExternalTable dorisTable, DropTagInfo tagInfo) throws UserException { - throw new UnsupportedOperationException("drop tag is not a supported operation!"); - } - - @Override - public void dropBranchImpl(ExternalTable dorisTable, DropBranchInfo branchInfo) throws UserException { - throw new UnsupportedOperationException("drop branch is not a supported operation!"); - } - - @Override - public List listDatabaseNames() { - try { - return executionAuthenticator.execute(() -> new ArrayList<>(catalog.listDatabases())); - } catch (Exception e) { - throw new RuntimeException("Failed to list databases names, catalog name: " + dorisCatalog.getName(), e); - } - } - - @Override - public List listTableNames(String db) { - try { - return executionAuthenticator.execute(() -> { - List tableNames = new ArrayList<>(); - try { - tableNames.addAll(catalog.listTables(db)); - } catch (DatabaseNotExistException e) { - LOG.warn("DatabaseNotExistException", e); - } - return tableNames; - }); - } catch (Exception e) { - throw new RuntimeException("Failed to list table names, catalog name: " + dorisCatalog.getName(), e); - } - } - - @Override - public boolean tableExist(String dbName, String tblName) { - try { - return executionAuthenticator.execute(() -> { - try { - catalog.getTable(Identifier.create(dbName, tblName)); - return true; - } catch (TableNotExistException e) { - return false; - } - }); - - } catch (Exception e) { - throw new RuntimeException("Failed to check table existence, catalog name: " + dorisCatalog.getName() - + "error message is:" + ExceptionUtils.getRootCauseMessage(e), e); - } - } - - @Override - public boolean databaseExist(String dbName) { - try { - return executionAuthenticator.execute(() -> { - try { - catalog.getDatabase(dbName); - return true; - } catch (DatabaseNotExistException e) { - return false; - } - }); - } catch (Exception e) { - throw new RuntimeException("Failed to check database exist, error message is:" + e.getMessage(), e); - } - } - - public Catalog getCatalog() { - return catalog; - } - - @Override - public void close() { - if (catalog != null) { - catalog = null; - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMvccSnapshot.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMvccSnapshot.java deleted file mode 100644 index 2307e91adb3911..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMvccSnapshot.java +++ /dev/null @@ -1,32 +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.datasource.paimon; - -import org.apache.doris.datasource.mvcc.MvccSnapshot; - -public class PaimonMvccSnapshot implements MvccSnapshot { - private final PaimonSnapshotCacheValue snapshotCacheValue; - - public PaimonMvccSnapshot(PaimonSnapshotCacheValue snapshotCacheValue) { - this.snapshotCacheValue = snapshotCacheValue; - } - - public PaimonSnapshotCacheValue getSnapshotCacheValue() { - return snapshotCacheValue; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonPartition.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonPartition.java deleted file mode 100644 index 545448199b3375..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonPartition.java +++ /dev/null @@ -1,61 +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.datasource.paimon; - -// https://paimon.apache.org/docs/0.9/maintenance/system-tables/#partitions-table -public class PaimonPartition { - // Partition values, for example: [1, dd] - private final String partitionValues; - // The amount of data in the partition - private final long recordCount; - // Partition file size - private final long fileSizeInBytes; - // Number of partition files - private final long fileCount; - // Last update time of partition - private final long lastUpdateTime; - - public PaimonPartition(String partitionValues, long recordCount, long fileSizeInBytes, long fileCount, - long lastUpdateTime) { - this.partitionValues = partitionValues; - this.recordCount = recordCount; - this.fileSizeInBytes = fileSizeInBytes; - this.fileCount = fileCount; - this.lastUpdateTime = lastUpdateTime; - } - - public String getPartitionValues() { - return partitionValues; - } - - public long getRecordCount() { - return recordCount; - } - - public long getFileSizeInBytes() { - return fileSizeInBytes; - } - - public long getFileCount() { - return fileCount; - } - - public long getLastUpdateTime() { - return lastUpdateTime; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonPartitionInfo.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonPartitionInfo.java deleted file mode 100644 index a6339ef5155e15..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonPartitionInfo.java +++ /dev/null @@ -1,56 +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.datasource.paimon; - -import org.apache.doris.catalog.PartitionItem; - -import com.google.common.collect.Maps; -import org.apache.paimon.partition.Partition; - -import java.util.Map; - -public class PaimonPartitionInfo { - public static final PaimonPartitionInfo EMPTY = new PaimonPartitionInfo(); - - private final Map nameToPartitionItem; - private final Map nameToPartition; - - private PaimonPartitionInfo() { - this.nameToPartitionItem = Maps.newHashMap(); - this.nameToPartition = Maps.newHashMap(); - } - - public PaimonPartitionInfo(Map nameToPartitionItem, - Map nameToPartition) { - this.nameToPartitionItem = nameToPartitionItem; - this.nameToPartition = nameToPartition; - } - - public Map getNameToPartitionItem() { - return nameToPartitionItem; - } - - public Map getNameToPartition() { - return nameToPartition; - } - - public boolean isPartitionInvalid() { - // when transfer to partitionItem failed, will not equal - return nameToPartitionItem.size() != nameToPartition.size(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonRestExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonRestExternalCatalog.java deleted file mode 100644 index 6360a61a00d7f6..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonRestExternalCatalog.java +++ /dev/null @@ -1,29 +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.datasource.paimon; - -import java.util.Map; - -@Deprecated -public class PaimonRestExternalCatalog extends PaimonExternalCatalog { - - public PaimonRestExternalCatalog(long catalogId, String name, String resource, - Map props, String comment) { - super(catalogId, name, resource, props, comment); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSchemaCacheKey.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSchemaCacheKey.java deleted file mode 100644 index 4eccb269c2fe56..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSchemaCacheKey.java +++ /dev/null @@ -1,56 +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.datasource.paimon; - -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.SchemaCacheKey; - -import com.google.common.base.Objects; - -public class PaimonSchemaCacheKey extends SchemaCacheKey { - private final long schemaId; - - public PaimonSchemaCacheKey(NameMapping nameMapping, long schemaId) { - super(nameMapping); - this.schemaId = schemaId; - } - - public long getSchemaId() { - return schemaId; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof PaimonSchemaCacheKey)) { - return false; - } - if (!super.equals(o)) { - return false; - } - PaimonSchemaCacheKey that = (PaimonSchemaCacheKey) o; - return schemaId == that.schemaId; - } - - @Override - public int hashCode() { - return Objects.hashCode(super.hashCode(), schemaId); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSchemaCacheValue.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSchemaCacheValue.java deleted file mode 100644 index e931b52336ba8f..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSchemaCacheValue.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.datasource.paimon; - -import org.apache.doris.catalog.Column; -import org.apache.doris.datasource.SchemaCacheValue; - -import org.apache.paimon.schema.TableSchema; - -import java.util.List; - -public class PaimonSchemaCacheValue extends SchemaCacheValue { - - private List partitionColumns; - - private TableSchema tableSchema; - // Caching TableSchema can reduce the reading of schema files and json parsing. - - public PaimonSchemaCacheValue(List schema, List partitionColumns, TableSchema tableSchema) { - super(schema); - this.partitionColumns = partitionColumns; - this.tableSchema = tableSchema; - } - - public List getPartitionColumns() { - return partitionColumns; - } - - public TableSchema getTableSchema() { - return tableSchema; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSnapshot.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSnapshot.java deleted file mode 100644 index 96f32370d999b7..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSnapshot.java +++ /dev/null @@ -1,45 +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.datasource.paimon; - -import org.apache.paimon.table.Table; - -public class PaimonSnapshot { - public static long INVALID_SNAPSHOT_ID = -1; - private final long snapshotId; - private final long schemaId; - private final Table table; - - public PaimonSnapshot(long snapshotId, long schemaId, Table table) { - this.snapshotId = snapshotId; - this.schemaId = schemaId; - this.table = table; - } - - public long getSnapshotId() { - return snapshotId; - } - - public long getSchemaId() { - return schemaId; - } - - public Table getTable() { - return table; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSnapshotCacheValue.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSnapshotCacheValue.java deleted file mode 100644 index c50ecdabfde3df..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSnapshotCacheValue.java +++ /dev/null @@ -1,37 +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.datasource.paimon; - -public class PaimonSnapshotCacheValue { - - private final PaimonPartitionInfo partitionInfo; - private final PaimonSnapshot snapshot; - - public PaimonSnapshotCacheValue(PaimonPartitionInfo partitionInfo, PaimonSnapshot snapshot) { - this.partitionInfo = partitionInfo; - this.snapshot = snapshot; - } - - public PaimonPartitionInfo getPartitionInfo() { - return partitionInfo; - } - - public PaimonSnapshot getSnapshot() { - return snapshot; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSysExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSysExternalTable.java deleted file mode 100644 index 744562d0b71027..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSysExternalTable.java +++ /dev/null @@ -1,277 +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.datasource.paimon; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.SchemaCacheKey; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.systable.SysTable; -import org.apache.doris.statistics.AnalysisInfo; -import org.apache.doris.statistics.BaseAnalysisTask; -import org.apache.doris.statistics.ExternalAnalysisTask; -import org.apache.doris.thrift.THiveTable; -import org.apache.doris.thrift.TTableDescriptor; -import org.apache.doris.thrift.TTableType; - -import com.google.common.collect.Lists; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.paimon.table.DataTable; -import org.apache.paimon.table.Table; -import org.apache.paimon.table.source.Split; -import org.apache.paimon.types.DataField; -import org.apache.paimon.types.DataTypeRoot; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -/** - * Represents a Paimon system table (e.g., snapshots, binlog, audit_log) that wraps a source data table. - * - *

    This class enables system tables to be queried using the native table execution path - * (FileQueryScanNode) instead of the TVF path (MetadataScanNode). This provides: - *

      - *
    • Unified execution path with regular tables
    • - *
    • Native vectorized reading for data-oriented system tables
    • - *
    • Better integration with query optimization
    • - *
    - * - *

    System tables are classified into two categories: - *

      - *
    • Data tables (e.g., binlog, audit_log, ro): Read actual ORC/Parquet data files
    • - *
    • Metadata tables (snapshots, partitions, etc.): Read metadata/manifest files
    • - *
    - */ -public class PaimonSysExternalTable extends ExternalTable { - - private static final Logger LOG = LogManager.getLogger(PaimonSysExternalTable.class); - - private final PaimonExternalTable sourceTable; - private final String sysTableType; - private volatile Boolean isDataTable; - private volatile Table paimonSysTable; - private volatile List fullSchema; - private volatile SchemaCacheValue schemaCacheValue; - - /** - * Creates a new Paimon system external table. - * - * @param sourceTable the underlying data table being wrapped - * @param sysTableType the type of system table (e.g., "snapshots", "binlog") - */ - public PaimonSysExternalTable(PaimonExternalTable sourceTable, String sysTableType) { - super(generateSysTableId(sourceTable.getId(), sysTableType), - sourceTable.getName() + "$" + sysTableType, - sourceTable.getRemoteName() + "$" + sysTableType, - (PaimonExternalCatalog) sourceTable.getCatalog(), - (PaimonExternalDatabase) sourceTable.getDatabase(), - TableIf.TableType.PAIMON_EXTERNAL_TABLE); - this.sourceTable = sourceTable; - this.sysTableType = sysTableType; - } - - @Override - public String getMetaCacheEngine() { - return PaimonExternalMetaCache.ENGINE; - } - - @Override - protected synchronized void makeSureInitialized() { - super.makeSureInitialized(); - if (!objectCreated) { - objectCreated = true; - } - } - - /** - * Generate a unique ID for the system table based on source table ID and system table type. - */ - private static long generateSysTableId(long sourceTableId, String sysTableType) { - // Use a simple hash combination to generate a unique ID - return sourceTableId ^ (sysTableType.hashCode() * 31L); - } - - /** - * Returns the Paimon system table instance (e.g., snapshots, binlog). - * Note: system tables currently ignore snapshot semantics. - */ - public Table getSysPaimonTable() { - if (paimonSysTable == null) { - synchronized (this) { - if (paimonSysTable == null) { - PaimonExternalCatalog catalog = (PaimonExternalCatalog) getCatalog(); - paimonSysTable = catalog.getPaimonTable( - sourceTable.getOrBuildNameMapping(), - "main", // branch - sysTableType // queryType: snapshots, binlog, etc. - ); - LOG.info("Created Paimon system table: {} for source table: {}", - sysTableType, sourceTable.getName()); - } - } - } - return paimonSysTable; - } - - /** - * Returns the schema of the system table. - * The schema is derived from the system table's rowType. - */ - @Override - public List getFullSchema() { - return getOrCreateSchemaCacheValue().getSchema(); - } - - public PaimonExternalTable getSourceTable() { - return sourceTable; - } - - @Override - public NameMapping getOrBuildNameMapping() { - return sourceTable.getOrBuildNameMapping(); - } - - public String getSysTableType() { - return sysTableType; - } - - public boolean isDataTable() { - return resolveIsDataTable(); - } - - private boolean resolveIsDataTable() { - if (isDataTable == null) { - synchronized (this) { - if (isDataTable == null) { - isDataTable = getSysPaimonTable() instanceof DataTable; - } - } - } - return isDataTable; - } - - @Override - public BaseAnalysisTask createAnalysisTask(AnalysisInfo info) { - makeSureInitialized(); - return new ExternalAnalysisTask(info); - } - - @Override - public TTableDescriptor toThrift() { - List schema = getFullSchema(); - String catalogType = sourceTable.getPaimonCatalogType(); - if (PaimonExternalCatalog.PAIMON_HMS.equals(catalogType) - || PaimonExternalCatalog.PAIMON_FILESYSTEM.equals(catalogType) - || PaimonExternalCatalog.PAIMON_DLF.equals(catalogType) - || PaimonExternalCatalog.PAIMON_REST.equals(catalogType) - || PaimonExternalCatalog.PAIMON_JDBC.equals(catalogType)) { - THiveTable tHiveTable = new THiveTable(dbName, name, new HashMap<>()); - TTableDescriptor tTableDescriptor = new TTableDescriptor(getId(), TTableType.HIVE_TABLE, schema.size(), 0, - getName(), dbName); - tTableDescriptor.setHiveTable(tHiveTable); - return tTableDescriptor; - } else { - throw new IllegalArgumentException( - "Currently only supports hms/dlf/rest/filesystem/jdbc catalog, do not support: " + catalogType); - } - } - - @Override - public long fetchRowCount() { - makeSureInitialized(); - long rowCount = 0; - List splits = getSysPaimonTable().newReadBuilder().newScan().plan().splits(); - for (Split split : splits) { - rowCount += split.rowCount(); - } - if (rowCount == 0) { - LOG.info("Paimon system table {} row count is 0, return -1", name); - } - return rowCount > 0 ? rowCount : UNKNOWN_ROW_COUNT; - } - - @Override - public Optional initSchema(SchemaCacheKey key) { - return Optional.of(getOrCreateSchemaCacheValue()); - } - - @Override - public Optional getSchemaCacheValue() { - return Optional.of(getOrCreateSchemaCacheValue()); - } - - @Override - public Map getSupportedSysTables() { - return sourceTable.getSupportedSysTables(); - } - - public Map getTableProperties() { - return sourceTable.getTableProperties(); - } - - @Override - public String getComment() { - return "Paimon system table: " + sysTableType + " for " + sourceTable.getName(); - } - - private SchemaCacheValue getOrCreateSchemaCacheValue() { - if (schemaCacheValue == null) { - synchronized (this) { - if (schemaCacheValue == null) { - if (fullSchema == null) { - fullSchema = buildFullSchema(); - } - schemaCacheValue = new SchemaCacheValue(fullSchema); - } - } - } - return schemaCacheValue; - } - - private List buildFullSchema() { - Table sysTable = getSysPaimonTable(); - List fields = sysTable.rowType().getFields(); - List columns = Lists.newArrayListWithCapacity(fields.size()); - - for (DataField field : fields) { - Column column = new Column( - field.name(), - PaimonUtil.paimonTypeToDorisType( - field.type(), - getCatalog().getEnableMappingVarbinary(), - getCatalog().getEnableMappingTimestampTz()), - true, - null, - true, - field.description(), - true, - field.id()); - PaimonUtil.updatePaimonColumnUniqueId(column, field); - if (field.type().getTypeRoot() == DataTypeRoot.TIMESTAMP_WITH_LOCAL_TIME_ZONE) { - column.setWithTZExtraInfo(); - } - columns.add(column); - } - return columns; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonTableCacheValue.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonTableCacheValue.java deleted file mode 100644 index 7539f28d770bf6..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonTableCacheValue.java +++ /dev/null @@ -1,44 +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.datasource.paimon; - -import com.google.common.base.Suppliers; -import org.apache.paimon.table.Table; - -import java.util.function.Supplier; - -/** - * Cache value for Paimon table metadata and its latest runtime snapshot projection. - */ -public class PaimonTableCacheValue { - private final Table paimonTable; - private final Supplier latestSnapshotCacheValue; - - public PaimonTableCacheValue(Table paimonTable, Supplier latestSnapshotCacheValue) { - this.paimonTable = paimonTable; - this.latestSnapshotCacheValue = Suppliers.memoize(latestSnapshotCacheValue::get); - } - - public Table getPaimonTable() { - return paimonTable; - } - - public PaimonSnapshotCacheValue getLatestSnapshotCacheValue() { - return latestSnapshotCacheValue.get(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonUtil.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonUtil.java deleted file mode 100644 index c96ffa6146cf2c..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonUtil.java +++ /dev/null @@ -1,710 +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.datasource.paimon; - -import org.apache.doris.analysis.PartitionValue; -import org.apache.doris.analysis.TableScanParams; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.ListPartitionItem; -import org.apache.doris.catalog.PartitionItem; -import org.apache.doris.catalog.PartitionKey; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.Type; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.UserException; -import org.apache.doris.common.util.TimeUtils; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.hive.HiveUtil; -import org.apache.doris.thrift.TColumnType; -import org.apache.doris.thrift.TPrimitiveType; -import org.apache.doris.thrift.schema.external.TArrayField; -import org.apache.doris.thrift.schema.external.TField; -import org.apache.doris.thrift.schema.external.TFieldPtr; -import org.apache.doris.thrift.schema.external.TMapField; -import org.apache.doris.thrift.schema.external.TNestedField; -import org.apache.doris.thrift.schema.external.TSchema; -import org.apache.doris.thrift.schema.external.TStructField; - -import com.google.common.base.Preconditions; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import org.apache.commons.collections4.CollectionUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.paimon.Snapshot; -import org.apache.paimon.data.BinaryRow; -import org.apache.paimon.data.InternalRow; -import org.apache.paimon.data.Timestamp; -import org.apache.paimon.data.serializer.InternalRowSerializer; -import org.apache.paimon.io.DataOutputViewStreamWrapper; -import org.apache.paimon.options.ConfigOption; -import org.apache.paimon.partition.Partition; -import org.apache.paimon.predicate.Predicate; -import org.apache.paimon.reader.RecordReader; -import org.apache.paimon.schema.TableSchema; -import org.apache.paimon.table.DataTable; -import org.apache.paimon.table.FileStoreTable; -import org.apache.paimon.table.SpecialFields; -import org.apache.paimon.table.Table; -import org.apache.paimon.table.source.DataSplit; -import org.apache.paimon.table.source.ReadBuilder; -import org.apache.paimon.tag.Tag; -import org.apache.paimon.types.ArrayType; -import org.apache.paimon.types.BinaryType; -import org.apache.paimon.types.CharType; -import org.apache.paimon.types.DataField; -import org.apache.paimon.types.DataType; -import org.apache.paimon.types.DecimalType; -import org.apache.paimon.types.MapType; -import org.apache.paimon.types.RowType; -import org.apache.paimon.types.VarBinaryType; -import org.apache.paimon.types.VarCharType; -import org.apache.paimon.utils.DateTimeUtils; -import org.apache.paimon.utils.InstantiationUtil; -import org.apache.paimon.utils.Pair; -import org.apache.paimon.utils.Projection; -import org.apache.paimon.utils.RowDataToObjectArrayConverter; - -import java.io.ByteArrayOutputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.time.DateTimeException; -import java.time.LocalDate; -import java.time.LocalTime; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Base64; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import javax.annotation.Nullable; - -public class PaimonUtil { - private static final Logger LOG = LogManager.getLogger(PaimonUtil.class); - private static final Base64.Encoder BASE64_ENCODER = java.util.Base64.getUrlEncoder().withoutPadding(); - private static final Pattern DIGITAL_REGEX = Pattern.compile("\\d+"); - private static final String SYS_TABLE_TYPE_AUDIT_LOG = "audit_log"; - private static final String SYS_TABLE_TYPE_BINLOG = "binlog"; - private static final String TABLE_READ_SEQUENCE_NUMBER_ENABLED = "table-read.sequence-number.enabled"; - private static final String PARTITION_LEGACY_NAME = "partition.legacy-name"; - - public static boolean isDigitalString(String value) { - return value != null && DIGITAL_REGEX.matcher(value).matches(); - } - - /** - * Extract the legacy partition name configuration from Paimon table options. - */ - public static boolean isLegacyPartitionName(Table paimonTable) { - return Boolean.parseBoolean( - paimonTable.options().getOrDefault(PARTITION_LEGACY_NAME, "true")); - } - - public static List read( - Table table, @Nullable int[] projection, @Nullable Predicate predicate, - Pair, String>... dynamicOptions) - throws IOException { - Map options = new HashMap<>(); - for (Pair, String> pair : dynamicOptions) { - options.put(pair.getKey().key(), pair.getValue()); - } - if (!options.isEmpty()) { - table = table.copy(options); - } - ReadBuilder readBuilder = table.newReadBuilder(); - if (projection != null) { - readBuilder.withProjection(projection); - } - if (predicate != null) { - readBuilder.withFilter(predicate); - } - RecordReader reader = - readBuilder.newRead().createReader(readBuilder.newScan().plan()); - InternalRowSerializer serializer = - new InternalRowSerializer( - projection == null - ? table.rowType() - : Projection.of(projection).project(table.rowType())); - List rows = new ArrayList<>(); - reader.forEachRemaining(row -> rows.add(serializer.copy(row))); - return rows; - } - - public static PaimonPartitionInfo generatePartitionInfo(List partitionColumns, - List paimonPartitions, boolean legacyPartitionName) { - - if (CollectionUtils.isEmpty(partitionColumns) || paimonPartitions.isEmpty()) { - return PaimonPartitionInfo.EMPTY; - } - - Map nameToPartitionItem = Maps.newHashMap(); - Map nameToPartition = Maps.newHashMap(); - PaimonPartitionInfo partitionInfo = new PaimonPartitionInfo(nameToPartitionItem, nameToPartition); - List types = partitionColumns.stream() - .map(Column::getType) - .collect(Collectors.toList()); - Map columnNameToType = partitionColumns.stream() - .collect(Collectors.toMap(Column::getName, Column::getType)); - - for (Partition partition : paimonPartitions) { - Map spec = partition.spec(); - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : spec.entrySet()) { - sb.append(entry.getKey()).append("="); - // When partition.legacy-name = true (default), Paimon stores DATE type as days since - // 1970-01-01 (epoch integer), so we need to convert the integer to a date string. - // When partition.legacy-name = false, the value is already a human read date string. - if (legacyPartitionName - && columnNameToType.getOrDefault(entry.getKey(), Type.NULL).isDateV2()) { - sb.append(DateTimeUtils.formatDate(Integer.parseInt(entry.getValue()))).append("/"); - } else { - sb.append(entry.getValue()).append("/"); - } - } - if (sb.length() > 0) { - sb.deleteCharAt(sb.length() - 1); - } - String partitionName = sb.toString(); - nameToPartition.put(partitionName, partition); - try { - // partition values return by paimon api, may have problem, - // to avoid affecting the query, we catch exceptions here - nameToPartitionItem.put(partitionName, toListPartitionItem(partitionName, types)); - } catch (Exception e) { - LOG.warn("toListPartitionItem failed, partitionColumns: {}, partitionValues: {}", - partitionColumns, partition.spec(), e); - } - } - return partitionInfo; - } - - public static ListPartitionItem toListPartitionItem(String partitionName, List types) - throws AnalysisException { - // Partition name will be in format: nation=cn/city=beijing - // parse it to get values "cn" and "beijing" - List partitionValues = HiveUtil.toPartitionValues(partitionName); - Preconditions.checkState(partitionValues.size() == types.size(), partitionName + " vs. " + types); - List values = Lists.newArrayListWithExpectedSize(types.size()); - for (String partitionValue : partitionValues) { - // null will in partition 'null' - // "null" will in partition 'null' - // NULL will in partition 'null' - // "NULL" will in partition 'NULL' - // values.add(new PartitionValue(partitionValue, "null".equals(partitionValue))); - values.add(new PartitionValue(partitionValue, false)); - } - PartitionKey key = PartitionKey.createListPartitionKeyWithTypes(values, types, true); - ListPartitionItem listPartitionItem = new ListPartitionItem(Lists.newArrayList(key)); - return listPartitionItem; - } - - private static Type paimonPrimitiveTypeToDorisType(org.apache.paimon.types.DataType dataType, - boolean enableVarbinaryMapping, boolean enableTimestampTzMapping) { - int tsScale = 3; // default - switch (dataType.getTypeRoot()) { - case BOOLEAN: - return Type.BOOLEAN; - case INTEGER: - return Type.INT; - case BIGINT: - return Type.BIGINT; - case FLOAT: - return Type.FLOAT; - case DOUBLE: - return Type.DOUBLE; - case SMALLINT: - return Type.SMALLINT; - case TINYINT: - return Type.TINYINT; - case VARCHAR: - int varcharLen = ((VarCharType) dataType).getLength(); - if (varcharLen > 65533) { - return ScalarType.createStringType(); - } - return ScalarType.createVarcharType(varcharLen); - case CHAR: - int charLen = ((CharType) dataType).getLength(); - if (charLen > 255) { - return ScalarType.createStringType(); - } - return ScalarType.createCharType(charLen); - case BINARY: - int binaryLen = ((BinaryType) dataType).getLength(); - return enableVarbinaryMapping ? ScalarType.createVarbinaryType(binaryLen) : Type.STRING; - case VARBINARY: - // Paimon VarBinaryType length is in [1, 2147483647] - int varbinaryLen = ((VarBinaryType) dataType).getLength(); - return enableVarbinaryMapping ? ScalarType.createVarbinaryType(varbinaryLen) : Type.STRING; - case DECIMAL: - DecimalType decimal = (DecimalType) dataType; - return ScalarType.createDecimalV3Type(decimal.getPrecision(), decimal.getScale()); - case DATE: - return ScalarType.createDateV2Type(); - case TIMESTAMP_WITHOUT_TIME_ZONE: - if (dataType instanceof org.apache.paimon.types.TimestampType) { - tsScale = ((org.apache.paimon.types.TimestampType) dataType).getPrecision(); - if (tsScale > 6) { - tsScale = 6; - } - } else if (dataType instanceof org.apache.paimon.types.LocalZonedTimestampType) { - tsScale = ((org.apache.paimon.types.LocalZonedTimestampType) dataType).getPrecision(); - if (tsScale > 6) { - tsScale = 6; - } - } - return ScalarType.createDatetimeV2Type(tsScale); - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - if (dataType instanceof org.apache.paimon.types.LocalZonedTimestampType) { - tsScale = ((org.apache.paimon.types.LocalZonedTimestampType) dataType).getPrecision(); - if (tsScale > 6) { - tsScale = 6; - } - } - if (enableTimestampTzMapping) { - return ScalarType.createTimeStampTzType(tsScale); - } - return ScalarType.createDatetimeV2Type(tsScale); - case ARRAY: - ArrayType arrayType = (ArrayType) dataType; - Type innerType = paimonPrimitiveTypeToDorisType(arrayType.getElementType(), enableVarbinaryMapping, - enableTimestampTzMapping); - return org.apache.doris.catalog.ArrayType.create(innerType, true); - case MAP: - MapType mapType = (MapType) dataType; - return new org.apache.doris.catalog.MapType( - paimonTypeToDorisType(mapType.getKeyType(), enableVarbinaryMapping, enableTimestampTzMapping), - paimonTypeToDorisType(mapType.getValueType(), enableVarbinaryMapping, - enableTimestampTzMapping)); - case ROW: - RowType rowType = (RowType) dataType; - List fields = rowType.getFields(); - return new org.apache.doris.catalog.StructType(fields.stream() - .map(field -> new org.apache.doris.catalog.StructField(field.name(), - paimonTypeToDorisType(field.type(), enableVarbinaryMapping, enableTimestampTzMapping))) - .collect(Collectors.toCollection(ArrayList::new))); - case TIME_WITHOUT_TIME_ZONE: - return Type.UNSUPPORTED; - default: - LOG.warn("Cannot transform unknown type: " + dataType.getTypeRoot()); - return Type.UNSUPPORTED; - } - } - - public static Type paimonTypeToDorisType(org.apache.paimon.types.DataType type, boolean enableVarbinaryMapping, - boolean enableTimestampTzMapping) { - return paimonPrimitiveTypeToDorisType(type, enableVarbinaryMapping, enableTimestampTzMapping); - } - - public static void updatePaimonColumnUniqueId(Column column, DataType dataType) { - List columns = column.getChildren(); - if (columns == null) { - return; - } - switch (dataType.getTypeRoot()) { - case ARRAY: - ArrayType arrayType = (ArrayType) dataType; - updatePaimonColumnUniqueId(columns.get(0), arrayType.getElementType()); - break; - case MAP: - MapType mapType = (MapType) dataType; - updatePaimonColumnUniqueId(columns.get(0), mapType.getKeyType()); - updatePaimonColumnUniqueId(columns.get(1), mapType.getValueType()); - break; - case ROW: - RowType rowType = (RowType) dataType; - for (int idx = 0; idx < columns.size(); idx++) { - updatePaimonColumnUniqueId(columns.get(idx), rowType.getFields().get(idx)); - } - break; - default: - return; - } - } - - public static void updatePaimonColumnUniqueId(Column column, DataField field) { - column.setUniqueId(field.id()); - updatePaimonColumnUniqueId(column, field.type()); - } - - public static TField getSchemaInfo(DataType dataType, boolean enableVarbinaryMapping, - boolean enableTimestampTzMapping) { - TField field = new TField(); - field.setIsOptional(dataType.isNullable()); - TNestedField nestedField = new TNestedField(); - switch (dataType.getTypeRoot()) { - case ARRAY: { - TArrayField listField = new TArrayField(); - org.apache.paimon.types.ArrayType paimonArrayType = (org.apache.paimon.types.ArrayType) dataType; - TFieldPtr fieldPtr = new TFieldPtr(); - fieldPtr.setFieldPtr(getSchemaInfo(paimonArrayType.getElementType(), enableVarbinaryMapping, - enableTimestampTzMapping)); - listField.setItemField(fieldPtr); - nestedField.setArrayField(listField); - field.setNestedField(nestedField); - - TColumnType tColumnType = new TColumnType(); - tColumnType.setType(TPrimitiveType.ARRAY); - field.setType(tColumnType); - break; - } - case MAP: { - TMapField mapField = new TMapField(); - org.apache.paimon.types.MapType mapType = (org.apache.paimon.types.MapType) dataType; - TFieldPtr keyField = new TFieldPtr(); - keyField.setFieldPtr( - getSchemaInfo(mapType.getKeyType(), enableVarbinaryMapping, enableTimestampTzMapping)); - mapField.setKeyField(keyField); - TFieldPtr valueField = new TFieldPtr(); - valueField.setFieldPtr( - getSchemaInfo(mapType.getValueType(), enableVarbinaryMapping, enableTimestampTzMapping)); - mapField.setValueField(valueField); - nestedField.setMapField(mapField); - field.setNestedField(nestedField); - - TColumnType tColumnType = new TColumnType(); - tColumnType.setType(TPrimitiveType.MAP); - field.setType(tColumnType); - break; - } - case ROW: { - RowType rowType = (RowType) dataType; - TStructField structField = getSchemaInfo(rowType.getFields(), enableVarbinaryMapping, - enableTimestampTzMapping); - nestedField.setStructField(structField); - field.setNestedField(nestedField); - - TColumnType tColumnType = new TColumnType(); - tColumnType.setType(TPrimitiveType.STRUCT); - field.setType(tColumnType); - break; - } - default: - field.setType(paimonPrimitiveTypeToDorisType(dataType, enableVarbinaryMapping, enableTimestampTzMapping) - .toColumnTypeThrift()); - break; - } - return field; - } - - public static TStructField getSchemaInfo(List paimonFields, boolean enableVarbinaryMapping, - boolean enableTimestampTzMapping) { - TStructField structField = new TStructField(); - for (DataField paimonField : paimonFields) { - TField childField = getSchemaInfo(paimonField.type(), enableVarbinaryMapping, enableTimestampTzMapping); - childField.setName(paimonField.name()); - childField.setId(paimonField.id()); - TFieldPtr fieldPtr = new TFieldPtr(); - fieldPtr.setFieldPtr(childField); - structField.addToFields(fieldPtr); - } - return structField; - } - - public static TSchema getSchemaInfo(TableSchema paimonTableSchema, boolean enableVarbinaryMapping, - boolean enableTimestampTzMapping) { - TSchema tSchema = new TSchema(); - tSchema.setSchemaId(paimonTableSchema.id()); - tSchema.setRootField( - getSchemaInfo(paimonTableSchema.fields(), enableVarbinaryMapping, enableTimestampTzMapping)); - return tSchema; - } - - public static TSchema getHistorySchemaInfo(ExternalTable targetTable, TableSchema sourceSchema, - boolean enableVarbinaryMapping, boolean enableTimestampTzMapping) { - TSchema tSchema = new TSchema(); - tSchema.setSchemaId(sourceSchema.id()); - tSchema.setRootField(getSchemaInfo(resolveHistorySchemaFields(targetTable, sourceSchema.fields()), - enableVarbinaryMapping, enableTimestampTzMapping)); - return tSchema; - } - - private static List resolveHistorySchemaFields(ExternalTable targetTable, List sourceFields) { - if (!(targetTable instanceof PaimonSysExternalTable)) { - return sourceFields; - } - - PaimonSysExternalTable sysTable = (PaimonSysExternalTable) targetTable; - boolean withSequenceNumber = isTableReadSequenceNumberEnabled(sysTable); - switch (sysTable.getSysTableType()) { - case SYS_TABLE_TYPE_AUDIT_LOG: - return buildAuditLogHistoryFields(sourceFields, withSequenceNumber); - case SYS_TABLE_TYPE_BINLOG: - return buildBinlogHistoryFields(sourceFields, withSequenceNumber); - default: - return sourceFields; - } - } - - private static List buildAuditLogHistoryFields(List sourceFields, - boolean withSequenceNumber) { - List fields = new ArrayList<>(sourceFields.size() + (withSequenceNumber ? 2 : 1)); - fields.add(SpecialFields.ROW_KIND); - if (withSequenceNumber) { - fields.add(SpecialFields.SEQUENCE_NUMBER); - } - fields.addAll(sourceFields); - return fields; - } - - private static List buildBinlogHistoryFields(List sourceFields, - boolean withSequenceNumber) { - List fields = new ArrayList<>(sourceFields.size() + (withSequenceNumber ? 2 : 1)); - fields.add(SpecialFields.ROW_KIND); - if (withSequenceNumber) { - fields.add(SpecialFields.SEQUENCE_NUMBER); - } - for (DataField sourceField : sourceFields) { - fields.add(sourceField.newType(new ArrayType(sourceField.type().nullable()))); - } - return fields; - } - - private static boolean isTableReadSequenceNumberEnabled(PaimonSysExternalTable sysTable) { - if (!SYS_TABLE_TYPE_AUDIT_LOG.equals(sysTable.getSysTableType()) - && !SYS_TABLE_TYPE_BINLOG.equals(sysTable.getSysTableType())) { - return false; - } - try { - String optionValue = sysTable.getTableProperties().get(TABLE_READ_SEQUENCE_NUMBER_ENABLED); - return Boolean.parseBoolean(optionValue); - } catch (Exception e) { - LOG.warn("Failed to parse table-read.sequence-number.enabled for Paimon system table {}: {}", - sysTable.getName(), e.getMessage()); - return false; - } - } - - public static List parseSchema(Table table, boolean enableVarbinaryMapping, - boolean enableTimestampTzMapping) { - List primaryKeys = table.primaryKeys(); - return parseSchema(table.rowType(), primaryKeys, enableVarbinaryMapping, enableTimestampTzMapping); - } - - public static List parseSchema(RowType rowType, List primaryKeys, boolean enableVarbinaryMapping, - boolean enableTimestampTzMapping) { - List resSchema = Lists.newArrayListWithCapacity(rowType.getFields().size()); - rowType.getFields().forEach(field -> { - resSchema.add(new Column(field.name(), - PaimonUtil.paimonTypeToDorisType(field.type(), enableVarbinaryMapping, enableTimestampTzMapping), - primaryKeys.contains(field.name()), - null, - field.type().isNullable(), - field.description(), - true, - field.id())); - }); - return resSchema; - } - - public static String encodeObjectToString(T t) { - try { - byte[] bytes = InstantiationUtil.serializeObject(t); - return new String(BASE64_ENCODER.encode(bytes), java.nio.charset.StandardCharsets.UTF_8); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - /** - * Serialize DataSplit using Paimon's native binary format. - * This format is compatible with paimon-cpp reader. - * Uses standard Base64 encoding (not URL-safe) for BE compatibility. - */ - public static String encodeDataSplitToString(DataSplit split) { - try { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - DataOutputViewStreamWrapper out = new DataOutputViewStreamWrapper(baos); - split.serialize(out); - byte[] bytes = baos.toByteArray(); - return Base64.getEncoder().encodeToString(bytes); - } catch (IOException e) { - throw new RuntimeException("Failed to serialize DataSplit using Paimon native format", e); - } - } - - public static Map getPartitionInfoMap(Table table, BinaryRow partitionValues, String timeZone) { - Map partitionInfoMap = new HashMap<>(); - List partitionKeys = table.partitionKeys(); - RowType partitionType = table.rowType().project(partitionKeys); - RowDataToObjectArrayConverter toObjectArrayConverter = new RowDataToObjectArrayConverter( - partitionType); - Object[] partitionValuesArray = toObjectArrayConverter.convert(partitionValues); - for (int i = 0; i < partitionKeys.size(); i++) { - try { - String partitionValue = serializePartitionValue(partitionType.getFields().get(i).type(), - partitionValuesArray[i], timeZone); - partitionInfoMap.put(partitionKeys.get(i), partitionValue); - } catch (UnsupportedOperationException e) { - LOG.warn("Failed to serialize table {} partition value for key {}: {}", table.name(), - partitionKeys.get(i), e.getMessage()); - return null; - } - } - return partitionInfoMap; - } - - private static String serializePartitionValue(org.apache.paimon.types.DataType type, Object value, - String timeZone) { - switch (type.getTypeRoot()) { - case BOOLEAN: - case INTEGER: - case BIGINT: - case SMALLINT: - case TINYINT: - case DECIMAL: - case VARCHAR: - case CHAR: - if (value == null) { - return null; - } - return value.toString(); - case FLOAT: - if (value == null) { - return null; - } - return Float.toString((Float) value); - case DOUBLE: - if (value == null) { - return null; - } - return Double.toString((Double) value); - // case binary: - // case varbinary: should not supported, because if return string with utf8, - // the data maybe be corrupted - case DATE: - if (value == null) { - return null; - } - // Paimon date is stored as days since epoch - LocalDate date = LocalDate.ofEpochDay((Integer) value); - return date.format(DateTimeFormatter.ISO_LOCAL_DATE); - case TIME_WITHOUT_TIME_ZONE: - if (value == null) { - return null; - } - // Paimon time is stored as microseconds since midnight in utc - long micros = (Long) value; - LocalTime time = LocalTime.ofNanoOfDay(micros * 1000); - return time.format(DateTimeFormatter.ISO_LOCAL_TIME); - case TIMESTAMP_WITHOUT_TIME_ZONE: - if (value == null) { - return null; - } - // Paimon timestamp is stored as Timestamp type in utc - return ((Timestamp) value).toLocalDateTime().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - if (value == null) { - return null; - } - // Paimon timestamp with local time zone is stored as Timestamp type in utc - Timestamp timestamp = (Timestamp) value; - return timestamp.toLocalDateTime() - .atZone(ZoneId.of("UTC")) - .withZoneSameInstant(ZoneId.of(timeZone)) - .toLocalDateTime() - .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); - default: - throw new UnsupportedOperationException("Unsupported type for serializePartitionValue: " + type); - } - } - - /** - * Extracts the reference name (branch or tag name) from table scan parameters. - * - * @param scanParams the scan parameters containing reference name information - * @return the extracted reference name - * @throws IllegalArgumentException if the reference name is not properly specified - */ - public static String extractBranchOrTagName(TableScanParams scanParams) { - if (!scanParams.getMapParams().isEmpty()) { - if (!scanParams.getMapParams().containsKey(TableScanParams.PARAMS_NAME)) { - throw new IllegalArgumentException("must contain key 'name' in params"); - } - return scanParams.getMapParams().get(TableScanParams.PARAMS_NAME); - } else { - if (scanParams.getListParams().isEmpty() || scanParams.getListParams().get(0) == null) { - throw new IllegalArgumentException("must contain a branch/tag name in params"); - } - return scanParams.getListParams().get(0); - } - } - - static Snapshot getPaimonSnapshotByTimestamp(DataTable table, String timestamp, boolean isDigital) - throws UserException { - long timestampMillis = 0; - if (isDigital) { - timestampMillis = Long.parseLong(timestamp); - } else { - // Supported formats include:yyyy-MM-dd, yyyy-MM-dd HH:mm:ss, yyyy-MM-dd HH:mm:ss.SSS. - // use default local time zone. - timestampMillis = DateTimeUtils.parseTimestampData(timestamp, 3, TimeUtils.getTimeZone()).getMillisecond(); - if (timestampMillis < 0) { - throw new DateTimeException("can't parse time: " + timestamp); - } - } - Snapshot snapshot = table.snapshotManager().earlierOrEqualTimeMills(timestampMillis); - if (snapshot == null) { - Snapshot earliestSnapshot = table.snapshotManager().earliestSnapshot(); - throw new UserException( - String.format( - "There is currently no snapshot earlier than or equal to timestamp [%s], " - + "the earliest snapshot's timestamp is [%s]", - timestampMillis, - earliestSnapshot == null - ? "null" - : String.valueOf(earliestSnapshot.timeMillis()))); - } - return snapshot; - } - - static Snapshot getPaimonSnapshotBySnapshotId(DataTable table, String snapshotString) - throws UserException { - long snapshotId = Long.parseLong(snapshotString); - try { - Snapshot snapshot = table.snapshotManager().tryGetSnapshot(snapshotId); - return snapshot; - } catch (FileNotFoundException e) { - throw new UserException("can't find snapshot by id: " + snapshotId, e); - } - } - - static Snapshot getPaimonSnapshotByTag(DataTable table, String tagName) - throws UserException { - Optional tag = table.tagManager().get(tagName); - return tag.orElseThrow(() -> new UserException("can't find snapshot by tag: " + tagName)); - } - - - public static String resolvePaimonBranch(TableScanParams tableScanParams, Table baseTable) - throws UserException { - String branchName = extractBranchOrTagName(tableScanParams); - if (!(baseTable instanceof FileStoreTable)) { - throw new UserException("Table type should be FileStoreTable but got: " + baseTable.getClass().getName()); - } - - final FileStoreTable fileStoreTable = (FileStoreTable) baseTable; - if (!fileStoreTable.branchManager().branchExists(branchName)) { - throw new UserException("can't find branch: " + branchName); - } - return branchName; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonUtils.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonUtils.java deleted file mode 100644 index dc28c083ca10fa..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonUtils.java +++ /dev/null @@ -1,59 +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.datasource.paimon; - -import org.apache.doris.catalog.Env; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.mvcc.MvccSnapshot; - -import org.apache.paimon.table.Table; - -import java.util.Optional; - -public class PaimonUtils { - - public static Table getPaimonTable(ExternalTable dorisTable) { - return paimonExternalMetaCache(dorisTable).getPaimonTable(dorisTable); - } - - public static PaimonSnapshotCacheValue getLatestSnapshotCacheValue(ExternalTable dorisTable) { - return paimonExternalMetaCache(dorisTable).getSnapshotCache(dorisTable); - } - - public static PaimonSnapshotCacheValue getSnapshotCacheValue(Optional snapshot, - ExternalTable dorisTable) { - if (snapshot.isPresent() && snapshot.get() instanceof PaimonMvccSnapshot) { - return ((PaimonMvccSnapshot) snapshot.get()).getSnapshotCacheValue(); - } - return getLatestSnapshotCacheValue(dorisTable); - } - - public static PaimonSchemaCacheValue getSchemaCacheValue(ExternalTable dorisTable, - PaimonSnapshotCacheValue snapshotValue) { - return getSchemaCacheValue(dorisTable, snapshotValue.getSnapshot().getSchemaId()); - } - - public static PaimonSchemaCacheValue getSchemaCacheValue(ExternalTable dorisTable, long schemaId) { - return paimonExternalMetaCache(dorisTable) - .getPaimonSchemaCacheValue(dorisTable.getOrBuildNameMapping(), schemaId); - } - - private static PaimonExternalMetaCache paimonExternalMetaCache(ExternalTable table) { - return Env.getCurrentEnv().getExtMetaCacheMgr().paimon(table.getCatalog().getId()); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonVendedCredentialsProvider.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonVendedCredentialsProvider.java deleted file mode 100644 index 0ea91a375c0aa9..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonVendedCredentialsProvider.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.datasource.paimon; - -import org.apache.doris.datasource.credentials.AbstractVendedCredentialsProvider; -import org.apache.doris.datasource.property.metastore.MetastoreProperties; -import org.apache.doris.datasource.property.metastore.PaimonRestMetaStoreProperties; - -import com.google.common.collect.Maps; -import org.apache.paimon.rest.RESTToken; -import org.apache.paimon.rest.RESTTokenFileIO; -import org.apache.paimon.table.Table; - -import java.util.Map; - -public class PaimonVendedCredentialsProvider extends AbstractVendedCredentialsProvider { - private static final PaimonVendedCredentialsProvider INSTANCE = new PaimonVendedCredentialsProvider(); - - private PaimonVendedCredentialsProvider() { - // Singleton pattern - } - - public static PaimonVendedCredentialsProvider getInstance() { - return INSTANCE; - } - - @Override - public boolean isVendedCredentialsEnabled(MetastoreProperties metastoreProperties) { - // Paimon REST catalog always supports vended credentials if it's REST type - return metastoreProperties instanceof PaimonRestMetaStoreProperties; - } - - @Override - protected Map extractRawVendedCredentials(T tableObject) { - if (!(tableObject instanceof Table)) { - return Maps.newHashMap(); - } - - Table table = (Table) tableObject; - if (table.fileIO() == null || !(table.fileIO() instanceof RESTTokenFileIO)) { - return Maps.newHashMap(); - } - - RESTTokenFileIO restTokenFileIO = (RESTTokenFileIO) table.fileIO(); - RESTToken restToken = restTokenFileIO.validToken(); - Map tokens = restToken.token(); - - // Convert the original token to OSS format properties, let StorageProperties.createAll() further convert - Map rawProperties = Maps.newHashMap(); - rawProperties.putAll(tokens); - - return rawProperties; - } - - @Override - protected String getTableName(T tableObject) { - if (tableObject instanceof Table) { - return ((Table) tableObject).name(); - } - return super.getTableName(tableObject); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/profile/PaimonScanMetricsReporter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/profile/PaimonScanMetricsReporter.java deleted file mode 100644 index b76cf74dfda8e5..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/profile/PaimonScanMetricsReporter.java +++ /dev/null @@ -1,152 +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.datasource.paimon.profile; - -import org.apache.doris.catalog.TableIf; -import org.apache.doris.common.profile.RuntimeProfile; -import org.apache.doris.common.profile.SummaryProfile; -import org.apache.doris.common.util.DebugUtil; -import org.apache.doris.qe.ConnectContext; - -import org.apache.paimon.metrics.Counter; -import org.apache.paimon.metrics.Gauge; -import org.apache.paimon.metrics.Histogram; -import org.apache.paimon.metrics.HistogramStatistics; -import org.apache.paimon.metrics.Metric; -import org.apache.paimon.metrics.MetricGroup; -import org.apache.paimon.operation.metrics.ScanMetrics; - -import java.util.Map; -import java.util.concurrent.TimeUnit; - -public class PaimonScanMetricsReporter { - private static final double P95 = 0.95d; - - public static void report(TableIf table, String paimonTableName, PaimonMetricRegistry registry) { - if (registry == null || paimonTableName == null) { - return; - } - String resolvedTableName = paimonTableName; - MetricGroup group = registry.getGroup(ScanMetrics.GROUP_NAME, paimonTableName); - if (group == null) { - String prefix = ScanMetrics.GROUP_NAME + ":"; - for (Map.Entry entry : registry.getAllGroupsAsMap().entrySet()) { - String key = entry.getKey(); - if (!key.startsWith(prefix)) { - continue; - } - if (group != null) { - group = null; - break; - } - group = entry.getValue(); - resolvedTableName = key.substring(prefix.length()); - } - } - if (group == null) { - return; - } - Map metrics = group.getMetrics(); - if (metrics == null || metrics.isEmpty()) { - return; - } - - SummaryProfile summaryProfile = SummaryProfile.getSummaryProfile(ConnectContext.get()); - if (summaryProfile == null) { - return; - } - RuntimeProfile executionSummary = summaryProfile.getExecutionSummary(); - if (executionSummary == null) { - return; - } - - RuntimeProfile paimonGroup = executionSummary.getChildMap().get(SummaryProfile.PAIMON_SCAN_METRICS); - if (paimonGroup == null) { - paimonGroup = new RuntimeProfile(SummaryProfile.PAIMON_SCAN_METRICS); - executionSummary.addChild(paimonGroup, true); - } - - String displayName = table == null ? paimonTableName : table.getNameWithFullQualifiers(); - RuntimeProfile scanProfile = new RuntimeProfile("Table Scan (" + displayName + ")"); - appendDuration(scanProfile, metrics, ScanMetrics.LAST_SCAN_DURATION, "last_scan_duration"); - appendHistogram(scanProfile, metrics, ScanMetrics.SCAN_DURATION, "scan_duration"); - appendCounter(scanProfile, metrics, ScanMetrics.LAST_SCANNED_MANIFESTS, "last_scanned_manifests"); - appendCounter(scanProfile, metrics, ScanMetrics.LAST_SCAN_SKIPPED_TABLE_FILES, - "last_scan_skipped_table_files"); - appendCounter(scanProfile, metrics, ScanMetrics.LAST_SCAN_RESULTED_TABLE_FILES, - "last_scan_resulted_table_files"); - appendCounter(scanProfile, metrics, ScanMetrics.MANIFEST_HIT_CACHE, "manifest_hit_cache"); - appendCounter(scanProfile, metrics, ScanMetrics.MANIFEST_MISSED_CACHE, "manifest_missed_cache"); - paimonGroup.addChild(scanProfile, true); - registry.removeGroup(ScanMetrics.GROUP_NAME, resolvedTableName); - } - - private static void appendDuration(RuntimeProfile profile, Map metrics, String metricKey, - String profileKey) { - Long value = getLongValue(metrics.get(metricKey)); - if (value == null) { - return; - } - profile.addInfoString(profileKey, formatDuration(value)); - } - - private static void appendCounter(RuntimeProfile profile, Map metrics, String metricKey, - String profileKey) { - Long value = getLongValue(metrics.get(metricKey)); - if (value == null) { - return; - } - profile.addInfoString(profileKey, Long.toString(value)); - } - - private static void appendHistogram(RuntimeProfile profile, Map metrics, String metricKey, - String profileKey) { - Metric metric = metrics.get(metricKey); - if (!(metric instanceof Histogram)) { - return; - } - Histogram histogram = (Histogram) metric; - HistogramStatistics stats = histogram.getStatistics(); - if (stats == null) { - return; - } - String formatted = "count=" + histogram.getCount() - + ", mean=" + formatDuration(stats.getMean()) - + ", p95=" + formatDuration(stats.getQuantile(P95)) - + ", max=" + formatDuration(stats.getMax()); - profile.addInfoString(profileKey, formatted); - } - - private static Long getLongValue(Metric metric) { - if (metric instanceof Counter) { - return ((Counter) metric).getCount(); - } - if (metric instanceof Gauge) { - Object value = ((Gauge) metric).getValue(); - if (value instanceof Number) { - return ((Number) value).longValue(); - } - } - return null; - } - - private static String formatDuration(double nanos) { - long ms = TimeUnit.NANOSECONDS.toMillis(Math.round(nanos)); - return DebugUtil.getPrettyStringMs(ms); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonPredicateConverter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonPredicateConverter.java deleted file mode 100644 index 867225fdf8b120..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonPredicateConverter.java +++ /dev/null @@ -1,210 +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.datasource.paimon.source; - -import org.apache.doris.analysis.BinaryPredicate; -import org.apache.doris.analysis.CastExpr; -import org.apache.doris.analysis.CompoundPredicate; -import org.apache.doris.analysis.Expr; -import org.apache.doris.analysis.ExprToExprNameVisitor; -import org.apache.doris.analysis.FunctionCallExpr; -import org.apache.doris.analysis.InPredicate; -import org.apache.doris.analysis.IsNullPredicate; -import org.apache.doris.analysis.LiteralExpr; -import org.apache.doris.analysis.SlotRef; - -import org.apache.paimon.data.BinaryString; -import org.apache.paimon.predicate.Predicate; -import org.apache.paimon.predicate.PredicateBuilder; -import org.apache.paimon.types.DataField; -import org.apache.paimon.types.DataType; -import org.apache.paimon.types.RowType; - -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - - -public class PaimonPredicateConverter { - private final PredicateBuilder builder; - private final List fieldNames; - private final List paimonFieldTypes; - - public PaimonPredicateConverter(RowType rowType) { - this.builder = new PredicateBuilder(rowType); - this.fieldNames = rowType.getFields().stream().map(DataField::name).collect(Collectors.toList()); - this.paimonFieldTypes = rowType.getFields().stream().map(DataField::type).collect(Collectors.toList()); - } - - public List convertToPaimonExpr(List conjuncts) { - List list = new ArrayList<>(conjuncts.size()); - for (Expr conjunct : conjuncts) { - Predicate predicate = convertToPaimonExpr(conjunct); - if (predicate != null) { - list.add(predicate); - } - } - return list; - } - - private Predicate convertToPaimonExpr(Expr dorisExpr) { - if (dorisExpr == null) { - return null; - } - if (dorisExpr instanceof CompoundPredicate) { - CompoundPredicate compoundPredicate = (CompoundPredicate) dorisExpr; - Predicate left = convertToPaimonExpr(compoundPredicate.getChild(0)); - Predicate right = convertToPaimonExpr(compoundPredicate.getChild(1)); - - switch (compoundPredicate.getOp()) { - case AND: { - if (left != null && right != null) { - return PredicateBuilder.and(left, right); - } - return null; - } - case OR: { - if (left != null && right != null) { - return PredicateBuilder.or(left, right); - } - return null; - } - default: - return null; - } - } else if (dorisExpr instanceof InPredicate) { - return doInPredicate((InPredicate) dorisExpr); - } else { - return binaryExprDesc(dorisExpr); - } - } - - private Predicate doInPredicate(InPredicate predicate) { - SlotRef slotRef = convertDorisExprToSlotRef(predicate.getChild(0)); - if (slotRef == null) { - return null; - } - String colName = slotRef.getColumnName(); - int idx = getFieldIndex(colName); - DataType dataType = paimonFieldTypes.get(idx); - List valueList = new ArrayList<>(); - for (int i = 1; i < predicate.getChildren().size(); i++) { - if (!(predicate.getChild(i) instanceof LiteralExpr)) { - return null; - } - LiteralExpr literalExpr = convertDorisExprToLiteralExpr(predicate.getChild(i)); - Object value = dataType.accept(new PaimonValueConverter(literalExpr)); - if (value == null) { - return null; - } - valueList.add(value); - } - - if (predicate.isNotIn()) { - // not in - return builder.notIn(idx, valueList); - } else { - // in - return builder.in(idx, valueList); - } - } - - private Predicate binaryExprDesc(Expr dorisExpr) { - // Make sure the col slot is always first - SlotRef slotRef = convertDorisExprToSlotRef(dorisExpr.getChild(0)); - LiteralExpr literalExpr = convertDorisExprToLiteralExpr(dorisExpr.getChild(1)); - if (slotRef == null || literalExpr == null) { - return null; - } - String colName = slotRef.getColumnName(); - int idx = getFieldIndex(colName); - DataType dataType = paimonFieldTypes.get(idx); - Object value = dataType.accept(new PaimonValueConverter(literalExpr)); - if (value == null) { - return null; - } - if (dorisExpr instanceof BinaryPredicate) { - BinaryPredicate.Operator op = ((BinaryPredicate) dorisExpr).getOp(); - switch (op) { - case EQ: - return builder.equal(idx, value); - case EQ_FOR_NULL: - return builder.isNull(idx); - case NE: - return builder.notEqual(idx, value); - case GE: - return builder.greaterOrEqual(idx, value); - case GT: - return builder.greaterThan(idx, value); - case LE: - return builder.lessOrEqual(idx, value); - case LT: - return builder.lessThan(idx, value); - default: - return null; - } - } else if (dorisExpr instanceof FunctionCallExpr) { - String name = dorisExpr.accept(ExprToExprNameVisitor.INSTANCE, null).toLowerCase(); - String s = value.toString(); - if (name.equals("like") && !s.startsWith("%") && s.endsWith("%")) { - return builder.startsWith(idx, BinaryString.fromString(s.substring(0, s.length() - 1))); - } - } else if (dorisExpr instanceof IsNullPredicate) { - if (((IsNullPredicate) dorisExpr).isNotNull()) { - return builder.isNotNull(idx); - } else { - return builder.isNull(idx); - } - } - return null; - } - - private int getFieldIndex(String colName) { - for (int i = 0; i < fieldNames.size(); i++) { - if (fieldNames.get(i).equalsIgnoreCase(colName)) { - return i; - } - } - return fieldNames.indexOf(colName); - } - - - public static SlotRef convertDorisExprToSlotRef(Expr expr) { - SlotRef slotRef = null; - if (expr instanceof SlotRef) { - slotRef = (SlotRef) expr; - } else if (expr instanceof CastExpr) { - if (expr.getChild(0) instanceof SlotRef) { - slotRef = (SlotRef) expr.getChild(0); - } - } - return slotRef; - } - - public LiteralExpr convertDorisExprToLiteralExpr(Expr expr) { - LiteralExpr literalExpr = null; - if (expr instanceof LiteralExpr) { - literalExpr = (LiteralExpr) expr; - } else if (expr instanceof CastExpr) { - if (expr.getChild(0) instanceof LiteralExpr) { - literalExpr = (LiteralExpr) expr.getChild(0); - } - } - return literalExpr; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonScanNode.java deleted file mode 100644 index 990e79923f0a93..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonScanNode.java +++ /dev/null @@ -1,931 +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.datasource.paimon.source; - -import org.apache.doris.analysis.TableScanParams; -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.MetaNotFoundException; -import org.apache.doris.common.UserException; -import org.apache.doris.common.util.FileFormatUtils; -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.ExternalUtil; -import org.apache.doris.datasource.FileQueryScanNode; -import org.apache.doris.datasource.credentials.CredentialUtils; -import org.apache.doris.datasource.credentials.VendedCredentialsFactory; -import org.apache.doris.datasource.paimon.PaimonExternalCatalog; -import org.apache.doris.datasource.paimon.PaimonSysExternalTable; -import org.apache.doris.datasource.paimon.PaimonUtil; -import org.apache.doris.datasource.paimon.PaimonUtils; -import org.apache.doris.datasource.paimon.profile.PaimonMetricRegistry; -import org.apache.doris.datasource.paimon.profile.PaimonScanMetricsReporter; -import org.apache.doris.datasource.property.metastore.PaimonJdbcMetaStoreProperties; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.spi.Split; -import org.apache.doris.thrift.TExplainLevel; -import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.TFileRangeDesc; -import org.apache.doris.thrift.TPaimonDeletionFileDesc; -import org.apache.doris.thrift.TPaimonFileDesc; -import org.apache.doris.thrift.TPaimonReaderType; -import org.apache.doris.thrift.TTableFormatFileDesc; - -import com.google.common.annotations.VisibleForTesting; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.paimon.data.BinaryRow; -import org.apache.paimon.predicate.Predicate; -import org.apache.paimon.schema.TableSchema; -import org.apache.paimon.table.Table; -import org.apache.paimon.table.source.DataSplit; -import org.apache.paimon.table.source.DeletionFile; -import org.apache.paimon.table.source.InnerTableScan; -import org.apache.paimon.table.source.RawFile; -import org.apache.paimon.table.source.ReadBuilder; -import org.apache.paimon.table.source.TableScan; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; - -public class PaimonScanNode extends FileQueryScanNode { - private static final Logger LOG = LogManager.getLogger(PaimonScanNode.class); - - private static final long COUNT_WITH_PARALLEL_SPLITS = 10000; - // The keys of incremental read params for Paimon SDK - private static final String PAIMON_SCAN_SNAPSHOT_ID = "scan.snapshot-id"; - private static final String PAIMON_SCAN_MODE = "scan.mode"; - private static final String PAIMON_INCREMENTAL_BETWEEN = "incremental-between"; - private static final String PAIMON_INCREMENTAL_BETWEEN_SCAN_MODE = "incremental-between-scan-mode"; - private static final String PAIMON_INCREMENTAL_BETWEEN_TIMESTAMP = "incremental-between-timestamp"; - // The keys of incremental read params for Doris Statement - private static final String DORIS_START_SNAPSHOT_ID = "startSnapshotId"; - private static final String DORIS_END_SNAPSHOT_ID = "endSnapshotId"; - private static final String DORIS_START_TIMESTAMP = "startTimestamp"; - private static final String DORIS_END_TIMESTAMP = "endTimestamp"; - private static final String DORIS_INCREMENTAL_BETWEEN_SCAN_MODE = "incrementalBetweenScanMode"; - private static final String PAIMON_PROPERTY_PREFIX = "paimon."; - private static final String DORIS_ENABLE_FILE_READER_ASYNC = "jni.enable_file_reader_async"; - private static final String DORIS_ENABLE_JNI_IO_MANAGER = "doris.enable_jni_io_manager"; - private static final String DORIS_JNI_IO_MANAGER_TMP_DIR = "doris.jni_io_manager.tmp_dir"; - private static final String DORIS_JNI_IO_MANAGER_IMPL_CLASS = "doris.jni_io_manager.impl_class"; - private static final List BACKEND_PAIMON_OPTIONS = Arrays.asList( - DORIS_ENABLE_JNI_IO_MANAGER, - DORIS_JNI_IO_MANAGER_TMP_DIR, - DORIS_JNI_IO_MANAGER_IMPL_CLASS, - DORIS_ENABLE_FILE_READER_ASYNC); - private static final String PAIMON_BINLOG_SYSTEM_TABLE_TYPE = "binlog"; - private static final String PAIMON_AUDIT_LOG_SYSTEM_TABLE_TYPE = "audit_log"; - - private enum SplitReadType { - JNI, - NATIVE, - } - - private class SplitStat { - SplitReadType type = SplitReadType.JNI; - private long rowCount = 0; - private Optional mergedRowCount = Optional.empty(); - private boolean rawFileConvertable = false; - private boolean hasDeletionVector = false; - - public void setType(SplitReadType type) { - this.type = type; - } - - public void setRowCount(long rowCount) { - this.rowCount = rowCount; - } - - public void setMergedRowCount(long mergedRowCount) { - this.mergedRowCount = Optional.of(mergedRowCount); - } - - public void setRawFileConvertable(boolean rawFileConvertable) { - this.rawFileConvertable = rawFileConvertable; - } - - public void setHasDeletionVector(boolean hasDeletionVector) { - this.hasDeletionVector = hasDeletionVector; - } - - @Override - public String toString() { - return "SplitStat [type=" + type - + ", rowCount=" + rowCount - + ", mergedRowCount=" + (mergedRowCount.isPresent() ? mergedRowCount.get() : "NONE") - + ", rawFileConvertable=" + rawFileConvertable - + ", hasDeletionVector=" + hasDeletionVector + "]"; - } - } - - private PaimonSource source = null; - private List predicates; - private int rawFileSplitNum = 0; - private int paimonSplitNum = 0; - private List splitStats = new ArrayList<>(); - private String serializedTable; - // Store PropertiesMap, including vended credentials or static credentials - // get them in doInitialize() to ensure internal consistency of ScanNode - private Map storagePropertiesMap; - private Map backendStorageProperties; - private Map backendPaimonOptions = Collections.emptyMap(); - - // The schema information involved in the current query process (including historical schema). - protected ConcurrentHashMap currentQuerySchema = new ConcurrentHashMap<>(); - - public PaimonScanNode(PlanNodeId id, - TupleDescriptor desc, - boolean needCheckColumnPriv, - SessionVariable sv, - ScanContext scanContext) { - super(id, desc, "PAIMON_SCAN_NODE", scanContext, needCheckColumnPriv, sv); - source = new PaimonSource(desc); - } - - @Override - protected void doInitialize() throws UserException { - super.doInitialize(); - long startTime = System.currentTimeMillis(); - serializedTable = PaimonUtil.encodeObjectToString(source.getPaimonTable()); - // Todo: Get the current schema id of the table, instead of using -1. - ExternalUtil.initSchemaInfo(params, -1L, source.getTargetTable().getColumns()); - PaimonExternalCatalog catalog = (PaimonExternalCatalog) source.getCatalog(); - storagePropertiesMap = VendedCredentialsFactory.getStoragePropertiesMapWithVendedCredentials( - catalog.getCatalogProperty().getMetastoreProperties(), - catalog.getCatalogProperty().getStoragePropertiesMap(), - source.getPaimonTable() - ); - backendStorageProperties = CredentialUtils.getBackendPropertiesFromStorageMap(storagePropertiesMap); - backendPaimonOptions = getBackendPaimonOptions(); - if (getSummaryProfile() != null) { - getSummaryProfile().addExternalTableGetTableMetaTime(System.currentTimeMillis() - startTime); - } - } - - @VisibleForTesting - public void setSource(PaimonSource source) { - this.source = source; - } - - @Override - protected void convertPredicate() { - PaimonPredicateConverter paimonPredicateConverter = new PaimonPredicateConverter( - source.getPaimonTable().rowType()); - predicates = paimonPredicateConverter.convertToPaimonExpr(conjuncts); - } - - @Override - protected void setScanParams(TFileRangeDesc rangeDesc, Split split) { - if (split instanceof PaimonSplit) { - setPaimonParams(rangeDesc, (PaimonSplit) split); - } - } - - @Override - protected Optional getSerializedTable() { - return Optional.of(serializedTable); - } - - @Override - public void createScanRangeLocations() throws UserException { - super.createScanRangeLocations(); - // Set paimon_predicate at ScanNode level to avoid redundant serialization in each split - String serializedPredicate = PaimonUtil.encodeObjectToString(predicates); - params.setPaimonPredicate(serializedPredicate); - setScanLevelPaimonOptions(); - } - - private void setScanLevelPaimonOptions() { - if (!backendPaimonOptions.isEmpty()) { - params.setPaimonOptions(backendPaimonOptions); - } - } - - private List getOrderedPathPartitionKeys() { - if (source == null) { - return Collections.emptyList(); - } - ExternalTable externalTable = source.getExternalTable(); - if (externalTable instanceof PaimonSysExternalTable - && !((PaimonSysExternalTable) externalTable).isDataTable()) { - return Collections.emptyList(); - } - return source.getPaimonTable().partitionKeys(); - } - - private void putHistorySchemaInfo(Long schemaId) { - if (currentQuerySchema.putIfAbsent(schemaId, Boolean.TRUE) == null) { - ExternalTable targetTable = source.getExternalTable(); - if (targetTable instanceof PaimonSysExternalTable) { - PaimonSysExternalTable sysTable = (PaimonSysExternalTable) targetTable; - if (!sysTable.isDataTable()) { - return; - } - } - - TableSchema tableSchema = PaimonUtils.getSchemaCacheValue(targetTable, schemaId).getTableSchema(); - params.addToHistorySchemaInfo(PaimonUtil.getHistorySchemaInfo(targetTable, tableSchema, - source.getCatalog().getEnableMappingVarbinary(), - source.getCatalog().getEnableMappingTimestampTz())); - } - } - - private void setPaimonParams(TFileRangeDesc rangeDesc, PaimonSplit paimonSplit) { - TTableFormatFileDesc tableFormatFileDesc = new TTableFormatFileDesc(); - tableFormatFileDesc.setTableFormatType(paimonSplit.getTableFormatType().value()); - TPaimonFileDesc fileDesc = new TPaimonFileDesc(); - org.apache.paimon.table.source.Split split = paimonSplit.getSplit(); - - String fileFormat = getFileFormat(paimonSplit.getPathString()); - if (split != null) { - // use jni reader or paimon-cpp reader - rangeDesc.setFormatType(TFileFormatType.FORMAT_JNI); - // Use Paimon native serialization for paimon-cpp reader - if (sessionVariable.isEnablePaimonCppReader() && split instanceof DataSplit) { - fileDesc.setReaderType(TPaimonReaderType.PAIMON_CPP); - fileDesc.setPaimonSplit(PaimonUtil.encodeDataSplitToString((DataSplit) split)); - } else { - fileDesc.setReaderType(TPaimonReaderType.PAIMON_JNI); - fileDesc.setPaimonSplit(PaimonUtil.encodeObjectToString(split)); - } - // Set table location for paimon-cpp reader - String tableLocation = source.getTableLocation(); - if (tableLocation != null) { - fileDesc.setPaimonTable(tableLocation); - } - rangeDesc.setSelfSplitWeight(paimonSplit.getSelfSplitWeight()); - } else { - // use native reader - fileDesc.setReaderType(TPaimonReaderType.PAIMON_NATIVE); - if (fileFormat.equals("orc")) { - rangeDesc.setFormatType(TFileFormatType.FORMAT_ORC); - } else if (fileFormat.equals("parquet")) { - rangeDesc.setFormatType(TFileFormatType.FORMAT_PARQUET); - } else { - throw new RuntimeException("Unsupported file format: " + fileFormat); - } - - putHistorySchemaInfo(paimonSplit.getSchemaId()); - fileDesc.setSchemaId(paimonSplit.getSchemaId()); - } - fileDesc.setFileFormat(fileFormat); - // Hadoop conf is set at ScanNode level via params.properties in createScanRangeLocations(), - // no need to set it for each split to avoid redundant configuration - Optional optDeletionFile = paimonSplit.getDeletionFile(); - if (optDeletionFile.isPresent()) { - DeletionFile deletionFile = optDeletionFile.get(); - TPaimonDeletionFileDesc tDeletionFile = new TPaimonDeletionFileDesc(); - // convert the deletion file uri to make sure FileReader can read it in be - LocationPath locationPath = LocationPath.of(deletionFile.path(), storagePropertiesMap); - String path = locationPath.toStorageLocation().toString(); - tDeletionFile.setPath(path); - tDeletionFile.setOffset(deletionFile.offset()); - tDeletionFile.setLength(deletionFile.length()); - fileDesc.setDeletionFile(tDeletionFile); - } - if (paimonSplit.getRowCount().isPresent()) { - tableFormatFileDesc.setTableLevelRowCount(paimonSplit.getRowCount().get()); - } else { - // MUST explicitly set to -1, to be distinct from valid row count >= 0 - tableFormatFileDesc.setTableLevelRowCount(-1); - } - tableFormatFileDesc.setPaimonParams(fileDesc); - rangeDesc.unsetColumnsFromPath(); - rangeDesc.unsetColumnsFromPathKeys(); - rangeDesc.unsetColumnsFromPathIsNull(); - Map partitionValues = paimonSplit.getPaimonPartitionValues(); - List orderedPartitionKeys = getOrderedPathPartitionKeys(); - if (partitionValues != null && !orderedPartitionKeys.isEmpty()) { - List fromPathKeys = new ArrayList<>(); - List fromPathValues = new ArrayList<>(); - List fromPathIsNull = new ArrayList<>(); - for (String partitionKey : orderedPartitionKeys) { - if (!partitionValues.containsKey(partitionKey)) { - continue; - } - String partitionValue = partitionValues.get(partitionKey); - fromPathKeys.add(partitionKey); - fromPathValues.add(partitionValue != null ? partitionValue : ""); - fromPathIsNull.add(partitionValue == null); - } - if (!fromPathKeys.isEmpty()) { - rangeDesc.setColumnsFromPathKeys(fromPathKeys); - rangeDesc.setColumnsFromPath(fromPathValues); - rangeDesc.setColumnsFromPathIsNull(fromPathIsNull); - } - } - rangeDesc.setTableFormatParams(tableFormatFileDesc); - } - - @Override - protected List getDeleteFiles(TFileRangeDesc rangeDesc) { - List deleteFiles = new ArrayList<>(); - if (rangeDesc == null || !rangeDesc.isSetTableFormatParams()) { - return deleteFiles; - } - TTableFormatFileDesc tableFormatParams = rangeDesc.getTableFormatParams(); - if (tableFormatParams == null || !tableFormatParams.isSetPaimonParams()) { - return deleteFiles; - } - TPaimonFileDesc paimonParams = tableFormatParams.getPaimonParams(); - if (paimonParams == null || !paimonParams.isSetDeletionFile()) { - return deleteFiles; - } - TPaimonDeletionFileDesc deletionFile = paimonParams.getDeletionFile(); - if (deletionFile != null && deletionFile.isSetPath()) { - // Format: path [offset: offset, length: length] - deleteFiles.add(deletionFile.getPath()); - } - return deleteFiles; - } - - @Override - public List getSplits(int numBackends) throws UserException { - boolean forceJniScanner = sessionVariable.isForceJniScanner(); - // Paimon system tables need Paimon-side semantics: - // - binlog: pack/merge + array materialization - // - audit_log: rowkind / sequence-number projection - // TODO: Allow native reader after Doris native parquet/orc reader can materialize - // these system-table rows consistently with Paimon system-table semantics. - boolean forceJniForSystemTable = shouldForceJniForSystemTable(); - SessionVariable.IgnoreSplitType ignoreSplitType = SessionVariable.IgnoreSplitType - .valueOf(sessionVariable.getIgnoreSplitType()); - List splits = new ArrayList<>(); - List pushDownCountSplits = new ArrayList<>(); - long pushDownCountSum = 0; - - List paimonSplits = getPaimonSplitFromAPI(); - List dataSplits = new ArrayList<>(); - List nonDataSplits = new ArrayList<>(); - for (org.apache.paimon.table.source.Split split : paimonSplits) { - if (split instanceof DataSplit) { - dataSplits.add((DataSplit) split); - } else { - // Non-DataSplit types (e.g., from some system tables) will use JNI reader - nonDataSplits.add(split); - } - } - - // Handle non-DataSplit splits (typically from metadata system tables) - // These must use JNI reader as they can't be converted to raw files - for (org.apache.paimon.table.source.Split split : nonDataSplits) { - if (ignoreSplitType == SessionVariable.IgnoreSplitType.IGNORE_JNI) { - continue; - } - splits.add(new PaimonSplit(split)); - ++paimonSplitNum; - } - - // Merged row counts contain only COUNT(*) semantics. COUNT(col) must keep every DataSplit - // because BE will read the argument column to account for NULL and schema-mapping rules. - boolean applyCountPushdown = isTableLevelCountStarPushdown(); - // Used to avoid repeatedly calculating partition info map for the same - // partition data. - // And for counting the number of selected partitions for this paimon table. - Map> partitionInfoMaps = new HashMap<>(); - boolean needPartitionMetadata = !getOrderedPathPartitionKeys().isEmpty(); - // if applyCountPushdown is true, we can't split the DataSplit - boolean hasDeterminedTargetFileSplitSize = false; - long targetFileSplitSize = 0; - for (DataSplit dataSplit : dataSplits) { - SplitStat splitStat = new SplitStat(); - splitStat.setRowCount(dataSplit.rowCount()); - - BinaryRow partitionValue = dataSplit.partition(); - Map partitionInfoMap = null; - if (needPartitionMetadata) { - partitionInfoMap = partitionInfoMaps.computeIfAbsent(partitionValue, k -> { - return PaimonUtil.getPartitionInfoMap( - source.getPaimonTable(), partitionValue, sessionVariable.getTimeZone()); - }); - } else { - partitionInfoMaps.put(partitionValue, null); - } - Optional> optRawFiles = dataSplit.convertToRawFiles(); - Optional> optDeletionFiles = dataSplit.deletionFiles(); - if (applyCountPushdown && dataSplit.mergedRowCountAvailable()) { - splitStat.setMergedRowCount(dataSplit.mergedRowCount()); - PaimonSplit split = new PaimonSplit(dataSplit); - split.setRowCount(dataSplit.mergedRowCount()); - if (partitionInfoMap != null) { - split.setPaimonPartitionValues(partitionInfoMap); - } - pushDownCountSplits.add(split); - pushDownCountSum += dataSplit.mergedRowCount(); - } else if (!forceJniScanner && !forceJniForSystemTable && supportNativeReader(optRawFiles)) { - if (ignoreSplitType == SessionVariable.IgnoreSplitType.IGNORE_NATIVE) { - continue; - } - if (!hasDeterminedTargetFileSplitSize) { - targetFileSplitSize = determineTargetFileSplitSize(dataSplits, isBatchMode()); - hasDeterminedTargetFileSplitSize = true; - } - splitStat.setType(SplitReadType.NATIVE); - splitStat.setRawFileConvertable(true); - List rawFiles = optRawFiles.get(); - for (int i = 0; i < rawFiles.size(); i++) { - RawFile file = rawFiles.get(i); - LocationPath locationPath = LocationPath.of(file.path(), storagePropertiesMap); - try { - List dorisSplits = fileSplitter.splitFile( - locationPath, - targetFileSplitSize, - null, - file.length(), - -1, - !applyCountPushdown, - Collections.emptyList(), - PaimonSplit.PaimonSplitCreator.DEFAULT); - for (Split dorisSplit : dorisSplits) { - PaimonSplit paimonSplit = (PaimonSplit) dorisSplit; - paimonSplit.setSchemaId(file.schemaId()); - paimonSplit.setPaimonPartitionValues(partitionInfoMap); - // try to set deletion file - if (optDeletionFiles.isPresent() && optDeletionFiles.get().get(i) != null) { - paimonSplit.setDeletionFile(optDeletionFiles.get().get(i)); - splitStat.setHasDeletionVector(true); - } - } - splits.addAll(dorisSplits); - ++rawFileSplitNum; - } catch (IOException e) { - throw new UserException("Paimon error to split file: " + e.getMessage(), e); - } - } - } else { - if (ignoreSplitType == SessionVariable.IgnoreSplitType.IGNORE_JNI) { - continue; - } - PaimonSplit jniSplit = new PaimonSplit(dataSplit); - jniSplit.setPaimonPartitionValues(partitionInfoMap); - splits.add(jniSplit); - ++paimonSplitNum; - } - - splitStats.add(splitStat); - } - - // if applyCountPushdown is true, calcute row count for count pushdown - if (applyCountPushdown && !pushDownCountSplits.isEmpty()) { - if (pushDownCountSum > COUNT_WITH_PARALLEL_SPLITS) { - int minSplits = sessionVariable.getParallelExecInstanceNum(scanContext.getClusterName()) - * numBackends; - pushDownCountSplits = pushDownCountSplits.subList(0, Math.min(pushDownCountSplits.size(), minSplits)); - } else { - pushDownCountSplits = Collections.singletonList(pushDownCountSplits.get(0)); - } - setPushDownCount(pushDownCountSum); - assignCountToSplits(pushDownCountSplits, pushDownCountSum); - splits.addAll(pushDownCountSplits); - } - - // We need to set the target size for all splits so that we can calculate the - // proportion of each split later. - splits.forEach(s -> s.setTargetSplitSize(sessionVariable.getFileSplitSize() > 0 - ? sessionVariable.getFileSplitSize() : sessionVariable.getMaxSplitSize())); - - this.selectedPartitionNum = partitionInfoMaps.size(); - return splits; - } - - @VisibleForTesting - Map getBackendPaimonOptions() { - if (source == null) { - return Collections.emptyMap(); - } - if (!(source.getCatalog() instanceof PaimonExternalCatalog)) { - return Collections.emptyMap(); - } - PaimonExternalCatalog catalog = (PaimonExternalCatalog) source.getCatalog(); - Map backendOptions = new HashMap<>(); - Map catalogProperties = catalog.getCatalogProperty().getProperties(); - if (catalogProperties == null) { - catalogProperties = Collections.emptyMap(); - } - for (String option : BACKEND_PAIMON_OPTIONS) { - String catalogProperty = PAIMON_PROPERTY_PREFIX + option; - if (catalogProperties.containsKey(catalogProperty)) { - backendOptions.put(option, catalogProperties.get(catalogProperty)); - } - } - if (!(catalog.getCatalogProperty().getMetastoreProperties() instanceof PaimonJdbcMetaStoreProperties)) { - return backendOptions; - } - PaimonJdbcMetaStoreProperties jdbcMetaStoreProperties = - (PaimonJdbcMetaStoreProperties) catalog.getCatalogProperty().getMetastoreProperties(); - backendOptions.putAll(jdbcMetaStoreProperties.getBackendPaimonOptions()); - return backendOptions; - } - - @VisibleForTesting - boolean shouldForceJniForSystemTable() { - if (source == null) { - return false; - } - ExternalTable externalTable = source.getExternalTable(); - if (!(externalTable instanceof PaimonSysExternalTable)) { - return false; - } - PaimonSysExternalTable paimonSysExternalTable = (PaimonSysExternalTable) externalTable; - String sysTableType = paimonSysExternalTable.getSysTableType(); - return PAIMON_BINLOG_SYSTEM_TABLE_TYPE.equalsIgnoreCase(sysTableType) - || PAIMON_AUDIT_LOG_SYSTEM_TABLE_TYPE.equalsIgnoreCase(sysTableType); - } - - private long determineTargetFileSplitSize(List dataSplits, - boolean isBatchMode) { - if (sessionVariable.getFileSplitSize() > 0) { - return sessionVariable.getFileSplitSize(); - } - /** Paimon batch split mode will return 0. and FileSplitter - * will determine file split size. - */ - if (isBatchMode) { - return 0; - } - long result = sessionVariable.getMaxInitialSplitSize(); - long totalFileSize = 0; - boolean exceedInitialThreshold = false; - for (DataSplit dataSplit : dataSplits) { - Optional> rawFiles = dataSplit.convertToRawFiles(); - if (!supportNativeReader(rawFiles)) { - continue; - } - for (RawFile rawFile : rawFiles.get()) { - totalFileSize += rawFile.fileSize(); - if (!exceedInitialThreshold && totalFileSize - >= sessionVariable.getMaxSplitSize() * sessionVariable.getMaxInitialSplitNum()) { - exceedInitialThreshold = true; - } - } - } - result = exceedInitialThreshold ? sessionVariable.getMaxSplitSize() : result; - result = applyMaxFileSplitNumLimit(result, totalFileSize); - return result; - } - - @VisibleForTesting - public Map getIncrReadParams() throws UserException { - Map paimonScanParams = new HashMap<>(); - if (scanParams != null && scanParams.incrementalRead()) { - // Validate parameter combinations and get the result map - paimonScanParams = validateIncrementalReadParams(scanParams.getMapParams()); - } - return paimonScanParams; - } - - @VisibleForTesting - public List getPaimonSplitFromAPI() throws UserException { - long startTime = System.currentTimeMillis(); - try { - Table paimonTable = getProcessedTable(); - List fieldNames = paimonTable.rowType().getFieldNames(); - int[] projected = desc.getSlots().stream().mapToInt( - slot -> getFieldIndex(fieldNames, slot.getColumn().getName())) - .filter(i -> i >= 0) - .toArray(); - ReadBuilder readBuilder = paimonTable.newReadBuilder(); - TableScan scan = readBuilder.withFilter(predicates) - .withProjection(projected) - .newScan(); - PaimonMetricRegistry registry = new PaimonMetricRegistry(); - if (scan instanceof InnerTableScan) { - scan = ((InnerTableScan) scan).withMetricRegistry(registry); - } - List splits = scan.plan().splits(); - PaimonScanMetricsReporter.report(source.getTargetTable(), paimonTable.name(), registry); - if (!registry.getAllGroups().isEmpty()) { - registry.clear(); - } - return splits; - } finally { - if (getSummaryProfile() != null) { - getSummaryProfile().addExternalTableGetFileScanTasksTime(System.currentTimeMillis() - startTime); - } - } - } - - @VisibleForTesting - static int getFieldIndex(List fieldNames, String columnName) { - for (int i = 0; i < fieldNames.size(); i++) { - if (fieldNames.get(i).equalsIgnoreCase(columnName)) { - return i; - } - } - return -1; - } - - private String getFileFormat(String path) { - return FileFormatUtils.getFileFormatBySuffix(path).orElse(source.getFileFormatFromTableProperties()); - } - - @VisibleForTesting - public boolean supportNativeReader(Optional> optRawFiles) { - if (!optRawFiles.isPresent()) { - return false; - } - List files = optRawFiles.get().stream().map(RawFile::path).collect(Collectors.toList()); - for (String f : files) { - String splitFileFormat = getFileFormat(f); - if (!splitFileFormat.equals("orc") && !splitFileFormat.equals("parquet")) { - return false; - } - } - return true; - } - - @Override - public TFileFormatType getFileFormatType() throws DdlException, MetaNotFoundException { - return TFileFormatType.FORMAT_JNI; - } - - @Override - public List getPathPartitionKeys() throws DdlException, MetaNotFoundException { - return getOrderedPathPartitionKeys(); - } - - @Override - public TableIf getTargetTable() { - return desc.getTable(); - } - - @Override - protected Map getLocationProperties() { - return backendStorageProperties; - } - - @Override - public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { - StringBuilder sb = new StringBuilder(super.getNodeExplainString(prefix, detailLevel)); - sb.append(String.format("%spaimonNativeReadSplits=%d/%d\n", - prefix, rawFileSplitNum, (paimonSplitNum + rawFileSplitNum))); - - sb.append(prefix).append("predicatesFromPaimon:"); - if (predicates.isEmpty()) { - sb.append(" NONE\n"); - } else { - sb.append("\n"); - for (Predicate predicate : predicates) { - sb.append(prefix).append(prefix).append(predicate).append("\n"); - } - } - - if (detailLevel == TExplainLevel.VERBOSE) { - sb.append(prefix).append("PaimonSplitStats: \n"); - int size = splitStats.size(); - if (size <= 4) { - for (SplitStat splitStat : splitStats) { - sb.append(String.format("%s %s\n", prefix, splitStat)); - } - } else { - for (int i = 0; i < 3; i++) { - SplitStat splitStat = splitStats.get(i); - sb.append(String.format("%s %s\n", prefix, splitStat)); - } - int other = size - 4; - sb.append(prefix).append(" ... other ").append(other).append(" paimon split stats ...\n"); - SplitStat split = splitStats.get(size - 1); - sb.append(String.format("%s %s\n", prefix, split)); - } - } - return sb.toString(); - } - - private void assignCountToSplits(List splits, long totalCount) { - int size = splits.size(); - long countPerSplit = totalCount / size; - for (int i = 0; i < size - 1; i++) { - ((PaimonSplit) splits.get(i)).setRowCount(countPerSplit); - } - ((PaimonSplit) splits.get(size - 1)).setRowCount(countPerSplit + totalCount % size); - } - - @VisibleForTesting - public static Map validateIncrementalReadParams(Map params) throws UserException { - // Check if snapshot-based parameters exist - boolean hasStartSnapshotId = params.containsKey(DORIS_START_SNAPSHOT_ID) - && params.get(DORIS_START_SNAPSHOT_ID) != null; - boolean hasEndSnapshotId = params.containsKey(DORIS_END_SNAPSHOT_ID) - && params.get(DORIS_END_SNAPSHOT_ID) != null; - boolean hasIncrementalBetweenScanMode = params.containsKey(DORIS_INCREMENTAL_BETWEEN_SCAN_MODE) - && params.get(DORIS_INCREMENTAL_BETWEEN_SCAN_MODE) != null; - - // Check if timestamp-based parameters exist - boolean hasStartTimestamp = params.containsKey(DORIS_START_TIMESTAMP) - && params.get(DORIS_START_TIMESTAMP) != null; - boolean hasEndTimestamp = params.containsKey(DORIS_END_TIMESTAMP) && params.get(DORIS_END_TIMESTAMP) != null; - - // Check if any snapshot-based parameters are present - boolean hasSnapshotParams = hasStartSnapshotId || hasEndSnapshotId || hasIncrementalBetweenScanMode; - - // Check if any timestamp-based parameters are present - boolean hasTimestampParams = hasStartTimestamp || hasEndTimestamp; - - // Rule 2: The two groups are mutually exclusive - if (hasSnapshotParams && hasTimestampParams) { - throw new UserException( - "Cannot specify both snapshot-based parameters" - + "(startSnapshotId, endSnapshotId, incrementalBetweenScanMode) " - + "and timestamp-based parameters (startTimestamp, endTimestamp) at the same time"); - } - - // Validate snapshot-based parameters group - if (hasSnapshotParams) { - // Rule 3.1 & 3.2: DORIS_START_SNAPSHOT_ID is required - if (!hasStartSnapshotId) { - throw new UserException("startSnapshotId is required when using snapshot-based incremental read"); - } - - // Rule 3.3: DORIS_INCREMENTAL_BETWEEN_SCAN_MODE can only appear - // when both start and end snapshot IDs are specified - if (hasIncrementalBetweenScanMode && (!hasStartSnapshotId || !hasEndSnapshotId)) { - throw new UserException( - "incrementalBetweenScanMode can only be specified when" - + " both startSnapshotId and endSnapshotId are provided"); - } - - // Validate snapshot ID values - if (hasStartSnapshotId) { - try { - long startSId = Long.parseLong(params.get(DORIS_START_SNAPSHOT_ID)); - if (startSId < 0) { - throw new UserException("startSnapshotId must be greater than or equal to 0"); - } - } catch (NumberFormatException e) { - throw new UserException("Invalid startSnapshotId format: " + e.getMessage()); - } - } - - if (hasEndSnapshotId) { - try { - long endSId = Long.parseLong(params.get(DORIS_END_SNAPSHOT_ID)); - if (endSId < 0) { - throw new UserException("endSnapshotId must be greater than or equal to 0"); - } - } catch (NumberFormatException e) { - throw new UserException("Invalid endSnapshotId format: " + e.getMessage()); - } - } - - // Check if both snapshot IDs are present and validate their relationship - if (hasStartSnapshotId && hasEndSnapshotId) { - try { - long startSId = Long.parseLong(params.get(DORIS_START_SNAPSHOT_ID)); - long endSId = Long.parseLong(params.get(DORIS_END_SNAPSHOT_ID)); - if (startSId > endSId) { - throw new UserException("startSnapshotId must be less than or equal to endSnapshotId"); - } - } catch (NumberFormatException e) { - throw new UserException("Invalid snapshot ID format: " + e.getMessage()); - } - } - - // Validate DORIS_INCREMENTAL_BETWEEN_SCAN_MODE - if (hasIncrementalBetweenScanMode) { - String scanMode = params.get(DORIS_INCREMENTAL_BETWEEN_SCAN_MODE).toLowerCase(); - if (!scanMode.equals("auto") && !scanMode.equals("diff") - && !scanMode.equals("delta") && !scanMode.equals("changelog")) { - throw new UserException("incrementalBetweenScanMode must be one of: auto, diff, delta, changelog"); - } - } - } - - // Validate timestamp-based parameters group - if (hasTimestampParams) { - // Rule 4.1 & 4.2: DORIS_START_TIMESTAMP is required - if (!hasStartTimestamp) { - throw new UserException("startTimestamp is required when using timestamp-based incremental read"); - } - - // Validate timestamp values - if (hasStartTimestamp) { - try { - long startTS = Long.parseLong(params.get(DORIS_START_TIMESTAMP)); - if (startTS < 0) { - throw new UserException("startTimestamp must be greater than or equal to 0"); - } - } catch (NumberFormatException e) { - throw new UserException("Invalid startTimestamp format: " + e.getMessage()); - } - } - - if (hasEndTimestamp) { - try { - long endTS = Long.parseLong(params.get(DORIS_END_TIMESTAMP)); - if (endTS <= 0) { - throw new UserException("endTimestamp must be greater than 0"); - } - } catch (NumberFormatException e) { - throw new UserException("Invalid endTimestamp format: " + e.getMessage()); - } - } - - // Check if both timestamps are present and validate their relationship - if (hasStartTimestamp && hasEndTimestamp) { - try { - long startTS = Long.parseLong(params.get(DORIS_START_TIMESTAMP)); - long endTS = Long.parseLong(params.get(DORIS_END_TIMESTAMP)); - if (startTS >= endTS) { - throw new UserException("startTimestamp must be less than endTimestamp"); - } - } catch (NumberFormatException e) { - throw new UserException("Invalid timestamp format: " + e.getMessage()); - } - } - } - - // If no incremental parameters are provided at all, that's also invalid in this context - if (!hasSnapshotParams && !hasTimestampParams) { - throw new UserException( - "Invalid paimon incremental read params: at least one valid parameter group must be specified"); - } - - // Fill the result map based on parameter combinations - Map paimonScanParams = new HashMap<>(); - paimonScanParams.put(PAIMON_SCAN_SNAPSHOT_ID, null); - paimonScanParams.put(PAIMON_SCAN_MODE, null); - - if (hasSnapshotParams) { - paimonScanParams.put(PAIMON_SCAN_MODE, null); - if (hasStartSnapshotId && !hasEndSnapshotId) { - // Only startSnapshotId is specified - throw new UserException("endSnapshotId is required when using snapshot-based incremental read"); - } else if (hasStartSnapshotId && hasEndSnapshotId) { - // Both start and end snapshot IDs are specified - String startSId = params.get(DORIS_START_SNAPSHOT_ID); - String endSId = params.get(DORIS_END_SNAPSHOT_ID); - paimonScanParams.put(PAIMON_INCREMENTAL_BETWEEN, startSId + "," + endSId); - } - - // Add incremental between scan mode if present - if (hasIncrementalBetweenScanMode) { - paimonScanParams.put(PAIMON_INCREMENTAL_BETWEEN_SCAN_MODE, - params.get(DORIS_INCREMENTAL_BETWEEN_SCAN_MODE)); - } - } - - if (hasTimestampParams) { - String startTS = params.get(DORIS_START_TIMESTAMP); - String endTS = params.get(DORIS_END_TIMESTAMP); - - if (hasStartTimestamp && !hasEndTimestamp) { - // Only startTimestamp is specified - paimonScanParams.put(PAIMON_INCREMENTAL_BETWEEN_TIMESTAMP, startTS + "," + Long.MAX_VALUE); - } else if (hasStartTimestamp && hasEndTimestamp) { - // Both start and end timestamps are specified - paimonScanParams.put(PAIMON_INCREMENTAL_BETWEEN_TIMESTAMP, startTS + "," + endTS); - } - } - - return paimonScanParams; - } - - private Table getProcessedTable() throws UserException { - Table baseTable = source.getPaimonTable(); - TableScanParams theScanParams = getScanParams(); - if (source.getExternalTable() instanceof PaimonSysExternalTable) { - if (theScanParams != null) { - throw new UserException("Paimon system tables do not support scan params."); - } - if (getQueryTableSnapshot() != null) { - throw new UserException("Paimon system tables do not support time travel."); - } - } - if (theScanParams != null && getQueryTableSnapshot() != null) { - throw new UserException("Can not specify scan params and table snapshot at same time."); - } - - if (theScanParams != null && theScanParams.incrementalRead()) { - return baseTable.copy(getIncrReadParams()); - } - return baseTable; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonSource.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonSource.java deleted file mode 100644 index 43c6ef4170168c..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonSource.java +++ /dev/null @@ -1,102 +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.datasource.paimon.source; - -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.mvcc.MvccSnapshot; -import org.apache.doris.datasource.mvcc.MvccUtil; -import org.apache.doris.datasource.paimon.PaimonExternalTable; -import org.apache.doris.datasource.paimon.PaimonSysExternalTable; -import org.apache.doris.thrift.TFileAttributes; - -import com.google.common.annotations.VisibleForTesting; -import org.apache.paimon.table.FileStoreTable; -import org.apache.paimon.table.Table; - -import java.util.Optional; - -public class PaimonSource { - private final ExternalTable paimonExtTable; - private final Table originTable; - private final TupleDescriptor desc; - - @VisibleForTesting - public PaimonSource() { - this.desc = null; - this.paimonExtTable = null; - this.originTable = null; - } - - public PaimonSource(TupleDescriptor desc) { - this.desc = desc; - this.paimonExtTable = (ExternalTable) desc.getTable(); - this.originTable = resolvePaimonTable(paimonExtTable); - } - - public TupleDescriptor getDesc() { - return desc; - } - - public Table getPaimonTable() { - return originTable; - } - - public TableIf getTargetTable() { - return paimonExtTable; - } - - public ExternalTable getExternalTable() { - return paimonExtTable; - } - - private Table resolvePaimonTable(ExternalTable table) { - Optional snapshot = MvccUtil.getSnapshotFromContext(table); - if (table instanceof PaimonExternalTable) { - return ((PaimonExternalTable) table).getPaimonTable(snapshot); - } - if (table instanceof PaimonSysExternalTable) { - return ((PaimonSysExternalTable) table).getSysPaimonTable(); - } - throw new IllegalArgumentException( - "Expected Paimon table but got " + table.getClass().getSimpleName()); - } - - public TFileAttributes getFileAttributes() throws UserException { - return new TFileAttributes(); - } - - public ExternalCatalog getCatalog() { - return paimonExtTable.getCatalog(); - } - - public String getFileFormatFromTableProperties() { - return originTable.options().getOrDefault("file.format", "parquet"); - } - - public String getTableLocation() { - if (originTable instanceof FileStoreTable) { - return ((FileStoreTable) originTable).location().toString(); - } - // Fallback to path option - return originTable.options().get("path"); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonSplit.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonSplit.java deleted file mode 100644 index 4a8808517b2176..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonSplit.java +++ /dev/null @@ -1,159 +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.datasource.paimon.source; - -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.FileSplit; -import org.apache.doris.datasource.SplitCreator; -import org.apache.doris.datasource.TableFormatType; - -import org.apache.paimon.io.DataFileMeta; -import org.apache.paimon.table.source.DataSplit; -import org.apache.paimon.table.source.DeletionFile; -import org.apache.paimon.table.source.Split; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -public class PaimonSplit extends FileSplit { - private static final LocationPath DUMMY_PATH = LocationPath.of("/dummyPath"); - // Paimon split - can be DataSplit or other Split types (e.g., from system tables) - private Split paimonSplit; - private TableFormatType tableFormatType; - private Optional optDeletionFile = Optional.empty(); - private Optional optRowCount = Optional.empty(); - private Optional schemaId = Optional.empty(); - private Map paimonPartitionValues = null; - - /** - * Constructor for Paimon splits. - * Handles both DataSplit (regular data tables) and other Split types (system tables). - */ - public PaimonSplit(Split paimonSplit) { - super(DUMMY_PATH, 0, 0, 0, 0, null, Collections.emptyList()); - this.paimonSplit = paimonSplit; - this.tableFormatType = TableFormatType.PAIMON; - - if (paimonSplit instanceof DataSplit) { - // For DataSplit, extract file info for path and weight calculation - DataSplit dataSplit = (DataSplit) paimonSplit; - List dataFileMetas = dataSplit.dataFiles(); - this.path = LocationPath.of("/" + dataFileMetas.get(0).fileName()); - this.selfSplitWeight = dataFileMetas.stream().mapToLong(DataFileMeta::fileSize).sum(); - } else { - // For non-DataSplit (e.g., system tables), use row count as weight - this.selfSplitWeight = paimonSplit.rowCount(); - } - } - - private PaimonSplit(LocationPath file, long start, long length, long fileLength, long modificationTime, - String[] hosts, List partitionList) { - super(file, start, length, fileLength, modificationTime, hosts, - partitionList == null ? Collections.emptyList() : partitionList); - this.tableFormatType = TableFormatType.PAIMON; - this.selfSplitWeight = length; - } - - @Override - public String getConsistentHashString() { - if (this.path == DUMMY_PATH) { - return UUID.randomUUID().toString(); - } - return getPathString(); - } - - /** - * Returns the underlying Paimon split. - * For JNI reader serialization. - */ - public Split getSplit() { - return paimonSplit; - } - - /** - * Returns the split as DataSplit if it's a DataSplit instance. - * Returns null if this is a non-DataSplit system table split. - */ - public DataSplit getDataSplit() { - return paimonSplit instanceof DataSplit ? (DataSplit) paimonSplit : null; - } - - public TableFormatType getTableFormatType() { - return tableFormatType; - } - - public void setTableFormatType(TableFormatType tableFormatType) { - this.tableFormatType = tableFormatType; - } - - public Optional getDeletionFile() { - return optDeletionFile; - } - - public void setDeletionFile(DeletionFile deletionFile) { - this.selfSplitWeight += deletionFile.length(); - this.optDeletionFile = Optional.of(deletionFile); - } - - public Optional getRowCount() { - return optRowCount; - } - - public void setRowCount(long rowCount) { - this.optRowCount = Optional.of(rowCount); - } - - public void setSchemaId(long schemaId) { - this.schemaId = Optional.of(schemaId); - } - - public Long getSchemaId() { - return schemaId.orElse(null); - } - - public void setPaimonPartitionValues(Map paimonPartitionValues) { - this.paimonPartitionValues = paimonPartitionValues; - } - - public Map getPaimonPartitionValues() { - return paimonPartitionValues; - } - - public static class PaimonSplitCreator implements SplitCreator { - - static final PaimonSplitCreator DEFAULT = new PaimonSplitCreator(); - - @Override - public org.apache.doris.spi.Split create(LocationPath path, - long start, - long length, - long fileLength, - long fileSplitSize, - long modificationTime, - String[] hosts, - List partitionValues) { - PaimonSplit split = new PaimonSplit(path, start, length, fileLength, - modificationTime, hosts, partitionValues); - split.setTargetSplitSize(fileSplitSize); - return split; - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonValueConverter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonValueConverter.java deleted file mode 100644 index d490474489d59d..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonValueConverter.java +++ /dev/null @@ -1,162 +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.datasource.paimon.source; - -import org.apache.doris.analysis.BoolLiteral; -import org.apache.doris.analysis.DateLiteral; -import org.apache.doris.analysis.DecimalLiteral; -import org.apache.doris.analysis.FloatLiteral; -import org.apache.doris.analysis.IntLiteral; -import org.apache.doris.analysis.LiteralExpr; - -import org.apache.paimon.data.BinaryString; -import org.apache.paimon.data.Decimal; -import org.apache.paimon.data.Timestamp; -import org.apache.paimon.types.BigIntType; -import org.apache.paimon.types.BooleanType; -import org.apache.paimon.types.CharType; -import org.apache.paimon.types.DataType; -import org.apache.paimon.types.DataTypeDefaultVisitor; -import org.apache.paimon.types.DateType; -import org.apache.paimon.types.DecimalType; -import org.apache.paimon.types.DoubleType; -import org.apache.paimon.types.FloatType; -import org.apache.paimon.types.IntType; -import org.apache.paimon.types.SmallIntType; -import org.apache.paimon.types.TimestampType; -import org.apache.paimon.types.TinyIntType; -import org.apache.paimon.types.VarCharType; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.util.Calendar; -import java.util.TimeZone; - -/** - * Convert LiteralExpr to paimon value. - */ -public class PaimonValueConverter extends DataTypeDefaultVisitor { - private LiteralExpr expr; - - public PaimonValueConverter(LiteralExpr expr) { - this.expr = expr; - } - - public BinaryString visit(VarCharType varCharType) { - return BinaryString.fromString(expr.getStringValue()); - } - - public BinaryString visit(CharType charType) { - // Currently, Paimon does not support predicate push-down for char - // ref: org.apache.paimon.predicate.PredicateBuilder.convertJavaObject - return null; - } - - public Boolean visit(BooleanType booleanType) { - if (expr instanceof BoolLiteral) { - BoolLiteral boolLiteral = (BoolLiteral) expr; - return boolLiteral.getValue(); - } - return null; - } - - public Decimal visit(DecimalType decimalType) { - if (expr instanceof DecimalLiteral) { - DecimalLiteral decimalLiteral = (DecimalLiteral) expr; - BigDecimal value = decimalLiteral.getValue(); - return Decimal.fromBigDecimal(value, value.precision(), value.scale()); - } - return null; - } - - public Short visit(SmallIntType smallIntType) { - if (expr instanceof IntLiteral) { - IntLiteral intLiteral = (IntLiteral) expr; - return (short) intLiteral.getValue(); - } - return null; - } - - public Byte visit(TinyIntType tinyIntType) { - if (expr instanceof IntLiteral) { - IntLiteral intLiteral = (IntLiteral) expr; - return (byte) intLiteral.getValue(); - } - return null; - } - - - public Integer visit(IntType intType) { - if (expr instanceof IntLiteral) { - IntLiteral intLiteral = (IntLiteral) expr; - return (int) intLiteral.getValue(); - } - return null; - } - - public Long visit(BigIntType bigIntType) { - if (expr instanceof IntLiteral) { - IntLiteral intLiteral = (IntLiteral) expr; - return intLiteral.getValue(); - } - return null; - } - - // when a = 9.1,paimon can get data,doris can not get data - // when a > 9.1,paimon can not get data,doris can get data - // paimon is no problem,but we consistent with Doris internal table - // Therefore, comment out this code - public Float visit(FloatType floatType) { - return null; - } - - public Double visit(DoubleType doubleType) { - if (expr instanceof FloatLiteral) { - FloatLiteral floatLiteral = (FloatLiteral) expr; - return floatLiteral.getValue(); - } - return null; - } - - public Integer visit(DateType dateType) { - if (expr instanceof DateLiteral) { - DateLiteral dateLiteral = (DateLiteral) expr; - long l = LocalDate.of((int) dateLiteral.getYear(), (int) dateLiteral.getMonth(), (int) dateLiteral.getDay()) - .toEpochDay(); - return (int) l; - } - return null; - } - - public Timestamp visit(TimestampType timestampType) { - if (expr instanceof DateLiteral) { - DateLiteral dateLiteral = (DateLiteral) expr; - Calendar instance = Calendar.getInstance(TimeZone.getTimeZone("GMT")); - instance.set((int) dateLiteral.getYear(), (int) (dateLiteral.getMonth() - 1), (int) dateLiteral.getDay(), - (int) dateLiteral.getHour(), (int) dateLiteral.getMinute(), (int) dateLiteral.getSecond()); - return Timestamp - .fromEpochMillis(instance.getTimeInMillis() / 1000 * 1000 + dateLiteral.getMicrosecond() / 1000); - } - return null; - } - - @Override - protected Object defaultMethod(DataType dataType) { - return null; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/CatalogStatementTransaction.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/CatalogStatementTransaction.java new file mode 100644 index 00000000000000..9e15d57fcce1c1 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/CatalogStatementTransaction.java @@ -0,0 +1,108 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorWriteOps; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.transaction.PluginDrivenTransactionManager; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * The per-statement owner of a plugin-driven write transaction, co-held on the statement scope next to the one + * memoized {@link org.apache.doris.connector.api.ConnectorMetadata} the read and write arms share — mirroring + * Trino's {@code CatalogTransaction}: one metadata instance and one transaction per (statement, catalog). The + * insert executor opens the transaction through {@link #begin}, minting it from that shared write-ops facet so + * the write inherits exactly the read arm's client/ops, and commits / rolls back through the + * {@link PluginDrivenTransactionManager} as before. + * + *

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

    + * + *

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

    + */ +public final class CatalogStatementTransaction { + + private static final Logger LOG = LogManager.getLogger(CatalogStatementTransaction.class); + + /** Sentinel for "no transaction opened yet" (never a real connector txn id). */ + public static final long INVALID_TXN_ID = -1L; + + // The write facet of the statement's one shared metadata instance; the transaction is minted from it. Held + // for co-hold coherence — the metadata itself is closed by the scope's generic pass, this class only + // finalizes the transaction. + private final ConnectorWriteOps writeOps; + private final ConnectorSession session; + private final PluginDrivenTransactionManager transactionManager; + + private ConnectorTransaction connectorTx; + private long txnId = INVALID_TXN_ID; + + public CatalogStatementTransaction(ConnectorWriteOps writeOps, ConnectorSession session, + PluginDrivenTransactionManager transactionManager) { + this.writeOps = writeOps; + this.session = session; + this.transactionManager = transactionManager; + } + + /** + * Opens the write transaction from the shared metadata and registers it with the manager (which also + * publishes it in the global registry the BE block-allocation RPC / commit-data feedback look up by id), + * returning it so the executor can bind it onto the sink session. Called once, from the insert executor's + * {@code beginTransaction}. + */ + public ConnectorTransaction begin(ConnectorTableHandle writeHandle) { + connectorTx = writeOps.beginTransaction(session, writeHandle); + txnId = transactionManager.begin(connectorTx); + return connectorTx; + } + + public long getTransactionId() { + return txnId; + } + + public ConnectorTransaction getConnectorTransaction() { + return connectorTx; + } + + /** + * Statement-end backstop: if the transaction is still active (the executor never reached commit / rollback, + * i.e. the statement was aborted mid-flight), roll it back. On every normal path the executor already + * finished it — the manager no longer holds it — so this is a no-op and can never undo a committed write. + * The scope runs this before it closes the shared metadata instance. + */ + public void finalizeAtStatementEnd() { + if (txnId == INVALID_TXN_ID || !transactionManager.isActive(txnId)) { + return; + } + LOG.warn("Statement ended with an uncommitted plugin-driven transaction {}; rolling it back as a backstop", + txnId); + transactionManager.rollback(txnId); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalog.java new file mode 100644 index 00000000000000..d1b87d5d9f0d67 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalog.java @@ -0,0 +1,1293 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.Env; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.catalog.info.ColumnPosition; +import org.apache.doris.catalog.info.CreateOrReplaceBranchInfo; +import org.apache.doris.catalog.info.CreateOrReplaceTagInfo; +import org.apache.doris.catalog.info.DropBranchInfo; +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.DdlException; +import org.apache.doris.common.ErrorCode; +import org.apache.doris.common.ErrorReport; +import org.apache.doris.common.UserException; +import org.apache.doris.common.util.Util; +import org.apache.doris.connector.ConnectorFactory; +import org.apache.doris.connector.ConnectorSessionBuilder; +import org.apache.doris.connector.DefaultConnectorContext; +import org.apache.doris.connector.DefaultConnectorValidationContext; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.ConnectorTestResult; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.ddl.ConnectorColumnPosition; +import org.apache.doris.connector.api.ddl.ConnectorCreateTableRequest; +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.datasource.CatalogMgr; +import org.apache.doris.datasource.CatalogProperty; +import org.apache.doris.datasource.ExternalCatalog; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.ExternalFunctionRules; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.SessionContext; +import org.apache.doris.datasource.connector.converter.ConnectorBranchTagConverter; +import org.apache.doris.datasource.connector.converter.ConnectorColumnConverter; +import org.apache.doris.datasource.connector.converter.ConnectorPartitionFieldConverter; +import org.apache.doris.datasource.log.ExternalObjectLog; +import org.apache.doris.datasource.log.InitCatalogLog; +import org.apache.doris.nereids.trees.plans.commands.info.AddPartitionFieldOp; +import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo; +import org.apache.doris.nereids.trees.plans.commands.info.DropPartitionFieldOp; +import org.apache.doris.nereids.trees.plans.commands.info.ReplacePartitionFieldOp; +import org.apache.doris.persist.CreateDbInfo; +import org.apache.doris.persist.DropDbInfo; +import org.apache.doris.persist.DropInfo; +import org.apache.doris.persist.TruncateTableInfo; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.transaction.PluginDrivenTransactionManager; + +import com.google.common.base.Preconditions; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * An {@link ExternalCatalog} backed by a Connector SPI plugin. + * + *

    This adapter bridges the connector SPI ({@link Connector}) with the existing + * ExternalCatalog hierarchy. Metadata operations are delegated to the connector's + * {@link org.apache.doris.connector.api.ConnectorMetadata} implementation.

    + * + *

    When created via {@link CatalogFactory}, the Connector instance is provided + * directly. After GSON deserialization (FE restart), the Connector is recreated + * from catalog properties during {@link #initLocalObjectsImpl()}.

    + */ +public class PluginDrivenExternalCatalog extends ExternalCatalog { + + private static final Logger LOG = LogManager.getLogger(PluginDrivenExternalCatalog.class); + + // Volatile for cross-thread visibility; all mutations happen under synchronized(this) + // via makeSureInitialized() → initLocalObjectsImpl(), or resetToUninitialized() → onClose(). + private transient volatile Connector connector; + + // The engine-owned context shared by the connector (and any sibling it builds via createSiblingConnector). + // Held so the catalog can close the context's cached engine FileSystem (DefaultConnectorContext.getFileSystem) + // on teardown -- connectors only borrow that FS and must not close it. Null until the real connector is built + // (the lightweight CatalogFactory context is not tracked here; its FS is never built). + private transient volatile DefaultConnectorContext connectorContext; + + /** No-arg constructor for GSON deserialization. */ + public PluginDrivenExternalCatalog() { + } + + /** + * Creates a plugin-driven catalog with an already-created Connector. + * + * @param catalogId unique catalog id + * @param name catalog name + * @param resource optional resource name + * @param props catalog properties + * @param comment catalog comment + * @param connector the SPI connector instance + */ + public PluginDrivenExternalCatalog(long catalogId, String name, String resource, + Map props, String comment, Connector connector) { + super(catalogId, name, InitCatalogLog.Type.PLUGIN, comment); + this.catalogProperty = new CatalogProperty(resource, props); + this.connector = connector; + } + + @Override + protected void initLocalObjectsImpl() { + // Always (re-)create the connector so it gets the proper engine context, + // including the catalog's execution authenticator for Kerberos/secured HMS. + // The connector created by CatalogFactory used a lightweight context + // without auth (the catalog didn't exist yet); we replace it now. + Connector oldConnector = connector; + // Capture the old context before createConnectorFromProperties() overwrites connectorContext, so we can + // close its cached FileSystem when the connector is actually replaced. + DefaultConnectorContext oldContext = connectorContext; + Connector newConnector = createConnectorFromProperties(); + if (newConnector != null) { + connector = newConnector; + // Close the old connector (e.g., the one injected by CatalogFactory during + // checkWhenCreating) to release its connection pool and classloader reference. + if (oldConnector != null && oldConnector != newConnector) { + try { + oldConnector.close(); + } catch (IOException e) { + LOG.warn("Failed to close old connector during re-initialization " + + "for catalog {}", name, e); + } + // ...and close the replaced context's cached engine FileSystem (never the live one). + if (oldContext != null && oldContext != connectorContext) { + closeConnectorContextQuietly(oldContext); + } + } + } + if (connector == null) { + throw new RuntimeException("No ConnectorProvider found for plugin-driven catalog: " + + name + ", type: " + getType() + + ". Ensure the connector plugin is installed."); + } + // Design S8: the connector owns storage-property derivation (e.g. the iceberg hadoop + // warehouse -> fs.defaultFS bridge); fe-core folds the connector-derived defaults into its storage map + // instead of parsing metastore properties. Read the connector field lazily so an ALTER-rebuilt (or + // dropped) connector is honored at storage-access time. + catalogProperty.setPluginDerivedStorageDefaultsSupplier(() -> { + Connector activeConnector = connector; + return activeConnector != null + ? activeConnector.deriveStorageProperties(catalogProperty.getProperties()) + : java.util.Collections.emptyMap(); + }); + transactionManager = new PluginDrivenTransactionManager(); + // Design S6: a plugin catalog's pre-execution Kerberos auth is owned entirely by the connector + // (TcclPinningConnectorContext runs each remote op under the connector's own plugin-side authenticator — + // storage Kerberos and, via {Iceberg,Paimon}Connector.buildPluginAuthenticator, HMS-metastore Kerberos). + // fe-core keeps only the base no-op ExecutionAuthenticator handle (non-null so + // BaseExternalTableInsertExecutor / ExternalCatalog.getExecutionAuthenticator can call it + // unconditionally, but it performs no doAs — the connector's inner doAs is authoritative). Hence no + // plugin-specific initPreExecutionAuthenticator override: inherit the base no-op. + initPreExecutionAuthenticator(); + } + + /** + * Creates a new Connector from catalog properties. Extracted as a protected method + * so tests can override without depending on the static ConnectorFactory registry. + */ + protected Connector createConnectorFromProperties() { + // Use getType() which falls back to logType when "type" is not in properties. + // This handles image deserialization of old resource-backed catalogs whose + // properties never contained "type" (it was derived from the Resource object). + String catalogType = getType(); + // Build the context up front and stash it so the catalog can close its cached engine FileSystem on + // teardown (onClose / connector replacement). The connector — and any sibling it builds — shares this + // one context instance, so there is a single cached FS per catalog. + DefaultConnectorContext context = new DefaultConnectorContext(name, id, this::getExecutionAuthenticator, + () -> catalogProperty.getStoragePropertiesMap(), + catalogProperty::getEffectiveRawStorageProperties); + this.connectorContext = context; + return ConnectorFactory.createConnector(catalogType, catalogProperty.getProperties(), context); + } + + @Override + public void checkProperties() throws DdlException { + super.checkProperties(); + String catalogType = getType(); + try { + ConnectorFactory.validateProperties(catalogType, catalogProperty.getProperties()); + } catch (IllegalArgumentException e) { + throw new DdlException(e.getMessage()); + } + // Validate function_rules JSON if present (shared across all connector types). + String functionRules = catalogProperty.getOrDefault("function_rules", null); + ExternalFunctionRules.check(functionRules); + } + + @Override + public void checkWhenCreating() throws DdlException { + // Let the connector perform its type-specific pre-creation validation + // (e.g., JDBC driver security, checksum computation). + DefaultConnectorValidationContext validationCtx = + new DefaultConnectorValidationContext(getId(), catalogProperty); + try { + connector.preCreateValidation(validationCtx); + } catch (DdlException e) { + throw e; + } catch (Exception e) { + throw new DdlException(e.getMessage(), e); + } + + boolean testConnection = Boolean.parseBoolean( + catalogProperty.getOrDefault(ExternalCatalog.TEST_CONNECTION, + String.valueOf(connector.defaultTestConnection()))); + if (!testConnection) { + return; + } + // Delegate FE→external connectivity testing to the connector SPI. + ConnectorSession session = buildConnectorSession(); + ConnectorTestResult result = connector.testConnection(session); + if (!result.isSuccess()) { + throw new DdlException("Connectivity test failed for catalog '" + + name + "': " + result.getMessage()); + } + LOG.info("Connectivity test passed for plugin-driven catalog '{}': {}", name, result); + + // Execute any BE→external connectivity test the connector registered. + validationCtx.executePendingBeTests(); + } + + /** + * Handles catalog property updates. Delegates to the parent which resets + * caches, sets objectCreated=false, and calls onClose() to release the + * current connector. The next makeSureInitialized() call will trigger + * initLocalObjectsImpl() which creates a new connector with the updated + * properties and proper engine context (auth, etc.). + * + *

    This follows the same lifecycle pattern as all other ExternalCatalog + * subclasses: reset → lazy re-initialization on next access.

    + */ + @Override + public void notifyPropertiesUpdated(Map updatedProps) { + super.notifyPropertiesUpdated(updatedProps); + } + + /** + * {@code REFRESH CATALOG} must also drop the connector's OWN caches (e.g. the iceberg latest-snapshot + * cache, default TTL 24h, and the manifest cache). The base {@link #onRefreshCache} only invalidates the + * registered engine caches via the route resolver — which for a plugin catalog resolves to the schema-only + * {@code ENGINE_DEFAULT} bucket and never reaches the connector-owned caches. And {@code REFRESH CATALOG} + * does NOT rebuild the connector (that only happens on {@code ADD}/{@code MODIFY CATALOG} via + * {@link #resetToUninitialized}), so without this the connector keeps serving stale metadata until TTL. + * + *

    Connector-agnostic: {@link Connector#invalidateAll()} is a generic SPI (no-op default; paimon clears + * its own latest-snapshot cache too). Reads the {@code connector} field directly (no forced init, mirroring + * {@link #overlayMetaCacheConfig}): an uninitialized catalog — or one whose connector was just nulled by + * {@code resetToUninitialized}'s {@code onClose()} before this runs — has no connector caches to drop, and + * the next access lazily rebuilds the connector with empty caches. + */ + @Override + public void onRefreshCache(boolean invalidCache) { + super.onRefreshCache(invalidCache); + if (invalidCache) { + Connector localConnector = connector; + if (localConnector != null) { + localConnector.invalidateAll(); + } + } + } + + @Override + protected List listDatabaseNames() { + try { + ConnectorSession session = buildCrossStatementSession(); + return PluginDrivenMetadata.get(session, connector).listDatabaseNames(session); + } catch (RuntimeException e) { + // The connector connects lazily: initLocalObjectsImpl() only constructs it, so the + // first metastore round-trip happens here — inside the meta-cache loader, which runs + // OUTSIDE makeSureInitialized()'s try/catch. Capture the failure so `show catalogs` + // surfaces it; makeSureInitialized() clears errorMsg again on the next successful + // (re-)initialization (e.g. after `alter catalog ... set properties`). This stays + // connector-agnostic: any plugin that connects lazily gets the same treatment. + recordDeferredInitError(e); + throw e; + } + } + + @Override + protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { + ConnectorSession session = buildCrossStatementSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + List tableNames = metadata.listTableNames(session, dbName); + if (!connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_VIEW)) { + return tableNames; + } + // Mirror legacy IcebergExternalCatalog.listTableNamesFromRemote: for a view-exposing connector + // (iceberg) SHOW TABLES includes both tables AND views, because the connector's listTableNames + // subtracts the view names. Re-merge the connector's view names here (the two sets are disjoint + // by construction, so a plain addAll cannot introduce duplicates). + List viewNames = metadata.listViewNames(session, dbName); + if (viewNames.isEmpty()) { + return tableNames; + } + List merged = new ArrayList<>(tableNames); + merged.addAll(viewNames); + return merged; + } + + @Override + public boolean tableExist(SessionContext ctx, String dbName, String tblName) { + ConnectorSession session = buildConnectorSession(); + return PluginDrivenMetadata.get(session, connector) + .getTableHandle(session, dbName, tblName).isPresent(); + } + + @Override + public String getType() { + // Return the actual catalog type (e.g., "es", "jdbc") from properties, + // not the internal "plugin" logType. + return catalogProperty.getOrDefault(CatalogMgr.CATALOG_TYPE_PROP, super.getType()); + } + + /** Returns the underlying SPI connector. Ensures the catalog is initialized first. */ + public Connector getConnector() { + makeSureInitialized(); + return connector; + } + + /** + * 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 + * {@code HMSExternalCatalog} so a flipped (generic) catalog no longer throws + * {@code NotImplementedException} on a create/rename-database event. The body is fully generic + * (buildDbForInit + metaCache, name-derived id) and mirrors the legacy HMS implementation. + */ + @Override + public void registerDatabase(long dbId, String dbName) { + ExternalDatabase db = buildDbForInit(dbName, null, dbId, logType, false); + if (isInitialized()) { + metaCache.updateCache(db.getRemoteName(), db.getFullName(), db, + Util.genIdByName(name, db.getFullName())); + } + } + + /** + * FIX-4: let the connector's own cache knob also govern the schema cache (restoring the legacy single-knob + * semantics — e.g. paimon's {@code meta.cache.paimon.table.ttl-second} sized the whole table cache, schema + * included). Applied to the engine's EPHEMERAL cache-sizing property copy only (never persisted). An + * explicit user {@code schema.cache.ttl-second} wins. Uses the {@code connector} field directly (no forced + * init / no throw): this hook only runs during a cache read, by which point the catalog is already + * initialized; a null connector (uninitialized or concurrently dropped) simply leaves the engine default. + */ + @Override + public void overlayMetaCacheConfig(Map metaCacheProperties) { + if (metaCacheProperties.containsKey(SCHEMA_CACHE_TTL_SECOND)) { + return; + } + Connector localConnector = connector; + if (localConnector == null) { + return; + } + OptionalLong override = localConnector.schemaCacheTtlSecondOverride(); + if (override.isPresent()) { + metaCacheProperties.put(SCHEMA_CACHE_TTL_SECOND, String.valueOf(override.getAsLong())); + } + } + + /** + * Routes {@code CREATE TABLE} through the SPI's + * {@code ConnectorTableOps.createTable(session, request)} instead of the + * legacy {@code metadataOps} path used by other {@link ExternalCatalog} + * subclasses. + * + *

    Connectors that have not overridden the new SPI default fall through + * to the SPI's "CREATE TABLE not supported" exception, which is wrapped + * here as a {@link DdlException} to match the existing caller contract.

    + * + *

    The SPI {@code createTable} is {@code void} and this override has no + * {@code metadataOps}, so it mirrors legacy + * {@code MaxComputeMetadataOps.createTableImpl}: when the table already exists + * and {@code IF NOT EXISTS} was given it returns {@code true} and skips the + * connector create + edit log + cache reset (so a {@code CREATE TABLE IF NOT + * EXISTS ... AS SELECT} short-circuits per the {@code Env.createTable} contract + * instead of INSERTing into the existing table); otherwise it creates the table, + * writes the edit log, resets the cache, and returns {@code false}.

    + */ + @Override + public boolean createTable(CreateTableInfo createTableInfo) throws UserException { + makeSureInitialized(); + // Resolve the local db name to its remote (ODPS) name before handing it to the connector, + // mirroring legacy MaxComputeMetadataOps.createTableImpl (db.getRemoteName()). Without this, + // name-mapped catalogs (lower_case_meta_names / meta_names_mapping, where the local display + // name differs from the remote name) would address the wrong remote schema. The table name + // is intentionally NOT remote-resolved (legacy parity: the table does not exist yet, so + // there is no local->remote mapping for it). + ExternalDatabase db = getDbNullable(createTableInfo.getDbName()); + if (db == null) { + throw new DdlException("Failed to get database: '" + createTableInfo.getDbName() + + "' in catalog: " + getName()); + } + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + // Mirror legacy MaxComputeMetadataOps.createTableImpl:178-197 -- probe BOTH the remote + // (connector) and the local FE cache for an existing table. On IF NOT EXISTS this lets CTAS + // short-circuit (Env.createTable contract: return true when the table already exists), so a + // "CREATE TABLE IF NOT EXISTS ... AS SELECT" does NOT fall through to an INSERT into the + // pre-existing table. The table name is intentionally NOT remote-resolved (legacy parity). + boolean remoteExists = metadata.getTableHandle(session, db.getRemoteName(), + createTableInfo.getTableName()).isPresent(); + boolean localExists = db.getTableNullable(createTableInfo.getTableName()) != null; + if (remoteExists || localExists) { + if (createTableInfo.isIfNotExists()) { + LOG.info("create table[{}.{}.{}] which already exists; skipping (IF NOT EXISTS)", + getName(), createTableInfo.getDbName(), createTableInfo.getTableName()); + return true; + } + // !IF NOT EXISTS: a table that already exists -- whether remotely (connector) OR only in the + // local FE cache (a case-variant name folded onto an existing table under lower_case_meta_names + // while the case-sensitive remote has no such table) -- must be rejected HERE with MySQL errno + // 1050 (ERR_TABLE_EXISTS_ERROR / SQLSTATE 42S01). Mirrors legacy {Paimon,MaxCompute}MetadataOps, + // which report ERR_TABLE_EXISTS_ERROR for BOTH the remote arm (PaimonMetadataOps:195 / + // MaxComputeMetadataOps:184) and the local arm (:212 / :195). Reporting before + // metadata.createTable also keeps a local-cache-only conflict from being CREATED remotely + // (the connector would otherwise create a duplicate). Reaching here already guarantees + // (remoteExists || localExists) && !isIfNotExists; reportDdlException throws. + ErrorReport.reportDdlException(ErrorCode.ERR_TABLE_EXISTS_ERROR, + createTableInfo.getTableName()); + } + ConnectorCreateTableRequest request = CreateTableInfoToConnectorRequestConverter + .convert(createTableInfo, db.getRemoteName()); + try { + metadata.createTable(session, request); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + // Drop any stale connector-owned cache entry for this name before the new table goes live + // (belt-and-suspenders with the DROP path, which is the load-bearing invalidation for drop+recreate). + // Connector-agnostic: invalidateTable is a no-op SPI default; hive/iceberg/paimon drop their own + // per-table caches (metastore/file-listing, latest-snapshot pin). The table name is intentionally NOT + // remote-resolved (a new table has no local->remote mapping — parity with the create request + editlog). + connector.invalidateTable(db.getRemoteName(), createTableInfo.getTableName()); + org.apache.doris.persist.CreateTableInfo persistInfo = + new org.apache.doris.persist.CreateTableInfo( + getName(), + createTableInfo.getDbName(), + createTableInfo.getTableName()); + Env.getCurrentEnv().getEditLog().logCreateTable(persistInfo); + // Invalidate the FE-side table-name cache so the new table is immediately visible on + // this FE. The legacy metadataOps path did this via afterCreateTable(); since + // PluginDrivenExternalCatalog has no metadataOps, the override must do it here. + // (Edit log and cache invalidation deliberately use the LOCAL db/table names for + // follower-replay consistency; only the connector-bound name is remote-resolved.) + getDbForReplay(createTableInfo.getDbName()).ifPresent(d -> d.resetMetaCacheNames()); + LOG.info("finished to create table {}.{}.{}", getName(), + createTableInfo.getDbName(), createTableInfo.getTableName()); + return false; + } + + /** + * Routes {@code CREATE DATABASE} through the SPI's + * {@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 + * {@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).

    + */ + @Override + public void createDb(String dbName, boolean ifNotExists, Map properties) throws DdlException { + makeSureInitialized(); + // Fast path: FE-cache hit + IF NOT EXISTS => no-op (legacy createDbImpl: dorisDb != null). + if (ifNotExists && getDbNullable(dbName) != null) { + return; + } + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + // FE-cache miss but the db may already exist REMOTELY (created on another FE / before this + // FE's db-name cache was populated). Legacy MaxComputeMetadataOps.createDbImpl consulted + // BOTH getDbNullable AND the remote databaseExist, and IF NOT EXISTS then no-oped. Mirror + // 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)) { + LOG.info("create database[{}] which already exists remotely, skip", dbName); + return; + } + try { + metadata.createDatabase(session, dbName, properties); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + Env.getCurrentEnv().getEditLog().logCreateDb(new CreateDbInfo(getName(), dbName, null)); + resetMetaCacheNames(); + LOG.info("finished to create database {}.{}", getName(), dbName); + } + + /** + * Routes {@code DROP DATABASE} through the SPI's + * {@code ConnectorSchemaOps.dropDatabase(session, dbName, ifExists)}. + * + *

    {@code force} is forwarded to the connector, which performs the table + * cascade (mirroring legacy {@code MaxComputeMetadataOps.dropDbImpl}; ODPS + * {@code schemas().delete()} does not auto-cascade). On success it writes the + * edit log and unregisters the database from the cache (mirroring the legacy + * {@code metadataOps.afterDropDb()}); legacy emits no per-table editlog for the + * cascaded tables, so the single {@code logDropDb} + {@code unregisterDatabase} + * below is the complete legacy db-level FE bookkeeping.

    + */ + @Override + public void dropDb(String dbName, boolean ifExists, boolean force) throws DdlException { + makeSureInitialized(); + // Resolve the local db name to its remote name before handing it to the connector, mirroring + // the sibling dropTable / legacy IcebergMetadataOps.performDropDb (dorisDb.getRemoteName()). + // Name-mapped catalogs (lower_case_meta_names / meta_names_mapping, where the local display + // name differs from the remote name) would otherwise address the wrong remote namespace. + ExternalDatabase db = getDbNullable(dbName); + if (db == null) { + if (ifExists) { + return; + } + throw new DdlException("Failed to get database: '" + dbName + "' in catalog: " + getName()); + } + ConnectorSession session = buildConnectorSession(); + try { + PluginDrivenMetadata.get(session, connector).dropDatabase(session, db.getRemoteName(), ifExists, force); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + // Drop the connector's own caches for every table in this db so a subsequent same-name CREATE + // DATABASE and the next reads go live rather than serving dropped tables up to the connector TTL. + // Connector-agnostic (no-op SPI default); keyed by the REMOTE db name, mirroring + // RefreshManager.refreshDbInternal. (createDb is intentionally NOT hooked: a brand-new db has no + // table-keyed connector entries that this dropDb did not already clear.) + connector.invalidateDb(db.getRemoteName()); + // Edit log + cache invalidation intentionally use the LOCAL name: followers replay the + // persisted DropDbInfo and the on-FE cache is keyed by local name (follower-replay parity). + Env.getCurrentEnv().getEditLog().logDropDb(new DropDbInfo(getName(), dbName)); + unregisterDatabase(dbName); + LOG.info("finished to drop database {}.{}", getName(), dbName); + } + + /** + * Routes {@code DROP TABLE} through the SPI's + * {@code ConnectorTableOps.dropTable(session, handle)}. + * + *

    The SPI takes a {@link ConnectorTableHandle} and carries no {@code ifExists}; + * this override resolves the handle first (absent = table does not exist) and + * enforces {@code IF EXISTS} FE-side. On success it writes the edit log and + * unregisters the table from the cache (mirroring {@code metadataOps.afterDropTable()}).

    + */ + @Override + public void dropTable(String dbName, String tableName, boolean isView, boolean isMtmv, boolean isStream, + boolean ifExists, boolean mustTemporary, boolean force) throws DdlException { + makeSureInitialized(); + // Resolve the local db/table names to their remote (ODPS) names before handing them to the + // connector, mirroring base ExternalCatalog.dropTable -- the exact path legacy + // MaxComputeMetadataOps.dropTableImpl ran through, which used dorisTable.getRemoteDbName() / + // getRemoteName(). Without this, name-mapped catalogs would locate the wrong remote table + // (IF EXISTS silently no-ops / non-IF-EXISTS wrongly reports "not found"). Matching base: + // a missing db ALWAYS throws (even with IF EXISTS); a missing table honors IF EXISTS. + ExternalDatabase db = getDbNullable(dbName); + if (db == null) { + throw new DdlException("Failed to get database: '" + dbName + "' in catalog: " + getName()); + } + ExternalTable dorisTable = db.getTableNullable(tableName); + if (dorisTable == null) { + if (ifExists) { + return; + } + throw new DdlException("Failed to get table: '" + tableName + "' in database: " + dbName); + } + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + // Route a DROP on a VIEW to dropView, mirroring legacy IcebergMetadataOps.dropTableImpl's + // viewExists -> performDropView dispatch: a connector that exposes views keeps them in a separate + // namespace, so getTableHandle/tableExists below is false for a view and the table-handle path + // could never drop it. For view-less connectors viewExists defaults to false (no remote call), so + // this routing is inert and the table path runs unchanged. The edit log + cache invalidation use + // the LOCAL names (follower-replay parity), identical to the table path. + if (metadata.viewExists(session, dorisTable.getRemoteDbName(), dorisTable.getRemoteName())) { + try { + metadata.dropView(session, dorisTable.getRemoteDbName(), dorisTable.getRemoteName()); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + // Uniform with the table branch: drop the connector's own caches for this name (harmless no-op + // for a view, which carries no snapshot pin). Keyed by the REMOTE names. + connector.invalidateTable(dorisTable.getRemoteDbName(), dorisTable.getRemoteName()); + Env.getCurrentEnv().getEditLog().logDropTable(new DropInfo(getName(), dbName, tableName)); + getDbForReplay(dbName).ifPresent(d -> d.unregisterTable(tableName)); + LOG.info("finished to drop view {}.{}.{}", getName(), dbName, tableName); + return; + } + Optional handle = metadata.getTableHandle( + session, dorisTable.getRemoteDbName(), dorisTable.getRemoteName()); + // The table is present in the FE cache but may have been dropped out-of-band on the remote + // side; preserve the existing IF EXISTS handling for that case. + if (!handle.isPresent()) { + if (ifExists) { + return; + } + throw new DdlException("Failed to get table: '" + tableName + "' in database: " + dbName); + } + try { + metadata.dropTable(session, handle.get()); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + // Drop the connector's own caches for this table (paimon/iceberg latest-snapshot pin, hive + // metastore + file-listing) so a subsequent same-name CREATE and the next read go live rather than + // serving the dropped table up to the connector TTL — the load-bearing fix for drop+recreate. + // Connector-agnostic (no-op SPI default); keyed by the REMOTE db/table names the connector caches + // under, mirroring RefreshManager.refreshTableInternal. + connector.invalidateTable(dorisTable.getRemoteDbName(), dorisTable.getRemoteName()); + // Edit log and cache invalidation deliberately use the LOCAL db/table names for + // follower-replay consistency; only the connector-bound names are remote-resolved. + Env.getCurrentEnv().getEditLog().logDropTable(new DropInfo(getName(), dbName, tableName)); + getDbForReplay(dbName).ifPresent(d -> d.unregisterTable(tableName)); + LOG.info("finished to drop table {}.{}.{}", getName(), dbName, tableName); + } + + /** + * Routes {@code ALTER TABLE ... RENAME} through the SPI's {@code ConnectorTableOps.renameTable} instead of + * the base {@link ExternalCatalog#renameTable} (which throws on {@code metadataOps == null}). + * + *

    Resolves the SOURCE table by REMOTE names (like {@link #dropTable}); {@code newTableName} is passed + * through as the target's name in the same remote database, mirroring legacy + * {@code IcebergMetadataOps.renameTableImpl} (which feeds the SQL name straight to + * {@code catalog.renameTable}) and createTable (which keeps the SQL name as the remote name). On success + * runs {@link #afterExternalRename} for the cache fix + constraint rename + editlog the base op delegated + * to {@code metadataOps}.

    + */ + @Override + public void renameTable(String dbName, String oldTableName, String newTableName) throws DdlException { + makeSureInitialized(); + ExternalDatabase db = getDbNullable(dbName); + if (db == null) { + throw new DdlException("Failed to get database: '" + dbName + "' in catalog: " + getName()); + } + ExternalTable dorisTable = db.getTableNullable(oldTableName); + if (dorisTable == null) { + throw new DdlException("Failed to get table: '" + oldTableName + "' in database: " + dbName); + } + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorTableHandle handle = resolveAlterHandle(dorisTable, session, metadata); + try { + metadata.renameTable(session, handle, newTableName); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + // R4: drop the connector's OWN caches for BOTH the source and target names so an atomic swap + // (RENAME t->t_arch; RENAME t_new->t) doesn't serve the pre-rename pinned snapshot under either name — + // afterExternalRename only fixes the FE name cache. Same drop+recreate class the dropTable hook covers. + // Connector-agnostic (no-op SPI default); the source is keyed by its resolved REMOTE names, the target + // by the new name in the same remote db (parity with createTable: a rename target has no prior + // local->remote mapping). Followers propagate this via RefreshManager.replayRefreshTable's rename branch. + connector.invalidateTable(dorisTable.getRemoteDbName(), dorisTable.getRemoteName()); + connector.invalidateTable(dorisTable.getRemoteDbName(), newTableName); + afterExternalRename(dbName, oldTableName, newTableName); + } + + /** + * Routes {@code TRUNCATE TABLE} through the SPI's {@code ConnectorTableOps.truncateTable(session, handle, + * partitions)} instead of the base {@link ExternalCatalog#truncateTable} (which throws on + * {@code metadataOps == null}). + * + *

    Resolves the table by REMOTE names for the connector (like {@link #dropTable}); {@code partitions} is + * {@code null} for a whole-table truncate or the named partitions otherwise. On success it emits the same + * {@link TruncateTableInfo} edit log the base op writes and refreshes the local table cache (mirroring legacy + * {@code HiveMetadataOps.afterTruncateTable -> RefreshManager.refreshTableInternal}); followers refresh via + * {@link #replayTruncateTable}. {@code forceDrop} / {@code rawTruncateSql} carry no external semantics (the + * connector truncates the remote table directly) and are ignored, matching the legacy path.

    + */ + @Override + public void truncateTable(String dbName, String tableName, PartitionNamesInfo partitionNamesInfo, + boolean forceDrop, String rawTruncateSql) throws DdlException { + makeSureInitialized(); + ExternalDatabase db = getDbNullable(dbName); + if (db == null) { + throw new DdlException("Failed to get database: '" + dbName + "' in catalog: " + getName()); + } + ExternalTable dorisTable = db.getTableNullable(tableName); + if (dorisTable == null) { + throw new DdlException("Failed to get table: '" + tableName + "' in database: " + dbName); + } + List partitions = partitionNamesInfo == null ? null : partitionNamesInfo.getPartitionNames(); + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorTableHandle handle = resolveAlterHandle(dorisTable, session, metadata); + try { + metadata.truncateTable(session, handle, partitions); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + long updateTime = System.currentTimeMillis(); + // Cache refresh + edit log use the LOCAL db/table names for follower-replay parity (only the + // connector-bound handle is remote-resolved), mirroring base ExternalCatalog.truncateTable. + Env.getCurrentEnv().getRefreshManager().refreshTableInternal(db, dorisTable, updateTime); + Env.getCurrentEnv().getEditLog().logTruncateTable( + new TruncateTableInfo(getName(), dbName, tableName, partitions, updateTime)); + LOG.info("finished to truncate table {}.{}.{}", getName(), dbName, tableName); + } + + /** + * Refreshes the local table cache on edit-log replay of a connector-driven truncate. The base + * {@link ExternalCatalog#replayTruncateTable} delegates to {@code metadataOps.afterTruncateTable}, which is a + * no-op for PluginDriven ({@code metadataOps == null}); this override re-resolves the cached table by the + * replayed LOCAL names and runs {@code refreshTableInternal} (the same effect the master path applied), + * mirroring legacy {@code HiveMetadataOps.afterTruncateTable}. + */ + @Override + public void replayTruncateTable(TruncateTableInfo info) { + getDbForReplay(info.getDb()).ifPresent(db -> + db.getTableForReplay(info.getTable()).ifPresent(tbl -> + Env.getCurrentEnv().getRefreshManager().refreshTableInternal(db, tbl, info.getUpdateTime()))); + } + + /** + * Propagates the coordinator {@link #dropTable} hook's connector-cache invalidation to followers/observers + * on edit-log replay. The base {@link ExternalCatalog#replayDropTable} plugin branch only touches the FE + * name cache ({@code unregisterTable}); without this, a follower that had queried a paimon/iceberg table + * keeps its latest-snapshot pin (and paimon's schema memo) for the dropped name until the 24h access-TTL — + * the coordinator-only half of the drop+recreate fix. Resolves the REMOTE names from the still-cached + * table BEFORE the base unregisters it, keyed exactly like the coordinator (mirrors + * {@code RefreshManager.replayRefreshTable → refreshTableInternal}'s connector hook). + * + *

    Never force-initializes during replay: {@code getConnector()} runs only inside the + * {@code getDbForReplay}/{@code getTableForReplay} match, which is present only when this catalog is + * already initialized on this FE (both return empty otherwise). A never-initialized catalog has no + * connector cache to drop, so skipping it is correct — mirroring {@code HiveConnector.forEachBuiltSibling} + * ("a never-built sibling has no cache") and preserving the base's no-force-init replay behavior. + */ + @Override + public void replayDropTable(String dbName, String tblName) { + getDbForReplay(dbName).ifPresent(db -> + db.getTableForReplay(tblName).ifPresent(tbl -> + getConnector().invalidateTable(db.getRemoteName(), tbl.getRemoteName()))); + super.replayDropTable(dbName, tblName); + } + + /** + * Replay analogue of the coordinator {@link #dropDb} hook's connector-cache invalidation — clears every + * table's connector cache for the dropped database on followers/observers (the base + * {@link ExternalCatalog#replayDropDb} plugin branch only unregisters the FE db). Resolves the REMOTE db + * name BEFORE the base unregisters the database. See {@link #replayDropTable} for the no-force-init + * rationale. + */ + @Override + public void replayDropDb(String dbName) { + getDbForReplay(dbName).ifPresent(db -> getConnector().invalidateDb(db.getRemoteName())); + super.replayDropDb(dbName); + } + + /** + * Replay analogue of the coordinator {@link #createTable} hook's belt-and-suspenders connector-cache + * invalidation (uniform with the drop path). The table name is NOT remote-resolved — parity with the + * coordinator, where a brand-new table has no local→remote mapping. See {@link #replayDropTable} for the + * no-force-init rationale. + */ + @Override + public void replayCreateTable(String dbName, String tblName) { + super.replayCreateTable(dbName, tblName); + getDbForReplay(dbName).ifPresent(db -> getConnector().invalidateTable(db.getRemoteName(), tblName)); + } + + /** + * Routes {@code ALTER TABLE ... ADD/DROP/RENAME/MODIFY/REORDER COLUMN} through the SPI's + * {@code ConnectorTableOps} column-evolution methods instead of the legacy {@code metadataOps} path used + * by other {@link ExternalCatalog} subclasses (which PluginDriven never sets, so the base ops would + * throw {@code metadataOps == null}). + * + *

    Each override resolves the connector handle (by REMOTE names, like {@link #dropTable}), converts the + * Doris {@link Column}/{@link ColumnPosition} to the neutral SPI types, dispatches, wraps a + * {@link DorisConnectorException} as a {@link DdlException}, and runs {@link #afterExternalDdl} for the + * editlog + cache invalidation the base op delegated to {@code metadataOps}.

    + */ + @Override + public void addColumn(TableIf dorisTable, Column column, ColumnPosition position) throws UserException { + ExternalTable externalTable = checkExternalTable(dorisTable); + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); + long updateTime = System.currentTimeMillis(); + try { + metadata.addColumn(session, handle, ConnectorColumnConverter.toConnectorColumn(column), + toConnectorPosition(position)); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + afterExternalDdl(externalTable, updateTime); + } + + @Override + public void addColumns(TableIf dorisTable, List columns) throws UserException { + ExternalTable externalTable = checkExternalTable(dorisTable); + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); + long updateTime = System.currentTimeMillis(); + try { + metadata.addColumns(session, handle, ConnectorColumnConverter.toConnectorColumns(columns)); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + afterExternalDdl(externalTable, updateTime); + } + + @Override + public void dropColumn(TableIf dorisTable, String columnName) throws UserException { + ExternalTable externalTable = checkExternalTable(dorisTable); + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); + long updateTime = System.currentTimeMillis(); + try { + metadata.dropColumn(session, handle, columnName); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + afterExternalDdl(externalTable, updateTime); + } + + @Override + public void renameColumn(TableIf dorisTable, String oldName, String newName) throws UserException { + ExternalTable externalTable = checkExternalTable(dorisTable); + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); + long updateTime = System.currentTimeMillis(); + try { + metadata.renameColumn(session, handle, oldName, newName); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + afterExternalDdl(externalTable, updateTime); + } + + @Override + public void modifyColumn(TableIf dorisTable, Column column, ColumnPosition position) throws UserException { + ExternalTable externalTable = checkExternalTable(dorisTable); + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); + long updateTime = System.currentTimeMillis(); + try { + metadata.modifyColumn(session, handle, ConnectorColumnConverter.toConnectorColumn(column), + toConnectorPosition(position)); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + afterExternalDdl(externalTable, updateTime); + } + + @Override + public void reorderColumns(TableIf dorisTable, List newOrder) throws UserException { + ExternalTable externalTable = checkExternalTable(dorisTable); + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); + long updateTime = System.currentTimeMillis(); + try { + metadata.reorderColumns(session, handle, newOrder); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + afterExternalDdl(externalTable, updateTime); + } + + /** + * Routes {@code ALTER TABLE ... CREATE/REPLACE/DROP BRANCH/TAG} through the SPI's {@code ConnectorTableOps} + * branch/tag methods instead of the legacy {@code metadataOps} path (which PluginDriven never sets, so the + * base ops throw {@code metadataOps == null}). + * + *

    Each override resolves the connector handle (by REMOTE names, like {@link #dropTable}), neutralizes the + * nereids info type to the SPI carrier ({@link ConnectorBranchTagConverter}), dispatches, wraps a + * {@link DorisConnectorException} as a {@link DdlException}, and runs {@link #afterExternalDdl} for the + * editlog + cache invalidation the base op delegated to {@code metadataOps}. A branch/tag op is a + * table-level change whose cache effect ({@code refreshTableInternal}) is identical to a column evolution, so + * the column-op bookkeeping helper is reused (the base {@code OP_BRANCH_OR_TAG} editlog's replay is + * {@code metadataOps}-gated and would be a no-op for PluginDriven; the replay-neutral + * {@code OP_REFRESH_EXTERNAL_TABLE} that {@code afterExternalDdl} emits yields the same refresh on + * followers).

    + */ + @Override + public void createOrReplaceBranch(TableIf dorisTable, CreateOrReplaceBranchInfo branchInfo) + throws UserException { + ExternalTable externalTable = checkExternalTable(dorisTable); + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); + long updateTime = System.currentTimeMillis(); + try { + metadata.createOrReplaceBranch(session, handle, + ConnectorBranchTagConverter.toBranchChange(branchInfo)); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + afterExternalDdl(externalTable, updateTime); + } + + @Override + public void createOrReplaceTag(TableIf dorisTable, CreateOrReplaceTagInfo tagInfo) throws UserException { + ExternalTable externalTable = checkExternalTable(dorisTable); + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); + long updateTime = System.currentTimeMillis(); + try { + metadata.createOrReplaceTag(session, handle, + ConnectorBranchTagConverter.toTagChange(tagInfo)); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + afterExternalDdl(externalTable, updateTime); + } + + @Override + public void dropBranch(TableIf dorisTable, DropBranchInfo branchInfo) throws UserException { + ExternalTable externalTable = checkExternalTable(dorisTable); + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); + long updateTime = System.currentTimeMillis(); + try { + metadata.dropBranch(session, handle, + ConnectorBranchTagConverter.toDropRefChange(branchInfo)); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + afterExternalDdl(externalTable, updateTime); + } + + @Override + public void dropTag(TableIf dorisTable, DropTagInfo tagInfo) throws UserException { + ExternalTable externalTable = checkExternalTable(dorisTable); + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); + long updateTime = System.currentTimeMillis(); + try { + metadata.dropTag(session, handle, + ConnectorBranchTagConverter.toDropRefChange(tagInfo)); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + afterExternalDdl(externalTable, updateTime); + } + + /** + * Routes {@code ALTER TABLE ... ADD/DROP/REPLACE PARTITION KEY} (Iceberg partition evolution) through the + * SPI's {@code ConnectorTableOps} partition-field methods, replacing the legacy {@code Alter.java} + * {@code instanceof IcebergExternalTable} dispatch. Each override resolves the connector handle (by REMOTE + * names, like {@link #dropTable}), neutralizes the nereids op to {@link PartitionFieldChange} via + * {@link ConnectorPartitionFieldConverter}, dispatches, wraps a {@link DorisConnectorException} as a + * {@link DdlException}, and runs {@link #afterExternalDdl} for the editlog + cache invalidation (a partition + * spec change is a table-level change whose {@code refreshTableInternal} effect matches a column evolution). + */ + @Override + public void addPartitionField(TableIf dorisTable, AddPartitionFieldOp op) throws UserException { + ExternalTable externalTable = checkExternalTable(dorisTable); + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); + long updateTime = System.currentTimeMillis(); + try { + metadata.addPartitionField(session, handle, ConnectorPartitionFieldConverter.toAddChange(op)); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + afterExternalDdl(externalTable, updateTime); + } + + @Override + public void dropPartitionField(TableIf dorisTable, DropPartitionFieldOp op) throws UserException { + ExternalTable externalTable = checkExternalTable(dorisTable); + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); + long updateTime = System.currentTimeMillis(); + try { + metadata.dropPartitionField(session, handle, ConnectorPartitionFieldConverter.toDropChange(op)); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + afterExternalDdl(externalTable, updateTime); + } + + @Override + public void replacePartitionField(TableIf dorisTable, ReplacePartitionFieldOp op) throws UserException { + ExternalTable externalTable = checkExternalTable(dorisTable); + ConnectorSession session = buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); + long updateTime = System.currentTimeMillis(); + try { + metadata.replacePartitionField(session, handle, ConnectorPartitionFieldConverter.toReplaceChange(op)); + } catch (DorisConnectorException e) { + throw new DdlException(e.getMessage(), e); + } + afterExternalDdl(externalTable, updateTime); + } + + /** Initializes + checks the table is an {@link ExternalTable}, mirroring the base {@link ExternalCatalog}. */ + private ExternalTable checkExternalTable(TableIf dorisTable) { + makeSureInitialized(); + Preconditions.checkState(dorisTable instanceof ExternalTable, dorisTable.getName()); + return (ExternalTable) dorisTable; + } + + /** + * Resolves the connector handle for an ALTER by the table's REMOTE names (mirroring {@link #dropTable}), + * failing loud as a {@link DdlException} when the table no longer exists remotely. + */ + private ConnectorTableHandle resolveAlterHandle(ExternalTable externalTable, ConnectorSession session, + ConnectorMetadata metadata) throws DdlException { + Optional handle = metadata.getTableHandle( + session, externalTable.getRemoteDbName(), externalTable.getRemoteName()); + if (!handle.isPresent()) { + throw new DdlException("Failed to get table: '" + externalTable.getName() + + "' in database: " + externalTable.getDbName()); + } + return handle.get(); + } + + /** Neutralizes the fe-catalog {@link ColumnPosition} to the SPI {@link ConnectorColumnPosition}; null-safe. */ + private static ConnectorColumnPosition toConnectorPosition(ColumnPosition position) { + if (position == null) { + return null; + } + return position.isFirst() + ? ConnectorColumnPosition.FIRST + : ConnectorColumnPosition.after(position.getLastCol()); + } + + /** + * Replays the base {@link ExternalCatalog} per-op bookkeeping for a connector-driven schema change. + * + *

    The base column ops only emit the editlog ({@code logRefreshExternalTable}); the actual cache + * invalidation is delegated INTO {@code metadataOps.refreshTable -> RefreshManager.refreshTableInternal}. + * Since PluginDrivenExternalCatalog has no {@code metadataOps}, this helper does BOTH explicitly: the + * {@code createForRefreshTable} editlog (LOCAL names, replay-neutral) and a {@code refreshTableInternal} + * (re-resolving the local cached table by its REMOTE names, mirroring legacy + * {@code IcebergMetadataOps.refreshTable}). {@code refreshTableInternal} is the single source of truth for + * the cache work ({@code unsetObjectCreated} + {@code setUpdateTime} + {@code invalidateTableCache} + the + * connector-side per-table cache drop), so it must NOT be re-inlined here. + */ + protected void afterExternalDdl(ExternalTable externalTable, long updateTime) { + Env.getCurrentEnv().getEditLog().logRefreshExternalTable( + ExternalObjectLog.createForRefreshTable(getId(), + externalTable.getDbName(), externalTable.getName(), updateTime)); + getDbForReplay(externalTable.getRemoteDbName()).ifPresent(db -> + db.getTableForReplay(externalTable.getRemoteName()).ifPresent(tbl -> + Env.getCurrentEnv().getRefreshManager().refreshTableInternal(db, tbl, updateTime))); + } + + /** + * Replays the base {@link ExternalCatalog#renameTable} bookkeeping for a connector-driven rename, since + * PluginDriven has no {@code metadataOps}: the table-name cache fix ({@code unregisterTable(old)} + + * {@code resetMetaCacheNames()}, mirroring legacy {@code IcebergMetadataOps.afterRenameTable}), the + * {@code constraintManager} rename, and the {@code createForRenameTable} editlog (whose replay, + * {@code RefreshManager.replayRefreshTable}, is already metadataOps-neutral). All use LOCAL names, matching + * the base op + the editlog payload, so followers replay consistently. Order mirrors the base op + * (cache → constraint → editlog). + */ + protected void afterExternalRename(String dbName, String oldTableName, String newTableName) { + getDbForReplay(dbName).ifPresent(db -> { + db.unregisterTable(oldTableName); + db.resetMetaCacheNames(); + }); + Env.getCurrentEnv().getConstraintManager().renameTable( + new TableNameInfo(getName(), dbName, oldTableName), + new TableNameInfo(getName(), dbName, newTableName)); + Env.getCurrentEnv().getEditLog().logRefreshExternalTable( + ExternalObjectLog.createForRenameTable(getId(), dbName, oldTableName, newTableName)); + } + + @Override + public String fromRemoteDatabaseName(String remoteDatabaseName) { + ConnectorSession session = buildCrossStatementSession(); + return PluginDrivenMetadata.get(session, connector).fromRemoteDatabaseName(session, remoteDatabaseName); + } + + @Override + public String fromRemoteTableName(String remoteDatabaseName, String remoteTableName) { + ConnectorSession session = buildCrossStatementSession(); + return PluginDrivenMetadata.get(session, connector) + .fromRemoteTableName(session, remoteDatabaseName, remoteTableName); + } + + /** + * Builds a {@link ConnectorSession} from the current thread's {@link ConnectContext}. + */ + public ConnectorSession buildConnectorSession() { + ConnectContext ctx = ConnectContext.get(); + if (ctx != null) { + // Interactive path: inject the user's delegated credential when the connector opts in + // (SUPPORTS_USER_SESSION). The credential rides the session and is consumed connector-side. + return ConnectorSessionBuilder.from(ctx) + .withCatalogId(getId()) + .withCatalogName(getName()) + .withCatalogProperties(catalogProperty.getProperties()) + .withUserSessionCapability(supportsUserSession()) + .build(); + } + // Background/internal path (no ConnectContext): never carries a delegated credential — a + // session=user connector then fails closed on interactive callers and gets no borrowed identity here. + return ConnectorSessionBuilder.create() + .withCatalogId(getId()) + .withCatalogName(getName()) + .withCatalogProperties(catalogProperty.getProperties()) + .build(); + } + + /** + * Builds a {@link ConnectorSession} for a CROSS-STATEMENT background loader — one that fills a cache + * living longer than any single statement (database/table name caches, schema cache, column-statistic + * cache, row-count cache, the BE-driven metadata TVF). Identical to {@link #buildConnectorSession()} + * (same credential handling) except the per-statement scope is forced to + * {@link ConnectorStatementScope#NONE}. That makes the read-through a contract rather than an accident: + * a metadata resolved through {@link PluginDrivenMetadata#get} with this session is built fresh and never + * memoized into — nor closed with — some live statement's scope, even when the loader happens to run on a + * request/ANALYZE thread that has one (e.g. {@code fetchRowCount} reached synchronously from + * {@code AnalysisManager.buildAnalysisJobInfo}). Under NONE the funnel memoizes nothing, so this is + * byte-identical to a bare {@code getMetadata} call. + */ + public ConnectorSession buildCrossStatementSession() { + ConnectContext ctx = ConnectContext.get(); + if (ctx != null) { + return ConnectorSessionBuilder.from(ctx) + .withCatalogId(getId()) + .withCatalogName(getName()) + .withCatalogProperties(catalogProperty.getProperties()) + .withUserSessionCapability(supportsUserSession()) + .withStatementScope(ConnectorStatementScope.NONE) + .build(); + } + return ConnectorSessionBuilder.create() + .withCatalogId(getId()) + .withCatalogName(getName()) + .withCatalogProperties(catalogProperty.getProperties()) + .withStatementScope(ConnectorStatementScope.NONE) + .build(); + } + + /** + * Whether the backing connector projects the querying user's delegated credential onto the remote + * metadata source ({@link ConnectorCapability#SUPPORTS_USER_SESSION}), gating both the FE credential + * injection above and the shared-cache bypass ({@link #shouldBypassTableNameCache}). + */ + private boolean supportsUserSession() { + return connector != null + && connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_USER_SESSION); + } + + /** + * Under a {@link ConnectorCapability#SUPPORTS_USER_SESSION} connector carrying a per-request delegated + * credential, the remote source returns PER-USER table metadata, so the shared (catalog+name-keyed, NOT + * user-keyed) table-name cache must be bypassed — otherwise one user's REST-authorized/vended table set + * would be served to another (cross-user leakage). A session with no credential keeps the shared cache; + * the fail-closed rejection then happens connector-side on the actual metadata read, never here. + */ + @Override + protected boolean shouldBypassTableNameCache(SessionContext ctx) { + return supportsUserSession() && ctx != null && ctx.hasDelegatedCredential(); + } + + /** + * Db-level analog of {@link #shouldBypassTableNameCache}: under a session=user connector with a per-request + * credential the remote source returns PER-USER databases, so the shared db-name cache is bypassed to avoid + * leaking one user's visible database set to another (O2). Same capability + credential gate. + */ + @Override + protected boolean shouldBypassDbNameCache(SessionContext ctx) { + return supportsUserSession() && ctx != null && ctx.hasDelegatedCredential(); + } + + /** + * Schema-level analog of {@link #shouldBypassTableNameCache}: under a session=user connector with a per-request + * credential the remote {@code loadTable} returns PER-USER schema (and authorizes per user), so the shared + * name-keyed schema cache is bypassed to avoid serving one user's schema to another who could list but not + * load the table (the "list != load" disclosure). Same capability + credential gate; a session with no + * credential keeps the shared cache and the fail-closed rejection happens connector-side. + */ + @Override + protected boolean shouldBypassSchemaCache(SessionContext ctx) { + return supportsUserSession() && ctx != null && ctx.hasDelegatedCredential(); + } + + @Override + protected ExternalDatabase buildDbForInit(String remoteDbName, String localDbName, + long dbId, InitCatalogLog.Type logType, boolean checkExists) { + // Always use PLUGIN logType regardless of what was serialized (e.g., ES from migration). + return super.buildDbForInit(remoteDbName, localDbName, dbId, InitCatalogLog.Type.PLUGIN, checkExists); + } + + @Override + public void gsonPostProcess() throws IOException { + super.gsonPostProcess(); + // For old resource-backed catalogs (e.g., ES, JDBC), the "type" property was never + // persisted — it was derived from the Resource object at runtime. After image + // deserialization with registerCompatibleSubtype, those catalogs land here as + // PluginDrivenExternalCatalog with logType still set to the original value (ES/JDBC). + // Backfill "type" from logType before we overwrite it below, so that + // createConnectorFromProperties() and getType() can resolve the catalog type. + if (logType != null && logType != InitCatalogLog.Type.PLUGIN + && logType != InitCatalogLog.Type.UNKNOWN) { + String oldType = legacyLogTypeToCatalogType(logType); + if (catalogProperty.getOrDefault(CatalogMgr.CATALOG_TYPE_PROP, "").isEmpty()) { + LOG.info("Backfilling missing 'type' property for catalog '{}' from logType: {}", + name, oldType); + catalogProperty.addProperty(CatalogMgr.CATALOG_TYPE_PROP, oldType); + } + } + // After deserializing a migrated old catalog (e.g., ES → PluginDriven), fix logType + // so that buildDbForInit uses PLUGIN path. + if (logType != InitCatalogLog.Type.PLUGIN) { + LOG.info("Migrating catalog '{}' logType from {} to PLUGIN", name, logType); + logType = InitCatalogLog.Type.PLUGIN; + } + } + + // CatalogFactory type strings don't all match Type.name().toLowerCase(): + // TRINO_CONNECTOR → "trino-connector" (hyphen), not "trino_connector". + // Add cases here whenever a connector's CatalogFactory key diverges from + // the lowercase enum name. + // MAX_COMPUTE needs no case: the default branch yields "max_compute", which + // already matches its CatalogFactory key — do not add a redundant case. + private static String legacyLogTypeToCatalogType(InitCatalogLog.Type logType) { + switch (logType) { + case TRINO_CONNECTOR: + return "trino-connector"; + default: + return logType.name().toLowerCase(Locale.ROOT); + } + } + + private void closeConnectorContextQuietly(DefaultConnectorContext context) { + if (context == null) { + return; + } + try { + context.close(); + } catch (IOException e) { + LOG.warn("Failed to close connector context filesystem for catalog {}", name, e); + } + } + + @Override + public void onClose() { + super.onClose(); + if (connector != null) { + try { + connector.close(); + } catch (IOException e) { + LOG.warn("Failed to close connector for catalog {}", name, e); + } + connector = null; + } + // Close the shared context's cached engine FileSystem AFTER the connector(s) release their borrowed + // reference to it. No-op when no FS was ever built (e.g. non-hive plugin catalogs never call + // getFileSystem()). + DefaultConnectorContext contextToClose = connectorContext; + connectorContext = null; + closeConnectorContextQuietly(contextToClose); + } +} 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 new file mode 100644 index 00000000000000..324d72ab886d95 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalDatabase.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.datasource.plugin; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.connector.api.ConnectorDatabaseMetadata; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.datasource.ExternalCatalog; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.log.InitDatabaseLog; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable; + +/** + * Generic {@link ExternalDatabase} for plugin-driven catalogs. + * + *

    Provides minimal implementation that delegates table construction + * to {@link PluginDrivenExternalTable}.

    + */ +public class PluginDrivenExternalDatabase extends ExternalDatabase { + + /** No-arg constructor for GSON deserialization. */ + public PluginDrivenExternalDatabase() { + super(null, 0, null, null, InitDatabaseLog.Type.PLUGIN); + } + + public PluginDrivenExternalDatabase(ExternalCatalog extCatalog, long id, + String name, String remoteName) { + super(extCatalog, id, name, remoteName, InitDatabaseLog.Type.PLUGIN); + } + + @Override + protected PluginDrivenExternalTable buildTableInternal(String remoteTableName, + String localTableName, long tblId, ExternalCatalog catalog, ExternalDatabase db) { + // 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); + } + } + return new PluginDrivenExternalTable(tblId, localTableName, remoteTableName, catalog, db); + } + + /** + * The database (namespace) base location for the SHOW CREATE DATABASE {@code LOCATION '...'} clause, + * fetched through the connector's {@code getDatabase} SPI (Trino-aligned properties-map, the + * {@code location} key). Returns "" when the connector exposes no namespace location (the default + * {@code getDatabase} returns an empty property map), so SHOW CREATE DATABASE renders no LOCATION for + * connectors without a database-level location — matching their pre-flip behavior. + */ + public String getLocation() { + if (!(extCatalog instanceof PluginDrivenExternalCatalog)) { + return ""; + } + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) extCatalog; + Connector connector = pluginCatalog.getConnector(); + if (connector == null) { + return ""; + } + ConnectorSession session = pluginCatalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorDatabaseMetadata dbMetadata = metadata.getDatabase(session, getRemoteName()); + return dbMetadata.getProperties().getOrDefault(ConnectorDatabaseMetadata.LOCATION_PROPERTY, ""); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java new file mode 100644 index 00000000000000..8df8e69eb10009 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java @@ -0,0 +1,1319 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.Env; +import org.apache.doris.catalog.PartitionItem; +import org.apache.doris.catalog.TableIf.TableType; +import org.apache.doris.catalog.Type; +import org.apache.doris.common.util.DebugPointUtil; +import org.apache.doris.common.util.Util; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorColumnStatistics; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorPartitionInfo; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorTableSchema; +import org.apache.doris.connector.api.ConnectorTableStatistics; +import org.apache.doris.connector.api.ConnectorViewDefinition; +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.mvcc.ConnectorMvccSnapshot; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; +import org.apache.doris.datasource.ExternalCatalog; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.SchemaCacheValue; +import org.apache.doris.datasource.TablePartitionValues; +import org.apache.doris.datasource.connector.converter.ConnectorColumnConverter; +import org.apache.doris.datasource.mvcc.MvccSnapshot; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccSnapshot; +import org.apache.doris.datasource.systable.PartitionsSysTable; +import org.apache.doris.datasource.systable.PluginDrivenSysTable; +import org.apache.doris.datasource.systable.SysTable; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.GlobalVariable; +import org.apache.doris.statistics.AnalysisInfo; +import org.apache.doris.statistics.BaseAnalysisTask; +import org.apache.doris.statistics.ColumnStatistic; +import org.apache.doris.statistics.ColumnStatisticBuilder; +import org.apache.doris.statistics.ExternalAnalysisTask; +import org.apache.doris.statistics.PluginDrivenSampleAnalysisTask; +import org.apache.doris.thrift.TTableDescriptor; +import org.apache.doris.thrift.TTableType; + +import com.google.common.collect.Maps; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Generic {@link ExternalTable} for plugin-driven catalogs. + * + *

    Provides table implementation that fetches schema from the connector SPI. + * Connector-specific behavior is accessed through the parent catalog's + * {@link org.apache.doris.connector.api.Connector} using opaque handles.

    + */ +public class PluginDrivenExternalTable extends ExternalTable { + + private static final Logger LOG = LogManager.getLogger(PluginDrivenExternalTable.class); + + /** + * Whether this table is actually a view, resolved once from the connector in + * {@link #makeSureInitialized()} (gated on {@link #supportsView()}) and recomputed after GSON replay + * (the {@code objectCreated} reset). Mirrors legacy {@code IcebergExternalTable.isView}; derived + * metadata, not persisted. + */ + private boolean isView; + + /** No-arg constructor for GSON deserialization. */ + public PluginDrivenExternalTable() { + } + + public PluginDrivenExternalTable(long id, String name, String remoteName, + ExternalCatalog catalog, ExternalDatabase db) { + super(id, name, remoteName, catalog, db, TableType.PLUGIN_EXTERNAL_TABLE); + } + + /** + * Single seam for acquiring this table's {@link ConnectorTableHandle}. The base class resolves + * the handle for its own remote name; {@link PluginDrivenSysExternalTable} overrides this to + * thread a system-table handle through {@code initSchema}/{@code getNameToPartitionItems}/ + * {@code fetchRowCount} without duplicating the metadata round-trip in each site. + */ + public Optional resolveConnectorTableHandle( + ConnectorSession session, ConnectorMetadata metadata) { + String dbName = db != null ? db.getRemoteName() : ""; + return metadata.getTableHandle(session, dbName, getRemoteName()); + } + + /** + * Resolves this table's write-target {@link ConnectorTableHandle} for the plugin-driven insert executor's + * per-handle transaction selection, failing loud if it cannot be resolved. A heterogeneous gateway needs + * the handle to open the SIBLING connector's transaction for a foreign (iceberg-on-HMS) table; a + * single-format connector ignores it (its {@code beginTransaction} defaults to the connector-level one), so + * this is byte-identical for it. Fails loud rather than returning null: a null handle is not an + * {@code instanceof} the gateway's own handle type and would misroute a plain write to the sibling. + */ + public ConnectorTableHandle resolveWriteTargetHandle() { + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + ConnectorSession session = pluginCatalog.buildConnectorSession(); + return resolveConnectorTableHandle(session, PluginDrivenMetadata.get(session, pluginCatalog.getConnector())) + .orElseThrow(() -> new DorisConnectorException( + "Cannot resolve the connector table handle for write target " + getName())); + } + + /** + * Returns the connector's synthetic scan predicates for this table at the given resolved MVCC + * {@code snapshot} — a connector "residual predicate" the read cannot enforce by file selection alone + * (e.g. a hudi incremental {@code _hoodie_commit_time} commit-time window), expressed in the neutral + * {@link ConnectorExpression} grammar. The analysis-time synthetic-predicate rule reverse-converts these + * into a {@code LogicalFilter} over this table's scan. + * + *

    The {@code snapshot} is the one {@link org.apache.doris.datasource.mvcc.MvccTable#loadSnapshot} + * resolved at analysis time (retrieved from {@code StatementContext}), so the row-filter window is the + * SAME single resolution the scan-time {@code applySnapshot} threads onto the handle — file selection and + * the row filter can never diverge. Returns empty when the snapshot is not a plugin MVCC snapshot, the + * handle cannot be resolved, or the connector has no residual predicate (iceberg/paimon/... and every + * non-incremental read inherit the empty SPI default) — so the plan stays byte-identical.

    + */ + public List getSyntheticScanPredicates(MvccSnapshot snapshot) { + if (!(snapshot instanceof PluginDrivenMvccSnapshot) || !(catalog instanceof PluginDrivenExternalCatalog)) { + return Collections.emptyList(); + } + ConnectorMvccSnapshot connectorSnapshot = ((PluginDrivenMvccSnapshot) snapshot).getConnectorSnapshot(); + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + ConnectorSession session = pluginCatalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, pluginCatalog.getConnector()); + Optional handleOpt = resolveConnectorTableHandle(session, metadata); + if (!handleOpt.isPresent()) { + return Collections.emptyList(); + } + return metadata.getSyntheticScanPredicates(session, handleOpt.get(), connectorSnapshot); + } + + /** + * Returns whether the underlying connector supports multiple concurrent writers. + * Used by the planner to decide GATHER (single writer) vs parallel distribution. + */ + public boolean supportsParallelWrite() { + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + return false; + } + Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); + // 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(); + } + + /** + * Resolves this table's connector handle for a per-handle write-capability probe, or empty on any miss (a + * null connector, or an unresolvable handle). A heterogeneous gateway needs the handle to answer write + * 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. + */ + private Optional resolveWriteCapabilityHandle(Connector connector) { + ConnectorSession session = ((PluginDrivenExternalCatalog) catalog).buildConnectorSession(); + return resolveConnectorTableHandle(session, PluginDrivenMetadata.get(session, connector)); + } + + /** + * The write operations the connector admits for THIS table, resolved per-handle so a heterogeneous gateway + * admits DELETE/MERGE/OVERWRITE for its iceberg tables but only INSERT/OVERWRITE for its hive tables. + * Degrades to the empty set (all writes rejected) on any miss, mirroring {@link #fetchSyntheticWriteColumns()}. + */ + public Set connectorSupportedWriteOperations() { + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + return EnumSet.noneOf(WriteOperation.class); + } + Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); + if (connector == null) { + return EnumSet.noneOf(WriteOperation.class); + } + return resolveWriteCapabilityHandle(connector) + .map(connector::supportedWriteOperations) + .orElseGet(() -> EnumSet.noneOf(WriteOperation.class)); + } + + /** + * Whether the connector admits branch writes for THIS table, resolved per-handle (iceberg supports + * write-to-branch, hive does not). Degrades to false on any miss. + */ + public boolean connectorSupportsWriteBranch() { + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + return false; + } + Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); + if (connector == null) { + return false; + } + return resolveWriteCapabilityHandle(connector) + .map(connector::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 + * 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 + * per-table for its plain-hive tables and reflects the iceberg sibling's connector-wide set onto an + * iceberg-on-HMS table's delegated schema — so hudi-on-HMS, whose connector declares neither, is correctly + * withheld. Mirrors {@link #supportsTopNLazyMaterialize} / {@link #supportsNestedColumnPrune}. + */ + public boolean supportsColumnAutoAnalyze() { + return hasScanCapability(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 + * 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); + } + + /** + * Returns whether THIS table supports nested-column pruning (reading only the accessed STRUCT/ARRAY/MAP + * 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 + * 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); + } + + /** + * Returns whether THIS table exposes a connector metadata table (e.g. the hudi commit timeline) queryable via + * the {@code hudi_meta()} / TIMELINE TVF. Resolved per-table via {@link #hasScanCapability}: a uniform + * connector (hudi) declares it connector-wide, and the hive gateway reflects it onto a hudi-on-HMS table's + * schema so the TVF keeps serving it after the flip; a connector with no metadata table returns false and the + * TVF rejects the table. + */ + public boolean supportsMetadataTable() { + return hasScanCapability(ConnectorCapability.SUPPORTS_METADATA_TABLE); + } + + /** + * 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 + * {@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); + } + + /** + * 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. + */ + private boolean hasScanCapability(ConnectorCapability capability) { + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + return false; + } + Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); + 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; + } + + /** + * Returns whether the underlying connector's table properties are user-facing and safe to render in + * SHOW CREATE TABLE. The SHOW CREATE TABLE plugin-driven arm renders LOCATION + PROPERTIES (+ the + * pre-rendered PARTITION BY / ORDER BY clauses) only when this is true (in place of the legacy + * paimon-only engine-name gate, which doubled as the JDBC/ES credential-leak guard). + */ + public boolean supportsShowCreateDdl() { + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + return false; + } + Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); + return connector != null + && connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_SHOW_CREATE_DDL); + } + + /** + * Returns whether the underlying connector exposes views (declares {@code SUPPORTS_VIEW}). When true, + * {@link #isView()} resolves this table's view-ness from the connector ({@code viewExists}) and the + * catalog merges the connector's {@code listViewNames} back into {@code SHOW TABLES}. View-less + * connectors (jdbc/es) return false and keep every object a non-view. Mirror of the other capability + * helpers. + */ + public boolean supportsView() { + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + return false; + } + Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); + return connector != null + && connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_VIEW); + } + + /** + * Returns whether the underlying connector requires dynamic-partition writes to be + * hash-distributed by partition columns and locally sorted by them (e.g. MaxCompute Storage + * API). Used by {@code PhysicalConnectorTableSink} to require that distribution + sort for + * dynamic-partition writes; defaults to false so non-partitioned connectors are unaffected. + */ + public boolean requirePartitionLocalSortOnWrite() { + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + return false; + } + Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); + return connector != null && connector.requiresPartitionLocalSort(); + } + + /** + * Returns whether the underlying connector requires dynamic-partition writes to be hash-distributed by + * partition columns but not locally sorted (e.g. Hive: the file writer buffers a per-partition + * writer, so the hash alone keeps each partition on one instance without a sort). Used by + * {@code PhysicalConnectorTableSink} to require that hash distribution (no {@code MustLocalSortOrderSpec}) for + * dynamic-partition writes; a connector sets at most one of this and {@link #requirePartitionLocalSortOnWrite()}. + * Defaults to false so non-partitioned connectors are unaffected. + */ + public boolean requirePartitionHashOnWrite() { + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + return false; + } + Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); + if (connector == null) { + return false; + } + // Per-table: hive requires partition-hash writes but iceberg does not, so resolve the handle. + return resolveWriteCapabilityHandle(connector) + .map(connector::requiresPartitionHashWrite) + .orElse(false); + } + + /** + * Returns whether the underlying connector maps write data columns positionally against the full + * table schema (e.g. MaxCompute), requiring the sink to project rows to full-schema order with + * unmentioned columns filled. Name-mapped connectors (e.g. JDBC) return false and keep their data + * in user/cols order. Used by {@code BindSink.bindConnectorTableSink}; defaults to false. + */ + public boolean requiresFullSchemaWriteOrder() { + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + return false; + } + Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); + return connector != null && connector.requiresFullSchemaWriteOrder(); + } + + /** + * Returns whether the underlying connector's data files retain partition columns, so a static-partition + * write must materialize the PARTITION-clause literal into the data column instead of NULL-filling it + * (e.g. Iceberg). Connectors that strip partition columns and refill them from {@code + * static_partition_values} (e.g. MaxCompute) return false. Used by {@code BindSink.bindConnectorTableSink}; + * defaults to false. + */ + public boolean materializeStaticPartitionValues() { + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + return false; + } + Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); + if (connector == null) { + return false; + } + // Per-table: iceberg retains partition columns (materialize the PARTITION literal), hive does not. + return resolveWriteCapabilityHandle(connector) + .map(connector::requiresMaterializeStaticPartitionValues) + .orElse(false); + } + + @Override + public boolean supportsExternalMetadataPreload() { + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + return false; + } + // F11: gate async metadata pre-load on the connector-declared SUPPORTS_METADATA_PRELOAD capability + // (replacing the legacy engine-name "jdbc" string, per the iron rule). jdbc and iceberg both declare + // it; connectors not yet validated for concurrent pre-warming (e.g. ES) do not, and fall back to + // synchronous load at binding time. Pure planning/lock-latency optimization, no correctness effect. + Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); + return connector != null + && connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_METADATA_PRELOAD); + } + + @Override + public Optional initSchema() { + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + // Keep the JDBC schema delay debug point available for manual regression verification. + if ("jdbc".equalsIgnoreCase(pluginCatalog.getType()) + && DebugPointUtil.isEnable("PluginDrivenExternalTable.initSchema.sleep")) { + long sleepMs = DebugPointUtil.getDebugParamOrDefault( + "PluginDrivenExternalTable.initSchema.sleep", "sleepMs", 0L); + if (sleepMs > 0) { + LOG.info("debug point PluginDrivenExternalTable.initSchema.sleep hit for {}.{}, sleep {}ms", + db != null ? db.getRemoteName() : "", getRemoteName(), sleepMs); + try { + Thread.sleep(sleepMs); + } catch (InterruptedException ignore) { + Thread.currentThread().interrupt(); + } + } + } + Connector connector = pluginCatalog.getConnector(); + ConnectorSession session = pluginCatalog.buildCrossStatementSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + + String dbName = db != null ? db.getRemoteName() : ""; + String tableName = getRemoteName(); + if (isView()) { + // A connector view has no table handle (the SDK tableExists() is false for views); build the schema + // from the view definition's columns instead. Mirrors legacy IcebergUtils.loadViewSchemaCacheValue + // (icebergView.schema()). Gated on isView() => only view-supporting connectors (SUPPORTS_VIEW) reach + // here; view-less connectors (jdbc/paimon/maxcompute) keep isView()==false and skip this. + ConnectorViewDefinition viewDefinition = metadata.getViewDefinition(session, dbName, tableName); + ConnectorTableSchema viewSchema = new ConnectorTableSchema( + tableName, viewDefinition.getColumns(), null, Collections.emptyMap()); + return Optional.of(toSchemaCacheValue(metadata, session, dbName, tableName, viewSchema)); + } + Optional handleOpt = resolveConnectorTableHandle(session, metadata); + if (!handleOpt.isPresent()) { + LOG.warn("Table handle not found for plugin-driven table: {}.{}", dbName, tableName); + return Optional.empty(); + } + + ConnectorTableSchema tableSchema = metadata.getTableSchema(session, handleOpt.get()); + return Optional.of(toSchemaCacheValue(metadata, session, dbName, tableName, tableSchema)); + } + + /** + * Converts a connector {@link ConnectorTableSchema} into a {@link PluginDrivenSchemaCacheValue}: + * applies identifier mapping to the column names and derives the partition-column views from the + * {@code partition_columns} property. Shared by {@link #initSchema()} (latest schema) and the + * MVCC subclass (schema AS OF a pinned snapshot), so both produce byte-identical cache values. + */ + protected PluginDrivenSchemaCacheValue toSchemaCacheValue(ConnectorMetadata metadata, + ConnectorSession session, String dbName, String tableName, ConnectorTableSchema tableSchema) { + // Apply identifier mapping to column names (lowercase / explicit mapping) + List mappedColumns = new ArrayList<>(tableSchema.getColumns().size()); + for (ConnectorColumn col : tableSchema.getColumns()) { + String mappedName = metadata.fromRemoteColumnName(session, dbName, tableName, col.getName()); + if (!mappedName.equals(col.getName())) { + ConnectorColumn remapped = new ConnectorColumn(mappedName, col.getType(), + col.getComment(), col.isNullable(), col.getDefaultValue(), col.isKey()); + // Preserve the WITH_TIMEZONE marker across the name remap (the 6-arg ctor defaults it off) + // so DESC still shows the Extra marker for renamed/explicitly-mapped TZ columns. + if (col.isWithTimeZone()) { + remapped = remapped.withTimeZone(); + } + mappedColumns.add(remapped); + } else { + mappedColumns.add(col); + } + } + + List columns = ConnectorColumnConverter.convertColumns(mappedColumns); + + // Identify partition columns from the connector's reserved PARTITION_COLUMNS_KEY property (a CSV of + // RAW remote column names; producer: hive/hudi/iceberg/paimon/maxcompute). We keep two aligned + // views: the Doris Columns (with mapped/local names, used for getPartitionColumns + types) + // and the raw remote names (used to index the raw-keyed partition-value maps from the SPI). + // The columns themselves are already present in `columns` (the connector appends partition + // columns to the schema, mirroring legacy); here we only mark which ones are partitions. + List partitionColumns = new ArrayList<>(); + List partitionColumnRemoteNames = new ArrayList<>(); + String partColsProp = tableSchema.getProperties().get(ConnectorTableSchema.PARTITION_COLUMNS_KEY); + if (partColsProp != null && !partColsProp.isEmpty()) { + Map byName = Maps.newHashMapWithExpectedSize(columns.size()); + for (Column c : columns) { + byName.putIfAbsent(c.getName(), c); + } + for (String rawName : partColsProp.split(",")) { + rawName = rawName.trim(); + if (rawName.isEmpty()) { + continue; + } + String mappedName = metadata.fromRemoteColumnName(session, dbName, tableName, rawName); + Column col = byName.get(mappedName); + if (col != null) { + partitionColumns.add(col); + partitionColumnRemoteNames.add(rawName); + } + } + } + return new PluginDrivenSchemaCacheValue(columns, partitionColumns, partitionColumnRemoteNames, + tableSchema.getProperties()); + } + + @Override + protected synchronized void makeSureInitialized() { + super.makeSureInitialized(); + if (!objectCreated) { + objectCreated = true; + isView = resolveIsView(); + } + } + + @Override + public boolean isView() { + makeSureInitialized(); + return isView; + } + + /** + * Resolves whether this table is a view by consulting the connector ({@code viewExists}), mirroring + * legacy {@code IcebergExternalTable.makeSureInitialized -> catalog.viewExists}. Gated on + * {@link #supportsView()} so view-less connectors (jdbc/es/paimon/maxcompute) issue no remote call and + * stay {@code isView()==false}. The system-table subclass overrides this to a constant {@code false} + * (metadata tables like {@code $snapshots} are never views, and a {@code viewExists} on their synthetic + * name would be a wasted — possibly failing — round-trip). + */ + protected boolean resolveIsView() { + if (!supportsView()) { + return false; + } + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + Connector connector = pluginCatalog.getConnector(); + if (connector == null) { + return false; + } + ConnectorSession session = pluginCatalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + String dbName = db != null ? db.getRemoteName() : ""; + return metadata.viewExists(session, dbName, getRemoteName()); + } + + /** + * Returns the stored SQL text of this view, mirroring legacy {@code IcebergExternalTable.getViewText}. + * Issues one connector round-trip ({@code getViewDefinition}) — the same single remote load the legacy + * query path made — so {@code BindRelation} (and SHOW CREATE) can parse and analyze the view body. + * Callers gate on {@link #supportsView()} + {@link #isView()}; on a view-less connector the SPI default + * fails loud. (Legacy {@code getSqlDialect} is intentionally not ported — it has no caller; the view + * body is converted by the session dialect in {@code BindRelation.parseAndAnalyzeExternalView}, and the + * connector already uses the view's own dialect internally to pick the SQL representation.) + */ + public String getViewText() { + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + Connector connector = pluginCatalog.getConnector(); + ConnectorSession session = pluginCatalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + String dbName = db != null ? db.getRemoteName() : ""; + ConnectorViewDefinition definition = metadata.getViewDefinition(session, dbName, getRemoteName()); + return definition.getSql(); + } + + /** + * Renders the connector's native {@code SHOW CREATE TABLE} DDL (a fresh, cache-bypassing metastore read) for + * the SHOW CREATE TABLE command's connector arm. Mirrors {@link #getViewText}'s single connector round-trip + * (and, like it, is safe to call under the command's table read-lock — no {@code makeSureInitialized}). Returns + * {@link Optional#empty()} when the connector supplies no native DDL (iceberg/paimon/es/jdbc inherit the empty + * SPI default {@link ConnectorMetadata#renderShowCreateTableDdl}), or when the handle cannot be resolved — the + * command then falls through to the generic {@code Env.getDdlStmt} rendering unchanged. A native-rendering + * connector (hive) returns the full statement, fetched fresh so it reflects a just-applied external ALTER even + * while DESC serves a cached schema. + */ + public Optional getShowCreateTableDdl() { + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + Connector connector = pluginCatalog.getConnector(); + if (connector == null) { + return Optional.empty(); + } + ConnectorSession session = pluginCatalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + Optional handleOpt = resolveConnectorTableHandle(session, metadata); + if (!handleOpt.isPresent()) { + return Optional.empty(); + } + return metadata.renderShowCreateTableDdl(session, handleOpt.get()); + } + + @Override + public boolean isPartitionedTable() { + makeSureInitialized(); + return !getPartitionColumns().isEmpty(); + } + + @Override + public List getPartitionColumns(Optional snapshot) { + makeSureInitialized(); + // Resolve against the CALLER's pin, not the ambient context: a statement pinning this table at two + // versions cannot be disambiguated ambiently and would degrade to the LATEST partition columns. + return getSchemaCacheValue(snapshot) + .map(value -> ((PluginDrivenSchemaCacheValue) value).getPartitionColumns()) + .orElse(Collections.emptyList()); + } + + public List getPartitionColumns() { + makeSureInitialized(); + return getSchemaCacheValue() + .map(value -> ((PluginDrivenSchemaCacheValue) value).getPartitionColumns()) + .orElse(Collections.emptyList()); + } + + /** + * Opens the hidden-column gate while a row-level DML over THIS table is in flight, mirroring legacy + * {@code IcebergExternalTable.needInternalHiddenColumns}. The signal is the neutral per-table ctx flag + * the generic {@code RowLevelDmlCommand} sets (not an iceberg concept); a connector with no synthetic + * write columns appends nothing even with the gate open, so this stays correct for every connector type. + */ + @Override + protected boolean needInternalHiddenColumns() { + ConnectContext ctx = ConnectContext.get(); + return ctx != null && ctx.needsSyntheticWriteColForTable(getId()); + } + + /** + * Appends the connector's request-scoped synthetic write columns to the full schema when a write/DML + * over this table is in flight. The base schema (including any always-present hidden columns the + * connector declares through the schema cache, e.g. iceberg v3 row-lineage) comes from + * {@code super.getFullSchema()}; the request-scoped columns (e.g. iceberg's row-id STRUCT) are fetched + * live from the connector — they must not be cached — and appended only when the request gate is open: + * show-hidden, or the synthetic-write-column ctx flag set for this table during row-level DML. Mirrors + * legacy {@code IcebergExternalTable.getFullSchema}, but connector-agnostic (iron-law: no iceberg branch + * here) — a connector with no synthetic write columns (jdbc/es/paimon/maxcompute) keeps its byte-identical + * full schema. + */ + @Override + public List getFullSchema() { + return appendSyntheticWriteColumns(super.getFullSchema()); + } + + /** + * Same as {@link #getFullSchema()}, but the BASE schema is resolved AS OF {@code snapshot} (this + * reference's own pin). The synthetic write columns are request-scoped, not version-scoped, so they are + * appended identically for either form — only the base schema read is version-aware. + * + *

    Every arity of this method must go through {@link #appendSyntheticWriteColumns}. The plan + * path ({@code LogicalFileScan.computePluginDrivenOutput}) calls THIS one, and it must not lose the + * append: when it did, iceberg's row-id STRUCT vanished from the scan's output, breaking every + * row-level DML with "Unknown column '__DORIS_ICEBERG_ROWID_COL__'" and dropping the column from + * {@code SELECT *} under show-hidden. A new overload that reads the schema cache directly silently + * bypasses this append — the compiler cannot catch it, since these are overloads, not overrides.

    + */ + @Override + public List getFullSchema(Optional snapshot) { + return appendSyntheticWriteColumns(super.getFullSchema(snapshot)); + } + + private List appendSyntheticWriteColumns(List schema) { + if (schema == null || !(Util.showHiddenColumns() || needInternalHiddenColumns())) { + return schema; + } + List synthetic = fetchSyntheticWriteColumns(); + if (synthetic.isEmpty()) { + return schema; + } + List result = new ArrayList<>(schema); + result.addAll(ConnectorColumnConverter.convertColumns(synthetic)); + return result; + } + + /** + * Fetches the connector's declared synthetic write columns for this table, in engine-neutral form. + * Degrades to an empty list on any miss (non-plugin catalog, a read-only connector with no write-plan + * provider, or an unresolvable table handle) and never throws — schema resolution must not fail a query. + */ + private List fetchSyntheticWriteColumns() { + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + return Collections.emptyList(); + } + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + Connector connector = pluginCatalog.getConnector(); + if (connector == null) { + return Collections.emptyList(); + } + // Resolve the handle first so the write provider is selected per-table (a heterogeneous gateway routes + // iceberg-on-HMS to its sibling by the handle type); both null-degrade checks keep the empty fallback. + // Equivalent result for single-format connectors (getWritePlanProvider(handle) defaults to the no-arg + // one); this gated (show-hidden / row-level-DML) path resolves the handle before the provider-null check, + // so a read-only connector now resolves the handle here — a no-op for a write-capable connector, which + // resolved it regardless. + ConnectorSession session = pluginCatalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + Optional handleOpt = resolveConnectorTableHandle(session, metadata); + if (!handleOpt.isPresent()) { + return Collections.emptyList(); + } + ConnectorWritePlanProvider writePlanProvider = connector.getWritePlanProvider(handleOpt.get()); + if (writePlanProvider == null) { + return Collections.emptyList(); + } + return writePlanProvider.getSyntheticWriteColumns(session, handleOpt.get()); + } + + /** The raw connector-emitted table-property map (including FE-internal / render-hint keys). */ + private Map rawTableProperties() { + makeSureInitialized(); + return getSchemaCacheValue() + .map(value -> ((PluginDrivenSchemaCacheValue) value).getTableProperties()) + .orElse(Collections.emptyMap()); + } + + /** + * 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 + * 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. + */ + public Map getTableProperties() { + Map raw = rawTableProperties(); + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : raw.entrySet()) { + if (ConnectorTableSchema.RESERVED_CONTROL_KEYS.contains(entry.getKey())) { + continue; + } + result.put(entry.getKey(), entry.getValue()); + } + return result; + } + + /** + * The table location string for the SHOW CREATE TABLE {@code LOCATION '...'} clause. Reads the + * connector's {@code show.location} render-hint key, falling back to the user-facing {@code path} + * property (paimon carries its location there, and keeps it in PROPERTIES). Returns "" if neither + * is present. + */ + public String getShowLocation() { + Map raw = rawTableProperties(); + String location = raw.getOrDefault(ConnectorTableSchema.SHOW_LOCATION_KEY, ""); + return location.isEmpty() ? raw.getOrDefault("path", "") : location; + } + + /** The pre-rendered {@code PARTITION BY ...} clause for SHOW CREATE TABLE, or "" if none. */ + public String getShowPartitionClause() { + return rawTableProperties().getOrDefault(ConnectorTableSchema.SHOW_PARTITION_CLAUSE_KEY, ""); + } + + /** The pre-rendered {@code ORDER BY (...)} clause for SHOW CREATE TABLE, or "" if none. */ + public String getShowSortClause() { + return rawTableProperties().getOrDefault(ConnectorTableSchema.SHOW_SORT_CLAUSE_KEY, ""); + } + + @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: + // - 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. + // It must NOT be gated on `!getPartitionColumns().isEmpty()`: returning false for a + // non-partitioned table sends PruneFileScanPartition down its ELSE branch, which overwrites the + // selection with SelectedPartitions(0, {}, isPruned=true). PluginDrivenScanNode.getSplits() then + // reads that as "pruned to zero partitions" and short-circuits to no splits, so a filtered query + // over a non-partitioned table silently returns zero rows (data loss). See FIX-NONPART-PRUNE-DATALOSS. + return true; + } + + @Override + public Map getNameToPartitionItems(Optional snapshot) { + List partitionColumns = getPartitionColumns(snapshot); + if (partitionColumns.isEmpty()) { + return Collections.emptyMap(); + } + List remoteNames = getSchemaCacheValue(snapshot) + .map(value -> ((PluginDrivenSchemaCacheValue) value).getPartitionColumnRemoteNames()) + .orElse(Collections.emptyList()); + List types = partitionColumns.stream().map(Column::getType).collect(Collectors.toList()); + + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + Connector connector = pluginCatalog.getConnector(); + ConnectorSession session = pluginCatalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + Optional handleOpt = resolveConnectorTableHandle(session, metadata); + if (!handleOpt.isPresent()) { + return Collections.emptyMap(); + } + + // One round-trip, no FE-side partition-value cache (per CACHE-P1: the cutover lists + // partitions per query instead of maintaining a second-level cache). The connector returns + // each partition's display name plus a raw-keyed value map; we extract values in + // partition-column order via the cached remote names. + List partitions = + metadata.listPartitions(session, handleOpt.get(), Optional.empty()); + List partitionNames = new ArrayList<>(partitions.size()); + List> partitionValues = new ArrayList<>(partitions.size()); + for (ConnectorPartitionInfo partition : partitions) { + partitionNames.add(partition.getPartitionName()); + List values = new ArrayList<>(remoteNames.size()); + for (String remoteName : remoteNames) { + values.add(partition.getPartitionValues().get(remoteName)); + } + partitionValues.add(values); + } + + // Reuse TablePartitionValues so the PartitionItem construction (ListPartitionItem, + // isHive=false) is identical to legacy MaxComputeExternalMetaCache.loadPartitionValues, + // then invert id->item via id->name (mirroring MaxComputeExternalTable.getNameToPartitionItems). + TablePartitionValues tablePartitionValues = new TablePartitionValues(); + tablePartitionValues.addPartitions(partitionNames, partitionValues, types, + Collections.nCopies(partitionNames.size(), 0L)); + Map idToPartitionItem = tablePartitionValues.getIdToPartitionItem(); + Map idToNameMap = tablePartitionValues.getPartitionIdToNameMap(); + Map nameToPartitionItem = Maps.newHashMapWithExpectedSize(idToPartitionItem.size()); + for (Entry entry : idToPartitionItem.entrySet()) { + nameToPartitionItem.put(idToNameMap.get(entry.getKey()), entry.getValue()); + } + return nameToPartitionItem; + } + + /** + * Partition display-name -> per-column partition values (in partition-column order), sourced from the + * connector's {@code listPartitions} in one round-trip (no FE-side partition-value cache, per the + * cutover's connector-owned caching). Values are extracted by the cached remote names; a value absent + * from the connector's raw map is left {@code null} (the partition_values() TVF renders it as SQL NULL, + * mirroring the legacy HMS {@code HivePartitionValues.getNameToPartitionValues()}). Empty for a + * non-partitioned table or an unresolved handle. Partition order preserved via a {@link LinkedHashMap}. + * + *

    Deliberately separate from {@link #getNameToPartitionItems} (not a shared helper) so that live + * path stays byte- and cost-identical for paimon/iceberg — a shared name-keyed map would collapse the + * pathological duplicate-partition-name case that the parallel-list build there tolerates. + */ + public Map> getNameToPartitionValues(Optional snapshot) { + if (getPartitionColumns(snapshot).isEmpty()) { + return Collections.emptyMap(); + } + List remoteNames = getSchemaCacheValue(snapshot) + .map(value -> ((PluginDrivenSchemaCacheValue) value).getPartitionColumnRemoteNames()) + .orElse(Collections.emptyList()); + + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + Connector connector = pluginCatalog.getConnector(); + ConnectorSession session = pluginCatalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + Optional handleOpt = resolveConnectorTableHandle(session, metadata); + if (!handleOpt.isPresent()) { + return Collections.emptyMap(); + } + List partitions = + metadata.listPartitions(session, handleOpt.get(), Optional.empty()); + Map> nameToValues = Maps.newLinkedHashMap(); + for (ConnectorPartitionInfo partition : partitions) { + List values = new ArrayList<>(remoteNames.size()); + for (String remoteName : remoteNames) { + values.add(partition.getPartitionValues().get(remoteName)); + } + nameToValues.put(partition.getPartitionName(), values); + } + return nameToValues; + } + + /** + * Engine-neutral rows for a connector metadata table (the {@code hudi_meta()} / TIMELINE TVF), sourced from + * the connector's {@link ConnectorMetadata#getMetadataTableRows} in one round-trip. {@code kind} selects the + * metadata table (currently only {@code "timeline"}). Each returned row is the ordered String cell values the + * TVF renders (nulls -> SQL NULL). Empty for an unresolved handle. A NEW standalone method (like + * {@link #getNameToPartitionValues}) — byte/cost-invariant for paimon/iceberg, which never declare + * {@code SUPPORTS_METADATA_TABLE} and so never reach this via the capability-gated TVF arm. No TCCL pin here; + * the connector pins internally (bundled hudi timeline reflection). + */ + public List> getMetadataTableRows(String kind) { + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + return Collections.emptyList(); + } + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + Connector connector = pluginCatalog.getConnector(); + if (connector == null) { + return Collections.emptyList(); + } + ConnectorSession session = pluginCatalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + Optional handleOpt = resolveConnectorTableHandle(session, metadata); + if (!handleOpt.isPresent()) { + return Collections.emptyList(); + } + return metadata.getMetadataTableRows(session, handleOpt.get(), kind); + } + + @Override + public long getCachedRowCount() { + // Do NOT call makeSureInitialized() here. + // ExternalTable.getCachedRowCount() intentionally returns -1 for uninitialized tables + // so that SHOW TABLE STATUS / information_schema.tables stays non-blocking. + if (!isObjectCreated()) { + return -1; + } + return Env.getCurrentEnv().getExtMetaCacheMgr().getRowCountCache() + .getCachedRowCount(catalog.getId(), dbId, id, false); + } + + @Override + public String getComment() { + return getComment(false); + } + + @Override + public String getComment(boolean escapeQuota) { + String remoteDbName = db != null ? db.getRemoteName() : ""; + try { + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + Connector connector = pluginCatalog.getConnector(); + ConnectorSession session = pluginCatalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + String tableName = getRemoteName(); + String comment = metadata.getTableComment(session, remoteDbName, tableName); + if (escapeQuota && comment != null) { + return comment.replace("'", "\\'"); + } + return comment != null ? comment : ""; + } catch (Exception e) { + LOG.debug("Failed to get table comment for {}.{}", remoteDbName, name, e); + return ""; + } + } + + /** + * Exposes the connector's system tables (e.g. {@code tbl$snapshots}) through the live fe-core + * system-table machinery. Delegates name discovery to the connector SPI + * ({@link ConnectorMetadata#listSupportedSysTables}); each returned bare name (already lowercase) + * is wrapped in a {@link PluginDrivenSysTable} so {@link org.apache.doris.catalog.TableIf#findSysTable} + * resolves {@code tbl$name} and {@link org.apache.doris.datasource.systable.SysTableResolver} can + * build the transient sys ExternalTable. Mirrors the legacy no-cache getTableHandle pattern: the + * handle/name list is fetched per call (system-table planning is infrequent), so no extra caching. + */ + @Override + public Map getSupportedSysTables() { + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + return Collections.emptyMap(); + } + makeSureInitialized(); + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + Connector connector = pluginCatalog.getConnector(); + ConnectorSession session = pluginCatalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + Optional handleOpt = resolveConnectorTableHandle(session, metadata); + if (!handleOpt.isPresent()) { + return Collections.emptyMap(); + } + List names = metadata.listSupportedSysTables(session, handleOpt.get()); + if (names.isEmpty()) { + return Collections.emptyMap(); + } + // Keep keys exactly as returned by the connector (already lowercase) so the inherited, + // case-sensitive findSysTable exact-match works, mirroring legacy PaimonSysTable keys. + Map result = Maps.newHashMapWithExpectedSize(names.size()); + for (String sysName : names) { + if (metadata.isPartitionValuesSysTable(session, handleOpt.get(), sysName)) { + // Connector declares this name is served by the generic partition_values TVF (e.g. hive + // t$partitions), not a native scan. Key on the singleton's OWN name (== "partitions"): + // PartitionsSysTable strips its hard-wired "$partitions" suffix in createFunction, so a + // differing key would crash there; identical to sysName for hive today, strictly safer. + result.put(PartitionsSysTable.INSTANCE.getSysTableName(), PartitionsSysTable.INSTANCE); + } else { + result.put(sysName, new PluginDrivenSysTable(sysName)); + } + } + return Collections.unmodifiableMap(result); + } + + @Override + public void gsonPostProcess() throws IOException { + super.gsonPostProcess(); + // After deserializing a migrated old table (e.g., EsExternalTable → PluginDrivenExternalTable), + // fix the table type so that BindRelation routes to LogicalFileScan (new path). + if (type != TableType.PLUGIN_EXTERNAL_TABLE) { + LOG.info("Migrating table '{}' type from {} to PLUGIN_EXTERNAL_TABLE", name, type); + type = TableType.PLUGIN_EXTERNAL_TABLE; + } + } + + @Override + public BaseAnalysisTask createAnalysisTask(AnalysisInfo info) { + makeSureInitialized(); + if (supportsSampleAnalyze()) { + // A flipped plain-hive table keeps ANALYZE ... WITH SAMPLE working (ExternalAnalysisTask.doSample + // throws NotImplementedException). iceberg/paimon do NOT declare the capability, so they stay on the + // byte-identical ExternalAnalysisTask path — the extra check is one cached capability lookup. + return new PluginDrivenSampleAnalysisTask(info); + } + return new ExternalAnalysisTask(info); + } + + /** + * The query-planner column-statistics fast path (consulted by {@code ColumnStatisticsCacheLoader} on a + * stats-cache miss): asks the connector for the no-scan column stats it can serve cheaply and turns the raw + * facts into a Doris {@link ColumnStatistic}. Empty (fe-core falls back to a full ANALYZE) when the + * connector has no such stats. Mirrors legacy {@code HMSExternalTable.getHiveColumnStats} + + * {@code setStatData}: the connector returns raw ndv / numNulls / (string) avgColLen, and THIS side does the + * Doris-type-dependent size math the connector cannot (it must not import fe-type) — a string column's size + * is {@code round(avgColLen * count)}, every other type's is {@code count * }; min/max stay + * unconstrained (the {@link ColumnStatisticBuilder} defaults, i.e. legacy's NEGATIVE/POSITIVE_INFINITY). + */ + @Override + public Optional getColumnStatistic(String colName) { + makeSureInitialized(); + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + return Optional.empty(); + } + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + Connector connector = pluginCatalog.getConnector(); + if (connector == null) { + return Optional.empty(); + } + ConnectorSession session = pluginCatalog.buildCrossStatementSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + Optional handleOpt = resolveConnectorTableHandle(session, metadata); + if (!handleOpt.isPresent()) { + return Optional.empty(); + } + Optional statsOpt = + metadata.getColumnStatistics(session, handleOpt.get(), colName); + if (!statsOpt.isPresent()) { + return Optional.empty(); + } + return toColumnStatistic(statsOpt.get(), getColumn(colName)); + } + + /** + * The raw per-file byte sizes that {@code ANALYZE ... WITH SAMPLE} seed-shuffles and cumulates into a sample + * scale factor, from the connector's file listing (like legacy {@code HMSExternalTable.getChunkSizes}). The + * connector returns only the raw byte lengths; the Doris-type slot-width math stays fe-core-side in the sample + * task. Overrides {@link ExternalTable#getChunkSizes()} (which throws {@code NotImplementedException}); returns + * empty on any miss (non-plugin catalog / null connector / unresolved handle) so a connector that cannot list + * degrades the sampler to scale factor 1. Inert for iceberg/paimon — only reached from the sample task, which + * they never create. No TCCL pin here; the hive {@code listFileSizes} impl pins internally (parity with + * {@link #fetchRowCount()} / {@link #getColumnStatistic}). + */ + @Override + public List getChunkSizes() { + makeSureInitialized(); + if (!(catalog instanceof PluginDrivenExternalCatalog)) { + return Collections.emptyList(); + } + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + Connector connector = pluginCatalog.getConnector(); + if (connector == null) { + return Collections.emptyList(); + } + ConnectorSession session = pluginCatalog.buildCrossStatementSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + Optional handleOpt = resolveConnectorTableHandle(session, metadata); + if (!handleOpt.isPresent()) { + return Collections.emptyList(); + } + return metadata.listFileSizes(session, handleOpt.get()); + } + + /** + * The table's distribution (bucketing) column names, lowercased, from the connector's per-table + * {@code connector.distribution-columns} schema marker (read from the already-cached schema, no round-trip). + * Overrides the {@code TableIf} empty default so a flipped bucketed hive table matches legacy + * {@code HMSExternalTable.getDistributionColumnNames} (which lowercased on this side too). Empty for a + * non-bucketed table and for connectors that emit no marker (paimon/iceberg) — byte-invariant for them. Used by + * sampled ANALYZE to pick the linear-vs-DUJ1 NDV estimator. + */ + @Override + public Set getDistributionColumnNames() { + String csv = rawTableProperties().get(ConnectorTableSchema.DISTRIBUTION_COLUMNS_KEY); + if (csv == null || csv.isEmpty()) { + return Collections.emptySet(); + } + Set result = new HashSet<>(); + for (String name : csv.split(",")) { + String trimmed = name.trim(); + if (!trimmed.isEmpty()) { + result.add(trimmed.toLowerCase()); + } + } + return result; + } + + /** + * Turns the connector's raw {@link ConnectorColumnStatistics} into a Doris {@link ColumnStatistic}, + * doing the Doris-type-dependent size math the connector cannot (legacy {@code setStatData} parity): a + * string column (avgSizeBytes >= 0) sizes to {@code round(avgColLen * count)}, every other type to + * {@code count * }; {@code avgSizeByte = dataSize / count}; min/max stay at the builder's + * unconstrained defaults. Empty when the column is unknown or the row count is non-positive (no size + * basis, legacy returned empty). Package-private + static so the math can be unit-tested directly. + */ + static Optional toColumnStatistic(ConnectorColumnStatistics stats, Column column) { + long count = stats.getRowCount(); + if (column == null || count <= 0) { + return Optional.empty(); + } + double dataSize; + if (stats.getAvgSizeBytes() >= 0) { + dataSize = Math.round(stats.getAvgSizeBytes() * count); + } else { + // Long arithmetic (count * slotSize) exactly like legacy setStatData, then widened. + dataSize = count * column.getType().getSlotSize(); + } + ColumnStatisticBuilder builder = new ColumnStatisticBuilder(count); + builder.setNdv(stats.getNdv()); + builder.setNumNulls(stats.getNumNulls()); + builder.setDataSize(dataSize); + builder.setAvgSizeByte(dataSize / count); + return Optional.of(builder.build()); + } + + @Override + public long fetchRowCount() { + makeSureInitialized(); + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + Connector connector = pluginCatalog.getConnector(); + ConnectorSession session = pluginCatalog.buildCrossStatementSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + + Optional handleOpt = resolveConnectorTableHandle(session, metadata); + if (!handleOpt.isPresent()) { + return UNKNOWN_ROW_COUNT; + } + + Optional statsOpt = metadata.getTableStatistics(session, handleOpt.get()); + if (statsOpt.isPresent()) { + ConnectorTableStatistics stats = statsOpt.get(); + if (stats.getRowCount() >= 0) { + return stats.getRowCount(); + } + // The connector surfaced an on-disk data size but no exact row count (e.g. a hive table with + // totalSize set but no numRows). Estimate the cardinality as dataSize / — the Doris-type-dependent division the connector cannot perform (it must not import + // fe-type). Connector-agnostic: every other connector reports dataSize -1, so this branch is + // inert for them. Mirrors legacy StatisticsUtil.getHiveRowCount's totalSize/estimatedRowSize + // path (row width summed over the FULL schema, partition columns included, exactly as legacy). + // A quotient that truncates to 0 is NOT a valid "empty table" answer — legacy collapsed 0 -> + // UNKNOWN and fell through to the file-list estimate below, so only a positive quotient returns. + if (stats.getDataSize() > 0) { + long rowWidth = estimatedRowWidth(false); + if (rowWidth > 0) { + long rows = stats.getDataSize() / rowWidth; + if (rows > 0) { + return rows; + } + } + } + } + + // Neither an exact count nor a metastore-recorded size: estimate the on-disk data size by listing + // the table's data files (connector-provided; every non-file connector returns -1) and divide by the + // row width, this time EXCLUDING partition columns because their values live in the directory path, + // not the data files. Mirrors legacy HMSExternalTable.getRowCountFromFileList. Gated by the global + // feature flag because the listing can be a costly remote round-trip; the connector self-samples, + // pins its classloader, and degrades to -1 rather than throwing. + if (GlobalVariable.enable_get_row_count_from_file_list) { + long dataSize = metadata.estimateDataSizeByListingFiles(session, handleOpt.get()); + if (dataSize > 0) { + long rowWidth = estimatedRowWidth(true); + if (rowWidth > 0) { + // 0 -> UNKNOWN (legacy getRowCountFromFileList's `rows > 0 ? rows : UNKNOWN` gate). + long rows = dataSize / rowWidth; + if (rows > 0) { + return rows; + } + } + } + } + return UNKNOWN_ROW_COUNT; + } + + /** + * Sum of Doris slot sizes over the full schema (or over the non-partition columns when + * {@code excludePartitionColumns}) — the average uncompressed row width used to turn a connector-reported + * on-disk data size into an estimated row count. Mirrors the two legacy hive formulas: a metastore + * {@code totalSize} is divided by the FULL-schema width ({@code StatisticsUtil.getHiveRowCount}), whereas + * a file-listed size is divided by the width EXCLUDING partition columns (whose values are not stored in + * the data files, {@code HMSExternalTable.getRowCountFromFileList}). Returns 0 for an empty/unavailable + * schema, which {@link #fetchRowCount} treats as "cannot estimate" (-> UNKNOWN). + */ + private long estimatedRowWidth(boolean excludePartitionColumns) { + List schema = getFullSchema(); + if (schema == null) { + return 0; + } + List partitionColumns = excludePartitionColumns ? getPartitionColumns() : null; + long rowWidth = 0; + for (Column column : schema) { + if (partitionColumns != null && partitionColumns.contains(column)) { + continue; + } + rowWidth += column.getDataType().getSlotSize(); + } + return rowWidth; + } + + @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(); + } + } + + @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(); + } + } + + @Override + public TTableDescriptor toThrift() { + makeSureInitialized(); + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; + Connector connector = pluginCatalog.getConnector(); + ConnectorSession session = pluginCatalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + + String dbName = db != null ? db.getRemoteName() : ""; + List schema = getFullSchema(); + TTableDescriptor desc = metadata.buildTableDescriptor(session, + getId(), getName(), dbName, getRemoteName(), + schema.size(), pluginCatalog.getId()); + if (desc != null) { + return desc; + } + LOG.warn("Connector returned null table descriptor for plugin table {}.{}, " + + "using generic fallback", dbName, getName()); + return new TTableDescriptor(getId(), TTableType.SCHEMA_TABLE, + schema.size(), 0, getName(), dbName); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenMetadata.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenMetadata.java new file mode 100644 index 00000000000000..cd12453144836c --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenMetadata.java @@ -0,0 +1,72 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; + +import java.util.Objects; + +/** + * The single engine-side funnel through which every metadata acquisition in a statement flows, so that a + * statement uses exactly ONE {@link ConnectorMetadata} instance per catalog: it memoizes the result of + * {@code connector.getMetadata(session)} on the session's per-statement {@link ConnectorStatementScope} and hands + * every read / scan / DDL / MVCC resolver the same instance, closed deterministically at statement end. + * + *

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

    + * + *

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

    + */ +public final class PluginDrivenMetadata { + + private PluginDrivenMetadata() { + } + + /** + * Returns the statement's single memoized {@link ConnectorMetadata} for {@code connector}, building it via + * {@code connector.getMetadata(session)} on first use and reusing it for the rest of the statement. Keyed by + * the session's catalog id, so a cross-catalog statement resolves each catalog independently. + */ + public static ConnectorMetadata get(ConnectorSession session, Connector connector) { + ConnectorStatementScope scope = session.getStatementScope(); + long catalogId = session.getCatalogId(); + // A statement resolves a catalog under exactly one identity (one statement = one user = one credential). + // Now that the write arm reuses the read arm's memoized instance, and a session=user connector bakes the + // querying user's delegated credential into that instance at getMetadata time, reusing it under a second + // identity would execute one user's operation with another's credentials. Pin the building identity and + // fail loud if a different one ever reuses the instance, turning a silent cross-user leak into a hard + // error. Uses the Doris principal (getUser), never a credential token, so fe-core parses no credentials. + // Under NONE this stores nothing, so the check is vacuously true and the factory runs on every call. + String user = session.getUser(); + String builderUser = scope.computeIfAbsent("metadata-identity:" + catalogId, () -> user); + if (!Objects.equals(builderUser, user)) { + throw new IllegalStateException("Per-statement metadata identity mismatch for catalog " + catalogId + + ": the instance was built for user '" + builderUser + "' but is being reused for user '" + + user + "'. A statement must resolve a catalog under a single identity."); + } + return scope.getOrCreateMetadata("metadata:" + catalogId, () -> connector.getMetadata(session)); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenSchemaCacheValue.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenSchemaCacheValue.java new file mode 100644 index 00000000000000..82cea73df617a0 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenSchemaCacheValue.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.datasource.plugin; + +import org.apache.doris.catalog.Column; +import org.apache.doris.datasource.SchemaCacheValue; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * {@link SchemaCacheValue} for plugin-driven external tables. + * + *

    In addition to the full schema, it caches which columns are partition + * columns so that {@link PluginDrivenExternalTable#getPartitionColumns()}, + * {@link PluginDrivenExternalTable#isPartitionedTable()} and partition pruning + * can be served from the schema cache (mirroring {@code MaxComputeSchemaCacheValue} + * / {@code HMSSchemaCacheValue}) instead of re-fetching the table schema from the + * connector on every call.

    + * + *

    Two views of the partition columns are kept: + *

      + *
    • {@code partitionColumns} — the Doris {@link Column}s (with the local, + * identifier-mapped names) used by {@code getPartitionColumns()} and to derive + * partition-column types.
    • + *
    • {@code partitionColumnRemoteNames} — the raw remote (e.g. ODPS) partition + * column names, aligned by index with {@code partitionColumns}, used to index + * the raw-keyed partition-value maps returned by the connector SPI + * ({@code ConnectorPartitionInfo.getPartitionValues()}).
    • + *
    + */ +public class PluginDrivenSchemaCacheValue extends SchemaCacheValue { + + private final List partitionColumns; + private final List partitionColumnRemoteNames; + // The connector's raw table-properties map (e.g. paimon coreOptions: path / file.format / + // write-only), retained so SHOW CREATE TABLE can render LOCATION + PROPERTIES (D-046). The + // 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; + + public PluginDrivenSchemaCacheValue(List schema, List partitionColumns, + List partitionColumnRemoteNames) { + this(schema, partitionColumns, partitionColumnRemoteNames, Collections.emptyMap()); + } + + public PluginDrivenSchemaCacheValue(List schema, List partitionColumns, + List partitionColumnRemoteNames, Map tableProperties) { + super(schema); + this.partitionColumns = partitionColumns; + this.partitionColumnRemoteNames = partitionColumnRemoteNames; + this.tableProperties = tableProperties == null ? Collections.emptyMap() : tableProperties; + } + + public List getPartitionColumns() { + return partitionColumns; + } + + public List getPartitionColumnRemoteNames() { + return partitionColumnRemoteNames; + } + + public Map getTableProperties() { + return tableProperties; + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenSysExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenSysExternalTable.java new file mode 100644 index 00000000000000..290b5405fdd1e1 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenSysExternalTable.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. + +package org.apache.doris.datasource.plugin; + +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.datasource.SchemaCacheKey; +import org.apache.doris.datasource.SchemaCacheValue; +import org.apache.doris.datasource.systable.SysTable; + +import java.util.Map; +import java.util.Optional; + +/** + * Generic {@link PluginDrivenExternalTable} for a connector system table (e.g. {@code tbl$snapshots}). + * + *

    Created transiently by {@link org.apache.doris.datasource.systable.PluginDrivenSysTable} during + * planning/describe (via {@code createSysExternalTable}); it is NEVER added to a persisted table map + * and is NOT GSON-registered, mirroring legacy sys ExternalTables (e.g. + * {@code PaimonSysExternalTable}).

    + * + *

    It reports {@link org.apache.doris.catalog.TableIf.TableType#PLUGIN_EXTERNAL_TABLE} (inherited); + * no connector-specific table type is introduced. The whole schema/partition/row-count path is reused + * from the base class; the only behavioral change is {@link #resolveConnectorTableHandle}, which threads + * the connector's system-table handle (not the base handle) through every base-class site.

    + */ +public class PluginDrivenSysExternalTable extends PluginDrivenExternalTable { + + private final PluginDrivenExternalTable sourceTable; + private final String sysTableName; + private volatile Optional cachedSchemaValue; + + /** + * @param source the underlying base table being wrapped + * @param sysName the bare system-table name (e.g. "snapshots"), no "$" prefix + */ + public PluginDrivenSysExternalTable(PluginDrivenExternalTable source, String sysName) { + super(generateSysTableId(source.getId(), sysName), + source.getName() + "$" + sysName, + source.getRemoteName() + "$" + sysName, + source.getCatalog(), + source.getDb()); + this.sourceTable = source; + this.sysTableName = sysName; + } + + /** + * Generate a unique ID from the source table ID and system table name (legacy parity with + * {@code PaimonSysExternalTable.generateSysTableId}). + */ + private static long generateSysTableId(long sourceTableId, String sysName) { + return sourceTableId ^ (sysName.hashCode() * 31L); + } + + /** + * Resolve the connector handle for THIS system table: first acquire the BASE table handle using the + * source's remote name (NOT this sys table's "$"-suffixed remote name), then ask the connector for + * the system-table handle. Returning the sys handle here threads it through + * {@code initSchema}/{@code getNameToPartitionItems}/{@code fetchRowCount} automatically, so a sys + * query reads the sys table rather than the base. + */ + @Override + public Optional resolveConnectorTableHandle( + ConnectorSession session, ConnectorMetadata metadata) { + String dbName = db != null ? db.getRemoteName() : ""; + Optional baseHandle = + metadata.getTableHandle(session, dbName, sourceTable.getRemoteName()); + if (!baseHandle.isPresent()) { + return Optional.empty(); + } + return metadata.getSysTableHandle(session, baseHandle.get(), sysTableName); + } + + /** + * A system/metadata table (e.g. {@code tbl$snapshots}) is never a view. Short-circuit to {@code false} + * so the base {@code resolveIsView} does not issue a {@code viewExists} round-trip on this synthetic + * {@code "$"}-suffixed name (which would be wasted work and could fail on an unparseable identifier). + */ + @Override + protected boolean resolveIsView() { + return false; + } + + /** + * A system/metadata table (e.g. {@code tbl$snapshots}) can NEVER take part in Top-N lazy materialization, + * regardless of what the connector declares. Lazy materialization reads the sort key plus the engine-wide + * row-id ({@code __DORIS_GLOBAL_ROWID_COL__}) first, then re-fetches the surviving rows' other columns by + * row-id — which requires a file+position row-id. A system table is served by the connector's JNI + * serialized-split metadata reader, which synthesizes rows from table metadata and produces no such row-id, + * so the injected row-id column comes back empty and BE aborts the scan + * ({@code __DORIS_GLOBAL_ROWID_COL__... return column size 0 not equal to expected size 1}). + * + *

    This restores legacy parity: legacy sys tables ({@code IcebergSysExternalTable} / + * {@code PaimonSysExternalTable}) extend {@code ExternalTable} — NOT the base file-scan table class — so + * they are absent from {@code MaterializeProbeVisitor.SUPPORT_RELATION_TYPES} and were never lazy- + * materialized. The base {@link PluginDrivenExternalTable#supportsTopNLazyMaterialize()} keys off the + * connector capability alone and would otherwise (wrongly) admit a flipped sys table; this override is the + * sys-table opt-out. Nested-column prune is similarly opted out for sys tables — see + * {@link #supportsNestedColumnPrune()}. + */ + @Override + public boolean supportsTopNLazyMaterialize() { + return false; + } + + /** + * A system/metadata table (e.g. {@code tbl$snapshots}) can NEVER take part in nested-column pruning, + * regardless of what the connector declares. Pruning has two stages in fe-core: (L1) generate name-based + * access paths ({@code LogicalFileScan.supportPruneNestedColumn}), then (L2) rewrite each access-path top + * element from the column NAME to its numeric field id ({@code SlotTypeReplacer.replaceAccessPathToFieldId}, + * gated on {@link PluginDrivenExternalTable#supportsNestedColumnPrune()}). BE can only field-id-match a + * complex column when the scan ships a field-id dictionary, but a system-table scan intentionally ships NONE + * ({@code IcebergScanPlanProvider} skips {@code SCHEMA_EVOLUTION_PROP} when {@code systemTable}), so + * {@code column->has_identifier_field_id()} is false and BE rejects the field-id access path with + * {@code AccessPathParser access path N does not match slot X}. + * + *

    Legacy parity: on master the L2 field-id rewrite was gated on {@code instanceof IcebergExternalTable}, + * and legacy sys tables ({@code IcebergSysExternalTable}) extend {@code ExternalTable} — NOT + * {@code IcebergExternalTable} — so L2 never fired for them and their access paths stayed name-based (BE + * matched by name). The migrated gate keys off the connector capability alone, which a flipped sys table + * inherits as {@code true}; this override is the sys-table opt-out. It disables BOTH stages, so no access + * paths are emitted and BE reads the whole (tiny) metadata-table complex column — which is correct. + */ + @Override + public boolean supportsNestedColumnPrune() { + return false; + } + + /** + * Compute the schema directly on this transient instance instead of going through the base + * {@link ExternalTable#getSchemaCacheValue()}, which routes through {@code ExternalCatalog.getSchema()} + * and re-resolves the table by name in the db map. A system table (e.g. {@code tbl$snapshots}) is never + * registered in that map, so the base path fails with "failed to load schema cache value". Memoized + * (double-checked) to avoid repeated connector round-trips, mirroring legacy + * {@code PaimonSysExternalTable.getSchemaCacheValue}. {@code initSchema()} (inherited from + * {@link PluginDrivenExternalTable}) honors this class's {@link #resolveConnectorTableHandle}, so it + * resolves the system-table schema rather than the base table's. + */ + @Override + public Optional getSchemaCacheValue() { + if (cachedSchemaValue == null) { + synchronized (this) { + if (cachedSchemaValue == null) { + cachedSchemaValue = initSchema(); + } + } + } + return cachedSchemaValue; + } + + @Override + public Optional initSchema(SchemaCacheKey key) { + return getSchemaCacheValue(); + } + + /** + * Delegate to the source table so DESCRIBE/SHOW on a system table still lists its sibling system + * tables (legacy parity with {@code PaimonSysExternalTable.getSupportedSysTables}). + */ + @Override + public Map getSupportedSysTables() { + return sourceTable.getSupportedSysTables(); + } + + @Override + public String getComment() { + return "Plugin system table: " + sysTableName + " for " + sourceTable.getName(); + } + + public PluginDrivenExternalTable getSourceTable() { + return sourceTable; + } + + public String getSysTableName() { + return sysTableName; + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/common/AwsCredentialsProviderFactory.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/common/AwsCredentialsProviderFactory.java index 8669488cf1a544..170d0527fd2ae3 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/common/AwsCredentialsProviderFactory.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/common/AwsCredentialsProviderFactory.java @@ -39,51 +39,6 @@ public final class AwsCredentialsProviderFactory { private AwsCredentialsProviderFactory() { } - /* ========================= - * AWS SDK V1 - * ========================= */ - - public static com.amazonaws.auth.AWSCredentialsProvider createV1( - AwsCredentialsProviderMode mode) { - - switch (mode) { - case ENV: - return new com.amazonaws.auth.EnvironmentVariableCredentialsProvider(); - case SYSTEM_PROPERTIES: - return new com.amazonaws.auth.SystemPropertiesCredentialsProvider(); - case WEB_IDENTITY: - return com.amazonaws.auth.WebIdentityTokenCredentialsProvider.create(); - case CONTAINER: - return new com.amazonaws.auth.EC2ContainerCredentialsProviderWrapper(); - case ANONYMOUS: - throw new UnsupportedOperationException( - "AWS SDK V1 does not support anonymous credentials provider."); - case INSTANCE_PROFILE: - return new com.amazonaws.auth.InstanceProfileCredentialsProvider(); - case DEFAULT: - return createDefaultV1(); - default: - throw new UnsupportedOperationException( - "AWS SDK V1 does not support credentials provider mode: " + mode); - } - } - - private static com.amazonaws.auth.AWSCredentialsProvider createDefaultV1() { - List providers = new ArrayList<>(); - providers.add(new com.amazonaws.auth.InstanceProfileCredentialsProvider()); - //lazy + env - if (isWebIdentityConfigured()) { - providers.add(com.amazonaws.auth.WebIdentityTokenCredentialsProvider.create()); - } - if (isContainerCredentialsConfigured()) { - providers.add(new com.amazonaws.auth.EC2ContainerCredentialsProviderWrapper()); - } - providers.add(new com.amazonaws.auth.EnvironmentVariableCredentialsProvider()); - providers.add(new com.amazonaws.auth.SystemPropertiesCredentialsProvider()); - return new com.amazonaws.auth.AWSCredentialsProviderChain( - providers.toArray(new com.amazonaws.auth.AWSCredentialsProvider[0])); - } - /* ========================= * AWS SDK V2 * ========================= */ diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/common/IcebergAwsAssumeRoleProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/common/IcebergAwsAssumeRoleProperties.java deleted file mode 100644 index afa224511bc3e0..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/common/IcebergAwsAssumeRoleProperties.java +++ /dev/null @@ -1,52 +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.datasource.property.common; - -import org.apache.doris.datasource.property.storage.S3Properties; - -import org.apache.commons.lang3.StringUtils; -import org.apache.iceberg.aws.AssumeRoleAwsClientFactory; -import org.apache.iceberg.aws.AwsProperties; - -import java.util.Map; - -/** - * Shared util for putting Iceberg AWS assume-role properties into a map. - * Used by Iceberg REST (glue/s3tables signing), S3 Tables catalog, and S3 FileIO. - */ -public final class IcebergAwsAssumeRoleProperties { - - private IcebergAwsAssumeRoleProperties() {} - - /** - * Puts assume-role related Iceberg client properties into the target map when roleArn is present. - * No-op if roleArn is blank. - */ - public static void putAssumeRoleProperties(Map target, S3Properties s3Properties) { - if (StringUtils.isBlank(s3Properties.getS3IAMRole())) { - return; - } - target.put(AwsProperties.CLIENT_FACTORY, AssumeRoleAwsClientFactory.class.getName()); - target.put("aws.region", s3Properties.getRegion()); - target.put(AwsProperties.CLIENT_ASSUME_ROLE_REGION, s3Properties.getRegion()); - target.put(AwsProperties.CLIENT_ASSUME_ROLE_ARN, s3Properties.getS3IAMRole()); - if (StringUtils.isNotBlank(s3Properties.getS3ExternalId())) { - target.put(AwsProperties.CLIENT_ASSUME_ROLE_EXTERNAL_ID, s3Properties.getS3ExternalId()); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/common/IcebergAwsClientCredentialsProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/common/IcebergAwsClientCredentialsProperties.java deleted file mode 100644 index 82228e700377e8..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/common/IcebergAwsClientCredentialsProperties.java +++ /dev/null @@ -1,144 +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.datasource.property.common; - -import org.apache.doris.datasource.property.storage.S3Properties; - -import org.apache.commons.lang3.StringUtils; -import org.apache.iceberg.aws.AwsClientProperties; -import org.apache.iceberg.aws.AwsProperties; -import org.apache.iceberg.aws.s3.S3FileIOProperties; -import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; - -import java.util.Map; - -public final class IcebergAwsClientCredentialsProperties { - - private IcebergAwsClientCredentialsProperties() {} - - public static void putCredentialProviderProperties(Map target, S3Properties s3Properties) { - switch (getCredentialType(s3Properties)) { - case EXPLICIT: - putExplicitRestCredentials(target, s3Properties.getAccessKey(), s3Properties.getSecretKey(), - s3Properties.getSessionToken()); - return; - case ASSUME_ROLE: - IcebergAwsAssumeRoleProperties.putAssumeRoleProperties(target, s3Properties); - return; - case PROVIDER_CHAIN: - putCredentialsProvider(target, s3Properties.getAwsCredentialsProviderMode()); - return; - default: - throw new IllegalStateException("Unsupported Iceberg AWS credential type"); - } - } - - public static void putCredentialProviderProperties(Map target, - String accessKey, String secretKey, String sessionToken, AwsCredentialsProviderMode providerMode) { - if (StringUtils.isNotBlank(accessKey) && StringUtils.isNotBlank(secretKey)) { - putExplicitRestCredentials(target, accessKey, secretKey, sessionToken); - return; - } - putCredentialsProvider(target, providerMode); - } - - public static void putS3FileIOCredentialProperties(Map target, - S3Properties s3Properties) { - putS3FileIOProperties(target, s3Properties); - switch (getCredentialType(s3Properties)) { - case EXPLICIT: - return; - case ASSUME_ROLE: - IcebergAwsAssumeRoleProperties.putAssumeRoleProperties(target, s3Properties); - return; - case PROVIDER_CHAIN: - putCredentialsProvider(target, s3Properties.getAwsCredentialsProviderMode()); - return; - default: - throw new IllegalStateException("Unsupported Iceberg AWS credential type"); - } - } - - public static AwsCredentialsProvider createAwsCredentialsProvider(S3Properties s3Properties, - boolean includeAnonymousInDefault) { - switch (getCredentialType(s3Properties)) { - case EXPLICIT: - case ASSUME_ROLE: - return s3Properties.getAwsCredentialsProvider(); - case PROVIDER_CHAIN: - return AwsCredentialsProviderFactory.createV2( - s3Properties.getAwsCredentialsProviderMode(), includeAnonymousInDefault); - default: - throw new IllegalStateException("Unsupported Iceberg AWS credential type"); - } - } - - private static CredentialType getCredentialType(S3Properties s3Properties) { - if (StringUtils.isNotBlank(s3Properties.getAccessKey()) - && StringUtils.isNotBlank(s3Properties.getSecretKey())) { - return CredentialType.EXPLICIT; - } - if (StringUtils.isNotBlank(s3Properties.getS3IAMRole())) { - return CredentialType.ASSUME_ROLE; - } - return CredentialType.PROVIDER_CHAIN; - } - - private static void putExplicitRestCredentials(Map target, - String accessKey, String secretKey, String sessionToken) { - target.put(AwsProperties.REST_ACCESS_KEY_ID, accessKey); - target.put(AwsProperties.REST_SECRET_ACCESS_KEY, secretKey); - if (StringUtils.isNotBlank(sessionToken)) { - target.put(AwsProperties.REST_SESSION_TOKEN, sessionToken); - } - } - - private static void putS3FileIOProperties(Map target, - S3Properties s3Properties) { - if (StringUtils.isNotBlank(s3Properties.getEndpoint())) { - target.put(S3FileIOProperties.ENDPOINT, s3Properties.getEndpoint()); - } - if (StringUtils.isNotBlank(s3Properties.getUsePathStyle())) { - target.put(S3FileIOProperties.PATH_STYLE_ACCESS, s3Properties.getUsePathStyle()); - } - if (StringUtils.isNotBlank(s3Properties.getAccessKey())) { - target.put(S3FileIOProperties.ACCESS_KEY_ID, s3Properties.getAccessKey()); - } - if (StringUtils.isNotBlank(s3Properties.getSecretKey())) { - target.put(S3FileIOProperties.SECRET_ACCESS_KEY, s3Properties.getSecretKey()); - } - if (StringUtils.isNotBlank(s3Properties.getSessionToken())) { - target.put(S3FileIOProperties.SESSION_TOKEN, s3Properties.getSessionToken()); - } - } - - private static void putCredentialsProvider(Map target, - AwsCredentialsProviderMode providerMode) { - if (providerMode == null || providerMode == AwsCredentialsProviderMode.DEFAULT) { - return; - } - target.put(AwsClientProperties.CLIENT_CREDENTIALS_PROVIDER, - AwsCredentialsProviderFactory.getV2ClassName(providerMode)); - } - - private enum CredentialType { - EXPLICIT, - ASSUME_ROLE, - PROVIDER_CHAIN - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AWSGlueMetaStoreBaseProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AWSGlueMetaStoreBaseProperties.java deleted file mode 100644 index e24405d78263eb..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AWSGlueMetaStoreBaseProperties.java +++ /dev/null @@ -1,212 +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.datasource.property.metastore; - -import org.apache.doris.foundation.property.ConnectorPropertiesUtils; -import org.apache.doris.foundation.property.ConnectorProperty; -import org.apache.doris.foundation.property.ParamRules; - -import com.google.common.base.Strings; -import lombok.Getter; -import org.apache.commons.lang3.StringUtils; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; -import software.amazon.awssdk.auth.credentials.AwsCredentialsProviderChain; -import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; -import software.amazon.awssdk.auth.credentials.ContainerCredentialsProvider; -import software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider; -import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider; -import software.amazon.awssdk.auth.credentials.ProfileCredentialsProvider; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider; -import software.amazon.awssdk.auth.credentials.WebIdentityTokenFileCredentialsProvider; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.sts.StsClient; -import software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider; - -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public class AWSGlueMetaStoreBaseProperties { - @Getter - @ConnectorProperty(names = {"glue.endpoint", "aws.endpoint", "aws.glue.endpoint"}, - description = "The endpoint of the AWS Glue.") - protected String glueEndpoint = ""; - - @ConnectorProperty(names = {"glue.region", "aws.region", "aws.glue.region"}, - description = "The region of the AWS Glue. " - + "If not set, it will use the default region configured in the AWS SDK or environment variables." - ) - @Getter - protected String glueRegion = ""; - - /** - * AWS credentials. - */ - @ConnectorProperty(names = {"client.credentials-provider"}, - description = "The class name of the credentials provider for AWS Glue.", - supported = false) - protected String credentialsProviderClass = "com.amazonaws.auth.DefaultAWSCredentialsProviderChain"; - - @ConnectorProperty(names = {"glue.access_key", - "aws.glue.access-key", "client.credentials-provider.glue.access_key"}, - description = "The access key of the AWS Glue.") - protected String glueAccessKey = ""; - - @ConnectorProperty(names = {"glue.secret_key", - "aws.glue.secret-key", "client.credentials-provider.glue.secret_key"}, - sensitive = true, - description = "The secret key of the AWS Glue.") - protected String glueSecretKey = ""; - - @ConnectorProperty(names = {"aws.glue.session-token"}, - description = "The session token of the AWS Glue.") - protected String glueSessionToken = ""; - - @ConnectorProperty(names = {"glue.role_arn"}, - description = "The IAM role the AWS Glue.") - protected String glueIAMRole = ""; - - @ConnectorProperty(names = {"glue.external_id"}, - description = "The external id of the AWS Glue.") - protected String glueExternalId = ""; - - public static AWSGlueMetaStoreBaseProperties of(Map properties) { - AWSGlueMetaStoreBaseProperties propertiesObj = new AWSGlueMetaStoreBaseProperties(); - ConnectorPropertiesUtils.bindConnectorProperties(propertiesObj, properties); - propertiesObj.checkAndInit(); - return propertiesObj; - } - - /** - * The pattern of the AWS Glue endpoint. - * FYI: https://docs.aws.amazon.com/general/latest/gr/glue.html#glue_region - * eg: - * glue.us-east-1.amazonaws.com↳ - *

    - * glue-fips.us-east-1.api.aws - *

    - * glue-fips.us-east-1.amazonaws.com - *

    - * glue.us-east-1.api.aws - */ - private static final Pattern ENDPOINT_PATTERN = Pattern.compile( - "^(?:https?://)?(?:glue|glue-fips)\\.([a-z0-9-]+)\\.(?:api\\.aws|amazonaws\\.com)$" - ); - - private ParamRules buildRules() { - - return new ParamRules().requireTogether(new String[]{glueAccessKey, glueSecretKey}, - "glue.access_key and glue.secret_key must be set together") - .require(glueEndpoint, "glue.endpoint must be set") - .check(() -> StringUtils.isNotBlank(glueEndpoint) && !glueEndpoint.startsWith("https://"), - "glue.endpoint must use https protocol,please set glue.endpoint to https://..."); - } - - private void checkAndInit() { - buildRules().validate(); - if (StringUtils.isNotBlank(glueRegion) && StringUtils.isNotBlank(glueEndpoint)) { - return; - } - // glue region is not set, try to extract from endpoint - Matcher matcher = ENDPOINT_PATTERN.matcher(glueEndpoint.toLowerCase()); - if (matcher.matches()) { - this.glueRegion = extractRegionFromEndpoint(matcher); - } - if (StringUtils.isBlank(glueRegion)) { - //follow aws sdk default region - glueRegion = "us-east-1"; - } - } - - /** - * Validate that at least one Glue credential (an access key or an IAM role) is explicitly provided. - * - * Purpose: Some catalog implementations (for example, Iceberg) do not support obtaining credentials - * from the default credential chain (instance metadata, environment variables, etc.). In addition, - * the configuration or UI may only expose two options: {@code glue.access_key} and {@code glue.role_arn}. - * In such cases, at least one of these must be explicitly set. If neither is provided, an - * {@link IllegalArgumentException} is thrown to prompt the user to complete the configuration. - */ - protected void requireExplicitGlueCredentials() { - if (StringUtils.isNotBlank(glueAccessKey) || StringUtils.isNotBlank(glueIAMRole)) { - return; - } - throw new IllegalArgumentException("At least one of glue.access_key or glue.role_arn must be set"); - } - - private String extractRegionFromEndpoint(Matcher matcher) { - for (int i = 1; i <= matcher.groupCount(); i++) { - String group = matcher.group(i); - if (StringUtils.isNotBlank(group)) { - return group; - } - } - throw new IllegalArgumentException("Could not extract region from endpoint: " + glueEndpoint); - } - - /** - * Build AWS credentials provider for Glue client. - * - * @return AwsCredentialsProvider - */ - public AwsCredentialsProvider getAwsCredentialsProvider() { - // If access key is configured, use it - if (StringUtils.isNotBlank(glueAccessKey) && StringUtils.isNotBlank(glueSecretKey)) { - if (Strings.isNullOrEmpty(glueSessionToken)) { - return StaticCredentialsProvider.create(AwsBasicCredentials.create(glueAccessKey, glueSecretKey)); - } else { - return StaticCredentialsProvider.create(AwsSessionCredentials.create(glueAccessKey, glueSecretKey, - glueSessionToken)); - } - } - // If IAM role is configured, use STS AssumeRole - if (StringUtils.isNotBlank(glueIAMRole)) { - StsClient stsClient = StsClient.builder() - .region(Region.of(glueRegion)) - .credentialsProvider(AwsCredentialsProviderChain.of( - WebIdentityTokenFileCredentialsProvider.create(), - ContainerCredentialsProvider.create(), - InstanceProfileCredentialsProvider.create(), - SystemPropertyCredentialsProvider.create(), - EnvironmentVariableCredentialsProvider.create(), - ProfileCredentialsProvider.create())) - .build(); - - return StsAssumeRoleCredentialsProvider.builder() - .stsClient(stsClient) - .refreshRequest(builder -> { - builder.roleArn(glueIAMRole).roleSessionName("aws-glue-java-fe"); - if (StringUtils.isNotBlank(glueExternalId)) { - builder.externalId(glueExternalId); - } - }).build(); - } - - return AwsCredentialsProviderChain.of( - WebIdentityTokenFileCredentialsProvider.create(), - ContainerCredentialsProvider.create(), - InstanceProfileCredentialsProvider.create(), - SystemPropertyCredentialsProvider.create(), - EnvironmentVariableCredentialsProvider.create(), - ProfileCredentialsProvider.create()); - } -} - - diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractHiveProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractHiveProperties.java deleted file mode 100644 index 8bfb2f1b153e64..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractHiveProperties.java +++ /dev/null @@ -1,62 +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.datasource.property.metastore; - -import org.apache.doris.common.Config; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; - -import lombok.Getter; -import org.apache.hadoop.hive.conf.HiveConf; - -import java.util.Map; - -public abstract class AbstractHiveProperties extends MetastoreProperties { - - @Getter - protected HiveConf hiveConf; - - /** - * Hadoop authenticator responsible for handling authentication with HDFS/HiveMetastore. - * - * By default, it uses simple authentication (HadoopSimpleAuthenticator with SimpleAuthenticationConfig). - * If Kerberos is required (e.g., when connecting to secure Hive or HDFS clusters), this field must be - * replaced with a proper Kerberos-based implementation during initialization. - * - * Note: In certain environments (such as AWS Glue, which doesn't require Kerberos), the default simple - * implementation is sufficient and no replacement is needed. - */ - @Getter - protected ExecutionAuthenticator executionAuthenticator = new ExecutionAuthenticator() {}; - - - @Getter - protected boolean hmsEventsIncrementalSyncEnabled = Config.enable_hms_events_incremental_sync; - - @Getter - protected int hmsEventsBatchSizePerRpc = Config.hms_events_batch_size_per_rpc; - - /** - * Base constructor for subclasses to initialize the common state. - * - * @param type metastore type - * @param origProps original configuration - */ - protected AbstractHiveProperties(Type type, Map origProps) { - super(type, origProps); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractIcebergProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractIcebergProperties.java deleted file mode 100644 index cf18f27a73454a..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractIcebergProperties.java +++ /dev/null @@ -1,301 +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.datasource.property.metastore; - -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.metacache.CacheSpec; -import org.apache.doris.datasource.property.common.IcebergAwsAssumeRoleProperties; -import org.apache.doris.datasource.property.storage.AbstractS3CompatibleProperties; -import org.apache.doris.datasource.property.storage.S3Properties; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.foundation.property.ConnectorProperty; - -import com.google.common.base.Preconditions; -import com.google.common.base.Strings; -import lombok.Getter; -import org.apache.commons.lang3.StringUtils; -import org.apache.hadoop.conf.Configuration; -import org.apache.iceberg.CatalogProperties; -import org.apache.iceberg.CatalogUtil; -import org.apache.iceberg.aws.AwsClientProperties; -import org.apache.iceberg.aws.s3.S3FileIOProperties; -import org.apache.iceberg.catalog.Catalog; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * @See org.apache.iceberg.CatalogProperties - */ -public abstract class AbstractIcebergProperties extends MetastoreProperties { - - @Getter - @ConnectorProperty( - names = {CatalogProperties.WAREHOUSE_LOCATION}, - required = false, - description = "The location of the Iceberg warehouse. This is where the tables will be stored." - ) - protected String warehouse; - - @Getter - @ConnectorProperty( - names = {CatalogProperties.IO_MANIFEST_CACHE_ENABLED}, - required = false, - description = "Controls whether to use caching during manifest reads or not. Default: false." - ) - protected String ioManifestCacheEnabled; - - @Getter - @ConnectorProperty( - names = {CatalogProperties.IO_MANIFEST_CACHE_EXPIRATION_INTERVAL_MS}, - required = false, - description = "Controls the maximum duration for which an entry stays in the manifest cache. " - + "Must be a non-negative value. Zero means entries expire only due to memory pressure. " - + "Default: 60000 (60s)." - ) - protected String ioManifestCacheExpirationIntervalMs; - - @Getter - @ConnectorProperty( - names = {CatalogProperties.IO_MANIFEST_CACHE_MAX_TOTAL_BYTES}, - required = false, - description = "Controls the maximum total amount of bytes to cache in manifest cache. " - + "Must be a positive value. Default: 104857600 (100MB)." - ) - protected String ioManifestCacheMaxTotalBytes; - - @Getter - @ConnectorProperty( - names = {CatalogProperties.IO_MANIFEST_CACHE_MAX_CONTENT_LENGTH}, - required = false, - description = "Controls the maximum length of file to be considered for caching. " - + "An InputFile will not be cached if the length is longer than this limit. " - + "Must be a positive value. Default: 8388608 (8MB)." - ) - protected String ioManifestCacheMaxContentLength; - - @Getter - @ConnectorProperty( - names = {CatalogProperties.FILE_IO_IMPL}, - required = false, - description = "Custom io impl for iceberg" - ) - protected String ioImpl; - - @Getter - protected ExecutionAuthenticator executionAuthenticator = new ExecutionAuthenticator(){}; - - public abstract String getIcebergCatalogType(); - - protected AbstractIcebergProperties(Map props) { - super(Type.ICEBERG, props); - } - - /** - * Iceberg Catalog instance responsible for managing metadata and lifecycle of Iceberg tables. - *

    - * The Catalog is a core component in Iceberg that handles table registration, - * loading, and metadata management. - *

    - * It is assigned during initialization via the `initialize` method, - * which calls the abstract `initCatalog` method to create a concrete Catalog instance. - * This instance is typically configured based on the provided catalog name - * and a list of storage properties. - *

    - * After initialization, the catalog must not be null; otherwise, - * an IllegalStateException is thrown to ensure that subsequent operations - * on Iceberg tables have a valid Catalog reference. - *

    - * Different Iceberg Catalog implementations (such as HadoopCatalog, HiveCatalog, - * RESTCatalog, etc.) can be flexibly switched and configured - * by subclasses overriding the `initCatalog` method. - *

    - * This field is used to perform metadata operations like creating, querying, - * and deleting Iceberg tables. - */ - public final Catalog initializeCatalog(String catalogName, - List storagePropertiesList) { - return initializeCatalog(catalogName, storagePropertiesList, SessionContext.empty()); - } - - public final Catalog initializeCatalog(String catalogName, - List storagePropertiesList, - SessionContext sessionContext) { - Map catalogProps = new HashMap<>(getOrigProps()); - if (StringUtils.isNotBlank(warehouse)) { - catalogProps.put(CatalogProperties.WAREHOUSE_LOCATION, warehouse); - } - - // Add manifest cache properties if configured - addManifestCacheProperties(catalogProps); - - Catalog catalog = initCatalog(catalogName, catalogProps, storagePropertiesList, sessionContext); - - if (catalog == null) { - throw new IllegalStateException("Catalog must not be null after initialization."); - } - return catalog; - } - - /** - * Add manifest cache related properties to catalog properties. - * These properties control caching behavior during manifest reads. - * - * @param catalogProps the catalog properties map to add manifest cache properties to - */ - protected void addManifestCacheProperties(Map catalogProps) { - boolean hasIoManifestCacheEnabled = StringUtils.isNotBlank(ioManifestCacheEnabled) - || StringUtils.isNotBlank(catalogProps.get(CatalogProperties.IO_MANIFEST_CACHE_ENABLED)); - if (StringUtils.isNotBlank(ioManifestCacheEnabled)) { - catalogProps.put(CatalogProperties.IO_MANIFEST_CACHE_ENABLED, ioManifestCacheEnabled); - } - if (StringUtils.isNotBlank(ioManifestCacheExpirationIntervalMs)) { - catalogProps.put(CatalogProperties.IO_MANIFEST_CACHE_EXPIRATION_INTERVAL_MS, - ioManifestCacheExpirationIntervalMs); - } - if (StringUtils.isNotBlank(ioManifestCacheMaxTotalBytes)) { - catalogProps.put(CatalogProperties.IO_MANIFEST_CACHE_MAX_TOTAL_BYTES, ioManifestCacheMaxTotalBytes); - } - if (StringUtils.isNotBlank(ioManifestCacheMaxContentLength)) { - catalogProps.put(CatalogProperties.IO_MANIFEST_CACHE_MAX_CONTENT_LENGTH, ioManifestCacheMaxContentLength); - } - - // default enable io manifest cache if the meta.cache.manifest is enabled - if (!hasIoManifestCacheEnabled) { - CacheSpec manifestCacheSpec = CacheSpec.fromProperties(catalogProps, CacheSpec.propertySpecBuilder() - .enable(IcebergExternalCatalog.ICEBERG_MANIFEST_CACHE_ENABLE, - IcebergExternalCatalog.DEFAULT_ICEBERG_MANIFEST_CACHE_ENABLE) - .ttl(IcebergExternalCatalog.ICEBERG_MANIFEST_CACHE_TTL_SECOND, - IcebergExternalCatalog.DEFAULT_ICEBERG_MANIFEST_CACHE_TTL_SECOND) - .capacity(IcebergExternalCatalog.ICEBERG_MANIFEST_CACHE_CAPACITY, - IcebergExternalCatalog.DEFAULT_ICEBERG_MANIFEST_CACHE_CAPACITY) - .build()); - if (CacheSpec.isCacheEnabled(manifestCacheSpec.isEnable(), - manifestCacheSpec.getTtlSecond(), - manifestCacheSpec.getCapacity())) { - catalogProps.put(CatalogProperties.IO_MANIFEST_CACHE_ENABLED, "true"); - } - } - } - - /** - * Subclasses must implement this to create the concrete Catalog instance. - */ - protected abstract Catalog initCatalog( - String catalogName, - Map catalogProps, - List storagePropertiesList - ); - - protected Catalog initCatalog( - String catalogName, - Map catalogProps, - List storagePropertiesList, - SessionContext sessionContext - ) { - return initCatalog(catalogName, catalogProps, storagePropertiesList); - } - - /** - * Unified method to configure FileIO properties for Iceberg catalog. - * This method handles all storage types (HDFS, S3, MinIO, etc.) by: - * 1. Adding all storage properties to Hadoop Configuration (for HadoopFileIO / S3A access). - * 2. Extracting S3-compatible properties into fileIOProperties map (for Iceberg S3FileIO). - * - * @param storagePropertiesList list of storage properties - * @param fileIOProperties options map to be populated with S3 FileIO properties - * @return Hadoop Configuration populated with all storage properties - */ - public void toFileIOProperties(List storagePropertiesList, - Map fileIOProperties, Configuration conf) { - // We only support one S3-compatible storage property for FileIO configuration. - // When multiple AbstractS3CompatibleProperties exist, prefer the first non-S3Properties one, - // because a non-S3 type (e.g. OSSProperties, COSProperties) indicates the user has explicitly - // specified a concrete S3-compatible storage, which should take priority over the generic S3Properties. - AbstractS3CompatibleProperties s3Fallback = null; - AbstractS3CompatibleProperties s3Target = null; - for (StorageProperties storageProperties : storagePropertiesList) { - if (conf != null && storageProperties.getHadoopStorageConfig() != null) { - conf.addResource(storageProperties.getHadoopStorageConfig()); - } - if (storageProperties instanceof AbstractS3CompatibleProperties) { - if (s3Fallback == null) { - s3Fallback = (AbstractS3CompatibleProperties) storageProperties; - } - if (s3Target == null && !(storageProperties instanceof S3Properties)) { - s3Target = (AbstractS3CompatibleProperties) storageProperties; - } - } - } - AbstractS3CompatibleProperties chosen = s3Target != null ? s3Target : s3Fallback; - if (chosen != null) { - toS3FileIOProperties(chosen, fileIOProperties); - } else { - String region = AbstractS3CompatibleProperties.getRegionFromProperties(fileIOProperties); - if (!Strings.isNullOrEmpty(region)) { - fileIOProperties.put(AwsClientProperties.CLIENT_REGION, region); - } - } - } - - /** - * Configure S3 FileIO properties for all S3-compatible storage types (S3, MinIO, etc.) - * This method provides a unified way to convert S3-compatible properties to Iceberg S3FileIO format. - * - * @param s3Properties S3-compatible properties - * @param options Options map to be populated with S3 FileIO properties - */ - private void toS3FileIOProperties(AbstractS3CompatibleProperties s3Properties, Map options) { - // Common properties - only set if not blank - if (StringUtils.isNotBlank(s3Properties.getEndpoint())) { - options.put(S3FileIOProperties.ENDPOINT, s3Properties.getEndpoint()); - } - if (StringUtils.isNotBlank(s3Properties.getUsePathStyle())) { - options.put(S3FileIOProperties.PATH_STYLE_ACCESS, s3Properties.getUsePathStyle()); - } - if (StringUtils.isNotBlank(s3Properties.getRegion())) { - options.put(AwsClientProperties.CLIENT_REGION, s3Properties.getRegion()); - } - if (StringUtils.isNotBlank(s3Properties.getAccessKey())) { - options.put(S3FileIOProperties.ACCESS_KEY_ID, s3Properties.getAccessKey()); - } - if (StringUtils.isNotBlank(s3Properties.getSecretKey())) { - options.put(S3FileIOProperties.SECRET_ACCESS_KEY, s3Properties.getSecretKey()); - } - if (StringUtils.isNotBlank(s3Properties.getSessionToken())) { - options.put(S3FileIOProperties.SESSION_TOKEN, s3Properties.getSessionToken()); - } - if (s3Properties instanceof S3Properties) { - S3Properties awsProperties = (S3Properties) s3Properties; - IcebergAwsAssumeRoleProperties.putAssumeRoleProperties(options, awsProperties); - } - } - - protected Catalog buildIcebergCatalog(String catalogName, Map options, Configuration conf) { - // For Iceberg SDK, "type" means catalog type, such as hive, jdbc, rest. - // But in Doris, "type" is "iceberg". - // And Iceberg SDK does not allow with both "type" and "catalog-impl" properties, - // So here we remove "type" and make sure "catalog-impl" is set. - options.remove(CatalogUtil.ICEBERG_CATALOG_TYPE); - Preconditions.checkArgument(options.containsKey(CatalogProperties.CATALOG_IMPL)); - return CatalogUtil.buildIcebergCatalog(catalogName, options, conf); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractPaimonProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractPaimonProperties.java deleted file mode 100644 index 9c9631a516fdac..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractPaimonProperties.java +++ /dev/null @@ -1,200 +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.datasource.property.metastore; - -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.foundation.property.ConnectorProperty; - -import com.google.common.collect.ImmutableList; -import lombok.Getter; -import org.apache.commons.lang3.StringUtils; -import org.apache.hadoop.conf.Configuration; -import org.apache.paimon.catalog.Catalog; -import org.apache.paimon.options.CatalogOptions; -import org.apache.paimon.options.Options; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; - -public abstract class AbstractPaimonProperties extends MetastoreProperties { - @ConnectorProperty( - names = {"warehouse"}, - description = "The location of the Paimon warehouse. This is where the tables will be stored." - ) - protected String warehouse; - - @Getter - protected ExecutionAuthenticator executionAuthenticator = new ExecutionAuthenticator() { - }; - - @Getter - protected Options catalogOptions; - - private final AtomicReference> catalogOptionsMapRef = new AtomicReference<>(); - - public abstract String getPaimonCatalogType(); - - private static final String USER_PROPERTY_PREFIX = "paimon."; - - protected AbstractPaimonProperties(Map props) { - super(Type.PAIMON, props); - } - - public abstract Catalog initializeCatalog(String catalogName, List storagePropertiesList); - - protected void appendCatalogOptions() { - if (StringUtils.isNotBlank(warehouse)) { - catalogOptions.set(CatalogOptions.WAREHOUSE.key(), warehouse); - } - catalogOptions.set(CatalogOptions.METASTORE.key(), getMetastoreType()); - - // FIXME(cmy): Rethink these custom properties - origProps.forEach((k, v) -> { - if (k.toLowerCase().startsWith(USER_PROPERTY_PREFIX)) { - String newKey = k.substring(USER_PROPERTY_PREFIX.length()); - if (StringUtils.isNotBlank(newKey)) { - boolean excluded = userStoragePrefixes.stream().anyMatch(k::startsWith); - if (!excluded) { - catalogOptions.set(newKey, v); - } - } - } - }); - } - - /** - * Build catalog options including common and subclass-specific ones. - */ - public void buildCatalogOptions() { - catalogOptions = new Options(); - appendCatalogOptions(); - appendCustomCatalogOptions(); - } - - protected void appendUserHadoopConfig(Configuration conf) { - normalizeS3Config().forEach(conf::set); - } - - public Map getCatalogOptionsMap() { - // Return the cached map if already initialized - Map existing = catalogOptionsMapRef.get(); - if (existing != null) { - return existing; - } - - // Check that the catalog options source is available - if (catalogOptions == null) { - throw new IllegalStateException("Catalog options have not been initialized. Call" - + " buildCatalogOptions first."); - } - - // Construct the map manually using the provided keys - Map computed = new HashMap<>(); - for (String key : catalogOptions.keySet()) { - computed.put(key, catalogOptions.get(key)); - } - - // Attempt to set the constructed map atomically; only one thread wins - if (catalogOptionsMapRef.compareAndSet(null, computed)) { - return computed; - } else { - // Another thread already initialized it; return the existing one - return catalogOptionsMapRef.get(); - } - } - - /** - * @See org.apache.paimon.s3.S3FileIO - * Possible S3 config key prefixes: - * 1. "s3." - Paimon legacy custom prefix - * 2. "s3a." - Paimon-supported shorthand - * 3. "fs.s3a." - Hadoop S3A official prefix - * - * All of them are normalized to the Hadoop-recognized prefix "fs.s3a." - */ - private final List userStoragePrefixes = ImmutableList.of( - "paimon.s3.", "paimon.s3a.", "paimon.fs.s3.", "paimon.fs.oss." - ); - - /** Hadoop S3A standard prefix */ - private static final String FS_S3A_PREFIX = "fs.s3a."; - - /** - * Normalizes user-provided S3 config keys to Hadoop S3A keys - */ - protected Map normalizeS3Config() { - Map result = new HashMap<>(); - origProps.forEach((key, value) -> { - for (String prefix : userStoragePrefixes) { - if (key.startsWith(prefix)) { - result.put(FS_S3A_PREFIX + key.substring(prefix.length()), value); - return; // stop after the first matching prefix - } - } - }); - return result; - } - - - /** - * Hook method for subclasses to append metastore-specific or custom catalog options. - * - *

    This method is invoked after common catalog options (e.g., warehouse path, - * metastore type, user-defined keys, and S3 compatibility mappings) have been - * added to the {@link org.apache.paimon.options.Options} instance. - * - *

    Subclasses should override this method to inject additional configuration - * required for their specific metastore or environment. For example: - * - *

      - *
    • DLF-based catalog may require a custom metastore client class.
    • - *
    • HMS-based catalog may include URI and client pool parameters.
    • - *
    • Other environments may inject authentication, endpoint, or caching options.
    • - *
    - * - *

    If the subclass does not require any special options beyond the common ones, - * it can safely leave this method empty. - */ - protected abstract void appendCustomCatalogOptions(); - - /** - * Returns the metastore type identifier used by the Paimon catalog factory. - * - *

    This identifier must match one of the known metastore types supported by - * Apache Paimon. Internally, the value returned here is used to configure the - * `metastore` option in {@code Options}, which determines the specific - * {@link org.apache.paimon.catalog.CatalogFactory} implementation to be used - * when instantiating the catalog. - * - *

    You can find valid identifiers by reviewing implementations of the - * {@link org.apache.paimon.catalog.CatalogFactory} interface. Each implementation - * declares its identifier via a static {@code IDENTIFIER} field or equivalent constant. - * - *

    Examples: - *

      - *
    • {@code "filesystem"} - for {@link org.apache.paimon.catalog.FileSystemCatalogFactory}
    • - *
    • {@code "hive"} - for {@link org.apache.paimon.hive.HiveCatalogFactory}
    • - *
    - * - * @return the metastore type identifier string - */ - protected abstract String getMetastoreType(); -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AliyunDLFBaseProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AliyunDLFBaseProperties.java deleted file mode 100644 index a0066b28dcb43a..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AliyunDLFBaseProperties.java +++ /dev/null @@ -1,106 +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.datasource.property.metastore; - -import org.apache.doris.foundation.property.ConnectorPropertiesUtils; -import org.apache.doris.foundation.property.ConnectorProperty; -import org.apache.doris.foundation.property.ParamRules; -import org.apache.doris.foundation.property.StoragePropertiesException; - -import com.aliyun.datalake.metastore.common.DataLakeConfig; -import org.apache.commons.lang3.BooleanUtils; -import org.apache.commons.lang3.StringUtils; - -import java.util.Map; - -public class AliyunDLFBaseProperties { - @ConnectorProperty(names = {"dlf.access_key", "dlf.catalog.accessKeyId"}, - description = "The access key of the Aliyun DLF.") - protected String dlfAccessKey = ""; - - @ConnectorProperty(names = {"dlf.secret_key", "dlf.catalog.accessKeySecret"}, - description = "The secret key of the Aliyun DLF.", - sensitive = true) - protected String dlfSecretKey = ""; - - @ConnectorProperty(names = {"dlf.session_token", "dlf.catalog.sessionToken"}, - required = false, - description = "The session token of the Aliyun DLF.", - sensitive = true) - protected String dlfSessionToken = ""; - - @ConnectorProperty(names = {"dlf.region"}, - required = false, - description = "The region of the Aliyun DLF.") - protected String dlfRegion = ""; - - @ConnectorProperty(names = {"dlf.endpoint", "dlf.catalog.endpoint"}, - required = false, - description = "The region of the Aliyun DLF.") - protected String dlfEndpoint = ""; - - @ConnectorProperty(names = {"dlf.catalog.uid", "dlf.uid"}, - description = "The uid of the Aliyun DLF.") - protected String dlfUid = ""; - - @ConnectorProperty(names = {"dlf.catalog.id", "dlf.catalog_id"}, - required = false, - description = "The catalog id of the Aliyun DLF. If not set, it will be the same as dlf.uid.") - protected String dlfCatalogId = ""; - - @ConnectorProperty(names = {"dlf.access.public", "dlf.catalog.accessPublic"}, - required = false, - description = "Enable public access to Aliyun DLF.") - protected String dlfAccessPublic = "false"; - - @ConnectorProperty(names = {DataLakeConfig.CATALOG_PROXY_MODE, "dlf.proxy.mode"}, - required = false, - description = "The proxy mode of the Aliyun DLF. Default is DLF_ONLY.") - protected String dlfProxyMode = "DLF_ONLY"; - - public static AliyunDLFBaseProperties of(Map properties) { - AliyunDLFBaseProperties propertiesObj = new AliyunDLFBaseProperties(); - ConnectorPropertiesUtils.bindConnectorProperties(propertiesObj, properties); - propertiesObj.checkAndInit(); - return propertiesObj; - } - - private ParamRules buildRules() { - return new ParamRules() - .require(dlfAccessKey, "dlf.access_key is required") - .require(dlfSecretKey, "dlf.secret_key is required"); - } - - private void checkAndInit() { - buildRules().validate(); - if (StringUtils.isBlank(dlfEndpoint) && StringUtils.isNotBlank(dlfRegion)) { - if (BooleanUtils.toBoolean(dlfAccessPublic)) { - dlfEndpoint = "dlf." + dlfRegion + ".aliyuncs.com"; - } else { - dlfEndpoint = "dlf-vpc." + dlfRegion + ".aliyuncs.com"; - } - } - if (StringUtils.isBlank(dlfEndpoint)) { - throw new StoragePropertiesException("dlf.endpoint is required."); - } - if (StringUtils.isBlank(dlfCatalogId)) { - this.dlfCatalogId = dlfUid; - } - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/HMSBaseProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/HMSBaseProperties.java deleted file mode 100644 index 75e5ef34a00ab6..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/HMSBaseProperties.java +++ /dev/null @@ -1,223 +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.datasource.property.metastore; - -import org.apache.doris.common.CatalogConfigFileUtils; -import org.apache.doris.common.Config; -import org.apache.doris.common.security.authentication.AuthenticationConfig; -import org.apache.doris.common.security.authentication.HadoopAuthenticator; -import org.apache.doris.common.security.authentication.KerberosAuthenticationConfig; -import org.apache.doris.foundation.property.ConnectorPropertiesUtils; -import org.apache.doris.foundation.property.ConnectorProperty; -import org.apache.doris.foundation.property.ParamRules; - -import com.google.common.base.Strings; -import lombok.Getter; -import org.apache.commons.lang3.StringUtils; -import org.apache.hadoop.hive.conf.HiveConf; -import org.apache.hadoop.hive.conf.HiveConf.ConfVars; - -import java.util.HashMap; -import java.util.Map; - -/** - * Base properties for Hive Metastore. - */ -public class HMSBaseProperties { - public static final String HIVE_METASTORE_TYPE = "hive.metastore.type"; - public static final String DLF_TYPE = "dlf"; - public static final String GLUE_TYPE = "glue"; - public static final String HIVE_VERSION = "hive.version"; - public static final String HIVE_METASTORE_URIS = "hive.metastore.uris"; - - @Getter - @ConnectorProperty(names = {HIVE_METASTORE_URIS, "uri"}, - description = "The uri of the hive metastore.") - private String hiveMetastoreUri = ""; - - @ConnectorProperty(names = {"hive.metastore.authentication.type"}, - required = false, - description = "The authentication type of the hive metastore.") - private String hiveMetastoreAuthenticationType = "none"; - - @ConnectorProperty(names = {"hive.conf.resources"}, - required = false, - description = "The conf resources of the hive metastore.") - private String hiveConfResourcesConfig = ""; - - @ConnectorProperty(names = {"hive.metastore.service.principal", "hive.metastore.kerberos.principal"}, - required = false, - description = "The service principal of the hive metastore.") - private String hiveMetastoreServicePrincipal = ""; - - @ConnectorProperty(names = {"hive.metastore.client.principal"}, - required = false, - description = "The client principal of the hive metastore.") - private String hiveMetastoreClientPrincipal = ""; - - @ConnectorProperty(names = {"hive.metastore.client.keytab"}, - required = false, - description = "The client keytab of the hive metastore.") - private String hiveMetastoreClientKeytab = ""; - - @ConnectorProperty(names = {"hadoop.security.authentication"}, - required = false, - description = "The authentication type of HDFS. The default value is 'simple'.") - private String hdfsAuthenticationType = ""; - - @ConnectorProperty(names = {"hive.metastore.username", "hadoop.username"}, - required = false, - description = "The user name for the Hive Metastore service. " - + "If not set, it will use the 'hadoop'.") - private String hmsUserName; - - @ConnectorProperty(names = {"hadoop.kerberos.principal"}, - required = false, - description = "The principal of the kerberos authentication.") - private String hdfsKerberosPrincipal = ""; - - @ConnectorProperty(names = {"hadoop.kerberos.keytab"}, - required = false, - description = "The keytab of the kerberos authentication.") - private String hdfsKerberosKeytab = ""; - - @Getter - private HiveConf hiveConf; - - @Getter - private HadoopAuthenticator hmsAuthenticator; - - private Map userOverriddenHiveConfig = new HashMap<>(); - - private Map origProps; - - public HMSBaseProperties(Map origProps) { - this.origProps = origProps; - } - - public static HMSBaseProperties of(Map properties) { - HMSBaseProperties propertiesObj = new HMSBaseProperties(properties); - ConnectorPropertiesUtils.bindConnectorProperties(propertiesObj, properties); - propertiesObj.checkAndInit(); - return propertiesObj; - } - - private ParamRules buildRules() { - return new ParamRules() - .require(hiveMetastoreUri, "hive.metastore.uris or uri is required") - .forbidIf(hiveMetastoreAuthenticationType, "simple", new String[]{ - hiveMetastoreClientPrincipal, hiveMetastoreClientKeytab}, - "hive.metastore.client.principal and hive.metastore.client.keytab cannot be set when " - + "hive.metastore.authentication.type is simple" - ) - .requireIf(hiveMetastoreAuthenticationType, "kerberos", new String[]{ - hiveMetastoreClientPrincipal, hiveMetastoreClientKeytab}, - "hive.metastore.client.principal and hive.metastore.client.keytab are required when " - + "hive.metastore.authentication.type is kerberos"); - } - - /** - * Helper class for initializing the Hadoop authenticator (HadoopAuthenticator). - *

    - * Authentication initialization logic: - * 1. First, check the Hive Metastore authentication type (hiveMetastoreAuthenticationType): - * - If set to "kerberos", use the Hive Metastore principal and keytab for Kerberos authentication; - * - If set to "simple", use the simple authentication method; - * 2. If Hive Metastore configuration does not match, fallback to checking HDFS Kerberos - * configuration (hdfsAuthenticationType): - * - If set to "kerberos", use the HDFS principal and keytab for Kerberos authentication; - * - Note: This branch exists purely for backward compatibility — using HDFS keytab is a - * workaround, not the preferred approach; - * 3. If none of the above conditions are met, fall back to simple authentication as the default. - *

    - * The overall design prioritizes Hive Metastore's authentication settings. - * HDFS Kerberos usage is retained for legacy compatibility, but unification under Hive configuration is - * strongly recommended. - */ - private void initHadoopAuthenticator() { - if (StringUtils.isNotBlank(hiveMetastoreServicePrincipal)) { - hiveConf.set("hive.metastore.kerberos.principal", hiveMetastoreServicePrincipal); - } - if (StringUtils.isNotBlank(origProps.get(AuthenticationConfig.HADOOP_SECURITY_AUTH_TO_LOCAL))) { - hiveConf.set(AuthenticationConfig.HADOOP_SECURITY_AUTH_TO_LOCAL, - origProps.get(AuthenticationConfig.HADOOP_SECURITY_AUTH_TO_LOCAL)); - } - if (this.hiveMetastoreAuthenticationType.equalsIgnoreCase("kerberos")) { - hiveConf.set("hadoop.security.authentication", "kerberos"); - hiveConf.set("hive.metastore.sasl.enabled", "true"); - KerberosAuthenticationConfig authenticationConfig = new KerberosAuthenticationConfig( - this.hiveMetastoreClientPrincipal, this.hiveMetastoreClientKeytab, hiveConf); - this.hmsAuthenticator = HadoopAuthenticator.getHadoopAuthenticator(authenticationConfig); - return; - } - if (this.hiveMetastoreAuthenticationType.equalsIgnoreCase("simple")) { - AuthenticationConfig authenticationConfig = AuthenticationConfig.getSimpleAuthenticationConfig(hiveConf); - this.hmsAuthenticator = HadoopAuthenticator.getHadoopAuthenticator(authenticationConfig); - return; - } - - if (StringUtils.isNotBlank(this.hdfsAuthenticationType) - && this.hdfsAuthenticationType.equalsIgnoreCase("kerberos")) { - KerberosAuthenticationConfig authenticationConfig = new KerberosAuthenticationConfig( - this.hdfsKerberosPrincipal, this.hdfsKerberosKeytab, hiveConf); - hiveConf.set("hadoop.security.authentication", "kerberos"); - hiveConf.set("hive.metastore.sasl.enabled", "true"); - this.hmsAuthenticator = HadoopAuthenticator.getHadoopAuthenticator(authenticationConfig); - return; - } - AuthenticationConfig simpleAuthenticationConfig = AuthenticationConfig.getSimpleAuthenticationConfig(hiveConf); - this.hmsAuthenticator = HadoopAuthenticator.getHadoopAuthenticator(simpleAuthenticationConfig); - } - - - private HiveConf loadHiveConfFromFile(String resourceConfig) { - if (Strings.isNullOrEmpty(resourceConfig)) { - return new HiveConf(); - } - return CatalogConfigFileUtils.loadHiveConfFromHiveConfDir(resourceConfig); - } - - private void checkAndInit() { - buildRules().validate(); - this.hiveConf = loadHiveConfFromFile(hiveConfResourcesConfig); - initUserHiveConfig(origProps); - userOverriddenHiveConfig.forEach(hiveConf::set); - hiveConf.set("hive.metastore.uris", hiveMetastoreUri); - if (StringUtils.isNotBlank(hmsUserName)) { - hiveConf.set(AuthenticationConfig.HADOOP_USER_NAME, hmsUserName); - } - if (!userOverriddenHiveConfig.containsKey(ConfVars.METASTORE_CLIENT_SOCKET_TIMEOUT.toString())) { - // use Config.hive_metastore_client_timeout_second as default timeout - HiveConf.setVar(hiveConf, HiveConf.ConfVars.METASTORE_CLIENT_SOCKET_TIMEOUT, - String.valueOf(Config.hive_metastore_client_timeout_second)); - } - initHadoopAuthenticator(); - } - - private void initUserHiveConfig(Map origProps) { - if (origProps == null || origProps.isEmpty()) { - return; - } - origProps.forEach((key, value) -> { - if (key.startsWith("hive.")) { - userOverriddenHiveConfig.put(key, value); - } - }); - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/HiveAliyunDLFMetaStoreProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/HiveAliyunDLFMetaStoreProperties.java deleted file mode 100644 index e78cd73b7229f1..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/HiveAliyunDLFMetaStoreProperties.java +++ /dev/null @@ -1,62 +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.datasource.property.metastore; - -import org.apache.doris.datasource.property.storage.OSSProperties; - -import com.aliyun.datalake.metastore.common.DataLakeConfig; -import org.apache.hadoop.hive.conf.HiveConf; - -import java.util.Map; - -public class HiveAliyunDLFMetaStoreProperties extends AbstractHiveProperties { - - private AliyunDLFBaseProperties baseProperties; - - private OSSProperties ossProperties; - - public HiveAliyunDLFMetaStoreProperties(Map origProps) { - super(Type.DLF, origProps); - } - - @Override - public void initNormalizeAndCheckProps() { - super.initNormalizeAndCheckProps(); - ossProperties = OSSProperties.of(origProps); - baseProperties = AliyunDLFBaseProperties.of(origProps); - initHiveConf(); - } - - private void initHiveConf() { - // @see com.aliyun.datalake.metastore.hive.common.utils.ConfigUtils - // todo support other parameters - hiveConf = new HiveConf(); - hiveConf.addResource(ossProperties.hadoopStorageConfig); - hiveConf.set(DataLakeConfig.CATALOG_ACCESS_KEY_ID, baseProperties.dlfAccessKey); - hiveConf.set(DataLakeConfig.CATALOG_ACCESS_KEY_SECRET, baseProperties.dlfSecretKey); - hiveConf.set(DataLakeConfig.CATALOG_ENDPOINT, baseProperties.dlfEndpoint); - hiveConf.set(DataLakeConfig.CATALOG_REGION_ID, baseProperties.dlfRegion); - hiveConf.set(DataLakeConfig.CATALOG_SECURITY_TOKEN, baseProperties.dlfSessionToken); - hiveConf.set(DataLakeConfig.CATALOG_USER_ID, baseProperties.dlfUid); - hiveConf.set(DataLakeConfig.CATALOG_ID, baseProperties.dlfCatalogId); - hiveConf.set(DataLakeConfig.CATALOG_PROXY_MODE, baseProperties.dlfProxyMode); - hiveConf.set("hive.metastore.type", "dlf"); - hiveConf.set("type", "hms"); - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/HiveGlueMetaStoreProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/HiveGlueMetaStoreProperties.java deleted file mode 100644 index ea0f5437d6cc9b..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/HiveGlueMetaStoreProperties.java +++ /dev/null @@ -1,140 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -package org.apache.doris.datasource.property.metastore; - -import org.apache.doris.datasource.property.common.AwsCredentialsProviderMode; -import org.apache.doris.foundation.property.ConnectorProperty; - -import com.amazonaws.ClientConfiguration; -import com.amazonaws.glue.catalog.util.AWSGlueConfig; -import lombok.Getter; -import org.apache.hadoop.hive.conf.HiveConf; - -import java.util.Map; - -public class HiveGlueMetaStoreProperties extends AbstractHiveProperties { - - // ========== Constants ========== - public static final String AWS_GLUE_SECRET_KEY_KEY = "aws.glue.secret-key"; - public static final String AWS_GLUE_ACCESS_KEY_KEY = "aws.glue.access-key"; - public static final String AWS_GLUE_ENDPOINT_KEY = "aws.glue.endpoint"; - public static final String AWS_REGION_KEY = "aws.region"; - public static final String AWS_GLUE_SESSION_TOKEN_KEY = "aws.glue.session-token"; - public static final String AWS_GLUE_CATALOG_SEPARATOR_KEY = "aws.glue.catalog.separator"; - public static final String AWS_GLUE_CONNECTION_TIMEOUT_KEY = "aws.glue.connection-timeout"; - public static final int DEFAULT_CONNECTION_TIMEOUT = ClientConfiguration.DEFAULT_CONNECTION_TIMEOUT; - public static final String AWS_GLUE_MAX_CONNECTIONS_KEY = "aws.glue.max-connections"; - public static final int DEFAULT_MAX_CONNECTIONS = ClientConfiguration.DEFAULT_MAX_CONNECTIONS; - public static final String AWS_GLUE_MAX_RETRY_KEY = "aws.glue.max-error-retries"; - public static final int DEFAULT_MAX_RETRY = 5; - public static final String AWS_GLUE_SOCKET_TIMEOUT_KEY = "aws.glue.socket-timeout"; - public static final int DEFAULT_SOCKET_TIMEOUT = ClientConfiguration.DEFAULT_SOCKET_TIMEOUT; - public static final String AWS_CATALOG_CREDENTIALS_PROVIDER_FACTORY_CLASS_KEY = - "aws.catalog.credentials.provider.factory.class"; - - // ========== Fields ========== - @Getter - private AWSGlueMetaStoreBaseProperties baseProperties; - - @ConnectorProperty(names = {AWS_GLUE_MAX_RETRY_KEY}, - required = false, - description = "Maximum number of retry attempts for AWS Glue errors.") - protected int awsGlueMaxErrorRetries = DEFAULT_MAX_RETRY; - - @ConnectorProperty(names = {AWS_GLUE_MAX_CONNECTIONS_KEY}, - required = false, - description = "Maximum allowed connections for AWS Glue.") - protected int awsGlueMaxConnections = DEFAULT_MAX_CONNECTIONS; - - @ConnectorProperty(names = {AWS_GLUE_CONNECTION_TIMEOUT_KEY}, - required = false, - description = "Connection timeout duration (in milliseconds) for AWS Glue.") - protected int awsGlueConnectionTimeout = DEFAULT_CONNECTION_TIMEOUT; - - @ConnectorProperty(names = {AWS_GLUE_SOCKET_TIMEOUT_KEY}, - required = false, - description = "Socket timeout duration (in milliseconds) for AWS Glue.") - protected int awsGlueSocketTimeout = DEFAULT_SOCKET_TIMEOUT; - - @ConnectorProperty(names = {AWS_GLUE_CATALOG_SEPARATOR_KEY}, - required = false, - description = "Catalog separator character for AWS Glue.") - protected String awsGlueCatalogSeparator = ""; - - @ConnectorProperty(names = {"glue.credentials_provider_type"}, - required = false, - description = "The credentials provider type of S3. " - + "Options are: DEFAULT, ASSUME_ROLE, ANONYMOUS, ENVIRONMENT, SYSTEM_PROPERTIES, " - + "WEB_IDENTITY_TOKEN_FILE, INSTANCE_PROFILE. " - + "If not set, it will use the default provider chain of AWS SDK.") - protected String awsCredentialsProviderType = AwsCredentialsProviderMode.DEFAULT.name(); - - // ========== Constructor ========== - - /** - * Constructs an instance with the given metastore type and original properties. - * - * @param type The metastore type. - * @param origProps The original configuration properties. - */ - protected HiveGlueMetaStoreProperties(Type type, Map origProps) { - super(type, origProps); - } - - // ========== Initialization Methods ========== - @Override - public void initNormalizeAndCheckProps() { - super.initNormalizeAndCheckProps(); - baseProperties = AWSGlueMetaStoreBaseProperties.of(origProps); - initHiveConf(); - } - - /** - * Initializes the HiveConf object with AWS Glue related properties. - */ - private void initHiveConf() { - hiveConf = new HiveConf(); - hiveConf.set(AWS_GLUE_ENDPOINT_KEY, baseProperties.glueEndpoint); - hiveConf.set(AWS_REGION_KEY, baseProperties.glueRegion); - hiveConf.set(AWS_GLUE_MAX_RETRY_KEY, String.valueOf(awsGlueMaxErrorRetries)); - hiveConf.set(AWS_GLUE_MAX_CONNECTIONS_KEY, String.valueOf(awsGlueMaxConnections)); - hiveConf.set(AWS_GLUE_CONNECTION_TIMEOUT_KEY, String.valueOf(awsGlueConnectionTimeout)); - hiveConf.set(AWS_GLUE_SOCKET_TIMEOUT_KEY, String.valueOf(awsGlueSocketTimeout)); - hiveConf.set(AWS_GLUE_CATALOG_SEPARATOR_KEY, awsGlueCatalogSeparator); - hiveConf.set(AWS_CATALOG_CREDENTIALS_PROVIDER_FACTORY_CLASS_KEY, - "com.amazonaws.glue.catalog.credentials.ConfigurationAWSCredentialsProviderFactory"); - hiveConf.set("hive.metastore.type", "glue"); - setHiveConfPropertiesIfNotNull(hiveConf, AWSGlueConfig.AWS_GLUE_ACCESS_KEY, baseProperties.glueAccessKey); - setHiveConfPropertiesIfNotNull(hiveConf, AWSGlueConfig.AWS_GLUE_SECRET_KEY, baseProperties.glueSecretKey); - setHiveConfPropertiesIfNotNull(hiveConf, AWSGlueConfig.AWS_GLUE_SESSION_TOKEN, baseProperties.glueSessionToken); - setHiveConfPropertiesIfNotNull(hiveConf, AWSGlueConfig.AWS_GLUE_ROLE_ARN, baseProperties.glueIAMRole); - setHiveConfPropertiesIfNotNull(hiveConf, AWSGlueConfig.AWS_GLUE_EXTERNAL_ID, baseProperties.glueExternalId); - setHiveConfPropertiesIfNotNull(hiveConf, - AWSGlueConfig.AWS_CREDENTIALS_PROVIDER_MODE, awsCredentialsProviderType); - } - - private static void setHiveConfPropertiesIfNotNull(HiveConf hiveConf, String key, String value) { - if (value != null) { - hiveConf.set(key, value); - } - } - - public HiveGlueMetaStoreProperties(Map origProps) { - super(Type.GLUE, origProps); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/HiveHMSProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/HiveHMSProperties.java deleted file mode 100644 index 5a22cfd7c9586e..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/HiveHMSProperties.java +++ /dev/null @@ -1,69 +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.datasource.property.metastore; - -import org.apache.doris.common.Config; -import org.apache.doris.common.security.authentication.HadoopExecutionAuthenticator; -import org.apache.doris.foundation.property.ConnectorProperty; - -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.BooleanUtils; - -import java.util.Map; - -@Slf4j -public class HiveHMSProperties extends AbstractHiveProperties { - - @Getter - private HMSBaseProperties hmsBaseProperties; - - @ConnectorProperty(names = {"hive.enable_hms_events_incremental_sync"}, - required = false, - description = "Whether to enable incremental sync of hms events.") - private boolean hmsEventsIncrementalSyncEnabledInput = Config.enable_hms_events_incremental_sync; - - @ConnectorProperty(names = {"hive.hms_events_batch_size_per_rpc"}, - required = false, - description = "The batch size of hms events per rpc.") - private int hmsEventisBatchSizePerRpcInput = Config.hms_events_batch_size_per_rpc; - - public HiveHMSProperties(Map origProps) { - super(Type.HMS, origProps); - } - - @Override - protected String getResourceConfigPropName() { - return "hive.conf.resources"; - } - - @Override - public void initNormalizeAndCheckProps() { - super.initNormalizeAndCheckProps(); - initRefreshParams(); - hmsBaseProperties = HMSBaseProperties.of(origProps); - this.hiveConf = hmsBaseProperties.getHiveConf(); - this.executionAuthenticator = new HadoopExecutionAuthenticator(hmsBaseProperties.getHmsAuthenticator()); - } - - private void initRefreshParams() { - this.hmsEventsIncrementalSyncEnabled = BooleanUtils.toBoolean(hmsEventsIncrementalSyncEnabledInput); - this.hmsEventsBatchSizePerRpc = hmsEventisBatchSizePerRpcInput; - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/HivePropertiesFactory.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/HivePropertiesFactory.java deleted file mode 100644 index a379a110fbf5f2..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/HivePropertiesFactory.java +++ /dev/null @@ -1,46 +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.datasource.property.metastore; - -import java.util.Map; - -/** - * Factory for creating {@link MetastoreProperties} instances for Hive-based catalogs. - *

    - * Supported subtypes include: - * - "default" or "hms" -> {@link HiveHMSProperties} - * - "glue" -> {@link HiveGlueMetaStoreProperties} - * - "dlf" -> {@link HiveAliyunDLFMetaStoreProperties} - */ -public class HivePropertiesFactory extends AbstractMetastorePropertiesFactory { - - private static final String KEY = "hive.metastore.type"; - private static final String DEFAULT_TYPE = "default"; - - public HivePropertiesFactory() { - register("default", HiveHMSProperties::new); - register("hms", HiveHMSProperties::new); - register("glue", HiveGlueMetaStoreProperties::new); - register("dlf", HiveAliyunDLFMetaStoreProperties::new); - } - - @Override - public MetastoreProperties create(Map props) { - return createInternal(props, KEY, DEFAULT_TYPE); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergAliyunDLFMetaStoreProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergAliyunDLFMetaStoreProperties.java deleted file mode 100644 index 26b7149bbc7af3..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergAliyunDLFMetaStoreProperties.java +++ /dev/null @@ -1,67 +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.datasource.property.metastore; - -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.dlf.DLFCatalog; -import org.apache.doris.datasource.property.storage.StorageProperties; - -import com.aliyun.datalake.metastore.common.DataLakeConfig; -import org.apache.hadoop.conf.Configuration; -import org.apache.iceberg.catalog.Catalog; - -import java.util.List; -import java.util.Map; - -public class IcebergAliyunDLFMetaStoreProperties extends AbstractIcebergProperties { - - private AliyunDLFBaseProperties baseProperties; - - - protected IcebergAliyunDLFMetaStoreProperties(Map props) { - super(props); - super.initNormalizeAndCheckProps(); - baseProperties = AliyunDLFBaseProperties.of(origProps); - } - - @Override - public String getIcebergCatalogType() { - return IcebergExternalCatalog.ICEBERG_DLF; - } - - @Override - public Catalog initCatalog(String catalogName, Map catalogProps, - List storagePropertiesList) { - DLFCatalog dlfCatalog = new DLFCatalog(); - // @see com.aliyun.datalake.metastore.hive.common.utils.ConfigUtils - Configuration conf = new Configuration(); - conf.set(DataLakeConfig.CATALOG_ACCESS_KEY_ID, baseProperties.dlfAccessKey); - conf.set(DataLakeConfig.CATALOG_ACCESS_KEY_SECRET, baseProperties.dlfSecretKey); - conf.set(DataLakeConfig.CATALOG_ENDPOINT, baseProperties.dlfEndpoint); - conf.set(DataLakeConfig.CATALOG_REGION_ID, baseProperties.dlfRegion); - conf.set(DataLakeConfig.CATALOG_SECURITY_TOKEN, baseProperties.dlfSessionToken); - conf.set(DataLakeConfig.CATALOG_USER_ID, baseProperties.dlfUid); - conf.set(DataLakeConfig.CATALOG_ID, baseProperties.dlfCatalogId); - conf.set(DataLakeConfig.CATALOG_PROXY_MODE, baseProperties.dlfProxyMode); - conf.set("hive.metastore.type", "dlf"); - conf.set("type", "hms"); - dlfCatalog.setConf(conf); - dlfCatalog.initialize(catalogName, catalogProps); - return dlfCatalog; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergFileSystemMetaStoreProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergFileSystemMetaStoreProperties.java deleted file mode 100644 index 3f7327b786cfed..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergFileSystemMetaStoreProperties.java +++ /dev/null @@ -1,73 +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.datasource.property.metastore; - -import org.apache.doris.common.security.authentication.HadoopExecutionAuthenticator; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.property.storage.HdfsProperties; -import org.apache.doris.datasource.property.storage.StorageProperties; - -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.apache.hadoop.conf.Configuration; -import org.apache.iceberg.CatalogProperties; -import org.apache.iceberg.CatalogUtil; -import org.apache.iceberg.catalog.Catalog; - -import java.util.List; -import java.util.Map; - -public class IcebergFileSystemMetaStoreProperties extends AbstractIcebergProperties { - - @Override - public String getIcebergCatalogType() { - return IcebergExternalCatalog.ICEBERG_HADOOP; - } - - public IcebergFileSystemMetaStoreProperties(Map props) { - super(props); - } - - @Override - public Catalog initCatalog(String catalogName, Map catalogProps, - List storagePropertiesList) { - try { - Configuration configuration = new Configuration(); - toFileIOProperties(storagePropertiesList, catalogProps, configuration); - catalogProps.put(CatalogProperties.CATALOG_IMPL, CatalogUtil.ICEBERG_CATALOG_HADOOP); - buildExecutionAuthenticator(storagePropertiesList); - return this.executionAuthenticator.execute(() -> - buildIcebergCatalog(catalogName, catalogProps, configuration)); - } catch (Exception e) { - throw new RuntimeException("Failed to initialize iceberg filesystem catalog: " - + ExceptionUtils.getRootCauseMessage(e), e); - } - } - - private void buildExecutionAuthenticator(List storagePropertiesList) { - if (storagePropertiesList.size() == 1 && storagePropertiesList.get(0) instanceof HdfsProperties) { - HdfsProperties hdfsProps = (HdfsProperties) storagePropertiesList.get(0); - if (hdfsProps.isKerberos()) { - // NOTE: Custom FileIO implementation (KerberizedHadoopFileIO) is commented out by default. - // Using FileIO for Kerberos authentication may cause serialization issues when accessing - // Iceberg system tables (e.g., history, snapshots, manifests). - //props.put(CatalogProperties.FILE_IO_IMPL,"org.apache.doris.datasource.iceberg.fileio.DelegateFileIO"); - this.executionAuthenticator = new HadoopExecutionAuthenticator(hdfsProps.getHadoopAuthenticator()); - } - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergGlueMetaStoreProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergGlueMetaStoreProperties.java deleted file mode 100644 index 1517a599094c7d..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergGlueMetaStoreProperties.java +++ /dev/null @@ -1,112 +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.datasource.property.metastore; - -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.property.storage.S3Properties; -import org.apache.doris.datasource.property.storage.StorageProperties; - -import lombok.Getter; -import org.apache.commons.lang3.StringUtils; -import org.apache.iceberg.CatalogProperties; -import org.apache.iceberg.CatalogUtil; -import org.apache.iceberg.aws.AwsProperties; -import org.apache.iceberg.aws.s3.S3FileIOProperties; -import org.apache.iceberg.catalog.Catalog; - -import java.util.List; -import java.util.Map; - -public class IcebergGlueMetaStoreProperties extends AbstractIcebergProperties { - - @Getter - public AWSGlueMetaStoreBaseProperties glueProperties; - - public S3Properties s3Properties; - - // As a default placeholder. The path just use for 'create table', query stmt will not use it. - private static final String CHECKED_WAREHOUSE = "s3://doris"; - - public IcebergGlueMetaStoreProperties(Map props) { - super(props); - } - - @Override - public String getIcebergCatalogType() { - return IcebergExternalCatalog.ICEBERG_GLUE; - } - - @Override - public void initNormalizeAndCheckProps() { - super.initNormalizeAndCheckProps(); - glueProperties = AWSGlueMetaStoreBaseProperties.of(origProps); - glueProperties.requireExplicitGlueCredentials(); - s3Properties = S3Properties.of(origProps); - } - - @Override - public Catalog initCatalog(String catalogName, Map catalogProps, - List storagePropertiesList) { - appendS3Props(catalogProps); - appendGlueProps(catalogProps); - catalogProps.put("client.region", glueProperties.glueRegion); - catalogProps.putIfAbsent(CatalogProperties.WAREHOUSE_LOCATION, CHECKED_WAREHOUSE); - // can not set - catalogProps.remove(CatalogUtil.ICEBERG_CATALOG_TYPE); - catalogProps.put(CatalogProperties.CATALOG_IMPL, CatalogUtil.ICEBERG_CATALOG_GLUE); - return buildIcebergCatalog(catalogName, catalogProps, null); - } - - private void appendS3Props(Map props) { - props.put(S3FileIOProperties.ACCESS_KEY_ID, s3Properties.getAccessKey()); - props.put(S3FileIOProperties.SECRET_ACCESS_KEY, s3Properties.getSecretKey()); - props.put(S3FileIOProperties.ENDPOINT, s3Properties.getEndpoint()); - props.put(S3FileIOProperties.PATH_STYLE_ACCESS, s3Properties.getUsePathStyle()); - props.put(S3FileIOProperties.SESSION_TOKEN, s3Properties.getSessionToken()); - } - - private void appendGlueProps(Map props) { - props.put(AwsProperties.GLUE_CATALOG_ENDPOINT, glueProperties.glueEndpoint); - - if (StringUtils.isNotBlank(glueProperties.glueAccessKey) && StringUtils - .isNotBlank(glueProperties.glueSecretKey)) { - props.put("client.credentials-provider", - "com.amazonaws.glue.catalog.credentials.ConfigurationAWSCredentialsProvider2x"); - props.put("client.credentials-provider.glue.access_key", glueProperties.glueAccessKey); - props.put("client.credentials-provider.glue.secret_key", glueProperties.glueSecretKey); - props.put("aws.catalog.credentials.provider.factory.class", - "com.amazonaws.glue.catalog.credentials.ConfigurationAWSCredentialsProviderFactory"); - if (StringUtils.isNotBlank(glueProperties.glueSessionToken)) { - props.put("client.credentials-provider.glue.session_token", glueProperties.glueSessionToken); - } - return; - } - //IAM Assume Role - if (StringUtils.isNotBlank(glueProperties.glueIAMRole)) { - props.put(AwsProperties.CLIENT_FACTORY, - "org.apache.iceberg.aws.AssumeRoleAwsClientFactory"); - props.put("aws.region", glueProperties.glueRegion); - - props.put(AwsProperties.CLIENT_ASSUME_ROLE_ARN, glueProperties.glueIAMRole); - props.put(AwsProperties.CLIENT_ASSUME_ROLE_REGION, glueProperties.glueRegion); - if (StringUtils.isNotBlank(glueProperties.glueExternalId)) { - props.put(AwsProperties.CLIENT_ASSUME_ROLE_EXTERNAL_ID, glueProperties.glueExternalId); - } - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergHMSMetaStoreProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergHMSMetaStoreProperties.java deleted file mode 100644 index 94c4d19f9eba8d..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergHMSMetaStoreProperties.java +++ /dev/null @@ -1,94 +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.datasource.property.metastore; - -import org.apache.doris.common.security.authentication.HadoopExecutionAuthenticator; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.foundation.property.ConnectorProperty; - -import lombok.Getter; -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.apache.hadoop.conf.Configuration; -import org.apache.iceberg.CatalogProperties; -import org.apache.iceberg.CatalogUtil; -import org.apache.iceberg.catalog.Catalog; -import org.apache.iceberg.hive.HiveCatalog; - -import java.util.List; -import java.util.Map; - -/** - * @See org.apache.iceberg.hive.HiveCatalog - */ -public class IcebergHMSMetaStoreProperties extends AbstractIcebergProperties { - - public IcebergHMSMetaStoreProperties(Map props) { - super(props); - } - - @ConnectorProperty( - names = {HiveCatalog.LIST_ALL_TABLES}, - required = false, - description = "Whether to list all tables in the catalog. If true, the catalog will list all tables in the " - + "catalog, otherwise it will only list the tables that are registered in the catalog.") - private boolean listAllTables = true; - - @Getter - private HMSBaseProperties hmsBaseProperties; - - @Override - public String getIcebergCatalogType() { - return IcebergExternalCatalog.ICEBERG_HMS; - } - - @Override - public void initNormalizeAndCheckProps() { - super.initNormalizeAndCheckProps(); - hmsBaseProperties = HMSBaseProperties.of(origProps); - this.executionAuthenticator = new HadoopExecutionAuthenticator(hmsBaseProperties.getHmsAuthenticator()); - } - - @Override - public Catalog initCatalog(String catalogName, Map catalogProps, - List storagePropertiesList) { - try { - catalogProps.put(CatalogProperties.CATALOG_IMPL, CatalogUtil.ICEBERG_CATALOG_HIVE); - Configuration conf = buildHiveConfiguration(storagePropertiesList); - return this.executionAuthenticator.execute(() -> - buildIcebergCatalog(catalogName, catalogProps, conf)); - } catch (Exception e) { - throw new RuntimeException("Failed to initialize HiveCatalog for Iceberg. " - + "CatalogName=" + catalogName + ", msg :" + ExceptionUtils.getRootCauseMessage(e), e); - } - } - - /** - * Builds the Hadoop Configuration by adding hive-site.xml and storage-specific configs. - */ - private Configuration buildHiveConfiguration(List storagePropertiesList) { - Configuration conf = new Configuration(); - conf.addResource(hmsBaseProperties.getHiveConf()); - for (StorageProperties sp : storagePropertiesList) { - if (sp.getHadoopStorageConfig() != null) { - conf.addResource(sp.getHadoopStorageConfig()); - } - } - return conf; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergJdbcMetaStoreProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergJdbcMetaStoreProperties.java deleted file mode 100644 index 6be4b31dc2d0fe..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergJdbcMetaStoreProperties.java +++ /dev/null @@ -1,273 +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.datasource.property.metastore; - -import org.apache.doris.catalog.JdbcResource; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.foundation.property.ConnectorProperty; - -import org.apache.commons.lang3.StringUtils; -import org.apache.hadoop.conf.Configuration; -import org.apache.iceberg.CatalogProperties; -import org.apache.iceberg.CatalogUtil; -import org.apache.iceberg.catalog.Catalog; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.net.MalformedURLException; -import java.net.URL; -import java.net.URLClassLoader; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -public class IcebergJdbcMetaStoreProperties extends AbstractIcebergProperties { - private static final Logger LOG = LogManager.getLogger(IcebergJdbcMetaStoreProperties.class); - - private static final String JDBC_PREFIX = "jdbc."; - private static final Map DRIVER_CLASS_LOADER_CACHE = new ConcurrentHashMap<>(); - - private Map icebergJdbcCatalogProperties; - - @ConnectorProperty( - names = {"uri", "iceberg.jdbc.uri"}, - required = true, - description = "JDBC connection URI for the Iceberg JDBC catalog." - ) - private String uri = ""; - - @ConnectorProperty( - names = {"iceberg.jdbc.user"}, - required = false, - description = "Username for the Iceberg JDBC catalog." - ) - private String jdbcUser; - - @ConnectorProperty( - names = {"iceberg.jdbc.password"}, - required = false, - sensitive = true, - description = "Password for the Iceberg JDBC catalog." - ) - private String jdbcPassword; - - @ConnectorProperty( - names = {"iceberg.jdbc.init-catalog-tables"}, - required = false, - description = "Whether to create catalog tables if they do not exist." - ) - private String jdbcInitCatalogTables; - - @ConnectorProperty( - names = {"iceberg.jdbc.schema-version"}, - required = false, - description = "Iceberg JDBC catalog schema version (V0/V1)." - ) - private String jdbcSchemaVersion; - - @ConnectorProperty( - names = {"iceberg.jdbc.strict-mode"}, - required = false, - description = "Whether to enforce strict JDBC catalog schema checks." - ) - private String jdbcStrictMode; - - @ConnectorProperty( - names = {"iceberg.jdbc.catalog_name"}, - required = true, - description = "The Iceberg JDBC catalog_name used to isolate metadata in JDBC catalog tables." - ) - private String jdbcCatalogName; - - @ConnectorProperty( - names = {"iceberg.jdbc.driver_url"}, - required = false, - description = "JDBC driver JAR file path or URL. " - + "Can be a local file name (will look in $DORIS_HOME/plugins/jdbc_drivers/) " - + "or a full URL (http://, https://, file://)." - ) - private String driverUrl; - - @ConnectorProperty( - names = {"iceberg.jdbc.driver_class"}, - required = false, - description = "JDBC driver class name. If not specified, will be auto-detected from the JDBC URI." - ) - private String driverClass; - - public IcebergJdbcMetaStoreProperties(Map props) { - super(props); - } - - @Override - public String getIcebergCatalogType() { - return IcebergExternalCatalog.ICEBERG_JDBC; - } - - @Override - public void initNormalizeAndCheckProps() { - super.initNormalizeAndCheckProps(); - initIcebergJdbcCatalogProperties(); - } - - @Override - protected void checkRequiredProperties() { - super.checkRequiredProperties(); - if (StringUtils.isBlank(warehouse)) { - throw new IllegalArgumentException("Property warehouse is required."); - } - } - - @Override - public Catalog initCatalog(String catalogName, Map catalogProps, - List storagePropertiesList) { - catalogProps.putAll(getIcebergJdbcCatalogProperties()); - Configuration configuration = new Configuration(); - toFileIOProperties(storagePropertiesList, catalogProps, configuration); - // Support dynamic JDBC driver loading - // We need to register the driver with DriverManager because Iceberg uses DriverManager.getConnection() - // which doesn't respect Thread.contextClassLoader - if (StringUtils.isNotBlank(driverUrl)) { - registerJdbcDriver(driverUrl, driverClass); - LOG.info("Using dynamic JDBC driver from: {}", driverUrl); - } - catalogProps.remove("iceberg.jdbc.catalog_name"); - return buildIcebergCatalog(jdbcCatalogName, catalogProps, configuration); - } - - /** - * Register JDBC driver with DriverManager. - * This is necessary because DriverManager.getConnection() doesn't use Thread.contextClassLoader, - * it uses the caller's ClassLoader. By registering the driver, DriverManager can find it. - * - * @param driverUrl Path or URL to the JDBC driver JAR - * @param driverClassName Driver class name to register - */ - private void registerJdbcDriver(String driverUrl, String driverClassName) { - try { - String fullDriverUrl = JdbcResource.getFullDriverUrl(driverUrl); - URL url = new URL(fullDriverUrl); - - ClassLoader classLoader = DRIVER_CLASS_LOADER_CACHE.computeIfAbsent(url, u -> { - ClassLoader parent = getClass().getClassLoader(); - return URLClassLoader.newInstance(new URL[]{u}, parent); - }); - - if (StringUtils.isBlank(driverClassName)) { - throw new IllegalArgumentException("driver_class is required when driver_url is specified"); - } - - // Load the driver class and register it with DriverManager - Class driverClass = Class.forName(driverClassName, true, classLoader); - java.sql.Driver driver = (java.sql.Driver) driverClass.getDeclaredConstructor().newInstance(); - - // Wrap with a shim driver because DriverManager refuses to use a driver not loaded by system classloader - java.sql.DriverManager.registerDriver(new DriverShim(driver)); - LOG.info("Successfully registered JDBC driver: {} from {}", driverClassName, fullDriverUrl); - - } catch (MalformedURLException e) { - throw new IllegalArgumentException("Invalid driver URL: " + driverUrl, e); - } catch (ClassNotFoundException e) { - throw new IllegalArgumentException("Failed to load JDBC driver class: " + driverClassName, e); - } catch (Exception e) { - throw new RuntimeException("Failed to register JDBC driver: " + driverClassName, e); - } - } - - /** - * A shim driver that wraps the actual driver loaded from a custom ClassLoader. - * This is needed because DriverManager refuses to use a driver that wasn't loaded by the system classloader. - */ - private static class DriverShim implements java.sql.Driver { - private final java.sql.Driver delegate; - - DriverShim(java.sql.Driver delegate) { - this.delegate = delegate; - } - - @Override - public java.sql.Connection connect(String url, java.util.Properties info) throws java.sql.SQLException { - return delegate.connect(url, info); - } - - @Override - public boolean acceptsURL(String url) throws java.sql.SQLException { - return delegate.acceptsURL(url); - } - - @Override - public java.sql.DriverPropertyInfo[] getPropertyInfo(String url, java.util.Properties info) - throws java.sql.SQLException { - return delegate.getPropertyInfo(url, info); - } - - @Override - public int getMajorVersion() { - return delegate.getMajorVersion(); - } - - @Override - public int getMinorVersion() { - return delegate.getMinorVersion(); - } - - @Override - public boolean jdbcCompliant() { - return delegate.jdbcCompliant(); - } - - @Override - public java.util.logging.Logger getParentLogger() throws java.sql.SQLFeatureNotSupportedException { - return delegate.getParentLogger(); - } - } - - public Map getIcebergJdbcCatalogProperties() { - return Collections.unmodifiableMap(icebergJdbcCatalogProperties); - } - - private void initIcebergJdbcCatalogProperties() { - icebergJdbcCatalogProperties = new HashMap<>(); - icebergJdbcCatalogProperties.put(CatalogProperties.CATALOG_IMPL, CatalogUtil.ICEBERG_CATALOG_JDBC); - icebergJdbcCatalogProperties.put(CatalogProperties.URI, uri); - addIfNotBlank(icebergJdbcCatalogProperties, "jdbc.user", jdbcUser); - addIfNotBlank(icebergJdbcCatalogProperties, "jdbc.password", jdbcPassword); - addIfNotBlank(icebergJdbcCatalogProperties, "jdbc.init-catalog-tables", jdbcInitCatalogTables); - addIfNotBlank(icebergJdbcCatalogProperties, "jdbc.schema-version", jdbcSchemaVersion); - addIfNotBlank(icebergJdbcCatalogProperties, "jdbc.strict-mode", jdbcStrictMode); - - if (origProps != null) { - for (Map.Entry entry : origProps.entrySet()) { - String key = entry.getKey(); - if (key != null && key.startsWith(JDBC_PREFIX) - && !icebergJdbcCatalogProperties.containsKey(key)) { - icebergJdbcCatalogProperties.put(key, entry.getValue()); - } - } - } - } - - private static void addIfNotBlank(Map props, String key, String value) { - if (StringUtils.isNotBlank(value)) { - props.put(key, value); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergPropertiesFactory.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergPropertiesFactory.java deleted file mode 100644 index 333c6c44806ce7..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergPropertiesFactory.java +++ /dev/null @@ -1,53 +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.datasource.property.metastore; - -import java.util.Map; - -/** - * Factory for creating {@link MetastoreProperties} instances for Iceberg catalogs. - *

    - * The required property key is "iceberg.catalog.type". - *

    - * Supported subtypes include: - * - "rest" -> {@link IcebergRestProperties} - * - "glue" -> {@link IcebergGlueMetaStoreProperties} - * - "hms" -> {@link IcebergHMSMetaStoreProperties} - * - "hadoop" -> {@link IcebergFileSystemMetaStoreProperties} - * - "s3tables" -> {@link IcebergS3TablesMetaStoreProperties} - * - "dlf" -> {@link IcebergAliyunDLFMetaStoreProperties} - */ -public class IcebergPropertiesFactory extends AbstractMetastorePropertiesFactory { - - private static final String KEY = "iceberg.catalog.type"; - - public IcebergPropertiesFactory() { - register("rest", IcebergRestProperties::new); - register("glue", IcebergGlueMetaStoreProperties::new); - register("hms", IcebergHMSMetaStoreProperties::new); - register("hadoop", IcebergFileSystemMetaStoreProperties::new); - register("s3tables", IcebergS3TablesMetaStoreProperties::new); - register("dlf", IcebergAliyunDLFMetaStoreProperties::new); - register("jdbc", IcebergJdbcMetaStoreProperties::new); - } - - @Override - public MetastoreProperties create(Map props) { - return createInternal(props, KEY, null); // No default, strictly required - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergRestProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergRestProperties.java deleted file mode 100644 index d276b6c317d749..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergRestProperties.java +++ /dev/null @@ -1,556 +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.datasource.property.metastore; - -import org.apache.doris.datasource.DelegatedCredential; -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.datasource.iceberg.IcebergDelegatedCredentialUtils; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.ReauthenticatingRestSessionCatalog; -import org.apache.doris.datasource.property.common.AwsCredentialsProviderMode; -import org.apache.doris.datasource.property.common.IcebergAwsClientCredentialsProperties; -import org.apache.doris.datasource.property.storage.S3Properties; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.foundation.property.ConnectorProperty; -import org.apache.doris.foundation.property.ParamRules; - -import lombok.Getter; -import org.apache.hadoop.conf.Configuration; -import org.apache.iceberg.CatalogProperties; -import org.apache.iceberg.CatalogUtil; -import org.apache.iceberg.catalog.BaseViewSessionCatalog; -import org.apache.iceberg.catalog.Catalog; -import org.apache.iceberg.catalog.SessionCatalog; -import org.apache.iceberg.rest.RESTSessionCatalog; -import org.apache.iceberg.rest.auth.AuthProperties; -import org.apache.iceberg.rest.auth.OAuth2Properties; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.logging.log4j.util.Strings; - -import java.io.Closeable; -import java.io.IOException; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class IcebergRestProperties extends AbstractIcebergProperties { - - private static final Logger LOG = LogManager.getLogger(IcebergRestProperties.class); - - // REST catalog property constants - private static final String PREFIX_PROPERTY = "prefix"; - private static final String VENDED_CREDENTIALS_HEADER = "header.X-Iceberg-Access-Delegation"; - private static final String VENDED_CREDENTIALS_VALUE = "vended-credentials"; - private static final String ICEBERG_REST_ROLE_ARN = "iceberg.rest.role_arn"; - private static final String ICEBERG_REST_EXTERNAL_ID = "iceberg.rest.external-id"; - - private Map icebergRestCatalogProperties; - private S3Properties s3Properties; - - // The session-aware Iceberg REST catalog. We build a RESTSessionCatalog directly (instead of the - // all-in-one RESTCatalog) so that per-user delegated credentials can be attached per request via - // asCatalog(SessionContext) / asViewCatalog(SessionContext), without reflecting RESTCatalog's private - // sessionCatalog field. This is the single underlying catalog shared by the default and user-session - // paths; IcebergMetadataOps reads it via getRestSessionCatalog() and owns no other REST catalog. - // When the catalog authenticates with its own identity this is a ReauthenticatingRestSessionCatalog - // wrapping the RESTSessionCatalog, so an expired/rejected token is recovered by rebuilding the client - // instead of failing every request until the FE restarts. - private BaseViewSessionCatalog restSessionCatalog; - - @Getter - @ConnectorProperty(names = {"iceberg.rest.uri", "uri"}, - description = "The uri of the iceberg rest catalog service.") - private String icebergRestUri = ""; - - @ConnectorProperty(names = {"iceberg.rest.prefix"}, - required = false, - description = "The prefix of the iceberg rest catalog service.") - private String icebergRestPrefix = ""; - - @ConnectorProperty(names = {"iceberg.rest.security.type"}, - required = false, - description = "The security type of the iceberg rest catalog service," - + "optional: (none, oauth2), default: none.") - private String icebergRestSecurityType = "none"; - - @ConnectorProperty(names = {"iceberg.rest.session"}, - required = false, - description = "The session type of the iceberg rest catalog service," - + "optional: (none, user), default: none.") - private String icebergRestSession = "none"; - - @ConnectorProperty(names = {"iceberg.rest.session-timeout"}, - required = false, - description = "The session timeout of the iceberg rest catalog service.") - private String icebergRestSessionTimeout = "0"; - - @ConnectorProperty(names = {"iceberg.rest.oauth2.token"}, - required = false, - sensitive = true, - description = "The oauth2 token for the iceberg rest catalog service.") - private String icebergRestOauth2Token; - - @ConnectorProperty(names = {"iceberg.rest.oauth2.credential"}, - required = false, - sensitive = true, - description = "The oauth2 credential for the iceberg rest catalog service.") - private String icebergRestOauth2Credential; - - @ConnectorProperty(names = {"iceberg.rest.oauth2.scope"}, - required = false, - description = "The oauth2 scope for the iceberg rest catalog service.") - private String icebergRestOauth2Scope; - - @ConnectorProperty(names = {"iceberg.rest.oauth2.server-uri"}, - required = false, - description = "The oauth2 server uri for fetching token.") - private String icebergRestOauth2ServerUri; - - @ConnectorProperty(names = {"iceberg.rest.oauth2.token-refresh-enabled"}, - required = false, - description = "Enable oauth2 token refresh for the iceberg rest catalog service.") - private String icebergRestOauth2TokenRefreshEnabled = String.valueOf( - OAuth2Properties.TOKEN_REFRESH_ENABLED_DEFAULT); - - @Getter - @ConnectorProperty(names = {"iceberg.rest.oauth2.delegated-token-mode"}, - required = false, - description = "How user delegated tokens are passed to the iceberg rest catalog." - + " Supported values are: access_token, token_exchange. Default: access_token.") - private String icebergRestOauth2DelegatedTokenMode = DelegatedTokenMode.ACCESS_TOKEN.value; - - @ConnectorProperty(names = {"iceberg.rest.vended-credentials-enabled"}, - required = false, - description = "Enable vended credentials for the iceberg rest catalog service.") - private String icebergRestVendedCredentialsEnabled = "false"; - - @ConnectorProperty(names = {"iceberg.rest.nested-namespace-enabled"}, - required = false, - description = "Enable nested namespace for the iceberg rest catalog service.") - private String icebergRestNestedNamespaceEnabled = "false"; - - @ConnectorProperty(names = {"iceberg.rest.view-enabled"}, - required = false, - description = "Enable view operations for the iceberg rest catalog service.") - private String icebergRestViewEnabled = "true"; - - @ConnectorProperty(names = {"iceberg.rest.case-insensitive-name-matching"}, - required = false, - supported = false, - description = "Enable case insensitive name matching for the iceberg rest catalog service.") - private String icebergRestCaseInsensitiveNameMatching = "false"; - - @ConnectorProperty(names = {"iceberg.rest.case-insensitive-name-matching.cache-ttl"}, - required = false, - supported = false, - description = "The cache TTL for case insensitive name matching in ms.") - private String icebergRestCaseInsensitiveNameMatchingCacheTtlMs = "0"; - - // The following properties are specific to AWS Glue Rest Catalog - @ConnectorProperty(names = {"iceberg.rest.sigv4-enabled"}, - required = false, - description = "True for Glue Rest Catalog") - private String icebergRestSigV4Enabled = ""; - - @ConnectorProperty(names = {"iceberg.rest.signing-name"}, - required = false, - description = "The signing name for the iceberg rest catalog service.") - private String icebergRestSigningName = ""; - - @ConnectorProperty(names = {"iceberg.rest.signing-region"}, - required = false, - description = "The signing region for the iceberg rest catalog service.") - private String icebergRestSigningRegion = ""; - - @ConnectorProperty(names = {"iceberg.rest.access-key-id"}, - required = false, - description = "The access key ID for the iceberg rest catalog service.") - private String icebergRestAccessKeyId = ""; - - @ConnectorProperty(names = {"iceberg.rest.secret-access-key"}, - required = false, - sensitive = true, - description = "The secret access key for the iceberg rest catalog service.") - private String icebergRestSecretAccessKey = ""; - - @ConnectorProperty(names = {"iceberg.rest.session-token"}, - required = false, - sensitive = true, - description = "The session-token for the iceberg rest catalog service.") - private String icebergRestSessionToken = ""; - - @ConnectorProperty(names = {"iceberg.rest.credentials_provider_type"}, - required = false, - description = "The credentials provider type for AWS authentication. " - + "Options are: DEFAULT, INSTANCE_PROFILE, ENV, SYSTEM_PROPERTIES, " - + "WEB_IDENTITY, CONTAINER. " - + "If not set, defaults to DEFAULT (provider chain).") - private String icebergRestCredentialsProviderType = AwsCredentialsProviderMode.DEFAULT.name(); - - private AwsCredentialsProviderMode icebergRestCredentialsProviderMode; - - @ConnectorProperty(names = {"iceberg.rest.connection-timeout-ms"}, - required = false, - description = "Connection timeout in milliseconds for the REST catalog HTTP client. Default: 10000 (10s).") - private String icebergRestConnectionTimeoutMs = "10000"; - - @ConnectorProperty(names = {"iceberg.rest.socket-timeout-ms"}, - required = false, - description = "Socket timeout in milliseconds for the REST catalog HTTP client. Default: 60000 (60s).") - private String icebergRestSocketTimeoutMs = "60000"; - - @Getter - private DelegatedTokenMode delegatedTokenMode = DelegatedTokenMode.ACCESS_TOKEN; - - protected IcebergRestProperties(Map props) { - super(props); - } - - @Override - public String getIcebergCatalogType() { - return IcebergExternalCatalog.ICEBERG_REST; - } - - @Override - public Catalog initCatalog(String catalogName, Map catalogProps, - List storagePropertiesList) { - return initCatalog(catalogName, catalogProps, storagePropertiesList, SessionContext.empty()); - } - - @Override - protected Catalog initCatalog(String catalogName, Map catalogProps, - List storagePropertiesList, SessionContext sessionContext) { - catalogProps.putAll(getIcebergRestCatalogPropertiesForCatalogInit(sessionContext)); - Configuration configuration = new Configuration(); - toFileIOProperties(storagePropertiesList, catalogProps, configuration); - // Build the REST catalog as a RESTSessionCatalog rather than the all-in-one RESTCatalog. - // RESTSessionCatalog is session-aware: asCatalog(ctx)/asViewCatalog(ctx) attach per-user delegated - // credentials per request. RESTCatalog internally does exactly this (its default delegate is - // sessionCatalog.asCatalog(empty)), but hides the session catalog behind a private field; building - // it ourselves keeps that capability without reflection. The "type" key is dropped because the - // Iceberg SDK rejects "type" together with a concrete catalog impl. - catalogProps.remove(CatalogUtil.ICEBERG_CATALOG_TYPE); - RESTSessionCatalog rawSessionCatalog = buildRestSessionCatalog(catalogName, catalogProps, configuration); - if (sessionContext == null || !sessionContext.hasDelegatedCredential()) { - // The catalog authenticates with its own identity (e.g. an oauth2 client credential). If that - // credential's token expires and the client cannot refresh it (auth server briefly unreachable at - // refresh time), the RESTSessionCatalog is left rejecting every request with a 401 until the FE - // restarts. Wrap it so a 401 rebuilds the client (fresh token) and retries once. The rebuild - // supplier re-resolves from the same catalog-identity properties, so it can never capture a - // per-user delegated credential. - Map frozenProps = Collections.unmodifiableMap(new HashMap<>(catalogProps)); - this.restSessionCatalog = new ReauthenticatingRestSessionCatalog(rawSessionCatalog, - () -> buildRestSessionCatalog(catalogName, frozenProps, configuration)); - } else { - // Catalog initialization under a per-user delegated credential: recovery must not re-mint the - // client with that user's token, so no wrapper — behavior is unchanged from before. - this.restSessionCatalog = rawSessionCatalog; - } - // The default (non-delegated) Catalog is asCatalog(empty), identical to what RESTCatalog exposes. - return restSessionCatalog.asCatalog(SessionCatalog.SessionContext.createEmpty()); - } - - /** - * Builds and initializes the underlying {@link RESTSessionCatalog}. Extracted as a seam so tests can - * capture the resolved catalog properties without performing real REST/OAuth network calls. - */ - protected RESTSessionCatalog buildRestSessionCatalog(String catalogName, Map catalogProps, - Configuration configuration) { - RESTSessionCatalog sessionCatalog = new RESTSessionCatalog(); - CatalogUtil.configureHadoopConf(sessionCatalog, configuration); - sessionCatalog.initialize(catalogName, catalogProps); - return sessionCatalog; - } - - /** - * Returns the session-aware Iceberg REST catalog built by {@link #initCatalog}, or {@code null} if the - * catalog has not been initialized yet. Callers use it to obtain per-request catalogs via - * {@code asCatalog(SessionContext)} / {@code asViewCatalog(SessionContext)}. - */ - public BaseViewSessionCatalog getRestSessionCatalog() { - return restSessionCatalog; - } - - /** - * Closes the underlying {@link RESTSessionCatalog} and releases its REST client/auth resources. - * Safe to call multiple times and before initialization. - */ - public void closeRestSessionCatalog() { - if (restSessionCatalog instanceof Closeable) { - try { - ((Closeable) restSessionCatalog).close(); - } catch (IOException e) { - LOG.warn("Failed to close Iceberg REST session catalog", e); - } - } - restSessionCatalog = null; - } - - @Override - public void initNormalizeAndCheckProps() { - super.initNormalizeAndCheckProps(); - validateSecurityType(); - validateSessionType(); - delegatedTokenMode = DelegatedTokenMode.fromString(icebergRestOauth2DelegatedTokenMode); - icebergRestCredentialsProviderMode = - AwsCredentialsProviderMode.fromString(icebergRestCredentialsProviderType); - buildRules().validate(); - if (shouldUseS3PropertiesForRestCredentials()) { - s3Properties = S3Properties.of(origProps); - } - initIcebergRestCatalogProperties(); - } - - @Override - protected void checkRequiredProperties() { - } - - private void validateSecurityType() { - try { - Security.valueOf(icebergRestSecurityType.toUpperCase()); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Invalid security type: " + icebergRestSecurityType - + ". Supported values are: none, oauth2"); - } - } - - private void validateSessionType() { - try { - Session.valueOf(icebergRestSession.toUpperCase()); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Invalid session type: " + icebergRestSession - + ". Supported values are: none, user"); - } - if (isIcebergRestUserSessionEnabled() && !"oauth2".equalsIgnoreCase(icebergRestSecurityType)) { - throw new IllegalArgumentException("iceberg.rest.session=user requires oauth2 security type"); - } - } - - private ParamRules buildRules() { - ParamRules rules = new ParamRules() - // OAuth2 requires either credential or token, but not both - .mutuallyExclusive(icebergRestOauth2Credential, icebergRestOauth2Token, - "OAuth2 cannot have both credential and token configured"); - - // Custom validation: OAuth2 scope should not be used with token - if (Strings.isNotBlank(icebergRestOauth2Token) && Strings.isNotBlank(icebergRestOauth2Scope)) { - throw new IllegalArgumentException("OAuth2 scope is only applicable when using credential, not token"); - } - // Custom validation: If OAuth2 is enabled, require either credential or token - if ("oauth2".equalsIgnoreCase(icebergRestSecurityType)) { - boolean hasCredential = Strings.isNotBlank(icebergRestOauth2Credential); - boolean hasToken = Strings.isNotBlank(icebergRestOauth2Token); - if (!hasCredential && !hasToken && !isIcebergRestUserSessionEnabled()) { - throw new IllegalArgumentException("OAuth2 requires either credential or token"); - } - } - - // When signing-name is glue or s3tables: require signing-region and sigv4-enabled - rules.requireIf(icebergRestSigningName, "glue", - new String[] {icebergRestSigningRegion, icebergRestSigV4Enabled}, - "Rest Catalog requires signing-region and sigv4-enabled set to true when signing-name is glue"); - rules.requireIf(icebergRestSigningName, "s3tables", - new String[] {icebergRestSigningRegion, icebergRestSigV4Enabled}, - "Rest Catalog requires signing-region and sigv4-enabled set to true when signing-name is s3tables"); - - rejectUnsupportedAwsAssumeRoleProperty(ICEBERG_REST_ROLE_ARN); - rejectUnsupportedAwsAssumeRoleProperty(ICEBERG_REST_EXTERNAL_ID); - - // access-key-id and secret-access-key must be set together when either is set - rules.requireTogether(new String[] {icebergRestAccessKeyId, icebergRestSecretAccessKey}, - "iceberg.rest.access-key-id and iceberg.rest.secret-access-key must be set together"); - - return rules; - } - - private void rejectUnsupportedAwsAssumeRoleProperty(String propertyName) { - if (Strings.isNotBlank(origProps.get(propertyName))) { - throw new IllegalArgumentException(propertyName + " is not supported for Iceberg REST catalog. " - + "Use iceberg.rest.access-key-id and iceberg.rest.secret-access-key, " - + "or iceberg.rest.credentials_provider_type instead"); - } - } - - private void initIcebergRestCatalogProperties() { - icebergRestCatalogProperties = new HashMap<>(); - // Core catalog properties - addCoreCatalogProperties(); - // Optional properties - addOptionalProperties(); - // Authentication properties - addAuthenticationProperties(); - // Glue Rest Catalog specific properties - addGlueRestCatalogProperties(); - } - - private void addCoreCatalogProperties() { - // See CatalogUtil.java - icebergRestCatalogProperties.put(CatalogProperties.CATALOG_IMPL, CatalogUtil.ICEBERG_CATALOG_REST); - // See CatalogProperties.java - icebergRestCatalogProperties.put(CatalogProperties.URI, icebergRestUri); - } - - private void addOptionalProperties() { - if (Strings.isNotBlank(icebergRestPrefix)) { - icebergRestCatalogProperties.put(PREFIX_PROPERTY, icebergRestPrefix); - } - - if (Strings.isNotBlank(warehouse)) { - icebergRestCatalogProperties.put(CatalogProperties.WAREHOUSE_LOCATION, warehouse); - } - - if (isIcebergRestVendedCredentialsEnabled()) { - icebergRestCatalogProperties.put(VENDED_CREDENTIALS_HEADER, VENDED_CREDENTIALS_VALUE); - } - - if (Strings.isNotBlank(icebergRestConnectionTimeoutMs)) { - icebergRestCatalogProperties.put("rest.client.connection-timeout-ms", icebergRestConnectionTimeoutMs); - } - if (Strings.isNotBlank(icebergRestSocketTimeoutMs)) { - icebergRestCatalogProperties.put("rest.client.socket-timeout-ms", icebergRestSocketTimeoutMs); - } - - if (isIcebergRestUserSessionEnabled() && Strings.isNotBlank(icebergRestSessionTimeout) - && Long.parseLong(icebergRestSessionTimeout) > 0) { - icebergRestCatalogProperties.put(CatalogProperties.AUTH_SESSION_TIMEOUT_MS, icebergRestSessionTimeout); - } - } - - private void addAuthenticationProperties() { - Security security = Security.valueOf(icebergRestSecurityType.toUpperCase()); - if (security == Security.OAUTH2) { - addOAuth2Properties(); - } - } - - private void addOAuth2Properties() { - icebergRestCatalogProperties.put(AuthProperties.AUTH_TYPE, AuthProperties.AUTH_TYPE_OAUTH2); - if (Strings.isNotBlank(icebergRestOauth2Credential)) { - // Client Credentials Flow - icebergRestCatalogProperties.put(OAuth2Properties.CREDENTIAL, icebergRestOauth2Credential); - if (Strings.isNotBlank(icebergRestOauth2ServerUri)) { - icebergRestCatalogProperties.put(OAuth2Properties.OAUTH2_SERVER_URI, icebergRestOauth2ServerUri); - } - if (Strings.isNotBlank(icebergRestOauth2Scope)) { - icebergRestCatalogProperties.put(OAuth2Properties.SCOPE, icebergRestOauth2Scope); - } - icebergRestCatalogProperties.put(OAuth2Properties.TOKEN_REFRESH_ENABLED, - icebergRestOauth2TokenRefreshEnabled); - } else if (Strings.isNotBlank(icebergRestOauth2Token)) { - // Pre-configured Token Flow - icebergRestCatalogProperties.put(OAuth2Properties.TOKEN, icebergRestOauth2Token); - } - } - - private void addGlueRestCatalogProperties() { - if (Strings.isNotBlank(icebergRestSigningName)) { - // signing-name is case sensible, do not use lowercase() - icebergRestCatalogProperties.put("rest.signing-name", icebergRestSigningName); - icebergRestCatalogProperties.put("rest.sigv4-enabled", icebergRestSigV4Enabled); - icebergRestCatalogProperties.put("rest.signing-region", icebergRestSigningRegion); - - if (shouldUseS3PropertiesForRestCredentials()) { - IcebergAwsClientCredentialsProperties.putCredentialProviderProperties( - icebergRestCatalogProperties, s3Properties); - } else { - IcebergAwsClientCredentialsProperties.putCredentialProviderProperties( - icebergRestCatalogProperties, icebergRestAccessKeyId, - icebergRestSecretAccessKey, icebergRestSessionToken, icebergRestCredentialsProviderMode); - } - } - } - - private boolean shouldUseS3PropertiesForRestCredentials() { - return "glue".equals(icebergRestSigningName) - || "s3tables".equals(icebergRestSigningName); - } - - public Map getIcebergRestCatalogProperties() { - return Collections.unmodifiableMap(icebergRestCatalogProperties); - } - - Map getIcebergRestCatalogPropertiesForCatalogInit(SessionContext sessionContext) { - Map catalogProperties = new HashMap<>(icebergRestCatalogProperties); - if (!isIcebergRestUserSessionEnabled() || sessionContext == null - || !sessionContext.hasDelegatedCredential()) { - return Collections.unmodifiableMap(catalogProperties); - } - - DelegatedCredential credential = sessionContext.getDelegatedCredential().get(); - if (delegatedTokenMode == DelegatedTokenMode.ACCESS_TOKEN) { - catalogProperties.remove(OAuth2Properties.CREDENTIAL); - catalogProperties.remove(OAuth2Properties.OAUTH2_SERVER_URI); - catalogProperties.remove(OAuth2Properties.SCOPE); - catalogProperties.remove(OAuth2Properties.TOKEN_REFRESH_ENABLED); - catalogProperties.put(OAuth2Properties.TOKEN, credential.getToken()); - } else { - catalogProperties.put(IcebergDelegatedCredentialUtils.credentialKey(credential.getType()), - credential.getToken()); - } - return Collections.unmodifiableMap(catalogProperties); - } - - public boolean isIcebergRestVendedCredentialsEnabled() { - return Boolean.parseBoolean(icebergRestVendedCredentialsEnabled); - } - - public boolean isIcebergRestNestedNamespaceEnabled() { - return Boolean.parseBoolean(icebergRestNestedNamespaceEnabled); - } - - public boolean isIcebergRestViewEnabled() { - return Boolean.parseBoolean(icebergRestViewEnabled); - } - - public boolean isIcebergRestUserSessionEnabled() { - return Session.USER.name().equalsIgnoreCase(icebergRestSession); - } - - public enum Security { - NONE, - OAUTH2, - } - - public enum Session { - NONE, - USER, - } - - public enum DelegatedTokenMode { - ACCESS_TOKEN("access_token"), - TOKEN_EXCHANGE("token_exchange"); - - private final String value; - - DelegatedTokenMode(String value) { - this.value = value; - } - - public static DelegatedTokenMode fromString(String value) { - for (DelegatedTokenMode mode : values()) { - if (mode.value.equalsIgnoreCase(value)) { - return mode; - } - } - throw new IllegalArgumentException("Invalid delegated token mode: " + value - + ". Supported values are: access_token, token_exchange"); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergS3TablesMetaStoreProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergS3TablesMetaStoreProperties.java deleted file mode 100644 index 33147cf4da850b..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergS3TablesMetaStoreProperties.java +++ /dev/null @@ -1,92 +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.datasource.property.metastore; - -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.property.common.IcebergAwsClientCredentialsProperties; -import org.apache.doris.datasource.property.storage.S3Properties; -import org.apache.doris.datasource.property.storage.StorageProperties; - -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.apache.iceberg.aws.AwsClientProperties; -import org.apache.iceberg.catalog.Catalog; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.s3tables.S3TablesClient; -import software.amazon.awssdk.services.s3tables.S3TablesClientBuilder; -import software.amazon.s3tables.iceberg.S3TablesCatalog; -import software.amazon.s3tables.iceberg.S3TablesProperties; -import software.amazon.s3tables.iceberg.imports.HttpClientProperties; - -import java.net.URI; -import java.util.List; -import java.util.Map; - -public class IcebergS3TablesMetaStoreProperties extends AbstractIcebergProperties { - - private S3Properties s3Properties; - - public IcebergS3TablesMetaStoreProperties(Map props) { - super(props); - } - - @Override - public String getIcebergCatalogType() { - return IcebergExternalCatalog.ICEBERG_S3_TABLES; - } - - @Override - public void initNormalizeAndCheckProps() { - super.initNormalizeAndCheckProps(); - s3Properties = S3Properties.of(origProps); - } - - @Override - public Catalog initCatalog(String catalogName, Map catalogProps, - List storagePropertiesList) { - buildS3CatalogProperties(catalogProps); - S3TablesClient client = buildS3TablesClient(catalogProps); - S3TablesCatalog catalog = new S3TablesCatalog(); - try { - catalog.initialize(catalogName, catalogProps, client); - return catalog; - } catch (Exception e) { - throw new RuntimeException("Failed to initialize S3TablesCatalog for Iceberg. " - + "CatalogName=" + catalogName + ", region=" + s3Properties.getRegion() - + ", msg: " + ExceptionUtils.getRootCauseMessage(e), e); - } - } - - private void buildS3CatalogProperties(Map props) { - props.put(AwsClientProperties.CLIENT_REGION, s3Properties.getRegion()); - IcebergAwsClientCredentialsProperties.putS3FileIOCredentialProperties(props, s3Properties); - } - - private S3TablesClient buildS3TablesClient(Map props) { - S3TablesClientBuilder builder = S3TablesClient.builder() - .region(Region.of(s3Properties.getRegion())) - .credentialsProvider(IcebergAwsClientCredentialsProperties.createAwsCredentialsProvider( - s3Properties, false)); - String s3TablesEndpoint = props.get(S3TablesProperties.S3TABLES_ENDPOINT); - if (StringUtils.isNotBlank(s3TablesEndpoint)) { - builder.endpointOverride(URI.create(s3TablesEndpoint)); - } - new HttpClientProperties(props).applyHttpClientConfigurations(builder); - return builder.build(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/MetastoreProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/MetastoreProperties.java index 3a39fdd3927406..ae6c4c1204021d 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/MetastoreProperties.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/MetastoreProperties.java @@ -18,13 +18,14 @@ package org.apache.doris.datasource.property.metastore; import org.apache.doris.common.UserException; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; import org.apache.doris.datasource.property.ConnectionProperties; +import org.apache.doris.kerberos.ExecutionAuthenticator; import lombok.Getter; import org.apache.commons.lang3.StringUtils; import java.util.Arrays; +import java.util.Collections; import java.util.EnumMap; import java.util.HashSet; import java.util.Locale; @@ -84,9 +85,10 @@ public static Optional fromString(String input) { static { //subclasses should be registered here - register(Type.HMS, new HivePropertiesFactory()); - register(Type.ICEBERG, new IcebergPropertiesFactory()); - register(Type.PAIMON, new PaimonPropertiesFactory()); + // Design S7: hms/iceberg/paimon are plugin (SPI) catalogs whose metastore properties live + // connector-side; fe-core no longer parses them. The Type.HMS/ICEBERG/PAIMON enum values remain (so a + // stray create() fails loud with "Unsupported metastore type") but their factories are intentionally + // not registered. register(Type.TRINO_CONNECTOR, new TrinoConnectorPropertiesFactory()); } @@ -128,9 +130,8 @@ protected MetastoreProperties(Map props) { /** * Returns the execution authenticator for this metastore. - * Subclasses that support Kerberos (e.g., {@link HiveHMSProperties}) - * override this via their {@code @Getter executionAuthenticator} field - * to return a Kerberos-capable authenticator. + * Subclasses that support Kerberos override this via their + * {@code @Getter executionAuthenticator} field to return a Kerberos-capable authenticator. * *

    The default implementation returns a simple no-op authenticator.

    */ @@ -139,4 +140,21 @@ protected MetastoreProperties(Map props) { public ExecutionAuthenticator getExecutionAuthenticator() { return NOOP_AUTH; } + + /** + * Storage-configuration properties derived from this metastore's own properties that the raw catalog + * property map does not already supply. {@code CatalogProperty.initStorageProperties} merges them (as + * defaults — an explicit user key always wins) into the map fed to {@code StorageProperties.createAll}, + * before storage-backend detection. + * + *

    The default is empty: no derivation, zero behavior change for every existing metastore type. The + * iceberg filesystem flavor overrides it to bridge a {@code warehouse=hdfs:///path} into + * {@code fs.defaultFS=hdfs://} — legacy {@code IcebergHadoopExternalCatalog} did this in its + * constructor (dead on the plugin/cutover path), and the shared HDFS detection never reads + * {@code warehouse}, so an HA-nameservice hadoop catalog configured with only {@code warehouse} would + * otherwise fail to bind HDFS storage.

    + */ + public Map getDerivedStorageProperties() { + return Collections.emptyMap(); + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/MetastorePropertiesFactory.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/MetastorePropertiesFactory.java index 3efbbf7eca87bb..49748804f305b6 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/MetastorePropertiesFactory.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/MetastorePropertiesFactory.java @@ -26,7 +26,7 @@ *

    * In general, the metastore type of a catalog follows a two-level structure: * - The first-level type is determined by the catalog type (e.g., "hive", "iceberg"). - * - The second-level type is a subtype that needs to be registered individually (e.g., "hms", "glue", "dlf"). + * - The second-level type is a subtype that needs to be registered individually (e.g., "hms"). *

    * Each catalog type should have its own implementation of this factory interface, * with its supported subtypes registered internally. diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonAliyunDLFMetaStoreProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonAliyunDLFMetaStoreProperties.java deleted file mode 100644 index 9bc77d543d3d59..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonAliyunDLFMetaStoreProperties.java +++ /dev/null @@ -1,116 +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.datasource.property.metastore; - -import org.apache.doris.datasource.paimon.PaimonExternalCatalog; -import org.apache.doris.datasource.property.storage.StorageProperties; - -import com.aliyun.datalake.metastore.common.DataLakeConfig; -import com.aliyun.datalake.metastore.hive2.ProxyMetaStoreClient; -import org.apache.hadoop.hive.conf.HiveConf; -import org.apache.paimon.catalog.Catalog; -import org.apache.paimon.catalog.CatalogContext; -import org.apache.paimon.catalog.CatalogFactory; -import org.apache.paimon.hive.HiveCatalogOptions; - -import java.util.List; -import java.util.Map; - -/** - * PaimonAliyunDLFMetaStoreProperties - * - *

    This class provides configuration support for using Apache Paimon with - * Aliyun Data Lake Formation (DLF) as the metastore. Although DLF is not an - * officially supported metastore type in Paimon, this implementation adapts - * DLF by treating it as a Hive Metastore (HMS) underneath, enabling - * interoperability with Paimon's HiveCatalog. - * - *

    Key Characteristics: - *

      - *
    • Internally uses HiveCatalog with custom HiveConf configured for Aliyun DLF.
    • - *
    • Relies on {@link ProxyMetaStoreClient} to bridge DLF compatibility.
    • - *
    • Requires Aliyun OSS as the storage backend. Other storage types are not - * currently verified for compatibility.
    • - *
    - * - *

    Note: This is an internal extension and not an officially supported Paimon - * metastore type. Future compatibility should be validated when upgrading Paimon - * or changing storage backends. - * - * @see org.apache.paimon.hive.HiveCatalog - * @see org.apache.paimon.catalog.CatalogFactory - * @see ProxyMetaStoreClient - */ -public class PaimonAliyunDLFMetaStoreProperties extends AbstractPaimonProperties { - - private AliyunDLFBaseProperties baseProperties; - - protected PaimonAliyunDLFMetaStoreProperties(Map props) { - super(props); - } - - @Override - public void initNormalizeAndCheckProps() { - super.initNormalizeAndCheckProps(); - baseProperties = AliyunDLFBaseProperties.of(origProps); - } - - private HiveConf buildHiveConf() { - HiveConf hiveConf = new HiveConf(); - hiveConf.set(DataLakeConfig.CATALOG_ACCESS_KEY_ID, baseProperties.dlfAccessKey); - hiveConf.set(DataLakeConfig.CATALOG_ACCESS_KEY_SECRET, baseProperties.dlfSecretKey); - hiveConf.set(DataLakeConfig.CATALOG_ENDPOINT, baseProperties.dlfEndpoint); - hiveConf.set(DataLakeConfig.CATALOG_REGION_ID, baseProperties.dlfRegion); - hiveConf.set(DataLakeConfig.CATALOG_SECURITY_TOKEN, baseProperties.dlfSessionToken); - hiveConf.set(DataLakeConfig.CATALOG_USER_ID, baseProperties.dlfUid); - hiveConf.set(DataLakeConfig.CATALOG_ID, baseProperties.dlfCatalogId); - hiveConf.set(DataLakeConfig.CATALOG_PROXY_MODE, baseProperties.dlfProxyMode); - return hiveConf; - } - - @Override - public Catalog initializeCatalog(String catalogName, List storagePropertiesList) { - HiveConf hiveConf = buildHiveConf(); - buildCatalogOptions(); - StorageProperties ossProps = storagePropertiesList.stream() - .filter(sp -> sp.getType() == StorageProperties.Type.OSS - || sp.getType() == StorageProperties.Type.OSS_HDFS) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Paimon DLF metastore requires OSS storage properties.")); - ossProps.getHadoopStorageConfig().forEach(entry -> hiveConf.set(entry.getKey(), entry.getValue())); - appendUserHadoopConfig(hiveConf); - CatalogContext catalogContext = CatalogContext.create(catalogOptions, hiveConf); - return CatalogFactory.createCatalog(catalogContext); - } - - @Override - protected void appendCustomCatalogOptions() { - catalogOptions.set("metastore.client.class", ProxyMetaStoreClient.class.getName()); - catalogOptions.set("client-pool-cache.keys", "conf:" + DataLakeConfig.CATALOG_ID); - } - - @Override - protected String getMetastoreType() { - return HiveCatalogOptions.IDENTIFIER; - } - - @Override - public String getPaimonCatalogType() { - return PaimonExternalCatalog.PAIMON_DLF; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonFileSystemMetaStoreProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonFileSystemMetaStoreProperties.java deleted file mode 100644 index df0ebae97490a8..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonFileSystemMetaStoreProperties.java +++ /dev/null @@ -1,73 +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.datasource.property.metastore; - -import org.apache.doris.common.security.authentication.HadoopExecutionAuthenticator; -import org.apache.doris.datasource.paimon.PaimonExternalCatalog; -import org.apache.doris.datasource.property.storage.HdfsProperties; -import org.apache.doris.datasource.property.storage.StorageProperties; - -import org.apache.hadoop.conf.Configuration; -import org.apache.paimon.catalog.Catalog; -import org.apache.paimon.catalog.CatalogContext; -import org.apache.paimon.catalog.CatalogFactory; -import org.apache.paimon.catalog.FileSystemCatalogFactory; - -import java.util.List; -import java.util.Map; - -public class PaimonFileSystemMetaStoreProperties extends AbstractPaimonProperties { - protected PaimonFileSystemMetaStoreProperties(Map props) { - super(props); - } - - @Override - public Catalog initializeCatalog(String catalogName, List storagePropertiesList) { - buildCatalogOptions(); - Configuration conf = new Configuration(); - storagePropertiesList.forEach(storageProperties -> { - conf.addResource(storageProperties.getHadoopStorageConfig()); - if (storageProperties.getType().equals(StorageProperties.Type.HDFS)) { - this.executionAuthenticator = new HadoopExecutionAuthenticator(((HdfsProperties) storageProperties) - .getHadoopAuthenticator()); - } - }); - appendUserHadoopConfig(conf); - CatalogContext catalogContext = CatalogContext.create(catalogOptions, conf); - try { - return this.executionAuthenticator.execute(() -> CatalogFactory.createCatalog(catalogContext)); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Override - protected void appendCustomCatalogOptions() { - //nothing need to do - } - - @Override - protected String getMetastoreType() { - return FileSystemCatalogFactory.IDENTIFIER; - } - - @Override - public String getPaimonCatalogType() { - return PaimonExternalCatalog.PAIMON_FILESYSTEM; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonHMSMetaStoreProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonHMSMetaStoreProperties.java deleted file mode 100644 index e7e6689d3e3cab..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonHMSMetaStoreProperties.java +++ /dev/null @@ -1,116 +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.datasource.property.metastore; - -import org.apache.doris.common.security.authentication.HadoopExecutionAuthenticator; -import org.apache.doris.datasource.paimon.PaimonExternalCatalog; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.foundation.property.ConnectorProperty; - -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.apache.hadoop.conf.Configuration; -import org.apache.paimon.catalog.Catalog; -import org.apache.paimon.catalog.CatalogContext; -import org.apache.paimon.catalog.CatalogFactory; -import org.apache.paimon.hive.HiveCatalogOptions; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; - -public class PaimonHMSMetaStoreProperties extends AbstractPaimonProperties { - - private HMSBaseProperties hmsBaseProperties; - - private static final String CLIENT_POOL_CACHE_EVICTION_INTERVAL_MS_KEY = "client-pool-cache.eviction-interval-ms"; - - private static final String LOCATION_IN_PROPERTIES_KEY = "location-in-properties"; - - @ConnectorProperty( - names = {CLIENT_POOL_CACHE_EVICTION_INTERVAL_MS_KEY}, - required = false, - description = "Setting the client's pool cache eviction interval(ms).") - private long clientPoolCacheEvictionIntervalMs = TimeUnit.MINUTES.toMillis(5L); - - @ConnectorProperty( - names = {LOCATION_IN_PROPERTIES_KEY}, - required = false, - description = "Setting whether to use the location in the properties.") - private boolean locationInProperties = false; - - - @Override - public String getPaimonCatalogType() { - return PaimonExternalCatalog.PAIMON_HMS; - } - - protected PaimonHMSMetaStoreProperties(Map props) { - super(props); - } - - @Override - public void initNormalizeAndCheckProps() { - super.initNormalizeAndCheckProps(); - hmsBaseProperties = HMSBaseProperties.of(origProps); - this.executionAuthenticator = new HadoopExecutionAuthenticator(hmsBaseProperties.getHmsAuthenticator()); - } - - - /** - * Builds the Hadoop Configuration by adding hive-site.xml and storage-specific configs. - */ - private Configuration buildHiveConfiguration(List storagePropertiesList) { - Configuration conf = hmsBaseProperties.getHiveConf(); - - for (StorageProperties sp : storagePropertiesList) { - if (sp.getHadoopStorageConfig() != null) { - conf.addResource(sp.getHadoopStorageConfig()); - } - } - return conf; - } - - @Override - public Catalog initializeCatalog(String catalogName, List storagePropertiesList) { - buildCatalogOptions(); - Configuration conf = buildHiveConfiguration(storagePropertiesList); - appendUserHadoopConfig(conf); - CatalogContext catalogContext = CatalogContext.create(catalogOptions, conf); - try { - return executionAuthenticator.execute(() -> CatalogFactory.createCatalog(catalogContext)); - } catch (Exception e) { - throw new RuntimeException("Failed to create Paimon catalog with HMS metastore, msg: " - + ExceptionUtils.getRootCause(e), e); - } - - } - - @Override - protected String getMetastoreType() { - //See org.apache.paimon.hive.HiveCatalogFactory - return HiveCatalogOptions.IDENTIFIER; - } - - @Override - protected void appendCustomCatalogOptions() { - catalogOptions.set(CLIENT_POOL_CACHE_EVICTION_INTERVAL_MS_KEY, - String.valueOf(clientPoolCacheEvictionIntervalMs)); - catalogOptions.set(LOCATION_IN_PROPERTIES_KEY, String.valueOf(locationInProperties)); - catalogOptions.set("uri", hmsBaseProperties.getHiveMetastoreUri()); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonJdbcMetaStoreProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonJdbcMetaStoreProperties.java deleted file mode 100644 index 7568d59c5fed33..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonJdbcMetaStoreProperties.java +++ /dev/null @@ -1,265 +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.datasource.property.metastore; - -import org.apache.doris.catalog.JdbcResource; -import org.apache.doris.common.security.authentication.HadoopExecutionAuthenticator; -import org.apache.doris.datasource.paimon.PaimonExternalCatalog; -import org.apache.doris.datasource.property.storage.HdfsProperties; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.foundation.property.ConnectorProperty; - -import org.apache.commons.lang3.StringUtils; -import org.apache.hadoop.conf.Configuration; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.paimon.catalog.Catalog; -import org.apache.paimon.catalog.CatalogContext; -import org.apache.paimon.catalog.CatalogFactory; -import org.apache.paimon.jdbc.JdbcCatalogFactory; -import org.apache.paimon.options.CatalogOptions; - -import java.net.MalformedURLException; -import java.net.URL; -import java.net.URLClassLoader; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - -public class PaimonJdbcMetaStoreProperties extends AbstractPaimonProperties { - private static final Logger LOG = LogManager.getLogger(PaimonJdbcMetaStoreProperties.class); - private static final String JDBC_PREFIX = "jdbc."; - private static final String JDBC_DRIVER_URL = JDBC_PREFIX + JdbcResource.DRIVER_URL; - private static final String JDBC_DRIVER_CLASS = JDBC_PREFIX + JdbcResource.DRIVER_CLASS; - private static final Map DRIVER_CLASS_LOADER_CACHE = new ConcurrentHashMap<>(); - private static final Set REGISTERED_DRIVER_KEYS = ConcurrentHashMap.newKeySet(); - - @ConnectorProperty( - names = {"uri", "paimon.jdbc.uri"}, - required = true, - description = "JDBC connection URI for the Paimon JDBC catalog." - ) - private String uri = ""; - - @ConnectorProperty( - names = {"paimon.jdbc.user", "jdbc.user"}, - required = false, - description = "Username for the Paimon JDBC catalog." - ) - private String jdbcUser; - - @ConnectorProperty( - names = {"paimon.jdbc.password", "jdbc.password"}, - required = false, - sensitive = true, - description = "Password for the Paimon JDBC catalog." - ) - private String jdbcPassword; - - @ConnectorProperty( - names = {"paimon.jdbc.driver_url", "jdbc.driver_url"}, - required = false, - description = "JDBC driver JAR file path or URL. " - + "Can be a local file name (will look in $DORIS_HOME/plugins/jdbc_drivers/) " - + "or a full URL (http://, https://, file://)." - ) - private String driverUrl; - - @ConnectorProperty( - names = {"paimon.jdbc.driver_class", "jdbc.driver_class"}, - required = false, - description = "JDBC driver class name. If specified with paimon.jdbc.driver_url, " - + "the driver will be loaded dynamically." - ) - private String driverClass; - - protected PaimonJdbcMetaStoreProperties(Map props) { - super(props); - } - - @Override - public String getPaimonCatalogType() { - return PaimonExternalCatalog.PAIMON_JDBC; - } - - @Override - protected void checkRequiredProperties() { - super.checkRequiredProperties(); - if (StringUtils.isBlank(warehouse)) { - throw new IllegalArgumentException("Property warehouse is required."); - } - } - - @Override - public Catalog initializeCatalog(String catalogName, List storagePropertiesList) { - buildCatalogOptions(); - Configuration conf = new Configuration(); - for (StorageProperties storageProperties : storagePropertiesList) { - if (storageProperties.getHadoopStorageConfig() != null) { - conf.addResource(storageProperties.getHadoopStorageConfig()); - } - if (storageProperties.getType().equals(StorageProperties.Type.HDFS)) { - this.executionAuthenticator = new HadoopExecutionAuthenticator(((HdfsProperties) storageProperties) - .getHadoopAuthenticator()); - } - } - appendUserHadoopConfig(conf); - if (StringUtils.isNotBlank(driverUrl)) { - registerJdbcDriver(driverUrl, driverClass); - LOG.info("Using dynamic JDBC driver for Paimon JDBC catalog from: {}", driverUrl); - } - CatalogContext catalogContext = CatalogContext.create(catalogOptions, conf); - try { - return this.executionAuthenticator.execute(() -> CatalogFactory.createCatalog(catalogContext)); - } catch (Exception e) { - throw new RuntimeException("Failed to create Paimon catalog with JDBC metastore: " + e.getMessage(), e); - } - } - - @Override - protected void appendCustomCatalogOptions() { - catalogOptions.set(CatalogOptions.URI.key(), uri); - addIfNotBlank("jdbc.user", jdbcUser); - addIfNotBlank("jdbc.password", jdbcPassword); - appendRawJdbcCatalogOptions(); - } - - @Override - protected String getMetastoreType() { - return JdbcCatalogFactory.IDENTIFIER; - } - - private void addIfNotBlank(String key, String value) { - if (StringUtils.isNotBlank(value)) { - catalogOptions.set(key, value); - } - } - - private void appendRawJdbcCatalogOptions() { - origProps.forEach((key, value) -> { - if (key != null && key.startsWith(JDBC_PREFIX) && !catalogOptions.keySet().contains(key)) { - catalogOptions.set(key, value); - } - }); - } - - public Map getBackendPaimonOptions() { - if (StringUtils.isBlank(driverUrl)) { - return Collections.emptyMap(); - } - if (StringUtils.isBlank(driverClass)) { - throw new IllegalArgumentException("jdbc.driver_class or paimon.jdbc.driver_class is required when " - + "jdbc.driver_url or paimon.jdbc.driver_url is specified"); - } - Map backendPaimonOptions = new HashMap<>(); - backendPaimonOptions.put(JDBC_DRIVER_URL, JdbcResource.getFullDriverUrl(driverUrl)); - backendPaimonOptions.put(JDBC_DRIVER_CLASS, driverClass); - return backendPaimonOptions; - } - - /** - * Register JDBC driver with DriverManager. - * This is necessary because DriverManager.getConnection() doesn't use Thread.contextClassLoader. - */ - private void registerJdbcDriver(String driverUrl, String driverClassName) { - try { - if (StringUtils.isBlank(driverClassName)) { - throw new IllegalArgumentException( - "jdbc.driver_class or paimon.jdbc.driver_class is required when jdbc.driver_url " - + "or paimon.jdbc.driver_url is specified"); - } - - String fullDriverUrl = JdbcResource.getFullDriverUrl(driverUrl); - URL url = new URL(fullDriverUrl); - String driverKey = fullDriverUrl + "#" + driverClassName; - if (!REGISTERED_DRIVER_KEYS.add(driverKey)) { - LOG.info("JDBC driver already registered for Paimon catalog: {} from {}", - driverClassName, fullDriverUrl); - return; - } - try { - ClassLoader classLoader = DRIVER_CLASS_LOADER_CACHE.computeIfAbsent(url, u -> { - ClassLoader parent = getClass().getClassLoader(); - return URLClassLoader.newInstance(new URL[] {u}, parent); - }); - Class loadedDriverClass = Class.forName(driverClassName, true, classLoader); - java.sql.Driver driver = (java.sql.Driver) loadedDriverClass.getDeclaredConstructor().newInstance(); - java.sql.DriverManager.registerDriver(new DriverShim(driver)); - LOG.info("Successfully registered JDBC driver for Paimon catalog: {} from {}", - driverClassName, fullDriverUrl); - } catch (ClassNotFoundException e) { - REGISTERED_DRIVER_KEYS.remove(driverKey); - throw new IllegalArgumentException("Failed to load JDBC driver class: " + driverClassName, e); - } catch (Exception e) { - REGISTERED_DRIVER_KEYS.remove(driverKey); - throw new RuntimeException("Failed to register JDBC driver: " + driverClassName, e); - } - } catch (MalformedURLException e) { - throw new IllegalArgumentException("Invalid driver URL: " + driverUrl, e); - } catch (IllegalArgumentException e) { - throw e; - } - } - - private static class DriverShim implements java.sql.Driver { - private final java.sql.Driver delegate; - - DriverShim(java.sql.Driver delegate) { - this.delegate = delegate; - } - - @Override - public java.sql.Connection connect(String url, java.util.Properties info) throws java.sql.SQLException { - return delegate.connect(url, info); - } - - @Override - public boolean acceptsURL(String url) throws java.sql.SQLException { - return delegate.acceptsURL(url); - } - - @Override - public java.sql.DriverPropertyInfo[] getPropertyInfo(String url, java.util.Properties info) - throws java.sql.SQLException { - return delegate.getPropertyInfo(url, info); - } - - @Override - public int getMajorVersion() { - return delegate.getMajorVersion(); - } - - @Override - public int getMinorVersion() { - return delegate.getMinorVersion(); - } - - @Override - public boolean jdbcCompliant() { - return delegate.jdbcCompliant(); - } - - @Override - public java.util.logging.Logger getParentLogger() throws java.sql.SQLFeatureNotSupportedException { - return delegate.getParentLogger(); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonPropertiesFactory.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonPropertiesFactory.java deleted file mode 100644 index cc9b1ecef49bfa..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonPropertiesFactory.java +++ /dev/null @@ -1,39 +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.datasource.property.metastore; - -import java.util.Map; - -public class PaimonPropertiesFactory extends AbstractMetastorePropertiesFactory { - - private static final String KEY = "paimon.catalog.type"; - private static final String DEFAULT_TYPE = "filesystem"; - - public PaimonPropertiesFactory() { - register("dlf", PaimonAliyunDLFMetaStoreProperties::new); - register("filesystem", PaimonFileSystemMetaStoreProperties::new); - register("hms", PaimonHMSMetaStoreProperties::new); - register("rest", PaimonRestMetaStoreProperties::new); - register("jdbc", PaimonJdbcMetaStoreProperties::new); - } - - @Override - public MetastoreProperties create(Map props) { - return createInternal(props, KEY, DEFAULT_TYPE); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonRestMetaStoreProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonRestMetaStoreProperties.java deleted file mode 100644 index 465fc873b7c5d5..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/PaimonRestMetaStoreProperties.java +++ /dev/null @@ -1,111 +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.datasource.property.metastore; - -import org.apache.doris.datasource.paimon.PaimonExternalCatalog; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.foundation.property.ConnectorProperty; -import org.apache.doris.foundation.property.ParamRules; - -import lombok.Getter; -import org.apache.paimon.catalog.Catalog; -import org.apache.paimon.catalog.CatalogContext; -import org.apache.paimon.catalog.CatalogFactory; - -import java.util.List; -import java.util.Map; - -public class PaimonRestMetaStoreProperties extends AbstractPaimonProperties { - - private static final String PAIMON_REST_PROPERTY_PREFIX = "paimon.rest."; - - @ConnectorProperty(names = {"paimon.rest.uri", "uri"}, - description = "The uri of the Paimon rest catalog service.") - private String paimonRestUri = ""; - - @Getter - @ConnectorProperty( - names = {"paimon.rest.token.provider"}, - description = "the token provider for Paimon REST metastore, e.g., 'dlf' for Aliyun DLF." - ) - protected String tokenProvider = ""; - - // The following properties are specific to DLF rest catalog - @ConnectorProperty( - names = {"paimon.rest.dlf.access-key-id"}, - required = false, - description = "The access key ID for DLF, required when using DLF as token provider." - ) - protected String paimonRestDlfAccessKey = ""; - - @ConnectorProperty( - names = {"paimon.rest.dlf.access-key-secret"}, - required = false, - description = "The secret key secret for DLF, required when using DLF as token provider." - ) - protected String paimonRestDlfSecretKey = ""; - - protected PaimonRestMetaStoreProperties(Map props) { - super(props); - } - - @Override - public void initNormalizeAndCheckProps() { - super.initNormalizeAndCheckProps(); - buildRules().validate(); - } - - @Override - public String getPaimonCatalogType() { - return PaimonExternalCatalog.PAIMON_REST; - } - - @Override - public Catalog initializeCatalog(String catalogName, List storagePropertiesList) { - buildCatalogOptions(); - CatalogContext catalogContext = CatalogContext.create(catalogOptions); - return CatalogFactory.createCatalog(catalogContext); - } - - @Override - protected void appendCustomCatalogOptions() { - catalogOptions.set("uri", paimonRestUri); - for (Map.Entry entry : origProps.entrySet()) { - if (entry.getKey().startsWith(PAIMON_REST_PROPERTY_PREFIX)) { - String key = entry.getKey().substring(PAIMON_REST_PROPERTY_PREFIX.length()); - catalogOptions.set(key, entry.getValue()); - } - } - } - - @Override - protected String getMetastoreType() { - return "rest"; - } - - private ParamRules buildRules() { - ParamRules rules = new ParamRules(); - // Check for dlf rest catalog - rules.requireIf(tokenProvider, "dlf", - new String[] {paimonRestDlfAccessKey, - paimonRestDlfSecretKey}, - "DLF token provider requires 'paimon.rest.dlf.access-key-id' " - + "and 'paimon.rest.dlf.access-key-secret'"); - return rules; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/BrokerProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/BrokerProperties.java index a1c9b2e4d369ce..06bc155156922a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/BrokerProperties.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/BrokerProperties.java @@ -55,7 +55,9 @@ public static BrokerProperties of(String brokerName, Map origPro return properties; } - private static final String BIND_BROKER_NAME_KEY = "broker.name"; + // Broker name for file split and query scan. The single source of truth for the "broker.name" catalog + // property key (the generic ExternalCatalog.bindBrokerName() reads it; guessIsMe matches it case-insensitively). + public static final String BIND_BROKER_NAME_KEY = "broker.name"; public static boolean guessIsMe(Map props) { if (props == null || props.isEmpty()) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/HdfsCompatibleProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/HdfsCompatibleProperties.java index 9a2d05d841f6ce..a7702d0a6bd3ee 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/HdfsCompatibleProperties.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/HdfsCompatibleProperties.java @@ -17,9 +17,9 @@ package org.apache.doris.datasource.property.storage; -import org.apache.doris.common.security.authentication.HadoopAuthenticator; -import org.apache.doris.common.security.authentication.HadoopSimpleAuthenticator; -import org.apache.doris.common.security.authentication.SimpleAuthenticationConfig; +import org.apache.doris.kerberos.HadoopAuthenticator; +import org.apache.doris.kerberos.HadoopSimpleAuthenticator; +import org.apache.doris.kerberos.SimpleAuthenticationConfig; import com.google.common.collect.ImmutableSet; import lombok.Getter; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/HdfsProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/HdfsProperties.java index 91249ad98d6c38..bf68eb075468d6 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/HdfsProperties.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/HdfsProperties.java @@ -18,8 +18,8 @@ package org.apache.doris.datasource.property.storage; import org.apache.doris.common.UserException; -import org.apache.doris.common.security.authentication.HadoopAuthenticator; import org.apache.doris.foundation.property.ConnectorProperty; +import org.apache.doris.kerberos.HadoopAuthenticator; import com.google.common.base.Strings; import com.google.common.collect.ImmutableSet; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/HttpProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/HttpProperties.java index b6b9eaa63c68a1..e0b51236984be0 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/HttpProperties.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/HttpProperties.java @@ -21,7 +21,7 @@ import com.google.common.collect.ImmutableSet; import com.google.common.collect.Maps; -import org.apache.hudi.common.util.MapUtils; +import org.apache.commons.collections4.MapUtils; import java.util.Map; import java.util.Set; @@ -55,7 +55,7 @@ public String validateAndGetUri(Map props) throws UserException } public static boolean guessIsMe(Map props) { - return !MapUtils.isNullOrEmpty(props) + return MapUtils.isNotEmpty(props) && HTTP_PROPERTIES.stream().anyMatch(props::containsKey); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/ExternalScanNode.java similarity index 98% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalScanNode.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/scan/ExternalScanNode.java index 04e1829fbe3496..c378c285dc55b2 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/ExternalScanNode.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.scan; import org.apache.doris.analysis.TupleDescriptor; import org.apache.doris.common.UserException; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/FederationBackendPolicy.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FederationBackendPolicy.java similarity index 99% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/FederationBackendPolicy.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FederationBackendPolicy.java index a8927d86a946e6..bcf921d9b0a508 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/FederationBackendPolicy.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FederationBackendPolicy.java @@ -18,7 +18,7 @@ // https://github.com/trinodb/trino/blob/master/core/trino-main/src/main/java/io/trino/execution/scheduler/UniformNodeSelector.java // and modified by Doris -package org.apache.doris.datasource; +package org.apache.doris.datasource.scan; import org.apache.doris.common.AnalysisException; import org.apache.doris.common.Config; @@ -27,6 +27,7 @@ import org.apache.doris.common.ResettableRandomizedIterator; import org.apache.doris.common.UserException; import org.apache.doris.common.util.ConsistentHash; +import org.apache.doris.datasource.split.SplitWeight; import org.apache.doris.qe.ConnectContext; import org.apache.doris.resource.computegroup.ComputeGroup; import org.apache.doris.spi.Split; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileCacheAdmissionManager.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileCacheAdmissionManager.java similarity index 99% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/FileCacheAdmissionManager.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileCacheAdmissionManager.java index 11cd15e0d3070f..ed04f17c5cab2b 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileCacheAdmissionManager.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileCacheAdmissionManager.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.scan; import org.apache.doris.common.Config; import org.apache.doris.common.ConfigWatcher; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileGroupInfo.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileGroupInfo.java similarity index 99% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/FileGroupInfo.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileGroupInfo.java index 9efeda06dbc83e..680989d959e5a0 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileGroupInfo.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileGroupInfo.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.scan; import org.apache.doris.analysis.BrokerDesc; import org.apache.doris.analysis.StorageBackend; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/FilePartitionUtils.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FilePartitionUtils.java similarity index 91% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/FilePartitionUtils.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FilePartitionUtils.java index e3748eefa9ba46..571e27437651ac 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/FilePartitionUtils.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FilePartitionUtils.java @@ -15,10 +15,10 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.scan; import org.apache.doris.common.UserException; -import org.apache.doris.datasource.hive.HiveExternalMetaCache; +import org.apache.doris.connector.api.scan.ConnectorPartitionValues; import com.google.common.collect.Lists; @@ -140,7 +140,7 @@ public static ParsedColumnsFromPath parseColumnsFromPathWithNullInfo( if (index == -1) { continue; } - boolean isNull = HiveExternalMetaCache.HIVE_DEFAULT_PARTITION.equals(pair[1]); + boolean isNull = ConnectorPartitionValues.HIVE_DEFAULT_PARTITION.equals(pair[1]); columns[index] = isNull ? "" : pair[1]; columnValueIsNull[index] = isNull; size++; @@ -162,7 +162,11 @@ public static ParsedColumnsFromPath normalizeColumnsFromPath(List column List values = new ArrayList<>(columnsFromPath.size()); List isNull = new ArrayList<>(columnsFromPath.size()); for (String value : columnsFromPath) { - boolean nullValue = value == null || HiveExternalMetaCache.HIVE_DEFAULT_PARTITION.equals(value); + // Only a genuine null maps to SQL NULL here. Source-specific null sentinels (the Hive + // default-partition directory name) are the connector's responsibility: each connector + // rewrites columns-from-path in its own ConnectorScanRange.populateRangeParams, overwriting + // this pre-fill. fe-core keeps no source-specific string matching. + boolean nullValue = value == null; values.add(nullValue ? "" : value); isNull.add(nullValue); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileQueryScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileQueryScanNode.java similarity index 97% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/FileQueryScanNode.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileQueryScanNode.java index b1dc260701eb03..194c47beb486db 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileQueryScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileQueryScanNode.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.scan; import org.apache.doris.analysis.SlotDescriptor; import org.apache.doris.analysis.TableSample; @@ -36,7 +36,14 @@ import org.apache.doris.common.UserException; import org.apache.doris.common.profile.SummaryProfile; import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.hive.source.HiveSplit; +import org.apache.doris.datasource.CatalogIf; +import org.apache.doris.datasource.ExternalCatalog; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.split.FileSplit; +import org.apache.doris.datasource.split.FileSplitter; +import org.apache.doris.datasource.split.SplitAssignment; +import org.apache.doris.datasource.split.SplitSource; +import org.apache.doris.datasource.split.SplitSourceManager; import org.apache.doris.planner.PlanNodeId; import org.apache.doris.planner.ScanContext; import org.apache.doris.qe.ConnectContext; @@ -464,12 +471,10 @@ private TScanRangeLocations splitToScanRange( FileSplit fileSplit = (FileSplit) split; TScanRangeLocations curLocations = newLocations(); // If fileSplit has partition values, use the values collected from hive partitions. - // Otherwise, use the values in file path. + // Otherwise, use the values in file path. Migrated tables emit PluginDrivenSplit (never a HiveSplit) + // and always carry connector-supplied partition values, so isACID stays false: the connector owns + // ACID delta-dir handling and rewrites columns-from-path in its ConnectorScanRange.populateRangeParams. boolean isACID = false; - if (fileSplit instanceof HiveSplit) { - HiveSplit hiveSplit = (HiveSplit) fileSplit; - isACID = hiveSplit.isACID(); - } FilePartitionUtils.ParsedColumnsFromPath partitionValuesFromPath = fileSplit.getPartitionValues() == null ? FilePartitionUtils.parseColumnsFromPathWithNullInfo( diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileScanNode.java similarity index 76% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/FileScanNode.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileScanNode.java index 53b0d11c5c7283..6874d929b67a0a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileScanNode.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.scan; import org.apache.doris.analysis.Expr; import org.apache.doris.analysis.ExprToThriftVisitor; @@ -166,68 +166,7 @@ public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { .append("\n"); if (detailLevel == TExplainLevel.VERBOSE && !isBatch) { - output.append(prefix).append("backends:").append("\n"); - Multimap scanRangeLocationsMap = ArrayListMultimap.create(); - // 1. group by backend id - for (TScanRangeLocations locations : scanRangeLocations) { - scanRangeLocationsMap.putAll(locations.getLocations().get(0).backend_id, - locations.getScanRange().getExtScanRange().getFileScanRange().getRanges()); - } - for (long beId : scanRangeLocationsMap.keySet()) { - output.append(prefix).append(" ").append(beId).append("\n"); - List fileRangeDescs = Lists.newArrayList(scanRangeLocationsMap.get(beId)); - // 2. sort by file start offset - Collections.sort(fileRangeDescs, new Comparator() { - @Override - public int compare(TFileRangeDesc o1, TFileRangeDesc o2) { - return Long.compare(o1.getStartOffset(), o2.getStartOffset()); - } - }); - - // A Data file may be divided into different splits, so a set is used to remove duplicates. - Set dataFilesSet = new HashSet<>(); - // A delete file might be used by multiple data files, so use set to remove duplicates. - Set deleteFilesSet = new HashSet<>(); - // You can estimate how many delete splits need to be read for a data split - // using deleteSplitNum / dataSplitNum(fileRangeDescs.size()) split. - long deleteSplitNum = 0; - for (TFileRangeDesc fileRangeDesc : fileRangeDescs) { - dataFilesSet.add(fileRangeDesc.getPath()); - List deletefiles = getDeleteFiles(fileRangeDesc); - deleteFilesSet.addAll(deletefiles); - deleteSplitNum += deletefiles.size(); - } - - // 3. if size <= 4, print all. if size > 4, print first 3 and last 1 - int size = fileRangeDescs.size(); - if (size <= 4) { - for (TFileRangeDesc file : fileRangeDescs) { - output.append(prefix).append(" ").append(file.getPath()) - .append(" start: ").append(file.getStartOffset()) - .append(" length: ").append(file.getSize()) - .append("\n"); - } - } else { - for (int i = 0; i < 3; i++) { - TFileRangeDesc file = fileRangeDescs.get(i); - output.append(prefix).append(" ").append(file.getPath()) - .append(" start: ").append(file.getStartOffset()) - .append(" length: ").append(file.getSize()) - .append("\n"); - } - int other = size - 4; - output.append(prefix).append(" ... other ").append(other).append(" files ...\n"); - TFileRangeDesc file = fileRangeDescs.get(size - 1); - output.append(prefix).append(" ").append(file.getPath()) - .append(" start: ").append(file.getStartOffset()) - .append(" length: ").append(file.getSize()) - .append("\n"); - } - output.append(prefix).append(" ").append("dataFileNum=").append(dataFilesSet.size()) - .append(", deleteFileNum=").append(deleteFilesSet.size()) - .append(", deleteSplitNum=").append(deleteSplitNum) - .append("\n"); - } + appendBackendScanRangeDetail(output, prefix); } output.append(prefix); @@ -262,6 +201,79 @@ public int compare(TFileRangeDesc o1, TFileRangeDesc o2) { return output.toString(); } + /** + * Appends the VERBOSE per-backend scan-range detail (the {@code backends:} block, the per-file + * {@code path start/length} lines, and the {@code dataFileNum/deleteFileNum/deleteSplitNum} + * summary) to {@code output}. Extracted verbatim from {@link #getNodeExplainString} so a custom + * EXPLAIN override that does NOT call super (e.g. {@code PluginDrivenScanNode}) can re-emit this + * block under the same {@code VERBOSE && !isBatchMode()} gate. Behavior-neutral for existing + * subclasses: the body is unchanged and still runs only from the same call site. + */ + protected void appendBackendScanRangeDetail(StringBuilder output, String prefix) { + output.append(prefix).append("backends:").append("\n"); + Multimap scanRangeLocationsMap = ArrayListMultimap.create(); + // 1. group by backend id + for (TScanRangeLocations locations : scanRangeLocations) { + scanRangeLocationsMap.putAll(locations.getLocations().get(0).backend_id, + locations.getScanRange().getExtScanRange().getFileScanRange().getRanges()); + } + for (long beId : scanRangeLocationsMap.keySet()) { + output.append(prefix).append(" ").append(beId).append("\n"); + List fileRangeDescs = Lists.newArrayList(scanRangeLocationsMap.get(beId)); + // 2. sort by file start offset + Collections.sort(fileRangeDescs, new Comparator() { + @Override + public int compare(TFileRangeDesc o1, TFileRangeDesc o2) { + return Long.compare(o1.getStartOffset(), o2.getStartOffset()); + } + }); + + // A Data file may be divided into different splits, so a set is used to remove duplicates. + Set dataFilesSet = new HashSet<>(); + // A delete file might be used by multiple data files, so use set to remove duplicates. + Set deleteFilesSet = new HashSet<>(); + // You can estimate how many delete splits need to be read for a data split + // using deleteSplitNum / dataSplitNum(fileRangeDescs.size()) split. + long deleteSplitNum = 0; + for (TFileRangeDesc fileRangeDesc : fileRangeDescs) { + dataFilesSet.add(fileRangeDesc.getPath()); + List deletefiles = getDeleteFiles(fileRangeDesc); + deleteFilesSet.addAll(deletefiles); + deleteSplitNum += deletefiles.size(); + } + + // 3. if size <= 4, print all. if size > 4, print first 3 and last 1 + int size = fileRangeDescs.size(); + if (size <= 4) { + for (TFileRangeDesc file : fileRangeDescs) { + output.append(prefix).append(" ").append(file.getPath()) + .append(" start: ").append(file.getStartOffset()) + .append(" length: ").append(file.getSize()) + .append("\n"); + } + } else { + for (int i = 0; i < 3; i++) { + TFileRangeDesc file = fileRangeDescs.get(i); + output.append(prefix).append(" ").append(file.getPath()) + .append(" start: ").append(file.getStartOffset()) + .append(" length: ").append(file.getSize()) + .append("\n"); + } + int other = size - 4; + output.append(prefix).append(" ... other ").append(other).append(" files ...\n"); + TFileRangeDesc file = fileRangeDescs.get(size - 1); + output.append(prefix).append(" ").append(file.getPath()) + .append(" start: ").append(file.getStartOffset()) + .append(" length: ").append(file.getSize()) + .append("\n"); + } + output.append(prefix).append(" ").append("dataFileNum=").append(dataFilesSet.size()) + .append(", deleteFileNum=").append(deleteFilesSet.size()) + .append(", deleteSplitNum=").append(deleteSplitNum) + .append("\n"); + } + } + protected void setDefaultValueExprs(TableIf tbl, Map slotDescByName, Map exprByName, diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/NodeSelectionStrategy.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/NodeSelectionStrategy.java similarity index 95% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/NodeSelectionStrategy.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/scan/NodeSelectionStrategy.java index 1cea6cc504d629..433cd40d638e1f 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/NodeSelectionStrategy.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/NodeSelectionStrategy.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.scan; public enum NodeSelectionStrategy { ROUND_ROBIN, 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 new file mode 100644 index 00000000000000..d70715a0342163 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java @@ -0,0 +1,1983 @@ +// 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.analysis.CastExpr; +import org.apache.doris.analysis.Expr; +import org.apache.doris.analysis.ExprToSqlVisitor; +import org.apache.doris.analysis.SlotDescriptor; +import org.apache.doris.analysis.TableSample; +import org.apache.doris.analysis.ToSqlParams; +import org.apache.doris.analysis.TupleDescriptor; +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.Env; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.common.UserException; +import org.apache.doris.common.profile.RuntimeProfile; +import org.apache.doris.common.profile.SummaryProfile; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.handle.ConnectorColumnHandle; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.PassthroughQueryTableHandle; +import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; +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.ConnectorSplitSource; +import org.apache.doris.connector.api.scan.ScanNodePropertiesResult; +import org.apache.doris.datasource.SchemaCacheValue; +import org.apache.doris.datasource.connector.converter.ExprToConnectorExpressionConverter; +import org.apache.doris.datasource.mvcc.MvccSnapshot; +import org.apache.doris.datasource.mvcc.MvccTable; +import org.apache.doris.datasource.mvcc.MvccUtil; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccSnapshot; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; +import org.apache.doris.datasource.plugin.PluginDrivenSysExternalTable; +import org.apache.doris.datasource.split.FileSplit; +import org.apache.doris.datasource.split.PluginDrivenSplit; +import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan.SelectedPartitions; +import org.apache.doris.planner.PlanNodeId; +import org.apache.doris.planner.ScanContext; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.QeProcessorImpl; +import org.apache.doris.qe.SessionVariable; +import org.apache.doris.spi.Split; +import org.apache.doris.thrift.TColumnCategory; +import org.apache.doris.thrift.TExplainLevel; +import org.apache.doris.thrift.TFileAttributes; +import org.apache.doris.thrift.TFileCompressType; +import org.apache.doris.thrift.TFileFormatType; +import org.apache.doris.thrift.TFileRangeDesc; +import org.apache.doris.thrift.TFileTextScanRangeParams; +import org.apache.doris.thrift.TTableFormatFileDesc; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.ArrayList; +import java.util.Arrays; +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.OptionalLong; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * Generic scan node that delegates scan planning to the connector SPI. + * + *

    Replaces connector-specific ScanNode subclasses for plugin-driven catalogs. + * Extends {@link FileQueryScanNode} to reuse the existing split-to-Thrift + * conversion pipeline. Uses {@code FORMAT_JNI} by default, which routes + * to BE's JNI scanner framework.

    + * + *

    Scan flow:

    + *
      + *
    1. {@link #getSplits} calls {@link ConnectorScanPlanProvider#planScan} + * to obtain {@link ConnectorScanRange}s from the connector plugin
    2. + *
    3. Each range is wrapped in a {@link PluginDrivenSplit}
    4. + *
    5. {@link FileQueryScanNode#createScanRangeLocations} distributes splits + * to backends
    6. + *
    7. {@link #setScanParams} populates {@link TTableFormatFileDesc} with + * connector-specific properties for each split
    8. + *
    + */ +public class PluginDrivenScanNode extends FileQueryScanNode { + + private static final Logger LOG = LogManager.getLogger(PluginDrivenScanNode.class); + + 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"; + + private final Connector connector; + private final ConnectorSession connectorSession; + + // Set during filter pushdown; may be updated from the original table handle. + private ConnectorTableHandle currentHandle; + + // Nereids partition-pruning result, injected by the translator. Defaults to NOT_PRUNED + // so that connectors / non-partitioned tables read all partitions unless pruning applies. + private SelectedPartitions selectedPartitions = SelectedPartitions.NOT_PRUNED; + + // Cached isBatchMode() result. isBatchMode is read on both the dispatch (FileQueryScanNode) + // and explain (FileScanNode) paths and num_partitions_in_batch_mode is fuzzy, so cache it to + // keep the decision stable across reads (mirrors IcebergScanNode). + private Boolean isBatchModeCache; + + // FIX-M3 (streaming splits): when a connector opts into file-count streaming split generation + // (ConnectorScanPlanProvider.streamingSplitEstimate >= 0), this caches that estimate and flags the + // streaming flavor of batch mode — distinct from the partition-count flavor in shouldUseBatchMode. + // -1 / false until computeBatchMode runs. + private long streamingSplitEstimate = -1; + private boolean streamingBatch; + + // FIX-E (explain gap): native (ORC/Parquet) vs total scan-range counts accumulated in getSplits() + // from ConnectorScanRange.isNativeReadRange(), surfaced to the connector's appendExplainInfo for the + // "paimonNativeReadSplits=/" line. Default 0/0 (no native splits) before getSplits runs. + private int nativeReadSplitNum; + private int totalReadSplitNum; + + // Populated from ConnectorScanPlanProvider.getScanNodePropertiesResult() + private ScanNodePropertiesResult cachedPropertiesResult; + private Map scanNodeProperties; + // Maps filtered conjunct indices (after CAST removal) back to original conjunct indices + private List filteredToOriginalIndex; + + // Memoized resolveScanProvider() result, keyed on currentHandle identity. See resolveScanProvider(). + // Volatile so the concurrent partition-batch appendBatch threads read a self-consistent (handle, provider). + private volatile ResolvedScanProvider resolvedScanProvider; + + // This node's per-statement ConnectorMetadata. The funnel already memoizes it in the statement scope + // (keyed by catalogId); cached here as well so the per-method resolvers don't re-hit the scope map. + // Volatile mirrors resolvedScanProvider — keeps an off-path metadata() read race-free. + private volatile ConnectorMetadata cachedMetadata; + + public PluginDrivenScanNode(PlanNodeId id, TupleDescriptor desc, + boolean needCheckColumnPriv, SessionVariable sv, + ScanContext scanContext, Connector connector, + ConnectorSession connectorSession, ConnectorTableHandle tableHandle) { + super(id, desc, "PluginDrivenScanNode", scanContext, needCheckColumnPriv, sv); + this.connector = connector; + this.connectorSession = connectorSession; + this.currentHandle = tableHandle; + } + + // Lazily resolves this node's ConnectorMetadata through the per-statement funnel and caches it, so the + // per-method resolvers below share one instance for the statement instead of rebuilding it each time. + private ConnectorMetadata metadata() { + ConnectorMetadata m = cachedMetadata; + if (m == null) { + m = PluginDrivenMetadata.get(connectorSession, connector); + cachedMetadata = m; + } + return m; + } + + /** + * Creates a PluginDrivenScanNode by resolving the connector, session, and table handle + * from the plugin-driven catalog and table. + */ + public static PluginDrivenScanNode create(PlanNodeId id, TupleDescriptor desc, + boolean needCheckColumnPriv, SessionVariable sv, + ScanContext scanContext, PluginDrivenExternalCatalog catalog, + PluginDrivenExternalTable table) { + Connector connector = catalog.getConnector(); + ConnectorSession session = catalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + String dbName = table.getDb() != null ? table.getDb().getRemoteName() : ""; + // Resolve through the table's sys-aware seam (NOT raw metadata.getTableHandle): for a normal + // table this is identical to getTableHandle(session, dbName, remoteName), but for a + // PluginDrivenSysExternalTable the override returns the connector's SYSTEM handle (carrying + // sysTableName + forceJni), so the scan path threads force-JNI correctly for binlog/audit_log. + ConnectorTableHandle handle = table.resolveConnectorTableHandle(session, metadata) + .orElseThrow(() -> new RuntimeException( + "Table handle not found for plugin-driven table: " + dbName + "." + + table.getRemoteName())); + return new PluginDrivenScanNode(id, desc, needCheckColumnPriv, sv, + scanContext, connector, session, handle); + } + + /** + * Resolves the scan plan provider for the table being scanned, allowing a heterogeneous gateway + * connector to select a per-table (per-format) provider. Keyed on {@link #currentHandle} — the + * same handle the rest of the scan uses (pushdown may refine it, but a table's format, and thus + * the selected provider, does not change across a scan). For every single-format connector the + * SPI default ignores the handle and returns the connector-level provider, so this stays + * byte-identical to the former {@code connector.getScanPlanProvider()} and may still be + * {@code null} for connectors without scan capability. + */ + private ConnectorScanPlanProvider resolveScanProvider() { + // Memoized per currentHandle IDENTITY. The provider is a pure function of currentHandle (SPI contract: + // providers are built fresh/stateless per call and the selected provider does not change across a scan), + // so the per-split getFileCompressType / getDeleteFiles hot path reuses one instance instead of + // re-allocating + TCCL-swapping a provider per split. currentHandle is only refined (a new object) during + // early pushdown/pin, before split enumeration; an identity miss re-resolves — making this byte-identical + // to calling connector.getScanPlanProvider(currentHandle) every time. The immutable holder is published via + // one volatile write so the concurrent partition-batch appendBatch threads always read a self-consistent + // (handle, provider) pair; a null provider (no scan capability) is cached and returned correctly. + ResolvedScanProvider cached = resolvedScanProvider; + if (cached == null || cached.handle != currentHandle) { + cached = new ResolvedScanProvider(currentHandle, connector.getScanPlanProvider(currentHandle)); + resolvedScanProvider = cached; + } + return cached.provider; + } + + /** + * Immutable (handle, provider) pair for {@link #resolveScanProvider()}'s memo. Both fields final so a single + * volatile write of the holder safely publishes the pair to concurrent readers (no torn new-key/old-provider + * view). {@code provider} may be {@code null} for a connector without scan capability. + */ + private static final class ResolvedScanProvider { + private final ConnectorTableHandle handle; + private final ConnectorScanPlanProvider provider; + + ResolvedScanProvider(ConnectorTableHandle handle, ConnectorScanPlanProvider provider) { + this.handle = handle; + this.provider = provider; + } + } + + /** + * Injects the Nereids partition-pruning result. Called by the translator so the pruned + * partition set can be pushed down to the connector's scan plan (see {@link #getSplits}). + */ + public void setSelectedPartitions(SelectedPartitions selectedPartitions) { + this.selectedPartitions = selectedPartitions; + } + + /** + * Resolves the pruned partition spec strings to push to the connector SPI. + * + *

    Mirrors legacy {@code MaxComputeScanNode.getSplits()} three-state handling:

    + *
      + *
    • not pruned (NOT_PRUNED / non-partitioned) → {@code null}: scan all partitions;
    • + *
    • pruned to a non-empty set → that set's partition names;
    • + *
    • pruned to zero partitions → empty list: caller short-circuits with no splits.
    • + *
    + */ + static List resolveRequiredPartitions(SelectedPartitions selectedPartitions) { + return resolveRequiredPartitions(selectedPartitions, false); + } + + /** + * Overload that lets a PREDICATE-DRIVEN connector opt out of the genuine prune-to-zero short-circuit. + * + *

    A connector whose {@link ConnectorScanPlanProvider#ignorePartitionPruneShortCircuit()} is {@code + * true} (e.g. paimon, whose {@code planScan} ignores {@code requiredPartitions} and re-plans through the + * SDK with the pushed predicate) must NOT be short-circuited to zero rows when FE pruning genuinely empties + * the partition set — e.g. {@code col = } prunes every partition away, + * yet planScan must still run and answer from the pushed predicate. For such a connector a prune-to-zero + * maps to {@code null} (scan-all) instead of the empty list, exactly as the legacy {@code PaimonScanNode} + * (which never consults {@code selectedPartitions}). A non-empty pruned set is still forwarded unchanged. + * (Note: {@code col IS NULL} over a connector-supplied genuine-NULL partition now prunes ACCURATELY to that + * {@code NullLiteral} partition — a non-empty set — so it no longer relies on this opt-out; see + * {@code PluginDrivenMvccExternalTable.toListPartitionItem}.) For every other connector + * ({@code ignorePartitionPruneShortCircuit=false}) the behavior is identical to + * {@link #resolveRequiredPartitions(SelectedPartitions)}.

    + */ + static List resolveRequiredPartitions(SelectedPartitions selectedPartitions, + boolean ignorePartitionPruneShortCircuit) { + if (selectedPartitions == null || !selectedPartitions.isPruned) { + return null; + } + // A pruned-but-EMPTY selection over an EMPTY partition universe (totalPartitionNum == 0) means FE + // never enumerated any partitions to prune against — e.g. an MVCC time-travel pin (FOR VERSION/TIME + // AS OF, @tag, @branch) whose snapshot deliberately carries an empty partition-item map and defers + // partition resolution to the connector's predicate pushdown. Treat it as scan-all (null) so the + // getSplits() short-circuit does NOT fire and planScan runs, mirroring legacy PaimonScanNode (which + // ignores selectedPartitions and re-plans through the SDK with the pushed predicate). A GENUINE + // prune-to-zero over a NON-empty universe (totalPartitionNum > 0, e.g. MaxCompute WHERE + // part=) keeps the empty set so getSplits() short-circuits to zero rows — the + // existing MaxCompute parity behavior (FIX-NONPART-PRUNE-DATALOSS sibling). NB: for a partitioned + // MaxCompute table that genuinely has ZERO partitions this scan-all is row-equivalent to legacy's + // unconditional empty short-circuit — MaxComputeScanPlanProvider.planScan returns no splits when + // getFileNum() <= 0, still zero rows. + if (selectedPartitions.selectedPartitions.isEmpty() && selectedPartitions.totalPartitionNum == 0) { + return null; + } + // A predicate-driven connector re-plans through its SDK with the pushed predicate (its planScan + // ignores requiredPartitions), so a GENUINE prune-to-zero must NOT short-circuit the scan — return + // scan-all (null) and let planScan answer from the predicate (e.g. paimon `col = `). Master PaimonScanNode parity. + if (ignorePartitionPruneShortCircuit && selectedPartitions.selectedPartitions.isEmpty()) { + return null; + } + return new ArrayList<>(selectedPartitions.selectedPartitions.keySet()); + } + + /** + * Partition counts to surface on this scan node — {@code {selectedPartitionNum, totalPartitionNum}} + * — or {@code null} to leave the fields at their default (nothing to show). Drives the EXPLAIN + * {@code partition=N/M} line and SQL-block-rule enforcement (via {@code getSelectedPartitionNum()}). + * + *

    Mirrors legacy {@code MaxComputeScanNode}'s display gate: any real partition selection + * reports {@code size/total}, whereas the {@link SelectedPartitions#NOT_PRUNED} sentinel + * (non-partitioned table, or one not supporting internal pruning) reports nothing.

    + * + *

    The gate is {@code != NOT_PRUNED}, deliberately not {@code isPruned}: a partitioned table + * queried without a partition predicate keeps the initial all-partitions selection from + * {@link ExternalTable#initSelectedPartitions} ({@code isPruned=false} but a full, non-{@code + * NOT_PRUNED} map; {@code PruneFileScanPartition} only runs under a {@code LogicalFilter}), and must + * still report {@code partition=total/total} (e.g. {@code SELECT *} over a 2-partition table → + * {@code 2/2}). An {@code isPruned} gate wrongly shows {@code 0/0}. This differs from the connector + * pushdown gate ({@link #resolveRequiredPartitions}, which stays {@code isPruned}): an unpruned scan + * must read ALL partitions, so it pushes no partition restriction.

    + */ + static long[] displayPartitionCounts(SelectedPartitions selectedPartitions) { + if (selectedPartitions == null || selectedPartitions == SelectedPartitions.NOT_PRUNED) { + return null; + } + return new long[] { + selectedPartitions.selectedPartitions.size(), selectedPartitions.totalPartitionNum}; + } + + /** + * The {@code selectedPartitionNum} to surface (EXPLAIN {@code partition=N/M} + {@code sql_block_rule} + * {@code partition_num}): the connector's real scanned-partition count when it reports one + * ({@link ConnectorScanPlanProvider#scannedPartitionCount}), else the engine's Nereids-pruned count + * (the {@code displayPartitionCounts} value already set on the node). + * + *

    A predicate-driven connector (paimon manifest pruning, iceberg hidden/transform partitioning) + * scans fewer distinct partitions than Nereids' declared-partition-column pruning can see, so its + * reported count is the faithful one. Directory-partitioned / requiredPartition-driven connectors + * (hive, MaxCompute) report {@code empty} and keep the Nereids count (the two coincide).

    + * + *

    Suppressed under {@code COUNT(*)} pushdown: the connector collapses its splits into one count + * range (paimon {@code countRepresentative}, iceberg's single count range), so per-partition info is + * lost from the returned ranges — the engine keeps its conservative Nereids count there (Nereids + * ≥ real, so the {@code partition_num} guard only tightens, never under-blocks). Pure so the gate + * is unit-testable.

    + */ + static long resolveSelectedPartitionNum(long nereidsSelectedPartitionNum, boolean countPushdown, + OptionalLong connectorScannedPartitionCount) { + if (!countPushdown && connectorScannedPartitionCount.isPresent()) { + return connectorScannedPartitionCount.getAsLong(); + } + return nereidsSelectedPartitionNum; + } + + /** + * Write the connector's SDK scan diagnostics ({@link ConnectorScanProfile}s harvested during planScan) + * into this query's profile execution summary. Looks up the query's {@link SummaryProfile} and delegates + * the tree-building to the pure {@link #writeScanProfilesInto}. No-op when there is no profile (e.g. a + * statement not collecting one) or nothing to write. + */ + private void appendConnectorScanProfiles(List profiles) { + if (profiles == null || profiles.isEmpty()) { + return; + } + SummaryProfile summaryProfile = SummaryProfile.getSummaryProfile(ConnectContext.get()); + if (summaryProfile == null) { + return; + } + writeScanProfilesInto(summaryProfile.getExecutionSummary(), profiles); + } + + /** + * Transcribe connector-supplied scan profiles into {@code executionSummary}: get-or-create a group named + * {@link ConnectorScanProfile#getGroupName()}, add a child named {@link ConnectorScanProfile#getScanLabel()}, + * and write each metric as an info string. Purely connector-agnostic (no source-type branch) — the engine + * only transcribes what the connector produced. Pure and takes a bare {@link RuntimeProfile} so the + * tree-building is unit-testable without a {@code ConnectContext}. + */ + static void writeScanProfilesInto(RuntimeProfile executionSummary, List profiles) { + if (executionSummary == null || profiles == null || profiles.isEmpty()) { + return; + } + for (ConnectorScanProfile profile : profiles) { + RuntimeProfile group = executionSummary.getChildMap().get(profile.getGroupName()); + if (group == null) { + group = new RuntimeProfile(profile.getGroupName()); + executionSummary.addChild(group, true); + } + RuntimeProfile scan = new RuntimeProfile(profile.getScanLabel()); + for (Map.Entry entry : profile.getMetrics().entrySet()) { + scan.addInfoString(entry.getKey(), entry.getValue()); + } + group.addChild(scan, true); + } + } + + @Override + public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { + StringBuilder output = new StringBuilder(); + if (currentHandle instanceof PassthroughQueryTableHandle) { + output.append(prefix).append("TABLE VALUE FUNCTION\n"); + String query = ((PassthroughQueryTableHandle) currentHandle).getQuery(); + output.append(prefix).append("QUERY: ").append(query).append("\n"); + } else { + Map props = getOrLoadScanNodeProperties(); + String query = props.get("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 + // generic node name does not hide which connector this scan delegates to. Reuses the + // same getDatabase().getCatalog() chain getNameWithFullQualifiers() already walks here. + output.append(prefix).append("CONNECTOR: ") + .append(desc.getTable().getDatabase().getCatalog().getType()).append("\n"); + if (query != null) { + output.append(prefix).append("QUERY: ").append(query).append("\n"); + } + if (!conjuncts.isEmpty()) { + Expr expr = convertConjunctsToAndCompoundPredicate(conjuncts); + output.append(prefix).append("PREDICATES: ") + .append(expr.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE)) + .append("\n"); + } + // FIX-E (explain gap): the parent FileScanNode emits an + // "inputSplitNum=N, totalFileSize=X, scanRanges=Y" line that this override drops by not + // calling super (legacy PaimonScanNode inherited it via super.getNodeExplainString; + // test_paimon_predict asserts inputSplitNum=N). Re-emit it byte-for-byte (incl. the + // (approximate) batch prefix) from the same selectedSplitNum/totalFileSize/scanRangeLocations + // the parent populates in createScanRangeLocations, immediately before partition=N/M to match + // FileScanNode ordering. Emitted UNCONDITIONALLY for every plugin connector — like the sibling + // partition=N/M (below) and pushdown agg= lines — since it is universal FileScanNode info, not + // connector-specific (no source-type branch in this generic node). + output.append(prefix); + if (isBatchMode()) { + output.append("(approximate)"); + } + output.append("inputSplitNum=").append(selectedSplitNum).append(", totalFileSize=") + .append(totalFileSize).append(", scanRanges=").append(scanRangeLocations.size()) + .append("\n"); + // Partition-pruning summary (selected/total), mirroring the parent + // FileScanNode.getNodeExplainString()'s `partition=N/M` line. This override replaces the + // parent's body wholesale (custom TABLE/QUERY/PREDICATES format), so it must re-emit the + // line itself; the counts are populated from the Nereids pruning result in + // getSplits()/startSplit() (see setSelectedPartitions). + output.append(prefix).append("partition=").append(selectedPartitionNum) + .append("/").append(totalPartitionNum).append("\n"); + // FIX-E / FIX-R3-RESIDUAL (explain gap): the VERBOSE per-backend block (the backends: list, + // per-file "path start/length" lines, and dataFileNum/deleteFileNum/deleteSplitNum) lives in + // the parent FileScanNode but this override does not call super, so re-emit it under the SAME + // gate the parent uses: VERBOSE && !isBatchMode() (FileScanNode#getNodeExplainString). Emitted + // UNCONDITIONALLY for every plugin connector -- like the sibling inputSplitNum / partition=N/M + // (above) and pushdown agg= (below) lines -- because it is universal FileScanNode info, not + // connector-specific: NO source-name branch belongs in this generic node (a "paimon".equals( + // getType()) gate here previously dropped it for non-paimon connectors). This RESTORES the + // block that legacy MaxComputeScanNode / TrinoConnectorScanNode inherited from FileScanNode + // pre-cutover, and is consistent for every FILE_SCAN plugin connector (es/jdbc render their + // synthetic per-split path; connectors with no delete files render deleteFileNum=0 via + // getDeleteFiles -> empty). Connector-SPECIFIC EXPLAIN stays delegated to + // ConnectorScanPlanProvider.appendExplainInfo below; this block is emitted before that + // delegation so the ordering matches the legacy PaimonScanNode (FileScanNode body, then the + // connector's lines). + if (detailLevel == TExplainLevel.VERBOSE && !isBatchMode()) { + appendBackendScanRangeDetail(output, prefix); + } + // R5 (explain gap): FileScanNode#getNodeExplainString emits the cardinality/avgRowSize/numNodes + // line right after the VERBOSE block; this override drops it by not calling super, so + // test_hive_statistics_p0's `cardinality=66` assertion failed. Re-emit it verbatim -- like the + // sibling inputSplitNum / partition / backend-detail / nested-columns lines -- because row-count + // stats visibility is universal FileScanNode info, not connector-specific (the cardinality field + // is populated for every plugin FileScan via PhysicalPlanTranslator#setCardinality). + output.append(prefix); + if (cardinality > 0) { + output.append(String.format("cardinality=%s, ", cardinality)); + } + if (avgRowSize > 0) { + output.append(String.format("avgRowSize=%s, ", avgRowSize)); + } + output.append(String.format("numNodes=%s", numNodes)).append("\n"); + // F6/F7 (explain gap): the parent FileScanNode emits the "nested columns:" block (pruned type / + // sub path / all + predicate access paths) via printNestedColumns, which this override drops by + // not calling super, so the ENTIRE block vanished for every plugin FileScan connector (broader + // than iceberg). Re-emit it -- like the sibling inputSplitNum / partition / backend-detail lines -- + // because nested-column-pruning visibility is universal FileScanNode info, not connector-specific. + // printNestedColumns is connector-agnostic here: this node is a PluginDrivenScanNode (never an + // IcebergScanNode), so it takes the generic name-join path (PlanNode:954/970) and the legacy + // iceberg field-id merge arms (PlanNode:949/965) stay dead -- the field-id-annotated access-path + // form (col(3).sub(5)) is deliberately NOT reproduced (cosmetic, tracked as FU-h10-deadcode; the + // BE still receives the id-form path). Emitted before the connector delegation so the FileScanNode + // body parts precede the connector's lines (matches legacy: base, then icebergPredicatePushdown). + printNestedColumns(output, prefix, getTupleDesc()); + // Delegate connector-specific EXPLAIN info to the SPI. Thread the native/total split counts + // (FIX-E) the node accumulated in getSplits() into a copy of the props map via the synthetic + // keys, so a connector that distinguishes native/JNI reads (paimon) can emit its + // "paimonNativeReadSplits=/" line without an SPI signature change. The copy keeps + // the cached scanNodeProperties unpolluted; non-paimon providers ignore the extra keys. + 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)); + if (detailLevel == TExplainLevel.VERBOSE) { + explainProps.put(VERBOSE_EXPLAIN_KEY, "true"); + } + onPluginClassLoader(scanProvider, () -> { + scanProvider.appendExplainInfo(output, prefix, explainProps); + return null; + }); + } + // FIX-E (explain gap): the "pushdown agg= (n)" line lives in the parent FileScanNode + // but this override does not call super. Re-emit it for ALL plugin connectors (universally + // correct — its absence on plugin nodes is itself an inconsistency vs every other + // FileScanNode). When a no-grouping COUNT(*) is pushed down, tableLevelRowCount is set in + // getSplits() from the connector's precomputed count (or stays -1 -> the (-1) sentinel). + output.append(prefix).append(String.format("pushdown agg=%s", getPushDownAggNoGroupingOp())); + if (isTableLevelCountStarPushdown()) { + 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(",", + topnFilterSortNodes.stream() + .map(node -> node.getId().asInt() + "").collect(Collectors.toList())); + output.append(prefix).append("TOPN OPT:").append(topnFilterSources).append("\n"); + } + return output.toString(); + } + + @Override + protected TFileFormatType getFileFormatType() throws UserException { + Map props = getOrLoadScanNodeProperties(); + String format = props.get(PROP_FILE_FORMAT_TYPE); + if (format != null) { + return mapFileFormatType(format); + } + return TFileFormatType.FORMAT_JNI; + } + + @Override + protected List getPathPartitionKeys() throws UserException { + Map props = getOrLoadScanNodeProperties(); + String keys = props.get(PROP_PATH_PARTITION_KEYS); + if (keys != null && !keys.isEmpty()) { + return Arrays.asList(keys.split(",")); + } + return Collections.emptyList(); + } + + /** + * Classifies a query slot's column for the BE reader (C2 WS-SYNTH-READ). This is the generic, + * connector-agnostic port of the per-connector overrides (legacy {@code IcebergScanNode.classifyColumn}, + * {@code HiveScanNode}, {@code TVFScanNode}): it must keep the synthesized / generated special columns + * out of the file-read set so they are materialized by the connector reader rather than read from a data + * file where they do not exist. + * + *

    Two sources, no connector-type branching:

    + *
      + *
    • {@code __DORIS_GLOBAL_ROWID_COL__*} — the engine-wide lazy-materialization row-id (injected by + * {@code LazyMaterializeTopN}, also classified by {@code HiveScanNode}/{@code TVFScanNode}) is a + * generic Doris mechanism, so it is classified here as {@code SYNTHESIZED} directly.
    • + *
    • connector special columns (e.g. iceberg's hidden row-id / v3 row-lineage) are classified by the + * connector through {@link ConnectorScanPlanProvider#classifyColumn(String)}, so no iceberg (or any + * connector) knowledge leaks into the generic node.
    • + *
    + * Everything else falls through to {@code super} (partition key / regular). + */ + @Override + protected TColumnCategory classifyColumn(SlotDescriptor slot, List partitionKeys) { + String name = slot.getColumn().getName(); + if (name.startsWith(Column.GLOBAL_ROWID_COL)) { + return TColumnCategory.SYNTHESIZED; + } + ConnectorColumnCategory category = classifyColumnByConnector(name); + if (category == ConnectorColumnCategory.SYNTHESIZED) { + return TColumnCategory.SYNTHESIZED; + } + if (category == ConnectorColumnCategory.GENERATED) { + return TColumnCategory.GENERATED; + } + return super.classifyColumn(slot, partitionKeys); + } + + /** + * Asks the connector how to classify a special column (iceberg's hidden row-id / v3 row-lineage), so no + * connector knowledge leaks into {@link #classifyColumn}. Package-private + overridable so the mapping is + * unit-testable on a Mockito mock without a live connector (mirrors {@link #sysTableSupportsTimeTravel}). + * A connector with no scan provider (no scan capability) contributes no special columns ({@code DEFAULT}). + */ + ConnectorColumnCategory classifyColumnByConnector(String columnName) { + ConnectorScanPlanProvider scanProvider = resolveScanProvider(); + if (scanProvider == null) { + return ConnectorColumnCategory.DEFAULT; + } + return onPluginClassLoader(scanProvider, () -> scanProvider.classifyColumn(columnName)); + } + + /** + * Lets the owning connector adjust the compression type this node inferred from the split's file path + * before it is shipped to BE, WITHOUT any source-specific code here: the base inference runs first, then + * the connector's {@link ConnectorScanPlanProvider#adjustFileCompressType} (identity by default) gets the + * final say. Hive uses it to remap {@code LZ4FRAME -> LZ4BLOCK} (hadoop writes {@code .lz4} as block codec); + * every other connector inherits the identity default and is byte-unchanged. A connector with no scan + * provider keeps the inferred type. Mirrors {@link #classifyColumnByConnector} (same resolve + TCCL pin). + */ + @Override + protected TFileCompressType getFileCompressType(FileSplit fileSplit) throws UserException { + TFileCompressType inferred = super.getFileCompressType(fileSplit); + ConnectorScanPlanProvider scanProvider = resolveScanProvider(); + if (scanProvider == null) { + return inferred; + } + return onPluginClassLoader(scanProvider, () -> scanProvider.adjustFileCompressType(inferred)); + } + + @Override + protected TableIf getTargetTable() throws UserException { + return desc.getTable(); + } + + /** + * Runs a connector scan-plan call with the thread-context classloader pinned to the connector + * plugin's own loader, restoring it afterward. + * + *

    Plugin connectors run isolated under a child-first classloader, while fe-core ships some of + * the same third-party libraries (e.g. iceberg) on the parent 'app' loader. Such libraries resolve + * helper classes by name through the TCCL — iceberg-aws's {@code HttpClientProperties} loads + * {@code ApacheHttpClientConfigurations} via {@code DynMethods}, whose default loader IS the TCCL. + * Under the query thread's default ('app') TCCL that reflective load returns the parent copy and + * {@link ClassCastException}s against the child-loaded plugin copy. Pinning the TCCL to the + * provider's loader keeps every reflective load on the plugin side — the same split-brain guard + * {@code IcebergConnector.buildCatalogAuthenticated} applies on the catalog path. Keyed off the + * provider's own classloader, so it is connector-agnostic and a no-op for connectors that are not + * classloader-isolated. Must wrap the call on the thread that runs it: the streaming split paths + * execute on a pool thread that does not inherit the caller's TCCL. + */ + private static T onPluginClassLoader(ConnectorScanPlanProvider provider, Supplier body) { + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(provider.getClass().getClassLoader()); + return body.get(); + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + } + + /** + * Builds the query-finish callback that releases a connector's per-query read transaction. Hive full-ACID / + * insert-only reads open a metastore read transaction + shared read lock during {@code planScan}; this + * callback commits it (releasing the lock) when the query finishes. Registered UNCONDITIONALLY for every + * plugin scan in {@link #getSplits} — connector-agnostic, since a connector that opens no read transaction + * inherits the no-op {@link ConnectorScanPlanProvider#releaseReadTransaction} default and the callback is + * inert for it. The release runs on the StmtExecutor thread at query finish, whose TCCL is the fe-core app + * loader, so it MUST be pinned to the provider's plugin classloader ({@link #onPluginClassLoader}) or the + * commit's by-name class resolution (metastore/thrift) would split-brain against the app loader's copies. + * Extracted as a pure function of {@code (scanProvider, queryId)} so the release + TCCL-pin behavior is + * unit-testable without driving a full {@code getSplits}. + */ + public static Runnable buildReadTransactionReleaseCallback( + ConnectorScanPlanProvider scanProvider, String queryId) { + return () -> onPluginClassLoader(scanProvider, () -> { + scanProvider.releaseReadTransaction(queryId); + return null; + }); + } + + /** + * FIX-E (explain gap): delegates the VERBOSE per-backend block's delete-file lookup to the + * connector SPI. The parent {@link FileScanNode#getDeleteFiles} returns empty; a connector that + * threads delete files onto its per-range thrift (paimon's deletion vectors) overrides + * {@link ConnectorScanPlanProvider#getDeleteFiles(TTableFormatFileDesc)} to read them back. Reads + * the table-format params off the range (null-guarded, mirroring legacy + * {@code PaimonScanNode.getDeleteFiles}); connectors without delete files return empty, so the + * {@code deleteFileNum} count stays 0. + */ + @Override + protected List getDeleteFiles(TFileRangeDesc rangeDesc) { + ConnectorScanPlanProvider scanProvider = resolveScanProvider(); + if (scanProvider == null || rangeDesc == null || !rangeDesc.isSetTableFormatParams()) { + return Collections.emptyList(); + } + return onPluginClassLoader(scanProvider, () -> scanProvider.getDeleteFiles(rangeDesc.getTableFormatParams())); + } + + @Override + 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()); + locationProps.put(realKey, entry.getValue()); + } + } + return locationProps; + } + + @Override + protected TFileAttributes getFileAttributes() throws UserException { + Map props = getOrLoadScanNodeProperties(); + String serDeLib = props.get(PROP_HIVE_TEXT_PREFIX + "serde_lib"); + if (serDeLib == null || serDeLib.isEmpty()) { + return new TFileAttributes(); + } + + TFileAttributes attrs = new TFileAttributes(); + String skipLinesStr = props.get(PROP_HIVE_TEXT_PREFIX + "skip_lines"); + if (skipLinesStr != null) { + try { + attrs.setSkipLines(Integer.parseInt(skipLinesStr)); + } catch (NumberFormatException e) { + // ignore + } + } + + TFileTextScanRangeParams textParams = new TFileTextScanRangeParams(); + String colSep = props.get(PROP_HIVE_TEXT_PREFIX + "column_separator"); + if (colSep != null) { + textParams.setColumnSeparator(colSep); + } + String lineSep = props.get(PROP_HIVE_TEXT_PREFIX + "line_delimiter"); + if (lineSep != null) { + textParams.setLineDelimiter(lineSep); + } + String mapkvDelim = props.get(PROP_HIVE_TEXT_PREFIX + "mapkv_delimiter"); + if (mapkvDelim != null) { + textParams.setMapkvDelimiter(mapkvDelim); + } + String collDelim = props.get(PROP_HIVE_TEXT_PREFIX + "collection_delimiter"); + if (collDelim != null) { + textParams.setCollectionDelimiter(collDelim); + } + String escape = props.get(PROP_HIVE_TEXT_PREFIX + "escape"); + if (escape != null && !escape.isEmpty()) { + textParams.setEscape(escape.getBytes()[0]); + } + String nullFmt = props.get(PROP_HIVE_TEXT_PREFIX + "null_format"); + if (nullFmt != null) { + textParams.setNullFormat(nullFmt); + } + String enclose = props.get(PROP_HIVE_TEXT_PREFIX + "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"))) { + attrs.setTrimDoubleQuotes(true); + } + + attrs.setTextParams(textParams); + attrs.setHeaderType(""); + attrs.setEnableTextValidateUtf8(sessionVariable.enableTextValidateUtf8); + + String isJson = props.get(PROP_HIVE_TEXT_PREFIX + "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"); + if (ignoreMalformed != null) { + attrs.setOpenxJsonIgnoreMalformed(Boolean.parseBoolean(ignoreMalformed)); + } + } + + return attrs; + } + + @Override + protected void convertPredicate() { + // Attempt filter pushdown via the connector SPI + if (conjuncts == null || conjuncts.isEmpty()) { + return; + } + ConnectorMetadata metadata = metadata(); + ConnectorFilterConstraint constraint = buildFilterConstraint(conjuncts); + Optional> result = + metadata.applyFilter(connectorSession, currentHandle, constraint); + if (result.isPresent()) { + FilterApplicationResult filterResult = result.get(); + currentHandle = filterResult.getHandle(); + + // Consume remainingFilter to avoid duplicate predicate evaluation on BE: + // - null means all predicates were fully pushed down → clear conjuncts + // - non-null means some/all predicates remain → keep conjuncts (conservative) + ConnectorExpression remaining = filterResult.getRemainingFilter(); + if (remaining == null) { + conjuncts.clear(); + LOG.debug("Filter fully pushed down for plugin-driven scan, cleared conjuncts"); + } else { + // Partial or full remaining: keep all conjuncts for BE-side evaluation. + // Fine-grained conjunct removal (matching individual remaining sub-expressions + // back to original Expr conjuncts) is deferred to a future enhancement. + LOG.debug("Filter pushdown accepted with remaining filter, keeping conjuncts"); + } + } + // Invalidate cached properties so they are rebuilt with the updated conjuncts/handle. + scanNodeProperties = null; + cachedPropertiesResult = null; + 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. + * + *

    If the connector accepts the projection, the handle is updated.

    + */ + private void tryPushDownProjection(List columns) { + if (columns.isEmpty()) { + return; + } + ConnectorMetadata metadata = metadata(); + Optional> result = + metadata.applyProjection(connectorSession, currentHandle, columns); + if (result.isPresent()) { + currentHandle = result.get().getHandle(); + LOG.debug("Projection pushed down via applyProjection for plugin-driven scan"); + } + } + + /** + * Threads the pinned MVCC snapshot (if any) onto the table handle via the SPI + * {@link ConnectorMetadata#applySnapshot} protocol. WHY: an MVCC-capable connector (e.g. a + * time-travel / MTMV-consistent read) must consume the SAME pinned point-in-time snapshot at + * every scan-side consumption of the handle ({@code planScan} and the serialized-table / + * {@code getScanNodeProperties} path); the pin therefore has to be threaded onto the handle + * BEFORE each of those points or one path silently reads LATEST. {@code applySnapshot} is + * idempotent (it re-derives the scan options from the snapshot each call), so calling this at + * every consumption site is safe. A missing pin — before the connector is MVCC-cutover, or a + * non-MVCC table, or a foreign (non-plugin) snapshot — leaves the handle unchanged (reads latest). + * + *

    Public static so the correctness-critical pin-vs-skip decision is unit-testable directly on + * Mockito mocks, without constructing a {@link FileQueryScanNode} (the call-site wiring is covered by + * live e2e — see DV-019), and so the WRITE path can reuse the IDENTICAL pin logic: the connector sink + * translator ({@code PhysicalPlanTranslator.visitPhysicalConnectorTableSink}) threads the same + * statement pin onto the write handle so a DML's write anchors at the snapshot its scan read + * ([SHOULD-2] / Fix B). Scan and write MUST pin identically — sharing this method guarantees that. + */ + public static ConnectorTableHandle applyMvccSnapshotPin(ConnectorMetadata metadata, ConnectorSession session, + ConnectorTableHandle handle, Optional snapshot) { + if (snapshot.isPresent() && snapshot.get() instanceof PluginDrivenMvccSnapshot) { + ConnectorMvccSnapshot connectorSnapshot = + ((PluginDrivenMvccSnapshot) snapshot.get()).getConnectorSnapshot(); + return metadata.applySnapshot(session, handle, connectorSnapshot); + } + // No pin in context, or a non-plugin snapshot -> read latest (unchanged handle). + return handle; + } + + /** + * 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. + */ + private void pinMvccSnapshot() throws UserException { + ConnectorMetadata metadata = metadata(); + // Version-aware lookup: a statement mixing main and @branch/@tag (or FOR-TIME) of the SAME table + // pins one snapshot per reference; resolve THIS scan's reference by its own selector so it reads + // its own snapshot (not whichever reference loaded first). getQueryTableSnapshot()/getScanParams() + // are this scan's selectors, threaded from the relation by the translator. + Optional snapshot = MvccUtil.getSnapshotFromContext(getTargetTable(), + Optional.ofNullable(getQueryTableSnapshot()), Optional.ofNullable(getScanParams())); + if (!snapshot.isPresent()) { + // A normal MVCC table's snapshot is materialized into the StatementContext during analysis + // (StatementContext.loadSnapshots, keyed by the table and the reference's version selector); + // a plugin SYSTEM table's is NOT — + // the sys table is not an MvccTable and BindRelation short-circuits loadSnapshots for the + // $-suffixed relation, so getSnapshotFromContext returns empty above. Resolve the sys-table + // FOR TIME AS OF / @branch / @tag pin directly off the source table here so the sys handle + // reads at the pin (legacy IcebergScanNode.createTableScan parity) instead of silently + // reading latest. Returns empty for every other case, so the normal-table path is unchanged. + snapshot = resolveSysTableSnapshotPin(); + } + // L17 fail-loud guard: this scan reads at its version-aware snapshot (resolved above), but the + // table's schema binding (PluginDrivenMvccExternalTable.getSchemaCacheValue) is version-BLIND, so a + // same-table multi-version reference (e.g. self-join FOR VERSION AS OF v1 vs v2 across a schema + // change, or v1 joined with a latest reference) can carry an FE tuple bound at a DIFFERENT schema + // than the version this reference scans -> BE field-id/name-mismatches file columns to tuple slots + // (crash / wrong NULLs). Verify every bound tuple column resolves in THIS reference's pinned schema; + // throw a clear error instead of silently skewing. Deterministic + per-reference (runs with all pins + // loaded, checks this reference's own pin), so it catches the latest-masked / @incr / MTMV-refresh + // cases the version-blind analysis-time binding cannot. Per user decision 2026-07-13: throw for now; + // the per-reference version-aware schema-binding refactor is tracked as D-MVCC-VERSION-SCHEMA. A + // latest / @incr / hive scan carries a null pinnedSchema -> no-op; so does a sys-table scan on a + // connector that rejects sys-table time travel (resolveSysTableSnapshotPin returns empty). A sys-table + // scan on a connector that DOES honor it (iceberg) is excluded inside the guard — see there. + if (snapshot.isPresent() && snapshot.get() instanceof PluginDrivenMvccSnapshot) { + List boundColumns = new ArrayList<>(); + for (SlotDescriptor slot : desc.getSlots()) { + if (slot.getColumn() != null) { + boundColumns.add(slot.getColumn()); + } + } + assertBoundColumnsResolveInPinnedSchema(boundColumns, + ((PluginDrivenMvccSnapshot) snapshot.get()).getPinnedSchema(), + getTargetTable()); + } + currentHandle = applyMvccSnapshotPin(metadata, connectorSession, currentHandle, snapshot); + } + + /** + * Fail-loud guard for the version-blind schema-binding gap (reverify #65185 L17). {@code boundColumns} + * are the projected tuple-slot columns (what the FE tuple / BE scan slots expect); {@code pinnedSchema} + * is the schema AS OF the version THIS reference actually scans. If any bound column cannot be resolved in + * the pinned schema, the tuple was bound at a different version than the scan reads and BE would + * mismatch — throw instead of silently returning wrong rows. + * + *

    Matching keys on whether the column carries a field id (a generic {@link Column} property, NOT a + * source-name branch): {@code uniqueId >= 0} (iceberg carries the iceberg field-id) matches by field-id + * (BE matches iceberg columns by id, so a rename that keeps the id is fine, a renumber / added column is + * caught); {@code uniqueId < 0} (paimon has no top-level field-id) matches by name. Reader-synthesized + * row-id columns ({@link Column#GLOBAL_ROWID_COL}) are skipped — they are not table columns and are + * absent from every pinned schema by construction.

    + * + *

    Two no-ops, both because the guard's precondition — {@code boundColumns} and {@code pinnedSchema} + * describe the SAME table — does not hold: + *

      + *
    • A {@code null} pinnedSchema (latest / {@code @incr} / hive reference): nothing to compare.
    • + *
    • A {@code table} that is a {@link PluginDrivenSysExternalTable}: a sys-table scan's pin is BY + * CONSTRUCTION resolved off the SOURCE table ({@code resolveSysTableSnapshotPin}), so pinnedSchema + * carries the source's columns while boundColumns carries the sys table's synthetic ones + * (file_path / pos / row / spec_id / ...). Comparing them is a category error that can never + * resolve — it threw a bogus "multiple versions" error on every iceberg sys-table time travel + * (CI 997422). Same class of exclusion as {@code GLOBAL_ROWID_COL} above. Connectors that reject + * sys-table time travel never get here (their pin resolves empty), so this changes nothing for + * them.
    • + *
    + * The guard keeps full strength for real MVCC tables: {@link PluginDrivenSysExternalTable} and + * {@link PluginDrivenMvccExternalTable} are sibling subclasses, so no normal table matches.

    + */ + static void assertBoundColumnsResolveInPinnedSchema(List boundColumns, + SchemaCacheValue pinnedSchema, TableIf table) throws UserException { + if (pinnedSchema == null || table instanceof PluginDrivenSysExternalTable) { + return; + } + String tableName = table.getName(); + Set pinnedFieldIds = new HashSet<>(); + Set pinnedNames = new HashSet<>(); + for (Column c : pinnedSchema.getSchema()) { + pinnedFieldIds.add(c.getUniqueId()); + pinnedNames.add(c.getName().toLowerCase()); + } + for (Column bound : boundColumns) { + if (bound.getName().startsWith(Column.GLOBAL_ROWID_COL)) { + // Reader-synthesized row-id injected by topn lazy materialization (LazyMaterializeTopN), + // not a table column: it carries uniqueId = Integer.MAX_VALUE, so it is BY CONSTRUCTION + // absent from every pinned schema and would make this guard fire on every + // "pinned version + order by/limit" query. Excluded exactly like classifyColumn() and + // hasTopnLazyMaterializeSlot() already do. + continue; + } + boolean resolved = bound.getUniqueId() >= 0 + ? pinnedFieldIds.contains(bound.getUniqueId()) + : pinnedNames.contains(bound.getName().toLowerCase()); + if (!resolved) { + throw new UserException("Reading the same table at multiple versions with different schemas " + + "in one statement is not supported yet: column '" + bound.getName() + "' of table '" + + tableName + "' is bound at a different version than the one this reference scans. " + + "Rewrite as separate statements."); + } + } + } + + /** + * Threads a distributed {@code rewrite_data_files} group's per-group file scope onto {@code handle} + * BEFORE {@code planScan}, so the group's INSERT-SELECT scans ONLY the data files that group bin-packed + * (mirrors {@link #applyMvccSnapshotPin}). {@code rawDataFilePaths} are the RAW paths the connector's + * {@code planRewrite} emitted; the connector's {@link ConnectorMetadata#applyRewriteFileScope} matches its + * re-enumerated tasks against the SAME raw strings. + * + *

    A {@code null}/empty path list is a no-op (returns {@code handle} unchanged — full-table scan): an + * absent scope must read everything, and an EMPTY scope must NOT be threaded down (it would scope to + * "match nothing"). Public static so the pin-vs-skip decision is unit-testable directly on a Mockito mock, + * exactly like {@link #applyMvccSnapshotPin}.

    + */ + public static ConnectorTableHandle applyRewriteFileScopePin(ConnectorMetadata metadata, + ConnectorSession session, ConnectorTableHandle handle, List rawDataFilePaths) { + if (rawDataFilePaths == null || rawDataFilePaths.isEmpty()) { + return handle; + } + return metadata.applyRewriteFileScope(session, handle, new HashSet<>(rawDataFilePaths)); + } + + /** + * Resolves the per-group rewrite file scope from the statement context and threads it onto + * {@link #currentHandle} (mutates exactly like {@link #pinMvccSnapshot}). Called at every scan-side + * handle-consumption point so the split path, the async batch path and the serialized-table path all scan + * the scoped file set. NON-consuming read (the per-group {@code StatementContext} is single-use, so the + * scope is the same at every site within the statement); a no-op for every non-rewrite scan, so it is + * byte-identical for normal reads. + */ + private void pinRewriteFileScope() { + ConnectContext ctx = ConnectContext.get(); + if (ctx == null || ctx.getStatementContext() == null) { + return; + } + List scope = ctx.getStatementContext().getRewriteSourceFilePaths(); + if (scope == null || scope.isEmpty()) { + return; + } + ConnectorMetadata metadata = metadata(); + currentHandle = applyRewriteFileScopePin(metadata, connectorSession, currentHandle, scope); + } + + /** + * Threads a Top-N lazy-materialization signal onto {@link #currentHandle} (mutates exactly like + * {@link #pinRewriteFileScope}) when this scan carries the engine-wide synthesized row-id column + * ({@code __DORIS_GLOBAL_ROWID_COL__}, injected by {@code LazyMaterializeTopN} for {@code ORDER BY + * ... LIMIT}). Under lazy materialization BE reads the sort key first, then re-fetches the OTHER + * (non-projected) columns of the surviving rows by row-id, so a connector whose scan metadata is + * pruned to the requested columns must rebuild it over the full schema (legacy + * {@code IcebergScanNode.createScanRangeLocations} {@code haveTopnLazyMatCol} parity). A no-op for + * every non-lazy-mat scan and for every connector whose {@link ConnectorMetadata#applyTopnLazyMaterialization} + * is the default (handle unchanged), so it is byte-identical for normal reads. + */ + private void pinTopnLazyMaterialize() { + if (!hasTopnLazyMaterializeSlot(desc.getSlots())) { + return; + } + ConnectorMetadata metadata = metadata(); + currentHandle = metadata.applyTopnLazyMaterialization(connectorSession, currentHandle); + } + + /** + * Whether any scan slot is the engine-wide synthesized lazy-materialization row-id column + * ({@code Column.GLOBAL_ROWID_COL} prefix). Mirrors legacy {@code IcebergScanNode.createScanRangeLocations}' + * {@code haveTopnLazyMatCol} detection and the same prefix test already used by {@link #classifyColumn}. + * Static + package-private so the pure detection is unit-testable directly (mirrors + * {@link #applyMvccSnapshotPin} / {@link #applyRewriteFileScopePin}). + */ + static boolean hasTopnLazyMaterializeSlot(List slots) { + for (SlotDescriptor slot : slots) { + Column col = slot.getColumn(); + if (col != null && col.getName().startsWith(Column.GLOBAL_ROWID_COL)) { + return true; + } + } + return false; + } + + /** + * Resolves the time-travel pin for a {@link PluginDrivenSysExternalTable} query whose pin never + * enters the {@link org.apache.doris.nereids.StatementContext} MVCC map (the sys table is not an + * {@link MvccTable}; see {@link #pinMvccSnapshot}). Delegates to the SOURCE table's + * {@link MvccTable#loadSnapshot} — the same resolution a normal-table read uses — so the connector's + * point-in-time conversion ({@code FOR VERSION/TIME AS OF} {@link org.apache.doris.analysis.TableSnapshot}, + * {@code @branch}/{@code @tag} {@link org.apache.doris.analysis.TableScanParams}), not-found messages and + * mutual-exclusion check are reused verbatim. The resolved snapshot is then threaded onto the sys handle + * by {@link #applyMvccSnapshotPin}, which {@code IcebergConnectorMetadata.applySnapshot} preserves as a + * pinned SYSTEM handle (the connector's {@code planSystemTableScan} applies it). + * + *

    Returns empty (no pin) when the target is not a sys table, when there is no time-travel selector, + * when the connector does not honor sys-table time travel ({@link #sysTableSupportsTimeTravel()} — + * paimon, the default), or — defensively — when the source is not MVCC-capable. + * + *

    The capability check must stay here, not be left to {@link #checkSysTableScanConstraints}. + * That guard is the one that produces the intended user-facing message, but it only runs at split + * generation ({@link #getSplits} / {@code startSplit}) — long AFTER this method runs at init. Resolving + * a pin for a connector that rejects sys-table time travel therefore hands the SOURCE table's snapshot + * (and its non-null pinned schema) to a scan whose tuple carries the SYS table's columns, and the + * failure surfaces first — as a bogus L17 "multiple versions" error, or as {@code loadSnapshot}'s own + * not-found {@link RuntimeException} (which is not a {@link UserException} and so escapes uncaught) — + * masking the intended message. Returning empty here lets the query reach the real guard. + * + *

    Package-private + overridable so the resolution is unit-testable on a Mockito mock without a live + * connector (mirrors {@link #applyMvccSnapshotPin} / {@link #checkSysTableScanConstraints}). + */ + Optional resolveSysTableSnapshotPin() throws UserException { + if (!(getTargetTable() instanceof PluginDrivenSysExternalTable)) { + return Optional.empty(); + } + if (getQueryTableSnapshot() == null && getScanParams() == null) { + return Optional.empty(); + } + if (!sysTableSupportsTimeTravel()) { + // Connector rejects sys-table time travel (paimon, the default). Do NOT resolve a pin: leave the + // rejection to checkSysTableScanConstraints, which owns the user-facing message. See the class + // note above on why deferring the capability check to that guard does not work. + return Optional.empty(); + } + PluginDrivenExternalTable source = ((PluginDrivenSysExternalTable) getTargetTable()).getSourceTable(); + if (!(source instanceof MvccTable)) { + return Optional.empty(); + } + return Optional.of(((MvccTable) source).loadSnapshot( + Optional.ofNullable(getQueryTableSnapshot()), + Optional.ofNullable(getScanParams()))); + } + + /** + * Fail-loud guard for plugin system-table scans: a {@link PluginDrivenSysExternalTable} must + * reject {@code FOR TIME AS OF} (snapshot) and {@code @incr}/scan-params queries rather than + * silently ignore them. Mirrors legacy {@code PaimonScanNode.getProcessedTable}, which throws the + * same two messages when the target is a {@code PaimonSysExternalTable}. Runs before split + * generation on BOTH planning entry points ({@link #getSplits}, {@link #startSplit}). + * + *

    Scope: SYS-table only. Normal-plugin-table time-travel handling is B5/MVCC and is out of + * scope here. + * + *

    Package-private (not private) so the guard can be unit-tested directly on a Mockito mock + * with the three accessors stubbed, without constructing a full {@link FileQueryScanNode}. + */ + void checkSysTableScanConstraints() throws UserException { + if (!(getTargetTable() instanceof PluginDrivenSysExternalTable)) { + return; + } + boolean timeTravelSupported = sysTableSupportsTimeTravel(); + if (getScanParams() != null) { + // A connector whose sys tables honor a pin (iceberg) still rejects @incr — incremental read + // of a synthetic metadata table is undefined; a connector that does not (paimon, the default) + // rejects EVERY scan-param. Branch/tag are allowed only for the former. + if (!timeTravelSupported || getScanParams().incrementalRead()) { + throw new UserException("Plugin system tables do not support scan params."); + } + } + if (getQueryTableSnapshot() != null && !timeTravelSupported) { + throw new UserException("Plugin system tables do not support time travel."); + } + } + + /** + * Whether the connector's system tables honor a time-travel / branch-tag pin (iceberg) versus reject + * it (paimon, the default). Package-private + overridable so {@link #checkSysTableScanConstraints} + * stays unit-testable on a Mockito mock without a live connector (mirrors the guard's own visibility). + */ + boolean sysTableSupportsTimeTravel() { + ConnectorScanPlanProvider scanProvider = resolveScanProvider(); + if (scanProvider == null) { + return false; + } + return onPluginClassLoader(scanProvider, scanProvider::supportsSystemTableTimeTravel); + } + + @Override + public List getSplits(int numBackends) throws UserException { + checkSysTableScanConstraints(); + // Attempt limit and projection pushdown via SPI protocol + tryPushDownLimit(); + + ConnectorScanPlanProvider scanProvider = resolveScanProvider(); + if (scanProvider == null) { + LOG.warn("Connector does not provide a scan plan provider, returning empty splits"); + return Collections.emptyList(); + } + + // Register the per-query read-transaction release BEFORE planScan (and before the pruned-to-zero + // short-circuit below), so a planScan that opens a metastore read transaction and then throws still has + // its release callback in place. Unconditional and connector-agnostic: a connector that opens no read + // transaction (every connector except transactional/ACID hive) inherits the no-op + // releaseReadTransaction default, so this is inert for it (the callback only pins TCCL and calls the + // no-op). The callback runs on the StmtExecutor thread at query finish, whose TCCL is the fe-core app + // loader, so the release is pinned to the provider's plugin classloader (see the helper). One string: + // connectorSession.getQueryId() == the query-finish registry key == the connector's txnMap key. + String readTxnQueryId = connectorSession.getQueryId(); + QeProcessorImpl.INSTANCE.registerQueryFinishCallback(readTxnQueryId, + buildReadTransactionReleaseCallback(scanProvider, readTxnQueryId)); + + // Deterministic close of the per-statement metadata scope, on the SAME query-finish hook and the SAME + // query-id key as the read-transaction release above. This is the PRIMARY close: getSplits runs only for + // coordinated scans, all of which reach unregisterQuery, so it fires after off-thread pump quiescence and + // leaves no dangling registry entry. Object-capture (scope::closeAll binds THIS scope instance) so a retry + // / prepared-EXECUTE that swaps the StatementContext field can never let this callback touch a successor + // scope. Skip NONE (off-thread / no-ConnectContext builds carry NONE and hold nothing to close). Non-scan + // statements (DDL / SHOW / EXPLAIN via Command.run) never reach here and are closed by StatementContext. + ConnectorStatementScope statementScope = connectorSession.getStatementScope(); + if (statementScope != ConnectorStatementScope.NONE) { + QeProcessorImpl.INSTANCE.registerQueryFinishCallback(readTxnQueryId, statementScope::closeAll); + } + + // Push the Nereids partition-pruning result down to the connector so the read session + // covers only the surviving partitions. A pruned-to-zero set means no data to read, + // mirroring legacy MaxComputeScanNode.getSplits()'s empty-selection short-circuit — UNLESS the + // connector is predicate-driven (ignorePartitionPruneShortCircuit), in which case a prune-to-zero + // maps to scan-all and planScan re-plans from the pushed predicate (paimon `col IS NULL` parity). + boolean ignorePartitionPruneShortCircuit = onPluginClassLoader( + scanProvider, scanProvider::ignorePartitionPruneShortCircuit); + List requiredPartitions = resolveRequiredPartitions( + selectedPartitions, ignorePartitionPruneShortCircuit); + // Surface the partition counts for EXPLAIN (partition=N/M) and SQL-block-rule enforcement, + // mirroring legacy MaxComputeScanNode.getSplits():720-722. Set BEFORE the pruned-to-zero + // short-circuit below so a 0-partition selection still reports partition=0/total (e.g. WHERE + // part=). Batch mode populates these in startSplit() instead. See + // displayPartitionCounts for why the gate covers the no-predicate all-partitions case. + long[] partitionCounts = displayPartitionCounts(selectedPartitions); + if (partitionCounts != null) { + this.selectedPartitionNum = partitionCounts[0]; + this.totalPartitionNum = partitionCounts[1]; + } + if (requiredPartitions != null && requiredPartitions.isEmpty()) { + return Collections.emptyList(); + } + + List columns = buildColumnHandles(); + tryPushDownProjection(columns); + Optional remainingFilter = buildRemainingFilter(); + // Pin the MVCC snapshot onto currentHandle AFTER projection/filter pushdown rebuilt it and + // immediately before planScan consumes it, so the native split path reads at the pinned + // snapshot. getSplits already declares UserException, so a getTargetTable() failure propagates. + pinMvccSnapshot(); + // Scope the scan to a distributed rewrite group's files (no-op for every non-rewrite read). + pinRewriteFileScope(); + + // If buildRemainingFilter stripped non-pushable (CAST) conjuncts (filteredToOriginalIndex + // != null), suppress source-side LIMIT pushdown: the connector now sees a filter that no + // longer reflects those predicates and could apply a LIMIT (e.g. MaxCompute's row-offset + // limit-split optimization, which fires on an empty/partition-only filter) over rows the + // stripped predicate has NOT filtered. Since BE re-evaluates the stripped predicate only on + // the rows the source returns, that would under-return. Legacy disabled limit-split whenever + // a non-partition-equality (incl. CAST) predicate was present; this mirrors it. + long sourceLimit = effectiveSourceLimit(limit, filteredToOriginalIndex != null); + // TABLESAMPLE (FIX-M1): applied (sampleSplits below) only when the connector declares its scan + // ranges carry byte lengths (supportsTableSample), so the size-weighted selection is valid. Only + // Hive sampled pre-SPI; connectors whose ranges lack byte-proportional lengths keep the default + // false and no-op the sample (full-table scan, as before) — surfaced with a warning rather than + // silently dropped. connector-agnostic: a generic capability, not a source-type branch. + boolean applySample = tableSample != null + && onPluginClassLoader(scanProvider, scanProvider::supportsTableSample); + if (tableSample != null && !applySample) { + LOG.warn("TABLESAMPLE is not supported by connector [{}]; scanning the full table", + desc.getTable().getDatabase().getCatalog().getType()); + } + // Forward the no-grouping COUNT(*) signal to the connector (FIX-COUNT-PUSHDOWN). The op is set + // on this node by the Nereids translator (PhysicalPlanTranslator) and shipped to BE via + // 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). + // 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)); + + List splits = new ArrayList<>(ranges.size()); + for (ConnectorScanRange range : ranges) { + splits.add(new PluginDrivenSplit(range)); + } + // FIX-E (explain gap): accumulate the native/total scan-range counts (for the connector + // EXPLAIN line paimonNativeReadSplits) and, under COUNT(*) pushdown, the precomputed merged row + // count (for FileScanNode's "pushdown agg=COUNT (n)" line). Both come from generic + // ConnectorScanRange getters (default false / -1), so non-paimon connectors are unaffected. + this.nativeReadSplitNum = countNativeReadRanges(ranges); + this.totalReadSplitNum = ranges.size(); + // FIX-L12: prefer the connector's real scanned-partition count (distinct native partitions after + // its SDK's manifest/residual/transform pruning) over the Nereids declared-column prune count set + // at displayPartitionCounts above, so partition=N/M and sql_block_rule reflect what is actually + // scanned. Opt-in: the default returns empty and the Nereids count stands (correct for + // hive/MaxCompute, where the two coincide). Suppressed under COUNT(*) pushdown (collapsed ranges + // lost per-partition info). connector-agnostic: one uniform SPI call + a pure helper, no source + // branch — the connector downcasts its own range type to read partition identity. + OptionalLong connectorScannedPartitions = onPluginClassLoader(scanProvider, + () -> scanProvider.scannedPartitionCount(ranges)); + this.selectedPartitionNum = resolveSelectedPartitionNum( + this.selectedPartitionNum, countPushdown, connectorScannedPartitions); + // FIX-SCAN-METRICS: drain the connector's SDK scan diagnostics (harvested during planScan, keyed by + // queryId) and write them into the query profile. connector-agnostic: the connector supplies the + // group/label/metrics, the engine only transcribes them (no source branch). Default empty for + // connectors that don't harvest. Same thread as planScan, so the harvest is complete. + List scanProfiles = onPluginClassLoader(scanProvider, + () -> scanProvider.collectScanProfiles(connectorSession)); + appendConnectorScanProfiles(scanProfiles); + long pushDownRowCount = resolvePushDownRowCount(countPushdown, ranges); + if (pushDownRowCount >= 0) { + // Only set when a range actually carries a precomputed count (e.g. paimon's collapsed count + // range). Deletion-vector tables emit no count range, so tableLevelRowCount stays -1 and the + // line renders the (-1) sentinel — the correctness-critical no-precomputed-count case. + setPushDownCount(pushDownRowCount); + } + // TABLESAMPLE (FIX-M1): keep a size-weighted random subset of the planned splits. Only reached + // when the connector opted in (applySample), i.e. its ranges carry byte lengths — so operating on + // the generic Split.getLength() is valid. estimatedRowSize (ROWS mode) = sum of column slot sizes, + // mirroring legacy HiveScanNode.selectFiles. + if (applySample) { + long estimatedRowSize = 0; + for (Column column : desc.getTable().getFullSchema()) { + estimatedRowSize += column.getDataType().getSlotSize(); + } + splits = sampleSplits(splits, tableSample, estimatedRowSize); + } + return splits; + } + + /** + * Connector-agnostic TABLESAMPLE: keeps a size-weighted random subset of splits, mirroring legacy + * {@code HiveScanNode.selectFiles} but operating on the generic {@link Split#getLength()}. Only called + * when the connector declares {@code supportsTableSample()} (its ranges carry positive byte lengths), + * so a negative/row-count length can never corrupt the accumulation. PERCENT targets + * {@code totalSize * value / 100}; ROWS targets {@code estimatedRowSize * value} (estimatedRowSize = sum + * of column slot sizes). The shuffle is seeded by the sample's REPEATABLE seek so a repeated query + * returns the same subset. Pure static so the size-accumulation + seed determinism is unit-testable + * without driving a full {@code planScan}. + */ + static List sampleSplits(List splits, TableSample tableSample, long estimatedRowSize) { + long totalSize = 0; + for (Split split : splits) { + totalSize += split.getLength(); + } + long sampleSize; + if (tableSample.isPercent()) { + sampleSize = totalSize * tableSample.getSampleValue() / 100; + } else { + sampleSize = estimatedRowSize * tableSample.getSampleValue(); + } + Collections.shuffle(splits, new Random(tableSample.getSeek())); + long selectedSize = 0; + int index = 0; + for (Split split : splits) { + selectedSize += split.getLength(); + index += 1; + if (selectedSize >= sampleSize) { + break; + } + } + return splits.subList(0, index); + } + + /** + * Counts the scan ranges read by BE's native (ORC/Parquet) reader (vs JNI), via the generic + * {@link ConnectorScanRange#isNativeReadRange()} (default false). Drives the EXPLAIN + * {@code paimonNativeReadSplits=/} numerator. Pure static so the accounting is + * unit-testable without driving a full {@code planScan}. + */ + static int countNativeReadRanges(List ranges) { + int nativeCount = 0; + for (ConnectorScanRange range : ranges) { + if (range.isNativeReadRange()) { + nativeCount++; + } + } + return nativeCount; + } + + /** + * Resolves the pushed-down COUNT(*) row count to surface on the EXPLAIN + * {@code pushdown agg=COUNT (n)} line: the first range carrying a precomputed count + * ({@link ConnectorScanRange#getPushDownRowCount()} {@code >= 0}) when count pushdown is active, + * else {@code -1}. The {@code -1} return is load-bearing (Rule 9): a deletion-vector table emits + * NO count range, so the sentinel must survive and render as {@code (-1)} — BE then counts by + * reading. Returns {@code -1} immediately when count pushdown is not active (a non-COUNT scan must + * never pick up a stray precomputed count). Pure static so the sentinel survival is unit-testable. + */ + static long resolvePushDownRowCount(boolean countPushdown, List ranges) { + if (!countPushdown) { + return -1; + } + for (ConnectorScanRange range : ranges) { + if (range.getPushDownRowCount() >= 0) { + return range.getPushDownRowCount(); + } + } + return -1; + } + + /** + * Source-side LIMIT to pass to {@code planScan}: the real limit normally, but {@code -1} + * (no source limit) when non-pushable conjuncts were stripped from the filter. A source LIMIT + * applied before a stripped (BE-only) predicate would return too few rows (BE can only filter + * the returned rows down, not recover rows the source never returned). Extracted as a pure + * static so the correctness-critical decision is unit-testable without a {@link FileQueryScanNode}. + */ + static long effectiveSourceLimit(long limit, boolean nonPushableConjunctsStripped) { + return nonPushableConjunctsStripped ? -1L : limit; + } + + /** + * Enables batched / streaming split generation for large partitioned scans, mirroring legacy + * {@code MaxComputeScanNode.isBatchMode()}. Three gates are evaluated generically from state the + * node already holds (partition pruning + slots + the {@code num_partitions_in_batch_mode} + * threshold); the connector-specific gate (legacy {@code odpsTable.getFileNum() > 0}) is + * delegated to {@link ConnectorScanPlanProvider#supportsBatchScan}. + */ + @Override + public boolean isBatchMode() { + if (isBatchModeCache == null) { + isBatchModeCache = computeBatchMode(); + } + return isBatchModeCache; + } + + private boolean computeBatchMode() { + // getScanPlanProvider() may be null for connectors without scan capability; mirror the + // null-guard in getSplits() so isBatchMode (run on the dispatch + explain paths) never NPEs. + ConnectorScanPlanProvider scanProvider = resolveScanProvider(); + if (scanProvider == null) { + return false; + } + // TABLESAMPLE (FIX-M1) is applied in the synchronous getSplits() path (sampleSplits); the batch + // path (startSplit) never samples. Force sync when the sample WILL be applied (connector opted in), + // so a sampled scan never silently skips sampling on the batch/streaming path. Sampling shuffles the + // whole split set anyway, so batch generation offers no benefit here. Gated on supportsTableSample + // so a non-sampling connector's TABLESAMPLE no-op does not lose its batch path. + if (tableSample != null && onPluginClassLoader(scanProvider, scanProvider::supportsTableSample)) { + return false; + } + boolean hasSlots = !desc.getSlots().isEmpty(); + // Streaming (file-count) batch flavor (FIX-M3): the connector owns the whole decision (e.g. + // Iceberg's matched-file count vs num_files_in_batch_mode); the engine only requires output slots + // (a scan with no slots needs no file ranges). Checked before the partition-count flavor because a + // connector implements at most one — Iceberg streams files, MaxCompute slices partitions. + if (hasSlots) { + boolean countPushdown = isTableLevelCountStarPushdown(); + long estimate = onPluginClassLoader(scanProvider, () -> scanProvider.streamingSplitEstimate( + connectorSession, currentHandle, buildRemainingFilter(), countPushdown)); + if (estimate >= 0) { + streamingSplitEstimate = estimate; + streamingBatch = true; + return true; + } + } + // Partition-count batch flavor (legacy MaxCompute): its connector odpsTable.getFileNum()>0 check is + // folded into supportsBatchScan; the partition threshold is evaluated generically. + boolean supportsBatchScan = onPluginClassLoader(scanProvider, + () -> scanProvider.supportsBatchScan(connectorSession, currentHandle)); + return shouldUseBatchMode(selectedPartitions, hasSlots, + supportsBatchScan, sessionVariable.getNumPartitionsInBatchMode()); + } + + /** + * Pure batch-mode gate, mirroring legacy {@code MaxComputeScanNode.isBatchMode()} (its connector + * {@code odpsTable.getFileNum() > 0} check is folded into {@code supportsBatchScan}). Extracted + * as a static helper so the four-input decision is unit-testable without constructing a + * {@link FileQueryScanNode} (the async/wiring half is covered by live e2e — see DV-019). + * + *

      + *
    • null or the {@link SelectedPartitions#NOT_PRUNED} sentinel (non-partitioned, or Nereids + * pruning not applicable) → false;
    • + *
    • no required slots → false;
    • + *
    • connector does not support batch scan (incl. no scan provider) → false;
    • + *
    • otherwise batch iff {@code numPartitionsInBatchMode > 0} and the selected partition count + * reaches that threshold.
    • + *
    + * + *

    The gate is {@code == NOT_PRUNED}, deliberately not {@code !isPruned} — mirroring legacy + * {@code MaxComputeScanNode.isBatchMode}'s {@code != NOT_PRUNED} and the sibling + * {@link #displayPartitionCounts}. The two are not equivalent: a partitioned table with no + * partition predicate is initialized by {@link ExternalTable#initSelectedPartitions} to a full, + * non-{@code NOT_PRUNED} map with {@code isPruned=false} ({@code PruneFileScanPartition} only runs + * under a {@code LogicalFilter}). Legacy batches that case — a large full scan is exactly what most + * needs async/streaming split generation — whereas an {@code !isPruned} gate wrongly forces it + * synchronous (the split-materialization regression this restores). The {@code == NOT_PRUNED} sentinel + * check still folds in the non-partitioned gate ({@code getPartitionColumns().isEmpty()}), which + * always carries the {@code NOT_PRUNED} singleton, so no gate is dropped.

    + */ + static boolean shouldUseBatchMode(SelectedPartitions selectedPartitions, boolean hasSlots, + boolean supportsBatchScan, int numPartitionsInBatchMode) { + if (selectedPartitions == null || selectedPartitions == SelectedPartitions.NOT_PRUNED) { + return false; + } + if (!hasSlots) { + return false; + } + if (!supportsBatchScan) { + return false; + } + return numPartitionsInBatchMode > 0 + && selectedPartitions.selectedPartitions.size() >= numPartitionsInBatchMode; + } + + @Override + public int numApproximateSplits() { + if (streamingBatch) { + // Streaming batch (FIX-M3): the connector's matched-file estimate. Legacy IcebergScanNode batch + // returned ~partition count; the file estimate is a strictly better, always-non-negative BE + // concurrency hint. Cap at Integer.MAX_VALUE for pathologically large tables. + return streamingSplitEstimate > Integer.MAX_VALUE + ? Integer.MAX_VALUE : (int) streamingSplitEstimate; + } + // Number of pruned partitions; must be non-negative in batch mode (FileQueryScanNode rejects + // negative). Under the isBatchMode gate this is >= num_partitions_in_batch_mode >= 1. + return selectedPartitions == null ? -1 : selectedPartitions.selectedPartitions.size(); + } + + /** + * Asynchronously generates splits in batches of {@code num_partitions_in_batch_mode} partitions, + * streaming each batch into {@link #splitAssignment}. Mirrors legacy + * {@code MaxComputeScanNode.startSplit}: one read session per partition batch (built by the + * connector via {@link ConnectorScanPlanProvider#planScanForPartitionBatch}) on the shared + * schedule executor, with the same completion/error protocol against {@code SplitAssignment}. + * + *

    Batch mode deliberately does NOT push the limit (passes {@code -1}): legacy's batch path + * ignores limit, and the LIMIT-split optimization stays on the non-batch {@link #getSplits} + * path only (the two are mutually exclusive).

    + */ + @Override + public void startSplit(int numBackends) { + if (streamingBatch) { + // File-count streaming flavor (FIX-M3): pump a connector-driven lazy source instead of + // slicing partitions. Mutually exclusive with the partition-slicing path below. + startStreamingSplit(); + return; + } + try { + checkSysTableScanConstraints(); + } catch (UserException e) { + // startSplit cannot throw checked exceptions; surface the fail-loud guard through the + // SplitAssignment error channel (same protocol the async batch path below uses) so the + // query fails rather than silently ignoring scan-params/time-travel on a sys table. + splitAssignment.setException(e); + return; + } + long[] partitionCounts = displayPartitionCounts(selectedPartitions); + if (partitionCounts != null) { + this.selectedPartitionNum = partitionCounts[0]; + this.totalPartitionNum = partitionCounts[1]; + } + if (selectedPartitions.selectedPartitions.isEmpty()) { + // Unreachable under the isBatchMode gate (size >= num_partitions_in_batch_mode >= 1); + // kept for fidelity with legacy MaxComputeScanNode.startSplit's empty short-circuit. + return; + } + + // Mirror getSplits()'s projection + filter pushdown (but NOT the limit) before going async. + // tryPushDownProjection mutates currentHandle, so capture the resolved handle afterwards. + final List columns = buildColumnHandles(); + tryPushDownProjection(columns); + final Optional remainingFilter = buildRemainingFilter(); + // Pin the MVCC snapshot onto currentHandle before the resolved handle is captured below, so + // the async batch path (planScanForPartitionBatch) reads at the pinned snapshot. startSplit + // cannot throw checked exceptions, so surface a getTargetTable() failure through the + // SplitAssignment error channel (same protocol as checkSysTableScanConstraints above). + try { + pinMvccSnapshot(); + } catch (UserException e) { + splitAssignment.setException(e); + return; + } + // Scope the scan to a distributed rewrite group's files (no-op for every non-rewrite read). + pinRewriteFileScope(); + final ConnectorTableHandle handle = currentHandle; + final ConnectorScanPlanProvider scanProvider = resolveScanProvider(); + final List allPartitions = + new ArrayList<>(selectedPartitions.selectedPartitions.keySet()); + final int batchSize = sessionVariable.getNumPartitionsInBatchMode(); + + Executor scheduleExecutor = Env.getCurrentEnv().getExtMetaCacheMgr().getScheduleExecutor(); + AtomicReference batchException = new AtomicReference<>(null); + AtomicInteger numFinishedPartitions = new AtomicInteger(0); + + CompletableFuture.runAsync(() -> { + for (int begin = 0; begin < allPartitions.size(); begin += batchSize) { + int end = Math.min(begin + batchSize, allPartitions.size()); + if (batchException.get() != null || splitAssignment.isStop()) { + break; + } + List batch = allPartitions.subList(begin, end); + int curBatchSize = end - begin; + try { + CompletableFuture.runAsync(() -> { + try { + List ranges = onPluginClassLoader(scanProvider, + () -> scanProvider.planScanForPartitionBatch( + connectorSession, handle, columns, remainingFilter, -1L, batch)); + List batchSplits = new ArrayList<>(ranges.size()); + for (ConnectorScanRange range : ranges) { + batchSplits.add(new PluginDrivenSplit(range)); + } + if (splitAssignment.needMoreSplit()) { + splitAssignment.addToQueue(batchSplits); + } + } catch (Exception e) { + batchException.set(new UserException(e.getMessage(), e)); + } finally { + if (batchException.get() != null) { + splitAssignment.setException(batchException.get()); + } + if (numFinishedPartitions.addAndGet(curBatchSize) == allPartitions.size()) { + splitAssignment.finishSchedule(); + } + } + }, scheduleExecutor); + } catch (Exception e) { + batchException.set(new UserException(e.getMessage(), e)); + } + if (batchException.get() != null) { + splitAssignment.setException(batchException.get()); + } + } + }, scheduleExecutor); + } + + /** + * Streams splits from a connector-driven lazy {@link ConnectorSplitSource} into + * {@link #splitAssignment} with backpressure, mirroring legacy {@code IcebergScanNode.doStartSplit}. + * Used by the file-count streaming batch flavor (see {@link #computeBatchMode}); the partition-count + * flavor stays on the {@link #startSplit} partition-slicing path. Deliberately does NOT push the limit + * (passes {@code -1}): the LIMIT-split optimization stays on the non-batch {@link #getSplits} path only. + */ + private void startStreamingSplit() { + try { + checkSysTableScanConstraints(); + } catch (UserException e) { + // startSplit cannot throw checked exceptions; surface the fail-loud guard through the + // SplitAssignment error channel so the query fails rather than silently ignoring the guard. + splitAssignment.setException(e); + return; + } + // Mirror getSplits()'s projection + filter pushdown (but NOT the limit) before going async. + // tryPushDownProjection mutates currentHandle, so capture the resolved handle afterwards. + final List columns = buildColumnHandles(); + tryPushDownProjection(columns); + final Optional remainingFilter = buildRemainingFilter(); + // Pin the MVCC snapshot + rewrite scope onto currentHandle before capturing the resolved handle, + // so the lazy source plans at the PINNED snapshot (rows are always correct). The streamingSplitEstimate + // gate ran pre-pin (current snapshot), so it only affects the approximate batch decision + concurrency + // hint, never the rows. Narrow caveat: a time-travel query to a past snapshot much LARGER than current + // (table since compacted/expired) may under-count -> pick the eager path -> lose streaming's OOM + // protection for that scan. Acceptable (perf-only, rare); documented in the FIX-M3 design. + try { + pinMvccSnapshot(); + } catch (UserException e) { + splitAssignment.setException(e); + return; + } + pinRewriteFileScope(); + final ConnectorTableHandle handle = currentHandle; + final ConnectorScanPlanProvider scanProvider = resolveScanProvider(); + Executor scheduleExecutor = Env.getCurrentEnv().getExtMetaCacheMgr().getScheduleExecutor(); + CompletableFuture.runAsync(() -> { + ConnectorSplitSource source = null; + try { + source = onPluginClassLoader(scanProvider, + () -> scanProvider.streamSplits(connectorSession, handle, columns, remainingFilter, -1L)); + // Pull ranges with backpressure (needMoreSplit) and pump them one at a time, exactly like + // legacy doStartSplit. The bounded SplitAssignment queue throttles the lazy source so FE + // heap stays bounded for million-file scans. + while (splitAssignment.needMoreSplit() && source.hasNext()) { + List one = new ArrayList<>(1); + one.add(new PluginDrivenSplit(source.next())); + splitAssignment.addToQueue(one); + } + splitAssignment.finishSchedule(); + } catch (Exception e) { + splitAssignment.setException(new UserException(e.getMessage(), e)); + } finally { + // Close in a finally that SWALLOWS close errors (NOT try-with-resources, whose close runs + // before the catch): a close() failure must not fail a scan whose splits were already + // enumerated + finishSchedule()-d (legacy doStartSplit swallowed close errors identically). + if (source != null) { + try { + source.close(); + } catch (Exception ce) { + LOG.warn("Failed to close streaming split source for {}", handle, ce); + } + } + } + }, scheduleExecutor); + } + + @Override + protected void setScanParams(TFileRangeDesc rangeDesc, Split split) { + if (!(split instanceof PluginDrivenSplit)) { + return; + } + PluginDrivenSplit pluginSplit = (PluginDrivenSplit) split; + ConnectorScanRange scanRange = pluginSplit.getConnectorScanRange(); + + TTableFormatFileDesc tableFormatFileDesc = new TTableFormatFileDesc(); + tableFormatFileDesc.setTableFormatType(scanRange.getTableFormatType()); + + // Delegate format-specific Thrift construction to the connector SPI + scanRange.populateRangeParams(tableFormatFileDesc, rangeDesc); + + rangeDesc.setTableFormatParams(tableFormatFileDesc); + } + + + @Override + protected Optional getSerializedTable() { + 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(); + } + + @Override + public void createScanRangeLocations() throws UserException { + super.createScanRangeLocations(); + // Delegate scan-level Thrift params to the connector SPI + ConnectorScanPlanProvider scanProvider = resolveScanProvider(); + if (scanProvider != null) { + Map props = getOrLoadScanNodeProperties(); + onPluginClassLoader(scanProvider, () -> { + scanProvider.populateScanLevelParams(params, props); + 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)); + } + } + + + /** + * 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.

    + */ + private void pruneConjunctsFromNodeProperties() { + if (conjuncts == null || conjuncts.isEmpty()) { + return; + } + ScanNodePropertiesResult result = getOrLoadPropertiesResult(); + + if (!result.hasConjunctTracking()) { + // No conjunct tracking — do not prune (keep all conjuncts for safety) + return; + } + + // notPushedSet indices are relative to the filtered conjunct list + // (after CAST expr removal). Map them back to original conjunct indices. + Set notPushedSet = result.getNotPushedConjunctIndices(); + Set originalNotPushed = new HashSet<>(); + if (filteredToOriginalIndex != null) { + for (int filteredIdx : notPushedSet) { + if (filteredIdx < filteredToOriginalIndex.size()) { + originalNotPushed.add(filteredToOriginalIndex.get(filteredIdx)); + } + } + } else { + // No CAST filtering was applied — indices map 1:1 + originalNotPushed.addAll(notPushedSet); + } + + // Also keep any conjuncts that were filtered out (CAST expressions) + // since those were never sent to the connector for pushdown + if (filteredToOriginalIndex != null) { + Set sentToConnector = new HashSet<>(filteredToOriginalIndex); + for (int i = 0; i < conjuncts.size(); i++) { + if (!sentToConnector.contains(i)) { + originalNotPushed.add(i); + } + } + } + + List remaining = new ArrayList<>(); + for (int i = 0; i < conjuncts.size(); i++) { + if (originalNotPushed.contains(i)) { + remaining.add(conjuncts.get(i)); + } + } + conjuncts.clear(); + conjuncts.addAll(remaining); + } + + /** + * Lazily loads and caches the ScanNodePropertiesResult from the connector. + * Both getOrLoadScanNodeProperties() and pruneConjunctsFromNodeProperties() + * use this to avoid redundant computation. + */ + private ScanNodePropertiesResult getOrLoadPropertiesResult() { + if (cachedPropertiesResult == null) { + ConnectorScanPlanProvider scanProvider = resolveScanProvider(); + if (scanProvider != null) { + List columns = buildColumnHandles(); + Optional filter = buildRemainingFilter(); + // Pin the MVCC snapshot onto currentHandle before getScanNodePropertiesResult + // consumes it: this single cached result feeds the serialized-table (JNI) path, + // scan-level params, explain, and file attributes, so the pin must land here or the + // serialized-table path silently reads LATEST while the split path reads the pin. + // This method is private and called from contexts that do not declare UserException + // (e.g. getNodeExplainString), so a getTargetTable() failure is surfaced by wrapping + // it in a RuntimeException (same unchecked error channel as create() above) rather + // than dropped. + try { + pinMvccSnapshot(); + } catch (UserException e) { + throw new RuntimeException("Failed to pin MVCC snapshot for plugin-driven scan", e); + } + // Scope the scan to a distributed rewrite group's files (no-op for every non-rewrite read). + pinRewriteFileScope(); + // Signal Top-N lazy materialization so a connector with a column-pruned scan dictionary + // rebuilds it over the full schema (no-op for every non-lazy-mat read / every connector + // without a pruned dictionary). + pinTopnLazyMaterialize(); + cachedPropertiesResult = onPluginClassLoader(scanProvider, + () -> scanProvider.getScanNodePropertiesResult( + connectorSession, currentHandle, columns, filter)); + } + if (cachedPropertiesResult == null) { + cachedPropertiesResult = new ScanNodePropertiesResult(Collections.emptyMap()); + } + } + return cachedPropertiesResult; + } + + /** + * Lazily loads scan node properties from the connector's scan plan provider. + */ + private Map getOrLoadScanNodeProperties() { + if (scanNodeProperties == null) { + scanNodeProperties = getOrLoadPropertiesResult().getProperties(); + if (scanNodeProperties == null) { + scanNodeProperties = Collections.emptyMap(); + } + } + return scanNodeProperties; + } + + /** + * Maps a file format name string to the corresponding TFileFormatType. + */ + private static TFileFormatType mapFileFormatType(String format) { + switch (format.toLowerCase()) { + case "parquet": + return TFileFormatType.FORMAT_PARQUET; + case "orc": + return TFileFormatType.FORMAT_ORC; + case "text": + // Hive text serde family (LazySimpleSerDe / MultiDelimitSerDe): the BE text reader honors hive + // collection/map delimiters, \N nulls and hive escaping — distinct from the flat CSV reader. + return TFileFormatType.FORMAT_TEXT; + case "csv": + return TFileFormatType.FORMAT_CSV_PLAIN; + case "json": + return TFileFormatType.FORMAT_JSON; + case "avro": + return TFileFormatType.FORMAT_AVRO; + case "es_http": + return TFileFormatType.FORMAT_ES_HTTP; + default: + return TFileFormatType.FORMAT_JNI; + } + } + + /** + * Builds column handles from the tuple descriptor's slot descriptors. + * These tell the connector which columns are needed for the query, + * enabling optimized column selection (e.g., SELECT col1, col2 instead of SELECT *). + */ + private List buildColumnHandles() { + ConnectorMetadata metadata = metadata(); + Map allHandles = + metadata.getColumnHandles(connectorSession, currentHandle); + if (allHandles.isEmpty()) { + return Collections.emptyList(); + } + List selected = new ArrayList<>(); + for (org.apache.doris.analysis.SlotDescriptor slot : desc.getSlots()) { + if (slot.getColumn() != null) { + ConnectorColumnHandle ch = allHandles.get(slot.getColumn().getName()); + if (ch != null) { + selected.add(ch); + } + } + } + return selected; + } + + /** + * Builds a {@link ConnectorFilterConstraint} from the current conjuncts. + */ + private ConnectorFilterConstraint buildFilterConstraint(List exprs) { + ConnectorExpression combined = ExprToConnectorExpressionConverter.convertConjuncts(exprs); + return new ConnectorFilterConstraint(combined, Collections.emptyMap()); + } + + /** + * Builds the remaining filter expression from unconsumed conjuncts. + * If no conjuncts remain, returns {@link Optional#empty()}. + * Filters out CAST-containing predicates when the connector does not support CAST pushdown. + */ + private Optional buildRemainingFilter() { + if (conjuncts == null || conjuncts.isEmpty()) { + filteredToOriginalIndex = null; + return Optional.empty(); + } + List pushableConjuncts = conjuncts; + ConnectorMetadata metadata = metadata(); + if (!metadata.supportsCastPredicatePushdown(connectorSession)) { + filteredToOriginalIndex = new ArrayList<>(); + pushableConjuncts = new ArrayList<>(); + for (int i = 0; i < conjuncts.size(); i++) { + if (!containsCastExpr(conjuncts.get(i))) { + pushableConjuncts.add(conjuncts.get(i)); + filteredToOriginalIndex.add(i); + } + } + // If no filtering occurred, clear the mapping (1:1) + if (filteredToOriginalIndex.size() == conjuncts.size()) { + filteredToOriginalIndex = null; + } + } else { + filteredToOriginalIndex = null; + } + if (pushableConjuncts.isEmpty()) { + return Optional.empty(); + } + return Optional.of(ExprToConnectorExpressionConverter.convertConjuncts(pushableConjuncts)); + } + + private static boolean containsCastExpr(Expr expr) { + List castExprs = new ArrayList<>(); + expr.collect(CastExpr.class, castExprs); + return !castExprs.isEmpty(); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/TableFormatType.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/TableFormatType.java similarity index 96% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/TableFormatType.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/scan/TableFormatType.java index 10d4fd25bcbc8b..f9bc9c364fc64a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/TableFormatType.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/TableFormatType.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.scan; public enum TableFormatType { HIVE("hive"), diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileSplit.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/FileSplit.java similarity index 89% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/FileSplit.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/split/FileSplit.java index fcb6f9562785ac..5884ccc34dc9f5 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileSplit.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/FileSplit.java @@ -15,9 +15,10 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.split; import org.apache.doris.common.util.LocationPath; +import org.apache.doris.datasource.scan.TableFormatType; import org.apache.doris.spi.Split; import org.apache.doris.thrift.TFileType; @@ -113,6 +114,9 @@ public SplitWeight getSplitWeight() { } public long getSelfSplitWeight() { - return selfSplitWeight; + // selfSplitWeight is null when unset; mirror the ConnectorScanRange SPI "-1 = not provided" + // sentinel. Lombok's @Data generates equals()/hashCode() that invoke this getter, so an + // unboxing null here would NPE for any FileSplit that never set a size-based weight. + return selfSplitWeight == null ? -1 : selfSplitWeight; } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileSplitter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/FileSplitter.java similarity index 99% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/FileSplitter.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/split/FileSplitter.java index b4e417b4f9184f..9ea50b95d5d173 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileSplitter.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/FileSplitter.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.split; import org.apache.doris.common.util.LocationPath; import org.apache.doris.common.util.Util; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/split/PluginDrivenSplit.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/PluginDrivenSplit.java new file mode 100644 index 00000000000000..8fa38264ab389a --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/PluginDrivenSplit.java @@ -0,0 +1,95 @@ +// 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.split; + +import org.apache.doris.common.util.LocationPath; +import org.apache.doris.connector.api.scan.ConnectorScanRange; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * A {@link FileSplit} that wraps a {@link ConnectorScanRange} from the SPI layer. + * + *

    Maps the connector scan range's data to the FileSplit interface so it can + * flow through the existing {@code FileQueryScanNode} pipeline. The original + * {@link ConnectorScanRange} is preserved and accessible for format-specific + * parameter extraction in {@code PluginDrivenScanNode.setScanParams()}.

    + */ +public class PluginDrivenSplit extends FileSplit { + + private final ConnectorScanRange connectorScanRange; + + public PluginDrivenSplit(ConnectorScanRange scanRange) { + super(buildPath(scanRange), + scanRange.getStart(), + scanRange.getLength(), + scanRange.getFileSize(), + scanRange.getModificationTime(), + scanRange.getHosts().toArray(new String[0]), + buildPartitionValues(scanRange)); + this.connectorScanRange = scanRange; + // FIX-A1: thread the connector's proportional split weight into the FileSplit scheduling fields so + // FederationBackendPolicy distributes by size (legacy parity) instead of uniform standard() weight. + // Set ONLY when the connector provides BOTH a weight (>= 0; 0 is a real weight) and a positive + // denominator (guards FileSplit.getSplitWeight's division). Connectors that supply neither keep the + // -1 SPI default -> both fields stay null -> getSplitWeight() == SplitWeight.standard() (no change). + long weight = scanRange.getSelfSplitWeight(); + long targetSize = scanRange.getTargetSplitSize(); + if (weight >= 0 && targetSize > 0) { + this.selfSplitWeight = weight; + this.targetSplitSize = targetSize; + } + } + + /** Returns the underlying connector scan range for format-specific param extraction. */ + public ConnectorScanRange getConnectorScanRange() { + return connectorScanRange; + } + + @Override + public Object getInfo() { + return null; + } + + @Override + public String getPathString() { + return connectorScanRange.getPath().orElse("connector://virtual"); + } + + private static LocationPath buildPath(ConnectorScanRange scanRange) { + String pathStr = scanRange.getPath().orElse("connector://virtual"); + return LocationPath.of(pathStr); + } + + private static List buildPartitionValues(ConnectorScanRange scanRange) { + Map partValues = scanRange.getPartitionValues(); + if (partValues == null) { + return null; + } + if (partValues.isEmpty()) { + // A partition-bearing range (metadata-sourced partitions, e.g. Iceberg) with no values must NOT + // collapse to null: FileQueryScanNode reads null as "parse partition values from the file path", + // which throws for non-Hive-laid-out files. Return a non-null empty list so it uses the (empty) + // values verbatim. Non-partition-bearing ranges keep the legacy empty->null collapse (no regression). + return scanRange.isPartitionBearing() ? new ArrayList<>() : null; + } + return new ArrayList<>(partValues.values()); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitAssignment.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitAssignment.java similarity index 98% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/SplitAssignment.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitAssignment.java index 5f79a006a7af11..61d7ab9ec0d56f 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitAssignment.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitAssignment.java @@ -15,9 +15,10 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.split; import org.apache.doris.common.UserException; +import org.apache.doris.datasource.scan.FederationBackendPolicy; import org.apache.doris.spi.Split; import org.apache.doris.system.Backend; import org.apache.doris.thrift.TScanRangeLocations; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitCreator.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitCreator.java similarity index 96% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/SplitCreator.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitCreator.java index 6df84d2f0f5ee9..77791a41bf6479 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitCreator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitCreator.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.split; import org.apache.doris.common.util.LocationPath; import org.apache.doris.spi.Split; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitGenerator.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitGenerator.java similarity index 98% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/SplitGenerator.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitGenerator.java index 391552a5106a83..66b0ae06c19027 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitGenerator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitGenerator.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.split; import org.apache.doris.common.NotImplementedException; import org.apache.doris.common.UserException; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitSource.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitSource.java similarity index 99% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/SplitSource.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitSource.java index e24af768781d71..0163223b2432f5 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitSource.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitSource.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.split; import org.apache.doris.common.UserException; import org.apache.doris.system.Backend; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitSourceManager.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitSourceManager.java similarity index 98% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/SplitSourceManager.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitSourceManager.java index 6d4b06e0e7bd27..e0637a1300dfb6 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitSourceManager.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitSourceManager.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.split; import org.apache.doris.common.util.MasterDaemon; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitToScanRange.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitToScanRange.java similarity index 96% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/SplitToScanRange.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitToScanRange.java index bea93e99adc1a8..c098565d3f509e 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitToScanRange.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitToScanRange.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.split; import org.apache.doris.common.UserException; import org.apache.doris.spi.Split; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitWeight.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitWeight.java similarity index 99% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/SplitWeight.java rename to fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitWeight.java index f35f88e3cb99bd..cedc1086df7c19 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitWeight.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitWeight.java @@ -18,7 +18,7 @@ // https://github.com/trinodb/trino/blob/master/core/trino-spi/src/main/java/io/trino/spi/SplitWeight.java // and modified by Doris -package org.apache.doris.datasource; +package org.apache.doris.datasource.split; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/statistics/CommonStatistics.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/statistics/CommonStatistics.java deleted file mode 100644 index 0285ad09db4c46..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/statistics/CommonStatistics.java +++ /dev/null @@ -1,85 +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.datasource.statistics; - -/** - * This class provides operations related to file statistics, including object and field granularity add, min, max - * and other merge operations - */ -public class CommonStatistics { - - public static final CommonStatistics EMPTY = new CommonStatistics(0L, 0L, 0L); - - private final long rowCount; - private final long fileCount; - private final long totalFileBytes; - - public CommonStatistics(long rowCount, long fileCount, long totalFileBytes) { - this.fileCount = fileCount; - this.rowCount = rowCount; - this.totalFileBytes = totalFileBytes; - } - - public long getRowCount() { - return rowCount; - } - - public long getFileCount() { - return fileCount; - } - - public long getTotalFileBytes() { - return totalFileBytes; - } - - public static CommonStatistics reduce( - CommonStatistics current, - CommonStatistics update, - ReduceOperator operator) { - return new CommonStatistics( - reduce(current.getRowCount(), update.getRowCount(), operator), - reduce(current.getFileCount(), update.getFileCount(), operator), - reduce(current.getTotalFileBytes(), update.getTotalFileBytes(), operator)); - } - - public static long reduce(long current, long update, ReduceOperator operator) { - if (current >= 0 && update >= 0) { - switch (operator) { - case ADD: - return current + update; - case SUBTRACT: - return current - update; - case MAX: - return Math.max(current, update); - case MIN: - return Math.min(current, update); - default: - throw new IllegalArgumentException("Unexpected operator: " + operator); - } - } - - return 0; - } - - public enum ReduceOperator { - ADD, - SUBTRACT, - MIN, - MAX, - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/systable/IcebergSysTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/systable/IcebergSysTable.java deleted file mode 100644 index 5115c2f9ec6f95..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/systable/IcebergSysTable.java +++ /dev/null @@ -1,71 +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.datasource.systable; - -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergSysExternalTable; - -import org.apache.iceberg.MetadataTableType; - -import java.util.Arrays; -import java.util.Collections; -import java.util.Locale; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; - -/** - * System table type for Iceberg metadata tables. - * - *

    Iceberg system tables provide access to table metadata such as - * snapshots, history, manifests, files, partitions, etc. - * - * @see org.apache.iceberg.MetadataTableType for all supported system table types - */ -public class IcebergSysTable extends NativeSysTable { - /** - * All supported Iceberg system tables. - * Key is the system table name (e.g., "snapshots", "history"). - */ - public static final Map SUPPORTED_SYS_TABLES = Collections.unmodifiableMap( - Arrays.stream(MetadataTableType.values()) - .map(type -> new IcebergSysTable(type.name().toLowerCase(Locale.ROOT))) - .collect(Collectors.toMap(SysTable::getSysTableName, Function.identity()))); - - private final String tableName; - - private IcebergSysTable(String tableName) { - super(tableName); - this.tableName = tableName; - } - - @Override - public String getSysTableName() { - return tableName; - } - - @Override - public ExternalTable createSysExternalTable(ExternalTable sourceTable) { - if (!(sourceTable instanceof IcebergExternalTable)) { - throw new IllegalArgumentException( - "Expected IcebergExternalTable but got " + sourceTable.getClass().getSimpleName()); - } - return new IcebergSysExternalTable((IcebergExternalTable) sourceTable, getSysTableName()); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/systable/NativeSysTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/systable/NativeSysTable.java index 010a73d0319b9f..426438d7b88793 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/systable/NativeSysTable.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/systable/NativeSysTable.java @@ -32,8 +32,6 @@ * *

    Subclasses must implement {@link #createSysExternalTable(ExternalTable)} to create * the appropriate system external table instance (e.g., PaimonSysExternalTable). - * - * @see PaimonSysTable */ public abstract class NativeSysTable extends SysTable { diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/systable/PaimonSysTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/systable/PaimonSysTable.java deleted file mode 100644 index 7873cc0b95ec6a..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/systable/PaimonSysTable.java +++ /dev/null @@ -1,68 +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.datasource.systable; - -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.paimon.PaimonExternalTable; -import org.apache.doris.datasource.paimon.PaimonSysExternalTable; - -import org.apache.paimon.table.system.SystemTableLoader; - -import java.util.Collections; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; - -/** - * System table type for Paimon system tables. - * - *

    Paimon system tables are classified into two categories: - *

      - *
    • Data tables (e.g., binlog, audit_log, ro): Read actual ORC/Parquet data files, - * benefit from native vectorized readers
    • - *
    • Metadata tables (snapshots, partitions, etc.): Read metadata/manifest files, - * use JNI readers
    • - *
    - * - *

    All Paimon system tables use the native table execution path (FileQueryScanNode) - * instead of the TVF path (MetadataScanNode). - */ -public class PaimonSysTable extends NativeSysTable { - - /** - * All supported Paimon system tables (both data and metadata). - * Key is the system table name (e.g., "snapshots", "binlog"). - */ - public static final Map SUPPORTED_SYS_TABLES = Collections.unmodifiableMap( - SystemTableLoader.SYSTEM_TABLES.stream() - .map(PaimonSysTable::new) - .collect(Collectors.toMap(SysTable::getSysTableName, Function.identity()))); - - private PaimonSysTable(String tableName) { - super(tableName); - } - - @Override - public ExternalTable createSysExternalTable(ExternalTable sourceTable) { - if (!(sourceTable instanceof PaimonExternalTable)) { - throw new IllegalArgumentException( - "Expected PaimonExternalTable but got " + sourceTable.getClass().getSimpleName()); - } - return new PaimonSysExternalTable((PaimonExternalTable) sourceTable, getSysTableName()); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/systable/PluginDrivenSysTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/systable/PluginDrivenSysTable.java new file mode 100644 index 00000000000000..472e3476ee07c9 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/systable/PluginDrivenSysTable.java @@ -0,0 +1,46 @@ +// 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.systable; + +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenSysExternalTable; + +/** + * Generic {@link NativeSysTable} for plugin-driven connectors. + * + *

    Unlike {@code PaimonSysTable} (which enumerates a fixed connector-specific set), instances of this + * class are created on demand by {@link PluginDrivenExternalTable#getSupportedSysTables()} from the + * names the connector SPI reports. {@link #createSysExternalTable(ExternalTable)} builds the transient + * {@link PluginDrivenSysExternalTable} that the planner executes through the native table path.

    + */ +public class PluginDrivenSysTable extends NativeSysTable { + + public PluginDrivenSysTable(String sysName) { + super(sysName); + } + + @Override + public ExternalTable createSysExternalTable(ExternalTable sourceTable) { + if (!(sourceTable instanceof PluginDrivenExternalTable)) { + throw new IllegalArgumentException( + "Expected PluginDrivenExternalTable but got " + sourceTable.getClass().getSimpleName()); + } + return new PluginDrivenSysExternalTable((PluginDrivenExternalTable) sourceTable, getSysTableName()); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/test/TestExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/test/TestExternalCatalog.java index 63e305141c6b88..be459e3b6a0f3c 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/test/TestExternalCatalog.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/test/TestExternalCatalog.java @@ -20,8 +20,8 @@ import org.apache.doris.catalog.Column; import org.apache.doris.datasource.CatalogProperty; import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.InitCatalogLog; import org.apache.doris.datasource.SessionContext; +import org.apache.doris.datasource.log.InitCatalogLog; import com.google.common.collect.Lists; import org.apache.logging.log4j.LogManager; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/test/TestExternalDatabase.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/test/TestExternalDatabase.java index 0c11dc8f9400a3..d30f353c999477 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/test/TestExternalDatabase.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/test/TestExternalDatabase.java @@ -19,7 +19,7 @@ import org.apache.doris.datasource.ExternalCatalog; import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.InitDatabaseLog; +import org.apache.doris.datasource.log.InitDatabaseLog; public class TestExternalDatabase extends ExternalDatabase { diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorExternalCatalog.java deleted file mode 100644 index e2db35707ddb7a..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorExternalCatalog.java +++ /dev/null @@ -1,329 +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.datasource.trinoconnector; - -import org.apache.doris.common.DdlException; -import org.apache.doris.datasource.CatalogProperty; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.InitCatalogLog.Type; -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.trinoconnector.TrinoConnectorServicesProvider; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ImmutableSet; -import com.google.common.util.concurrent.MoreExecutors; -import io.airlift.node.NodeInfo; -import io.opentelemetry.api.OpenTelemetry; -import io.trino.Session; -import io.trino.SystemSessionProperties; -import io.trino.SystemSessionPropertiesProvider; -import io.trino.client.ClientCapabilities; -import io.trino.connector.CatalogServiceProviderModule; -import io.trino.connector.ConnectorName; -import io.trino.connector.ConnectorServicesProvider; -import io.trino.connector.DefaultCatalogFactory; -import io.trino.connector.LazyCatalogFactory; -import io.trino.eventlistener.EventListenerConfig; -import io.trino.eventlistener.EventListenerManager; -import io.trino.execution.DynamicFilterConfig; -import io.trino.execution.QueryIdGenerator; -import io.trino.execution.QueryManagerConfig; -import io.trino.execution.TaskManagerConfig; -import io.trino.execution.scheduler.NodeSchedulerConfig; -import io.trino.memory.MemoryManagerConfig; -import io.trino.memory.NodeMemoryConfig; -import io.trino.metadata.InMemoryNodeManager; -import io.trino.metadata.MetadataManager; -import io.trino.metadata.QualifiedObjectName; -import io.trino.metadata.QualifiedTablePrefix; -import io.trino.metadata.SessionPropertyManager; -import io.trino.operator.GroupByHashPageIndexerFactory; -import io.trino.operator.PagesIndex; -import io.trino.operator.PagesIndexPageSorter; -import io.trino.plugin.base.security.AllowAllSystemAccessControl; -import io.trino.spi.classloader.ThreadContextClassLoader; -import io.trino.spi.connector.CatalogHandle; -import io.trino.spi.connector.CatalogHandle.CatalogVersion; -import io.trino.spi.connector.Connector; -import io.trino.spi.connector.ConnectorFactory; -import io.trino.spi.connector.ConnectorMetadata; -import io.trino.spi.connector.ConnectorSession; -import io.trino.spi.connector.ConnectorTableHandle; -import io.trino.spi.connector.ConnectorTransactionHandle; -import io.trino.spi.connector.SchemaTableName; -import io.trino.spi.security.Identity; -import io.trino.spi.transaction.IsolationLevel; -import io.trino.spi.type.TimeZoneKey; -import io.trino.sql.gen.JoinCompiler; -import io.trino.sql.planner.OptimizerConfig; -import io.trino.testing.TestingAccessControlManager; -import io.trino.transaction.NoOpTransactionManager; -import io.trino.type.InternalTypeManager; -import io.trino.util.EmbedVersion; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.time.ZoneId; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -public class TrinoConnectorExternalCatalog extends ExternalCatalog { - private static final Logger LOG = LogManager.getLogger(TrinoConnectorExternalCatalog.class); - private static final String TRINO_CONNECTOR_PROPERTIES_PREFIX = "trino."; - public static final String TRINO_CONNECTOR_NAME = "trino.connector.name"; - - private static final List TRINO_CONNECTOR_REQUIRED_PROPERTIES = ImmutableList.of( - TRINO_CONNECTOR_NAME - ); - - private CatalogHandle trinoCatalogHandle; - private Connector connector; - private ConnectorName connectorName; - private Session trinoSession; - private ImmutableMap trinoProperties; - - public TrinoConnectorExternalCatalog(long catalogId, String name, String resource, - Map props, String comment) { - super(catalogId, name, Type.TRINO_CONNECTOR, comment); - Objects.requireNonNull(name, "catalogName is null"); - catalogProperty = new CatalogProperty(resource, props); - } - - @Override - public void onClose() { - super.onClose(); - if (connector != null) { - try (ThreadContextClassLoader ignored = new ThreadContextClassLoader( - connector.getClass().getClassLoader())) { - connector.shutdown(); - } - } - } - - @Override - protected void initLocalObjectsImpl() { - this.trinoCatalogHandle = CatalogHandle.createRootCatalogHandle(name, new CatalogVersion("test")); - // All properties obtained by this method are used by the trino-connector. - // We should not modify this map - trinoProperties = ImmutableMap.copyOf(catalogProperty.getProperties().entrySet().stream() - .filter(kv -> kv.getKey().startsWith(TRINO_CONNECTOR_PROPERTIES_PREFIX)) - .collect(Collectors - .toMap(kv1 -> kv1.getKey().substring(TRINO_CONNECTOR_PROPERTIES_PREFIX.length()), - kv1 -> kv1.getValue()))); - - ConnectorServicesProvider connectorServicesProvider = createConnectorServicesProvider(); - - this.connector = connectorServicesProvider.getConnectorServices(trinoCatalogHandle).getConnector(); - SessionPropertyManager sessionPropertyManager = createTrinoSessionPropertyManager(connectorServicesProvider); - - QueryIdGenerator queryIdGenerator = new QueryIdGenerator(); - this.trinoSession = Session.builder(sessionPropertyManager) - .setQueryId(queryIdGenerator.createNextQueryId()) - .setIdentity(Identity.ofUser("user")) - .setOriginalIdentity(Identity.ofUser("user")) - .setSource("test") - .setCatalog("catalog") - .setSchema("schema") - .setTimeZoneKey(TimeZoneKey.getTimeZoneKey(ZoneId.systemDefault().toString())) - .setLocale(Locale.ENGLISH) - .setClientCapabilities(Arrays.stream(ClientCapabilities.values()).map(Enum::name) - .collect(ImmutableSet.toImmutableSet())) - .setRemoteUserAddress("address") - .setUserAgent("agent") - .build(); - } - - @Override - public void checkProperties() throws DdlException { - super.checkProperties(); - for (String requiredProperty : TRINO_CONNECTOR_REQUIRED_PROPERTIES) { - if (!catalogProperty.getProperties().containsKey(requiredProperty)) { - throw new DdlException("Required property '" + requiredProperty + "' is missing"); - } - } - } - - @Override - protected List listDatabaseNames() { - ConnectorSession connectorSession = trinoSession.toConnectorSession(trinoCatalogHandle); - ConnectorTransactionHandle connectorTransactionHandle = this.connector.beginTransaction( - IsolationLevel.READ_UNCOMMITTED, true, true); - ConnectorMetadata connectorMetadata = this.connector.getMetadata(connectorSession, connectorTransactionHandle); - return connectorMetadata.listSchemaNames(connectorSession); - } - - @Override - public boolean tableExist(SessionContext ctx, String dbName, String tblName) { - makeSureInitialized(); - return getTrinoConnectorTable(dbName, tblName).isPresent(); - } - - @Override - protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { - QualifiedTablePrefix qualifiedTablePrefix = new QualifiedTablePrefix(trinoCatalogHandle.getCatalogName(), - dbName); - List tables = trinoListTables(qualifiedTablePrefix); - return tables.stream().map(field -> field.getObjectName()).collect(Collectors.toList()); - } - - private ConnectorServicesProvider createConnectorServicesProvider() { - // 1. check and create ConnectorName - if (!trinoProperties.containsKey("connector.name")) { - throw new RuntimeException("Can not find trino.connector.name property, please specify a connector name."); - } - Map trinoConnectorProperties = new HashMap<>(); - trinoConnectorProperties.putAll(trinoProperties); - String connectorNameString = trinoConnectorProperties.remove("connector.name"); - Objects.requireNonNull(connectorNameString, "connectorName is null"); - if (connectorNameString.indexOf('-') >= 0) { - String deprecatedConnectorName = connectorNameString; - connectorNameString = connectorNameString.replace('-', '_'); - LOG.warn("You are using the deprecated connector name '{}'. The correct connector name is '{}'", - deprecatedConnectorName, connectorNameString); - } - - this.connectorName = new ConnectorName(connectorNameString); - - // 2. create CatalogFactory - LazyCatalogFactory catalogFactory = new LazyCatalogFactory(); - NoOpTransactionManager noOpTransactionManager = new NoOpTransactionManager(); - TestingAccessControlManager accessControl = new TestingAccessControlManager(noOpTransactionManager, - new EventListenerManager(new EventListenerConfig())); - accessControl.loadSystemAccessControl(AllowAllSystemAccessControl.NAME, ImmutableMap.of()); - catalogFactory.setCatalogFactory(new DefaultCatalogFactory( - MetadataManager.createTestMetadataManager(), - accessControl, - new InMemoryNodeManager(), - new PagesIndexPageSorter(new PagesIndex.TestingFactory(false)), - new GroupByHashPageIndexerFactory(new JoinCompiler(TrinoConnectorPluginLoader.getTypeOperators())), - new NodeInfo("test"), - EmbedVersion.testingVersionEmbedder(), - OpenTelemetry.noop(), - noOpTransactionManager, - new InternalTypeManager(TrinoConnectorPluginLoader.getTypeRegistry()), - new NodeSchedulerConfig().setIncludeCoordinator(true), - new OptimizerConfig())); - - Optional connectorFactory = Optional.ofNullable( - TrinoConnectorPluginLoader.getTrinoConnectorPluginManager().getConnectorFactories().get(connectorName)); - if (!connectorFactory.isPresent()) { - throw new RuntimeException("Can not find connectorFactory, did you forget to install plugins?"); - } - catalogFactory.addConnectorFactory(connectorFactory.get()); - - // 3. create TrinoConnectorServicesProvider - TrinoConnectorServicesProvider trinoConnectorServicesProvider = new TrinoConnectorServicesProvider( - trinoCatalogHandle.getCatalogName(), connectorNameString, catalogFactory, - trinoConnectorProperties, MoreExecutors.directExecutor()); - trinoConnectorServicesProvider.loadInitialCatalogs(); - return trinoConnectorServicesProvider; - } - - private SessionPropertyManager createTrinoSessionPropertyManager( - ConnectorServicesProvider trinoConnectorServicesProvider) { - Set extraSessionProperties = ImmutableSet.of(); - Set systemSessionProperties = - ImmutableSet.builder() - .addAll(Objects.requireNonNull(extraSessionProperties, "extraSessionProperties is null")) - .add(new SystemSessionProperties( - new QueryManagerConfig(), - new TaskManagerConfig(), - new MemoryManagerConfig(), - TrinoConnectorPluginLoader.getFeaturesConfig(), - new OptimizerConfig(), - new NodeMemoryConfig(), - new DynamicFilterConfig(), - new NodeSchedulerConfig())) - .build(); - - return CatalogServiceProviderModule.createSessionPropertyManager(systemSessionProperties, - trinoConnectorServicesProvider); - } - - private List trinoListTables(QualifiedTablePrefix prefix) { - Objects.requireNonNull(prefix, "prefix can not be null"); - - Set tables = new LinkedHashSet(); - ConnectorSession connectorSession = trinoSession.toConnectorSession(trinoCatalogHandle); - ConnectorTransactionHandle connectorTransactionHandle = this.connector.beginTransaction( - IsolationLevel.READ_UNCOMMITTED, true, true); - ConnectorMetadata connectorMetadata = this.connector.getMetadata(connectorSession, connectorTransactionHandle); - List schemaTableNames = connectorMetadata.listTables(connectorSession, prefix.getSchemaName()); - List tmpTables = new ArrayList<>(); - for (SchemaTableName schemaTableName : schemaTableNames) { - QualifiedObjectName objName = QualifiedObjectName.convertFromSchemaTableName(prefix.getCatalogName()) - .apply(schemaTableName); - tmpTables.add(objName); - } - Objects.requireNonNull(tables); - tmpTables.stream().filter(prefix::matches).forEach(tables::add); - return ImmutableList.copyOf(tables); - } - - public Optional getTrinoConnectorTable(String dbName, String tblName) { - makeSureInitialized(); - QualifiedObjectName tableName = new QualifiedObjectName(trinoCatalogHandle.getCatalogName(), dbName, tblName); - - if (!tableName.getCatalogName().isEmpty() - && !tableName.getSchemaName().isEmpty() - && !tableName.getObjectName().isEmpty()) { - ConnectorSession connectorSession = trinoSession.toConnectorSession(trinoCatalogHandle); - ConnectorTransactionHandle connectorTransactionHandle = this.connector.beginTransaction( - IsolationLevel.READ_UNCOMMITTED, true, true); - return Optional.ofNullable( - this.connector.getMetadata(connectorSession, connectorTransactionHandle) - .getTableHandle(connectorSession, tableName.asSchemaTableName(), - Optional.empty(), Optional.empty())); - } - return Optional.empty(); - } - - // BE need create_time key - public Map getTrinoConnectorPropertiesWithCreateTime() { - Map trinoPropertiesWithCreateTime = new HashMap<>(); - trinoPropertiesWithCreateTime.putAll(trinoProperties); - trinoPropertiesWithCreateTime.put("create_time", catalogProperty.getProperties().get("create_time")); - return trinoPropertiesWithCreateTime; - } - - public Connector getConnector() { - return connector; - } - - public ConnectorName getConnectorName() { - return connectorName; - } - - public CatalogHandle getTrinoCatalogHandle() { - return trinoCatalogHandle; - } - - public Session getTrinoSession() { - return trinoSession; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorExternalCatalogFactory.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorExternalCatalogFactory.java deleted file mode 100644 index b6e11565a4df6a..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorExternalCatalogFactory.java +++ /dev/null @@ -1,30 +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.datasource.trinoconnector; - -import org.apache.doris.common.DdlException; -import org.apache.doris.datasource.ExternalCatalog; - -import java.util.Map; - -public class TrinoConnectorExternalCatalogFactory { - public static ExternalCatalog createCatalog(long catalogId, String name, String resource, Map props, - String comment) throws DdlException { - return new TrinoConnectorExternalCatalog(catalogId, name, resource, props, comment); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorExternalDatabase.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorExternalDatabase.java deleted file mode 100644 index 31ada04eeb68e5..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorExternalDatabase.java +++ /dev/null @@ -1,37 +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.datasource.trinoconnector; - -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.InitDatabaseLog.Type; - -public class TrinoConnectorExternalDatabase extends ExternalDatabase { - public TrinoConnectorExternalDatabase(ExternalCatalog extCatalog, Long id, String name, String remoteName) { - super(extCatalog, id, name, remoteName, Type.TRINO_CONNECTOR); - } - - @Override - public TrinoConnectorExternalTable buildTableInternal(String remoteTableName, String localTableName, long tblId, - ExternalCatalog catalog, - ExternalDatabase db) { - return new TrinoConnectorExternalTable(tblId, localTableName, remoteTableName, - (TrinoConnectorExternalCatalog) extCatalog, - (TrinoConnectorExternalDatabase) db); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorExternalTable.java deleted file mode 100644 index 20e82d0b53735b..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorExternalTable.java +++ /dev/null @@ -1,263 +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.datasource.trinoconnector; - -import org.apache.doris.catalog.ArrayType; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.MapType; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.StructField; -import org.apache.doris.catalog.StructType; -import org.apache.doris.catalog.Type; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.thrift.TTableDescriptor; -import org.apache.doris.thrift.TTableType; -import org.apache.doris.thrift.TTrinoConnectorTable; - -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Lists; -import io.trino.Session; -import io.trino.metadata.QualifiedObjectName; -import io.trino.spi.connector.CatalogHandle; -import io.trino.spi.connector.ColumnHandle; -import io.trino.spi.connector.ColumnMetadata; -import io.trino.spi.connector.Connector; -import io.trino.spi.connector.ConnectorMetadata; -import io.trino.spi.connector.ConnectorSession; -import io.trino.spi.connector.ConnectorTableHandle; -import io.trino.spi.connector.ConnectorTransactionHandle; -import io.trino.spi.transaction.IsolationLevel; -import io.trino.spi.type.BigintType; -import io.trino.spi.type.BooleanType; -import io.trino.spi.type.CharType; -import io.trino.spi.type.DateType; -import io.trino.spi.type.DecimalType; -import io.trino.spi.type.DoubleType; -import io.trino.spi.type.IntegerType; -import io.trino.spi.type.RealType; -import io.trino.spi.type.RowType; -import io.trino.spi.type.RowType.Field; -import io.trino.spi.type.SmallintType; -import io.trino.spi.type.TimeType; -import io.trino.spi.type.TimestampType; -import io.trino.spi.type.TimestampWithTimeZoneType; -import io.trino.spi.type.TinyintType; -import io.trino.spi.type.VarbinaryType; -import io.trino.spi.type.VarcharType; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Optional; - -public class TrinoConnectorExternalTable extends ExternalTable { - - public TrinoConnectorExternalTable(long id, String name, String remoteName, TrinoConnectorExternalCatalog catalog, - TrinoConnectorExternalDatabase db) { - super(id, name, remoteName, catalog, db, TableType.TRINO_CONNECTOR_EXTERNAL_TABLE); - } - - @Override - protected synchronized void makeSureInitialized() { - super.makeSureInitialized(); - if (!objectCreated) { - objectCreated = true; - } - } - - @Override - public Optional initSchema() { - // 1. Get necessary objects - TrinoConnectorExternalCatalog trinoConnectorCatalog = (TrinoConnectorExternalCatalog) catalog; - CatalogHandle catalogHandle = trinoConnectorCatalog.getTrinoCatalogHandle(); - Connector connector = trinoConnectorCatalog.getConnector(); - Session trinoSession = trinoConnectorCatalog.getTrinoSession(); - ConnectorSession connectorSession = trinoSession.toConnectorSession(catalogHandle); - - // 2. Begin transaction and get ConnectorMetadata - ConnectorTransactionHandle connectorTransactionHandle = connector.beginTransaction( - IsolationLevel.READ_UNCOMMITTED, true, true); - ConnectorMetadata connectorMetadata = connector.getMetadata(connectorSession, connectorTransactionHandle); - - // 3. Get ConnectorTableHandle - Optional connectorTableHandle = Optional.empty(); - QualifiedObjectName qualifiedTable = new QualifiedObjectName(trinoConnectorCatalog.getName(), dbName, - name); - if (!qualifiedTable.getCatalogName().isEmpty() - && !qualifiedTable.getSchemaName().isEmpty() - && !qualifiedTable.getObjectName().isEmpty()) { - connectorTableHandle = Optional.ofNullable(connectorMetadata.getTableHandle(connectorSession, - qualifiedTable.asSchemaTableName(), Optional.empty(), Optional.empty())); - } - if (!connectorTableHandle.isPresent()) { - throw new RuntimeException(String.format("Table does not exist: %s.%s.%s", trinoConnectorCatalog.getName(), - dbName, name)); - } - - // 4. Get ColumnHandle - Map handles = connectorMetadata.getColumnHandles(connectorSession, - connectorTableHandle.get()); - ImmutableMap.Builder columnHandleMapBuilder = ImmutableMap.builder(); - for (Entry mapEntry : handles.entrySet()) { - columnHandleMapBuilder.put(mapEntry.getKey().toLowerCase(Locale.ENGLISH), mapEntry.getValue()); - } - Map columnHandleMap = columnHandleMapBuilder.buildOrThrow(); - - // 5. Get ColumnMetadata - ImmutableMap.Builder columnMetadataMapBuilder = ImmutableMap.builder(); - List columns = Lists.newArrayListWithCapacity(columnHandleMap.size()); - for (ColumnHandle columnHandle : columnHandleMap.values()) { - ColumnMetadata columnMetadata = connectorMetadata.getColumnMetadata(connectorSession, - connectorTableHandle.get(), columnHandle); - if (columnMetadata.isHidden()) { - continue; - } - columnMetadataMapBuilder.put(columnMetadata.getName(), columnMetadata); - - Column column = new Column(columnMetadata.getName(), - trinoConnectorTypeToDorisType(columnMetadata.getType()), - true, - null, - true, - columnMetadata.getComment(), - !columnMetadata.isHidden(), - Column.COLUMN_UNIQUE_ID_INIT_VALUE); - columns.add(column); - } - Map columnMetadataMap = columnMetadataMapBuilder.buildOrThrow(); - return Optional.of( - new TrinoSchemaCacheValue(columns, connectorMetadata, connectorTableHandle, connectorTransactionHandle, - columnHandleMap, columnMetadataMap)); - } - - @Override - public TTableDescriptor toThrift() { - List schema = getFullSchema(); - TTrinoConnectorTable tTrinoConnectorTable = new TTrinoConnectorTable(); - tTrinoConnectorTable.setDbName(dbName); - tTrinoConnectorTable.setTableName(name); - tTrinoConnectorTable.setProperties(new HashMap<>()); - - TTableDescriptor tTableDescriptor = new TTableDescriptor(getId(), - TTableType.TRINO_CONNECTOR_TABLE, schema.size(), 0, getName(), dbName); - tTableDescriptor.setTrinoConnectorTable(tTrinoConnectorTable); - return tTableDescriptor; - } - - private Type trinoConnectorTypeToDorisType(io.trino.spi.type.Type type) { - if (type instanceof BooleanType) { - return Type.BOOLEAN; - } else if (type instanceof TinyintType) { - return Type.TINYINT; - } else if (type instanceof SmallintType) { - return Type.SMALLINT; - } else if (type instanceof IntegerType) { - return Type.INT; - } else if (type instanceof BigintType) { - return Type.BIGINT; - } else if (type instanceof RealType) { - return Type.FLOAT; - } else if (type instanceof DoubleType) { - return Type.DOUBLE; - } else if (type instanceof CharType) { - return Type.CHAR; - } else if (type instanceof VarcharType) { - return Type.STRING; - // } else if (type instanceof BinaryType) { - // return Type.STRING; - } else if (type instanceof VarbinaryType) { - return Type.STRING; - } else if (type instanceof DecimalType) { - DecimalType decimal = (DecimalType) type; - return ScalarType.createDecimalV3Type(decimal.getPrecision(), decimal.getScale()); - } else if (type instanceof TimeType) { - return Type.STRING; - } else if (type instanceof DateType) { - return ScalarType.createDateV2Type(); - } else if (type instanceof TimestampType) { - TimestampType timestampType = (TimestampType) type; - return ScalarType.createDatetimeV2Type(getMaxDatetimePrecision(timestampType.getPrecision())); - } else if (type instanceof TimestampWithTimeZoneType) { - TimestampWithTimeZoneType timestampWithTimeZoneType = (TimestampWithTimeZoneType) type; - return ScalarType.createDatetimeV2Type(getMaxDatetimePrecision(timestampWithTimeZoneType.getPrecision())); - } else if (type instanceof io.trino.spi.type.ArrayType) { - Type elementType = trinoConnectorTypeToDorisType( - ((io.trino.spi.type.ArrayType) type).getElementType()); - return ArrayType.create(elementType, true); - } else if (type instanceof io.trino.spi.type.MapType) { - Type keyType = trinoConnectorTypeToDorisType( - ((io.trino.spi.type.MapType) type).getKeyType()); - Type valueType = trinoConnectorTypeToDorisType( - ((io.trino.spi.type.MapType) type).getValueType()); - return new MapType(keyType, valueType, true, true); - } else if (type instanceof RowType) { - ArrayList dorisFields = Lists.newArrayList(); - for (Field field : ((RowType) type).getFields()) { - Type childType = trinoConnectorTypeToDorisType(field.getType()); - if (field.getName().isPresent()) { - dorisFields.add(new StructField(field.getName().get(), childType)); - } else { - dorisFields.add(new StructField(childType)); - } - } - return new StructType(dorisFields); - } else { - throw new IllegalArgumentException("Cannot transform unknown type: " + type); - } - } - - private int getMaxDatetimePrecision(int precision) { - return Math.min(precision, 6); - } - - public ConnectorTableHandle getConnectorTableHandle() { - makeSureInitialized(); - Optional schemaCacheValue = getSchemaCacheValue(); - return schemaCacheValue.map(value -> ((TrinoSchemaCacheValue) value).getConnectorTableHandle().get()) - .orElse(null); - } - - public ConnectorMetadata getConnectorMetadata() { - makeSureInitialized(); - Optional schemaCacheValue = getSchemaCacheValue(); - return schemaCacheValue.map(value -> ((TrinoSchemaCacheValue) value).getConnectorMetadata()).orElse(null); - } - - public ConnectorTransactionHandle getConnectorTransactionHandle() { - makeSureInitialized(); - Optional schemaCacheValue = getSchemaCacheValue(); - return schemaCacheValue.map(value -> ((TrinoSchemaCacheValue) value).getConnectorTransactionHandle()) - .orElse(null); - } - - public Map getColumnHandleMap() { - makeSureInitialized(); - Optional schemaCacheValue = getSchemaCacheValue(); - return schemaCacheValue.map(value -> ((TrinoSchemaCacheValue) value).getColumnHandleMap()).orElse(null); - } - - public Map getColumnMetadataMap() { - makeSureInitialized(); - Optional schemaCacheValue = getSchemaCacheValue(); - return schemaCacheValue.map(value -> ((TrinoSchemaCacheValue) value).getColumnMetadataMap()).orElse(null); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorPluginLoader.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorPluginLoader.java deleted file mode 100644 index bc925785c57ebb..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorPluginLoader.java +++ /dev/null @@ -1,134 +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.datasource.trinoconnector; - -import org.apache.doris.common.Config; -import org.apache.doris.common.EnvUtils; -import org.apache.doris.trinoconnector.TrinoConnectorPluginManager; - -import com.google.common.util.concurrent.MoreExecutors; -import io.trino.FeaturesConfig; -import io.trino.metadata.HandleResolver; -import io.trino.metadata.TypeRegistry; -import io.trino.server.ServerPluginsProvider; -import io.trino.server.ServerPluginsProviderConfig; -import io.trino.spi.type.TypeOperators; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.File; -import java.util.logging.FileHandler; -import java.util.logging.Level; -import java.util.logging.SimpleFormatter; - -// Noninstancetiable utility class -public class TrinoConnectorPluginLoader { - private static final Logger LOG = LogManager.getLogger(TrinoConnectorPluginLoader.class); - - // Suppress default constructor for noninstantiability - private TrinoConnectorPluginLoader() { - throw new AssertionError(); - } - - private static class TrinoConnectorPluginLoad { - private static FeaturesConfig featuresConfig = new FeaturesConfig(); - private static TypeOperators typeOperators = new TypeOperators(); - private static HandleResolver handleResolver = new HandleResolver(); - private static TypeRegistry typeRegistry; - private static TrinoConnectorPluginManager trinoConnectorPluginManager; - - static { - try { - // Allow self-attachment for Java agents,this is required for certain debugging and monitoring functions - System.setProperty("jdk.attach.allowAttachSelf", "true"); - // Get the operating system name - String osName = System.getProperty("os.name").toLowerCase(); - // Skip HotSpot SAAttach for Mac/Darwin systems to avoid potential issues - if (osName.contains("mac") || osName.contains("darwin")) { - System.setProperty("jol.skipHotspotSAAttach", "true"); - } - // Trino uses jul as its own log system, so the attributes of JUL are configured here - System.setProperty("java.util.logging.SimpleFormatter.format", - "%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS %4$s: %5$s%6$s%n"); - java.util.logging.Logger logger = java.util.logging.Logger.getLogger(""); - logger.setUseParentHandlers(false); - FileHandler fileHandler = new FileHandler(EnvUtils.getDorisHome() + "/log/trinoconnector%g.log", - 500000000, 10, true); - fileHandler.setLevel(Level.INFO); - fileHandler.setFormatter(new SimpleFormatter()); - logger.addHandler(fileHandler); - java.util.logging.LogManager.getLogManager().addLogger(logger); - - typeRegistry = new TypeRegistry(typeOperators, featuresConfig); - ServerPluginsProviderConfig serverPluginsProviderConfig = new ServerPluginsProviderConfig() - .setInstalledPluginsDir(new File(checkAndReturnPluginDir())); - ServerPluginsProvider serverPluginsProvider = new ServerPluginsProvider(serverPluginsProviderConfig, - MoreExecutors.directExecutor()); - trinoConnectorPluginManager = new TrinoConnectorPluginManager(serverPluginsProvider, - typeRegistry, handleResolver); - trinoConnectorPluginManager.loadPlugins(); - } catch (Exception e) { - LOG.warn("Failed load trino-connector plugins from " + checkAndReturnPluginDir() - + ", Exception:" + e.getMessage(), e); - } - } - - private static String checkAndReturnPluginDir() { - final String defaultDir = System.getenv("DORIS_HOME") + "/plugins/connectors"; - final String defaultOldDir = System.getenv("DORIS_HOME") + "/connectors"; - if (Config.trino_connector_plugin_dir.equals(defaultDir)) { - // If true, which means user does not set `trino_connector_plugin_dir` and use the default one. - // Because in 2.1.8, we change the default value of `trino_connector_plugin_dir` - // from `DORIS_HOME/connectors` to `DORIS_HOME/plugins/connectors`, - // so we need to check the old default dir for compatibility. - File oldDir = new File(defaultOldDir); - if (oldDir.exists() && oldDir.isDirectory()) { - String[] contents = oldDir.list(); - if (contents != null && contents.length > 0) { - // there are contents in old dir, use old one - return defaultOldDir; - } - } - return defaultDir; - } else { - // Return user specified dir directly. - return Config.trino_connector_plugin_dir; - } - } - } - - public static FeaturesConfig getFeaturesConfig() { - return TrinoConnectorPluginLoad.featuresConfig; - } - - public static TypeOperators getTypeOperators() { - return TrinoConnectorPluginLoad.typeOperators; - } - - public static HandleResolver getHandleResolver() { - return TrinoConnectorPluginLoad.handleResolver; - } - - public static TypeRegistry getTypeRegistry() { - return TrinoConnectorPluginLoad.typeRegistry; - } - - public static TrinoConnectorPluginManager getTrinoConnectorPluginManager() { - return TrinoConnectorPluginLoad.trinoConnectorPluginManager; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoSchemaCacheValue.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoSchemaCacheValue.java deleted file mode 100644 index 43bbe76c3b303b..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoSchemaCacheValue.java +++ /dev/null @@ -1,90 +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.datasource.trinoconnector; - -import org.apache.doris.catalog.Column; -import org.apache.doris.datasource.SchemaCacheValue; - -import io.trino.spi.connector.ColumnHandle; -import io.trino.spi.connector.ColumnMetadata; -import io.trino.spi.connector.ConnectorMetadata; -import io.trino.spi.connector.ConnectorTableHandle; -import io.trino.spi.connector.ConnectorTransactionHandle; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -public class TrinoSchemaCacheValue extends SchemaCacheValue { - private ConnectorMetadata connectorMetadata; - private Optional connectorTableHandle; - private ConnectorTransactionHandle connectorTransactionHandle; - private Map columnHandleMap; - private Map columnMetadataMap; - - public TrinoSchemaCacheValue(List schema, ConnectorMetadata connectorMetadata, - Optional connectorTableHandle, ConnectorTransactionHandle connectorTransactionHandle, - Map columnHandleMap, Map columnMetadataMap) { - super(schema); - this.connectorMetadata = connectorMetadata; - this.connectorTableHandle = connectorTableHandle; - this.connectorTransactionHandle = connectorTransactionHandle; - this.columnHandleMap = columnHandleMap; - this.columnMetadataMap = columnMetadataMap; - } - - public ConnectorMetadata getConnectorMetadata() { - return connectorMetadata; - } - - public Optional getConnectorTableHandle() { - return connectorTableHandle; - } - - public ConnectorTransactionHandle getConnectorTransactionHandle() { - return connectorTransactionHandle; - } - - public Map getColumnHandleMap() { - return columnHandleMap; - } - - public Map getColumnMetadataMap() { - return columnMetadataMap; - } - - public void setConnectorMetadata(ConnectorMetadata connectorMetadata) { - this.connectorMetadata = connectorMetadata; - } - - public void setConnectorTableHandle(Optional connectorTableHandle) { - this.connectorTableHandle = connectorTableHandle; - } - - public void setConnectorTransactionHandle(ConnectorTransactionHandle connectorTransactionHandle) { - this.connectorTransactionHandle = connectorTransactionHandle; - } - - public void setColumnHandleMap(Map columnHandleMap) { - this.columnHandleMap = columnHandleMap; - } - - public void setColumnMetadataMap(Map columnMetadataMap) { - this.columnMetadataMap = columnMetadataMap; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/source/TrinoConnectorPredicateConverter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/source/TrinoConnectorPredicateConverter.java deleted file mode 100644 index 2ccd069f8286f1..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/source/TrinoConnectorPredicateConverter.java +++ /dev/null @@ -1,334 +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.datasource.trinoconnector.source; - -import org.apache.doris.analysis.BinaryPredicate; -import org.apache.doris.analysis.CastExpr; -import org.apache.doris.analysis.CompoundPredicate; -import org.apache.doris.analysis.DateLiteral; -import org.apache.doris.analysis.DecimalLiteral; -import org.apache.doris.analysis.Expr; -import org.apache.doris.analysis.InPredicate; -import org.apache.doris.analysis.IsNullPredicate; -import org.apache.doris.analysis.LiteralExpr; -import org.apache.doris.analysis.NullLiteral; -import org.apache.doris.analysis.SlotRef; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.util.TimeUtils; - -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Lists; -import io.airlift.slice.Slices; -import io.trino.spi.connector.ColumnHandle; -import io.trino.spi.connector.ColumnMetadata; -import io.trino.spi.predicate.Domain; -import io.trino.spi.predicate.Range; -import io.trino.spi.predicate.TupleDomain; -import io.trino.spi.predicate.ValueSet; -import io.trino.spi.type.Int128; -import io.trino.spi.type.LongTimestamp; -import io.trino.spi.type.LongTimestampWithTimeZone; -import io.trino.spi.type.TimeZoneKey; -import io.trino.spi.type.Type; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.math.BigDecimal; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.TimeZone; - - -public class TrinoConnectorPredicateConverter { - private static final Logger LOG = LogManager.getLogger(TrinoConnectorPredicateConverter.class); - private static final String EPOCH_DATE = "1970-01-01"; - private static final String GMT = "GMT"; - private final Map trinoConnectorColumnHandleMap; - - private final Map trinoConnectorColumnMetadataMap; - - public TrinoConnectorPredicateConverter(Map columnHandleMap, - Map columnMetadataMap) { - this.trinoConnectorColumnHandleMap = columnHandleMap; - this.trinoConnectorColumnMetadataMap = columnMetadataMap; - } - - public TupleDomain convertExprToTrinoTupleDomain(Expr predicate) throws AnalysisException { - if (predicate instanceof CompoundPredicate) { - return compoundPredicateConverter((CompoundPredicate) predicate); - } else if (predicate instanceof InPredicate) { - return inPredicateConverter((InPredicate) predicate); - } else if (predicate instanceof BinaryPredicate) { - return binaryPredicateConverter((BinaryPredicate) predicate); - } else if (predicate instanceof IsNullPredicate) { - return isNullPredicateConverter((IsNullPredicate) predicate); - } else { - throw new AnalysisException("Do not support convert predicate: [" + predicate + "]."); - } - } - - private TupleDomain compoundPredicateConverter(CompoundPredicate compoundPredicate) - throws AnalysisException { - switch (compoundPredicate.getOp()) { - case AND: { - TupleDomain left = null; - TupleDomain right = null; - try { - left = convertExprToTrinoTupleDomain(compoundPredicate.getChild(0)); - } catch (AnalysisException e) { - LOG.warn("left predicate of compund predicate failed, exception: " + e.getMessage()); - } - try { - right = convertExprToTrinoTupleDomain(compoundPredicate.getChild(1)); - } catch (AnalysisException e) { - LOG.warn("right predicate of compound predicate failed, exception: " + e.getMessage()); - } - if (left != null && right != null) { - return left.intersect(right); - } else if (left != null) { - return left; - } else if (right != null) { - return right; - } - throw new AnalysisException("Can not convert both sides of compound predicate [" - + compoundPredicate.getOp() + "] to TupleDomain."); - } - case OR: { - TupleDomain left = convertExprToTrinoTupleDomain(compoundPredicate.getChild(0)); - TupleDomain right = convertExprToTrinoTupleDomain(compoundPredicate.getChild(1)); - return TupleDomain.columnWiseUnion(left, right); - } - case NOT: - default: - throw new AnalysisException("Do not support convert compound predicate [" + compoundPredicate.getOp() - + "] to TupleDomain."); - } - } - - private TupleDomain inPredicateConverter(InPredicate predicate) throws AnalysisException { - // Make sure the col slot is always first - SlotRef slotRef = convertExprToSlotRef(predicate.getChild(0)); - if (slotRef == null) { - throw new AnalysisException("slotRef is null in inPredicateConverter."); - } - String colName = slotRef.getColumnName(); - Type type = trinoConnectorColumnMetadataMap.get(colName).getType(); - List ranges = Lists.newArrayList(); - for (int i = 1; i < predicate.getChildren().size(); i++) { - LiteralExpr literalExpr = convertExprToLiteral(predicate.getChild(i)); - if (literalExpr == null) { - throw new AnalysisException("literalExpr of InPredicate's children is null in inPredicateConverter."); - } - ranges.add(Range.equal(type, convertLiteralToDomainValues(type.getClass(), literalExpr))); - } - - Domain domain = predicate.isNotIn() - ? Domain.create(ValueSet.all(type).subtract(ValueSet.ofRanges(ranges)), false) - : Domain.create(ValueSet.ofRanges(ranges), false); - TupleDomain tupleDomain = TupleDomain.withColumnDomains( - ImmutableMap.of(trinoConnectorColumnHandleMap.get(colName), domain)); - return tupleDomain; - } - - private TupleDomain binaryPredicateConverter(BinaryPredicate predicate) throws AnalysisException { - // Make sure the col slot is always first - SlotRef slotRef = convertExprToSlotRef(predicate.getChild(0)); - if (slotRef == null) { - throw new AnalysisException("slotRef is null in binaryPredicateConverter."); - } - LiteralExpr literalExpr = convertExprToLiteral(predicate.getChild(1)); - // literalExpr == null means predicate.getChild(1) is not a LiteralExpr or CastExpr - // such as 'where A.a < A.b',predicate.getChild(1) is SlotRef - if (literalExpr == null) { - throw new AnalysisException("literalExpr of BinaryPredicate's child is null in binaryPredicateConverter."); - } - - String colName = slotRef.getColumnName(); - Type type = trinoConnectorColumnMetadataMap.get(colName).getType(); - Domain domain = null; - BinaryPredicate.Operator op = ((BinaryPredicate) predicate).getOp(); - switch (op) { - case EQ: - domain = Domain.create(ValueSet.ofRanges(Range.equal(type, - convertLiteralToDomainValues(type.getClass(), literalExpr))), false); - break; - case EQ_FOR_NULL: { - if (literalExpr instanceof NullLiteral) { - domain = Domain.onlyNull(type); - } else { - domain = Domain.create(ValueSet.ofRanges(Range.equal(type, - convertLiteralToDomainValues(type.getClass(), literalExpr))), false); - } - break; - } - case NE: - domain = Domain.create(ValueSet.all(type).subtract(ValueSet.ofRanges(Range.equal(type, - convertLiteralToDomainValues(type.getClass(), literalExpr)))), false); - break; - case LT: - domain = Domain.create(ValueSet.ofRanges(Range.lessThan(type, - convertLiteralToDomainValues(type.getClass(), literalExpr))), false); - break; - case LE: - domain = Domain.create(ValueSet.ofRanges(Range.lessThanOrEqual(type, - convertLiteralToDomainValues(type.getClass(), literalExpr))), false); - break; - case GT: - domain = Domain.create(ValueSet.ofRanges(Range.greaterThan(type, - convertLiteralToDomainValues(type.getClass(), literalExpr))), false); - break; - case GE: - domain = Domain.create(ValueSet.ofRanges(Range.greaterThanOrEqual(type, - convertLiteralToDomainValues(type.getClass(), literalExpr))), false); - break; - default: - throw new AnalysisException("Do not support operator [" + op + "] in binaryPredicateConverter."); - } - return TupleDomain.withColumnDomains(ImmutableMap.of(trinoConnectorColumnHandleMap.get(colName), domain)); - } - - private TupleDomain isNullPredicateConverter(IsNullPredicate predicate) throws AnalysisException { - Objects.requireNonNull(predicate.getChild(0), "The first child of IsNullPredicate is null."); - SlotRef slotRef = convertExprToSlotRef(predicate.getChild(0)); - if (slotRef == null) { - throw new AnalysisException("slotRef is null in IsNullPredicate."); - } - String colName = slotRef.getColumnName(); - Type type = trinoConnectorColumnMetadataMap.get(colName).getType(); - if (predicate.isNotNull()) { - return TupleDomain.withColumnDomains( - ImmutableMap.of(trinoConnectorColumnHandleMap.get(colName), Domain.notNull(type))); - } - return TupleDomain.withColumnDomains( - ImmutableMap.of(trinoConnectorColumnHandleMap.get(colName), Domain.onlyNull(type))); - } - - /* Since different Trino types have different data formats stored in their Range, - we need to convert the data format stored in Doris's LiteralExpr to the corresponding Java data type - which can be recognized by the Trino Type Range. - The correspondence between different Trino types and the Java data types stored in their Range is as follows: - - Trino Type Java Type - - BooleanType boolean - TinyintType long - SmallintType long - IntegerType long - BigintType long - RealType long - ShortDecimalType long - LongDecimalType io.trino.spi.type.Int128 - CharType io.airlift.slice.Slice - VarbinaryType io.airlift.slice.Slice - VarcharType io.airlift.slice.Slice - DateType long - DoubleType double - TimeType long - ShortTimestampType long - LongTimestampType io.trino.spi.type.LongTimestamp - ShortTimestampWithTimeZoneType long - LongTimestampWithTimeZoneType io.trino.spi.type.LongTimestampWithTimeZone - ArrayType io.trino.spi.block.Block - MapType io.trino.spi.block.SqlMap - RowType io.trino.spi.block.SqlRow*/ - private Object convertLiteralToDomainValues(Class type, LiteralExpr literalExpr) - throws AnalysisException { - switch (type.getSimpleName()) { - case "BooleanType": - return literalExpr.getRealValue(); - case "TinyintType": - case "SmallintType": - case "IntegerType": - case "BigintType": - return literalExpr.getLongValue(); - case "RealType": - return (long) Float.floatToIntBits((float) literalExpr.getDoubleValue()); - case "DoubleType": - return literalExpr.getDoubleValue(); - case "ShortDecimalType": { - BigDecimal value = (BigDecimal) literalExpr.getRealValue(); - BigDecimal tmpValue = new BigDecimal(Math.pow(10, DecimalLiteral.getBigDecimalScale(value))); - value = value.multiply(tmpValue); - return value.longValue(); - } - case "LongDecimalType": { - BigDecimal value = (BigDecimal) literalExpr.getRealValue(); - BigDecimal tmpValue = new BigDecimal(Math.pow(10, DecimalLiteral.getBigDecimalScale(value))); - value = value.multiply(tmpValue); - return Int128.valueOf(value.toBigIntegerExact()); - } - case "CharType": - case "VarbinaryType": - case "VarcharType": - return Slices.utf8Slice((String) literalExpr.getRealValue()); - case "DateType": - return ((DateLiteral) literalExpr).daynr() - new DateLiteral(1970, 1, 1).daynr(); - case "ShortTimestampType": { - DateLiteral dateLiteral = (DateLiteral) literalExpr; - return dateLiteral.unixTimestamp(TimeZone.getTimeZone(GMT)) * 1000 - + dateLiteral.getMicrosecond(); - } - case "LongTimestampType": { - DateLiteral dateLiteral = (DateLiteral) literalExpr; - long epochMicros = dateLiteral.unixTimestamp(TimeZone.getTimeZone(GMT)) * 1000 - + dateLiteral.getMicrosecond(); - return new LongTimestamp(epochMicros, 0); - } - case "LongTimestampWithTimeZoneType": { - DateLiteral dateLiteral = (DateLiteral) literalExpr; - long epochMillis = dateLiteral.unixTimestamp(TimeUtils.getTimeZone()); - int picosOfMilli = (int) dateLiteral.getMicrosecond() * 1000000; - TimeZoneKey timeZoneKey = TimeZoneKey.getTimeZoneKey(TimeUtils.getTimeZone().toZoneId().toString()); - return LongTimestampWithTimeZone.fromEpochMillisAndFraction(epochMillis, picosOfMilli, timeZoneKey); - } - case "ShortTimestampWithTimeZoneType": - case "TimeType": - case "ArrayType": - case "MapType": - case "RowType": - default: - return new AnalysisException("Do not support convert trino type [" + type.getSimpleName() - + "] to domain values."); - } - } - - private SlotRef convertExprToSlotRef(Expr expr) { - SlotRef slotRef = null; - if (expr instanceof SlotRef) { - slotRef = (SlotRef) expr; - } else if (expr instanceof CastExpr) { - if (expr.getChild(0) instanceof SlotRef) { - slotRef = (SlotRef) expr.getChild(0); - } - } - return slotRef; - } - - private LiteralExpr convertExprToLiteral(Expr expr) { - LiteralExpr literalExpr = null; - if (expr instanceof LiteralExpr) { - literalExpr = (LiteralExpr) expr; - } else if (expr instanceof CastExpr) { - if (expr.getChild(0) instanceof LiteralExpr) { - literalExpr = (LiteralExpr) expr.getChild(0); - } - } - return literalExpr; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/source/TrinoConnectorScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/source/TrinoConnectorScanNode.java deleted file mode 100644 index 279a71ded44ba7..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/source/TrinoConnectorScanNode.java +++ /dev/null @@ -1,342 +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.datasource.trinoconnector.source; - -import org.apache.doris.analysis.SlotDescriptor; -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.MetaNotFoundException; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.FileQueryScanNode; -import org.apache.doris.datasource.TableFormatType; -import org.apache.doris.datasource.trinoconnector.TrinoConnectorPluginLoader; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.spi.Split; -import org.apache.doris.thrift.TFileAttributes; -import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.TFileRangeDesc; -import org.apache.doris.thrift.TTableFormatFileDesc; -import org.apache.doris.thrift.TTrinoConnectorFileDesc; -import org.apache.doris.trinoconnector.TrinoColumnMetadata; - -import com.fasterxml.jackson.databind.Module; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import io.airlift.concurrent.BoundedExecutor; -import io.airlift.concurrent.MoreFutures; -import io.airlift.concurrent.Threads; -import io.airlift.json.JsonCodecFactory; -import io.airlift.json.ObjectMapperProvider; -import io.trino.Session; -import io.trino.SystemSessionProperties; -import io.trino.block.BlockJsonSerde; -import io.trino.metadata.BlockEncodingManager; -import io.trino.metadata.HandleJsonModule; -import io.trino.metadata.HandleResolver; -import io.trino.metadata.InternalBlockEncodingSerde; -import io.trino.spi.block.Block; -import io.trino.spi.connector.ColumnHandle; -import io.trino.spi.connector.ColumnMetadata; -import io.trino.spi.connector.Connector; -import io.trino.spi.connector.ConnectorMetadata; -import io.trino.spi.connector.ConnectorSession; -import io.trino.spi.connector.ConnectorSplitManager; -import io.trino.spi.connector.ConnectorSplitSource; -import io.trino.spi.connector.ConnectorTableHandle; -import io.trino.spi.connector.Constraint; -import io.trino.spi.connector.ConstraintApplicationResult; -import io.trino.spi.connector.DynamicFilter; -import io.trino.spi.connector.LimitApplicationResult; -import io.trino.spi.connector.ProjectionApplicationResult; -import io.trino.spi.expression.ConnectorExpression; -import io.trino.spi.expression.Variable; -import io.trino.spi.predicate.TupleDomain; -import io.trino.spi.type.TypeManager; -import io.trino.split.BufferingSplitSource; -import io.trino.split.ConnectorAwareSplitSource; -import io.trino.split.SplitSource; -import io.trino.type.InternalTypeManager; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.stream.Collectors; - -public class TrinoConnectorScanNode extends FileQueryScanNode { - private static final Logger LOG = LogManager.getLogger(TrinoConnectorScanNode.class); - private static final int minScheduleSplitBatchSize = 10; - private TrinoConnectorSource source = null; - private ObjectMapperProvider objectMapperProvider; - - private ConnectorMetadata connectorMetadata; - private Constraint constraint; - - public TrinoConnectorScanNode(PlanNodeId id, TupleDescriptor desc, boolean needCheckColumnPriv, - SessionVariable sv, ScanContext scanContext) { - super(id, desc, "TRINO_CONNECTOR_SCAN_NODE", scanContext, needCheckColumnPriv, sv); - } - - @Override - protected void doInitialize() throws UserException { - super.doInitialize(); - source = new TrinoConnectorSource(desc); - } - - @Override - protected void convertPredicate() { - if (conjuncts.isEmpty()) { - constraint = Constraint.alwaysTrue(); - } - TupleDomain summary = TupleDomain.all(); - TrinoConnectorPredicateConverter trinoConnectorPredicateConverter = new TrinoConnectorPredicateConverter( - source.getTargetTable().getColumnHandleMap(), - source.getTargetTable().getColumnMetadataMap()); - try { - for (int i = 0; i < conjuncts.size(); ++i) { - summary = summary.intersect( - trinoConnectorPredicateConverter.convertExprToTrinoTupleDomain(conjuncts.get(i))); - } - } catch (AnalysisException e) { - LOG.warn("Can not convert Expr to trino tuple domain, exception: {}", e.getMessage()); - summary = TupleDomain.all(); - } - constraint = new Constraint(summary); - } - - @Override - public List getSplits(int numBackends) throws UserException { - // 1. Get necessary objects - Connector connector = source.getConnector(); - connectorMetadata = source.getConnectorMetadata(); - ConnectorSession connectorSession = source.getTrinoSession().toConnectorSession(source.getCatalogHandle()); - - List splits = Lists.newArrayList(); - try { - connectorMetadata.beginQuery(connectorSession); - applyPushDown(connectorSession); - - // 3. get splitSource - try (SplitSource splitSource = getTrinoSplitSource(connector, source.getTrinoSession(), - source.getTrinoConnectorTableHandle(), DynamicFilter.EMPTY)) { - // 4. get trino.Splits and convert it to doris.Splits - while (!splitSource.isFinished()) { - for (io.trino.metadata.Split split : getNextSplitBatch(splitSource)) { - splits.add(new TrinoConnectorSplit(split.getConnectorSplit(), source.getConnectorName())); - } - } - } - } finally { - // 4. Clear query - connectorMetadata.cleanupQuery(connectorSession); - } - return splits; - } - - private void applyPushDown(ConnectorSession connectorSession) { - // push down predicate/filter - Optional> filterResult - = connectorMetadata.applyFilter(connectorSession, source.getTrinoConnectorTableHandle(), constraint); - if (filterResult.isPresent()) { - source.setTrinoConnectorTableHandle(filterResult.get().getHandle()); - } - - // push down limit - if (hasLimit()) { - long limit = getLimit(); - Optional> limitResult - = connectorMetadata.applyLimit(connectorSession, source.getTrinoConnectorTableHandle(), limit); - if (limitResult.isPresent()) { - source.setTrinoConnectorTableHandle(limitResult.get().getHandle()); - } - } - - if (LOG.isDebugEnabled()) { - LOG.debug("The TrinoConnectorTableHandle is " + source.getTrinoConnectorTableHandle() - + " after pushing down."); - } - - // push down projection - Map columnHandleMap = source.getTargetTable().getColumnHandleMap(); - Map columnMetadataMap = source.getTargetTable().getColumnMetadataMap(); - Map assignments = Maps.newLinkedHashMap(); - List projections = Lists.newArrayList(); - for (SlotDescriptor slotDescriptor : desc.getSlots()) { - String colName = slotDescriptor.getColumn().getName(); - assignments.put(colName, columnHandleMap.get(colName)); - projections.add(new Variable(colName, columnMetadataMap.get(colName).getType())); - } - Optional> projectionResult - = connectorMetadata.applyProjection(connectorSession, source.getTrinoConnectorTableHandle(), - projections, assignments); - if (projectionResult.isPresent()) { - source.setTrinoConnectorTableHandle(projectionResult.get().getHandle()); - } - } - - private SplitSource getTrinoSplitSource(Connector connector, Session session, ConnectorTableHandle table, - DynamicFilter dynamicFilter) { - ConnectorSplitManager splitManager = connector.getSplitManager(); - - if (!SystemSessionProperties.isAllowPushdownIntoConnectors(session)) { - dynamicFilter = DynamicFilter.EMPTY; - } - - ConnectorSession connectorSession = session.toConnectorSession(source.getCatalogHandle()); - // Constraint is not used by Hive/BigQuery Connector - ConnectorSplitSource connectorSplitSource = splitManager.getSplits(source.getConnectorTransactionHandle(), - connectorSession, table, dynamicFilter, constraint); - - SplitSource splitSource = new ConnectorAwareSplitSource(source.getCatalogHandle(), connectorSplitSource); - if (this.minScheduleSplitBatchSize > 1) { - ExecutorService executorService = Executors.newCachedThreadPool( - Threads.daemonThreadsNamed(TrinoConnectorScanNode.class.getSimpleName() + "-%s")); - splitSource = new BufferingSplitSource(splitSource, - new BoundedExecutor(executorService, 10), this.minScheduleSplitBatchSize); - } - return splitSource; - } - - private List getNextSplitBatch(SplitSource splitSource) { - return MoreFutures.getFutureValue(splitSource.getNextBatch(1000)).getSplits(); - } - - @Override - protected void setScanParams(TFileRangeDesc rangeDesc, Split split) { - if (split instanceof TrinoConnectorSplit) { - setTrinoConnectorParams(rangeDesc, (TrinoConnectorSplit) split); - } - } - - private void setTrinoConnectorParams(TFileRangeDesc rangeDesc, TrinoConnectorSplit trinoConnectorSplit) { - // mock ObjectMapperProvider - objectMapperProvider = createObjectMapperProvider(); - - // set TTrinoConnectorFileDesc - TTrinoConnectorFileDesc fileDesc = new TTrinoConnectorFileDesc(); - fileDesc.setTrinoConnectorSplit(encodeObjectToString(trinoConnectorSplit.getSplit(), objectMapperProvider)); - fileDesc.setCatalogName(source.getCatalog().getName()); - fileDesc.setDbName(source.getTargetTable().getDbName()); - fileDesc.setTrinoConnectorOptions(source.getCatalog().getTrinoConnectorPropertiesWithCreateTime()); - fileDesc.setTableName(source.getTargetTable().getName()); - fileDesc.setTrinoConnectorTableHandle(encodeObjectToString( - source.getTrinoConnectorTableHandle(), objectMapperProvider)); - - Map columnHandleMap = source.getTargetTable().getColumnHandleMap(); - Map columnMetadataMap = source.getTargetTable().getColumnMetadataMap(); - List columnHandles = new ArrayList<>(); - List columnMetadataList = new ArrayList<>(); - for (SlotDescriptor slotDescriptor : source.getDesc().getSlots()) { - String colName = slotDescriptor.getColumn().getName(); - if (columnMetadataMap.containsKey(colName)) { - columnMetadataList.add(columnMetadataMap.get(colName)); - columnHandles.add(columnHandleMap.get(colName)); - } - } - fileDesc.setTrinoConnectorColumnHandles(encodeObjectToString(columnHandles, objectMapperProvider)); - fileDesc.setTrinoConnectorTrascationHandle( - encodeObjectToString(source.getConnectorTransactionHandle(), objectMapperProvider)); - fileDesc.setTrinoConnectorColumnMetadata(encodeObjectToString(columnMetadataList.stream().map( - filed -> new TrinoColumnMetadata(filed.getName(), filed.getType(), filed.isNullable(), - filed.getComment(), - filed.getExtraInfo(), filed.isHidden(), filed.getProperties())) - .collect(Collectors.toList()), objectMapperProvider)); - - // set TTableFormatFileDesc - TTableFormatFileDesc tableFormatFileDesc = new TTableFormatFileDesc(); - tableFormatFileDesc.setTrinoConnectorParams(fileDesc); - tableFormatFileDesc.setTableFormatType(TableFormatType.TRINO_CONNECTOR.value()); - - // set TFileRangeDesc - rangeDesc.setTableFormatParams(tableFormatFileDesc); - } - - private ObjectMapperProvider createObjectMapperProvider() { - // mock ObjectMapperProvider - ObjectMapperProvider objectMapperProvider = new ObjectMapperProvider(); - Set modules = new HashSet(); - HandleResolver handleResolver = TrinoConnectorPluginLoader.getHandleResolver(); - modules.add(HandleJsonModule.tableHandleModule(handleResolver)); - modules.add(HandleJsonModule.columnHandleModule(handleResolver)); - modules.add(HandleJsonModule.splitModule(handleResolver)); - modules.add(HandleJsonModule.transactionHandleModule(handleResolver)); - // modules.add(HandleJsonModule.outputTableHandleModule(handleResolver)); - // modules.add(HandleJsonModule.insertTableHandleModule(handleResolver)); - // modules.add(HandleJsonModule.tableExecuteHandleModule(handleResolver)); - // modules.add(HandleJsonModule.indexHandleModule(handleResolver)); - // modules.add(HandleJsonModule.partitioningHandleModule(handleResolver)); - // modules.add(HandleJsonModule.tableFunctionHandleModule(handleResolver)); - objectMapperProvider.setModules(modules); - - // set json deserializers - TypeManager typeManager = new InternalTypeManager(TrinoConnectorPluginLoader.getTypeRegistry()); - InternalBlockEncodingSerde blockEncodingSerde = new InternalBlockEncodingSerde(new BlockEncodingManager(), - typeManager); - objectMapperProvider.setJsonSerializers(ImmutableMap.of(Block.class, - new BlockJsonSerde.Serializer(blockEncodingSerde))); - return objectMapperProvider; - } - - private String encodeObjectToString(T t, ObjectMapperProvider objectMapperProvider) { - try { - io.airlift.json.JsonCodec jsonCodec = (io.airlift.json.JsonCodec) new JsonCodecFactory( - objectMapperProvider).jsonCodec(t.getClass()); - return jsonCodec.toJson(t); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Override - public TFileFormatType getFileFormatType() throws DdlException, MetaNotFoundException { - return TFileFormatType.FORMAT_JNI; - } - - @Override - public List getPathPartitionKeys() throws DdlException, MetaNotFoundException { - return new ArrayList<>(); - } - - @Override - public TFileAttributes getFileAttributes() throws UserException { - return source.getFileAttributes(); - } - - @Override - public TableIf getTargetTable() { - // can not use `source.getTargetTable()` - // because source is null when called getTargetTable - return desc.getTable(); - } - - @Override - public Map getLocationProperties() throws MetaNotFoundException, DdlException { - return source.getCatalog().getCatalogProperty().getHadoopProperties(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/source/TrinoConnectorSource.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/source/TrinoConnectorSource.java deleted file mode 100644 index 20dcf996595a48..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/source/TrinoConnectorSource.java +++ /dev/null @@ -1,106 +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.datasource.trinoconnector.source; - -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.trinoconnector.TrinoConnectorExternalCatalog; -import org.apache.doris.datasource.trinoconnector.TrinoConnectorExternalTable; -import org.apache.doris.thrift.TFileAttributes; - -import io.trino.Session; -import io.trino.connector.ConnectorName; -import io.trino.spi.connector.CatalogHandle; -import io.trino.spi.connector.Connector; -import io.trino.spi.connector.ConnectorMetadata; -import io.trino.spi.connector.ConnectorTableHandle; -import io.trino.spi.connector.ConnectorTransactionHandle; - -public class TrinoConnectorSource { - private final TupleDescriptor desc; - private final TrinoConnectorExternalCatalog trinoConnectorExternalCatalog; - private final TrinoConnectorExternalTable trinoConnectorExtTable; - private final CatalogHandle catalogHandle; - private final Session trinoSession; - private final Connector connector; - private final ConnectorName connectorName; - private ConnectorTransactionHandle connectorTransactionHandle; - private ConnectorTableHandle trinoConnectorTableHandle; - private ConnectorMetadata connectorMetadata; - - public TrinoConnectorSource(TupleDescriptor desc) { - this.desc = desc; - this.trinoConnectorExtTable = (TrinoConnectorExternalTable) desc.getTable(); - this.trinoConnectorExternalCatalog = (TrinoConnectorExternalCatalog) trinoConnectorExtTable.getCatalog(); - this.catalogHandle = trinoConnectorExternalCatalog.getTrinoCatalogHandle(); - this.trinoConnectorTableHandle = trinoConnectorExtTable.getConnectorTableHandle(); - this.connectorMetadata = trinoConnectorExtTable.getConnectorMetadata(); - this.connectorTransactionHandle = trinoConnectorExtTable.getConnectorTransactionHandle(); - this.trinoSession = trinoConnectorExternalCatalog.getTrinoSession(); - this.connector = trinoConnectorExternalCatalog.getConnector(); - this.connectorName = trinoConnectorExternalCatalog.getConnectorName(); - } - - public TupleDescriptor getDesc() { - return desc; - } - - public ConnectorTableHandle getTrinoConnectorTableHandle() { - return trinoConnectorTableHandle; - } - - public TrinoConnectorExternalTable getTargetTable() { - return trinoConnectorExtTable; - } - - public TFileAttributes getFileAttributes() throws UserException { - return new TFileAttributes(); - } - - public TrinoConnectorExternalCatalog getCatalog() { - return trinoConnectorExternalCatalog; - } - - public CatalogHandle getCatalogHandle() { - return catalogHandle; - } - - public Session getTrinoSession() { - return trinoSession; - } - - public Connector getConnector() { - return connector; - } - - public ConnectorName getConnectorName() { - return connectorName; - } - - public ConnectorMetadata getConnectorMetadata() { - return connectorMetadata; - } - - public void setTrinoConnectorTableHandle(ConnectorTableHandle trinoConnectorExtTableHandle) { - this.trinoConnectorTableHandle = trinoConnectorExtTableHandle; - } - - public ConnectorTransactionHandle getConnectorTransactionHandle() { - return connectorTransactionHandle; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/source/TrinoConnectorSplit.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/source/TrinoConnectorSplit.java deleted file mode 100644 index 3aca8ba96d14a8..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/source/TrinoConnectorSplit.java +++ /dev/null @@ -1,95 +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.datasource.trinoconnector.source; - -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.FileSplit; -import org.apache.doris.datasource.TableFormatType; - -import io.trino.connector.ConnectorName; -import io.trino.spi.HostAddress; -import io.trino.spi.connector.ConnectorSplit; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -public class TrinoConnectorSplit extends FileSplit { - private static final Logger LOG = LogManager.getLogger(TrinoConnectorSplit.class); - private static final LocationPath DUMMY_PATH = LocationPath.of("/dummyPath"); - private ConnectorSplit connectorSplit; - private TableFormatType tableFormatType; - private final ConnectorName connectorName; - - public TrinoConnectorSplit(ConnectorSplit connectorSplit, ConnectorName connectorName) { - super(DUMMY_PATH, 0, 0, 0, 0, null, null); - this.connectorSplit = connectorSplit; - this.tableFormatType = TableFormatType.TRINO_CONNECTOR; - this.connectorName = connectorName; - initSplitInfo(); - } - - public ConnectorSplit getSplit() { - return connectorSplit; - } - - public void setSplit(ConnectorSplit connectorSplit) { - this.connectorSplit = connectorSplit; - } - - public TableFormatType getTableFormatType() { - return tableFormatType; - } - - public void setTableFormatType(TableFormatType tableFormatType) { - this.tableFormatType = tableFormatType; - } - - private void initSplitInfo() { - // set hosts - List addresses = connectorSplit.getAddresses(); - this.hosts = new String[addresses.size()]; - for (int i = 0; i < addresses.size(); i++) { - hosts[i] = addresses.get(0).getHostText(); - } - - switch (connectorName.toString()) { - case "hive": - initHiveSplitInfo(); - break; - default: - LOG.debug("Unknow connector name: " + connectorName); - return; - } - } - - private void initHiveSplitInfo() { - Object info = connectorSplit.getInfo(); - if (info instanceof Map) { - Map splitInfo = (Map) info; - path = LocationPath.of((String) splitInfo.getOrDefault("path", "dummyPath")); - start = (long) splitInfo.getOrDefault("start", 0); - length = (long) splitInfo.getOrDefault("length", 0); - fileLength = (long) splitInfo.getOrDefault("estimatedFileSize", 0); - partitionValues = new ArrayList<>(); - partitionValues.add((String) splitInfo.getOrDefault("partitionName", "")); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/tvf/source/MetadataScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/tvf/source/MetadataScanNode.java index 796090b00998ef..ce8e53c251e599 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/tvf/source/MetadataScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/tvf/source/MetadataScanNode.java @@ -19,7 +19,7 @@ import org.apache.doris.analysis.TupleDescriptor; import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalScanNode; +import org.apache.doris.datasource.scan.ExternalScanNode; import org.apache.doris.planner.PlanNodeId; import org.apache.doris.planner.ScanContext; import org.apache.doris.qe.ConnectContext; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/tvf/source/TVFScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/tvf/source/TVFScanNode.java index 2124318c5a6fbc..e610b137929eb4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/tvf/source/TVFScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/tvf/source/TVFScanNode.java @@ -28,11 +28,11 @@ import org.apache.doris.common.UserException; import org.apache.doris.common.util.LocationPath; import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.FileQueryScanNode; -import org.apache.doris.datasource.FileSplit; -import org.apache.doris.datasource.FileSplit.FileSplitCreator; -import org.apache.doris.datasource.FileSplitter; -import org.apache.doris.datasource.TableFormatType; +import org.apache.doris.datasource.scan.FileQueryScanNode; +import org.apache.doris.datasource.scan.TableFormatType; +import org.apache.doris.datasource.split.FileSplit; +import org.apache.doris.datasource.split.FileSplit.FileSplitCreator; +import org.apache.doris.datasource.split.FileSplitter; import org.apache.doris.planner.PlanNodeId; import org.apache.doris.planner.ScanContext; import org.apache.doris.qe.SessionVariable; diff --git a/fe/fe-core/src/main/java/org/apache/doris/fs/DirectoryLister.java b/fe/fe-core/src/main/java/org/apache/doris/fs/DirectoryLister.java deleted file mode 100644 index 4302f3397892cb..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/fs/DirectoryLister.java +++ /dev/null @@ -1,32 +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. -// This file is copied from -// https://github.com/trinodb/trino/blob/438/plugin/trino-hive/src/main/java/io/trino/plugin/hive/fs/DirectoryLister.java -// and modified by Doris - -package org.apache.doris.fs; - -import org.apache.doris.catalog.TableIf; -import org.apache.doris.filesystem.FileEntry; -import org.apache.doris.filesystem.FileSystem; -import org.apache.doris.filesystem.FileSystemIOException; -import org.apache.doris.filesystem.RemoteIterator; - -public interface DirectoryLister { - RemoteIterator listFiles(FileSystem fs, boolean recursive, TableIf table, String location) - throws FileSystemIOException; -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/fs/FileSystemDirectoryLister.java b/fe/fe-core/src/main/java/org/apache/doris/fs/FileSystemDirectoryLister.java deleted file mode 100644 index 19c411e37cc67f..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/fs/FileSystemDirectoryLister.java +++ /dev/null @@ -1,42 +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.fs; - -import org.apache.doris.catalog.TableIf; -import org.apache.doris.filesystem.FileEntry; -import org.apache.doris.filesystem.FileSystem; -import org.apache.doris.filesystem.FileSystemIOException; -import org.apache.doris.filesystem.FileSystemTransferUtil; -import org.apache.doris.filesystem.RemoteIterator; -import org.apache.doris.filesystem.SimpleRemoteIterator; - -import java.io.IOException; -import java.util.List; - -public class FileSystemDirectoryLister implements DirectoryLister { - public RemoteIterator listFiles(FileSystem fs, boolean recursive, - TableIf table, String location) - throws FileSystemIOException { - try { - List entries = FileSystemTransferUtil.globList(fs, location, recursive); - return new SimpleRemoteIterator(entries.iterator()); - } catch (IOException ex) { - throw new FileSystemIOException(ex.getMessage(), ex); - } - } -} 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 955e27f9cf99a2..cc236a9fabc1b3 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 @@ -105,6 +105,42 @@ 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 + * directory-loaded object-store providers are visible; otherwise falls back to ServiceLoader + * discovery (unit-test / migration path). Legacy providers without typed binding are skipped + * (see {@link FileSystemPluginManager#bindAll}). Never returns null. + */ + public static List bindAllStorageProperties( + Map properties) { + // Bridge the operator-configured hadoop config dir to filesystem plugins: a plugin leaf cannot import + // fe-core Config, so the HDFS plugin's config-resource loader reads this system property instead. Keep + // the key in sync with HdfsConfigFileLoader.CONFIG_DIR_PROPERTY ("doris.hadoop.config.dir"). + System.setProperty("doris.hadoop.config.dir", Config.hadoop_config_dir); + FileSystemPluginManager mgr = pluginManager; + if (mgr != null) { + return mgr.bindAll(properties); + } + // Fallback: ServiceLoader discovery (unit-test / migration path), mirroring getFileSystem(Map). + List result = new ArrayList<>(); + for (FileSystemProvider provider : getProviders()) { + if (provider.supports(properties)) { + try { + result.add(provider.bind(properties)); + } catch (UnsupportedOperationException e) { + LOG.debug("FileSystemProvider {} has no typed binding; skipping in " + + "bindAllStorageProperties", provider.name()); + } + } + } + return result; + } + /** * SPI entry point accepting legacy {@link StorageProperties}. * Converts via {@link StoragePropertiesConverter} then delegates to diff --git a/fe/fe-core/src/main/java/org/apache/doris/fs/FileSystemPluginManager.java b/fe/fe-core/src/main/java/org/apache/doris/fs/FileSystemPluginManager.java index 51bad3a55a34d3..b9269f27103f30 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/fs/FileSystemPluginManager.java +++ b/fe/fe-core/src/main/java/org/apache/doris/fs/FileSystemPluginManager.java @@ -24,6 +24,7 @@ import org.apache.doris.extension.loader.LoadReport; import org.apache.doris.extension.loader.PluginHandle; import org.apache.doris.filesystem.FileSystem; +import org.apache.doris.filesystem.properties.StorageProperties; import org.apache.doris.filesystem.spi.FileSystemProvider; import org.apache.logging.log4j.LogManager; @@ -135,6 +136,41 @@ public FileSystem createFileSystem(Map properties) throws IOExce + properties.get("_STORAGE_TYPE_") + ". Registered providers: " + providerNames()); } + /** + * Binds the given raw properties against every registered provider that + * {@link FileSystemProvider#supports(Map)}, collecting each provider's typed + * {@link StorageProperties}. + * + *

    Unlike {@link #createFileSystem(Map)} (which uses only the first supporting provider to + * build one runtime FileSystem), this returns ALL supporting providers' bound property models — + * mirroring the legacy {@code StorageProperties.createAll(rawMap)} so a catalog configured with + * more than one backend (e.g. an object store plus HDFS) yields one entry per backend. + * + *

    Providers not yet migrated to typed binding (their {@link FileSystemProvider#bind(Map)} + * still throws {@link UnsupportedOperationException}: broker / local) are skipped — they + * contribute no typed {@code StorageProperties} (the connector handles those backends via raw + * {@code fs.}/{@code dfs.}/{@code hadoop.} passthrough), matching the legacy object-store-only + * Hadoop config helper. (HDFS is migrated: it binds a typed BE model whose {@code toBackendProperties()} + * re-emits the {@code hadoop./dfs./HA/kerberos} backend keys — FU-T01.) Returns an empty list when + * nothing matches. Binding/validation errors from a migrated provider propagate (fail-loud), + * mirroring legacy {@code createAll}. + */ + public List bindAll(Map properties) { + List result = new ArrayList<>(); + for (FileSystemProvider provider : providers) { + if (!provider.supports(properties)) { + continue; + } + try { + result.add(provider.bind(properties)); + } catch (UnsupportedOperationException e) { + LOG.debug("FileSystemProvider {} supports the properties but has no typed binding; " + + "skipping in bindAll", provider.name()); + } + } + return result; + } + /** Registers a provider at highest priority. For testing overrides. */ public void registerProvider(FileSystemProvider provider) { providers.add(0, provider); diff --git a/fe/fe-core/src/main/java/org/apache/doris/fs/SpiSwitchingFileSystem.java b/fe/fe-core/src/main/java/org/apache/doris/fs/SpiSwitchingFileSystem.java index dac4c9071786e7..60f99bad34754d 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/fs/SpiSwitchingFileSystem.java +++ b/fe/fe-core/src/main/java/org/apache/doris/fs/SpiSwitchingFileSystem.java @@ -100,6 +100,7 @@ public FileSystem forPath(String uri) throws IOException { } /** Resolves the appropriate {@link FileSystem} for the given {@link Location}. */ + @Override public FileSystem forLocation(Location location) throws IOException { return resolve(location).fs; } diff --git a/fe/fe-core/src/main/java/org/apache/doris/fs/TransactionDirectoryListingCacheKey.java b/fe/fe-core/src/main/java/org/apache/doris/fs/TransactionDirectoryListingCacheKey.java deleted file mode 100644 index 6be3c03f824d04..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/fs/TransactionDirectoryListingCacheKey.java +++ /dev/null @@ -1,64 +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. -// This file is copied from -// https://github.com/trinodb/trino/blob/438/plugin/trino-hive/src/main/java/io/trino/plugin/hive/fs/TransactionDirectoryListingCacheKey.java -// and modified by Doris - -package org.apache.doris.fs; - -import java.util.Objects; - -public class TransactionDirectoryListingCacheKey { - - private final long transactionId; - private final String path; - - public TransactionDirectoryListingCacheKey(long transactionId, String path) { - this.transactionId = transactionId; - this.path = Objects.requireNonNull(path, "path is null"); - } - - public String getPath() { - return path; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - TransactionDirectoryListingCacheKey that = (TransactionDirectoryListingCacheKey) o; - return transactionId == that.transactionId && path.equals(that.path); - } - - @Override - public int hashCode() { - return Objects.hash(transactionId, path); - } - - @Override - public String toString() { - final StringBuilder sb = new StringBuilder("TransactionDirectoryListingCacheKey{"); - sb.append("transactionId=").append(transactionId); - sb.append(", path='").append(path).append('\''); - sb.append('}'); - return sb.toString(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/fs/TransactionScopeCachingDirectoryLister.java b/fe/fe-core/src/main/java/org/apache/doris/fs/TransactionScopeCachingDirectoryLister.java deleted file mode 100644 index 24979236c6d03a..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/fs/TransactionScopeCachingDirectoryLister.java +++ /dev/null @@ -1,226 +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. -// This file is copied from -// https://github.com/trinodb/trino/blob/438/plugin/trino-hive/src/main/java/io/trino/plugin/hive/fs/TransactionScopeCachingDirectoryLister.java -// and modified by Doris - -package org.apache.doris.fs; - -import org.apache.doris.catalog.TableIf; -import org.apache.doris.filesystem.FileEntry; -import org.apache.doris.filesystem.FileSystemIOException; -import org.apache.doris.filesystem.RemoteIterator; -import org.apache.doris.filesystem.SimpleRemoteIterator; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Preconditions; -import com.google.common.base.Throwables; -import com.google.common.cache.Cache; -import com.google.common.util.concurrent.UncheckedExecutionException; -import com.google.errorprone.annotations.concurrent.GuardedBy; -import org.apache.commons.collections4.ListUtils; - -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.NoSuchElementException; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.ExecutionException; -import javax.annotation.Nullable; - -/** - * Caches directory content (including listings that were started concurrently). - * {@link TransactionScopeCachingDirectoryLister} assumes that all listings - * are performed by same user within single transaction, therefore any failure can - * be shared between concurrent listings. - */ -public class TransactionScopeCachingDirectoryLister implements DirectoryLister { - private final long transactionId; - - @VisibleForTesting - public Cache getCache() { - return cache; - } - - //TODO use a cache key based on Path & SchemaTableName and iterate over the cache keys - // to deal more efficiently with cache invalidation scenarios for partitioned tables. - private final Cache cache; - private final DirectoryLister delegate; - - public TransactionScopeCachingDirectoryLister(DirectoryLister delegate, long transactionId, - Cache cache) { - this.delegate = Objects.requireNonNull(delegate, "delegate is null"); - this.transactionId = transactionId; - this.cache = Objects.requireNonNull(cache, "cache is null"); - } - - @Override - public RemoteIterator listFiles(org.apache.doris.filesystem.FileSystem fs, boolean recursive, - TableIf table, String location) - throws FileSystemIOException { - return listInternal(fs, recursive, table, new TransactionDirectoryListingCacheKey(transactionId, location)); - } - - private RemoteIterator listInternal(org.apache.doris.filesystem.FileSystem fs, - boolean recursive, TableIf table, - TransactionDirectoryListingCacheKey cacheKey) - throws FileSystemIOException { - FetchingValueHolder cachedValueHolder; - try { - cachedValueHolder = cache.get(cacheKey, - () -> new FetchingValueHolder(createListingRemoteIterator(fs, recursive, table, cacheKey))); - } catch (ExecutionException | UncheckedExecutionException e) { - Throwable throwable = e.getCause(); - Throwables.throwIfInstanceOf(throwable, FileSystemIOException.class); - Throwables.throwIfUnchecked(throwable); - throw new RuntimeException("Failed to list directory: " + cacheKey.getPath(), throwable); - } - - if (cachedValueHolder.isFullyCached()) { - return new SimpleRemoteIterator(cachedValueHolder.getCachedFiles()); - } - - return cachingRemoteIterator(cachedValueHolder, cacheKey); - } - - private RemoteIterator createListingRemoteIterator( - org.apache.doris.filesystem.FileSystem fs, - boolean recursive, - TableIf table, - TransactionDirectoryListingCacheKey cacheKey) - throws FileSystemIOException { - return delegate.listFiles(fs, recursive, table, cacheKey.getPath()); - } - - - private RemoteIterator cachingRemoteIterator(FetchingValueHolder cachedValueHolder, - TransactionDirectoryListingCacheKey cacheKey) { - return new RemoteIterator() { - private int fileIndex; - - @Override - public boolean hasNext() - throws FileSystemIOException { - try { - boolean hasNext = cachedValueHolder.getCachedFile(fileIndex).isPresent(); - // Update cache weight of cachedValueHolder for a given path. - // The cachedValueHolder acts as an invalidation guard. - // If a cache invalidation happens while this iterator goes over the files from the specified path, - // the eventually outdated file listing will not be added anymore to the cache. - cache.asMap().replace(cacheKey, cachedValueHolder, cachedValueHolder); - return hasNext; - } catch (Exception exception) { - // invalidate cached value to force retry of directory listing - cache.invalidate(cacheKey); - throw exception; - } - } - - @Override - public FileEntry next() - throws FileSystemIOException { - // force cache entry weight update in case next file is cached - Preconditions.checkState(hasNext()); - return cachedValueHolder.getCachedFile(fileIndex++).orElseThrow(NoSuchElementException::new); - } - }; - } - - @VisibleForTesting - boolean isCached(String location) { - return isCached(new TransactionDirectoryListingCacheKey(transactionId, location)); - } - - @VisibleForTesting - boolean isCached(TransactionDirectoryListingCacheKey cacheKey) { - FetchingValueHolder cached = cache.getIfPresent(cacheKey); - return cached != null && cached.isFullyCached(); - } - - static class FetchingValueHolder { - - private final List cachedFiles = ListUtils.synchronizedList(new ArrayList()); - - @GuardedBy("this") - @Nullable - private RemoteIterator fileIterator; - @GuardedBy("this") - @Nullable - private Exception exception; - - public FetchingValueHolder(RemoteIterator fileIterator) { - this.fileIterator = Objects.requireNonNull(fileIterator, "fileIterator is null"); - } - - public synchronized boolean isFullyCached() { - return fileIterator == null && exception == null; - } - - public long getCacheFileCount() { - return cachedFiles.size(); - } - - public Iterator getCachedFiles() { - Preconditions.checkState(isFullyCached()); - return cachedFiles.iterator(); - } - - public Optional getCachedFile(int index) - throws FileSystemIOException { - int filesSize = cachedFiles.size(); - Preconditions.checkArgument(index >= 0 && index <= filesSize, - "File index (%s) out of bounds [0, %s]", index, filesSize); - - // avoid fileIterator synchronization (and thus blocking) for already cached files - if (index < filesSize) { - return Optional.of(cachedFiles.get(index)); - } - - return fetchNextCachedFile(index); - } - - private synchronized Optional fetchNextCachedFile(int index) - throws FileSystemIOException { - if (exception != null) { - throw new FileSystemIOException("Exception while listing directory", exception); - } - - if (index < cachedFiles.size()) { - // file was fetched concurrently - return Optional.of(cachedFiles.get(index)); - } - - try { - if (fileIterator == null || !fileIterator.hasNext()) { - // no more files - fileIterator = null; - return Optional.empty(); - } - - FileEntry fileStatus = fileIterator.next(); - cachedFiles.add(fileStatus); - return Optional.of(fileStatus); - } catch (Exception exception) { - fileIterator = null; - this.exception = exception; - throw exception; - } - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/fs/TransactionScopeCachingDirectoryListerFactory.java b/fe/fe-core/src/main/java/org/apache/doris/fs/TransactionScopeCachingDirectoryListerFactory.java deleted file mode 100644 index f75e68e89042da..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/fs/TransactionScopeCachingDirectoryListerFactory.java +++ /dev/null @@ -1,59 +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. -// This file is copied from -// https://github.com/trinodb/trino/blob/438/plugin/trino-hive/src/main/java/io/trino/plugin/hive/fs/TransactionScopeCachingDirectoryListerFactory.java -// and modified by Doris - -package org.apache.doris.fs; - -import org.apache.doris.common.EvictableCacheBuilder; -import org.apache.doris.fs.TransactionScopeCachingDirectoryLister.FetchingValueHolder; - -import com.google.common.cache.Cache; - -import java.util.Optional; -import java.util.concurrent.atomic.AtomicLong; - -public class TransactionScopeCachingDirectoryListerFactory { - //TODO use a cache key based on Path & SchemaTableName and iterate over the cache keys - // to deal more efficiently with cache invalidation scenarios for partitioned tables. - // private final Optional> cache; - - private final Optional> cache; - - private final AtomicLong nextTransactionId = new AtomicLong(); - - public TransactionScopeCachingDirectoryListerFactory(long maxSize) { - if (maxSize > 0) { - EvictableCacheBuilder cacheBuilder = - EvictableCacheBuilder.newBuilder() - .maximumWeight(maxSize) - .weigher((key, value) -> - Math.toIntExact(value.getCacheFileCount())); - this.cache = Optional.of(cacheBuilder.build()); - } else { - cache = Optional.empty(); - } - } - - public DirectoryLister get(DirectoryLister delegate) { - return cache - .map(cache -> (DirectoryLister) new TransactionScopeCachingDirectoryLister(delegate, - nextTransactionId.getAndIncrement(), cache)) - .orElse(delegate); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/RestBaseController.java b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/RestBaseController.java index b3d3d284a706e4..dd77b33d3a4b10 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/RestBaseController.java +++ b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/RestBaseController.java @@ -36,7 +36,6 @@ import com.google.common.base.Strings; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import jline.internal.Nullable; import org.apache.commons.codec.digest.DigestUtils; import org.apache.http.conn.ssl.NoopHostnameVerifier; import org.apache.logging.log4j.LogManager; @@ -61,6 +60,7 @@ import java.net.URISyntaxException; import java.util.Collections; import java.util.stream.Collectors; +import javax.annotation.Nullable; import javax.net.ssl.HttpsURLConnection; public class RestBaseController extends BaseController { 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 932db86d276ac7..48f6947bcda402 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 @@ -21,7 +21,7 @@ import org.apache.doris.common.Config; import org.apache.doris.common.util.JsonUtil; import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.httpv2.entity.ResponseEntityBuilder; import org.apache.doris.httpv2.rest.RestBaseController; diff --git a/fe/fe-core/src/main/java/org/apache/doris/journal/JournalEntity.java b/fe/fe-core/src/main/java/org/apache/doris/journal/JournalEntity.java index 2c92775bc3f1cf..95c40809d00832 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/journal/JournalEntity.java +++ b/fe/fe-core/src/main/java/org/apache/doris/journal/JournalEntity.java @@ -44,11 +44,11 @@ import org.apache.doris.common.util.SmallFileMgr.SmallFile; import org.apache.doris.cooldown.CooldownConfList; import org.apache.doris.cooldown.CooldownDelete; -import org.apache.doris.datasource.CatalogLog; -import org.apache.doris.datasource.ExternalObjectLog; -import org.apache.doris.datasource.InitCatalogLog; -import org.apache.doris.datasource.InitDatabaseLog; -import org.apache.doris.datasource.MetaIdMappingsLog; +import org.apache.doris.datasource.log.CatalogLog; +import org.apache.doris.datasource.log.ExternalObjectLog; +import org.apache.doris.datasource.log.InitCatalogLog; +import org.apache.doris.datasource.log.InitDatabaseLog; +import org.apache.doris.datasource.log.MetaIdMappingsLog; import org.apache.doris.ha.MasterInfo; import org.apache.doris.indexpolicy.DropIndexPolicyLog; import org.apache.doris.indexpolicy.IndexPolicy; diff --git a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPartitionUtil.java b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPartitionUtil.java index aea97a2dc875cc..d2bcfc4ca69760 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPartitionUtil.java +++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPartitionUtil.java @@ -19,6 +19,7 @@ import org.apache.doris.analysis.AllPartitionDesc; import org.apache.doris.analysis.PartitionKeyDesc; +import org.apache.doris.analysis.PartitionValue; import org.apache.doris.analysis.SinglePartitionDesc; import org.apache.doris.catalog.Column; import org.apache.doris.catalog.Database; @@ -65,6 +66,12 @@ public class MTMVPartitionUtil { private static final Logger LOG = LogManager.getLogger(MTMVPartitionUtil.class); private static final Pattern PARTITION_NAME_PATTERN = Pattern.compile("[^a-zA-Z0-9,]"); private static final String PARTITION_NAME_PREFIX = "p_"; + // A genuine-NULL list value and a literal 'NULL' string both render to the text "NULL" once + // PartitionKeyDesc quotes every value and PARTITION_NAME_PATTERN strips the quotes, so a column holding + // both would generate two partitions named p_NULL and fail the uniqueness check. Null-bearing partitions + // use this "pn_" prefix instead: string values always yield a "p_" name (second char '_'), so a "pn_" + // name (second char 'n') can never collide, keeping the real-NULL and 'NULL'-string partitions distinct. + private static final String PARTITION_NAME_NULL_PREFIX = "pn_"; private static final List partitionDescGenerators = ImmutableList .of( @@ -363,7 +370,8 @@ public static boolean isSyncWithPartitions(MTMVRefreshContext context, String mt */ public static String generatePartitionName(PartitionKeyDesc desc) { Matcher matcher = PARTITION_NAME_PATTERN.matcher(desc.toSql()); - String partitionName = PARTITION_NAME_PREFIX + matcher.replaceAll("").replaceAll("\\,", "_"); + String prefix = hasNullPartitionValue(desc) ? PARTITION_NAME_NULL_PREFIX : PARTITION_NAME_PREFIX; + String partitionName = prefix + matcher.replaceAll("").replaceAll("\\,", "_"); if (partitionName.length() > 50) { partitionName = partitionName.substring(0, 30) + Math.abs(Objects.hash(partitionName)) + "_" + System.currentTimeMillis(); @@ -371,6 +379,26 @@ public static String generatePartitionName(PartitionKeyDesc desc) { return partitionName; } + /** + * Whether the list-partition desc carries a genuine-NULL value ({@link PartitionValue#isNullPartition()}). + * Such a partition must be named with {@link #PARTITION_NAME_NULL_PREFIX} so it never collides with a + * literal 'NULL' string partition (both otherwise render to the same p_NULL name). Range/other descs have + * no in-values and are never null-bearing here. + */ + private static boolean hasNullPartitionValue(PartitionKeyDesc desc) { + if (!desc.hasInValues()) { + return false; + } + for (List values : desc.getInValues()) { + for (PartitionValue value : values) { + if (value.isNullPartition()) { + return true; + } + } + } + return false; + } + /** * drop partition of mtmv * diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java index e532ce611fe8dc..aeb58b771c6bbf 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java @@ -26,7 +26,7 @@ import org.apache.doris.catalog.TableIf.TableType; import org.apache.doris.common.Pair; import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.hive.HMSExternalTable; +import org.apache.doris.mtmv.MTMVRelatedTableIf; import org.apache.doris.mysql.FieldInfo; import org.apache.doris.mysql.privilege.Auth; import org.apache.doris.mysql.privilege.DataMaskPolicy; @@ -193,9 +193,17 @@ public synchronized void addUsedTable(TableIf tableIf) { try { if (tableIf instanceof OlapTable) { version = ((OlapTable) tableIf).getVisibleVersion(); - } else if (tableIf instanceof HMSExternalTable) { - HMSExternalTable hmsExternalTable = (HMSExternalTable) tableIf; - version = hmsExternalTable.getUpdateTime(); + } else if (tableIf instanceof MTMVRelatedTableIf) { + // Connector-agnostic data-version token (hive: max transient_lastDdlTime; iceberg/paimon: + // monotonic snapshot version). OlapTable is handled above; MTMV is an OlapTable too, so this + // arm only catches external MVCC tables (flipped hive/iceberg/paimon/hudi). + version = ((MTMVRelatedTableIf) tableIf).getNewestUpdateVersionOrTime(); + if (version <= 0) { + // No reliable data-change signal (empty partition set / dropped): fail-safe, do not + // pin a bogus constant that would serve stale results. + setHasUnsupportedTables(true); + return; + } } } catch (Throwable e) { // in cloud, getVisibleVersion throw exception, disable sql cache temporary diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java index cd6a985218dd60..5541d8ae57dee4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java @@ -30,6 +30,9 @@ import org.apache.doris.common.Id; import org.apache.doris.common.IdGenerator; import org.apache.doris.common.Pair; +import org.apache.doris.connector.ConnectorStatementScopeImpl; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.handle.ConnectorTransaction; import org.apache.doris.datasource.ExternalTable; import org.apache.doris.datasource.mvcc.MvccSnapshot; import org.apache.doris.datasource.mvcc.MvccTable; @@ -197,6 +200,14 @@ public enum TableFrom { private final Set keySlots = Sets.newHashSet(); private BitSet disableRules; + // A per-statement memoization arena for connectors: e.g. Iceberg loads a table once and shares that + // single object across read + write resolvers within the statement. Lazily built (see + // getOrCreateConnectorStatementScope), values stored opaquely by the connector. Like snapshots, it is + // NOT cleared on close()/releasePlannerResources -- it survives to statement GC. A reused + // prepared-statement context drops it per execution via resetConnectorStatementScope() (see + // ExecuteCommand) so one execution's cached tables never leak into the next. + private ConnectorStatementScope connectorStatementScope; + // table locks private final Stack plannerResources = new Stack<>(); @@ -316,13 +327,15 @@ public enum TableFrom { private boolean isInsert = false; private Optional>> mvRefreshPredicates = Optional.empty(); - // For Iceberg rewrite operations: store file scan tasks to be used by - // IcebergScanNode - // TODO: better solution? - private List icebergRewriteFileScanTasks = null; - // For Iceberg rewrite operations: control whether to use GATHER distribution - // When true, data will be collected to a single node to avoid generating too many small files - private boolean useGatherForIcebergRewrite = false; + // The RAW data-file paths a distributed rewrite_data_files group must scope its scan to. + // PluginDrivenScanNode reads it and pins it onto the connector scan handle + // (metadata.applyRewriteFileScope), the engine-neutral rewrite scoping path. + private List rewriteSourceFilePaths = null; + // Post-flip neutral counterpart of the legacy shared rewrite transaction: the one connector transaction a + // distributed rewrite_data_files driver opens and shares across the N per-group INSERT-SELECTs. The + // neutral rewrite executor (ConnectorRewriteExecutor) reads it here and binds it onto its sink session so + // the connector's planWrite resolves the SAME transaction (rather than each group opening its own). + private ConnectorTransaction rewriteSharedTransaction = null; private boolean hasNestedColumns; private boolean queryStatsRecorded = false; @@ -634,6 +647,35 @@ public synchronized BitSet getOrCacheDisableRules(SessionVariable sessionVariabl return this.disableRules; } + /** + * Returns this statement's connector scope, lazily creating it on first use (mirrors + * {@link #getOrCacheDisableRules}). A connector reaches it through + * {@link org.apache.doris.connector.api.ConnectorSession#getStatementScope()} to load a table once and + * share it across the statement's read + write resolvers. + */ + public synchronized ConnectorStatementScope getOrCreateConnectorStatementScope() { + if (this.connectorStatementScope == null) { + this.connectorStatementScope = new ConnectorStatementScopeImpl(); + } + return this.connectorStatementScope; + } + + /** + * Closes (deterministically) then drops the connector scope so the next statement execution starts fresh. + * Prepared-statement EXECUTE reuses one StatementContext across executions (see + * {@link org.apache.doris.nereids.trees.plans.commands.ExecuteCommand}) and a retry reuses it across attempts; + * this is called at the start of each, so a prior execution's / attempt's cached tables and closeable state + * are released (closeAll is idempotent — a no-op if the query-finish callback already closed it) and never + * leak into the next. Callers invoke this only after the prior execution/attempt has finished (no off-thread + * pump still running), so close-before-drop is safe. + */ + public synchronized void resetConnectorStatementScope() { + if (this.connectorStatementScope != null) { + this.connectorStatementScope.closeAll(); + } + this.connectorStatementScope = null; + } + /** * Some value of the cacheKey may change, invalid cache when value change */ @@ -942,6 +984,17 @@ protected void finalize() throws Throwable { @Override public void close() { releasePlannerResources(); + // Fallback deterministic close of the per-statement connector scope, for statements that never reach the + // query-finish callback: external DDL / SHOW / DESCRIBE / EXPLAIN / foreground ANALYZE run via Command.run + // with no coordinator, so PluginDrivenScanNode.getSplits never registers a primary close for them. close() + // runs in ConnectProcessor's per-statement finally (direct connection) and the forwarded-request finally + // (master side), so it covers them. Guarded by isReturnResultFromLocal so an arrow-flight statement (which + // returns results asynchronously and defers cleanup to its own query-finish close) is not closed early + // here. Idempotent (closeAll is close-once), so a coordinated statement whose scope the primary already + // closed is a no-op. Command statements have no off-thread scan pump, so close-after-use holds. + if (connectorStatementScope != null && connectContext != null && connectContext.isReturnResultFromLocal()) { + connectorStatementScope.closeAll(); + } } public List getPlaceholders() { @@ -985,7 +1038,8 @@ public void addPlannerHook(PlannerHook plannerHook) { public void loadSnapshots(TableIf specificTable, Optional tableSnapshot, Optional scanParams) { if (specificTable instanceof MvccTable) { - MvccTableInfo mvccTableInfo = new MvccTableInfo(specificTable); + MvccTableInfo mvccTableInfo = new MvccTableInfo(specificTable, + versionKeyOf(tableSnapshot, scanParams)); if (!snapshots.containsKey(mvccTableInfo)) { snapshots.put(mvccTableInfo, ((MvccTable) specificTable).loadSnapshot(tableSnapshot, scanParams)); @@ -994,7 +1048,16 @@ public void loadSnapshots(TableIf specificTable, Optional tableSn } /** - * Obtain snapshot information of mvcc + * Obtain snapshot information of mvcc, version-blind. Used by the metadata/schema/partition readers + * that do not thread the per-reference version. Resolution order: + * (1) the default ("" version) entry if present — covers a plain/latest reference, and is the + * deterministic choice when a statement pins both main and {@code @branch}/{@code @tag} of one table; + * (2) else, if EXACTLY ONE snapshot is pinned for this table (ignoring version) — e.g. a standalone + * {@code @branch}/{@code @tag}/FOR-TIME read — that lone entry, so those readers still see the pinned + * snapshot; (3) else (two or more non-default versions pinned and no default, e.g. {@code t@tag('v1')} + * joined with {@code t@tag('v2')}) the version is ambiguous here, so empty and the caller falls back to + * latest. The scan path always resolves the exact per-reference snapshot via + * {@link #getSnapshot(TableIf, Optional, Optional)} regardless. * * @param tableIf tableIf * @return MvccSnapshot @@ -1003,10 +1066,71 @@ public Optional getSnapshot(TableIf tableIf) { if (!(tableIf instanceof MvccTable)) { return Optional.empty(); } - MvccTableInfo mvccTableInfo = new MvccTableInfo(tableIf); + MvccTableInfo defaultKey = new MvccTableInfo(tableIf); + MvccSnapshot defaultSnapshot = snapshots.get(defaultKey); + if (defaultSnapshot != null) { + return Optional.of(defaultSnapshot); + } + MvccSnapshot only = null; + for (Map.Entry entry : snapshots.entrySet()) { + if (defaultKey.isSameTable(entry.getKey())) { + if (only != null) { + return Optional.empty(); + } + only = entry.getValue(); + } + } + return Optional.ofNullable(only); + } + + /** + * Obtain snapshot information of mvcc, version-aware: resolves the snapshot pinned for the SAME table + * reference (same {@code @branch}/{@code @tag}/FOR-TIME selector) the scan carries, so a statement + * mixing main and {@code @branch} of one table reads each at its own snapshot. Used by the scan path + * ({@code PluginDrivenScanNode.pinMvccSnapshot}); the version key is computed identically to + * {@link #loadSnapshots}, so a pinned reference always hits its own entry. + * + * @param tableIf tableIf + * @param tableSnapshot the reference's FOR VERSION/TIME AS OF selector (if any) + * @param scanParams the reference's {@code @branch}/{@code @tag} selector (if any) + * @return MvccSnapshot + */ + public Optional getSnapshot(TableIf tableIf, Optional tableSnapshot, + Optional scanParams) { + if (!(tableIf instanceof MvccTable)) { + return Optional.empty(); + } + MvccTableInfo mvccTableInfo = new MvccTableInfo(tableIf, versionKeyOf(tableSnapshot, scanParams)); return Optional.ofNullable(snapshots.get(mvccTableInfo)); } + /** + * Derives the version key separating snapshots of the SAME table pinned at different selectors within + * one statement. A FOR VERSION/TIME AS OF selector keys on its type+value; a + * {@code @branch}/{@code @tag}/{@code @incr} selector keys on its paramType + map/list params; a plain + * (latest) reference keys on "". MUST be a pure function of the selector so {@link #loadSnapshots} + * (analysis) and the scan-time lookup compute the SAME key. (Not {@code TableSnapshot.toDigest}, which + * redacts the value to '?'.) + */ + private static String versionKeyOf(Optional tableSnapshot, + Optional scanParams) { + // Concatenate both selectors (rather than returning on the first) so the key stays injective even + // if a reference ever carried BOTH a snapshot and scan params. Today the two are mutually exclusive + // (e.g. IcebergUtils.getQuerySpecSnapshot rejects FOR-TIME together with @branch/@tag), so in every + // reachable case exactly one part is non-empty and the key is "v:...", "p:...", or "". + StringBuilder key = new StringBuilder(); + if (tableSnapshot != null && tableSnapshot.isPresent()) { + TableSnapshot ts = tableSnapshot.get(); + key.append("v:").append(ts.getType()).append(':').append(ts.getValue()); + } + if (scanParams != null && scanParams.isPresent()) { + TableScanParams sp = scanParams.get(); + key.append("p:").append(sp.getParamType()).append(':').append(sp.getMapParams()) + .append(':').append(sp.getListParams()); + } + return key.toString(); + } + /** * Obtain snapshot information of mvcc * @@ -1262,37 +1386,35 @@ public void setMvRefreshPredicates( } /** - * Set file scan tasks for Iceberg rewrite operations. - * This allows IcebergScanNode to use specific file scan tasks instead of - * scanning the full table. + * Set the RAW data-file paths a distributed rewrite group must scope its scan to (post-flip neutral + * path, consumed by {@link org.apache.doris.datasource.scan.PluginDrivenScanNode}). */ - public void setIcebergRewriteFileScanTasks(List tasks) { - this.icebergRewriteFileScanTasks = tasks; + public void setRewriteSourceFilePaths(List paths) { + this.rewriteSourceFilePaths = paths; } /** - * Get and consume file scan tasks for Iceberg rewrite operations. - * Returns the tasks and clears the field to prevent reuse. + * Get the per-group rewrite scan scope. NON-consuming (unlike the legacy iceberg getAndClear): the pin is + * applied at several scan-side handle-consumption points within one statement and must read the same scope + * each time (mirrors the non-consuming MVCC snapshot pin); the per-group StatementContext is single-use, so + * there is no stale-reuse risk. Returns {@code null} when no scope is set (full-table scan). */ - public List getAndClearIcebergRewriteFileScanTasks() { - List tasks = this.icebergRewriteFileScanTasks; - this.icebergRewriteFileScanTasks = null; - return tasks; + public List getRewriteSourceFilePaths() { + return this.rewriteSourceFilePaths; } /** - * Set whether to use GATHER distribution for Iceberg rewrite operations. - * When enabled, data will be collected to a single node to minimize output files. + * Set the shared connector transaction a distributed rewrite group's sink must bind onto its session. */ - public void setUseGatherForIcebergRewrite(boolean useGather) { - this.useGatherForIcebergRewrite = useGather; + public void setRewriteSharedTransaction(ConnectorTransaction transaction) { + this.rewriteSharedTransaction = transaction; } /** - * Check if GATHER distribution should be used for Iceberg rewrite operations. + * Get the shared connector transaction for the current rewrite group (null outside a distributed rewrite). */ - public boolean isUseGatherForIcebergRewrite() { - return this.useGatherForIcebergRewrite; + public ConnectorTransaction getRewriteSharedTransaction() { + return this.rewriteSharedTransaction; } public boolean hasNestedColumns() { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundConnectorTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundConnectorTableSink.java index b9d620a1bd580f..0948a5b5d5cabb 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundConnectorTableSink.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundConnectorTableSink.java @@ -19,6 +19,7 @@ import org.apache.doris.nereids.memo.GroupExpression; import org.apache.doris.nereids.properties.LogicalProperties; +import org.apache.doris.nereids.trees.expressions.Expression; import org.apache.doris.nereids.trees.plans.Plan; import org.apache.doris.nereids.trees.plans.PlanType; import org.apache.doris.nereids.trees.plans.commands.info.DMLCommandType; @@ -26,8 +27,10 @@ import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import java.util.List; +import java.util.Map; import java.util.Optional; /** @@ -36,14 +39,37 @@ */ public class UnboundConnectorTableSink extends UnboundBaseExternalTableSink { + // Static partition spec from INSERT ... PARTITION(col=val); null when none. Mirrors + // UnboundMaxComputeTableSink so plugin-driven MaxCompute keeps static-partition / overwrite + // semantics after the cutover. Consumed via the PluginDrivenInsertCommandContext. + private final Map staticPartitionKeyValues; + + // Rewrite (compaction) marker. Mirrors UnboundIcebergTableSink.rewrite: carried through bind so the + // neutral connector sink chain (Logical/Physical) can force single-node GATHER output for a + // rewrite_data_files INSERT-SELECT (controls output file count). Defaults false; set true only by the + // distributed rewrite coordinator. Always false for ordinary INSERT, so this is dormant pre-cutover. + private final boolean rewrite; + public UnboundConnectorTableSink(List nameParts, List colNames, List hints, List partitions, CHILD_TYPE child) { this(nameParts, colNames, hints, partitions, DMLCommandType.NONE, - Optional.empty(), Optional.empty(), child); + Optional.empty(), Optional.empty(), child, null); + } + + public UnboundConnectorTableSink(List nameParts, + List colNames, + List hints, + List partitions, + DMLCommandType dmlCommandType, + Optional groupExpression, + Optional logicalProperties, + CHILD_TYPE child) { + this(nameParts, colNames, hints, partitions, dmlCommandType, + groupExpression, logicalProperties, child, null); } /** - * constructor + * constructor with static partition */ public UnboundConnectorTableSink(List nameParts, List colNames, @@ -52,9 +78,43 @@ public UnboundConnectorTableSink(List nameParts, DMLCommandType dmlCommandType, Optional groupExpression, Optional logicalProperties, - CHILD_TYPE child) { + CHILD_TYPE child, + Map staticPartitionKeyValues) { + this(nameParts, colNames, hints, partitions, dmlCommandType, + groupExpression, logicalProperties, child, staticPartitionKeyValues, false); + } + + /** + * constructor with static partition and rewrite flag + */ + public UnboundConnectorTableSink(List nameParts, + List colNames, + List hints, + List partitions, + DMLCommandType dmlCommandType, + Optional groupExpression, + Optional logicalProperties, + CHILD_TYPE child, + Map staticPartitionKeyValues, + boolean rewrite) { super(nameParts, PlanType.LOGICAL_UNBOUND_CONNECTOR_TABLE_SINK, ImmutableList.of(), groupExpression, logicalProperties, colNames, dmlCommandType, child, hints, partitions); + this.staticPartitionKeyValues = staticPartitionKeyValues != null + ? ImmutableMap.copyOf(staticPartitionKeyValues) + : null; + this.rewrite = rewrite; + } + + public Map getStaticPartitionKeyValues() { + return staticPartitionKeyValues; + } + + public boolean isRewrite() { + return rewrite; + } + + public boolean hasStaticPartition() { + return staticPartitionKeyValues != null && !staticPartitionKeyValues.isEmpty(); } @Override @@ -67,19 +127,20 @@ public Plan withChildren(List children) { Preconditions.checkArgument(children.size() == 1, "UnboundConnectorTableSink only accepts one child"); return new UnboundConnectorTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, groupExpression, Optional.empty(), children.get(0)); + dmlCommandType, groupExpression, Optional.empty(), children.get(0), staticPartitionKeyValues, rewrite); } @Override public Plan withGroupExpression(Optional groupExpression) { return new UnboundConnectorTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, groupExpression, Optional.of(getLogicalProperties()), child()); + dmlCommandType, groupExpression, Optional.of(getLogicalProperties()), child(), + staticPartitionKeyValues, rewrite); } @Override public Plan withGroupExprLogicalPropChildren(Optional groupExpression, Optional logicalProperties, List children) { return new UnboundConnectorTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, groupExpression, logicalProperties, children.get(0)); + dmlCommandType, groupExpression, logicalProperties, children.get(0), staticPartitionKeyValues, rewrite); } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundHiveTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundHiveTableSink.java deleted file mode 100644 index 4ffbc0230a005e..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundHiveTableSink.java +++ /dev/null @@ -1,84 +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.analyzer; - -import org.apache.doris.nereids.memo.GroupExpression; -import org.apache.doris.nereids.properties.LogicalProperties; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.PlanType; -import org.apache.doris.nereids.trees.plans.commands.info.DMLCommandType; -import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; - -import java.util.List; -import java.util.Optional; - -/** - * Represent a hive table sink plan node that has not been bound. - */ -public class UnboundHiveTableSink extends UnboundBaseExternalTableSink { - - public UnboundHiveTableSink(List nameParts, List colNames, List hints, - List partitions, CHILD_TYPE child) { - this(nameParts, colNames, hints, partitions, DMLCommandType.NONE, - Optional.empty(), Optional.empty(), child); - } - - /** - * constructor - */ - public UnboundHiveTableSink(List nameParts, - List colNames, - List hints, - List partitions, - DMLCommandType dmlCommandType, - Optional groupExpression, - Optional logicalProperties, - CHILD_TYPE child) { - super(nameParts, PlanType.LOGICAL_UNBOUND_HIVE_TABLE_SINK, ImmutableList.of(), groupExpression, - logicalProperties, colNames, dmlCommandType, child, hints, partitions); - } - - @Override - public R accept(PlanVisitor visitor, C context) { - return visitor.visitUnboundHiveTableSink(this, context); - } - - @Override - public Plan withChildren(List children) { - Preconditions.checkArgument(children.size() == 1, - "UnboundHiveTableSink only accepts one child"); - return new UnboundHiveTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, groupExpression, Optional.empty(), children.get(0)); - } - - @Override - public Plan withGroupExpression(Optional groupExpression) { - return new UnboundHiveTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, groupExpression, Optional.of(getLogicalProperties()), child()); - } - - @Override - public Plan withGroupExprLogicalPropChildren(Optional groupExpression, - Optional logicalProperties, List children) { - return new UnboundHiveTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, groupExpression, logicalProperties, children.get(0)); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundMaxComputeTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundMaxComputeTableSink.java deleted file mode 100644 index bb397a6bc35a19..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundMaxComputeTableSink.java +++ /dev/null @@ -1,117 +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.analyzer; - -import org.apache.doris.nereids.memo.GroupExpression; -import org.apache.doris.nereids.properties.LogicalProperties; -import org.apache.doris.nereids.trees.expressions.Expression; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.PlanType; -import org.apache.doris.nereids.trees.plans.commands.info.DMLCommandType; -import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -/** - * Represent a MaxCompute table sink plan node that has not been bound. - */ -public class UnboundMaxComputeTableSink extends UnboundBaseExternalTableSink { - - private final Map staticPartitionKeyValues; - - public UnboundMaxComputeTableSink(List nameParts, List colNames, List hints, - List partitions, CHILD_TYPE child) { - this(nameParts, colNames, hints, partitions, DMLCommandType.NONE, - Optional.empty(), Optional.empty(), child, null); - } - - /** - * constructor - */ - public UnboundMaxComputeTableSink(List nameParts, - List colNames, - List hints, - List partitions, - DMLCommandType dmlCommandType, - Optional groupExpression, - Optional logicalProperties, - CHILD_TYPE child) { - this(nameParts, colNames, hints, partitions, dmlCommandType, - groupExpression, logicalProperties, child, null); - } - - /** - * constructor with static partition - */ - public UnboundMaxComputeTableSink(List nameParts, - List colNames, - List hints, - List partitions, - DMLCommandType dmlCommandType, - Optional groupExpression, - Optional logicalProperties, - CHILD_TYPE child, - Map staticPartitionKeyValues) { - super(nameParts, PlanType.LOGICAL_UNBOUND_MAX_COMPUTE_TABLE_SINK, ImmutableList.of(), groupExpression, - logicalProperties, colNames, dmlCommandType, child, hints, partitions); - this.staticPartitionKeyValues = staticPartitionKeyValues != null - ? ImmutableMap.copyOf(staticPartitionKeyValues) - : null; - } - - public Map getStaticPartitionKeyValues() { - return staticPartitionKeyValues; - } - - public boolean hasStaticPartition() { - return staticPartitionKeyValues != null && !staticPartitionKeyValues.isEmpty(); - } - - @Override - public Plan withChildren(List children) { - Preconditions.checkArgument(children.size() == 1, - "UnboundMaxComputeTableSink only accepts one child"); - return new UnboundMaxComputeTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, groupExpression, Optional.empty(), children.get(0), staticPartitionKeyValues); - } - - @Override - public R accept(PlanVisitor visitor, C context) { - return visitor.visitUnboundMaxComputeTableSink(this, context); - } - - @Override - public Plan withGroupExpression(Optional groupExpression) { - return new UnboundMaxComputeTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, groupExpression, Optional.of(getLogicalProperties()), child(), - staticPartitionKeyValues); - } - - @Override - public Plan withGroupExprLogicalPropChildren(Optional groupExpression, - Optional logicalProperties, List children) { - return new UnboundMaxComputeTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, groupExpression, logicalProperties, children.get(0), staticPartitionKeyValues); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundTableSinkCreator.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundTableSinkCreator.java index ff0cfc71264a12..5716e23d15b23a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundTableSinkCreator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundTableSinkCreator.java @@ -21,11 +21,8 @@ import org.apache.doris.common.UserException; import org.apache.doris.datasource.CatalogIf; import org.apache.doris.datasource.InternalCatalog; -import org.apache.doris.datasource.PluginDrivenExternalCatalog; import org.apache.doris.datasource.doris.RemoteDorisExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.dictionary.Dictionary; import org.apache.doris.nereids.exceptions.AnalysisException; import org.apache.doris.nereids.exceptions.ParseException; @@ -59,12 +56,6 @@ public static LogicalSink createUnboundTableSink(List na CatalogIf curCatalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(catalogName); if (curCatalog instanceof InternalCatalog) { return new UnboundTableSink<>(nameParts, colNames, hints, partitions, query); - } else if (curCatalog instanceof HMSExternalCatalog) { - return new UnboundHiveTableSink<>(nameParts, colNames, hints, partitions, query); - } else if (curCatalog instanceof IcebergExternalCatalog) { - return new UnboundIcebergTableSink<>(nameParts, colNames, hints, partitions, query); - } else if (curCatalog instanceof MaxComputeExternalCatalog) { - return new UnboundMaxComputeTableSink<>(nameParts, colNames, hints, partitions, query); } else if (curCatalog instanceof PluginDrivenExternalCatalog) { return new UnboundConnectorTableSink<>(nameParts, colNames, hints, partitions, query); } @@ -96,18 +87,9 @@ public static LogicalSink createUnboundTableSink(List na return new UnboundTableSink<>(nameParts, colNames, hints, temporaryPartition, partitions, isPartialUpdate, partialUpdateNewKeyPolicy, dmlCommandType, Optional.empty(), Optional.empty(), plan); - } else if (curCatalog instanceof HMSExternalCatalog) { - return new UnboundHiveTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, Optional.empty(), Optional.empty(), plan); - } else if (curCatalog instanceof IcebergExternalCatalog) { - return new UnboundIcebergTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, Optional.empty(), Optional.empty(), plan, staticPartitionKeyValues, false); - } else if (curCatalog instanceof MaxComputeExternalCatalog) { - return new UnboundMaxComputeTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, Optional.empty(), Optional.empty(), plan, staticPartitionKeyValues); } else if (curCatalog instanceof PluginDrivenExternalCatalog) { return new UnboundConnectorTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, Optional.empty(), Optional.empty(), plan); + dmlCommandType, Optional.empty(), Optional.empty(), plan, staticPartitionKeyValues); } throw new RuntimeException("Load data to " + curCatalog.getClass().getSimpleName() + " is not supported."); } @@ -137,18 +119,9 @@ public static LogicalSink createUnboundTableSinkMaybeOverwrite(L isAutoDetectPartition, isPartialUpdate, partialUpdateNewKeyPolicy, dmlCommandType, Optional.empty(), Optional.empty(), plan); - } else if (curCatalog instanceof HMSExternalCatalog && !isAutoDetectPartition) { - return new UnboundHiveTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, Optional.empty(), Optional.empty(), plan); - } else if (curCatalog instanceof IcebergExternalCatalog && !isAutoDetectPartition) { - return new UnboundIcebergTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, Optional.empty(), Optional.empty(), plan, staticPartitionKeyValues, false); - } else if (curCatalog instanceof MaxComputeExternalCatalog && !isAutoDetectPartition) { - return new UnboundMaxComputeTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, Optional.empty(), Optional.empty(), plan, staticPartitionKeyValues); } else if (curCatalog instanceof PluginDrivenExternalCatalog && !isAutoDetectPartition) { return new UnboundConnectorTableSink<>(nameParts, colNames, hints, partitions, - dmlCommandType, Optional.empty(), Optional.empty(), plan); + dmlCommandType, Optional.empty(), Optional.empty(), plan, staticPartitionKeyValues); } throw new AnalysisException( 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 f42112d1e11d28..246d9271b1bff1 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java @@ -39,7 +39,6 @@ import org.apache.doris.catalog.KeysType; import org.apache.doris.catalog.OlapTable; import org.apache.doris.catalog.TableIf; -import org.apache.doris.common.Config; import org.apache.doris.common.Pair; import org.apache.doris.common.util.Util; import org.apache.doris.connector.api.Connector; @@ -48,33 +47,19 @@ 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.write.ConnectorWriteConfig; +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.FileQueryScanNode; -import org.apache.doris.datasource.PluginDrivenExternalCatalog; -import org.apache.doris.datasource.PluginDrivenExternalTable; -import org.apache.doris.datasource.PluginDrivenScanNode; import org.apache.doris.datasource.doris.RemoteDorisExternalTable; import org.apache.doris.datasource.doris.RemoteOlapTable; import org.apache.doris.datasource.doris.source.RemoteDorisScanNode; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HMSExternalTable.DLAType; -import org.apache.doris.datasource.hive.source.HiveScanNode; -import org.apache.doris.datasource.hudi.source.HudiScanNode; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergMergeOperation; -import org.apache.doris.datasource.iceberg.IcebergSysExternalTable; -import org.apache.doris.datasource.iceberg.source.IcebergScanNode; -import org.apache.doris.datasource.lakesoul.LakeSoulExternalTable; -import org.apache.doris.datasource.lakesoul.source.LakeSoulScanNode; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalTable; -import org.apache.doris.datasource.maxcompute.source.MaxComputeScanNode; -import org.apache.doris.datasource.paimon.source.PaimonScanNode; -import org.apache.doris.datasource.trinoconnector.TrinoConnectorExternalTable; -import org.apache.doris.datasource.trinoconnector.source.TrinoConnectorScanNode; -import org.apache.doris.fs.DirectoryLister; -import org.apache.doris.fs.FileSystemDirectoryLister; -import org.apache.doris.fs.TransactionScopeCachingDirectoryListerFactory; +import org.apache.doris.datasource.mvcc.MvccUtil; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; +import org.apache.doris.datasource.scan.FileQueryScanNode; +import org.apache.doris.datasource.scan.PluginDrivenScanNode; import org.apache.doris.nereids.exceptions.AnalysisException; import org.apache.doris.nereids.properties.DistributionSpec; import org.apache.doris.nereids.properties.DistributionSpecAllSingleton; @@ -119,9 +104,11 @@ import org.apache.doris.nereids.trees.plans.PreAggStatus; import org.apache.doris.nereids.trees.plans.algebra.Aggregate; import org.apache.doris.nereids.trees.plans.algebra.Relation; +import org.apache.doris.nereids.trees.plans.commands.merge.MergeOperation; import org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalJoin; import org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalSort; import org.apache.doris.nereids.trees.plans.physical.PhysicalAssertNumRows; +import org.apache.doris.nereids.trees.plans.physical.PhysicalBaseExternalTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalBlackholeSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalBucketedHashAggregate; import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEAnchor; @@ -138,17 +125,13 @@ import org.apache.doris.nereids.trees.plans.physical.PhysicalGenerate; import org.apache.doris.nereids.trees.plans.physical.PhysicalHashAggregate; import org.apache.doris.nereids.trees.plans.physical.PhysicalHashJoin; -import org.apache.doris.nereids.trees.plans.physical.PhysicalHiveTableSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalHudiScan; import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergDeleteSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergMergeSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalIntersect; import org.apache.doris.nereids.trees.plans.physical.PhysicalLazyMaterialize; import org.apache.doris.nereids.trees.plans.physical.PhysicalLazyMaterializeOlapScan; import org.apache.doris.nereids.trees.plans.physical.PhysicalLazyMaterializeTVFScan; import org.apache.doris.nereids.trees.plans.physical.PhysicalLimit; -import org.apache.doris.nereids.trees.plans.physical.PhysicalMaxComputeTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalNestedLoopJoin; import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapScan; import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapTableSink; @@ -200,14 +183,9 @@ import org.apache.doris.planner.ExchangeNode; import org.apache.doris.planner.GroupCommitBlockSink; import org.apache.doris.planner.HashJoinNode; -import org.apache.doris.planner.HiveTableSink; -import org.apache.doris.planner.IcebergDeleteSink; -import org.apache.doris.planner.IcebergMergeSink; -import org.apache.doris.planner.IcebergTableSink; import org.apache.doris.planner.IntersectNode; import org.apache.doris.planner.JoinNodeBase; import org.apache.doris.planner.MaterializationNode; -import org.apache.doris.planner.MaxComputeTableSink; import org.apache.doris.planner.MultiCastDataSink; import org.apache.doris.planner.MultiCastPlanFragment; import org.apache.doris.planner.NestedLoopJoinNode; @@ -238,6 +216,7 @@ import org.apache.doris.thrift.TPartitionType; import org.apache.doris.thrift.TPushAggOp; import org.apache.doris.thrift.TResultSinkType; +import org.apache.doris.thrift.TSortInfo; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; @@ -285,8 +264,6 @@ public class PhysicalPlanTranslator extends DefaultPlanVisitor hiveTableSink, - PlanTranslatorContext context) { - PlanFragment rootFragment = hiveTableSink.child().accept(this, context); - rootFragment.setOutputPartition(DataPartition.UNPARTITIONED); - HiveTableSink sink = new HiveTableSink((HMSExternalTable) hiveTableSink.getTargetTable()); - rootFragment.setSink(sink); - return rootFragment; - } - - @Override - public PlanFragment visitPhysicalIcebergTableSink(PhysicalIcebergTableSink icebergTableSink, - PlanTranslatorContext context) { - PlanFragment rootFragment = icebergTableSink.child().accept(this, context); - rootFragment.setOutputPartition(DataPartition.UNPARTITIONED); - List outputExprs = Lists.newArrayList(); - icebergTableSink.getOutput().stream().map(Slot::getExprId) - .forEach(exprId -> outputExprs.add(context.findSlotRef(exprId))); - IcebergTableSink sink = new IcebergTableSink((IcebergExternalTable) icebergTableSink.getTargetTable()); - rootFragment.setSink(sink); - sink.setOutputExprs(outputExprs); - return rootFragment; - } - - @Override - public PlanFragment visitPhysicalMaxComputeTableSink(PhysicalMaxComputeTableSink mcTableSink, - PlanTranslatorContext context) { - PlanFragment rootFragment = mcTableSink.child().accept(this, context); - rootFragment.setOutputPartition(DataPartition.UNPARTITIONED); - MaxComputeTableSink sink = new MaxComputeTableSink( - (MaxComputeExternalTable) mcTableSink.getTargetTable()); - rootFragment.setSink(sink); - return rootFragment; - } - @Override public PlanFragment visitPhysicalIcebergDeleteSink(PhysicalIcebergDeleteSink icebergDeleteSink, PlanTranslatorContext context) { PlanFragment rootFragment = icebergDeleteSink.child().accept(this, context); rootFragment.setOutputPartition(DataPartition.UNPARTITIONED); - IcebergDeleteSink sink = new IcebergDeleteSink( - (IcebergExternalTable) icebergDeleteSink.getTargetTable(), - icebergDeleteSink.getDeleteContext()); - rootFragment.setSink(sink); + // The DELETE target is a PluginDrivenExternalTable: route through the connector's + // PluginDrivenTableSink with WriteOperation.DELETE so the connector's planWrite emits its + // TIcebergDeleteSink dialect. No output-expr / materialized-name loop is needed: the row id reaches + // BE as the __DORIS_ICEBERG_ROWID_COL__ block column (a real hidden column), and viceberg_delete_sink + // resolves it by block-name, not by output-expr name. + rootFragment.setSink(buildPluginRowLevelDmlSink(icebergDeleteSink, WriteOperation.DELETE)); return rootFragment; } @@ -619,6 +563,11 @@ public PlanFragment visitPhysicalIcebergMergeSink(PhysicalIcebergMergeSink outputExprs = Lists.newArrayList(); for (Slot slot : icebergMergeSink.getOutput()) { SlotRef slotRef = Objects.requireNonNull(context.findSlotRef(slot.getExprId()), @@ -627,7 +576,7 @@ public PlanFragment visitPhysicalIcebergMergeSink(PhysicalIcebergMergeSink sink, WriteOperation writeOperation) { + PluginDrivenExternalTable targetTable = (PluginDrivenExternalTable) sink.getTargetTable(); + PluginDrivenExternalCatalog catalog = (PluginDrivenExternalCatalog) targetTable.getCatalog(); + + Connector connector = catalog.getConnector(); + ConnectorSession connSession = catalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(connSession, connector); + + List connectorColumns = sink.getCols().stream() + .map(col -> new ConnectorColumn(col.getName(), + ConnectorType.of(col.getType().getPrimitiveType().toString()), + null, col.isAllowNull(), null)) + .collect(java.util.stream.Collectors.toList()); + + // Resolve the table handle first so BOTH the write-admission gate and the write provider are chosen + // per-table (a heterogeneous gateway routes iceberg-on-HMS to its sibling by the handle type); + // byte-identical for every single-format connector (the per-handle overloads default to connector-level). + ConnectorTableHandle providerTableHandle = metadata.getTableHandle(connSession, + targetTable.getRemoteDbName(), targetTable.getRemoteName()) + .orElseThrow(() -> new AnalysisException( + "Table not found: " + targetTable.getRemoteDbName() + + "." + targetTable.getRemoteName() + + " in catalog " + catalog.getName())); + Set writeOps = connector.supportedWriteOperations(providerTableHandle); + 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)); + + // writeSortInfo == null: a row-level DML has no engine-resolved write sort (MERGE's sort lives in the + // connector's TIcebergMergeSink.sort_fields, DELETE is unsorted). + return new PluginDrivenTableSink(targetTable, writePlanProvider, connSession, + providerTableHandle, connectorColumns, null, writeOperation); + } + @Override public PlanFragment visitPhysicalConnectorTableSink( PhysicalConnectorTableSink connectorTableSink, @@ -658,7 +658,7 @@ public PlanFragment visitPhysicalConnectorTableSink( // Get write config from the connector Connector connector = catalog.getConnector(); ConnectorSession connSession = catalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(connSession); + ConnectorMetadata metadata = PluginDrivenMetadata.get(connSession, connector); // Convert sink columns to connector columns for INSERT SQL generation List connectorColumns = connectorTableSink.getCols().stream() @@ -667,27 +667,76 @@ public PlanFragment visitPhysicalConnectorTableSink( null, col.isAllowNull(), null)) .collect(java.util.stream.Collectors.toList()); - ConnectorWriteConfig writeConfig; - if (metadata.supportsInsert()) { - ConnectorTableHandle tableHandle = metadata.getTableHandle(connSession, - targetTable.getRemoteDbName(), targetTable.getRemoteName()) - .orElseThrow(() -> new AnalysisException( - "Table not found: " + targetTable.getRemoteDbName() - + "." + targetTable.getRemoteName() - + " in catalog " + catalog.getName())); - writeConfig = metadata.getWriteConfig( - connSession, tableHandle, connectorColumns); - } else { + // Every write-capable connector builds its own opaque TDataSink via its write-plan + // provider (jdbc / maxcompute / iceberg). A connector whose declared write operations do + // not include INSERT does not support writes. + // Resolve the table handle first so BOTH the INSERT-admission gate and the write provider are chosen + // per-table (a heterogeneous gateway routes iceberg-on-HMS to its sibling by the handle type); + // byte-identical for every single-format connector (the per-handle overloads default to connector-level). + ConnectorTableHandle providerTableHandle = metadata.getTableHandle(connSession, + targetTable.getRemoteDbName(), targetTable.getRemoteName()) + .orElseThrow(() -> new AnalysisException( + "Table not found: " + targetTable.getRemoteDbName() + + "." + targetTable.getRemoteName() + + " in catalog " + catalog.getName())); + if (!connector.supportedWriteOperations(providerTableHandle).contains(WriteOperation.INSERT)) { throw new AnalysisException( "Connector '" + catalog.getName() + "' (type: " + catalog.getType() + ") does not support INSERT operations"); } - - PluginDrivenTableSink sink = new PluginDrivenTableSink(targetTable, writeConfig); - rootFragment.setSink(sink); + 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 + // catalog/db/table in StatementContext, so the write target resolves the scan's pin). WHY: an MVCC + // connector's RowDelta DELETE/MERGE re-derives the deletes to remove from the write's base snapshot, + // while BE unions the scan-time deletes into the new DV — pinning both at the read snapshot keeps + // them on one snapshot ([SHOULD-2] / Fix B). A no-op for non-MVCC tables (jdbc/maxcompute) and any + // connector whose handle is not snapshot-pinned, so it is byte-identical for every current write path. + providerTableHandle = PluginDrivenScanNode.applyMvccSnapshotPin( + metadata, connSession, providerTableHandle, MvccUtil.getSnapshotFromContext(targetTable)); + + // The connector declares its write-sort columns (e.g. an iceberg WRITE ORDERED BY) as positions + // into the sink's full-schema output; the engine resolves them to bound slots and builds the + // TSortInfo here (the connector's planWrite has no bound exprs). Empty for connectors with no + // write sort (jdbc/maxcompute) -> null, byte-identical unsorted sink. + TSortInfo writeSortInfo = buildConnectorWriteSortInfo( + writePlanProvider.getWriteSortColumns(connSession, providerTableHandle), + connectorTableSink, context); + + // A distributed rewrite_data_files INSERT-SELECT threads WriteOperation.REWRITE so the connector's + // planWrite enters its REWRITE arm (RewriteFiles semantics) instead of the plain-INSERT append; the + // rewrite marker rides on the sink (PhysicalConnectorTableSink.isRewrite), not on a ConnectContext or + // an instanceof Iceberg. Ordinary connector INSERTs keep WriteOperation.INSERT (byte-identical). + WriteOperation writeOperation = connectorTableSink.isRewrite() + ? WriteOperation.REWRITE : WriteOperation.INSERT; + PluginDrivenTableSink providerSink = new PluginDrivenTableSink(targetTable, + writePlanProvider, connSession, providerTableHandle, connectorColumns, writeSortInfo, + writeOperation); + rootFragment.setSink(providerSink); return rootFragment; } + private TSortInfo buildConnectorWriteSortInfo(List sortColumns, + PhysicalConnectorTableSink connectorTableSink, PlanTranslatorContext context) { + // null == no write sort order -> no TSortInfo (jdbc/maxcompute, unsorted iceberg). A non-null + // list (even empty) means the target has a sort order, so emit a TSortInfo (empty ordering for a + // sort order with no engine-resolvable column), matching legacy's unconditional setSortInfo. + if (sortColumns == null) { + return null; + } + List orderingExprs = Lists.newArrayList(); + List isAscOrder = Lists.newArrayList(); + List nullsFirst = Lists.newArrayList(); + for (ConnectorWriteSortColumn sortColumn : sortColumns) { + orderingExprs.add(context.findSlotRef( + connectorTableSink.getOutput().get(sortColumn.getColumnIndex()).getExprId())); + isAscOrder.add(sortColumn.isAsc()); + nullsFirst.add(sortColumn.isNullsFirst()); + } + return new SortInfo(orderingExprs, isAscOrder, nullsFirst, null).toThrift(); + } + @Override public PlanFragment visitPhysicalFileSink(PhysicalFileSink fileSink, PlanTranslatorContext context) { @@ -732,60 +781,30 @@ public PlanFragment visitPhysicalFileScan(PhysicalFileScan fileScan, PlanTransla SessionVariable sv = ConnectContext.get().getSessionVariable(); // TODO(cmy): determine the needCheckColumnPriv param ScanNode scanNode; - if (table instanceof HMSExternalTable) { - if (directoryLister == null) { - this.directoryLister = new TransactionScopeCachingDirectoryListerFactory( - Config.max_external_table_split_file_meta_cache_num).get(new FileSystemDirectoryLister()); - } - switch (((HMSExternalTable) table).getDlaType()) { - case ICEBERG: - scanNode = new IcebergScanNode(context.nextPlanNodeId(), tupleDescriptor, false, sv, - context.getScanContext()); - break; - case HIVE: - scanNode = new HiveScanNode(context.nextPlanNodeId(), tupleDescriptor, false, sv, directoryLister, - context.getScanContext()); - HiveScanNode hiveScanNode = (HiveScanNode) scanNode; - hiveScanNode.setSelectedPartitions(fileScan.getSelectedPartitions()); - if (fileScan.getTableSample().isPresent()) { - hiveScanNode.setTableSample(new TableSample(fileScan.getTableSample().get().isPercent, - fileScan.getTableSample().get().sampleValue, fileScan.getTableSample().get().seek)); - } - break; - case HUDI: - // HUDI table should be handled by visitPhysicalHudiScan, not here. - // If we reach here, it means LogicalHudiScan was incorrectly converted to - // PhysicalFileScan. - throw new RuntimeException("HUDI table should use PhysicalHudiScan instead of PhysicalFileScan. " - + "This indicates a bug in the optimizer rules. " - + "FileScan class: " + fileScan.getClass().getSimpleName()); - default: - throw new RuntimeException("do not support DLA type " + ((HMSExternalTable) table).getDlaType()); - } - } else if (table instanceof IcebergExternalTable || table instanceof IcebergSysExternalTable) { - scanNode = new IcebergScanNode(context.nextPlanNodeId(), tupleDescriptor, false, sv, - context.getScanContext()); - } else if (table.getType() == TableIf.TableType.PAIMON_EXTERNAL_TABLE) { - scanNode = new PaimonScanNode(context.nextPlanNodeId(), tupleDescriptor, false, sv, - context.getScanContext()); - } else if (table instanceof TrinoConnectorExternalTable) { - scanNode = new TrinoConnectorScanNode(context.nextPlanNodeId(), tupleDescriptor, false, sv, - context.getScanContext()); - } else if (table instanceof MaxComputeExternalTable) { - scanNode = new MaxComputeScanNode(context.nextPlanNodeId(), tupleDescriptor, - fileScan.getSelectedPartitions(), false, sv, context.getScanContext()); - } else if (table instanceof LakeSoulExternalTable) { - scanNode = new LakeSoulScanNode(context.nextPlanNodeId(), tupleDescriptor, false, sv, - context.getScanContext()); - } else if (table instanceof RemoteDorisExternalTable) { - scanNode = new RemoteDorisScanNode(context.nextPlanNodeId(), tupleDescriptor, false, sv, - context.getScanContext()); - } else if (table instanceof PluginDrivenExternalTable) { + // Plugin-driven (SPI) tables are matched first; the connector-specific + // instanceof branches below are migration-period fallbacks that get removed + // as each connector lands on the SPI in P3-P7. + if (table instanceof PluginDrivenExternalTable) { PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) table.getCatalog(); - scanNode = PluginDrivenScanNode.create(context.nextPlanNodeId(), tupleDescriptor, - false, sv, context.getScanContext(), pluginCatalog, + PluginDrivenScanNode pluginScanNode = PluginDrivenScanNode.create(context.nextPlanNodeId(), + tupleDescriptor, false, sv, context.getScanContext(), pluginCatalog, ((PluginDrivenExternalTable) table)); + // Forward the pruned partitions so the connector reads only the surviving partitions + // (mirrors the legacy MaxCompute / Hive branches below). + pluginScanNode.setSelectedPartitions(fileScan.getSelectedPartitions()); + // Forward TABLESAMPLE (mirrors the legacy Hive branch below). Whether it is actually applied + // is decided in PluginDrivenScanNode by the connector's supportsTableSample() capability: a + // connector whose split ranges carry byte lengths (Hive) samples; the others no-op with a + // warning. Without this forward the sample is silently dropped and the query scans the full table. + if (fileScan.getTableSample().isPresent()) { + pluginScanNode.setTableSample(new TableSample(fileScan.getTableSample().get().isPercent, + fileScan.getTableSample().get().sampleValue, fileScan.getTableSample().get().seek)); + } + scanNode = pluginScanNode; + } else if (table instanceof RemoteDorisExternalTable) { + scanNode = new RemoteDorisScanNode(context.nextPlanNodeId(), tupleDescriptor, false, sv, + context.getScanContext()); } else { throw new RuntimeException("do not support table type " + table.getType()); } @@ -820,30 +839,6 @@ public PlanFragment visitPhysicalEmptyRelation(PhysicalEmptyRelation emptyRelati return planFragment; } - @Override - public PlanFragment visitPhysicalHudiScan(PhysicalHudiScan hudiScan, PlanTranslatorContext context) { - if (directoryLister == null) { - this.directoryLister = new TransactionScopeCachingDirectoryListerFactory( - Config.max_external_table_split_file_meta_cache_num).get(new FileSystemDirectoryLister()); - } - List slots = hudiScan.getOutput(); - ExternalTable table = hudiScan.getTable(); - TupleDescriptor tupleDescriptor = generateTupleDesc(slots, table, context); - - if (!(table instanceof HMSExternalTable) || ((HMSExternalTable) table).getDlaType() != DLAType.HUDI) { - throw new RuntimeException("Invalid table type for Hudi scan: " + table.getType()); - } - HudiScanNode hudiScanNode = new HudiScanNode(context.nextPlanNodeId(), tupleDescriptor, false, - hudiScan.getScanParams(), hudiScan.getIncrementalRelation(), ConnectContext.get().getSessionVariable(), - directoryLister, context.getScanContext()); - if (hudiScan.getTableSnapshot().isPresent()) { - hudiScanNode.setQueryTableSnapshot(hudiScan.getTableSnapshot().get()); - } - hudiScanNode.setSelectedPartitions(hudiScan.getSelectedPartitions()); - hudiScanNode.setDistributeExprLists(getDistributeExpr(hudiScan)); - return getPlanFragmentForPhysicalFileScan(hudiScan, context, hudiScanNode); - } - @NotNull private PlanFragment getPlanFragmentForPhysicalFileScan(PhysicalFileScan fileScan, PlanTranslatorContext context, ScanNode scanNode) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/load/NereidsFileGroupInfo.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/load/NereidsFileGroupInfo.java index 86768f6e8553b7..b170d4e60c69bb 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/load/NereidsFileGroupInfo.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/load/NereidsFileGroupInfo.java @@ -27,9 +27,9 @@ import org.apache.doris.common.Pair; import org.apache.doris.common.UserException; import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.FederationBackendPolicy; -import org.apache.doris.datasource.FileGroupInfo; -import org.apache.doris.datasource.FilePartitionUtils; +import org.apache.doris.datasource.scan.FederationBackendPolicy; +import org.apache.doris.datasource.scan.FileGroupInfo; +import org.apache.doris.datasource.scan.FilePartitionUtils; import org.apache.doris.system.Backend; import org.apache.doris.thrift.TBrokerFileStatus; import org.apache.doris.thrift.TExternalScanRange; 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 c470bc789d9e78..d140168911d59a 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 @@ -62,8 +62,8 @@ import org.apache.doris.common.Pair; import org.apache.doris.common.UserException; import org.apache.doris.common.util.PropertyAnalyzer; -import org.apache.doris.datasource.FileCacheAdmissionManager; import org.apache.doris.datasource.InternalCatalog; +import org.apache.doris.datasource.scan.FileCacheAdmissionManager; import org.apache.doris.dictionary.LayoutType; import org.apache.doris.info.TableRefInfo; import org.apache.doris.info.TableValuedFunctionRefInfo; diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/ShuffleKeyPruner.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/ShuffleKeyPruner.java index 0f5453b16fde46..50c540911e4935 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/ShuffleKeyPruner.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/ShuffleKeyPruner.java @@ -41,10 +41,7 @@ import org.apache.doris.nereids.trees.plans.physical.PhysicalGenerate; import org.apache.doris.nereids.trees.plans.physical.PhysicalHashAggregate; import org.apache.doris.nereids.trees.plans.physical.PhysicalHashJoin; -import org.apache.doris.nereids.trees.plans.physical.PhysicalHiveTableSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalLimit; -import org.apache.doris.nereids.trees.plans.physical.PhysicalMaxComputeTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalNestedLoopJoin; import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalPartitionTopN; @@ -241,47 +238,6 @@ public Plan visitPhysicalResultSink(PhysicalResultSink sink, Pru return rewriteUnary(sink, ctx.withAllowShuffleKeyPrune(childAllowShuffleKeyPrune)); } - @Override - public Plan visitPhysicalHiveTableSink(PhysicalHiveTableSink hiveTableSink, PruneCtx ctx) { - boolean childAllowShuffleKeyPrune; - if (ctx.cascadesContext.getConnectContext() != null - && !ctx.cascadesContext.getConnectContext().getSessionVariable().enableStrictConsistencyDml) { - childAllowShuffleKeyPrune = true; - } else { - childAllowShuffleKeyPrune = - hiveTableSink.getRequirePhysicalProperties().equals(PhysicalProperties.ANY); - } - return rewriteUnary(hiveTableSink, ctx.withAllowShuffleKeyPrune(childAllowShuffleKeyPrune)); - } - - @Override - public Plan visitPhysicalIcebergTableSink( - PhysicalIcebergTableSink icebergTableSink, PruneCtx ctx) { - boolean childAllowShuffleKeyPrune; - if (ctx.cascadesContext.getConnectContext() != null - && !ctx.cascadesContext.getConnectContext().getSessionVariable().enableStrictConsistencyDml) { - childAllowShuffleKeyPrune = true; - } else { - childAllowShuffleKeyPrune = - icebergTableSink.getRequirePhysicalProperties().equals(PhysicalProperties.ANY); - } - return rewriteUnary(icebergTableSink, ctx.withAllowShuffleKeyPrune(childAllowShuffleKeyPrune)); - } - - @Override - public Plan visitPhysicalMaxComputeTableSink( - PhysicalMaxComputeTableSink mcTableSink, PruneCtx ctx) { - boolean childAllowShuffleKeyPrune; - if (ctx.cascadesContext.getConnectContext() != null - && !ctx.cascadesContext.getConnectContext().getSessionVariable().enableStrictConsistencyDml) { - childAllowShuffleKeyPrune = true; - } else { - childAllowShuffleKeyPrune = mcTableSink.getRequirePhysicalProperties().equals( - PhysicalProperties.ANY); - } - return rewriteUnary(mcTableSink, ctx.withAllowShuffleKeyPrune(childAllowShuffleKeyPrune)); - } - @Override public Plan visitPhysicalConnectorTableSink( PhysicalConnectorTableSink connectorSink, PruneCtx ctx) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/materialize/MaterializeProbeVisitor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/materialize/MaterializeProbeVisitor.java index 0a49789660096d..021d3a061884b7 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/materialize/MaterializeProbeVisitor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/materialize/MaterializeProbeVisitor.java @@ -20,9 +20,7 @@ import org.apache.doris.catalog.HiveTable; import org.apache.doris.catalog.KeysType; import org.apache.doris.catalog.OlapTable; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HMSExternalTable.DLAType; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.nereids.processor.post.materialize.MaterializeProbeVisitor.ProbeContext; import org.apache.doris.nereids.trees.expressions.Alias; import org.apache.doris.nereids.trees.expressions.NamedExpression; @@ -57,9 +55,7 @@ public class MaterializeProbeVisitor extends DefaultPlanVisitor SUPPORT_RELATION_TYPES = ImmutableSet.of( OlapTable.class, - HiveTable.class, - IcebergExternalTable.class, - HMSExternalTable.class + HiveTable.class ); /** @@ -124,15 +120,17 @@ public Optional visit(Plan plan, ProbeContext context) { } boolean checkRelationTableSupportedType(PhysicalCatalogRelation relation) { - if (!SUPPORT_RELATION_TYPES.contains(relation.getTable().getClass())) { + boolean supported = SUPPORT_RELATION_TYPES.contains(relation.getTable().getClass()); + if (!supported && relation.getTable() instanceof PluginDrivenExternalTable) { + // Post-flip iceberg becomes PluginDrivenMvccExternalTable (not in the legacy exact-class set); + // admit it via the connector capability instead of the legacy IcebergExternalTable.class member. + // Row/passthrough plugin connectors (jdbc/es) do not declare the capability, so they stay excluded. + supported = ((PluginDrivenExternalTable) relation.getTable()).supportsTopNLazyMaterialize(); + } + if (!supported) { return false; } - if (relation.getTable() instanceof HMSExternalTable) { - HMSExternalTable hmsExternalTable = (HMSExternalTable) relation.getTable(); - return (hmsExternalTable.getDlaType() == DLAType.HIVE && hmsExternalTable.supportedHiveTopNLazyTable()) - || hmsExternalTable.getDlaType() == DLAType.ICEBERG; - } return true; } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/pre/TurnOffPageCacheForInsertIntoSelect.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/pre/TurnOffPageCacheForInsertIntoSelect.java index 8abd1094b3ca05..77955a94114ccb 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/pre/TurnOffPageCacheForInsertIntoSelect.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/pre/TurnOffPageCacheForInsertIntoSelect.java @@ -24,9 +24,6 @@ import org.apache.doris.nereids.exceptions.AnalysisException; import org.apache.doris.nereids.trees.plans.Plan; import org.apache.doris.nereids.trees.plans.logical.LogicalFileSink; -import org.apache.doris.nereids.trees.plans.logical.LogicalHiveTableSink; -import org.apache.doris.nereids.trees.plans.logical.LogicalIcebergTableSink; -import org.apache.doris.nereids.trees.plans.logical.LogicalMaxComputeTableSink; import org.apache.doris.nereids.trees.plans.logical.LogicalOlapTableSink; import org.apache.doris.qe.SessionVariable; import org.apache.doris.qe.VariableMgr; @@ -55,26 +52,6 @@ public Plan visitLogicalOlapTableSink(LogicalOlapTableSink table return tableSink; } - @Override - public Plan visitLogicalHiveTableSink(LogicalHiveTableSink tableSink, StatementContext context) { - turnOffPageCache(context); - return tableSink; - } - - @Override - public Plan visitLogicalIcebergTableSink( - LogicalIcebergTableSink tableSink, StatementContext context) { - turnOffPageCache(context); - return tableSink; - } - - @Override - public Plan visitLogicalMaxComputeTableSink( - LogicalMaxComputeTableSink tableSink, StatementContext context) { - turnOffPageCache(context); - return tableSink; - } - private void turnOffPageCache(StatementContext context) { SessionVariable sessionVariable = context.getConnectContext().getSessionVariable(); // set temporary session value, and then revert value in the 'finally block' of StmtExecutor#execute diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java index 6ea2601bbee73c..4f5be4cce68f2b 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java @@ -47,12 +47,9 @@ import org.apache.doris.nereids.trees.plans.physical.PhysicalFilter; import org.apache.doris.nereids.trees.plans.physical.PhysicalHashAggregate; import org.apache.doris.nereids.trees.plans.physical.PhysicalHashJoin; -import org.apache.doris.nereids.trees.plans.physical.PhysicalHiveTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergDeleteSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergMergeSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalLimit; -import org.apache.doris.nereids.trees.plans.physical.PhysicalMaxComputeTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalNestedLoopJoin; import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalPartitionTopN; @@ -156,38 +153,6 @@ public Void visitPhysicalOlapTableSink(PhysicalOlapTableSink ola return null; } - @Override - public Void visitPhysicalHiveTableSink(PhysicalHiveTableSink hiveTableSink, PlanContext context) { - if (connectContext != null && !connectContext.getSessionVariable().isEnableStrictConsistencyDml()) { - addRequestPropertyToChildren(PhysicalProperties.ANY); - } else { - addRequestPropertyToChildren(hiveTableSink.getRequirePhysicalProperties()); - } - return null; - } - - @Override - public Void visitPhysicalIcebergTableSink( - PhysicalIcebergTableSink icebergTableSink, PlanContext context) { - if (connectContext != null && !connectContext.getSessionVariable().isEnableStrictConsistencyDml()) { - addRequestPropertyToChildren(PhysicalProperties.ANY); - } else { - addRequestPropertyToChildren(icebergTableSink.getRequirePhysicalProperties()); - } - return null; - } - - @Override - public Void visitPhysicalMaxComputeTableSink( - PhysicalMaxComputeTableSink mcTableSink, PlanContext context) { - if (connectContext != null && !connectContext.getSessionVariable().isEnableStrictConsistencyDml()) { - addRequestPropertyToChildren(PhysicalProperties.ANY); - } else { - addRequestPropertyToChildren(mcTableSink.getRequirePhysicalProperties()); - } - return null; - } - @Override public Void visitPhysicalIcebergDeleteSink( PhysicalIcebergDeleteSink icebergDeleteSink, PlanContext context) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/RuleSet.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/RuleSet.java index 7b05dc6394d7a1..2e4da87fe86ced 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/RuleSet.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/RuleSet.java @@ -71,16 +71,12 @@ import org.apache.doris.nereids.rules.implementation.LogicalFileSinkToPhysicalFileSink; import org.apache.doris.nereids.rules.implementation.LogicalFilterToPhysicalFilter; import org.apache.doris.nereids.rules.implementation.LogicalGenerateToPhysicalGenerate; -import org.apache.doris.nereids.rules.implementation.LogicalHiveTableSinkToPhysicalHiveTableSink; -import org.apache.doris.nereids.rules.implementation.LogicalHudiScanToPhysicalHudiScan; import org.apache.doris.nereids.rules.implementation.LogicalIcebergDeleteSinkToPhysicalIcebergDeleteSink; import org.apache.doris.nereids.rules.implementation.LogicalIcebergMergeSinkToPhysicalIcebergMergeSink; -import org.apache.doris.nereids.rules.implementation.LogicalIcebergTableSinkToPhysicalIcebergTableSink; import org.apache.doris.nereids.rules.implementation.LogicalIntersectToPhysicalIntersect; import org.apache.doris.nereids.rules.implementation.LogicalJoinToHashJoin; import org.apache.doris.nereids.rules.implementation.LogicalJoinToNestedLoopJoin; import org.apache.doris.nereids.rules.implementation.LogicalLimitToPhysicalLimit; -import org.apache.doris.nereids.rules.implementation.LogicalMaxComputeTableSinkToPhysicalMaxComputeTableSink; import org.apache.doris.nereids.rules.implementation.LogicalOdbcScanToPhysicalOdbcScan; import org.apache.doris.nereids.rules.implementation.LogicalOlapScanToPhysicalOlapScan; import org.apache.doris.nereids.rules.implementation.LogicalOlapTableSinkToPhysicalOlapTableSink; @@ -200,7 +196,6 @@ public class RuleSet { .add(new LogicalJoinToNestedLoopJoin()) .add(new LogicalOlapScanToPhysicalOlapScan()) .add(new LogicalSchemaScanToPhysicalSchemaScan()) - .add(new LogicalHudiScanToPhysicalHudiScan()) .add(new LogicalFileScanToPhysicalFileScan()) .add(new LogicalOdbcScanToPhysicalOdbcScan()) .add(new LogicalWorkTableReferenceToPhysicalWorkTableReference()) @@ -226,9 +221,6 @@ public class RuleSet { .add(new LogicalIntersectToPhysicalIntersect()) .add(new LogicalGenerateToPhysicalGenerate()) .add(new LogicalOlapTableSinkToPhysicalOlapTableSink()) - .add(new LogicalHiveTableSinkToPhysicalHiveTableSink()) - .add(new LogicalIcebergTableSinkToPhysicalIcebergTableSink()) - .add(new LogicalMaxComputeTableSinkToPhysicalMaxComputeTableSink()) .add(new LogicalIcebergDeleteSinkToPhysicalIcebergDeleteSink()) .add(new LogicalIcebergMergeSinkToPhysicalIcebergMergeSink()) .add(new LogicalConnectorTableSinkToPhysicalConnectorTableSink()) @@ -249,7 +241,6 @@ public class RuleSet { .add(new LogicalJoinToNestedLoopJoin()) .add(new LogicalOlapScanToPhysicalOlapScan()) .add(new LogicalSchemaScanToPhysicalSchemaScan()) - .add(new LogicalHudiScanToPhysicalHudiScan()) .add(new LogicalFileScanToPhysicalFileScan()) .add(new LogicalOdbcScanToPhysicalOdbcScan()) .add(new LogicalWorkTableReferenceToPhysicalWorkTableReference()) @@ -274,9 +265,6 @@ public class RuleSet { .add(new LogicalIntersectToPhysicalIntersect()) .add(new LogicalGenerateToPhysicalGenerate()) .add(new LogicalOlapTableSinkToPhysicalOlapTableSink()) - .add(new LogicalHiveTableSinkToPhysicalHiveTableSink()) - .add(new LogicalIcebergTableSinkToPhysicalIcebergTableSink()) - .add(new LogicalMaxComputeTableSinkToPhysicalMaxComputeTableSink()) .add(new LogicalIcebergDeleteSinkToPhysicalIcebergDeleteSink()) .add(new LogicalIcebergMergeSinkToPhysicalIcebergMergeSink()) .add(new LogicalConnectorTableSinkToPhysicalConnectorTableSink()) diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/RuleType.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/RuleType.java index 1e4f38fd005c80..83f788b9a24d0b 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/RuleType.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/RuleType.java @@ -39,7 +39,6 @@ public enum RuleType { BINDING_ICEBERG_MERGE_SINK_OUTPUT(RuleTypeClass.REWRITE), BINDING_INSERT_BLACKHOLE_SINK(RuleTypeClass.REWRITE), BINDING_INSERT_HIVE_TABLE(RuleTypeClass.REWRITE), - BINDING_INSERT_ICEBERG_TABLE(RuleTypeClass.REWRITE), BINDING_INSERT_MAX_COMPUTE_TABLE(RuleTypeClass.REWRITE), BINDING_INSERT_JDBC_TABLE(RuleTypeClass.REWRITE), BINDING_INSERT_CONNECTOR_TABLE(RuleTypeClass.REWRITE), @@ -552,15 +551,12 @@ public enum RuleType { LOGICAL_OLAP_SCAN_TO_PHYSICAL_OLAP_SCAN_RULE(RuleTypeClass.IMPLEMENTATION), LOGICAL_SCHEMA_SCAN_TO_PHYSICAL_SCHEMA_SCAN_RULE(RuleTypeClass.IMPLEMENTATION), LOGICAL_FILE_SCAN_TO_PHYSICAL_FILE_SCAN_RULE(RuleTypeClass.IMPLEMENTATION), - LOGICAL_HUDI_SCAN_TO_PHYSICAL_HUDI_SCAN_RULE(RuleTypeClass.IMPLEMENTATION), LOGICAL_JDBC_SCAN_TO_PHYSICAL_JDBC_SCAN_RULE(RuleTypeClass.IMPLEMENTATION), LOGICAL_ODBC_SCAN_TO_PHYSICAL_ODBC_SCAN_RULE(RuleTypeClass.IMPLEMENTATION), LOGICAL_ES_SCAN_TO_PHYSICAL_ES_SCAN_RULE(RuleTypeClass.IMPLEMENTATION), LOGICAL_WORK_TABLE_REFERENCE_TO_PHYSICAL_WORK_TABLE_REFERENCE(RuleTypeClass.IMPLEMENTATION), LOGICAL_BLACKHOLE_SINK_TO_PHYSICAL_BLACKHOLE_SINK_RULE(RuleTypeClass.IMPLEMENTATION), LOGICAL_OLAP_TABLE_SINK_TO_PHYSICAL_OLAP_TABLE_SINK_RULE(RuleTypeClass.IMPLEMENTATION), - LOGICAL_HIVE_TABLE_SINK_TO_PHYSICAL_HIVE_TABLE_SINK_RULE(RuleTypeClass.IMPLEMENTATION), - LOGICAL_ICEBERG_TABLE_SINK_TO_PHYSICAL_ICEBERG_TABLE_SINK_RULE(RuleTypeClass.IMPLEMENTATION), LOGICAL_MAX_COMPUTE_TABLE_SINK_TO_PHYSICAL_MAX_COMPUTE_TABLE_SINK_RULE(RuleTypeClass.IMPLEMENTATION), LOGICAL_ICEBERG_DELETE_SINK_TO_PHYSICAL_ICEBERG_DELETE_SINK_RULE(RuleTypeClass.IMPLEMENTATION), LOGICAL_ICEBERG_MERGE_SINK_TO_PHYSICAL_ICEBERG_MERGE_SINK_RULE(RuleTypeClass.IMPLEMENTATION), diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindExpression.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindExpression.java index 79844c9e033a5d..ff7da6525b4c34 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindExpression.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindExpression.java @@ -21,8 +21,6 @@ import org.apache.doris.catalog.Env; import org.apache.doris.catalog.FunctionRegistry; import org.apache.doris.common.Pair; -import org.apache.doris.datasource.iceberg.IcebergMergeOperation; -import org.apache.doris.datasource.iceberg.IcebergUtils; import org.apache.doris.nereids.CascadesContext; import org.apache.doris.nereids.SqlCacheContext; import org.apache.doris.nereids.StatementContext; @@ -76,6 +74,7 @@ import org.apache.doris.nereids.trees.plans.algebra.OneRowRelation; import org.apache.doris.nereids.trees.plans.algebra.SetOperation; import org.apache.doris.nereids.trees.plans.algebra.SetOperation.Qualifier; +import org.apache.doris.nereids.trees.plans.commands.merge.MergeOperation; import org.apache.doris.nereids.trees.plans.logical.LogicalAggregate; import org.apache.doris.nereids.trees.plans.logical.LogicalCatalogRelation; import org.apache.doris.nereids.trees.plans.logical.LogicalExcept; @@ -300,9 +299,17 @@ private LogicalIcebergMergeSink bindIcebergMergeSink( List visibleColumns = sink.getCols().stream() .filter(Column::isVisible) .collect(ImmutableList.toImmutableList()); + // The connector-reserved passthrough columns (iceberg v3 row-lineage) are the hidden target columns + // marked reservedPassthrough. Derive their names from the sink's target schema so the meta-column + // check below stays name-based here (this site sees output-expression names, not Column objects) while + // fe-core no longer string-matches the iceberg column names. + List reservedPassthroughNames = sink.getCols().stream() + .filter(Column::isReservedPassthrough) + .map(Column::getName) + .collect(ImmutableList.toImmutableList()); int dataExprCount = 0; for (NamedExpression expr : outputExprs) { - if (!isIcebergMergeMetaColumn(expr.getName())) { + if (!isIcebergMergeMetaColumn(expr.getName(), reservedPassthroughNames)) { dataExprCount++; } } @@ -317,7 +324,7 @@ private LogicalIcebergMergeSink bindIcebergMergeSink( int columnIndex = 0; List castExprs = Lists.newArrayListWithCapacity(outputExprs.size()); for (NamedExpression expr : outputExprs) { - if (isIcebergMergeMetaColumn(expr.getName())) { + if (isIcebergMergeMetaColumn(expr.getName(), reservedPassthroughNames)) { castExprs.add(expr); continue; } @@ -348,14 +355,17 @@ private LogicalIcebergMergeSink bindIcebergMergeSink( return (LogicalIcebergMergeSink) sink.withChildAndUpdateOutput(project); } - private boolean isIcebergMergeMetaColumn(String name) { - if (IcebergMergeOperation.OPERATION_COLUMN.equalsIgnoreCase(name)) { + private boolean isIcebergMergeMetaColumn(String name, List reservedPassthroughNames) { + if (MergeOperation.OPERATION_COLUMN.equalsIgnoreCase(name)) { return true; } if (Column.ICEBERG_ROWID_COL.equalsIgnoreCase(name)) { return true; } - return IcebergUtils.isIcebergRowLineageColumn(name); + // Connector-reserved passthrough columns (iceberg v3 row-lineage), matched case-insensitively by name + // — the names come from the sink's target Columns marked reservedPassthrough, so fe-core no longer + // knows the iceberg column names. The reserved set is tiny (0-2), so a linear scan is fine. + return reservedPassthroughNames.stream().anyMatch(n -> n.equalsIgnoreCase(name)); } private static boolean hasUnboundPlan(Plan plan) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java index a9849b5dfa2019..4544b1dc8c4e10 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java @@ -40,17 +40,15 @@ import org.apache.doris.catalog.stream.OlapTableStream; import org.apache.doris.catalog.stream.OlapTableStreamWrapper; import org.apache.doris.catalog.stream.StreamReadMode; -import org.apache.doris.common.Config; import org.apache.doris.common.IdGenerator; import org.apache.doris.common.Pair; import org.apache.doris.common.util.Util; import org.apache.doris.datasource.ExternalTable; import org.apache.doris.datasource.ExternalView; import org.apache.doris.datasource.doris.RemoteDorisExternalTable; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HMSExternalTable.DLAType; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.datasource.systable.SysTableResolver; +import org.apache.doris.mtmv.MTMVRelatedTableIf; import org.apache.doris.nereids.CTEContext; import org.apache.doris.nereids.CascadesContext; import org.apache.doris.nereids.SqlCacheContext; @@ -101,7 +99,6 @@ import org.apache.doris.nereids.trees.plans.logical.LogicalCTEConsumer; import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan; import org.apache.doris.nereids.trees.plans.logical.LogicalFilter; -import org.apache.doris.nereids.trees.plans.logical.LogicalHudiScan; import org.apache.doris.nereids.trees.plans.logical.LogicalOdbcScan; import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan; import org.apache.doris.nereids.trees.plans.logical.LogicalOlapTableStreamScan; @@ -749,67 +746,37 @@ private LogicalPlan getLogicalPlan(TableIf table, UnboundRelation unboundRelatio Plan viewBody = parseAndAnalyzeDorisView(view, qualifiedTableName, cascadesContext); LogicalView logicalView = new LogicalView<>(view, viewBody); return new LogicalSubQueryAlias<>(qualifiedTableName, logicalView); - case HMS_EXTERNAL_TABLE: - HMSExternalTable hmsTable = (HMSExternalTable) table; - if (Config.enable_query_hive_views && hmsTable.isView()) { - isView = true; - String hiveCatalog = hmsTable.getCatalog().getName(); - String hiveDb = hmsTable.getDatabase().getFullName(); - String ddlSql = hmsTable.getViewText(); - Plan hiveViewPlan = parseAndAnalyzeExternalView( - hmsTable, hiveCatalog, hiveDb, ddlSql, cascadesContext); - return new LogicalSubQueryAlias<>(qualifiedTableName, hiveViewPlan); - } - if (hmsTable.getDlaType() == DLAType.HUDI) { - LogicalHudiScan hudiScan = new LogicalHudiScan(unboundRelation.getRelationId(), hmsTable, - qualifierWithoutTableName, ImmutableList.of(), Optional.empty(), - unboundRelation.getTableSample(), unboundRelation.getTableSnapshot(), - Optional.empty()); - hudiScan = hudiScan.withScanParams( - hmsTable, Optional.ofNullable(unboundRelation.getScanParams())); - return hudiScan; - } else { - return new LogicalFileScan(unboundRelation.getRelationId(), (HMSExternalTable) table, - qualifierWithoutTableName, - ImmutableList.of(), - unboundRelation.getTableSample(), - unboundRelation.getTableSnapshot(), - Optional.ofNullable(unboundRelation.getScanParams()), Optional.empty()); - } - case ICEBERG_EXTERNAL_TABLE: - IcebergExternalTable icebergExternalTable = (IcebergExternalTable) table; - if (Config.enable_query_iceberg_views && icebergExternalTable.isView()) { - Optional tableSnapshot = unboundRelation.getTableSnapshot(); - if (tableSnapshot.isPresent()) { - // iceberg view not supported with snapshot time/version travel + case PAIMON_EXTERNAL_TABLE: + case MAX_COMPUTE_EXTERNAL_TABLE: + case TRINO_CONNECTOR_EXTERNAL_TABLE: + case LAKESOUl_EXTERNAL_TABLE: + case PLUGIN_EXTERNAL_TABLE: + if (table instanceof PluginDrivenExternalTable + && ((PluginDrivenExternalTable) table).isView()) { + // Plugin view (hive after the hms cutover, or iceberg): any connector that declares + // SUPPORTS_VIEW serves its view here, unconditionally — the legacy + // enable_query_hive_views / enable_query_iceberg_views switches are deprecated no-ops, + // so a view is served regardless of them. The view body is converted by the session + // dialect inside parseAndAnalyzeExternalView (shared with the legacy HMS hive-view + // path), which is fully neutral. + PluginDrivenExternalTable pluginViewTable = (PluginDrivenExternalTable) table; + if (unboundRelation.getTableSnapshot().isPresent()) { + // A view cannot be combined with snapshot time/version travel (meaningless for a + // view, for hive and iceberg alike). // note that enable_fallback_to_original_planner should be set with false // or else this exception will not be thrown // because legacy planner will retry and thrown other exception throw new UnsupportedOperationException( - "iceberg view not supported with snapshot time/version travel"); + "view not supported with snapshot time/version travel"); } isView = true; - String icebergCatalog = icebergExternalTable.getCatalog().getName(); - String icebergDb = icebergExternalTable.getDatabase().getFullName(); - String ddlSql = icebergExternalTable.getViewText(); - Plan icebergViewPlan = parseAndAnalyzeExternalView(icebergExternalTable, - icebergCatalog, icebergDb, ddlSql, cascadesContext); - return new LogicalSubQueryAlias<>(qualifiedTableName, icebergViewPlan); + String pluginCatalog = pluginViewTable.getCatalog().getName(); + String pluginDb = pluginViewTable.getDatabase().getFullName(); + String ddlSql = pluginViewTable.getViewText(); + Plan pluginViewPlan = parseAndAnalyzeExternalView(pluginViewTable, + pluginCatalog, pluginDb, ddlSql, cascadesContext); + return new LogicalSubQueryAlias<>(qualifiedTableName, pluginViewPlan); } - if (icebergExternalTable.isView()) { - throw new UnsupportedOperationException( - "please set enable_query_iceberg_views=true to enable query iceberg views"); - } - return new LogicalFileScan(unboundRelation.getRelationId(), (ExternalTable) table, - qualifierWithoutTableName, ImmutableList.of(), - unboundRelation.getTableSample(), - unboundRelation.getTableSnapshot(), - Optional.ofNullable(unboundRelation.getScanParams()), Optional.empty()); - case PAIMON_EXTERNAL_TABLE: - case MAX_COMPUTE_EXTERNAL_TABLE: - case TRINO_CONNECTOR_EXTERNAL_TABLE: - case LAKESOUl_EXTERNAL_TABLE: - case PLUGIN_EXTERNAL_TABLE: return new LogicalFileScan(unboundRelation.getRelationId(), (ExternalTable) table, qualifierWithoutTableName, ImmutableList.of(), unboundRelation.getTableSample(), @@ -885,8 +852,12 @@ private LogicalPlan getLogicalPlan(TableIf table, UnboundRelation unboundRelatio sqlCacheContext.setHasUnsupportedTables(true); } else if (table instanceof OlapTable) { sqlCacheContext.addUsedTable(table); - } else if (table instanceof HMSExternalTable + } else if (table instanceof ExternalTable && table instanceof MTMVRelatedTableIf && cascadesContext.getConnectContext().getSessionVariable().enableHiveSqlCache) { + // Any external lakehouse plugin table that exposes a stable data-version token + // (MTMVRelatedTableIf#getNewestUpdateVersionOrTime) is cacheable; addUsedTable + // records the token and fails safe if it is unavailable. Gated by the (default + // false) enable_hive_sql_cache switch so behavior is unchanged unless opted in. sqlCacheContext.addUsedTable(table); } else { sqlCacheContext.setHasUnsupportedTables(true); diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindSink.java index 8c580171b21151..e474fd30cbdea6 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindSink.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindSink.java @@ -33,17 +33,15 @@ import org.apache.doris.common.Config; import org.apache.doris.common.IdGenerator; import org.apache.doris.common.Pair; +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.ConnectorTableHandle; import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.PluginDrivenExternalTable; import org.apache.doris.datasource.doris.RemoteDorisExternalTable; -import org.apache.doris.datasource.hive.HMSExternalDatabase; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HiveUtil; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergUtils; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalDatabase; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.dictionary.Dictionary; import org.apache.doris.nereids.CascadesContext; import org.apache.doris.nereids.StatementContext; @@ -51,9 +49,6 @@ import org.apache.doris.nereids.analyzer.UnboundBlackholeSink; import org.apache.doris.nereids.analyzer.UnboundConnectorTableSink; import org.apache.doris.nereids.analyzer.UnboundDictionarySink; -import org.apache.doris.nereids.analyzer.UnboundHiveTableSink; -import org.apache.doris.nereids.analyzer.UnboundIcebergTableSink; -import org.apache.doris.nereids.analyzer.UnboundMaxComputeTableSink; import org.apache.doris.nereids.analyzer.UnboundSlot; import org.apache.doris.nereids.analyzer.UnboundTVFTableSink; import org.apache.doris.nereids.analyzer.UnboundTableSink; @@ -84,9 +79,6 @@ import org.apache.doris.nereids.trees.plans.logical.LogicalConnectorTableSink; import org.apache.doris.nereids.trees.plans.logical.LogicalDictionarySink; import org.apache.doris.nereids.trees.plans.logical.LogicalEmptyRelation; -import org.apache.doris.nereids.trees.plans.logical.LogicalHiveTableSink; -import org.apache.doris.nereids.trees.plans.logical.LogicalIcebergTableSink; -import org.apache.doris.nereids.trees.plans.logical.LogicalMaxComputeTableSink; import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan; import org.apache.doris.nereids.trees.plans.logical.LogicalOlapTableSink; import org.apache.doris.nereids.trees.plans.logical.LogicalOneRowRelation; @@ -108,15 +100,13 @@ import org.apache.doris.qe.SessionVariable; import org.apache.doris.thrift.TPartialUpdateNewRowPolicy; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.collect.Sets; -import org.apache.iceberg.PartitionField; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Table; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -162,12 +152,6 @@ public List buildRules() { return fileSink.withOutputExprs(output); }) ), - // TODO: bind hive target table - RuleType.BINDING_INSERT_HIVE_TABLE.build(unboundHiveTableSink().thenApply(this::bindHiveTableSink)), - RuleType.BINDING_INSERT_ICEBERG_TABLE.build( - unboundIcebergTableSink().thenApply(this::bindIcebergTableSink)), - RuleType.BINDING_INSERT_MAX_COMPUTE_TABLE.build( - unboundMaxComputeTableSink().thenApply(this::bindMaxComputeTableSink)), RuleType.BINDING_INSERT_CONNECTOR_TABLE.build( unboundConnectorTableSink().thenApply(this::bindConnectorTableSink)), RuleType.BINDING_INSERT_DICTIONARY_TABLE @@ -667,262 +651,76 @@ private Plan bindTVFTableSink(MatchingContext> ctx) { Optional.empty(), Optional.empty(), projectWithCast); } - private Plan bindHiveTableSink(MatchingContext> ctx) { - UnboundHiveTableSink sink = ctx.root; - Pair pair = bind(ctx.cascadesContext, sink); - HMSExternalDatabase database = pair.first; - HMSExternalTable table = pair.second; - LogicalPlan child = ((LogicalPlan) sink.child()); - - if (!sink.getPartitions().isEmpty()) { - throw new AnalysisException("Not support insert with partition spec in hive catalog."); - } - - // Fast-fail: if the table-level SD already declares an LZO InputFormat, reject immediately - // without entering the expensive partition-lookup path in bindDataSink(). - // Note: this is a best-effort early check. The definitive LZO guard lives in - // BaseExternalTableDataSink.getTFileFormatType(), which is called for both the table-level - // SD and every existing partition SD — covering the case where the table SD is plain text - // but individual partitions override it with an LZO InputFormat. - String inputFormat = table.getRemoteTable().getSd().getInputFormat(); - if (HiveUtil.isLzoInputFormat(inputFormat)) { - throw new AnalysisException("INSERT INTO is not supported for LZO Hive tables " - + "(input format: " + inputFormat + "). LZO tables are read-only in Doris."); - } - - List bindColumns; - if (sink.getColNames().isEmpty()) { - bindColumns = table.getBaseSchema(true).stream().collect(ImmutableList.toImmutableList()); - } else { - bindColumns = sink.getColNames().stream().map(cn -> { - Column column = table.getColumn(cn); - if (column == null) { - throw new AnalysisException(String.format("column %s is not found in table %s", - cn, table.getName())); - } - return column; - }).collect(ImmutableList.toImmutableList()); - } - LogicalHiveTableSink boundSink = new LogicalHiveTableSink<>( - database, - table, - bindColumns, - child.getOutput().stream() - .map(NamedExpression.class::cast) - .collect(ImmutableList.toImmutableList()), - sink.getDMLCommandType(), - Optional.empty(), - Optional.empty(), - child); - // we need to insert all the columns of the target table - if (boundSink.getCols().size() != child.getOutput().size()) { - throw new AnalysisException("insert into cols should be corresponding to the query output"); - } - Map columnToOutput = getColumnToOutput(ctx, table, false, false, - boundSink, child); - LogicalProject fullOutputProject = getOutputProjectByCoercion(table.getFullSchema(), child, columnToOutput); - return boundSink.withChildAndUpdateOutput(fullOutputProject); - } - - private Plan bindIcebergTableSink(MatchingContext> ctx) { - UnboundIcebergTableSink sink = ctx.root; - Pair pair = bind(ctx.cascadesContext, sink); - IcebergExternalDatabase database = pair.first; - IcebergExternalTable table = pair.second; - LogicalPlan child = ((LogicalPlan) sink.child()); - - // Get static partition columns if present - Map staticPartitions = sink.getStaticPartitionKeyValues(); - Set staticPartitionColNames = staticPartitions != null - ? staticPartitions.keySet() - : Sets.newHashSet(); - - // Validate static partition if present - if (sink.hasStaticPartition()) { - validateStaticPartition(sink, table); - } - - // Build bindColumns: exclude static partition columns from the columns that - // need to come from SELECT - // Because static partition column values come from PARTITION clause, not from - // SELECT - List bindColumns; - if (sink.getColNames().isEmpty()) { - // When no column names specified, include all non-static-partition columns - if (sink.isRewrite()) { - bindColumns = table.getBaseSchema(true).stream() - .filter(col -> !staticPartitionColNames.contains(col.getName())) - .filter(col -> col.isVisible() || IcebergUtils.isIcebergRowLineageColumn(col)) - .collect(ImmutableList.toImmutableList()); - } else { - bindColumns = table.getBaseSchema(true).stream() - .filter(col -> !staticPartitionColNames.contains(col.getName())) - .filter(Column::isVisible) - .collect(ImmutableList.toImmutableList()); - } - } else { - bindColumns = sink.getColNames().stream().map(cn -> { - Column column = table.getColumn(cn); - if (column == null) { - throw new AnalysisException(String.format("column %s is not found in table %s", - cn, table.getName())); - } - if (IcebergUtils.isIcebergRowLineageColumn(column)) { - throw new AnalysisException(String.format( - "Cannot specify row lineage column '%s' in INSERT statement", cn)); - } - return column; - }).collect(ImmutableList.toImmutableList()); - } - - LogicalIcebergTableSink boundSink = new LogicalIcebergTableSink<>( - database, - table, - bindColumns, - child.getOutput().stream() - .map(NamedExpression.class::cast) - .collect(ImmutableList.toImmutableList()), - sink.getDMLCommandType(), - Optional.empty(), - Optional.empty(), - child); - - // Check column count: SELECT columns should match bindColumns (excluding static - // partition columns) - if (boundSink.getCols().size() != child.getOutput().size()) { - throw new AnalysisException("insert into cols should be corresponding to the query output. " - + "Expected " + boundSink.getCols().size() + " columns but got " + child.getOutput().size()); - } - - Map columnToOutput = getColumnToOutput(ctx, table, false, false, - boundSink, child); - - // For static partition columns, add constant expressions from PARTITION clause - // This ensures partition column values are written to the data file - if (!staticPartitionColNames.isEmpty()) { - for (Map.Entry entry : staticPartitions.entrySet()) { - String colName = entry.getKey(); - Expression valueExpr = entry.getValue(); - Column column = table.getColumn(colName); - if (column != null) { - // Cast the literal to the correct column type - Expression castExpr = TypeCoercionUtils.castIfNotSameType( - valueExpr, DataType.fromCatalogType(column.getType())); - columnToOutput.put(colName, new Alias(castExpr, colName)); - } - } - } - - List insertSchema = table.getFullSchema(); - if (!sink.isRewrite()) { - insertSchema = insertSchema.stream() - .filter(Column::isVisible) - .collect(Collectors.toList()); - } - LogicalProject fullOutputProject = getOutputProjectByCoercion(insertSchema, child, columnToOutput); - return boundSink.withChildAndUpdateOutput(fullOutputProject); - } - /** - * Validate static partition specification for Iceberg table + * Connector analogue of the retired legacy iceberg static-partition validation: validates a + * flipped-connector table's + * static-partition spec through the neutral {@code ConnectorMetadata#validateStaticPartitionColumns} SPI, so + * the partition-spec knowledge (unknown column / non-identity transform / unpartitioned) and its messages + * stay in the connector (iceberg). A connector {@link DorisConnectorException} is surfaced as the + * analysis-time {@link AnalysisException} the legacy native path threw, preserving the user-facing message + * and the exception type. The literal-value check is connector-agnostic and stays here, where the Nereids + * expression is available. Plumbing mirrors {@code IcebergRowLevelDmlTransform.checkPluginMode}. */ - private void validateStaticPartition(UnboundIcebergTableSink sink, IcebergExternalTable table) { - Map staticPartitions = sink.getStaticPartitionKeyValues(); + private void checkConnectorStaticPartitions(PluginDrivenExternalTable table, + Map staticPartitions, Set staticPartitionColNames) { if (staticPartitions == null || staticPartitions.isEmpty()) { return; } - - Table icebergTable = table.getIcebergTable(); - PartitionSpec partitionSpec = icebergTable.spec(); - - // Check if table is partitioned - if (!partitionSpec.isPartitioned()) { - throw new AnalysisException( - String.format("Table %s is not partitioned, cannot use static partition syntax", table.getName())); + if (!(table.getCatalog() instanceof PluginDrivenExternalCatalog)) { + return; } - - // Get partition field names - Map partitionFieldMap = Maps.newHashMap(); - for (PartitionField field : partitionSpec.fields()) { - String fieldName = field.name(); - partitionFieldMap.put(fieldName, field); + PluginDrivenExternalCatalog catalog = (PluginDrivenExternalCatalog) table.getCatalog(); + ConnectorSession session = catalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, catalog.getConnector()); + ConnectorTableHandle handle = metadata.getTableHandle( + session, table.getRemoteDbName(), table.getRemoteName()) + .orElseThrow(() -> new AnalysisException("Table not found: " + + table.getRemoteDbName() + "." + table.getRemoteName() + + " in catalog " + catalog.getName())); + try { + metadata.validateStaticPartitionColumns(session, handle, new ArrayList<>(staticPartitionColNames)); + } catch (DorisConnectorException e) { + throw new AnalysisException(e.getMessage(), e); } - - // Validate each static partition column + // Partition values must be literals (mirrors the retired legacy iceberg literal check; connector-agnostic). for (Map.Entry entry : staticPartitions.entrySet()) { - String partitionColName = entry.getKey(); - Expression partitionValue = entry.getValue(); - - // 1. Check if partition column exists - if (!partitionFieldMap.containsKey(partitionColName)) { - throw new AnalysisException( - String.format("Unknown partition column '%s' in table '%s'. Available partition columns: %s", - partitionColName, table.getName(), partitionFieldMap.keySet())); - } - - // 2. Check if it's an identity partition. - // Static partition overwrite is only supported for identity partitions. - PartitionField field = partitionFieldMap.get(partitionColName); - if (!field.transform().isIdentity()) { - throw new AnalysisException( - String.format("Cannot use static partition syntax for non-identity partition field '%s'" - + " (transform: %s).", partitionColName, field.transform().toString())); - } - - // 3. Validate partition value type must be a literal - if (!(partitionValue instanceof Literal)) { - throw new AnalysisException( - String.format("Partition value for column '%s' must be a literal, but got: %s", - partitionColName, partitionValue)); + if (!(entry.getValue() instanceof Literal)) { + throw new AnalysisException(String.format( + "Partition value for column '%s' must be a literal, but got: %s", + entry.getKey(), entry.getValue())); } } } - private Plan bindMaxComputeTableSink(MatchingContext> ctx) { - UnboundMaxComputeTableSink sink = ctx.root; - Pair pair = bind(ctx.cascadesContext, sink); - MaxComputeExternalDatabase database = pair.first; - MaxComputeExternalTable table = pair.second; - LogicalPlan child = ((LogicalPlan) sink.child()); - - Map staticPartitions = sink.getStaticPartitionKeyValues(); - Set staticPartitionColNames = staticPartitions != null - ? staticPartitions.keySet() - : Sets.newHashSet(); - - List bindColumns; - if (sink.getColNames().isEmpty()) { - bindColumns = table.getBaseSchema(true).stream() - .filter(col -> !staticPartitionColNames.contains(col.getName())) - .collect(ImmutableList.toImmutableList()); - } else { - bindColumns = sink.getColNames().stream().map(cn -> { - Column column = table.getColumn(cn); - if (column == null) { - throw new AnalysisException(String.format("column %s is not found in table %s", - cn, table.getName())); - } - return column; - }).collect(ImmutableList.toImmutableList()); + /** + * Connector analogue of the legacy hive partition-spec reject (retired legacy {@code bindHiveTableSink}): + * rejects the dynamic partition-NAME list form ({@code INSERT ... PARTITION(p1, p2)}) through the neutral + * {@code ConnectorMetadata#validateWritePartitionNames} SPI, so the rejection and its message stay in the + * connector (hive rejects, iceberg accepts). A connector {@link DorisConnectorException} is surfaced as the + * analysis-time {@link AnalysisException} the legacy native path threw, preserving the message and exception + * type. The handle round-trip + SPI call happen only when the list is non-empty, so a plain {@code INSERT ... + * SELECT} (empty list) is byte-unchanged for every live connector. Mirrors {@link #checkConnectorStaticPartitions}. + */ + private void checkConnectorWritePartitionNames(PluginDrivenExternalTable table, List partitionNames) { + if (partitionNames == null || partitionNames.isEmpty()) { + return; } - LogicalMaxComputeTableSink boundSink = new LogicalMaxComputeTableSink<>( - database, - table, - bindColumns, - child.getOutput().stream() - .map(NamedExpression.class::cast) - .collect(ImmutableList.toImmutableList()), - sink.getDMLCommandType(), - Optional.empty(), - Optional.empty(), - child); - if (boundSink.getCols().size() != child.getOutput().size()) { - throw new AnalysisException("insert into cols should be corresponding to the query output"); + if (!(table.getCatalog() instanceof PluginDrivenExternalCatalog)) { + return; + } + PluginDrivenExternalCatalog catalog = (PluginDrivenExternalCatalog) table.getCatalog(); + ConnectorSession session = catalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, catalog.getConnector()); + ConnectorTableHandle handle = metadata.getTableHandle( + session, table.getRemoteDbName(), table.getRemoteName()) + .orElseThrow(() -> new AnalysisException("Table not found: " + + table.getRemoteDbName() + "." + table.getRemoteName() + + " in catalog " + catalog.getName())); + try { + metadata.validateWritePartitionNames(session, handle, partitionNames); + } catch (DorisConnectorException e) { + throw new AnalysisException(e.getMessage(), e); } - Map columnToOutput = getColumnToOutput(ctx, table, false, false, - boundSink, child); - LogicalProject fullOutputProject = getOutputProjectByCoercion(table.getFullSchema(), child, columnToOutput); - return boundSink.withChildAndUpdateOutput(fullOutputProject); } private Plan bindConnectorTableSink(MatchingContext> ctx) { @@ -932,19 +730,29 @@ private Plan bindConnectorTableSink(MatchingContext bindColumns; - if (sink.getColNames().isEmpty()) { - bindColumns = table.getBaseSchema(true).stream().collect(ImmutableList.toImmutableList()); - } else { - bindColumns = sink.getColNames().stream().map(cn -> { - Column column = table.getColumn(cn); - if (column == null) { - throw new AnalysisException(String.format("column %s is not found in table %s", - cn, table.getName())); - } - return column; - }).collect(ImmutableList.toImmutableList()); - } + // Static-partition columns (e.g. MaxCompute `PARTITION(pt='x')`) carry their value via the + // static partition spec rather than the query output, so they are excluded from the bound + // columns when no explicit column list is given (mirrors legacy bindMaxComputeTableSink). + Map staticPartitions = sink.getStaticPartitionKeyValues(); + Set staticPartitionColNames = staticPartitions != null + ? staticPartitions.keySet() + : Sets.newHashSet(); + + // Validate the static-partition spec against the connector's partition metadata (unknown column / + // non-identity transform / unpartitioned table) via the neutral SPI, so the iceberg PartitionSpec + // knowledge and its messages stay in the connector — the retired legacy validation never ran on this + // path. Fail loud at analysis time, before the write plan is synthesized (otherwise an unknown column is + // silently swallowed by the materialize block below and surfaces as an unrelated planning error). + checkConnectorStaticPartitions(table, staticPartitions, staticPartitionColNames); + + // Reject the dynamic partition-NAME list form (INSERT ... PARTITION(p1, p2)) via the neutral SPI, so the + // reject and its message stay in the connector (hive rejects with the legacy message; iceberg accepts). + // The retired legacy hive path threw "Not support insert with partition spec in hive catalog." here. + // Guarded on non-empty inside the helper, so a plain INSERT ... SELECT is byte-unchanged for live connectors. + checkConnectorWritePartitionNames(table, sink.getPartitions()); + + List bindColumns = selectConnectorSinkBindColumns( + table, sink.getColNames(), staticPartitionColNames, sink.isRewrite()); LogicalConnectorTableSink boundSink = new LogicalConnectorTableSink<>( database, table, @@ -953,22 +761,111 @@ private Plan bindConnectorTableSink(MatchingContext columnToOutput = getColumnToOutput(ctx, table, false, false, boundSink, child); + if (table.materializeStaticPartitionValues() && !staticPartitionColNames.isEmpty()) { + // Connectors whose data files RETAIN partition columns (e.g. Iceberg) must write the static + // partition value INTO the data column: getColumnToOutput excluded it from the bound columns + // and NULL-filled it, so re-project the PARTITION-clause literal here (mirrors the + // retired legacy iceberg bind). Connectors that STRIP partition columns and refill them from + // static_partition_values (e.g. MaxCompute) do NOT declare the capability and keep the NULL fill. + for (Map.Entry entry : staticPartitions.entrySet()) { + String colName = entry.getKey(); + Column column = table.getColumn(colName); + if (column != null) { + Expression castExpr = TypeCoercionUtils.castIfNotSameType( + entry.getValue(), DataType.fromCatalogType(column.getType())); + columnToOutput.put(colName, new Alias(castExpr, colName)); + } + } + } + // The BE writer validates its incoming data columns against the connector's write schema-json, + // which for an ORDINARY write is the DATA (visible) schema only; a rewrite (rewrite_data_files) + // additionally carries the engine-managed invisible columns (iceberg v3 row-lineage) that its + // rewrite schema-json declares. Projecting the full schema unconditionally would emit invisible + // columns an ordinary write's BE schema does not declare ("data columns N do not match schema + // columns M"), so drop them unless this is a rewrite — mirroring the retired legacy iceberg + // bind's insertSchema visible filter. Connectors with no invisible columns (e.g. MaxCompute) are + // unaffected: the filter is a no-op there. + List writeSchema = sink.isRewrite() + ? table.getFullSchema() + : table.getFullSchema().stream() + .filter(Column::isVisible) + .collect(ImmutableList.toImmutableList()); + LogicalProject fullOutputProject = + getOutputProjectByCoercion(writeSchema, child, columnToOutput); + return boundSink.withChildAndUpdateOutput(fullOutputProject); } - // For JDBC-backed connector tables, we must keep columns in user-specified order - // because the INSERT SQL column list is built from cols (user order) and the data - // values must match. For file-based writes, full schema order with defaults is needed. - // Currently only JDBC catalogs use connector sink, so use the JDBC-compatible approach: - // only project user-specified columns in user-specified order. + // Name-mapped connector tables (JDBC / ES): keep columns in user-specified order because the + // INSERT SQL column list is built from cols (user order) and the data values must match; only + // project user-specified columns in user order. Map columnToOutput = getConnectorColumnToOutput(bindColumns, child); LogicalProject outputProject = getOutputProjectByCoercion(bindColumns, child, columnToOutput); return boundSink.withChildAndUpdateOutput(outputProject); } + /** + * Selects the bound columns for a connector table sink. With an explicit column list, binds those + * columns in user order. Without one, binds the base schema minus any static partition columns + * (their value comes from the static partition spec, not the query output, so they must not be + * matched against the query columns) — mirrors legacy {@code bindMaxComputeTableSink}. + * + *

    Invisible columns (e.g. iceberg v3 row-lineage {@code _row_id} / + * {@code _last_updated_sequence_number}) are excluded from an ordinary write's default target — the + * user never supplies their values, so counting them would break the "insert cols == query output" + * check. They are RETAINED for a {@code rewrite} (a distributed {@code rewrite_data_files} reads and + * rewrites full rows, preserving the engine-managed lineage values), mirroring the retired + * legacy iceberg bind's rewrite branch. The {@code isVisible} / {@code isRewrite} split is + * connector-agnostic, so no source-specific code enters the generic SPI path. + */ + @VisibleForTesting + static List selectConnectorSinkBindColumns(PluginDrivenExternalTable table, + List colNames, Set staticPartitionColNames, boolean isRewrite) { + if (colNames.isEmpty()) { + return table.getBaseSchema(true).stream() + .filter(col -> !staticPartitionColNames.contains(col.getName())) + .filter(col -> isRewrite || col.isVisible()) + .collect(ImmutableList.toImmutableList()); + } + return colNames.stream().map(cn -> { + Column column = table.getColumn(cn); + if (column == null) { + throw new AnalysisException(String.format("column %s is not found in table %s", + cn, table.getName())); + } + // Reject explicitly naming an engine-managed invisible column (e.g. iceberg v3 row-lineage + // _row_id / _last_updated_sequence_number) in an ordinary INSERT: the user never supplies + // its value. RETAINED for a rewrite (rewrite_data_files reads/rewrites full rows, preserving + // the engine-managed values), mirroring the isVisible/isRewrite split of the empty-colNames + // branch above. Uses only Column.isVisible(), so no source-specific code enters the generic + // SPI path (replaces the retired legacy source-specific iceberg row-lineage guard). + if (!isRewrite && !column.isVisible()) { + throw new AnalysisException(String.format( + "Cannot specify invisible column '%s' in INSERT statement", cn)); + } + return column; + }).collect(ImmutableList.toImmutableList()); + } + /** * Build column-to-output mapping for connector table sinks. * Maps each user-specified column to the corresponding child output expression @@ -1066,45 +963,6 @@ private Pair bind(CascadesContext cascadesContext, Unboun ? ((RemoteDorisExternalTable) pair.second).getOlapTable() : (OlapTable) pair.second); } - private Pair bind(CascadesContext cascadesContext, - UnboundHiveTableSink sink) { - List tableQualifier = RelationUtil.getQualifierName(cascadesContext.getConnectContext(), - sink.getNameParts()); - Pair, TableIf> pair = RelationUtil.getDbAndTable(tableQualifier, - cascadesContext.getConnectContext().getEnv(), Optional.empty()); - if (pair.second instanceof HMSExternalTable) { - HMSExternalTable table = (HMSExternalTable) pair.second; - if (table.getDlaType() == HMSExternalTable.DLAType.HIVE) { - return Pair.of(((HMSExternalDatabase) pair.first), table); - } - } - throw new AnalysisException("the target table of insert into is not a Hive table"); - } - - private Pair bind(CascadesContext cascadesContext, - UnboundIcebergTableSink sink) { - List tableQualifier = RelationUtil.getQualifierName(cascadesContext.getConnectContext(), - sink.getNameParts()); - Pair, TableIf> pair = RelationUtil.getDbAndTable(tableQualifier, - cascadesContext.getConnectContext().getEnv(), Optional.empty()); - if (pair.second instanceof IcebergExternalTable) { - return Pair.of(((IcebergExternalDatabase) pair.first), (IcebergExternalTable) pair.second); - } - throw new AnalysisException("the target table of insert into is not an iceberg table"); - } - - private Pair bind(CascadesContext cascadesContext, - UnboundMaxComputeTableSink sink) { - List tableQualifier = RelationUtil.getQualifierName(cascadesContext.getConnectContext(), - sink.getNameParts()); - Pair, TableIf> pair = RelationUtil.getDbAndTable(tableQualifier, - cascadesContext.getConnectContext().getEnv(), Optional.empty()); - if (pair.second instanceof MaxComputeExternalTable) { - return Pair.of(((MaxComputeExternalDatabase) pair.first), (MaxComputeExternalTable) pair.second); - } - throw new AnalysisException("the target table of insert into is not a MaxCompute table"); - } - @SuppressWarnings("rawtypes") private Pair bind(CascadesContext cascadesContext, UnboundConnectorTableSink sink) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/CheckPolicy.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/CheckPolicy.java index 8c100726716d9f..a9635041d5f014 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/CheckPolicy.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/CheckPolicy.java @@ -17,17 +17,23 @@ package org.apache.doris.nereids.rules.analysis; -import org.apache.doris.datasource.hive.HMSExternalTable; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.datasource.connector.converter.ConnectorExpressionToNereidsConverter; +import org.apache.doris.datasource.mvcc.MvccSnapshot; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.nereids.StatementContext; import org.apache.doris.nereids.analyzer.UnboundRelation; import org.apache.doris.nereids.rules.Rule; import org.apache.doris.nereids.rules.RuleType; import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.expressions.Slot; +import org.apache.doris.nereids.trees.expressions.SlotReference; import org.apache.doris.nereids.trees.plans.Plan; import org.apache.doris.nereids.trees.plans.logical.LogicalAggregate; import org.apache.doris.nereids.trees.plans.logical.LogicalCheckPolicy; import org.apache.doris.nereids.trees.plans.logical.LogicalCheckPolicy.RelatedPolicy; +import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan; import org.apache.doris.nereids.trees.plans.logical.LogicalFilter; -import org.apache.doris.nereids.trees.plans.logical.LogicalHudiScan; import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; import org.apache.doris.nereids.trees.plans.logical.LogicalProject; import org.apache.doris.nereids.trees.plans.logical.LogicalRelation; @@ -37,8 +43,11 @@ import com.google.common.collect.ImmutableList; +import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -80,12 +89,16 @@ public List buildRules() { Set combineFilter = new LinkedHashSet<>(); // replace incremental params as AND expression - if (relation instanceof LogicalHudiScan) { - LogicalHudiScan hudiScan = (LogicalHudiScan) relation; - if (hudiScan.getTable() instanceof HMSExternalTable) { - combineFilter.addAll(hudiScan.generateIncrementalExpression( - hudiScan.getLogicalProperties().getOutput())); - } + if (relation instanceof LogicalFileScan + && ((LogicalFileScan) relation).getTable() instanceof PluginDrivenExternalTable + && ((LogicalFileScan) relation).getScanParams().isPresent()) { + // Neutral synthetic-predicate injection for an SPI-driven (plugin) scan: the + // connector supplies a residual predicate the engine must apply (e.g. a hudi @incr + // _hoodie_commit_time commit-time window), which fe-core reverse-converts into an + // AND filter WITHOUT branching on the source. iceberg/paimon/... return empty, so + // nothing is added and the plan stays byte-identical (the iron-rule guarantee). + combineFilter.addAll(collectConnectorSyntheticPredicates( + (LogicalFileScan) relation, ctx.cascadesContext.getStatementContext())); } RelatedPolicy relatedPolicy = checkPolicy.findPolicy(relation, ctx.cascadesContext); @@ -108,6 +121,42 @@ public List buildRules() { ); } + /** + * Collects the connector's synthetic scan predicates for a plugin {@link LogicalFileScan} and reverse-converts + * them into bound Nereids conjuncts. The connector-neutral {@link ConnectorExpression}s (e.g. a hudi @incr + * {@code _hoodie_commit_time} window) come from the SPI; fe-core only binds their column refs to the scan's + * output slots by name and maps the node shapes back to Nereids — it never branches on the source. Empty for + * every non-opting connector (iceberg/paimon/...) and every non-incremental read, so the plan is unchanged. + */ + private Set collectConnectorSyntheticPredicates(LogicalFileScan scan, + StatementContext statementContext) { + PluginDrivenExternalTable table = (PluginDrivenExternalTable) scan.getTable(); + // The MVCC snapshot resolved at analysis time (StatementContext.loadSnapshots) carries the + // connector-resolved window — the SAME single resolution the scan-time applySnapshot threads onto the + // handle, so the row filter and the file selection can never diverge. + MvccSnapshot snapshot = statementContext + .getSnapshot(table, scan.getTableSnapshot(), scan.getScanParams()) + .orElse(null); + if (snapshot == null) { + return Collections.emptySet(); + } + List predicates = table.getSyntheticScanPredicates(snapshot); + if (predicates.isEmpty()) { + return Collections.emptySet(); + } + Map boundSlots = new HashMap<>(); + for (Slot slot : scan.getLogicalProperties().getOutput()) { + if (slot instanceof SlotReference) { + boundSlots.put(slot.getName(), (SlotReference) slot); + } + } + Set result = new LinkedHashSet<>(); + for (ConnectorExpression predicate : predicates) { + result.add(ConnectorExpressionToNereidsConverter.convert(predicate, boundSlots)); + } + return result; + } + // logicalView() or logicalSubQueryAlias(logicalView()) private boolean isView(Plan plan) { if (plan instanceof LogicalView) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/UserAuthentication.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/UserAuthentication.java index 1f67afe4fae55a..541939b3e2b7db 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/UserAuthentication.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/UserAuthentication.java @@ -27,8 +27,7 @@ import org.apache.doris.common.ErrorReport; import org.apache.doris.common.UserException; import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.iceberg.IcebergSysExternalTable; -import org.apache.doris.datasource.paimon.PaimonSysExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenSysExternalTable; import org.apache.doris.mysql.privilege.AccessControllerManager; import org.apache.doris.mysql.privilege.PrivPredicate; import org.apache.doris.qe.ConnectContext; @@ -54,11 +53,12 @@ public static void checkPermission(TableIf table, ConnectContext connectContext, } TableIf authTable = table; Set authColumns = columns; - if (table instanceof PaimonSysExternalTable) { - authTable = ((PaimonSysExternalTable) table).getSourceTable(); - authColumns = Collections.emptySet(); - } else if (table instanceof IcebergSysExternalTable) { - authTable = ((IcebergSysExternalTable) table).getSourceTable(); + if (table instanceof PluginDrivenSysExternalTable) { + // After the SPI cutover a paimon sys-table ($snapshots/$files/...) is a + // PluginDrivenSysExternalTable; authorize against its source table (mirrors the + // legacy PaimonSysExternalTable branch above), so a user holding SELECT on db.tbl + // can query db.tbl$snapshots. + authTable = ((PluginDrivenSysExternalTable) table).getSourceTable(); authColumns = Collections.emptySet(); } String tableName = authTable.getName(); diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/ExpressionRewrite.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/ExpressionRewrite.java index 54b2b9a3395aff..6080f319ab8876 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/ExpressionRewrite.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/ExpressionRewrite.java @@ -108,9 +108,6 @@ public List buildRules() { new LogicalCteConsumerRewrite().build(), new LogicalResultSinkRewrite().build(), new LogicalFileSinkRewrite().build(), - new LogicalHiveTableSinkRewrite().build(), - new LogicalIcebergTableSinkRewrite().build(), - new LogicalMaxComputeTableSinkRewrite().build(), new LogicalIcebergMergeSinkRewrite().build(), new LogicalConnectorTableSinkRewrite().build(), new LogicalOlapTableSinkRewrite().build(), @@ -503,30 +500,6 @@ public Rule build() { } } - private class LogicalHiveTableSinkRewrite extends OneRewriteRuleFactory { - @Override - public Rule build() { - return logicalHiveTableSink().thenApply(ExpressionRewrite.this::applyRewriteToSink) - .toRule(RuleType.REWRITE_SINK_EXPRESSION); - } - } - - private class LogicalIcebergTableSinkRewrite extends OneRewriteRuleFactory { - @Override - public Rule build() { - return logicalIcebergTableSink().thenApply(ExpressionRewrite.this::applyRewriteToSink) - .toRule(RuleType.REWRITE_SINK_EXPRESSION); - } - } - - private class LogicalMaxComputeTableSinkRewrite extends OneRewriteRuleFactory { - @Override - public Rule build() { - return logicalMaxComputeTableSink().thenApply(ExpressionRewrite.this::applyRewriteToSink) - .toRule(RuleType.REWRITE_SINK_EXPRESSION); - } - } - private class LogicalIcebergMergeSinkRewrite extends OneRewriteRuleFactory { @Override public Rule build() { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/AggregateStrategies.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/AggregateStrategies.java index e91d458a098c81..c566d59ade5b92 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/AggregateStrategies.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/AggregateStrategies.java @@ -50,7 +50,6 @@ import org.apache.doris.nereids.trees.plans.logical.LogicalAggregate; import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan; import org.apache.doris.nereids.trees.plans.logical.LogicalFilter; -import org.apache.doris.nereids.trees.plans.logical.LogicalHudiScan; import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan; import org.apache.doris.nereids.trees.plans.logical.LogicalProject; import org.apache.doris.nereids.trees.plans.logical.LogicalRelation; @@ -768,8 +767,7 @@ private LogicalAggregate storageLayerAggregate( } } else if (logicalScan instanceof LogicalFileScan) { - Rule rule = (logicalScan instanceof LogicalHudiScan) ? new LogicalHudiScanToPhysicalHudiScan().build() - : new LogicalFileScanToPhysicalFileScan().build(); + Rule rule = new LogicalFileScanToPhysicalFileScan().build(); PhysicalFileScan physicalScan = (PhysicalFileScan) rule.transform(logicalScan, cascadesContext) .get(0); if (project != null) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalConnectorTableSinkToPhysicalConnectorTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalConnectorTableSinkToPhysicalConnectorTableSink.java index 8460d5df748891..d58ae8f40963fb 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalConnectorTableSinkToPhysicalConnectorTableSink.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalConnectorTableSinkToPhysicalConnectorTableSink.java @@ -42,6 +42,7 @@ public Rule build() { sink.getLogicalProperties(), null, null, + sink.isRewrite(), sink.child()); }).toRule(RuleType.LOGICAL_CONNECTOR_TABLE_SINK_TO_PHYSICAL_CONNECTOR_TABLE_SINK_RULE); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalFileScanToPhysicalFileScan.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalFileScanToPhysicalFileScan.java index 2b258052dfd868..526e0de3a73f1b 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalFileScanToPhysicalFileScan.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalFileScanToPhysicalFileScan.java @@ -20,7 +20,6 @@ import org.apache.doris.nereids.properties.DistributionSpecAny; import org.apache.doris.nereids.rules.Rule; import org.apache.doris.nereids.rules.RuleType; -import org.apache.doris.nereids.trees.plans.logical.LogicalHudiScan; import org.apache.doris.nereids.trees.plans.physical.PhysicalFileScan; import java.util.Optional; @@ -31,7 +30,7 @@ public class LogicalFileScanToPhysicalFileScan extends OneImplementationRuleFactory { @Override public Rule build() { - return logicalFileScan().when(plan -> !(plan instanceof LogicalHudiScan)).then(fileScan -> + return logicalFileScan().then(fileScan -> new PhysicalFileScan( fileScan.getRelationId(), fileScan.getTable(), diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalHiveTableSinkToPhysicalHiveTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalHiveTableSinkToPhysicalHiveTableSink.java deleted file mode 100644 index 153216d6ac765f..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalHiveTableSinkToPhysicalHiveTableSink.java +++ /dev/null @@ -1,48 +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.rules.implementation; - -import org.apache.doris.nereids.rules.Rule; -import org.apache.doris.nereids.rules.RuleType; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.logical.LogicalHiveTableSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalHiveTableSink; - -import java.util.Optional; - -/** - * Implementation rule that convert logical HiveTableSink to physical HiveTableSink. - */ -public class LogicalHiveTableSinkToPhysicalHiveTableSink extends OneImplementationRuleFactory { - @Override - public Rule build() { - return logicalHiveTableSink().thenApply(ctx -> { - LogicalHiveTableSink sink = ctx.root; - return new PhysicalHiveTableSink<>( - sink.getDatabase(), - sink.getTargetTable(), - sink.getCols(), - sink.getOutputExprs(), - Optional.empty(), - sink.getLogicalProperties(), - null, - null, - sink.child()); - }).toRule(RuleType.LOGICAL_HIVE_TABLE_SINK_TO_PHYSICAL_HIVE_TABLE_SINK_RULE); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalHudiScanToPhysicalHudiScan.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalHudiScanToPhysicalHudiScan.java deleted file mode 100644 index 97ed60c6078167..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalHudiScanToPhysicalHudiScan.java +++ /dev/null @@ -1,49 +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.rules.implementation; - -import org.apache.doris.nereids.properties.DistributionSpecAny; -import org.apache.doris.nereids.rules.Rule; -import org.apache.doris.nereids.rules.RuleType; -import org.apache.doris.nereids.trees.plans.physical.PhysicalHudiScan; - -import java.util.Optional; - -/** - * Implementation rule that convert logical HudiScan to physical HudiScan. - */ -public class LogicalHudiScanToPhysicalHudiScan extends OneImplementationRuleFactory { - @Override - public Rule build() { - return logicalHudiScan().then(fileScan -> - new PhysicalHudiScan( - fileScan.getRelationId(), - fileScan.getTable(), - fileScan.getQualifier(), - DistributionSpecAny.INSTANCE, - Optional.empty(), - fileScan.getLogicalProperties(), - fileScan.getSelectedPartitions(), - fileScan.getTableSample(), - fileScan.getTableSnapshot(), - fileScan.getScanParams(), - fileScan.getIncrementalRelation(), - fileScan.getOperativeSlots()) - ).toRule(RuleType.LOGICAL_HUDI_SCAN_TO_PHYSICAL_HUDI_SCAN_RULE); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalIcebergTableSinkToPhysicalIcebergTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalIcebergTableSinkToPhysicalIcebergTableSink.java deleted file mode 100644 index c520ef83f2730c..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalIcebergTableSinkToPhysicalIcebergTableSink.java +++ /dev/null @@ -1,48 +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.rules.implementation; - -import org.apache.doris.nereids.rules.Rule; -import org.apache.doris.nereids.rules.RuleType; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.logical.LogicalIcebergTableSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergTableSink; - -import java.util.Optional; - -/** - * Implementation rule that convert logical IcebergTableSink to physical IcebergTableSink. - */ -public class LogicalIcebergTableSinkToPhysicalIcebergTableSink extends OneImplementationRuleFactory { - @Override - public Rule build() { - return logicalIcebergTableSink().thenApply(ctx -> { - LogicalIcebergTableSink sink = ctx.root; - return new PhysicalIcebergTableSink<>( - sink.getDatabase(), - sink.getTargetTable(), - sink.getCols(), - sink.getOutputExprs(), - Optional.empty(), - sink.getLogicalProperties(), - null, - null, - sink.child()); - }).toRule(RuleType.LOGICAL_ICEBERG_TABLE_SINK_TO_PHYSICAL_ICEBERG_TABLE_SINK_RULE); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalMaxComputeTableSinkToPhysicalMaxComputeTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalMaxComputeTableSinkToPhysicalMaxComputeTableSink.java deleted file mode 100644 index b73fd0e5d841da..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalMaxComputeTableSinkToPhysicalMaxComputeTableSink.java +++ /dev/null @@ -1,48 +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.rules.implementation; - -import org.apache.doris.nereids.rules.Rule; -import org.apache.doris.nereids.rules.RuleType; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.logical.LogicalMaxComputeTableSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalMaxComputeTableSink; - -import java.util.Optional; - -/** - * Implementation rule that converts logical MaxComputeTableSink to physical MaxComputeTableSink. - */ -public class LogicalMaxComputeTableSinkToPhysicalMaxComputeTableSink extends OneImplementationRuleFactory { - @Override - public Rule build() { - return logicalMaxComputeTableSink().thenApply(ctx -> { - LogicalMaxComputeTableSink sink = ctx.root; - return new PhysicalMaxComputeTableSink<>( - sink.getDatabase(), - sink.getTargetTable(), - sink.getCols(), - sink.getOutputExprs(), - Optional.empty(), - sink.getLogicalProperties(), - null, - null, - sink.child()); - }).toRule(RuleType.LOGICAL_MAX_COMPUTE_TABLE_SINK_TO_PHYSICAL_MAX_COMPUTE_TABLE_SINK_RULE); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PruneFileScanPartition.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PruneFileScanPartition.java index f3822215e8c4c7..4a0a4d99dcbbf5 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PruneFileScanPartition.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PruneFileScanPartition.java @@ -76,7 +76,8 @@ private SelectedPartitions pruneExternalPartitions(ExternalTable externalTable, LogicalFilter filter, LogicalFileScan scan, CascadesContext ctx) { Map selectedPartitionItems = Maps.newHashMap(); if (CollectionUtils.isEmpty(externalTable.getPartitionColumns( - ctx.getStatementContext().getSnapshot(externalTable)))) { + ctx.getStatementContext().getSnapshot(externalTable, + scan.getTableSnapshot(), scan.getScanParams())))) { // non partitioned table, return NOT_PRUNED. // non partition table will be handled in HiveScanNode. return SelectedPartitions.NOT_PRUNED; @@ -85,7 +86,8 @@ private SelectedPartitions pruneExternalPartitions(ExternalTable externalTable, .stream() .collect(Collectors.toMap(slot -> slot.getName().toLowerCase(), Function.identity())); List partitionSlots = externalTable.getPartitionColumns( - ctx.getStatementContext().getSnapshot(externalTable)) + ctx.getStatementContext().getSnapshot(externalTable, + scan.getTableSnapshot(), scan.getScanParams())) .stream() .map(column -> scanOutput.get(column.getName().toLowerCase())) .collect(Collectors.toList()); diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/SlotTypeReplacer.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/SlotTypeReplacer.java index 6a8fd28b902ba7..5e3b601df33958 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/SlotTypeReplacer.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/SlotTypeReplacer.java @@ -21,7 +21,7 @@ import org.apache.doris.analysis.ColumnAccessPathType; import org.apache.doris.catalog.Column; import org.apache.doris.common.Pair; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.nereids.exceptions.AnalysisException; import org.apache.doris.nereids.properties.OrderKey; import org.apache.doris.nereids.rules.rewrite.NestedColumnPruning.DataTypeAccessTree; @@ -377,7 +377,12 @@ public Plan visitLogicalFileScan(LogicalFileScan fileScan, Void context) { Pair> replaced = replaceExpressions(fileScan.getOutput(), false, true); if (replaced.first) { List replaceSlots = new ArrayList<>(replaced.second); - if (fileScan.getTable() instanceof IcebergExternalTable) { + // Gate the name-to-field-id access-path rewrite on the nested-column-prune capability (not the + // legacy exact-class IcebergExternalTable, which is dead post-flip — the table is a + // PluginDrivenExternalTable). The translation below is connector-agnostic: it reads + // column.getUniqueId()/getChildren(), which the connector populates with its stable field ids. + if (fileScan.getTable() instanceof PluginDrivenExternalTable + && ((PluginDrivenExternalTable) fileScan.getTable()).supportsNestedColumnPrune()) { for (int i = 0; i < replaceSlots.size(); i++) { Slot slot = replaceSlots.get(i); if (!(slot instanceof SlotReference)) { @@ -389,8 +394,8 @@ public Plan visitLogicalFileScan(LogicalFileScan fileScan, Void context) { continue; } List allAccessPathsWithId - = replaceIcebergAccessPathToId(allAccessPaths.get(), slotReference); - List predicateAccessPathsWithId = replaceIcebergAccessPathToId( + = replaceAccessPathToFieldId(allAccessPaths.get(), slotReference); + List predicateAccessPathsWithId = replaceAccessPathToFieldId( slotReference.getPredicateAccessPaths().get(), slotReference); replaceSlots.set(i, ((SlotReference) slot).withAccessPaths( allAccessPathsWithId, @@ -621,37 +626,37 @@ private Expression rewriteCast(Cast cast, boolean fillAccessPath) { return new Cast(newChild, newType); } - private List replaceIcebergAccessPathToId( + private List replaceAccessPathToFieldId( List originAccessPaths, SlotReference slotReference) { Column column = slotReference.getOriginalColumn().get(); List replacedAccessPaths = new ArrayList<>(); for (ColumnAccessPath accessPath : originAccessPaths) { - List icebergColumnAccessPath = new ArrayList<>(accessPath.getPath()); - replaceIcebergAccessPathToId( - icebergColumnAccessPath, 0, slotReference.getDataType(), column + List fieldIdAccessPath = new ArrayList<>(accessPath.getPath()); + replaceAccessPathToFieldId( + fieldIdAccessPath, 0, slotReference.getDataType(), column ); - replacedAccessPaths.add(new ColumnAccessPath(accessPath.getType(), icebergColumnAccessPath)); + replacedAccessPaths.add(new ColumnAccessPath(accessPath.getType(), fieldIdAccessPath)); } return replacedAccessPaths; } - private void replaceIcebergAccessPathToId(List originPath, int index, DataType type, Column column) { + private void replaceAccessPathToFieldId(List originPath, int index, DataType type, Column column) { if (index >= originPath.size()) { return; } if (index == 0) { originPath.set(index, String.valueOf(column.getUniqueId())); - replaceIcebergAccessPathToId(originPath, index + 1, type, column); + replaceAccessPathToFieldId(originPath, index + 1, type, column); } else { String fieldName = originPath.get(index); if (type instanceof ArrayType) { // skip replace * - replaceIcebergAccessPathToId( + replaceAccessPathToFieldId( originPath, index + 1, ((ArrayType) type).getItemType(), column.getChildren().get(0) ); } else if (type instanceof MapType) { if (fieldName.equals(AccessPathInfo.ACCESS_ALL) || fieldName.equals(AccessPathInfo.ACCESS_MAP_VALUES)) { - replaceIcebergAccessPathToId( + replaceAccessPathToFieldId( originPath, index + 1, ((MapType) type).getValueType(), column.getChildren().get(1) ); } @@ -660,7 +665,7 @@ private void replaceIcebergAccessPathToId(List originPath, int index, Da if (child.getName().equals(fieldName)) { originPath.set(index, String.valueOf(child.getUniqueId())); DataType childType = ((StructType) type).getNameToFields().get(fieldName).getDataType(); - replaceIcebergAccessPathToId(originPath, index + 1, childType, child); + replaceAccessPathToFieldId(originPath, index + 1, childType, child); break; } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/stats/StatsCalculator.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/stats/StatsCalculator.java index 982109aa1ea4b8..ed7bd33fbd6cf4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/stats/StatsCalculator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/stats/StatsCalculator.java @@ -78,7 +78,6 @@ import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan; import org.apache.doris.nereids.trees.plans.logical.LogicalFilter; import org.apache.doris.nereids.trees.plans.logical.LogicalGenerate; -import org.apache.doris.nereids.trees.plans.logical.LogicalHudiScan; import org.apache.doris.nereids.trees.plans.logical.LogicalIntersect; import org.apache.doris.nereids.trees.plans.logical.LogicalJoin; import org.apache.doris.nereids.trees.plans.logical.LogicalLimit; @@ -845,11 +844,6 @@ public Statistics visitLogicalFileScan(LogicalFileScan fileScan, Void context) { return computeCatalogRelation(fileScan); } - @Override - public Statistics visitLogicalHudiScan(LogicalHudiScan fileScan, Void context) { - return computeCatalogRelation(fileScan); - } - @Override public Statistics visitLogicalTVFRelation(LogicalTVFRelation tvfRelation, Void context) { return tvfRelation.getFunction().computeStats(tvfRelation.getOutput()); diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/PlanType.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/PlanType.java index 2f5dcbb7cfce9f..17f62f0f057370 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/PlanType.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/PlanType.java @@ -48,8 +48,6 @@ public enum PlanType { // logical sinks LOGICAL_FILE_SINK, LOGICAL_OLAP_TABLE_SINK, - LOGICAL_HIVE_TABLE_SINK, - LOGICAL_ICEBERG_TABLE_SINK, LOGICAL_MAX_COMPUTE_TABLE_SINK, LOGICAL_ICEBERG_DELETE_SINK, LOGICAL_ICEBERG_MERGE_SINK, @@ -111,7 +109,6 @@ public enum PlanType { PHYSICAL_EMPTY_RELATION, PHYSICAL_ES_SCAN, PHYSICAL_FILE_SCAN, - PHYSICAL_HUDI_SCAN, PHYSICAL_JDBC_SCAN, PHYSICAL_ODBC_SCAN, PHYSICAL_ONE_ROW_RELATION, @@ -123,8 +120,6 @@ public enum PlanType { // physical sinks PHYSICAL_FILE_SINK, PHYSICAL_OLAP_TABLE_SINK, - PHYSICAL_HIVE_TABLE_SINK, - PHYSICAL_ICEBERG_TABLE_SINK, PHYSICAL_MAX_COMPUTE_TABLE_SINK, PHYSICAL_ICEBERG_DELETE_SINK, PHYSICAL_ICEBERG_MERGE_SINK, diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AnalyzeTableCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AnalyzeTableCommand.java index 05ff1f976b40ef..eb1a9896c45cc9 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AnalyzeTableCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AnalyzeTableCommand.java @@ -32,7 +32,7 @@ import org.apache.doris.common.FeNameFormat; import org.apache.doris.common.UserException; import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.hive.HMSExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.mysql.privilege.PrivPredicate; import org.apache.doris.nereids.trees.plans.PlanType; import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; @@ -312,13 +312,18 @@ public boolean isPartitionOnly() { * isSamplingPartition */ public boolean isSamplingPartition() { - if (!(table instanceof HMSExternalTable) || partitionNames != null) { + // A plain-hive table is a PluginDrivenExternalTable declaring SUPPORTS_SAMPLE_ANALYZE per-table. + // iceberg/hudi-on-HMS and native iceberg/paimon do not declare it, so they stay + // non-partition-sampled as before. + boolean sampleable = table instanceof PluginDrivenExternalTable + && ((PluginDrivenExternalTable) table).supportsSampleAnalyze(); + if (!sampleable || partitionNames != null) { return false; } int partNum = ConnectContext.get().getSessionVariable().getExternalTableAnalyzePartNum(); - if (partNum == -1 || partitionNames != null) { + if (partNum == -1) { return false; } - return table instanceof HMSExternalTable && table.getPartitionNames().size() > partNum; + return table.getPartitionNames().size() > partNum; } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/DeleteFromCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/DeleteFromCommand.java index 67a0a81932df2e..f28934b316aff6 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/DeleteFromCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/DeleteFromCommand.java @@ -40,7 +40,6 @@ import org.apache.doris.common.ErrorCode; import org.apache.doris.common.ErrorReport; import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; import org.apache.doris.mysql.privilege.PrivPredicate; import org.apache.doris.nereids.CascadesContext; import org.apache.doris.nereids.NereidsPlanner; @@ -141,17 +140,14 @@ public void run(ConnectContext ctx, StmtExecutor executor) throws Exception { // Table not found, will be handled by regular error flow } - // Route to IcebergDeleteCommand for Iceberg tables - if (table instanceof org.apache.doris.datasource.iceberg.IcebergExternalTable) { - LOG.info("Routing DELETE to IcebergDeleteCommand for table: {}", table.getName()); - org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext deleteCtx = - new org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext(); - deleteCtx.setDeleteFileType(org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext - .DeleteFileType.POSITION_DELETE); - IcebergDeleteCommand icebergDeleteCommand = new IcebergDeleteCommand( - nameParts, tableAlias, isTempPart, partitions, logicalQuery, - deleteCtx); - icebergDeleteCommand.run(ctx, executor); + // Route row-level DML on external tables (e.g. iceberg) through the generic shell. + Optional transform = RowLevelDmlRegistry.find(table); + if (transform.isPresent()) { + DeleteCommandContext deleteCtx = new DeleteCommandContext(); + deleteCtx.setDeleteFileType(DeleteCommandContext.DeleteFileType.POSITION_DELETE); + RowLevelDmlArgs args = RowLevelDmlArgs.forDelete( + table, nameParts, tableAlias, isTempPart, partitions, logicalQuery, deleteCtx); + new RowLevelDmlCommand(transform.get(), args, RowLevelDmlOp.DELETE).run(ctx, executor); return; } @@ -503,12 +499,13 @@ public R accept(PlanVisitor visitor, C context) { public Plan getExplainPlan(ConnectContext ctx) { List qualifiedTableName = RelationUtil.getQualifierName(ctx, nameParts); TableIf table = RelationUtil.getTable(qualifiedTableName, ctx.getEnv(), Optional.empty()); - if (table instanceof IcebergExternalTable) { + Optional transform = RowLevelDmlRegistry.find(table); + if (transform.isPresent()) { DeleteCommandContext deleteCtx = new DeleteCommandContext(); deleteCtx.setDeleteFileType(DeleteCommandContext.DeleteFileType.POSITION_DELETE); - IcebergDeleteCommand icebergDeleteCommand = new IcebergDeleteCommand( - nameParts, tableAlias, isTempPart, partitions, logicalQuery, deleteCtx); - return icebergDeleteCommand.getExplainPlan(ctx); + RowLevelDmlArgs args = RowLevelDmlArgs.forDelete( + table, nameParts, tableAlias, isTempPart, partitions, logicalQuery, deleteCtx); + return new RowLevelDmlCommand(transform.get(), args, RowLevelDmlOp.DELETE).getExplainPlan(ctx); } return completeQueryPlan(ctx, logicalQuery); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExecuteActionCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExecuteActionCommand.java index 0f78c816b305e1..2001dd1103526b 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExecuteActionCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExecuteActionCommand.java @@ -27,8 +27,8 @@ import org.apache.doris.common.DdlException; import org.apache.doris.common.UserException; import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.ExternalObjectLog; import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.log.ExternalObjectLog; import org.apache.doris.nereids.trees.expressions.Expression; import org.apache.doris.nereids.trees.plans.PlanType; import org.apache.doris.nereids.trees.plans.commands.execute.ExecuteAction; diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExecuteCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExecuteCommand.java index df50bbf6266ecd..45b5c7d70410ee 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExecuteCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExecuteCommand.java @@ -89,6 +89,10 @@ public void run(ConnectContext ctx, StmtExecutor executor) throws Exception { StatementContext statementContext = preparedStmtCtx.getStatementContext(); statementContext.setPrepareStage(false); statementContext.setIsInsert(false); + // A prepared EXECUTE reuses this one StatementContext across executions; drop the connector + // per-statement scope so a prior execution's cached tables/state never leak into this one (the + // scope key's queryId is a second line of defense). See StatementContext#resetConnectorStatementScope. + statementContext.resetConnectorStatementScope(); LogicalPlan logicalPlan = prepareCommand.getLogicalPlan(); if (logicalPlan instanceof LogicalSqlCache) { throw new AnalysisException("Unsupported sql cache for server prepared statement"); diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExplainCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExplainCommand.java index cb3785ae1062cc..9278a82ba747e8 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExplainCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExplainCommand.java @@ -97,17 +97,17 @@ public void run(ConnectContext ctx, StmtExecutor executor) throws Exception { new NereidsPlanner(ctx.getStatementContext()) ); - long previousTargetTableId = ctx.getIcebergRowIdTargetTableId(); + long previousTargetTableId = ctx.getSyntheticWriteColTargetTableId(); boolean resetTargetTableId = false; if (explainPlan instanceof LogicalIcebergDeleteSink) { if (previousTargetTableId < 0) { - ctx.setIcebergRowIdTargetTableId( + ctx.setSyntheticWriteColTargetTableId( ((LogicalIcebergDeleteSink) explainPlan).getTargetTable().getId()); resetTargetTableId = true; } } else if (explainPlan instanceof LogicalIcebergMergeSink) { if (previousTargetTableId < 0) { - ctx.setIcebergRowIdTargetTableId( + ctx.setSyntheticWriteColTargetTableId( ((LogicalIcebergMergeSink) explainPlan).getTargetTable().getId()); resetTargetTableId = true; } @@ -134,7 +134,7 @@ public void run(ConnectContext ctx, StmtExecutor executor) throws Exception { } } finally { if (resetTargetTableId) { - ctx.setIcebergRowIdTargetTableId(previousTargetTableId); + ctx.setSyntheticWriteColTargetTableId(previousTargetTableId); } } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergDeleteCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergDeleteCommand.java index 370c164d19311e..83fa42d25f71ce 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergDeleteCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergDeleteCommand.java @@ -17,53 +17,32 @@ package org.apache.doris.nereids.trees.plans.commands; -import org.apache.doris.analysis.StmtType; import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.iceberg.IcebergConflictDetectionFilterUtils; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergMergeOperation; -import org.apache.doris.datasource.iceberg.IcebergNereidsUtils; -import org.apache.doris.nereids.NereidsPlanner; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.ExternalTable; import org.apache.doris.nereids.analyzer.UnboundAlias; import org.apache.doris.nereids.analyzer.UnboundSlot; -import org.apache.doris.nereids.exceptions.AnalysisException; -import org.apache.doris.nereids.glue.LogicalPlanAdapter; import org.apache.doris.nereids.trees.expressions.NamedExpression; import org.apache.doris.nereids.trees.expressions.Slot; import org.apache.doris.nereids.trees.expressions.literal.TinyIntLiteral; -import org.apache.doris.nereids.trees.plans.Explainable; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.PlanType; import org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext; -import org.apache.doris.nereids.trees.plans.commands.insert.IcebergDeleteExecutor; +import org.apache.doris.nereids.trees.plans.commands.merge.MergeOperation; import org.apache.doris.nereids.trees.plans.logical.LogicalIcebergDeleteSink; import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; import org.apache.doris.nereids.trees.plans.logical.LogicalProject; -import org.apache.doris.nereids.trees.plans.physical.PhysicalEmptyRelation; -import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergDeleteSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; -import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; -import org.apache.doris.nereids.util.RelationUtil; import org.apache.doris.nereids.util.Utils; -import org.apache.doris.planner.DataSink; -import org.apache.doris.planner.PlanFragment; import org.apache.doris.qe.ConnectContext; -import org.apache.doris.qe.StmtExecutor; -import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import java.util.List; import java.util.Optional; -import java.util.concurrent.Callable; /** - * DELETE command for Iceberg tables. + * DELETE plan synthesizer for Iceberg tables, invoked by + * IcebergRowLevelDmlTransform.synthesize via {@link #completeQueryPlan}. * - * This command converts DELETE operations to INSERT operations that generate + * It rewrites a DELETE into an insert-shaped plan that generates * position DeleteFile entries instead of data files. * * Example: @@ -73,8 +52,10 @@ * 1. Scan rows matching the WHERE condition * 2. Generate DeleteFile containing the matching rows * 3. Commit the DeleteFile to Iceberg table using RowDelta API + * + * The legacy Command execution half was removed as dead post-cutover code. */ -public class IcebergDeleteCommand extends Command implements ForwardWithSync, Explainable { +public class IcebergDeleteCommand { protected final List nameParts; protected final String tableAlias; @@ -93,7 +74,6 @@ public IcebergDeleteCommand( List partitions, LogicalPlan logicalQuery, DeleteCommandContext deleteCtx) { - super(PlanType.DELETE_COMMAND); this.nameParts = Utils.copyRequiredList(nameParts); this.tableAlias = tableAlias; this.isTempPart = isTempPart; @@ -102,108 +82,18 @@ public IcebergDeleteCommand( this.deleteCtx = deleteCtx != null ? deleteCtx : new DeleteCommandContext(); } - @Override - public void run(ConnectContext ctx, StmtExecutor executor) throws Exception { - // Check if target table is Iceberg table - List qualifiedTableName = RelationUtil.getQualifierName(ctx, nameParts); - TableIf table = RelationUtil.getTable(qualifiedTableName, ctx.getEnv(), Optional.empty()); - - if (!(table instanceof IcebergExternalTable)) { - throw new AnalysisException("DELETE command can only be used on Iceberg tables. " - + "Table " + Util.getTempTableDisplayName(table.getName()) + " is not an Iceberg table."); - } - - IcebergExternalTable icebergTable = (IcebergExternalTable) table; - IcebergDmlCommandUtils.checkDeleteMode(icebergTable); - - // Verify table format version (must be v2+ for delete support) - // org.apache.iceberg.Table icebergTableObj = icebergTable.getIcebergTable(); - // String formatVersionStr = icebergTableObj.properties().get("format-version"); - // int formatVersion = formatVersionStr != null ? Integer.parseInt(formatVersionStr) : 1; - // if (formatVersion < 2) { - // throw new AnalysisException("Iceberg table DELETE requires format version >= 2. " - // + "Current format version: " + formatVersion); - // } - - long previousTargetTableId = ctx.getIcebergRowIdTargetTableId(); - ctx.setIcebergRowIdTargetTableId(icebergTable.getId()); - try { - LogicalPlan deleteQueryPlan = completeQueryPlan(ctx, logicalQuery, icebergTable); - executeWithExternalTableBatchModeDisabled(ctx, () -> { - // Create planner and plan the delete operation - NereidsPlanner planner = new NereidsPlanner(ctx.getStatementContext()); - LogicalPlanAdapter logicalPlanAdapter = - new LogicalPlanAdapter(deleteQueryPlan, ctx.getStatementContext()); - - // Plan the delete query to generate physical plan and distributed plan - planner.plan(logicalPlanAdapter, ctx.getSessionVariable().toThrift()); - - // Set planner in executor for later use - executor.setPlanner(planner); - executor.checkBlockRules(); - Optional conflictFilter = - IcebergConflictDetectionFilterUtils.buildConflictDetectionFilter( - planner.getAnalyzedPlan(), icebergTable); - - PhysicalSink physicalSink = getPhysicalSink(planner); - PlanFragment fragment = planner.getFragments().get(0); - DataSink dataSink = fragment.getSink(); - boolean emptyInsert = childIsEmptyRelation(physicalSink); - String label = String.format("iceberg_delete_%x_%x", ctx.queryId().hi, ctx.queryId().lo); - - // Create IcebergDeleteExecutor and execute - IcebergDeleteExecutor deleteExecutor = new IcebergDeleteExecutor( - ctx, - icebergTable, - label, - planner, - emptyInsert, - -1L); - deleteExecutor.setConflictDetectionFilter(conflictFilter); - - if (deleteExecutor.isEmptyInsert()) { - return null; - } - - deleteExecutor.beginTransaction(); - deleteExecutor.finalizeSinkForDelete(fragment, dataSink, physicalSink); - deleteExecutor.getCoordinator().setTxnId(deleteExecutor.getTxnId()); - executor.setCoord(deleteExecutor.getCoordinator()); - deleteExecutor.executeSingleInsert(executor); - return null; - }); - } finally { - ctx.setIcebergRowIdTargetTableId(previousTargetTableId); - } - } - - @VisibleForTesting - static T executeWithExternalTableBatchModeDisabled( - ConnectContext ctx, Callable action) throws Exception { - boolean previousEnableExternalTableBatchMode = - ctx.getSessionVariable().enableExternalTableBatchMode; - // disable batch mode for iceberg scan node get all splits. - // IcebergRewritableDeletePlanner.collect for map list> - ctx.getSessionVariable().enableExternalTableBatchMode = false; - try { - return action.call(); - } finally { - ctx.getSessionVariable().enableExternalTableBatchMode = - previousEnableExternalTableBatchMode; - } - } - /** * Complete the query plan by adding necessary columns for position delete operation. * Select $row_id (file_path, row_position, partition info). */ - private LogicalPlan completeQueryPlan(ConnectContext ctx, LogicalPlan logicalQuery, - IcebergExternalTable icebergTable) { + // package-visible: the generic RowLevelDmlCommand shell delegates synthesis here (T07c). + LogicalPlan completeQueryPlan(ConnectContext ctx, LogicalPlan logicalQuery, + ExternalTable icebergTable) { LogicalPlan queryPlan = buildPositionDeletePlan(ctx, logicalQuery, icebergTable); // Convert output to NamedExpression list List outputExprs; - if (!IcebergNereidsUtils.hasUnboundPlan(queryPlan)) { + if (!RowLevelDmlRowIdUtils.hasUnboundPlan(queryPlan)) { outputExprs = queryPlan.getOutput().stream() .map(slot -> (NamedExpression) slot) .collect(java.util.stream.Collectors.toList()); @@ -215,7 +105,7 @@ private LogicalPlan completeQueryPlan(ConnectContext ctx, LogicalPlan logicalQue // Wrap query plan with LogicalIcebergDeleteSink LogicalIcebergDeleteSink deleteSink = new LogicalIcebergDeleteSink<>( - (IcebergExternalDatabase) icebergTable.getDatabase(), + (ExternalDatabase) icebergTable.getDatabase(), icebergTable, icebergTable.getBaseSchema(true), // cols outputExprs, // outputExprs @@ -239,18 +129,18 @@ private LogicalPlan completeQueryPlan(ConnectContext ctx, LogicalPlan logicalQue * 4. These will be written to Position Delete file */ private LogicalPlan buildPositionDeletePlan(ConnectContext ctx, LogicalPlan logicalQuery, - IcebergExternalTable icebergTable) { + ExternalTable icebergTable) { // Step 1: Inject $row_id metadata column into the scan - LogicalPlan planWithRowId = IcebergNereidsUtils.injectRowIdColumn(logicalQuery); + LogicalPlan planWithRowId = RowLevelDmlRowIdUtils.injectRowIdColumn(logicalQuery); // Step 2: Project operation + __DORIS_ICEBERG_ROWID_COL__ Optional rowIdSlot = Optional.empty(); - if (!IcebergNereidsUtils.hasUnboundPlan(planWithRowId)) { - rowIdSlot = IcebergNereidsUtils.findRowIdSlot(planWithRowId.getOutput()); + if (!RowLevelDmlRowIdUtils.hasUnboundPlan(planWithRowId)) { + rowIdSlot = RowLevelDmlRowIdUtils.findRowIdSlot(planWithRowId.getOutput()); } NamedExpression operationColumn = new UnboundAlias( - new TinyIntLiteral(IcebergMergeOperation.DELETE_OPERATION_NUMBER), - IcebergMergeOperation.OPERATION_COLUMN); + new TinyIntLiteral(MergeOperation.DELETE_OPERATION_NUMBER), + MergeOperation.OPERATION_COLUMN); NamedExpression rowIdColumn = rowIdSlot.isPresent() ? (NamedExpression) rowIdSlot.get() : new UnboundSlot(Column.ICEBERG_ROWID_COL); @@ -259,52 +149,6 @@ private LogicalPlan buildPositionDeletePlan(ConnectContext ctx, LogicalPlan logi return new LogicalProject<>(projectItems, planWithRowId); } - private PhysicalSink getPhysicalSink(NereidsPlanner planner) { - Optional> plan = planner.getPhysicalPlan() - .>collect(PhysicalSink.class::isInstance).stream().findAny(); - if (!plan.isPresent()) { - throw new AnalysisException("DELETE command must contain target table"); - } - PhysicalSink sink = plan.get(); - if (!(sink instanceof PhysicalIcebergDeleteSink)) { - throw new AnalysisException("DELETE plan must use Iceberg delete sink"); - } - return sink; - } - - private boolean childIsEmptyRelation(PhysicalSink sink) { - return sink.children() != null && sink.children().size() == 1 - && sink.child(0) instanceof PhysicalEmptyRelation; - } - - @Override - public Plan getExplainPlan(ConnectContext ctx) { - List qualifiedTableName = RelationUtil.getQualifierName(ctx, nameParts); - TableIf table = RelationUtil.getTable(qualifiedTableName, ctx.getEnv(), Optional.empty()); - if (!(table instanceof IcebergExternalTable)) { - throw new AnalysisException("Table must be IcebergExternalTable in DELETE command"); - } - IcebergExternalTable icebergTable = (IcebergExternalTable) table; - IcebergDmlCommandUtils.checkDeleteMode(icebergTable); - long previousTargetTableId = ctx.getIcebergRowIdTargetTableId(); - ctx.setIcebergRowIdTargetTableId(table.getId()); - try { - return completeQueryPlan(ctx, logicalQuery, icebergTable); - } finally { - ctx.setIcebergRowIdTargetTableId(previousTargetTableId); - } - } - - @Override - public R accept(PlanVisitor visitor, C context) { - return visitor.visitCommand(this, context); - } - - @Override - public StmtType stmtType() { - return StmtType.DELETE; - } - public DeleteCommandContext getDeleteCtx() { return deleteCtx; } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergDmlCommandUtils.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergDmlCommandUtils.java deleted file mode 100644 index df721b14380d02..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergDmlCommandUtils.java +++ /dev/null @@ -1,61 +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; - -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.nereids.exceptions.AnalysisException; - -import org.apache.iceberg.RowLevelOperationMode; -import org.apache.iceberg.TableProperties; - -import java.util.Map; - -/** - * Helpers for Iceberg row-level DML commands. - */ -final class IcebergDmlCommandUtils { - private IcebergDmlCommandUtils() { - } - - static void checkDeleteMode(IcebergExternalTable table) { - checkNotCopyOnWrite(table, "DELETE", TableProperties.DELETE_MODE, - TableProperties.DELETE_MODE_DEFAULT); - } - - static void checkUpdateMode(IcebergExternalTable table) { - checkNotCopyOnWrite(table, "UPDATE", TableProperties.UPDATE_MODE, - TableProperties.UPDATE_MODE_DEFAULT); - } - - static void checkMergeMode(IcebergExternalTable table) { - checkNotCopyOnWrite(table, "MERGE INTO", TableProperties.MERGE_MODE, - TableProperties.MERGE_MODE_DEFAULT); - } - - private static void checkNotCopyOnWrite(IcebergExternalTable table, String operation, - String modeProperty, String defaultMode) { - Map properties = table.getIcebergTable().properties(); - String mode = properties.getOrDefault(modeProperty, defaultMode); - if (RowLevelOperationMode.COPY_ON_WRITE.modeName().equalsIgnoreCase(mode)) { - throw new AnalysisException(String.format( - "Doris does not support %s on Iceberg copy-on-write tables. " - + "Set table property '%s' to 'merge-on-read'.", - operation, modeProperty)); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergMergeCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergMergeCommand.java index 59eb6a6d6acc26..de6d63895a8fdf 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergMergeCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergMergeCommand.java @@ -17,24 +17,16 @@ package org.apache.doris.nereids.trees.plans.commands; -import org.apache.doris.analysis.StmtType; import org.apache.doris.catalog.Column; import org.apache.doris.catalog.TableIf; import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.iceberg.IcebergConflictDetectionFilterUtils; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergMergeOperation; -import org.apache.doris.datasource.iceberg.IcebergNereidsUtils; -import org.apache.doris.datasource.iceberg.IcebergRowId; -import org.apache.doris.datasource.iceberg.IcebergUtils; -import org.apache.doris.nereids.NereidsPlanner; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.ExternalTable; import org.apache.doris.nereids.analyzer.UnboundAlias; import org.apache.doris.nereids.analyzer.UnboundRelation; import org.apache.doris.nereids.analyzer.UnboundSlot; import org.apache.doris.nereids.analyzer.UnboundStar; import org.apache.doris.nereids.exceptions.AnalysisException; -import org.apache.doris.nereids.glue.LogicalPlanAdapter; import org.apache.doris.nereids.parser.LogicalPlanBuilderAssistant; import org.apache.doris.nereids.parser.NereidsParser; import org.apache.doris.nereids.rules.exploration.join.JoinReorderContext; @@ -50,35 +42,23 @@ import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral; import org.apache.doris.nereids.trees.expressions.literal.NullLiteral; import org.apache.doris.nereids.trees.expressions.literal.TinyIntLiteral; -import org.apache.doris.nereids.trees.plans.Explainable; import org.apache.doris.nereids.trees.plans.JoinType; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.PlanType; import org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext; -import org.apache.doris.nereids.trees.plans.commands.insert.IcebergMergeExecutor; import org.apache.doris.nereids.trees.plans.commands.merge.MergeMatchedClause; import org.apache.doris.nereids.trees.plans.commands.merge.MergeNotMatchedClause; +import org.apache.doris.nereids.trees.plans.commands.merge.MergeOperation; import org.apache.doris.nereids.trees.plans.logical.LogicalFilter; import org.apache.doris.nereids.trees.plans.logical.LogicalIcebergMergeSink; import org.apache.doris.nereids.trees.plans.logical.LogicalJoin; import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; import org.apache.doris.nereids.trees.plans.logical.LogicalProject; import org.apache.doris.nereids.trees.plans.logical.LogicalSubQueryAlias; -import org.apache.doris.nereids.trees.plans.physical.PhysicalEmptyRelation; -import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergMergeSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; -import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; import org.apache.doris.nereids.types.DataType; import org.apache.doris.nereids.types.IntegerType; import org.apache.doris.nereids.util.RelationUtil; import org.apache.doris.nereids.util.Utils; -import org.apache.doris.planner.DataSink; -import org.apache.doris.planner.PlanFragment; import org.apache.doris.qe.ConnectContext; -import org.apache.doris.qe.QueryState; -import org.apache.doris.qe.StmtExecutor; -import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Lists; @@ -89,12 +69,12 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.concurrent.Callable; /** - * MERGE INTO command for Iceberg tables. + * Iceberg MERGE INTO plan synthesizer, invoked via IcebergRowLevelDmlTransform.synthesize + * (legacy execution half removed as dead post-cutover code). */ -public class IcebergMergeCommand extends Command implements ForwardWithSync, Explainable { +public class IcebergMergeCommand { private static final String BRANCH_LABEL = "__DORIS_ICEBERG_MERGE_INTO_BRANCH_LABEL__"; private final List targetNameParts; @@ -113,7 +93,6 @@ public class IcebergMergeCommand extends Command implements ForwardWithSync, Exp public IcebergMergeCommand(List targetNameParts, Optional targetAlias, Optional cte, LogicalPlan source, Expression onClause, List matchedClauses, List notMatchedClauses) { - super(PlanType.MERGE_INTO_COMMAND); this.targetNameParts = Utils.copyRequiredList(targetNameParts); this.targetAlias = Objects.requireNonNull(targetAlias, "targetAlias should not be null"); if (targetAlias.isPresent()) { @@ -131,53 +110,6 @@ public IcebergMergeCommand(List targetNameParts, Optional target this.deleteCtx = new DeleteCommandContext(); } - @Override - public void run(ConnectContext ctx, StmtExecutor executor) throws Exception { - TableIf table = getTargetTable(ctx); - if (!(table instanceof IcebergExternalTable)) { - throw new AnalysisException("MERGE INTO can only be used on Iceberg tables. " - + "Table " + Util.getTempTableDisplayName(table.getName()) + " is not an Iceberg table."); - } - IcebergExternalTable icebergTable = (IcebergExternalTable) table; - IcebergDmlCommandUtils.checkMergeMode(icebergTable); - long previousTargetTableId = ctx.getIcebergRowIdTargetTableId(); - ctx.setIcebergRowIdTargetTableId(icebergTable.getId()); - try { - LogicalPlan mergePlan = buildMergePlan(ctx, icebergTable); - executeMergePlan(ctx, executor, icebergTable, mergePlan); - } finally { - ctx.setIcebergRowIdTargetTableId(previousTargetTableId); - } - } - - @Override - public Plan getExplainPlan(ConnectContext ctx) { - TableIf table = getTargetTable(ctx); - if (!(table instanceof IcebergExternalTable)) { - throw new AnalysisException("MERGE INTO can only be used on Iceberg tables. " - + "Table " + Util.getTempTableDisplayName(table.getName()) + " is not an Iceberg table."); - } - IcebergExternalTable icebergTable = (IcebergExternalTable) table; - IcebergDmlCommandUtils.checkMergeMode(icebergTable); - long previousTargetTableId = ctx.getIcebergRowIdTargetTableId(); - ctx.setIcebergRowIdTargetTableId(icebergTable.getId()); - try { - return buildMergePlan(ctx, icebergTable); - } finally { - ctx.setIcebergRowIdTargetTableId(previousTargetTableId); - } - } - - @Override - public R accept(PlanVisitor visitor, C context) { - return visitor.visitCommand(this, context); - } - - @Override - public StmtType stmtType() { - return StmtType.MERGE_INTO; - } - private TableIf getTargetTable(ConnectContext ctx) { List qualifiedTableName = RelationUtil.getQualifierName(ctx, targetNameParts); return RelationUtil.getTable(qualifiedTableName, ctx.getEnv(), Optional.empty()); @@ -237,10 +169,10 @@ private NamedExpression generateBranchLabel(Expression rowIdExpr) { private List buildDeleteProjection(Expression rowIdExpr, List columns) { List projection = new ArrayList<>(); - projection.add(new TinyIntLiteral(IcebergMergeOperation.DELETE_OPERATION_NUMBER)); + projection.add(new TinyIntLiteral(MergeOperation.DELETE_OPERATION_NUMBER)); projection.add(rowIdExpr); for (Column column : columns) { - if (!column.isVisible() && !IcebergUtils.isIcebergRowLineageColumn(column)) { + if (!column.isVisible() && !column.isReservedPassthrough()) { continue; } List nameParts = Lists.newArrayList(targetNameInPlan); @@ -262,10 +194,10 @@ private List buildUpdateProjection(MergeMatchedClause clause, Expres } } List projection = new ArrayList<>(); - projection.add(new TinyIntLiteral(IcebergMergeOperation.UPDATE_OPERATION_NUMBER)); + projection.add(new TinyIntLiteral(MergeOperation.UPDATE_OPERATION_NUMBER)); projection.add(rowIdExpr); for (Column column : columns) { - if (IcebergUtils.isIcebergRowLineageColumn(column)) { + if (column.isReservedPassthrough()) { List nameParts = Lists.newArrayList(targetNameInPlan); nameParts.add(column.getName()); projection.add(new UnboundSlot(nameParts)); @@ -319,12 +251,12 @@ private List buildInsertProjection(MergeNotMatchedClause clause, } List projection = new ArrayList<>(); - projection.add(new TinyIntLiteral(IcebergMergeOperation.INSERT_OPERATION_NUMBER)); + projection.add(new TinyIntLiteral(MergeOperation.INSERT_OPERATION_NUMBER)); projection.add(new NullLiteral(rowIdType)); int visibleIndex = 0; for (Column column : columns) { - if (IcebergUtils.isIcebergRowLineageColumn(column)) { + if (column.isReservedPassthrough()) { projection.add(new NullLiteral(DataType.fromCatalogType(column.getType()))); continue; } @@ -387,27 +319,30 @@ private List generateFinalProjections(List colNames, return output; } - private LogicalPlan buildMergeProjectPlan(ConnectContext ctx, IcebergExternalTable icebergTable) { + private LogicalPlan buildMergeProjectPlan(ConnectContext ctx, ExternalTable icebergTable) { List columns = icebergTable.getBaseSchema(true); LogicalPlan plan = generateBasePlan(); plan = injectRowIdColumn(plan, icebergTable); Expression rowIdExpr = getTargetRowIdSlot(); - if (!IcebergNereidsUtils.hasUnboundPlan(plan)) { - Optional rowIdSlot = IcebergNereidsUtils.findRowIdSlot(plan.getOutput()); + if (!RowLevelDmlRowIdUtils.hasUnboundPlan(plan)) { + Optional rowIdSlot = RowLevelDmlRowIdUtils.findRowIdSlot(plan.getOutput()); if (rowIdSlot.isPresent()) { rowIdExpr = rowIdSlot.get(); } } - boolean hasRowLineageColumns = columns.stream().anyMatch(IcebergUtils::isIcebergRowLineageColumn); List outputProjections = new ArrayList<>(); outputProjections.add(new UnboundStar(ImmutableList.of())); if (!Util.showHiddenColumns()) { outputProjections.add((NamedExpression) rowIdExpr); - if (hasRowLineageColumns) { - outputProjections.add(getTargetRowLineageSlot(IcebergUtils.ICEBERG_ROW_ID_COL)); - outputProjections.add(getTargetRowLineageSlot(IcebergUtils.ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL)); + // Pass through the connector-reserved row-lineage columns in schema order (the connector appends + // _row_id before _last_updated_sequence_number), read via the neutral reservedPassthrough marker + // instead of matching iceberg column names. + for (Column column : columns) { + if (column.isReservedPassthrough()) { + outputProjections.add(getTargetRowLineageSlot(column.getName())); + } } } outputProjections.add(generateBranchLabel(rowIdExpr)); @@ -430,10 +365,10 @@ private LogicalPlan buildMergeProjectPlan(ConnectContext ctx, IcebergExternalTab } List colNames = new ArrayList<>(); - colNames.add(IcebergMergeOperation.OPERATION_COLUMN); + colNames.add(MergeOperation.OPERATION_COLUMN); colNames.add(Column.ICEBERG_ROWID_COL); for (Column column : columns) { - if (column.isVisible() || IcebergUtils.isIcebergRowLineageColumn(column)) { + if (column.isVisible() || column.isReservedPassthrough()) { colNames.add(column.getName()); } } @@ -445,11 +380,12 @@ private LogicalPlan buildMergeProjectPlan(ConnectContext ctx, IcebergExternalTab return plan; } - private LogicalPlan buildMergePlan(ConnectContext ctx, IcebergExternalTable icebergTable) { + // package-visible: the generic RowLevelDmlCommand shell delegates synthesis here (T07c). + LogicalPlan buildMergePlan(ConnectContext ctx, ExternalTable icebergTable) { LogicalPlan projectPlan = buildMergeProjectPlan(ctx, icebergTable); List outputExprs; - if (!IcebergNereidsUtils.hasUnboundPlan(projectPlan)) { + if (!RowLevelDmlRowIdUtils.hasUnboundPlan(projectPlan)) { outputExprs = projectPlan.getOutput().stream() .map(NamedExpression.class::cast) .collect(ImmutableList.toImmutableList()); @@ -460,7 +396,7 @@ private LogicalPlan buildMergePlan(ConnectContext ctx, IcebergExternalTable iceb } return new LogicalIcebergMergeSink<>( - (IcebergExternalDatabase) icebergTable.getDatabase(), + (ExternalDatabase) icebergTable.getDatabase(), icebergTable, icebergTable.getBaseSchema(true), outputExprs, @@ -470,82 +406,11 @@ private LogicalPlan buildMergePlan(ConnectContext ctx, IcebergExternalTable iceb projectPlan); } - private boolean executeMergePlan(ConnectContext ctx, StmtExecutor executor, - IcebergExternalTable icebergTable, - LogicalPlan logicalPlan) throws Exception { - return executeWithExternalTableBatchModeDisabled(ctx, () -> { - LogicalPlanAdapter logicalPlanAdapter = - new LogicalPlanAdapter(logicalPlan, ctx.getStatementContext()); - NereidsPlanner planner = new NereidsPlanner(ctx.getStatementContext()); - planner.plan(logicalPlanAdapter, ctx.getSessionVariable().toThrift()); - executor.setPlanner(planner); - executor.checkBlockRules(); - Optional conflictFilter = - IcebergConflictDetectionFilterUtils.buildConflictDetectionFilter( - planner.getAnalyzedPlan(), icebergTable); - - PhysicalSink physicalSink = getPhysicalMergeSink(planner); - PlanFragment fragment = planner.getFragments().get(0); - DataSink dataSink = fragment.getSink(); - boolean emptyInsert = childIsEmptyRelation(physicalSink); - String label = String.format("iceberg_merge_into_%x_%x", ctx.queryId().hi, ctx.queryId().lo); - - IcebergMergeExecutor insertExecutor = - new IcebergMergeExecutor(ctx, icebergTable, label, planner, emptyInsert, -1L); - insertExecutor.setConflictDetectionFilter(conflictFilter); - - if (insertExecutor.isEmptyInsert()) { - return true; - } - - insertExecutor.beginTransaction(); - insertExecutor.finalizeSinkForMerge(fragment, dataSink, physicalSink); - insertExecutor.getCoordinator().setTxnId(insertExecutor.getTxnId()); - executor.setCoord(insertExecutor.getCoordinator()); - insertExecutor.executeSingleInsert(executor); - return ctx.getState().getStateType() != QueryState.MysqlStateType.ERR; - }); - } - - @VisibleForTesting - static T executeWithExternalTableBatchModeDisabled( - ConnectContext ctx, Callable action) throws Exception { - boolean previousEnableExternalTableBatchMode = - ctx.getSessionVariable().enableExternalTableBatchMode; - // disable batch mode for iceberg scan node get all splits. - // IcebergRewritableDeletePlanner.collect for map list> - ctx.getSessionVariable().enableExternalTableBatchMode = false; - try { - return action.call(); - } finally { - ctx.getSessionVariable().enableExternalTableBatchMode = - previousEnableExternalTableBatchMode; - } - } - - private PhysicalSink getPhysicalMergeSink(NereidsPlanner planner) { - Optional> plan = planner.getPhysicalPlan() - .>collect(PhysicalSink.class::isInstance).stream().findAny(); - if (!plan.isPresent()) { - throw new AnalysisException("MERGE INTO command must contain target table"); - } - PhysicalSink sink = plan.get(); - if (!(sink instanceof PhysicalIcebergMergeSink)) { - throw new AnalysisException("MERGE INTO plan must use Iceberg merge sink"); - } - return sink; - } - - private boolean childIsEmptyRelation(PhysicalSink sink) { - return sink.children() != null && sink.children().size() == 1 - && sink.child(0) instanceof PhysicalEmptyRelation; - } - - private LogicalPlan injectRowIdColumn(LogicalPlan plan, IcebergExternalTable targetTable) { - if (IcebergNereidsUtils.hasUnboundPlan(plan)) { + private LogicalPlan injectRowIdColumn(LogicalPlan plan, ExternalTable targetTable) { + if (RowLevelDmlRowIdUtils.hasUnboundPlan(plan)) { return plan; } - return IcebergNereidsUtils.injectRowIdColumn(plan, targetTable); + return RowLevelDmlRowIdUtils.injectRowIdColumn(plan, targetTable); } private Expression getTargetRowIdSlot() { @@ -558,8 +423,4 @@ private NamedExpression getTargetRowLineageSlot(String columnName) { return new UnboundSlot(nameParts); } - private static Column getRowIdColumn(IcebergExternalTable table) { - return IcebergNereidsUtils.getRowIdColumn(table); - } - } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataColumn.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergMetadataColumn.java similarity index 98% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataColumn.java rename to fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergMetadataColumn.java index bad7e37b25bc28..2d3e3716e51f67 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataColumn.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergMetadataColumn.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource.iceberg; +package org.apache.doris.nereids.trees.plans.commands; import org.apache.doris.catalog.ScalarType; import org.apache.doris.catalog.Type; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergRowId.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergRowId.java similarity index 97% rename from fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergRowId.java rename to fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergRowId.java index 40a51b05a4f3fc..50c85520c9b052 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergRowId.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergRowId.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource.iceberg; +package org.apache.doris.nereids.trees.plans.commands; import org.apache.doris.catalog.Column; import org.apache.doris.catalog.PrimitiveType; diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergRowLevelDmlTransform.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergRowLevelDmlTransform.java new file mode 100644 index 00000000000000..fb2c5d679f2ea7 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergRowLevelDmlTransform.java @@ -0,0 +1,235 @@ +// 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; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.TableIf; +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.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.connector.converter.WriteConstraintExtractor; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; +import org.apache.doris.nereids.NereidsPlanner; +import org.apache.doris.nereids.exceptions.AnalysisException; +import org.apache.doris.nereids.trees.expressions.SlotReference; +import org.apache.doris.nereids.trees.plans.Plan; +import org.apache.doris.nereids.trees.plans.commands.insert.BaseExternalTableInsertExecutor; +import org.apache.doris.nereids.trees.plans.commands.insert.PluginDrivenInsertExecutor; +import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; +import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergDeleteSink; +import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergMergeSink; +import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; +import org.apache.doris.planner.DataSink; +import org.apache.doris.planner.PlanFragment; +import org.apache.doris.qe.ConnectContext; + +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; + +/** + * Iceberg {@link RowLevelDmlTransform}: routes {@code DELETE}/{@code UPDATE}/{@code MERGE INTO} on iceberg + * tables through the generic {@link RowLevelDmlCommand} shell. + * + *

    Per the T07c "delegated synthesis" decision, the iceberg plan-synthesis algebra is not relocated: + * {@link #synthesize} constructs the corresponding {@code Iceberg*Command} (same package) and calls its + * (now package-visible) synthesis method, so the synthesized {@code LogicalIceberg{Delete,Merge}Sink} tree is + * byte-identical to legacy. The per-executor-only bits (conflict-filter stash, finalize) are routed here via + * {@code instanceof}-free op switches; the O5-2 exclusion predicate mirrors legacy + * {@code IcebergConflictDetectionFilterUtils} (note the {@code equalsIgnoreCase} vs {@code equals} asymmetry).

    + */ +public class IcebergRowLevelDmlTransform implements RowLevelDmlTransform { + + /** + * Slots excluded from the O5-2 target-only write constraint: the synthetic {@code $row_id} column and + * iceberg metadata columns. Mirrors legacy {@code IcebergConflictDetectionFilterUtils.isTargetOnlyPredicate} + * exactly — keep the {@code equalsIgnoreCase} (rowid) vs {@code equals} (metadata) asymmetry. + */ + private static final Predicate ICEBERG_EXCLUSION = + slot -> Column.ICEBERG_ROWID_COL.equalsIgnoreCase(slot.getName()) + || IcebergMetadataColumn.isMetadataColumn(slot.getName()); + + @Override + public boolean handles(TableIf table) { + return table instanceof PluginDrivenExternalTable + && pluginConnectorSupportsRowLevelDml((PluginDrivenExternalTable) table); + } + + /** + * A plugin-driven (SPI connector) table is routed through the iceberg row-level DML synthesis only if + * its connector declares row-level DML support ({@code supportsDelete()} or {@code supportsMerge()}). + * Mirrors the connector-capability probe in + * {@code InsertOverwriteTableCommand.pluginConnectorSupportsInsertOverwrite}. + * + *

    This gate is op-agnostic by design: {@code RowLevelDmlRegistry.find} carries no operation, so it + * admits "supports any row-level DML"; per-op validity (e.g. UPDATE against a delete-only connector) is + * enforced later in {@link #checkMode}.

    + * + *

    Today only the iceberg connector declares these capabilities (every other SPI connector inherits + * the {@code ConnectorWriteOps} default {@code false}).

    + */ + private static boolean pluginConnectorSupportsRowLevelDml(PluginDrivenExternalTable table) { + // Per-handle write-op probe: a heterogeneous gateway admits row-level DML for its iceberg tables only. + Set ops = table.connectorSupportedWriteOperations(); + return ops.contains(WriteOperation.DELETE) || ops.contains(WriteOperation.MERGE); + } + + @Override + public void checkMode(TableIf table, RowLevelDmlOp op) { + checkPluginMode((PluginDrivenExternalTable) table, op); + } + + /** + * {@link #checkMode} body: route the copy-on-write rejection through the connector's neutral + * {@code validateRowLevelDmlMode} SPI, so the iceberg property knowledge and the message stay in the + * connector. A connector {@link DorisConnectorException} is surfaced as the analysis-time + * {@link AnalysisException} the legacy native path threw, preserving the user-facing message and the + * exception type. + */ + private static void checkPluginMode(PluginDrivenExternalTable table, RowLevelDmlOp op) { + PluginDrivenExternalCatalog catalog = (PluginDrivenExternalCatalog) table.getCatalog(); + ConnectorSession session = catalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, catalog.getConnector()); + ConnectorTableHandle handle = metadata.getTableHandle( + session, table.getRemoteDbName(), table.getRemoteName()) + .orElseThrow(() -> new AnalysisException("Table not found: " + + table.getRemoteDbName() + "." + table.getRemoteName() + + " in catalog " + catalog.getName())); + try { + metadata.validateRowLevelDmlMode(session, handle, toWriteOperation(op)); + } catch (DorisConnectorException e) { + throw new AnalysisException(e.getMessage(), e); + } + } + + private static WriteOperation toWriteOperation(RowLevelDmlOp op) { + switch (op) { + case DELETE: + return WriteOperation.DELETE; + case UPDATE: + return WriteOperation.UPDATE; + default: + return WriteOperation.MERGE; + } + } + + @Override + public LogicalPlan synthesize(ConnectContext ctx, RowLevelDmlArgs args, RowLevelDmlOp op) { + ExternalTable icebergTable = (ExternalTable) args.getTable(); + switch (op) { + case DELETE: + return new IcebergDeleteCommand(args.getNameParts(), args.getTableAlias(), args.isTempPart(), + args.getPartitions(), args.getLogicalQuery(), args.getDeleteCtx()) + .completeQueryPlan(ctx, args.getLogicalQuery(), icebergTable); + case UPDATE: + return new IcebergUpdateCommand(args.getNameParts(), args.getTableAlias(), args.getAssignments(), + args.getLogicalQuery(), args.getDeleteCtx()) + .buildMergePlan(ctx, args.getLogicalQuery(), args.getAssignments(), icebergTable); + default: + return new IcebergMergeCommand(args.getTargetNameParts(), args.getTargetAlias(), args.getCte(), + args.getSource(), args.getOnClause(), args.getMatchedClauses(), args.getNotMatchedClauses()) + .buildMergePlan(ctx, icebergTable); + } + } + + @Override + public BaseExternalTableInsertExecutor newExecutor(ConnectContext ctx, TableIf table, String label, + NereidsPlanner planner, boolean emptyInsert, RowLevelDmlOp op) { + // The connector-driven executor opens an SPI ConnectorTransaction (non-null), which activates the + // neutral O5-2 conflict path in RowLevelDmlCommand.applyWriteConstraintIfPresent. The op rides the + // sink's WriteOperation (set by the translator), so one executor serves DELETE/MERGE; no + // InsertCommandContext is needed for a row-level write. + return new PluginDrivenInsertExecutor(ctx, (PluginDrivenExternalTable) table, label, planner, + Optional.empty(), emptyInsert, -1L); + } + + @Override + public PhysicalSink requirePhysicalSink(NereidsPlanner planner, RowLevelDmlOp op) { + Optional> plan = planner.getPhysicalPlan() + .>collect(PhysicalSink.class::isInstance).stream().findAny(); + switch (op) { + case DELETE: + if (!plan.isPresent()) { + throw new AnalysisException("DELETE command must contain target table"); + } + if (!(plan.get() instanceof PhysicalIcebergDeleteSink)) { + throw new AnalysisException("DELETE plan must use Iceberg delete sink"); + } + return plan.get(); + case UPDATE: + if (!plan.isPresent()) { + throw new AnalysisException("UPDATE command must contain target table"); + } + if (!(plan.get() instanceof PhysicalIcebergMergeSink)) { + throw new AnalysisException("UPDATE merge plan must use Iceberg merge sink"); + } + return plan.get(); + default: + if (!plan.isPresent()) { + throw new AnalysisException("MERGE INTO command must contain target table"); + } + if (!(plan.get() instanceof PhysicalIcebergMergeSink)) { + throw new AnalysisException("MERGE INTO plan must use Iceberg merge sink"); + } + return plan.get(); + } + } + + @Override + public String labelPrefix(RowLevelDmlOp op) { + switch (op) { + case DELETE: + return "iceberg_delete"; + case UPDATE: + return "iceberg_update_merge"; + default: + return "iceberg_merge_into"; + } + } + + @Override + public void setupConflictDetection(BaseExternalTableInsertExecutor executor, Plan analyzedPlan, TableIf table, + RowLevelDmlOp op) { + // No-op: the conflict filter is supplied through the neutral SPI path + // (RowLevelDmlCommand.applyWriteConstraintIfPresent -> extractWriteConstraint -> + // ConnectorTransaction.applyWriteConstraint), converted to a native iceberg Expression lazily at + // commit. Running ONLY the SPI path avoids double-filtering; the SPI converter is byte-verified + // equivalent to the retired native filter builder, the residual divergence only widening the + // filter -> at worst a harmless extra OCC retry (see [DEC-S5]). + } + + @Override + public void finalizeSink(BaseExternalTableInsertExecutor executor, RowLevelDmlOp op, PlanFragment fragment, + DataSink sink, PhysicalSink physicalSink) { + // Finalize through the connector's single transaction model (bind tx -> bindDataSink -> planWrite), + // which supplies rewritable_delete_file_sets itself via the scan-time stash -> exactly one finalize, + // no double-overlay. + ((PluginDrivenInsertExecutor) executor).finalizeRowLevelDmlSink(fragment, sink, physicalSink); + } + + @Override + public Optional extractWriteConstraint(Plan analyzedPlan, TableIf table) { + return WriteConstraintExtractor.extract(analyzedPlan, table.getId(), ICEBERG_EXCLUSION); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergUpdateCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergUpdateCommand.java index 8759343e055565..d1a1b39e338bbd 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergUpdateCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergUpdateCommand.java @@ -17,45 +17,25 @@ package org.apache.doris.nereids.trees.plans.commands; -import org.apache.doris.analysis.StmtType; import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.TableIf; import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.iceberg.IcebergConflictDetectionFilterUtils; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergMergeOperation; -import org.apache.doris.datasource.iceberg.IcebergNereidsUtils; -import org.apache.doris.datasource.iceberg.IcebergUtils; -import org.apache.doris.nereids.NereidsPlanner; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.ExternalTable; import org.apache.doris.nereids.analyzer.UnboundAlias; import org.apache.doris.nereids.analyzer.UnboundSlot; import org.apache.doris.nereids.exceptions.AnalysisException; -import org.apache.doris.nereids.glue.LogicalPlanAdapter; import org.apache.doris.nereids.trees.expressions.EqualTo; import org.apache.doris.nereids.trees.expressions.Expression; import org.apache.doris.nereids.trees.expressions.NamedExpression; import org.apache.doris.nereids.trees.expressions.Slot; import org.apache.doris.nereids.trees.expressions.literal.TinyIntLiteral; -import org.apache.doris.nereids.trees.plans.Explainable; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.PlanType; import org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext; -import org.apache.doris.nereids.trees.plans.commands.insert.IcebergMergeExecutor; +import org.apache.doris.nereids.trees.plans.commands.merge.MergeOperation; import org.apache.doris.nereids.trees.plans.logical.LogicalIcebergMergeSink; import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; import org.apache.doris.nereids.trees.plans.logical.LogicalProject; -import org.apache.doris.nereids.trees.plans.physical.PhysicalEmptyRelation; -import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergMergeSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; -import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; -import org.apache.doris.nereids.util.RelationUtil; import org.apache.doris.nereids.util.Utils; -import org.apache.doris.planner.DataSink; -import org.apache.doris.planner.PlanFragment; import org.apache.doris.qe.ConnectContext; -import org.apache.doris.qe.QueryState; -import org.apache.doris.qe.StmtExecutor; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; @@ -65,11 +45,12 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.concurrent.Callable; import java.util.stream.Collectors; /** - * UPDATE command for Iceberg tables. + * Merge-plan synthesizer for UPDATE on Iceberg tables, invoked via + * IcebergRowLevelDmlTransform.synthesize. The legacy Command execution half + * was removed as dead post-cutover code. * * UPDATE operations are implemented as a single scan + merge sink: * 1. Scan rows matching WHERE condition with row_id injected @@ -77,7 +58,7 @@ * 3. Merge sink writes position deletes and new data files * 4. RowDelta commits delete + insert atomically */ -public class IcebergUpdateCommand extends Command implements ForwardWithSync, Explainable { +public class IcebergUpdateCommand { private final List assignments; private final List nameParts; @@ -94,7 +75,6 @@ public IcebergUpdateCommand( List assignments, LogicalPlan logicalQuery, DeleteCommandContext deleteCtx) { - super(PlanType.UPDATE_COMMAND); this.nameParts = Utils.copyRequiredList(nameParts); this.assignments = Utils.copyRequiredList(assignments); this.tableAlias = tableAlias; @@ -102,93 +82,6 @@ public IcebergUpdateCommand( this.deleteCtx = deleteCtx != null ? deleteCtx : new DeleteCommandContext(); } - @Override - public void run(ConnectContext ctx, StmtExecutor executor) throws Exception { - // Check if target table is Iceberg table - List qualifiedTableName = RelationUtil.getQualifierName(ctx, nameParts); - TableIf table = RelationUtil.getTable(qualifiedTableName, ctx.getEnv(), Optional.empty()); - - if (!(table instanceof IcebergExternalTable)) { - throw new AnalysisException("UPDATE command can only be used on Iceberg tables. " - + "Table " + Util.getTempTableDisplayName(table.getName()) + " is not an Iceberg table."); - } - - IcebergExternalTable icebergTable = (IcebergExternalTable) table; - IcebergDmlCommandUtils.checkUpdateMode(icebergTable); - - // Verify table format version (must be v2+ for update support) - // org.apache.iceberg.Table icebergTableObj = icebergTable.getIcebergTable(); - // String formatVersionStr = icebergTableObj.properties().get("format-version"); - // int formatVersion = formatVersionStr != null ? Integer.parseInt(formatVersionStr) : 1; - // if (formatVersion < 2) { - // throw new AnalysisException("Iceberg table UPDATE requires format version >= 2. " - // + "Current format version: " + formatVersion); - // } - - long previousTargetTableId = ctx.getIcebergRowIdTargetTableId(); - ctx.setIcebergRowIdTargetTableId(icebergTable.getId()); - try { - // UPDATE is implemented as a single merge plan (delete + insert in one scan) - LogicalPlan mergePlan = buildMergePlan(ctx, logicalQuery, assignments, icebergTable); - executeMergePlan(ctx, executor, icebergTable, mergePlan); - } finally { - ctx.setIcebergRowIdTargetTableId(previousTargetTableId); - } - } - - private boolean executeMergePlan(ConnectContext ctx, StmtExecutor executor, - IcebergExternalTable icebergTable, - LogicalPlan logicalPlan) throws Exception { - return executeWithExternalTableBatchModeDisabled(ctx, () -> { - LogicalPlanAdapter logicalPlanAdapter = - new LogicalPlanAdapter(logicalPlan, ctx.getStatementContext()); - NereidsPlanner planner = new NereidsPlanner(ctx.getStatementContext()); - planner.plan(logicalPlanAdapter, ctx.getSessionVariable().toThrift()); - executor.setPlanner(planner); - executor.checkBlockRules(); - Optional conflictFilter = - IcebergConflictDetectionFilterUtils.buildConflictDetectionFilter( - planner.getAnalyzedPlan(), icebergTable); - - PhysicalSink physicalSink = getPhysicalMergeSink(planner); - PlanFragment fragment = planner.getFragments().get(0); - DataSink dataSink = fragment.getSink(); - boolean emptyInsert = childIsEmptyRelation(physicalSink); - String label = String.format("iceberg_update_merge_%x_%x", ctx.queryId().hi, ctx.queryId().lo); - - IcebergMergeExecutor insertExecutor = - new IcebergMergeExecutor(ctx, icebergTable, label, planner, emptyInsert, -1L); - insertExecutor.setConflictDetectionFilter(conflictFilter); - - if (insertExecutor.isEmptyInsert()) { - return true; - } - - insertExecutor.beginTransaction(); - insertExecutor.finalizeSinkForMerge(fragment, dataSink, physicalSink); - insertExecutor.getCoordinator().setTxnId(insertExecutor.getTxnId()); - executor.setCoord(insertExecutor.getCoordinator()); - insertExecutor.executeSingleInsert(executor); - return ctx.getState().getStateType() != QueryState.MysqlStateType.ERR; - }); - } - - @VisibleForTesting - static T executeWithExternalTableBatchModeDisabled( - ConnectContext ctx, Callable action) throws Exception { - boolean previousEnableExternalTableBatchMode = - ctx.getSessionVariable().enableExternalTableBatchMode; - // disable batch mode for iceberg scan node get all splits. - // IcebergRewritableDeletePlanner.collect for map list> - ctx.getSessionVariable().enableExternalTableBatchMode = false; - try { - return action.call(); - } finally { - ctx.getSessionVariable().enableExternalTableBatchMode = - previousEnableExternalTableBatchMode; - } - } - @VisibleForTesting LogicalPlan buildMergeProjectPlan(ConnectContext ctx, LogicalPlan logicalQuery, List assignments, List columns, String tableName) { @@ -199,25 +92,26 @@ LogicalPlan buildMergeProjectPlan(ConnectContext ctx, LogicalPlan logicalQuery, colNameToExpression.put(colNameParts.get(colNameParts.size() - 1), equalTo.right()); } List updateColumns = buildUpdateSelectItems(colNameToExpression, columns, tableName); - LogicalPlan planWithRowId = IcebergNereidsUtils.injectRowIdColumn(logicalQuery); + LogicalPlan planWithRowId = RowLevelDmlRowIdUtils.injectRowIdColumn(logicalQuery); NamedExpression rowIdColumn = getRowIdColumnExpr(planWithRowId); NamedExpression operationColumn = new UnboundAlias( - new TinyIntLiteral(IcebergMergeOperation.UPDATE_OPERATION_NUMBER), - IcebergMergeOperation.OPERATION_COLUMN); + new TinyIntLiteral(MergeOperation.UPDATE_OPERATION_NUMBER), + MergeOperation.OPERATION_COLUMN); List projectItems = new ArrayList<>(2 + updateColumns.size()); projectItems.add(operationColumn); projectItems.add(rowIdColumn); projectItems.addAll(updateColumns); for (Column col : columns) { - if (IcebergUtils.isIcebergRowLineageColumn(col)) { + if (col.isReservedPassthrough()) { projectItems.add(new UnboundSlot(tableName, col.getName())); } } return new LogicalProject<>(projectItems, planWithRowId); } - private LogicalPlan buildMergePlan(ConnectContext ctx, LogicalPlan logicalQuery, - List assignments, IcebergExternalTable icebergTable) { + // package-visible: the generic RowLevelDmlCommand shell delegates synthesis here (T07c). + LogicalPlan buildMergePlan(ConnectContext ctx, LogicalPlan logicalQuery, + List assignments, ExternalTable icebergTable) { String tableName = tableAlias != null ? tableAlias : Util.getTempTableDisplayName(icebergTable.getName()); @@ -225,7 +119,7 @@ private LogicalPlan buildMergePlan(ConnectContext ctx, LogicalPlan logicalQuery, icebergTable.getBaseSchema(true), tableName); List outputExprs; - if (!IcebergNereidsUtils.hasUnboundPlan(queryPlan)) { + if (!RowLevelDmlRowIdUtils.hasUnboundPlan(queryPlan)) { outputExprs = queryPlan.getOutput().stream() .map(NamedExpression.class::cast) .collect(Collectors.toList()); @@ -236,7 +130,7 @@ private LogicalPlan buildMergePlan(ConnectContext ctx, LogicalPlan logicalQuery, } return new LogicalIcebergMergeSink<>( - (IcebergExternalDatabase) icebergTable.getDatabase(), + (ExternalDatabase) icebergTable.getDatabase(), icebergTable, icebergTable.getBaseSchema(true), outputExprs, @@ -247,8 +141,8 @@ private LogicalPlan buildMergePlan(ConnectContext ctx, LogicalPlan logicalQuery, } private NamedExpression getRowIdColumnExpr(LogicalPlan planWithRowId) { - if (!IcebergNereidsUtils.hasUnboundPlan(planWithRowId)) { - Optional rowIdSlot = IcebergNereidsUtils.findRowIdSlot(planWithRowId.getOutput()); + if (!RowLevelDmlRowIdUtils.hasUnboundPlan(planWithRowId)) { + Optional rowIdSlot = RowLevelDmlRowIdUtils.findRowIdSlot(planWithRowId.getOutput()); if (rowIdSlot.isPresent()) { return (NamedExpression) rowIdSlot.get(); } @@ -281,52 +175,6 @@ List buildUpdateSelectItems(Map colNameToEx return selectItems; } - private PhysicalSink getPhysicalMergeSink(NereidsPlanner planner) { - Optional> plan = planner.getPhysicalPlan() - .>collect(PhysicalSink.class::isInstance).stream().findAny(); - if (!plan.isPresent()) { - throw new AnalysisException("UPDATE command must contain target table"); - } - PhysicalSink sink = plan.get(); - if (!(sink instanceof PhysicalIcebergMergeSink)) { - throw new AnalysisException("UPDATE merge plan must use Iceberg merge sink"); - } - return sink; - } - - private boolean childIsEmptyRelation(PhysicalSink sink) { - return sink.children() != null && sink.children().size() == 1 - && sink.child(0) instanceof PhysicalEmptyRelation; - } - - @Override - public Plan getExplainPlan(ConnectContext ctx) { - List qualifiedTableName = RelationUtil.getQualifierName(ctx, nameParts); - TableIf table = RelationUtil.getTable(qualifiedTableName, ctx.getEnv(), Optional.empty()); - if (!(table instanceof IcebergExternalTable)) { - throw new AnalysisException("Table must be IcebergExternalTable in UPDATE command"); - } - IcebergExternalTable icebergTable = (IcebergExternalTable) table; - IcebergDmlCommandUtils.checkUpdateMode(icebergTable); - long previousTargetTableId = ctx.getIcebergRowIdTargetTableId(); - ctx.setIcebergRowIdTargetTableId(table.getId()); - try { - return buildMergePlan(ctx, logicalQuery, assignments, icebergTable); - } finally { - ctx.setIcebergRowIdTargetTableId(previousTargetTableId); - } - } - - @Override - public R accept(PlanVisitor visitor, C context) { - return visitor.visitCommand(this, context); - } - - @Override - public StmtType stmtType() { - return StmtType.UPDATE; - } - public DeleteCommandContext getDeleteCtx() { return deleteCtx; } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlArgs.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlArgs.java new file mode 100644 index 00000000000000..40b0122733ca85 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlArgs.java @@ -0,0 +1,169 @@ +// 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; + +import org.apache.doris.catalog.TableIf; +import org.apache.doris.nereids.trees.expressions.EqualTo; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext; +import org.apache.doris.nereids.trees.plans.commands.merge.MergeMatchedClause; +import org.apache.doris.nereids.trees.plans.commands.merge.MergeNotMatchedClause; +import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; + +import java.util.List; +import java.util.Optional; + +/** + * Immutable carrier of the per-operation arguments a {@link RowLevelDmlTransform} needs to synthesize a + * row-level DML plan, plus the already-resolved target {@link TableIf}. + * + *

    The dispatching command ({@code UpdateCommand}/{@code DeleteFromCommand}/{@code MergeIntoCommand}) + * resolves the table (preserving its own swallow/throw discipline) and builds the matching variant via the + * {@code forDelete}/{@code forUpdate}/{@code forMerge} factories. Fields are a union across the three + * operations; only the relevant ones are populated per factory. {@code cte} is forwarded for MERGE only — + * UPDATE/DELETE drop it, faithful to legacy {@code IcebergUpdateCommand}/{@code IcebergDeleteCommand}, which + * carry no CTE.

    + */ +public final class RowLevelDmlArgs { + + private final TableIf table; + + // DELETE / UPDATE + private final List nameParts; + private final String tableAlias; + private final LogicalPlan logicalQuery; + private final DeleteCommandContext deleteCtx; + // DELETE only + private final boolean isTempPart; + private final List partitions; + // UPDATE only + private final List assignments; + + // MERGE + private final List targetNameParts; + private final Optional targetAlias; + private final Optional cte; + private final LogicalPlan source; + private final Expression onClause; + private final List matchedClauses; + private final List notMatchedClauses; + + private RowLevelDmlArgs(TableIf table, List nameParts, String tableAlias, LogicalPlan logicalQuery, + DeleteCommandContext deleteCtx, boolean isTempPart, List partitions, List assignments, + List targetNameParts, Optional targetAlias, Optional cte, LogicalPlan source, + Expression onClause, List matchedClauses, + List notMatchedClauses) { + this.table = table; + this.nameParts = nameParts; + this.tableAlias = tableAlias; + this.logicalQuery = logicalQuery; + this.deleteCtx = deleteCtx; + this.isTempPart = isTempPart; + this.partitions = partitions; + this.assignments = assignments; + this.targetNameParts = targetNameParts; + this.targetAlias = targetAlias; + this.cte = cte; + this.source = source; + this.onClause = onClause; + this.matchedClauses = matchedClauses; + this.notMatchedClauses = notMatchedClauses; + } + + /** Arguments for a DELETE (mirrors the legacy {@code IcebergDeleteCommand} constructor inputs). */ + public static RowLevelDmlArgs forDelete(TableIf table, List nameParts, String tableAlias, + boolean isTempPart, List partitions, LogicalPlan logicalQuery, DeleteCommandContext deleteCtx) { + return new RowLevelDmlArgs(table, nameParts, tableAlias, logicalQuery, deleteCtx, isTempPart, partitions, + null, null, null, null, null, null, null, null); + } + + /** Arguments for an UPDATE (mirrors the legacy {@code IcebergUpdateCommand} constructor inputs). */ + public static RowLevelDmlArgs forUpdate(TableIf table, List nameParts, String tableAlias, + List assignments, LogicalPlan logicalQuery, DeleteCommandContext deleteCtx) { + return new RowLevelDmlArgs(table, nameParts, tableAlias, logicalQuery, deleteCtx, false, null, + assignments, null, null, null, null, null, null, null); + } + + /** Arguments for a MERGE INTO (mirrors the legacy {@code IcebergMergeCommand} constructor inputs). */ + public static RowLevelDmlArgs forMerge(TableIf table, List targetNameParts, Optional targetAlias, + Optional cte, LogicalPlan source, Expression onClause, + List matchedClauses, List notMatchedClauses) { + return new RowLevelDmlArgs(table, null, null, null, null, false, null, null, + targetNameParts, targetAlias, cte, source, onClause, matchedClauses, notMatchedClauses); + } + + public TableIf getTable() { + return table; + } + + public List getNameParts() { + return nameParts; + } + + public String getTableAlias() { + return tableAlias; + } + + public LogicalPlan getLogicalQuery() { + return logicalQuery; + } + + public DeleteCommandContext getDeleteCtx() { + return deleteCtx; + } + + public boolean isTempPart() { + return isTempPart; + } + + public List getPartitions() { + return partitions; + } + + public List getAssignments() { + return assignments; + } + + public List getTargetNameParts() { + return targetNameParts; + } + + public Optional getTargetAlias() { + return targetAlias; + } + + public Optional getCte() { + return cte; + } + + public LogicalPlan getSource() { + return source; + } + + public Expression getOnClause() { + return onClause; + } + + public List getMatchedClauses() { + return matchedClauses; + } + + public List getNotMatchedClauses() { + return notMatchedClauses; + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommand.java new file mode 100644 index 00000000000000..47c237334bba32 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommand.java @@ -0,0 +1,182 @@ +// 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; + +import org.apache.doris.catalog.TableIf; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.nereids.NereidsPlanner; +import org.apache.doris.nereids.glue.LogicalPlanAdapter; +import org.apache.doris.nereids.trees.plans.Plan; +import org.apache.doris.nereids.trees.plans.commands.insert.BaseExternalTableInsertExecutor; +import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; +import org.apache.doris.nereids.trees.plans.physical.PhysicalEmptyRelation; +import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; +import org.apache.doris.planner.DataSink; +import org.apache.doris.planner.PlanFragment; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.StmtExecutor; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Throwables; + +import java.util.concurrent.Callable; + +/** + * Generic shell for row-level DML ({@code DELETE}/{@code UPDATE}/{@code MERGE INTO}) against external tables. + * + *

    Owns the single live planner-drive loop that was triplicated across {@code IcebergDeleteCommand}, + * {@code IcebergUpdateCommand} and {@code IcebergMergeCommand}: the per-operation points (mode check, plan + * synthesis, required sink, executor factory, label prefix, conflict-detection wiring, finalize) are routed + * through a {@link RowLevelDmlTransform} resolved from {@link RowLevelDmlRegistry}. The dispatching commands + * ({@code UpdateCommand}/{@code DeleteFromCommand}/{@code MergeIntoCommand}) delegate here once a transform is + * found, so the reverse {@code instanceof} dispatch is consolidated into the registry.

    + * + *

    This is intentionally a plain class, not a Nereids {@code Command}: it is invoked from within the + * dispatching commands' {@code run}/{@code getExplainPlan}, so it needs no visitor/plan-type/stmt-type of its + * own (those stay on the dispatching commands, preserving their per-op differences).

    + */ +public class RowLevelDmlCommand { + + private final RowLevelDmlTransform transform; + private final RowLevelDmlArgs args; + private final RowLevelDmlOp op; + + public RowLevelDmlCommand(RowLevelDmlTransform transform, RowLevelDmlArgs args, RowLevelDmlOp op) { + this.transform = transform; + this.args = args; + this.op = op; + } + + /** + * Execute the row-level DML. Mirrors legacy {@code IcebergDeleteCommand.run} / + * {@code IcebergUpdateCommand.executeMergePlan} / {@code IcebergMergeCommand.executeMergePlan} step-for-step; + * the four divergences (required sink, label prefix, executor + finalize, result) are parameterized by op. + */ + public void run(ConnectContext ctx, StmtExecutor stmtExecutor) throws Exception { + TableIf table = args.getTable(); + transform.checkMode(table, op); + long previousTargetTableId = ctx.getSyntheticWriteColTargetTableId(); + ctx.setSyntheticWriteColTargetTableId(table.getId()); + try { + LogicalPlan plan = transform.synthesize(ctx, args, op); + executeWithExternalTableBatchModeDisabled(ctx, () -> { + LogicalPlanAdapter logicalPlanAdapter = new LogicalPlanAdapter(plan, ctx.getStatementContext()); + NereidsPlanner planner = new NereidsPlanner(ctx.getStatementContext()); + planner.plan(logicalPlanAdapter, ctx.getSessionVariable().toThrift()); + stmtExecutor.setPlanner(planner); + stmtExecutor.checkBlockRules(); + + PhysicalSink physicalSink = transform.requirePhysicalSink(planner, op); + PlanFragment fragment = planner.getFragments().get(0); + DataSink dataSink = fragment.getSink(); + boolean emptyInsert = childIsEmptyRelation(physicalSink); + String label = String.format(transform.labelPrefix(op) + "_%x_%x", + ctx.queryId().hi, ctx.queryId().lo); + + BaseExternalTableInsertExecutor insertExecutor = + transform.newExecutor(ctx, table, label, planner, emptyInsert, op); + transform.setupConflictDetection(insertExecutor, planner.getAnalyzedPlan(), table, op); + + if (insertExecutor.isEmptyInsert()) { + return null; + } + + beginTransactionAndFinalizeSink(transform, op, insertExecutor, stmtExecutor, + planner.getAnalyzedPlan(), table, fragment, dataSink, physicalSink); + insertExecutor.executeSingleInsert(stmtExecutor); + return null; + }); + } finally { + ctx.setSyntheticWriteColTargetTableId(previousTargetTableId); + } + } + + /** EXPLAIN path: synthesis only (no planner-drive loop, no transaction), mirroring legacy getExplainPlan. */ + public Plan getExplainPlan(ConnectContext ctx) { + TableIf table = args.getTable(); + transform.checkMode(table, op); + long previousTargetTableId = ctx.getSyntheticWriteColTargetTableId(); + ctx.setSyntheticWriteColTargetTableId(table.getId()); + try { + return transform.synthesize(ctx, args, op); + } finally { + ctx.setSyntheticWriteColTargetTableId(previousTargetTableId); + } + } + + /** + * The begin→finalize window, guarded like {@code InsertIntoTableCommand}'s prepare step: + * {@code beginTransaction} registers the transaction with the connector transaction manager and the + * global external-transaction registry, but the executor's own failure handling only takes over at + * {@code executeSingleInsert} — a throw from the constraint/finalize/coordinator steps in between + * would otherwise leak both registrations until FE restart. + */ + @VisibleForTesting + static void beginTransactionAndFinalizeSink(RowLevelDmlTransform transform, RowLevelDmlOp op, + BaseExternalTableInsertExecutor insertExecutor, StmtExecutor stmtExecutor, Plan analyzedPlan, + TableIf table, PlanFragment fragment, DataSink dataSink, PhysicalSink physicalSink) { + try { + insertExecutor.beginTransaction(); + applyWriteConstraintIfPresent(transform, insertExecutor, analyzedPlan, table); + transform.finalizeSink(insertExecutor, op, fragment, dataSink, physicalSink); + insertExecutor.getCoordinator().setTxnId(insertExecutor.getTxnId()); + stmtExecutor.setCoord(insertExecutor.getCoordinator()); + } catch (Throwable e) { + // the abortTxn in onFail need to acquire table write lock + insertExecutor.onFail(e); + Throwables.throwIfInstanceOf(e, RuntimeException.class); + throw new IllegalStateException(e.getMessage(), e); + } + } + + /** + * O5-2 new write-constraint path. Dormant until P6.6: only fires when the executor exposes an SPI + * {@link ConnectorTransaction}. Today iceberg DELETE/MERGE run on the legacy {@code IcebergTransaction} + * (the base {@code getConnectorTransactionOrNull()} returns {@code null}), so this is a no-op; the legacy + * 3-hop conflict-detection path ({@link RowLevelDmlTransform#setupConflictDetection}) remains the live one. + */ + @VisibleForTesting + static void applyWriteConstraintIfPresent(RowLevelDmlTransform transform, + BaseExternalTableInsertExecutor executor, Plan analyzedPlan, TableIf table) { + ConnectorTransaction connectorTx = executor.getConnectorTransactionOrNull(); + if (connectorTx == null) { + return; + } + transform.extractWriteConstraint(analyzedPlan, table).ifPresent(connectorTx::applyWriteConstraint); + } + + /** + * Run {@code action} with external-table batch mode disabled so the iceberg scan node yields all splits + * (needed by {@code IcebergRewritableDeletePlanner.collect}). Byte-identical to the per-command copies + * retained on the legacy {@code Iceberg*Command} classes until P6.7. + */ + static T executeWithExternalTableBatchModeDisabled(ConnectContext ctx, Callable action) throws Exception { + boolean previousEnableExternalTableBatchMode = ctx.getSessionVariable().enableExternalTableBatchMode; + ctx.getSessionVariable().enableExternalTableBatchMode = false; + try { + return action.call(); + } finally { + ctx.getSessionVariable().enableExternalTableBatchMode = previousEnableExternalTableBatchMode; + } + } + + private static boolean childIsEmptyRelation(PhysicalSink sink) { + return sink.children() != null && sink.children().size() == 1 + && sink.child(0) instanceof PhysicalEmptyRelation; + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlOp.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlOp.java new file mode 100644 index 00000000000000..1ed1e145780b03 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlOp.java @@ -0,0 +1,30 @@ +// 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; + +/** + * The kind of row-level DML driven by the generic {@link RowLevelDmlCommand} shell. + * + *

    Selects the per-operation behavior of a {@link RowLevelDmlTransform}: mode check, plan synthesis, + * required physical sink, executor factory, label prefix and the (op-specific) finalize step.

    + */ +public enum RowLevelDmlOp { + DELETE, + UPDATE, + MERGE +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlRegistry.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlRegistry.java new file mode 100644 index 00000000000000..e42fa0a9b1cdc1 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlRegistry.java @@ -0,0 +1,55 @@ +// 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; + +import org.apache.doris.catalog.TableIf; + +import com.google.common.collect.ImmutableList; + +import java.util.List; +import java.util.Optional; + +/** + * Registry of {@link RowLevelDmlTransform}s. The dispatching DML commands consult this instead of testing the + * target table type, so the reverse {@code instanceof} dispatch is consolidated here. + * + *

    Explicit static registration (no {@code ServiceLoader}) — avoids the thread-context-classloader pitfalls + * seen with SPI loaders. Today the single entry is {@link IcebergRowLevelDmlTransform}, whose {@code handles} + * is {@code instanceof IcebergExternalTable}; at P6.6 cutover the predicate can become a capability check.

    + */ +public final class RowLevelDmlRegistry { + + private static final List TRANSFORMS = + ImmutableList.of(new IcebergRowLevelDmlTransform()); + + private RowLevelDmlRegistry() { + } + + /** Returns the first transform that handles the table, or empty (the OLAP/native path). */ + public static Optional find(TableIf table) { + if (table == null) { + return Optional.empty(); + } + for (RowLevelDmlTransform transform : TRANSFORMS) { + if (transform.handles(table)) { + return Optional.of(transform); + } + } + return Optional.empty(); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlRowIdUtils.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlRowIdUtils.java new file mode 100644 index 00000000000000..6ae7ce38f2acdb --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlRowIdUtils.java @@ -0,0 +1,184 @@ +// 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; + +import org.apache.doris.catalog.Column; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.nereids.analyzer.Unbound; +import org.apache.doris.nereids.trees.expressions.NamedExpression; +import org.apache.doris.nereids.trees.expressions.Slot; +import org.apache.doris.nereids.trees.expressions.SlotReference; +import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator; +import org.apache.doris.nereids.trees.plans.Plan; +import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan; +import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; +import org.apache.doris.nereids.trees.plans.logical.LogicalProject; +import org.apache.doris.nereids.trees.plans.visitor.DefaultPlanRewriter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import javax.annotation.Nullable; + +/** + * Row-id injection utilities for row-level DML (DELETE/UPDATE/MERGE) commands. + * Provides shared helpers for row-id injection (the SDK expression-conversion half was removed together + * with its dead legacy callers; the live conversion lives in the connector's IcebergPredicateConverter). + */ +public class RowLevelDmlRowIdUtils { + + // ==================== Row-ID Injection Utilities ==================== + + /** + * Inject $row_id column into the plan for any Iceberg table scan. + * Used by DELETE and UPDATE commands (single-table, no ambiguity). + */ + public static LogicalPlan injectRowIdColumn(LogicalPlan plan) { + if (hasUnboundPlan(plan)) { + return plan; + } + return (LogicalPlan) plan.accept(new IcebergRowIdInjector(null), null); + } + + /** + * Inject $row_id column only for the specified target table. + * Used by MERGE INTO where source may also be an Iceberg table. + */ + public static LogicalPlan injectRowIdColumn(LogicalPlan plan, ExternalTable targetTable) { + if (hasUnboundPlan(plan)) { + return plan; + } + return (LogicalPlan) plan.accept(new IcebergRowIdInjector(targetTable), null); + } + + /** Check if any slot in the list is the row-id column. */ + public static boolean hasRowIdSlot(List slots) { + return findRowIdSlot(slots).isPresent(); + } + + /** Find the row-id slot in the list, if present. */ + public static Optional findRowIdSlot(List slots) { + for (Slot slot : slots) { + if (Column.ICEBERG_ROWID_COL.equalsIgnoreCase(slot.getName())) { + return Optional.of(slot); + } + } + return Optional.empty(); + } + + /** Check if any project expression is the row-id column. */ + public static boolean hasRowIdProject(List projects) { + for (NamedExpression project : projects) { + if (project instanceof Slot + && Column.ICEBERG_ROWID_COL.equalsIgnoreCase(((Slot) project).getName())) { + return true; + } + } + return false; + } + + /** Resolve the row-id Column definition from the table's full schema. */ + public static Column getRowIdColumn(ExternalTable table) { + List fullSchema = table.getFullSchema(); + if (fullSchema != null) { + for (Column column : fullSchema) { + if (Column.ICEBERG_ROWID_COL.equalsIgnoreCase(column.getName())) { + return column; + } + } + } + return IcebergRowId.createHiddenColumn(); + } + + /** + * Whether a scan's table is a row-id-injection target: an iceberg table is a + * {@link PluginDrivenExternalTable}, identified by the neutral row-level-DML connector capability rather + * than {@code instanceof Iceberg*} (iron-law: connector-capability-driven). Only the iceberg connector + * declares supportsDelete/supportsMerge, so this precisely selects iceberg scans among a mixed plan + * (e.g. a MERGE whose source is a different table type). + */ + static boolean isRowIdInjectionTarget(ExternalTable table) { + return table instanceof PluginDrivenExternalTable + && pluginConnectorSupportsRowLevelDml((PluginDrivenExternalTable) table); + } + + private static boolean pluginConnectorSupportsRowLevelDml(PluginDrivenExternalTable table) { + // Resolved per-handle through the table's write-op probe (a heterogeneous gateway admits row-level DML + // for its iceberg tables but not its hive tables). It degrades to the empty set on a dropped connector / + // unresolvable handle (mirroring fetchSyntheticWriteColumns), so a mid-DML catalog drop is "not a target" + // rather than an NPE. + Set ops = table.connectorSupportedWriteOperations(); + return ops.contains(WriteOperation.DELETE) || ops.contains(WriteOperation.MERGE); + } + + /** Check if a plan tree contains any unbound nodes or expressions. */ + public static boolean hasUnboundPlan(Plan plan) { + return plan.anyMatch(node -> node instanceof Unbound || ((Plan) node).hasUnboundExpression()); + } + + /** + * Plan rewriter that injects the $row_id hidden column into Iceberg scans and projects. + * + *

    When {@code targetTable} is null, injects on ALL Iceberg scans (DELETE/UPDATE). + * When non-null, only injects on the scan whose table ID matches (MERGE INTO). + */ + private static class IcebergRowIdInjector extends DefaultPlanRewriter { + @Nullable + private final ExternalTable targetTable; + + IcebergRowIdInjector(@Nullable ExternalTable targetTable) { + this.targetTable = targetTable; + } + + @Override + public Plan visitLogicalFileScan(LogicalFileScan scan, Void context) { + if (!isRowIdInjectionTarget(scan.getTable())) { + return scan; + } + if (targetTable != null + && scan.getTable().getId() != targetTable.getId()) { + return scan; + } + if (hasRowIdSlot(scan.getOutput())) { + return scan; + } + ExternalTable table = scan.getTable(); + Column rowIdColumn = getRowIdColumn(table); + SlotReference rowIdSlot = SlotReference.fromColumn( + StatementScopeIdGenerator.newExprId(), table, rowIdColumn, scan.getQualifier()); + List outputs = new ArrayList<>(scan.getOutput()); + outputs.add(rowIdSlot); + return scan.withCachedOutput(outputs); + } + + @Override + public Plan visitLogicalProject(LogicalProject project, Void context) { + project = (LogicalProject) visitChildren(this, project, context); + Optional rowIdSlot = findRowIdSlot(project.child().getOutput()); + if (!rowIdSlot.isPresent() || hasRowIdProject(project.getProjects())) { + return project; + } + List newProjects = new ArrayList<>(project.getProjects()); + newProjects.add((NamedExpression) rowIdSlot.get()); + return project.withProjects(newProjects); + } + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlTransform.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlTransform.java new file mode 100644 index 00000000000000..192b2c1388ff09 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlTransform.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.nereids.trees.plans.commands; + +import org.apache.doris.catalog.TableIf; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.nereids.NereidsPlanner; +import org.apache.doris.nereids.trees.plans.Plan; +import org.apache.doris.nereids.trees.plans.commands.insert.BaseExternalTableInsertExecutor; +import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; +import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; +import org.apache.doris.planner.DataSink; +import org.apache.doris.planner.PlanFragment; +import org.apache.doris.qe.ConnectContext; + +import java.util.Optional; + +/** + * Per-table strategy that turns a row-level DML ({@code DELETE}/{@code UPDATE}/{@code MERGE INTO}) against an + * external table into a synthesized INSERT-shaped plan plus the connector-specific wiring the generic + * {@link RowLevelDmlCommand} shell drives. Implementations are registered in {@link RowLevelDmlRegistry}; the + * dispatching commands look one up via {@link RowLevelDmlRegistry#find(TableIf)} instead of testing the table + * type directly (the reverse {@code instanceof} moves into {@link #handles(TableIf)}). + * + *

    The single live row-level-DML loop lives in {@link RowLevelDmlCommand}; this interface parameterizes the + * six points that differ per table/operation (mode check, synthesis, required sink, executor factory, label + * prefix, finalize) plus the connector-agnostic O5-2 write-constraint extraction.

    + */ +public interface RowLevelDmlTransform { + + /** Whether this transform handles the given target table. Pre-cutover this is the table-type check. */ + boolean handles(TableIf table); + + /** Reject unsupported table modes (e.g. copy-on-write) for the operation, mirroring legacy command checks. */ + void checkMode(TableIf table, RowLevelDmlOp op); + + /** Synthesize the logical plan (the table-sink-rooted INSERT-shaped plan) for the operation. */ + LogicalPlan synthesize(ConnectContext ctx, RowLevelDmlArgs args, RowLevelDmlOp op); + + /** Create the executor that performs the write for the operation. */ + BaseExternalTableInsertExecutor newExecutor(ConnectContext ctx, TableIf table, String label, + NereidsPlanner planner, boolean emptyInsert, RowLevelDmlOp op); + + /** Locate and validate the required physical sink in the planned plan (throws with the legacy messages). */ + PhysicalSink requirePhysicalSink(NereidsPlanner planner, RowLevelDmlOp op); + + /** The label prefix; the shell appends {@code __}. Frozen for profile/txn parity. */ + String labelPrefix(RowLevelDmlOp op); + + /** + * Legacy optimistic-conflict-detection wiring (kept live until P6.7): build the connector-specific + * conflict filter from the analyzed plan and stash it on the executor for its {@code beforeExec}. + */ + void setupConflictDetection(BaseExternalTableInsertExecutor executor, Plan analyzedPlan, TableIf table, + RowLevelDmlOp op); + + /** Finalize the sink (op-specific; e.g. attaching rewritable delete-file metadata for the BE). */ + void finalizeSink(BaseExternalTableInsertExecutor executor, RowLevelDmlOp op, PlanFragment fragment, + DataSink sink, PhysicalSink physicalSink); + + /** + * O5-2 write-constraint extraction: the target-only predicate handed to a {@code ConnectorTransaction} via + * {@code applyWriteConstraint}. Supplies the connector-specific synthetic-column exclusion. Returns empty + * when no target-only conjunct survives. + */ + Optional extractWriteConstraint(Plan analyzedPlan, TableIf table); +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowCreateDatabaseCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowCreateDatabaseCommand.java index 8ba313e5dc8530..24d06f1988dfb4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowCreateDatabaseCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowCreateDatabaseCommand.java @@ -26,10 +26,8 @@ import org.apache.doris.common.ErrorReport; import org.apache.doris.common.util.DatasourcePrintableMap; import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalDatabase; import org.apache.doris.mysql.privilege.PrivPredicate; import org.apache.doris.nereids.trees.plans.PlanType; import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; @@ -83,21 +81,24 @@ public ShowResultSet doRun(ConnectContext ctx, StmtExecutor executor) throws Exc StringBuilder sb = new StringBuilder(); CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalogOrAnalysisException(ctlgName); - if (catalog instanceof HMSExternalCatalog) { - String simpleDBName = databaseName; - ExternalDatabase dorisDb = ((HMSExternalCatalog) catalog).getDbOrAnalysisException(simpleDBName); - org.apache.hadoop.hive.metastore.api.Database db = ((HMSExternalCatalog) catalog).getClient() - .getDatabase(dorisDb.getRemoteName()); - sb.append("CREATE DATABASE `").append(dorisDb.getRemoteName()).append("`") - .append(" LOCATION '") - .append(db.getLocationUri()) - .append("'"); - } else if (catalog instanceof IcebergExternalCatalog) { - IcebergExternalDatabase db = (IcebergExternalDatabase) catalog.getDbOrAnalysisException(databaseName); - sb.append("CREATE DATABASE `").append(databaseName).append("`") - .append(" LOCATION '") - .append(db.getLocation()) - .append("'"); + if (catalog instanceof PluginDrivenExternalCatalog) { + // Post-cutover an iceberg (and any plugin-driven) catalog surfaces databases as + // PluginDrivenExternalDatabase; render LOCATION from the connector's getDatabase SPI (the + // neutral properties-map "location" key), keyed off the connector rather than instanceof. + // Connectors without a namespace location (paimon/jdbc/es) return "" -> no LOCATION clause, + // matching their pre-flip generic-else output. PROPERTIES preserved from the generic path. + PluginDrivenExternalDatabase db = + (PluginDrivenExternalDatabase) catalog.getDbOrAnalysisException(databaseName); + sb.append("CREATE DATABASE `").append(databaseName).append("`"); + String location = db.getLocation(); + if (!Strings.isNullOrEmpty(location)) { + sb.append(" LOCATION '").append(location).append("'"); + } + if (db.getDbProperties().getProperties().size() > 0) { + sb.append("\nPROPERTIES (\n"); + sb.append(new DatasourcePrintableMap<>(db.getDbProperties().getProperties(), "=", true, true, false)); + sb.append("\n)"); + } } else { DatabaseIf db = catalog.getDbOrAnalysisException(databaseName); sb.append("CREATE DATABASE `").append(databaseName).append("`"); diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowCreateTableCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowCreateTableCommand.java index 90963d9fc0923e..18e39124b89dd7 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowCreateTableCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowCreateTableCommand.java @@ -32,11 +32,8 @@ import org.apache.doris.common.ErrorReport; import org.apache.doris.common.Pair; import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HiveMetaStoreClientHelper; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergSysExternalTable; -import org.apache.doris.datasource.iceberg.IcebergUtils; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenSysExternalTable; import org.apache.doris.datasource.systable.SysTable; import org.apache.doris.datasource.systable.SysTableResolver; import org.apache.doris.mysql.privilege.PrivPredicate; @@ -113,9 +110,15 @@ private void validate(ConnectContext ctx) throws AnalysisException { wanted = PrivPredicate.SHOW; } - String authTableName = tableIf instanceof IcebergSysExternalTable - ? ((IcebergSysExternalTable) tableIf).getSourceTable().getName() - : tableIf.getName(); + String authTableName; + if (tableIf instanceof PluginDrivenSysExternalTable) { + // P6.5-T06: after the SPI cutover a sys table ($snapshots/...) is a PluginDrivenSysExternalTable; + // authorize SHOW CREATE against its source table (mirrors the IcebergSysExternalTable branch above + // and UserAuthentication), so a user holding SHOW on db.tbl can SHOW CREATE db.tbl$snapshots. + authTableName = ((PluginDrivenSysExternalTable) tableIf).getSourceTable().getName(); + } else { + authTableName = tableIf.getName(); + } if (!Env.getCurrentEnv().getAccessManager().checkTblPriv(ConnectContext.get(), tblNameInfo.getCtl(), tblNameInfo.getDb(), authTableName, wanted)) { ErrorReport.reportAnalysisException(ErrorCode.ERR_TABLEACCESS_DENIED_ERROR, "SHOW CREATE TABLE", @@ -142,25 +145,33 @@ public ShowResultSet doRun(ConnectContext ctx, StmtExecutor executor) throws Exc // Fetch the catalog, database, and table metadata DatabaseIf db = ctx.getEnv().getCatalogMgr().getCatalogOrAnalysisException(tblNameInfo.getCtl()) .getDbOrMetaException(tblNameInfo.getDb()); - TableIf table = resolveShowCreateTarget(db); - if (table instanceof IcebergSysExternalTable) { - table = ((IcebergSysExternalTable) table).getSourceTable(); - } + TableIf table = redirectSysTableToSource(resolveShowCreateTarget(db)); List> rows = Lists.newArrayList(); table.readLock(); try { - if (table.getType() == Table.TableType.HMS_EXTERNAL_TABLE) { + if (table instanceof PluginDrivenExternalTable && ((PluginDrivenExternalTable) table).isView()) { + // Flipped iceberg view: reproduce the legacy ICEBERG_EXTERNAL_TABLE view arm above on the + // neutral plugin path (only iceberg declares SUPPORTS_VIEW). Render the same bytes as + // IcebergUtils.showCreateView ("CREATE VIEW `name` AS " + view body) and return the same + // 2-column META_DATA result set, so SHOW CREATE on a flipped view stays byte-faithful. rows.add(Arrays.asList(table.getName(), - HiveMetaStoreClientHelper.showCreateTable((HMSExternalTable) table))); + String.format("CREATE VIEW `%s` AS ", table.getName()) + + ((PluginDrivenExternalTable) table).getViewText())); return new ShowResultSet(META_DATA, rows); } - if ((table.getType() == Table.TableType.ICEBERG_EXTERNAL_TABLE) - && ((IcebergExternalTable) table).isView()) { - rows.add(Arrays.asList(table.getName(), - IcebergUtils.showCreateView(((IcebergExternalTable) table)))); - return new ShowResultSet(META_DATA, rows); + if (table instanceof PluginDrivenExternalTable) { + // Native connector-rendered SHOW CREATE (hive: ROW FORMAT SERDE / STORED AS ..., fetched fresh), + // reached only for a non-view plugin table (the view arm above returns first). The guard is the + // method returning a value — NOT the source name — so iceberg/paimon/es/jdbc (empty SPI default) + // fall through to Env.getDdlStmt below and render exactly as today; only a connector that natively + // renders its DDL short-circuits here. Delegated iceberg/hudi-on-HMS tables also return empty. + Optional nativeDdl = ((PluginDrivenExternalTable) table).getShowCreateTableDdl(); + if (nativeDdl.isPresent()) { + rows.add(Arrays.asList(table.getName(), nativeDdl.get())); + return new ShowResultSet(META_DATA, rows); + } } List createTableStmt = Lists.newArrayList(); Env.getDdlStmt(null, null, table, createTableStmt, null, null, false, @@ -186,6 +197,19 @@ public ShowResultSet doRun(ConnectContext ctx, StmtExecutor executor) throws Exc } } + /** + * Redirects a system table ($snapshots/...) to its source base table so SHOW CREATE renders the base + * table's DDL (name / data columns / PARTITION BY) rather than the sys-table shell. Post-cutover a sys + * table is a {@link PluginDrivenSysExternalTable} (a neutral sys-table type, not {@code Iceberg*}), so + * this stays iron-rule clean. Non-sys tables pass through unchanged. + */ + static TableIf redirectSysTableToSource(TableIf table) { + if (table instanceof PluginDrivenSysExternalTable) { + return ((PluginDrivenSysExternalTable) table).getSourceTable(); + } + return table; + } + private TableIf resolveShowCreateTarget(DatabaseIf db) throws AnalysisException { TableIf table = db.getTableNullable(tblNameInfo.getTbl()); if (table != null) { 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 a3b4fd438db14f..28d6f0cf525739 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowPartitionsCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowPartitionsCommand.java @@ -36,14 +36,17 @@ 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; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.datasource.CatalogIf; import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog; -import org.apache.doris.datasource.paimon.PaimonExternalCatalog; -import org.apache.doris.datasource.paimon.PaimonExternalDatabase; -import org.apache.doris.datasource.paimon.PaimonExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.mysql.privilege.PrivPredicate; import org.apache.doris.nereids.analyzer.UnboundSlot; import org.apache.doris.nereids.properties.OrderKey; @@ -68,13 +71,10 @@ import com.google.common.base.Strings; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.apache.paimon.partition.Partition; import java.util.ArrayList; -import java.util.Collections; import java.util.Comparator; import java.util.HashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -151,10 +151,6 @@ private void analyzeSubExpression(Expression subExpr) throws AnalysisException { throw new AnalysisException("Only allow column in filter"); } String leftKey = ((UnboundSlot) subExpr.child(0)).getName(); - if (catalog instanceof HMSExternalCatalog && !leftKey.equalsIgnoreCase(FILTER_PARTITION_NAME)) { - throw new AnalysisException(String.format("Only %s column supported in where clause for this catalog", - FILTER_PARTITION_NAME)); - } // FILTER_LAST_CONSISTENCY_CHECK_TIME != 'abc' if (subExpr instanceof ComparisonPredicate) { @@ -199,9 +195,7 @@ protected void validate(ConnectContext ctx) throws AnalysisException { } // disallow unsupported catalog - if (!(catalog.isInternalCatalog() || catalog instanceof HMSExternalCatalog - || catalog instanceof MaxComputeExternalCatalog - || catalog instanceof PaimonExternalCatalog)) { + if (!(catalog.isInternalCatalog() || catalog instanceof PluginDrivenExternalCatalog)) { throw new AnalysisException(String.format("Catalog of type '%s' is not allowed in ShowPartitionsCommand", catalog.getType())); } @@ -220,9 +214,6 @@ protected void validate(ConnectContext ctx) throws AnalysisException { } UnboundSlot slot = (UnboundSlot) orderKey.getExpr(); String colName = slot.getName(); - if (catalog instanceof HMSExternalCatalog && !colName.equalsIgnoreCase(FILTER_PARTITION_NAME)) { - throw new AnalysisException("External table only support Order By on PartitionName"); - } // analyze column int index = -1; @@ -252,7 +243,8 @@ protected void analyze() throws UserException { DatabaseIf db = catalog.getDbOrAnalysisException(dbName); TableIf table = db.getTableOrMetaException(tblName, TableType.OLAP, - TableType.HMS_EXTERNAL_TABLE, TableType.MAX_COMPUTE_EXTERNAL_TABLE, TableType.PAIMON_EXTERNAL_TABLE); + TableType.HMS_EXTERNAL_TABLE, TableType.MAX_COMPUTE_EXTERNAL_TABLE, TableType.PAIMON_EXTERNAL_TABLE, + TableType.PLUGIN_EXTERNAL_TABLE); if (!catalog.isInternalCatalog()) { if (!table.isPartitionedTable()) { @@ -283,118 +275,81 @@ protected void analyze() throws UserException { } } - private ShowResultSet handleShowMaxComputeTablePartitions() { - MaxComputeExternalCatalog mcCatalog = (MaxComputeExternalCatalog) (catalog); - List> rows = new ArrayList<>(); + private ShowResultSet handleShowPluginDrivenTablePartitions() throws AnalysisException { + PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; String dbName = tableName.getDb(); - List partitionNames; - if (limit < 0) { - partitionNames = mcCatalog.listPartitionNames(dbName, tableName.getTbl()); - } else { - partitionNames = mcCatalog.listPartitionNames(dbName, tableName.getTbl(), offset, limit); - } - for (String partition : partitionNames) { - List list = new ArrayList<>(); - list.add(partition); - rows.add(list); - } - // sort by partition name - rows.sort(Comparator.comparing(x -> x.get(0))); - return new ShowResultSet(getMetaData(), rows); - } - - private ShowResultSet handleShowPaimonTablePartitions() throws AnalysisException { - PaimonExternalCatalog paimonCatalog = (PaimonExternalCatalog) catalog; - String db = tableName.getDb(); - String tbl = tableName.getTbl(); - - PaimonExternalDatabase database = (PaimonExternalDatabase) paimonCatalog.getDb(db) - .orElseThrow(() -> new AnalysisException("Paimon database '" + db + "' does not exist")); - PaimonExternalTable paimonTable = database.getTable(tbl) - .orElseThrow(() -> new AnalysisException("Paimon table '" + db + "." + tbl + "' does not exist")); - - Map partitionSnapshot = paimonTable.getPartitionSnapshot(Optional.empty()); - if (partitionSnapshot == null) { - partitionSnapshot = Collections.emptyMap(); - } - - LinkedHashSet partitionColumnNames = paimonTable - .getPartitionColumns(Optional.empty()) - .stream() - .map(Column::getName) - .collect(Collectors.toCollection(LinkedHashSet::new)); - String partitionColumnsStr = String.join(",", partitionColumnNames); - - List> rows = partitionSnapshot - .entrySet() - .stream() - .map(entry -> { - List row = new ArrayList<>(5); - row.add(entry.getKey()); - row.add(partitionColumnsStr); - row.add(String.valueOf(entry.getValue().recordCount())); - row.add(String.valueOf(entry.getValue().fileSizeInBytes())); - row.add(String.valueOf(entry.getValue().fileCount())); - return row; - }).collect(Collectors.toList()); - // sort by partition name - if (orderByPairs != null && orderByPairs.get(0).isDesc()) { - rows.sort(Comparator.comparing(x -> x.get(0), Comparator.reverseOrder())); - } else { - rows.sort(Comparator.comparing(x -> x.get(0))); - } - - rows = applyLimit(limit, offset, rows); + ExternalTable dorisTable = pluginCatalog.getDbOrAnalysisException(dbName) + .getTableOrAnalysisException(tableName.getTbl()); - return new ShowResultSet(getMetaData(), rows); - } + // Route partition listing through the connector SPI. + ConnectorSession session = pluginCatalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, pluginCatalog.getConnector()); + ConnectorTableHandle handle = metadata + .getTableHandle(session, dorisTable.getRemoteDbName(), dorisTable.getRemoteName()) + .orElseThrow(() -> new AnalysisException( + "table not found: " + dbName + "." + tableName.getTbl())); - private ShowResultSet handleShowHMSTablePartitions() throws AnalysisException { - HMSExternalCatalog hmsCatalog = (HMSExternalCatalog) catalog; List> rows = new ArrayList<>(); - String dbName = tableName.getDb(); - List partitionNames; - - // catalog.getClient().listPartitionNames() returned string is the encoded string. - // example: insert into tmp partition(pt="1=3/3") values( xxx ); - // show partitions from tmp: pt=1%3D3%2F3 - // Need to consider whether to call `HiveUtil.toPartitionColNameAndValues` method - ExternalTable dorisTable = hmsCatalog.getDbOrAnalysisException(dbName) - .getTableOrAnalysisException(tableName.getTbl()); - - if (limit >= 0 && offset == 0 && (orderByPairs == null || !orderByPairs.get(0).isDesc())) { - partitionNames = hmsCatalog.getClient() - .listPartitionNames(dorisTable.getRemoteDbName(), dorisTable.getRemoteName(), limit); + if (hasPartitionStatsCapability()) { + // Rich 5-column result (Partition / PartitionKey / RecordCount / FileSizeInBytes / + // FileCount), matching the legacy paimon SHOW PARTITIONS (D-045). PartitionKey is the + // table's partition-column names comma-joined, identical on every row (legacy semantics). + String partitionColumnsStr = ((PluginDrivenExternalTable) dorisTable).getPartitionColumns() + .stream().map(Column::getName).collect(Collectors.joining(",")); + for (ConnectorPartitionInfo partition + : metadata.listPartitions(session, handle, Optional.empty())) { + String partitionName = partition.getPartitionName(); + if (filterMap != null && !filterMap.isEmpty() + && !PartitionsProcDir.filterExpression(FILTER_PARTITION_NAME, partitionName, filterMap)) { + continue; + } + List row = new ArrayList<>(5); + row.add(partitionName); + row.add(partitionColumnsStr); + row.add(String.valueOf(partition.getRowCount())); + row.add(String.valueOf(partition.getSizeBytes())); + row.add(String.valueOf(partition.getFileCount())); + rows.add(row); + } } else { - partitionNames = hmsCatalog.getClient() - .listPartitionNames(dorisTable.getRemoteDbName(), dorisTable.getRemoteName()); - } - - /* Filter add rows */ - for (String partition : partitionNames) { - List list = new ArrayList<>(); - - if (filterMap != null && !filterMap.isEmpty()) { - if (!PartitionsProcDir.filterExpression(FILTER_PARTITION_NAME, partition, filterMap)) { + // Single-column result (partition name only). The SPI's listPartitionNames has no + // offset/limit, so paging is applied FE-side below. + for (String partition : metadata.listPartitionNames(session, handle)) { + if (filterMap != null && !filterMap.isEmpty() + && !PartitionsProcDir.filterExpression(FILTER_PARTITION_NAME, partition, filterMap)) { continue; } + List row = new ArrayList<>(1); + row.add(partition); + rows.add(row); } - list.add(partition); - rows.add(list); } - // sort by partition name if (orderByPairs != null && orderByPairs.get(0).isDesc()) { rows.sort(Comparator.comparing(x -> x.get(0), Comparator.reverseOrder())); } else { rows.sort(Comparator.comparing(x -> x.get(0))); } - rows = applyLimit(limit, offset, rows); - return new ShowResultSet(getMetaData(), rows); } + /** + * Whether the current (plugin) catalog's connector exposes per-partition statistics + * ({@link ConnectorCapability#SUPPORTS_PARTITION_STATS}). Drives the 5-column SHOW PARTITIONS + * result for paimon while non-declaring connectors (e.g. MaxCompute) stay single-column. Both + * {@link #handleShowPluginDrivenTablePartitions()} and {@link #getMetaData()} consult this so the + * 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); + } + protected ShowResultSet handleShowPartitions(ConnectContext ctx, StmtExecutor executor) throws UserException { // validate the where clause validate(ctx); @@ -412,12 +367,9 @@ protected ShowResultSet handleShowPartitions(ConnectContext ctx, StmtExecutor ex List> rows = ((PartitionsProcDir) node).fetchResultByExpressionFilter(filterMap, orderByPairs, limitElement).getRows(); return new ShowResultSet(getMetaData(), rows); - } else if (catalog instanceof MaxComputeExternalCatalog) { - return handleShowMaxComputeTablePartitions(); - } else if (catalog instanceof PaimonExternalCatalog) { - return handleShowPaimonTablePartitions(); } else { - return handleShowHMSTablePartitions(); + // After the disallow gate, a non-internal catalog is a PluginDrivenExternalCatalog. + return handleShowPluginDrivenTablePartitions(); } } @@ -435,11 +387,10 @@ public ShowResultSetMetaData getMetaData() { for (String col : result.getColumnNames()) { builder.addColumn(new Column(col, ScalarType.createVarchar(30))); } - } else if (catalog instanceof IcebergExternalCatalog) { - builder.addColumn(new Column("Partition", ScalarType.createVarchar(60))); - builder.addColumn(new Column("Lower Bound", ScalarType.createVarchar(100))); - builder.addColumn(new Column("Upper Bound", ScalarType.createVarchar(100))); - } else if (catalog instanceof PaimonExternalCatalog) { + } else if (hasPartitionStatsCapability()) { + // A plugin connector that declares SUPPORTS_PARTITION_STATS (paimon after cutover): + // 5-column rich result. Must match the row width built in + // handleShowPluginDrivenTablePartitions(). builder.addColumn(new Column("Partition", ScalarType.createVarchar(300))) .addColumn(new Column("PartitionKey", ScalarType.createVarchar(300))) .addColumn(new Column("RecordCount", ScalarType.createVarchar(300))) diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/UpdateCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/UpdateCommand.java index 736577f88047e4..6dbf09f76c901a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/UpdateCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/UpdateCommand.java @@ -25,7 +25,6 @@ import org.apache.doris.catalog.Table; import org.apache.doris.catalog.TableIf; import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; import org.apache.doris.nereids.analyzer.UnboundAlias; import org.apache.doris.nereids.analyzer.UnboundSlot; import org.apache.doris.nereids.analyzer.UnboundTableSinkCreator; @@ -108,14 +107,14 @@ public void run(ConnectContext ctx, StmtExecutor executor) throws Exception { // Table not found, will be handled by regular error flow } - // Route to IcebergUpdateCommand for Iceberg tables - if (table instanceof IcebergExternalTable) { + // Route row-level DML on external tables (e.g. iceberg) through the generic shell. + Optional transform = RowLevelDmlRegistry.find(table); + if (transform.isPresent()) { DeleteCommandContext deleteCtx = new DeleteCommandContext(); deleteCtx.setDeleteFileType(DeleteCommandContext.DeleteFileType.POSITION_DELETE); - IcebergUpdateCommand icebergUpdateCommand = new IcebergUpdateCommand( - nameParts, tableAlias, assignments, logicalQuery, - deleteCtx); - icebergUpdateCommand.run(ctx, executor); + RowLevelDmlArgs args = RowLevelDmlArgs.forUpdate( + table, nameParts, tableAlias, assignments, logicalQuery, deleteCtx); + new RowLevelDmlCommand(transform.get(), args, RowLevelDmlOp.UPDATE).run(ctx, executor); return; } @@ -281,12 +280,13 @@ private void checkTable(ConnectContext ctx) { public Plan getExplainPlan(ConnectContext ctx) { List qualifiedTableName = RelationUtil.getQualifierName(ctx, nameParts); TableIf table = RelationUtil.getTable(qualifiedTableName, ctx.getEnv(), Optional.empty()); - if (table instanceof IcebergExternalTable) { + Optional transform = RowLevelDmlRegistry.find(table); + if (transform.isPresent()) { DeleteCommandContext deleteCtx = new DeleteCommandContext(); deleteCtx.setDeleteFileType(DeleteCommandContext.DeleteFileType.POSITION_DELETE); - IcebergUpdateCommand icebergUpdateCommand = new IcebergUpdateCommand( - nameParts, tableAlias, assignments, logicalQuery, deleteCtx); - return icebergUpdateCommand.getExplainPlan(ctx); + RowLevelDmlArgs args = RowLevelDmlArgs.forUpdate( + table, nameParts, tableAlias, assignments, logicalQuery, deleteCtx); + return new RowLevelDmlCommand(transform.get(), args, RowLevelDmlOp.UPDATE).getExplainPlan(ctx); } return completeQueryPlan(ctx, logicalQuery); } 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 42b7131b31abee..6f7624795635bc 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/call/CallExecuteStmtFunc.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/call/CallExecuteStmtFunc.java @@ -22,7 +22,8 @@ import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.mysql.privilege.PrivPredicate; import org.apache.doris.nereids.exceptions.AnalysisException; import org.apache.doris.nereids.trees.expressions.Expression; @@ -98,7 +99,7 @@ public void run() { if (catalogIf instanceof PluginDrivenExternalCatalog) { PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalogIf; ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = pluginCatalog.getConnector().getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, pluginCatalog.getConnector()); metadata.executeStmt(session, stmt); } else { throw new AnalysisException("executeStmt not supported for catalog type: " diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/BaseExecuteAction.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/BaseExecuteAction.java index 8ad443b4fe9395..d01051e89f0ed9 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/BaseExecuteAction.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/BaseExecuteAction.java @@ -23,11 +23,12 @@ import org.apache.doris.catalog.TableIf; 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; -import org.apache.doris.common.NamedArguments; import org.apache.doris.common.UserException; +import org.apache.doris.foundation.util.NamedArguments; import org.apache.doris.mysql.privilege.PrivPredicate; import org.apache.doris.nereids.trees.expressions.Expression; import org.apache.doris.qe.CommonResultSet; @@ -90,8 +91,14 @@ public final void validate(TableNameInfo tableNameInfo, UserIdentity currentUser tableNameInfo.getTbl()); } - // Validate all registered arguments - namedArguments.validate(properties); + // Validate all registered arguments. NamedArguments (fe-foundation, shared with the connectors) + // signals failures with an unchecked IllegalArgumentException; re-wrap it as AnalysisException to + // preserve the legacy error type and message. + try { + namedArguments.validate(properties); + } catch (IllegalArgumentException e) { + throw new AnalysisException(e.getMessage()); + } // Additional validation logic specific to the action validateAction(); 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 new file mode 100644 index 00000000000000..2db11166bbc635 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorExecuteAction.java @@ -0,0 +1,258 @@ +// 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.execute; + +import org.apache.doris.analysis.UserIdentity; +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.Env; +import org.apache.doris.catalog.TableIf; +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; +import org.apache.doris.common.UserException; +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.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.procedure.ConnectorProcedureOps; +import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; +import org.apache.doris.connector.api.procedure.ProcedureExecutionMode; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.connector.converter.ConnectorColumnConverter; +import org.apache.doris.datasource.connector.converter.UnboundExpressionToConnectorPredicateConverter; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; +import org.apache.doris.mysql.privilege.PrivPredicate; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.qe.CommonResultSet; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.ResultSet; +import org.apache.doris.qe.ResultSetMetaData; + +import com.google.common.base.Preconditions; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Engine-side {@link ExecuteAction} adapter that routes {@code ALTER TABLE t EXECUTE proc(...)} on a + * {@link PluginDrivenExternalTable} to the connector's {@link ConnectorProcedureOps} (P6.4-T07). + * + *

    The procedure-side analogue of the connector scan/write dispatch: it threads the catalog's + * {@link ConnectorSession} and the resolved {@link ConnectorTableHandle} into + * {@code getProcedureOps().execute(...)} (mirroring {@code PhysicalPlanTranslator.visitPhysicalConnectorTableSink}), + * then wraps the engine-neutral {@link ConnectorProcedureResult} back into a {@code ResultSet}.

    + * + *

    Engine/connector split (D-062 §2). The engine keeps the command shell — this adapter performs + * the {@code ALTER} privilege check ({@link #validate}) and the single-row {@code CommonResultSet} wrapping + * ({@link #execute}); {@code ExecuteActionCommand} keeps the edit-log refresh. The connector owns the + * procedure body — per-argument validation (the {@code NamedArguments} framework is not reachable across the + * import gate), the underlying SDK call and the result schema/rows. The connector signals failures with an + * 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} + * 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 (still dormant — not yet in {@code SPI_READY_TYPES}) + * exposes none at the connector level and diverts a foreign iceberg-on-HMS handle to its iceberg sibling.

    + */ +public class ConnectorExecuteAction implements ExecuteAction { + + private final String actionType; + private final Map properties; + private final Optional partitionNamesInfo; + private final Optional whereCondition; + private final PluginDrivenExternalTable table; + + public ConnectorExecuteAction(String actionType, Map properties, + Optional partitionNamesInfo, Optional whereCondition, + PluginDrivenExternalTable table) { + this.actionType = actionType; + this.properties = properties != null ? properties : Collections.emptyMap(); + this.partitionNamesInfo = partitionNamesInfo; + this.whereCondition = whereCondition; + this.table = table; + } + + @Override + public void validate(TableNameInfo tableNameInfo, UserIdentity currentUser) throws UserException { + // Engine keeps the ALTER privilege check (D-062 §2); per-argument validation is connector-owned and + // runs inside execute() (the NamedArguments framework is not reachable across the connector import gate). + if (!Env.getCurrentEnv().getAccessManager() + .checkTblPriv(ConnectContext.get(), tableNameInfo.getCtl(), tableNameInfo.getDb(), + tableNameInfo.getTbl(), PrivPredicate.ALTER)) { + ErrorReport.reportAnalysisException(ErrorCode.ERR_TABLEACCESS_DENIED_ERROR, "ALTER", + currentUser.getQualifiedUser(), ConnectContext.get().getRemoteIP(), + tableNameInfo.getTbl()); + } + } + + @Override + public ResultSet execute(TableIf ignored) throws UserException { + PluginDrivenExternalCatalog catalog = (PluginDrivenExternalCatalog) table.getCatalog(); + Connector connector = catalog.getConnector(); + + // Resolve the connector session + the target table handle FIRST, so procedure-ops selection is + // PER-HANDLE. A single-format connector's per-handle getProcedureOps(handle) just returns its + // connector-level ops, but a flipped hms GATEWAY exposes no connector-level procedures + // (getProcedureOps() == null) while its iceberg-on-HMS tables do — getProcedureOps(handle) diverts a + // foreign handle to the iceberg sibling, and a plain-hive handle keeps the null. Both dispatch arms + // (single-call and distributed) also need the session/handle. Resolving the handle first means a bad + // table name now surfaces "Table not found" before "does not support EXECUTE". + ConnectorSession session = catalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + ConnectorTableHandle tableHandle = metadata + .getTableHandle(session, table.getRemoteDbName(), table.getRemoteName()) + .orElseThrow(() -> new AnalysisException("Table not found: " + table.getRemoteDbName() + + "." + table.getRemoteName() + " in catalog " + catalog.getName())); + + ConnectorProcedureOps procedureOps = connector.getProcedureOps(tableHandle); + if (procedureOps == null) { + throw new DdlException("Connector '" + catalog.getName() + "' (type: " + catalog.getType() + + ") does not support EXECUTE actions"); + } + // The execution mode (the connector decides; no instanceof Iceberg, no procedure name hard-coded in the + // engine) gates BOTH the WHERE handling and the dispatch arm. + ProcedureExecutionMode mode = procedureOps.getExecutionMode(actionType); + + // WHERE handling is mode-split. Only a DISTRIBUTED rewrite (rewrite_data_files) scopes its work by a + // WHERE; the eight pure-SDK SINGLE_CALL procedures reject any WHERE (fail-loud over silently dropping a + // user predicate). The DISTRIBUTED arm lowers the WHERE to a neutral ConnectorPredicate below. + if (whereCondition.isPresent() && mode != ProcedureExecutionMode.DISTRIBUTED) { + throw new DdlException("WHERE condition is not supported for this EXECUTE action"); + } + + List partitionNames = partitionNamesInfo + .map(PartitionNamesInfo::getPartitionNames).orElse(Collections.emptyList()); + + // A DISTRIBUTED procedure (rewrite_data_files) cannot be expressed by the single-row execute() contract, + // so it goes to the distributed rewrite driver. Lower a present WHERE to a neutral ConnectorPredicate + // here (engine half, no iceberg types); the converter is fail-loud, so an unrepresentable WHERE throws + // rather than silently widening the rewrite scope. + if (mode == ProcedureExecutionMode.DISTRIBUTED) { + ConnectorPredicate loweredWhere = whereCondition.isPresent() + ? UnboundExpressionToConnectorPredicateConverter.convert(whereCondition.get(), table) + : null; + ConnectorRewriteDriver driver = new ConnectorRewriteDriver(ConnectContext.get(), table, catalog, + metadata, procedureOps, session, tableHandle, actionType, properties, partitionNames, + loweredWhere); + try { + ConnectorProcedureResult result = driver.run(); + refreshTableCachesAfterMutation(); + return wrapResult(result); + } catch (DorisConnectorException e) { + throw new UserException(e.getMessage(), e); + } + } + + // SINGLE_CALL: a synchronous single-result procedure. + try { + ConnectorProcedureResult result = procedureOps.execute( + session, tableHandle, actionType, properties, null, partitionNames); + refreshTableCachesAfterMutation(); + return wrapResult(result); + } catch (DorisConnectorException e) { + // Surface the connector's unchecked exception as a checked UserException so + // ExecuteActionCommand.run() catches it and re-wraps it with the legacy "Failed to execute action:" + // prefix. Use the plain UserException type the legacy action bodies threw (e.g. + // IcebergRollbackToSnapshotAction.executeAction), so getMessage() formats identically; the message is + // kept verbatim (the connector preserves the legacy text byte-for-byte — T08 byte-parity). + throw new UserException(e.getMessage(), e); + } + } + + /** + * After a successful procedure commit, drop this FE's caches for the mutated table through the standard + * refresh-table path — exactly what a follower FE does when it replays the refresh-table journal that + * {@code ExecuteActionCommand} writes after this returns. {@code refreshTableInternal} clears BOTH the + * engine meta cache (keyed by the table's LOCAL names) and the connector's own per-table cache (keyed by + * the REMOTE names), resolving both from the {@link PluginDrivenExternalTable}. Without this, the FE that + * ran the procedure keeps serving stale connector metadata (the iceberg latest-snapshot cache, default TTL + * 24h) until expiry — a leader/follower split. Connector-agnostic: {@code refreshTableInternal}'s connector + * arm is a generic SPI call (no-op default). + */ + private void refreshTableCachesAfterMutation() { + Env.getCurrentEnv().getRefreshManager() + .refreshTableInternal((ExternalDatabase) table.getDatabase(), table, System.currentTimeMillis()); + } + + /** + * Wraps the engine-neutral {@link ConnectorProcedureResult} into a {@link CommonResultSet}, enforcing the + * legacy single-row contract (each row's width must equal the declared column count, + * {@code BaseExecuteAction:106-108}). Mirrors {@code BaseExecuteAction.execute}, which returns {@code null} + * when the metadata is absent OR the body row is {@code null}: the connector encodes a {@code null} body row + * as {@code (schema, emptyRows)} ({@code BaseIcebergAction.execute}), so an empty schema OR zero rows maps to + * a {@code null} ResultSet (the command sends nothing). + */ + private ResultSet wrapResult(ConnectorProcedureResult result) { + List resultSchema = result.getResultSchema(); + if (resultSchema == null || resultSchema.isEmpty() || result.getRows().isEmpty()) { + return null; + } + List columns = ConnectorColumnConverter.convertColumns(resultSchema); + ResultSetMetaData metaData = new CommonResultSet.CommonResultSetMetaData(columns); + for (List row : result.getRows()) { + Preconditions.checkState(columns.size() == row.size(), + "Result row size does not match metadata column count"); + } + return new CommonResultSet(metaData, result.getRows()); + } + + @Override + public boolean isSupported(TableIf table) { + // The connector rejects unknown procedure names inside execute() with its own faithful message + // ("Unsupported procedure: ..."), so there is no engine-side support pre-filter here. + return true; + } + + @Override + public String getDescription() { + return "Connector procedure: " + actionType; + } + + @Override + public String getActionType() { + return actionType; + } + + @Override + public Map getProperties() { + return properties; + } + + @Override + public Optional getPartitionNamesInfo() { + return partitionNamesInfo; + } + + @Override + public Optional getWhereCondition() { + return whereCondition; + } +} 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 new file mode 100644 index 00000000000000..87a982a073f2fe --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorRewriteDriver.java @@ -0,0 +1,311 @@ +// 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.execute; + +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; +import org.apache.doris.connector.api.procedure.ConnectorProcedureOps; +import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; +import org.apache.doris.connector.api.procedure.ConnectorRewriteGroup; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.scheduler.exception.JobException; +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; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 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). + * + *

    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.

    + * + *

    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 — + * is deferred (see the rewrite-output-sizing follow-up): every group GATHERs to a single writer via the + * rewrite sink flag, which is correct but not size-tuned. This only affects real BE writes (exercised at the + * flip rehearsal), not the dormant pre-flip / mock path.

    + */ +public class ConnectorRewriteDriver { + private static final Logger LOG = LogManager.getLogger(ConnectorRewriteDriver.class); + + private final ConnectContext ctx; + private final ExternalTable table; + private final PluginDrivenExternalCatalog catalog; + private final ConnectorMetadata metadata; + private final ConnectorProcedureOps procedureOps; + private final ConnectorSession session; + private final ConnectorTableHandle tableHandle; + private final String procedureName; + private final Map properties; + private final List partitionNames; + // The engine-lowered WHERE restricting which files to rewrite, or null when there is no WHERE. Passed + // straight through to the connector's planRewrite (the connector scopes the rewrite to the matching files). + private final ConnectorPredicate whereCondition; + + /** + * Builds a driver bound to one {@code ALTER TABLE ... EXECUTE rewrite_data_files} invocation; all of + * these are already resolved by {@code ConnectorExecuteAction} (the only caller). + */ + public ConnectorRewriteDriver(ConnectContext ctx, ExternalTable table, PluginDrivenExternalCatalog catalog, + ConnectorMetadata metadata, ConnectorProcedureOps procedureOps, ConnectorSession session, + ConnectorTableHandle tableHandle, String procedureName, Map properties, + List partitionNames, ConnectorPredicate whereCondition) { + this.ctx = ctx; + this.table = table; + this.catalog = catalog; + this.metadata = metadata; + this.procedureOps = procedureOps; + this.session = session; + this.tableHandle = tableHandle; + this.procedureName = procedureName; + this.properties = properties; + this.partitionNames = partitionNames; + this.whereCondition = whereCondition; + } + + /** + * Runs the distributed rewrite and returns the single-row result the engine wraps into a ResultSet. + */ + public ConnectorProcedureResult run() throws UserException { + // STEP 0: ask the connector to plan the bin-packed groups, scoped by the lowered WHERE (null = no WHERE). + List groups; + try { + groups = procedureOps.planRewrite(session, tableHandle, procedureName, properties, whereCondition, + partitionNames); + } catch (DorisConnectorException e) { + 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); + } + + // STEP 1: open ONE shared connector transaction for all groups. + PluginDrivenTransactionManager txnManager = + (PluginDrivenTransactionManager) catalog.getTransactionManager(); + ConnectorTransaction connectorTx; + long txnId; + try { + connectorTx = metadata.beginTransaction(session, tableHandle); + txnId = txnManager.begin(connectorTx); + } catch (DorisConnectorException e) { + throw new UserException(e.getMessage(), e); + } + + try { + // STEP 2: run one INSERT-SELECT per group concurrently, all sharing the transaction. + runGroups(groups, txnId, connectorTx); + + // STEP 3: register the UNION of every group's source data files in a SINGLE call. The connector + // re-derives them from the table at the pinned OCC snapshot with ONE planFiles() scan; the former + // per-group loop repeated that full-table scan once per group (G groups = G+1 scans). Ordering is + // unchanged — still AFTER the groups ran (so the first group's write loaded the table + pinned the + // OCC snapshot that the connector re-derives against) and BEFORE commit (which consumes the + // registered files in the RewriteFiles op). Every per-group call scanned the SAME pinned snapshot, so + // one union scan is equivalent; the connector's registration accumulates and dedups by path, and the + // planner emits path-DISJOINT groups (iceberg: planFiles() yields one task per data file, bin-packed + // into disjoint groups), so the union reconstructs exactly the per-group calls' accumulated file set. + connectorTx.registerRewriteSourceFiles(unionSourceFilePaths(groups)); + } catch (Exception e) { + txnManager.rollback(txnId); + if (e instanceof UserException) { + throw (UserException) e; + } + throw new UserException("Failed to rewrite data files: " + e.getMessage(), e); + } + + // STEP 4: commit once. The manager deregisters the transaction on both success and failure, so a + // failed commit needs no rollback (it would find nothing) — surface it directly. + txnManager.commit(txnId); + + // STEP 5: post-commit statistics. The added-files count is only valid after commit (it is + // materialized from the BE commit fragments during commit); the other three are summed from the + // planning groups (the connector exposes them on each ConnectorRewriteGroup). + int addedDataFilesCount = connectorTx.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(); + + return buildResult(rewrittenDataFilesCount, addedDataFilesCount, rewrittenBytesCount, + removedDeleteFilesCount); + } + + /** + * Unions every group's source data-file paths into one dedup'd set, so the connector re-derives them all in + * a single {@code planFiles()} scan (STEP 3) instead of one scan per group. Bin-packed groups are + * path-disjoint so this is a straight union; the connector's own per-path dedup keeps the registered file set + * exact regardless. Package-visible for unit testing (the full distributed STEP 3 needs a live cluster). + */ + static Set unionSourceFilePaths(List groups) { + Set sourceFilePaths = new HashSet<>(); + for (ConnectorRewriteGroup group : groups) { + sourceFilePaths.addAll(group.getDataFilePaths()); + } + return sourceFilePaths; + } + + private void runGroups(List groups, long txnId, ConnectorTransaction connectorTx) + throws UserException { + List tasks = Lists.newArrayList(); + RewriteResultCollector collector = new RewriteResultCollector(groups.size(), tasks); + + for (ConnectorRewriteGroup group : groups) { + ConnectorRewriteGroupTask task = new ConnectorRewriteGroupTask(group, txnId, connectorTx, table, ctx, + new ConnectorRewriteGroupTask.RewriteResultCallback() { + @Override + public void onTaskCompleted(Long taskId) { + collector.onTaskCompleted(taskId); + } + + @Override + public void onTaskFailed(Long taskId, Exception error) { + collector.onTaskFailed(taskId, error); + } + }); + tasks.add(task); + } + + try { + for (TransientTaskExecutor task : tasks) { + Env.getCurrentEnv().getTransientTaskManager().addMemoryTask(task); + } + } catch (JobException e) { + throw new UserException("Failed to submit rewrite tasks: " + e.getMessage(), e); + } + + int maxWaitTime = ctx.getSessionVariable().getInsertTimeoutS(); + try { + boolean completed = collector.await(maxWaitTime, TimeUnit.SECONDS); + if (!completed) { + throw new UserException("Rewrite tasks did not complete within timeout"); + } + if (collector.getFirstError() != null) { + throw new UserException("Some rewrite tasks failed: " + collector.getFirstError().getMessage(), + collector.getFirstError()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new UserException("Wait for rewrite tasks completion was interrupted", e); + } + } + + 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}). + */ + private static class RewriteResultCollector { + private final int expectedTasks; + private final AtomicInteger completedTasks = new AtomicInteger(0); + private final AtomicInteger failedTasks = new AtomicInteger(0); + private volatile Exception firstError = null; + private final CountDownLatch completionLatch; + private final List allTasks; + + RewriteResultCollector(int expectedTasks, List tasks) { + this.expectedTasks = expectedTasks; + this.completionLatch = new CountDownLatch(expectedTasks); + this.allTasks = tasks; + } + + public synchronized void onTaskCompleted(Long taskId) { + int completed = completedTasks.incrementAndGet(); + LOG.info("Connector rewrite task {} completed ({}/{})", taskId, completed, expectedTasks); + completionLatch.countDown(); + } + + public synchronized void onTaskFailed(Long taskId, Exception error) { + int failed = failedTasks.incrementAndGet(); + if (firstError == null) { + firstError = error; + cancelAllOtherTasks(taskId); + } + LOG.warn("Connector rewrite task {} failed ({}/{}): {}", taskId, failed, expectedTasks, + error.getMessage()); + completionLatch.countDown(); + } + + private void cancelAllOtherTasks(Long failedTaskId) { + for (ConnectorRewriteGroupTask task : allTasks) { + if (!task.getId().equals(failedTaskId)) { + try { + task.cancel(); + } catch (Exception e) { + LOG.warn("Failed to cancel rewrite task {}: {}", task.getId(), e.getMessage()); + } + } + } + } + + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + return completionLatch.await(timeout, unit); + } + + public Exception getFirstError() { + return firstError; + } + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorRewriteGroupTask.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorRewriteGroupTask.java new file mode 100644 index 00000000000000..90d4b99e4121f8 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorRewriteGroupTask.java @@ -0,0 +1,242 @@ +// 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.execute; + +import org.apache.doris.analysis.StatementBase; +import org.apache.doris.catalog.Env; +import org.apache.doris.common.Status; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.connector.api.procedure.ConnectorRewriteGroup; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.nereids.StatementContext; +import org.apache.doris.nereids.analyzer.UnboundConnectorTableSink; +import org.apache.doris.nereids.analyzer.UnboundRelation; +import org.apache.doris.nereids.glue.LogicalPlanAdapter; +import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator; +import org.apache.doris.nereids.trees.plans.commands.info.DMLCommandType; +import org.apache.doris.nereids.trees.plans.commands.insert.AbstractInsertExecutor; +import org.apache.doris.nereids.trees.plans.commands.insert.ConnectorRewriteExecutor; +import org.apache.doris.nereids.trees.plans.commands.insert.RewriteTableCommand; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.OriginStatement; +import org.apache.doris.qe.StmtExecutor; +import org.apache.doris.qe.VariableMgr; +import org.apache.doris.scheduler.exception.JobException; +import org.apache.doris.scheduler.executor.TransientTaskExecutor; +import org.apache.doris.thrift.TStatusCode; +import org.apache.doris.thrift.TUniqueId; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Independent task executor for one bin-packed group of a distributed {@code rewrite_data_files} — the + * engine-neutral counterpart of the legacy {@code RewriteGroupTask}. Runs the group's {@code INSERT-SELECT} + * through the connector SPI: it scopes the source scan to the group's data files (the neutral + * {@link StatementContext} raw-path stash, read by {@code PluginDrivenScanNode.pinRewriteFileScope}), builds a + * rewrite-tagged {@link UnboundConnectorTableSink} (which forces GATHER distribution), binds the driver's + * SHARED {@link ConnectorTransaction} onto this group's sink session (via the stash that + * {@link ConnectorRewriteExecutor#finalizeSink} reads), and stamps the shared transaction id onto the BE + * coordinator so every group's commit fragments flow into the one rewrite transaction. + */ +public class ConnectorRewriteGroupTask implements TransientTaskExecutor { + private static final Logger LOG = LogManager.getLogger(ConnectorRewriteGroupTask.class); + + private final ConnectorRewriteGroup group; + private final long transactionId; + private final ConnectorTransaction sharedTransaction; + private final ExternalTable dorisTable; + private final ConnectContext connectContext; + private final RewriteResultCallback resultCallback; + private final Long taskId; + private final AtomicBoolean isCanceled; + private final AtomicBoolean isFinished; + + // for canceling the task + private StmtExecutor stmtExecutor; + + /** + * Builds a task for one bin-packed rewrite group, sharing {@code transactionId} / + * {@code sharedTransaction} with the driver's other groups. + */ + public ConnectorRewriteGroupTask(ConnectorRewriteGroup group, + long transactionId, + ConnectorTransaction sharedTransaction, + ExternalTable dorisTable, + ConnectContext connectContext, + RewriteResultCallback resultCallback) { + this.group = group; + this.transactionId = transactionId; + this.sharedTransaction = sharedTransaction; + this.dorisTable = dorisTable; + this.connectContext = connectContext; + this.resultCallback = resultCallback; + this.taskId = UUID.randomUUID().getMostSignificantBits(); + this.isCanceled = new AtomicBoolean(false); + this.isFinished = new AtomicBoolean(false); + } + + @Override + public Long getId() { + return taskId; + } + + @Override + public void execute() throws JobException { + if (isCanceled.get()) { + throw new JobException("Rewrite task has been canceled, task id: " + taskId); + } + if (isFinished.get()) { + return; + } + + try { + // Step 1: Build a fresh ConnectContext for this group and stash the per-group scan scope + the + // shared connector transaction (read back during planning by pinRewriteFileScope / finalizeSink). + ConnectContext taskConnectContext = buildConnectContext(); + StatementContext stmtCtx = taskConnectContext.getStatementContext(); + stmtCtx.setRewriteSourceFilePaths(new ArrayList<>(group.getDataFilePaths())); + stmtCtx.setRewriteSharedTransaction(sharedTransaction); + + // Step 2: Build the INSERT-SELECT plan (rewrite-tagged connector sink) for this group. + RewriteTableCommand taskLogicalPlan = buildRewriteLogicalPlan(); + LogicalPlanAdapter taskParsedStmt = new LogicalPlanAdapter(taskLogicalPlan, stmtCtx); + taskParsedStmt.setOrigStmt(new OriginStatement(taskLogicalPlan.toString(), 0)); + + // Step 3: Execute the rewrite write for this group. + executeGroup(taskConnectContext, taskLogicalPlan, taskParsedStmt); + + if (resultCallback != null) { + resultCallback.onTaskCompleted(taskId); + } + } catch (Exception e) { + LOG.warn("Failed to execute connector rewrite group: {}", e.getMessage(), e); + if (resultCallback != null) { + resultCallback.onTaskFailed(taskId, e); + } + throw new JobException("Rewrite group execution failed: " + e.getMessage(), e); + } finally { + isFinished.set(true); + } + } + + @Override + public void cancel() throws JobException { + if (isFinished.get()) { + return; + } + isCanceled.set(true); + if (stmtExecutor != null) { + stmtExecutor.cancel(new Status(TStatusCode.CANCELLED, "rewrite task cancelled")); + } + LOG.info("[Connector Rewrite Task] taskId: {} cancelled", taskId); + } + + private void executeGroup(ConnectContext taskConnectContext, + RewriteTableCommand taskLogicalPlan, + StatementBase taskParsedStmt) throws Exception { + stmtExecutor = new StmtExecutor(taskConnectContext, taskParsedStmt); + + // initPlan finalizes the sink (ConnectorRewriteExecutor.finalizeSink binds the shared transaction + // onto the sink session BEFORE planWrite reads it). + AbstractInsertExecutor insertExecutor = taskLogicalPlan.initPlan(taskConnectContext, stmtExecutor); + Preconditions.checkState(insertExecutor instanceof ConnectorRewriteExecutor, + "Expected ConnectorRewriteExecutor, got: " + insertExecutor.getClass()); + + // Stamp the shared transaction id onto the coordinator so the BE-reported commit fragments + // accumulate on the one rewrite transaction (mirrors legacy RewriteGroupTask). + insertExecutor.getCoordinator().setTxnId(transactionId); + + insertExecutor.executeSingleInsert(stmtExecutor); + } + + private RewriteTableCommand buildRewriteLogicalPlan() { + List tableNameParts = ImmutableList.of( + dorisTable.getCatalog().getName(), + dorisTable.getDbName(), + dorisTable.getName()); + + UnboundRelation sourceRelation = new UnboundRelation( + StatementScopeIdGenerator.newRelationId(), + tableNameParts, + ImmutableList.of(), // partitions + false, // isTemporary + ImmutableList.of(), // tabletIds + ImmutableList.of(), // hints + Optional.empty(), // orderKeys + Optional.empty() // limit + ); + + // rewrite=true (last arg) -> PhysicalConnectorTableSink.isRewrite -> GATHER + WriteOperation.REWRITE. + UnboundConnectorTableSink tableSink = new UnboundConnectorTableSink<>( + tableNameParts, + ImmutableList.of(), // colNames (empty means all columns) + ImmutableList.of(), // hints + ImmutableList.of(), // partitions + DMLCommandType.INSERT, + Optional.empty(), // groupExpression + Optional.empty(), // logicalProperties + sourceRelation, + null, // staticPartitionKeyValues + true); // rewrite + return new RewriteTableCommand( + tableSink, + Optional.empty(), // labelName + Optional.empty(), // insertCtx + Optional.empty(), // cte + Optional.empty() // branchName + ); + } + + private ConnectContext buildConnectContext() { + ConnectContext taskContext = new ConnectContext(); + taskContext.setSessionVariable(VariableMgr.cloneSessionVariable(connectContext.getSessionVariable())); + taskContext.setEnv(Env.getCurrentEnv()); + taskContext.setDatabase(connectContext.getDatabase()); + taskContext.setCurrentUserIdentity(connectContext.getCurrentUserIdentity()); + taskContext.setRemoteIP(connectContext.getRemoteIP()); + + UUID uuid = UUID.randomUUID(); + TUniqueId queryId = new TUniqueId(uuid.getMostSignificantBits(), uuid.getLeastSignificantBits()); + taskContext.setQueryId(queryId); + taskContext.setThreadLocalInfo(); + taskContext.setStartTime(); + + StatementContext statementContext = new StatementContext(); + statementContext.setConnectContext(taskContext); + taskContext.setStatementContext(statementContext); + return taskContext; + } + + /** + * Callback interface for task completion. + */ + public interface RewriteResultCallback { + void onTaskCompleted(Long taskId); + + void onTaskFailed(Long taskId, Exception error); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ExecuteActionFactory.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ExecuteActionFactory.java index 064099bec367a3..a2c2b0b9d29186 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ExecuteActionFactory.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ExecuteActionFactory.java @@ -20,9 +20,10 @@ import org.apache.doris.catalog.TableIf; import org.apache.doris.catalog.info.PartitionNamesInfo; import org.apache.doris.common.DdlException; +import org.apache.doris.connector.api.procedure.ConnectorProcedureOps; import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.action.IcebergExecuteActionFactory; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.nereids.trees.expressions.Expression; import java.util.Map; @@ -52,11 +53,12 @@ public static ExecuteAction createAction(String actionType, Map Optional whereCondition, TableIf table) throws DdlException { - // Delegate to specific table engine factories - if (table instanceof IcebergExternalTable) { - return IcebergExecuteActionFactory.createAction(actionType, properties, - partitionNamesInfo, whereCondition, (IcebergExternalTable) table); - } else if (table instanceof ExternalTable) { + // Plugin-driven (connector SPI) tables route to the connector's ConnectorProcedureOps. + if (table instanceof PluginDrivenExternalTable) { + return new ConnectorExecuteAction(actionType, properties, + partitionNamesInfo, whereCondition, (PluginDrivenExternalTable) table); + } + if (table instanceof ExternalTable) { // Handle other external table types in the future throw new DdlException("Execute actions are not supported for table type: " + table.getClass().getSimpleName()); @@ -74,8 +76,16 @@ public static ExecuteAction createAction(String actionType, Map * @return array of supported action type strings */ public static String[] getSupportedActions(TableIf table) { - if (table instanceof IcebergExternalTable) { - return IcebergExecuteActionFactory.getSupportedActions(); + if (table instanceof PluginDrivenExternalTable) { + // Mirrors createAction's PluginDriven routing (dormant until P6.6). No live caller today — this is the + // forward-looking pathfinder so SHOW-style discovery exports the connector's procedure names at flip. + PluginDrivenExternalCatalog catalog = + (PluginDrivenExternalCatalog) ((PluginDrivenExternalTable) table).getCatalog(); + ConnectorProcedureOps procedureOps = catalog.getConnector().getProcedureOps(); + if (procedureOps == null) { + return new String[0]; + } + return procedureOps.getSupportedProcedures().toArray(new String[0]); } // Add support for other table types in the future return new String[0]; diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/ColumnDefinition.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/ColumnDefinition.java index 4be6315f079fdb..98196358a2094f 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/ColumnDefinition.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/ColumnDefinition.java @@ -183,6 +183,18 @@ public boolean hasDefaultValue() { return defaultValue.isPresent(); } + /** + * Returns the column's default value as the catalog-level string (the same value the translated + * {@link org.apache.doris.catalog.Column#getDefaultValue()} carries), or {@code null} when the column + * has no default. Exposed so {@code CreateTableInfoToConnectorRequestConverter} can thread it onto + * {@code ConnectorColumn.defaultValue} for connectors (Hive) that build metastore default constraints + * and gate DDL on per-column defaults; connectors that ignore create-time defaults (iceberg/paimon/ + * maxcompute) are unaffected. + */ + public String getDefaultValueString() { + return defaultValue.map(DefaultValue::getValue).orElse(null); + } + public boolean isVisible() { return isVisible; } 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 fd30dacc9d1d8e..149ff2412c662c 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 @@ -48,11 +48,7 @@ import org.apache.doris.common.util.Util; import org.apache.doris.datasource.CatalogIf; import org.apache.doris.datasource.InternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergUtils; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog; -import org.apache.doris.datasource.paimon.PaimonExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.mysql.privilege.PrivPredicate; import org.apache.doris.nereids.CascadesContext; import org.apache.doris.nereids.analyzer.Scope; @@ -386,14 +382,13 @@ private void checkEngineWithCatalog() { return; } CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(ctlName); - if (catalog instanceof HMSExternalCatalog && !engineName.equals(ENGINE_HIVE)) { - throw new AnalysisException("Hms type catalog can only use `hive` engine."); - } else if (catalog instanceof IcebergExternalCatalog && !engineName.equals(ENGINE_ICEBERG)) { - throw new AnalysisException("Iceberg type catalog can only use `iceberg` engine."); - } else if (catalog instanceof PaimonExternalCatalog && !engineName.equals(ENGINE_PAIMON)) { - throw new AnalysisException("Paimon type catalog can only use `paimon` engine."); - } else if (catalog instanceof MaxComputeExternalCatalog && !engineName.equals(ENGINE_MAXCOMPUTE)) { - throw new AnalysisException("MaxCompute type catalog can only use `maxcompute` engine."); + 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."); + } } } @@ -796,10 +791,6 @@ public void validate(ConnectContext ctx) { + "and you can use 'bucket(num, column)' in 'PARTITIONED BY'."); } - if (engineName.equalsIgnoreCase(ENGINE_ICEBERG)) { - validateIcebergRowLineageColumns(); - } - // Validate Iceberg sort order columns if (sortOrderFields != null && !sortOrderFields.isEmpty()) { if (!engineName.equalsIgnoreCase(ENGINE_ICEBERG)) { @@ -912,20 +903,44 @@ private void paddingEngineName(String ctlName, ConnectContext ctx) { if (catalog instanceof InternalCatalog) { engineName = ENGINE_OLAP; - } else if (catalog instanceof HMSExternalCatalog) { - engineName = ENGINE_HIVE; - } else if (catalog instanceof IcebergExternalCatalog) { - engineName = ENGINE_ICEBERG; - } else if (catalog instanceof PaimonExternalCatalog) { - engineName = ENGINE_PAIMON; - } else if (catalog instanceof MaxComputeExternalCatalog) { - engineName = ENGINE_MAXCOMPUTE; + } 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); } } } + /** + * 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; + } + } + /** * validate ctas definition */ @@ -1114,34 +1129,6 @@ private void validateKeyColumns() { } } - /** - * Validate that Iceberg v3 tables do not define row lineage reserved columns. - */ - public void validateIcebergRowLineageColumns(int formatVersion) { - if (formatVersion < IcebergUtils.ICEBERG_ROW_LINEAGE_MIN_VERSION) { - return; - } - for (ColumnDefinition columnDef : columns) { - if (IcebergUtils.isIcebergRowLineageColumn(columnDef.getName())) { - throw new AnalysisException("Cannot create Iceberg v" + formatVersion - + " table with reserved row lineage column: " + columnDef.getName()); - } - } - } - - private void validateIcebergRowLineageColumns() { - validateIcebergRowLineageColumns(getEffectiveIcebergFormatVersion()); - } - - private int getEffectiveIcebergFormatVersion() { - CatalogIf catalog = Strings.isNullOrEmpty(ctlName) ? null - : Env.getCurrentEnv().getCatalogMgr().getCatalog(ctlName); - if (catalog instanceof IcebergExternalCatalog) { - return IcebergUtils.getEffectiveIcebergFormatVersion(properties, catalog.getProperties()); - } - return IcebergUtils.getEffectiveIcebergFormatVersion(properties, Collections.emptyMap()); - } - /** * analyzeEngine */ diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/BaseExternalTableInsertExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/BaseExternalTableInsertExecutor.java index 3d8f74d502a78b..797c865b7dbf8c 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/BaseExternalTableInsertExecutor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/BaseExternalTableInsertExecutor.java @@ -25,6 +25,7 @@ import org.apache.doris.common.profile.SummaryProfile; import org.apache.doris.common.util.DebugUtil; import org.apache.doris.common.util.Util; +import org.apache.doris.connector.api.handle.ConnectorTransaction; import org.apache.doris.datasource.ExternalTable; import org.apache.doris.nereids.NereidsPlanner; import org.apache.doris.nereids.exceptions.AnalysisException; @@ -88,6 +89,15 @@ public void beginTransaction() { txnId = transactionManager.begin(); } + /** + * Returns the SPI {@link ConnectorTransaction} backing this write, or {@code null} if this executor runs on + * the legacy (non-plugin-driven) transaction path. Used by the generic {@code RowLevelDmlCommand} shell to + * apply the O5-2 write constraint only when a connector transaction is present. Default: legacy path → null. + */ + public ConnectorTransaction getConnectorTransactionOrNull() { + return null; + } + @Override protected void onComplete() throws UserException { if (ctx.getState().getStateType() == QueryState.MysqlStateType.ERR) { @@ -149,7 +159,7 @@ protected void finalizeSink(PlanFragment fragment, DataSink sink, PhysicalSink p } @Override - protected void onFail(Throwable t) { + public void onFail(Throwable t) { errMsg = Util.getRootCauseMessage(t); String queryId = DebugUtil.printId(ctx.queryId()); // if any throwable being thrown during insert operation, first we should abort this txn diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/ConnectorRewriteExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/ConnectorRewriteExecutor.java new file mode 100644 index 00000000000000..649f21fa4edc01 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/ConnectorRewriteExecutor.java @@ -0,0 +1,92 @@ +// 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.insert; + +import org.apache.doris.common.UserException; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.nereids.NereidsPlanner; +import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; +import org.apache.doris.planner.DataSink; +import org.apache.doris.planner.PlanFragment; +import org.apache.doris.planner.PluginDrivenTableSink; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.transaction.TransactionType; + +import java.util.Optional; + +/** + * Rewrite executor for plugin-driven connector data file rewrite (compaction) operations — the neutral + * counterpart of {@link IcebergRewriteExecutor}. + * + *

    Like the iceberg one, the per-group INSERT-SELECT transaction is NOT managed here: the distributed + * rewrite coordinator opens a single connector transaction and holds it across the N per-group writes, + * binding it onto each group's sink session and committing once at the end. So {@code beforeExec} and + * {@code doBeforeCommit} are no-ops, and the transaction is keyed neutrally (not + * {@link TransactionType#ICEBERG}).

    + */ +public class ConnectorRewriteExecutor extends BaseExternalTableInsertExecutor { + + /** + * constructor + */ + public ConnectorRewriteExecutor(ConnectContext ctx, ExternalTable table, + String labelName, NereidsPlanner planner, + Optional insertCtx, + boolean emptyInsert, long jobId) { + super(ctx, table, labelName, planner, insertCtx, emptyInsert, jobId); + } + + @Override + protected void finalizeSink(PlanFragment fragment, DataSink sink, PhysicalSink physicalSink) { + // Bind the SHARED rewrite transaction (opened once by the distributed rewrite driver and stashed on + // this group's StatementContext) onto the SINK's session BEFORE super.finalizeSink -> bindDataSink -> + // planWrite, which resolves it via ConnectorSession.getCurrentTransaction(). Mirrors + // PluginDrivenInsertExecutor.finalizeSink, but the transaction is the driver's shared one (this + // executor opens none of its own), so every group's write joins the single rewrite transaction and + // its commit data flows to one place. No-op (and no NPE) when the sink carries no connector session. + ConnectorTransaction sharedTx = ctx.getStatementContext() == null + ? null : ctx.getStatementContext().getRewriteSharedTransaction(); + if (sharedTx != null && sink instanceof PluginDrivenTableSink) { + ConnectorSession sinkSession = ((PluginDrivenTableSink) sink).getConnectorSession(); + if (sinkSession != null) { + sinkSession.setCurrentTransaction(sharedTx); + } + } + super.finalizeSink(fragment, sink, physicalSink); + } + + @Override + protected void beforeExec() throws UserException { + // do nothing, the transaction is held by the rewrite coordinator, not by ConnectorRewriteExecutor + } + + @Override + protected void doBeforeCommit() throws UserException { + // do nothing, the transaction is held by the rewrite coordinator, not by ConnectorRewriteExecutor + } + + @Override + protected TransactionType transactionType() { + // Neutral key: the connector tags its own transaction with a profile label; rewrite opens no + // executor-owned transaction here, so report UNKNOWN (mirrors PluginDrivenInsertExecutor's + // no-transaction fallback) rather than the iceberg-specific TransactionType.ICEBERG. + return TransactionType.UNKNOWN; + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/HiveInsertExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/HiveInsertExecutor.java deleted file mode 100644 index 760a2c0551d5a0..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/HiveInsertExecutor.java +++ /dev/null @@ -1,126 +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.insert; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.UserException; -import org.apache.doris.common.util.DebugUtil; -import org.apache.doris.datasource.ExternalObjectLog; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HMSTransaction; -import org.apache.doris.datasource.hive.HiveExternalMetaCache; -import org.apache.doris.nereids.NereidsPlanner; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.thrift.THivePartitionUpdate; -import org.apache.doris.thrift.TUniqueId; -import org.apache.doris.transaction.TransactionType; - -import com.google.common.base.Preconditions; -import com.google.common.collect.Lists; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.List; -import java.util.Optional; - -/** - * Insert executor for hive table - */ -public class HiveInsertExecutor extends BaseExternalTableInsertExecutor { - private static final Logger LOG = LogManager.getLogger(HiveInsertExecutor.class); - - private List partitionUpdates; - - /** - * constructor - */ - public HiveInsertExecutor(ConnectContext ctx, HMSExternalTable table, - String labelName, NereidsPlanner planner, - Optional insertCtx, boolean emptyInsert, long jobId) { - super(ctx, table, labelName, planner, insertCtx, emptyInsert, jobId); - } - - @Override - protected void beforeExec() throws UserException { - // check params - HMSTransaction transaction = (HMSTransaction) transactionManager.getTransaction(txnId); - Preconditions.checkArgument(insertCtx.isPresent(), "insert context must be present"); - HiveInsertCommandContext ctx = (HiveInsertCommandContext) insertCtx.get(); - TUniqueId tUniqueId = ConnectContext.get().queryId(); - Preconditions.checkArgument(tUniqueId != null, "query id shouldn't be null"); - ctx.setQueryId(DebugUtil.printId(tUniqueId)); - transaction.beginInsertTable(ctx); - } - - @Override - protected void doBeforeCommit() throws UserException { - HMSTransaction transaction = (HMSTransaction) transactionManager.getTransaction(txnId); - loadedRows = transaction.getUpdateCnt(); - transaction.finishInsertTable(((ExternalTable) table).getOrBuildNameMapping()); - - // Save partition updates for cache refresh after commit - partitionUpdates = transaction.getHivePartitionUpdates(); - } - - @Override - protected void doAfterCommit() throws DdlException { - HMSExternalTable hmsTable = (HMSExternalTable) table; - - // For partitioned tables, do selective partition refresh - // For non-partitioned tables, do full table cache invalidation - List modifiedPartNames = Lists.newArrayList(); - List newPartNames = Lists.newArrayList(); - if (hmsTable.isPartitionedTable() && partitionUpdates != null && !partitionUpdates.isEmpty()) { - HiveExternalMetaCache cache = Env.getCurrentEnv().getExtMetaCacheMgr() - .hive(hmsTable.getCatalog().getId()); - cache.refreshAffectedPartitions(hmsTable, partitionUpdates, modifiedPartNames, newPartNames); - } else { - // Non-partitioned table or no partition updates, do full table refresh - Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTableCache(hmsTable); - } - - // Write edit log to notify other FEs - long updateTime = System.currentTimeMillis(); - hmsTable.setUpdateTime(updateTime); - ExternalObjectLog log; - if (!modifiedPartNames.isEmpty() || !newPartNames.isEmpty()) { - // Partition-level refresh for other FEs - log = ExternalObjectLog.createForRefreshPartitions( - hmsTable.getCatalog().getId(), - table.getDatabase().getFullName(), - table.getName(), - modifiedPartNames, - newPartNames, - updateTime); - } else { - // Full table refresh for other FEs - log = ExternalObjectLog.createForRefreshTable( - hmsTable.getCatalog().getId(), - table.getDatabase().getFullName(), - table.getName(), updateTime); - } - Env.getCurrentEnv().getEditLog().logRefreshExternalTable(log); - } - - @Override - protected TransactionType transactionType() { - return TransactionType.HMS; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergDeleteExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergDeleteExecutor.java deleted file mode 100644 index 0bc25dc15519b2..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergDeleteExecutor.java +++ /dev/null @@ -1,125 +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.insert; - -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergTransaction; -import org.apache.doris.datasource.iceberg.helper.IcebergRewritableDeletePlan; -import org.apache.doris.datasource.iceberg.helper.IcebergRewritableDeletePlanner; -import org.apache.doris.nereids.NereidsPlanner; -import org.apache.doris.nereids.exceptions.AnalysisException; -import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; -import org.apache.doris.planner.DataSink; -import org.apache.doris.planner.IcebergDeleteSink; -import org.apache.doris.planner.PlanFragment; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.transaction.TransactionType; - -import org.apache.iceberg.expressions.Expression; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.Optional; - -/** - * Executor for Iceberg DELETE operations. - * - * DELETE is implemented by generating Position Delete files - * instead of rewriting data files. - * - * Flow: - * 1. Execute query to get rows matching WHERE clause (with $row_id column) - * 2. BE writes position delete files and returns TIcebergCommitData - * 3. FE commits DeleteFiles to Iceberg table using RowDelta API - */ -public class IcebergDeleteExecutor extends BaseExternalTableInsertExecutor { - private static final Logger LOG = LogManager.getLogger(IcebergDeleteExecutor.class); - private final NereidsPlanner nereidsPlanner; - private Optional conflictDetectionFilter = Optional.empty(); - private IcebergRewritableDeletePlan rewritableDeletePlan = IcebergRewritableDeletePlan.empty(); - - public IcebergDeleteExecutor(ConnectContext ctx, IcebergExternalTable table, - String labelName, NereidsPlanner planner, - boolean emptyInsert, long jobId) { - // BaseExternalTableInsertExecutor requires Optional - // For DELETE operations, we pass Optional.empty(). - super(ctx, table, labelName, planner, Optional.empty(), emptyInsert, jobId); - this.nereidsPlanner = planner; - } - - /** Finalize delete sink and attach rewritable delete-file metadata for BE. */ - public void finalizeSinkForDelete(PlanFragment fragment, DataSink sink, PhysicalSink physicalSink) { - super.finalizeSink(fragment, sink, physicalSink); - if (!(sink instanceof IcebergDeleteSink)) { - return; - } - try { - rewritableDeletePlan = IcebergRewritableDeletePlanner.collectForDelete( - (IcebergExternalTable) table, nereidsPlanner); - } catch (UserException e) { - throw new AnalysisException(e.getMessage(), e); - } - ((IcebergDeleteSink) sink).setRewritableDeleteFileSets(rewritableDeletePlan.getThriftDeleteFileSets()); - } - - public void setConflictDetectionFilter(Optional filter) { - conflictDetectionFilter = filter == null ? Optional.empty() : filter; - } - - @Override - protected void beforeExec() throws UserException { - IcebergTransaction transaction = (IcebergTransaction) transactionManager.getTransaction(txnId); - transaction.beginDelete((IcebergExternalTable) table); - transaction.setRewrittenDeleteFilesByReferencedDataFile( - rewritableDeletePlan.getDeleteFilesByReferencedDataFile()); - if (conflictDetectionFilter.isPresent()) { - transaction.setConflictDetectionFilter(conflictDetectionFilter.get()); - } else { - transaction.clearConflictDetectionFilter(); - } - } - - @Override - protected void doBeforeCommit() throws UserException { - IcebergExternalTable dorisTable = (IcebergExternalTable) table; - IcebergTransaction transaction = (IcebergTransaction) transactionManager.getTransaction(txnId); - - // Position delete files are written by BE and returned as TIcebergCommitData. - // FE only needs to use commitDataList to update loaded rows and commit RowDelta. - LOG.info("Processing Position Delete commit data for table: {}", dorisTable.getName()); - - this.loadedRows = transaction.getUpdateCnt(); - - // Finish delete and commit - org.apache.doris.datasource.NameMapping nameMapping = - new org.apache.doris.datasource.NameMapping( - dorisTable.getCatalog().getId(), - dorisTable.getDbName(), - dorisTable.getName(), - dorisTable.getRemoteDbName(), - dorisTable.getRemoteName()); - - transaction.finishDelete(nameMapping); - } - - @Override - protected TransactionType transactionType() { - return TransactionType.ICEBERG; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergInsertExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergInsertExecutor.java deleted file mode 100644 index 6f9b951a9a6e06..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergInsertExecutor.java +++ /dev/null @@ -1,71 +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.insert; - -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergTransaction; -import org.apache.doris.nereids.NereidsPlanner; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.transaction.TransactionType; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.Optional; - -/** - * Insert executor for iceberg table - */ -public class IcebergInsertExecutor extends BaseExternalTableInsertExecutor { - private static final Logger LOG = LogManager.getLogger(IcebergInsertExecutor.class); - - /** - * constructor - */ - public IcebergInsertExecutor(ConnectContext ctx, IcebergExternalTable table, - String labelName, NereidsPlanner planner, - Optional insertCtx, - boolean emptyInsert, long jobId) { - super(ctx, table, labelName, planner, insertCtx, emptyInsert, jobId); - } - - @Override - protected void beforeExec() throws UserException { - IcebergTransaction transaction = (IcebergTransaction) transactionManager.getTransaction(txnId); - transaction.beginInsert((IcebergExternalTable) table, insertCtx); - } - - @Override - protected void doBeforeCommit() throws UserException { - IcebergExternalTable dorisTable = (IcebergExternalTable) table; - NameMapping nameMapping = new NameMapping(dorisTable.getCatalog().getId(), - dorisTable.getDbName(), dorisTable.getName(), - dorisTable.getRemoteDbName(), dorisTable.getRemoteName()); - IcebergTransaction transaction = (IcebergTransaction) transactionManager.getTransaction(txnId); - this.loadedRows = transaction.getUpdateCnt(); - transaction.finishInsert(nameMapping); - } - - @Override - protected TransactionType transactionType() { - return TransactionType.ICEBERG; - } - -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergMergeExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergMergeExecutor.java deleted file mode 100644 index a96dba696eafe2..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergMergeExecutor.java +++ /dev/null @@ -1,105 +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.insert; - -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergTransaction; -import org.apache.doris.datasource.iceberg.helper.IcebergRewritableDeletePlan; -import org.apache.doris.datasource.iceberg.helper.IcebergRewritableDeletePlanner; -import org.apache.doris.nereids.NereidsPlanner; -import org.apache.doris.nereids.exceptions.AnalysisException; -import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; -import org.apache.doris.planner.DataSink; -import org.apache.doris.planner.IcebergMergeSink; -import org.apache.doris.planner.PlanFragment; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.transaction.TransactionType; - -import org.apache.iceberg.expressions.Expression; - -import java.util.Optional; - -/** - * Executor for Iceberg UPDATE merge operations (single scan + merge sink). - */ -public class IcebergMergeExecutor extends BaseExternalTableInsertExecutor { - private final NereidsPlanner nereidsPlanner; - private Optional conflictDetectionFilter = Optional.empty(); - private IcebergRewritableDeletePlan rewritableDeletePlan = IcebergRewritableDeletePlan.empty(); - - public IcebergMergeExecutor(ConnectContext ctx, IcebergExternalTable table, - String labelName, NereidsPlanner planner, - boolean emptyInsert, long jobId) { - super(ctx, table, labelName, planner, Optional.empty(), emptyInsert, jobId); - this.nereidsPlanner = planner; - } - - /** Finalize merge sink and attach rewritable delete-file metadata for BE. */ - public void finalizeSinkForMerge(PlanFragment fragment, DataSink sink, PhysicalSink physicalSink) { - super.finalizeSink(fragment, sink, physicalSink); - if (!(sink instanceof IcebergMergeSink)) { - return; - } - try { - rewritableDeletePlan = IcebergRewritableDeletePlanner.collectForMerge( - (IcebergExternalTable) table, nereidsPlanner); - } catch (UserException e) { - throw new AnalysisException(e.getMessage(), e); - } - ((IcebergMergeSink) sink).setRewritableDeleteFileSets(rewritableDeletePlan.getThriftDeleteFileSets()); - } - - public void setConflictDetectionFilter(Optional filter) { - conflictDetectionFilter = filter == null ? Optional.empty() : filter; - } - - @Override - protected void beforeExec() throws UserException { - IcebergTransaction transaction = (IcebergTransaction) transactionManager.getTransaction(txnId); - transaction.beginMerge((IcebergExternalTable) table); - transaction.setRewrittenDeleteFilesByReferencedDataFile( - rewritableDeletePlan.getDeleteFilesByReferencedDataFile()); - if (conflictDetectionFilter.isPresent()) { - transaction.setConflictDetectionFilter(conflictDetectionFilter.get()); - } else { - transaction.clearConflictDetectionFilter(); - } - } - - @Override - protected void doBeforeCommit() throws UserException { - IcebergExternalTable dorisTable = (IcebergExternalTable) table; - IcebergTransaction transaction = (IcebergTransaction) transactionManager.getTransaction(txnId); - this.loadedRows = transaction.getUpdateCnt(); - - NameMapping nameMapping = new NameMapping( - dorisTable.getCatalog().getId(), - dorisTable.getDbName(), - dorisTable.getName(), - dorisTable.getRemoteDbName(), - dorisTable.getRemoteName()); - transaction.finishMerge(nameMapping); - } - - @Override - protected TransactionType transactionType() { - return TransactionType.ICEBERG; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergRewriteExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergRewriteExecutor.java deleted file mode 100644 index fe0efc98b8f8fa..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergRewriteExecutor.java +++ /dev/null @@ -1,60 +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.insert; - -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.nereids.NereidsPlanner; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.transaction.TransactionType; - -import java.util.Optional; - -/** - * Rewrite executor for iceberg table data file rewrite operations. - * - * This executor is specifically designed for rewrite operations and uses - * rewrite-specific transaction logic instead of insert transaction logic. - */ -public class IcebergRewriteExecutor extends BaseExternalTableInsertExecutor { - - /** - * constructor - */ - public IcebergRewriteExecutor(ConnectContext ctx, IcebergExternalTable table, - String labelName, NereidsPlanner planner, - Optional insertCtx, - boolean emptyInsert, long jobId) { - super(ctx, table, labelName, planner, insertCtx, emptyInsert, jobId); - } - - @Override - protected void beforeExec() throws UserException { - // do nothing, the transaction is not managed by IcebergRewriteExecutor - } - - @Override - protected void doBeforeCommit() throws UserException { - // do nothing, the transaction is not managed by IcebergRewriteExecutor - } - - @Override - protected TransactionType transactionType() { - return TransactionType.ICEBERG; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java index 1372cce62e2ab9..16063fdb645468 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java @@ -26,17 +26,14 @@ import org.apache.doris.common.Config; import org.apache.doris.common.ErrorCode; import org.apache.doris.common.ErrorReport; -import org.apache.doris.common.UserException; import org.apache.doris.common.profile.ProfileManager.ProfileType; import org.apache.doris.common.util.DebugUtil; import org.apache.doris.common.util.Util; import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.FileScanNode; import org.apache.doris.datasource.doris.RemoteDorisExternalTable; import org.apache.doris.datasource.doris.RemoteOlapTable; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.scan.FileScanNode; import org.apache.doris.dictionary.Dictionary; import org.apache.doris.load.loadv2.LoadJob; import org.apache.doris.load.loadv2.LoadStatistic; @@ -44,7 +41,7 @@ import org.apache.doris.nereids.CascadesContext; import org.apache.doris.nereids.NereidsPlanner; import org.apache.doris.nereids.StatementContext; -import org.apache.doris.nereids.analyzer.UnboundMaxComputeTableSink; +import org.apache.doris.nereids.analyzer.UnboundConnectorTableSink; import org.apache.doris.nereids.analyzer.UnboundTVFRelation; import org.apache.doris.nereids.analyzer.UnboundTableSink; import org.apache.doris.nereids.exceptions.AnalysisException; @@ -71,9 +68,6 @@ import org.apache.doris.nereids.trees.plans.physical.PhysicalConnectorTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalDictionarySink; import org.apache.doris.nereids.trees.plans.physical.PhysicalEmptyRelation; -import org.apache.doris.nereids.trees.plans.physical.PhysicalHiveTableSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergTableSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalMaxComputeTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; @@ -417,8 +411,11 @@ ExecutorFactory selectInsertExecutorFactory( (targetTableIf instanceof RemoteDorisExternalTable) ? "_remote_" + Env.getCurrentEnv().getClusterId() : "", ctx.queryId().hi, ctx.queryId().lo)); - // check branch - if (branchName.isPresent() && !(physicalSink instanceof PhysicalIcebergTableSink)) { + // check branch: only iceberg supports INSERT INTO a named branch. An iceberg table is + // plugin-driven (generic sink), so admit it via the connector's supportsWriteBranch() + // capability — without which the branch would be silently dropped and the write would land + // on the table's default ref. + if (branchName.isPresent() && !connectorSupportsWriteBranch(targetTableIf)) { throw new AnalysisException("Only support insert data into iceberg table's branch"); } @@ -503,75 +500,35 @@ ExecutorFactory selectInsertExecutorFactory( && coordinator.getQueryOptions().isEnableMemtableOnSinkNode(); coordinator.getQueryOptions().setEnableMemtableOnSinkNode(isEnableMemtableOnSinkNode); }); - } else if (physicalSink instanceof PhysicalHiveTableSink) { - boolean emptyInsert = childIsEmptyRelation(physicalSink); - HMSExternalTable hiveExternalTable = (HMSExternalTable) targetTableIf; - if (hiveExternalTable.isHiveTransactionalTable()) { - throw new UserException("Not supported insert into hive transactional table."); - } - - return ExecutorFactory.from( - planner, - dataSink, - physicalSink, - () -> new HiveInsertExecutor(ctx, hiveExternalTable, label, planner, - Optional.of(insertCtx.orElse((new HiveInsertCommandContext()))), emptyInsert, jobId) - ); - // set hive query options - } else if (physicalSink instanceof PhysicalIcebergTableSink) { - boolean emptyInsert = childIsEmptyRelation(physicalSink); - IcebergExternalTable icebergExternalTable = (IcebergExternalTable) targetTableIf; - IcebergInsertCommandContext icebergInsertCtx = insertCtx - .map(insertCommandContext -> (IcebergInsertCommandContext) insertCommandContext) - .orElseGet(IcebergInsertCommandContext::new); - branchName.ifPresent(notUsed -> icebergInsertCtx.setBranchName(branchName)); - return ExecutorFactory.from( - planner, - dataSink, - physicalSink, - () -> new IcebergInsertExecutor(ctx, icebergExternalTable, label, planner, - Optional.of(icebergInsertCtx), - emptyInsert, jobId - ) - ); - } else if (physicalSink instanceof PhysicalMaxComputeTableSink) { + } else if (physicalSink instanceof PhysicalConnectorTableSink) { boolean emptyInsert = childIsEmptyRelation(physicalSink); - MaxComputeExternalTable mcExternalTable = (MaxComputeExternalTable) targetTableIf; - MCInsertCommandContext mcInsertCtx = insertCtx - .map(insertCommandContext -> (MCInsertCommandContext) insertCommandContext) - .orElseGet(MCInsertCommandContext::new); - if (mcInsertCtx.getStaticPartitionSpec() == null - && originLogicalQuery instanceof UnboundMaxComputeTableSink) { - UnboundMaxComputeTableSink mcSink = - (UnboundMaxComputeTableSink) originLogicalQuery; - if (mcSink.hasStaticPartition()) { + ExternalTable externalTable = (ExternalTable) targetTableIf; + PluginDrivenInsertCommandContext pluginCtx = insertCtx + .map(insertCommandContext -> (PluginDrivenInsertCommandContext) insertCommandContext) + .orElseGet(PluginDrivenInsertCommandContext::new); + // Thread the @branch target onto the generic write context so the connector points the + // commit at the branch (iceberg validates it in beginWrite). The guard above already + // rejected @branch for connectors without supportsWriteBranch(). + branchName.ifPresent(notUsed -> pluginCtx.setBranchName(branchName)); + if (pluginCtx.getStaticPartitionSpec().isEmpty() + && originLogicalQuery instanceof UnboundConnectorTableSink) { + UnboundConnectorTableSink pluginSink = + (UnboundConnectorTableSink) originLogicalQuery; + if (pluginSink.hasStaticPartition()) { Map staticSpec = Maps.newHashMap(); for (Map.Entry e - : mcSink.getStaticPartitionKeyValues().entrySet()) { + : pluginSink.getStaticPartitionKeyValues().entrySet()) { if (e.getValue() instanceof Literal) { staticSpec.put(e.getKey(), ((Literal) e.getValue()).getStringValue()); } } - mcInsertCtx.setStaticPartitionSpec(staticSpec); + pluginCtx.setStaticPartitionSpec(staticSpec); } } - return ExecutorFactory.from( - planner, - dataSink, - physicalSink, - () -> new MCInsertExecutor(ctx, mcExternalTable, label, planner, - Optional.of(mcInsertCtx), - emptyInsert, jobId - ) - ); - } else if (physicalSink instanceof PhysicalConnectorTableSink) { - boolean emptyInsert = childIsEmptyRelation(physicalSink); - ExternalTable externalTable = (ExternalTable) targetTableIf; return ExecutorFactory.from(planner, dataSink, physicalSink, () -> new PluginDrivenInsertExecutor(ctx, externalTable, label, planner, - Optional.of(insertCtx.orElse( - new PluginDrivenInsertCommandContext())), + Optional.of(pluginCtx), emptyInsert, jobId) ); } else if (physicalSink instanceof PhysicalDictionarySink) { @@ -759,6 +716,21 @@ private boolean childIsEmptyRelation(PhysicalSink sink) { return false; } + /** + * A plugin-driven (SPI connector) table accepts an {@code INSERT INTO t@branch(name)} only if its + * connector declares {@code supportsWriteBranch()}. Connectors with no branch concept must be + * rejected here (fail loud) instead of reaching the generic sink, which would silently drop the + * branch and write to the table's default ref. Mirrors {@code allowInsertOverwrite}'s connector + * capability probe. + */ + private static boolean connectorSupportsWriteBranch(TableIf targetTable) { + if (!(targetTable instanceof PluginDrivenExternalTable)) { + return false; + } + // Per-handle: a heterogeneous gateway supports write-to-branch for its iceberg tables but not its hive. + return ((PluginDrivenExternalTable) targetTable).connectorSupportsWriteBranch(); + } + @Override public StmtType stmtType() { return StmtType.INSERT; diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteTableCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteTableCommand.java index 7d5d0e49e77fa2..1cdfe2d69d8b93 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteTableCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteTableCommand.java @@ -26,11 +26,10 @@ import org.apache.doris.common.ErrorReport; import org.apache.doris.common.UserException; import org.apache.doris.common.util.InternalDatabaseUtil; +import org.apache.doris.connector.api.handle.WriteOperation; import org.apache.doris.datasource.doris.RemoteDorisExternalTable; import org.apache.doris.datasource.doris.RemoteOlapTable; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.insertoverwrite.AbstractInsertOverwriteManager; import org.apache.doris.insertoverwrite.InsertOverwriteUtil; import org.apache.doris.insertoverwrite.RemoteInsertOverwriteManager; @@ -39,9 +38,8 @@ import org.apache.doris.nereids.CascadesContext; import org.apache.doris.nereids.NereidsPlanner; import org.apache.doris.nereids.StatementContext; -import org.apache.doris.nereids.analyzer.UnboundHiveTableSink; +import org.apache.doris.nereids.analyzer.UnboundConnectorTableSink; import org.apache.doris.nereids.analyzer.UnboundIcebergTableSink; -import org.apache.doris.nereids.analyzer.UnboundMaxComputeTableSink; import org.apache.doris.nereids.analyzer.UnboundTableSink; import org.apache.doris.nereids.analyzer.UnboundTableSinkCreator; import org.apache.doris.nereids.exceptions.AnalysisException; @@ -61,7 +59,6 @@ import org.apache.doris.nereids.trees.plans.commands.NeedAuditEncryption; import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; import org.apache.doris.nereids.trees.plans.logical.UnboundLogicalSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalTableSink; import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; @@ -140,7 +137,8 @@ public void run(ConnectContext ctx, StmtExecutor executor) throws Exception { TableIf targetTableIf = InsertUtils.getTargetTable(originLogicalQuery, ctx); // check allow insert overwrite if (!allowInsertOverwrite(targetTableIf)) { - String errMsg = "insert into overwrite only support OLAP/Remote OLAP and HMS/ICEBERG table." + String errMsg = "insert into overwrite only support OLAP/Remote OLAP table and external" + + " tables (HMS/Iceberg, or a plugin-driven connector that supports overwrite)." + " But current table type is " + targetTableIf.getType(); LOG.error(errMsg); throw new AnalysisException(errMsg); @@ -216,8 +214,10 @@ public void run(ConnectContext ctx, StmtExecutor executor) throws Exception { partitionNames = new ArrayList<>(); } - // check branch - if (branchName.isPresent() && !(physicalTableSink instanceof PhysicalIcebergTableSink)) { + // check branch: only iceberg supports INSERT OVERWRITE into a named branch. An iceberg table is + // plugin-driven, so admit it via the connector's supportsWriteBranch() capability — without which + // the branch would be silently dropped and the overwrite would land on the table's default ref. + if (branchName.isPresent() && !pluginConnectorSupportsWriteBranch(targetTable)) { throw new AnalysisException( "Only support insert overwrite into iceberg table's branch"); } @@ -315,12 +315,38 @@ private boolean allowInsertOverwrite(TableIf targetTable) { if (targetTable instanceof OlapTable || targetTable instanceof RemoteDorisExternalTable) { return true; } else { - return targetTable instanceof HMSExternalTable - || targetTable instanceof IcebergExternalTable - || targetTable instanceof MaxComputeExternalTable; + return targetTable instanceof PluginDrivenExternalTable + && pluginConnectorSupportsInsertOverwrite((PluginDrivenExternalTable) targetTable); } } + /** + * A plugin-driven (SPI connector) table supports INSERT OVERWRITE only if its connector + * declares the capability. Connectors that support plain INSERT but not overwrite (e.g. jdbc) + * must be rejected here so the command fails loud, rather than reaching the sink and silently + * degrading OVERWRITE to a plain append. Mirrors the connector-access pattern in + * {@code PhysicalPlanTranslator}. + */ + private static boolean pluginConnectorSupportsInsertOverwrite(PluginDrivenExternalTable table) { + // Per-handle write-op probe (a heterogeneous gateway answers per-table; OVERWRITE happens to be admitted + // by both hive and iceberg, but the probe is resolved uniformly with the other write-op admission gates). + return table.connectorSupportedWriteOperations().contains(WriteOperation.OVERWRITE); + } + + /** + * A plugin-driven (SPI connector) table accepts an {@code INSERT OVERWRITE t@branch(name)} only if + * its connector declares {@code supportsWriteBranch()}. Connectors with no branch concept must be + * rejected here (fail loud) instead of reaching the generic sink, which would silently drop the + * branch and overwrite the table's default ref. Mirrors {@code pluginConnectorSupportsInsertOverwrite}. + */ + private static boolean pluginConnectorSupportsWriteBranch(TableIf targetTable) { + if (!(targetTable instanceof PluginDrivenExternalTable)) { + return false; + } + // Per-handle: a heterogeneous gateway supports write-to-branch for its iceberg tables but not its hive. + return ((PluginDrivenExternalTable) targetTable).connectorSupportsWriteBranch(); + } + private void runInsertCommand(LogicalPlan logicalQuery, InsertCommandContext insertCtx, ConnectContext ctx, StmtExecutor executor) throws Exception { InsertIntoTableCommand insertCommand = new InsertIntoTableCommand(logicalQuery, labelName, @@ -364,20 +390,6 @@ private void insertIntoPartitions(ConnectContext ctx, StmtExecutor executor, Lis // 2. we save and pass overwrite auto detect by insertCtx boolean allowAutoPartition = wholeTable && ctx.getSessionVariable().isEnableAutoCreateWhenOverwrite(); insertCtx = new OlapInsertCommandContext(allowAutoPartition, true); - } else if (logicalQuery instanceof UnboundHiveTableSink) { - UnboundHiveTableSink sink = (UnboundHiveTableSink) logicalQuery; - copySink = (UnboundLogicalSink) UnboundTableSinkCreator.createUnboundTableSink( - sink.getNameParts(), - sink.getColNames(), - sink.getHints(), - false, - sink.getPartitions(), - false, - TPartialUpdateNewRowPolicy.APPEND, - sink.getDMLCommandType(), - (LogicalPlan) (sink.child(0))); - insertCtx = new HiveInsertCommandContext(); - ((HiveInsertCommandContext) insertCtx).setOverwrite(true); } else if (logicalQuery instanceof UnboundIcebergTableSink) { UnboundIcebergTableSink sink = (UnboundIcebergTableSink) logicalQuery; copySink = (UnboundLogicalSink) UnboundTableSinkCreator.createUnboundTableSink( @@ -395,8 +407,8 @@ private void insertIntoPartitions(ConnectContext ctx, StmtExecutor executor, Lis ((IcebergInsertCommandContext) insertCtx).setOverwrite(true); setStaticPartitionToContext(sink, (IcebergInsertCommandContext) insertCtx); branchName.ifPresent(notUsed -> ((IcebergInsertCommandContext) insertCtx).setBranchName(branchName)); - } else if (logicalQuery instanceof UnboundMaxComputeTableSink) { - UnboundMaxComputeTableSink sink = (UnboundMaxComputeTableSink) logicalQuery; + } else if (logicalQuery instanceof UnboundConnectorTableSink) { + UnboundConnectorTableSink sink = (UnboundConnectorTableSink) logicalQuery; copySink = (UnboundLogicalSink) UnboundTableSinkCreator.createUnboundTableSink( sink.getNameParts(), sink.getColNames(), sink.getHints(), false, sink.getPartitions(), false, @@ -404,8 +416,12 @@ private void insertIntoPartitions(ConnectContext ctx, StmtExecutor executor, Lis sink.getDMLCommandType(), (LogicalPlan) (sink.child(0)), sink.getStaticPartitionKeyValues()); - MCInsertCommandContext mcCtx = new MCInsertCommandContext(); - mcCtx.setOverwrite(true); + PluginDrivenInsertCommandContext pluginCtx = new PluginDrivenInsertCommandContext(); + pluginCtx.setOverwrite(true); + // Thread the @branch target onto the generic write context (the inner InsertIntoTableCommand + // reuses this ctx) so the connector points the overwrite commit at the branch. The guard above + // already rejected @branch for connectors without supportsWriteBranch(). + branchName.ifPresent(notUsed -> pluginCtx.setBranchName(branchName)); if (sink.hasStaticPartition()) { Map staticSpec = Maps.newHashMap(); for (Map.Entry e : sink.getStaticPartitionKeyValues().entrySet()) { @@ -413,9 +429,9 @@ private void insertIntoPartitions(ConnectContext ctx, StmtExecutor executor, Lis staticSpec.put(e.getKey(), ((Literal) e.getValue()).getStringValue()); } } - mcCtx.setStaticPartitionSpec(staticSpec); + pluginCtx.setStaticPartitionSpec(staticSpec); } - insertCtx = mcCtx; + insertCtx = pluginCtx; } else { throw new UserException("Current catalog does not support insert overwrite yet."); } @@ -437,9 +453,6 @@ private void insertIntoAutoDetect(ConnectContext ctx, StmtExecutor executor, lon boolean allowAutoPartition = ctx.getSessionVariable().isEnableAutoCreateWhenOverwrite(); insertCtx = new OlapInsertCommandContext(allowAutoPartition, ((UnboundTableSink) logicalQuery).isAutoDetectPartition(), groupId, true); - } else if (logicalQuery instanceof UnboundHiveTableSink) { - insertCtx = new HiveInsertCommandContext(); - ((HiveInsertCommandContext) insertCtx).setOverwrite(true); } else { throw new UserException("Current catalog does not support insert overwrite with auto-detect partition."); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtils.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtils.java index fa5e34046d1c80..c4fb8d6f529308 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtils.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtils.java @@ -29,7 +29,7 @@ import org.apache.doris.catalog.TableIf; import org.apache.doris.common.Config; import org.apache.doris.common.util.DebugPointUtil; -import org.apache.doris.datasource.hive.HMSExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.foundation.format.FormatOptions; import org.apache.doris.nereids.CascadesContext; import org.apache.doris.nereids.analyzer.Scope; @@ -38,10 +38,8 @@ import org.apache.doris.nereids.analyzer.UnboundConnectorTableSink; import org.apache.doris.nereids.analyzer.UnboundDictionarySink; import org.apache.doris.nereids.analyzer.UnboundFunction; -import org.apache.doris.nereids.analyzer.UnboundHiveTableSink; import org.apache.doris.nereids.analyzer.UnboundIcebergTableSink; import org.apache.doris.nereids.analyzer.UnboundInlineTable; -import org.apache.doris.nereids.analyzer.UnboundMaxComputeTableSink; import org.apache.doris.nereids.analyzer.UnboundSlot; import org.apache.doris.nereids.analyzer.UnboundStar; import org.apache.doris.nereids.analyzer.UnboundTableSink; @@ -286,11 +284,11 @@ private static Plan normalizePlanWithoutLock(LogicalPlan plan, TableIf table, Optional analyzeContext, Optional insertCtx) { UnboundLogicalSink unboundLogicalSink = (UnboundLogicalSink) plan; - if (table instanceof HMSExternalTable) { - HMSExternalTable hiveTable = (HMSExternalTable) table; - if (hiveTable.isView()) { - throw new AnalysisException("View is not support in hive external table."); - } + // Plugin-driven (flipped) external views: the legacy engine sinks rejected writes to a view (e.g. + // IcebergTableSink threw on isView()); on the neutral write path that guard must live here, since a + // flipped catalog reaches a connector sink, not the engine-specific sink. + if (table instanceof PluginDrivenExternalTable && ((PluginDrivenExternalTable) table).isView()) { + throw new AnalysisException("Write data to view is not supported"); } // Re-read partial update settings from session variable to handle multi-statement // batches where SET and INSERT are parsed together before execution. @@ -377,8 +375,8 @@ private static Plan normalizePlanWithoutLock(LogicalPlan plan, TableIf table, Map staticPartitions = null; if (unboundLogicalSink instanceof UnboundIcebergTableSink) { staticPartitions = ((UnboundIcebergTableSink) unboundLogicalSink).getStaticPartitionKeyValues(); - } else if (unboundLogicalSink instanceof UnboundMaxComputeTableSink) { - staticPartitions = ((UnboundMaxComputeTableSink) unboundLogicalSink).getStaticPartitionKeyValues(); + } else if (unboundLogicalSink instanceof UnboundConnectorTableSink) { + staticPartitions = ((UnboundConnectorTableSink) unboundLogicalSink).getStaticPartitionKeyValues(); } if (staticPartitions != null && !staticPartitions.isEmpty() && CollectionUtils.isEmpty(unboundLogicalSink.getColNames())) { @@ -596,16 +594,12 @@ public static List getTargetTableQualified(Plan plan, ConnectContext ctx UnboundLogicalSink unboundTableSink; if (plan instanceof UnboundTableSink) { unboundTableSink = (UnboundTableSink) plan; - } else if (plan instanceof UnboundHiveTableSink) { - unboundTableSink = (UnboundHiveTableSink) plan; } else if (plan instanceof UnboundIcebergTableSink) { unboundTableSink = (UnboundIcebergTableSink) plan; } else if (plan instanceof UnboundDictionarySink) { unboundTableSink = (UnboundDictionarySink) plan; } else if (plan instanceof UnboundBlackholeSink) { unboundTableSink = (UnboundBlackholeSink) plan; - } else if (plan instanceof UnboundMaxComputeTableSink) { - unboundTableSink = (UnboundMaxComputeTableSink) plan; } else if (plan instanceof UnboundConnectorTableSink) { unboundTableSink = (UnboundConnectorTableSink) plan; } else { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/MCInsertCommandContext.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/MCInsertCommandContext.java deleted file mode 100644 index 0eb693e4480f24..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/MCInsertCommandContext.java +++ /dev/null @@ -1,84 +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.insert; - -import java.util.Map; - -/** - * Insert command context for MaxCompute tables. - */ -public class MCInsertCommandContext extends BaseExternalTableInsertCommandContext { - - private Map staticPartitionSpec; - private boolean overwrite; - private String sessionId; - private long blockIdStart; - private long blockIdCount; - private String writeSessionId; - - public MCInsertCommandContext() { - } - - public Map getStaticPartitionSpec() { - return staticPartitionSpec; - } - - public void setStaticPartitionSpec(Map staticPartitionSpec) { - this.staticPartitionSpec = staticPartitionSpec; - } - - public boolean isOverwrite() { - return overwrite; - } - - public void setOverwrite(boolean overwrite) { - this.overwrite = overwrite; - } - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } - - public long getBlockIdStart() { - return blockIdStart; - } - - public void setBlockIdStart(long blockIdStart) { - this.blockIdStart = blockIdStart; - } - - public long getBlockIdCount() { - return blockIdCount; - } - - public void setBlockIdCount(long blockIdCount) { - this.blockIdCount = blockIdCount; - } - - public String getWriteSessionId() { - return writeSessionId; - } - - public void setWriteSessionId(String writeSessionId) { - this.writeSessionId = writeSessionId; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/MCInsertExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/MCInsertExecutor.java deleted file mode 100644 index 47df06485e7546..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/MCInsertExecutor.java +++ /dev/null @@ -1,84 +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.insert; - -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.maxcompute.MCTransaction; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalTable; -import org.apache.doris.nereids.NereidsPlanner; -import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; -import org.apache.doris.planner.DataSink; -import org.apache.doris.planner.MaxComputeTableSink; -import org.apache.doris.planner.PlanFragment; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.transaction.TransactionType; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.Optional; - -/** - * MCInsertExecutor for MaxCompute external table insert. - */ -public class MCInsertExecutor extends BaseExternalTableInsertExecutor { - - private static final Logger LOG = LogManager.getLogger(MCInsertExecutor.class); - - // Saved during finalizeSink() so we can inject writeSessionId before execution - private MaxComputeTableSink mcTableSink; - - public MCInsertExecutor(ConnectContext ctx, MaxComputeExternalTable table, - String labelName, NereidsPlanner planner, - Optional insertCtx, - boolean emptyInsert, long jobId) { - super(ctx, table, labelName, planner, insertCtx, emptyInsert, jobId); - } - - @Override - protected void finalizeSink(PlanFragment fragment, DataSink sink, PhysicalSink physicalSink) { - // Let parent call bindDataSink() to build the Thrift sink - super.finalizeSink(fragment, sink, physicalSink); - // Save reference so beforeExec() can inject writeSessionId later - mcTableSink = (MaxComputeTableSink) sink; - } - - @Override - protected void beforeExec() throws UserException { - // 1. Create Storage API write session as part of the transaction - MCTransaction transaction = (MCTransaction) transactionManager.getTransaction(txnId); - transaction.beginInsert((MaxComputeExternalTable) table, insertCtx); - - // 2. Inject write context into the Thrift sink before fragments are sent to BE - if (mcTableSink != null) { - mcTableSink.setWriteContext(txnId, transaction.getWriteSessionId()); - } - } - - @Override - protected void doBeforeCommit() throws UserException { - MCTransaction transaction = (MCTransaction) transactionManager.getTransaction(txnId); - loadedRows = transaction.getUpdateCnt(); - transaction.finishInsert(); - } - - @Override - protected TransactionType transactionType() { - return TransactionType.MAXCOMPUTE; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertCommandContext.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertCommandContext.java index 2799a6c7b666a8..e766e1572ba990 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertCommandContext.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertCommandContext.java @@ -17,10 +17,43 @@ package org.apache.doris.nereids.trees.plans.commands.insert; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + /** * Insert command context for plugin-driven connector catalogs. - * No additional fields — overwrite is inherited from BaseExternalTableInsertCommandContext. - * Connector plugins provide write config through the ConnectorWriteOps SPI. + * + *

    {@code overwrite} is inherited from {@link BaseExternalTableInsertCommandContext}. + * The static partition spec — a generic {@code col -> val} map — is carried here and + * handed to the connector via the write context of + * {@code ConnectorWritePlanProvider.planWrite}. It is populated during sink binding + * (wired at the connector cutover) and defaults to empty, so a write with no static + * partition contributes nothing to partition pinning.

    + * + *

    {@code branchName} carries the {@code INSERT INTO t@branch(name)} target. It is threaded onto + * the connector write handle ({@code ConnectorWriteHandle.getBranchName}) so a versioned-table + * connector points the commit at the branch; empty (the default) means the table's default ref.

    */ public class PluginDrivenInsertCommandContext extends BaseExternalTableInsertCommandContext { + + private Map staticPartitionSpec = Collections.emptyMap(); + private Optional branchName = Optional.empty(); + + public Map getStaticPartitionSpec() { + return staticPartitionSpec; + } + + public void setStaticPartitionSpec(Map staticPartitionSpec) { + this.staticPartitionSpec = + staticPartitionSpec == null ? Collections.emptyMap() : staticPartitionSpec; + } + + public Optional getBranchName() { + return branchName; + } + + public void setBranchName(Optional branchName) { + this.branchName = branchName == null ? Optional.empty() : branchName; + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertExecutor.java index 4c1b5594102797..ab3265c18927f7 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertExecutor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertExecutor.java @@ -17,44 +17,52 @@ package org.apache.doris.nereids.trees.plans.commands.insert; -import org.apache.doris.catalog.Column; import org.apache.doris.common.DdlException; import org.apache.doris.common.UserException; 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.ConnectorWriteOps; -import org.apache.doris.connector.api.handle.ConnectorInsertHandle; import org.apache.doris.connector.api.handle.ConnectorTableHandle; -import org.apache.doris.connector.api.write.ConnectorWriteType; -import org.apache.doris.datasource.ConnectorColumnConverter; +import org.apache.doris.connector.api.handle.ConnectorTransaction; import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.CatalogStatementTransaction; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.nereids.NereidsPlanner; +import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; +import org.apache.doris.planner.DataSink; +import org.apache.doris.planner.PlanFragment; +import org.apache.doris.planner.PluginDrivenTableSink; import org.apache.doris.qe.ConnectContext; +import org.apache.doris.transaction.PluginDrivenTransactionManager; import org.apache.doris.transaction.TransactionType; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import java.util.Collections; -import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** * Insert executor for plugin-driven connector catalogs. - * Delegates the write lifecycle to the connector's ConnectorWriteOps SPI. + * + *

    Delegates the write lifecycle to the connector's {@link ConnectorWriteOps} SPI through a + * single transaction model: {@link #beginTransaction()} opens a {@link ConnectorTransaction} + * and registers it globally; {@link #finalizeSink} binds it onto the sink's session so the + * connector's {@code planWrite} sees it; BE feeds commit fragments back through the transaction; + * {@code onComplete} commits / {@code onFail} rolls back via the transaction manager. Connectors + * whose writes are auto-committed by BE (e.g. jdbc) return a degenerate no-op transaction.

    */ public class PluginDrivenInsertExecutor extends BaseExternalTableInsertExecutor { private static final Logger LOG = LogManager.getLogger(PluginDrivenInsertExecutor.class); - private transient ConnectorInsertHandle insertHandle; private transient ConnectorSession connectorSession; private transient ConnectorWriteOps writeOps; - private transient ConnectorWriteType resolvedWriteType; + // The connector transaction for this write: opened in beginTransaction(), bound onto the + // sink's session in finalizeSink(), and committed / rolled back via the transaction manager + // in onComplete() / onFail(). Null only on the empty-insert path, which skips beginTransaction. + private transient ConnectorTransaction connectorTx; /** * constructor @@ -67,106 +75,147 @@ public PluginDrivenInsertExecutor(ConnectContext ctx, ExternalTable table, } @Override - protected void beforeExec() throws UserException { - PluginDrivenExternalCatalog catalog = - (PluginDrivenExternalCatalog) ((ExternalTable) table).getCatalog(); - Connector connector = catalog.getConnector(); - connectorSession = catalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(connectorSession); - writeOps = metadata; - if (!writeOps.supportsInsert()) { - throw new UserException("Connector does not support INSERT for table: " - + table.getName()); - } + public void beginTransaction() { + ensureConnectorSetup(); + // Single transaction model: every plugin-driven write opens a ConnectorTransaction and + // registers it globally, so the BE block-allocation RPC and commit-data feedback can look + // it up by id. Connectors whose writes are auto-committed by BE (jdbc) return a degenerate + // no-op transaction; maxcompute returns a real one. The connector-specific write session is + // created later by planWrite (reached through finalizeSink -> bindDataSink). + // + // Pass the resolved write-target handle so a heterogeneous gateway (e.g. an iceberg-on-HMS table served + // by the hive plugin) opens the SIBLING connector's transaction, whose concrete type its write plan + // downcasts; a single-format connector ignores the handle (the SPI default delegates to the no-arg + // beginTransaction). resolveWriteTargetHandle fails loud rather than handing the gateway a null handle. + ConnectorTableHandle writeHandle = ((PluginDrivenExternalTable) table).resolveWriteTargetHandle(); + // Co-hold the transaction with the statement's one shared metadata (writeOps) + session on the statement + // scope, so read and write share one instance and the scope deterministically rolls back a transaction + // aborted mid-flight at statement end -- before it closes that shared metadata. begin() still mints from + // writeOps and registers with the manager (global lookup for the BE block-allocation RPC / commit-data + // feedback), returning the transaction for the sink-session binding in finalizeSink. Under NONE the scope + // stores nothing, so the holder is transient and the executor's own commit/rollback remains the only + // lifecycle (byte-identical to the pre-co-holder path). + CatalogStatementTransaction stmtTxn = (CatalogStatementTransaction) connectorSession.getStatementScope() + .computeIfAbsent("txn:" + connectorSession.getCatalogId(), + () -> new CatalogStatementTransaction(writeOps, connectorSession, + (PluginDrivenTransactionManager) transactionManager)); + connectorTx = stmtTxn.begin(writeHandle); + txnId = connectorTx.getTransactionId(); + } - // Get table handle using remote names (not local/mapped names) - ExternalTable extTable = (ExternalTable) table; - String remoteDbName = extTable.getRemoteDbName(); - String remoteTableName = extTable.getRemoteName(); - Optional tableHandle = metadata.getTableHandle( - connectorSession, remoteDbName, remoteTableName); - if (!tableHandle.isPresent()) { - throw new UserException("Table not found via connector: " - + remoteDbName + "." + remoteTableName); - } + @Override + public ConnectorTransaction getConnectorTransactionOrNull() { + return connectorTx; + } - // Convert Doris columns to connector columns - List columns = toConnectorColumns(extTable.getBaseSchema(true)); + @Override + protected void finalizeSink(PlanFragment fragment, DataSink sink, PhysicalSink physicalSink) { + // Bind the connector transaction onto the SINK's session BEFORE super.finalizeSink -> + // bindDataSink -> planWrite, which reads it via ConnectorSession.getCurrentTransaction(). + // Only plan-provider sinks (e.g. maxcompute) carry a connector session; config-bag sinks + // (jdbc) have none and build their TDataSink without the transaction, so skip binding for + // them (getConnectorSession() == null) — otherwise this would NPE. + if (connectorTx != null && sink instanceof PluginDrivenTableSink) { + ConnectorSession sinkSession = ((PluginDrivenTableSink) sink).getConnectorSession(); + if (sinkSession != null) { + sinkSession.setCurrentTransaction(connectorTx); + } + } + super.finalizeSink(fragment, sink, physicalSink); + } - // Resolve write type for transaction type decision - resolvedWriteType = writeOps.getWriteConfig( - connectorSession, tableHandle.get(), columns).getWriteType(); + /** + * Public finalize entry for the row-level DML shell ({@code RowLevelDmlCommand} via + * {@code IcebergRowLevelDmlTransform.finalizeSink}), which lives outside this package and so cannot reach + * the {@code protected} {@link #finalizeSink}. Mirrors the legacy + * {@code IcebergDeleteExecutor.finalizeSinkForDelete} public entry, but with NO rewritable-delete overlay: + * the connector's {@code planWrite} supplies {@code rewritable_delete_file_sets} via the write handle (the + * scan-time stash), so the base finalize (bind tx → {@code bindDataSink} → {@code planWrite}) is + * the single, complete finalize for a row-level DELETE/MERGE write. {@code executeSingleInsert} does not + * finalize, so this is the one and only finalize call on the row-level DML path. + */ + public void finalizeRowLevelDmlSink(PlanFragment fragment, DataSink sink, PhysicalSink physicalSink) { + finalizeSink(fragment, sink, physicalSink); + } - // Begin insert - insertHandle = writeOps.beginInsert(connectorSession, tableHandle.get(), columns); - LOG.info("Plugin-driven insert started for table {}.{}, txnId={}", - remoteDbName, remoteTableName, txnId); + @Override + protected void beforeExec() throws UserException { + // Single transaction model: the connector write session is created by planWrite + // (in finalizeSink). There is no per-statement handle to open here. } @Override protected void doBeforeCommit() throws UserException { - if (writeOps != null && insertHandle != null) { - writeOps.finishInsert(connectorSession, insertHandle, Collections.emptyList()); + if (connectorTx != null) { + // BE reports the affected-row count either through the connector transaction's + // commit-data (e.g. maxcompute TMCCommitData.row_count) or through the coordinator's + // DPP_NORMAL_ALL load counter (e.g. jdbc). getUpdateCnt() == -1 means "no count from + // the transaction; keep the coordinator counter" (NoOpConnectorTransaction); a value + // >= 0 is authoritative and backfills loadedRows, which AbstractInsertExecutor otherwise + // leaves at 0 for the transaction model. Mirrors legacy MCInsertExecutor.doBeforeCommit. + long cnt = connectorTx.getUpdateCnt(); + if (cnt >= 0) { + loadedRows = cnt; + } } } /** - * Post-commit refresh is best-effort for connector writes. + * Post-commit refresh is best-effort for ALL connector write paths. * - *

    For JDBC_WRITE, the remote write is committed directly by BE via - * PreparedStatement — FE cannot roll it back. If the post-commit cache - * refresh fails (e.g., catalog dropped concurrently, edit log I/O error), - * reporting the INSERT as failed would mislead the user into retrying, - * causing duplicate data. The old JdbcInsertExecutor avoided this by - * not performing any post-commit work at all.

    + *

    By the time this runs, the remote write is already durably committed and FE cannot roll + * it back: for jdbc the BE commits directly via PreparedStatement; for the connector-transaction + * path (maxcompute) the write session is committed by the transaction manager in onComplete, + * before this step. {@code super.doAfterCommit()} only refreshes FE-side metadata cache and + * writes an external-table refresh edit log (a cache-invalidation hint to followers); it never + * touches the already-committed remote data.

    * - *

    We preserve that safety guarantee while still attempting the refresh - * so that cache stays fresh in the common case.

    + *

    If that refresh fails (e.g., catalog dropped concurrently, edit log I/O error), reporting + * the INSERT as failed would mislead the user into retrying and writing duplicate data. The + * worst case of swallowing is transient cache staleness, which self-heals on the next refresh / + * TTL. This intentionally diverges from legacy MCInsertExecutor (see deviations-log DV-018), + * preserving the safer swallow-and-warn behavior of the old JdbcInsertExecutor.

    */ @Override protected void doAfterCommit() throws DdlException { try { super.doAfterCommit(); } catch (Exception e) { - LOG.warn("Post-commit cache refresh failed for table {} (write type: {}). " + LOG.warn("Post-commit cache refresh failed for table {}. " + "Data was committed successfully; cache may be stale until next refresh.", - table.getName(), resolvedWriteType, e); + table.getName(), e); } } - @Override - protected void onFail(Throwable t) { - // Abort the connector-level write before the Doris-level transaction rollback - if (writeOps != null && insertHandle != null) { - try { - writeOps.abortInsert(connectorSession, insertHandle); - } catch (Exception e) { - LOG.warn("Failed to abort connector insert for table {}: {}", - table.getName(), e.getMessage(), e); - } - } - super.onFail(t); - } - @Override protected TransactionType transactionType() { - if (resolvedWriteType == ConnectorWriteType.JDBC_WRITE) { - return TransactionType.JDBC; + if (connectorTx == null) { + // empty-insert path skips beginTransaction; no transaction was opened. + return TransactionType.UNKNOWN; } - return TransactionType.HMS; + // The connector tags its transaction with a profile label (e.g. "JDBC" / "MAXCOMPUTE"); + // map it to the profiling TransactionType. Unknown labels fall back to UNKNOWN. + String label = connectorTx.profileLabel(); + for (TransactionType type : TransactionType.values()) { + if (type.name().equals(label)) { + return type; + } + } + return TransactionType.UNKNOWN; } /** - * Converts a list of Doris {@link Column} to a list of {@link ConnectorColumn}. - * This is the reverse of {@link org.apache.doris.datasource.ConnectorColumnConverter#convertColumns}. + * Lazily builds the connector session and write-ops handle for this insert. Called from + * {@link #beginTransaction()} before opening the connector transaction. Idempotent. */ - private static List toConnectorColumns(List dorisColumns) { - return dorisColumns.stream() - .map(PluginDrivenInsertExecutor::toConnectorColumn) - .collect(Collectors.toList()); - } - - private static ConnectorColumn toConnectorColumn(Column col) { - return ConnectorColumnConverter.toConnectorColumn(col); + private void ensureConnectorSetup() { + if (connectorSession != null) { + return; + } + PluginDrivenExternalCatalog catalog = + (PluginDrivenExternalCatalog) ((ExternalTable) table).getCatalog(); + Connector connector = catalog.getConnector(); + connectorSession = catalog.buildConnectorSession(); + writeOps = PluginDrivenMetadata.get(connectorSession, connector); } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/RewriteTableCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/RewriteTableCommand.java index 127d5955dfcf00..f6af9478c894f0 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/RewriteTableCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/RewriteTableCommand.java @@ -25,7 +25,7 @@ import org.apache.doris.common.ErrorReport; import org.apache.doris.common.util.DebugUtil; import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; +import org.apache.doris.datasource.ExternalTable; import org.apache.doris.nereids.CascadesContext; import org.apache.doris.nereids.NereidsPlanner; import org.apache.doris.nereids.StatementContext; @@ -41,8 +41,8 @@ import org.apache.doris.nereids.trees.plans.commands.ForwardWithSync; import org.apache.doris.nereids.trees.plans.commands.NeedAuditEncryption; import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; +import org.apache.doris.nereids.trees.plans.physical.PhysicalConnectorTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalEmptyRelation; -import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; import org.apache.doris.nereids.util.RelationUtil; @@ -185,19 +185,18 @@ private ExecutorFactory selectInsertExecutorFactory(NereidsPlanner planner, Conn DataSink dataSink = planner.getFragments().get(0).getSink(); String label = this.labelName.orElse(String.format("label_%x_%x", ctx.queryId().hi, ctx.queryId().lo)); - if (physicalSink instanceof PhysicalIcebergTableSink) { + if (physicalSink instanceof PhysicalConnectorTableSink) { + // Neutral connector rewrite path (post-cutover). The rewrite marker rides on the sink + // (UnboundConnectorTableSink.isRewrite -> PhysicalConnectorTableSink.isRewrite), not on an + // insert context, so no setRewriting() is needed here; we only route to the neutral + // executor by the sink type (no instanceof Iceberg). boolean emptyInsert = childIsEmptyRelation(physicalSink); - IcebergExternalTable icebergExternalTable = (IcebergExternalTable) targetTableIf; - IcebergInsertCommandContext icebergInsertCtx = insertCtx - .map(c -> (IcebergInsertCommandContext) c) - .orElseGet(IcebergInsertCommandContext::new); - icebergInsertCtx.setRewriting(true); - branchName.ifPresent(notUsed -> icebergInsertCtx.setBranchName(branchName)); + ExternalTable connectorTable = (ExternalTable) targetTableIf; return ExecutorFactory.from(planner, dataSink, physicalSink, - () -> new IcebergRewriteExecutor(ctx, icebergExternalTable, label, planner, - Optional.of(icebergInsertCtx), emptyInsert, jobId)); + () -> new ConnectorRewriteExecutor(ctx, connectorTable, label, planner, + insertCtx, emptyInsert, jobId)); } - throw new AnalysisException("Rewrite only supports iceberg table"); + throw new AnalysisException("Rewrite only supports iceberg and connector tables"); } catch (Throwable t) { Throwables.throwIfInstanceOf(t, RuntimeException.class); throw new IllegalStateException(t.getMessage(), t); diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/merge/MergeIntoCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/merge/MergeIntoCommand.java index e002e596c7df1d..9f6f67008fc4ef 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/merge/MergeIntoCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/merge/MergeIntoCommand.java @@ -23,7 +23,6 @@ import org.apache.doris.catalog.OlapTable; import org.apache.doris.catalog.TableIf; import org.apache.doris.catalog.Type; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; import org.apache.doris.nereids.analyzer.UnboundAlias; import org.apache.doris.nereids.analyzer.UnboundRelation; import org.apache.doris.nereids.analyzer.UnboundSlot; @@ -53,7 +52,11 @@ import org.apache.doris.nereids.trees.plans.PlanType; import org.apache.doris.nereids.trees.plans.commands.Command; import org.apache.doris.nereids.trees.plans.commands.ForwardWithSync; -import org.apache.doris.nereids.trees.plans.commands.IcebergMergeCommand; +import org.apache.doris.nereids.trees.plans.commands.RowLevelDmlArgs; +import org.apache.doris.nereids.trees.plans.commands.RowLevelDmlCommand; +import org.apache.doris.nereids.trees.plans.commands.RowLevelDmlOp; +import org.apache.doris.nereids.trees.plans.commands.RowLevelDmlRegistry; +import org.apache.doris.nereids.trees.plans.commands.RowLevelDmlTransform; import org.apache.doris.nereids.trees.plans.commands.SupportProfile; import org.apache.doris.nereids.trees.plans.commands.UpdateCommand; import org.apache.doris.nereids.trees.plans.commands.info.DMLCommandType; @@ -125,9 +128,11 @@ public MergeIntoCommand(List targetNameParts, Optional targetAli @Override public void run(ConnectContext ctx, StmtExecutor executor) throws Exception { TableIf table = getTargetTableIf(ctx); - if (table instanceof IcebergExternalTable) { - new IcebergMergeCommand(targetNameParts, targetAlias, cte, - source, onClause, matchedClauses, notMatchedClauses).run(ctx, executor); + Optional transform = RowLevelDmlRegistry.find(table); + if (transform.isPresent()) { + RowLevelDmlArgs args = RowLevelDmlArgs.forMerge(table, targetNameParts, targetAlias, cte, + source, onClause, matchedClauses, notMatchedClauses); + new RowLevelDmlCommand(transform.get(), args, RowLevelDmlOp.MERGE).run(ctx, executor); return; } new InsertIntoTableCommand(completeQueryPlan(ctx), Optional.empty(), Optional.empty(), @@ -142,9 +147,11 @@ public R accept(PlanVisitor visitor, C context) { @Override public Plan getExplainPlan(ConnectContext ctx) { TableIf table = getTargetTableIf(ctx); - if (table instanceof IcebergExternalTable) { - return new IcebergMergeCommand(targetNameParts, targetAlias, cte, - source, onClause, matchedClauses, notMatchedClauses).getExplainPlan(ctx); + Optional transform = RowLevelDmlRegistry.find(table); + if (transform.isPresent()) { + RowLevelDmlArgs args = RowLevelDmlArgs.forMerge(table, targetNameParts, targetAlias, cte, + source, onClause, matchedClauses, notMatchedClauses); + return new RowLevelDmlCommand(transform.get(), args, RowLevelDmlOp.MERGE).getExplainPlan(ctx); } return completeQueryPlan(ctx); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/merge/MergeOperation.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/merge/MergeOperation.java new file mode 100644 index 00000000000000..da73b38b2dbfd6 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/merge/MergeOperation.java @@ -0,0 +1,39 @@ +// 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.merge; + +/** + * Operation codes used for merge-style DML routing. + */ +public final class MergeOperation { + public static final String OPERATION_COLUMN = "operation"; + + // Merge sink routing: + // 1 (INSERT): only insert writer + // 2 (DELETE): only delete writer + // 3 (UPDATE): update rows (merge sink writes delete + insert) + // 4 (UPDATE_INSERT): pre-split update insert rows + // 5 (UPDATE_DELETE): pre-split update delete rows + public static final byte INSERT_OPERATION_NUMBER = 1; + public static final byte DELETE_OPERATION_NUMBER = 2; + public static final byte UPDATE_OPERATION_NUMBER = 3; + public static final byte UPDATE_INSERT_OPERATION_NUMBER = 4; + public static final byte UPDATE_DELETE_OPERATION_NUMBER = 5; + + private MergeOperation() {} +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedAllBEJob.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedAllBEJob.java index fb3e1a07526af0..c4355d76626d44 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedAllBEJob.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedAllBEJob.java @@ -19,8 +19,8 @@ import org.apache.doris.catalog.Env; import org.apache.doris.common.AnalysisException; -import org.apache.doris.datasource.ExternalScanNode; import org.apache.doris.datasource.mvcc.MvccUtil; +import org.apache.doris.datasource.scan.ExternalScanNode; import org.apache.doris.dictionary.Dictionary; import org.apache.doris.mtmv.MTMVRelatedTableIf; import org.apache.doris.mtmv.MTMVSnapshotIf; diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalConnectorTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalConnectorTableSink.java index 608f487c41780e..d1ec0d63041bb6 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalConnectorTableSink.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalConnectorTableSink.java @@ -48,6 +48,10 @@ public class LogicalConnectorTableSink extends LogicalT private final ExternalDatabase database; private final ExternalTable targetTable; private final DMLCommandType dmlCommandType; + // Rewrite (compaction) marker, carried from UnboundConnectorTableSink.isRewrite so the physical sink + // can force single-node GATHER output for a rewrite_data_files INSERT-SELECT. Part of plan identity + // (equals/hashCode) so the memo never collapses a rewrite sink onto a non-rewrite one. Defaults false. + private final boolean rewrite; /** * constructor @@ -57,6 +61,7 @@ public LogicalConnectorTableSink(ExternalDatabase database, List cols, List outputExprs, DMLCommandType dmlCommandType, + boolean rewrite, Optional groupExpression, Optional logicalProperties, CHILD_TYPE child) { @@ -64,6 +69,7 @@ public LogicalConnectorTableSink(ExternalDatabase database, this.database = Objects.requireNonNull(database, "database != null in LogicalConnectorTableSink"); this.targetTable = Objects.requireNonNull(targetTable, "targetTable != null in LogicalConnectorTableSink"); this.dmlCommandType = dmlCommandType; + this.rewrite = rewrite; } /** Update output expressions based on child output and replace child. */ @@ -73,7 +79,7 @@ public Plan withChildAndUpdateOutput(Plan child) { .collect(ImmutableList.toImmutableList()); return AbstractPlan.copyWithSameId(this, () -> new LogicalConnectorTableSink<>(database, targetTable, cols, output, - dmlCommandType, Optional.empty(), Optional.empty(), child)); + dmlCommandType, rewrite, Optional.empty(), Optional.empty(), child)); } @Override @@ -81,13 +87,13 @@ public Plan withChildren(List children) { Preconditions.checkArgument(children.size() == 1, "LogicalConnectorTableSink only accepts one child"); return AbstractPlan.copyWithSameId(this, () -> new LogicalConnectorTableSink<>(database, targetTable, cols, outputExprs, - dmlCommandType, Optional.empty(), Optional.empty(), children.get(0))); + dmlCommandType, rewrite, Optional.empty(), Optional.empty(), children.get(0))); } public LogicalConnectorTableSink withOutputExprs(List outputExprs) { return AbstractPlan.copyWithSameId(this, () -> new LogicalConnectorTableSink<>(database, targetTable, cols, outputExprs, - dmlCommandType, Optional.empty(), Optional.empty(), child())); + dmlCommandType, rewrite, Optional.empty(), Optional.empty(), child())); } public ExternalDatabase getDatabase() { @@ -102,6 +108,10 @@ public DMLCommandType getDmlCommandType() { return dmlCommandType; } + public boolean isRewrite() { + return rewrite; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -115,13 +125,14 @@ public boolean equals(Object o) { } LogicalConnectorTableSink that = (LogicalConnectorTableSink) o; return dmlCommandType == that.dmlCommandType + && rewrite == that.rewrite && Objects.equals(database, that.database) && Objects.equals(targetTable, that.targetTable) && Objects.equals(cols, that.cols); } @Override public int hashCode() { - return Objects.hash(super.hashCode(), database, targetTable, cols, dmlCommandType); + return Objects.hash(super.hashCode(), database, targetTable, cols, dmlCommandType, rewrite); } @Override @@ -131,7 +142,8 @@ public String toString() { "database", database.getFullName(), "targetTable", targetTable.getName(), "cols", cols, - "dmlCommandType", dmlCommandType + "dmlCommandType", dmlCommandType, + "rewrite", rewrite ); } @@ -144,7 +156,7 @@ public R accept(PlanVisitor visitor, C context) { public Plan withGroupExpression(Optional groupExpression) { return AbstractPlan.copyWithSameId(this, () -> new LogicalConnectorTableSink<>(database, targetTable, cols, outputExprs, - dmlCommandType, groupExpression, Optional.of(getLogicalProperties()), child())); + dmlCommandType, rewrite, groupExpression, Optional.of(getLogicalProperties()), child())); } @Override @@ -152,6 +164,6 @@ public Plan withGroupExprLogicalPropChildren(Optional groupExpr Optional logicalProperties, List children) { return AbstractPlan.copyWithSameId(this, () -> new LogicalConnectorTableSink<>(database, targetTable, cols, outputExprs, - dmlCommandType, groupExpression, logicalProperties, children.get(0))); + dmlCommandType, rewrite, groupExpression, logicalProperties, children.get(0))); } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScan.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScan.java index e70933b5b64580..db09cf2153a0fd 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScan.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScan.java @@ -22,10 +22,8 @@ import org.apache.doris.catalog.PartitionItem; import org.apache.doris.common.IdGenerator; import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergSysExternalTable; import org.apache.doris.datasource.mvcc.MvccUtil; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.nereids.memo.GroupExpression; import org.apache.doris.nereids.properties.LogicalProperties; import org.apache.doris.nereids.trees.TableSample; @@ -40,9 +38,6 @@ import org.apache.doris.nereids.trees.plans.RelationId; import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; import org.apache.doris.nereids.util.Utils; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.thrift.TFileFormatType; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; @@ -73,7 +68,10 @@ public LogicalFileScan(RelationId id, ExternalTable table, List qualifie Optional tableSample, Optional tableSnapshot, Optional scanParams, Optional> cachedOutputs) { this(id, table, qualifier, - table.initSelectedPartitions(MvccUtil.getSnapshotFromContext(table)), + // This reference's OWN version, not the ambient one: the selectors are right here as ctor + // params, and the blind lookup degrades to LATEST once the table is pinned at two versions. + table.initSelectedPartitions( + MvccUtil.getSnapshotFromContext(table, tableSnapshot, scanParams)), operativeSlots, ImmutableList.of(), tableSample, tableSnapshot, scanParams, Optional.empty(), Optional.empty(), "", @@ -203,22 +201,29 @@ public List computeOutput() { return cachedOutputs.get(); } - if (table instanceof IcebergExternalTable) { - // iceberg v3 need append row lineage columns - return computeIcebergOutput((IcebergExternalTable) table); - } else { - return super.computeOutput(); + if (table instanceof PluginDrivenExternalTable) { + // SPI-driven tables: schema is fetched via ConnectorMetadata.getTableSchema() + // (see PluginDrivenExternalTable.initSchema). Use getFullSchema() so any + // hidden/metadata columns the connector exposes are reachable. + return computePluginDrivenOutput(); } + return super.computeOutput(); } - private List computeIcebergOutput(IcebergExternalTable iceTable) { + private List computePluginDrivenOutput() { IdGenerator exprIdGenerator = StatementScopeIdGenerator.getExprIdGenerator(); Builder slots = ImmutableList.builder(); - table.getFullSchema() + // Resolve the schema AS OF THIS reference's own version. tableSnapshot/scanParams are final fields + // set in the ctor, so they are available even though computeOutput() is evaluated lazily + // (AbstractPlan.logicalPropertiesSupplier) -- and the version-aware lookup is key-exact, so the + // answer does not depend on how many versions the statement pins or on when this runs. The + // version-BLIND getFullSchema() would degrade to LATEST once this table is pinned at two versions + // (e.g. t@tag(a) JOIN t@tag(b)), binding a schema NO reference asked for and making the scan-time + // guard fire on a column the query never referenced. + getTable().getFullSchema(MvccUtil.getSnapshotFromContext(table, tableSnapshot, scanParams)) .stream() .map(col -> SlotReference.fromColumn(exprIdGenerator.getNextId(), table, col, qualified())) .forEach(slots::add); - // add virtual slots for (NamedExpression virtualColumn : virtualColumns) { slots.add(virtualColumn.toSlot()); } @@ -233,34 +238,12 @@ public List computeAsteriskOutput() { @Override public boolean supportPruneNestedColumn() { ExternalTable table = getTable(); - if (table instanceof IcebergExternalTable) { - return true; - } else if (table instanceof IcebergSysExternalTable) { - // Position deletes use the native reader, which supports nested column pruning. Other - // Iceberg system tables are materialized as StructLike rows by the SDK and consumed by - // ordinal in the JNI reader, so their nested struct layout must remain unchanged. - return ((IcebergSysExternalTable) table).isPositionDeletesTable(); - } else if (table instanceof HMSExternalTable) { - HMSExternalTable hmsTable = (HMSExternalTable) table; - if (hmsTable.getDlaType() == HMSExternalTable.DLAType.HUDI) { - // Don't prune nested column for HUDI table for now, because HUDI table - // may have some issues when pruning nested column. - return false; - } - try { - ConnectContext connectContext = ConnectContext.get(); - SessionVariable sessionVariable = connectContext.getSessionVariable(); - TFileFormatType fileFormatType = ((HMSExternalTable) table).getFileFormatType(sessionVariable); - switch (fileFormatType) { - case FORMAT_PARQUET: - case FORMAT_ORC: - return true; - default: - return false; - } - } catch (Throwable t) { - // ignore and not prune - } + if (table instanceof PluginDrivenExternalTable) { + // Post-flip plugin-driven tables (e.g. iceberg as PluginDrivenMvccExternalTable) declare + // nested-column prune via ConnectorCapability; the legacy exact-class IcebergExternalTable arm + // below is dead for them. Only enabled when the connector also carries nested field ids (see + // SUPPORTS_NESTED_COLUMN_PRUNE / SlotTypeReplacer), else nested leaves would read NULL. + return ((PluginDrivenExternalTable) table).supportsNestedColumnPrune(); } return false; } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalHiveTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalHiveTableSink.java deleted file mode 100644 index 507549e7c2a4d8..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalHiveTableSink.java +++ /dev/null @@ -1,157 +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.logical; - -import org.apache.doris.catalog.Column; -import org.apache.doris.datasource.hive.HMSExternalDatabase; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.nereids.memo.GroupExpression; -import org.apache.doris.nereids.properties.LogicalProperties; -import org.apache.doris.nereids.trees.expressions.NamedExpression; -import org.apache.doris.nereids.trees.plans.AbstractPlan; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.PlanType; -import org.apache.doris.nereids.trees.plans.PropagateFuncDeps; -import org.apache.doris.nereids.trees.plans.algebra.Sink; -import org.apache.doris.nereids.trees.plans.commands.info.DMLCommandType; -import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; -import org.apache.doris.nereids.util.Utils; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; - -import java.util.List; -import java.util.Objects; -import java.util.Optional; - -/** - * logical hive table sink for insert command - */ -public class LogicalHiveTableSink extends LogicalTableSink - implements Sink, PropagateFuncDeps { - // bound data sink - private final HMSExternalDatabase database; - private final HMSExternalTable targetTable; - private final DMLCommandType dmlCommandType; - - /** - * constructor - */ - public LogicalHiveTableSink(HMSExternalDatabase database, - HMSExternalTable targetTable, - List cols, - List outputExprs, - DMLCommandType dmlCommandType, - Optional groupExpression, - Optional logicalProperties, - CHILD_TYPE child) { - super(PlanType.LOGICAL_HIVE_TABLE_SINK, outputExprs, groupExpression, logicalProperties, cols, child); - this.database = Objects.requireNonNull(database, "database != null in LogicalHiveTableSink"); - this.targetTable = Objects.requireNonNull(targetTable, "targetTable != null in LogicalHiveTableSink"); - this.dmlCommandType = dmlCommandType; - } - - /** Update output expressions based on child output and replace child. */ - public Plan withChildAndUpdateOutput(Plan child) { - List output = child.getOutput().stream() - .map(NamedExpression.class::cast) - .collect(ImmutableList.toImmutableList()); - return AbstractPlan.copyWithSameId(this, () -> - new LogicalHiveTableSink<>(database, targetTable, cols, output, - dmlCommandType, Optional.empty(), Optional.empty(), child)); - } - - @Override - public Plan withChildren(List children) { - Preconditions.checkArgument(children.size() == 1, "LogicalHiveTableSink only accepts one child"); - return AbstractPlan.copyWithSameId(this, () -> - new LogicalHiveTableSink<>(database, targetTable, cols, outputExprs, - dmlCommandType, Optional.empty(), Optional.empty(), children.get(0))); - } - - public LogicalHiveTableSink withOutputExprs(List outputExprs) { - return AbstractPlan.copyWithSameId(this, () -> - new LogicalHiveTableSink<>(database, targetTable, cols, outputExprs, - dmlCommandType, Optional.empty(), Optional.empty(), child())); - } - - public HMSExternalDatabase getDatabase() { - return database; - } - - public HMSExternalTable getTargetTable() { - return targetTable; - } - - public DMLCommandType getDmlCommandType() { - return dmlCommandType; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - if (!super.equals(o)) { - return false; - } - LogicalHiveTableSink that = (LogicalHiveTableSink) o; - return dmlCommandType == that.dmlCommandType - && Objects.equals(database, that.database) - && Objects.equals(targetTable, that.targetTable) && Objects.equals(cols, that.cols); - } - - @Override - public int hashCode() { - return Objects.hash(super.hashCode(), database, targetTable, cols, dmlCommandType); - } - - @Override - public String toString() { - return Utils.toSqlString("LogicalHiveTableSink[" + id.asInt() + "]", - "outputExprs", outputExprs, - "database", database.getFullName(), - "targetTable", targetTable.getName(), - "cols", cols, - "dmlCommandType", dmlCommandType - ); - } - - @Override - public R accept(PlanVisitor visitor, C context) { - return visitor.visitLogicalHiveTableSink(this, context); - } - - @Override - public Plan withGroupExpression(Optional groupExpression) { - return AbstractPlan.copyWithSameId(this, () -> - new LogicalHiveTableSink<>(database, targetTable, cols, outputExprs, - dmlCommandType, groupExpression, Optional.of(getLogicalProperties()), child())); - } - - @Override - public Plan withGroupExprLogicalPropChildren(Optional groupExpression, - Optional logicalProperties, List children) { - return AbstractPlan.copyWithSameId(this, () -> - new LogicalHiveTableSink<>(database, targetTable, cols, outputExprs, - dmlCommandType, groupExpression, logicalProperties, children.get(0))); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalHudiScan.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalHudiScan.java deleted file mode 100644 index 5ff0f93744460b..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalHudiScan.java +++ /dev/null @@ -1,282 +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.logical; - -import org.apache.doris.analysis.TableScanParams; -import org.apache.doris.analysis.TableSnapshot; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HiveMetaStoreClientHelper; -import org.apache.doris.datasource.hudi.source.COWIncrementalRelation; -import org.apache.doris.datasource.hudi.source.EmptyIncrementalRelation; -import org.apache.doris.datasource.hudi.source.IncrementalRelation; -import org.apache.doris.datasource.hudi.source.MORIncrementalRelation; -import org.apache.doris.nereids.exceptions.AnalysisException; -import org.apache.doris.nereids.memo.GroupExpression; -import org.apache.doris.nereids.properties.LogicalProperties; -import org.apache.doris.nereids.trees.TableSample; -import org.apache.doris.nereids.trees.expressions.ComparisonPredicate; -import org.apache.doris.nereids.trees.expressions.Expression; -import org.apache.doris.nereids.trees.expressions.GreaterThan; -import org.apache.doris.nereids.trees.expressions.LessThanEqual; -import org.apache.doris.nereids.trees.expressions.NamedExpression; -import org.apache.doris.nereids.trees.expressions.Slot; -import org.apache.doris.nereids.trees.expressions.SlotReference; -import org.apache.doris.nereids.trees.expressions.literal.StringLiteral; -import org.apache.doris.nereids.trees.plans.AbstractPlan; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.RelationId; -import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; -import org.apache.doris.nereids.util.Utils; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; -import org.apache.hudi.common.table.HoodieTableMetaClient; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; - -/** - * Logical Hudi scan for Hudi table - */ -public class LogicalHudiScan extends LogicalFileScan { - private static final Logger LOG = LogManager.getLogger(LogicalHudiScan.class); - - // for hudi incremental read - private final Optional incrementalRelation; - - /** - * Constructor for LogicalHudiScan. - */ - protected LogicalHudiScan(RelationId id, ExternalTable table, List qualifier, - SelectedPartitions selectedPartitions, Optional tableSample, - Optional tableSnapshot, - Optional scanParams, Optional incrementalRelation, - Collection operativeSlots, - List virtualColumns, - Optional groupExpression, - Optional logicalProperties, - String tableAlias, - Optional> cachedOutputs) { - super(id, table, qualifier, selectedPartitions, operativeSlots, virtualColumns, - tableSample, tableSnapshot, scanParams, groupExpression, logicalProperties, tableAlias, cachedOutputs); - Objects.requireNonNull(scanParams, "scanParams should not null"); - Objects.requireNonNull(incrementalRelation, "incrementalRelation should not null"); - this.incrementalRelation = incrementalRelation; - } - - /** - * Constructor for LogicalHudiScan (backward compatibility without tableAlias). - */ - protected LogicalHudiScan(RelationId id, ExternalTable table, List qualifier, - SelectedPartitions selectedPartitions, Optional tableSample, - Optional tableSnapshot, - Optional scanParams, Optional incrementalRelation, - Collection operativeSlots, - List virtualColumns, - Optional groupExpression, - Optional logicalProperties, - Optional> cachedOutputs) { - this(id, table, qualifier, selectedPartitions, tableSample, tableSnapshot, scanParams, incrementalRelation, - operativeSlots, virtualColumns, groupExpression, logicalProperties, "", cachedOutputs); - } - - public LogicalHudiScan(RelationId id, ExternalTable table, List qualifier, - Collection operativeSlots, Optional scanParams, - Optional tableSample, Optional tableSnapshot, - Optional> cachedOutputs) { - this(id, table, qualifier, ((HMSExternalTable) table).initHudiSelectedPartitions(tableSnapshot), - tableSample, tableSnapshot, scanParams, Optional.empty(), operativeSlots, ImmutableList.of(), - Optional.empty(), Optional.empty(), cachedOutputs); - } - - public Optional getScanParams() { - return scanParams; - } - - public Optional getIncrementalRelation() { - return incrementalRelation; - } - - /** - * replace incremental params as AND expression - * incr('beginTime'='20240308110257169', 'endTime'='20240308110677278') => - * _hoodie_commit_time > 20240308110257169 and _hoodie_commit_time <= '20240308110677278' - */ - public Set generateIncrementalExpression(List slots) { - if (!incrementalRelation.isPresent()) { - return Collections.emptySet(); - } - SlotReference timeField = null; - for (Slot slot : slots) { - if ("_hoodie_commit_time".equals(slot.getName())) { - timeField = (SlotReference) slot; - break; - } - } - if (timeField == null) { - return Collections.emptySet(); - } - StringLiteral upperValue = new StringLiteral(incrementalRelation.get().getEndTs()); - StringLiteral lowerValue = new StringLiteral(incrementalRelation.get().getStartTs()); - ComparisonPredicate less = new LessThanEqual(timeField, upperValue); - ComparisonPredicate great = new GreaterThan(timeField, lowerValue); - return ImmutableSet.of(great, less); - } - - @Override - public String toString() { - return Utils.toSqlStringSkipNull("LogicalHudiScan", - "qualified", qualifiedName(), - "output", getOutput(), - "stats", statistics - ); - } - - @Override - public LogicalHudiScan withGroupExpression(Optional groupExpression) { - return AbstractPlan.copyWithSameId(this, () -> - new LogicalHudiScan(relationId, (ExternalTable) table, qualifier, - selectedPartitions, tableSample, tableSnapshot, scanParams, incrementalRelation, - operativeSlots, virtualColumns, groupExpression, Optional.of(getLogicalProperties()), - tableAlias, cachedOutputs)); - } - - @Override - public Plan withGroupExprLogicalPropChildren(Optional groupExpression, - Optional logicalProperties, List children) { - return AbstractPlan.copyWithSameId(this, () -> - new LogicalHudiScan(relationId, (ExternalTable) table, qualifier, - selectedPartitions, tableSample, tableSnapshot, scanParams, incrementalRelation, - operativeSlots, virtualColumns, groupExpression, logicalProperties, - tableAlias, cachedOutputs)); - } - - public LogicalHudiScan withSelectedPartitions(SelectedPartitions selectedPartitions) { - return AbstractPlan.copyWithSameId(this, () -> - new LogicalHudiScan(relationId, (ExternalTable) table, qualifier, - selectedPartitions, tableSample, tableSnapshot, scanParams, incrementalRelation, - operativeSlots, virtualColumns, groupExpression, Optional.of(getLogicalProperties()), - tableAlias, cachedOutputs)); - } - - @Override - public LogicalHudiScan withRelationId(RelationId relationId) { - return AbstractPlan.copyWithSameId(this, () -> - new LogicalHudiScan(relationId, (ExternalTable) table, qualifier, - selectedPartitions, tableSample, tableSnapshot, scanParams, incrementalRelation, - operativeSlots, virtualColumns, groupExpression, Optional.empty(), - tableAlias, cachedOutputs)); - } - - @Override - public R accept(PlanVisitor visitor, C context) { - return visitor.visitLogicalHudiScan(this, context); - } - - @Override - public LogicalHudiScan withOperativeSlots(Collection operativeSlots) { - return AbstractPlan.copyWithSameId(this, () -> - new LogicalHudiScan(relationId, (ExternalTable) table, qualifier, - selectedPartitions, tableSample, tableSnapshot, scanParams, incrementalRelation, - operativeSlots, virtualColumns, groupExpression, Optional.of(getLogicalProperties()), - tableAlias, cachedOutputs)); - } - - @Override - public LogicalHudiScan withTableAlias(String tableAlias) { - return AbstractPlan.copyWithSameId(this, () -> - new LogicalHudiScan(relationId, (ExternalTable) table, qualifier, - selectedPartitions, tableSample, tableSnapshot, scanParams, incrementalRelation, - operativeSlots, virtualColumns, Optional.empty(), Optional.of(getLogicalProperties()), - tableAlias, cachedOutputs)); - } - - @Override - public LogicalHudiScan withCachedOutput(List cachedOutputs) { - return AbstractPlan.copyWithSameId(this, () -> - new LogicalHudiScan(relationId, (ExternalTable) table, qualifier, - selectedPartitions, tableSample, tableSnapshot, scanParams, incrementalRelation, - operativeSlots, virtualColumns, groupExpression, Optional.empty(), - tableAlias, Optional.of(cachedOutputs))); - } - - /** - * Set scan params for incremental read - * - * @param table should be hudi table - */ - public LogicalHudiScan withScanParams(HMSExternalTable table, Optional optScanParams) { - Optional newIncrementalRelation = Optional.empty(); - if (optScanParams.isPresent() && optScanParams.get().incrementalRead()) { - TableScanParams scanParams = optScanParams.get(); - // Clone the getBackendStorageProperties, because we need to modify it for the incremental read - Map optParams = new HashMap<>(table.getBackendStorageProperties()); - if (scanParams.getMapParams().containsKey("beginTime")) { - optParams.put("hoodie.datasource.read.begin.instanttime", scanParams.getMapParams().get("beginTime")); - } - if (scanParams.getMapParams().containsKey("endTime")) { - optParams.put("hoodie.datasource.read.end.instanttime", scanParams.getMapParams().get("endTime")); - } - scanParams.getMapParams().forEach((k, v) -> { - if (k.startsWith("hoodie.")) { - optParams.put(k, v); - } - }); - HoodieTableMetaClient hudiClient = table.getHudiClient(); - try { - boolean isCowOrRoTable = table.isHoodieCowTable(); - if (isCowOrRoTable) { - Map serd = table.getRemoteTable().getSd().getSerdeInfo().getParameters(); - if ("true".equals(serd.get("hoodie.query.as.ro.table")) - && table.getRemoteTable().getTableName().endsWith("_ro")) { - // Incremental read RO table as RT table, I don't know why? - isCowOrRoTable = false; - LOG.warn("Execute incremental read on RO table: {}", table.getFullQualifiers()); - } - } - if (hudiClient.getCommitsTimeline().filterCompletedInstants().countInstants() == 0) { - newIncrementalRelation = Optional.of(new EmptyIncrementalRelation(optParams)); - } else if (isCowOrRoTable) { - newIncrementalRelation = Optional.of(new COWIncrementalRelation( - optParams, HiveMetaStoreClientHelper.getConfiguration(table), hudiClient)); - } else { - newIncrementalRelation = Optional.of(new MORIncrementalRelation( - optParams, HiveMetaStoreClientHelper.getConfiguration(table), hudiClient)); - } - } catch (Exception e) { - throw new AnalysisException( - "Failed to create incremental relation for table: " + table.getFullQualifiers(), e); - } - } - Optional finalIncrementalRelation = newIncrementalRelation; - return AbstractPlan.copyWithSameId(this, () -> - new LogicalHudiScan(relationId, (ExternalTable) table, qualifier, - selectedPartitions, tableSample, tableSnapshot, scanParams, finalIncrementalRelation, - operativeSlots, virtualColumns, groupExpression, Optional.of(getLogicalProperties()), - tableAlias, cachedOutputs)); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalIcebergDeleteSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalIcebergDeleteSink.java index 17904c330dc592..71208acd5f6453 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalIcebergDeleteSink.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalIcebergDeleteSink.java @@ -18,8 +18,8 @@ package org.apache.doris.nereids.trees.plans.logical; import org.apache.doris.catalog.Column; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.ExternalTable; import org.apache.doris.nereids.memo.GroupExpression; import org.apache.doris.nereids.properties.LogicalProperties; import org.apache.doris.nereids.trees.expressions.NamedExpression; @@ -44,15 +44,21 @@ */ public class LogicalIcebergDeleteSink extends LogicalTableSink implements Sink, PropagateFuncDeps { - private final IcebergExternalDatabase database; - private final IcebergExternalTable targetTable; + private final ExternalDatabase database; + private final ExternalTable targetTable; private final DeleteCommandContext deleteContext; /** - * Constructor + * Constructor. + * + *

    {@code database}/{@code targetTable} are typed to the generic {@link ExternalDatabase}/ + * {@link ExternalTable} (not the concrete iceberg types): pre-flip the synthesis passes the legacy + * {@code IcebergExternalTable}, post-flip it passes a {@code PluginDrivenExternalTable} for the same + * iceberg table. Every consumer ({@code ExplainCommand}, the implementation rule, the translator) only + * uses the generic {@code getId()}/schema API, so the widening is byte-identical pre-flip.

    */ - public LogicalIcebergDeleteSink(IcebergExternalDatabase database, - IcebergExternalTable targetTable, + public LogicalIcebergDeleteSink(ExternalDatabase database, + ExternalTable targetTable, List cols, List outputExprs, DeleteCommandContext deleteContext, @@ -85,11 +91,11 @@ public LogicalIcebergDeleteSink withOutputExprs(List extends LogicalTableSink implements Sink, PropagateFuncDeps { - private final IcebergExternalDatabase database; - private final IcebergExternalTable targetTable; + private final ExternalDatabase database; + private final ExternalTable targetTable; private final DeleteCommandContext deleteContext; /** - * Constructor + * Constructor. + * + *

    {@code database}/{@code targetTable} are typed to the generic {@link ExternalDatabase}/ + * {@link ExternalTable} (not the concrete iceberg types): pre-flip the synthesis passes the legacy + * {@code IcebergExternalTable}, post-flip it passes a {@code PluginDrivenExternalTable} for the same + * iceberg table. Every consumer ({@code ExplainCommand}, the implementation rule, the translator) only + * uses the generic {@code getId()}/schema API, so the widening is byte-identical pre-flip.

    */ - public LogicalIcebergMergeSink(IcebergExternalDatabase database, - IcebergExternalTable targetTable, + public LogicalIcebergMergeSink(ExternalDatabase database, + ExternalTable targetTable, List cols, List outputExprs, DeleteCommandContext deleteContext, @@ -85,11 +91,11 @@ public LogicalIcebergMergeSink withOutputExprs(List deleteContext, Optional.empty(), Optional.empty(), child()); } - public IcebergExternalDatabase getDatabase() { + public ExternalDatabase getDatabase() { return database; } - public IcebergExternalTable getTargetTable() { + public ExternalTable getTargetTable() { return targetTable; } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalIcebergTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalIcebergTableSink.java deleted file mode 100644 index b229f4c4cb3fd4..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalIcebergTableSink.java +++ /dev/null @@ -1,157 +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.logical; - -import org.apache.doris.catalog.Column; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.nereids.memo.GroupExpression; -import org.apache.doris.nereids.properties.LogicalProperties; -import org.apache.doris.nereids.trees.expressions.NamedExpression; -import org.apache.doris.nereids.trees.plans.AbstractPlan; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.PlanType; -import org.apache.doris.nereids.trees.plans.PropagateFuncDeps; -import org.apache.doris.nereids.trees.plans.algebra.Sink; -import org.apache.doris.nereids.trees.plans.commands.info.DMLCommandType; -import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; -import org.apache.doris.nereids.util.Utils; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; - -import java.util.List; -import java.util.Objects; -import java.util.Optional; - -/** - * logical iceberg table sink for insert command - */ -public class LogicalIcebergTableSink extends LogicalTableSink - implements Sink, PropagateFuncDeps { - // bound data sink - private final IcebergExternalDatabase database; - private final IcebergExternalTable targetTable; - private final DMLCommandType dmlCommandType; - - /** - * constructor - */ - public LogicalIcebergTableSink(IcebergExternalDatabase database, - IcebergExternalTable targetTable, - List cols, - List outputExprs, - DMLCommandType dmlCommandType, - Optional groupExpression, - Optional logicalProperties, - CHILD_TYPE child) { - super(PlanType.LOGICAL_ICEBERG_TABLE_SINK, outputExprs, groupExpression, logicalProperties, cols, child); - this.database = Objects.requireNonNull(database, "database != null in LogicalIcebergTableSink"); - this.targetTable = Objects.requireNonNull(targetTable, "targetTable != null in LogicalIcebergTableSink"); - this.dmlCommandType = dmlCommandType; - } - - /** Update output expressions based on child output and replace child. */ - public Plan withChildAndUpdateOutput(Plan child) { - List output = child.getOutput().stream() - .map(NamedExpression.class::cast) - .collect(ImmutableList.toImmutableList()); - return AbstractPlan.copyWithSameId(this, () -> - new LogicalIcebergTableSink<>(database, targetTable, cols, output, - dmlCommandType, Optional.empty(), Optional.empty(), child)); - } - - @Override - public Plan withChildren(List children) { - Preconditions.checkArgument(children.size() == 1, "LogicalIcebergTableSink only accepts one child"); - return AbstractPlan.copyWithSameId(this, () -> - new LogicalIcebergTableSink<>(database, targetTable, cols, outputExprs, - dmlCommandType, Optional.empty(), Optional.empty(), children.get(0))); - } - - public LogicalIcebergTableSink withOutputExprs(List outputExprs) { - return AbstractPlan.copyWithSameId(this, () -> - new LogicalIcebergTableSink<>(database, targetTable, cols, outputExprs, - dmlCommandType, Optional.empty(), Optional.empty(), child())); - } - - public IcebergExternalDatabase getDatabase() { - return database; - } - - public IcebergExternalTable getTargetTable() { - return targetTable; - } - - public DMLCommandType getDmlCommandType() { - return dmlCommandType; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - if (!super.equals(o)) { - return false; - } - LogicalIcebergTableSink that = (LogicalIcebergTableSink) o; - return dmlCommandType == that.dmlCommandType - && Objects.equals(database, that.database) - && Objects.equals(targetTable, that.targetTable) && Objects.equals(cols, that.cols); - } - - @Override - public int hashCode() { - return Objects.hash(super.hashCode(), database, targetTable, cols, dmlCommandType); - } - - @Override - public String toString() { - return Utils.toSqlString("LogicalIcebergTableSink[" + id.asInt() + "]", - "outputExprs", outputExprs, - "database", database.getFullName(), - "targetTable", targetTable.getName(), - "cols", cols, - "dmlCommandType", dmlCommandType - ); - } - - @Override - public R accept(PlanVisitor visitor, C context) { - return visitor.visitLogicalIcebergTableSink(this, context); - } - - @Override - public Plan withGroupExpression(Optional groupExpression) { - return AbstractPlan.copyWithSameId(this, () -> - new LogicalIcebergTableSink<>(database, targetTable, cols, outputExprs, - dmlCommandType, groupExpression, Optional.of(getLogicalProperties()), child())); - } - - @Override - public Plan withGroupExprLogicalPropChildren(Optional groupExpression, - Optional logicalProperties, List children) { - return AbstractPlan.copyWithSameId(this, () -> - new LogicalIcebergTableSink<>(database, targetTable, cols, outputExprs, - dmlCommandType, groupExpression, logicalProperties, children.get(0))); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalMaxComputeTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalMaxComputeTableSink.java deleted file mode 100644 index 8514fcc885c60d..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalMaxComputeTableSink.java +++ /dev/null @@ -1,156 +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.logical; - -import org.apache.doris.catalog.Column; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalDatabase; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalTable; -import org.apache.doris.nereids.memo.GroupExpression; -import org.apache.doris.nereids.properties.LogicalProperties; -import org.apache.doris.nereids.trees.expressions.NamedExpression; -import org.apache.doris.nereids.trees.plans.AbstractPlan; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.PlanType; -import org.apache.doris.nereids.trees.plans.PropagateFuncDeps; -import org.apache.doris.nereids.trees.plans.algebra.Sink; -import org.apache.doris.nereids.trees.plans.commands.info.DMLCommandType; -import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; -import org.apache.doris.nereids.util.Utils; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; - -import java.util.List; -import java.util.Objects; -import java.util.Optional; - -/** - * logical maxcompute table sink for insert command - */ -public class LogicalMaxComputeTableSink extends LogicalTableSink - implements Sink, PropagateFuncDeps { - private final MaxComputeExternalDatabase database; - private final MaxComputeExternalTable targetTable; - private final DMLCommandType dmlCommandType; - - /** - * constructor - */ - public LogicalMaxComputeTableSink(MaxComputeExternalDatabase database, - MaxComputeExternalTable targetTable, - List cols, - List outputExprs, - DMLCommandType dmlCommandType, - Optional groupExpression, - Optional logicalProperties, - CHILD_TYPE child) { - super(PlanType.LOGICAL_MAX_COMPUTE_TABLE_SINK, outputExprs, groupExpression, logicalProperties, cols, child); - this.database = Objects.requireNonNull(database, "database != null in LogicalMaxComputeTableSink"); - this.targetTable = Objects.requireNonNull(targetTable, "targetTable != null in LogicalMaxComputeTableSink"); - this.dmlCommandType = dmlCommandType; - } - - /** Update output expressions based on child output and replace child. */ - public Plan withChildAndUpdateOutput(Plan child) { - List output = child.getOutput().stream() - .map(NamedExpression.class::cast) - .collect(ImmutableList.toImmutableList()); - return AbstractPlan.copyWithSameId(this, () -> - new LogicalMaxComputeTableSink<>(database, targetTable, cols, output, - dmlCommandType, Optional.empty(), Optional.empty(), child)); - } - - @Override - public Plan withChildren(List children) { - Preconditions.checkArgument(children.size() == 1, "LogicalMaxComputeTableSink only accepts one child"); - return AbstractPlan.copyWithSameId(this, () -> - new LogicalMaxComputeTableSink<>(database, targetTable, cols, outputExprs, - dmlCommandType, Optional.empty(), Optional.empty(), children.get(0))); - } - - public LogicalMaxComputeTableSink withOutputExprs(List outputExprs) { - return AbstractPlan.copyWithSameId(this, () -> - new LogicalMaxComputeTableSink<>(database, targetTable, cols, outputExprs, - dmlCommandType, Optional.empty(), Optional.empty(), child())); - } - - public MaxComputeExternalDatabase getDatabase() { - return database; - } - - public MaxComputeExternalTable getTargetTable() { - return targetTable; - } - - public DMLCommandType getDmlCommandType() { - return dmlCommandType; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - if (!super.equals(o)) { - return false; - } - LogicalMaxComputeTableSink that = (LogicalMaxComputeTableSink) o; - return dmlCommandType == that.dmlCommandType - && Objects.equals(database, that.database) - && Objects.equals(targetTable, that.targetTable) && Objects.equals(cols, that.cols); - } - - @Override - public int hashCode() { - return Objects.hash(super.hashCode(), database, targetTable, cols, dmlCommandType); - } - - @Override - public String toString() { - return Utils.toSqlString("LogicalMaxComputeTableSink[" + id.asInt() + "]", - "outputExprs", outputExprs, - "database", database.getFullName(), - "targetTable", targetTable.getName(), - "cols", cols, - "dmlCommandType", dmlCommandType - ); - } - - @Override - public R accept(PlanVisitor visitor, C context) { - return visitor.visitLogicalMaxComputeTableSink(this, context); - } - - @Override - public Plan withGroupExpression(Optional groupExpression) { - return AbstractPlan.copyWithSameId(this, () -> - new LogicalMaxComputeTableSink<>(database, targetTable, cols, outputExprs, - dmlCommandType, groupExpression, Optional.of(getLogicalProperties()), child())); - } - - @Override - public Plan withGroupExprLogicalPropChildren(Optional groupExpression, - Optional logicalProperties, List children) { - return AbstractPlan.copyWithSameId(this, () -> - new LogicalMaxComputeTableSink<>(database, targetTable, cols, outputExprs, - dmlCommandType, groupExpression, logicalProperties, children.get(0))); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalConnectorTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalConnectorTableSink.java index 8d06c773dba014..277c6986274bf5 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalConnectorTableSink.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalConnectorTableSink.java @@ -20,10 +20,14 @@ import org.apache.doris.catalog.Column; import org.apache.doris.datasource.ExternalDatabase; import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.nereids.memo.GroupExpression; +import org.apache.doris.nereids.properties.DistributionSpecHiveTableSinkHashPartitioned; import org.apache.doris.nereids.properties.LogicalProperties; +import org.apache.doris.nereids.properties.MustLocalSortOrderSpec; +import org.apache.doris.nereids.properties.OrderKey; import org.apache.doris.nereids.properties.PhysicalProperties; +import org.apache.doris.nereids.trees.expressions.ExprId; import org.apache.doris.nereids.trees.expressions.NamedExpression; import org.apache.doris.nereids.trees.plans.AbstractPlan; import org.apache.doris.nereids.trees.plans.Plan; @@ -31,14 +35,24 @@ import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; import org.apache.doris.statistics.Statistics; +import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; /** * Physical table sink for plugin-driven connector catalogs. */ public class PhysicalConnectorTableSink extends PhysicalBaseExternalTableSink { + // Rewrite (compaction) marker, threaded from LogicalConnectorTableSink.isRewrite. When set, + // getRequirePhysicalProperties() short-circuits to GATHER (single writer) so a rewrite_data_files + // INSERT-SELECT controls its output file count even on a partitioned table — the override must win + // over the partition-shuffle / parallel-write arms below. Carried as a sink field (no ConnectContext, + // no instanceof Iceberg). Defaults false → behavior is byte-identical for ordinary connector writes. + private final boolean isRewrite; + /** * constructor */ @@ -48,9 +62,10 @@ public PhysicalConnectorTableSink(ExternalDatabase database, List outputExprs, Optional groupExpression, LogicalProperties logicalProperties, + boolean isRewrite, CHILD_TYPE child) { this(database, targetTable, cols, outputExprs, groupExpression, logicalProperties, - PhysicalProperties.GATHER, null, child); + PhysicalProperties.GATHER, null, isRewrite, child); } /** @@ -64,16 +79,18 @@ public PhysicalConnectorTableSink(ExternalDatabase database, LogicalProperties logicalProperties, PhysicalProperties physicalProperties, Statistics statistics, + boolean isRewrite, CHILD_TYPE child) { super(PlanType.PHYSICAL_CONNECTOR_TABLE_SINK, database, targetTable, cols, outputExprs, groupExpression, logicalProperties, physicalProperties, statistics, child); + this.isRewrite = isRewrite; } @Override public Plan withChildren(List children) { return AbstractPlan.copyWithSameId(this, () -> new PhysicalConnectorTableSink<>( (ExternalDatabase) database, (ExternalTable) targetTable, cols, outputExprs, groupExpression, - getLogicalProperties(), physicalProperties, statistics, children.get(0))); + getLogicalProperties(), physicalProperties, statistics, isRewrite, children.get(0))); } @Override @@ -85,7 +102,7 @@ public R accept(PlanVisitor visitor, C context) { public Plan withGroupExpression(Optional groupExpression) { return AbstractPlan.copyWithSameId(this, () -> new PhysicalConnectorTableSink<>( (ExternalDatabase) database, (ExternalTable) targetTable, cols, outputExprs, - groupExpression, getLogicalProperties(), child())); + groupExpression, getLogicalProperties(), isRewrite, child())); } @Override @@ -93,28 +110,143 @@ public Plan withGroupExprLogicalPropChildren(Optional groupExpr Optional logicalProperties, List children) { return AbstractPlan.copyWithSameId(this, () -> new PhysicalConnectorTableSink<>( (ExternalDatabase) database, (ExternalTable) targetTable, cols, outputExprs, - groupExpression, logicalProperties.get(), children.get(0))); + groupExpression, logicalProperties.get(), isRewrite, children.get(0))); } @Override public PhysicalPlan withPhysicalPropertiesAndStats(PhysicalProperties physicalProperties, Statistics statistics) { return AbstractPlan.copyWithSameId(this, () -> new PhysicalConnectorTableSink<>( (ExternalDatabase) database, (ExternalTable) targetTable, cols, outputExprs, - groupExpression, getLogicalProperties(), physicalProperties, statistics, child())); + groupExpression, getLogicalProperties(), physicalProperties, statistics, isRewrite, child())); } /** - * Get required physical properties for sink distribution. + * Whether this sink is a distributed {@code rewrite_data_files} (compaction) write. The neutral + * translator threads {@code WriteOperation.REWRITE} onto the connector write handle when set, and + * {@link #getRequirePhysicalProperties} short-circuits to GATHER. + */ + public boolean isRewrite() { + return isRewrite; + } + + /** + * Get required physical properties for sink distribution. Generalizes the legacy + * {@code PhysicalMaxComputeTableSink.getRequirePhysicalProperties()} 3-branch behavior, gated + * by connector capabilities so non-partitioned connectors (JDBC, ES) keep the GATHER default: + * + *
      + *
    • Dynamic-partition write (a partition column is present in {@code cols}) when the + * connector's write provider returns {@code true} from {@code requiresPartitionLocalSort()}: + * hash-distribute by the partition columns and require a mandatory local sort on them. + * Streaming partition writers (MaxCompute Storage API) close the previous partition writer + * once a different partition value appears; un-grouped rows cause "writer has been closed".
    • + *
    • Non-partitioned / all-static-partition write when the connector's write provider + * returns {@code true} from {@code requiresParallelWrite()}: {@code SINK_RANDOM_PARTITIONED} + * (parallel writers).
    • + *
    • Otherwise (e.g. JDBC, ES): {@code GATHER} (single writer) for transactional + * safety.
    • + *
    * - *

    Connectors that declare {@code SUPPORTS_PARALLEL_WRITE} capability - * (e.g., Hive, Iceberg) use random partitioned distribution for parallel writers. - * All other connectors (e.g., JDBC, ES) default to GATHER (single writer) - * for transactional safety.

    + *

    Index by full schema, not {@code cols}. For a positional-write connector (one whose write + * provider returns {@code true} from {@code requiresFullSchemaWriteOrder()}, e.g. MaxCompute), + * {@code BindSink.bindConnectorTableSink} projects the child to full-schema order (any + * unmentioned / static-partition columns filled in), exactly like legacy {@code bindMaxComputeTableSink}, + * because the BE writer strips the trailing partition columns by position. So {@code child().getOutput()} + * is aligned 1:1 with {@code targetTable.getFullSchema()}, while {@code cols} excludes the static + * partition columns and may be in a different (user-specified) order. Partition columns are therefore + * located by their position in the full schema. (An earlier revision indexed by {@code cols}, which + * mislocated the dynamic column whenever {@code cols} order diverged from the full schema — the + * partial-static {@code PARTITION(p1='x') SELECT ..., p2} and reordered-explicit-list cases.)

    */ @Override public PhysicalProperties getRequirePhysicalProperties() { - if (targetTable instanceof PluginDrivenExternalTable - && ((PluginDrivenExternalTable) targetTable).supportsParallelWrite()) { + // Rewrite (compaction) writes must gather to a single writer to control the output file count; + // this neutral flag wins over the partition-shuffle / parallel-write arms below. Carried as a + // sink field (no ConnectContext access, no instanceof Iceberg). + if (isRewrite) { + return PhysicalProperties.GATHER; + } + if (!(targetTable instanceof PluginDrivenExternalTable)) { + return PhysicalProperties.GATHER; + } + PluginDrivenExternalTable table = (PluginDrivenExternalTable) targetTable; + + if (table.requirePartitionLocalSortOnWrite()) { + Set partitionNames = table.getPartitionColumns().stream() + .map(Column::getName) + .collect(Collectors.toSet()); + if (!partitionNames.isEmpty()) { + // A partition column present in cols == its value comes from the query == a + // dynamic-partition write (static partition cols are excluded from cols by + // BindSink.bindConnectorTableSink). If any remains, this is a dynamic / partial-static + // write that must be hash-distributed and locally sorted by partition columns. + Set colNames = cols.stream() + .map(Column::getName) + .collect(Collectors.toSet()); + boolean hasDynamicPartition = partitionNames.stream().anyMatch(colNames::contains); + if (hasDynamicPartition) { + // Index by FULL-SCHEMA position, NOT cols. For a static / partial-static write the + // bind layer projects the child to full schema (static partition cols filled), so + // child().getOutput() is aligned 1:1 with the full schema while cols excludes the + // static partition cols. Indexing by full-schema position is required to hash/sort + // by the correct (dynamic) column in the partial-static case. Mirrors legacy + // PhysicalMaxComputeTableSink. + List columnIdx = new ArrayList<>(); + List fullSchema = targetTable.getFullSchema(); + for (int i = 0; i < fullSchema.size(); i++) { + if (partitionNames.contains(fullSchema.get(i).getName())) { + columnIdx.add(i); + } + } + List exprIds = columnIdx.stream() + .map(idx -> child().getOutput().get(idx).getExprId()) + .collect(Collectors.toList()); + DistributionSpecHiveTableSinkHashPartitioned shuffleInfo + = new DistributionSpecHiveTableSinkHashPartitioned(); + shuffleInfo.setOutputColExprIds(exprIds); + // Local sort by partition columns so rows for the same partition are grouped + // together before the streaming partition writer (MaxCompute Storage API closes a + // partition writer once a different partition value appears). + List orderKeys = columnIdx.stream() + .map(idx -> new OrderKey(child().getOutput().get(idx), true, false)) + .collect(Collectors.toList()); + return new PhysicalProperties(shuffleInfo) + .withOrderSpec(new MustLocalSortOrderSpec(orderKeys)); + } + // Partition columns exist but none in cols == all partitions statically specified; + // fall through to the parallel/gather branch (no sort/shuffle needed). + } + } + + if (table.requirePartitionHashOnWrite()) { + Set partitionNames = table.getPartitionColumns().stream() + .map(Column::getName) + .collect(Collectors.toSet()); + if (!partitionNames.isEmpty()) { + // Hash-distribute by partition columns with NO local sort (byte-exact to legacy + // PhysicalHiveTableSink.getRequirePhysicalProperties): same partition value -> same writer + // instance keeps the output file count at ~one-per-partition, and the hive file writer buffers a + // per-partition writer so — unlike the MaxCompute arm above — no MustLocalSortOrderSpec is added. + // Index by full-schema position, which is aligned 1:1 with child output because a connector + // declaring requiresPartitionHashWrite also declares requiresFullSchemaWriteOrder. + List columnIdx = new ArrayList<>(); + List fullSchema = targetTable.getFullSchema(); + for (int i = 0; i < fullSchema.size(); i++) { + if (partitionNames.contains(fullSchema.get(i).getName())) { + columnIdx.add(i); + } + } + List exprIds = columnIdx.stream() + .map(idx -> child().getOutput().get(idx).getExprId()) + .collect(Collectors.toList()); + DistributionSpecHiveTableSinkHashPartitioned shuffleInfo + = new DistributionSpecHiveTableSinkHashPartitioned(); + shuffleInfo.setOutputColExprIds(exprIds); + return new PhysicalProperties(shuffleInfo); + } + } + + if (table.supportsParallelWrite()) { return PhysicalProperties.SINK_RANDOM_PARTITIONED; } return PhysicalProperties.GATHER; diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalHiveTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalHiveTableSink.java deleted file mode 100644 index 6df97eee5105d5..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalHiveTableSink.java +++ /dev/null @@ -1,134 +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.physical; - -import org.apache.doris.catalog.Column; -import org.apache.doris.datasource.hive.HMSExternalDatabase; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.nereids.memo.GroupExpression; -import org.apache.doris.nereids.properties.DistributionSpecHiveTableSinkHashPartitioned; -import org.apache.doris.nereids.properties.LogicalProperties; -import org.apache.doris.nereids.properties.PhysicalProperties; -import org.apache.doris.nereids.trees.expressions.ExprId; -import org.apache.doris.nereids.trees.expressions.NamedExpression; -import org.apache.doris.nereids.trees.plans.AbstractPlan; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.PlanType; -import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; -import org.apache.doris.statistics.Statistics; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -/** physical hive sink */ -public class PhysicalHiveTableSink extends PhysicalBaseExternalTableSink { - - /** - * constructor - */ - public PhysicalHiveTableSink(HMSExternalDatabase database, - HMSExternalTable targetTable, - List cols, - List outputExprs, - Optional groupExpression, - LogicalProperties logicalProperties, - CHILD_TYPE child) { - this(database, targetTable, cols, outputExprs, groupExpression, logicalProperties, - PhysicalProperties.GATHER, null, child); - } - - /** - * constructor - */ - public PhysicalHiveTableSink(HMSExternalDatabase database, - HMSExternalTable targetTable, - List cols, - List outputExprs, - Optional groupExpression, - LogicalProperties logicalProperties, - PhysicalProperties physicalProperties, - Statistics statistics, - CHILD_TYPE child) { - super(PlanType.PHYSICAL_HIVE_TABLE_SINK, database, targetTable, cols, outputExprs, groupExpression, - logicalProperties, physicalProperties, statistics, child); - } - - @Override - public Plan withChildren(List children) { - return AbstractPlan.copyWithSameId(this, () -> new PhysicalHiveTableSink<>( - (HMSExternalDatabase) database, (HMSExternalTable) targetTable, cols, outputExprs, groupExpression, - getLogicalProperties(), physicalProperties, statistics, children.get(0))); - } - - @Override - public R accept(PlanVisitor visitor, C context) { - return visitor.visitPhysicalHiveTableSink(this, context); - } - - @Override - public Plan withGroupExpression(Optional groupExpression) { - return AbstractPlan.copyWithSameId(this, () -> new PhysicalHiveTableSink<>( - (HMSExternalDatabase) database, (HMSExternalTable) targetTable, cols, outputExprs, - groupExpression, getLogicalProperties(), child())); - } - - @Override - public Plan withGroupExprLogicalPropChildren(Optional groupExpression, - Optional logicalProperties, List children) { - return AbstractPlan.copyWithSameId(this, () -> new PhysicalHiveTableSink<>( - (HMSExternalDatabase) database, (HMSExternalTable) targetTable, cols, outputExprs, - groupExpression, logicalProperties.get(), children.get(0))); - } - - @Override - public PhysicalPlan withPhysicalPropertiesAndStats(PhysicalProperties physicalProperties, Statistics statistics) { - return AbstractPlan.copyWithSameId(this, () -> new PhysicalHiveTableSink<>( - (HMSExternalDatabase) database, (HMSExternalTable) targetTable, cols, outputExprs, - groupExpression, getLogicalProperties(), physicalProperties, statistics, child())); - } - - /** - * get output physical properties - */ - @Override - public PhysicalProperties getRequirePhysicalProperties() { - Set hivePartitionKeys = ((HMSExternalTable) targetTable).getPartitionColumnNames(); - if (!hivePartitionKeys.isEmpty()) { - List columnIdx = new ArrayList<>(); - List fullSchema = targetTable.getFullSchema(); - for (int i = 0; i < fullSchema.size(); i++) { - Column column = fullSchema.get(i); - if (hivePartitionKeys.contains(column.getName())) { - columnIdx.add(i); - } - } - // mapping partition id - List exprIds = columnIdx.stream() - .map(idx -> child().getOutput().get(idx).getExprId()) - .collect(Collectors.toList()); - DistributionSpecHiveTableSinkHashPartitioned shuffleInfo - = new DistributionSpecHiveTableSinkHashPartitioned(); - shuffleInfo.setOutputColExprIds(exprIds); - return new PhysicalProperties(shuffleInfo); - } - return PhysicalProperties.SINK_RANDOM_PARTITIONED; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalHudiScan.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalHudiScan.java deleted file mode 100644 index 13eee0bf1b954d..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalHudiScan.java +++ /dev/null @@ -1,132 +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.physical; - -import org.apache.doris.analysis.TableScanParams; -import org.apache.doris.analysis.TableSnapshot; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.hudi.source.IncrementalRelation; -import org.apache.doris.nereids.memo.GroupExpression; -import org.apache.doris.nereids.properties.DistributionSpec; -import org.apache.doris.nereids.properties.LogicalProperties; -import org.apache.doris.nereids.properties.PhysicalProperties; -import org.apache.doris.nereids.trees.TableSample; -import org.apache.doris.nereids.trees.expressions.Slot; -import org.apache.doris.nereids.trees.plans.AbstractPlan; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.PlanType; -import org.apache.doris.nereids.trees.plans.RelationId; -import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan.SelectedPartitions; -import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; -import org.apache.doris.nereids.util.Utils; -import org.apache.doris.statistics.Statistics; - -import java.util.Collection; -import java.util.List; -import java.util.Objects; -import java.util.Optional; - -/** - * Physical Hudi scan for Hudi table. - */ -public class PhysicalHudiScan extends PhysicalFileScan { - - // for hudi incremental read - private final Optional incrementalRelation; - - /** - * Constructor for PhysicalHudiScan. - */ - public PhysicalHudiScan(RelationId id, ExternalTable table, List qualifier, - DistributionSpec distributionSpec, Optional groupExpression, - LogicalProperties logicalProperties, - SelectedPartitions selectedPartitions, Optional tableSample, - Optional tableSnapshot, - Optional scanParams, Optional incrementalRelation, - Collection operativeSlots) { - super(id, PlanType.PHYSICAL_HUDI_SCAN, table, qualifier, distributionSpec, groupExpression, logicalProperties, - selectedPartitions, tableSample, tableSnapshot, operativeSlots, scanParams); - Objects.requireNonNull(scanParams, "scanParams should not null"); - Objects.requireNonNull(incrementalRelation, "incrementalRelation should not null"); - this.incrementalRelation = incrementalRelation; - } - - /** - * Constructor for PhysicalHudiScan. - */ - public PhysicalHudiScan(RelationId id, ExternalTable table, List qualifier, - DistributionSpec distributionSpec, Optional groupExpression, - LogicalProperties logicalProperties, PhysicalProperties physicalProperties, - Statistics statistics, SelectedPartitions selectedPartitions, - Optional tableSample, Optional tableSnapshot, - Optional scanParams, Optional incrementalRelation, - Collection operativeSlots) { - super(id, PlanType.PHYSICAL_HUDI_SCAN, table, qualifier, distributionSpec, groupExpression, logicalProperties, - physicalProperties, statistics, selectedPartitions, tableSample, tableSnapshot, - operativeSlots, scanParams); - this.incrementalRelation = incrementalRelation; - } - - public Optional getScanParams() { - return scanParams; - } - - public Optional getIncrementalRelation() { - return incrementalRelation; - } - - @Override - public PhysicalHudiScan withGroupExpression(Optional groupExpression) { - return AbstractPlan.copyWithSameId(this, () -> new PhysicalHudiScan(relationId, getTable(), qualifier, - distributionSpec, groupExpression, getLogicalProperties(), selectedPartitions, tableSample, - tableSnapshot, scanParams, incrementalRelation, operativeSlots)); - } - - @Override - public Plan withGroupExprLogicalPropChildren(Optional groupExpression, - Optional logicalProperties, List children) { - return AbstractPlan.copyWithSameId(this, () -> new PhysicalHudiScan(relationId, getTable(), qualifier, - distributionSpec, groupExpression, logicalProperties.get(), selectedPartitions, tableSample, - tableSnapshot, scanParams, incrementalRelation, operativeSlots)); - } - - @Override - public PhysicalHudiScan withPhysicalPropertiesAndStats(PhysicalProperties physicalProperties, - Statistics statistics) { - return AbstractPlan.copyWithSameId(this, () -> new PhysicalHudiScan(relationId, getTable(), qualifier, - distributionSpec, groupExpression, getLogicalProperties(), physicalProperties, statistics, - selectedPartitions, tableSample, tableSnapshot, scanParams, incrementalRelation, operativeSlots)); - } - - @Override - public R accept(PlanVisitor visitor, C context) { - return visitor.visitPhysicalHudiScan(this, context); - } - - @Override - public String toString() { - return Utils.toSqlString("PhysicalHudiScan[" + id.asInt() + "]" + getGroupIdWithPrefix(), - "qualified", Utils.qualifiedName(qualifier, table.getName()), - "output", getOutput(), - "stats", statistics, - "selected partitions num", - selectedPartitions.isPruned ? selectedPartitions.selectedPartitions.size() : "unknown", - "isIncremental", incrementalRelation.isPresent() - ); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergDeleteSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergDeleteSink.java index 67192dbb4fb14e..2498ece0ead774 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergDeleteSink.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergDeleteSink.java @@ -18,9 +18,8 @@ package org.apache.doris.nereids.trees.plans.physical; import org.apache.doris.catalog.Column; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergMergeOperation; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.ExternalTable; import org.apache.doris.nereids.memo.GroupExpression; import org.apache.doris.nereids.properties.DistributionSpecHash.ShuffleType; import org.apache.doris.nereids.properties.DistributionSpecMerge; @@ -32,6 +31,7 @@ import org.apache.doris.nereids.trees.plans.Plan; import org.apache.doris.nereids.trees.plans.PlanType; import org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext; +import org.apache.doris.nereids.trees.plans.commands.merge.MergeOperation; import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; import org.apache.doris.statistics.Statistics; @@ -51,8 +51,8 @@ public class PhysicalIcebergDeleteSink extends Physical /** * Constructor */ - public PhysicalIcebergDeleteSink(IcebergExternalDatabase database, - IcebergExternalTable targetTable, + public PhysicalIcebergDeleteSink(ExternalDatabase database, + ExternalTable targetTable, List cols, List outputExprs, DeleteCommandContext deleteContext, @@ -66,8 +66,8 @@ public PhysicalIcebergDeleteSink(IcebergExternalDatabase database, /** * Constructor */ - public PhysicalIcebergDeleteSink(IcebergExternalDatabase database, - IcebergExternalTable targetTable, + public PhysicalIcebergDeleteSink(ExternalDatabase database, + ExternalTable targetTable, List cols, List outputExprs, DeleteCommandContext deleteContext, @@ -89,7 +89,7 @@ public DeleteCommandContext getDeleteContext() { @Override public Plan withChildren(List children) { return new PhysicalIcebergDeleteSink<>( - (IcebergExternalDatabase) database, (IcebergExternalTable) targetTable, + database, targetTable, cols, outputExprs, deleteContext, groupExpression, getLogicalProperties(), physicalProperties, statistics, children.get(0)); } @@ -102,7 +102,7 @@ public R accept(PlanVisitor visitor, C context) { @Override public Plan withGroupExpression(Optional groupExpression) { return new PhysicalIcebergDeleteSink<>( - (IcebergExternalDatabase) database, (IcebergExternalTable) targetTable, cols, outputExprs, + database, targetTable, cols, outputExprs, deleteContext, groupExpression, getLogicalProperties(), child()); } @@ -110,14 +110,14 @@ public Plan withGroupExpression(Optional groupExpression) { public Plan withGroupExprLogicalPropChildren(Optional groupExpression, Optional logicalProperties, List children) { return new PhysicalIcebergDeleteSink<>( - (IcebergExternalDatabase) database, (IcebergExternalTable) targetTable, cols, outputExprs, + database, targetTable, cols, outputExprs, deleteContext, groupExpression, logicalProperties.get(), children.get(0)); } @Override public PhysicalPlan withPhysicalPropertiesAndStats(PhysicalProperties physicalProperties, Statistics statistics) { return new PhysicalIcebergDeleteSink<>( - (IcebergExternalDatabase) database, (IcebergExternalTable) targetTable, cols, outputExprs, + database, targetTable, cols, outputExprs, deleteContext, groupExpression, getLogicalProperties(), physicalProperties, statistics, child()); } @@ -150,7 +150,7 @@ public PhysicalProperties getRequirePhysicalProperties() { ExprId operationExprId = null; for (Slot slot : child().getOutput()) { String name = slot.getName(); - if (operationExprId == null && IcebergMergeOperation.OPERATION_COLUMN.equalsIgnoreCase(name)) { + if (operationExprId == null && MergeOperation.OPERATION_COLUMN.equalsIgnoreCase(name)) { operationExprId = slot.getExprId(); } if (rowIdExprId == null && Column.ICEBERG_ROWID_COL.equalsIgnoreCase(name)) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSink.java index 0281ad23243496..3f390ec10ae0af 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSink.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSink.java @@ -18,9 +18,18 @@ package org.apache.doris.nereids.trees.plans.physical; import org.apache.doris.catalog.Column; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergMergeOperation; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.write.ConnectorWritePartitionField; +import org.apache.doris.connector.api.write.ConnectorWritePartitionSpec; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.nereids.memo.GroupExpression; import org.apache.doris.nereids.properties.DistributionSpecHash.ShuffleType; import org.apache.doris.nereids.properties.DistributionSpecMerge; @@ -32,16 +41,12 @@ import org.apache.doris.nereids.trees.plans.Plan; import org.apache.doris.nereids.trees.plans.PlanType; import org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext; +import org.apache.doris.nereids.trees.plans.commands.merge.MergeOperation; import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; import org.apache.doris.qe.ConnectContext; import org.apache.doris.statistics.Statistics; import com.google.common.collect.ImmutableList; -import org.apache.iceberg.PartitionField; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Schema; -import org.apache.iceberg.Table; -import org.apache.iceberg.types.Types; import java.util.ArrayList; import java.util.List; @@ -60,8 +65,8 @@ public class PhysicalIcebergMergeSink extends PhysicalB /** * Constructor */ - public PhysicalIcebergMergeSink(IcebergExternalDatabase database, - IcebergExternalTable targetTable, + public PhysicalIcebergMergeSink(ExternalDatabase database, + ExternalTable targetTable, List cols, List outputExprs, DeleteCommandContext deleteContext, @@ -75,8 +80,8 @@ public PhysicalIcebergMergeSink(IcebergExternalDatabase database, /** * Constructor */ - public PhysicalIcebergMergeSink(IcebergExternalDatabase database, - IcebergExternalTable targetTable, + public PhysicalIcebergMergeSink(ExternalDatabase database, + ExternalTable targetTable, List cols, List outputExprs, DeleteCommandContext deleteContext, @@ -98,7 +103,7 @@ public DeleteCommandContext getDeleteContext() { @Override public Plan withChildren(List children) { return new PhysicalIcebergMergeSink<>( - (IcebergExternalDatabase) database, (IcebergExternalTable) targetTable, + database, targetTable, cols, outputExprs, deleteContext, groupExpression, getLogicalProperties(), physicalProperties, statistics, children.get(0)); } @@ -111,7 +116,7 @@ public R accept(PlanVisitor visitor, C context) { @Override public Plan withGroupExpression(Optional groupExpression) { return new PhysicalIcebergMergeSink<>( - (IcebergExternalDatabase) database, (IcebergExternalTable) targetTable, cols, outputExprs, + database, targetTable, cols, outputExprs, deleteContext, groupExpression, getLogicalProperties(), child()); } @@ -119,14 +124,14 @@ public Plan withGroupExpression(Optional groupExpression) { public Plan withGroupExprLogicalPropChildren(Optional groupExpression, Optional logicalProperties, List children) { return new PhysicalIcebergMergeSink<>( - (IcebergExternalDatabase) database, (IcebergExternalTable) targetTable, cols, outputExprs, + database, targetTable, cols, outputExprs, deleteContext, groupExpression, logicalProperties.get(), children.get(0)); } @Override public PhysicalPlan withPhysicalPropertiesAndStats(PhysicalProperties physicalProperties, Statistics statistics) { return new PhysicalIcebergMergeSink<>( - (IcebergExternalDatabase) database, (IcebergExternalTable) targetTable, cols, outputExprs, + database, targetTable, cols, outputExprs, deleteContext, groupExpression, getLogicalProperties(), physicalProperties, statistics, child()); } @@ -161,7 +166,7 @@ public PhysicalProperties getRequirePhysicalProperties() { List outputSlots = child().getOutput(); for (Slot slot : outputSlots) { String name = slot.getName(); - if (operationExprId == null && IcebergMergeOperation.OPERATION_COLUMN.equalsIgnoreCase(name)) { + if (operationExprId == null && MergeOperation.OPERATION_COLUMN.equalsIgnoreCase(name)) { operationExprId = slot.getExprId(); } if (rowIdExprId == null && Column.ICEBERG_ROWID_COL.equalsIgnoreCase(name)) { @@ -185,14 +190,14 @@ public PhysicalProperties getRequirePhysicalProperties() { List insertPartitionExprIds = new ArrayList<>(); List insertPartitionFields = new ArrayList<>(); Integer partitionSpecId = null; - List partitionColumns = ((IcebergExternalTable) targetTable).getPartitionColumns(Optional.empty()); + List partitionColumns = targetTable.getPartitionColumns(Optional.empty()); Map columnExprIdMap = buildColumnExprIdMap(outputSlots, nameToExprId); boolean insertExprsOk = false; if (!partitionColumns.isEmpty()) { insertExprsOk = buildInsertPartitionExprIds(insertPartitionExprIds, partitionColumns, columnExprIdMap); } - InsertPartitionFieldResult fieldResult = buildInsertPartitionFields( - insertPartitionFields, (IcebergExternalTable) targetTable, columnExprIdMap); + InsertPartitionFieldResult fieldResult = getIcebergPartitioning( + insertPartitionFields, targetTable, columnExprIdMap); boolean insertFieldsOk = fieldResult.success; boolean hasNonIdentity = fieldResult.hasNonIdentity; if (insertFieldsOk) { @@ -255,7 +260,7 @@ private List getDataSlots(List outputSlots) { List dataSlots = new ArrayList<>(); for (Slot slot : outputSlots) { String name = slot.getName(); - if (IcebergMergeOperation.OPERATION_COLUMN.equalsIgnoreCase(name)) { + if (MergeOperation.OPERATION_COLUMN.equalsIgnoreCase(name)) { continue; } if (Column.ICEBERG_ROWID_COL.equalsIgnoreCase(name)) { @@ -266,70 +271,117 @@ private List getDataSlots(List outputSlots) { return dataSlots; } - private InsertPartitionFieldResult buildInsertPartitionFields( + /** + * Partition-field resolution for the merge-write distribution: asks the connector for its + * engine-neutral {@link ConnectorWritePartitionSpec} and reconstructs the legacy result via + * {@link #reconstructPartitionFields}, preserving the three legacy parities (hard-fail clear on an + * unresolvable source column, the non-identity pre-pass over all fields, and the spec-id carry). + * Routes entirely through neutral connector SPI (no {@code instanceof Iceberg*}, no native types). + */ + private InsertPartitionFieldResult getIcebergPartitioning( List insertPartitionFields, - IcebergExternalTable icebergTable, + ExternalTable table, Map columnExprIdMap) { - Table table = icebergTable.getIcebergTable(); - if (table == null) { + return buildInsertPartitionFieldsFromConnector( + insertPartitionFields, (PluginDrivenExternalTable) table, columnExprIdMap); + } + + /** + * Post-flip arm of {@link #getIcebergPartitioning}: fetches the connector's engine-neutral + * {@link ConnectorWritePartitionSpec} via the same canonical access path as + * {@code PhysicalPlanTranslator.visitPhysicalConnectorTableSink}, then reconstructs the partition + * fields. A {@code null} write-plan provider or an unresolvable table handle degrades to the + * non-partitioned result (false, GATHER/random fallback), never an exception — matching the legacy + * native walk, which only ever returns result objects from inside the distribution derivation. + */ + private InsertPartitionFieldResult buildInsertPartitionFieldsFromConnector( + List insertPartitionFields, + PluginDrivenExternalTable table, + Map columnExprIdMap) { + PluginDrivenExternalCatalog catalog = (PluginDrivenExternalCatalog) table.getCatalog(); + Connector connector = catalog.getConnector(); + ConnectorSession session = catalog.buildConnectorSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); + // Resolve the handle first so the write provider is selected per-table (a heterogeneous gateway routes + // iceberg-on-HMS to its sibling by the handle type); both null-degrade checks keep the non-partitioned + // fallback. Byte-identical for single-format connectors (getWritePlanProvider(handle) defaults through). + ConnectorTableHandle handle = metadata.getTableHandle( + session, table.getRemoteDbName(), table.getRemoteName()).orElse(null); + if (handle == null) { return new InsertPartitionFieldResult(false, false, null); } - PartitionSpec spec = table.spec(); - if (spec == null || !spec.isPartitioned()) { + ConnectorWritePlanProvider writePlanProvider = connector.getWritePlanProvider(handle); + if (writePlanProvider == null) { return new InsertPartitionFieldResult(false, false, null); } - Schema schema = table.schema(); + ConnectorWritePartitionSpec spec = writePlanProvider.getWritePartitioning(session, handle); + return reconstructPartitionFields(insertPartitionFields, spec, columnExprIdMap); + } + + /** + * Reconstructs the legacy {@link InsertPartitionFieldResult} from a connector's engine-neutral + * {@link ConnectorWritePartitionSpec}, byte-for-byte equivalent to the retired native + * {@code PartitionSpec} walk. Pure (no native types, no I/O) so the three parities are + * pinned deterministically: + *
      + *
    • P1 hard-fail clear: a field with a {@code null} source column name, or one whose name + * does not resolve to a bound expr id, clears the accumulated fields and returns + * {@code success=false} — short-circuited before constructing the field, since the + * {@link DistributionSpecMerge.IcebergPartitionField} ctor requires a non-null expr id;
    • + *
    • P2 non-identity pre-pass: {@code hasNonIdentity} is computed over all fields + * from the transform string ({@code !"identity".equals}) independently of resolvability, + * matching legacy {@code field.transform().isIdentity()} (only {@code Identity.toString()} is + * {@code "identity"}); it gates the caller's random fallback;
    • + *
    • spec-id carry: the spec id is returned on every partitioned outcome (success or + * hard-fail), {@code null} only when unpartitioned.
    • + *
    + * A {@code null} spec means the connector reported the target unpartitioned (mirroring legacy + * {@code spec().isPartitioned()}), yielding {@code (false, false, null)}. + */ + static InsertPartitionFieldResult reconstructPartitionFields( + List insertPartitionFields, + ConnectorWritePartitionSpec spec, + Map columnExprIdMap) { + if (spec == null) { + return new InsertPartitionFieldResult(false, false, null); + } + List fields = spec.getFields(); boolean hasNonIdentity = false; - for (PartitionField field : spec.fields()) { - if (!field.transform().isIdentity()) { + for (ConnectorWritePartitionField field : fields) { + if (!"identity".equals(field.getTransform())) { hasNonIdentity = true; break; } } - if (schema == null) { - return new InsertPartitionFieldResult(false, hasNonIdentity, spec.specId()); - } - for (PartitionField field : spec.fields()) { - Types.NestedField sourceField = schema.findField(field.sourceId()); - if (sourceField == null) { + for (ConnectorWritePartitionField field : fields) { + String sourceColumnName = field.getSourceColumnName(); + if (sourceColumnName == null) { insertPartitionFields.clear(); - return new InsertPartitionFieldResult(false, hasNonIdentity, spec.specId()); + return new InsertPartitionFieldResult(false, hasNonIdentity, spec.getSpecId()); } - ExprId exprId = columnExprIdMap.get(sourceField.name()); + ExprId exprId = columnExprIdMap.get(sourceColumnName); if (exprId == null) { insertPartitionFields.clear(); - return new InsertPartitionFieldResult(false, hasNonIdentity, spec.specId()); + return new InsertPartitionFieldResult(false, hasNonIdentity, spec.getSpecId()); } - String transform = field.transform().toString(); - Integer param = parseTransformParam(transform); insertPartitionFields.add(new DistributionSpecMerge.IcebergPartitionField( - transform, exprId, param, field.name(), field.sourceId())); + field.getTransform(), exprId, field.getTransformParam(), + field.getFieldName(), field.getSourceId())); } if (insertPartitionFields.isEmpty()) { - return new InsertPartitionFieldResult(false, hasNonIdentity, spec.specId()); - } - return new InsertPartitionFieldResult(true, hasNonIdentity, spec.specId()); - } - - private Integer parseTransformParam(String transform) { - int start = transform.indexOf('['); - int end = transform.indexOf(']'); - if (start < 0 || end <= start) { - return null; - } - try { - return Integer.parseInt(transform.substring(start + 1, end)); - } catch (NumberFormatException e) { - return null; + return new InsertPartitionFieldResult(false, hasNonIdentity, spec.getSpecId()); } + return new InsertPartitionFieldResult(true, hasNonIdentity, spec.getSpecId()); } - private static class InsertPartitionFieldResult { - private final boolean success; - private final boolean hasNonIdentity; - private final Integer partitionSpecId; + // Package-private (not private) so the same-package parity test can assert on the reconstructed + // result of {@link #reconstructPartitionFields} directly, without driving the full distribution. + static class InsertPartitionFieldResult { + final boolean success; + final boolean hasNonIdentity; + final Integer partitionSpecId; - private InsertPartitionFieldResult(boolean success, boolean hasNonIdentity, Integer partitionSpecId) { + InsertPartitionFieldResult(boolean success, boolean hasNonIdentity, Integer partitionSpecId) { this.success = success; this.hasNonIdentity = hasNonIdentity; this.partitionSpecId = partitionSpecId; diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergTableSink.java deleted file mode 100644 index f0941257bf44ca..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergTableSink.java +++ /dev/null @@ -1,145 +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.physical; - -import org.apache.doris.catalog.Column; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.nereids.memo.GroupExpression; -import org.apache.doris.nereids.properties.DistributionSpecHiveTableSinkHashPartitioned; -import org.apache.doris.nereids.properties.LogicalProperties; -import org.apache.doris.nereids.properties.PhysicalProperties; -import org.apache.doris.nereids.trees.expressions.ExprId; -import org.apache.doris.nereids.trees.expressions.NamedExpression; -import org.apache.doris.nereids.trees.plans.AbstractPlan; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.PlanType; -import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.statistics.Statistics; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -/** physical iceberg sink */ -public class PhysicalIcebergTableSink extends PhysicalBaseExternalTableSink { - - /** - * constructor - */ - public PhysicalIcebergTableSink(IcebergExternalDatabase database, - IcebergExternalTable targetTable, - List cols, - List outputExprs, - Optional groupExpression, - LogicalProperties logicalProperties, - CHILD_TYPE child) { - this(database, targetTable, cols, outputExprs, groupExpression, logicalProperties, - PhysicalProperties.GATHER, null, child); - } - - /** - * constructor - */ - public PhysicalIcebergTableSink(IcebergExternalDatabase database, - IcebergExternalTable targetTable, - List cols, - List outputExprs, - Optional groupExpression, - LogicalProperties logicalProperties, - PhysicalProperties physicalProperties, - Statistics statistics, - CHILD_TYPE child) { - super(PlanType.PHYSICAL_ICEBERG_TABLE_SINK, database, targetTable, cols, outputExprs, groupExpression, - logicalProperties, physicalProperties, statistics, child); - } - - @Override - public Plan withChildren(List children) { - return AbstractPlan.copyWithSameId(this, () -> new PhysicalIcebergTableSink<>( - (IcebergExternalDatabase) database, (IcebergExternalTable) targetTable, - cols, outputExprs, groupExpression, - getLogicalProperties(), physicalProperties, statistics, children.get(0))); - } - - @Override - public R accept(PlanVisitor visitor, C context) { - return visitor.visitPhysicalIcebergTableSink(this, context); - } - - @Override - public Plan withGroupExpression(Optional groupExpression) { - return AbstractPlan.copyWithSameId(this, () -> new PhysicalIcebergTableSink<>( - (IcebergExternalDatabase) database, (IcebergExternalTable) targetTable, cols, outputExprs, - groupExpression, getLogicalProperties(), child())); - } - - @Override - public Plan withGroupExprLogicalPropChildren(Optional groupExpression, - Optional logicalProperties, List children) { - return AbstractPlan.copyWithSameId(this, () -> new PhysicalIcebergTableSink<>( - (IcebergExternalDatabase) database, (IcebergExternalTable) targetTable, cols, outputExprs, - groupExpression, logicalProperties.get(), children.get(0))); - } - - @Override - public PhysicalPlan withPhysicalPropertiesAndStats(PhysicalProperties physicalProperties, Statistics statistics) { - return AbstractPlan.copyWithSameId(this, () -> new PhysicalIcebergTableSink<>( - (IcebergExternalDatabase) database, (IcebergExternalTable) targetTable, cols, outputExprs, - groupExpression, getLogicalProperties(), physicalProperties, statistics, child())); - } - - /** - * get output physical properties - */ - @Override - public PhysicalProperties getRequirePhysicalProperties() { - // For Iceberg rewrite operations with small data volume, - // use GATHER distribution to collect data to a single node - // This helps minimize the number of output files - ConnectContext connectContext = ConnectContext.get(); - if (connectContext != null && connectContext.getStatementContext() != null - && connectContext.getStatementContext().isUseGatherForIcebergRewrite()) { - return PhysicalProperties.GATHER; - } - - Set partitionNames = targetTable.getPartitionNames(); - if (!partitionNames.isEmpty()) { - List columnIdx = new ArrayList<>(); - List fullSchema = targetTable.getFullSchema(); - for (int i = 0; i < fullSchema.size(); i++) { - Column column = fullSchema.get(i); - if (partitionNames.contains(column.getName())) { - columnIdx.add(i); - } - } - // mapping partition id - List exprIds = columnIdx.stream() - .map(idx -> child().getOutput().get(idx).getExprId()) - .collect(Collectors.toList()); - DistributionSpecHiveTableSinkHashPartitioned shuffleInfo - = new DistributionSpecHiveTableSinkHashPartitioned(); - shuffleInfo.setOutputColExprIds(exprIds); - return new PhysicalProperties(shuffleInfo); - } - return PhysicalProperties.SINK_RANDOM_PARTITIONED; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalMaxComputeTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalMaxComputeTableSink.java deleted file mode 100644 index c02a2553e795ac..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalMaxComputeTableSink.java +++ /dev/null @@ -1,156 +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.physical; - -import org.apache.doris.catalog.Column; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalDatabase; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalTable; -import org.apache.doris.nereids.memo.GroupExpression; -import org.apache.doris.nereids.properties.DistributionSpecHiveTableSinkHashPartitioned; -import org.apache.doris.nereids.properties.LogicalProperties; -import org.apache.doris.nereids.properties.MustLocalSortOrderSpec; -import org.apache.doris.nereids.properties.OrderKey; -import org.apache.doris.nereids.properties.PhysicalProperties; -import org.apache.doris.nereids.trees.expressions.ExprId; -import org.apache.doris.nereids.trees.expressions.NamedExpression; -import org.apache.doris.nereids.trees.plans.AbstractPlan; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.PlanType; -import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; -import org.apache.doris.statistics.Statistics; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -/** physical maxcompute table sink */ -public class PhysicalMaxComputeTableSink extends PhysicalBaseExternalTableSink { - - /** - * constructor - */ - public PhysicalMaxComputeTableSink(MaxComputeExternalDatabase database, - MaxComputeExternalTable targetTable, - List cols, - List outputExprs, - Optional groupExpression, - LogicalProperties logicalProperties, - CHILD_TYPE child) { - this(database, targetTable, cols, outputExprs, groupExpression, logicalProperties, - PhysicalProperties.GATHER, null, child); - } - - /** - * constructor - */ - public PhysicalMaxComputeTableSink(MaxComputeExternalDatabase database, - MaxComputeExternalTable targetTable, - List cols, - List outputExprs, - Optional groupExpression, - LogicalProperties logicalProperties, - PhysicalProperties physicalProperties, - Statistics statistics, - CHILD_TYPE child) { - super(PlanType.PHYSICAL_MAX_COMPUTE_TABLE_SINK, database, targetTable, cols, outputExprs, groupExpression, - logicalProperties, physicalProperties, statistics, child); - } - - @Override - public Plan withChildren(List children) { - return AbstractPlan.copyWithSameId(this, () -> new PhysicalMaxComputeTableSink<>( - (MaxComputeExternalDatabase) database, (MaxComputeExternalTable) targetTable, - cols, outputExprs, groupExpression, - getLogicalProperties(), physicalProperties, statistics, children.get(0))); - } - - @Override - public R accept(PlanVisitor visitor, C context) { - return visitor.visitPhysicalMaxComputeTableSink(this, context); - } - - @Override - public Plan withGroupExpression(Optional groupExpression) { - return AbstractPlan.copyWithSameId(this, () -> new PhysicalMaxComputeTableSink<>( - (MaxComputeExternalDatabase) database, (MaxComputeExternalTable) targetTable, cols, outputExprs, - groupExpression, getLogicalProperties(), child())); - } - - @Override - public Plan withGroupExprLogicalPropChildren(Optional groupExpression, - Optional logicalProperties, List children) { - return AbstractPlan.copyWithSameId(this, () -> new PhysicalMaxComputeTableSink<>( - (MaxComputeExternalDatabase) database, (MaxComputeExternalTable) targetTable, cols, outputExprs, - groupExpression, logicalProperties.get(), children.get(0))); - } - - @Override - public PhysicalPlan withPhysicalPropertiesAndStats(PhysicalProperties physicalProperties, Statistics statistics) { - return AbstractPlan.copyWithSameId(this, () -> new PhysicalMaxComputeTableSink<>( - (MaxComputeExternalDatabase) database, (MaxComputeExternalTable) targetTable, cols, outputExprs, - groupExpression, getLogicalProperties(), physicalProperties, statistics, child())); - } - - @Override - public PhysicalProperties getRequirePhysicalProperties() { - Set partitionNames = ((MaxComputeExternalTable) targetTable).getPartitionColumns().stream() - .map(Column::getName) - .collect(Collectors.toSet()); - if (!partitionNames.isEmpty()) { - // Check if any partition column is present in cols (the bound columns from SELECT). - // Static partition columns are excluded from cols by BindSink.bindMaxComputeTableSink(), - // so if no partition column remains in cols, all partitions are statically specified - // and we don't need sort/shuffle — all data goes to a single known partition. - Set colNames = cols.stream() - .map(Column::getName) - .collect(Collectors.toSet()); - boolean hasDynamicPartition = partitionNames.stream().anyMatch(colNames::contains); - if (!hasDynamicPartition) { - // All partition columns are statically specified, no sort needed - return PhysicalProperties.SINK_RANDOM_PARTITIONED; - } - - List columnIdx = new ArrayList<>(); - List fullSchema = targetTable.getFullSchema(); - for (int i = 0; i < fullSchema.size(); i++) { - Column column = fullSchema.get(i); - if (partitionNames.contains(column.getName())) { - columnIdx.add(i); - } - } - List exprIds = columnIdx.stream() - .map(idx -> child().getOutput().get(idx).getExprId()) - .collect(Collectors.toList()); - DistributionSpecHiveTableSinkHashPartitioned shuffleInfo - = new DistributionSpecHiveTableSinkHashPartitioned(); - shuffleInfo.setOutputColExprIds(exprIds); - // Require local sort by partition columns so that rows for the same partition - // are grouped together. MaxCompute Storage API streams dynamic partition data - // and will close a partition writer once it sees a different partition; - // unsorted data causes "writer has been closed" errors. - List orderKeys = columnIdx.stream() - .map(idx -> new OrderKey(child().getOutput().get(idx), true, false)) - .collect(Collectors.toList()); - return new PhysicalProperties(shuffleInfo) - .withOrderSpec(new MustLocalSortOrderSpec(orderKeys)); - } - return PhysicalProperties.SINK_RANDOM_PARTITIONED; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/visitor/RelationVisitor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/visitor/RelationVisitor.java index c852410c07cca5..08aed1b7b86261 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/visitor/RelationVisitor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/visitor/RelationVisitor.java @@ -23,7 +23,6 @@ import org.apache.doris.nereids.trees.plans.logical.LogicalCatalogRelation; import org.apache.doris.nereids.trees.plans.logical.LogicalEmptyRelation; import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan; -import org.apache.doris.nereids.trees.plans.logical.LogicalHudiScan; import org.apache.doris.nereids.trees.plans.logical.LogicalOdbcScan; import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan; import org.apache.doris.nereids.trees.plans.logical.LogicalOlapTableStreamScan; @@ -37,7 +36,6 @@ import org.apache.doris.nereids.trees.plans.physical.PhysicalCatalogRelation; import org.apache.doris.nereids.trees.plans.physical.PhysicalEmptyRelation; import org.apache.doris.nereids.trees.plans.physical.PhysicalFileScan; -import org.apache.doris.nereids.trees.plans.physical.PhysicalHudiScan; import org.apache.doris.nereids.trees.plans.physical.PhysicalOdbcScan; import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapScan; import org.apache.doris.nereids.trees.plans.physical.PhysicalOneRowRelation; @@ -95,10 +93,6 @@ default R visitLogicalFileScan(LogicalFileScan fileScan, C context) { return visitLogicalCatalogRelation(fileScan, context); } - default R visitLogicalHudiScan(LogicalHudiScan fileScan, C context) { - return visitLogicalFileScan(fileScan, context); - } - default R visitLogicalOdbcScan(LogicalOdbcScan odbcScan, C context) { return visitLogicalCatalogRelation(odbcScan, context); } @@ -143,10 +137,6 @@ default R visitPhysicalFileScan(PhysicalFileScan fileScan, C context) { return visitPhysicalCatalogRelation(fileScan, context); } - default R visitPhysicalHudiScan(PhysicalHudiScan hudiScan, C context) { - return visitPhysicalFileScan(hudiScan, context); - } - default R visitPhysicalOdbcScan(PhysicalOdbcScan odbcScan, C context) { return visitPhysicalCatalogRelation(odbcScan, context); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/visitor/SinkVisitor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/visitor/SinkVisitor.java index dcc6f715c9e3c8..280828d1d6ea10 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/visitor/SinkVisitor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/visitor/SinkVisitor.java @@ -20,9 +20,7 @@ import org.apache.doris.nereids.analyzer.UnboundBlackholeSink; import org.apache.doris.nereids.analyzer.UnboundConnectorTableSink; import org.apache.doris.nereids.analyzer.UnboundDictionarySink; -import org.apache.doris.nereids.analyzer.UnboundHiveTableSink; import org.apache.doris.nereids.analyzer.UnboundIcebergTableSink; -import org.apache.doris.nereids.analyzer.UnboundMaxComputeTableSink; import org.apache.doris.nereids.analyzer.UnboundResultSink; import org.apache.doris.nereids.analyzer.UnboundTVFTableSink; import org.apache.doris.nereids.analyzer.UnboundTableSink; @@ -31,11 +29,8 @@ import org.apache.doris.nereids.trees.plans.logical.LogicalConnectorTableSink; import org.apache.doris.nereids.trees.plans.logical.LogicalDictionarySink; import org.apache.doris.nereids.trees.plans.logical.LogicalFileSink; -import org.apache.doris.nereids.trees.plans.logical.LogicalHiveTableSink; import org.apache.doris.nereids.trees.plans.logical.LogicalIcebergDeleteSink; import org.apache.doris.nereids.trees.plans.logical.LogicalIcebergMergeSink; -import org.apache.doris.nereids.trees.plans.logical.LogicalIcebergTableSink; -import org.apache.doris.nereids.trees.plans.logical.LogicalMaxComputeTableSink; import org.apache.doris.nereids.trees.plans.logical.LogicalOlapTableSink; import org.apache.doris.nereids.trees.plans.logical.LogicalResultSink; import org.apache.doris.nereids.trees.plans.logical.LogicalSink; @@ -45,11 +40,8 @@ import org.apache.doris.nereids.trees.plans.physical.PhysicalConnectorTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalDictionarySink; import org.apache.doris.nereids.trees.plans.physical.PhysicalFileSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalHiveTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergDeleteSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergMergeSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergTableSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalMaxComputeTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalResultSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; @@ -77,10 +69,6 @@ default R visitUnboundTableSink(UnboundTableSink unboundTableSin return visitLogicalSink(unboundTableSink, context); } - default R visitUnboundHiveTableSink(UnboundHiveTableSink unboundTableSink, C context) { - return visitLogicalSink(unboundTableSink, context); - } - default R visitUnboundIcebergTableSink(UnboundIcebergTableSink unboundTableSink, C context) { return visitLogicalSink(unboundTableSink, context); } @@ -101,10 +89,6 @@ default R visitUnboundBlackholeSink(UnboundBlackholeSink unbound return visitLogicalSink(unboundBlackholeSink, context); } - default R visitUnboundMaxComputeTableSink(UnboundMaxComputeTableSink unboundTableSink, C context) { - return visitLogicalSink(unboundTableSink, context); - } - default R visitUnboundTVFTableSink(UnboundTVFTableSink unboundTVFTableSink, C context) { return visitLogicalSink(unboundTVFTableSink, context); } @@ -125,18 +109,6 @@ default R visitLogicalOlapTableSink(LogicalOlapTableSink olapTab return visitLogicalTableSink(olapTableSink, context); } - default R visitLogicalHiveTableSink(LogicalHiveTableSink hiveTableSink, C context) { - return visitLogicalTableSink(hiveTableSink, context); - } - - default R visitLogicalIcebergTableSink(LogicalIcebergTableSink icebergTableSink, C context) { - return visitLogicalTableSink(icebergTableSink, context); - } - - default R visitLogicalMaxComputeTableSink(LogicalMaxComputeTableSink mcTableSink, C context) { - return visitLogicalTableSink(mcTableSink, context); - } - default R visitLogicalIcebergDeleteSink(LogicalIcebergDeleteSink icebergDeleteSink, C context) { return visitLogicalTableSink(icebergDeleteSink, context); } @@ -189,18 +161,6 @@ default R visitPhysicalOlapTableSink(PhysicalOlapTableSink olapT return visitPhysicalTableSink(olapTableSink, context); } - default R visitPhysicalHiveTableSink(PhysicalHiveTableSink hiveTableSink, C context) { - return visitPhysicalTableSink(hiveTableSink, context); - } - - default R visitPhysicalIcebergTableSink(PhysicalIcebergTableSink icebergTableSink, C context) { - return visitPhysicalTableSink(icebergTableSink, context); - } - - default R visitPhysicalMaxComputeTableSink(PhysicalMaxComputeTableSink mcTableSink, C context) { - return visitPhysicalTableSink(mcTableSink, context); - } - default R visitPhysicalIcebergDeleteSink(PhysicalIcebergDeleteSink icebergDeleteSink, C context) { return visitPhysicalTableSink(icebergDeleteSink, context); } 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 baf2615bdbde38..41d2c4f3dfcd40 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 @@ -60,13 +60,13 @@ import org.apache.doris.cooldown.CooldownConfList; import org.apache.doris.cooldown.CooldownDelete; import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.CatalogLog; import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.ExternalObjectLog; -import org.apache.doris.datasource.InitCatalogLog; -import org.apache.doris.datasource.InitDatabaseLog; import org.apache.doris.datasource.InternalCatalog; -import org.apache.doris.datasource.MetaIdMappingsLog; +import org.apache.doris.datasource.log.CatalogLog; +import org.apache.doris.datasource.log.ExternalObjectLog; +import org.apache.doris.datasource.log.InitCatalogLog; +import org.apache.doris.datasource.log.InitDatabaseLog; +import org.apache.doris.datasource.log.MetaIdMappingsLog; import org.apache.doris.dictionary.Dictionary; import org.apache.doris.ha.MasterInfo; import org.apache.doris.indexpolicy.DropIndexPolicyLog; diff --git a/fe/fe-core/src/main/java/org/apache/doris/persist/gson/GsonUtils.java b/fe/fe-core/src/main/java/org/apache/doris/persist/gson/GsonUtils.java index d4135f166884df..a02f5bbf204b1a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/persist/gson/GsonUtils.java +++ b/fe/fe-core/src/main/java/org/apache/doris/persist/gson/GsonUtils.java @@ -142,46 +142,18 @@ import org.apache.doris.datasource.ExternalDatabase; import org.apache.doris.datasource.ExternalTable; import org.apache.doris.datasource.InternalCatalog; -import org.apache.doris.datasource.PluginDrivenExternalCatalog; -import org.apache.doris.datasource.PluginDrivenExternalDatabase; -import org.apache.doris.datasource.PluginDrivenExternalTable; import org.apache.doris.datasource.doris.RemoteDorisExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalDatabase; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.iceberg.IcebergDLFExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergGlueExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergHMSExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergHadoopExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergJdbcExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergRestExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergS3TablesExternalCatalog; import org.apache.doris.datasource.infoschema.ExternalInfoSchemaDatabase; import org.apache.doris.datasource.infoschema.ExternalInfoSchemaTable; import org.apache.doris.datasource.infoschema.ExternalMysqlDatabase; import org.apache.doris.datasource.infoschema.ExternalMysqlTable; -import org.apache.doris.datasource.lakesoul.LakeSoulExternalCatalog; -import org.apache.doris.datasource.lakesoul.LakeSoulExternalDatabase; -import org.apache.doris.datasource.lakesoul.LakeSoulExternalTable; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalDatabase; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalTable; -import org.apache.doris.datasource.paimon.PaimonDLFExternalCatalog; -import org.apache.doris.datasource.paimon.PaimonExternalCatalog; -import org.apache.doris.datasource.paimon.PaimonExternalDatabase; -import org.apache.doris.datasource.paimon.PaimonExternalTable; -import org.apache.doris.datasource.paimon.PaimonFileExternalCatalog; -import org.apache.doris.datasource.paimon.PaimonHMSExternalCatalog; -import org.apache.doris.datasource.paimon.PaimonRestExternalCatalog; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalDatabase; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.datasource.test.TestExternalCatalog; import org.apache.doris.datasource.test.TestExternalDatabase; import org.apache.doris.datasource.test.TestExternalTable; -import org.apache.doris.datasource.trinoconnector.TrinoConnectorExternalCatalog; -import org.apache.doris.datasource.trinoconnector.TrinoConnectorExternalDatabase; -import org.apache.doris.datasource.trinoconnector.TrinoConnectorExternalTable; import org.apache.doris.dictionary.Dictionary; import org.apache.doris.job.extensions.insert.InsertJob; import org.apache.doris.job.extensions.insert.streaming.StreamingInsertJob; @@ -385,26 +357,7 @@ public class GsonUtils { static { dsTypeAdapterFactory = RuntimeTypeAdapterFactory.of(CatalogIf.class, "clazz") .registerSubtype(CloudInternalCatalog.class, CloudInternalCatalog.class.getSimpleName()) - .registerSubtype(HMSExternalCatalog.class, HMSExternalCatalog.class.getSimpleName()) - .registerSubtype(IcebergExternalCatalog.class, IcebergExternalCatalog.class.getSimpleName()) - .registerSubtype(IcebergHMSExternalCatalog.class, IcebergHMSExternalCatalog.class.getSimpleName()) - .registerSubtype(IcebergGlueExternalCatalog.class, IcebergGlueExternalCatalog.class.getSimpleName()) - .registerSubtype(IcebergRestExternalCatalog.class, IcebergRestExternalCatalog.class.getSimpleName()) - .registerSubtype(IcebergDLFExternalCatalog.class, IcebergDLFExternalCatalog.class.getSimpleName()) - .registerSubtype(IcebergHadoopExternalCatalog.class, IcebergHadoopExternalCatalog.class.getSimpleName()) - .registerSubtype(IcebergJdbcExternalCatalog.class, IcebergJdbcExternalCatalog.class.getSimpleName()) - .registerSubtype(IcebergS3TablesExternalCatalog.class, - IcebergS3TablesExternalCatalog.class.getSimpleName()) - .registerSubtype(PaimonExternalCatalog.class, PaimonExternalCatalog.class.getSimpleName()) - .registerSubtype(PaimonHMSExternalCatalog.class, PaimonHMSExternalCatalog.class.getSimpleName()) - .registerSubtype(PaimonFileExternalCatalog.class, PaimonFileExternalCatalog.class.getSimpleName()) - .registerSubtype(PaimonRestExternalCatalog.class, PaimonRestExternalCatalog.class.getSimpleName()) - .registerSubtype(MaxComputeExternalCatalog.class, MaxComputeExternalCatalog.class.getSimpleName()) - .registerSubtype( - TrinoConnectorExternalCatalog.class, TrinoConnectorExternalCatalog.class.getSimpleName()) - .registerSubtype(LakeSoulExternalCatalog.class, LakeSoulExternalCatalog.class.getSimpleName()) .registerSubtype(TestExternalCatalog.class, TestExternalCatalog.class.getSimpleName()) - .registerSubtype(PaimonDLFExternalCatalog.class, PaimonDLFExternalCatalog.class.getSimpleName()) .registerSubtype(RemoteDorisExternalCatalog.class, RemoteDorisExternalCatalog.class.getSimpleName()) .registerSubtype(PluginDrivenExternalCatalog.class, PluginDrivenExternalCatalog.class.getSimpleName()) @@ -413,7 +366,50 @@ public class GsonUtils { PluginDrivenExternalCatalog.class, "EsExternalCatalog") // Migrate old JDBC catalogs to PluginDriven on deserialization .registerCompatibleSubtype( - PluginDrivenExternalCatalog.class, "JdbcExternalCatalog"); + PluginDrivenExternalCatalog.class, "JdbcExternalCatalog") + // Migrate old Trino-connector catalogs to PluginDriven on deserialization + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "TrinoConnectorExternalCatalog") + // Migrate old MaxCompute catalogs to PluginDriven on deserialization + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "MaxComputeExternalCatalog") + // Migrate old Paimon catalogs (all 5 flavors) to PluginDriven on deserialization + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "PaimonExternalCatalog") + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "PaimonHMSExternalCatalog") + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "PaimonFileExternalCatalog") + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "PaimonRestExternalCatalog") + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "PaimonDLFExternalCatalog") + // Migrate old Iceberg catalogs (all 8 flavors) to PluginDriven on deserialization + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "IcebergExternalCatalog") + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "IcebergHMSExternalCatalog") + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "IcebergGlueExternalCatalog") + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "IcebergRestExternalCatalog") + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "IcebergDLFExternalCatalog") + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "IcebergHadoopExternalCatalog") + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "IcebergJdbcExternalCatalog") + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "IcebergS3TablesExternalCatalog") + // Migrate old HMS (hive) catalogs to PluginDriven on deserialization; the hms gateway serves + // plain-hive + hudi-on-HMS + iceberg-on-HMS through the hive connector. + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "HMSExternalCatalog") + // Migrate old LakeSoul catalogs to PluginDriven on deserialization. LakeSoul is deprecated and + // was never migrated to a connector, so the remapped catalog has no backing connector and + // errors on access (it must be dropped); this only keeps old images/edit-logs loadable. + .registerCompatibleSubtype( + PluginDrivenExternalCatalog.class, "LakeSoulExternalCatalog"); if (Config.isNotCloudMode()) { dsTypeAdapterFactory .registerSubtype(InternalCatalog.class, InternalCatalog.class.getSimpleName()); @@ -449,40 +445,65 @@ public class GsonUtils { private static RuntimeTypeAdapterFactory dbTypeAdapterFactory = RuntimeTypeAdapterFactory.of( DatabaseIf.class, "clazz") .registerSubtype(ExternalDatabase.class, ExternalDatabase.class.getSimpleName()) - .registerSubtype(HMSExternalDatabase.class, HMSExternalDatabase.class.getSimpleName()) - .registerSubtype(IcebergExternalDatabase.class, IcebergExternalDatabase.class.getSimpleName()) - .registerSubtype(LakeSoulExternalDatabase.class, LakeSoulExternalDatabase.class.getSimpleName()) - .registerSubtype(PaimonExternalDatabase.class, PaimonExternalDatabase.class.getSimpleName()) - .registerSubtype(MaxComputeExternalDatabase.class, MaxComputeExternalDatabase.class.getSimpleName()) .registerSubtype(ExternalInfoSchemaDatabase.class, ExternalInfoSchemaDatabase.class.getSimpleName()) .registerSubtype(ExternalMysqlDatabase.class, ExternalMysqlDatabase.class.getSimpleName()) - .registerSubtype(TrinoConnectorExternalDatabase.class, TrinoConnectorExternalDatabase.class.getSimpleName()) .registerSubtype(TestExternalDatabase.class, TestExternalDatabase.class.getSimpleName()) .registerSubtype(PluginDrivenExternalDatabase.class, PluginDrivenExternalDatabase.class.getSimpleName()) .registerCompatibleSubtype( PluginDrivenExternalDatabase.class, "EsExternalDatabase") .registerCompatibleSubtype( - PluginDrivenExternalDatabase.class, "JdbcExternalDatabase"); + PluginDrivenExternalDatabase.class, "JdbcExternalDatabase") + .registerCompatibleSubtype( + PluginDrivenExternalDatabase.class, "TrinoConnectorExternalDatabase") + .registerCompatibleSubtype( + PluginDrivenExternalDatabase.class, "MaxComputeExternalDatabase") + .registerCompatibleSubtype( + PluginDrivenExternalDatabase.class, "PaimonExternalDatabase") + // Migrate old Iceberg databases to PluginDriven on deserialization + .registerCompatibleSubtype( + PluginDrivenExternalDatabase.class, "IcebergExternalDatabase") + // Migrate old HMS (hive) databases to PluginDriven on deserialization + .registerCompatibleSubtype( + PluginDrivenExternalDatabase.class, "HMSExternalDatabase") + // Migrate old LakeSoul databases to PluginDriven on deserialization (deprecated, no connector) + .registerCompatibleSubtype( + PluginDrivenExternalDatabase.class, "LakeSoulExternalDatabase"); private static RuntimeTypeAdapterFactory tblTypeAdapterFactory = RuntimeTypeAdapterFactory.of( TableIf.class, "clazz").registerSubtype(ExternalTable.class, ExternalTable.class.getSimpleName()) .registerSubtype(OlapTable.class, OlapTable.class.getSimpleName()) - .registerSubtype(HMSExternalTable.class, HMSExternalTable.class.getSimpleName()) - .registerSubtype(IcebergExternalTable.class, IcebergExternalTable.class.getSimpleName()) - .registerSubtype(LakeSoulExternalTable.class, LakeSoulExternalTable.class.getSimpleName()) - .registerSubtype(PaimonExternalTable.class, PaimonExternalTable.class.getSimpleName()) - .registerSubtype(MaxComputeExternalTable.class, MaxComputeExternalTable.class.getSimpleName()) .registerSubtype(ExternalInfoSchemaTable.class, ExternalInfoSchemaTable.class.getSimpleName()) .registerSubtype(ExternalMysqlTable.class, ExternalMysqlTable.class.getSimpleName()) - .registerSubtype(TrinoConnectorExternalTable.class, TrinoConnectorExternalTable.class.getSimpleName()) .registerSubtype(TestExternalTable.class, TestExternalTable.class.getSimpleName()) .registerSubtype(PluginDrivenExternalTable.class, PluginDrivenExternalTable.class.getSimpleName()) + .registerSubtype(PluginDrivenMvccExternalTable.class, + PluginDrivenMvccExternalTable.class.getSimpleName()) .registerCompatibleSubtype( PluginDrivenExternalTable.class, "EsExternalTable") .registerCompatibleSubtype( PluginDrivenExternalTable.class, "JdbcExternalTable") + .registerCompatibleSubtype( + PluginDrivenExternalTable.class, "TrinoConnectorExternalTable") + .registerCompatibleSubtype( + PluginDrivenExternalTable.class, "MaxComputeExternalTable") + // LakeSoul tables migrate to the non-MVCC PluginDriven variant (deprecated, no connector backing) + .registerCompatibleSubtype( + PluginDrivenExternalTable.class, "LakeSoulExternalTable") + // Paimon tables migrate to the MVCC variant (paimon supports MVCC/MTMV/time-travel) + .registerCompatibleSubtype( + PluginDrivenMvccExternalTable.class, "PaimonExternalTable") + // Iceberg tables likewise migrate to the MVCC variant (iceberg exposes snapshots/time-travel, + // declaring SUPPORTS_MVCC_SNAPSHOT) so a flipped iceberg table is a PluginDrivenMvccExternalTable + .registerCompatibleSubtype( + PluginDrivenMvccExternalTable.class, "IcebergExternalTable") + // HMS (hive) tables migrate to the MVCC variant too: the hive connector declares + // SUPPORTS_MVCC_SNAPSHOT (snapshots/time-travel/MTMV freshness, and the gateway serves the + // MVCC-capable iceberg-on-HMS + hudi-on-HMS siblings), so a flipped hms table is a + // PluginDrivenMvccExternalTable — matching what buildTableInternal rebuilds it as on replay. + .registerCompatibleSubtype( + PluginDrivenMvccExternalTable.class, "HMSExternalTable") .registerSubtype(BrokerTable.class, BrokerTable.class.getSimpleName()) .registerSubtype(EsTable.class, EsTable.class.getSimpleName()) .registerSubtype(FunctionGenTable.class, FunctionGenTable.class.getSimpleName()) diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/BaseExternalTableDataSink.java b/fe/fe-core/src/main/java/org/apache/doris/planner/BaseExternalTableDataSink.java index 8ebdf9d538ec5b..853d6d0fa320af 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/BaseExternalTableDataSink.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/BaseExternalTableDataSink.java @@ -20,21 +20,13 @@ package org.apache.doris.planner; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.FsBroker; import org.apache.doris.common.AnalysisException; -import org.apache.doris.datasource.hive.HiveUtil; import org.apache.doris.nereids.trees.plans.commands.insert.InsertCommandContext; import org.apache.doris.thrift.TDataSink; -import org.apache.doris.thrift.TFileCompressType; import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.TNetworkAddress; -import java.util.Collections; -import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; public abstract class BaseExternalTableDataSink extends DataSink { @@ -60,71 +52,6 @@ public DataPartition getOutputPartition() { */ protected abstract Set supportedFileFormatTypes(); - protected List getBrokerAddresses(String bindBroker) throws AnalysisException { - List brokers; - if (bindBroker != null) { - brokers = Env.getCurrentEnv().getBrokerMgr().getBrokers(bindBroker); - } else { - brokers = Env.getCurrentEnv().getBrokerMgr().getAllBrokers(); - } - if (brokers == null || brokers.isEmpty()) { - throw new AnalysisException("No alive broker."); - } - Collections.shuffle(brokers); - return brokers.stream().map(broker -> new TNetworkAddress(broker.host, broker.port)) - .collect(Collectors.toList()); - } - - protected TFileFormatType getTFileFormatType(String format) throws AnalysisException { - // LZO InputFormats must be rejected before any other match, because their class names also - // contain "text" (e.g. LzoTextInputFormat) and would otherwise silently match FORMAT_CSV_PLAIN. - // The BE writer has no LZO codec for Hive sink: it emits plain-text files without a .lzo - // suffix, while the read path for LZO partitions filters to *.lzo only — causing every - // Doris-written row to become permanently invisible. Reject here to cover both the - // table-level SD (line ~126) and every existing partition SD (line ~223 in HiveTableSink), - // since both resolve their write format through this method. - if (HiveUtil.isLzoInputFormat(format)) { - throw new AnalysisException("INSERT INTO is not supported for LZO Hive tables " - + "(input format: " + format + "). LZO tables are read-only in Doris."); - } - TFileFormatType fileFormatType = TFileFormatType.FORMAT_UNKNOWN; - String lowerCase = format.toLowerCase(); - if (lowerCase.contains("orc")) { - fileFormatType = TFileFormatType.FORMAT_ORC; - } else if (lowerCase.contains("parquet")) { - fileFormatType = TFileFormatType.FORMAT_PARQUET; - } else if (lowerCase.contains("text")) { - fileFormatType = TFileFormatType.FORMAT_CSV_PLAIN; - } - if (!supportedFileFormatTypes().contains(fileFormatType)) { - throw new AnalysisException("Unsupported input format type: " + format); - } - return fileFormatType; - } - - protected TFileCompressType getTFileCompressType(String compressType) { - if ("snappy".equalsIgnoreCase(compressType)) { - return TFileCompressType.SNAPPYBLOCK; - } else if ("lz4".equalsIgnoreCase(compressType)) { - return TFileCompressType.LZ4BLOCK; - } else if ("lzo".equalsIgnoreCase(compressType)) { - return TFileCompressType.LZO; - } else if ("zlib".equalsIgnoreCase(compressType)) { - return TFileCompressType.ZLIB; - } else if ("zstd".equalsIgnoreCase(compressType)) { - return TFileCompressType.ZSTD; - } else if ("gzip".equalsIgnoreCase(compressType)) { - return TFileCompressType.GZ; - } else if ("bzip2".equalsIgnoreCase(compressType)) { - return TFileCompressType.BZ2; - } else if ("uncompressed".equalsIgnoreCase(compressType)) { - return TFileCompressType.PLAIN; - } else { - // try to use plain type to decompress parquet or orc file - return TFileCompressType.PLAIN; - } - } - /** * check sink params and generate thrift data sink to BE * @param insertCtx insert info context diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/DataGenScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/DataGenScanNode.java index d5f5f079f0e56f..d17981c9177324 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/DataGenScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/DataGenScanNode.java @@ -20,7 +20,7 @@ import org.apache.doris.analysis.TupleDescriptor; import org.apache.doris.common.NereidsException; import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalScanNode; +import org.apache.doris.datasource.scan.ExternalScanNode; import org.apache.doris.qe.ConnectContext; import org.apache.doris.tablefunction.DataGenTableValuedFunction; import org.apache.doris.tablefunction.TableValuedFunctionTask; diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/FileLoadScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/FileLoadScanNode.java index 3944b57b211ac9..3a38263fe3bba9 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/FileLoadScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/FileLoadScanNode.java @@ -21,8 +21,8 @@ import org.apache.doris.analysis.SlotDescriptor; import org.apache.doris.analysis.TupleDescriptor; import org.apache.doris.common.UserException; -import org.apache.doris.datasource.FederationBackendPolicy; -import org.apache.doris.datasource.FileScanNode; +import org.apache.doris.datasource.scan.FederationBackendPolicy; +import org.apache.doris.datasource.scan.FileScanNode; import org.apache.doris.load.BrokerFileGroup; import org.apache.doris.nereids.load.NereidsFileGroupInfo; import org.apache.doris.nereids.load.NereidsLoadPlanInfoCollector; diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/GroupCommitScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/GroupCommitScanNode.java index da068c286e05e3..491858046e1fb3 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/GroupCommitScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/GroupCommitScanNode.java @@ -19,7 +19,7 @@ import org.apache.doris.analysis.TupleDescriptor; import org.apache.doris.common.UserException; -import org.apache.doris.datasource.ExternalScanNode; +import org.apache.doris.datasource.scan.ExternalScanNode; import org.apache.doris.thrift.TExplainLevel; import org.apache.doris.thrift.TGroupCommitScanNode; import org.apache.doris.thrift.TPlanNode; diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/HiveTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/planner/HiveTableSink.java deleted file mode 100644 index dd7dc1b120a1ef..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/HiveTableSink.java +++ /dev/null @@ -1,269 +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. -// This file is copied from -// https://github.com/apache/impala/blob/branch-2.9.0/fe/src/main/java/org/apache/impala/DataSink.java -// and modified by Doris - -package org.apache.doris.planner; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HiveExternalMetaCache; -import org.apache.doris.datasource.hive.HiveMetaStoreClientHelper; -import org.apache.doris.datasource.hive.HivePartition; -import org.apache.doris.datasource.hive.HiveProperties; -import org.apache.doris.datasource.mvcc.MvccUtil; -import org.apache.doris.nereids.trees.plans.commands.insert.HiveInsertCommandContext; -import org.apache.doris.nereids.trees.plans.commands.insert.InsertCommandContext; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.thrift.TDataSink; -import org.apache.doris.thrift.TDataSinkType; -import org.apache.doris.thrift.TExplainLevel; -import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.TFileType; -import org.apache.doris.thrift.THiveBucket; -import org.apache.doris.thrift.THiveColumn; -import org.apache.doris.thrift.THiveColumnType; -import org.apache.doris.thrift.THiveLocationParams; -import org.apache.doris.thrift.THivePartition; -import org.apache.doris.thrift.THiveSerDeProperties; -import org.apache.doris.thrift.THiveTableSink; - -import com.google.common.base.Strings; -import org.apache.hadoop.fs.Path; -import org.apache.hadoop.hive.metastore.api.StorageDescriptor; -import org.apache.hadoop.hive.metastore.api.Table; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -public class HiveTableSink extends BaseExternalTableDataSink { - private final HMSExternalTable targetTable; - private static final HashSet supportedTypes = new HashSet() {{ - add(TFileFormatType.FORMAT_CSV_PLAIN); - add(TFileFormatType.FORMAT_ORC); - add(TFileFormatType.FORMAT_PARQUET); - add(TFileFormatType.FORMAT_TEXT); - }}; - - public HiveTableSink(HMSExternalTable targetTable) { - super(); - this.targetTable = targetTable; - } - - @Override - protected Set supportedFileFormatTypes() { - return supportedTypes; - } - - @Override - public String getExplainString(String prefix, TExplainLevel explainLevel) { - StringBuilder strBuilder = new StringBuilder(); - strBuilder.append(prefix + "HIVE TABLE SINK\n"); - if (explainLevel == TExplainLevel.BRIEF) { - return strBuilder.toString(); - } - // TODO: explain partitions - return strBuilder.toString(); - } - - @Override - public void bindDataSink(Optional insertCtx) - throws AnalysisException { - THiveTableSink tSink = new THiveTableSink(); - tSink.setDbName(targetTable.getDbName()); - tSink.setTableName(targetTable.getName()); - Set partNames = new HashSet<>(targetTable.getPartitionColumnNames()); - List allColumns = targetTable.getColumns(); - Set colNames = allColumns.stream().map(Column::getName).collect(Collectors.toSet()); - colNames.removeAll(partNames); - List targetColumns = new ArrayList<>(); - for (Column col : allColumns) { - if (partNames.contains(col.getName())) { - THiveColumn tHiveColumn = new THiveColumn(); - tHiveColumn.setName(col.getName()); - tHiveColumn.setColumnType(THiveColumnType.PARTITION_KEY); - targetColumns.add(tHiveColumn); - } else if (colNames.contains(col.getName())) { - THiveColumn tHiveColumn = new THiveColumn(); - tHiveColumn.setName(col.getName()); - tHiveColumn.setColumnType(THiveColumnType.REGULAR); - targetColumns.add(tHiveColumn); - } - } - tSink.setColumns(targetColumns); - - setPartitionValues(tSink); - - StorageDescriptor sd = targetTable.getRemoteTable().getSd(); - THiveBucket bucketInfo = new THiveBucket(); - bucketInfo.setBucketedBy(sd.getBucketCols()); - bucketInfo.setBucketCount(sd.getNumBuckets()); - tSink.setBucketInfo(bucketInfo); - - TFileFormatType formatType = getTFileFormatType(sd.getInputFormat()); - tSink.setFileFormat(formatType); - setCompressType(tSink, formatType); - setSerDeProperties(tSink); - - THiveLocationParams locationParams = new THiveLocationParams(); - String originalLocation = sd.getLocation(); - LocationPath locationPath = LocationPath.of(sd.getLocation(), targetTable.getStoragePropertiesMap()); - String location = sd.getLocation(); - TFileType fileType = locationPath.getTFileTypeForBE(); - if (fileType == TFileType.FILE_S3) { - locationParams.setWritePath(locationPath.getNormalizedLocation()); - locationParams.setOriginalWritePath(originalLocation); - locationParams.setTargetPath(locationPath.getNormalizedLocation()); - if (insertCtx.isPresent()) { - HiveInsertCommandContext context = (HiveInsertCommandContext) insertCtx.get(); - tSink.setOverwrite(context.isOverwrite()); - context.setWritePath(locationPath.getNormalizedLocation()); - context.setFileType(fileType); - } - } else { - String writeTempPath = createTempPath(location); - locationParams.setWritePath(writeTempPath); - locationParams.setOriginalWritePath(writeTempPath); - locationParams.setTargetPath(location); - if (insertCtx.isPresent()) { - HiveInsertCommandContext context = (HiveInsertCommandContext) insertCtx.get(); - tSink.setOverwrite(context.isOverwrite()); - context.setWritePath(writeTempPath); - context.setFileType(fileType); - } - } - locationParams.setFileType(fileType); - tSink.setLocation(locationParams); - if (fileType.equals(TFileType.FILE_BROKER)) { - tSink.setBrokerAddresses(getBrokerAddresses(targetTable.getCatalog().bindBrokerName())); - } - - tSink.setHadoopConfig(targetTable.getBackendStorageProperties()); - - tDataSink = new TDataSink(getDataSinkType()); - tDataSink.setHiveTableSink(tSink); - } - - private String createTempPath(String location) { - String user = ConnectContext.get().getCurrentUserIdentity().getUser(); - String stagingBaseDir = targetTable.getCatalog().getCatalogProperty() - .getOrDefault(HMSExternalCatalog.HIVE_STAGING_DIR, HMSExternalCatalog.DEFAULT_STAGING_BASE_DIR); - String stagingDir = new Path(stagingBaseDir, user).toString(); - return LocationPath.getTempWritePath(location, stagingDir); - } - - private void setCompressType(THiveTableSink tSink, TFileFormatType formatType) { - String compressType; - switch (formatType) { - case FORMAT_ORC: - compressType = targetTable.getRemoteTable().getParameters().get("orc.compress"); - break; - case FORMAT_PARQUET: - compressType = targetTable.getRemoteTable().getParameters().get("parquet.compression"); - break; - case FORMAT_CSV_PLAIN: - case FORMAT_TEXT: - compressType = targetTable.getRemoteTable().getParameters().get("text.compression"); - if (Strings.isNullOrEmpty(compressType)) { - compressType = ConnectContext.get().getSessionVariable().hiveTextCompression(); - } - break; - default: - compressType = "plain"; - break; - } - tSink.setCompressionType(getTFileCompressType(compressType)); - } - - private void setPartitionValues(THiveTableSink tSink) throws AnalysisException { - if (ConnectContext.get().getExecutor() != null) { - ConnectContext.get().getExecutor().getSummaryProfile().setSinkGetPartitionsStartTime(); - } - - List partitions = new ArrayList<>(); - - List hivePartitions = new ArrayList<>(); - if (targetTable.isPartitionedTable()) { - // Get partitions from cache instead of HMS client (similar to HiveScanNode) - HiveExternalMetaCache cache = Env.getCurrentEnv().getExtMetaCacheMgr() - .hive(targetTable.getCatalog().getId()); - HiveExternalMetaCache.HivePartitionValues partitionValues = - targetTable.getHivePartitionValues(MvccUtil.getSnapshotFromContext(targetTable)); - List> partitionValuesList = - new ArrayList<>(partitionValues.getNameToPartitionValues().values()); - hivePartitions = cache.getAllPartitionsWithCache(targetTable, partitionValuesList); - } - - // Convert HivePartition to THivePartition (same logic as before) - for (HivePartition partition : hivePartitions) { - THivePartition hivePartition = new THivePartition(); - hivePartition.setFileFormat(getTFileFormatType(partition.getInputFormat())); - hivePartition.setValues(partition.getPartitionValues()); - - THiveLocationParams locationParams = new THiveLocationParams(); - String location = partition.getPath(); - // pass the same of write path and target path to partition - locationParams.setWritePath(location); - locationParams.setTargetPath(location); - locationParams.setFileType(LocationPath.getTFileTypeForBE(location)); - hivePartition.setLocation(locationParams); - partitions.add(hivePartition); - } - - tSink.setPartitions(partitions); - - if (ConnectContext.get().getExecutor() != null) { - ConnectContext.get().getExecutor().getSummaryProfile().setSinkGetPartitionsFinishTime(); - } - } - - private void setSerDeProperties(THiveTableSink tSink) { - THiveSerDeProperties serDeProperties = new THiveSerDeProperties(); - Table table = targetTable.getRemoteTable(); - String serDeLib = table.getSd().getSerdeInfo().getSerializationLib(); - // 1. set field delimiter - if (HiveMetaStoreClientHelper.HIVE_MULTI_DELIMIT_SERDE.equals(serDeLib)) { - serDeProperties.setFieldDelim(HiveProperties.getFieldDelimiter(table, true)); - } else { - serDeProperties.setFieldDelim(HiveProperties.getFieldDelimiter(table)); - } - // 2. set line delimiter - serDeProperties.setLineDelim(HiveProperties.getLineDelimiter(table)); - // 3. set collection delimiter - serDeProperties.setCollectionDelim(HiveProperties.getCollectionDelimiter(table)); - // 4. set mapkv delimiter - serDeProperties.setMapkvDelim(HiveProperties.getMapKvDelimiter(table)); - // 5. set escape delimiter - HiveProperties.getEscapeDelimiter(table).ifPresent(serDeProperties::setEscapeChar); - // 6. set null format - serDeProperties.setNullFormat(HiveProperties.getNullFormat(table)); - tSink.setSerdeProperties(serDeProperties); - } - - protected TDataSinkType getDataSinkType() { - return TDataSinkType.HIVE_TABLE_SINK; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergDeleteSink.java b/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergDeleteSink.java deleted file mode 100644 index 19bc1af6f14252..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergDeleteSink.java +++ /dev/null @@ -1,155 +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.planner; - -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.credentials.VendedCredentialsFactory; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergUtils; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext; -import org.apache.doris.nereids.trees.plans.commands.insert.InsertCommandContext; -import org.apache.doris.thrift.TDataSink; -import org.apache.doris.thrift.TDataSinkType; -import org.apache.doris.thrift.TExplainLevel; -import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.TFileType; -import org.apache.doris.thrift.TIcebergDeleteSink; -import org.apache.doris.thrift.TIcebergRewritableDeleteFileSet; - -import org.apache.iceberg.Table; - -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; - -/** - * Planner sink for Iceberg DELETE operations. - * Generates TIcebergDeleteSink for BE to write delete files. - */ -public class IcebergDeleteSink extends BaseExternalTableDataSink { - - private final IcebergExternalTable targetTable; - private final DeleteCommandContext deleteContext; - private List rewritableDeleteFileSets = Collections.emptyList(); - - private static final HashSet supportedTypes = new HashSet() {{ - add(TFileFormatType.FORMAT_PARQUET); - add(TFileFormatType.FORMAT_ORC); - }}; - - // Store PropertiesMap, including vended credentials or static credentials - private Map storagePropertiesMap; - - public IcebergDeleteSink(IcebergExternalTable targetTable, DeleteCommandContext deleteContext) { - super(); - if (targetTable.isView()) { - throw new UnsupportedOperationException("DELETE from iceberg view is not supported"); - } - this.targetTable = targetTable; - this.deleteContext = deleteContext; - - IcebergExternalCatalog catalog = (IcebergExternalCatalog) targetTable.getCatalog(); - storagePropertiesMap = VendedCredentialsFactory.getStoragePropertiesMapWithVendedCredentials( - catalog.getCatalogProperty().getMetastoreProperties(), - catalog.getCatalogProperty().getStoragePropertiesMap(), - targetTable.getIcebergTable()); - } - - public void setRewritableDeleteFileSets(List deleteFileSets) { - rewritableDeleteFileSets = deleteFileSets != null ? deleteFileSets : Collections.emptyList(); - if (tDataSink != null && tDataSink.isSetIcebergDeleteSink()) { - tDataSink.getIcebergDeleteSink().setRewritableDeleteFileSets(rewritableDeleteFileSets); - } - } - - @Override - protected Set supportedFileFormatTypes() { - return supportedTypes; - } - - @Override - public String getExplainString(String prefix, TExplainLevel explainLevel) { - StringBuilder strBuilder = new StringBuilder(); - strBuilder.append(prefix).append("ICEBERG DELETE SINK\n"); - if (explainLevel == TExplainLevel.BRIEF) { - return strBuilder.toString(); - } - strBuilder.append(prefix).append(" DeleteType: ") - .append(deleteContext.getDeleteFileType()).append("\n"); - return strBuilder.toString(); - } - - @Override - public void bindDataSink(Optional insertCtx) - throws AnalysisException { - - TIcebergDeleteSink tSink = new TIcebergDeleteSink(); - - Table icebergTable = targetTable.getIcebergTable(); - - tSink.setDbName(targetTable.getDbName()); - tSink.setTbName(targetTable.getName()); - - // Set delete type (POSITION_DELETES only) - tSink.setDeleteType(deleteContext.toTFileContent()); - - // File format and compression - tSink.setFileFormat(getTFileFormatType(IcebergUtils.getFileFormat(icebergTable).name())); - tSink.setCompressType(getTFileCompressType(IcebergUtils.getFileCompress(icebergTable))); - - // Hadoop config - Map props = new HashMap<>(); - for (StorageProperties storageProperties : storagePropertiesMap.values()) { - props.putAll(storageProperties.getBackendConfigProperties()); - } - tSink.setHadoopConfig(props); - - // Location for delete files (typically under metadata/) - String tableLocation = IcebergUtils.dataLocation(icebergTable); - LocationPath locationPath = LocationPath.of(tableLocation, storagePropertiesMap); - tSink.setOutputPath(locationPath.toStorageLocation().toString()); - tSink.setTableLocation(tableLocation); - - TFileType fileType = locationPath.getTFileTypeForBE(); - tSink.setFileType(fileType); - if (fileType.equals(TFileType.FILE_BROKER)) { - tSink.setBrokerAddresses(getBrokerAddresses(targetTable.getCatalog().bindBrokerName())); - } - - // Partition information - if (icebergTable.spec().isPartitioned()) { - tSink.setPartitionSpecId(icebergTable.spec().specId()); - } - - int formatVersion = IcebergUtils.getFormatVersion(icebergTable); - tSink.setFormatVersion(formatVersion); - if (formatVersion >= 3 && !rewritableDeleteFileSets.isEmpty()) { - tSink.setRewritableDeleteFileSets(rewritableDeleteFileSets); - } - - tDataSink = new TDataSink(TDataSinkType.ICEBERG_DELETE_SINK); - tDataSink.setIcebergDeleteSink(tSink); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergMergeSink.java b/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergMergeSink.java deleted file mode 100644 index 4af4ba17e18578..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergMergeSink.java +++ /dev/null @@ -1,199 +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.planner; - -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.credentials.VendedCredentialsFactory; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergUtils; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext; -import org.apache.doris.nereids.trees.plans.commands.insert.InsertCommandContext; -import org.apache.doris.thrift.TDataSink; -import org.apache.doris.thrift.TDataSinkType; -import org.apache.doris.thrift.TExplainLevel; -import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.TFileType; -import org.apache.doris.thrift.TIcebergMergeSink; -import org.apache.doris.thrift.TIcebergRewritableDeleteFileSet; -import org.apache.doris.thrift.TSortField; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Maps; -import org.apache.iceberg.NullOrder; -import org.apache.iceberg.PartitionSpecParser; -import org.apache.iceberg.Schema; -import org.apache.iceberg.SchemaParser; -import org.apache.iceberg.SortDirection; -import org.apache.iceberg.SortField; -import org.apache.iceberg.SortOrder; -import org.apache.iceberg.Table; -import org.apache.iceberg.types.Types; - -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; - -/** - * Planner sink for Iceberg UPDATE merge operations. - * Generates TIcebergMergeSink for BE to write delete files and data files. - */ -public class IcebergMergeSink extends BaseExternalTableDataSink { - - private final IcebergExternalTable targetTable; - private final DeleteCommandContext deleteContext; - private List rewritableDeleteFileSets = Collections.emptyList(); - - private static final HashSet supportedTypes = new HashSet() {{ - add(TFileFormatType.FORMAT_PARQUET); - add(TFileFormatType.FORMAT_ORC); - }}; - - // Store PropertiesMap, including vended credentials or static credentials - private Map storagePropertiesMap; - - public IcebergMergeSink(IcebergExternalTable targetTable, DeleteCommandContext deleteContext) { - super(); - if (targetTable.isView()) { - throw new UnsupportedOperationException("UPDATE on iceberg view is not supported"); - } - this.targetTable = targetTable; - this.deleteContext = deleteContext; - - IcebergExternalCatalog catalog = (IcebergExternalCatalog) targetTable.getCatalog(); - storagePropertiesMap = VendedCredentialsFactory.getStoragePropertiesMapWithVendedCredentials( - catalog.getCatalogProperty().getMetastoreProperties(), - catalog.getCatalogProperty().getStoragePropertiesMap(), - targetTable.getIcebergTable()); - } - - public void setRewritableDeleteFileSets(List deleteFileSets) { - rewritableDeleteFileSets = deleteFileSets != null ? deleteFileSets : Collections.emptyList(); - if (tDataSink != null && tDataSink.isSetIcebergMergeSink()) { - tDataSink.getIcebergMergeSink().setRewritableDeleteFileSets(rewritableDeleteFileSets); - } - } - - @Override - protected Set supportedFileFormatTypes() { - return supportedTypes; - } - - @Override - public String getExplainString(String prefix, TExplainLevel explainLevel) { - StringBuilder strBuilder = new StringBuilder(); - strBuilder.append(prefix).append("ICEBERG MERGE SINK\n"); - if (explainLevel == TExplainLevel.BRIEF) { - return strBuilder.toString(); - } - strBuilder.append(prefix).append(" DeleteType: ") - .append(deleteContext.getDeleteFileType()).append("\n"); - return strBuilder.toString(); - } - - @Override - public void bindDataSink(Optional insertCtx) - throws AnalysisException { - - TIcebergMergeSink tSink = new TIcebergMergeSink(); - - Table icebergTable = targetTable.getIcebergTable(); - - tSink.setDbName(targetTable.getDbName()); - tSink.setTbName(targetTable.getName()); - - Schema schema = icebergTable.schema(); - int formatVersion = IcebergUtils.getFormatVersion(icebergTable); - if (formatVersion >= 3) { - schema = IcebergUtils.appendRowLineageFieldsForV3(schema); - } - tSink.setFormatVersion(formatVersion); - tSink.setSchemaJson(SchemaParser.toJson(schema)); - - // partition spec - if (icebergTable.spec().isPartitioned()) { - tSink.setPartitionSpecsJson(Maps.transformValues(icebergTable.specs(), PartitionSpecParser::toJson)); - tSink.setPartitionSpecId(icebergTable.spec().specId()); - } - - // sort order - if (icebergTable.sortOrder().isSorted()) { - SortOrder sortOrder = icebergTable.sortOrder(); - Set baseColumnFieldIds = icebergTable.schema().columns().stream() - .map(Types.NestedField::fieldId) - .collect(ImmutableSet.toImmutableSet()); - ImmutableList.Builder sortFields = ImmutableList.builder(); - for (SortField sortField : sortOrder.fields()) { - if (!sortField.transform().isIdentity()) { - continue; - } - if (!baseColumnFieldIds.contains(sortField.sourceId())) { - continue; - } - TSortField tSortField = new TSortField(); - tSortField.setSourceColumnId(sortField.sourceId()); - tSortField.setAscending(sortField.direction().equals(SortDirection.ASC)); - tSortField.setNullFirst(sortField.nullOrder().equals(NullOrder.NULLS_FIRST)); - sortFields.add(tSortField); - } - tSink.setSortFields(sortFields.build()); - } - - // file info - tSink.setFileFormat(getTFileFormatType(IcebergUtils.getFileFormat(icebergTable).name())); - tSink.setCompressionType(getTFileCompressType(IcebergUtils.getFileCompress(icebergTable))); - - // hadoop config - Map props = new HashMap<>(); - for (StorageProperties storageProperties : storagePropertiesMap.values()) { - props.putAll(storageProperties.getBackendConfigProperties()); - } - tSink.setHadoopConfig(props); - - // location - String originalLocation = IcebergUtils.dataLocation(icebergTable); - LocationPath locationPath = LocationPath.of(originalLocation, storagePropertiesMap); - tSink.setOutputPath(locationPath.toStorageLocation().toString()); - tSink.setOriginalOutputPath(originalLocation); - tSink.setTableLocation(originalLocation); - TFileType fileType = locationPath.getTFileTypeForBE(); - tSink.setFileType(fileType); - if (fileType.equals(TFileType.FILE_BROKER)) { - tSink.setBrokerAddresses(getBrokerAddresses(targetTable.getCatalog().bindBrokerName())); - } - - // delete side - tSink.setDeleteType(deleteContext.toTFileContent()); - if (icebergTable.spec().isPartitioned()) { - tSink.setPartitionSpecIdForDelete(icebergTable.spec().specId()); - } - - if (formatVersion >= 3 && !rewritableDeleteFileSets.isEmpty()) { - tSink.setRewritableDeleteFileSets(rewritableDeleteFileSets); - } - tDataSink = new TDataSink(TDataSinkType.ICEBERG_MERGE_SINK); - tDataSink.setIcebergMergeSink(tSink); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergTableSink.java deleted file mode 100644 index 0f3b1bb24d26bc..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergTableSink.java +++ /dev/null @@ -1,206 +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.planner; - -import org.apache.doris.analysis.Expr; -import org.apache.doris.analysis.SortInfo; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.credentials.VendedCredentialsFactory; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergUtils; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.nereids.trees.plans.commands.insert.IcebergInsertCommandContext; -import org.apache.doris.nereids.trees.plans.commands.insert.InsertCommandContext; -import org.apache.doris.thrift.TDataSink; -import org.apache.doris.thrift.TDataSinkType; -import org.apache.doris.thrift.TExplainLevel; -import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.TFileType; -import org.apache.doris.thrift.TIcebergTableSink; -import org.apache.doris.thrift.TIcebergWriteType; - -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import org.apache.iceberg.NullOrder; -import org.apache.iceberg.PartitionSpecParser; -import org.apache.iceberg.Schema; -import org.apache.iceberg.SchemaParser; -import org.apache.iceberg.SortDirection; -import org.apache.iceberg.SortField; -import org.apache.iceberg.SortOrder; -import org.apache.iceberg.Table; -import org.apache.iceberg.types.Types.NestedField; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; - -public class IcebergTableSink extends BaseExternalTableDataSink { - - private List outputExprs; - private final IcebergExternalTable targetTable; - private static final HashSet supportedTypes = new HashSet() {{ - add(TFileFormatType.FORMAT_ORC); - add(TFileFormatType.FORMAT_PARQUET); - }}; - - // Store PropertiesMap, including vended credentials or static credentials - // get them in doInitialize() to ensure internal consistency of ScanNode - private Map storagePropertiesMap; - - public IcebergTableSink(IcebergExternalTable targetTable) { - super(); - if (targetTable.isView()) { - throw new UnsupportedOperationException("Write data to iceberg view is not supported"); - } - this.targetTable = targetTable; - IcebergExternalCatalog catalog = (IcebergExternalCatalog) targetTable.getCatalog(); - storagePropertiesMap = VendedCredentialsFactory.getStoragePropertiesMapWithVendedCredentials( - catalog.getCatalogProperty().getMetastoreProperties(), - catalog.getCatalogProperty().getStoragePropertiesMap(), - targetTable.getIcebergTable()); - } - - @Override - protected Set supportedFileFormatTypes() { - return supportedTypes; - } - - public void setOutputExprs(List outputExprs) { - this.outputExprs = outputExprs; - } - - @Override - public String getExplainString(String prefix, TExplainLevel explainLevel) { - StringBuilder strBuilder = new StringBuilder(); - strBuilder.append(prefix).append("ICEBERG TABLE SINK\n"); - if (explainLevel == TExplainLevel.BRIEF) { - return strBuilder.toString(); - } - Table icebergTable = targetTable.getIcebergTable(); - strBuilder.append(prefix).append("Table: ").append(icebergTable.name()).append("\n"); - if (icebergTable.sortOrder().isSorted()) { - strBuilder.append(prefix).append(targetTable.getSortOrderSql()).append("\n"); - } - - // TODO: explain partitions - return strBuilder.toString(); - } - - @Override - public void bindDataSink(Optional insertCtx) - throws AnalysisException { - - TIcebergTableSink tSink = new TIcebergTableSink(); - - Table icebergTable = targetTable.getIcebergTable(); - - tSink.setDbName(targetTable.getDbName()); - tSink.setTbName(targetTable.getName()); - - boolean isRewriting = false; - if (insertCtx.isPresent() && insertCtx.get() instanceof IcebergInsertCommandContext) { - IcebergInsertCommandContext context = (IcebergInsertCommandContext) insertCtx.get(); - isRewriting = context.isRewriting(); - if (isRewriting) { - tSink.setWriteType(TIcebergWriteType.REWRITE); - } - } - - Schema schema = icebergTable.schema(); - if (isRewriting - && IcebergUtils.getFormatVersion(icebergTable) >= IcebergUtils.ICEBERG_ROW_LINEAGE_MIN_VERSION) { - // iceberg v3 format requires additional row lineage fields when rewrite data files. - schema = IcebergUtils.appendRowLineageFieldsForV3(schema); - } - tSink.setSchemaJson(SchemaParser.toJson(schema)); - - // partition spec - if (icebergTable.spec().isPartitioned()) { - tSink.setPartitionSpecsJson(Maps.transformValues(icebergTable.specs(), PartitionSpecParser::toJson)); - tSink.setPartitionSpecId(icebergTable.spec().specId()); - } - - // sort order - if (icebergTable.sortOrder().isSorted()) { - SortOrder sortOrder = icebergTable.sortOrder(); - ArrayList orderingExprs = Lists.newArrayList(); - ArrayList isAscOrder = Lists.newArrayList(); - ArrayList isNullsFirst = Lists.newArrayList(); - for (SortField sortField : sortOrder.fields()) { - if (!sortField.transform().isIdentity()) { - continue; - } - for (int i = 0; i < icebergTable.schema().columns().size(); ++i) { - NestedField column = icebergTable.schema().columns().get(i); - if (column.fieldId() == sortField.sourceId()) { - orderingExprs.add(outputExprs.get(i)); - isAscOrder.add(sortField.direction().equals(SortDirection.ASC)); - isNullsFirst.add(sortField.nullOrder().equals(NullOrder.NULLS_FIRST)); - break; - } - } - } - SortInfo sortInfo = new SortInfo(orderingExprs, isAscOrder, isNullsFirst, null); - tSink.setSortInfo(sortInfo.toThrift()); - } - - // file info - tSink.setFileFormat(getTFileFormatType(IcebergUtils.getFileFormat(icebergTable).name())); - tSink.setCompressionType(getTFileCompressType(IcebergUtils.getFileCompress(icebergTable))); - - // hadoop config - Map props = new HashMap<>(); - for (StorageProperties storageProperties : storagePropertiesMap.values()) { - props.putAll(storageProperties.getBackendConfigProperties()); - } - tSink.setHadoopConfig(props); - - // location - String originalLocation = IcebergUtils.dataLocation(icebergTable); - LocationPath locationPath = LocationPath.of(originalLocation, storagePropertiesMap); - tSink.setOutputPath(locationPath.toStorageLocation().toString()); - tSink.setOriginalOutputPath(originalLocation); - TFileType fileType = locationPath.getTFileTypeForBE(); - tSink.setFileType(fileType); - if (fileType.equals(TFileType.FILE_BROKER)) { - tSink.setBrokerAddresses(getBrokerAddresses(targetTable.getCatalog().bindBrokerName())); - } - - if (insertCtx.isPresent() && insertCtx.get() instanceof IcebergInsertCommandContext) { - IcebergInsertCommandContext context = (IcebergInsertCommandContext) insertCtx.get(); - tSink.setOverwrite(context.isOverwrite()); - - // Pass static partition values to BE for static partition overwrite - if (context.isStaticPartitionOverwrite()) { - Map staticPartitionValues = context.getStaticPartitionValues(); - if (staticPartitionValues != null && !staticPartitionValues.isEmpty()) { - tSink.setStaticPartitionValues(staticPartitionValues); - } - } - } - tDataSink = new TDataSink(TDataSinkType.ICEBERG_TABLE_SINK); - tDataSink.setIcebergTableSink(tSink); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/MaxComputeTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/planner/MaxComputeTableSink.java deleted file mode 100644 index 98537fa0307dd0..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/MaxComputeTableSink.java +++ /dev/null @@ -1,113 +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.planner; - -import org.apache.doris.common.AnalysisException; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalTable; -import org.apache.doris.nereids.trees.plans.commands.insert.InsertCommandContext; -import org.apache.doris.nereids.trees.plans.commands.insert.MCInsertCommandContext; -import org.apache.doris.thrift.TDataSink; -import org.apache.doris.thrift.TDataSinkType; -import org.apache.doris.thrift.TExplainLevel; -import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.TMaxComputeTableSink; - -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -public class MaxComputeTableSink extends BaseExternalTableDataSink { - - private final MaxComputeExternalTable targetTable; - - public MaxComputeTableSink(MaxComputeExternalTable targetTable) { - super(); - this.targetTable = targetTable; - } - - @Override - protected Set supportedFileFormatTypes() { - return new HashSet<>(); - } - - @Override - public String getExplainString(String prefix, TExplainLevel explainLevel) { - StringBuilder strBuilder = new StringBuilder(); - strBuilder.append(prefix).append("MAXCOMPUTE TABLE SINK\n"); - if (explainLevel == TExplainLevel.BRIEF) { - return strBuilder.toString(); - } - strBuilder.append(prefix).append(" TABLE: ").append(targetTable.getName()).append("\n"); - return strBuilder.toString(); - } - - @Override - public void bindDataSink(Optional insertCtx) throws AnalysisException { - TMaxComputeTableSink tSink = new TMaxComputeTableSink(); - - MaxComputeExternalCatalog catalog = (MaxComputeExternalCatalog) targetTable.getCatalog(); - - tSink.setProperties(catalog.getProperties()); - tSink.setEndpoint(catalog.getEndpoint()); - tSink.setProject(catalog.getDefaultProject()); - tSink.setTableName(targetTable.getName()); - tSink.setQuota(catalog.getQuota()); - tSink.setConnectTimeout(catalog.getConnectTimeout()); - tSink.setReadTimeout(catalog.getReadTimeout()); - tSink.setRetryCount(catalog.getRetryTimes()); - - // Partition columns - List partitionColumnNames = targetTable.getPartitionColumns().stream() - .map(col -> col.getName()) - .collect(Collectors.toList()); - if (!partitionColumnNames.isEmpty()) { - tSink.setPartitionColumns(partitionColumnNames); - } - - if (insertCtx.isPresent() && insertCtx.get() instanceof MCInsertCommandContext) { - MCInsertCommandContext mcCtx = (MCInsertCommandContext) insertCtx.get(); - // Static partition spec - Map staticPartitionSpec = mcCtx.getStaticPartitionSpec(); - if (staticPartitionSpec != null && !staticPartitionSpec.isEmpty()) { - tSink.setStaticPartitionSpec(staticPartitionSpec); - } - } - - // Note: writeSessionId is set later by MCInsertExecutor.beforeExec() - // after MCTransaction.beginInsert() creates the Storage API session. - - tDataSink = new TDataSink(TDataSinkType.MAXCOMPUTE_TABLE_SINK); - tDataSink.setMaxComputeTableSink(tSink); - } - - /** - * Called by MCInsertExecutor.beforeExec() to inject runtime write context - * after MCTransaction.beginInsert() creates the Storage API session. - * This must be called before fragments are sent to BE (i.e., before execImpl). - */ - public void setWriteContext(long txnId, String writeSessionId) { - if (tDataSink != null && tDataSink.isSetMaxComputeTableSink()) { - tDataSink.getMaxComputeTableSink().setTxnId(txnId); - tDataSink.getMaxComputeTableSink().setWriteSessionId(writeSessionId); - } - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/PlanNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/PlanNode.java index 1fc55fe8f5e1c0..a635ca26730892 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/PlanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/PlanNode.java @@ -20,7 +20,6 @@ package org.apache.doris.planner; -import org.apache.doris.analysis.ColumnAccessPath; import org.apache.doris.analysis.CompoundPredicate; import org.apache.doris.analysis.Expr; import org.apache.doris.analysis.ExprSubstitutionMap; @@ -36,7 +35,6 @@ import org.apache.doris.common.Pair; import org.apache.doris.common.TreeNode; import org.apache.doris.common.UserException; -import org.apache.doris.datasource.iceberg.source.IcebergScanNode; import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; @@ -953,33 +951,19 @@ protected void printNestedColumns(StringBuilder output, String prefix, TupleDesc if (slot.getDisplayAllAccessPaths() != null && slot.getDisplayAllAccessPaths() != null && !slot.getDisplayAllAccessPaths().isEmpty()) { - if (this instanceof IcebergScanNode) { - displayAllAccessPathsString = mergeIcebergAccessPathsWithId( - slot.getAllAccessPaths(), - slot.getDisplayAllAccessPaths() - ); - } else { - displayAllAccessPathsString = slot.getDisplayAllAccessPaths() - .stream() - .map(a -> StringUtils.join(a.getPath(), ".")) - .collect(Collectors.joining(", ")); - } + displayAllAccessPathsString = slot.getDisplayAllAccessPaths() + .stream() + .map(a -> StringUtils.join(a.getPath(), ".")) + .collect(Collectors.joining(", ")); } String displayPredicateAccessPathsString = null; if (slot.getDisplayPredicateAccessPaths() != null && slot.getDisplayPredicateAccessPaths() != null && !slot.getDisplayPredicateAccessPaths().isEmpty()) { - if (this instanceof IcebergScanNode) { - displayPredicateAccessPathsString = mergeIcebergAccessPathsWithId( - slot.getPredicateAccessPaths(), - slot.getDisplayPredicateAccessPaths() - ); - } else { - displayPredicateAccessPathsString = slot.getPredicateAccessPaths() - .stream() - .map(a -> StringUtils.join(a.getPath(), ".")) - .collect(Collectors.joining(", ")); - } + displayPredicateAccessPathsString = slot.getPredicateAccessPaths() + .stream() + .map(a -> StringUtils.join(a.getPath(), ".")) + .collect(Collectors.joining(", ")); } @@ -1015,30 +999,6 @@ protected void printNestedColumns(StringBuilder output, String prefix, TupleDesc } } - private String mergeIcebergAccessPathsWithId( - List accessPaths, List displayAccessPaths) { - List mergeDisplayAccessPaths = Lists.newArrayList(); - for (int i = 0; i < displayAccessPaths.size(); i++) { - ColumnAccessPath displayAccessPath = displayAccessPaths.get(i); - ColumnAccessPath idAccessPath = accessPaths.get(i); - List nameAccessPathStrings = displayAccessPath.getPath(); - List idAccessPathStrings = idAccessPath.getPath(); - - List mergedPath = new ArrayList<>(); - for (int j = 0; j < idAccessPathStrings.size(); j++) { - String name = nameAccessPathStrings.get(j); - String id = idAccessPathStrings.get(j); - if (name.equals(id)) { - mergedPath.add(name); - } else { - mergedPath.add(name + "(" + id + ")"); - } - } - mergeDisplayAccessPaths.add(StringUtils.join(mergedPath, ".")); - } - return StringUtils.join(mergeDisplayAccessPaths, ", "); - } - public Pair enforceAndDeriveLocalExchange( PlanTranslatorContext translatorContext, PlanNode parent, LocalExchangeTypeRequire parentRequire) { ArrayList newChildren = Lists.newArrayList(); 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 c04be2c01c42e0..9730925167a755 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 @@ -17,32 +17,24 @@ package org.apache.doris.planner; -import org.apache.doris.catalog.Column; import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.connector.api.write.ConnectorWriteConfig; -import org.apache.doris.connector.api.write.ConnectorWriteType; -import org.apache.doris.datasource.PluginDrivenExternalTable; +import org.apache.doris.connector.api.ConnectorColumn; +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; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.nereids.trees.plans.commands.insert.InsertCommandContext; +import org.apache.doris.nereids.trees.plans.commands.insert.PluginDrivenInsertCommandContext; import org.apache.doris.thrift.TDataSink; -import org.apache.doris.thrift.TDataSinkType; import org.apache.doris.thrift.TExplainLevel; import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.TFileType; -import org.apache.doris.thrift.THiveColumn; -import org.apache.doris.thrift.THiveColumnType; -import org.apache.doris.thrift.THiveLocationParams; -import org.apache.doris.thrift.THiveTableSink; -import org.apache.doris.thrift.TJdbcTable; -import org.apache.doris.thrift.TJdbcTableSink; -import org.apache.doris.thrift.TOdbcTableType; +import org.apache.doris.thrift.TSortInfo; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.ArrayList; +import java.util.Collections; import java.util.EnumSet; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -51,63 +43,90 @@ /** * Generic data sink for plugin-driven external tables. * - *

    Extends {@link BaseExternalTableDataSink} and constructs the appropriate - * Thrift {@link TDataSink} based on {@link ConnectorWriteConfig} obtained from - * the connector SPI. This allows different connector plugins to produce their - * write configuration without knowing Thrift types, while the engine handles - * the Thrift serialization.

    - * - *

    Supported write types and their Thrift mappings:

    - *
      - *
    • {@link ConnectorWriteType#FILE_WRITE} → {@link TDataSinkType#HIVE_TABLE_SINK}
    • - *
    • {@link ConnectorWriteType#JDBC_WRITE} → {@link TDataSinkType#JDBC_TABLE_SINK}
    • - *
    • Others → determined by per-connector migration
    • - *
    + *

    Extends {@link BaseExternalTableDataSink}. The connector supplies a + * {@link ConnectorWritePlanProvider} and builds its own opaque {@link TDataSink} + * via {@link ConnectorWritePlanProvider#planWrite}; the engine dispatches that + * sink to BE unchanged. This is the single, source-agnostic write path used by + * every write-capable connector (jdbc / maxcompute / iceberg). The connector- + * specific {@code T*TableSink} dialect lives entirely inside the connector.

    */ public class PluginDrivenTableSink extends BaseExternalTableDataSink { - private static final Logger LOG = LogManager.getLogger(PluginDrivenTableSink.class); - - // Well-known property keys in ConnectorWriteConfig.properties - public static final String PROP_DB_NAME = "db_name"; - public static final String PROP_TABLE_NAME = "table_name"; - public static final String PROP_OVERWRITE = "overwrite"; - public static final String PROP_WRITE_PATH = "write_path"; - public static final String PROP_TARGET_PATH = "target_path"; - public static final String PROP_ORIGINAL_WRITE_PATH = "original_write_path"; + private final PluginDrivenExternalTable targetTable; + // Plan-provider mode (W5): the connector builds its own opaque TDataSink via planWrite(). + private final ConnectorWritePlanProvider writePlanProvider; + private final ConnectorSession connectorSession; + private final ConnectorTableHandle tableHandle; + private final List connectorColumns; + // The engine-built BE sort instruction for a connector that declares write-sort columns (iceberg + // WRITE ORDERED BY); null when the target needs no write sort. The connector cannot build it (the + // bound output exprs live only here), so the translator resolves the connector's declared sort + // columns against the sink output and hands the TSortInfo here to thread onto the write handle. + private final TSortInfo writeSortInfo; + // The DML write operation this sink performs. A plain INSERT sink keeps the default INSERT (the + // connector promotes it to OVERWRITE from the handle's isOverwrite() flag); the row-level DML + // translator arms (DELETE / UPDATE / MERGE) pass the operation here so the connector's planWrite + // dispatches to the matching BE sink dialect (TIcebergDeleteSink / TIcebergMergeSink) instead of + // the INSERT TIcebergTableSink. Threaded onto the write handle so planWrite's buildWriteContext + // reads it via ConnectorWriteHandle.getWriteOperation(). + private final WriteOperation writeOperation; - // JDBC-specific property keys - public static final String PROP_JDBC_URL = "jdbc_url"; - public static final String PROP_JDBC_USER = "jdbc_user"; - public static final String PROP_JDBC_PASSWORD = "jdbc_password"; - public static final String PROP_JDBC_DRIVER_URL = "jdbc_driver_url"; - public static final String PROP_JDBC_DRIVER_CLASS = "jdbc_driver_class"; - public static final String PROP_JDBC_DRIVER_CHECKSUM = "jdbc_driver_checksum"; - public static final String PROP_JDBC_TABLE_NAME = "jdbc_table_name"; - public static final String PROP_JDBC_RESOURCE_NAME = "jdbc_resource_name"; - public static final String PROP_JDBC_TABLE_TYPE = "jdbc_table_type"; - public static final String PROP_JDBC_INSERT_SQL = "jdbc_insert_sql"; - public static final String PROP_JDBC_USE_TRANSACTION = "jdbc_use_transaction"; - public static final String PROP_JDBC_CATALOG_ID = "jdbc_catalog_id"; - public static final String PROP_JDBC_POOL_MIN = "connection_pool_min_size"; - public static final String PROP_JDBC_POOL_MAX = "connection_pool_max_size"; - public static final String PROP_JDBC_POOL_MAX_WAIT = "connection_pool_max_wait_time"; - public static final String PROP_JDBC_POOL_MAX_LIFE = "connection_pool_max_life_time"; - public static final String PROP_JDBC_POOL_KEEP_ALIVE = "connection_pool_keep_alive"; + /** + * Plan-provider mode (W5): the connector supplies a {@link ConnectorWritePlanProvider} + * and builds its own opaque {@link TDataSink} via + * {@link ConnectorWritePlanProvider#planWrite}. + */ + public PluginDrivenTableSink(PluginDrivenExternalTable targetTable, + ConnectorWritePlanProvider writePlanProvider, ConnectorSession connectorSession, + ConnectorTableHandle tableHandle, List connectorColumns) { + this(targetTable, writePlanProvider, connectorSession, tableHandle, connectorColumns, null); + } - private final PluginDrivenExternalTable targetTable; - private final ConnectorWriteConfig writeConfig; + /** + * Plan-provider mode with an engine-built write {@link TSortInfo} threaded to the connector's write + * handle (for a connector that declares write-sort columns). + */ + public PluginDrivenTableSink(PluginDrivenExternalTable targetTable, + ConnectorWritePlanProvider writePlanProvider, ConnectorSession connectorSession, + ConnectorTableHandle tableHandle, List connectorColumns, + TSortInfo writeSortInfo) { + this(targetTable, writePlanProvider, connectorSession, tableHandle, connectorColumns, + writeSortInfo, WriteOperation.INSERT); + } + /** + * Plan-provider mode with the DML {@link WriteOperation} threaded to the connector's write handle, so + * the connector's {@code planWrite} dispatches to the matching BE sink dialect. The two shorter ctors + * default this to {@link WriteOperation#INSERT} (the byte-identical plain-INSERT path); the row-level + * DML translator arms use this ctor with {@code DELETE} / {@code UPDATE} / {@code MERGE}. + */ public PluginDrivenTableSink(PluginDrivenExternalTable targetTable, - ConnectorWriteConfig writeConfig) { + ConnectorWritePlanProvider writePlanProvider, ConnectorSession connectorSession, + ConnectorTableHandle tableHandle, List connectorColumns, + TSortInfo writeSortInfo, WriteOperation writeOperation) { super(); this.targetTable = targetTable; - this.writeConfig = writeConfig; + this.writePlanProvider = writePlanProvider; + this.connectorSession = connectorSession; + this.tableHandle = tableHandle; + this.connectorColumns = connectorColumns; + this.writeSortInfo = writeSortInfo; + this.writeOperation = writeOperation == null ? WriteOperation.INSERT : writeOperation; + } + + /** + * The connector session this sink's write plan reads. The insert executor binds the + * connector transaction onto it (via {@link ConnectorSession#setCurrentTransaction}) + * before {@code bindDataSink} runs, so the connector's {@code planWrite} sees the active + * transaction. + */ + public ConnectorSession getConnectorSession() { + return connectorSession; } @Override protected Set supportedFileFormatTypes() { - // Connector determines format through write config; accept all + // Connector determines format through its own write plan; accept all return EnumSet.allOf(TFileFormatType.class); } @@ -118,50 +137,43 @@ public String getExplainString(String prefix, TExplainLevel explainLevel) { if (explainLevel == TExplainLevel.BRIEF) { return sb.toString(); } - sb.append(prefix).append(" WRITE TYPE: ").append(writeConfig.getWriteType()).append("\n"); + sb.append(prefix).append(" WRITE: plan-provider\n"); sb.append(prefix).append(" TABLE: ").append(targetTable.getName()).append("\n"); - if (writeConfig.getWriteType() == ConnectorWriteType.JDBC_WRITE) { - Map props = writeConfig.getProperties(); - sb.append(prefix).append(" TABLE TYPE: ") - .append(props.getOrDefault(PROP_JDBC_TABLE_TYPE, "")).append("\n"); - sb.append(prefix).append(" INSERT SQL: ") - .append(props.getOrDefault(PROP_JDBC_INSERT_SQL, "")).append("\n"); - sb.append(prefix).append(" USE TRANSACTION: ") - .append(props.getOrDefault(PROP_JDBC_USE_TRANSACTION, "false")).append("\n"); - } else { - if (writeConfig.getFileFormat() != null) { - sb.append(prefix).append(" FORMAT: ").append(writeConfig.getFileFormat()).append("\n"); - } - if (writeConfig.getWriteLocation() != null) { - sb.append(prefix).append(" LOCATION: ").append(writeConfig.getWriteLocation()).append("\n"); - } - } + // Let the connector surface its own write detail (e.g. jdbc INSERT SQL); the sink itself is + // source-agnostic. This runs before the write plan is bound (planWrite has not run yet for an + // EXPLAIN), so the connector derives the detail from the write handle. + ConnectorWriteHandle handle = new PluginDrivenWriteHandle( + tableHandle, connectorColumns, false, Collections.emptyMap(), null, Optional.empty(), + writeOperation); + writePlanProvider.appendExplainInfo(sb, prefix, connectorSession, handle); return sb.toString(); } + /** + * Delegates sink construction to the connector, which returns its own opaque + * {@link TDataSink}; the engine dispatches it to BE unchanged. The + * {@link ConnectorWriteHandle} carries the bound target table handle and write columns. + * + *

    Connector-specific write context (OVERWRITE flag, static partition spec) is read from + * the {@link PluginDrivenInsertCommandContext} and passed through to the connector.

    + */ @Override public void bindDataSink(Optional insertCtx) throws AnalysisException { - ConnectorWriteType writeType = writeConfig.getWriteType(); - switch (writeType) { - case FILE_WRITE: - bindFileWriteSink(insertCtx); - break; - case JDBC_WRITE: - bindJdbcWriteSink(insertCtx); - break; - default: - throw new AnalysisException( - "Unsupported write type for plugin-driven sink: " + writeType); + boolean overwrite = false; + Map writeContext = Collections.emptyMap(); + Optional branchName = Optional.empty(); + if (insertCtx.isPresent() && insertCtx.get() instanceof PluginDrivenInsertCommandContext) { + PluginDrivenInsertCommandContext ctx = (PluginDrivenInsertCommandContext) insertCtx.get(); + overwrite = ctx.isOverwrite(); + writeContext = ctx.getStaticPartitionSpec(); + branchName = ctx.getBranchName(); } - } - - /** - * Returns the write config associated with this sink. - * Used by the insert executor to access connector write configuration. - */ - public ConnectorWriteConfig getWriteConfig() { - return writeConfig; + ConnectorWriteHandle handle = new PluginDrivenWriteHandle( + tableHandle, connectorColumns, overwrite, writeContext, writeSortInfo, branchName, + writeOperation); + ConnectorSinkPlan sinkPlan = writePlanProvider.planWrite(connectorSession, handle); + this.tDataSink = sinkPlan.getDataSink(); } /** @@ -171,143 +183,61 @@ public PluginDrivenExternalTable getTargetTable() { return targetTable; } - /** - * Builds a THiveTableSink for file-based writes. - * - *

    BE's Hive table sink is the generic file writer that handles - * Parquet/ORC/Text output. Connectors provide all necessary - * configuration through {@link ConnectorWriteConfig}.

    - */ - private void bindFileWriteSink(Optional insertCtx) - throws AnalysisException { - Map props = writeConfig.getProperties(); - THiveTableSink tSink = new THiveTableSink(); - - // DB and table names - tSink.setDbName(props.getOrDefault(PROP_DB_NAME, targetTable.getDbName())); - tSink.setTableName(props.getOrDefault(PROP_TABLE_NAME, targetTable.getName())); - - // Columns: build from target table schema + partition info from write config - Set partNames = new HashSet<>(writeConfig.getPartitionColumns()); - List allColumns = targetTable.getColumns(); - List targetColumns = new ArrayList<>(); - for (Column col : allColumns) { - THiveColumn tHiveColumn = new THiveColumn(); - tHiveColumn.setName(col.getName()); - tHiveColumn.setColumnType( - partNames.contains(col.getName()) - ? THiveColumnType.PARTITION_KEY - : THiveColumnType.REGULAR); - targetColumns.add(tHiveColumn); + /** Bound {@link ConnectorWriteHandle} passed to {@link ConnectorWritePlanProvider#planWrite}. */ + private static final class PluginDrivenWriteHandle implements ConnectorWriteHandle { + private final ConnectorTableHandle tableHandle; + private final List columns; + private final boolean overwrite; + private final Map writeContext; + private final TSortInfo sortInfo; + private final Optional branchName; + private final WriteOperation writeOperation; + + private PluginDrivenWriteHandle(ConnectorTableHandle tableHandle, List columns, + boolean overwrite, Map writeContext, TSortInfo sortInfo, + Optional branchName, WriteOperation writeOperation) { + this.tableHandle = tableHandle; + this.columns = columns; + this.overwrite = overwrite; + this.writeContext = writeContext; + this.sortInfo = sortInfo; + this.branchName = branchName == null ? Optional.empty() : branchName; + this.writeOperation = writeOperation == null ? WriteOperation.INSERT : writeOperation; } - tSink.setColumns(targetColumns); - // File format - if (writeConfig.getFileFormat() != null) { - TFileFormatType formatType = getTFileFormatType(writeConfig.getFileFormat()); - tSink.setFileFormat(formatType); + @Override + public TSortInfo getSortInfo() { + return sortInfo; } - // Compression - if (writeConfig.getCompression() != null) { - tSink.setCompressionType(getTFileCompressType(writeConfig.getCompression())); + @Override + public Optional getBranchName() { + return branchName; } - // Location - String writePath = props.getOrDefault(PROP_WRITE_PATH, writeConfig.getWriteLocation()); - String targetPath = props.getOrDefault(PROP_TARGET_PATH, writeConfig.getWriteLocation()); - if (writePath != null) { - THiveLocationParams locationParams = new THiveLocationParams(); - locationParams.setWritePath(writePath); - locationParams.setOriginalWritePath( - props.getOrDefault(PROP_ORIGINAL_WRITE_PATH, writePath)); - locationParams.setTargetPath(targetPath); - LocationPath locationPath = LocationPath.of(targetPath, - targetTable.getCatalog().getCatalogProperty().getStoragePropertiesMap()); - TFileType fileType = locationPath.getTFileTypeForBE(); - locationParams.setFileType(fileType); - tSink.setLocation(locationParams); - - if (fileType.equals(TFileType.FILE_BROKER)) { - tSink.setBrokerAddresses( - getBrokerAddresses(targetTable.getCatalog().bindBrokerName())); - } + @Override + public ConnectorTableHandle getTableHandle() { + return tableHandle; } - // Overwrite flag - if (props.containsKey(PROP_OVERWRITE)) { - tSink.setOverwrite(Boolean.parseBoolean(props.get(PROP_OVERWRITE))); + @Override + public List getColumns() { + return columns; } - // Hadoop/storage config for BE access - Map beStorageProps = targetTable.getCatalog() - .getCatalogProperty().getBackendStorageProperties(); - tSink.setHadoopConfig(beStorageProps); - - // Any extra connector-specific properties: pass through via hadoop_config - for (Map.Entry entry : props.entrySet()) { - String key = entry.getKey(); - if (!isWellKnownProperty(key)) { - tSink.putToHadoopConfig(key, entry.getValue()); - } + @Override + public boolean isOverwrite() { + return overwrite; } - tDataSink = new TDataSink(TDataSinkType.HIVE_TABLE_SINK); - tDataSink.setHiveTableSink(tSink); - } - - /** - * Builds a TJdbcTableSink for JDBC-based writes. - */ - private void bindJdbcWriteSink(Optional insertCtx) - throws AnalysisException { - Map props = writeConfig.getProperties(); - - TJdbcTableSink jdbcSink = new TJdbcTableSink(); - - TJdbcTable tJdbcTable = new TJdbcTable(); - tJdbcTable.setJdbcUrl(props.getOrDefault(PROP_JDBC_URL, "")); - tJdbcTable.setJdbcUser(props.getOrDefault(PROP_JDBC_USER, "")); - tJdbcTable.setJdbcPassword(props.getOrDefault(PROP_JDBC_PASSWORD, "")); - tJdbcTable.setJdbcDriverUrl(props.getOrDefault(PROP_JDBC_DRIVER_URL, "")); - tJdbcTable.setJdbcDriverClass(props.getOrDefault(PROP_JDBC_DRIVER_CLASS, "")); - tJdbcTable.setJdbcDriverChecksum(props.getOrDefault(PROP_JDBC_DRIVER_CHECKSUM, "")); - tJdbcTable.setJdbcTableName(props.getOrDefault(PROP_JDBC_TABLE_NAME, "")); - tJdbcTable.setJdbcResourceName(props.getOrDefault(PROP_JDBC_RESOURCE_NAME, "")); - tJdbcTable.setCatalogId(Long.parseLong(props.getOrDefault(PROP_JDBC_CATALOG_ID, "0"))); - tJdbcTable.setConnectionPoolMinSize( - Integer.parseInt(props.getOrDefault(PROP_JDBC_POOL_MIN, "1"))); - tJdbcTable.setConnectionPoolMaxSize( - Integer.parseInt(props.getOrDefault(PROP_JDBC_POOL_MAX, "10"))); - tJdbcTable.setConnectionPoolMaxWaitTime( - Integer.parseInt(props.getOrDefault(PROP_JDBC_POOL_MAX_WAIT, "5000"))); - tJdbcTable.setConnectionPoolMaxLifeTime( - Integer.parseInt(props.getOrDefault(PROP_JDBC_POOL_MAX_LIFE, "1800000"))); - tJdbcTable.setConnectionPoolKeepAlive( - Boolean.parseBoolean(props.getOrDefault(PROP_JDBC_POOL_KEEP_ALIVE, "false"))); - jdbcSink.setJdbcTable(tJdbcTable); - - String insertSql = props.getOrDefault(PROP_JDBC_INSERT_SQL, ""); - jdbcSink.setInsertSql(insertSql); - - boolean useTxn = Boolean.parseBoolean( - props.getOrDefault(PROP_JDBC_USE_TRANSACTION, "false")); - jdbcSink.setUseTransaction(useTxn); - - String tableType = props.getOrDefault(PROP_JDBC_TABLE_TYPE, ""); - if (!tableType.isEmpty()) { - jdbcSink.setTableType(TOdbcTableType.valueOf(tableType)); + @Override + public Map getWriteContext() { + return writeContext; } - tDataSink = new TDataSink(TDataSinkType.JDBC_TABLE_SINK); - tDataSink.setJdbcTableSink(jdbcSink); - } - - private boolean isWellKnownProperty(String key) { - return key.equals(PROP_DB_NAME) || key.equals(PROP_TABLE_NAME) - || key.equals(PROP_OVERWRITE) - || key.equals(PROP_WRITE_PATH) || key.equals(PROP_TARGET_PATH) - || key.equals(PROP_ORIGINAL_WRITE_PATH) - || key.startsWith("jdbc_"); + @Override + public WriteOperation getWriteOperation() { + return writeOperation; + } } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/ScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/ScanNode.java index e69d310154851f..b0982160ff14fb 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/ScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/ScanNode.java @@ -44,10 +44,10 @@ import org.apache.doris.common.Config; import org.apache.doris.common.Pair; import org.apache.doris.common.UserException; -import org.apache.doris.datasource.FederationBackendPolicy; -import org.apache.doris.datasource.SplitAssignment; -import org.apache.doris.datasource.SplitGenerator; -import org.apache.doris.datasource.SplitSource; +import org.apache.doris.datasource.scan.FederationBackendPolicy; +import org.apache.doris.datasource.split.SplitAssignment; +import org.apache.doris.datasource.split.SplitGenerator; +import org.apache.doris.datasource.split.SplitSource; import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/SchemaScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/SchemaScanNode.java index a76b77d6b8ac06..a2c0b4d962e9dd 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/SchemaScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/SchemaScanNode.java @@ -27,7 +27,7 @@ import org.apache.doris.common.Config; import org.apache.doris.common.UserException; import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.FederationBackendPolicy; +import org.apache.doris.datasource.scan.FederationBackendPolicy; import org.apache.doris.persist.gson.GsonUtils; import org.apache.doris.qe.ConnectContext; import org.apache.doris.service.FrontendOptions; diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java index c79b8d824ddade..ab5b37162c2ac0 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java @@ -283,11 +283,12 @@ public void setUserInsertTimeout(int insertTimeout) { } private StatementContext statementContext; - // internal flag to expose Iceberg rowid metadata during analysis/planning. - // When set to a valid table ID (>= 0), only that specific table's getFullSchema() - // will include __DORIS_ICEBERG_ROWID_COL__. This prevents ambiguity in MERGE INTO - // when the source table is also an Iceberg table. - private long icebergRowIdTargetTableId = -1; + // Internal flag to expose a connector's synthetic write column (the hidden row-identity column a + // row-level DML write needs) for a SINGLE target table during analysis/planning. When set to a valid + // table ID (>= 0), only that table's getFullSchema() injects its synthetic write column (today the + // only consumer is iceberg's __DORIS_ICEBERG_ROWID_COL__). Scoping it to one table prevents ambiguity + // in MERGE INTO when the source table is also a write-capable table of the same format. + private long syntheticWriteColTargetTableId = -1; // new planner private Map preparedStatementContextMap = Maps.newHashMap(); @@ -1157,24 +1158,24 @@ public void setStatementContext(StatementContext statementContext) { this.statementContext = statementContext; } - /** Backward-compatible: returns true if any Iceberg table is targeted for row_id injection. */ - public boolean needIcebergRowId() { - return icebergRowIdTargetTableId >= 0; + /** Returns true if any table is targeted for synthetic write-column injection. */ + public boolean needsSyntheticWriteCol() { + return syntheticWriteColTargetTableId >= 0; } - /** Check if a specific table should include the hidden row_id column. */ - public boolean needIcebergRowIdForTable(long tableId) { - return icebergRowIdTargetTableId >= 0 && icebergRowIdTargetTableId == tableId; + /** Check if a specific table should inject its hidden synthetic write column. */ + public boolean needsSyntheticWriteColForTable(long tableId) { + return syntheticWriteColTargetTableId >= 0 && syntheticWriteColTargetTableId == tableId; } - /** Set the target table ID for row_id injection. Use -1 to clear. */ - public void setIcebergRowIdTargetTableId(long tableId) { - this.icebergRowIdTargetTableId = tableId; + /** Set the target table ID for synthetic write-column injection. Use -1 to clear. */ + public void setSyntheticWriteColTargetTableId(long tableId) { + this.syntheticWriteColTargetTableId = tableId; } - /** Get the previously saved target table ID (for save/restore pattern). */ - public long getIcebergRowIdTargetTableId() { - return icebergRowIdTargetTableId; + /** Get the previously saved target table ID (for the save/restore pattern). */ + public long getSyntheticWriteColTargetTableId() { + return syntheticWriteColTargetTableId; } diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java index 72bbf95a372ce9..c83397f58dc0e7 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java @@ -784,6 +784,16 @@ public TMasterOpResult proxyExecute(TMasterOpRequest request) throws TException // If reach here, maybe Doris bug. LOG.warn("Process one query failed because unknown reason: ", e); ctx.getState().setError(ErrorCode.ERR_UNKNOWN_ERROR, "Unexpected exception: " + e.getMessage()); + } finally { + // Master-side forwarded statements (redirect DDL / SHOW) execute via Command.run and never reach + // unregisterQuery, so their per-statement connector scope has no query-finish trigger. Close it here + // via StatementContext.close() (the same per-statement close the direct-connection path runs in its + // finally). Idempotent: a coordinated forwarded query's scope is already closed by its query-finish + // callback. These statements run synchronously with no off-thread scan pump, so close-after-use holds. + StatementContext forwardedStatementContext = ctx.getStatementContext(); + if (forwardedStatementContext != null) { + forwardedStatementContext.close(); + } } // no matter the master execute success or fail, the master must transfer the result to follower // and tell the follower the current journalID. diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java b/fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java index 74e0128e8dad38..7edb7e602e6246 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java @@ -36,11 +36,8 @@ import org.apache.doris.common.util.DebugUtil; import org.apache.doris.common.util.ListUtil; import org.apache.doris.common.util.TimeUtils; -import org.apache.doris.datasource.ExternalScanNode; -import org.apache.doris.datasource.FileQueryScanNode; -import org.apache.doris.datasource.hive.HMSTransaction; -import org.apache.doris.datasource.iceberg.IcebergTransaction; -import org.apache.doris.datasource.maxcompute.MCTransaction; +import org.apache.doris.datasource.scan.ExternalScanNode; +import org.apache.doris.datasource.scan.FileQueryScanNode; import org.apache.doris.load.loadv2.LoadJob; import org.apache.doris.metric.MetricRepo; import org.apache.doris.mysql.MysqlCommand; @@ -130,6 +127,8 @@ import org.apache.doris.thrift.TTabletCommitInfo; import org.apache.doris.thrift.TTopnFilterDesc; import org.apache.doris.thrift.TUniqueId; +import org.apache.doris.transaction.CommitDataSerializer; +import org.apache.doris.transaction.Transaction; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; @@ -2632,17 +2631,17 @@ public void updateFragmentExecStatus(TReportExecStatusParams params) { if (params.isSetErrorTabletInfos()) { updateErrorTabletInfos(params.getErrorTabletInfos()); } - if (params.isSetHivePartitionUpdates()) { - ((HMSTransaction) Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().getTxnById(txnId)) - .updateHivePartitionUpdates(params.getHivePartitionUpdates()); - } - if (params.isSetIcebergCommitDatas()) { - ((IcebergTransaction) Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().getTxnById(txnId)) - .updateIcebergCommitData(params.getIcebergCommitDatas()); - } - if (params.isSetMcCommitDatas()) { - ((MCTransaction) Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().getTxnById(txnId)) - .updateMCCommitData(params.getMcCommitDatas()); + if (params.isSetHivePartitionUpdates() || params.isSetIcebergCommitDatas() || params.isSetMcCommitDatas()) { + Transaction txn = Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().getTxnById(txnId); + if (params.isSetHivePartitionUpdates()) { + CommitDataSerializer.feed(txn, params.getHivePartitionUpdates()); + } + if (params.isSetIcebergCommitDatas()) { + CommitDataSerializer.feed(txn, params.getIcebergCommitDatas()); + } + if (params.isSetMcCommitDatas()) { + CommitDataSerializer.feed(txn, params.getMcCommitDatas()); + } } if (ctx.done) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/QeProcessor.java b/fe/fe-core/src/main/java/org/apache/doris/qe/QeProcessor.java index c5aff2c9d5c11b..39a56deed6977b 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/QeProcessor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/QeProcessor.java @@ -37,6 +37,14 @@ public interface QeProcessor { void unregisterQuery(TUniqueId queryId); + /** + * Register a callback to run when the given query finishes (is unregistered). + * Connector-agnostic: lets a connector hook query completion (for example to + * commit a read transaction / release a metastore lock) without fe-core + * naming the connector in its generic query-cleanup path. + */ + void registerQueryFinishCallback(String queryId, Runnable callback); + Map getQueryStatistics(); String getCurrentQueryByQueryId(TUniqueId queryId); diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/QeProcessorImpl.java b/fe/fe-core/src/main/java/org/apache/doris/qe/QeProcessorImpl.java index ff023aeb9394de..2c4202c3078757 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/QeProcessorImpl.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/QeProcessorImpl.java @@ -57,6 +57,7 @@ public final class QeProcessorImpl implements QeProcessor { private Map queryToInstancesNum; private Map userToInstancesCount; private ExecutorService writeProfileExecutor; + private final QueryFinishCallbackRegistry queryFinishCallbackRegistry = new QueryFinishCallbackRegistry(); public static final QeProcessor INSTANCE; @@ -206,8 +207,14 @@ public void unregisterQuery(TUniqueId queryId) { } } - // commit hive tranaction if needed - Env.getCurrentHiveTransactionMgr().deregister(DebugUtil.printId(queryId)); + // Run connector-registered query-finish callbacks (e.g. committing a hive + // read transaction). Connector-agnostic: fe-core names no source here. + queryFinishCallbackRegistry.runAndClear(DebugUtil.printId(queryId)); + } + + @Override + public void registerQueryFinishCallback(String queryId, Runnable callback) { + queryFinishCallbackRegistry.register(queryId, callback); } @Override diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/QueryFinishCallbackRegistry.java b/fe/fe-core/src/main/java/org/apache/doris/qe/QueryFinishCallbackRegistry.java new file mode 100644 index 00000000000000..1dda4f34b94198 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/QueryFinishCallbackRegistry.java @@ -0,0 +1,76 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.qe; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Connector-agnostic registry of callbacks to run when a query finishes. + * + *

    fe-core owns the query lifecycle: when a query is unregistered (see + * {@link QeProcessorImpl#unregisterQuery}), all callbacks registered for that + * query id are run exactly once and then removed. This lets any connector hook + * query completion (for example committing a hive read transaction or releasing + * a metastore lock) without fe-core naming the connector in its generic + * query-cleanup path, aligning with the engine-driven query/transaction + * lifecycle used by systems such as Trino. + * + *

    Callbacks are best-effort: a failure in one callback is logged and does + * not prevent the remaining callbacks (or the rest of query cleanup) from + * running. + */ +public class QueryFinishCallbackRegistry { + private static final Logger LOG = LogManager.getLogger(QueryFinishCallbackRegistry.class); + + private final Map> callbacks = new ConcurrentHashMap<>(); + + /** + * Register a callback to run when the query with the given id finishes. + * Multiple callbacks may be registered for one query; they run in + * registration order. + */ + public void register(String queryId, Runnable callback) { + callbacks.computeIfAbsent(queryId, k -> new CopyOnWriteArrayList<>()).add(callback); + } + + /** + * Run and remove all callbacks registered for the given query. Idempotent: + * a second call, or a call for a query with no callbacks, is a no-op. + * Exceptions thrown by an individual callback are isolated so that one + * connector's failing cleanup cannot block another's. + */ + public void runAndClear(String queryId) { + List queryCallbacks = callbacks.remove(queryId); + if (queryCallbacks == null) { + return; + } + for (Runnable callback : queryCallbacks) { + try { + callback.run(); + } catch (Exception e) { + LOG.warn("query finish callback failed for query {}", queryId, e); + } + } + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java index 795a81c8a76ed7..7bad37645b7cae 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java @@ -64,7 +64,7 @@ import org.apache.doris.common.util.TimeUtils; import org.apache.doris.common.util.UniqueIdUtils; import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.FileScanNode; +import org.apache.doris.datasource.scan.FileScanNode; import org.apache.doris.datasource.tvf.source.TVFScanNode; import org.apache.doris.filesystem.FileSystemUtil; import org.apache.doris.filesystem.Location; @@ -1069,6 +1069,13 @@ private void handleQueryWithRetry(TUniqueId queryId) throws Exception { DebugUtil.printId(queryId), i, DebugUtil.printId(newQueryId)); context.setQueryId(newQueryId); context.setNeedRegenerateInstanceId(newQueryId); + // Each retry attempt gets a fresh per-statement connector scope. The previous attempt's scope + // was closed by its own query-finish callback (registered under the previous query id), so + // reusing it would memoize into an already-closed scope whose values then never close. Reset + // closes (idempotent) and drops it; this attempt builds a fresh one under the new query id. + if (context.getStatementContext() != null) { + context.getStatementContext().resetConnectorStatementScope(); + } if (Config.isCloudMode()) { // sleep random millis [1000, 1500] ms // in the begining of retryTime/2 diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/cache/CacheAnalyzer.java b/fe/fe-core/src/main/java/org/apache/doris/qe/cache/CacheAnalyzer.java index 1736aeeb1ad5a8..83ce3ef3f1497e 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/cache/CacheAnalyzer.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/cache/CacheAnalyzer.java @@ -34,8 +34,8 @@ import org.apache.doris.common.UserException; import org.apache.doris.common.util.DebugUtil; import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.hive.source.HiveScanNode; import org.apache.doris.metric.MetricRepo; +import org.apache.doris.mtmv.MTMVRelatedTableIf; import org.apache.doris.nereids.NereidsPlanner; import org.apache.doris.nereids.SqlCacheContext; import org.apache.doris.nereids.SqlCacheContext.FullTableName; @@ -301,24 +301,21 @@ private List buildCacheTableList() { // Check the last version time of the table MetricRepo.COUNTER_QUERY_TABLE.increase(1L); long olapScanNodeSize = 0; - long hiveScanNodeSize = 0; + long externalCacheableSize = 0; for (ScanNode scanNode : scanNodes) { if (scanNode instanceof OlapScanNode) { olapScanNodeSize++; - } else if (scanNode instanceof HiveScanNode) { - hiveScanNodeSize++; + } else if (isExternalCacheableScanNode(scanNode)) { + externalCacheableSize++; } } if (olapScanNodeSize > 0) { MetricRepo.COUNTER_QUERY_OLAP_TABLE.increase(1L); } - if (hiveScanNodeSize > 0) { - MetricRepo.COUNTER_QUERY_HIVE_TABLE.increase(1L); - } - if (!(olapScanNodeSize == scanNodes.size() || hiveScanNodeSize == scanNodes.size())) { + if (!(olapScanNodeSize == scanNodes.size() || externalCacheableSize == scanNodes.size())) { if (LOG.isDebugEnabled()) { - LOG.debug("only support olap/hive table with non-federated query, " + LOG.debug("only support olap/external table with non-federated query, " + "other types are not supported now, queryId {}", DebugUtil.printId(queryId)); } return Collections.emptyList(); @@ -329,7 +326,7 @@ private List buildCacheTableList() { ScanNode node = scanNodes.get(i); CacheTable cTable = node instanceof OlapScanNode ? buildCacheTableForOlapScanNode((OlapScanNode) node) - : buildCacheTableForHiveScanNode((HiveScanNode) node); + : buildCacheTableForExternalScanNode(node); tblTimeList.add(cTable); } Collections.sort(tblTimeList); @@ -469,12 +466,27 @@ private CacheTable buildCacheTableForOlapScanNode(OlapScanNode node) { return cacheTable; } - private CacheTable buildCacheTableForHiveScanNode(HiveScanNode node) { + /** + * A non-Olap scan node is cacheable iff its target table exposes a stable data-version token + * (implements {@link MTMVRelatedTableIf}). Keying on the target-table capability rather than the scan + * node class is connector-agnostic and robust: it recognizes every lakehouse plugin table (hive / + * iceberg / paimon / hudi, whether scanned via {@code PluginDrivenScanNode} or a legacy + * {@code HudiScanNode}) while excluding token-less nodes such as {@code jdbc_query(...)} TVFs (backed by + * a {@code FunctionGenTable}) and system-table scans. + */ + private boolean isExternalCacheableScanNode(ScanNode scanNode) { + return scanNode.getTupleDesc() != null + && scanNode.getTupleDesc().getTable() instanceof MTMVRelatedTableIf; + } + + private CacheTable buildCacheTableForExternalScanNode(ScanNode node) { CacheTable cacheTable = new CacheTable(); - cacheTable.table = node.getTargetTable(); + TableIf tableIf = node.getTupleDesc().getTable(); + cacheTable.table = tableIf; cacheTable.partitionNum = node.getSelectedPartitionNum(); - cacheTable.latestPartitionTime = cacheTable.table.getUpdateTime(); - TableIf tableIf = cacheTable.table; + // Connector-agnostic data-version token (hive: max transient_lastDdlTime; iceberg/paimon: monotonic + // snapshot version). Gated to MTMVRelatedTableIf tables by isExternalCacheableScanNode above. + cacheTable.latestPartitionTime = ((MTMVRelatedTableIf) tableIf).getNewestUpdateVersionOrTime(); DatabaseIf database = tableIf.getDatabase(); CatalogIf catalog = database.getCatalog(); ScanTable scanTable = new ScanTable(new FullTableName( diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/LoadProcessor.java b/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/LoadProcessor.java index 4e5a6d804cd372..069cd5e8a8924b 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/LoadProcessor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/LoadProcessor.java @@ -21,9 +21,6 @@ import org.apache.doris.common.MarkedCountDownLatch; import org.apache.doris.common.Status; import org.apache.doris.common.util.DebugUtil; -import org.apache.doris.datasource.hive.HMSTransaction; -import org.apache.doris.datasource.iceberg.IcebergTransaction; -import org.apache.doris.datasource.maxcompute.MCTransaction; import org.apache.doris.nereids.util.Utils; import org.apache.doris.qe.AbstractJobProcessor; import org.apache.doris.qe.CoordinatorContext; @@ -31,6 +28,8 @@ import org.apache.doris.thrift.TFragmentInstanceReport; import org.apache.doris.thrift.TReportExecStatusParams; import org.apache.doris.thrift.TUniqueId; +import org.apache.doris.transaction.CommitDataSerializer; +import org.apache.doris.transaction.Transaction; import com.google.common.collect.Lists; import org.apache.logging.log4j.LogManager; @@ -222,17 +221,17 @@ protected void doProcessReportExecStatus(TReportExecStatusParams params, SingleF loadContext.updateErrorTabletInfos(params.getErrorTabletInfos()); } long txnId = loadContext.getTransactionId(); - if (params.isSetHivePartitionUpdates()) { - ((HMSTransaction) Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().getTxnById(txnId)) - .updateHivePartitionUpdates(params.getHivePartitionUpdates()); - } - if (params.isSetIcebergCommitDatas()) { - ((IcebergTransaction) Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().getTxnById(txnId)) - .updateIcebergCommitData(params.getIcebergCommitDatas()); - } - if (params.isSetMcCommitDatas()) { - ((MCTransaction) Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().getTxnById(txnId)) - .updateMCCommitData(params.getMcCommitDatas()); + if (params.isSetHivePartitionUpdates() || params.isSetIcebergCommitDatas() || params.isSetMcCommitDatas()) { + Transaction txn = Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().getTxnById(txnId); + if (params.isSetHivePartitionUpdates()) { + CommitDataSerializer.feed(txn, params.getHivePartitionUpdates()); + } + if (params.isSetIcebergCommitDatas()) { + CommitDataSerializer.feed(txn, params.getIcebergCommitDatas()); + } + if (params.isSetMcCommitDatas()) { + CommitDataSerializer.feed(txn, params.getMcCommitDatas()); + } } if (fragmentTask.isDone()) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/ThriftPlansBuilder.java b/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/ThriftPlansBuilder.java index 4a7349a37f7b13..1a239e3122a365 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/ThriftPlansBuilder.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/ThriftPlansBuilder.java @@ -23,7 +23,7 @@ import org.apache.doris.catalog.Env; import org.apache.doris.catalog.Resource; import org.apache.doris.common.Config; -import org.apache.doris.datasource.FileQueryScanNode; +import org.apache.doris.datasource.scan.FileQueryScanNode; import org.apache.doris.nereids.StatementContext; import org.apache.doris.nereids.trees.plans.distribute.DistributedPlan; import org.apache.doris.nereids.trees.plans.distribute.PipelineDistributedPlan; diff --git a/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java b/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java index b737ed2bc9496e..a930fc0ddd1cc1 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java +++ b/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java @@ -89,8 +89,7 @@ import org.apache.doris.datasource.ExternalCatalog; import org.apache.doris.datasource.ExternalDatabase; import org.apache.doris.datasource.InternalCatalog; -import org.apache.doris.datasource.SplitSource; -import org.apache.doris.datasource.maxcompute.MCTransaction; +import org.apache.doris.datasource.split.SplitSource; import org.apache.doris.encryption.EncryptionKey; import org.apache.doris.ha.FrontendNodeType; import org.apache.doris.info.TableRefInfo; @@ -3885,12 +3884,12 @@ public TMaxComputeBlockIdResult getMaxComputeBlockIdRange(TMaxComputeBlockIdRequ try { Transaction transaction = Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr() .getTxnById(request.getTxnId()); - if (!(transaction instanceof MCTransaction)) { + if (!transaction.supportsWriteBlockAllocation()) { throw new UserException("Transaction " + request.getTxnId() + " is not a MaxCompute transaction"); } - long start = ((MCTransaction) transaction).allocateBlockIdRange( + long start = transaction.allocateWriteBlockRange( request.getWriteSessionId(), request.getLength()); result.setStart(start); result.setLength(request.getLength()); diff --git a/fe/fe-core/src/main/java/org/apache/doris/spi/Split.java b/fe/fe-core/src/main/java/org/apache/doris/spi/Split.java index 1212841d0290e6..48b6acd68e39c2 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/spi/Split.java +++ b/fe/fe-core/src/main/java/org/apache/doris/spi/Split.java @@ -17,7 +17,7 @@ package org.apache.doris.spi; -import org.apache.doris.datasource.SplitWeight; +import org.apache.doris.datasource.split.SplitWeight; import java.util.List; diff --git a/fe/fe-core/src/main/java/org/apache/doris/statistics/AnalysisManager.java b/fe/fe-core/src/main/java/org/apache/doris/statistics/AnalysisManager.java index b2b4c0d57f63a1..faac932743fbd5 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/statistics/AnalysisManager.java +++ b/fe/fe-core/src/main/java/org/apache/doris/statistics/AnalysisManager.java @@ -43,7 +43,7 @@ import org.apache.doris.common.util.Util; import org.apache.doris.datasource.CatalogIf; import org.apache.doris.datasource.InternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.info.TableNameInfoUtils; import org.apache.doris.metric.MetricRepo; import org.apache.doris.mysql.privilege.PrivPredicate; @@ -1481,8 +1481,14 @@ public boolean canSample(TableIf table) { if (table instanceof OlapTable) { return true; } - return table instanceof HMSExternalTable - && ((HMSExternalTable) table).getDlaType().equals(HMSExternalTable.DLAType.HIVE); + // Additive: a flipped plain-hive table is a PluginDrivenExternalTable declaring SUPPORTS_SAMPLE_ANALYZE + // per-table (iceberg/hudi-on-HMS and native iceberg/paimon do NOT declare it). Keeps the legacy + // HMSExternalTable arm below live for the un-flipped path, like StatisticsUtil.supportAutoAnalyze. + if (table instanceof PluginDrivenExternalTable + && ((PluginDrivenExternalTable) table).supportsSampleAnalyze()) { + return true; + } + return false; } diff --git a/fe/fe-core/src/main/java/org/apache/doris/statistics/HMSAnalysisTask.java b/fe/fe-core/src/main/java/org/apache/doris/statistics/HMSAnalysisTask.java deleted file mode 100644 index 3b611f60000cdd..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/statistics/HMSAnalysisTask.java +++ /dev/null @@ -1,403 +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.statistics; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.DdlException; -import org.apache.doris.common.Pair; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HiveExternalMetaCache; -import org.apache.doris.datasource.hive.HiveUtil; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.statistics.util.StatisticsUtil; - -import com.google.common.collect.Sets; -import org.apache.commons.text.StringSubstitutor; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Random; -import java.util.Set; - -public class HMSAnalysisTask extends ExternalAnalysisTask { - private static final Logger LOG = LogManager.getLogger(HMSAnalysisTask.class); - private HMSExternalTable hmsExternalTable; - - // for test - public HMSAnalysisTask() { - } - - public HMSAnalysisTask(AnalysisInfo info) { - super(info); - hmsExternalTable = (HMSExternalTable) tbl; - } - - private boolean isPartitionColumn() { - return hmsExternalTable.getPartitionColumns().stream().anyMatch(c -> c.getName().equals(col.getName())); - } - - // For test - protected void setTable(HMSExternalTable table) { - setTable((ExternalTable) table); - this.hmsExternalTable = table; - } - - @Override - public void doExecute() throws Exception { - if (killed) { - return; - } - if (info.usingSqlForExternalTable) { - // If user specify with sql in analyze statement, using sql to collect stats. - super.doExecute(); - } else { - // By default, using HMS stats and partition info to collect stats. - try { - if (StatisticsUtil.enablePartitionAnalyze() && tbl.isPartitionedTable()) { - throw new RuntimeException("HMS doesn't support fetch partition level stats."); - } - if (isPartitionColumn()) { - getPartitionColumnStats(); - } else { - getHmsColumnStats(); - } - } catch (Exception e) { - LOG.info("Failed to collect stats for {}col {} using metadata, " - + "fallback to normal collection. Because {}", - isPartitionColumn() ? "partition " : "", col.getName(), e.getMessage()); - /* retry using sql way! */ - super.doExecute(); - } - } - } - - // Collect the partition column stats through HMS metadata. - // Get all the partition values and calculate the stats based on the values. - private void getPartitionColumnStats() { - Set partitionNames = hmsExternalTable.getPartitionNames(); - Set ndvPartValues = Sets.newHashSet(); - long numNulls = 0; - long dataSize = 0; - String min = null; - String max = null; - for (String names : partitionNames) { - // names is like "date=20230101" for one level partition - // and like "date=20230101/hour=12" for two level partition - List parts = HiveUtil.toPartitionColNameAndValues(names); - for (String[] part : parts) { - String colName = part[0]; - String value = part[1]; - if (colName != null && colName.equals(col.getName())) { - // HIVE_DEFAULT_PARTITION hive partition value when the partition name is not specified. - if (value == null || value.isEmpty() - || value.equals(HiveExternalMetaCache.HIVE_DEFAULT_PARTITION)) { - numNulls += 1; - continue; - } - ndvPartValues.add(value); - dataSize += col.getType().isStringType() ? value.length() : col.getType().getSlotSize(); - min = updateMinValue(min, value); - max = updateMaxValue(max, value); - } - } - } - // getRowCount may return 0 if cache is empty, in this case, call fetchRowCount. - long count = hmsExternalTable.getRowCount(); - if (count == 0) { - count = hmsExternalTable.fetchRowCount(); - } - dataSize = dataSize * count / partitionNames.size(); - numNulls = numNulls * count / partitionNames.size(); - int ndv = ndvPartValues.size(); - - Map params = buildSqlParams(); - params.put("row_count", String.valueOf(count)); - params.put("ndv", String.valueOf(ndv)); - params.put("null_count", String.valueOf(numNulls)); - params.put("min", StatisticsUtil.quote(min)); - params.put("max", StatisticsUtil.quote(max)); - params.put("data_size", String.valueOf(dataSize)); - StringSubstitutor stringSubstitutor = new StringSubstitutor(params); - String sql = stringSubstitutor.replace(ANALYZE_PARTITION_COLUMN_TEMPLATE); - runQuery(sql); - } - - // Collect the spark analyzed column stats through HMS metadata. - private void getHmsColumnStats() throws Exception { - // getRowCount may return 0 if cache is empty, in this case, call fetchRowCount. - long count = hmsExternalTable.getRowCount(); - if (count == 0) { - count = hmsExternalTable.fetchRowCount(); - } - - Map params = buildSqlParams(); - Map statsParams = new HashMap<>(); - statsParams.put(StatsType.NDV, "ndv"); - statsParams.put(StatsType.NUM_NULLS, "null_count"); - statsParams.put(StatsType.MIN_VALUE, "min"); - statsParams.put(StatsType.MAX_VALUE, "max"); - statsParams.put(StatsType.AVG_SIZE, "avg_len"); - - if (!hmsExternalTable.fillColumnStatistics(info.colName, statsParams, params)) { - throw new AnalysisException("some column stats not available"); - } - - long dataSize = Long.parseLong(params.get("avg_len")) * count; - params.put("row_count", String.valueOf(count)); - params.put("data_size", String.valueOf(dataSize)); - - StringSubstitutor stringSubstitutor = new StringSubstitutor(params); - String sql = stringSubstitutor.replace(ANALYZE_PARTITION_COLUMN_TEMPLATE); - runQuery(sql); - } - - private String updateMinValue(String currentMin, String value) { - if (currentMin == null) { - return value; - } - if (col.getType().isFixedPointType()) { - if (Long.parseLong(value) < Long.parseLong(currentMin)) { - return value; - } else { - return currentMin; - } - } - if (col.getType().isFloatingPointType() || col.getType().isDecimalV2() || col.getType().isDecimalV3()) { - if (Double.parseDouble(value) < Double.parseDouble(currentMin)) { - return value; - } else { - return currentMin; - } - } - return value.compareTo(currentMin) < 0 ? value : currentMin; - } - - private String updateMaxValue(String currentMax, String value) { - if (currentMax == null) { - return value; - } - if (col.getType().isFixedPointType()) { - if (Long.parseLong(value) > Long.parseLong(currentMax)) { - return value; - } else { - return currentMax; - } - } - if (col.getType().isFloatingPointType() || col.getType().isDecimalV2() || col.getType().isDecimalV3()) { - if (Double.parseDouble(value) > Double.parseDouble(currentMax)) { - return value; - } else { - return currentMax; - } - } - return value.compareTo(currentMax) > 0 ? value : currentMax; - } - - @Override - protected void doSample() { - StringBuilder sb = new StringBuilder(); - Map params = buildSqlParams(); - params.put("min", getMinFunction()); - params.put("max", getMaxFunction()); - params.put("dataSizeFunction", getDataSizeFunction(col, false)); - Pair sampleInfo = getSampleInfo(); - params.put("scaleFactor", String.valueOf(sampleInfo.first)); - params.put("hotValueCollectCount", String.valueOf(SessionVariable.getHotValueCollectCount())); - if (LOG.isDebugEnabled()) { - LOG.debug("Will do sample collection for column {}", col.getName()); - } - boolean limitFlag = false; - boolean bucketFlag = false; - // If sample size is too large, use limit to control the sample size. - if (needLimit(sampleInfo.second, sampleInfo.first)) { - limitFlag = true; - long columnSize = 0; - for (Column column : table.getFullSchema()) { - columnSize += column.getDataType().getSlotSize(); - } - double targetRows = (double) sampleInfo.second / columnSize; - // Estimate the new scaleFactor based on the schema. - if (targetRows > StatisticsUtil.getHugeTableSampleRows()) { - params.put("limit", "limit " + StatisticsUtil.getHugeTableSampleRows()); - params.put("scaleFactor", - String.valueOf(sampleInfo.first * targetRows / StatisticsUtil.getHugeTableSampleRows())); - } - } - // Single distribution column is not fit for DUJ1 estimator, use linear estimator. - Set distributionColumns = tbl.getDistributionColumnNames(); - if (distributionColumns.size() == 1 && distributionColumns.contains(col.getName().toLowerCase())) { - bucketFlag = true; - sb.append(LINEAR_ANALYZE_TEMPLATE); - params.put("ndvFunction", "ROUND(NDV(`${colName}`) * ${scaleFactor})"); - params.put("rowCount", "ROUND(COUNT(1) * ${scaleFactor})"); - params.put("rowCount2", "(SELECT COUNT(1) FROM cte1 WHERE `${colName}` IS NOT NULL)"); - } else { - sb.append(DUJ1_ANALYZE_TEMPLATE); - params.put("subStringColName", getStringTypeColName(col)); - params.put("dataSizeFunction", getDataSizeFunction(col, true)); - params.put("ndvFunction", getNdvFunction("ROUND(SUM(t1.count) * ${scaleFactor})")); - params.put("rowCount", "ROUND(SUM(t1.count) * ${scaleFactor})"); - params.put("rowCount2", "(SELECT SUM(`count`) FROM cte1 WHERE `col_value` IS NOT NULL)"); - } - LOG.info("Sample for column [{}]. Scale factor [{}], " - + "limited [{}], is distribute column [{}]", - col.getName(), params.get("scaleFactor"), limitFlag, bucketFlag); - StringSubstitutor stringSubstitutor = new StringSubstitutor(params); - String sql = stringSubstitutor.replace(sb.toString()); - runQuery(sql); - } - - @Override - protected void doFull() throws Exception { - if (StatisticsUtil.enablePartitionAnalyze() && tbl.isPartitionedTable()) { - doPartitionTable(); - } else { - super.doFull(); - } - } - - @Override - protected void deleteNotExistPartitionStats(AnalysisInfo jobInfo) throws DdlException { - TableStatsMeta tableStats = Env.getServingEnv().getAnalysisManager().findTableStatsStatus(tbl.getId()); - if (tableStats == null) { - return; - } - String indexName = table.getName(); - ColStatsMeta columnStats = tableStats.findColumnStatsMeta(indexName, info.colName); - if (columnStats == null) { - return; - } - // For external table, simply remove all partition stats for the given column and re-analyze it again. - String columnCondition = "AND col_id = " + StatisticsUtil.quote(col.getName()); - StatisticsRepository.dropPartitionsColumnStatistics(info.catalogId, info.dbId, info.tblId, - columnCondition, ""); - } - - @Override - protected String getPartitionInfo(String partitionName) { - // partitionName is like "date=20230101" for one level partition - // and like "date=20230101/hour=12" for two level partition - String[] parts = partitionName.split("/"); - if (parts.length == 0) { - throw new RuntimeException("Invalid partition name " + partitionName); - } - StringBuilder sb = new StringBuilder(); - sb.append(" WHERE "); - for (int i = 0; i < parts.length; i++) { - String[] split = parts[i].split("="); - if (split.length != 2 || split[0].isEmpty() || split[1].isEmpty()) { - throw new RuntimeException("Invalid partition name " + partitionName); - } - sb.append("`"); - sb.append(split[0]); - sb.append("`"); - sb.append(" = "); - sb.append("'"); - sb.append(split[1]); - sb.append("'"); - if (i < parts.length - 1) { - sb.append(" AND "); - } - } - return sb.toString(); - } - - protected String getSampleHint() { - if (tableSample == null) { - return ""; - } - if (tableSample.isPercent()) { - return String.format("TABLESAMPLE(%d PERCENT)", tableSample.getSampleValue()); - } else { - return String.format("TABLESAMPLE(%d ROWS)", tableSample.getSampleValue()); - } - } - - /** - * Get the pair of sample scale factor and the file size going to sample. - * While analyzing, the result of count, null count and data size need to - * multiply this scale factor to get more accurate result. - * @return Pair of sample scale factor and the file size going to sample. - */ - protected Pair getSampleInfo() { - if (tableSample == null) { - return Pair.of(1.0, 0L); - } - long target; - // Get list of all files' size in this HMS table. - List chunkSizes = table.getChunkSizes(); - Collections.shuffle(chunkSizes, new Random(tableSample.getSeek())); - long total = 0; - // Calculate the total size of this HMS table. - for (long size : chunkSizes) { - total += size; - } - if (total == 0) { - return Pair.of(1.0, 0L); - } - // Calculate the sample target size for percent and rows sample. - if (tableSample.isPercent()) { - target = total * tableSample.getSampleValue() / 100; - } else { - int columnSize = 0; - for (Column column : table.getFullSchema()) { - columnSize += column.getDataType().getSlotSize(); - } - target = columnSize * tableSample.getSampleValue(); - } - // Calculate the actual sample size (cumulate). - long cumulate = 0; - for (long size : chunkSizes) { - cumulate += size; - if (cumulate >= target) { - break; - } - } - return Pair.of(Math.max(((double) total) / cumulate, 1), cumulate); - } - - /** - * If the size to sample is larger than LIMIT_SIZE (1GB) - * and is much larger (1.2*) than the size user want to sample, - * use limit to control the total sample size. - * @param sizeToRead The file size to sample. - * @param factor sizeToRead * factor = Table total size. - * @return True if need to limit. - */ - protected boolean needLimit(long sizeToRead, double factor) { - long total = (long) (sizeToRead * factor); - long target; - if (tableSample.isPercent()) { - target = total * tableSample.getSampleValue() / 100; - } else { - int columnSize = 0; - for (Column column : table.getFullSchema()) { - columnSize += column.getDataType().getSlotSize(); - } - target = columnSize * tableSample.getSampleValue(); - } - return sizeToRead > LIMIT_SIZE && sizeToRead > target * LIMIT_FACTOR; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/statistics/PluginDrivenSampleAnalysisTask.java b/fe/fe-core/src/main/java/org/apache/doris/statistics/PluginDrivenSampleAnalysisTask.java new file mode 100644 index 00000000000000..220c985ee1eda2 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/statistics/PluginDrivenSampleAnalysisTask.java @@ -0,0 +1,174 @@ +// 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.statistics; + +import org.apache.doris.catalog.Column; +import org.apache.doris.common.Pair; +import org.apache.doris.qe.SessionVariable; +import org.apache.doris.statistics.util.StatisticsUtil; + +import org.apache.commons.text.StringSubstitutor; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; + +/** + * Sample-capable analyze task for a flipped plugin-driven file-scan table (plain-hive after the HMS cutover). + * + *

    The generic {@link ExternalAnalysisTask} throws {@code NotImplementedException} from {@link #doSample()}, + * which is correct for connectors that cannot serve raw per-file sizes (iceberg/paimon/JDBC/ES). A plain-hive + * table CAN (legacy {@code HMSExternalTask.doSample}), so {@code PluginDrivenExternalTable.createAnalysisTask} + * hands back THIS task when the connector declares {@code SUPPORTS_SAMPLE_ANALYZE} per-table. The + * {@code doSample}/{@code getSampleInfo}/{@code needLimit} bodies are a verbatim port of the legacy + * {@code HMSAnalysisTask} equivalents — they touch only base-class members ({@link #table}, {@code tbl}, + * {@code col}, {@code tableSample} and the shared templates/helpers), never an HMS-specific field, so the scale + * factor, limit heuristic and linear-vs-DUJ1 estimator choice stay byte-identical to legacy. The raw file sizes + * come from {@code PluginDrivenExternalTable.getChunkSizes()} (connector's {@code listFileSizes}); the + * Doris-type slot-width math stays here. Full (non-sample) analyze falls through to the base {@link #doFull()}. + */ +public class PluginDrivenSampleAnalysisTask extends ExternalAnalysisTask { + + public PluginDrivenSampleAnalysisTask() { + super(); + } + + public PluginDrivenSampleAnalysisTask(AnalysisInfo info) { + super(info); + } + + @Override + protected void doSample() { + StringBuilder sb = new StringBuilder(); + Map params = buildSqlParams(); + params.put("min", getMinFunction()); + params.put("max", getMaxFunction()); + params.put("dataSizeFunction", getDataSizeFunction(col, false)); + Pair sampleInfo = getSampleInfo(); + params.put("scaleFactor", String.valueOf(sampleInfo.first)); + params.put("hotValueCollectCount", String.valueOf(SessionVariable.getHotValueCollectCount())); + if (LOG.isDebugEnabled()) { + LOG.debug("Will do sample collection for column {}", col.getName()); + } + boolean limitFlag = false; + boolean bucketFlag = false; + // If sample size is too large, use limit to control the sample size. + if (needLimit(sampleInfo.second, sampleInfo.first)) { + limitFlag = true; + long columnSize = 0; + for (Column column : table.getFullSchema()) { + columnSize += column.getDataType().getSlotSize(); + } + double targetRows = (double) sampleInfo.second / columnSize; + // Estimate the new scaleFactor based on the schema. + if (targetRows > StatisticsUtil.getHugeTableSampleRows()) { + params.put("limit", "limit " + StatisticsUtil.getHugeTableSampleRows()); + params.put("scaleFactor", + String.valueOf(sampleInfo.first * targetRows / StatisticsUtil.getHugeTableSampleRows())); + } + } + // Single distribution column is not fit for DUJ1 estimator, use linear estimator. + Set distributionColumns = tbl.getDistributionColumnNames(); + if (distributionColumns.size() == 1 && distributionColumns.contains(col.getName().toLowerCase())) { + bucketFlag = true; + sb.append(LINEAR_ANALYZE_TEMPLATE); + params.put("ndvFunction", "ROUND(NDV(`${colName}`) * ${scaleFactor})"); + params.put("rowCount", "ROUND(COUNT(1) * ${scaleFactor})"); + params.put("rowCount2", "(SELECT COUNT(1) FROM cte1 WHERE `${colName}` IS NOT NULL)"); + } else { + sb.append(DUJ1_ANALYZE_TEMPLATE); + params.put("subStringColName", getStringTypeColName(col)); + params.put("dataSizeFunction", getDataSizeFunction(col, true)); + params.put("ndvFunction", getNdvFunction("ROUND(SUM(t1.count) * ${scaleFactor})")); + params.put("rowCount", "ROUND(SUM(t1.count) * ${scaleFactor})"); + params.put("rowCount2", "(SELECT SUM(`count`) FROM cte1 WHERE `col_value` IS NOT NULL)"); + } + LOG.info("Sample for column [{}]. Scale factor [{}], " + + "limited [{}], is distribute column [{}]", + col.getName(), params.get("scaleFactor"), limitFlag, bucketFlag); + StringSubstitutor stringSubstitutor = new StringSubstitutor(params); + String sql = stringSubstitutor.replace(sb.toString()); + runQuery(sql); + } + + /** + * Get the pair of sample scale factor and the file size going to sample. While analyzing, the result of + * count, null count and data size need to multiply this scale factor to get a more accurate result. + * @return Pair of sample scale factor and the file size going to sample. + */ + protected Pair getSampleInfo() { + if (tableSample == null) { + return Pair.of(1.0, 0L); + } + long target; + // Get list of all files' size in this table (from the connector, via getChunkSizes()). + List chunkSizes = table.getChunkSizes(); + Collections.shuffle(chunkSizes, new Random(tableSample.getSeek())); + long total = 0; + // Calculate the total size of this table. + for (long size : chunkSizes) { + total += size; + } + if (total == 0) { + return Pair.of(1.0, 0L); + } + // Calculate the sample target size for percent and rows sample. + if (tableSample.isPercent()) { + target = total * tableSample.getSampleValue() / 100; + } else { + int columnSize = 0; + for (Column column : table.getFullSchema()) { + columnSize += column.getDataType().getSlotSize(); + } + target = columnSize * tableSample.getSampleValue(); + } + // Calculate the actual sample size (cumulate). + long cumulate = 0; + for (long size : chunkSizes) { + cumulate += size; + if (cumulate >= target) { + break; + } + } + return Pair.of(Math.max(((double) total) / cumulate, 1), cumulate); + } + + /** + * If the size to sample is larger than LIMIT_SIZE (1GB) and is much larger (1.2*) than the size the user + * wants to sample, use limit to control the total sample size. + * @param sizeToRead The file size to sample. + * @param factor sizeToRead * factor = Table total size. + * @return True if need to limit. + */ + protected boolean needLimit(long sizeToRead, double factor) { + long total = (long) (sizeToRead * factor); + long target; + if (tableSample.isPercent()) { + target = total * tableSample.getSampleValue() / 100; + } else { + int columnSize = 0; + for (Column column : table.getFullSchema()) { + columnSize += column.getDataType().getSlotSize(); + } + target = columnSize * tableSample.getSampleValue(); + } + return sizeToRead > LIMIT_SIZE && sizeToRead > target * LIMIT_FACTOR; + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsAutoCollector.java b/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsAutoCollector.java index 4f142cc05874fc..793b43a8c939ff 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsAutoCollector.java +++ b/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsAutoCollector.java @@ -26,14 +26,14 @@ import org.apache.doris.common.DdlException; import org.apache.doris.common.Pair; import org.apache.doris.common.util.MasterDaemon; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.persist.TableStatsDeletionLog; import org.apache.doris.statistics.AnalysisInfo.AnalysisMethod; import org.apache.doris.statistics.AnalysisInfo.JobType; import org.apache.doris.statistics.AnalysisInfo.ScheduleType; import org.apache.doris.statistics.util.StatisticsUtil; -import org.apache.hudi.common.util.VisibleForTesting; +import com.google.common.annotations.VisibleForTesting; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -148,7 +148,13 @@ protected void processOneJob(TableIf table, Set> columns, J if (StatisticsUtil.enablePartitionAnalyze() && table.isPartitionedTable()) { analysisMethod = AnalysisMethod.FULL; } - if (table instanceof IcebergExternalTable) { // IcebergExternalTable table only support full analyze now + if (table instanceof PluginDrivenExternalTable + && ((PluginDrivenExternalTable) table).supportsColumnAutoAnalyze() + && !((PluginDrivenExternalTable) table).supportsSampleAnalyze()) { + // Force FULL only for plugin tables that CANNOT sample (iceberg/paimon): ExternalAnalysisTask.doSample + // throws, so the SAMPLE default would fail. A flipped plain-hive table declares SUPPORTS_SAMPLE_ANALYZE + // and keeps the SAMPLE/FULL heuristic above (its PluginDrivenSampleAnalysisTask.doSample works), matching + // legacy hive background auto-analyze which could sample. analysisMethod = AnalysisMethod.FULL; } boolean isSampleAnalyze = analysisMethod.equals(AnalysisMethod.SAMPLE); diff --git a/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsCache.java b/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsCache.java index 08532976defe6c..58a4a2081ce9d8 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsCache.java +++ b/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsCache.java @@ -40,8 +40,8 @@ import com.github.benmanes.caffeine.cache.AsyncLoadingCache; import com.github.benmanes.caffeine.cache.Caffeine; +import com.google.common.annotations.VisibleForTesting; import org.apache.commons.collections4.CollectionUtils; -import org.apache.hudi.common.util.VisibleForTesting; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; diff --git a/fe/fe-core/src/main/java/org/apache/doris/statistics/util/StatisticsUtil.java b/fe/fe-core/src/main/java/org/apache/doris/statistics/util/StatisticsUtil.java index 10b139b73f047f..6649c928af51e6 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/statistics/util/StatisticsUtil.java +++ b/fe/fe-core/src/main/java/org/apache/doris/statistics/util/StatisticsUtil.java @@ -54,9 +54,7 @@ import org.apache.doris.common.util.TimeUtils; import org.apache.doris.datasource.CatalogIf; import org.apache.doris.datasource.InternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HMSExternalTable.DLAType; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.nereids.trees.expressions.literal.DateTimeLiteral; import org.apache.doris.nereids.trees.expressions.literal.IPv4Literal; import org.apache.doris.nereids.trees.expressions.literal.IPv6Literal; @@ -117,11 +115,6 @@ public class StatisticsUtil { private static final Logger LOG = LogManager.getLogger(StatisticsUtil.class); - private static final String TOTAL_SIZE = "totalSize"; - private static final String NUM_ROWS = "numRows"; - private static final String SPARK_NUM_ROWS = "spark.sql.statistics.numRows"; - private static final String SPARK_TOTAL_SIZE = "spark.sql.statistics.totalSize"; - private static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss"; public static final int UPDATED_PARTITION_THRESHOLD = 3; @@ -521,75 +514,6 @@ public static String getReadableTime(long timeInMs) { return format.format(new Date(timeInMs)); } - /** - * Estimate hive table row count. - * First get it from remote table parameters. If not found, estimate it : totalSize/estimatedRowSize - * - * @param table Hive HMSExternalTable to estimate row count. - * @return estimated row count - */ - public static long getHiveRowCount(HMSExternalTable table) { - Map parameters = table.getRemoteTable().getParameters(); - if (parameters == null) { - return TableIf.UNKNOWN_ROW_COUNT; - } - // Table parameters contains row count, simply get and return it. - long rows = getRowCountFromParameters(parameters); - if (rows > 0) { - LOG.info("Get row count {} for hive table {} in table parameters.", rows, table.getName()); - return rows; - } - if (!parameters.containsKey(TOTAL_SIZE) && !parameters.containsKey(SPARK_TOTAL_SIZE)) { - return TableIf.UNKNOWN_ROW_COUNT; - } - // Table parameters doesn't contain row count but contain total size. Estimate row count : totalSize/rowSize - long totalSize = parameters.containsKey(TOTAL_SIZE) ? Long.parseLong(parameters.get(TOTAL_SIZE)) - : Long.parseLong(parameters.get(SPARK_TOTAL_SIZE)); - long estimatedRowSize = 0; - for (Column column : table.getFullSchema()) { - estimatedRowSize += column.getDataType().getSlotSize(); - } - if (estimatedRowSize == 0) { - LOG.warn("Hive table {} estimated row size is invalid {}", table.getName(), estimatedRowSize); - return TableIf.UNKNOWN_ROW_COUNT; - } - rows = totalSize / estimatedRowSize; - LOG.info("Get row count {} for hive table {} by total size {} and row size {}", - rows, table.getName(), totalSize, estimatedRowSize); - return rows; - } - - private static long getRowCountFromParameters(Map parameters) { - if (parameters == null) { - return TableIf.UNKNOWN_ROW_COUNT; - } - // Table parameters contains row count, simply get and return it. - if (parameters.containsKey(NUM_ROWS)) { - long rows = Long.parseLong(parameters.get(NUM_ROWS)); - if (rows <= 0 && parameters.containsKey(SPARK_NUM_ROWS)) { - rows = Long.parseLong(parameters.get(SPARK_NUM_ROWS)); - } - // Sometimes, the NUM_ROWS in hms is 0 but actually is not. Need to check TOTAL_SIZE if NUM_ROWS is 0. - if (rows > 0) { - return rows; - } - } - return TableIf.UNKNOWN_ROW_COUNT; - } - - /** - * Get total size parameter from HMS. - * @param table Hive HMSExternalTable to get HMS total size parameter. - * @return Long value of table total size, return 0 if not found. - */ - public static long getTotalSizeFromHMS(HMSExternalTable table) { - Map parameters = table.getRemoteTable().getParameters(); - if (parameters == null) { - return 0; - } - return parameters.containsKey(TOTAL_SIZE) ? Long.parseLong(parameters.get(TOTAL_SIZE)) : 0; - } - /** * Get Iceberg column statistics. * @@ -996,17 +920,14 @@ public static boolean supportAutoAnalyze(TableIf table) { return true; } - // Support Iceberg table - if (table instanceof IcebergExternalTable) { + // Support flipped plugin-driven external tables whose connector declares column auto-analyze + // (post-cutover iceberg/paimon). Additive to the legacy arms above so pre-cutover behavior is + // unchanged; the capability replaces the legacy iceberg-class discrimination once flipped. + if (table instanceof PluginDrivenExternalTable + && ((PluginDrivenExternalTable) table).supportsColumnAutoAnalyze()) { return true; } - // Support HMS table (only HIVE and ICEBERG types) - if (table instanceof HMSExternalTable) { - HMSExternalTable hmsTable = (HMSExternalTable) table; - DLAType dlaType = hmsTable.getDlaType(); - return dlaType.equals(DLAType.HIVE) || dlaType.equals(DLAType.ICEBERG); - } return false; } diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/JdbcQueryTableValueFunction.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/JdbcQueryTableValueFunction.java index 5aa21ebb1b31dd..f8250bee8a84b5 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/JdbcQueryTableValueFunction.java +++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/JdbcQueryTableValueFunction.java @@ -24,9 +24,10 @@ import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.ConnectorTableSchema; import org.apache.doris.connector.api.handle.PassthroughQueryTableHandle; -import org.apache.doris.datasource.ConnectorColumnConverter; -import org.apache.doris.datasource.PluginDrivenExternalCatalog; -import org.apache.doris.datasource.PluginDrivenScanNode; +import org.apache.doris.datasource.connector.converter.ConnectorColumnConverter; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; +import org.apache.doris.datasource.scan.PluginDrivenScanNode; import org.apache.doris.planner.PlanNodeId; import org.apache.doris.planner.ScanContext; import org.apache.doris.planner.ScanNode; @@ -49,7 +50,7 @@ public JdbcQueryTableValueFunction(Map params) throws AnalysisEx public List getTableColumns() throws AnalysisException { PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalogIf; ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = pluginCatalog.getConnector().getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, pluginCatalog.getConnector()); ConnectorTableSchema schema = metadata.getColumnsFromQuery(session, query); return ConnectorColumnConverter.convertColumns(schema.getColumns()); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/MetadataGenerator.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/MetadataGenerator.java index 1838085cb1f4f2..be08a36b69f4cb 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/MetadataGenerator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/MetadataGenerator.java @@ -57,6 +57,9 @@ import org.apache.doris.common.util.NetUtils; import org.apache.doris.common.util.TimeUtils; import org.apache.doris.common.util.Util; +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.datasource.CatalogIf; import org.apache.doris.datasource.CatalogMgr; import org.apache.doris.datasource.ExternalCatalog; @@ -64,12 +67,12 @@ import org.apache.doris.datasource.ExternalTable; import org.apache.doris.datasource.InternalCatalog; import org.apache.doris.datasource.TablePartitionValues; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HiveExternalMetaCache; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog; import org.apache.doris.datasource.metacache.MetaCacheEntryStats; +import org.apache.doris.datasource.mvcc.MvccSnapshot; import org.apache.doris.datasource.mvcc.MvccUtil; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.job.common.JobType; import org.apache.doris.job.extensions.insert.streaming.AbstractStreamingTask; import org.apache.doris.job.extensions.insert.streaming.StreamingInsertJob; @@ -122,8 +125,6 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; import com.google.gson.Gson; -import org.apache.hudi.common.table.timeline.HoodieInstant; -import org.apache.hudi.common.table.timeline.HoodieTimeline; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.thrift.TException; @@ -136,6 +137,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; @@ -452,32 +454,36 @@ private static TFetchSchemaTableDataResult hudiMetadataResult(TMetadataTableRequ return errorResult("The specified db or table does not exist"); } - if (!(dorisTable instanceof HMSExternalTable)) { - return errorResult("The specified table is not a hudi table: " + hudiMetadataParams.getTable()); + if (hudiQueryType != THudiQueryType.TIMELINE) { + return errorResult("Unsupported hudi inspect type: " + hudiQueryType); } - HMSExternalTable hudiTable = (HMSExternalTable) dorisTable; - List dataBatch = Lists.newArrayList(); - TFetchSchemaTableDataResult result = new TFetchSchemaTableDataResult(); - - switch (hudiQueryType) { - case TIMELINE: - HoodieTimeline timeline = Env.getCurrentEnv().getExtMetaCacheMgr() - .hudi(catalog.getId()) - .getHoodieTableMetaClient(hudiTable.getOrBuildNameMapping()) - .getActiveTimeline(); - for (HoodieInstant instant : timeline.getInstants()) { - TRow trow = new TRow(); - trow.addToColumnValue(new TCell().setStringVal(instant.requestedTime())); - trow.addToColumnValue(new TCell().setStringVal(instant.getAction())); - trow.addToColumnValue(new TCell().setStringVal(instant.getState().name())); - trow.addToColumnValue(new TCell().setStringVal(instant.getCompletionTime())); - dataBatch.add(trow); + // A flipped hudi table is a PluginDrivenExternalTable served by its connector via the + // SUPPORTS_METADATA_TABLE SPI. Timeline iteration/parsing lives OUTSIDE this class, so + // MetadataGenerator no longer imports org.apache.hudi. + List> timelineRows; + switch (dorisTable.getType()) { + case PLUGIN_EXTERNAL_TABLE: { + PluginDrivenExternalTable pluginTable = (PluginDrivenExternalTable) dorisTable; + if (!pluginTable.supportsMetadataTable()) { + return errorResult("The specified table is not a hudi table: " + hudiMetadataParams.getTable()); } + timelineRows = pluginTable.getMetadataTableRows("timeline"); break; + } default: - return errorResult("Unsupported hudi inspect type: " + hudiQueryType); + return errorResult("The specified table is not a hudi table: " + hudiMetadataParams.getTable()); } + + List dataBatch = Lists.newArrayList(); + for (List row : timelineRows) { + TRow trow = new TRow(); + for (String cell : row) { + trow.addToColumnValue(new TCell().setStringVal(cell)); + } + dataBatch.add(trow); + } + TFetchSchemaTableDataResult result = new TFetchSchemaTableDataResult(); result.setDataBatch(dataBatch); result.setStatus(new TStatus(TStatusCode.OK)); return result; @@ -1307,10 +1313,8 @@ private static TFetchSchemaTableDataResult partitionMetadataResult(TMetadataTabl if (catalog instanceof InternalCatalog) { return dealInternalCatalog((Database) db, table); - } else if (catalog instanceof MaxComputeExternalCatalog) { - return dealMaxComputeCatalog((MaxComputeExternalCatalog) catalog, (ExternalTable) table); - } else if (catalog instanceof HMSExternalCatalog) { - return dealHMSCatalog((HMSExternalCatalog) catalog, (ExternalTable) table); + } else if (catalog instanceof PluginDrivenExternalCatalog) { + return dealPluginDrivenCatalog((PluginDrivenExternalCatalog) catalog, (ExternalTable) table); } if (LOG.isDebugEnabled()) { @@ -1319,29 +1323,19 @@ private static TFetchSchemaTableDataResult partitionMetadataResult(TMetadataTabl return errorResult("not support catalog: " + catalogName); } - private static TFetchSchemaTableDataResult dealHMSCatalog(HMSExternalCatalog catalog, ExternalTable table) { - List dataBatch = Lists.newArrayList(); - List partitionNames = catalog.getClient() - .listPartitionNames(table.getRemoteDbName(), table.getRemoteName()); - for (String partition : partitionNames) { - TRow trow = new TRow(); - trow.addToColumnValue(new TCell().setStringVal(partition)); - dataBatch.add(trow); - } - TFetchSchemaTableDataResult result = new TFetchSchemaTableDataResult(); - result.setDataBatch(dataBatch); - result.setStatus(new TStatus(TStatusCode.OK)); - return result; - } - - private static TFetchSchemaTableDataResult dealMaxComputeCatalog(MaxComputeExternalCatalog catalog, + private static TFetchSchemaTableDataResult dealPluginDrivenCatalog(PluginDrivenExternalCatalog catalog, ExternalTable table) { List dataBatch = Lists.newArrayList(); - List partitionNames = catalog.listPartitionNames(table.getRemoteDbName(), table.getRemoteName()); - for (String partition : partitionNames) { - TRow trow = new TRow(); - trow.addToColumnValue(new TCell().setStringVal(partition)); - dataBatch.add(trow); + ConnectorSession session = catalog.buildCrossStatementSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, catalog.getConnector()); + Optional handle = metadata.getTableHandle( + session, table.getRemoteDbName(), table.getRemoteName()); + if (handle.isPresent()) { + for (String partition : metadata.listPartitionNames(session, handle.get())) { + TRow trow = new TRow(); + trow.addToColumnValue(new TCell().setStringVal(partition)); + dataBatch.add(trow); + } } TFetchSchemaTableDataResult result = new TFetchSchemaTableDataResult(); result.setDataBatch(dataBatch); @@ -2079,8 +2073,8 @@ private static TFetchSchemaTableDataResult partitionValuesMetadataResult(TMetada TableIf table = PartitionValuesTableValuedFunction.analyzeAndGetTable(ctlName, dbName, tblName, false); TableType tableType = table.getType(); switch (tableType) { - case HMS_EXTERNAL_TABLE: - dataBatch = partitionValuesMetadataResultForHmsTable((HMSExternalTable) table, + case PLUGIN_EXTERNAL_TABLE: + dataBatch = partitionValuesMetadataResultForPluginTable((PluginDrivenExternalTable) table, params.getColumnsName()); break; default: @@ -2096,9 +2090,18 @@ private static TFetchSchemaTableDataResult partitionValuesMetadataResult(TMetada } } - private static List partitionValuesMetadataResultForHmsTable(HMSExternalTable tbl, List colNames) - throws AnalysisException { - List partitionCols = tbl.getPartitionColumns(); + // 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). + private static List partitionValuesMetadataResultForPluginTable(PluginDrivenExternalTable tbl, + List colNames) throws AnalysisException { + Optional snapshot = MvccUtil.getSnapshotFromContext(tbl); + Map> valuesMap = tbl.getNameToPartitionValues(snapshot); + return partitionValuesRows(tbl.getPartitionColumns(snapshot), colNames, valuesMap, tbl.getName()); + } + + private static List partitionValuesRows(List partitionCols, List colNames, + Map> valuesMap, String tableName) throws AnalysisException { List colIdxs = Lists.newArrayList(); List types = Lists.newArrayList(); for (String colName : colNames) { @@ -2111,12 +2114,9 @@ private static List partitionValuesMetadataResultForHmsTable(HMSExternalTa } if (colIdxs.size() != colNames.size()) { throw new AnalysisException( - "column " + colNames + " does not match partition columns of table " + tbl.getName()); + "column " + colNames + " does not match partition columns of table " + tableName); } - HiveExternalMetaCache.HivePartitionValues hivePartitionValues = tbl.getHivePartitionValues( - MvccUtil.getSnapshotFromContext(tbl)); - Map> valuesMap = hivePartitionValues.getNameToPartitionValues(); List dataBatch = Lists.newArrayList(); for (Map.Entry> entry : valuesMap.entrySet()) { TRow trow = new TRow(); diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/PartitionValuesTableValuedFunction.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/PartitionValuesTableValuedFunction.java index 494a68edf3af9c..2a53861fd3af46 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/PartitionValuesTableValuedFunction.java +++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/PartitionValuesTableValuedFunction.java @@ -25,9 +25,10 @@ import org.apache.doris.common.ErrorCode; import org.apache.doris.common.MetaNotFoundException; import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.mvcc.MvccUtil; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.mysql.privilege.PrivPredicate; import org.apache.doris.nereids.exceptions.AnalysisException; import org.apache.doris.qe.ConnectContext; @@ -111,8 +112,7 @@ public static TableIf analyzeAndGetTable(String catalogName, String dbName, Stri throw new AnalysisException("can not find catalog: " + catalogName); } // disallow unsupported catalog - if (!(catalog.isInternalCatalog() || catalog instanceof HMSExternalCatalog - || catalog instanceof MaxComputeExternalCatalog)) { + if (!(catalog.isInternalCatalog() || catalog instanceof PluginDrivenExternalCatalog)) { throw new AnalysisException(String.format("Catalog of type '%s' is not allowed in ShowPartitionsStmt", catalog.getType())); } @@ -124,19 +124,21 @@ public static TableIf analyzeAndGetTable(String catalogName, String dbName, Stri TableIf table; try { table = db.get().getTableOrMetaException(tableName, TableType.OLAP, - TableType.HMS_EXTERNAL_TABLE, TableType.MAX_COMPUTE_EXTERNAL_TABLE); + TableType.HMS_EXTERNAL_TABLE, TableType.MAX_COMPUTE_EXTERNAL_TABLE, + TableType.PLUGIN_EXTERNAL_TABLE); } catch (MetaNotFoundException e) { throw new AnalysisException(e.getMessage(), e); } - if (!(table instanceof HMSExternalTable)) { - throw new AnalysisException("Currently only support hive table's partition values meta table"); - } - HMSExternalTable hmsTable = (HMSExternalTable) table; - if (!hmsTable.isPartitionedTable()) { - throw new AnalysisException("Table " + tableName + " is not a partitioned table"); + // A flipped hms table is a PluginDrivenExternalTable, not an HMSExternalTable; both are served + // via their common partition SPI below, mirroring the $partitions TVF (PartitionsTableValuedFunction). + if (table instanceof PluginDrivenExternalTable) { + if (!((PluginDrivenExternalTable) table).isPartitionedTable()) { + throw new AnalysisException("Table " + tableName + " is not a partitioned table"); + } + return table; } - return table; + throw new AnalysisException("Currently only support hive table's partition values meta table"); } @Override @@ -169,7 +171,8 @@ public List getTableColumns() throws AnalysisException { Preconditions.checkNotNull(table); // TODO: support other type of sys tables if (schema == null) { - List partitionColumns = ((HMSExternalTable) table).getPartitionColumns(); + List partitionColumns = ((ExternalTable) table).getPartitionColumns( + MvccUtil.getSnapshotFromContext(table)); schema = Lists.newArrayList(); for (Column column : partitionColumns) { schema.add(new Column(column)); diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/PartitionsTableValuedFunction.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/PartitionsTableValuedFunction.java index 160399bfd000b3..e37575a302e6f4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/PartitionsTableValuedFunction.java +++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/PartitionsTableValuedFunction.java @@ -28,10 +28,8 @@ import org.apache.doris.common.MetaNotFoundException; import org.apache.doris.datasource.CatalogIf; import org.apache.doris.datasource.InternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.mysql.privilege.PrivPredicate; import org.apache.doris.nereids.exceptions.AnalysisException; import org.apache.doris.qe.ConnectContext; @@ -44,7 +42,6 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Maps; -import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -169,8 +166,7 @@ private void analyze(String catalogName, String dbName, String tableName) { throw new AnalysisException(message); } // disallow unsupported catalog - if (!(catalog.isInternalCatalog() || catalog instanceof HMSExternalCatalog - || catalog instanceof MaxComputeExternalCatalog)) { + if (!(catalog.isInternalCatalog() || catalog instanceof PluginDrivenExternalCatalog)) { throw new AnalysisException(String.format("Catalog of type '%s' is not allowed in ShowPartitionsStmt", catalog.getType())); } @@ -182,23 +178,18 @@ private void analyze(String catalogName, String dbName, String tableName) { TableIf table = null; try { table = db.get().getTableOrMetaException(tableName, TableType.OLAP, - TableType.HMS_EXTERNAL_TABLE, TableType.MAX_COMPUTE_EXTERNAL_TABLE); + TableType.HMS_EXTERNAL_TABLE, TableType.MAX_COMPUTE_EXTERNAL_TABLE, + TableType.PLUGIN_EXTERNAL_TABLE); } catch (MetaNotFoundException e) { throw new AnalysisException(e.getMessage(), e); } - if (table instanceof HMSExternalTable) { - if (((HMSExternalTable) table).isView()) { - throw new AnalysisException("Table " + tableName + " is not a partitioned table"); - } - if (CollectionUtils.isEmpty(((HMSExternalTable) table).getPartitionColumns())) { - throw new AnalysisException("Table " + tableName + " is not a partitioned table"); - } - return; - } - - if (table instanceof MaxComputeExternalTable) { - if (((MaxComputeExternalTable) table).getOdpsTable().getPartitions().isEmpty()) { + if (table instanceof PluginDrivenExternalTable) { + // Keyed on partition columns (isPartitionedTable), consistent with the SHOW PARTITIONS + // gate (ShowPartitionsCommand). A partitioned-but-empty table returns 0 rows rather than + // throwing -- a deliberate, more-correct deviation from legacy MC's partition-instance + // check above. + if (!((PluginDrivenExternalTable) table).isPartitionedTable()) { throw new AnalysisException("Table " + tableName + " is not a partitioned table"); } } 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 4d736af33bb9c8..799b8299167921 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 @@ -24,7 +24,7 @@ import org.apache.doris.common.AnalysisException; import org.apache.doris.connector.api.ConnectorCapability; import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.mysql.privilege.PrivPredicate; import org.apache.doris.planner.PlanNodeId; import org.apache.doris.planner.ScanNode; diff --git a/fe/fe-core/src/main/java/org/apache/doris/transaction/AbstractExternalTransactionManager.java b/fe/fe-core/src/main/java/org/apache/doris/transaction/AbstractExternalTransactionManager.java deleted file mode 100644 index da80b8f77bd6f0..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/transaction/AbstractExternalTransactionManager.java +++ /dev/null @@ -1,81 +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.transaction; - -import org.apache.doris.catalog.Env; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.operations.ExternalMetadataOps; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -public abstract class AbstractExternalTransactionManager implements TransactionManager { - private static final Logger LOG = LogManager.getLogger(AbstractExternalTransactionManager.class); - private final Map transactions = new ConcurrentHashMap<>(); - protected final ExternalMetadataOps ops; - - public AbstractExternalTransactionManager(ExternalMetadataOps ops) { - this.ops = ops; - } - - abstract T createTransaction(); - - @Override - public long begin() { - long id = Env.getCurrentEnv().getNextId(); - T transaction = createTransaction(); - transactions.put(id, transaction); - Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().putTxnById(id, transaction); - return id; - } - - @Override - public void commit(long id) throws UserException { - getTransactionWithException(id).commit(); - transactions.remove(id); - Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().removeTxnById(id); - } - - @Override - public void rollback(long id) { - try { - getTransactionWithException(id).rollback(); - } catch (TransactionNotFoundException e) { - LOG.warn(e.getMessage(), e); - } finally { - transactions.remove(id); - Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().removeTxnById(id); - } - } - - @Override - public Transaction getTransaction(long id) throws UserException { - return getTransactionWithException(id); - } - - private Transaction getTransactionWithException(long id) throws TransactionNotFoundException { - Transaction txn = transactions.get(id); - if (txn == null) { - throw new TransactionNotFoundException("Can't find transaction for " + id); - } - return txn; - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/transaction/CommitDataSerializer.java b/fe/fe-core/src/main/java/org/apache/doris/transaction/CommitDataSerializer.java new file mode 100644 index 00000000000000..926e96086387b8 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/transaction/CommitDataSerializer.java @@ -0,0 +1,58 @@ +// 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.transaction; + +import org.apache.thrift.TBase; +import org.apache.thrift.TException; +import org.apache.thrift.TSerializer; +import org.apache.thrift.protocol.TBinaryProtocol; + +import java.util.List; + +/** + * Serializes connector-specific Thrift commit fragments produced by BE and feeds + * them, one fragment at a time, into a {@link Transaction} through + * {@link Transaction#addCommitData(byte[])}. + * + *

    This is the single place the FE-side serialization protocol is defined. It + * MUST match the deserialization protocol used by the write transactions' + * {@code addCommitData} overrides (maxcompute / hive / iceberg); the + * {@code CommitDataSerializerTest} golden tests pin that agreement.

    + */ +public final class CommitDataSerializer { + + private CommitDataSerializer() { + } + + /** + * Serializes each commit fragment and accumulates it into {@code txn}. + * + * @param txn the transaction collecting commit data for this write + * @param fragments connector-specific Thrift commit fragments, one per BE write fragment + */ + public static void feed(Transaction txn, List> fragments) { + try { + TSerializer serializer = new TSerializer(new TBinaryProtocol.Factory()); + for (TBase fragment : fragments) { + txn.addCommitData(serializer.serialize(fragment)); + } + } catch (TException e) { + throw new RuntimeException("failed to serialize connector commit data", e); + } + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/transaction/HiveTransactionManager.java b/fe/fe-core/src/main/java/org/apache/doris/transaction/HiveTransactionManager.java deleted file mode 100644 index b80f94f4dbcfe2..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/transaction/HiveTransactionManager.java +++ /dev/null @@ -1,42 +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.transaction; - -import org.apache.doris.datasource.hive.HMSTransaction; -import org.apache.doris.datasource.hive.HiveMetadataOps; -import org.apache.doris.fs.SpiSwitchingFileSystem; - -import java.util.concurrent.Executor; - -public class HiveTransactionManager extends AbstractExternalTransactionManager { - - private final SpiSwitchingFileSystem fileSystem; - private final Executor fileSystemExecutor; - - public HiveTransactionManager(HiveMetadataOps ops, SpiSwitchingFileSystem fileSystem, - Executor fileSystemExecutor) { - super(ops); - this.fileSystem = fileSystem; - this.fileSystemExecutor = fileSystemExecutor; - } - - @Override - HMSTransaction createTransaction() { - return new HMSTransaction((HiveMetadataOps) ops, fileSystem, fileSystemExecutor); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/transaction/IcebergTransactionManager.java b/fe/fe-core/src/main/java/org/apache/doris/transaction/IcebergTransactionManager.java deleted file mode 100644 index 8f4d25a19b3ac5..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/transaction/IcebergTransactionManager.java +++ /dev/null @@ -1,34 +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.transaction; - - -import org.apache.doris.datasource.iceberg.IcebergMetadataOps; -import org.apache.doris.datasource.iceberg.IcebergTransaction; - -public class IcebergTransactionManager extends AbstractExternalTransactionManager { - - public IcebergTransactionManager(IcebergMetadataOps ops) { - super(ops); - } - - @Override - IcebergTransaction createTransaction() { - return new IcebergTransaction((IcebergMetadataOps) ops); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/transaction/MCTransactionManager.java b/fe/fe-core/src/main/java/org/apache/doris/transaction/MCTransactionManager.java deleted file mode 100644 index a7d1428f641a95..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/transaction/MCTransactionManager.java +++ /dev/null @@ -1,36 +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.transaction; - -import org.apache.doris.datasource.maxcompute.MCTransaction; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog; - -public class MCTransactionManager extends AbstractExternalTransactionManager { - - private final MaxComputeExternalCatalog catalog; - - public MCTransactionManager(MaxComputeExternalCatalog catalog) { - super(null); - this.catalog = catalog; - } - - @Override - MCTransaction createTransaction() { - return new MCTransaction(catalog); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/transaction/PluginDrivenTransactionManager.java b/fe/fe-core/src/main/java/org/apache/doris/transaction/PluginDrivenTransactionManager.java index 92ed5830d99fb7..16c230f8d7de40 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/transaction/PluginDrivenTransactionManager.java +++ b/fe/fe-core/src/main/java/org/apache/doris/transaction/PluginDrivenTransactionManager.java @@ -19,21 +19,27 @@ import org.apache.doris.catalog.Env; import org.apache.doris.common.UserException; +import org.apache.doris.connector.api.handle.ConnectorTransaction; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; /** * Transaction manager for plugin-driven external catalogs. * - *

    This is a lightweight implementation that generates transaction IDs via - * {@link Env#getNextId()} and tracks them in a local map. The actual commit - * and rollback logic is handled by the connector's {@code ConnectorWriteOps} - * through the insert executor — this manager simply provides the transaction - * lifecycle bookkeeping required by {@link org.apache.doris.nereids.trees.plans - * .commands.insert.BaseExternalTableInsertExecutor}.

    + *

    The insert executor opens every plugin-driven write through + * {@link #begin(ConnectorTransaction)}: connectors return a real {@link ConnectorTransaction} + * from {@code ConnectorWriteOps.beginTransaction} (a degenerate no-op one for writes that BE + * auto-commits, such as jdbc). The manager uses {@link ConnectorTransaction#getTransactionId()} + * as the txn id, registers it globally, and delegates commit/rollback/close to the connector.

    + * + *

    {@link #begin()} (no-arg) remains only to satisfy the {@link TransactionManager} interface; + * it allocates a txn id via {@link Env#getNextId()} and stores a marker transaction with no + * connector delegate. Both paths share the {@link #commit(long)} / {@link #rollback(long)} + * surface required by {@link TransactionManager}.

    */ public class PluginDrivenTransactionManager implements TransactionManager { @@ -45,27 +51,57 @@ public class PluginDrivenTransactionManager implements TransactionManager { @Override public long begin() { long txnId = Env.getCurrentEnv().getNextId(); - PluginDrivenTransaction txn = new PluginDrivenTransaction(txnId); - transactions.put(txnId, txn); + transactions.put(txnId, new PluginDrivenTransaction(txnId, null)); LOG.debug("Plugin-driven transaction begun: {}", txnId); return txnId; } + /** + * Registers a connector-provided {@link ConnectorTransaction}. Commit / rollback + * lifecycle is delegated to it (including {@code close()}). + * + * @return the txn id, taken from {@code connectorTx.getTransactionId()} + */ + public long begin(ConnectorTransaction connectorTx) { + Objects.requireNonNull(connectorTx, "connectorTx"); + long txnId = connectorTx.getTransactionId(); + PluginDrivenTransaction txn = new PluginDrivenTransaction(txnId, connectorTx); + transactions.put(txnId, txn); + // Register globally so the BE block-allocation RPC and the commit-data feedback can + // look the transaction up by id (FrontendServiceImpl.getMaxComputeBlockIdRange -> + // getTxnById). Connectors whose writes BE auto-commits (jdbc) register a no-op transaction + // here too; BE never sends them commit fragments, so the global entry is simply never + // looked up before it is removed on commit. + Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().putTxnById(txnId, txn); + LOG.debug("Plugin-driven transaction begun with SPI ConnectorTransaction: {}", txnId); + return txnId; + } + @Override public void commit(long id) throws UserException { PluginDrivenTransaction txn = transactions.remove(id); - if (txn != null) { - txn.commit(); - LOG.debug("Plugin-driven transaction committed: {}", id); + try { + if (txn != null) { + txn.commit(); + LOG.debug("Plugin-driven transaction committed: {}", id); + } + } finally { + // Always deregister from the global registry, even if connectorTx.commit() throws, + // so a failed commit cannot leave a stale entry behind (mirrors rollback()). + Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().removeTxnById(id); } } @Override public void rollback(long id) { PluginDrivenTransaction txn = transactions.remove(id); - if (txn != null) { - txn.rollback(); - LOG.debug("Plugin-driven transaction rolled back: {}", id); + try { + if (txn != null) { + txn.rollback(); + LOG.debug("Plugin-driven transaction rolled back: {}", id); + } + } finally { + Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().removeTxnById(id); } } @@ -79,24 +115,86 @@ public Transaction getTransaction(long id) throws UserException { } /** - * Simple transaction that tracks state. Actual connector-level commit/rollback - * is performed by the insert executor via ConnectorWriteOps. + * Whether transaction {@code id} is still open here, i.e. registered by {@link #begin(ConnectorTransaction)} + * and not yet committed or rolled back (both {@link #commit(long)} and {@link #rollback(long)} remove it). + * The statement scope's end-of-statement backstop uses this to tell a mid-flight-aborted transaction from + * one the executor already finished, so it rolls back only genuine orphans and never a committed write. + */ + public boolean isActive(long id) { + return transactions.containsKey(id); + } + + /** + * Internal transaction record. When {@code connectorTx} is non-null (every plugin-driven + * write) the SPI is the source of truth and commit/rollback delegate to it; close() always + * runs after delegation. {@code connectorTx} is null only for the no-arg {@link #begin()} + * interface-contract path, where this is an inert no-op marker. */ - private static class PluginDrivenTransaction implements Transaction { + private static final class PluginDrivenTransaction implements Transaction { private final long id; + private final ConnectorTransaction connectorTx; - PluginDrivenTransaction(long id) { + PluginDrivenTransaction(long id, ConnectorTransaction connectorTx) { this.id = id; + this.connectorTx = connectorTx; } @Override public void commit() { - // No-op: actual commit is done via ConnectorWriteOps.finishInsert() + if (connectorTx == null) { + return; + } + try { + connectorTx.commit(); + } finally { + closeQuietly(); + } } @Override public void rollback() { - // No-op: actual rollback is done via ConnectorWriteOps.abortInsert() + if (connectorTx == null) { + return; + } + try { + connectorTx.rollback(); + } finally { + closeQuietly(); + } + } + + @Override + public void addCommitData(byte[] commitFragment) { + if (connectorTx != null) { + connectorTx.addCommitData(commitFragment); + } + // legacy no-op marker: nothing to accumulate + } + + @Override + public boolean supportsWriteBlockAllocation() { + return connectorTx != null && connectorTx.supportsWriteBlockAllocation(); + } + + @Override + public long allocateWriteBlockRange(String writeSessionId, long count) throws UserException { + if (connectorTx == null) { + throw new UnsupportedOperationException("write block allocation not supported"); + } + return connectorTx.allocateWriteBlockRange(writeSessionId, count); + } + + @Override + public long getUpdateCnt() { + return connectorTx == null ? 0 : connectorTx.getUpdateCnt(); + } + + private void closeQuietly() { + try { + connectorTx.close(); + } catch (Exception e) { + LOG.warn("Failed to close ConnectorTransaction {}: {}", id, e.getMessage()); + } } } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/transaction/Transaction.java b/fe/fe-core/src/main/java/org/apache/doris/transaction/Transaction.java index b319fb78983324..ecb21b487a667d 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/transaction/Transaction.java +++ b/fe/fe-core/src/main/java/org/apache/doris/transaction/Transaction.java @@ -24,4 +24,45 @@ public interface Transaction { void commit() throws UserException; void rollback(); + + /** + * Receives one serialized commit fragment produced by BE after writing a + * data fragment. Implementations deserialize their connector-specific Thrift + * payload and accumulate it for {@link #commit()}. + * + *

    Default is a no-op for transactions that do not collect BE commit data.

    + * + * @param commitFragment the serialized connector-specific commit payload + */ + default void addCommitData(byte[] commitFragment) { + // no-op: write transactions override this + } + + /** + * Whether this transaction allocates write block ranges through a write-time + * BE→FE callback (e.g. maxcompute). Default {@code false}. + */ + default boolean supportsWriteBlockAllocation() { + return false; + } + + /** + * Allocates a contiguous range of write block ids for the given write + * session, returning the first allocated id. Only invoked when + * {@link #supportsWriteBlockAllocation()} returns {@code true}; the default + * throws. + * + * @param writeSessionId opaque connector-defined write session identifier + * @param count number of block ids to allocate + * @return the first allocated block id + * @throws UserException on validation failure or allocation overflow + */ + default long allocateWriteBlockRange(String writeSessionId, long count) throws UserException { + throw new UnsupportedOperationException("write block allocation not supported"); + } + + /** Returns the number of rows affected by the write(s) in this transaction. */ + default long getUpdateCnt() { + return 0; + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/transaction/TransactionManagerFactory.java b/fe/fe-core/src/main/java/org/apache/doris/transaction/TransactionManagerFactory.java deleted file mode 100644 index 9a5584a0601874..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/transaction/TransactionManagerFactory.java +++ /dev/null @@ -1,41 +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.transaction; - -import org.apache.doris.datasource.hive.HiveMetadataOps; -import org.apache.doris.datasource.iceberg.IcebergMetadataOps; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog; -import org.apache.doris.fs.SpiSwitchingFileSystem; - -import java.util.concurrent.Executor; - -public class TransactionManagerFactory { - - public static TransactionManager createHiveTransactionManager(HiveMetadataOps ops, - SpiSwitchingFileSystem fileSystem, Executor fileSystemExecutor) { - return new HiveTransactionManager(ops, fileSystem, fileSystemExecutor); - } - - public static TransactionManager createIcebergTransactionManager(IcebergMetadataOps ops) { - return new IcebergTransactionManager(ops); - } - - public static TransactionManager createMCTransactionManager(MaxComputeExternalCatalog catalog) { - return new MCTransactionManager(catalog); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/catalog/EnvShowCreatePluginTableTest.java b/fe/fe-core/src/test/java/org/apache/doris/catalog/EnvShowCreatePluginTableTest.java new file mode 100644 index 00000000000000..614e16da3263d3 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/catalog/EnvShowCreatePluginTableTest.java @@ -0,0 +1,146 @@ +// 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.catalog; + +import org.apache.doris.catalog.TableIf.TableType; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenSysExternalTable; + +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Pins the SHOW CREATE TABLE plugin-driven render arm of {@link Env#getDdlStmt} — the integration point that + * consumes the connector-pre-rendered SHOW CREATE hints. Covers the three pieces of arm logic not exercised by + * the {@code PluginDrivenExternalTable} accessor unit tests: (1) the SUPPORTS_SHOW_CREATE_DDL capability gate, + * (2) the legacy-iceberg clause order (ORDER BY -> PARTITION BY -> LOCATION -> PROPERTIES), and (3) the + * system-table PARTITION BY suppression. The accessors themselves are stubbed (unit-tested separately); this + * test is the only automated guard on the Env wiring. (Full byte-level render parity is flip-gated e2e.) + */ +public class EnvShowCreatePluginTableTest { + + private static final List COLUMNS = ImmutableList.of( + new Column("id", ScalarType.INT, true, null, true, null, ""), + new Column("name", ScalarType.createStringType(), false, null, true, null, "")); + + /** Stubs the lead-in (columns/engine/comment) metadata calls getDdlStmt makes before the plugin arm. */ + private static void stubLeadIn(PluginDrivenExternalTable table) { + Mockito.doReturn(TableType.PLUGIN_EXTERNAL_TABLE).when(table).getType(); + Mockito.doReturn(false).when(table).isTemporary(); + Mockito.doReturn(false).when(table).isManagedTable(); + Mockito.doReturn("t").when(table).getName(); + Mockito.doReturn("").when(table).getComment(); + Mockito.doReturn(COLUMNS).when(table).getBaseSchema(false); + Mockito.doReturn(TableType.ICEBERG_EXTERNAL_TABLE.name()).when(table).getEngineTableTypeName(); + } + + private static String renderDdl(PluginDrivenExternalTable table) { + List createTableStmt = new ArrayList<>(); + Env.getDdlStmt(null, "mydb", table, createTableStmt, new ArrayList<>(), new ArrayList<>(), + false, false, false, -1L, false, false); + Assertions.assertEquals(1, createTableStmt.size()); + return createTableStmt.get(0); + } + + @Test + public void rendersClausesInLegacyOrderWhenConnectorSupportsShowCreate() { + PluginDrivenExternalTable table = + Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + stubLeadIn(table); + Mockito.doReturn(true).when(table).supportsShowCreateDdl(); + Mockito.doReturn("ORDER BY (`name` DESC NULLS LAST)").when(table).getShowSortClause(); + Mockito.doReturn("PARTITION BY LIST (BUCKET(8, `id`)) ()").when(table).getShowPartitionClause(); + Mockito.doReturn("s3://bucket/db/t").when(table).getShowLocation(); + Map props = new LinkedHashMap<>(); + props.put("write.format.default", "parquet"); + Mockito.doReturn(props).when(table).getTableProperties(); + + String ddl = renderDdl(table); + + Assertions.assertTrue(ddl.contains("ORDER BY (`name` DESC NULLS LAST)"), ddl); + Assertions.assertTrue(ddl.contains("PARTITION BY LIST (BUCKET(8, `id`)) ()"), ddl); + Assertions.assertTrue(ddl.contains("LOCATION 's3://bucket/db/t'"), ddl); + Assertions.assertTrue(ddl.contains("\"write.format.default\" = \"parquet\""), ddl); + // WHY: the clause order must mirror the legacy iceberg arm exactly (ORDER BY before PARTITION BY before + // LOCATION before PROPERTIES). MUTATION: reordering any append, or reading the wrong getShow* accessor + // for a clause -> the index ordering breaks -> red. + int sortIdx = ddl.indexOf("ORDER BY ("); + int partIdx = ddl.indexOf("PARTITION BY LIST"); + int locIdx = ddl.indexOf("LOCATION '"); + int propIdx = ddl.indexOf("PROPERTIES ("); + Assertions.assertTrue(sortIdx >= 0 && sortIdx < partIdx, ddl); + Assertions.assertTrue(partIdx < locIdx, ddl); + Assertions.assertTrue(locIdx < propIdx, ddl); + } + + @Test + public void rendersCommentOnlyWhenConnectorDoesNotSupportShowCreate() { + // A connector that does NOT declare SUPPORTS_SHOW_CREATE_DDL (jdbc/es: credential-bearing properties) + // must stay comment-only — no LOCATION/PROPERTIES/PARTITION/ORDER. MUTATION: dropping the capability + // gate (always render) -> these clauses appear (leaking jdbc connection props) -> red. + PluginDrivenExternalTable table = + Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + stubLeadIn(table); + Mockito.doReturn(false).when(table).supportsShowCreateDdl(); + // Even if accessors would return content, the gate must suppress everything. (lenient: not invoked) + Mockito.lenient().doReturn("ORDER BY (`name` ASC NULLS FIRST)").when(table).getShowSortClause(); + Mockito.lenient().doReturn("s3://leak").when(table).getShowLocation(); + + String ddl = renderDdl(table); + + Assertions.assertFalse(ddl.contains("LOCATION '"), ddl); + Assertions.assertFalse(ddl.contains("PROPERTIES ("), ddl); + Assertions.assertFalse(ddl.contains("PARTITION BY"), ddl); + Assertions.assertFalse(ddl.contains("ORDER BY"), ddl); + // The engine line is still rendered (this is a real table DDL, just without the connector specifics). + Assertions.assertTrue(ddl.contains("ENGINE=" + TableType.ICEBERG_EXTERNAL_TABLE.name()), ddl); + } + + @Test + public void suppressesPartitionClauseForSystemTable() { + // A system table ($snapshots etc.) renders its SOURCE table's DDL but NOT a PARTITION BY clause + // (mirroring the legacy arm, which gated partitions on the table being the data table). The sort clause + // still renders from the source. MUTATION: dropping the isSysTable guard -> PARTITION BY appears -> red. + PluginDrivenExternalTable source = + Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + Mockito.doReturn(true).when(source).supportsShowCreateDdl(); + Mockito.doReturn("ORDER BY (`name` DESC NULLS LAST)").when(source).getShowSortClause(); + Mockito.doReturn("PARTITION BY LIST (`id`) ()").when(source).getShowPartitionClause(); + Mockito.doReturn("s3://bucket/db/t").when(source).getShowLocation(); + Mockito.doReturn(new LinkedHashMap()).when(source).getTableProperties(); + + PluginDrivenSysExternalTable sysTable = + Mockito.mock(PluginDrivenSysExternalTable.class, Mockito.CALLS_REAL_METHODS); + stubLeadIn(sysTable); + Mockito.doReturn(source).when(sysTable).getSourceTable(); + + String ddl = renderDdl(sysTable); + + Assertions.assertTrue(ddl.contains("ORDER BY (`name` DESC NULLS LAST)"), ddl); + Assertions.assertTrue(ddl.contains("LOCATION 's3://bucket/db/t'"), ddl); + Assertions.assertFalse(ddl.contains("PARTITION BY"), + "a system table must not render a PARTITION BY clause: " + ddl); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/catalog/HiveTableTest.java b/fe/fe-core/src/test/java/org/apache/doris/catalog/HiveTableTest.java index 06ba92d1ddfc70..8ff7b48101731a 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/catalog/HiveTableTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/catalog/HiveTableTest.java @@ -18,7 +18,6 @@ package org.apache.doris.catalog; import org.apache.doris.common.DdlException; -import org.apache.doris.datasource.property.metastore.HMSBaseProperties; import com.google.common.collect.Lists; import com.google.common.collect.Maps; @@ -81,8 +80,8 @@ public void testNoHiveMetastoreUris() throws DdlException { @Test() public void testVersion() throws DdlException { - properties.put(HMSBaseProperties.HIVE_VERSION, "2.1.2"); + properties.put("hive.version", "2.1.2"); HiveTable table = new HiveTable(1000, "hive_table", columns, properties); - Assert.assertEquals("2.1.2", table.getHiveProperties().get(HMSBaseProperties.HIVE_VERSION)); + Assert.assertEquals("2.1.2", table.getHiveProperties().get("hive.version")); } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/catalog/ListPartitionItemTest.java b/fe/fe-core/src/test/java/org/apache/doris/catalog/ListPartitionItemTest.java new file mode 100644 index 00000000000000..6d57249f76091f --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/catalog/ListPartitionItemTest.java @@ -0,0 +1,112 @@ +// 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.catalog; + +import org.apache.doris.analysis.PartitionValue; +import org.apache.doris.common.AnalysisException; +import org.apache.doris.datasource.TablePartitionValues; +import org.apache.doris.mtmv.MTMVPartitionUtil; + +import com.google.common.collect.Lists; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +/** + * Tests for {@link ListPartitionItem#toPartitionKeyDesc} null-partition display handling. + * + *

    Guards the naming of a genuine-NULL partition: its MTMV partition NAME must be {@code pn_NULL} (the + * {@code pn_} prefix marks a null-bearing partition), NOT the bare {@code p_NULL}. A real NULL value and a + * literal {@code 'NULL'} string both render to the text {@code NULL} once {@code PartitionKeyDesc} quotes the + * value and the name pattern strips the quotes, so on a column holding both (e.g. paimon + * {@code null_partition}: a real NULL row plus a {@code 'NULL'} string row) they would BOTH be named + * {@code p_NULL} and fail the partition-uniqueness check (regression test_paimon_mtmv). The {@code pn_} prefix + * keeps them distinct: a string value always yields a {@code p_}-prefixed name, so {@code pn_} can never + * collide. The nullness is read from {@link PartitionValue#isNullPartition()}, which BOTH the connector-side + * genuine-null item and the MV-OLAP item carry, so both render the SAME {@code pn_NULL} — keeping the + * related-desc vs MV-OLAP-desc partition-mapping join symmetric (unlike the reverted FIX-3, which used the + * one-sided {@code originHiveKeys} sentinel and broke the join). + */ +public class ListPartitionItemTest { + + /** + * A genuine-NULL partition (e.g. a hive {@code __HIVE_DEFAULT_PARTITION__} default partition, built isNull + * with the sentinel preserved as originHiveKeys) must render its MTMV partition name as {@code pn_NULL} so + * that (a) it never collides with a literal {@code 'NULL'} string partition (which renders {@code p_NULL}) + * and (b) the MV-OLAP partition (which has no originHiveKeys) renders the SAME name, keeping the + * sync-compare join symmetric. The value must still resolve to a NULL literal so {@code col IS NULL} + * pruning is unaffected. + */ + @Test + public void testGenuineNullPartitionRendersAsPnNull() throws AnalysisException { + List types = Collections.singletonList(Type.VARCHAR); + + // Genuine NULL partition as a hive/paimon connector builds it: a NULL literal whose origin-hive key + // preserves the canonical sentinel string. + PartitionKey nullKey = PartitionKey.createListPartitionKeyWithTypes( + Collections.singletonList(new PartitionValue(TablePartitionValues.HIVE_DEFAULT_PARTITION, true)), + types, true); + ListPartitionItem nullItem = new ListPartitionItem(Lists.newArrayList(nullKey)); + + Assertions.assertEquals("pn_NULL", + MTMVPartitionUtil.generatePartitionName(nullItem.toPartitionKeyDesc(0)), + "a genuine-null partition must render as pn_NULL (distinct from a literal 'NULL' string's p_NULL)"); + + // The null partition's desc value must still resolve to a NULL literal so `col IS NULL` prunes to it. + PartitionValue nullDescValue = nullItem.toPartitionKeyDesc(0).getInValues().get(0).get(0); + Assertions.assertTrue(nullDescValue.isNullPartition(), + "the null partition desc value must stay isNull"); + Assertions.assertTrue(nullDescValue.getValue(Type.VARCHAR).isNullLiteral(), + "the null partition must still resolve to a NULL literal (IS NULL prune preserved)"); + } + + /** + * An internal OLAP null partition (no originHiveKeys) renders as {@code pn_NULL} — the SAME name the + * connector-side genuine-null item produces. Kept as a symmetry anchor for + * {@link #testGenuineNullPartitionRendersAsPnNull}: both sides must produce the SAME pn_NULL name so the + * partition-mapping join stays symmetric. + */ + @Test + public void testOlapNullPartitionRendersAsPnNull() throws AnalysisException { + List types = Collections.singletonList(Type.VARCHAR); + PartitionKey olapNullKey = PartitionKey.createListPartitionKeyWithTypes( + Collections.singletonList(new PartitionValue("NULL", true)), types, false); + ListPartitionItem item = new ListPartitionItem(Lists.newArrayList(olapNullKey)); + Assertions.assertEquals("pn_NULL", + MTMVPartitionUtil.generatePartitionName(item.toPartitionKeyDesc(0)), + "an OLAP null partition (no originHiveKeys) must render as pn_NULL"); + } + + /** + * A literal {@code 'NULL'} string partition (NOT a genuine null) must keep the bare {@code p_NULL} name — + * it is ordinary string data and must stay distinct from the real-NULL partition's {@code pn_NULL}. This + * is the collision the {@code pn_} prefix resolves (regression test_paimon_mtmv, which has both). + */ + @Test + public void testLiteralNullStringPartitionRendersAsPNull() throws AnalysisException { + List types = Collections.singletonList(Type.VARCHAR); + PartitionKey strKey = PartitionKey.createListPartitionKeyWithTypes( + Collections.singletonList(new PartitionValue("NULL")), types, false); + ListPartitionItem item = new ListPartitionItem(Lists.newArrayList(strKey)); + Assertions.assertEquals("p_NULL", + MTMVPartitionUtil.generatePartitionName(item.toPartitionKeyDesc(0)), + "a literal 'NULL' string partition must render as p_NULL, distinct from the real-NULL pn_NULL"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/catalog/RefreshManagerRenameReplayTest.java b/fe/fe-core/src/test/java/org/apache/doris/catalog/RefreshManagerRenameReplayTest.java new file mode 100644 index 00000000000000..b7e40ae418f5e8 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/catalog/RefreshManagerRenameReplayTest.java @@ -0,0 +1,124 @@ +// 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.catalog; + +import org.apache.doris.catalog.constraint.ConstraintManager; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.datasource.CatalogMgr; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.log.ExternalObjectLog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.Optional; + +/** + * Tests {@link RefreshManager#replayRefreshTable}'s RENAME branch (R4). A connector-driven RENAME TABLE is + * logged as a {@code createForRenameTable} external-object log and replayed here on followers/observers. + * + *

    Before R4 the replay only fixed the FE name cache ({@code unregisterTable} + {@code resetMetaCacheNames} + * + constraint rename) — it never dropped the connector's OWN caches, so a follower that had queried the + * source table kept its latest-snapshot pin (and paimon's schema memo) to the 24h TTL after an atomic table + * swap. This pins that the replay now propagates {@code connector.invalidateTable} for BOTH the source and + * target names, keyed exactly like the coordinator {@code PluginDrivenExternalCatalog.renameTable} hook. + */ +public class RefreshManagerRenameReplayTest { + + private MockedStatic mockedEnv; + private CatalogMgr catalogMgr; + private RefreshManager refreshManager; + + @BeforeEach + public void setUp() { + Env mockEnv = Mockito.mock(Env.class); + catalogMgr = Mockito.mock(CatalogMgr.class); + ConstraintManager constraintManager = Mockito.mock(ConstraintManager.class); + mockedEnv = Mockito.mockStatic(Env.class); + mockedEnv.when(Env::getCurrentEnv).thenReturn(mockEnv); + Mockito.when(mockEnv.getCatalogMgr()).thenReturn(catalogMgr); + Mockito.when(mockEnv.getConstraintManager()).thenReturn(constraintManager); + refreshManager = new RefreshManager(); + } + + @AfterEach + public void tearDown() { + if (mockedEnv != null) { + mockedEnv.close(); + } + } + + @SuppressWarnings("unchecked") + private static ExternalDatabase mockDb() { + return (ExternalDatabase) Mockito.mock(ExternalDatabase.class); + } + + @Test + public void testRenameReplayInvalidatesConnectorSourceAndTargetOnFollower() { + // local db1.t1 -> t2, mapping to remote DB1.TBL1 (source). The source table is still in the replay + // cache when the rename replays (getDbForReplay/getTableForReplay resolve it), so the catalog is + // already initialized on this FE and getConnector() does not force-init. + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Connector connector = Mockito.mock(Connector.class); + ExternalDatabase db = mockDb(); + ExternalTable table = Mockito.mock(ExternalTable.class); + // doReturn (not when().thenReturn) because getCatalog returns a wildcard CatalogIf + // whose capture rejects a concrete PluginDrivenExternalCatalog in the typed stubbing form. + Mockito.doReturn(catalog).when(catalogMgr).getCatalog(7L); + Mockito.when(catalog.getName()).thenReturn("c"); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.doReturn(Optional.of(db)).when(catalog).getDbForReplay("db1"); + Mockito.when(db.getRemoteName()).thenReturn("DB1"); + Mockito.doReturn(Optional.of(table)).when(db).getTableForReplay("t1"); + Mockito.when(table.getRemoteName()).thenReturn("TBL1"); + + refreshManager.replayRefreshTable(ExternalObjectLog.createForRenameTable(7L, "db1", "t1", "t2")); + + // WHY (Rule 9 / R4): both the source (REMOTE DB1.TBL1) and the target (DB1.t2, new name NOT + // remote-resolved — parity with the coordinator) connector caches must be dropped so an atomic swap + // doesn't serve the pre-rename pin. MUTATION: removing the rename-branch invalidation, or passing the + // LOCAL names, turns this red. + Mockito.verify(connector).invalidateTable("DB1", "TBL1"); + Mockito.verify(connector).invalidateTable("DB1", "t2"); + // Base bookkeeping is preserved: the source name is unregistered and the db's name cache reset. + Mockito.verify(db).unregisterTable("t1"); + Mockito.verify(db).resetMetaCacheNames(); + } + + @Test + public void testRenameReplayUninitializedCatalogSkipsInvalidate() { + // A follower that never initialized this catalog: getDbForReplay returns empty, so the replay resolves + // no db/table and returns early — the connector is never consulted (no force-init, no cache to drop). + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Connector connector = Mockito.mock(Connector.class); + Mockito.doReturn(catalog).when(catalogMgr).getCatalog(7L); + Mockito.doReturn(Optional.empty()).when(catalog).getDbForReplay("db1"); + + refreshManager.replayRefreshTable(ExternalObjectLog.createForRenameTable(7L, "db1", "t1", "t2")); + + // WHY (R4 no-force-init): an uninitialized catalog has no connector cache to drop; the replay must not + // touch (or force-build) the connector. MUTATION: force-initializing / invalidating here -> red. + Mockito.verify(catalog, Mockito.never()).getConnector(); + Mockito.verifyNoInteractions(connector); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/FeNameFormatTest.java b/fe/fe-core/src/test/java/org/apache/doris/common/FeNameFormatTest.java index 336c693d82f31e..d8445a162667dc 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/common/FeNameFormatTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/common/FeNameFormatTest.java @@ -21,7 +21,7 @@ import org.apache.doris.qe.VariableMgr; import com.google.common.collect.Lists; -import org.apache.ivy.util.StringUtils; +import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSqlCacheManagerPluginTableTest.java b/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSqlCacheManagerPluginTableTest.java new file mode 100644 index 00000000000000..44cf75f2f11d75 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSqlCacheManagerPluginTableTest.java @@ -0,0 +1,118 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.common.cache; + +import org.apache.doris.analysis.UserIdentity; +import org.apache.doris.catalog.DatabaseIf; +import org.apache.doris.catalog.Env; +import org.apache.doris.catalog.TableIf.TableType; +import org.apache.doris.common.jmockit.Deencapsulation; +import org.apache.doris.datasource.CatalogIf; +import org.apache.doris.datasource.CatalogMgr; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable; +import org.apache.doris.nereids.SqlCacheContext; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Optional; + +/** + * Unit tests for the lookup-time freshness re-check the SQL-result-cache migration wired into + * {@link NereidsSqlCacheManager} for flipped lakehouse tables. The stored data-version token + * ({@code getNewestUpdateVersionOrTime()}) is compared against the live one: equal ⇒ NOT_CHANGED + * (cache may be served), different ⇒ CHANGED_AND_INVALIDATE_CACHE. This is the correctness core of the + * feature — a cache must invalidate exactly when the underlying data changes. RED on the pre-cutover HEAD, + * whose gate rejected {@code PLUGIN_EXTERNAL_TABLE} outright (always invalidate, never a hit) and re-checked + * only {@code instanceof HMSExternalTable}. + */ +public class NereidsSqlCacheManagerPluginTableTest { + + private static final String CTL = "hms_ctl"; + private static final String DB = "hms_db"; + private static final String TBL = "t"; + private static final long TABLE_ID = 42L; + + private PluginDrivenMvccExternalTable mockTable(long liveToken) { + PluginDrivenMvccExternalTable table = Mockito.mock(PluginDrivenMvccExternalTable.class); + DatabaseIf db = Mockito.mock(DatabaseIf.class); + CatalogIf catalog = Mockito.mock(CatalogIf.class); + Mockito.when(catalog.isInternalCatalog()).thenReturn(false); + Mockito.when(catalog.getName()).thenReturn(CTL); + Mockito.when(catalog.getProperties()).thenReturn(new java.util.HashMap<>()); + Mockito.when(db.getCatalog()).thenReturn(catalog); + Mockito.when(db.getFullName()).thenReturn(DB); + Mockito.when(table.getDatabase()).thenReturn(db); + Mockito.when(table.getId()).thenReturn(TABLE_ID); + Mockito.when(table.getName()).thenReturn(TBL); + Mockito.when(table.isTemporary()).thenReturn(false); + Mockito.when(table.getType()).thenReturn(TableType.PLUGIN_EXTERNAL_TABLE); + Mockito.when(table.getNewestUpdateVersionOrTime()).thenReturn(liveToken); + return table; + } + + /** Wires a mock Env so that findTableIf(env, {CTL,DB,TBL}) resolves to the given live table. */ + private Env mockEnvResolvingTo(PluginDrivenMvccExternalTable liveTable) { + Env env = Mockito.mock(Env.class); + CatalogMgr mgr = Mockito.mock(CatalogMgr.class); + CatalogIf catalog = Mockito.mock(CatalogIf.class); + DatabaseIf db = Mockito.mock(DatabaseIf.class); + Mockito.when(env.getCatalogMgr()).thenReturn(mgr); + Mockito.when(mgr.getCatalog(CTL)).thenReturn(catalog); + Mockito.doReturn(Optional.of(db)).when(catalog).getDb(DB); + Mockito.doReturn(Optional.of(liveTable)).when(db).getTable(TBL); + return env; + } + + private boolean isChangedField(Object verdict, String field) { + return Deencapsulation.getField(verdict, field); + } + + /** Same stored and live token ⇒ the cache is fresh (NOT_CHANGED). */ + @Test + public void testNotChangedWhenTokenUnchanged() { + long token = 1_700_000_000_000L; + SqlCacheContext context = new SqlCacheContext(UserIdentity.ROOT); + context.addUsedTable(mockTable(token)); // stores TableVersion(id, token, PLUGIN) + + Env env = mockEnvResolvingTo(mockTable(token)); // live token unchanged + Object verdict = Deencapsulation.invoke(new NereidsSqlCacheManager(), + "tablesOrDataChanged", env, context); + + Assertions.assertFalse(isChangedField(verdict, "changed"), + "an unchanged token must keep the cache (NOT_CHANGED)"); + } + + /** Live token advanced past the stored one ⇒ data changed ⇒ invalidate. */ + @Test + public void testInvalidateWhenTokenChanged() { + long storedToken = 1_700_000_000_000L; + SqlCacheContext context = new SqlCacheContext(UserIdentity.ROOT); + context.addUsedTable(mockTable(storedToken)); + + Env env = mockEnvResolvingTo(mockTable(storedToken + 1000L)); // data mutated: newer token + Object verdict = Deencapsulation.invoke(new NereidsSqlCacheManager(), + "tablesOrDataChanged", env, context); + + Assertions.assertTrue(isChangedField(verdict, "changed"), + "a changed token must invalidate the cache"); + Assertions.assertTrue(isChangedField(verdict, "invalidCache"), + "a data change must evict the stale entry, not just miss"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/proc/DbsProcDirTest.java b/fe/fe-core/src/test/java/org/apache/doris/common/proc/DbsProcDirTest.java index 5012c56651416c..0f273e7a7c35bc 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/common/proc/DbsProcDirTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/common/proc/DbsProcDirTest.java @@ -22,9 +22,9 @@ import org.apache.doris.common.AnalysisException; import org.apache.doris.common.Config; import org.apache.doris.common.FeConstants; +import org.apache.doris.datasource.ExternalCatalog; +import org.apache.doris.datasource.ExternalDatabase; import org.apache.doris.datasource.InternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergHadoopExternalCatalog; import org.apache.doris.transaction.GlobalTransactionMgr; import com.google.common.collect.Lists; @@ -185,10 +185,10 @@ public void testFetchResultInvalid() throws AnalysisException { @Test public void testListTableNameFailed() throws AnalysisException { - IcebergHadoopExternalCatalog ctlg = Mockito.mock(IcebergHadoopExternalCatalog.class); + ExternalCatalog ctlg = Mockito.mock(ExternalCatalog.class); Mockito.when(ctlg.getDbNames()).thenReturn(Lists.newArrayList("db1")); - IcebergExternalDatabase mockDb = Mockito.mock(IcebergExternalDatabase.class); + ExternalDatabase mockDb = Mockito.mock(ExternalDatabase.class); Mockito.when(mockDb.getId()).thenReturn(3L); Mockito.when(mockDb.getTables()).thenThrow(new RuntimeException("list table failed")); Mockito.doReturn(mockDb).when(ctlg).getDbNullable("db1"); diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/util/BrokerUtilTest.java b/fe/fe-core/src/test/java/org/apache/doris/common/util/BrokerUtilTest.java index b757904075234b..43614db3034761 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/common/util/BrokerUtilTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/common/util/BrokerUtilTest.java @@ -18,7 +18,7 @@ package org.apache.doris.common.util; import org.apache.doris.common.UserException; -import org.apache.doris.datasource.FilePartitionUtils; +import org.apache.doris.datasource.scan.FilePartitionUtils; import com.google.common.collect.Lists; import org.junit.Assert; diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/util/DatasourcePrintableMapTest.java b/fe/fe-core/src/test/java/org/apache/doris/common/util/DatasourcePrintableMapTest.java index dc2d3d2ce9e596..6cf98b511ed55c 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/common/util/DatasourcePrintableMapTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/common/util/DatasourcePrintableMapTest.java @@ -43,8 +43,13 @@ public void testSensitiveKeysContainAliyunDLFProperties() { Assertions.assertTrue(DatasourcePrintableMap.SENSITIVE_KEY.contains("bos_secret_accesskey")); Assertions.assertTrue(DatasourcePrintableMap.SENSITIVE_KEY.contains("jdbc.password")); Assertions.assertTrue(DatasourcePrintableMap.SENSITIVE_KEY.contains("elasticsearch.password")); + // All four iceberg REST secret keys must stay masked. These are enumerated explicitly in + // DatasourcePrintableMap (formerly reflected off the now-removed fe-core IcebergRestProperties); + // a dropped key is a silent SHOW CREATE CATALOG secret leak, so pin the full set. Assertions.assertTrue(DatasourcePrintableMap.SENSITIVE_KEY.contains("iceberg.rest.oauth2.credential")); Assertions.assertTrue(DatasourcePrintableMap.SENSITIVE_KEY.contains("iceberg.rest.oauth2.token")); + Assertions.assertTrue(DatasourcePrintableMap.SENSITIVE_KEY.contains("iceberg.rest.secret-access-key")); + Assertions.assertTrue(DatasourcePrintableMap.SENSITIVE_KEY.contains("iceberg.rest.session-token")); // Verify cloud storage related sensitive keys (these are constants added in static initialization block) Assertions.assertTrue(DatasourcePrintableMap.SENSITIVE_KEY.contains("s3.secret_key")); diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorContractValidatorTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorContractValidatorTest.java new file mode 100644 index 00000000000000..5e47ae7b7f6c85 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorContractValidatorTest.java @@ -0,0 +1,160 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorContractValidator; +import org.apache.doris.connector.api.handle.WriteOperation; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.EnumSet; + +/** + * Rule-9 behavior gates for {@link ConnectorContractValidator}: it must fail loud + * ({@link IllegalStateException}) when a connector's own delegators are internally inconsistent, and it + * must pass silently when they are not. These are the primary enforcement of the two structural invariants + * (static per-connector properties, checked here and in each connector's own contract test rather than at + * catalog registration). Fake {@link Connector}s (plain Mockito mocks, stubbing only the argless delegators + * the two invariants read) stand in for a real connector. + */ +public class ConnectorContractValidatorTest { + + @Test + void validatorRejectsBranchWithoutInsert() { + // Invariant #2: supportsWriteBranch() implies supportedWriteOperations() contains INSERT (a + // branch write is an INSERT modifier, never a capability on its own). A connector claiming + // branch support with no declared INSERT is self-contradictory -> 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)); + + IllegalStateException ex = Assertions.assertThrows(IllegalStateException.class, + () -> ConnectorContractValidator.validate(fake, "fake_branch_no_insert")); + Assertions.assertTrue(ex.getMessage().contains("supportsWriteBranch"), "got: " + ex.getMessage()); + Assertions.assertTrue(ex.getMessage().contains("fake_branch_no_insert"), "got: " + ex.getMessage()); + } + + @Test + void validatorRejectsLocalSortWithoutParallelAndFullSchema() { + // Invariant #3: requiresPartitionLocalSort() implies BOTH requiresParallelWrite() AND + // requiresFullSchemaWriteOrder() — the local-sort write plan hash-distributes by partition + // 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); + + IllegalStateException ex = Assertions.assertThrows(IllegalStateException.class, + () -> ConnectorContractValidator.validate(fake, "fake_localsort_no_parallel")); + Assertions.assertTrue(ex.getMessage().contains("requiresPartitionLocalSort"), "got: " + ex.getMessage()); + Assertions.assertTrue(ex.getMessage().contains("fake_localsort_no_parallel"), "got: " + ex.getMessage()); + } + + @Test + void validatorRejectsLocalSortWithoutFullSchema() { + // Invariant #3, the OTHER half: local-sort with parallel write but WITHOUT full-schema write order is + // equally self-contradictory. This is the distinguishing input (localSort=T, parallel=T, fullSchema=F) + // 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); + + IllegalStateException ex = Assertions.assertThrows(IllegalStateException.class, + () -> ConnectorContractValidator.validate(fake, "fake_localsort_no_fullschema")); + Assertions.assertTrue(ex.getMessage().contains("requiresPartitionLocalSort"), "got: " + ex.getMessage()); + Assertions.assertTrue(ex.getMessage().contains("fake_localsort_no_fullschema"), "got: " + ex.getMessage()); + } + + @Test + void validatorRejectsHashWriteWithoutParallelAndFullSchema() { + // Invariant #4: requiresPartitionHashWrite() (hash-by-partition without a local sort) likewise + // 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); + + IllegalStateException ex = Assertions.assertThrows(IllegalStateException.class, + () -> ConnectorContractValidator.validate(fake, "fake_hash_no_parallel")); + Assertions.assertTrue(ex.getMessage().contains("requiresPartitionHashWrite"), "got: " + ex.getMessage()); + Assertions.assertTrue(ex.getMessage().contains("fake_hash_no_parallel"), "got: " + ex.getMessage()); + } + + @Test + void validatorRejectsBothPartitionDistributionArms() { + // Invariant #5: the two hash arms are mutually exclusive. PhysicalConnectorTableSink checks + // requirePartitionLocalSortOnWrite() BEFORE requirePartitionHashOnWrite(), so a connector declaring + // 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); + + IllegalStateException ex = Assertions.assertThrows(IllegalStateException.class, + () -> ConnectorContractValidator.validate(fake, "fake_both_arms")); + Assertions.assertTrue(ex.getMessage().contains("requiresPartitionHashWrite"), "got: " + ex.getMessage()); + Assertions.assertTrue(ex.getMessage().contains("fake_both_arms"), "got: " + ex.getMessage()); + } + + @Test + 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()) + .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); + + Assertions.assertDoesNotThrow(() -> ConnectorContractValidator.validate(fake, "fake_hash_consistent")); + } + + @Test + void validatorPassesForAnInternallyConsistentConnector() { + // Positive control (Rule 9): a maxcompute-shaped fake (parallel write + full-schema write order + + // 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()) + .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); + + Assertions.assertDoesNotThrow(() -> ConnectorContractValidator.validate(fake, "fake_consistent")); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorSessionImplTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorSessionImplTest.java index fe9e3e68cde6d9..e745aa6af8d289 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorSessionImplTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorSessionImplTest.java @@ -17,13 +17,19 @@ package org.apache.doris.connector; +import org.apache.doris.connector.api.ConnectorDelegatedCredential; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.nereids.StatementContext; +import org.apache.doris.qe.ConnectContext; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import java.util.HashMap; import java.util.Map; +import java.util.Optional; /** * Tests for {@link ConnectorSessionImpl} and {@link ConnectorSessionBuilder}. @@ -178,4 +184,152 @@ public void testDefaultValues() { Assertions.assertEquals("en_US", session.getLocale()); Assertions.assertEquals("", session.getCatalogName()); } + + // ──────────────── transaction binding (P4-T06a W-a / gap G1) ──────────────── + + // The session is otherwise immutable, but the insert executor binds a connector + // transaction onto it at write time (setCurrentTransaction) so the connector's + // planWrite can read it back (getCurrentTransaction). If this round-trip regresses, + // the maxcompute write plan fails loud ("no transaction on session") at bind time. + + @Test + public void testCurrentTransactionIsEmptyBeforeBinding() { + ConnectorSession session = ConnectorSessionBuilder.create().build(); + Assertions.assertEquals(Optional.empty(), session.getCurrentTransaction(), + "a freshly built session must carry no transaction"); + } + + @Test + public void testSetCurrentTransactionBindsThenReadsBackSameInstance() { + ConnectorSession session = ConnectorSessionBuilder.create().build(); + ConnectorTransaction txn = new StubConnectorTransaction(1234L); + + session.setCurrentTransaction(txn); + + Optional bound = session.getCurrentTransaction(); + Assertions.assertTrue(bound.isPresent(), "transaction must be present after binding"); + Assertions.assertSame(txn, bound.get(), + "getCurrentTransaction must return the exact instance the executor bound, " + + "because planWrite stamps that transaction's id into the sink"); + } + + @Test + public void testSetCurrentTransactionNullUnbindsToEmpty() { + ConnectorSession session = ConnectorSessionBuilder.create().build(); + session.setCurrentTransaction(new StubConnectorTransaction(1L)); + + session.setCurrentTransaction(null); + + Assertions.assertEquals(Optional.empty(), session.getCurrentTransaction(), + "binding null must clear the transaction back to empty (Optional.ofNullable semantics)"); + } + + // ──────────── delegated-credential injection (SUPPORTS_USER_SESSION gate, #63068) ──────────── + + @Test + public void capableConnectorReceivesDelegatedCredentialAndSessionId() { + // A SUPPORTS_USER_SESSION connector: the offered credential + stable session id are carried onto the + // session so the connector can project the user's identity onto the remote metadata source. + ConnectorDelegatedCredential cred = + new ConnectorDelegatedCredential(ConnectorDelegatedCredential.Type.ACCESS_TOKEN, "user-token"); + ConnectorSession session = ConnectorSessionBuilder.create() + .withQueryId("q1") + .withUserSessionCapability(true) + .withSessionId("stable-session-1") + .withDelegatedCredential(cred) + .build(); + + Assertions.assertTrue(session.getDelegatedCredential().isPresent(), + "a capable connector receives the delegated credential"); + Assertions.assertSame(cred, session.getDelegatedCredential().get()); + Assertions.assertEquals("stable-session-1", session.getSessionId(), + "the stable session id (AuthSession key) is carried, not the queryId"); + } + + @Test + public void nonCapableConnectorSkipsDelegatedCredential() { + // Least-privilege: without withUserSessionCapability(true) (the default), the offered credential is NOT + // carried — a connector that would never consume the OIDC token never receives it. The session id then + // falls back to the queryId. + ConnectorSession session = ConnectorSessionBuilder.create() + .withQueryId("q1") + .withSessionId("stable-session-1") + .withDelegatedCredential( + new ConnectorDelegatedCredential(ConnectorDelegatedCredential.Type.ACCESS_TOKEN, "tok")) + .build(); + + Assertions.assertFalse(session.getDelegatedCredential().isPresent(), + "a non-capable connector must never receive the delegated credential (least-privilege)"); + Assertions.assertEquals("q1", session.getSessionId(), + "with no credential carried, the session id falls back to the queryId"); + } + + @Test + public void capableConnectorWithNoOfferedCredentialCarriesNone() { + // The capability gate alone does not manufacture a credential: a capable session with none offered still + // exposes an empty credential (the connector then fails closed on the actual metadata read). + ConnectorSession session = ConnectorSessionBuilder.create() + .withQueryId("q1") + .withUserSessionCapability(true) + .build(); + + Assertions.assertFalse(session.getDelegatedCredential().isPresent()); + } + + @Test + public void explicitNoneStatementScopeWinsOverLiveContext() { + // A cross-statement background loader (PluginDrivenExternalCatalog#buildCrossStatementSession) forces the + // per-statement scope to NONE so a metadata it resolves is never memoized into — nor closed with — the + // live statement's scope, even when the loader runs on a thread that has one (e.g. fetchRowCount reached + // synchronously from AnalysisManager.buildAnalysisJobInfo on the ANALYZE execution thread). This pins the + // builder guarantee that helper relies on: an explicit withStatementScope(NONE) wins over the scope + // captured from the live ConnectContext. MUTATION: capture ignoring the explicit override -> the loader + // binds to the live statement scope (the leak) -> red. + ConnectContext ctx = new ConnectContext(); + ctx.setThreadLocalInfo(); + try { + StatementContext stmtCtx = new StatementContext(); + ctx.setStatementContext(stmtCtx); + ConnectorStatementScope live = stmtCtx.getOrCreateConnectorStatementScope(); + + // A default session capture binds to the live statement scope (the path a loader must avoid). + ConnectorSession bound = ConnectorSessionBuilder.from(ctx).withCatalogId(1L).build(); + Assertions.assertSame(live, bound.getStatementScope(), + "a default session capture binds to the live statement scope"); + + // The forced-NONE session (what buildCrossStatementSession builds) wins over the live scope. + ConnectorSession crossStatement = ConnectorSessionBuilder.from(ctx).withCatalogId(1L) + .withStatementScope(ConnectorStatementScope.NONE).build(); + Assertions.assertSame(ConnectorStatementScope.NONE, crossStatement.getStatementScope(), + "an explicit NONE scope wins over the live statement context (forced read-through)"); + } finally { + ConnectContext.remove(); + } + } + + /** Minimal hand-written {@link ConnectorTransaction}; only identity matters for this test. */ + private static final class StubConnectorTransaction implements ConnectorTransaction { + private final long txnId; + + private StubConnectorTransaction(long txnId) { + this.txnId = txnId; + } + + @Override + public long getTransactionId() { + return txnId; + } + + @Override + public void commit() { + } + + @Override + public void rollback() { + } + + @Override + public void close() { + } + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorStatementScopeTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorStatementScopeTest.java new file mode 100644 index 00000000000000..b7d868e4a3f43e --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorStatementScopeTest.java @@ -0,0 +1,214 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.ConnectorWriteOps; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.datasource.plugin.CatalogStatementTransaction; +import org.apache.doris.nereids.StatementContext; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.transaction.PluginDrivenTransactionManager; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Tests for the per-statement {@link ConnectorStatementScope}: the {@link ConnectorStatementScope#NONE} no-op, + * the memoizing {@link ConnectorStatementScopeImpl}, and the {@link StatementContext} hosting + per-execution + * reset a reused prepared statement relies on. + */ +public class ConnectorStatementScopeTest { + + @Test + public void noneNeverMemoizes() { + // NONE = the off-context default (offline / no live statement): the loader runs every call, so a + // connector under NONE behaves byte-identically to loading every time. MUTATION: NONE memoizing -> the + // second call reuses the first value -> red. + AtomicInteger loads = new AtomicInteger(); + ConnectorStatementScope none = ConnectorStatementScope.NONE; + Object first = none.computeIfAbsent("k", () -> { + loads.incrementAndGet(); + return new Object(); + }); + Object second = none.computeIfAbsent("k", () -> { + loads.incrementAndGet(); + return new Object(); + }); + Assertions.assertEquals(2, loads.get(), "NONE must run the loader every time (no memo)"); + Assertions.assertNotSame(first, second, "NONE returns a fresh value each call"); + } + + @Test + public void implMemoizesPerKeyAndIsolatesKeys() { + // The real scope memoizes per key: the same key returns the same instance to every caller (this is what + // makes a statement's read/scan/write resolvers share ONE loaded table), and the loader runs once. + // MUTATION: not memoizing -> two instances / two loads -> red. + ConnectorStatementScope scope = new ConnectorStatementScopeImpl(); + AtomicInteger loads = new AtomicInteger(); + Object firstA = scope.computeIfAbsent("A", () -> { + loads.incrementAndGet(); + return new Object(); + }); + Object secondA = scope.computeIfAbsent("A", () -> { + loads.incrementAndGet(); + return new Object(); + }); + Assertions.assertSame(firstA, secondA, "the same key returns the same instance (read/write share one load)"); + Assertions.assertEquals(1, loads.get(), "the loader runs once per key"); + + Object b = scope.computeIfAbsent("B", Object::new); + Assertions.assertNotSame(firstA, b, "different keys are isolated (cross-catalog / cross-table)"); + } + + @Test + public void statementContextScopeIsStableThenResetsForReusedPreparedContext() { + // StatementContext lazily builds one scope and reuses it across the statement (getOrCreate...). A prepared + // EXECUTE reuses one StatementContext across executions; resetConnectorStatementScope() (called by + // ExecuteCommand each execution) drops it so a prior execution's memoized state never leaks into the next. + // MUTATION: reset not clearing the field -> s2 == s1 and the stale value leaks -> red. + StatementContext ctx = new StatementContext(); + ConnectorStatementScope s1 = ctx.getOrCreateConnectorStatementScope(); + Assertions.assertSame(s1, ctx.getOrCreateConnectorStatementScope(), + "the scope is lazily created once and reused across a statement"); + + Object memoized = s1.computeIfAbsent("k", Object::new); + ctx.resetConnectorStatementScope(); + + ConnectorStatementScope s2 = ctx.getOrCreateConnectorStatementScope(); + Assertions.assertNotSame(s1, s2, "reset drops the scope so a reused prepared context starts fresh"); + Assertions.assertNotSame(memoized, s2.computeIfAbsent("k", Object::new), + "a prior execution's memoized value must not leak into the next execution"); + } + + @Test + public void closeAllClosesCloseableValuesOnceAndIgnoresPlainValues() throws Exception { + // closeAll() closes every AutoCloseable value the statement memoized (a ConnectorMetadata is Closeable) + // and leaves non-closeable values (the shared table object, the scan->write delete-supply map) untouched. + // It must be idempotent: the engine fires it from more than one locus (the query-finish callback + a reused + // prepared statement's per-execution reset), so a second call must not double-close. + // MUTATION: dropping the close-once guard -> closes==2 -> red. + ConnectorStatementScope scope = new ConnectorStatementScopeImpl(); + AtomicInteger closes = new AtomicInteger(); + AutoCloseable closeable = () -> closes.incrementAndGet(); + scope.computeIfAbsent("closeable", () -> closeable); + scope.computeIfAbsent("plain", Object::new); // a non-closeable value must be ignored, not crash + + scope.closeAll(); + scope.closeAll(); + + Assertions.assertEquals(1, closes.get(), + "each closeable value is closed exactly once across repeated closeAll (idempotent)"); + } + + @Test + public void closeAllFinalizesTransactionsBeforeClosingMetadata() { + // Two-pass teardown: the scope must finalize (roll back an orphaned) write transaction BEFORE it closes + // the shared metadata instance the transaction was minted from, so the transaction is never left holding a + // closed instance. MUTATION: single-pass close (or closing metadata first) -> the recorded order flips + // -> red. + List order = new ArrayList<>(); + PluginDrivenTransactionManager mgr = new PluginDrivenTransactionManager(); + ConnectorTransaction tx = Mockito.mock(ConnectorTransaction.class); + Mockito.when(tx.getTransactionId()).thenReturn(90001L); + Mockito.doAnswer(inv -> { + order.add("txn-rollback"); + return null; + }).when(tx).rollback(); + ConnectorWriteOps ops = Mockito.mock(ConnectorWriteOps.class); + Mockito.when(ops.beginTransaction(Mockito.any(), Mockito.any())).thenReturn(tx); + + ConnectorStatementScopeImpl scope = new ConnectorStatementScopeImpl(); + // The statement's shared metadata: a closeable that records the moment it is closed. + AutoCloseable metadata = () -> order.add("metadata-close"); + scope.computeIfAbsent("metadata:1", () -> metadata); + CatalogStatementTransaction holder = + new CatalogStatementTransaction(ops, Mockito.mock(ConnectorSession.class), mgr); + scope.computeIfAbsent("txn:1", () -> holder); + holder.begin(new ConnectorTableHandle() { }); // active orphan: the executor never committed it + + scope.closeAll(); + + Assertions.assertEquals(Arrays.asList("txn-rollback", "metadata-close"), order, + "the transaction is finalized before the shared metadata is closed"); + } + + @Test + public void resetClosesTheDroppedScopeBeforeStartingFresh() { + // A prepared EXECUTE / retry reuses one StatementContext and calls resetConnectorStatementScope() at the + // start of each execution/attempt. Reset must CLOSE the outgoing scope's closeable values (else the prior + // execution's tables/FileIO leak once close() does real work) and then drop it so the next starts fresh. + // MUTATION: reset only nulling (not closing) -> closes==0 -> red. + StatementContext ctx = new StatementContext(); + ConnectorStatementScope s1 = ctx.getOrCreateConnectorStatementScope(); + AtomicInteger closes = new AtomicInteger(); + AutoCloseable closeable = () -> closes.incrementAndGet(); + s1.computeIfAbsent("k", () -> closeable); + + ctx.resetConnectorStatementScope(); + + Assertions.assertEquals(1, closes.get(), "reset closes the dropped scope's closeable values before dropping it"); + Assertions.assertNotSame(s1, ctx.getOrCreateConnectorStatementScope(), + "reset drops the scope so the next execution/attempt starts fresh"); + } + + @Test + public void closeClosesScopeWhenReturningResultLocally() { + // A statement that never reaches the query-finish callback (external DDL / SHOW via Command.run) is + // closed by StatementContext.close(), the fallback ConnectProcessor runs in its per-statement finally. + // It fires only when the result is produced locally. MUTATION: close() not closing the scope -> red. + ConnectContext connectContext = Mockito.mock(ConnectContext.class); + Mockito.when(connectContext.isReturnResultFromLocal()).thenReturn(true); + StatementContext ctx = new StatementContext(connectContext, null); + ConnectorStatementScope scope = ctx.getOrCreateConnectorStatementScope(); + AtomicInteger closes = new AtomicInteger(); + AutoCloseable closeable = () -> closes.incrementAndGet(); + scope.computeIfAbsent("k", () -> closeable); + + ctx.close(); + + Assertions.assertEquals(1, closes.get(), "close() closes the scope for a locally-returned statement"); + } + + @Test + public void closeDefersScopeCloseForAsyncResult() { + // An arrow-flight statement returns results asynchronously (isReturnResultFromLocal == false) and defers + // scope cleanup to its own query-finish close; StatementContext.close() must NOT close it early here, or an + // in-flight fetch would touch a closed scope. MUTATION: dropping the guard -> closes==1 -> red. + ConnectContext connectContext = Mockito.mock(ConnectContext.class); + Mockito.when(connectContext.isReturnResultFromLocal()).thenReturn(false); + StatementContext ctx = new StatementContext(connectContext, null); + ConnectorStatementScope scope = ctx.getOrCreateConnectorStatementScope(); + AtomicInteger closes = new AtomicInteger(); + AutoCloseable closeable = () -> closes.incrementAndGet(); + scope.computeIfAbsent("k", () -> closeable); + + ctx.close(); + + Assertions.assertEquals(0, closes.get(), + "close() defers to the query-finish callback for async (arrow-flight) results"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorWriteDelegationTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorWriteDelegationTest.java new file mode 100644 index 00000000000000..6474ca0ae9ea90 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorWriteDelegationTest.java @@ -0,0 +1,78 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.handle.ConnectorWriteHandle; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.write.ConnectorSinkPlan; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.EnumSet; +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}. + */ +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()); + + ConnectorWritePlanProvider prov = new ConnectorWritePlanProvider() { + @Override + public ConnectorSinkPlan planWrite(ConnectorSession s, ConnectorWriteHandle h) { + return null; + } + + @Override + 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); + Assertions.assertEquals(EnumSet.of(WriteOperation.INSERT, WriteOperation.OVERWRITE), + w.supportedWriteOperations()); + Assertions.assertTrue(w.requiresParallelWrite()); + Assertions.assertFalse(w.requiresPartitionLocalSort()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextBackendStoragePropsTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextBackendStoragePropsTest.java new file mode 100644 index 00000000000000..1b7ffef470306a --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextBackendStoragePropsTest.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; + +import org.apache.doris.datasource.property.storage.StorageProperties; +import org.apache.doris.kerberos.ExecutionAuthenticator; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * FIX-STATIC-CREDS-BE (B-9) fe-core bridge test: pins that + * {@link DefaultConnectorContext#getBackendStorageProperties} translates the catalog's parsed + * {@code StorageProperties} map into the BE-canonical {@code AWS_*} keys (the same + * {@code CredentialUtils.getBackendPropertiesFromStorageMap} legacy {@code PaimonScanNode} returns + * from {@code getLocationProperties()}). The paimon connector cannot import that machinery, so this + * hook is its only access; without it the connector ships raw {@code s3.access_key}/{@code oss.*} + * aliases to BE, whose native (FILE_S3) reader understands only {@code AWS_*} -> no usable + * credentials -> 403 on a private bucket. FAILS before the fix (the method is a no-op default + * returning empty). + */ +public class DefaultConnectorContextBackendStoragePropsTest { + + private static final Supplier NOOP_AUTH = + () -> new ExecutionAuthenticator() {}; + + /** A context whose storage-props supplier yields a real OSS storage-properties map, built with + * the same {@code StorageProperties.createAll} machinery a real OSS catalog uses. */ + private static DefaultConnectorContext ossContext() throws Exception { + Map oss = new HashMap<>(); + oss.put("oss.endpoint", "oss-cn-beijing.aliyuncs.com"); + oss.put("oss.access_key", "ak"); + oss.put("oss.secret_key", "sk"); + List all = StorageProperties.createAll(oss); + Map map = all.stream() + .collect(Collectors.toMap(StorageProperties::getType, Function.identity(), (a, b) -> a)); + return new DefaultConnectorContext("c", 1L, NOOP_AUTH, () -> map); + } + + @Test + public void normalizesStaticOssCredsToBackendAwsProps() throws Exception { + // WHY (BLOCKER B-9): the BE native S3/object-store reader consumes ONLY canonical AWS_* keys; + // the raw oss.access_key/oss.secret_key catalog aliases are unintelligible to it. The bridge + // must run getBackendPropertiesFromStorageMap to produce them. MUTATION: returning the no-op + // default (empty), or echoing the raw oss.* keys -> AWS_ACCESS_KEY absent -> red. + Map be = ossContext().getBackendStorageProperties(); + + Assertions.assertEquals("ak", be.get("AWS_ACCESS_KEY")); + Assertions.assertEquals("sk", be.get("AWS_SECRET_KEY")); + Assertions.assertNotNull(be.get("AWS_ENDPOINT"), "endpoint must be emitted as canonical AWS_ENDPOINT"); + Assertions.assertFalse(be.containsKey("oss.access_key"), + "the raw catalog alias must NOT survive to BE (that is the B-9 bug)"); + } + + @Test + public void noStorageMapYieldsEmpty() { + // WHY: a context with no storage map (non-plugin ctor, or a credential-less local-FS warehouse) + // must short-circuit to empty -> no overlay, parity with legacy + // getBackendPropertiesFromStorageMap({}). MUTATION: NPE, or fabricating props from nothing -> red. + Assertions.assertTrue(new DefaultConnectorContext("c", 1L).getBackendStorageProperties().isEmpty()); + } +} 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 new file mode 100644 index 00000000000000..aa97df02c0a556 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextCleanupTest.java @@ -0,0 +1,104 @@ +// 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.filesystem.Location; +import org.apache.doris.fs.MemoryFileSystem; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +/** + * Tests the engine-side empty-directory pruning that backs {@link ConnectorContext#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. + */ +public class DefaultConnectorContextCleanupTest { + + @Test + public void deletesFullyEmptyDirectory() throws Exception { + MemoryFileSystem fs = new MemoryFileSystem(); + Location dir = Location.of("hdfs://nn/wh/db/t"); + fs.mkdirs(dir); + fs.mkdirs(dir.resolve("data")); + + Assertions.assertTrue(DefaultConnectorContext.deleteEmptyDirectory(fs, dir)); + Assertions.assertFalse(fs.exists(dir)); + } + + @Test + public void keepsDirectoryThatContainsAFile() throws Exception { + MemoryFileSystem fs = new MemoryFileSystem(); + Location dir = Location.of("hdfs://nn/wh/db/t"); + fs.mkdirs(dir); + Location file = dir.resolve("part-0"); + fs.put(file, new byte[] {1}); + + // WHY (Rule 9/12): the cleanup must NEVER delete a directory still holding data — a broken abort + // condition would silently destroy table files. MUTATION: flipping the `return false` on a + // non-directory entry would make this red. + Assertions.assertFalse(DefaultConnectorContext.deleteEmptyDirectory(fs, dir)); + Assertions.assertTrue(fs.exists(dir)); + Assertions.assertTrue(fs.exists(file)); + } + + @Test + public void deletesEmptyTableLocationWithChildDirs() throws Exception { + MemoryFileSystem fs = new MemoryFileSystem(); + Location table = Location.of("hdfs://nn/wh/db/t"); + fs.mkdirs(table); + fs.mkdirs(table.resolve("data")); + fs.mkdirs(table.resolve("metadata")); + + Assertions.assertTrue( + DefaultConnectorContext.deleteEmptyTableLocation(fs, table, Arrays.asList("data", "metadata"))); + Assertions.assertFalse(fs.exists(table)); + } + + @Test + public void keepsTableLocationWhenAChildHoldsAFile() throws Exception { + MemoryFileSystem fs = new MemoryFileSystem(); + Location table = Location.of("hdfs://nn/wh/db/t"); + fs.mkdirs(table); + fs.mkdirs(table.resolve("metadata")); + fs.put(table.resolve("data").resolve("part-0"), new byte[] {1}); + + Assertions.assertFalse( + DefaultConnectorContext.deleteEmptyTableLocation(fs, table, Arrays.asList("data", "metadata"))); + Assertions.assertTrue(fs.exists(table)); + } + + @Test + public void cleanupBlankLocationIsNoOp() { + DefaultConnectorContext ctx = new DefaultConnectorContext("test", 1L); + // No storage supplier wired + blank location: must be a silent no-op (never throws). + Assertions.assertDoesNotThrow(() -> ctx.cleanupEmptyManagedLocation("", Arrays.asList("data", "metadata"))); + Assertions.assertDoesNotThrow(() -> ctx.cleanupEmptyManagedLocation(null, null)); + } + + @Test + public void cleanupWithNoStoragePropertiesIsNoOp() { + // Default ctor wires an empty storage supplier -> no FileSystem to build -> no-op, no throw. + DefaultConnectorContext ctx = new DefaultConnectorContext("test", 1L); + Assertions.assertDoesNotThrow( + () -> ctx.cleanupEmptyManagedLocation("hdfs://nn/wh/db/t", Arrays.asList("data", "metadata"))); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextFileSystemTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextFileSystemTest.java new file mode 100644 index 00000000000000..2f8aa6356afa70 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextFileSystemTest.java @@ -0,0 +1,113 @@ +// 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.datasource.property.storage.StorageProperties; +import org.apache.doris.filesystem.FileSystem; +import org.apache.doris.fs.SpiSwitchingFileSystem; +import org.apache.doris.kerberos.ExecutionAuthenticator; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.Map; +import java.util.function.Supplier; + +/** + * HIVEFS-3: pins {@link DefaultConnectorContext#getFileSystem} — the engine-owned, per-catalog FileSystem is + * built lazily, cached (one instance reused across scans), returns {@code null} when the catalog has no + * storage, and is closed exactly once on teardown. The connector read/write paths borrow this FS and must + * not close it, so the engine owning close is load-bearing. + */ +public class DefaultConnectorContextFileSystemTest { + + private static final Supplier NOOP_AUTH = () -> new ExecutionAuthenticator() {}; + + /** Records close() so the test can assert the engine forwards teardown to the cached filesystem. */ + private static final class RecordingFileSystem extends SpiSwitchingFileSystem { + private int closeCount; + + private RecordingFileSystem() { + super((FileSystem) null); // test-delegate ctor; no path is ever routed through it + } + + @Override + public void close() { + closeCount++; + } + } + + /** Context that intercepts the FS build with a recording fake (no real storage/FS wiring needed). */ + private static final class RecordingContext extends DefaultConnectorContext { + private final RecordingFileSystem fs = new RecordingFileSystem(); + private int buildCount; + + private RecordingContext(Supplier> storageSupplier) { + super("c", 1L, NOOP_AUTH, storageSupplier); + } + + @Override + FileSystem buildCatalogFileSystem(Map storageProps) { + buildCount++; + return fs; + } + } + + private static Map nonEmptyStorage() { + // The value is irrelevant — RecordingContext overrides the actual FS build; only non-emptiness matters, + // because getFileSystem returns null on an empty storage map. + return Collections.singletonMap(StorageProperties.Type.HDFS, (StorageProperties) null); + } + + @Test + public void returnsNullWhenCatalogHasNoStorage() { + // 2-arg ctor -> empty storage map -> no engine-managed filesystem (parity with + // getBackendStorageProperties). MUTATION: building an FS over the empty map -> non-null -> red. + Assertions.assertNull(new DefaultConnectorContext("c", 1L).getFileSystem(null)); + } + + @Test + public void lazilyBuildsAndReusesSingleInstance() { + RecordingContext ctx = new RecordingContext(DefaultConnectorContextFileSystemTest::nonEmptyStorage); + FileSystem first = ctx.getFileSystem(null); + FileSystem second = ctx.getFileSystem(null); + Assertions.assertNotNull(first); + Assertions.assertSame(first, second, "getFileSystem must cache and reuse one instance"); + Assertions.assertEquals(1, ctx.buildCount, "the catalog filesystem must be built exactly once"); + } + + @Test + public void closeForwardsToCachedFileSystemAndIsIdempotent() throws Exception { + RecordingContext ctx = new RecordingContext(DefaultConnectorContextFileSystemTest::nonEmptyStorage); + ctx.getFileSystem(null); // build + cache + ctx.close(); + ctx.close(); // idempotent + Assertions.assertEquals(1, ctx.fs.closeCount, "close must forward to the cached FS exactly once"); + // After teardown the context yields no filesystem and does not rebuild. + Assertions.assertNull(ctx.getFileSystem(null)); + Assertions.assertEquals(1, ctx.buildCount, "getFileSystem must not rebuild after close"); + } + + @Test + public void closeWithoutGetFileSystemIsNoop() throws Exception { + RecordingContext ctx = new RecordingContext(DefaultConnectorContextFileSystemTest::nonEmptyStorage); + ctx.close(); // never built a filesystem + Assertions.assertEquals(0, ctx.fs.closeCount, "close must not touch a filesystem that was never built"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextNormalizeUriTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextNormalizeUriTest.java new file mode 100644 index 00000000000000..b2f95c634b8fb8 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextNormalizeUriTest.java @@ -0,0 +1,226 @@ +// 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.datasource.property.storage.StorageProperties; +import org.apache.doris.kerberos.ExecutionAuthenticator; +import org.apache.doris.thrift.TFileType; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; + +/** + * FIX-URI-NORMALIZE fe-core bridge test: pins that + * {@link DefaultConnectorContext#normalizeStorageUri} rewrites a connector-supplied storage URI to + * BE's canonical {@code s3://} scheme using the catalog's storage properties (the same + * {@code LocationPath} normalization legacy {@code PaimonScanNode} applies via the 2-arg + * {@code LocationPath.of(path, storagePropertiesMap)}). The paimon connector cannot import that + * machinery, so this hook is its only access; without it a native ORC/Parquet read on an + * OSS/COS/OBS warehouse reaches BE with an un-openable {@code oss://} path (data file fails, or a + * deletion vector is silently dropped). FAILS before the fix (the method is a no-op default + * returning the raw URI). + */ +public class DefaultConnectorContextNormalizeUriTest { + + private static final Supplier NOOP_AUTH = + () -> new ExecutionAuthenticator() {}; + + /** A context whose storage-props supplier yields a real OSS storage-properties map, built with + * the same {@code StorageProperties.createAll} machinery a real OSS catalog uses. */ + private static DefaultConnectorContext ossContext() throws Exception { + Map oss = new HashMap<>(); + oss.put("oss.endpoint", "oss-cn-beijing.aliyuncs.com"); + oss.put("oss.access_key", "ak"); + oss.put("oss.secret_key", "sk"); + List all = StorageProperties.createAll(oss); + Map map = all.stream() + .collect(Collectors.toMap(StorageProperties::getType, Function.identity(), (a, b) -> a)); + return new DefaultConnectorContext("c", 1L, NOOP_AUTH, () -> map); + } + + @Test + public void normalizesOssSchemeToS3() throws Exception { + // WHY: BE's scheme-dispatched S3 file factory only recognizes s3://; legacy LocationPath.of + // rewrites oss:// (and cos/obs/s3a) -> s3://. This hook is the connector's ONLY access to that + // normalization (it must not import LocationPath). MUTATION: returning the raw oss:// path + // (the no-op SPI default) -> red. + Assertions.assertEquals("s3://bkt/warehouse/db/t/part-0.parquet", + ossContext().normalizeStorageUri("oss://bkt/warehouse/db/t/part-0.parquet")); + } + + @Test + public void s3SchemeIsUnchanged() throws Exception { + // WHY: an already-canonical s3:// path must pass through unchanged (idempotent fast path). + // MUTATION: mangling the s3:// path -> red. + Assertions.assertEquals("s3://bkt/warehouse/f.parquet", + ossContext().normalizeStorageUri("s3://bkt/warehouse/f.parquet")); + } + + @Test + public void nullOrBlankIsReturnedUnchanged() throws Exception { + // WHY: defensive short-circuit before touching the storage-props supplier -> no NPE on a + // null/blank path. MUTATION: NPE, or fabricating output from nothing -> red. + Assertions.assertNull(ossContext().normalizeStorageUri(null)); + Assertions.assertEquals("", ossContext().normalizeStorageUri("")); + } + + @Test + public void failsLoudWhenNoStoragePropertiesForScheme() { + // WHY: a context with no storage-properties map must FAIL LOUD on a real path rather than + // silently shipping the raw oss:// to BE (which would corrupt reads). Mirrors legacy + // LocationPath.of(path, {}) throwing StoragePropertiesException. The ctors that do not wire a + // storage map are never used by paimon, but the fail-loud contract is pinned here. + // MUTATION: swallowing the error and returning the raw path -> red. + DefaultConnectorContext noStorage = new DefaultConnectorContext("c", 1L); + Assertions.assertThrows(RuntimeException.class, + () -> noStorage.normalizeStorageUri("oss://bkt/a/part-0.parquet")); + } + + // ---- FIX-REST-VENDED-URI-NORMALIZE (P9-1): the 2-arg overload normalizes via the per-table + // vended token, which is the ONLY storage map a REST catalog has (its static map is empty). ---- + + /** The raw per-table OSS vended token shape a REST catalog returns (mirrors + * DefaultConnectorContextVendTest / PaimonVendedCredentialsProviderTest). */ + private static Map ossVendedToken() { + Map token = new HashMap<>(); + token.put("fs.oss.accessKeyId", "STS.testAccessKey123"); + token.put("fs.oss.accessKeySecret", "testSecretKey456"); + token.put("fs.oss.securityToken", "testSessionToken789"); + token.put("fs.oss.endpoint", "oss-cn-beijing.aliyuncs.com"); + return token; + } + + @Test + public void vendedRestCredentialsNormalizeUnderEmptyStaticMap() { + // THE BUG (P9-1, BLOCKER): a REST catalog's static storage map is EMPTY by design (vended creds + // are per-table/dynamic), so the static-only path throws "No storage properties found for schema: + // oss" on a native ORC/Parquet read — the exact corner DV-025 deferred but never closed. The + // 2-arg overload normalizes against the per-table VENDED token instead (legacy + // VendedCredentialsFactory: the vended map REPLACES the empty static map). MUTATION: ignoring the + // token (the old static-only path) -> throws -> red. + DefaultConnectorContext restCtx = new DefaultConnectorContext("c", 1L); // empty static map = REST + Assertions.assertEquals("s3://bkt/warehouse/db/t/part-0.parquet", + restCtx.normalizeStorageUri("oss://bkt/warehouse/db/t/part-0.parquet", ossVendedToken())); + } + + @Test + public void emptyTokenUnderEmptyStaticStillFailsLoud() { + // WHY: prove the fix is the TOKEN, not a swallow — with an empty static map AND no vended token + // there is genuinely no credential, so normalization must still FAIL LOUD (legacy parity) rather + // than ship the raw oss:// to BE (silent read corruption). MUTATION: swallowing to the raw path + // when the token is empty -> red. + DefaultConnectorContext restCtx = new DefaultConnectorContext("c", 1L); + Assertions.assertThrows(RuntimeException.class, + () -> restCtx.normalizeStorageUri("oss://bkt/a/part-0.parquet", Collections.emptyMap())); + } + + @Test + public void staticMapPathUnaffectedByEmptyToken() throws Exception { + // WHY: the 2-arg overload with an EMPTY token must fold to the static-map path byte-identically + // to the 1-arg form, so non-REST (static-cred) reads are unchanged. MUTATION: an empty token + // suppressing the static map -> no normalization / throw -> red. + Assertions.assertEquals("s3://bkt/warehouse/db/t/part-0.parquet", + ossContext().normalizeStorageUri( + "oss://bkt/warehouse/db/t/part-0.parquet", Collections.emptyMap())); + } + + // ---- T06 write-sink file type: getBackendFileType resolves the BE file type via the SAME + // LocationPath the legacy IcebergTableSink used (broker-aware), returned as the enum NAME. ---- + + @Test + public void backendFileTypeForOssResolvesToS3ViaLocationPath() throws Exception { + // WHY: the iceberg write sink must tell BE which file-system family opens the output path. The + // engine resolves it through LocationPath.getTFileTypeForBE() (same as legacy), so an OSS data + // location yields FILE_S3 (object store). Returned as the enum NAME (the SPI is Thrift-free). + // MUTATION: scheme-only default that can't see storage props, or a wrong family -> red. + Assertions.assertEquals(TFileType.FILE_S3.name(), + ossContext().getBackendFileType("oss://bkt/warehouse/db/t/data", null)); + } + + @Test + public void backendFileTypeVendedRestResolvesUnderEmptyStaticMap() { + // WHY: a REST catalog's static storage map is empty; the vended token resolves the file type the + // same way the vended-aware normalizeStorageUri resolves the path. MUTATION: ignoring the token + // (static-only) throws "no storage properties" -> red. + DefaultConnectorContext restCtx = new DefaultConnectorContext("c", 1L); + Assertions.assertEquals(TFileType.FILE_S3.name(), + restCtx.getBackendFileType("oss://bkt/warehouse/db/t/data", ossVendedToken())); + } + + // ---- FIX-PERF-06: newStorageUriNormalizer hoists the (scan-invariant) token->storage-config + // derivation to ONCE per scan; every application must stay byte-identical to a per-call + // normalizeStorageUri(uri, token), across all four cases the per-call form covers. ---- + + @Test + public void newNormalizerVendedMatchesPerCallAndServesManyUris() { + // WHY: the scan-scoped normalizer bakes the vended token in once, then normalizes many paths; + // each application must equal normalizeStorageUri(uri, token) (REST empty-static -> vended + // replaces static), and ONE normalizer must serve multiple files (the whole point of the hoist). + // MUTATION: dropping the token (static-only) throws; a stale/rebuilt map yielding a different path + // -> red. + DefaultConnectorContext restCtx = new DefaultConnectorContext("c", 1L); + UnaryOperator n = restCtx.newStorageUriNormalizer(ossVendedToken()); + Assertions.assertEquals(restCtx.normalizeStorageUri("oss://bkt/a/f1.parquet", ossVendedToken()), + n.apply("oss://bkt/a/f1.parquet")); + Assertions.assertEquals("s3://bkt/a/f1.parquet", n.apply("oss://bkt/a/f1.parquet")); + // Reuse the SAME normalizer for a second, different path — one derivation, many applications. + Assertions.assertEquals("s3://bkt/b/f2.parquet", n.apply("oss://bkt/b/f2.parquet")); + } + + @Test + public void newNormalizerStaticMapMatchesPerCallUnderEmptyToken() throws Exception { + // WHY: with a static OSS map and an empty token, the normalizer folds to the static-map path, + // byte-identical to the per-call form. MUTATION: an empty token suppressing the static map -> red. + DefaultConnectorContext ctx = ossContext(); + UnaryOperator n = ctx.newStorageUriNormalizer(Collections.emptyMap()); + Assertions.assertEquals( + ctx.normalizeStorageUri("oss://bkt/warehouse/db/t/part-0.parquet", Collections.emptyMap()), + n.apply("oss://bkt/warehouse/db/t/part-0.parquet")); + } + + @Test + public void newNormalizerShortCircuitsNullAndBlankWithoutForcingDerivation() throws Exception { + // WHY: same empty-uri short-circuit as normalizeStorageUri — a null/blank path returns unchanged + // and never reaches the fail-loud LocationPath, even on an empty static map + empty token (so a + // scan that only ever sees blank uris triggers no derivation/throw). MUTATION: NPE / fabricated + // output / forcing the derivation to throw -> red. + DefaultConnectorContext restCtx = new DefaultConnectorContext("c", 1L); + UnaryOperator n = restCtx.newStorageUriNormalizer(Collections.emptyMap()); + Assertions.assertNull(n.apply(null)); + Assertions.assertEquals("", n.apply("")); + } + + @Test + public void newNormalizerFailsLoudOnBadPathLikePerCall() { + // WHY: fail-loud parity — an empty static map + empty token has no credential, so applying to a + // real oss:// path must throw (not ship the raw path to BE), exactly like normalizeStorageUri. + // MUTATION: swallowing to the raw path -> red. + DefaultConnectorContext restCtx = new DefaultConnectorContext("c", 1L); + UnaryOperator n = restCtx.newStorageUriNormalizer(Collections.emptyMap()); + Assertions.assertThrows(RuntimeException.class, () -> n.apply("oss://bkt/a/part-0.parquet")); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextSiblingTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextSiblingTest.java new file mode 100644 index 00000000000000..41a9318eb92d5b --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextSiblingTest.java @@ -0,0 +1,132 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector; + +import org.apache.doris.connector.api.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.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Tests the fe-core override of the cross-plugin sibling-connector seam + * ({@link DefaultConnectorContext#createSiblingConnector}). The override must build the sibling through the shared + * {@link ConnectorFactory}/{@link ConnectorPluginManager} (so the sibling loads in the requested type's own plugin + * classloader) and pass THIS context through unchanged (so the sibling reuses the caller catalog's id/auth/storage). + * + *

    Dormant: no production code calls {@code createSiblingConnector} yet (the hive gateway substep does), so this + * only exercises the seam in isolation with a recording fake provider registered on the shared manager. + */ +public class DefaultConnectorContextSiblingTest { + + @AfterEach + void tearDown() { + // The plugin manager is a process-wide static; reset it so this test does not leak the recording fake + // provider into any other test that shares the ConnectorFactory singleton. + ConnectorFactory.clearPluginManager(); + } + + @Test + void createSiblingConnector_buildsViaFactory_passesPropsAndThisContext() { + RecordingProvider provider = new RecordingProvider("iceberg"); + ConnectorPluginManager manager = new ConnectorPluginManager(); + manager.registerProvider(provider); + ConnectorFactory.initPluginManager(manager); + + DefaultConnectorContext ctx = new DefaultConnectorContext("hms_catalog", 42L); + Map props = new HashMap<>(); + props.put("iceberg.catalog.type", "hms"); + props.put("hive.metastore.uris", "thrift://host:9083"); + + Connector sibling = ctx.createSiblingConnector("iceberg", props); + + Assertions.assertNotNull(sibling, "sibling must be built when a provider matches the type"); + Assertions.assertSame(provider.lastConnector, sibling, + "the sibling must be exactly the connector the matching provider produced"); + Assertions.assertSame(props, provider.lastProperties, + "the caller-synthesized properties must be forwarded to the sibling provider unchanged"); + Assertions.assertSame(ctx, provider.lastContext, + "the gateway's own context (this) must be passed to the sibling so it shares id/auth/storage"); + } + + @Test + void createSiblingConnector_returnsNull_whenNoProviderMatches() { + ConnectorPluginManager manager = new ConnectorPluginManager(); + manager.registerProvider(new RecordingProvider("iceberg")); + ConnectorFactory.initPluginManager(manager); + + DefaultConnectorContext ctx = new DefaultConnectorContext("hms_catalog", 42L); + // A gateway asking for a type no registered provider serves: fe-core returns null (connector-agnostic); + // the gateway caller is the one that fails loud. Here the only provider serves "iceberg", not "paimon". + Connector sibling = ctx.createSiblingConnector("paimon", new HashMap<>()); + + Assertions.assertNull(sibling, "no matching provider must yield null, not an exception"); + } + + @Test + void createSiblingConnector_returnsNull_whenPluginManagerUninitialized() { + // No initPluginManager -> ConnectorFactory has no manager -> null (never throws). Mirrors the pre-flip + // reality where a connector context may exist before/without the plugin manager being wired. + ConnectorFactory.clearPluginManager(); + + DefaultConnectorContext ctx = new DefaultConnectorContext("hms_catalog", 42L); + Connector sibling = ctx.createSiblingConnector("iceberg", new HashMap<>()); + + Assertions.assertNull(sibling, "uninitialized plugin manager must yield null, not an exception"); + } + + /** A ConnectorProvider that records the exact args of its last {@code create} call and the connector it made. */ + private static final class RecordingProvider implements ConnectorProvider { + private final String type; + private Map lastProperties; + private ConnectorContext lastContext; + private Connector lastConnector; + + RecordingProvider(String type) { + this.type = type; + } + + @Override + public String getType() { + return type; + } + + @Override + public Connector create(Map properties, ConnectorContext context) { + this.lastProperties = properties; + this.lastContext = context; + this.lastConnector = new RecordingConnector(); + return lastConnector; + } + } + + /** A minimal Connector — every method uses its SPI default; identity is all the test asserts on. */ + private static final class RecordingConnector implements Connector { + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + return null; + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextStoragePropsTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextStoragePropsTest.java new file mode 100644 index 00000000000000..a137eb8f6ad4ee --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextStoragePropsTest.java @@ -0,0 +1,153 @@ +// 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.filesystem.FileSystem; +import org.apache.doris.filesystem.FileSystemType; +import org.apache.doris.filesystem.properties.FileSystemProperties; +import org.apache.doris.filesystem.properties.StorageKind; +import org.apache.doris.filesystem.spi.FileSystemProvider; +import org.apache.doris.fs.FileSystemFactory; +import org.apache.doris.fs.FileSystemPluginManager; +import org.apache.doris.kerberos.ExecutionAuthenticator; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +/** + * Design S2: pins that {@link DefaultConnectorContext#getStorageProperties()} binds the catalog's raw storage + * map directly through {@link FileSystemFactory#bindAllStorageProperties} (the live plugin-loaded manager), + * sourced straight from the raw-props supplier — with no fe-core {@code StorageProperties.createAll} parse / + * {@code getOrigProps()} round-trip on this path. The raw supplier is responsible for merging the catalog's + * derived storage defaults and honoring the vended gate (see {@code CatalogProperty.getEffectiveRawStorageProperties}). + */ +public class DefaultConnectorContextStoragePropsTest { + + private static final Supplier NOOP_AUTH = + () -> new ExecutionAuthenticator() {}; + + @AfterEach + public void resetFactory() { + // The wiring test injects a live manager; restore the "no live manager" default for other tests. + FileSystemFactory.initPluginManager(null); + } + + @Test + public void getStorageProperties_emptyWhenNoRawSupplier() { + // 2-arg ctor -> empty raw supplier -> empty list (REST/vended/non-plugin/local-FS warehouse), so + // non-plugin paths are unaffected and there is no NPE. MUTATION: null / throw -> red. + Assertions.assertTrue(new DefaultConnectorContext("c", 1L).getStorageProperties().isEmpty()); + } + + @Test + public void getStorageProperties_emptyWhenRawSupplierEmpty() { + // A REST/vended or credential-less catalog: the raw supplier yields an empty map -> empty list, so no + // static storage is bound. MUTATION: dropping the isEmpty() short-circuit -> reaches the factory -> red. + DefaultConnectorContext ctx = new DefaultConnectorContext("c", 1L, NOOP_AUTH, + Collections::emptyMap, Collections::emptyMap); + Assertions.assertTrue(ctx.getStorageProperties().isEmpty()); + } + + @Test + public void getStorageProperties_bindsRawCatalogMapViaLiveManager() { + // The raw catalog map is bound as-is through the live plugin-loaded manager. + Map raw = new HashMap<>(); + raw.put("oss.endpoint", "oss-cn-beijing.aliyuncs.com"); + raw.put("oss.access_key", "ak"); + raw.put("oss.secret_key", "sk"); + + // Inject a live manager whose provider captures the raw map it is asked to bind. + CapturingProvider provider = new CapturingProvider(); + FileSystemPluginManager mgr = new FileSystemPluginManager(); + mgr.registerProvider(provider); + FileSystemFactory.initPluginManager(mgr); + + // 5-arg ctor: the typed supplier (unused by getStorageProperties, kept for other consumers) is empty; + // the raw supplier is exactly what this path binds — no getOrigProps() round-trip. + DefaultConnectorContext ctx = new DefaultConnectorContext("c", 1L, NOOP_AUTH, + Collections::emptyMap, () -> raw); + List result = ctx.getStorageProperties(); + + // The connector received the props bound from the raw supplier's map. + // MUTATION: returning the default empty / not reaching the factory / a filtered map -> red. + Assertions.assertEquals(1, result.size()); + Assertions.assertNotNull(provider.capturedRawMap, "getStorageProperties() must bind via the factory"); + Assertions.assertEquals("ak", provider.capturedRawMap.get("oss.access_key"), + "must bind the raw catalog map from the raw supplier"); + Assertions.assertEquals("oss-cn-beijing.aliyuncs.com", provider.capturedRawMap.get("oss.endpoint")); + } + + private static final class CapturingProvider implements FileSystemProvider { + private Map capturedRawMap; + + @Override + public boolean supports(Map properties) { + return true; + } + + @Override + public FileSystemProperties bind(Map properties) { + this.capturedRawMap = properties; + return new FakeFsProps(); + } + + @Override + public FileSystem create(Map properties) { + return null; + } + + @Override + public String name() { + return "capturing"; + } + } + + private static final class FakeFsProps implements FileSystemProperties { + @Override + public String providerName() { + return "FAKE"; + } + + @Override + public StorageKind kind() { + return null; + } + + @Override + public FileSystemType type() { + return null; + } + + @Override + public Map rawProperties() { + return Collections.emptyMap(); + } + + @Override + public Map matchedProperties() { + return Collections.emptyMap(); + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextVendTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextVendTest.java new file mode 100644 index 00000000000000..b8314046dd5512 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextVendTest.java @@ -0,0 +1,70 @@ +// 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.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * FIX-REST-VENDED fe-core bridge test: pins that + * {@link DefaultConnectorContext#vendStorageCredentials} reuses the engine's + * {@code StorageProperties} normalization (the same chain legacy + * {@code AbstractVendedCredentialsProvider} runs) to turn a raw per-table OSS vended token into the + * BE-facing {@code AWS_*} storage properties. The connector cannot import that machinery, so this + * hook is the single source of truth — without it a REST native-reader table reaches BE with no + * usable credentials (403). FAILS before the fix (the method is a no-op default returning empty). + */ +public class DefaultConnectorContextVendTest { + + private static DefaultConnectorContext context() { + return new DefaultConnectorContext("c", 1L); + } + + @Test + public void normalizesOssTokenToBackendAwsProps() { + // Mirrors the raw OSS vended token shape from PaimonVendedCredentialsProviderTest. + Map token = new HashMap<>(); + token.put("fs.oss.accessKeyId", "STS.testAccessKey123"); + token.put("fs.oss.accessKeySecret", "testSecretKey456"); + token.put("fs.oss.securityToken", "testSessionToken789"); + token.put("fs.oss.endpoint", "oss-cn-beijing.aliyuncs.com"); + + Map be = context().vendStorageCredentials(token); + + // WHY: the BE native S3/object-store client consumes ONLY normalized AWS_* keys; the raw + // fs.oss.* token is unintelligible to it. The bridge must run StorageProperties.createAll + + // getBackendPropertiesFromStorageMap to produce them. MUTATION: leaving the default no-op + // (empty) or skipping the normalization -> AWS_ACCESS_KEY absent -> red. + Assertions.assertFalse(be.isEmpty(), "a valid OSS token must normalize to non-empty BE props"); + Assertions.assertEquals("STS.testAccessKey123", be.get("AWS_ACCESS_KEY")); + Assertions.assertEquals("testSecretKey456", be.get("AWS_SECRET_KEY")); + Assertions.assertEquals("testSessionToken789", be.get("AWS_TOKEN")); + } + + @Test + public void emptyOrNullInputYieldsEmpty() { + // WHY: a non-REST / no-token table passes an empty map; the bridge must short-circuit to + // empty (no overlay), never NPE. MUTATION: NPE on null, or fabricating props from nothing -> red. + Assertions.assertTrue(context().vendStorageCredentials(Collections.emptyMap()).isEmpty()); + Assertions.assertTrue(context().vendStorageCredentials(null).isEmpty()); + } +} 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 new file mode 100644 index 00000000000000..94f50a91138613 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/ExternalMetaCacheInvalidatorTest.java @@ -0,0 +1,107 @@ +// 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 new file mode 100644 index 00000000000000..207afcabfb4f13 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/ddl/CreateTableInfoToConnectorRequestConverterTest.java @@ -0,0 +1,391 @@ +// 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.ddl; + +import org.apache.doris.catalog.AggregateType; +import org.apache.doris.catalog.PartitionType; +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ddl.ConnectorBucketSpec; +import org.apache.doris.connector.api.ddl.ConnectorCreateTableRequest; +import org.apache.doris.connector.api.ddl.ConnectorPartitionField; +import org.apache.doris.connector.api.ddl.ConnectorPartitionSpec; +import org.apache.doris.connector.api.ddl.ConnectorSortField; +import org.apache.doris.nereids.analyzer.UnboundFunction; +import org.apache.doris.nereids.analyzer.UnboundSlot; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral; +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.PartitionTableInfo; +import org.apache.doris.nereids.trees.plans.commands.info.SortFieldInfo; +import org.apache.doris.nereids.types.IntegerType; +import org.apache.doris.nereids.types.StringType; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Covers each branch of {@link CreateTableInfoToConnectorRequestConverter}: + * the four partition styles (IDENTITY, TRANSFORM, LIST, RANGE) and both + * bucket flavors (hash, random), plus the no-partition / no-distribution + * fall-throughs. + * + *

    {@link CreateTableInfo} is mocked because its full constructor pulls in + * heavy nereids analyzer state; the converter only reads a handful of + * getters from it, all of which are easy to stub. + */ +public class CreateTableInfoToConnectorRequestConverterTest { + + @Test + public void columnsAndScalarFieldsArePassedThrough() { + ColumnDefinition idCol = new ColumnDefinition( + "id", IntegerType.INSTANCE, false, "primary key"); + ColumnDefinition nameCol = new ColumnDefinition( + "name", StringType.INSTANCE, true, "display name"); + CreateTableInfo info = stubInfo( + "orders", + Arrays.asList(idCol, nameCol), + null, + null, + "an orders table", + ImmutableMap.of("k", "v"), + true, + true); + + ConnectorCreateTableRequest req = CreateTableInfoToConnectorRequestConverter + .convert(info, "sales"); + + Assertions.assertEquals("sales", req.getDbName()); + Assertions.assertEquals("orders", req.getTableName()); + 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); + Assertions.assertEquals("id", col0.getName()); + Assertions.assertFalse(col0.isNullable()); + Assertions.assertEquals("primary key", col0.getComment()); + ConnectorColumn col1 = req.getColumns().get(1); + Assertions.assertEquals("name", col1.getName()); + Assertions.assertTrue(col1.isNullable()); + + // No partition / distribution in this fixture. + Assertions.assertNull(req.getPartitionSpec()); + Assertions.assertNull(req.getBucketSpec()); + } + + @Test + public void autoIncInitValueIsPropagatedAsIsAutoInc() { + // ColumnDefinition is mocked (its auto-inc ctor pulls in ColumnNullableType machinery); + // the converter only reads these getters. A column is auto-inc when getAutoIncInitValue() != -1. + ColumnDefinition autoIncCol = Mockito.mock(ColumnDefinition.class); + Mockito.when(autoIncCol.getName()).thenReturn("id"); + Mockito.when(autoIncCol.getType()).thenReturn(IntegerType.INSTANCE); + Mockito.when(autoIncCol.getComment()).thenReturn(""); + Mockito.when(autoIncCol.isNullable()).thenReturn(false); + Mockito.when(autoIncCol.isKey()).thenReturn(false); + Mockito.when(autoIncCol.getAutoIncInitValue()).thenReturn(1L); // != -1 => auto-increment + + CreateTableInfo info = stubInfo("t", Collections.singletonList(autoIncCol), + null, null, "", Collections.emptyMap(), false, false); + ConnectorCreateTableRequest req = CreateTableInfoToConnectorRequestConverter.convert(info, "db"); + + // WHY (Rule 9): the connector can only reject what the converter carries. This proves the + // auto-inc flag survives the ColumnDefinition -> ConnectorColumn boundary (without it, the + // connector's auto-inc rejection would be dead code). MUTATION: reverting the converter to + // the 6-arg ctor (dropping `d.getAutoIncInitValue() != -1`) makes this red. + Assertions.assertTrue(req.getColumns().get(0).isAutoInc(), + "autoIncInitValue != -1 must propagate to ConnectorColumn.isAutoInc"); + } + + @Test + public void sortOrderIsCarriedThrough() { + ColumnDefinition idCol = new ColumnDefinition("id", IntegerType.INSTANCE, false, ""); + CreateTableInfo info = stubInfo("t", Collections.singletonList(idCol), + null, null, "", Collections.emptyMap(), false, false); + Mockito.when(info.getSortOrderFields()).thenReturn(Arrays.asList( + new SortFieldInfo("id", true, false), + new SortFieldInfo("name", false, true))); + + ConnectorCreateTableRequest req = CreateTableInfoToConnectorRequestConverter.convert(info, "db"); + + // WHY (Rule 9): iceberg createTable can only build a write sort order from what the converter carries. + // Legacy iceberg supported CREATE TABLE ... ORDER BY; without this carrier the clause is silently + // dropped post-flip. MUTATION: removing the converter's .sortOrder(...) call makes this red. + Assertions.assertEquals(2, req.getSortOrder().size()); + ConnectorSortField f0 = req.getSortOrder().get(0); + Assertions.assertEquals("id", f0.getColumnName()); + Assertions.assertTrue(f0.isAscending()); + Assertions.assertFalse(f0.isNullFirst()); + ConnectorSortField f1 = req.getSortOrder().get(1); + Assertions.assertEquals("name", f1.getColumnName()); + Assertions.assertFalse(f1.isAscending()); + Assertions.assertTrue(f1.isNullFirst()); + } + + @Test + public void sortOrderEmptyWhenAbsent() { + ColumnDefinition idCol = new ColumnDefinition("id", IntegerType.INSTANCE, false, ""); + CreateTableInfo info = stubInfo("t", Collections.singletonList(idCol), + null, null, "", Collections.emptyMap(), false, false); + // getSortOrderFields() unstubbed -> null -> the converter yields an empty (never null) list. + ConnectorCreateTableRequest req = CreateTableInfoToConnectorRequestConverter.convert(info, "db"); + Assertions.assertTrue(req.getSortOrder().isEmpty()); + } + + @Test + public void plainColumnIsNotAutoInc() { + ColumnDefinition plainCol = Mockito.mock(ColumnDefinition.class); + Mockito.when(plainCol.getName()).thenReturn("c"); + Mockito.when(plainCol.getType()).thenReturn(IntegerType.INSTANCE); + Mockito.when(plainCol.getComment()).thenReturn(""); + Mockito.when(plainCol.isNullable()).thenReturn(true); + Mockito.when(plainCol.isKey()).thenReturn(false); + Mockito.when(plainCol.getAutoIncInitValue()).thenReturn(-1L); // default => not auto-increment + + CreateTableInfo info = stubInfo("t", Collections.singletonList(plainCol), + null, null, "", Collections.emptyMap(), false, false); + ConnectorCreateTableRequest req = CreateTableInfoToConnectorRequestConverter.convert(info, "db"); + + // WHY: guards the `!= -1` predicate boundary -- a normal column must map to false, not true + // (catches an inverted or constant-true mistake). + Assertions.assertFalse(req.getColumns().get(0).isAutoInc(), + "autoIncInitValue == -1 (a normal column) must map to isAutoInc=false"); + } + + @Test + public void aggTypePropagatedAsIsAggregated() { + // ColumnDefinition is mocked; the converter computes isAggregated from getAggType() + // (mirroring Column.isAggregated()): non-null and non-NONE. + ColumnDefinition aggCol = Mockito.mock(ColumnDefinition.class); + Mockito.when(aggCol.getName()).thenReturn("c"); + Mockito.when(aggCol.getType()).thenReturn(IntegerType.INSTANCE); + Mockito.when(aggCol.getComment()).thenReturn(""); + Mockito.when(aggCol.isNullable()).thenReturn(false); + Mockito.when(aggCol.isKey()).thenReturn(false); + Mockito.when(aggCol.getAutoIncInitValue()).thenReturn(-1L); + Mockito.when(aggCol.getAggType()).thenReturn(AggregateType.SUM); + + CreateTableInfo info = stubInfo("t", Collections.singletonList(aggCol), + null, null, "", Collections.emptyMap(), false, false); + ConnectorCreateTableRequest req = CreateTableInfoToConnectorRequestConverter.convert(info, "db"); + + // WHY (Rule 9): the connector can only reject what the converter carries. This proves the + // aggregate flag survives the ColumnDefinition -> ConnectorColumn boundary (without it the + // connector's aggregate rejection would be dead code). MUTATION: dropping the 8th ctor arg + // (or forcing the boolean false) in the converter makes this red. + Assertions.assertTrue(req.getColumns().get(0).isAggregated(), + "non-NONE aggType must propagate to ConnectorColumn.isAggregated"); + } + + @Test + public void plainColumnIsNotAggregated() { + ColumnDefinition plainCol = Mockito.mock(ColumnDefinition.class); + Mockito.when(plainCol.getName()).thenReturn("c"); + Mockito.when(plainCol.getType()).thenReturn(IntegerType.INSTANCE); + Mockito.when(plainCol.getComment()).thenReturn(""); + Mockito.when(plainCol.isNullable()).thenReturn(true); + Mockito.when(plainCol.isKey()).thenReturn(false); + Mockito.when(plainCol.getAutoIncInitValue()).thenReturn(-1L); + Mockito.when(plainCol.getAggType()).thenReturn(null); // no aggregate type + + CreateTableInfo info = stubInfo("t", Collections.singletonList(plainCol), + null, null, "", Collections.emptyMap(), false, false); + ConnectorCreateTableRequest req = CreateTableInfoToConnectorRequestConverter.convert(info, "db"); + + // WHY: guards the boundary -- a normal column (null/NONE aggType) must map to false. + Assertions.assertFalse(req.getColumns().get(0).isAggregated(), + "null aggType (a normal column) must map to isAggregated=false"); + } + + @Test + public void identityPartitionStyle() { + // PARTITIONED BY (dt) on a Hive-style external table. + PartitionTableInfo partition = new PartitionTableInfo( + false, + PartitionType.UNPARTITIONED.name(), + null, + ImmutableList.of(new UnboundSlot("dt"))); + ConnectorPartitionSpec spec = convertWithPartition(partition).getPartitionSpec(); + + Assertions.assertNotNull(spec); + Assertions.assertEquals(ConnectorPartitionSpec.Style.IDENTITY, spec.getStyle()); + Assertions.assertEquals(1, spec.getFields().size()); + ConnectorPartitionField field = spec.getFields().get(0); + Assertions.assertEquals("dt", field.getColumnName()); + Assertions.assertEquals("identity", field.getTransform()); + Assertions.assertTrue(field.getTransformArgs().isEmpty()); + Assertions.assertTrue(spec.getInitialValues().isEmpty()); + } + + @Test + public void transformPartitionStyleWithIcebergStyleFunctions() { + // PARTITIONED BY (bucket(16, id), year(d)) — Iceberg style. + Expression bucket = new UnboundFunction("bucket", + Arrays.asList(new UnboundSlot("id"), new IntegerLiteral(16))); + Expression year = new UnboundFunction("YEAR", + Collections.singletonList(new UnboundSlot("d"))); + PartitionTableInfo partition = new PartitionTableInfo( + false, + PartitionType.UNPARTITIONED.name(), + null, + ImmutableList.of(bucket, year)); + + ConnectorPartitionSpec spec = convertWithPartition(partition).getPartitionSpec(); + Assertions.assertNotNull(spec); + Assertions.assertEquals(ConnectorPartitionSpec.Style.TRANSFORM, spec.getStyle()); + + Assertions.assertEquals(2, spec.getFields().size()); + ConnectorPartitionField bucketField = spec.getFields().get(0); + Assertions.assertEquals("id", bucketField.getColumnName()); + Assertions.assertEquals("bucket", bucketField.getTransform()); + Assertions.assertEquals(Collections.singletonList(16), bucketField.getTransformArgs()); + + ConnectorPartitionField yearField = spec.getFields().get(1); + Assertions.assertEquals("d", yearField.getColumnName()); + // transform name is lower-cased even though the source was uppercase. + Assertions.assertEquals("year", yearField.getTransform()); + Assertions.assertTrue(yearField.getTransformArgs().isEmpty()); + } + + @Test + public void listPartitionStyle() { + // PARTITION BY LIST (region) — Doris native list partitioning. + PartitionTableInfo partition = new PartitionTableInfo( + false, + PartitionType.LIST.name(), + null, + ImmutableList.of(new UnboundSlot("region"))); + + ConnectorPartitionSpec spec = convertWithPartition(partition).getPartitionSpec(); + Assertions.assertNotNull(spec); + 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()); + } + + @Test + public void rangePartitionStyle() { + // PARTITION BY RANGE (dt) — Doris native range partitioning. + PartitionTableInfo partition = new PartitionTableInfo( + false, + PartitionType.RANGE.name(), + null, + ImmutableList.of(new UnboundSlot("dt"))); + + ConnectorPartitionSpec spec = convertWithPartition(partition).getPartitionSpec(); + Assertions.assertNotNull(spec); + 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()); + } + + @Test + public void hashDistributionMapsToDorisDefaultAlgorithm() { + DistributionDescriptor dd = new DistributionDescriptor( + true, false, 4, Arrays.asList("id")); + ConnectorBucketSpec bucket = convertWithDistribution(dd).getBucketSpec(); + + Assertions.assertNotNull(bucket); + Assertions.assertEquals(Arrays.asList("id"), bucket.getColumns()); + Assertions.assertEquals(4, bucket.getNumBuckets()); + Assertions.assertEquals("doris_default", bucket.getAlgorithm()); + } + + @Test + public void randomDistributionMapsToDorisRandomAlgorithm() { + DistributionDescriptor dd = new DistributionDescriptor( + false, false, 8, Collections.emptyList()); + ConnectorBucketSpec bucket = convertWithDistribution(dd).getBucketSpec(); + + Assertions.assertNotNull(bucket); + Assertions.assertEquals(Collections.emptyList(), bucket.getColumns()); + Assertions.assertEquals(8, bucket.getNumBuckets()); + Assertions.assertEquals("doris_random", bucket.getAlgorithm()); + } + + // ──────────────────── helpers ──────────────────── + + private static ConnectorCreateTableRequest convertWithPartition( + PartitionTableInfo partition) { + return CreateTableInfoToConnectorRequestConverter.convert( + stubInfo("t", + Collections.singletonList(new ColumnDefinition( + "id", IntegerType.INSTANCE, true)), + partition, + null, + "", + Collections.emptyMap(), + false, + false), + "db"); + } + + private static ConnectorCreateTableRequest convertWithDistribution( + DistributionDescriptor distribution) { + return CreateTableInfoToConnectorRequestConverter.convert( + stubInfo("t", + Collections.singletonList(new ColumnDefinition( + "id", IntegerType.INSTANCE, true)), + null, + distribution, + "", + Collections.emptyMap(), + false, + false), + "db"); + } + + /** + * Builds a mock {@link CreateTableInfo} answering only the getters that + * the converter actually reads. Saves the test from threading 18 args + * through the real ctor (which also calls {@code PropertyAnalyzer}). + */ + private static CreateTableInfo stubInfo(String tableName, + List columns, + PartitionTableInfo partition, + DistributionDescriptor distribution, + String comment, + java.util.Map properties, + boolean ifNotExists, + boolean external) { + CreateTableInfo info = Mockito.mock(CreateTableInfo.class); + Mockito.when(info.getTableName()).thenReturn(tableName); + Mockito.when(info.getColumnDefinitions()).thenReturn(columns); + Mockito.when(info.getPartitionTableInfo()).thenReturn(partition); + Mockito.when(info.getDistribution()).thenReturn(distribution); + 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/ConnectorTransactionDefaultsTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/fake/ConnectorTransactionDefaultsTest.java new file mode 100644 index 00000000000000..0c2ea3dcefc29b --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/fake/ConnectorTransactionDefaultsTest.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.fake; + +import org.apache.doris.connector.api.handle.ConnectorTransaction; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Verifies the default (read-only) behavior of the write-SPI surface added to + * {@link ConnectorTransaction} in W-phase W1. A connector that does not + * participate in writes leaves all four methods at their defaults. + */ +public class ConnectorTransactionDefaultsTest { + + /** Minimal read-only transaction: overrides only the abstract methods. */ + private static final class ReadOnlyTransaction implements ConnectorTransaction { + @Override + public long getTransactionId() { + return 1L; + } + + @Override + public void commit() { + } + + @Override + public void rollback() { + } + + @Override + public void close() { + } + } + + @Test + void addCommitDataDefaultIsNoOp() { + // A read-only connector must silently ignore commit fragments, not throw. + new ReadOnlyTransaction().addCommitData(new byte[] {1, 2, 3}); + } + + @Test + void supportsWriteBlockAllocationDefaultsFalse() { + Assertions.assertFalse(new ReadOnlyTransaction().supportsWriteBlockAllocation()); + } + + @Test + void allocateWriteBlockRangeDefaultThrows() { + ConnectorTransaction txn = new ReadOnlyTransaction(); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> txn.allocateWriteBlockRange("session", 10L)); + } + + @Test + void getUpdateCntDefaultsZero() { + Assertions.assertEquals(0L, new ReadOnlyTransaction().getUpdateCnt()); + } + + @Test + void profileLabelDefaultsToExternal() { + Assertions.assertEquals("EXTERNAL", new ReadOnlyTransaction().profileLabel()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/fake/FakeConnectorPlugin.java b/fe/fe-core/src/test/java/org/apache/doris/connector/fake/FakeConnectorPlugin.java new file mode 100644 index 00000000000000..1cf144f4a4d457 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/fake/FakeConnectorPlugin.java @@ -0,0 +1,143 @@ +// 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.fake; + +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 java.util.Collections; +import java.util.Map; + +/** + * "Empty" connector plugin used as a baseline by P0 batch-2 tests. + * + *

    Implements only the bare minimum of the SPI surface so that every + * other method on {@link Connector}, {@link ConnectorMetadata}, + * {@link ConnectorSession}, and {@link ConnectorContext} exercises its + * default implementation. Tests that depend on a particular default + * behavior (e.g. {@code listPartitionNames()} returning an empty list, + * {@code beginTransaction()} throwing) can construct a fake catalog from + * this plugin without having to stub each interface by hand. + * + *

    NOT registered via {@code META-INF/services} — tests instantiate it + * directly to keep production discovery deterministic. + */ +public final class FakeConnectorPlugin implements ConnectorProvider { + + public static final String TYPE = "fake"; + + @Override + public String getType() { + return TYPE; + } + + @Override + public Connector create(Map properties, ConnectorContext context) { + return new FakeConnector(); + } + + /** Connector exposing a metadata that overrides nothing. */ + public static final class FakeConnector implements Connector { + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + return new FakeMetadata(); + } + } + + /** {@link ConnectorMetadata} with zero overrides — every method uses the default. */ + public static final class FakeMetadata implements ConnectorMetadata { + } + + /** {@link ConnectorSession} that only fills the always-required fields. */ + public static final class FakeSession implements ConnectorSession { + + private final String catalogName; + private final long catalogId; + + public FakeSession(String catalogName, long catalogId) { + this.catalogName = catalogName; + this.catalogId = catalogId; + } + + @Override + public String getQueryId() { + return "fake-query"; + } + + @Override + public String getUser() { + return "fake-user"; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public long getCatalogId() { + return catalogId; + } + + @Override + public String getCatalogName() { + return catalogName; + } + + @Override + @SuppressWarnings("unchecked") + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + } + + /** {@link ConnectorContext} that only fills catalog name + id. */ + public static final class FakeContext implements ConnectorContext { + + private final String catalogName; + private final long catalogId; + + public FakeContext(String catalogName, long catalogId) { + this.catalogName = catalogName; + this.catalogId = catalogId; + } + + @Override + public String getCatalogName() { + return catalogName; + } + + @Override + public long getCatalogId() { + return catalogId; + } + } +} 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 new file mode 100644 index 00000000000000..0146a432d3857a --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/fake/FakeConnectorPluginTest.java @@ -0,0 +1,182 @@ +// 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.fake; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.ddl.ConnectorCreateTableRequest; +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; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.Optional; + +/** + * Exercises the SPI default fall-throughs through {@link FakeConnectorPlugin}. + * + *

    The fake overrides nothing beyond the minimum required to compile — every + * assertion below targets a default method body added during P0 batches 0+1. + * If a future change accidentally drops or alters a default, this test fails + * before the change reaches any real connector. + */ +public class FakeConnectorPluginTest { + + private FakeConnectorPlugin plugin; + private Connector connector; + private ConnectorSession session; + private ConnectorMetadata metadata; + + @BeforeEach + void setUp() { + plugin = new FakeConnectorPlugin(); + ConnectorContext context = new FakeConnectorPlugin.FakeContext("fake_cat", 1L); + connector = plugin.create(Collections.emptyMap(), context); + session = new FakeConnectorPlugin.FakeSession("fake_cat", 1L); + 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 + void sessionCurrentTransactionDefaultsToEmpty() { + // T07: default getCurrentTransaction() returns Optional.empty(). + Assertions.assertEquals(Optional.empty(), session.getCurrentTransaction()); + } + + @Test + void sessionSessionPropertiesDefaultsToEmpty() { + Assertions.assertTrue(session.getSessionProperties().isEmpty()); + } + + // ──────────────────── ConnectorMetadata defaults (E5 MVCC) ──────────────────── + + @Test + void mvccSnapshotMethodsDefaultToEmpty() { + ConnectorTableHandle handle = new ConnectorTableHandle() { }; + // T08: the mvcc defaults return Optional.empty() — connector opts out of MVCC. The old + // getSnapshotAt/getSnapshotById defaults were retired in B5b-2a and replaced by the unified + // resolveTimeTravel seam, which also defaults to Optional.empty for non-time-travel connectors. + Assertions.assertEquals(Optional.empty(), + metadata.beginQuerySnapshot(session, handle)); + Assertions.assertEquals(Optional.empty(), + metadata.resolveTimeTravel(session, handle, + ConnectorTimeTravelSpec.snapshotId("1"))); + } + + // ──────────────────── ConnectorSchemaOps defaults ──────────────────── + + @Test + void schemaOpsDefaults() { + Assertions.assertTrue(metadata.listDatabaseNames(session).isEmpty()); + Assertions.assertFalse(metadata.databaseExists(session, "anydb")); + } + + // ──────────────────── ConnectorTableOps defaults ──────────────────── + + @Test + void tableOpsListDefaults() { + // SHOW TABLES against an unimplemented connector returns empty rather than throwing. + Assertions.assertTrue(metadata.listTableNames(session, "any_db").isEmpty()); + + 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. + 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() { + 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. + 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()); + } + + // ──────────────────── ConnectorWriteOps defaults ──────────────────── + + @Test + void beginTransactionDefaultThrows() { + // T06: default beginTransaction throws — engine treats statement as auto-commit. + DorisConnectorException ex = Assertions.assertThrows( + DorisConnectorException.class, + () -> metadata.beginTransaction(session)); + Assertions.assertTrue(ex.getMessage().contains("Transactions not supported"), + "expected transaction-not-supported message, got: " + ex.getMessage()); + } + + // ──────────────────── Connector-level defaults ──────────────────── + + @Test + 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/CatalogPropertyEffectiveRawStoragePropsTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/CatalogPropertyEffectiveRawStoragePropsTest.java new file mode 100644 index 00000000000000..bc6662d7014b0e --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/CatalogPropertyEffectiveRawStoragePropsTest.java @@ -0,0 +1,117 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource; + +import org.apache.doris.datasource.property.storage.HdfsProperties; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Design S2: unit tests for {@link CatalogProperty#getEffectiveRawStorageProperties()} — the raw storage map a + * plugin catalog hands fe-filesystem to bind directly (no fe-core {@code StorageProperties.createAll} + * round-trip). The invariant that de-risks the whole cut: this map is byte-identical to what the fe-core parse + * path exposes via {@code getStoragePropertiesMap().values().iterator().next().getOrigProps()}, so binding + * either yields the same typed storage and the same BE {@code location.*} map. Also pins that the derived + * warehouse -> fs.defaultFS defaults survive and (design S4) that the removed vended gate no longer empties + * the map for a vended catalog. + * + *

    Design S8: the warehouse -> fs.defaultFS derivation now lives in the connector (fe-core no longer parses + * metastore properties), so these tests wire the plugin derivation supplier to simulate the iceberg connector's + * output. The derivation logic itself is covered by + * {@code IcebergConnectorDeriveStoragePropertiesTest}; here we pin that fe-core folds the connector-supplied + * defaults identically into the raw-bind and typed-parse paths.

    + */ +public class CatalogPropertyEffectiveRawStoragePropsTest { + + /** A hadoop-flavored native iceberg catalog whose connector supplies the warehouse -> fs.defaultFS bridge. */ + private static CatalogProperty hadoopIceberg(String warehouse) { + Map props = new HashMap<>(); + props.put("type", "iceberg"); + props.put("iceberg.catalog.type", "hadoop"); + props.put(HdfsProperties.FS_HDFS_SUPPORT, "true"); + Map connectorDerived = new HashMap<>(); + if (warehouse != null) { + props.put("warehouse", warehouse); + if (warehouse.startsWith("hdfs://")) { + // Simulate the iceberg connector's hadoop warehouse -> fs.defaultFS derivation. + String ns = warehouse.substring("hdfs://".length(), warehouse.indexOf('/', "hdfs://".length())); + connectorDerived.put("fs.defaultFS", "hdfs://" + ns); + } + } + CatalogProperty cp = new CatalogProperty(null, props); + cp.setPluginDerivedStorageDefaultsSupplier(() -> connectorDerived); + return cp; + } + + @Test + public void effectiveRawEqualsGetOrigProps() { + // The fe-filesystem bind path (getEffectiveRawStorageProperties) and the fe-core parse path + // (getStoragePropertiesMap -> getOrigProps) must feed byte-identical raw maps, so binding either yields + // the same typed storage / BE location.* map. MUTATION: skip the derived merge in either path -> the + // maps diverge -> red. + CatalogProperty cp = hadoopIceberg("hdfs://nsbridge/wh"); + Map viaBind = cp.getEffectiveRawStorageProperties(); + Map viaOrigProps = + cp.getStoragePropertiesMap().values().iterator().next().getOrigProps(); + Assertions.assertEquals(viaOrigProps, viaBind); + } + + @Test + public void effectiveRawCarriesDerivedDefaultFs() { + // A hadoop catalog with ONLY warehouse (no inline fs.defaultFS): the connector-derived warehouse -> + // fs.defaultFS default must be present in the raw map handed to fe-filesystem, else HA-nameservice / + // warehouse-only catalogs regress. MUTATION: drop the plugin-derived merge -> fs.defaultFS absent -> red. + CatalogProperty cp = hadoopIceberg("hdfs://nsbridge/wh"); + Assertions.assertEquals("hdfs://nsbridge", + cp.getEffectiveRawStorageProperties().get("fs.defaultFS")); + } + + @Test + public void effectiveRawDoesNotMutatePersistedProps() { + // Derived defaults are merged into a copy; the persisted catalog map is never mutated. + CatalogProperty cp = hadoopIceberg("hdfs://nsbridge/wh"); + cp.getEffectiveRawStorageProperties(); + Assertions.assertFalse(cp.getProperties().containsKey("fs.defaultFS"), + "persisted props must not gain the derived fs.defaultFS"); + } + + @Test + public void vendedCatalogRawMapNoLongerGated() { + // Design S4: the former vended gate is removed — fe-core hands the connector the raw storage map + // unconditionally (the connector owns static+vended precedence, overlaying vended per-table). A vended + // REST catalog is no longer emptied by fe-core; it carries no static object-store keys (its connector + // derives nothing), so a downstream fe-filesystem bind still yields no static storage, but the map + // itself is un-gated. MUTATION: re-add a vended gate returning empty -> the raw props vanish -> red. + Map props = new HashMap<>(); + props.put("type", "iceberg"); + props.put("iceberg.catalog.type", "rest"); + props.put("iceberg.rest.uri", "http://localhost:8181"); + props.put("iceberg.rest.vended-credentials-enabled", "true"); + CatalogProperty cp = new CatalogProperty(null, props); + cp.setPluginDerivedStorageDefaultsSupplier(Collections::emptyMap); + Map raw = cp.getEffectiveRawStorageProperties(); + Assertions.assertFalse(raw.isEmpty(), "S4: vended no longer gates the raw storage map to empty"); + Assertions.assertEquals("http://localhost:8181", raw.get("iceberg.rest.uri"), + "the raw catalog props are handed over un-gated"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/CatalogPropertyPluginStorageDerivationTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/CatalogPropertyPluginStorageDerivationTest.java new file mode 100644 index 00000000000000..6e560b8fc0bba6 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/CatalogPropertyPluginStorageDerivationTest.java @@ -0,0 +1,89 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource; + +import org.apache.doris.datasource.property.storage.HdfsProperties; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Design S8: for a plugin catalog the connector owns storage-property derivation, so {@link CatalogProperty} + * folds the connector-supplied defaults (via {@link CatalogProperty#setPluginDerivedStorageDefaultsSupplier}) + * into BOTH the raw fe-filesystem bind map ({@link CatalogProperty#getEffectiveRawStorageProperties}) and the + * typed BE storage map ({@link CatalogProperty#getStoragePropertiesMap}) WITHOUT parsing fe-core + * {@code MetastoreProperties}. This is what lets the fe-core Iceberg/Paimon MetastoreProperties cluster be + * retired (their factory is un-registered, so any getMetastoreProperties() on the plugin path would throw). + */ +public class CatalogPropertyPluginStorageDerivationTest { + + private static CatalogProperty hadoopIcebergCatalog() { + Map props = new HashMap<>(); + props.put("type", "iceberg"); + props.put("iceberg.catalog.type", "hadoop"); + props.put(HdfsProperties.FS_HDFS_SUPPORT, "true"); + props.put("warehouse", "hdfs://realns/wh"); + return new CatalogProperty(null, props); + } + + @Test + public void pluginSupplierFoldsDerivedDefaultsIntoBothMaps() { + CatalogProperty cp = hadoopIcebergCatalog(); + // A value the fe-core metastore path would NOT produce (that path derives hdfs://realns from warehouse): + // asserting hdfs://from-connector proves the plugin path uses the connector supplier and does not + // re-derive from MetastoreProperties. MUTATION: route resolveDerivedStorageDefaults back through + // getMetastoreProperties() -> value becomes hdfs://realns -> red. + cp.setPluginDerivedStorageDefaultsSupplier( + () -> Collections.singletonMap("fs.defaultFS", "hdfs://from-connector")); + // Raw supplier (fe-filesystem bind path). + Assertions.assertEquals("hdfs://from-connector", + cp.getEffectiveRawStorageProperties().get("fs.defaultFS")); + // Typed supplier (BE storage map / URI normalization path): same folded default. + Assertions.assertEquals("hdfs://from-connector", + cp.getStoragePropertiesMap().values().iterator().next().getOrigProps().get("fs.defaultFS")); + } + + @Test + public void pluginSupplierEmptyYieldsNoDerivedFs() { + // A rest/vended catalog: the connector derives nothing, so the raw map carries the user props unchanged + // and no synthesized fs.defaultFS. MUTATION: fall back to a warehouse bridge -> red. + Map props = new HashMap<>(); + props.put("type", "iceberg"); + props.put("iceberg.catalog.type", "rest"); + props.put("iceberg.rest.uri", "http://localhost:8181"); + CatalogProperty cp = new CatalogProperty(null, props); + cp.setPluginDerivedStorageDefaultsSupplier(Collections::emptyMap); + Map raw = cp.getEffectiveRawStorageProperties(); + Assertions.assertFalse(raw.containsKey("fs.defaultFS")); + Assertions.assertEquals("http://localhost:8181", raw.get("iceberg.rest.uri")); + } + + @Test + public void derivedDefaultsNeverMutatePersistedProps() { + CatalogProperty cp = hadoopIcebergCatalog(); + cp.setPluginDerivedStorageDefaultsSupplier( + () -> Collections.singletonMap("fs.defaultFS", "hdfs://from-connector")); + cp.getEffectiveRawStorageProperties(); + Assertions.assertFalse(cp.getProperties().containsKey("fs.defaultFS"), + "persisted props must not gain the derived fs.defaultFS"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/ConnectorColumnConverterTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/ConnectorColumnConverterTest.java deleted file mode 100644 index cacc70d94560f4..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/ConnectorColumnConverterTest.java +++ /dev/null @@ -1,142 +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.datasource; - -import org.apache.doris.catalog.ArrayType; -import org.apache.doris.catalog.MapType; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.StructField; -import org.apache.doris.catalog.StructType; -import org.apache.doris.catalog.Type; -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; - -class ConnectorColumnConverterTest { - - @Test - void testScalarTypeRoundtrip() { - // INT → ConnectorType → Doris Type should roundtrip - ConnectorType ct = ConnectorColumnConverter.toConnectorType(ScalarType.INT); - Type back = ConnectorColumnConverter.convertType(ct); - Assertions.assertEquals(ScalarType.INT, back); - } - - @Test - void testArrayTypeRoundtrip() { - ArrayType arrayInt = ArrayType.create(ScalarType.INT, true); - ConnectorType ct = ConnectorColumnConverter.toConnectorType(arrayInt); - - Assertions.assertEquals("ARRAY", ct.getTypeName()); - Assertions.assertEquals(1, ct.getChildren().size()); - Assertions.assertEquals("INT", ct.getChildren().get(0).getTypeName()); - - Type back = ConnectorColumnConverter.convertType(ct); - Assertions.assertTrue(back instanceof ArrayType); - Assertions.assertEquals(ScalarType.INT, ((ArrayType) back).getItemType()); - } - - @Test - void testMapTypeRoundtrip() { - MapType mapType = new MapType(ScalarType.createStringType(), ScalarType.INT); - ConnectorType ct = ConnectorColumnConverter.toConnectorType(mapType); - - Assertions.assertEquals("MAP", ct.getTypeName()); - Assertions.assertEquals(2, ct.getChildren().size()); - Assertions.assertEquals("STRING", ct.getChildren().get(0).getTypeName()); - Assertions.assertEquals("INT", ct.getChildren().get(1).getTypeName()); - - Type back = ConnectorColumnConverter.convertType(ct); - Assertions.assertTrue(back instanceof MapType); - Assertions.assertEquals(ScalarType.createStringType(), ((MapType) back).getKeyType()); - Assertions.assertEquals(ScalarType.INT, ((MapType) back).getValueType()); - } - - @Test - void testStructTypeRoundtrip() { - ArrayList fields = new ArrayList<>(); - fields.add(new StructField("a", ScalarType.INT)); - fields.add(new StructField("b", ScalarType.createStringType())); - StructType structType = new StructType(fields); - - ConnectorType ct = ConnectorColumnConverter.toConnectorType(structType); - - Assertions.assertEquals("STRUCT", ct.getTypeName()); - Assertions.assertEquals(2, ct.getChildren().size()); - Assertions.assertEquals(Arrays.asList("a", "b"), ct.getFieldNames()); - Assertions.assertEquals("INT", ct.getChildren().get(0).getTypeName()); - Assertions.assertEquals("STRING", ct.getChildren().get(1).getTypeName()); - - Type back = ConnectorColumnConverter.convertType(ct); - Assertions.assertTrue(back instanceof StructType); - StructType backStruct = (StructType) back; - Assertions.assertEquals(2, backStruct.getFields().size()); - Assertions.assertEquals("a", backStruct.getFields().get(0).getName()); - Assertions.assertEquals(ScalarType.INT, backStruct.getFields().get(0).getType()); - } - - @Test - void testNestedComplexType() { - // ARRAY> - MapType innerMap = new MapType(ScalarType.createStringType(), ScalarType.INT); - ArrayType nested = ArrayType.create(innerMap, true); - - ConnectorType ct = ConnectorColumnConverter.toConnectorType(nested); - - Assertions.assertEquals("ARRAY", ct.getTypeName()); - ConnectorType mapCt = ct.getChildren().get(0); - Assertions.assertEquals("MAP", mapCt.getTypeName()); - Assertions.assertEquals("STRING", mapCt.getChildren().get(0).getTypeName()); - Assertions.assertEquals("INT", mapCt.getChildren().get(1).getTypeName()); - - // Full roundtrip - Type back = ConnectorColumnConverter.convertType(ct); - Assertions.assertTrue(back instanceof ArrayType); - Type backItem = ((ArrayType) back).getItemType(); - Assertions.assertTrue(backItem instanceof MapType); - } - - @Test - void testUnsupportedTypeConversion() { - ConnectorType ct = ConnectorType.of("UNSUPPORTED", -1, -1); - Type back = ConnectorColumnConverter.convertType(ct); - Assertions.assertTrue(back.isUnsupported()); - } - - @Test - void testUnknownTypeDefaultsToUnsupported() { - ConnectorType ct = ConnectorType.of("GEOMETRY", -1, -1); - Type back = ConnectorColumnConverter.convertType(ct); - Assertions.assertTrue(back.isUnsupported()); - } - - @Test - void testDecimalTypeRoundtrip() { - ScalarType decimal = ScalarType.createDecimalV3Type(18, 6); - ConnectorType ct = ConnectorColumnConverter.toConnectorType(decimal); - - // PrimitiveType.toString() returns the specific decimal width (DECIMAL64 for p<=18) - Assertions.assertTrue(ct.getTypeName().startsWith("DECIMAL")); - Assertions.assertEquals(18, ct.getPrecision()); - Assertions.assertEquals(6, ct.getScale()); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalCatalogDeadlockTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalCatalogDeadlockTest.java index 8a8172f2125853..c6a9ce8841a6a0 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalCatalogDeadlockTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalCatalogDeadlockTest.java @@ -17,7 +17,7 @@ package org.apache.doris.datasource; -import org.apache.doris.datasource.InitCatalogLog.Type; +import org.apache.doris.datasource.log.InitCatalogLog.Type; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.LoadingCache; diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalCatalogTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalCatalogTest.java index f9c332f451bb2f..bc866203754b84 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalCatalogTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalCatalogTest.java @@ -22,7 +22,7 @@ import org.apache.doris.catalog.PrimitiveType; import org.apache.doris.common.FeConstants; import org.apache.doris.common.util.DatasourcePrintableMap; -import org.apache.doris.datasource.hive.HMSExternalCatalog; +import org.apache.doris.datasource.log.CatalogLog; import org.apache.doris.datasource.test.TestExternalCatalog; import org.apache.doris.nereids.parser.NereidsParser; import org.apache.doris.nereids.trees.plans.commands.CreateCatalogCommand; @@ -37,7 +37,6 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -124,44 +123,24 @@ protected void runBeforeAll() throws Exception { } } - @Test - public void testExternalCatalogAutoAnalyze() throws Exception { - HMSExternalCatalog catalog = new HMSExternalCatalog(); - Assertions.assertFalse(catalog.enableAutoAnalyze()); - - HashMap prop = Maps.newHashMap(); - prop.put(ExternalCatalog.ENABLE_AUTO_ANALYZE, "false"); - catalog.modifyCatalogProps(prop); - Assertions.assertFalse(catalog.enableAutoAnalyze()); - - prop = Maps.newHashMap(); - prop.put(ExternalCatalog.ENABLE_AUTO_ANALYZE, "true"); - catalog.modifyCatalogProps(prop); - Assertions.assertTrue(catalog.enableAutoAnalyze()); - - prop = Maps.newHashMap(); - prop.put(ExternalCatalog.ENABLE_AUTO_ANALYZE, "TRUE"); - catalog.modifyCatalogProps(prop); - Assertions.assertTrue(catalog.enableAutoAnalyze()); - } - @Test public void testShowCreateCatalogMasksSensitiveProperties() throws Exception { - String createStmt = "create catalog mask_iceberg_rest properties(\n" - + " \"type\" = \"iceberg\",\n" - + " \"iceberg.catalog.type\" = \"rest\",\n" - + " \"iceberg.rest.uri\" = \"http://localhost:8181\",\n" - + " \"warehouse\" = \"test_db\",\n" - + " \"iceberg.rest.security.type\" = \"oauth2\",\n" - + " \"iceberg.rest.oauth2.credential\" = \"super-secret-pat\",\n" - + " \"iceberg.rest.oauth2.server-uri\" = \"http://localhost:8181/v1/oauth/tokens\",\n" - + " \"iceberg.rest.oauth2.scope\" = \"session:role:TEST_ROLE\"\n" - + ");"; - - NereidsParser nereidsParser = new NereidsParser(); - LogicalPlan logicalPlan = nereidsParser.parseSingle(createStmt); - Assertions.assertTrue(logicalPlan instanceof CreateCatalogCommand); - ((CreateCatalogCommand) logicalPlan).run(rootCtx, null); + // After the iceberg SPI cutover (P6.6), CREATE CATALOG type=iceberg routes through the + // connector plugin path, which is not loadable in fe-core UT. This test only needs a + // registered catalog whose stored properties include iceberg REST secrets, so register it + // via the replay (degraded) path — exactly like edit-log replay does — which does not + // require the connector plugin. SHOW CREATE CATALOG masking is still exercised end-to-end; + // masking of the iceberg REST oauth2 keys themselves is unit-covered in DatasourcePrintableMapTest. + Map credentialProps = Maps.newHashMap(); + credentialProps.put("type", "iceberg"); + credentialProps.put("iceberg.catalog.type", "rest"); + credentialProps.put("iceberg.rest.uri", "http://localhost:8181"); + credentialProps.put("warehouse", "test_db"); + credentialProps.put("iceberg.rest.security.type", "oauth2"); + credentialProps.put("iceberg.rest.oauth2.credential", "super-secret-pat"); + credentialProps.put("iceberg.rest.oauth2.server-uri", "http://localhost:8181/v1/oauth/tokens"); + credentialProps.put("iceberg.rest.oauth2.scope", "session:role:TEST_ROLE"); + registerCatalogViaReplay("mask_iceberg_rest", credentialProps); List> rows = mgr.showCreateCatalog("mask_iceberg_rest"); Assertions.assertEquals(1, rows.size()); @@ -170,18 +149,14 @@ public void testShowCreateCatalogMasksSensitiveProperties() throws Exception { + DatasourcePrintableMap.PASSWORD_MASK + "\"")); Assertions.assertFalse(ddl.contains("super-secret-pat")); - String createTokenStmt = "create catalog mask_iceberg_rest_token properties(\n" - + " \"type\" = \"iceberg\",\n" - + " \"iceberg.catalog.type\" = \"rest\",\n" - + " \"iceberg.rest.uri\" = \"http://localhost:8181\",\n" - + " \"warehouse\" = \"test_db\",\n" - + " \"iceberg.rest.security.type\" = \"oauth2\",\n" - + " \"iceberg.rest.oauth2.token\" = \"super-secret-token\"\n" - + ");"; - - logicalPlan = nereidsParser.parseSingle(createTokenStmt); - Assertions.assertTrue(logicalPlan instanceof CreateCatalogCommand); - ((CreateCatalogCommand) logicalPlan).run(rootCtx, null); + Map tokenProps = Maps.newHashMap(); + tokenProps.put("type", "iceberg"); + tokenProps.put("iceberg.catalog.type", "rest"); + tokenProps.put("iceberg.rest.uri", "http://localhost:8181"); + tokenProps.put("warehouse", "test_db"); + tokenProps.put("iceberg.rest.security.type", "oauth2"); + tokenProps.put("iceberg.rest.oauth2.token", "super-secret-token"); + registerCatalogViaReplay("mask_iceberg_rest_token", tokenProps); rows = mgr.showCreateCatalog("mask_iceberg_rest_token"); Assertions.assertEquals(1, rows.size()); @@ -191,6 +166,16 @@ public void testShowCreateCatalogMasksSensitiveProperties() throws Exception { Assertions.assertFalse(ddl.contains("super-secret-token")); } + private void registerCatalogViaReplay(String name, Map props) throws Exception { + CatalogLog log = new CatalogLog(); + log.setCatalogId(Env.getCurrentEnv().getNextId()); + log.setCatalogName(name); + log.setResource(""); + log.setComment(""); + log.setProps(props); + mgr.replayCreateCatalog(log); + } + @Test public void testExternalCatalogFilteredDatabase() throws Exception { TestExternalCatalog ctl = (TestExternalCatalog) mgr.getCatalog("test1"); diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalDatabaseSessionContextTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalDatabaseSessionContextTest.java index d055049b59fb88..bc5ba7fb1c36e4 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalDatabaseSessionContextTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalDatabaseSessionContextTest.java @@ -19,189 +19,117 @@ import org.apache.doris.catalog.InfoSchemaDb; import org.apache.doris.catalog.MysqlDb; -import org.apache.doris.common.FeConstants; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergRestExternalCatalog; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.utframe.TestWithFeService; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import com.google.common.collect.Lists; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; -import java.util.Collections; +import java.util.ArrayList; +import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map; -public class ExternalDatabaseSessionContextTest extends TestWithFeService { - - @Override - protected void runBeforeAll() throws Exception { - FeConstants.runningUnitTest = true; - } - - @Test - public void testDelegatedSessionTableNamesBypassSharedCache() { - SessionAwareIcebergCatalog catalog = new SessionAwareIcebergCatalog(); - IcebergExternalDatabase db = new IcebergExternalDatabase(catalog, 2L, "db1", "db1"); - - withDelegatedToken("token_a", () -> Assertions.assertEquals( - Collections.singleton("table_a"), db.getTableNamesWithLock())); - withDelegatedToken("token_b", () -> Assertions.assertEquals( - Collections.singleton("table_b"), db.getTableNamesWithLock())); - Assertions.assertEquals(Lists.newArrayList("token_a", "token_b"), catalog.tokensUsedToListTables); - } - - @Test - public void testDelegatedSessionDatabaseLookupBypassesSharedCache() { - SessionAwareIcebergCatalog catalog = new SessionAwareIcebergCatalog(); - - withDelegatedToken("token_a", () -> Assertions.assertNotNull(catalog.getDbNullable("db_a"))); - withDelegatedToken("token_b", () -> Assertions.assertNull(catalog.getDbNullable("db_a"))); - withDelegatedToken("token_b", () -> Assertions.assertNotNull(catalog.getDbNullable("db_b"))); - Assertions.assertEquals(Lists.newArrayList("token_a", "token_b", "token_b"), - catalog.tokensUsedToListDatabases); - } - - @Test - public void testDelegatedSessionDatabaseNamesDoNotPopulateSharedCache() { - SessionAwareIcebergCatalog catalog = new SessionAwareIcebergCatalog(); - - withDelegatedToken("token_a", () -> Assertions.assertEquals( - Lists.newArrayList("db_a", InfoSchemaDb.DATABASE_NAME, MysqlDb.DATABASE_NAME), catalog.getDbNames())); - withDelegatedToken("token_b", () -> Assertions.assertEquals( - Lists.newArrayList("db_b", InfoSchemaDb.DATABASE_NAME, MysqlDb.DATABASE_NAME), catalog.getDbNames())); - Assertions.assertEquals(Lists.newArrayList("token_a", "token_b"), catalog.tokensUsedToListDatabases); - - withDelegatedToken("token_a", () -> { - List sharedDatabaseNames = catalog.getSharedDatabaseNames(); - Assertions.assertTrue(sharedDatabaseNames.contains("db1")); - Assertions.assertFalse(sharedDatabaseNames.contains("db_a")); - }); - Assertions.assertEquals(Lists.newArrayList("token_a", "token_b", "bootstrap"), - catalog.tokensUsedToListDatabases); +/** + * Re-migrates #63068's {@code ExternalDatabaseSessionContextTest} onto the SPI architecture: the DATA-FLOW proof + * (not just the bypass DECISION, which {@link PluginDrivenExternalCatalogSessionBypassTest} pins) that a + * {@code iceberg.rest.session=user} catalog serves PER-USER metadata live and never through the shared + * (catalog+name-keyed, NOT user-keyed) name cache — the cross-user leakage guard (Trino CVE-2026-34214). + * + *

    The bypass reads {@link SessionContext#current()} for both the decision and the live listing, so the token is + * driven through a {@code mockStatic}; the catalog overrides the remote listing to return each user's own + * databases and to record the token it listed under. Because every read records a fresh token (even a repeat of + * an earlier token), we prove no read was served from a shared cache; because the per-user results are disjoint, + * we prove no user's database set leaks to another. #63068 asserted the same via a "bootstrap" shared read, which + * on this branch fail-closes (a session=user catalog has no shared identity to bootstrap with) — so the live + * per-read token record is the equivalent, architecture-correct observable. + */ +public class ExternalDatabaseSessionContextTest { + + private static SessionContext ctxFor(String token) { + return SessionContext.of(token, new DelegatedCredential(DelegatedCredential.Type.ACCESS_TOKEN, token)); } @Test - public void testDelegatedSessionDatabaseLookupUsesLocalNameMapping() { - SessionAwareIcebergCatalog catalog = new SessionAwareIcebergCatalog( - Collections.singletonMap("lower_case_database_names", "1")); - - withDelegatedToken("token_upper", () -> { - Assertions.assertEquals(Lists.newArrayList("salesdb", InfoSchemaDb.DATABASE_NAME, MysqlDb.DATABASE_NAME), - catalog.getDbNames()); - ExternalDatabase db = catalog.getDbNullable("salesdb"); - Assertions.assertNotNull(db); - Assertions.assertEquals("salesdb", db.getFullName()); - Assertions.assertEquals("SalesDB", db.getRemoteName()); - }); - Assertions.assertEquals(Lists.newArrayList("token_upper", "token_upper"), - catalog.tokensUsedToListDatabases); - } - - private static void withDelegatedToken(String token, Runnable action) { - ConnectContext context = new ConnectContext(); - context.setSessionContext(SessionContext.of(new DelegatedCredential( - DelegatedCredential.Type.ACCESS_TOKEN, token))); - context.setThreadLocalInfo(); - try { - action.run(); - } finally { - ConnectContext.remove(); + public void delegatedSessionDatabaseNamesGoLivePerTokenAndNeverShareTheCache() { + SessionAwareCatalog catalog = new SessionAwareCatalog(); + // Build the per-token contexts with the REAL SessionContext.of BEFORE mocking the static current() + // (calling a mocked static inside when(...).thenReturn(...) would corrupt the stubbing). + SessionContext ctxA = ctxFor("token_a"); + SessionContext ctxB = ctxFor("token_b"); + try (MockedStatic sc = Mockito.mockStatic(SessionContext.class)) { + sc.when(SessionContext::current).thenReturn(ctxA); + List aDbs = catalog.getDbNames(); + sc.when(SessionContext::current).thenReturn(ctxB); + List bDbs = catalog.getDbNames(); + // Repeat token_a: if any read were served from a shared cache this would NOT re-list live. + sc.when(SessionContext::current).thenReturn(ctxA); + List aDbsAgain = catalog.getDbNames(); + + // Per-user visibility: each token sees only its own database — no cross-user leakage. + Assertions.assertTrue(aDbs.contains("db_a") && !aDbs.contains("db_b"), + "token_a must see only its own database"); + Assertions.assertTrue(bDbs.contains("db_b") && !bDbs.contains("db_a"), + "token_b must NOT see token_a's database (shared cache would have leaked it)"); + Assertions.assertEquals(aDbs, aDbsAgain, "the repeat read re-lists token_a's live view"); + // Every read listed live under its OWN token -> nothing was served from a shared cache. + Assertions.assertEquals(Lists.newArrayList("token_a", "token_b", "token_a"), + catalog.tokensUsedToListDatabases, + "each getDbNames must go live with the current user's token, never hit a shared cache"); + // System databases stay visible under a per-user listing. + Assertions.assertTrue(aDbs.contains(InfoSchemaDb.DATABASE_NAME) && aDbs.contains(MysqlDb.DATABASE_NAME), + "information_schema + mysql must remain visible under the per-user bypass"); } } - private static class SessionAwareIcebergCatalog extends IcebergRestExternalCatalog { - private final List tokensUsedToListTables = Lists.newArrayList(); - private final List tokensUsedToListDatabases = Lists.newArrayList(); - - private SessionAwareIcebergCatalog() { - this(Collections.emptyMap()); - } - - private SessionAwareIcebergCatalog(Map overrideProps) { - super(1L, "session_catalog", null, catalogProperties(overrideProps), ""); - } - - private static Map catalogProperties(Map overrideProps) { - Map props = new HashMap<>(); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "rest"); - props.put("iceberg.rest.uri", "http://localhost:8181"); - props.put("iceberg.rest.security.type", "oauth2"); - props.put("iceberg.rest.session", "user"); - props.put("iceberg.rest.oauth2.credential", "client_credentials"); - props.put("iceberg.rest.oauth2.server-uri", "http://auth.example.com/token"); - props.putAll(overrideProps); - return props; + /** + * A {@code session=user} plugin catalog whose remote database listing is per-token (each token sees only + * {@code db_}) and records the token it listed under. Pre-initialized so {@code getDbNames} skips the + * Env-dependent metaCache build; the credentialed bypass path never touches that cache anyway. + */ + private static final class SessionAwareCatalog extends PluginDrivenExternalCatalog { + private final List tokensUsedToListDatabases = new ArrayList<>(); + + SessionAwareCatalog() { + super(1L, "test_ctl", null, props(), "", userSessionConnector()); + this.initialized = true; } @Override protected void initLocalObjectsImpl() { - executionAuthenticator = new ExecutionAuthenticator() { - }; + // no-op: the connector is injected via the constructor and the catalog is pre-initialized. } @Override protected List listDatabaseNames() { - tokensUsedToListDatabases.add("bootstrap"); - return databaseNamesForToken("bootstrap"); - } - - @Override - protected List listDatabaseNames(SessionContext ctx) { - String token = token(ctx); + String token = SessionContext.current().getDelegatedCredential().get().getToken(); tokensUsedToListDatabases.add(token); - return databaseNamesForToken(token); - } - - private List databaseNamesForToken(String token) { - if ("token_a".equals(token)) { - return Lists.newArrayList("db_a"); - } - if ("token_b".equals(token)) { - return Lists.newArrayList("db_b"); - } - if ("token_upper".equals(token)) { - return Lists.newArrayList("SalesDB"); - } - return Lists.newArrayList("db1"); + // per-user: token_a -> [db_a], token_b -> [db_b] + return Lists.newArrayList("db_" + token.substring("token_".length())); } @Override - protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { - String token = token(ctx); - tokensUsedToListTables.add(token); - if ("token_a".equals(token)) { - return Lists.newArrayList("table_a"); - } - if ("token_b".equals(token)) { - return Lists.newArrayList("table_b"); - } - return Lists.newArrayList("bootstrap_table"); + public String fromRemoteDatabaseName(String remoteDatabaseName) { + // identity mapping (avoids routing through the mocked connector's metadata for the local name) + return remoteDatabaseName; } - @Override - public boolean tableExist(SessionContext ctx, String dbName, String tblName) { - return listTableNamesFromRemote(ctx, dbName).contains(tblName); + private static Connector userSessionConnector() { + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getCapabilities()) + .thenReturn(EnumSet.of(ConnectorCapability.SUPPORTS_USER_SESSION)); + return connector; } - @Override - public boolean isIcebergRestUserSessionEnabled() { - return true; - } - - private List getSharedDatabaseNames() { - makeSureInitialized(); - return metaCache.listNames(); - } - - private static String token(SessionContext ctx) { - return ctx.getDelegatedCredential() - .map(DelegatedCredential::getToken) - .orElse("bootstrap"); + private static Map props() { + Map props = new HashMap<>(); + props.put("type", "iceberg"); + return props; } } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalMetaCacheRouteResolverTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalMetaCacheRouteResolverTest.java deleted file mode 100644 index 55cc0d32dc9fc6..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalMetaCacheRouteResolverTest.java +++ /dev/null @@ -1,386 +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.datasource; - -import org.apache.doris.catalog.DatabaseIf; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.datasource.doris.RemoteDorisExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergHMSExternalCatalog; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog; -import org.apache.doris.datasource.metacache.ExternalMetaCache; -import org.apache.doris.datasource.metacache.MetaCacheEntry; -import org.apache.doris.datasource.metacache.MetaCacheEntryStats; -import org.apache.doris.datasource.paimon.PaimonExternalCatalog; - -import org.junit.After; -import org.junit.Assert; -import org.junit.Test; -import org.mockito.MockedStatic; -import org.mockito.Mockito; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - -public class ExternalMetaCacheRouteResolverTest { - - private MockedStatic envMockedStatic; - - @After - public void tearDown() { - if (envMockedStatic != null) { - envMockedStatic.close(); - envMockedStatic = null; - } - } - - @Test - public void testEngineAliasCompatibility() { - ExternalMetaCacheMgr metaCacheMgr = new ExternalMetaCacheMgr(true); - Assert.assertEquals("hive", metaCacheMgr.engine("hms").engine()); - Assert.assertEquals("doris", metaCacheMgr.engine("External_Doris").engine()); - Assert.assertEquals("maxcompute", metaCacheMgr.engine("max_compute").engine()); - } - - @Test - public void testRouteByCatalogType() { - ExternalMetaCacheMgr metaCacheMgr = new ExternalMetaCacheMgr(true); - - List hmsEngines = metaCacheMgr.resolveCatalogEngineNamesForTest( - new HMSExternalCatalog(1L, "hms", null, Collections.emptyMap(), ""), 1L); - Assert.assertTrue(hmsEngines.contains("hive")); - Assert.assertTrue(hmsEngines.contains("hudi")); - Assert.assertTrue(hmsEngines.contains("iceberg")); - Assert.assertFalse(hmsEngines.contains("paimon")); - Assert.assertFalse(hmsEngines.contains("doris")); - Assert.assertFalse(hmsEngines.contains("maxcompute")); - Assert.assertFalse(hmsEngines.contains("default")); - - List icebergEngines = metaCacheMgr.resolveCatalogEngineNamesForTest( - new IcebergHMSExternalCatalog(2L, "iceberg", null, Collections.emptyMap(), ""), 2L); - Assert.assertEquals(java.util.Collections.singletonList("iceberg"), icebergEngines); - - List paimonEngines = metaCacheMgr.resolveCatalogEngineNamesForTest( - new PaimonExternalCatalog(3L, "paimon", null, Collections.emptyMap(), ""), 3L); - Assert.assertEquals(java.util.Collections.singletonList("paimon"), paimonEngines); - - List maxComputeEngines = metaCacheMgr.resolveCatalogEngineNamesForTest( - new MaxComputeExternalCatalog(4L, "maxcompute", null, Collections.emptyMap(), ""), 4L); - Assert.assertEquals(java.util.Collections.singletonList("maxcompute"), maxComputeEngines); - - List dorisEngines = metaCacheMgr.resolveCatalogEngineNamesForTest( - new RemoteDorisExternalCatalog(5L, "doris", null, Collections.emptyMap(), ""), 5L); - Assert.assertEquals(java.util.Collections.singletonList("doris"), dorisEngines); - } - - @Test - public void testMissingCatalogOnlyRoutesInitializedEngines() { - ExternalMetaCacheMgr metaCacheMgr = new ExternalMetaCacheMgr(true); - long catalogId = 7L; - - metaCacheMgr.prepareCatalogByEngine(catalogId, "hive", java.util.Collections.emptyMap()); - - List engines = metaCacheMgr.resolveCatalogEngineNamesForTest(null, catalogId); - Assert.assertTrue(engines.contains("hive")); - Assert.assertFalse(engines.contains("iceberg")); - Assert.assertFalse(engines.contains("paimon")); - } - - @Test - public void testPrepareCatalogByEngineSkipsMissingCatalog() throws Exception { - RecordingExternalMetaCache hive = new RecordingExternalMetaCache( - "hive", Collections.singletonList("hms"), catalog -> catalog instanceof HMSExternalCatalog); - ExternalMetaCacheMgr metaCacheMgr = newManagerWithCaches(hive); - long catalogId = 10L; - - mockCurrentCatalog(catalogId, null); - - metaCacheMgr.prepareCatalog(catalogId); - metaCacheMgr.prepareCatalogByEngine(catalogId, "hive"); - - Assert.assertEquals(0, hive.initCatalogCalls); - } - - @Test - public void testGetSchemaCacheValueReturnsEmptyWhenCatalogMissing() throws Exception { - MissingCatalogSchemaExternalMetaCache schemaCache = new MissingCatalogSchemaExternalMetaCache("default"); - ExternalMetaCacheMgr metaCacheMgr = newManagerWithCaches(schemaCache); - long catalogId = 11L; - - mockCurrentCatalog(catalogId, null); - - TestingExternalTable table = new TestingExternalTable(catalogId, "default"); - Assert.assertFalse(metaCacheMgr.getSchemaCacheValue( - table, new SchemaCacheKey(table.getOrBuildNameMapping())).isPresent()); - Assert.assertEquals(1, schemaCache.entryCalls); - } - - @Test - public void testLifecycleRoutingOnlyTouchesSupportedEngine() throws Exception { - RecordingExternalMetaCache hive = new RecordingExternalMetaCache( - "hive", Collections.singletonList("hms"), catalog -> catalog instanceof HMSExternalCatalog); - RecordingExternalMetaCache hudi = new RecordingExternalMetaCache( - "hudi", Collections.emptyList(), catalog -> catalog instanceof HMSExternalCatalog); - RecordingExternalMetaCache iceberg = new RecordingExternalMetaCache( - "iceberg", Collections.emptyList(), catalog -> catalog instanceof HMSExternalCatalog); - RecordingExternalMetaCache paimon = new RecordingExternalMetaCache( - "paimon", Collections.emptyList(), catalog -> catalog instanceof PaimonExternalCatalog); - ExternalMetaCacheMgr metaCacheMgr = newManagerWithCaches(hive, hudi, iceberg, paimon); - long catalogId = 8L; - - HMSExternalCatalog catalog = new HMSExternalCatalog( - catalogId, "hms", null, Collections.singletonMap("k", "v"), ""); - mockCurrentCatalog(catalogId, catalog); - - metaCacheMgr.prepareCatalog(catalogId); - metaCacheMgr.invalidateCatalog(catalogId); - metaCacheMgr.invalidateDb(catalogId, "db1"); - metaCacheMgr.invalidateTable(catalogId, "db1", "tbl1"); - metaCacheMgr.invalidatePartitions(catalogId, "db1", "tbl1", Collections.singletonList("p=1")); - metaCacheMgr.removeCatalog(catalogId); - - Assert.assertEquals(1, hive.initCatalogCalls); - Assert.assertEquals(1, hive.invalidateCatalogEntriesCalls); - Assert.assertEquals(1, hive.invalidateDbCalls); - Assert.assertEquals(1, hive.invalidateTableCalls); - Assert.assertEquals(1, hive.invalidatePartitionsCalls); - Assert.assertEquals(1, hive.invalidateCatalogCalls); - - Assert.assertEquals(1, hudi.initCatalogCalls); - Assert.assertEquals(1, hudi.invalidateCatalogEntriesCalls); - Assert.assertEquals(1, hudi.invalidateDbCalls); - Assert.assertEquals(1, hudi.invalidateTableCalls); - Assert.assertEquals(1, hudi.invalidatePartitionsCalls); - Assert.assertEquals(1, hudi.invalidateCatalogCalls); - - Assert.assertEquals(1, iceberg.initCatalogCalls); - Assert.assertEquals(1, iceberg.invalidateCatalogEntriesCalls); - Assert.assertEquals(1, iceberg.invalidateDbCalls); - Assert.assertEquals(1, iceberg.invalidateTableCalls); - Assert.assertEquals(1, iceberg.invalidatePartitionsCalls); - Assert.assertEquals(1, iceberg.invalidateCatalogCalls); - - Assert.assertEquals(0, paimon.initCatalogCalls); - Assert.assertEquals(0, paimon.invalidateCatalogEntriesCalls); - Assert.assertEquals(0, paimon.invalidateDbCalls); - Assert.assertEquals(0, paimon.invalidateTableCalls); - Assert.assertEquals(0, paimon.invalidatePartitionsCalls); - Assert.assertEquals(0, paimon.invalidateCatalogCalls); - } - - @Test - public void testMissingCatalogLifecycleOnlyTouchesInitializedEngine() throws Exception { - RecordingExternalMetaCache hive = new RecordingExternalMetaCache( - "hive", Collections.singletonList("hms"), catalog -> catalog instanceof HMSExternalCatalog); - RecordingExternalMetaCache paimon = new RecordingExternalMetaCache( - "paimon", Collections.emptyList(), catalog -> catalog instanceof PaimonExternalCatalog); - ExternalMetaCacheMgr metaCacheMgr = newManagerWithCaches(hive, paimon); - long catalogId = 9L; - - hive.initializedCatalogIds.add(catalogId); - mockCurrentCatalog(catalogId, null); - - metaCacheMgr.invalidateCatalog(catalogId); - metaCacheMgr.invalidateDb(catalogId, "db1"); - metaCacheMgr.invalidateTable(catalogId, "db1", "tbl1"); - metaCacheMgr.invalidatePartitions(catalogId, "db1", "tbl1", Collections.singletonList("p=1")); - metaCacheMgr.removeCatalog(catalogId); - - Assert.assertEquals(1, hive.invalidateCatalogEntriesCalls); - Assert.assertEquals(1, hive.invalidateDbCalls); - Assert.assertEquals(1, hive.invalidateTableCalls); - Assert.assertEquals(1, hive.invalidatePartitionsCalls); - Assert.assertEquals(1, hive.invalidateCatalogCalls); - - Assert.assertEquals(0, paimon.invalidateCatalogEntriesCalls); - Assert.assertEquals(0, paimon.invalidateDbCalls); - Assert.assertEquals(0, paimon.invalidateTableCalls); - Assert.assertEquals(0, paimon.invalidatePartitionsCalls); - Assert.assertEquals(0, paimon.invalidateCatalogCalls); - } - - @SuppressWarnings("unchecked") - private ExternalMetaCacheMgr newManagerWithCaches(RecordingExternalMetaCache... caches) throws Exception { - ExternalMetaCacheMgr metaCacheMgr = new ExternalMetaCacheMgr(true); - metaCacheMgr.replaceEngineCachesForTest(java.util.Arrays.asList(caches)); - return metaCacheMgr; - } - - private void mockCurrentCatalog(long catalogId, - CatalogIf> catalog) { - if (envMockedStatic != null) { - envMockedStatic.close(); - } - CatalogMgr catalogMgr = new TestingCatalogMgr(catalogId, catalog); - Env env = new TestingEnv(catalogMgr); - envMockedStatic = Mockito.mockStatic(Env.class); - envMockedStatic.when(Env::getCurrentEnv).thenReturn(env); - } - - private static final class TestingCatalogMgr extends CatalogMgr { - private final Map>> catalogs = new HashMap<>(); - - private TestingCatalogMgr(long catalogId, CatalogIf> catalog) { - catalogs.put(catalogId, catalog); - } - - @Override - public CatalogIf> getCatalog(long id) { - return catalogs.get(id); - } - } - - private static final class TestingEnv extends Env { - private final CatalogMgr catalogMgr; - - private TestingEnv(CatalogMgr catalogMgr) { - super(true); - this.catalogMgr = catalogMgr; - } - - @Override - public CatalogMgr getCatalogMgr() { - return catalogMgr; - } - } - - private static final class TestingExternalTable extends ExternalTable { - private final String metaCacheEngine; - - private TestingExternalTable(long catalogId, String metaCacheEngine) { - this.metaCacheEngine = metaCacheEngine; - this.catalog = new HMSExternalCatalog(catalogId, "hms", null, Collections.emptyMap(), ""); - this.dbName = "db1"; - this.name = "tbl1"; - this.remoteName = "remote_tbl1"; - this.nameMapping = new NameMapping(catalogId, "db1", "tbl1", "remote_db1", "remote_tbl1"); - } - - @Override - public String getMetaCacheEngine() { - return metaCacheEngine; - } - } - - private static class RecordingExternalMetaCache implements ExternalMetaCache { - private final String engine; - private final List aliases; - private final Set initializedCatalogIds = ConcurrentHashMap.newKeySet(); - - private int initCatalogCalls; - private int invalidateCatalogCalls; - private int invalidateCatalogEntriesCalls; - private int invalidateDbCalls; - private int invalidateTableCalls; - private int invalidatePartitionsCalls; - - private RecordingExternalMetaCache(String engine, List aliases, - java.util.function.Predicate> ignoredPredicate) { - this.engine = engine; - this.aliases = aliases; - } - - @Override - public String engine() { - return engine; - } - - @Override - public List aliases() { - return aliases; - } - - @Override - public void initCatalog(long catalogId, Map catalogProperties) { - initializedCatalogIds.add(catalogId); - initCatalogCalls++; - } - - @Override - public MetaCacheEntry entry( - long catalogId, String entryName, Class keyType, Class valueType) { - throw new UnsupportedOperationException(); - } - - @Override - public void checkCatalogInitialized(long catalogId) { - if (!isCatalogInitialized(catalogId)) { - throw new IllegalStateException("catalog " + catalogId + " is not initialized"); - } - } - - @Override - public boolean isCatalogInitialized(long catalogId) { - return initializedCatalogIds.contains(catalogId); - } - - @Override - public void invalidateCatalog(long catalogId) { - initializedCatalogIds.remove(catalogId); - invalidateCatalogCalls++; - } - - @Override - public void invalidateCatalogEntries(long catalogId) { - invalidateCatalogEntriesCalls++; - } - - @Override - public void invalidateDb(long catalogId, String dbName) { - invalidateDbCalls++; - } - - @Override - public void invalidateTable(long catalogId, String dbName, String tableName) { - invalidateTableCalls++; - } - - @Override - public void invalidatePartitions(long catalogId, String dbName, String tableName, List partitions) { - invalidatePartitionsCalls++; - } - - @Override - public Map stats(long catalogId) { - return Collections.emptyMap(); - } - - @Override - public void close() { - } - } - - private static final class MissingCatalogSchemaExternalMetaCache extends RecordingExternalMetaCache { - private int entryCalls; - - private MissingCatalogSchemaExternalMetaCache(String engine) { - super(engine, Collections.emptyList(), catalog -> true); - } - - @Override - public MetaCacheEntry entry(long catalogId, String entryName, Class keyType, Class valueType) { - entryCalls++; - throw new IllegalStateException("catalog " + catalogId + " is not initialized"); - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalMetaIdMgrTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalMetaIdMgrTest.java index 12e018a4cffaaf..be05bce1c302d5 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalMetaIdMgrTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalMetaIdMgrTest.java @@ -17,8 +17,14 @@ package org.apache.doris.datasource; +import org.apache.doris.catalog.Env; +import org.apache.doris.datasource.log.MetaIdMappingsLog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; + import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; public class ExternalMetaIdMgrTest { @@ -73,4 +79,60 @@ public void testReplayMetaIdMappingsLog() { Assertions.assertNotEquals(-1L, mgr.getPartitionId(1L, "db1", "tbl1", "p1")); } + /** + * An HMS-event id-mapping log carries the master's synced-event-id cursor and is replayed on + * every FE. A flipped HMS catalog is served by a generic {@link PluginDrivenExternalCatalog}, so + * replay must propagate that cursor keyed by {@code catalogId} through {@link MetastoreEventSyncDriver} + * without casting the live catalog to {@code HMSExternalCatalog} — that cast would throw + * {@link ClassCastException} and abort edit-log replay, wedging FE startup. + */ + @Test + public void testReplayHmsEventCursorDoesNotRequireHmsCatalogType() { + final long catalogId = 7L; + final long lastSyncedEventId = 42L; + + CatalogMgr catalogMgr = Mockito.mock(CatalogMgr.class); + // The live post-cutover catalog is a generic PluginDrivenExternalCatalog (never HMSExternalCatalog); + // doReturn avoids stubbing the wildcard-generic return type of getCatalog(long). + Mockito.doReturn(Mockito.mock(PluginDrivenExternalCatalog.class)).when(catalogMgr).getCatalog(catalogId); + MetastoreEventSyncDriver syncDriver = Mockito.mock(MetastoreEventSyncDriver.class); + Env env = new TestingEnv(catalogMgr, syncDriver); + + MetaIdMappingsLog log = new MetaIdMappingsLog(); + log.setCatalogId(catalogId); + log.setFromHmsEvent(true); + log.setLastSyncedEventId(lastSyncedEventId); + + try (MockedStatic envMockedStatic = Mockito.mockStatic(Env.class)) { + envMockedStatic.when(Env::getCurrentEnv).thenReturn(env); + + // A (HMSExternalCatalog) cast here would throw ClassCastException on the generic catalog. + Assertions.assertDoesNotThrow(() -> new ExternalMetaIdMgr().replayMetaIdMappingsLog(log)); + + // The cursor is propagated keyed by catalogId, via the sync driver, not by casting the catalog. + Mockito.verify(syncDriver).updateMasterLastSyncedEventId(catalogId, lastSyncedEventId); + } + } + + private static final class TestingEnv extends Env { + private final CatalogMgr catalogMgr; + private final MetastoreEventSyncDriver metastoreEventSyncDriver; + + private TestingEnv(CatalogMgr catalogMgr, MetastoreEventSyncDriver metastoreEventSyncDriver) { + super(true); + this.catalogMgr = catalogMgr; + this.metastoreEventSyncDriver = metastoreEventSyncDriver; + } + + @Override + public CatalogMgr getCatalogMgr() { + return catalogMgr; + } + + @Override + public MetastoreEventSyncDriver getMetastoreEventSyncDriver() { + return metastoreEventSyncDriver; + } + } + } diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalTableSchemaCacheDelegationTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalTableSchemaCacheDelegationTest.java index ed375c42fd3da6..c14f8f3765e0ce 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalTableSchemaCacheDelegationTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalTableSchemaCacheDelegationTest.java @@ -23,6 +23,7 @@ import com.google.common.collect.Lists; import org.junit.Assert; import org.junit.Test; +import org.mockito.Mockito; import java.util.List; import java.util.Optional; @@ -42,6 +43,46 @@ public void testGetFullSchemaReturnsNullWhenSchemaCacheMissing() { Assert.assertNull(table.getFullSchema()); } + @Test + public void getSchemaCacheValueBypassesSharedCacheUnderSessionUser() { + // Read-site routing for the "list != load" fix: when the catalog reports bypass (session=user + delegated + // credential), getSchemaCacheValue reads schema LIVE via initSchema and NEVER consults the shared + // name-keyed cache (Env.getExtMetaCacheMgr), so one user's schema is not served to another who can list + // but not load the table. MUTATION: dropping the bypass branch -> this bare unit reaches Env (null) and + // NPEs instead of returning the live sentinel. + List live = Lists.newArrayList(new Column("c1", PrimitiveType.INT)); + ExternalCatalog catalog = Mockito.mock(ExternalCatalog.class); + Mockito.when(catalog.shouldBypassSchemaCache(Mockito.any())).thenReturn(true); + BypassProbeTable table = new BypassProbeTable(catalog, live); + + Optional result = table.getSchemaCacheValue(); + Assert.assertTrue(result.isPresent()); + Assert.assertEquals(live, result.get().getSchema()); + Assert.assertEquals("schema was read live, once, through initSchema (not the shared cache)", + 1, table.initSchemaCalls); + } + + private static final class BypassProbeTable extends ExternalTable { + private final List live; + private int initSchemaCalls; + + private BypassProbeTable(ExternalCatalog catalog, List live) { + this.catalog = catalog; + this.live = live; + } + + @Override + public NameMapping getOrBuildNameMapping() { + return NameMapping.createForTest("db", "tbl"); + } + + @Override + public Optional initSchema() { + initSchemaCalls++; + return Optional.of(new SchemaCacheValue(live)); + } + } + private static final class DelegatingExternalTable extends ExternalTable { private final Optional schemaCacheValue; diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalUtilTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalUtilTest.java deleted file mode 100644 index 3ecb3a72d6b023..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalUtilTest.java +++ /dev/null @@ -1,268 +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.datasource; - -import org.apache.doris.analysis.SlotDescriptor; -import org.apache.doris.analysis.SlotId; -import org.apache.doris.catalog.ArrayType; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.MapType; -import org.apache.doris.catalog.StructField; -import org.apache.doris.catalog.StructType; -import org.apache.doris.catalog.Type; -import org.apache.doris.thrift.TFileScanRangeParams; -import org.apache.doris.thrift.schema.external.TArrayField; -import org.apache.doris.thrift.schema.external.TField; -import org.apache.doris.thrift.schema.external.TNestedField; -import org.apache.doris.thrift.schema.external.TSchema; -import org.apache.doris.thrift.schema.external.TStructField; - -import org.junit.Assert; -import org.junit.Test; - -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class ExternalUtilTest { - - @Test - public void testInitSchemaInfoForPrunedColumnBasicAndNameMapping() { - TFileScanRangeParams params = new TFileScanRangeParams(); - Long schemaId = 100L; - - SlotDescriptor normalSlot = new SlotDescriptor(new SlotId(1), null); - Column normalColumn = new Column("col1", Type.INT, true); - normalColumn.setUniqueId(1); - normalSlot.setType(Type.INT); - normalSlot.setColumn(normalColumn); - - SlotDescriptor globalRowIdSlot = new SlotDescriptor(new SlotId(2), null); - Column globalRowIdColumn = new Column(Column.GLOBAL_ROWID_COL + "_suffix", Type.BIGINT, true); - globalRowIdColumn.setUniqueId(2); - globalRowIdSlot.setType(Type.BIGINT); - globalRowIdSlot.setColumn(globalRowIdColumn); - - List slots = Arrays.asList(normalSlot, globalRowIdSlot); - - Map> nameMapping = new HashMap<>(); - List mappedNames = Arrays.asList("mapped_col1"); - nameMapping.put(normalColumn.getUniqueId(), mappedNames); - - ExternalUtil.initSchemaInfoForPrunedColumn(params, schemaId, slots, nameMapping); - - Assert.assertEquals(schemaId.longValue(), params.getCurrentSchemaId()); - - List history = params.getHistorySchemaInfo(); - Assert.assertEquals(1, history.size()); - - TSchema tSchema = history.get(0); - Assert.assertEquals(schemaId.longValue(), tSchema.getSchemaId()); - - TStructField rootField = tSchema.getRootField(); - Assert.assertNotNull(rootField); - // GLOBAL_ROWID_COL should be skipped - Assert.assertEquals(1, rootField.getFieldsSize()); - - TField field = rootField.getFields().get(0).getFieldPtr(); - Assert.assertEquals(normalColumn.getName(), field.getName()); - Assert.assertEquals(normalColumn.getUniqueId(), field.getId()); - Assert.assertEquals(normalColumn.isAllowNull(), field.isIsOptional()); - Assert.assertEquals(normalColumn.getType().toColumnTypeThrift(), field.getType()); - Assert.assertEquals(mappedNames, field.getNameMapping()); - } - - @Test - public void testInitSchemaInfoForPrunedColumnWithArrayNestedType() { - TFileScanRangeParams params = new TFileScanRangeParams(); - Long schemaId = 200L; - - ArrayType arrayType = ArrayType.create(Type.INT, true); - Column arrayColumn = new Column("arr_col", arrayType, true); - arrayColumn.setUniqueId(10); - arrayColumn.createChildrenColumn(arrayType, arrayColumn); - - SlotDescriptor arraySlot = new SlotDescriptor(new SlotId(3), null); - arraySlot.setType(arrayType); - arraySlot.setColumn(arrayColumn); - - List slots = Collections.singletonList(arraySlot); - - ExternalUtil.initSchemaInfoForPrunedColumn(params, schemaId, slots, null); - - List history = params.getHistorySchemaInfo(); - Assert.assertEquals(1, history.size()); - - TSchema tSchema = history.get(0); - Assert.assertEquals(schemaId.longValue(), tSchema.getSchemaId()); - - TStructField rootField = tSchema.getRootField(); - Assert.assertNotNull(rootField); - Assert.assertEquals(1, rootField.getFieldsSize()); - - TField rootArrayField = rootField.getFields().get(0).getFieldPtr(); - Assert.assertTrue(rootArrayField.isSetNestedField()); - - TNestedField nestedField = rootArrayField.getNestedField(); - Assert.assertTrue(nestedField.isSetArrayField()); - - TArrayField tArrayField = nestedField.getArrayField(); - TField itemField = tArrayField.getItemField().getFieldPtr(); - Assert.assertEquals(arrayColumn.getChildren().get(0).getType().toColumnTypeThrift(), itemField.getType()); - } - - @Test - public void testInitSchemaInfoForPrunedColumnWithStructNestedType() { - TFileScanRangeParams params = new TFileScanRangeParams(); - Long schemaId = 300L; - - StructType structType = new StructType( - new StructField("f_int", Type.INT), - new StructField("f_str", Type.VARCHAR)); - Column structColumn = new Column("struct_col", structType, true); - structColumn.setUniqueId(20); - structColumn.createChildrenColumn(structType, structColumn); - - SlotDescriptor structSlot = new SlotDescriptor(new SlotId(4), null); - structSlot.setType(structType); - structSlot.setColumn(structColumn); - - List slots = Collections.singletonList(structSlot); - - ExternalUtil.initSchemaInfoForPrunedColumn(params, schemaId, slots, null); - - List history = params.getHistorySchemaInfo(); - Assert.assertEquals(1, history.size()); - - TSchema tSchema = history.get(0); - Assert.assertEquals(schemaId.longValue(), tSchema.getSchemaId()); - - TStructField rootField = tSchema.getRootField(); - Assert.assertNotNull(rootField); - Assert.assertEquals(1, rootField.getFieldsSize()); - - TField rootStructField = rootField.getFields().get(0).getFieldPtr(); - Assert.assertTrue(rootStructField.isSetNestedField()); - - TNestedField nestedField = rootStructField.getNestedField(); - Assert.assertTrue(nestedField.isSetStructField()); - TStructField tStructField = nestedField.getStructField(); - - Assert.assertEquals(2, tStructField.getFieldsSize()); - // 保证字段顺序和类型与 StructType 一致 - TField firstField = tStructField.getFields().get(0).getFieldPtr(); - TField secondField = tStructField.getFields().get(1).getFieldPtr(); - Assert.assertEquals(structColumn.getChildren().get(0).getType().toColumnTypeThrift(), firstField.getType()); - Assert.assertEquals(structColumn.getChildren().get(1).getType().toColumnTypeThrift(), secondField.getType()); - } - - @Test - public void testInitSchemaInfoForPrunedColumnWithMapNestedType() { - TFileScanRangeParams params = new TFileScanRangeParams(); - Long schemaId = 400L; - - MapType mapType = new MapType(Type.VARCHAR, Type.INT); - Column mapColumn = new Column("map_col", mapType, true); - mapColumn.setUniqueId(30); - mapColumn.createChildrenColumn(mapType, mapColumn); - - SlotDescriptor mapSlot = new SlotDescriptor(new SlotId(5), null); - mapSlot.setType(mapType); - mapSlot.setColumn(mapColumn); - - List slots = Collections.singletonList(mapSlot); - - ExternalUtil.initSchemaInfoForPrunedColumn(params, schemaId, slots, null); - - List history = params.getHistorySchemaInfo(); - Assert.assertEquals(1, history.size()); - - TSchema tSchema = history.get(0); - Assert.assertEquals(schemaId.longValue(), tSchema.getSchemaId()); - - TStructField rootField = tSchema.getRootField(); - Assert.assertNotNull(rootField); - Assert.assertEquals(1, rootField.getFieldsSize()); - - TField rootMapField = rootField.getFields().get(0).getFieldPtr(); - Assert.assertTrue(rootMapField.isSetNestedField()); - - TNestedField nestedField = rootMapField.getNestedField(); - Assert.assertTrue(nestedField.isSetMapField()); - - // key / value 类型应与 children 中的列类型一致 - TField keyField = nestedField.getMapField().getKeyField().getFieldPtr(); - TField valueField = nestedField.getMapField().getValueField().getFieldPtr(); - Assert.assertEquals(mapColumn.getChildren().get(0).getType().toColumnTypeThrift(), keyField.getType()); - Assert.assertEquals(mapColumn.getChildren().get(1).getType().toColumnTypeThrift(), valueField.getType()); - } - - @Test - public void testInitSchemaInfoForAllColumnMultipleColumnsAndNameMapping() { - TFileScanRangeParams params = new TFileScanRangeParams(); - Long schemaId = 500L; - - Column col1 = new Column("c1", Type.INT, false, null, true, "7", ""); - col1.setUniqueId(101); - Column col2 = new Column("c2", Type.VARCHAR, false); - col2.setUniqueId(102); - - List columns = Arrays.asList(col1, col2); - - Map> nameMapping = new HashMap<>(); - nameMapping.put(col1.getUniqueId(), Arrays.asList("m_c1")); - nameMapping.put(col2.getUniqueId(), Arrays.asList("m_c2_a", "m_c2_b")); - - Map base64InitialDefaults = new HashMap<>(); - base64InitialDefaults.put(col2.getUniqueId(), "AAEC/w=="); - ExternalUtil.initSchemaInfoForAllColumn( - params, schemaId, columns, nameMapping, base64InitialDefaults); - - Assert.assertEquals(schemaId.longValue(), params.getCurrentSchemaId()); - List history = params.getHistorySchemaInfo(); - Assert.assertEquals(1, history.size()); - - TSchema tSchema = history.get(0); - Assert.assertEquals(schemaId.longValue(), tSchema.getSchemaId()); - - TStructField rootField = tSchema.getRootField(); - Assert.assertNotNull(rootField); - Assert.assertEquals(2, rootField.getFieldsSize()); - - TField field1 = rootField.getFields().get(0).getFieldPtr(); - TField field2 = rootField.getFields().get(1).getFieldPtr(); - - Assert.assertEquals(col1.getName(), field1.getName()); - Assert.assertEquals(col1.getUniqueId(), field1.getId()); - Assert.assertEquals(col1.isAllowNull(), field1.isIsOptional()); - Assert.assertEquals(col1.getType().toColumnTypeThrift(), field1.getType()); - Assert.assertEquals(Arrays.asList("m_c1"), field1.getNameMapping()); - Assert.assertEquals("7", field1.getInitialDefaultValue()); - Assert.assertFalse(field1.isSetInitialDefaultValueIsBase64()); - - Assert.assertEquals(col2.getName(), field2.getName()); - Assert.assertEquals(col2.getUniqueId(), field2.getId()); - Assert.assertEquals(col2.isAllowNull(), field2.isIsOptional()); - Assert.assertEquals(col2.getType().toColumnTypeThrift(), field2.getType()); - Assert.assertEquals(Arrays.asList("m_c2_a", "m_c2_b"), field2.getNameMapping()); - Assert.assertEquals("AAEC/w==", field2.getInitialDefaultValue()); - Assert.assertTrue(field2.isInitialDefaultValueIsBase64()); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/FileCacheAdmissionRuleRefresherTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/FileCacheAdmissionRuleRefresherTest.java index a5325a36f8c084..d17b1092a39952 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/FileCacheAdmissionRuleRefresherTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/FileCacheAdmissionRuleRefresherTest.java @@ -18,6 +18,7 @@ package org.apache.doris.datasource; import org.apache.doris.common.Config; +import org.apache.doris.datasource.scan.FileCacheAdmissionManager; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/FileGroupIntoTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/FileGroupIntoTest.java index b4470075717062..6830c14d9cfd95 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/FileGroupIntoTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/FileGroupIntoTest.java @@ -17,6 +17,8 @@ package org.apache.doris.datasource; +import org.apache.doris.datasource.scan.FileGroupInfo; + import org.junit.jupiter.api.Assertions; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/HmsGsonCompatReplayTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/HmsGsonCompatReplayTest.java new file mode 100644 index 00000000000000..32593fa1ff0a1c --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/HmsGsonCompatReplayTest.java @@ -0,0 +1,113 @@ +// 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.catalog.DatabaseIf; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalDatabase; +import org.apache.doris.persist.gson.GsonUtils; + +import com.google.common.collect.Maps; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +/** + * Guards the hms (hive) SPI cutover edit-log compatibility (mirrors {@link IcebergGsonCompatReplayTest} / + * {@link PaimonGsonCompatReplayTest}): an FE image / edit log written by a pre-cutover version persisted hms + * catalogs/databases/tables under their legacy class simple names (the GSON {@code "clazz"} discriminator). + * After the cutover those legacy classes are no longer {@code registerSubtype}'d, so on replay the + * {@code registerCompatibleSubtype} mappings in {@link GsonUtils} MUST redirect every legacy tag to the + * generic PluginDriven class — otherwise the FE crashes on startup with a {@code JsonParseException} (tag not + * registered) or a downstream {@code ClassCastException}. + * + *

    Why this matters / what would break it: the three GSON registries (catalog, db, table) must + * migrate atomically. Unlike iceberg/paimon (which have several catalog flavors), hms has a single catalog + * class {@code HMSExternalCatalog} — but the table tag is the load-bearing one: {@code HMSExternalTable} is + * the gateway class for plain-hive AND iceberg-on-HMS AND hudi-on-HMS, and the hive connector declares + * {@code SUPPORTS_MVCC_SNAPSHOT} (snapshots/time-travel/MTMV freshness), so a flipped hms table is a + * {@code PluginDrivenMvccExternalTable}. Therefore {@code HMSExternalTable} MUST replay as the MVCC variant, + * not the base {@code PluginDrivenExternalTable} — replaying as the base would silently downgrade a persisted + * hms table and lose the MVCC behavior on every FE restart.

    + * + *

    Each case round-trips a valid PluginDriven object through GSON, rewrites only the {@code "clazz"} + * discriminator to the legacy tag (faithfully reproducing old-image bytes without depending on the legacy + * HMSExternal* classes), then deserializes and asserts the resolved runtime class.

    + */ +public class HmsGsonCompatReplayTest { + + private static String swapClazz(String json, String currentTag, String legacyTag) { + String needle = "\"clazz\":\"" + currentTag + "\""; + // Sanity: the polymorphic serialization must emit the discriminator we are about to rewrite. + Assertions.assertTrue(json.contains(needle), + "expected discriminator " + needle + " in serialized json: " + json); + return json.replace(needle, "\"clazz\":\"" + legacyTag + "\""); + } + + @Test + public void testLegacyHmsCatalogTagReplaysAsPluginDriven() { + Map props = Maps.newHashMap(); + props.put("type", "hms"); + // 6-arg ctor sets logType=PLUGIN and a non-null catalogProperty, so gsonPostProcess replays cleanly. + PluginDrivenExternalCatalog catalog = + new PluginDrivenExternalCatalog(1L, "hms_ctl", "", props, "c", null); + String baseJson = GsonUtils.GSON.toJson(catalog, CatalogIf.class); + + String json = swapClazz(baseJson, "PluginDrivenExternalCatalog", "HMSExternalCatalog"); + // MUTATION: removing the registerCompatibleSubtype for HMSExternalCatalog throws + // "cannot deserialize ... subtype named HMSExternalCatalog" here; a wrong target class fails instanceof. + CatalogIf restored = GsonUtils.GSON.fromJson(json, CatalogIf.class); + Assertions.assertTrue(restored instanceof PluginDrivenExternalCatalog, + "legacy edit-log tag 'HMSExternalCatalog' must replay as PluginDrivenExternalCatalog " + + "(no crash/ClassCastException)"); + } + + @Test + public void testLegacyHmsDatabaseTagReplaysAsPluginDriven() { + PluginDrivenExternalDatabase db = new PluginDrivenExternalDatabase(); + db.id = 2L; + db.name = "hms_db"; + String baseJson = GsonUtils.GSON.toJson(db, DatabaseIf.class); + + String json = swapClazz(baseJson, "PluginDrivenExternalDatabase", "HMSExternalDatabase"); + // MUTATION: dropping the db registerCompatibleSubtype makes this throw on deserialize. + DatabaseIf restored = GsonUtils.GSON.fromJson(json, DatabaseIf.class); + Assertions.assertTrue(restored instanceof PluginDrivenExternalDatabase, + "legacy 'HMSExternalDatabase' tag must replay as PluginDrivenExternalDatabase"); + } + + @Test + public void testLegacyHmsTableTagReplaysAsMvccPluginDriven() { + PluginDrivenMvccExternalTable table = new PluginDrivenMvccExternalTable(); + table.id = 3L; + table.name = "hms_tbl"; + table.dbName = "hms_db"; + String baseJson = GsonUtils.GSON.toJson(table, TableIf.class); + + String json = swapClazz(baseJson, "PluginDrivenMvccExternalTable", "HMSExternalTable"); + TableIf restored = GsonUtils.GSON.fromJson(json, TableIf.class); + // hms tables must replay as the MVCC variant. instanceof would also pass for a subclass, so assert the + // EXACT class to catch a mistaken mapping to the base PluginDrivenExternalTable. + Assertions.assertSame(PluginDrivenMvccExternalTable.class, restored.getClass(), + "legacy 'HMSExternalTable' tag must replay as PluginDrivenMvccExternalTable (the hive connector" + + " is MVCC-capable), not the base PluginDrivenExternalTable"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/IcebergGsonCompatReplayTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/IcebergGsonCompatReplayTest.java new file mode 100644 index 00000000000000..22d4c08f664c64 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/IcebergGsonCompatReplayTest.java @@ -0,0 +1,125 @@ +// 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.catalog.DatabaseIf; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalDatabase; +import org.apache.doris.persist.gson.GsonUtils; + +import com.google.common.collect.Maps; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +/** + * Guards the iceberg SPI cutover edit-log compatibility (mirrors {@link PaimonGsonCompatReplayTest}): an + * FE image / edit log written by a pre-cutover version persisted iceberg catalogs/databases/tables under + * their legacy class simple names (the GSON {@code "clazz"} discriminator). After the cutover those legacy + * classes are no longer {@code registerSubtype}'d, so on replay the {@code registerCompatibleSubtype} + * mappings in {@link GsonUtils} MUST redirect every legacy tag to the generic PluginDriven class — otherwise + * the FE crashes on startup with a {@code JsonParseException} (tag not registered) or a downstream + * {@code ClassCastException}. + * + *

    Why this matters / what would break it: the three GSON registries (catalog, db, table) must + * migrate atomically. Iceberg has EIGHT catalog flavors persisted under distinct legacy tags; leaving any + * one unmapped fails replay of an image from a cluster that had that flavor. The table tag is special: + * iceberg exposes snapshots/time-travel (declares {@code SUPPORTS_MVCC_SNAPSHOT}), so a flipped iceberg + * table is a {@code PluginDrivenMvccExternalTable}; therefore {@code IcebergExternalTable} MUST replay as + * the MVCC variant, not the base {@code PluginDrivenExternalTable} — replaying as the base would silently + * downgrade a persisted iceberg table and lose the MVCC behavior on every FE restart.

    + * + *

    Each case round-trips a valid PluginDriven object through GSON, rewrites only the {@code "clazz"} + * discriminator to the legacy tag (faithfully reproducing old-image bytes without depending on the legacy + * Iceberg* classes), then deserializes and asserts the resolved runtime class.

    + */ +public class IcebergGsonCompatReplayTest { + + private static String swapClazz(String json, String currentTag, String legacyTag) { + String needle = "\"clazz\":\"" + currentTag + "\""; + // Sanity: the polymorphic serialization must emit the discriminator we are about to rewrite. + Assertions.assertTrue(json.contains(needle), + "expected discriminator " + needle + " in serialized json: " + json); + return json.replace(needle, "\"clazz\":\"" + legacyTag + "\""); + } + + @Test + public void testLegacyIcebergCatalogTagsReplayAsPluginDriven() { + Map props = Maps.newHashMap(); + props.put("type", "iceberg"); + // 6-arg ctor sets logType=PLUGIN and a non-null catalogProperty, so gsonPostProcess replays cleanly. + PluginDrivenExternalCatalog catalog = + new PluginDrivenExternalCatalog(1L, "ice_ctl", "", props, "c", null); + String baseJson = GsonUtils.GSON.toJson(catalog, CatalogIf.class); + + // All 8 iceberg catalog flavors persisted by a pre-cutover FE. + String[] legacyTags = { + "IcebergExternalCatalog", + "IcebergHMSExternalCatalog", + "IcebergGlueExternalCatalog", + "IcebergRestExternalCatalog", + "IcebergDLFExternalCatalog", + "IcebergHadoopExternalCatalog", + "IcebergJdbcExternalCatalog", + "IcebergS3TablesExternalCatalog", + }; + for (String tag : legacyTags) { + String json = swapClazz(baseJson, "PluginDrivenExternalCatalog", tag); + // MUTATION: removing the registerCompatibleSubtype for this flavor throws + // "cannot deserialize ... subtype named " here; a wrong target class fails instanceof. + CatalogIf restored = GsonUtils.GSON.fromJson(json, CatalogIf.class); + Assertions.assertTrue(restored instanceof PluginDrivenExternalCatalog, + "legacy edit-log tag '" + tag + + "' must replay as PluginDrivenExternalCatalog (no crash/ClassCastException)"); + } + } + + @Test + public void testLegacyIcebergDatabaseTagReplaysAsPluginDriven() { + PluginDrivenExternalDatabase db = new PluginDrivenExternalDatabase(); + db.id = 2L; + db.name = "ice_db"; + String baseJson = GsonUtils.GSON.toJson(db, DatabaseIf.class); + + String json = swapClazz(baseJson, "PluginDrivenExternalDatabase", "IcebergExternalDatabase"); + // MUTATION: dropping the db registerCompatibleSubtype makes this throw on deserialize. + DatabaseIf restored = GsonUtils.GSON.fromJson(json, DatabaseIf.class); + Assertions.assertTrue(restored instanceof PluginDrivenExternalDatabase, + "legacy 'IcebergExternalDatabase' tag must replay as PluginDrivenExternalDatabase"); + } + + @Test + public void testLegacyIcebergTableTagReplaysAsMvccPluginDriven() { + PluginDrivenMvccExternalTable table = new PluginDrivenMvccExternalTable(); + table.id = 3L; + table.name = "ice_tbl"; + table.dbName = "ice_db"; + String baseJson = GsonUtils.GSON.toJson(table, TableIf.class); + + String json = swapClazz(baseJson, "PluginDrivenMvccExternalTable", "IcebergExternalTable"); + TableIf restored = GsonUtils.GSON.fromJson(json, TableIf.class); + // iceberg tables must replay as the MVCC variant. instanceof would also pass for a subclass, so + // assert the EXACT class to catch a mistaken mapping to the base PluginDrivenExternalTable. + Assertions.assertSame(PluginDrivenMvccExternalTable.class, restored.getClass(), + "legacy 'IcebergExternalTable' tag must replay as PluginDrivenMvccExternalTable (iceberg is" + + " MVCC-capable), not the base PluginDrivenExternalTable"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/PaimonGsonCompatReplayTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/PaimonGsonCompatReplayTest.java new file mode 100644 index 00000000000000..a3be138d893a2d --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/PaimonGsonCompatReplayTest.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.datasource; + +import org.apache.doris.catalog.DatabaseIf; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalDatabase; +import org.apache.doris.persist.gson.GsonUtils; + +import com.google.common.collect.Maps; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +/** + * Guards the P5 paimon SPI cutover edit-log compatibility: an FE image / edit log written by a + * pre-cutover version persisted paimon catalogs/databases/tables under their legacy class simple + * names (the GSON "clazz" discriminator). After the cutover those legacy classes are no longer + * {@code registerSubtype}'d, so on replay the {@code registerCompatibleSubtype} mappings in + * {@link GsonUtils} MUST redirect every legacy tag to the generic PluginDriven class — otherwise + * the FE crashes on startup with a {@code JsonParseException} (tag not registered) or a downstream + * {@code ClassCastException}. + * + *

    Why this matters / what would break it: the three GSON registries (catalog, db, table) + * must migrate atomically. If any one of the 7 legacy tags is left unmapped, replaying an image + * from a cluster that had a paimon catalog would fail. The table tag is special (D-042): paimon + * supports MVCC/MTMV/time-travel, so {@code PaimonExternalTable} must replay as the MVCC variant, + * not the base {@code PluginDrivenExternalTable} — replaying as the base would silently downgrade a + * persisted paimon table and lose the MVCC behavior on every FE restart.

    + * + *

    Each case round-trips a valid PluginDriven object through GSON, rewrites only the "clazz" + * discriminator to the legacy tag (faithfully reproducing old-image bytes without depending on the + * soon-to-be-deleted Paimon* classes), then deserializes and asserts the resolved runtime class.

    + */ +public class PaimonGsonCompatReplayTest { + + private static String swapClazz(String json, String currentTag, String legacyTag) { + String needle = "\"clazz\":\"" + currentTag + "\""; + // Sanity: the polymorphic serialization must emit the discriminator we are about to rewrite. + Assertions.assertTrue(json.contains(needle), + "expected discriminator " + needle + " in serialized json: " + json); + return json.replace(needle, "\"clazz\":\"" + legacyTag + "\""); + } + + @Test + public void testLegacyPaimonCatalogTagsReplayAsPluginDriven() { + Map props = Maps.newHashMap(); + props.put("type", "paimon"); + // 6-arg ctor sets logType=PLUGIN and a non-null catalogProperty, so gsonPostProcess replays + // cleanly (the legacy-logType backfill branch is skipped and setDefaultPropsIfMissing has a + // catalogProperty to write into). + PluginDrivenExternalCatalog catalog = + new PluginDrivenExternalCatalog(1L, "pmn_ctl", "", props, "c", null); + String baseJson = GsonUtils.GSON.toJson(catalog, CatalogIf.class); + + // All 5 paimon catalog flavors persisted by a pre-cutover FE. + String[] legacyTags = { + "PaimonExternalCatalog", + "PaimonHMSExternalCatalog", + "PaimonFileExternalCatalog", + "PaimonRestExternalCatalog", + "PaimonDLFExternalCatalog", + }; + for (String tag : legacyTags) { + String json = swapClazz(baseJson, "PluginDrivenExternalCatalog", tag); + // MUTATION: removing the registerCompatibleSubtype for this flavor throws + // "cannot deserialize ... subtype named " here; a wrong target class fails instanceof. + CatalogIf restored = GsonUtils.GSON.fromJson(json, CatalogIf.class); + Assertions.assertTrue(restored instanceof PluginDrivenExternalCatalog, + "legacy edit-log tag '" + tag + + "' must replay as PluginDrivenExternalCatalog (no crash/ClassCastException)"); + } + } + + @Test + public void testLegacyPaimonDatabaseTagReplaysAsPluginDriven() { + PluginDrivenExternalDatabase db = new PluginDrivenExternalDatabase(); + db.id = 2L; + db.name = "pmn_db"; + String baseJson = GsonUtils.GSON.toJson(db, DatabaseIf.class); + + String json = swapClazz(baseJson, "PluginDrivenExternalDatabase", "PaimonExternalDatabase"); + // MUTATION: dropping the db registerCompatibleSubtype makes this throw on deserialize. + DatabaseIf restored = GsonUtils.GSON.fromJson(json, DatabaseIf.class); + Assertions.assertTrue(restored instanceof PluginDrivenExternalDatabase, + "legacy 'PaimonExternalDatabase' tag must replay as PluginDrivenExternalDatabase"); + } + + @Test + public void testLegacyPaimonTableTagReplaysAsMvccPluginDriven() { + PluginDrivenMvccExternalTable table = new PluginDrivenMvccExternalTable(); + table.id = 3L; + table.name = "pmn_tbl"; + table.dbName = "pmn_db"; + String baseJson = GsonUtils.GSON.toJson(table, TableIf.class); + + String json = swapClazz(baseJson, "PluginDrivenMvccExternalTable", "PaimonExternalTable"); + TableIf restored = GsonUtils.GSON.fromJson(json, TableIf.class); + // D-042: paimon tables must replay as the MVCC variant. instanceof would also pass for a + // subclass, so assert the EXACT class to catch a mistaken mapping to the base table. + Assertions.assertSame(PluginDrivenMvccExternalTable.class, restored.getClass(), + "legacy 'PaimonExternalTable' tag must replay as PluginDrivenMvccExternalTable (D-042)," + + " not the base PluginDrivenExternalTable"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/PathVisibleTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/PathVisibleTest.java deleted file mode 100644 index 8282aeabb1cbf5..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/PathVisibleTest.java +++ /dev/null @@ -1,49 +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.datasource; - -import org.apache.doris.datasource.hive.HiveExternalMetaCache.FileCacheValue; - -import org.junit.Assert; -import org.junit.Test; - -public class PathVisibleTest { - @Test - public void shouldReturnFalseWhenPathIsNull() { - Assert.assertFalse(FileCacheValue.isFileVisible(null)); - Assert.assertFalse(FileCacheValue.isFileVisible("s3://visible/.hidden/path")); - Assert.assertFalse(FileCacheValue.isFileVisible("/visible/.hidden/path")); - Assert.assertFalse(FileCacheValue.isFileVisible("hdfs://visible/path/.file")); - Assert.assertFalse(FileCacheValue.isFileVisible("/visible/path/_temporary_xx")); - Assert.assertFalse(FileCacheValue.isFileVisible("/visible/path/_impala_insert_staging")); - - Assert.assertFalse(FileCacheValue.isFileVisible("/visible//.hidden/path")); - Assert.assertFalse(FileCacheValue.isFileVisible("s3://visible/.hidden/path")); - Assert.assertFalse(FileCacheValue.isFileVisible("///visible/path/.file")); - Assert.assertFalse(FileCacheValue.isFileVisible("/visible/path///_temporary_xx")); - Assert.assertFalse(FileCacheValue.isFileVisible("hdfs://visible//path/_impala_insert_staging")); - Assert.assertFalse(FileCacheValue.isFileVisible( - "hdfs://hacluster/user/hive/warehouse/db1.db/tbl1/_spark_metadata/")); - - Assert.assertTrue(FileCacheValue.isFileVisible("s3://visible/path")); - Assert.assertTrue(FileCacheValue.isFileVisible("path")); - Assert.assertTrue(FileCacheValue.isFileVisible("hdfs://visible/path./1.txt")); - Assert.assertTrue(FileCacheValue.isFileVisible("/1.txt")); - Assert.assertTrue(FileCacheValue.isFileVisible("hdfs://vis_ible_/pa.th./1_.txt__")); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/PluginDrivenExternalTableEngineTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/PluginDrivenExternalTableEngineTest.java deleted file mode 100644 index 2c3173af8c0e4e..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/PluginDrivenExternalTableEngineTest.java +++ /dev/null @@ -1,204 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -package org.apache.doris.datasource; - -import org.apache.doris.catalog.TableIf.TableType; -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.ConnectorTableSchema; -import org.apache.doris.connector.api.ConnectorType; -import org.apache.doris.connector.api.handle.ConnectorTableHandle; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -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.). - */ -public class PluginDrivenExternalTableEngineTest { - - @Test - public void testJdbcCatalogReturnsJdbcEngineName() { - PluginDrivenExternalTable table = createTableWithCatalogType("jdbc"); - Assertions.assertEquals("jdbc", table.getEngine(), - "JDBC catalog tables should report engine='jdbc'"); - } - - @Test - public void testEsCatalogReturnsEsEngineName() { - PluginDrivenExternalTable table = createTableWithCatalogType("es"); - Assertions.assertEquals("es", table.getEngine(), - "ES catalog tables should report engine='es'"); - } - - @Test - public void testUnknownCatalogReturnsPluginEngineName() { - PluginDrivenExternalTable table = createTableWithCatalogType("custom_type"); - Assertions.assertEquals("Plugin", table.getEngine(), - "Unknown catalog types should report engine='Plugin'"); - } - - @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"); - } - - @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"); - } - - @Test - public void testUnknownCatalogReturnsPluginEngineTableTypeName() { - PluginDrivenExternalTable table = createTableWithCatalogType("custom_type"); - Assertions.assertEquals(TableType.PLUGIN_EXTERNAL_TABLE.name(), - table.getEngineTableTypeName(), - "Unknown catalog types should report PLUGIN_EXTERNAL_TABLE type name"); - } - - @Test - public void testTableTypeIsAlwaysPluginExternalTable() { - PluginDrivenExternalTable jdbcTable = createTableWithCatalogType("jdbc"); - PluginDrivenExternalTable esTable = createTableWithCatalogType("es"); - Assertions.assertEquals(TableType.PLUGIN_EXTERNAL_TABLE, jdbcTable.getType(), - "Internal table type should always be PLUGIN_EXTERNAL_TABLE"); - Assertions.assertEquals(TableType.PLUGIN_EXTERNAL_TABLE, esTable.getType(), - "Internal table type should always be PLUGIN_EXTERNAL_TABLE"); - } - - @Test - public void testInitSchemaReturnsEmptyWhenTableHandleMissing() { - Connector connector = createMockConnector(false, false); - PluginDrivenExternalTable table = createTableWithCatalogType("jdbc", connector); - - // Return an empty schema result when the connector cannot resolve the table handle. - Assertions.assertFalse(table.initSchema().isPresent(), - "Missing connector table handles should produce an empty schema result"); - } - - @Test - public void testInitSchemaAppliesRemoteColumnNameMapping() { - Connector connector = createMockConnector(true, true); - PluginDrivenExternalTable table = createTableWithCatalogType("jdbc", connector); - - // Verify that plugin-driven schema loading preserves mapped column names from the connector. - Optional schema = table.initSchema(); - Assertions.assertTrue(schema.isPresent(), "Schema should be present when a table handle exists"); - Assertions.assertEquals("mapped_id", schema.get().getSchema().get(0).getName(), - "Mapped remote column names should be reflected in Doris schema metadata"); - } - - // -------- Helpers -------- - - private PluginDrivenExternalTable createTableWithCatalogType(String catalogType) { - return createTableWithCatalogType(catalogType, createMockConnector(true, false)); - } - - private PluginDrivenExternalTable createTableWithCatalogType(String catalogType, Connector connector) { - TestablePluginCatalog catalog = new TestablePluginCatalog(catalogType, connector); - ExternalDatabase db = mockExternalDatabase(); - Mockito.when(db.getFullName()).thenReturn("test_db"); - Mockito.when(db.getRemoteName()).thenReturn("test_db"); - - PluginDrivenExternalTable table = new PluginDrivenExternalTable( - 1L, "test_table", "test_table", catalog, db); - return table; - } - - private Connector createMockConnector(boolean tableExists, boolean renameColumn) { - Connector connector = Mockito.mock(Connector.class); - ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); - ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); - ConnectorTableSchema schema = new ConnectorTableSchema("test_table", - Collections.singletonList(new ConnectorColumn( - "id", ConnectorType.of("INT", -1, -1), "", true, null, true)), - null, Collections.emptyMap()); - Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); - Mockito.when(metadata.getTableHandle(Mockito.any(ConnectorSession.class), Mockito.anyString(), Mockito.anyString())) - .thenReturn(tableExists ? Optional.of(handle) : Optional.empty()); - Mockito.when(metadata.getTableSchema(Mockito.any(ConnectorSession.class), Mockito.eq(handle))).thenReturn(schema); - Mockito.when(metadata.fromRemoteColumnName(Mockito.any(ConnectorSession.class), Mockito.anyString(), - Mockito.anyString(), Mockito.anyString())).thenAnswer(invocation -> { - String remoteName = invocation.getArgument(3); - return renameColumn ? "mapped_" + remoteName : remoteName; - }); - return connector; - } - - @SuppressWarnings("unchecked") - private ExternalDatabase mockExternalDatabase() { - return Mockito.mock(ExternalDatabase.class); - } - - /** - * Minimal testable PluginDrivenExternalCatalog that returns a configurable type - * without requiring full Doris environment initialization. - */ - private static class TestablePluginCatalog extends PluginDrivenExternalCatalog { - private final String catalogType; - - TestablePluginCatalog(String catalogType, Connector connector) { - super(1L, "test-catalog", null, makeProps(catalogType), "", connector); - this.catalogType = catalogType; - } - - @Override - public String getType() { - return catalogType; - } - - @Override - protected List listDatabaseNames() { - return Collections.emptyList(); - } - - @Override - protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { - return Collections.emptyList(); - } - - @Override - public boolean tableExist(SessionContext ctx, String dbName, String tblName) { - return false; - } - - private static Map makeProps(String type) { - Map props = new HashMap<>(); - props.put("type", type); - return props; - } - } -} 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 new file mode 100644 index 00000000000000..94a0fe54313f69 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/PluginDrivenSysTableTest.java @@ -0,0 +1,475 @@ +// 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.catalog.TableIf.TableType; +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.ConnectorTableSchema; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenSysExternalTable; +import org.apache.doris.datasource.systable.PartitionsSysTable; +import org.apache.doris.datasource.systable.PluginDrivenSysTable; +import org.apache.doris.datasource.systable.SysTable; +import org.apache.doris.datasource.systable.TvfSysTable; + +import com.google.gson.annotations.SerializedName; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Tests for the generic plugin-driven system-table machinery (T18): {@link PluginDrivenSysTable}, + * {@link PluginDrivenSysExternalTable}, and {@link PluginDrivenExternalTable#getSupportedSysTables()}. + * + *

    Why this matters: plugin-driven external tables must expose connector system tables + * (e.g. {@code cat.db.tbl$snapshots}) by REUSING the live fe-core system-table machinery + * ({@code TableIf.findSysTable} + {@code NativeSysTable.createSysExternalTable} + + * {@code SysTableResolver}), delegating the connector-specific bits (which sys tables exist, how to + * obtain a sys handle) to the SPI. The discovery must be GENERIC (driven by the connector SPI, not + * hardcoded per connector) and a system-table query must read the SYSTEM table, not the base table.

    + */ +public class PluginDrivenSysTableTest { + + // ==================== getSupportedSysTables() delegates to the connector SPI ==================== + + @Test + public void testGetSupportedSysTablesDelegatesToConnector() { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); + TestablePluginCatalog catalog = new TestablePluginCatalog("paimon", metadata, session); + ExternalDatabase db = mockDb("REMOTE_DB"); + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.of(baseHandle)); + Mockito.when(metadata.listSupportedSysTables(session, baseHandle)) + .thenReturn(Arrays.asList("snapshots", "binlog")); + + PluginDrivenExternalTable table = bareTable(catalog, db, "REMOTE_TBL"); + Map sysTables = table.getSupportedSysTables(); + + // WHY: discovery must come from the connector SPI (listSupportedSysTables), keyed by the + // bare name so the inherited findSysTable exact-match resolves. MUTATION: returning + // Collections.emptyMap() (ignoring the SPI) makes both keys absent -> red. + Assertions.assertEquals(2, sysTables.size()); + Assertions.assertTrue(sysTables.containsKey("snapshots"), "must expose 'snapshots' from the SPI"); + Assertions.assertTrue(sysTables.containsKey("binlog"), "must expose 'binlog' from the SPI"); + Assertions.assertTrue(sysTables.get("snapshots") instanceof PluginDrivenSysTable, + "each value must be a generic PluginDrivenSysTable, not a connector-specific subtype"); + Mockito.verify(metadata).listSupportedSysTables(session, baseHandle); + } + + @Test + public void testGetSupportedSysTablesMapsTvfKindToPartitionsSysTable() { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); + TestablePluginCatalog catalog = new TestablePluginCatalog("hms", metadata, session); + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.of(baseHandle)); + Mockito.when(metadata.listSupportedSysTables(session, baseHandle)) + .thenReturn(Arrays.asList("partitions", "snapshots")); + // The connector declares only "partitions" as partition_values-TVF-backed (hive). "snapshots" + // defaults to native. (mockito returns false for the unstubbed boolean call.) + Mockito.when(metadata.isPartitionValuesSysTable(session, baseHandle, "partitions")) + .thenReturn(true); + + PluginDrivenExternalTable table = bareTable(catalog, mockDb("REMOTE_DB"), "REMOTE_TBL"); + Map sysTables = table.getSupportedSysTables(); + + // WHY: a TVF-declared name must become the TVF-backed PartitionsSysTable (routed to the + // partition_values TVF in SysTableResolver), NOT the generic native PluginDrivenSysTable — else + // t$partitions would drive a native scan the hive connector has no BE reader for. MUTATION: + // wrapping every name as PluginDrivenSysTable (ignoring the kind) makes "partitions" native -> red. + SysTable partitions = sysTables.get("partitions"); + Assertions.assertTrue(partitions instanceof PartitionsSysTable, + "a partition_values-TVF-declared name must map to the TVF-backed PartitionsSysTable"); + Assertions.assertTrue(partitions instanceof TvfSysTable, "PartitionsSysTable is a TvfSysTable"); + Assertions.assertFalse(partitions.useNativeTablePath(), + "the partitions sys table must route through the TVF path, not the native scan path"); + // A name the connector did NOT declare TVF stays native. + Assertions.assertTrue(sysTables.get("snapshots") instanceof PluginDrivenSysTable, + "a name not declared TVF-backed stays a generic native PluginDrivenSysTable"); + // findSysTable resolves the TVF-backed entry by its exact suffix. + Optional hit = table.findSysTable("REMOTE_TBL$partitions"); + Assertions.assertTrue(hit.isPresent() && !hit.get().useNativeTablePath(), + "t$partitions must resolve to the TVF-backed sys table"); + } + + @Test + public void testGetSupportedSysTablesEmptyWhenNoBaseHandle() { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + TestablePluginCatalog catalog = new TestablePluginCatalog("paimon", metadata, session); + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.empty()); + + PluginDrivenExternalTable table = bareTable(catalog, mockDb("REMOTE_DB"), "REMOTE_TBL"); + Assertions.assertTrue(table.getSupportedSysTables().isEmpty(), + "with no base handle there is nothing to query for sys tables"); + Mockito.verify(metadata, Mockito.never()) + .listSupportedSysTables(Mockito.any(), Mockito.any()); + } + + // ==================== findSysTable (inherited TableIf default) ==================== + + @Test + public void testFindSysTableResolvesBySuffix() { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); + TestablePluginCatalog catalog = new TestablePluginCatalog("paimon", metadata, session); + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.of(baseHandle)); + Mockito.when(metadata.listSupportedSysTables(session, baseHandle)) + .thenReturn(Arrays.asList("snapshots", "binlog")); + + PluginDrivenExternalTable table = bareTable(catalog, mockDb("REMOTE_DB"), "REMOTE_TBL"); + + Optional hit = table.findSysTable("t$snapshots"); + Assertions.assertTrue(hit.isPresent(), "t$snapshots must resolve to the 'snapshots' SysTable"); + Assertions.assertEquals("snapshots", hit.get().getSysTableName()); + // WHY: findSysTable does an exact, case-sensitive map.get of the suffix; an unknown suffix + // must miss. MUTATION: returning the whole map regardless of suffix would make 'nope' present. + Assertions.assertFalse(table.findSysTable("t$nope").isPresent(), + "an unknown system-table suffix must not resolve"); + } + + // ==================== createSysExternalTable: type + name + sibling delegation ==================== + + @Test + public void testCreateSysExternalTableReportsPluginTypeAndName() { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); + TestablePluginCatalog catalog = new TestablePluginCatalog("paimon", metadata, session); + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.of(baseHandle)); + Mockito.when(metadata.listSupportedSysTables(session, baseHandle)) + .thenReturn(Collections.singletonList("snapshots")); + + PluginDrivenExternalTable base = bareTable(catalog, mockDb("REMOTE_DB"), "REMOTE_TBL"); + PluginDrivenSysTable sysType = new PluginDrivenSysTable("snapshots"); + ExternalTable sysTable = sysType.createSysExternalTable(base); + + Assertions.assertTrue(sysTable instanceof PluginDrivenSysExternalTable); + // WHY (explicit guard "勿报 PAIMON_EXTERNAL_TABLE"): the generic sys table must inherit the + // PLUGIN_EXTERNAL_TABLE type and MUST NOT report any connector-specific type. MUTATION: a ctor + // that passes (e.g.) PAIMON_EXTERNAL_TABLE to super makes getType() != PLUGIN_EXTERNAL_TABLE -> red. + Assertions.assertEquals(TableType.PLUGIN_EXTERNAL_TABLE, sysTable.getType(), + "sys table must report PLUGIN_EXTERNAL_TABLE, not a connector-specific type"); + Assertions.assertEquals("tbl$snapshots", sysTable.getName(), + "sys table name must be base name + '$' + sysName (planner-visible name)"); + Assertions.assertEquals("REMOTE_TBL$snapshots", sysTable.getRemoteName(), + "sys table remote name must be base remote name + '$' + sysName"); + // getSupportedSysTables delegates to the source so DESCRIBE/SHOW on a sys table lists siblings. + Assertions.assertTrue(sysTable.getSupportedSysTables().containsKey("snapshots"), + "sys table getSupportedSysTables must delegate to the source table (sibling listing)"); + + Assertions.assertThrows(IllegalArgumentException.class, + () -> sysType.createSysExternalTable(Mockito.mock(ExternalTable.class)), + "createSysExternalTable must reject non-PluginDrivenExternalTable sources"); + } + + // ==================== handle threading: sys query reads the SYS table, not the base =========== + + @Test + public void testSysTableThreadsSysHandleNotBaseHandle() { + // Mock getTableHandle -> a BASE handle; getSysTableHandle -> a DISTINCT sys handle. + // Driving initSchema on the sys table must read schema via the SYS handle, proving the sys + // query reads the system table, not the base. This is the whole point of T18. + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); + ConnectorTableHandle sysHandle = Mockito.mock(ConnectorTableHandle.class); + TestablePluginCatalog catalog = new TestablePluginCatalog("paimon", metadata, session); + ExternalDatabase db = mockDb("REMOTE_DB"); + + // Base handle resolved from the SOURCE remote name (not the "$"-suffixed sys remote name). + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.of(baseHandle)); + Mockito.when(metadata.getSysTableHandle(session, baseHandle, "snapshots")) + .thenReturn(Optional.of(sysHandle)); + ConnectorTableSchema sysSchema = new ConnectorTableSchema( + "REMOTE_TBL$snapshots", + Collections.singletonList(new ConnectorColumn("snapshot_id", ConnectorType.of("BIGINT"), "", true, null)), + "paimon", + Collections.emptyMap()); + Mockito.when(metadata.getTableSchema(session, sysHandle)).thenReturn(sysSchema); + Mockito.when(metadata.fromRemoteColumnName(Mockito.eq(session), Mockito.anyString(), + Mockito.anyString(), Mockito.anyString())) + .thenAnswer(inv -> inv.getArgument(3)); + + PluginDrivenExternalTable base = bareTable(catalog, db, "REMOTE_TBL"); + PluginDrivenSysExternalTable sysTable = new PluginDrivenSysExternalTable(base, "snapshots") { + @Override + protected synchronized void makeSureInitialized() { + // no-op: skip Env-backed catalog/db init + } + }; + + Optional result = sysTable.initSchema(); + + Assertions.assertTrue(result.isPresent()); + // WHY: the sys handle (NOT the base handle) must be what flows into getTableSchema, so a sys + // query reads the system table's schema. MUTATION: an override that returned the base handle + // (skipping getSysTableHandle) would call getTableSchema(session, baseHandle) -> these verify + // assertions go red. + Mockito.verify(metadata).getSysTableHandle(session, baseHandle, "snapshots"); + Mockito.verify(metadata).getTableSchema(session, sysHandle); + Mockito.verify(metadata, Mockito.never()).getTableSchema(session, baseHandle); + } + + @Test + public void testSysTableEmptyWhenBaseHandleMissing() { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + TestablePluginCatalog catalog = new TestablePluginCatalog("paimon", metadata, session); + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.empty()); + + PluginDrivenExternalTable base = bareTable(catalog, mockDb("REMOTE_DB"), "REMOTE_TBL"); + PluginDrivenSysExternalTable sysTable = new PluginDrivenSysExternalTable(base, "snapshots") { + @Override + protected synchronized void makeSureInitialized() { + // no-op + } + }; + + Assertions.assertFalse(sysTable.initSchema().isPresent(), + "no base handle -> no sys handle -> empty schema (no spurious getSysTableHandle)"); + Mockito.verify(metadata, Mockito.never()) + .getSysTableHandle(Mockito.any(), Mockito.any(), Mockito.anyString()); + } + + // ==================== iceberg sys table: user-visible type/engine parity (T07 gap-fill) ========= + + @Test + public void sysExternalTableReportsBaseTableMysqlTypeMatchingLegacyIceberg() { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + TestablePluginCatalog catalog = new TestablePluginCatalog("iceberg", metadata, session); + PluginDrivenExternalTable base = bareTable(catalog, mockDb("REMOTE_DB"), "REMOTE_TBL"); + PluginDrivenSysExternalTable sys = new PluginDrivenSysExternalTable(base, "snapshots"); + + // WHY: information_schema.tables.TABLE_TYPE for an iceberg sys table (e.g. tbl$snapshots) must read + // "BASE TABLE", byte-identical to a legacy ICEBERG_EXTERNAL_TABLE. The sys table inherits + // PLUGIN_EXTERNAL_TABLE and routes getMysqlType -> TableType.toMysqlType; the same-test pin of + // ICEBERG_EXTERNAL_TABLE.toMysqlType proves new == legacy with no Env. MUTATION: deleting the + // PLUGIN_EXTERNAL_TABLE case in TableIf.TableType.toMysqlType -> sys getMysqlType returns null -> red. + Assertions.assertEquals("BASE TABLE", sys.getMysqlType()); + Assertions.assertEquals("BASE TABLE", TableType.ICEBERG_EXTERNAL_TABLE.toMysqlType(), + "the new plugin sys path must match the legacy iceberg TABLE_TYPE"); + } + + @Test + public void sysExternalTableReportsIcebergEngineAndEngineTableTypeName() { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + TestablePluginCatalog catalog = new TestablePluginCatalog("iceberg", metadata, session); + PluginDrivenExternalTable base = bareTable(catalog, mockDb("REMOTE_DB"), "REMOTE_TBL"); + PluginDrivenSysExternalTable sys = new PluginDrivenSysExternalTable(base, "snapshots"); + + // 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. + Assertions.assertAll( + () -> Assertions.assertEquals("iceberg", sys.getEngine()), + () -> Assertions.assertEquals("ICEBERG_EXTERNAL_TABLE", sys.getEngineTableTypeName())); + } + + // ==================== generic not-found path (no legacy position_deletes marker) ================ + + @Test + public void positionDeletesAbsentWhenConnectorDoesNotListIt() { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); + TestablePluginCatalog catalog = new TestablePluginCatalog("iceberg", metadata, session); + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.of(baseHandle)); + // An iceberg-style supported list (MetadataTableType.values() lower-cased) but WITHOUT + // position_deletes — exactly what IcebergConnectorMetadata.listSupportedSysTables returns. + Mockito.when(metadata.listSupportedSysTables(session, baseHandle)) + .thenReturn(Arrays.asList("snapshots", "history", "files", "manifests", "partitions")); + + PluginDrivenExternalTable table = bareTable(catalog, mockDb("REMOTE_DB"), "REMOTE_TBL"); + Map sysTables = table.getSupportedSysTables(); + + // Positive control: a listed name resolves, proving the SPI-delegated machinery works (so the + // negatives below are not trivially green because discovery happens to be empty). + Assertions.assertTrue(sysTables.containsKey("snapshots")); + Assertions.assertTrue(table.findSysTable("t$snapshots").isPresent()); + // WHY: position_deletes is the one metadata table iceberg does not expose; legacy modeled it as a + // special UNSUPPORTED_POSITION_DELETES_TABLE that threw "not supported yet". The generic plugin path + // has no such marker — an unlisted sys name simply does not resolve via the ordinary not-found path. + // MUTATION: injecting "position_deletes" into getSupportedSysTables regardless of the SPI list -> + // containsKey true / findSysTable present -> red. + Assertions.assertFalse(sysTables.containsKey("position_deletes"), + "position_deletes must not be exposed when the connector does not list it"); + Assertions.assertFalse(table.findSysTable("t$position_deletes").isPresent(), + "t$position_deletes must take the generic not-found path, not a legacy 'unsupported' marker"); + } + + // ==================== sys tables are transient: not registered, not edit-log serialized ======== + + @Test + public void sysExternalTableIsTransientNeitherRegisteredNorSerialized() { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); + TestablePluginCatalog catalog = new TestablePluginCatalog("iceberg", metadata, session); + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.of(baseHandle)); + Mockito.when(metadata.listSupportedSysTables(session, baseHandle)) + .thenReturn(Arrays.asList("snapshots", "files")); + + PluginDrivenExternalTable base = bareTable(catalog, mockDb("REMOTE_DB"), "REMOTE_TBL"); + PluginDrivenSysTable sysType = new PluginDrivenSysTable("snapshots"); + PluginDrivenSysExternalTable sys = (PluginDrivenSysExternalTable) sysType.createSysExternalTable(base); + + // The planner-visible sys NAME carries the "$" suffix (so a query against tbl$snapshots routes here)... + Assertions.assertEquals("REMOTE_TBL$snapshots", sys.getRemoteName()); + // ...but the discovery map (the basis for SHOW TABLES sys-listing) is keyed by BARE names only: a + // "$"-suffixed key must never appear, or a sys table would leak into SHOW TABLES as a real table. + // MUTATION: keying getSupportedSysTables by "$" + name -> a "$"-key appears -> red. + for (String key : base.getSupportedSysTables().keySet()) { + Assertions.assertFalse(key.contains("$"), + "sys discovery keys must be bare names, never '$'-suffixed: " + key); + } + // And the transient sys ExternalTable must never be GSON-serialized into the edit log: none of its + // OWN declared fields may carry @SerializedName (it is rebuilt per query, never persisted/replayed). + // MUTATION: annotating any PluginDrivenSysExternalTable field with @SerializedName -> red. + Field[] declared = PluginDrivenSysExternalTable.class.getDeclaredFields(); + Assertions.assertTrue(declared.length > 0, "guard has teeth: the sys class does declare fields"); + for (Field f : declared) { + Assertions.assertFalse(f.isAnnotationPresent(SerializedName.class), + "transient sys table must not serialize field: " + f.getName()); + } + } + + // ==================== helpers (mirror PluginDrivenExternalTablePartitionTest) ==================== + + /** Table that drives the real getSupportedSysTables()/initSchema(); does not stub the schema cache. */ + private static PluginDrivenExternalTable bareTable(PluginDrivenExternalCatalog catalog, + ExternalDatabase db, String remoteName) { + return new PluginDrivenExternalTable(1L, "tbl", remoteName, catalog, db) { + @Override + protected synchronized void makeSureInitialized() { + // no-op: skip Env-backed catalog/db init + } + }; + } + + @SuppressWarnings("unchecked") + private static ExternalDatabase mockDb(String remoteName) { + ExternalDatabase db = Mockito.mock(ExternalDatabase.class); + Mockito.when(db.getRemoteName()).thenReturn(remoteName); + return db; + } + + /** + * Minimal PluginDrivenExternalCatalog that returns a fixed connector/session without standing up + * the Doris environment (mirrors PluginDrivenExternalTablePartitionTest.TestablePluginCatalog). + */ + private static class TestablePluginCatalog extends PluginDrivenExternalCatalog { + private final Connector connector; + private final ConnectorSession session; + + TestablePluginCatalog(String catalogType, ConnectorMetadata metadata, ConnectorSession session) { + this(catalogType, mockConnector(metadata, session), session); + } + + private TestablePluginCatalog(String catalogType, Connector connector, ConnectorSession session) { + super(1L, "test-catalog", null, makeProps(catalogType), "", connector); + this.connector = connector; + this.session = session; + } + + private static Connector mockConnector(ConnectorMetadata metadata, ConnectorSession session) { + Connector c = Mockito.mock(Connector.class); + Mockito.when(c.getMetadata(session)).thenReturn(metadata); + return c; + } + + @Override + public Connector getConnector() { + return connector; + } + + @Override + public ConnectorSession buildConnectorSession() { + return session; + } + + @Override + public ConnectorSession buildCrossStatementSession() { + return buildConnectorSession(); + } + + @Override + protected List listDatabaseNames() { + return Collections.emptyList(); + } + + @Override + protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { + return Collections.emptyList(); + } + + @Override + public boolean tableExist(SessionContext ctx, String dbName, String tblName) { + return false; + } + + private static Map makeProps(String type) { + Map props = new HashMap<>(); + props.put("type", type); + return props; + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/TestHMSCachedClient.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/TestHMSCachedClient.java deleted file mode 100644 index 2c0253a1f38421..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/TestHMSCachedClient.java +++ /dev/null @@ -1,337 +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.datasource; - -import org.apache.doris.catalog.info.TableNameInfo; -import org.apache.doris.datasource.hive.HMSCachedClient; -import org.apache.doris.datasource.hive.HiveDatabaseMetadata; -import org.apache.doris.datasource.hive.HivePartitionStatistics; -import org.apache.doris.datasource.hive.HivePartitionWithStatistics; -import org.apache.doris.datasource.hive.HiveTableMetadata; -import org.apache.doris.datasource.hive.HiveUtil; -import org.apache.doris.datasource.hive.event.MetastoreNotificationFetchException; - -import com.google.common.collect.ImmutableList; -import org.apache.hadoop.hive.metastore.IMetaStoreClient; -import org.apache.hadoop.hive.metastore.api.ColumnStatisticsObj; -import org.apache.hadoop.hive.metastore.api.CurrentNotificationEventId; -import org.apache.hadoop.hive.metastore.api.Database; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.NotificationEventResponse; -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.Table; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Function; -import java.util.stream.Collectors; - -public class TestHMSCachedClient implements HMSCachedClient { - - public Map> partitions = new ConcurrentHashMap<>(); - public Map> tables = new HashMap<>(); - public List dbs = new ArrayList<>(); - - @Override - public void close() { - } - - @Override - public Database getDatabase(String dbName) { - for (Database db : this.dbs) { - if (db.getName().equals(dbName)) { - return db; - } - } - throw new RuntimeException("can't found database: " + dbName); - } - - @Override - public List getAllDatabases() { - return null; - } - - @Override - public List getAllTables(String dbName) { - return null; - } - - @Override - public boolean tableExists(String dbName, String tblName) { - List

    tablesList = getTableList(dbName); - for (Table table : tablesList) { - if (table.getTableName().equals(tblName)) { - return true; - } - } - return false; - } - - @Override - public List listPartitionNames(String dbName, String tblName) { - List partitionList = getPartitionList(dbName, tblName); - ArrayList ret = new ArrayList<>(); - for (Partition partition : partitionList) { - StringBuilder names = new StringBuilder(); - List values = partition.getValues(); - for (int i = 0; i < values.size(); i++) { - names.append(values.get(i)); - if (i < values.size() - 1) { - names.append("/"); - } - } - ret.add(names.toString()); - } - return ret; - } - - @Override - public List listPartitions(String dbName, String tblName) { - return getPartitionList(dbName, tblName); - } - - @Override - public List listPartitionNames(String dbName, String tblName, long maxListPartitionNum) { - return listPartitionNames(dbName, tblName); - } - - @Override - public Partition getPartition(String dbName, String tblName, List partitionValues) { - synchronized (this) { - List partitionList = getPartitionList(dbName, tblName); - for (Partition partition : partitionList) { - if (partition.getValues().equals(partitionValues)) { - return partition; - } - } - throw new RuntimeException("can't found partition"); - } - } - - @Override - public List getPartitions(String dbName, String tblName, List partitionNames) { - synchronized (this) { - List partitionList = getPartitionList(dbName, tblName); - ArrayList ret = new ArrayList<>(); - List> partitionValuesList = - partitionNames - .stream() - .map(HiveUtil::toPartitionValues) - .collect(Collectors.toList()); - partitionValuesList.forEach(values -> { - for (Partition partition : partitionList) { - if (partition.getValues().equals(values)) { - ret.add(partition); - break; - } - } - }); - return ret; - } - } - - @Override - public Table getTable(String dbName, String tblName) { - List
    tablesList = getTableList(dbName); - for (Table table : tablesList) { - if (table.getTableName().equals(tblName)) { - return table; - } - } - throw new RuntimeException("can't found table: " + tblName); - } - - @Override - public List getSchema(String dbName, String tblName) { - return null; - } - - @Override - public Map getDefaultColumnValues(String dbName, String tblName) { - return new HashMap<>(); - } - - @Override - public List getTableColumnStatistics(String dbName, String tblName, List columns) { - return null; - } - - @Override - public Map> getPartitionColumnStatistics(String dbName, String tblName, List partNames, List columns) { - return null; - } - - @Override - public CurrentNotificationEventId getCurrentNotificationEventId() { - return null; - } - - @Override - public NotificationEventResponse getNextNotification(long lastEventId, int maxEvents, IMetaStoreClient.NotificationFilter filter) throws MetastoreNotificationFetchException { - return null; - } - - @Override - public long openTxn(String user) { - return 0; - } - - @Override - public void commitTxn(long txnId) { - - } - - @Override - public Map getValidWriteIds(String fullTableName, long currentTransactionId) { - return null; - } - - @Override - public void acquireSharedLock(String queryId, long txnId, String user, TableNameInfo tblName, List partitionNames, long timeoutMs) { - - } - - @Override - public String getCatalogLocation(String catalogName) { - return null; - } - - @Override - public void createDatabase(DatabaseMetadata db) { - dbs.add(HiveUtil.toHiveDatabase((HiveDatabaseMetadata) db)); - tables.put(db.getDbName(), new ArrayList<>()); - } - - @Override - public void dropDatabase(String dbName) { - Database db = getDatabase(dbName); - this.dbs.remove(db); - } - - @Override - public void dropTable(String dbName, String tableName) { - Table table = getTable(dbName, tableName); - this.tables.get(dbName).remove(table); - this.partitions.remove(NameMapping.createForTest(dbName, tableName)); - } - - @Override - public void truncateTable(String dbName, String tblName, List partitions) {} - - @Override - public void createTable(TableMetadata tbl, boolean ignoreIfExists) { - String dbName = tbl.getDbName(); - String tbName = tbl.getTableName(); - if (tableExists(dbName, tbName)) { - throw new RuntimeException("Table '" + tbName + "' has existed in '" + dbName + "'."); - } - - List
    tableList = getTableList(tbl.getDbName()); - tableList.add(HiveUtil.toHiveTable((HiveTableMetadata) tbl)); - partitions.put(NameMapping.createForTest(dbName, tbName), new ArrayList<>()); - } - - @Override - public void updateTableStatistics(String dbName, String tableName, Function update) { - synchronized (this) { - Table originTable = getTable(dbName, tableName); - Map originParams = originTable.getParameters(); - HivePartitionStatistics updatedStats = update.apply(HiveUtil.toHivePartitionStatistics(originParams)); - - Table newTable = originTable.deepCopy(); - Map newParams = - HiveUtil.updateStatisticsParameters(originParams, updatedStats.getCommonStatistics()); - newParams.put("transient_lastDdlTime", String.valueOf(System.currentTimeMillis() / 1000)); - newTable.setParameters(newParams); - List
    tableList = getTableList(dbName); - tableList.remove(originTable); - tableList.add(newTable); - } - } - - @Override - public void updatePartitionStatistics(String dbName, String tableName, String partitionName, Function update) { - - synchronized (this) { - List partitions = getPartitions(dbName, tableName, ImmutableList.of(partitionName)); - if (partitions.size() != 1) { - throw new RuntimeException("Metastore returned multiple partitions for name: " + partitionName); - } - - Partition originPartition = partitions.get(0); - Map originParams = originPartition.getParameters(); - HivePartitionStatistics updatedStats = update.apply(HiveUtil.toHivePartitionStatistics(originParams)); - - Partition modifiedPartition = originPartition.deepCopy(); - Map newParams = - HiveUtil.updateStatisticsParameters(originParams, updatedStats.getCommonStatistics()); - newParams.put("transient_lastDdlTime", String.valueOf(System.currentTimeMillis() / 1000)); - modifiedPartition.setParameters(newParams); - - List partitionList = getPartitionList(dbName, tableName); - partitionList.remove(originPartition); - partitionList.add(modifiedPartition); - } - } - - @Override - public void addPartitions(String dbName, String tableName, List partitions) { - synchronized (this) { - List partitionList = getPartitionList(dbName, tableName); - List hivePartitions = partitions.stream() - .map(HiveUtil::toMetastoreApiPartition) - .collect(Collectors.toList()); - partitionList.addAll(hivePartitions); - } - } - - @Override - public void dropPartition(String dbName, String tableName, List partitionValues, boolean deleteData) { - synchronized (this) { - List partitionList = getPartitionList(dbName, tableName); - for (int j = 0; j < partitionList.size(); j++) { - Partition partition = partitionList.get(j); - if (partition.getValues().equals(partitionValues)) { - partitionList.remove(partition); - return; - } - } - throw new RuntimeException("can't found the partition"); - } - } - - public List getPartitionList(String dbName, String tableName) { - NameMapping nameMapping = NameMapping.createForTest(dbName, tableName); - List partitionList = this.partitions.get(NameMapping.createForTest(dbName, tableName)); - if (partitionList == null) { - throw new RuntimeException("can't found table: " + nameMapping.getFullLocalName()); - } - return partitionList; - } - - public List
    getTableList(String dbName) { - List
    tablesList = this.tables.get(dbName); - if (tablesList == null) { - throw new RuntimeException("can't found database: " + dbName); - } - return tablesList; - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/ConnectorBranchTagConverterTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/ConnectorBranchTagConverterTest.java new file mode 100644 index 00000000000000..e9d1e4d2f9177e --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/ConnectorBranchTagConverterTest.java @@ -0,0 +1,105 @@ +// 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.connector.converter; + +import org.apache.doris.catalog.info.BranchOptions; +import org.apache.doris.catalog.info.CreateOrReplaceBranchInfo; +import org.apache.doris.catalog.info.CreateOrReplaceTagInfo; +import org.apache.doris.catalog.info.DropBranchInfo; +import org.apache.doris.catalog.info.DropTagInfo; +import org.apache.doris.catalog.info.TagOptions; +import org.apache.doris.connector.api.ddl.BranchChange; +import org.apache.doris.connector.api.ddl.DropRefChange; +import org.apache.doris.connector.api.ddl.TagChange; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +/** + * Unit tests for {@link ConnectorBranchTagConverter}: every nereids info/option field must land on the right + * neutral carrier field (Rule 9), incl. the legacy {@code BranchOptions} naming (retain -> maxSnapshotAge, + * numSnapshots -> minSnapshotsToKeep, retention -> maxRefAge), and absent options must become {@code null}. + */ +public class ConnectorBranchTagConverterTest { + + @Test + public void testBranchFullOptions() { + BranchChange b = ConnectorBranchTagConverter.toBranchChange( + new CreateOrReplaceBranchInfo("b1", true, false, true, + new BranchOptions(Optional.of(42L), Optional.of(86400000L), + Optional.of(5), Optional.of(172800000L)))); + Assertions.assertEquals("b1", b.getName()); + Assertions.assertTrue(b.isCreate()); + Assertions.assertFalse(b.isReplace()); + Assertions.assertTrue(b.isIfNotExists()); + Assertions.assertEquals(42L, b.getSnapshotId().longValue()); + Assertions.assertEquals(86400000L, b.getMaxSnapshotAgeMs().longValue()); + Assertions.assertEquals(5, b.getMinSnapshotsToKeep().intValue()); + Assertions.assertEquals(172800000L, b.getMaxRefAgeMs().longValue()); + } + + @Test + public void testBranchEmptyOptionsBecomeNull() { + BranchChange b = ConnectorBranchTagConverter.toBranchChange( + new CreateOrReplaceBranchInfo("b1", false, true, false, BranchOptions.EMPTY)); + Assertions.assertFalse(b.isCreate()); + Assertions.assertTrue(b.isReplace()); + Assertions.assertFalse(b.isIfNotExists()); + Assertions.assertNull(b.getSnapshotId()); + Assertions.assertNull(b.getMaxSnapshotAgeMs()); + Assertions.assertNull(b.getMinSnapshotsToKeep()); + Assertions.assertNull(b.getMaxRefAgeMs()); + } + + @Test + public void testTagFullOptions() { + TagChange t = ConnectorBranchTagConverter.toTagChange( + new CreateOrReplaceTagInfo("v1", true, false, true, + new TagOptions(Optional.of(9L), Optional.of(99000L)))); + Assertions.assertEquals("v1", t.getName()); + Assertions.assertTrue(t.isCreate()); + Assertions.assertFalse(t.isReplace()); + Assertions.assertTrue(t.isIfNotExists()); + Assertions.assertEquals(9L, t.getSnapshotId().longValue()); + Assertions.assertEquals(99000L, t.getMaxRefAgeMs().longValue()); + } + + @Test + public void testTagEmptyOptionsBecomeNull() { + TagChange t = ConnectorBranchTagConverter.toTagChange( + new CreateOrReplaceTagInfo("v1", true, false, false, TagOptions.EMPTY)); + Assertions.assertNull(t.getSnapshotId()); + Assertions.assertNull(t.getMaxRefAgeMs()); + } + + @Test + public void testDropBranch() { + DropRefChange d = ConnectorBranchTagConverter.toDropRefChange(new DropBranchInfo("b1", true)); + Assertions.assertEquals("b1", d.getName()); + Assertions.assertTrue(d.isIfExists()); + } + + @Test + public void testDropTag() { + DropRefChange d = ConnectorBranchTagConverter.toDropRefChange(new DropTagInfo("v1", false)); + Assertions.assertEquals("v1", d.getName()); + Assertions.assertFalse(d.isIfExists()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/ConnectorColumnConverterTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/ConnectorColumnConverterTest.java new file mode 100644 index 00000000000000..14b19138df621b --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/ConnectorColumnConverterTest.java @@ -0,0 +1,439 @@ +// 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.connector.converter; + +import org.apache.doris.catalog.AggregateType; +import org.apache.doris.catalog.ArrayType; +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.MapType; +import org.apache.doris.catalog.ScalarType; +import org.apache.doris.catalog.StructField; +import org.apache.doris.catalog.StructType; +import org.apache.doris.catalog.Type; +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.nereids.trees.plans.commands.IcebergRowId; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; + +class ConnectorColumnConverterTest { + + @Test + void testScalarTypeRoundtrip() { + // INT → ConnectorType → Doris Type should roundtrip + ConnectorType ct = ConnectorColumnConverter.toConnectorType(ScalarType.INT); + Type back = ConnectorColumnConverter.convertType(ct); + Assertions.assertEquals(ScalarType.INT, back); + } + + @Test + void testArrayTypeRoundtrip() { + ArrayType arrayInt = ArrayType.create(ScalarType.INT, true); + ConnectorType ct = ConnectorColumnConverter.toConnectorType(arrayInt); + + Assertions.assertEquals("ARRAY", ct.getTypeName()); + Assertions.assertEquals(1, ct.getChildren().size()); + Assertions.assertEquals("INT", ct.getChildren().get(0).getTypeName()); + + Type back = ConnectorColumnConverter.convertType(ct); + Assertions.assertTrue(back instanceof ArrayType); + Assertions.assertEquals(ScalarType.INT, ((ArrayType) back).getItemType()); + } + + @Test + void testMapTypeRoundtrip() { + MapType mapType = new MapType(ScalarType.createStringType(), ScalarType.INT); + ConnectorType ct = ConnectorColumnConverter.toConnectorType(mapType); + + Assertions.assertEquals("MAP", ct.getTypeName()); + Assertions.assertEquals(2, ct.getChildren().size()); + Assertions.assertEquals("STRING", ct.getChildren().get(0).getTypeName()); + Assertions.assertEquals("INT", ct.getChildren().get(1).getTypeName()); + + Type back = ConnectorColumnConverter.convertType(ct); + Assertions.assertTrue(back instanceof MapType); + Assertions.assertEquals(ScalarType.createStringType(), ((MapType) back).getKeyType()); + Assertions.assertEquals(ScalarType.INT, ((MapType) back).getValueType()); + } + + @Test + void testStructTypeRoundtrip() { + ArrayList fields = new ArrayList<>(); + fields.add(new StructField("a", ScalarType.INT)); + fields.add(new StructField("b", ScalarType.createStringType())); + StructType structType = new StructType(fields); + + ConnectorType ct = ConnectorColumnConverter.toConnectorType(structType); + + Assertions.assertEquals("STRUCT", ct.getTypeName()); + Assertions.assertEquals(2, ct.getChildren().size()); + Assertions.assertEquals(Arrays.asList("a", "b"), ct.getFieldNames()); + Assertions.assertEquals("INT", ct.getChildren().get(0).getTypeName()); + Assertions.assertEquals("STRING", ct.getChildren().get(1).getTypeName()); + + Type back = ConnectorColumnConverter.convertType(ct); + Assertions.assertTrue(back instanceof StructType); + StructType backStruct = (StructType) back; + Assertions.assertEquals(2, backStruct.getFields().size()); + Assertions.assertEquals("a", backStruct.getFields().get(0).getName()); + Assertions.assertEquals(ScalarType.INT, backStruct.getFields().get(0).getType()); + } + + @Test + void testNestedComplexType() { + // ARRAY> + MapType innerMap = new MapType(ScalarType.createStringType(), ScalarType.INT); + ArrayType nested = ArrayType.create(innerMap, true); + + ConnectorType ct = ConnectorColumnConverter.toConnectorType(nested); + + Assertions.assertEquals("ARRAY", ct.getTypeName()); + ConnectorType mapCt = ct.getChildren().get(0); + Assertions.assertEquals("MAP", mapCt.getTypeName()); + Assertions.assertEquals("STRING", mapCt.getChildren().get(0).getTypeName()); + Assertions.assertEquals("INT", mapCt.getChildren().get(1).getTypeName()); + + // Full roundtrip + Type back = ConnectorColumnConverter.convertType(ct); + Assertions.assertTrue(back instanceof ArrayType); + Type backItem = ((ArrayType) back).getItemType(); + Assertions.assertTrue(backItem instanceof MapType); + } + + @Test + void testUnsupportedTypeConversion() { + ConnectorType ct = ConnectorType.of("UNSUPPORTED", -1, -1); + Type back = ConnectorColumnConverter.convertType(ct); + Assertions.assertTrue(back.isUnsupported()); + } + + @Test + void testUnknownTypeDefaultsToUnsupported() { + ConnectorType ct = ConnectorType.of("GEOMETRY", -1, -1); + Type back = ConnectorColumnConverter.convertType(ct); + Assertions.assertTrue(back.isUnsupported()); + } + + @Test + void testDecimalTypeRoundtrip() { + ScalarType decimal = ScalarType.createDecimalV3Type(18, 6); + ConnectorType ct = ConnectorColumnConverter.toConnectorType(decimal); + + // PrimitiveType.toString() returns the specific decimal width (DECIMAL64 for p<=18) + Assertions.assertTrue(ct.getTypeName().startsWith("DECIMAL")); + Assertions.assertEquals(18, ct.getPrecision()); + Assertions.assertEquals(6, ct.getScale()); + } + + @Test + void testWithTimeZoneColumnSetsExtraInfo() { + // A ConnectorColumn marked withTimeZone() must convert to a Doris Column carrying the + // WITH_TIMEZONE extra info — the value DESC shows in its "Extra" column (IndexSchemaProcNode + // reads Column.getExtraInfo()). This is the SPI transport for legacy + // PaimonExternalTable/IcebergUtils setWithTZExtraInfo(). MUTATION: dropping the + // setWithTZExtraInfo() call in convertColumn -> getExtraInfo()==null -> red. + ConnectorColumn marked = new ConnectorColumn("ts_ltz", + ConnectorType.of("TIMESTAMPTZ", 3, 0), null, true, null, true).withTimeZone(); + Column col = ConnectorColumnConverter.convertColumn(marked); + Assertions.assertEquals("WITH_TIMEZONE", col.getExtraInfo()); + } + + @Test + void testPlainColumnHasNoExtraInfo() { + // Regression guard: an unmarked column must NOT receive the WITH_TIMEZONE extra info. + ConnectorColumn plain = new ConnectorColumn("id", + ConnectorType.of("INT", -1, -1), null, true, null, true); + Column col = ConnectorColumnConverter.convertColumn(plain); + Assertions.assertNull(col.getExtraInfo()); + } + + @Test + void testCharVarcharLengthPreserved() { + // Regression: CHAR/VARCHAR carry length in `len`, not `precision`; the + // converter must encode the length into the ConnectorType precision field + // so it survives the CREATE TABLE request path (previously emitted 0). + ScalarType charType = ScalarType.createCharType(20); + ConnectorType charCt = ConnectorColumnConverter.toConnectorType(charType); + Assertions.assertEquals("CHAR", charCt.getTypeName()); + Assertions.assertEquals(20, charCt.getPrecision()); + Type charBack = ConnectorColumnConverter.convertType(charCt); + Assertions.assertTrue(charBack instanceof ScalarType); + Assertions.assertEquals(20, ((ScalarType) charBack).getLength()); + + ScalarType varcharType = ScalarType.createVarcharType(255); + ConnectorType varcharCt = ConnectorColumnConverter.toConnectorType(varcharType); + Assertions.assertEquals("VARCHAR", varcharCt.getTypeName()); + Assertions.assertEquals(255, varcharCt.getPrecision()); + Type varcharBack = ConnectorColumnConverter.convertType(varcharCt); + Assertions.assertTrue(varcharBack instanceof ScalarType); + Assertions.assertEquals(255, ((ScalarType) varcharBack).getLength()); + } + + @Test + void convertColumnDefaultsToVisible() { + ConnectorType intType = ConnectorColumnConverter.toConnectorType(ScalarType.INT); + Column col = ConnectorColumnConverter.convertColumn( + new ConnectorColumn("c", intType, null, true, null)); + Assertions.assertTrue(col.isVisible(), + "a ConnectorColumn not marked invisible converts to a visible Doris column"); + } + + @Test + void convertColumnPropagatesInvisibleMarker() { + // WHY: the iceberg synthetic write columns (__DORIS_ICEBERG_ROWID_COL__ + the v3 row-lineage + // columns) are hidden (Column.setIsVisible(false)). Post-flip they are declared through the + // connector schema SPI, so the neutral ConnectorColumn must carry the invisible marker across the + // boundary and the converter must re-apply it (mirroring the withTimeZone marker). + // MUTATION: dropping the setIsVisible(false) re-apply leaves the column visible -> this turns red. + ConnectorType intType = ConnectorColumnConverter.toConnectorType(ScalarType.INT); + Column col = ConnectorColumnConverter.convertColumn( + new ConnectorColumn("rowid", intType, null, true, null).invisible()); + Assertions.assertFalse(col.isVisible(), + "an invisible ConnectorColumn must convert to a hidden (isVisible=false) Doris column"); + } + + @Test + void convertColumnDefaultsToUnsetUniqueId() { + // Regression guard: a ConnectorColumn that does not carry a reserved field id must leave the Doris + // Column at its default unset (-1) uniqueId — the converter must not stamp an id where none was + // declared. Mirrors convertColumnDefaultsToVisible. + ConnectorType bigintType = ConnectorColumnConverter.toConnectorType(ScalarType.BIGINT); + Column col = ConnectorColumnConverter.convertColumn( + new ConnectorColumn("c", bigintType, null, true, null)); + Assertions.assertEquals(-1, col.getUniqueId(), + "a ConnectorColumn without withUniqueId() converts to a Doris column with the default -1 uniqueId"); + } + + @Test + void convertColumnPropagatesUniqueId() { + // WHY: the iceberg v3 row-lineage columns must keep their reserved field ids across the SPI boundary + // (_row_id=2147483540, _last_updated_sequence_number=2147483539), which BE matches by field id when + // reading lineage from iceberg data files. Post-flip the connector declares them through the schema + // SPI as invisible() + withUniqueId(reservedId), so both markers must survive the immutable-copy + // chain AND the converter must re-apply Column.setUniqueId(). The .withUniqueId(...).invisible() + // chaining order verifies invisible() preserves the carried uniqueId. + // MUTATION: dropping the setUniqueId(cc.getUniqueId()) re-apply leaves the id at -1 -> this turns red. + ConnectorType bigintType = ConnectorColumnConverter.toConnectorType(ScalarType.BIGINT); + Column col = ConnectorColumnConverter.convertColumn( + new ConnectorColumn("_row_id", bigintType, null, true, null) + .withUniqueId(2147483540).invisible()); + Assertions.assertEquals(2147483540, col.getUniqueId(), + "an invisible ConnectorColumn carrying a reserved uniqueId must convert to a Doris column with that id"); + Assertions.assertFalse(col.isVisible(), + "the invisible marker must survive alongside the carried uniqueId"); + } + + @Test + void convertColumnDefaultsToNotReservedPassthrough() { + // Regression guard: a ConnectorColumn that does not carry the reserved-passthrough marker must leave + // the Doris Column at its default false — the converter must not stamp it where none was declared. + ConnectorType bigintType = ConnectorColumnConverter.toConnectorType(ScalarType.BIGINT); + Column col = ConnectorColumnConverter.convertColumn( + new ConnectorColumn("c", bigintType, null, true, null)); + Assertions.assertFalse(col.isReservedPassthrough(), + "a ConnectorColumn without reservedPassthrough() converts to a non-passthrough Doris column"); + } + + @Test + void convertColumnPropagatesReservedPassthroughMarker() { + // WHY: the iceberg v3 row-lineage columns (_row_id / _last_updated_sequence_number) are declared + // through the connector schema SPI as invisible() + withUniqueId(reservedId) + reservedPassthrough(). + // The engine's MERGE/UPDATE / sink binding must recognize them generically via + // Column.isReservedPassthrough() instead of string-matching iceberg column names, so the neutral marker + // must survive the immutable-copy chain AND the converter must re-apply Column.setReservedPassthrough(true). + // MUTATION: dropping the setReservedPassthrough(true) re-apply leaves it false -> this turns red. + ConnectorType bigintType = ConnectorColumnConverter.toConnectorType(ScalarType.BIGINT); + Column col = ConnectorColumnConverter.convertColumn( + new ConnectorColumn("_row_id", bigintType, null, true, null) + .withUniqueId(2147483540).invisible().reservedPassthrough()); + Assertions.assertTrue(col.isReservedPassthrough(), + "a reservedPassthrough ConnectorColumn must convert to a reserved-passthrough Doris column"); + Assertions.assertFalse(col.isVisible(), + "the invisible marker must survive alongside the reserved-passthrough marker"); + Assertions.assertEquals(2147483540, col.getUniqueId(), + "the carried uniqueId must survive alongside the reserved-passthrough marker"); + } + + @Test + void convertColumnReconstructsIcebergRowIdHiddenColumn() { + // CONTRACT PIN (③ C3b-core, fe-core half): the iceberg connector declares the request-scoped row-id + // synthetic write column (__DORIS_ICEBERG_ROWID_COL__) as an engine-neutral invisible STRUCT + // ConnectorColumn through ConnectorWritePlanProvider.getSyntheticWriteColumns (pinned connector-side by + // IcebergWritePlanProviderTest.getSyntheticWriteColumnsDeclaresRowIdStruct). fe-core cannot import the + // connector, so this two-sided pin asserts that converting that exact agreed shape yields the legacy + // fe-core IcebergRowId.createHiddenColumn() byte-for-byte (name / STRUCT type / hidden / not-null) — the + // column post-flip getFullSchema appends. If the connector's literal and the legacy IcebergRowId drift + // apart, one of the two sides turns red. + ConnectorType rowIdStruct = ConnectorType.structOf( + Arrays.asList("file_path", "row_position", "partition_spec_id", "partition_data"), + Arrays.asList(ConnectorType.of("STRING"), ConnectorType.of("BIGINT"), + ConnectorType.of("INT"), ConnectorType.of("STRING"))); + Column converted = ConnectorColumnConverter.convertColumn( + new ConnectorColumn("__DORIS_ICEBERG_ROWID_COL__", rowIdStruct, + "Iceberg row position metadata", false, null, false).invisible()); + + Column legacy = IcebergRowId.createHiddenColumn(); + Assertions.assertEquals(Column.ICEBERG_ROWID_COL, converted.getName()); + Assertions.assertEquals(legacy.getName(), converted.getName()); + Assertions.assertEquals(legacy.getType(), converted.getType(), + "the converted STRUCT must equal the legacy IcebergRowId struct type"); + Assertions.assertFalse(converted.isVisible(), "the row-id column must be hidden"); + Assertions.assertEquals(legacy.isVisible(), converted.isVisible()); + Assertions.assertEquals(legacy.isAllowNull(), converted.isAllowNull()); + } + + @Test + void testToConnectorColumnPreservesKeyAndAggregation() { + Column key = new Column("k", Type.INT, true, null, true, null, ""); + ConnectorColumn ck = ConnectorColumnConverter.toConnectorColumn(key); + Assertions.assertTrue(ck.isKey()); + Assertions.assertFalse(ck.isAggregated()); + + // WHY (B2): the iceberg connector rejects aggregated columns in ALTER ADD/MODIFY COLUMN + // (validateCommonColumnInfo); toConnectorColumn must carry isAggregated across the SPI or the + // connector could not tell an aggregated column apart from a plain one. A mutation reverting to + // the 5-arg ctor (dropping these flags) makes isAggregated default false -> this assert goes red. + Column agg = new Column("s", Type.INT, false, AggregateType.SUM, true, null, ""); + ConnectorColumn ca = ConnectorColumnConverter.toConnectorColumn(agg); + Assertions.assertTrue(ca.isAggregated()); + Assertions.assertFalse(ca.isKey()); + Assertions.assertEquals("s", ca.getName()); + } + + @Test + void toConnectorTypeCarriesStructFieldNullabilityAndComment() { + // WHY (B2b): a complex MODIFY COLUMN diffs field-by-field on the connector side, and CREATE TABLE + // must preserve a NOT NULL / commented STRUCT field. toConnectorType must thread each field's + // nullability + comment across the SPI; a mutation dropping them flips these asserts. + ArrayList fields = new ArrayList<>(); + fields.add(new StructField("a", ScalarType.INT, "ca", true)); + fields.add(new StructField("b", ScalarType.createStringType(), null, false)); + ConnectorType ct = ConnectorColumnConverter.toConnectorType(new StructType(fields)); + Assertions.assertTrue(ct.isChildNullable(0)); + Assertions.assertEquals("ca", ct.getChildComment(0)); + Assertions.assertFalse(ct.isChildNullable(1)); + } + + @Test + void toConnectorTypeThreadsArrayElementNullability() { + // Doris ARRAY elements are always nullable (ArrayType.getContainsNull() is hard-coded true), so the + // threaded value is always true; honoring a NOT NULL element from a non-Doris source happens in the + // connector schema builder (IcebergSchemaBuilderTest.testNestedNullabilityAndCommentPreserved). + ConnectorType ct = ConnectorColumnConverter.toConnectorType(ArrayType.create(ScalarType.INT, true)); + Assertions.assertTrue(ct.isChildNullable(0)); + } + + @Test + void toConnectorTypeCarriesMapValueNullability() { + // child index 1 is the MAP value (keys are always required). + MapType notNullValue = new MapType(ScalarType.createStringType(), ScalarType.INT, true, false); + ConnectorType ct = ConnectorColumnConverter.toConnectorType(notNullValue); + Assertions.assertFalse(ct.isChildNullable(1)); + } + + @Test + void convertColumnStampsNestedFieldIdsOntoChildTree() { + // WHY (H-10 L3): post-flip iceberg nested-column pruning is only correct if EVERY level of the Doris + // Column tree carries uniqueId = iceberg field-id (legacy IcebergUtils.updateIcebergColumnUniqueId set + // them recursively). The connector carries the top-level id on ConnectorColumn.withUniqueId and the + // per-child ids on ConnectorType.withChildrenFieldIds; convertColumn must stamp the whole child tree so + // SlotTypeReplacer can rewrite the nested access path to ids and BE matches the pruned leaf by id (a -1 + // leaf is skipped -> NULL). MUTATION: dropping the applyNestedFieldIds call leaves children at -1 -> red. + // struct, top-level field-id 3 + ConnectorType structType = ConnectorType.structOf( + Arrays.asList("a", "b"), + Arrays.asList(ConnectorType.of("INT"), ConnectorType.of("STRING"))) + .withChildrenFieldIds(Arrays.asList(4, 5)); + Column col = ConnectorColumnConverter.convertColumn( + new ConnectorColumn("s", structType, null, true, null).withUniqueId(3)); + Assertions.assertEquals(3, col.getUniqueId(), "top-level struct column carries field-id 3"); + Assertions.assertEquals(4, col.getChildren().get(0).getUniqueId(), "struct field a carries field-id 4"); + Assertions.assertEquals(5, col.getChildren().get(1).getUniqueId(), "struct field b carries field-id 5"); + } + + @Test + void convertColumnStampsDeeplyNestedAndArrayMapFieldIds() { + // Verifies the recursion descends through an ARRAY element into a nested STRUCT, and that ARRAY/MAP + // child ordering ([item] / [key,value]) aligns with ConnectorType.getChildFieldId (legacy parity: + // updateIcebergColumnUniqueId recursed via ListType/MapType .fields()). + // array> with array element id 6, top-level id 5 + ConnectorType innerStruct = ConnectorType.structOf( + Arrays.asList("c"), Arrays.asList(ConnectorType.of("INT"))) + .withChildrenFieldIds(Arrays.asList(7)); + ConnectorType arrayType = ConnectorType.arrayOf(innerStruct) + .withChildrenFieldIds(Arrays.asList(6)); + Column arrCol = ConnectorColumnConverter.convertColumn( + new ConnectorColumn("arr", arrayType, null, true, null).withUniqueId(5)); + Assertions.assertEquals(5, arrCol.getUniqueId()); + Column elem = arrCol.getChildren().get(0); // array element (the struct) + Assertions.assertEquals(6, elem.getUniqueId(), "array element carries field-id 6"); + Assertions.assertEquals(7, elem.getChildren().get(0).getUniqueId(), + "nested struct field c carries field-id 7"); + + // map, top-level id 8 + ConnectorType mapType = ConnectorType.mapOf(ConnectorType.of("STRING"), ConnectorType.of("INT")) + .withChildrenFieldIds(Arrays.asList(9, 10)); + Column mapCol = ConnectorColumnConverter.convertColumn( + new ConnectorColumn("m", mapType, null, true, null).withUniqueId(8)); + Assertions.assertEquals(8, mapCol.getUniqueId()); + Assertions.assertEquals(9, mapCol.getChildren().get(0).getUniqueId(), "map key carries field-id 9"); + Assertions.assertEquals(10, mapCol.getChildren().get(1).getUniqueId(), "map value carries field-id 10"); + } + + @Test + void convertColumnLeavesNestedUniqueIdsUnsetWithoutFieldIds() { + // Regression guard: a connector that does NOT carry nested field ids (no withChildrenFieldIds, e.g. + // paimon) must leave every child uniqueId at the default -1 — applyNestedFieldIds must be inert, so a + // non-iceberg connector's nested columns are never accidentally stamped. + ConnectorType structType = ConnectorType.structOf( + Arrays.asList("a", "b"), + Arrays.asList(ConnectorType.of("INT"), ConnectorType.of("STRING"))); + Column col = ConnectorColumnConverter.convertColumn( + new ConnectorColumn("s", structType, null, true, null)); + Assertions.assertEquals(-1, col.getChildren().get(0).getUniqueId()); + Assertions.assertEquals(-1, col.getChildren().get(1).getUniqueId()); + } + + @Test + void connectorTypeChildFieldIdDefaultsAndEqualityExclusion() { + // getChildFieldId returns -1 for unset / out-of-range indices; withChildrenFieldIds is excluded from + // equals/hashCode (type identity stays the structural shape), matching childrenNullable/childrenComments + // so existing equality-based schema-change detection is unaffected. + ConnectorType bare = ConnectorType.structOf( + Arrays.asList("a"), Arrays.asList(ConnectorType.of("INT"))); + Assertions.assertEquals(-1, bare.getChildFieldId(0), "unset child field id defaults to -1"); + Assertions.assertEquals(-1, bare.getChildFieldId(5), "out-of-range child field id defaults to -1"); + ConnectorType withIds = bare.withChildrenFieldIds(Arrays.asList(4)); + Assertions.assertEquals(4, withIds.getChildFieldId(0)); + Assertions.assertEquals(bare, withIds, "field ids must be excluded from equals (structural identity)"); + Assertions.assertEquals(bare.hashCode(), withIds.hashCode(), "field ids must be excluded from hashCode"); + } + + @Test + void testToConnectorColumnsConvertsList() { + java.util.List cols = ConnectorColumnConverter.toConnectorColumns( + Arrays.asList(new Column("a", Type.INT), new Column("b", Type.INT))); + Assertions.assertEquals(2, cols.size()); + Assertions.assertEquals("a", cols.get(0).getName()); + Assertions.assertEquals("b", cols.get(1).getName()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/ConnectorExpressionToNereidsConverterTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/ConnectorExpressionToNereidsConverterTest.java new file mode 100644 index 00000000000000..359f7dc150f63e --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/ConnectorExpressionToNereidsConverterTest.java @@ -0,0 +1,147 @@ +// 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.connector.converter; + +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.ConnectorOr; +import org.apache.doris.nereids.exceptions.AnalysisException; +import org.apache.doris.nereids.trees.expressions.And; +import org.apache.doris.nereids.trees.expressions.EqualTo; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.expressions.GreaterThan; +import org.apache.doris.nereids.trees.expressions.GreaterThanEqual; +import org.apache.doris.nereids.trees.expressions.LessThan; +import org.apache.doris.nereids.trees.expressions.LessThanEqual; +import org.apache.doris.nereids.trees.expressions.SlotReference; +import org.apache.doris.nereids.trees.expressions.literal.StringLiteral; +import org.apache.doris.nereids.types.StringType; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Unit tests for the reverse {@link ConnectorExpressionToNereidsConverter}: it turns a connector-neutral + * residual predicate (e.g. hudi's incremental {@code _hoodie_commit_time > c1 AND <= c2}) back into a bound + * Nereids {@link Expression}, binding column refs to scan-output slots by name. + * + *

    The converter is TOTAL + FAIL-LOUD (the inverse of the forward converter's safe drop-on-unknown): dropping + * a conjunct from a scan residual predicate would UNDER-filter and leak rows, so anything outside the supported + * grammar throws rather than returning null. These tests pin both the happy conversion and every fail-loud arm.

    + */ +public class ConnectorExpressionToNereidsConverterTest { + + private static final ConnectorType STRING = ConnectorType.of("STRING"); + + private final SlotReference commitTime = new SlotReference("_hoodie_commit_time", StringType.INSTANCE); + + private Map slots() { + Map m = new HashMap<>(); + m.put("_hoodie_commit_time", commitTime); + return m; + } + + private static ConnectorComparison cmp(ConnectorComparison.Operator op, String bound) { + return new ConnectorComparison(op, + new ConnectorColumnRef("_hoodie_commit_time", STRING), ConnectorLiteral.ofString(bound)); + } + + @Test + public void convertsComparisonBindingColumnRefToTheScanSlot() { + Expression result = ConnectorExpressionToNereidsConverter.convert( + cmp(ConnectorComparison.Operator.GT, "c1"), slots()); + Assertions.assertTrue(result instanceof GreaterThan, "GT must map to a Nereids GreaterThan"); + Assertions.assertSame(commitTime, result.child(0), + "the column ref must bind to the SAME scan-output slot instance (a fresh slot would be unbound)"); + Assertions.assertEquals(new StringLiteral("c1"), result.child(1), + "the bound must be a STRING literal so the compare is lexicographic over instants"); + } + + @Test + public void mapsEachOrderEqualityOperatorToItsNereidsNode() { + Assertions.assertTrue(convert(ConnectorComparison.Operator.EQ) instanceof EqualTo); + Assertions.assertTrue(convert(ConnectorComparison.Operator.LT) instanceof LessThan); + Assertions.assertTrue(convert(ConnectorComparison.Operator.LE) instanceof LessThanEqual); + Assertions.assertTrue(convert(ConnectorComparison.Operator.GT) instanceof GreaterThan); + Assertions.assertTrue(convert(ConnectorComparison.Operator.GE) instanceof GreaterThanEqual); + } + + private Expression convert(ConnectorComparison.Operator op) { + return ConnectorExpressionToNereidsConverter.convert(cmp(op, "c1"), slots()); + } + + @Test + public void convertsConnectorAndToNereidsAnd() { + ConnectorExpression window = new ConnectorAnd(Arrays.asList( + cmp(ConnectorComparison.Operator.GT, "c1"), cmp(ConnectorComparison.Operator.LE, "c2"))); + Expression result = ConnectorExpressionToNereidsConverter.convert(window, slots()); + Assertions.assertTrue(result instanceof And, "a ConnectorAnd must map to a Nereids And"); + Assertions.assertEquals(2, result.children().size(), "the And must keep both conjuncts"); + Assertions.assertTrue(result.child(0) instanceof GreaterThan); + Assertions.assertTrue(result.child(1) instanceof LessThanEqual); + } + + @Test + public void singleConjunctConnectorAndIsUnwrapped() { + // Nereids And(List) rejects < 2 children, so a 1-element ConnectorAnd must return the bare conjunct. + ConnectorExpression single = new ConnectorAnd( + Collections.singletonList(cmp(ConnectorComparison.Operator.GT, "c1"))); + Expression result = ConnectorExpressionToNereidsConverter.convert(single, slots()); + Assertions.assertTrue(result instanceof GreaterThan, "a single-conjunct AND must unwrap, not build And(1)"); + } + + @Test + public void failsLoudWhenColumnNotInScanOutput() { + // Dropping a residual predicate whose column is absent would UNDER-filter (leak out-of-window rows) — the + // inverse of the forward converter's safe drop. So an unresolvable column MUST throw, not no-op. + Assertions.assertThrows(AnalysisException.class, () -> ConnectorExpressionToNereidsConverter.convert( + cmp(ConnectorComparison.Operator.GT, "c1"), Collections.emptyMap())); + } + + @Test + public void failsLoudOnUnsupportedNode() { + ConnectorExpression or = new ConnectorOr(Arrays.asList( + cmp(ConnectorComparison.Operator.GT, "c1"), cmp(ConnectorComparison.Operator.LE, "c2"))); + Assertions.assertThrows(AnalysisException.class, + () -> ConnectorExpressionToNereidsConverter.convert(or, slots())); + } + + @Test + public void failsLoudOnNonStringLiteral() { + // A numeric literal must NOT be silently coerced to a string (would change lexicographic compare). + ConnectorExpression gt = new ConnectorComparison(ConnectorComparison.Operator.GT, + new ConnectorColumnRef("_hoodie_commit_time", STRING), ConnectorLiteral.ofInt(42)); + Assertions.assertThrows(AnalysisException.class, + () -> ConnectorExpressionToNereidsConverter.convert(gt, slots())); + } + + @Test + public void failsLoudOnUnsupportedComparisonOperator() { + Assertions.assertThrows(AnalysisException.class, () -> ConnectorExpressionToNereidsConverter.convert( + cmp(ConnectorComparison.Operator.NE, "c1"), slots())); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/ConnectorPartitionFieldConverterTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/ConnectorPartitionFieldConverterTest.java new file mode 100644 index 00000000000000..edd228af679f4e --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/ConnectorPartitionFieldConverterTest.java @@ -0,0 +1,126 @@ +// 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.connector.converter; + +import org.apache.doris.connector.api.ddl.PartitionFieldChange; +import org.apache.doris.nereids.trees.plans.commands.info.AddPartitionFieldOp; +import org.apache.doris.nereids.trees.plans.commands.info.DropPartitionFieldOp; +import org.apache.doris.nereids.trees.plans.commands.info.ReplacePartitionFieldOp; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ConnectorPartitionFieldConverter}: every nereids op field must land on the right neutral + * carrier field (Rule 9). Add/drop populate only the primary field; replace also maps the {@code new*} side to the + * primary field and the {@code old*} side to the old field. + */ +public class ConnectorPartitionFieldConverterTest { + + @Test + public void testAddCopiesPrimaryFieldAndLeavesOldNull() { + PartitionFieldChange c = ConnectorPartitionFieldConverter.toAddChange( + new AddPartitionFieldOp("bucket", 8, "id", "id_b")); + Assertions.assertEquals("bucket", c.getTransformName()); + Assertions.assertEquals(8, c.getTransformArg().intValue()); + Assertions.assertEquals("id", c.getColumnName()); + Assertions.assertEquals("id_b", c.getPartitionFieldName()); + // The old side belongs to replace only. + Assertions.assertNull(c.getOldPartitionFieldName()); + Assertions.assertNull(c.getOldTransformName()); + Assertions.assertNull(c.getOldTransformArg()); + Assertions.assertNull(c.getOldColumnName()); + } + + @Test + public void testAddIdentityKeepsNullTransform() { + // Identity transform: a null transform name is preserved (the connector reads it as Expressions.ref). + PartitionFieldChange c = ConnectorPartitionFieldConverter.toAddChange( + new AddPartitionFieldOp(null, null, "id", null)); + Assertions.assertNull(c.getTransformName()); + Assertions.assertNull(c.getTransformArg()); + Assertions.assertEquals("id", c.getColumnName()); + Assertions.assertNull(c.getPartitionFieldName()); + } + + @Test + public void testDropByNameCopiesFieldName() { + PartitionFieldChange c = ConnectorPartitionFieldConverter.toDropChange( + new DropPartitionFieldOp("p_id")); + Assertions.assertEquals("p_id", c.getPartitionFieldName()); + Assertions.assertNull(c.getTransformName()); + Assertions.assertNull(c.getColumnName()); + } + + @Test + public void testDropByTransformCopiesTriple() { + PartitionFieldChange c = ConnectorPartitionFieldConverter.toDropChange( + new DropPartitionFieldOp("bucket", 8, "id")); + Assertions.assertNull(c.getPartitionFieldName()); + Assertions.assertEquals("bucket", c.getTransformName()); + Assertions.assertEquals(8, c.getTransformArg().intValue()); + Assertions.assertEquals("id", c.getColumnName()); + } + + @Test + public void testReplaceMapsNewToPrimaryAndOldToOldSide() { + // OLD identified by name "p"; NEW is bucket(4) on id aliased "p2". + PartitionFieldChange c = ConnectorPartitionFieldConverter.toReplaceChange( + new ReplacePartitionFieldOp("p", null, null, null, "bucket", 4, "id", "p2")); + // new* -> primary + Assertions.assertEquals("bucket", c.getTransformName()); + Assertions.assertEquals(4, c.getTransformArg().intValue()); + Assertions.assertEquals("id", c.getColumnName()); + Assertions.assertEquals("p2", c.getPartitionFieldName()); + // old* -> old side + Assertions.assertEquals("p", c.getOldPartitionFieldName()); + Assertions.assertNull(c.getOldTransformName()); + Assertions.assertNull(c.getOldColumnName()); + } + + @Test + public void testReplaceMapsOldIdentityTransform() { + // OLD identified by an identity transform (oldTransformName null, oldColumnName non-null); NEW is year on ts. + PartitionFieldChange c = ConnectorPartitionFieldConverter.toReplaceChange( + new ReplacePartitionFieldOp(null, null, null, "id", "year", null, "ts", "ty")); + // new* -> primary + Assertions.assertEquals("year", c.getTransformName()); + Assertions.assertEquals("ts", c.getColumnName()); + Assertions.assertEquals("ty", c.getPartitionFieldName()); + // old* -> old side: identity means name null + transformName null, only the column is carried. + Assertions.assertNull(c.getOldPartitionFieldName()); + Assertions.assertNull(c.getOldTransformName()); + Assertions.assertNull(c.getOldTransformArg()); + Assertions.assertEquals("id", c.getOldColumnName()); + } + + @Test + public void testReplaceMapsOldTransformTriple() { + // OLD identified by transform bucket(8) on id; NEW is truncate(4) on name. + PartitionFieldChange c = ConnectorPartitionFieldConverter.toReplaceChange( + new ReplacePartitionFieldOp(null, "bucket", 8, "id", "truncate", 4, "name", null)); + Assertions.assertEquals("truncate", c.getTransformName()); + Assertions.assertEquals(4, c.getTransformArg().intValue()); + Assertions.assertEquals("name", c.getColumnName()); + Assertions.assertNull(c.getPartitionFieldName()); + Assertions.assertNull(c.getOldPartitionFieldName()); + Assertions.assertEquals("bucket", c.getOldTransformName()); + Assertions.assertEquals(8, c.getOldTransformArg().intValue()); + Assertions.assertEquals("id", c.getOldColumnName()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/NereidsToConnectorExpressionConverterTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/NereidsToConnectorExpressionConverterTest.java new file mode 100644 index 00000000000000..303f9cd4855016 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/NereidsToConnectorExpressionConverterTest.java @@ -0,0 +1,298 @@ +// 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.connector.converter; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.ScalarType; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorBetween; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorIn; +import org.apache.doris.connector.api.pushdown.ConnectorIsNull; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.ConnectorNot; +import org.apache.doris.connector.api.pushdown.ConnectorOr; +import org.apache.doris.nereids.trees.expressions.And; +import org.apache.doris.nereids.trees.expressions.Between; +import org.apache.doris.nereids.trees.expressions.Cast; +import org.apache.doris.nereids.trees.expressions.EqualTo; +import org.apache.doris.nereids.trees.expressions.GreaterThan; +import org.apache.doris.nereids.trees.expressions.GreaterThanEqual; +import org.apache.doris.nereids.trees.expressions.InPredicate; +import org.apache.doris.nereids.trees.expressions.IsNull; +import org.apache.doris.nereids.trees.expressions.LessThan; +import org.apache.doris.nereids.trees.expressions.LessThanEqual; +import org.apache.doris.nereids.trees.expressions.Not; +import org.apache.doris.nereids.trees.expressions.NullSafeEqual; +import org.apache.doris.nereids.trees.expressions.Or; +import org.apache.doris.nereids.trees.expressions.SlotReference; +import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator; +import org.apache.doris.nereids.trees.expressions.literal.BigIntLiteral; +import org.apache.doris.nereids.trees.expressions.literal.BooleanLiteral; +import org.apache.doris.nereids.trees.expressions.literal.DateTimeV2Literal; +import org.apache.doris.nereids.trees.expressions.literal.DateV2Literal; +import org.apache.doris.nereids.trees.expressions.literal.DecimalV3Literal; +import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral; +import org.apache.doris.nereids.trees.expressions.literal.SmallIntLiteral; +import org.apache.doris.nereids.trees.expressions.literal.TinyIntLiteral; +import org.apache.doris.nereids.trees.expressions.literal.VarcharLiteral; +import org.apache.doris.nereids.types.BigIntType; +import org.apache.doris.nereids.types.DecimalV3Type; + +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * Unit tests for {@link NereidsToConnectorExpressionConverter} (P6.3-T07b, O5-2 production half). + * + *

    The converter mirrors the legacy iceberg conflict matrix + * ({@code IcebergNereidsUtils.convertNereidsToIcebergExpression}): And/Or/Not, the 5 comparisons, + * IN, IS NULL, BETWEEN. Forms the legacy conflict path drops (Cast-wrapped column, NullSafeEqual, + * col-col) yield {@code null} — a safe over-approximation that never narrows the conflict filter + * (no missed conflict). Literal encoding routes through the analyzed-plan-identical + * {@code Expr -> ConnectorExpression} path so type tokens stay byte-identical with the scan side.

    + */ +public class NereidsToConnectorExpressionConverterTest { + + private static SlotReference slot(String name, ScalarType type) { + Column column = new Column(name, type); + return SlotReference.fromColumn( + StatementScopeIdGenerator.newExprId(), Mockito.mock(TableIf.class), column, ImmutableList.of()); + } + + private static ConnectorLiteral rightLiteralOf(ConnectorExpression cmp) { + Assertions.assertInstanceOf(ConnectorComparison.class, cmp); + ConnectorExpression right = ((ConnectorComparison) cmp).getRight(); + Assertions.assertInstanceOf(ConnectorLiteral.class, right); + return (ConnectorLiteral) right; + } + + private static String colNameOf(ConnectorExpression cmp) { + ConnectorExpression left = ((ConnectorComparison) cmp).getLeft(); + Assertions.assertInstanceOf(ConnectorColumnRef.class, left); + return ((ConnectorColumnRef) left).getColumnName(); + } + + // ---- C2: integer literal type tokens must be the UPPERCASE primitive name (int32 != int64) ---- + + @Test + public void intLiteralUsesUppercaseIntToken() { + ConnectorExpression e = NereidsToConnectorExpressionConverter.convert( + new EqualTo(slot("a", ScalarType.INT), new IntegerLiteral(1))); + ConnectorLiteral lit = rightLiteralOf(e); + // The BE-facing type matrix in IcebergPredicateConverter.isInteger32 keys off this exact token; + // a lowercase "integer" would mis-tag int32 as int64 and silently mis-convert. Pin it. + Assertions.assertEquals("INT", lit.getType().getTypeName()); + Assertions.assertEquals(1L, lit.getValue()); + Assertions.assertEquals("a", colNameOf(e)); + Assertions.assertEquals(ConnectorComparison.Operator.EQ, ((ConnectorComparison) e).getOperator()); + } + + @Test + public void integerFamilyTokensDistinguishWidth() { + Assertions.assertEquals("TINYINT", rightLiteralOf(NereidsToConnectorExpressionConverter.convert( + new EqualTo(slot("a", ScalarType.TINYINT), new TinyIntLiteral((byte) 1)))).getType().getTypeName()); + Assertions.assertEquals("SMALLINT", rightLiteralOf(NereidsToConnectorExpressionConverter.convert( + new EqualTo(slot("a", ScalarType.SMALLINT), new SmallIntLiteral((short) 1)))).getType().getTypeName()); + Assertions.assertEquals("BIGINT", rightLiteralOf(NereidsToConnectorExpressionConverter.convert( + new EqualTo(slot("a", ScalarType.BIGINT), new BigIntLiteral(1L)))).getType().getTypeName()); + } + + // ---- C1: DECIMAL literals carry precision/scale (the scan-side extractIcebergLiteral ignores it, + // but the type must still round-trip identically to the analyzed-plan path) ---- + + @Test + public void decimalLiteralCarriesPrecisionAndScale() { + ConnectorLiteral lit = rightLiteralOf(NereidsToConnectorExpressionConverter.convert( + new EqualTo(slot("d", ScalarType.createDecimalV3Type(10, 2)), + new DecimalV3Literal(DecimalV3Type.createDecimalV3Type(10, 2), new BigDecimal("1.23"))))); + Assertions.assertEquals(10, lit.getType().getPrecision()); + Assertions.assertEquals(2, lit.getType().getScale()); + Assertions.assertEquals(new BigDecimal("1.23"), lit.getValue()); + } + + @Test + public void dateAndDatetimeLiteralsBecomeJavaTimeValues() { + ConnectorLiteral date = rightLiteralOf(NereidsToConnectorExpressionConverter.convert( + new EqualTo(slot("dt", ScalarType.DATEV2), new DateV2Literal(2023, 1, 2)))); + Assertions.assertEquals(LocalDate.of(2023, 1, 2), date.getValue()); + + ConnectorLiteral ts = rightLiteralOf(NereidsToConnectorExpressionConverter.convert( + new EqualTo(slot("ts", ScalarType.createDatetimeV2Type(0)), + new DateTimeV2Literal(2024, 1, 2, 12, 34, 56)))); + Assertions.assertEquals(LocalDateTime.of(2024, 1, 2, 12, 34, 56), ts.getValue()); + } + + // ---- node shape mapping ---- + + @Test + public void comparisonOperatorsMap() { + Assertions.assertEquals(ConnectorComparison.Operator.GT, ((ConnectorComparison) + NereidsToConnectorExpressionConverter.convert( + new GreaterThan(slot("a", ScalarType.INT), new IntegerLiteral(1)))).getOperator()); + Assertions.assertEquals(ConnectorComparison.Operator.GE, ((ConnectorComparison) + NereidsToConnectorExpressionConverter.convert( + new GreaterThanEqual(slot("a", ScalarType.INT), new IntegerLiteral(1)))).getOperator()); + Assertions.assertEquals(ConnectorComparison.Operator.LT, ((ConnectorComparison) + NereidsToConnectorExpressionConverter.convert( + new LessThan(slot("a", ScalarType.INT), new IntegerLiteral(1)))).getOperator()); + Assertions.assertEquals(ConnectorComparison.Operator.LE, ((ConnectorComparison) + NereidsToConnectorExpressionConverter.convert( + new LessThanEqual(slot("a", ScalarType.INT), new IntegerLiteral(1)))).getOperator()); + } + + @Test + public void reversedOperandsNormalizeColumnToLeft() { + // `1 = a` must become a column-on-left comparison so the connector (which only pushes + // column-op-literal) can convert it; legacy convertNereidsBinaryPredicate does the same swap. + ConnectorExpression e = NereidsToConnectorExpressionConverter.convert( + new EqualTo(new IntegerLiteral(1), slot("a", ScalarType.INT))); + Assertions.assertEquals("a", colNameOf(e)); + Assertions.assertEquals(1L, rightLiteralOf(e).getValue()); + } + + @Test + public void andFlattensToConnectorAnd() { + ConnectorExpression e = NereidsToConnectorExpressionConverter.convert(new And( + new EqualTo(slot("a", ScalarType.INT), new IntegerLiteral(1)), + new EqualTo(slot("b", ScalarType.INT), new IntegerLiteral(2)))); + Assertions.assertInstanceOf(ConnectorAnd.class, e); + Assertions.assertEquals(2, ((ConnectorAnd) e).getConjuncts().size()); + } + + @Test + public void orMapsToConnectorOr() { + ConnectorExpression e = NereidsToConnectorExpressionConverter.convert(new Or( + new EqualTo(slot("a", ScalarType.INT), new IntegerLiteral(1)), + new EqualTo(slot("a", ScalarType.INT), new IntegerLiteral(2)))); + Assertions.assertInstanceOf(ConnectorOr.class, e); + Assertions.assertEquals(2, ((ConnectorOr) e).getDisjuncts().size()); + } + + @Test + public void orWithUnconvertibleDisjunctDropsEntirelyToNull() { + // O5-2-GAP-006: OR conversion is all-or-nothing — if ANY disjunct is unconvertible (here a NullSafeEqual, + // which nullSafeEqualDropsToNull proves drops to null) the WHOLE OR drops to null. Pushing only the + // convertible disjunct would NARROW the conflict filter (drop the unrepresentable alternative) -> a missed + // conflict -> unsafe. orMapsToConnectorOr covers only the both-disjuncts-convertible case. + Assertions.assertNull(NereidsToConnectorExpressionConverter.convert(new Or( + new EqualTo(slot("a", ScalarType.INT), new IntegerLiteral(1)), + new NullSafeEqual(slot("b", ScalarType.INT), new IntegerLiteral(2))))); + } + + @Test + public void isNullMapsToConnectorIsNullNotNegated() { + ConnectorExpression e = NereidsToConnectorExpressionConverter.convert( + new IsNull(slot("a", ScalarType.INT))); + Assertions.assertInstanceOf(ConnectorIsNull.class, e); + Assertions.assertFalse(((ConnectorIsNull) e).isNegated()); + } + + @Test + public void notIsNullMapsToConnectorNotOfConnectorIsNull() { + // Nereids represents IS NOT NULL as Not(IsNull); preserve that shape so the connector's + // conflict-mode Not-only-IsNull rule lowers it to not(isNull) (legacy parity). + ConnectorExpression e = NereidsToConnectorExpressionConverter.convert( + new Not(new IsNull(slot("a", ScalarType.INT)))); + Assertions.assertInstanceOf(ConnectorNot.class, e); + Assertions.assertInstanceOf(ConnectorIsNull.class, ((ConnectorNot) e).getOperand()); + } + + @Test + public void inMapsToConnectorInNotNegated() { + ConnectorExpression e = NereidsToConnectorExpressionConverter.convert(new InPredicate( + slot("a", ScalarType.INT), ImmutableList.of(new IntegerLiteral(1), new IntegerLiteral(2)))); + Assertions.assertInstanceOf(ConnectorIn.class, e); + ConnectorIn in = (ConnectorIn) e; + Assertions.assertFalse(in.isNegated()); + Assertions.assertEquals(2, in.getInList().size()); + Assertions.assertEquals("a", ((ConnectorColumnRef) in.getValue()).getColumnName()); + } + + @Test + public void betweenMapsToConnectorBetween() { + ConnectorExpression e = NereidsToConnectorExpressionConverter.convert(new Between( + slot("a", ScalarType.INT), new IntegerLiteral(1), new IntegerLiteral(9))); + Assertions.assertInstanceOf(ConnectorBetween.class, e); + ConnectorBetween bt = (ConnectorBetween) e; + Assertions.assertEquals("a", ((ConnectorColumnRef) bt.getValue()).getColumnName()); + Assertions.assertEquals(1L, ((ConnectorLiteral) bt.getLower()).getValue()); + Assertions.assertEquals(9L, ((ConnectorLiteral) bt.getUpper()).getValue()); + } + + // ---- Option A faithfulness: forms legacy conflict path does not handle drop to null (safe) ---- + + @Test + public void castWrappedColumnDropsToNull() { + // Legacy convertNereidsBinaryPredicate requires a bare Slot; a Cast-wrapped column is not + // pushable. Unwrapping it would push MORE than legacy -> narrower conflict filter -> unsafe. + Assertions.assertNull(NereidsToConnectorExpressionConverter.convert( + new EqualTo(new Cast(slot("a", ScalarType.INT), BigIntType.INSTANCE), new BigIntLiteral(1L)))); + } + + @Test + public void nullSafeEqualDropsToNull() { + Assertions.assertNull(NereidsToConnectorExpressionConverter.convert( + new NullSafeEqual(slot("a", ScalarType.INT), new IntegerLiteral(1)))); + } + + @Test + public void columnToColumnComparisonDropsToNull() { + Assertions.assertNull(NereidsToConnectorExpressionConverter.convert( + new EqualTo(slot("a", ScalarType.INT), slot("b", ScalarType.INT)))); + } + + @Test + public void booleanLiteralAloneDropsToNull() { + Assertions.assertNull(NereidsToConnectorExpressionConverter.convert(BooleanLiteral.of(true))); + } + + // ---- literal-encoding parity with the analyzed-plan-side ExprToConnectorExpressionConverter ---- + + @Test + public void literalEncodingMatchesExprConverter() { + // The neutral ConnectorLiteral a comparison carries must be byte-identical to what the scan-side + // ExprToConnectorExpressionConverter produces for the same literal, so the connector's shared + // IcebergPredicateConverter type matrix behaves identically for both paths. + IntegerLiteral nereidsLit = new IntegerLiteral(7); + ConnectorLiteral viaNereids = rightLiteralOf(NereidsToConnectorExpressionConverter.convert( + new EqualTo(slot("a", ScalarType.INT), nereidsLit))); + ConnectorExpression viaExpr = ExprToConnectorExpressionConverter.convert(nereidsLit.toLegacyLiteral()); + Assertions.assertEquals(viaExpr, viaNereids); + + VarcharLiteral nereidsStr = new VarcharLiteral("abc"); + ConnectorLiteral strViaNereids = rightLiteralOf(NereidsToConnectorExpressionConverter.convert( + new EqualTo(slot("s", ScalarType.createVarcharType(10)), nereidsStr))); + ConnectorExpression strViaExpr = ExprToConnectorExpressionConverter.convert(nereidsStr.toLegacyLiteral()); + Assertions.assertEquals(strViaExpr, strViaNereids); + } + + @Test + public void nullInputReturnsNull() { + Assertions.assertNull(NereidsToConnectorExpressionConverter.convert(null)); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/UnboundExpressionToConnectorPredicateConverterTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/UnboundExpressionToConnectorPredicateConverterTest.java new file mode 100644 index 00000000000000..b232827ada9e1a --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/UnboundExpressionToConnectorPredicateConverterTest.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.datasource.connector.converter; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.ScalarType; +import org.apache.doris.common.AnalysisException; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorBetween; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorIn; +import org.apache.doris.connector.api.pushdown.ConnectorIsNull; +import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.doris.connector.api.pushdown.ConnectorOr; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.nereids.analyzer.UnboundSlot; +import org.apache.doris.nereids.trees.expressions.And; +import org.apache.doris.nereids.trees.expressions.Between; +import org.apache.doris.nereids.trees.expressions.EqualTo; +import org.apache.doris.nereids.trees.expressions.GreaterThan; +import org.apache.doris.nereids.trees.expressions.InPredicate; +import org.apache.doris.nereids.trees.expressions.IsNull; +import org.apache.doris.nereids.trees.expressions.Or; +import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral; + +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * Unit tests for {@link UnboundExpressionToConnectorPredicateConverter} (WS-REWRITE R7). + * + *

    WHY this matters: the {@code ALTER TABLE EXECUTE rewrite_data_files(...) WHERE } predicate + * arrives UNBOUND ({@link UnboundSlot}, never analysed), so the bound-slot + * {@code NereidsToConnectorExpressionConverter} would silently drop every leaf and rewrite the whole table. + * This converter resolves each column by name against the target table and is strictly FAIL-LOUD — it throws + * rather than emit a partial/empty predicate that would widen the rewrite scope. These tests pin that the + * common WHERE shapes lower to a non-null neutral predicate (the regression guard) and that every + * unrepresentable shape throws (the "never widen" guarantee).

    + */ +public class UnboundExpressionToConnectorPredicateConverterTest { + + private static ExternalTable table() { + ExternalTable table = Mockito.mock(ExternalTable.class); + Mockito.when(table.getName()).thenReturn("t"); + Mockito.when(table.getColumn("a")).thenReturn(new Column("a", ScalarType.INT)); + Mockito.when(table.getColumn("b")).thenReturn(new Column("b", ScalarType.INT)); + Mockito.when(table.getColumn("s")).thenReturn(new Column("s", ScalarType.createVarcharType(20))); + // "c" is intentionally not stubbed -> getColumn("c") returns null (unknown column). + return table; + } + + private static UnboundSlot col(String name) { + return new UnboundSlot(name); + } + + // ---- common WHERE shapes lower to a non-null neutral predicate (the F2 regression guard) ---- + + @Test + public void unboundComparisonLowersToConnectorComparison() throws Exception { + // The crux: an UnboundSlot comparison must NOT silently drop (that would scope the rewrite to the whole + // table). It must produce a real ConnectorComparison with the column resolved by name + its real type. + ConnectorPredicate predicate = UnboundExpressionToConnectorPredicateConverter.convert( + new GreaterThan(col("a"), new IntegerLiteral(5)), table()); + ConnectorExpression expr = predicate.getExpression(); + Assertions.assertInstanceOf(ConnectorComparison.class, expr); + ConnectorComparison cmp = (ConnectorComparison) expr; + Assertions.assertEquals(ConnectorComparison.Operator.GT, cmp.getOperator()); + Assertions.assertInstanceOf(ConnectorColumnRef.class, cmp.getLeft()); + Assertions.assertEquals("a", ((ConnectorColumnRef) cmp.getLeft()).getColumnName()); + Assertions.assertInstanceOf(ConnectorLiteral.class, cmp.getRight()); + Assertions.assertEquals(5L, ((ConnectorLiteral) cmp.getRight()).getValue()); + // The column-ref type is the table column's real type (resolved from the schema), not a placeholder — + // it equals what the analyzed-plan-side converter would produce for the same Doris type. + Assertions.assertEquals(ExprToConnectorExpressionConverter.typeToConnectorType(ScalarType.INT), + ((ConnectorColumnRef) cmp.getLeft()).getType()); + } + + @Test + public void andLowersEveryConjunct() throws Exception { + ConnectorExpression expr = UnboundExpressionToConnectorPredicateConverter.convert( + new And(new EqualTo(col("a"), new IntegerLiteral(1)), + new EqualTo(col("b"), new IntegerLiteral(2))), table()).getExpression(); + Assertions.assertInstanceOf(ConnectorAnd.class, expr); + Assertions.assertEquals(2, ((ConnectorAnd) expr).getConjuncts().size()); + } + + @Test + public void inLowersToConnectorIn() throws Exception { + ConnectorExpression expr = UnboundExpressionToConnectorPredicateConverter.convert(new InPredicate( + col("a"), ImmutableList.of(new IntegerLiteral(1), new IntegerLiteral(2))), table()).getExpression(); + Assertions.assertInstanceOf(ConnectorIn.class, expr); + Assertions.assertEquals("a", ((ConnectorColumnRef) ((ConnectorIn) expr).getValue()).getColumnName()); + } + + @Test + public void isNullLowersToConnectorIsNull() throws Exception { + ConnectorExpression expr = UnboundExpressionToConnectorPredicateConverter.convert( + new IsNull(col("a")), table()).getExpression(); + Assertions.assertInstanceOf(ConnectorIsNull.class, expr); + } + + @Test + public void betweenLowersToConnectorBetween() throws Exception { + ConnectorExpression expr = UnboundExpressionToConnectorPredicateConverter.convert(new Between( + col("a"), new IntegerLiteral(1), new IntegerLiteral(9)), table()).getExpression(); + Assertions.assertInstanceOf(ConnectorBetween.class, expr); + } + + @Test + public void crossColumnOrIsRepresentedByFeCore() throws Exception { + // The two-layer split: fe-core REPRESENTS a cross-column OR (it does not know iceberg's pushdown limits + // — iron law). The connector's RewriteDataFilePlanner is the layer that rejects an un-pushable conjunct. + // So fe-core must NOT throw here (only the connector does), or the layers would double-reject differently. + ConnectorExpression expr = UnboundExpressionToConnectorPredicateConverter.convert( + new Or(new EqualTo(col("a"), new IntegerLiteral(1)), + new EqualTo(col("b"), new IntegerLiteral(2))), table()).getExpression(); + Assertions.assertInstanceOf(ConnectorOr.class, expr); + Assertions.assertEquals(2, ((ConnectorOr) expr).getDisjuncts().size()); + } + + // ---- fail-loud: an unrepresentable WHERE throws rather than widening the rewrite scope ---- + + @Test + public void unsupportedNodeThrows() { + // A column-to-column comparison cannot be represented (no literal). Dropping it would leave an empty + // predicate -> whole-table rewrite, so it must throw (legacy live-rewrite parity). + Assertions.assertThrows(AnalysisException.class, () -> + UnboundExpressionToConnectorPredicateConverter.convert(new EqualTo(col("a"), col("b")), table())); + } + + @Test + public void partialAndThrowsAllOrNothing() { + // One good conjunct (a=1) and one unrepresentable (col-col). The converter must NOT keep just the good + // one (that would widen the rewrite past the user's WHERE); it must fail the whole predicate. + Assertions.assertThrows(AnalysisException.class, () -> + UnboundExpressionToConnectorPredicateConverter.convert( + new And(new EqualTo(col("a"), new IntegerLiteral(1)), + new EqualTo(col("a"), col("b"))), table())); + } + + @Test + public void unknownColumnThrows() { + AnalysisException e = Assertions.assertThrows(AnalysisException.class, () -> + UnboundExpressionToConnectorPredicateConverter.convert( + new EqualTo(col("c"), new IntegerLiteral(1)), table())); + Assertions.assertTrue(e.getMessage().contains("Column not found"), + "an unknown column must fail loud with a clear message, not silently drop"); + } + + @Test + public void multiPartColumnThrows() { + // `t.a` (a qualified reference) is not a bare column name; legacy IcebergNereidsUtils.extractColumnName + // rejected multi-part too. A silent drop here would again widen the rewrite. + Assertions.assertThrows(AnalysisException.class, () -> + UnboundExpressionToConnectorPredicateConverter.convert( + new EqualTo(new UnboundSlot("t", "a"), new IntegerLiteral(1)), table())); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/WriteConstraintExtractorTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/WriteConstraintExtractorTest.java new file mode 100644 index 00000000000000..1d47ae5ac4eb02 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/connector/converter/WriteConstraintExtractorTest.java @@ -0,0 +1,233 @@ +// 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.connector.converter; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.ScalarType; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.connector.api.pushdown.ConnectorAnd; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.nereids.trees.expressions.EqualTo; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.expressions.GreaterThan; +import org.apache.doris.nereids.trees.expressions.NamedExpression; +import org.apache.doris.nereids.trees.expressions.SlotReference; +import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator; +import org.apache.doris.nereids.trees.expressions.literal.BooleanLiteral; +import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral; +import org.apache.doris.nereids.trees.plans.Plan; +import org.apache.doris.nereids.trees.plans.RelationId; +import org.apache.doris.nereids.trees.plans.logical.LogicalEmptyRelation; +import org.apache.doris.nereids.trees.plans.logical.LogicalFilter; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; + +/** + * Unit tests for {@link WriteConstraintExtractor} (P6.3-T07b, O5-2 production half). + * + *

    The extractor walks an analyzed DELETE/UPDATE/MERGE plan and keeps only the conjuncts that reference + * solely the target table's own columns (slot origin-table == target), excluding synthetic / metadata + * columns via an injected {@link Predicate} (the iceberg-specific exclusion is supplied by the row-level DML + * transform in T07c — here a generic name-based predicate stands in). It mirrors the generic collection half + * of legacy {@code IcebergConflictDetectionFilterUtils} but is connector-neutral + * ({@code long targetTableId} + neutral {@link ConnectorPredicate} output).

    + */ +public class WriteConstraintExtractorTest { + + private static final long TARGET_ID = 1L; + private static final Predicate NO_EXCLUSION = s -> false; + + private TableIf targetTable; + + @Before + public void setUp() { + targetTable = Mockito.mock(TableIf.class); + Mockito.when(targetTable.getId()).thenReturn(TARGET_ID); + } + + private SlotReference slot(TableIf table, String name, ScalarType type) { + Column column = new Column(name, type); + return SlotReference.fromColumn(StatementScopeIdGenerator.newExprId(), table, column, ImmutableList.of()); + } + + private Plan filterOver(Set conjuncts, SlotReference output) { + LogicalEmptyRelation child = new LogicalEmptyRelation(new RelationId(0), + ImmutableList.of((NamedExpression) output)); + return new LogicalFilter<>(conjuncts, child); + } + + @Test + public void targetOnlyPredicateIsKept() { + SlotReference slot = slot(targetTable, "id", ScalarType.INT); + Plan plan = filterOver(ImmutableSet.of(new EqualTo(slot, new IntegerLiteral(1))), slot); + + Optional result = WriteConstraintExtractor.extract(plan, TARGET_ID, NO_EXCLUSION); + + Assert.assertTrue(result.isPresent()); + ConnectorExpression expr = result.get().getExpression(); + Assert.assertTrue(expr instanceof ConnectorComparison); + ConnectorComparison cmp = (ConnectorComparison) expr; + Assert.assertEquals(ConnectorComparison.Operator.EQ, cmp.getOperator()); + Assert.assertEquals("id", ((ConnectorColumnRef) cmp.getLeft()).getColumnName()); + } + + @Test + public void crossTablePredicateIsDropped() { + TableIf other = Mockito.mock(TableIf.class); + Mockito.when(other.getId()).thenReturn(2L); + SlotReference slot = slot(other, "id", ScalarType.INT); + Plan plan = filterOver(ImmutableSet.of(new EqualTo(slot, new IntegerLiteral(1))), slot); + + Assert.assertFalse(WriteConstraintExtractor.extract(plan, TARGET_ID, NO_EXCLUSION).isPresent()); + } + + @Test + public void mixedConjunctsKeepOnlyTargetArm() { + TableIf other = Mockito.mock(TableIf.class); + Mockito.when(other.getId()).thenReturn(2L); + SlotReference targetSlot = slot(targetTable, "id", ScalarType.INT); + SlotReference otherSlot = slot(other, "id", ScalarType.INT); + Set conjuncts = ImmutableSet.of( + new EqualTo(targetSlot, new IntegerLiteral(1)), + new EqualTo(otherSlot, new IntegerLiteral(2))); + Plan plan = filterOver(conjuncts, targetSlot); + + Optional result = WriteConstraintExtractor.extract(plan, TARGET_ID, NO_EXCLUSION); + + Assert.assertTrue(result.isPresent()); + // only the single target-arm survives -> a lone comparison, not an AND of both + Assert.assertTrue(result.get().getExpression() instanceof ConnectorComparison); + } + + @Test + public void multipleTargetConjunctsAreAnded() { + SlotReference a = slot(targetTable, "id", ScalarType.INT); + SlotReference b = slot(targetTable, "v", ScalarType.INT); + Set conjuncts = ImmutableSet.of( + new EqualTo(a, new IntegerLiteral(1)), + new GreaterThan(b, new IntegerLiteral(2))); + Plan plan = filterOver(conjuncts, a); + + Optional result = WriteConstraintExtractor.extract(plan, TARGET_ID, NO_EXCLUSION); + + Assert.assertTrue(result.isPresent()); + Assert.assertTrue(result.get().getExpression() instanceof ConnectorAnd); + Assert.assertEquals(2, ((ConnectorAnd) result.get().getExpression()).getConjuncts().size()); + } + + @Test + public void injectedExclusionDropsSyntheticColumnConjunct() { + // Load-bearing (critic finding 1 = BLOCKER): a synthetic column slot built via fromColumn has + // getOriginalTable() == target, so the origin-table check alone would let it through. Only the + // injected exclusion predicate drops it. Prove both halves. + SlotReference synthetic = slot(targetTable, "rowid_col", ScalarType.INT); + Plan plan = filterOver(ImmutableSet.of(new EqualTo(synthetic, new IntegerLiteral(1))), synthetic); + + Assert.assertTrue("without exclusion the synthetic-column conjunct slips through", + WriteConstraintExtractor.extract(plan, TARGET_ID, NO_EXCLUSION).isPresent()); + + Predicate excludeRowId = s -> "rowid_col".equalsIgnoreCase(s.getName()); + Assert.assertFalse("the injected exclusion predicate must drop the synthetic-column conjunct", + WriteConstraintExtractor.extract(plan, TARGET_ID, excludeRowId).isPresent()); + } + + @Test + public void targetConjunctUnrepresentableByConverterIsDropped() { + // a target-only predicate the neutral converter cannot represent (column-to-column) yields nothing + SlotReference a = slot(targetTable, "id", ScalarType.INT); + SlotReference b = slot(targetTable, "v", ScalarType.INT); + Plan plan = filterOver(ImmutableSet.of(new EqualTo(a, b)), a); + + Assert.assertFalse(WriteConstraintExtractor.extract(plan, TARGET_ID, NO_EXCLUSION).isPresent()); + } + + @Test + public void targetConjunctsDropOnlyTheUnconvertibleArm() { + // O5-2-GAP-001: with two TARGET-only conjuncts where one is convertible (id = 1) and the other is an + // unconvertible column-to-column comparison (v = w), the converter drops only the unconvertible conjunct + // (per-conjunct, NOT the whole AND) -> a lone surviving comparison. multipleTargetConjunctsAreAnded covers + // two convertible arms; targetConjunctUnrepresentableByConverterIsDropped covers a single unconvertible + // arm; neither covers per-conjunct drop inside a multi-conjunct AND (which only widens the filter -> safe). + SlotReference id = slot(targetTable, "id", ScalarType.INT); + SlotReference v = slot(targetTable, "v", ScalarType.INT); + SlotReference w = slot(targetTable, "w", ScalarType.INT); + Set conjuncts = ImmutableSet.of( + new EqualTo(id, new IntegerLiteral(1)), // convertible + new EqualTo(v, w)); // target-only, column-to-column -> unconvertible + + Optional result = + WriteConstraintExtractor.extract(filterOver(conjuncts, id), TARGET_ID, NO_EXCLUSION); + + Assert.assertTrue("the convertible target conjunct survives", result.isPresent()); + Assert.assertTrue("only the convertible arm remains -> a lone comparison, not an AND of one", + result.get().getExpression() instanceof ConnectorComparison); + Assert.assertEquals("id", + ((ConnectorColumnRef) ((ConnectorComparison) result.get().getExpression()).getLeft()) + .getColumnName()); + } + + @Test + public void conjunctWithoutInputSlotsIsDropped() { + SlotReference slot = slot(targetTable, "id", ScalarType.INT); + Plan plan = filterOver(ImmutableSet.of(BooleanLiteral.of(true)), slot); + + Assert.assertFalse(WriteConstraintExtractor.extract(plan, TARGET_ID, NO_EXCLUSION).isPresent()); + } + + @Test + public void recursesIntoChildFilters() { + SlotReference slot = slot(targetTable, "id", ScalarType.INT); + LogicalEmptyRelation leaf = new LogicalEmptyRelation(new RelationId(0), + ImmutableList.of((NamedExpression) slot)); + LogicalFilter inner = new LogicalFilter<>( + ImmutableSet.of(new EqualTo(slot, new IntegerLiteral(1))), leaf); + LogicalFilter outer = new LogicalFilter<>( + ImmutableSet.of(new GreaterThan(slot, new IntegerLiteral(0))), inner); + + Optional result = WriteConstraintExtractor.extract(outer, TARGET_ID, NO_EXCLUSION); + + Assert.assertTrue(result.isPresent()); + Assert.assertTrue(result.get().getExpression() instanceof ConnectorAnd); + Assert.assertEquals(2, ((ConnectorAnd) result.get().getExpression()).getConjuncts().size()); + } + + @Test + public void planWithoutFilterReturnsEmpty() { + SlotReference slot = slot(targetTable, "id", ScalarType.INT); + Plan plan = new LogicalEmptyRelation(new RelationId(0), ImmutableList.of((NamedExpression) slot)); + + Assert.assertFalse(WriteConstraintExtractor.extract(plan, TARGET_ID, NO_EXCLUSION).isPresent()); + } + + @Test + public void nullPlanReturnsEmpty() { + Assert.assertFalse(WriteConstraintExtractor.extract(null, TARGET_ID, NO_EXCLUSION).isPresent()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/credentials/AbstractVendedCredentialsProviderTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/credentials/AbstractVendedCredentialsProviderTest.java deleted file mode 100644 index 7b2f123f953b73..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/credentials/AbstractVendedCredentialsProviderTest.java +++ /dev/null @@ -1,297 +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.datasource.credentials; - -import org.apache.doris.datasource.property.metastore.MetastoreProperties; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.datasource.property.storage.StorageProperties.Type; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.HashMap; -import java.util.Map; - -public class AbstractVendedCredentialsProviderTest { - - /** - * Test implementation of AbstractVendedCredentialsProvider for testing purposes - */ - private static class TestVendedCredentialsProvider extends AbstractVendedCredentialsProvider { - private boolean isVendedCredentialsEnabledResult = true; - private Map rawVendedCredentialsResult = new HashMap<>(); - private String tableNameResult = "test_table"; - - public void setVendedCredentialsEnabledResult(boolean result) { - this.isVendedCredentialsEnabledResult = result; - } - - public void setRawVendedCredentialsResult(Map result) { - this.rawVendedCredentialsResult = result; - } - - public void setTableNameResult(String result) { - this.tableNameResult = result; - } - - @Override - public boolean isVendedCredentialsEnabled(MetastoreProperties metastoreProperties) { - return isVendedCredentialsEnabledResult; - } - - @Override - protected Map extractRawVendedCredentials(T tableObject) { - return rawVendedCredentialsResult; - } - - @Override - protected String getTableName(T tableObject) { - return tableNameResult; - } - } - - @Test - public void testGetStoragePropertiesMapWithVendedCredentialsSuccess() { - TestVendedCredentialsProvider provider = new TestVendedCredentialsProvider(); - - // Setup test data - Map rawCredentials = new HashMap<>(); - rawCredentials.put("s3.access-key-id", "testAccessKey"); - rawCredentials.put("s3.secret-access-key", "testSecretKey"); - rawCredentials.put("s3.region", "us-west-2"); - - provider.setVendedCredentialsEnabledResult(true); - provider.setRawVendedCredentialsResult(rawCredentials); - - MetastoreProperties metastoreProperties = Mockito.mock(MetastoreProperties.class); - Object tableObject = new Object(); - - Map result = provider.getStoragePropertiesMapWithVendedCredentials( - metastoreProperties, tableObject); - - // Note: The actual result depends on StorageProperties.createAll() implementation - // At minimum, it should not be null and should attempt to process the credentials - Assertions.assertNotNull(result); - } - - @Test - public void testGetStoragePropertiesMapWithVendedCredentialsDisabled() { - TestVendedCredentialsProvider provider = new TestVendedCredentialsProvider(); - provider.setVendedCredentialsEnabledResult(false); - - MetastoreProperties metastoreProperties = Mockito.mock(MetastoreProperties.class); - Object tableObject = new Object(); - - Map result = provider.getStoragePropertiesMapWithVendedCredentials( - metastoreProperties, tableObject); - - Assertions.assertNull(result); - } - - @Test - public void testGetStoragePropertiesMapWithNullTableObject() { - TestVendedCredentialsProvider provider = new TestVendedCredentialsProvider(); - provider.setVendedCredentialsEnabledResult(true); - - MetastoreProperties metastoreProperties = Mockito.mock(MetastoreProperties.class); - - Map result = provider.getStoragePropertiesMapWithVendedCredentials( - metastoreProperties, null); - - Assertions.assertNull(result); - } - - @Test - public void testGetStoragePropertiesMapWithEmptyRawCredentials() { - TestVendedCredentialsProvider provider = new TestVendedCredentialsProvider(); - provider.setVendedCredentialsEnabledResult(true); - provider.setRawVendedCredentialsResult(new HashMap<>()); // Empty map - - MetastoreProperties metastoreProperties = Mockito.mock(MetastoreProperties.class); - Object tableObject = new Object(); - - Map result = provider.getStoragePropertiesMapWithVendedCredentials( - metastoreProperties, tableObject); - - Assertions.assertNull(result); - } - - @Test - public void testGetStoragePropertiesMapWithFilteredCredentials() { - TestVendedCredentialsProvider provider = new TestVendedCredentialsProvider(); - - // Setup credentials with mixed properties (some will be filtered out) - Map rawCredentials = new HashMap<>(); - rawCredentials.put("s3.access-key-id", "testAccessKey"); - rawCredentials.put("s3.secret-access-key", "testSecretKey"); - rawCredentials.put("table.name", "test_table"); // Should be filtered out - rawCredentials.put("other.property", "other_value"); // Should be filtered out - - provider.setVendedCredentialsEnabledResult(true); - provider.setRawVendedCredentialsResult(rawCredentials); - - MetastoreProperties metastoreProperties = Mockito.mock(MetastoreProperties.class); - Object tableObject = new Object(); - - Map result = provider.getStoragePropertiesMapWithVendedCredentials( - metastoreProperties, tableObject); - - // The filtering should happen internally via CredentialUtils.filterCloudStorageProperties() - Assertions.assertNotNull(result); - } - - @Test - public void testGetStoragePropertiesMapWithOnlyNonCloudStorageProperties() { - TestVendedCredentialsProvider provider = new TestVendedCredentialsProvider(); - - // Setup credentials with only non-cloud storage properties - Map rawCredentials = new HashMap<>(); - rawCredentials.put("table.name", "test_table"); - rawCredentials.put("database.name", "test_db"); - rawCredentials.put("other.property", "other_value"); - - provider.setVendedCredentialsEnabledResult(true); - provider.setRawVendedCredentialsResult(rawCredentials); - - MetastoreProperties metastoreProperties = Mockito.mock(MetastoreProperties.class); - Object tableObject = new Object(); - - Map result = provider.getStoragePropertiesMapWithVendedCredentials( - metastoreProperties, tableObject); - - // Should return null since no cloud storage properties after filtering - Assertions.assertNull(result); - } - - @Test - public void testGetStoragePropertiesMapWithNullRawCredentials() { - TestVendedCredentialsProvider provider = new TestVendedCredentialsProvider(); - provider.setVendedCredentialsEnabledResult(true); - provider.setRawVendedCredentialsResult(null); - - MetastoreProperties metastoreProperties = Mockito.mock(MetastoreProperties.class); - Object tableObject = new Object(); - - Map result = provider.getStoragePropertiesMapWithVendedCredentials( - metastoreProperties, tableObject); - - Assertions.assertNull(result); - } - - @Test - public void testGetStoragePropertiesMapWithExceptionHandling() { - // Test the case where extractRawVendedCredentials returns null (simulating an internal failure) - AbstractVendedCredentialsProvider provider = new AbstractVendedCredentialsProvider() { - @Override - public boolean isVendedCredentialsEnabled(MetastoreProperties metastoreProperties) { - return true; - } - - @Override - protected Map extractRawVendedCredentials(T tableObject) { - // Return null to simulate extraction failure (like network timeout, invalid response, etc.) - return null; - } - - @Override - protected String getTableName(T tableObject) { - return "test_table"; - } - }; - - MetastoreProperties metastoreProperties = Mockito.mock(MetastoreProperties.class); - Object tableObject = new Object(); - - // Should handle null credentials gracefully and return null - Map result = provider.getStoragePropertiesMapWithVendedCredentials( - metastoreProperties, tableObject); - - Assertions.assertNull(result); - } - - @Test - public void testDefaultGetTableNameImplementation() { - // Create a minimal provider that doesn't override getTableName() to test the default implementation - AbstractVendedCredentialsProvider provider = new AbstractVendedCredentialsProvider() { - @Override - public boolean isVendedCredentialsEnabled(MetastoreProperties metastoreProperties) { - return true; - } - - @Override - protected Map extractRawVendedCredentials(T tableObject) { - return new HashMap<>(); - } - }; - - // Test with null object - String result1 = provider.getTableName(null); - Assertions.assertEquals("null", result1); - - // Test with non-null object (should use the default implementation) - Object tableObject = new Object(); - String result2 = provider.getTableName(tableObject); - Assertions.assertEquals(tableObject.toString(), result2); // Default implementation returns toString() - } - - @Test - public void testAbstractMethodsAreImplemented() { - // Verify that our test implementation correctly implements all abstract methods - TestVendedCredentialsProvider provider = new TestVendedCredentialsProvider(); - - MetastoreProperties metastoreProperties = Mockito.mock(MetastoreProperties.class); - Object tableObject = new Object(); - - // These should not throw AbstractMethodError - Assertions.assertDoesNotThrow(() -> { - provider.isVendedCredentialsEnabled(metastoreProperties); - provider.extractRawVendedCredentials(tableObject); - provider.getTableName(tableObject); - }); - } - - @Test - public void testWorkflowWithMultipleCloudStorageTypes() { - TestVendedCredentialsProvider provider = new TestVendedCredentialsProvider(); - - // Setup credentials with multiple cloud storage types - Map rawCredentials = new HashMap<>(); - rawCredentials.put("s3.access-key-id", "s3AccessKey"); - rawCredentials.put("s3.secret-access-key", "s3SecretKey"); - rawCredentials.put("s3.region", "us-west-2"); - rawCredentials.put("oss.access-key-id", "ossAccessKey"); - rawCredentials.put("oss.secret-access-key", "ossSecretKey"); - rawCredentials.put("oss.endpoint", "oss-cn-beijing.aliyuncs.com"); - rawCredentials.put("cos.access-key", "cosAccessKey"); - rawCredentials.put("cos.secret-key", "cosSecretKey"); - rawCredentials.put("non.cloud.property", "should_be_filtered"); - - provider.setVendedCredentialsEnabledResult(true); - provider.setRawVendedCredentialsResult(rawCredentials); - - MetastoreProperties metastoreProperties = Mockito.mock(MetastoreProperties.class); - Object tableObject = new Object(); - - Map result = provider.getStoragePropertiesMapWithVendedCredentials( - metastoreProperties, tableObject); - - // Should process multiple cloud storage types - Assertions.assertNotNull(result); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/credentials/VendedCredentialsFactoryTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/credentials/VendedCredentialsFactoryTest.java deleted file mode 100644 index edde53ac8ae63d..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/credentials/VendedCredentialsFactoryTest.java +++ /dev/null @@ -1,213 +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.datasource.credentials; - -import org.apache.doris.datasource.property.metastore.IcebergRestProperties; -import org.apache.doris.datasource.property.metastore.MetastoreProperties; -import org.apache.doris.datasource.property.metastore.PaimonRestMetaStoreProperties; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.datasource.property.storage.StorageProperties.Type; - -import org.apache.iceberg.Table; -import org.apache.iceberg.io.FileIO; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.HashMap; -import java.util.Map; - -public class VendedCredentialsFactoryTest { - - @Test - public void testGetStoragePropertiesMapWithVendedCredentialsForIceberg() { - // Mock Iceberg REST properties - IcebergRestProperties icebergProperties = Mockito.mock(IcebergRestProperties.class); - Mockito.when(icebergProperties.getType()).thenReturn(MetastoreProperties.Type.ICEBERG); - Mockito.when(icebergProperties.isIcebergRestVendedCredentialsEnabled()).thenReturn(true); - - // Mock table with vended credentials - Table table = Mockito.mock(Table.class); - FileIO fileIO = Mockito.mock(FileIO.class); - - Map ioProperties = new HashMap<>(); - ioProperties.put("s3.access-key-id", "testAccessKey"); - ioProperties.put("s3.secret-access-key", "testSecretKey"); - ioProperties.put("s3.region", "us-west-2"); - - Mockito.when(table.io()).thenReturn(fileIO); - Mockito.when(fileIO.properties()).thenReturn(ioProperties); - - Map baseStorageMap = new HashMap<>(); - - Map result = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(icebergProperties, baseStorageMap, table); - - // Should return the result from IcebergVendedCredentialsProvider or fall back to base map - Assertions.assertNotNull(result); - } - - @Test - public void testGetStoragePropertiesMapWithVendedCredentialsForPaimon() { - // Mock Paimon REST properties - PaimonRestMetaStoreProperties paimonProperties = Mockito.mock(PaimonRestMetaStoreProperties.class); - Mockito.when(paimonProperties.getType()).thenReturn(MetastoreProperties.Type.PAIMON); - Mockito.when(paimonProperties.getTokenProvider()).thenReturn("dlf"); - - // Mock Paimon table - org.apache.paimon.table.Table paimonTable = Mockito.mock(org.apache.paimon.table.Table.class); - - Map baseStorageMap = new HashMap<>(); - - Map result = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(paimonProperties, baseStorageMap, paimonTable); - - // Should return the result from PaimonVendedCredentialsProvider or fall back to base map - Assertions.assertNotNull(result); - } - - @Test - public void testGetStoragePropertiesMapWithVendedCredentialsForUnsupportedType() { - // Mock unsupported metastore type (e.g., HMS) - MetastoreProperties hmsProperties = Mockito.mock(MetastoreProperties.class); - Mockito.when(hmsProperties.getType()).thenReturn(MetastoreProperties.Type.HMS); - - Object table = new Object(); - Map baseStorageMap = new HashMap<>(); - - Map result = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(hmsProperties, baseStorageMap, table); - - // Should return the base storage map for unsupported types - Assertions.assertEquals(baseStorageMap, result); - } - - @Test - public void testGetStoragePropertiesMapWithVendedCredentialsWithNullMetastore() { - Object table = new Object(); - Map baseStorageMap = new HashMap<>(); - - Map result = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(null, baseStorageMap, table); - - // Should return the base storage map when metastore is null - Assertions.assertEquals(baseStorageMap, result); - } - - @Test - public void testGetStoragePropertiesMapWithVendedCredentialsWithNullTable() { - IcebergRestProperties icebergProperties = Mockito.mock(IcebergRestProperties.class); - Mockito.when(icebergProperties.getType()).thenReturn(MetastoreProperties.Type.ICEBERG); - Mockito.when(icebergProperties.isIcebergRestVendedCredentialsEnabled()).thenReturn(true); - - Map baseStorageMap = new HashMap<>(); - - Map result = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(icebergProperties, baseStorageMap, null); - - // Should return the base storage map when table is null - Assertions.assertEquals(baseStorageMap, result); - } - - @Test - public void testGetStoragePropertiesMapWithVendedCredentialsWithException() { - // Mock properties that will cause an exception in the provider - MetastoreProperties problematicProperties = Mockito.mock(MetastoreProperties.class); - Mockito.when(problematicProperties.getType()).thenReturn(MetastoreProperties.Type.ICEBERG); - - Object table = new Object(); // Wrong type will cause ClassCastException - Map baseStorageMap = new HashMap<>(); - - Map result = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(problematicProperties, baseStorageMap, table); - - // Should return the base storage map when there's an exception - Assertions.assertEquals(baseStorageMap, result); - } - - @Test - public void testGetStoragePropertiesMapWithNonEmptyBaseStorageMap() { - // Create base storage map with some properties - StorageProperties baseS3Properties = Mockito.mock(StorageProperties.class); - Map baseStorageMap = new HashMap<>(); - baseStorageMap.put(Type.S3, baseS3Properties); - - // Mock unsupported metastore type - MetastoreProperties hmsProperties = Mockito.mock(MetastoreProperties.class); - Mockito.when(hmsProperties.getType()).thenReturn(MetastoreProperties.Type.HMS); - - Object table = new Object(); - - Map result = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(hmsProperties, baseStorageMap, table); - - // Should return the base storage map - Assertions.assertEquals(baseStorageMap, result); - Assertions.assertEquals(1, result.size()); - Assertions.assertEquals(baseS3Properties, result.get(Type.S3)); - } - - @Test - public void testGetStoragePropertiesMapWithIcebergVendedCredentialsDisabled() { - IcebergRestProperties icebergProperties = Mockito.mock(IcebergRestProperties.class); - Mockito.when(icebergProperties.getType()).thenReturn(MetastoreProperties.Type.ICEBERG); - Mockito.when(icebergProperties.isIcebergRestVendedCredentialsEnabled()).thenReturn(false); - - Table table = Mockito.mock(Table.class); - Map baseStorageMap = new HashMap<>(); - - Map result = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(icebergProperties, baseStorageMap, table); - - // Should return the base storage map when vended credentials are disabled - Assertions.assertEquals(baseStorageMap, result); - } - - @Test - public void testGetProviderTypeReturnsCorrectProvider() { - // Note: getProviderType is private, but we can test it indirectly through the public method - - // Test Iceberg type - IcebergRestProperties icebergProperties = Mockito.mock(IcebergRestProperties.class); - Mockito.when(icebergProperties.getType()).thenReturn(MetastoreProperties.Type.ICEBERG); - Mockito.when(icebergProperties.isIcebergRestVendedCredentialsEnabled()).thenReturn(true); - - Table icebergTable = Mockito.mock(Table.class); - FileIO fileIO = Mockito.mock(FileIO.class); - Mockito.when(icebergTable.io()).thenReturn(fileIO); - Mockito.when(fileIO.properties()).thenReturn(new HashMap<>()); - - Map result1 = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(icebergProperties, new HashMap<>(), icebergTable); - - // Should use IcebergVendedCredentialsProvider - Assertions.assertNotNull(result1); - - // Test Paimon type - PaimonRestMetaStoreProperties paimonProperties = Mockito.mock(PaimonRestMetaStoreProperties.class); - Mockito.when(paimonProperties.getType()).thenReturn(MetastoreProperties.Type.PAIMON); - - org.apache.paimon.table.Table paimonTable = Mockito.mock(org.apache.paimon.table.Table.class); - - Map result2 = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(paimonProperties, new HashMap<>(), paimonTable); - - // Should use PaimonVendedCredentialsProvider - Assertions.assertNotNull(result2); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HMSExternalTableTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HMSExternalTableTest.java deleted file mode 100644 index 5cf4779f73e79f..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HMSExternalTableTest.java +++ /dev/null @@ -1,374 +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.datasource.hive; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.ListPartitionItem; -import org.apache.doris.catalog.PartitionItem; -import org.apache.doris.catalog.PartitionKey; -import org.apache.doris.catalog.Type; -import org.apache.doris.common.UserException; -import org.apache.doris.common.jmockit.Deencapsulation; -import org.apache.doris.datasource.ExternalMetaCacheMgr; -import org.apache.doris.fs.FileSystemDirectoryLister; -import org.apache.doris.thrift.TFileFormatType; - -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Lists; -import org.apache.hadoop.hive.metastore.api.SerDeInfo; -import org.apache.hadoop.hive.metastore.api.StorageDescriptor; -import org.apache.hadoop.hive.metastore.api.Table; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.mockito.Mockito; - -import java.util.Collections; -import java.util.List; - - -/** - * Test class for HMSExternalTable, focusing on view-related functionality - */ -public class HMSExternalTableTest { - private TestHMSExternalTable table; - private static final String TEST_VIEW_TEXT = "SELECT * FROM test_table"; - private static final String TEST_EXPANDED_VIEW = "/* Presto View */"; - - // Real example of a Presto View definition - private static final String PRESTO_VIEW_ORIGINAL = "/* Presto View: eyJvcmlnaW5hbFNxbCI6IlNFTEVDVFxuICBkZXBhcnRtZW50XG4sIGxlbmd0aChkZXBhcnRtZW50KSBkZXBhcnRtZW50X2xlbmd0aFxuLCBkYXRlX3RydW5jKCd5ZWFyJywgaGlyZV9kYXRlKSB5ZWFyXG5GUk9NXG4gIGVtcGxveWVlc1xuIiwiY2F0YWxvZyI6ImhpdmUiLCJzY2hlbWEiOiJtbWNfaGl2ZSIsImNvbHVtbnMiOlt7Im5hbWUiOiJkZXBhcnRtZW50IiwidHlwZSI6InZhcmNoYXIifSx7Im5hbWUiOiJkZXBhcnRtZW50X2xlbmd0aCIsInR5cGUiOiJiaWdpbnQifSx7Im5hbWUiOiJ5ZWFyIiwidHlwZSI6ImRhdGUifV0sIm93bmVyIjoidHJpbm8vbWFzdGVyLTEtMS5jLTA1OTYxNzY2OThiZDRkMTcuY24tYmVpamluZy5lbXIuYWxpeXVuY3MuY29tIiwicnVuQXNJbnZva2VyIjpmYWxzZX0= */"; - - // Expected SQL query after decoding and parsing - private static final String EXPECTED_SQL = "SELECT\n department\n, length(department) department_length\n, date_trunc('year', hire_date) year\nFROM\n employees\n"; - - private HMSExternalCatalog mockCatalog = Mockito.mock(HMSExternalCatalog.class); - - private HMSExternalDatabase mockDb; - - @BeforeEach - public void setUp() { - // Create a mock database with minimal required functionality - mockDb = new HMSExternalDatabase(mockCatalog, 1L, "test_db", "remote_test_db") { - @Override - public String getFullName() { - return "test_catalog.test_db"; - } - }; - - table = new TestHMSExternalTable(mockCatalog, mockDb); - } - - @Test - public void testGetViewText_Normal() { - // Test regular view text retrieval - table.setViewOriginalText(TEST_VIEW_TEXT); - table.setViewExpandedText(TEST_VIEW_TEXT); - Assertions.assertEquals(TEST_VIEW_TEXT, table.getViewText()); - } - - @Test - public void testGetViewText_PrestoView() { - // Test Presto view parsing including base64 decode and JSON extraction - table.setViewOriginalText(PRESTO_VIEW_ORIGINAL); - table.setViewExpandedText(TEST_EXPANDED_VIEW); - Assertions.assertEquals(EXPECTED_SQL, table.getViewText()); - } - - @Test - public void testGetViewText_InvalidPrestoView() { - // Test handling of invalid Presto view definition - String invalidPrestoView = "/* Presto View: invalid_base64_content */"; - table.setViewOriginalText(invalidPrestoView); - table.setViewExpandedText(TEST_EXPANDED_VIEW); - Assertions.assertEquals(invalidPrestoView, table.getViewText()); - } - - @Test - public void testGetViewText_EmptyExpandedView() { - // Test handling of empty expanded view text - table.setViewOriginalText(TEST_VIEW_TEXT); - table.setViewExpandedText(""); - Assertions.assertEquals(TEST_VIEW_TEXT, table.getViewText()); - } - - // ------------------------------------------------------------------------- - // Tests for SUPPORTED_HIVE_FILE_FORMATS whitelist (LZO input formats) - // ------------------------------------------------------------------------- - - @Test - public void testSupportedFileFormats_ContainsCompressionLzoTextInputFormat() { - // twitter hadoop-lzo (GPL): com.hadoop.compression.lzo.LzoTextInputFormat - Assertions.assertTrue( - HMSExternalTable.SUPPORTED_HIVE_FILE_FORMATS.contains( - "com.hadoop.compression.lzo.LzoTextInputFormat"), - "com.hadoop.compression.lzo.LzoTextInputFormat should be in the supported formats whitelist"); - } - - @Test - public void testSupportedFileFormats_ContainsMapreduceLzoTextInputFormat() { - // lzo-hadoop (org.anarres) mapreduce API: com.hadoop.mapreduce.LzoTextInputFormat - Assertions.assertTrue( - HMSExternalTable.SUPPORTED_HIVE_FILE_FORMATS.contains( - "com.hadoop.mapreduce.LzoTextInputFormat"), - "com.hadoop.mapreduce.LzoTextInputFormat should be in the supported formats whitelist"); - } - - @Test - public void testSupportedFileFormats_ContainsDeprecatedLzoTextInputFormat() { - // lzo-hadoop (org.anarres) legacy mapred API: com.hadoop.mapred.DeprecatedLzoTextInputFormat - Assertions.assertTrue( - HMSExternalTable.SUPPORTED_HIVE_FILE_FORMATS.contains( - "com.hadoop.mapred.DeprecatedLzoTextInputFormat"), - "com.hadoop.mapred.DeprecatedLzoTextInputFormat should be in the supported formats whitelist"); - } - - // ------------------------------------------------------------------------- - // Tests for getFileFormatType: LZO tables must reject INSERT INTO - // ------------------------------------------------------------------------- - - /** - * Build a minimal Hive Table SD stub with the given InputFormat class name. - */ - private Table buildRemoteTableWithInputFormat(String inputFormatName) { - SerDeInfo serDeInfo = new SerDeInfo(); - serDeInfo.setSerializationLib("org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe"); - StorageDescriptor sd = new StorageDescriptor(); - sd.setInputFormat(inputFormatName); - sd.setSerdeInfo(serDeInfo); - Table remoteTable = new Table(); - remoteTable.setSd(sd); - return remoteTable; - } - - @Test - public void testGetFileFormatType_LzoTextInputFormat_ReturnsText() throws UserException { - // LZO tables use the LazySimpleSerDe (text SerDe); getFileFormatType() must return - // FORMAT_TEXT so that the read path can decode the CSV-like payload inside each .lzo block. - // The INSERT rejection lives in BindSink.bindHiveTableSink(), NOT here. - String lzoFormat = "com.hadoop.compression.lzo.LzoTextInputFormat"; - Table remoteTable = buildRemoteTableWithInputFormat(lzoFormat); - TestHMSExternalTableWithRemote lzoTable = new TestHMSExternalTableWithRemote( - mockCatalog, mockDb, remoteTable); - TFileFormatType type = lzoTable.getFileFormatType(null); - Assertions.assertEquals(TFileFormatType.FORMAT_TEXT, type, - "LZO table with LazySimpleSerDe should resolve to FORMAT_TEXT for reading"); - } - - @Test - public void testGetFileFormatType_DeprecatedLzoTextInputFormat_ReturnsText() throws UserException { - String lzoFormat = "com.hadoop.mapred.DeprecatedLzoTextInputFormat"; - Table remoteTable = buildRemoteTableWithInputFormat(lzoFormat); - TestHMSExternalTableWithRemote lzoTable = new TestHMSExternalTableWithRemote( - mockCatalog, mockDb, remoteTable); - TFileFormatType type = lzoTable.getFileFormatType(null); - Assertions.assertEquals(TFileFormatType.FORMAT_TEXT, type, - "DeprecatedLzoTextInputFormat table should also resolve to FORMAT_TEXT for reading"); - } - - @Test - public void testGetFileFormatType_MapreduceLzoTextInputFormat_ReturnsText() throws UserException { - String lzoFormat = "com.hadoop.mapreduce.LzoTextInputFormat"; - Table remoteTable = buildRemoteTableWithInputFormat(lzoFormat); - TestHMSExternalTableWithRemote lzoTable = new TestHMSExternalTableWithRemote( - mockCatalog, mockDb, remoteTable); - TFileFormatType type = lzoTable.getFileFormatType(null); - Assertions.assertEquals(TFileFormatType.FORMAT_TEXT, type, - "com.hadoop.mapreduce.LzoTextInputFormat table should also resolve to FORMAT_TEXT for reading"); - } - - @Test - public void testFetchRowCountFillsMetaCacheOnlyWhenRequested() throws Exception { - long catalogId = 100L; - String localDbName = "test_db"; - String partitionValue = "2026-05-21"; - String inputFormat = "org.apache.hadoop.mapred.TextInputFormat"; - String partitionLocation = "file:///tmp/doris_hms_row_count_cache/dt=2026-05-21"; - - HMSExternalCatalog catalog = Mockito.mock(HMSExternalCatalog.class); - HMSExternalDatabase db = Mockito.mock(HMSExternalDatabase.class); - Mockito.when(catalog.getId()).thenReturn(catalogId); - Mockito.when(catalog.getName()).thenReturn("test_catalog"); - Mockito.when(catalog.getProperties()).thenReturn(ImmutableMap.of()); - Mockito.when(db.getFullName()).thenReturn(localDbName); - - Table remoteTable = buildRemoteTableWithInputFormat(inputFormat); - remoteTable.setParameters(ImmutableMap.of()); - TestHMSExternalTableForMetaCache table = new TestHMSExternalTableForMetaCache( - catalog, db, remoteTable, partitionValue); - Deencapsulation.setField(table, "dlaType", HMSExternalTable.DLAType.HIVE); - - List partitions = Collections.singletonList(new HivePartition( - null, false, inputFormat, partitionLocation, Collections.singletonList(partitionValue), - Collections.emptyMap())); - HiveExternalMetaCache.FileCacheValue fileCacheValue = new HiveExternalMetaCache.FileCacheValue(); - HiveExternalMetaCache.HiveFileStatus status = new HiveExternalMetaCache.HiveFileStatus(); - status.setLength(128L); - fileCacheValue.getFiles().add(status); - List files = Collections.singletonList(fileCacheValue); - - HiveExternalMetaCache hiveCache = Mockito.mock(HiveExternalMetaCache.class); - Mockito.when(hiveCache.getAllPartitionsWithCache(Mockito.eq(table), Mockito.anyList())) - .thenReturn(partitions); - Mockito.when(hiveCache.getAllPartitionsWithoutCache(Mockito.eq(table), Mockito.anyList())) - .thenReturn(partitions); - Mockito.when(hiveCache.getFilesByPartitions(Mockito.eq(partitions), Mockito.eq(true), Mockito.eq(true), - Mockito.any(FileSystemDirectoryLister.class), Mockito.isNull())) - .thenReturn(files); - Mockito.when(hiveCache.getFilesByPartitions(Mockito.eq(partitions), Mockito.eq(false), Mockito.eq(true), - Mockito.any(FileSystemDirectoryLister.class), Mockito.isNull())) - .thenReturn(files); - - Env env = Mockito.mock(Env.class); - ExternalMetaCacheMgr extMetaCacheMgr = Mockito.mock(ExternalMetaCacheMgr.class); - Mockito.when(env.getExtMetaCacheMgr()).thenReturn(extMetaCacheMgr); - Mockito.when(extMetaCacheMgr.hive(catalogId)).thenReturn(hiveCache); - - try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { - mockedEnv.when(Env::getCurrentEnv).thenReturn(env); - - Assertions.assertEquals(32L, table.fetchRowCountWithMetaCache(true)); - Mockito.verify(hiveCache).getAllPartitionsWithCache(Mockito.eq(table), Mockito.anyList()); - Mockito.verify(hiveCache, Mockito.never()) - .getAllPartitionsWithoutCache(Mockito.eq(table), Mockito.anyList()); - Mockito.verify(hiveCache).getFilesByPartitions(Mockito.eq(partitions), Mockito.eq(true), - Mockito.eq(true), Mockito.any(FileSystemDirectoryLister.class), Mockito.isNull()); - - Mockito.clearInvocations(hiveCache); - - Assertions.assertEquals(32L, table.fetchRowCount()); - Mockito.verify(hiveCache).getAllPartitionsWithoutCache(Mockito.eq(table), Mockito.anyList()); - Mockito.verify(hiveCache, Mockito.never()) - .getAllPartitionsWithCache(Mockito.eq(table), Mockito.anyList()); - Mockito.verify(hiveCache).getFilesByPartitions(Mockito.eq(partitions), Mockito.eq(false), - Mockito.eq(true), Mockito.any(FileSystemDirectoryLister.class), Mockito.isNull()); - } - } - - /** - * Variant that exposes a pre-built remote table for getFileFormatType tests. - */ - private static class TestHMSExternalTableWithRemote extends HMSExternalTable { - private final Table remoteTable; - - public TestHMSExternalTableWithRemote(HMSExternalCatalog catalog, - HMSExternalDatabase db, Table remoteTable) { - super(1L, "test_table", "test_table", catalog, db); - this.remoteTable = remoteTable; - } - - @Override - public Table getRemoteTable() { - return remoteTable; - } - - @Override - protected synchronized void makeSureInitialized() { - this.objectCreated = true; - } - } - - private static class TestHMSExternalTableForMetaCache extends TestHMSExternalTableWithRemote { - private final Column dataColumn = new Column("c1", Type.INT); - private final Column partitionColumn = new Column("dt", Type.VARCHAR); - private final HiveExternalMetaCache.HivePartitionValues partitionValues; - - public TestHMSExternalTableForMetaCache(HMSExternalCatalog catalog, HMSExternalDatabase db, - Table remoteTable, String partitionValue) throws Exception { - super(catalog, db, remoteTable); - PartitionKey partitionKey = PartitionKey.createListPartitionKeyWithTypes( - Lists.newArrayList(new org.apache.doris.analysis.PartitionValue(partitionValue)), - Lists.newArrayList(Type.VARCHAR), - true); - PartitionItem partitionItem = new ListPartitionItem(Lists.newArrayList(partitionKey)); - this.partitionValues = new HiveExternalMetaCache.HivePartitionValues( - ImmutableMap.of("dt=" + partitionValue, partitionItem), - ImmutableMap.of("dt=" + partitionValue, Collections.singletonList(partitionValue))); - } - - @Override - public List getFullSchema() { - return Lists.newArrayList(dataColumn, partitionColumn); - } - - @Override - public boolean isView() { - return false; - } - - @Override - public List getPartitionColumnTypes(java.util.Optional - snapshot) { - return Collections.singletonList(Type.VARCHAR); - } - - @Override - public List getPartitionColumns() { - return Collections.singletonList(partitionColumn); - } - - @Override - public List getPartitionColumns(java.util.Optional - snapshot) { - return Collections.singletonList(partitionColumn); - } - - @Override - public HiveExternalMetaCache.HivePartitionValues getHivePartitionValues( - java.util.Optional snapshot) { - return partitionValues; - } - } - - /** - * Test implementation of HMSExternalTable that allows setting view texts - * Uses parent's getViewText() implementation for actual testing - */ - private static class TestHMSExternalTable extends HMSExternalTable { - private String viewExpandedText; - private String viewOriginalText; - - public TestHMSExternalTable(HMSExternalCatalog catalog, HMSExternalDatabase db) { - super(1L, "test_table", "test_table", catalog, db); - } - - @Override - public String getViewExpandedText() { - return viewExpandedText; - } - - @Override - public String getViewOriginalText() { - return viewOriginalText; - } - - public void setViewExpandedText(String viewExpandedText) { - this.viewExpandedText = viewExpandedText; - } - - public void setViewOriginalText(String viewOriginalText) { - this.viewOriginalText = viewOriginalText; - } - - @Override - protected synchronized void makeSureInitialized() { - this.objectCreated = true; - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HMSTransactionPathTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HMSTransactionPathTest.java deleted file mode 100644 index 35bdb6865445cf..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HMSTransactionPathTest.java +++ /dev/null @@ -1,465 +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.datasource.hive; - -import org.apache.doris.filesystem.DorisInputFile; -import org.apache.doris.filesystem.DorisOutputFile; -import org.apache.doris.filesystem.FileEntry; -import org.apache.doris.filesystem.FileIterator; -import org.apache.doris.filesystem.FileSystem; -import org.apache.doris.filesystem.Location; -import org.apache.doris.filesystem.UploadPartResult; -import org.apache.doris.filesystem.local.LocalFileSystem; -import org.apache.doris.filesystem.spi.ObjFileSystem; -import org.apache.doris.filesystem.spi.ObjStorage; -import org.apache.doris.filesystem.spi.RemoteObject; -import org.apache.doris.filesystem.spi.RemoteObjects; -import org.apache.doris.filesystem.spi.RequestBody; -import org.apache.doris.fs.SpiSwitchingFileSystem; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.thrift.THiveLocationParams; -import org.apache.doris.thrift.THivePartitionUpdate; -import org.apache.doris.thrift.TS3MPUPendingUpload; - -import org.junit.After; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; - -import java.io.IOException; -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.nio.file.Files; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Set; - -public class HMSTransactionPathTest { - private ConnectContext connectContext; - - @Before - public void setUp() { - connectContext = new ConnectContext(); - connectContext.setThreadLocalInfo(); - } - - @After - public void tearDown() { - ConnectContext.remove(); - connectContext = null; - } - - @Test - public void testIsSubDirectory() throws Exception { - Assert.assertFalse(HMSTransaction.isSubDirectory(null, "/a")); - Assert.assertFalse(HMSTransaction.isSubDirectory("/a", null)); - Assert.assertFalse(HMSTransaction.isSubDirectory("/a/b", "/a/b")); - Assert.assertFalse(HMSTransaction.isSubDirectory("/a/b", "/a/bc")); - Assert.assertFalse(HMSTransaction.isSubDirectory( - "hdfs://host1:8020/a", "hdfs://host2:8020/a/b")); - Assert.assertTrue(HMSTransaction.isSubDirectory( - "hdfs://host:8020/a/b/", "hdfs://host:8020/a/b/c/d")); - Assert.assertTrue(HMSTransaction.isSubDirectory("a/b", "a/b/c")); - } - - @Test - public void testGetImmediateChildPath() throws Exception { - String parent = "hdfs://host:8020/warehouse/table"; - String child = "hdfs://host:8020/warehouse/table/.doris_staging/user/uuid"; - Assert.assertEquals( - "hdfs://host:8020/warehouse/table/.doris_staging", - HMSTransaction.getImmediateChildPath(parent, child)); - - String directChild = "hdfs://host:8020/warehouse/table/part=1"; - Assert.assertEquals( - "hdfs://host:8020/warehouse/table/part=1", - HMSTransaction.getImmediateChildPath(parent, directChild)); - - String notSubdir = "hdfs://host:8020/warehouse/other"; - Assert.assertNull(HMSTransaction.getImmediateChildPath(parent, notSubdir)); - } - - // Ensures NOT_FOUND results from list operations are treated as no-op cleanup. - @Test - public void testDeleteTargetPathContentsNotFoundAllowed() throws Exception { - FakeFileSystem fakeFs = new FakeFileSystem(); - fakeFs.listDirectoriesThrows = new IOException("not found"); - fakeFs.listFilesThrows = new IOException("not found"); - - HMSTransaction transaction = createTransaction(fakeFs); - Assert.assertThrows(RuntimeException.class, () -> transaction.deleteTargetPathContents( - "/tmp/does_not_exist", "/tmp/does_not_exist/.doris_staging")); - } - - // Verifies listDirectories failures surface as runtime errors. - @Test - public void testDeleteTargetPathContentsListError() throws Exception { - FakeFileSystem fakeFs = new FakeFileSystem(); - fakeFs.listDirectoriesThrows = new IOException("list failed"); - - HMSTransaction transaction = createTransaction(fakeFs); - Assert.assertThrows(RuntimeException.class, () -> transaction.deleteTargetPathContents( - "/tmp/target", "/tmp/target/.doris_staging")); - } - - @Test - public void testEnsureDirectorySuccess() throws Exception { - LocalFileSystem localFs = new LocalFileSystem(Collections.emptyMap()); - HMSTransaction transaction = createTransaction(localFs); - - java.nio.file.Path dir = Files.createTempDirectory("hms_tx_ensure_").resolve("nested"); - transaction.ensureDirectory(dir.toString()); - - Assert.assertTrue(Files.exists(dir)); - } - - @Test - public void testEnsureDirectoryError() throws Exception { - FakeFileSystem fakeFs = new FakeFileSystem(); - fakeFs.mkdirsThrows = new IOException("mkdir failed"); - - HMSTransaction transaction = createTransaction(fakeFs); - Assert.assertThrows(RuntimeException.class, () -> transaction.ensureDirectory("/tmp/target")); - } - - // Verifies the staging-under-target flow: - // 1) Detect write path nested under target. - // 2) Compute the immediate staging root under target. - // 3) Delete target contents while preserving the staging root. - // 4) Ensure the target directory exists after cleanup. - @Test - public void testDeleteTargetPathContentsSkipsExcludedDir() throws Exception { - LocalFileSystem localFs = new LocalFileSystem(Collections.emptyMap()); - HMSTransaction transaction = createTransaction(localFs); - - java.nio.file.Path targetDir = Files.createTempDirectory("hms_tx_path_test_"); - java.nio.file.Path stagingDir = targetDir.resolve(".doris_staging"); - java.nio.file.Path writeDir = stagingDir.resolve("user/uuid"); - java.nio.file.Path stagingFile = stagingDir.resolve("staging.tmp"); - java.nio.file.Path otherDir = targetDir.resolve("part=1"); - java.nio.file.Path otherFile = targetDir.resolve("data.txt"); - - Files.createDirectories(stagingDir); - Files.createDirectories(writeDir); - Files.createFile(stagingFile); - Files.createDirectories(otherDir); - Files.createFile(otherFile); - - String targetPath = targetDir.toString(); - String writePath = writeDir.toString(); - Assert.assertTrue(HMSTransaction.isSubDirectory(targetPath, writePath)); - String stagingRoot = HMSTransaction.getImmediateChildPath(targetPath, writePath); - transaction.deleteTargetPathContents(targetPath, stagingRoot); - transaction.ensureDirectory(targetPath); - - Assert.assertTrue(Files.exists(stagingDir)); - Assert.assertTrue(Files.exists(stagingFile)); - Assert.assertFalse(Files.exists(otherDir)); - Assert.assertFalse(Files.exists(otherFile)); - } - - private static HMSTransaction createTransaction(FileSystem delegate) { - SpiSwitchingFileSystem spiFs = new SpiSwitchingFileSystem(delegate); - return new HMSTransaction(null, spiFs, Runnable::run); - } - - private static void setEmptyStagingDirectory(HMSTransaction tx) throws Exception { - Field stagingDirField = HMSTransaction.class.getDeclaredField("stagingDirectory"); - stagingDirField.setAccessible(true); - stagingDirField.set(tx, java.util.Optional.empty()); - } - - private static class FakeFileSystem implements FileSystem { - IOException listDirectoriesThrows; - IOException listFilesThrows; - IOException mkdirsThrows; - - final List deletedDirectories = new ArrayList<>(); - final List deletedFiles = new ArrayList<>(); - - @Override - public Set listDirectories(Location dir) throws IOException { - if (listDirectoriesThrows != null) { - throw listDirectoriesThrows; - } - return Collections.emptySet(); - } - - @Override - public List listFiles(Location dir) throws IOException { - if (listFilesThrows != null) { - throw listFilesThrows; - } - return Collections.emptyList(); - } - - @Override - public void mkdirs(Location location) throws IOException { - if (mkdirsThrows != null) { - throw mkdirsThrows; - } - } - - @Override - public void delete(Location location, boolean recursive) throws IOException { - if (recursive) { - deletedDirectories.add(location.uri()); - } else { - deletedFiles.add(location.uri()); - } - } - - @Override - public boolean exists(Location location) throws IOException { - return false; - } - - @Override - public void rename(Location src, Location dst) throws IOException { - } - - @Override - public FileIterator list(Location location) throws IOException { - return new FileIterator() { - @Override - public boolean hasNext() { - return false; - } - - @Override - public FileEntry next() { - throw new java.util.NoSuchElementException(); - } - - @Override - public void close() { - } - }; - } - - @Override - public DorisInputFile newInputFile(Location location) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public DorisInputFile newInputFile(Location location, long length) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public DorisOutputFile newOutputFile(Location location) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public void close() throws IOException { - } - } - - /** - * H3: Verify that {@code HmsCommitter.abortMultiUploads()} does NOT throw {@link ClassCastException} - * when the underlying {@link FileSystem} is a non-{@code ObjFileSystem} (e.g. HDFS / local). - * - *

    Before the H3 fix, {@code abortMultiUploads()} unconditionally cast the resolved filesystem to - * {@code ObjFileSystem}, causing a {@link ClassCastException} when using HDFS-backed repositories. - * The fix adds an {@code instanceof ObjFileSystem} guard that logs a warning and skips the abort. - * - *

    Because {@code HmsCommitter} and {@code UncompletedMpuPendingUpload} are package-private / - * private inner classes, the test uses reflection to inject the required state. - */ - @Test - @SuppressWarnings("unchecked") - public void testAbortMultiUploadsDoesNotThrowForNonObjFileSystem() throws Exception { - LocalFileSystem localFs = new LocalFileSystem(Collections.emptyMap()); - HMSTransaction tx = createTransaction(localFs); - - // Obtain the private UncompletedMpuPendingUpload class via reflection. - Class uploadClass = Arrays.stream(HMSTransaction.class.getDeclaredClasses()) - .filter(c -> "UncompletedMpuPendingUpload".equals(c.getSimpleName())) - .findFirst() - .orElseThrow(() -> new AssertionError("Could not find UncompletedMpuPendingUpload class")); - - // Build a minimal TS3MPUPendingUpload (bucket/key/uploadId need not be real). - TS3MPUPendingUpload mpu = new TS3MPUPendingUpload(); - mpu.setBucket("test-bucket"); - mpu.setKey("test/key"); - mpu.setUploadId("upload-id-0"); - - Constructor uploadCtor = uploadClass.getDeclaredConstructor(TS3MPUPendingUpload.class, String.class); - uploadCtor.setAccessible(true); - // Use "local:///tmp/file" — will be resolved via SpiSwitchingFileSystem to LocalFileSystem. - Object upload = uploadCtor.newInstance(mpu, "/tmp/file"); - - // Inject into HMSTransaction.uncompletedMpuPendingUploads (field on outer class). - Field mpuField = HMSTransaction.class.getDeclaredField("uncompletedMpuPendingUploads"); - mpuField.setAccessible(true); - Set uploads = (Set) mpuField.get(tx); - uploads.add(upload); - - // stagingDirectory is only initialized inside commit(); initialize it here to avoid NPE in rollback(). - setEmptyStagingDirectory(tx); - - // Instantiate HmsCommitter (package-private inner class) for this transaction. - Class committerClass = Arrays.stream(HMSTransaction.class.getDeclaredClasses()) - .filter(c -> "HmsCommitter".equals(c.getSimpleName())) - .findFirst() - .orElseThrow(() -> new AssertionError("Could not find HmsCommitter class")); - Constructor committerCtor = committerClass.getDeclaredConstructor(HMSTransaction.class); - committerCtor.setAccessible(true); - Object committer = committerCtor.newInstance(tx); - - // rollback() calls abortMultiUploads(); with the H3 fix it must skip (not cast) for LocalFileSystem. - // Before the fix this would throw ClassCastException. - Method rollback = committerClass.getMethod("rollback"); - rollback.invoke(committer); // must NOT throw - - // After rollback, uncompletedMpuPendingUploads must be cleared. - Assert.assertTrue("uncompletedMpuPendingUploads must be cleared after rollback", uploads.isEmpty()); - } - - @Test - public void testRollbackAbortsPendingMpuBeforeCommitterCreated() throws Exception { - TrackingObjStorage storage = new TrackingObjStorage(); - HMSTransaction tx = createTransaction(new TestObjFileSystem(storage)); - - TS3MPUPendingUpload mpu = new TS3MPUPendingUpload(); - mpu.setBucket("test-bucket"); - mpu.setKey("warehouse/table/data-0.parquet"); - mpu.setUploadId("upload-id-1"); - - THiveLocationParams location = new THiveLocationParams(); - location.setWritePath("s3://test-bucket/warehouse/table"); - - THivePartitionUpdate update = new THivePartitionUpdate(); - update.setLocation(location); - update.setFileNames(Collections.emptyList()); - update.setRowCount(0); - update.setFileSize(0); - update.setS3MpuPendingUploads(Collections.singletonList(mpu)); - tx.updateHivePartitionUpdates(Collections.singletonList(update)); - setEmptyStagingDirectory(tx); - - tx.rollback(); - - Assert.assertEquals(Collections.singletonList("s3://test-bucket/warehouse/table/data-0.parquet"), - storage.abortedPaths); - Assert.assertEquals(Collections.singletonList("upload-id-1"), storage.abortedUploadIds); - } - - private static class TestObjFileSystem extends ObjFileSystem { - TestObjFileSystem(ObjStorage storage) { - super(storage); - } - - @Override - public void mkdirs(Location location) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public void delete(Location location, boolean recursive) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public void rename(Location src, Location dst) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public FileIterator list(Location location) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public DorisInputFile newInputFile(Location location) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public DorisOutputFile newOutputFile(Location location) throws IOException { - throw new UnsupportedOperationException(); - } - } - - private static class TrackingObjStorage implements ObjStorage { - private final List abortedPaths = new ArrayList<>(); - private final List abortedUploadIds = new ArrayList<>(); - - @Override - public Object getClient() throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public RemoteObjects listObjects(String remotePath, String continuationToken) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public RemoteObject headObject(String remotePath) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public void putObject(String remotePath, RequestBody requestBody) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public void deleteObject(String remotePath) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public void copyObject(String srcPath, String dstPath) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public String initiateMultipartUpload(String remotePath) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public UploadPartResult uploadPart(String remotePath, String uploadId, int partNum, - RequestBody body) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public void completeMultipartUpload(String remotePath, String uploadId, - List parts) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public void abortMultipartUpload(String remotePath, String uploadId) throws IOException { - abortedPaths.add(remotePath); - abortedUploadIds.add(uploadId); - } - - @Override - public void close() throws IOException { - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HiveAcidTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HiveAcidTest.java deleted file mode 100644 index 2ebc7449978358..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HiveAcidTest.java +++ /dev/null @@ -1,601 +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.datasource.hive; - -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.hive.HiveExternalMetaCache.FileCacheValue; -import org.apache.doris.datasource.property.storage.LocalProperties; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.filesystem.local.LocalFileSystem; - -import com.google.common.collect.ImmutableMap; -import org.apache.hadoop.hive.common.ValidReadTxnList; -import org.apache.hadoop.hive.common.ValidReaderWriteIdList; -import org.junit.Assert; -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.BitSet; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - - -public class HiveAcidTest { - - private static final Map storagePropertiesMap = ImmutableMap.of( - StorageProperties.Type.LOCAL, new LocalProperties(new HashMap<>()) - ); - - private static final LocalFileSystem SPI_LOCAL_FS = new LocalFileSystem(new HashMap<>()); - - private static void createFile(String fileUri) throws Exception { - Path path = java.nio.file.Paths.get(fileUri.substring("file://".length())); - Files.createDirectories(path.getParent()); - if (!Files.exists(path)) { - Files.createFile(path); - } - } - - @Test - public void testOriginalDeltas() throws Exception { - Path tempPath = Files.createTempDirectory("tbl"); - createFile("file://" + tempPath.toAbsolutePath() + "/000000_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/000001_1"); - createFile("file://" + tempPath.toAbsolutePath() + "/000002_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/random"); - createFile("file://" + tempPath.toAbsolutePath() + "/_done"); - createFile("file://" + tempPath.toAbsolutePath() + "/subdir/000000_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_025_025"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_029_029"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_025_030"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_050_100"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_101_101"); - - Map txnValidIds = new HashMap<>(); - txnValidIds.put( - AcidUtil.VALID_TXNS_KEY, - new ValidReadTxnList(new long[0], new BitSet(), 1000, Long.MAX_VALUE).writeToString()); - txnValidIds.put( - AcidUtil.VALID_WRITEIDS_KEY, - new ValidReaderWriteIdList("tbl:100:" + Long.MAX_VALUE + ":").writeToString()); - - HivePartition partition = new HivePartition(NameMapping.createForTest("", "tbl"), - false, "", "file://" + tempPath.toAbsolutePath() + "", - new ArrayList<>(), new HashMap<>()); - try { - AcidUtil.getAcidState(SPI_LOCAL_FS, partition, txnValidIds, new HashMap<>(), true); - } catch (UnsupportedOperationException e) { - Assert.assertTrue(e.getMessage().contains("For no acid table convert to acid, please COMPACT 'major'.")); - } - } - - - @Test - public void testObsoleteOriginals() throws Exception { - Path tempPath = Files.createTempDirectory("tbl"); - createFile("file://" + tempPath.toAbsolutePath() + "/base_10/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/base_5/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/000000_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/000001_1"); - - Map txnValidIds = new HashMap<>(); - txnValidIds.put( - AcidUtil.VALID_TXNS_KEY, - new ValidReadTxnList(new long[0], new BitSet(), 1000, Long.MAX_VALUE).writeToString()); - txnValidIds.put( - AcidUtil.VALID_WRITEIDS_KEY, - new ValidReaderWriteIdList("tbl:150:" + Long.MAX_VALUE + ":").writeToString()); - - HivePartition partition = new HivePartition( - NameMapping.createForTest("", "tbl"), - false, - "", - "file://" + tempPath.toAbsolutePath() + "", - new ArrayList<>(), - new HashMap<>()); - try { - AcidUtil.getAcidState(SPI_LOCAL_FS, partition, txnValidIds, storagePropertiesMap, true); - } catch (UnsupportedOperationException e) { - Assert.assertTrue(e.getMessage().contains("For no acid table convert to acid, please COMPACT 'major'.")); - } - } - - - @Test - public void testOverlapingDelta() throws Exception { - Path tempPath = Files.createTempDirectory("tbl"); - - createFile("file://" + tempPath.toAbsolutePath() + "/delta_0000063_63/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_000062_62/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_00061_61/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_40_60/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_0060_60/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_052_55/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/base_50/bucket_0"); - - Map txnValidIds = new HashMap<>(); - txnValidIds.put( - AcidUtil.VALID_TXNS_KEY, - new ValidReadTxnList(new long[0], new BitSet(), 1000, Long.MAX_VALUE).writeToString()); - txnValidIds.put( - AcidUtil.VALID_WRITEIDS_KEY, - new ValidReaderWriteIdList("tbl:100:" + Long.MAX_VALUE + ":").writeToString()); - - HivePartition partition = new HivePartition( - NameMapping.createForTest("", "tbl"), - false, - "", - "file://" + tempPath.toAbsolutePath() + "", - new ArrayList<>(), - new HashMap<>()); - - FileCacheValue fileCacheValue = - AcidUtil.getAcidState(SPI_LOCAL_FS, partition, txnValidIds, storagePropertiesMap, true); - - List readFile = - fileCacheValue.getFiles().stream().map(x -> x.path.getNormalizedLocation()).collect(Collectors.toList()); - - - List resultReadFile = Arrays.asList( - "file://" + tempPath.toAbsolutePath() + "/base_50/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_40_60/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_00061_61/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_000062_62/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_0000063_63/bucket_0" - ); - Assert.assertTrue(resultReadFile.containsAll(readFile) && readFile.containsAll(resultReadFile)); - } - - - @Test - public void testOverlapingDelta2() throws Exception { - Path tempPath = Files.createTempDirectory("tbl"); - - createFile("file://" + tempPath.toAbsolutePath() + "/delta_0000063_63_0/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_000062_62_0/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_000062_62_3/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_00061_61_0/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_40_60/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_0060_60_1/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_0060_60_4/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_0060_60_7/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_052_55/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_058_58/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/base_50/bucket_0"); - - Map txnValidIds = new HashMap<>(); - txnValidIds.put( - AcidUtil.VALID_TXNS_KEY, - new ValidReadTxnList(new long[0], new BitSet(), 1000, Long.MAX_VALUE).writeToString()); - txnValidIds.put( - AcidUtil.VALID_WRITEIDS_KEY, - new ValidReaderWriteIdList("tbl:100:" + Long.MAX_VALUE + ":").writeToString()); - - HivePartition partition = new HivePartition( - NameMapping.createForTest("", "tbl"), - false, - "", - "file://" + tempPath.toAbsolutePath() + "", - new ArrayList<>(), - new HashMap<>()); - - FileCacheValue fileCacheValue = - AcidUtil.getAcidState(SPI_LOCAL_FS, partition, txnValidIds, storagePropertiesMap, true); - - - List readFile = - fileCacheValue.getFiles().stream().map(x -> x.path.getNormalizedLocation()).collect(Collectors.toList()); - - - List resultReadFile = Arrays.asList( - "file://" + tempPath.toAbsolutePath() + "/base_50/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_40_60/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_00061_61_0/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_000062_62_0/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_000062_62_3/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_0000063_63_0/bucket_0" - - ); - - Assert.assertTrue(resultReadFile.containsAll(readFile) && readFile.containsAll(resultReadFile)); - } - - - @Test - public void deltasWithOpenTxnInRead() throws Exception { - Path tempPath = Files.createTempDirectory("tbl"); - - createFile("file://" + tempPath.toAbsolutePath() + "/delta_1_1/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_2_5/bucket_0"); - - Map txnValidIds = new HashMap<>(); - - txnValidIds.put( - AcidUtil.VALID_TXNS_KEY, - new ValidReadTxnList(new long[] {50}, new BitSet(), 1000, 55).writeToString()); - txnValidIds.put( - AcidUtil.VALID_WRITEIDS_KEY, - new ValidReaderWriteIdList("tbl:100:4:4").writeToString()); - - HivePartition partition = new HivePartition( - NameMapping.createForTest("", "tbl"), - false, - "", - "file://" + tempPath.toAbsolutePath() + "", - new ArrayList<>(), - new HashMap<>()); - - - FileCacheValue fileCacheValue = - AcidUtil.getAcidState(SPI_LOCAL_FS, partition, txnValidIds, storagePropertiesMap, true); - - - List readFile = - fileCacheValue.getFiles().stream().map(x -> x.path.getNormalizedLocation()).collect(Collectors.toList()); - - - List resultReadFile = Arrays.asList( - "file://" + tempPath.toAbsolutePath() + "/delta_1_1/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_2_5/bucket_0" - ); - - Assert.assertTrue(resultReadFile.containsAll(readFile) && readFile.containsAll(resultReadFile)); - - } - - - @Test - public void deltasWithOpenTxnInRead2() throws Exception { - Path tempPath = Files.createTempDirectory("tbl"); - - createFile("file://" + tempPath.toAbsolutePath() + "/delta_1_1/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_2_5/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_4_4_1/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_4_4_3/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_101_101_1/bucket_0"); - - Map txnValidIds = new HashMap<>(); - - txnValidIds.put( - AcidUtil.VALID_TXNS_KEY, - new ValidReadTxnList(new long[] {50}, new BitSet(), 1000, 55).writeToString()); - txnValidIds.put( - AcidUtil.VALID_WRITEIDS_KEY, - new ValidReaderWriteIdList("tbl:100:4:4").writeToString()); - - HivePartition partition = new HivePartition( - NameMapping.createForTest("", "tbl"), - false, - "", - "file://" + tempPath.toAbsolutePath() + "", - new ArrayList<>(), - new HashMap<>()); - - FileCacheValue fileCacheValue = - AcidUtil.getAcidState(SPI_LOCAL_FS, partition, txnValidIds, storagePropertiesMap, true); - - - List readFile = - fileCacheValue.getFiles().stream().map(x -> x.path.getNormalizedLocation()).collect(Collectors.toList()); - - - List resultReadFile = Arrays.asList( - "file://" + tempPath.toAbsolutePath() + "/delta_1_1/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_2_5/bucket_0" - ); - - Assert.assertTrue(resultReadFile.containsAll(readFile) && readFile.containsAll(resultReadFile)); - } - - @Test - public void testBaseWithDeleteDeltas() throws Exception { - Path tempPath = Files.createTempDirectory("tbl"); - - createFile("file://" + tempPath.toAbsolutePath() + "/base_5/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/base_10/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/base_49/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_025_025/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_029_029/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delete_delta_029_029/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_025_030/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delete_delta_025_030/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_050_105/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delete_delta_050_105/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delete_delta_110_110/bucket_0"); - - Map txnValidIds = new HashMap<>(); - txnValidIds.put( - AcidUtil.VALID_TXNS_KEY, - new ValidReadTxnList(new long[0], new BitSet(), 1000, Long.MAX_VALUE).writeToString()); - txnValidIds.put( - AcidUtil.VALID_WRITEIDS_KEY, - new ValidReaderWriteIdList("tbl:100:" + Long.MAX_VALUE + ":").writeToString()); - - // Map tableProps = new HashMap<>(); - // tableProps.put(HiveConf.ConfVars.HIVE_TXN_OPERATIONAL_PROPERTIES.varname, - // AcidUtils.AcidOperationalProperties.getDefault().toString()); - - HivePartition partition = new HivePartition( - NameMapping.createForTest("", "tbl"), - false, - "", - "file://" + tempPath.toAbsolutePath() + "", - new ArrayList<>(), - new HashMap<>()); - - FileCacheValue fileCacheValue = - AcidUtil.getAcidState(SPI_LOCAL_FS, partition, txnValidIds, storagePropertiesMap, true); - - - List readFile = - fileCacheValue.getFiles().stream().map(x -> x.path.getNormalizedLocation()).collect(Collectors.toList()); - - - List resultReadFile = Arrays.asList( - "file://" + tempPath.toAbsolutePath() + "/base_49/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_050_105/bucket_0" - ); - - Assert.assertTrue(resultReadFile.containsAll(readFile) && readFile.containsAll(resultReadFile)); - - List resultDelta = Arrays.asList( - "file://" + tempPath.toAbsolutePath() + "/delete_delta_050_105/bucket_0" - ); - - List deltaFiles = new ArrayList<>(); - fileCacheValue.getAcidInfo().getDeleteDeltas().forEach( - deltaInfo -> { - String loc = deltaInfo.getDirectoryLocation(); - deltaInfo.getFileNames().forEach( - fileName -> deltaFiles.add(loc + "/" + fileName) - ); - } - ); - - Assert.assertTrue(resultDelta.containsAll(deltaFiles) && deltaFiles.containsAll(resultDelta)); - } - - - @Test - public void testOverlapingDeltaAndDeleteDelta() throws Exception { - Path tempPath = Files.createTempDirectory("tbl"); - - createFile("file://" + tempPath.toAbsolutePath() + "/delta_0000063_63/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_000062_62/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_00061_61/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delete_delta_00064_64/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_40_60/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delete_delta_40_60/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_0060_60/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_052_55/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delete_delta_052_55/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/base_50/bucket_0"); - - Map txnValidIds = new HashMap<>(); - txnValidIds.put( - AcidUtil.VALID_TXNS_KEY, - new ValidReadTxnList(new long[0], new BitSet(), 1000, Long.MAX_VALUE).writeToString()); - txnValidIds.put( - AcidUtil.VALID_WRITEIDS_KEY, - new ValidReaderWriteIdList("tbl:100:" + Long.MAX_VALUE + ":").writeToString()); - - - Map tableProps = new HashMap<>(); - // tableProps.put(HiveConf.ConfVars.HIVE_TXN_OPERATIONAL_PROPERTIES.varname, - // AcidUtils.AcidOperationalProperties.getDefault().toString()); - - HivePartition partition = new HivePartition( - NameMapping.createForTest("", "tbl"), - false, - "", - "file://" + tempPath.toAbsolutePath() + "", - new ArrayList<>(), - tableProps); - - FileCacheValue fileCacheValue = - AcidUtil.getAcidState(SPI_LOCAL_FS, partition, txnValidIds, storagePropertiesMap, true); - - - - List readFile = - fileCacheValue.getFiles().stream().map(x -> x.path.getNormalizedLocation()).collect(Collectors.toList()); - - - List resultReadFile = Arrays.asList( - "file://" + tempPath.toAbsolutePath() + "/base_50/bucket_0", - - "file://" + tempPath.toAbsolutePath() + "/delta_40_60/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_00061_61/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_000062_62/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_0000063_63/bucket_0" - - ); - - Assert.assertTrue(resultReadFile.containsAll(readFile) && readFile.containsAll(resultReadFile)); - - List resultDelta = Arrays.asList( - - "file://" + tempPath.toAbsolutePath() + "/delete_delta_40_60/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delete_delta_00064_64/bucket_0" - ); - - List deltaFiles = new ArrayList<>(); - fileCacheValue.getAcidInfo().getDeleteDeltas().forEach( - deltaInfo -> { - String loc = deltaInfo.getDirectoryLocation(); - deltaInfo.getFileNames().forEach( - fileName -> deltaFiles.add(loc + "/" + fileName) - ); - } - ); - - Assert.assertTrue(resultDelta.containsAll(deltaFiles) && deltaFiles.containsAll(resultDelta)); - } - - @Test - public void testMinorCompactedDeltaMakesInBetweenDelteDeltaObsolete() throws Exception { - Path tempPath = Files.createTempDirectory("tbl"); - - - createFile("file://" + tempPath.toAbsolutePath() + "/delta_40_60/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delete_delta_50_50/bucket_0"); - - Map txnValidIds = new HashMap<>(); - txnValidIds.put( - AcidUtil.VALID_TXNS_KEY, - new ValidReadTxnList(new long[0], new BitSet(), 1000, Long.MAX_VALUE).writeToString()); - txnValidIds.put( - AcidUtil.VALID_WRITEIDS_KEY, - new ValidReaderWriteIdList("tbl:100:" + Long.MAX_VALUE + ":").writeToString()); - - Map tableProps = new HashMap<>(); - HivePartition partition = new HivePartition( - NameMapping.createForTest("", "tbl"), - false, - "", - "file://" + tempPath.toAbsolutePath() + "", - new ArrayList<>(), - tableProps); - - FileCacheValue fileCacheValue = - AcidUtil.getAcidState(SPI_LOCAL_FS, partition, txnValidIds, storagePropertiesMap, true); - - List readFile = - fileCacheValue.getFiles().stream().map(x -> x.path.getNormalizedLocation()).collect(Collectors.toList()); - - - List resultReadFile = Arrays.asList( - "file://" + tempPath.toAbsolutePath() + "/delta_40_60/bucket_0" - ); - - Assert.assertTrue(resultReadFile.containsAll(readFile) && readFile.containsAll(resultReadFile)); - } - - @Test - public void deleteDeltasWithOpenTxnInRead() throws Exception { - Path tempPath = Files.createTempDirectory("tbl"); - - createFile("file://" + tempPath.toAbsolutePath() + "/delta_1_1/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_2_5/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delete_delta_2_5/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delete_delta_3_3/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_4_4_1/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_4_4_3/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_101_101_1/bucket_0"); - - - Map txnValidIds = new HashMap<>(); - txnValidIds.put( - AcidUtil.VALID_TXNS_KEY, - new ValidReadTxnList(new long[] {50}, new BitSet(), 1000, 55).writeToString()); - txnValidIds.put( - AcidUtil.VALID_WRITEIDS_KEY, - new ValidReaderWriteIdList("tbl:100:4:4").writeToString()); - - - Map tableProps = new HashMap<>(); - HivePartition partition = new HivePartition( - NameMapping.createForTest("", "tbl"), - false, - "", - "file://" + tempPath.toAbsolutePath() + "", - new ArrayList<>(), - tableProps); - - FileCacheValue fileCacheValue = - AcidUtil.getAcidState(SPI_LOCAL_FS, partition, txnValidIds, storagePropertiesMap, true); - - - List readFile = - fileCacheValue.getFiles().stream().map(x -> x.path.getNormalizedLocation()).collect(Collectors.toList()); - - - List resultReadFile = Arrays.asList( - "file://" + tempPath.toAbsolutePath() + "/delta_1_1/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_2_5/bucket_0" - ); - - Assert.assertTrue(resultReadFile.containsAll(readFile) && readFile.containsAll(resultReadFile)); - List resultDelta = Arrays.asList( - "file://" + tempPath.toAbsolutePath() + "/delete_delta_2_5/bucket_0" - ); - - List deltaFiles = new ArrayList<>(); - fileCacheValue.getAcidInfo().getDeleteDeltas().forEach( - deltaInfo -> { - String loc = deltaInfo.getDirectoryLocation(); - deltaInfo.getFileNames().forEach( - fileName -> deltaFiles.add(loc + "/" + fileName) - ); - } - ); - - Assert.assertTrue(resultDelta.containsAll(deltaFiles) && deltaFiles.containsAll(resultDelta)); - } - - @Test - public void testBaseDeltas() throws Exception { - Path tempPath = Files.createTempDirectory("tbl"); - - createFile("file://" + tempPath.toAbsolutePath() + "/base_5/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/base_10/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/base_49/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_025_025/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_029_029/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_025_030/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_050_105/bucket_0"); - createFile("file://" + tempPath.toAbsolutePath() + "/delta_90_120/bucket_0"); - - Map txnValidIds = new HashMap<>(); - txnValidIds.put( - AcidUtil.VALID_TXNS_KEY, - new ValidReadTxnList(new long[0], new BitSet(), 1000, Long.MAX_VALUE).writeToString()); - txnValidIds.put( - AcidUtil.VALID_WRITEIDS_KEY, - new ValidReaderWriteIdList("tbl:100:" + Long.MAX_VALUE + ":").writeToString()); - - Map tableProps = new HashMap<>(); - - HivePartition partition = new HivePartition( - NameMapping.createForTest("", "tbl"), - false, - "", - "file://" + tempPath.toAbsolutePath() + "", - new ArrayList<>(), - tableProps); - - FileCacheValue fileCacheValue = - AcidUtil.getAcidState(SPI_LOCAL_FS, partition, txnValidIds, storagePropertiesMap, true); - - List readFile = - fileCacheValue.getFiles().stream().map(x -> x.path.getNormalizedLocation()).collect(Collectors.toList()); - - - - List resultReadFile = Arrays.asList( - "file://" + tempPath.toAbsolutePath() + "/base_49/bucket_0", - "file://" + tempPath.toAbsolutePath() + "/delta_050_105/bucket_0" - ); - Assert.assertTrue(resultReadFile.containsAll(readFile) && readFile.containsAll(resultReadFile)); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HiveDDLAndDMLPlanTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HiveDDLAndDMLPlanTest.java deleted file mode 100644 index 5ff126ac30e372..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HiveDDLAndDMLPlanTest.java +++ /dev/null @@ -1,744 +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.datasource.hive; - -import org.apache.doris.analysis.DbName; -import org.apache.doris.analysis.HashDistributionDesc; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.common.Config; -import org.apache.doris.common.ExceptionChecker; -import org.apache.doris.common.FeConstants; -import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.DatabaseMetadata; -import org.apache.doris.datasource.TableMetadata; -import org.apache.doris.nereids.NereidsPlanner; -import org.apache.doris.nereids.StatementContext; -import org.apache.doris.nereids.parser.NereidsParser; -import org.apache.doris.nereids.properties.DistributionSpecHiveTableSinkHashPartitioned; -import org.apache.doris.nereids.properties.DistributionSpecHiveTableSinkUnPartitioned; -import org.apache.doris.nereids.properties.PhysicalProperties; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.PlanType; -import org.apache.doris.nereids.trees.plans.commands.CreateCatalogCommand; -import org.apache.doris.nereids.trees.plans.commands.CreateDatabaseCommand; -import org.apache.doris.nereids.trees.plans.commands.CreateTableCommand; -import org.apache.doris.nereids.trees.plans.commands.DropDatabaseCommand; -import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo; -import org.apache.doris.nereids.trees.plans.commands.info.DropDatabaseInfo; -import org.apache.doris.nereids.trees.plans.commands.insert.InsertIntoTableCommand; -import org.apache.doris.nereids.trees.plans.commands.insert.InsertOverwriteTableCommand; -import org.apache.doris.nereids.trees.plans.commands.use.SwitchCommand; -import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; -import org.apache.doris.nereids.trees.plans.logical.UnboundLogicalSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalDistribute; -import org.apache.doris.nereids.trees.plans.physical.PhysicalHiveTableSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalPlan; -import org.apache.doris.nereids.util.MemoTestUtils; -import org.apache.doris.utframe.TestWithFeService; - -import org.apache.commons.lang3.RandomUtils; -import org.apache.hadoop.hive.metastore.api.Database; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.Table; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.MockedConstruction; -import org.mockito.Mockito; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; - -public class HiveDDLAndDMLPlanTest extends TestWithFeService { - private static final String mockedCtlName = "hive"; - private static final String mockedDbName = "mockedDb"; - private final NereidsParser nereidsParser = new NereidsParser(); - - private MockedConstruction mockedClientConstruction; - private HMSExternalCatalog hmsExternalCatalog; - - private List checkedHiveCols; - - private final Set createdDbs = new HashSet<>(); - - private final Set

    createdTables = new HashSet<>(); - - private volatile List mockTableSchema; - private volatile Set mockTablePartNames; - - @Override - protected void runBeforeAll() throws Exception { - Config.enable_query_hive_views = false; - // create test internal table - createDatabase(mockedDbName); - useDatabase(mockedDbName); - String createSourceInterTable = "CREATE TABLE `unpart_ctas_olap`(\n" - + " `col1` INT COMMENT 'col1',\n" - + " `col2` STRING COMMENT 'col2'\n" - + ") ENGINE=olap\n" - + "DISTRIBUTED BY HASH (col1) BUCKETS 16\n" - + "PROPERTIES (\n" - + " 'replication_num' = '1')"; - createTable(createSourceInterTable, true); - - // partitioned table - String createSourceInterPTable = "CREATE TABLE `part_ctas_olap`(\n" - + " `col1` INT COMMENT 'col1',\n" - + " `pt1` VARCHAR(16) COMMENT 'pt1',\n" - + " `pt2` VARCHAR(16) COMMENT 'pt2'\n" - + ") ENGINE=olap\n" - + "PARTITION BY LIST (pt1, pt2) ()\n" - + "DISTRIBUTED BY HASH (col1) BUCKETS 16\n" - + "PROPERTIES (\n" - + " 'replication_num' = '1')"; - createTable(createSourceInterPTable, true); - - // Set up MockedConstruction for ThriftHMSCachedClient before catalog creation - mockedClientConstruction = Mockito.mockConstruction(ThriftHMSCachedClient.class, - (mock, context) -> { - Mockito.doAnswer(inv -> { - DatabaseMetadata db = inv.getArgument(0); - if (db instanceof HiveDatabaseMetadata) { - Database hiveDb = HiveUtil.toHiveDatabase((HiveDatabaseMetadata) db); - createdDbs.add(hiveDb.getName()); - } - return null; - }).when(mock).createDatabase(Mockito.any(DatabaseMetadata.class)); - - Mockito.doAnswer(inv -> { - String dbName = inv.getArgument(0); - if (createdDbs.contains(dbName)) { - return new Database(dbName, "", "", null); - } - return null; - }).when(mock).getDatabase(Mockito.anyString()); - - Mockito.doAnswer(inv -> { - String dbName = inv.getArgument(0); - String tblName = inv.getArgument(1); - for (Table table : createdTables) { - if (table.getDbName().equals(dbName) && table.getTableName().equals(tblName)) { - return true; - } - } - return false; - }).when(mock).tableExists(Mockito.anyString(), Mockito.anyString()); - - Mockito.doAnswer(inv -> new ArrayList<>(createdDbs)) - .when(mock).getAllDatabases(); - - Mockito.doAnswer(inv -> { - TableMetadata tbl = inv.getArgument(0); - if (tbl instanceof HiveTableMetadata) { - Table table = HiveUtil.toHiveTable((HiveTableMetadata) tbl); - createdTables.add(table); - if (checkedHiveCols != null) { - List fieldSchemas = table.getSd().getCols(); - Assertions.assertEquals(checkedHiveCols.size(), fieldSchemas.size()); - for (int i = 0; i < checkedHiveCols.size(); i++) { - FieldSchema checkedCol = checkedHiveCols.get(i); - FieldSchema actualCol = fieldSchemas.get(i); - Assertions.assertEquals(checkedCol.getName(), actualCol.getName().toLowerCase()); - Assertions.assertEquals(checkedCol.getType(), actualCol.getType().toLowerCase()); - } - } - } - return null; - }).when(mock).createTable(Mockito.any(TableMetadata.class), Mockito.anyBoolean()); - - Mockito.doAnswer(inv -> { - String dbName = inv.getArgument(0); - String tblName = inv.getArgument(1); - for (Table createdTable : createdTables) { - if (createdTable.getDbName().equals(dbName) && createdTable.getTableName().equals(tblName)) { - return createdTable; - } - } - return null; - }).when(mock).getTable(Mockito.anyString(), Mockito.anyString()); - }); - - // create external catalog and switch it - String hiveCatalog = "create catalog " + mockedCtlName - + " properties('type' = 'hms'," - + " 'hive.metastore.uris' = 'thrift://192.168.0.1:9083');"; - - LogicalPlan logicalPlan = nereidsParser.parseSingle(hiveCatalog); - if (logicalPlan instanceof CreateCatalogCommand) { - ((CreateCatalogCommand) logicalPlan).run(connectContext, null); - } - switchHive(); - - // create db and use it - Map dbProps = new HashMap<>(); - dbProps.put(HiveMetadataOps.LOCATION_URI_KEY, "file://loc/db"); - CreateDatabaseCommand command = new CreateDatabaseCommand(true, new DbName("hive", mockedDbName), dbProps); - Env.getCurrentEnv().createDb(command); - // checkout ifNotExists - Env.getCurrentEnv().createDb(command); - useDatabase(mockedDbName); - - // un-partitioned table - String createSourceExtUTable = "CREATE TABLE `unpart_ctas_src`(\n" - + " `col1` INT COMMENT 'col1',\n" - + " `col2` STRING COMMENT 'col2'\n" - + ") ENGINE=hive\n" - + "PROPERTIES (\n" - + " 'location'='hdfs://loc/db/tbl',\n" - + " 'file_format'='parquet')"; - createTable(createSourceExtUTable, true); - // partitioned table - String createSourceExtTable = "CREATE TABLE `part_ctas_src`(\n" - + " `col1` INT COMMENT 'col1',\n" - + " `pt1` VARCHAR COMMENT 'pt1',\n" - + " `pt2` VARCHAR COMMENT 'pt2'\n" - + ") ENGINE=hive\n" - + "PARTITION BY LIST (pt1, pt2) ()\n" - + "PROPERTIES (\n" - + " 'location'='hdfs://loc/db/tbl',\n" - + " 'file_format'='orc')"; - createTable(createSourceExtTable, true); - - hmsExternalCatalog = (HMSExternalCatalog) Env.getCurrentEnv().getCatalogMgr() - .getCatalog(mockedCtlName); - // Spy on the catalog to override getDbNullable - HMSExternalCatalog spyCatalog = Mockito.spy(hmsExternalCatalog); - Mockito.doAnswer(inv -> { - String dbName = inv.getArgument(0); - if (createdDbs.contains(dbName)) { - // Create a real HMSExternalDatabase and spy on it - HMSExternalDatabase realDb = new HMSExternalDatabase(spyCatalog, RandomUtils.nextLong(), dbName, dbName); - HMSExternalDatabase spyDb = Mockito.spy(realDb); - Mockito.doAnswer(inv2 -> { - String tableName = inv2.getArgument(0); - for (Table table : createdTables) { - if (table.getTableName().equals(tableName)) { - // Create a real HMSExternalTable and spy on it - HMSExternalTable realTable = new HMSExternalTable(0, tableName, tableName, - spyCatalog, spyDb); - HMSExternalTable spyTable = Mockito.spy(realTable); - if (mockTableSchema != null) { - Mockito.doReturn(false).when(spyTable).isView(); - Mockito.doReturn(mockTableSchema).when(spyTable).getFullSchema(); - Mockito.doReturn(mockTablePartNames != null ? mockTablePartNames : new HashSet<>()) - .when(spyTable).getPartitionColumnNames(); - } - return spyTable; - } - } - return null; - }).when(spyDb).getTableNullable(Mockito.anyString()); - return spyDb; - } - return null; - }).when(spyCatalog).getDbNullable(Mockito.anyString()); - // Replace catalog in CatalogMgr with the spy - @SuppressWarnings("unchecked") - Map nameMap = (Map) - org.apache.doris.common.jmockit.Deencapsulation.getField( - Env.getCurrentEnv().getCatalogMgr(), "nameToCatalog"); - nameMap.put(mockedCtlName, spyCatalog); - hmsExternalCatalog = spyCatalog; - } - - private void switchHive() throws Exception { - SwitchCommand switchTest = (SwitchCommand) parseStmt("switch hive;"); - Env.getCurrentEnv().changeCatalog(connectContext, switchTest.getCatalogName()); - } - - private void switchInternal() throws Exception { - SwitchCommand switchTest = (SwitchCommand) parseStmt("switch internal;"); - Env.getCurrentEnv().changeCatalog(connectContext, switchTest.getCatalogName()); - } - - @Override - protected void runAfterAll() throws Exception { - switchHive(); - String dropDbStmtStr = "DROP DATABASE IF EXISTS " + mockedDbName; - - NereidsParser parser = new NereidsParser(); - LogicalPlan logicalPlan = parser.parseSingle(dropDbStmtStr); - if (logicalPlan instanceof DropDatabaseCommand) { - ((DropDatabaseCommand) logicalPlan).run(connectContext, null); - } - - // check IF EXISTS - DropDatabaseCommand command = (DropDatabaseCommand) logicalPlan; - DropDatabaseInfo dropDatabaseInfo = command.getDropDatabaseInfo(); - Env.getCurrentEnv().dropDb( - dropDatabaseInfo.getCatalogName(), - dropDatabaseInfo.getDatabaseName(), - dropDatabaseInfo.isIfExists(), - dropDatabaseInfo.isForce()); - - if (mockedClientConstruction != null) { - mockedClientConstruction.close(); - } - } - - @Test - public void testExistsDbOrTbl() throws Exception { - switchHive(); - String db = "exists_db"; - String createDbStmtStr = "CREATE DATABASE IF NOT EXISTS " + db; - createDatabaseWithSql(createDbStmtStr); - createDatabaseWithSql(createDbStmtStr); - useDatabase(db); - - String createTableIfNotExists = "CREATE TABLE IF NOT EXISTS test_tbl(\n" - + " `col1` BOOLEAN COMMENT 'col1'," - + " `col2` INT COMMENT 'col2'" - + ") ENGINE=hive\n" - + "PROPERTIES (\n" - + " 'location'='hdfs://loc/db/tbl',\n" - + " 'file_format'='orc')"; - createTable(createTableIfNotExists, true); - createTable(createTableIfNotExists, true); - - dropTableWithSql("DROP TABLE IF EXISTS test_tbl"); - dropTableWithSql("DROP TABLE IF EXISTS test_tbl"); - - String dropDbStmtStr = "DROP DATABASE IF EXISTS " + db; - dropDatabaseWithSql(dropDbStmtStr); - dropDatabaseWithSql(dropDbStmtStr); - } - - @Test - public void testCreateAndDropWithSql() throws Exception { - switchHive(); - useDatabase(mockedDbName); - Optional hiveDb = Env.getCurrentEnv().getCurrentCatalog().getDb(mockedDbName); - Assertions.assertTrue(hiveDb.isPresent()); - Assertions.assertTrue(hiveDb.get() instanceof HMSExternalDatabase); - - String createUnPartTable = "CREATE TABLE unpart_tbl(\n" - + " `col1` BOOLEAN COMMENT 'col1',\n" - + " `col2` INT COMMENT 'col2',\n" - + " `col3` BIGINT COMMENT 'col3',\n" - + " `col4` DECIMAL(5,2) COMMENT 'col4',\n" - + " `pt1` STRING COMMENT 'pt1',\n" - + " `pt2` STRING COMMENT 'pt2'\n" - + ") ENGINE=hive\n" - + "PROPERTIES (\n" - + " 'location'='hdfs://loc/db/tbl',\n" - + " 'file_format'='orc')"; - createTable(createUnPartTable, true); - dropTableWithSql("drop table unpart_tbl"); - - String createPartTable = "CREATE TABLE IF NOT EXISTS `part_tbl`(\n" - + " `col1` BOOLEAN COMMENT 'col1',\n" - + " `col2` INT COMMENT 'col2',\n" - + " `col3` BIGINT COMMENT 'col3',\n" - + " `col4` DECIMAL(5,2) COMMENT 'col4',\n" - + " `col5` DATE COMMENT 'col5',\n" - + " `col6` DATETIME COMMENT 'col6',\n" - + " `pt1` VARCHAR(16) COMMENT 'pt1',\n" - + " `pt2` STRING COMMENT 'pt2'\n" - + ") ENGINE=hive\n" - + "PARTITION BY LIST (pt1, pt2) ()\n" - + "PROPERTIES (\n" - + " 'location'='hdfs://loc/db/tbl',\n" - + " 'file_format'='parquet')"; - createTable(createPartTable, true); - // check IF NOT EXISTS - createTable(createPartTable, true); - dropTableWithSql("drop table part_tbl"); - - String createBucketedTableErr = "CREATE TABLE `err_buck_tbl`(\n" - + " `col1` BOOLEAN COMMENT 'col1',\n" - + " `col2` INT COMMENT 'col2',\n" - + " `col3` BIGINT COMMENT 'col3',\n" - + " `col4` DECIMAL(5,2) COMMENT 'col4'\n" - + ") ENGINE=hive\n" - + "DISTRIBUTED BY HASH (col2) BUCKETS 16\n" - + "PROPERTIES (\n" - + " 'location'='hdfs://loc/db/tbl',\n" - + " 'file_format'='orc')"; - ExceptionChecker.expectThrowsWithMsg(org.apache.doris.common.UserException.class, - "errCode = 2," - + " detailMessage = Create hive bucket table need set enable_create_hive_bucket_table to true", - () -> createTable(createBucketedTableErr, true)); - - Config.enable_create_hive_bucket_table = true; - String createBucketedTableOk1 = "CREATE TABLE `buck_tbl`(\n" - + " `col1` BOOLEAN COMMENT 'col1',\n" - + " `col2` INT COMMENT 'col2',\n" - + " `col3` BIGINT COMMENT 'col3',\n" - + " `col4` DECIMAL(5,2) COMMENT 'col4'\n" - + ") ENGINE=hive\n" - + "DISTRIBUTED BY HASH (col2) BUCKETS 16\n" - + "PROPERTIES (\n" - + " 'location'='hdfs://loc/db/tbl',\n" - + " 'file_format'='orc')"; - createTable(createBucketedTableOk1, true); - dropTableWithSql("drop table buck_tbl"); - - String createBucketedTableOk2 = "CREATE TABLE `part_buck_tbl`(\n" - + " `col1` BOOLEAN COMMENT 'col1',\n" - + " `col2` INT COMMENT 'col2',\n" - + " `col3` BIGINT COMMENT 'col3',\n" - + " `col4` DECIMAL(5,2) COMMENT 'col4',\n" - + " `pt1` VARCHAR(16) COMMENT 'pt1',\n" - + " `pt2` STRING COMMENT 'pt2'\n" - + ") ENGINE=hive\n" - + "PARTITION BY LIST (pt2) ()\n" - + "DISTRIBUTED BY HASH (col2) BUCKETS 16\n" - + "PROPERTIES (\n" - + " 'location'='hdfs://loc/db/tbl',\n" - + " 'file_format'='orc')"; - createTable(createBucketedTableOk2, true); - dropTableWithSql("drop table part_buck_tbl"); - } - - @Test - public void testCTASPlanSql() throws Exception { - switchHive(); - useDatabase(mockedDbName); - // external to external table - String ctas1 = "CREATE TABLE hive_ctas1 AS SELECT col1 FROM unpart_ctas_src WHERE col2='a';"; - LogicalPlan st1 = nereidsParser.parseSingle(ctas1); - Assertions.assertTrue(st1 instanceof CreateTableCommand); - // ((CreateTableCommand) st1).run(connectContext, null); - String its1 = "INSERT INTO hive_ctas1 SELECT col1 FROM unpart_ctas_src WHERE col2='a';"; - LogicalPlan it1 = nereidsParser.parseSingle(its1); - Assertions.assertTrue(it1 instanceof InsertIntoTableCommand); - // ((InsertIntoTableCommand) it1).run(connectContext, null); - // partitioned table - String ctasU1 = "CREATE TABLE hive_ctas2 AS SELECT col1,pt1,pt2 FROM part_ctas_src WHERE col1>0;"; - LogicalPlan stU1 = nereidsParser.parseSingle(ctasU1); - Assertions.assertTrue(stU1 instanceof CreateTableCommand); - // ((CreateTableCommand) stU1).run(connectContext, null); - String itsp1 = "INSERT INTO hive_ctas2 SELECT col1,pt1,pt2 FROM part_ctas_src WHERE col1>0;"; - LogicalPlan itp1 = nereidsParser.parseSingle(itsp1); - Assertions.assertTrue(itp1 instanceof InsertIntoTableCommand); - // ((InsertIntoTableCommand) itp1).run(connectContext, null); - - // external to internal table - switchInternal(); - useDatabase(mockedDbName); - String ctas2 = "CREATE TABLE olap_ctas1 AS SELECT col1,col2 FROM hive.mockedDb.unpart_ctas_src WHERE col2='a';"; - LogicalPlan st2 = nereidsParser.parseSingle(ctas2); - Assertions.assertTrue(st2 instanceof CreateTableCommand); - // ((CreateTableCommand) st2).run(connectContext, null); - - // partitioned table - String ctasU2 = "CREATE TABLE olap_ctas2 AS SELECT col1,pt1,pt2 FROM hive.mockedDb.part_ctas_src WHERE col1>0;"; - LogicalPlan stU2 = nereidsParser.parseSingle(ctasU2); - Assertions.assertTrue(stU2 instanceof CreateTableCommand); - // ((CreateTableCommand) stU2).run(connectContext, null); - - // internal to external table - String ctas3 = "CREATE TABLE hive.mockedDb.ctas_o1 AS SELECT col1,col2 FROM unpart_ctas_olap WHERE col2='a';"; - LogicalPlan st3 = nereidsParser.parseSingle(ctas3); - Assertions.assertTrue(st3 instanceof CreateTableCommand); - // ((CreateTableCommand) st3).run(connectContext, null); - - String its2 = "INSERT INTO hive.mockedDb.ctas_o1 SELECT col1,col2 FROM unpart_ctas_olap WHERE col2='a';"; - LogicalPlan it2 = nereidsParser.parseSingle(its2); - Assertions.assertTrue(it2 instanceof InsertIntoTableCommand); - // ((InsertIntoTableCommand) it2).run(connectContext, null); - - String ctasP3 = "CREATE TABLE hive.mockedDb.ctas_o2 AS SELECT col1,pt1,pt2 FROM part_ctas_olap WHERE col1>0;"; - LogicalPlan stP3 = nereidsParser.parseSingle(ctasP3); - Assertions.assertTrue(stP3 instanceof CreateTableCommand); - // ((CreateTableCommand) stP3).run(connectContext, null); - - String itsp2 = "INSERT INTO hive.mockedDb.ctas_o2 SELECT col1,pt1,pt2 FROM part_ctas_olap WHERE col1>0;"; - LogicalPlan itp2 = nereidsParser.parseSingle(itsp2); - Assertions.assertTrue(itp2 instanceof InsertIntoTableCommand); - // ((InsertIntoTableCommand) itp2).run(connectContext, null); - - // test olap CTAS in hive catalog - FeConstants.runningUnitTest = true; - String createOlapSrc = "CREATE TABLE `olap_src`(\n" - + " `col1` BOOLEAN COMMENT 'col1',\n" - + " `col2` INT COMMENT 'col2',\n" - + " `col3` BIGINT COMMENT 'col3',\n" - + " `col4` DECIMAL(5,2) COMMENT 'col4'\n" - + ")\n" - + "DISTRIBUTED BY HASH (col1) BUCKETS 100\n" - + "PROPERTIES (\n" - + " 'replication_num' = '1')"; - createTable(createOlapSrc, true); - switchHive(); - useDatabase(mockedDbName); - String olapCtasErr = "CREATE TABLE no_buck_olap ENGINE=olap AS SELECT * FROM internal.mockedDb.olap_src"; - LogicalPlan olapCtasErrPlan = nereidsParser.parseSingle(olapCtasErr); - Assertions.assertTrue(olapCtasErrPlan instanceof CreateTableCommand); - ExceptionChecker.expectThrowsWithMsg(org.apache.doris.nereids.exceptions.AnalysisException.class, - "Cannot create olap table out of internal catalog." - + " Make sure 'engine' type is specified when use the catalog: hive", - () -> ((CreateTableCommand) olapCtasErrPlan).run(connectContext, null)); - - String olapCtasOk = "CREATE TABLE internal.mockedDb.no_buck_olap ENGINE=olap" - + " PROPERTIES('replication_num' = '1')" - + " AS SELECT * FROM internal.mockedDb.olap_src"; - LogicalPlan olapCtasOkPlan = createTablesAndReturnPlans(olapCtasOk).get(0); - CreateTableInfo stmt = ((CreateTableCommand) olapCtasOkPlan).getCreateTableInfo(); - Assertions.assertTrue(stmt.getDistributionDesc() instanceof HashDistributionDesc); - Assertions.assertEquals(10, stmt.getDistributionDesc().getBuckets()); - // ((CreateTableCommand) olapCtasOkPlan).run(connectContext, null); - - String olapCtasOk2 = "CREATE TABLE internal.mockedDb.no_buck_olap2 DISTRIBUTED BY HASH (col1) BUCKETS 16" - + " PROPERTIES('replication_num' = '1')" - + " AS SELECT * FROM internal.mockedDb.olap_src"; - LogicalPlan olapCtasOk2Plan = createTablesAndReturnPlans(olapCtasOk2).get(0); - CreateTableInfo createTableInfo = ((CreateTableCommand) olapCtasOk2Plan).getCreateTableInfo(); - Assertions.assertTrue(createTableInfo.getDistributionDesc() instanceof HashDistributionDesc); - Assertions.assertEquals(16, createTableInfo.getDistributionDesc().getBuckets()); - } - - private void mockTargetTable(List schema, Set partNames) { - mockTableSchema = schema; - mockTablePartNames = partNames; - } - - @Test - public void testInsertIntoPlanSql() throws Exception { - switchHive(); - useDatabase(mockedDbName); - String insertTable = "insert_table"; - createTargetTable(insertTable); - - // test un-partitioned table - List schema = new ArrayList() { - { - add(new Column("col1", PrimitiveType.INT)); - add(new Column("col2", PrimitiveType.STRING)); - add(new Column("col3", PrimitiveType.DECIMAL32)); - add(new Column("col4", PrimitiveType.CHAR)); - } - }; - - mockTargetTable(schema, new HashSet<>()); - String unPartTargetTable = "unpart_" + insertTable; - String insertSql = "INSERT INTO " + unPartTargetTable + " values(1, 'v1', 32.1, 'aabb')"; - PhysicalPlan physicalSink = getPhysicalPlan(insertSql, PhysicalProperties.SINK_RANDOM_PARTITIONED, - false); - checkUnpartTableSinkPlan(schema, unPartTargetTable, physicalSink); - - String insertOverwriteSql = "INSERT OVERWRITE TABLE " + unPartTargetTable + " values(1, 'v1', 32.1, 'aabb')"; - PhysicalPlan physicalOverwriteSink = getPhysicalPlan(insertOverwriteSql, PhysicalProperties.SINK_RANDOM_PARTITIONED, - true); - checkUnpartTableSinkPlan(schema, unPartTargetTable, physicalOverwriteSink); - - // test partitioned table - schema = new ArrayList() { - { - add(new Column("col1", PrimitiveType.INT)); - add(new Column("pt1", PrimitiveType.VARCHAR)); - add(new Column("pt2", PrimitiveType.STRING)); - add(new Column("pt3", PrimitiveType.DATE)); - } - }; - Set parts = new HashSet() { - { - add("pt1"); - add("pt2"); - add("pt3"); - } - }; - mockTargetTable(schema, parts); - String partTargetTable = "part_" + insertTable; - - String insertSql2 = "INSERT INTO " + partTargetTable + " values(1, 'v1', 'v2', '2020-03-13')"; - PhysicalPlan physicalSink2 = getPhysicalPlan(insertSql2, - new PhysicalProperties(new DistributionSpecHiveTableSinkHashPartitioned()), false); - checkPartTableSinkPlan(schema, partTargetTable, physicalSink2); - - String insertOverwrite2 = "INSERT OVERWRITE TABLE " + partTargetTable + " values(1, 'v1', 'v2', '2020-03-13')"; - PhysicalPlan physicalOverwriteSink2 = getPhysicalPlan(insertOverwrite2, - new PhysicalProperties(new DistributionSpecHiveTableSinkHashPartitioned()), true); - checkPartTableSinkPlan(schema, partTargetTable, physicalOverwriteSink2); - } - - private static void checkUnpartTableSinkPlan(List schema, String unPartTargetTable, PhysicalPlan physicalSink) { - Assertions.assertSame(physicalSink.getType(), PlanType.PHYSICAL_DISTRIBUTE); - // check exchange - PhysicalDistribute distribute = (PhysicalDistribute) physicalSink; - Assertions.assertTrue(distribute.getDistributionSpec() instanceof DistributionSpecHiveTableSinkUnPartitioned); - Assertions.assertSame(distribute.child(0).getType(), PlanType.PHYSICAL_HIVE_TABLE_SINK); - // check sink - PhysicalHiveTableSink physicalHiveSink = (PhysicalHiveTableSink) physicalSink.child(0); - Assertions.assertEquals(unPartTargetTable, physicalHiveSink.getTargetTable().getName()); - Assertions.assertEquals(schema.size(), physicalHiveSink.getOutput().size()); - } - - private static void checkPartTableSinkPlan(List schema, String unPartTargetTable, PhysicalPlan physicalSink) { - Assertions.assertSame(physicalSink.getType(), PlanType.PHYSICAL_DISTRIBUTE); - // check exchange - PhysicalDistribute distribute2 = (PhysicalDistribute) physicalSink; - Assertions.assertTrue(distribute2.getDistributionSpec() instanceof DistributionSpecHiveTableSinkHashPartitioned); - Assertions.assertSame(distribute2.child(0).getType(), PlanType.PHYSICAL_HIVE_TABLE_SINK); - // check sink - PhysicalHiveTableSink physicalHiveSink2 = (PhysicalHiveTableSink) physicalSink.child(0); - Assertions.assertEquals(unPartTargetTable, physicalHiveSink2.getTargetTable().getName()); - Assertions.assertEquals(schema.size(), physicalHiveSink2.getOutput().size()); - } - - private void createTargetTable(String tableName) throws Exception { - String createInsertTable = "CREATE TABLE `unpart_" + tableName + "`(\n" - + " `col1` INT COMMENT 'col1',\n" - + " `col2` STRING COMMENT 'col2',\n" - + " `col3` DECIMAL(3,1) COMMENT 'col3',\n" - + " `col4` CHAR(11) COMMENT 'col4'\n" - + ") ENGINE=hive\n" - + "PROPERTIES ('file_format'='orc')"; - createTable(createInsertTable, true); - - String createInsertPTable = "CREATE TABLE `part_" + tableName + "`(\n" - + " `col1` INT COMMENT 'col1',\n" - + " `pt1` VARCHAR(16) COMMENT 'pt1',\n" - + " `pt2` STRING COMMENT 'pt2',\n" - + " `pt3` DATE COMMENT 'pt3'\n" - + ") ENGINE=hive\n" - + "PARTITION BY LIST (pt1, pt2, pt3) ()\n" - + "PROPERTIES ('file_format'='orc')"; - createTable(createInsertPTable, true); - } - - private PhysicalPlan getPhysicalPlan(String insertSql, PhysicalProperties physicalProperties, - boolean isOverwrite) { - LogicalPlan plan = nereidsParser.parseSingle(insertSql); - StatementContext statementContext = MemoTestUtils.createStatementContext(connectContext, insertSql); - Plan exPlan; - if (isOverwrite) { - Assertions.assertTrue(plan instanceof InsertOverwriteTableCommand); - exPlan = ((InsertOverwriteTableCommand) plan).getExplainPlan(connectContext); - } else { - Assertions.assertTrue(plan instanceof InsertIntoTableCommand); - exPlan = ((InsertIntoTableCommand) plan).getExplainPlan(connectContext); - } - Assertions.assertTrue(exPlan instanceof UnboundLogicalSink); - NereidsPlanner planner = new NereidsPlanner(statementContext); - return planner.planWithLock((UnboundLogicalSink) exPlan, physicalProperties); - } - - @Test - public void testComplexTypeCreateTable() throws Exception { - checkedHiveCols = new ArrayList<>(); // init it to enable check - switchHive(); - useDatabase(mockedDbName); - String createArrayTypeTable = "CREATE TABLE complex_type_array(\n" - + " `col1` ARRAY COMMENT 'col1',\n" - + " `col2` ARRAY COMMENT 'col2',\n" - + " `col3` ARRAY COMMENT 'col3',\n" - + " `col4` ARRAY COMMENT 'col4',\n" - + " `col5` ARRAY COMMENT 'col5'\n" - + ") ENGINE=hive\n" - + "PROPERTIES ('file_format'='orc')"; - List checkArrayCols = new ArrayList<>(); - checkArrayCols.add(new FieldSchema("col1", "array", "")); - checkArrayCols.add(new FieldSchema("col2", "array", "")); - checkArrayCols.add(new FieldSchema("col3", "array", "")); - checkArrayCols.add(new FieldSchema("col4", "array", "")); - checkArrayCols.add(new FieldSchema("col5", "array", "")); - resetCheckedColumns(checkArrayCols); - - LogicalPlan plan = createTablesAndReturnPlans(createArrayTypeTable).get(0); - List columns = ((CreateTableCommand) plan).getCreateTableInfo().getColumns(); - Assertions.assertEquals(5, columns.size()); - dropTableWithSql("drop table complex_type_array"); - - String createMapTypeTable = "CREATE TABLE complex_type_map(\n" - + " `col1` MAP COMMENT 'col1',\n" - + " `col2` MAP COMMENT 'col2',\n" - + " `col3` MAP COMMENT 'col3',\n" - + " `col4` MAP COMMENT 'col4'\n" - + ") ENGINE=hive\n" - + "PROPERTIES ('file_format'='orc')"; - checkArrayCols = new ArrayList<>(); - checkArrayCols.add(new FieldSchema("col1", "map", "")); - checkArrayCols.add(new FieldSchema("col2", "map", "")); - checkArrayCols.add(new FieldSchema("col3", "map", "")); - checkArrayCols.add(new FieldSchema("col4", "map", "")); - resetCheckedColumns(checkArrayCols); - - plan = createTablesAndReturnPlans(createMapTypeTable).get(0); - columns = ((CreateTableCommand) plan).getCreateTableInfo().getColumns(); - Assertions.assertEquals(4, columns.size()); - dropTableWithSql("drop table complex_type_map"); - - String createStructTypeTable = "CREATE TABLE complex_type_struct(\n" - + " `col1` STRUCT,name:string> COMMENT 'col1',\n" - + " `col2` STRUCT COMMENT 'col2',\n" - + " `col3` STRUCT COMMENT 'col3',\n" - + " `col4` STRUCT> COMMENT 'col4'\n" - + ") ENGINE=hive\n" - + "PROPERTIES ('file_format'='orc')"; - checkArrayCols = new ArrayList<>(); - checkArrayCols.add(new FieldSchema("col1", "struct,name:string>", "")); - checkArrayCols.add(new FieldSchema("col2", "struct", "")); - checkArrayCols.add(new FieldSchema("col3", "struct", "")); - checkArrayCols.add(new FieldSchema("col4", "struct>", "")); - resetCheckedColumns(checkArrayCols); - - plan = createTablesAndReturnPlans(createStructTypeTable).get(0); - columns = ((CreateTableCommand) plan).getCreateTableInfo().getColumns(); - Assertions.assertEquals(4, columns.size()); - dropTableWithSql("drop table complex_type_struct"); - - String compoundTypeTable1 = "CREATE TABLE complex_type_compound1(\n" - + " `col1` ARRAY> COMMENT 'col1',\n" - + " `col2` ARRAY> COMMENT 'col2'\n" - + ") ENGINE=hive\n" - + "PROPERTIES ('file_format'='orc')"; - checkArrayCols = new ArrayList<>(); - checkArrayCols.add(new FieldSchema("col1", "array>", "")); - checkArrayCols.add(new FieldSchema("col2", - "array>", "")); - resetCheckedColumns(checkArrayCols); - - plan = createTablesAndReturnPlans(compoundTypeTable1).get(0); - columns = ((CreateTableCommand) plan).getCreateTableInfo().getColumns(); - Assertions.assertEquals(2, columns.size()); - dropTableWithSql("drop table complex_type_compound1"); - - String compoundTypeTable2 = "CREATE TABLE complex_type_compound2(\n" - + " `col1` MAP> COMMENT 'col1',\n" - + " `col2` MAP>> COMMENT 'col2',\n" - + " `col3` MAP> COMMENT 'col3',\n" - + " `col4` MAP> COMMENT 'col4'\n" - + ") ENGINE=hive\n" - + "PROPERTIES ('file_format'='orc')"; - checkArrayCols = new ArrayList<>(); - checkArrayCols.add(new FieldSchema("col1", "map>", "")); - checkArrayCols.add(new FieldSchema("col2", "map>>", "")); - checkArrayCols.add(new FieldSchema("col3", "map>", "")); - checkArrayCols.add(new FieldSchema("col4", - "map>", "")); - resetCheckedColumns(checkArrayCols); - - plan = createTablesAndReturnPlans(compoundTypeTable2).get(0); - columns = ((CreateTableCommand) plan).getCreateTableInfo().getColumns(); - Assertions.assertEquals(4, columns.size()); - dropTableWithSql("drop table complex_type_compound2"); - - } - - private void resetCheckedColumns(List checkArrayCols) { - checkedHiveCols.clear(); - checkedHiveCols.addAll(checkArrayCols); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HiveMetaStoreCacheTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HiveMetaStoreCacheTest.java deleted file mode 100644 index 62f70b77982e78..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HiveMetaStoreCacheTest.java +++ /dev/null @@ -1,324 +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.datasource.hive; - -import org.apache.doris.catalog.PartitionItem; -import org.apache.doris.common.Config; -import org.apache.doris.common.ThreadPoolManager; -import org.apache.doris.common.util.Util; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.metacache.MetaCacheEntry; -import org.apache.doris.datasource.metacache.MetaCacheEntryStats; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.atomic.AtomicLong; - -public class HiveMetaStoreCacheTest { - - @Test - public void testInvalidateTableCache() { - ThreadPoolExecutor executor = ThreadPoolManager.newDaemonFixedThreadPool( - 1, 1, "refresh", 1, false); - ThreadPoolExecutor listExecutor = ThreadPoolManager.newDaemonFixedThreadPool( - 1, 1, "file", 1, false); - - HiveExternalMetaCache hiveMetaStoreCache = new HiveExternalMetaCache(executor, listExecutor); - hiveMetaStoreCache.initCatalog(0, new HashMap<>()); - - MetaCacheEntry fileCache = - hiveMetaStoreCache.entry(0, HiveExternalMetaCache.ENTRY_FILE, - HiveExternalMetaCache.FileCacheKey.class, - HiveExternalMetaCache.FileCacheValue.class); - MetaCacheEntry partitionCache = - hiveMetaStoreCache.entry(0, HiveExternalMetaCache.ENTRY_PARTITION, - HiveExternalMetaCache.PartitionCacheKey.class, - HivePartition.class); - MetaCacheEntry - partitionValuesCache = hiveMetaStoreCache.entry(0, HiveExternalMetaCache.ENTRY_PARTITION_VALUES, - HiveExternalMetaCache.PartitionValueCacheKey.class, - HiveExternalMetaCache.HivePartitionValues.class); - - String dbName = "db"; - String tbName = "tb"; - String tbName2 = "tb2"; - - putCache(fileCache, partitionCache, partitionValuesCache, dbName, tbName); - Assertions.assertEquals(2, entrySize(fileCache)); - Assertions.assertEquals(1, entrySize(partitionCache)); - Assertions.assertEquals(1, entrySize(partitionValuesCache)); - - putCache(fileCache, partitionCache, partitionValuesCache, dbName, tbName2); - Assertions.assertEquals(4, entrySize(fileCache)); - Assertions.assertEquals(2, entrySize(partitionCache)); - Assertions.assertEquals(2, entrySize(partitionValuesCache)); - - hiveMetaStoreCache.invalidateTableCache(NameMapping.createForTest(dbName, tbName2)); - Assertions.assertEquals(2, entrySize(fileCache)); - Assertions.assertEquals(1, entrySize(partitionCache)); - Assertions.assertEquals(1, entrySize(partitionValuesCache)); - - hiveMetaStoreCache.invalidateTableCache(NameMapping.createForTest(dbName, tbName)); - Assertions.assertEquals(0, entrySize(fileCache)); - Assertions.assertEquals(0, entrySize(partitionCache)); - Assertions.assertEquals(0, entrySize(partitionValuesCache)); - } - - @Test - public void testInvalidatePartitionCacheClearsStaleFileCacheOnPartitionMiss() { - ThreadPoolExecutor executor = ThreadPoolManager.newDaemonFixedThreadPool( - 1, 1, "refresh", 1, false); - ThreadPoolExecutor listExecutor = ThreadPoolManager.newDaemonFixedThreadPool( - 1, 1, "file", 1, false); - try { - HiveExternalMetaCache cache = new HiveExternalMetaCache(executor, listExecutor); - cache.initCatalog(0, new HashMap<>()); - - MetaCacheEntry fileCache = - cache.entry(0, HiveExternalMetaCache.ENTRY_FILE, - HiveExternalMetaCache.FileCacheKey.class, - HiveExternalMetaCache.FileCacheValue.class); - - String dbName = "db"; - String tbName = "tb"; - NameMapping nameMapping = NameMapping.createForTest(dbName, tbName); - long catalogId = nameMapping.getCtlId(); - long tableId = Util.genIdByName(dbName, tbName); - long otherTableId = Util.genIdByName(dbName, "tb2"); - - String targetPartName = "dt=2024-01-01"; - List targetValues = Collections.singletonList("2024-01-01"); - String otherPartName = "dt=2024-01-02"; - List otherValues = Collections.singletonList("2024-01-02"); - - // Neither the `partition` cache nor the `partition_values` cache is populated for this table, - // simulating entries that were evicted or never loaded. invalidatePartitionCache must still - // clear the stale file listing: it derives the partition values from the partition name and - // cannot rebuild the exact FileCacheKey (which needs the partition path / input format). - HiveExternalMetaCache.FileCacheKey targetFileKey = new HiveExternalMetaCache.FileCacheKey( - catalogId, tableId, "/wh/db/tb/" + targetPartName, "orc", targetValues); - // Same table, a different partition -> must be kept. - HiveExternalMetaCache.FileCacheKey otherPartFileKey = new HiveExternalMetaCache.FileCacheKey( - catalogId, tableId, "/wh/db/tb/" + otherPartName, "orc", otherValues); - // A different table that merely shares the same partition value names at a different location - // -> must be kept (the fallback is intentionally scoped by table id, not by values alone). - HiveExternalMetaCache.FileCacheKey otherTableFileKey = new HiveExternalMetaCache.FileCacheKey( - catalogId, otherTableId, "/wh/db/tb2/" + targetPartName, "orc", targetValues); - fileCache.put(targetFileKey, new HiveExternalMetaCache.FileCacheValue()); - fileCache.put(otherPartFileKey, new HiveExternalMetaCache.FileCacheValue()); - fileCache.put(otherTableFileKey, new HiveExternalMetaCache.FileCacheValue()); - Assertions.assertEquals(3, entrySize(fileCache)); - - // Partition-level refresh for the target partition. Even though its `partition` cache entry - // is missing, the stale file listing for that partition must still be invalidated. - cache.invalidatePartitionCache(nameMapping, targetPartName); - - Assertions.assertNull(fileCache.getIfPresent(targetFileKey), - "stale file cache for the refreshed partition must be cleared even on partition cache miss"); - Assertions.assertNotNull(fileCache.getIfPresent(otherPartFileKey), - "file cache for other partitions of the same table must NOT be affected"); - Assertions.assertNotNull(fileCache.getIfPresent(otherTableFileKey), - "file cache for other tables sharing the same partition values must NOT be affected"); - Assertions.assertEquals(2, entrySize(fileCache)); - } finally { - executor.shutdownNow(); - listExecutor.shutdownNow(); - } - } - - @Test - public void testDefaultSpecsFollowConfig() { - ThreadPoolExecutor executor = ThreadPoolManager.newDaemonFixedThreadPool( - 1, 1, "refresh", 1, false); - ThreadPoolExecutor listExecutor = ThreadPoolManager.newDaemonFixedThreadPool( - 1, 1, "file", 1, false); - long originalExpireAfterAccess = Config.external_cache_expire_time_seconds_after_access; - long originalPartitionCapacity = Config.max_hive_partition_cache_num; - long originalPartitionTableCapacity = Config.max_hive_partition_table_cache_num; - long originalFileCapacity = Config.max_external_file_cache_num; - try { - Config.external_cache_expire_time_seconds_after_access = 321L; - Config.max_hive_partition_cache_num = 100L; - Config.max_hive_partition_table_cache_num = 20L; - Config.max_external_file_cache_num = 30L; - - HiveExternalMetaCache hiveMetaStoreCache = new HiveExternalMetaCache(executor, listExecutor); - hiveMetaStoreCache.initCatalog(0, Collections.emptyMap()); - - Map stats = hiveMetaStoreCache.stats(0); - MetaCacheEntryStats partitionValuesStats = stats.get(HiveExternalMetaCache.ENTRY_PARTITION_VALUES); - MetaCacheEntryStats partitionStats = stats.get(HiveExternalMetaCache.ENTRY_PARTITION); - MetaCacheEntryStats fileStats = stats.get(HiveExternalMetaCache.ENTRY_FILE); - Assertions.assertEquals(321L, partitionValuesStats.getTtlSecond()); - Assertions.assertEquals(20L, partitionValuesStats.getCapacity()); - Assertions.assertEquals(321L, partitionStats.getTtlSecond()); - Assertions.assertEquals(100L, partitionStats.getCapacity()); - Assertions.assertEquals(321L, fileStats.getTtlSecond()); - Assertions.assertEquals(30L, fileStats.getCapacity()); - } finally { - Config.external_cache_expire_time_seconds_after_access = originalExpireAfterAccess; - Config.max_hive_partition_cache_num = originalPartitionCapacity; - Config.max_hive_partition_table_cache_num = originalPartitionTableCapacity; - Config.max_external_file_cache_num = originalFileCapacity; - executor.shutdownNow(); - listExecutor.shutdownNow(); - } - } - - @Test - public void testHivePartitionValuesCopyKeepsIndependentNameMaps() { - Map nameToPartitionItem = new HashMap<>(); - Map> nameToPartitionValues = new HashMap<>(); - nameToPartitionValues.put("dt=2026-06-26", Collections.singletonList("2026-06-26")); - HiveExternalMetaCache.HivePartitionValues partitionValues = - new HiveExternalMetaCache.HivePartitionValues(nameToPartitionItem, nameToPartitionValues); - - HiveExternalMetaCache.HivePartitionValues copy = partitionValues.copy(); - copy.getNameToPartitionValues().put("dt=2026-06-27", Collections.singletonList("2026-06-27")); - Assertions.assertFalse(partitionValues.getNameToPartitionValues().containsKey("dt=2026-06-27")); - } - - private void putCache( - MetaCacheEntry fileCache, - MetaCacheEntry partitionCache, - MetaCacheEntry - partitionValuesCache, - String dbName, String tbName) { - NameMapping nameMapping = NameMapping.createForTest(dbName, tbName); - long catalogId = nameMapping.getCtlId(); - long fileId = Util.genIdByName(dbName, tbName); - HiveExternalMetaCache.FileCacheKey fileCacheKey1 = new HiveExternalMetaCache.FileCacheKey( - catalogId, fileId, tbName, "", new ArrayList<>()); - HiveExternalMetaCache.FileCacheKey fileCacheKey2 = HiveExternalMetaCache.FileCacheKey - .createDummyCacheKey(catalogId, fileId, tbName, ""); - fileCache.put(fileCacheKey1, new HiveExternalMetaCache.FileCacheValue()); - fileCache.put(fileCacheKey2, new HiveExternalMetaCache.FileCacheValue()); - - HiveExternalMetaCache.PartitionCacheKey partitionCacheKey = new HiveExternalMetaCache.PartitionCacheKey( - nameMapping, - new ArrayList<>() - ); - partitionCache.put(partitionCacheKey, - new HivePartition(nameMapping, false, "", "", new ArrayList<>(), new HashMap<>())); - - HiveExternalMetaCache.PartitionValueCacheKey partitionValueCacheKey - = new HiveExternalMetaCache.PartitionValueCacheKey(nameMapping, new ArrayList<>()); - partitionValuesCache.put(partitionValueCacheKey, new HiveExternalMetaCache.HivePartitionValues()); - - } - - private long entrySize(MetaCacheEntry entry) { - AtomicLong count = new AtomicLong(); - entry.forEach((k, v) -> count.incrementAndGet()); - return count.get(); - } - - // ------------------------------------------------------------------------- - // FileCacheKey identity: inputFormat must be part of equals() / hashCode() - // so that two tables at the same partition location but with different - // InputFormats (e.g. TextInputFormat vs LzoTextInputFormat) never share - // a cached FileCacheValue. - // ------------------------------------------------------------------------- - - @Test - public void testFileCacheKeyIdentity_SameInputFormat_Equal() { - long catalogId = 1L; - long id = 100L; - String location = "hdfs://namenode/warehouse/db/tbl/dt=2024-01-01"; - String inputFormat = "org.apache.hadoop.mapred.TextInputFormat"; - ArrayList partitionValues = new ArrayList<>(); - - HiveExternalMetaCache.FileCacheKey key1 = new HiveExternalMetaCache.FileCacheKey( - catalogId, id, location, inputFormat, partitionValues); - HiveExternalMetaCache.FileCacheKey key2 = new HiveExternalMetaCache.FileCacheKey( - catalogId, id, location, inputFormat, partitionValues); - - Assertions.assertEquals(key1, key2, - "Keys with same catalogId, location, inputFormat and partitionValues must be equal"); - Assertions.assertEquals(key1.hashCode(), key2.hashCode(), - "Equal keys must have equal hashCodes"); - } - - @Test - public void testFileCacheKeyIdentity_DifferentInputFormat_NotEqual() { - long catalogId = 1L; - long id = 100L; - String location = "hdfs://namenode/warehouse/db/tbl/dt=2024-01-01"; - ArrayList partitionValues = new ArrayList<>(); - - // TextInputFormat table and LzoTextInputFormat table share the same partition path. - // Without inputFormat in the cache identity they would collide and one table could - // inherit the other's file list (e.g. TextInputFormat table inherits filtered .lzo - // listing, or LZO table inherits an unfiltered, splittable listing). - HiveExternalMetaCache.FileCacheKey textKey = new HiveExternalMetaCache.FileCacheKey( - catalogId, id, location, - "org.apache.hadoop.mapred.TextInputFormat", partitionValues); - HiveExternalMetaCache.FileCacheKey lzoKey = new HiveExternalMetaCache.FileCacheKey( - catalogId, id, location, - "com.hadoop.mapreduce.LzoTextInputFormat", partitionValues); - - Assertions.assertNotEquals(textKey, lzoKey, - "Keys with different inputFormats must NOT be equal even when location is identical"); - Assertions.assertNotEquals(textKey.hashCode(), lzoKey.hashCode(), - "Keys with different inputFormats should have different hashCodes"); - } - - @Test - public void testFileCacheKeyIdentity_AllLzoVariants_Distinct() { - long catalogId = 1L; - long id = 100L; - String location = "hdfs://namenode/warehouse/db/tbl/dt=2024-01-01"; - ArrayList partitionValues = new ArrayList<>(); - - HiveExternalMetaCache.FileCacheKey lzoKey = new HiveExternalMetaCache.FileCacheKey( - catalogId, id, location, "com.hadoop.compression.lzo.LzoTextInputFormat", partitionValues); - HiveExternalMetaCache.FileCacheKey lzoMrKey = new HiveExternalMetaCache.FileCacheKey( - catalogId, id, location, "com.hadoop.mapreduce.LzoTextInputFormat", partitionValues); - HiveExternalMetaCache.FileCacheKey deprecatedLzoKey = new HiveExternalMetaCache.FileCacheKey( - catalogId, id, location, "com.hadoop.mapred.DeprecatedLzoTextInputFormat", partitionValues); - - // All three LZO variants are distinct input formats and must produce distinct cache keys. - Assertions.assertNotEquals(lzoKey, lzoMrKey); - Assertions.assertNotEquals(lzoKey, deprecatedLzoKey); - Assertions.assertNotEquals(lzoMrKey, deprecatedLzoKey); - } - - @Test - public void testFileCacheKeyIdentity_DummyKey_IgnoresInputFormat() { - // Dummy keys are keyed by (catalogId, id) only; inputFormat must not affect them. - long catalogId = 1L; - long id = 100L; - String location = "hdfs://namenode/warehouse/db/tbl"; - - HiveExternalMetaCache.FileCacheKey dummy1 = HiveExternalMetaCache.FileCacheKey - .createDummyCacheKey(catalogId, id, location, "org.apache.hadoop.mapred.TextInputFormat"); - HiveExternalMetaCache.FileCacheKey dummy2 = HiveExternalMetaCache.FileCacheKey - .createDummyCacheKey(catalogId, id, location, "com.hadoop.mapreduce.LzoTextInputFormat"); - - Assertions.assertEquals(dummy1, dummy2, - "Dummy keys with same catalogId and id must be equal regardless of inputFormat"); - Assertions.assertEquals(dummy1.hashCode(), dummy2.hashCode()); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HiveUtilTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HiveUtilTest.java deleted file mode 100644 index 4abbbd10020244..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HiveUtilTest.java +++ /dev/null @@ -1,175 +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.datasource.hive; - -import org.apache.doris.common.UserException; -import org.apache.doris.filesystem.FileSystem; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -/** - * Unit tests for {@link HiveUtil}, focusing on isSplittable() behavior - * for LZO compressed text InputFormats. - */ -public class HiveUtilTest { - - private static final FileSystem MOCK_FS = Mockito.mock(FileSystem.class); - private static final String DUMMY_LOCATION = "hdfs://namenode/warehouse/test.lzo"; - - // ------------------------------------------------------------------------- - // LZO InputFormat variants: must NOT be splittable - // ------------------------------------------------------------------------- - - @Test - public void testIsSplittable_CompressionLzoTextInputFormat_ReturnsFalse() throws UserException { - // twitter hadoop-lzo: com.hadoop.compression.lzo.LzoTextInputFormat - // LZO files have no global index by default, so they cannot be split. - boolean result = HiveUtil.isSplittable(MOCK_FS, - "com.hadoop.compression.lzo.LzoTextInputFormat", DUMMY_LOCATION); - Assertions.assertFalse(result, - "com.hadoop.compression.lzo.LzoTextInputFormat should not be splittable"); - } - - @Test - public void testIsSplittable_MapreduceLzoTextInputFormat_ReturnsFalse() throws UserException { - // lzo-hadoop (org.anarres) mapreduce API: com.hadoop.mapreduce.LzoTextInputFormat - // LZO files have no global index by default, so they cannot be split. - boolean result = HiveUtil.isSplittable(MOCK_FS, - "com.hadoop.mapreduce.LzoTextInputFormat", DUMMY_LOCATION); - Assertions.assertFalse(result, - "com.hadoop.mapreduce.LzoTextInputFormat should not be splittable"); - } - - @Test - public void testIsSplittable_DeprecatedLzoTextInputFormat_ReturnsFalse() throws UserException { - // lzo-hadoop (org.anarres) legacy mapred API: com.hadoop.mapred.DeprecatedLzoTextInputFormat - // It produces the same .lzo file format and is equally non-splittable. - boolean result = HiveUtil.isSplittable(MOCK_FS, - "com.hadoop.mapred.DeprecatedLzoTextInputFormat", DUMMY_LOCATION); - Assertions.assertFalse(result, - "com.hadoop.mapred.DeprecatedLzoTextInputFormat should not be splittable"); - } - - // ------------------------------------------------------------------------- - // Standard splittable formats: must still be splittable - // ------------------------------------------------------------------------- - - @Test - public void testIsSplittable_TextInputFormat_ReturnsTrue() throws UserException { - boolean result = HiveUtil.isSplittable(MOCK_FS, - "org.apache.hadoop.mapred.TextInputFormat", DUMMY_LOCATION); - Assertions.assertTrue(result, - "org.apache.hadoop.mapred.TextInputFormat should be splittable"); - } - - @Test - public void testIsSplittable_ParquetInputFormat_ReturnsTrue() throws UserException { - boolean result = HiveUtil.isSplittable(MOCK_FS, - "org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat", DUMMY_LOCATION); - Assertions.assertTrue(result, - "MapredParquetInputFormat should be splittable"); - } - - @Test - public void testIsSplittable_OrcInputFormat_ReturnsTrue() throws UserException { - boolean result = HiveUtil.isSplittable(MOCK_FS, - "org.apache.hadoop.hive.ql.io.orc.OrcInputFormat", DUMMY_LOCATION); - Assertions.assertTrue(result, - "OrcInputFormat should be splittable"); - } - - // ------------------------------------------------------------------------- - // Unsupported format: must return false (not in whitelist) - // ------------------------------------------------------------------------- - - @Test - public void testIsSplittable_UnsupportedFormat_ReturnsFalse() throws UserException { - boolean result = HiveUtil.isSplittable(MOCK_FS, - "org.apache.hadoop.mapred.SequenceFileInputFormat", DUMMY_LOCATION); - Assertions.assertFalse(result, - "Unsupported input format should not be splittable"); - } - - // ------------------------------------------------------------------------- - // isLzoInputFormat: class-name detection - // ------------------------------------------------------------------------- - - @Test - public void testIsLzoInputFormat_CompressionVariant() { - Assertions.assertTrue(HiveUtil.isLzoInputFormat("com.hadoop.compression.lzo.LzoTextInputFormat")); - } - - @Test - public void testIsLzoInputFormat_MapreduceVariant() { - Assertions.assertTrue(HiveUtil.isLzoInputFormat("com.hadoop.mapreduce.LzoTextInputFormat")); - } - - @Test - public void testIsLzoInputFormat_DeprecatedVariant() { - Assertions.assertTrue(HiveUtil.isLzoInputFormat("com.hadoop.mapred.DeprecatedLzoTextInputFormat")); - } - - @Test - public void testIsLzoInputFormat_NonLzo() { - Assertions.assertFalse(HiveUtil.isLzoInputFormat("org.apache.hadoop.mapred.TextInputFormat")); - Assertions.assertFalse(HiveUtil.isLzoInputFormat("org.apache.hadoop.hive.ql.io.orc.OrcInputFormat")); - } - - @Test - public void testIsLzoInputFormat_Null_ReturnsFalse() { - // Null inputFormat (e.g. damaged HMS metadata) must not throw NPE; treat as non-LZO. - Assertions.assertFalse(HiveUtil.isLzoInputFormat(null)); - } - - // ------------------------------------------------------------------------- - // isLzoDataFile: sidecar filter - // ------------------------------------------------------------------------- - - @Test - public void testIsLzoDataFile_DataFile() { - // Real LZO data files must be included - Assertions.assertTrue(HiveUtil.isLzoDataFile("hdfs://namenode/warehouse/part-m-00000.lzo")); - Assertions.assertTrue(HiveUtil.isLzoDataFile("/data/part-00001.LZO")); // case-insensitive - } - - @Test - public void testIsLzoDataFile_IndexSidecar_Excluded() { - // .lzo.index sidecar files must be excluded - Assertions.assertFalse(HiveUtil.isLzoDataFile("hdfs://namenode/warehouse/part-m-00000.lzo.index")); - Assertions.assertFalse(HiveUtil.isLzoDataFile("hdfs://namenode/warehouse/part-m-00000.LZO.INDEX")); - } - - @Test - public void testIsLzoDataFile_OtherExtensions_Excluded() { - // Other files in the partition directory must also be excluded for LZO InputFormats - Assertions.assertFalse(HiveUtil.isLzoDataFile("hdfs://namenode/warehouse/part-m-00000")); - Assertions.assertFalse(HiveUtil.isLzoDataFile("hdfs://namenode/warehouse/part-m-00000.gz")); - Assertions.assertFalse(HiveUtil.isLzoDataFile("hdfs://namenode/warehouse/part-m-00000.orc")); - } - - @Test - public void testIsLzoDataFile_QueryStringStripped() { - // Paths with query strings should still be recognised correctly - Assertions.assertTrue( - HiveUtil.isLzoDataFile("hdfs://namenode/warehouse/part-m-00000.lzo?auth=token")); - Assertions.assertFalse( - HiveUtil.isLzoDataFile("hdfs://namenode/warehouse/part-m-00000.lzo.index?auth=token")); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HmsCommitTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HmsCommitTest.java deleted file mode 100644 index f27c44f2ac53fc..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HmsCommitTest.java +++ /dev/null @@ -1,796 +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.datasource.hive; - -import org.apache.doris.analysis.UserIdentity; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; -import org.apache.doris.common.util.DebugUtil; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.TestHMSCachedClient; -import org.apache.doris.filesystem.local.LocalFileSystem; -import org.apache.doris.fs.SpiSwitchingFileSystem; -import org.apache.doris.nereids.trees.plans.commands.insert.HiveInsertCommandContext; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.thrift.THiveLocationParams; -import org.apache.doris.thrift.THivePartitionUpdate; -import org.apache.doris.thrift.TUniqueId; -import org.apache.doris.thrift.TUpdateMode; - -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import org.apache.hadoop.hive.conf.HiveConf; -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.Table; -import org.junit.After; -import org.junit.AfterClass; -import org.junit.Assert; -import org.junit.Before; -import org.junit.BeforeClass; -import org.junit.Test; -import org.mockito.MockedConstruction; -import org.mockito.Mockito; - -import java.io.IOException; -import java.lang.reflect.Field; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Random; -import java.util.UUID; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Consumer; - -public class HmsCommitTest { - - private static HiveMetadataOps hmsOps; - private static HMSCachedClient hmsClient; - - private static SpiSwitchingFileSystem fileSystemProvider; - private static final String dbName = "test_db"; - private static final String tbWithPartition = "test_tb_with_partition"; - private static final String tbWithoutPartition = "test_tb_without_partition"; - private static SpiSwitchingFileSystem fs; - private static Executor fileSystemExecutor; - static String dbLocation; - static String writeLocation; - static String uri = "thrift://127.0.0.1:9083"; - static boolean hasRealHmsService = false; - private ConnectContext connectContext; - private final List mockedConstructions = new ArrayList<>(); - private Consumer transactionSpySetup; - - @BeforeClass - public static void beforeClass() throws Throwable { - Path warehousePath = Files.createTempDirectory("test_warehouse_"); - Path writePath = Files.createTempDirectory("test_write_"); - dbLocation = "file://" + warehousePath.toAbsolutePath() + "/"; - writeLocation = "file://" + writePath.toAbsolutePath() + "/"; - createTestHiveCatalog(); - createTestHiveDatabase(); - } - - @AfterClass - public static void afterClass() { - hmsClient.dropDatabase(dbName); - } - - public static void createTestHiveCatalog() throws IOException { - fs = new SpiSwitchingFileSystem(new LocalFileSystem(java.util.Collections.emptyMap())); - fileSystemProvider = fs; - - if (hasRealHmsService) { - // If you have a real HMS service, then you can use this client to create real connections for testing - HiveConf entries = new HiveConf(); - entries.set("hive.metastore.uris", uri); - hmsClient = new ThriftHMSCachedClient(entries, 2, new ExecutionAuthenticator() { - }); - } else { - hmsClient = new TestHMSCachedClient(); - } - hmsOps = new HiveMetadataOps(null, hmsClient); - fileSystemExecutor = Executors.newFixedThreadPool(16); - } - - public static void createTestHiveDatabase() { - // create database - HiveDatabaseMetadata dbMetadata = new HiveDatabaseMetadata(); - dbMetadata.setDbName(dbName); - dbMetadata.setLocationUri(dbLocation); - hmsClient.createDatabase(dbMetadata); - } - - @Before - public void before() throws IOException { - // create table for tbWithPartition - List columns = new ArrayList<>(); - columns.add(new Column("c1", PrimitiveType.INT, true)); - columns.add(new Column("c2", PrimitiveType.STRING, true)); - columns.add(new Column("c3", PrimitiveType.STRING, false)); - List partitionKeys = new ArrayList<>(); - partitionKeys.add("c3"); - String fileFormat = "orc"; - Map tblProperties = Maps.newHashMap(); - tblProperties.put("owner", "admin"); - HiveTableMetadata tableMetadata = new HiveTableMetadata( - dbName, tbWithPartition, Optional.of(dbLocation + tbWithPartition + UUID.randomUUID()), - columns, partitionKeys, - tblProperties, fileFormat, ""); - hmsClient.createTable(tableMetadata, true); - Table tbl = hmsClient.getTable(dbName, tbWithPartition); - Assert.assertEquals(UserIdentity.ADMIN.getUser(), tbl.getParameters().get("owner")); - - // create table for tbWithoutPartition - HiveTableMetadata tableMetadata2 = new HiveTableMetadata( - dbName, tbWithoutPartition, Optional.of(dbLocation + tbWithPartition + UUID.randomUUID()), - columns, new ArrayList<>(), - new HashMap<>(), fileFormat, ""); - hmsClient.createTable(tableMetadata2, true); - - // context - connectContext = new ConnectContext(); - connectContext.setThreadLocalInfo(); - } - - @After - public void after() { - hmsClient.dropTable(dbName, tbWithoutPartition); - hmsClient.dropTable(dbName, tbWithPartition); - for (AutoCloseable mc : mockedConstructions) { - try { - mc.close(); - } catch (Exception e) { - // ignore - } - } - mockedConstructions.clear(); - transactionSpySetup = null; - } - - @Test - public void testNewPartitionForUnPartitionedTable() throws IOException { - List pus = new ArrayList<>(); - pus.add(createRandomNew(null)); - Assert.assertThrows(Exception.class, () -> commit(dbName, tbWithoutPartition, pus)); - } - - @Test - public void testAppendPartitionForUnPartitionedTable() throws IOException { - genQueryID(); - List pus = new ArrayList<>(); - pus.add(createRandomAppend(null)); - pus.add(createRandomAppend(null)); - pus.add(createRandomAppend(null)); - try (MockedConstruction mocked = Mockito.mockConstruction( - HMSTransaction.HmsCommitter.class, - Mockito.withSettings().defaultAnswer(Mockito.CALLS_REAL_METHODS), - (mock, context) -> { - initHmsCommitterFields(mock, context); - Mockito.doAnswer(inv -> { - Assert.assertFalse(fsExists(getWritePath())); - return null; - }).when(mock).doNothing(); - })) { - commit(dbName, tbWithoutPartition, pus); - Table table = hmsClient.getTable(dbName, tbWithoutPartition); - assertNumRows(3, table); - - - genQueryID(); - List pus2 = new ArrayList<>(); - pus2.add(createRandomAppend(null)); - pus2.add(createRandomAppend(null)); - pus2.add(createRandomAppend(null)); - commit(dbName, tbWithoutPartition, pus2); - table = hmsClient.getTable(dbName, tbWithoutPartition); - assertNumRows(6, table); - } - } - - @Test - public void testOverwritePartitionForUnPartitionedTable() throws IOException { - testAppendPartitionForUnPartitionedTable(); - - genQueryID(); - List pus = new ArrayList<>(); - pus.add(createRandomOverwrite(null)); - pus.add(createRandomOverwrite(null)); - pus.add(createRandomOverwrite(null)); - commit(dbName, tbWithoutPartition, pus); - Table table = hmsClient.getTable(dbName, tbWithoutPartition); - assertNumRows(3, table); - } - - @Test - public void testNewPartitionForPartitionedTable() throws IOException { - try (MockedConstruction mocked = Mockito.mockConstruction( - HMSTransaction.HmsCommitter.class, - Mockito.withSettings().defaultAnswer(Mockito.CALLS_REAL_METHODS), - (mock, context) -> { - initHmsCommitterFields(mock, context); - Mockito.doAnswer(inv -> { - Assert.assertFalse(fsExists(getWritePath())); - return null; - }).when(mock).doNothing(); - })) { - genQueryID(); - List pus = new ArrayList<>(); - pus.add(createRandomNew("a")); - pus.add(createRandomNew("a")); - pus.add(createRandomNew("a")); - pus.add(createRandomNew("b")); - pus.add(createRandomNew("b")); - pus.add(createRandomNew("c")); - commit(dbName, tbWithPartition, pus); - - Partition pa = hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("a")); - assertNumRows(3, pa); - Partition pb = hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("b")); - assertNumRows(2, pb); - Partition pc = hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("c")); - assertNumRows(1, pc); - } - } - - @Test - public void testAppendPartitionForPartitionedTable() throws IOException { - testNewPartitionForPartitionedTable(); - - genQueryID(); - List pus = new ArrayList<>(); - pus.add(createRandomAppend("a")); - pus.add(createRandomAppend("a")); - pus.add(createRandomAppend("a")); - pus.add(createRandomAppend("b")); - pus.add(createRandomAppend("b")); - pus.add(createRandomAppend("c")); - commit(dbName, tbWithPartition, pus); - - Partition pa = hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("a")); - assertNumRows(6, pa); - Partition pb = hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("b")); - assertNumRows(4, pb); - Partition pc = hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("c")); - assertNumRows(2, pc); - } - - @Test - public void testOverwritePartitionForPartitionedTable() throws IOException { - testAppendPartitionForPartitionedTable(); - - genQueryID(); - List pus = new ArrayList<>(); - pus.add(createRandomOverwrite("a")); - pus.add(createRandomOverwrite("b")); - pus.add(createRandomOverwrite("c")); - commit(dbName, tbWithPartition, pus); - - Partition pa = hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("a")); - assertNumRows(1, pa); - Partition pb = hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("b")); - assertNumRows(1, pb); - Partition pc = hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("c")); - assertNumRows(1, pc); - } - - @Test - public void testNewManyPartitionForPartitionedTable() throws IOException { - genQueryID(); - List pus = new ArrayList<>(); - int nums = 150; - for (int i = 0; i < nums; i++) { - pus.add(createRandomNew("" + i)); - } - - commit(dbName, tbWithPartition, pus); - for (int i = 0; i < nums; i++) { - Partition p = hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("" + i)); - assertNumRows(1, p); - } - - genQueryID(); - try { - commit(dbName, tbWithPartition, Collections.singletonList(createRandomNew("1"))); - } catch (Exception e) { - Assert.assertTrue(e.getMessage().contains("failed to add partitions")); - } - } - - @Test - public void testErrorPartitionTypeFromHmsCheck() throws IOException { - // first add three partition: a,b,c - testNewPartitionForPartitionedTable(); - - genQueryID(); - // second append two partition: a,x - // but there is no 'x' partition in the previous table, so when verifying based on HMS, - // it will throw exception - List pus = new ArrayList<>(); - pus.add(createRandomAppend("a")); - pus.add(createRandomAppend("x")); - - Assert.assertThrows( - Exception.class, - () -> commit(dbName, tbWithPartition, pus) - ); - } - - public void assertNumRows(long expected, Partition p) { - Assert.assertEquals(expected, Long.parseLong(p.getParameters().get("numRows"))); - } - - public void assertNumRows(long expected, Table t) { - Assert.assertEquals(expected, Long.parseLong(t.getParameters().get("numRows"))); - } - - public THivePartitionUpdate genOnePartitionUpdate(TUpdateMode mode) throws IOException { - return genOnePartitionUpdate("", mode); - } - - public THivePartitionUpdate genOnePartitionUpdate(String partitionValue, TUpdateMode mode) throws IOException { - - String queryId = ""; - if (connectContext.queryId() != null) { - queryId = DebugUtil.printId(connectContext.queryId()); - } - - THiveLocationParams location = new THiveLocationParams(); - String targetPath = dbLocation + queryId + "/" + partitionValue; - - location.setTargetPath(targetPath); - String writePath = writeLocation + queryId + "/" + partitionValue; - location.setWritePath(writePath); - - THivePartitionUpdate pu = new THivePartitionUpdate(); - if (partitionValue != null) { - pu.setName(partitionValue); - } - pu.setUpdateMode(mode); - pu.setRowCount(1); - pu.setFileSize(1); - pu.setLocation(location); - String uuid = UUID.randomUUID().toString(); - String f1 = queryId + "_" + uuid + "_f1.orc"; - String f2 = queryId + "_" + uuid + "_f2.orc"; - String f3 = queryId + "_" + uuid + "_f3.orc"; - - pu.setFileNames(new ArrayList() { - { - add(f1); - add(f2); - add(f3); - } - }); - - if (mode != TUpdateMode.NEW) { - fs.mkdirs(org.apache.doris.filesystem.Location.of(targetPath)); - } - - java.nio.file.Path writeDir = java.nio.file.Paths.get( - java.net.URI.create(writePath)); - java.nio.file.Files.createDirectories(writeDir); - java.nio.file.Files.createFile(writeDir.resolve(f1)); - java.nio.file.Files.createFile(writeDir.resolve(f2)); - java.nio.file.Files.createFile(writeDir.resolve(f3)); - return pu; - } - - public THivePartitionUpdate createRandomNew(String partition) throws IOException { - return partition == null ? genOnePartitionUpdate(TUpdateMode.NEW) : - genOnePartitionUpdate("c3=" + partition, TUpdateMode.NEW); - } - - public THivePartitionUpdate createRandomAppend(String partition) throws IOException { - return partition == null ? genOnePartitionUpdate(TUpdateMode.APPEND) : - genOnePartitionUpdate("c3=" + partition, TUpdateMode.APPEND); - } - - public THivePartitionUpdate createRandomOverwrite(String partition) throws IOException { - return partition == null ? genOnePartitionUpdate(TUpdateMode.OVERWRITE) : - genOnePartitionUpdate("c3=" + partition, TUpdateMode.OVERWRITE); - } - - private String getWritePath() { - String queryId = DebugUtil.printId(ConnectContext.get().queryId()); - return writeLocation + queryId + "/"; - } - - private boolean fsExists(String path) { - try { - return fs.exists(org.apache.doris.filesystem.Location.of(path)); - } catch (java.io.IOException e) { - return false; - } - } - - public void commit(String dbName, - String tableName, - List hivePUs) { - HMSTransaction hmsTransaction = new HMSTransaction(hmsOps, fileSystemProvider, fileSystemExecutor); - if (transactionSpySetup != null) { - hmsTransaction = Mockito.spy(hmsTransaction); - transactionSpySetup.accept(hmsTransaction); - } - hmsTransaction.setHivePartitionUpdates(hivePUs); - HiveInsertCommandContext ctx = new HiveInsertCommandContext(); - String queryId = DebugUtil.printId(ConnectContext.get().queryId()); - ctx.setQueryId(queryId); - ctx.setWritePath(getWritePath()); - hmsTransaction.beginInsertTable(ctx); - hmsTransaction.finishInsertTable(NameMapping.createForTest(dbName, tableName)); - hmsTransaction.commit(); - } - - public void mockAddPartitionTaskException(Runnable runnable) { - MockedConstruction mc = Mockito.mockConstruction( - HMSTransaction.AddPartitionsTask.class, - Mockito.withSettings().defaultAnswer(Mockito.CALLS_REAL_METHODS), - (mock, context) -> { - initAddPartitionsTaskFields(mock); - Mockito.doAnswer(inv -> { - runnable.run(); - throw new RuntimeException("failed to add partition"); - }).when(mock).run(Mockito.any()); - }); - mockedConstructions.add(mc); - } - - public void mockAsyncRenameDir(Runnable runnable) { - transactionSpySetup = tx -> { - Mockito.doAnswer(inv -> { - runnable.run(); - throw new RuntimeException("failed to rename dir"); - }).when(tx).wrapperAsyncRenameDirWithProfileSummary( - Mockito.any(), Mockito.any(), Mockito.any(), - Mockito.any(), Mockito.any(), Mockito.any()); - }; - } - - public void mockDoOther(Runnable runnable) { - MockedConstruction mc = Mockito.mockConstruction( - HMSTransaction.HmsCommitter.class, - Mockito.withSettings().defaultAnswer(Mockito.CALLS_REAL_METHODS), - (mock, context) -> { - initHmsCommitterFields(mock, context); - Mockito.doAnswer(inv -> { - runnable.run(); - throw new RuntimeException("failed to do nothing"); - }).when(mock).doNothing(); - }); - mockedConstructions.add(mc); - } - - public void mockUpdateStatisticsTaskException(Runnable runnable) { - MockedConstruction mc = Mockito.mockConstruction( - HMSTransaction.UpdateStatisticsTask.class, - Mockito.withSettings().defaultAnswer(Mockito.CALLS_REAL_METHODS), - (mock, context) -> { - initUpdateStatisticsTaskFields(mock, context); - Mockito.doAnswer(inv -> { - runnable.run(); - throw new RuntimeException("failed to update partition"); - }).when(mock).run(Mockito.any()); - }); - mockedConstructions.add(mc); - } - - public void genQueryID() { - connectContext.setQueryId(new TUniqueId(new Random().nextInt(), new Random().nextInt())); - } - - @Test - public void testRollbackWritePath() throws IOException { - genQueryID(); - List pus = new ArrayList<>(); - pus.add(createRandomNew("a")); - - THiveLocationParams location = pus.get(0).getLocation(); - - // For new partition, there should be no target path - Assert.assertFalse(fsExists(location.getTargetPath())); - Assert.assertTrue(fsExists(location.getWritePath())); - - mockAsyncRenameDir(() -> { - // commit will be failed, and it will remain some files in write path - String writePath = location.getWritePath(); - Assert.assertTrue(fsExists(writePath)); - for (String file : pus.get(0).getFileNames()) { - Assert.assertTrue(fsExists(writePath + "/" + file)); - } - }); - - try { - commit(dbName, tbWithPartition, pus); - Assert.assertTrue(false); - } catch (Exception e) { - // ignore - } - - // After rollback, these files in write path will be deleted - String writePath = location.getWritePath(); - Assert.assertFalse(fsExists(writePath)); - for (String file : pus.get(0).getFileNames()) { - Assert.assertFalse(fsExists(writePath + "/" + file)); - } - } - - @Test - public void testRollbackNewPartitionForPartitionedTableForFilesystem() throws IOException { - genQueryID(); - List pus = new ArrayList<>(); - pus.add(createRandomNew("a")); - - THiveLocationParams location = pus.get(0).getLocation(); - - // For new partition, there should be no target path - Assert.assertFalse(fsExists(location.getTargetPath())); - Assert.assertTrue(fsExists(location.getWritePath())); - - mockAddPartitionTaskException(() -> { - // When the commit is completed, these files should be renamed successfully - String targetPath = location.getTargetPath(); - Assert.assertTrue(fsExists(targetPath)); - for (String file : pus.get(0).getFileNames()) { - Assert.assertTrue(fsExists(targetPath + "/" + file)); - } - }); - - try { - commit(dbName, tbWithPartition, pus); - Assert.assertTrue(false); - } catch (Exception e) { - // ignore - } - - // After rollback, these files will be deleted - String targetPath = location.getTargetPath(); - Assert.assertFalse(fsExists(targetPath)); - for (String file : pus.get(0).getFileNames()) { - Assert.assertFalse(fsExists(targetPath + "/" + file)); - } - } - - - @Test - public void testRollbackNewPartitionForPartitionedTableWithNewPartition() throws IOException { - // first create three partitions: a,b,c - testNewPartitionForPartitionedTable(); - - genQueryID(); - - // second add 'new partition' for 'x' - // add 'append partition' for 'a' - // when 'doCommit', 'new partition' will be executed before 'append partition' - // so, when 'rollback', the 'x' partition will be added and then deleted - List pus = new ArrayList<>(); - pus.add(createRandomNew("x")); - pus.add(createRandomAppend("a")); - - THiveLocationParams locationForX = pus.get(0).getLocation(); - THiveLocationParams locationForA = pus.get(0).getLocation(); - - // For new partition, there should be no target path - Assert.assertFalse(fsExists(locationForX.getTargetPath())); - Assert.assertTrue(fsExists(locationForX.getWritePath())); - - mockUpdateStatisticsTaskException(() -> { - // When the commit is completed, these files should be renamed successfully - String targetPath = locationForX.getTargetPath(); - Assert.assertTrue(fsExists(targetPath)); - for (String file : pus.get(0).getFileNames()) { - Assert.assertTrue(fsExists(targetPath + "/" + file)); - } - // new partition will be executed before append partition, - // so, we can get the new partition - Partition px = hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("x")); - assertNumRows(1, px); - }); - - try { - commit(dbName, tbWithPartition, pus); - Assert.assertTrue(false); - } catch (Exception e) { - // ignore - } - - // After rollback, these files will be deleted - String targetPath = locationForX.getTargetPath(); - Assert.assertFalse(fsExists(targetPath)); - for (String file : pus.get(0).getFileNames()) { - Assert.assertFalse(fsExists(targetPath + "/" + file)); - } - Assert.assertFalse(fsExists(locationForX.getWritePath())); - Assert.assertFalse(fsExists(locationForA.getWritePath())); - // x partition will be deleted - Assert.assertThrows( - "the 'x' partition should be deleted", - Exception.class, - () -> hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("x")) - ); - } - - @Test - public void testRollbackNewPartitionForPartitionedTableWithNewAppendPartition() throws IOException { - // first create three partitions: a,b,c - testNewPartitionForPartitionedTable(); - - genQueryID(); - // second add 'new partition' for 'x' - // add 'append partition' for 'a' - List pus = new ArrayList<>(); - pus.add(createRandomNew("x")); - pus.add(createRandomAppend("a")); - - THiveLocationParams locationForParX = pus.get(0).getLocation(); - // in test, targetPath is a random path - // but when appending a partition, it uses the location of the original partition as the targetPath - // so here we need to get the path of partition a - Partition a = hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("a")); - String location = a.getSd().getLocation(); - pus.get(1).getLocation().setTargetPath(location); - THiveLocationParams locationForParA = pus.get(1).getLocation(); - - // For new partition, there should be no target path - Assert.assertFalse(fsExists(locationForParX.getTargetPath())); - Assert.assertTrue(fsExists(locationForParX.getWritePath())); - - // For exist partition - Assert.assertTrue(fsExists(locationForParA.getTargetPath())); - - mockDoOther(() -> { - // When the commit is completed, these files should be renamed successfully - String targetPathForX = locationForParX.getTargetPath(); - Assert.assertTrue(fsExists(targetPathForX)); - for (String file : pus.get(0).getFileNames()) { - Assert.assertTrue(fsExists(targetPathForX + "/" + file)); - } - String targetPathForA = locationForParA.getTargetPath(); - for (String file : pus.get(1).getFileNames()) { - Assert.assertTrue(fsExists(targetPathForA + "/" + file)); - } - // new partition will be executed, - // so, we can get the new partition - Partition px = hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("x")); - assertNumRows(1, px); - // append partition will be executed, - // so, we can get the updated partition - Partition pa = hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("a")); - assertNumRows(4, pa); - }); - - try { - commit(dbName, tbWithPartition, pus); - Assert.assertTrue(false); - } catch (Exception e) { - // ignore - } - - // After rollback, these files will be deleted - String targetPathForX = locationForParX.getTargetPath(); - Assert.assertFalse(fsExists(targetPathForX)); - for (String file : pus.get(0).getFileNames()) { - Assert.assertFalse(fsExists(targetPathForX + "/" + file)); - } - Assert.assertFalse(fsExists(locationForParX.getWritePath())); - String targetPathForA = locationForParA.getTargetPath(); - for (String file : pus.get(1).getFileNames()) { - Assert.assertFalse(fsExists(targetPathForA + "/" + file)); - } - Assert.assertTrue(fsExists(targetPathForA)); - Assert.assertFalse(fsExists(locationForParA.getWritePath())); - // x partition will be deleted - Assert.assertThrows( - "the 'x' partition should be deleted", - Exception.class, - () -> hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("x")) - ); - // the 'a' partition should be rollback - Partition pa = hmsClient.getPartition(dbName, tbWithPartition, Lists.newArrayList("a")); - assertNumRows(3, pa); - } - - @Test - public void testCommitWithRollback() { - genQueryID(); - List pus = new ArrayList<>(); - try { - pus.add(createRandomAppend(null)); - pus.add(createRandomAppend(null)); - pus.add(createRandomAppend(null)); - } catch (Throwable t) { - Assert.fail(); - } - - mockDoOther(() -> { - Table table = hmsClient.getTable(dbName, tbWithoutPartition); - assertNumRows(3, table); - }); - - HMSTransaction hmsTransaction = new HMSTransaction(hmsOps, fileSystemProvider, fileSystemExecutor); - try { - hmsTransaction.setHivePartitionUpdates(pus); - HiveInsertCommandContext ctx = new HiveInsertCommandContext(); - String queryId = DebugUtil.printId(ConnectContext.get().queryId()); - ctx.setQueryId(queryId); - ctx.setWritePath(getWritePath()); - hmsTransaction.beginInsertTable(ctx); - hmsTransaction.finishInsertTable(NameMapping.createForTest(dbName, tbWithoutPartition)); - hmsTransaction.commit(); - Assert.fail(); - } catch (Throwable t) { - Assert.assertTrue(t.getMessage().contains("failed to do nothing")); - } - - try { - hmsTransaction.rollback(); - } catch (Throwable t) { - Assert.fail(); - } - } - - private void initHmsCommitterFields(Object mock, MockedConstruction.Context context) throws Exception { - Class clazz = HMSTransaction.HmsCommitter.class; - setField(clazz, mock, "updateStatisticsTasks", new ArrayList<>()); - setField(clazz, mock, "updateStatisticsExecutor", Executors.newFixedThreadPool(16)); - setField(clazz, mock, "addPartitionsTask", new HMSTransaction.AddPartitionsTask()); - setField(clazz, mock, "fileSystemTaskCancelled", new AtomicBoolean(false)); - setField(clazz, mock, "asyncFileSystemTaskFutures", new ArrayList<>()); - setField(clazz, mock, "directoryCleanUpTasksForAbort", new ConcurrentLinkedQueue<>()); - setField(clazz, mock, "renameDirectoryTasksForAbort", new ArrayList<>()); - setField(clazz, mock, "clearDirsForFinish", new ArrayList<>()); - setField(clazz, mock, "s3cleanWhenSuccess", new ArrayList<>()); - // Set the outer class reference (HMSTransaction instance) - if (!context.arguments().isEmpty()) { - Field outerRef = clazz.getDeclaredField("this$0"); - outerRef.setAccessible(true); - outerRef.set(mock, context.arguments().get(0)); - } - } - - private void initAddPartitionsTaskFields(Object mock) throws Exception { - Class clazz = HMSTransaction.AddPartitionsTask.class; - setField(clazz, mock, "partitions", new ArrayList<>()); - setField(clazz, mock, "createdPartitionValues", new ArrayList<>()); - } - - private void initUpdateStatisticsTaskFields(Object mock, MockedConstruction.Context context) throws Exception { - Class clazz = HMSTransaction.UpdateStatisticsTask.class; - List args = context.arguments(); - if (args.size() >= 4) { - setField(clazz, mock, "nameMapping", args.get(0)); - setField(clazz, mock, "partitionName", args.get(1)); - setField(clazz, mock, "updatePartitionStat", args.get(2)); - setField(clazz, mock, "merge", args.get(3)); - } - } - - private void setField(Class clazz, Object obj, String fieldName, Object value) throws Exception { - Field field = clazz.getDeclaredField(fieldName); - field.setAccessible(true); - field.set(obj, value); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/ThriftHMSCachedClientTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/ThriftHMSCachedClientTest.java deleted file mode 100644 index a95503e7d194ba..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/ThriftHMSCachedClientTest.java +++ /dev/null @@ -1,416 +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.datasource.hive; - -import org.apache.doris.catalog.info.TableNameInfo; -import org.apache.doris.common.jmockit.Deencapsulation; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.property.metastore.HMSBaseProperties; - -import com.aliyun.datalake.metastore.hive2.ProxyMetaStoreClient; -import com.amazonaws.glue.catalog.metastore.AWSCatalogMetastoreClient; -import org.apache.commons.pool2.impl.GenericObjectPool; -import org.apache.hadoop.hive.conf.HiveConf; -import org.apache.hadoop.hive.metastore.HiveMetaStoreClient; -import org.apache.hadoop.hive.metastore.IMetaStoreClient; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.LockResponse; -import org.apache.hadoop.hive.metastore.api.LockState; -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.Table; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; - -import java.lang.reflect.Proxy; -import java.util.ArrayDeque; -import java.util.Collections; -import java.util.Deque; -import java.util.HashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -public class ThriftHMSCachedClientTest { - private MockMetastoreClientProvider provider; - - @Before - public void setUp() { - provider = new MockMetastoreClientProvider(); - } - - @Test - public void testPoolConfigKeepsBorrowValidationAndIdleEvictionDisabled() { - ThriftHMSCachedClient cachedClient = newClient(1); - - GenericObjectPool pool = getPool(cachedClient); - Assert.assertFalse(pool.getTestOnBorrow()); - Assert.assertFalse(pool.getTestOnReturn()); - Assert.assertFalse(pool.getTestWhileIdle()); - Assert.assertEquals(60_000L, pool.getMaxWaitMillis()); - Assert.assertEquals(-1L, pool.getTimeBetweenEvictionRunsMillis()); - } - - @Test - public void testPoolDisabledCreatesAndClosesClientPerBorrow() throws Exception { - ThriftHMSCachedClient cachedClient = newClient(0); - - Assert.assertNull(getPool(cachedClient)); - - Object firstBorrowed = borrowClient(cachedClient); - closeBorrowed(firstBorrowed); - Assert.assertEquals(1, provider.createdClients.get()); - Assert.assertEquals(1, provider.closedClients.get()); - - Object secondBorrowed = borrowClient(cachedClient); - Assert.assertNotSame(firstBorrowed, secondBorrowed); - closeBorrowed(secondBorrowed); - Assert.assertEquals(2, provider.createdClients.get()); - Assert.assertEquals(2, provider.closedClients.get()); - } - - @Test - public void testReturnObjectToPool() throws Exception { - ThriftHMSCachedClient cachedClient = newClient(1); - - Object firstBorrowed = borrowClient(cachedClient); - closeBorrowed(firstBorrowed); - - Assert.assertEquals(1, getPool(cachedClient).getNumIdle()); - Assert.assertEquals(0, getPool(cachedClient).getNumActive()); - Assert.assertEquals(1, provider.createdClients.get()); - Assert.assertEquals(0, provider.closedClients.get()); - - Object secondBorrowed = borrowClient(cachedClient); - Assert.assertSame(firstBorrowed, secondBorrowed); - closeBorrowed(secondBorrowed); - } - - @Test - public void testInvalidateBrokenObject() throws Exception { - ThriftHMSCachedClient cachedClient = newClient(1); - - Object brokenBorrowed = borrowClient(cachedClient); - markBorrowedBroken(brokenBorrowed, new RuntimeException("broken")); - closeBorrowed(brokenBorrowed); - - Assert.assertEquals(0, getPool(cachedClient).getNumIdle()); - Assert.assertEquals(0, getPool(cachedClient).getNumActive()); - Assert.assertEquals(1, provider.createdClients.get()); - Assert.assertEquals(1, provider.closedClients.get()); - - Object nextBorrowed = borrowClient(cachedClient); - Assert.assertNotSame(brokenBorrowed, nextBorrowed); - Assert.assertEquals(2, provider.createdClients.get()); - closeBorrowed(nextBorrowed); - } - - @Test - public void testBorrowBlocksUntilObjectReturned() throws Exception { - ThriftHMSCachedClient cachedClient = newClient(1); - Object firstBorrowed = borrowClient(cachedClient); - - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - Future waitingBorrow = executor.submit(() -> borrowClient(cachedClient)); - Thread.sleep(200L); - Assert.assertFalse(waitingBorrow.isDone()); - - closeBorrowed(firstBorrowed); - - Object secondBorrowed = waitingBorrow.get(2, TimeUnit.SECONDS); - Assert.assertSame(firstBorrowed, secondBorrowed); - closeBorrowed(secondBorrowed); - } finally { - executor.shutdownNow(); - } - } - - @Test - public void testCloseDestroysIdleObjectsAndRejectsBorrow() throws Exception { - ThriftHMSCachedClient cachedClient = newClient(1); - Object borrowed = borrowClient(cachedClient); - closeBorrowed(borrowed); - - cachedClient.close(); - - Assert.assertTrue(getPool(cachedClient).isClosed()); - Assert.assertEquals(1, provider.closedClients.get()); - Assert.assertThrows(IllegalStateException.class, () -> borrowClient(cachedClient)); - } - - @Test - public void testCloseWhileObjectBorrowedClosesClientOnReturn() throws Exception { - ThriftHMSCachedClient cachedClient = newClient(1); - Object borrowed = borrowClient(cachedClient); - - cachedClient.close(); - Assert.assertEquals(0, provider.closedClients.get()); - - closeBorrowed(borrowed); - - Assert.assertEquals(1, provider.closedClients.get()); - Assert.assertEquals(0, getPool(cachedClient).getNumIdle()); - } - - @Test - public void testGetMetastoreClientClassName() { - HiveConf hiveConf = new HiveConf(); - Assert.assertEquals(HiveMetaStoreClient.class.getName(), - ThriftHMSCachedClient.getMetastoreClientClassName(hiveConf)); - - hiveConf.set(HMSBaseProperties.HIVE_METASTORE_TYPE, HMSBaseProperties.GLUE_TYPE); - Assert.assertEquals(AWSCatalogMetastoreClient.class.getName(), - ThriftHMSCachedClient.getMetastoreClientClassName(hiveConf)); - - hiveConf.set(HMSBaseProperties.HIVE_METASTORE_TYPE, HMSBaseProperties.DLF_TYPE); - Assert.assertEquals(ProxyMetaStoreClient.class.getName(), - ThriftHMSCachedClient.getMetastoreClientClassName(hiveConf)); - } - - @Test - public void testUpdateTableStatisticsDoesNotBorrowSecondClient() { - ThriftHMSCachedClient cachedClient = newClient(1); - - cachedClient.updateTableStatistics("db1", "tbl1", statistics -> statistics); - - Assert.assertEquals(1, provider.createdClients.get()); - Assert.assertEquals(1, getPool(cachedClient).getNumIdle()); - Assert.assertEquals(0, getPool(cachedClient).getNumActive()); - } - - @Test - public void testAcquireSharedLockDoesNotBorrowSecondClient() { - provider.lockStates.add(LockState.WAITING); - provider.lockStates.add(LockState.ACQUIRED); - ThriftHMSCachedClient cachedClient = newClient(1); - - cachedClient.acquireSharedLock("query-1", 1L, "user", - new TableNameInfo("db1", "tbl1"), Collections.emptyList(), 5_000L); - - Assert.assertEquals(1, provider.createdClients.get()); - Assert.assertEquals(1, provider.checkLockCalls.get()); - Assert.assertEquals(1, getPool(cachedClient).getNumIdle()); - Assert.assertEquals(0, getPool(cachedClient).getNumActive()); - } - - @Test - public void testUpdatePartitionStatisticsInvalidatesFailedClient() throws Exception { - provider.alterPartitionFailure = new RuntimeException("alter partition failed"); - ThriftHMSCachedClient cachedClient = newClient(1); - - RuntimeException exception = Assert.assertThrows(RuntimeException.class, - () -> cachedClient.updatePartitionStatistics("db1", "tbl1", "p1", statistics -> statistics)); - Assert.assertTrue(exception.getMessage().contains("failed to update table statistics")); - assertBrokenBorrowerIsNotReused(cachedClient); - } - - @Test - public void testAddPartitionsInvalidatesFailedClient() throws Exception { - provider.addPartitionsFailure = new RuntimeException("add partitions failed"); - ThriftHMSCachedClient cachedClient = newClient(1); - - RuntimeException exception = Assert.assertThrows(RuntimeException.class, - () -> cachedClient.addPartitions("db1", "tbl1", Collections.singletonList(newPartitionWithStatistics()))); - Assert.assertTrue(exception.getMessage().contains("failed to add partitions")); - assertBrokenBorrowerIsNotReused(cachedClient); - } - - @Test - public void testDropPartitionInvalidatesFailedClient() throws Exception { - provider.dropPartitionFailure = new RuntimeException("drop partition failed"); - ThriftHMSCachedClient cachedClient = newClient(1); - - RuntimeException exception = Assert.assertThrows(RuntimeException.class, - () -> cachedClient.dropPartition("db1", "tbl1", Collections.singletonList("p1"), false)); - Assert.assertTrue(exception.getMessage().contains("failed to drop partition")); - assertBrokenBorrowerIsNotReused(cachedClient); - } - - private void assertBrokenBorrowerIsNotReused(ThriftHMSCachedClient cachedClient) throws Exception { - Assert.assertEquals(0, getPool(cachedClient).getNumIdle()); - Assert.assertEquals(0, getPool(cachedClient).getNumActive()); - Assert.assertEquals(1, provider.createdClients.get()); - Assert.assertEquals(1, provider.closedClients.get()); - - Object nextBorrowed = borrowClient(cachedClient); - Assert.assertEquals(2, provider.createdClients.get()); - closeBorrowed(nextBorrowed); - } - - private ThriftHMSCachedClient newClient(int poolSize) { - return newClient(new HiveConf(), poolSize); - } - - private ThriftHMSCachedClient newClient(HiveConf hiveConf, int poolSize) { - return new ThriftHMSCachedClient(hiveConf, poolSize, new ExecutionAuthenticator() { - }, provider); - } - - private GenericObjectPool getPool(ThriftHMSCachedClient cachedClient) { - return Deencapsulation.getField(cachedClient, "clientPool"); - } - - private Object borrowClient(ThriftHMSCachedClient cachedClient) { - return Deencapsulation.invoke(cachedClient, "getClient"); - } - - private void markBorrowedBroken(Object borrowedClient, Throwable throwable) { - Deencapsulation.invoke(borrowedClient, "setThrowable", throwable); - } - - private void closeBorrowed(Object borrowedClient) throws Exception { - ((AutoCloseable) borrowedClient).close(); - } - - private HivePartitionWithStatistics newPartitionWithStatistics() { - HivePartition partition = new HivePartition( - NameMapping.createForTest("db1", "tbl1"), - false, - "input-format", - "file:///tmp/part", - Collections.singletonList("p1"), - new HashMap<>(), - "output-format", - "serde", - Collections.singletonList(new FieldSchema("c1", "string", ""))); - return new HivePartitionWithStatistics("k1=v1", partition, HivePartitionStatistics.EMPTY); - } - - private static class MockMetastoreClientProvider implements ThriftHMSCachedClient.MetaStoreClientProvider { - private final AtomicInteger createdClients = new AtomicInteger(); - private final AtomicInteger closedClients = new AtomicInteger(); - private final AtomicInteger checkLockCalls = new AtomicInteger(); - private final Deque lockStates = new ArrayDeque<>(); - - private volatile RuntimeException alterPartitionFailure; - private volatile RuntimeException addPartitionsFailure; - private volatile RuntimeException dropPartitionFailure; - - @Override - public IMetaStoreClient create(HiveConf hiveConf) { - createdClients.incrementAndGet(); - return (IMetaStoreClient) Proxy.newProxyInstance( - IMetaStoreClient.class.getClassLoader(), - new Class[] {IMetaStoreClient.class}, - (proxy, method, args) -> handleMethod(proxy, method.getName(), args, method.getReturnType())); - } - - private Object handleMethod(Object proxy, String methodName, Object[] args, Class returnType) { - if ("close".equals(methodName)) { - closedClients.incrementAndGet(); - return null; - } - if ("hashCode".equals(methodName)) { - return System.identityHashCode(proxy); - } - if ("equals".equals(methodName)) { - return proxy == args[0]; - } - if ("toString".equals(methodName)) { - return "MockHmsClient"; - } - if ("getTable".equals(methodName)) { - Table table = new Table(); - table.setParameters(new HashMap<>()); - return table; - } - if ("getPartitionsByNames".equals(methodName)) { - Partition partition = new Partition(); - partition.setParameters(new HashMap<>()); - return Collections.singletonList(partition); - } - if ("alter_partition".equals(methodName)) { - if (alterPartitionFailure != null) { - throw alterPartitionFailure; - } - return null; - } - if ("add_partitions".equals(methodName)) { - if (addPartitionsFailure != null) { - throw addPartitionsFailure; - } - return 1; - } - if ("dropPartition".equals(methodName)) { - if (dropPartitionFailure != null) { - throw dropPartitionFailure; - } - return true; - } - if ("lock".equals(methodName)) { - return newLockResponse(nextLockState()); - } - if ("checkLock".equals(methodName)) { - checkLockCalls.incrementAndGet(); - return newLockResponse(nextLockState()); - } - return defaultValue(returnType); - } - - private LockState nextLockState() { - synchronized (lockStates) { - if (lockStates.isEmpty()) { - return LockState.ACQUIRED; - } - return lockStates.removeFirst(); - } - } - - private LockResponse newLockResponse(LockState state) { - LockResponse response = new LockResponse(); - response.setLockid(1L); - response.setState(state); - return response; - } - - private Object defaultValue(Class returnType) { - if (!returnType.isPrimitive()) { - return null; - } - if (returnType == boolean.class) { - return false; - } - if (returnType == byte.class) { - return (byte) 0; - } - if (returnType == short.class) { - return (short) 0; - } - if (returnType == int.class) { - return 0; - } - if (returnType == long.class) { - return 0L; - } - if (returnType == float.class) { - return 0F; - } - if (returnType == double.class) { - return 0D; - } - if (returnType == char.class) { - return '\0'; - } - return null; - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/event/GzipJSONMessageDeserializerTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/event/GzipJSONMessageDeserializerTest.java deleted file mode 100644 index 7a3bc29d3246a5..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/event/GzipJSONMessageDeserializerTest.java +++ /dev/null @@ -1,63 +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.datasource.hive.event; - -import org.apache.hadoop.hive.metastore.messaging.AlterPartitionMessage; -import org.apache.hadoop.hive.metastore.messaging.EventMessage; -import org.apache.hadoop.hive.metastore.messaging.MessageDeserializer; -import org.junit.Assert; -import org.junit.Test; - -public class GzipJSONMessageDeserializerTest { - - @Test - public void testGetAlterPartitionMessage() { - // copy from an online HDP Hive cluster, - // eventType: ALTER_PARTITION, messageFormat: gzip(json-2.0) - String messageBody = "H4sIAAAAAAAAAO1WW2sbRxT+L/usjHa1ullQqOMkbUpSBWwKJVuW0e7IGntvmZlVcIwhT8UlfuhDiimUGk" - + "NKk5fQQkmD3dI/Y8nuv+iZmZWtXa+VKNSUgh7sHc05cy5z5jvf2TY4YUPCjI4hBoz2RadaTVqW3WwP0SANNlFvI/TRIzqIY" - + "xQR0Vky27ZRUYeoRx4wGnk0wQEcH9AhqbqfdlfXPr735coKWuneB0W/B6I+jVzsczeOAhoR2BW4FxAQyM3ETd0eGeAhjZnL" - + "iBcz3wXTLhdYUC6oByo+nRxa20rkwfvLny9/cvuWu7Z8897tiazb2/iMxxGItx3DcowOfLhgsHDm8OQYOxXHqOWP5zPQKnZ" - + "eReZ/nrnWqGsNaktrVrPVqNVNq2lLUWNaZMqd5qWdlt6BSPUiSyngAhYPM0GlUbmcbZacoCEpTQe+NFovTeP0+cF499uz17" - + "+M/vxuvP/m7/3fQG2nxMfUrfl4a243xwdnrw/H+z+Nfzi4wgEZkki4HhZkPWZzOzg5enZy/Obk7dPTo59Hu1+f/no8042Ad" - + "/VhLsDy6MdnVxj3scDuEAdpufEeXaeRmHFBo6d/SMtflZwd+H0OYLWb5gaNWBytB1ueV00BmVUFxceYkUEMv6v5x4v8XnVe" - + "NBRii9k6wgn2BgQNsB/HCZIe0aMA0RjFzENd5t2NklTciVmIRQ4M89jopqJgJION6Jei5oZVBpva/I5XCfPzKA9xokGnzFQ" - + "mX0vWHK6c4oA+gXuLI9TPwu2oxwCl25Fm2gXs6uOmKuxSKa61zDJn+jcr2/pxWNY7msW0w1pBphaZrJivluX9qYysWq4WpV" - + "nqTKwSYCQf0DPGe9+MXrwCMI/2js5RsTTzfpZUfRLMNhGHCvM4ZR7hiEP1Q4yiNHyAmViJAz6pV2WGOuwLZCrNbceBzGXPc" - + "ORvR/lLPSFXFfmvT0ngcyV9qJQjHF4oT/fnyYmiNXkLE1mUBoFkOCUHR0TthkRg2WDUrnLixWEI3ezcTEkzd7JOVQyp0M6v" - + "Narp3n9VPIXuf53xlFDF7KgmQVxzRBmzXBXLFLeUxhLE/1qtJA1dFUZy3c8lB3tHA18B1WMEnsdkvgtp9G74AtQzBOsGBPq" - + "C4YhTWdYAc3HLD9bU1CTbwfnEphR7qbdJBGTkwrzModUrpZoOJYtZtanvj07+Ojx9/nK8+/vZ4d7J26PsEl+8Ojt8OTvGrC" - + "OVtyOdL8p5RzaSfxaqI/OGbTUU40x150nvzA3Murtal8jUahVGDCDGTLc9zbOKZq2s89JmXRMAjOGUr7E0kqDtJnLuxwFX4" - + "z7cKPSXMFFTcLtu11v1RsV4zKggd304XDE2ydYX8ilzsKlqA8drZs02LduCCAxZPCr5FYb8mwQ4llwa9QtUZ0luySw4RtkE" - + "9R5D/byDUsnEb7Vbsyf+xXy/mO//s/m+KrH20QVOFtP+Ytp/32m/NTNYW17WTHLVRKDJVcQCB6v0iRYuNTT3AR3eoQHhU/d" - + "7cYMlLGVepqRp4ljuw7iw4I0Fbyx4Y8EbC974f/NG4zp5Y+cflz5IfxgZAAA="; - MessageDeserializer messageDeserializer = MetastoreEventsProcessor.getMessageDeserializer("gzip(json-2.0)"); - Assert.assertTrue(messageDeserializer instanceof GzipJSONMessageDeserializer); - - try { - AlterPartitionMessage alterPartitionMessage = messageDeserializer.getAlterPartitionMessage(messageBody); - Assert.assertTrue(alterPartitionMessage != null); - Assert.assertTrue(alterPartitionMessage.getEventType() == EventMessage.EventType.ALTER_PARTITION); - Assert.assertTrue(alterPartitionMessage.getTableObj() != null); - } catch (Exception e) { - e.printStackTrace(); - Assert.assertTrue(false); - } - } - -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/source/HiveScanNodeTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/source/HiveScanNodeTest.java deleted file mode 100644 index db7a8abe024004..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/source/HiveScanNodeTest.java +++ /dev/null @@ -1,156 +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.datasource.hive.source; - -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.analysis.TupleId; -import org.apache.doris.datasource.TableFormatType; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HiveExternalMetaCache; -import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan.SelectedPartitions; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.thrift.TFileScanRangeParams; -import org.apache.doris.thrift.TFileTextScanRangeParams; - -import com.google.common.collect.ImmutableMap; -import org.junit.Assert; -import org.junit.Test; -import org.mockito.Mockito; - -import java.lang.reflect.Method; -import java.util.Collections; -import java.util.List; - -public class HiveScanNodeTest { - private static final long MB = 1024L * 1024L; - - @Test - public void testDetermineTargetFileSplitSizeHonorsMaxFileSplitNum() throws Exception { - SessionVariable sv = new SessionVariable(); - sv.setMaxFileSplitNum(100); - TupleDescriptor desc = new TupleDescriptor(new TupleId(0)); - HMSExternalTable table = Mockito.mock(HMSExternalTable.class); - HMSExternalCatalog catalog = Mockito.mock(HMSExternalCatalog.class); - Mockito.when(table.getCatalog()).thenReturn(catalog); - Mockito.when(catalog.bindBrokerName()).thenReturn(""); - desc.setTable(table); - HiveScanNode node = new HiveScanNode(new PlanNodeId(0), desc, false, sv, null, ScanContext.EMPTY); - - HiveExternalMetaCache.FileCacheValue fileCacheValue = new HiveExternalMetaCache.FileCacheValue(); - HiveExternalMetaCache.HiveFileStatus status = new HiveExternalMetaCache.HiveFileStatus(); - status.setLength(10_000L * MB); - fileCacheValue.getFiles().add(status); - List caches = Collections.singletonList(fileCacheValue); - - Method method = HiveScanNode.class.getDeclaredMethod( - "determineTargetFileSplitSize", List.class, boolean.class); - method.setAccessible(true); - long target = (long) method.invoke(node, caches, false); - Assert.assertEquals(100 * MB, target); - } - - @Test - public void testDetermineTargetFileSplitSizeKeepsInitialSize() throws Exception { - SessionVariable sv = new SessionVariable(); - sv.setMaxFileSplitNum(100); - TupleDescriptor desc = new TupleDescriptor(new TupleId(0)); - HMSExternalTable table = Mockito.mock(HMSExternalTable.class); - HMSExternalCatalog catalog = Mockito.mock(HMSExternalCatalog.class); - Mockito.when(table.getCatalog()).thenReturn(catalog); - Mockito.when(catalog.bindBrokerName()).thenReturn(""); - desc.setTable(table); - HiveScanNode node = new HiveScanNode(new PlanNodeId(0), desc, false, sv, null, ScanContext.EMPTY); - - HiveExternalMetaCache.FileCacheValue fileCacheValue = new HiveExternalMetaCache.FileCacheValue(); - HiveExternalMetaCache.HiveFileStatus status = new HiveExternalMetaCache.HiveFileStatus(); - status.setLength(500L * MB); - fileCacheValue.getFiles().add(status); - List caches = Collections.singletonList(fileCacheValue); - - Method method = HiveScanNode.class.getDeclaredMethod( - "determineTargetFileSplitSize", List.class, boolean.class); - method.setAccessible(true); - long target = (long) method.invoke(node, caches, false); - Assert.assertEquals(32 * MB, target); - } - - @Test - public void testSelectedPartitionsCarryPartitionPredicateFlag() { - SelectedPartitions selectedPartitions = new SelectedPartitions(3, ImmutableMap.of(), true, true); - Assert.assertTrue(selectedPartitions.hasPartitionPredicate); - } - - @Test - public void testHiveScanNodeExposePartitionPredicateFlag() { - HiveScanNode node = createHiveScanNode(); - node.setSelectedPartitions(new SelectedPartitions(3, ImmutableMap.of(), true, true)); - Assert.assertTrue(node.hasPartitionPredicate()); - } - - @Test - public void testHiveScanNodeExposePartitionedTableFlag() { - HiveScanNode node = createHiveScanNode(true); - Assert.assertTrue(node.isPartitionedTable()); - } - - @Test - public void testHiveScanNodeExposeMissingPartitionPredicateFlag() { - HiveScanNode node = createHiveScanNode(); - node.setSelectedPartitions(new SelectedPartitions(3, ImmutableMap.of(), true, false)); - Assert.assertFalse(node.hasPartitionPredicate()); - } - - @Test - public void testMarkTransactionalHiveScanParams() { - TFileScanRangeParams scanParams = new TFileScanRangeParams(); - HiveScanNode.markTransactionalHiveScanParams(scanParams); - - Assert.assertTrue(scanParams.isSetTableFormatParams()); - Assert.assertEquals(TableFormatType.TRANSACTIONAL_HIVE.value(), - scanParams.getTableFormatParams().getTableFormatType()); - } - - @Test - public void testTrimDoubleQuotesOnlyForDoubleQuoteEnclose() { - TFileTextScanRangeParams textParams = new TFileTextScanRangeParams(); - textParams.setEnclose((byte) '"'); - Assert.assertTrue(HiveScanNode.shouldTrimDoubleQuotes(textParams)); - - textParams.setEnclose((byte) '\''); - Assert.assertFalse(HiveScanNode.shouldTrimDoubleQuotes(textParams)); - } - - private HiveScanNode createHiveScanNode() { - return createHiveScanNode(false); - } - - private HiveScanNode createHiveScanNode(boolean partitioned) { - SessionVariable sv = new SessionVariable(); - TupleDescriptor desc = new TupleDescriptor(new TupleId(0)); - HMSExternalTable table = Mockito.mock(HMSExternalTable.class); - HMSExternalCatalog catalog = Mockito.mock(HMSExternalCatalog.class); - Mockito.when(table.getCatalog()).thenReturn(catalog); - Mockito.when(catalog.bindBrokerName()).thenReturn(""); - Mockito.when(table.isPartitionedTable()).thenReturn(partitioned); - desc.setTable(table); - return new HiveScanNode(new PlanNodeId(0), desc, false, sv, null, ScanContext.EMPTY); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/hudi/HudiExternalMetaCacheTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/hudi/HudiExternalMetaCacheTest.java deleted file mode 100644 index 3932294033d23f..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/hudi/HudiExternalMetaCacheTest.java +++ /dev/null @@ -1,188 +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.datasource.hudi; - -import org.apache.doris.common.Config; -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.TablePartitionValues; -import org.apache.doris.datasource.metacache.MetaCacheEntry; -import org.apache.doris.datasource.metacache.MetaCacheEntryStats; - -import org.apache.hudi.common.table.HoodieTableMetaClient; -import org.junit.Assert; -import org.junit.Test; - -import java.util.Collections; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -public class HudiExternalMetaCacheTest { - - @Test - public void testEntryAccessAfterExplicitInit() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - HudiExternalMetaCache cache = new HudiExternalMetaCache(executor); - cache.initCatalog(1L, Collections.emptyMap()); - MetaCacheEntry partitionEntry = cache.entry( - 1L, HudiExternalMetaCache.ENTRY_PARTITION, HudiPartitionCacheKey.class, - TablePartitionValues.class); - Assert.assertNotNull(partitionEntry); - cache.checkCatalogInitialized(1L); - } finally { - executor.shutdownNow(); - } - } - - @Test - public void testInvalidateTablePreciseAcrossEntries() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - HudiExternalMetaCache cache = new HudiExternalMetaCache(executor); - long catalogId = 1L; - cache.initCatalog(catalogId, Collections.emptyMap()); - - NameMapping t1 = nameMapping(catalogId, "db1", "tbl1"); - NameMapping t2 = nameMapping(catalogId, "db1", "tbl2"); - - HudiPartitionCacheKey partitionKey1 = partitionKey(t1, 1L, true); - HudiPartitionCacheKey partitionKey2 = partitionKey(t2, 2L, false); - MetaCacheEntry partitionEntry = cache.entry(catalogId, - HudiExternalMetaCache.ENTRY_PARTITION, HudiPartitionCacheKey.class, TablePartitionValues.class); - partitionEntry.put(partitionKey1, new TablePartitionValues()); - partitionEntry.put(partitionKey2, new TablePartitionValues()); - - HudiMetaClientCacheKey metaKey1 = metaClientKey(t1); - HudiMetaClientCacheKey metaKey2 = metaClientKey(t2); - MetaCacheEntry metaClientEntry = cache.entry(catalogId, - HudiExternalMetaCache.ENTRY_META_CLIENT, HudiMetaClientCacheKey.class, HoodieTableMetaClient.class); - metaClientEntry.put(metaKey1, new HoodieTableMetaClient()); - metaClientEntry.put(metaKey2, new HoodieTableMetaClient()); - - HudiSchemaCacheKey schemaKey1 = new HudiSchemaCacheKey(t1, 1L); - HudiSchemaCacheKey schemaKey2 = new HudiSchemaCacheKey(t2, 2L); - MetaCacheEntry schemaEntry = cache.entry(catalogId, - HudiExternalMetaCache.ENTRY_SCHEMA, HudiSchemaCacheKey.class, SchemaCacheValue.class); - schemaEntry.put(schemaKey1, new SchemaCacheValue(Collections.emptyList())); - schemaEntry.put(schemaKey2, new SchemaCacheValue(Collections.emptyList())); - - cache.invalidateTable(catalogId, "db1", "tbl1"); - - Assert.assertNull(partitionEntry.getIfPresent(partitionKey1)); - Assert.assertNotNull(partitionEntry.getIfPresent(partitionKey2)); - Assert.assertNull(metaClientEntry.getIfPresent(metaKey1)); - Assert.assertNotNull(metaClientEntry.getIfPresent(metaKey2)); - Assert.assertNull(schemaEntry.getIfPresent(schemaKey1)); - Assert.assertNotNull(schemaEntry.getIfPresent(schemaKey2)); - } finally { - executor.shutdownNow(); - } - } - - @Test - public void testInvalidatePartitionsFallsBackToTableInvalidation() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - HudiExternalMetaCache cache = new HudiExternalMetaCache(executor); - long catalogId = 1L; - cache.initCatalog(catalogId, Collections.emptyMap()); - - HudiPartitionCacheKey partitionKey1 = partitionKey(nameMapping(catalogId, "db1", "tbl1"), 1L, true); - HudiPartitionCacheKey partitionKey2 = partitionKey(nameMapping(catalogId, "db1", "tbl2"), 2L, false); - MetaCacheEntry partitionEntry = cache.entry(catalogId, - HudiExternalMetaCache.ENTRY_PARTITION, HudiPartitionCacheKey.class, TablePartitionValues.class); - partitionEntry.put(partitionKey1, new TablePartitionValues()); - partitionEntry.put(partitionKey2, new TablePartitionValues()); - - cache.invalidatePartitions(catalogId, "db1", "tbl1", Collections.singletonList("dt=20250101")); - - Assert.assertNull(partitionEntry.getIfPresent(partitionKey1)); - Assert.assertNotNull(partitionEntry.getIfPresent(partitionKey2)); - } finally { - executor.shutdownNow(); - } - } - - @Test - public void testSchemaStatsWhenSchemaCacheDisabled() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - HudiExternalMetaCache cache = new HudiExternalMetaCache(executor); - long catalogId = 1L; - Map properties = com.google.common.collect.Maps.newHashMap(); - properties.put(ExternalCatalog.SCHEMA_CACHE_TTL_SECOND, "0"); - cache.initCatalog(catalogId, properties); - - Map stats = cache.stats(catalogId); - MetaCacheEntryStats schemaStats = stats.get(HudiExternalMetaCache.ENTRY_SCHEMA); - Assert.assertNotNull(schemaStats); - Assert.assertEquals(0L, schemaStats.getTtlSecond()); - Assert.assertTrue(schemaStats.isConfigEnabled()); - Assert.assertFalse(schemaStats.isEffectiveEnabled()); - } finally { - executor.shutdownNow(); - } - } - - @Test - public void testDefaultSpecsFollowConfig() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - long originalExpireAfterAccess = Config.external_cache_expire_time_seconds_after_access; - long originalTableCapacity = Config.max_external_table_cache_num; - long originalSchemaCapacity = Config.max_external_schema_cache_num; - try { - Config.external_cache_expire_time_seconds_after_access = 321L; - Config.max_external_table_cache_num = 7L; - Config.max_external_schema_cache_num = 11L; - - HudiExternalMetaCache cache = new HudiExternalMetaCache(executor); - long catalogId = 1L; - cache.initCatalog(catalogId, Collections.emptyMap()); - - Map stats = cache.stats(catalogId); - MetaCacheEntryStats partitionStats = stats.get(HudiExternalMetaCache.ENTRY_PARTITION); - MetaCacheEntryStats schemaStats = stats.get(HudiExternalMetaCache.ENTRY_SCHEMA); - Assert.assertNotNull(partitionStats); - Assert.assertNotNull(schemaStats); - Assert.assertEquals(321L, partitionStats.getTtlSecond()); - Assert.assertEquals(7L, partitionStats.getCapacity()); - Assert.assertEquals(321L, schemaStats.getTtlSecond()); - Assert.assertEquals(11L, schemaStats.getCapacity()); - } finally { - Config.external_cache_expire_time_seconds_after_access = originalExpireAfterAccess; - Config.max_external_table_cache_num = originalTableCapacity; - Config.max_external_schema_cache_num = originalSchemaCapacity; - executor.shutdownNow(); - } - } - - private NameMapping nameMapping(long catalogId, String dbName, String tableName) { - return new NameMapping(catalogId, dbName, tableName, "remote_" + dbName, "remote_" + tableName); - } - - private HudiPartitionCacheKey partitionKey(NameMapping nameMapping, long timestamp, boolean useHiveSyncPartition) { - return HudiPartitionCacheKey.of(nameMapping, timestamp, useHiveSyncPartition); - } - - private HudiMetaClientCacheKey metaClientKey(NameMapping nameMapping) { - return HudiMetaClientCacheKey.of(nameMapping); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/hudi/HudiUtilsTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/hudi/HudiUtilsTest.java deleted file mode 100644 index 720969c04d7cfc..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/hudi/HudiUtilsTest.java +++ /dev/null @@ -1,263 +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.datasource.hudi; - -import org.apache.doris.catalog.DatabaseIf; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.CatalogMgr; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalDatabase; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HiveMetaStoreClientHelper; - -import com.google.common.collect.Maps; -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.hive.metastore.api.StorageDescriptor; -import org.apache.hadoop.hive.metastore.api.Table; -import org.junit.Assert; -import org.junit.Test; -import org.mockito.MockedStatic; -import org.mockito.Mockito; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -public class HudiUtilsTest { - - private MockedStatic envMockedStatic; - - @org.junit.After - public void tearDown() { - if (envMockedStatic != null) { - envMockedStatic.close(); - envMockedStatic = null; - } - } - - @Test - public void testGetHudiSchemaWithCleanCommit() throws IOException { - - /* - example table: - CREATE TABLE tbx ( - c1 INT) - USING hudi - TBLPROPERTIES ( - 'hoodie.cleaner.policy'='KEEP_LATEST_COMMITS', - 'hoodie.clean.automatic' = 'true', - 'hoodie.cleaner.commits.retained' = '2' - ); - */ - - String commitContent1 = "{\n" - + " \"partitionToWriteStats\" : {\n" - + " \"\" : [ {\n" - + " \"fileId\" : \"91b75cdf-e851-4524-b579-a9b08edd61d8-0\",\n" - + " \"path\" : \"91b75cdf-e851-4524-b579-a9b08edd61d8-0_0-2164-2318_20241219214517936.parquet\",\n" - + " \"cdcStats\" : null,\n" - + " \"prevCommit\" : \"20241219214431757\",\n" - + " \"numWrites\" : 2,\n" - + " \"numDeletes\" : 0,\n" - + " \"numUpdateWrites\" : 0,\n" - + " \"numInserts\" : 1,\n" - + " \"totalWriteBytes\" : 434370,\n" - + " \"totalWriteErrors\" : 0,\n" - + " \"tempPath\" : null,\n" - + " \"partitionPath\" : \"\",\n" - + " \"totalLogRecords\" : 0,\n" - + " \"totalLogFilesCompacted\" : 0,\n" - + " \"totalLogSizeCompacted\" : 0,\n" - + " \"totalUpdatedRecordsCompacted\" : 0,\n" - + " \"totalLogBlocks\" : 0,\n" - + " \"totalCorruptLogBlock\" : 0,\n" - + " \"totalRollbackBlocks\" : 0,\n" - + " \"fileSizeInBytes\" : 434370,\n" - + " \"minEventTime\" : null,\n" - + " \"maxEventTime\" : null,\n" - + " \"runtimeStats\" : {\n" - + " \"totalScanTime\" : 0,\n" - + " \"totalUpsertTime\" : 87,\n" - + " \"totalCreateTime\" : 0\n" - + " }\n" - + " } ]\n" - + " },\n" - + " \"compacted\" : false,\n" - + " \"extraMetadata\" : {\n" - + " \"schema\" : \"{\\\"type\\\":\\\"record\\\",\\\"name\\\":\\\"tbx_record\\\",\\\"namespace\\\":\\\"hoodie.tbx\\\",\\\"fields\\\":[{\\\"name\\\":\\\"c1\\\",\\\"type\\\":[\\\"null\\\",\\\"int\\\"],\\\"default\\\":null}]}\"\n" - + " },\n" - + " \"operationType\" : \"INSERT\"\n" - + "}"; - - String commitContent2 = "{\n" - + " \"partitionToWriteStats\" : {\n" - + " \"\" : [ {\n" - + " \"fileId\" : \"91b75cdf-e851-4524-b579-a9b08edd61d8-0\",\n" - + " \"path\" : \"91b75cdf-e851-4524-b579-a9b08edd61d8-0_0-2180-2334_20241219214518880.parquet\",\n" - + " \"cdcStats\" : null,\n" - + " \"prevCommit\" : \"20241219214517936\",\n" - + " \"numWrites\" : 3,\n" - + " \"numDeletes\" : 0,\n" - + " \"numUpdateWrites\" : 0,\n" - + " \"numInserts\" : 1,\n" - + " \"totalWriteBytes\" : 434397,\n" - + " \"totalWriteErrors\" : 0,\n" - + " \"tempPath\" : null,\n" - + " \"partitionPath\" : \"\",\n" - + " \"totalLogRecords\" : 0,\n" - + " \"totalLogFilesCompacted\" : 0,\n" - + " \"totalLogSizeCompacted\" : 0,\n" - + " \"totalUpdatedRecordsCompacted\" : 0,\n" - + " \"totalLogBlocks\" : 0,\n" - + " \"totalCorruptLogBlock\" : 0,\n" - + " \"totalRollbackBlocks\" : 0,\n" - + " \"fileSizeInBytes\" : 434397,\n" - + " \"minEventTime\" : null,\n" - + " \"maxEventTime\" : null,\n" - + " \"runtimeStats\" : {\n" - + " \"totalScanTime\" : 0,\n" - + " \"totalUpsertTime\" : 86,\n" - + " \"totalCreateTime\" : 0\n" - + " }\n" - + " } ]\n" - + " },\n" - + " \"compacted\" : false,\n" - + " \"extraMetadata\" : {\n" - + " \"schema\" : \"{\\\"type\\\":\\\"record\\\",\\\"name\\\":\\\"tbx_record\\\",\\\"namespace\\\":\\\"hoodie.tbx\\\",\\\"fields\\\":[{\\\"name\\\":\\\"c1\\\",\\\"type\\\":[\\\"null\\\",\\\"int\\\"],\\\"default\\\":null}]}\"\n" - + " },\n" - + " \"operationType\" : \"INSERT\"\n" - + "}"; - - String propContent = "#Updated at 2024-12-19T13:44:32.166Z\n" - + "#Thu Dec 19 21:44:32 CST 2024\n" - + "hoodie.datasource.write.drop.partition.columns=false\n" - + "hoodie.table.type=COPY_ON_WRITE\n" - + "hoodie.archivelog.folder=archived\n" - + "hoodie.timeline.layout.version=1\n" - + "hoodie.table.version=6\n" - + "hoodie.table.metadata.partitions=files\n" - + "hoodie.database.name=mmc_hudi\n" - + "hoodie.datasource.write.partitionpath.urlencode=false\n" - + "hoodie.table.keygenerator.class=org.apache.hudi.keygen.NonpartitionedKeyGenerator\n" - + "hoodie.table.name=tbx\n" - + "hoodie.table.metadata.partitions.inflight=\n" - + "hoodie.datasource.write.hive_style_partitioning=true\n" - + "hoodie.table.checksum=1632286010\n" - + "hoodie.table.create.schema={\"type\"\\:\"record\",\"name\"\\:\"tbx_record\",\"namespace\"\\:\"hoodie.tbx\",\"fields\"\\:[{\"name\"\\:\"c1\",\"type\"\\:[\"int\",\"null\"]}]}"; - - - // 1. prepare table path - Path hudiTable = Files.createTempDirectory("hudiTable"); - File meta = new File(hudiTable + "/.hoodie"); - Assert.assertTrue(meta.mkdirs()); - - // 2. generate properties and commit - File prop = new File(meta + "/hoodie.properties"); - Files.write(prop.toPath(), propContent.getBytes()); - File commit1 = new File(meta + "/1.commit"); - Files.write(commit1.toPath(), commitContent1.getBytes()); - - // 3. now, we can get the schema from this table. - HMSExternalCatalog catalog = Mockito.spy(new HMSExternalCatalog(10001, "hudi_ut", null, Maps.newHashMap(), "")); - Env env = mockCurrentEnvWithCatalog(catalog); - Assert.assertNotNull(env); - env.getExtMetaCacheMgr().prepareCatalogByEngine(catalog.getId(), HudiExternalMetaCache.ENGINE, - catalog.getProperties()); - HMSExternalDatabase db = Mockito.spy(new HMSExternalDatabase(catalog, 1, "db", "db")); - HMSExternalTable hmsExternalTable = Mockito.spy(new HMSExternalTable(2, "tb", "tb", catalog, db)); - Table remoteTable = new Table(); - StorageDescriptor storageDescriptor = new StorageDescriptor(); - storageDescriptor.setLocation("file://" + hudiTable.toAbsolutePath()); - remoteTable.setSd(storageDescriptor); - Mockito.doReturn(remoteTable).when(hmsExternalTable).getRemoteTable(); - mockCatalogLookup(catalog, db, hmsExternalTable); - HiveMetaStoreClientHelper.getHudiTableSchema(hmsExternalTable, new boolean[] {false}, "20241219214518880"); - - // 4. delete the commit file, - // this operation is used to imitate the clean operation in hudi - Assert.assertTrue(commit1.delete()); - - // 5. generate a new commit - File commit2 = new File(meta + "/2.commit"); - Files.write(commit2.toPath(), commitContent2.getBytes()); - - // 6. we should get schema correctly - // because we will refresh timeline in this `getHudiTableSchema` method, - // and we can get the latest commit. - // so that this error: `Could not read commit details from file /.hoodie/1.commit` will be not reported. - HiveMetaStoreClientHelper.getHudiTableSchema(hmsExternalTable, new boolean[] {false}, "20241219214518880"); - - // 7. clean up - Assert.assertTrue(commit2.delete()); - Assert.assertTrue(prop.delete()); - Assert.assertTrue(meta.delete()); - Files.delete(hudiTable); - env.getExtMetaCacheMgr().invalidateCatalogByEngine(catalog.getId(), HudiExternalMetaCache.ENGINE); - } - - private Env mockCurrentEnvWithCatalog(HMSExternalCatalog catalog) { - CatalogMgr catalogMgr = new TestingCatalogMgr(catalog); - Env env = new TestingEnv(catalogMgr); - envMockedStatic = Mockito.mockStatic(Env.class); - envMockedStatic.when(Env::getCurrentEnv).thenReturn(env); - return env; - } - - private void mockCatalogLookup(HMSExternalCatalog catalog, HMSExternalDatabase db, HMSExternalTable table) { - Mockito.doAnswer(invocation -> { - String dbName = invocation.getArgument(0); - return "db".equals(dbName) ? db : null; - }).when(catalog).getDbNullable(Mockito.anyString()); - Mockito.doReturn(new Configuration()).when(catalog).getConfiguration(); - - Mockito.doAnswer(invocation -> { - String tableName = invocation.getArgument(0); - return "tb".equals(tableName) ? table : null; - }).when(db).getTableNullable(Mockito.anyString()); - } - - private static final class TestingCatalogMgr extends CatalogMgr { - private final CatalogIf> catalog; - - private TestingCatalogMgr(CatalogIf> catalog) { - this.catalog = catalog; - } - - @Override - public CatalogIf> getCatalog(long id) { - return catalog.getId() == id ? catalog : null; - } - } - - private static final class TestingEnv extends Env { - private final CatalogMgr catalogMgr; - - private TestingEnv(CatalogMgr catalogMgr) { - super(true); - this.catalogMgr = catalogMgr; - } - - @Override - public CatalogMgr getCatalogMgr() { - return catalogMgr; - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/CreateIcebergTableTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/CreateIcebergTableTest.java deleted file mode 100644 index 6962f911538f81..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/CreateIcebergTableTest.java +++ /dev/null @@ -1,294 +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.datasource.iceberg; - -import org.apache.doris.common.DdlException; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.CatalogFactory; -import org.apache.doris.nereids.parser.NereidsParser; -import org.apache.doris.nereids.trees.plans.commands.CreateCatalogCommand; -import org.apache.doris.nereids.trees.plans.commands.CreateTableCommand; -import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo; -import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; -import org.apache.doris.qe.ConnectContext; - -import com.google.common.collect.Maps; -import org.apache.iceberg.BaseTable; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.RowLevelOperationMode; -import org.apache.iceberg.Schema; -import org.apache.iceberg.Table; -import org.apache.iceberg.TableProperties; -import org.apache.iceberg.catalog.TableIdentifier; -import org.apache.iceberg.types.Type; -import org.apache.iceberg.types.Types; -import org.junit.Assert; -import org.junit.BeforeClass; -import org.junit.Test; -import org.junit.jupiter.api.Assertions; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.List; -import java.util.UUID; - -public class CreateIcebergTableTest { - - public static String warehouse; - public static IcebergHadoopExternalCatalog icebergCatalog; - public static IcebergMetadataOps ops; - public static String dbName = "testdb"; - public static ConnectContext connectContext; - - @BeforeClass - public static void beforeClass() throws Throwable { - Path warehousePath = Files.createTempDirectory("test_warehouse_"); - warehouse = "file://" + warehousePath.toAbsolutePath() + "/"; - - HashMap param = new HashMap<>(); - param.put("type", "iceberg"); - param.put("iceberg.catalog.type", "hadoop"); - param.put("warehouse", warehouse); - - // create catalog - CreateCatalogCommand createCatalogCommand = new CreateCatalogCommand("iceberg", true, "", "comment", param); - icebergCatalog = (IcebergHadoopExternalCatalog) CatalogFactory.createFromCommand(1, createCatalogCommand); - icebergCatalog.makeSureInitialized(); - // create db - ops = new IcebergMetadataOps(icebergCatalog, icebergCatalog.getCatalog()); - ops.createDb(dbName, true, Maps.newHashMap()); - icebergCatalog.makeSureInitialized(); - IcebergExternalDatabase db = new IcebergExternalDatabase(icebergCatalog, 1L, dbName, dbName); - icebergCatalog.addDatabaseForTest(db); - - // context - connectContext = new ConnectContext(); - connectContext.setThreadLocalInfo(); - } - - @Test - public void testSimpleTable() throws UserException { - TableIdentifier tb = TableIdentifier.of(dbName, getTableName()); - String sql = "create table " + tb + " (id int) engine = iceberg"; - createTable(sql); - Table table = ops.getCatalog().loadTable(tb); - Schema schema = table.schema(); - Assert.assertEquals(1, schema.columns().size()); - Assert.assertEquals(PartitionSpec.unpartitioned(), table.spec()); - } - - @Test - public void testProperties() throws UserException { - TableIdentifier tb = TableIdentifier.of(dbName, getTableName()); - String sql = "create table " + tb + " (id int) engine = iceberg properties(\"a\"=\"b\")"; - createTable(sql); - Table table = ops.getCatalog().loadTable(tb); - Schema schema = table.schema(); - Assert.assertEquals(1, schema.columns().size()); - Assert.assertEquals(PartitionSpec.unpartitioned(), table.spec()); - Assert.assertEquals("b", table.properties().get("a")); - } - - @Test - public void testDefaultProperties() throws UserException { - TableIdentifier tb = TableIdentifier.of(dbName, getTableName()); - String sql = "create table " + tb + " (id int) engine = iceberg"; - createTable(sql); - Table table = ops.getCatalog().loadTable(tb); - Assert.assertEquals(2, getFormatVersion(table)); - Assert.assertEquals(RowLevelOperationMode.MERGE_ON_READ.modeName(), - table.properties().get(TableProperties.DELETE_MODE)); - Assert.assertEquals(RowLevelOperationMode.MERGE_ON_READ.modeName(), - table.properties().get(TableProperties.UPDATE_MODE)); - Assert.assertEquals(RowLevelOperationMode.MERGE_ON_READ.modeName(), - table.properties().get(TableProperties.MERGE_MODE)); - } - - @Test - public void testExplicitProperties() throws UserException { - TableIdentifier tb = TableIdentifier.of(dbName, getTableName()); - String sql = "create table " + tb + " (id int) engine = iceberg properties(" - + "\"format-version\"=\"1\", " - + "\"write.delete.mode\"=\"copy-on-write\", " - + "\"write.update.mode\"=\"copy-on-write\", " - + "\"write.merge.mode\"=\"copy-on-write\")"; - createTable(sql); - Table table = ops.getCatalog().loadTable(tb); - Assert.assertEquals(1, getFormatVersion(table)); - Assert.assertEquals(RowLevelOperationMode.COPY_ON_WRITE.modeName(), - table.properties().get(TableProperties.DELETE_MODE)); - Assert.assertEquals(RowLevelOperationMode.COPY_ON_WRITE.modeName(), - table.properties().get(TableProperties.UPDATE_MODE)); - Assert.assertEquals(RowLevelOperationMode.COPY_ON_WRITE.modeName(), - table.properties().get(TableProperties.MERGE_MODE)); - } - - @Test - public void testType() throws UserException { - TableIdentifier tb = TableIdentifier.of(dbName, getTableName()); - String sql = "create table " + tb + " (" - + "c0 int, " - + "c1 bigint, " - + "c2 float, " - + "c3 double, " - + "c4 string, " - + "c5 date, " - + "c6 decimal(20, 10), " - + "c7 datetime" - + ") engine = iceberg " - + "properties(\"a\"=\"b\")"; - createTable(sql); - Table table = ops.getCatalog().loadTable(tb); - Schema schema = table.schema(); - List columns = schema.columns(); - Assert.assertEquals(8, columns.size()); - Assert.assertEquals(Type.TypeID.INTEGER, columns.get(0).type().typeId()); - Assert.assertEquals(Type.TypeID.LONG, columns.get(1).type().typeId()); - Assert.assertEquals(Type.TypeID.FLOAT, columns.get(2).type().typeId()); - Assert.assertEquals(Type.TypeID.DOUBLE, columns.get(3).type().typeId()); - Assert.assertEquals(Type.TypeID.STRING, columns.get(4).type().typeId()); - Assert.assertEquals(Type.TypeID.DATE, columns.get(5).type().typeId()); - Assert.assertEquals(Type.TypeID.DECIMAL, columns.get(6).type().typeId()); - Assert.assertEquals(Type.TypeID.TIMESTAMP, columns.get(7).type().typeId()); - } - - @Test - public void testPartition() throws UserException { - TableIdentifier tb = TableIdentifier.of(dbName, getTableName()); - String sql = "create table " + tb + " (" - + "id int, " - + "ts1 datetime, " - + "ts2 datetime, " - + "ts3 datetime, " - + "ts4 datetime, " - + "dt1 date, " - + "dt2 date, " - + "dt3 date, " - + "s string" - + ") engine = iceberg " - + "partition by (" - + "id, " - + "bucket(2, id), " - + "year(ts1), " - + "year(dt1), " - + "month(ts2), " - + "month(dt2), " - + "day(ts3), " - + "day(dt3), " - + "hour(ts4), " - + "truncate(10, s)) ()" - + "properties(\"a\"=\"b\")"; - createTable(sql); - Table table = ops.getCatalog().loadTable(tb); - Schema schema = table.schema(); - Assert.assertEquals(9, schema.columns().size()); - PartitionSpec spec = PartitionSpec.builderFor(schema) - .identity("id") - .bucket("id", 2) - .year("ts1") - .year("dt1") - .month("ts2") - .month("dt2") - .day("ts3") - .day("dt3") - .hour("ts4") - .truncate("s", 10) - .build(); - Assert.assertEquals(spec, table.spec()); - Assert.assertEquals("b", table.properties().get("a")); - } - - @Test - public void testPartitionPreservesNonLowercaseColumnNames() throws UserException { - TableIdentifier tb = TableIdentifier.of(dbName, getTableName()); - String sql = "create table " + tb + " (" - + "data int, " - + "`PART` int, " - + "`mIxEd_COL` int" - + ") engine = iceberg " - + "partition by (`PART`, bucket(2, `mIxEd_COL`)) ()"; - createTable(sql); - Table table = ops.getCatalog().loadTable(tb); - Schema schema = table.schema(); - - Assert.assertEquals("PART", schema.columns().get(1).name()); - Assert.assertEquals("mIxEd_COL", schema.columns().get(2).name()); - PartitionSpec spec = PartitionSpec.builderFor(schema) - .identity("PART") - .bucket("mIxEd_COL", 2) - .build(); - Assert.assertEquals(spec, table.spec()); - } - - @Test - public void testSortOrderResolvesNonLowercaseColumnNamesCaseInsensitively() throws UserException { - TableIdentifier tb = TableIdentifier.of(dbName, getTableName()); - String sql = "create table " + tb + " (" - + "data int, " - + "`mIxEd_COL` int" - + ") engine = iceberg " - + "order by (`mixed_col` asc)"; - createTable(sql); - Table table = ops.getCatalog().loadTable(tb); - Schema schema = table.schema(); - - Assert.assertEquals("mIxEd_COL", schema.columns().get(1).name()); - Assert.assertEquals(1, table.sortOrder().fields().size()); - Assert.assertEquals(schema.findField("mIxEd_COL").fieldId(), table.sortOrder().fields().get(0).sourceId()); - } - - public void createTable(String sql) throws UserException { - LogicalPlan plan = new NereidsParser().parseSingle(sql); - Assertions.assertTrue(plan instanceof CreateTableCommand); - CreateTableInfo createTableInfo = ((CreateTableCommand) plan).getCreateTableInfo(); - createTableInfo.setIsExternal(true); - createTableInfo.analyzeEngine(); - ops.createTable(createTableInfo); - } - - public String getTableName() { - String s = "test_tb_" + UUID.randomUUID(); - return s.replaceAll("-", ""); - } - - private int getFormatVersion(Table table) { - Assert.assertTrue(table instanceof BaseTable); - return ((BaseTable) table).operations().current().formatVersion(); - } - - @Test - public void testDropDB() { - try { - // create db success - ops.createDb("iceberg", false, Maps.newHashMap()); - // drop db success - ops.dropDb("iceberg", false, false); - } catch (Throwable t) { - Assert.fail(); - } - - try { - ops.dropDb("iceberg", false, false); - Assert.fail(); - } catch (Throwable t) { - Assert.assertTrue(t instanceof DdlException); - Assert.assertTrue(t.getMessage().contains("database doesn't exist")); - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergConflictDetectionFilterUtilsTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergConflictDetectionFilterUtilsTest.java deleted file mode 100644 index 030348fc023988..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergConflictDetectionFilterUtilsTest.java +++ /dev/null @@ -1,198 +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.datasource.iceberg; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.nereids.trees.expressions.EqualTo; -import org.apache.doris.nereids.trees.expressions.Expression; -import org.apache.doris.nereids.trees.expressions.IsNull; -import org.apache.doris.nereids.trees.expressions.NamedExpression; -import org.apache.doris.nereids.trees.expressions.Not; -import org.apache.doris.nereids.trees.expressions.Or; -import org.apache.doris.nereids.trees.expressions.SlotReference; -import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator; -import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral; -import org.apache.doris.nereids.trees.expressions.literal.StringLiteral; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.RelationId; -import org.apache.doris.nereids.trees.plans.logical.LogicalEmptyRelation; -import org.apache.doris.nereids.trees.plans.logical.LogicalFilter; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; -import org.apache.iceberg.Schema; -import org.apache.iceberg.Table; -import org.apache.iceberg.types.Types; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.mockito.Mockito; - -import java.util.Optional; -import java.util.Set; - -public class IcebergConflictDetectionFilterUtilsTest { - - private IcebergExternalTable targetTable; - - @Before - public void setUp() { - Table icebergTable = Mockito.mock(Table.class); - Schema schema = new Schema( - Types.NestedField.optional(1, "id", Types.IntegerType.get()), - Types.NestedField.optional(2, "name", Types.StringType.get())); - Mockito.when(icebergTable.schema()).thenReturn(schema); - - targetTable = Mockito.mock(IcebergExternalTable.class); - Mockito.when(targetTable.getId()).thenReturn(1L); - Mockito.when(targetTable.getIcebergTable()).thenReturn(icebergTable); - } - - @Test - public void testBuildConflictDetectionFilterWithTargetPredicate() { - SlotReference slot = buildSlot(targetTable, "id", ScalarType.INT); - Expression predicate = new EqualTo(slot, new IntegerLiteral(1)); - Plan plan = buildPlan(ImmutableSet.of(predicate), slot); - - Optional result = - IcebergConflictDetectionFilterUtils.buildConflictDetectionFilter(plan, targetTable); - - Assert.assertTrue(result.isPresent()); - } - - @Test - public void testBuildConflictDetectionFilterIgnoreNonTargetPredicate() { - IcebergExternalTable otherTable = Mockito.mock(IcebergExternalTable.class); - Mockito.when(otherTable.getId()).thenReturn(2L); - SlotReference slot = buildSlot(otherTable, "id", ScalarType.INT); - Expression predicate = new EqualTo(slot, new IntegerLiteral(1)); - Plan plan = buildPlan(ImmutableSet.of(predicate), slot); - - Optional result = - IcebergConflictDetectionFilterUtils.buildConflictDetectionFilter(plan, targetTable); - - Assert.assertFalse(result.isPresent()); - } - - @Test - public void testBuildConflictDetectionFilterKeepTargetPredicateInMixedConjuncts() { - IcebergExternalTable otherTable = Mockito.mock(IcebergExternalTable.class); - Mockito.when(otherTable.getId()).thenReturn(2L); - - SlotReference targetSlot = buildSlot(targetTable, "id", ScalarType.INT); - SlotReference otherSlot = buildSlot(otherTable, "id", ScalarType.INT); - Set conjuncts = ImmutableSet.of( - new EqualTo(targetSlot, new IntegerLiteral(1)), - new EqualTo(otherSlot, new IntegerLiteral(2))); - Plan plan = buildPlan(conjuncts, targetSlot); - - Optional result = - IcebergConflictDetectionFilterUtils.buildConflictDetectionFilter(plan, targetTable); - - Assert.assertTrue(result.isPresent()); - } - - @Test - public void testBuildConflictDetectionFilterIgnoreRowIdPredicate() { - SlotReference slot = buildSlot(targetTable, Column.ICEBERG_ROWID_COL, ScalarType.STRING); - Expression predicate = new EqualTo(slot, new StringLiteral("rowid")); - Plan plan = buildPlan(ImmutableSet.of(predicate), slot); - - Optional result = - IcebergConflictDetectionFilterUtils.buildConflictDetectionFilter(plan, targetTable); - - Assert.assertFalse(result.isPresent()); - } - - @Test - public void testBuildConflictDetectionFilterIgnoreMetadataPredicate() { - SlotReference slot = buildSlot(targetTable, IcebergMetadataColumn.FILE_PATH.getColumnName(), - ScalarType.STRING); - Expression predicate = new EqualTo(slot, new StringLiteral("/path/a.parquet")); - Plan plan = buildPlan(ImmutableSet.of(predicate), slot); - - Optional result = - IcebergConflictDetectionFilterUtils.buildConflictDetectionFilter(plan, targetTable); - - Assert.assertFalse(result.isPresent()); - } - - @Test - public void testBuildConflictDetectionFilterAllowsOrOnSameColumn() { - SlotReference slot = buildSlot(targetTable, "id", ScalarType.INT); - Expression predicate = new Or(new EqualTo(slot, new IntegerLiteral(1)), - new EqualTo(slot, new IntegerLiteral(2))); - Plan plan = buildPlan(ImmutableSet.of(predicate), slot); - - Optional result = - IcebergConflictDetectionFilterUtils.buildConflictDetectionFilter(plan, targetTable); - - Assert.assertTrue(result.isPresent()); - } - - @Test - public void testBuildConflictDetectionFilterIgnoreOrOnDifferentColumns() { - SlotReference idSlot = buildSlot(targetTable, "id", ScalarType.INT); - SlotReference nameSlot = buildSlot(targetTable, "name", ScalarType.STRING); - Expression predicate = new Or(new EqualTo(idSlot, new IntegerLiteral(1)), - new EqualTo(nameSlot, new StringLiteral("a"))); - Plan plan = buildPlan(ImmutableSet.of(predicate), idSlot); - - Optional result = - IcebergConflictDetectionFilterUtils.buildConflictDetectionFilter(plan, targetTable); - - Assert.assertFalse(result.isPresent()); - } - - @Test - public void testBuildConflictDetectionFilterAllowsIsNotNull() { - SlotReference slot = buildSlot(targetTable, "id", ScalarType.INT); - Expression predicate = new Not(new IsNull(slot), true); - Plan plan = buildPlan(ImmutableSet.of(predicate), slot); - - Optional result = - IcebergConflictDetectionFilterUtils.buildConflictDetectionFilter(plan, targetTable); - - Assert.assertTrue(result.isPresent()); - } - - @Test - public void testBuildConflictDetectionFilterIgnoreNotPredicate() { - SlotReference slot = buildSlot(targetTable, "id", ScalarType.INT); - Expression predicate = new Not(new EqualTo(slot, new IntegerLiteral(1))); - Plan plan = buildPlan(ImmutableSet.of(predicate), slot); - - Optional result = - IcebergConflictDetectionFilterUtils.buildConflictDetectionFilter(plan, targetTable); - - Assert.assertFalse(result.isPresent()); - } - - private SlotReference buildSlot(TableIf table, String name, ScalarType type) { - Column column = new Column(name, type); - return SlotReference.fromColumn(StatementScopeIdGenerator.newExprId(), table, column, ImmutableList.of()); - } - - private Plan buildPlan(Set conjuncts, SlotReference outputSlot) { - LogicalEmptyRelation child = new LogicalEmptyRelation(new RelationId(0), - ImmutableList.of((NamedExpression) outputSlot)); - return new LogicalFilter<>(conjuncts, child); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergDDLAndDMLPlanTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergDDLAndDMLPlanTest.java deleted file mode 100644 index 0a798faff93fef..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergDDLAndDMLPlanTest.java +++ /dev/null @@ -1,929 +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.datasource.iceberg; - -import org.apache.doris.analysis.ExplainOptions; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.common.FeConstants; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.nereids.NereidsPlanner; -import org.apache.doris.nereids.StatementContext; -import org.apache.doris.nereids.analyzer.UnboundAlias; -import org.apache.doris.nereids.glue.LogicalPlanAdapter; -import org.apache.doris.nereids.properties.DistributionSpecHash; -import org.apache.doris.nereids.properties.DistributionSpecMerge; -import org.apache.doris.nereids.properties.PhysicalProperties; -import org.apache.doris.nereids.trees.expressions.Alias; -import org.apache.doris.nereids.trees.expressions.ExprId; -import org.apache.doris.nereids.trees.expressions.Expression; -import org.apache.doris.nereids.trees.expressions.NamedExpression; -import org.apache.doris.nereids.trees.expressions.Slot; -import org.apache.doris.nereids.trees.plans.Plan; -import org.apache.doris.nereids.trees.plans.commands.CreateTableCommand; -import org.apache.doris.nereids.trees.plans.commands.DeleteFromCommand; -import org.apache.doris.nereids.trees.plans.commands.ExplainCommand; -import org.apache.doris.nereids.trees.plans.commands.UpdateCommand; -import org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext; -import org.apache.doris.nereids.trees.plans.commands.merge.MergeIntoCommand; -import org.apache.doris.nereids.trees.plans.commands.use.SwitchCommand; -import org.apache.doris.nereids.trees.plans.logical.LogicalIcebergDeleteSink; -import org.apache.doris.nereids.trees.plans.logical.LogicalIcebergMergeSink; -import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; -import org.apache.doris.nereids.trees.plans.logical.LogicalProject; -import org.apache.doris.nereids.trees.plans.physical.PhysicalDistribute; -import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergDeleteSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergMergeSink; -import org.apache.doris.nereids.trees.plans.physical.PhysicalPlan; -import org.apache.doris.nereids.types.DataType; -import org.apache.doris.nereids.util.MemoTestUtils; -import org.apache.doris.nereids.util.RelationUtil; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.thrift.TUniqueId; -import org.apache.doris.utframe.TestWithFeService; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.RowLevelOperationMode; -import org.apache.iceberg.Schema; -import org.apache.iceberg.Table; -import org.apache.iceberg.TableProperties; -import org.apache.iceberg.TableScan; -import org.apache.iceberg.catalog.Catalog; -import org.apache.iceberg.catalog.Namespace; -import org.apache.iceberg.catalog.SupportsNamespaces; -import org.apache.iceberg.catalog.TableIdentifier; -import org.apache.iceberg.io.CloseableIterable; -import org.apache.iceberg.types.Types; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentMatchers; -import org.mockito.MockedStatic; -import org.mockito.Mockito; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; - -public class IcebergDDLAndDMLPlanTest extends TestWithFeService { - private String catalogName; - private String dbName; - private String tableName; - private String warehouse; - private Table mockedIcebergTable; - private PartitionSpec basePartitionSpec; - private Schema baseIcebergSchema; - private boolean previousEnableNereidsDistributePlanner; - private MockedStatic icebergUtilsMock; - - @Override - protected void runBeforeAll() throws Exception { - FeConstants.runningUnitTest = true; - previousEnableNereidsDistributePlanner = - connectContext.getSessionVariable().isEnableNereidsDistributePlanner(); - connectContext.getSessionVariable().setEnableNereidsDistributePlanner(true); - connectContext.getSessionVariable().setEnablePipelineXEngine("true"); - - String suffix = java.util.UUID.randomUUID().toString().replace("-", ""); - catalogName = "iceberg_test_" + suffix; - dbName = "iceberg_db_" + suffix; - tableName = "iceberg_tbl_" + suffix; - Path warehousePath = Files.createTempDirectory("iceberg_warehouse_"); - warehouse = "file://" + warehousePath.toAbsolutePath() + "/"; - - String createCatalogSql = "create catalog " + catalogName - + " properties('type'='iceberg'," - + " 'iceberg.catalog.type'='hadoop'," - + " 'warehouse'='" + warehouse + "')"; - createCatalog(createCatalogSql); - - IcebergExternalCatalog catalog = (IcebergExternalCatalog) Env.getCurrentEnv() - .getCatalogMgr().getCatalog(catalogName); - catalog.setInitializedForTest(true); - - IcebergExternalDatabase database = new IcebergExternalDatabase( - catalog, Env.getCurrentEnv().getNextId(), dbName, dbName); - catalog.addDatabaseForTest(database); - - Catalog icebergCatalog = catalog.getCatalog(); - if (icebergCatalog instanceof SupportsNamespaces) { - SupportsNamespaces nsCatalog = (SupportsNamespaces) icebergCatalog; - Namespace namespace = Namespace.of(dbName); - if (!nsCatalog.namespaceExists(namespace)) { - nsCatalog.createNamespace(namespace); - } - } - Schema icebergSchema = new Schema( - Types.NestedField.required(1, "id", Types.IntegerType.get()), - Types.NestedField.required(2, "name", Types.StringType.get()), - Types.NestedField.required(3, "age", Types.IntegerType.get()), - Types.NestedField.required(4, "score", Types.IntegerType.get()), - Types.NestedField.required(5, "amount", Types.DecimalType.of(10, 2))); - this.baseIcebergSchema = icebergSchema; - icebergCatalog.createTable( - TableIdentifier.of(dbName, tableName), - icebergSchema, - PartitionSpec.unpartitioned(), - ImmutableMap.of( - TableProperties.FORMAT_VERSION, "2", - TableProperties.DELETE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName(), - TableProperties.UPDATE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName(), - TableProperties.MERGE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName())); - - List schema = ImmutableList.of( - new Column("id", PrimitiveType.INT), - new Column("name", PrimitiveType.STRING), - new Column("age", PrimitiveType.INT), - new Column("score", PrimitiveType.SMALLINT), - new Column("amount", PrimitiveType.DECIMAL64, 0, 10, 2, false)); - - IcebergExternalTable table = new IcebergExternalTable( - Env.getCurrentEnv().getNextId(), tableName, tableName, catalog, database); - IcebergExternalTable spyTable = Mockito.spy(table); - Mockito.doNothing().when(spyTable).makeSureInitialized(); - Mockito.doAnswer(invocation -> { - List fullSchema = new ArrayList<>(schema); - if (ConnectContext.get() != null - && ConnectContext.get().needIcebergRowId()) { - fullSchema.add(IcebergRowId.createHiddenColumn()); - } - return fullSchema; - }).when(spyTable).getFullSchema(); - Mockito.doReturn(ImmutableList.of()).when(spyTable) - .getPartitionColumns(ArgumentMatchers.any()); - IcebergSnapshotCacheValue snapshotCacheValue = new IcebergSnapshotCacheValue( - IcebergPartitionInfo.empty(), new IcebergSnapshot(0L, 0L)); - Mockito.doReturn(new IcebergMvccSnapshot(snapshotCacheValue)).when(spyTable) - .loadSnapshot(ArgumentMatchers.any(), ArgumentMatchers.any()); - Table mockedIcebergTable = Mockito.mock(Table.class); - PartitionSpec mockedSpec = Mockito.mock(PartitionSpec.class); - Mockito.doReturn(false).when(mockedSpec).isPartitioned(); - Mockito.doReturn(ImmutableMap.of( - TableProperties.FORMAT_VERSION, "2", - TableProperties.DELETE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName(), - TableProperties.UPDATE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName(), - TableProperties.MERGE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName())) - .when(mockedIcebergTable).properties(); - Mockito.doReturn(mockedSpec).when(mockedIcebergTable).spec(); - Mockito.doReturn(ImmutableMap.of()).when(mockedIcebergTable).specs(); - Mockito.doReturn(icebergSchema).when(mockedIcebergTable).schema(); - - // Mock newScan() chain used by IcebergScanNode.createTableScan() - TableScan mockedTableScan = Mockito.mock(TableScan.class, Mockito.RETURNS_DEEP_STUBS); - Mockito.doReturn(mockedTableScan).when(mockedIcebergTable).newScan(); - Mockito.doReturn(mockedTableScan).when(mockedTableScan).metricsReporter(ArgumentMatchers.any()); - Mockito.doReturn(mockedTableScan).when(mockedTableScan).useSnapshot(ArgumentMatchers.anyLong()); - Mockito.doReturn(mockedTableScan).when(mockedTableScan).useRef(ArgumentMatchers.any()); - Mockito.doReturn(mockedTableScan).when(mockedTableScan).filter(ArgumentMatchers.any()); - Mockito.doReturn(mockedTableScan).when(mockedTableScan).planWith(ArgumentMatchers.any()); - Mockito.doReturn(null).when(mockedTableScan).snapshot(); - // Keep the scan schema aligned with the mocked table schema. IcebergScanNode reads the - // selected scan schema when serializing initial defaults, and several tests temporarily - // replace the table schema to exercise partition transforms. - Mockito.doAnswer(invocation -> mockedIcebergTable.schema()).when(mockedTableScan).schema(); - Mockito.doReturn(CloseableIterable.withNoopClose(java.util.Collections.emptyList())) - .when(mockedTableScan).planFiles(); - - Mockito.doReturn(mockedIcebergTable).when(spyTable).getIcebergTable(); - this.mockedIcebergTable = mockedIcebergTable; - this.basePartitionSpec = mockedSpec; - database.addTableForTest(spyTable); - - icebergUtilsMock = Mockito.mockStatic(IcebergUtils.class, Mockito.CALLS_REAL_METHODS); - icebergUtilsMock.when(() -> IcebergUtils.getIcebergTable(ArgumentMatchers.any(ExternalTable.class))) - .thenAnswer(invocation -> { - ExternalTable externalTable = invocation.getArgument(0); - if (externalTable instanceof IcebergExternalTable - && tableName.equalsIgnoreCase(externalTable.getName()) - && dbName.equalsIgnoreCase(externalTable.getDbName())) { - return mockedIcebergTable; - } - return invocation.callRealMethod(); - }); - } - - @Override - protected void runAfterAll() throws Exception { - if (icebergUtilsMock != null) { - icebergUtilsMock.close(); - icebergUtilsMock = null; - } - connectContext.getSessionVariable() - .setEnableNereidsDistributePlanner(previousEnableNereidsDistributePlanner); - if (catalogName != null) { - Env.getCurrentEnv().getCatalogMgr().dropCatalog(catalogName, true); - } - } - - @Test - public void testIcebergDeletePlanAddsRowIdProject() throws Exception { - useIceberg(); - String sql = "delete from " + tableName + " where id > 1"; - LogicalPlan deletePlan = parseStmt(sql); - Assertions.assertTrue(deletePlan instanceof DeleteFromCommand); - - Plan explainPlan = ((DeleteFromCommand) deletePlan).getExplainPlan(connectContext); - Assertions.assertTrue(explainPlan instanceof LogicalIcebergDeleteSink); - - Plan child = explainPlan.child(0); - Assertions.assertTrue(child instanceof LogicalProject); - List projects = ((LogicalProject) child).getProjects(); - Assertions.assertEquals(2, projects.size()); - boolean hasOperation = false; - boolean hasRowId = false; - for (NamedExpression project : projects) { - if (project instanceof UnboundAlias) { - Optional alias = ((UnboundAlias) project).getAlias(); - if (alias.isPresent() - && IcebergMergeOperation.OPERATION_COLUMN.equalsIgnoreCase(alias.get())) { - hasOperation = true; - } - continue; - } - if (project instanceof Slot) { - String name = ((Slot) project).getName(); - if (IcebergMergeOperation.OPERATION_COLUMN.equalsIgnoreCase(name)) { - hasOperation = true; - } - if (Column.ICEBERG_ROWID_COL.equalsIgnoreCase(name)) { - hasRowId = true; - } - } - } - Assertions.assertTrue(hasOperation); - Assertions.assertTrue(hasRowId); - - PhysicalPlan physicalPlan = planPhysicalPlan((LogicalPlan) explainPlan, PhysicalProperties.GATHER, sql); - assertContainsPhysicalSink(physicalPlan, PhysicalIcebergDeleteSink.class); - } - - @Test - public void testCreateIcebergV3TableRejectsRowLineageReservedColumn() throws Exception { - useIceberg(); - String rowIdTable = "row_lineage_reserved_" + UUID.randomUUID().toString().replace("-", ""); - String rowIdSql = "create table " + rowIdTable - + " (_row_id bigint) properties('format-version'='3')"; - LogicalPlan rowIdPlan = parseStmt(rowIdSql); - Assertions.assertTrue(rowIdPlan instanceof CreateTableCommand); - Assertions.assertThrows(org.apache.doris.nereids.exceptions.AnalysisException.class, - () -> ((CreateTableCommand) rowIdPlan).getCreateTableInfo().validate(connectContext)); - - String lastUpdatedSequenceNumberTable = - "row_lineage_reserved_" + UUID.randomUUID().toString().replace("-", ""); - String lastUpdatedSequenceNumberSql = "create table " + lastUpdatedSequenceNumberTable - + " (_last_updated_sequence_number bigint) properties('format-version'='3')"; - LogicalPlan lastUpdatedSequenceNumberPlan = parseStmt(lastUpdatedSequenceNumberSql); - Assertions.assertTrue(lastUpdatedSequenceNumberPlan instanceof CreateTableCommand); - Assertions.assertThrows(org.apache.doris.nereids.exceptions.AnalysisException.class, - () -> ((CreateTableCommand) lastUpdatedSequenceNumberPlan).getCreateTableInfo() - .validate(connectContext)); - - String formatV2Table = "row_lineage_reserved_" + UUID.randomUUID().toString().replace("-", ""); - String formatV2Sql = "create table " + formatV2Table - + " (_row_id bigint) properties('format-version'='2')"; - LogicalPlan formatV2Plan = parseStmt(formatV2Sql); - Assertions.assertTrue(formatV2Plan instanceof CreateTableCommand); - Assertions.assertDoesNotThrow( - () -> ((CreateTableCommand) formatV2Plan).getCreateTableInfo().validate(connectContext)); - } - - @Test - public void testCreateIcebergDefaultV3TableRejectsRowLineageReservedColumn() throws Exception { - useIceberg(); - IcebergExternalCatalog catalog = (IcebergExternalCatalog) Env.getCurrentEnv() - .getCatalogMgr().getCatalog(catalogName); - catalog.getCatalogProperty().addProperty("table-default.format-version", "3"); - try { - String rowIdTable = "row_lineage_reserved_" + UUID.randomUUID().toString().replace("-", ""); - String rowIdSql = "create table " + rowIdTable + " (_row_id bigint)"; - LogicalPlan rowIdPlan = parseStmt(rowIdSql); - Assertions.assertTrue(rowIdPlan instanceof CreateTableCommand); - Assertions.assertThrows(org.apache.doris.nereids.exceptions.AnalysisException.class, - () -> ((CreateTableCommand) rowIdPlan).getCreateTableInfo().validate(connectContext)); - Assertions.assertFalse(catalog.getCatalog().tableExists(TableIdentifier.of(dbName, rowIdTable))); - - String formatV2Table = "row_lineage_reserved_" + UUID.randomUUID().toString().replace("-", ""); - String formatV2Sql = "create table " + formatV2Table - + " (_row_id bigint) properties('format-version'='2')"; - LogicalPlan formatV2Plan = parseStmt(formatV2Sql); - Assertions.assertTrue(formatV2Plan instanceof CreateTableCommand); - Assertions.assertDoesNotThrow( - () -> ((CreateTableCommand) formatV2Plan).getCreateTableInfo().validate(connectContext)); - } finally { - catalog.getCatalogProperty().deleteProperty("table-default.format-version"); - } - } - - @Test - public void testIcebergV3CtasRejectsRowLineageReservedColumn() throws Exception { - useIceberg(); - String ctasTable = "row_lineage_reserved_" + UUID.randomUUID().toString().replace("-", ""); - String ctasSql = "create table " + ctasTable - + " properties('format-version'='3') as select 1 as _row_id"; - LogicalPlan ctasPlan = parseStmt(ctasSql); - Assertions.assertTrue(ctasPlan instanceof CreateTableCommand); - Assertions.assertThrows(org.apache.doris.nereids.exceptions.AnalysisException.class, - () -> ((CreateTableCommand) ctasPlan).validateCreateTableAsSelect( - connectContext, ((CreateTableCommand) ctasPlan).getCtasQuery().get())); - } - - @Test - public void testIcebergUpdatePlans() throws Exception { - useIceberg(); - String sql = "update " + tableName + " set name = 'new_name' where id = 1"; - LogicalPlan updatePlan = parseStmt(sql); - Assertions.assertTrue(updatePlan instanceof UpdateCommand); - - Plan explainPlan = ((UpdateCommand) updatePlan).getExplainPlan(connectContext); - Assertions.assertTrue(explainPlan instanceof LogicalIcebergMergeSink); - - Plan child = explainPlan.child(0); - Assertions.assertTrue(child instanceof LogicalProject); - List projects = ((LogicalProject) child).getProjects(); - boolean hasOperation = false; - boolean hasRowId = false; - for (NamedExpression project : projects) { - if (project instanceof UnboundAlias) { - Optional alias = ((UnboundAlias) project).getAlias(); - if (alias.isPresent() - && IcebergMergeOperation.OPERATION_COLUMN.equalsIgnoreCase(alias.get())) { - hasOperation = true; - } - continue; - } - if (project instanceof Slot) { - String name = ((Slot) project).getName(); - if (IcebergMergeOperation.OPERATION_COLUMN.equalsIgnoreCase(name)) { - hasOperation = true; - } - if (Column.ICEBERG_ROWID_COL.equalsIgnoreCase(name)) { - hasRowId = true; - } - } - } - Assertions.assertTrue(hasOperation); - Assertions.assertTrue(hasRowId); - - PhysicalPlan physicalPlan = planPhysicalPlan((LogicalPlan) explainPlan, PhysicalProperties.GATHER, sql); - assertContainsPhysicalSink(physicalPlan, PhysicalIcebergMergeSink.class); - } - - @Test - public void testIcebergMergeIntoExplainUsesMergePartitioning() throws Exception { - useIceberg(); - boolean previous = connectContext.getSessionVariable().enableIcebergMergePartitioning; - connectContext.getSessionVariable().enableIcebergMergePartitioning = true; - try { - String sql = "merge into " + tableName + " t " - + "using (select 1 as id, 'name1' as name, 10 as age, 1 as score, 1.23 as amount) s " - + "on t.id = s.id " - + "when matched then update set name = s.name " - + "when not matched then insert (id, name, age, score, amount) " - + "values (s.id, s.name, s.age, s.score, s.amount)"; - LogicalPlan mergePlan = parseStmt(sql); - Assertions.assertTrue(mergePlan instanceof MergeIntoCommand); - - Plan explainPlan = ((MergeIntoCommand) mergePlan).getExplainPlan(connectContext); - Assertions.assertTrue(explainPlan instanceof LogicalIcebergMergeSink); - - String explain = getExplainString((LogicalPlan) explainPlan, - ExplainCommand.ExplainLevel.DISTRIBUTED_PLAN, sql); - String upper = explain.toUpperCase(); - Assertions.assertTrue(upper.contains("ICEBERG MERGE SINK"), explain); - Assertions.assertTrue(upper.contains("MERGE_PARTITIONED"), explain); - } finally { - connectContext.getSessionVariable().enableIcebergMergePartitioning = previous; - } - } - - @Test - public void testIcebergMergeIntoExchangeUsesMergePartitioningWhenEnabled() throws Exception { - useIceberg(); - boolean previous = connectContext.getSessionVariable().enableIcebergMergePartitioning; - connectContext.getSessionVariable().enableIcebergMergePartitioning = true; - try { - String sql = "merge into " + tableName + " t " - + "using (select 1 as id, 'name1' as name, 10 as age, 1 as score, 1.23 as amount) s " - + "on t.id = s.id " - + "when matched then update set name = s.name " - + "when not matched then insert (id, name, age, score, amount) " - + "values (s.id, s.name, s.age, s.score, s.amount)"; - LogicalPlan mergePlan = parseStmt(sql); - Assertions.assertTrue(mergePlan instanceof MergeIntoCommand); - - Plan explainPlan = ((MergeIntoCommand) mergePlan).getExplainPlan(connectContext); - PhysicalPlan physicalPlan = - planPhysicalPlan((LogicalPlan) explainPlan, PhysicalProperties.GATHER, sql); - - PhysicalIcebergMergeSink sink = - getSinglePhysicalSink(physicalPlan, PhysicalIcebergMergeSink.class); - ExprId operationExprId = findOperationExprId(sink.child().getOutput()); - ExprId rowIdExprId = findRowIdExprId(sink.child().getOutput()); - Assertions.assertTrue(sink.child() instanceof PhysicalDistribute, - "Missing merge partition exchange\n" + physicalPlan.treeString()); - PhysicalDistribute distribute = (PhysicalDistribute) sink.child(); - Assertions.assertTrue(distribute.getDistributionSpec() instanceof DistributionSpecMerge, - "Missing merge distribution spec\n" + physicalPlan.treeString()); - - DistributionSpecMerge spec = (DistributionSpecMerge) distribute.getDistributionSpec(); - Assertions.assertEquals(operationExprId, spec.getOperationExprId()); - Assertions.assertTrue(spec.isInsertRandom()); - Assertions.assertTrue(spec.getInsertPartitionExprIds().isEmpty()); - Assertions.assertEquals(1, spec.getDeletePartitionExprIds().size()); - Assertions.assertEquals(rowIdExprId, spec.getDeletePartitionExprIds().get(0)); - } finally { - connectContext.getSessionVariable().enableIcebergMergePartitioning = previous; - } - } - - @Test - public void testIcebergUpdateCastsConstantForSmallintAndDecimal() throws Exception { - useIceberg(); - String sql = "update " + tableName + " set score = 1, amount = 1.23 where id = 1"; - LogicalPlan updatePlan = parseStmt(sql); - Plan explainPlan = ((UpdateCommand) updatePlan).getExplainPlan(connectContext); - PhysicalPlan physicalPlan = planPhysicalPlan((LogicalPlan) explainPlan, PhysicalProperties.GATHER, sql); - - PhysicalIcebergMergeSink sink = - getSinglePhysicalSink(physicalPlan, PhysicalIcebergMergeSink.class); - assertOutputCastedToColumnType(sink, "score"); - assertOutputCastedToColumnType(sink, "amount"); - } - - @Test - public void testIcebergUpdateExchangeUsesRowIdOnlyWhenDisabled() throws Exception { - useIceberg(); - boolean previous = connectContext.getSessionVariable().enableIcebergMergePartitioning; - connectContext.getSessionVariable().enableIcebergMergePartitioning = false; - try { - String sql = "update " + tableName + " set name = 'new_name' where id = 1"; - LogicalPlan updatePlan = parseStmt(sql); - Plan explainPlan = ((UpdateCommand) updatePlan).getExplainPlan(connectContext); - PhysicalPlan physicalPlan = - planPhysicalPlan((LogicalPlan) explainPlan, PhysicalProperties.GATHER, sql); - - PhysicalIcebergMergeSink sink = - getSinglePhysicalSink(physicalPlan, PhysicalIcebergMergeSink.class); - ExprId rowIdExprId = findRowIdExprId(sink.child().getOutput()); - Assertions.assertTrue(sink.child() instanceof PhysicalDistribute, - "Missing row_id exchange\n" + physicalPlan.treeString()); - PhysicalDistribute distribute = (PhysicalDistribute) sink.child(); - Assertions.assertTrue(distribute.getDistributionSpec() instanceof DistributionSpecHash, - "Missing row_id hash distribution\n" + physicalPlan.treeString()); - DistributionSpecHash hash = (DistributionSpecHash) distribute.getDistributionSpec(); - Assertions.assertEquals(ImmutableList.of(rowIdExprId), hash.getOrderedShuffledColumns()); - } finally { - connectContext.getSessionVariable().enableIcebergMergePartitioning = previous; - } - } - - @Test - public void testIcebergUpdateExchangeUsesMergePartitioningWhenEnabled() throws Exception { - useIceberg(); - boolean previous = connectContext.getSessionVariable().enableIcebergMergePartitioning; - connectContext.getSessionVariable().enableIcebergMergePartitioning = true; - try { - String sql = "update " + tableName + " set name = 'new_name' where id = 1"; - LogicalPlan updatePlan = parseStmt(sql); - Plan explainPlan = ((UpdateCommand) updatePlan).getExplainPlan(connectContext); - PhysicalPlan physicalPlan = - planPhysicalPlan((LogicalPlan) explainPlan, PhysicalProperties.GATHER, sql); - - PhysicalIcebergMergeSink sink = - getSinglePhysicalSink(physicalPlan, PhysicalIcebergMergeSink.class); - ExprId operationExprId = findOperationExprId(sink.child().getOutput()); - ExprId rowIdExprId = findRowIdExprId(sink.child().getOutput()); - Assertions.assertTrue(sink.child() instanceof PhysicalDistribute, - "Missing merge partition exchange\n" + physicalPlan.treeString()); - PhysicalDistribute distribute = (PhysicalDistribute) sink.child(); - Assertions.assertTrue(distribute.getDistributionSpec() instanceof DistributionSpecMerge, - "Missing merge distribution spec\n" + physicalPlan.treeString()); - - DistributionSpecMerge spec = (DistributionSpecMerge) distribute.getDistributionSpec(); - Assertions.assertEquals(operationExprId, spec.getOperationExprId()); - Assertions.assertTrue(spec.isInsertRandom()); - Assertions.assertTrue(spec.getInsertPartitionExprIds().isEmpty()); - Assertions.assertEquals(1, spec.getDeletePartitionExprIds().size()); - Assertions.assertEquals(rowIdExprId, spec.getDeletePartitionExprIds().get(0)); - } finally { - connectContext.getSessionVariable().enableIcebergMergePartitioning = previous; - } - } - - @Test - public void testIcebergUpdateExchangeUsesPartitionColumnsWhenEnabled() throws Exception { - useIceberg(); - IcebergExternalTable table = getIcebergTable(); - Column partitionColumn = new Column("age", PrimitiveType.INT); - Mockito.doReturn(ImmutableList.of(partitionColumn)).when(table) - .getPartitionColumns(ArgumentMatchers.any()); - boolean previous = connectContext.getSessionVariable().enableIcebergMergePartitioning; - connectContext.getSessionVariable().enableIcebergMergePartitioning = true; - try { - String sql = "update " + tableName + " set name = 'new_name' where id = 1"; - LogicalPlan updatePlan = parseStmt(sql); - Plan explainPlan = ((UpdateCommand) updatePlan).getExplainPlan(connectContext); - PhysicalPlan physicalPlan = - planPhysicalPlan((LogicalPlan) explainPlan, PhysicalProperties.GATHER, sql); - - PhysicalIcebergMergeSink sink = - getSinglePhysicalSink(physicalPlan, PhysicalIcebergMergeSink.class); - ExprId operationExprId = findOperationExprId(sink.child().getOutput()); - ExprId rowIdExprId = findRowIdExprId(sink.child().getOutput()); - ExprId partitionExprId = findExprIdByName(sink.child().getOutput(), "age"); - Assertions.assertTrue(sink.child() instanceof PhysicalDistribute, - "Missing merge partition exchange\n" + physicalPlan.treeString()); - PhysicalDistribute distribute = (PhysicalDistribute) sink.child(); - Assertions.assertTrue(distribute.getDistributionSpec() instanceof DistributionSpecMerge, - "Missing merge distribution spec\n" + physicalPlan.treeString()); - - DistributionSpecMerge spec = (DistributionSpecMerge) distribute.getDistributionSpec(); - Assertions.assertEquals(operationExprId, spec.getOperationExprId()); - Assertions.assertFalse(spec.isInsertRandom()); - Assertions.assertEquals(1, spec.getInsertPartitionExprIds().size()); - Assertions.assertEquals(partitionExprId, spec.getInsertPartitionExprIds().get(0)); - Assertions.assertEquals(1, spec.getDeletePartitionExprIds().size()); - Assertions.assertEquals(rowIdExprId, spec.getDeletePartitionExprIds().get(0)); - } finally { - connectContext.getSessionVariable().enableIcebergMergePartitioning = previous; - Mockito.doReturn(ImmutableList.of()).when(table).getPartitionColumns(ArgumentMatchers.any()); - } - } - - @Test - public void testIcebergUpdatePartitionExpressionUsesPartitionColumnWhenEnabled() throws Exception { - useIceberg(); - IcebergExternalTable table = getIcebergTable(); - Column partitionColumn = new Column("age", PrimitiveType.INT); - Mockito.doReturn(ImmutableList.of(partitionColumn)).when(table) - .getPartitionColumns(ArgumentMatchers.any()); - boolean previous = connectContext.getSessionVariable().enableIcebergMergePartitioning; - connectContext.getSessionVariable().enableIcebergMergePartitioning = true; - try { - String sql = "update " + tableName + " set age = age + 1 where id = 1"; - LogicalPlan updatePlan = parseStmt(sql); - Plan explainPlan = ((UpdateCommand) updatePlan).getExplainPlan(connectContext); - PhysicalPlan physicalPlan = - planPhysicalPlan((LogicalPlan) explainPlan, PhysicalProperties.GATHER, sql); - - PhysicalIcebergMergeSink sink = - getSinglePhysicalSink(physicalPlan, PhysicalIcebergMergeSink.class); - ExprId operationExprId = findOperationExprId(sink.child().getOutput()); - ExprId rowIdExprId = findRowIdExprId(sink.child().getOutput()); - Assertions.assertTrue(sink.child() instanceof PhysicalDistribute, - "Missing merge partition exchange\n" + physicalPlan.treeString()); - PhysicalDistribute distribute = (PhysicalDistribute) sink.child(); - Assertions.assertTrue(distribute.getDistributionSpec() instanceof DistributionSpecMerge, - "Missing merge distribution spec\n" + physicalPlan.treeString()); - - DistributionSpecMerge spec = (DistributionSpecMerge) distribute.getDistributionSpec(); - Assertions.assertEquals(operationExprId, spec.getOperationExprId()); - Assertions.assertFalse(spec.isInsertRandom()); - ExprId expectedExprId = findPartitionExprIdByColumnOrder( - sink.getCols(), sink.child().getOutput(), "age"); - Assertions.assertEquals(ImmutableList.of(expectedExprId), spec.getInsertPartitionExprIds()); - Assertions.assertEquals(ImmutableList.of(rowIdExprId), spec.getDeletePartitionExprIds()); - } finally { - connectContext.getSessionVariable().enableIcebergMergePartitioning = previous; - Mockito.doReturn(ImmutableList.of()).when(table).getPartitionColumns(ArgumentMatchers.any()); - } - } - - @Test - public void testIcebergUpdateExchangeUsesPartitionSpecTransform() throws Exception { - useIceberg(); - Schema schema = new Schema( - Types.NestedField.required(1, "id", Types.IntegerType.get()), - Types.NestedField.required(2, "name", Types.StringType.get()), - Types.NestedField.required(3, "age", Types.IntegerType.get())); - PartitionSpec partitionSpec = PartitionSpec.builderFor(schema).bucket("id", 16).build(); - Mockito.doReturn(schema).when(mockedIcebergTable).schema(); - Mockito.doReturn(partitionSpec).when(mockedIcebergTable).spec(); - - boolean previous = connectContext.getSessionVariable().enableIcebergMergePartitioning; - connectContext.getSessionVariable().enableIcebergMergePartitioning = true; - try { - String sql = "update " + tableName + " set name = 'new_name' where id = 1"; - LogicalPlan updatePlan = parseStmt(sql); - Plan explainPlan = ((UpdateCommand) updatePlan).getExplainPlan(connectContext); - PhysicalPlan physicalPlan = - planPhysicalPlan((LogicalPlan) explainPlan, PhysicalProperties.GATHER, sql); - - PhysicalIcebergMergeSink sink = - getSinglePhysicalSink(physicalPlan, PhysicalIcebergMergeSink.class); - Assertions.assertTrue(sink.child() instanceof PhysicalDistribute, - "Missing merge partition exchange\n" + physicalPlan.treeString()); - PhysicalDistribute distribute = (PhysicalDistribute) sink.child(); - Assertions.assertTrue(distribute.getDistributionSpec() instanceof DistributionSpecMerge, - "Missing merge distribution spec\n" + physicalPlan.treeString()); - DistributionSpecMerge mergeSpec = (DistributionSpecMerge) distribute.getDistributionSpec(); - - ExprId idExprId = findExprIdByName(sink.child().getOutput(), "id"); - Assertions.assertFalse(mergeSpec.isInsertRandom()); - Assertions.assertTrue(mergeSpec.getInsertPartitionExprIds().isEmpty()); - Assertions.assertEquals(1, mergeSpec.getInsertPartitionFields().size()); - DistributionSpecMerge.IcebergPartitionField field = mergeSpec.getInsertPartitionFields().get(0); - Assertions.assertEquals(idExprId, field.getSourceExprId()); - Assertions.assertEquals("bucket[16]", field.getTransform()); - Assertions.assertEquals(Integer.valueOf(partitionSpec.specId()), mergeSpec.getPartitionSpecId()); - } finally { - connectContext.getSessionVariable().enableIcebergMergePartitioning = previous; - Mockito.doReturn(basePartitionSpec).when(mockedIcebergTable).spec(); - Mockito.doReturn(baseIcebergSchema).when(mockedIcebergTable).schema(); - } - } - - @Test - public void testIcebergDeleteExchangeUsesMergePartitioning() throws Exception { - useIceberg(); - String sql = "delete from " + tableName + " where id > 1"; - LogicalPlan deletePlan = parseStmt(sql); - Plan explainPlan = ((DeleteFromCommand) deletePlan).getExplainPlan(connectContext); - PhysicalPlan physicalPlan = planPhysicalPlan((LogicalPlan) explainPlan, PhysicalProperties.GATHER, sql); - - PhysicalIcebergDeleteSink sink = getSinglePhysicalSink(physicalPlan, PhysicalIcebergDeleteSink.class); - ExprId rowIdExprId = findRowIdExprId(sink.child().getOutput()); - ExprId operationExprId = findOperationExprId(sink.child().getOutput()); - Assertions.assertTrue(sink.child() instanceof PhysicalDistribute, - "Missing merge-partition exchange\n" + physicalPlan.treeString()); - PhysicalDistribute distribute = (PhysicalDistribute) sink.child(); - Assertions.assertTrue(distribute.getDistributionSpec() instanceof DistributionSpecMerge, - "Missing merge distribution spec\n" + physicalPlan.treeString()); - DistributionSpecMerge spec = (DistributionSpecMerge) distribute.getDistributionSpec(); - Assertions.assertEquals(operationExprId, spec.getOperationExprId()); - Assertions.assertEquals(ImmutableList.of(rowIdExprId), spec.getDeletePartitionExprIds()); - Assertions.assertTrue(spec.getInsertPartitionExprIds().isEmpty()); - Assertions.assertTrue(spec.getInsertPartitionFields().isEmpty()); - Assertions.assertTrue(spec.isInsertRandom()); - Assertions.assertNull(spec.getPartitionSpecId()); - } - - - @Test - public void testIcebergUpdateExplainHasExchange() throws Exception { - useIceberg(); - String sql = "update " + tableName + " set name = 'new_name' where id = 1"; - LogicalPlan updatePlan = parseStmt(sql); - Plan explainPlan = ((UpdateCommand) updatePlan).getExplainPlan(connectContext); - String explain = getExplainString((LogicalPlan) explainPlan, - ExplainCommand.ExplainLevel.DISTRIBUTED_PLAN, sql); - String upper = explain.toUpperCase(); - Assertions.assertTrue(upper.contains("EXCHANGE"), explain); - Assertions.assertTrue(upper.contains("ICEBERG MERGE SINK"), explain); - Assertions.assertTrue(upper.contains(Column.ICEBERG_ROWID_COL.toUpperCase()), explain); - } - - @Test - public void testIcebergUpdateExplainHasMergePartitioningWhenEnabled() throws Exception { - useIceberg(); - boolean previous = connectContext.getSessionVariable().enableIcebergMergePartitioning; - connectContext.getSessionVariable().enableIcebergMergePartitioning = true; - try { - String sql = "update " + tableName + " set name = 'new_name' where id = 1"; - LogicalPlan updatePlan = parseStmt(sql); - Plan explainPlan = ((UpdateCommand) updatePlan).getExplainPlan(connectContext); - String explain = getExplainString((LogicalPlan) explainPlan, - ExplainCommand.ExplainLevel.DISTRIBUTED_PLAN, sql); - Assertions.assertTrue(explain.toUpperCase().contains("MERGE_PARTITIONED"), explain); - } finally { - connectContext.getSessionVariable().enableIcebergMergePartitioning = previous; - } - } - - @Test - public void testIcebergDeleteExplainHasExchange() throws Exception { - useIceberg(); - String sql = "delete from " + tableName + " where id > 1"; - LogicalPlan deletePlan = parseStmt(sql); - Plan explainPlan = ((DeleteFromCommand) deletePlan).getExplainPlan(connectContext); - String explain = getExplainString((LogicalPlan) explainPlan, - ExplainCommand.ExplainLevel.DISTRIBUTED_PLAN, sql); - String upper = explain.toUpperCase(); - Assertions.assertTrue(upper.contains("EXCHANGE"), explain); - Assertions.assertTrue(upper.contains("MERGE_PARTITIONED"), explain); - Assertions.assertTrue(upper.contains("ICEBERG DELETE SINK"), explain); - Assertions.assertTrue(upper.contains(Column.ICEBERG_ROWID_COL.toUpperCase()), explain); - } - - private void switchCatalog(String catalogName) throws Exception { - SwitchCommand switchCommand = (SwitchCommand) parseStmt("switch " + catalogName + ";"); - Env.getCurrentEnv().changeCatalog(connectContext, switchCommand.getCatalogName()); - } - - private void useIceberg() throws Exception { - switchCatalog(catalogName); - useDatabase(dbName); - } - - private IcebergExternalTable getIcebergTable() { - List nameParts = ImmutableList.of(catalogName, dbName, tableName); - return (IcebergExternalTable) RelationUtil.getTable(nameParts, Env.getCurrentEnv(), Optional.empty()); - } - - private PhysicalPlan planPhysicalPlan(LogicalPlan plan, PhysicalProperties physicalProperties, String sql) { - connectContext.setThreadLocalInfo(); - ensureQueryId(); - StatementContext statementContext = MemoTestUtils.createStatementContext(connectContext, sql); - LogicalPlanAdapter adapter = new LogicalPlanAdapter(plan, statementContext); - adapter.setViewDdlSqls(statementContext.getViewDdlSqls()); - statementContext.setParsedStatement(adapter); - NereidsPlanner planner = new NereidsPlanner(statementContext); - long previousTargetTableId = connectContext.getIcebergRowIdTargetTableId(); - DeleteCommandContext deleteContext = null; - long targetTableId = -1; - if (plan instanceof LogicalIcebergDeleteSink) { - deleteContext = ((LogicalIcebergDeleteSink) plan).getDeleteContext(); - targetTableId = ((LogicalIcebergDeleteSink) plan).getTargetTable().getId(); - } else if (plan instanceof LogicalIcebergMergeSink) { - deleteContext = ((LogicalIcebergMergeSink) plan).getDeleteContext(); - targetTableId = ((LogicalIcebergMergeSink) plan).getTargetTable().getId(); - } - if (deleteContext != null - && deleteContext.getDeleteFileType() == DeleteCommandContext.DeleteFileType.POSITION_DELETE - && previousTargetTableId < 0) { - connectContext.setIcebergRowIdTargetTableId(targetTableId); - } - try { - planner.plan(adapter, connectContext.getSessionVariable().toThrift()); - PhysicalPlan physicalPlan = planner.getPhysicalPlan(); - ExplainOptions explainOptions = new ExplainOptions(ExplainCommand.ExplainLevel.OPTIMIZED_PLAN, false); - System.out.println("Physical plan for: " + sql + "\n" + planner.getExplainString(explainOptions)); - return physicalPlan; - } catch (Exception exception) { - throw new IllegalStateException("Failed to plan statement: " + sql, exception); - } finally { - connectContext.setIcebergRowIdTargetTableId(previousTargetTableId); - } - } - - private String getExplainString(LogicalPlan plan, ExplainCommand.ExplainLevel level, String sql) { - connectContext.setThreadLocalInfo(); - ensureQueryId(); - StatementContext statementContext = MemoTestUtils.createStatementContext(connectContext, sql); - LogicalPlanAdapter adapter = new LogicalPlanAdapter(plan, statementContext); - adapter.setViewDdlSqls(statementContext.getViewDdlSqls()); - statementContext.setParsedStatement(adapter); - NereidsPlanner planner = new NereidsPlanner(statementContext); - long previousTargetTableId = connectContext.getIcebergRowIdTargetTableId(); - DeleteCommandContext deleteContext = null; - long targetTableId = -1; - if (plan instanceof LogicalIcebergDeleteSink) { - deleteContext = ((LogicalIcebergDeleteSink) plan).getDeleteContext(); - targetTableId = ((LogicalIcebergDeleteSink) plan).getTargetTable().getId(); - } else if (plan instanceof LogicalIcebergMergeSink) { - deleteContext = ((LogicalIcebergMergeSink) plan).getDeleteContext(); - targetTableId = ((LogicalIcebergMergeSink) plan).getTargetTable().getId(); - } - if (deleteContext != null - && deleteContext.getDeleteFileType() == DeleteCommandContext.DeleteFileType.POSITION_DELETE - && previousTargetTableId < 0) { - connectContext.setIcebergRowIdTargetTableId(targetTableId); - } - try { - planner.plan(adapter, connectContext.getSessionVariable().toThrift()); - ExplainOptions explainOptions = new ExplainOptions(level, false); - return planner.getExplainString(explainOptions); - } catch (Exception exception) { - throw new IllegalStateException("Failed to plan statement: " + sql, exception); - } finally { - connectContext.setIcebergRowIdTargetTableId(previousTargetTableId); - } - } - - private static void assertContainsPhysicalSink(PhysicalPlan plan, Class sinkClass) { - Set sinks = plan.collect(sinkClass::isInstance); - Assertions.assertFalse(sinks.isEmpty()); - } - - private void ensureQueryId() { - if (connectContext.queryId() == null) { - UUID uuid = UUID.randomUUID(); - connectContext.setQueryId(new TUniqueId(uuid.getMostSignificantBits(), uuid.getLeastSignificantBits())); - } - } - - private static ExprId findRowIdExprId(List slots) { - for (Slot slot : slots) { - if (Column.ICEBERG_ROWID_COL.equalsIgnoreCase(slot.getName())) { - return slot.getExprId(); - } - } - Assertions.fail("Missing row_id slot in output"); - return null; - } - - private static ExprId findOperationExprId(List slots) { - for (Slot slot : slots) { - if (IcebergMergeOperation.OPERATION_COLUMN.equalsIgnoreCase(slot.getName())) { - return slot.getExprId(); - } - } - Assertions.fail("Missing operation slot in output"); - return null; - } - - private static ExprId findExprIdByName(List slots, String name) { - for (Slot slot : slots) { - if (name.equalsIgnoreCase(slot.getName())) { - return slot.getExprId(); - } - } - Assertions.fail("Missing slot in output: " + name); - return null; - } - - private static ExprId findPartitionExprIdByColumnOrder(List columns, List slots, - String columnName) { - List visibleColumns = new ArrayList<>(); - for (Column column : columns) { - if (column.isVisible()) { - visibleColumns.add(column); - } - } - List dataSlots = getDataSlots(slots); - Assertions.assertEquals(visibleColumns.size(), dataSlots.size()); - int index = -1; - for (int i = 0; i < visibleColumns.size(); i++) { - if (columnName.equalsIgnoreCase(visibleColumns.get(i).getName())) { - index = i; - break; - } - } - Assertions.assertTrue(index >= 0, "Missing column in visible columns: " + columnName); - return dataSlots.get(index).getExprId(); - } - - private static List getDataSlots(List slots) { - List dataSlots = new ArrayList<>(); - for (Slot slot : slots) { - String name = slot.getName(); - if (IcebergMergeOperation.OPERATION_COLUMN.equalsIgnoreCase(name)) { - continue; - } - if (Column.ICEBERG_ROWID_COL.equalsIgnoreCase(name)) { - continue; - } - dataSlots.add(slot); - } - return dataSlots; - } - - private static T getSinglePhysicalSink(PhysicalPlan plan, Class sinkClass) { - Set sinks = plan.collect(sinkClass::isInstance); - Assertions.assertEquals(1, sinks.size()); - return sinkClass.cast(sinks.iterator().next()); - } - - private static void assertOutputCastedToColumnType(PhysicalIcebergMergeSink sink, String columnName) { - Column column = findColumnByName(sink.getCols(), columnName); - NamedExpression expr = findOutputExprByName(sink.getOutputExprs(), columnName); - Expression child = expr; - if (expr instanceof Alias) { - child = ((Alias) expr).child(); - } - DataType expected = DataType.fromCatalogType(column.getType()); - Assertions.assertEquals(expected, child.getDataType(), - "Output expression type mismatch for column: " + columnName); - } - - private static Column findColumnByName(List columns, String name) { - for (Column column : columns) { - if (name.equalsIgnoreCase(column.getName())) { - return column; - } - } - Assertions.fail("Missing column: " + name); - return null; - } - - private static NamedExpression findOutputExprByName(List exprs, String name) { - for (NamedExpression expr : exprs) { - if (name.equalsIgnoreCase(expr.getName())) { - return expr; - } - } - Assertions.fail("Missing output expression: " + name); - return null; - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergExternalMetaCacheTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergExternalMetaCacheTest.java deleted file mode 100644 index 00dde9f4cc0a74..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergExternalMetaCacheTest.java +++ /dev/null @@ -1,373 +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.datasource.iceberg; - -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.iceberg.cache.ManifestCacheValue; -import org.apache.doris.datasource.metacache.MetaCacheEntry; -import org.apache.doris.datasource.metacache.MetaCacheEntryStats; - -import org.apache.iceberg.ManifestContent; -import org.apache.iceberg.ManifestFile; -import org.apache.iceberg.Table; -import org.junit.Assert; -import org.junit.Test; - -import java.lang.reflect.Proxy; -import java.nio.ByteBuffer; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -public class IcebergExternalMetaCacheTest { - - @Test - public void testInvalidateTableKeepsManifestCache() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - IcebergExternalMetaCache cache = new IcebergExternalMetaCache(executor); - long catalogId = 1L; - cache.initCatalog(catalogId, manifestCacheEnabledProperties()); - NameMapping t1 = new NameMapping(catalogId, "db1", "tbl1", "rdb1", "rtbl1"); - NameMapping t2 = new NameMapping(catalogId, "db1", "tbl2", "rdb1", "rtbl2"); - - MetaCacheEntry tableEntry = cache.entry(catalogId, - IcebergExternalMetaCache.ENTRY_TABLE, NameMapping.class, IcebergTableCacheValue.class); - tableEntry.put(t1, new IcebergTableCacheValue(newInterfaceProxy(Table.class), - () -> new IcebergSnapshotCacheValue(IcebergPartitionInfo.empty(), new IcebergSnapshot(1L, 1L)))); - tableEntry.put(t2, new IcebergTableCacheValue(newInterfaceProxy(Table.class), - () -> new IcebergSnapshotCacheValue(IcebergPartitionInfo.empty(), new IcebergSnapshot(2L, 2L)))); - - MetaCacheEntry viewEntry = cache.entry(catalogId, - IcebergExternalMetaCache.ENTRY_VIEW, NameMapping.class, org.apache.iceberg.view.View.class); - viewEntry.put(t1, newInterfaceProxy(org.apache.iceberg.view.View.class)); - viewEntry.put(t2, newInterfaceProxy(org.apache.iceberg.view.View.class)); - - String sharedManifestPath = "/tmp/shared.avro"; - IcebergManifestEntryKey m1 = mockManifestKey(sharedManifestPath); - IcebergManifestEntryKey m2 = mockManifestKey(sharedManifestPath); - MetaCacheEntry manifestEntry = cache.entry(catalogId, - IcebergExternalMetaCache.ENTRY_MANIFEST, IcebergManifestEntryKey.class, ManifestCacheValue.class); - Assert.assertEquals(m1, m2); - manifestEntry.put(m1, ManifestCacheValue.forDataFiles(com.google.common.collect.Lists.newArrayList())); - manifestEntry.put(m2, ManifestCacheValue.forDataFiles(com.google.common.collect.Lists.newArrayList())); - - Assert.assertNotNull(manifestEntry.getIfPresent(m1)); - Assert.assertNotNull(manifestEntry.getIfPresent(m2)); - cache.invalidateTable(catalogId, "db1", "tbl1"); - - Assert.assertNull(tableEntry.getIfPresent(t1)); - Assert.assertNotNull(tableEntry.getIfPresent(t2)); - Assert.assertNull(viewEntry.getIfPresent(t1)); - Assert.assertNotNull(viewEntry.getIfPresent(t2)); - Assert.assertNotNull(manifestEntry.getIfPresent(m1)); - Assert.assertNotNull(manifestEntry.getIfPresent(m2)); - } finally { - executor.shutdownNow(); - } - } - - @Test - public void testInvalidateDbAndStats() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - IcebergExternalMetaCache cache = new IcebergExternalMetaCache(executor); - long catalogId = 1L; - cache.initCatalog(catalogId, manifestCacheEnabledProperties()); - NameMapping db1Table = new NameMapping(catalogId, "db1", "tbl1", "rdb1", "rtbl1"); - NameMapping db2Table = new NameMapping(catalogId, "db2", "tbl1", "rdb2", "rtbl1"); - - MetaCacheEntry tableEntry = cache.entry(catalogId, - IcebergExternalMetaCache.ENTRY_TABLE, NameMapping.class, IcebergTableCacheValue.class); - tableEntry.put(db1Table, new IcebergTableCacheValue(newInterfaceProxy(Table.class), - () -> new IcebergSnapshotCacheValue(IcebergPartitionInfo.empty(), new IcebergSnapshot(1L, 1L)))); - tableEntry.put(db2Table, new IcebergTableCacheValue(newInterfaceProxy(Table.class), - () -> new IcebergSnapshotCacheValue(IcebergPartitionInfo.empty(), new IcebergSnapshot(2L, 2L)))); - - MetaCacheEntry schemaEntry = cache.entry(catalogId, - IcebergExternalMetaCache.ENTRY_SCHEMA, IcebergSchemaCacheKey.class, SchemaCacheValue.class); - IcebergSchemaCacheKey db1Schema = new IcebergSchemaCacheKey(db1Table, 1L); - IcebergSchemaCacheKey db2Schema = new IcebergSchemaCacheKey(db2Table, 2L); - schemaEntry.put(db1Schema, new SchemaCacheValue(Collections.emptyList())); - schemaEntry.put(db2Schema, new SchemaCacheValue(Collections.emptyList())); - MetaCacheEntry manifestEntry = cache.entry(catalogId, - IcebergExternalMetaCache.ENTRY_MANIFEST, IcebergManifestEntryKey.class, ManifestCacheValue.class); - IcebergManifestEntryKey manifestKey = mockManifestKey("/tmp/db-invalidate.avro"); - manifestEntry.put(manifestKey, - ManifestCacheValue.forDataFiles(com.google.common.collect.Lists.newArrayList())); - - cache.invalidateDb(catalogId, "db1"); - - Assert.assertNull(tableEntry.getIfPresent(db1Table)); - Assert.assertNotNull(tableEntry.getIfPresent(db2Table)); - Assert.assertNull(schemaEntry.getIfPresent(db1Schema)); - Assert.assertNotNull(schemaEntry.getIfPresent(db2Schema)); - Assert.assertNotNull(manifestEntry.getIfPresent(manifestKey)); - - Map stats = cache.stats(catalogId); - Assert.assertTrue(stats.containsKey(IcebergExternalMetaCache.ENTRY_TABLE)); - Assert.assertTrue(stats.get(IcebergExternalMetaCache.ENTRY_MANIFEST).isConfigEnabled()); - Assert.assertTrue(stats.get(IcebergExternalMetaCache.ENTRY_MANIFEST).isEffectiveEnabled()); - Assert.assertFalse(stats.get(IcebergExternalMetaCache.ENTRY_MANIFEST).isAutoRefresh()); - Assert.assertEquals(-1L, stats.get(IcebergExternalMetaCache.ENTRY_MANIFEST).getTtlSecond()); - } finally { - executor.shutdownNow(); - } - } - - @Test - public void testSchemaStatsWhenSchemaCacheDisabled() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - IcebergExternalMetaCache cache = new IcebergExternalMetaCache(executor); - long catalogId = 1L; - Map properties = com.google.common.collect.Maps.newHashMap(); - properties.put(ExternalCatalog.SCHEMA_CACHE_TTL_SECOND, "0"); - cache.initCatalog(catalogId, properties); - - Map stats = cache.stats(catalogId); - MetaCacheEntryStats schemaStats = stats.get(IcebergExternalMetaCache.ENTRY_SCHEMA); - Assert.assertNotNull(schemaStats); - Assert.assertEquals(0L, schemaStats.getTtlSecond()); - Assert.assertTrue(schemaStats.isConfigEnabled()); - Assert.assertFalse(schemaStats.isEffectiveEnabled()); - } finally { - executor.shutdownNow(); - } - } - - @Test - public void testManifestStatsDisabledByDefault() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - IcebergExternalMetaCache cache = new IcebergExternalMetaCache(executor); - long catalogId = 1L; - cache.initCatalog(catalogId, Collections.emptyMap()); - - Map stats = cache.stats(catalogId); - MetaCacheEntryStats manifestStats = stats.get(IcebergExternalMetaCache.ENTRY_MANIFEST); - Assert.assertNotNull(manifestStats); - Assert.assertFalse(manifestStats.isConfigEnabled()); - Assert.assertFalse(manifestStats.isEffectiveEnabled()); - Assert.assertFalse(manifestStats.isAutoRefresh()); - Assert.assertEquals(-1L, manifestStats.getTtlSecond()); - Assert.assertEquals(100000L, manifestStats.getCapacity()); - } finally { - executor.shutdownNow(); - } - } - - @Test - public void testManifestEntryRequiresContextualLoader() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - IcebergExternalMetaCache cache = new IcebergExternalMetaCache(executor); - long catalogId = 1L; - cache.initCatalog(catalogId, manifestCacheEnabledProperties()); - MetaCacheEntry manifestEntry = cache.entry(catalogId, - IcebergExternalMetaCache.ENTRY_MANIFEST, IcebergManifestEntryKey.class, ManifestCacheValue.class); - IcebergManifestEntryKey manifestKey = mockManifestKey("/tmp/contextual-only.avro"); - - UnsupportedOperationException exception = Assert.assertThrows(UnsupportedOperationException.class, - () -> manifestEntry.get(manifestKey)); - Assert.assertTrue(exception.getMessage().contains("contextual miss loader")); - - ManifestCacheValue value = manifestEntry.get(manifestKey, - ignored -> ManifestCacheValue.forDataFiles(com.google.common.collect.Lists.newArrayList())); - Assert.assertNotNull(value); - Assert.assertSame(value, manifestEntry.getIfPresent(manifestKey)); - } finally { - executor.shutdownNow(); - } - } - - @Test - public void testManifestEnableUsesDefaultCapacity() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - IcebergExternalMetaCache cache = new IcebergExternalMetaCache(executor); - long catalogId = 1L; - Map properties = com.google.common.collect.Maps.newHashMap(); - properties.put("meta.cache.iceberg.manifest.enable", "true"); - cache.initCatalog(catalogId, properties); - - Map stats = cache.stats(catalogId); - MetaCacheEntryStats manifestStats = stats.get(IcebergExternalMetaCache.ENTRY_MANIFEST); - Assert.assertNotNull(manifestStats); - Assert.assertTrue(manifestStats.isConfigEnabled()); - Assert.assertTrue(manifestStats.isEffectiveEnabled()); - Assert.assertEquals(-1L, manifestStats.getTtlSecond()); - Assert.assertEquals(100000L, manifestStats.getCapacity()); - } finally { - executor.shutdownNow(); - } - } - - private Map manifestCacheEnabledProperties() { - Map properties = com.google.common.collect.Maps.newHashMap(); - properties.put("meta.cache.iceberg.manifest.enable", "true"); - return properties; - } - - private IcebergManifestEntryKey mockManifestKey(String path) { - return IcebergManifestEntryKey.of(new TestingManifestFile(path, ManifestContent.DATA)); - } - - private T newInterfaceProxy(Class type) { - return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[] {type}, (proxy, method, args) -> { - if (method.getDeclaringClass() == Object.class) { - switch (method.getName()) { - case "equals": - return proxy == args[0]; - case "hashCode": - return System.identityHashCode(proxy); - case "toString": - return type.getSimpleName() + "Proxy"; - default: - return null; - } - } - return defaultValue(method.getReturnType()); - })); - } - - private Object defaultValue(Class type) { - if (!type.isPrimitive()) { - return null; - } - if (type == boolean.class) { - return false; - } - if (type == byte.class) { - return (byte) 0; - } - if (type == short.class) { - return (short) 0; - } - if (type == int.class) { - return 0; - } - if (type == long.class) { - return 0L; - } - if (type == float.class) { - return 0F; - } - if (type == double.class) { - return 0D; - } - if (type == char.class) { - return '\0'; - } - throw new IllegalArgumentException("unsupported primitive type: " + type); - } - - private static final class TestingManifestFile implements ManifestFile { - private final String path; - private final ManifestContent content; - - private TestingManifestFile(String path, ManifestContent content) { - this.path = path; - this.content = content; - } - - @Override - public String path() { - return path; - } - - @Override - public ManifestContent content() { - return content; - } - - @Override - public long length() { - return 0; - } - - @Override - public int partitionSpecId() { - return 0; - } - - @Override - public long sequenceNumber() { - return 0; - } - - @Override - public long minSequenceNumber() { - return 0; - } - - @Override - public Long snapshotId() { - return null; - } - - @Override - public Integer addedFilesCount() { - return null; - } - - @Override - public Long addedRowsCount() { - return null; - } - - @Override - public Integer existingFilesCount() { - return null; - } - - @Override - public Long existingRowsCount() { - return null; - } - - @Override - public Integer deletedFilesCount() { - return null; - } - - @Override - public Long deletedRowsCount() { - return null; - } - - @Override - public List partitions() { - return null; - } - - @Override - public ByteBuffer keyMetadata() { - return null; - } - - @Override - public ManifestFile copy() { - return new TestingManifestFile(path, content); - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergExternalTableBranchAndTagTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergExternalTableBranchAndTagTest.java deleted file mode 100644 index d98714d12ba818..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergExternalTableBranchAndTagTest.java +++ /dev/null @@ -1,466 +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.datasource.iceberg; - -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.RefreshManager; -import org.apache.doris.catalog.info.BranchOptions; -import org.apache.doris.catalog.info.CreateOrReplaceBranchInfo; -import org.apache.doris.catalog.info.CreateOrReplaceTagInfo; -import org.apache.doris.catalog.info.DropBranchInfo; -import org.apache.doris.catalog.info.DropTagInfo; -import org.apache.doris.catalog.info.TagOptions; -import org.apache.doris.common.UserException; -import org.apache.doris.persist.EditLog; - -import com.google.common.collect.Lists; -import org.apache.hadoop.conf.Configuration; -import org.apache.iceberg.CatalogUtil; -import org.apache.iceberg.DataFiles; -import org.apache.iceberg.Schema; -import org.apache.iceberg.Snapshot; -import org.apache.iceberg.SnapshotRef; -import org.apache.iceberg.Table; -import org.apache.iceberg.catalog.Namespace; -import org.apache.iceberg.catalog.TableIdentifier; -import org.apache.iceberg.hadoop.HadoopCatalog; -import org.apache.iceberg.types.Types; -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.MockedStatic; -import org.mockito.Mockito; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public class IcebergExternalTableBranchAndTagTest { - - Path tempDirectory; - Table icebergTable; - IcebergExternalCatalog catalog; - IcebergExternalDatabase db; - IcebergExternalTable dorisTable; - HadoopCatalog icebergCatalog; - MockedStatic mockedIcebergUtils; - MockedStatic mockedEnv; - String dbName = "db"; - String tblName = "tbl"; - - @BeforeEach - public void setUp() throws IOException { - HashMap map = new HashMap<>(); - tempDirectory = Files.createTempDirectory(""); - map.put("warehouse", "file://" + tempDirectory.toString()); - map.put("type", "hadoop"); - map.put("iceberg.catalog.type", IcebergExternalCatalog.ICEBERG_HADOOP); - icebergCatalog = - (HadoopCatalog) CatalogUtil.buildIcebergCatalog("iceberg_catalog", map, new Configuration()); - map.put("type", "iceberg"); - // init iceberg table - icebergCatalog.createNamespace(Namespace.of(dbName)); - icebergTable = icebergCatalog.createTable( - TableIdentifier.of(dbName, tblName), - new Schema(Types.NestedField.required(1, "level", Types.StringType.get()))); - // init external table - catalog = Mockito.spy(new IcebergHadoopExternalCatalog(1L, "iceberg", null, map, null)); - catalog.setInitializedForTest(true); - // db = new IcebergExternalDatabase(catalog, 1L, dbName, dbName); - db = Mockito.spy(new IcebergExternalDatabase(catalog, 1L, dbName, dbName)); - dorisTable = Mockito.spy(new IcebergExternalTable(1, tblName, tblName, catalog, db)); - Mockito.doReturn(db).when(catalog).getDbNullable(Mockito.any()); - Mockito.doReturn(dorisTable).when(db).getTableNullable(Mockito.any()); - - // mock IcebergUtils.getIcebergTable to return our test icebergTable - mockedIcebergUtils = Mockito.mockStatic(IcebergUtils.class); - mockedIcebergUtils.when(() -> IcebergUtils.getIcebergTable(Mockito.any())) - .thenReturn(icebergTable); - - // mock Env.getCurrentEnv().getEditLog().logBranchOrTag(info) to do nothing - Env mockEnv = Mockito.mock(Env.class); - EditLog mockEditLog = Mockito.mock(EditLog.class); - mockedEnv = Mockito.mockStatic(Env.class); - mockedEnv.when(Env::getCurrentEnv).thenReturn(mockEnv); - Mockito.when(mockEnv.getEditLog()).thenReturn(mockEditLog); - Mockito.doNothing().when(mockEditLog).logBranchOrTag(Mockito.any()); - - // mock refresh table after branch/tag operation - // Env.getCurrentEnv().getRefreshManager() - // .refreshTableInternal(dorisCatalog, db, tbl, System.currentTimeMillis()); - RefreshManager refreshManager = Mockito.mock(RefreshManager.class); - Mockito.when(mockEnv.getRefreshManager()).thenReturn(refreshManager); - Mockito.doNothing().when(refreshManager) - .refreshTableInternal(Mockito.any(), Mockito.any(), Mockito.anyLong()); - } - - @AfterEach - public void tearDown() throws IOException { - if (icebergCatalog != null) { - icebergCatalog.dropTable(TableIdentifier.of("db", "tbl")); - icebergCatalog.dropNamespace(Namespace.of("db")); - } - Files.deleteIfExists(tempDirectory); - - // close the static mock - if (mockedIcebergUtils != null) { - mockedIcebergUtils.close(); - } - if (mockedEnv != null) { - mockedEnv.close(); - } - } - - @Test - public void testCreateTagWithTable() throws UserException, IOException { - String tag1 = "tag1"; - String tag2 = "tag2"; - String tag3 = "tag3"; - - // create a new tag: tag1 - // will fail - CreateOrReplaceTagInfo info = - new CreateOrReplaceTagInfo(tag1, true, false, false, TagOptions.EMPTY); - Assertions.assertThrows( - UserException.class, - () -> catalog.createOrReplaceTag(dorisTable, info)); - - // add some data - addSomeDataIntoIcebergTable(); - List snapshots = Lists.newArrayList(icebergTable.snapshots()); - Assertions.assertEquals(1, snapshots.size()); - - // create a new tag: tag1 - catalog.createOrReplaceTag(dorisTable, info); - assertSnapshotRef( - icebergTable.refs().get(tag1), - icebergTable.currentSnapshot().snapshotId(), - false, null, null, null); - - // create an existed tag: tag1 - Assertions.assertThrows( - RuntimeException.class, - () -> catalog.createOrReplaceTag(dorisTable, info)); - - // create an existed tag with replace - CreateOrReplaceTagInfo info2 = - new CreateOrReplaceTagInfo(tag1, true, true, false, TagOptions.EMPTY); - catalog.createOrReplaceTag(dorisTable, info2); - assertSnapshotRef( - icebergTable.refs().get(tag1), - icebergTable.currentSnapshot().snapshotId(), - false, null, null, null); - - // create an existed tag with if not exists - CreateOrReplaceTagInfo info3 = - new CreateOrReplaceTagInfo(tag1, true, false, true, TagOptions.EMPTY); - catalog.createOrReplaceTag(dorisTable, info3); - assertSnapshotRef( - icebergTable.refs().get(tag1), - icebergTable.currentSnapshot().snapshotId(), - false, null, null, null); - - // add some data - addSomeDataIntoIcebergTable(); - addSomeDataIntoIcebergTable(); - snapshots = Lists.newArrayList(icebergTable.snapshots()); - Assertions.assertEquals(3, snapshots.size()); - - // create new tag: tag2 with snapshotId - TagOptions tagOps = new TagOptions( - Optional.of(snapshots.get(1).snapshotId()), - Optional.empty()); - CreateOrReplaceTagInfo info4 = - new CreateOrReplaceTagInfo(tag2, true, false, false, tagOps); - catalog.createOrReplaceTag(dorisTable, info4); - assertSnapshotRef( - icebergTable.refs().get(tag2), - snapshots.get(1).snapshotId(), - false, null, null, null); - - // update tag2 - TagOptions tagOps2 = new TagOptions( - Optional.empty(), - Optional.of(2L)); - CreateOrReplaceTagInfo info5 = - new CreateOrReplaceTagInfo(tag2, true, true, false, tagOps2); - catalog.createOrReplaceTag(dorisTable, info5); - assertSnapshotRef( - icebergTable.refs().get(tag2), - icebergTable.currentSnapshot().snapshotId(), - false, null, null, 2L); - - // create new tag: tag3 - CreateOrReplaceTagInfo info6 = - new CreateOrReplaceTagInfo(tag3, true, false, false, tagOps2); - catalog.createOrReplaceTag(dorisTable, info6); - assertSnapshotRef( - icebergTable.refs().get(tag3), - icebergTable.currentSnapshot().snapshotId(), - false, null, null, 2L); - - Assertions.assertEquals(4, icebergTable.refs().size()); - } - - @Test - public void testCreateBranchWithNotEmptyTable() throws UserException, IOException { - - String branch1 = "branch1"; - String branch2 = "branch2"; - String branch3 = "branch3"; - - // create a new branch: branch1 - CreateOrReplaceBranchInfo info = - new CreateOrReplaceBranchInfo(branch1, true, false, false, BranchOptions.EMPTY); - catalog.createOrReplaceBranch(dorisTable, info); - List snapshots = Lists.newArrayList(icebergTable.snapshots()); - Assertions.assertEquals(1, snapshots.size()); - assertSnapshotRef( - icebergTable.refs().get(branch1), - snapshots.get(0).snapshotId(), - true, null, null, null); - - // create an existed branch, failed - Assertions.assertThrowsExactly(RuntimeException.class, - () -> catalog.createOrReplaceBranch(dorisTable, info)); - - // create or replace an empty branch, will fail - // because cannot perform a replace operation on an empty branch. - CreateOrReplaceBranchInfo info2 = - new CreateOrReplaceBranchInfo(branch1, true, true, false, BranchOptions.EMPTY); - Assertions.assertThrows( - UserException.class, - () -> catalog.createOrReplaceBranch(dorisTable, info2)); - - // create an existed branch with ifNotExists - CreateOrReplaceBranchInfo info4 = - new CreateOrReplaceBranchInfo(branch1, true, false, true, BranchOptions.EMPTY); - catalog.createOrReplaceBranch(dorisTable, info4); - assertSnapshotRef( - icebergTable.refs().get(branch1), - snapshots.get(0).snapshotId(), - true, null, null, null); - - // add some data - addSomeDataIntoIcebergTable(); - snapshots = Lists.newArrayList(icebergTable.snapshots()); - Assertions.assertEquals(2, snapshots.size()); - - // update branch1 - catalog.createOrReplaceBranch(dorisTable, info2); - assertSnapshotRef( - icebergTable.refs().get(branch1), - icebergTable.currentSnapshot().snapshotId(), - true, null, null, null); - - // create or replace a new branch: branch2 - CreateOrReplaceBranchInfo info3 = - new CreateOrReplaceBranchInfo(branch2, true, true, false, BranchOptions.EMPTY); - catalog.createOrReplaceBranch(dorisTable, info3); - assertSnapshotRef( - icebergTable.refs().get(branch2), - icebergTable.currentSnapshot().snapshotId(), - true, null, null, null); - - // update branch2 - BranchOptions brOps = new BranchOptions( - Optional.empty(), - Optional.of(1L), - Optional.of(2), - Optional.of(3L)); - CreateOrReplaceBranchInfo info5 = - new CreateOrReplaceBranchInfo(branch2, true, true, false, brOps); - catalog.createOrReplaceBranch(dorisTable, info5); - assertSnapshotRef( - icebergTable.refs().get(branch2), - icebergTable.currentSnapshot().snapshotId(), - true, 1L, 2, 3L); - - // total branch: - // 'main','branch1','branch2' - Assertions.assertEquals(3, icebergTable.refs().size()); - - // insert some data - addSomeDataIntoIcebergTable(); - addSomeDataIntoIcebergTable(); - addSomeDataIntoIcebergTable(); - addSomeDataIntoIcebergTable(); - snapshots = Lists.newArrayList(icebergTable.snapshots()); - Assertions.assertEquals(6, snapshots.size()); - - // create a new branch: branch3 - BranchOptions brOps2 = new BranchOptions( - Optional.of(snapshots.get(4).snapshotId()), - Optional.of(1L), - Optional.of(2), - Optional.of(3L)); - CreateOrReplaceBranchInfo info6 = - new CreateOrReplaceBranchInfo(branch3, true, true, false, brOps2); - catalog.createOrReplaceBranch(dorisTable, info6); - assertSnapshotRef( - icebergTable.refs().get(branch3), - snapshots.get(4).snapshotId(), - true, 1L, 2, 3L); - - // update branch1 - catalog.createOrReplaceBranch(dorisTable, info2); - assertSnapshotRef( - icebergTable.refs().get(branch1), - icebergTable.currentSnapshot().snapshotId(), - true, null, null, null); - - Assertions.assertEquals(4, icebergTable.refs().size()); - } - - private void addSomeDataIntoIcebergTable() throws IOException { - Path fileA = Files.createFile(tempDirectory.resolve(UUID.randomUUID().toString())); - DataFiles.Builder builder = DataFiles.builder(icebergTable.spec()) - .withPath(fileA.toString()) - .withFileSizeInBytes(10) - .withRecordCount(1) - .withFormat("parquet"); - icebergTable.newFastAppend() - .appendFile(builder.build()) - .commit(); - } - - private void assertSnapshotRef( - SnapshotRef ref, - Long snapshotId, - boolean isBranch, - Long maxSnapshotAgeMs, - Integer minSnapshotsToKeep, - Long maxRefAgeMs) { - if (snapshotId != null) { - Assertions.assertEquals(snapshotId, ref.snapshotId()); - } - if (isBranch) { - Assertions.assertTrue(ref.isBranch()); - } else { - Assertions.assertTrue(ref.isTag()); - } - Assertions.assertEquals(maxSnapshotAgeMs, ref.maxSnapshotAgeMs()); - Assertions.assertEquals(minSnapshotsToKeep, ref.minSnapshotsToKeep()); - Assertions.assertEquals(maxRefAgeMs, ref.maxRefAgeMs()); - } - - @Test - public void testDropBranchAndTag() throws IOException, UserException { - String tag1 = "tag1"; - String tag2 = "tag2"; - String branch1 = "branch1"; - String branch2 = "branch2"; - String tagNotExists = "tagNotExists"; - String branchNotExists = "branchNotExists"; - - // create a new tag: tag1 - addSomeDataIntoIcebergTable(); - CreateOrReplaceTagInfo tagInfo = - new CreateOrReplaceTagInfo(tag1, true, false, false, TagOptions.EMPTY); - catalog.createOrReplaceTag(dorisTable, tagInfo); - - // create a new branch: branch1 - CreateOrReplaceBranchInfo branchInfo = - new CreateOrReplaceBranchInfo(branch1, true, false, false, BranchOptions.EMPTY); - catalog.createOrReplaceBranch(dorisTable, branchInfo); - - // create a new tag: tag2 - addSomeDataIntoIcebergTable(); - CreateOrReplaceTagInfo tagInfo2 = - new CreateOrReplaceTagInfo(tag2, true, false, false, TagOptions.EMPTY); - catalog.createOrReplaceTag(dorisTable, tagInfo2); - - // create a new branch: branch2 - CreateOrReplaceBranchInfo branchInfo2 = - new CreateOrReplaceBranchInfo(branch2, true, false, false, BranchOptions.EMPTY); - catalog.createOrReplaceBranch(dorisTable, branchInfo2); - - Assertions.assertEquals(5, icebergTable.refs().size()); - - Assertions.assertTrue(icebergTable.refs().containsKey(tag1)); - Assertions.assertTrue(icebergTable.refs().get(tag1).isTag()); - - Assertions.assertTrue(icebergTable.refs().containsKey(tag2)); - Assertions.assertTrue(icebergTable.refs().get(tag2).isTag()); - - Assertions.assertTrue(icebergTable.refs().containsKey(branch1)); - Assertions.assertTrue(icebergTable.refs().get(branch1).isBranch()); - - Assertions.assertTrue(icebergTable.refs().containsKey(branch2)); - Assertions.assertTrue(icebergTable.refs().get(branch2).isBranch()); - - // drop tag with branch interface, will fail - DropBranchInfo dropBranchInfoWithTag1 = new DropBranchInfo(tag1, false); - DropBranchInfo dropBranchInfoIfExistsWithTag1 = new DropBranchInfo(tag1, true); - Assertions.assertThrows(RuntimeException.class, - () -> catalog.dropBranch(dorisTable, dropBranchInfoWithTag1)); - Assertions.assertThrows(RuntimeException.class, - () -> catalog.dropBranch(dorisTable, dropBranchInfoIfExistsWithTag1)); - - // drop branch with tag interface, will fail - DropTagInfo dropTagInfoWithBranch1 = new DropTagInfo(branch1, false); - DropTagInfo dropTagInfoWithBranchIfExists1 = new DropTagInfo(branch1, true); - Assertions.assertThrows(RuntimeException.class, - () -> catalog.dropTag(dorisTable, dropTagInfoWithBranch1)); - Assertions.assertThrows(RuntimeException.class, - () -> catalog.dropTag(dorisTable, dropTagInfoWithBranchIfExists1)); - - // drop not exists tag - DropTagInfo dropTagInfoWithNotExistsTag1 = new DropTagInfo(tagNotExists, true); - DropTagInfo dropTagInfoWithNotExistsTag2 = new DropTagInfo(tagNotExists, false); - DropTagInfo dropTagInfoWithNotExistsBranch1 = new DropTagInfo(branchNotExists, true); - DropTagInfo dropTagInfoWithNotExistsBranch2 = new DropTagInfo(branchNotExists, false); - catalog.dropTag(dorisTable, dropTagInfoWithNotExistsTag1); - Assertions.assertThrows(RuntimeException.class, - () -> catalog.dropTag(dorisTable, dropTagInfoWithNotExistsTag2)); - catalog.dropTag(dorisTable, dropTagInfoWithNotExistsBranch1); - Assertions.assertThrows(RuntimeException.class, - () -> catalog.dropTag(dorisTable, dropTagInfoWithNotExistsBranch2)); - - // drop not exists branch - DropBranchInfo dropBranchInfoWithNotExistsTag1 = new DropBranchInfo(tagNotExists, true); - DropBranchInfo dropBranchInfoWithNotExistsTag2 = new DropBranchInfo(tagNotExists, false); - DropBranchInfo dropBranchInfoIfExistsWithBranch1 = new DropBranchInfo(branchNotExists, true); - DropBranchInfo dropBranchInfoIfExistsWithBranch2 = new DropBranchInfo(branchNotExists, false); - catalog.dropBranch(dorisTable, dropBranchInfoWithNotExistsTag1); - Assertions.assertThrows(RuntimeException.class, - () -> catalog.dropBranch(dorisTable, dropBranchInfoWithNotExistsTag2)); - catalog.dropBranch(dorisTable, dropBranchInfoIfExistsWithBranch1); - Assertions.assertThrows(RuntimeException.class, - () -> catalog.dropBranch(dorisTable, dropBranchInfoIfExistsWithBranch2)); - - // drop branch1 and branch2 - DropBranchInfo dropBranchInfoWithBranch1 = new DropBranchInfo(branch1, false); - DropBranchInfo dropBranchInfoWithBranch2 = new DropBranchInfo(branch2, true); - catalog.dropBranch(dorisTable, dropBranchInfoWithBranch1); - catalog.dropBranch(dorisTable, dropBranchInfoWithBranch2); - - // drop tag1 and tag2 - DropTagInfo dropTagInfoWithTag1 = new DropTagInfo(tag1, false); - DropTagInfo dropTagInfoWithTag2 = new DropTagInfo(tag2, true); - catalog.dropTag(dorisTable, dropTagInfoWithTag1); - catalog.dropTag(dorisTable, dropTagInfoWithTag2); - - Assertions.assertEquals(1, icebergTable.refs().size()); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergExternalTableTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergExternalTableTest.java deleted file mode 100644 index 0a5a4ab11d4621..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergExternalTableTest.java +++ /dev/null @@ -1,423 +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.datasource.iceberg; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.PartitionItem; -import org.apache.doris.catalog.PartitionKey; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.catalog.RangePartitionItem; -import org.apache.doris.common.AnalysisException; - -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import com.google.common.collect.Range; -import org.apache.iceberg.PartitionField; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Schema; -import org.apache.iceberg.transforms.Transform; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentMatchers; -import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Set; - -public class IcebergExternalTableTest { - - private org.apache.iceberg.Table icebergTable; - private PartitionSpec spec; - private PartitionField field; - private Schema schema; - private IcebergExternalCatalog mockCatalog; - - @BeforeEach - public void setUp() { - MockitoAnnotations.openMocks(this); - icebergTable = Mockito.mock(org.apache.iceberg.Table.class); - spec = Mockito.mock(PartitionSpec.class); - field = Mockito.mock(PartitionField.class); - schema = Mockito.mock(Schema.class); - mockCatalog = Mockito.mock(IcebergExternalCatalog.class); - } - - @Test - public void testIsSupportedPartitionTable() { - IcebergExternalDatabase database = new IcebergExternalDatabase(mockCatalog, 1L, "2", "2"); - IcebergExternalTable table = new IcebergExternalTable(1, "1", "1", mockCatalog, database); - - // Create a spy to be able to mock the getIcebergTable method and the makeSureInitialized method - IcebergExternalTable spyTable = Mockito.spy(table); - Mockito.doReturn(icebergTable).when(spyTable).getIcebergTable(); - // Simulate the makeSureInitialized method as a no-op to avoid calling the parent class implementation - Mockito.doNothing().when(spyTable).makeSureInitialized(); - - Map specs = Maps.newHashMap(); - - // Test null - specs.put(0, null); - Mockito.when(icebergTable.specs()).thenReturn(specs); - - Assertions.assertFalse(spyTable.isValidRelatedTableCached()); - Assertions.assertFalse(spyTable.isValidRelatedTable()); - - Mockito.verify(icebergTable, Mockito.times(1)).specs(); - Assertions.assertTrue(spyTable.isValidRelatedTableCached()); - Assertions.assertFalse(spyTable.validRelatedTableCache()); - - // Test spec fields are empty. - specs.put(0, spec); - spyTable.setIsValidRelatedTableCached(false); - Assertions.assertFalse(spyTable.isValidRelatedTableCached()); - - Mockito.when(icebergTable.specs()).thenReturn(specs); - List fields = Lists.newArrayList(); - Mockito.when(spec.fields()).thenReturn(fields); - - Assertions.assertFalse(spyTable.isValidRelatedTable()); - Mockito.verify(spec, Mockito.times(1)).fields(); - Assertions.assertTrue(spyTable.isValidRelatedTableCached()); - Assertions.assertFalse(spyTable.validRelatedTableCache()); - - // Test spec fields are more than 1. - specs.put(0, spec); - spyTable.setIsValidRelatedTableCached(false); - Assertions.assertFalse(spyTable.isValidRelatedTableCached()); - - Mockito.when(icebergTable.specs()).thenReturn(specs); - fields.add(null); - fields.add(null); - Mockito.when(spec.fields()).thenReturn(fields); - - Assertions.assertFalse(spyTable.isValidRelatedTable()); - Mockito.verify(spec, Mockito.times(2)).fields(); - Assertions.assertTrue(spyTable.isValidRelatedTableCached()); - Assertions.assertFalse(spyTable.validRelatedTableCache()); - fields.clear(); - - // Test true - fields.add(field); - spyTable.setIsValidRelatedTableCached(false); - Assertions.assertFalse(spyTable.isValidRelatedTableCached()); - - Mockito.when(icebergTable.schema()).thenReturn(schema); - Mockito.when(schema.findColumnName(ArgumentMatchers.anyInt())).thenReturn("col1"); - Mockito.doReturn(mockTransform("hour")).when(field).transform(); - Mockito.when(field.sourceId()).thenReturn(1); - - Assertions.assertTrue(spyTable.isValidRelatedTable()); - Assertions.assertTrue(spyTable.isValidRelatedTableCached()); - Assertions.assertTrue(spyTable.validRelatedTableCache()); - Mockito.verify(schema, Mockito.times(1)).findColumnName(ArgumentMatchers.anyInt()); - - Mockito.doReturn(mockTransform("day")).when(field).transform(); - Mockito.when(field.sourceId()).thenReturn(1); - spyTable.setIsValidRelatedTableCached(false); - Assertions.assertFalse(spyTable.isValidRelatedTableCached()); - Assertions.assertTrue(spyTable.isValidRelatedTable()); - - Mockito.doReturn(mockTransform("month")).when(field).transform(); - Mockito.when(field.sourceId()).thenReturn(1); - spyTable.setIsValidRelatedTableCached(false); - Assertions.assertFalse(spyTable.isValidRelatedTableCached()); - Assertions.assertTrue(spyTable.isValidRelatedTable()); - Assertions.assertTrue(spyTable.isValidRelatedTableCached()); - Assertions.assertTrue(spyTable.validRelatedTableCache()); - } - - @Test - public void testGetPartitionRange() throws AnalysisException { - Column c = new Column("ts", PrimitiveType.DATETIMEV2); - List partitionColumns = Lists.newArrayList(c); - - // Test null partition value - Range nullRange = IcebergUtils.getPartitionRange(null, "hour", partitionColumns); - Assertions.assertEquals("0000-01-01 00:00:00", - nullRange.lowerEndpoint().getPartitionValuesAsStringList().get(0)); - Assertions.assertEquals("0000-01-01 00:00:01", - nullRange.upperEndpoint().getPartitionValuesAsStringList().get(0)); - - // Test hour transform. - Range hour = IcebergUtils.getPartitionRange("100", "hour", partitionColumns); - PartitionKey lowKey = hour.lowerEndpoint(); - PartitionKey upKey = hour.upperEndpoint(); - Assertions.assertEquals("1970-01-05 04:00:00", lowKey.getPartitionValuesAsStringList().get(0)); - Assertions.assertEquals("1970-01-05 05:00:00", upKey.getPartitionValuesAsStringList().get(0)); - - // Test day transform. - Range day = IcebergUtils.getPartitionRange("100", "day", partitionColumns); - lowKey = day.lowerEndpoint(); - upKey = day.upperEndpoint(); - Assertions.assertEquals("1970-04-11 00:00:00", lowKey.getPartitionValuesAsStringList().get(0)); - Assertions.assertEquals("1970-04-12 00:00:00", upKey.getPartitionValuesAsStringList().get(0)); - - // Test month transform. - Range month = IcebergUtils.getPartitionRange("100", "month", partitionColumns); - lowKey = month.lowerEndpoint(); - upKey = month.upperEndpoint(); - Assertions.assertEquals("1978-05-01 00:00:00", lowKey.getPartitionValuesAsStringList().get(0)); - Assertions.assertEquals("1978-06-01 00:00:00", upKey.getPartitionValuesAsStringList().get(0)); - - // Test year transform. - Range year = IcebergUtils.getPartitionRange("100", "year", partitionColumns); - lowKey = year.lowerEndpoint(); - upKey = year.upperEndpoint(); - Assertions.assertEquals("2070-01-01 00:00:00", lowKey.getPartitionValuesAsStringList().get(0)); - Assertions.assertEquals("2071-01-01 00:00:00", upKey.getPartitionValuesAsStringList().get(0)); - - // Test unsupported transform - Exception exception = Assertions.assertThrows(RuntimeException.class, () -> { - IcebergUtils.getPartitionRange("100", "bucket", partitionColumns); - }); - Assertions.assertEquals("Unsupported transform bucket", exception.getMessage()); - } - - @Test - public void testSortRange() throws AnalysisException { - Column c = new Column("c", PrimitiveType.DATETIMEV2); - ArrayList columns = Lists.newArrayList(c); - PartitionItem nullRange = new RangePartitionItem(IcebergUtils.getPartitionRange(null, "hour", columns)); - PartitionItem year1970 = new RangePartitionItem(IcebergUtils.getPartitionRange("0", "year", columns)); - PartitionItem year1971 = new RangePartitionItem(IcebergUtils.getPartitionRange("1", "year", columns)); - PartitionItem month197002 = new RangePartitionItem(IcebergUtils.getPartitionRange("1", "month", columns)); - PartitionItem month197103 = new RangePartitionItem(IcebergUtils.getPartitionRange("14", "month", columns)); - PartitionItem month197204 = new RangePartitionItem(IcebergUtils.getPartitionRange("27", "month", columns)); - PartitionItem day19700202 = new RangePartitionItem(IcebergUtils.getPartitionRange("32", "day", columns)); - PartitionItem day19730101 = new RangePartitionItem(IcebergUtils.getPartitionRange("1096", "day", columns)); - Map map = Maps.newHashMap(); - map.put("nullRange", nullRange); - map.put("year1970", year1970); - map.put("year1971", year1971); - map.put("month197002", month197002); - map.put("month197103", month197103); - map.put("month197204", month197204); - map.put("day19700202", day19700202); - map.put("day19730101", day19730101); - List> entries = IcebergUtils.sortPartitionMap(map); - Assertions.assertEquals(8, entries.size()); - Assertions.assertEquals("nullRange", entries.get(0).getKey()); - Assertions.assertEquals("year1970", entries.get(1).getKey()); - Assertions.assertEquals("month197002", entries.get(2).getKey()); - Assertions.assertEquals("day19700202", entries.get(3).getKey()); - Assertions.assertEquals("year1971", entries.get(4).getKey()); - Assertions.assertEquals("month197103", entries.get(5).getKey()); - Assertions.assertEquals("month197204", entries.get(6).getKey()); - Assertions.assertEquals("day19730101", entries.get(7).getKey()); - - Map> stringSetMap = IcebergUtils.mergeOverlapPartitions(map); - Assertions.assertEquals(2, stringSetMap.size()); - Assertions.assertTrue(stringSetMap.containsKey("year1970")); - Assertions.assertTrue(stringSetMap.containsKey("year1971")); - - Set names1970 = stringSetMap.get("year1970"); - Assertions.assertEquals(3, names1970.size()); - Assertions.assertTrue(names1970.contains("year1970")); - Assertions.assertTrue(names1970.contains("month197002")); - Assertions.assertTrue(names1970.contains("day19700202")); - - Set names1971 = stringSetMap.get("year1971"); - Assertions.assertEquals(2, names1971.size()); - Assertions.assertTrue(names1971.contains("year1971")); - Assertions.assertTrue(names1971.contains("month197103")); - - Assertions.assertEquals(5, map.size()); - Assertions.assertTrue(map.containsKey("nullRange")); - Assertions.assertTrue(map.containsKey("year1970")); - Assertions.assertTrue(map.containsKey("year1971")); - Assertions.assertTrue(map.containsKey("month197204")); - Assertions.assertTrue(map.containsKey("day19730101")); - } - - // ── helpers ──────────────────────────────────────────────────────────── - - private IcebergExternalTable createSpyTable() { - IcebergExternalDatabase db = new IcebergExternalDatabase(mockCatalog, 1L, "db", "db"); - IcebergExternalTable t = new IcebergExternalTable(1, "tbl", "tbl", mockCatalog, db); - IcebergExternalTable spy = Mockito.spy(t); - Mockito.doReturn(icebergTable).when(spy).getIcebergTable(); - Mockito.doNothing().when(spy).makeSureInitialized(); - return spy; - } - - @Test - public void testGetComment() { - IcebergExternalTable spy = createSpyTable(); - Map properties = Maps.newHashMap(); - properties.put("comment", "my-table-comment"); - Mockito.when(icebergTable.properties()).thenReturn(properties); - - Assertions.assertEquals("my-table-comment", spy.getComment()); - - properties.put("comment", "comment with \"quote\""); - Assertions.assertEquals("comment with \\\"quote\\\"", spy.getComment(true)); - - properties.remove("comment"); - Assertions.assertEquals("", spy.getComment()); - } - - /** Creates a mock Transform with the given canonical toString() value. - * Also stubs isIdentity() and isVoid() based on the value. */ - @SuppressWarnings({"unchecked", "rawtypes"}) - private static Transform mockTransform(String toStringValue) { - Transform t = Mockito.mock(Transform.class); - Mockito.when(t.toString()).thenReturn(toStringValue); - Mockito.when(t.isIdentity()).thenReturn("identity".equals(toStringValue)); - Mockito.when(t.isVoid()).thenReturn("void".equals(toStringValue)); - return t; - } - - @SuppressWarnings("rawtypes") - private void setupSingleField(Transform transform, String colName) { - Mockito.when(icebergTable.spec()).thenReturn(spec); - Mockito.when(icebergTable.schema()).thenReturn(schema); - Mockito.when(spec.isUnpartitioned()).thenReturn(false); - Mockito.when(spec.fields()).thenReturn(Lists.newArrayList(field)); - Mockito.when(field.sourceId()).thenReturn(1); - Mockito.when(schema.findColumnName(1)).thenReturn(colName); - Mockito.doReturn(transform).when(field).transform(); - } - - // ── getPartitionSpecSql tests ─────────────────────────────────────────── - - @Test - public void testGetPartitionSpecSqlNullSpec() { - IcebergExternalTable spy = createSpyTable(); - Mockito.when(icebergTable.spec()).thenReturn(null); - Assertions.assertEquals("", spy.getPartitionSpecSql()); - } - - @Test - public void testGetPartitionSpecSqlUnpartitioned() { - IcebergExternalTable spy = createSpyTable(); - Mockito.when(icebergTable.spec()).thenReturn(spec); - Mockito.when(spec.isUnpartitioned()).thenReturn(true); - Assertions.assertEquals("", spy.getPartitionSpecSql()); - } - - @Test - public void testGetPartitionSpecSqlIdentity() { - IcebergExternalTable spy = createSpyTable(); - setupSingleField(mockTransform("identity"), "d_year"); - Assertions.assertEquals("PARTITION BY LIST (`d_year`) ()", spy.getPartitionSpecSql()); - } - - @Test - public void testGetPartitionSpecSqlPreservesNonLowercaseColumnName() { - IcebergExternalTable spy = createSpyTable(); - setupSingleField(mockTransform("identity"), "mIxEd_COL"); - Assertions.assertEquals("PARTITION BY LIST (`mIxEd_COL`) ()", spy.getPartitionSpecSql()); - } - - @Test - public void testGetPartitionSpecSqlBucket() { - IcebergExternalTable spy = createSpyTable(); - setupSingleField(mockTransform("bucket[2048]"), "ss_item_sk"); - Assertions.assertEquals("PARTITION BY LIST (BUCKET(2048, `ss_item_sk`)) ()", - spy.getPartitionSpecSql()); - } - - @Test - public void testGetPartitionSpecSqlTruncate() { - IcebergExternalTable spy = createSpyTable(); - setupSingleField(mockTransform("truncate[10]"), "category"); - Assertions.assertEquals("PARTITION BY LIST (TRUNCATE(10, `category`)) ()", - spy.getPartitionSpecSql()); - } - - @Test - public void testGetPartitionSpecSqlTimeTransforms() { - IcebergExternalTable spy = createSpyTable(); - Mockito.when(icebergTable.spec()).thenReturn(spec); - Mockito.when(icebergTable.schema()).thenReturn(schema); - Mockito.when(spec.isUnpartitioned()).thenReturn(false); - Mockito.when(spec.fields()).thenReturn(Lists.newArrayList(field)); - Mockito.when(field.sourceId()).thenReturn(1); - Mockito.when(schema.findColumnName(ArgumentMatchers.anyInt())).thenReturn("ts"); - - Mockito.doReturn(mockTransform("year")).when(field).transform(); - Assertions.assertEquals("PARTITION BY LIST (YEAR(`ts`)) ()", spy.getPartitionSpecSql()); - - Mockito.doReturn(mockTransform("month")).when(field).transform(); - Assertions.assertEquals("PARTITION BY LIST (MONTH(`ts`)) ()", spy.getPartitionSpecSql()); - - Mockito.doReturn(mockTransform("day")).when(field).transform(); - Assertions.assertEquals("PARTITION BY LIST (DAY(`ts`)) ()", spy.getPartitionSpecSql()); - - Mockito.doReturn(mockTransform("hour")).when(field).transform(); - Assertions.assertEquals("PARTITION BY LIST (HOUR(`ts`)) ()", spy.getPartitionSpecSql()); - } - - @Test - public void testGetPartitionSpecSqlVoidSkipped() { - IcebergExternalTable spy = createSpyTable(); - Mockito.when(icebergTable.spec()).thenReturn(spec); - Mockito.when(icebergTable.schema()).thenReturn(schema); - Mockito.when(spec.isUnpartitioned()).thenReturn(false); - Mockito.when(spec.fields()).thenReturn(Lists.newArrayList(field)); - Mockito.when(field.sourceId()).thenReturn(1); - Mockito.when(schema.findColumnName(1)).thenReturn("ts"); - Mockito.doReturn(mockTransform("void")).when(field).transform(); - Assertions.assertEquals("", spy.getPartitionSpecSql()); - } - - @Test - public void testGetPartitionSpecSqlMultipleFields() { - IcebergExternalTable spy = createSpyTable(); - PartitionField field2 = Mockito.mock(PartitionField.class); - - Mockito.when(icebergTable.spec()).thenReturn(spec); - Mockito.when(icebergTable.schema()).thenReturn(schema); - Mockito.when(spec.isUnpartitioned()).thenReturn(false); - Mockito.when(spec.fields()).thenReturn(Lists.newArrayList(field, field2)); - Mockito.when(field.sourceId()).thenReturn(1); - Mockito.when(schema.findColumnName(1)).thenReturn("sold_date_sk"); - Mockito.doReturn(mockTransform("identity")).when(field).transform(); - Mockito.when(field2.sourceId()).thenReturn(2); - Mockito.when(schema.findColumnName(2)).thenReturn("item_sk"); - Mockito.doReturn(mockTransform("bucket[128]")).when(field2).transform(); - - Assertions.assertEquals("PARTITION BY LIST (`sold_date_sk`, BUCKET(128, `item_sk`)) ()", - spy.getPartitionSpecSql()); - } - - @Test - public void testGetPartitionSpecSqlReservedWordColumnQuoted() { - // Reserved SQL keyword as column name must be backtick-quoted for replayable DDL. - IcebergExternalTable spy = createSpyTable(); - setupSingleField(mockTransform("identity"), "select"); - Assertions.assertEquals("PARTITION BY LIST (`select`) ()", spy.getPartitionSpecSql()); - } - - @Test - public void testGetPartitionSpecSqlUnresolvableColumnSkipped() { - IcebergExternalTable spy = createSpyTable(); - int unknownSourceId = 999; - Mockito.when(icebergTable.spec()).thenReturn(spec); - Mockito.when(icebergTable.schema()).thenReturn(schema); - Mockito.when(spec.isUnpartitioned()).thenReturn(false); - Mockito.when(spec.fields()).thenReturn(Lists.newArrayList(field)); - Mockito.when(field.sourceId()).thenReturn(unknownSourceId); - Mockito.when(schema.findColumnName(unknownSourceId)).thenReturn(null); - Assertions.assertEquals("", spy.getPartitionSpecSql()); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergHiddenColumnTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergHiddenColumnTest.java deleted file mode 100644 index 4f8b5fc98162f8..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergHiddenColumnTest.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.datasource.iceberg; - -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.junit.Assert; -import org.junit.Test; - -import java.util.List; - -/** - * 测试 Iceberg 隐藏列功能 - */ -public class IcebergHiddenColumnTest { - - @Test - public void testHiddenColumnStructType() { - // 获取隐藏列类型 - Type rowIdType = IcebergRowId.getRowIdType(); - Assert.assertTrue(rowIdType instanceof StructType); - - StructType structType = (StructType) rowIdType; - List fields = structType.getFields(); - Assert.assertEquals(4, fields.size()); - - // 验证字段名称(不带 $ 前缀) - Assert.assertEquals("file_path", fields.get(0).getName()); - Assert.assertEquals("row_position", fields.get(1).getName()); - Assert.assertEquals("partition_spec_id", fields.get(2).getName()); - Assert.assertEquals("partition_data", fields.get(3).getName()); - - // 验证字段类型 - Assert.assertTrue(fields.get(0).getType().isStringType()); - Assert.assertTrue(fields.get(1).getType().isBigIntType()); - Assert.assertTrue(fields.get(2).getType().isScalarType(PrimitiveType.INT)); - Assert.assertTrue(fields.get(3).getType().isStringType()); - } - - @Test - public void testIcebergRowIdColumnName() { - // 验证常量定义 - Assert.assertEquals("__DORIS_ICEBERG_ROWID_COL__", Column.ICEBERG_ROWID_COL); - - // 验证以 __DORIS_ 开头 - Assert.assertTrue(Column.ICEBERG_ROWID_COL.startsWith(Column.HIDDEN_COLUMN_PREFIX)); - } - - @Test - public void testStructFieldOrder() { - // 验证 STRUCT 字段顺序 - Type rowIdType = IcebergRowId.getRowIdType(); - StructType structType = (StructType) rowIdType; - List fields = structType.getFields(); - - // 确保字段顺序正确(与 BE 一致) - // 顺序:file_path, row_position, partition_spec_id, partition_data - Assert.assertEquals("file_path", fields.get(0).getName()); - Assert.assertEquals("row_position", fields.get(1).getName()); - Assert.assertEquals("partition_spec_id", fields.get(2).getName()); - Assert.assertEquals("partition_data", fields.get(3).getName()); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergMetadataColumnTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergMetadataColumnTest.java deleted file mode 100644 index 485c2eef25e6d0..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergMetadataColumnTest.java +++ /dev/null @@ -1,88 +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.datasource.iceberg; - -import org.junit.Assert; -import org.junit.Test; - -/** - * Unit tests for IcebergMetadataColumn. - */ -public class IcebergMetadataColumnTest { - - @Test - public void testFilePathColumn() { - IcebergMetadataColumn filePath = IcebergMetadataColumn.FILE_PATH; - - Assert.assertNotNull(filePath); - Assert.assertEquals("$file_path", filePath.getColumnName()); - Assert.assertTrue(filePath.getColumnType().isStringType()); - } - - @Test - public void testRowPositionColumn() { - IcebergMetadataColumn rowPosition = IcebergMetadataColumn.ROW_POSITION; - - Assert.assertNotNull(rowPosition); - Assert.assertEquals("$row_position", rowPosition.getColumnName()); - Assert.assertTrue(rowPosition.getColumnType().isBigIntType()); - } - - @Test - public void testPartitionSpecIdColumn() { - IcebergMetadataColumn partitionSpecId = IcebergMetadataColumn.PARTITION_SPEC_ID; - - Assert.assertNotNull(partitionSpecId); - Assert.assertEquals("$partition_spec_id", partitionSpecId.getColumnName()); - Assert.assertTrue(partitionSpecId.getColumnType().isScalarType()); - } - - @Test - public void testPartitionDataColumn() { - IcebergMetadataColumn partitionData = IcebergMetadataColumn.PARTITION_DATA; - - Assert.assertNotNull(partitionData); - Assert.assertEquals("$partition_data", partitionData.getColumnName()); - Assert.assertTrue(partitionData.getColumnType().isStringType()); - } - - @Test - public void testGetAllColumnNames() { - Assert.assertTrue(IcebergMetadataColumn.getAllColumnNames().contains("$file_path")); - Assert.assertTrue(IcebergMetadataColumn.getAllColumnNames().contains("$row_position")); - Assert.assertTrue(IcebergMetadataColumn.getAllColumnNames().contains("$partition_spec_id")); - Assert.assertTrue(IcebergMetadataColumn.getAllColumnNames().contains("$partition_data")); - Assert.assertFalse(IcebergMetadataColumn.getAllColumnNames().contains("$row_id")); - } - - @Test - public void testIsMetadataColumn() { - Assert.assertTrue(IcebergMetadataColumn.isMetadataColumn("$file_path")); - Assert.assertFalse(IcebergMetadataColumn.isMetadataColumn("regular_column")); - Assert.assertFalse(IcebergMetadataColumn.isMetadataColumn(null)); - Assert.assertFalse(IcebergMetadataColumn.isMetadataColumn("$row_id")); - } - - @Test - public void testFromColumnName() { - Assert.assertEquals(IcebergMetadataColumn.FILE_PATH, - IcebergMetadataColumn.fromColumnName("$file_path")); - Assert.assertNull(IcebergMetadataColumn.fromColumnName("not_a_metadata_column")); - Assert.assertNull(IcebergMetadataColumn.fromColumnName("$row_id")); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergMetadataOpTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergMetadataOpTest.java deleted file mode 100644 index 16130d3f2e2df3..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergMetadataOpTest.java +++ /dev/null @@ -1,444 +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.datasource.iceberg; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Type; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; -import org.apache.doris.datasource.CatalogProperty; -import org.apache.doris.datasource.DelegatedCredential; -import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.datasource.property.metastore.IcebergRestProperties; -import org.apache.doris.filesystem.DorisInputFile; -import org.apache.doris.filesystem.DorisOutputFile; -import org.apache.doris.filesystem.FileEntry; -import org.apache.doris.filesystem.FileIterator; -import org.apache.doris.filesystem.FileSystem; -import org.apache.doris.filesystem.Location; -import org.apache.doris.fs.MemoryFileSystem; -import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo; - -import org.apache.iceberg.CatalogProperties; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Schema; -import org.apache.iceberg.TableProperties; -import org.apache.iceberg.catalog.Catalog; -import org.apache.iceberg.catalog.Namespace; -import org.apache.iceberg.catalog.SupportsNamespaces; -import org.apache.iceberg.catalog.TableIdentifier; -import org.apache.iceberg.catalog.ViewCatalog; -import org.apache.iceberg.rest.RESTSessionCatalog; -import org.junit.Assert; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.NoSuchElementException; -import java.util.Optional; -import java.util.Set; - -public class IcebergMetadataOpTest { - - @Test - public void testGetNamespaces() { - Namespace ns = IcebergMetadataOps.getNamespace(Optional.empty(), "db1"); - Assert.assertEquals(1, ns.length()); - - ns = IcebergMetadataOps.getNamespace(Optional.empty(), "db1.db2.db3"); - Assert.assertEquals(3, ns.length()); - - ns = IcebergMetadataOps.getNamespace(Optional.empty(), "db1..db2"); - Assert.assertEquals(2, ns.length()); - - ns = IcebergMetadataOps.getNamespace(Optional.of("p1"), "db1"); - Assert.assertEquals(2, ns.length()); - - ns = IcebergMetadataOps.getNamespace(Optional.of("p1"), ""); - Assert.assertEquals(1, ns.length()); - - ns = IcebergMetadataOps.getNamespace(Optional.empty(), ""); - Assert.assertEquals(0, ns.length()); - } - - @Test - public void testListTableNamesSkipsViewsWhenRestViewDisabled() { - IcebergRestExternalCatalog dorisCatalog = Mockito.mock(IcebergRestExternalCatalog.class); - Catalog icebergCatalog = Mockito.mock(Catalog.class, - Mockito.withSettings().extraInterfaces(SupportsNamespaces.class, ViewCatalog.class)); - - Map props = new HashMap<>(); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "rest"); - props.put("iceberg.rest.uri", "http://localhost:8181"); - props.put("iceberg.rest.view-enabled", "false"); - - Mockito.when(dorisCatalog.getExecutionAuthenticator()).thenReturn(new ExecutionAuthenticator() { - }); - Mockito.when(dorisCatalog.getProperties()).thenReturn(Collections.emptyMap()); - Mockito.when(dorisCatalog.getCatalogProperty()).thenReturn(new CatalogProperty(null, props)); - - Namespace namespace = Namespace.of("PUBLIC"); - TableIdentifier table = TableIdentifier.of(namespace, "DORIS_HORIZON_T"); - Mockito.when(icebergCatalog.listTables(namespace)).thenReturn(Collections.singletonList(table)); - - IcebergMetadataOps ops = new IcebergMetadataOps(dorisCatalog, icebergCatalog); - List tableNames = ops.listTableNames("PUBLIC"); - - Assert.assertEquals(Collections.singletonList("DORIS_HORIZON_T"), tableNames); - Mockito.verify((ViewCatalog) icebergCatalog, Mockito.never()).listViews(Mockito.any()); - } - - @Test - public void testListTableNamesFiltersViewsWhenRestViewEnabled() { - IcebergRestExternalCatalog dorisCatalog = Mockito.mock(IcebergRestExternalCatalog.class); - // The default Catalog handed to IcebergMetadataOps is asCatalog(empty); it is NOT a ViewCatalog. - Catalog icebergCatalog = Mockito.mock(Catalog.class, - Mockito.withSettings().extraInterfaces(SupportsNamespaces.class)); - RESTSessionCatalog sessionCatalog = Mockito.mock(RESTSessionCatalog.class); - ViewCatalog viewCatalog = Mockito.mock(ViewCatalog.class); - - Mockito.when(dorisCatalog.getExecutionAuthenticator()).thenReturn(new ExecutionAuthenticator() { - }); - Mockito.when(dorisCatalog.getProperties()).thenReturn(Collections.emptyMap()); - Mockito.when(dorisCatalog.useSessionCatalog(Mockito.any())).thenReturn(false); - Mockito.when(dorisCatalog.isViewEnabled()).thenReturn(true); - Mockito.when(dorisCatalog.getRestSessionCatalog()).thenReturn(sessionCatalog); - Mockito.when(dorisCatalog.getDelegatedTokenMode()) - .thenReturn(IcebergRestProperties.DelegatedTokenMode.ACCESS_TOKEN); - Mockito.when(sessionCatalog.asViewCatalog(Mockito.any())).thenReturn(viewCatalog); - - Namespace namespace = Namespace.of("PUBLIC"); - TableIdentifier table = TableIdentifier.of(namespace, "DORIS_HORIZON_T"); - TableIdentifier view = TableIdentifier.of(namespace, "DORIS_HORIZON_V"); - Mockito.when(icebergCatalog.listTables(namespace)).thenReturn(Arrays.asList(table, view)); - Mockito.when(viewCatalog.listViews(namespace)).thenReturn(Collections.singletonList(view)); - - IcebergMetadataOps ops = new IcebergMetadataOps(dorisCatalog, icebergCatalog); - List tableNames = ops.listTableNames("PUBLIC"); - - Assert.assertEquals(Collections.singletonList("DORIS_HORIZON_T"), tableNames); - Mockito.verify(viewCatalog).listViews(namespace); - } - - @Test - public void testRejectsRequestWithoutCredentialWhenDynamicIdentityEnabled() { - Map props = new HashMap<>(); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "rest"); - props.put("iceberg.rest.uri", "http://localhost:8181"); - props.put("iceberg.rest.security.type", "oauth2"); - props.put("iceberg.rest.session", "user"); - props.put("iceberg.rest.oauth2.credential", "client_credentials"); - props.put("iceberg.rest.oauth2.server-uri", "http://auth.example.com/token"); - - IcebergRestExternalCatalog catalog = - new IcebergRestExternalCatalog(1, "rest_user_session", null, props, ""); - - // Dynamic identity is configured but the session has no delegated credential (e.g. a password login): - // rejected, never falls back to a shared/borrowed identity. - Assertions.assertThrows(IllegalStateException.class, - () -> catalog.useSessionCatalog(SessionContext.empty())); - - // With a delegated credential, the per-user session catalog is used. - SessionContext withCredential = SessionContext.of( - new DelegatedCredential(DelegatedCredential.Type.ACCESS_TOKEN, "delegated-access-token")); - Assert.assertTrue(catalog.useSessionCatalog(withCredential)); - } - - @Test - public void testNoSessionCatalogWhenDynamicIdentityDisabled() { - Map props = new HashMap<>(); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "rest"); - props.put("iceberg.rest.uri", "http://localhost:8181"); - - IcebergRestExternalCatalog catalog = - new IcebergRestExternalCatalog(1, "rest_plain", null, props, ""); - - // Without dynamic identity, no request uses the session catalog and none is rejected. - Assert.assertFalse(catalog.useSessionCatalog(SessionContext.empty())); - Assert.assertFalse(catalog.useSessionCatalog(SessionContext.of( - new DelegatedCredential(DelegatedCredential.Type.ACCESS_TOKEN, "delegated-access-token")))); - } - - @Test - public void testPerformCreateTableRespectsCatalogDefaultFormatVersion() throws Exception { - Map catalogProps = new HashMap<>(); - catalogProps.put(CatalogProperties.TABLE_DEFAULT_PREFIX + TableProperties.FORMAT_VERSION, "3"); - IcebergExternalCatalog dorisCatalog = mockHmsCatalog(catalogProps); - Catalog icebergCatalog = Mockito.mock(Catalog.class, - Mockito.withSettings().extraInterfaces(SupportsNamespaces.class)); - IcebergMetadataOps ops = new IcebergMetadataOps(dorisCatalog, icebergCatalog); - - ExternalDatabase dorisDb = Mockito.mock(ExternalDatabase.class); - Mockito.when(dorisDb.getRemoteName()).thenReturn("db"); - Mockito.when(dorisDb.getTableNullable("tbl")).thenReturn(null); - Mockito.doReturn(dorisDb).when(dorisCatalog).getDbNullable("db"); - Mockito.when(dorisCatalog.getName()).thenReturn("iceberg_catalog"); - Mockito.when(icebergCatalog.tableExists(TableIdentifier.of("db", "tbl"))).thenReturn(false); - - CreateTableInfo createTableInfo = Mockito.mock(CreateTableInfo.class); - Map tableProps = new HashMap<>(); - Mockito.when(createTableInfo.getDbName()).thenReturn("db"); - Mockito.when(createTableInfo.getTableName()).thenReturn("tbl"); - Mockito.when(createTableInfo.isIfNotExists()).thenReturn(false); - Mockito.when(createTableInfo.getColumns()).thenReturn(Collections.singletonList( - new Column("id", Type.INT, true))); - Mockito.when(createTableInfo.getProperties()).thenReturn(tableProps); - - ops.performCreateTable(createTableInfo); - - Mockito.verify(createTableInfo).validateIcebergRowLineageColumns(3); - ArgumentCaptor> propsCaptor = ArgumentCaptor.forClass(Map.class); - Mockito.verify(icebergCatalog).createTable(Mockito.eq(TableIdentifier.of("db", "tbl")), - Mockito.any(Schema.class), Mockito.any(PartitionSpec.class), propsCaptor.capture()); - Assert.assertFalse(propsCaptor.getValue().containsKey(TableProperties.FORMAT_VERSION)); - Assert.assertEquals(3, IcebergUtils.getEffectiveIcebergFormatVersion( - propsCaptor.getValue(), catalogProps)); - } - - @Test - public void testDropTableCleansEmptyTableLocation() throws Exception { - MemoryFileSystem fs = new MemoryFileSystem(); - Location tableLocation = Location.of("hdfs://nn/warehouse/db/t1"); - fs.mkdirs(tableLocation); - fs.mkdirs(tableLocation.resolve("data")); - fs.mkdirs(tableLocation.resolve("metadata")); - - IcebergExternalCatalog dorisCatalog = mockHmsCatalog(); - Catalog icebergCatalog = Mockito.mock(Catalog.class, - Mockito.withSettings().extraInterfaces(SupportsNamespaces.class)); - IcebergMetadataOps ops = newOpsWithCleanupFileSystem(dorisCatalog, icebergCatalog, fs); - - TableIdentifier tableIdentifier = TableIdentifier.of(Namespace.of("db"), "t1"); - org.apache.iceberg.Table icebergTable = Mockito.mock(org.apache.iceberg.Table.class); - Mockito.when(icebergTable.location()).thenReturn(tableLocation.uri()); - Mockito.when(icebergCatalog.tableExists(tableIdentifier)).thenReturn(true); - Mockito.when(icebergCatalog.loadTable(tableIdentifier)).thenReturn(icebergTable); - Mockito.when(icebergCatalog.dropTable(tableIdentifier, true)).thenReturn(true); - - ExternalTable dorisTable = Mockito.mock(ExternalTable.class); - Mockito.when(dorisTable.getRemoteDbName()).thenReturn("db"); - Mockito.when(dorisTable.getRemoteName()).thenReturn("t1"); - Mockito.when(dorisTable.getName()).thenReturn("t1"); - ops.dropTableImpl(dorisTable, false); - - Assert.assertFalse(fs.exists(tableLocation)); - Mockito.verify(icebergCatalog).dropTable(tableIdentifier, true); - } - - @Test - public void testDropDbCleansEmptyNamespaceLocation() throws Exception { - MemoryFileSystem fs = new MemoryFileSystem(); - Location namespaceLocation = Location.of("hdfs://nn/warehouse/db.db"); - fs.mkdirs(namespaceLocation); - - IcebergExternalCatalog dorisCatalog = mockHmsCatalog(); - Catalog icebergCatalog = Mockito.mock(Catalog.class, - Mockito.withSettings().extraInterfaces(SupportsNamespaces.class)); - IcebergMetadataOps ops = newOpsWithCleanupFileSystem(dorisCatalog, icebergCatalog, fs); - - ExternalDatabase dorisDb = Mockito.mock(ExternalDatabase.class); - Mockito.when(dorisDb.getRemoteName()).thenReturn("db"); - Mockito.doReturn(dorisDb).when(dorisCatalog).getDbNullable("db"); - - SupportsNamespaces nsCatalog = (SupportsNamespaces) icebergCatalog; - Namespace namespace = Namespace.of("db"); - Mockito.when(nsCatalog.loadNamespaceMetadata(namespace)) - .thenReturn(Collections.singletonMap("location", namespaceLocation.uri())); - Mockito.when(nsCatalog.dropNamespace(namespace)).thenReturn(true); - ops.dropDbImpl("db", false, false); - - Assert.assertFalse(fs.exists(namespaceLocation)); - Mockito.verify(nsCatalog).dropNamespace(namespace); - } - - @Test - public void testDeleteEmptyDirectoryKeepsDirectoryWithExternalFile() throws Exception { - MemoryFileSystem fs = new MemoryFileSystem(); - Location tableLocation = Location.of("hdfs://nn/warehouse/db/t2"); - fs.mkdirs(tableLocation); - fs.mkdirs(tableLocation.resolve("data")); - Location externalFile = tableLocation.resolve("external-file"); - fs.put(externalFile, new byte[] {1}); - - Assert.assertFalse(IcebergMetadataOps.deleteEmptyDirectory(fs, tableLocation)); - Assert.assertTrue(fs.exists(tableLocation)); - Assert.assertTrue(fs.exists(externalFile)); - Assert.assertTrue(fs.exists(tableLocation.resolve("data"))); - } - - @Test - public void testDeleteEmptyTableLocationCleansFlatObjectStoreMarkers() throws Exception { - FlatMarkerFileSystem fs = new FlatMarkerFileSystem(); - Location tableLocation = Location.of("s3://bucket/warehouse/db/t3"); - fs.mkdirs(tableLocation); - fs.mkdirs(tableLocation.resolve("data")); - fs.mkdirs(tableLocation.resolve("metadata")); - - Assert.assertTrue(fs.exists(tableLocation)); - Assert.assertTrue(IcebergMetadataOps.deleteEmptyTableLocation(fs, tableLocation)); - Assert.assertFalse(fs.exists(tableLocation)); - } - - private IcebergExternalCatalog mockHmsCatalog() { - return mockHmsCatalog(Collections.emptyMap()); - } - - private IcebergExternalCatalog mockHmsCatalog(Map catalogProperties) { - IcebergExternalCatalog dorisCatalog = Mockito.mock(IcebergExternalCatalog.class); - Mockito.when(dorisCatalog.getExecutionAuthenticator()).thenReturn(new ExecutionAuthenticator() { - }); - Mockito.when(dorisCatalog.getProperties()).thenReturn(catalogProperties); - Mockito.when(dorisCatalog.getIcebergCatalogType()).thenReturn(IcebergExternalCatalog.ICEBERG_HMS); - Mockito.when(dorisCatalog.getCatalogProperty()).thenReturn(new CatalogProperty(null, Collections.emptyMap())); - return dorisCatalog; - } - - private IcebergMetadataOps newOpsWithCleanupFileSystem( - IcebergExternalCatalog dorisCatalog, Catalog icebergCatalog, FileSystem fs) { - IcebergMetadataOps ops = new IcebergMetadataOps(dorisCatalog, icebergCatalog) { - @Override - protected FileSystem createCleanupFileSystem() { - return fs; - } - }; - Mockito.when(dorisCatalog.getMetadataOps()).thenReturn(ops); - return ops; - } - - private static class FlatMarkerFileSystem implements FileSystem { - private final Set markers = new HashSet<>(); - private final Set files = new HashSet<>(); - - @Override - public boolean exists(Location location) { - String uri = location.uri(); - String marker = withTrailingSlash(uri); - if (markers.contains(uri) || markers.contains(marker) || files.contains(uri)) { - return true; - } - String prefix = withTrailingSlash(uri); - for (String file : files) { - if (file.startsWith(prefix)) { - return true; - } - } - for (String directoryMarker : markers) { - if (directoryMarker.startsWith(prefix)) { - return true; - } - } - return false; - } - - @Override - public void mkdirs(Location location) { - markers.add(withTrailingSlash(location.uri())); - } - - @Override - public void delete(Location location, boolean recursive) throws IOException { - if (recursive) { - throw new IOException("recursive delete is not fail-safe"); - } - String marker = withTrailingSlash(location.uri()); - for (String file : files) { - if (file.startsWith(marker) && !file.equals(marker)) { - throw new IOException("Directory not empty: " + location.uri()); - } - } - for (String directoryMarker : markers) { - if (directoryMarker.startsWith(marker) && !directoryMarker.equals(marker)) { - throw new IOException("Directory not empty: " + location.uri()); - } - } - markers.remove(marker); - files.remove(location.uri()); - } - - @Override - public void rename(Location src, Location dst) { - throw new UnsupportedOperationException(); - } - - @Override - public FileIterator list(Location location) { - String prefix = withTrailingSlash(location.uri()); - List entries = new ArrayList<>(); - for (String file : files) { - if (file.startsWith(prefix)) { - entries.add(new FileEntry(Location.of(file), 1L, false, 0L, null)); - } - } - return iteratorOf(entries); - } - - @Override - public DorisInputFile newInputFile(Location location) { - throw new UnsupportedOperationException(); - } - - @Override - public DorisOutputFile newOutputFile(Location location) { - throw new UnsupportedOperationException(); - } - - @Override - public void close() { - } - - private static FileIterator iteratorOf(List entries) { - return new FileIterator() { - private int index = 0; - - @Override - public boolean hasNext() { - return index < entries.size(); - } - - @Override - public FileEntry next() { - if (!hasNext()) { - throw new NoSuchElementException(); - } - return entries.get(index++); - } - - @Override - public void close() { - } - }; - } - - private static String withTrailingSlash(String uri) { - return uri.endsWith("/") ? uri : uri + "/"; - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergMetadataOpsValidationTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergMetadataOpsValidationTest.java deleted file mode 100644 index 937167c34c351d..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergMetadataOpsValidationTest.java +++ /dev/null @@ -1,215 +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.datasource.iceberg; - -import org.apache.doris.catalog.ArrayType; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.MapType; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.StructField; -import org.apache.doris.catalog.StructType; -import org.apache.doris.catalog.Type; -import org.apache.doris.common.UserException; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; -import org.apache.doris.datasource.ExternalCatalog; - -import org.apache.iceberg.catalog.Catalog; -import org.apache.iceberg.catalog.SupportsNamespaces; -import org.apache.iceberg.types.Types; -import org.apache.iceberg.types.Types.NestedField; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.mockito.Mockito; - -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.util.Collections; - -public class IcebergMetadataOpsValidationTest { - - private IcebergMetadataOps ops; - private Method validateForModifyColumnMethod; - private Method validateForModifyComplexColumnMethod; - - @Before - public void setUp() throws Exception { - ExternalCatalog dorisCatalog = Mockito.mock(ExternalCatalog.class); - Catalog icebergCatalog = Mockito.mock(Catalog.class, - Mockito.withSettings().extraInterfaces(SupportsNamespaces.class)); - Mockito.when(dorisCatalog.getExecutionAuthenticator()).thenReturn(new ExecutionAuthenticator() { - }); - Mockito.when(dorisCatalog.getProperties()).thenReturn(Collections.emptyMap()); - ops = new IcebergMetadataOps(dorisCatalog, icebergCatalog); - - validateForModifyColumnMethod = IcebergMetadataOps.class.getDeclaredMethod( - "validateForModifyColumn", Column.class, NestedField.class); - validateForModifyColumnMethod.setAccessible(true); - validateForModifyComplexColumnMethod = IcebergMetadataOps.class.getDeclaredMethod( - "validateForModifyComplexColumn", Column.class, NestedField.class); - validateForModifyComplexColumnMethod.setAccessible(true); - } - - @Test - public void testValidateForModifyColumnRejectsComplexType() { - Column column = new Column("arr_i", ArrayType.create(Type.INT, true), true); - NestedField currentCol = Types.NestedField.required(1, "arr_i", Types.IntegerType.get()); - assertUserException(() -> invokeValidateForModifyColumn(column, currentCol), - "Modify column type to non-primitive type is not supported"); - } - - @Test - public void testValidateForModifyColumnRejectsNullableToNotNull() { - Column column = new Column("int_col", Type.INT, false); - NestedField currentCol = Types.NestedField.optional(1, "int_col", Types.IntegerType.get()); - assertUserException(() -> invokeValidateForModifyColumn(column, currentCol), - "Can not change nullable column int_col to not null"); - } - - @Test - public void testValidateForModifyColumnSuccess() throws Throwable { - Column column = new Column("int_col", Type.INT, true); - NestedField currentCol = Types.NestedField.required(1, "int_col", Types.IntegerType.get()); - invokeValidateForModifyColumn(column, currentCol); - } - - @Test - public void testValidateForModifyComplexColumnRejectsPrimitiveType() { - Column column = new Column("arr_i", Type.INT, true); - NestedField currentCol = Types.NestedField.required(1, "arr_i", - Types.ListType.ofOptional(2, Types.IntegerType.get())); - assertUserException(() -> invokeValidateForModifyComplexColumn(column, currentCol), - "Modify column type to non-complex type is not supported"); - } - - @Test - public void testValidateForModifyComplexColumnRejectsIncompatibleNestedType() { - Column column = new Column("arr_i", ArrayType.create(Type.SMALLINT, true), true); - NestedField currentCol = Types.NestedField.required(1, "arr_i", - Types.ListType.ofOptional(2, Types.IntegerType.get())); - assertUserException(() -> invokeValidateForModifyComplexColumn(column, currentCol), - "Cannot change int to smallint in nested types"); - } - - @Test - public void testValidateForModifyComplexColumnAllowsNestedDecimalPrecisionPromotion() throws Throwable { - Column column = new Column("struct_col", - new StructType(new StructField("d", ScalarType.createDecimalV3Type(10, 3))), true); - NestedField currentCol = Types.NestedField.required(1, "struct_col", - Types.StructType.of(Types.NestedField.optional(2, "d", - Types.DecimalType.of(5, 3)))); - invokeValidateForModifyComplexColumn(column, currentCol); - } - - @Test - public void testValidateForModifyComplexColumnRejectsNestedDecimalPrecisionNarrowing() { - Column column = new Column("struct_col", - new StructType(new StructField("d", ScalarType.createDecimalV3Type(5, 3))), true); - NestedField currentCol = Types.NestedField.required(1, "struct_col", - Types.StructType.of(Types.NestedField.optional(2, "d", - Types.DecimalType.of(10, 3)))); - assertUserException(() -> invokeValidateForModifyComplexColumn(column, currentCol), - "Cannot change decimalv3(10,3) to decimalv3(5,3) in nested types"); - } - - @Test - public void testValidateForModifyComplexColumnRejectsNestedDecimalScaleChange() { - Column column = new Column("struct_col", - new StructType(new StructField("d", ScalarType.createDecimalV3Type(10, 4))), true); - NestedField currentCol = Types.NestedField.required(1, "struct_col", - Types.StructType.of(Types.NestedField.optional(2, "d", - Types.DecimalType.of(5, 3)))); - assertUserException(() -> invokeValidateForModifyComplexColumn(column, currentCol), - "Cannot change decimalv3(5,3) to decimalv3(10,4) in nested types"); - } - - @Test - public void testValidateForModifyComplexColumnRejectsPrimitiveToComplex() { - Column column = new Column("arr_i", ArrayType.create(Type.INT, true), true); - NestedField currentCol = Types.NestedField.required(1, "arr_i", Types.IntegerType.get()); - assertUserException(() -> invokeValidateForModifyComplexColumn(column, currentCol), - "Modify column type from non-complex to complex is not supported"); - } - - @Test - public void testValidateForModifyComplexColumnRejectsDifferentComplexCategory() { - Column column = new Column("arr_i", new MapType(Type.INT, Type.INT), true); - NestedField currentCol = Types.NestedField.required(1, "arr_i", - Types.ListType.ofOptional(2, Types.IntegerType.get())); - assertUserException(() -> invokeValidateForModifyComplexColumn(column, currentCol), - "Cannot change complex column type category"); - } - - @Test - public void testValidateForModifyComplexColumnRejectsNullableToNotNull() { - Column column = new Column("arr_i", ArrayType.create(Type.INT, true), false); - NestedField currentCol = Types.NestedField.optional(1, "arr_i", - Types.ListType.ofOptional(2, Types.IntegerType.get())); - assertUserException(() -> invokeValidateForModifyComplexColumn(column, currentCol), - "Cannot change nullable column arr_i to not null"); - } - - @Test - public void testValidateForModifyComplexColumnRejectsDefaultValue() { - Column column = new Column("arr_i", ArrayType.create(Type.INT, true), - false, null, true, "1", ""); - NestedField currentCol = Types.NestedField.required(1, "arr_i", - Types.ListType.ofOptional(2, Types.IntegerType.get())); - assertUserException(() -> invokeValidateForModifyComplexColumn(column, currentCol), - "Complex type default value only supports NULL"); - } - - @Test - public void testValidateForModifyComplexColumnSuccess() throws Throwable { - Column column = new Column("arr_i", ArrayType.create(Type.BIGINT, true), true); - NestedField currentCol = Types.NestedField.required(1, "arr_i", - Types.ListType.ofOptional(2, Types.IntegerType.get())); - invokeValidateForModifyComplexColumn(column, currentCol); - } - - private void invokeValidateForModifyColumn(Column column, NestedField currentCol) throws Throwable { - invokeValidationMethod(validateForModifyColumnMethod, column, currentCol); - } - - private void invokeValidateForModifyComplexColumn(Column column, NestedField currentCol) throws Throwable { - invokeValidationMethod(validateForModifyComplexColumnMethod, column, currentCol); - } - - private void invokeValidationMethod(Method method, Column column, NestedField currentCol) throws Throwable { - try { - method.invoke(ops, column, currentCol); - } catch (InvocationTargetException e) { - throw e.getCause(); - } - } - - private void assertUserException(ThrowingRunnable runnable, String expectedMessage) { - try { - runnable.run(); - Assert.fail("expected UserException"); - } catch (Throwable t) { - Assert.assertTrue(t instanceof UserException); - Assert.assertTrue(t.getMessage().contains(expectedMessage)); - } - } - - @FunctionalInterface - private interface ThrowingRunnable { - void run() throws Throwable; - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergNereidsUtilsTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergNereidsUtilsTest.java deleted file mode 100644 index 3bb8f005828931..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergNereidsUtilsTest.java +++ /dev/null @@ -1,1004 +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.datasource.iceberg; - -import org.apache.doris.common.UserException; -import org.apache.doris.nereids.analyzer.UnboundSlot; -import org.apache.doris.nereids.trees.expressions.And; -import org.apache.doris.nereids.trees.expressions.Between; -import org.apache.doris.nereids.trees.expressions.EqualTo; -import org.apache.doris.nereids.trees.expressions.Expression; -import org.apache.doris.nereids.trees.expressions.GreaterThan; -import org.apache.doris.nereids.trees.expressions.GreaterThanEqual; -import org.apache.doris.nereids.trees.expressions.InPredicate; -import org.apache.doris.nereids.trees.expressions.LessThan; -import org.apache.doris.nereids.trees.expressions.LessThanEqual; -import org.apache.doris.nereids.trees.expressions.Not; -import org.apache.doris.nereids.trees.expressions.Or; -import org.apache.doris.nereids.trees.expressions.SlotReference; -import org.apache.doris.nereids.trees.expressions.literal.BigIntLiteral; -import org.apache.doris.nereids.trees.expressions.literal.BooleanLiteral; -import org.apache.doris.nereids.trees.expressions.literal.CharLiteral; -import org.apache.doris.nereids.trees.expressions.literal.DateLiteral; -import org.apache.doris.nereids.trees.expressions.literal.DecimalLiteral; -import org.apache.doris.nereids.trees.expressions.literal.DecimalV3Literal; -import org.apache.doris.nereids.trees.expressions.literal.DoubleLiteral; -import org.apache.doris.nereids.trees.expressions.literal.FloatLiteral; -import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral; -import org.apache.doris.nereids.trees.expressions.literal.NullLiteral; -import org.apache.doris.nereids.trees.expressions.literal.SmallIntLiteral; -import org.apache.doris.nereids.trees.expressions.literal.StringLiteral; -import org.apache.doris.nereids.trees.expressions.literal.TinyIntLiteral; -import org.apache.doris.nereids.types.BigIntType; -import org.apache.doris.nereids.types.BooleanType; -import org.apache.doris.nereids.types.CharType; -import org.apache.doris.nereids.types.DateType; -import org.apache.doris.nereids.types.DecimalV2Type; -import org.apache.doris.nereids.types.DoubleType; -import org.apache.doris.nereids.types.FloatType; -import org.apache.doris.nereids.types.IntegerType; -import org.apache.doris.nereids.types.SmallIntType; -import org.apache.doris.nereids.types.StringType; -import org.apache.doris.nereids.types.TinyIntType; -import org.apache.doris.nereids.types.VarcharType; - -import org.apache.iceberg.Schema; -import org.apache.iceberg.expressions.Expressions; -import org.apache.iceberg.types.Types; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.Mockito; - -import java.math.BigDecimal; -import java.util.Arrays; -import java.util.List; - -/** - * Unit tests for IcebergNereidsUtils - */ -public class IcebergNereidsUtilsTest { - - @Mock - private Schema mockSchema; - - @Mock - private Types.NestedField mockNestedField; - - private Schema testSchema; - - @BeforeEach - public void setUp() { - // Create a real schema for testing - testSchema = new Schema( - Types.NestedField.required(1, "id", Types.IntegerType.get()), - Types.NestedField.required(2, "name", Types.StringType.get()), - Types.NestedField.required(3, "age", Types.IntegerType.get()), - Types.NestedField.required(4, "salary", Types.DoubleType.get()), - Types.NestedField.required(5, "is_active", Types.BooleanType.get()), - Types.NestedField.required(6, "birth_date", Types.DateType.get()), - Types.NestedField.required(7, "event_time_tz", Types.TimestampType.withZone()), - Types.NestedField.required(8, "event_time_ntz", Types.TimestampType.withoutZone()), - Types.NestedField.required(9, "dec_col", Types.DecimalType.of(10, 2)), - Types.NestedField.required(10, "time_col", Types.TimeType.get())); - } - - @Test - public void testConvertNereidsToIcebergExpression_NullInput() { - UserException exception = Assertions.assertThrows(UserException.class, () -> { - IcebergNereidsUtils.convertNereidsToIcebergExpression(null, testSchema); - }); - Assertions.assertEquals("Nereids expression is null", exception.getDetailMessage()); - } - - @Test - public void testConvertNereidsToIcebergExpression_EqualTo() throws UserException { - SlotReference slotRef = new SlotReference("id", IntegerType.INSTANCE, false); - IntegerLiteral literal = new IntegerLiteral(100); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.equal("id", 100).toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_GreaterThan() throws UserException { - SlotReference slotRef = new SlotReference("age", IntegerType.INSTANCE, false); - IntegerLiteral literal = new IntegerLiteral(18); - GreaterThan greaterThan = new GreaterThan(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(greaterThan, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.greaterThan("age", 18).toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_GreaterThanEqual() throws UserException { - SlotReference slotRef = new SlotReference("age", IntegerType.INSTANCE, false); - IntegerLiteral literal = new IntegerLiteral(18); - GreaterThanEqual greaterThanEqual = new GreaterThanEqual(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(greaterThanEqual, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.greaterThanOrEqual("age", 18).toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_LessThan() throws UserException { - SlotReference slotRef = new SlotReference("age", IntegerType.INSTANCE, false); - IntegerLiteral literal = new IntegerLiteral(65); - LessThan lessThan = new LessThan(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(lessThan, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.lessThan("age", 65).toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_LessThanEqual() throws UserException { - SlotReference slotRef = new SlotReference("age", IntegerType.INSTANCE, false); - IntegerLiteral literal = new IntegerLiteral(65); - LessThanEqual lessThanEqual = new LessThanEqual(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(lessThanEqual, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.lessThanOrEqual("age", 65).toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_And() throws UserException { - SlotReference slotRef1 = new SlotReference("age", IntegerType.INSTANCE, false); - SlotReference slotRef2 = new SlotReference("salary", DoubleType.INSTANCE, false); - IntegerLiteral literal1 = new IntegerLiteral(18); - DoubleLiteral literal2 = new DoubleLiteral(50000.0); - - GreaterThan greaterThan = new GreaterThan(slotRef1, literal1); - GreaterThanEqual greaterThanEqual = new GreaterThanEqual(slotRef2, literal2); - And andExpr = new And(greaterThan, greaterThanEqual); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(andExpr, testSchema); - - Assertions.assertNotNull(result); - } - - @Test - public void testConvertNereidsToIcebergExpression_Or() throws UserException { - SlotReference slotRef1 = new SlotReference("age", IntegerType.INSTANCE, false); - SlotReference slotRef2 = new SlotReference("age", IntegerType.INSTANCE, false); - IntegerLiteral literal1 = new IntegerLiteral(18); - IntegerLiteral literal2 = new IntegerLiteral(65); - - LessThan lessThan = new LessThan(slotRef1, literal1); - GreaterThan greaterThan = new GreaterThan(slotRef2, literal2); - Or orExpr = new Or(lessThan, greaterThan); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils.convertNereidsToIcebergExpression(orExpr, - testSchema); - - Assertions.assertNotNull(result); - } - - @Test - public void testConvertNereidsToIcebergExpression_Not() throws UserException { - SlotReference slotRef = new SlotReference("is_active", BooleanType.INSTANCE, false); - BooleanLiteral literal = BooleanLiteral.of(true); - EqualTo equalTo = new EqualTo(slotRef, literal); - Not notExpr = new Not(equalTo); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(notExpr, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertTrue(result.toString().toLowerCase().contains("not")); - } - - @Test - public void testConvertNereidsToIcebergExpression_InPredicate() throws UserException { - SlotReference slotRef = new SlotReference("id", IntegerType.INSTANCE, false); - IntegerLiteral literal1 = new IntegerLiteral(1); - IntegerLiteral literal2 = new IntegerLiteral(2); - IntegerLiteral literal3 = new IntegerLiteral(3); - - InPredicate inPredicate = new InPredicate(slotRef, Arrays.asList(literal1, literal2, literal3)); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(inPredicate, testSchema); - - Assertions.assertNotNull(result); - String s = result.toString(); - Assertions.assertTrue(s.contains("id")); - Assertions.assertTrue(s.contains("1")); - Assertions.assertTrue(s.contains("2")); - Assertions.assertTrue(s.contains("3")); - } - - @Test - public void testConvertNereidsToIcebergExpression_ComplexNested() throws UserException { - // Test complex nested expression: (age > 18 AND salary >= 50000) OR (age < 65 - // AND salary < 100000) - SlotReference ageRef = new SlotReference("age", IntegerType.INSTANCE, false); - SlotReference salaryRef = new SlotReference("salary", DoubleType.INSTANCE, false); - - GreaterThan ageGt = new GreaterThan(ageRef, new IntegerLiteral(18)); - GreaterThanEqual salaryGte = new GreaterThanEqual(salaryRef, new DoubleLiteral(50000.0)); - And leftAnd = new And(ageGt, salaryGte); - - LessThan ageLt = new LessThan(ageRef, new IntegerLiteral(65)); - LessThan salaryLt = new LessThan(salaryRef, new DoubleLiteral(100000.0)); - And rightAnd = new And(ageLt, salaryLt); - - Or orExpr = new Or(leftAnd, rightAnd); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils.convertNereidsToIcebergExpression(orExpr, - testSchema); - - Assertions.assertNotNull(result); - Assertions.assertTrue(result.toString().toLowerCase().contains("or")); - } - - @Test - public void testConvertNereidsToIcebergExpression_WithNullLiteral() throws UserException { - SlotReference slotRef = new SlotReference("id", IntegerType.INSTANCE, false); - NullLiteral nullLiteral = new NullLiteral(); - EqualTo equalTo = new EqualTo(slotRef, nullLiteral); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.isNull("id").toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_ColumnNotFound() { - SlotReference slotRef = new SlotReference("non_existent_column", IntegerType.INSTANCE, false); - IntegerLiteral literal = new IntegerLiteral(100); - EqualTo equalTo = new EqualTo(slotRef, literal); - - UserException exception = Assertions.assertThrows(UserException.class, () -> { - IcebergNereidsUtils.convertNereidsToIcebergExpression(equalTo, testSchema); - }); - Assertions.assertEquals("Column not found in Iceberg schema: non_existent_column", - exception.getDetailMessage()); - } - - @Test - public void testConvertNereidsToIcebergExpression_CaseInsensitiveColumnName() throws UserException { - // Test case insensitive column name matching - SlotReference slotRef = new SlotReference("ID", IntegerType.INSTANCE, false); // uppercase - IntegerLiteral literal = new IntegerLiteral(100); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.equal("id", 100).toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_UnsupportedExpression() { - // Test with an unsupported expression type - SlotReference slotRef = new SlotReference("id", IntegerType.INSTANCE, false); - IntegerLiteral literal = new IntegerLiteral(100); - - // Create a mock expression that's not supported - org.apache.doris.nereids.trees.expressions.Expression unsupportedExpr = Mockito.mock( - org.apache.doris.nereids.trees.expressions.Expression.class); - Mockito.when(unsupportedExpr.children()).thenReturn(Arrays.asList(slotRef, literal)); - - UserException exception = Assertions.assertThrows(UserException.class, () -> { - IcebergNereidsUtils.convertNereidsToIcebergExpression(unsupportedExpr, testSchema); - }); - Assertions.assertTrue(exception.getDetailMessage().contains("Unsupported expression type")); - } - - @Test - public void testConvertNereidsToIcebergExpression_StringLiteral() throws UserException { - SlotReference slotRef = new SlotReference("name", StringType.INSTANCE, false); - StringLiteral literal = new StringLiteral("John"); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.equal("name", "John").toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_BooleanLiteral() throws UserException { - SlotReference slotRef = new SlotReference("is_active", BooleanType.INSTANCE, false); - BooleanLiteral literal = BooleanLiteral.of(true); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.equal("is_active", true).toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_DoubleLiteral() throws UserException { - SlotReference slotRef = new SlotReference("salary", DoubleType.INSTANCE, false); - DoubleLiteral literal = new DoubleLiteral(50000.5); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.equal("salary", 50000.5).toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_FloatLiteral() throws UserException { - SlotReference slotRef = new SlotReference("salary", FloatType.INSTANCE, false); - FloatLiteral literal = new FloatLiteral(50000.5f); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.equal("salary", 50000.5f).toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_BigIntLiteral() throws UserException { - SlotReference slotRef = new SlotReference("id", BigIntType.INSTANCE, false); - BigIntLiteral literal = new BigIntLiteral(123456789L); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.equal("id", 123456789L).toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_DecimalLiteral() throws UserException { - SlotReference slotRef = new SlotReference("salary", DecimalV2Type.SYSTEM_DEFAULT, false); - DecimalLiteral literal = new DecimalLiteral(new BigDecimal("50000.50")); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - } - - @Test - public void testConvertNereidsToIcebergExpression_DateLiteral() throws UserException { - SlotReference slotRef = new SlotReference("birth_date", DateType.INSTANCE, false); - DateLiteral literal = new DateLiteral("2023-01-01"); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.equal("birth_date", "2023-01-01").toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_TimestampWithZoneMicros() throws UserException { - org.apache.doris.nereids.trees.expressions.literal.DateTimeV2Literal literal = - new org.apache.doris.nereids.trees.expressions.literal.DateTimeV2Literal( - org.apache.doris.nereids.types.DateTimeV2Type.forTypeFromString("2023-01-02 03:04:05.123456"), - 2023, 1, 2, 3, 4, 5, 123456); - EqualTo equalTo = new EqualTo(new SlotReference("event_time_tz", - org.apache.doris.nereids.types.DateTimeV2Type.forTypeFromString("2023-01-02 03:04:05.123456"), false), - literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - java.time.ZoneId zone = org.apache.doris.nereids.util.DateUtils.getTimeZone(); - java.time.LocalDateTime ldt = java.time.LocalDateTime.of(2023, 1, 2, 3, 4, 5, 123456000); - long expectedMicros = ldt.atZone(zone).toInstant().toEpochMilli() * 1000L + 123456; - Assertions.assertEquals(Expressions.equal("event_time_tz", expectedMicros).toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_TimestampWithoutZoneMicros() throws UserException { - org.apache.doris.nereids.trees.expressions.literal.DateTimeLiteral literal = - new org.apache.doris.nereids.trees.expressions.literal.DateTimeLiteral(2023, 1, 2, 3, 4, 5); - EqualTo equalTo = new EqualTo(new SlotReference("event_time_ntz", - org.apache.doris.nereids.types.DateTimeType.INSTANCE, false), literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - java.time.ZoneId zone = java.time.ZoneId.of("UTC"); - java.time.LocalDateTime ldt = java.time.LocalDateTime.of(2023, 1, 2, 3, 4, 5, 0); - long expectedMicros = ldt.atZone(zone).toInstant().toEpochMilli() * 1000L; - Assertions.assertEquals(Expressions.equal("event_time_ntz", expectedMicros).toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_DecimalMapping() throws UserException { - SlotReference slotRef = new SlotReference("dec_col", DecimalV2Type.SYSTEM_DEFAULT, false); - DecimalLiteral literal = new DecimalLiteral(new BigDecimal("12.34")); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertTrue(result.toString().contains("12.34")); - } - - @Test - public void testConvertNereidsToIcebergExpression_DecimalV3Mapping() throws UserException { - SlotReference slotRef = new SlotReference("dec_col", DecimalV2Type.SYSTEM_DEFAULT, false); - DecimalV3Literal literal = - new DecimalV3Literal(new BigDecimal("99.990")); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertTrue(result.toString().contains("99.99")); - } - - @Test - public void testConvertNereidsToIcebergExpression_TimeAsLong() throws UserException { - SlotReference slotRef = new SlotReference("time_col", IntegerType.INSTANCE, false); - // use a numeric literal to represent micros since midnight - BigIntLiteral literal = new BigIntLiteral(12_345_678L); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.equal("time_col", 12_345_678L).toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_StringToIntParsing() throws UserException { - SlotReference slotRef = new SlotReference("id", IntegerType.INSTANCE, false); - StringLiteral literal = new StringLiteral("123"); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.equal("id", 123).toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_CharLiteral() throws UserException { - SlotReference slotRef = new SlotReference("name", CharType.createCharType(1), false); - CharLiteral literal = new CharLiteral("A", 1); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.equal("name", "A").toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_TinyIntLiteral() throws UserException { - SlotReference slotRef = new SlotReference("age", TinyIntType.INSTANCE, false); - TinyIntLiteral literal = new TinyIntLiteral((byte) 25); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertTrue(result.toString().contains("age")); - Assertions.assertTrue(result.toString().contains("25")); - } - - @Test - public void testConvertNereidsToIcebergExpression_SmallIntLiteral() throws UserException { - SlotReference slotRef = new SlotReference("age", SmallIntType.INSTANCE, false); - SmallIntLiteral literal = new SmallIntLiteral((short) 25); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertTrue(result.toString().contains("age")); - Assertions.assertTrue(result.toString().contains("25")); - } - - @Test - public void testConvertNereidsToIcebergExpression_VarcharLiteral() throws UserException { - SlotReference slotRef = new SlotReference("name", VarcharType.SYSTEM_DEFAULT, false); - StringLiteral literal = new StringLiteral("John Doe"); - EqualTo equalTo = new EqualTo(slotRef, literal); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - - Assertions.assertNotNull(result); - Assertions.assertEquals(Expressions.equal("name", "John Doe").toString(), result.toString()); - } - - @Test - public void testConvertNereidsToIcebergExpression_MixedLiteralTypesInInPredicate() throws UserException { - SlotReference slotRef = new SlotReference("id", IntegerType.INSTANCE, false); - IntegerLiteral literal1 = new IntegerLiteral(1); - IntegerLiteral literal2 = new IntegerLiteral(2); - IntegerLiteral literal3 = new IntegerLiteral(3); - - InPredicate inPredicate = new InPredicate(slotRef, Arrays.asList(literal1, literal2, literal3)); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(inPredicate, testSchema); - - Assertions.assertNotNull(result); - String s = result.toString(); - Assertions.assertTrue(s.contains("id")); - Assertions.assertTrue(s.contains("1")); - Assertions.assertTrue(s.contains("2")); - Assertions.assertTrue(s.contains("3")); - } - - @Test - public void testConvertNereidsToIcebergExpression_DeeplyNestedExpression() throws UserException { - // Test deeply nested expression: NOT ((age > 18 AND salary >= 50000) OR (age < - // 65 AND salary < 100000)) - SlotReference ageRef = new SlotReference("age", IntegerType.INSTANCE, false); - SlotReference salaryRef = new SlotReference("salary", DoubleType.INSTANCE, false); - - GreaterThan ageGt = new GreaterThan(ageRef, new IntegerLiteral(18)); - GreaterThanEqual salaryGte = new GreaterThanEqual(salaryRef, new DoubleLiteral(50000.0)); - And leftAnd = new And(ageGt, salaryGte); - - LessThan ageLt = new LessThan(ageRef, new IntegerLiteral(65)); - LessThan salaryLt = new LessThan(salaryRef, new DoubleLiteral(100000.0)); - And rightAnd = new And(ageLt, salaryLt); - - Or orExpr = new Or(leftAnd, rightAnd); - Not notExpr = new Not(orExpr); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(notExpr, testSchema); - - Assertions.assertNotNull(result); - String s = result.toString().toLowerCase(); - Assertions.assertTrue(s.contains("not")); - Assertions.assertTrue(s.contains("or")); - Assertions.assertTrue(s.contains("and")); - } - - @Test - public void testConvertNereidsToIcebergExpression_AllComparisonOperators() throws UserException { - SlotReference slotRef = new SlotReference("age", IntegerType.INSTANCE, false); - IntegerLiteral literal = new IntegerLiteral(25); - - // Test all comparison operators - EqualTo equalTo = new EqualTo(slotRef, literal); - GreaterThan greaterThan = new GreaterThan(slotRef, literal); - GreaterThanEqual greaterThanEqual = new GreaterThanEqual(slotRef, literal); - LessThan lessThan = new LessThan(slotRef, literal); - LessThanEqual lessThanEqual = new LessThanEqual(slotRef, literal); - - org.apache.iceberg.expressions.Expression equalResult = IcebergNereidsUtils - .convertNereidsToIcebergExpression(equalTo, testSchema); - org.apache.iceberg.expressions.Expression greaterThanResult = IcebergNereidsUtils - .convertNereidsToIcebergExpression(greaterThan, testSchema); - org.apache.iceberg.expressions.Expression greaterThanEqualResult = IcebergNereidsUtils - .convertNereidsToIcebergExpression(greaterThanEqual, testSchema); - org.apache.iceberg.expressions.Expression lessThanResult = IcebergNereidsUtils - .convertNereidsToIcebergExpression(lessThan, testSchema); - org.apache.iceberg.expressions.Expression lessThanEqualResult = IcebergNereidsUtils - .convertNereidsToIcebergExpression(lessThanEqual, testSchema); - - Assertions.assertNotNull(equalResult); - Assertions.assertNotNull(greaterThanResult); - Assertions.assertNotNull(greaterThanEqualResult); - Assertions.assertNotNull(lessThanResult); - Assertions.assertNotNull(lessThanEqualResult); - - String eq = equalResult.toString().toLowerCase(); - String gt = greaterThanResult.toString().toLowerCase(); - String gte = greaterThanEqualResult.toString().toLowerCase(); - String lt = lessThanResult.toString().toLowerCase(); - String lte = lessThanEqualResult.toString().toLowerCase(); - Assertions.assertTrue(eq.contains("age") && eq.contains("25")); - Assertions.assertTrue(gt.contains("age") && gt.contains(">") && gt.contains("25")); - Assertions.assertTrue(gte.contains("age") && gte.contains(">=") && gte.contains("25")); - Assertions.assertTrue(lt.contains("age") && lt.contains("<") && lt.contains("25")); - Assertions.assertTrue(lte.contains("age") && lte.contains("<=") && lte.contains("25")); - } - - @Test - public void testConvertNereidsToIcebergExpression_ComplexInPredicate() throws UserException { - SlotReference slotRef = new SlotReference("id", IntegerType.INSTANCE, false); - List literals = Arrays.asList( - new IntegerLiteral(1), - new IntegerLiteral(2), - new IntegerLiteral(3), - new IntegerLiteral(4), - new IntegerLiteral(5)); - - InPredicate inPredicate = new InPredicate(slotRef, literals); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(inPredicate, testSchema); - - Assertions.assertNotNull(result); - String s = result.toString(); - Assertions.assertTrue(s.contains("id")); - Assertions.assertTrue(s.contains("1")); - Assertions.assertTrue(s.contains("2")); - Assertions.assertTrue(s.contains("3")); - Assertions.assertTrue(s.contains("4")); - Assertions.assertTrue(s.contains("5")); - } - - @Test - public void testConvertNereidsToIcebergExpression_StringInPredicate() throws UserException { - SlotReference slotRef = new SlotReference("name", StringType.INSTANCE, false); - List literals = Arrays.asList( - new StringLiteral("Alice"), - new StringLiteral("Bob"), - new StringLiteral("Charlie")); - - InPredicate inPredicate = new InPredicate(slotRef, literals); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(inPredicate, testSchema); - - Assertions.assertNotNull(result); - String s = result.toString(); - Assertions.assertTrue(s.contains("name")); - Assertions.assertTrue(s.contains("Alice")); - Assertions.assertTrue(s.contains("Bob")); - Assertions.assertTrue(s.contains("Charlie")); - } - - @Test - public void testConvertNereidsToIcebergExpression_BooleanInPredicate() throws UserException { - SlotReference slotRef = new SlotReference("is_active", BooleanType.INSTANCE, false); - List literals = Arrays.asList( - BooleanLiteral.of(true), - BooleanLiteral.of(false)); - - InPredicate inPredicate = new InPredicate(slotRef, literals); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(inPredicate, testSchema); - - Assertions.assertNotNull(result); - String s = result.toString().toLowerCase(); - Assertions.assertTrue(s.contains("is_active")); - Assertions.assertTrue(s.contains("true")); - Assertions.assertTrue(s.contains("false")); - } - - @Test - public void testConvertNereidsToIcebergExpression_AllLogicalOperators() throws UserException { - SlotReference slotRef = new SlotReference("id", IntegerType.INSTANCE, false); - IntegerLiteral literal = new IntegerLiteral(100); - EqualTo equalTo = new EqualTo(slotRef, literal); - - // Test all logical operators - And andExpr = new And(equalTo, equalTo); - Or orExpr = new Or(equalTo, equalTo); - Not notExpr = new Not(equalTo); - - org.apache.iceberg.expressions.Expression andResult = IcebergNereidsUtils - .convertNereidsToIcebergExpression(andExpr, testSchema); - org.apache.iceberg.expressions.Expression orResult = IcebergNereidsUtils - .convertNereidsToIcebergExpression(orExpr, testSchema); - org.apache.iceberg.expressions.Expression notResult = IcebergNereidsUtils - .convertNereidsToIcebergExpression(notExpr, testSchema); - - Assertions.assertNotNull(andResult); - Assertions.assertNotNull(orResult); - Assertions.assertNotNull(notResult); - - String andStr = andResult.toString().toLowerCase(); - String orStr = orResult.toString().toLowerCase(); - String notStr = notResult.toString().toLowerCase(); - Assertions.assertTrue(andStr.contains("and")); - Assertions.assertTrue(orStr.contains("or")); - Assertions.assertTrue(notStr.contains("not")); - } - - @Test - public void testConvertNereidsToIcebergExpression_EmptySchema() { - // Test with empty schema - Schema emptySchema = new Schema(); - SlotReference slotRef = new SlotReference("id", IntegerType.INSTANCE, false); - IntegerLiteral literal = new IntegerLiteral(100); - EqualTo equalTo = new EqualTo(slotRef, literal); - - UserException exception = Assertions.assertThrows(UserException.class, () -> { - IcebergNereidsUtils.convertNereidsToIcebergExpression(equalTo, emptySchema); - }); - Assertions.assertEquals("Column not found in Iceberg schema: id", exception.getDetailMessage()); - } - - @Test - public void testConvertNereidsToIcebergExpression_AllSupportedExpressionTypes() throws UserException { - // Test all supported expression types in one comprehensive test - SlotReference slotRef = new SlotReference("id", IntegerType.INSTANCE, false); - IntegerLiteral literal = new IntegerLiteral(100); - IntegerLiteral lowerBound = new IntegerLiteral(50); - IntegerLiteral upperBound = new IntegerLiteral(150); - - // Test all supported expressions - EqualTo equalTo = new EqualTo(slotRef, literal); - GreaterThan greaterThan = new GreaterThan(slotRef, literal); - GreaterThanEqual greaterThanEqual = new GreaterThanEqual(slotRef, literal); - LessThan lessThan = new LessThan(slotRef, literal); - LessThanEqual lessThanEqual = new LessThanEqual(slotRef, literal); - InPredicate inPredicate = new InPredicate(slotRef, Arrays.asList(literal)); - Between between = new Between(slotRef, lowerBound, upperBound); - And andExpr = new And(equalTo, greaterThan); - Or orExpr = new Or(equalTo, greaterThan); - Not notExpr = new Not(equalTo); - - // All should convert successfully - Assertions.assertNotNull(IcebergNereidsUtils.convertNereidsToIcebergExpression(equalTo, testSchema)); - Assertions.assertNotNull(IcebergNereidsUtils.convertNereidsToIcebergExpression(greaterThan, testSchema)); - Assertions.assertNotNull(IcebergNereidsUtils.convertNereidsToIcebergExpression(greaterThanEqual, testSchema)); - Assertions.assertNotNull(IcebergNereidsUtils.convertNereidsToIcebergExpression(lessThan, testSchema)); - Assertions.assertNotNull(IcebergNereidsUtils.convertNereidsToIcebergExpression(lessThanEqual, testSchema)); - Assertions.assertNotNull(IcebergNereidsUtils.convertNereidsToIcebergExpression(inPredicate, testSchema)); - Assertions.assertNotNull(IcebergNereidsUtils.convertNereidsToIcebergExpression(between, testSchema)); - Assertions.assertNotNull(IcebergNereidsUtils.convertNereidsToIcebergExpression(andExpr, testSchema)); - Assertions.assertNotNull(IcebergNereidsUtils.convertNereidsToIcebergExpression(orExpr, testSchema)); - Assertions.assertNotNull(IcebergNereidsUtils.convertNereidsToIcebergExpression(notExpr, testSchema)); - } - - @Test - public void testConvertNereidsToIcebergExpression_Between() throws UserException { - SlotReference slotRef = new SlotReference("age", IntegerType.INSTANCE, false); - IntegerLiteral lowerBound = new IntegerLiteral(18); - IntegerLiteral upperBound = new IntegerLiteral(65); - Between between = new Between(slotRef, lowerBound, upperBound); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(between, testSchema); - - Assertions.assertNotNull(result); - String resultStr = result.toString().toLowerCase(); - Assertions.assertTrue(resultStr.contains("age")); - Assertions.assertTrue(resultStr.contains("18")); - Assertions.assertTrue(resultStr.contains("65")); - Assertions.assertTrue(resultStr.contains("and")); - // Verify it's equivalent to: age >= 18 AND age <= 65 - Assertions.assertTrue(resultStr.contains(">=") || resultStr.contains("greaterthanequal")); - Assertions.assertTrue(resultStr.contains("<=") || resultStr.contains("lessthanequal")); - } - - @Test - public void testConvertNereidsToIcebergExpression_BetweenWithUnboundSlot() throws UserException { - UnboundSlot unboundSlot = new UnboundSlot("age"); - IntegerLiteral lowerBound = new IntegerLiteral(18); - IntegerLiteral upperBound = new IntegerLiteral(65); - Between between = new Between(unboundSlot, lowerBound, upperBound); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(between, testSchema); - - Assertions.assertNotNull(result); - String resultStr = result.toString().toLowerCase(); - Assertions.assertTrue(resultStr.contains("age")); - Assertions.assertTrue(resultStr.contains("18")); - Assertions.assertTrue(resultStr.contains("65")); - } - - @Test - public void testConvertNereidsToIcebergExpression_BetweenWithDouble() throws UserException { - SlotReference slotRef = new SlotReference("salary", DoubleType.INSTANCE, false); - DoubleLiteral lowerBound = new DoubleLiteral(10000.0); - DoubleLiteral upperBound = new DoubleLiteral(100000.0); - Between between = new Between(slotRef, lowerBound, upperBound); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(between, testSchema); - - Assertions.assertNotNull(result); - String resultStr = result.toString().toLowerCase(); - Assertions.assertTrue(resultStr.contains("salary")); - Assertions.assertTrue(resultStr.contains("10000")); - Assertions.assertTrue(resultStr.contains("100000")); - } - - @Test - public void testConvertNereidsToIcebergExpression_BetweenWithString() throws UserException { - SlotReference slotRef = new SlotReference("name", StringType.INSTANCE, false); - StringLiteral lowerBound = new StringLiteral("Alice"); - StringLiteral upperBound = new StringLiteral("Charlie"); - Between between = new Between(slotRef, lowerBound, upperBound); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(between, testSchema); - - Assertions.assertNotNull(result); - String resultStr = result.toString(); - Assertions.assertTrue(resultStr.contains("name")); - Assertions.assertTrue(resultStr.contains("Alice")); - Assertions.assertTrue(resultStr.contains("Charlie")); - } - - @Test - public void testConvertNereidsToIcebergExpression_BetweenWithDate() throws UserException { - SlotReference slotRef = new SlotReference("birth_date", DateType.INSTANCE, false); - DateLiteral lowerBound = new DateLiteral("2000-01-01"); - DateLiteral upperBound = new DateLiteral("2010-12-31"); - Between between = new Between(slotRef, lowerBound, upperBound); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(between, testSchema); - - Assertions.assertNotNull(result); - String resultStr = result.toString(); - Assertions.assertTrue(resultStr.contains("birth_date")); - Assertions.assertTrue(resultStr.contains("2000-01-01")); - Assertions.assertTrue(resultStr.contains("2010-12-31")); - } - - @Test - public void testConvertNereidsToIcebergExpression_BetweenInvalidCompareExpr() { - // Test with non-slot compareExpr - IntegerLiteral compareExpr = new IntegerLiteral(100); - IntegerLiteral lowerBound = new IntegerLiteral(18); - IntegerLiteral upperBound = new IntegerLiteral(65); - Between between = new Between(compareExpr, lowerBound, upperBound); - - UserException exception = Assertions.assertThrows(UserException.class, () -> { - IcebergNereidsUtils.convertNereidsToIcebergExpression(between, testSchema); - }); - Assertions.assertTrue(exception.getDetailMessage().contains("must be a slot")); - } - - @Test - public void testConvertNereidsToIcebergExpression_BetweenInvalidLowerBound() { - // Test with non-literal lowerBound - SlotReference slotRef = new SlotReference("age", IntegerType.INSTANCE, false); - SlotReference lowerBound = new SlotReference("min_age", IntegerType.INSTANCE, false); - IntegerLiteral upperBound = new IntegerLiteral(65); - Between between = new Between(slotRef, lowerBound, upperBound); - - UserException exception = Assertions.assertThrows(UserException.class, () -> { - IcebergNereidsUtils.convertNereidsToIcebergExpression(between, testSchema); - }); - Assertions.assertTrue(exception.getDetailMessage().contains("Lower bound") - && exception.getDetailMessage().contains("must be a literal")); - } - - @Test - public void testConvertNereidsToIcebergExpression_BetweenInvalidUpperBound() { - // Test with non-literal upperBound - SlotReference slotRef = new SlotReference("age", IntegerType.INSTANCE, false); - IntegerLiteral lowerBound = new IntegerLiteral(18); - SlotReference upperBound = new SlotReference("max_age", IntegerType.INSTANCE, false); - Between between = new Between(slotRef, lowerBound, upperBound); - - UserException exception = Assertions.assertThrows(UserException.class, () -> { - IcebergNereidsUtils.convertNereidsToIcebergExpression(between, testSchema); - }); - Assertions.assertTrue(exception.getDetailMessage().contains("Upper bound") - && exception.getDetailMessage().contains("must be a literal")); - } - - @Test - public void testConvertNereidsToIcebergExpression_BetweenColumnNotFound() { - SlotReference slotRef = new SlotReference("non_existent_column", IntegerType.INSTANCE, false); - IntegerLiteral lowerBound = new IntegerLiteral(18); - IntegerLiteral upperBound = new IntegerLiteral(65); - Between between = new Between(slotRef, lowerBound, upperBound); - - UserException exception = Assertions.assertThrows(UserException.class, () -> { - IcebergNereidsUtils.convertNereidsToIcebergExpression(between, testSchema); - }); - Assertions.assertEquals("Column not found in Iceberg schema: non_existent_column", - exception.getDetailMessage()); - } - - @Test - public void testConvertNereidsToIcebergExpression_BetweenWithNullBounds() { - SlotReference slotRef = new SlotReference("age", IntegerType.INSTANCE, false); - NullLiteral nullLiteral = new NullLiteral(); - IntegerLiteral upperBound = new IntegerLiteral(65); - Between between = new Between(slotRef, nullLiteral, upperBound); - - UserException exception = Assertions.assertThrows(UserException.class, () -> { - IcebergNereidsUtils.convertNereidsToIcebergExpression(between, testSchema); - }); - Assertions.assertTrue(exception.getDetailMessage().contains("cannot be null")); - } - - @Test - public void testConvertNereidsToIcebergExpression_BetweenInComplexExpression() throws UserException { - // Test BETWEEN in AND expression: age BETWEEN 18 AND 65 AND salary > 50000 - SlotReference ageRef = new SlotReference("age", IntegerType.INSTANCE, false); - SlotReference salaryRef = new SlotReference("salary", DoubleType.INSTANCE, false); - - Between between = new Between(ageRef, new IntegerLiteral(18), new IntegerLiteral(65)); - GreaterThan salaryGt = new GreaterThan(salaryRef, new DoubleLiteral(50000.0)); - And andExpr = new And(between, salaryGt); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(andExpr, testSchema); - - Assertions.assertNotNull(result); - String resultStr = result.toString().toLowerCase(); - Assertions.assertTrue(resultStr.contains("age")); - Assertions.assertTrue(resultStr.contains("salary")); - Assertions.assertTrue(resultStr.contains("and")); - } - - @Test - public void testConvertNereidsToIcebergExpression_BetweenWithUnboundSlotInvalidNameParts() { - // Test UnboundSlot with multiple nameParts (should fail) - UnboundSlot unboundSlot = new UnboundSlot("table", "age"); - IntegerLiteral lowerBound = new IntegerLiteral(18); - IntegerLiteral upperBound = new IntegerLiteral(65); - Between between = new Between(unboundSlot, lowerBound, upperBound); - - UserException exception = Assertions.assertThrows(UserException.class, () -> { - IcebergNereidsUtils.convertNereidsToIcebergExpression(between, testSchema); - }); - Assertions.assertTrue(exception.getDetailMessage().contains("single name part")); - } - - @Test - public void testConvertNereidsToIcebergExpression_BetweenNestedInOr() throws UserException { - // Test: (age BETWEEN 18 AND 30) OR (age BETWEEN 50 AND 65) - SlotReference ageRef = new SlotReference("age", IntegerType.INSTANCE, false); - - Between between1 = new Between(ageRef, new IntegerLiteral(18), new IntegerLiteral(30)); - Between between2 = new Between(ageRef, new IntegerLiteral(50), new IntegerLiteral(65)); - Or orExpr = new Or(between1, between2); - - org.apache.iceberg.expressions.Expression result = IcebergNereidsUtils - .convertNereidsToIcebergExpression(orExpr, testSchema); - - Assertions.assertNotNull(result); - String resultStr = result.toString().toLowerCase(); - Assertions.assertTrue(resultStr.contains("age")); - Assertions.assertTrue(resultStr.contains("or")); - Assertions.assertTrue(resultStr.contains("18")); - Assertions.assertTrue(resultStr.contains("30")); - Assertions.assertTrue(resultStr.contains("50")); - Assertions.assertTrue(resultStr.contains("65")); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergPartitionInfoTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergPartitionInfoTest.java deleted file mode 100644 index 74c8c3f6954a97..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergPartitionInfoTest.java +++ /dev/null @@ -1,53 +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.datasource.iceberg; - -import com.google.common.collect.Maps; -import com.google.common.collect.Sets; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.Map; -import java.util.Set; - -public class IcebergPartitionInfoTest { - - @Test - public void testGetLatestSnapshotId() { - IcebergPartition p1 = new IcebergPartition("p1", 0, 0, 0, 0, 1, 101, null, null); - IcebergPartition p2 = new IcebergPartition("p2", 0, 0, 0, 0, 2, 102, null, null); - IcebergPartition p3 = new IcebergPartition("p3", 0, 0, 0, 0, 3, 103, null, null); - Map nameToIcebergPartition = Maps.newHashMap(); - nameToIcebergPartition.put(p1.getPartitionName(), p1); - nameToIcebergPartition.put(p2.getPartitionName(), p2); - nameToIcebergPartition.put(p3.getPartitionName(), p3); - Map> nameToIcebergPartitionNames = Maps.newHashMap(); - Set names = Sets.newHashSet(); - names.add("p1"); - names.add("p2"); - nameToIcebergPartitionNames.put("p1", names); - - IcebergPartitionInfo info = new IcebergPartitionInfo(null, nameToIcebergPartition, nameToIcebergPartitionNames); - long snapshot1 = info.getLatestSnapshotId("p1"); - long snapshot2 = info.getLatestSnapshotId("p2"); - long snapshot3 = info.getLatestSnapshotId("p3"); - Assertions.assertEquals(102, snapshot1); - Assertions.assertEquals(102, snapshot2); - Assertions.assertEquals(103, snapshot3); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergPredicateTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergPredicateTest.java deleted file mode 100644 index 366d1dc8a42273..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergPredicateTest.java +++ /dev/null @@ -1,259 +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.datasource.iceberg; - -import org.apache.doris.analysis.BinaryPredicate; -import org.apache.doris.analysis.BoolLiteral; -import org.apache.doris.analysis.CompoundPredicate; -import org.apache.doris.analysis.CompoundPredicate.Operator; -import org.apache.doris.analysis.DateLiteral; -import org.apache.doris.analysis.DecimalLiteral; -import org.apache.doris.analysis.Expr; -import org.apache.doris.analysis.ExprToSqlVisitor; -import org.apache.doris.analysis.FloatLiteral; -import org.apache.doris.analysis.InPredicate; -import org.apache.doris.analysis.IntLiteral; -import org.apache.doris.analysis.LiteralExpr; -import org.apache.doris.analysis.SlotRef; -import org.apache.doris.analysis.StringLiteral; -import org.apache.doris.analysis.ToSqlParams; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.Type; -import org.apache.doris.catalog.info.TableNameInfo; -import org.apache.doris.common.AnalysisException; - -import com.google.common.collect.ArrayListMultimap; -import com.google.common.collect.Lists; -import org.apache.iceberg.Schema; -import org.apache.iceberg.expressions.Expression; -import org.apache.iceberg.expressions.Expressions; -import org.apache.iceberg.types.Types; -import org.junit.Assert; -import org.junit.BeforeClass; -import org.junit.Test; - -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -public class IcebergPredicateTest { - - public static Schema schema; - - @BeforeClass - public static void before() throws AnalysisException { - schema = new Schema( - Types.NestedField.required(1, "c_int", Types.IntegerType.get()), - Types.NestedField.required(2, "c_long", Types.LongType.get()), - Types.NestedField.required(3, "c_bool", Types.BooleanType.get()), - Types.NestedField.required(4, "c_float", Types.FloatType.get()), - Types.NestedField.required(5, "c_double", Types.DoubleType.get()), - Types.NestedField.required(6, "c_dec", Types.DecimalType.of(20, 10)), - Types.NestedField.required(7, "c_date", Types.DateType.get()), - Types.NestedField.required(8, "c_ts", Types.TimestampType.withoutZone()), - Types.NestedField.required(10, "c_str", Types.StringType.get()) - ); - } - - @Test - public void testBinaryPredicate() throws AnalysisException { - List literalList = new ArrayList() {{ - add(new BoolLiteral(true)); - add(new DateLiteral(2023, 1, 2, Type.DATEV2)); - add(new DateLiteral(2024, 1, 2, 12, 34, 56, 123456, Type.DATETIMEV2)); - add(new DecimalLiteral(new BigDecimal("1.23"), ScalarType.createDecimalV3Type(3, 2))); - add(new FloatLiteral(1.23, Type.FLOAT)); - add(new FloatLiteral(3.456, Type.DOUBLE)); - add(new IntLiteral(1, Type.TINYINT)); - add(new IntLiteral(1, Type.SMALLINT)); - add(new IntLiteral(1, Type.INT)); - add(new IntLiteral(1, Type.BIGINT)); - add(new StringLiteral("abc")); - add(new StringLiteral("2023-01-02")); - add(new StringLiteral("2023-01-02 01:02:03.456789")); - }}; - - List slotRefs = new ArrayList() {{ - add(new SlotRef(new TableNameInfo(), "c_int")); - add(new SlotRef(new TableNameInfo(), "c_long")); - add(new SlotRef(new TableNameInfo(), "c_bool")); - add(new SlotRef(new TableNameInfo(), "c_float")); - add(new SlotRef(new TableNameInfo(), "c_double")); - add(new SlotRef(new TableNameInfo(), "c_dec")); - add(new SlotRef(new TableNameInfo(), "c_date")); - add(new SlotRef(new TableNameInfo(), "c_ts")); - add(new SlotRef(new TableNameInfo(), "c_str")); - }}; - - // true indicates support for pushdown - Boolean[][] expects = new Boolean[][] { - { // int - false, false, false, false, false, false, true, true, true, true, false, false, false - }, - { // long - false, false, false, false, false, false, true, true, true, true, false, false, false - }, - { // boolean - true, false, false, false, false, false, false, false, false, false, false, false, false - }, - { // float - false, false, false, false, true, false, true, true, true, true, false, false, false - }, - { // double - false, false, false, true, true, true, true, true, true, true, false, false, false - }, - { // decimal - false, false, false, true, true, true, true, true, true, true, false, false, false - }, - { // date - false, true, false, false, false, false, true, true, true, true, false, true, false - }, - { // timestamp - false, true, true, false, false, false, false, false, false, true, false, false, false - }, - { // string - true, true, true, true, false, false, false, false, false, false, true, true, true - } - }; - - ArrayListMultimap validPredicateMap = ArrayListMultimap.create(); - - // binary predicate - for (int i = 0; i < slotRefs.size(); i++) { - final int loc = i; - List ret = literalList.stream().map(literal -> { - BinaryPredicate expr = new BinaryPredicate(BinaryPredicate.Operator.EQ, slotRefs.get(loc), literal); - Expression expression = IcebergUtils.convertToIcebergExpr(expr, schema); - validPredicateMap.put(expression != null, expr); - return expression != null; - }).collect(Collectors.toList()); - Assert.assertArrayEquals(expects[i], ret.toArray()); - } - - // in predicate - for (int i = 0; i < slotRefs.size(); i++) { - final int loc = i; - List ret = literalList.stream().map(literal -> { - InPredicate expr = new InPredicate(slotRefs.get(loc), Lists.newArrayList(literal), false); - Expression expression = IcebergUtils.convertToIcebergExpr(expr, schema); - validPredicateMap.put(expression != null, expr); - return expression != null; - }).collect(Collectors.toList()); - Assert.assertArrayEquals(expects[i], ret.toArray()); - } - - // not in predicate - for (int i = 0; i < slotRefs.size(); i++) { - final int loc = i; - List ret = literalList.stream().map(literal -> { - InPredicate expr = new InPredicate(slotRefs.get(loc), Lists.newArrayList(literal), true); - Expression expression = IcebergUtils.convertToIcebergExpr(expr, schema); - validPredicateMap.put(expression != null, expr); - return expression != null; - }).collect(Collectors.toList()); - Assert.assertArrayEquals(expects[i], ret.toArray()); - } - - // bool literal - Expression trueExpr = IcebergUtils.convertToIcebergExpr(new BoolLiteral(true), schema); - Expression falseExpr = IcebergUtils.convertToIcebergExpr(new BoolLiteral(false), schema); - Assert.assertEquals(Expressions.alwaysTrue(), trueExpr); - Assert.assertEquals(Expressions.alwaysFalse(), falseExpr); - validPredicateMap.put(true, new BoolLiteral(true)); - validPredicateMap.put(true, new BoolLiteral(false)); - - List validExprs = validPredicateMap.get(true); - List invalidExprs = validPredicateMap.get(false); - // OR predicate - // both valid - for (int i = 0; i < validExprs.size(); i++) { - for (int j = 0; j < validExprs.size(); j++) { - CompoundPredicate orPredicate = new CompoundPredicate(Operator.OR, - validExprs.get(i), validExprs.get(j)); - Expression expression = IcebergUtils.convertToIcebergExpr(orPredicate, schema); - Assert.assertNotNull("pred: " + orPredicate.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE), expression); - } - } - // both invalid - for (int i = 0; i < invalidExprs.size(); i++) { - for (int j = 0; j < invalidExprs.size(); j++) { - CompoundPredicate orPredicate = new CompoundPredicate(Operator.OR, - invalidExprs.get(i), invalidExprs.get(j)); - Expression expression = IcebergUtils.convertToIcebergExpr(orPredicate, schema); - Assert.assertNull("pred: " + orPredicate.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE), expression); - } - } - // valid or invalid - for (int i = 0; i < validExprs.size(); i++) { - for (int j = 0; j < invalidExprs.size(); j++) { - CompoundPredicate orPredicate = new CompoundPredicate(Operator.OR, - validExprs.get(i), invalidExprs.get(j)); - Expression expression = IcebergUtils.convertToIcebergExpr(orPredicate, schema); - Assert.assertNull("pred: " + orPredicate.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE), expression); - } - } - - // AND predicate - // both valid - for (int i = 0; i < validExprs.size(); i++) { - for (int j = 0; j < validExprs.size(); j++) { - CompoundPredicate andPredicate = new CompoundPredicate(Operator.AND, - validExprs.get(i), validExprs.get(j)); - Expression expression = IcebergUtils.convertToIcebergExpr(andPredicate, schema); - Assert.assertNotNull("pred: " + andPredicate.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE), expression); - } - } - // both invalid - for (int i = 0; i < invalidExprs.size(); i++) { - for (int j = 0; j < invalidExprs.size(); j++) { - CompoundPredicate andPredicate = new CompoundPredicate(Operator.AND, - invalidExprs.get(i), invalidExprs.get(j)); - Expression expression = IcebergUtils.convertToIcebergExpr(andPredicate, schema); - Assert.assertNull("pred: " + andPredicate.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE), expression); - } - } - // valid and invalid - for (int i = 0; i < validExprs.size(); i++) { - for (int j = 0; j < invalidExprs.size(); j++) { - CompoundPredicate andPredicate = new CompoundPredicate(Operator.AND, - validExprs.get(i), invalidExprs.get(j)); - Expression expression = IcebergUtils.convertToIcebergExpr(andPredicate, schema); - Assert.assertNotNull("pred: " + andPredicate.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE), expression); - Assert.assertEquals(IcebergUtils.convertToIcebergExpr(validExprs.get(i), schema).toString(), - expression.toString()); - } - } - - // NOT predicate - // valid - for (int i = 0; i < validExprs.size(); i++) { - CompoundPredicate notPredicate = new CompoundPredicate(Operator.NOT, - validExprs.get(i), null); - Expression expression = IcebergUtils.convertToIcebergExpr(notPredicate, schema); - Assert.assertNotNull("pred: " + notPredicate.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE), expression); - } - // invalid - for (int i = 0; i < invalidExprs.size(); i++) { - CompoundPredicate notPredicate = new CompoundPredicate(Operator.NOT, - invalidExprs.get(i), null); - Expression expression = IcebergUtils.convertToIcebergExpr(notPredicate, schema); - Assert.assertNull("pred: " + notPredicate.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE), expression); - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergSessionCatalogAdapterTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergSessionCatalogAdapterTest.java deleted file mode 100644 index 85f1c1384dbfb9..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergSessionCatalogAdapterTest.java +++ /dev/null @@ -1,332 +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.datasource.iceberg; - -import org.apache.doris.datasource.DelegatedCredential; -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.datasource.property.metastore.IcebergRestProperties.DelegatedTokenMode; - -import org.apache.iceberg.Schema; -import org.apache.iceberg.Table; -import org.apache.iceberg.catalog.BaseSessionCatalog; -import org.apache.iceberg.catalog.Catalog; -import org.apache.iceberg.catalog.Namespace; -import org.apache.iceberg.catalog.SupportsNamespaces; -import org.apache.iceberg.catalog.TableIdentifier; -import org.apache.iceberg.exceptions.NamespaceNotEmptyException; -import org.apache.iceberg.rest.auth.OAuth2Properties; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Set; - -public class IcebergSessionCatalogAdapterTest { - - @Test - public void testAccessTokenMapsToIcebergOAuthBearerTokenCredential() { - SessionContext context = SessionContext.of(new DelegatedCredential( - DelegatedCredential.Type.ACCESS_TOKEN, "access-token")); - org.apache.iceberg.catalog.SessionCatalog.SessionContext icebergContext = - IcebergSessionCatalogAdapter.toIcebergSessionContext(context); - org.apache.iceberg.catalog.SessionCatalog.SessionContext secondIcebergContext = - IcebergSessionCatalogAdapter.toIcebergSessionContext(context); - - Assertions.assertEquals(context.getSessionId(), icebergContext.sessionId()); - Assertions.assertEquals(icebergContext.sessionId(), secondIcebergContext.sessionId()); - Assertions.assertEquals("access-token", icebergContext.credentials().get(OAuth2Properties.TOKEN)); - Assertions.assertEquals(1, icebergContext.credentials().size()); - } - - @Test - public void testIdTokenUsesBearerTokenCredentialByDefault() { - SessionContext context = SessionContext.of(new DelegatedCredential( - DelegatedCredential.Type.ID_TOKEN, "oidc-login-token")); - - org.apache.iceberg.catalog.SessionCatalog.SessionContext icebergContext = - IcebergSessionCatalogAdapter.toIcebergSessionContext(context); - - Assertions.assertEquals("oidc-login-token", icebergContext.credentials().get(OAuth2Properties.TOKEN)); - Assertions.assertEquals(1, icebergContext.credentials().size()); - } - - @Test - public void testIdTokenUsesTokenExchangeCredentialWhenConfigured() { - SessionContext context = SessionContext.of(new DelegatedCredential( - DelegatedCredential.Type.ID_TOKEN, "id-token")); - - org.apache.iceberg.catalog.SessionCatalog.SessionContext icebergContext = - IcebergSessionCatalogAdapter.toIcebergSessionContext(context, DelegatedTokenMode.TOKEN_EXCHANGE); - - Assertions.assertEquals("id-token", icebergContext.credentials().get(OAuth2Properties.ID_TOKEN_TYPE)); - Assertions.assertEquals(1, icebergContext.credentials().size()); - } - - @Test - public void testJwtAndSamlUseTokenExchangeCredentialsWhenConfigured() { - SessionContext jwtContext = SessionContext.of(new DelegatedCredential( - DelegatedCredential.Type.JWT, "jwt-token")); - SessionContext samlContext = SessionContext.of(new DelegatedCredential( - DelegatedCredential.Type.SAML, "saml-assertion")); - - org.apache.iceberg.catalog.SessionCatalog.SessionContext icebergJwtContext = - IcebergSessionCatalogAdapter.toIcebergSessionContext(jwtContext, DelegatedTokenMode.TOKEN_EXCHANGE); - org.apache.iceberg.catalog.SessionCatalog.SessionContext icebergSamlContext = - IcebergSessionCatalogAdapter.toIcebergSessionContext(samlContext, DelegatedTokenMode.TOKEN_EXCHANGE); - - Assertions.assertEquals("jwt-token", icebergJwtContext.credentials().get(OAuth2Properties.JWT_TOKEN_TYPE)); - Assertions.assertEquals("saml-assertion", - icebergSamlContext.credentials().get(OAuth2Properties.SAML2_TOKEN_TYPE)); - Assertions.assertEquals(1, icebergJwtContext.credentials().size()); - Assertions.assertEquals(1, icebergSamlContext.credentials().size()); - } - - @Test - public void testDelegatedCatalogUsesIcebergSessionCredentials() { - RecordingSessionCatalog sessionCatalog = new RecordingSessionCatalog(); - SessionBackedCatalog catalog = new SessionBackedCatalog(); - IcebergSessionCatalogAdapter adapter = new IcebergSessionCatalogAdapter(catalog, sessionCatalog); - SessionContext context = SessionContext.of(new DelegatedCredential( - DelegatedCredential.Type.ACCESS_TOKEN, "access-token")); - - adapter.catalog(context).tableExists(TableIdentifier.of("db", "tbl")); - - Map credentials = sessionCatalog.lastContext.credentials(); - Assertions.assertEquals("access-token", credentials.get(OAuth2Properties.TOKEN)); - Assertions.assertFalse(catalog.tableExistsCalled); - } - - @Test - public void testDelegatedNamespacesUseIcebergSessionCredentials() { - RecordingSessionCatalog sessionCatalog = new RecordingSessionCatalog(); - SessionBackedCatalog catalog = new SessionBackedCatalog(); - IcebergSessionCatalogAdapter adapter = new IcebergSessionCatalogAdapter(catalog, sessionCatalog); - SessionContext context = SessionContext.of(new DelegatedCredential( - DelegatedCredential.Type.ACCESS_TOKEN, "access-token")); - - adapter.namespaces(context).listNamespaces(Namespace.empty()); - - Map credentials = sessionCatalog.lastContext.credentials(); - Assertions.assertEquals("access-token", credentials.get(OAuth2Properties.TOKEN)); - Assertions.assertFalse(catalog.listNamespacesCalled); - } - - @Test - public void testPlainCatalogIsUsedWithoutDelegatedCredential() { - RecordingSessionCatalog sessionCatalog = new RecordingSessionCatalog(); - SessionBackedCatalog catalog = new SessionBackedCatalog(); - IcebergSessionCatalogAdapter adapter = new IcebergSessionCatalogAdapter(catalog, sessionCatalog); - - adapter.catalog(SessionContext.empty()).tableExists(TableIdentifier.of("db", "tbl")); - - Assertions.assertTrue(catalog.tableExistsCalled); - Assertions.assertNull(sessionCatalog.lastContext); - } - - @Test - public void testDelegatedCatalogRequiresDelegatedCredential() { - RecordingSessionCatalog sessionCatalog = new RecordingSessionCatalog(); - SessionBackedCatalog catalog = new SessionBackedCatalog(); - IcebergSessionCatalogAdapter adapter = new IcebergSessionCatalogAdapter(catalog, sessionCatalog); - - IllegalStateException exception = Assertions.assertThrows( - IllegalStateException.class, - () -> adapter.delegatedCatalog(SessionContext.empty())); - - Assertions.assertTrue(exception.getMessage().contains("requires delegated credential")); - Assertions.assertFalse(catalog.tableExistsCalled); - Assertions.assertNull(sessionCatalog.lastContext); - } - - private static class SessionBackedCatalog implements Catalog, SupportsNamespaces { - private boolean tableExistsCalled; - private boolean listNamespacesCalled; - - @Override - public List listTables(Namespace namespace) { - return Collections.emptyList(); - } - - @Override - public boolean tableExists(TableIdentifier ident) { - tableExistsCalled = true; - return true; - } - - @Override - public Table loadTable(TableIdentifier ident) { - return Mockito.mock(Table.class); - } - - @Override - public void invalidateTable(TableIdentifier ident) { - } - - @Override - public TableBuilder buildTable(TableIdentifier ident, Schema schema) { - throw new UnsupportedOperationException(); - } - - @Override - public boolean dropTable(TableIdentifier ident) { - return false; - } - - @Override - public boolean dropTable(TableIdentifier ident, boolean purge) { - return false; - } - - @Override - public void renameTable(TableIdentifier from, TableIdentifier to) { - } - - @Override - public void createNamespace(Namespace namespace, Map metadata) { - } - - @Override - public List listNamespaces(Namespace namespace) { - listNamespacesCalled = true; - return Collections.emptyList(); - } - - @Override - public Map loadNamespaceMetadata(Namespace namespace) { - return Collections.emptyMap(); - } - - @Override - public boolean dropNamespace(Namespace namespace) throws NamespaceNotEmptyException { - return false; - } - - @Override - public boolean setProperties(Namespace namespace, Map properties) { - return false; - } - - @Override - public boolean removeProperties(Namespace namespace, Set properties) { - return false; - } - - @Override - public boolean namespaceExists(Namespace namespace) { - return true; - } - } - - private static class RecordingSessionCatalog extends BaseSessionCatalog { - private org.apache.iceberg.catalog.SessionCatalog.SessionContext lastContext; - - @Override - public List listTables( - org.apache.iceberg.catalog.SessionCatalog.SessionContext context, Namespace namespace) { - lastContext = context; - return Collections.emptyList(); - } - - @Override - public Catalog.TableBuilder buildTable( - org.apache.iceberg.catalog.SessionCatalog.SessionContext context, - TableIdentifier ident, Schema schema) { - throw new UnsupportedOperationException(); - } - - @Override - public Table registerTable( - org.apache.iceberg.catalog.SessionCatalog.SessionContext context, - TableIdentifier ident, String metadataFileLocation) { - throw new UnsupportedOperationException(); - } - - @Override - public boolean tableExists( - org.apache.iceberg.catalog.SessionCatalog.SessionContext context, TableIdentifier ident) { - lastContext = context; - return true; - } - - @Override - public Table loadTable( - org.apache.iceberg.catalog.SessionCatalog.SessionContext context, TableIdentifier ident) { - lastContext = context; - return Mockito.mock(Table.class); - } - - @Override - public boolean dropTable( - org.apache.iceberg.catalog.SessionCatalog.SessionContext context, TableIdentifier ident) { - return false; - } - - @Override - public boolean purgeTable( - org.apache.iceberg.catalog.SessionCatalog.SessionContext context, TableIdentifier ident) { - return false; - } - - @Override - public void renameTable( - org.apache.iceberg.catalog.SessionCatalog.SessionContext context, - TableIdentifier from, TableIdentifier to) { - } - - @Override - public void invalidateTable( - org.apache.iceberg.catalog.SessionCatalog.SessionContext context, TableIdentifier ident) { - } - - @Override - public void createNamespace( - org.apache.iceberg.catalog.SessionCatalog.SessionContext context, - Namespace namespace, Map metadata) { - } - - @Override - public List listNamespaces( - org.apache.iceberg.catalog.SessionCatalog.SessionContext context, Namespace namespace) { - lastContext = context; - return Collections.emptyList(); - } - - @Override - public Map loadNamespaceMetadata( - org.apache.iceberg.catalog.SessionCatalog.SessionContext context, Namespace namespace) { - lastContext = context; - return Collections.emptyMap(); - } - - @Override - public boolean dropNamespace( - org.apache.iceberg.catalog.SessionCatalog.SessionContext context, Namespace namespace) { - return false; - } - - @Override - public boolean updateNamespaceMetadata( - org.apache.iceberg.catalog.SessionCatalog.SessionContext context, - Namespace namespace, Map updates, Set removals) { - return false; - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergTransactionTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergTransactionTest.java deleted file mode 100644 index d037e6974ec42f..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergTransactionTest.java +++ /dev/null @@ -1,614 +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.datasource.iceberg; - -import org.apache.doris.common.UserException; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.iceberg.helper.IcebergWriterHelper; -import org.apache.doris.foundation.util.SerializationUtils; -import org.apache.doris.nereids.trees.plans.commands.insert.IcebergInsertCommandContext; -import org.apache.doris.thrift.TFileContent; -import org.apache.doris.thrift.TIcebergCommitData; - -import org.apache.hadoop.conf.Configuration; -import org.apache.iceberg.CatalogProperties; -import org.apache.iceberg.DeleteFile; -import org.apache.iceberg.FileFormat; -import org.apache.iceberg.FileMetadata; -import org.apache.iceberg.FileScanTask; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.RowDelta; -import org.apache.iceberg.Schema; -import org.apache.iceberg.Table; -import org.apache.iceberg.catalog.Namespace; -import org.apache.iceberg.catalog.TableIdentifier; -import org.apache.iceberg.expressions.Expression; -import org.apache.iceberg.expressions.Expressions; -import org.apache.iceberg.expressions.UnboundPredicate; -import org.apache.iceberg.hadoop.HadoopCatalog; -import org.apache.iceberg.io.CloseableIterable; -import org.apache.iceberg.transforms.Transform; -import org.apache.iceberg.transforms.Transforms; -import org.apache.iceberg.types.Types; -import org.apache.iceberg.util.DateTimeUtil; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.mockito.ArgumentMatchers; -import org.mockito.MockedStatic; -import org.mockito.Mockito; - -import java.io.IOException; -import java.io.Serializable; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicReference; - -public class IcebergTransactionTest { - - private static String dbName = "db3"; - private static String tbWithPartition = "tbWithPartition"; - private static String tbWithoutPartition = "tbWithoutPartition"; - - private IcebergExternalCatalog spyExternalCatalog; - private IcebergMetadataOps ops; - - @Before - public void init() throws IOException { - createCatalog(); - createTable(); - } - - private void createCatalog() throws IOException { - Path warehousePath = Files.createTempDirectory("test_warehouse_"); - String warehouse = "file://" + warehousePath.toAbsolutePath() + "/"; - HadoopCatalog hadoopCatalog = new HadoopCatalog(); - Map props = new HashMap<>(); - props.put(CatalogProperties.WAREHOUSE_LOCATION, warehouse); - hadoopCatalog.setConf(new Configuration()); - hadoopCatalog.initialize("df", props); - this.spyExternalCatalog = Mockito.mock(IcebergExternalCatalog.class); - Mockito.when(spyExternalCatalog.getCatalog()).thenReturn(hadoopCatalog); - Mockito.when(spyExternalCatalog.getExecutionAuthenticator()).thenReturn(new ExecutionAuthenticator() { - }); - ops = new IcebergMetadataOps(spyExternalCatalog, hadoopCatalog); - } - - private void createTable() { - HadoopCatalog icebergCatalog = (HadoopCatalog) ops.getCatalog(); - icebergCatalog.createNamespace(Namespace.of(dbName)); - Schema schema = new Schema( - Types.NestedField.required(11, "ts1", Types.TimestampType.withoutZone()), - Types.NestedField.required(12, "ts2", Types.TimestampType.withoutZone()), - Types.NestedField.required(13, "ts3", Types.TimestampType.withoutZone()), - Types.NestedField.required(14, "ts4", Types.TimestampType.withoutZone()), - Types.NestedField.required(15, "dt1", Types.DateType.get()), - Types.NestedField.required(16, "dt2", Types.DateType.get()), - Types.NestedField.required(17, "dt3", Types.DateType.get()), - Types.NestedField.required(18, "dt4", Types.DateType.get()), - Types.NestedField.required(19, "str1", Types.StringType.get()), - Types.NestedField.required(20, "str2", Types.StringType.get()), - Types.NestedField.required(21, "int1", Types.IntegerType.get()), - Types.NestedField.required(22, "int2", Types.IntegerType.get()) - ); - - PartitionSpec partitionSpec = PartitionSpec.builderFor(schema) - .year("ts1") - .month("ts2") - .day("ts3") - .hour("ts4") - .year("dt1") - .month("dt2") - .day("dt3") - .identity("dt4") - .identity("str1") - .truncate("str2", 10) - .bucket("int1", 2) - .build(); - icebergCatalog.createTable(TableIdentifier.of(dbName, tbWithPartition), schema, partitionSpec); - icebergCatalog.createTable(TableIdentifier.of(dbName, tbWithoutPartition), schema); - } - - private List createPartitionValues() { - - Instant instant = Instant.parse("2024-12-11T12:34:56.123456Z"); - long ts = DateTimeUtil.microsFromInstant(instant); - int dt = DateTimeUtil.daysFromInstant(instant); - - List partitionValues = new ArrayList<>(); - - // reference: org.apache.iceberg.transforms.Timestamps - partitionValues.add(Integer.valueOf(DateTimeUtil.microsToYears(ts)).toString()); - partitionValues.add(Integer.valueOf(DateTimeUtil.microsToMonths(ts)).toString()); - partitionValues.add("2024-12-11"); - partitionValues.add(Integer.valueOf(DateTimeUtil.microsToHours(ts)).toString()); - - // reference: org.apache.iceberg.transforms.Dates - partitionValues.add(Integer.valueOf(DateTimeUtil.daysToYears(dt)).toString()); - partitionValues.add(Integer.valueOf(DateTimeUtil.daysToMonths(dt)).toString()); - partitionValues.add("2024-12-11"); - - // identity dt4 - partitionValues.add("2024-12-11"); - // identity str1 - partitionValues.add("2024-12-11"); - // truncate str2 - partitionValues.add("2024-12-11"); - // bucket int1 - partitionValues.add("1"); - - return partitionValues; - } - - @Test - public void testPartitionedTable() throws UserException { - List partitionValues = createPartitionValues(); - - List ctdList = new ArrayList<>(); - TIcebergCommitData ctd1 = new TIcebergCommitData(); - ctd1.setFilePath("f1.parquet"); - ctd1.setPartitionValues(partitionValues); - ctd1.setFileContent(TFileContent.DATA); - ctd1.setRowCount(2); - ctd1.setFileSize(2); - - TIcebergCommitData ctd2 = new TIcebergCommitData(); - ctd2.setFilePath("f2.parquet"); - ctd2.setPartitionValues(partitionValues); - ctd2.setFileContent(TFileContent.DATA); - ctd2.setRowCount(4); - ctd2.setFileSize(4); - - ctdList.add(ctd1); - ctdList.add(ctd2); - - Table table = ops.getCatalog().loadTable(TableIdentifier.of(dbName, tbWithPartition)); - - IcebergExternalTable icebergExternalTable = Mockito.mock(IcebergExternalTable.class); - Mockito.when(icebergExternalTable.getCatalog()).thenReturn(spyExternalCatalog); - Mockito.when(icebergExternalTable.getDbName()).thenReturn(dbName); - Mockito.when(icebergExternalTable.getName()).thenReturn(tbWithPartition); - - try (MockedStatic mockedStatic = Mockito.mockStatic(IcebergUtils.class)) { - mockedStatic.when(() -> IcebergUtils.getIcebergTable(ArgumentMatchers.any(ExternalTable.class))) - .thenReturn(table); - // Allow parsePartitionValueFromString to call the real implementation - mockedStatic.when(() -> IcebergUtils.parsePartitionValueFromString( - ArgumentMatchers.any(), ArgumentMatchers.any())) - .thenCallRealMethod(); - IcebergTransaction txn = getTxn(); - txn.updateIcebergCommitData(ctdList); - txn.beginInsert(icebergExternalTable, Optional.empty()); - txn.finishInsert(NameMapping.createForTest(dbName, tbWithPartition)); - txn.commit(); - } - - checkSnapshotAddProperties(table.currentSnapshot().summary(), "6", "2", "6"); - checkPushDownByPartitionForTs(table, "ts1"); - checkPushDownByPartitionForTs(table, "ts2"); - checkPushDownByPartitionForTs(table, "ts3"); - checkPushDownByPartitionForTs(table, "ts4"); - - checkPushDownByPartitionForDt(table, "dt1"); - checkPushDownByPartitionForDt(table, "dt2"); - checkPushDownByPartitionForDt(table, "dt3"); - checkPushDownByPartitionForDt(table, "dt4"); - - checkPushDownByPartitionForString(table, "str1"); - checkPushDownByPartitionForString(table, "str2"); - - checkPushDownByPartitionForBucketInt(table, "int1"); - } - - private void checkPushDownByPartitionForBucketInt(Table table, String column) { - // (BucketUtil.hash(15) & Integer.MAX_VALUE) % 2 = 0 - Integer i1 = 15; - - UnboundPredicate lessThan = Expressions.lessThan(column, i1); - checkPushDownByPartition(table, lessThan, 2); - // can only filter this case - UnboundPredicate equal = Expressions.equal(column, i1); - checkPushDownByPartition(table, equal, 0); - UnboundPredicate greaterThan = Expressions.greaterThan(column, i1); - checkPushDownByPartition(table, greaterThan, 2); - - // (BucketUtil.hash(25) & Integer.MAX_VALUE) % 2 = 1 - Integer i2 = 25; - - UnboundPredicate lessThan2 = Expressions.lessThan(column, i2); - checkPushDownByPartition(table, lessThan2, 2); - UnboundPredicate equal2 = Expressions.equal(column, i2); - checkPushDownByPartition(table, equal2, 2); - UnboundPredicate greaterThan2 = Expressions.greaterThan(column, i2); - checkPushDownByPartition(table, greaterThan2, 2); - } - - private void checkPushDownByPartitionForString(Table table, String column) { - // Since the string used to create the partition is in date format, the date check can be reused directly - checkPushDownByPartitionForDt(table, column); - } - - private void checkPushDownByPartitionForTs(Table table, String column) { - String lessTs = "2023-12-11T12:34:56.123456"; - String eqTs = "2024-12-11T12:34:56.123456"; - String greaterTs = "2025-12-11T12:34:56.123456"; - - UnboundPredicate lessThan = Expressions.lessThan(column, lessTs); - checkPushDownByPartition(table, lessThan, 0); - UnboundPredicate equal = Expressions.equal(column, eqTs); - checkPushDownByPartition(table, equal, 2); - UnboundPredicate greaterThan = Expressions.greaterThan(column, greaterTs); - checkPushDownByPartition(table, greaterThan, 0); - } - - private void checkPushDownByPartitionForDt(Table table, String column) { - String less = "2023-12-11"; - String eq = "2024-12-11"; - String greater = "2025-12-11"; - - UnboundPredicate lessThan = Expressions.lessThan(column, less); - checkPushDownByPartition(table, lessThan, 0); - UnboundPredicate equal = Expressions.equal(column, eq); - checkPushDownByPartition(table, equal, 2); - UnboundPredicate greaterThan = Expressions.greaterThan(column, greater); - checkPushDownByPartition(table, greaterThan, 0); - } - - private void checkPushDownByPartition(Table table, Expression expr, Integer expectFiles) { - CloseableIterable fileScanTasks = table.newScan().filter(expr).planFiles(); - AtomicReference cnt = new AtomicReference<>(0); - fileScanTasks.forEach(notUse -> cnt.updateAndGet(v -> v + 1)); - Assert.assertEquals(expectFiles, cnt.get()); - } - - @Test - public void testUnPartitionedTable() throws UserException { - ArrayList ctdList = new ArrayList<>(); - TIcebergCommitData ctd1 = new TIcebergCommitData(); - ctd1.setFilePath("f1.parquet"); - ctd1.setFileContent(TFileContent.DATA); - ctd1.setRowCount(2); - ctd1.setFileSize(2); - - TIcebergCommitData ctd2 = new TIcebergCommitData(); - ctd2.setFilePath("f2.parquet"); - ctd2.setFileContent(TFileContent.DATA); - ctd2.setRowCount(4); - ctd2.setFileSize(4); - - ctdList.add(ctd1); - ctdList.add(ctd2); - - Table table = ops.getCatalog().loadTable(TableIdentifier.of(dbName, tbWithoutPartition)); - IcebergExternalTable icebergExternalTable = Mockito.mock(IcebergExternalTable.class); - Mockito.when(icebergExternalTable.getCatalog()).thenReturn(spyExternalCatalog); - Mockito.when(icebergExternalTable.getDbName()).thenReturn(dbName); - Mockito.when(icebergExternalTable.getName()).thenReturn(tbWithoutPartition); - - try (MockedStatic mockedStatic = Mockito.mockStatic(IcebergUtils.class)) { - mockedStatic.when(() -> IcebergUtils.getIcebergTable(ArgumentMatchers.any(ExternalTable.class))) - .thenReturn(table); - - IcebergTransaction txn = getTxn(); - txn.updateIcebergCommitData(ctdList); - txn.beginInsert(icebergExternalTable, Optional.empty()); - txn.finishInsert(NameMapping.createForTest(dbName, tbWithPartition)); - txn.commit(); - } - - checkSnapshotAddProperties(table.currentSnapshot().summary(), "6", "2", "6"); - } - - private IcebergTransaction getTxn() { - return new IcebergTransaction(ops); - } - - private void checkSnapshotAddProperties(Map props, - String addRecords, - String addFileCnt, - String addFileSize) { - Assert.assertEquals(addRecords, props.get("added-records")); - Assert.assertEquals(addFileCnt, props.get("added-data-files")); - Assert.assertEquals(addFileSize, props.get("added-files-size")); - } - - private void checkSnapshotTotalProperties(Map props, - String totalRecords, - String totalFileCnt, - String totalFileSize) { - Assert.assertEquals(totalRecords, props.get("total-records")); - Assert.assertEquals(totalFileCnt, props.get("total-data-files")); - Assert.assertEquals(totalFileSize, props.get("total-files-size")); - } - - private String numToYear(Integer num) { - Transform year = Transforms.year(); - return year.toHumanString(Types.IntegerType.get(), num); - } - - private String numToMonth(Integer num) { - Transform month = Transforms.month(); - return month.toHumanString(Types.IntegerType.get(), num); - } - - private String numToDay(Integer num) { - Transform day = Transforms.day(); - return day.toHumanString(Types.IntegerType.get(), num); - } - - private String numToHour(Integer num) { - Transform hour = Transforms.hour(); - return hour.toHumanString(Types.IntegerType.get(), num); - } - - @Test - public void tableCloneTest() { - Table table = ops.getCatalog().loadTable(TableIdentifier.of(dbName, tbWithoutPartition)); - Table cloneTable = (Table) SerializationUtils.clone((Serializable) table); - Assert.assertNotNull(cloneTable); - } - - @Test - public void testTransform() { - Instant instant = Instant.parse("2024-12-11T12:34:56.123456Z"); - long ts = DateTimeUtil.microsFromInstant(instant); - Assert.assertEquals("2024", numToYear(DateTimeUtil.microsToYears(ts))); - Assert.assertEquals("2024-12", numToMonth(DateTimeUtil.microsToMonths(ts))); - Assert.assertEquals("2024-12-11", numToDay(DateTimeUtil.microsToDays(ts))); - Assert.assertEquals("2024-12-11-12", numToHour(DateTimeUtil.microsToHours(ts))); - - int dt = DateTimeUtil.daysFromInstant(instant); - Assert.assertEquals("2024", numToYear(DateTimeUtil.daysToYears(dt))); - Assert.assertEquals("2024-12", numToMonth(DateTimeUtil.daysToMonths(dt))); - Assert.assertEquals("2024-12-11", numToDay(dt)); - } - - @Test - public void testUnPartitionedTableOverwriteWithData() throws UserException { - - testUnPartitionedTable(); - - ArrayList ctdList = new ArrayList<>(); - TIcebergCommitData ctd1 = new TIcebergCommitData(); - ctd1.setFilePath("f3.parquet"); - ctd1.setFileContent(TFileContent.DATA); - ctd1.setRowCount(6); - ctd1.setFileSize(6); - - TIcebergCommitData ctd2 = new TIcebergCommitData(); - ctd2.setFilePath("f4.parquet"); - ctd2.setFileContent(TFileContent.DATA); - ctd2.setRowCount(8); - ctd2.setFileSize(8); - - TIcebergCommitData ctd3 = new TIcebergCommitData(); - ctd3.setFilePath("f5.parquet"); - ctd3.setFileContent(TFileContent.DATA); - ctd3.setRowCount(10); - ctd3.setFileSize(10); - - ctdList.add(ctd1); - ctdList.add(ctd2); - ctdList.add(ctd3); - - Table table = ops.getCatalog().loadTable(TableIdentifier.of(dbName, tbWithoutPartition)); - IcebergExternalTable icebergExternalTable = Mockito.mock(IcebergExternalTable.class); - Mockito.when(icebergExternalTable.getCatalog()).thenReturn(spyExternalCatalog); - Mockito.when(icebergExternalTable.getDbName()).thenReturn(dbName); - Mockito.when(icebergExternalTable.getName()).thenReturn(tbWithoutPartition); - try (MockedStatic mockedStatic = Mockito.mockStatic(IcebergUtils.class)) { - mockedStatic.when(() -> IcebergUtils.getIcebergTable(ArgumentMatchers.any(ExternalTable.class))) - .thenReturn(table); - - IcebergTransaction txn = getTxn(); - txn.updateIcebergCommitData(ctdList); - IcebergInsertCommandContext ctx = new IcebergInsertCommandContext(); - txn.beginInsert(icebergExternalTable, Optional.of(ctx)); - ctx.setOverwrite(true); - txn.finishInsert(NameMapping.createForTest(dbName, tbWithPartition)); - txn.commit(); - } - - checkSnapshotTotalProperties(table.currentSnapshot().summary(), "24", "3", "24"); - } - - @Test - public void testUnpartitionedTableOverwriteWithoutData() throws UserException { - - testUnPartitionedTableOverwriteWithData(); - - Table table = ops.getCatalog().loadTable(TableIdentifier.of(dbName, tbWithoutPartition)); - IcebergExternalTable icebergExternalTable = Mockito.mock(IcebergExternalTable.class); - Mockito.when(icebergExternalTable.getCatalog()).thenReturn(spyExternalCatalog); - Mockito.when(icebergExternalTable.getDbName()).thenReturn(dbName); - Mockito.when(icebergExternalTable.getName()).thenReturn(tbWithoutPartition); - try (MockedStatic mockedStatic = Mockito.mockStatic(IcebergUtils.class)) { - mockedStatic.when(() -> IcebergUtils.getIcebergTable(ArgumentMatchers.any(ExternalTable.class))) - .thenReturn(table); - - IcebergTransaction txn = getTxn(); - IcebergInsertCommandContext ctx = new IcebergInsertCommandContext(); - txn.beginInsert(icebergExternalTable, Optional.of(ctx)); - ctx.setOverwrite(true); - txn.finishInsert(NameMapping.createForTest(dbName, tbWithPartition)); - txn.commit(); - } - - checkSnapshotTotalProperties(table.currentSnapshot().summary(), "0", "0", "0"); - } - - @Test - public void testFinishDeleteDoesNotRewritePreviousDeleteFilesForV2() throws UserException { - verifyFinishDeleteRewriteBehavior(2, false); - } - - @Test - public void testFinishDeleteRewritesAllSharedPuffinDeleteFilesForV3() throws UserException { - String referencedDataFile = "s3a://warehouse/wh/db3/tbWithoutPartition/data/data-file.parquet"; - - Table icebergTable = Mockito.mock(Table.class); - org.apache.iceberg.Transaction icebergTxn = Mockito.mock(org.apache.iceberg.Transaction.class); - RowDelta rowDelta = Mockito.mock(RowDelta.class, Mockito.RETURNS_SELF); - DeleteFile newDeleteFile = Mockito.mock(DeleteFile.class); - DeleteFile oldDeleteFile1 = buildDeletionVectorDeleteFile( - "s3a://warehouse/wh/db3/tbWithoutPartition/data/delete-shared.puffin", - referencedDataFile, 4L, 21L); - DeleteFile oldDeleteFile2 = buildDeletionVectorDeleteFile( - "s3a://warehouse/wh/db3/tbWithoutPartition/data/delete-shared.puffin", - referencedDataFile, 25L, 19L); - IcebergExternalTable icebergExternalTable = Mockito.mock(IcebergExternalTable.class); - - PartitionSpec spec = PartitionSpec.unpartitioned(); - Mockito.when(icebergTable.newTransaction()).thenReturn(icebergTxn); - Mockito.when(icebergTable.currentSnapshot()).thenReturn(null); - Mockito.when(icebergTable.spec()).thenReturn(spec); - Mockito.when(icebergTable.specs()).thenReturn(Collections.singletonMap(spec.specId(), spec)); - Mockito.when(icebergTable.properties()).thenReturn(Collections.emptyMap()); - Mockito.when(icebergTable.name()).thenReturn(tbWithoutPartition); - Mockito.when(icebergTxn.table()).thenReturn(icebergTable); - Mockito.when(icebergTxn.newRowDelta()).thenReturn(rowDelta); - Mockito.when(newDeleteFile.path()).thenReturn("s3a://warehouse/wh/db3/tbWithoutPartition/data/delete-new.puffin"); - - Mockito.when(icebergExternalTable.getCatalog()).thenReturn(spyExternalCatalog); - Mockito.when(icebergExternalTable.getName()).thenReturn(tbWithoutPartition); - - TIcebergCommitData commitData = new TIcebergCommitData(); - commitData.setFilePath("delete-dv-shared.puffin"); - commitData.setFileContent(TFileContent.POSITION_DELETES); - commitData.setRowCount(3); - commitData.setFileSize(44); - commitData.setContentOffset(4); - commitData.setContentSizeInBytes(21); - commitData.setReferencedDataFilePath(referencedDataFile); - - IcebergTransaction txn = getTxn(); - txn.updateIcebergCommitData(Collections.singletonList(commitData)); - - try (MockedStatic mockedUtils = Mockito.mockStatic(IcebergUtils.class); - MockedStatic mockedWriterHelper = - Mockito.mockStatic(IcebergWriterHelper.class)) { - mockedUtils.when(() -> IcebergUtils.getIcebergTable(ArgumentMatchers.any(ExternalTable.class))) - .thenReturn(icebergTable); - mockedUtils.when(() -> IcebergUtils.getFileFormat(icebergTable)).thenReturn(FileFormat.PARQUET); - mockedUtils.when(() -> IcebergUtils.getFormatVersion(icebergTable)).thenReturn(3); - mockedWriterHelper.when(() -> IcebergWriterHelper.convertToDeleteFiles( - ArgumentMatchers.any(FileFormat.class), - ArgumentMatchers.eq(spec), - ArgumentMatchers.anyList())) - .thenReturn(Collections.singletonList(newDeleteFile)); - - txn.beginDelete(icebergExternalTable); - txn.setRewrittenDeleteFilesByReferencedDataFile( - Collections.singletonMap(referencedDataFile, Arrays.asList(oldDeleteFile1, oldDeleteFile2))); - txn.finishDelete(NameMapping.createForTest(dbName, tbWithoutPartition)); - } - - Mockito.verify(rowDelta).addDeletes(newDeleteFile); - Mockito.verify(rowDelta).removeDeletes(oldDeleteFile1); - Mockito.verify(rowDelta).removeDeletes(oldDeleteFile2); - Mockito.verify(rowDelta).commit(); - } - - private void verifyFinishDeleteRewriteBehavior(int formatVersion, boolean expectRewrite) - throws UserException { - String referencedDataFile = "s3a://warehouse/wh/db3/tbWithoutPartition/data/data-file.parquet"; - - Table icebergTable = Mockito.mock(Table.class); - org.apache.iceberg.Transaction icebergTxn = Mockito.mock(org.apache.iceberg.Transaction.class); - RowDelta rowDelta = Mockito.mock(RowDelta.class, Mockito.RETURNS_SELF); - DeleteFile newDeleteFile = Mockito.mock(DeleteFile.class); - DeleteFile oldDeleteFile = Mockito.mock(DeleteFile.class); - IcebergExternalTable icebergExternalTable = Mockito.mock(IcebergExternalTable.class); - - PartitionSpec spec = PartitionSpec.unpartitioned(); - Mockito.when(icebergTable.newTransaction()).thenReturn(icebergTxn); - Mockito.when(icebergTable.currentSnapshot()).thenReturn(null); - Mockito.when(icebergTable.spec()).thenReturn(spec); - Mockito.when(icebergTable.specs()).thenReturn(Collections.singletonMap(spec.specId(), spec)); - Mockito.when(icebergTable.properties()).thenReturn(Collections.emptyMap()); - Mockito.when(icebergTable.name()).thenReturn(tbWithoutPartition); - Mockito.when(icebergTxn.table()).thenReturn(icebergTable); - Mockito.when(icebergTxn.newRowDelta()).thenReturn(rowDelta); - Mockito.when(newDeleteFile.path()).thenReturn("s3a://warehouse/wh/db3/tbWithoutPartition/data/delete-new.puffin"); - Mockito.when(oldDeleteFile.path()).thenReturn("s3a://warehouse/wh/db3/tbWithoutPartition/data/delete-old.parquet"); - - Mockito.when(icebergExternalTable.getCatalog()).thenReturn(spyExternalCatalog); - Mockito.when(icebergExternalTable.getName()).thenReturn(tbWithoutPartition); - - TIcebergCommitData commitData = new TIcebergCommitData(); - commitData.setFilePath("delete-dv.puffin"); - commitData.setFileContent(TFileContent.POSITION_DELETES); - commitData.setRowCount(3); - commitData.setFileSize(33); - commitData.setReferencedDataFilePath(referencedDataFile); - - IcebergTransaction txn = getTxn(); - txn.updateIcebergCommitData(Collections.singletonList(commitData)); - - try (MockedStatic mockedUtils = Mockito.mockStatic(IcebergUtils.class); - MockedStatic mockedWriterHelper = - Mockito.mockStatic(IcebergWriterHelper.class)) { - mockedUtils.when(() -> IcebergUtils.getIcebergTable(ArgumentMatchers.any(ExternalTable.class))) - .thenReturn(icebergTable); - mockedUtils.when(() -> IcebergUtils.getFileFormat(icebergTable)).thenReturn(FileFormat.PARQUET); - mockedUtils.when(() -> IcebergUtils.getFormatVersion(icebergTable)).thenReturn(formatVersion); - mockedWriterHelper.when(() -> IcebergWriterHelper.convertToDeleteFiles( - ArgumentMatchers.any(FileFormat.class), - ArgumentMatchers.eq(spec), - ArgumentMatchers.anyList())) - .thenReturn(Collections.singletonList(newDeleteFile)); - - txn.beginDelete(icebergExternalTable); - txn.setRewrittenDeleteFilesByReferencedDataFile( - Collections.singletonMap(referencedDataFile, Collections.singletonList(oldDeleteFile))); - txn.finishDelete(NameMapping.createForTest(dbName, tbWithoutPartition)); - } - - Mockito.verify(rowDelta).addDeletes(newDeleteFile); - if (expectRewrite) { - Mockito.verify(rowDelta).removeDeletes(oldDeleteFile); - } else { - Mockito.verify(rowDelta, Mockito.never()).removeDeletes(ArgumentMatchers.any(DeleteFile.class)); - } - Mockito.verify(rowDelta).commit(); - } - - private DeleteFile buildDeletionVectorDeleteFile(String puffinPath, String referencedDataFile, - long contentOffset, long contentLength) { - return FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) - .ofPositionDeletes() - .withPath(puffinPath) - .withFormat(FileFormat.PUFFIN) - .withFileSizeInBytes(128) - .withRecordCount(2) - .withContentOffset(contentOffset) - .withContentSizeInBytes(contentLength) - .withReferencedDataFile(referencedDataFile) - .build(); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergUtilsTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergUtilsTest.java deleted file mode 100644 index 349e1e0bb82ab1..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergUtilsTest.java +++ /dev/null @@ -1,874 +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.datasource.iceberg; - -import org.apache.doris.analysis.TableScanParams; -import org.apache.doris.analysis.TableSnapshot; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.Type; -import org.apache.doris.common.UserException; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; -import org.apache.doris.datasource.DelegatedCredential; -import org.apache.doris.datasource.ExternalMetaCacheMgr; -import org.apache.doris.datasource.ExternalTable; -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.datasource.iceberg.source.IcebergTableQueryInfo; -import org.apache.doris.qe.ConnectContext; - -import com.google.common.collect.ImmutableMap; -import org.apache.iceberg.GenericPartitionFieldSummary; -import org.apache.iceberg.HistoryEntry; -import org.apache.iceberg.ManifestContent; -import org.apache.iceberg.ManifestFile; -import org.apache.iceberg.ManifestFile.PartitionFieldSummary; -import org.apache.iceberg.MetadataColumns; -import org.apache.iceberg.PartitionData; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Schema; -import org.apache.iceberg.Snapshot; -import org.apache.iceberg.SnapshotRef; -import org.apache.iceberg.Table; -import org.apache.iceberg.TableProperties; -import org.apache.iceberg.expressions.Expression; -import org.apache.iceberg.expressions.Expressions; -import org.apache.iceberg.expressions.UnboundPredicate; -import org.apache.iceberg.hive.HiveCatalog; -import org.apache.iceberg.io.CloseableIterable; -import org.apache.iceberg.types.Conversions; -import org.apache.iceberg.types.Types; -import org.apache.iceberg.types.Types.LongType; -import org.apache.iceberg.types.Types.StructType; -import org.apache.iceberg.view.View; -import org.junit.Assert; -import org.junit.Test; -import org.mockito.MockedStatic; -import org.mockito.Mockito; - -import java.lang.reflect.Field; -import java.nio.ByteBuffer; -import java.time.DateTimeException; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -public class IcebergUtilsTest { - @Test - public void testGetFileFormatUsesPropertiesWithoutPlanningDataFiles() { - Table table = Mockito.mock(Table.class); - Mockito.when(table.properties()).thenReturn(Collections.emptyMap()); - Mockito.when(table.currentSnapshot()).thenReturn(Mockito.mock(Snapshot.class)); - - Assert.assertEquals(org.apache.iceberg.FileFormat.PARQUET, IcebergUtils.getFileFormat(table)); - // Do not call newScan planFiles() - Mockito.verify(table, Mockito.never()).newScan(); - } - - @Test - public void testGetFileFormatUsesConfiguredTableFormat() { - Table table = Mockito.mock(Table.class); - Mockito.when(table.properties()).thenReturn( - ImmutableMap.of(TableProperties.DEFAULT_FILE_FORMAT, "orc")); - - Assert.assertEquals(org.apache.iceberg.FileFormat.ORC, IcebergUtils.getFileFormat(table)); - // Do not call newScan planFiles() - Mockito.verify(table, Mockito.never()).newScan(); - } - - @Test - public void testGetIcebergViewUsesSessionCatalogWithDelegatedCredential() { - ConnectContext context = new ConnectContext(); - SessionContext sessionContext = SessionContext.of(new DelegatedCredential( - DelegatedCredential.Type.ACCESS_TOKEN, "delegated-access-token")); - context.setSessionContext(sessionContext); - context.setThreadLocalInfo(); - - ExternalTable dorisTable = Mockito.mock(ExternalTable.class); - IcebergRestExternalCatalog catalog = Mockito.mock(IcebergRestExternalCatalog.class); - IcebergMetadataOps ops = Mockito.mock(IcebergMetadataOps.class); - View delegatedView = Mockito.mock(View.class); - View cachedView = Mockito.mock(View.class); - Mockito.when(dorisTable.getCatalog()).thenReturn(catalog); - Mockito.when(dorisTable.getRemoteDbName()).thenReturn("db"); - Mockito.when(dorisTable.getRemoteName()).thenReturn("view1"); - Mockito.when(catalog.useSessionCatalog(Mockito.any())).thenReturn(true); - Mockito.when(catalog.getMetadataOps()).thenReturn(ops); - Mockito.when(catalog.getId()).thenReturn(1L); - Mockito.when(ops.loadView(Mockito.same(sessionContext), Mockito.eq("db"), Mockito.eq("view1"))) - .thenReturn(delegatedView); - - Env env = Mockito.mock(Env.class); - ExternalMetaCacheMgr cacheMgr = Mockito.mock(ExternalMetaCacheMgr.class); - IcebergExternalMetaCache cache = Mockito.mock(IcebergExternalMetaCache.class); - Mockito.when(env.getExtMetaCacheMgr()).thenReturn(cacheMgr); - Mockito.when(cacheMgr.iceberg(1L)).thenReturn(cache); - Mockito.when(cache.getIcebergView(dorisTable)).thenReturn(cachedView); - - try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { - mockedEnv.when(Env::getCurrentEnv).thenReturn(env); - - Assert.assertSame(delegatedView, IcebergUtils.getIcebergView(dorisTable)); - Mockito.verify(cache, Mockito.never()).getIcebergView(dorisTable); - } finally { - ConnectContext.remove(); - } - } - - @Test - public void testGetIcebergSchemaUsesSessionCatalogForView() { - ConnectContext context = new ConnectContext(); - SessionContext sessionContext = SessionContext.of(new DelegatedCredential( - DelegatedCredential.Type.ACCESS_TOKEN, "delegated-access-token")); - context.setSessionContext(sessionContext); - context.setThreadLocalInfo(); - - ExternalTable dorisTable = Mockito.mock(ExternalTable.class); - IcebergRestExternalCatalog catalog = Mockito.mock(IcebergRestExternalCatalog.class); - IcebergMetadataOps ops = Mockito.mock(IcebergMetadataOps.class); - View delegatedView = Mockito.mock(View.class); - Mockito.when(dorisTable.getCatalog()).thenReturn(catalog); - Mockito.when(dorisTable.getRemoteDbName()).thenReturn("db"); - Mockito.when(dorisTable.getRemoteName()).thenReturn("view1"); - Mockito.when(dorisTable.isView()).thenReturn(true); - Mockito.when(catalog.useSessionCatalog(Mockito.any())).thenReturn(true); - Mockito.when(catalog.getMetadataOps()).thenReturn(ops); - Mockito.when(catalog.getExecutionAuthenticator()).thenReturn(new ExecutionAuthenticator() {}); - Mockito.when(ops.loadView(Mockito.same(sessionContext), Mockito.eq("db"), Mockito.eq("view1"))) - .thenReturn(delegatedView); - Mockito.when(delegatedView.schema()).thenReturn(new Schema( - Types.NestedField.required(1, "c1", Types.IntegerType.get()))); - - try { - List schema = IcebergUtils.getIcebergSchema(dorisTable); - - Assert.assertEquals(1, schema.size()); - Assert.assertEquals("c1", schema.get(0).getName()); - Mockito.verify(ops, Mockito.never()).loadTable(Mockito.any(), Mockito.anyString(), Mockito.anyString()); - } finally { - ConnectContext.remove(); - } - } - - @Test - public void testParseTableName() { - try { - IcebergHMSExternalCatalog c1 = - new IcebergHMSExternalCatalog(1, "name", null, new HashMap<>(), ""); - HiveCatalog i1 = IcebergUtils.createIcebergHiveCatalog(c1, "i1"); - Assert.assertTrue(getListAllTables(i1)); - - IcebergHMSExternalCatalog c2 = - new IcebergHMSExternalCatalog(1, "name", null, - new HashMap() {{ - put("list-all-tables", "true"); - put("type", "hms"); - put("hive.metastore.uris", "http://127.1.1.0:9000"); - }}, - ""); - HiveCatalog i2 = IcebergUtils.createIcebergHiveCatalog(c2, "i1"); - Assert.assertTrue(getListAllTables(i2)); - - IcebergHMSExternalCatalog c3 = - new IcebergHMSExternalCatalog(1, "name", null, - new HashMap() {{ - put("list-all-tables", "false"); - put("type", "hms"); - put("hive.metastore.uris", "http://127.1.1.0:9000"); - }}, - ""); - HiveCatalog i3 = IcebergUtils.createIcebergHiveCatalog(c3, "i1"); - Assert.assertFalse(getListAllTables(i3)); - } catch (Exception e) { - e.printStackTrace(); - Assert.fail(); - } - } - - private boolean getListAllTables(HiveCatalog hiveCatalog) throws IllegalAccessException, NoSuchFieldException { - Field declaredField = hiveCatalog.getClass().getDeclaredField("listAllTables"); - declaredField.setAccessible(true); - return declaredField.getBoolean(hiveCatalog); - } - - @Test - public void testDataLocationUsesLegacyObjectStorePath() { - Table table = Mockito.mock(Table.class); - Mockito.when(table.properties()).thenReturn(ImmutableMap.of( - TableProperties.OBJECT_STORE_ENABLED, "true", - TableProperties.OBJECT_STORE_PATH, "s3://bucket/legacy-object-store", - TableProperties.WRITE_FOLDER_STORAGE_LOCATION, "s3://bucket/folder-storage")); - - Assert.assertEquals("s3://bucket/legacy-object-store", IcebergUtils.dataLocation(table)); - } - - @Test - public void testDataLocationPrefersWriteDataPathOverLegacyObjectStorePath() { - Table table = Mockito.mock(Table.class); - Mockito.when(table.properties()).thenReturn(ImmutableMap.of( - TableProperties.WRITE_DATA_LOCATION, "s3://bucket/data-path", - TableProperties.OBJECT_STORE_PATH, "s3://bucket/legacy-object-store")); - - Assert.assertEquals("s3://bucket/data-path", IcebergUtils.dataLocation(table)); - } - - @Test - public void testDataLocationIgnoresObjectStorePathWhenObjectStoreDisabled() { - Table table = Mockito.mock(Table.class); - Mockito.when(table.properties()).thenReturn(ImmutableMap.of( - TableProperties.OBJECT_STORE_ENABLED, "false", - TableProperties.OBJECT_STORE_PATH, "s3://bucket/legacy-object-store", - TableProperties.WRITE_FOLDER_STORAGE_LOCATION, "s3://bucket/folder-storage")); - - Assert.assertEquals("s3://bucket/folder-storage", IcebergUtils.dataLocation(table)); - } - - @Test - public void testDataLocationIgnoresObjectStorePathWhenObjectStoreUnset() { - Table table = Mockito.mock(Table.class); - Mockito.when(table.properties()).thenReturn(ImmutableMap.of( - TableProperties.OBJECT_STORE_PATH, "s3://bucket/legacy-object-store", - TableProperties.WRITE_FOLDER_STORAGE_LOCATION, "s3://bucket/folder-storage")); - - Assert.assertEquals("s3://bucket/folder-storage", IcebergUtils.dataLocation(table)); - } - - @Test - public void testIsIcebergRowLineageColumn() { - Column rowIdColumn = new Column(IcebergUtils.ICEBERG_ROW_ID_COL, Type.BIGINT, true); - Column sequenceColumn = new Column(IcebergUtils.ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL, Type.BIGINT, true); - Column normalColumn = new Column("id", Type.INT, true); - - Assert.assertTrue(IcebergUtils.isIcebergRowLineageColumn(rowIdColumn)); - Assert.assertTrue(IcebergUtils.isIcebergRowLineageColumn(sequenceColumn)); - Assert.assertTrue(IcebergUtils.isIcebergRowLineageColumn("_ROW_ID")); - Assert.assertFalse(IcebergUtils.isIcebergRowLineageColumn(normalColumn)); - Assert.assertFalse(IcebergUtils.isIcebergRowLineageColumn("id")); - } - - @Test - public void testAppendRowLineageColumnsForV3AddsInvisibleColumns() { - List schema = new ArrayList<>(); - schema.add(new Column("id", Type.INT, true)); - Table table = Mockito.mock(Table.class); - Mockito.when(table.properties()).thenReturn(ImmutableMap.of("format-version", "3")); - - List schemaWithRowLineage = IcebergUtils.appendRowLineageColumnsForV3(schema, table); - - Assert.assertEquals(3, schemaWithRowLineage.size()); - Assert.assertEquals(IcebergUtils.ICEBERG_ROW_ID_COL, schemaWithRowLineage.get(1).getName()); - Assert.assertEquals(IcebergUtils.ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL, - schemaWithRowLineage.get(2).getName()); - Assert.assertFalse(schemaWithRowLineage.get(1).isVisible()); - Assert.assertFalse(schemaWithRowLineage.get(2).isVisible()); - } - - @Test - public void testAppendRowLineageColumnsForV2ReturnsOriginalSchema() { - List schema = new ArrayList<>(); - schema.add(new Column("id", Type.INT, true)); - Table table = Mockito.mock(Table.class); - Mockito.when(table.properties()).thenReturn(ImmutableMap.of("format-version", "2")); - - List schemaWithRowLineage = IcebergUtils.appendRowLineageColumnsForV3(schema, table); - - Assert.assertSame(schema, schemaWithRowLineage); - Assert.assertEquals(1, schemaWithRowLineage.size()); - } - - @Test - public void testAppendRowLineageFieldsForV3AddsMetadataFields() { - Schema schema = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); - - Schema schemaWithRowLineage = IcebergUtils.appendRowLineageFieldsForV3(schema); - - Assert.assertNotNull(schemaWithRowLineage.findField(MetadataColumns.ROW_ID.fieldId())); - Assert.assertNotNull(schemaWithRowLineage.findField(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.fieldId())); - } - - @Test - public void testParseSchemaPreservesNonLowercaseColumnNames() { - Schema schema = new Schema( - Types.NestedField.required(1, "mIxEd_COL", Types.IntegerType.get()), - Types.NestedField.required(2, "PART", Types.StringType.get())); - - List columns = IcebergUtils.parseSchema(schema, false, false); - - Assert.assertEquals("mIxEd_COL", columns.get(0).getName()); - Assert.assertEquals("PART", columns.get(1).getName()); - } - - @Test - public void testParseSchemaPreservesInitialDefault() { - Schema schema = new Schema( - Types.NestedField.optional("added_column") - .withId(1) - .ofType(Types.IntegerType.get()) - .withInitialDefault(7) - .build(), - Types.NestedField.optional("added_timestamp") - .withId(2) - .ofType(Types.TimestampType.withoutZone()) - .withInitialDefault(1_704_067_200_123_456L) - .build(), - Types.NestedField.optional("added_uuid") - .withId(3) - .ofType(Types.UUIDType.get()) - .withInitialDefault(UUID.fromString("00000000-0000-0000-0000-000000000000")) - .build(), - Types.NestedField.optional("added_binary") - .withId(4) - .ofType(Types.BinaryType.get()) - .withInitialDefault(ByteBuffer.wrap(new byte[] {0, 1, 2, (byte) 0xFF})) - .build(), - Types.NestedField.optional("added_fixed") - .withId(5) - .ofType(Types.FixedType.ofLength(4)) - .withInitialDefault(ByteBuffer.wrap(new byte[] {3, 2, 1, 0})) - .build()); - - List columns = IcebergUtils.parseSchema(schema, true, false); - - Assert.assertEquals("7", columns.get(0).getDefaultValue()); - Assert.assertEquals("2024-01-01 00:00:00.123456", columns.get(1).getDefaultValue()); - Assert.assertEquals("AAAAAAAAAAAAAAAAAAAAAA==", columns.get(2).getDefaultValue()); - Assert.assertEquals("AAEC/w==", columns.get(3).getDefaultValue()); - - Map base64Defaults = IcebergUtils.getBase64EncodedInitialDefaults(schema); - Assert.assertEquals("AAAAAAAAAAAAAAAAAAAAAA==", base64Defaults.get(3)); - Assert.assertEquals("AAEC/w==", base64Defaults.get(4)); - Assert.assertEquals("AwIBAA==", base64Defaults.get(5)); - } - - @Test - public void testGetPartitionInfoMapSkipBinaryIdentityPartition() { - Schema schema = new Schema( - Types.NestedField.required(1, "id", Types.IntegerType.get()), - Types.NestedField.required(2, "partition_bin", Types.BinaryType.get())); - PartitionSpec partitionSpec = PartitionSpec.builderFor(schema).identity("partition_bin").build(); - PartitionData partitionData = new PartitionData(partitionSpec.partitionType()); - partitionData.set(0, ByteBuffer.wrap(new byte[] {0x0F, (byte) 0xF1, 0x02, (byte) 0xFD, (byte) 0xFE, - (byte) 0xFF})); - - Map partitionInfoMap = IcebergUtils.getPartitionInfoMap(partitionData, partitionSpec, "UTC"); - Assert.assertNull(partitionInfoMap); - } - - @Test - public void testGetIdentityPartitionColumnsIgnoresTransformPartitions() { - Schema schema = new Schema( - Types.NestedField.required(1, "id", Types.IntegerType.get()), - Types.NestedField.required(2, "Dt", Types.StringType.get()), - Types.NestedField.required(3, "ts", Types.TimestampType.withoutZone())); - PartitionSpec specWithTransform = PartitionSpec.builderFor(schema) - .withSpecId(1) - .identity("Dt") - .day("ts") - .build(); - PartitionSpec identityOnlySpec = PartitionSpec.builderFor(schema) - .withSpecId(2) - .identity("id") - .build(); - Map specs = new LinkedHashMap<>(); - specs.put(specWithTransform.specId(), specWithTransform); - specs.put(identityOnlySpec.specId(), identityOnlySpec); - - Table table = Mockito.mock(Table.class); - Mockito.when(table.schema()).thenReturn(schema); - Mockito.when(table.specs()).thenReturn(specs); - - Assert.assertEquals(Arrays.asList("Dt", "id"), IcebergUtils.getIdentityPartitionColumns(table)); - } - - @Test - public void testGetIdentityPartitionInfoMapReturnsIdentityColumnsOnly() { - Schema schema = new Schema( - Types.NestedField.required(1, "Dt", Types.StringType.get()), - Types.NestedField.required(2, "ts", Types.TimestampType.withoutZone())); - PartitionSpec partitionSpec = PartitionSpec.builderFor(schema) - .identity("Dt") - .day("ts") - .build(); - PartitionData partitionData = new PartitionData(partitionSpec.partitionType()); - partitionData.set(0, "2025-01-01"); - partitionData.set(1, 20000); - - Table table = Mockito.mock(Table.class); - Mockito.when(table.schema()).thenReturn(schema); - - Map partitionInfoMap = IcebergUtils.getIdentityPartitionInfoMap( - partitionData, partitionSpec, table, "UTC"); - Assert.assertEquals(Collections.singletonMap("Dt", "2025-01-01"), partitionInfoMap); - } - - @Test - public void testGetIdentityPartitionInfoMapSupportsFloatingPointPartitions() { - Schema schema = new Schema( - Types.NestedField.required(1, "float_partition", Types.FloatType.get()), - Types.NestedField.required(2, "double_partition", Types.DoubleType.get())); - PartitionSpec partitionSpec = PartitionSpec.builderFor(schema) - .identity("float_partition") - .identity("double_partition") - .build(); - float floatValue = Math.nextUp(0.1F); - double doubleValue = Math.nextUp(0.1D); - PartitionData partitionData = new PartitionData(partitionSpec.partitionType()); - partitionData.set(0, floatValue); - partitionData.set(1, doubleValue); - - Table table = Mockito.mock(Table.class); - Mockito.when(table.schema()).thenReturn(schema); - - Map partitionInfoMap = IcebergUtils.getIdentityPartitionInfoMap( - partitionData, partitionSpec, table, "UTC"); - - String serializedFloat = partitionInfoMap.get("float_partition"); - String serializedDouble = partitionInfoMap.get("double_partition"); - Assert.assertEquals(Float.toString(floatValue), serializedFloat); - Assert.assertEquals(Double.toString(doubleValue), serializedDouble); - Assert.assertEquals(Float.floatToIntBits(floatValue), - Float.floatToIntBits(Float.parseFloat(serializedFloat))); - Assert.assertEquals(Double.doubleToLongBits(doubleValue), - Double.doubleToLongBits(Double.parseDouble(serializedDouble))); - } - - @Test - public void testParseFloatingPointPartitionValueSupportsSpecialValues() { - Assert.assertTrue(Float.isNaN( - (Float) IcebergUtils.parsePartitionValueFromString("NaN", Types.FloatType.get()))); - Assert.assertTrue(Float.isNaN( - (Float) IcebergUtils.parsePartitionValueFromString("nan", Types.FloatType.get()))); - Assert.assertEquals(Float.POSITIVE_INFINITY, - (Float) IcebergUtils.parsePartitionValueFromString("Infinity", Types.FloatType.get()), 0.0F); - Assert.assertEquals(Float.NEGATIVE_INFINITY, - (Float) IcebergUtils.parsePartitionValueFromString("-inf", Types.FloatType.get()), 0.0F); - Assert.assertTrue(Double.isNaN( - (Double) IcebergUtils.parsePartitionValueFromString("NaN", Types.DoubleType.get()))); - Assert.assertTrue(Double.isNaN( - (Double) IcebergUtils.parsePartitionValueFromString("nan", Types.DoubleType.get()))); - Assert.assertEquals(Double.POSITIVE_INFINITY, - (Double) IcebergUtils.parsePartitionValueFromString("Infinity", Types.DoubleType.get()), 0.0D); - Assert.assertEquals(Double.NEGATIVE_INFINITY, - (Double) IcebergUtils.parsePartitionValueFromString("-inf", Types.DoubleType.get()), 0.0D); - } - - @Test - public void testGetMatchingManifest() { - - // partition : 100 - 200 - ManifestFile f1 = getManifestFileForDataTypeWithPartitionSummary( - "manifest_f1.avro", - Collections.singletonList(new GenericPartitionFieldSummary( - false, false, getByteBufferForLong(100), getByteBufferForLong(200)))); - - // partition : 300 - 400 - ManifestFile f2 = getManifestFileForDataTypeWithPartitionSummary( - "manifest_f2.avro", - Collections.singletonList(new GenericPartitionFieldSummary( - false, false, getByteBufferForLong(300), getByteBufferForLong(400)))); - - // partition : 500 - 600 - ManifestFile f3 = getManifestFileForDataTypeWithPartitionSummary( - "manifest_f3.avro", - Collections.singletonList(new GenericPartitionFieldSummary( - false, false, getByteBufferForLong(500), getByteBufferForLong(600)))); - - List manifestFiles = new ArrayList() {{ - add(f1); - add(f2); - add(f3); - }}; - - Schema schema = new Schema( - StructType.of( - Types.NestedField.required(1, "id", LongType.get()), - Types.NestedField.required(2, "data", LongType.get()), - Types.NestedField.required(3, "par", LongType.get())) - .fields()); - - // test empty partition spec - HashMap emptyPartitionSpecsById = new HashMap() {{ - put(0, PartitionSpec.builderFor(schema).build()); - }}; - assertManifest(manifestFiles, emptyPartitionSpecsById, Expressions.alwaysTrue(), manifestFiles); - - // test long partition spec - HashMap longPartitionSpecsById = new HashMap() {{ - put(0, PartitionSpec.builderFor(schema).identity("par").build()); - }}; - // 1. par > 10 - UnboundPredicate e1 = Expressions.greaterThan("par", 10L); - assertManifest(manifestFiles, longPartitionSpecsById, Expressions.and(Expressions.alwaysTrue(), e1), manifestFiles); - - // 2. 10 < par < 90 - UnboundPredicate e2 = Expressions.greaterThan("par", 90L); - assertManifest(manifestFiles, longPartitionSpecsById, Expressions.and(e1, e2), manifestFiles); - - // 3. 10 < par < 300 - UnboundPredicate e3 = Expressions.lessThan("par", 300L); - assertManifest(manifestFiles, longPartitionSpecsById, Expressions.and(e1, e3), Collections.singletonList(f1)); - - // 4. 10 < par < 400 - UnboundPredicate e4 = Expressions.lessThan("par", 400L); - ArrayList expect1 = new ArrayList() {{ - add(f1); - add(f2); - }}; - assertManifest(manifestFiles, longPartitionSpecsById, Expressions.and(e1, e4), expect1); - - // 5. 10 < par < 501 - UnboundPredicate e5 = Expressions.lessThan("par", 501L); - assertManifest(manifestFiles, longPartitionSpecsById, Expressions.and(e1, e5), manifestFiles); - - // 6. 200 < par < 501 - UnboundPredicate e6 = Expressions.greaterThan("par", 200L); - ArrayList expect2 = new ArrayList() {{ - add(f2); - add(f3); - }}; - assertManifest(manifestFiles, longPartitionSpecsById, Expressions.and(e6, e5), expect2); - - // 7. par > 600 - UnboundPredicate e7 = Expressions.greaterThan("par", 600L); - assertManifest(manifestFiles, longPartitionSpecsById, Expressions.and(Expressions.alwaysTrue(), e7), Collections.emptyList()); - - // 8. par < 100 - UnboundPredicate e8 = Expressions.lessThan("par", 100L); - assertManifest(manifestFiles, longPartitionSpecsById, Expressions.and(Expressions.alwaysTrue(), e8), Collections.emptyList()); - } - - private void assertManifest(List dataManifests, - Map specsById, - Expression dataFilter, - List expected) { - CloseableIterable matchingManifest = - IcebergUtils.getMatchingManifest(dataManifests, specsById, dataFilter); - List ret = new ArrayList<>(); - matchingManifest.forEach(ret::add); - ret.sort(Comparator.comparing(ManifestFile::path)); - Assert.assertEquals(expected, ret); - } - - private ByteBuffer getByteBufferForLong(long num) { - return Conversions.toByteBuffer(Types.LongType.get(), num); - } - - private ManifestFile getManifestFileForDataTypeWithPartitionSummary( - String path, - List partitionFieldSummaries) { - ManifestFile file = Mockito.mock(ManifestFile.class); - Mockito.when(file.path()).thenReturn(path); - Mockito.when(file.length()).thenReturn(1024L); - Mockito.when(file.partitionSpecId()).thenReturn(0); - Mockito.when(file.content()).thenReturn(ManifestContent.DATA); - Mockito.when(file.sequenceNumber()).thenReturn(1L); - Mockito.when(file.minSequenceNumber()).thenReturn(1L); - Mockito.when(file.snapshotId()).thenReturn(123456789L); - Mockito.when(file.partitions()).thenReturn(partitionFieldSummaries); - Mockito.when(file.addedFilesCount()).thenReturn(1); - Mockito.when(file.addedRowsCount()).thenReturn(100L); - Mockito.when(file.existingFilesCount()).thenReturn(0); - Mockito.when(file.existingRowsCount()).thenReturn(0L); - Mockito.when(file.deletedFilesCount()).thenReturn(0); - Mockito.when(file.deletedRowsCount()).thenReturn(0L); - Mockito.when(file.hasAddedFiles()).thenReturn(true); - Mockito.when(file.hasExistingFiles()).thenReturn(false); - Mockito.when(file.copy()).thenReturn(file); - return file; - } - - @Test - public void testGetQuerySpecSnapshot() throws UserException { - Table table = Mockito.mock(Table.class); - - // init schemas 0,1,2 - HashMap schemas = new HashMap<>(); - schemas.put(0, mockSchemaWithId(0)); - schemas.put(1, mockSchemaWithId(1)); - schemas.put(2, mockSchemaWithId(2)); - Mockito.when(table.schemas()).thenReturn(schemas); - // init current schema - Mockito.when(table.schema()).thenReturn(schemas.get(2)); - - // init snapshot 1,2,3,4 - Snapshot s1 = mockSnapshot(1, 0); - Mockito.when(table.snapshot(1)).thenReturn(s1); - Snapshot s2 = mockSnapshot(2, 0); - Mockito.when(table.snapshot(2)).thenReturn(s2); - Snapshot s3 = mockSnapshot(3, 1); - Mockito.when(table.snapshot(3)).thenReturn(s3); - Snapshot s4 = mockSnapshot(4, 1); - Mockito.when(table.snapshot(4)).thenReturn(s4); - - // init history for snapshots - List history = new ArrayList<>(); - history.add(mockHistory(1, "2025-05-01 12:34:56")); - history.add(mockHistory(2, "2025-05-01 22:34:56")); - history.add(mockHistory(3, "2025-05-02 12:34:56")); - history.add(mockHistory(4, "2025-05-03 12:34:56")); - Mockito.when(table.history()).thenReturn(history); - - // create some refs - HashMap refs = new HashMap<>(); - String tag1 = "tag1"; - refs.put(tag1, SnapshotRef.tagBuilder(1).build()); - String branch1 = "branch1"; - refs.put(branch1, SnapshotRef.branchBuilder(1).build()); - String branch2 = "branch2"; - refs.put(branch2, SnapshotRef.branchBuilder(3).build()); - Mockito.when(table.refs()).thenReturn(refs); - - // query tag1 - assertQuerySpecSnapshotByVersionOf(table, tag1, 1, 0, tag1); - assertQuerySpecSnapshotByAtTagMap(table, tag1, 1, 0, tag1); - assertQuerySpecSnapshotByAtTagList(table, tag1, 1, 0, tag1); - - // query branch1 - assertQuerySpecSnapshotByVersionOf(table, branch1, 1, 2, branch1); - assertQuerySpecSnapshotByAtBranchMap(table, branch1, 1, 2, branch1); - assertQuerySpecSnapshotByAtBranchList(table, branch1, 1, 2, branch1); - - // query branch2 - assertQuerySpecSnapshotByVersionOf(table, branch2, 3, 2, branch2); - assertQuerySpecSnapshotByAtBranchMap(table, branch2, 3, 2, branch2); - assertQuerySpecSnapshotByAtBranchList(table, branch2, 3, 2, branch2); - - // query snapshotId 1 - assertQuerySpecSnapshotByVersionOf(table, "1", 1, 0, null); - - // query snapshotId 2 - assertQuerySpecSnapshotByVersionOf(table, "2", 2, 0, null); - - // query snapshotId 3 - assertQuerySpecSnapshotByVersionOf(table, "3", 3, 1, null); - - // query ref not exists - Assert.assertThrows( - UserException.class, - () -> assertQuerySpecSnapshotByVersionOf(table, "ref_not_exists", -1, -1, null)); - - // query snapshotId not exists - Assert.assertThrows( - UserException.class, - () -> assertQuerySpecSnapshotByVersionOf(table, "99", -3, -1, null)); - - // query branch not exists - Assert.assertThrows( - UserException.class, - () -> assertQuerySpecSnapshotByAtBranchMap(table, "branch_not_exists", -3, -1, null)); - - // query tag not exists - Assert.assertThrows( - UserException.class, - () -> assertQuerySpecSnapshotByAtTagMap(table, "tag_not_exists", -3, -1, null)); - - // query tag with @branch - Assert.assertThrows( - UserException.class, - () -> assertQuerySpecSnapshotByAtBranchMap(table, tag1, -3, -1, null)); - - // query branch with @tag - Assert.assertThrows( - UserException.class, - () -> assertQuerySpecSnapshotByAtTagMap(table, branch1, -3, -1, null)); - Assert.assertThrows( - UserException.class, - () -> assertQuerySpecSnapshotByAtTagMap(table, branch2, -3, -1, null)); - - // query version with tag - Assert.assertThrows( - IllegalArgumentException.class, - () -> IcebergUtils.getQuerySpecSnapshot( - table, - Optional.of(TableSnapshot.timeOf("v1")), - Optional.of(new TableScanParams("tag", null, - new ArrayList() {{ - add("v1"); - } - })) - )); - - // query version with branch - Assert.assertThrows( - IllegalArgumentException.class, - () -> IcebergUtils.getQuerySpecSnapshot( - table, - Optional.of(TableSnapshot.timeOf("v1")), - Optional.of(new TableScanParams("branch", null, - new ArrayList() {{ - add("v1"); - } - })) - )); - - // query branch with invalid param - Assert.assertThrows( - IllegalArgumentException.class, - () -> IcebergUtils.getQuerySpecSnapshot( - table, - Optional.empty(), - Optional.of(new TableScanParams("branch", - ImmutableMap.of( - "k1", "k2"), - null)) - )); - - // query time - assertQuerySpecSnapshotByTimeOf(table, "2025-05-01 12:34:56", 1, 0, null); - assertQuerySpecSnapshotByTimeOf(table, "2025-05-01 14:34:56", 1, 0, null); - assertQuerySpecSnapshotByTimeOf(table, "2025-05-02 11:34:56", 2, 0, null); - assertQuerySpecSnapshotByTimeOf(table, "2025-05-02 12:34:56", 3, 1, null); - assertQuerySpecSnapshotByTimeOf(table, "2025-05-03 12:34:56", 4, 1, null); - - // query invalid time format - Assert.assertThrows( - DateTimeException.class, - () -> assertQuerySpecSnapshotByTimeOf(table, "1212-240", 3, 1, null) - ); - - // query invalid time - Assert.assertThrows( - IllegalArgumentException.class, - () -> assertQuerySpecSnapshotByTimeOf(table, "2025-05-01 12:34:55", 3, 1, null) - ); - } - - private Snapshot mockSnapshot(long snapshotId, int schemaId) { - Snapshot snapshot = Mockito.mock(Snapshot.class); - Mockito.when(snapshot.snapshotId()).thenReturn(snapshotId); - Mockito.when(snapshot.schemaId()).thenReturn(schemaId); - return snapshot; - } - - private HistoryEntry mockHistory(long snapshotId, String time) { - HistoryEntry historyEntry = Mockito.mock(HistoryEntry.class); - Mockito.when(historyEntry.snapshotId()).thenReturn(snapshotId); - - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - LocalDateTime dateTime = LocalDateTime.parse(time, formatter); - long millis = dateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); - - Mockito.when(historyEntry.timestampMillis()).thenReturn(millis); - return historyEntry; - } - - private Schema mockSchemaWithId(int id) { - Schema schema = Mockito.mock(Schema.class); - Mockito.when(schema.schemaId()).thenReturn(id); - return schema; - } - - // select * from tb for version as of ... - private void assertQuerySpecSnapshotByVersionOf( - Table table, - String version, - long expectSnapshotId, - int expectSchemaId, - String expectRef) throws UserException { - Optional tableSnapshot = Optional.of(TableSnapshot.versionOf(version)); - IcebergTableQueryInfo queryInfo = IcebergUtils.getQuerySpecSnapshot(table, tableSnapshot, Optional.empty()); - assertQueryInfo(queryInfo, expectSnapshotId, expectSchemaId, expectRef); - } - - // select * from tb for time as of ... - private void assertQuerySpecSnapshotByTimeOf( - Table table, - String version, - long expectSnapshotId, - int expectSchemaId, - String expectRef) throws UserException { - Optional tableSnapshot = Optional.of(TableSnapshot.timeOf(version)); - IcebergTableQueryInfo queryInfo = IcebergUtils.getQuerySpecSnapshot(table, tableSnapshot, Optional.empty()); - assertQueryInfo(queryInfo, expectSnapshotId, expectSchemaId, expectRef); - } - - // select * from abc@tag('name'='tag_name') - private void assertQuerySpecSnapshotByAtTagMap( - Table table, - String version, - long expectSnapshotId, - int expectSchemaId, - String expectRef) throws UserException { - HashMap map = new HashMap<>(); - map.put("name", version); - TableScanParams tsp = new TableScanParams("tag", map, null); - IcebergTableQueryInfo queryInfo = IcebergUtils.getQuerySpecSnapshot(table, Optional.empty(), Optional.of(tsp)); - assertQueryInfo(queryInfo, expectSnapshotId, expectSchemaId, expectRef); - } - - // select * from abc@tag(tag_name) - private void assertQuerySpecSnapshotByAtTagList( - Table table, - String version, - long expectSnapshotId, - int expectSchemaId, - String expectRef) throws UserException { - List list = new ArrayList<>(); - list.add(version); - TableScanParams tsp = new TableScanParams("tag", null, list); - IcebergTableQueryInfo queryInfo = IcebergUtils.getQuerySpecSnapshot(table, Optional.empty(), Optional.of(tsp)); - assertQueryInfo(queryInfo, expectSnapshotId, expectSchemaId, expectRef); - } - - // select * from abc@branch('name'='branch_name') - private void assertQuerySpecSnapshotByAtBranchMap( - Table table, - String version, - long expectSnapshotId, - int expectSchemaId, - String expectRef) throws UserException { - HashMap map = new HashMap<>(); - map.put("name", version); - TableScanParams tsp = new TableScanParams("branch", map, null); - IcebergTableQueryInfo queryInfo = IcebergUtils.getQuerySpecSnapshot(table, Optional.empty(), Optional.of(tsp)); - assertQueryInfo(queryInfo, expectSnapshotId, expectSchemaId, expectRef); - } - - // select * from abc@branch(branch_name) - private void assertQuerySpecSnapshotByAtBranchList( - Table table, - String version, - long expectSnapshotId, - int expectSchemaId, - String expectRef) throws UserException { - List list = new ArrayList<>(); - list.add(version); - TableScanParams tsp = new TableScanParams("branch", null, list); - IcebergTableQueryInfo queryInfo = IcebergUtils.getQuerySpecSnapshot(table, Optional.empty(), Optional.of(tsp)); - assertQueryInfo(queryInfo, expectSnapshotId, expectSchemaId, expectRef); - } - - private void assertQueryInfo( - IcebergTableQueryInfo queryInfo, - long expectSnapshotId, - int expectSchemaId, - String expectRef) { - Assert.assertEquals(expectSnapshotId, queryInfo.getSnapshotId()); - Assert.assertEquals(expectSchemaId, queryInfo.getSchemaId()); - Assert.assertEquals(expectRef, queryInfo.getRef()); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergVendedCredentialsProviderTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergVendedCredentialsProviderTest.java deleted file mode 100644 index a1c5f94aaa8ed9..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergVendedCredentialsProviderTest.java +++ /dev/null @@ -1,243 +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.datasource.iceberg; - -import org.apache.doris.datasource.credentials.CredentialUtils; -import org.apache.doris.datasource.credentials.VendedCredentialsFactory; -import org.apache.doris.datasource.property.metastore.IcebergRestProperties; -import org.apache.doris.datasource.property.metastore.MetastoreProperties; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.datasource.property.storage.StorageProperties.Type; - -import org.apache.iceberg.Table; -import org.apache.iceberg.aws.s3.S3FileIOProperties; -import org.apache.iceberg.io.FileIO; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.HashMap; -import java.util.Map; - -public class IcebergVendedCredentialsProviderTest { - - @Test - public void testIsVendedCredentialsEnabled() { - IcebergVendedCredentialsProvider provider = IcebergVendedCredentialsProvider.getInstance(); - - // Test with REST catalog and vended credentials enabled - IcebergRestProperties restProperties = Mockito.mock(IcebergRestProperties.class); - Mockito.when(restProperties.isIcebergRestVendedCredentialsEnabled()).thenReturn(true); - - Assertions.assertTrue(provider.isVendedCredentialsEnabled(restProperties)); - - // Test with REST catalog and vended credentials disabled - Mockito.when(restProperties.isIcebergRestVendedCredentialsEnabled()).thenReturn(false); - Assertions.assertFalse(provider.isVendedCredentialsEnabled(restProperties)); - } - - @Test - public void testExtractRawVendedCredentials() { - IcebergVendedCredentialsProvider provider = IcebergVendedCredentialsProvider.getInstance(); - - // Mock table with S3 vended credentials - Table table = Mockito.mock(Table.class); - FileIO fileIO = Mockito.mock(FileIO.class); - - Map ioProperties = new HashMap<>(); - ioProperties.put(S3FileIOProperties.ACCESS_KEY_ID, "ASIATEST123456"); - ioProperties.put(S3FileIOProperties.SECRET_ACCESS_KEY, "testSecretKey"); - ioProperties.put(S3FileIOProperties.SESSION_TOKEN, "testSessionToken"); - ioProperties.put("s3.region", "us-west-2"); - - Mockito.when(table.io()).thenReturn(fileIO); - Mockito.when(fileIO.properties()).thenReturn(ioProperties); - - Map rawCredentials = provider.extractRawVendedCredentials(table); - - Assertions.assertEquals("ASIATEST123456", rawCredentials.get("s3.access-key-id")); - Assertions.assertEquals("testSecretKey", rawCredentials.get("s3.secret-access-key")); - Assertions.assertEquals("testSessionToken", rawCredentials.get("s3.session-token")); - Assertions.assertEquals("us-west-2", rawCredentials.get("s3.region")); - } - - @Test - public void testExtractRawVendedCredentialsWithNullTable() { - IcebergVendedCredentialsProvider provider = IcebergVendedCredentialsProvider.getInstance(); - - Map rawCredentials = provider.extractRawVendedCredentials(null); - Assertions.assertTrue(rawCredentials.isEmpty()); - } - - @Test - public void testExtractRawVendedCredentialsWithNullIO() { - IcebergVendedCredentialsProvider provider = IcebergVendedCredentialsProvider.getInstance(); - - Table table = Mockito.mock(Table.class); - Mockito.when(table.io()).thenReturn(null); - - Map rawCredentials = provider.extractRawVendedCredentials(table); - Assertions.assertTrue(rawCredentials.isEmpty()); - } - - @Test - public void testExtractRawVendedCredentialsWithEmptyProperties() { - IcebergVendedCredentialsProvider provider = IcebergVendedCredentialsProvider.getInstance(); - - Table table = Mockito.mock(Table.class); - FileIO fileIO = Mockito.mock(FileIO.class); - - Mockito.when(table.io()).thenReturn(fileIO); - Mockito.when(fileIO.properties()).thenReturn(new HashMap<>()); - - Map rawCredentials = provider.extractRawVendedCredentials(table); - Assertions.assertTrue(rawCredentials.isEmpty()); - } - - @Test - public void testFilterCloudStorageProperties() { - Map rawCredentials = new HashMap<>(); - rawCredentials.put("s3.access-key-id", "testAccessKey"); - rawCredentials.put("s3.secret-access-key", "testSecretKey"); - rawCredentials.put("s3.region", "us-west-2"); - rawCredentials.put("iceberg.table.name", "test_table"); - rawCredentials.put("other.property", "other_value"); - - Map filtered = CredentialUtils.filterCloudStorageProperties(rawCredentials); - - Assertions.assertEquals(3, filtered.size()); - Assertions.assertEquals("testAccessKey", filtered.get("s3.access-key-id")); - Assertions.assertEquals("testSecretKey", filtered.get("s3.secret-access-key")); - Assertions.assertEquals("us-west-2", filtered.get("s3.region")); - Assertions.assertFalse(filtered.containsKey("iceberg.table.name")); - Assertions.assertFalse(filtered.containsKey("other.property")); - } - - @Test - public void testGetStoragePropertiesMapWithVendedCredentials() { - // Mock metastore properties with vended credentials enabled - IcebergRestProperties restProperties = Mockito.mock(IcebergRestProperties.class); - Mockito.when(restProperties.getType()).thenReturn(MetastoreProperties.Type.ICEBERG); - Mockito.when(restProperties.isIcebergRestVendedCredentialsEnabled()).thenReturn(true); - - // Mock table with vended credentials - Table table = Mockito.mock(Table.class); - FileIO fileIO = Mockito.mock(FileIO.class); - - Map ioProperties = new HashMap<>(); - ioProperties.put(S3FileIOProperties.ACCESS_KEY_ID, "ASIATEST123456"); - ioProperties.put(S3FileIOProperties.SECRET_ACCESS_KEY, "testSecretKey"); - ioProperties.put(S3FileIOProperties.SESSION_TOKEN, "testSessionToken"); - ioProperties.put("s3.region", "us-west-2"); - - Mockito.when(table.io()).thenReturn(fileIO); - Mockito.when(fileIO.properties()).thenReturn(ioProperties); - - // Test using VendedCredentialsFactory - Map result = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(restProperties, new HashMap<>(), table); - - // Should not be null (assuming StorageProperties.createAll works correctly) - // Note: The actual result depends on whether StorageProperties.createAll() can properly map the credentials - // This test verifies the integration flow works without exceptions - Assertions.assertNotNull(result); - } - - @Test - public void testGetStoragePropertiesMapWithVendedCredentialsDisabled() { - // Mock metastore properties with vended credentials disabled - IcebergRestProperties restProperties = Mockito.mock(IcebergRestProperties.class); - Mockito.when(restProperties.getType()).thenReturn(MetastoreProperties.Type.ICEBERG); - Mockito.when(restProperties.isIcebergRestVendedCredentialsEnabled()).thenReturn(false); - - Table table = Mockito.mock(Table.class); - - Map result = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(restProperties, new HashMap<>(), table); - - // When vended credentials are disabled, should return the baseStoragePropertiesMap (empty HashMap) - Assertions.assertNotNull(result); - Assertions.assertTrue(result.isEmpty()); - } - - @Test - public void testGetStoragePropertiesMapWithNullTable() { - IcebergRestProperties restProperties = Mockito.mock(IcebergRestProperties.class); - Mockito.when(restProperties.getType()).thenReturn(MetastoreProperties.Type.ICEBERG); - Mockito.when(restProperties.isIcebergRestVendedCredentialsEnabled()).thenReturn(true); - - Map result = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(restProperties, new HashMap<>(), null); - - // When table is null, should return the baseStoragePropertiesMap (empty HashMap) - Assertions.assertNotNull(result); - Assertions.assertTrue(result.isEmpty()); - } - - @Test - public void testGetBackendPropertiesFromStorageMap() { - // Create mock storage properties - StorageProperties s3Properties = Mockito.mock(StorageProperties.class); - StorageProperties hdfsProperties = Mockito.mock(StorageProperties.class); - - Map s3BackendProps = new HashMap<>(); - s3BackendProps.put("AWS_ACCESS_KEY", "testAccessKey"); - s3BackendProps.put("AWS_SECRET_KEY", "testSecretKey"); - s3BackendProps.put("AWS_TOKEN", "testToken"); - - Map hdfsBackendProps = new HashMap<>(); - hdfsBackendProps.put("HDFS_PROPERTY", "hdfsValue"); - - Mockito.when(s3Properties.getBackendConfigProperties()).thenReturn(s3BackendProps); - Mockito.when(hdfsProperties.getBackendConfigProperties()).thenReturn(hdfsBackendProps); - - Map storagePropertiesMap = new HashMap<>(); - storagePropertiesMap.put(Type.S3, s3Properties); - storagePropertiesMap.put(Type.HDFS, hdfsProperties); - - Map result = CredentialUtils.getBackendPropertiesFromStorageMap(storagePropertiesMap); - - Assertions.assertEquals(4, result.size()); - Assertions.assertEquals("testAccessKey", result.get("AWS_ACCESS_KEY")); - Assertions.assertEquals("testSecretKey", result.get("AWS_SECRET_KEY")); - Assertions.assertEquals("testToken", result.get("AWS_TOKEN")); - Assertions.assertEquals("hdfsValue", result.get("HDFS_PROPERTY")); - } - - @Test - public void testGetBackendPropertiesFromStorageMapWithNullValues() { - StorageProperties s3Properties = Mockito.mock(StorageProperties.class); - - Map s3BackendProps = new HashMap<>(); - s3BackendProps.put("AWS_ACCESS_KEY", "testAccessKey"); - s3BackendProps.put("AWS_SECRET_KEY", null); // null value should be filtered out - s3BackendProps.put("AWS_TOKEN", "testToken"); - - Mockito.when(s3Properties.getBackendConfigProperties()).thenReturn(s3BackendProps); - - Map storagePropertiesMap = new HashMap<>(); - storagePropertiesMap.put(Type.S3, s3Properties); - - Map result = CredentialUtils.getBackendPropertiesFromStorageMap(storagePropertiesMap); - - Assertions.assertEquals(2, result.size()); - Assertions.assertEquals("testAccessKey", result.get("AWS_ACCESS_KEY")); - Assertions.assertEquals("testToken", result.get("AWS_TOKEN")); - Assertions.assertFalse(result.containsKey("AWS_SECRET_KEY")); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/dlf/client/IcebergDLFExternalCatalogTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/dlf/client/IcebergDLFExternalCatalogTest.java deleted file mode 100644 index 93c8f64360a1db..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/dlf/client/IcebergDLFExternalCatalogTest.java +++ /dev/null @@ -1,54 +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.datasource.iceberg.dlf.client; - -import org.apache.doris.datasource.iceberg.IcebergDLFExternalCatalog; -import org.apache.doris.nereids.exceptions.NotSupportedException; - -import com.google.common.collect.Maps; -import org.apache.hadoop.conf.Configuration; -import org.junit.Assert; -import org.junit.Test; - -import java.util.HashMap; - -public class IcebergDLFExternalCatalogTest { - @Test - public void testDatabaseList() { - HashMap props = new HashMap<>(); - Configuration conf = new Configuration(); - - DLFCachedClientPool cachedClientPool1 = new DLFCachedClientPool(conf, props); - DLFCachedClientPool cachedClientPool2 = new DLFCachedClientPool(conf, props); - DLFClientPool dlfClientPool1 = cachedClientPool1.clientPool(); - DLFClientPool dlfClientPool2 = cachedClientPool2.clientPool(); - // This cache should belong to the catalog level, - // so the object addresses of clients in different pools must be different - Assert.assertNotSame(dlfClientPool1, dlfClientPool2); - } - - @Test - public void testNotSupportOperation() { - HashMap props = new HashMap<>(); - IcebergDLFExternalCatalog catalog = new IcebergDLFExternalCatalog(1, "test", "test", props, "test"); - Assert.assertThrows(NotSupportedException.class, () -> catalog.createDb("db1", true, Maps.newHashMap())); - Assert.assertThrows(NotSupportedException.class, () -> catalog.dropDb("", true, true)); - Assert.assertThrows(NotSupportedException.class, () -> catalog.dropTable("", "", true, true, false, true, false, true)); - Assert.assertThrows(NotSupportedException.class, () -> catalog.truncateTable("", "", null, true, "")); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/helper/IcebergRewritableDeletePlannerTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/helper/IcebergRewritableDeletePlannerTest.java deleted file mode 100644 index 4e797f75807bac..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/helper/IcebergRewritableDeletePlannerTest.java +++ /dev/null @@ -1,144 +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.datasource.iceberg.helper; - -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.analysis.TupleId; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.source.IcebergScanNode; -import org.apache.doris.nereids.NereidsPlanner; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.planner.ScanNode; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.thrift.TIcebergDeleteFileDesc; -import org.apache.doris.thrift.TIcebergRewritableDeleteFileSet; - -import org.apache.iceberg.DeleteFile; -import org.apache.iceberg.FileFormat; -import org.apache.iceberg.FileMetadata; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Table; -import org.apache.iceberg.TableProperties; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -public class IcebergRewritableDeletePlannerTest { - - private static class TestIcebergScanNode extends IcebergScanNode { - TestIcebergScanNode() { - super(new PlanNodeId(0), new TupleDescriptor(new TupleId(0)), new SessionVariable(), ScanContext.EMPTY); - } - } - - @Test - public void testCollectForDeleteReturnsEmptyWhenTableFormatVersionIsLessThanThree() throws Exception { - IcebergExternalTable table = mockIcebergExternalTable(2); - NereidsPlanner planner = Mockito.mock(NereidsPlanner.class); - Mockito.when(planner.getScanNodes()).thenReturn(Collections.singletonList(buildScanNode( - "file:///tmp/data.parquet", - buildDeleteFile("file:///tmp/delete.parquet", "file:///tmp/data.parquet"), - buildDeleteFileDesc("file:///tmp/delete.parquet")))); - - IcebergRewritableDeletePlan plan = IcebergRewritableDeletePlanner.collectForDelete(table, planner); - - Assertions.assertTrue(plan.getThriftDeleteFileSets().isEmpty()); - Assertions.assertTrue(plan.getDeleteFilesByReferencedDataFile().isEmpty()); - } - - @Test - public void testCollectForMergeAggregatesDeleteFilesAcrossIcebergScanNodes() throws Exception { - String firstDataFile = "file:///tmp/data-1.parquet"; - String secondDataFile = "file:///tmp/data-2.parquet"; - DeleteFile firstDeleteFile = buildDeleteFile("file:///tmp/delete-1.puffin", firstDataFile); - DeleteFile secondDeleteFile = buildDeleteFile("file:///tmp/delete-2.puffin", secondDataFile); - TestIcebergScanNode firstScanNode = buildScanNode( - firstDataFile, firstDeleteFile, buildDeleteFileDesc("file:///tmp/delete-1.puffin")); - TestIcebergScanNode secondScanNode = buildScanNode( - secondDataFile, secondDeleteFile, buildDeleteFileDesc("file:///tmp/delete-2.puffin")); - ScanNode otherScanNode = Mockito.mock(ScanNode.class); - - IcebergExternalTable table = mockIcebergExternalTable(3); - NereidsPlanner planner = Mockito.mock(NereidsPlanner.class); - Mockito.when(planner.getScanNodes()).thenReturn(Arrays.asList(firstScanNode, otherScanNode, secondScanNode)); - - IcebergRewritableDeletePlan plan = IcebergRewritableDeletePlanner.collectForMerge(table, planner); - - Assertions.assertEquals(2, plan.getThriftDeleteFileSets().size()); - Assertions.assertEquals(2, plan.getDeleteFilesByReferencedDataFile().size()); - Assertions.assertSame(firstDeleteFile, plan.getDeleteFilesByReferencedDataFile().get(firstDataFile).get(0)); - Assertions.assertSame(secondDeleteFile, plan.getDeleteFilesByReferencedDataFile().get(secondDataFile).get(0)); - - List referencedDataFiles = plan.getThriftDeleteFileSets().stream() - .map(TIcebergRewritableDeleteFileSet::getReferencedDataFilePath) - .sorted() - .collect(Collectors.toList()); - Assertions.assertEquals(Arrays.asList(firstDataFile, secondDataFile), referencedDataFiles); - - Assertions.assertThrows(UnsupportedOperationException.class, - () -> plan.getDeleteFilesByReferencedDataFile().put("new", Collections.emptyList())); - } - - private static TestIcebergScanNode buildScanNode( - String referencedDataFile, - DeleteFile deleteFile, - TIcebergDeleteFileDesc deleteFileDesc) { - TestIcebergScanNode scanNode = new TestIcebergScanNode(); - scanNode.deleteFilesByReferencedDataFile.put(referencedDataFile, Collections.singletonList(deleteFile)); - scanNode.deleteFilesDescByReferencedDataFile.put(referencedDataFile, Collections.singletonList(deleteFileDesc)); - return scanNode; - } - - private static DeleteFile buildDeleteFile(String deleteFilePath, String referencedDataFilePath) { - return FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) - .ofPositionDeletes() - .withPath(deleteFilePath) - .withFormat(FileFormat.PUFFIN) - .withFileSizeInBytes(128L) - .withRecordCount(4L) - .withReferencedDataFile(referencedDataFilePath) - .withContentOffset(16L) - .withContentSizeInBytes(64L) - .build(); - } - - private static TIcebergDeleteFileDesc buildDeleteFileDesc(String deleteFilePath) { - TIcebergDeleteFileDesc deleteFileDesc = new TIcebergDeleteFileDesc(); - deleteFileDesc.setPath(deleteFilePath); - return deleteFileDesc; - } - - private static IcebergExternalTable mockIcebergExternalTable(int formatVersion) { - Table icebergTable = Mockito.mock(Table.class); - Map properties = new HashMap<>(); - properties.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); - Mockito.when(icebergTable.properties()).thenReturn(properties); - - IcebergExternalTable table = Mockito.mock(IcebergExternalTable.class); - Mockito.when(table.getIcebergTable()).thenReturn(icebergTable); - return table; - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/helper/IcebergWriterHelperTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/helper/IcebergWriterHelperTest.java deleted file mode 100644 index 77d318518f326e..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/helper/IcebergWriterHelperTest.java +++ /dev/null @@ -1,179 +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.datasource.iceberg.helper; - -import org.apache.doris.thrift.TFileContent; -import org.apache.doris.thrift.TIcebergCommitData; - -import org.apache.iceberg.DeleteFile; -import org.apache.iceberg.FileFormat; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Schema; -import org.apache.iceberg.types.Types; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.ArrayList; -import java.util.List; - -/** - * Test for IcebergWriterHelper DeleteFile conversion - */ -public class IcebergWriterHelperTest { - - private Schema schema; - private PartitionSpec unpartitionedSpec; - private FileFormat format; - - @BeforeEach - public void setUp() { - // Create a simple schema - schema = new Schema( - Types.NestedField.required(1, "id", Types.IntegerType.get()), - Types.NestedField.optional(2, "name", Types.StringType.get()), - Types.NestedField.optional(3, "age", Types.IntegerType.get()) - ); - - // Create unpartitioned spec - unpartitionedSpec = PartitionSpec.unpartitioned(); - - // Use Parquet format - format = FileFormat.PARQUET; - - } - - @Test - public void testConvertToDeleteFiles_EmptyList() { - List commitDataList = new ArrayList<>(); - List deleteFiles = IcebergWriterHelper.convertToDeleteFiles( - format, unpartitionedSpec, commitDataList); - - Assertions.assertTrue(deleteFiles.isEmpty()); - } - - @Test - public void testConvertToDeleteFiles_DataFileIgnored() { - List commitDataList = new ArrayList<>(); - - // Add a DATA file (should be ignored) - TIcebergCommitData commitData = new TIcebergCommitData(); - commitData.setFilePath("/path/to/data.parquet"); - commitData.setRowCount(100); - commitData.setFileSize(1024); - commitData.setFileContent(TFileContent.DATA); - commitDataList.add(commitData); - - List deleteFiles = IcebergWriterHelper.convertToDeleteFiles( - format, unpartitionedSpec, commitDataList); - - Assertions.assertTrue(deleteFiles.isEmpty()); - } - - @Test - public void testConvertToDeleteFiles_PositionDelete() { - List commitDataList = new ArrayList<>(); - - TIcebergCommitData commitData = new TIcebergCommitData(); - commitData.setFilePath("/path/to/delete.parquet"); - commitData.setRowCount(10); - commitData.setFileSize(512); - commitData.setFileContent(TFileContent.POSITION_DELETES); - commitData.setReferencedDataFilePath("/path/to/data.parquet"); - commitDataList.add(commitData); - - List deleteFiles = IcebergWriterHelper.convertToDeleteFiles( - format, unpartitionedSpec, commitDataList); - - Assertions.assertEquals(1, deleteFiles.size()); - DeleteFile deleteFile = deleteFiles.get(0); - Assertions.assertEquals("/path/to/delete.parquet", deleteFile.path()); - Assertions.assertEquals(10, deleteFile.recordCount()); - Assertions.assertEquals(512, deleteFile.fileSizeInBytes()); - Assertions.assertEquals(org.apache.iceberg.FileContent.POSITION_DELETES, deleteFile.content()); - } - - @Test - public void testConvertToDeleteFiles_DeletionVectorUsesPuffinMetadata() { - List commitDataList = new ArrayList<>(); - - TIcebergCommitData commitData = new TIcebergCommitData(); - commitData.setFilePath("/path/to/delete.puffin"); - commitData.setRowCount(7); - commitData.setFileSize(2048); - commitData.setFileContent(TFileContent.DELETION_VECTOR); - commitData.setContentOffset(128L); - commitData.setContentSizeInBytes(64L); - commitData.setReferencedDataFilePath("/path/to/data.parquet"); - commitDataList.add(commitData); - - List deleteFiles = IcebergWriterHelper.convertToDeleteFiles( - format, unpartitionedSpec, commitDataList); - - Assertions.assertEquals(1, deleteFiles.size()); - DeleteFile deleteFile = deleteFiles.get(0); - Assertions.assertEquals(FileFormat.PUFFIN, deleteFile.format()); - Assertions.assertEquals(128L, deleteFile.contentOffset()); - Assertions.assertEquals(64L, deleteFile.contentSizeInBytes()); - Assertions.assertEquals("/path/to/data.parquet", deleteFile.referencedDataFile()); - Assertions.assertEquals(org.apache.iceberg.FileContent.POSITION_DELETES, deleteFile.content()); - } - - @Test - public void testConvertToDeleteFiles_UnsupportedDeleteContent() { - List commitDataList = new ArrayList<>(); - - TIcebergCommitData commitData = new TIcebergCommitData(); - commitData.setFilePath("/path/to/delete.parquet"); - commitData.setRowCount(20); - commitData.setFileSize(1024); - commitData.setFileContent(TFileContent.EQUALITY_DELETES); - commitDataList.add(commitData); - - Assertions.assertThrows(com.google.common.base.VerifyException.class, () -> { - IcebergWriterHelper.convertToDeleteFiles( - format, unpartitionedSpec, commitDataList); - }); - } - - @Test - public void testConvertToDeleteFiles_MultipleDeleteFiles() { - List commitDataList = new ArrayList<>(); - - // Add position delete - TIcebergCommitData commitData1 = new TIcebergCommitData(); - commitData1.setFilePath("/path/to/delete1.parquet"); - commitData1.setRowCount(10); - commitData1.setFileSize(512); - commitData1.setFileContent(TFileContent.POSITION_DELETES); - commitDataList.add(commitData1); - - // Add another position delete - TIcebergCommitData commitData2 = new TIcebergCommitData(); - commitData2.setFilePath("/path/to/delete2.parquet"); - commitData2.setRowCount(20); - commitData2.setFileSize(1024); - commitData2.setFileContent(TFileContent.POSITION_DELETES); - commitDataList.add(commitData2); - - List deleteFiles = IcebergWriterHelper.convertToDeleteFiles( - format, unpartitionedSpec, commitDataList); - - Assertions.assertEquals(2, deleteFiles.size()); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/rewrite/RewriteDataFilePlannerTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/rewrite/RewriteDataFilePlannerTest.java deleted file mode 100644 index 230a7223d63bc7..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/rewrite/RewriteDataFilePlannerTest.java +++ /dev/null @@ -1,1165 +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.datasource.iceberg.rewrite; - -import org.apache.doris.common.UserException; - -import org.apache.iceberg.DataFile; -import org.apache.iceberg.DeleteFile; -import org.apache.iceberg.FileScanTask; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Schema; -import org.apache.iceberg.StructLike; -import org.apache.iceberg.Table; -import org.apache.iceberg.TableScan; -import org.apache.iceberg.io.CloseableIterable; -import org.apache.iceberg.types.Types; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Optional; - -/** - * Unit tests for RewriteDataFilePlanner - */ -public class RewriteDataFilePlannerTest { - - @Mock - private Table mockTable; - - @Mock - private TableScan mockTableScan; - - @Mock - private Schema mockSchema; - - @Mock - private PartitionSpec mockPartitionSpec; - - @Mock - private DataFile mockDataFile; - - @Mock - private DeleteFile mockDeleteFile; - - @Mock - private FileScanTask mockFileScanTask; - - @Mock - private StructLike mockPartition; - - private RewriteDataFilePlanner.Parameters defaultParameters; - private RewriteDataFilePlanner planner; - - @BeforeEach - public void setUp() { - MockitoAnnotations.openMocks(this); - // Create default parameters for testing - defaultParameters = new RewriteDataFilePlanner.Parameters( - 128 * 1024 * 1024L, // targetFileSizeBytes: 128MB - 64 * 1024 * 1024L, // minFileSizeBytes: 64MB - 256 * 1024 * 1024L, // maxFileSizeBytes: 256MB - 2, // minInputFiles - false, // rewriteAll - 512 * 1024 * 1024L, // maxFileGroupSizeBytes: 512MB - 3, // deleteFileThreshold - 0.1, // deleteRatioThreshold: 10% - 1L, // outputSpecId - Optional.empty() // whereCondition - ); - - planner = new RewriteDataFilePlanner(defaultParameters); - - // Setup common mocks for table scan chain used by planFileScanTasks() - // planFileScanTasks() calls: table.newScan() -> tableScan.ignoreResiduals() -> tableScan.planFiles() - // Also handles: table.currentSnapshot() and tableScan.useSnapshot() if snapshot exists - Mockito.when(mockTable.currentSnapshot()).thenReturn(null); - Mockito.when(mockTableScan.ignoreResiduals()).thenReturn(mockTableScan); - } - - @Test - public void testParametersGetters() { - Assertions.assertEquals(128 * 1024 * 1024L, defaultParameters.getTargetFileSizeBytes()); - Assertions.assertEquals(64 * 1024 * 1024L, defaultParameters.getMinFileSizeBytes()); - Assertions.assertEquals(256 * 1024 * 1024L, defaultParameters.getMaxFileSizeBytes()); - Assertions.assertEquals(2, defaultParameters.getMinInputFiles()); - Assertions.assertFalse(defaultParameters.isRewriteAll()); - Assertions.assertEquals(512 * 1024 * 1024L, defaultParameters.getMaxFileGroupSizeBytes()); - Assertions.assertEquals(3, defaultParameters.getDeleteFileThreshold()); - Assertions.assertEquals(0.1, defaultParameters.getDeleteRatioThreshold(), 0.001); - Assertions.assertFalse(defaultParameters.hasWhereCondition()); - } - - @Test - public void testParametersToString() { - String toString = defaultParameters.toString(); - Assertions.assertTrue(toString.contains("targetFileSizeBytes=134217728")); - Assertions.assertTrue(toString.contains("minFileSizeBytes=67108864")); - Assertions.assertTrue(toString.contains("maxFileSizeBytes=268435456")); - Assertions.assertTrue(toString.contains("minInputFiles=2")); - Assertions.assertTrue(toString.contains("rewriteAll=false")); - Assertions.assertTrue(toString.contains("hasWhereCondition=false")); - } - - @Test - public void testPlanAndOrganizeTasksWithRewriteAll() throws UserException { - // Test with rewriteAll = true - RewriteDataFilePlanner.Parameters rewriteAllParams = new RewriteDataFilePlanner.Parameters( - 128 * 1024 * 1024L, 64 * 1024 * 1024L, 256 * 1024 * 1024L, - 2, true, 512 * 1024 * 1024L, 3, 0.1, 1L, - Optional.empty()); - - RewriteDataFilePlanner rewriteAllPlanner = new RewriteDataFilePlanner(rewriteAllParams); - - // Mock table scan - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()) - .thenReturn(CloseableIterable.withNoopClose(Collections.singletonList(mockFileScanTask))); - - // Mock file scan task - Mockito.when(mockFileScanTask.file()).thenReturn(mockDataFile); - Mockito.when(mockFileScanTask.spec()).thenReturn(mockPartitionSpec); - Mockito.when(mockFileScanTask.deletes()).thenReturn(null); - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(mockDataFile.partition()).thenReturn(mockPartition); - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - List result = rewriteAllPlanner.planAndOrganizeTasks(mockTable); - - Assertions.assertNotNull(result); - Assertions.assertEquals(1, result.size()); - Assertions.assertEquals(1, result.get(0).getTaskCount()); - Assertions.assertEquals(100 * 1024 * 1024L, result.get(0).getTotalSize()); - } - - @Test - public void testFileSizeFiltering() throws UserException { - // Test file size filtering logic - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()) - .thenReturn(CloseableIterable.withNoopClose(Collections.singletonList(mockFileScanTask))); - Mockito.when(mockFileScanTask.file()).thenReturn(mockDataFile); - Mockito.when(mockFileScanTask.spec()).thenReturn(mockPartitionSpec); - Mockito.when(mockFileScanTask.deletes()).thenReturn(null); - Mockito.when(mockDataFile.partition()).thenReturn(mockPartition); - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - // Test file too small (should be selected for rewrite but filtered by group size - single file) - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(32 * 1024 * 1024L); // 32MB < 64MB min - - List result = planner.planAndOrganizeTasks(mockTable); - // Single file groups are filtered unless they meet tooMuchContent threshold - Assertions.assertTrue(result.isEmpty(), "Single small file should be filtered by group rules"); - - // Test file too large (should be selected for rewrite but filtered by group size - single file) - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(300 * 1024 * 1024L); // 300MB > 256MB max - - result = planner.planAndOrganizeTasks(mockTable); - // Single file groups are filtered unless they meet tooMuchContent threshold - Assertions.assertTrue(result.isEmpty(), "Single large file should be filtered by group rules"); - - // Test file in acceptable range (should be filtered out as it doesn't need rewriting) - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); // 100MB between 64MB-256MB - - result = planner.planAndOrganizeTasks(mockTable); - // File in acceptable range with no deletes doesn't need rewriting - Assertions.assertTrue(result.isEmpty(), "File in acceptable range should not be rewritten"); - } - - @Test - public void testDeleteFileThreshold() throws UserException { - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()) - .thenReturn(CloseableIterable.withNoopClose(Collections.singletonList(mockFileScanTask))); - Mockito.when(mockFileScanTask.file()).thenReturn(mockDataFile); - Mockito.when(mockFileScanTask.spec()).thenReturn(mockPartitionSpec); - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(mockDataFile.partition()).thenReturn(mockPartition); - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - // Test with delete files below threshold (should be filtered out) - Mockito.when(mockFileScanTask.deletes()).thenReturn(Arrays.asList(mockDeleteFile, mockDeleteFile)); // 2 < 3 threshold - - List result = planner.planAndOrganizeTasks(mockTable); - Assertions.assertTrue(result.isEmpty(), "File with 2 delete files (< 3 threshold) should not be selected"); - - // Test with delete files at threshold (should be included) - Mockito.when(mockFileScanTask.deletes()).thenReturn(Arrays.asList(mockDeleteFile, mockDeleteFile, mockDeleteFile)); // 3 = 3 threshold - - result = planner.planAndOrganizeTasks(mockTable); - Assertions.assertEquals(1, result.size(), "Should have exactly 1 group"); - Assertions.assertEquals(1, result.get(0).getTaskCount(), "Group should contain 1 task"); - Assertions.assertEquals(100 * 1024 * 1024L, result.get(0).getTotalSize(), "Group size should be 100MB"); - } - - @Test - public void testDeleteRatioThreshold() throws UserException { - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()) - .thenReturn(CloseableIterable.withNoopClose(Collections.singletonList(mockFileScanTask))); - Mockito.when(mockFileScanTask.file()).thenReturn(mockDataFile); - Mockito.when(mockFileScanTask.spec()).thenReturn(mockPartitionSpec); - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(mockDataFile.partition()).thenReturn(mockPartition); - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - // Mock delete file with record count - Mockito.when(mockDeleteFile.recordCount()).thenReturn(5L); - Mockito.when(mockDataFile.recordCount()).thenReturn(100L); // 5/100 = 5% < 10% threshold - - Mockito.when(mockFileScanTask.deletes()).thenReturn(Collections.singletonList(mockDeleteFile)); - - List result = planner.planAndOrganizeTasks(mockTable); - // Low delete ratio + single file = filtered out - Assertions.assertTrue(result.isEmpty(), "File with 5% delete ratio (< 10% threshold) should not be selected"); - - // Test with high delete ratio (should be included) - Mockito.when(mockDeleteFile.recordCount()).thenReturn(15L); // 15/100 = 15% > 10% threshold - - result = planner.planAndOrganizeTasks(mockTable); - // Note: delete ratio calculation requires ContentFileUtil.isFileScoped to return true - // which cannot be easily mocked. Single file groups are also filtered out. - Assertions.assertTrue(result.isEmpty(), "Single file group filtered even with high delete ratio (ContentFileUtil limitation)"); - } - - @Test - public void testDeleteRatioWithZeroRecordCount() throws UserException { - // Test that recordCount == 0 doesn't cause division by zero - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()) - .thenReturn(CloseableIterable.withNoopClose(Collections.singletonList(mockFileScanTask))); - Mockito.when(mockFileScanTask.file()).thenReturn(mockDataFile); - Mockito.when(mockFileScanTask.spec()).thenReturn(mockPartitionSpec); - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(mockDataFile.partition()).thenReturn(mockPartition); - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - // Mock delete file with record count = 0 (should not cause division by zero) - Mockito.when(mockDeleteFile.recordCount()).thenReturn(10L); - Mockito.when(mockDataFile.recordCount()).thenReturn(0L); // Zero record count - - Mockito.when(mockFileScanTask.deletes()).thenReturn(Collections.singletonList(mockDeleteFile)); - - List result = planner.planAndOrganizeTasks(mockTable); - // File with zero record count should not be selected (should return false without throwing exception) - Assertions.assertTrue(result.isEmpty(), "File with zero record count should not be selected"); - } - - @Test - public void testGroupFilteringByInputFiles() throws UserException { - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()) - .thenReturn(CloseableIterable.withNoopClose(Collections.singletonList(mockFileScanTask))); - Mockito.when(mockFileScanTask.file()).thenReturn(mockDataFile); - Mockito.when(mockFileScanTask.spec()).thenReturn(mockPartitionSpec); - Mockito.when(mockFileScanTask.deletes()).thenReturn(null); - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(mockDataFile.partition()).thenReturn(mockPartition); - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - // Single file group (1 < 2 minInputFiles) should be filtered out - List result = planner.planAndOrganizeTasks(mockTable); - Assertions.assertTrue(result.isEmpty(), "Single file group with taskCount=1 < minInputFiles=2 should be filtered"); - } - - @Test - public void testGroupFilteringByContentSize() throws UserException { - // Create parameters with lower target file size for testing - RewriteDataFilePlanner.Parameters params = new RewriteDataFilePlanner.Parameters( - 50 * 1024 * 1024L, // targetFileSizeBytes: 50MB - 64 * 1024 * 1024L, // minFileSizeBytes: 64MB - 256 * 1024 * 1024L, // maxFileSizeBytes: 256MB - 1, // minInputFiles - false, // rewriteAll - 512 * 1024 * 1024L, // maxFileGroupSizeBytes: 512MB - 3, // deleteFileThreshold - 0.1, // deleteRatioThreshold: 10% - 1L, // outputSpecId - Optional.empty() // whereCondition - ); - - RewriteDataFilePlanner testPlanner = new RewriteDataFilePlanner(params); - - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()) - .thenReturn(CloseableIterable.withNoopClose(Collections.singletonList(mockFileScanTask))); - Mockito.when(mockFileScanTask.file()).thenReturn(mockDataFile); - Mockito.when(mockFileScanTask.spec()).thenReturn(mockPartitionSpec); - Mockito.when(mockFileScanTask.deletes()).thenReturn(null); - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); // 100MB > 50MB target - Mockito.when(mockDataFile.partition()).thenReturn(mockPartition); - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - List result = testPlanner.planAndOrganizeTasks(mockTable); - // Single file in acceptable range doesn't need rewriting - // enoughContent requires taskCount > 1 - Assertions.assertTrue(result.isEmpty()); - } - - @Test - public void testGroupFilteringByMaxFileGroupSize() throws UserException { - // Create parameters with very small max file group size - RewriteDataFilePlanner.Parameters params = new RewriteDataFilePlanner.Parameters( - 128 * 1024 * 1024L, // targetFileSizeBytes: 128MB - 64 * 1024 * 1024L, // minFileSizeBytes: 64MB - 256 * 1024 * 1024L, // maxFileSizeBytes: 256MB - 1, // minInputFiles - false, // rewriteAll - 50 * 1024 * 1024L, // maxFileGroupSizeBytes: 50MB (very small) - 3, // deleteFileThreshold - 0.1, // deleteRatioThreshold: 10% - 1L, // outputSpecId - Optional.empty() // whereCondition - ); - - RewriteDataFilePlanner testPlanner = new RewriteDataFilePlanner(params); - - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()) - .thenReturn(CloseableIterable.withNoopClose(Collections.singletonList(mockFileScanTask))); - Mockito.when(mockFileScanTask.file()).thenReturn(mockDataFile); - Mockito.when(mockFileScanTask.spec()).thenReturn(mockPartitionSpec); - Mockito.when(mockFileScanTask.deletes()).thenReturn(null); - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); // 100MB > 50MB max group size - Mockito.when(mockDataFile.partition()).thenReturn(mockPartition); - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - List result = testPlanner.planAndOrganizeTasks(mockTable); - // File in acceptable range (64-256MB), so not selected by filterFiles - // Even though 100MB > 50MB maxFileGroupSize, the file is filtered before grouping - Assertions.assertTrue(result.isEmpty()); - } - - @Test - public void testPartitionGrouping() throws UserException { - // Create two file scan tasks with different partitions - FileScanTask task1 = Mockito.mock(FileScanTask.class); - FileScanTask task2 = Mockito.mock(FileScanTask.class); - DataFile dataFile1 = Mockito.mock(DataFile.class); - DataFile dataFile2 = Mockito.mock(DataFile.class); - StructLike partition1 = Mockito.mock(StructLike.class); - StructLike partition2 = Mockito.mock(StructLike.class); - - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()).thenReturn(CloseableIterable.withNoopClose(Arrays.asList(task1, task2))); - - // Task 1 - Mockito.when(task1.file()).thenReturn(dataFile1); - Mockito.when(task1.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task1.deletes()).thenReturn(null); - Mockito.when(dataFile1.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(dataFile1.partition()).thenReturn(partition1); - - // Task 2 - Mockito.when(task2.file()).thenReturn(dataFile2); - Mockito.when(task2.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task2.deletes()).thenReturn(null); - Mockito.when(dataFile2.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(dataFile2.partition()).thenReturn(partition2); - - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - // Use rewriteAll to avoid filtering - RewriteDataFilePlanner.Parameters rewriteAllParams = new RewriteDataFilePlanner.Parameters( - 128 * 1024 * 1024L, 64 * 1024 * 1024L, 256 * 1024 * 1024L, - 1, true, 512 * 1024 * 1024L, 3, 0.1, 1L, - Optional.empty()); - - RewriteDataFilePlanner rewriteAllPlanner = new RewriteDataFilePlanner(rewriteAllParams); - List result = rewriteAllPlanner.planAndOrganizeTasks(mockTable); - - // With rewriteAll=true, all files are included - // StructLikeWrapper groups by partition, but since we can't mock partition equality, - // we just verify that groups are created - Assertions.assertFalse(result.isEmpty()); - Assertions.assertTrue(result.size() >= 1 && result.size() <= 2); - } - - @Test - public void testExceptionHandling() { - Mockito.when(mockTable.newScan()).thenThrow(new RuntimeException("Table scan failed")); - - UserException exception = Assertions.assertThrows(UserException.class, () -> { - planner.planAndOrganizeTasks(mockTable); - }); - - Assertions.assertTrue(exception.getMessage().contains("Failed to plan file scan tasks")); - Assertions.assertTrue(exception.getCause() instanceof RuntimeException); - Assertions.assertEquals("Table scan failed", exception.getCause().getMessage()); - } - - @Test - public void testEmptyTableScan() throws UserException { - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()).thenReturn(CloseableIterable.withNoopClose(Collections.emptyList())); - - List result = planner.planAndOrganizeTasks(mockTable); - Assertions.assertTrue(result.isEmpty()); - } - - @Test - public void testRewriteDataGroupOperations() { - RewriteDataGroup group = new RewriteDataGroup(); - - Assertions.assertTrue(group.isEmpty()); - Assertions.assertEquals(0, group.getTaskCount()); - Assertions.assertEquals(0, group.getTotalSize()); - Assertions.assertTrue(group.getDataFiles().isEmpty()); - - // Add a task - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(mockFileScanTask.file()).thenReturn(mockDataFile); - - group.addTask(mockFileScanTask); - - Assertions.assertFalse(group.isEmpty()); - Assertions.assertEquals(1, group.getTaskCount()); - Assertions.assertEquals(100 * 1024 * 1024L, group.getTotalSize()); - Assertions.assertEquals(1, group.getDataFiles().size()); - Assertions.assertTrue(group.getDataFiles().contains(mockDataFile)); - } - - @Test - public void testRewriteDataGroupConstructorWithTasks() { - // Test constructor that takes a list of tasks - FileScanTask task1 = Mockito.mock(FileScanTask.class); - FileScanTask task2 = Mockito.mock(FileScanTask.class); - DataFile dataFile1 = Mockito.mock(DataFile.class); - DataFile dataFile2 = Mockito.mock(DataFile.class); - DeleteFile deleteFile1 = Mockito.mock(DeleteFile.class); - DeleteFile deleteFile2 = Mockito.mock(DeleteFile.class); - - Mockito.when(task1.file()).thenReturn(dataFile1); - Mockito.when(task1.deletes()).thenReturn(Arrays.asList(deleteFile1)); - Mockito.when(dataFile1.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - - Mockito.when(task2.file()).thenReturn(dataFile2); - Mockito.when(task2.deletes()).thenReturn(Arrays.asList(deleteFile2)); - Mockito.when(dataFile2.fileSizeInBytes()).thenReturn(200 * 1024 * 1024L); - - List tasks = Arrays.asList(task1, task2); - RewriteDataGroup group = new RewriteDataGroup(tasks); - - Assertions.assertFalse(group.isEmpty()); - Assertions.assertEquals(2, group.getTaskCount()); - Assertions.assertEquals(300 * 1024 * 1024L, group.getTotalSize()); // 100MB + 200MB - Assertions.assertEquals(2, group.getDeleteFileCount()); // 1 + 1 - Assertions.assertEquals(2, group.getDataFiles().size()); - Assertions.assertTrue(group.getDataFiles().contains(dataFile1)); - Assertions.assertTrue(group.getDataFiles().contains(dataFile2)); - } - - @Test - public void testParametersEdgeCases() { - // Test with zero values - RewriteDataFilePlanner.Parameters zeroParams = new RewriteDataFilePlanner.Parameters( - 0L, 0L, 0L, 0, true, 0L, 0, 0.0, 0L, - Optional.empty()); - - Assertions.assertEquals(0L, zeroParams.getTargetFileSizeBytes()); - Assertions.assertEquals(0L, zeroParams.getMinFileSizeBytes()); - Assertions.assertEquals(0L, zeroParams.getMaxFileSizeBytes()); - Assertions.assertEquals(0, zeroParams.getMinInputFiles()); - Assertions.assertTrue(zeroParams.isRewriteAll()); - Assertions.assertEquals(0L, zeroParams.getMaxFileGroupSizeBytes()); - Assertions.assertEquals(0, zeroParams.getDeleteFileThreshold()); - Assertions.assertEquals(0.0, zeroParams.getDeleteRatioThreshold(), 0.001); - } - - @Test - public void testFileSizeFilteringWithMultipleFiles() throws UserException { - // Create multiple file scan tasks with different sizes - FileScanTask smallFile = Mockito.mock(FileScanTask.class); - FileScanTask mediumFile = Mockito.mock(FileScanTask.class); - FileScanTask largeFile = Mockito.mock(FileScanTask.class); - DataFile smallDataFile = Mockito.mock(DataFile.class); - DataFile mediumDataFile = Mockito.mock(DataFile.class); - DataFile largeDataFile = Mockito.mock(DataFile.class); - - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()) - .thenReturn(CloseableIterable.withNoopClose(Arrays.asList(smallFile, mediumFile, largeFile))); - - // Small file (should be filtered out) - Mockito.when(smallFile.file()).thenReturn(smallDataFile); - Mockito.when(smallFile.spec()).thenReturn(mockPartitionSpec); - Mockito.when(smallFile.deletes()).thenReturn(null); - Mockito.when(smallDataFile.fileSizeInBytes()).thenReturn(32 * 1024 * 1024L); // 32MB < 64MB min - Mockito.when(smallDataFile.partition()).thenReturn(mockPartition); - - // Medium file (should be included) - Mockito.when(mediumFile.file()).thenReturn(mediumDataFile); - Mockito.when(mediumFile.spec()).thenReturn(mockPartitionSpec); - Mockito.when(mediumFile.deletes()).thenReturn(null); - Mockito.when(mediumDataFile.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); // 100MB between 64MB-256MB - Mockito.when(mediumDataFile.partition()).thenReturn(mockPartition); - - // Large file (should be filtered out) - Mockito.when(largeFile.file()).thenReturn(largeDataFile); - Mockito.when(largeFile.spec()).thenReturn(mockPartitionSpec); - Mockito.when(largeFile.deletes()).thenReturn(null); - Mockito.when(largeDataFile.fileSizeInBytes()).thenReturn(300 * 1024 * 1024L); // 300MB > 256MB max - Mockito.when(largeDataFile.partition()).thenReturn(mockPartition); - - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - List result = planner.planAndOrganizeTasks(mockTable); - - // Small file (32MB) and large file (300MB) are outside range, but single files are filtered by group rules - // Medium file (100MB) is in range, so not selected for rewrite - // With minInputFiles=2, we need at least 2 files. We have 2 files that need rewriting (small+large) - // but they form a group of 2 files which meets the minInputFiles requirement - Assertions.assertTrue(result.size() >= 1); - if (!result.isEmpty()) { - Assertions.assertEquals(2, result.get(0).getTaskCount()); - } - } - - @Test - public void testDeleteFileThresholdWithMultipleFiles() throws UserException { - // Create multiple file scan tasks with different delete file counts - FileScanTask lowDeletes = Mockito.mock(FileScanTask.class); - FileScanTask highDeletes = Mockito.mock(FileScanTask.class); - DataFile dataFile1 = Mockito.mock(DataFile.class); - DataFile dataFile2 = Mockito.mock(DataFile.class); - DeleteFile deleteFile1 = Mockito.mock(DeleteFile.class); - DeleteFile deleteFile2 = Mockito.mock(DeleteFile.class); - DeleteFile deleteFile3 = Mockito.mock(DeleteFile.class); - - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()) - .thenReturn(CloseableIterable.withNoopClose(Arrays.asList(lowDeletes, highDeletes))); - - // File with low delete count (should be filtered out) - Mockito.when(lowDeletes.file()).thenReturn(dataFile1); - Mockito.when(lowDeletes.spec()).thenReturn(mockPartitionSpec); - Mockito.when(lowDeletes.deletes()).thenReturn(Arrays.asList(deleteFile1, deleteFile2)); // 2 < 3 threshold - Mockito.when(dataFile1.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(dataFile1.partition()).thenReturn(mockPartition); - - // File with high delete count (should be included) - Mockito.when(highDeletes.file()).thenReturn(dataFile2); - Mockito.when(highDeletes.spec()).thenReturn(mockPartitionSpec); - Mockito.when(highDeletes.deletes()).thenReturn(Arrays.asList(deleteFile1, deleteFile2, deleteFile3)); // 3 = 3 threshold - Mockito.when(dataFile2.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(dataFile2.partition()).thenReturn(mockPartition); - - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - List result = planner.planAndOrganizeTasks(mockTable); - - // Only high delete count file should be included (low delete file filtered by filterFiles) - Assertions.assertEquals(1, result.size(), "Should have exactly 1 group"); - Assertions.assertEquals(1, result.get(0).getTaskCount(), "Group should contain only 1 file (high delete count)"); - Assertions.assertEquals(100 * 1024 * 1024L, result.get(0).getTotalSize(), "Total size should be 100MB"); - Assertions.assertTrue(result.get(0).getDataFiles().contains(dataFile2), "Should contain the high-delete file"); - Assertions.assertFalse(result.get(0).getDataFiles().contains(dataFile1), "Should NOT contain the low-delete file"); - } - - @Test - public void testDeleteRatioThresholdWithMultipleFiles() throws UserException { - // Create multiple file scan tasks with different delete ratios - FileScanTask lowRatio = Mockito.mock(FileScanTask.class); - FileScanTask highRatio = Mockito.mock(FileScanTask.class); - DataFile dataFile1 = Mockito.mock(DataFile.class); - DataFile dataFile2 = Mockito.mock(DataFile.class); - DeleteFile deleteFile1 = Mockito.mock(DeleteFile.class); - DeleteFile deleteFile2 = Mockito.mock(DeleteFile.class); - - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()).thenReturn(CloseableIterable.withNoopClose(Arrays.asList(lowRatio, highRatio))); - - // File with low delete ratio (should be filtered out) - Mockito.when(lowRatio.file()).thenReturn(dataFile1); - Mockito.when(lowRatio.spec()).thenReturn(mockPartitionSpec); - Mockito.when(lowRatio.deletes()).thenReturn(Collections.singletonList(deleteFile1)); - Mockito.when(dataFile1.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(dataFile1.partition()).thenReturn(mockPartition); - Mockito.when(deleteFile1.recordCount()).thenReturn(5L); - Mockito.when(dataFile1.recordCount()).thenReturn(100L); // 5/100 = 5% < 10% threshold - - // File with high delete ratio (should be included) - Mockito.when(highRatio.file()).thenReturn(dataFile2); - Mockito.when(highRatio.spec()).thenReturn(mockPartitionSpec); - Mockito.when(highRatio.deletes()).thenReturn(Collections.singletonList(deleteFile2)); - Mockito.when(dataFile2.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(dataFile2.partition()).thenReturn(mockPartition); - Mockito.when(deleteFile2.recordCount()).thenReturn(15L); - Mockito.when(dataFile2.recordCount()).thenReturn(100L); // 15/100 = 15% > 10% threshold - - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - List result = planner.planAndOrganizeTasks(mockTable); - - // Delete ratio calculation requires ContentFileUtil.isFileScoped which cannot be easily mocked - // Single file groups are also filtered out by group rules - // So result should be empty or have limited entries - Assertions.assertTrue(result.size() <= 1, "Result should have at most 1 group (limited by ContentFileUtil and group rules)"); - if (!result.isEmpty()) { - Assertions.assertTrue(result.get(0).getTaskCount() <= 2, "If group exists, should have at most 2 tasks"); - } - } - - @Test - public void testGroupFilteringWithMultipleFiles() throws UserException { - // Create parameters that require multiple files for grouping - RewriteDataFilePlanner.Parameters params = new RewriteDataFilePlanner.Parameters( - 128 * 1024 * 1024L, // targetFileSizeBytes: 128MB - 64 * 1024 * 1024L, // minFileSizeBytes: 64MB - 256 * 1024 * 1024L, // maxFileSizeBytes: 256MB - 3, // minInputFiles: 3 - false, // rewriteAll - 512 * 1024 * 1024L, // maxFileGroupSizeBytes: 512MB - 3, // deleteFileThreshold - 0.1, // deleteRatioThreshold: 10% - 1L, // outputSpecId - Optional.empty() // whereCondition - ); - - RewriteDataFilePlanner testPlanner = new RewriteDataFilePlanner(params); - - // Create multiple file scan tasks - FileScanTask task1 = Mockito.mock(FileScanTask.class); - FileScanTask task2 = Mockito.mock(FileScanTask.class); - FileScanTask task3 = Mockito.mock(FileScanTask.class); - DataFile dataFile1 = Mockito.mock(DataFile.class); - DataFile dataFile2 = Mockito.mock(DataFile.class); - DataFile dataFile3 = Mockito.mock(DataFile.class); - - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()).thenReturn(CloseableIterable.withNoopClose(Arrays.asList(task1, task2, task3))); - - // All files have acceptable size and no delete issues - Mockito.when(task1.file()).thenReturn(dataFile1); - Mockito.when(task1.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task1.deletes()).thenReturn(null); - Mockito.when(dataFile1.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(dataFile1.partition()).thenReturn(mockPartition); - - Mockito.when(task2.file()).thenReturn(dataFile2); - Mockito.when(task2.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task2.deletes()).thenReturn(null); - Mockito.when(dataFile2.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(dataFile2.partition()).thenReturn(mockPartition); - - Mockito.when(task3.file()).thenReturn(dataFile3); - Mockito.when(task3.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task3.deletes()).thenReturn(null); - Mockito.when(dataFile3.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(dataFile3.partition()).thenReturn(mockPartition); - - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - List result = testPlanner.planAndOrganizeTasks(mockTable); - - // Files in acceptable range (64-256MB) are not selected for rewrite - // All 3 files are 100MB which is in range, so they are filtered out - Assertions.assertTrue(result.isEmpty(), "All 3 files (100MB each) are in acceptable range [64-256MB], should not be rewritten"); - } - - @Test - public void testBoundaryValueFileSizes() throws UserException { - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()) - .thenReturn(CloseableIterable.withNoopClose(Collections.singletonList(mockFileScanTask))); - Mockito.when(mockFileScanTask.file()).thenReturn(mockDataFile); - Mockito.when(mockFileScanTask.spec()).thenReturn(mockPartitionSpec); - Mockito.when(mockFileScanTask.deletes()).thenReturn(null); - Mockito.when(mockDataFile.partition()).thenReturn(mockPartition); - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - // Test file size exactly at minFileSizeBytes (should NOT be selected for rewrite) - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(64 * 1024 * 1024L); // Exactly 64MB - List result = planner.planAndOrganizeTasks(mockTable); - Assertions.assertTrue(result.isEmpty(), "File at exactly minFileSizeBytes (64MB) should NOT be selected"); - - // Test file size just below minFileSizeBytes (should be selected but filtered by group rules) - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(64 * 1024 * 1024L - 1); // 64MB - 1 - result = planner.planAndOrganizeTasks(mockTable); - Assertions.assertTrue(result.isEmpty(), "Single file at 64MB-1 selected but filtered by group rules"); - - // Test file size exactly at maxFileSizeBytes (should NOT be selected for rewrite) - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(256 * 1024 * 1024L); // Exactly 256MB - result = planner.planAndOrganizeTasks(mockTable); - Assertions.assertTrue(result.isEmpty(), "File at exactly maxFileSizeBytes (256MB) should NOT be selected"); - - // Test file size just above maxFileSizeBytes (should be selected but filtered by group rules) - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(256 * 1024 * 1024L + 1); // 256MB + 1 - result = planner.planAndOrganizeTasks(mockTable); - Assertions.assertTrue(result.isEmpty(), "Single file at 256MB+1 selected but filtered by group rules"); - } - - @Test - public void testEnoughContentTrigger() throws UserException { - // Create parameters with specific target size - RewriteDataFilePlanner.Parameters params = new RewriteDataFilePlanner.Parameters( - 100 * 1024 * 1024L, // targetFileSizeBytes: 100MB - 50 * 1024 * 1024L, // minFileSizeBytes: 50MB - 200 * 1024 * 1024L, // maxFileSizeBytes: 200MB - 2, // minInputFiles: 2 - false, // rewriteAll - 500 * 1024 * 1024L, // maxFileGroupSizeBytes: 500MB - 3, // deleteFileThreshold - 0.1, // deleteRatioThreshold: 10% - 1L, // outputSpecId - Optional.empty() // whereCondition - ); - - RewriteDataFilePlanner testPlanner = new RewriteDataFilePlanner(params); - - // Create two small files that together exceed target size - FileScanTask task1 = Mockito.mock(FileScanTask.class); - FileScanTask task2 = Mockito.mock(FileScanTask.class); - DataFile dataFile1 = Mockito.mock(DataFile.class); - DataFile dataFile2 = Mockito.mock(DataFile.class); - - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()).thenReturn(CloseableIterable.withNoopClose(Arrays.asList(task1, task2))); - - // Both files are too small (< 50MB min), so they will be selected for rewrite - Mockito.when(task1.file()).thenReturn(dataFile1); - Mockito.when(task1.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task1.deletes()).thenReturn(null); - Mockito.when(dataFile1.fileSizeInBytes()).thenReturn(40 * 1024 * 1024L); // 40MB < 50MB min - Mockito.when(dataFile1.partition()).thenReturn(mockPartition); - - Mockito.when(task2.file()).thenReturn(dataFile2); - Mockito.when(task2.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task2.deletes()).thenReturn(null); - Mockito.when(dataFile2.fileSizeInBytes()).thenReturn(70 * 1024 * 1024L); // 70MB > 50MB min but < 200MB max, in range! - Mockito.when(dataFile2.partition()).thenReturn(mockPartition); - - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - List result = testPlanner.planAndOrganizeTasks(mockTable); - - // file1 (40MB) is selected by filterFiles (< 50MB min) - // file2 (70MB) is in range, not selected - // Only 1 file in group, doesn't meet minInputFiles requirement - Assertions.assertTrue(result.isEmpty(), "Only 1 file selected (taskCount=1 < minInputFiles=2), should be filtered"); - } - - @Test - public void testEnoughContentTriggerWithBothFilesTooSmall() throws UserException { - // Create parameters with specific target size - RewriteDataFilePlanner.Parameters params = new RewriteDataFilePlanner.Parameters( - 100 * 1024 * 1024L, // targetFileSizeBytes: 100MB - 50 * 1024 * 1024L, // minFileSizeBytes: 50MB - 200 * 1024 * 1024L, // maxFileSizeBytes: 200MB - 2, // minInputFiles: 2 - false, // rewriteAll - 500 * 1024 * 1024L, // maxFileGroupSizeBytes: 500MB - 3, // deleteFileThreshold - 0.1, // deleteRatioThreshold: 10% - 1L, // outputSpecId - Optional.empty() // whereCondition - ); - - RewriteDataFilePlanner testPlanner = new RewriteDataFilePlanner(params); - - // Create two small files that together exceed target size - FileScanTask task1 = Mockito.mock(FileScanTask.class); - FileScanTask task2 = Mockito.mock(FileScanTask.class); - DataFile dataFile1 = Mockito.mock(DataFile.class); - DataFile dataFile2 = Mockito.mock(DataFile.class); - - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()).thenReturn(CloseableIterable.withNoopClose(Arrays.asList(task1, task2))); - - // Both files are too small (< 50MB min), so they will be selected for rewrite - Mockito.when(task1.file()).thenReturn(dataFile1); - Mockito.when(task1.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task1.deletes()).thenReturn(null); - Mockito.when(dataFile1.fileSizeInBytes()).thenReturn(40 * 1024 * 1024L); // 40MB < 50MB min - Mockito.when(dataFile1.partition()).thenReturn(mockPartition); - - Mockito.when(task2.file()).thenReturn(dataFile2); - Mockito.when(task2.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task2.deletes()).thenReturn(null); - Mockito.when(dataFile2.fileSizeInBytes()).thenReturn(45 * 1024 * 1024L); // 45MB < 50MB min - Mockito.when(dataFile2.partition()).thenReturn(mockPartition); - - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - List result = testPlanner.planAndOrganizeTasks(mockTable); - - // Both files are too small and selected - // Total size = 85MB < 100MB target (so enoughContent doesn't trigger) - // But taskCount = 2 >= 2 minInputFiles (so enoughInputFiles triggers) - Assertions.assertEquals(1, result.size()); - Assertions.assertEquals(2, result.get(0).getTaskCount()); - Assertions.assertEquals(85 * 1024 * 1024L, result.get(0).getTotalSize()); - } - - @Test - public void testEnoughContentWithLargerFiles() throws UserException { - // Test specifically for enoughContent condition: taskCount > 1 && totalSize > targetSize - RewriteDataFilePlanner.Parameters params = new RewriteDataFilePlanner.Parameters( - 80 * 1024 * 1024L, // targetFileSizeBytes: 80MB - 50 * 1024 * 1024L, // minFileSizeBytes: 50MB - 200 * 1024 * 1024L, // maxFileSizeBytes: 200MB - 5, // minInputFiles: 5 (high threshold, won't be met) - false, // rewriteAll - 500 * 1024 * 1024L, // maxFileGroupSizeBytes: 500MB - 3, // deleteFileThreshold - 0.1, // deleteRatioThreshold: 10% - 1L, // outputSpecId - Optional.empty() // whereCondition - ); - - RewriteDataFilePlanner testPlanner = new RewriteDataFilePlanner(params); - - FileScanTask task1 = Mockito.mock(FileScanTask.class); - FileScanTask task2 = Mockito.mock(FileScanTask.class); - DataFile dataFile1 = Mockito.mock(DataFile.class); - DataFile dataFile2 = Mockito.mock(DataFile.class); - - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()).thenReturn(CloseableIterable.withNoopClose(Arrays.asList(task1, task2))); - - // Both files too small - Mockito.when(task1.file()).thenReturn(dataFile1); - Mockito.when(task1.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task1.deletes()).thenReturn(null); - Mockito.when(dataFile1.fileSizeInBytes()).thenReturn(45 * 1024 * 1024L); // 45MB < 50MB - Mockito.when(dataFile1.partition()).thenReturn(mockPartition); - - Mockito.when(task2.file()).thenReturn(dataFile2); - Mockito.when(task2.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task2.deletes()).thenReturn(null); - Mockito.when(dataFile2.fileSizeInBytes()).thenReturn(48 * 1024 * 1024L); // 48MB < 50MB - Mockito.when(dataFile2.partition()).thenReturn(mockPartition); - - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - List result = testPlanner.planAndOrganizeTasks(mockTable); - - // taskCount = 2 < 5 minInputFiles (enoughInputFiles = false) - // totalSize = 93MB > 80MB target && taskCount = 2 > 1 (enoughContent = true!) - Assertions.assertEquals(1, result.size()); - Assertions.assertEquals(2, result.get(0).getTaskCount()); - Assertions.assertEquals(93 * 1024 * 1024L, result.get(0).getTotalSize()); - } - - @Test - public void testTooMuchContentTrigger() throws UserException { - // Create parameters with very small maxFileGroupSizeBytes - RewriteDataFilePlanner.Parameters params = new RewriteDataFilePlanner.Parameters( - 128 * 1024 * 1024L, // targetFileSizeBytes: 128MB - 64 * 1024 * 1024L, // minFileSizeBytes: 64MB - 256 * 1024 * 1024L, // maxFileSizeBytes: 256MB - 2, // minInputFiles: 2 - false, // rewriteAll - 50 * 1024 * 1024L, // maxFileGroupSizeBytes: 50MB (very small!) - 3, // deleteFileThreshold - 0.1, // deleteRatioThreshold: 10% - 1L, // outputSpecId - Optional.empty() // whereCondition - ); - - RewriteDataFilePlanner testPlanner = new RewriteDataFilePlanner(params); - - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()) - .thenReturn(CloseableIterable.withNoopClose(Collections.singletonList(mockFileScanTask))); - Mockito.when(mockFileScanTask.file()).thenReturn(mockDataFile); - Mockito.when(mockFileScanTask.spec()).thenReturn(mockPartitionSpec); - Mockito.when(mockFileScanTask.deletes()).thenReturn(null); - // File is too large (> 256MB max), so it will be selected by filterFiles - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(300 * 1024 * 1024L); // 300MB - Mockito.when(mockDataFile.partition()).thenReturn(mockPartition); - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - List result = testPlanner.planAndOrganizeTasks(mockTable); - - // Even though it's a single file (normally filtered), 300MB > 50MB maxFileGroupSize - // This triggers tooMuchContent, so the file should be included - Assertions.assertEquals(1, result.size()); - Assertions.assertEquals(1, result.get(0).getTaskCount()); - Assertions.assertEquals(300 * 1024 * 1024L, result.get(0).getTotalSize()); - } - - @Test - public void testGroupWithAnyFileHavingDeletes() throws UserException { - // Test that if any file in a group has delete issues, the whole group is selected - FileScanTask cleanTask = Mockito.mock(FileScanTask.class); - FileScanTask dirtyTask = Mockito.mock(FileScanTask.class); - DataFile cleanFile = Mockito.mock(DataFile.class); - DataFile dirtyFile = Mockito.mock(DataFile.class); - - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()).thenReturn(CloseableIterable.withNoopClose(Arrays.asList(cleanTask, dirtyTask))); - - // Clean file - no deletes, size in range - Mockito.when(cleanTask.file()).thenReturn(cleanFile); - Mockito.when(cleanTask.spec()).thenReturn(mockPartitionSpec); - Mockito.when(cleanTask.deletes()).thenReturn(null); - Mockito.when(cleanFile.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(cleanFile.partition()).thenReturn(mockPartition); - - // Dirty file - has deletes exceeding threshold, size in range - Mockito.when(dirtyTask.file()).thenReturn(dirtyFile); - Mockito.when(dirtyTask.spec()).thenReturn(mockPartitionSpec); - Mockito.when(dirtyTask.deletes()).thenReturn(Arrays.asList(mockDeleteFile, mockDeleteFile, mockDeleteFile)); // 3 >= 3 threshold - Mockito.when(dirtyFile.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(dirtyFile.partition()).thenReturn(mockPartition); - - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - List result = planner.planAndOrganizeTasks(mockTable); - - // The dirty file triggers the group to be selected (via shouldRewriteGroup) - // Only dirty file is selected by filterFiles (cleanFile is in acceptable range) - Assertions.assertEquals(1, result.size(), "Should have exactly 1 group"); - Assertions.assertEquals(1, result.get(0).getTaskCount(), "Group should contain 1 task (dirty file only)"); - Assertions.assertEquals(100 * 1024 * 1024L, result.get(0).getTotalSize(), "Total size should be 100MB (dirty file)"); - Assertions.assertTrue(result.get(0).getDataFiles().contains(dirtyFile), "Should contain dirty file"); - Assertions.assertFalse(result.get(0).getDataFiles().contains(cleanFile), "Should NOT contain clean file (not selected by filterFiles)"); - } - - @Test - public void testMixedScenarioWithMultiplePartitions() throws UserException { - // Create a complex scenario with multiple partitions and mixed file sizes - FileScanTask task1 = Mockito.mock(FileScanTask.class); - FileScanTask task2 = Mockito.mock(FileScanTask.class); - FileScanTask task3 = Mockito.mock(FileScanTask.class); - FileScanTask task4 = Mockito.mock(FileScanTask.class); - DataFile file1 = Mockito.mock(DataFile.class); - DataFile file2 = Mockito.mock(DataFile.class); - DataFile file3 = Mockito.mock(DataFile.class); - DataFile file4 = Mockito.mock(DataFile.class); - StructLike partition1 = Mockito.mock(StructLike.class); - StructLike partition2 = Mockito.mock(StructLike.class); - - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()).thenReturn(CloseableIterable.withNoopClose( - Arrays.asList(task1, task2, task3, task4))); - - // Partition 1: Two small files that should be rewritten - Mockito.when(task1.file()).thenReturn(file1); - Mockito.when(task1.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task1.deletes()).thenReturn(null); - Mockito.when(file1.fileSizeInBytes()).thenReturn(30 * 1024 * 1024L); // Too small - Mockito.when(file1.partition()).thenReturn(partition1); - - Mockito.when(task2.file()).thenReturn(file2); - Mockito.when(task2.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task2.deletes()).thenReturn(null); - Mockito.when(file2.fileSizeInBytes()).thenReturn(40 * 1024 * 1024L); // Too small - Mockito.when(file2.partition()).thenReturn(partition1); - - // Partition 2: One large file + one good file - Mockito.when(task3.file()).thenReturn(file3); - Mockito.when(task3.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task3.deletes()).thenReturn(null); - Mockito.when(file3.fileSizeInBytes()).thenReturn(300 * 1024 * 1024L); // Too large - Mockito.when(file3.partition()).thenReturn(partition2); - - Mockito.when(task4.file()).thenReturn(file4); - Mockito.when(task4.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task4.deletes()).thenReturn(null); - Mockito.when(file4.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); // Good size - Mockito.when(file4.partition()).thenReturn(partition2); - - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - List result = planner.planAndOrganizeTasks(mockTable); - - // Note: StructLikeWrapper may group all mocked StructLike objects together - // Because we can't properly mock partition equality, the exact grouping is unpredictable - // We can only verify that groups are created for files needing rewrite - Assertions.assertTrue(result.size() >= 1, "Should have at least 1 group"); - - // Verify total files selected for rewrite - int totalFiles = result.stream().mapToInt(RewriteDataGroup::getTaskCount).sum(); - long totalSize = result.stream().mapToLong(RewriteDataGroup::getTotalSize).sum(); - // file1 (30MB), file2 (40MB), file3 (300MB) are outside range → selected - // file4 (100MB) is in range → NOT selected - // So we expect 2-3 files (depending on grouping and single-file filtering) - Assertions.assertTrue(totalFiles >= 2, "Should have at least 2 files selected for rewrite"); - Assertions.assertTrue(totalFiles <= 3, "Should have at most 3 files selected"); - // Total size should be at least the 2 small files - Assertions.assertTrue(totalSize >= 70 * 1024 * 1024L, "Total size should be at least 70MB (file1+file2)"); - } - - @Test - public void testMultipleSmallFilesGroupedTogether() throws UserException { - // Test that multiple small files in same partition are grouped and selected - FileScanTask task1 = Mockito.mock(FileScanTask.class); - FileScanTask task2 = Mockito.mock(FileScanTask.class); - FileScanTask task3 = Mockito.mock(FileScanTask.class); - DataFile file1 = Mockito.mock(DataFile.class); - DataFile file2 = Mockito.mock(DataFile.class); - DataFile file3 = Mockito.mock(DataFile.class); - - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()).thenReturn(CloseableIterable.withNoopClose( - Arrays.asList(task1, task2, task3))); - - // All files are too small - Mockito.when(task1.file()).thenReturn(file1); - Mockito.when(task1.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task1.deletes()).thenReturn(null); - Mockito.when(file1.fileSizeInBytes()).thenReturn(40 * 1024 * 1024L); // 40MB < 64MB min - Mockito.when(file1.partition()).thenReturn(mockPartition); - - Mockito.when(task2.file()).thenReturn(file2); - Mockito.when(task2.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task2.deletes()).thenReturn(null); - Mockito.when(file2.fileSizeInBytes()).thenReturn(50 * 1024 * 1024L); // 50MB < 64MB min - Mockito.when(file2.partition()).thenReturn(mockPartition); - - Mockito.when(task3.file()).thenReturn(file3); - Mockito.when(task3.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task3.deletes()).thenReturn(null); - Mockito.when(file3.fileSizeInBytes()).thenReturn(45 * 1024 * 1024L); // 45MB < 64MB min - Mockito.when(file3.partition()).thenReturn(mockPartition); - - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - List result = planner.planAndOrganizeTasks(mockTable); - - // All 3 files are too small, grouped together, total = 135MB > 128MB target - // taskCount = 3 >= 2 minInputFiles, should be selected via enoughInputFiles or enoughContent - Assertions.assertEquals(1, result.size(), "Should have exactly 1 group"); - Assertions.assertEquals(3, result.get(0).getTaskCount(), "Group should contain all 3 small files"); - Assertions.assertEquals(135 * 1024 * 1024L, result.get(0).getTotalSize(), "Total size should be 135MB (40+50+45)"); - // Verify all three files are in the group - Assertions.assertTrue(result.get(0).getDataFiles().contains(file1), "Should contain file1"); - Assertions.assertTrue(result.get(0).getDataFiles().contains(file2), "Should contain file2"); - Assertions.assertTrue(result.get(0).getDataFiles().contains(file3), "Should contain file3"); - } - - @Test - public void testDeleteFileThresholdBoundary() throws UserException { - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()) - .thenReturn(CloseableIterable.withNoopClose(Collections.singletonList(mockFileScanTask))); - Mockito.when(mockFileScanTask.file()).thenReturn(mockDataFile); - Mockito.when(mockFileScanTask.spec()).thenReturn(mockPartitionSpec); - Mockito.when(mockDataFile.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); - Mockito.when(mockDataFile.partition()).thenReturn(mockPartition); - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - // Test with delete count exactly at threshold - 1 (should NOT be selected) - DeleteFile delete1 = Mockito.mock(DeleteFile.class); - DeleteFile delete2 = Mockito.mock(DeleteFile.class); - Mockito.when(mockFileScanTask.deletes()).thenReturn(Arrays.asList(delete1, delete2)); // 2 < 3 threshold - - List result = planner.planAndOrganizeTasks(mockTable); - Assertions.assertTrue(result.isEmpty(), "File with 2 delete files (< 3 threshold) should not be selected"); - - // Test with delete count exactly at threshold (should be selected) - DeleteFile delete3 = Mockito.mock(DeleteFile.class); - Mockito.when(mockFileScanTask.deletes()).thenReturn(Arrays.asList(delete1, delete2, delete3)); // 3 >= 3 threshold - - result = planner.planAndOrganizeTasks(mockTable); - Assertions.assertEquals(1, result.size(), "File with 3 delete files (>= 3 threshold) should be selected"); - Assertions.assertEquals(1, result.get(0).getTaskCount(), "Group should contain 1 task"); - Assertions.assertEquals(100 * 1024 * 1024L, result.get(0).getTotalSize(), "Total size should be 100MB"); - - // Test with delete count above threshold (should be selected) - DeleteFile delete4 = Mockito.mock(DeleteFile.class); - Mockito.when(mockFileScanTask.deletes()).thenReturn(Arrays.asList(delete1, delete2, delete3, delete4)); // 4 >= 3 - - result = planner.planAndOrganizeTasks(mockTable); - Assertions.assertEquals(1, result.size(), "File with 4 delete files (> 3 threshold) should be selected"); - Assertions.assertEquals(1, result.get(0).getTaskCount(), "Group should contain 1 task"); - Assertions.assertEquals(100 * 1024 * 1024L, result.get(0).getTotalSize(), "Total size should be 100MB"); - } - - @Test - public void testBinPackGroupingWithLargePartition() throws UserException { - // Test binPack grouping: when a partition has files exceeding maxFileGroupSizeBytes, - // they should be split into multiple groups - RewriteDataFilePlanner.Parameters params = new RewriteDataFilePlanner.Parameters( - 128 * 1024 * 1024L, // targetFileSizeBytes: 128MB - 64 * 1024 * 1024L, // minFileSizeBytes: 64MB - 256 * 1024 * 1024L, // maxFileSizeBytes: 256MB - 1, // minInputFiles: 1 - true, // rewriteAll: true (to avoid filtering) - 200 * 1024 * 1024L, // maxFileGroupSizeBytes: 200MB (small to trigger splitting) - 3, // deleteFileThreshold - 0.1, // deleteRatioThreshold: 10% - 1L, // outputSpecId - Optional.empty() // whereCondition - ); - - RewriteDataFilePlanner testPlanner = new RewriteDataFilePlanner(params); - - // Create multiple files in the same partition that together exceed maxFileGroupSizeBytes - FileScanTask task1 = Mockito.mock(FileScanTask.class); - FileScanTask task2 = Mockito.mock(FileScanTask.class); - FileScanTask task3 = Mockito.mock(FileScanTask.class); - DataFile file1 = Mockito.mock(DataFile.class); - DataFile file2 = Mockito.mock(DataFile.class); - DataFile file3 = Mockito.mock(DataFile.class); - - Mockito.when(mockTable.newScan()).thenReturn(mockTableScan); - Mockito.when(mockTableScan.planFiles()).thenReturn(CloseableIterable.withNoopClose( - Arrays.asList(task1, task2, task3))); - - // All files in the same partition - Mockito.when(task1.file()).thenReturn(file1); - Mockito.when(task1.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task1.deletes()).thenReturn(null); - Mockito.when(file1.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); // 100MB - Mockito.when(file1.partition()).thenReturn(mockPartition); - - Mockito.when(task2.file()).thenReturn(file2); - Mockito.when(task2.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task2.deletes()).thenReturn(null); - Mockito.when(file2.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); // 100MB - Mockito.when(file2.partition()).thenReturn(mockPartition); - - Mockito.when(task3.file()).thenReturn(file3); - Mockito.when(task3.spec()).thenReturn(mockPartitionSpec); - Mockito.when(task3.deletes()).thenReturn(null); - Mockito.when(file3.fileSizeInBytes()).thenReturn(100 * 1024 * 1024L); // 100MB - Mockito.when(file3.partition()).thenReturn(mockPartition); - - Mockito.when(mockPartitionSpec.partitionType()).thenReturn(Types.StructType.of()); - - List result = testPlanner.planAndOrganizeTasks(mockTable); - - // Total size = 300MB > 200MB maxFileGroupSizeBytes - // binPack should split into multiple groups - // Expected: 2 groups (100MB + 100MB in first group, 100MB in second group) - Assertions.assertTrue(result.size() >= 2, "Should have at least 2 groups due to binPack splitting"); - - // Verify total files are preserved - int totalFiles = result.stream().mapToInt(RewriteDataGroup::getTaskCount).sum(); - long totalSize = result.stream().mapToLong(RewriteDataGroup::getTotalSize).sum(); - Assertions.assertEquals(3, totalFiles, "Should have all 3 files"); - Assertions.assertEquals(300 * 1024 * 1024L, totalSize, "Total size should be 300MB"); - - // Verify each group doesn't exceed maxFileGroupSizeBytes - for (RewriteDataGroup group : result) { - Assertions.assertTrue(group.getTotalSize() <= 200 * 1024 * 1024L, - "Each group should not exceed maxFileGroupSizeBytes (200MB)"); - } - } - -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/rewrite/RewriteGroupTaskTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/rewrite/RewriteGroupTaskTest.java deleted file mode 100644 index 93e92655da6881..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/rewrite/RewriteGroupTaskTest.java +++ /dev/null @@ -1,524 +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.datasource.iceberg.rewrite; - -import org.apache.doris.catalog.Env; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.system.SystemInfoService; - -import org.apache.iceberg.DataFile; -import org.apache.iceberg.FileScanTask; -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.Mock; -import org.mockito.MockedStatic; -import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; - -import java.lang.reflect.Method; -import java.util.Arrays; -import java.util.Collections; - -/** - * Unit tests for RewriteGroupTask, specifically testing calculateRewriteStrategy logic - */ -public class RewriteGroupTaskTest { - - @Mock - private RewriteDataGroup mockGroup; - - @Mock - private IcebergExternalTable mockTable; - - @Mock - private ConnectContext mockConnectContext; - - private SessionVariable sessionVariable; - - @Mock - private FileScanTask mockFileScanTask; - - @Mock - private DataFile mockDataFile; - - @Mock - private Env mockEnv; - - @Mock - private SystemInfoService mockSystemInfoService; - - private MockedStatic mockedStaticEnv; - - private static final long MB = 1024 * 1024L; - private static final long GB = 1024 * 1024 * 1024L; - - @BeforeEach - public void setUp() { - MockitoAnnotations.openMocks(this); - - // Setup common mocks - sessionVariable = new SessionVariable(); - sessionVariable.parallelPipelineTaskNum = 8; - sessionVariable.maxInstanceNum = 64; - Mockito.when(mockConnectContext.getSessionVariable()).thenReturn(sessionVariable); - - // Mock Env and SystemInfoService - mockedStaticEnv = Mockito.mockStatic(Env.class); - mockedStaticEnv.when(Env::getCurrentEnv).thenReturn(mockEnv); - mockedStaticEnv.when(Env::getCurrentSystemInfo).thenReturn(mockSystemInfoService); - } - - @AfterEach - public void tearDown() { - // Clean up static mock to avoid "already registered" errors - if (mockedStaticEnv != null) { - mockedStaticEnv.close(); - mockedStaticEnv = null; - } - } - - /** - * Test small data scenario - should use GATHER distribution - * Data: 500MB, Target file size: 512MB - * Expected: 1 file, useGather=true, parallelism=1 - */ - @Test - public void testCalculateRewriteStrategy_SmallData_UseGather() throws Exception { - // Setup: 500MB data, 512MB target file size -> expectedFileCount = 1 - long totalSize = 500 * MB; - long targetFileSizeBytes = 512 * MB; - int availableBeCount = 100; - - Mockito.when(mockGroup.getTotalSize()).thenReturn(totalSize); - Mockito.when(mockGroup.getTasks()).thenReturn(Collections.singletonList(mockFileScanTask)); - - // Create task and invoke private method via reflection - RewriteGroupTask task = new RewriteGroupTask( - mockGroup, 1L, mockTable, mockConnectContext, - targetFileSizeBytes, availableBeCount, null); - - Object strategy = invokeCalculateRewriteStrategy(task); - - // Verify strategy - int parallelism = (int) getFieldValue(strategy, "parallelism"); - boolean useGather = (boolean) getFieldValue(strategy, "useGather"); - - Assertions.assertEquals(1, parallelism, "Parallelism should be 1 for small data"); - Assertions.assertTrue(useGather, "Should use GATHER for small data (expected files <= 1)"); - } - - /** - * Test very small data scenario - data smaller than target file size - * Data: 100MB, Target file size: 512MB - * Expected: 1 file, useGather=true, parallelism=1 - */ - @Test - public void testCalculateRewriteStrategy_VerySmallData_UseGather() throws Exception { - long totalSize = 100 * MB; - long targetFileSizeBytes = 512 * MB; - int availableBeCount = 100; - - Mockito.when(mockGroup.getTotalSize()).thenReturn(totalSize); - Mockito.when(mockGroup.getTasks()).thenReturn(Collections.singletonList(mockFileScanTask)); - - RewriteGroupTask task = new RewriteGroupTask( - mockGroup, 1L, mockTable, mockConnectContext, - targetFileSizeBytes, availableBeCount, null); - - Object strategy = invokeCalculateRewriteStrategy(task); - - int parallelism = (int) getFieldValue(strategy, "parallelism"); - boolean useGather = (boolean) getFieldValue(strategy, "useGather"); - - Assertions.assertEquals(1, parallelism); - Assertions.assertTrue(useGather, "Should use GATHER for very small data"); - } - - /** - * Test medium data scenario - should use GATHER when expected files < available BEs - * Data: 5GB, Target file size: 512MB - * Expected: expectedFileCount=11, useGather=true, parallelism=min(8, 11)=8 - */ - @Test - public void testCalculateRewriteStrategy_MediumData_UseGatherWhenExpectedFilesNotExceedBeCount() - throws Exception { - long totalSize = 5 * GB; - long targetFileSizeBytes = 512 * MB; - int availableBeCount = 100; - - Mockito.when(mockGroup.getTotalSize()).thenReturn(totalSize); - Mockito.when(mockGroup.getTasks()).thenReturn(Collections.singletonList(mockFileScanTask)); - - RewriteGroupTask task = new RewriteGroupTask( - mockGroup, 1L, mockTable, mockConnectContext, - targetFileSizeBytes, availableBeCount, null); - - Object strategy = invokeCalculateRewriteStrategy(task); - - int parallelism = (int) getFieldValue(strategy, "parallelism"); - boolean useGather = (boolean) getFieldValue(strategy, "useGather"); - - // expectedFileCount = ceil(5GB / 512MB) = ceil(10.24) = 11 - // expectedFileCount < availableBeCount, so use GATHER - Assertions.assertEquals(8, parallelism, "Parallelism should be limited by default parallelism"); - Assertions.assertTrue(useGather, "Should use GATHER when expected files < available BEs"); - } - - /** - * Test large data scenario - do NOT use GATHER when expected files == available BEs - * Data: 50GB, Target file size: 512MB - * Expected: expectedFileCount=100, useGather=false, parallelism=min(8, floor(100/100)=1)=1 - */ - @Test - public void testCalculateRewriteStrategy_LargeData_NoGatherWhenExpectedFilesEqualBeCount() - throws Exception { - long totalSize = 50 * GB; - long targetFileSizeBytes = 512 * MB; - int availableBeCount = 100; - - Mockito.when(mockGroup.getTotalSize()).thenReturn(totalSize); - Mockito.when(mockGroup.getTasks()).thenReturn(Collections.singletonList(mockFileScanTask)); - - RewriteGroupTask task = new RewriteGroupTask( - mockGroup, 1L, mockTable, mockConnectContext, - targetFileSizeBytes, availableBeCount, null); - - Object strategy = invokeCalculateRewriteStrategy(task); - - int parallelism = (int) getFieldValue(strategy, "parallelism"); - boolean useGather = (boolean) getFieldValue(strategy, "useGather"); - - // expectedFileCount = ceil(50GB / 512MB) = 100 - // expectedFileCount == availableBeCount, so do not use GATHER - Assertions.assertEquals(1, parallelism, "Parallelism should be limited by expected files / BE count"); - Assertions.assertFalse(useGather, "Should NOT use GATHER when expected files == available BEs"); - } - - /** - * Test boundary case: exactly at threshold (1 file expected) - * Data: 512MB, Target file size: 512MB - * Expected: 1 file, useGather=true, parallelism=1 - */ - @Test - public void testCalculateRewriteStrategy_BoundaryCase_ExactlyOneFile() throws Exception { - long totalSize = 512 * MB; - long targetFileSizeBytes = 512 * MB; - int availableBeCount = 100; - - Mockito.when(mockGroup.getTotalSize()).thenReturn(totalSize); - Mockito.when(mockGroup.getTasks()).thenReturn(Collections.singletonList(mockFileScanTask)); - - RewriteGroupTask task = new RewriteGroupTask( - mockGroup, 1L, mockTable, mockConnectContext, - targetFileSizeBytes, availableBeCount, null); - - Object strategy = invokeCalculateRewriteStrategy(task); - - int parallelism = (int) getFieldValue(strategy, "parallelism"); - boolean useGather = (boolean) getFieldValue(strategy, "useGather"); - - // expectedFileCount = ceil(512MB / 512MB) = 1 - Assertions.assertEquals(1, parallelism); - Assertions.assertTrue(useGather, "Should use GATHER when exactly 1 file expected"); - } - - /** - * Test boundary case: just over 1 file - * Data: 513MB, Target file size: 512MB - * Expected: expectedFileCount=2, useGather=true, parallelism=min(8, 2)=2 - */ - @Test - public void testCalculateRewriteStrategy_BoundaryCase_JustOverOneFile() throws Exception { - long totalSize = 513 * MB; - long targetFileSizeBytes = 512 * MB; - int availableBeCount = 100; - - Mockito.when(mockGroup.getTotalSize()).thenReturn(totalSize); - Mockito.when(mockGroup.getTasks()).thenReturn(Collections.singletonList(mockFileScanTask)); - - RewriteGroupTask task = new RewriteGroupTask( - mockGroup, 1L, mockTable, mockConnectContext, - targetFileSizeBytes, availableBeCount, null); - - Object strategy = invokeCalculateRewriteStrategy(task); - - int parallelism = (int) getFieldValue(strategy, "parallelism"); - boolean useGather = (boolean) getFieldValue(strategy, "useGather"); - - // expectedFileCount = ceil(513MB / 512MB) = 2 - // expectedFileCount < availableBeCount, so use GATHER - Assertions.assertEquals(2, parallelism); - Assertions.assertTrue(useGather, "Should use GATHER when expected files < available BEs"); - } - - /** - * Test boundary case: expected files equal available BEs - should not use GATHER - * Data: 512MB, Target file size: 64MB - * Expected: expectedFileCount=8, useGather=false, parallelism=min(8, floor(8/8)=1)=1 - */ - @Test - public void testCalculateRewriteStrategy_BoundaryCase_EqualToBeCount_NoGather() throws Exception { - long totalSize = 512 * MB; - long targetFileSizeBytes = 64 * MB; - int availableBeCount = 8; - - Mockito.when(mockGroup.getTotalSize()).thenReturn(totalSize); - Mockito.when(mockGroup.getTasks()).thenReturn(Collections.singletonList(mockFileScanTask)); - - RewriteGroupTask task = new RewriteGroupTask( - mockGroup, 1L, mockTable, mockConnectContext, - targetFileSizeBytes, availableBeCount, null); - - Object strategy = invokeCalculateRewriteStrategy(task); - - int parallelism = (int) getFieldValue(strategy, "parallelism"); - boolean useGather = (boolean) getFieldValue(strategy, "useGather"); - - // expectedFileCount = ceil(512MB / 64MB) = 8 - // expectedFileCount == availableBeCount, so do not use GATHER - Assertions.assertEquals(1, parallelism); - Assertions.assertFalse(useGather); - } - - /** - * Test GATHER with high default parallelism - should cap at expected file count - * Data: 513MB, Target file size: 512MB, Default parallelism: 100 - * Expected: expectedFileCount=2, useGather=true, parallelism=min(100, 2)=2 - */ - @Test - public void testCalculateRewriteStrategy_GatherCapsAtExpectedFileCount() throws Exception { - long totalSize = 513 * MB; - long targetFileSizeBytes = 512 * MB; - int availableBeCount = 100; - - Mockito.when(mockGroup.getTotalSize()).thenReturn(totalSize); - Mockito.when(mockGroup.getTasks()).thenReturn(Collections.singletonList(mockFileScanTask)); - sessionVariable.parallelPipelineTaskNum = 100; - - RewriteGroupTask task = new RewriteGroupTask( - mockGroup, 1L, mockTable, mockConnectContext, - targetFileSizeBytes, availableBeCount, null); - - Object strategy = invokeCalculateRewriteStrategy(task); - - int parallelism = (int) getFieldValue(strategy, "parallelism"); - boolean useGather = (boolean) getFieldValue(strategy, "useGather"); - - // expectedFileCount = ceil(513MB / 512MB) = 2 - // expectedFileCount < availableBeCount, so use GATHER - Assertions.assertEquals(2, parallelism); - Assertions.assertTrue(useGather); - } - - /** - * Test with limited BE count - parallelism limited by available BEs - * Data: 10GB, Target file size: 512MB, Available BEs: 5 - * Expected: ~20 files, useGather=false, parallelism=min(8, floor(21/5)=4)=4 - */ - @Test - public void testCalculateRewriteStrategy_LimitedByBeCount() throws Exception { - long totalSize = 10 * GB; - long targetFileSizeBytes = 512 * MB; - int availableBeCount = 5; // Limited BE count - - Mockito.when(mockGroup.getTotalSize()).thenReturn(totalSize); - Mockito.when(mockGroup.getTasks()).thenReturn(Collections.singletonList(mockFileScanTask)); - - RewriteGroupTask task = new RewriteGroupTask( - mockGroup, 1L, mockTable, mockConnectContext, - targetFileSizeBytes, availableBeCount, null); - - Object strategy = invokeCalculateRewriteStrategy(task); - - int parallelism = (int) getFieldValue(strategy, "parallelism"); - boolean useGather = (boolean) getFieldValue(strategy, "useGather"); - - // expectedFileCount = ceil(10GB / 512MB) = ceil(20.48) = 21 - // maxParallelismByFileCount = floor(21 / 5) = 4 - // optimalParallelism = min(8, 4) = 4 - Assertions.assertEquals(4, parallelism, "Parallelism should be limited by expected files / BE count"); - Assertions.assertFalse(useGather); - } - - /** - * Test with high default parallelism - still use GATHER if expected files < available BEs - * Data: 2GB, Target file size: 512MB, Default parallelism: 100 - * Expected: expectedFileCount=4, useGather=true, parallelism=min(100, 4)=4 - */ - @Test - public void testCalculateRewriteStrategy_UseGatherEvenWithHighDefaultParallelism() throws Exception { - long totalSize = 2 * GB; - long targetFileSizeBytes = 512 * MB; - int availableBeCount = 100; - - Mockito.when(mockGroup.getTotalSize()).thenReturn(totalSize); - Mockito.when(mockGroup.getTasks()).thenReturn(Collections.singletonList(mockFileScanTask)); - sessionVariable.parallelPipelineTaskNum = 100; - - RewriteGroupTask task = new RewriteGroupTask( - mockGroup, 1L, mockTable, mockConnectContext, - targetFileSizeBytes, availableBeCount, null); - - Object strategy = invokeCalculateRewriteStrategy(task); - - int parallelism = (int) getFieldValue(strategy, "parallelism"); - boolean useGather = (boolean) getFieldValue(strategy, "useGather"); - - // expectedFileCount = ceil(2GB / 512MB) = ceil(4.0) = 4 - // expectedFileCount < availableBeCount, so use GATHER - Assertions.assertEquals(4, parallelism, "Parallelism should be limited by expected file count"); - Assertions.assertTrue(useGather); - } - - /** - * Test with very small target file size - * Data: 1GB, Target file size: 100MB - * Expected: 11 files, useGather=true, parallelism=min(11, 100, 8)=8 - */ - @Test - public void testCalculateRewriteStrategy_SmallTargetFileSize() throws Exception { - long totalSize = 1 * GB; - long targetFileSizeBytes = 100 * MB; // Small target file size - int availableBeCount = 100; - - Mockito.when(mockGroup.getTotalSize()).thenReturn(totalSize); - Mockito.when(mockGroup.getTasks()).thenReturn(Collections.singletonList(mockFileScanTask)); - - RewriteGroupTask task = new RewriteGroupTask( - mockGroup, 1L, mockTable, mockConnectContext, - targetFileSizeBytes, availableBeCount, null); - - Object strategy = invokeCalculateRewriteStrategy(task); - - int parallelism = (int) getFieldValue(strategy, "parallelism"); - boolean useGather = (boolean) getFieldValue(strategy, "useGather"); - - // expectedFileCount = ceil(1GB / 100MB) = ceil(10.24) = 11 - // expectedFileCount < availableBeCount, so use GATHER - Assertions.assertEquals(8, parallelism); - Assertions.assertTrue(useGather); - } - - /** - * Test minimum parallelism guarantee - * Data: 0 bytes (edge case), Target file size: 512MB - * Expected: parallelism=1 (guaranteed minimum) - */ - @Test - public void testCalculateRewriteStrategy_ZeroData_MinimumParallelism() throws Exception { - long totalSize = 0L; - long targetFileSizeBytes = 512 * MB; - int availableBeCount = 100; - - Mockito.when(mockGroup.getTotalSize()).thenReturn(totalSize); - Mockito.when(mockGroup.getTasks()).thenReturn(Collections.emptyList()); - - RewriteGroupTask task = new RewriteGroupTask( - mockGroup, 1L, mockTable, mockConnectContext, - targetFileSizeBytes, availableBeCount, null); - - Object strategy = invokeCalculateRewriteStrategy(task); - - int parallelism = (int) getFieldValue(strategy, "parallelism"); - - // expectedFileCount = ceil(0 / 512MB) = 0 - // optimalParallelism = max(1, min(0, 100, 8)) = max(1, 0) = 1 - Assertions.assertEquals(1, parallelism, "Parallelism should be at least 1"); - } - - /** - * Test invalid BE count - should throw when available BE count is zero - */ - @Test - public void testCalculateRewriteStrategy_ZeroAvailableBe_Throws() throws Exception { - long totalSize = 1 * GB; - long targetFileSizeBytes = 512 * MB; - int availableBeCount = 0; - - Mockito.when(mockGroup.getTotalSize()).thenReturn(totalSize); - Mockito.when(mockGroup.getTasks()).thenReturn(Collections.singletonList(mockFileScanTask)); - - RewriteGroupTask task = new RewriteGroupTask( - mockGroup, 1L, mockTable, mockConnectContext, - targetFileSizeBytes, availableBeCount, null); - - java.lang.reflect.InvocationTargetException exception = Assertions.assertThrows( - java.lang.reflect.InvocationTargetException.class, - () -> invokeCalculateRewriteStrategy(task)); - - Assertions.assertTrue(exception.getCause() instanceof IllegalStateException); - Assertions.assertTrue(exception.getCause().getMessage().contains("availableBeCount"), - "Exception message should mention availableBeCount"); - } - - /** - * Test realistic scenario with multiple partitions - * Data: 3GB across multiple partitions, Target file size: 512MB - * Expected: ~6 files, useGather=true, parallelism=min(6, 100, 8)=6 - */ - @Test - public void testCalculateRewriteStrategy_MultiplePartitions() throws Exception { - long totalSize = 3 * GB; - long targetFileSizeBytes = 512 * MB; - int availableBeCount = 100; - - // Multiple tasks representing different partitions - Mockito.when(mockGroup.getTotalSize()).thenReturn(totalSize); - Mockito.when(mockGroup.getTasks()).thenReturn(Arrays.asList( - mockFileScanTask, mockFileScanTask, mockFileScanTask)); - - RewriteGroupTask task = new RewriteGroupTask( - mockGroup, 1L, mockTable, mockConnectContext, - targetFileSizeBytes, availableBeCount, null); - - Object strategy = invokeCalculateRewriteStrategy(task); - - int parallelism = (int) getFieldValue(strategy, "parallelism"); - boolean useGather = (boolean) getFieldValue(strategy, "useGather"); - - // expectedFileCount = ceil(3GB / 512MB) = ceil(6.0) = 6 - // expectedFileCount < availableBeCount, so use GATHER - Assertions.assertEquals(6, parallelism); - Assertions.assertTrue(useGather); - } - - // ========== Helper Methods ========== - - /** - * Invoke private method calculateRewriteStrategy using reflection - */ - private Object invokeCalculateRewriteStrategy(RewriteGroupTask task) throws Exception { - Method method = RewriteGroupTask.class.getDeclaredMethod("calculateRewriteStrategy"); - method.setAccessible(true); - return method.invoke(task); - } - - /** - * Get field value from RewriteStrategy object using reflection - */ - private Object getFieldValue(Object obj, String fieldName) throws Exception { - Class clazz = obj.getClass(); - java.lang.reflect.Field field = clazz.getDeclaredField(fieldName); - field.setAccessible(true); - return field.get(obj); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/source/IcebergCountPushDownTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/source/IcebergCountPushDownTest.java deleted file mode 100644 index 19ad6bece44ed5..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/source/IcebergCountPushDownTest.java +++ /dev/null @@ -1,86 +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.datasource.iceberg.source; - -import org.apache.doris.datasource.iceberg.IcebergUtils; - -import com.google.common.collect.ImmutableMap; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.Collections; -import java.util.Map; - -public class IcebergCountPushDownTest { - - private static Map summary(String equalityDeletes, String positionDeletes, String totalRecords) { - ImmutableMap.Builder builder = ImmutableMap.builder(); - if (equalityDeletes != null) { - builder.put(IcebergUtils.TOTAL_EQUALITY_DELETES, equalityDeletes); - } - if (positionDeletes != null) { - builder.put(IcebergUtils.TOTAL_POSITION_DELETES, positionDeletes); - } - if (totalRecords != null) { - builder.put(IcebergUtils.TOTAL_RECORDS, totalRecords); - } - return builder.build(); - } - - @Test - public void testMissingCounterFallsBackToScan() { - // Snapshots written by compaction/replace (and some writers) may omit - // total-* counters. The pushdown previously NPE'd on the missing key; - // it must now fall back to a normal scan (return -1). - Assertions.assertEquals(-1L, IcebergUtils.getCountFromSummary(summary(null, "0", "100"), false)); - Assertions.assertEquals(-1L, IcebergUtils.getCountFromSummary(summary("0", null, "100"), false)); - Assertions.assertEquals(-1L, IcebergUtils.getCountFromSummary(summary("0", "0", null), false)); - Assertions.assertEquals(-1L, IcebergUtils.getCountFromSummary(Collections.emptyMap(), false)); - } - - @Test - public void testUtilityMissingCounterReturnsUnknownCount() { - Assertions.assertEquals(-1L, IcebergUtils.getCountFromSummary(summary("0", null, "100"), true)); - } - - @Test - public void testNoDeletesPushesDownTotalRecords() { - Assertions.assertEquals(100L, IcebergUtils.getCountFromSummary(summary("0", "0", "100"), false)); - } - - @Test - public void testEqualityDeletesCannotPushDown() { - Assertions.assertEquals(-1L, IcebergUtils.getCountFromSummary(summary("3", "0", "100"), false)); - } - - @Test - public void testPositionDeletesRespectIgnoreDangling() { - // ignoreDanglingDelete = true -> total-records minus position-deletes - Assertions.assertEquals(90L, IcebergUtils.getCountFromSummary(summary("0", "10", "100"), true)); - // ignoreDanglingDelete = false -> cannot push down (fall back to scan) - Assertions.assertEquals(-1L, IcebergUtils.getCountFromSummary(summary("0", "10", "100"), false)); - } - - @Test - public void testZeroCountWithPositionDeletesIsPushedDown() { - // total-records == position-deletes -> count is 0. With ignore_iceberg_dangling_delete this - // is a valid pushed-down count; FE returns 0 and BE honors it via CountReader(0) (the BE - // table-level guard accepts table_level_row_count >= 0). It must NOT fall back to -1. - Assertions.assertEquals(0L, IcebergUtils.getCountFromSummary(summary("0", "100", "100"), true)); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/source/IcebergDeleteFileFilterTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/source/IcebergDeleteFileFilterTest.java deleted file mode 100644 index 6a714107a6e856..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/source/IcebergDeleteFileFilterTest.java +++ /dev/null @@ -1,48 +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.datasource.iceberg.source; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -public class IcebergDeleteFileFilterTest { - @Test - public void testValidateDeletionVectorMetadataAcceptsLongRange() { - Assertions.assertDoesNotThrow(() -> IcebergDeleteFileFilter.validateDeletionVectorMetadata( - "puffin.dv", 1L << 40, (1L << 32) + 17, (1L << 30) + 19)); - } - - @Test - public void testValidateDeletionVectorMetadataRejectsInvalidRange() { - Assertions.assertThrows(IllegalArgumentException.class, - () -> IcebergDeleteFileFilter.validateDeletionVectorMetadata("puffin.dv", 100, null, 1L)); - Assertions.assertThrows(IllegalArgumentException.class, - () -> IcebergDeleteFileFilter.validateDeletionVectorMetadata("puffin.dv", 100, 1L, null)); - Assertions.assertThrows(IllegalArgumentException.class, - () -> IcebergDeleteFileFilter.validateDeletionVectorMetadata("puffin.dv", -1, 1L, 1L)); - Assertions.assertThrows(IllegalArgumentException.class, - () -> IcebergDeleteFileFilter.validateDeletionVectorMetadata("puffin.dv", 100, -1L, 1L)); - Assertions.assertThrows(IllegalArgumentException.class, - () -> IcebergDeleteFileFilter.validateDeletionVectorMetadata("puffin.dv", 100, 1L, -1L)); - Assertions.assertThrows(IllegalArgumentException.class, - () -> IcebergDeleteFileFilter.validateDeletionVectorMetadata( - "puffin.dv", Long.MAX_VALUE, Long.MAX_VALUE, 1L)); - Assertions.assertThrows(IllegalArgumentException.class, - () -> IcebergDeleteFileFilter.validateDeletionVectorMetadata("puffin.dv", 100, 90L, 11L)); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/source/IcebergScanNodeTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/source/IcebergScanNodeTest.java deleted file mode 100644 index db0f52cd871765..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/source/IcebergScanNodeTest.java +++ /dev/null @@ -1,502 +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.datasource.iceberg.source; - -import org.apache.doris.analysis.SlotDescriptor; -import org.apache.doris.analysis.SlotId; -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.analysis.TupleId; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Type; -import org.apache.doris.common.UserException; -import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.TableFormatType; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.system.Backend; -import org.apache.doris.thrift.TFileFormatType; -import org.apache.doris.thrift.TFileRangeDesc; -import org.apache.doris.thrift.TIcebergDeleteFileDesc; -import org.apache.doris.thrift.TPushAggOp; - -import org.apache.iceberg.DataFile; -import org.apache.iceberg.DeleteFile; -import org.apache.iceberg.FileFormat; -import org.apache.iceberg.FileScanTask; -import org.apache.iceberg.PartitionData; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.PositionDeletesScanTask; -import org.apache.iceberg.Schema; -import org.apache.iceberg.Snapshot; -import org.apache.iceberg.Table; -import org.apache.iceberg.TableScan; -import org.apache.iceberg.expressions.Expression; -import org.apache.iceberg.expressions.Expressions; -import org.apache.iceberg.types.Types; -import org.apache.iceberg.util.ScanTaskUtil; -import org.junit.Assert; -import org.junit.Test; -import org.mockito.Mockito; - -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -public class IcebergScanNodeTest { - private static final long MB = 1024L * 1024L; - - private static class TestIcebergScanNode extends IcebergScanNode { - private final boolean enableMappingVarbinary; - private TableScan tableScan; - - TestIcebergScanNode(SessionVariable sv) { - this(sv, false); - } - - TestIcebergScanNode(SessionVariable sv, boolean enableMappingVarbinary) { - super(new PlanNodeId(0), new TupleDescriptor(new TupleId(0)), sv, ScanContext.EMPTY); - this.enableMappingVarbinary = enableMappingVarbinary; - } - - void setTableScan(TableScan tableScan) { - this.tableScan = tableScan; - } - - @Override - public TableScan createTableScan() { - return tableScan; - } - - @Override - public boolean isBatchMode() { - return false; - } - - @Override - protected boolean getEnableMappingVarbinary() { - return enableMappingVarbinary; - } - - @Override - public List getPathPartitionKeys() { - return Collections.emptyList(); - } - - void addSlot(int slotId, Column column) { - SlotDescriptor slot = new SlotDescriptor(new SlotId(slotId), desc.getId()); - slot.setColumn(column); - desc.addSlot(slot); - } - } - - @Test - public void testSystemTableProjectionMatchesFileSlotOrder() throws Exception { - Schema systemTableSchema = new Schema( - Types.NestedField.required(1, "file_path", Types.StringType.get()), - Types.NestedField.required(2, "record_count", Types.LongType.get()), - Types.NestedField.optional(3, "readable_metrics", Types.StructType.of( - Types.NestedField.optional(4, "id", Types.StructType.of( - Types.NestedField.optional(5, "lower_bound", Types.IntegerType.get())))))); - Table systemTable = Mockito.mock(Table.class); - Mockito.when(systemTable.schema()).thenReturn(systemTableSchema); - - TestIcebergScanNode node = new TestIcebergScanNode(new SessionVariable()); - setIcebergTable(node, systemTable); - node.addSlot(1, new Column("RECORD_COUNT", Type.BIGINT)); - node.addSlot(2, new Column(Column.GLOBAL_ROWID_COL + "system_table", Type.BIGINT)); - node.addSlot(3, new Column("FILE_PATH", Type.STRING)); - - List filters = Collections.singletonList(Expressions.greaterThan("record_count", 0L)); - Schema projectedSchema = node.getSystemTableProjectedSchema(filters, false); - - Assert.assertEquals(2, projectedSchema.columns().size()); - Assert.assertEquals("record_count", projectedSchema.columns().get(0).name()); - Assert.assertEquals("file_path", projectedSchema.columns().get(1).name()); - Assert.assertNull(projectedSchema.findField("readable_metrics")); - } - - @Test - public void testSystemTableProjectionRejectsUnmaterializedFilterColumn() throws Exception { - Schema systemTableSchema = new Schema( - Types.NestedField.required(1, "file_path", Types.StringType.get()), - Types.NestedField.required(2, "record_count", Types.LongType.get())); - Table systemTable = Mockito.mock(Table.class); - Mockito.when(systemTable.schema()).thenReturn(systemTableSchema); - - TestIcebergScanNode node = new TestIcebergScanNode(new SessionVariable()); - setIcebergTable(node, systemTable); - node.addSlot(1, new Column("record_count", Type.BIGINT)); - - try { - node.getSystemTableProjectedSchema( - Collections.singletonList(Expressions.equal("file_path", "data.parquet")), true); - Assert.fail("Filter columns must be materialized by the planner"); - } catch (UserException e) { - Assert.assertTrue(e.getMessage().contains("filter column file_path is not materialized")); - } - } - - private static class CountPlanningIcebergScanNode extends IcebergScanNode { - private final TableScan tableScan; - private final long snapshotCount; - private int snapshotCountCalls; - - CountPlanningIcebergScanNode(SessionVariable sv, TableScan tableScan, long snapshotCount) { - super(new PlanNodeId(0), new TupleDescriptor(new TupleId(0)), sv, ScanContext.EMPTY); - this.tableScan = tableScan; - this.snapshotCount = snapshotCount; - } - - @Override - public TableScan createTableScan() { - return tableScan; - } - - @Override - public long getCountFromSnapshot() { - ++snapshotCountCalls; - return snapshotCount; - } - } - - @Test - public void testTableLevelCountSplitPlanningRequiresCountStar() { - SessionVariable sv = Mockito.mock(SessionVariable.class); - Mockito.when(sv.getEnableExternalTableBatchMode()).thenReturn(false); - TableScan tableScan = Mockito.mock(TableScan.class); - Mockito.when(tableScan.snapshot()).thenReturn(Mockito.mock(Snapshot.class)); - - // COUNT(required_col) carries a non-empty semantic argument list. Even though its result - // equals COUNT(*) for valid data, BE intentionally reads the column to enforce schema - // contracts. FE must therefore leave all real file tasks available to that fallback. - CountPlanningIcebergScanNode countColumnNode = - new CountPlanningIcebergScanNode(sv, tableScan, 30_000); - countColumnNode.setPushDownAggNoGrouping(TPushAggOp.COUNT); - countColumnNode.setPushDownCountSlotIds(Collections.singletonList(new SlotId(7))); - Assert.assertFalse(countColumnNode.isBatchMode()); - Assert.assertEquals(0, countColumnNode.snapshotCountCalls); - - // COUNT(*) has an explicitly empty argument list, so snapshot row count remains eligible - // and doGetSplits may retain only representative tasks for parallel materialization. - CountPlanningIcebergScanNode countStarNode = - new CountPlanningIcebergScanNode(sv, tableScan, 30_000); - countStarNode.setPushDownAggNoGrouping(TPushAggOp.COUNT); - countStarNode.setPushDownCountSlotIds(Collections.emptyList()); - Assert.assertFalse(countStarNode.isBatchMode()); - Assert.assertEquals(1, countStarNode.snapshotCountCalls); - } - - @Test - public void testInitialDefaultMetadataUsesSnapshotSchema() throws Exception { - Schema snapshotSchema = new Schema(Types.NestedField.optional("historical_binary") - .withId(7) - .ofType(Types.BinaryType.get()) - .withInitialDefault(ByteBuffer.wrap(new byte[] {0, 1, 2, (byte) 0xFF})) - .build()); - Schema currentSchema = new Schema(Types.NestedField.optional("current_string") - .withId(7) - .ofType(Types.StringType.get()) - .withInitialDefault("not-base64") - .build()); - Snapshot snapshot = Mockito.mock(Snapshot.class); - Mockito.when(snapshot.schemaId()).thenReturn(11); - Table table = Mockito.mock(Table.class); - Mockito.when(table.schemas()).thenReturn(Collections.singletonMap(11, snapshotSchema)); - TableScan snapshotScan = Mockito.mock(TableScan.class); - Mockito.when(snapshotScan.snapshot()).thenReturn(snapshot); - Mockito.when(snapshotScan.table()).thenReturn(table); - Mockito.when(snapshotScan.schema()).thenReturn(currentSchema); - - TestIcebergScanNode node = new TestIcebergScanNode(new SessionVariable()); - node.setTableScan(snapshotScan); - - Map defaults = node.getBase64EncodedInitialDefaultsForScan(); - Assert.assertEquals(Collections.singletonMap(7, "AAEC/w=="), defaults); - } - - @Test - public void testInitialDefaultMetadataUsesSystemTableSchemaWithoutTableScan() throws Exception { - Schema systemTableSchema = new Schema(Types.NestedField.optional("binary_default") - .withId(7) - .ofType(Types.BinaryType.get()) - .withInitialDefault(ByteBuffer.wrap(new byte[] {0, 1, 2, (byte) 0xFF})) - .build()); - Table systemTable = Mockito.mock(Table.class); - Mockito.when(systemTable.schema()).thenReturn(systemTableSchema); - - TestIcebergScanNode node = Mockito.spy(new TestIcebergScanNode(new SessionVariable())); - Field icebergTableField = IcebergScanNode.class.getDeclaredField("icebergTable"); - icebergTableField.setAccessible(true); - icebergTableField.set(node, systemTable); - Field isSystemTableField = IcebergScanNode.class.getDeclaredField("isSystemTable"); - isSystemTableField.setAccessible(true); - isSystemTableField.setBoolean(node, true); - - Map defaults = node.getBase64EncodedInitialDefaultsForScan(); - - Assert.assertEquals(Collections.singletonMap(7, "AAEC/w=="), defaults); - Mockito.verify(node, Mockito.never()).createTableScan(); - } - - private static void setIcebergTable(IcebergScanNode node, Table table) throws Exception { - Field icebergTableField = IcebergScanNode.class.getDeclaredField("icebergTable"); - icebergTableField.setAccessible(true); - icebergTableField.set(node, table); - } - - @Test - public void testDetermineTargetFileSplitSizeHonorsMaxFileSplitNum() throws Exception { - SessionVariable sv = new SessionVariable(); - sv.setMaxFileSplitNum(100); - TestIcebergScanNode node = new TestIcebergScanNode(sv); - - DataFile dataFile = Mockito.mock(DataFile.class); - Mockito.when(dataFile.fileSizeInBytes()).thenReturn(10_000L * MB); - FileScanTask task = Mockito.mock(FileScanTask.class); - Mockito.when(task.file()).thenReturn(dataFile); - Mockito.when(task.length()).thenReturn(10_000L * MB); - - try (org.mockito.MockedStatic mockedScanTaskUtil = - Mockito.mockStatic(ScanTaskUtil.class)) { - mockedScanTaskUtil.when(() -> ScanTaskUtil.contentSizeInBytes(dataFile)) - .thenReturn(10_000L * MB); - - Method method = IcebergScanNode.class.getDeclaredMethod("determineTargetFileSplitSize", Iterable.class); - method.setAccessible(true); - long target = (long) method.invoke(node, Collections.singletonList(task)); - Assert.assertEquals(100 * MB, target); - } - } - - @Test - public void testSetIcebergParamsKeepsDeletionVectorOffsetAsLong() throws Exception { - SessionVariable sv = new SessionVariable(); - TestIcebergScanNode node = new TestIcebergScanNode(sv); - - Field formatVersionField = IcebergScanNode.class.getDeclaredField("formatVersion"); - formatVersionField.setAccessible(true); - formatVersionField.set(node, 3); - - String dataPath = "file:///tmp/data-file.parquet"; - String deletePath = "file:///tmp/delete-shared.puffin"; - IcebergSplit split = new IcebergSplit(LocationPath.of(dataPath), 0, 128, 128, new String[0], - 3, Collections.emptyMap(), new ArrayList<>(), dataPath); - split.setTableFormatType(TableFormatType.ICEBERG); - split.setSplitFileFormat(FileFormat.PARQUET); - split.setFirstRowId(10L); - split.setLastUpdatedSequenceNumber(20L); - split.setDeleteFileFilters(Collections.emptyList(), Collections.singletonList( - new IcebergDeleteFileFilter.DeletionVector(deletePath, -1L, -1L, 256L, - (long) Integer.MAX_VALUE + 5L, (long) Integer.MAX_VALUE + 7L))); - - Method method = IcebergScanNode.class.getDeclaredMethod("setIcebergParams", - TFileRangeDesc.class, IcebergSplit.class); - method.setAccessible(true); - - TFileRangeDesc rangeDesc = new TFileRangeDesc(); - method.invoke(node, rangeDesc, split); - - TIcebergDeleteFileDesc deleteFileDesc = rangeDesc.getTableFormatParams() - .getIcebergParams() - .getDeleteFiles() - .get(0); - Assert.assertEquals((long) Integer.MAX_VALUE + 5L, deleteFileDesc.getContentOffset()); - Assert.assertEquals((long) Integer.MAX_VALUE + 7L, deleteFileDesc.getContentSizeInBytes()); - } - - @Test - public void testSetIcebergParamsUsesSplitFileFormat() throws Exception { - TestIcebergScanNode node = new TestIcebergScanNode(new SessionVariable()); - String dataPath = "file:///tmp/data-file.orc"; - IcebergSplit split = new IcebergSplit(LocationPath.of(dataPath), 0, 128, 128, new String[0], - 2, Collections.emptyMap(), new ArrayList<>(), dataPath); - split.setTableFormatType(TableFormatType.ICEBERG); - split.setSplitFileFormat(FileFormat.ORC); - - Method method = IcebergScanNode.class.getDeclaredMethod("setIcebergParams", - TFileRangeDesc.class, IcebergSplit.class); - method.setAccessible(true); - - TFileRangeDesc rangeDesc = new TFileRangeDesc(); - method.invoke(node, rangeDesc, split); - - Assert.assertEquals(TFileFormatType.FORMAT_ORC, rangeDesc.getFormatType()); - } - - @Test - public void testPositionDeleteSystemTableValidatesDeletionVectorMetadata() throws Exception { - DeleteFile deleteFile = Mockito.mock(DeleteFile.class); - Mockito.when(deleteFile.path()).thenReturn("file:///tmp/delete-shared.puffin"); - Mockito.when(deleteFile.format()).thenReturn(FileFormat.PUFFIN); - Mockito.when(deleteFile.fileSizeInBytes()).thenReturn(100L); - Mockito.when(deleteFile.contentOffset()).thenReturn(null); - Mockito.when(deleteFile.contentSizeInBytes()).thenReturn(10L); - - PositionDeletesScanTask task = Mockito.mock(PositionDeletesScanTask.class); - Mockito.when(task.file()).thenReturn(deleteFile); - Mockito.when(task.start()).thenReturn(0L); - Mockito.when(task.length()).thenReturn(100L); - - TestIcebergScanNode node = new TestIcebergScanNode(new SessionVariable()); - Method method = IcebergScanNode.class.getDeclaredMethod( - "createIcebergPositionDeleteSysSplit", PositionDeletesScanTask.class); - method.setAccessible(true); - - try { - method.invoke(node, task); - Assert.fail("position_deletes planning should reject invalid deletion vector metadata"); - } catch (InvocationTargetException e) { - Assert.assertTrue(e.getCause() instanceof IllegalArgumentException); - Assert.assertTrue(e.getCause().getMessage().contains("delete-shared.puffin")); - } - } - - @Test - public void testSetIcebergParamsPropagatesPositionDeleteFileFormat() throws Exception { - SessionVariable sv = new SessionVariable(); - TestIcebergScanNode node = new TestIcebergScanNode(sv); - - Field formatVersionField = IcebergScanNode.class.getDeclaredField("formatVersion"); - formatVersionField.setAccessible(true); - formatVersionField.set(node, 2); - - String dataPath = "file:///tmp/data-file.parquet"; - String deletePath = "file:///tmp/delete-file.orc"; - IcebergSplit split = new IcebergSplit(LocationPath.of(dataPath), 0, 128, 128, new String[0], - 2, Collections.emptyMap(), new ArrayList<>(), dataPath); - split.setTableFormatType(TableFormatType.ICEBERG); - split.setSplitFileFormat(FileFormat.PARQUET); - split.setDeleteFileFilters(Collections.emptyList(), Collections.singletonList( - new IcebergDeleteFileFilter.PositionDelete(deletePath, -1L, -1L, 256L, - org.apache.iceberg.FileFormat.ORC))); - - Method method = IcebergScanNode.class.getDeclaredMethod("setIcebergParams", - TFileRangeDesc.class, IcebergSplit.class); - method.setAccessible(true); - - TFileRangeDesc rangeDesc = new TFileRangeDesc(); - method.invoke(node, rangeDesc, split); - - TIcebergDeleteFileDesc deleteFileDesc = rangeDesc.getTableFormatParams() - .getIcebergParams() - .getDeleteFiles() - .get(0); - Assert.assertEquals(org.apache.doris.thrift.TFileFormatType.FORMAT_ORC, deleteFileDesc.getFileFormat()); - } - - @Test - public void testPartitionDataJsonMatchesRenamedFieldById() throws Exception { - SessionVariable sv = new SessionVariable(); - TestIcebergScanNode node = new TestIcebergScanNode(sv); - - Schema schema = new Schema(Types.NestedField.required(1, "p", Types.IntegerType.get())); - PartitionSpec oldSpec = PartitionSpec.builderFor(schema).identity("p").build(); - PartitionData partitionData = new PartitionData(oldSpec.partitionType()); - partitionData.set(0, 10); - int partitionFieldId = oldSpec.fields().get(0).fieldId(); - List outputPartitionFields = Collections.singletonList( - Types.NestedField.optional(partitionFieldId, "p2", Types.IntegerType.get())); - - Method method = IcebergScanNode.class.getDeclaredMethod("getPartitionDataObjectJson", - PartitionData.class, PartitionSpec.class, List.class); - method.setAccessible(true); - - Assert.assertEquals("{\"p2\":10}", method.invoke(node, partitionData, oldSpec, outputPartitionFields)); - } - - @Test - public void testRejectBinaryPartitionValueWithoutBinarySafeTransport() throws Exception { - assertUnsupportedPositionDeletesPartitionValue( - Types.BinaryType.get(), ByteBuffer.wrap(new byte[] {0, (byte) 0xff}), false, "binary"); - assertUnsupportedPositionDeletesPartitionValue( - Types.FixedType.ofLength(2), ByteBuffer.wrap(new byte[] {0, (byte) 0xff}), false, "fixed[2]"); - } - - @Test - public void testRejectUuidPartitionValueWhenMappedToVarbinary() throws Exception { - assertUnsupportedPositionDeletesPartitionValue( - Types.UUIDType.get(), UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), true, "uuid"); - } - - private void assertUnsupportedPositionDeletesPartitionValue( - org.apache.iceberg.types.Type type, Object value, boolean enableMappingVarbinary, - String expectedType) throws Exception { - TestIcebergScanNode node = new TestIcebergScanNode(new SessionVariable(), enableMappingVarbinary); - Schema schema = new Schema(Types.NestedField.required(1, "p", type)); - PartitionSpec spec = PartitionSpec.builderFor(schema).identity("p").build(); - PartitionData partitionData = new PartitionData(spec.partitionType()); - partitionData.set(0, value); - - Method method = IcebergScanNode.class.getDeclaredMethod("getPartitionDataObjectJson", - PartitionData.class, PartitionSpec.class, List.class); - method.setAccessible(true); - try { - method.invoke(node, partitionData, spec, spec.partitionType().fields()); - Assert.fail("Binary partition values must not be silently materialized as NULL"); - } catch (InvocationTargetException e) { - Assert.assertTrue(e.getCause() instanceof UserException); - Assert.assertTrue(e.getCause().getMessage().contains("partition field 'p'")); - Assert.assertTrue(e.getCause().getMessage().contains(expectedType)); - } - } - - @Test - public void testRejectUnsupportedPositionDeleteFileFormat() throws Exception { - TestIcebergScanNode node = new TestIcebergScanNode(new SessionVariable()); - Method method = IcebergScanNode.class.getDeclaredMethod( - "getNativePositionDeleteFileFormat", FileFormat.class); - method.setAccessible(true); - - try { - method.invoke(node, FileFormat.AVRO); - Assert.fail("AVRO position delete files should be rejected explicitly"); - } catch (InvocationTargetException e) { - Assert.assertTrue(e.getCause() instanceof UnsupportedOperationException); - Assert.assertEquals("Unsupported Iceberg position delete file format: AVRO", - e.getCause().getMessage()); - } - } - - @Test - public void testRejectSmoothUpgradeSourceBackendForPositionDeletes() throws Exception { - Backend currentBackend = Mockito.mock(Backend.class); - Mockito.when(currentBackend.isSmoothUpgradeSrc()).thenReturn(false); - IcebergScanNode.checkPositionDeletesBackendCompatibility(Collections.singletonList(currentBackend)); - - Backend smoothUpgradeSource = Mockito.mock(Backend.class); - Mockito.when(smoothUpgradeSource.isSmoothUpgradeSrc()).thenReturn(true); - Mockito.when(smoothUpgradeSource.getId()).thenReturn(10001L); - List backends = new ArrayList<>(); - backends.add(currentBackend); - backends.add(smoothUpgradeSource); - - try { - IcebergScanNode.checkPositionDeletesBackendCompatibility(backends); - Assert.fail("smooth upgrade source backend should reject native position_deletes planning"); - } catch (UserException e) { - Assert.assertTrue(e.getMessage().contains("backend 10001 is a smooth upgrade source")); - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/lakesoul/LakeSoulPredicateTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/lakesoul/LakeSoulPredicateTest.java deleted file mode 100644 index 50bee5d1a3ec2e..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/lakesoul/LakeSoulPredicateTest.java +++ /dev/null @@ -1,283 +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.datasource.lakesoul; - -import org.apache.doris.analysis.BinaryPredicate; -import org.apache.doris.analysis.BoolLiteral; -import org.apache.doris.analysis.CompoundPredicate; -import org.apache.doris.analysis.CompoundPredicate.Operator; -import org.apache.doris.analysis.DateLiteral; -import org.apache.doris.analysis.DecimalLiteral; -import org.apache.doris.analysis.Expr; -import org.apache.doris.analysis.ExprToSqlVisitor; -import org.apache.doris.analysis.FloatLiteral; -import org.apache.doris.analysis.InPredicate; -import org.apache.doris.analysis.IntLiteral; -import org.apache.doris.analysis.LiteralExpr; -import org.apache.doris.analysis.SlotRef; -import org.apache.doris.analysis.StringLiteral; -import org.apache.doris.analysis.ToSqlParams; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.Type; -import org.apache.doris.catalog.info.TableNameInfo; -import org.apache.doris.common.AnalysisException; - -import com.dmetasoul.lakesoul.lakesoul.io.substrait.SubstraitUtil; -import com.google.common.collect.ArrayListMultimap; -import com.google.common.collect.Lists; -import io.substrait.expression.Expression; -import org.apache.arrow.vector.types.DateUnit; -import org.apache.arrow.vector.types.FloatingPointPrecision; -import org.apache.arrow.vector.types.TimeUnit; -import org.apache.arrow.vector.types.pojo.ArrowType; -import org.apache.arrow.vector.types.pojo.Field; -import org.apache.arrow.vector.types.pojo.FieldType; -import org.apache.arrow.vector.types.pojo.Schema; -import org.junit.Assert; -import org.junit.BeforeClass; -import org.junit.Test; - -import java.io.IOException; -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; - -public class LakeSoulPredicateTest { - - public static Schema schema; - - @BeforeClass - public static void before() throws AnalysisException, IOException { - schema = new Schema( - Arrays.asList( - new Field("c_int", FieldType.nullable(new ArrowType.Int(32, true)), null), - new Field("c_long", FieldType.nullable(new ArrowType.Int(64, true)), null), - new Field("c_bool", FieldType.nullable(new ArrowType.Bool()), null), - new Field("c_float", FieldType.nullable(new ArrowType.FloatingPoint(FloatingPointPrecision.SINGLE)), null), - new Field("c_double", FieldType.nullable(new ArrowType.FloatingPoint(FloatingPointPrecision.DOUBLE)), null), - new Field("c_dec", FieldType.nullable(new ArrowType.Decimal(20, 10)), null), - new Field("c_date", FieldType.nullable(new ArrowType.Date(DateUnit.DAY)), null), - new Field("c_ts", FieldType.nullable(new ArrowType.Timestamp(TimeUnit.MICROSECOND, "UTC")), null), - new Field("c_str", FieldType.nullable(new ArrowType.Utf8()), null) - )); - } - - @Test - public void testBinaryPredicate() throws AnalysisException, IOException { - List literalList = new ArrayList() {{ - add(new BoolLiteral(true)); - add(new DateLiteral(2023, 1, 2, Type.DATEV2)); - add(new DateLiteral(2024, 1, 2, 12, 34, 56, 123456, Type.DATETIMEV2)); - add(new DecimalLiteral(new BigDecimal("1.23"), ScalarType.createDecimalV3Type(3, 2))); - add(new FloatLiteral(1.23, Type.FLOAT)); - add(new FloatLiteral(3.456, Type.DOUBLE)); - add(new IntLiteral(1, Type.TINYINT)); - add(new IntLiteral(1, Type.SMALLINT)); - add(new IntLiteral(1, Type.INT)); - add(new IntLiteral(1, Type.BIGINT)); - add(new StringLiteral("abc")); - add(new StringLiteral("2023-01-02")); - add(new StringLiteral("2023-01-02 01:02:03.456789")); - }}; - - List slotRefs = new ArrayList() {{ - add(new SlotRef(new TableNameInfo(), "c_int")); - add(new SlotRef(new TableNameInfo(), "c_long")); - add(new SlotRef(new TableNameInfo(), "c_bool")); - add(new SlotRef(new TableNameInfo(), "c_float")); - add(new SlotRef(new TableNameInfo(), "c_double")); - add(new SlotRef(new TableNameInfo(), "c_dec")); - add(new SlotRef(new TableNameInfo(), "c_date")); - add(new SlotRef(new TableNameInfo(), "c_ts")); - add(new SlotRef(new TableNameInfo(), "c_str")); - }}; - - // true indicates support for pushdown - Boolean[][] expects = new Boolean[][] { - { // int - false, false, false, false, false, false, true, true, true, true, false, false, false - }, - { // long - false, false, false, false, false, false, true, true, true, true, false, false, false - }, - { // boolean - true, false, false, false, false, false, false, false, false, false, false, false, false - }, - { // float - false, false, false, false, true, false, true, true, true, true, false, false, false - }, - { // double - false, false, false, true, true, true, true, true, true, true, false, false, false - }, - { // decimal - false, false, false, true, true, true, true, true, true, true, false, false, false - }, - { // date - false, true, false, false, false, false, true, true, true, true, false, true, false - }, - { // timestamp - false, true, true, false, false, false, false, false, false, true, false, false, false - }, - { // string - true, true, true, true, false, false, false, false, false, false, true, true, true - } - }; - - ArrayListMultimap validPredicateMap = ArrayListMultimap.create(); - - // binary predicate - for (int i = 0; i < slotRefs.size(); i++) { - final int loc = i; - List ret = literalList.stream().map(literal -> { - BinaryPredicate expr = new BinaryPredicate(BinaryPredicate.Operator.EQ, slotRefs.get(loc), literal); - Expression expression = null; - try { - expression = LakeSoulUtils.convertToSubstraitExpr(expr, schema); - } catch (IOException e) { - throw new RuntimeException(e); - } - validPredicateMap.put(expression != null, expr); - return expression != null; - }).collect(Collectors.toList()); - Assert.assertArrayEquals(expects[i], ret.toArray()); - } - - // in predicate - for (int i = 0; i < slotRefs.size(); i++) { - final int loc = i; - List ret = literalList.stream().map(literal -> { - InPredicate expr = new InPredicate(slotRefs.get(loc), Lists.newArrayList(literal), false); - Expression expression = null; - try { - expression = LakeSoulUtils.convertToSubstraitExpr(expr, schema); - } catch (IOException e) { - throw new RuntimeException(e); - } - validPredicateMap.put(expression != null, expr); - return expression != null; - }).collect(Collectors.toList()); - Assert.assertArrayEquals(expects[i], ret.toArray()); - } - - // not in predicate - for (int i = 0; i < slotRefs.size(); i++) { - final int loc = i; - List ret = literalList.stream().map(literal -> { - InPredicate expr = new InPredicate(slotRefs.get(loc), Lists.newArrayList(literal), true); - Expression expression = null; - try { - expression = LakeSoulUtils.convertToSubstraitExpr(expr, schema); - } catch (IOException e) { - throw new RuntimeException(e); - } - validPredicateMap.put(expression != null, expr); - return expression != null; - }).collect(Collectors.toList()); - Assert.assertArrayEquals(expects[i], ret.toArray()); - } - - // bool literal - - Expression trueExpr = LakeSoulUtils.convertToSubstraitExpr(new BoolLiteral(true), schema); - Expression falseExpr = LakeSoulUtils.convertToSubstraitExpr(new BoolLiteral(false), schema); - Assert.assertEquals(SubstraitUtil.CONST_TRUE, trueExpr); - Assert.assertEquals(SubstraitUtil.CONST_FALSE, falseExpr); - validPredicateMap.put(true, new BoolLiteral(true)); - validPredicateMap.put(true, new BoolLiteral(false)); - - List validExprs = validPredicateMap.get(true); - List invalidExprs = validPredicateMap.get(false); - // OR predicate - // both valid - for (int i = 0; i < validExprs.size(); i++) { - for (int j = 0; j < validExprs.size(); j++) { - CompoundPredicate orPredicate = new CompoundPredicate(Operator.OR, - validExprs.get(i), validExprs.get(j)); - Expression expression = LakeSoulUtils.convertToSubstraitExpr(orPredicate, schema); - Assert.assertNotNull("pred: " + orPredicate.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE), expression); - } - } - // both invalid - for (int i = 0; i < invalidExprs.size(); i++) { - for (int j = 0; j < invalidExprs.size(); j++) { - CompoundPredicate orPredicate = new CompoundPredicate(Operator.OR, - invalidExprs.get(i), invalidExprs.get(j)); - Expression expression = LakeSoulUtils.convertToSubstraitExpr(orPredicate, schema); - Assert.assertNull("pred: " + orPredicate.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE), expression); - } - } - // valid or invalid - for (int i = 0; i < validExprs.size(); i++) { - for (int j = 0; j < invalidExprs.size(); j++) { - CompoundPredicate orPredicate = new CompoundPredicate(Operator.OR, - validExprs.get(i), invalidExprs.get(j)); - Expression expression = LakeSoulUtils.convertToSubstraitExpr(orPredicate, schema); - Assert.assertNull("pred: " + orPredicate.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE), expression); - } - } - - // AND predicate - // both valid - for (int i = 0; i < validExprs.size(); i++) { - for (int j = 0; j < validExprs.size(); j++) { - CompoundPredicate andPredicate = new CompoundPredicate(Operator.AND, - validExprs.get(i), validExprs.get(j)); - Expression expression = LakeSoulUtils.convertToSubstraitExpr(andPredicate, schema); - Assert.assertNotNull("pred: " + andPredicate.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE), expression); - } - } - // both invalid - for (int i = 0; i < invalidExprs.size(); i++) { - for (int j = 0; j < invalidExprs.size(); j++) { - CompoundPredicate andPredicate = new CompoundPredicate(Operator.AND, - invalidExprs.get(i), invalidExprs.get(j)); - Expression expression = LakeSoulUtils.convertToSubstraitExpr(andPredicate, schema); - Assert.assertNull("pred: " + andPredicate.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE), expression); - } - } - // valid and invalid - for (int i = 0; i < validExprs.size(); i++) { - for (int j = 0; j < invalidExprs.size(); j++) { - CompoundPredicate andPredicate = new CompoundPredicate(Operator.AND, - validExprs.get(i), invalidExprs.get(j)); - Expression expression = LakeSoulUtils.convertToSubstraitExpr(andPredicate, schema); - Assert.assertNotNull("pred: " + andPredicate.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE), expression); - Assert.assertEquals(SubstraitUtil.substraitExprToProto(LakeSoulUtils.convertToSubstraitExpr(validExprs.get(i), schema), "table"), - SubstraitUtil.substraitExprToProto(expression, "table")); - } - } - - // NOT predicate - // valid - for (int i = 0; i < validExprs.size(); i++) { - CompoundPredicate notPredicate = new CompoundPredicate(Operator.NOT, - validExprs.get(i), null); - Expression expression = LakeSoulUtils.convertToSubstraitExpr(notPredicate, schema); - Assert.assertNotNull("pred: " + notPredicate.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE), expression); - } - // invalid - for (int i = 0; i < invalidExprs.size(); i++) { - CompoundPredicate notPredicate = new CompoundPredicate(Operator.NOT, - invalidExprs.get(i), null); - Expression expression = LakeSoulUtils.convertToSubstraitExpr(notPredicate, schema); - Assert.assertNull("pred: " + notPredicate.accept(ExprToSqlVisitor.INSTANCE, ToSqlParams.WITH_TABLE), expression); - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/MetaIdMappingsLogTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/log/MetaIdMappingsLogTest.java similarity index 97% rename from fe/fe-core/src/test/java/org/apache/doris/datasource/MetaIdMappingsLogTest.java rename to fe/fe-core/src/test/java/org/apache/doris/datasource/log/MetaIdMappingsLogTest.java index fec57c29eda880..5cdccd6d1aa4f7 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/MetaIdMappingsLogTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/log/MetaIdMappingsLogTest.java @@ -15,7 +15,9 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.log; + +import org.apache.doris.datasource.ExternalMetaIdMgr; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/MCTransactionTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/MCTransactionTest.java deleted file mode 100644 index e76f192a858917..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/MCTransactionTest.java +++ /dev/null @@ -1,54 +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.datasource.maxcompute; - -import org.apache.doris.common.UserException; - -import org.junit.Assert; -import org.junit.Test; -import org.mockito.Mockito; - -import java.util.Optional; - -public class MCTransactionTest { - @Test - public void testBeginInsertRejectsOdpsExternalTable() { - assertBeginInsertRejectsUnsupportedOdpsTable("mc_external_table"); - } - - @Test - public void testBeginInsertRejectsOdpsLogicalView() { - assertBeginInsertRejectsUnsupportedOdpsTable("mc_logical_view"); - } - - private void assertBeginInsertRejectsUnsupportedOdpsTable(String tableName) { - MaxComputeExternalCatalog catalog = Mockito.mock(MaxComputeExternalCatalog.class); - MaxComputeExternalTable table = Mockito.mock(MaxComputeExternalTable.class); - Mockito.when(table.isUnsupportedOdpsTable()).thenReturn(true); - Mockito.when(table.getDbName()).thenReturn("default"); - Mockito.when(table.getName()).thenReturn(tableName); - - MCTransaction transaction = new MCTransaction(catalog); - - UserException exception = Assert.assertThrows(UserException.class, - () -> transaction.beginInsert(table, Optional.empty())); - Assert.assertTrue(exception.getMessage().contains( - "Writing MaxCompute external table or logical view is not supported: default." + tableName)); - Mockito.verify(catalog, Mockito.never()).getOdpsTableIdentifier(Mockito.anyString(), Mockito.anyString()); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalCatalogTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalCatalogTest.java deleted file mode 100644 index dfe22f136b5ca4..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalCatalogTest.java +++ /dev/null @@ -1,146 +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.datasource.maxcompute; - -import org.apache.doris.common.DdlException; -import org.apache.doris.common.maxcompute.MCProperties; -import org.apache.doris.datasource.ExternalCatalog; - -import org.junit.Assert; -import org.junit.Test; - -import java.util.HashMap; -import java.util.Map; - -public class MaxComputeExternalCatalogTest { - @Test - public void testSplitByteSizeErrorMessage() { - Map props = new HashMap<>(); - addRequiredProperties(props); - props.put(MCProperties.SPLIT_STRATEGY, MCProperties.SPLIT_BY_BYTE_SIZE_STRATEGY); - props.put(MCProperties.SPLIT_BYTE_SIZE, "1048576"); - - MaxComputeExternalCatalog catalog = new MaxComputeExternalCatalog(1L, "mc_catalog", null, props, ""); - - DdlException exception = Assert.assertThrows(DdlException.class, catalog::checkProperties); - Assert.assertTrue(exception.getMessage().contains( - MCProperties.SPLIT_BYTE_SIZE + " must be greater than or equal to 10485760")); - Assert.assertFalse(exception.getMessage().contains(MCProperties.SPLIT_ROW_COUNT)); - } - - @Test - public void testCheckWhenCreatingSkipsValidationByDefault() throws DdlException { - Map props = createRequiredProperties(true); - TestMaxComputeExternalCatalog catalog = new TestMaxComputeExternalCatalog(props); - - catalog.checkWhenCreating(); - - Assert.assertNull(catalog.checkedProjectName); - Assert.assertNull(catalog.checkedNamespaceSchemaProjectName); - } - - @Test - public void testCheckWhenCreatingValidatesProjectWhenValidationEnabled() throws DdlException { - Map props = createRequiredProperties(false); - props.put(ExternalCatalog.TEST_CONNECTION, "true"); - TestMaxComputeExternalCatalog catalog = new TestMaxComputeExternalCatalog(props); - - catalog.checkWhenCreating(); - - Assert.assertEquals("mc_project", catalog.checkedProjectName); - Assert.assertNull(catalog.checkedNamespaceSchemaProjectName); - } - - @Test - public void testCheckWhenCreatingValidatesSchemaWhenNamespaceSchemaEnabled() throws DdlException { - Map props = createRequiredProperties(true); - props.put(ExternalCatalog.TEST_CONNECTION, "true"); - TestMaxComputeExternalCatalog catalog = new TestMaxComputeExternalCatalog(props); - - catalog.checkWhenCreating(); - - Assert.assertNull(catalog.checkedProjectName); - Assert.assertEquals("mc_project", catalog.checkedNamespaceSchemaProjectName); - } - - @Test - public void testCheckWhenCreatingReportsInaccessibleProject() { - Map props = createRequiredProperties(false); - props.put(ExternalCatalog.TEST_CONNECTION, "true"); - TestMaxComputeExternalCatalog catalog = new TestMaxComputeExternalCatalog(props); - catalog.projectExists = false; - - DdlException exception = Assert.assertThrows(DdlException.class, catalog::checkWhenCreating); - - Assert.assertTrue(exception.getMessage().contains("Failed to validate MaxCompute project 'mc_project'")); - Assert.assertTrue(exception.getMessage().contains("does not exist or is not accessible")); - Assert.assertNull(catalog.checkedNamespaceSchemaProjectName); - } - - @Test - public void testCheckWhenCreatingReportsInaccessibleNamespaceSchema() { - Map props = createRequiredProperties(true); - props.put(ExternalCatalog.TEST_CONNECTION, "true"); - TestMaxComputeExternalCatalog catalog = new TestMaxComputeExternalCatalog(props); - catalog.threeTierModel = false; - - DdlException exception = Assert.assertThrows(DdlException.class, catalog::checkWhenCreating); - - Assert.assertTrue(exception.getMessage().contains("Failed to validate MaxCompute project 'mc_project'")); - Assert.assertTrue(exception.getMessage().contains("schema list is accessible")); - } - - private static Map createRequiredProperties(boolean enableNamespaceSchema) { - Map props = new HashMap<>(); - addRequiredProperties(props); - props.put(MCProperties.ENABLE_NAMESPACE_SCHEMA, Boolean.toString(enableNamespaceSchema)); - return props; - } - - private static void addRequiredProperties(Map props) { - props.put(MCProperties.PROJECT, "mc_project"); - props.put(MCProperties.ENDPOINT, "http://service.cn-beijing.maxcompute.aliyun-inc.com/api"); - props.put(MCProperties.ACCESS_KEY, "access_key"); - props.put(MCProperties.SECRET_KEY, "secret_key"); - } - - private static class TestMaxComputeExternalCatalog extends MaxComputeExternalCatalog { - private boolean projectExists = true; - private boolean threeTierModel = true; - private String checkedProjectName; - private String checkedNamespaceSchemaProjectName; - - private TestMaxComputeExternalCatalog(Map props) { - super(1L, "mc_catalog", null, props, ""); - } - - @Override - protected boolean maxComputeProjectExists(String projectName) { - checkedProjectName = projectName; - return projectExists; - } - - @Override - protected void validateMaxComputeNamespaceSchemaAccess(String projectName) { - checkedNamespaceSchemaProjectName = projectName; - if (!threeTierModel) { - throw new RuntimeException("schema list is not accessible"); - } - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalMetaCacheTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalMetaCacheTest.java deleted file mode 100644 index dd99b578c4a335..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalMetaCacheTest.java +++ /dev/null @@ -1,139 +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.datasource.maxcompute; - -import org.apache.doris.catalog.Type; -import org.apache.doris.common.Config; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.SchemaCacheKey; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.TablePartitionValues; -import org.apache.doris.datasource.metacache.MetaCacheEntry; -import org.apache.doris.datasource.metacache.MetaCacheEntryStats; - -import org.junit.Assert; -import org.junit.Test; - -import java.util.Collections; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -public class MaxComputeExternalMetaCacheTest { - - @Test - public void testPartitionValuesLoadFromSchemaEntryInsideEngineCache() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - MaxComputeExternalMetaCache cache = new MaxComputeExternalMetaCache(executor); - long catalogId = 1L; - cache.initCatalog(catalogId, Collections.emptyMap()); - - NameMapping table = new NameMapping(catalogId, "db1", "tbl1", "remote_db1", "remote_tbl1"); - MetaCacheEntry schemaEntry = cache.entry( - catalogId, MaxComputeExternalMetaCache.ENTRY_SCHEMA, SchemaCacheKey.class, SchemaCacheValue.class); - schemaEntry.put(new SchemaCacheKey(table), new MaxComputeSchemaCacheValue( - Collections.emptyList(), - null, - null, - Collections.singletonList("pt"), - Collections.singletonList("pt=20250101"), - Collections.emptyList(), - Collections.singletonList(Type.INT), - Collections.emptyMap())); - - TablePartitionValues partitionValues = cache.getPartitionValues(table); - - Assert.assertEquals(1, partitionValues.getPartitionNameToIdMap().size()); - Assert.assertTrue(partitionValues.getPartitionNameToIdMap().containsKey("pt=20250101")); - } finally { - executor.shutdownNow(); - } - } - - @Test - public void testInvalidateTablePrecise() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - MaxComputeExternalMetaCache cache = new MaxComputeExternalMetaCache(executor); - long catalogId = 1L; - cache.initCatalog(catalogId, Collections.emptyMap()); - - NameMapping t1 = new NameMapping(catalogId, "db1", "tbl1", "remote_db1", "remote_tbl1"); - NameMapping t2 = new NameMapping(catalogId, "db1", "tbl2", "remote_db1", "remote_tbl2"); - - MetaCacheEntry partitionEntry = cache.entry( - catalogId, - MaxComputeExternalMetaCache.ENTRY_PARTITION_VALUES, - NameMapping.class, - TablePartitionValues.class); - partitionEntry.put(t1, new TablePartitionValues()); - partitionEntry.put(t2, new TablePartitionValues()); - - cache.invalidateTable(catalogId, "db1", "tbl1"); - - Assert.assertNull(partitionEntry.getIfPresent(t1)); - Assert.assertNotNull(partitionEntry.getIfPresent(t2)); - } finally { - executor.shutdownNow(); - } - } - - @Test - public void testStatsIncludePartitionValuesEntry() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - MaxComputeExternalMetaCache cache = new MaxComputeExternalMetaCache(executor); - long catalogId = 1L; - cache.initCatalog(catalogId, Collections.emptyMap()); - - Map stats = cache.stats(catalogId); - Assert.assertTrue(stats.containsKey(MaxComputeExternalMetaCache.ENTRY_PARTITION_VALUES)); - Assert.assertTrue(stats.containsKey(MaxComputeExternalMetaCache.ENTRY_SCHEMA)); - } finally { - executor.shutdownNow(); - } - } - - @Test - public void testPartitionValuesDefaultSpecUsesTableLevelCapacity() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - long originalPartitionCapacity = Config.max_hive_partition_cache_num; - long originalPartitionTableCapacity = Config.max_hive_partition_table_cache_num; - long originalRefreshTime = Config.external_cache_refresh_time_minutes; - try { - Config.max_hive_partition_cache_num = 100L; - Config.max_hive_partition_table_cache_num = 20L; - Config.external_cache_refresh_time_minutes = 3L; - - MaxComputeExternalMetaCache cache = new MaxComputeExternalMetaCache(executor); - long catalogId = 1L; - cache.initCatalog(catalogId, Collections.emptyMap()); - - MetaCacheEntryStats partitionValuesStats = cache.stats(catalogId) - .get(MaxComputeExternalMetaCache.ENTRY_PARTITION_VALUES); - Assert.assertEquals(20L, partitionValuesStats.getCapacity()); - Assert.assertEquals(180L, partitionValuesStats.getTtlSecond()); - } finally { - Config.max_hive_partition_cache_num = originalPartitionCapacity; - Config.max_hive_partition_table_cache_num = originalPartitionTableCapacity; - Config.external_cache_refresh_time_minutes = originalRefreshTime; - executor.shutdownNow(); - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalTableTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalTableTest.java deleted file mode 100644 index abbb4d3394f394..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/MaxComputeExternalTableTest.java +++ /dev/null @@ -1,50 +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.datasource.maxcompute; - -import org.junit.Assert; -import org.junit.Test; - -import java.util.Arrays; -import java.util.List; - -public class MaxComputeExternalTableTest { - @Test - public void testParsePartitionValues() { - List partitionColumns = Arrays.asList("p1", "p2"); - - Assert.assertEquals(Arrays.asList("a", "b"), - MaxComputeExternalTable.parsePartitionValues(partitionColumns, "p1=a/p2=b")); - Assert.assertEquals(Arrays.asList("a", "b"), - MaxComputeExternalTable.parsePartitionValues(partitionColumns, "p2=b/p1=a")); - } - - @Test - public void testParsePartitionValuesRejectsInvalidSpec() { - List partitionColumns = Arrays.asList("p1", "p2"); - - Assert.assertThrows(RuntimeException.class, - () -> MaxComputeExternalTable.parsePartitionValues(partitionColumns, "p1=a")); - Assert.assertThrows(RuntimeException.class, - () -> MaxComputeExternalTable.parsePartitionValues(partitionColumns, "p1=a/raw")); - Assert.assertThrows(RuntimeException.class, - () -> MaxComputeExternalTable.parsePartitionValues(partitionColumns, "p1=a/p1=b")); - Assert.assertThrows(RuntimeException.class, - () -> MaxComputeExternalTable.parsePartitionValues(partitionColumns, "p1=a/p3=b")); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/source/MaxComputeScanNodeTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/source/MaxComputeScanNodeTest.java deleted file mode 100644 index 4989c2c53f21cb..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/source/MaxComputeScanNodeTest.java +++ /dev/null @@ -1,463 +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.datasource.maxcompute.source; - -import org.apache.doris.analysis.BinaryPredicate; -import org.apache.doris.analysis.Expr; -import org.apache.doris.analysis.InPredicate; -import org.apache.doris.analysis.SlotDescriptor; -import org.apache.doris.analysis.SlotRef; -import org.apache.doris.analysis.StringLiteral; -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.analysis.TupleId; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalTable; -import org.apache.doris.datasource.maxcompute.source.MaxComputeSplit.SplitType; -import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan.SelectedPartitions; -import org.apache.doris.planner.PlanNode; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.spi.Split; - -import com.aliyun.odps.table.DataFormat; -import com.aliyun.odps.table.DataSchema; -import com.aliyun.odps.table.SessionStatus; -import com.aliyun.odps.table.TableIdentifier; -import com.aliyun.odps.table.read.TableBatchReadSession; -import com.aliyun.odps.table.read.split.InputSplitAssigner; -import com.google.common.collect.Lists; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.MockitoJUnitRunner; - -import java.io.IOException; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Date; -import java.util.List; - -@RunWith(MockitoJUnitRunner.class) -public class MaxComputeScanNodeTest { - - @Mock - private MaxComputeExternalTable table; - - @Mock - private MaxComputeExternalCatalog catalog; - - @Mock - private com.aliyun.odps.Table odpsTable; - - private SessionVariable sv; - private TupleDescriptor desc; - private MaxComputeScanNode node; - - private List partitionColumns; - - @Before - public void setUp() { - partitionColumns = Arrays.asList( - new Column("dt", PrimitiveType.VARCHAR), - new Column("hr", PrimitiveType.VARCHAR) - ); - Mockito.when(table.getPartitionColumns()).thenReturn(partitionColumns); - Mockito.when(table.getCatalog()).thenReturn(catalog); - Mockito.when(table.getOdpsTable()).thenReturn(odpsTable); - - desc = Mockito.mock(TupleDescriptor.class); - Mockito.when(desc.getTable()).thenReturn(table); - Mockito.when(desc.getId()).thenReturn(new TupleId(0)); - Mockito.when(desc.getSlots()).thenReturn(new ArrayList<>()); - - sv = new SessionVariable(); - node = new MaxComputeScanNode(new PlanNodeId(0), desc, - SelectedPartitions.NOT_PRUNED, false, sv, ScanContext.EMPTY); - } - - // ==================== Reflection Helpers ==================== - - private void setConjuncts(PlanNode target, List conjuncts) throws Exception { - Field f = PlanNode.class.getDeclaredField("conjuncts"); - f.setAccessible(true); - f.set(target, conjuncts); - } - - private void setLimit(PlanNode target, long limit) throws Exception { - Field f = PlanNode.class.getDeclaredField("limit"); - f.setAccessible(true); - f.setLong(target, limit); - } - - private void setOnlyPartitionEqualityPredicate(MaxComputeScanNode target, boolean value) throws Exception { - Field f = MaxComputeScanNode.class.getDeclaredField("onlyPartitionEqualityPredicate"); - f.setAccessible(true); - f.setBoolean(target, value); - } - - private boolean invokeCheckOnlyPartitionEqualityPredicate(MaxComputeScanNode target) throws Exception { - Method m = MaxComputeScanNode.class.getDeclaredMethod("checkOnlyPartitionEqualityPredicate"); - m.setAccessible(true); - return (boolean) m.invoke(target); - } - - // ==================== Group 1: checkOnlyPartitionEqualityPredicate ==================== - - @Test - public void testCheckOnlyPartEq_emptyConjuncts() throws Exception { - setConjuncts(node, new ArrayList<>()); - Assert.assertTrue(invokeCheckOnlyPartitionEqualityPredicate(node)); - } - - @Test - public void testCheckOnlyPartEq_singlePartitionEquality() throws Exception { - SlotRef dtSlot = new SlotRef(null, "dt"); - StringLiteral val = new StringLiteral("2026-02-26"); - BinaryPredicate eq = new BinaryPredicate(BinaryPredicate.Operator.EQ, dtSlot, val); - setConjuncts(node, Lists.newArrayList(eq)); - Assert.assertTrue(invokeCheckOnlyPartitionEqualityPredicate(node)); - } - - @Test - public void testCheckOnlyPartEq_multiPartitionEquality() throws Exception { - SlotRef dtSlot = new SlotRef(null, "dt"); - SlotRef hrSlot = new SlotRef(null, "hr"); - BinaryPredicate eq1 = new BinaryPredicate(BinaryPredicate.Operator.EQ, dtSlot, new StringLiteral("x")); - BinaryPredicate eq2 = new BinaryPredicate(BinaryPredicate.Operator.EQ, hrSlot, new StringLiteral("10")); - setConjuncts(node, Lists.newArrayList(eq1, eq2)); - Assert.assertTrue(invokeCheckOnlyPartitionEqualityPredicate(node)); - } - - @Test - public void testCheckOnlyPartEq_nonPartitionColumn() throws Exception { - SlotRef statusSlot = new SlotRef(null, "status"); - BinaryPredicate eq = new BinaryPredicate(BinaryPredicate.Operator.EQ, statusSlot, new StringLiteral("active")); - setConjuncts(node, Lists.newArrayList(eq)); - Assert.assertFalse(invokeCheckOnlyPartitionEqualityPredicate(node)); - } - - @Test - public void testCheckOnlyPartEq_nonEqOperator() throws Exception { - SlotRef dtSlot = new SlotRef(null, "dt"); - BinaryPredicate gt = new BinaryPredicate(BinaryPredicate.Operator.GT, dtSlot, new StringLiteral("2026-01-01")); - setConjuncts(node, Lists.newArrayList(gt)); - Assert.assertFalse(invokeCheckOnlyPartitionEqualityPredicate(node)); - } - - @Test - public void testCheckOnlyPartEq_inPredicateOnPartitionColumn() throws Exception { - SlotRef dtSlot = new SlotRef(null, "dt"); - List inList = Lists.newArrayList(new StringLiteral("a"), new StringLiteral("b")); - InPredicate inPred = new InPredicate(dtSlot, inList, false); - setConjuncts(node, Lists.newArrayList(inPred)); - Assert.assertTrue(invokeCheckOnlyPartitionEqualityPredicate(node)); - } - - @Test - public void testCheckOnlyPartEq_notInPredicate() throws Exception { - SlotRef dtSlot = new SlotRef(null, "dt"); - List inList = Lists.newArrayList(new StringLiteral("a"), new StringLiteral("b")); - InPredicate notInPred = new InPredicate(dtSlot, inList, true); - setConjuncts(node, Lists.newArrayList(notInPred)); - Assert.assertFalse(invokeCheckOnlyPartitionEqualityPredicate(node)); - } - - @Test - public void testCheckOnlyPartEq_inPredicateOnNonPartitionColumn() throws Exception { - SlotRef statusSlot = new SlotRef(null, "status"); - List inList = Lists.newArrayList(new StringLiteral("a"), new StringLiteral("b")); - InPredicate inPred = new InPredicate(statusSlot, inList, false); - setConjuncts(node, Lists.newArrayList(inPred)); - Assert.assertFalse(invokeCheckOnlyPartitionEqualityPredicate(node)); - } - - @Test - public void testCheckOnlyPartEq_inPredicateWithNonLiteralValue() throws Exception { - SlotRef dtSlot = new SlotRef(null, "dt"); - SlotRef hrSlot = new SlotRef(null, "hr"); - List inList = Lists.newArrayList(hrSlot); - InPredicate inPred = new InPredicate(dtSlot, inList, false); - setConjuncts(node, Lists.newArrayList(inPred)); - Assert.assertFalse(invokeCheckOnlyPartitionEqualityPredicate(node)); - } - - @Test - public void testCheckOnlyPartEq_mixedEqAndInOnPartitionColumns() throws Exception { - SlotRef dtSlot = new SlotRef(null, "dt"); - BinaryPredicate eq = new BinaryPredicate(BinaryPredicate.Operator.EQ, dtSlot, new StringLiteral("2026-01-01")); - - SlotRef hrSlot = new SlotRef(null, "hr"); - List inList = Lists.newArrayList(new StringLiteral("10"), new StringLiteral("11")); - InPredicate inPred = new InPredicate(hrSlot, inList, false); - - setConjuncts(node, Lists.newArrayList(eq, inPred)); - Assert.assertTrue(invokeCheckOnlyPartitionEqualityPredicate(node)); - } - - @Test - public void testCheckOnlyPartEq_leftSideNotSlotRef() throws Exception { - StringLiteral left = new StringLiteral("x"); - StringLiteral right = new StringLiteral("x"); - BinaryPredicate eq = new BinaryPredicate(BinaryPredicate.Operator.EQ, left, right); - setConjuncts(node, Lists.newArrayList(eq)); - Assert.assertFalse(invokeCheckOnlyPartitionEqualityPredicate(node)); - } - - @Test - public void testCheckOnlyPartEq_rightSideNotLiteral() throws Exception { - SlotRef dtSlot = new SlotRef(null, "dt"); - SlotRef hrSlot = new SlotRef(null, "hr"); - BinaryPredicate eq = new BinaryPredicate(BinaryPredicate.Operator.EQ, dtSlot, hrSlot); - setConjuncts(node, Lists.newArrayList(eq)); - Assert.assertFalse(invokeCheckOnlyPartitionEqualityPredicate(node)); - } - - // ==================== Serializable Stub for TableBatchReadSession ==================== - - private static class StubTableBatchReadSession implements TableBatchReadSession { - private static final long serialVersionUID = 1L; - private transient InputSplitAssigner assigner; - - StubTableBatchReadSession(InputSplitAssigner assigner) { - this.assigner = assigner; - } - - @Override - public InputSplitAssigner getInputSplitAssigner() throws IOException { - return assigner; - } - - @Override - public DataSchema readSchema() { - return null; - } - - @Override - public boolean supportsDataFormat(DataFormat dataFormat) { - return false; - } - - @Override - public String getId() { - return "stub-session"; - } - - @Override - public TableIdentifier getTableIdentifier() { - return null; - } - - @Override - public SessionStatus getStatus() { - return SessionStatus.NORMAL; - } - - @Override - public String toJson() { - return "{}"; - } - } - - // ==================== Mock Session Helper ==================== - - private MaxComputeScanNode createSpyNodeWithMockSession(long totalRowCount) throws Exception { - MaxComputeScanNode spyNode = Mockito.spy(node); - - InputSplitAssigner mockAssigner = Mockito.mock(InputSplitAssigner.class); - com.aliyun.odps.table.read.split.InputSplit mockInputSplit = - Mockito.mock(com.aliyun.odps.table.read.split.InputSplit.class); - - Mockito.when(mockAssigner.getTotalRowCount()).thenReturn(totalRowCount); - Mockito.when(mockAssigner.getSplitByRowOffset(Mockito.anyLong(), Mockito.anyLong())) - .thenReturn(mockInputSplit); - Mockito.when(mockInputSplit.getSessionId()).thenReturn("test-session-id"); - - StubTableBatchReadSession stubSession = new StubTableBatchReadSession(mockAssigner); - - Mockito.doReturn(stubSession).when(spyNode) - .createTableBatchReadSession(Mockito.anyList(), Mockito.any( - com.aliyun.odps.table.configuration.SplitOptions.class)); - Mockito.doReturn(stubSession).when(spyNode) - .createTableBatchReadSession(Mockito.anyList()); - - Mockito.when(odpsTable.getLastDataModifiedTime()).thenReturn(new Date(1000L)); - - return spyNode; - } - - // ==================== Group 2: getSplitsWithLimitOptimization ==================== - - private List invokeGetSplitsWithLimitOptimization( - MaxComputeScanNode target) throws Exception { - Method m = MaxComputeScanNode.class.getDeclaredMethod( - "getSplitsWithLimitOptimization", List.class); - m.setAccessible(true); - @SuppressWarnings("unchecked") - List result = (List) m.invoke(target, Collections.emptyList()); - return result; - } - - @Test - public void testLimitOpt_limitLessThanTotal() throws Exception { - MaxComputeScanNode spyNode = createSpyNodeWithMockSession(10000L); - setLimit(spyNode, 100L); - - List result = invokeGetSplitsWithLimitOptimization(spyNode); - - Assert.assertEquals(1, result.size()); - MaxComputeSplit split = (MaxComputeSplit) result.get(0); - Assert.assertEquals(SplitType.ROW_OFFSET, split.splitType); - Assert.assertEquals(100L, split.getLength()); - } - - @Test - public void testLimitOpt_limitGreaterThanTotal() throws Exception { - MaxComputeScanNode spyNode = createSpyNodeWithMockSession(200L); - setLimit(spyNode, 50000L); - - List result = invokeGetSplitsWithLimitOptimization(spyNode); - - Assert.assertEquals(1, result.size()); - MaxComputeSplit split = (MaxComputeSplit) result.get(0); - Assert.assertEquals(SplitType.ROW_OFFSET, split.splitType); - Assert.assertEquals(200L, split.getLength()); - } - - @Test - public void testLimitOpt_totalRowCountZero() throws Exception { - MaxComputeScanNode spyNode = createSpyNodeWithMockSession(0L); - setLimit(spyNode, 100L); - - List result = invokeGetSplitsWithLimitOptimization(spyNode); - - Assert.assertTrue(result.isEmpty()); - } - - // ==================== Group 3: getSplits gating conditions ==================== - - private MaxComputeScanNode createSpyNodeForGetSplits(long totalRowCount) throws Exception { - // Need non-empty slots so getSplits doesn't return early - SlotDescriptor mockSlotDesc = Mockito.mock(SlotDescriptor.class); - Column dataCol = new Column("value", PrimitiveType.VARCHAR); - Mockito.when(mockSlotDesc.getColumn()).thenReturn(dataCol); - Mockito.when(desc.getSlots()).thenReturn(Lists.newArrayList(mockSlotDesc)); - - // Need fileNum > 0 - Mockito.when(odpsTable.getFileNum()).thenReturn(10L); - - // For normal path: use row_count strategy - Mockito.when(catalog.getSplitStrategy()).thenReturn("row_count"); - Mockito.when(catalog.getSplitRowCount()).thenReturn(totalRowCount); - - // Need table.getColumns() for createRequiredColumns() - List allColumns = Lists.newArrayList( - new Column("dt", PrimitiveType.VARCHAR), - new Column("hr", PrimitiveType.VARCHAR), - new Column("value", PrimitiveType.VARCHAR) - ); - Mockito.when(table.getColumns()).thenReturn(allColumns); - - return createSpyNodeWithMockSession(totalRowCount); - } - - @Test - public void testGetSplits_allConditionsMet_optimizationPath() throws Exception { - MaxComputeScanNode spyNode = createSpyNodeForGetSplits(10000L); - sv.enableMcLimitSplitOptimization = true; - setOnlyPartitionEqualityPredicate(spyNode, true); - setLimit(spyNode, 100L); - - List result = spyNode.getSplits(1); - - Assert.assertEquals(1, result.size()); - MaxComputeSplit split = (MaxComputeSplit) result.get(0); - Assert.assertEquals(SplitType.ROW_OFFSET, split.splitType); - Assert.assertEquals(100L, split.getLength()); - } - - @Test - public void testGetSplits_optimizationDisabled_normalPath() throws Exception { - MaxComputeScanNode spyNode = createSpyNodeForGetSplits(1000L); - sv.enableMcLimitSplitOptimization = false; - setOnlyPartitionEqualityPredicate(spyNode, true); - setLimit(spyNode, 100L); - - List result = spyNode.getSplits(1); - - // Normal path with row_count strategy: totalRowCount=1000, splitRowCount=1000 → 1 split - // but the split length equals splitRowCount, not limit - Assert.assertFalse(result.isEmpty()); - } - - @Test - public void testGetSplits_nonPartitionPredicate_normalPath() throws Exception { - MaxComputeScanNode spyNode = createSpyNodeForGetSplits(1000L); - sv.enableMcLimitSplitOptimization = true; - setOnlyPartitionEqualityPredicate(spyNode, false); - setLimit(spyNode, 100L); - - List result = spyNode.getSplits(1); - - Assert.assertFalse(result.isEmpty()); - } - - @Test - public void testGetSplits_noLimit_normalPath() throws Exception { - MaxComputeScanNode spyNode = createSpyNodeForGetSplits(1000L); - sv.enableMcLimitSplitOptimization = true; - setOnlyPartitionEqualityPredicate(spyNode, true); - // limit defaults to -1 (no limit), don't set it - - List result = spyNode.getSplits(1); - - Assert.assertFalse(result.isEmpty()); - } - - @Test - public void testGetSplitsRejectsOdpsExternalTable() { - assertGetSplitsRejectsUnsupportedOdpsTable(true, false, "mc_external_table"); - } - - @Test - public void testGetSplitsRejectsOdpsLogicalView() { - assertGetSplitsRejectsUnsupportedOdpsTable(false, true, "mc_logical_view"); - } - - private void assertGetSplitsRejectsUnsupportedOdpsTable(boolean isExternalTable, boolean isVirtualView, - String tableName) { - Mockito.when(odpsTable.isExternalTable()).thenReturn(isExternalTable); - Mockito.when(odpsTable.isVirtualView()).thenReturn(isVirtualView); - Mockito.when(table.getDbName()).thenReturn("default"); - Mockito.when(table.getName()).thenReturn(tableName); - - UserException exception = Assert.assertThrows(UserException.class, () -> node.getSplits(1)); - Assert.assertTrue(exception.getMessage().contains( - "Reading MaxCompute external table or logical view is not supported: default." + tableName)); - Mockito.verify(odpsTable, Mockito.never()).getFileNum(); - } -} 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 new file mode 100644 index 00000000000000..ffbdff5e476569 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTableTest.java @@ -0,0 +1,1510 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.mvcc; + +import org.apache.doris.analysis.TableScanParams; +import org.apache.doris.analysis.TableSnapshot; +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.ListPartitionItem; +import org.apache.doris.catalog.PartitionItem; +import org.apache.doris.catalog.PartitionKey; +import org.apache.doris.catalog.PartitionType; +import org.apache.doris.catalog.RangePartitionItem; +import org.apache.doris.catalog.Type; +import org.apache.doris.common.AnalysisException; +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.ConnectorPartitionInfo; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.ConnectorTableSchema; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.mvcc.ConnectorMvccPartition; +import org.apache.doris.connector.api.mvcc.ConnectorMvccPartitionView; +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.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; +import org.apache.doris.mtmv.MTMVMaxTimestampSnapshot; +import org.apache.doris.mtmv.MTMVSnapshotIdSnapshot; +import org.apache.doris.mtmv.MTMVTimestampSnapshot; +import org.apache.doris.nereids.StatementContext; +import org.apache.doris.qe.ConnectContext; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * Tests for {@link PluginDrivenMvccExternalTable}, the generic MVCC/MTMV-capable plugin table. + * + *

    Why these matter: this class is the fe-core MTMV/MvccTable bridge for snapshot-capable + * connectors (Paimon first; later Iceberg/Hudi). It must (1) pin the REAL connector snapshot id for + * incremental MTMV change-detection — a constant would make every refresh see "no change" or "always + * changed"; (2) honor a supplied pin so the whole query reads ONE consistent partition set with no + * extra connector round-trip (single-pin invariant); (3) build partition keys from the RENDERED + * partition name the connector produced (date already a string), not a raw epoch; (4) fall back to + * UNPARTITIONED when a partition fails to build rather than silently pruning to a partial set; and + * (5) dispatch explicit time-travel (FOR VERSION/TIME, tag/branch/incr scan params) source-agnostically + * into a {@link ConnectorTimeTravelSpec}, translate not-found into a user error, and pin the + * schema-AS-OF the snapshot so reads under schema evolution see the historical columns. The class is + * source-agnostic: it is constructed directly here against a mocked connector.

    + */ +public class PluginDrivenMvccExternalTableTest { + + private static final long PINNED_SNAPSHOT_ID = 4242L; + private static final long TS_2024_01_01 = 1_700_000_000_000L; + private static final long TS_2024_02_02 = 1_800_000_000_000L; + + @AfterEach + public void cleanup() { + ConnectContext.remove(); + } + + // ==================== getTableSnapshot: REAL pinned id ==================== + + @Test + public void testGetTableSnapshotReturnsRealPinnedId() throws AnalysisException { + Fixture f = Fixture.partitioned(); + MTMVSnapshotIdSnapshot snap = + (MTMVSnapshotIdSnapshot) f.table.getTableSnapshot(Optional.empty()); + // MUTATION: returning a constant -1 (or any other id) makes this red. The pinned id is what + // MTMV uses to decide whether the base table changed since last refresh. + Assertions.assertEquals(PINNED_SNAPSHOT_ID, snap.getSnapshotVersion(), + "getTableSnapshot must carry the REAL connector snapshot id"); + } + + // ==================== getPartitionSnapshot: timestamp + missing throws ==================== + + @Test + public void testGetPartitionSnapshotReturnsLastModifiedMillis() throws AnalysisException { + Fixture f = Fixture.partitioned(); + MTMVTimestampSnapshot ts = (MTMVTimestampSnapshot) f.table.getPartitionSnapshot( + "dt=2024-01-01", null, Optional.empty()); + // MUTATION: returning the wrong partition's millis (or 0) makes this red. + Assertions.assertEquals(TS_2024_01_01, ts.getSnapshotVersion(), + "partition snapshot must be that partition's lastModifiedMillis"); + } + + @Test + public void testGetPartitionSnapshotMissingThrows() { + Fixture f = Fixture.partitioned(); + // MUTATION: returning a default snapshot instead of throwing makes this red. + Assertions.assertThrows(AnalysisException.class, + () -> f.table.getPartitionSnapshot("dt=1999-12-31", null, Optional.empty()), + "an unknown partition name must raise AnalysisException, not silently succeed"); + } + + // ==================== last-modified freshness (e.g. hive): table + partition snapshots ==================== + + /** + * Re-stubs {@code beginQuerySnapshot} so the query-begin pin advertises last-modified freshness (the flag a + * hive connector sets). fe-core reads this off the pin to decide whether to serve MTMV freshness from the + * on-demand SPI (hive) vs the snapshot id (paimon/iceberg). + */ + private static void flagPinLastModified(Fixture f) { + Mockito.when(f.metadata.beginQuerySnapshot(f.session, f.handle)) + .thenReturn(Optional.of(ConnectorMvccSnapshot.builder() + .snapshotId(-1L).lastModifiedFreshness(true).build())); + } + + @Test + public void testGetTableSnapshotLastModifiedEmitsMaxTimestampSnapshot() throws AnalysisException { + // A last-modified connector (hive) flags its pin and reports whole-table freshness via getTableFreshness; + // fe-core must wrap it in MTMVMaxTimestampSnapshot (byte-parity with legacy HiveDlaTable.getTableSnapshot), + // NOT the snapshot-id token. Without this a plain-hive empty pin's snapshot id is a constant -1, so an MV + // over a hive base table would compare equal forever and never refresh. + Fixture f = Fixture.partitioned(); + flagPinLastModified(f); + Mockito.when(f.metadata.getTableFreshness(Mockito.any(), Mockito.any())) + .thenReturn(Optional.of(new ConnectorTableFreshness("dt=2024-02-02", TS_2024_02_02))); + + // MUTATION: keeping the hardcoded MTMVSnapshotIdSnapshot (ignoring getTableFreshness) makes this cast + // throw ClassCastException -> red. + MTMVMaxTimestampSnapshot snap = + (MTMVMaxTimestampSnapshot) f.table.getTableSnapshot(Optional.empty()); + // MTMVMaxTimestampSnapshot.equals compares BOTH the partition name and the timestamp (the name guards + // against dropping the partition that owns the max time). MUTATION: dropping the name or the millis makes + // this red. + Assertions.assertEquals(new MTMVMaxTimestampSnapshot("dt=2024-02-02", TS_2024_02_02), snap, + "a last-modified connector's table snapshot must carry (max-partition-name, max-modify-millis)"); + } + + @Test + public void testGetTableSnapshotContextOverloadAlsoLastModified() throws AnalysisException { + // The MTMVRefreshContext overload (used by MTMVPartitionUtil.getTableSnapshotFromContext) must route to + // the same freshness-aware path, not a separate hardcoded one. + Fixture f = Fixture.partitioned(); + flagPinLastModified(f); + Mockito.when(f.metadata.getTableFreshness(Mockito.any(), Mockito.any())) + .thenReturn(Optional.of(new ConnectorTableFreshness("t", 4242L))); + MTMVMaxTimestampSnapshot snap = + (MTMVMaxTimestampSnapshot) f.table.getTableSnapshot(null, Optional.empty()); + Assertions.assertEquals(new MTMVMaxTimestampSnapshot("t", 4242L), snap); + } + + @Test + public void testGetPartitionSnapshotLastModifiedUsesOnDemandNotPin() throws AnalysisException { + // A last-modified connector withholds per-partition modify time from listPartitions (names-only hot + // path), so the pin carries the -1 UNKNOWN sentinel; getPartitionSnapshot must take the REAL time from + // the on-demand getPartitionFreshnessMillis, not the pin. + Fixture f = Fixture.with(Collections.singletonList( + cpi("dt=2024-01-01", ConnectorPartitionInfo.UNKNOWN))); + flagPinLastModified(f); + Mockito.when(f.metadata.getPartitionFreshnessMillis(Mockito.any(), Mockito.any(), + Mockito.eq("dt=2024-01-01"))).thenReturn(OptionalLong.of(TS_2024_01_01)); + + MTMVTimestampSnapshot ts = (MTMVTimestampSnapshot) f.table.getPartitionSnapshot( + "dt=2024-01-01", null, Optional.empty()); + // MUTATION: reading the pin value (-1) instead of the on-demand fetch makes this red (would be -1), + // which would make every partition compare equal forever (stale MV at partition granularity). + Assertions.assertEquals(TS_2024_01_01, ts.getSnapshotVersion(), + "a last-modified connector's partition snapshot must use the on-demand millis, not the pin's -1"); + } + + @Test + public void testGetPartitionSnapshotLastModifiedMissingStillThrows() { + // Existence is validated against the materialized partition set BEFORE the on-demand fetch, so even a + // last-modified connector raises AnalysisException for an unknown partition (parity legacy + // HiveDlaTable.getPartitionSnapshot -> checkPartitionExists). + Fixture f = Fixture.partitioned(); + flagPinLastModified(f); + Mockito.when(f.metadata.getPartitionFreshnessMillis(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(OptionalLong.of(TS_2024_01_01)); + Assertions.assertThrows(AnalysisException.class, + () -> f.table.getPartitionSnapshot("dt=1999-12-31", null, Optional.empty()), + "an unknown partition must throw even for a last-modified connector (existence checked first)"); + } + + @Test + public void testGetPartitionSnapshotLastModifiedVanishedThrows() { + // The partition IS in the materialized set (existence check passes) but VANISHED before the on-demand + // fetch (a refresh-time race), so getPartitionFreshnessMillis returns empty -> fe-core must raise the + // legacy "can not find partition", NOT emit a bogus MTMVTimestampSnapshot(0). MUTATION: falling back to + // MTMVTimestampSnapshot(0) instead of throwing makes this red. + Fixture f = Fixture.with(Collections.singletonList( + cpi("dt=2024-01-01", ConnectorPartitionInfo.UNKNOWN))); + flagPinLastModified(f); + Mockito.when(f.metadata.getPartitionFreshnessMillis(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(OptionalLong.empty()); + Assertions.assertThrows(AnalysisException.class, + () -> f.table.getPartitionSnapshot("dt=2024-01-01", null, Optional.empty()), + "a vanished partition (on-demand empty) must throw, not return a bogus 0 timestamp"); + } + + // ==================== snapshot-id connectors (paimon/iceberg): NO extra freshness probe ==================== + + @Test + public void testGetTableSnapshotSnapshotIdConnectorSkipsFreshnessProbe() throws AnalysisException { + // A snapshot-id connector (paimon/iceberg) leaves the pin flag false, so getTableSnapshot must take the + // exact pre-change path: read the snapshot id off the pin and NEVER fire the freshness probe (an extra + // getTableHandle round-trip + a new throw surface on the live MTMV path). This guards the regression the + // adversarial review caught. + Fixture f = Fixture.partitioned(); // build() pin has lastModifiedFreshness=false + MTMVSnapshotIdSnapshot snap = (MTMVSnapshotIdSnapshot) f.table.getTableSnapshot(Optional.empty()); + Assertions.assertEquals(PINNED_SNAPSHOT_ID, snap.getSnapshotVersion()); + // MUTATION: dropping the pin-flag gate (probing unconditionally) makes this verify red. + Mockito.verify(f.metadata, Mockito.never()).getTableFreshness(Mockito.any(), Mockito.any()); + } + + @Test + public void testGetPartitionSnapshotSnapshotIdConnectorSkipsFreshnessProbe() throws AnalysisException { + // Same guard at partition granularity: a pin-timestamp connector (paimon) must read the pin value and + // NEVER call getPartitionFreshnessMillis (which, per-partition in the isSyncWithPartitions loop, would be + // an O(partitions) metadata regression). + Fixture f = Fixture.partitioned(); + MTMVTimestampSnapshot ts = (MTMVTimestampSnapshot) f.table.getPartitionSnapshot( + "dt=2024-01-01", null, Optional.empty()); + Assertions.assertEquals(TS_2024_01_01, ts.getSnapshotVersion()); + // MUTATION: probing unconditionally (no pin-flag gate) makes this verify red. + Mockito.verify(f.metadata, Mockito.never()) + .getPartitionFreshnessMillis(Mockito.any(), Mockito.any(), Mockito.any()); + } + + // ==================== getNameToPartitionItems: render-from-name parity ==================== + + @Test + public void testGetNameToPartitionItemsBuildsKeyFromRenderedDateName() { + Fixture f = Fixture.partitioned(); + Map items = f.table.getNameToPartitionItems(Optional.empty()); + + Assertions.assertEquals(2, items.size()); + PartitionItem item = items.get("dt=2024-01-01"); + Assertions.assertTrue(item instanceof ListPartitionItem, "expected a ListPartitionItem"); + PartitionKey key = ((ListPartitionItem) item).getItems().get(0); + // MUTATION: if the connector had returned a raw epoch "19723" and we built from that, the + // DATEV2 key would be a different date (or fail to parse). The connector renders the date to + // a string in getPartitionName(), so the key must be 2024-01-01. + Assertions.assertEquals("2024-01-01", key.getKeys().get(0).getStringValue(), + "partition key must be built from the RENDERED date name, not a raw epoch"); + } + + @Test + public void testDefaultSentinelWithoutFlagBuildsNonNullStringKey() { + // NO-FLAG DEFAULT path: a connector that supplies NO per-value null flags leaves every value non-null + // (isNull=false), so a __HIVE_DEFAULT_PARTITION__ value on a VARCHAR column builds a plain StringLiteral, + // NOT a NullLiteral. This is the unchanged default for connectors that do not opt in (hudi/maxcompute/ + // iceberg). NB: hive and paimon DO opt in now (variant B) and would supply isNull=true here — see the two + // ...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); + Map items = f.table.getNameToPartitionItems(Optional.empty()); + + Assertions.assertEquals(1, items.size()); + PartitionItem item = items.get("dt=" + TablePartitionValues.HIVE_DEFAULT_PARTITION); + 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, + key.getKeys().get(0).getStringValue(), + "the no-flag partition key must carry the sentinel string verbatim (a plain StringLiteral)"); + } + + @Test + public void testDefaultSentinelWithNullFlagOnIntColumnBuildsGenuineNullPartition() { + // RED before the fix (fe-core hardcoded isNull=false): the sentinel on an INT column parses via + // IntLiteral("__HIVE_DEFAULT_PARTITION__") -> NumberFormatException -> the partition is dropped -> the + // snapshot is invalid -> the table mis-reports UNPARTITIONED (partition=0/0). With the connector-supplied + // 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); + + Assertions.assertEquals(PartitionType.LIST, f.table.getPartitionType(Optional.empty()), + "a genuine-NULL INT partition must NOT collapse the table to UNPARTITIONED"); + Assertions.assertFalse(f.table.getPartitionColumns(Optional.empty()).isEmpty(), + "partition columns must survive (not emptied by an invalid partition set)"); + 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); + // 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"); + } + + @Test + 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); + + 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); + Assertions.assertTrue(key.getKeys().get(0).isNullLiteral(), + "the connector-supplied NULL flag must build a typed NullLiteral for the DATE column"); + } + + @Test + public void testDefaultSentinelWithoutFlagStillDrops() { + // Locks the fix as OPT-IN: a connector that does NOT supply the flag keeps the pre-fix behavior on a + // 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); + + Assertions.assertEquals(PartitionType.UNPARTITIONED, f.table.getPartitionType(Optional.empty()), + "without the connector flag, an INT sentinel still drops the partition (UNPARTITIONED)"); + Assertions.assertTrue(f.table.getNameToPartitionItems(Optional.empty()).isEmpty()); + } + + // ==================== no-cache schema: bypass the name-keyed cache and read fresh ==================== + + @Test + public void testSchemaCacheDisabledByConnectorTtl() { + // ttl-second <= 0 (the no-cache catalog) -> the generic name-keyed schema cache (no schemaId) must be + // bypassed and the schema read fresh; an absent/positive override keeps the cached path. + Connector noCache = Mockito.mock(Connector.class); + Mockito.when(noCache.schemaCacheTtlSecondOverride()).thenReturn(OptionalLong.of(0)); + Assertions.assertTrue(PluginDrivenMvccExternalTable.schemaCacheDisabled(noCache), + "ttl-second=0 (no-cache catalog) must disable the schema cache"); + + Connector negative = Mockito.mock(Connector.class); + Mockito.when(negative.schemaCacheTtlSecondOverride()).thenReturn(OptionalLong.of(-1)); + Assertions.assertTrue(PluginDrivenMvccExternalTable.schemaCacheDisabled(negative), + "a negative ttl-second also disables the schema cache"); + + Connector withCache = Mockito.mock(Connector.class); + Mockito.when(withCache.schemaCacheTtlSecondOverride()).thenReturn(OptionalLong.empty()); + Assertions.assertFalse(PluginDrivenMvccExternalTable.schemaCacheDisabled(withCache), + "an absent override (the cached catalog) keeps the schema cache"); + + Connector positive = Mockito.mock(Connector.class); + Mockito.when(positive.schemaCacheTtlSecondOverride()).thenReturn(OptionalLong.of(3600)); + Assertions.assertFalse(PluginDrivenMvccExternalTable.schemaCacheDisabled(positive), + "a positive ttl-second keeps the schema cache"); + + Assertions.assertFalse(PluginDrivenMvccExternalTable.schemaCacheDisabled(null), + "a null connector (uninitialized) keeps the engine default"); + } + + @Test + public void testNoCacheReadsFreshSchemaElseCached() { + // The no-cache catalog must serve the FRESH (initSchema) schema, bypassing the cached (super) path; + // the cached catalog serves the cached value. This restores master's meta.cache.paimon.table + // .ttl-second=0 -> always-fresh-schema after an external ALTER (regression test_paimon_table_meta_cache + // line 112, no-cache desc expected 3 cols but got the stale 2). + SchemaCacheValue cached = new PluginDrivenSchemaCacheValue( + Collections.singletonList(new Column("c", Type.INT)), + Collections.emptyList(), Collections.emptyList()); + SchemaCacheValue fresh = new PluginDrivenSchemaCacheValue( + Arrays.asList(new Column("c", Type.INT), new Column("c2", Type.INT)), + Collections.emptyList(), Collections.emptyList()); + + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + TestablePluginCatalog catalog = new TestablePluginCatalog(metadata, session); + Connector connector = catalog.getConnector(); + ExternalDatabase db = mockDb("REMOTE_DB"); + + PluginDrivenMvccExternalTable table = + new PluginDrivenMvccExternalTable(1L, "tbl", "REMOTE_TBL", catalog, db) { + @Override + protected synchronized void makeSureInitialized() { + } + + @Override + public Optional initSchema() { + return Optional.of(fresh); + } + + @Override + protected Optional cachedSchemaCacheValue() { + return Optional.of(cached); + } + }; + + // no-cache (ttl=0): bypass the cache -> fresh + Mockito.when(connector.schemaCacheTtlSecondOverride()).thenReturn(OptionalLong.of(0)); + Assertions.assertSame(fresh, table.getLatestSchemaCacheValue().orElse(null), + "no-cache catalog must read the fresh schema (initSchema), not the cached value"); + + // with-cache (override absent): cached + Mockito.when(connector.schemaCacheTtlSecondOverride()).thenReturn(OptionalLong.empty()); + Assertions.assertSame(cached, table.getLatestSchemaCacheValue().orElse(null), + "cached catalog must read the cached schema value"); + } + + // ==================== single-pin invariant: no re-query when pin supplied ==================== + + @Test + public void testSuppliedPinIsNotReQueried() throws AnalysisException { + Fixture f = Fixture.partitioned(); + // Materialize ONCE (no pin) -> this is the single round-trip we allow. + PluginDrivenMvccSnapshot pin = + (PluginDrivenMvccSnapshot) f.table.loadSnapshot(Optional.empty(), Optional.empty()); + // Reset interaction counters so the verify below only counts post-pin calls. + Mockito.clearInvocations(f.metadata); + + Optional pinOpt = Optional.of(pin); + MTMVSnapshotIdSnapshot snap = (MTMVSnapshotIdSnapshot) f.table.getTableSnapshot(pinOpt); + Map items = f.table.getNameToPartitionItems(pinOpt); + + Assertions.assertEquals(PINNED_SNAPSHOT_ID, snap.getSnapshotVersion()); + Assertions.assertEquals(2, items.size()); + // MUTATION: if getOrMaterialize re-listed when a pin is present, these verifies (zero calls) + // would fail. The whole query must read the SAME materialized view passed in. + Mockito.verify(f.metadata, Mockito.never()) + .beginQuerySnapshot(Mockito.any(), Mockito.any()); + Mockito.verify(f.metadata, Mockito.never()) + .listPartitions(Mockito.any(), Mockito.any(), Mockito.any()); + } + + // ==================== isPartitionInvalid -> UNPARTITIONED ==================== + + @Test + public void testValueCountMismatchDegradesToUnpartitioned() { + // A value/column count mismatch is LEGITIMATE under iceberg partition spec evolution: the column + // list comes from the CURRENT spec while a row's values come from the spec its data file was + // written under. It must degrade to UNPARTITIONED (parity master / PaimonUtil.generatePartitionInfo) + // rather than fail the query -- cfb0958e607 hoisted the size check out of the per-partition + // try/catch to "fail loud", and every real-world hit turned out to be a legitimate evolution, + // taking down 6 suites (CI 996541). + Fixture f = Fixture.with(Arrays.asList( + cpi("dt=2024-01-01/region=cn", TS_2024_01_01))); + // MUTATION: hoisting the checkState back out of the per-partition try/catch makes this red. + Assertions.assertEquals(PartitionType.UNPARTITIONED, f.table.getPartitionType(Optional.empty()), + "a value/column count mismatch must degrade to UNPARTITIONED, not fail the query"); + } + + @Test + public void testZeroValuesFromUnpartitionedOriginDegradeToUnpartitioned() { + // The spec-0 shape: rows written before the table's first ADD PARTITION KEY render to an empty + // partition name and carry ZERO values while the table now has 1 partition column. This is the + // shape behind test_iceberg_table_cache / _partition_evolution_ddl / _partition_evolution_query_write. + // Supplied explicitly (not via cpi()) because orderedValuesOf("") derives [""] -- size 1 -- which + // would exercise the type-parse degrade instead of the arity one. + Fixture f = Fixture.with(Arrays.asList( + cpiValues("", TS_2024_01_01, Collections.emptyList()))); + // MUTATION: hoisting the checkState back out of the per-partition try/catch makes this red. + Assertions.assertEquals(PartitionType.UNPARTITIONED, f.table.getPartitionType(Optional.empty()), + "a zero-value partition from an unpartitioned-origin spec must degrade, not fail the query"); + } + + @Test + public void testValidPartitionSetIsList() { + Fixture f = Fixture.partitioned(); + Assertions.assertEquals(PartitionType.LIST, f.table.getPartitionType(Optional.empty()), + "a fully-built partitioned table must report LIST"); + } + + @Test + public void testDuplicateRenderedNamesCollapseAndStayValid() { + // Two connector partitions that RENDER to the SAME partition name collapse into one entry in + // BOTH name-keyed maps (item + lastModified). isPartitionInvalid compares those two like-keyed + // maps (1 == 1 -> valid), matching legacy PaimonPartitionInfo which keys both maps by name. + Fixture f = Fixture.with(Arrays.asList( + cpi("dt=2024-01-01", TS_2024_01_01), + cpi("dt=2024-01-01", TS_2024_02_02))); + // MUTATION: basing the invalid check on the RAW listed count (parts.size()=2) instead of the + // de-duplicated name-keyed size (1) makes this red — it would falsely force UNPARTITIONED and + // drop the table's partitioning even though every listed partition built successfully. + Assertions.assertEquals(PartitionType.LIST, f.table.getPartitionType(Optional.empty()), + "partitions rendering to the same name must collapse, not force UNPARTITIONED"); + Assertions.assertEquals(1, f.table.getNameToPartitionItems(Optional.empty()).size(), + "the duplicate rendered name must collapse to a single partition item"); + } + + // ==================== loadSnapshot: B5a latest materialize ==================== + + @Test + public void testLoadSnapshotEmptyMaterializes() { + Fixture f = Fixture.partitioned(); + MvccSnapshot snap = f.table.loadSnapshot(Optional.empty(), Optional.empty()); + Assertions.assertNotNull(snap); + Assertions.assertTrue(snap instanceof PluginDrivenMvccSnapshot); + PluginDrivenMvccSnapshot pin = (PluginDrivenMvccSnapshot) snap; + Assertions.assertEquals(PINNED_SNAPSHOT_ID, pin.getConnectorSnapshot().getSnapshotId()); + // B5a latest pin must NOT carry a pinned schema (callers fall back to latest) and must + // materialize the partition maps. MUTATION: pinning a schema or dropping the partition maps + // on the latest path makes this red. + Assertions.assertNull(pin.getPinnedSchema(), + "the B5a latest pin must have a null pinnedSchema (use latest schema)"); + Assertions.assertEquals(2, pin.getNameToPartitionItem().size(), + "the latest pin must carry the materialized partition view"); + } + + @Test + public void testLoadSnapshotNoHandleLatestDegradesToEmptyPin() { + // No connector handle (e.g. table dropped) on the LATEST path: materializeLatest must DEGRADE + // to a valid empty pin (snapshot id -1, empty partition maps) so downstream callers fall back + // to UNPARTITIONED instead of NPE-ing on a null handle. + Fixture f = Fixture.noHandle(); + PluginDrivenMvccSnapshot pin = + (PluginDrivenMvccSnapshot) f.table.loadSnapshot(Optional.empty(), Optional.empty()); + // MUTATION: NPE-ing instead of degrading (dropping the !handleOpt.isPresent() guard) makes this + // red; a wrong sentinel id makes the -1 assertion red. + Assertions.assertEquals(-1L, pin.getConnectorSnapshot().getSnapshotId(), + "the no-handle latest pin must carry the -1 snapshot sentinel"); + Assertions.assertTrue(pin.getNameToPartitionItem().isEmpty(), + "the no-handle latest pin must have an empty partition-item map"); + Assertions.assertTrue(pin.getNameToLastModifiedMillis().isEmpty(), + "the no-handle latest pin must have an empty last-modified map"); + } + + @Test + public void testMaterializeLatestNullConnectorDegradesToEmptyPin() { + // A concurrently-DROPPED catalog: onClose() nulled the (transient) connector but left objectCreated + // true, so makeSureInitialized() does not re-create it and getConnector() returns null. A stale + // metadata-table access (mv_infos()/jobs() scan -> isMTMVSync -> materializeLatest) must DEGRADE to a + // valid empty pin instead of NPE-ing and aborting the whole metadata query (CI 973411 test_mysql_mtmv + // collateral). MUTATION: dropping the null-connector guard in materializeLatest -> NPE -> red. + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + PluginDrivenExternalCatalog droppedCatalog = new TestablePluginCatalog((Connector) null, session); + ExternalDatabase db = mockDb("REMOTE_DB"); + PluginDrivenMvccExternalTable table = + new PluginDrivenMvccExternalTable(1L, "tbl", "REMOTE_TBL", droppedCatalog, db) { + @Override + protected synchronized void makeSureInitialized() { + // no-op: skip Env-backed catalog/db init (mirror the Fixture table) + } + }; + + PluginDrivenMvccSnapshot pin = + (PluginDrivenMvccSnapshot) table.loadSnapshot(Optional.empty(), Optional.empty()); + + Assertions.assertEquals(-1L, pin.getConnectorSnapshot().getSnapshotId(), + "the null-connector (dropped-catalog) latest pin must carry the -1 snapshot sentinel"); + Assertions.assertTrue(pin.getNameToPartitionItem().isEmpty(), + "the null-connector latest pin must have an empty partition-item map"); + Assertions.assertTrue(pin.getNameToLastModifiedMillis().isEmpty(), + "the null-connector latest pin must have an empty last-modified map"); + } + + @Test + public void testLoadSnapshotNoHandleTimeTravelThrows() { + // No connector handle on a TIME-TRAVEL request: unlike the latest path it must FAIL LOUD (a + // time-travel read against a missing table cannot degrade to "latest empty"). + Fixture f = Fixture.noHandle(); + RuntimeException e = Assertions.assertThrows(RuntimeException.class, + () -> f.table.loadSnapshot(Optional.of(TableSnapshot.versionOf("7")), Optional.empty())); + // MUTATION: dropping the time-travel no-handle guard (lines ~206-208) makes this red. + Assertions.assertEquals("can not find table for time travel: REMOTE_DB.REMOTE_TBL", + e.getMessage()); + } + + // ==================== loadSnapshot: B5b time-travel spec dispatch ==================== + + @Test + public void testForTimeAsOfDigitalMillisDispatchesTimestampDigital() { + Fixture f = Fixture.timeTravel(); + f.table.loadSnapshot(Optional.of(TableSnapshot.timeOf("1700000000000")), Optional.empty()); + ConnectorTimeTravelSpec spec = f.captureSpec(); + // MUTATION: dispatching VERSION instead of TIME, or digital=false, makes this red — the + // connector would parse epoch-millis as a datetime string. + Assertions.assertEquals(ConnectorTimeTravelSpec.Kind.TIMESTAMP, spec.getKind()); + Assertions.assertTrue(spec.isDigital(), "an all-digits FOR TIME value is epoch millis"); + Assertions.assertEquals("1700000000000", spec.getStringValue()); + } + + @Test + public void testForTimeAsOfDatetimeStringDispatchesTimestampNonDigital() { + Fixture f = Fixture.timeTravel(); + f.table.loadSnapshot(Optional.of(TableSnapshot.timeOf("2024-01-01 00:00:00")), Optional.empty()); + ConnectorTimeTravelSpec spec = f.captureSpec(); + Assertions.assertEquals(ConnectorTimeTravelSpec.Kind.TIMESTAMP, spec.getKind()); + // MUTATION: marking a datetime string digital makes this red — the connector would treat it + // as epoch millis instead of parsing it with the session time zone. + Assertions.assertFalse(spec.isDigital(), "a datetime string is NOT epoch millis"); + Assertions.assertEquals("2024-01-01 00:00:00", spec.getStringValue()); + } + + @Test + public void testForVersionAsOfDigitalDispatchesSnapshotId() { + Fixture f = Fixture.timeTravel(); + f.table.loadSnapshot(Optional.of(TableSnapshot.versionOf("123")), Optional.empty()); + ConnectorTimeTravelSpec spec = f.captureSpec(); + Assertions.assertEquals(ConnectorTimeTravelSpec.Kind.SNAPSHOT_ID, spec.getKind()); + Assertions.assertEquals("123", spec.getStringValue()); + } + + @Test + public void testForVersionAsOfNonDigitalDispatchesVersionRef() { + Fixture f = Fixture.timeTravel(); + f.table.loadSnapshot(Optional.of(TableSnapshot.versionOf("my_ref")), Optional.empty()); + ConnectorTimeTravelSpec spec = f.captureSpec(); + // MUTATION: always picking SNAPSHOT_ID (ignoring the isDigitalString branch) makes this red. + // A non-digital FOR VERSION AS OF is a source-resolved ref (VERSION_REF), NOT @tag (TAG): the + // connector decides branch-vs-tag (iceberg accepts a branch OR a tag; paimon resolves a tag). + // MUTATION: dispatching TAG here (the old paimon-only assumption) would re-introduce H-7 (a + // branch ref rejected) — keep VERSION_REF so the connector owns the semantics. + Assertions.assertEquals(ConnectorTimeTravelSpec.Kind.VERSION_REF, spec.getKind(), + "a non-digital FOR VERSION AS OF is a source-resolved ref (VERSION_REF), not @tag"); + Assertions.assertEquals("my_ref", spec.getStringValue()); + } + + @Test + public void testScanParamsTagDispatchesTag() { + Fixture f = Fixture.timeTravel(); + TableScanParams params = new TableScanParams(TableScanParams.TAG, + Collections.singletonMap(TableScanParams.PARAMS_NAME, "t1"), Collections.emptyList()); + f.table.loadSnapshot(Optional.empty(), Optional.of(params)); + ConnectorTimeTravelSpec spec = f.captureSpec(); + Assertions.assertEquals(ConnectorTimeTravelSpec.Kind.TAG, spec.getKind()); + Assertions.assertEquals("t1", spec.getStringValue()); + } + + @Test + public void testScanParamsBranchDispatchesBranchFromListParams() { + Fixture f = Fixture.timeTravel(); + TableScanParams params = new TableScanParams(TableScanParams.BRANCH, + Collections.emptyMap(), Collections.singletonList("b1")); + f.table.loadSnapshot(Optional.empty(), Optional.of(params)); + ConnectorTimeTravelSpec spec = f.captureSpec(); + // MUTATION: ignoring the listParams extraction path makes this red. + Assertions.assertEquals(ConnectorTimeTravelSpec.Kind.BRANCH, spec.getKind()); + Assertions.assertEquals("b1", spec.getStringValue()); + } + + @Test + public void testScanParamsIncrementalDispatchesIncrementalWithParams() { + Fixture f = Fixture.timeTravel(); + Map incr = new HashMap<>(); + incr.put("startSnapshotId", "1"); + incr.put("endSnapshotId", "5"); + TableScanParams params = new TableScanParams(TableScanParams.INCREMENTAL_READ, + incr, Collections.emptyList()); + f.table.loadSnapshot(Optional.empty(), Optional.of(params)); + ConnectorTimeTravelSpec spec = f.captureSpec(); + Assertions.assertEquals(ConnectorTimeTravelSpec.Kind.INCREMENTAL, spec.getKind()); + // MUTATION: dropping the params (or passing list/empty) makes this red — the connector needs + // the raw window arguments to validate and interpret the incremental read. + Assertions.assertEquals(incr, spec.getIncrementalParams()); + } + + @Test + public void testIncrementalPinListsLatestPartitionsAndUsesLatestSchema() { + // RD-2 (B5b-4): @incr is NOT a point-in-time pin. Legacy PaimonExternalTable.getPaimonSnapshotCacheValue + // falls through (neither tag/branch nor FOR VERSION/TIME AS OF) to getLatestSnapshotCacheValue — the + // LATEST partition view + LATEST schema — and applies the incremental window at scan time. The bridge + // must mirror that: POPULATE the partition maps (unlike snapshot/tag/timestamp/branch, which stay + // EMPTY) and use the LATEST schema (pinnedSchema == null). + Fixture f = Fixture.timeTravel(); + Map incr = new HashMap<>(); + incr.put("startSnapshotId", "1"); + incr.put("endSnapshotId", "5"); + TableScanParams params = new TableScanParams(TableScanParams.INCREMENTAL_READ, + incr, Collections.emptyList()); + + PluginDrivenMvccSnapshot pin = (PluginDrivenMvccSnapshot) f.table.loadSnapshot( + Optional.empty(), Optional.of(params)); + + // The pin carries the connector-resolved snapshot (which holds the incremental-between scan options + // threaded onto the handle at scan time via applySnapshot). + Assertions.assertSame(f.resolvedSnapshot, pin.getConnectorSnapshot()); + + // MUTATION: routing @incr through the EMPTY-map time-travel path (like snapshot/tag) leaves these + // empty -> red. @incr must list the LATEST partitions (the two fixture partitions). + Assertions.assertEquals(2, pin.getNameToPartitionItem().size(), + "@incr must list the LATEST partitions (parity legacy getLatestSnapshotCacheValue)"); + Assertions.assertEquals(TS_2024_01_01, pin.getNameToLastModifiedMillis().get("dt=2024-01-01")); + Assertions.assertEquals(TS_2024_02_02, pin.getNameToLastModifiedMillis().get("dt=2024-02-02")); + Assertions.assertFalse(pin.isPartitionInvalid(), + "a fully-built latest partition set must not be flagged invalid"); + Mockito.verify(f.metadata).listPartitions(Mockito.any(), Mockito.any(), Mockito.any()); + + // @incr uses the LATEST schema, NOT an at-snapshot schema: pinnedSchema must be null so + // getSchemaCacheValue() falls back to latest. MUTATION: resolving a schema-at-snapshot for @incr + // (the snapshot/tag/branch path) sets a non-null pinnedSchema and invokes applySnapshot/getTableSchema + // -> these go red. + Assertions.assertNull(pin.getPinnedSchema(), + "@incr reads the LATEST schema; pinnedSchema must be null"); + Mockito.verify(f.metadata, Mockito.never()).getTableSchema(Mockito.any(), Mockito.any(), + Mockito.any(ConnectorMvccSnapshot.class)); + Mockito.verify(f.metadata, Mockito.never()).applySnapshot(Mockito.any(), Mockito.any(), + Mockito.any()); + } + + @Test + public void testExtractBranchOrTagNameErrors() { + Fixture f = Fixture.timeTravel(); + // Non-empty mapParams missing the 'name' key. + TableScanParams missingName = new TableScanParams(TableScanParams.TAG, + Collections.singletonMap("other", "x"), Collections.emptyList()); + IllegalArgumentException e1 = Assertions.assertThrows(IllegalArgumentException.class, + () -> f.table.loadSnapshot(Optional.empty(), Optional.of(missingName))); + Assertions.assertEquals("must contain key 'name' in params", e1.getMessage()); + + // Empty mapParams AND empty listParams. + TableScanParams empty = new TableScanParams(TableScanParams.TAG, + Collections.emptyMap(), Collections.emptyList()); + IllegalArgumentException e2 = Assertions.assertThrows(IllegalArgumentException.class, + () -> f.table.loadSnapshot(Optional.empty(), Optional.of(empty))); + Assertions.assertEquals("must contain a branch/tag name in params", e2.getMessage()); + } + + @Test + public void testMutualExclusionBothPresentThrows() { + Fixture f = Fixture.timeTravel(); + RuntimeException e = Assertions.assertThrows(RuntimeException.class, + () -> f.table.loadSnapshot(Optional.of(TableSnapshot.versionOf("1")), + Optional.of(new TableScanParams(TableScanParams.TAG, + Collections.singletonMap(TableScanParams.PARAMS_NAME, "t1"), + Collections.emptyList())))); + // MUTATION: silently choosing one over the other makes this red. + Assertions.assertEquals("Can not specify scan params and table snapshot at same time.", + e.getMessage()); + } + + // ==================== loadSnapshot: not-found translation ==================== + + @Test + public void testNotFoundTranslationSnapshotId() { + assertNotFound(TableSnapshot.versionOf("999"), Optional.empty(), + "can't find snapshot by id: 999"); + } + + @Test + public void testNotFoundTranslationVersionRef() { + // Non-numeric FOR VERSION AS OF (VERSION_REF) renders "can't find snapshot by tag" — the + // source-agnostic wording must not claim a branch lookup a tag-only source (paimon) never did, and + // "no such tag" is never false. Byte-identical to legacy paimon (paimon_time_travel.groovy pins it). + // MUTATION: a default/other-kind message, or "tag or branch" (which breaks paimon parity), makes + // this red. + assertNotFound(TableSnapshot.versionOf("no_such_ref"), Optional.empty(), + "can't find snapshot by tag: no_such_ref"); + } + + @Test + public void testNotFoundTranslationScanParamTag() { + // @tag('x') (explicit scan param, Kind.TAG) -> "can't find snapshot by tag" — covers the scan-param + // tag path (the FOR VERSION path above is Kind.VERSION_REF; both share the TAG wording by design). + TableScanParams params = new TableScanParams(TableScanParams.TAG, + Collections.singletonMap(TableScanParams.PARAMS_NAME, "no_such_tag"), Collections.emptyList()); + assertNotFound(null, Optional.of(params), "can't find snapshot by tag: no_such_tag"); + } + + @Test + public void testNotFoundTranslationBranch() { + TableScanParams params = new TableScanParams(TableScanParams.BRANCH, + Collections.emptyMap(), Collections.singletonList("no_such_branch")); + assertNotFound(null, Optional.of(params), "can't find branch: no_such_branch"); + } + + @Test + public void testNotFoundTranslationTimestamp() { + // The TIMESTAMP branch of notFoundMessage carries a DOCUMENTED intentional divergence from + // legacy's detailed "...the earliest snapshot's timestamp is [...]" text (the connector owns + // the parsed millis + earliest snapshot, which fe-core cannot see). Pin its exact text. + // MUTATION: relabeling the TIMESTAMP case to another kind's text (or the default) makes this red. + assertNotFound(TableSnapshot.timeOf("2024-01-01 00:00:00"), Optional.empty(), + "can't find snapshot earlier than or equal to time: 2024-01-01 00:00:00"); + } + + private void assertNotFound(TableSnapshot ts, Optional sp, String expectedMsg) { + Fixture f = Fixture.timeTravel(); + // Connector resolves the spec to "not found". + Mockito.when(f.metadata.resolveTimeTravel(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(Optional.empty()); + Optional tsOpt = ts == null ? Optional.empty() : Optional.of(ts); + RuntimeException e = Assertions.assertThrows(RuntimeException.class, + () -> f.table.loadSnapshot(tsOpt, sp)); + // MUTATION: a generic / wrong-kind message makes this red — the user error must name the + // exact missing target. + Assertions.assertEquals(expectedMsg, e.getMessage()); + } + + // ==================== loadSnapshot: successful time-travel pin ==================== + + @Test + public void testSuccessfulTimeTravelPinsSnapshotAndAtSnapshotSchemaNoPartitions() { + Fixture f = Fixture.timeTravel(); + PluginDrivenMvccSnapshot pin = (PluginDrivenMvccSnapshot) f.table.loadSnapshot( + Optional.of(TableSnapshot.versionOf("7")), Optional.empty()); + + // The returned pin carries the connector-resolved snapshot. + Assertions.assertSame(f.resolvedSnapshot, pin.getConnectorSnapshot()); + Assertions.assertEquals(Fixture.TT_SCHEMA_ID, pin.getSchemaId()); + // MUTATION: listing partitions for time-travel makes these maps non-empty (red) and the + // verify(never) below catches the listPartitions call. + Assertions.assertTrue(pin.getNameToPartitionItem().isEmpty(), + "time-travel reads must NOT list partitions"); + Assertions.assertTrue(pin.getNameToLastModifiedMillis().isEmpty(), + "time-travel reads must NOT list partitions"); + Mockito.verify(f.metadata, Mockito.never()) + .listPartitions(Mockito.any(), Mockito.any(), Mockito.any()); + + // The pinned schema must be the AT-SNAPSHOT schema (column "v1"), NOT the latest fixture + // schema (column "dt"). MUTATION: pinning the latest schema instead of the at-snapshot one + // makes this red. + PluginDrivenSchemaCacheValue pinned = (PluginDrivenSchemaCacheValue) pin.getPinnedSchema(); + Assertions.assertNotNull(pinned); + Assertions.assertEquals(1, pinned.getSchema().size()); + Assertions.assertEquals("v1", pinned.getSchema().get(0).getName(), + "the pinned schema must reflect getTableSchema(..., snapshot), not the latest schema"); + } + + @Test + public void testBranchAppliesSnapshotBeforeResolvingSchema() { + Fixture f = Fixture.timeTravel(); + TableScanParams params = new TableScanParams(TableScanParams.BRANCH, + Collections.emptyMap(), Collections.singletonList("b1")); + f.table.loadSnapshot(Optional.empty(), Optional.of(params)); + + // applySnapshot was invoked, and getTableSchema(...,snapshot) was called with the handle + // RETURNED by applySnapshot (the branch-aware handle), not the base handle. MUTATION: calling + // getTableSchema with the base handle resolves the branch schemaId against the base table's + // schemaManager = wrong schema, and makes this red. + Mockito.verify(f.metadata).applySnapshot(Mockito.any(), Mockito.eq(f.handle), + Mockito.eq(f.resolvedSnapshot)); + Mockito.verify(f.metadata).getTableSchema(Mockito.any(), Mockito.eq(f.pinnedHandle), + Mockito.eq(f.resolvedSnapshot)); + // Make the apply-BEFORE-getTableSchema ordering explicit (not just implied by data-flow): + // applySnapshot must thread the pin onto the handle FIRST, so the branch-aware pinnedHandle is + // what getTableSchema resolves the schema against. MUTATION: resolving the schema before/without + // applySnapshot (or swapping the order) makes this red. + InOrder ord = Mockito.inOrder(f.metadata); + ord.verify(f.metadata).applySnapshot(Mockito.any(), Mockito.eq(f.handle), + Mockito.eq(f.resolvedSnapshot)); + ord.verify(f.metadata).getTableSchema(Mockito.any(), Mockito.eq(f.pinnedHandle), + Mockito.eq(f.resolvedSnapshot)); + } + + // ==================== getSchemaCacheValue: schema-at-snapshot override ==================== + + @Test + public void testGetSchemaCacheValueReturnsPinnedSchemaWhenContextPinned() { + Fixture f = Fixture.timeTravel(); + PluginDrivenSchemaCacheValue pinnedSchema = new PluginDrivenSchemaCacheValue( + Collections.singletonList(new Column("v1", Type.INT)), + Collections.emptyList(), Collections.emptyList()); + PluginDrivenMvccSnapshot pin = new PluginDrivenMvccSnapshot(f.resolvedSnapshot, + Collections.emptyMap(), Collections.emptyMap(), pinnedSchema); + + withContextSnapshot(f.table, pin, () -> { + Optional got = f.table.getSchemaCacheValue(); + // MUTATION: ignoring the context pin (returning latest) makes this red. + Assertions.assertTrue(got.isPresent()); + Assertions.assertSame(pinnedSchema, got.get(), + "a context pin with a pinnedSchema must yield the schema AS OF the snapshot"); + }); + } + + @Test + public void testGetSchemaCacheValueBindsOwnVersionWhenTwoVersionsPinned() { + Fixture f = Fixture.timeTravel(); + PluginDrivenSchemaCacheValue schemaV5 = new PluginDrivenSchemaCacheValue( + Collections.singletonList(new Column("c1", Type.INT)), + Collections.emptyList(), Collections.emptyList()); + PluginDrivenSchemaCacheValue schemaV6 = new PluginDrivenSchemaCacheValue( + Arrays.asList(new Column("c1", Type.INT), new Column("c2", Type.INT)), + Collections.emptyList(), Collections.emptyList()); + PluginDrivenMvccSnapshot pinV5 = new PluginDrivenMvccSnapshot(f.resolvedSnapshot, + Collections.emptyMap(), Collections.emptyMap(), schemaV5); + PluginDrivenMvccSnapshot pinV6 = new PluginDrivenMvccSnapshot(f.resolvedSnapshot, + Collections.emptyMap(), Collections.emptyMap(), schemaV6); + + ConnectContext ctx = new ConnectContext(); + StatementContext stmtCtx = new StatementContext(ctx, null); + ctx.setStatementContext(stmtCtx); + ctx.setThreadLocalInfo(); + try { + // ONE table, TWO version selectors, NO bare reference: exactly the shape a self-join of two + // @tag/FOR-VERSION references produces, and the only shape where the version-BLIND lookup + // gives up (StatementContext.getSnapshot(TableIf): two non-default pins and no default). + stmtCtx.setSnapshot(new MvccTableInfo(f.table, "v:VERSION:5"), pinV5); + stmtCtx.setSnapshot(new MvccTableInfo(f.table, "v:VERSION:6"), pinV6); + + // Each reference must bind ITS OWN schema, resolved from the snapshot IT pinned. This is the + // whole point: a reference's schema may not depend on what OTHER references the statement has. + // MUTATION: dropping the snapshot-aware override (so this re-enters the ambient lookup) makes + // both of these red by returning f.latestCacheValue. + Assertions.assertSame(schemaV5, f.table.getSchemaCacheValue(Optional.of(pinV5)).orElse(null), + "a reference pinned at v5 must bind v5's schema regardless of what else is pinned"); + Assertions.assertSame(schemaV6, f.table.getSchemaCacheValue(Optional.of(pinV6)).orElse(null), + "a reference pinned at v6 must bind v6's schema regardless of what else is pinned"); + + // ...and the version-BLIND path still degrades to LATEST here. That degradation is WHY the + // per-reference overload exists: LATEST is a schema NO reference asked for, so handing it to + // the binding layer manufactures a schema skew the scan-time guard then reports on a column + // the query never referenced. Kept as an assertion (not a comment) so that if the blind + // fallback is ever changed, this test says so instead of silently agreeing. + Assertions.assertSame(f.latestCacheValue, f.table.getSchemaCacheValue().orElse(null), + "the version-blind lookup is ambiguous with two versions pinned and still yields LATEST"); + } finally { + ConnectContext.remove(); + } + } + + @Test + public void testGetSchemaCacheValueFallsBackToLatestWhenPinHasNullSchema() { + Fixture f = Fixture.timeTravel(); + // A B5a latest pin (pinnedSchema == null). + PluginDrivenMvccSnapshot pin = new PluginDrivenMvccSnapshot(f.resolvedSnapshot, + Collections.emptyMap(), Collections.emptyMap(), null); + + withContextSnapshot(f.table, pin, () -> { + Optional got = f.table.getSchemaCacheValue(); + // MUTATION: returning the (null) pinned schema instead of falling back to latest makes + // this red; a B5a latest pin must read the latest schema. + Assertions.assertTrue(got.isPresent()); + Assertions.assertSame(f.latestCacheValue, got.get(), + "a pin with a null pinnedSchema must fall back to the latest schema"); + }); + } + + @Test + public void testGetSchemaCacheValueFallsBackToLatestWhenNoPin() { + Fixture f = Fixture.timeTravel(); + // No ConnectContext at all -> no pin -> latest. + Optional got = f.table.getSchemaCacheValue(); + Assertions.assertTrue(got.isPresent()); + Assertions.assertSame(f.latestCacheValue, got.get(), + "with no context pin getSchemaCacheValue must return the latest schema"); + } + + // ==================== getNewestUpdateVersionOrTime: max, bypass pin ==================== + + @Test + public void testGetNewestUpdateVersionOrTimeMaxAndBypassesPin() throws AnalysisException { + Fixture f = Fixture.partitioned(); + // Pin a CONTEXT snapshot whose nameToLastModifiedMillis carries a max (Long.MAX_VALUE) that is + // strictly LARGER than the fresh LATEST listing's max (TS_2024_02_02). getNewestUpdateVersionOrTime + // takes no snapshot arg and must NOT read this pin: it calls materializeLatest() directly, + // re-listing live. + PluginDrivenMvccSnapshot contextPin = new PluginDrivenMvccSnapshot( + ConnectorMvccSnapshot.builder().snapshotId(PINNED_SNAPSHOT_ID).build(), + Collections.emptyMap(), + Collections.singletonMap("dt=2099-12-31", Long.MAX_VALUE)); + + long[] newest = new long[1]; + withContextSnapshot(f.table, contextPin, () -> { + newest[0] = f.table.getNewestUpdateVersionOrTime(); + }); + + // MUTATION: returning min instead of max makes this red. MUTATION: reading the CONTEXT pin + // instead of re-listing would return Long.MAX_VALUE (the pinned max), not the fresh-listing max + // — proving the pin is bypassed. + Assertions.assertEquals(TS_2024_02_02, newest[0], + "must return max(lastModifiedMillis) from a fresh LATEST listing, NOT the context pin's max"); + // MUTATION: reading a context pin instead of re-listing would skip this call (zero + // interactions), making the verify red. Proves the pin is bypassed. + Mockito.verify(f.metadata).listPartitions(Mockito.any(), Mockito.any(), Mockito.any()); + } + + @Test + public void testGetNewestUpdateVersionOrTimeAllUnknownReturnsZeroNotSentinel() { + // Every partition advertises UNKNOWN(-1) lastModifiedMillis (connector did not collect a + // modified time). Legacy used Paimon's lastFileCreationTime() which has no -1 sentinel and + // reduced to 0 when empty; the bridge must match that, not leak -1 into MTMV staleness. + Fixture f = Fixture.with(Arrays.asList( + cpi("dt=2024-01-01", ConnectorPartitionInfo.UNKNOWN), + cpi("dt=2024-02-02", ConnectorPartitionInfo.UNKNOWN))); + // MUTATION: without the `filter(v -> v >= 0)`, max() over {-1,-1} returns -1, not 0 -> red. + Assertions.assertEquals(0L, f.table.getNewestUpdateVersionOrTime(), + "an all-UNKNOWN table must reduce to the legacy 0, never the -1 sentinel"); + } + + @Test + public void testGetNewestUpdateVersionOrTimeIgnoresUnknownAmongReal() throws AnalysisException { + // A mix of a real modified time and an UNKNOWN(-1) sentinel: the sentinel must be ignored so + // the max is the REAL value, not -1 (and not skewed by -1 participating in the reduction). + Fixture f = Fixture.with(Arrays.asList( + cpi("dt=2024-01-01", ConnectorPartitionInfo.UNKNOWN), + cpi("dt=2024-02-02", TS_2024_02_02))); + // MUTATION: the real value already wins over -1 in a plain max(), so this is a weak guard on + // its own; the all-UNKNOWN==0 test above is the primary sentinel-leak catcher. + Assertions.assertEquals(TS_2024_02_02, f.table.getNewestUpdateVersionOrTime(), + "the UNKNOWN sentinel must be filtered, leaving the max of the REAL values"); + } + + // ==================== getNewestUpdateVersionOrTime: last-modified (hive) freshness ==================== + + private static final long TS_TABLE_FRESH = 1_888_000_000_000L; // distinct from the partition maxes above + + @Test + public void testGetNewestUpdateVersionLastModifiedUsesTableFreshness() { + // A last-modified connector (hive) lists partitions names-only (all lastModifiedMillis == -1), so the + // legacy max-over-partitions path would collapse to a CONSTANT 0 and an MV / SQL dictionary over a hive + // base table would never auto-refresh. The pin flags last-modified freshness, so + // getNewestUpdateVersionOrTime must return the connector's whole-table freshness millis instead. + Fixture f = Fixture.partitioned(); + flagPinLastModified(f); + Mockito.when(f.metadata.getTableFreshness(Mockito.any(), Mockito.any())) + .thenReturn(Optional.of(new ConnectorTableFreshness("dt=2024-02-02", TS_TABLE_FRESH))); + + // MUTATION: taking the max-over-partitions path (ignoring the pin flag) would return the partition max + // TS_2024_02_02, not the freshness value TS_TABLE_FRESH -> red (the values are deliberately distinct). + Assertions.assertEquals(TS_TABLE_FRESH, f.table.getNewestUpdateVersionOrTime(), + "a last-modified connector must surface the whole-table freshness millis, not a constant 0"); + } + + @Test + public void testGetNewestUpdateVersionLastModifiedEmptyFreshnessReturnsZero() { + // A dropped catalog/table, or a genuinely empty partition set, makes getTableFreshness empty; fe-core must + // degrade to 0 (parity legacy getNewestUpdateVersionOrTime), NOT throw or leak a sentinel. + Fixture f = Fixture.partitioned(); + flagPinLastModified(f); + Mockito.when(f.metadata.getTableFreshness(Mockito.any(), Mockito.any())) + .thenReturn(Optional.empty()); + // MUTATION: mapping an empty freshness to anything but 0 (e.g. throwing, or leaking -1) makes this red. + Assertions.assertEquals(0L, f.table.getNewestUpdateVersionOrTime(), + "an empty whole-table freshness (dropped/empty) must reduce to the legacy 0"); + } + + @Test + public void testGetNewestUpdateVersionSnapshotIdConnectorSkipsFreshnessProbe() { + // Byte/cost-neutrality guard: a snapshot-id connector (paimon/iceberg) leaves the pin flag false, so + // getNewestUpdateVersionOrTime must take the EXACT pre-change max-over-partitions path and NEVER fire the + // freshness probe (an added metadata round-trip on the live dictionary poll). + Fixture f = Fixture.partitioned(); // build() pin has lastModifiedFreshness=false + Assertions.assertEquals(TS_2024_02_02, f.table.getNewestUpdateVersionOrTime(), + "a snapshot-id connector must keep the max-partition-modify path"); + // MUTATION: dropping the pin-flag gate (probing unconditionally) makes this verify red. + Mockito.verify(f.metadata, Mockito.never()).getTableFreshness(Mockito.any(), Mockito.any()); + } + + @Test + public void testIsPartitionColumnAllowNullTrue() { + Assertions.assertTrue(Fixture.partitioned().table.isPartitionColumnAllowNull()); + } + + // ==================== connector range-view path (e.g. iceberg) ==================== + + private static final long FRESH_555 = 555L; + private static final long FRESH_777 = 777L; + private static final long NEWEST_UPDATE_TIME = 1_900_000_000_000L; + + private static ConnectorMvccPartition rangePart(String name, String low, String high, long freshness) { + return new ConnectorMvccPartition(name, Collections.singletonList(low), + high == null ? Collections.emptyList() : Collections.singletonList(high), freshness); + } + + private static ConnectorMvccPartitionView rangeView(ConnectorMvccPartition... parts) { + return new ConnectorMvccPartitionView(ConnectorMvccPartitionView.Style.RANGE, + ConnectorMvccPartitionView.Freshness.SNAPSHOT_ID, Arrays.asList(parts), NEWEST_UPDATE_TIME); + } + + @Test + public void testRangeViewBuildsRangePartitionTypeAndItems() { + Fixture f = Fixture.rangeView(rangeView( + rangePart("p20240101", "2024-01-01", "2024-01-02", FRESH_555))); + + // MUTATION: deriving LIST/UNPARTITIONED from getPartitionColumns().size() (ignoring the connector's + // RANGE style) makes this red — a roll-up MTMV with date_trunc requires RANGE or it throws. + Assertions.assertEquals(PartitionType.RANGE, f.table.getPartitionType(Optional.empty()), + "the connector's RANGE style must drive getPartitionType"); + + Map items = f.table.getNameToPartitionItems(Optional.empty()); + Assertions.assertEquals(1, items.size()); + PartitionItem item = items.get("p20240101"); + Assertions.assertTrue(item instanceof RangePartitionItem, "expected a RangePartitionItem"); + com.google.common.collect.Range range = ((RangePartitionItem) item).getItems(); + // [2024-01-01, 2024-01-02) built from the connector's pre-rendered closed/open bounds. + Assertions.assertEquals("2024-01-01", range.lowerEndpoint().getKeys().get(0).getStringValue()); + Assertions.assertEquals("2024-01-02", range.upperEndpoint().getKeys().get(0).getStringValue()); + } + + @Test + public void testRangeViewNullMinPartitionDerivesSuccessorUpperBound() { + // An EMPTY upper bound denotes the NULL-min partition: fe-core derives the exclusive upper as the + // column-type successor of the lower key (the connector cannot — it has no Doris Column). Parity with + // master IcebergUtils.getPartitionRange's nullLowKey.successor(). + Fixture f = Fixture.rangeView(rangeView( + rangePart("pnull", "0000-01-01", null, FRESH_777))); + + Map items = f.table.getNameToPartitionItems(Optional.empty()); + com.google.common.collect.Range range = + ((RangePartitionItem) items.get("pnull")).getItems(); + Assertions.assertEquals("0000-01-01", range.lowerEndpoint().getKeys().get(0).getStringValue()); + // MUTATION: building the upper from the (empty) tuple instead of lower.successor() throws or yields the + // wrong bound -> red. DATEV2 successor of 0000-01-01 is 0000-01-02. + Assertions.assertEquals("0000-01-02", range.upperEndpoint().getKeys().get(0).getStringValue(), + "the NULL-min partition's exclusive upper must be lowerKey.successor()"); + } + + @Test + public void testRangeViewPartitionSnapshotIsSnapshotId() throws AnalysisException { + Fixture f = Fixture.rangeView(rangeView( + rangePart("p20240101", "2024-01-01", "2024-01-02", FRESH_555))); + + // MUTATION: wrapping the freshness value in MTMVTimestampSnapshot (ignoring the SNAPSHOT_ID freshness + // kind) makes this ClassCastException/red — MTMV change-detection must compare snapshot ids, not millis. + MTMVSnapshotIdSnapshot snap = (MTMVSnapshotIdSnapshot) f.table.getPartitionSnapshot( + "p20240101", null, Optional.empty()); + Assertions.assertEquals(FRESH_555, snap.getSnapshotVersion(), + "a snapshot-id-freshness view must pin the per-partition snapshot id"); + + // Table snapshot stays the connector pin id. + Assertions.assertEquals(PINNED_SNAPSHOT_ID, + ((MTMVSnapshotIdSnapshot) f.table.getTableSnapshot(Optional.empty())).getSnapshotVersion()); + + // An unknown partition still throws (parity with the legacy path). + Assertions.assertThrows(AnalysisException.class, + () -> f.table.getPartitionSnapshot("missing", null, Optional.empty())); + } + + @Test + public void testRangeViewNewestUpdateTimeUsesMonotonicMarkerNotSnapshotId() { + // The dictionary auto-refresh path needs a MONOTONIC marker; the per-partition snapshot ids are not + // monotonic. getNewestUpdateVersionOrTime must return the view's newest-update-time, NOT a max over the + // snapshot-id freshness values. + Fixture f = Fixture.rangeView(rangeView( + rangePart("p20240101", "2024-01-01", "2024-01-02", FRESH_555), + rangePart("p20240202", "2024-02-02", "2024-02-03", FRESH_777))); + + // MUTATION: returning max(freshness)=777 (the legacy max-over-the-map path) instead of the view's + // newest-update-time makes this red — proving the view path reads newestUpdateTimeMillis. + Assertions.assertEquals(NEWEST_UPDATE_TIME, f.table.getNewestUpdateVersionOrTime(), + "the range-view path must answer the dictionary with the monotonic newest-update-time"); + } + + @Test + public void testRangeViewValidRelatedTableMirrorsStyle() { + // RANGE style => valid related table; UNPARTITIONED style (the connector's eligibility gate failed) => + // invalid, so MTMVTask stops the refresh loud. MUTATION: always returning true (the interface default) + // makes the UNPARTITIONED assertion red. + Fixture valid = Fixture.rangeView(rangeView( + rangePart("p20240101", "2024-01-01", "2024-01-02", FRESH_555))); + Assertions.assertTrue(valid.table.isValidRelatedTable(), + "a RANGE range-view table is a valid related table"); + + Fixture invalid = Fixture.rangeView(ConnectorMvccPartitionView.unpartitioned()); + Assertions.assertEquals(PartitionType.UNPARTITIONED, + invalid.table.getPartitionType(Optional.empty())); + Assertions.assertFalse(invalid.table.isValidRelatedTable(), + "an UNPARTITIONED range-view table is NOT a valid related table (stops MTMV refresh)"); + } + + @Test + public void testRangeViewAppliesSnapshotBeforeQueryingViewOnPinnedHandle() { + // Snapshot-consistency: the query-begin pin must be threaded onto the handle (applySnapshot) BEFORE + // getMvccPartitionView, and the view must be enumerated from that pinned handle — so the MTMV partition + // set/freshness stays consistent with the data-scan pin. MUTATION: querying the view on the BASE handle + // (or before applySnapshot) makes the InOrder / pinnedHandle verify red. + Fixture f = Fixture.rangeView(rangeView( + rangePart("p20240101", "2024-01-01", "2024-01-02", FRESH_555))); + f.table.getNameToPartitionItems(Optional.empty()); + + InOrder ord = Mockito.inOrder(f.metadata); + ord.verify(f.metadata).applySnapshot(Mockito.eq(f.session), Mockito.eq(f.handle), Mockito.any()); + ord.verify(f.metadata).getMvccPartitionView(f.session, f.pinnedHandle); + // The legacy listPartitions path must NOT run when a range view is present. + Mockito.verify(f.metadata, Mockito.never()) + .listPartitions(Mockito.any(), Mockito.any(), Mockito.any()); + } + + @Test + public void testAbsentRangeViewKeepsLegacyListPath() throws AnalysisException { + // Paimon-parity guard: a connector WITHOUT a range view (getMvccPartitionView empty) keeps the legacy + // listPartitions/LIST/timestamp path byte-unchanged. MUTATION: defaulting to a RANGE/empty view when the + // connector returns empty would flip this to RANGE and skip listPartitions -> red. + Fixture f = Fixture.partitioned(); // does NOT stub getMvccPartitionView -> Mockito returns empty + // Materialize ONCE (the single allowed round-trip), then read both accessors off that pin so the + // verify(...) counts below are unambiguous. + Optional pin = + Optional.of(f.table.loadSnapshot(Optional.empty(), Optional.empty())); + Assertions.assertEquals(PartitionType.LIST, f.table.getPartitionType(pin), + "an absent range view must keep the legacy LIST path"); + MTMVTimestampSnapshot ts = (MTMVTimestampSnapshot) f.table.getPartitionSnapshot( + "dt=2024-01-01", null, pin); + Assertions.assertEquals(TS_2024_01_01, ts.getSnapshotVersion(), + "the legacy path must keep timestamp freshness"); + // The connector WAS consulted for a range view (and returned empty), then the legacy list path ran. + Mockito.verify(f.metadata).getMvccPartitionView(Mockito.any(), Mockito.any()); + Mockito.verify(f.metadata).listPartitions(Mockito.any(), Mockito.any(), Mockito.any()); + } + + // ==================== fixtures / helpers ==================== + + private static ConnectorPartitionInfo cpi(String name, long lastModifiedMillis) { + return new ConnectorPartitionInfo(name, Collections.emptyMap(), Collections.emptyMap(), + ConnectorPartitionInfo.UNKNOWN, ConnectorPartitionInfo.UNKNOWN, lastModifiedMillis, + ConnectorPartitionInfo.UNKNOWN, orderedValuesOf(name), Collections.emptyList()); + } + + /** + * Like {@link #cpi} but with the ordered values supplied EXPLICITLY rather than derived from the + * rendered name — needed for the spec-evolution shapes, where a row's value count legitimately + * differs from the current spec's field count (see + * {@code testZeroValuesFromUnpartitionedOriginDegradeToUnpartitioned}). + */ + private static ConnectorPartitionInfo cpiValues(String name, long lastModifiedMillis, + List orderedValues) { + return new ConnectorPartitionInfo(name, Collections.emptyMap(), Collections.emptyMap(), + ConnectorPartitionInfo.UNKNOWN, ConnectorPartitionInfo.UNKNOWN, lastModifiedMillis, + ConnectorPartitionInfo.UNKNOWN, orderedValues, Collections.emptyList()); + } + + /** Like {@link #cpi} but with connector-supplied per-value SQL-NULL flags (the opt-in path). */ + private static ConnectorPartitionInfo cpiNull(String name, long lastModifiedMillis, boolean... nullFlags) { + List flags = new ArrayList<>(nullFlags.length); + for (boolean b : nullFlags) { + flags.add(b); + } + return new ConnectorPartitionInfo(name, Collections.emptyMap(), Collections.emptyMap(), + ConnectorPartitionInfo.UNKNOWN, ConnectorPartitionInfo.UNKNOWN, lastModifiedMillis, + ConnectorPartitionInfo.UNKNOWN, orderedValuesOf(name), flags); + } + + /** + * One already-parsed value per name segment — what a real connector now supplies (mirrors the + * connector-side {@code HiveWriteUtils.toPartitionValues}). fe-core no longer parses the rendered + * name itself, so a fixture that supplies none is a mis-wired connector, not a valid input. + */ + private static List orderedValuesOf(String partitionName) { + List values = new ArrayList<>(); + for (String segment : partitionName.split("/", -1)) { + int eq = segment.indexOf('='); + values.add(eq < 0 ? segment : segment.substring(eq + 1)); + } + return values; + } + + /** + * Runs {@code body} with {@code snapshot} pinned for {@code table} in a thread-local + * {@link ConnectContext}'s {@link StatementContext}, then clears the thread-local. + */ + private static void withContextSnapshot(PluginDrivenMvccExternalTable table, + MvccSnapshot snapshot, Runnable body) { + ConnectContext ctx = new ConnectContext(); + StatementContext stmtCtx = new StatementContext(ctx, null); + ctx.setStatementContext(stmtCtx); + ctx.setThreadLocalInfo(); + try { + stmtCtx.setSnapshot(new MvccTableInfo(table), snapshot); + body.run(); + } finally { + ConnectContext.remove(); + } + } + + /** + * Wires a {@link PluginDrivenMvccExternalTable} over a mocked connector/metadata, stubbing the + * LATEST schema cache so {@code getPartitionColumns()} returns a single DATE column {@code dt}. + * The {@code timeTravel()} variant additionally stubs the time-travel SPI methods so + * {@code loadSnapshot} with an explicit spec resolves to a known snapshot + at-snapshot schema. + */ + private static final class Fixture { + static final long TT_SCHEMA_ID = 9L; + + final PluginDrivenMvccExternalTable table; + final ConnectorMetadata metadata; + final ConnectorTableHandle handle; + final ConnectorTableHandle pinnedHandle; + final ConnectorSession session; + final PluginDrivenSchemaCacheValue latestCacheValue; + final ConnectorMvccSnapshot resolvedSnapshot; + + private Fixture(PluginDrivenMvccExternalTable table, ConnectorMetadata metadata, + ConnectorTableHandle handle, ConnectorTableHandle pinnedHandle, ConnectorSession session, + PluginDrivenSchemaCacheValue latestCacheValue, ConnectorMvccSnapshot resolvedSnapshot) { + this.table = table; + this.metadata = metadata; + this.handle = handle; + this.pinnedHandle = pinnedHandle; + this.session = session; + this.latestCacheValue = latestCacheValue; + this.resolvedSnapshot = resolvedSnapshot; + } + + /** Captures the {@link ConnectorTimeTravelSpec} passed to {@code resolveTimeTravel}. */ + ConnectorTimeTravelSpec captureSpec() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(ConnectorTimeTravelSpec.class); + Mockito.verify(metadata).resolveTimeTravel(Mockito.any(), Mockito.any(), captor.capture()); + return captor.getValue(); + } + + static Fixture partitioned() { + return with(Arrays.asList( + cpi("dt=2024-01-01", TS_2024_01_01), + cpi("dt=2024-02-02", TS_2024_02_02))); + } + + static Fixture with(List partitions) { + return build(partitions, false); + } + + static Fixture with(List partitions, Type partitionColType) { + return build(partitions, false, partitionColType); + } + + /** Adds time-travel SPI stubs on top of the base fixture. */ + static Fixture timeTravel() { + return build(Arrays.asList( + cpi("dt=2024-01-01", TS_2024_01_01), + cpi("dt=2024-02-02", TS_2024_02_02)), true); + } + + /** + * Base fixture but with {@code getTableHandle(...)} re-stubbed to {@link Optional#empty()}, + * exercising the no-handle degrade (materializeLatest empty-pin) and the time-travel no-handle + * guard (loadSnapshot throwing). + */ + static Fixture noHandle() { + Fixture f = partitioned(); + Mockito.when(f.metadata.getTableHandle(f.session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.empty()); + return f; + } + + /** + * Base fixture wired for the connector range-view path: {@code applySnapshot} threads the query-begin + * pin onto the handle (returning the branch-aware {@code pinnedHandle}) and {@code getMvccPartitionView} + * returns {@code view} from THAT pinned handle, so a test can assert the apply-before-view ordering and + * the snapshot-consistent enumeration. The partition column is the default DATEV2 {@code dt}. + */ + static Fixture rangeView(ConnectorMvccPartitionView view) { + Fixture f = partitioned(); + Mockito.when(f.metadata.applySnapshot(Mockito.eq(f.session), Mockito.eq(f.handle), Mockito.any())) + .thenReturn(f.pinnedHandle); + Mockito.when(f.metadata.getMvccPartitionView(f.session, f.pinnedHandle)) + .thenReturn(Optional.of(view)); + return f; + } + + private static Fixture build(List partitions, boolean timeTravel) { + return build(partitions, timeTravel, Type.DATEV2); + } + + private static Fixture build(List partitions, boolean timeTravel, + Type partitionColType) { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + ConnectorTableHandle pinnedHandle = Mockito.mock(ConnectorTableHandle.class); + TestablePluginCatalog catalog = new TestablePluginCatalog(metadata, session); + ExternalDatabase db = mockDb("REMOTE_DB"); + + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.of(handle)); + Mockito.when(metadata.beginQuerySnapshot(session, handle)) + .thenReturn(Optional.of( + ConnectorMvccSnapshot.builder().snapshotId(PINNED_SNAPSHOT_ID).build())); + Mockito.when(metadata.listPartitions(Mockito.eq(session), Mockito.eq(handle), Mockito.any())) + .thenReturn(partitions); + // A Mockito mock does NOT run interface default methods (returns null for these), so mimic the SPI + // default here: a snapshot-id connector (paimon/iceberg) surfaces no last-modified freshness. The + // last-modified tests below re-stub these to a present value. + Mockito.when(metadata.getTableFreshness(Mockito.any(), Mockito.any())) + .thenReturn(Optional.empty()); + Mockito.when(metadata.getPartitionFreshnessMillis(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(OptionalLong.empty()); + + // Single partition column "dt" (DATE by default; VARCHAR variant exercises the genuine-null + // string-key path) — the LATEST schema. + List schema = Collections.singletonList(new Column("dt", partitionColType)); + PluginDrivenSchemaCacheValue latestCacheValue = new PluginDrivenSchemaCacheValue( + schema, schema, Collections.singletonList("dt")); + + ConnectorMvccSnapshot resolvedSnapshot = ConnectorMvccSnapshot.builder() + .snapshotId(7L).schemaId(TT_SCHEMA_ID).build(); + + if (timeTravel) { + // resolveTimeTravel succeeds; applySnapshot returns the branch-aware pinnedHandle; + // getTableSchema(..,snapshot) returns the AT-SNAPSHOT schema (column "v1"), distinct + // from the latest schema (column "dt"). fromRemoteColumnName is identity. + Mockito.when(metadata.resolveTimeTravel(Mockito.eq(session), Mockito.eq(handle), + Mockito.any())).thenReturn(Optional.of(resolvedSnapshot)); + Mockito.when(metadata.applySnapshot(session, handle, resolvedSnapshot)) + .thenReturn(pinnedHandle); + ConnectorTableSchema atSchema = new ConnectorTableSchema("REMOTE_TBL", + Collections.singletonList(new ConnectorColumn("v1", ConnectorType.of("INT"), + "", true, null)), + "", Collections.emptyMap()); + Mockito.when(metadata.getTableSchema(Mockito.eq(session), Mockito.any(), + Mockito.any(ConnectorMvccSnapshot.class))).thenReturn(atSchema); + Mockito.when(metadata.fromRemoteColumnName(Mockito.eq(session), Mockito.any(), + Mockito.any(), Mockito.anyString())) + .thenAnswer(inv -> inv.getArgument(3, String.class)); + } + + PluginDrivenMvccExternalTable table = + new PluginDrivenMvccExternalTable(1L, "tbl", "REMOTE_TBL", catalog, db) { + @Override + protected synchronized void makeSureInitialized() { + // no-op: skip Env-backed catalog/db init + } + + @Override + protected Optional getLatestSchemaCacheValue() { + // Bypass the live Env-backed schema cache; route the LATEST seam to the + // canned value so the real getSchemaCacheValue() override is exercised. + return Optional.of(latestCacheValue); + } + }; + return new Fixture(table, metadata, handle, pinnedHandle, session, latestCacheValue, + resolvedSnapshot); + } + } + + @SuppressWarnings("unchecked") + private static ExternalDatabase mockDb(String remoteName) { + ExternalDatabase db = Mockito.mock(ExternalDatabase.class); + Mockito.when(db.getRemoteName()).thenReturn(remoteName); + // Needed so MvccTableInfo(table) -> db.getFullName()/db.getCatalog().getName() resolve in the + // context-pin tests. + Mockito.when(db.getFullName()).thenReturn("test_db"); + ExternalCatalog ctl = Mockito.mock(ExternalCatalog.class); + Mockito.when(ctl.getName()).thenReturn("test_catalog"); + Mockito.when(db.getCatalog()).thenReturn(ctl); + return db; + } + + /** + * Minimal catalog returning a fixed connector/session without standing up the Doris + * environment (mirrors PluginDrivenExternalTablePartitionTest.TestablePluginCatalog). + */ + private static final class TestablePluginCatalog extends PluginDrivenExternalCatalog { + private final Connector connector; + private final ConnectorSession session; + + TestablePluginCatalog(ConnectorMetadata metadata, ConnectorSession session) { + this(mockConnector(metadata, session), session); + } + + private TestablePluginCatalog(Connector connector, ConnectorSession session) { + super(1L, "test-catalog", null, makeProps(), "", connector); + this.connector = connector; + this.session = session; + } + + private static Connector mockConnector(ConnectorMetadata metadata, ConnectorSession session) { + Connector c = Mockito.mock(Connector.class); + Mockito.when(c.getMetadata(session)).thenReturn(metadata); + return c; + } + + @Override + public Connector getConnector() { + return connector; + } + + @Override + public ConnectorSession buildConnectorSession() { + return session; + } + + @Override + public ConnectorSession buildCrossStatementSession() { + return buildConnectorSession(); + } + + @Override + protected List listDatabaseNames() { + return Collections.emptyList(); + } + + @Override + protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { + return Collections.emptyList(); + } + + @Override + public boolean tableExist(SessionContext ctx, String dbName, String tblName) { + return false; + } + + private static Map makeProps() { + Map props = new HashMap<>(); + props.put("type", "mvcc-test"); + return props; + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonExternalMetaCacheTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonExternalMetaCacheTest.java deleted file mode 100644 index dc33354f6f93fd..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonExternalMetaCacheTest.java +++ /dev/null @@ -1,123 +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.datasource.paimon; - -import org.apache.doris.datasource.ExternalCatalog; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.SchemaCacheValue; -import org.apache.doris.datasource.metacache.MetaCacheEntryStats; - -import org.junit.Assert; -import org.junit.Test; - -import java.util.Collections; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -public class PaimonExternalMetaCacheTest { - - @Test - public void testInvalidateTablePrecise() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - PaimonExternalMetaCache cache = new PaimonExternalMetaCache(executor); - long catalogId = 1L; - cache.initCatalog(catalogId, Collections.emptyMap()); - NameMapping t1 = new NameMapping(catalogId, "db1", "tbl1", "rdb1", "rtbl1"); - NameMapping t2 = new NameMapping(catalogId, "db1", "tbl2", "rdb1", "rtbl2"); - - org.apache.doris.datasource.metacache.MetaCacheEntry tableEntry = - cache.entry(catalogId, PaimonExternalMetaCache.ENTRY_TABLE, - NameMapping.class, PaimonTableCacheValue.class); - tableEntry.put(t1, new PaimonTableCacheValue(null, - () -> new PaimonSnapshotCacheValue(PaimonPartitionInfo.EMPTY, new PaimonSnapshot(1L, 1L, null)))); - tableEntry.put(t2, new PaimonTableCacheValue(null, - () -> new PaimonSnapshotCacheValue(PaimonPartitionInfo.EMPTY, new PaimonSnapshot(2L, 2L, null)))); - - cache.invalidateTable(catalogId, "db1", "tbl1"); - - Assert.assertNull(tableEntry.getIfPresent(t1)); - Assert.assertNotNull(tableEntry.getIfPresent(t2)); - } finally { - executor.shutdownNow(); - } - } - - @Test - public void testInvalidateDbAndStats() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - PaimonExternalMetaCache cache = new PaimonExternalMetaCache(executor); - long catalogId = 1L; - cache.initCatalog(catalogId, Collections.emptyMap()); - NameMapping db1Table = new NameMapping(catalogId, "db1", "tbl1", "rdb1", "rtbl1"); - NameMapping db2Table = new NameMapping(catalogId, "db2", "tbl1", "rdb2", "rtbl1"); - - org.apache.doris.datasource.metacache.MetaCacheEntry tableEntry = - cache.entry(catalogId, PaimonExternalMetaCache.ENTRY_TABLE, - NameMapping.class, PaimonTableCacheValue.class); - tableEntry.put(db1Table, new PaimonTableCacheValue(null, - () -> new PaimonSnapshotCacheValue(PaimonPartitionInfo.EMPTY, new PaimonSnapshot(1L, 1L, null)))); - tableEntry.put(db2Table, new PaimonTableCacheValue(null, - () -> new PaimonSnapshotCacheValue(PaimonPartitionInfo.EMPTY, new PaimonSnapshot(2L, 2L, null)))); - - org.apache.doris.datasource.metacache.MetaCacheEntry schemaEntry = - cache.entry(catalogId, PaimonExternalMetaCache.ENTRY_SCHEMA, - PaimonSchemaCacheKey.class, SchemaCacheValue.class); - PaimonSchemaCacheKey db1Schema = new PaimonSchemaCacheKey(db1Table, 1L); - PaimonSchemaCacheKey db2Schema = new PaimonSchemaCacheKey(db2Table, 2L); - schemaEntry.put(db1Schema, new SchemaCacheValue(Collections.emptyList())); - schemaEntry.put(db2Schema, new SchemaCacheValue(Collections.emptyList())); - - cache.invalidateDb(catalogId, "db1"); - - Assert.assertNull(tableEntry.getIfPresent(db1Table)); - Assert.assertNotNull(tableEntry.getIfPresent(db2Table)); - Assert.assertNull(schemaEntry.getIfPresent(db1Schema)); - Assert.assertNotNull(schemaEntry.getIfPresent(db2Schema)); - - Map stats = cache.stats(catalogId); - Assert.assertTrue(stats.containsKey(PaimonExternalMetaCache.ENTRY_TABLE)); - Assert.assertTrue(stats.containsKey(PaimonExternalMetaCache.ENTRY_SCHEMA)); - } finally { - executor.shutdownNow(); - } - } - - @Test - public void testSchemaStatsWhenSchemaCacheDisabled() { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - PaimonExternalMetaCache cache = new PaimonExternalMetaCache(executor); - long catalogId = 1L; - Map properties = com.google.common.collect.Maps.newHashMap(); - properties.put(ExternalCatalog.SCHEMA_CACHE_TTL_SECOND, "0"); - cache.initCatalog(catalogId, properties); - - Map stats = cache.stats(catalogId); - MetaCacheEntryStats schemaStats = stats.get(PaimonExternalMetaCache.ENTRY_SCHEMA); - Assert.assertNotNull(schemaStats); - Assert.assertEquals(0L, schemaStats.getTtlSecond()); - Assert.assertTrue(schemaStats.isConfigEnabled()); - Assert.assertFalse(schemaStats.isEffectiveEnabled()); - } finally { - executor.shutdownNow(); - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonMetadataOpsTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonMetadataOpsTest.java deleted file mode 100644 index dda2c3d23447a4..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonMetadataOpsTest.java +++ /dev/null @@ -1,281 +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.datasource.paimon; - -import org.apache.doris.common.DdlException; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.CatalogFactory; -import org.apache.doris.nereids.parser.NereidsParser; -import org.apache.doris.nereids.trees.plans.commands.CreateCatalogCommand; -import org.apache.doris.nereids.trees.plans.commands.CreateTableCommand; -import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo; -import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; -import org.apache.doris.qe.ConnectContext; - -import com.google.common.collect.Maps; -import org.apache.paimon.catalog.Catalog; -import org.apache.paimon.catalog.FileSystemCatalog; -import org.apache.paimon.catalog.Identifier; -import org.apache.paimon.hive.HiveCatalog; -import org.apache.paimon.table.Table; -import org.apache.paimon.types.BigIntType; -import org.apache.paimon.types.DataField; -import org.apache.paimon.types.DateType; -import org.apache.paimon.types.DecimalType; -import org.apache.paimon.types.DoubleType; -import org.apache.paimon.types.FloatType; -import org.apache.paimon.types.IntType; -import org.apache.paimon.types.TimestampType; -import org.apache.paimon.types.VarCharType; -import org.junit.Assert; -import org.junit.BeforeClass; -import org.junit.Test; -import org.junit.jupiter.api.Assertions; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.UUID; -import java.util.stream.Collectors; - -public class PaimonMetadataOpsTest { - public static String warehouse; - public static PaimonExternalCatalog paimonCatalog; - public static PaimonMetadataOps ops; - public static String dbName = "test_db"; - public static ConnectContext connectContext; - - @BeforeClass - public static void beforeClass() throws Throwable { - Path warehousePath = Files.createTempDirectory("test_warehouse_"); - warehouse = "file://" + warehousePath.toAbsolutePath() + "/"; - HashMap param = new HashMap<>(); - param.put("type", "paimon"); - param.put("paimon.catalog.type", "filesystem"); - param.put("warehouse", warehouse); - // create catalog - CreateCatalogCommand createCatalogCommand = new CreateCatalogCommand("paimon", true, "", "comment", param); - paimonCatalog = (PaimonExternalCatalog) CatalogFactory.createFromCommand(1, createCatalogCommand); - paimonCatalog.makeSureInitialized(); - // create db - ops = new PaimonMetadataOps(paimonCatalog, paimonCatalog.catalog); - ops.createDb(dbName, true, Maps.newHashMap()); - paimonCatalog.makeSureInitialized(); - - // context - connectContext = new ConnectContext(); - connectContext.setThreadLocalInfo(); - } - - @Test - public void testSimpleTable() throws Exception { - String tableName = getTableName(); - Identifier identifier = new Identifier(dbName, tableName); - String sql = "create table " + dbName + "." + tableName + " (id int) engine = paimon"; - createTable(sql); - Catalog catalog = ops.getCatalog(); - Table table = catalog.getTable(identifier); - List columnNames = new ArrayList<>(); - if (catalog instanceof HiveCatalog) { - columnNames.addAll(((HiveCatalog) catalog).loadTableSchema(identifier).fieldNames()); - } else if (catalog instanceof FileSystemCatalog) { - columnNames.addAll(((FileSystemCatalog) catalog).loadTableSchema(identifier).fieldNames()); - } - - if (!columnNames.isEmpty()) { - Assert.assertEquals(1, columnNames.size()); - } - Assert.assertEquals(0, table.partitionKeys().size()); - } - - @Test - public void testProperties() throws Exception { - String tableName = getTableName(); - Identifier identifier = new Identifier(dbName, tableName); - String sql = "create table " + dbName + "." + tableName + " (id int) engine = paimon properties(\"primary-key\"=id)"; - createTable(sql); - Catalog catalog = ops.getCatalog(); - Table table = catalog.getTable(identifier); - - List columnNames = new ArrayList<>(); - if (catalog instanceof HiveCatalog) { - columnNames.addAll(((HiveCatalog) catalog).loadTableSchema(identifier).fieldNames()); - } else if (catalog instanceof FileSystemCatalog) { - columnNames.addAll(((FileSystemCatalog) catalog).loadTableSchema(identifier).fieldNames()); - } - - if (!columnNames.isEmpty()) { - Assert.assertEquals(1, columnNames.size()); - } - Assert.assertEquals(0, table.partitionKeys().size()); - Assert.assertTrue(table.primaryKeys().contains("id")); - Assert.assertEquals(1, table.primaryKeys().size()); - } - - @Test - public void testType() throws Exception { - String tableName = getTableName(); - Identifier identifier = new Identifier(dbName, tableName); - String sql = "create table " + dbName + "." + tableName + " (" - + "c0 int, " - + "c1 bigint, " - + "c2 float, " - + "c3 double, " - + "c4 string, " - + "c5 date, " - + "c6 decimal(20, 10), " - + "c7 datetime" - + ") engine = paimon " - + "properties(\"primary-key\"=c0)"; - createTable(sql); - Catalog catalog = ops.getCatalog(); - Table table = catalog.getTable(identifier); - - List columns = new ArrayList<>(); - if (catalog instanceof HiveCatalog) { - columns.addAll(((HiveCatalog) catalog).loadTableSchema(identifier).fields()); - } else if (catalog instanceof FileSystemCatalog) { - columns.addAll(((FileSystemCatalog) catalog).loadTableSchema(identifier).fields()); - } - - if (!columns.isEmpty()) { - Assert.assertEquals(8, columns.size()); - Assert.assertEquals(new IntType().asSQLString(), columns.get(0).type().toString()); - Assert.assertEquals(new BigIntType().asSQLString(), columns.get(1).type().toString()); - Assert.assertEquals(new FloatType().asSQLString(), columns.get(2).type().toString()); - Assert.assertEquals(new DoubleType().asSQLString(), columns.get(3).type().toString()); - Assert.assertEquals(new VarCharType(VarCharType.MAX_LENGTH).asSQLString(), columns.get(4).type().toString()); - Assert.assertEquals(new DateType().asSQLString(), columns.get(5).type().toString()); - Assert.assertEquals(new DecimalType(20, 10).asSQLString(), columns.get(6).type().toString()); - Assert.assertEquals(new TimestampType().asSQLString(), columns.get(7).type().toString()); - } - - Assert.assertEquals(0, table.partitionKeys().size()); - Assert.assertTrue(table.primaryKeys().contains("c0")); - Assert.assertEquals(1, table.primaryKeys().size()); - } - - @Test - public void testPartition() throws Exception { - String tableName = "test04"; - Identifier identifier = new Identifier(dbName, tableName); - String sql = "create table " + dbName + "." + tableName + " (" - + "c0 int, " - + "c1 bigint, " - + "c2 float, " - + "c3 double, " - + "c4 string, " - + "c5 date, " - + "c6 decimal(20, 10), " - + "c7 datetime" - + ") engine = paimon " - + "partition by (" - + "c1 ) ()" - + "properties(\"primary-key\"=c0)"; - createTable(sql); - Catalog catalog = ops.getCatalog(); - Table table = catalog.getTable(identifier); - Assert.assertEquals(1, table.partitionKeys().size()); - Assert.assertTrue(table.primaryKeys().contains("c0")); - Assert.assertEquals(1, table.primaryKeys().size()); - } - - @Test - public void testPartitionPreservesNonLowercaseColumnNames() throws Exception { - String tableName = getTableName(); - Identifier identifier = new Identifier(dbName, tableName); - String sql = "create table " + dbName + "." + tableName + " (" - + "data int, " - + "`PART` int, " - + "`mIxEd_COL` int" - + ") engine = paimon " - + "partition by (`PART`) ()"; - createTable(sql); - Catalog catalog = ops.getCatalog(); - Table table = catalog.getTable(identifier); - - List columnNames = table.rowType().getFields().stream() - .map(DataField::name) - .collect(Collectors.toList()); - - Assert.assertEquals("PART", columnNames.get(1)); - Assert.assertEquals("mIxEd_COL", columnNames.get(2)); - Assert.assertEquals(1, table.partitionKeys().size()); - Assert.assertEquals("PART", table.partitionKeys().get(0)); - } - - @Test - public void testBucket() throws Exception { - String tableName = getTableName(); - Identifier identifier = new Identifier(dbName, tableName); - String sql = "create table " + dbName + "." + tableName + " (" - + "c0 int, " - + "c1 bigint, " - + "c2 float, " - + "c3 double, " - + "c4 string, " - + "c5 date, " - + "c6 decimal(20, 10), " - + "c7 datetime" - + ") engine = paimon " - + "properties(\"primary-key\"=c0," - + "\"bucket\" = 4," - + "\"bucket-key\" = c0)"; - createTable(sql); - Catalog catalog = ops.getCatalog(); - Table table = catalog.getTable(identifier); - Assert.assertEquals("4", table.options().get("bucket")); - Assert.assertEquals("c0", table.options().get("bucket-key")); - } - - public void createTable(String sql) throws UserException { - LogicalPlan plan = new NereidsParser().parseSingle(sql); - Assertions.assertTrue(plan instanceof CreateTableCommand); - CreateTableInfo createTableInfo = ((CreateTableCommand) plan).getCreateTableInfo(); - createTableInfo.setIsExternal(true); - createTableInfo.analyzeEngine(); - ops.createTable(createTableInfo); - } - - public String getTableName() { - String s = "test_tb_" + UUID.randomUUID(); - return s.replaceAll("-", ""); - } - - @Test - public void testDropDB() { - try { - // create db success - ops.createDb("t_paimon", false, Maps.newHashMap()); - // drop db success - ops.dropDb("t_paimon", false, false); - } catch (Throwable t) { - Assert.fail(); - } - - try { - ops.dropDb("t_paimon", false, false); - Assert.fail(); - } catch (Throwable t) { - Assert.assertTrue(t instanceof DdlException); - Assert.assertTrue(t.getMessage().contains("database doesn't exist")); - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonUtilTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonUtilTest.java deleted file mode 100644 index 050e547816d404..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonUtilTest.java +++ /dev/null @@ -1,161 +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.datasource.paimon; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Type; -import org.apache.doris.thrift.TPrimitiveType; -import org.apache.doris.thrift.schema.external.TFieldPtr; -import org.apache.doris.thrift.schema.external.TSchema; - -import org.apache.paimon.data.BinaryRow; -import org.apache.paimon.data.BinaryRowWriter; -import org.apache.paimon.data.BinaryString; -import org.apache.paimon.schema.TableSchema; -import org.apache.paimon.table.Table; -import org.apache.paimon.types.CharType; -import org.apache.paimon.types.DataField; -import org.apache.paimon.types.DataTypes; -import org.apache.paimon.types.RowType; -import org.apache.paimon.types.VarCharType; -import org.junit.Assert; -import org.junit.Test; -import org.mockito.Mockito; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -public class PaimonUtilTest { - private static final String TABLE_READ_SEQUENCE_NUMBER_ENABLED = "table-read.sequence-number.enabled"; - - @Test - public void testSchemaForVarcharAndChar() { - DataField c1 = new DataField(1, "c1", new VarCharType(32)); - DataField c2 = new DataField(2, "c2", new CharType(14)); - Type type1 = PaimonUtil.paimonTypeToDorisType(c1.type(), true, true); - Type type2 = PaimonUtil.paimonTypeToDorisType(c2.type(), true, true); - Assert.assertTrue(type1.isVarchar()); - Assert.assertEquals(32, type1.getLength()); - Assert.assertEquals(14, type2.getLength()); - } - - @Test - public void testGetPartitionInfoMapSupportsFloatingPointPartitions() { - DataField floatPartition = DataTypes.FIELD(0, "float_partition", DataTypes.FLOAT()); - DataField doublePartition = DataTypes.FIELD(1, "double_partition", DataTypes.DOUBLE()); - Table table = Mockito.mock(Table.class); - Mockito.when(table.name()).thenReturn("mock_table"); - Mockito.when(table.partitionKeys()).thenReturn(Arrays.asList("float_partition", "double_partition")); - Mockito.when(table.rowType()).thenReturn(DataTypes.ROW(floatPartition, doublePartition)); - - float floatValue = Math.nextUp(0.1F); - double doubleValue = Math.nextUp(0.1D); - BinaryRow partitionValues = new BinaryRow(2); - BinaryRowWriter writer = new BinaryRowWriter(partitionValues); - writer.writeFloat(0, floatValue); - writer.writeDouble(1, doubleValue); - writer.complete(); - - Map partitionInfoMap = PaimonUtil.getPartitionInfoMap(table, partitionValues, "UTC"); - - String serializedFloat = partitionInfoMap.get("float_partition"); - String serializedDouble = partitionInfoMap.get("double_partition"); - Assert.assertEquals(Float.toString(floatValue), serializedFloat); - Assert.assertEquals(Double.toString(doubleValue), serializedDouble); - Assert.assertEquals(Float.floatToIntBits(floatValue), - Float.floatToIntBits(Float.parseFloat(serializedFloat))); - Assert.assertEquals(Double.doubleToLongBits(doubleValue), - Double.doubleToLongBits(Double.parseDouble(serializedDouble))); - } - - @Test - public void testParseSchemaPreservesNonLowercaseColumnNames() { - RowType rowType = DataTypes.ROW( - DataTypes.FIELD(0, "mIxEd_COL", DataTypes.INT()), - DataTypes.FIELD(1, "PART", DataTypes.STRING())); - - List columns = PaimonUtil.parseSchema(rowType, Collections.singletonList("PART"), false, false); - - Assert.assertEquals("mIxEd_COL", columns.get(0).getName()); - Assert.assertEquals("PART", columns.get(1).getName()); - Assert.assertTrue(columns.get(1).isKey()); - } - - @Test - public void testGetPartitionInfoMapPreservesNonLowercaseKeys() { - DataField mixedCasePartition = DataTypes.FIELD(0, "Dt", DataTypes.STRING()); - Table table = Mockito.mock(Table.class); - Mockito.when(table.name()).thenReturn("mock_table"); - Mockito.when(table.partitionKeys()).thenReturn(Collections.singletonList("Dt")); - Mockito.when(table.rowType()).thenReturn(DataTypes.ROW(mixedCasePartition)); - - BinaryRow partitionValues = BinaryRow.singleColumn(BinaryString.fromString("2026-05-26")); - - Map partitionInfoMap = PaimonUtil.getPartitionInfoMap(table, partitionValues, "UTC"); - - Assert.assertFalse(partitionInfoMap.containsKey("dt")); - Assert.assertEquals("2026-05-26", partitionInfoMap.get("Dt")); - } - - @Test - public void testBinlogHistorySchemaWithSequenceNumber() { - PaimonSysExternalTable binlogTable = Mockito.mock(PaimonSysExternalTable.class); - Mockito.when(binlogTable.getSysTableType()).thenReturn("binlog"); - Mockito.when(binlogTable.getTableProperties()).thenReturn( - Collections.singletonMap(TABLE_READ_SEQUENCE_NUMBER_ENABLED, "true")); - Mockito.when(binlogTable.getName()).thenReturn("mock_binlog"); - - List sourceFields = Arrays.asList( - new DataField(0, "id", DataTypes.INT()), - new DataField(1, "name", DataTypes.STRING())); - TableSchema sourceSchema = new TableSchema(1L, sourceFields, 1, Collections.emptyList(), - Collections.emptyList(), Collections.emptyMap(), ""); - TSchema historySchema = PaimonUtil.getHistorySchemaInfo(binlogTable, sourceSchema, true, true); - List fields = historySchema.getRootField().getFields(); - - Assert.assertEquals("rowkind", fields.get(0).getFieldPtr().getName()); - Assert.assertEquals("_SEQUENCE_NUMBER", fields.get(1).getFieldPtr().getName()); - Assert.assertEquals("id", fields.get(2).getFieldPtr().getName()); - Assert.assertEquals(TPrimitiveType.ARRAY, fields.get(2).getFieldPtr().getType().getType()); - Assert.assertEquals("name", fields.get(3).getFieldPtr().getName()); - Assert.assertEquals(TPrimitiveType.ARRAY, fields.get(3).getFieldPtr().getType().getType()); - } - - @Test - public void testAuditLogHistorySchemaWithoutSequenceNumber() { - PaimonSysExternalTable auditLogTable = Mockito.mock(PaimonSysExternalTable.class); - Mockito.when(auditLogTable.getSysTableType()).thenReturn("audit_log"); - Mockito.when(auditLogTable.getTableProperties()).thenReturn(Collections.emptyMap()); - Mockito.when(auditLogTable.getName()).thenReturn("mock_audit_log"); - - List sourceFields = Arrays.asList( - new DataField(0, "id", DataTypes.INT()), - new DataField(1, "name", DataTypes.STRING())); - TableSchema sourceSchema = new TableSchema(1L, sourceFields, 1, Collections.emptyList(), - Collections.emptyList(), Collections.emptyMap(), ""); - TSchema historySchema = PaimonUtil.getHistorySchemaInfo(auditLogTable, sourceSchema, true, true); - List fields = historySchema.getRootField().getFields(); - - Assert.assertEquals(3, fields.size()); - Assert.assertEquals("rowkind", fields.get(0).getFieldPtr().getName()); - Assert.assertEquals("id", fields.get(1).getFieldPtr().getName()); - Assert.assertEquals("name", fields.get(2).getFieldPtr().getName()); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonVendedCredentialsProviderTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonVendedCredentialsProviderTest.java deleted file mode 100644 index d672d69045e401..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonVendedCredentialsProviderTest.java +++ /dev/null @@ -1,349 +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.datasource.paimon; - -import org.apache.doris.datasource.credentials.CredentialUtils; -import org.apache.doris.datasource.credentials.VendedCredentialsFactory; -import org.apache.doris.datasource.property.metastore.MetastoreProperties; -import org.apache.doris.datasource.property.metastore.PaimonRestMetaStoreProperties; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.datasource.property.storage.StorageProperties.Type; - -import org.apache.paimon.rest.RESTToken; -import org.apache.paimon.rest.RESTTokenFileIO; -import org.apache.paimon.table.Table; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.HashMap; -import java.util.Map; - -public class PaimonVendedCredentialsProviderTest { - - @Test - public void testIsVendedCredentialsEnabled() { - PaimonVendedCredentialsProvider provider = PaimonVendedCredentialsProvider.getInstance(); - - // Test with PaimonRestMetaStore and DLF token provider - PaimonRestMetaStoreProperties restProperties = Mockito.mock(PaimonRestMetaStoreProperties.class); - Mockito.when(restProperties.getType()).thenReturn(MetastoreProperties.Type.PAIMON); - Mockito.when(restProperties.getTokenProvider()).thenReturn("dlf"); - - Assertions.assertTrue(provider.isVendedCredentialsEnabled(restProperties)); - - // Test with PaimonRestMetaStore but unsupported token provider - // Note: PaimonVendedCredentialsProvider enables vended credentials for all PaimonRestMetaStore - // regardless of token provider, actual provider check happens later - Mockito.when(restProperties.getTokenProvider()).thenReturn("unsupported"); - Assertions.assertTrue(provider.isVendedCredentialsEnabled(restProperties)); - - // Test with non-PaimonRest metastore - MetastoreProperties nonRestProperties = Mockito.mock(MetastoreProperties.class); - Mockito.when(nonRestProperties.getType()).thenReturn(MetastoreProperties.Type.HMS); - Assertions.assertFalse(provider.isVendedCredentialsEnabled(nonRestProperties)); - } - - @Test - public void testExtractRawVendedCredentials() { - PaimonVendedCredentialsProvider provider = PaimonVendedCredentialsProvider.getInstance(); - - // Mock table with OSS vended credentials - Table table = Mockito.mock(Table.class); - RESTTokenFileIO restTokenFileIO = Mockito.mock(RESTTokenFileIO.class); - RESTToken restToken = Mockito.mock(RESTToken.class); - - Map tokenMap = new HashMap<>(); - tokenMap.put("fs.oss.accessKeyId", "STS.testAccessKey123"); - tokenMap.put("fs.oss.accessKeySecret", "testSecretKey456"); - tokenMap.put("fs.oss.securityToken", "testSessionToken789"); - tokenMap.put("fs.oss.endpoint", "oss-cn-beijing.aliyuncs.com"); - - Mockito.when(table.fileIO()).thenReturn(restTokenFileIO); - Mockito.when(table.name()).thenReturn("test_table"); - Mockito.when(restTokenFileIO.validToken()).thenReturn(restToken); - Mockito.when(restToken.token()).thenReturn(tokenMap); - - Map rawCredentials = provider.extractRawVendedCredentials(table); - - Assertions.assertEquals("STS.testAccessKey123", rawCredentials.get("fs.oss.accessKeyId")); - Assertions.assertEquals("testSecretKey456", rawCredentials.get("fs.oss.accessKeySecret")); - Assertions.assertEquals("testSessionToken789", rawCredentials.get("fs.oss.securityToken")); - Assertions.assertEquals("oss-cn-beijing.aliyuncs.com", rawCredentials.get("fs.oss.endpoint")); - } - - @Test - public void testExtractRawVendedCredentialsWithNullTable() { - PaimonVendedCredentialsProvider provider = PaimonVendedCredentialsProvider.getInstance(); - - Map rawCredentials = provider.extractRawVendedCredentials(null); - Assertions.assertTrue(rawCredentials.isEmpty()); - } - - @Test - public void testExtractRawVendedCredentialsWithNullFileIO() { - PaimonVendedCredentialsProvider provider = PaimonVendedCredentialsProvider.getInstance(); - - Table table = Mockito.mock(Table.class); - Mockito.when(table.fileIO()).thenReturn(null); - - Map rawCredentials = provider.extractRawVendedCredentials(table); - Assertions.assertTrue(rawCredentials.isEmpty()); - } - - @Test - public void testExtractRawVendedCredentialsWithNonRESTTokenFileIO() { - PaimonVendedCredentialsProvider provider = PaimonVendedCredentialsProvider.getInstance(); - - Table table = Mockito.mock(Table.class); - // Mock a different FileIO type that's not RESTTokenFileIO - Mockito.when(table.fileIO()).thenReturn(Mockito.mock(org.apache.paimon.fs.FileIO.class)); - - Map rawCredentials = provider.extractRawVendedCredentials(table); - Assertions.assertTrue(rawCredentials.isEmpty()); - } - - @Test - public void testExtractRawVendedCredentialsWithEmptyToken() { - PaimonVendedCredentialsProvider provider = PaimonVendedCredentialsProvider.getInstance(); - - Table table = Mockito.mock(Table.class); - RESTTokenFileIO restTokenFileIO = Mockito.mock(RESTTokenFileIO.class); - RESTToken restToken = Mockito.mock(RESTToken.class); - - Mockito.when(table.fileIO()).thenReturn(restTokenFileIO); - Mockito.when(table.name()).thenReturn("test_table"); - Mockito.when(restTokenFileIO.validToken()).thenReturn(restToken); - Mockito.when(restToken.token()).thenReturn(new HashMap<>()); - - Map rawCredentials = provider.extractRawVendedCredentials(table); - Assertions.assertTrue(rawCredentials.isEmpty()); - } - - @Test - public void testExtractRawVendedCredentialsWithPartialOSSCredentials() { - PaimonVendedCredentialsProvider provider = PaimonVendedCredentialsProvider.getInstance(); - - Table table = Mockito.mock(Table.class); - RESTTokenFileIO restTokenFileIO = Mockito.mock(RESTTokenFileIO.class); - RESTToken restToken = Mockito.mock(RESTToken.class); - - Map tokenMap = new HashMap<>(); - tokenMap.put("fs.oss.accessKeyId", "testAccessKey"); - tokenMap.put("fs.oss.accessKeySecret", "testSecretKey"); - // Missing endpoint and session token - - Mockito.when(table.fileIO()).thenReturn(restTokenFileIO); - Mockito.when(table.name()).thenReturn("test_table"); - Mockito.when(restTokenFileIO.validToken()).thenReturn(restToken); - Mockito.when(restToken.token()).thenReturn(tokenMap); - - Map rawCredentials = provider.extractRawVendedCredentials(table); - - Assertions.assertEquals("testAccessKey", rawCredentials.get("fs.oss.accessKeyId")); - Assertions.assertEquals("testSecretKey", rawCredentials.get("fs.oss.accessKeySecret")); - Assertions.assertFalse(rawCredentials.containsKey("fs.oss.securityToken")); - Assertions.assertFalse(rawCredentials.containsKey("fs.oss.endpoint")); - } - - @Test - public void testFilterCloudStoragePropertiesWithOSS() { - Map rawCredentials = new HashMap<>(); - rawCredentials.put("oss.access-key-id", "testAccessKey"); - rawCredentials.put("oss.secret-access-key", "testSecretKey"); - rawCredentials.put("oss.endpoint", "oss-cn-beijing.aliyuncs.com"); - rawCredentials.put("paimon.table.name", "test_table"); - rawCredentials.put("other.property", "other_value"); - - Map filtered = CredentialUtils.filterCloudStorageProperties(rawCredentials); - - Assertions.assertEquals(3, filtered.size()); - Assertions.assertEquals("testAccessKey", filtered.get("oss.access-key-id")); - Assertions.assertEquals("testSecretKey", filtered.get("oss.secret-access-key")); - Assertions.assertEquals("oss-cn-beijing.aliyuncs.com", filtered.get("oss.endpoint")); - Assertions.assertFalse(filtered.containsKey("paimon.table.name")); - Assertions.assertFalse(filtered.containsKey("other.property")); - } - - @Test - public void testGetStoragePropertiesMapWithVendedCredentials() { - // Mock metastore properties with DLF token provider - PaimonRestMetaStoreProperties restProperties = Mockito.mock(PaimonRestMetaStoreProperties.class); - Mockito.when(restProperties.getType()).thenReturn(MetastoreProperties.Type.PAIMON); - Mockito.when(restProperties.getTokenProvider()).thenReturn("dlf"); - - // Mock table with vended credentials - Table table = Mockito.mock(Table.class); - RESTTokenFileIO restTokenFileIO = Mockito.mock(RESTTokenFileIO.class); - RESTToken restToken = Mockito.mock(RESTToken.class); - - Map tokenMap = new HashMap<>(); - tokenMap.put("fs.oss.accessKeyId", "STS.testAccessKey123"); - tokenMap.put("fs.oss.accessKeySecret", "testSecretKey456"); - tokenMap.put("fs.oss.securityToken", "testSessionToken789"); - tokenMap.put("fs.oss.endpoint", "oss-cn-beijing.aliyuncs.com"); - - Mockito.when(table.fileIO()).thenReturn(restTokenFileIO); - Mockito.when(table.name()).thenReturn("test_table"); - Mockito.when(restTokenFileIO.validToken()).thenReturn(restToken); - Mockito.when(restToken.token()).thenReturn(tokenMap); - - // Test using VendedCredentialsFactory - Map result = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(restProperties, new HashMap<>(), table); - - // Should not be null (assuming StorageProperties.createAll works correctly) - // Note: The actual result depends on whether StorageProperties.createAll() can properly map the credentials - // This test verifies the integration flow works without exceptions - Assertions.assertNotNull(result); - } - - @Test - public void testGetStoragePropertiesMapWithVendedCredentialsDisabled() { - // Mock metastore properties with unsupported token provider - PaimonRestMetaStoreProperties restProperties = Mockito.mock(PaimonRestMetaStoreProperties.class); - Mockito.when(restProperties.getType()).thenReturn(MetastoreProperties.Type.PAIMON); - Mockito.when(restProperties.getTokenProvider()).thenReturn("unsupported"); - - Table table = Mockito.mock(Table.class); - - Map result = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(restProperties, new HashMap<>(), table); - - // Should return the baseStoragePropertiesMap (empty HashMap) - Assertions.assertNotNull(result); - Assertions.assertTrue(result.isEmpty()); - } - - @Test - public void testGetStoragePropertiesMapWithNullTable() { - PaimonRestMetaStoreProperties restProperties = Mockito.mock(PaimonRestMetaStoreProperties.class); - Mockito.when(restProperties.getType()).thenReturn(MetastoreProperties.Type.PAIMON); - Mockito.when(restProperties.getTokenProvider()).thenReturn("dlf"); - - Map result = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(restProperties, new HashMap<>(), null); - - // Should return the baseStoragePropertiesMap (empty HashMap) - Assertions.assertNotNull(result); - Assertions.assertTrue(result.isEmpty()); - } - - @Test - public void testGetStoragePropertiesMapWithNonPaimonRest() { - // Test with non-PaimonRest metastore - MetastoreProperties nonRestProperties = Mockito.mock(MetastoreProperties.class); - Mockito.when(nonRestProperties.getType()).thenReturn(MetastoreProperties.Type.HMS); - Table table = Mockito.mock(Table.class); - - Map result = VendedCredentialsFactory - .getStoragePropertiesMapWithVendedCredentials(nonRestProperties, new HashMap<>(), table); - - // Should return the baseStoragePropertiesMap (empty HashMap) - Assertions.assertNotNull(result); - Assertions.assertTrue(result.isEmpty()); - } - - @Test - public void testGetBackendPropertiesFromStorageMapWithOSS() { - // Create mock storage properties - StorageProperties ossProperties = Mockito.mock(StorageProperties.class); - StorageProperties hdfsProperties = Mockito.mock(StorageProperties.class); - - Map ossBackendProps = new HashMap<>(); - ossBackendProps.put("AWS_ACCESS_KEY", "testOssAccessKey"); - ossBackendProps.put("AWS_SECRET_KEY", "testOssSecretKey"); - ossBackendProps.put("AWS_TOKEN", "testOssToken"); - ossBackendProps.put("AWS_ENDPOINT", "oss-cn-beijing.aliyuncs.com"); - - Map hdfsBackendProps = new HashMap<>(); - hdfsBackendProps.put("HDFS_PROPERTY", "hdfsValue"); - - Mockito.when(ossProperties.getBackendConfigProperties()).thenReturn(ossBackendProps); - Mockito.when(hdfsProperties.getBackendConfigProperties()).thenReturn(hdfsBackendProps); - - Map storagePropertiesMap = new HashMap<>(); - storagePropertiesMap.put(Type.OSS, ossProperties); - storagePropertiesMap.put(Type.HDFS, hdfsProperties); - - Map result = CredentialUtils.getBackendPropertiesFromStorageMap(storagePropertiesMap); - - Assertions.assertEquals(5, result.size()); - Assertions.assertEquals("testOssAccessKey", result.get("AWS_ACCESS_KEY")); - Assertions.assertEquals("testOssSecretKey", result.get("AWS_SECRET_KEY")); - Assertions.assertEquals("testOssToken", result.get("AWS_TOKEN")); - Assertions.assertEquals("oss-cn-beijing.aliyuncs.com", result.get("AWS_ENDPOINT")); - Assertions.assertEquals("hdfsValue", result.get("HDFS_PROPERTY")); - } - - @Test - public void testGetBackendPropertiesFromStorageMapWithNullValues() { - StorageProperties ossProperties = Mockito.mock(StorageProperties.class); - - Map ossBackendProps = new HashMap<>(); - ossBackendProps.put("AWS_ACCESS_KEY", "testAccessKey"); - ossBackendProps.put("AWS_SECRET_KEY", null); // null value should be filtered out - ossBackendProps.put("AWS_TOKEN", "testToken"); - - Mockito.when(ossProperties.getBackendConfigProperties()).thenReturn(ossBackendProps); - - Map storagePropertiesMap = new HashMap<>(); - storagePropertiesMap.put(Type.OSS, ossProperties); - - Map result = CredentialUtils.getBackendPropertiesFromStorageMap(storagePropertiesMap); - - Assertions.assertEquals(2, result.size()); - Assertions.assertEquals("testAccessKey", result.get("AWS_ACCESS_KEY")); - Assertions.assertEquals("testToken", result.get("AWS_TOKEN")); - Assertions.assertFalse(result.containsKey("AWS_SECRET_KEY")); - } - - @Test - public void testEndpointToRegionConversion() { - PaimonVendedCredentialsProvider provider = PaimonVendedCredentialsProvider.getInstance(); - - // Test different OSS endpoint patterns and their expected regions - String[] endpoints = { - "oss-cn-beijing.aliyuncs.com", - "oss-cn-shanghai.aliyuncs.com", - "oss-us-west-1.aliyuncs.com", - "oss-ap-southeast-1.aliyuncs.com" - }; - - for (int i = 0; i < endpoints.length; i++) { - Table table = Mockito.mock(Table.class); - RESTTokenFileIO restTokenFileIO = Mockito.mock(RESTTokenFileIO.class); - RESTToken restToken = Mockito.mock(RESTToken.class); - - Map tokenMap = new HashMap<>(); - tokenMap.put("fs.oss.accessKeyId", "testAccessKey"); - tokenMap.put("fs.oss.accessKeySecret", "testSecretKey"); - tokenMap.put("fs.oss.endpoint", endpoints[i]); - - Mockito.when(table.fileIO()).thenReturn(restTokenFileIO); - Mockito.when(table.name()).thenReturn("test_table"); - Mockito.when(restTokenFileIO.validToken()).thenReturn(restToken); - Mockito.when(restToken.token()).thenReturn(tokenMap); - - Map rawCredentials = provider.extractRawVendedCredentials(table); - - Assertions.assertEquals(endpoints[i], rawCredentials.get("fs.oss.endpoint")); - // Note: Current implementation doesn't convert endpoint to region, so region is not set - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonScanNodeTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonScanNodeTest.java deleted file mode 100644 index 9f8583ed1bc860..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonScanNodeTest.java +++ /dev/null @@ -1,745 +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.datasource.paimon.source; - -import org.apache.doris.analysis.SlotId; -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.analysis.TupleId; -import org.apache.doris.common.ExceptionChecker; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.CatalogProperty; -import org.apache.doris.datasource.FileQueryScanNode; -import org.apache.doris.datasource.FileSplitter; -import org.apache.doris.datasource.paimon.PaimonExternalCatalog; -import org.apache.doris.datasource.paimon.PaimonExternalTable; -import org.apache.doris.datasource.paimon.PaimonFileExternalCatalog; -import org.apache.doris.datasource.paimon.PaimonSysExternalTable; -import org.apache.doris.datasource.property.metastore.MetastoreProperties; -import org.apache.doris.datasource.property.metastore.PaimonJdbcMetaStoreProperties; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.thrift.TFileRangeDesc; -import org.apache.doris.thrift.TFileScanRangeParams; -import org.apache.doris.thrift.TPushAggOp; - -import org.apache.paimon.data.BinaryRow; -import org.apache.paimon.io.DataFileMeta; -import org.apache.paimon.manifest.FileSource; -import org.apache.paimon.stats.SimpleStats; -import org.apache.paimon.table.Table; -import org.apache.paimon.table.source.DataSplit; -import org.apache.paimon.table.source.RawFile; -import org.junit.Assert; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.ArgumentMatchers; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.MockitoJUnitRunner; - -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -@RunWith(MockitoJUnitRunner.class) -public class PaimonScanNodeTest { - @Mock - private SessionVariable sv; - - @Mock - private PaimonFileExternalCatalog paimonFileExternalCatalog; - - @Test - public void testCountColumnKeepsAllSplitsWhileCountStarUsesMergedRowCount() throws UserException { - PaimonScanNode node = Mockito.spy(newTestNode(new PlanNodeId(1), new TupleId(3), sv)); - node.setSource(mockPaimonSourceWithPartitionKeys(Collections.emptyList())); - List dataSplits = Arrays.asList( - mockCountDataSplit("f1.parquet", 4_000), - mockCountDataSplit("f2.parquet", 5_000), - mockCountDataSplit("f3.parquet", 6_000)); - Mockito.doReturn(dataSplits).when(node).getPaimonSplitFromAPI(); - Mockito.when(sv.isForceJniScanner()).thenReturn(true); - Mockito.when(sv.getIgnoreSplitType()).thenReturn("NONE"); - Mockito.when(sv.getParallelExecInstanceNum(ArgumentMatchers.nullable(String.class))).thenReturn(1); - - // Before the fix, the raw COUNT opcode made this path keep only parallel representative - // splits and attach the 15,000 metadata rows. BE rejects that shortcut for COUNT(col), so - // it would scan only those representatives and silently miss the discarded DataSplits. - node.setPushDownAggNoGrouping(TPushAggOp.COUNT); - node.setPushDownCountSlotIds(Collections.singletonList(new SlotId(7))); - List countColumnSplits = node.getSplits(1); - Assert.assertEquals(3, countColumnSplits.size()); - for (org.apache.doris.spi.Split split : countColumnSplits) { - Assert.assertFalse(((PaimonSplit) split).getRowCount().isPresent()); - } - - // COUNT(*) remains metadata-only. The 15,000 rows exceed the parallel threshold, so one - // configured execution instance retains one representative split carrying the full sum. - node.setPushDownCountSlotIds(Collections.emptyList()); - List countStarSplits = node.getSplits(1); - Assert.assertEquals(1, countStarSplits.size()); - Assert.assertEquals(Optional.of(15_000L), ((PaimonSplit) countStarSplits.get(0)).getRowCount()); - } - - @Test - public void testSplitWeight() throws UserException { - - PaimonScanNode paimonScanNode = newTestNode(new PlanNodeId(1), new TupleId(3), sv); - - paimonScanNode.setSource(mockPaimonSourceWithPartitionKeys(Collections.emptyList())); - - DataFileMeta dfm1 = DataFileMeta.forAppend("f1.parquet", 64L * 1024 * 1024, 1L, SimpleStats.EMPTY_STATS, - 1L, 1L, 1L, Collections.emptyList(), null, FileSource.APPEND, - Collections.emptyList(), null, null, Collections.emptyList()); - BinaryRow binaryRow1 = BinaryRow.singleColumn(1); - DataSplit ds1 = DataSplit.builder() - .rawConvertible(true) - .withPartition(binaryRow1) - .withBucket(1) - .withBucketPath("file://b1") - .withDataFiles(Collections.singletonList(dfm1)) - .build(); - - DataFileMeta dfm2 = DataFileMeta.forAppend("f2.parquet", 32L * 1024 * 1024, 2L, SimpleStats.EMPTY_STATS, - 1L, 1L, 1L, Collections.emptyList(), null, FileSource.APPEND, - Collections.emptyList(), null, null, Collections.emptyList()); - BinaryRow binaryRow2 = BinaryRow.singleColumn(1); - DataSplit ds2 = DataSplit.builder() - .rawConvertible(true) - .withPartition(binaryRow2) - .withBucket(1) - .withBucketPath("file://b1") - .withDataFiles(Collections.singletonList(dfm2)) - .build(); - - - // Mock PaimonScanNode to return test data splits - PaimonScanNode spyPaimonScanNode = Mockito.spy(paimonScanNode); - Mockito.doReturn(new ArrayList() { - { - add(ds1); - add(ds2); - } - }).when(spyPaimonScanNode).getPaimonSplitFromAPI(); - - long maxInitialSplitSize = 32L * 1024L * 1024L; - long maxSplitSize = 64L * 1024L * 1024L; - // Ensure fileSplitter is initialized on the spy as doInitialize() is not called in this unit test - FileSplitter fileSplitter = new FileSplitter(maxInitialSplitSize, maxSplitSize, - 0); - try { - java.lang.reflect.Field field = FileQueryScanNode.class.getDeclaredField("fileSplitter"); - field.setAccessible(true); - field.set(spyPaimonScanNode, fileSplitter); - - java.lang.reflect.Field storagePropertiesField = - PaimonScanNode.class.getDeclaredField("storagePropertiesMap"); - storagePropertiesField.setAccessible(true); - storagePropertiesField.set(spyPaimonScanNode, Collections.emptyMap()); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new RuntimeException("Failed to inject test fields into PaimonScanNode", e); - } - - // Note: The original PaimonSource is sufficient for this test - // No need to mock catalog properties since doInitialize() is not called in this test - // Mock SessionVariable behavior - Mockito.when(sv.isForceJniScanner()).thenReturn(false); - Mockito.when(sv.getIgnoreSplitType()).thenReturn("NONE"); - Mockito.when(sv.getMaxInitialSplitSize()).thenReturn(maxInitialSplitSize); - Mockito.when(sv.getMaxSplitSize()).thenReturn(maxSplitSize); - - // native - mockNativeReader(spyPaimonScanNode); - List s1 = spyPaimonScanNode.getSplits(1); - PaimonSplit s11 = (PaimonSplit) s1.get(0); - PaimonSplit s12 = (PaimonSplit) s1.get(1); - Assert.assertEquals(2, s1.size()); - Assert.assertEquals(100, s11.getSplitWeight().getRawValue()); - Assert.assertNull(s11.getSplit()); - Assert.assertEquals(50, s12.getSplitWeight().getRawValue()); - Assert.assertNull(s12.getSplit()); - - // jni - mockJniReader(spyPaimonScanNode); - List s2 = spyPaimonScanNode.getSplits(1); - PaimonSplit s21 = (PaimonSplit) s2.get(0); - PaimonSplit s22 = (PaimonSplit) s2.get(1); - Assert.assertEquals(2, s2.size()); - Assert.assertNotNull(s21.getSplit()); - Assert.assertNotNull(s22.getSplit()); - Assert.assertEquals(100, s21.getSplitWeight().getRawValue()); - Assert.assertEquals(50, s22.getSplitWeight().getRawValue()); - } - - @Test - public void testValidateIncrementalReadParams() throws UserException { - // Test valid parameter combinations - - // 1. Only startSnapshotId - Map params1 = new HashMap<>(); - params1.put("startSnapshotId", "5"); - ExceptionChecker.expectThrowsWithMsg(UserException.class, - "endSnapshotId is required when using snapshot-based incremental read", - () -> PaimonScanNode.validateIncrementalReadParams(params1)); - - // 2. Both startSnapshotId and endSnapshotId - Map params = new HashMap<>(); - params.put("startSnapshotId", "1"); - params.put("endSnapshotId", "5"); - Map result = PaimonScanNode.validateIncrementalReadParams(params); - Assert.assertEquals("1,5", result.get("incremental-between")); - Assert.assertTrue(result.containsKey("scan.mode") && result.get("scan.mode") == null); - Assert.assertEquals(3, result.size()); - - // 3. startSnapshotId + endSnapshotId + incrementalBetweenScanMode - params.clear(); - params.put("startSnapshotId", "2"); - params.put("endSnapshotId", "8"); - params.put("incrementalBetweenScanMode", "diff"); - result = PaimonScanNode.validateIncrementalReadParams(params); - Assert.assertEquals("2,8", result.get("incremental-between")); - Assert.assertEquals("diff", result.get("incremental-between-scan-mode")); - Assert.assertTrue(result.containsKey("scan.mode") && result.get("scan.mode") == null); - Assert.assertEquals(4, result.size()); - - // 4. Only startTimestamp - params.clear(); - params.put("startTimestamp", "1000"); - result = PaimonScanNode.validateIncrementalReadParams(params); - Assert.assertEquals("1000," + Long.MAX_VALUE, result.get("incremental-between-timestamp")); - Assert.assertTrue(result.containsKey("scan.mode") && result.get("scan.mode") == null); - Assert.assertTrue(result.containsKey("scan.snapshot-id") && result.get("scan.snapshot-id") == null); - Assert.assertEquals(3, result.size()); - - // 5. Both startTimestamp and endTimestamp - params.clear(); - params.put("startTimestamp", "1000"); - params.put("endTimestamp", "2000"); - result = PaimonScanNode.validateIncrementalReadParams(params); - Assert.assertEquals("1000,2000", result.get("incremental-between-timestamp")); - Assert.assertTrue(result.containsKey("scan.mode") && result.get("scan.mode") == null); - Assert.assertTrue(result.containsKey("scan.snapshot-id") && result.get("scan.snapshot-id") == null); - Assert.assertEquals(3, result.size()); - - // Test invalid parameter combinations - - // 6. Test mutual exclusivity - both snapshot and timestamp params - params.clear(); - params.put("startSnapshotId", "1"); - params.put("startTimestamp", "1000"); - try { - PaimonScanNode.validateIncrementalReadParams(params); - Assert.fail("Should throw exception for mutual exclusivity"); - } catch (UserException e) { - Assert.assertTrue(e.getMessage().contains("Cannot specify both snapshot-based parameters")); - } - - // 7. Test snapshot params without required startSnapshotId - params.clear(); - params.put("endSnapshotId", "5"); - try { - PaimonScanNode.validateIncrementalReadParams(params); - Assert.fail("Should throw exception when startSnapshotId is missing"); - } catch (UserException e) { - Assert.assertTrue(e.getMessage().contains("startSnapshotId is required")); - } - - // 8. Test timestamp params without required startTimestamp - params.clear(); - params.put("endTimestamp", "2000"); - try { - PaimonScanNode.validateIncrementalReadParams(params); - Assert.fail("Should throw exception when startTimestamp is missing"); - } catch (UserException e) { - Assert.assertTrue(e.getMessage().contains("startTimestamp is required")); - } - - // 9. Test incrementalBetweenScanMode without endSnapshotId - params.clear(); - params.put("startSnapshotId", "1"); - params.put("incrementalBetweenScanMode", "auto"); - try { - PaimonScanNode.validateIncrementalReadParams(params); - Assert.fail("Should throw exception when incrementalBetweenScanMode appears without endSnapshotId"); - } catch (UserException e) { - Assert.assertTrue(e.getMessage().contains("incrementalBetweenScanMode can only be specified when both")); - } - - // 10. Test incrementalBetweenScanMode alone - params.clear(); - params.put("incrementalBetweenScanMode", "auto"); - try { - PaimonScanNode.validateIncrementalReadParams(params); - Assert.fail("Should throw exception when incrementalBetweenScanMode appears alone"); - } catch (UserException e) { - Assert.assertTrue( - e.getMessage().contains("startSnapshotId is required when using snapshot-based incremental read")); - } - - // 11. Test invalid snapshot ID values < 0) - params.clear(); - params.put("startSnapshotId", "-1"); - try { - PaimonScanNode.validateIncrementalReadParams(params); - Assert.fail("Should throw exception for startSnapshotId < 0"); - } catch (UserException e) { - Assert.assertTrue(e.getMessage().contains("startSnapshotId must be greater than or equal to 0")); - } - - params.clear(); - params.put("startSnapshotId", "1"); - params.put("endSnapshotId", "-1"); - try { - PaimonScanNode.validateIncrementalReadParams(params); - Assert.fail("Should throw exception for endSnapshotId < 0"); - } catch (UserException e) { - Assert.assertTrue(e.getMessage().contains("endSnapshotId must be greater than or equal to 0")); - } - - // 12. Test start > end for snapshot IDs - params.clear(); - params.put("startSnapshotId", "6"); - params.put("endSnapshotId", "5"); - try { - PaimonScanNode.validateIncrementalReadParams(params); - Assert.fail("Should throw exception when startSnapshotId > endSnapshotId"); - } catch (UserException e) { - Assert.assertTrue(e.getMessage().contains("startSnapshotId must be less than or equal to endSnapshotId")); - } - - // 12.1. Test startSnapshotId == endSnapshotId (should be allowed, consistent with Spark Paimon behavior) - params.clear(); - params.put("startSnapshotId", "5"); - params.put("endSnapshotId", "5"); - result = PaimonScanNode.validateIncrementalReadParams(params); - Assert.assertEquals("5,5", result.get("incremental-between")); - Assert.assertTrue(result.containsKey("scan.mode") && result.get("scan.mode") == null); - Assert.assertEquals(3, result.size()); - - // 13. Test invalid timestamp values (< 0) - params.clear(); - params.put("startTimestamp", "-1"); - try { - PaimonScanNode.validateIncrementalReadParams(params); - Assert.fail("Should throw exception for startTimestamp < 0"); - } catch (UserException e) { - Assert.assertTrue(e.getMessage().contains("startTimestamp must be greater than or equal to 0")); - } - - params.clear(); - params.put("startTimestamp", "1000"); - params.put("endTimestamp", "0"); - try { - PaimonScanNode.validateIncrementalReadParams(params); - Assert.fail("Should throw exception for endTimestamp ≤ 0"); - } catch (UserException e) { - Assert.assertTrue(e.getMessage().contains("endTimestamp must be greater than 0")); - } - - // 14. Test start ≥ end for timestamps - params.clear(); - params.put("startTimestamp", "2000"); - params.put("endTimestamp", "2000"); - try { - PaimonScanNode.validateIncrementalReadParams(params); - Assert.fail("Should throw exception when startTimestamp = endTimestamp"); - } catch (UserException e) { - Assert.assertTrue(e.getMessage().contains("startTimestamp must be less than endTimestamp")); - } - - params.clear(); - params.put("startTimestamp", "3000"); - params.put("endTimestamp", "2000"); - try { - PaimonScanNode.validateIncrementalReadParams(params); - Assert.fail("Should throw exception when startTimestamp > endTimestamp"); - } catch (UserException e) { - Assert.assertTrue(e.getMessage().contains("startTimestamp must be less than endTimestamp")); - } - - // 15. Test invalid number format - params.clear(); - params.put("startSnapshotId", "invalid"); - try { - PaimonScanNode.validateIncrementalReadParams(params); - Assert.fail("Should throw exception for invalid number format"); - } catch (UserException e) { - Assert.assertTrue(e.getMessage().contains("Invalid startSnapshotId format")); - } - - params.clear(); - params.put("startTimestamp", "invalid"); - try { - PaimonScanNode.validateIncrementalReadParams(params); - Assert.fail("Should throw exception for invalid timestamp format"); - } catch (UserException e) { - Assert.assertTrue(e.getMessage().contains("Invalid startTimestamp format")); - } - - // 16. Test invalid incrementalBetweenScanMode values - params.clear(); - params.put("startSnapshotId", "1"); - params.put("endSnapshotId", "5"); - params.put("incrementalBetweenScanMode", "invalid"); - try { - PaimonScanNode.validateIncrementalReadParams(params); - Assert.fail("Should throw exception for invalid scan mode"); - } catch (UserException e) { - Assert.assertTrue( - e.getMessage().contains("incrementalBetweenScanMode must be one of: auto, diff, delta, changelog")); - } - - // 17. Test valid incrementalBetweenScanMode values (case insensitive) - String[] validModes = {"auto", "AUTO", "diff", "DIFF", "delta", "DELTA", "changelog", "CHANGELOG"}; - for (String mode : validModes) { - params.clear(); - params.put("startSnapshotId", "1"); - params.put("endSnapshotId", "5"); - params.put("incrementalBetweenScanMode", mode); - result = PaimonScanNode.validateIncrementalReadParams(params); - Assert.assertEquals("1,5", result.get("incremental-between")); - Assert.assertEquals(mode, result.get("incremental-between-scan-mode")); - Assert.assertTrue(result.containsKey("scan.mode") && result.get("scan.mode") == null); - Assert.assertTrue(result.containsKey("scan.mode") && result.get("scan.mode") == null); - Assert.assertEquals(4, result.size()); - } - - // 18. Test no parameters at all - params.clear(); - try { - PaimonScanNode.validateIncrementalReadParams(params); - Assert.fail("Should throw exception when no parameters provided"); - } catch (UserException e) { - Assert.assertTrue(e.getMessage().contains("at least one valid parameter group must be specified")); - } - } - - @Test - public void testPaimonDataSystemTableForceJniEvenWhenNativeSupported() throws UserException { - PaimonScanNode paimonScanNode = newTestNode(new PlanNodeId(1), new TupleId(3), sv); - PaimonScanNode spyPaimonScanNode = Mockito.spy(paimonScanNode); - - DataFileMeta dfm = DataFileMeta.forAppend("f1.parquet", 64L * 1024 * 1024, 1L, SimpleStats.EMPTY_STATS, - 1L, 1L, 1L, Collections.emptyList(), null, FileSource.APPEND, - Collections.emptyList(), null, null, Collections.emptyList()); - BinaryRow binaryRow = BinaryRow.singleColumn(1); - DataSplit dataSplit = DataSplit.builder() - .rawConvertible(true) - .withPartition(binaryRow) - .withBucket(1) - .withBucketPath("file://b1") - .withDataFiles(Collections.singletonList(dfm)) - .build(); - - PaimonSource source = Mockito.mock(PaimonSource.class); - PaimonSysExternalTable binlogTable = Mockito.mock(PaimonSysExternalTable.class); - Mockito.when(binlogTable.getSysTableType()).thenReturn("binlog"); - Mockito.when(source.getExternalTable()).thenReturn(binlogTable); - spyPaimonScanNode.setSource(source); - - Mockito.doReturn(Collections.singletonList(dataSplit)).when(spyPaimonScanNode).getPaimonSplitFromAPI(); - Assert.assertTrue(spyPaimonScanNode.supportNativeReader(dataSplit.convertToRawFiles())); - - long maxInitialSplitSize = 32L * 1024L * 1024L; - long maxSplitSize = 64L * 1024L * 1024L; - FileSplitter fileSplitter = new FileSplitter(maxInitialSplitSize, maxSplitSize, 0); - try { - java.lang.reflect.Field field = FileQueryScanNode.class.getDeclaredField("fileSplitter"); - field.setAccessible(true); - field.set(spyPaimonScanNode, fileSplitter); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new RuntimeException("Failed to inject FileSplitter into PaimonScanNode test", e); - } - - Mockito.when(sv.isForceJniScanner()).thenReturn(false); - Mockito.when(sv.getIgnoreSplitType()).thenReturn("NONE"); - Mockito.when(sv.getMaxSplitSize()).thenReturn(maxSplitSize); - - Assert.assertTrue(spyPaimonScanNode.shouldForceJniForSystemTable()); - List splits = spyPaimonScanNode.getSplits(1); - Assert.assertEquals(1, splits.size()); - Assert.assertNotNull(((PaimonSplit) splits.get(0)).getSplit()); - - PaimonSysExternalTable auditLogTable = Mockito.mock(PaimonSysExternalTable.class); - Mockito.when(auditLogTable.getSysTableType()).thenReturn("audit_log"); - Mockito.when(source.getExternalTable()).thenReturn(auditLogTable); - - Assert.assertTrue(spyPaimonScanNode.shouldForceJniForSystemTable()); - List auditLogSplits = spyPaimonScanNode.getSplits(1); - Assert.assertEquals(1, auditLogSplits.size()); - Assert.assertNotNull(((PaimonSplit) auditLogSplits.get(0)).getSplit()); - } - - @Test - public void testDetermineTargetFileSplitSizeHonorsMaxFileSplitNum() throws Exception { - SessionVariable sv = new SessionVariable(); - sv.setMaxFileSplitNum(100); - PaimonScanNode node = newTestNode(new PlanNodeId(0), new TupleId(0), sv); - - PaimonSource source = Mockito.mock(PaimonSource.class); - Mockito.when(source.getFileFormatFromTableProperties()).thenReturn("parquet"); - node.setSource(source); - - RawFile rawFile = Mockito.mock(RawFile.class); - Mockito.when(rawFile.path()).thenReturn("file.parquet"); - Mockito.when(rawFile.fileSize()).thenReturn(10_000L * 1024L * 1024L); - - DataSplit dataSplit = Mockito.mock(DataSplit.class); - Mockito.when(dataSplit.convertToRawFiles()).thenReturn(Optional.of(Collections.singletonList(rawFile))); - - Method method = PaimonScanNode.class.getDeclaredMethod("determineTargetFileSplitSize", List.class, boolean.class); - method.setAccessible(true); - long target = (long) method.invoke(node, Collections.singletonList(dataSplit), false); - Assert.assertEquals(100L * 1024L * 1024L, target); - } - - @Test - public void testGetBackendPaimonOptionsForJdbcCatalog() throws Exception { - String driverUrl = "file:///tmp/postgresql-42.5.0.jar"; - Map props = new HashMap<>(); - props.put("type", "paimon"); - props.put("paimon.catalog.type", "jdbc"); - props.put("uri", "jdbc:postgresql://127.0.0.1:5442/postgres"); - props.put("warehouse", "s3://warehouse/path"); - props.put("paimon.jdbc.driver_url", driverUrl); - props.put("paimon.jdbc.driver_class", "org.postgresql.Driver"); - PaimonJdbcMetaStoreProperties jdbcMetaStoreProperties = - (PaimonJdbcMetaStoreProperties) MetastoreProperties.create(props); - - CatalogProperty catalogProperty = Mockito.mock(CatalogProperty.class); - Mockito.when(catalogProperty.getMetastoreProperties()).thenReturn(jdbcMetaStoreProperties); - - PaimonExternalCatalog catalog = Mockito.mock(PaimonExternalCatalog.class); - Mockito.when(catalog.getCatalogProperty()).thenReturn(catalogProperty); - - PaimonSource source = Mockito.mock(PaimonSource.class); - Mockito.when(source.getCatalog()).thenReturn(catalog); - - PaimonScanNode node = newTestNode(new PlanNodeId(0), new TupleId(0), sv); - node.setSource(source); - - Map backendOptions = node.getBackendPaimonOptions(); - Assert.assertEquals("org.postgresql.Driver", backendOptions.get("jdbc.driver_class")); - Assert.assertEquals(driverUrl, backendOptions.get("jdbc.driver_url")); - Assert.assertEquals(2, backendOptions.size()); - } - - @Test - public void testGetBackendPaimonOptionsForJniIOManager() { - Map props = new HashMap<>(); - props.put("paimon.doris.enable_jni_io_manager", "true"); - props.put("paimon.doris.jni_io_manager.tmp_dir", "/tmp/doris-paimon"); - props.put("paimon.doris.jni_io_manager.impl_class", "org.example.CustomIOManager"); - - CatalogProperty catalogProperty = Mockito.mock(CatalogProperty.class); - Mockito.when(catalogProperty.getProperties()).thenReturn(props); - Mockito.when(catalogProperty.getMetastoreProperties()).thenReturn(Mockito.mock(MetastoreProperties.class)); - - PaimonExternalCatalog catalog = Mockito.mock(PaimonExternalCatalog.class); - Mockito.when(catalog.getCatalogProperty()).thenReturn(catalogProperty); - - PaimonSource source = Mockito.mock(PaimonSource.class); - Mockito.when(source.getCatalog()).thenReturn(catalog); - - PaimonScanNode node = newTestNode(new PlanNodeId(0), new TupleId(0), sv); - node.setSource(source); - - Map backendOptions = node.getBackendPaimonOptions(); - Assert.assertEquals("true", backendOptions.get("doris.enable_jni_io_manager")); - Assert.assertEquals("/tmp/doris-paimon", backendOptions.get("doris.jni_io_manager.tmp_dir")); - Assert.assertEquals("org.example.CustomIOManager", - backendOptions.get("doris.jni_io_manager.impl_class")); - Assert.assertEquals(3, backendOptions.size()); - } - - @Test - public void testApplyBackendPaimonOptionsAtScanNodeLevel() throws Exception { - PaimonScanNode node = newTestNode(new PlanNodeId(0), new TupleId(0), sv); - PaimonSource source = Mockito.mock(PaimonSource.class); - Mockito.when(source.getTableLocation()).thenReturn("file:///warehouse"); - Table paimonTable = mockPaimonTableWithPartitionKeys(Collections.emptyList()); - Mockito.when(source.getPaimonTable()).thenReturn(paimonTable); - node.setSource(source); - - Map backendOptions = new HashMap<>(); - backendOptions.put("jdbc.driver_url", "file:///tmp/postgresql-42.5.0.jar"); - backendOptions.put("jdbc.driver_class", "org.postgresql.Driver"); - setField(FileQueryScanNode.class, node, "params", new TFileScanRangeParams()); - setField(PaimonScanNode.class, node, "backendPaimonOptions", backendOptions); - setField(PaimonScanNode.class, node, "storagePropertiesMap", Collections.emptyMap()); - - invokePrivateMethod(node, "setScanLevelPaimonOptions"); - - Assert.assertEquals(backendOptions, node.getFileScanRangeParams().getPaimonOptions()); - - TFileRangeDesc rangeDesc = new TFileRangeDesc(); - invokePrivateMethod(node, "setPaimonParams", - new Class[] {TFileRangeDesc.class, PaimonSplit.class}, - rangeDesc, new PaimonSplit(createDataSplit("scan_level.parquet"))); - Assert.assertFalse(rangeDesc.getTableFormatParams().getPaimonParams().isSetPaimonOptions()); - } - - @Test - public void testGetPathPartitionKeysReturnsTablePartitionKeys() throws Exception { - PaimonScanNode node = newTestNode(new PlanNodeId(0), new TupleId(0), sv); - PaimonSource source = Mockito.mock(PaimonSource.class); - Table table = Mockito.mock(Table.class); - PaimonSysExternalTable sysTable = Mockito.mock(PaimonSysExternalTable.class); - Mockito.when(source.getPaimonTable()).thenReturn(table); - Mockito.when(source.getExternalTable()).thenReturn(sysTable); - Mockito.when(table.partitionKeys()).thenReturn(Arrays.asList("Dt", "Region")); - Mockito.when(sysTable.isDataTable()).thenReturn(true); - node.setSource(source); - - Assert.assertEquals(Arrays.asList("Dt", "Region"), node.getPathPartitionKeys()); - } - - @Test - public void testGetPathPartitionKeysReturnsEmptyForMetadataSystemTable() throws Exception { - PaimonScanNode node = newTestNode(new PlanNodeId(0), new TupleId(0), sv); - PaimonSource source = Mockito.mock(PaimonSource.class); - PaimonSysExternalTable sysTable = Mockito.mock(PaimonSysExternalTable.class); - Mockito.when(source.getExternalTable()).thenReturn(sysTable); - Mockito.when(sysTable.isDataTable()).thenReturn(false); - node.setSource(source); - - Assert.assertEquals(Collections.emptyList(), node.getPathPartitionKeys()); - } - - @Test - public void testSetPaimonParamsUsesOrderedPartitionKeys() throws Exception { - PaimonScanNode node = newTestNode(new PlanNodeId(0), new TupleId(0), sv); - PaimonSource source = Mockito.mock(PaimonSource.class); - Table table = Mockito.mock(Table.class); - PaimonSysExternalTable sysTable = Mockito.mock(PaimonSysExternalTable.class); - Mockito.when(source.getPaimonTable()).thenReturn(table); - Mockito.when(source.getTableLocation()).thenReturn("file:///warehouse"); - Mockito.when(source.getExternalTable()).thenReturn(sysTable); - Mockito.when(sysTable.isDataTable()).thenReturn(true); - Mockito.when(table.partitionKeys()).thenReturn(Arrays.asList("Pt", "Dt")); - node.setSource(source); - - TFileRangeDesc rangeDesc = new TFileRangeDesc(); - rangeDesc.setColumnsFromPathKeys(Collections.singletonList("stale")); - rangeDesc.setColumnsFromPath(Collections.singletonList("old")); - rangeDesc.setColumnsFromPathIsNull(Collections.singletonList(false)); - Map partitionValues = new HashMap<>(); - partitionValues.put("Dt", "2025-01-01"); - partitionValues.put("Pt", "p1"); - PaimonSplit split = new PaimonSplit(createDataSplit("ordered.parquet")); - split.setPaimonPartitionValues(partitionValues); - - invokePrivateMethod(node, "setPaimonParams", - new Class[] {TFileRangeDesc.class, PaimonSplit.class}, rangeDesc, split); - - Assert.assertEquals(Arrays.asList("Pt", "Dt"), rangeDesc.getColumnsFromPathKeys()); - Assert.assertEquals(Arrays.asList("p1", "2025-01-01"), rangeDesc.getColumnsFromPath()); - Assert.assertEquals(Arrays.asList(false, false), rangeDesc.getColumnsFromPathIsNull()); - } - - @Test - public void testGetFieldIndexMatchesMixedCaseColumns() { - List fieldNames = Arrays.asList("data", "mIxEd_COL", "PART"); - - Assert.assertEquals(1, PaimonScanNode.getFieldIndex(fieldNames, "mixed_col")); - Assert.assertEquals(2, PaimonScanNode.getFieldIndex(fieldNames, "part")); - Assert.assertEquals(-1, PaimonScanNode.getFieldIndex(fieldNames, "missing_col")); - } - - private void mockJniReader(PaimonScanNode spyNode) { - Mockito.doReturn(false).when(spyNode).supportNativeReader(ArgumentMatchers.any(Optional.class)); - } - - private void mockNativeReader(PaimonScanNode spyNode) { - Mockito.doReturn(true).when(spyNode).supportNativeReader(ArgumentMatchers.any(Optional.class)); - } - - private PaimonScanNode newTestNode(PlanNodeId planNodeId, TupleId tupleId, SessionVariable sessionVariable) { - TupleDescriptor desc = new TupleDescriptor(tupleId); - PaimonExternalTable externalTable = Mockito.mock(PaimonExternalTable.class); - Table paimonTable = mockPaimonTableWithPartitionKeys(Collections.emptyList()); - Mockito.when(externalTable.getPaimonTable(ArgumentMatchers.any())).thenReturn(paimonTable); - desc.setTable(externalTable); - return new PaimonScanNode(planNodeId, desc, false, sessionVariable, ScanContext.EMPTY); - } - - private PaimonSource mockPaimonSourceWithPartitionKeys(List partitionKeys) { - PaimonSource source = Mockito.mock(PaimonSource.class); - Table paimonTable = mockPaimonTableWithPartitionKeys(partitionKeys); - Mockito.when(source.getPaimonTable()).thenReturn(paimonTable); - return source; - } - - private Table mockPaimonTableWithPartitionKeys(List partitionKeys) { - Table paimonTable = Mockito.mock(Table.class); - Mockito.when(paimonTable.partitionKeys()).thenReturn(partitionKeys); - return paimonTable; - } - - private void setField(Class clazz, Object target, String fieldName, Object value) throws Exception { - java.lang.reflect.Field field = clazz.getDeclaredField(fieldName); - field.setAccessible(true); - field.set(target, value); - } - - private Object invokePrivateMethod(Object target, String methodName, Class[] parameterTypes, Object... args) - throws Exception { - Method method = target.getClass().getDeclaredMethod(methodName, parameterTypes); - method.setAccessible(true); - return method.invoke(target, args); - } - - private Object invokePrivateMethod(Object target, String methodName) throws Exception { - return invokePrivateMethod(target, methodName, new Class[0]); - } - - private DataSplit createDataSplit(String fileName) { - DataFileMeta dataFileMeta = DataFileMeta.forAppend(fileName, 64L * 1024 * 1024, 1L, SimpleStats.EMPTY_STATS, - 1L, 1L, 1L, Collections.emptyList(), null, FileSource.APPEND, - Collections.emptyList(), null, null, Collections.emptyList()); - return DataSplit.builder() - .rawConvertible(true) - .withPartition(BinaryRow.singleColumn(1)) - .withBucket(1) - .withBucketPath("file://b1") - .withDataFiles(Collections.singletonList(dataFileMeta)) - .build(); - } - - private DataSplit mockCountDataSplit(String fileName, long rowCount) { - DataFileMeta dataFileMeta = DataFileMeta.forAppend(fileName, 64L * 1024 * 1024, rowCount, - SimpleStats.EMPTY_STATS, 1L, 1L, 1L, Collections.emptyList(), null, - FileSource.APPEND, Collections.emptyList(), null, null, - Collections.emptyList()); - DataSplit dataSplit = Mockito.mock(DataSplit.class); - Mockito.when(dataSplit.rowCount()).thenReturn(rowCount); - Mockito.when(dataSplit.mergedRowCountAvailable()).thenReturn(true); - Mockito.when(dataSplit.mergedRowCount()).thenReturn(rowCount); - Mockito.when(dataSplit.partition()).thenReturn(BinaryRow.singleColumn(1)); - Mockito.when(dataSplit.dataFiles()).thenReturn(Collections.singletonList(dataFileMeta)); - Mockito.when(dataSplit.convertToRawFiles()).thenReturn(Optional.empty()); - Mockito.when(dataSplit.deletionFiles()).thenReturn(Optional.empty()); - return dataSplit; - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/CatalogStatementTransactionTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/CatalogStatementTransactionTest.java new file mode 100644 index 00000000000000..385f43c2caced9 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/CatalogStatementTransactionTest.java @@ -0,0 +1,128 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorWriteOps; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.transaction.PluginDrivenTransactionManager; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * Pins {@link CatalogStatementTransaction}, the per-statement owner of a plugin-driven write transaction: + * {@link CatalogStatementTransaction#begin} mints the transaction from the statement's shared write-ops and + * registers it with the manager, and the statement-end backstop + * ({@link CatalogStatementTransaction#finalizeAtStatementEnd}) rolls back only a genuinely orphaned + * transaction (mid-flight abort) and NEVER undoes one the executor already committed or rolled back. + */ +public class CatalogStatementTransactionTest { + + private static ConnectorTransaction tx(long id) { + ConnectorTransaction tx = Mockito.mock(ConnectorTransaction.class); + Mockito.when(tx.getTransactionId()).thenReturn(id); + return tx; + } + + private static ConnectorWriteOps writeOpsReturning(ConnectorTransaction tx) { + ConnectorWriteOps ops = Mockito.mock(ConnectorWriteOps.class); + Mockito.when(ops.beginTransaction(Mockito.any(), Mockito.any())).thenReturn(tx); + return ops; + } + + private static CatalogStatementTransaction holder(ConnectorTransaction tx, PluginDrivenTransactionManager mgr) { + return new CatalogStatementTransaction(writeOpsReturning(tx), Mockito.mock(ConnectorSession.class), mgr); + } + + @Test + public void beginMintsFromWriteOpsAndRegistersWithManager() { + // begin() opens the transaction from the statement's ONE shared metadata (writeOps) and registers it, so + // the write inherits the read arm's client/ops and the BE RPC can look it up by id. MUTATION: not + // registering -> isActive false -> red. + PluginDrivenTransactionManager mgr = new PluginDrivenTransactionManager(); + ConnectorTransaction tx = tx(80001L); + CatalogStatementTransaction holder = holder(tx, mgr); + + ConnectorTransaction opened = holder.begin(new ConnectorTableHandle() { }); + + Assertions.assertSame(tx, opened, "begin returns the transaction minted from the shared write-ops"); + Assertions.assertEquals(80001L, holder.getTransactionId(), "the holder stamps the connector txn id"); + Assertions.assertTrue(mgr.isActive(80001L), "the transaction is registered active with the manager"); + } + + @Test + public void finalizeRollsBackAnOrphanedTransaction() { + // A statement aborted mid-flight leaves the transaction active (the executor reached neither commit nor + // rollback). The statement-end backstop rolls it back. MUTATION: skipping the rollback -> the orphan + // leaks its resources -> this verify fails. + PluginDrivenTransactionManager mgr = new PluginDrivenTransactionManager(); + ConnectorTransaction tx = tx(80002L); + CatalogStatementTransaction holder = holder(tx, mgr); + holder.begin(new ConnectorTableHandle() { }); + + holder.finalizeAtStatementEnd(); + + Mockito.verify(tx).rollback(); + Assertions.assertFalse(mgr.isActive(80002L), "the orphan is deregistered after the backstop rollback"); + } + + @Test + public void finalizeNeverUndoesACommittedTransaction() throws Exception { + // THE safety property: on the normal path the executor commits (removing the txn from the manager), so + // the statement-end backstop must find nothing active and NOT roll back -- otherwise it would undo a + // durably committed write. MUTATION: dropping the isActive guard -> finalize rolls back a committed txn. + PluginDrivenTransactionManager mgr = new PluginDrivenTransactionManager(); + ConnectorTransaction tx = tx(80003L); + CatalogStatementTransaction holder = holder(tx, mgr); + holder.begin(new ConnectorTableHandle() { }); + mgr.commit(80003L); // the executor's onComplete path finished it + + holder.finalizeAtStatementEnd(); + + Mockito.verify(tx).commit(); + Mockito.verify(tx, Mockito.never()).rollback(); + } + + @Test + public void finalizeIsANoOpAfterRollback() throws Exception { + // The executor's onFail already rolled back; the backstop must be idempotent -- roll back once, not + // twice. MUTATION: dropping the isActive guard -> a second rollback -> red. + PluginDrivenTransactionManager mgr = new PluginDrivenTransactionManager(); + ConnectorTransaction tx = tx(80004L); + CatalogStatementTransaction holder = holder(tx, mgr); + holder.begin(new ConnectorTableHandle() { }); + mgr.rollback(80004L); // the executor's onFail path finished it + + holder.finalizeAtStatementEnd(); + + Mockito.verify(tx, Mockito.times(1)).rollback(); + } + + @Test + public void finalizeIsANoOpWhenNoTransactionWasEverOpened() { + // The empty-insert path never calls begin(); a stray finalize must not touch the manager or NPE. + PluginDrivenTransactionManager mgr = new PluginDrivenTransactionManager(); + CatalogStatementTransaction holder = holder(tx(80005L), mgr); + + Assertions.assertEquals(CatalogStatementTransaction.INVALID_TXN_ID, holder.getTransactionId()); + Assertions.assertDoesNotThrow(holder::finalizeAtStatementEnd); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogCacheTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogCacheTest.java new file mode 100644 index 00000000000000..997d388e5a51cb --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogCacheTest.java @@ -0,0 +1,93 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.catalog.Env; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.datasource.ExternalMetaCacheMgr; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.HashMap; +import java.util.Map; + +/** + * Pins {@link PluginDrivenExternalCatalog#onRefreshCache} (H-5): {@code REFRESH CATALOG} with cache + * invalidation must also drop the connector's OWN caches (e.g. the iceberg latest-snapshot cache, default TTL + * 24h), which the base engine route resolver never reaches for a plugin catalog. {@code REFRESH CATALOG} does + * not rebuild the connector (only {@code ADD}/{@code MODIFY CATALOG} does), so this override is the only thing + * that drops the connector caches on refresh. + */ +public class PluginDrivenExternalCatalogCacheTest { + + private static PluginDrivenExternalCatalog catalogWith(Connector connector) { + Map props = new HashMap<>(); + props.put("type", "iceberg"); + return new PluginDrivenExternalCatalog(1L, "test_ctl", null, props, "", connector); + } + + @Test + public void refreshCatalogWithInvalidateDropsConnectorCaches() { + Connector connector = Mockito.mock(Connector.class); + PluginDrivenExternalCatalog catalog = catalogWith(connector); + Env env = Mockito.mock(Env.class); + ExternalMetaCacheMgr cacheMgr = Mockito.mock(ExternalMetaCacheMgr.class); + Mockito.when(env.getExtMetaCacheMgr()).thenReturn(cacheMgr); + try (MockedStatic envStatic = Mockito.mockStatic(Env.class)) { + envStatic.when(Env::getCurrentEnv).thenReturn(env); + catalog.onRefreshCache(true); + } + // H-5 has TWO halves; pin both. (a) the base engine invalidation must STILL run: super.onRefreshCache(true) + // -> Env...getExtMetaCacheMgr().invalidateCatalog(id) flushes the engine route-resolver/schema cache for + // this plugin catalog. MUTATION: dropping the super.onRefreshCache(...) delegation -> verify fails. + Mockito.verify(cacheMgr).invalidateCatalog(1L); + // (b) the connector's OWN caches must be dropped too (the part the base path never reaches). MUTATION: + // removing connector.invalidateAll() -> the connector keeps serving stale snapshots up to 24h -> fails. + Mockito.verify(connector, Mockito.times(1)).invalidateAll(); + } + + @Test + public void refreshCatalogWithoutInvalidateDoesNotTouchConnector() { + Connector connector = Mockito.mock(Connector.class); + PluginDrivenExternalCatalog catalog = catalogWith(connector); + Env env = Mockito.mock(Env.class); + try (MockedStatic envStatic = Mockito.mockStatic(Env.class)) { + envStatic.when(Env::getCurrentEnv).thenReturn(env); + // invalidCache=false (the plain "reload metadata, keep caches" refresh) must NOT drop connector + // caches. MUTATION: dropping the invalidCache guard -> connector cleared unconditionally -> fails. + catalog.onRefreshCache(false); + } + Mockito.verify(connector, Mockito.never()).invalidateAll(); + } + + @Test + public void refreshCatalogWithNullConnectorIsSafe() { + // resetToUninitialized() nulls the connector (onClose) BEFORE calling onRefreshCache, and an + // uninitialized catalog has no connector yet. The override must be a safe no-op, not an NPE. + PluginDrivenExternalCatalog catalog = catalogWith(null); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getExtMetaCacheMgr()).thenReturn(Mockito.mock(ExternalMetaCacheMgr.class)); + try (MockedStatic envStatic = Mockito.mockStatic(Env.class)) { + envStatic.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertDoesNotThrow(() -> catalog.onRefreshCache(true)); + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/PluginDrivenExternalCatalogConcurrencyTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogConcurrencyTest.java similarity index 99% rename from fe/fe-core/src/test/java/org/apache/doris/datasource/PluginDrivenExternalCatalogConcurrencyTest.java rename to fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogConcurrencyTest.java index 5c2fc4c5f8bd8d..f0f11d42f385dd 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/PluginDrivenExternalCatalogConcurrencyTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogConcurrencyTest.java @@ -15,11 +15,12 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.plugin; import org.apache.doris.connector.api.Connector; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.datasource.SessionContext; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; 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 new file mode 100644 index 00000000000000..3f7a262680fcf2 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogDdlRoutingTest.java @@ -0,0 +1,1389 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.Env; +import org.apache.doris.catalog.RefreshManager; +import org.apache.doris.catalog.Type; +import org.apache.doris.catalog.constraint.ConstraintManager; +import org.apache.doris.catalog.info.BranchOptions; +import org.apache.doris.catalog.info.ColumnPosition; +import org.apache.doris.catalog.info.CreateOrReplaceBranchInfo; +import org.apache.doris.catalog.info.CreateOrReplaceTagInfo; +import org.apache.doris.catalog.info.DropBranchInfo; +import org.apache.doris.catalog.info.DropTagInfo; +import org.apache.doris.catalog.info.TableNameInfo; +import org.apache.doris.catalog.info.TagOptions; +import org.apache.doris.common.DdlException; +import org.apache.doris.common.ErrorCode; +import org.apache.doris.common.UserException; +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.DorisConnectorException; +import org.apache.doris.connector.api.ddl.BranchChange; +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.ConnectorTableHandle; +import org.apache.doris.connector.ddl.CreateTableInfoToConnectorRequestConverter; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.log.ExternalObjectLog; +import org.apache.doris.nereids.trees.plans.commands.info.AddPartitionFieldOp; +import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo; +import org.apache.doris.nereids.trees.plans.commands.info.DropPartitionFieldOp; +import org.apache.doris.nereids.trees.plans.commands.info.ReplacePartitionFieldOp; +import org.apache.doris.persist.EditLog; + +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.MockedStatic; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Tests for {@link PluginDrivenExternalCatalog}'s DDL overrides (createDb / dropDb / + * dropTable) added by P4-T06c, and the cache-invalidation fix to the existing + * createTable override. + * + *

    Why these tests matter: after the MaxCompute SPI cutover (T06b), a + * {@code max_compute} catalog is a {@link PluginDrivenExternalCatalog} whose + * {@code metadataOps} is always {@code null}. Without these overrides every DDL + * would hit the base class and throw "… is not supported for catalog". These tests + * lock in that DDL is routed to the connector SPI instead, that connector failures + * are surfaced as {@link DdlException} (caller contract), that the SPI's missing + * {@code ifNotExists}/{@code ifExists} semantics are enforced FE-side, and that the + * FE metadata cache is invalidated after each op so the change is visible on the + * same FE — exactly what the legacy {@code MaxComputeMetadataOps.afterX()} hooks did.

    + */ +public class PluginDrivenExternalCatalogDdlRoutingTest { + + private MockedStatic mockedEnv; + private EditLog mockEditLog; + private RefreshManager mockRefreshManager; + private ConstraintManager mockConstraintManager; + private Connector connector; + private ConnectorMetadata metadata; + private ConnectorSession session; + private TestablePluginCatalog catalog; + + @BeforeEach + public void setUp() { + connector = Mockito.mock(Connector.class); + metadata = Mockito.mock(ConnectorMetadata.class); + session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); + + // Construct with the real Env singleton (the constructor is Env-safe), then + // activate the static Env mock so the DDL overrides' edit-log writes are no-ops. + catalog = new TestablePluginCatalog(connector); + catalog.sessionMock = session; + + Env mockEnv = Mockito.mock(Env.class); + mockEditLog = Mockito.mock(EditLog.class); + mockRefreshManager = Mockito.mock(RefreshManager.class); + mockConstraintManager = Mockito.mock(ConstraintManager.class); + mockedEnv = Mockito.mockStatic(Env.class); + mockedEnv.when(Env::getCurrentEnv).thenReturn(mockEnv); + Mockito.when(mockEnv.getEditLog()).thenReturn(mockEditLog); + Mockito.when(mockEnv.getRefreshManager()).thenReturn(mockRefreshManager); + Mockito.when(mockEnv.getConstraintManager()).thenReturn(mockConstraintManager); + } + + @AfterEach + public void tearDown() { + if (mockedEnv != null) { + mockedEnv.close(); + } + } + + // ==================== CREATE DATABASE ==================== + + @Test + public void testCreateDbRoutesToConnectorAndInvalidatesCache() throws Exception { + Map props = new HashMap<>(); + props.put("k", "v"); + + catalog.createDb("db1", false, props); + + Mockito.verify(metadata).createDatabase(session, "db1", props); + Mockito.verify(mockEditLog).logCreateDb(Mockito.any()); + Assertions.assertEquals(1, catalog.resetMetaCacheNamesCount, + "createDb must invalidate the catalog db-name cache (legacy afterCreateDb parity)"); + } + + @Test + public void testCreateDbDoesNotInvalidateConnectorCache() throws Exception { + catalog.createDb("db1", false, new HashMap<>()); + + // WHY (Rule 9): createDb is DELIBERATELY not hooked to connector.invalidate* — a brand-new database + // has no table-keyed connector cache entries to clear that a prior dropDb did not already clear (the + // db-name-list is refreshed via resetMetaCacheNames, asserted above). This pins that scoping choice: + // a mutation that adds connector.invalidateDb/invalidateTable to createDb turns this red. + Mockito.verify(connector, Mockito.never()).invalidateDb(Mockito.any()); + Mockito.verify(connector, Mockito.never()).invalidateTable(Mockito.any(), Mockito.any()); + } + + @Test + public void testCreateDbIfNotExistsShortCircuitsWhenDbExists() throws Exception { + catalog.dbNullableResult = Mockito.mock(ExternalDatabase.class); + + catalog.createDb("db1", true, new HashMap<>()); + + Mockito.verify(metadata, Mockito.never()).createDatabase(Mockito.any(), Mockito.any(), Mockito.any()); + Mockito.verify(mockEditLog, Mockito.never()).logCreateDb(Mockito.any()); + Assertions.assertEquals(0, catalog.resetMetaCacheNamesCount); + } + + @Test + public void testCreateDbWrapsConnectorException() { + Mockito.doThrow(new DorisConnectorException("boom")) + .when(metadata).createDatabase(Mockito.any(), Mockito.any(), Mockito.any()); + + DdlException ex = Assertions.assertThrows(DdlException.class, + () -> catalog.createDb("db1", false, new HashMap<>())); + Assertions.assertTrue(ex.getMessage().contains("boom")); + } + + @Test + public void testCreateDbIfNotExistsSkipsWhenRemoteExistsAndConnectorSupportsCreate() 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<>()); + + // WHY (Rule 9): DG-4 regression -- a db that exists REMOTELY but is not yet in this FE's + // cache must make CREATE DATABASE IF NOT EXISTS a clean no-op (legacy createDbImpl consulted + // the remote databaseExist), NOT surface a remote "already exists" error. A mutation that + // removes the remote precheck calls createDatabase/logCreateDb -> these never() asserts red. + 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()); + Assertions.assertEquals(0, catalog.resetMetaCacheNamesCount); + } + + @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<>(); + + catalog.createDb("db1", true, props); + + // WHY: remote-absent must still create + editlog + cache reset -- proves the fix did not + // degrade IF NOT EXISTS into "never create". Paired with the test above (exists<->absent), + // this pins both sides of legacy createDbImpl's existence branch. + Mockito.verify(metadata).databaseExists(session, "db1"); + Mockito.verify(metadata).createDatabase(session, "db1", props); + Mockito.verify(mockEditLog).logCreateDb(Mockito.any()); + Assertions.assertEquals(1, catalog.resetMetaCacheNamesCount); + } + + @Test + public void testCreateDbIfNotExistsBypassesPrecheckWhenConnectorLacksCreateSupport() 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<>(); + + catalog.createDb("db1", true, props); + + // 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); + } + + // ==================== DROP DATABASE ==================== + + @Test + public void testDropDbRoutesToConnectorAndUnregisters() throws Exception { + ExternalDatabase db = mockExternalDatabase(); + Mockito.when(db.getRemoteName()).thenReturn("db1"); // non-mapped: LOCAL == REMOTE + catalog.dbNullableResult = db; + + catalog.dropDb("db1", false, false); + + Mockito.verify(metadata).dropDatabase(session, "db1", false, false); + Mockito.verify(mockEditLog).logDropDb(Mockito.any()); + Assertions.assertEquals("db1", catalog.unregisteredDb, + "dropDb must remove the db from the cache (legacy afterDropDb parity)"); + } + + @Test + public void testDropDbIfExistsWhenMissingIsNoop() throws Exception { + catalog.dbNullableResult = null; // db not present + + catalog.dropDb("missing", true, false); + + Mockito.verify(metadata, Mockito.never()) + .dropDatabase(Mockito.any(), Mockito.any(), Mockito.anyBoolean(), Mockito.anyBoolean()); + Assertions.assertNull(catalog.unregisteredDb); + } + + @Test + public void testDropDbMissingWithoutIfExistsThrows() { + catalog.dbNullableResult = null; + + Assertions.assertThrows(DdlException.class, () -> catalog.dropDb("missing", false, false)); + Mockito.verifyNoInteractions(metadata); + } + + @Test + public void testDropDbWrapsConnectorException() { + catalog.dbNullableResult = Mockito.mock(ExternalDatabase.class); + Mockito.doThrow(new DorisConnectorException("boom")) + .when(metadata).dropDatabase(Mockito.any(), Mockito.any(), Mockito.anyBoolean(), Mockito.anyBoolean()); + + DdlException ex = Assertions.assertThrows(DdlException.class, + () -> catalog.dropDb("db1", false, false)); + Assertions.assertTrue(ex.getMessage().contains("boom")); + } + + @Test + public void testDropDbForceForwardsForceTrueToConnector() throws Exception { + ExternalDatabase db = mockExternalDatabase(); + Mockito.when(db.getRemoteName()).thenReturn("db1"); // non-mapped: LOCAL == REMOTE + catalog.dbNullableResult = db; + + catalog.dropDb("db1", false, true); + + // WHY (Rule 9 / Rule 12): the regression (DG-3) is that the user's FORCE intent was + // silently dropped at the FE→SPI boundary, so DROP DB FORCE stopped cascading table + // drops. This asserts force=true actually reaches the connector. A mutation reverting + // PluginDrivenExternalCatalog.dropDb to the 3-arg / hardcoded-false call makes it red. + Mockito.verify(metadata).dropDatabase(session, "db1", false, true); + } + + @Test + public void testDropDbNonForceForwardsForceFalseToConnector() throws Exception { + ExternalDatabase db = mockExternalDatabase(); + Mockito.when(db.getRemoteName()).thenReturn("db1"); // non-mapped: LOCAL == REMOTE + catalog.dbNullableResult = db; + + catalog.dropDb("db1", false, false); + + // WHY: guards that the fix does NOT over-correct into always-cascading -- a plain + // (non-FORCE) DROP DB must forward force=false so the connector never deletes tables. + Mockito.verify(metadata).dropDatabase(session, "db1", false, false); + } + + @Test + public void testDropDbResolvesRemoteNameRoutesAndUnregisters() throws Exception { + // local "db1" maps to remote "REMOTE_DB1" (name mapping enabled). + ExternalDatabase db = mockExternalDatabase(); + Mockito.when(db.getRemoteName()).thenReturn("REMOTE_DB1"); + catalog.dbNullableResult = db; + + catalog.dropDb("db1", false, true); + + // WHY (Rule 9): the connector must receive the REMOTE db name so name-mapped catalogs hit the + // real remote namespace -- the regression forwarded the bare LOCAL "db1", which on a mapped + // catalog drops/cascades the wrong (or nonexistent) namespace. A mutation reverting to the + // local dbName makes this verify red. Mirrors the dropTable remote-name resolution. + Mockito.verify(metadata).dropDatabase(session, "REMOTE_DB1", false, true); + // WHY: edit log + cache invalidation MUST keep the LOCAL name -- followers replay the persisted + // DropDbInfo and the on-FE cache is keyed by local name. A mutation persisting the remote name + // into DropDbInfo / unregisterDatabase must turn these red. + ArgumentCaptor dropDbInfo = + ArgumentCaptor.forClass(org.apache.doris.persist.DropDbInfo.class); + Mockito.verify(mockEditLog).logDropDb(dropDbInfo.capture()); + Assertions.assertEquals("db1", dropDbInfo.getValue().getDbName(), + "edit-log DropDbInfo must carry the LOCAL db name for follower replay"); + Assertions.assertEquals("db1", catalog.unregisteredDb, + "cache invalidation must use the LOCAL db name"); + // WHY (Rule 9): the connector's own caches for every table in this db must be dropped on DROP + // DATABASE with the REMOTE db name (mirrors RefreshManager.refreshDbInternal). A mutation dropping + // the call — or passing the LOCAL "db1" — makes this red. + Mockito.verify(connector).invalidateDb("REMOTE_DB1"); + } + + // ==================== DROP TABLE ==================== + // FIX-DDL-REMOTE: dropTable now resolves the local db/table names to their REMOTE (ODPS) + // names (via getDbNullable + db.getTableNullable + getRemoteDbName/getRemoteName) before + // calling the connector, mirroring base ExternalCatalog.dropTable / legacy + // MaxComputeMetadataOps.dropTableImpl. Every drop test therefore stubs dbNullableResult and + // db.getTableNullable; edit log / cache invalidation still use the LOCAL names. + + @Test + public void testDropTableResolvesRemoteNamesRoutesAndUnregisters() throws Exception { + // local db1.t1 maps to remote DB1.TBL1 (name mapping enabled). + ExternalDatabase db = mockExternalDatabase(); // resolution db (getDbNullable) + ExternalTable table = Mockito.mock(ExternalTable.class); + Mockito.when(table.getRemoteDbName()).thenReturn("DB1"); + Mockito.when(table.getRemoteName()).thenReturn("TBL1"); + Mockito.doReturn(table).when(db).getTableNullable("t1"); + catalog.dbNullableResult = db; + // Distinct replay db: locks that cache invalidation uses the getDbForReplay lookup, NOT + // the resolution db (a refactor routing unregister through the resolution db must go red). + ExternalDatabase replayDb = mockExternalDatabase(); + catalog.dbForReplayResult = Optional.of(replayDb); + + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + Mockito.when(metadata.getTableHandle(session, "DB1", "TBL1")).thenReturn(Optional.of(handle)); + + catalog.dropTable("db1", "t1", false, false, false, false, false, false); + + // WHY: the connector must receive the REMOTE names so name-mapped catalogs hit the real + // ODPS object; a mutation that passes the local "db1"/"t1" makes this verify red. + Mockito.verify(metadata).getTableHandle(session, "DB1", "TBL1"); + Mockito.verify(metadata).dropTable(session, handle); + // WHY: edit log + cache invalidation MUST use the LOCAL names -- followers replay the + // persisted DropInfo and the on-FE cache is keyed by local name. A mutation building + // DropInfo / looking up getDbForReplay with the remote names must turn these red. + ArgumentCaptor dropInfo = + ArgumentCaptor.forClass(org.apache.doris.persist.DropInfo.class); + Mockito.verify(mockEditLog).logDropTable(dropInfo.capture()); + Assertions.assertEquals("db1", dropInfo.getValue().getDb(), + "edit-log DropInfo must carry the LOCAL db name for follower replay"); + Assertions.assertEquals("t1", dropInfo.getValue().getTableName(), + "edit-log DropInfo must carry the LOCAL table name for follower replay"); + Assertions.assertEquals("db1", catalog.lastGetDbForReplayArg, + "cache invalidation must look up the LOCAL db name"); + Mockito.verify(replayDb).unregisterTable("t1"); + Mockito.verify(db, Mockito.never()).unregisterTable(Mockito.anyString()); + // WHY (Rule 9): the connector's OWN caches (paimon/iceberg latest-snapshot pin, hive metastore + + // file-listing) must be dropped on DROP TABLE with the REMOTE names, so a subsequent same-name + // CREATE + read go live instead of serving the dropped table up to the connector TTL (the LIVE + // paimon/iceberg drop+recreate stale-pin fix). A mutation dropping the call — or passing the LOCAL + // "db1"/"t1" — makes this verify red. + Mockito.verify(connector).invalidateTable("DB1", "TBL1"); + } + + @Test + public void testDropTableMissingDbThrowsEvenWithIfExists() { + catalog.dbNullableResult = null; // db not present + + // WHY: mirror base ExternalCatalog.dropTable -- a missing db ALWAYS throws, even with + // IF EXISTS (only a missing TABLE honors IF EXISTS). A mutation that ifExists-gates the + // db==null branch makes this test red. + Assertions.assertThrows(DdlException.class, + () -> catalog.dropTable("missing", "t1", false, false, false, true, false, false)); + Mockito.verifyNoInteractions(metadata); + } + + @Test + public void testDropTableIfExistsWhenMissingTableIsNoop() throws Exception { + ExternalDatabase db = mockExternalDatabase(); + Mockito.doReturn(null).when(db).getTableNullable("missing"); + catalog.dbNullableResult = db; + + catalog.dropTable("db1", "missing", false, false, false, true, false, false); + + // Table missing + IF EXISTS => no-op; the connector is never even consulted. + Mockito.verifyNoInteractions(metadata); + Mockito.verify(mockEditLog, Mockito.never()).logDropTable(Mockito.any()); + } + + @Test + public void testDropTableMissingTableWithoutIfExistsThrows() { + ExternalDatabase db = mockExternalDatabase(); + Mockito.doReturn(null).when(db).getTableNullable("missing"); + catalog.dbNullableResult = db; + + Assertions.assertThrows(DdlException.class, + () -> catalog.dropTable("db1", "missing", false, false, false, false, false, false)); + Mockito.verifyNoInteractions(metadata); + } + + @Test + public void testDropTableHandleAbsentAfterLocalResolveIsNoopWithIfExists() throws Exception { + // FE cache has the table (resolves locally), but it was dropped out-of-band remotely: + // getTableHandle returns empty. IF EXISTS must still no-op. + ExternalDatabase db = mockExternalDatabase(); + ExternalTable table = Mockito.mock(ExternalTable.class); + Mockito.when(table.getRemoteDbName()).thenReturn("DB1"); + Mockito.when(table.getRemoteName()).thenReturn("TBL1"); + Mockito.doReturn(table).when(db).getTableNullable("t1"); + catalog.dbNullableResult = db; + Mockito.when(metadata.getTableHandle(session, "DB1", "TBL1")).thenReturn(Optional.empty()); + + catalog.dropTable("db1", "t1", false, false, false, true, false, false); + + Mockito.verify(metadata).getTableHandle(session, "DB1", "TBL1"); + Mockito.verify(metadata, Mockito.never()).dropTable(Mockito.any(), Mockito.any()); + Mockito.verify(mockEditLog, Mockito.never()).logDropTable(Mockito.any()); + } + + @Test + public void testDropTableHandleAbsentAfterLocalResolveThrowsWithoutIfExists() { + ExternalDatabase db = mockExternalDatabase(); + ExternalTable table = Mockito.mock(ExternalTable.class); + Mockito.when(table.getRemoteDbName()).thenReturn("DB1"); + Mockito.when(table.getRemoteName()).thenReturn("TBL1"); + Mockito.doReturn(table).when(db).getTableNullable("t1"); + catalog.dbNullableResult = db; + Mockito.when(metadata.getTableHandle(session, "DB1", "TBL1")).thenReturn(Optional.empty()); + + Assertions.assertThrows(DdlException.class, + () -> catalog.dropTable("db1", "t1", false, false, false, false, false, false)); + Mockito.verify(metadata, Mockito.never()).dropTable(Mockito.any(), Mockito.any()); + } + + @Test + public void testDropTableWrapsConnectorException() { + ExternalDatabase db = mockExternalDatabase(); + ExternalTable table = Mockito.mock(ExternalTable.class); + Mockito.when(table.getRemoteDbName()).thenReturn("DB1"); + Mockito.when(table.getRemoteName()).thenReturn("TBL1"); + Mockito.doReturn(table).when(db).getTableNullable("t1"); + catalog.dbNullableResult = db; + + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + Mockito.when(metadata.getTableHandle(session, "DB1", "TBL1")).thenReturn(Optional.of(handle)); + Mockito.doThrow(new DorisConnectorException("boom")) + .when(metadata).dropTable(session, handle); + + DdlException ex = Assertions.assertThrows(DdlException.class, + () -> catalog.dropTable("db1", "t1", false, false, false, false, false, false)); + Assertions.assertTrue(ex.getMessage().contains("boom")); + // WHY (Rule 9 / Rule 12): a remote drop failure must abort BEFORE the cache is invalidated — the + // invalidate sits AFTER the successful metadata.dropTable, so the connector cache stays consistent + // with the (unchanged) remote. A mutation moving invalidateTable ahead of the mutation goes red. + Mockito.verify(connector, Mockito.never()).invalidateTable(Mockito.any(), Mockito.any()); + } + + // B2: DROP on a flipped iceberg VIEW must route to metadata.dropView (mirroring legacy + // IcebergMetadataOps.dropTableImpl's viewExists -> performDropView dispatch). The connector's + // getTableHandle/tableExists is false for a view, so without this routing the handle path would no-op + // (IF EXISTS) / throw (no such table). Edit log + cache invalidation use the LOCAL names like the table path. + + @Test + public void testDropTableRoutesViewToDropViewAndUnregisters() throws Exception { + ExternalDatabase db = mockExternalDatabase(); + ExternalTable view = Mockito.mock(ExternalTable.class); + Mockito.when(view.getRemoteDbName()).thenReturn("DB1"); + Mockito.when(view.getRemoteName()).thenReturn("V1"); + Mockito.doReturn(view).when(db).getTableNullable("v1"); + catalog.dbNullableResult = db; + ExternalDatabase replayDb = mockExternalDatabase(); + catalog.dbForReplayResult = Optional.of(replayDb); + // The connector reports the (remote) object as a view. + Mockito.when(metadata.viewExists(session, "DB1", "V1")).thenReturn(true); + + catalog.dropTable("db1", "v1", false, false, false, false, false, false); + + // WHY: a view must be dropped via dropView with the REMOTE names, and the table-handle path must be + // skipped entirely (getTableHandle/dropTable never consulted) -- legacy dropTableImpl checks viewExists + // BEFORE resolving the table. A mutation that drops the routing makes the dropView verify red (and the + // getTableHandle would be reached on a non-existent table). + Mockito.verify(metadata).dropView(session, "DB1", "V1"); + Mockito.verify(metadata, Mockito.never()).getTableHandle(Mockito.any(), Mockito.any(), Mockito.any()); + Mockito.verify(metadata, Mockito.never()).dropTable(Mockito.any(), Mockito.any()); + // WHY: edit log + cache invalidation MUST use the LOCAL names (follower replay parity), identical to + // the table path. A mutation persisting the remote names turns these red. + ArgumentCaptor dropInfo = + ArgumentCaptor.forClass(org.apache.doris.persist.DropInfo.class); + Mockito.verify(mockEditLog).logDropTable(dropInfo.capture()); + Assertions.assertEquals("db1", dropInfo.getValue().getDb(), + "edit-log DropInfo must carry the LOCAL db name for follower replay"); + Assertions.assertEquals("v1", dropInfo.getValue().getTableName(), + "edit-log DropInfo must carry the LOCAL view name for follower replay"); + Assertions.assertEquals("db1", catalog.lastGetDbForReplayArg, + "cache invalidation must look up the LOCAL db name"); + Mockito.verify(replayDb).unregisterTable("v1"); + // The view branch drops the connector caches too (uniform with the table branch), keyed by REMOTE names. + Mockito.verify(connector).invalidateTable("DB1", "V1"); + } + + @Test + public void testDropViewWrapsConnectorException() { + ExternalDatabase db = mockExternalDatabase(); + ExternalTable view = Mockito.mock(ExternalTable.class); + Mockito.when(view.getRemoteDbName()).thenReturn("DB1"); + Mockito.when(view.getRemoteName()).thenReturn("V1"); + Mockito.doReturn(view).when(db).getTableNullable("v1"); + catalog.dbNullableResult = db; + Mockito.when(metadata.viewExists(session, "DB1", "V1")).thenReturn(true); + Mockito.doThrow(new DorisConnectorException("boom")).when(metadata).dropView(session, "DB1", "V1"); + + // WHY: a remote view-drop failure must surface as a DdlException (same as the table path) and abort + // BEFORE any bookkeeping -- no editlog, no unregister, so the FE cache stays consistent with the remote. + DdlException ex = Assertions.assertThrows(DdlException.class, + () -> catalog.dropTable("db1", "v1", false, false, false, false, false, false)); + Assertions.assertTrue(ex.getMessage().contains("boom")); + Mockito.verify(mockEditLog, Mockito.never()).logDropTable(Mockito.any()); + } + + // ==================== RENAME TABLE ==================== + // renameTable resolves the SOURCE by REMOTE names (like dropTable) and passes the new name through + // (legacy renameTableImpl parity); afterExternalRename does the cache fix (unregister old + reset names) + // + constraintManager rename + createForRenameTable editlog, all with LOCAL names for follower replay. + + @Test + public void testRenameTableResolvesRemoteSourceRoutesAndFixesCache() throws Exception { + // local db1.t1 maps to remote DB1.TBL1 (name mapping enabled). + ExternalDatabase db = mockExternalDatabase(); + ExternalTable table = Mockito.mock(ExternalTable.class); + Mockito.when(table.getRemoteDbName()).thenReturn("DB1"); + Mockito.when(table.getRemoteName()).thenReturn("TBL1"); + Mockito.doReturn(table).when(db).getTableNullable("t1"); + catalog.dbNullableResult = db; + // Distinct replay db: locks that the cache fix uses the getDbForReplay lookup (LOCAL name), not the + // resolution db. + ExternalDatabase replayDb = mockExternalDatabase(); + catalog.dbForReplayResult = Optional.of(replayDb); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + Mockito.when(metadata.getTableHandle(session, "DB1", "TBL1")).thenReturn(Optional.of(handle)); + + catalog.renameTable("db1", "t1", "t2"); + + // WHY: the connector must receive the REMOTE source names + the new name; a mutation passing the + // local "db1"/"t1" makes this verify red. + Mockito.verify(metadata).getTableHandle(session, "DB1", "TBL1"); + Mockito.verify(metadata).renameTable(session, handle, "t2"); + // WHY (Rule 9): cache fix + constraint + editlog MUST use LOCAL names (followers replay the + // createForRenameTable entry and the cache is keyed by local name). A mutation using remote names + // for the bookkeeping turns these red. + Assertions.assertEquals("db1", catalog.lastGetDbForReplayArg, + "cache fix must look up the LOCAL db name"); + Mockito.verify(replayDb).unregisterTable("t1"); + Mockito.verify(replayDb).resetMetaCacheNames(); + ArgumentCaptor oldName = ArgumentCaptor.forClass(TableNameInfo.class); + ArgumentCaptor newName = ArgumentCaptor.forClass(TableNameInfo.class); + Mockito.verify(mockConstraintManager).renameTable(oldName.capture(), newName.capture()); + Assertions.assertEquals("t1", oldName.getValue().getTbl()); + Assertions.assertEquals("t2", newName.getValue().getTbl()); + ArgumentCaptor logCap = ArgumentCaptor.forClass(ExternalObjectLog.class); + Mockito.verify(mockEditLog).logRefreshExternalTable(logCap.capture()); + Assertions.assertEquals("db1", logCap.getValue().getDbName()); + Assertions.assertEquals("t1", logCap.getValue().getTableName()); + Assertions.assertEquals("t2", logCap.getValue().getNewTableName()); + // WHY (Rule 9 / R4): the connector's own caches for BOTH the source (REMOTE DB1.TBL1) and the target + // (DB1.t2, new name NOT remote-resolved) must be dropped so an atomic swap (RENAME t->t_arch; + // RENAME t_new->t) doesn't serve the pre-rename pinned snapshot under either name. Before R4 + // afterExternalRename fixed only the FE name cache. MUTATION: dropping either call — or passing the + // LOCAL names — turns this red. + Mockito.verify(connector).invalidateTable("DB1", "TBL1"); + Mockito.verify(connector).invalidateTable("DB1", "t2"); + } + + @Test + public void testRenameTableMissingDbThrows() { + catalog.dbNullableResult = null; + + Assertions.assertThrows(DdlException.class, () -> catalog.renameTable("missing", "t1", "t2")); + Mockito.verifyNoInteractions(metadata); + } + + @Test + public void testRenameTableMissingTableThrows() { + ExternalDatabase db = mockExternalDatabase(); + Mockito.doReturn(null).when(db).getTableNullable("t1"); + catalog.dbNullableResult = db; + + Assertions.assertThrows(DdlException.class, () -> catalog.renameTable("db1", "t1", "t2")); + Mockito.verifyNoInteractions(metadata); + } + + @Test + public void testRenameTableWrapsConnectorExceptionAndSkipsBookkeeping() { + ExternalDatabase db = mockExternalDatabase(); + ExternalTable table = Mockito.mock(ExternalTable.class); + Mockito.when(table.getRemoteDbName()).thenReturn("DB1"); + Mockito.when(table.getRemoteName()).thenReturn("TBL1"); + Mockito.doReturn(table).when(db).getTableNullable("t1"); + catalog.dbNullableResult = db; + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + Mockito.when(metadata.getTableHandle(session, "DB1", "TBL1")).thenReturn(Optional.of(handle)); + Mockito.doThrow(new DorisConnectorException("boom")).when(metadata).renameTable(session, handle, "t2"); + + DdlException ex = Assertions.assertThrows(DdlException.class, + () -> catalog.renameTable("db1", "t1", "t2")); + Assertions.assertTrue(ex.getMessage().contains("boom")); + // WHY: a remote rename failure must abort BEFORE any bookkeeping (no editlog, no constraint rename), + // so the FE cache + constraints stay consistent with the unchanged remote. + Mockito.verify(mockEditLog, Mockito.never()).logRefreshExternalTable(Mockito.any()); + Mockito.verifyNoInteractions(mockConstraintManager); + // WHY (R4): the connector-cache invalidation sits AFTER the successful renameTable, so a remote + // failure must NOT drop the connector cache — it stays consistent with the unchanged remote. + Mockito.verify(connector, Mockito.never()).invalidateTable(Mockito.any(), Mockito.any()); + } + + // ==================== CREATE TABLE ==================== + // FIX-DDL-REMOTE: createTable now resolves the local db name to its REMOTE (ODPS) name (via + // getDbNullable + db.getRemoteName()) and passes THAT to the converter; the table name is + // intentionally NOT remote-resolved (legacy parity). Edit log / cache invalidation still use + // the local names. + + @Test + public void testCreateTablePassesRemoteDbNameToConverter() throws UserException { + // local db1 maps to remote DB1. + ExternalDatabase db = mockExternalDatabase(); + Mockito.when(db.getRemoteName()).thenReturn("DB1"); + catalog.dbNullableResult = db; + catalog.dbForReplayResult = Optional.of(db); + + try (MockedStatic conv = + Mockito.mockStatic(CreateTableInfoToConnectorRequestConverter.class)) { + ConnectorCreateTableRequest req = Mockito.mock(ConnectorCreateTableRequest.class); + conv.when(() -> CreateTableInfoToConnectorRequestConverter.convert(Mockito.any(), Mockito.any())) + .thenReturn(req); + CreateTableInfo info = Mockito.mock(CreateTableInfo.class); + Mockito.when(info.getDbName()).thenReturn("db1"); + Mockito.when(info.getTableName()).thenReturn("t1"); + + catalog.createTable(info); + + // WHY: the converter (and thus the connector) must receive the REMOTE db name "DB1", + // not the local "db1", so name-mapped catalogs address the real ODPS schema. We assert + // on the SECOND argument actually passed to convert() -- NOT on req.getDbName(), which + // would be vacuous here because the converter is mocked and returns a stub unaffected + // by the dbName argument. A mutation that passes info.getDbName() makes this red. + conv.verify(() -> CreateTableInfoToConnectorRequestConverter.convert(info, "DB1")); + } + } + + @Test + public void testCreateTableMissingDbThrows() { + catalog.dbNullableResult = null; // db not present + CreateTableInfo info = Mockito.mock(CreateTableInfo.class); + Mockito.when(info.getDbName()).thenReturn("missing"); + + Assertions.assertThrows(DdlException.class, () -> catalog.createTable(info)); + Mockito.verifyNoInteractions(metadata); + } + + @Test + public void testCreateTableInvalidatesDbCacheUsingLocalNames() throws UserException { + // remote DB1 != local db1, so the LOCAL-name assertions below are meaningful. + ExternalDatabase db = mockExternalDatabase(); + Mockito.when(db.getRemoteName()).thenReturn("DB1"); + catalog.dbNullableResult = db; + ExternalDatabase replayDb = mockExternalDatabase(); + catalog.dbForReplayResult = Optional.of(replayDb); + + try (MockedStatic conv = + Mockito.mockStatic(CreateTableInfoToConnectorRequestConverter.class)) { + ConnectorCreateTableRequest req = Mockito.mock(ConnectorCreateTableRequest.class); + conv.when(() -> CreateTableInfoToConnectorRequestConverter.convert(Mockito.any(), Mockito.any())) + .thenReturn(req); + CreateTableInfo info = Mockito.mock(CreateTableInfo.class); + Mockito.when(info.getDbName()).thenReturn("db1"); + Mockito.when(info.getTableName()).thenReturn("t1"); + + catalog.createTable(info); + + Mockito.verify(metadata).createTable(session, req); + // WHY: edit log MUST carry the LOCAL names (followers replay this persist entry), even + // though the connector got the remote "DB1". A mutation persisting db.getRemoteName() + // must turn these red. + ArgumentCaptor persist = + ArgumentCaptor.forClass(org.apache.doris.persist.CreateTableInfo.class); + Mockito.verify(mockEditLog).logCreateTable(persist.capture()); + Assertions.assertEquals("db1", persist.getValue().getDbName(), + "edit-log CreateTableInfo must carry the LOCAL db name for follower replay"); + Assertions.assertEquals("t1", persist.getValue().getTblName(), + "edit-log CreateTableInfo must carry the LOCAL table name for follower replay"); + // Cache invalidation must look up the LOCAL db name and act on the replay db. + Assertions.assertEquals("db1", catalog.lastGetDbForReplayArg, + "cache invalidation must look up the LOCAL db name"); + Mockito.verify(replayDb).resetMetaCacheNames(); + // WHY (Rule 9): CREATE TABLE also drops any stale connector cache entry for the new name + // (belt-and-suspenders with the DROP path). Keyed by the REMOTE db name "DB1" but the + // (non-remote-resolved) table name "t1" — a mutation flipping the table name to remote, or + // dropping the call, makes this red. + Mockito.verify(connector).invalidateTable("DB1", "t1"); + } + } + + @Test + public void testCreateTableIfNotExistsExistingRemoteTableReturnsTrueAndSkipsSideEffects() throws Exception { + ExternalDatabase db = mockExternalDatabase(); + Mockito.when(db.getRemoteName()).thenReturn("DB1"); + catalog.dbNullableResult = db; + // Distinct replay db: production resets the cache via getDbForReplay(...).resetMetaCacheNames() + // on the REPLAY db object (NOT catalog.resetMetaCacheNames()), so we must assert on it. + ExternalDatabase replayDb = mockExternalDatabase(); + catalog.dbForReplayResult = Optional.of(replayDb); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + Mockito.when(metadata.getTableHandle(session, "DB1", "t1")).thenReturn(Optional.of(handle)); + CreateTableInfo info = Mockito.mock(CreateTableInfo.class); + Mockito.when(info.getDbName()).thenReturn("db1"); + Mockito.when(info.getTableName()).thenReturn("t1"); + Mockito.when(info.isIfNotExists()).thenReturn(true); + + boolean res = catalog.createTable(info); + + // WHY (Rule 9 / DG-6): returning false here makes CreateTableCommand:103 not short-circuit, + // so CTAS (CREATE TABLE IF NOT EXISTS ... AS SELECT) runs an INSERT into the pre-existing + // table -- a SILENT DATA CHANGE. The fix must return true and skip create/editlog/cache-reset. + Assertions.assertTrue(res, + "IF NOT EXISTS on an existing table must return true so CTAS short-circuits (no INSERT)"); + Mockito.verify(metadata, Mockito.never()).createTable(Mockito.any(), Mockito.any()); + Mockito.verify(mockEditLog, Mockito.never()).logCreateTable(Mockito.any()); + Mockito.verify(replayDb, Mockito.never()).resetMetaCacheNames(); + } + + @Test + public void testCreateTableIfNotExistsExistingLocalTableReturnsTrue() throws Exception { + // Remote says absent (getTableHandle empty) but the FE cache HAS it -- the local arm of the + // legacy OR (createTableImpl:189, the case-sensitivity / stale-remote guard). + ExternalDatabase db = mockExternalDatabase(); + Mockito.when(db.getRemoteName()).thenReturn("DB1"); + Mockito.doReturn(Mockito.mock(ExternalTable.class)).when(db).getTableNullable("t1"); + catalog.dbNullableResult = db; + Mockito.when(metadata.getTableHandle(session, "DB1", "t1")).thenReturn(Optional.empty()); + CreateTableInfo info = Mockito.mock(CreateTableInfo.class); + Mockito.when(info.getDbName()).thenReturn("db1"); + Mockito.when(info.getTableName()).thenReturn("t1"); + Mockito.when(info.isIfNotExists()).thenReturn(true); + + boolean res = catalog.createTable(info); + + // WHY: legacy checks BOTH remote AND local; this pins the local arm so a refactor that drops + // the `|| db.getTableNullable(...) != null` probe (keeping only getTableHandle) goes red. + Assertions.assertTrue(res, "existing local table + IF NOT EXISTS must return true"); + Mockito.verify(metadata, Mockito.never()).createTable(Mockito.any(), Mockito.any()); + Mockito.verify(mockEditLog, Mockito.never()).logCreateTable(Mockito.any()); + } + + @Test + public void testCreateTableExistingRemoteTableWithoutIfNotExistsReportsErrno1050() { + // FIX-R1-TABLE: a table that exists REMOTELY but is absent from this FE's cache (stale cache / + // other-FE / external create), created without IF NOT EXISTS, must be rejected with MySQL errno + // 1050 (ERR_TABLE_EXISTS_ERROR / SQLSTATE 42S01) -- legacy parity ({Paimon,MaxCompute}MetadataOps + // both report 1050 for the remote arm). The connector is NOT consulted (the FE short-circuits). + ExternalDatabase db = mockExternalDatabase(); + Mockito.when(db.getRemoteName()).thenReturn("DB1"); + catalog.dbNullableResult = db; + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + Mockito.when(metadata.getTableHandle(session, "DB1", "t1")).thenReturn(Optional.of(handle)); + CreateTableInfo info = Mockito.mock(CreateTableInfo.class); + Mockito.when(info.getDbName()).thenReturn("db1"); + Mockito.when(info.getTableName()).thenReturn("t1"); + Mockito.when(info.isIfNotExists()).thenReturn(false); + + // WHY (Rule 9 / Rule 12): pre-fix the bridge gated the 1050 report on localExists only, so a + // remote-ONLY conflict fell through to metadata.createTable and surfaced a GENERIC DdlException + // (errno ERR_UNKNOWN_ERROR = 0) -- silently dropping the documented MySQL 1050 contract some + // ORMs branch on. The fix reports 1050 at the FE before the connector. MUTATION: restoring the + // `if (localExists)` guard makes this remote-only case (localExists=false) fall through -> errno + // reverts to 0 and metadata.createTable IS called -> the errno assertion AND the never().createTable + // verify both go red. + DdlException ex = Assertions.assertThrows(DdlException.class, () -> catalog.createTable(info)); + Assertions.assertEquals(ErrorCode.ERR_TABLE_EXISTS_ERROR, ex.getMysqlErrorCode(), + "remote-existing table without IF NOT EXISTS must surface MySQL errno 1050 (legacy parity)"); + Assertions.assertTrue(ex.getMessage().contains("already exists")); + Mockito.verify(metadata, Mockito.never()).createTable(Mockito.any(), Mockito.any()); + Mockito.verify(mockEditLog, Mockito.never()).logCreateTable(Mockito.any()); + } + + @Test + public void testCreateTableLocalConflictWithoutIfNotExistsRejects() throws Exception { + // Remote says ABSENT (getTableHandle empty) but the FE cache HAS the table -- the local arm of the + // legacy remote-then-local probe (PaimonMetadataOps.performCreateTable:206-214). Under + // lower_case_meta_names a case-variant name folds onto an existing local table while the + // case-sensitive remote has no such table. Legacy throws ERR_TABLE_EXISTS_ERROR here; the bridge + // must NOT fall through to metadata.createTable, which would CREATE a duplicate remote table + // (silent metadata corruption). + ExternalDatabase db = mockExternalDatabase(); + Mockito.when(db.getRemoteName()).thenReturn("DB1"); + Mockito.doReturn(Mockito.mock(ExternalTable.class)).when(db).getTableNullable("t1"); + catalog.dbNullableResult = db; + Mockito.when(metadata.getTableHandle(session, "DB1", "t1")).thenReturn(Optional.empty()); + + try (MockedStatic conv = + Mockito.mockStatic(CreateTableInfoToConnectorRequestConverter.class)) { + ConnectorCreateTableRequest req = Mockito.mock(ConnectorCreateTableRequest.class); + conv.when(() -> CreateTableInfoToConnectorRequestConverter.convert(Mockito.any(), Mockito.any())) + .thenReturn(req); + CreateTableInfo info = Mockito.mock(CreateTableInfo.class); + Mockito.when(info.getDbName()).thenReturn("db1"); + Mockito.when(info.getTableName()).thenReturn("t1"); + Mockito.when(info.isIfNotExists()).thenReturn(false); + + // WHY (Rule 9 / Rule 12): a local-ONLY conflict without IF NOT EXISTS must be REJECTED at the FE + // level with MySQL errno 1050 (ERR_TABLE_EXISTS_ERROR), never handed to connector.createTable + // (which would create a duplicate remote table under lower_case_meta_names case-folding). Paired + // with testCreateTableExistingRemoteTableWithoutIfNotExistsReportsErrno1050, this pins that the + // existence rejection covers EITHER arm: MUTATION re-narrowing the report to the remote arm only + // (e.g. `if (remoteExists)`) lets this local-only case (remoteExists=false) fall through -> + // createTable called + errno reverts to ERR_UNKNOWN_ERROR -> the asserts below go red. + DdlException ex = Assertions.assertThrows(DdlException.class, () -> catalog.createTable(info)); + Assertions.assertEquals(ErrorCode.ERR_TABLE_EXISTS_ERROR, ex.getMysqlErrorCode(), + "local-cache conflict without IF NOT EXISTS must surface MySQL errno 1050"); + Assertions.assertTrue(ex.getMessage().contains("already exists")); + Mockito.verify(metadata, Mockito.never()).createTable(Mockito.any(), Mockito.any()); + Mockito.verify(mockEditLog, Mockito.never()).logCreateTable(Mockito.any()); + } + } + + // ==================== EDIT-LOG REPLAY (follower / observer propagation) ==================== + // R1: the coordinator dropTable/createTable/dropDb hooks drop the connector's OWN cache, but editlog + // replay on followers/observers went through the base ExternalCatalog.replay* plugin branch, which only + // touched the FE name cache — so a follower kept the dropped/renamed object's snapshot pin to the TTL. + // These overrides propagate connector.invalidate* on replay too, keyed like the coordinator, WITHOUT + // force-initializing a catalog (the getDbForReplay/getTableForReplay match is present only when already + // initialized on this FE). + + @Test + public void testReplayDropTableInvalidatesConnectorOnFollower() { + // local db1.t1 maps to remote DB1.TBL1; the table is still in the replay cache when the drop replays. + ExternalDatabase replayDb = mockExternalDatabase(); + Mockito.when(replayDb.getRemoteName()).thenReturn("DB1"); + ExternalTable cached = Mockito.mock(ExternalTable.class); + Mockito.when(cached.getRemoteName()).thenReturn("TBL1"); + Mockito.doReturn(Optional.of(cached)).when(replayDb).getTableForReplay("t1"); + catalog.dbForReplayResult = Optional.of(replayDb); + + catalog.replayDropTable("db1", "t1"); + + // WHY (Rule 9 / R1): without the override a follower kept the dropped table's latest-snapshot pin (and + // paimon schema memo) to the 24h TTL — the coordinator-only half of the drop+recreate fix. Replay must + // drop it, keyed by the REMOTE names resolved from the still-cached table BEFORE unregister. MUTATION: + // removing the override, or passing the LOCAL names, turns this red. + Mockito.verify(connector).invalidateTable("DB1", "TBL1"); + // Base bookkeeping is preserved: the table is still unregistered from the FE name cache. + Mockito.verify(replayDb).unregisterTable("t1"); + } + + @Test + public void testReplayDropTableUninitializedCatalogSkipsInvalidate() { + // A follower that never initialized this catalog: getDbForReplay returns empty -> no connector cache + // exists to drop, and the override must NOT force-initialize the catalog (no invalidate, no throw). + catalog.dbForReplayResult = Optional.empty(); + + catalog.replayDropTable("db1", "t1"); + + // WHY (R1 no-force-init): the connector is untouched when the catalog is not initialized on this FE. + // MUTATION: using getConnector() unconditionally (force-init) would invoke the connector here -> red. + Mockito.verify(connector, Mockito.never()).invalidateTable(Mockito.any(), Mockito.any()); + } + + @Test + public void testReplayDropDbInvalidatesConnectorOnFollower() { + ExternalDatabase replayDb = mockExternalDatabase(); + Mockito.when(replayDb.getRemoteName()).thenReturn("DB1"); + catalog.dbForReplayResult = Optional.of(replayDb); + + catalog.replayDropDb("db1"); + + // WHY (R1): DROP DATABASE replay must drop every table's connector cache for the db (keyed by the + // REMOTE db name), mirroring the coordinator dropDb hook. MUTATION: removing the override or passing + // the LOCAL "db1" turns this red. + Mockito.verify(connector).invalidateDb("DB1"); + // Base bookkeeping is preserved (the db is unregistered by the LOCAL name). + Assertions.assertEquals("db1", catalog.unregisteredDb); + } + + @Test + public void testReplayCreateTableInvalidatesConnectorOnFollower() { + ExternalDatabase replayDb = mockExternalDatabase(); + Mockito.when(replayDb.getRemoteName()).thenReturn("DB1"); + catalog.dbForReplayResult = Optional.of(replayDb); + + catalog.replayCreateTable("db1", "t1"); + + // WHY (R1): belt-and-suspenders parity with the coordinator createTable hook — keyed by the REMOTE db + // name "DB1" but the (non-remote-resolved) table name "t1". MUTATION: flipping the table name to remote + // or dropping the call turns this red. + Mockito.verify(connector).invalidateTable("DB1", "t1"); + // Base bookkeeping is preserved (the db's table-name cache is refreshed). + Mockito.verify(replayDb).resetMetaCacheNames(); + } + + // ==================== COLUMN EVOLUTION (B2) ==================== + // The 6 column-op overrides resolve the connector handle by REMOTE names (like dropTable), convert the + // Doris Column/ColumnPosition to the neutral SPI types, dispatch, wrap DorisConnectorException as + // DdlException, and run afterExternalDdl (editlog with LOCAL names + RefreshManager.refreshTableInternal + // re-resolving by REMOTE names). PluginDriven has no metadataOps, so without these overrides the base ops + // would throw "not supported". + + @Test + public void testAddColumnRoutesConvertsAndLogsRefresh() throws Exception { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + + catalog.addColumn(table, nullableIntColumn("age"), ColumnPosition.FIRST); + + ArgumentCaptor colCap = ArgumentCaptor.forClass(ConnectorColumn.class); + ArgumentCaptor posCap = ArgumentCaptor.forClass(ConnectorColumnPosition.class); + Mockito.verify(metadata).addColumn(Mockito.eq(session), Mockito.eq(handle), + colCap.capture(), posCap.capture()); + Assertions.assertEquals("age", colCap.getValue().getName()); + // WHY: position FIRST must be neutralized to ConnectorColumnPosition.FIRST (toConnectorPosition); a + // mutation dropping the isFirst() branch makes this red. + Assertions.assertTrue(posCap.getValue().isFirst()); + // WHY (Rule 9): the editlog MUST carry the LOCAL names for follower replay (base + // logRefreshExternalTable parity); a mutation persisting the remote names turns these red. + ArgumentCaptor logCap = ArgumentCaptor.forClass(ExternalObjectLog.class); + Mockito.verify(mockEditLog).logRefreshExternalTable(logCap.capture()); + Assertions.assertEquals("db1", logCap.getValue().getDbName()); + Assertions.assertEquals("t1", logCap.getValue().getTableName()); + } + + @Test + public void testAddColumnsRoutesConvertedList() throws Exception { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + + catalog.addColumns(table, Arrays.asList(nullableIntColumn("a"), nullableIntColumn("b"))); + + ArgumentCaptor> cap = ArgumentCaptor.forClass(java.util.List.class); + Mockito.verify(metadata).addColumns(Mockito.eq(session), Mockito.eq(handle), cap.capture()); + Assertions.assertEquals(2, cap.getValue().size()); + Assertions.assertEquals("a", cap.getValue().get(0).getName()); + Assertions.assertEquals("b", cap.getValue().get(1).getName()); + } + + @Test + public void testDropColumnRoutes() throws Exception { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + + catalog.dropColumn(table, "age"); + + Mockito.verify(metadata).dropColumn(session, handle, "age"); + Mockito.verify(mockEditLog).logRefreshExternalTable(Mockito.any()); + } + + @Test + public void testRenameColumnRoutes() throws Exception { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + + catalog.renameColumn(table, "old", "new"); + + Mockito.verify(metadata).renameColumn(session, handle, "old", "new"); + } + + @Test + public void testModifyColumnRoutesWithAfterPosition() throws Exception { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + + catalog.modifyColumn(table, nullableIntColumn("age"), new ColumnPosition("id")); + + ArgumentCaptor posCap = ArgumentCaptor.forClass(ConnectorColumnPosition.class); + Mockito.verify(metadata).modifyColumn(Mockito.eq(session), Mockito.eq(handle), + Mockito.any(ConnectorColumn.class), posCap.capture()); + // WHY: AFTER
    must be neutralized to ConnectorColumnPosition.after(col); a mutation that drops + // the afterColumn or flips it to FIRST makes these red. + Assertions.assertFalse(posCap.getValue().isFirst()); + Assertions.assertEquals("id", posCap.getValue().getAfterColumn()); + } + + @Test + public void testReorderColumnsRoutes() throws Exception { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + + catalog.reorderColumns(table, Arrays.asList("b", "a")); + + Mockito.verify(metadata).reorderColumns(session, handle, Arrays.asList("b", "a")); + } + + @Test + public void testColumnOpNullPositionConvertedToNull() throws Exception { + ExternalTable table = mockAlterTable(); + stubAlterHandle(); + + catalog.addColumn(table, nullableIntColumn("age"), null); + + ArgumentCaptor posCap = ArgumentCaptor.forClass(ConnectorColumnPosition.class); + Mockito.verify(metadata).addColumn(Mockito.any(), Mockito.any(), + Mockito.any(ConnectorColumn.class), posCap.capture()); + // WHY: a null ColumnPosition (no position clause) must stay null across the SPI (toConnectorPosition + // null-guard); a mutation returning FIRST/after for null would change append semantics. + Assertions.assertNull(posCap.getValue()); + } + + @Test + public void testColumnOpRefreshesTableCacheViaRefreshManager() throws Exception { + ExternalTable table = mockAlterTable(); + stubAlterHandle(); + // afterExternalDdl re-resolves the cached table by the REMOTE names (legacy IcebergMetadataOps.refreshTable + // parity), then calls RefreshManager.refreshTableInternal — the cache-invalidation the base column op + // delegated into metadataOps and PluginDriven (metadataOps == null) must reproduce explicitly. + ExternalDatabase replayDb = mockExternalDatabase(); + ExternalTable cached = Mockito.mock(ExternalTable.class); + Mockito.doReturn(Optional.of(cached)).when(replayDb).getTableForReplay("TBL1"); + catalog.dbForReplayResult = Optional.of(replayDb); + + catalog.dropColumn(table, "age"); + + // WHY (Rule 9 / BLOCKER-2): the base column ops do NOT invalidate the cache themselves — they delegate it + // into metadataOps.refreshTable -> RefreshManager.refreshTableInternal. A helper that only writes the + // editlog (the literal "copy the base op" reading) would SILENTLY lose cache invalidation after every + // connector-driven schema change. These asserts pin that the refresh actually runs, re-resolving by the + // REMOTE names. A mutation dropping the refreshTableInternal call goes red. + Assertions.assertEquals("DB1", catalog.lastGetDbForReplayArg, + "afterExternalDdl must re-resolve the cached table by the REMOTE db name (legacy parity)"); + Mockito.verify(replayDb).getTableForReplay("TBL1"); + Mockito.verify(mockRefreshManager) + .refreshTableInternal(Mockito.eq(replayDb), Mockito.eq(cached), Mockito.anyLong()); + } + + @Test + public void testColumnOpHandleAbsentThrows() { + ExternalTable table = mockAlterTable(); + Mockito.when(metadata.getTableHandle(session, "DB1", "TBL1")).thenReturn(Optional.empty()); + + Assertions.assertThrows(DdlException.class, () -> catalog.dropColumn(table, "age")); + Mockito.verify(metadata, Mockito.never()).dropColumn(Mockito.any(), Mockito.any(), Mockito.any()); + } + + @Test + public void testColumnOpWrapsConnectorException() { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + Mockito.doThrow(new DorisConnectorException("boom")) + .when(metadata).dropColumn(session, handle, "age"); + + DdlException ex = Assertions.assertThrows(DdlException.class, () -> catalog.dropColumn(table, "age")); + Assertions.assertTrue(ex.getMessage().contains("boom")); + } + + // Branch/tag ALTERs resolve the handle by REMOTE names (like the column ops), neutralize the nereids info + // type to the SPI carrier (ConnectorBranchTagConverter), wrap a DorisConnectorException as a DdlException, + // and run afterExternalDdl (editlog with LOCAL names + refreshTableInternal). PluginDriven has no + // metadataOps, so without these overrides the base ops would throw "branching operation is not supported". + + @Test + public void testCreateOrReplaceBranchRoutesConvertsAndRefreshes() throws Exception { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + catalog.dbForReplayResult = Optional.of(mockExternalDatabase()); + + CreateOrReplaceBranchInfo info = new CreateOrReplaceBranchInfo("b1", true, false, true, + new BranchOptions(Optional.of(42L), Optional.of(86400000L), + Optional.of(5), Optional.of(172800000L))); + catalog.createOrReplaceBranch(table, info); + + // WHY (Rule 9): the converter must map every field of the nereids info/options to the neutral carrier + // (incl. the legacy retain->maxSnapshotAge / numSnapshots->minSnapshotsToKeep / retention->maxRefAge + // mapping). A mutation dropping any field makes one of these asserts red. + ArgumentCaptor cap = ArgumentCaptor.forClass(BranchChange.class); + Mockito.verify(metadata).createOrReplaceBranch(Mockito.eq(session), Mockito.eq(handle), cap.capture()); + BranchChange b = cap.getValue(); + Assertions.assertEquals("b1", b.getName()); + Assertions.assertTrue(b.isCreate()); + Assertions.assertFalse(b.isReplace()); + Assertions.assertTrue(b.isIfNotExists()); + Assertions.assertEquals(42L, b.getSnapshotId().longValue()); + Assertions.assertEquals(86400000L, b.getMaxSnapshotAgeMs().longValue()); + Assertions.assertEquals(5, b.getMinSnapshotsToKeep().intValue()); + Assertions.assertEquals(172800000L, b.getMaxRefAgeMs().longValue()); + // WHY: branch/tag share the column-op bookkeeping (afterExternalDdl) — editlog with LOCAL names + cache + // refresh re-resolving by REMOTE names. A mutation dropping the bookkeeping turns these red. + ArgumentCaptor logCap = ArgumentCaptor.forClass(ExternalObjectLog.class); + Mockito.verify(mockEditLog).logRefreshExternalTable(logCap.capture()); + Assertions.assertEquals("db1", logCap.getValue().getDbName()); + Assertions.assertEquals("t1", logCap.getValue().getTableName()); + Assertions.assertEquals("DB1", catalog.lastGetDbForReplayArg, + "afterExternalDdl must re-resolve the cached table by the REMOTE db name"); + } + + @Test + public void testCreateOrReplaceBranchEmptyOptionsConvertToNulls() throws Exception { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + catalog.dbForReplayResult = Optional.of(mockExternalDatabase()); + + catalog.createOrReplaceBranch(table, new CreateOrReplaceBranchInfo("b1", true, false, false, + BranchOptions.EMPTY)); + + ArgumentCaptor cap = ArgumentCaptor.forClass(BranchChange.class); + Mockito.verify(metadata).createOrReplaceBranch(Mockito.eq(session), Mockito.eq(handle), cap.capture()); + BranchChange b = cap.getValue(); + // An absent SQL option must become a null carrier field (== "leave the snapshot/retention untouched"). + Assertions.assertNull(b.getSnapshotId()); + Assertions.assertNull(b.getMaxSnapshotAgeMs()); + Assertions.assertNull(b.getMinSnapshotsToKeep()); + Assertions.assertNull(b.getMaxRefAgeMs()); + } + + @Test + public void testCreateOrReplaceBranchWrapsConnectorException() { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + Mockito.doThrow(new DorisConnectorException("boom")) + .when(metadata).createOrReplaceBranch(Mockito.eq(session), Mockito.eq(handle), Mockito.any()); + + DdlException ex = Assertions.assertThrows(DdlException.class, () -> catalog.createOrReplaceBranch(table, + new CreateOrReplaceBranchInfo("b1", true, false, false, BranchOptions.EMPTY))); + Assertions.assertTrue(ex.getMessage().contains("boom")); + // A remote failure must abort BEFORE bookkeeping (no editlog). + Mockito.verify(mockEditLog, Mockito.never()).logRefreshExternalTable(Mockito.any()); + } + + @Test + public void testCreateOrReplaceTagRoutesConvertsAndRefreshes() throws Exception { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + catalog.dbForReplayResult = Optional.of(mockExternalDatabase()); + + catalog.createOrReplaceTag(table, new CreateOrReplaceTagInfo("v1", false, true, false, + new TagOptions(Optional.of(9L), Optional.of(99000L)))); + + ArgumentCaptor cap = ArgumentCaptor.forClass(TagChange.class); + Mockito.verify(metadata).createOrReplaceTag(Mockito.eq(session), Mockito.eq(handle), cap.capture()); + TagChange t = cap.getValue(); + Assertions.assertEquals("v1", t.getName()); + Assertions.assertFalse(t.isCreate()); + Assertions.assertTrue(t.isReplace()); + Assertions.assertEquals(9L, t.getSnapshotId().longValue()); + Assertions.assertEquals(99000L, t.getMaxRefAgeMs().longValue()); + Mockito.verify(mockEditLog).logRefreshExternalTable(Mockito.any()); + } + + @Test + public void testDropBranchRoutesConvertsAndRefreshes() throws Exception { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + catalog.dbForReplayResult = Optional.of(mockExternalDatabase()); + + catalog.dropBranch(table, new DropBranchInfo("b1", true)); + + ArgumentCaptor cap = ArgumentCaptor.forClass(DropRefChange.class); + Mockito.verify(metadata).dropBranch(Mockito.eq(session), Mockito.eq(handle), cap.capture()); + Assertions.assertEquals("b1", cap.getValue().getName()); + Assertions.assertTrue(cap.getValue().isIfExists()); + Mockito.verify(mockEditLog).logRefreshExternalTable(Mockito.any()); + } + + @Test + public void testDropTagRoutesConvertsAndRefreshes() throws Exception { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + catalog.dbForReplayResult = Optional.of(mockExternalDatabase()); + + catalog.dropTag(table, new DropTagInfo("v1", false)); + + ArgumentCaptor cap = ArgumentCaptor.forClass(DropRefChange.class); + Mockito.verify(metadata).dropTag(Mockito.eq(session), Mockito.eq(handle), cap.capture()); + Assertions.assertEquals("v1", cap.getValue().getName()); + Assertions.assertFalse(cap.getValue().isIfExists()); + Mockito.verify(mockEditLog).logRefreshExternalTable(Mockito.any()); + } + + @Test + public void testBranchTagHandleAbsentThrows() { + ExternalTable table = mockAlterTable(); + Mockito.when(metadata.getTableHandle(session, "DB1", "TBL1")).thenReturn(Optional.empty()); + + Assertions.assertThrows(DdlException.class, () -> catalog.dropTag(table, new DropTagInfo("v1", false))); + Mockito.verify(metadata, Mockito.never()) + .dropTag(Mockito.any(), Mockito.any(), Mockito.any()); + } + + // ---------- Partition evolution (B5): route by handle, convert op -> DTO, bookkeep ---------- + + @Test + public void testAddPartitionFieldRoutesConvertsAndRefreshes() throws Exception { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + catalog.dbForReplayResult = Optional.of(mockExternalDatabase()); + + catalog.addPartitionField(table, new AddPartitionFieldOp("bucket", 8, "id", "id_b")); + + // WHY (Rule 9): the op's transform spec must reach the connector as a neutral PartitionFieldChange; a + // mutation dropping any field makes one of these asserts red. + ArgumentCaptor cap = ArgumentCaptor.forClass(PartitionFieldChange.class); + Mockito.verify(metadata).addPartitionField(Mockito.eq(session), Mockito.eq(handle), cap.capture()); + PartitionFieldChange c = cap.getValue(); + Assertions.assertEquals("bucket", c.getTransformName()); + Assertions.assertEquals(8, c.getTransformArg().intValue()); + Assertions.assertEquals("id", c.getColumnName()); + Assertions.assertEquals("id_b", c.getPartitionFieldName()); + Assertions.assertNull(c.getOldColumnName()); + // WHY: a partition spec change shares the column-op bookkeeping (afterExternalDdl) — editlog with LOCAL + // names + cache refresh re-resolving by REMOTE names. A mutation dropping it turns these red. + ArgumentCaptor logCap = ArgumentCaptor.forClass(ExternalObjectLog.class); + Mockito.verify(mockEditLog).logRefreshExternalTable(logCap.capture()); + Assertions.assertEquals("db1", logCap.getValue().getDbName()); + Assertions.assertEquals("t1", logCap.getValue().getTableName()); + Assertions.assertEquals("DB1", catalog.lastGetDbForReplayArg, + "afterExternalDdl must re-resolve the cached table by the REMOTE db name"); + } + + @Test + public void testDropPartitionFieldRoutesByName() throws Exception { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + catalog.dbForReplayResult = Optional.of(mockExternalDatabase()); + + catalog.dropPartitionField(table, new DropPartitionFieldOp("p_id")); + + ArgumentCaptor cap = ArgumentCaptor.forClass(PartitionFieldChange.class); + Mockito.verify(metadata).dropPartitionField(Mockito.eq(session), Mockito.eq(handle), cap.capture()); + Assertions.assertEquals("p_id", cap.getValue().getPartitionFieldName()); + Assertions.assertNull(cap.getValue().getColumnName()); + Mockito.verify(mockEditLog).logRefreshExternalTable(Mockito.any()); + } + + @Test + public void testReplacePartitionFieldRoutesMapsOldAndNew() throws Exception { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + catalog.dbForReplayResult = Optional.of(mockExternalDatabase()); + + catalog.replacePartitionField(table, + new ReplacePartitionFieldOp("p", null, null, null, "bucket", 4, "id", "p2")); + + ArgumentCaptor cap = ArgumentCaptor.forClass(PartitionFieldChange.class); + Mockito.verify(metadata).replacePartitionField(Mockito.eq(session), Mockito.eq(handle), cap.capture()); + PartitionFieldChange c = cap.getValue(); + // new* maps to the primary field, old* to the old side (Rule 9). + Assertions.assertEquals("bucket", c.getTransformName()); + Assertions.assertEquals(4, c.getTransformArg().intValue()); + Assertions.assertEquals("id", c.getColumnName()); + Assertions.assertEquals("p2", c.getPartitionFieldName()); + Assertions.assertEquals("p", c.getOldPartitionFieldName()); + Mockito.verify(mockEditLog).logRefreshExternalTable(Mockito.any()); + } + + @Test + public void testPartitionFieldWrapsConnectorException() { + ExternalTable table = mockAlterTable(); + ConnectorTableHandle handle = stubAlterHandle(); + Mockito.doThrow(new DorisConnectorException("boom")) + .when(metadata).addPartitionField(Mockito.eq(session), Mockito.eq(handle), Mockito.any()); + + DdlException ex = Assertions.assertThrows(DdlException.class, () -> catalog.addPartitionField(table, + new AddPartitionFieldOp(null, null, "id", null))); + Assertions.assertTrue(ex.getMessage().contains("boom")); + // A remote failure must abort BEFORE bookkeeping (no editlog). + Mockito.verify(mockEditLog, Mockito.never()).logRefreshExternalTable(Mockito.any()); + } + + @Test + public void testPartitionFieldHandleAbsentThrows() { + ExternalTable table = mockAlterTable(); + Mockito.when(metadata.getTableHandle(session, "DB1", "TBL1")).thenReturn(Optional.empty()); + + Assertions.assertThrows(DdlException.class, + () -> catalog.addPartitionField(table, new AddPartitionFieldOp(null, null, "id", null))); + Mockito.verify(metadata, Mockito.never()) + .addPartitionField(Mockito.any(), Mockito.any(), Mockito.any()); + } + + // ==================== helpers ==================== + + /** A mock external table whose LOCAL names are db1.t1 and REMOTE names DB1.TBL1 (name mapping enabled). */ + private ExternalTable mockAlterTable() { + ExternalTable table = Mockito.mock(ExternalTable.class); + Mockito.when(table.getDbName()).thenReturn("db1"); + Mockito.when(table.getName()).thenReturn("t1"); + Mockito.when(table.getRemoteDbName()).thenReturn("DB1"); + Mockito.when(table.getRemoteName()).thenReturn("TBL1"); + return table; + } + + /** Stubs the connector handle resolution for the REMOTE names of {@link #mockAlterTable()}. */ + private ConnectorTableHandle stubAlterHandle() { + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + Mockito.when(metadata.getTableHandle(session, "DB1", "TBL1")).thenReturn(Optional.of(handle)); + return handle; + } + + /** A nullable INT Doris column (iceberg add/modify reject non-nullable adds). */ + private static Column nullableIntColumn(String name) { + return new Column(name, Type.INT, false, null, true, null, ""); + } + + @SuppressWarnings("unchecked") + private ExternalDatabase mockExternalDatabase() { + return (ExternalDatabase) Mockito.mock(ExternalDatabase.class); + } + + /** + * Testable subclass: injects a mock connector, neutralizes init machinery, and + * makes the FE-cache hooks observable so DDL routing + cache invalidation can be + * asserted without a full Doris environment. + */ + private static class TestablePluginCatalog extends PluginDrivenExternalCatalog { + ConnectorSession sessionMock; + ExternalDatabase dbNullableResult; + Optional> dbForReplayResult = Optional.empty(); + int resetMetaCacheNamesCount; + String unregisteredDb; + // Records the arg passed to getDbForReplay so tests can assert the cache-invalidation + // lookup uses the LOCAL db name (follower-replay parity), not the remote-resolved one. + String lastGetDbForReplayArg; + + TestablePluginCatalog(Connector initial) { + super(1L, "test-catalog", null, testProps(), "", initial); + this.initialized = true; + } + + @Override + protected void initLocalObjectsImpl() { + // no-op: connector is injected via constructor; skip txn-manager/auth setup. + } + + @Override + public ConnectorSession buildConnectorSession() { + return sessionMock; + } + + @Override + public ConnectorSession buildCrossStatementSession() { + return buildConnectorSession(); + } + + @Override + public ExternalDatabase getDbNullable(String dbName) { + return dbNullableResult; + } + + @Override + public Optional> getDbForReplay(String dbName) { + lastGetDbForReplayArg = dbName; + return dbForReplayResult; + } + + @Override + public void resetMetaCacheNames() { + resetMetaCacheNamesCount++; + } + + @Override + public void unregisterDatabase(String dbName) { + unregisteredDb = dbName; + } + + private static Map testProps() { + Map props = new HashMap<>(); + props.put("type", "test"); + return props; + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogErrorMsgTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogErrorMsgTest.java new file mode 100644 index 00000000000000..363010da4a1a15 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogErrorMsgTest.java @@ -0,0 +1,113 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Tests that {@link PluginDrivenExternalCatalog} records a deferred metadata-load failure into + * {@code errorMsg} so it shows up in {@code show catalogs}. + * + *

    Plugin connectors connect lazily (initLocalObjectsImpl only constructs the connector), so the + * first metastore round-trip happens inside the meta-cache loader — outside makeSureInitialized()'s + * try/catch, which is the only other writer of {@code errorMsg}. Without capturing it there, a + * broken catalog would show an empty error even though {@code show databases} throws. This encodes + * WHY the capture exists: the error must be user-visible in {@code show catalogs}.

    + */ +public class PluginDrivenExternalCatalogErrorMsgTest { + + @Test + public void listDatabaseNamesCapturesErrorMsgOnDeferredFailure() { + Connector connector = Mockito.mock(Connector.class); + ConnectorMetadata meta = Mockito.mock(ConnectorMetadata.class); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(meta); + // Mirrors the real iceberg failure surfaced by the show_catalogs_error_msg regression: + // a bad REST port (181812) rejected only when the connector actually connects. + Mockito.when(meta.listDatabaseNames(Mockito.any())) + .thenThrow(new RuntimeException("port out of range:181812 is out of range")); + + TestErrorCatalog catalog = new TestErrorCatalog(connector); + Assertions.assertTrue(catalog.getErrorMsg().isEmpty(), "errorMsg starts empty"); + + // listDatabaseNames() is the meta-cache loader's db-name source; the failure must both + // propagate (so `show databases` reports it) AND be captured into errorMsg. + RuntimeException ex = Assertions.assertThrows(RuntimeException.class, + catalog::listDatabaseNames); + Assertions.assertTrue(ex.getMessage().contains("181812 is out of range"), ex.getMessage()); + Assertions.assertTrue(catalog.getErrorMsg().contains("181812 is out of range"), + "errorMsg should capture the deferred failure, was: " + catalog.getErrorMsg()); + } + + @Test + public void listDatabaseNamesLeavesErrorMsgEmptyOnSuccess() { + Connector connector = Mockito.mock(Connector.class); + ConnectorMetadata meta = Mockito.mock(ConnectorMetadata.class); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(meta); + Mockito.when(meta.listDatabaseNames(Mockito.any())) + .thenReturn(Collections.singletonList("db1")); + + TestErrorCatalog catalog = new TestErrorCatalog(connector); + List dbs = catalog.listDatabaseNames(); + Assertions.assertEquals(Collections.singletonList("db1"), dbs); + Assertions.assertTrue(catalog.getErrorMsg().isEmpty(), + "errorMsg must stay empty when the metadata load succeeds"); + } + + /** + * Minimal subclass that keeps the real {@link PluginDrivenExternalCatalog#listDatabaseNames()} + * (the method under test) but stubs out the pieces that need a full Doris environment. + */ + private static class TestErrorCatalog extends PluginDrivenExternalCatalog { + TestErrorCatalog(Connector connector) { + super(1L, "err-catalog", null, testProps(), "", connector); + this.initialized = true; + } + + @Override + protected Connector createConnectorFromProperties() { + return null; + } + + @Override + protected void initLocalObjectsImpl() { + // Connector is already injected via the constructor; nothing to build. + } + + @Override + public ConnectorSession buildConnectorSession() { + return Mockito.mock(ConnectorSession.class); + } + + private static Map testProps() { + Map props = new HashMap<>(); + props.put("type", "test"); + return props; + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogSessionBypassTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogSessionBypassTest.java new file mode 100644 index 00000000000000..04b1eaf3ce0435 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogSessionBypassTest.java @@ -0,0 +1,99 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.datasource.DelegatedCredential; +import org.apache.doris.datasource.SessionContext; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * Pins the per-user shared-cache-bypass DECISION for a {@code iceberg.rest.session=user} catalog (#63068, the + * highest-severity concern — cross-user cache leakage). Under a {@link ConnectorCapability#SUPPORTS_USER_SESSION} + * connector carrying a per-request delegated credential the remote source returns PER-USER metadata, so the shared + * (catalog+name-keyed, NOT user-keyed) db/table-name caches must be bypassed; without a credential — or for a + * connector that never opts in — the shared cache is kept (the fail-closed rejection then happens connector-side + * on the actual read, never by serving/poisoning a shared entry). The data-flow consequence (a bypassing read + * never mutates the shared lookup) is covered by the connector-suite + docker e2e; this pins the gate itself. + */ +public class PluginDrivenExternalCatalogSessionBypassTest { + + private static PluginDrivenExternalCatalog catalogWith(Set capabilities) { + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getCapabilities()).thenReturn(capabilities); + Map props = new HashMap<>(); + props.put("type", "iceberg"); + return new PluginDrivenExternalCatalog(1L, "test_ctl", null, props, "", connector); + } + + private static SessionContext credentialed() { + return SessionContext.of("sess-1", + new DelegatedCredential(DelegatedCredential.Type.ACCESS_TOKEN, "user-token")); + } + + @Test + public void bypassesSharedCachesForCapableConnectorCarryingCredential() { + PluginDrivenExternalCatalog userSession = + catalogWith(EnumSet.of(ConnectorCapability.SUPPORTS_USER_SESSION)); + + Assertions.assertTrue(userSession.shouldBypassTableNameCache(credentialed()), + "session=user + credential must bypass the shared table-name cache (per-user metadata)"); + Assertions.assertTrue(userSession.shouldBypassDbNameCache(credentialed()), + "session=user + credential must bypass the shared db-name cache (per-user metadata)"); + Assertions.assertTrue(userSession.shouldBypassSchemaCache(credentialed()), + "session=user + credential must bypass the shared schema cache (list != load metadata disclosure)"); + } + + @Test + public void keepsSharedCachesWhenNoCredentialPresent() { + PluginDrivenExternalCatalog userSession = + catalogWith(EnumSet.of(ConnectorCapability.SUPPORTS_USER_SESSION)); + + // No credential (background/internal work, or a password login to a user-session catalog): keep the shared + // cache here. The fail-closed rejection is enforced connector-side on the actual metadata read, not by + // bypassing into a per-user read that has no identity to authorize with. + Assertions.assertFalse(userSession.shouldBypassTableNameCache(SessionContext.empty())); + Assertions.assertFalse(userSession.shouldBypassDbNameCache(SessionContext.empty())); + Assertions.assertFalse(userSession.shouldBypassSchemaCache(SessionContext.empty())); + Assertions.assertFalse(userSession.shouldBypassTableNameCache(null), + "a null session context must not bypass (no credential to key a per-user read)"); + Assertions.assertFalse(userSession.shouldBypassDbNameCache(null)); + Assertions.assertFalse(userSession.shouldBypassSchemaCache(null), + "a null session context must not bypass the schema cache either"); + } + + @Test + public void neverBypassesForNonUserSessionConnectorEvenWithCredential() { + // A connector that does not declare SUPPORTS_USER_SESSION never bypasses — ordinary catalogs keep their + // shared caches unconditionally (zero-regression gate; least-privilege). + PluginDrivenExternalCatalog plain = catalogWith(EnumSet.noneOf(ConnectorCapability.class)); + + Assertions.assertFalse(plain.shouldBypassTableNameCache(credentialed())); + Assertions.assertFalse(plain.shouldBypassDbNameCache(credentialed())); + Assertions.assertFalse(plain.shouldBypassSchemaCache(credentialed())); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogViewListingTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogViewListingTest.java new file mode 100644 index 00000000000000..38b8e44457c872 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogViewListingTest.java @@ -0,0 +1,132 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Pins {@link PluginDrivenExternalCatalog#listTableNamesFromRemote}'s view re-merge. A view-exposing + * connector (iceberg) subtracts view names from {@code listTableNames}, so the catalog must merge the + * connector's {@code listViewNames} back into {@code SHOW TABLES} — byte-faithful to legacy + * {@code IcebergExternalCatalog.listTableNamesFromRemote}. A view-less connector (jdbc/es) must skip the + * merge entirely (no {@code listViewNames} round-trip) and return the table list verbatim. + */ +public class PluginDrivenExternalCatalogViewListingTest { + + private Connector connector; + private ConnectorMetadata metadata; + private TestableCatalog catalog; + + @BeforeEach + public void setUp() { + connector = Mockito.mock(Connector.class); + metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); + catalog = new TestableCatalog(connector, session); + } + + @Test + public void mergesViewNamesWhenConnectorSupportsView() { + Mockito.when(connector.getCapabilities()) + .thenReturn(EnumSet.of(ConnectorCapability.SUPPORTS_VIEW)); + Mockito.when(metadata.listTableNames(Mockito.any(), Mockito.eq("db1"))) + .thenReturn(Arrays.asList("t1", "t2")); + Mockito.when(metadata.listViewNames(Mockito.any(), Mockito.eq("db1"))) + .thenReturn(Arrays.asList("v1", "v2")); + + List result = catalog.listTableNamesFromRemote(null, "db1"); + + // WHY: legacy IcebergExternalCatalog re-merges views into SHOW TABLES (its listTableNames subtracts + // them). MUTATION: dropping the merge / the capability gate -> views vanish from SHOW TABLES -> red. + Assertions.assertEquals(Arrays.asList("t1", "t2", "v1", "v2"), result); + } + + @Test + public void skipsMergeAndViewRoundTripWhenConnectorIsViewLess() { + Mockito.when(connector.getCapabilities()) + .thenReturn(EnumSet.noneOf(ConnectorCapability.class)); + Mockito.when(metadata.listTableNames(Mockito.any(), Mockito.eq("db1"))) + .thenReturn(Arrays.asList("t1", "t2")); + + List result = catalog.listTableNamesFromRemote(null, "db1"); + + // WHY: jdbc/es have no views; the capability gate must skip both the merge AND the listViewNames + // round-trip, returning the table list verbatim. MUTATION: dropping the gate -> an extra + // listViewNames call (caught by verify(never)) and a needless merge -> red. + Assertions.assertEquals(Arrays.asList("t1", "t2"), result); + Mockito.verify(metadata, Mockito.never()).listViewNames(Mockito.any(), Mockito.anyString()); + } + + @Test + public void returnsTableListWhenViewCapableButNoViews() { + Mockito.when(connector.getCapabilities()) + .thenReturn(EnumSet.of(ConnectorCapability.SUPPORTS_VIEW)); + Mockito.when(metadata.listTableNames(Mockito.any(), Mockito.eq("db1"))) + .thenReturn(Arrays.asList("t1")); + Mockito.when(metadata.listViewNames(Mockito.any(), Mockito.eq("db1"))) + .thenReturn(Collections.emptyList()); + + List result = catalog.listTableNamesFromRemote(null, "db1"); + + // An empty view set yields exactly the table list (the merge is a no-op). + Assertions.assertEquals(Arrays.asList("t1"), result); + } + + /** A PluginDrivenExternalCatalog wired to a mock connector, skipping the real local-object/auth setup. */ + private static final class TestableCatalog extends PluginDrivenExternalCatalog { + private final ConnectorSession sessionMock; + + TestableCatalog(Connector initial, ConnectorSession session) { + super(1L, "test-catalog", null, testProps(), "", initial); + this.sessionMock = session; + this.initialized = true; + } + + @Override + protected void initLocalObjectsImpl() { + // no-op: connector is injected via the constructor. + } + + @Override + public ConnectorSession buildConnectorSession() { + return sessionMock; + } + + private static Map testProps() { + Map props = new HashMap<>(); + props.put("type", "test"); + return props; + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalDatabaseTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalDatabaseTest.java new file mode 100644 index 00000000000000..d1b3390b553f61 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalDatabaseTest.java @@ -0,0 +1,108 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorDatabaseMetadata; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.datasource.ExternalCatalog; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Pins {@link PluginDrivenExternalDatabase#getLocation()}, the SHOW CREATE DATABASE LOCATION source for a + * flipped iceberg (and any plugin-driven) catalog. It reads the namespace location through the connector's + * {@code getDatabase} SPI (Trino-aligned properties-map, the {@code location} key) keyed off the + * remote db name, degrading to "" (no LOCATION clause) when the connector exposes none. + */ +public class PluginDrivenExternalDatabaseTest { + + private static PluginDrivenExternalCatalog catalogReturning(ConnectorDatabaseMetadata dbMetadata) { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + Mockito.when(metadata.getDatabase(Mockito.any(), Mockito.anyString())).thenReturn(dbMetadata); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); + return catalog; + } + + @Test + public void getLocationSurfacesConnectorNamespaceLocationByRemoteName() { + Map props = new HashMap<>(); + props.put(ConnectorDatabaseMetadata.LOCATION_PROPERTY, "s3://bucket/remote_db"); + PluginDrivenExternalCatalog catalog = + catalogReturning(new ConnectorDatabaseMetadata("remote_db", props)); + PluginDrivenExternalDatabase db = + new PluginDrivenExternalDatabase(catalog, 1L, "db1", "remote_db"); + + Assertions.assertEquals("s3://bucket/remote_db", db.getLocation(), + "getLocation must surface the connector's namespace location property"); + // The lookup must use the REMOTE db name (the connector addresses the remote namespace). + // MUTATION: passing the local name -> the verify fails. + Mockito.verify(catalog.getConnector().getMetadata(null)) + .getDatabase(Mockito.any(), Mockito.eq("remote_db")); + } + + @Test + public void getLocationReturnsEmptyWhenNamespaceHasNoLocation() { + // A connector with no namespace location (paimon/jdbc/es default getDatabase -> empty props) yields "" + // so SHOW CREATE DATABASE renders no LOCATION clause. MUTATION: defaulting to a non-empty value -> red. + PluginDrivenExternalCatalog catalog = + catalogReturning(new ConnectorDatabaseMetadata("remote_db", Collections.emptyMap())); + PluginDrivenExternalDatabase db = + new PluginDrivenExternalDatabase(catalog, 1L, "db1", "remote_db"); + + Assertions.assertEquals("", db.getLocation()); + } + + @Test + public void getLocationReturnsEmptyWhenConnectorAbsent() { + // MUTATION: dropping the null-connector guard NPEs here — a not-yet-built / read-only connector must + // degrade to "" rather than crash SHOW CREATE DATABASE. + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(null); + PluginDrivenExternalDatabase db = + new PluginDrivenExternalDatabase(catalog, 1L, "db1", "remote_db"); + + Assertions.assertEquals("", db.getLocation()); + } + + @Test + public void getLocationReturnsEmptyWhenCatalogNotPluginDriven() { + // MUTATION: dropping the instanceof guard ClassCast/NPEs — the defensive guard returns "" if the + // owning catalog is somehow not plugin-driven. + ExternalCatalog catalog = Mockito.mock(ExternalCatalog.class); + PluginDrivenExternalDatabase db = + new PluginDrivenExternalDatabase(catalog, 1L, "db1", "remote_db"); + + Assertions.assertEquals("", db.getLocation()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableColumnStatTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableColumnStatTest.java new file mode 100644 index 00000000000000..901a9f11474ec9 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableColumnStatTest.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.datasource.plugin; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.Type; +import org.apache.doris.connector.api.ConnectorColumnStatistics; +import org.apache.doris.statistics.ColumnStatistic; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +/** + * Tests {@link PluginDrivenExternalTable#toColumnStatistic}, the Doris-type-dependent column-stat math that + * fe-core does on the connector's raw facts (HMS cutover §4.2a). + * + *

    WHY: this is the byte-parity half of legacy {@code HMSExternalTable.setStatData} the connector cannot do + * (it must not import fe-type). A string column's data size uses the source {@code avgColLen} + * ({@code round(avgColLen * count)}); every other type uses the Doris fixed slot width + * ({@code count * slotSize}); {@code avgSizeByte = dataSize / count}; min/max stay unconstrained. Getting the + * string-vs-fixed-width split or the rounding wrong would skew every cardinality estimate that reads the + * column's stats.

    + */ +public class PluginDrivenExternalTableColumnStatTest { + + @Test + public void stringColumnUsesAvgColLen() { + ConnectorColumnStatistics stats = new ConnectorColumnStatistics(100, 10, 2, 5.0); + ColumnStatistic cs = PluginDrivenExternalTable + .toColumnStatistic(stats, new Column("c", Type.STRING)).get(); + Assertions.assertEquals(100, cs.count, 0.0); + Assertions.assertEquals(10, cs.ndv, 0.0); + Assertions.assertEquals(2, cs.numNulls, 0.0); + // round(5.0 * 100) = 500; avgSizeByte = 500 / 100 = 5.0 + Assertions.assertEquals(500, cs.dataSize, 0.0); + Assertions.assertEquals(5.0, cs.avgSizeByte, 0.0); + Assertions.assertEquals(Double.NEGATIVE_INFINITY, cs.minValue, 0.0); + Assertions.assertEquals(Double.POSITIVE_INFINITY, cs.maxValue, 0.0); + } + + @Test + public void nonStringColumnUsesSlotWidth() { + Column column = new Column("c", Type.INT); + long count = 100; + long slotSize = column.getType().getSlotSize(); + // avgSizeBytes = -1 => non-string => data size from the Doris slot width. + ConnectorColumnStatistics stats = new ConnectorColumnStatistics(count, 7, 1, -1); + ColumnStatistic cs = PluginDrivenExternalTable.toColumnStatistic(stats, column).get(); + Assertions.assertEquals(7, cs.ndv, 0.0); + Assertions.assertEquals(1, cs.numNulls, 0.0); + Assertions.assertEquals((double) count * slotSize, cs.dataSize, 0.0); + Assertions.assertEquals((double) slotSize, cs.avgSizeByte, 0.0); + } + + @Test + public void nonPositiveCountIsEmpty() { + Assertions.assertFalse(PluginDrivenExternalTable + .toColumnStatistic(new ConnectorColumnStatistics(0, 1, 0, -1), new Column("c", Type.INT)) + .isPresent()); + } + + @Test + public void nullColumnIsEmpty() { + Optional result = PluginDrivenExternalTable + .toColumnStatistic(new ConnectorColumnStatistics(100, 1, 0, -1), null); + Assertions.assertFalse(result.isPresent()); + } +} 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 new file mode 100644 index 00000000000000..dbc761a119e23f --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableEngineTest.java @@ -0,0 +1,379 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.PrimitiveType; +import org.apache.doris.catalog.TableIf.TableType; +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.ConnectorTableSchema; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +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.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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.). + */ +public class PluginDrivenExternalTableEngineTest { + + @Test + public void testJdbcCatalogReturnsJdbcEngineName() { + PluginDrivenExternalTable table = createTableWithCatalogType("jdbc"); + Assertions.assertEquals("jdbc", table.getEngine(), + "JDBC catalog tables should report engine='jdbc'"); + } + + @Test + public void testEsCatalogReturnsEsEngineName() { + PluginDrivenExternalTable table = createTableWithCatalogType("es"); + Assertions.assertEquals("es", table.getEngine(), + "ES catalog tables should report engine='es'"); + } + + @Test + public void testMaxComputeCatalogReturnsLegacyEngineName() { + 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"); + } + + @Test + public void testUnknownCatalogReturnsPluginEngineName() { + PluginDrivenExternalTable table = createTableWithCatalogType("custom_type"); + Assertions.assertEquals("Plugin", table.getEngine(), + "Unknown catalog types should report engine='Plugin'"); + } + + @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"); + } + + @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"); + } + + @Test + public void testMaxComputeCatalogReturnsMaxComputeEngineTableTypeName() { + 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"); + } + + @Test + public void testUnknownCatalogReturnsPluginEngineTableTypeName() { + PluginDrivenExternalTable table = createTableWithCatalogType("custom_type"); + Assertions.assertEquals(TableType.PLUGIN_EXTERNAL_TABLE.name(), + table.getEngineTableTypeName(), + "Unknown catalog types should report PLUGIN_EXTERNAL_TABLE type name"); + } + + @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'"); + } + + @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"); + } + + @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. + PluginDrivenExternalTable table = createTableWithCatalogType("hms"); + Assertions.assertEquals("hms", table.getEngine(), + "Hms catalog tables should report engine='hms' (legacy parity), not 'Plugin' or 'hive'"); + } + + @Test + public void testHmsCatalogReturnsHmsEngineTableTypeName() { + PluginDrivenExternalTable table = createTableWithCatalogType("hms"); + Assertions.assertEquals(TableType.HMS_EXTERNAL_TABLE.name(), + table.getEngineTableTypeName(), + "Hms catalog tables should report HMS_EXTERNAL_TABLE type name"); + } + + @Test + public void testTableTypeIsAlwaysPluginExternalTable() { + PluginDrivenExternalTable jdbcTable = createTableWithCatalogType("jdbc"); + PluginDrivenExternalTable esTable = createTableWithCatalogType("es"); + Assertions.assertEquals(TableType.PLUGIN_EXTERNAL_TABLE, jdbcTable.getType(), + "Internal table type should always be PLUGIN_EXTERNAL_TABLE"); + Assertions.assertEquals(TableType.PLUGIN_EXTERNAL_TABLE, esTable.getType(), + "Internal table type should always be PLUGIN_EXTERNAL_TABLE"); + } + + @Test + public void testInitSchemaReturnsEmptyWhenTableHandleMissing() { + Connector connector = createMockConnector(false, false); + PluginDrivenExternalTable table = createTableWithCatalogType("jdbc", connector); + + // Return an empty schema result when the connector cannot resolve the table handle. + Assertions.assertFalse(table.initSchema().isPresent(), + "Missing connector table handles should produce an empty schema result"); + } + + @Test + public void testInitSchemaAppliesRemoteColumnNameMapping() { + Connector connector = createMockConnector(true, true); + PluginDrivenExternalTable table = createTableWithCatalogType("jdbc", connector); + + // Verify that plugin-driven schema loading preserves mapped column names from the connector. + Optional schema = table.initSchema(); + Assertions.assertTrue(schema.isPresent(), "Schema should be present when a table handle exists"); + Assertions.assertEquals("mapped_id", schema.get().getSchema().get(0).getName(), + "Mapped remote column names should be reflected in Doris schema metadata"); + } + + /** + * Verifies the fe-core call site of {@link PluginDrivenExternalTable#toThrift()}: it must pass + * the REMOTE db/table names and the schema column count into + * {@code ConnectorMetadata.buildTableDescriptor(...)}. + * + *

    WHY this matters: after the max_compute cutover, BE static_casts the descriptor to + * {@code MaxComputeTableDescriptor} and reads {@code project}/{@code table} (built by + * {@code MaxComputeConnectorMetadata.buildTableDescriptor} from these two args) as the JNI + * read-session addressing contract, which uses REMOTE names. If the call site passed the LOCAL + * names (or a wrong numCols), the descriptor would address the wrong ODPS project/table and the + * column count would be inconsistent with the schema, breaking reads. The connector-module UT + * ({@code MaxComputeBuildTableDescriptorTest}) only covers the override's own output; this test + * is the only automated guard on the cross-module WIRING. + * + *

    It FAILS if the call site is changed to pass {@code db.getFullName()}/{@code getName()} + * (local names) or any column count other than {@code schema.size()}. + */ + @Test + public void testToThriftPassesRemoteNamesAndNumColsToBuildTableDescriptor() { + ConnectorMetadata meta = Mockito.mock(ConnectorMetadata.class); + TestablePluginCatalog catalog = new TestablePluginCatalog("max_compute", meta); + + // Local names differ from remote names, so a regression that passes local names is caught. + ExternalDatabase db = Mockito.mock(ExternalDatabase.class); + Mockito.when(db.getFullName()).thenReturn("mydb"); + Mockito.when(db.getRemoteName()).thenReturn("REMOTE_DB"); + + // Schema with a known, non-trivial column count so numCols regressions are caught. + final int expectedNumCols = 3; + final List schema = new ArrayList<>(); + for (int i = 0; i < expectedNumCols; i++) { + schema.add(new Column("c" + i, PrimitiveType.INT)); + } + + // Subclass stubs ONLY the two Env-backed methods toThrift() traverses (catalog/db init and + // schema-cache lookup), isolating the call-site wiring without standing up Env/CatalogMgr. + PluginDrivenExternalTable table = new PluginDrivenExternalTable( + 1L, "mytbl", "REMOTE_TBL", catalog, db) { + @Override + protected synchronized void makeSureInitialized() { + // no-op: skip real catalog/db initialization (Env-backed) + } + + @Override + public List getFullSchema() { + return schema; + } + }; + + TTableDescriptor stub = new TTableDescriptor(1L, TTableType.MAX_COMPUTE_TABLE, + expectedNumCols, 0, "mytbl", "REMOTE_DB"); + Mockito.when(meta.buildTableDescriptor( + Mockito.any(), Mockito.anyLong(), Mockito.anyString(), Mockito.anyString(), + Mockito.anyString(), Mockito.anyInt(), Mockito.anyLong())) + .thenReturn(stub); + + table.toThrift(); + + ArgumentCaptor dbNameCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor remoteNameCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor numColsCaptor = ArgumentCaptor.forClass(Integer.class); + Mockito.verify(meta).buildTableDescriptor( + Mockito.any(ConnectorSession.class), Mockito.anyLong(), Mockito.anyString(), + dbNameCaptor.capture(), remoteNameCaptor.capture(), + numColsCaptor.capture(), Mockito.anyLong()); + + Assertions.assertEquals("REMOTE_DB", dbNameCaptor.getValue(), + "toThrift() must pass db.getRemoteName() as dbName, not the local db name"); + Assertions.assertEquals("REMOTE_TBL", remoteNameCaptor.getValue(), + "toThrift() must pass table.getRemoteName() as remoteName, not the local table name"); + Assertions.assertEquals(expectedNumCols, numColsCaptor.getValue().intValue(), + "toThrift() must pass schema.size() as numCols"); + } + + // -------- Helpers -------- + + private PluginDrivenExternalTable createTableWithCatalogType(String catalogType) { + return createTableWithCatalogType(catalogType, createMockConnector(true, false)); + } + + private PluginDrivenExternalTable createTableWithCatalogType(String catalogType, Connector connector) { + TestablePluginCatalog catalog = new TestablePluginCatalog(catalogType, connector); + ExternalDatabase db = mockExternalDatabase(); + Mockito.when(db.getFullName()).thenReturn("test_db"); + Mockito.when(db.getRemoteName()).thenReturn("test_db"); + + // Unit isolation: these tests exercise engine-name / initSchema() logic against mock + // connector/db objects that are not registered in a real Env-backed catalog. Since P6.6 H-8 + // (iceberg view schema), initSchema() consults isView() -> makeSureInitialized(); the real + // makeSureInitialized() would resolve the db against Env and throw "Unknown database 'test_db'". + // Stub it out (mirrors testToThrift...'s inline no-op); isView() then stays false (the view-less + // connector path these tests assert), so initSchema() takes the table-handle path unchanged. + PluginDrivenExternalTable table = new PluginDrivenExternalTable( + 1L, "test_table", "test_table", catalog, db) { + @Override + protected synchronized void makeSureInitialized() { + // no-op: skip real Env-backed catalog/db initialization + } + }; + return table; + } + + private Connector createMockConnector(boolean tableExists, boolean renameColumn) { + Connector connector = Mockito.mock(Connector.class); + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + ConnectorTableSchema schema = new ConnectorTableSchema("test_table", + Collections.singletonList(new ConnectorColumn( + "id", ConnectorType.of("INT", -1, -1), "", true, null, true)), + null, Collections.emptyMap()); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); + Mockito.when(metadata.getTableHandle(Mockito.any(ConnectorSession.class), Mockito.anyString(), Mockito.anyString())) + .thenReturn(tableExists ? Optional.of(handle) : Optional.empty()); + Mockito.when(metadata.getTableSchema(Mockito.any(ConnectorSession.class), Mockito.eq(handle))).thenReturn(schema); + Mockito.when(metadata.fromRemoteColumnName(Mockito.any(ConnectorSession.class), Mockito.anyString(), + Mockito.anyString(), Mockito.anyString())).thenAnswer(invocation -> { + String remoteName = invocation.getArgument(3); + return renameColumn ? "mapped_" + remoteName : remoteName; + }); + return connector; + } + + @SuppressWarnings("unchecked") + private ExternalDatabase mockExternalDatabase() { + return Mockito.mock(ExternalDatabase.class); + } + + /** + * Minimal testable PluginDrivenExternalCatalog that returns a configurable type + * without requiring full Doris environment initialization. + */ + private static class TestablePluginCatalog extends PluginDrivenExternalCatalog { + private final String catalogType; + private final Connector connector; + + TestablePluginCatalog(String catalogType) { + this(catalogType, mockConnector(Mockito.mock(ConnectorMetadata.class))); + } + + TestablePluginCatalog(String catalogType, ConnectorMetadata meta) { + this(catalogType, mockConnector(meta)); + } + + private TestablePluginCatalog(String catalogType, Connector connector) { + super(1L, "test-catalog", null, makeProps(catalogType), "", connector); + this.catalogType = catalogType; + this.connector = connector; + } + + @Override + public String getType() { + return catalogType; + } + + @Override + public Connector getConnector() { + // Bypass the parent's makeSureInitialized() (Env-backed catalog init) so the call-site + // wiring test can reach toThrift() without standing up Env/CatalogMgr. + return connector; + } + + @Override + protected List listDatabaseNames() { + return Collections.emptyList(); + } + + @Override + protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { + return Collections.emptyList(); + } + + @Override + public boolean tableExist(SessionContext ctx, String dbName, String tblName) { + return false; + } + + private static Map makeProps(String type) { + Map props = new HashMap<>(); + props.put("type", type); + return props; + } + + private static Connector mockConnector(ConnectorMetadata meta) { + Connector c = Mockito.mock(Connector.class); + Mockito.when(c.getMetadata(Mockito.any())).thenReturn(meta); + return c; + } + } +} 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 new file mode 100644 index 00000000000000..d6be2c1bad6acd --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTablePartitionTest.java @@ -0,0 +1,417 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.ListPartitionItem; +import org.apache.doris.catalog.PartitionItem; +import org.apache.doris.catalog.PartitionKey; +import org.apache.doris.catalog.PrimitiveType; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorPartitionInfo; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.ConnectorTableSchema; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.SchemaCacheValue; +import org.apache.doris.datasource.SessionContext; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Tests for {@link PluginDrivenExternalTable}'s partition-metadata overrides added by + * FIX-PART-GATES: {@code isPartitionedTable}, {@code getPartitionColumns}, + * {@code supportInternalPartitionPruned}, {@code getNameToPartitionItems}, and the + * {@code initSchema} partition-column extraction into {@link PluginDrivenSchemaCacheValue}. + * + *

    Why these matter: after the MaxCompute SPI cutover, partition visibility + * (SHOW PARTITIONS / partitions() TVF) and internal partition pruning both depend on these + * overrides. Without them a partitioned MaxCompute table reports as non-partitioned (SHOW + * PARTITIONS throws "not a partitioned table") and large partitioned tables degrade to a + * full scan. The tests lock: (1) partition columns sourced from the cached + * {@code partition_columns} property; (2) {@code getNameToPartitionItems} addressing the + * connector's raw-keyed partition values by the RAW remote column names (not the mapped + * local names); (3) {@code supportInternalPartitionPruned} returning unconditional true (mirroring + * legacy MaxComputeExternalTable) for BOTH partitioned and non-partitioned tables — gating it on + * partition columns silently dropped all rows of filtered non-partitioned scans + * (FIX-NONPART-PRUNE-DATALOSS).

    + */ +public class PluginDrivenExternalTablePartitionTest { + + // ==================== read-back overrides (cache value constructed directly) ==================== + + @Test + public void testPartitionedTableExposesPartitionColumnsAndPruning() { + List schema = Arrays.asList( + new Column("year", PrimitiveType.INT), + new Column("month", PrimitiveType.INT), + new Column("val", PrimitiveType.INT)); + List partitionColumns = Arrays.asList(schema.get(0), schema.get(1)); + PluginDrivenSchemaCacheValue cacheValue = new PluginDrivenSchemaCacheValue( + schema, partitionColumns, Arrays.asList("year", "month")); + PluginDrivenExternalTable table = tableWithCacheValue(cacheValue); + + Assertions.assertTrue(table.isPartitionedTable(), + "a table with partition columns must report isPartitionedTable()==true (SHOW PARTITIONS gate)"); + Assertions.assertEquals(partitionColumns, table.getPartitionColumns(), + "getPartitionColumns() must return the cached partition columns"); + Assertions.assertTrue(table.supportInternalPartitionPruned(), + "a partitioned table must opt into internal partition pruning"); + } + + @Test + public void testNonPartitionedTableReportsNoPartitionsButStillOptsIntoPruning() { + List schema = Collections.singletonList(new Column("val", PrimitiveType.INT)); + PluginDrivenSchemaCacheValue cacheValue = new PluginDrivenSchemaCacheValue( + schema, Collections.emptyList(), Collections.emptyList()); + PluginDrivenExternalTable table = tableWithCacheValue(cacheValue); + + Assertions.assertFalse(table.isPartitionedTable()); + Assertions.assertTrue(table.getPartitionColumns().isEmpty()); + // WHY (FIX-NONPART-PRUNE-DATALOSS): supportInternalPartitionPruned MUST be unconditional true, + // even for a NON-partitioned table (mirrors legacy MaxComputeExternalTable). A previous version + // gated it on partition columns -> returned false here, which sent PruneFileScanPartition down + // its ELSE branch (selection := SelectedPartitions(0, {}, isPruned=true)); PluginDrivenScanNode + // then read that as "pruned to zero" and short-circuited to no splits, so a filtered query over + // a non-partitioned table silently returned ZERO ROWS. With true, the rule's IF branch / + // pruneExternalPartitions returns NOT_PRUNED for empty partition columns -> scan all. A mutation + // reverting to `!getPartitionColumns().isEmpty()` (false here) makes this assertion red. + Assertions.assertTrue(table.supportInternalPartitionPruned(), + "a non-partitioned table must STILL opt into internal partition pruning, or filtered " + + "queries silently return zero rows (FIX-NONPART-PRUNE-DATALOSS)"); + } + + // ==================== getNameToPartitionItems (raw remote-name addressing) ==================== + + @Test + public void testGetNameToPartitionItemsBuildsFromConnectorByRemoteNames() { + // Doris (local/mapped) partition column names differ from the RAW remote names, so a + // mutation indexing the connector's raw-keyed value map by the local names would miss. + List schema = Arrays.asList( + new Column("year", PrimitiveType.INT), + new Column("month", PrimitiveType.INT), + new Column("val", PrimitiveType.INT)); + List partitionColumns = Arrays.asList(schema.get(0), schema.get(1)); + PluginDrivenSchemaCacheValue cacheValue = new PluginDrivenSchemaCacheValue( + schema, partitionColumns, Arrays.asList("YEAR", "MONTH")); + + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + TestablePluginCatalog catalog = new TestablePluginCatalog("max_compute", metadata, session); + ExternalDatabase db = mockDb("REMOTE_DB"); + 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())) + .thenReturn(Arrays.asList( + partition("YEAR=2024/MONTH=1", "2024", "1"), + partition("YEAR=2023/MONTH=2", "2023", "2"))); + + PluginDrivenExternalTable table = tableWithCacheValue(cacheValue, catalog, db, "REMOTE_TBL"); + + Map items = table.getNameToPartitionItems(Optional.empty()); + + Assertions.assertEquals(2, items.size()); + assertPartition(items, "YEAR=2024/MONTH=1", "2024", "1"); + assertPartition(items, "YEAR=2023/MONTH=2", "2023", "2"); + // WHY: addressing must use the RAW remote names; if it used the local "year"/"month" the + // raw-keyed value map lookups would return null and partition-key construction would break. + Mockito.verify(metadata).getTableHandle(session, "REMOTE_DB", "REMOTE_TBL"); + } + + @Test + public void testGetNameToPartitionItemsEmptyWhenNotPartitioned() { + PluginDrivenSchemaCacheValue cacheValue = new PluginDrivenSchemaCacheValue( + Collections.singletonList(new Column("val", PrimitiveType.INT)), + Collections.emptyList(), Collections.emptyList()); + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + TestablePluginCatalog catalog = new TestablePluginCatalog( + "max_compute", metadata, noneScopedSession()); + PluginDrivenExternalTable table = tableWithCacheValue( + cacheValue, catalog, mockDb("REMOTE_DB"), "REMOTE_TBL"); + + Assertions.assertTrue(table.getNameToPartitionItems(Optional.empty()).isEmpty()); + Mockito.verifyNoInteractions(metadata); + } + + // ==================== initSchema partition extraction (raw -> mapped bridge) ==================== + + @Test + public void testInitSchemaExtractsPartitionColumnsMappingRemoteNames() { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + TestablePluginCatalog catalog = new TestablePluginCatalog("max_compute", metadata, session); + ExternalDatabase db = mockDb("REMOTE_DB"); + + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.of(handle)); + // Connector schema: raw remote column names; partition_columns prop lists RAW names. + ConnectorTableSchema tableSchema = new ConnectorTableSchema( + "REMOTE_TBL", + Arrays.asList( + new ConnectorColumn("YEAR", ConnectorType.of("INT"), "", true, null), + new ConnectorColumn("REGION", ConnectorType.of("INT"), "", true, null), + new ConnectorColumn("VAL", ConnectorType.of("INT"), "", true, null)), + "max_compute", + Collections.singletonMap(ConnectorTableSchema.PARTITION_COLUMNS_KEY, "YEAR,REGION")); + Mockito.when(metadata.getTableSchema(session, handle)).thenReturn(tableSchema); + // Identifier mapping lowercases the remote names (raw "YEAR" -> mapped "year"). + Mockito.when(metadata.fromRemoteColumnName(Mockito.eq(session), Mockito.anyString(), + Mockito.anyString(), Mockito.anyString())) + .thenAnswer(inv -> ((String) inv.getArgument(3)).toLowerCase()); + + PluginDrivenExternalTable table = bareTable(catalog, db, "REMOTE_TBL"); + Optional result = table.initSchema(); + + Assertions.assertTrue(result.isPresent()); + Assertions.assertTrue(result.get() instanceof PluginDrivenSchemaCacheValue); + PluginDrivenSchemaCacheValue value = (PluginDrivenSchemaCacheValue) result.get(); + Assertions.assertEquals(Arrays.asList("year", "region", "val"), columnNames(value.getSchema())); + // WHY: partition columns are matched after mapping raw->local; a mutation that matched by the + // RAW name would find nothing (schema holds mapped "year"/"region") and drop the partitions. + Assertions.assertEquals(Arrays.asList("year", "region"), columnNames(value.getPartitionColumns()), + "partition columns must be the MAPPED Doris columns identified via fromRemoteColumnName"); + Assertions.assertEquals(Arrays.asList("YEAR", "REGION"), value.getPartitionColumnRemoteNames(), + "remote names must be kept raw for addressing connector partition values"); + } + + @Test + public void testInitSchemaNoPartitionsWhenPropAbsent() { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + TestablePluginCatalog catalog = new TestablePluginCatalog("max_compute", metadata, session); + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.of(handle)); + Mockito.when(metadata.getTableSchema(session, handle)).thenReturn(new ConnectorTableSchema( + "REMOTE_TBL", + Collections.singletonList(new ConnectorColumn("c", ConnectorType.of("INT"), "", true, null)), + "max_compute", + Collections.emptyMap())); + Mockito.when(metadata.fromRemoteColumnName(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) + .thenAnswer(inv -> inv.getArgument(3)); + + PluginDrivenExternalTable table = bareTable(catalog, mockDb("REMOTE_DB"), "REMOTE_TBL"); + Optional result = table.initSchema(); + + Assertions.assertTrue(result.get() instanceof PluginDrivenSchemaCacheValue); + Assertions.assertTrue(((PluginDrivenSchemaCacheValue) result.get()).getPartitionColumns().isEmpty()); + } + + // ==================== getTableProperties (SHOW CREATE TABLE source, D-046) ==================== + + @Test + public void testGetTablePropertiesStripsSchemaControlKeysButKeepsUserOptions() { + // The connector stuffs BOTH user-facing table options (path / file.format) AND the FE-internal + // schema-control keys (reserved, namespaced under __internal.) into one properties map. + Map rawProps = new LinkedHashMap<>(); + 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"); + PluginDrivenSchemaCacheValue cacheValue = new PluginDrivenSchemaCacheValue( + Collections.singletonList(new Column("id", PrimitiveType.INT)), + Collections.emptyList(), Collections.emptyList(), rawProps); + PluginDrivenExternalTable table = tableWithCacheValue(cacheValue); + + Map props = table.getTableProperties(); + // WHY (D-046): SHOW CREATE TABLE's LOCATION reads "path" and PROPERTIES(...) dumps this map. + // The user-facing options MUST survive, but the FE-internal reserved keys MUST be stripped — + // they are emitted only so initSchema() can derive partition columns and would corrupt the + // round-tripped DDL. MUTATION: dropping the filter -> the reserved keys leak -> + // red; over-filtering (removing "path") -> LOCATION renders empty -> red. + Assertions.assertEquals("s3://wh/db/t", props.get("path")); + 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"); + } + + @Test + public void testGetTablePropertiesEmptyWhenConnectorEmitsNone() { + // MaxCompute-style connector emits no table properties: the 3-arg cache-value ctor must + // default to an empty map so SHOW CREATE TABLE stays comment-only (no empty LOCATION ''/ + // PROPERTIES () lines). MUTATION: defaulting to null -> NPE in getTableProperties / Env -> red. + PluginDrivenSchemaCacheValue cacheValue = new PluginDrivenSchemaCacheValue( + Collections.singletonList(new Column("c", PrimitiveType.INT)), + Collections.emptyList(), Collections.emptyList()); + PluginDrivenExternalTable table = tableWithCacheValue(cacheValue); + + Assertions.assertTrue(table.getTableProperties().isEmpty(), + "a connector emitting no properties (e.g. MaxCompute) must yield empty table properties"); + } + + // ==================== helpers ==================== + + /** A ConnectorSession mock that reports the off-context NONE statement scope (matches the real + * interface default), so the PluginDrivenMetadata funnel does not NPE on getStatementScope(). */ + private static ConnectorSession noneScopedSession() { + ConnectorSession s = Mockito.mock(ConnectorSession.class); + Mockito.when(s.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + return s; + } + + private static ConnectorPartitionInfo partition(String name, String year, String month) { + Map values = new LinkedHashMap<>(); + values.put("YEAR", year); + values.put("MONTH", month); + return new ConnectorPartitionInfo(name, values, Collections.emptyMap()); + } + + private static void assertPartition(Map items, String name, + String year, String month) { + PartitionItem item = items.get(name); + Assertions.assertNotNull(item, "missing partition " + name); + Assertions.assertTrue(item instanceof ListPartitionItem); + PartitionKey key = ((ListPartitionItem) item).getItems().get(0); + Assertions.assertEquals(year, key.getKeys().get(0).getStringValue(), + "partition value for the first (year) column must come from the YEAR remote key"); + Assertions.assertEquals(month, key.getKeys().get(1).getStringValue(), + "partition value for the second (month) column must come from the MONTH remote key"); + } + + private static List columnNames(List columns) { + List names = new ArrayList<>(columns.size()); + for (Column c : columns) { + names.add(c.getName()); + } + return names; + } + + /** Table whose schema-cache lookup returns the given value; not backed by a real connector. */ + private static PluginDrivenExternalTable tableWithCacheValue(SchemaCacheValue cacheValue) { + return tableWithCacheValue(cacheValue, + new TestablePluginCatalog("max_compute", Mockito.mock(ConnectorMetadata.class), + noneScopedSession()), + mockDb("REMOTE_DB"), "REMOTE_TBL"); + } + + private static PluginDrivenExternalTable tableWithCacheValue(SchemaCacheValue cacheValue, + PluginDrivenExternalCatalog catalog, ExternalDatabase db, String remoteName) { + return new PluginDrivenExternalTable(1L, "tbl", remoteName, catalog, db) { + @Override + protected synchronized void makeSureInitialized() { + // no-op: skip Env-backed catalog/db init + } + + @Override + public Optional getSchemaCacheValue() { + return Optional.of(cacheValue); + } + }; + } + + /** Table that drives the real initSchema(); does not stub the schema cache. */ + private static PluginDrivenExternalTable bareTable(PluginDrivenExternalCatalog catalog, + ExternalDatabase db, String remoteName) { + return new PluginDrivenExternalTable(1L, "tbl", remoteName, catalog, db) { + @Override + protected synchronized void makeSureInitialized() { + // no-op + } + }; + } + + @SuppressWarnings("unchecked") + private static ExternalDatabase mockDb(String remoteName) { + ExternalDatabase db = Mockito.mock(ExternalDatabase.class); + Mockito.when(db.getRemoteName()).thenReturn(remoteName); + return db; + } + + /** + * Minimal PluginDrivenExternalCatalog that returns a fixed connector/session without standing + * up the Doris environment (mirrors the pattern in PluginDrivenExternalTableEngineTest). + */ + private static class TestablePluginCatalog extends PluginDrivenExternalCatalog { + private final Connector connector; + private final ConnectorSession session; + + TestablePluginCatalog(String catalogType, ConnectorMetadata metadata, ConnectorSession session) { + this(catalogType, mockConnector(metadata, session), session); + } + + private TestablePluginCatalog(String catalogType, Connector connector, ConnectorSession session) { + super(1L, "test-catalog", null, makeProps(catalogType), "", connector); + this.connector = connector; + this.session = session; + } + + private static Connector mockConnector(ConnectorMetadata metadata, ConnectorSession session) { + Connector c = Mockito.mock(Connector.class); + Mockito.when(c.getMetadata(session)).thenReturn(metadata); + return c; + } + + @Override + public Connector getConnector() { + return connector; + } + + @Override + public ConnectorSession buildConnectorSession() { + return session; + } + + @Override + public ConnectorSession buildCrossStatementSession() { + return buildConnectorSession(); + } + + @Override + protected List listDatabaseNames() { + return Collections.emptyList(); + } + + @Override + protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { + return Collections.emptyList(); + } + + @Override + public boolean tableExist(SessionContext ctx, String dbName, String tblName) { + return false; + } + + private static Map makeProps(String type) { + Map props = new HashMap<>(); + props.put("type", type); + return props; + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableRowCountTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableRowCountTest.java new file mode 100644 index 00000000000000..f9e6e234fc98bd --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableRowCountTest.java @@ -0,0 +1,357 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.PrimitiveType; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.ConnectorTableStatistics; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.SchemaCacheValue; +import org.apache.doris.datasource.SessionContext; +import org.apache.doris.qe.GlobalVariable; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Tests {@link PluginDrivenExternalTable#fetchRowCount}'s two-layer statistics consumption (§4.2 read-side + * SPI): (1) an exact connector row count is used directly; (2) when the connector reports an on-disk data + * size but no exact count, fe-core estimates the cardinality as {@code dataSize / } — the + * type-dependent division the connector cannot do. The row width is summed over the FULL schema (partition + * columns included), mirroring legacy {@code StatisticsUtil.getHiveRowCount}. This branch is + * connector-agnostic: every non-hive connector reports dataSize -1, leaving it inert. + */ +public class PluginDrivenExternalTableRowCountTest { + + private static Column intCol(String name) { + return new Column(name, PrimitiveType.INT); // slot size 4 + } + + private static Column bigintCol(String name) { + return new Column(name, PrimitiveType.BIGINT); // slot size 8 + } + + @Test + public void exactRowCountUsedDirectlyIgnoringDataSize() { + // A present rowCount >= 0 short-circuits: dataSize is not consulted even when set. MUTATION: + // preferring the dataSize estimate would return 5000/4=1250, not 1234 -> red. + PluginDrivenExternalTable table = tableReturning( + Optional.of(new ConnectorTableStatistics(1234, 5000)), + Collections.singletonList(intCol("v"))); + Assertions.assertEquals(1234L, table.fetchRowCount()); + } + + @Test + public void dataSizeEstimatedOverFullSchemaWidth() { + // rowCount UNKNOWN(-1) + dataSize 4000, schema 10x INT (width 40) -> 4000/40 = 100. MUTATION: + // not estimating (returning UNKNOWN) -> red; wrong width -> wrong number. + List schema = Arrays.asList( + intCol("c0"), intCol("c1"), intCol("c2"), intCol("c3"), intCol("c4"), + intCol("c5"), intCol("c6"), intCol("c7"), intCol("c8"), intCol("c9")); + PluginDrivenExternalTable table = tableReturning( + Optional.of(new ConnectorTableStatistics(-1, 4000)), schema); + Assertions.assertEquals(100L, table.fetchRowCount()); + } + + @Test + public void partitionColumnsCountTowardRowWidth() { + // WHY: legacy getHiveRowCount summed the row width over the FULL schema, INCLUDING partition + // columns. Schema = INT(4) data + BIGINT(8) partition -> width 12; dataSize 1200 -> 100. A + // mutation excluding partition columns would use width 4 -> 300 -> red. + List schema = Arrays.asList(intCol("v"), bigintCol("dt")); + PluginDrivenExternalTable table = tableReturning( + Optional.of(new ConnectorTableStatistics(-1, 1200)), schema); + Assertions.assertEquals(100L, table.fetchRowCount()); + } + + @Test + public void emptyStatisticsYieldUnknown() { + PluginDrivenExternalTable table = tableReturning( + Optional.empty(), Collections.singletonList(intCol("v"))); + Assertions.assertEquals(TableIf.UNKNOWN_ROW_COUNT, table.fetchRowCount()); + } + + @Test + public void dataSizeWithEmptySchemaYieldsUnknownNotDivideByZero() { + // width 0 -> "cannot estimate" -> UNKNOWN (not an ArithmeticException). MUTATION: dividing by a + // 0 width throws -> red. + PluginDrivenExternalTable table = tableReturning( + Optional.of(new ConnectorTableStatistics(-1, 4000)), Collections.emptyList()); + Assertions.assertEquals(TableIf.UNKNOWN_ROW_COUNT, table.fetchRowCount()); + } + + @Test + public void unresolvableHandleYieldsUnknown() { + PluginDrivenExternalTable table = tableReturning( + null, Collections.singletonList(intCol("v"))); // null -> getTableHandle returns empty + Assertions.assertEquals(TableIf.UNKNOWN_ROW_COUNT, table.fetchRowCount()); + } + + // ==================== layer 3: file-list data-size estimate ==================== + + @Test + public void fileListEstimateDividesByNonPartitionWidth() { + // No exact count, no metastore size -> the connector's file-list dataSize (400) is divided by the + // NON-partition row width. Schema = INT(4) data "v" + BIGINT(8) partition "dt"; the BIGINT is + // EXCLUDED (its values live in the path, not the data files) -> width 4 -> 400/4 = 100. This is the + // deliberate contrast with layer 2 (which includes partition columns): a mutation using the full + // width (12) would return 33 -> red. + withFileListGate(true, () -> { + PluginDrivenExternalTable table = tableForFileList(400, + Arrays.asList(intCol("v"), bigintCol("dt")), Collections.singletonList(1)); + Assertions.assertEquals(100L, table.fetchRowCount()); + }); + } + + @Test + public void fileListEstimateSkippedWhenGateDisabled() { + // The global feature flag gates the (potentially costly) file listing. Off -> UNKNOWN even though the + // connector would return a size. MUTATION: ignoring the gate returns 100 here -> red. + withFileListGate(false, () -> { + PluginDrivenExternalTable table = tableForFileList(400, + Arrays.asList(intCol("v"), bigintCol("dt")), Collections.singletonList(1)); + Assertions.assertEquals(TableIf.UNKNOWN_ROW_COUNT, table.fetchRowCount()); + }); + } + + @Test + public void fileListEstimateUnknownWhenConnectorReturnsMinusOne() { + withFileListGate(true, () -> { + PluginDrivenExternalTable table = tableForFileList(-1, + Collections.singletonList(intCol("v")), Collections.emptyList()); + Assertions.assertEquals(TableIf.UNKNOWN_ROW_COUNT, table.fetchRowCount()); + }); + } + + @Test + public void fileListEstimateUnknownWhenAllColumnsArePartitions() { + // Every column is a partition column -> non-partition width 0 -> "cannot estimate" -> UNKNOWN (no + // divide-by-zero). MUTATION: dividing by a 0 width throws -> red. + withFileListGate(true, () -> { + PluginDrivenExternalTable table = tableForFileList(400, + Collections.singletonList(bigintCol("dt")), Collections.singletonList(0)); + Assertions.assertEquals(TableIf.UNKNOWN_ROW_COUNT, table.fetchRowCount()); + }); + } + + @Test + public void layer2ZeroQuotientFallsThroughToFileList() { + // totalSize 10 over a 3x INT schema (full width 12) truncates to 0 at layer 2. Legacy collapsed that + // 0 to UNKNOWN and fell through to the file-list estimate, so the connector's 120-byte file-list size + // / width 12 = 10 must win. MUTATION: returning the layer-2 quotient 0 (a bogus "empty table") or + // failing to fall through -> 0 -> red. + withFileListGate(true, () -> { + PluginDrivenExternalTable table = tableWith( + Optional.of(new ConnectorTableStatistics(-1, 10)), 120, + Arrays.asList(intCol("a"), intCol("b"), intCol("c")), Collections.emptyList()); + Assertions.assertEquals(10L, table.fetchRowCount()); + }); + } + + @Test + public void layer2ZeroQuotientYieldsUnknownWhenFileListDisabled() { + // Same truncate-to-0 layer-2 case, gate off: the answer is UNKNOWN, never the bogus 0. + withFileListGate(false, () -> { + PluginDrivenExternalTable table = tableWith( + Optional.of(new ConnectorTableStatistics(-1, 10)), 120, + Arrays.asList(intCol("a"), intCol("b"), intCol("c")), Collections.emptyList()); + Assertions.assertEquals(TableIf.UNKNOWN_ROW_COUNT, table.fetchRowCount()); + }); + } + + @Test + public void layer3ZeroQuotientYieldsUnknownNotEmptyTable() { + // A file-list size (10) smaller than the row width (12) truncates to 0; legacy getRowCountFromFileList + // returned UNKNOWN for a 0 result, so this must be UNKNOWN, not 0. MUTATION: returning 0 -> red. + withFileListGate(true, () -> { + PluginDrivenExternalTable table = tableWith(Optional.empty(), 10, + Arrays.asList(intCol("a"), intCol("b"), intCol("c")), Collections.emptyList()); + Assertions.assertEquals(TableIf.UNKNOWN_ROW_COUNT, table.fetchRowCount()); + }); + } + + private static void withFileListGate(boolean enabled, Runnable body) { + boolean previous = GlobalVariable.enable_get_row_count_from_file_list; + GlobalVariable.enable_get_row_count_from_file_list = enabled; + try { + body.run(); + } finally { + GlobalVariable.enable_get_row_count_from_file_list = previous; + } + } + + /** Table whose connector reports no getTableStatistics (empty) but a file-list size of {@code estimateBytes}. */ + private static PluginDrivenExternalTable tableForFileList( + long estimateBytes, List schema, List partitionColumnIndexes) { + return tableWith(Optional.empty(), estimateBytes, schema, partitionColumnIndexes); + } + + /** + * Table whose connector returns {@code stats} from getTableStatistics AND {@code estimateBytes} from the + * file-list estimate; {@code partitionColumnIndexes} names which schema columns are partition columns + * (excluded from the layer-3 row width). + */ + private static PluginDrivenExternalTable tableWith(Optional stats, + long estimateBytes, List schema, List partitionColumnIndexes) { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.of(handle)); + Mockito.when(metadata.getTableStatistics(session, handle)).thenReturn(stats); + Mockito.when(metadata.estimateDataSizeByListingFiles(session, handle)).thenReturn(estimateBytes); + TestablePluginCatalog catalog = new TestablePluginCatalog(metadata, session); + + @SuppressWarnings("unchecked") + ExternalDatabase db = Mockito.mock(ExternalDatabase.class); + Mockito.when(db.getRemoteName()).thenReturn("REMOTE_DB"); + + List partitionColumns = new java.util.ArrayList<>(); + List partitionRemoteNames = new java.util.ArrayList<>(); + for (int idx : partitionColumnIndexes) { + partitionColumns.add(schema.get(idx)); + partitionRemoteNames.add(schema.get(idx).getName()); + } + PluginDrivenSchemaCacheValue cacheValue = new PluginDrivenSchemaCacheValue( + schema, partitionColumns, partitionRemoteNames); + return new PluginDrivenExternalTable(1L, "tbl", "REMOTE_TBL", catalog, db) { + @Override + protected synchronized void makeSureInitialized() { + // no-op: skip Env-backed catalog/db init + } + + @Override + public Optional getSchemaCacheValue() { + return Optional.of(cacheValue); + } + }; + } + + /** + * Builds a table over a mock connector. {@code stats} == null makes {@code getTableHandle} return empty + * (unresolvable handle); otherwise the handle resolves and {@code getTableStatistics} returns {@code stats}. + * The full schema is served from a stubbed schema-cache value. + */ + private static PluginDrivenExternalTable tableReturning( + Optional stats, List schema) { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + if (stats == null) { + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.empty()); + } else { + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.of(handle)); + Mockito.when(metadata.getTableStatistics(session, handle)).thenReturn(stats); + } + TestablePluginCatalog catalog = new TestablePluginCatalog(metadata, session); + + @SuppressWarnings("unchecked") + ExternalDatabase db = Mockito.mock(ExternalDatabase.class); + Mockito.when(db.getRemoteName()).thenReturn("REMOTE_DB"); + + PluginDrivenSchemaCacheValue cacheValue = new PluginDrivenSchemaCacheValue( + schema, Collections.emptyList(), Collections.emptyList()); + return new PluginDrivenExternalTable(1L, "tbl", "REMOTE_TBL", catalog, db) { + @Override + protected synchronized void makeSureInitialized() { + // no-op: skip Env-backed catalog/db init + } + + @Override + public Optional getSchemaCacheValue() { + return Optional.of(cacheValue); + } + }; + } + + /** Minimal catalog returning a fixed connector/session without standing up the Doris environment. */ + private static class TestablePluginCatalog extends PluginDrivenExternalCatalog { + private final Connector connector; + private final ConnectorSession session; + + TestablePluginCatalog(ConnectorMetadata metadata, ConnectorSession session) { + this(mockConnector(metadata, session), session); + } + + private TestablePluginCatalog(Connector connector, ConnectorSession session) { + super(1L, "test-catalog", null, props(), "", connector); + this.connector = connector; + this.session = session; + } + + private static Connector mockConnector(ConnectorMetadata metadata, ConnectorSession session) { + Connector c = Mockito.mock(Connector.class); + Mockito.when(c.getMetadata(session)).thenReturn(metadata); + return c; + } + + @Override + public Connector getConnector() { + return connector; + } + + @Override + public ConnectorSession buildConnectorSession() { + return session; + } + + @Override + public ConnectorSession buildCrossStatementSession() { + return buildConnectorSession(); + } + + @Override + protected List listDatabaseNames() { + return Collections.emptyList(); + } + + @Override + protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { + return Collections.emptyList(); + } + + @Override + public boolean tableExist(SessionContext ctx, String dbName, String tblName) { + return false; + } + + private static Map props() { + Map props = new HashMap<>(); + props.put("type", "hms"); + return props; + } + } +} 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 new file mode 100644 index 00000000000000..c08cf81b13472a --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableTest.java @@ -0,0 +1,1017 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.ScalarType; +import org.apache.doris.common.jmockit.Deencapsulation; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.connector.api.ConnectorColumn; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.ConnectorTableSchema; +import org.apache.doris.connector.api.ConnectorType; +import org.apache.doris.connector.api.ConnectorViewDefinition; +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.mvcc.ConnectorMvccSnapshot; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.SchemaCacheValue; +import org.apache.doris.datasource.mvcc.MvccSnapshot; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccSnapshot; +import org.apache.doris.qe.ConnectContext; + +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Pins {@link PluginDrivenExternalTable#getFullSchema()} request-scoped synthetic-write-column injection + * (③ C3b-core). Post-flip, the iceberg DML row-id hidden column that legacy + * {@code IcebergExternalTable.getFullSchema} appended is gone (a {@link PluginDrivenExternalTable} carries + * no iceberg knowledge); the generic table must instead append whatever the connector declares through + * {@link ConnectorWritePlanProvider#getSyntheticWriteColumns}, gated request-side by show-hidden / the + * synthetic-write-column ctx flag. The injection is connector-agnostic (iron-law: no iceberg branch here), + * so these tests use a generic invisible synthetic column. + * + *

    Mockito {@code CALLS_REAL_METHODS} runs the real getFullSchema/needInternalHiddenColumns/fetch+append + * over stubbed seams (schema cache, the connector chain), mirroring {@code PhysicalIcebergMergeSinkTest}.

    + */ +public class PluginDrivenExternalTableTest { + + private static final List BASE_SCHEMA = ImmutableList.of( + new Column("id", ScalarType.INT, true, null, true, null, ""), + new Column("name", ScalarType.createStringType(), false, null, true, null, "")); + + /** A generic, connector-declared invisible synthetic write column (stands in for iceberg's row-id STRUCT). */ + private static final ConnectorColumn SYNTHETIC = + new ConnectorColumn("__syn_write_col__", ConnectorType.of("BIGINT"), "", false, null, false).invisible(); + + @AfterEach + public void clearCtx() { + ConnectContext.remove(); + } + + /** + * A ConnectorSession mock that reports the off-context {@link ConnectorStatementScope#NONE} scope, so the + * {@code PluginDrivenMetadata.get(session, connector)} funnel runs {@code connector.getMetadata(session)} on + * every call instead of NPE-ing on a null scope (a plain mock's getStatementScope() would return null). + */ + private static ConnectorSession noneScopedSession() { + ConnectorSession s = Mockito.mock(ConnectorSession.class); + Mockito.when(s.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + return s; + } + + // ==================== §4.4 W3: per-handle write-admission capability probes ==================== + + /** + * A CALLS_REAL_METHODS table whose connector answers the write capabilities PER-HANDLE (the overloads a + * heterogeneous gateway diverts). {@code handlePresent=false} models an unresolvable handle. + */ + private static PluginDrivenExternalTable capabilityTable(boolean handlePresent, + Set ops, boolean branch) { + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + Mockito.when(metadata.getTableHandle(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(handlePresent ? Optional.of(handle) : Optional.empty()); + 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); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(connector); + ConnectorSession session = noneScopedSession(); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(table, "catalog", catalog); + return table; + } + + @Test + public void connectorWriteCapabilitiesResolvePerHandle() { + Set ops = EnumSet.of(WriteOperation.INSERT, WriteOperation.DELETE, WriteOperation.MERGE); + PluginDrivenExternalTable table = capabilityTable(true, ops, true); + Assertions.assertEquals(ops, table.connectorSupportedWriteOperations(), + "the write ops must come from the connector's per-handle overload (resolved via the handle)"); + Assertions.assertTrue(table.connectorSupportsWriteBranch(), + "the branch capability must come from the connector's per-handle overload"); + Assertions.assertTrue(table.requirePartitionHashOnWrite(), + "partition-hash-write must come from the connector's per-handle overload"); + Assertions.assertTrue(table.materializeStaticPartitionValues(), + "materialize-static-partition must come from the connector's per-handle overload"); + } + + @Test + public void connectorWriteCapabilitiesDegradeWhenHandleUnresolvable() { + // An unresolvable handle (dropped table / catalog) must degrade to "no writes" — empty op set + false — + // rather than misrouting or NPE-ing, even though the connector WOULD report the capabilities for a handle. + PluginDrivenExternalTable table = capabilityTable(false, EnumSet.of(WriteOperation.DELETE), true); + Assertions.assertTrue(table.connectorSupportedWriteOperations().isEmpty(), + "an unresolvable handle degrades write ops to the empty set"); + Assertions.assertFalse(table.connectorSupportsWriteBranch(), + "an unresolvable handle degrades branch support to false"); + Assertions.assertFalse(table.requirePartitionHashOnWrite(), + "an unresolvable handle degrades partition-hash-write to false"); + Assertions.assertFalse(table.materializeStaticPartitionValues(), + "an unresolvable handle degrades materialize-static-partition to false"); + } + + @Test + public void connectorWriteCapabilitiesDegradeWhenConnectorNull() { + // A catalog dropped mid-planning nulls its transient connector; the probes must degrade (empty / false) + // rather than NPE — this is the null-connector guard the row-id / DML gates rely on. + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(null); + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(table, "catalog", catalog); + Assertions.assertTrue(table.connectorSupportedWriteOperations().isEmpty(), + "a null connector degrades write ops to the empty set"); + Assertions.assertFalse(table.connectorSupportsWriteBranch(), "a null connector degrades branch to false"); + } + + // ==================== §4.4 W4: per-handle transaction write-target handle resolution ==================== + + // A CALLS_REAL_METHODS table whose connector resolves the write-target handle to `resolved` (null => empty). + private static PluginDrivenExternalTable writeTargetTable(ConnectorTableHandle resolved) { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + Mockito.when(metadata.getTableHandle(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(Optional.ofNullable(resolved)); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(connector); + ConnectorSession session = noneScopedSession(); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(table, "catalog", catalog); + return table; + } + + @Test + public void resolveWriteTargetHandleReturnsTheConnectorResolvedHandle() { + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + Assertions.assertSame(handle, writeTargetTable(handle).resolveWriteTargetHandle(), + "the insert executor threads THIS handle into beginTransaction(session, handle) so a heterogeneous " + + "gateway opens the sibling's transaction for a foreign table"); + } + + @Test + public void resolveWriteTargetHandleFailsLoudWhenUnresolvable() { + // FAILS LOUD rather than returning null: a null handle is not an instanceof the gateway's own handle type + // and would misroute a plain write to the sibling. A downgrade to orElse(null) must break this test. + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> writeTargetTable(null).resolveWriteTargetHandle(), + "an unresolvable write-target handle must fail loud, not return null"); + Assertions.assertTrue(e.getMessage().startsWith("Cannot resolve the connector table handle for write target"), + "the fail-loud message must name the unresolved write target"); + } + + // ============= HD-C3 INC-4: synthetic scan predicate (connector residual predicate) plumbing ============= + + // A CALLS_REAL_METHODS table whose connector resolves the handle to `resolved` (null => empty) and returns + // `predicates` from getSyntheticScanPredicates. + private static PluginDrivenExternalTable syntheticPredicateTable(ConnectorTableHandle resolved, + List predicates) { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + Mockito.when(metadata.getTableHandle(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(Optional.ofNullable(resolved)); + Mockito.when(metadata.getSyntheticScanPredicates(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(predicates); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(connector); + ConnectorSession session = noneScopedSession(); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(table, "catalog", catalog); + return table; + } + + private static PluginDrivenMvccSnapshot pluginSnapshot() { + return new PluginDrivenMvccSnapshot(ConnectorMvccSnapshot.builder().build(), + Collections.emptyMap(), Collections.emptyMap()); + } + + @Test + public void getSyntheticScanPredicatesDelegatesToTheConnectorForAPluginSnapshot() { + // The analysis rule retrieves the resolved snapshot and asks the connector for its residual predicate + // (the hudi @incr _hoodie_commit_time window); the table threads it verbatim from the SPI. + List sentinel = Collections.singletonList( + new ConnectorColumnRef("_hoodie_commit_time", ConnectorType.of("STRING"))); + PluginDrivenExternalTable table = + syntheticPredicateTable(Mockito.mock(ConnectorTableHandle.class), sentinel); + Assertions.assertSame(sentinel, table.getSyntheticScanPredicates(pluginSnapshot()), + "the residual predicate must be threaded verbatim from the connector SPI"); + } + + @Test + public void getSyntheticScanPredicatesEmptyForNonPluginSnapshot() { + // A non-plugin MvccSnapshot carries no ConnectorMvccSnapshot to hand the SPI -> empty (and the guard + // avoids a ClassCastException). The rule then adds no filter. + MvccSnapshot foreign = Mockito.mock(MvccSnapshot.class); + Assertions.assertTrue(syntheticPredicateTable(Mockito.mock(ConnectorTableHandle.class), + Collections.emptyList()).getSyntheticScanPredicates(foreign).isEmpty(), + "a non-plugin snapshot must yield no synthetic predicates"); + } + + @Test + public void getSyntheticScanPredicatesEmptyWhenHandleUnresolvable() { + // An unresolvable handle (concurrent DROP / transient metadata error) degrades to empty rather than + // handing the gateway a null handle -> the rule simply adds no filter. + Assertions.assertTrue(syntheticPredicateTable(null, Collections.emptyList()) + .getSyntheticScanPredicates(pluginSnapshot()).isEmpty(), + "an unresolvable handle must degrade to no synthetic predicates"); + } + + /** + * Builds a CALLS_REAL_METHODS PluginDrivenExternalTable wired to a stubbed connector chain whose + * write-plan provider returns {@code synthetic}. {@code writeProviderPresent=false} models a read-only + * connector; {@code handlePresent=false} models an unresolvable handle. + */ + private static PluginDrivenExternalTable pluginTable(List synthetic, + boolean writeProviderPresent, boolean handlePresent) { + ConnectorWritePlanProvider provider = Mockito.mock(ConnectorWritePlanProvider.class); + Mockito.when(provider.getSyntheticWriteColumns(Mockito.any(), Mockito.any())).thenReturn(synthetic); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + Mockito.when(metadata.getTableHandle(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(handlePresent ? Optional.of(handle) : Optional.empty()); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getWritePlanProvider()).thenReturn(writeProviderPresent ? provider : null); + // Production now selects the write provider per-handle; a plain Mockito mock does not run the interface + // default, so stub the per-handle overload to the same provider (mirrors the scan seam). + Mockito.when(connector.getWritePlanProvider(Mockito.any())) + .thenReturn(writeProviderPresent ? provider : null); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); + + PluginDrivenExternalTable table = + Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(table, "catalog", catalog); + SchemaCacheValue scv = Mockito.mock(SchemaCacheValue.class); + Mockito.when(scv.getSchema()).thenReturn(BASE_SCHEMA); + Mockito.doReturn(Optional.of(scv)).when(table).getSchemaCacheValue(); + return table; + } + + @Test + public void getFullSchemaAppendsConvertedSyntheticColumnsWhenGated() { + PluginDrivenExternalTable table = pluginTable(Collections.singletonList(SYNTHETIC), true, true); + // Gate open: a DML over this table is in flight (the ctx flag drives needInternalHiddenColumns()). + Mockito.doReturn(true).when(table).needInternalHiddenColumns(); + + List schema = table.getFullSchema(); + + Assertions.assertEquals(BASE_SCHEMA.size() + 1, schema.size(), + "the connector's synthetic write column is appended after the base schema"); + Assertions.assertEquals("id", schema.get(0).getName()); + Assertions.assertEquals("name", schema.get(1).getName()); + Column appended = schema.get(2); + Assertions.assertEquals("__syn_write_col__", appended.getName(), + "the appended column is the converted connector-declared synthetic write column"); + Assertions.assertFalse(appended.isVisible(), + "the synthetic write column's invisible marker survives the SPI conversion"); + } + + @Test + public void getFullSchemaWithSnapshotAppendsSyntheticColumnsWhenGated() { + // The PLAN path (LogicalFileScan.computePluginDrivenOutput) calls the 1-arg overload, NOT the 0-arg + // one. It must append the synthetic write columns exactly like the 0-arg form -- when it did not, + // iceberg's row-id STRUCT vanished from the scan output: every row-level DML died with "Unknown + // column '__DORIS_ICEBERG_ROWID_COL__'" and SELECT * under show-hidden came back one column short + // (CI 997422, 9 suites). + // MUTATION: deleting the 1-arg override in PluginDrivenExternalTable -> red (ExternalTable's + // non-appending overload takes over and this returns BASE_SCHEMA.size()). + // NOTE: this MUST run on a CALLS_REAL_METHODS instance. Stubbing getFullSchema(Optional) on a mock + // intercepts before the real body, so it can never detect a missing override -- that blind spot is + // exactly why the regression shipped green. + PluginDrivenExternalTable table = pluginTable(Collections.singletonList(SYNTHETIC), true, true); + Mockito.doReturn(true).when(table).needInternalHiddenColumns(); + + List schema = table.getFullSchema(Optional.empty()); + + Assertions.assertEquals(BASE_SCHEMA.size() + 1, schema.size(), + "the 1-arg (plan-path) form appends the connector's synthetic write column"); + Assertions.assertEquals("__syn_write_col__", schema.get(2).getName()); + Assertions.assertEquals(table.getFullSchema(), schema, + "0-arg and 1-arg must agree: the synthetic write columns are request-scoped, not " + + "version-scoped, so only the BASE schema read differs between the two forms"); + } + + @Test + public void getFullSchemaWithSnapshotReturnsBaseWhenNotGated() { + // The 1-arg form honours the same gate: an ordinary query (no DML, no show-hidden) planned through + // LogicalFileScan must see exactly the base schema. MUTATION: routing the 1-arg override around the + // gate (always append) -> red. + PluginDrivenExternalTable table = pluginTable(Collections.singletonList(SYNTHETIC), true, true); + Mockito.doReturn(false).when(table).needInternalHiddenColumns(); + + List schema = table.getFullSchema(Optional.empty()); + + Assertions.assertEquals(BASE_SCHEMA.size(), schema.size(), + "ungated 1-arg getFullSchema returns the base schema with no synthetic write column"); + Assertions.assertTrue(schema.stream().noneMatch(c -> "__syn_write_col__".equals(c.getName()))); + } + + @Test + public void getFullSchemaReturnsBaseScheamWhenNotGated() { + // MUTATION: dropping the show-hidden/ctx gate (always append) makes this red — an ordinary query + // (no DML, no show-hidden) must see exactly the base schema, never the synthetic write column. + PluginDrivenExternalTable table = pluginTable(Collections.singletonList(SYNTHETIC), true, true); + Mockito.doReturn(false).when(table).needInternalHiddenColumns(); + + List schema = table.getFullSchema(); + + Assertions.assertEquals(BASE_SCHEMA.size(), schema.size(), + "ungated getFullSchema returns the base schema with no synthetic write column"); + Assertions.assertTrue(schema.stream().noneMatch(c -> "__syn_write_col__".equals(c.getName()))); + } + + @Test + public void getFullSchemaReturnsBaseWhenConnectorDeclaresNoSyntheticColumns() { + // A connector with no synthetic write columns (jdbc/es/paimon/maxcompute) keeps its byte-identical + // full schema even while gated. + PluginDrivenExternalTable table = pluginTable(Collections.emptyList(), true, true); + Mockito.doReturn(true).when(table).needInternalHiddenColumns(); + + Assertions.assertEquals(BASE_SCHEMA.size(), table.getFullSchema().size()); + } + + @Test + public void getFullSchemaDegradesWhenWriteProviderAbsent() { + // MUTATION: dropping the null-write-provider guard throws NPE here — a read-only connector + // (getWritePlanProvider()==null) must degrade to the base schema, never fail schema resolution. + PluginDrivenExternalTable table = pluginTable(Collections.singletonList(SYNTHETIC), false, true); + Mockito.doReturn(true).when(table).needInternalHiddenColumns(); + + Assertions.assertEquals(BASE_SCHEMA.size(), table.getFullSchema().size()); + } + + @Test + public void getFullSchemaDegradesWhenTableHandleAbsent() { + // MUTATION: dropping the absent-handle guard NPEs on handleOpt.get() — an unresolvable table handle + // must degrade to the base schema. + PluginDrivenExternalTable table = pluginTable(Collections.singletonList(SYNTHETIC), true, false); + Mockito.doReturn(true).when(table).needInternalHiddenColumns(); + + Assertions.assertEquals(BASE_SCHEMA.size(), table.getFullSchema().size()); + } + + /** + * Builds a CALLS_REAL_METHODS PluginDrivenExternalTable whose connector declares exactly + * {@code capabilities} connector-wide and whose cached schema emits no per-table capability marker, to + * exercise the capability-helper methods over the real connector chain. + */ + private static PluginDrivenExternalTable pluginTableWithCapabilities(Set capabilities) { + return pluginTableWithCapabilities(capabilities, Collections.emptyMap()); + } + + /** + * 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). + */ + private static PluginDrivenExternalTable pluginTableWithCapabilities( + Set capabilities, Map perTableProps) { + 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); + PluginDrivenExternalTable table = + Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(table, "catalog", catalog); + Mockito.doNothing().when(table).makeSureInitialized(); + Mockito.doReturn(Optional.of(scv)).when(table).getSchemaCacheValue(); + return table; + } + + @Test + public void supportsColumnAutoAnalyzeReflectsConnectorCapability() { + Assertions.assertTrue(pluginTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE)).supportsColumnAutoAnalyze()); + // The two capabilities are independent: declaring auto-analyze must NOT enable lazy top-N. + Assertions.assertFalse(pluginTableWithCapabilities( + 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 + // 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()); + Assertions.assertTrue(pluginTableWithCapabilities( + EnumSet.noneOf(ConnectorCapability.class), autoAnalyzeMarker).supportsColumnAutoAnalyze()); + // The marker is capability-specific: an auto-analyze marker must NOT enable Top-N. + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.noneOf(ConnectorCapability.class), autoAnalyzeMarker).supportsTopNLazyMaterialize()); + } + + @Test + public void supportsMetadataTableReflectsConnectorCapability() { + // The hudi_meta() / TIMELINE TVF's plugin arm gates on this. Hudi declares it connector-wide; the hive + // gateway reflects it onto a hudi-on-HMS table via the per-table marker (both resolved by hasScanCapability). + // MUTATION: hard-coding it / reading a different capability -> a flipped hudi table's hudi_meta() rejects + // with "not a hudi table". + Assertions.assertTrue(pluginTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_METADATA_TABLE)).supportsMetadataTable()); + Assertions.assertTrue(pluginTableWithCapabilities( + EnumSet.noneOf(ConnectorCapability.class), + Collections.singletonMap(ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY, + ConnectorCapability.SUPPORTS_METADATA_TABLE.name())).supportsMetadataTable()); + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.noneOf(ConnectorCapability.class)).supportsMetadataTable()); + // Independent of the other capabilities: declaring metadata-table must NOT enable auto-analyze. + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_METADATA_TABLE)).supportsColumnAutoAnalyze()); + } + + @Test + public void supportsSampleAnalyzeReflectsConnectorCapability() { + // AnalysisManager.canSample / AnalyzeTableCommand.isSamplingPartition / createAnalysisTask gate on this. + // Hive emits it per-table for plain-hive only (legacy dlaType==HIVE); iceberg/hudi-on-HMS and native + // iceberg/paimon do NOT declare it, keeping their build-time reject. MUTATION: hard-coding it / reading a + // different capability -> sampled ANALYZE wrongly admitted or wrongly rejected. + Assertions.assertTrue(pluginTableWithCapabilities( + 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()); + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.noneOf(ConnectorCapability.class)).supportsSampleAnalyze()); + // Independent: sample must NOT enable auto-analyze (iceberg-on-HMS gets auto-analyze but not sample). + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE)).supportsColumnAutoAnalyze()); + } + + @Test + public void getDistributionColumnNamesReadsLowercasedMarker() { + // Bucketing columns come from the connector's per-table marker (emitted RAW), lowercased HERE to mirror + // 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), + 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). + Assertions.assertTrue(pluginTableWithCapabilities(EnumSet.noneOf(ConnectorCapability.class)) + .getDistributionColumnNames().isEmpty()); + } + + @Test + public void supportsTopNLazyMaterializeReflectsConnectorCapability() { + Assertions.assertTrue(pluginTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE)).supportsTopNLazyMaterialize()); + // Independent the other way too: declaring lazy top-N must NOT enable auto-analyze. + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE)).supportsColumnAutoAnalyze()); + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.noneOf(ConnectorCapability.class)).supportsTopNLazyMaterialize()); + } + + @Test + public void supportsNestedColumnPruneReflectsConnectorCapability() { + // WHY (H-10 L1): LogicalFileScan.supportPruneNestedColumn and the SlotTypeReplacer name->field-id + // rewrite both gate on this for a flipped plugin table (replacing the legacy exact-class + // IcebergExternalTable arm). MUTATION: hard-coding true/false -> the capability no longer drives it. + Assertions.assertTrue(pluginTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE)).supportsNestedColumnPrune()); + // Independent of the other optimizer capabilities. + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE)).supportsTopNLazyMaterialize()); + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE)).supportsNestedColumnPrune()); + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.noneOf(ConnectorCapability.class)).supportsNestedColumnPrune()); + } + + @Test + public void scanCapabilityHonorsPerTableMarkerWhenConnectorWideAbsent() { + // 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()); + Assertions.assertTrue(pluginTableWithCapabilities( + EnumSet.noneOf(ConnectorCapability.class), topnMarker).supportsTopNLazyMaterialize()); + // The marker is capability-specific: a Top-N marker 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()); + Assertions.assertTrue(pluginTableWithCapabilities( + EnumSet.noneOf(ConnectorCapability.class), bothMarker).supportsTopNLazyMaterialize()); + Assertions.assertTrue(pluginTableWithCapabilities( + EnumSet.noneOf(ConnectorCapability.class), bothMarker).supportsNestedColumnPrune()); + // An empty / absent marker leaves both off (the plain-hive text-table case). + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.noneOf(ConnectorCapability.class), Collections.emptyMap()).supportsTopNLazyMaterialize()); + } + + @Test + public void supportsExternalMetadataPreloadReflectsConnectorCapability() { + // F11: async metadata pre-load is gated on the connector-declared SUPPORTS_METADATA_PRELOAD capability + // (replacing the legacy engine-name "jdbc" string). MUTATION: hard-coding true/false, or restoring the + // engine-name gate, -> the capability no longer drives it. + Assertions.assertTrue(pluginTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_METADATA_PRELOAD)).supportsExternalMetadataPreload()); + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.noneOf(ConnectorCapability.class)).supportsExternalMetadataPreload()); + // Independent of the other capabilities. + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE)).supportsExternalMetadataPreload()); + } + + @Test + public void capabilityHelpersReturnFalseWhenConnectorAbsent() { + // MUTATION: dropping the null-connector guard NPEs here — a catalog with no connector (read-only / + // not-yet-initialized) must degrade to "capability absent", never crash planning. + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(null); + PluginDrivenExternalTable table = + Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(table, "catalog", catalog); + Assertions.assertFalse(table.supportsColumnAutoAnalyze()); + Assertions.assertFalse(table.supportsTopNLazyMaterialize()); + Assertions.assertFalse(table.supportsShowCreateDdl()); + Assertions.assertFalse(table.supportsView()); + Assertions.assertFalse(table.supportsNestedColumnPrune()); + Assertions.assertFalse(table.supportsExternalMetadataPreload()); + } + + @Test + public void supportsShowCreateDdlReflectsConnectorCapability() { + // The SHOW CREATE TABLE plugin arm renders LOCATION/PROPERTIES/clauses only when this is true. + // MUTATION: dropping the capability check (or always-true) -> a credential-bearing connector (jdbc/es) + // would render its connection props -> red here for the no-capability case. + Assertions.assertTrue(pluginTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_SHOW_CREATE_DDL)).supportsShowCreateDdl()); + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.noneOf(ConnectorCapability.class)).supportsShowCreateDdl()); + // Independent of the other capabilities: declaring auto-analyze must NOT enable SHOW CREATE rendering. + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE)).supportsShowCreateDdl()); + } + + @Test + public void supportsViewReflectsConnectorCapability() { + // isView() resolution and the SHOW TABLES view-merge engage only when the connector declares + // SUPPORTS_VIEW. MUTATION: dropping the capability check (or always-true) -> view-less connectors + // (jdbc/es) would issue view round-trips / look like potential views -> red for the no-capability case. + Assertions.assertTrue(pluginTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_VIEW)).supportsView()); + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.noneOf(ConnectorCapability.class)).supportsView()); + // Independent of the other capabilities. + Assertions.assertFalse(pluginTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_SHOW_CREATE_DDL)).supportsView()); + } + + /** + * Builds a CALLS_REAL_METHODS PluginDrivenExternalTable wired so the REAL makeSureInitialized / + * resolveIsView / isView path runs end-to-end: the connector declares {@code caps}, its metadata reports + * {@code viewExists} for the (db, remote-name) pair, and the table resolves to remote {@code db1.v1}. The + * db is wired both as the {@code db} field (used by resolveIsView) and via getDbOrAnalysisException (used + * by the base makeSureInitialized). + */ + private static PluginDrivenExternalTable pluginViewTable(Set caps, boolean viewExists) { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + Mockito.when(metadata.viewExists(Mockito.any(), Mockito.anyString(), Mockito.anyString())) + .thenReturn(viewExists); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getCapabilities()).thenReturn(caps); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); + ExternalDatabase db = Mockito.mock(ExternalDatabase.class); + Mockito.when(db.getRemoteName()).thenReturn("db1"); + Mockito.when(db.getId()).thenReturn(100L); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); + try { + Mockito.when(catalog.getDbOrAnalysisException(Mockito.anyString())).thenReturn(db); + } catch (Exception ignore) { + // getDbOrAnalysisException declares a checked exception; the stub never throws. + } + PluginDrivenExternalTable table = + Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(table, "catalog", catalog); + Deencapsulation.setField(table, "db", db); + Deencapsulation.setField(table, "dbName", "db1"); + Deencapsulation.setField(table, "remoteName", "v1"); + return table; + } + + @Test + public void resolveIsViewConsultsConnectorWhenViewCapable() { + // WHY: a flipped table reports view-ness by asking the connector (mirrors legacy + // IcebergExternalTable.makeSureInitialized -> catalog.viewExists). MUTATION: returning a constant + // instead of metadata.viewExists -> red for one of the two cases. + Assertions.assertTrue( + pluginViewTable(EnumSet.of(ConnectorCapability.SUPPORTS_VIEW), true).resolveIsView()); + Assertions.assertFalse( + pluginViewTable(EnumSet.of(ConnectorCapability.SUPPORTS_VIEW), false).resolveIsView()); + } + + @Test + public void resolveIsViewIsFalseWithoutCapabilityAndIssuesNoViewRoundTrip() { + // WHY: view-less connectors (jdbc/es) must issue NO viewExists round-trip and stay isView()==false. + // MUTATION: dropping the supportsView() gate -> resolveIsView reaches viewExists(session,"db1","v1") + // (both args non-null so the stub returns true AND the verify(never) matches the call) -> the + // assertion flips to true and verify(never) trips -> red. + // NOTE: the table MUST carry a non-null remoteName + db (so the would-be viewExists call has non-null + // string args) — otherwise getRemoteName()==null dodges anyString() in both the stub and the verify, + // and the gate-drop mutation survives silently. + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + Mockito.when(metadata.viewExists(Mockito.any(), Mockito.anyString(), Mockito.anyString())) + .thenReturn(true); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getCapabilities()).thenReturn(EnumSet.noneOf(ConnectorCapability.class)); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); + ExternalDatabase db = Mockito.mock(ExternalDatabase.class); + Mockito.when(db.getRemoteName()).thenReturn("db1"); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); + PluginDrivenExternalTable table = + Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(table, "catalog", catalog); + Deencapsulation.setField(table, "db", db); + Deencapsulation.setField(table, "remoteName", "v1"); + + Assertions.assertFalse(table.resolveIsView()); + Mockito.verify(metadata, Mockito.never()) + .viewExists(Mockito.any(), Mockito.anyString(), Mockito.anyString()); + } + + @Test + public void resolveIsViewIsFalseWhenConnectorAbsent() { + // MUTATION: dropping the null-connector guard NPEs — a not-yet-initialized catalog must degrade to + // "not a view", never crash planning. + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(null); + PluginDrivenExternalTable table = + Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(table, "catalog", catalog); + Assertions.assertFalse(table.resolveIsView()); + } + + @Test + public void isViewSurfacesResolvedFlagThroughRealInit() { + // WHY: isView() must run makeSureInitialized (which resolves+caches the flag) and surface it. + // MUTATION: hard-coding isView() to the base false, or not triggering makeSureInitialized -> the + // resolved view flag is lost -> red. + Assertions.assertTrue( + pluginViewTable(EnumSet.of(ConnectorCapability.SUPPORTS_VIEW), true).isView()); + Assertions.assertFalse( + pluginViewTable(EnumSet.of(ConnectorCapability.SUPPORTS_VIEW), false).isView()); + } + + @Test + public void getViewTextReturnsConnectorViewSqlForVerbatimNames() { + // WHY: BindRelation's plugin view arm (and SHOW CREATE) take the view body from getViewText(); it must + // surface the connector's view SQL (NOT the dialect) for the table's REMOTE (db, view) pair. MUTATION: + // returning getDialect() instead of getSql() -> body becomes "spark" -> red; passing wrong db/view + // names -> the eq-stub misses -> null -> NPE -> red. + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + Mockito.when(metadata.getViewDefinition(Mockito.any(), Mockito.eq("db1"), Mockito.eq("v1"))) + .thenReturn(new ConnectorViewDefinition("SELECT 1", "spark", + ImmutableList.of( + new ConnectorColumn("vid", ConnectorType.of("INT"), "", true, null, true), + new ConnectorColumn("vname", ConnectorType.of("STRING"), "", true, null, true)))); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); + ExternalDatabase db = Mockito.mock(ExternalDatabase.class); + Mockito.when(db.getRemoteName()).thenReturn("db1"); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); + PluginDrivenExternalTable table = + Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(table, "catalog", catalog); + Deencapsulation.setField(table, "db", db); + Deencapsulation.setField(table, "remoteName", "v1"); + + Assertions.assertEquals("SELECT 1", table.getViewText()); + // Make the verbatim-names contract explicit (not just implicit via the eq-stub -> null -> NPE): + // getViewText must ask the connector for THIS table's remote (db, view) pair, exactly once. + Mockito.verify(metadata).getViewDefinition(Mockito.any(), Mockito.eq("db1"), Mockito.eq("v1")); + } + + /** + * Builds a CALLS_REAL_METHODS PluginDrivenExternalTable wired so the REAL initSchema runs against a stubbed + * connector chain: metadata returns {@code viewDef} from getViewDefinition (identity column mapping) and NO + * table handle (so a deleted isView() branch would fall through to an empty schema). isView() is stubbed + * directly to {@code isView} (the branch under test) — mirroring how the getFullSchema tests stub + * needInternalHiddenColumns. Remote (db, view) = (db1, v1). + */ + private static PluginDrivenExternalTable initSchemaViewTable(ConnectorViewDefinition viewDef, boolean isView) { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + Mockito.when(metadata.getViewDefinition(Mockito.any(), Mockito.eq("db1"), Mockito.eq("v1"))) + .thenReturn(viewDef); + // Identity column-name mapping (the 4th arg is the column name); the Mockito default would return null + // and NPE in toSchemaCacheValue. + Mockito.when(metadata.fromRemoteColumnName(Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.anyString())).thenAnswer(inv -> inv.getArgument(3)); + // No table handle: proves the view schema does NOT come from the table-handle path. + Mockito.when(metadata.getTableHandle(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(Optional.empty()); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); + ExternalDatabase db = Mockito.mock(ExternalDatabase.class); + Mockito.when(db.getRemoteName()).thenReturn("db1"); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); + PluginDrivenExternalTable table = + Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(table, "catalog", catalog); + Deencapsulation.setField(table, "db", db); + Deencapsulation.setField(table, "remoteName", "v1"); + Mockito.doReturn(isView).when(table).isView(); + return table; + } + + @Test + public void initSchemaBuildsViewSchemaFromViewDefinitionColumns() { + // WHY (H8): a flipped connector VIEW has no table handle (the SDK tableExists()==false for views), so + // initSchema must build the schema from getViewDefinition().getColumns() instead of the table-handle + // path — otherwise DESC / SHOW COLUMNS / information_schema.columns of the view are empty. MUTATION: + // deleting the isView() branch -> initSchema falls through to the (absent) table handle -> Optional.empty + // -> the present-with-columns assertions go red. + ConnectorViewDefinition viewDef = new ConnectorViewDefinition("SELECT 1", "spark", + ImmutableList.of( + new ConnectorColumn("vid", ConnectorType.of("INT"), "", true, null, true), + new ConnectorColumn("vname", ConnectorType.of("STRING"), "", true, null, true))); + PluginDrivenExternalTable table = initSchemaViewTable(viewDef, true); + + Optional result = table.initSchema(); + + Assertions.assertTrue(result.isPresent(), "a view must resolve a (non-empty) schema cache value"); + List columns = result.get().getSchema(); + Assertions.assertEquals(2, columns.size(), "the view schema columns come from the view definition"); + Assertions.assertEquals("vid", columns.get(0).getName()); + Assertions.assertEquals("vname", columns.get(1).getName()); + // A view has no partition columns (legacy IcebergUtils.loadViewSchemaCacheValue: empty partition list). + Assertions.assertTrue( + ((PluginDrivenSchemaCacheValue) result.get()).getPartitionColumns().isEmpty(), + "a view has no partition columns"); + } + + @Test + public void initSchemaUsesTableHandlePathForNonView() { + // WHY: the isView() branch must NOT hijack ordinary tables — a non-view must still resolve its schema + // via the table handle (getTableHandle -> getTableSchema) and must never call getViewDefinition. + // MUTATION: gating the new branch on something always-true (or inverting isView()) -> a table is routed + // through getViewDefinition / the handle path is skipped -> red. + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + Mockito.when(metadata.getTableHandle(Mockito.any(), Mockito.eq("db1"), Mockito.eq("t1"))) + .thenReturn(Optional.of(handle)); + Mockito.when(metadata.getTableSchema(Mockito.any(), Mockito.eq(handle))) + .thenReturn(new ConnectorTableSchema("t1", + ImmutableList.of(new ConnectorColumn("id", ConnectorType.of("INT"), "", true, null, true)), + "ICEBERG", Collections.emptyMap())); + Mockito.when(metadata.fromRemoteColumnName(Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.anyString())).thenAnswer(inv -> inv.getArgument(3)); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); + ExternalDatabase db = Mockito.mock(ExternalDatabase.class); + Mockito.when(db.getRemoteName()).thenReturn("db1"); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); + PluginDrivenExternalTable table = + Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(table, "catalog", catalog); + Deencapsulation.setField(table, "db", db); + Deencapsulation.setField(table, "remoteName", "t1"); + Mockito.doReturn(false).when(table).isView(); + + Optional result = table.initSchema(); + + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals(1, result.get().getSchema().size()); + Assertions.assertEquals("id", result.get().getSchema().get(0).getName()); + Mockito.verify(metadata, Mockito.never()) + .getViewDefinition(Mockito.any(), Mockito.anyString(), Mockito.anyString()); + } + + @Test + public void systemTableOverridesResolveIsViewToFalse() { + // A system/metadata table ($snapshots etc.) overrides resolveIsView to a constant false so the base + // never issues a viewExists round-trip on its synthetic "$"-suffixed name. Here the catalog declares + // SUPPORTS_VIEW and viewExists==true, so the BASE resolveIsView WOULD return true; the override must + // still yield false. MUTATION: dropping the sys override -> base consults the connector -> true -> red. + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + Mockito.when(metadata.viewExists(Mockito.any(), Mockito.anyString(), Mockito.anyString())) + .thenReturn(true); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getCapabilities()).thenReturn(EnumSet.of(ConnectorCapability.SUPPORTS_VIEW)); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); + ExternalDatabase db = Mockito.mock(ExternalDatabase.class); + Mockito.when(db.getRemoteName()).thenReturn("db1"); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); + + PluginDrivenSysExternalTable sys = + Mockito.mock(PluginDrivenSysExternalTable.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(sys, "catalog", catalog); + Deencapsulation.setField(sys, "db", db); + Deencapsulation.setField(sys, "remoteName", "v1$snapshots"); + + Assertions.assertFalse(sys.resolveIsView(), "a system table must never report itself as a view"); + } + + /** + * Builds a CALLS_REAL_METHODS PluginDrivenExternalTable whose schema cache returns {@code rawProps} as the + * connector-emitted raw table-property map, to exercise getTableProperties()/getShow* over the real strip + * + render-hint logic. makeSureInitialized is stubbed to a no-op (no Env-backed init in a unit test). + */ + private static PluginDrivenExternalTable pluginTableWithRawProperties(Map rawProps) { + PluginDrivenSchemaCacheValue scv = Mockito.mock(PluginDrivenSchemaCacheValue.class); + Mockito.when(scv.getTableProperties()).thenReturn(rawProps); + PluginDrivenExternalTable table = + Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + Mockito.doNothing().when(table).makeSureInitialized(); + Mockito.doReturn(Optional.of(scv)).when(table).getSchemaCacheValue(); + return table; + } + + @Test + public void getTablePropertiesStripsReservedKeysAndPassesThroughColludingUserKeys() { + // WHY: the rendered PROPERTIES(...) block must contain only user-facing properties — every FE-internal + // reserved control key (ConnectorTableSchema.RESERVED_CONTROL_KEYS, all namespaced under __internal.: + // the partition-columns / primary-keys markers + the SHOW CREATE render hints) must be stripped. + // Because the reserved keys are namespaced, a source table's own BARE property (e.g. literally named + // "partition_columns") can NEVER collide with one, so it flows through unchanged. + // MUTATION: reverting a reserved key to a bare name -> the bare user key would be stripped (data loss) + // or the reserved key would leak into PROPERTIES -> red. + 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.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)"); + raw.put("path", "s3://bucket/db/t"); + // A user's own BARE property whose name equals the OLD un-namespaced reserved name: it must survive. + raw.put("partition_columns", "a_user_value"); + + Map props = pluginTableWithRawProperties(raw).getTableProperties(); + + Assertions.assertEquals("parquet", props.get("write.format.default"), + "user-facing properties are preserved"); + Assertions.assertTrue(props.containsKey("path"), + "a connector's user-facing path property (paimon) is preserved"); + 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.SHOW_LOCATION_KEY)); + Assertions.assertFalse(props.containsKey(ConnectorTableSchema.SHOW_PARTITION_CLAUSE_KEY)); + Assertions.assertFalse(props.containsKey(ConnectorTableSchema.SHOW_SORT_CLAUSE_KEY)); + } + + @Test + public void getShowLocationReadsHintKeyWithPathFallback() { + // Reserved show-location hint -> rendered LOCATION. + Map iceberg = new HashMap<>(); + iceberg.put(ConnectorTableSchema.SHOW_LOCATION_KEY, "s3://bucket/db/t"); + Assertions.assertEquals("s3://bucket/db/t", + pluginTableWithRawProperties(iceberg).getShowLocation()); + + // Paimon carries its location in the user-facing "path" property (no show.location) -> path fallback. + // MUTATION: dropping the path fallback -> paimon LOCATION renders empty -> red. + Map paimon = new HashMap<>(); + paimon.put("path", "s3://bucket/db/p"); + Assertions.assertEquals("s3://bucket/db/p", + pluginTableWithRawProperties(paimon).getShowLocation()); + + // the show-location hint wins over path when both present (a connector that emits both). + Map both = new HashMap<>(); + both.put(ConnectorTableSchema.SHOW_LOCATION_KEY, "s3://hint"); + both.put("path", "s3://path"); + Assertions.assertEquals("s3://hint", pluginTableWithRawProperties(both).getShowLocation()); + + // Neither present -> empty (no LOCATION clause rendered). + Assertions.assertEquals("", pluginTableWithRawProperties(new HashMap<>()).getShowLocation()); + } + + @Test + public void getShowPartitionAndSortClauseReadHintKeys() { + Map raw = new HashMap<>(); + raw.put(ConnectorTableSchema.SHOW_PARTITION_CLAUSE_KEY, "PARTITION BY LIST (BUCKET(8, `id`)) ()"); + raw.put(ConnectorTableSchema.SHOW_SORT_CLAUSE_KEY, "ORDER BY (`name` DESC NULLS LAST)"); + PluginDrivenExternalTable table = pluginTableWithRawProperties(raw); + Assertions.assertEquals("PARTITION BY LIST (BUCKET(8, `id`)) ()", table.getShowPartitionClause()); + Assertions.assertEquals("ORDER BY (`name` DESC NULLS LAST)", table.getShowSortClause()); + + // Absent -> empty (no clause rendered). MUTATION: returning null/non-empty -> red. + PluginDrivenExternalTable none = pluginTableWithRawProperties(new HashMap<>()); + Assertions.assertEquals("", none.getShowPartitionClause()); + Assertions.assertEquals("", none.getShowSortClause()); + } + + @Test + public void needInternalHiddenColumnsTracksSyntheticWriteCtxFlag() { + // MUTATION: a needInternalHiddenColumns() that ignores the ctx flag (always false) makes post-flip + // DML skip the row-id injection. This pins the neutral ctx signal (set per-table during row-level DML). + PluginDrivenExternalTable table = + Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); + Mockito.doReturn(99L).when(table).getId(); + + ConnectContext ctx = new ConnectContext(); + ctx.setThreadLocalInfo(); + + ctx.setSyntheticWriteColTargetTableId(99L); + Assertions.assertTrue(table.needInternalHiddenColumns(), + "the ctx synthetic-write flag for this table id opens the hidden-column gate"); + + ctx.setSyntheticWriteColTargetTableId(101L); + Assertions.assertFalse(table.needInternalHiddenColumns(), + "the flag set for a different table id does not open the gate for this table"); + + ctx.setSyntheticWriteColTargetTableId(-1L); + Assertions.assertFalse(table.needInternalHiddenColumns(), + "the cleared (-1) flag closes the gate"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMetadataTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMetadataTest.java new file mode 100644 index 00000000000000..99bf828099a042 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMetadataTest.java @@ -0,0 +1,162 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.connector.ConnectorStatementScopeImpl; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Pins the engine-side metadata funnel {@link PluginDrivenMetadata}: within one statement it hands every caller + * the SAME memoized {@link ConnectorMetadata} per catalog (so read / scan / DDL / MVCC share one), isolates + * distinct catalogs, runs the factory on every call under {@link ConnectorStatementScope#NONE} (offline / + * byte-identical to today), and lets the scope close the memoized instance exactly once at statement end. + */ +public class PluginDrivenMetadataTest { + + private static ConnectorSession session(long catalogId, ConnectorStatementScope scope) { + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getCatalogId()).thenReturn(catalogId); + Mockito.when(session.getStatementScope()).thenReturn(scope); + return session; + } + + private static ConnectorSession session(long catalogId, ConnectorStatementScope scope, String user) { + ConnectorSession session = session(catalogId, scope); + Mockito.when(session.getUser()).thenReturn(user); + return session; + } + + private static Connector countingConnector(AtomicInteger builds) { + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getMetadata(Mockito.any())).thenAnswer(inv -> { + builds.incrementAndGet(); + return Mockito.mock(ConnectorMetadata.class); + }); + return connector; + } + + @Test + public void memoizesOneMetadataPerStatement() { + // The read/scan/DDL/MVCC resolvers of one statement all route through the funnel and must collapse onto a + // single getMetadata build and share the one instance. MUTATION: not memoizing -> two builds / two + // instances -> red. + ConnectorStatementScope scope = new ConnectorStatementScopeImpl(); + ConnectorSession session = session(7L, scope); + AtomicInteger builds = new AtomicInteger(); + Connector connector = countingConnector(builds); + + ConnectorMetadata first = PluginDrivenMetadata.get(session, connector); + ConnectorMetadata second = PluginDrivenMetadata.get(session, connector); + + Assertions.assertSame(first, second, "one statement + one catalog -> one shared metadata instance"); + Assertions.assertEquals(1, builds.get(), "connector.getMetadata is built once per statement"); + } + + @Test + public void noneBuildsMetadataEachCall() { + // No live statement scope (offline / tests / no ConnectContext): the funnel must degrade to today's + // behavior -> a fresh getMetadata every call, byte-identical to pre-funnel. MUTATION: NONE memoizing -> + // one build -> red. + ConnectorSession session = session(7L, ConnectorStatementScope.NONE); + AtomicInteger builds = new AtomicInteger(); + Connector connector = countingConnector(builds); + + ConnectorMetadata a = PluginDrivenMetadata.get(session, connector); + ConnectorMetadata b = PluginDrivenMetadata.get(session, connector); + + Assertions.assertNotSame(a, b, "NONE memoizes nothing -> a fresh metadata each call"); + Assertions.assertEquals(2, builds.get(), "NONE runs the factory (getMetadata) every call"); + } + + @Test + public void differentCatalogIdIsolates() { + // A cross-catalog statement (e.g. a MERGE reading two catalogs) resolves each catalog's metadata + // independently, because the memo key carries the catalog id. MUTATION: dropping the catalog id from the + // key -> the two catalogs collide onto one instance -> red. + ConnectorStatementScope scope = new ConnectorStatementScopeImpl(); + AtomicInteger builds = new AtomicInteger(); + Connector connector = countingConnector(builds); + + ConnectorMetadata c1 = PluginDrivenMetadata.get(session(1L, scope), connector); + ConnectorMetadata c2 = PluginDrivenMetadata.get(session(2L, scope), connector); + + Assertions.assertNotSame(c1, c2, "distinct catalogs are isolated"); + Assertions.assertEquals(2, builds.get(), "one build per distinct catalog"); + } + + @Test + public void sameIdentityReusesTheMemoizedInstance() { + // The write arm now reuses the read arm's memoized instance. Within one statement read and write are the + // same user, so the identity guard is satisfied and the instance is shared. MUTATION: guard throwing on a + // matching identity -> red. + ConnectorStatementScope scope = new ConnectorStatementScopeImpl(); + AtomicInteger builds = new AtomicInteger(); + Connector connector = countingConnector(builds); + + ConnectorMetadata first = PluginDrivenMetadata.get(session(7L, scope, "alice"), connector); + ConnectorMetadata second = PluginDrivenMetadata.get(session(7L, scope, "alice"), connector); + + Assertions.assertSame(first, second, "same user in one statement -> one shared instance"); + Assertions.assertEquals(1, builds.get(), "built once"); + } + + @Test + public void differentIdentityReusingTheInstanceFailsLoud() { + // A session=user connector bakes the querying user's delegated credential into the instance at build time. + // Reusing that instance under a second identity would execute one user's operation with another's + // credentials, so the funnel fails loud rather than serving it. MUTATION: dropping the identity guard -> + // the mismatched reuse silently returns the first user's instance -> red. + ConnectorStatementScope scope = new ConnectorStatementScopeImpl(); + AtomicInteger builds = new AtomicInteger(); + Connector connector = countingConnector(builds); + + PluginDrivenMetadata.get(session(7L, scope, "alice"), connector); + + IllegalStateException ex = Assertions.assertThrows(IllegalStateException.class, + () -> PluginDrivenMetadata.get(session(7L, scope, "bob"), connector)); + Assertions.assertTrue(ex.getMessage().contains("identity mismatch"), + "message names the identity mismatch: " + ex.getMessage()); + } + + @Test + public void closeAllClosesTheMemoizedMetadataOnce() throws Exception { + // The statement's one memoized ConnectorMetadata (which is Closeable) is closed deterministically at + // statement end, exactly once even though the engine fires closeAll from more than one locus (query-finish + // callback + prepared-statement reset). MUTATION: closeAll not idempotent -> close() called twice -> red. + ConnectorStatementScopeImpl scope = new ConnectorStatementScopeImpl(); + ConnectorSession session = session(7L, scope); + ConnectorMetadata md = Mockito.mock(ConnectorMetadata.class); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(md); + + PluginDrivenMetadata.get(session, connector); + scope.closeAll(); + scope.closeAll(); + + Mockito.verify(md, Mockito.times(1)).close(); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMvccTableFactoryTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMvccTableFactoryTest.java new file mode 100644 index 00000000000000..a4eb8d5f033012 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMvccTableFactoryTest.java @@ -0,0 +1,139 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.plugin; + +import org.apache.doris.catalog.TableIf; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable; +import org.apache.doris.persist.gson.GsonUtils; + +import com.google.common.collect.Sets; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Collections; + +/** + * Tests for the capability-selected table factory in {@link PluginDrivenExternalDatabase} and the + * GSON registration of {@link PluginDrivenMvccExternalTable}. + * + *

    Why these matter: the factory is the ONLY place a connector's MVCC capability is turned + * into the right table class. If it always built the base class, an MTMV over a Paimon table would + * never see the MvccTable/MTMV hooks (no snapshot pinning, broken incremental refresh); if it always + * built the subclass, plain jdbc/es/max_compute tables would acquire MTMV behavior they do not + * support. The GSON test guards edit-log durability: a persisted MVCC table must deserialize back as + * the SAME subclass, otherwise an FE restart would silently downgrade it to the base table and lose + * the MVCC behavior on replay.

    + */ +public class PluginDrivenMvccTableFactoryTest { + + // ==================== factory: capability selects the subclass ==================== + + @Test + public void testBuildsMvccTableWhenConnectorDeclaresMvccCapability() { + PluginDrivenExternalDatabase db = new PluginDrivenExternalDatabase(); + PluginDrivenExternalCatalog catalog = catalogWithCapabilities( + ConnectorCapability.SUPPORTS_MVCC_SNAPSHOT); + + ExternalTable table = db.buildTableInternal("rt", "lt", 1L, catalog, db); + + // MUTATION: always returning the base class (dropping the capability branch) makes this red. + Assertions.assertTrue(table instanceof PluginDrivenMvccExternalTable, + "an MVCC-capable connector must yield the MVCC/MTMV subclass"); + } + + @Test + public void testBuildsBaseTableWhenConnectorLacksMvccCapability() { + PluginDrivenExternalDatabase db = new PluginDrivenExternalDatabase(); + // jdbc/es/max_compute/trino-connector advertise no MVCC capability. + PluginDrivenExternalCatalog catalog = catalogWithCapabilities( + ConnectorCapability.SUPPORTS_VIEW); + + ExternalTable table = db.buildTableInternal("rt", "lt", 1L, catalog, db); + + // MUTATION: always returning the subclass makes this red — a non-MVCC connector must NOT get + // MTMV behavior. instanceof would still pass on a subclass, so assert the EXACT class. + Assertions.assertSame(PluginDrivenExternalTable.class, table.getClass(), + "a connector without SUPPORTS_MVCC_SNAPSHOT must keep the base PluginDrivenExternalTable"); + } + + @Test + public void testBuildsBaseTableWhenConnectorIsNull() { + PluginDrivenExternalDatabase db = new PluginDrivenExternalDatabase(); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(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. + Assertions.assertSame(PluginDrivenExternalTable.class, table.getClass(), + "a not-yet-built connector must degrade to the base table, never NPE"); + } + + // ==================== GSON: MVCC subclass survives a round-trip ==================== + + @Test + public void testMvccTableGsonRoundTripPreservesSubclass() { + PluginDrivenMvccExternalTable table = new PluginDrivenMvccExternalTable(); + // Set only the GSON-serialized fields; catalog/db are not @SerializedName so they are not + // persisted (and need not be set for a pure serialization round-trip). These fields are + // declared protected in ExternalTable (a different package after the datasource split), so + // they are injected reflectively rather than assigned directly. + setExternalTableField(table, "id", 7L); + setExternalTableField(table, "name", "mvcc_tbl"); + setExternalTableField(table, "remoteName", "REMOTE_MVCC_TBL"); + setExternalTableField(table, "dbName", "mvcc_db"); + + // Round-trip through the TableIf hierarchy so the polymorphic "clazz" discriminator is used. + String json = GsonUtils.GSON.toJson(table, TableIf.class); + TableIf restored = GsonUtils.GSON.fromJson(json, TableIf.class); + + // MUTATION: omitting the registerSubtype(PluginDrivenMvccExternalTable) makes serialization + // throw "subtype not registered", failing this test. A wrong registration (e.g. tagging it as + // the base class) would deserialize to PluginDrivenExternalTable and fail the instanceof. + Assertions.assertTrue(restored instanceof PluginDrivenMvccExternalTable, + "a persisted MVCC table must deserialize back as PluginDrivenMvccExternalTable"); + Assertions.assertEquals(7L, restored.getId()); + Assertions.assertEquals("mvcc_tbl", restored.getName()); + } + + // ==================== helpers ==================== + + private static void setExternalTableField(ExternalTable table, String field, Object value) { + try { + java.lang.reflect.Field f = ExternalTable.class.getDeclaredField(field); + f.setAccessible(true); + f.set(table, value); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("failed to set ExternalTable." + field, e); + } + } + + 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 catalog; + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenSysExternalTableTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenSysExternalTableTest.java new file mode 100644 index 00000000000000..aa2d0d229fe3f7 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenSysExternalTableTest.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.plugin; + +import org.apache.doris.common.jmockit.Deencapsulation; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.EnumSet; +import java.util.Set; + +/** + * Pins the system-table opt-outs from Top-N lazy materialization and nested-column pruning on + * {@link PluginDrivenSysExternalTable}. + * + *

    WHY the lazy-mat opt-out matters: a system/metadata table (e.g. {@code tbl$snapshots}) is served by the + * connector's JNI serialized-split metadata reader, which synthesizes rows and produces no file+position row-id. + * Top-N lazy materialization injects the engine-wide row-id slot ({@code __DORIS_GLOBAL_ROWID_COL__}) and expects + * the scan to re-fetch survivors by row-id, so admitting a sys table makes BE abort with + * {@code __DORIS_GLOBAL_ROWID_COL__... return column size 0 not equal to expected size 1}. Legacy never lazy- + * materialized sys tables ({@code IcebergSysExternalTable} is absent from + * {@code MaterializeProbeVisitor.SUPPORT_RELATION_TYPES}); the base {@link PluginDrivenExternalTable} keys the + * capability off the connector alone, so the sys table must opt out itself. + * + *

    WHY the nested-prune opt-out matters: pruning would rewrite a complex column's access-path top element from + * its NAME to a numeric iceberg field id ({@code SlotTypeReplacer}), but a system-table scan ships no field-id + * dictionary ({@code IcebergScanPlanProvider} skips {@code SCHEMA_EVOLUTION_PROP} when {@code systemTable}), so + * BE cannot field-id-match and rejects the scan with {@code AccessPathParser access path N does not match slot X}. + * Legacy gated the field-id rewrite on the exact class {@code IcebergExternalTable}, which sys tables are not, so + * it never fired for them; the migrated gate keys off the connector capability alone, so the sys table must opt + * out itself. + * + *

    Mockito {@code CALLS_REAL_METHODS} runs the real capability methods over a stubbed connector chain, + * mirroring {@code PluginDrivenExternalTableTest}. + */ +public class PluginDrivenSysExternalTableTest { + + /** + * A CALLS_REAL_METHODS {@link PluginDrivenSysExternalTable} whose connector declares exactly + * {@code capabilities}, to exercise the capability-helper methods over the real connector chain. Only the + * {@code catalog} field is set — the methods under test never touch the sys-table's source/name fields. + */ + private static PluginDrivenSysExternalTable sysTableWithCapabilities(Set capabilities) { + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getCapabilities()).thenReturn(capabilities); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(connector); + PluginDrivenSysExternalTable table = + Mockito.mock(PluginDrivenSysExternalTable.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(table, "catalog", catalog); + return table; + } + + @Test + public void systemTableNeverSupportsTopNLazyMaterializeEvenWhenConnectorDeclaresIt() { + // The BE JNI metadata reader cannot produce the lazy-mat row-id for a synthesized sys-table row, so the + // sys table must opt out of Top-N lazy materialization even though its connector declares the + // capability. MUTATION: deleting the override re-inherits the connector-capability answer -> true -> red. + Assertions.assertFalse(sysTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE)).supportsTopNLazyMaterialize(), + "a system/metadata table must never lazy-materialize, even when the connector supports it"); + } + + @Test + public void systemTableNeverSupportsNestedColumnPruneEvenWhenConnectorDeclaresIt() { + // A system/metadata-table scan ships NO field-id dictionary, so the name->field-id access-path rewrite BE + // would receive (SlotTypeReplacer) cannot be field-id-matched and BE rejects it with + // "AccessPathParser access path N does not match slot X". The sys table must therefore opt out of + // nested-column prune (disabling both name-based path generation and the field-id rewrite), even though + // its connector declares the capability. On master the field-id rewrite was gated on the exact class + // IcebergExternalTable, which a sys table is not, so it never fired for sys tables. + // MUTATION: deleting the override re-inherits the connector-capability answer -> true -> red. + Assertions.assertFalse(sysTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE)).supportsNestedColumnPrune(), + "a system/metadata table must never nested-column-prune, even when the connector supports it"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/AWSGlueMetaStoreBasePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/AWSGlueMetaStoreBasePropertiesTest.java deleted file mode 100644 index f3d9e0490b99dd..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/AWSGlueMetaStoreBasePropertiesTest.java +++ /dev/null @@ -1,139 +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.datasource.property.metastore; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.HashMap; -import java.util.Map; - -public class AWSGlueMetaStoreBasePropertiesTest { - private Map baseValidProps() { - Map props = new HashMap<>(); - props.put("glue.access_key", "ak"); - props.put("glue.secret_key", "sk"); - props.put("glue.endpoint", "https://glue.us-east-1.amazonaws.com"); - return props; - } - - @Test - void testValidPropertiesWithRegionFromEndpoint() { - Map props = baseValidProps(); - // no region set -> should be extracted from endpoint - AWSGlueMetaStoreBaseProperties glueProps = AWSGlueMetaStoreBaseProperties.of(props); - Assertions.assertEquals("ak", glueProps.glueAccessKey); - Assertions.assertEquals("sk", glueProps.glueSecretKey); - Assertions.assertEquals("us-east-1", glueProps.glueRegion); - } - - @Test - void testValidPropertiesWithRegion() { - Map props = baseValidProps(); - props.put("glue.region", "us-east-1"); - props.put("glue.endpoint", "https://glue.us-east-1.amazonaws.com.cn"); - AWSGlueMetaStoreBaseProperties glueProps = AWSGlueMetaStoreBaseProperties.of(props); - Assertions.assertTrue("https://glue.us-east-1.amazonaws.com.cn".equals(glueProps.glueEndpoint)); - Assertions.assertEquals("us-east-1", glueProps.glueRegion); - props.remove("glue.region"); - glueProps = AWSGlueMetaStoreBaseProperties.of(props); - Assertions.assertTrue("https://glue.us-east-1.amazonaws.com.cn".equals(glueProps.glueEndpoint)); - Assertions.assertEquals("us-east-1", glueProps.glueRegion); - } - - @Test - void testValidPropertiesWithExplicitRegion() { - Map props = baseValidProps(); - props.put("glue.region", "ap-southeast-1"); - AWSGlueMetaStoreBaseProperties glueProps = AWSGlueMetaStoreBaseProperties.of(props); - Assertions.assertEquals("ap-southeast-1", glueProps.glueRegion); - } - - @Test - void testMissingAccessKeyThrows() { - Map props = baseValidProps(); - props.remove("glue.access_key"); - IllegalArgumentException ex = Assertions.assertThrows( - IllegalArgumentException.class, - () -> AWSGlueMetaStoreBaseProperties.of(props) - ); - Assertions.assertTrue(ex.getMessage().contains("glue.access_key")); - } - - @Test - void testMissingSecretKeyThrows() { - Map props = baseValidProps(); - props.remove("glue.secret_key"); - - IllegalArgumentException ex = Assertions.assertThrows( - IllegalArgumentException.class, - () -> AWSGlueMetaStoreBaseProperties.of(props) - ); - Assertions.assertTrue(ex.getMessage().contains("glue.access_key and glue.secret_key must be set together")); - } - - @Test - void testMissingEndpointThrows() { - Map props = baseValidProps(); - props.remove("glue.endpoint"); - - IllegalArgumentException ex = Assertions.assertThrows( - IllegalArgumentException.class, - () -> AWSGlueMetaStoreBaseProperties.of(props) - ); - Assertions.assertTrue(ex.getMessage().contains("glue.endpoint must be set")); - } - - @Test - void testInvalidEndpoint() { - Map props = baseValidProps(); - props.put("glue.endpoint", "http://invalid-endpoint.com"); - IllegalArgumentException ex = Assertions.assertThrows( - IllegalArgumentException.class, - () -> AWSGlueMetaStoreBaseProperties.of(props) - ); - Assertions.assertTrue(ex.getMessage().contains("glue.endpoint must use https protocol,please set glue.endpoint to https://...")); - props.put("glue.endpoint", "http://glue.us-east-1.amazonaws.com"); - ex = Assertions.assertThrows( - IllegalArgumentException.class, - () -> AWSGlueMetaStoreBaseProperties.of(props) - ); - Assertions.assertTrue(ex.getMessage().contains("glue.endpoint must use https protocol,please set glue.endpoint to https://...")); - } - - @Test - void testExtractRegionFailsWhenPatternMatchesButNoRegion() { - Map props = baseValidProps(); - props.put("glue.endpoint", "glue..amazonaws.com"); // malformed - IllegalArgumentException ex = Assertions.assertThrows( - IllegalArgumentException.class, - () -> AWSGlueMetaStoreBaseProperties.of(props) - ); - Assertions.assertTrue(ex.getMessage().contains("glue.endpoint must use https protocol,please set glue.endpoint to https://...")); - } - - @Test - void testIamRole() { - Map props = baseValidProps(); - props.remove("glue.access_key"); - props.remove("glue.secret_key"); - props.put("glue.role_arn", "arn:aws:iam::1001:role/doris-glue-role"); - AWSGlueMetaStoreBaseProperties glueProps = AWSGlueMetaStoreBaseProperties.of(props); - Assertions.assertEquals("arn:aws:iam::1001:role/doris-glue-role", glueProps.glueIAMRole); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/AbstractIcebergPropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/AbstractIcebergPropertiesTest.java deleted file mode 100644 index 228afe85b618f4..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/AbstractIcebergPropertiesTest.java +++ /dev/null @@ -1,125 +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.datasource.property.metastore; - -import org.apache.doris.datasource.property.storage.StorageProperties; - -import org.apache.iceberg.CatalogProperties; -import org.apache.iceberg.catalog.Catalog; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class AbstractIcebergPropertiesTest { - - private static class TestIcebergProperties extends AbstractIcebergProperties { - private final Catalog catalogToReturn; - private Map capturedCatalogProps; - - TestIcebergProperties(Map props, Catalog catalogToReturn) { - super(props); - this.catalogToReturn = catalogToReturn; - } - - @Override - public String getIcebergCatalogType() { - return "test"; - } - - @Override - protected Catalog initCatalog(String catalogName, - Map catalogProps, - List storagePropertiesList) { - // Capture the catalogProps for verification - this.capturedCatalogProps = new HashMap<>(catalogProps); - return catalogToReturn; - } - - Map getCapturedCatalogProps() { - return capturedCatalogProps; - } - } - - @Test - void testInitializeCatalogWithWarehouse() { - Catalog mockCatalog = Mockito.mock(Catalog.class); - Mockito.when(mockCatalog.name()).thenReturn("mocked-catalog"); - Map props = new HashMap<>(); - props.put("k1", "v1"); - TestIcebergProperties properties = new TestIcebergProperties(props, mockCatalog); - properties.warehouse = "s3://bucket/warehouse"; - Catalog result = properties.initializeCatalog("testCatalog", Collections.emptyList()); - Assertions.assertNotNull(result); - Assertions.assertEquals("mocked-catalog", result.name()); - // Verify that warehouse is included in catalogProps - Assertions.assertTrue(properties.getCapturedCatalogProps() - .containsKey(CatalogProperties.WAREHOUSE_LOCATION)); - Assertions.assertEquals("s3://bucket/warehouse", - properties.getCapturedCatalogProps().get(CatalogProperties.WAREHOUSE_LOCATION)); - Assertions.assertNotNull(properties.getExecutionAuthenticator()); - } - - @Test - void testInitializeCatalogWithoutWarehouse() { - Catalog mockCatalog = Mockito.mock(Catalog.class); - Mockito.when(mockCatalog.name()).thenReturn("no-warehouse"); - TestIcebergProperties properties = new TestIcebergProperties(new HashMap<>(), mockCatalog); - properties.warehouse = null; - Catalog result = properties.initializeCatalog("testCatalog", Collections.emptyList()); - Assertions.assertNotNull(result); - Assertions.assertEquals("no-warehouse", result.name()); - // Verify that warehouse key is not present - Assertions.assertFalse(properties.getCapturedCatalogProps() - .containsKey(CatalogProperties.WAREHOUSE_LOCATION)); - } - - @Test - void testInitializeCatalogThrowsWhenNull() { - AbstractIcebergProperties properties = new AbstractIcebergProperties(new HashMap<>()) { - @Override - public String getIcebergCatalogType() { - return "test"; - } - - @Override - protected Catalog initCatalog(String catalogName, - Map catalogProps, - List storagePropertiesList) { - return null; // Simulate a failure case - } - }; - - IllegalStateException ex = Assertions.assertThrows( - IllegalStateException.class, - () -> properties.initializeCatalog("testCatalog", Collections.emptyList()) - ); - Assertions.assertEquals("Catalog must not be null after initialization.", ex.getMessage()); - } - - @Test - void testExecutionAuthenticatorNotNull() { - Catalog mockCatalog = Mockito.mock(Catalog.class); - TestIcebergProperties properties = new TestIcebergProperties(new HashMap<>(), mockCatalog); - Assertions.assertNotNull(properties.executionAuthenticator); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/AbstractPaimonPropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/AbstractPaimonPropertiesTest.java deleted file mode 100644 index e5a775ba6e3ef2..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/AbstractPaimonPropertiesTest.java +++ /dev/null @@ -1,89 +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.datasource.property.metastore; - -import org.apache.doris.datasource.property.storage.StorageProperties; - -import org.apache.paimon.catalog.Catalog; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class AbstractPaimonPropertiesTest { - - private static class TestPaimonProperties extends AbstractPaimonProperties { - - - protected TestPaimonProperties(Map props) { - super(props); - } - - @Override - public String getPaimonCatalogType() { - return "test"; - } - - @Override - public Catalog initializeCatalog(String catalogName, List storagePropertiesList) { - return null; - } - - @Override - protected void appendCustomCatalogOptions() { - - } - - @Override - protected String getMetastoreType() { - return "test"; - } - } - - TestPaimonProperties props; - - @BeforeEach - void setup() { - Map input = new HashMap<>(); - input.put("warehouse", "s3://tmp/warehouse"); - input.put("paimon.metastore", "filesystem"); - input.put("paimon.s3.access-key", "AK"); - input.put("paimon.s3.secret-key", "SK"); - input.put("paimon.custom.key", "value"); - props = new TestPaimonProperties(input); - } - - @Test - void testNormalizeS3Config() { - Map input = new HashMap<>(); - input.put("paimon.s3.list.version", "1"); - input.put("paimon.s3.paging.maximum", "100"); - input.put("paimon.fs.s3.read.ahead.buffer.size", "1"); - input.put("paimon.s3a.replication.factor", "3"); - TestPaimonProperties testProps = new TestPaimonProperties(input); - Map result = testProps.normalizeS3Config(); - Assertions.assertTrue("1".equals(result.get("fs.s3a.list.version"))); - Assertions.assertTrue("100".equals(result.get("fs.s3a.paging.maximum"))); - Assertions.assertTrue("1".equals(result.get("fs.s3a.read.ahead.buffer.size"))); - Assertions.assertTrue("3".equals(result.get("fs.s3a.replication.factor"))); - } - -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/AliyunDLFBasePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/AliyunDLFBasePropertiesTest.java deleted file mode 100644 index 42862ef0c75f31..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/AliyunDLFBasePropertiesTest.java +++ /dev/null @@ -1,117 +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.datasource.property.metastore; - -import org.apache.doris.foundation.property.ConnectorPropertiesUtils; -import org.apache.doris.foundation.property.StoragePropertiesException; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.HashMap; -import java.util.Map; -import java.util.Set; - - -public class AliyunDLFBasePropertiesTest { - - @Test - void testAutoGenerateEndpointWithPublicAccess() { - Map props = new HashMap<>(); - props.put("dlf.access_key", "ak"); - props.put("dlf.secret_key", "sk"); - props.put("dlf.region", "cn-hangzhou"); - props.put("dlf.access.public", "true"); - - AliyunDLFBaseProperties dlfProps = AliyunDLFBaseProperties.of(props); - Assertions.assertEquals("dlf.cn-hangzhou.aliyuncs.com", dlfProps.dlfEndpoint); - } - - @Test - void testAutoGenerateEndpointWithVpcAccess() { - Map props = new HashMap<>(); - props.put("dlf.access_key", "ak"); - props.put("dlf.secret_key", "sk"); - props.put("dlf.region", "cn-hangzhou"); - props.put("dlf.access.public", "false"); - - AliyunDLFBaseProperties dlfProps = AliyunDLFBaseProperties.of(props); - Assertions.assertEquals("dlf-vpc.cn-hangzhou.aliyuncs.com", dlfProps.dlfEndpoint); - } - - @Test - void testExplicitEndpointOverridesAutoGeneration() { - Map props = new HashMap<>(); - props.put("dlf.access_key", "ak"); - props.put("dlf.secret_key", "sk"); - props.put("dlf.region", "cn-beijing"); - props.put("dlf.endpoint", "custom.endpoint.com"); - - AliyunDLFBaseProperties dlfProps = AliyunDLFBaseProperties.of(props); - Assertions.assertEquals("custom.endpoint.com", dlfProps.dlfEndpoint); - } - - @Test - void testMissingEndpointAndRegionThrowsException() { - Map props = new HashMap<>(); - props.put("dlf.access_key", "ak"); - props.put("dlf.secret_key", "sk"); - - StoragePropertiesException ex = Assertions.assertThrows( - StoragePropertiesException.class, - () -> AliyunDLFBaseProperties.of(props) - ); - Assertions.assertEquals("dlf.endpoint is required.", ex.getMessage()); - } - - @Test - void testMissingAccessKeyThrowsException() { - Map props = new HashMap<>(); - props.put("dlf.secret_key", "sk"); - props.put("dlf.endpoint", "custom.endpoint.com"); - - Exception ex = Assertions.assertThrows( - IllegalArgumentException.class, - () -> AliyunDLFBaseProperties.of(props) - ); - Assertions.assertTrue(ex.getMessage().contains("dlf.access_key is required")); - } - - @Test - void testMissingSecretKeyThrowsException() { - Map props = new HashMap<>(); - props.put("dlf.access_key", "ak"); - props.put("dlf.endpoint", "custom.endpoint.com"); - - Exception ex = Assertions.assertThrows( - IllegalArgumentException.class, - () -> AliyunDLFBaseProperties.of(props) - ); - Assertions.assertTrue(ex.getMessage().contains("dlf.secret_key is required")); - } - - @Test - void testGetSensitiveKeys() { - Set keys = ConnectorPropertiesUtils.getSensitiveKeys(AliyunDLFBaseProperties.class); - System.out.println(keys); - Assertions.assertTrue(keys.contains("dlf.catalog.sessionToken")); - Assertions.assertTrue(keys.contains("dlf.catalog.accessKeySecret")); - Assertions.assertTrue(keys.contains("dlf.secret_key")); - Assertions.assertTrue(keys.contains("dlf.session_token")); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/GlueCatalogTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/GlueCatalogTest.java deleted file mode 100644 index 5352907d1d2149..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/GlueCatalogTest.java +++ /dev/null @@ -1,111 +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.datasource.property.metastore; - -import org.apache.doris.common.UserException; - -import org.apache.iceberg.aws.glue.GlueCatalog; -import org.apache.iceberg.catalog.Namespace; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -@Disabled("Disabled until AWS credentials are available") -public class GlueCatalogTest { - - private GlueCatalog glueCatalog; - private AWSGlueMetaStoreBaseProperties glueProperties; - private static final Namespace queryNameSpace = Namespace.of("test"); // Replace with your namespace - private static final String AWS_ACCESS_KEY_ID = "YOUR_ACCESS_KEY_ID"; // Replace with actual access key - private static final String AWS_SECRET_ACCESS_KEY = "YOUR_SECRET_ACCESS_KEY"; // Replace with actual secret key - private static final String AWS_GLUE_ENDPOINT = "https://glue.ap-northeast-1.amazonaws.com"; // Replace with your endpoint - - @BeforeEach - public void setUp() throws UserException { - glueCatalog = new GlueCatalog(); - System.setProperty("queryNameSpace", "lakes_test_glue"); - - // Setup properties - Map props = new HashMap<>(); - // Use environment variables for sensitive keys - props.put("glue.access_key", AWS_ACCESS_KEY_ID); - props.put("glue.secret_key", AWS_SECRET_ACCESS_KEY); - props.put("glue.endpoint", AWS_GLUE_ENDPOINT); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "glue"); - - - // Initialize AWSGlueProperties - glueProperties = (AWSGlueMetaStoreBaseProperties) AWSGlueMetaStoreBaseProperties.of(props); - - // Convert to catalog properties - Map catalogProps = new HashMap<>(); - catalogProps.put("catalog-name", "ck"); - - - // Initialize Glue Catalog - glueCatalog.initialize("ck", catalogProps); - } - - @Test - public void testListNamespaces() { - - // List namespaces and assert - glueCatalog.listNamespaces(Namespace.empty()).forEach(namespace1 -> { - System.out.println("Namespace: " + namespace1); - Assertions.assertNotNull(namespace1, "Namespace should not be null"); - }); - } - - @Test - public void testListTables() { - // List tables in a given namespace - glueCatalog.listTables(queryNameSpace).forEach(tableIdentifier -> { - System.out.println("Table: " + tableIdentifier.name()); - Assertions.assertNotNull(tableIdentifier, "TableIdentifier should not be null"); - - // Load table history and assert - glueCatalog.loadTable(tableIdentifier).history().forEach(snapshot -> { - System.out.println("Snapshot: " + snapshot); - Assertions.assertNotNull(snapshot, "Snapshot should not be null"); - }); - }); - } - - @Test - public void testConnection() { - // Check if catalog can be initialized without errors - Assertions.assertNotNull(glueCatalog, "Glue Catalog should be initialized"); - - // Ensure at least one namespace exists - Assertions.assertFalse(glueCatalog.listNamespaces(Namespace.empty()).isEmpty(), - "Namespace list should not be empty"); - } - - @AfterEach - public void tearDown() throws IOException { - // Close the Glue Catalog - glueCatalog.close(); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/HMSAliyunDLFMetaStorePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/HMSAliyunDLFMetaStorePropertiesTest.java deleted file mode 100644 index f60f190ff587d6..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/HMSAliyunDLFMetaStorePropertiesTest.java +++ /dev/null @@ -1,51 +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.datasource.property.metastore; - -import org.apache.doris.common.UserException; - -import com.aliyun.datalake.metastore.common.DataLakeConfig; -import com.google.common.collect.Maps; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.Map; - -public class HMSAliyunDLFMetaStorePropertiesTest { - - @Test - public void testCreate() throws UserException { - Map props = Maps.newHashMap(); - props.put("type", "hms"); - props.put("hive.metastore.type", "dlf"); - props.put("dlf.endpoint", "dlf.cn-shanghai.aliyuncs.com"); - props.put("dlf.region", "cn-shanghai"); - props.put("dlf.access_key", "xxx"); - props.put("dlf.secret_key", "xxx"); - props.put("dlf.catalog_id", "5789"); - HiveAliyunDLFMetaStoreProperties hmsAliyunDLFMetaStoreProperties = - (HiveAliyunDLFMetaStoreProperties) MetastoreProperties.create(props); - Assertions.assertEquals("xxx", hmsAliyunDLFMetaStoreProperties.getHiveConf().get(DataLakeConfig.CATALOG_ACCESS_KEY_ID)); - Assertions.assertEquals("xxx", hmsAliyunDLFMetaStoreProperties.getHiveConf().get(DataLakeConfig.CATALOG_ACCESS_KEY_SECRET)); - Assertions.assertEquals("5789", hmsAliyunDLFMetaStoreProperties.getHiveConf().get(DataLakeConfig.CATALOG_ID)); - Assertions.assertEquals("cn-shanghai", hmsAliyunDLFMetaStoreProperties.getHiveConf().get(DataLakeConfig.CATALOG_REGION_ID)); - Assertions.assertEquals("dlf.cn-shanghai.aliyuncs.com", hmsAliyunDLFMetaStoreProperties.getHiveConf().get(DataLakeConfig.CATALOG_ENDPOINT)); - Assertions.assertEquals("dlf", hmsAliyunDLFMetaStoreProperties.getHiveConf().get("hive.metastore.type")); - Assertions.assertEquals("hms", hmsAliyunDLFMetaStoreProperties.getHiveConf().get("type")); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/HMSGlueIT.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/HMSGlueIT.java deleted file mode 100644 index 15aefdcc8f2d5c..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/HMSGlueIT.java +++ /dev/null @@ -1,49 +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.datasource.property.metastore; - -import org.apache.doris.common.UserException; -import org.apache.doris.common.security.authentication.ExecutionAuthenticator; -import org.apache.doris.datasource.hive.ThriftHMSCachedClient; - -import com.google.common.collect.ImmutableMap; - -import java.util.Map; - -/* - * This is for local development and testing only. Use actual environment settings in production - */ -public class HMSGlueIT { - - public static void main(String[] args) throws UserException { - Map baseProps = ImmutableMap.of( - "type", "hms", - "hive.metastore.type", "glue", - "glue.role_arn", "arn:aws:iam::1001:role/doris-glue-role", - "glue.external_id", "1001", - "glue.endpoint", "https://glue.us-east-1.amazonaws.com" - ); - System.setProperty("aws.region", "us-east-1"); - System.setProperty("aws.accessKeyId", ""); - System.setProperty("aws.secretKey", ""); - HiveGlueMetaStoreProperties properties = (HiveGlueMetaStoreProperties) MetastoreProperties.create(baseProps); - ThriftHMSCachedClient client = new ThriftHMSCachedClient(properties.hiveConf, 1, new ExecutionAuthenticator() { - }); - client.getTable("default", "test_hive_table"); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/HMSGlueMetaStorePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/HMSGlueMetaStorePropertiesTest.java deleted file mode 100644 index a13e7299e42e45..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/HMSGlueMetaStorePropertiesTest.java +++ /dev/null @@ -1,108 +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.datasource.property.metastore; - -import com.google.common.collect.ImmutableBiMap; -import org.apache.hadoop.hive.conf.HiveConf; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.HashMap; -import java.util.Map; - -public class HMSGlueMetaStorePropertiesTest { - private HiveGlueMetaStoreProperties properties; - - @BeforeEach - public void setUp() { - Map config = ImmutableBiMap.of( - "aws.glue.endpoint", "https://glue.us-west-2.amazonaws.com", - "aws.region", "us-west-2", - "aws.glue.session-token", "dummy-session-token", - "aws.glue.access-key", "dummy-access-key", - "aws.glue.secret-key", "dummy-secret-key", - "aws.glue.max-error-retries", "10", - "aws.glue.max-connections", "20", - "aws.glue.connection-timeout", "60000", - "aws.glue.socket-timeout", "45000", - "aws.glue.catalog.separator", "::" - ); - properties = new HiveGlueMetaStoreProperties(config); - } - - @Test - public void testInitNormalizeAndCheckPropsSetsHiveConfCorrectly() { - properties.initNormalizeAndCheckProps(); - HiveConf hiveConf = properties.getHiveConf(); - Assertions.assertEquals("https://glue.us-west-2.amazonaws.com", hiveConf.get("aws.glue.endpoint")); - Assertions.assertEquals("us-west-2", hiveConf.get("aws.region")); - Assertions.assertEquals("dummy-session-token", hiveConf.get("aws.glue.session-token")); - Assertions.assertEquals("dummy-access-key", hiveConf.get("aws.glue.access-key")); - Assertions.assertEquals("dummy-secret-key", hiveConf.get("aws.glue.secret-key")); - Assertions.assertEquals("10", hiveConf.get("aws.glue.max-error-retries")); - Assertions.assertEquals("20", hiveConf.get("aws.glue.max-connections")); - Assertions.assertEquals("60000", hiveConf.get("aws.glue.connection-timeout")); - Assertions.assertEquals("45000", hiveConf.get("aws.glue.socket-timeout")); - Assertions.assertEquals("::", hiveConf.get("aws.glue.catalog.separator")); - Assertions.assertEquals("glue", hiveConf.get("hive.metastore.type")); - } - - @Test - public void testConstructorSetsTypeCorrectly() { - Assertions.assertEquals(AbstractHiveProperties.Type.GLUE, properties.getType()); - } - - @Test - public void testMissingRequiredPropertyThrows() { - Map incompleteConfig = new HashMap<>(properties.getOrigProps()); - incompleteConfig.remove("aws.glue.secret-key"); - HiveGlueMetaStoreProperties props = new HiveGlueMetaStoreProperties(incompleteConfig); - Exception exception = Assertions.assertThrows(IllegalArgumentException.class, props::initNormalizeAndCheckProps); - Assertions.assertTrue(exception.getMessage().contains("glue.access_key and glue.secret_key must be set together")); - } - - @Test - public void testInvalidNumberPropertyThrows() { - Map invalidConfig = new HashMap<>(properties.getOrigProps()); - invalidConfig.put("aws.glue.max-error-retries", "notANumber"); - HiveGlueMetaStoreProperties props = new HiveGlueMetaStoreProperties(invalidConfig); - Exception exception = Assertions.assertThrows(RuntimeException.class, props::initNormalizeAndCheckProps); - Assertions.assertTrue(exception.getMessage().contains("Failed to set property")); - } - - @Test - public void testDefaultValuesAreAppliedWhenMissing() { - Map partialConfig = new HashMap<>(properties.getOrigProps()); - partialConfig.remove("aws.glue.max-error-retries"); - HiveGlueMetaStoreProperties props = new HiveGlueMetaStoreProperties(partialConfig); - props.initNormalizeAndCheckProps(); - HiveConf hiveConf = props.getHiveConf(); - Assertions.assertEquals(String.valueOf(HiveGlueMetaStoreProperties.DEFAULT_MAX_RETRY), hiveConf.get("aws.glue.max-error-retries")); - } - - @Test - public void testUnsupportedPropertyIsIgnored() { - Map configWithExtra = new HashMap<>(properties.getOrigProps()); - configWithExtra.put("some.unsupported.property", "value"); - HiveGlueMetaStoreProperties props = new HiveGlueMetaStoreProperties(configWithExtra); - props.initNormalizeAndCheckProps(); - HiveConf hiveConf = props.getHiveConf(); - Assertions.assertNull(hiveConf.get("some.unsupported.property")); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/HMSIntegrationTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/HMSIntegrationTest.java deleted file mode 100644 index 7c93bef6d76beb..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/HMSIntegrationTest.java +++ /dev/null @@ -1,222 +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.datasource.property.metastore; - -import org.apache.hadoop.hive.conf.HiveConf; -import org.apache.hadoop.hive.metastore.HiveMetaStoreClient; -import org.apache.hadoop.hive.metastore.api.FieldSchema; -import org.apache.hadoop.hive.metastore.api.SerDeInfo; -import org.apache.hadoop.hive.metastore.api.StorageDescriptor; -import org.apache.hadoop.hive.metastore.api.Table; -import org.apache.hadoop.security.UserGroupInformation; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import shade.doris.hive.org.apache.thrift.TException; - -import java.io.IOException; -import java.security.PrivilegedAction; -import java.util.ArrayList; -import java.util.List; - -@Disabled -public class HMSIntegrationTest { - - // Hive configuration file path - private static final String HIVE_CONF_PATH = ""; - // krb5 configuration file path - private static final String KRB5_CONF_PATH = ""; - // Path to the Kerberos keytab file - private static final String KEYTAB_PATH = ""; - // Principal name for Kerberos authentication - private static final String PRINCIPAL_NAME = ""; - - private static final String QUERY_DB_NAME = ""; - private static final String QUERY_TBL_NAME = ""; - private static final String CREATE_TBL_NAME = ""; - private static final String CREATE_TBL_IN_DB_NAME = ""; - // HDFS URI for the table location - private static final String HDFS_URI = ""; - private static final boolean ENABLE_EXECUTE_CREATE_TABLE_TEST = false; - - @Test - public void testHms() throws IOException { - // Set up HiveConf and Kerberos authentication - HiveConf hiveConf = setupHiveConf(); - setupKerberos(hiveConf); - - // Authenticate user using the provided keytab file - UserGroupInformation ugi = authenticateUser(); - System.out.println("User Credentials: " + ugi.getCredentials()); - - // Perform Hive MetaStore client operations - ugi.doAs((PrivilegedAction) () -> { - try { - HiveMetaStoreClient client = createHiveMetaStoreClient(hiveConf); - - // Get database and table information - getDatabaseAndTableInfo(client); - - // Create a new table in Hive - createNewTable(client); - - } catch (TException e) { - throw new RuntimeException("HiveMetaStoreClient operation failed", e); - } - return null; - }); - } - - /** - * Sets up the HiveConf object by loading necessary configuration files. - * - * @return Configured HiveConf object - */ - private static HiveConf setupHiveConf() { - HiveConf hiveConf = new HiveConf(); - // Load the Hive configuration file - hiveConf.addResource(HIVE_CONF_PATH); - // Set Hive Metastore URIs and Kerberos principal - //if not in config-site - //hiveConf.set("hive.metastore.uris", ""); - //hiveConf.set("hive.metastore.sasl.enabled", "true"); - //hiveConf.set("hive.metastore.kerberos.principal", ""); - return hiveConf; - } - - /** - * Sets up Kerberos authentication properties in the HiveConf. - * - * @param hiveConf HiveConf object to update with Kerberos settings - */ - private static void setupKerberos(HiveConf hiveConf) { - // Set the Kerberos configuration file path - System.setProperty("java.security.krb5.conf", KRB5_CONF_PATH); - // Enable Kerberos authentication for Hadoop - hiveConf.set("hadoop.security.authentication", "kerberos"); - // Set the Hive configuration for Kerberos authentication - UserGroupInformation.setConfiguration(hiveConf); - } - - /** - * Authenticates the user using Kerberos with a provided keytab file. - * - * @return Authenticated UserGroupInformation object - * @throws IOException If there is an error during authentication - */ - private static UserGroupInformation authenticateUser() throws IOException { - return UserGroupInformation.loginUserFromKeytabAndReturnUGI(PRINCIPAL_NAME, KEYTAB_PATH); - } - - /** - * Creates a new HiveMetaStoreClient using the provided HiveConf. - * - * @param hiveConf The HiveConf object with configuration settings - * @return A new instance of HiveMetaStoreClient - * @throws TException If there is an error creating the client - */ - private static HiveMetaStoreClient createHiveMetaStoreClient(HiveConf hiveConf) throws TException { - return new HiveMetaStoreClient(hiveConf); - } - - /** - * Retrieves database and table information from the Hive MetaStore. - * - * @param client The HiveMetaStoreClient used to interact with the MetaStore - * @throws TException If there is an error retrieving database or table info - */ - private static void getDatabaseAndTableInfo(HiveMetaStoreClient client) throws TException { - // Retrieve and print the list of databases - System.out.println("Databases: " + client.getAllDatabases()); - Table tbl = client.getTable(QUERY_DB_NAME, QUERY_TBL_NAME); - System.out.println(tbl); - } - - /** - * Creates a new table in Hive with specified metadata. - * - * @param client The HiveMetaStoreClient used to create the table - * @throws TException If there is an error creating the table - */ - private static void createNewTable(HiveMetaStoreClient client) throws TException { - if (!ENABLE_EXECUTE_CREATE_TABLE_TEST) { - return; - } - // Create StorageDescriptor for the table - StorageDescriptor storageDescriptor = createTableStorageDescriptor(); - - // Create the table object and set its properties - Table table = new Table(); - table.setDbName(CREATE_TBL_IN_DB_NAME); - table.setTableName(CREATE_TBL_NAME); - table.setPartitionKeys(createPartitionColumns()); - table.setSd(storageDescriptor); - - // Create the table in the Hive MetaStore - client.createTable(table); - System.out.println("Table 'exampletable' created successfully."); - } - - /** - * Creates the StorageDescriptor for a table, which includes columns and location. - * - * @return A StorageDescriptor object containing table metadata - */ - private static StorageDescriptor createTableStorageDescriptor() { - // Define the table columns - List columns = new ArrayList<>(); - columns.add(new FieldSchema("id", "int", "ID column")); - columns.add(new FieldSchema("name", "string", "Name column")); - columns.add(new FieldSchema("age", "int", "Age column")); - - // Create and configure the StorageDescriptor for the table - StorageDescriptor storageDescriptor = new StorageDescriptor(); - storageDescriptor.setCols(columns); - storageDescriptor.setLocation(HDFS_URI); - - // Configure SerDe for the table - SerDeInfo serDeInfo = createSerDeInfo(); - storageDescriptor.setSerdeInfo(serDeInfo); - - return storageDescriptor; - } - - /** - * Creates the SerDeInfo object for the table, which defines how data is serialized and deserialized. - * - * @return A SerDeInfo object with the specified serialization settings - */ - private static SerDeInfo createSerDeInfo() { - SerDeInfo serDeInfo = new SerDeInfo(); - serDeInfo.setName("example_serde"); - serDeInfo.setSerializationLib("org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe"); - return serDeInfo; - } - - /** - * Creates the partition columns for the table. - * - * @return A list of FieldSchema objects representing partition columns - */ - private static List createPartitionColumns() { - List partitionColumns = new ArrayList<>(); - partitionColumns.add(new FieldSchema("year", "int", "Year partition")); - partitionColumns.add(new FieldSchema("month", "int", "Month partition")); - return partitionColumns; - } -} - diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/HMSPropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/HMSPropertiesTest.java deleted file mode 100644 index 6d240f657299c2..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/HMSPropertiesTest.java +++ /dev/null @@ -1,152 +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.datasource.property.metastore; - -import org.apache.doris.common.Config; -import org.apache.doris.common.UserException; -import org.apache.doris.common.security.authentication.HadoopExecutionAuthenticator; - -import org.apache.hadoop.hive.conf.HiveConf; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.net.URL; -import java.util.HashMap; -import java.util.Map; - -public class HMSPropertiesTest { - - @Test - public void testHiveConfDirNotExist() { - Map params = new HashMap<>(); - params.put("hive.conf.resources", "/opt/hive-site.xml"); - params.put("hive.metastore.type", "hms"); - Map finalParams = params; - Assertions.assertThrows(IllegalArgumentException.class, () -> MetastoreProperties.create(finalParams)); - } - - @Test - public void testHiveConfDirExist() throws UserException { - URL hiveFileUrl = HMSPropertiesTest.class.getClassLoader().getResource("plugins"); - Config.hadoop_config_dir = hiveFileUrl.getPath().toString(); - Map params = new HashMap<>(); - params.put("hive.conf.resources", "/hive-conf/hive1/hive-site.xml"); - params.put("type", "hms"); - HiveHMSProperties hmsProperties; - Assertions.assertThrows(IllegalArgumentException.class, () -> MetastoreProperties.create(params)); - params.put("hive.metastore.uris", "thrift://default:9083"); - hmsProperties = (HiveHMSProperties) MetastoreProperties.create(params); - HiveConf hiveConf = hmsProperties.getHiveConf(); - Assertions.assertNotNull(hiveConf); - Assertions.assertEquals("/user/hive/default", hiveConf.get("hive.metastore.warehouse.dir")); - } - - @Test - public void testBasicParamsTest() throws UserException { - Map notValidParams = new HashMap<>(); - notValidParams.put("hive.metastore.type", "hms"); - Assertions.assertThrows(IllegalArgumentException.class, () -> MetastoreProperties.create(notValidParams)); - // Step 1: Set up initial parameters for HMSProperties - Map params = createBaseParams(); - - // Step 2: Test HMSProperties to PaimonOptions and Conf conversion - HiveHMSProperties hmsProperties = getHMSProperties(params); - Assertions.assertNotNull(hmsProperties); - Assertions.assertEquals("thrift://127.0.0.1:9083", hmsProperties.getHiveConf().get("hive.metastore.uris")); - Assertions.assertEquals(HadoopExecutionAuthenticator.class, hmsProperties.getExecutionAuthenticator().getClass()); - params.put("hadoop.security.authentication", "simple"); - hmsProperties = getHMSProperties(params); - Assertions.assertNotNull(hmsProperties); - Assertions.assertEquals(HadoopExecutionAuthenticator.class, hmsProperties.getExecutionAuthenticator().getClass()); - params.put("hive.metastore.authentication.type", "kerberos"); - Assertions.assertThrows(IllegalArgumentException.class, () -> MetastoreProperties.create(params), - "Hive metastore authentication type is kerberos, but service principal, client principal or client keytab is not set."); - params.put("hive.metastore.client.principal", "hive/127.0.0.1@EXAMPLE.COM"); - Assertions.assertThrows(IllegalArgumentException.class, () -> MetastoreProperties.create(params), - "Hive metastore authentication type is kerberos, but client keytab is not set."); - params.put("hive.metastore.client.keytab", "/path/to/keytab"); - hmsProperties = getHMSProperties(params); - Assertions.assertNotNull(hmsProperties); - Assertions.assertEquals(HadoopExecutionAuthenticator.class, hmsProperties.getExecutionAuthenticator().getClass()); - params.put("hive.metastore.authentication.type", "simple"); - Assertions.assertThrows(IllegalArgumentException.class, () -> MetastoreProperties.create(params), - "Hive metastore authentication type is simple, but service principal, client principal or client keytab is set."); - - } - - private Map createBaseParams() { - Map params = new HashMap<>(); - params.put("type", "hms"); - params.put("hive.metastore.uris", "thrift://127.0.0.1:9083"); - params.put("hive.metastore.authentication.type", "simple"); - return params; - } - - private HiveHMSProperties getHMSProperties(Map params) throws UserException { - return (HiveHMSProperties) MetastoreProperties.create(params); - } - - @Test - public void testRefreshParams() throws UserException { - Map params = createBaseParams(); - HiveHMSProperties hmsProperties = getHMSProperties(params); - Assertions.assertFalse(hmsProperties.isHmsEventsIncrementalSyncEnabled()); - params.put("hive.enable_hms_events_incremental_sync", "true"); - hmsProperties = getHMSProperties(params); - Assertions.assertTrue(hmsProperties.isHmsEventsIncrementalSyncEnabled()); - params.put("hive.enable_hms_events_incremental_sync", "xxxx"); - hmsProperties = getHMSProperties(params); - Assertions.assertFalse(hmsProperties.isHmsEventsIncrementalSyncEnabled()); - params.put("hive.hms_events_batch_size_per_rpc", "false"); - Assertions.assertThrows(IllegalArgumentException.class, () -> getHMSProperties(params)); - params.put("hive.hms_events_batch_size_per_rpc", "123"); - hmsProperties = getHMSProperties(params); - Assertions.assertEquals(123, hmsProperties.getHmsEventsBatchSizePerRpc()); - } - - @Test - public void testHmsKerberosParams() throws UserException { - Map params = createBaseParams(); - params.put("hive.metastore.uris", "thrift://127.0.0.1:9083"); - params.put("hive.metastore.sasl.enabled", "true"); - params.put("hive.metastore.authentication.type", "kerberos"); - Assertions.assertThrows(IllegalArgumentException.class, () -> MetastoreProperties.create(params)); - //params.put("hive.metastore.client.principal", "hive/127.0.0.1@EXAMPLE.COM"); - params.put("hive.metastore.client.keytab", "/path/to/keytab"); - Assertions.assertThrows(IllegalArgumentException.class, () -> MetastoreProperties.create(params), - "Hive metastore authentication type is kerberos, but service principal, client principal or client keytab is not set."); - params.put("hive.metastore.client.principal", "hive/127.0.0.1@EXAMPLE.COM"); - HiveHMSProperties hmsProperties = getHMSProperties(params); - Assertions.assertNotNull(hmsProperties); - } - - @Test - public void testHmsTimeoutParams() throws UserException { - Map params = createBaseParams(); - // case1: normal case, use Config.hive_metastore_client_timeout_second as default - HiveHMSProperties hmsProperties = getHMSProperties(params); - HiveConf hiveConf = hmsProperties.getHiveConf(); - Assertions.assertEquals(String.valueOf(Config.hive_metastore_client_timeout_second), - hiveConf.get("hive.metastore.client.socket.timeout", null)); - - params.put("hive.metastore.client.socket.timeout", "123"); - hmsProperties = getHMSProperties(params); - hiveConf = hmsProperties.getHiveConf(); - Assertions.assertEquals("123", hiveConf.get("hive.metastore.client.socket.timeout", null)); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergAliyunDLFMetaStorePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergAliyunDLFMetaStorePropertiesTest.java deleted file mode 100644 index 45deebe4b4a291..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergAliyunDLFMetaStorePropertiesTest.java +++ /dev/null @@ -1,106 +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.datasource.property.metastore; - -import org.apache.doris.datasource.iceberg.dlf.DLFCatalog; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.foundation.property.StoragePropertiesException; - -import org.apache.iceberg.catalog.Catalog; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -public class IcebergAliyunDLFMetaStorePropertiesTest { - @Test - void testGetIcebergCatalogType() { - Map props = new HashMap<>(); - props.put("dlf.access_key", "ak"); - props.put("dlf.secret_key", "sk"); - props.put("dlf.endpoint", "custom-endpoint"); - props.put("dlf.catalog.uid", "123"); - - IcebergAliyunDLFMetaStoreProperties properties = - new IcebergAliyunDLFMetaStoreProperties(props); - - Assertions.assertEquals("dlf", properties.getIcebergCatalogType()); - } - - @Test - void testInitCatalog() { - Map props = new HashMap<>(); - props.put("dlf.access_key", "ak"); - props.put("dlf.secret_key", "sk"); - props.put("dlf.endpoint", "dlf-vpc.cn-beijing.aliyuncs.com"); - props.put("dlf.region", "cn-hz"); - props.put("dlf.catalog.uid", "uid-123"); - props.put("dlf.catalog.id", "id-456"); - props.put("dlf.proxy.mode", "DLF_ONLY"); - - IcebergAliyunDLFMetaStoreProperties properties = - new IcebergAliyunDLFMetaStoreProperties(props); - // Replace DLFCatalog with a mock - Catalog catalog = properties.initCatalog("test_catalog", props, - Collections.singletonList(StorageProperties.createPrimary(props))); - Assertions.assertEquals(DLFCatalog.class, catalog.getClass()); - } - - @Test - void testAliyunDLFBasePropertiesSuccessWithPublicEndpoint() { - Map props = new HashMap<>(); - props.put("dlf.access_key", "ak"); - props.put("dlf.secret_key", "sk"); - props.put("dlf.region", "cn-shanghai"); - props.put("dlf.access.public", "true"); - props.put("dlf.uid", "uid-001"); - - AliyunDLFBaseProperties base = AliyunDLFBaseProperties.of(props); - - Assertions.assertEquals("ak", base.dlfAccessKey); - Assertions.assertEquals("sk", base.dlfSecretKey); - Assertions.assertEquals("dlf.cn-shanghai.aliyuncs.com", base.dlfEndpoint); - Assertions.assertEquals("uid-001", base.dlfCatalogId); // defaulted to uid - } - - @Test - void testAliyunDLFBasePropertiesSuccessWithVpcEndpoint() { - Map props = new HashMap<>(); - props.put("dlf.access_key", "ak"); - props.put("dlf.secret_key", "sk"); - props.put("dlf.region", "cn-hangzhou"); - props.put("dlf.access.public", "false"); - props.put("dlf.uid", "uid-002"); - AliyunDLFBaseProperties base = AliyunDLFBaseProperties.of(props); - Assertions.assertEquals("dlf-vpc.cn-hangzhou.aliyuncs.com", base.dlfEndpoint); - Assertions.assertEquals("uid-002", base.dlfCatalogId); - } - - @Test - void testAliyunDLFBasePropertiesThrowsWhenEndpointMissing() { - Map props = new HashMap<>(); - props.put("dlf.access_key", "ak"); - props.put("dlf.secret_key", "sk"); - // No endpoint and no region - - Assertions.assertThrows(StoragePropertiesException.class, - () -> AliyunDLFBaseProperties.of(props)); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergFileSystemMetaStorePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergFileSystemMetaStorePropertiesTest.java deleted file mode 100644 index 6aa33c2c47d9ec..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergFileSystemMetaStorePropertiesTest.java +++ /dev/null @@ -1,69 +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.datasource.property.metastore; - -import org.apache.doris.datasource.property.storage.HdfsProperties; -import org.apache.doris.datasource.property.storage.StorageProperties; - -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; - -public class IcebergFileSystemMetaStorePropertiesTest { - - @Test - public void testKerberosCatalog() throws Exception { - Map props = new HashMap<>(); - props.put(HdfsProperties.FS_HDFS_SUPPORT, "true"); - props.put("fs.defaultFS", "hdfs://mycluster_test"); - props.put("hadoop.security.authentication", "kerberos"); - props.put("hadoop.kerberos.principal", "myprincipal"); - props.put("hadoop.kerberos.keytab", "mykeytab"); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "hadoop"); - props.put("warehouse", "hdfs://mycluster_test/ice"); - IcebergFileSystemMetaStoreProperties icebergProps = (IcebergFileSystemMetaStoreProperties) MetastoreProperties.create(props); - List storagePropertiesList = Collections.singletonList(StorageProperties.createPrimary(props)); - //We expect a Kerberos-related exception, but because the messages vary by environment, we’re only doing a simple check. - Assertions.assertThrows(RuntimeException.class, () -> icebergProps.initializeCatalog("iceberg", storagePropertiesList)); - } - - @Test - public void testNonKerberosCatalog() throws Exception { - Map props = new HashMap<>(); - props.put(HdfsProperties.FS_HDFS_SUPPORT, "true"); - props.put("fs.defaultFS", "file:///tmp"); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "hadoop"); - props.put("warehouse", "file:///tmp"); - IcebergFileSystemMetaStoreProperties icebergProps = (IcebergFileSystemMetaStoreProperties) MetastoreProperties.create(props); - Assertions.assertEquals("hadoop", icebergProps.getIcebergCatalogType()); - List storagePropertiesList = Collections.singletonList(StorageProperties.createPrimary(props)); - Assertions.assertDoesNotThrow(() -> icebergProps.initializeCatalog("iceberg", storagePropertiesList)); - props.put("fs.defaultFS", "hdfs://mycluster" + System.currentTimeMillis()); - props.put("warehouse", "hdfs://mycluster" + System.currentTimeMillis()); - IcebergFileSystemMetaStoreProperties icebergPropsFailed = (IcebergFileSystemMetaStoreProperties) MetastoreProperties.create(props); - RuntimeException e = Assertions.assertThrows(RuntimeException.class, () -> icebergPropsFailed.initializeCatalog("iceberg", storagePropertiesList)); - Assertions.assertTrue(e.getMessage().contains("UnknownHostException:")); - } - -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergGlueIT.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergGlueIT.java deleted file mode 100644 index c91194eeb8c6e3..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergGlueIT.java +++ /dev/null @@ -1,51 +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.datasource.property.metastore; - -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.property.storage.StorageProperties; - -import com.google.common.collect.ImmutableMap; -import org.apache.iceberg.catalog.SupportsNamespaces; - -import java.util.List; -import java.util.Map; - -/* - * This is for local development and testing only. Use actual environment settings in production - */ -public class IcebergGlueIT { - public static void main(String[] args) throws UserException { - - Map baseProps = ImmutableMap.of( - "type", "iceberg", - "iceberg.catalog.type", "glue", - "glue.role_arn", "arn:aws:iam::12345:role/kristen", - "glue.external_id", "1001", - "glue.endpoint", "https://glue.us-east-1.amazonaws.com" - ); - System.setProperty("aws.region", "us-east-1"); - System.setProperty("aws.accessKeyId", "acc"); - System.setProperty("aws.secretAccessKey", "heyhey"); - IcebergGlueMetaStoreProperties properties = (IcebergGlueMetaStoreProperties) MetastoreProperties.create(baseProps); - List storagePropertiesList = StorageProperties.createAll(baseProps); - SupportsNamespaces catalog = (SupportsNamespaces) properties.initializeCatalog("iceberg_glue_test", storagePropertiesList); - catalog.listNamespaces().forEach(System.out::println); - - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergGlueMetaStorePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergGlueMetaStorePropertiesTest.java deleted file mode 100644 index 2eb5ca36486b7a..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergGlueMetaStorePropertiesTest.java +++ /dev/null @@ -1,66 +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.datasource.property.metastore; - -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.property.storage.StorageProperties; - -import com.google.common.collect.ImmutableMap; -import org.apache.iceberg.aws.glue.GlueCatalog; -import org.apache.iceberg.catalog.Catalog; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.Map; - -public class IcebergGlueMetaStorePropertiesTest { - - @Test - public void glueTest() throws UserException { - Map baseProps = ImmutableMap.of( - "type", "iceberg", - "iceberg.catalog.type", "glue", - "glue.region", "us-west-2", - "glue.access_key", "AK", - "glue.secret_key", "SK", - "glue.endpoint", "https://glue.us-west-2.amazonaws.com", - "warehouse", "s3://my-bucket/warehouse"); - IcebergGlueMetaStoreProperties properties = (IcebergGlueMetaStoreProperties) MetastoreProperties.create(baseProps); - Assertions.assertEquals("glue", properties.getIcebergCatalogType()); - Catalog catalog = properties.initializeCatalog("iceberg_catalog", StorageProperties.createAll(baseProps)); - Assertions.assertEquals(GlueCatalog.class, catalog.getClass()); - } - - @Test - public void glueAndS3Test() throws UserException { - Map baseProps = ImmutableMap.of( - "type", "iceberg", - "iceberg.catalog.type", "glue", - "glue.region", "us-west-2", - "glue.access_key", "AK", - "glue.secret_key", "SK", - "glue.endpoint", "https://glue.us-west-2.amazonaws.com", - "warehouse", "s3://my-bucket/warehouse", - "s3.region", "us-west-2", - "s3.endpoint", "https://s3.us-west-2.amazonaws.com" - ); - IcebergGlueMetaStoreProperties properties = (IcebergGlueMetaStoreProperties) MetastoreProperties.create(baseProps); - Catalog catalog = properties.initializeCatalog("iceberg_catalog", StorageProperties.createAll(baseProps)); - Assertions.assertEquals(GlueCatalog.class, catalog.getClass()); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergHMSMetaStorePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergHMSMetaStorePropertiesTest.java deleted file mode 100644 index 63d3a0b6bf5a67..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergHMSMetaStorePropertiesTest.java +++ /dev/null @@ -1,69 +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.datasource.property.metastore; - -import org.apache.doris.common.security.authentication.HadoopExecutionAuthenticator; -import org.apache.doris.datasource.property.storage.HdfsProperties; -import org.apache.doris.datasource.property.storage.StorageProperties; - -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; - -public class IcebergHMSMetaStorePropertiesTest { - @Test - public void testKerberosCatalog() throws Exception { - Map props = new HashMap<>(); - props.put(HdfsProperties.FS_HDFS_SUPPORT, "true"); - props.put("fs.defaultFS", "hdfs://mycluster_test"); - props.put("hadoop.security.authentication", "kerberos"); - props.put("hadoop.kerberos.principal", "myprincipal"); - props.put("hadoop.kerberos.keytab", "mykeytab"); - props.put("type", "iceberg"); - props.put("hive.metastore.uris", "thrift://localhost:12345"); - props.put("iceberg.catalog.type", "hms"); - props.put("warehouse", "hdfs://mycluster_test/ice"); - IcebergHMSMetaStoreProperties icebergProps = (IcebergHMSMetaStoreProperties) MetastoreProperties.create(props); - List storagePropertiesList = Collections.singletonList(StorageProperties.createPrimary(props)); - //We expect a Kerberos-related exception, but because the messages vary by environment, we’re only doing a simple check. - Assertions.assertThrows(RuntimeException.class, - () -> icebergProps.initializeCatalog("iceberg", storagePropertiesList)); - } - - @Test - public void testNonKerberosCatalog() throws Exception { - Map props = new HashMap<>(); - props.put(HdfsProperties.FS_HDFS_SUPPORT, "true"); - props.put("fs.defaultFS", "file:///tmp"); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "hms"); - props.put("hive.metastore.uris", "thrift://localhost:9083"); - props.put("warehouse", "file:///tmp"); - IcebergHMSMetaStoreProperties paimonProps = (IcebergHMSMetaStoreProperties) MetastoreProperties.create(props); - Assertions.assertEquals("hms", paimonProps.getIcebergCatalogType()); - - List storagePropertiesList = Collections.singletonList(StorageProperties.createPrimary(props)); - Assertions.assertDoesNotThrow(() -> paimonProps.initializeCatalog("iceberg", storagePropertiesList)); - Assertions.assertEquals(HadoopExecutionAuthenticator.class, paimonProps.getExecutionAuthenticator().getClass()); - } - -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergJdbcMetaStorePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergJdbcMetaStorePropertiesTest.java deleted file mode 100644 index 07977a17615d87..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergJdbcMetaStorePropertiesTest.java +++ /dev/null @@ -1,154 +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.datasource.property.metastore; - -import org.apache.hadoop.conf.Configuration; -import org.apache.iceberg.CatalogProperties; -import org.apache.iceberg.CatalogUtil; -import org.apache.iceberg.catalog.Catalog; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -public class IcebergJdbcMetaStorePropertiesTest { - - private static class CapturingIcebergJdbcMetaStoreProperties extends IcebergJdbcMetaStoreProperties { - private String capturedCatalogName; - private Map capturedOptions; - - CapturingIcebergJdbcMetaStoreProperties(Map props) { - super(props); - } - - @Override - protected Catalog buildIcebergCatalog(String catalogName, Map options, Configuration conf) { - capturedCatalogName = catalogName; - capturedOptions = new HashMap<>(options); - return Mockito.mock(Catalog.class); - } - - String getCapturedCatalogName() { - return capturedCatalogName; - } - - Map getCapturedOptions() { - return capturedOptions; - } - } - - @Test - public void testBasicJdbcProperties() { - Map props = new HashMap<>(); - props.put("uri", "jdbc:mysql://localhost:3306/iceberg"); - props.put("warehouse", "s3://warehouse/path"); - props.put("jdbc.user", "iceberg"); - props.put("jdbc.password", "secret"); - props.put("iceberg.jdbc.catalog_name", "iceberg_catalog"); - - IcebergJdbcMetaStoreProperties jdbcProps = new IcebergJdbcMetaStoreProperties(props); - jdbcProps.initNormalizeAndCheckProps(); - - Map catalogProps = jdbcProps.getIcebergJdbcCatalogProperties(); - Assertions.assertEquals(CatalogUtil.ICEBERG_CATALOG_JDBC, - catalogProps.get(CatalogProperties.CATALOG_IMPL)); - Assertions.assertEquals("jdbc:mysql://localhost:3306/iceberg", catalogProps.get(CatalogProperties.URI)); - Assertions.assertEquals("iceberg", catalogProps.get("jdbc.user")); - Assertions.assertEquals("secret", catalogProps.get("jdbc.password")); - } - - @Test - public void testJdbcPrefixPassthrough() { - Map props = new HashMap<>(); - props.put("uri", "jdbc:mysql://localhost:3306/iceberg"); - props.put("warehouse", "s3://warehouse/path"); - props.put("jdbc.useSSL", "true"); - props.put("jdbc.verifyServerCertificate", "true"); - props.put("iceberg.jdbc.catalog_name", "iceberg_catalog"); - - IcebergJdbcMetaStoreProperties jdbcProps = new IcebergJdbcMetaStoreProperties(props); - jdbcProps.initNormalizeAndCheckProps(); - - Map catalogProps = jdbcProps.getIcebergJdbcCatalogProperties(); - Assertions.assertEquals("true", catalogProps.get("jdbc.useSSL")); - Assertions.assertEquals("true", catalogProps.get("jdbc.verifyServerCertificate")); - } - - @Test - public void testMissingWarehouse() { - Map props = new HashMap<>(); - props.put("uri", "jdbc:mysql://localhost:3306/iceberg"); - - IcebergJdbcMetaStoreProperties jdbcProps = new IcebergJdbcMetaStoreProperties(props); - Assertions.assertThrows(IllegalArgumentException.class, jdbcProps::initNormalizeAndCheckProps); - } - - @Test - public void testMissingUri() { - Map props = new HashMap<>(); - props.put("warehouse", "s3://warehouse/path"); - - IcebergJdbcMetaStoreProperties jdbcProps = new IcebergJdbcMetaStoreProperties(props); - Assertions.assertThrows(IllegalArgumentException.class, jdbcProps::initNormalizeAndCheckProps); - } - - @Test - public void testJdbcCatalogNameOverridesSdkCatalogName() { - Map props = createBaseProps(); - props.put("iceberg.jdbc.catalog_name", "spark_catalog"); - - CapturingIcebergJdbcMetaStoreProperties jdbcProps = new CapturingIcebergJdbcMetaStoreProperties(props); - jdbcProps.initNormalizeAndCheckProps(); - jdbcProps.initializeCatalog("doris_catalog", Collections.emptyList()); - - Assertions.assertEquals("spark_catalog", jdbcProps.getCapturedCatalogName()); - Assertions.assertFalse(jdbcProps.getCapturedOptions().containsKey("iceberg.jdbc.catalog_name")); - } - - @Test - public void testMissingJdbcCatalogNameThrowsException() { - CapturingIcebergJdbcMetaStoreProperties jdbcProps = - new CapturingIcebergJdbcMetaStoreProperties(createBaseProps()); - - IllegalArgumentException exception = Assertions.assertThrows( - IllegalArgumentException.class, jdbcProps::initNormalizeAndCheckProps); - Assertions.assertEquals("Property iceberg.jdbc.catalog_name is required.", exception.getMessage()); - } - - @Test - public void testBlankJdbcCatalogNameThrowsException() { - Map props = createBaseProps(); - props.put("iceberg.jdbc.catalog_name", " "); - - CapturingIcebergJdbcMetaStoreProperties jdbcProps = new CapturingIcebergJdbcMetaStoreProperties(props); - - IllegalArgumentException exception = Assertions.assertThrows( - IllegalArgumentException.class, jdbcProps::initNormalizeAndCheckProps); - Assertions.assertEquals("Property iceberg.jdbc.catalog_name is required.", exception.getMessage()); - } - - private static Map createBaseProps() { - Map props = new HashMap<>(); - props.put("uri", "jdbc:mysql://localhost:3306/iceberg"); - props.put("warehouse", "s3://warehouse/path"); - return props; - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergRestPropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergRestPropertiesTest.java deleted file mode 100644 index 2c381f053f127b..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergRestPropertiesTest.java +++ /dev/null @@ -1,1053 +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.datasource.property.metastore; - -import org.apache.doris.datasource.DelegatedCredential; -import org.apache.doris.datasource.SessionContext; -import org.apache.doris.datasource.property.storage.OSSProperties; -import org.apache.doris.datasource.property.storage.S3Properties; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.qe.ConnectContext; - -import org.apache.hadoop.conf.Configuration; -import org.apache.iceberg.CatalogProperties; -import org.apache.iceberg.CatalogUtil; -import org.apache.iceberg.aws.AwsClientProperties; -import org.apache.iceberg.aws.s3.S3FileIOProperties; -import org.apache.iceberg.rest.RESTSessionCatalog; -import org.apache.iceberg.rest.auth.AuthProperties; -import org.apache.iceberg.rest.auth.OAuth2Properties; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class IcebergRestPropertiesTest { - - @Test - public void testBasicRestProperties() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.prefix", "prefix"); - props.put("warehouse", "s3://warehouse/path"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertEquals(CatalogUtil.ICEBERG_CATALOG_REST, - catalogProps.get(CatalogProperties.CATALOG_IMPL)); - Assertions.assertEquals("http://localhost:8080", catalogProps.get(CatalogProperties.URI)); - Assertions.assertEquals("s3://warehouse/path", catalogProps.get(CatalogProperties.WAREHOUSE_LOCATION)); - Assertions.assertEquals("prefix", catalogProps.get("prefix")); - } - - @Test - public void testVendedCredentialsEnabled() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.vended-credentials-enabled", "true"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); - - Assertions.assertTrue(restProps.isIcebergRestVendedCredentialsEnabled()); - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertEquals("vended-credentials", catalogProps.get("header.X-Iceberg-Access-Delegation")); - } - - @Test - public void testVendedCredentialsDisabled() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.vended-credentials-enabled", "false"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); - - Assertions.assertFalse(restProps.isIcebergRestVendedCredentialsEnabled()); - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertFalse(catalogProps.containsKey("header.X-Iceberg-Access-Delegation")); - } - - @Test - public void testRestViewEnabled() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - - IcebergRestProperties defaultProps = new IcebergRestProperties(props); - defaultProps.initNormalizeAndCheckProps(); - Assertions.assertTrue(defaultProps.isIcebergRestViewEnabled()); - - props.put("iceberg.rest.view-enabled", "false"); - IcebergRestProperties disabledProps = new IcebergRestProperties(props); - disabledProps.initNormalizeAndCheckProps(); - Assertions.assertFalse(disabledProps.isIcebergRestViewEnabled()); - Assertions.assertFalse(disabledProps.getIcebergRestCatalogProperties() - .containsKey("iceberg.rest.view-enabled")); - } - - @Test - public void testOAuth2CredentialFlow() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.security.type", "oauth2"); - props.put("iceberg.rest.oauth2.credential", "client_credentials"); - props.put("iceberg.rest.oauth2.server-uri", "http://auth.example.com/token"); - props.put("iceberg.rest.oauth2.scope", "read write"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertEquals("client_credentials", catalogProps.get(OAuth2Properties.CREDENTIAL)); - Assertions.assertEquals("http://auth.example.com/token", catalogProps.get(OAuth2Properties.OAUTH2_SERVER_URI)); - Assertions.assertEquals("read write", catalogProps.get(OAuth2Properties.SCOPE)); - Assertions.assertEquals(String.valueOf(OAuth2Properties.TOKEN_REFRESH_ENABLED_DEFAULT), - catalogProps.get(OAuth2Properties.TOKEN_REFRESH_ENABLED)); - } - - @Test - public void testOAuth2TokenFlow() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.security.type", "oauth2"); - props.put("iceberg.rest.oauth2.token", "my-access-token"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertEquals("my-access-token", catalogProps.get(OAuth2Properties.TOKEN)); - Assertions.assertFalse(catalogProps.containsKey(OAuth2Properties.CREDENTIAL)); - Assertions.assertFalse(catalogProps.containsKey(OAuth2Properties.OAUTH2_SERVER_URI)); - Assertions.assertFalse(catalogProps.containsKey(OAuth2Properties.SCOPE)); - } - - @Test - public void testOAuth2UserSessionFlow() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.security.type", "oauth2"); - props.put("iceberg.rest.session", "user"); - props.put("iceberg.rest.session-timeout", "60000"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertTrue(restProps.isIcebergRestUserSessionEnabled()); - Assertions.assertEquals(AuthProperties.AUTH_TYPE_OAUTH2, catalogProps.get(AuthProperties.AUTH_TYPE)); - Assertions.assertEquals("60000", catalogProps.get(CatalogProperties.AUTH_SESSION_TIMEOUT_MS)); - Assertions.assertFalse(catalogProps.containsKey(OAuth2Properties.TOKEN)); - Assertions.assertFalse(catalogProps.containsKey(OAuth2Properties.CREDENTIAL)); - } - - @Test - public void testOAuth2UserSessionCatalogInitKeepsBootstrapCredential() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.security.type", "oauth2"); - props.put("iceberg.rest.session", "user"); - props.put("iceberg.rest.oauth2.credential", "client_credentials"); - props.put("iceberg.rest.oauth2.server-uri", "http://auth.example.com/token"); - props.put("iceberg.rest.oauth2.scope", "read write"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - - Assertions.assertEquals(AuthProperties.AUTH_TYPE_OAUTH2, catalogProps.get(AuthProperties.AUTH_TYPE)); - Assertions.assertEquals("client_credentials", catalogProps.get(OAuth2Properties.CREDENTIAL)); - Assertions.assertEquals("http://auth.example.com/token", catalogProps.get(OAuth2Properties.OAUTH2_SERVER_URI)); - Assertions.assertEquals("read write", catalogProps.get(OAuth2Properties.SCOPE)); - - Map tokenProps = new HashMap<>(); - tokenProps.put("iceberg.rest.uri", "http://localhost:8080"); - tokenProps.put("iceberg.rest.security.type", "oauth2"); - tokenProps.put("iceberg.rest.session", "user"); - tokenProps.put("iceberg.rest.oauth2.token", "static-access-token"); - - IcebergRestProperties tokenRestProps = new IcebergRestProperties(tokenProps); - tokenRestProps.initNormalizeAndCheckProps(); - - Map tokenCatalogProps = tokenRestProps.getIcebergRestCatalogProperties(); - Assertions.assertEquals("static-access-token", tokenCatalogProps.get(OAuth2Properties.TOKEN)); - } - - @Test - public void testOAuth2UserSessionCatalogInitUsesDelegatedAccessToken() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.security.type", "oauth2"); - props.put("iceberg.rest.session", "user"); - props.put("iceberg.rest.oauth2.credential", "client_credentials"); - props.put("iceberg.rest.oauth2.server-uri", "http://auth.example.com/token"); - props.put("iceberg.rest.oauth2.scope", "read write"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); - - Map catalogProps = restProps.getIcebergRestCatalogPropertiesForCatalogInit( - SessionContext.of(new DelegatedCredential( - DelegatedCredential.Type.ACCESS_TOKEN, "delegated-access-token"))); - - Assertions.assertEquals("delegated-access-token", catalogProps.get(OAuth2Properties.TOKEN)); - Assertions.assertFalse(catalogProps.containsKey(OAuth2Properties.CREDENTIAL)); - Assertions.assertFalse(catalogProps.containsKey(OAuth2Properties.OAUTH2_SERVER_URI)); - Assertions.assertFalse(catalogProps.containsKey(OAuth2Properties.SCOPE)); - } - - @Test - public void testOAuth2UserSessionCatalogInitUsesDelegatedTokenExchangeCredential() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.security.type", "oauth2"); - props.put("iceberg.rest.session", "user"); - props.put("iceberg.rest.oauth2.delegated-token-mode", "token_exchange"); - props.put("iceberg.rest.oauth2.credential", "client_credentials"); - props.put("iceberg.rest.oauth2.server-uri", "http://auth.example.com/token"); - props.put("iceberg.rest.oauth2.scope", "read write"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); - - Map catalogProps = restProps.getIcebergRestCatalogPropertiesForCatalogInit( - SessionContext.of(new DelegatedCredential( - DelegatedCredential.Type.ID_TOKEN, "delegated-id-token"))); - - Assertions.assertEquals("delegated-id-token", catalogProps.get(OAuth2Properties.ID_TOKEN_TYPE)); - Assertions.assertEquals("client_credentials", catalogProps.get(OAuth2Properties.CREDENTIAL)); - Assertions.assertEquals("http://auth.example.com/token", catalogProps.get(OAuth2Properties.OAUTH2_SERVER_URI)); - Assertions.assertEquals("read write", catalogProps.get(OAuth2Properties.SCOPE)); - Assertions.assertFalse(catalogProps.containsKey(OAuth2Properties.TOKEN)); - } - - @Test - public void testInitCatalogDoesNotCaptureCurrentDelegatedCredential() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.security.type", "oauth2"); - props.put("iceberg.rest.session", "user"); - props.put("iceberg.rest.oauth2.credential", "client_credentials"); - props.put("iceberg.rest.oauth2.server-uri", "http://auth.example.com/token"); - props.put("iceberg.rest.oauth2.scope", "read write"); - - CapturingIcebergRestProperties restProps = new CapturingIcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); - - ConnectContext context = new ConnectContext(); - context.setSessionContext(SessionContext.of(new DelegatedCredential( - DelegatedCredential.Type.ACCESS_TOKEN, "delegated-access-token"))); - context.setThreadLocalInfo(); - try { - restProps.initCatalog("test_catalog", new HashMap<>(), new ArrayList<>()); - - Assertions.assertFalse(restProps.capturedCatalogProps.containsKey(OAuth2Properties.TOKEN)); - Assertions.assertEquals("client_credentials", - restProps.capturedCatalogProps.get(OAuth2Properties.CREDENTIAL)); - } finally { - ConnectContext.remove(); - } - } - - @Test - public void testOAuth2DelegatedTokenMode() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.security.type", "oauth2"); - props.put("iceberg.rest.session", "user"); - - IcebergRestProperties defaultProps = new IcebergRestProperties(props); - defaultProps.initNormalizeAndCheckProps(); - Assertions.assertEquals(IcebergRestProperties.DelegatedTokenMode.ACCESS_TOKEN, - defaultProps.getDelegatedTokenMode()); - - props.put("iceberg.rest.oauth2.delegated-token-mode", "token_exchange"); - IcebergRestProperties tokenExchangeProps = new IcebergRestProperties(props); - tokenExchangeProps.initNormalizeAndCheckProps(); - Assertions.assertEquals(IcebergRestProperties.DelegatedTokenMode.TOKEN_EXCHANGE, - tokenExchangeProps.getDelegatedTokenMode()); - - props.put("iceberg.rest.oauth2.delegated-token-mode", "invalid"); - IcebergRestProperties invalidProps = new IcebergRestProperties(props); - Assertions.assertThrows(IllegalArgumentException.class, invalidProps::initNormalizeAndCheckProps); - } - - @Test - public void testUserSessionRequiresOAuth2SecurityType() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.session", "user"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - IllegalArgumentException exception = Assertions.assertThrows( - IllegalArgumentException.class, restProps::initNormalizeAndCheckProps); - Assertions.assertTrue(exception.getMessage().contains("iceberg.rest.session=user requires oauth2")); - } - - @Test - public void testOAuth2ValidationErrors() { - // Test: both credential and token provided - Map props1 = new HashMap<>(); - props1.put("iceberg.rest.uri", "http://localhost:8080"); - props1.put("iceberg.rest.security.type", "oauth2"); - props1.put("iceberg.rest.oauth2.credential", "client_credentials"); - props1.put("iceberg.rest.oauth2.token", "my-token"); - - IcebergRestProperties restProps1 = new IcebergRestProperties(props1); - Assertions.assertThrows(IllegalArgumentException.class, restProps1::initNormalizeAndCheckProps); - - // Test: OAuth2 enabled but no credential or token - Map props2 = new HashMap<>(); - props2.put("iceberg.rest.uri", "http://localhost:8080"); - props2.put("iceberg.rest.security.type", "oauth2"); - - IcebergRestProperties restProps2 = new IcebergRestProperties(props2); - Assertions.assertThrows(IllegalArgumentException.class, restProps2::initNormalizeAndCheckProps); - - // Test: credential flow without server URI is ok - Map props3 = new HashMap<>(); - props3.put("iceberg.rest.uri", "http://localhost:8080"); - props3.put("iceberg.rest.security.type", "oauth2"); - props3.put("iceberg.rest.oauth2.credential", "client_credentials"); - - IcebergRestProperties restProps3 = new IcebergRestProperties(props3); - Assertions.assertDoesNotThrow(restProps3::initNormalizeAndCheckProps); - - // Test: scope with token (should fail) - Map props4 = new HashMap<>(); - props4.put("iceberg.rest.uri", "http://localhost:8080"); - props4.put("iceberg.rest.security.type", "oauth2"); - props4.put("iceberg.rest.oauth2.token", "my-token"); - props4.put("iceberg.rest.oauth2.scope", "read"); - - IcebergRestProperties restProps4 = new IcebergRestProperties(props4); - Assertions.assertThrows(IllegalArgumentException.class, restProps4::initNormalizeAndCheckProps); - } - - @Test - public void testInvalidSecurityType() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.security.type", "invalid"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - Assertions.assertThrows(IllegalArgumentException.class, restProps::initNormalizeAndCheckProps); - } - - @Test - public void testSecurityTypeNone() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.security.type", "none"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - // Should only have basic properties, no OAuth2 properties - Assertions.assertEquals(CatalogUtil.ICEBERG_CATALOG_REST, - catalogProps.get(CatalogProperties.CATALOG_IMPL)); - Assertions.assertEquals("http://localhost:8080", catalogProps.get(CatalogProperties.URI)); - Assertions.assertFalse(catalogProps.containsKey(OAuth2Properties.CREDENTIAL)); - Assertions.assertFalse(catalogProps.containsKey(OAuth2Properties.TOKEN)); - } - - @Test - public void testUriAliases() { - // Test different URI property names - Map props1 = new HashMap<>(); - props1.put("uri", "http://localhost:8080"); - IcebergRestProperties restProps1 = new IcebergRestProperties(props1); - restProps1.initNormalizeAndCheckProps(); - Assertions.assertEquals("http://localhost:8080", - restProps1.getIcebergRestCatalogProperties().get(CatalogProperties.URI)); - - Map props2 = new HashMap<>(); - props2.put("iceberg.rest.uri", "http://localhost:8080"); - IcebergRestProperties restProps2 = new IcebergRestProperties(props2); - restProps2.initNormalizeAndCheckProps(); - Assertions.assertEquals("http://localhost:8080", - restProps2.getIcebergRestCatalogProperties().get(CatalogProperties.URI)); - } - - @Test - public void testWarehouseAliases() { - // Test different warehouse property names - Map props1 = new HashMap<>(); - props1.put("iceberg.rest.uri", "http://localhost:8080"); - props1.put("warehouse", "s3://warehouse/path"); - IcebergRestProperties restProps1 = new IcebergRestProperties(props1); - restProps1.initNormalizeAndCheckProps(); - Assertions.assertEquals("s3://warehouse/path", - restProps1.getIcebergRestCatalogProperties().get(CatalogProperties.WAREHOUSE_LOCATION)); - - Map props2 = new HashMap<>(); - props2.put("iceberg.rest.uri", "http://localhost:8080"); - props2.put("warehouse", "s3://warehouse/path"); - IcebergRestProperties restProps2 = new IcebergRestProperties(props2); - restProps2.initNormalizeAndCheckProps(); - Assertions.assertEquals("s3://warehouse/path", - restProps2.getIcebergRestCatalogProperties().get(CatalogProperties.WAREHOUSE_LOCATION)); - } - - @Test - public void testImmutablePropertiesMap() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - - // Should throw UnsupportedOperationException when trying to modify - Assertions.assertThrows(UnsupportedOperationException.class, () -> { - catalogProps.put("test", "value"); - }); - } - - @Test - public void testGlueRestCatalogValidConfiguration() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "glue"); - props.put("iceberg.rest.signing-region", "us-east-1"); - props.put("iceberg.rest.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - props.put("iceberg.rest.secret-access-key", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - props.put("iceberg.rest.sigv4-enabled", "true"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertEquals("glue", catalogProps.get("rest.signing-name")); - Assertions.assertEquals("us-east-1", catalogProps.get("rest.signing-region")); - Assertions.assertEquals("AKIAIOSFODNN7EXAMPLE", catalogProps.get("rest.access-key-id")); - Assertions.assertEquals("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - catalogProps.get("rest.secret-access-key")); - Assertions.assertEquals("true", catalogProps.get("rest.sigv4-enabled")); - } - - @Test - public void testGlueRestCatalogCaseInsensitive() { - // Test that "GLUE" is also recognized (case insensitive) - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "GLUE"); - props.put("iceberg.rest.signing-region", "us-west-2"); - props.put("iceberg.rest.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - props.put("iceberg.rest.secret-access-key", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - props.put("iceberg.rest.sigv4-enabled", "true"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertEquals("GLUE", catalogProps.get("rest.signing-name")); - Assertions.assertEquals("us-west-2", catalogProps.get("rest.signing-region")); - } - - @Test - public void testGlueRestCatalogMissingSigningRegion() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "glue"); - props.put("iceberg.rest.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - props.put("iceberg.rest.secret-access-key", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - props.put("iceberg.rest.sigv4-enabled", "true"); - // Missing signing-region - - IcebergRestProperties restProps = new IcebergRestProperties(props); - Assertions.assertThrows(IllegalArgumentException.class, restProps::initNormalizeAndCheckProps); - } - - @Test - public void testGlueRestCatalogMissingAccessKeyId() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "glue"); - props.put("iceberg.rest.signing-region", "us-east-1"); - props.put("iceberg.rest.secret-access-key", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - props.put("iceberg.rest.sigv4-enabled", "true"); - // Missing access-key-id - - IcebergRestProperties restProps = new IcebergRestProperties(props); - Assertions.assertThrows(IllegalArgumentException.class, restProps::initNormalizeAndCheckProps); - } - - @Test - public void testGlueRestCatalogMissingSecretAccessKey() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "glue"); - props.put("iceberg.rest.signing-region", "us-east-1"); - props.put("iceberg.rest.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - props.put("iceberg.rest.sigv4-enabled", "true"); - // Missing secret-access-key - - IcebergRestProperties restProps = new IcebergRestProperties(props); - Assertions.assertThrows(IllegalArgumentException.class, restProps::initNormalizeAndCheckProps); - } - - @Test - public void testGlueRestCatalogMissingSigV4Enabled() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "glue"); - props.put("iceberg.rest.signing-region", "us-east-1"); - props.put("iceberg.rest.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - props.put("iceberg.rest.secret-access-key", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - // Missing sigv4-enabled - - IcebergRestProperties restProps = new IcebergRestProperties(props); - Assertions.assertThrows(IllegalArgumentException.class, restProps::initNormalizeAndCheckProps); - } - - @Test - public void testNonGlueSigningNameDoesNotRequireAdditionalProperties() { - // Test that non-glue signing names don't require additional properties - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing", "custom-service"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); // Should not throw - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - // Should not contain glue-specific properties - Assertions.assertFalse(catalogProps.containsKey("rest.signing-name")); - Assertions.assertFalse(catalogProps.containsKey("rest.signing-region")); - Assertions.assertFalse(catalogProps.containsKey("rest.access-key-id")); - Assertions.assertFalse(catalogProps.containsKey("rest.secret-access-key")); - Assertions.assertFalse(catalogProps.containsKey("rest.sigv4-enabled")); - props.put("iceberg.rest.signing-name", "custom-service"); - props.put("iceberg.rest.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - props.put("iceberg.rest.secret-access-key", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); // Should not throw - catalogProps = restProps.getIcebergRestCatalogProperties(); - // Should not contain glue-specific properties - Assertions.assertTrue(catalogProps.containsKey("rest.signing-name")); - Assertions.assertTrue(catalogProps.containsKey("rest.signing-region")); - Assertions.assertTrue(catalogProps.containsKey("rest.access-key-id")); - Assertions.assertTrue(catalogProps.containsKey("rest.secret-access-key")); - Assertions.assertTrue(catalogProps.containsKey("rest.sigv4-enabled")); - } - - @Test - public void testEmptySigningNameDoesNotAddGlueProperties() { - // Test that empty signing name doesn't add glue properties - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-region", "us-east-1"); - props.put("iceberg.rest.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - props.put("iceberg.rest.secret-access-key", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - props.put("iceberg.rest.sigv4-enabled", "true"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); // Should not throw - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - // Should not contain glue-specific properties since signing-name is not "glue" - Assertions.assertFalse(catalogProps.containsKey("rest.signing-name")); - Assertions.assertFalse(catalogProps.containsKey("rest.signing-region")); - Assertions.assertFalse(catalogProps.containsKey("rest.access-key-id")); - Assertions.assertFalse(catalogProps.containsKey("rest.secret-access-key")); - Assertions.assertFalse(catalogProps.containsKey("rest.sigv4-enabled")); - } - - @Test - public void testGlueRestCatalogWithOAuth2() { - // Test that Glue properties can be combined with OAuth2 - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.security.type", "oauth2"); - props.put("iceberg.rest.oauth2.token", "my-access-token"); - props.put("iceberg.rest.signing-name", "glue"); - props.put("iceberg.rest.signing-region", "us-east-1"); - props.put("iceberg.rest.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - props.put("iceberg.rest.secret-access-key", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - props.put("iceberg.rest.sigv4-enabled", "true"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - restProps.initNormalizeAndCheckProps(); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - // Should have both OAuth2 and Glue properties - Assertions.assertEquals("my-access-token", catalogProps.get(OAuth2Properties.TOKEN)); - Assertions.assertEquals("glue", catalogProps.get("rest.signing-name")); - Assertions.assertEquals("us-east-1", catalogProps.get("rest.signing-region")); - Assertions.assertEquals("AKIAIOSFODNN7EXAMPLE", catalogProps.get("rest.access-key-id")); - Assertions.assertEquals("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - catalogProps.get("rest.secret-access-key")); - Assertions.assertEquals("true", catalogProps.get("rest.sigv4-enabled")); - } - - @Test - public void testGlueRestCatalogMissingMultipleProperties() { - // Test error message when multiple required properties are missing - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "glue"); - // Missing all required properties - - IcebergRestProperties restProps = new IcebergRestProperties(props); - IllegalArgumentException exception = Assertions.assertThrows( - IllegalArgumentException.class, restProps::initNormalizeAndCheckProps); - - // The error message should mention the required properties - String errorMessage = exception.getMessage(); - Assertions.assertTrue(errorMessage.contains("signing-region") - || errorMessage.contains("access-key-id") - || errorMessage.contains("secret-access-key") - || errorMessage.contains("sigv4-enabled")); - } - - @Test - public void testS3TablesSigningNameValidWithAccessKeyAndSecretKey() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "s3tables"); - props.put("iceberg.rest.signing-region", "us-east-1"); - props.put("iceberg.rest.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - props.put("iceberg.rest.secret-access-key", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - props.put("iceberg.rest.sigv4-enabled", "true"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - Assertions.assertDoesNotThrow(restProps::initNormalizeAndCheckProps); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertEquals("s3tables", catalogProps.get("rest.signing-name")); - Assertions.assertEquals("us-east-1", catalogProps.get("rest.signing-region")); - Assertions.assertEquals("AKIAIOSFODNN7EXAMPLE", catalogProps.get("rest.access-key-id")); - Assertions.assertEquals("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - catalogProps.get("rest.secret-access-key")); - } - - @Test - public void testS3TablesSigningNameCaseInsensitive() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "S3TABLES"); - props.put("iceberg.rest.signing-region", "us-west-2"); - props.put("iceberg.rest.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - props.put("iceberg.rest.secret-access-key", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - props.put("iceberg.rest.sigv4-enabled", "true"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - Assertions.assertDoesNotThrow(restProps::initNormalizeAndCheckProps); - Assertions.assertEquals("S3TABLES", restProps.getIcebergRestCatalogProperties().get("rest.signing-name")); - } - - @Test - public void testGlueSigningNameWithIamRoleFails() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "glue"); - props.put("iceberg.rest.signing-region", "us-east-1"); - props.put("iceberg.rest.role_arn", "arn:aws:iam::123456789012:role/MyGlueRole"); - props.put("iceberg.rest.sigv4-enabled", "true"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, - restProps::initNormalizeAndCheckProps); - Assertions.assertTrue(e.getMessage().contains("iceberg.rest.role_arn")); - } - - @Test - public void testS3TablesSigningNameWithIamRoleFails() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "s3tables"); - props.put("iceberg.rest.signing-region", "us-west-2"); - props.put("iceberg.rest.role_arn", "arn:aws:iam::999999999999:role/S3TablesRole"); - props.put("iceberg.rest.sigv4-enabled", "true"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, - restProps::initNormalizeAndCheckProps); - Assertions.assertTrue(e.getMessage().contains("iceberg.rest.role_arn")); - } - - @Test - public void testGlueSigningNameWithDefaultCredentialsProvider() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "glue"); - props.put("iceberg.rest.signing-region", "us-east-1"); - props.put("iceberg.rest.sigv4-enabled", "true"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - Assertions.assertDoesNotThrow(restProps::initNormalizeAndCheckProps); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider")); - } - - @Test - public void testS3TablesSigningNameWithDefaultCredentialsProvider() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "s3tables"); - props.put("iceberg.rest.signing-region", "us-east-1"); - props.put("iceberg.rest.sigv4-enabled", "true"); - // No credentials, should use DEFAULT provider - - IcebergRestProperties restProps = new IcebergRestProperties(props); - Assertions.assertDoesNotThrow(restProps::initNormalizeAndCheckProps); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider")); - } - - @Test - public void testS3TablesSigningNameMissingSigningRegionFails() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "s3tables"); - props.put("iceberg.rest.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - props.put("iceberg.rest.secret-access-key", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - props.put("iceberg.rest.sigv4-enabled", "true"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, - restProps::initNormalizeAndCheckProps); - Assertions.assertTrue(e.getMessage().contains("signing-region") && e.getMessage().contains("s3tables")); - } - - @Test - public void testAccessKeyAndSecretKeyMustBeSetTogether() { - Map props1 = new HashMap<>(); - props1.put("iceberg.rest.uri", "http://localhost:8080"); - props1.put("iceberg.rest.signing-name", "glue"); - props1.put("iceberg.rest.signing-region", "us-east-1"); - props1.put("iceberg.rest.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - props1.put("iceberg.rest.sigv4-enabled", "true"); - - IcebergRestProperties restProps1 = new IcebergRestProperties(props1); - IllegalArgumentException e1 = Assertions.assertThrows(IllegalArgumentException.class, - restProps1::initNormalizeAndCheckProps); - Assertions.assertTrue(e1.getMessage().contains("access-key-id") - && e1.getMessage().contains("secret-access-key")); - - Map props2 = new HashMap<>(); - props2.put("iceberg.rest.uri", "http://localhost:8080"); - props2.put("iceberg.rest.signing-name", "glue"); - props2.put("iceberg.rest.signing-region", "us-east-1"); - props2.put("iceberg.rest.secret-access-key", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - props2.put("iceberg.rest.sigv4-enabled", "true"); - - IcebergRestProperties restProps2 = new IcebergRestProperties(props2); - Assertions.assertThrows(IllegalArgumentException.class, restProps2::initNormalizeAndCheckProps); - } - - @Test - public void testGlueWithIamRoleAndExternalIdFails() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "glue"); - props.put("iceberg.rest.signing-region", "us-east-1"); - props.put("iceberg.rest.role_arn", "arn:aws:iam::123456789012:role/MyGlueRole"); - props.put("iceberg.rest.external-id", "external-123"); - props.put("iceberg.rest.sigv4-enabled", "true"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, - restProps::initNormalizeAndCheckProps); - Assertions.assertTrue(e.getMessage().contains("iceberg.rest.role_arn")); - } - - @Test - public void testGlueWithExternalIdFails() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "glue"); - props.put("iceberg.rest.signing-region", "us-east-1"); - props.put("iceberg.rest.external-id", "external-123"); - props.put("iceberg.rest.sigv4-enabled", "true"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, - restProps::initNormalizeAndCheckProps); - Assertions.assertTrue(e.getMessage().contains("iceberg.rest.external-id")); - } - - @Test - public void testGlueWithCredentialsProviderTypeDefault() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "glue"); - props.put("iceberg.rest.signing-region", "us-east-1"); - props.put("iceberg.rest.sigv4-enabled", "true"); - props.put("iceberg.rest.credentials_provider_type", "DEFAULT"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - Assertions.assertDoesNotThrow(restProps::initNormalizeAndCheckProps); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertEquals("glue", catalogProps.get("rest.signing-name")); - Assertions.assertEquals("us-east-1", catalogProps.get("rest.signing-region")); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider")); - } - - @Test - public void testS3TablesWithCredentialsProviderTypeInstanceProfile() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "s3tables"); - props.put("iceberg.rest.signing-region", "us-west-2"); - props.put("iceberg.rest.sigv4-enabled", "true"); - props.put("iceberg.rest.credentials_provider_type", "INSTANCE_PROFILE"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - Assertions.assertDoesNotThrow(restProps::initNormalizeAndCheckProps); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertEquals("s3tables", catalogProps.get("rest.signing-name")); - Assertions.assertEquals( - "software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider", - catalogProps.get("client.credentials-provider")); - } - - @Test - public void testS3TablesWithCredentialsProviderTypeEnvWithoutAccessKey() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "s3tables"); - props.put("iceberg.rest.signing-region", "us-west-2"); - props.put("iceberg.rest.sigv4-enabled", "true"); - props.put("iceberg.rest.credentials_provider_type", "ENV"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - Assertions.assertDoesNotThrow(restProps::initNormalizeAndCheckProps); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertEquals("s3tables", catalogProps.get("rest.signing-name")); - Assertions.assertEquals("us-west-2", catalogProps.get("rest.signing-region")); - Assertions.assertEquals( - "software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider", - catalogProps.get("client.credentials-provider")); - Assertions.assertFalse(catalogProps.containsKey("rest.access-key-id")); - Assertions.assertFalse(catalogProps.containsKey("rest.secret-access-key")); - } - - @Test - public void testGlueWithCredentialsProviderTypeEnv() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "glue"); - props.put("iceberg.rest.signing-region", "ap-east-1"); - props.put("iceberg.rest.sigv4-enabled", "true"); - props.put("iceberg.rest.credentials_provider_type", "ENV"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - Assertions.assertDoesNotThrow(restProps::initNormalizeAndCheckProps); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertEquals( - "software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider", - catalogProps.get("client.credentials-provider")); - } - - @Test - public void testAccessKeyPriorityOverCredentialsProviderType() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "glue"); - props.put("iceberg.rest.signing-region", "us-east-1"); - props.put("iceberg.rest.sigv4-enabled", "true"); - props.put("iceberg.rest.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - props.put("iceberg.rest.secret-access-key", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - props.put("iceberg.rest.credentials_provider_type", "DEFAULT"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - Assertions.assertDoesNotThrow(restProps::initNormalizeAndCheckProps); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertEquals("AKIAIOSFODNN7EXAMPLE", - catalogProps.get("rest.access-key-id")); - Assertions.assertEquals("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - catalogProps.get("rest.secret-access-key")); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider")); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider.s3.access-key-id")); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider.s3.secret-access-key")); - } - - @Test - public void testIamRoleWithCredentialsProviderTypeFails() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "s3tables"); - props.put("iceberg.rest.signing-region", "us-west-2"); - props.put("iceberg.rest.sigv4-enabled", "true"); - props.put("iceberg.rest.role_arn", "arn:aws:iam::123456789012:role/MyRole"); - props.put("iceberg.rest.credentials_provider_type", "INSTANCE_PROFILE"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, - restProps::initNormalizeAndCheckProps); - Assertions.assertTrue(e.getMessage().contains("iceberg.rest.role_arn")); - } - - @Test - public void testNonGlueSigningNameWithoutCredentialsAllowed() { - Map props = new HashMap<>(); - props.put("iceberg.rest.uri", "http://localhost:8080"); - props.put("iceberg.rest.signing-name", "custom-service"); - props.put("iceberg.rest.signing-region", "us-east-1"); - props.put("iceberg.rest.sigv4-enabled", "true"); - - IcebergRestProperties restProps = new IcebergRestProperties(props); - Assertions.assertDoesNotThrow(restProps::initNormalizeAndCheckProps); - - Map catalogProps = restProps.getIcebergRestCatalogProperties(); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider")); - } - - @Test - public void testToFileIOPropertiesPrefersNonS3Properties() { - // When both S3Properties and OSSProperties exist, OSSProperties should be chosen - Map s3Props = new HashMap<>(); - s3Props.put("s3.endpoint", "https://s3.us-east-1.amazonaws.com"); - s3Props.put("s3.access_key", "s3AccessKey"); - s3Props.put("s3.secret_key", "s3SecretKey"); - s3Props.put("s3.region", "us-east-1"); - s3Props.put(StorageProperties.FS_S3_SUPPORT, "true"); - S3Properties s3 = (S3Properties) StorageProperties.createPrimary(s3Props); - - Map ossProps = new HashMap<>(); - ossProps.put("oss.endpoint", "oss-cn-beijing.aliyuncs.com"); - ossProps.put("oss.access_key", "ossAccessKey"); - ossProps.put("oss.secret_key", "ossSecretKey"); - ossProps.put(StorageProperties.FS_OSS_SUPPORT, "true"); - OSSProperties oss = (OSSProperties) StorageProperties.createPrimary(ossProps); - - Map restPropsMap = new HashMap<>(); - restPropsMap.put("iceberg.rest.uri", "http://localhost:8080"); - IcebergRestProperties restProps = new IcebergRestProperties(restPropsMap); - restProps.initNormalizeAndCheckProps(); - - List storageList = new ArrayList<>(); - storageList.add(s3); - storageList.add(oss); - - Map fileIOProperties = new HashMap<>(); - Configuration conf = new Configuration(); - restProps.toFileIOProperties(storageList, fileIOProperties, conf); - - // OSSProperties should be used, not S3Properties - Assertions.assertEquals("oss-cn-beijing.aliyuncs.com", fileIOProperties.get(S3FileIOProperties.ENDPOINT)); - Assertions.assertEquals("ossAccessKey", fileIOProperties.get(S3FileIOProperties.ACCESS_KEY_ID)); - Assertions.assertEquals("ossSecretKey", fileIOProperties.get(S3FileIOProperties.SECRET_ACCESS_KEY)); - } - - @Test - public void testToFileIOPropertiesFallsBackToS3Properties() { - // When only S3Properties exists, it should be used - Map s3Props = new HashMap<>(); - s3Props.put("s3.endpoint", "https://s3.us-east-1.amazonaws.com"); - s3Props.put("s3.access_key", "s3AccessKey"); - s3Props.put("s3.secret_key", "s3SecretKey"); - s3Props.put("s3.region", "us-east-1"); - s3Props.put(StorageProperties.FS_S3_SUPPORT, "true"); - S3Properties s3 = (S3Properties) StorageProperties.createPrimary(s3Props); - - Map restPropsMap = new HashMap<>(); - restPropsMap.put("iceberg.rest.uri", "http://localhost:8080"); - IcebergRestProperties restProps = new IcebergRestProperties(restPropsMap); - restProps.initNormalizeAndCheckProps(); - - List storageList = new ArrayList<>(); - storageList.add(s3); - - Map fileIOProperties = new HashMap<>(); - Configuration conf = new Configuration(); - restProps.toFileIOProperties(storageList, fileIOProperties, conf); - - Assertions.assertEquals("https://s3.us-east-1.amazonaws.com", fileIOProperties.get(S3FileIOProperties.ENDPOINT)); - Assertions.assertEquals("s3AccessKey", fileIOProperties.get(S3FileIOProperties.ACCESS_KEY_ID)); - Assertions.assertEquals("us-east-1", fileIOProperties.get(AwsClientProperties.CLIENT_REGION)); - } - - @Test - public void testToFileIOPropertiesOnlyFirstNonS3Used() { - // When S3Properties comes first, then two non-S3 types, only the first non-S3 is used - Map s3Props = new HashMap<>(); - s3Props.put("s3.endpoint", "https://s3.amazonaws.com"); - s3Props.put("s3.access_key", "s3AK"); - s3Props.put("s3.secret_key", "s3SK"); - s3Props.put("s3.region", "us-east-1"); - s3Props.put(StorageProperties.FS_S3_SUPPORT, "true"); - S3Properties s3 = (S3Properties) StorageProperties.createPrimary(s3Props); - - Map ossProps1 = new HashMap<>(); - ossProps1.put("oss.endpoint", "oss-cn-beijing.aliyuncs.com"); - ossProps1.put("oss.access_key", "ossAK1"); - ossProps1.put("oss.secret_key", "ossSK1"); - ossProps1.put(StorageProperties.FS_OSS_SUPPORT, "true"); - OSSProperties oss1 = (OSSProperties) StorageProperties.createPrimary(ossProps1); - - Map ossProps2 = new HashMap<>(); - ossProps2.put("oss.endpoint", "oss-cn-shanghai.aliyuncs.com"); - ossProps2.put("oss.access_key", "ossAK2"); - ossProps2.put("oss.secret_key", "ossSK2"); - ossProps2.put(StorageProperties.FS_OSS_SUPPORT, "true"); - OSSProperties oss2 = (OSSProperties) StorageProperties.createPrimary(ossProps2); - - Map restPropsMap = new HashMap<>(); - restPropsMap.put("iceberg.rest.uri", "http://localhost:8080"); - IcebergRestProperties restProps = new IcebergRestProperties(restPropsMap); - restProps.initNormalizeAndCheckProps(); - - List storageList = new ArrayList<>(); - storageList.add(s3); - storageList.add(oss1); - storageList.add(oss2); - - Map fileIOProperties = new HashMap<>(); - Configuration conf = new Configuration(); - restProps.toFileIOProperties(storageList, fileIOProperties, conf); - - // First non-S3Properties (oss1) should be used - Assertions.assertEquals("oss-cn-beijing.aliyuncs.com", fileIOProperties.get(S3FileIOProperties.ENDPOINT)); - Assertions.assertEquals("ossAK1", fileIOProperties.get(S3FileIOProperties.ACCESS_KEY_ID)); - } - - private static class CapturingIcebergRestProperties extends IcebergRestProperties { - private Map capturedCatalogProps; - - private CapturingIcebergRestProperties(Map props) { - super(props); - } - - @Override - protected RESTSessionCatalog buildRestSessionCatalog(String catalogName, Map options, - Configuration conf) { - capturedCatalogProps = new HashMap<>(options); - // Return an uninitialized RESTSessionCatalog: asCatalog(empty) on it is a cheap, lazy wrapper - // (no REST/OAuth network call), which is all initCatalog does with the result here. - return new RESTSessionCatalog(); - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergS3TablesMetaStorePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergS3TablesMetaStorePropertiesTest.java deleted file mode 100644 index 3043ecc02c5188..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergS3TablesMetaStorePropertiesTest.java +++ /dev/null @@ -1,327 +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.datasource.property.metastore; - -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.property.common.IcebergAwsClientCredentialsProperties; -import org.apache.doris.datasource.property.storage.S3Properties; -import org.apache.doris.datasource.property.storage.StorageProperties; - -import com.google.common.collect.ImmutableMap; -import org.apache.iceberg.aws.AssumeRoleAwsClientFactory; -import org.apache.iceberg.aws.AwsClientProperties; -import org.apache.iceberg.aws.AwsProperties; -import org.apache.iceberg.aws.s3.S3FileIOProperties; -import org.apache.iceberg.catalog.Catalog; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import software.amazon.s3tables.iceberg.S3TablesCatalog; - -import java.lang.reflect.Method; -import java.util.HashMap; -import java.util.Map; - -public class IcebergS3TablesMetaStorePropertiesTest { - - /** - * Call private buildS3CatalogProperties to fill catalogProps without initializing S3TablesCatalog - * (which requires warehouse/table bucket ARN and would throw ValidationException). - */ - private static void buildS3CatalogProperties(IcebergS3TablesMetaStoreProperties metaProps, - Map catalogProps) throws Exception { - Method m = IcebergS3TablesMetaStoreProperties.class.getDeclaredMethod("buildS3CatalogProperties", Map.class); - m.setAccessible(true); - m.invoke(metaProps, catalogProps); - } - - @Test - public void s3FileIOCredentialPropertiesUseSharedS3Properties() { - Map props = new HashMap<>(); - props.put("s3.region", "us-east-1"); - props.put("s3.endpoint", "https://s3.us-east-1.amazonaws.com"); - props.put("s3.access_key", "AKID"); - props.put("s3.secret_key", "SECRET"); - props.put("s3.session_token", "TOKEN"); - props.put("s3.credentials_provider_type", "INSTANCE_PROFILE"); - - Map catalogProps = new HashMap<>(); - IcebergAwsClientCredentialsProperties.putS3FileIOCredentialProperties( - catalogProps, S3Properties.of(props)); - - Assertions.assertEquals("https://s3.us-east-1.amazonaws.com", - catalogProps.get(S3FileIOProperties.ENDPOINT)); - Assertions.assertEquals("AKID", catalogProps.get(S3FileIOProperties.ACCESS_KEY_ID)); - Assertions.assertEquals("SECRET", catalogProps.get(S3FileIOProperties.SECRET_ACCESS_KEY)); - Assertions.assertEquals("TOKEN", catalogProps.get(S3FileIOProperties.SESSION_TOKEN)); - Assertions.assertFalse(catalogProps.containsKey(AwsClientProperties.CLIENT_CREDENTIALS_PROVIDER)); - Assertions.assertFalse(catalogProps.containsKey(AwsProperties.CLIENT_ASSUME_ROLE_ARN)); - } - - @Test - public void s3TablesTest() throws UserException { - Map baseProps = ImmutableMap.of( - "type", "iceberg", - "iceberg.catalog.type", "s3tables", - "warehouse", "s3://my-bucket/warehouse"); - Map s3Props = ImmutableMap.of( - "s3.region", "us-west-2", - "s3.access_key", "AK", - "s3.secret_key", "SK", - "s3.endpoint", "https://s3.us-west-2.amazonaws.com"); - RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> MetastoreProperties.create(baseProps)); - Assertions.assertTrue(exception.getMessage().contains("Region is not set.")); - Map allProps = ImmutableMap.builder() - .putAll(baseProps) - .putAll(s3Props) - .build(); - IcebergS3TablesMetaStoreProperties properties = (IcebergS3TablesMetaStoreProperties) MetastoreProperties.create(allProps); - Catalog catalog = properties.initializeCatalog("iceberg_catalog", StorageProperties.createAll(allProps)); - Assertions.assertEquals(S3TablesCatalog.class, catalog.getClass()); - } - - @Test - public void s3TablesWithIamRole() throws Exception { - Map props = new HashMap<>(); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "s3tables"); - props.put("warehouse", "s3://my-bucket/warehouse"); - props.put("s3.region", "us-east-1"); - props.put("s3.role_arn", "arn:aws:iam::123456789012:role/S3TablesRole"); - props.put("s3.endpoint", "https://s3.us-east-1.amazonaws.com"); - - IcebergS3TablesMetaStoreProperties metaProps = new IcebergS3TablesMetaStoreProperties(props); - metaProps.initNormalizeAndCheckProps(); - - Assertions.assertEquals(IcebergExternalCatalog.ICEBERG_S3_TABLES, metaProps.getIcebergCatalogType()); - - Map catalogProps = new HashMap<>(); - buildS3CatalogProperties(metaProps, catalogProps); - - Assertions.assertEquals(AssumeRoleAwsClientFactory.class.getName(), - catalogProps.get(AwsProperties.CLIENT_FACTORY)); - Assertions.assertFalse(catalogProps.containsKey(S3FileIOProperties.CLIENT_FACTORY)); - Assertions.assertEquals("arn:aws:iam::123456789012:role/S3TablesRole", catalogProps.get("client.assume-role.arn")); - Assertions.assertEquals("us-east-1", catalogProps.get("client.assume-role.region")); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider")); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider.s3.role_arn")); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider.s3.region")); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider.s3.credentials_provider_type")); - Assertions.assertFalse(catalogProps.containsKey( - "client.credentials-provider.assume-role.arn")); - Assertions.assertFalse(catalogProps.containsKey( - "client.credentials-provider.assume-role.source-credentials-provider")); - Assertions.assertFalse(catalogProps.containsKey( - "client.credentials-provider.assume-role.source-provider-type")); - Assertions.assertFalse(catalogProps.containsKey( - "client.credentials-provider.client.assume-role.arn")); - } - - @Test - public void s3TablesWithIamRoleAndExternalId() throws Exception { - Map props = new HashMap<>(); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "s3tables"); - props.put("warehouse", "s3://my-bucket/warehouse"); - props.put("s3.region", "us-west-2"); - props.put("s3.role_arn", "arn:aws:iam::999999999999:role/MyRole"); - props.put("s3.external_id", "external-id-123"); - props.put("s3.endpoint", "https://s3.us-west-2.amazonaws.com"); - - IcebergS3TablesMetaStoreProperties metaProps = new IcebergS3TablesMetaStoreProperties(props); - metaProps.initNormalizeAndCheckProps(); - - Map catalogProps = new HashMap<>(); - buildS3CatalogProperties(metaProps, catalogProps); - - Assertions.assertEquals("arn:aws:iam::999999999999:role/MyRole", catalogProps.get("client.assume-role.arn")); - Assertions.assertEquals("external-id-123", catalogProps.get("client.assume-role.external-id")); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider.s3.external_id")); - Assertions.assertFalse(catalogProps.containsKey( - "client.credentials-provider.assume-role.external-id")); - } - - @Test - public void s3TablesWithAccessKeyPreferOverIamRole() throws Exception { - Map props = new HashMap<>(); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "s3tables"); - props.put("warehouse", "s3://my-bucket/warehouse"); - props.put("s3.region", "us-east-1"); - props.put("s3.access_key", "AKID"); - props.put("s3.secret_key", "SECRET"); - props.put("s3.role_arn", "arn:aws:iam::123456789012:role/Role"); - props.put("s3.endpoint", "https://s3.us-east-1.amazonaws.com"); - - IcebergS3TablesMetaStoreProperties metaProps = new IcebergS3TablesMetaStoreProperties(props); - metaProps.initNormalizeAndCheckProps(); - - Map catalogProps = new HashMap<>(); - buildS3CatalogProperties(metaProps, catalogProps); - - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider")); - Assertions.assertEquals("AKID", catalogProps.get(S3FileIOProperties.ACCESS_KEY_ID)); - Assertions.assertEquals("SECRET", catalogProps.get(S3FileIOProperties.SECRET_ACCESS_KEY)); - Assertions.assertNull(catalogProps.get("client.assume-role.arn")); - } - - // --- UT for credentials_provider_type support --- - - @Test - public void s3TablesWithCredentialsProviderTypeDefault() throws Exception { - Map props = new HashMap<>(); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "s3tables"); - props.put("warehouse", "s3://my-bucket/warehouse"); - props.put("s3.region", "us-west-2"); - props.put("s3.endpoint", "https://s3.us-west-2.amazonaws.com"); - props.put("s3.credentials_provider_type", "DEFAULT"); - - IcebergS3TablesMetaStoreProperties metaProps = new IcebergS3TablesMetaStoreProperties(props); - metaProps.initNormalizeAndCheckProps(); - - Map catalogProps = new HashMap<>(); - buildS3CatalogProperties(metaProps, catalogProps); - - Assertions.assertEquals("us-west-2", catalogProps.get("client.region")); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider")); - } - - @Test - public void s3TablesWithCredentialsProviderTypeInstanceProfile() throws Exception { - Map props = new HashMap<>(); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "s3tables"); - props.put("warehouse", "s3://my-bucket/warehouse"); - props.put("s3.region", "ap-east-1"); - props.put("s3.endpoint", "https://s3.ap-east-1.amazonaws.com"); - props.put("s3.credentials_provider_type", "INSTANCE_PROFILE"); - - IcebergS3TablesMetaStoreProperties metaProps = new IcebergS3TablesMetaStoreProperties(props); - metaProps.initNormalizeAndCheckProps(); - - Map catalogProps = new HashMap<>(); - buildS3CatalogProperties(metaProps, catalogProps); - - Assertions.assertEquals("software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider", - catalogProps.get("client.credentials-provider")); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider.s3.credentials_provider_type")); - } - - @Test - public void s3TablesWithCredentialsProviderTypeEnv() throws Exception { - Map props = new HashMap<>(); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "s3tables"); - props.put("warehouse", "s3://my-bucket/warehouse"); - props.put("s3.region", "us-east-1"); - props.put("s3.endpoint", "https://s3.us-east-1.amazonaws.com"); - props.put("s3.credentials_provider_type", "ENV"); - - IcebergS3TablesMetaStoreProperties metaProps = new IcebergS3TablesMetaStoreProperties(props); - metaProps.initNormalizeAndCheckProps(); - - Map catalogProps = new HashMap<>(); - buildS3CatalogProperties(metaProps, catalogProps); - - Assertions.assertEquals("software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider", - catalogProps.get("client.credentials-provider")); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider.s3.credentials_provider_type")); - } - - @Test - public void s3TablesAccessKeyPriorityOverCredentialsProviderType() throws Exception { - Map props = new HashMap<>(); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "s3tables"); - props.put("warehouse", "s3://my-bucket/warehouse"); - props.put("s3.region", "us-west-2"); - props.put("s3.access_key", "AKIAIOSFODNN7EXAMPLE"); - props.put("s3.secret_key", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - props.put("s3.credentials_provider_type", "INSTANCE_PROFILE"); - props.put("s3.endpoint", "https://s3.us-west-2.amazonaws.com"); - - IcebergS3TablesMetaStoreProperties metaProps = new IcebergS3TablesMetaStoreProperties(props); - metaProps.initNormalizeAndCheckProps(); - - Map catalogProps = new HashMap<>(); - buildS3CatalogProperties(metaProps, catalogProps); - - // Access key should take priority - Assertions.assertEquals("AKIAIOSFODNN7EXAMPLE", catalogProps.get(S3FileIOProperties.ACCESS_KEY_ID)); - Assertions.assertEquals("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - catalogProps.get(S3FileIOProperties.SECRET_ACCESS_KEY)); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider")); - } - - @Test - public void s3TablesIamRolePriorityOverCredentialsProviderType() throws Exception { - Map props = new HashMap<>(); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "s3tables"); - props.put("warehouse", "s3://my-bucket/warehouse"); - props.put("s3.region", "us-east-1"); - props.put("s3.role_arn", "arn:aws:iam::123456789012:role/S3TablesRole"); - props.put("s3.credentials_provider_type", "INSTANCE_PROFILE"); - props.put("s3.endpoint", "https://s3.us-east-1.amazonaws.com"); - - IcebergS3TablesMetaStoreProperties metaProps = new IcebergS3TablesMetaStoreProperties(props); - metaProps.initNormalizeAndCheckProps(); - - Map catalogProps = new HashMap<>(); - buildS3CatalogProperties(metaProps, catalogProps); - - // IAM Role should take priority - Assertions.assertEquals("arn:aws:iam::123456789012:role/S3TablesRole", - catalogProps.get("client.assume-role.arn")); - Assertions.assertEquals(AssumeRoleAwsClientFactory.class.getName(), - catalogProps.get(AwsProperties.CLIENT_FACTORY)); - Assertions.assertFalse(catalogProps.containsKey(S3FileIOProperties.CLIENT_FACTORY)); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider")); - Assertions.assertFalse(catalogProps.containsKey( - "client.credentials-provider.s3.credentials_provider_type")); - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider.s3.role_arn")); - Assertions.assertFalse(catalogProps.containsKey( - "client.credentials-provider.assume-role.source-credentials-provider")); - Assertions.assertFalse(catalogProps.containsKey( - "client.credentials-provider.assume-role.source-provider-type")); - Assertions.assertFalse(catalogProps.containsKey( - "client.credentials-provider.client.credentials-provider")); - } - - @Test - public void s3TablesDefaultCredentialsProviderTypeWhenNothingSet() throws Exception { - Map props = new HashMap<>(); - props.put("type", "iceberg"); - props.put("iceberg.catalog.type", "s3tables"); - props.put("warehouse", "s3://my-bucket/warehouse"); - props.put("s3.region", "us-west-2"); - props.put("s3.endpoint", "https://s3.us-west-2.amazonaws.com"); - // Not setting any credentials or credentials_provider_type - // S3Properties defaults to DEFAULT mode - - IcebergS3TablesMetaStoreProperties metaProps = new IcebergS3TablesMetaStoreProperties(props); - metaProps.initNormalizeAndCheckProps(); - - Map catalogProps = new HashMap<>(); - buildS3CatalogProperties(metaProps, catalogProps); - - // Let AWS SDK use its default provider chain when no credentials are provided. - Assertions.assertFalse(catalogProps.containsKey("client.credentials-provider")); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergUnityCatalogRestCatalogTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergUnityCatalogRestCatalogTest.java index 88862355c0a435..34d131bcb2a8ca 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergUnityCatalogRestCatalogTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergUnityCatalogRestCatalogTest.java @@ -17,11 +17,6 @@ package org.apache.doris.datasource.property.metastore; -import org.apache.doris.catalog.DatabaseIf; -import org.apache.doris.datasource.ExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergRestExternalCatalog; - import com.google.common.collect.Maps; import org.apache.hadoop.conf.Configuration; import org.apache.iceberg.CatalogProperties; @@ -38,8 +33,6 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import java.util.Collection; -import java.util.List; import java.util.Map; @Disabled("set your databricks token and uri before running the test") @@ -125,30 +118,4 @@ public void rawTest() { e.printStackTrace(); } } - - @Test - public void testCreateRestCatalog() { - Map properties = Maps.newHashMap(); - properties.put("uri", uri); - properties.put("type", "iceberg"); - properties.put("warehouse", "yy_unity_catalog"); - properties.put("iceberg.catalog.type", "rest"); - properties.put("iceberg.rest.security.type", "oauth2"); - properties.put("iceberg.rest.oauth2.token", oauthToken); - // properties.put("iceberg.rest.oauth2.scope", "all-apis"); - IcebergRestExternalCatalog catalog = new IcebergRestExternalCatalog( - 1, "databricks_test", null, properties, "test"); - catalog.setDefaultPropsIfMissing(false); - Collection> dbs = catalog.getAllDbs(); - for (DatabaseIf db : dbs) { - ExternalDatabase extDb = (ExternalDatabase) db; - System.out.println(extDb.getFullName()); - List tables = extDb.getTables(); - for (Object table : tables) { - IcebergExternalTable tbl = (IcebergExternalTable) table; - System.out.println(tbl.getName()); - System.out.println(tbl.location()); - } - } - } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonAliyunDLFMetaStorePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonAliyunDLFMetaStorePropertiesTest.java deleted file mode 100644 index 1e02de6a5a43a5..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonAliyunDLFMetaStorePropertiesTest.java +++ /dev/null @@ -1,197 +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.datasource.property.metastore; - -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.property.storage.StorageProperties; - -import org.apache.paimon.catalog.Catalog; -import org.apache.paimon.catalog.CatalogContext; -import org.apache.paimon.catalog.CatalogFactory; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.mockito.Mockito; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class PaimonAliyunDLFMetaStorePropertiesTest { - private Map createValidProps() { - Map props = new HashMap<>(); - props.put("type", "paimon"); - props.put("paimon.catalog.type", "dlf"); - props.put("dlf.access_key", "ak"); - props.put("dlf.secret_key", "sk"); - props.put("dlf.endpoint", "dlf.cn-hangzhou.aliyuncs.com"); - props.put("dlf.region", "cn-hangzhou"); - props.put("dlf.catalog.id", "catalogId"); - props.put("dlf.uid", "uid"); - props.put("warehouse", "oss://bucket/warehouse"); - return props; - } - - @Test - void testInitNormalizeAndCheckProps() { - Map props = createValidProps(); - PaimonAliyunDLFMetaStoreProperties dlfProps = - new PaimonAliyunDLFMetaStoreProperties(props); - - dlfProps.initNormalizeAndCheckProps(); - - Assertions.assertEquals( - "dlf", - dlfProps.getPaimonCatalogType(), - "Catalog type should be PAIMON_DLF" - ); - Assertions.assertEquals( - "hive", - dlfProps.getMetastoreType(), - "Metastore type should be hive" - ); - } - - @Test - void testInitializeCatalogWithValidOssProperties() throws UserException { - Map props = createValidProps(); - PaimonAliyunDLFMetaStoreProperties dlfProps = - new PaimonAliyunDLFMetaStoreProperties(props); - dlfProps.initNormalizeAndCheckProps(); - - // Prepare OSSProperties mock - Map ossProps = new HashMap<>(); - ossProps.put("oss.access_key", "ak"); - ossProps.put("oss.secret_key", "sk"); - ossProps.put("oss.endpoint", "oss-cn-hangzhou.aliyuncs.com"); - - - List storageProperties = StorageProperties.createAll(ossProps); - - Catalog mockCatalog = Mockito.mock(Catalog.class); - - try (MockedStatic mocked = Mockito.mockStatic(CatalogFactory.class)) { - mocked.when(() -> CatalogFactory.createCatalog(Mockito.any(CatalogContext.class))) - .thenReturn(mockCatalog); - - Catalog catalog = dlfProps.initializeCatalog("testCatalog", storageProperties); - - Assertions.assertNotNull(catalog, "Catalog should not be null"); - Assertions.assertEquals(mockCatalog, catalog, "Catalog should be the mocked one"); - - mocked.verify(() -> CatalogFactory.createCatalog(Mockito.any(CatalogContext.class))); - } - } - - - @Test - void testInitializeCatalogWithValidOssHdfsProperties() throws UserException { - Map props = createValidProps(); - PaimonAliyunDLFMetaStoreProperties dlfProps = - new PaimonAliyunDLFMetaStoreProperties(props); - dlfProps.initNormalizeAndCheckProps(); - - // Prepare OSSProperties mock - Map ossProps = new HashMap<>(); - ossProps.put("dlf.access_key", "ak"); - ossProps.put("dlf.secret_key", "sk"); - ossProps.put("dlf.endpoint", "dlf-vpc.cn-beijing.aliyuncs.com"); - ossProps.put("dlf.region", "cn-beijing"); - ossProps.put("oss.hdfs.enabled", "true"); - - - List storageProperties = StorageProperties.createAll(ossProps); - - Catalog mockCatalog = Mockito.mock(Catalog.class); - - try (MockedStatic mocked = Mockito.mockStatic(CatalogFactory.class)) { - mocked.when(() -> CatalogFactory.createCatalog(Mockito.any(CatalogContext.class))) - .thenReturn(mockCatalog); - - Catalog catalog = dlfProps.initializeCatalog("testCatalog", storageProperties); - - Assertions.assertNotNull(catalog, "Catalog should not be null"); - Assertions.assertEquals(mockCatalog, catalog, "Catalog should be the mocked one"); - - mocked.verify(() -> CatalogFactory.createCatalog(Mockito.any(CatalogContext.class))); - } - ossProps = new HashMap<>(); - ossProps.put("dlf.access_key", "ak"); - ossProps.put("dlf.secret_key", "sk"); - ossProps.put("dlf.endpoint", "dlf-vpc.cn-beijing.aliyuncs.com"); - ossProps.put("dlf.region", "cn-beijing"); - ossProps.put("oss.access_key", "ak"); - ossProps.put("oss.secret_key", "sk"); - ossProps.put("oss.endpoint", "oss-cn-beijing.oss-dls.aliyuncs.com"); - storageProperties = StorageProperties.createAll(ossProps); - - mockCatalog = Mockito.mock(Catalog.class); - - try (MockedStatic mocked = Mockito.mockStatic(CatalogFactory.class)) { - mocked.when(() -> CatalogFactory.createCatalog(Mockito.any(CatalogContext.class))) - .thenReturn(mockCatalog); - - Catalog catalog = dlfProps.initializeCatalog("testCatalog", storageProperties); - - Assertions.assertNotNull(catalog, "Catalog should not be null"); - Assertions.assertEquals(mockCatalog, catalog, "Catalog should be the mocked one"); - - mocked.verify(() -> CatalogFactory.createCatalog(Mockito.any(CatalogContext.class))); - } - - } - - @Test - void testInitializeCatalogWithoutOssPropertiesThrows() { - Map props = createValidProps(); - PaimonAliyunDLFMetaStoreProperties dlfProps = - new PaimonAliyunDLFMetaStoreProperties(props); - dlfProps.initNormalizeAndCheckProps(); - - List storageProperties = new ArrayList<>(); // No OSS properties - - IllegalStateException ex = Assertions.assertThrows( - IllegalStateException.class, - () -> dlfProps.initializeCatalog("testCatalog", storageProperties) - ); - - Assertions.assertTrue(ex.getMessage().contains("OSS storage properties")); - } - - @Test - void testInitializeCatalogWithNonOssTypeThrows() { - Map props = createValidProps(); - PaimonAliyunDLFMetaStoreProperties dlfProps = - new PaimonAliyunDLFMetaStoreProperties(props); - dlfProps.initNormalizeAndCheckProps(); - - StorageProperties nonOssProps = Mockito.mock(StorageProperties.class); - Mockito.when(nonOssProps.getType()).thenReturn(StorageProperties.Type.HDFS); - - List storageProperties = Collections.singletonList(nonOssProps); - - IllegalStateException ex = Assertions.assertThrows( - IllegalStateException.class, - () -> dlfProps.initializeCatalog("testCatalog", storageProperties) - ); - - Assertions.assertTrue(ex.getMessage().contains("Paimon DLF metastore requires OSS storage properties.")); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonCatalogTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonCatalogTest.java deleted file mode 100644 index 85633008a7145b..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonCatalogTest.java +++ /dev/null @@ -1,94 +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.datasource.property.metastore; - -import org.apache.doris.datasource.property.storage.StorageProperties; - -import org.apache.paimon.catalog.Catalog; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -@Disabled("only used for your local test") -public class PaimonCatalogTest { - @Test - public void testNameSpace() throws Exception { - Map pa = new HashMap<>(); - pa.put("type", "paimon"); - pa.put("paimon.catalog.type", "hms"); - pa.put("hive.metastore.uris", "thrift://172.20.48.119:9383"); - pa.put("warehouse", "s3a://doris/paimon_warehouse"); - pa.put("s3.region", "ap-east-1"); - - // User must provide real Access Key / Secret Key to enable initialization - pa.put("s3.access_key", ""); - pa.put("s3.secret_key", ""); - pa.put("s3.endpoint", "s3.ap-east-1.amazonaws.com"); - - Catalog catalog = initCatalog(pa); - if (catalog != null) { - catalog.listDatabases().forEach(System.out::println); - } - } - - /** - * Initializes a Paimon HMS Catalog. - *

    - * Initialization is skipped by default. Users must provide valid S3 - * Access Key and Secret Key in the configuration map to enable it. - *

    - * Steps: - * 1. Validate that credentials are provided. - * 2. Normalize and check metastore properties. - * 3. Create storage properties. - * 4. Initialize and return the Catalog instance. - * - * @param params A map containing the configuration parameters. - * @return Catalog instance if initialized, or {@code null} if skipped. - * @throws Exception If initialization fails. - */ - private Catalog initCatalog(Map params) throws Exception { - if (isDisabled(params)) { - System.out.println("Catalog initialization skipped: Missing valid S3 Access Key/Secret Key."); - return null; - } - AbstractPaimonProperties metaStoreProps = - (AbstractPaimonProperties) MetastoreProperties.create(params); - metaStoreProps.initNormalizeAndCheckProps(); - Assertions.assertNotNull(metaStoreProps.getExecutionAuthenticator()); - List storageProps = StorageProperties.createAll(params); - - return metaStoreProps.initializeCatalog("paimon_catalog", storageProps); - } - - /** - * Checks if initialization should be skipped due to missing credentials. - * - * @param params The configuration parameters. - * @return {@code true} if missing AK/SK, {@code false} otherwise. - */ - private boolean isDisabled(Map params) { - String ak = params.get("s3.access_key"); - String sk = params.get("s3.secret_key"); - return ak == null || ak.isEmpty() || sk == null || sk.isEmpty(); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonDlfRestCatalogTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonDlfRestCatalogTest.java deleted file mode 100644 index ce317382606b9a..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonDlfRestCatalogTest.java +++ /dev/null @@ -1,243 +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.datasource.property.metastore; - -import org.apache.doris.common.UserException; -import org.apache.doris.common.util.S3URI; -import org.apache.doris.common.util.Util; - -import com.amazonaws.ClientConfiguration; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicSessionCredentials; -import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; -import com.amazonaws.services.s3.model.GetObjectRequest; -import com.amazonaws.services.s3.model.S3Object; -import org.apache.hadoop.hive.conf.HiveConf; -import org.apache.paimon.catalog.Catalog.DatabaseNotExistException; -import org.apache.paimon.catalog.Catalog.TableNotExistException; -import org.apache.paimon.catalog.CatalogContext; -import org.apache.paimon.catalog.CatalogFactory; -import org.apache.paimon.catalog.Database; -import org.apache.paimon.fs.FileIO; -import org.apache.paimon.options.Options; -import org.apache.paimon.rest.RESTToken; -import org.apache.paimon.rest.RESTTokenFileIO; -import org.apache.paimon.table.source.DataSplit; -import org.apache.paimon.table.source.RawFile; -import org.apache.paimon.table.source.ReadBuilder; -import org.apache.paimon.table.source.Split; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.core.ResponseInputStream; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.S3Configuration; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -@Disabled("set aliyun access key, secret key before running the test") -public class PaimonDlfRestCatalogTest { - - private String aliyunAk = ""; - private String aliyunSk = ""; - - @Test - public void testPaimonDlfRestCatalog() throws DatabaseNotExistException, TableNotExistException, UserException { - org.apache.paimon.catalog.Catalog catalog = initPaimonDlfRestCatalog(); - System.out.println(catalog); - List dbs = catalog.listDatabases(); - for (String dbName : dbs) { - System.out.println("test debug get db: " + dbName); - Database db = catalog.getDatabase(dbName); - System.out.println("test debug get db instance: " + db.name() + ", " + db.options() + ", " + db.comment()); - List tables = catalog.listTables(dbName); - for (String tblName : tables) { - System.out.println("test debug get table: " + tblName); - if (!tblName.equalsIgnoreCase("users_samples")) { - continue; - } - org.apache.paimon.table.Table table = catalog.getTable( - org.apache.paimon.catalog.Identifier.create(dbName, tblName)); - System.out.println("test debug get table instance: " + table.name() + ", " + table.options() + ", " - + table.comment()); - - FileIO fileIO = table.fileIO(); - if (fileIO instanceof RESTTokenFileIO) { - System.out.println("test debug get file io instance: " + fileIO.getClass().getName()); - RESTTokenFileIO restTokenFileIO = (RESTTokenFileIO) fileIO; - RESTToken restToken = restTokenFileIO.validToken(); - Map tokens = restToken.token(); - for (Map.Entry kv : tokens.entrySet()) { - System.out.println("test debug get token: " + kv.getKey() + ", " + kv.getValue()); - } - // String accType = tokens.get("fs.oss.token.access.type"); - String tmpAk = tokens.get("fs.oss.accessKeyId"); - String tmpSk = tokens.get("fs.oss.accessKeySecret"); - String stsToken = tokens.get("fs.oss.securityToken"); - String endpoint = tokens.get("fs.oss.endpoint"); - - ReadBuilder readBuilder = table.newReadBuilder(); - List paimonSplits = readBuilder.newScan().plan().splits(); - for (Split split : paimonSplits) { - System.out.println("test debug get split: " + split); - if (split instanceof DataSplit) { - DataSplit dataSplit = (DataSplit) split; - Optional> rawFiles = dataSplit.convertToRawFiles(); - if (rawFiles.isPresent()) { - for (RawFile rawFile : rawFiles.get()) { - System.out.println("test debug get raw file: " + rawFile.path()); - readByAwsSdkV1(rawFile.path(), tmpAk, tmpSk, stsToken, endpoint, "oss-cn-beijing"); - readByAwsSdkV2(rawFile.path(), tmpAk, tmpSk, stsToken, endpoint, "oss-cn-beijing"); - } - } else { - System.out.println("test debug no raw files in this data split"); - } - } - } - } else { - System.out.println( - "test debug fileIO is not RESTTokenFileIO, it is: " + fileIO.getClass().getName()); - } - } - } - } - - /** - * https://paimon.apache.org/docs/1.1/concepts/rest/dlf/ - * CREATE CATALOG `paimon-rest-catalog` - * WITH ( - * 'type' = 'paimon', - * 'uri' = '', - * 'metastore' = 'rest', - * 'warehouse' = 'my_instance_name', - * 'token.provider' = 'dlf', - * 'dlf.access-key-id'='', - * 'dlf.access-key-secret'='', - * ); - * - * @return - */ - private org.apache.paimon.catalog.Catalog initPaimonDlfRestCatalog() { - HiveConf hiveConf = new HiveConf(); - Options catalogOptions = new Options(); - catalogOptions.set("metastore", "rest"); - catalogOptions.set("warehouse", "new_dfl_paimon_catalog"); - catalogOptions.set("uri", "http://cn-beijing-vpc.dlf.aliyuncs.com"); - catalogOptions.set("token.provider", "dlf"); - catalogOptions.set("dlf.access-key-id", aliyunAk); - catalogOptions.set("dlf.access-key-secret", aliyunSk); - CatalogContext catalogContext = CatalogContext.create(catalogOptions, hiveConf); - return CatalogFactory.createCatalog(catalogContext); - } - - private void readByAwsSdkV1(String filePath, String accessKeyId, String secretAccessKey, - String sessionToken, String endpoint, String region) throws UserException { - BasicSessionCredentials sessionCredentials = new BasicSessionCredentials( - accessKeyId, - secretAccessKey, - sessionToken - ); - ClientConfiguration clientConfig = new ClientConfiguration(); - clientConfig.setSignerOverride("AWSS3V4SignerType"); - - AmazonS3 s3Client = AmazonS3ClientBuilder.standard() - .withCredentials(new AWSStaticCredentialsProvider(sessionCredentials)) - .withEndpointConfiguration(new EndpointConfiguration(endpoint, region)) - .withClientConfiguration(clientConfig) - .withPathStyleAccessEnabled(false) - .build(); - - S3URI s3URI = S3URI.create(filePath); - System.out.println("test debug s3uri: " + s3URI); - try { - String content = downloadAndReadFileWithSdkV1(s3Client, s3URI.getBucket(), s3URI.getKey()); - System.out.println("Content: " + content); - } catch (Exception e) { - e.printStackTrace(); - Assertions.fail(Util.getRootCauseMessage(e)); - } - } - - private String downloadAndReadFileWithSdkV1(AmazonS3 s3Client, String bucketName, String objectKey) - throws IOException { - S3Object s3Object = s3Client.getObject(new GetObjectRequest(bucketName, objectKey)); - try (InputStream inputStream = s3Object.getObjectContent(); - InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) { - StringBuilder content = new StringBuilder(); - char[] buffer = new char[1024]; - int bytesRead; - while ((bytesRead = reader.read(buffer)) != -1) { - content.append(buffer, 0, bytesRead); - } - return content.toString(); - } - } - - private void readByAwsSdkV2(String path, String tmpAk, String tmpSk, String stsToken, String endpoint, - String region) { - S3Client s3Client = S3Client.builder() - .credentialsProvider(StaticCredentialsProvider.create(AwsSessionCredentials.create(tmpAk, tmpSk, - stsToken))) - .region(Region.of(region)) - .endpointOverride(URI.create("https://" + endpoint)) - .serviceConfiguration(S3Configuration.builder() - .chunkedEncodingEnabled(false) - .pathStyleAccessEnabled(false) - .build()) - .build(); - try { - S3URI s3URI = S3URI.create(path); - System.out.println("test debug s3uri: " + s3URI); - downloadAndReadFileWithSdkV2(s3Client, s3URI.getBucket(), s3URI.getKey()); - } catch (Exception e) { - Assertions.fail(Util.getRootCauseMessage(e)); - } finally { - s3Client.close(); - } - } - - private void downloadAndReadFileWithSdkV2(S3Client s3Client, String bucketName, String objectKey) - throws IOException { - software.amazon.awssdk.services.s3.model.GetObjectRequest request - = software.amazon.awssdk.services.s3.model.GetObjectRequest.builder() - .bucket(bucketName) - .key(objectKey) - .build(); - - try (ResponseInputStream inputStream = s3Client.getObject(request); - BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { - String line; - while ((line = reader.readLine()) != null) { - System.out.println(line); - } - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonFileSystemMetaStorePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonFileSystemMetaStorePropertiesTest.java deleted file mode 100644 index fa52316357dd57..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonFileSystemMetaStorePropertiesTest.java +++ /dev/null @@ -1,63 +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.datasource.property.metastore; - -import org.apache.doris.common.security.authentication.HadoopExecutionAuthenticator; -import org.apache.doris.datasource.property.storage.HdfsProperties; -import org.apache.doris.datasource.property.storage.StorageProperties; - -import org.apache.paimon.catalog.FileSystemCatalogFactory; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.HashMap; -import java.util.Map; - -public class PaimonFileSystemMetaStorePropertiesTest { - - @Test - public void testKerberosCatalog() throws Exception { - Map props = new HashMap<>(); - props.put(HdfsProperties.FS_HDFS_SUPPORT, "true"); - props.put("fs.defaultFS", "hdfs://mycluster_test"); - props.put("hadoop.security.authentication", "kerberos"); - props.put("hadoop.kerberos.principal", "myprincipal"); - props.put("hadoop.kerberos.keytab", "mykeytab"); - props.put("type", "paimon"); - props.put("paimon.catalog.type", "filesystem"); - props.put("warehouse", "hdfs://mycluster_test/paimon"); - PaimonFileSystemMetaStoreProperties paimonProps = (PaimonFileSystemMetaStoreProperties) MetastoreProperties.create(props); - //We expect a Kerberos-related exception, but because the messages vary by environment, we’re only doing a simple check. - Assertions.assertThrows(RuntimeException.class, () -> paimonProps.initializeCatalog("paimon", StorageProperties.createAll(props)) - ); - } - - @Test - public void testNonKerberosCatalog() throws Exception { - Map props = new HashMap<>(); - props.put("fs.defaultFS", "file:///tmp"); - props.put("type", "paimon"); - props.put("paimon.catalog.type", "filesystem"); - props.put("warehouse", "file:///tmp"); - PaimonFileSystemMetaStoreProperties paimonProps = (PaimonFileSystemMetaStoreProperties) MetastoreProperties.create(props); - Assertions.assertEquals(FileSystemCatalogFactory.IDENTIFIER, paimonProps.getMetastoreType()); - Assertions.assertEquals("filesystem", paimonProps.getPaimonCatalogType()); - Assertions.assertDoesNotThrow(() -> paimonProps.initializeCatalog("paimon", StorageProperties.createAll(props))); - Assertions.assertEquals(HadoopExecutionAuthenticator.class, paimonProps.getExecutionAuthenticator().getClass()); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonHMSMetaStorePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonHMSMetaStorePropertiesTest.java deleted file mode 100644 index ef382c2c517f3b..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonHMSMetaStorePropertiesTest.java +++ /dev/null @@ -1,65 +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.datasource.property.metastore; - -import org.apache.doris.datasource.property.storage.HdfsProperties; -import org.apache.doris.datasource.property.storage.StorageProperties; - -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; - -public class PaimonHMSMetaStorePropertiesTest { - @Test - public void testKerberosCatalog() throws Exception { - Map props = new HashMap<>(); - props.put(HdfsProperties.FS_HDFS_SUPPORT, "true"); - props.put("fs.defaultFS", "hdfs://mycluster_test"); - props.put("hadoop.security.authentication", "kerberos"); - props.put("hadoop.kerberos.principal", "myprincipal"); - props.put("hadoop.kerberos.keytab", "mykeytab"); - props.put("type", "paimon"); - props.put("hive.metastore.uris", "thrift://localhost:12345"); - props.put("paimon.catalog.type", "hms"); - props.put("warehouse", "hdfs://mycluster/paimon"); - PaimonHMSMetaStoreProperties paimonProps = (PaimonHMSMetaStoreProperties) MetastoreProperties.create(props); - List storagePropertiesList = Collections.singletonList(StorageProperties.createPrimary(props)); - //We expect a Kerberos-related exception, but because the messages vary by environment, we’re only doing a simple check. - Assertions.assertThrows(RuntimeException.class, - () -> paimonProps.initializeCatalog("paimon", storagePropertiesList)); - } - - @Test - public void testNonKerberosCatalog() throws Exception { - Map props = new HashMap<>(); - props.put(HdfsProperties.FS_HDFS_SUPPORT, "true"); - props.put("fs.defaultFS", "file:///tmp"); - props.put("type", "paimon"); - props.put("paimon.catalog.type", "hms"); - props.put("hive.metastore.uris", "thrift://localhost:9083"); - props.put("warehouse", "file:///tmp"); - PaimonHMSMetaStoreProperties paimonProps = (PaimonHMSMetaStoreProperties) MetastoreProperties.create(props); - Assertions.assertEquals("hms", paimonProps.getPaimonCatalogType()); - //should mock connection to hms - } - -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonJdbcMetaStorePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonJdbcMetaStorePropertiesTest.java deleted file mode 100644 index cd430d8a631f13..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonJdbcMetaStorePropertiesTest.java +++ /dev/null @@ -1,192 +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.datasource.property.metastore; - -import org.apache.doris.catalog.JdbcResource; -import org.apache.doris.datasource.paimon.PaimonExternalCatalog; - -import org.apache.paimon.options.CatalogOptions; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -public class PaimonJdbcMetaStorePropertiesTest { - - @Test - public void testBasicJdbcProperties() throws Exception { - Map props = new HashMap<>(); - props.put("type", "paimon"); - props.put("paimon.catalog.type", "jdbc"); - props.put("uri", "jdbc:mysql://localhost:3306/paimon"); - props.put("warehouse", "s3://warehouse/path"); - props.put("paimon.jdbc.user", "paimon"); - props.put("paimon.jdbc.password", "secret"); - - PaimonJdbcMetaStoreProperties jdbcProps = (PaimonJdbcMetaStoreProperties) MetastoreProperties.create(props); - jdbcProps.initNormalizeAndCheckProps(); - jdbcProps.buildCatalogOptions(); - - Assertions.assertEquals(PaimonExternalCatalog.PAIMON_JDBC, jdbcProps.getPaimonCatalogType()); - Assertions.assertEquals("jdbc", jdbcProps.getCatalogOptions().get(CatalogOptions.METASTORE.key())); - Assertions.assertEquals("jdbc:mysql://localhost:3306/paimon", - jdbcProps.getCatalogOptions().get(CatalogOptions.URI.key())); - Assertions.assertEquals("paimon", jdbcProps.getCatalogOptions().get("jdbc.user")); - Assertions.assertEquals("secret", jdbcProps.getCatalogOptions().get("jdbc.password")); - } - - @Test - public void testJdbcPrefixPassthrough() throws Exception { - Map props = new HashMap<>(); - props.put("type", "paimon"); - props.put("paimon.catalog.type", "jdbc"); - props.put("uri", "jdbc:mysql://localhost:3306/paimon"); - props.put("warehouse", "s3://warehouse/path"); - props.put("paimon.jdbc.useSSL", "true"); - props.put("paimon.jdbc.verifyServerCertificate", "true"); - - PaimonJdbcMetaStoreProperties jdbcProps = (PaimonJdbcMetaStoreProperties) MetastoreProperties.create(props); - jdbcProps.initNormalizeAndCheckProps(); - jdbcProps.buildCatalogOptions(); - - Assertions.assertEquals("true", jdbcProps.getCatalogOptions().get("jdbc.useSSL")); - Assertions.assertEquals("true", jdbcProps.getCatalogOptions().get("jdbc.verifyServerCertificate")); - } - - @Test - public void testRawJdbcPrefixPassthrough() throws Exception { - Map props = new HashMap<>(); - props.put("type", "paimon"); - props.put("paimon.catalog.type", "jdbc"); - props.put("uri", "jdbc:mysql://localhost:3306/paimon"); - props.put("warehouse", "s3://warehouse/path"); - props.put("jdbc.user", "raw_user"); - props.put("jdbc.password", "raw_password"); - props.put("jdbc.useSSL", "true"); - props.put("jdbc.verifyServerCertificate", "true"); - - PaimonJdbcMetaStoreProperties jdbcProps = (PaimonJdbcMetaStoreProperties) MetastoreProperties.create(props); - jdbcProps.initNormalizeAndCheckProps(); - jdbcProps.buildCatalogOptions(); - - Assertions.assertEquals("raw_user", jdbcProps.getCatalogOptions().get("jdbc.user")); - Assertions.assertEquals("raw_password", jdbcProps.getCatalogOptions().get("jdbc.password")); - Assertions.assertEquals("true", jdbcProps.getCatalogOptions().get("jdbc.useSSL")); - Assertions.assertEquals("true", jdbcProps.getCatalogOptions().get("jdbc.verifyServerCertificate")); - } - - @Test - public void testFactoryCreateJdbcType() throws Exception { - Map props = new HashMap<>(); - props.put("type", "paimon"); - props.put("paimon.catalog.type", "jdbc"); - props.put("uri", "jdbc:mysql://localhost:3306/paimon"); - props.put("warehouse", "s3://warehouse/path"); - - MetastoreProperties properties = MetastoreProperties.create(props); - Assertions.assertEquals(PaimonJdbcMetaStoreProperties.class, properties.getClass()); - } - - @Test - public void testMissingWarehouse() throws Exception { - Map props = new HashMap<>(); - props.put("type", "paimon"); - props.put("paimon.catalog.type", "jdbc"); - props.put("uri", "jdbc:mysql://localhost:3306/paimon"); - - Assertions.assertThrows(IllegalArgumentException.class, () -> MetastoreProperties.create(props)); - } - - @Test - public void testMissingUri() throws Exception { - Map props = new HashMap<>(); - props.put("type", "paimon"); - props.put("paimon.catalog.type", "jdbc"); - props.put("warehouse", "s3://warehouse/path"); - - Assertions.assertThrows(IllegalArgumentException.class, () -> MetastoreProperties.create(props)); - } - - @Test - public void testDriverClassRequiredWhenDriverUrlIsSet() throws Exception { - Map props = new HashMap<>(); - props.put("type", "paimon"); - props.put("paimon.catalog.type", "jdbc"); - props.put("uri", "jdbc:mysql://localhost:3306/paimon"); - props.put("warehouse", "s3://warehouse/path"); - props.put("paimon.jdbc.driver_url", "https://example.com/mysql-connector-java.jar"); - - PaimonJdbcMetaStoreProperties jdbcProps = (PaimonJdbcMetaStoreProperties) MetastoreProperties.create(props); - jdbcProps.initNormalizeAndCheckProps(); - Assertions.assertThrows(IllegalArgumentException.class, - () -> jdbcProps.initializeCatalog("paimon_catalog", Collections.emptyList())); - } - - @Test - public void testRawDriverClassRequiredWhenDriverUrlIsSet() throws Exception { - Map props = new HashMap<>(); - props.put("type", "paimon"); - props.put("paimon.catalog.type", "jdbc"); - props.put("uri", "jdbc:mysql://localhost:3306/paimon"); - props.put("warehouse", "s3://warehouse/path"); - props.put("jdbc.driver_url", "https://example.com/mysql-connector-java.jar"); - - PaimonJdbcMetaStoreProperties jdbcProps = (PaimonJdbcMetaStoreProperties) MetastoreProperties.create(props); - jdbcProps.initNormalizeAndCheckProps(); - Assertions.assertThrows(IllegalArgumentException.class, - () -> jdbcProps.initializeCatalog("paimon_catalog", Collections.emptyList())); - } - - @Test - public void testGetBackendPaimonOptions() throws Exception { - String driverUrl = "file:///tmp/postgresql-42.5.0.jar"; - Map props = new HashMap<>(); - props.put("type", "paimon"); - props.put("paimon.catalog.type", "jdbc"); - props.put("uri", "jdbc:postgresql://127.0.0.1:5442/postgres"); - props.put("warehouse", "s3://warehouse/path"); - props.put("paimon.jdbc.driver_url", driverUrl); - props.put("paimon.jdbc.driver_class", "org.postgresql.Driver"); - - PaimonJdbcMetaStoreProperties jdbcProps = (PaimonJdbcMetaStoreProperties) MetastoreProperties.create(props); - Map backendOptions = jdbcProps.getBackendPaimonOptions(); - - Assertions.assertEquals( - JdbcResource.getFullDriverUrl(driverUrl), - backendOptions.get("jdbc.driver_url")); - Assertions.assertEquals("org.postgresql.Driver", backendOptions.get("jdbc.driver_class")); - Assertions.assertEquals(2, backendOptions.size()); - } - - @Test - public void testGetBackendPaimonOptionsRequiresDriverClass() throws Exception { - Map props = new HashMap<>(); - props.put("type", "paimon"); - props.put("paimon.catalog.type", "jdbc"); - props.put("uri", "jdbc:postgresql://127.0.0.1:5442/postgres"); - props.put("warehouse", "s3://warehouse/path"); - props.put("paimon.jdbc.driver_url", "file:///tmp/postgresql-42.5.0.jar"); - - PaimonJdbcMetaStoreProperties jdbcProps = (PaimonJdbcMetaStoreProperties) MetastoreProperties.create(props); - IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, - jdbcProps::getBackendPaimonOptions); - Assertions.assertTrue(exception.getMessage().contains("driver_class")); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonRestMetaStorePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonRestMetaStorePropertiesTest.java deleted file mode 100644 index cbfa6a01c80012..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/PaimonRestMetaStorePropertiesTest.java +++ /dev/null @@ -1,394 +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.datasource.property.metastore; - -import org.apache.doris.datasource.paimon.PaimonExternalCatalog; - -import org.apache.paimon.options.Options; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.HashMap; -import java.util.Map; - -public class PaimonRestMetaStorePropertiesTest { - - @Test - public void testBasicRestProperties() { - Map props = new HashMap<>(); - props.put("paimon.rest.uri", "http://localhost:8080"); - props.put("warehouse", "catalog_name"); - props.put("paimon.rest.token.provider", "none"); - - PaimonRestMetaStoreProperties restProps = new PaimonRestMetaStoreProperties(props); - restProps.initNormalizeAndCheckProps(); - - Assertions.assertEquals(PaimonExternalCatalog.PAIMON_REST, restProps.getPaimonCatalogType()); - Assertions.assertEquals("rest", restProps.getMetastoreType()); - } - - @Test - public void testUriAliases() { - // Test different URI property names - Map props1 = new HashMap<>(); - props1.put("uri", "http://localhost:8080"); - props1.put("paimon.rest.token.provider", "none"); - props1.put("warehouse", "catalog_name"); - PaimonRestMetaStoreProperties restProps1 = new PaimonRestMetaStoreProperties(props1); - restProps1.initNormalizeAndCheckProps(); - - Map props2 = new HashMap<>(); - props2.put("paimon.rest.uri", "http://localhost:8080"); - props2.put("paimon.rest.token.provider", "none"); - props2.put("warehouse", "catalog_name"); - PaimonRestMetaStoreProperties restProps2 = new PaimonRestMetaStoreProperties(props2); - restProps2.initNormalizeAndCheckProps(); - - // Both should work and set the same URI in catalog options - restProps1.buildCatalogOptions(); - restProps2.buildCatalogOptions(); - - Options options1 = restProps1.getCatalogOptions(); - Options options2 = restProps2.getCatalogOptions(); - - Assertions.assertEquals("http://localhost:8080", options1.get("uri")); - Assertions.assertEquals("http://localhost:8080", options2.get("uri")); - } - - @Test - public void testPaimonRestPropertiesPassthrough() { - Map props = new HashMap<>(); - props.put("paimon.rest.uri", "http://localhost:8080"); - props.put("paimon.rest.custom.property", "custom-value"); - props.put("paimon.rest.timeout", "30000"); - props.put("paimon.rest.retry.count", "3"); - props.put("paimon.rest.token.provider", "none"); - props.put("warehouse", "catalog_name"); - - PaimonRestMetaStoreProperties restProps = new PaimonRestMetaStoreProperties(props); - restProps.initNormalizeAndCheckProps(); - - restProps.buildCatalogOptions(); - Options catalogOptions = restProps.getCatalogOptions(); - - // Basic URI should be set - Assertions.assertEquals("http://localhost:8080", catalogOptions.get("uri")); - - // Custom paimon.rest.* properties should be passed through without prefix - Assertions.assertEquals("custom-value", catalogOptions.get("custom.property")); - Assertions.assertEquals("30000", catalogOptions.get("timeout")); - Assertions.assertEquals("3", catalogOptions.get("retry.count")); - } - - @Test - public void testTokenProviderProperty() { - Map props = new HashMap<>(); - props.put("paimon.rest.uri", "http://localhost:8080"); - props.put("paimon.rest.token.provider", "dlf"); - props.put("warehouse", "catalog_name"); - props.put("paimon.rest.dlf.access-key-id", "ak"); - props.put("paimon.rest.dlf.access-key-secret", "sk"); - - PaimonRestMetaStoreProperties restProps = new PaimonRestMetaStoreProperties(props); - restProps.initNormalizeAndCheckProps(); - - Assertions.assertEquals("dlf", restProps.getTokenProvider()); - } - - @Test - public void testDlfTokenProviderValidConfiguration() { - Map props = new HashMap<>(); - props.put("paimon.rest.uri", "http://localhost:8080"); - props.put("paimon.rest.token.provider", "dlf"); - props.put("paimon.rest.dlf.access-key-id", "ak"); - props.put("paimon.rest.dlf.access-key-secret", "sk"); - props.put("warehouse", "catalog_name"); - - PaimonRestMetaStoreProperties restProps = new PaimonRestMetaStoreProperties(props); - restProps.initNormalizeAndCheckProps(); // Should not throw - - Assertions.assertEquals("dlf", restProps.getTokenProvider()); - } - - @Test - public void testDlfTokenProviderMissingAccessKeyId() { - Map props = new HashMap<>(); - props.put("paimon.rest.uri", "http://localhost:8080"); - props.put("paimon.rest.token.provider", "dlf"); - props.put("paimon.rest.dlf.access-key-secret", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - // Missing access-key-id - - PaimonRestMetaStoreProperties restProps = new PaimonRestMetaStoreProperties(props); - Assertions.assertThrows(IllegalArgumentException.class, restProps::initNormalizeAndCheckProps); - } - - @Test - public void testDlfTokenProviderMissingSecretKey() { - Map props = new HashMap<>(); - props.put("paimon.rest.uri", "http://localhost:8080"); - props.put("paimon.rest.token.provider", "dlf"); - props.put("paimon.rest.dlf.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - // Missing secret-access-key - - PaimonRestMetaStoreProperties restProps = new PaimonRestMetaStoreProperties(props); - Assertions.assertThrows(IllegalArgumentException.class, restProps::initNormalizeAndCheckProps); - } - - @Test - public void testDlfTokenProviderMissingBothCredentials() { - Map props = new HashMap<>(); - props.put("paimon.rest.uri", "http://localhost:8080"); - props.put("paimon.rest.token.provider", "dlf"); - props.put("warehouse", "catalog_name"); - // Missing both credentials - - PaimonRestMetaStoreProperties restProps = new PaimonRestMetaStoreProperties(props); - IllegalArgumentException exception = Assertions.assertThrows( - IllegalArgumentException.class, restProps::initNormalizeAndCheckProps); - - // The error message should mention the required properties - String errorMessage = exception.getMessage(); - Assertions.assertTrue(errorMessage.contains("access-key-id") - && errorMessage.contains("access-key-secret")); - } - - @Test - public void testNonDlfTokenProviderDoesNotRequireCredentials() { - // Test that non-dlf token providers don't require DLF credentials - Map props = new HashMap<>(); - props.put("paimon.rest.uri", "http://localhost:8080"); - props.put("paimon.rest.token.provider", "custom-provider"); - props.put("warehouse", "catalog_name"); - - PaimonRestMetaStoreProperties restProps = new PaimonRestMetaStoreProperties(props); - restProps.initNormalizeAndCheckProps(); // Should not throw - - Assertions.assertEquals("custom-provider", restProps.getTokenProvider()); - } - - @Test - public void testNonDlfTokenProviderWithDlfCredentialsStillWorks() { - // Test that non-dlf token provider doesn't require DLF credentials - Map props = new HashMap<>(); - props.put("paimon.rest.uri", "http://localhost:8080"); - props.put("paimon.rest.token.provider", "other"); - props.put("paimon.rest.dlf.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - props.put("paimon.rest.dlf.access-key-secret", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - props.put("warehouse", "catalog_name"); - - PaimonRestMetaStoreProperties restProps = new PaimonRestMetaStoreProperties(props); - restProps.initNormalizeAndCheckProps(); // Should not throw - - Assertions.assertEquals("other", restProps.getTokenProvider()); - } - - @Test - public void testWarehouseProperty() { - Map props = new HashMap<>(); - props.put("paimon.rest.uri", "http://localhost:8080"); - props.put("warehouse", "s3://my-warehouse/path"); - props.put("paimon.rest.token.provider", "none"); - - PaimonRestMetaStoreProperties restProps = new PaimonRestMetaStoreProperties(props); - restProps.initNormalizeAndCheckProps(); - - // Warehouse property should be accessible through parent class - Assertions.assertNotNull(restProps); - } - - @Test - public void testCaseInsensitiveDlfTokenProvider() { - // Test that "DLF" is also recognized (case insensitive in validation) - Map props = new HashMap<>(); - props.put("paimon.rest.uri", "http://localhost:8080"); - props.put("paimon.rest.token.provider", "DLF"); - props.put("paimon.rest.dlf.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - props.put("paimon.rest.dlf.access-key-secret", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - props.put("warehouse", "catalog_name"); - - PaimonRestMetaStoreProperties restProps = new PaimonRestMetaStoreProperties(props); - restProps.initNormalizeAndCheckProps(); - - Assertions.assertEquals("DLF", restProps.getTokenProvider()); - } - - @Test - public void testMixedCaseTokenProvider() { - // Test mixed case token provider - Map props = new HashMap<>(); - props.put("paimon.rest.uri", "http://localhost:8080"); - props.put("paimon.rest.token.provider", "DlF"); - props.put("paimon.rest.dlf.access-key-id", "ak"); - props.put("paimon.rest.dlf.access-key-secret", "sk"); - props.put("warehouse", "catalog_name"); - - PaimonRestMetaStoreProperties restProps = new PaimonRestMetaStoreProperties(props); - restProps.initNormalizeAndCheckProps(); - - Assertions.assertEquals("DlF", restProps.getTokenProvider()); - } - - @Test - public void testTokenProviderValidationLogic() { - // Test: Token provider is required - should throw when missing - Map props1 = new HashMap<>(); - props1.put("paimon.rest.uri", "http://localhost:8080"); - - PaimonRestMetaStoreProperties restProps1 = new PaimonRestMetaStoreProperties(props1); - IllegalArgumentException exception1 = Assertions.assertThrows( - IllegalArgumentException.class, restProps1::initNormalizeAndCheckProps); - Assertions.assertTrue(exception1.getMessage().contains("paimon.rest.token.provider")); - - // Test: Token provider is required - should throw when empty - Map props2 = new HashMap<>(); - props2.put("paimon.rest.uri", "http://localhost:8080"); - props2.put("paimon.rest.token.provider", ""); - - PaimonRestMetaStoreProperties restProps2 = new PaimonRestMetaStoreProperties(props2); - IllegalArgumentException exception2 = Assertions.assertThrows( - IllegalArgumentException.class, restProps2::initNormalizeAndCheckProps); - Assertions.assertTrue(exception2.getMessage().contains("paimon.rest.token.provider")); - - // Test: Valid non-dlf token provider should work - Map props3 = new HashMap<>(); - props3.put("paimon.rest.uri", "http://localhost:8080"); - props3.put("paimon.rest.token.provider", "oauth2"); - props3.put("warehouse", "catalog_name"); - - PaimonRestMetaStoreProperties restProps3 = new PaimonRestMetaStoreProperties(props3); - restProps3.initNormalizeAndCheckProps(); // Should not throw - Assertions.assertEquals("oauth2", restProps3.getTokenProvider()); - } - - @Test - public void testDlfTokenProviderPositiveValidation() { - // Test: DLF token provider with all required credentials should work - Map props = new HashMap<>(); - props.put("paimon.rest.uri", "http://localhost:8080"); - props.put("paimon.rest.token.provider", "dlf"); - props.put("paimon.rest.dlf.access-key-id", "ak"); - props.put("paimon.rest.dlf.access-key-secret", "sk"); - props.put("warehouse", "catalog_name"); - - PaimonRestMetaStoreProperties restProps = new PaimonRestMetaStoreProperties(props); - restProps.initNormalizeAndCheckProps(); // Should not throw - - Assertions.assertEquals("dlf", restProps.getTokenProvider()); - } - - @Test - public void testDlfTokenProviderNegativeValidation() { - // Test: DLF token provider missing access-key-id should throw - Map props1 = new HashMap<>(); - props1.put("paimon.rest.uri", "http://localhost:8080"); - props1.put("paimon.rest.token.provider", "dlf"); - props1.put("warehouse", "catalog_name"); - props1.put("paimon.rest.dlf.access-key-secret", "sk"); - // Missing access-key-id - - PaimonRestMetaStoreProperties restProps1 = new PaimonRestMetaStoreProperties(props1); - IllegalArgumentException exception1 = Assertions.assertThrows( - IllegalArgumentException.class, restProps1::initNormalizeAndCheckProps); - String errorMessage1 = exception1.getMessage(); - Assertions.assertTrue(errorMessage1.contains("DLF token provider requires")); - Assertions.assertTrue(errorMessage1.contains("access-key-id")); - - // Test: DLF token provider missing secret-access-key should throw - Map props2 = new HashMap<>(); - props2.put("paimon.rest.uri", "http://localhost:8080"); - props2.put("paimon.rest.token.provider", "dlf"); - props2.put("warehouse", "catalog_name"); - props2.put("paimon.rest.dlf.access-key-id", "AKIAIOSFODNN7EXAMPLE"); - // Missing secret-access-key - - PaimonRestMetaStoreProperties restProps2 = new PaimonRestMetaStoreProperties(props2); - IllegalArgumentException exception2 = Assertions.assertThrows( - IllegalArgumentException.class, restProps2::initNormalizeAndCheckProps); - String errorMessage2 = exception2.getMessage(); - Assertions.assertTrue(errorMessage2.contains("DLF token provider requires")); - Assertions.assertTrue(errorMessage2.contains("access-key-secret")); - - // Test: DLF token provider missing both credentials should throw - Map props3 = new HashMap<>(); - props3.put("paimon.rest.uri", "http://localhost:8080"); - props3.put("paimon.rest.token.provider", "dlf"); - props3.put("warehouse", "catalog_name"); - // Missing both credentials - - PaimonRestMetaStoreProperties restProps3 = new PaimonRestMetaStoreProperties(props3); - IllegalArgumentException exception3 = Assertions.assertThrows( - IllegalArgumentException.class, restProps3::initNormalizeAndCheckProps); - String errorMessage3 = exception3.getMessage(); - Assertions.assertTrue(errorMessage3.contains("DLF token provider requires")); - } - - @Test - public void testPaimonRestPropertiesWithMultipleCustomProperties() { - Map props = new HashMap<>(); - props.put("paimon.rest.uri", "http://localhost:8080"); - props.put("paimon.rest.custom.auth.token", "token123"); - props.put("paimon.rest.custom.header.x-api-key", "api-key-456"); - props.put("paimon.rest.custom.ssl.verify", "false"); - props.put("non.paimon.property", "should-not-be-included"); - props.put("paimon.rest.token.provider", "none"); - props.put("warehouse", "catalog_name"); - - PaimonRestMetaStoreProperties restProps = new PaimonRestMetaStoreProperties(props); - restProps.initNormalizeAndCheckProps(); - - restProps.buildCatalogOptions(); - Options catalogOptions = restProps.getCatalogOptions(); - - // paimon.rest.* properties should be passed through without prefix - Assertions.assertEquals("token123", catalogOptions.get("custom.auth.token")); - Assertions.assertEquals("api-key-456", catalogOptions.get("custom.header.x-api-key")); - Assertions.assertEquals("false", catalogOptions.get("custom.ssl.verify")); - - // Non-paimon.rest properties should not be included - Assertions.assertNull(catalogOptions.get("non.paimon.property")); - Assertions.assertNull(catalogOptions.get("should-not-be-included")); - } - - @Test - public void testMissingTokenProviderThrowsException() { - Map props = new HashMap<>(); - props.put("paimon.rest.uri", "http://localhost:8080"); - // Missing token provider - - PaimonRestMetaStoreProperties restProps = new PaimonRestMetaStoreProperties(props); - IllegalArgumentException exception = Assertions.assertThrows( - IllegalArgumentException.class, restProps::initNormalizeAndCheckProps); - - String errorMessage = exception.getMessage(); - Assertions.assertTrue(errorMessage.contains("paimon.rest.token.provider")); - } - - @Test - public void testEmptyTokenProviderThrowsException() { - Map props = new HashMap<>(); - props.put("paimon.rest.uri", "http://localhost:8080"); - props.put("paimon.rest.token.provider", ""); - - PaimonRestMetaStoreProperties restProps = new PaimonRestMetaStoreProperties(props); - IllegalArgumentException exception = Assertions.assertThrows( - IllegalArgumentException.class, restProps::initNormalizeAndCheckProps); - - String errorMessage = exception.getMessage(); - Assertions.assertTrue(errorMessage.contains("paimon.rest.token.provider")); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/storage/BrokerPropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/storage/BrokerPropertiesTest.java index d9fb972e98c377..aaa1a7fb6c06b2 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/storage/BrokerPropertiesTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/storage/BrokerPropertiesTest.java @@ -36,6 +36,16 @@ void testOfMethod_initializesBrokerNameAndParams() { Assertions.assertEquals("abc", props.getBackendConfigProperties().get("fs.s3a.access.key")); } + @Test + void testBindBrokerNameKeyIsTheSharedBrokerNameLiteral() { + // BIND_BROKER_NAME_KEY is now the SINGLE source of truth for the "broker.name" catalog property, read by + // BOTH guessIsMe (case-insensitive match, below) and the generic ExternalCatalog.bindBrokerName() + // (case-sensitive Map.get). It replaced the former HMSExternalCatalog.BIND_BROKER_NAME copy so the generic + // base no longer depends on the HMS subclass. Pin its value: a drift here silently breaks broker + // resolution for every catalog (both the guessIsMe probe and the bindBrokerName lookup). + Assertions.assertEquals("broker.name", BrokerProperties.BIND_BROKER_NAME_KEY); + } + @Test void testGuessIsMe_returnsTrueWhenBrokerNamePresent() { Map props1 = ImmutableMap.of("broker.name", "test"); diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/storage/HdfsPropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/storage/HdfsPropertiesTest.java index f0a4e73231f54b..bacaa99d7669c4 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/storage/HdfsPropertiesTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/storage/HdfsPropertiesTest.java @@ -19,9 +19,9 @@ import org.apache.doris.common.Config; import org.apache.doris.common.UserException; -import org.apache.doris.common.security.authentication.HadoopKerberosAuthenticator; -import org.apache.doris.common.security.authentication.HadoopSimpleAuthenticator; import org.apache.doris.foundation.property.StoragePropertiesException; +import org.apache.doris.kerberos.HadoopKerberosAuthenticator; +import org.apache.doris.kerberos.HadoopSimpleAuthenticator; import com.google.common.collect.Maps; import org.apache.hadoop.conf.Configuration; diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/FileCacheAdmissionManagerTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/FileCacheAdmissionManagerTest.java similarity index 99% rename from fe/fe-core/src/test/java/org/apache/doris/datasource/FileCacheAdmissionManagerTest.java rename to fe/fe-core/src/test/java/org/apache/doris/datasource/scan/FileCacheAdmissionManagerTest.java index 383c526ccaaf06..b75cd880a2200f 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/FileCacheAdmissionManagerTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/FileCacheAdmissionManagerTest.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.scan; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/FileQueryScanNodeTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/FileQueryScanNodeTest.java similarity index 99% rename from fe/fe-core/src/test/java/org/apache/doris/datasource/FileQueryScanNodeTest.java rename to fe/fe-core/src/test/java/org/apache/doris/datasource/scan/FileQueryScanNodeTest.java index 752ee5195a4398..427bebd013d09f 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/FileQueryScanNodeTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/FileQueryScanNodeTest.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.scan; import org.apache.doris.analysis.SlotDescriptor; import org.apache.doris.analysis.SlotId; diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeBatchModeTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeBatchModeTest.java new file mode 100644 index 00000000000000..7f445db40336eb --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeBatchModeTest.java @@ -0,0 +1,216 @@ +// 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.analysis.SlotDescriptor; +import org.apache.doris.analysis.TupleDescriptor; +import org.apache.doris.catalog.PartitionItem; +import org.apache.doris.common.jmockit.Deencapsulation; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; +import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan.SelectedPartitions; +import org.apache.doris.qe.SessionVariable; +import org.apache.doris.thrift.TPushAggOp; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * FIX-BATCH-MODE-SPLIT (P4-T06e / NG-7) — guards {@link PluginDrivenScanNode#shouldUseBatchMode}, + * the pure four-input gate deciding whether a plugin-driven partitioned scan uses batched/streaming + * split generation instead of synchronous enumeration. + * + *

    Why this matters: batch mode mirrors legacy {@code MaxComputeScanNode.isBatchMode()}. + * Getting any gate wrong has real consequences: enabling batch when it should not (e.g. dropping the + * "not the NOT_PRUNED sentinel" or "must have files" guard) spins up async read sessions for the wrong tables; + * disabling it when it should fire (e.g. an off-by-one on the partition-count threshold) silently + * regresses large-partition scans back to slow synchronous planning + large single sessions (the + * exact OOM/latency risk this fix removes). The connector {@code fileNum > 0} check is folded into + * the {@code supportsBatchScan} input.

    + * + *

    Coverage scope: the original tests pin the PURE static {@code shouldUseBatchMode} gate. The + * FIX-M3 tests additionally drive {@code computeBatchMode}'s streaming-flavor dispatch + {@code + * numApproximateSplits} on a {@code CALLS_REAL_METHODS} mock (connector/desc fields injected). Still NOT + * exercised here: the partition-flavor {@code computeBatchMode} branch's {@code scanProvider != null} + * null-guard, and BOTH async {@code startSplit} pumps (partition + streaming) — these need a live harness + * this module lacks (DV-019 gaps), covered by flip-gated e2e.

    + */ +public class PluginDrivenScanNodeBatchModeTest { + + private static final int THRESHOLD = 1024; // num_partitions_in_batch_mode default; pinned (it is fuzzy at runtime) + + private static SelectedPartitions pruned(int count) { + Map items = new LinkedHashMap<>(); + for (int i = 0; i < count; i++) { + items.put("pt=" + i, Mockito.mock(PartitionItem.class)); + } + return new SelectedPartitions(count, items, true); + } + + @Test + public void testNotPrunedNeverBatches() { + // NOT_PRUNED = non-partitioned / pruning not applicable -> never batch. NOTE: NOT_PRUNED carries + // an EMPTY map, so this case is non-discriminating for the == NOT_PRUNED guard alone (0 >= THRESHOLD + // is false regardless); the guard's discriminating counterpart is testNoPredicatePartitionedTableBatches + // (a full, non-sentinel isPruned=false map, which MUST batch). This test documents the NOT_PRUNED + // singleton path (non-partitioned / unpruned). + Assertions.assertFalse( + PluginDrivenScanNode.shouldUseBatchMode(SelectedPartitions.NOT_PRUNED, true, true, THRESHOLD)); + } + + @Test + public void testNullSelectionNeverBatches() { + Assertions.assertFalse(PluginDrivenScanNode.shouldUseBatchMode(null, true, true, THRESHOLD)); + } + + @Test + public void testNoPredicatePartitionedTableBatches() { + // A partitioned table with NO partition predicate: ExternalTable.initSelectedPartitions returns a + // FULL, non-NOT_PRUNED map with isPruned=false (PruneFileScanPartition only runs under a + // LogicalFilter). Scanning every partition is EXACTLY the case that most needs async/streaming split + // generation, and legacy MaxComputeScanNode.isBatchMode (its != NOT_PRUNED gate) batched it. The gate + // is == NOT_PRUNED, NOT !isPruned: this object is not the sentinel, so it MUST batch once the + // partition count reaches the threshold. The old !isPruned gate wrongly returned false here (forcing + // slow synchronous planning on the largest scans) — the regression this now pins against. NOTE this + // inverts the former testUnprocessedPruningNeverBatches assertion: a prior review's "!isPruned is + // equivalent to != NOT_PRUNED and slightly stronger" note was mistaken (they diverge for exactly this + // case); that note and the test pinning it are superseded (decisions-log D-035 / deviations-log DV-019). + Map items = new LinkedHashMap<>(); + for (int i = 0; i < THRESHOLD; i++) { + items.put("pt=" + i, Mockito.mock(PartitionItem.class)); + } + SelectedPartitions noPredicateFullScan = new SelectedPartitions(THRESHOLD, items, false); + Assertions.assertTrue( + PluginDrivenScanNode.shouldUseBatchMode(noPredicateFullScan, true, true, THRESHOLD)); + } + + @Test + public void testNoSlotsNeverBatches() { + // No required slots (e.g. count-only) -> not batch. Pins the hasSlots guard. + Assertions.assertFalse(PluginDrivenScanNode.shouldUseBatchMode(pruned(THRESHOLD), false, true, THRESHOLD)); + } + + @Test + public void testConnectorWithoutBatchSupportNeverBatches() { + // supportsBatchScan=false -> not batch. Pins the supportsBatchScan guard. (A null scan provider + // also resolves to supportsBatchScan=false, but that mapping lives in computeBatchMode's + // null-guard and is NOT exercised by this static-helper test — see DV-019.) + Assertions.assertFalse(PluginDrivenScanNode.shouldUseBatchMode(pruned(THRESHOLD), true, false, THRESHOLD)); + } + + @Test + public void testZeroThresholdDisablesBatch() { + // num_partitions_in_batch_mode == 0 disables batch mode entirely (legacy contract). Pins the + // `numPartitionsInBatchMode > 0` guard: with `>= 0` a zero threshold would wrongly batch. + Assertions.assertFalse(PluginDrivenScanNode.shouldUseBatchMode(pruned(THRESHOLD), true, true, 0)); + } + + @Test + public void testBelowThresholdDoesNotBatch() { + // Fewer pruned partitions than the threshold -> synchronous path (small scans need no batching). + Assertions.assertFalse( + PluginDrivenScanNode.shouldUseBatchMode(pruned(THRESHOLD - 1), true, true, THRESHOLD)); + } + + @Test + public void testAtThresholdBatches() { + // size == threshold is INCLUSIVE (legacy uses >=). Pins the boundary: a `>` mutant fails here. + Assertions.assertTrue( + PluginDrivenScanNode.shouldUseBatchMode(pruned(THRESHOLD), true, true, THRESHOLD)); + } + + @Test + public void testAboveThresholdBatches() { + // The main success case: a large pruned partition set on a file-bearing, sloted, pruned table. + Assertions.assertTrue( + PluginDrivenScanNode.shouldUseBatchMode(pruned(THRESHOLD + 5), true, true, THRESHOLD)); + } + + // --- FIX-M3 streaming (file-count) batch flavor: computeBatchMode dispatch + numApproximateSplits --- + // Driven on a CALLS_REAL_METHODS mock (no constructor — see class note), with the connector/desc fields + // injected and getPushDownAggNoGroupingOp stubbed, so the real computeBatchMode wiring runs. + + /** A node whose connector exposes the given provider, with a single-slot desc + non-count agg. */ + private static PluginDrivenScanNode streamingNode(ConnectorScanPlanProvider provider) { + PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class, Mockito.CALLS_REAL_METHODS); + Connector connector = Mockito.mock(Connector.class); + // The node resolves the provider PER TABLE via getScanPlanProvider(currentHandle); a real connector + // delegates that overload to the no-arg getter, so the mock answers the arg form (currentHandle is + // null in this partial node, hence the null-tolerant any() matcher). + Mockito.when(connector.getScanPlanProvider(Mockito.any())).thenReturn(provider); + Deencapsulation.setField(node, "connector", connector); + // hasSlots = true (the streaming gate requires output slots). getSlots() returns ArrayList (concrete). + TupleDescriptor desc = Mockito.mock(TupleDescriptor.class); + ArrayList slots = new ArrayList<>(); + slots.add(Mockito.mock(SlotDescriptor.class)); + Mockito.when(desc.getSlots()).thenReturn(slots); + Deencapsulation.setField(node, "desc", desc); + // Non-count agg so countPushdown=false; sessionVariable needed by the partition fallback arg eval. + Mockito.doReturn(TPushAggOp.NONE).when(node).getPushDownAggNoGroupingOp(); + SessionVariable sv = Mockito.mock(SessionVariable.class); + Mockito.when(sv.getNumPartitionsInBatchMode()).thenReturn(THRESHOLD); + Deencapsulation.setField(node, "sessionVariable", sv); + return node; + } + + @Test + public void testComputeBatchModePrefersStreamingWhenEstimateNonNegative() { + // A connector that returns a non-negative streamingSplitEstimate enters the streaming flavor of batch + // mode (before the partition-count flavor). MUTATION: dropping the `estimate >= 0` block -> falls through + // to the partition path (false here) -> red. + ConnectorScanPlanProvider provider = Mockito.mock(ConnectorScanPlanProvider.class); + Mockito.when(provider.streamingSplitEstimate(Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.anyBoolean())).thenReturn(50L); + PluginDrivenScanNode node = streamingNode(provider); + + Assertions.assertTrue(node.isBatchMode()); + Assertions.assertTrue((Boolean) Deencapsulation.getField(node, "streamingBatch")); + Assertions.assertEquals(50L, (long) (Long) Deencapsulation.getField(node, "streamingSplitEstimate")); + // numApproximateSplits then reports the streamed estimate (not the partition count). + Assertions.assertEquals(50, node.numApproximateSplits()); + } + + @Test + public void testComputeBatchModeFallsBackToPartitionPathWhenNoStreaming() { + // A connector that declines streaming (estimate < 0) must NOT set the streaming flag; the node falls + // back to the partition-count gate (false here: no pruned partitions, supportsBatchScan false). MUTATION: + // setting streamingBatch unconditionally -> the flag would be true -> red. + ConnectorScanPlanProvider provider = Mockito.mock(ConnectorScanPlanProvider.class); + Mockito.when(provider.streamingSplitEstimate(Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.anyBoolean())).thenReturn(-1L); + PluginDrivenScanNode node = streamingNode(provider); + + Assertions.assertFalse(node.isBatchMode()); + Assertions.assertFalse((Boolean) Deencapsulation.getField(node, "streamingBatch")); + } + + @Test + public void testNumApproximateSplitsStreamingCapsAtIntMax() { + // A pathologically large matched-file count must clamp to Integer.MAX_VALUE, never overflow to a + // negative split count (FileQueryScanNode rejects negative). MUTATION: dropping the cap -> negative -> red. + PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(node, "streamingBatch", true); + Deencapsulation.setField(node, "streamingSplitEstimate", (long) Integer.MAX_VALUE + 100L); + Assertions.assertEquals(Integer.MAX_VALUE, node.numApproximateSplits()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeClassifyColumnTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeClassifyColumnTest.java new file mode 100644 index 00000000000000..1868a491bdba72 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeClassifyColumnTest.java @@ -0,0 +1,144 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.datasource.scan; + +import org.apache.doris.analysis.SlotDescriptor; +import org.apache.doris.catalog.Column; +import org.apache.doris.connector.api.scan.ConnectorColumnCategory; +import org.apache.doris.thrift.TColumnCategory; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.List; + +/** + * Guards {@link PluginDrivenScanNode#classifyColumn}, the P6.6-C2 (WS-SYNTH-READ) generic, connector-agnostic + * port of the legacy per-connector {@code classifyColumn} overrides. + * + *

    WHY this matters: once iceberg flips onto the generic {@link PluginDrivenScanNode}, the base + * {@code FileQueryScanNode.classifyColumn} tags every column REGULAR. A REGULAR column becomes a FILE slot + * ({@code isFileSlot = category == REGULAR || category == GENERATED}), so the BE would try to read the + * synthesized row-id columns ({@code __DORIS_GLOBAL_ROWID_COL__*} lazy-materialization id, + * {@code __DORIS_ICEBERG_ROWID_COL__} hidden id) from a data file where they do not exist, and would demote + * the v3 row-lineage columns from backfill-capable GENERATED to plain REGULAR. This override keeps the + * engine-wide GLOBAL_ROWID classification in the generic node (a Doris mechanism, mirroring + * {@code HiveScanNode}/{@code TVFScanNode}) and delegates connector-owned special columns to the connector + * SPI, so no connector knowledge leaks into fe-core.

    + * + *

    Driven on a Mockito {@code CALLS_REAL_METHODS} mock (no constructor — building a full + * {@link FileQueryScanNode} needs a harness this module lacks) with the connector seam + * {@code classifyColumnByConnector} stubbed (package-private exactly for this, mirroring + * {@code sysTableSupportsTimeTravel}), so the real category-mapping logic runs against controlled state.

    + */ +public class PluginDrivenScanNodeClassifyColumnTest { + + /** {@code isFileSlot} as derived in {@code FileQueryScanNode#initSchemaParams} (the read-from-file gate). */ + private static boolean isFileSlot(TColumnCategory category) { + return category == TColumnCategory.REGULAR || category == TColumnCategory.GENERATED; + } + + private static PluginDrivenScanNode node() { + return Mockito.mock(PluginDrivenScanNode.class, Mockito.CALLS_REAL_METHODS); + } + + private static SlotDescriptor slotNamed(String name) { + SlotDescriptor slot = Mockito.mock(SlotDescriptor.class); + Column column = Mockito.mock(Column.class); + Mockito.doReturn(name).when(column).getName(); + Mockito.doReturn(column).when(slot).getColumn(); + return slot; + } + + @Test + public void globalRowIdIsSynthesizedInGenericNodeWithoutConsultingConnector() { + PluginDrivenScanNode node = node(); + // A suffixed lazy-materialization row-id (LazyMaterializeTopN appends the table/function name). + SlotDescriptor slot = slotNamed(Column.GLOBAL_ROWID_COL + "my_tbl"); + + TColumnCategory category = node.classifyColumn(slot, Collections.emptyList()); + + // WHY: GLOBAL_ROWID is a generic Doris lazy-mat mechanism (also classified by Hive/TVF), so the + // generic node owns it and must NOT delegate to the connector. MUTATION: startsWith -> equals drops + // the suffixed name to REGULAR (a file slot) -> red. + Assertions.assertEquals(TColumnCategory.SYNTHESIZED, category); + Assertions.assertFalse(isFileSlot(category), "GLOBAL_ROWID must not be read from the data file"); + Mockito.verify(node, Mockito.never()).classifyColumnByConnector(Mockito.anyString()); + } + + @Test + public void connectorSynthesizedColumnMapsToSynthesized() { + PluginDrivenScanNode node = node(); + SlotDescriptor slot = slotNamed("__DORIS_ICEBERG_ROWID_COL__"); + Mockito.doReturn(ConnectorColumnCategory.SYNTHESIZED).when(node) + .classifyColumnByConnector("__DORIS_ICEBERG_ROWID_COL__"); + + TColumnCategory category = node.classifyColumn(slot, Collections.emptyList()); + + // WHY: a connector special column reported SYNTHESIZED must NOT become a file slot (the BE + // materializes it). MUTATION: dropping the connector delegation -> REGULAR file slot -> the BE reads + // a non-existent file column -> red. + Assertions.assertEquals(TColumnCategory.SYNTHESIZED, category); + Assertions.assertFalse(isFileSlot(category), "connector SYNTHESIZED column must not be a file slot"); + } + + @Test + public void connectorGeneratedColumnMapsToGeneratedAndStaysFileSlot() { + PluginDrivenScanNode node = node(); + SlotDescriptor slot = slotNamed("_row_id"); + Mockito.doReturn(ConnectorColumnCategory.GENERATED).when(node).classifyColumnByConnector("_row_id"); + + TColumnCategory category = node.classifyColumn(slot, Collections.emptyList()); + + // WHY: v3 row-lineage is GENERATED = read from file when present, otherwise backfilled, so it MUST + // stay a file slot. MUTATION: mapping GENERATED -> SYNTHESIZED would drop it from the file-read set + // and lose the backfill path -> red. + Assertions.assertEquals(TColumnCategory.GENERATED, category); + Assertions.assertTrue(isFileSlot(category), "GENERATED row-lineage must remain a file slot for backfill"); + } + + @Test + public void defaultColumnFallsThroughToRegular() { + PluginDrivenScanNode node = node(); + SlotDescriptor slot = slotNamed("id"); + Mockito.doReturn(ConnectorColumnCategory.DEFAULT).when(node).classifyColumnByConnector("id"); + + TColumnCategory category = node.classifyColumn(slot, Collections.emptyList()); + + // WHY: a plain data column (connector says DEFAULT, not a partition key) is REGULAR. MUTATION: + // breaking the `else super()` fall-through -> wrong category -> red. + Assertions.assertEquals(TColumnCategory.REGULAR, category); + Assertions.assertTrue(isFileSlot(category)); + } + + @Test + public void defaultPartitionColumnFallsThroughToPartitionKey() { + PluginDrivenScanNode node = node(); + SlotDescriptor slot = slotNamed("part_col"); + Mockito.doReturn(ConnectorColumnCategory.DEFAULT).when(node).classifyColumnByConnector("part_col"); + List partitionKeys = Collections.singletonList("part_col"); + + TColumnCategory category = node.classifyColumn(slot, partitionKeys); + + // WHY: partition keys must keep flowing through super() so partition handling is unchanged. MUTATION: + // swallowing DEFAULT instead of calling super() would lose PARTITION_KEY -> red. + Assertions.assertEquals(TColumnCategory.PARTITION_KEY, category); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeDeleteFilesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeDeleteFilesTest.java new file mode 100644 index 00000000000000..ac7f6c71654592 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeDeleteFilesTest.java @@ -0,0 +1,118 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +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.scan.ConnectorScanPlanProvider; +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 org.mockito.Mockito; + +import java.util.Arrays; +import java.util.List; + +/** + * FIX-E (explain gap) — guards {@link PluginDrivenScanNode#getDeleteFiles(TFileRangeDesc)}, the + * override the SPI scan path was missing. The VERBOSE per-backend EXPLAIN block (inherited from + * {@code FileScanNode}) calls {@code getDeleteFiles(rangeDesc)} to count deletion files; without this + * override it returned empty, so {@code deleteFileNum} was always 0 and the {@code deleteFileNum} + * substring never appeared ({@code test_paimon_deletion_vector_oss} asserts it is present). + * + *

    Why this matters (Rule 9): the override must DELEGATE to the connector's + * {@link ConnectorScanPlanProvider#getDeleteFiles(TTableFormatFileDesc)} (paimon reads its deletion + * vector off the per-range thrift), and must null-guard a range with no table-format params (legacy + * {@code PaimonScanNode.getDeleteFiles} parity) so the VERBOSE loop never NPEs. Driven on a + * {@code CALLS_REAL_METHODS} mock with the {@code connector} field injected (no full + * {@code FileQueryScanNode} constructor needed; the method is package/protected exactly to enable + * this, mirroring {@code PluginDrivenScanNodeSysHandleTest}'s Deencapsulation approach).

    + */ +public class PluginDrivenScanNodeDeleteFilesTest { + + private static PluginDrivenScanNode nodeWithProvider(ConnectorScanPlanProvider provider) { + PluginDrivenScanNode node = + Mockito.mock(PluginDrivenScanNode.class, Mockito.CALLS_REAL_METHODS); + Connector connector = Mockito.mock(Connector.class); + // The node resolves the provider PER TABLE via getScanPlanProvider(currentHandle); a real connector + // delegates that overload to the no-arg getter, so the mock answers the arg form (currentHandle is + // null in this partial node, hence the null-tolerant any() matcher). + Mockito.when(connector.getScanPlanProvider(Mockito.any())).thenReturn(provider); + Deencapsulation.setField(node, "connector", connector); + return node; + } + + @Test + public void delegatesToProviderWithTableFormatParams() { + // WHY: the node must hand the range's table-format params to the connector, which reads the + // paimon deletion-file path back off them. MUTATION: an override that returns empty (no + // delegation) makes deleteFileNum always 0 -> red. The distinct returned list proves the + // connector's result flows through, and verify() proves the exact params were passed. + TTableFormatFileDesc tableFormat = new TTableFormatFileDesc(); + List expected = Arrays.asList("oss://bkt/db/tbl/index/deletion-1.bin"); + + ConnectorScanPlanProvider provider = Mockito.mock(ConnectorScanPlanProvider.class); + Mockito.when(provider.getDeleteFiles(tableFormat)).thenReturn(expected); + + PluginDrivenScanNode node = nodeWithProvider(provider); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + rangeDesc.setTableFormatParams(tableFormat); + + List result = node.getDeleteFiles(rangeDesc); + + Assertions.assertEquals(expected, result); + Mockito.verify(provider).getDeleteFiles(tableFormat); + } + + @Test + public void rangeWithoutTableFormatParamsReturnsEmptyAndSkipsProvider() { + // WHY: a range with no table-format params (e.g. a non-paimon split path) must yield empty + // WITHOUT consulting the provider — legacy PaimonScanNode.getDeleteFiles null-guards exactly + // this so the VERBOSE loop never NPEs. MUTATION: dropping the isSetTableFormatParams guard + // would call the provider with null -> here it would fail verifyNoInteractions. + ConnectorScanPlanProvider provider = Mockito.mock(ConnectorScanPlanProvider.class); + PluginDrivenScanNode node = nodeWithProvider(provider); + + List result = node.getDeleteFiles(new TFileRangeDesc()); + + Assertions.assertTrue(result.isEmpty()); + Mockito.verifyNoInteractions(provider); + } + + @Test + public void nullRangeReturnsEmpty() { + // Defensive: a null range must not NPE (returns empty), mirroring the legacy guard. + ConnectorScanPlanProvider provider = Mockito.mock(ConnectorScanPlanProvider.class); + PluginDrivenScanNode node = nodeWithProvider(provider); + + Assertions.assertTrue(node.getDeleteFiles(null).isEmpty()); + Mockito.verifyNoInteractions(provider); + } + + @Test + public void nullProviderReturnsEmpty() { + // A connector without a scan plan provider (no scan capability) must yield empty, never NPE. + PluginDrivenScanNode node = nodeWithProvider(null); + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + rangeDesc.setTableFormatParams(new TTableFormatFileDesc()); + + Assertions.assertTrue(node.getDeleteFiles(rangeDesc).isEmpty()); + } +} 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 new file mode 100644 index 00000000000000..2e295076b5104b --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeExplainStatsTest.java @@ -0,0 +1,146 @@ +// 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.ConnectorScanRange; +import org.apache.doris.connector.api.scan.ConnectorScanRangeType; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * FIX-E (explain gap) — guards {@link PluginDrivenScanNode#countNativeReadRanges} and + * {@link PluginDrivenScanNode#resolvePushDownRowCount}, the per-scan accounting that feeds the two + * EXPLAIN lines the legacy {@code PaimonScanNode} emitted but the SPI scan path dropped: + * {@code paimonNativeReadSplits=/} and {@code pushdown agg=COUNT (n)}. + * + *

    Why this matters (Rule 9 — tests encode WHY):

    + *
      + *
    • native/total accounting: under {@code force_jni_scanner=true} every paimon range goes + * JNI ({@code isNativeReadRange()==false}), so the native numerator MUST be 0 over N total + * ({@code paimonNativeReadSplits=0/1} is the exact assertion in + * {@code test_paimon_catalog_varbinary}/{@code _timestamp_tz}). A mutation that counts all ranges + * as native, or that ignores {@code isNativeReadRange()}, is killed.
    • + *
    • the {@code -1} sentinel must survive: a deletion-vector table emits NO precomputed count + * range, so the pushdown count must stay {@code -1} and render as {@code (-1)} + * ({@code test_paimon_deletion_vector} asserts {@code pushdown agg=COUNT (-1)}). Append/merge + * tables DO emit a count range carrying the merged sum (12 / 8), which must be picked up. A + * mutation that defaults the sentinel to 0, or that reads a count even when pushdown is inactive, + * is killed.
    • + *
    + */ +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(); + } + + @Override + public boolean isNativeReadRange() { + return nativeRead; + } + + @Override + public long getPushDownRowCount() { + return pushDownRowCount; + } + }; + } + + private static List ranges(ConnectorScanRange... rs) { + List list = new ArrayList<>(); + Collections.addAll(list, rs); + return list; + } + + // ==================== native/total accounting ==================== + + @Test + public void allJniRangesCountZeroNative() { + // force_jni_scanner=true: every range is JNI -> native count 0 (the 0 in 0/1). Total is the + // caller's ranges.size(); here the single JNI split gives 0/1, exactly the failing assertion. + List rs = ranges(range(false, -1)); + Assertions.assertEquals(0, PluginDrivenScanNode.countNativeReadRanges(rs)); + Assertions.assertEquals(1, rs.size()); + } + + @Test + public void mixedNativeAndJniCountsOnlyNative() { + // Native router on: a mix of native ORC/Parquet sub-splits and JNI splits -> numerator counts + // ONLY the native ones. Kills a "count all ranges" or "count JNI" mutation. + List rs = ranges( + range(true, -1), range(false, -1), range(true, -1), range(false, -1)); + Assertions.assertEquals(2, PluginDrivenScanNode.countNativeReadRanges(rs)); + Assertions.assertEquals(4, rs.size()); + } + + @Test + public void emptyRangesCountZeroNative() { + Assertions.assertEquals(0, + PluginDrivenScanNode.countNativeReadRanges(Collections.emptyList())); + } + + // ==================== pushdown COUNT(*) sentinel ==================== + + @Test + public void countPushdownPicksUpPrecomputedSum() { + // Append/merge tables: the collapsed count range carries the merged sum (e.g. 12). With count + // pushdown active it is surfaced so EXPLAIN prints "pushdown agg=COUNT (12)". + List rs = ranges(range(false, 12)); + Assertions.assertEquals(12, PluginDrivenScanNode.resolvePushDownRowCount(true, rs)); + } + + @Test + public void countPushdownWithNoCountRangeKeepsMinusOneSentinel() { + // THE sentinel guard: a deletion-vector table emits no precomputed-count range (every range + // returns -1). Even with count pushdown active the result must stay -1 -> "pushdown agg=COUNT + // (-1)" (test_paimon_deletion_vector). A mutation defaulting to 0 makes this red. + List rs = ranges(range(true, -1), range(false, -1)); + Assertions.assertEquals(-1, PluginDrivenScanNode.resolvePushDownRowCount(true, rs)); + } + + @Test + public void noCountPushdownNeverReadsACount() { + // A non-COUNT scan must NOT pick up a stray precomputed count even if a range happens to carry + // one. Pins that the count is gated on countPushdown, not just on the range value. + List rs = ranges(range(false, 99)); + Assertions.assertEquals(-1, PluginDrivenScanNode.resolvePushDownRowCount(false, rs)); + } + + @Test + public void countPushdownReturnsFirstPrecomputedCount() { + // Only ONE collapsed count range is emitted, but guard the "first non-negative wins" contract + // so a leading data range (-1) does not mask the trailing count range's value. + List rs = ranges(range(true, -1), range(false, 8)); + Assertions.assertEquals(8, PluginDrivenScanNode.resolvePushDownRowCount(true, rs)); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeLimitStripTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeLimitStripTest.java new file mode 100644 index 00000000000000..914fea749258bc --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeLimitStripTest.java @@ -0,0 +1,54 @@ +// 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.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * FIX-CAST-PUSHDOWN (F9) impl-review F9-LIMITOPT-1 — guards + * {@link PluginDrivenScanNode#effectiveSourceLimit}, which suppresses source-side LIMIT pushdown when + * non-pushable (CAST) conjuncts were stripped from the filter. + * + *

    Why this matters: the F9 fix makes MaxCompute strip CAST conjuncts before pushdown, so + * the connector sees a filter that no longer reflects them. If the real LIMIT were still pushed, the + * source (e.g. MaxCompute's row-offset limit-split optimization, which fires on an empty/partition-only + * filter) could return the first N rows without applying the stripped predicate; BE then re-evaluates + * the CAST predicate only on those rows and silently UNDER-returns (BE can filter the returned rows + * down, never recover rows the source never returned). Passing {@code -1} (no source limit) when a + * conjunct was stripped mirrors legacy, which disabled limit-split whenever a non-partition-equality + * (incl. CAST) predicate was present. BE still applies the LIMIT.

    + */ +public class PluginDrivenScanNodeLimitStripTest { + + @Test + public void strippedConjunctsSuppressSourceLimit() { + // The load-bearing case: a CAST conjunct was stripped, so the source must NOT apply the LIMIT + // (else under-return). Must return -1 regardless of the real limit. + Assertions.assertEquals(-1L, PluginDrivenScanNode.effectiveSourceLimit(10L, true)); + Assertions.assertEquals(-1L, PluginDrivenScanNode.effectiveSourceLimit(1L, true)); + } + + @Test + public void noStripPassesLimitThrough() { + // No conjunct stripped -> the real limit flows to the source (legitimate limit pushdown, + // e.g. limit-opt on a genuinely empty/partition-equality filter). + Assertions.assertEquals(10L, PluginDrivenScanNode.effectiveSourceLimit(10L, false)); + Assertions.assertEquals(-1L, PluginDrivenScanNode.effectiveSourceLimit(-1L, false)); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeMvccPinTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeMvccPinTest.java new file mode 100644 index 00000000000000..6e01038f9f2311 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeMvccPinTest.java @@ -0,0 +1,109 @@ +// 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.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; +import org.apache.doris.datasource.mvcc.MvccSnapshot; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccSnapshot; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.Optional; + +/** + * Guards {@link PluginDrivenScanNode#applyMvccSnapshotPin}, the pure pin-vs-skip decision threaded + * onto the table handle before every scan-side consumption (planScan + the serialized-table / + * getScanNodeProperties path). + * + *

    Why this matters: an MVCC-capable connector (paimon today) must read the WHOLE query at + * one pinned point-in-time snapshot — a time-travel ({@code FOR TIME AS OF}) or MTMV-consistent read. + * If the pin is not threaded onto the handle before a consumption point, that path silently reads + * LATEST instead, producing rows from a different snapshot than the rest of the query. The helper + * must (1) apply the pin when a plugin snapshot is present, (2) NOT pin when there is no snapshot + * (read latest, e.g. before the connector is MVCC-cutover or a non-MVCC table), and (3) NOT pin — and + * not ClassCastException — on a foreign (non-plugin) {@link MvccSnapshot}. Each test kills the + * corresponding mutation.

    + */ +public class PluginDrivenScanNodeMvccPinTest { + + @Test + public void pluginSnapshotPresentPinsHandle() { + // MUTATION: a "return input handle unchanged" / "never call applySnapshot" mutation is killed + // here — a present plugin snapshot MUST be unwrapped and threaded onto the handle, else a + // time-travel/MTMV read silently reads LATEST. Distinct input vs pinned mock handles ensure + // the returned value is the connector's pinned handle, not the untouched input. + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + ConnectorTableHandle inputHandle = Mockito.mock(ConnectorTableHandle.class); + ConnectorTableHandle pinnedHandle = Mockito.mock(ConnectorTableHandle.class); + ConnectorMvccSnapshot connectorSnapshot = Mockito.mock(ConnectorMvccSnapshot.class); + PluginDrivenMvccSnapshot snapshot = new PluginDrivenMvccSnapshot( + connectorSnapshot, Collections.emptyMap(), Collections.emptyMap()); + + Mockito.when(metadata.applySnapshot(session, inputHandle, connectorSnapshot)) + .thenReturn(pinnedHandle); + + ConnectorTableHandle result = PluginDrivenScanNode.applyMvccSnapshotPin( + metadata, session, inputHandle, Optional.of(snapshot)); + + // applySnapshot must be invoked with the UNWRAPPED ConnectorMvccSnapshot (not the wrapper). + Mockito.verify(metadata).applySnapshot(session, inputHandle, connectorSnapshot); + // and the pinned handle the connector returned is what flows downstream to planScan. + Assertions.assertSame(pinnedHandle, result); + } + + @Test + public void emptySnapshotReadsLatestUnchanged() { + // MUTATION: a "pin unconditionally" mutation (dropping the isPresent guard) is killed — with no + // snapshot in context (no MVCC pin, e.g. pre-cutover or a non-MVCC table) the handle must be + // returned UNCHANGED so the scan reads latest, and applySnapshot must NOT be called. + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + ConnectorTableHandle inputHandle = Mockito.mock(ConnectorTableHandle.class); + + ConnectorTableHandle result = PluginDrivenScanNode.applyMvccSnapshotPin( + metadata, session, inputHandle, Optional.empty()); + + Assertions.assertSame(inputHandle, result); + Mockito.verifyNoInteractions(metadata); + } + + @Test + public void foreignSnapshotReadsLatestUnchanged() { + // MUTATION: dropping the instanceof PluginDrivenMvccSnapshot guard is killed — a foreign + // MvccSnapshot (some other table type's snapshot present in the same statement context) must + // NOT be pinned and must NOT ClassCastException; the handle is returned unchanged (read latest) + // and applySnapshot is never called for a snapshot this node cannot unwrap. + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + ConnectorTableHandle inputHandle = Mockito.mock(ConnectorTableHandle.class); + MvccSnapshot foreignSnapshot = Mockito.mock(MvccSnapshot.class); + + ConnectorTableHandle result = PluginDrivenScanNode.applyMvccSnapshotPin( + metadata, session, inputHandle, Optional.of(foreignSnapshot)); + + Assertions.assertSame(inputHandle, result); + Mockito.verifyNoInteractions(metadata); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeMvccSchemaGuardTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeMvccSchemaGuardTest.java new file mode 100644 index 00000000000000..6a87d1db22c80b --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeMvccSchemaGuardTest.java @@ -0,0 +1,203 @@ +// 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.catalog.Column; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.catalog.Type; +import org.apache.doris.common.UserException; +import org.apache.doris.datasource.SchemaCacheValue; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenSysExternalTable; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Unit tests for the L17 fail-loud guard {@link PluginDrivenScanNode#assertBoundColumnsResolveInPinnedSchema}: + * a same-table multi-version reference whose FE tuple was bound at a DIFFERENT schema than the version it + * scans must be rejected loud (BE would field-id/name-mismatch), while a reference whose bound columns all + * resolve in its own version-aware pinned schema (incl. a field-id-stable rename or a subset projection) + * must pass. The helper takes plain {@link Column}s + a {@link SchemaCacheValue} so it is exercised without + * constructing a scan node. + */ +public class PluginDrivenScanNodeMvccSchemaGuardTest { + + private static Column col(String name, int uniqueId) { + Column c = new Column(name, Type.INT); + c.setUniqueId(uniqueId); + return c; + } + + private static SchemaCacheValue schema(Column... cols) { + return new SchemaCacheValue(Arrays.asList(cols)); + } + + /** An ordinary (non-sys) table: the guard applies in full. */ + private static TableIf table() { + TableIf t = Mockito.mock(PluginDrivenMvccExternalTable.class); + Mockito.when(t.getName()).thenReturn("db.t"); + return t; + } + + @Test + public void fieldIdRenumberBetweenBoundAndScannedVersionThrows() throws UserException { + // The tuple was bound at LATEST where column `c` has field-id 7, but THIS reference scans a pinned + // version where `c` has field-id 5. BE matches iceberg columns by field-id, so slot field-id 7 has no + // entry in the v-pinned dict -> crash. The guard must throw. MUTATION: dropping the guard (or matching + // by name only) -> the renumber slips through -> red. + List bound = Collections.singletonList(col("c", 7)); + SchemaCacheValue pinned = schema(col("c", 5)); + UserException e = Assertions.assertThrows(UserException.class, + () -> PluginDrivenScanNode.assertBoundColumnsResolveInPinnedSchema(bound, pinned, table())); + Assertions.assertTrue(e.getMessage().contains("multiple versions"), e.getMessage()); + Assertions.assertTrue(e.getMessage().contains("'c'"), e.getMessage()); + } + + @Test + public void columnAddedAfterScannedVersionThrows() throws UserException { + // The tuple (bound latest) references a column `added` (field-id 9) that does NOT exist in the pinned + // version this reference scans -> the version's files have no such field -> the guard must throw. + List bound = Arrays.asList(col("id", 1), col("added", 9)); + SchemaCacheValue pinned = schema(col("id", 1)); // pinned version predates `added` + UserException e = Assertions.assertThrows(UserException.class, + () -> PluginDrivenScanNode.assertBoundColumnsResolveInPinnedSchema(bound, pinned, table())); + Assertions.assertTrue(e.getMessage().contains("'added'"), e.getMessage()); + } + + @Test + public void nameMissWhenNoFieldIdThrows() throws UserException { + // Paimon carries no top-level field-id (uniqueId == -1) so matching is by NAME: a tuple bound at + // LATEST with column `newname` scanning a version that only has `oldname` (a paimon rename) must throw + // (BE matches by name -> `newname` unreadable from the old files). + List bound = Collections.singletonList(col("newname", -1)); + SchemaCacheValue pinned = schema(col("oldname", -1)); + Assertions.assertThrows(UserException.class, + () -> PluginDrivenScanNode.assertBoundColumnsResolveInPinnedSchema(bound, pinned, table())); + } + + @Test + public void fieldIdStableRenameResolvesByIdNoThrow() throws UserException { + // A rename that KEEPS the field-id is fine: BE reads iceberg field-id 5 regardless of name, so a tuple + // bound with `newname`@5 scanning a version whose column is `oldname`@5 must NOT throw (id resolves). + // Guards against a name-only check over-rejecting the benign rename case. + List bound = Collections.singletonList(col("newname", 5)); + SchemaCacheValue pinned = schema(col("oldname", 5), col("other", 6)); + Assertions.assertDoesNotThrow(() -> + PluginDrivenScanNode.assertBoundColumnsResolveInPinnedSchema(bound, pinned, table())); + } + + @Test + public void subsetProjectionAllResolvedNoThrow() throws UserException { + // The tuple is a projection (subset) of the version's columns; every bound field-id is present -> ok. + List bound = Arrays.asList(col("a", 1), col("c", 3)); + SchemaCacheValue pinned = schema(col("a", 1), col("b", 2), col("c", 3)); + Assertions.assertDoesNotThrow(() -> + PluginDrivenScanNode.assertBoundColumnsResolveInPinnedSchema(bound, pinned, table())); + } + + @Test + public void nameMatchWhenNoFieldIdNoThrow() throws UserException { + // Paimon (uniqueId == -1) matching by name: same names -> resolved -> no throw. + List bound = Arrays.asList(col("a", -1), col("b", -1)); + SchemaCacheValue pinned = schema(col("a", -1), col("b", -1), col("c", -1)); + Assertions.assertDoesNotThrow(() -> + PluginDrivenScanNode.assertBoundColumnsResolveInPinnedSchema(bound, pinned, table())); + } + + @Test + public void nullPinnedSchemaIsNoOp() throws UserException { + // A latest / @incr / hive reference carries a null pinnedSchema -> the guard is a no-op (no + // version-at-snapshot schema to skew against), regardless of the bound columns. (A sys-table + // reference is excluded by TYPE, not by a null schema -- see sysTableIsExcludedNoThrow.) + List bound = Collections.singletonList(col("anything", 42)); + Assertions.assertDoesNotThrow(() -> + PluginDrivenScanNode.assertBoundColumnsResolveInPinnedSchema(bound, null, table())); + } + + @Test + public void rowIdColumnIsExcludedNoThrow() throws UserException { + // Topn lazy materialization (LazyMaterializeTopN) injects a reader-synthesized row-id carrying + // uniqueId = Integer.MAX_VALUE, which is BY CONSTRUCTION absent from every pinned schema. Without the + // carve-out the guard fires on every "pinned version + order by/limit" query -- it took down + // test_iceberg_time_travel and iceberg_branch_complex_queries (CI 996541). + // MUTATION: dropping the GLOBAL_ROWID_COL carve-out -> red. + List bound = Arrays.asList(col("id", 1), + col(Column.GLOBAL_ROWID_COL + "tag_branch_table", Integer.MAX_VALUE)); + SchemaCacheValue pinned = schema(col("id", 1)); + Assertions.assertDoesNotThrow(() -> + PluginDrivenScanNode.assertBoundColumnsResolveInPinnedSchema(bound, pinned, table())); + } + + @Test + public void rowIdBeforeSkewedColumnStillThrows() throws UserException { + // The carve-out must SKIP the row-id and keep checking the rest of the tuple, not abandon the whole + // check. MUTATION: writing the carve-out as `return` instead of `continue` -> a real skew on `added` + // that sits AFTER the row-id slips through silently -> red. + List bound = Arrays.asList( + col(Column.GLOBAL_ROWID_COL + "t", Integer.MAX_VALUE), + col("added", 9)); + SchemaCacheValue pinned = schema(col("id", 1)); + Assertions.assertThrows(UserException.class, + () -> PluginDrivenScanNode.assertBoundColumnsResolveInPinnedSchema(bound, pinned, table())); + } + + @Test + public void sysTableIsExcludedNoThrow() throws UserException { + // A sys-table scan's pin is resolved off the SOURCE table (resolveSysTableSnapshotPin), so pinnedSchema + // carries the SOURCE's columns {id, name} while the tuple carries the SYS table's synthetic columns + // {file_path, pos, ...}. Comparing them is a category error that can NEVER resolve -- it threw a bogus + // "multiple versions" error (surfaced as "Failed to pin MVCC snapshot") on every iceberg sys-table time + // travel: `select count(*) from t$position_deletes for version as of ` (CI 997422, + // test_iceberg_position_deletes_sys_table). + // MUTATION: dropping the `table instanceof PluginDrivenSysExternalTable` exclusion -> red. + // Two things here are LOAD-BEARING against a vacuous pass: + // - non-null pinnedSchema: a null one passes via the null no-op and stays green even with the + // exclusion reverted; + // - uniqueId == -1 on the bound columns: the guard keys on field-id when uniqueId >= 0, so giving the + // sys columns ids that happen to collide with the source's ids (file_path@1 vs id@1) would resolve + // by ID and never throw -- green with the exclusion reverted. Name matching is also what the real + // case does: CI 997422 threw on 'file_path' precisely because it resolved by NAME against the + // source's {id, name}. + List bound = Arrays.asList(col("file_path", -1), col("pos", -1)); + SchemaCacheValue pinned = schema(col("id", -1), col("name", -1)); + TableIf sysTable = Mockito.mock(PluginDrivenSysExternalTable.class); + Mockito.when(sysTable.getName()).thenReturn("db.t$position_deletes"); + + Assertions.assertDoesNotThrow(() -> + PluginDrivenScanNode.assertBoundColumnsResolveInPinnedSchema(bound, pinned, sysTable)); + } + + @Test + public void normalMvccTableWithSameShapeStillThrows() throws UserException { + // Locks the exclusion to the sys-table TYPE, not to the column shape: an ordinary MVCC table whose + // tuple genuinely skews against its pinned schema must still throw. PluginDrivenSysExternalTable and + // PluginDrivenMvccExternalTable are sibling subclasses, so the exclusion cannot swallow a real table. + // MUTATION: widening the exclusion to PluginDrivenExternalTable (their common parent) -> red. + List bound = Arrays.asList(col("file_path", -1), col("pos", -1)); + SchemaCacheValue pinned = schema(col("id", -1), col("name", -1)); + + Assertions.assertThrows(UserException.class, + () -> PluginDrivenScanNode.assertBoundColumnsResolveInPinnedSchema(bound, pinned, table())); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodePartitionCountTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodePartitionCountTest.java new file mode 100644 index 00000000000000..8dea308d07bc47 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodePartitionCountTest.java @@ -0,0 +1,135 @@ +// 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.catalog.PartitionItem; +import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan.SelectedPartitions; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.OptionalLong; + +/** + * FIX-EXPLAIN-PARTITION-COUNT — guards {@link PluginDrivenScanNode#displayPartitionCounts}, which + * derives the EXPLAIN {@code partition=N/M} counts (also fed to SQL-block-rule enforcement via + * {@code getSelectedPartitionNum()}) from the Nereids {@link SelectedPartitions}. + * + *

    Why this matters / the bug this pins: the gate is {@code != NOT_PRUNED}, deliberately NOT + * {@code isPruned}. A partitioned table queried WITHOUT a partition predicate keeps the initial + * all-partitions selection from {@code ExternalTable.initSelectedPartitions} — {@code isPruned=false} + * but a full, non-{@code NOT_PRUNED} map ({@code PruneFileScanPartition} only runs under a + * {@code LogicalFilter}, so a no-WHERE / non-partition-predicate query never flips {@code isPruned}). + * It must still report {@code partition=total/total} (e.g. {@code SELECT * FROM t} over 2 partitions + * → {@code 2/2}). An {@code isPruned} gate regressed this to {@code 0/0} + * ({@code test_max_compute_partition_prune}'s {@code one_partition_3_all} et al.). The contrast with + * the connector pushdown gate ({@code resolveRequiredPartitions}, which correctly stays {@code + * isPruned} — an unpruned scan reads ALL partitions and pushes no restriction) is the load-bearing + * subtlety: the same {@code SelectedPartitions} maps to DIFFERENT answers for "what to display" vs + * "what to push down".

    + */ +public class PluginDrivenScanNodePartitionCountTest { + + private static Map items(int count) { + Map items = new LinkedHashMap<>(); + for (int i = 0; i < count; i++) { + items.put("pt=" + i, Mockito.mock(PartitionItem.class)); + } + return items; + } + + @Test + public void testNotPrunedSentinelShowsNoCounts() { + // NOT_PRUNED = non-partitioned / pruning unsupported -> leave the fields at default (0/0), as + // legacy did (its display gate was `!= NOT_PRUNED`). Returning [0,0] here would be acceptable + // numerically but null keeps "nothing to show" distinct from a genuine 0-partition selection. + Assertions.assertNull(PluginDrivenScanNode.displayPartitionCounts(SelectedPartitions.NOT_PRUNED)); + } + + @Test + public void testNullShowsNoCounts() { + Assertions.assertNull(PluginDrivenScanNode.displayPartitionCounts(null)); + } + + @Test + public void testNoPartitionPredicateReportsAllOverAll() { + // THE regression guard: a partitioned table with NO partition predicate keeps the initial + // all-partitions selection (isPruned=FALSE, full map). It must report total/total (2/2), NOT + // 0/0. A mutation reverting the gate to `isPruned` makes this red — exactly the bug that showed + // `partition=0/0` for `SELECT * FROM one_partition_tb`. + SelectedPartitions allPartitions = new SelectedPartitions(2, items(2), false); + Assertions.assertArrayEquals(new long[] {2, 2}, + PluginDrivenScanNode.displayPartitionCounts(allPartitions)); + } + + @Test + public void testPrunedSubsetReportsSelectedOverTotal() { + // Pruned to 2 of 5 partitions -> selected=2 (map size), total=5 (totalPartitionNum). + SelectedPartitions pruned = new SelectedPartitions(5, items(2), true); + Assertions.assertArrayEquals(new long[] {2, 5}, + PluginDrivenScanNode.displayPartitionCounts(pruned)); + } + + @Test + public void testPrunedToZeroReportsZeroOverTotal() { + // Pruned away every partition (e.g. WHERE part=) -> 0/total, NOT 0/0. Pins that + // total comes from totalPartitionNum (kept even when the surviving map is empty), and that this + // value is produced BEFORE getSplits()'s pruned-to-zero short-circuit so EXPLAIN still shows it. + SelectedPartitions prunedToZero = new SelectedPartitions(2, Collections.emptyMap(), true); + Assertions.assertArrayEquals(new long[] {0, 2}, + PluginDrivenScanNode.displayPartitionCounts(prunedToZero)); + } + + // FIX-L12 — guards resolveSelectedPartitionNum, which prefers the connector's real scanned-partition + // count (distinct native partitions after the connector's SDK manifest/residual/transform pruning) over + // the engine's Nereids declared-column prune count, so partition=N/M and sql_block_rule reflect what is + // actually scanned. WHY it matters: for a predicate-driven connector (iceberg days(ts) hidden + // partitioning, paimon non-partition-column manifest pruning) the Nereids count OVER-reports the scanned + // partitions (it can only see declared partition columns) and would over-block a governed query that + // really touches one partition. + + @Test + public void testConnectorCountOverridesNereidsWhenNotCountPushdown() { + // THE load-bearing RED assertion: a connector that reports 1 real scanned partition wins over the + // Nereids count of 30 (e.g. iceberg WHERE ts= over a days(ts) table). A mutation that drops + // the override and keeps the Nereids number makes this red. + Assertions.assertEquals(1L, + PluginDrivenScanNode.resolveSelectedPartitionNum(30L, false, OptionalLong.of(1L))); + } + + @Test + public void testCountPushdownKeepsNereidsCount() { + // Under COUNT(*) pushdown the connector collapses its splits into one count range, so its + // per-partition info is lost — keep the conservative Nereids count (>= real, never under-blocks) + // even though the connector reports a (collapsed, wrong) 1. + Assertions.assertEquals(30L, + PluginDrivenScanNode.resolveSelectedPartitionNum(30L, true, OptionalLong.of(1L))); + } + + @Test + public void testEmptyConnectorCountKeepsNereidsCount() { + // A connector that does not report a scanned-partition count (hive/MaxCompute, where the Nereids + // count already equals the real one) keeps the engine's Nereids count. + Assertions.assertEquals(5L, + PluginDrivenScanNode.resolveSelectedPartitionNum(5L, false, OptionalLong.empty())); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodePartitionPruningTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodePartitionPruningTest.java new file mode 100644 index 00000000000000..e7ebc4eef1a23a --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodePartitionPruningTest.java @@ -0,0 +1,165 @@ +// 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.catalog.PartitionItem; +import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan.SelectedPartitions; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * FIX-PRUNE-PUSHDOWN (P4-T06e / DG-1) — guards {@link PluginDrivenScanNode#resolveRequiredPartitions}, + * the three-state mapping from the Nereids {@code SelectedPartitions} to the partition list pushed + * down to the connector SPI. + * + *

    Why this matters: before this fix the plugin-driven MaxCompute read path dropped the + * pruned partition set entirely, so the ODPS read session spanned ALL partitions (full-scan + * perf/memory regression). The fix threads the pruned set through, but the null-vs-empty distinction + * is load-bearing and easy to get wrong:

    + *
      + *
    • {@code null} = "not pruned, scan all" — must NOT be confused with the short-circuit case, + * or every row would be silently dropped;
    • + *
    • non-empty list = "scan only these" — must be forwarded, or large tables regress to a full + * scan;
    • + *
    • empty list = "pruned to zero partitions" — must be distinguishable (non-null) so + * {@code getSplits()} can short-circuit with no splits, mirroring legacy + * {@code MaxComputeScanNode.getSplits():724-727}.
    • + *
    + */ +public class PluginDrivenScanNodePartitionPruningTest { + + @Test + public void testNotPrunedScansAllPartitions() { + // NOT_PRUNED -> null (scan all). Returning [] here would be read as "pruned to zero" and + // silently drop all rows. + Assertions.assertNull( + PluginDrivenScanNode.resolveRequiredPartitions(SelectedPartitions.NOT_PRUNED)); + } + + @Test + public void testNullSelectionScansAllPartitions() { + Assertions.assertNull(PluginDrivenScanNode.resolveRequiredPartitions(null)); + } + + @Test + public void testUnprocessedPruningScansAllPartitions() { + // isPruned=false with a populated map is still "pruning not processed" -> scan all. + Map items = new LinkedHashMap<>(); + items.put("pt=1", Mockito.mock(PartitionItem.class)); + SelectedPartitions notProcessed = new SelectedPartitions(3, items, false); + Assertions.assertNull(PluginDrivenScanNode.resolveRequiredPartitions(notProcessed)); + } + + @Test + public void testPrunedSubsetForwardsPartitionNames() { + // Pruned non-empty set must be forwarded; otherwise the connector reads all partitions. + Map items = new LinkedHashMap<>(); + items.put("pt=1", Mockito.mock(PartitionItem.class)); + items.put("pt=2,region=cn", Mockito.mock(PartitionItem.class)); + SelectedPartitions pruned = new SelectedPartitions(5, items, true); + + List result = PluginDrivenScanNode.resolveRequiredPartitions(pruned); + + Assertions.assertNotNull(result); + Assertions.assertEquals(2, result.size()); + Assertions.assertTrue(result.containsAll(Arrays.asList("pt=1", "pt=2,region=cn"))); + } + + @Test + public void testPrunedToZeroReturnsEmptyNonNullForShortCircuit() { + // Pruned to zero partitions -> non-null empty list, distinct from the null "scan all" + // case, so getSplits() can short-circuit and read nothing. + // NOTE (FIX-NONPART-PRUNE-DATALOSS): this isPruned=true+empty state is correct ONLY when it + // comes from a genuinely PARTITIONED table whose predicates pruned away every partition + // (e.g. WHERE pt='nonexistent'). A NON-partitioned table must never reach this state, or the + // short-circuit silently drops all rows; PluginDrivenExternalTable.supportInternalPartitionPruned() + // returns unconditional true precisely so PruneFileScanPartition leaves non-partitioned tables + // NOT_PRUNED (see PluginDrivenExternalTablePartitionTest + // #testNonPartitionedTableReportsNoPartitionsButStillOptsIntoPruning). + // totalPartitionNum=5 (> 0): a GENUINE prune-to-zero over a non-empty universe. + SelectedPartitions emptyPruned = new SelectedPartitions(5, Collections.emptyMap(), true); + + List result = PluginDrivenScanNode.resolveRequiredPartitions(emptyPruned); + + Assertions.assertNotNull(result); + Assertions.assertTrue(result.isEmpty()); + } + + @Test + public void testPrunedToZeroWithoutIgnoreStillShortCircuits() { + // Default (non-predicate-driven connector, ignoreShortCircuit=false): a genuine prune-to-zero still + // maps to the non-null empty list so getSplits() short-circuits — the existing MaxCompute parity. + SelectedPartitions emptyPruned = new SelectedPartitions(5, Collections.emptyMap(), true); + List result = PluginDrivenScanNode.resolveRequiredPartitions(emptyPruned, false); + Assertions.assertNotNull(result); + Assertions.assertTrue(result.isEmpty()); + } + + @Test + public void testPrunedToZeroWithIgnoreShortCircuitScansAll() { + // A predicate-driven connector (paimon: ConnectorScanPlanProvider.ignorePartitionPruneShortCircuit() + // == true) must NOT short-circuit a genuine prune-to-zero. With the master-parity isNull=false a + // genuine-null paimon partition renders as a NON-null sentinel, so `region IS NULL` prunes EVERY + // partition away (empty set over a 5-partition universe); short-circuiting here would drop the + // genuine-null row. Returning null (scan-all) lets getSplits() call planScan, which the paimon SDK + // answers from the pushed `region IS NULL` predicate -> the genuine-null row is returned (master + // PaimonScanNode parity; regression test_paimon_runtime_filter_partition_pruning qt_null_partition_4). + // MUTATION: ignoring the flag -> returns the empty short-circuit list -> red. + SelectedPartitions emptyPruned = new SelectedPartitions(5, Collections.emptyMap(), true); + Assertions.assertNull( + PluginDrivenScanNode.resolveRequiredPartitions(emptyPruned, true), + "a predicate-driven connector must scan-all (null) on a prune-to-zero, not short-circuit"); + } + + @Test + public void testPrunedSubsetWithIgnoreShortCircuitStillForwards() { + // The flag only governs the empty/short-circuit case. A non-empty pruned set is still forwarded + // unchanged (the connector may ignore it, but the non-empty contract is preserved). + Map items = new LinkedHashMap<>(); + items.put("pt=1", Mockito.mock(PartitionItem.class)); + SelectedPartitions pruned = new SelectedPartitions(5, items, true); + List result = PluginDrivenScanNode.resolveRequiredPartitions(pruned, true); + Assertions.assertNotNull(result); + Assertions.assertEquals(1, result.size()); + } + + @Test + public void testPrunedEmptyOverEmptyUniverseScansAll() { + // RD-1 (B5b-4): a pruned-but-empty selection whose partition UNIVERSE was ALSO empty + // (totalPartitionNum == 0) is NOT a genuine prune-to-zero. It is an MVCC time-travel pin + // (FOR VERSION/TIME AS OF, @tag, @branch) whose snapshot deliberately carries an empty + // partition-item map and defers partition pruning to the connector's predicate pushdown. + // It must map to null (scan-all) so getSplits() does NOT short-circuit and planScan runs, + // mirroring legacy PaimonScanNode (which ignores selectedPartitions and re-plans via the SDK). + // MUTATION: returning the empty list (like the totalPartitionNum>0 case above) short-circuits + // to zero splits -> silent data loss for partitioned time-travel + a partition predicate, and + // this assertion goes red. + SelectedPartitions emptyUniverse = new SelectedPartitions(0, Collections.emptyMap(), true); + + Assertions.assertNull(PluginDrivenScanNode.resolveRequiredPartitions(emptyUniverse), + "a pruned-empty selection over an empty partition universe (time-travel pin) must scan all"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeReadTxnReleaseTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeReadTxnReleaseTest.java new file mode 100644 index 00000000000000..a0d09639931215 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeReadTxnReleaseTest.java @@ -0,0 +1,92 @@ +// 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.ConnectorScanPlanProvider; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Guards {@link PluginDrivenScanNode#buildReadTransactionReleaseCallback}, the query-finish callback that + * releases a connector's per-query read transaction. Hive full-ACID / insert-only reads open a metastore read + * transaction + shared read lock during {@code planScan}; the engine registers this callback in + * {@code getSplits} to commit it (releasing the lock) when the query finishes. Without the release the shared + * read lock leaks for the metastore's lifetime. + * + *

    Why this matters: the callback runs on the StmtExecutor thread at query finish, whose TCCL is the + * fe-core app loader. {@code releaseReadTransaction -> txn.commit -> hmsClient.commitTxn} resolves + * metastore/thrift classes by name via the TCCL, so the callback MUST pin the provider's plugin classloader for + * the duration of the release, else it split-brains against the app loader's duplicate copies (ClassCast / + * NoClassDef at commit). Each test kills a mutation: (1) dropping the release call / wrong queryId, (2) dropping + * the TCCL pin, (3) leaking the pin.

    + */ +public class PluginDrivenScanNodeReadTxnReleaseTest { + + @Test + public void callbackReleasesReadTransactionForTheQuery() { + ConnectorScanPlanProvider provider = Mockito.mock(ConnectorScanPlanProvider.class); + + Runnable callback = PluginDrivenScanNode.buildReadTransactionReleaseCallback(provider, "query-42"); + // The transaction is released only when the query finishes: merely building the callback must not + // release it yet (a mutation that releases eagerly would leak nothing but breaks the deferred contract). + Mockito.verifyNoInteractions(provider); + + callback.run(); + // MUTATION: dropping the releaseReadTransaction call, or passing a different queryId than the one the + // txn was registered under (so deregister would miss it), is killed here. + Mockito.verify(provider).releaseReadTransaction("query-42"); + } + + @Test + public void callbackPinsProviderClassLoaderDuringReleaseAndRestoresAfter() throws Exception { + ConnectorScanPlanProvider provider = Mockito.mock(ConnectorScanPlanProvider.class); + ClassLoader providerLoader = provider.getClass().getClassLoader(); + + AtomicReference tcclDuringRelease = new AtomicReference<>(); + Mockito.doAnswer(inv -> { + tcclDuringRelease.set(Thread.currentThread().getContextClassLoader()); + return null; + }).when(provider).releaseReadTransaction(Mockito.anyString()); + + Thread current = Thread.currentThread(); + ClassLoader outer = current.getContextClassLoader(); + // A distinct sentinel TCCL (never equal to the provider's mock loader), so a "no pin" mutation — which + // would leave the sentinel in place during the release — is caught by the assertSame below. + try (URLClassLoader sentinel = new URLClassLoader(new URL[0], null)) { + current.setContextClassLoader(sentinel); + + PluginDrivenScanNode.buildReadTransactionReleaseCallback(provider, "q").run(); + + // MUTATION: dropping the onPluginClassLoader pin is killed — during the release the TCCL must be the + // provider's (plugin) classloader, not the caller's app/sentinel loader. + Assertions.assertSame(providerLoader, tcclDuringRelease.get(), + "the release must run with the TCCL pinned to the provider's plugin classloader"); + // MUTATION: leaking the pin (not restoring in a finally) is killed — the caller's TCCL must be back. + Assertions.assertSame(sentinel, current.getContextClassLoader(), + "the TCCL must be restored to the caller's after the release"); + } finally { + current.setContextClassLoader(outer); + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeRewriteFileScopePinTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeRewriteFileScopePinTest.java new file mode 100644 index 00000000000000..b58f725543b0f1 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeRewriteFileScopePinTest.java @@ -0,0 +1,97 @@ +// 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.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; + +/** + * Guards {@link PluginDrivenScanNode#applyRewriteFileScopePin}, the pure pin-vs-skip decision that scopes a + * distributed {@code rewrite_data_files} group's scan to its own files before every scan-side consumption. + * + *

    Why this matters: each group's INSERT-SELECT must scan ONLY the data files that group bin-packed. + * If the per-group path scope is not threaded onto the handle, the group scans the WHOLE table — so every + * group rewrites far beyond its set, producing duplicate rows and (under OCC) clobbering concurrent writes. + * The helper must (1) thread the raw paths onto the handle when present, (2) NOT scope — and not touch the + * connector — when the path list is null (no scope = full scan) or (3) empty (an empty scope would scan + * nothing). Each test kills the corresponding mutation.

    + */ +public class PluginDrivenScanNodeRewriteFileScopePinTest { + + @Test + public void scopePresentThreadsRawPathsOntoHandle() { + // MUTATION: a "return input handle unchanged" / "never call applyRewriteFileScope" mutation is killed + // here — a present scope MUST be threaded onto the handle (as a Set), else the group scans the full + // table. Distinct input vs scoped mock handles prove the returned value is the connector's scoped one. + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + ConnectorTableHandle inputHandle = Mockito.mock(ConnectorTableHandle.class); + ConnectorTableHandle scopedHandle = Mockito.mock(ConnectorTableHandle.class); + List paths = Arrays.asList("s3://b/db1/t1/a.parquet", "s3://b/db1/t1/b.parquet"); + + Mockito.when(metadata.applyRewriteFileScope(session, inputHandle, new HashSet<>(paths))) + .thenReturn(scopedHandle); + + ConnectorTableHandle result = PluginDrivenScanNode.applyRewriteFileScopePin( + metadata, session, inputHandle, paths); + + // applyRewriteFileScope must be invoked with the RAW paths as a Set (no normalization on this side). + Mockito.verify(metadata).applyRewriteFileScope(session, inputHandle, new HashSet<>(paths)); + Assertions.assertSame(scopedHandle, result); + } + + @Test + public void nullScopeReadsFullTableUnchanged() { + // MUTATION: dropping the null guard (scoping unconditionally) is killed — no scope means a normal + // (non-rewrite) read, which must return the handle UNCHANGED and never touch the connector. + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + ConnectorTableHandle inputHandle = Mockito.mock(ConnectorTableHandle.class); + + ConnectorTableHandle result = PluginDrivenScanNode.applyRewriteFileScopePin( + metadata, session, inputHandle, null); + + Assertions.assertSame(inputHandle, result); + Mockito.verifyNoInteractions(metadata); + } + + @Test + public void emptyScopeReadsFullTableUnchanged() { + // MUTATION: treating an EMPTY scope as "scope to nothing" is killed — an empty path list must be a + // no-op (return the handle unchanged, full scan), NOT threaded down (which would scan zero files). + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + ConnectorTableHandle inputHandle = Mockito.mock(ConnectorTableHandle.class); + + ConnectorTableHandle result = PluginDrivenScanNode.applyRewriteFileScopePin( + metadata, session, inputHandle, Collections.emptyList()); + + Assertions.assertSame(inputHandle, result); + Mockito.verifyNoInteractions(metadata); + } +} 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 new file mode 100644 index 00000000000000..f1546fd0afa5e6 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeScanProfileTest.java @@ -0,0 +1,100 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.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; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * FIX-SCAN-METRICS — guards {@link PluginDrivenScanNode#writeScanProfilesInto}, the connector-agnostic + * transcription of connector-supplied scan diagnostics into the query profile execution summary. WHY it + * matters: the plugin migration dropped the paimon/iceberg SDK scan metrics (manifest cache hit/miss, scan + * durations) from the profile; this generic writer restores them without the engine knowing any connector + * specifics — it only get-or-creates a group and transcribes the connector's labels + metric strings. + */ +public class PluginDrivenScanNodeScanProfileTest { + + private static ConnectorScanProfile profile(String group, String label, String... kv) { + Map metrics = new LinkedHashMap<>(); + for (int i = 0; i + 1 < kv.length; i += 2) { + metrics.put(kv[i], kv[i + 1]); + } + return new ConnectorScanProfile(group, label, metrics); + } + + @Test + public void nullOrEmptyIsNoOp() { + RuntimeProfile summary = new RuntimeProfile("Execution Summary"); + PluginDrivenScanNode.writeScanProfilesInto(summary, null); + PluginDrivenScanNode.writeScanProfilesInto(summary, Collections.emptyList()); + Assertions.assertTrue(summary.getChildMap().isEmpty(), "no profiles -> no group written"); + // null summary must not throw. + PluginDrivenScanNode.writeScanProfilesInto(null, Collections.singletonList(profile("G", "L"))); + } + + @Test + public void writesGroupChildAndMetrics() { + // THE load-bearing RED assertion: one profile becomes a group -> "Table Scan (...)" child -> info + // strings. A mutation that skips writing leaves the summary childless. + RuntimeProfile summary = new RuntimeProfile("Execution Summary"); + PluginDrivenScanNode.writeScanProfilesInto(summary, Collections.singletonList( + profile("Paimon Scan Metrics", "Table Scan (db.t)", + "manifest_hit_cache", "4", "manifest_missed_cache", "1"))); + + RuntimeProfile group = summary.getChildMap().get("Paimon Scan Metrics"); + Assertions.assertNotNull(group, "group must be created"); + RuntimeProfile scan = group.getChildMap().get("Table Scan (db.t)"); + Assertions.assertNotNull(scan, "per-scan child must be created"); + Assertions.assertEquals("4", scan.getInfoString("manifest_hit_cache")); + Assertions.assertEquals("1", scan.getInfoString("manifest_missed_cache")); + } + + @Test + public void sharesGroupAcrossScans() { + // Two scans of the same connector go under ONE get-or-created group as two children (a join over two + // paimon tables must not create two "Paimon Scan Metrics" groups). + RuntimeProfile summary = new RuntimeProfile("Execution Summary"); + PluginDrivenScanNode.writeScanProfilesInto(summary, Arrays.asList( + profile("Iceberg Scan Metrics", "Table Scan (db.a)", "data_files", "3"), + profile("Iceberg Scan Metrics", "Table Scan (db.b)", "data_files", "5"))); + + RuntimeProfile group = summary.getChildMap().get("Iceberg Scan Metrics"); + Assertions.assertNotNull(group); + Assertions.assertEquals(2, group.getChildMap().size(), "one group, two scan children"); + 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/PluginDrivenScanNodeScanProviderSelectionTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeScanProviderSelectionTest.java new file mode 100644 index 00000000000000..ef2c02c847229b --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeScanProviderSelectionTest.java @@ -0,0 +1,112 @@ +// 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; + +/** + * Guards that {@link PluginDrivenScanNode} resolves its scan plan provider PER TABLE — passing the table's + * {@code currentHandle} to {@link Connector#getScanPlanProvider(ConnectorTableHandle)} — so a heterogeneous + * gateway connector can route each table to the right backing scanner. + * + *

    WHY this matters (Rule 9): before this seam the node called the no-arg + * {@code connector.getScanPlanProvider()} for every table, so one catalog could expose exactly ONE scan + * provider. All provider look-ups now route through {@code resolveScanProvider()} keyed on the handle; a + * mutant that drops the handle (reverts to the no-arg getter) would send an iceberg-on-HMS table to the hive + * scanner and return wrong/empty rows. Driven on a partial ({@code CALLS_REAL_METHODS}) node with only + * {@code connector}/{@code currentHandle} injected — the same technique as + * {@code PluginDrivenScanNodeVerboseExplainTest}.

    + */ +public class PluginDrivenScanNodeScanProviderSelectionTest { + + @Test + public void resolvesProviderForCurrentHandle() { + PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class, Mockito.CALLS_REAL_METHODS); + + ConnectorTableHandle icebergHandle = Mockito.mock(ConnectorTableHandle.class); + ConnectorTableHandle hiveHandle = Mockito.mock(ConnectorTableHandle.class); + ConnectorScanPlanProvider icebergProvider = Mockito.mock(ConnectorScanPlanProvider.class); + ConnectorScanPlanProvider hiveProvider = Mockito.mock(ConnectorScanPlanProvider.class); + + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getScanPlanProvider(icebergHandle)).thenReturn(icebergProvider); + Mockito.when(connector.getScanPlanProvider(hiveHandle)).thenReturn(hiveProvider); + Deencapsulation.setField(node, "connector", connector); + + // The provider is selected by whichever handle the scan currently holds (pushdown may refine it). + Deencapsulation.setField(node, "currentHandle", icebergHandle); + Assertions.assertSame(icebergProvider, Deencapsulation.invoke(node, "resolveScanProvider"), + "the node must resolve the provider for the iceberg-on-HMS handle it is scanning"); + + // After the handle changes the node must re-resolve to the matching provider (per-table routing), + // not cache the first one. + Deencapsulation.setField(node, "currentHandle", hiveHandle); + Assertions.assertSame(hiveProvider, Deencapsulation.invoke(node, "resolveScanProvider"), + "after the handle changes the node must resolve the matching provider (per-table routing)"); + } + + /** + * Guards that {@link PluginDrivenScanNode#resolveScanProvider()} MEMOIZES the resolved provider for a stable + * {@code currentHandle} so the per-split hot path ({@code getFileCompressType} / {@code getDeleteFiles}) stops + * re-allocating a provider on every split, while still RE-RESOLVING when pushdown/pin refines the handle. + * + *

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

    + */ + @Test + public void memoizesProviderForStableHandleAndReResolvesOnHandleChange() { + PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class, Mockito.CALLS_REAL_METHODS); + + ConnectorTableHandle icebergHandle = Mockito.mock(ConnectorTableHandle.class); + ConnectorTableHandle hiveHandle = Mockito.mock(ConnectorTableHandle.class); + ConnectorScanPlanProvider icebergProvider = Mockito.mock(ConnectorScanPlanProvider.class); + ConnectorScanPlanProvider hiveProvider = Mockito.mock(ConnectorScanPlanProvider.class); + + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getScanPlanProvider(icebergHandle)).thenReturn(icebergProvider); + Mockito.when(connector.getScanPlanProvider(hiveHandle)).thenReturn(hiveProvider); + Deencapsulation.setField(node, "connector", connector); + + // Stable handle: many resolves (mirroring the per-split getFileCompressType calls) resolve ONCE. + Deencapsulation.setField(node, "currentHandle", icebergHandle); + for (int i = 0; i < 3; i++) { + Assertions.assertSame(icebergProvider, Deencapsulation.invoke(node, "resolveScanProvider"), + "a stable handle must return the memoized provider"); + } + Mockito.verify(connector, Mockito.times(1)).getScanPlanProvider(icebergHandle); + + // Handle refined by pushdown/pin (a NEW handle object): re-resolve exactly once for the new handle. + Deencapsulation.setField(node, "currentHandle", hiveHandle); + for (int i = 0; i < 2; i++) { + Assertions.assertSame(hiveProvider, Deencapsulation.invoke(node, "resolveScanProvider"), + "after the handle changes the node must re-resolve to the matching provider"); + } + Mockito.verify(connector, Mockito.times(1)).getScanPlanProvider(hiveHandle); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeSysHandleTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeSysHandleTest.java new file mode 100644 index 00000000000000..333e7229b8db7d --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeSysHandleTest.java @@ -0,0 +1,230 @@ +// 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.analysis.TupleDescriptor; +import org.apache.doris.analysis.TupleId; +import org.apache.doris.common.jmockit.Deencapsulation; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.SessionContext; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenSysExternalTable; +import org.apache.doris.planner.PlanNodeId; +import org.apache.doris.planner.ScanContext; +import org.apache.doris.qe.SessionVariable; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Guards the SCAN-PATH handle resolution in {@link PluginDrivenScanNode#create} (B4 final-review BLOCKER). + * + *

    Why this matters: for a connector system table ({@link PluginDrivenSysExternalTable}, + * e.g. {@code tbl$binlog}), the scan node must thread the connector's SYSTEM-table handle — the one + * carrying {@code sysTableName}/{@code forceJni} — not a plain handle for the base table. If + * {@code create} resolved the handle with a RAW {@code metadata.getTableHandle(...)} (the base-table + * handle), the connector's force-JNI flag never reaches the scan plan, so a binlog/audit_log split + * whose native conversion succeeds would take the NATIVE reader instead of JNI and return silently + * wrong rows. {@code create} must go through the sys-aware seam + * {@link PluginDrivenExternalTable#resolveConnectorTableHandle} so the override on the sys table feeds + * the system handle through.

    + * + *

    This drives the REAL static {@code create(...)} end-to-end (full {@code FileQueryScanNode} + * constructor chain — same construction used by {@code IcebergScanNodeTest}) over a real sys table, + * then reads back the node's resolved {@code currentHandle} and asserts it is the SYS handle (distinct + * mock), not the base handle. fe-core has no paimon on its classpath, so the connector-specific + * {@code PaimonTableHandle.isForceJni()} cannot be referenced here; asserting "the sys handle, not the + * base handle, was threaded" is the in-module proxy for "force-JNI is preserved on the scan path".

    + */ +public class PluginDrivenScanNodeSysHandleTest { + + @Test + public void createThreadsSysHandleNotBaseHandleForSysTable() { + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + // Two DISTINCT handles: a base-table handle and the connector's system-table handle. + // The base handle stands in for the NORMAL PaimonTableHandle (forceJni=false) that raw + // getTableHandle would yield; the sys handle stands in for the force-JNI sys handle. + ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); + ConnectorTableHandle sysHandle = Mockito.mock(ConnectorTableHandle.class); + + TestablePluginCatalog catalog = new TestablePluginCatalog("paimon", metadata, session); + ExternalDatabase db = mockDb("REMOTE_DB"); + + // Base handle resolved from the SOURCE remote name (not the "$"-suffixed sys remote name); + // the connector then maps base handle + "binlog" -> the sys handle. NOTE: there is no stub + // for getTableHandle on the "$"-suffixed sys remote name, so a raw resolution in create() + // would return the unstubbed (default) value, never sysHandle. + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.of(baseHandle)); + Mockito.when(metadata.getSysTableHandle(session, baseHandle, "binlog")) + .thenReturn(Optional.of(sysHandle)); + + PluginDrivenExternalTable base = bareTable(catalog, db, "REMOTE_TBL"); + PluginDrivenSysExternalTable sysTable = new PluginDrivenSysExternalTable(base, "binlog") { + @Override + protected synchronized void makeSureInitialized() { + // no-op: skip Env-backed catalog/db init + } + }; + + PluginDrivenScanNode node = PluginDrivenScanNode.create(new PlanNodeId(0), + new TupleDescriptor(new TupleId(0)), false, new SessionVariable(), + ScanContext.EMPTY, catalog, sysTable); + + ConnectorTableHandle resolved = Deencapsulation.getField(node, "currentHandle"); + // WHY: a system-table scan must thread the connector's SYS handle (force-JNI) so binlog/ + // audit_log go through JNI, not the native reader. The sys table's resolveConnectorTableHandle + // override returns the sys handle; create() MUST honor that seam. + // MUTATION: reverting create() to raw metadata.getTableHandle(session, dbName, + // table.getRemoteName()) resolves "REMOTE_TBL$binlog" (unstubbed -> not sysHandle, and + // never calls getSysTableHandle) -> resolved != sysHandle -> red. + Assertions.assertSame(sysHandle, resolved, + "scan node must resolve the connector SYS handle (force-JNI) via the sys-aware seam, " + + "not a raw base-table handle"); + Assertions.assertNotSame(baseHandle, resolved, + "scan node must NOT use the base-table handle for a system table"); + Mockito.verify(metadata).getSysTableHandle(session, baseHandle, "binlog"); + } + + @Test + public void createUsesBaseHandleForNormalTableUnchanged() { + // Normal (non-sys) plugin table: base resolveConnectorTableHandle == old inline call, so the + // resolved handle is exactly metadata.getTableHandle(session, db, remoteName). Pins that the + // fix is behavior-preserving for normal tables (max_compute/jdbc/etc.). + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); + + TestablePluginCatalog catalog = new TestablePluginCatalog("paimon", metadata, session); + ExternalDatabase db = mockDb("REMOTE_DB"); + Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) + .thenReturn(Optional.of(baseHandle)); + + PluginDrivenExternalTable table = bareTable(catalog, db, "REMOTE_TBL"); + + PluginDrivenScanNode node = PluginDrivenScanNode.create(new PlanNodeId(0), + new TupleDescriptor(new TupleId(0)), false, new SessionVariable(), + ScanContext.EMPTY, catalog, table); + + ConnectorTableHandle resolved = Deencapsulation.getField(node, "currentHandle"); + // WHY: for a normal table the sys-aware seam's base impl is identical to the old inline + // getTableHandle, so the resolved handle must still be the plain base handle and the sys + // path must never be consulted. MUTATION: a create() that always wrapped/forced a sys + // handle would break normal tables -> this would no longer be baseHandle. + Assertions.assertSame(baseHandle, resolved, + "normal plugin table must resolve the plain base-table handle (behavior unchanged)"); + Mockito.verify(metadata, Mockito.never()) + .getSysTableHandle(Mockito.any(), Mockito.any(), Mockito.anyString()); + } + + // ==================== helpers (mirror PluginDrivenSysTableTest) ==================== + + private static PluginDrivenExternalTable bareTable(PluginDrivenExternalCatalog catalog, + ExternalDatabase db, String remoteName) { + return new PluginDrivenExternalTable(1L, "tbl", remoteName, catalog, db) { + @Override + protected synchronized void makeSureInitialized() { + // no-op: skip Env-backed catalog/db init + } + }; + } + + @SuppressWarnings("unchecked") + private static ExternalDatabase mockDb(String remoteName) { + ExternalDatabase db = Mockito.mock(ExternalDatabase.class); + Mockito.when(db.getRemoteName()).thenReturn(remoteName); + return db; + } + + /** + * Minimal {@link PluginDrivenExternalCatalog} returning a fixed connector/session without standing + * up the Doris environment (mirrors {@code PluginDrivenSysTableTest.TestablePluginCatalog}). + */ + private static class TestablePluginCatalog extends PluginDrivenExternalCatalog { + private final Connector connector; + private final ConnectorSession session; + + TestablePluginCatalog(String catalogType, ConnectorMetadata metadata, ConnectorSession session) { + this(catalogType, mockConnector(metadata, session), session); + } + + private TestablePluginCatalog(String catalogType, Connector connector, ConnectorSession session) { + super(1L, "test-catalog", null, makeProps(catalogType), "", connector); + this.connector = connector; + this.session = session; + } + + private static Connector mockConnector(ConnectorMetadata metadata, ConnectorSession session) { + Connector c = Mockito.mock(Connector.class); + Mockito.when(c.getMetadata(session)).thenReturn(metadata); + return c; + } + + @Override + public Connector getConnector() { + return connector; + } + + @Override + public ConnectorSession buildConnectorSession() { + return session; + } + + @Override + public ConnectorSession buildCrossStatementSession() { + return buildConnectorSession(); + } + + @Override + protected List listDatabaseNames() { + return Collections.emptyList(); + } + + @Override + protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { + return Collections.emptyList(); + } + + @Override + public boolean tableExist(SessionContext ctx, String dbName, String tblName) { + return false; + } + + private static Map makeProps(String type) { + Map props = new HashMap<>(); + props.put("type", type); + return props; + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeSysTableGuardTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeSysTableGuardTest.java new file mode 100644 index 00000000000000..ad7935217815ae --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeSysTableGuardTest.java @@ -0,0 +1,187 @@ +// 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.analysis.TableScanParams; +import org.apache.doris.analysis.TableSnapshot; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.common.UserException; +import org.apache.doris.datasource.plugin.PluginDrivenSysExternalTable; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * Guards the fail-loud sys-table scan-constraint check in + * {@link PluginDrivenScanNode#checkSysTableScanConstraints()} (P5-T19 Part C). + * + *

    WHY this matters: for a connector whose sys tables have no point-in-time semantics (paimon — + * the default capability), a {@code FOR TIME AS OF} (snapshot) or {@code @incr}/scan-params query + * against a plugin system table ({@link PluginDrivenSysExternalTable}) is undefined; legacy + * {@code PaimonScanNode.getProcessedTable} throws for exactly this case. Without the guard the + * scan-params / snapshot would be silently dropped and the query would return the plain sys-table + * contents, masking a user error. These tests pin that the guard fails loud (Rule 12).

    + * + *

    P6.5-T07: the guard is now CONNECTOR-CAPABILITY-AWARE. A connector reporting + * {@code supportsSystemTableTimeTravel()==true} (iceberg — whose metadata tables legally time-travel) + * lets a pinned read ({@code FOR TIME AS OF}, {@code @branch}/{@code @tag}) through to the connector, + * which retains+honors the pin; {@code @incr} is rejected for EVERY connector (undefined on a synthetic + * metadata table). Paimon keeps the default {@code false} and the original blanket rejection.

    + * + *

    Driven on a Mockito mock with {@code CALLS_REAL_METHODS} (no constructor — building a full + * {@link FileQueryScanNode} needs a harness this module lacks) and the four accessors + * ({@code getTargetTable}, {@code getScanParams}, {@code getQueryTableSnapshot}, + * {@code sysTableSupportsTimeTravel}) stubbed, so the real guard runs against controlled state. The + * guard and the capability hook are package-private exactly to enable this.

    + */ +public class PluginDrivenScanNodeSysTableGuardTest { + + private static PluginDrivenScanNode guardOnlyNode() throws Exception { + PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class, Mockito.CALLS_REAL_METHODS); + // Default: no scan-params, no snapshot, and a connector whose sys tables do NOT time-travel + // (paimon-like — the default capability). Time-travel-capable cases (iceberg) override the flag. + Mockito.doReturn(null).when(node).getScanParams(); + Mockito.doReturn(null).when(node).getQueryTableSnapshot(); + Mockito.doReturn(false).when(node).sysTableSupportsTimeTravel(); + return node; + } + + @Test + public void sysTableRejectsScanParams() throws Exception { + PluginDrivenScanNode node = guardOnlyNode(); + Mockito.doReturn(Mockito.mock(PluginDrivenSysExternalTable.class)).when(node).getTargetTable(); + Mockito.doReturn(Mockito.mock(TableScanParams.class)).when(node).getScanParams(); + + // WHY: an @incr / scan-params query on a sys table must fail loud, not silently ignore the + // params. MUTATION: removing the getScanParams() throw in the guard -> no exception -> red. + UserException ex = Assertions.assertThrows(UserException.class, + node::checkSysTableScanConstraints); + Assertions.assertTrue(ex.getMessage().contains("scan params"), + "scan-params rejection must carry the expected message, got: " + ex.getMessage()); + } + + @Test + public void sysTableRejectsTimeTravel() throws Exception { + PluginDrivenScanNode node = guardOnlyNode(); + Mockito.doReturn(Mockito.mock(PluginDrivenSysExternalTable.class)).when(node).getTargetTable(); + Mockito.doReturn(Mockito.mock(TableSnapshot.class)).when(node).getQueryTableSnapshot(); + + // WHY: a FOR TIME AS OF query on a sys table must fail loud. MUTATION: removing the + // getQueryTableSnapshot() throw in the guard -> no exception -> red. + UserException ex = Assertions.assertThrows(UserException.class, + node::checkSysTableScanConstraints); + Assertions.assertTrue(ex.getMessage().contains("time travel"), + "time-travel rejection must carry the expected message, got: " + ex.getMessage()); + } + + @Test + public void sysTableWithoutScanParamsOrSnapshotDoesNotThrow() throws Exception { + PluginDrivenScanNode node = guardOnlyNode(); + Mockito.doReturn(Mockito.mock(PluginDrivenSysExternalTable.class)).when(node).getTargetTable(); + + // WHY: a plain sys-table scan (no params, no snapshot) is valid and must pass the guard. + // This pins that the guard only rejects the two unsupported features, not all sys scans. + Assertions.assertDoesNotThrow(node::checkSysTableScanConstraints); + } + + @Test + public void normalTableWithScanParamsDoesNotThrowFromGuard() throws Exception { + PluginDrivenScanNode node = guardOnlyNode(); + // A NON-sys plugin table: even with scan-params/snapshot set, this guard is a no-op + // (normal-table time-travel is B5/MVCC, out of scope here). + Mockito.doReturn(Mockito.mock(TableIf.class)).when(node).getTargetTable(); + Mockito.doReturn(Mockito.mock(TableScanParams.class)).when(node).getScanParams(); + Mockito.doReturn(Mockito.mock(TableSnapshot.class)).when(node).getQueryTableSnapshot(); + + // WHY: the guard is SYS-table only. MUTATION: widening the instanceof check to all tables + // would throw here -> red. Pins the scope limit. + Assertions.assertDoesNotThrow(node::checkSysTableScanConstraints); + } + + // --------------------------------------------------------------------- + // P6.5-T07: connector-capability-aware gating (iceberg sys tables time-travel) + // --------------------------------------------------------------------- + + @Test + public void timeTravelCapableSysTableAllowsTimeTravel() throws Exception { + PluginDrivenScanNode node = guardOnlyNode(); + Mockito.doReturn(true).when(node).sysTableSupportsTimeTravel(); + Mockito.doReturn(Mockito.mock(PluginDrivenSysExternalTable.class)).when(node).getTargetTable(); + Mockito.doReturn(Mockito.mock(TableSnapshot.class)).when(node).getQueryTableSnapshot(); + + // WHY: iceberg metadata tables legally time-travel (t$snapshots FOR TIME AS OF ...); legacy + // IcebergScanNode.createTableScan honors the pin for sys tables. A connector reporting + // supportsSystemTableTimeTravel()==true must let the pinned read through (the connector retains + + // honors the pin), NOT reject it as paimon does. MUTATION: dropping the `&& !timeTravelSupported` + // gate on the snapshot throw -> rejects even capable connectors -> red. + Assertions.assertDoesNotThrow(node::checkSysTableScanConstraints); + } + + @Test + public void timeTravelCapableSysTableAllowsBranchTagScanParams() throws Exception { + PluginDrivenScanNode node = guardOnlyNode(); + Mockito.doReturn(true).when(node).sysTableSupportsTimeTravel(); + Mockito.doReturn(Mockito.mock(PluginDrivenSysExternalTable.class)).when(node).getTargetTable(); + // A @branch/@tag scan-param: incrementalRead()==false (the generic mock default). + Mockito.doReturn(Mockito.mock(TableScanParams.class)).when(node).getScanParams(); + + // WHY: t$files@branch('b') / @tag is a valid snapshot selector legacy iceberg honors for sys + // tables. A time-travel-capable connector must allow it through. MUTATION: removing the + // timeTravelSupported gate on the scan-params throw -> rejects branch/tag too -> red. + Assertions.assertDoesNotThrow(node::checkSysTableScanConstraints); + } + + @Test + public void timeTravelCapableSysTableStillRejectsIncrementalRead() throws Exception { + PluginDrivenScanNode node = guardOnlyNode(); + Mockito.doReturn(true).when(node).sysTableSupportsTimeTravel(); + Mockito.doReturn(Mockito.mock(PluginDrivenSysExternalTable.class)).when(node).getTargetTable(); + TableScanParams incr = Mockito.mock(TableScanParams.class); + Mockito.doReturn(true).when(incr).incrementalRead(); + Mockito.doReturn(incr).when(node).getScanParams(); + + // WHY: @incr (incremental read) is undefined on a synthetic metadata table even for iceberg — + // legacy silently ignored it; the guard fails loud instead (strictly safer). MUTATION: dropping + // the `|| getScanParams().incrementalRead()` clause -> @incr slips through on a capable connector + // -> red. + UserException ex = Assertions.assertThrows(UserException.class, + node::checkSysTableScanConstraints); + Assertions.assertTrue(ex.getMessage().contains("scan params"), + "incremental-read rejection must carry the scan-params message, got: " + ex.getMessage()); + } + + @Test + public void scanParamsRejectionTakesPrecedenceOverTimeTravel() throws Exception { + PluginDrivenScanNode node = guardOnlyNode(); + // Paimon-like (NOT time-travel-capable, the guardOnlyNode default), a sys table, with BOTH a + // non-incremental scan-param AND a snapshot pin set at once. + Mockito.doReturn(Mockito.mock(PluginDrivenSysExternalTable.class)).when(node).getTargetTable(); + Mockito.doReturn(Mockito.mock(TableScanParams.class)).when(node).getScanParams(); + Mockito.doReturn(Mockito.mock(TableSnapshot.class)).when(node).getQueryTableSnapshot(); + + // WHY: when both unsupported features are present the guard checks scan-params FIRST (its block + // precedes the snapshot block), so the user sees the scan-params diagnostic, not time travel. + // Pinning the order keeps the error message stable. MUTATION: swapping the two blocks (snapshot + // check first) -> the message becomes "time travel" -> red. + UserException ex = Assertions.assertThrows(UserException.class, + node::checkSysTableScanConstraints); + Assertions.assertTrue(ex.getMessage().contains("scan params"), + "scan-params rejection must take precedence over time travel, got: " + ex.getMessage()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeSysTablePinTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeSysTablePinTest.java new file mode 100644 index 00000000000000..4970648e1130c3 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeSysTablePinTest.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.datasource.scan; + +import org.apache.doris.analysis.TableScanParams; +import org.apache.doris.analysis.TableSnapshot; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.datasource.mvcc.MvccSnapshot; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenSysExternalTable; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Optional; + +/** + * Guards {@link PluginDrivenScanNode#resolveSysTableSnapshotPin()}, the P6.6-C1 (WS-PIN) sys-table + * time-travel pin-feed. + * + *

    WHY this matters: a {@code FOR TIME AS OF} / {@code @branch} / {@code @tag} query against a + * plugin system table ({@link PluginDrivenSysExternalTable}) never materializes its query + * snapshot into the {@code StatementContext} MVCC map — the sys table is NOT an {@code MvccTable} + * and {@code BindRelation} short-circuits {@code loadSnapshots} for {@code $}-suffixed relations, so + * the pin is keyed/looked-up under mismatched names and is starved. Without this fallback the sys + * handle stays unpinned and {@code t$snapshots FOR TIME AS OF X} silently reads the LATEST metadata + * (a correctness regression vs legacy {@code IcebergScanNode.createTableScan}, which honors the pin). + * The fix resolves the pin directly off the SOURCE table's {@code MvccTable.loadSnapshot} so the + * generic scan node's {@code applyMvccSnapshotPin} threads it onto the sys handle.

    + * + *

    Driven on a Mockito {@code CALLS_REAL_METHODS} mock (no constructor — building a full + * {@link FileQueryScanNode} needs a harness this module lacks) with the three accessors + * ({@code getTargetTable}, {@code getQueryTableSnapshot}, {@code getScanParams}) stubbed, so the real + * resolution runs against controlled state. {@code resolveSysTableSnapshotPin} is package-private + * exactly to enable this (mirrors the sibling guard/pin tests). + * + *

    The fallback only ever runs for a pin-capable connector because {@code resolveSysTableSnapshotPin} + * checks {@code sysTableSupportsTimeTravel()} ITSELF. It must not rely on + * {@code checkSysTableScanConstraints} for that: despite what this javadoc used to claim, that guard runs + * at split generation, long AFTER this resolution runs at init, so a non-capable connector (paimon) got + * the source pin anyway and blew up before ever reaching it (CI 996541, paimon_system_table).

    + */ +public class PluginDrivenScanNodeSysTablePinTest { + + private static PluginDrivenScanNode sysPinNode() { + PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class, Mockito.CALLS_REAL_METHODS); + // Default: no time travel (plain scan). Time-travel cases override these. + Mockito.doReturn(null).when(node).getQueryTableSnapshot(); + Mockito.doReturn(null).when(node).getScanParams(); + // Default: a pin-capable connector (iceberg). The real method would need a live scan provider; + // sysTableSupportsTimeTravelFalseDoesNotPin overrides this to exercise the non-capable side. + Mockito.doReturn(true).when(node).sysTableSupportsTimeTravel(); + return node; + } + + @Test + public void sysTableForTimeAsOfDelegatesToSourceLoadSnapshot() throws Exception { + PluginDrivenScanNode node = sysPinNode(); + PluginDrivenSysExternalTable sysTable = Mockito.mock(PluginDrivenSysExternalTable.class); + // PluginDrivenMvccExternalTable IS-A PluginDrivenExternalTable AND implements MvccTable. + PluginDrivenMvccExternalTable source = Mockito.mock(PluginDrivenMvccExternalTable.class); + MvccSnapshot resolved = Mockito.mock(MvccSnapshot.class); + TableSnapshot ts = Mockito.mock(TableSnapshot.class); + Mockito.doReturn(sysTable).when(node).getTargetTable(); + Mockito.doReturn(ts).when(node).getQueryTableSnapshot(); + Mockito.doReturn(source).when(sysTable).getSourceTable(); + Mockito.when(source.loadSnapshot(Optional.of(ts), Optional.empty())).thenReturn(resolved); + + // WHY: FOR TIME AS OF on a sys table resolves the pin off the source table. MUTATION: returning + // Optional.empty() instead of the source fallback -> pin lost -> sys reads latest -> red. + Optional result = node.resolveSysTableSnapshotPin(); + Assertions.assertTrue(result.isPresent(), "sys FOR TIME AS OF must resolve a pin off the source"); + Assertions.assertSame(resolved, result.get()); + Mockito.verify(source).loadSnapshot(Optional.of(ts), Optional.empty()); + } + + @Test + public void sysTableBranchTagDelegatesScanParams() throws Exception { + PluginDrivenScanNode node = sysPinNode(); + PluginDrivenSysExternalTable sysTable = Mockito.mock(PluginDrivenSysExternalTable.class); + PluginDrivenMvccExternalTable source = Mockito.mock(PluginDrivenMvccExternalTable.class); + MvccSnapshot resolved = Mockito.mock(MvccSnapshot.class); + TableScanParams sp = Mockito.mock(TableScanParams.class); + Mockito.doReturn(sysTable).when(node).getTargetTable(); + Mockito.doReturn(sp).when(node).getScanParams(); + Mockito.doReturn(source).when(sysTable).getSourceTable(); + Mockito.when(source.loadSnapshot(Optional.empty(), Optional.of(sp))).thenReturn(resolved); + + // WHY: t$files@branch('b') / @tag is a snapshot selector legacy iceberg honors for sys tables; + // it arrives as scan-params, not a TableSnapshot. MUTATION: dropping the getScanParams() + // threading (passing Optional.empty()) -> branch/tag pin lost -> red. + Optional result = node.resolveSysTableSnapshotPin(); + Assertions.assertTrue(result.isPresent(), "sys @branch/@tag must resolve a pin off the source"); + Assertions.assertSame(resolved, result.get()); + Mockito.verify(source).loadSnapshot(Optional.empty(), Optional.of(sp)); + } + + @Test + public void sysTablePlainScanDoesNotPin() throws Exception { + PluginDrivenScanNode node = sysPinNode(); + PluginDrivenSysExternalTable sysTable = Mockito.mock(PluginDrivenSysExternalTable.class); + PluginDrivenMvccExternalTable source = Mockito.mock(PluginDrivenMvccExternalTable.class); + Mockito.doReturn(sysTable).when(node).getTargetTable(); + Mockito.doReturn(source).when(sysTable).getSourceTable(); + // No snapshot, no scan-params (sysPinNode default). + + // WHY: a plain sys scan (no time travel) must NOT resolve a pin — doing so would force an + // unnecessary remote round-trip and pin nothing meaningful. MUTATION: removing the + // "both null -> empty" short-circuit -> loadSnapshot is invoked -> red. + Optional result = node.resolveSysTableSnapshotPin(); + Assertions.assertFalse(result.isPresent(), "plain sys scan must not pin"); + Mockito.verifyNoInteractions(source); + } + + @Test + public void normalTableNeverUsesSysFallback() throws Exception { + PluginDrivenScanNode node = sysPinNode(); + // A NON-sys plugin table, even with a snapshot set: its pin comes from StatementContext, never + // from this fallback. + Mockito.doReturn(Mockito.mock(TableIf.class)).when(node).getTargetTable(); + Mockito.doReturn(Mockito.mock(TableSnapshot.class)).when(node).getQueryTableSnapshot(); + + // WHY: the sys fallback is SYS-table only. MUTATION: widening the + // instanceof PluginDrivenSysExternalTable check -> non-empty here -> red (pins the scope limit). + Optional result = node.resolveSysTableSnapshotPin(); + Assertions.assertFalse(result.isPresent(), "normal-table pin must not flow through the sys fallback"); + } + + @Test + public void sysTableWithNonMvccSourceDoesNotPin() throws Exception { + PluginDrivenScanNode node = sysPinNode(); + PluginDrivenSysExternalTable sysTable = Mockito.mock(PluginDrivenSysExternalTable.class); + // A source that is NOT an MvccTable (no time-travel capability). + PluginDrivenExternalTable nonMvccSource = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.doReturn(sysTable).when(node).getTargetTable(); + Mockito.doReturn(Mockito.mock(TableSnapshot.class)).when(node).getQueryTableSnapshot(); + Mockito.doReturn(nonMvccSource).when(sysTable).getSourceTable(); + + // WHY: defensive — a connector whose sys tables are not MVCC-capable is already rejected by the + // guard, but if it ever reaches here we fall back to no-pin rather than ClassCastException. + // MUTATION: dropping the instanceof MvccTable guard -> CCE on the cast -> red. + Optional result = node.resolveSysTableSnapshotPin(); + Assertions.assertFalse(result.isPresent(), "non-MVCC source must not pin (no CCE)"); + } + + @Test + public void sysTableSupportsTimeTravelFalseDoesNotPin() throws Exception { + PluginDrivenScanNode node = sysPinNode(); + PluginDrivenSysExternalTable sysTable = Mockito.mock(PluginDrivenSysExternalTable.class); + PluginDrivenMvccExternalTable source = Mockito.mock(PluginDrivenMvccExternalTable.class); + Mockito.doReturn(sysTable).when(node).getTargetTable(); + Mockito.doReturn(Mockito.mock(TableSnapshot.class)).when(node).getQueryTableSnapshot(); + Mockito.doReturn(source).when(sysTable).getSourceTable(); + // A connector whose sys tables do NOT honor a pin (paimon, the default). + Mockito.doReturn(false).when(node).sysTableSupportsTimeTravel(); + + // WHY: the capability must be checked HERE, at init. checkSysTableScanConstraints owns the + // user-facing "Plugin system tables do not support time travel." message but only runs at split + // generation; pinning first hands the SOURCE's snapshot (and its non-null pinned schema) to a tuple + // carrying the SYS table's columns, and the L17 guard / loadSnapshot's own RuntimeException fires + // first, masking that message (CI 996541, paimon_system_table). + // MUTATION: dropping the sysTableSupportsTimeTravel() gate -> a pin is resolved -> red. + Optional result = node.resolveSysTableSnapshotPin(); + Assertions.assertFalse(result.isPresent(), + "a connector that rejects sys-table time travel must not resolve a pin"); + Mockito.verify(source, Mockito.never()).loadSnapshot(Mockito.any(), Mockito.any()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeTableSampleTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeTableSampleTest.java new file mode 100644 index 00000000000000..a539c06236ee33 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeTableSampleTest.java @@ -0,0 +1,131 @@ +// 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.analysis.TableSample; +import org.apache.doris.spi.Split; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.List; + +/** + * FIX-M1 — guards {@link PluginDrivenScanNode#sampleSplits}, the pure, connector-agnostic TABLESAMPLE + * split selector that restores the sampling lost when Hive flipped onto the plugin SPI. + * + *

    Why this matters: post-cutover, TABLESAMPLE was silently dropped for plugin-driven external + * tables — {@code SELECT ... TABLESAMPLE(N)} returned the FULL table. The fix re-applies sampling in + * {@code getSplits}, but ONLY for connectors that declare {@code supportsTableSample()} (their ranges carry + * byte lengths); this test pins the size-weighted selection itself. Getting the accumulation wrong + * re-introduces the silent full-table bug (returning every split) or over-truncates (dropping rows the + * sample should keep). The helper is invoked only with positive-length splits (the capability gate lives in + * getSplits and is covered by live e2e — connectors whose ranges report -1 must NOT opt in).

    + */ +public class PluginDrivenScanNodeTableSampleTest { + + /** A split whose byte length is {@code len} (the only property sampleSplits reads). */ + private static Split split(long len) { + Split s = Mockito.mock(Split.class); + Mockito.when(s.getLength()).thenReturn(len); + return s; + } + + /** {@code count} splits, each {@code len} bytes. */ + private static List uniform(int count, long len) { + List splits = new ArrayList<>(); + for (int i = 0; i < count; i++) { + splits.add(split(len)); + } + return splits; + } + + private static long totalLen(List splits) { + long t = 0; + for (Split s : splits) { + t += s.getLength(); + } + return t; + } + + @Test + public void testPercentFullSelectsAll() { + // 100 PERCENT -> sampleSize == totalSize -> every split is selected (no reduction). + List splits = uniform(10, 10L); + List sampled = PluginDrivenScanNode.sampleSplits(splits, new TableSample(true, 100L, 0L), 0L); + Assertions.assertEquals(10, sampled.size()); + } + + @Test + public void testPercentHalfIsMinimalPrefixOverTarget() { + // 50 PERCENT of 10x10-byte splits: sampleSize = 50 -> exactly 5 splits (5*10 >= 50, 4*10 < 50). + // Pins the accumulate-until-(>=sampleSize) boundary: an off-by-one (> vs >=, or pre/post-increment) + // changes the count. Split sizes are uniform, so the count is shuffle-independent. + List splits = uniform(10, 10L); + List sampled = PluginDrivenScanNode.sampleSplits(splits, new TableSample(true, 50L, 0L), 0L); + Assertions.assertEquals(5, sampled.size()); + // The selection is a strict reduction of the full set (this is the property the silent-drop bug violated). + Assertions.assertTrue(sampled.size() < 10); + // Minimal prefix: selected size >= target, and dropping the last selected split would fall under it. + long selected = totalLen(sampled); + Assertions.assertTrue(selected >= 50L); + Assertions.assertTrue(selected - 10L < 50L); + } + + @Test + public void testRowsModeUsesEstimatedRowSize() { + // ROWS sampling: sampleSize = estimatedRowSize * rows = 8 * 3 = 24 -> 3 splits of 10 bytes + // (10,20,30; 30 >= 24 at index 3). Pins that ROWS mode consults estimatedRowSize (not totalSize). + List splits = uniform(10, 10L); + List sampled = PluginDrivenScanNode.sampleSplits(splits, new TableSample(false, 3L, 0L), 8L); + Assertions.assertEquals(3, sampled.size()); + } + + @Test + public void testSampleLargerThanTableSelectsAll() { + // A sample target exceeding the whole table returns every split (never NPEs / over-reads). + List splits = uniform(5, 10L); + List sampled = PluginDrivenScanNode.sampleSplits(splits, new TableSample(false, 1_000_000L, 0L), 1000L); + Assertions.assertEquals(5, sampled.size()); + } + + @Test + public void testEmptySplitsReturnEmpty() { + List sampled = PluginDrivenScanNode.sampleSplits(new ArrayList<>(), new TableSample(true, 10L, 0L), 8L); + Assertions.assertTrue(sampled.isEmpty()); + } + + @Test + public void testRepeatableSeekIsDeterministic() { + // REPEATABLE seeds the shuffle: two runs of the SAME query (same seek) select the SAME subset. + // Distinct split sizes make the selection shuffle-sensitive, so this actually exercises seed determinism + // (uniform sizes would pass trivially). MUTATION: an unseeded / time-seeded shuffle -> flaky subset -> red. + List base = new ArrayList<>(); + for (int i = 1; i <= 10; i++) { + base.add(split(i)); // sizes 1..10, total 55 + } + // Copies preserve element identity + initial order, so same-seed shuffles produce the same permutation. + List run1 = PluginDrivenScanNode.sampleSplits(new ArrayList<>(base), new TableSample(true, 30L, 7L), 0L); + List run2 = PluginDrivenScanNode.sampleSplits(new ArrayList<>(base), new TableSample(true, 30L, 7L), 0L); + Assertions.assertEquals(run1, run2); + // And it is a real sample, not the whole table (30% target). + Assertions.assertTrue(run1.size() < base.size()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeTopnLazyMatTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeTopnLazyMatTest.java new file mode 100644 index 00000000000000..518ce855cc9490 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeTopnLazyMatTest.java @@ -0,0 +1,92 @@ +// 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.analysis.SlotDescriptor; +import org.apache.doris.catalog.Column; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; + +/** + * Guards {@link PluginDrivenScanNode#hasTopnLazyMaterializeSlot}, the connector-agnostic detection of the + * engine-wide Top-N lazy-materialization row-id slot ({@code __DORIS_GLOBAL_ROWID_COL__}). + * + *

    Why this matters (M-4): under Top-N lazy materialization BE reads the sort key first, then + * re-fetches the OTHER (non-projected) columns of the surviving rows by row-id. {@code pinTopnLazyMaterialize} + * uses this detection to signal the connector (via {@code ConnectorMetadata.applyTopnLazyMaterialization}) + * that its column-pruned scan metadata (iceberg's field-id dictionary) must be rebuilt over the FULL schema — + * otherwise a lazily re-fetched, schema-evolved column has no field-id entry and the native read mis-reads it. + * The detection is the engine-wide GLOBAL_ROWID prefix test (a generic Doris mechanism, also used by + * {@code classifyColumn} / {@code HiveScanNode} / {@code TVFScanNode}), so no connector knowledge leaks into + * fe-core. Mirrors legacy {@code IcebergScanNode.createScanRangeLocations}'s {@code haveTopnLazyMatCol}.

    + */ +public class PluginDrivenScanNodeTopnLazyMatTest { + + private static SlotDescriptor slotNamed(String name) { + SlotDescriptor slot = Mockito.mock(SlotDescriptor.class); + Column column = Mockito.mock(Column.class); + Mockito.doReturn(name).when(column).getName(); + Mockito.doReturn(column).when(slot).getColumn(); + return slot; + } + + @Test + public void detectsSuffixedGlobalRowIdSlot() { + // LazyMaterializeTopN appends the table/function name to the prefix, so the test must be startsWith, + // not equals. MUTATION: startsWith -> equals drops the suffixed name -> detection false -> the dict + // stays pruned -> a schema-evolved lazy re-fetch mis-reads -> red. + Assertions.assertTrue(PluginDrivenScanNode.hasTopnLazyMaterializeSlot( + Collections.singletonList(slotNamed(Column.GLOBAL_ROWID_COL + "my_tbl")))); + } + + @Test + public void detectsGlobalRowIdAmongRegularSlots() { + // The row-id slot co-exists with the projected sort key(s). MUTATION: scanning only the first slot / + // not iterating -> the row-id after "id" is missed -> red. + Assertions.assertTrue(PluginDrivenScanNode.hasTopnLazyMaterializeSlot( + Arrays.asList(slotNamed("id"), slotNamed(Column.GLOBAL_ROWID_COL + "t")))); + } + + @Test + public void noGlobalRowIdSlotIsNotTopn() { + // WHY: a normal (non-lazy-mat) scan must NOT be flagged, or the connector would drop its column-pruning + // optimization (full-schema dict) for every query. MUTATION: returning true unconditionally -> red. + Assertions.assertFalse(PluginDrivenScanNode.hasTopnLazyMaterializeSlot( + Arrays.asList(slotNamed("id"), slotNamed("name")))); + } + + @Test + public void emptySlotsIsNotTopn() { + Assertions.assertFalse(PluginDrivenScanNode.hasTopnLazyMaterializeSlot(Collections.emptyList())); + } + + @Test + public void nullColumnSlotIsSkippedWithoutNpe() { + // A slot with no bound column (slot.getColumn() == null) must be skipped, not throw. MUTATION: + // dropping the null guard -> NPE here -> red. + SlotDescriptor nullColSlot = Mockito.mock(SlotDescriptor.class); + Mockito.doReturn(null).when(nullColSlot).getColumn(); + Assertions.assertFalse(PluginDrivenScanNode.hasTopnLazyMaterializeSlot( + Collections.singletonList(nullColSlot))); + } +} 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 new file mode 100644 index 00000000000000..0d7d7c4b9dca62 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeVerboseExplainTest.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.datasource.scan; + +import org.apache.doris.analysis.ColumnAccessPath; +import org.apache.doris.analysis.SlotDescriptor; +import org.apache.doris.analysis.SlotId; +import org.apache.doris.analysis.TupleDescriptor; +import org.apache.doris.analysis.TupleId; +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.DatabaseIf; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.catalog.Type; +import org.apache.doris.common.jmockit.Deencapsulation; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.datasource.CatalogIf; +import org.apache.doris.thrift.TExplainLevel; +import org.apache.doris.thrift.TPushAggOp; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; + +/** + * FIX-R3-RESIDUAL — guards that the VERBOSE per-backend {@code backends:} block in + * {@link PluginDrivenScanNode#getNodeExplainString} is emitted for EVERY plugin connector, NOT gated to a + * hardcoded source name. + * + *

    Why this matters (Rule 9 — tests encode WHY): the {@code backends:} block (the per-backend + * scan-range detail with {@code dataFileNum/deleteFileNum/deleteSplitNum}) is universal {@link FileScanNode} + * behavior: the parent emits it unconditionally under {@code VERBOSE && !isBatchMode()} + * ({@code FileScanNode#getNodeExplainString}). This override does not call super, and previously re-emitted + * the block only when {@code "paimon".equals(catalog.getType())}. That source-name gate (a) regressed + * 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.

    + * + *

    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 + * {@code "backends:"} disappears from VERBOSE EXPLAIN → {@link #verboseEmitsBackendsBlockForNonPaimonConnector} + * goes red. {@link #nonVerboseOmitsBackendsBlock} pins the surviving {@code VERBOSE} gate so a mutant that + * drops the level check (always emitting the block) is also killed.

    + * + *

    Driven on a {@code CALLS_REAL_METHODS} mock with only the fields the explain path reads injected (the + * same partial-node technique as {@code PluginDrivenScanNodeDeleteFilesTest}; full {@code create(...)} + * construction is unnecessary). {@code scanRangeLocations} is left EMPTY: the per-backend loop is then + * skipped and only the unconditional bare {@code backends:} header is emitted, so no synthetic + * {@code FileScanRange} plumbing is needed and the deref chain inside the loop never runs.

    + */ +public class PluginDrivenScanNodeVerboseExplainTest { + + /** + * A {@code CALLS_REAL_METHODS} node whose {@code desc} resolves to a table on a catalog of + * {@code catalogType}, with empty scan ranges / conjuncts and {@code isBatchMode()==false}, so + * {@code getNodeExplainString} runs its full table-scan (else) branch without I/O or NPE. + */ + private static PluginDrivenScanNode nodeForCatalogType(String catalogType) { + PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class, Mockito.CALLS_REAL_METHODS); + + TableIf table = Mockito.mock(TableIf.class); + DatabaseIf db = Mockito.mock(DatabaseIf.class); + CatalogIf catalog = Mockito.mock(CatalogIf.class); + Mockito.when(table.getNameWithFullQualifiers()).thenReturn(catalogType + "_ctl.db.tbl"); + Mockito.when(table.getDatabase()).thenReturn(db); + Mockito.when(db.getCatalog()).thenReturn(catalog); + Mockito.when(catalog.getType()).thenReturn(catalogType); + TupleDescriptor desc = new TupleDescriptor(new TupleId(0)); + desc.setTable(table); + + Deencapsulation.setField(node, "desc", desc); + // Mockito skips the constructor, so field initializers do not run -> set the non-null fields the + // explain path reads. Empty scanRangeLocations => the per-backend loop body is skipped. + Deencapsulation.setField(node, "conjuncts", new ArrayList<>()); + Deencapsulation.setField(node, "scanRangeLocations", new ArrayList<>()); + // useTopnFilter() runs at the method tail (common to both EXPLAIN paths) and derefs this list. + Deencapsulation.setField(node, "topnFilterSortNodes", new ArrayList<>()); + // Pre-seed the cache so getOrLoadScanNodeProperties() returns it without contacting the connector. + Deencapsulation.setField(node, "scanNodeProperties", Collections.emptyMap()); + // Pre-seed the isBatchMode cache so the gate's !isBatchMode() is deterministic (no computeBatchMode). + Deencapsulation.setField(node, "isBatchModeCache", Boolean.FALSE); + Deencapsulation.setField(node, "connector", Mockito.mock(Connector.class)); + Deencapsulation.setField(node, "pushDownAggNoGroupingOp", TPushAggOp.NONE); + return node; + } + + @Test + public void verboseEmitsBackendsBlockForNonPaimonConnector() { + // max_compute is the connector that actually regressed at cut-over (legacy MaxComputeScanNode + // inherited the unconditional FileScanNode block); the same holds for es/jdbc/trino-connector. + PluginDrivenScanNode node = nodeForCatalogType("max_compute"); + + String explain = node.getNodeExplainString("", TExplainLevel.VERBOSE); + + Assertions.assertTrue(explain.contains("backends:"), + "VERBOSE EXPLAIN must emit the universal FileScanNode backends: block for a non-paimon " + + "plugin connector (no source-name gate). Actual:\n" + explain); + } + + @Test + public void verboseEmitsBackendsBlockForPaimon() { + // Parity guard: removing the gate must NOT drop the block for paimon (it stays emitted). + PluginDrivenScanNode node = nodeForCatalogType("paimon"); + + String explain = node.getNodeExplainString("", TExplainLevel.VERBOSE); + + Assertions.assertTrue(explain.contains("backends:"), + "VERBOSE EXPLAIN must still emit the backends: block for paimon. Actual:\n" + explain); + } + + @Test + public void emitsNestedColumnsBlockForPluginConnector() { + // F6/F7: the parent FileScanNode emits the "nested columns:" block (pruned type / sub path / all + + // predicate access paths) via printNestedColumns; this override drops it by not calling super, so the + // WHOLE block vanished for EVERY plugin FileScan connector (broader than iceberg). Attach a slot + // carrying nested-pruned access paths and assert the block re-appears. MUTATION: removing the + // printNestedColumns(...) call -> "nested columns:" disappears -> red. The node is a + // PluginDrivenScanNode (never an IcebergScanNode), so the GENERIC name-join path renders "a.b" + // (the dead iceberg field-id merge arms PlanNode:949/965 -> "a(3).b(5)" are NOT reached). + PluginDrivenScanNode node = nodeForCatalogType("max_compute"); + TupleDescriptor desc = Deencapsulation.getField(node, "desc"); + SlotDescriptor slot = new SlotDescriptor(new SlotId(1), desc.getId()); + Column col = new Column("c1", Type.INT); + slot.setColumn(col); + slot.setType(Type.INT); + // printNestedColumns gates the "all access paths" line on getDisplayAllAccessPaths but the generic + // branch renders getDisplayAllAccessPaths; it gates the "predicate access paths" line on + // getDisplayPredicateAccessPaths but the generic branch renders getPredicateAccessPaths. Set BOTH the + // display and non-display forms so each line renders its value regardless of that asymmetry. + slot.setAllAccessPaths(Collections.singletonList(ColumnAccessPath.data(Arrays.asList("a", "b")))); + slot.setDisplayAllAccessPaths( + Collections.singletonList(ColumnAccessPath.data(Arrays.asList("a", "b")))); + slot.setPredicateAccessPaths( + Collections.singletonList(ColumnAccessPath.data(Collections.singletonList("x")))); + slot.setDisplayPredicateAccessPaths( + Collections.singletonList(ColumnAccessPath.data(Collections.singletonList("x")))); + desc.addSlot(slot); + + String explain = node.getNodeExplainString("", TExplainLevel.VERBOSE); + + Assertions.assertTrue(explain.contains("nested columns:"), + "the nested columns block must be re-emitted for a plugin FileScan connector. Actual:\n" + + explain); + Assertions.assertTrue(explain.contains("all access paths: [a.b]"), + "F6: the all-access-paths line must re-appear (generic name-join). Actual:\n" + explain); + Assertions.assertTrue(explain.contains("predicate access paths: [x]"), + "F7: the predicate-access-paths line must re-appear. Actual:\n" + explain); + } + + @Test + public void nonVerboseOmitsBackendsBlock() { + // Pins the surviving level gate: the block is VERBOSE-only. A mutant that emits it unconditionally + // (drops the TExplainLevel.VERBOSE check) would leak the block into NORMAL EXPLAIN -> red. + PluginDrivenScanNode node = nodeForCatalogType("max_compute"); + + String explain = node.getNodeExplainString("", TExplainLevel.NORMAL); + + Assertions.assertFalse(explain.contains("backends:"), + "NORMAL EXPLAIN must NOT emit the VERBOSE-only backends: block. Actual:\n" + explain); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/FileSplitterTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/split/FileSplitterTest.java similarity index 96% rename from fe/fe-core/src/test/java/org/apache/doris/datasource/FileSplitterTest.java rename to fe/fe-core/src/test/java/org/apache/doris/datasource/split/FileSplitterTest.java index 5a39135103825d..9931edb19cc9f7 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/FileSplitterTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/split/FileSplitterTest.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.split; import org.apache.doris.common.util.LocationPath; import org.apache.doris.spi.Split; @@ -49,9 +49,9 @@ public void testNonSplittableCompressedFileProducesSingleSplit() throws Exceptio FileSplit.FileSplitCreator.DEFAULT); Assert.assertEquals(1, splits.size()); Split s = splits.get(0); - Assert.assertEquals(10 * MB, ((org.apache.doris.datasource.FileSplit) s).getLength()); + Assert.assertEquals(10 * MB, ((org.apache.doris.datasource.split.FileSplit) s).getLength()); // host should be preserved - Assert.assertArrayEquals(new String[]{"h1"}, ((org.apache.doris.datasource.FileSplit) s).getHosts()); + Assert.assertArrayEquals(new String[]{"h1"}, ((org.apache.doris.datasource.split.FileSplit) s).getHosts()); Assert.assertEquals(DEFAULT_INITIAL_SPLITS - 1, fileSplitter.getRemainingInitialSplitNum()); } @@ -70,7 +70,7 @@ public void testEmptyBlockLocationsProducesSingleSplitAndNullHosts() throws Exce Collections.emptyList(), FileSplit.FileSplitCreator.DEFAULT); Assert.assertEquals(1, splits.size()); - org.apache.doris.datasource.FileSplit s = (org.apache.doris.datasource.FileSplit) splits.get(0); + org.apache.doris.datasource.split.FileSplit s = (org.apache.doris.datasource.split.FileSplit) splits.get(0); Assert.assertEquals(5 * MB, s.getLength()); // hosts should be empty array when passing null Assert.assertNotNull(s.getHosts()); @@ -99,7 +99,7 @@ public void testSplittableSingleBigBlockProducesExpectedSplitsWithInitialSmallCh Assert.assertEquals(expected.length, splits.size()); long sum = 0L; for (int i = 0; i < expected.length; i++) { - org.apache.doris.datasource.FileSplit s = (org.apache.doris.datasource.FileSplit) splits.get(i); + org.apache.doris.datasource.split.FileSplit s = (org.apache.doris.datasource.split.FileSplit) splits.get(i); Assert.assertEquals(expected[i], s.getLength()); sum += s.getLength(); // ensure host preserved 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 new file mode 100644 index 00000000000000..be6ad2a84bdeac --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/split/PluginDrivenSplitPartitionValuesTest.java @@ -0,0 +1,102 @@ +// 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.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; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * {@link PluginDrivenSplit#buildPartitionValues} must NOT collapse an empty partition-value map to {@code null} + * for a partition-bearing range ({@link ConnectorScanRange#isPartitionBearing()} == true). The generic + * {@code FileQueryScanNode} treats a {@code null} partition-value list as "parse partition values from the file + * path" (a Hive-ism): for a connector like Iceberg, whose data files are NOT laid out as {@code key=value} + * directories, that path parse throws {@code UserException} for a partitioned file that happens to have no + * identity partition values (e.g. partition-spec evolution from a transform to identity). Legacy + * {@code IcebergScanNode} never path-parses (it always supplies a non-null empty list). Returning a non-null + * empty list here reproduces that. Non-partition-bearing ranges (the SPI default — paimon and all others) keep + * the legacy empty->null collapse, so this is a zero-regression, opt-in fix. + */ +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(); + } + + @Override + public Map getPartitionValues() { + return partitionValues; + } + + @Override + public boolean isPartitionBearing() { + return partitionBearing; + } + }; + } + + @Test + public void partitionBearingEmptyMapYieldsEmptyListNotNull() { + // The bug: a partitioned Iceberg file with no identity partition values -> empty map -> (old) null -> + // FileQueryScanNode path-parses -> UserException. The fix: non-null empty list -> normalizeColumnsFromPath + // -> no throw. MUTATION: the old `empty -> null` collapse -> getPartitionValues() == null -> red. + PluginDrivenSplit split = new PluginDrivenSplit(range(Collections.emptyMap(), true)); + Assertions.assertNotNull(split.getPartitionValues(), + "a partition-bearing range must not collapse an empty map to null (would trigger path parsing)"); + Assertions.assertTrue(split.getPartitionValues().isEmpty()); + } + + @Test + public void nonPartitionBearingEmptyMapStaysNull() { + // The SPI default (paimon + every other connector): empty map -> null (legacy collapse preserved). + // MUTATION: dropping the isPartitionBearing gate -> empty list -> changes paimon behavior -> red. + PluginDrivenSplit split = new PluginDrivenSplit(range(Collections.emptyMap(), false)); + Assertions.assertNull(split.getPartitionValues(), + "non-partition-bearing ranges must keep the legacy empty->null collapse (no paimon regression)"); + } + + @Test + public void nullMapStaysNull() { + // A genuinely null map is always null regardless of the flag. + Assertions.assertNull(new PluginDrivenSplit(range(null, true)).getPartitionValues()); + Assertions.assertNull(new PluginDrivenSplit(range(null, false)).getPartitionValues()); + } + + @Test + public void nonEmptyMapYieldsValuesInOrder() { + Map parts = new LinkedHashMap<>(); + parts.put("a", "1"); + parts.put("b", "2"); + PluginDrivenSplit split = new PluginDrivenSplit(range(parts, true)); + Assertions.assertEquals(java.util.Arrays.asList("1", "2"), split.getPartitionValues()); + } +} 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 new file mode 100644 index 00000000000000..acf3b09f36b210 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/split/PluginDrivenSplitWeightTest.java @@ -0,0 +1,117 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.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; + +import java.util.Collections; +import java.util.Map; + +/** + * FIX-A1: {@link PluginDrivenSplit} must thread the connector's proportional split weight + * ({@link ConnectorScanRange#getSelfSplitWeight()} / {@link ConnectorScanRange#getTargetSplitSize()}) into + * the {@link FileSplit} scheduling fields so {@code FederationBackendPolicy} distributes splits by size + * (legacy paimon parity), and must fall back to the uniform {@link SplitWeight#standard()} when the + * connector provides no weight (the {@code -1} SPI sentinel — no-regression for other connectors). + * + *

    Assertions pin the EXACT {@code getRawValue()} so the RED state (fields unset → standard, rawValue + * 100) is always distinguishable from the GREEN proportional value (50 / 1). {@code fromProportion} uses + * {@code ceil(weight * 100)} (SplitWeight:74), and FileSplit clamps the proportion to [0.01, 1.0] + * (FileSplit:106-112). + */ +public class PluginDrivenSplitWeightTest { + + /** Minimal fake range exposing only the two weight getters (+ the two required methods). */ + 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(); + } + + @Override + public long getSelfSplitWeight() { + return selfWeight; + } + + @Override + public long getTargetSplitSize() { + return targetSize; + } + }; + } + + @Test + public void proportionalWeightWhenConnectorProvidesBoth() { + // W=50 / T=100 -> proportion 0.5 -> fromProportion rawValue = ceil(50) = 50 (NOT standard's 100). + // WHY: legacy paimon set selfSplitWeight + targetSplitSize so FederationBackendPolicy weighted + // splits by size; the SPI must reproduce that. MUTATION: the ctor not threading the fields -> + // getSplitWeight() == standard() (rawValue 100) -> red. + PluginDrivenSplit split = new PluginDrivenSplit(range(50L, 100L)); + Assertions.assertEquals(50L, split.getSplitWeight().getRawValue(), + "a weighted range must yield a proportional (non-standard) split weight"); + } + + @Test + public void proportionalWeightClampsToLowerBound() { + // W=1 / T=100 -> 0.01 floor clamp -> fromProportion(0.01) rawValue = ceil(1) = 1 (NOT 0, NOT 100). + PluginDrivenSplit split = new PluginDrivenSplit(range(1L, 100L)); + Assertions.assertEquals(1L, split.getSplitWeight().getRawValue(), + "a tiny weight must clamp to the 0.01 lower bound (rawValue 1), not collapse to 0"); + } + + @Test + public void zeroWeightIsValidAndProportional() { + // W=0 (empty file / 0-row sys table) is a legitimate weight, not "unset": 0/100 -> clamp 0.01 -> + // rawValue 1. The gate is weight>=0 (NOT >0), so a genuine 0 still produces a clamped proportional + // weight. MUTATION: a weight>0 gate would drop 0-weight splits back to standard() (rawValue 100). + PluginDrivenSplit split = new PluginDrivenSplit(range(0L, 100L)); + Assertions.assertEquals(1L, split.getSplitWeight().getRawValue(), + "weight 0 is valid (>=0 gate) and clamps to 0.01, matching legacy"); + } + + @Test + public void standardWeightWhenConnectorProvidesNoWeight() { + // The -1 SPI sentinel (a connector with no weight model: jdbc/es/trino/maxcompute) -> both FileSplit + // fields stay null -> standard() (rawValue 100). The no-regression guarantee. + PluginDrivenSplit split = new PluginDrivenSplit(range(-1L, -1L)); + Assertions.assertEquals(100L, split.getSplitWeight().getRawValue(), + "no connector weight (-1 sentinel) must keep the uniform standard() weight"); + Assertions.assertSame(SplitWeight.standard(), split.getSplitWeight()); + } + + @Test + public void standardWeightWhenOnlyOneFieldProvided() { + // Proportional weight needs BOTH a weight and a POSITIVE denominator (target>0 guards div-by-zero). + Assertions.assertEquals(100L, + new PluginDrivenSplit(range(50L, -1L)).getSplitWeight().getRawValue(), + "a weight with no target denominator must stay standard()"); + Assertions.assertEquals(100L, + new PluginDrivenSplit(range(-1L, 100L)).getSplitWeight().getRawValue(), + "a target with no weight must stay standard()"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/SplitAssignmentTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/split/SplitAssignmentTest.java similarity index 99% rename from fe/fe-core/src/test/java/org/apache/doris/datasource/SplitAssignmentTest.java rename to fe/fe-core/src/test/java/org/apache/doris/datasource/split/SplitAssignmentTest.java index 18a03171d2f4ef..b3eb77158b3524 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/SplitAssignmentTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/split/SplitAssignmentTest.java @@ -15,9 +15,10 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource; +package org.apache.doris.datasource.split; import org.apache.doris.common.UserException; +import org.apache.doris.datasource.scan.FederationBackendPolicy; import org.apache.doris.spi.Split; import org.apache.doris.system.Backend; import org.apache.doris.thrift.TScanRangeLocations; diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/systable/IcebergSysTableResolverTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/systable/IcebergSysTableResolverTest.java deleted file mode 100644 index ef7380a6a7f708..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/systable/IcebergSysTableResolverTest.java +++ /dev/null @@ -1,78 +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.datasource.systable; - -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergSysExternalTable; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.Optional; - -public class IcebergSysTableResolverTest { - private IcebergExternalCatalog catalog = Mockito.mock(IcebergExternalCatalog.class); - private IcebergExternalDatabase db = Mockito.mock(IcebergExternalDatabase.class); - - @Test - public void testSupportedSysTablesIncludePositionDeletes() { - Assertions.assertTrue(IcebergSysTable.SUPPORTED_SYS_TABLES.containsKey("position_deletes")); - } - - @Test - public void testResolveForPlanAndDescribeUseNativePath() throws Exception { - IcebergExternalTable sourceTable = newIcebergTable(); - - Optional plan = SysTableResolver.resolveForPlan( - sourceTable, "test_ctl", "test_db", "tbl$snapshots"); - Assertions.assertTrue(plan.isPresent()); - Assertions.assertTrue(plan.get().isNative()); - Assertions.assertTrue(plan.get().getSysExternalTable() instanceof IcebergSysExternalTable); - Assertions.assertEquals("tbl$snapshots", plan.get().getSysExternalTable().getName()); - - Optional describe = SysTableResolver.resolveForDescribe( - sourceTable, "test_ctl", "test_db", "tbl$snapshots"); - Assertions.assertTrue(describe.isPresent()); - Assertions.assertTrue(describe.get().isNative()); - Assertions.assertTrue(describe.get().getSysExternalTable() instanceof IcebergSysExternalTable); - Assertions.assertEquals("tbl$snapshots", describe.get().getSysExternalTable().getName()); - } - - @Test - public void testPositionDeletesUseNativePath() throws Exception { - IcebergExternalTable sourceTable = newIcebergTable(); - Optional plan = SysTableResolver.resolveForPlan( - sourceTable, "test_ctl", "test_db", "tbl$position_deletes"); - Assertions.assertTrue(plan.isPresent()); - Assertions.assertTrue(plan.get().isNative()); - Assertions.assertTrue(plan.get().getSysExternalTable() instanceof IcebergSysExternalTable); - Assertions.assertEquals("tbl$position_deletes", plan.get().getSysExternalTable().getName()); - } - - private IcebergExternalTable newIcebergTable() throws Exception { - Mockito.when(catalog.getId()).thenReturn(1L); - Mockito.when(db.getFullName()).thenReturn("test_db"); - Mockito.when(db.getRemoteName()).thenReturn("test_db"); - Mockito.doReturn(db).when(catalog).getDbOrAnalysisException("test_db"); - Mockito.when(db.getId()).thenReturn(2L); - return new IcebergExternalTable(3L, "tbl", "tbl", catalog, db); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorPredicateTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorPredicateTest.java deleted file mode 100644 index d01b1ae485b1db..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorPredicateTest.java +++ /dev/null @@ -1,736 +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.datasource.trinoconnector; - -import org.apache.doris.analysis.BinaryPredicate; -import org.apache.doris.analysis.BinaryPredicate.Operator; -import org.apache.doris.analysis.BoolLiteral; -import org.apache.doris.analysis.CompoundPredicate; -import org.apache.doris.analysis.DateLiteral; -import org.apache.doris.analysis.DecimalLiteral; -import org.apache.doris.analysis.Expr; -import org.apache.doris.analysis.FloatLiteral; -import org.apache.doris.analysis.InPredicate; -import org.apache.doris.analysis.IntLiteral; -import org.apache.doris.analysis.LiteralExpr; -import org.apache.doris.analysis.NullLiteral; -import org.apache.doris.analysis.SlotRef; -import org.apache.doris.analysis.StringLiteral; -import org.apache.doris.catalog.ScalarType; -import org.apache.doris.catalog.Type; -import org.apache.doris.catalog.info.TableNameInfo; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.datasource.trinoconnector.source.TrinoConnectorPredicateConverter; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Lists; -import io.airlift.slice.Slices; -import io.trino.spi.connector.ColumnHandle; -import io.trino.spi.connector.ColumnMetadata; -import io.trino.spi.predicate.Domain; -import io.trino.spi.predicate.Range; -import io.trino.spi.predicate.TupleDomain; -import io.trino.spi.predicate.ValueSet; -import io.trino.spi.type.BigintType; -import io.trino.spi.type.BooleanType; -import io.trino.spi.type.CharType; -import io.trino.spi.type.DateType; -import io.trino.spi.type.DecimalType; -import io.trino.spi.type.DoubleType; -import io.trino.spi.type.Int128; -import io.trino.spi.type.IntegerType; -import io.trino.spi.type.LongTimestamp; -import io.trino.spi.type.LongTimestampWithTimeZone; -import io.trino.spi.type.RealType; -import io.trino.spi.type.SmallintType; -import io.trino.spi.type.TimeZoneKey; -import io.trino.spi.type.TimestampType; -import io.trino.spi.type.TimestampWithTimeZoneType; -import io.trino.spi.type.TinyintType; -import io.trino.spi.type.VarbinaryType; -import io.trino.spi.type.VarcharType; -import org.junit.Assert; -import org.junit.BeforeClass; -import org.junit.Test; - -import java.math.BigDecimal; -import java.math.BigInteger; -import java.util.List; -import java.util.Objects; - -public class TrinoConnectorPredicateTest { - - private static final ImmutableMap trinoConnectorColumnHandleMap = - new ImmutableMap.Builder() - .put("c_bool", new MockColumnHandle("c_bool")) - .put("c_tinyint", new MockColumnHandle("c_tinyint")) - .put("c_smallint", new MockColumnHandle("c_smallint")) - .put("c_int", new MockColumnHandle("c_int")) - .put("c_bigint", new MockColumnHandle("c_bigint")) - .put("c_real", new MockColumnHandle("c_real")) - .put("c_short_decimal", new MockColumnHandle("c_short_decimal")) - .put("c_long_decimal", new MockColumnHandle("c_long_decimal")) - .put("c_char", new MockColumnHandle("c_char")) - .put("c_varchar", new MockColumnHandle("c_varchar")) - .put("c_varbinary", new MockColumnHandle("c_varbinary")) - .put("c_date", new MockColumnHandle("c_date")) - .put("c_double", new MockColumnHandle("c_double")) - .put("c_short_timestamp", new MockColumnHandle("c_short_timestamp")) - // .put("c_short_timestamp_timezone", new MockColumnHandle("c_short_timestamp_timezone")) - .put("c_long_timestamp", new MockColumnHandle("c_long_timestamp")) - .put("c_long_timestamp_timezone", new MockColumnHandle("c_long_timestamp_timezone")) - .build(); - - private static final ImmutableMap trinoConnectorColumnMetadataMap = - new ImmutableMap.Builder() - .put("c_bool", new ColumnMetadata("c_bool", BooleanType.BOOLEAN)) - .put("c_tinyint", new ColumnMetadata("c_tinyint", TinyintType.TINYINT)) - .put("c_smallint", new ColumnMetadata("c_smallint", SmallintType.SMALLINT)) - .put("c_int", new ColumnMetadata("c_int", IntegerType.INTEGER)) - .put("c_bigint", new ColumnMetadata("c_bigint", BigintType.BIGINT)) - .put("c_real", new ColumnMetadata("c_real", RealType.REAL)) - .put("c_short_decimal", new ColumnMetadata("c_short_decimal", - DecimalType.createDecimalType(9, 2))) - .put("c_long_decimal", new ColumnMetadata("c_long_decimal", - DecimalType.createDecimalType(38, 15))) - .put("c_char", new ColumnMetadata("c_char", CharType.createCharType(128))) - .put("c_varchar", new ColumnMetadata("c_varchar", - VarcharType.createVarcharType(128))) - .put("c_varbinary", new ColumnMetadata("c_varbinary", VarbinaryType.VARBINARY)) - .put("c_date", new ColumnMetadata("c_date", DateType.DATE)) - .put("c_double", new ColumnMetadata("c_double", DoubleType.DOUBLE)) - .put("c_short_timestamp", new ColumnMetadata("c_short_timestamp", - TimestampType.TIMESTAMP_MICROS)) - // .put("c_short_timestamp_timezone", new ColumnMetadata("c_short_timestamp_timezone", - // TimestampWithTimeZoneType.TIMESTAMP_TZ_MILLIS)) - .put("c_long_timestamp", new ColumnMetadata("c_long_timestamp", - TimestampType.TIMESTAMP_PICOS)) - .put("c_long_timestamp_timezone", new ColumnMetadata("c_long_timestamp_timezone", - TimestampWithTimeZoneType.TIMESTAMP_TZ_PICOS)) - .build(); - - private static TrinoConnectorPredicateConverter trinoConnectorPredicateConverter; - - @BeforeClass - public static void before() throws AnalysisException { - trinoConnectorPredicateConverter = new TrinoConnectorPredicateConverter( - trinoConnectorColumnHandleMap, - trinoConnectorColumnMetadataMap); - } - - @Test - public void testBinaryEqPredicate() throws AnalysisException { - // construct slotRefs and literalLists - List slotRefs = mockSlotRefs(); - List literalList = mockLiteralExpr(); - - // expect results - List> expectTupleDomain = Lists.newArrayList(); - ImmutableList expectRanges = new ImmutableList.Builder() - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_bool").getType(), true)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_tinyint").getType(), 1L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_smallint").getType(), 1L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_int").getType(), 1L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_bigint").getType(), 1L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_real").getType(), - Long.valueOf(Float.floatToIntBits(1.23f)))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_double").getType(), 3.1415926456)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_short_decimal").getType(), 12345623L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_long_decimal").getType(), - Int128.valueOf(new BigInteger("12345678901234567890123123")))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_char").getType(), - Slices.utf8Slice("trino connector char test"))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_varchar").getType(), - Slices.utf8Slice("trino connector varchar test"))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_varbinary").getType(), - Slices.utf8Slice("trino connector varbinary test"))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_date").getType(), -1L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_short_timestamp").getType(), - 1000001L)) - // .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_short_timestamp_timezone").getType(), - // 0L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_long_timestamp").getType(), - new LongTimestamp(1000001L, 0))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_long_timestamp_timezone").getType(), - LongTimestampWithTimeZone.fromEpochMillisAndFraction(1000L, 1000000, - TimeZoneKey.getTimeZoneKey("Asia/Shanghai")))) - .build(); - for (int i = 0; i < slotRefs.size(); i++) { - final String colName = slotRefs.get(i).getColumnName(); - Domain domain = Domain.create(ValueSet.ofRanges(Lists.newArrayList(expectRanges.get(i))), false); - TupleDomain tupleDomain = TupleDomain.withColumnDomains( - ImmutableMap.of(trinoConnectorColumnHandleMap.get(colName), domain)); - expectTupleDomain.add(tupleDomain); - } - - // test results, construct equal binary predicate - List> testTupleDomain = Lists.newArrayList(); - for (int i = 0; i < slotRefs.size(); i++) { - BinaryPredicate expr = new BinaryPredicate(BinaryPredicate.Operator.EQ, slotRefs.get(i), - literalList.get(i)); - TupleDomain tupleDomain = trinoConnectorPredicateConverter.convertExprToTrinoTupleDomain( - expr); - testTupleDomain.add(tupleDomain); - } - - // verify if `testTupleDomain` is equal to `expectTupleDomain`. - for (int i = 0; i < expectTupleDomain.size(); i++) { - Assert.assertTrue(expectTupleDomain.get(i).contains(testTupleDomain.get(i))); - } - } - - @Test - public void testBinaryEqualForNullPredicate() throws AnalysisException { - // construct slotRefs and literalLists - List slotRefs = mockSlotRefs(); - List literalList = mockLiteralExpr(); - - // expect results - List> expectTupleDomain = Lists.newArrayList(); - ImmutableList expectRanges = new ImmutableList.Builder() - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_bool").getType(), true)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_tinyint").getType(), 1L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_smallint").getType(), 1L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_int").getType(), 1L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_bigint").getType(), 1L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_real").getType(), - Long.valueOf(Float.floatToIntBits(1.23f)))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_double").getType(), 3.1415926456)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_short_decimal").getType(), 12345623L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_long_decimal").getType(), - Int128.valueOf(new BigInteger("12345678901234567890123123")))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_char").getType(), - Slices.utf8Slice("trino connector char test"))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_varchar").getType(), - Slices.utf8Slice("trino connector varchar test"))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_varbinary").getType(), - Slices.utf8Slice("trino connector varbinary test"))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_date").getType(), -1L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_short_timestamp").getType(), - 1000001L)) - // .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_short_timestamp_timezone").getType(), - // 0L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_long_timestamp").getType(), - new LongTimestamp(1000001L, 0))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_long_timestamp_timezone").getType(), - LongTimestampWithTimeZone.fromEpochMillisAndFraction(1000L, 1000000, - TimeZoneKey.getTimeZoneKey("Asia/Shanghai")))) - .build(); - for (int i = 0; i < slotRefs.size(); i++) { - final String colName = slotRefs.get(i).getColumnName(); - Domain domain = Domain.create(ValueSet.ofRanges(Lists.newArrayList(expectRanges.get(i))), false); - TupleDomain tupleDomain = TupleDomain.withColumnDomains( - ImmutableMap.of(trinoConnectorColumnHandleMap.get(colName), domain)); - expectTupleDomain.add(tupleDomain); - } - - // test results, construct equal binary predicate - List> testTupleDomain = Lists.newArrayList(); - for (int i = 0; i < slotRefs.size(); i++) { - BinaryPredicate expr = new BinaryPredicate(Operator.EQ_FOR_NULL, slotRefs.get(i), - literalList.get(i)); - TupleDomain tupleDomain = trinoConnectorPredicateConverter.convertExprToTrinoTupleDomain( - expr); - testTupleDomain.add(tupleDomain); - } - - // verify if `testTupleDomain` is equal to `expectTupleDomain`. - for (int i = 0; i < expectTupleDomain.size(); i++) { - Assert.assertTrue(expectTupleDomain.get(i).contains(testTupleDomain.get(i))); - } - - // test <=> - SlotRef intSlot = new SlotRef(new TableNameInfo("test_table"), "c_int"); - NullLiteral nullLiteral = NullLiteral.create(Type.INT); - BinaryPredicate expr = new BinaryPredicate(Operator.EQ_FOR_NULL, intSlot, nullLiteral); - TupleDomain testNullTupleDomain = trinoConnectorPredicateConverter.convertExprToTrinoTupleDomain( - expr); - TupleDomain expectNullTupleDomain = TupleDomain.withColumnDomains( - ImmutableMap.of(trinoConnectorColumnHandleMap.get("c_int"), Domain.onlyNull(IntegerType.INTEGER))); - Assert.assertTrue(expectNullTupleDomain.contains(testNullTupleDomain)); - } - - @Test - public void testBinaryLessThanPredicate() throws AnalysisException { - // construct slotRefs and literalLists - List slotRefs = mockSlotRefs(); - List literalList = mockLiteralExpr(); - - // expect results - List> expectTupleDomain = Lists.newArrayList(); - ImmutableList expectRanges = new ImmutableList.Builder() - .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_bool").getType(), true)) - .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_tinyint").getType(), 1L)) - .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_smallint").getType(), 1L)) - .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_int").getType(), 1L)) - .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_bigint").getType(), 1L)) - .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_real").getType(), - Long.valueOf(Float.floatToIntBits(1.23f)))) - .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_double").getType(), 3.1415926456)) - .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_short_decimal").getType(), 12345623L)) - .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_long_decimal").getType(), - Int128.valueOf(new BigInteger("12345678901234567890123123")))) - .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_char").getType(), - Slices.utf8Slice("trino connector char test"))) - .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_varchar").getType(), - Slices.utf8Slice("trino connector varchar test"))) - .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_varbinary").getType(), - Slices.utf8Slice("trino connector varbinary test"))) - .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_date").getType(), -1L)) - .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_short_timestamp").getType(), - 1000001L)) - // .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_short_timestamp_timezone").getType(), - // 0L)) - .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_long_timestamp").getType(), - new LongTimestamp(1000001L, 0))) - .add(Range.lessThan(trinoConnectorColumnMetadataMap.get("c_long_timestamp_timezone").getType(), - LongTimestampWithTimeZone.fromEpochMillisAndFraction(1000L, 1000000, - TimeZoneKey.getTimeZoneKey("Asia/Shanghai")))) - .build(); - for (int i = 0; i < slotRefs.size(); i++) { - final String colName = slotRefs.get(i).getColumnName(); - Domain domain = Domain.create(ValueSet.ofRanges(Lists.newArrayList(expectRanges.get(i))), false); - TupleDomain tupleDomain = TupleDomain.withColumnDomains( - ImmutableMap.of(trinoConnectorColumnHandleMap.get(colName), domain)); - expectTupleDomain.add(tupleDomain); - } - - // test results, construct lessThan binary predicate - List> testTupleDomain = Lists.newArrayList(); - for (int i = 0; i < slotRefs.size(); i++) { - BinaryPredicate expr = new BinaryPredicate(Operator.LT, slotRefs.get(i), - literalList.get(i)); - TupleDomain tupleDomain = trinoConnectorPredicateConverter.convertExprToTrinoTupleDomain( - expr); - testTupleDomain.add(tupleDomain); - } - - // verify if `testTupleDomain` is equal to `expectTupleDomain`. - for (int i = 0; i < expectTupleDomain.size(); i++) { - Assert.assertTrue(expectTupleDomain.get(i).contains(testTupleDomain.get(i))); - } - } - - @Test - public void testBinaryLessEqualPredicate() throws AnalysisException { - // construct slotRefs and literalLists - List slotRefs = mockSlotRefs(); - List literalList = mockLiteralExpr(); - - // expect results - List> expectTupleDomain = Lists.newArrayList(); - ImmutableList expectRanges = new ImmutableList.Builder() - .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_bool").getType(), true)) - .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_tinyint").getType(), 1L)) - .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_smallint").getType(), 1L)) - .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_int").getType(), 1L)) - .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_bigint").getType(), 1L)) - .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_real").getType(), - Long.valueOf(Float.floatToIntBits(1.23f)))) - .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_double").getType(), 3.1415926456)) - .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_short_decimal").getType(), 12345623L)) - .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_long_decimal").getType(), - Int128.valueOf(new BigInteger("12345678901234567890123123")))) - .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_char").getType(), - Slices.utf8Slice("trino connector char test"))) - .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_varchar").getType(), - Slices.utf8Slice("trino connector varchar test"))) - .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_varbinary").getType(), - Slices.utf8Slice("trino connector varbinary test"))) - .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_date").getType(), -1L)) - .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_short_timestamp").getType(), - 1000001L)) - // .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_short_timestamp_timezone").getType(), - // 0L)) - .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_long_timestamp").getType(), - new LongTimestamp(1000001L, 0))) - .add(Range.lessThanOrEqual(trinoConnectorColumnMetadataMap.get("c_long_timestamp_timezone").getType(), - LongTimestampWithTimeZone.fromEpochMillisAndFraction(1000L, 1000000, - TimeZoneKey.getTimeZoneKey("Asia/Shanghai")))) - .build(); - for (int i = 0; i < slotRefs.size(); i++) { - final String colName = slotRefs.get(i).getColumnName(); - Domain domain = Domain.create(ValueSet.ofRanges(Lists.newArrayList(expectRanges.get(i))), false); - TupleDomain tupleDomain = TupleDomain.withColumnDomains( - ImmutableMap.of(trinoConnectorColumnHandleMap.get(colName), domain)); - expectTupleDomain.add(tupleDomain); - } - - // test results, construct lessThanOrEqual binary predicate - List> testTupleDomain = Lists.newArrayList(); - for (int i = 0; i < slotRefs.size(); i++) { - BinaryPredicate expr = new BinaryPredicate(Operator.LE, slotRefs.get(i), - literalList.get(i)); - TupleDomain tupleDomain = trinoConnectorPredicateConverter.convertExprToTrinoTupleDomain( - expr); - testTupleDomain.add(tupleDomain); - } - - // verify if `testTupleDomain` is equal to `expectTupleDomain`. - for (int i = 0; i < expectTupleDomain.size(); i++) { - Assert.assertTrue(expectTupleDomain.get(i).contains(testTupleDomain.get(i))); - } - } - - @Test - public void testBinaryGreatThanPredicate() throws AnalysisException { - // construct slotRefs and literalLists - List slotRefs = mockSlotRefs(); - List literalList = mockLiteralExpr(); - - // expect results - List> expectTupleDomain = Lists.newArrayList(); - ImmutableList expectRanges = new ImmutableList.Builder() - .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_bool").getType(), true)) - .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_tinyint").getType(), 1L)) - .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_smallint").getType(), 1L)) - .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_int").getType(), 1L)) - .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_bigint").getType(), 1L)) - .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_real").getType(), - Long.valueOf(Float.floatToIntBits(1.23f)))) - .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_double").getType(), 3.1415926456)) - .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_short_decimal").getType(), 12345623L)) - .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_long_decimal").getType(), - Int128.valueOf(new BigInteger("12345678901234567890123123")))) - .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_char").getType(), - Slices.utf8Slice("trino connector char test"))) - .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_varchar").getType(), - Slices.utf8Slice("trino connector varchar test"))) - .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_varbinary").getType(), - Slices.utf8Slice("trino connector varbinary test"))) - .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_date").getType(), -1L)) - .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_short_timestamp").getType(), - 1000001L)) - // .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_short_timestamp_timezone").getType(), - // 0L)) - .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_long_timestamp").getType(), - new LongTimestamp(1000001L, 0))) - .add(Range.greaterThan(trinoConnectorColumnMetadataMap.get("c_long_timestamp_timezone").getType(), - LongTimestampWithTimeZone.fromEpochMillisAndFraction(1000L, 1000000, - TimeZoneKey.getTimeZoneKey("Asia/Shanghai")))) - .build(); - for (int i = 0; i < slotRefs.size(); i++) { - final String colName = slotRefs.get(i).getColumnName(); - Domain domain = Domain.create(ValueSet.ofRanges(Lists.newArrayList(expectRanges.get(i))), false); - TupleDomain tupleDomain = TupleDomain.withColumnDomains( - ImmutableMap.of(trinoConnectorColumnHandleMap.get(colName), domain)); - expectTupleDomain.add(tupleDomain); - } - - // test results, construct greaterThan binary predicate - List> testTupleDomain = Lists.newArrayList(); - for (int i = 0; i < slotRefs.size(); i++) { - BinaryPredicate expr = new BinaryPredicate(Operator.GT, slotRefs.get(i), - literalList.get(i)); - TupleDomain tupleDomain = trinoConnectorPredicateConverter.convertExprToTrinoTupleDomain( - expr); - testTupleDomain.add(tupleDomain); - } - - // verify if `testTupleDomain` is equal to `expectTupleDomain`. - for (int i = 0; i < expectTupleDomain.size(); i++) { - Assert.assertTrue(expectTupleDomain.get(i).contains(testTupleDomain.get(i))); - } - } - - @Test - public void testBinaryGreaterEqualPredicate() throws AnalysisException { - // construct slotRefs and literalLists - List slotRefs = mockSlotRefs(); - List literalList = mockLiteralExpr(); - - // expect results - List> expectTupleDomain = Lists.newArrayList(); - ImmutableList expectRanges = new ImmutableList.Builder() - .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_bool").getType(), true)) - .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_tinyint").getType(), 1L)) - .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_smallint").getType(), 1L)) - .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_int").getType(), 1L)) - .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_bigint").getType(), 1L)) - .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_real").getType(), - Long.valueOf(Float.floatToIntBits(1.23f)))) - .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_double").getType(), 3.1415926456)) - .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_short_decimal").getType(), 12345623L)) - .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_long_decimal").getType(), - Int128.valueOf(new BigInteger("12345678901234567890123123")))) - .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_char").getType(), - Slices.utf8Slice("trino connector char test"))) - .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_varchar").getType(), - Slices.utf8Slice("trino connector varchar test"))) - .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_varbinary").getType(), - Slices.utf8Slice("trino connector varbinary test"))) - .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_date").getType(), -1L)) - .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_short_timestamp").getType(), - 1000001L)) - // .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_short_timestamp_timezone").getType(), - // 0L)) - .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_long_timestamp").getType(), - new LongTimestamp(1000001L, 0))) - .add(Range.greaterThanOrEqual(trinoConnectorColumnMetadataMap.get("c_long_timestamp_timezone").getType(), - LongTimestampWithTimeZone.fromEpochMillisAndFraction(1000L, 1000000, - TimeZoneKey.getTimeZoneKey("Asia/Shanghai")))) - .build(); - for (int i = 0; i < slotRefs.size(); i++) { - final String colName = slotRefs.get(i).getColumnName(); - Domain domain = Domain.create(ValueSet.ofRanges(Lists.newArrayList(expectRanges.get(i))), false); - TupleDomain tupleDomain = TupleDomain.withColumnDomains( - ImmutableMap.of(trinoConnectorColumnHandleMap.get(colName), domain)); - expectTupleDomain.add(tupleDomain); - } - - // test results, construct greaterThanOrEqual binary predicate - List> testTupleDomain = Lists.newArrayList(); - for (int i = 0; i < slotRefs.size(); i++) { - BinaryPredicate expr = new BinaryPredicate(Operator.GE, slotRefs.get(i), - literalList.get(i)); - TupleDomain tupleDomain = trinoConnectorPredicateConverter.convertExprToTrinoTupleDomain( - expr); - testTupleDomain.add(tupleDomain); - } - - // verify if `testTupleDomain` is equal to `expectTupleDomain`. - for (int i = 0; i < expectTupleDomain.size(); i++) { - Assert.assertTrue(expectTupleDomain.get(i).contains(testTupleDomain.get(i))); - } - } - - @Test - public void testInPredicate() throws AnalysisException { - // construct slotRefs and literalLists - List slotRefs = mockSlotRefs(); - List literalList = mockLiteralExpr(); - - // expect results - List> expectInTupleDomain = Lists.newArrayList(); - List> expectNotInTupleDomain = Lists.newArrayList(); - ImmutableList expectRanges = new ImmutableList.Builder() - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_bool").getType(), true)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_tinyint").getType(), 1L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_smallint").getType(), 1L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_int").getType(), 1L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_bigint").getType(), 1L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_real").getType(), - Long.valueOf(Float.floatToIntBits(1.23f)))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_double").getType(), 3.1415926456)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_short_decimal").getType(), 12345623L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_long_decimal").getType(), - Int128.valueOf(new BigInteger("12345678901234567890123123")))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_char").getType(), - Slices.utf8Slice("trino connector char test"))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_varchar").getType(), - Slices.utf8Slice("trino connector varchar test"))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_varbinary").getType(), - Slices.utf8Slice("trino connector varbinary test"))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_date").getType(), -1L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_short_timestamp").getType(), - 1000001L)) - // .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_short_timestamp_timezone").getType(), - // 0L)) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_long_timestamp").getType(), - new LongTimestamp(1000001L, 0))) - .add(Range.equal(trinoConnectorColumnMetadataMap.get("c_long_timestamp_timezone").getType(), - LongTimestampWithTimeZone.fromEpochMillisAndFraction(1000L, 1000000, - TimeZoneKey.getTimeZoneKey("Asia/Shanghai")))) - .build(); - - for (int i = 0; i < slotRefs.size(); i++) { - final String colName = slotRefs.get(i).getColumnName(); - Domain inDomain = Domain.create( - ValueSet.ofRanges(Lists.newArrayList(expectRanges.get(i))), false); - Domain notInDomain = Domain.create(ValueSet.all(trinoConnectorColumnMetadataMap.get(colName).getType()) - .subtract(ValueSet.ofRanges(expectRanges.get(i))), false); - TupleDomain inTupleDomain = TupleDomain.withColumnDomains( - ImmutableMap.of(trinoConnectorColumnHandleMap.get(colName), inDomain)); - TupleDomain notInTupleDomain = TupleDomain.withColumnDomains( - ImmutableMap.of(trinoConnectorColumnHandleMap.get(colName), notInDomain)); - expectInTupleDomain.add(inTupleDomain); - expectNotInTupleDomain.add(notInTupleDomain); - } - - // test results, construct equal binary predicate - List> testTupleDomain = Lists.newArrayList(); - for (int i = 0; i < slotRefs.size(); i++) { - InPredicate expr = new InPredicate(slotRefs.get(i), Lists.newArrayList(literalList.get(i)), false); - TupleDomain tupleDomain = trinoConnectorPredicateConverter.convertExprToTrinoTupleDomain( - expr); - testTupleDomain.add(tupleDomain); - } - // verify if `testTupleDomain` is equal to `expectTupleDomain`. - for (int i = 0; i < expectInTupleDomain.size(); i++) { - Assert.assertTrue(expectInTupleDomain.get(i).contains(testTupleDomain.get(i))); - } - - testTupleDomain.clear(); - for (int i = 0; i < slotRefs.size(); i++) { - InPredicate expr = new InPredicate(slotRefs.get(i), Lists.newArrayList(literalList.get(i)), true); - TupleDomain tupleDomain = trinoConnectorPredicateConverter.convertExprToTrinoTupleDomain( - expr); - testTupleDomain.add(tupleDomain); - } - // verify if `testTupleDomain` is equal to `expectTupleDomain`. - for (int i = 0; i < expectNotInTupleDomain.size(); i++) { - Assert.assertTrue(expectNotInTupleDomain.get(i).contains(testTupleDomain.get(i))); - } - } - - @Test - public void testCompoundPredicate() throws AnalysisException { - // construct slotRefs and literalLists - List slotRefs = mockSlotRefs(); - List literalList = mockLiteralExpr(); - - // valid expr - List validExprs = Lists.newArrayList(); - for (int i = 0; i < slotRefs.size(); i++) { - BinaryPredicate expr = new BinaryPredicate(BinaryPredicate.Operator.EQ, slotRefs.get(i), - literalList.get(i)); - validExprs.add(expr); - } - - // invalid expr - BinaryPredicate invalidExpr = new BinaryPredicate(BinaryPredicate.Operator.EQ, - literalList.get(0), literalList.get(0)); - - // AND - // valid AND valid - for (int i = 0; i < validExprs.size(); i++) { - for (int j = 0; j < validExprs.size(); j++) { - CompoundPredicate andPredicate = new CompoundPredicate(CompoundPredicate.Operator.AND, - validExprs.get(i), validExprs.get(j)); - trinoConnectorPredicateConverter.convertExprToTrinoTupleDomain(andPredicate); - } - } - - // valid AND invalid - CompoundPredicate andPredicate = new CompoundPredicate(CompoundPredicate.Operator.AND, - validExprs.get(0), invalidExpr); - trinoConnectorPredicateConverter.convertExprToTrinoTupleDomain(andPredicate); - - // invalid AND valid - andPredicate = new CompoundPredicate(CompoundPredicate.Operator.AND, invalidExpr, validExprs.get(0)); - trinoConnectorPredicateConverter.convertExprToTrinoTupleDomain(andPredicate); - - // invalid AND invalid - andPredicate = new CompoundPredicate(CompoundPredicate.Operator.AND, invalidExpr, invalidExpr); - try { - trinoConnectorPredicateConverter.convertExprToTrinoTupleDomain(andPredicate); - } catch (AnalysisException e) { - Assert.assertTrue(e.getMessage().contains("Can not convert both sides of compound predicate")); - } - - // OR - // valid OR valid - for (int i = 0; i < validExprs.size(); i++) { - for (int j = 0; j < validExprs.size(); j++) { - CompoundPredicate orPredicate = new CompoundPredicate(CompoundPredicate.Operator.OR, - validExprs.get(i), validExprs.get(j)); - trinoConnectorPredicateConverter.convertExprToTrinoTupleDomain(orPredicate); - } - } - - // // valid OR valid - try { - CompoundPredicate orPredicate = new CompoundPredicate(CompoundPredicate.Operator.AND, - validExprs.get(0), invalidExpr); - trinoConnectorPredicateConverter.convertExprToTrinoTupleDomain(orPredicate); - } catch (AnalysisException e) { - Assert.assertTrue(e.getMessage().contains("slotRef is null in binaryPredicateConverter")); - } - } - - private List mockSlotRefs() { - return new ImmutableList.Builder() - .add(new SlotRef(new TableNameInfo("test_table"), "c_bool")) - - .add(new SlotRef(new TableNameInfo("test_table"), "c_tinyint")) - .add(new SlotRef(new TableNameInfo("test_table"), "c_smallint")) - .add(new SlotRef(new TableNameInfo("test_table"), "c_int")) - .add(new SlotRef(new TableNameInfo("test_table"), "c_bigint")) - - .add(new SlotRef(new TableNameInfo("test_table"), "c_real")) - .add(new SlotRef(new TableNameInfo("test_table"), "c_double")) - - .add(new SlotRef(new TableNameInfo("test_table"), "c_short_decimal")) - .add(new SlotRef(new TableNameInfo("test_table"), "c_long_decimal")) - - .add(new SlotRef(new TableNameInfo("test_table"), "c_char")) - .add(new SlotRef(new TableNameInfo("test_table"), "c_varchar")) - .add(new SlotRef(new TableNameInfo("test_table"), "c_varbinary")) - - .add(new SlotRef(new TableNameInfo("test_table"), "c_date")) - .add(new SlotRef(new TableNameInfo("test_table"), "c_short_timestamp")) - // .add(new SlotRef(new TableName("test_table"), "c_short_timestamp_timezone")) - .add(new SlotRef(new TableNameInfo("test_table"), "c_long_timestamp")) - .add(new SlotRef(new TableNameInfo("test_table"), "c_long_timestamp_timezone")) - .build(); - } - - private List mockLiteralExpr() throws AnalysisException { - return new ImmutableList.Builder() - // boolean - .add(new BoolLiteral(true)) - // Integer - .add(new IntLiteral(1, Type.TINYINT)) - .add(new IntLiteral(1, Type.SMALLINT)) - .add(new IntLiteral(1, Type.INT)) - .add(new IntLiteral(1, Type.BIGINT)) - - .add(new FloatLiteral(1.23, Type.FLOAT)) // Real type - .add(new FloatLiteral(3.1415926456, Type.DOUBLE)) - - .add(new DecimalLiteral(new BigDecimal("123456.23"), ScalarType.createDecimalV3Type(8, 2))) - .add(new DecimalLiteral(new BigDecimal("12345678901234567890123.123"), ScalarType.createDecimalV3Type(26, 3))) - - .add(new StringLiteral("trino connector char test")) - .add(new StringLiteral("trino connector varchar test")) - .add(new StringLiteral("trino connector varbinary test")) - - .add(new DateLiteral(1969, 12, 31, Type.DATEV2)) - .add(new DateLiteral(1970, 1, 1, 0, 0, 1, 1, Type.DATETIMEV2)) - // .add(new DateLiteral(1970, 1, 1, 0, 0, 0, 0, Type.DATETIMEV2)) - .add(new DateLiteral(1970, 1, 1, 0, 0, 1, 1, Type.DATETIMEV2)) - .add(new DateLiteral(1970, 1, 1, 8, 0, 1, 1, Type.DATETIMEV2)) - .build(); - } - - private static class MockColumnHandle implements ColumnHandle { - private String colName; - - MockColumnHandle(String colName) { - this.colName = colName; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - MockColumnHandle that = (MockColumnHandle) o; - return colName.equals(that.colName); - } - - @Override - public int hashCode() { - return Objects.hash(colName); - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/tvf/source/MetadataScanNodeTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/tvf/source/MetadataScanNodeTest.java index 9700c306f51346..4381777cf66a95 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/tvf/source/MetadataScanNodeTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/tvf/source/MetadataScanNodeTest.java @@ -153,7 +153,7 @@ public void testMultipleCallsToGetScanRangeLocations() throws Exception { private void mockBackendPolicy(MetadataScanNode scanNode) throws Exception { // Create a mock backend policy Object mockBackendPolicy = Mockito - .mock(Class.forName("org.apache.doris.datasource.FederationBackendPolicy")); + .mock(Class.forName("org.apache.doris.datasource.scan.FederationBackendPolicy")); // Set up the mock to return our mock backend Mockito.when(mockBackendPolicy.getClass().getMethod("getNextBe").invoke(mockBackendPolicy)) diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/tvf/source/TVFScanNodeTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/tvf/source/TVFScanNodeTest.java index 8ea9041480dadd..8e7cf7e99ddb11 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/tvf/source/TVFScanNodeTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/tvf/source/TVFScanNodeTest.java @@ -21,8 +21,8 @@ import org.apache.doris.analysis.TupleDescriptor; import org.apache.doris.analysis.TupleId; import org.apache.doris.catalog.FunctionGenTable; -import org.apache.doris.datasource.FileQueryScanNode; -import org.apache.doris.datasource.FileSplitter; +import org.apache.doris.datasource.scan.FileQueryScanNode; +import org.apache.doris.datasource.split.FileSplitter; import org.apache.doris.planner.PlanNodeId; import org.apache.doris.planner.ScanContext; import org.apache.doris.qe.SessionVariable; diff --git a/fe/fe-core/src/test/java/org/apache/doris/external/hms/HmsCatalogTest.java b/fe/fe-core/src/test/java/org/apache/doris/external/hms/HmsCatalogTest.java deleted file mode 100644 index 744933d5ee498e..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/external/hms/HmsCatalogTest.java +++ /dev/null @@ -1,285 +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.external.hms; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.common.Config; -import org.apache.doris.common.FeConstants; -import org.apache.doris.common.jmockit.Deencapsulation; -import org.apache.doris.datasource.CatalogMgr; -import org.apache.doris.datasource.InternalCatalog; -import org.apache.doris.datasource.SchemaCacheKey; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalDatabase; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HMSExternalTable.DLAType; -import org.apache.doris.datasource.hive.HMSSchemaCacheValue; -import org.apache.doris.datasource.hive.HiveDlaTable; -import org.apache.doris.nereids.datasets.tpch.AnalyzeCheckTestBase; -import org.apache.doris.nereids.parser.NereidsParser; -import org.apache.doris.nereids.trees.plans.commands.CreateCatalogCommand; -import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; -import org.apache.doris.qe.SessionVariable; - -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import org.junit.Assert; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.List; -import java.util.Optional; - -public class HmsCatalogTest extends AnalyzeCheckTestBase { - private static final String HMS_CATALOG = "hms_ctl"; - private static final long NOW = System.currentTimeMillis(); - private Env env; - private CatalogMgr mgr; - - private HMSExternalTable tbl = Mockito.mock(HMSExternalTable.class); - private HMSExternalTable view1 = Mockito.mock(HMSExternalTable.class); - private HMSExternalTable view2 = Mockito.mock(HMSExternalTable.class); - private HMSExternalTable view3 = Mockito.mock(HMSExternalTable.class); - private HMSExternalTable view4 = Mockito.mock(HMSExternalTable.class); - - @Override - protected void runBeforeAll() throws Exception { - FeConstants.runningUnitTest = true; - Config.enable_query_hive_views = true; - env = Env.getCurrentEnv(); - connectContext.setEnv(env); - mgr = env.getCatalogMgr(); - - // create hms catalog - String createStmt = "create catalog hms_ctl " - + "properties(" - + "'type' = 'hms', " - + "'hive.metastore.uris' = 'thrift://192.168.0.1:9083');"; - - NereidsParser nereidsParser = new NereidsParser(); - LogicalPlan logicalPlan = nereidsParser.parseSingle(createStmt); - if (logicalPlan instanceof CreateCatalogCommand) { - ((CreateCatalogCommand) logicalPlan).run(connectContext, null); - } - - // create inner db and tbl for test - mgr.getInternalCatalog().createDb("test", false, Maps.newHashMap()); - - createTable("create table test.tbl1(\n" - + "k1 int comment 'test column k1', " - + "k2 int comment 'test column k2') comment 'test table1' " - + "distributed by hash(k1) buckets 1\n" - + "properties(\"replication_num\" = \"1\");"); - } - - private void createDbAndTableForHmsCatalog(HMSExternalCatalog hmsCatalog) { - Deencapsulation.setField(hmsCatalog, "initialized", true); - Deencapsulation.setField(hmsCatalog, "objectCreated", true); - Env.getCurrentEnv().getExtMetaCacheMgr().prepareCatalog(hmsCatalog.getId()); - - List schema = Lists.newArrayList(); - schema.add(new Column("k1", PrimitiveType.INT)); - HMSSchemaCacheValue schemaCacheValue = new HMSSchemaCacheValue(schema, Lists.newArrayList()); - - HMSExternalDatabase db = new HMSExternalDatabase(hmsCatalog, 10000, "hms_db", "hms_db"); - Deencapsulation.setField(db, "initialized", true); - - Deencapsulation.setField(tbl, "objectCreated", true); - Deencapsulation.setField(tbl, "updateTime", NOW); - Deencapsulation.setField(tbl, "catalog", hmsCatalog); - Deencapsulation.setField(tbl, "dbName", "hms_db"); - Deencapsulation.setField(tbl, "name", "hms_tbl"); - Deencapsulation.setField(tbl, "dlaTable", new HiveDlaTable(tbl)); - Deencapsulation.setField(tbl, "dlaType", DLAType.HIVE); - long now = System.currentTimeMillis(); - Mockito.when(tbl.getId()).thenReturn(10001L); - Mockito.when(tbl.getName()).thenReturn("hms_tbl"); - Mockito.when(tbl.getDbName()).thenReturn("hms_db"); - Mockito.when(tbl.getFullSchema()).thenReturn(schema); - Mockito.when(tbl.isSupportedHmsTable()).thenReturn(true); - Mockito.when(tbl.isView()).thenReturn(false); - Mockito.when(tbl.getType()).thenReturn(TableIf.TableType.HMS_EXTERNAL_TABLE); - Mockito.when(tbl.getCatalog()).thenReturn(hmsCatalog); - Mockito.when(tbl.getSchemaCacheValue()).thenReturn(Optional.of(schemaCacheValue)); - Mockito.when(tbl.initSchemaAndUpdateTime(Mockito.any(SchemaCacheKey.class))).thenReturn(Optional.of(schemaCacheValue)); - Mockito.when(tbl.getDatabase()).thenReturn(db); - Mockito.when(tbl.getDlaType()).thenReturn(DLAType.HIVE); - Mockito.when(tbl.getNewestUpdateVersionOrTime()).thenReturn(now); - - Deencapsulation.setField(view1, "objectCreated", true); - Deencapsulation.setField(view1, "updateTime", NOW); - Deencapsulation.setField(view1, "catalog", hmsCatalog); - Deencapsulation.setField(view1, "dbName", "hms_db"); - Deencapsulation.setField(view1, "name", "hms_view1"); - Deencapsulation.setField(view1, "dlaType", DLAType.HIVE); - - Mockito.when(view1.getId()).thenReturn(10002L); - Mockito.when(view1.getName()).thenReturn("hms_view1"); - Mockito.when(view1.getDbName()).thenReturn("hms_db"); - Mockito.when(view1.isView()).thenReturn(true); - Mockito.when(view1.getCatalog()).thenReturn(hmsCatalog); - Mockito.when(view1.getType()).thenReturn(TableIf.TableType.HMS_EXTERNAL_TABLE); - Mockito.when(view1.getFullSchema()).thenReturn(schema); - Mockito.when(view1.getViewText()).thenReturn("SELECT * FROM hms_db.hms_tbl"); - Mockito.when(view1.isSupportedHmsTable()).thenReturn(true); - Mockito.when(view1.getDatabase()).thenReturn(db); - - Deencapsulation.setField(view2, "objectCreated", true); - Deencapsulation.setField(view2, "updateTime", NOW); - Deencapsulation.setField(view2, "catalog", hmsCatalog); - Deencapsulation.setField(view2, "dbName", "hms_db"); - Deencapsulation.setField(view2, "name", "hms_view2"); - Deencapsulation.setField(view2, "dlaType", DLAType.HIVE); - - Mockito.when(view2.getId()).thenReturn(10003L); - Mockito.when(view2.getName()).thenReturn("hms_view2"); - Mockito.when(view2.getDbName()).thenReturn("hms_db"); - Mockito.when(view2.getCatalog()).thenReturn(hmsCatalog); - Mockito.when(view2.isView()).thenReturn(true); - Mockito.when(view2.getType()).thenReturn(TableIf.TableType.HMS_EXTERNAL_TABLE); - Mockito.when(view2.getFullSchema()).thenReturn(schema); - Mockito.when(view2.getViewText()).thenReturn("SELECT * FROM (SELECT * FROM hms_db.hms_view1) t1"); - Mockito.when(view2.isSupportedHmsTable()).thenReturn(true); - Mockito.when(view2.getDatabase()).thenReturn(db); - - Deencapsulation.setField(view3, "objectCreated", true); - Deencapsulation.setField(view3, "updateTime", NOW); - Deencapsulation.setField(view3, "catalog", hmsCatalog); - Deencapsulation.setField(view3, "dbName", "hms_db"); - Deencapsulation.setField(view3, "name", "hms_view3"); - Deencapsulation.setField(view3, "dlaType", DLAType.HIVE); - - Mockito.when(view3.getId()).thenReturn(10004L); - Mockito.when(view3.getName()).thenReturn("hms_view3"); - Mockito.when(view3.getDbName()).thenReturn("hms_db"); - Mockito.when(view3.getCatalog()).thenReturn(hmsCatalog); - Mockito.when(view3.isView()).thenReturn(true); - Mockito.when(view3.getType()).thenReturn(TableIf.TableType.HMS_EXTERNAL_TABLE); - Mockito.when(view3.getFullSchema()).thenReturn(schema); - Mockito.when(view3.getViewText()).thenReturn("SELECT * FROM hms_db.hms_view1 UNION ALL SELECT * FROM hms_db.hms_view2"); - Mockito.when(view3.isSupportedHmsTable()).thenReturn(true); - Mockito.when(view3.getDatabase()).thenReturn(db); - - Deencapsulation.setField(view4, "objectCreated", true); - Deencapsulation.setField(view4, "updateTime", NOW); - Deencapsulation.setField(view4, "catalog", hmsCatalog); - Deencapsulation.setField(view4, "dbName", "hms_db"); - Deencapsulation.setField(view4, "name", "hms_view4"); - Deencapsulation.setField(view4, "dlaType", DLAType.HIVE); - - Mockito.when(view4.getId()).thenReturn(10005L); - Mockito.when(view4.getName()).thenReturn("hms_view4"); - Mockito.when(view4.getDbName()).thenReturn("hms_db"); - Mockito.when(view4.getCatalog()).thenReturn(hmsCatalog); - Mockito.when(view4.isView()).thenReturn(true); - Mockito.when(view4.getType()).thenReturn(TableIf.TableType.HMS_EXTERNAL_TABLE); - Mockito.when(view4.getFullSchema()).thenReturn(schema); - Mockito.when(view4.getViewText()).thenReturn("SELECT not_exists_func(k1) FROM hms_db.hms_tbl"); - Mockito.when(view4.isSupportedHmsTable()).thenReturn(true); - Mockito.when(view4.getDatabase()).thenReturn(db); - - db.addTableForTest(tbl); - db.addTableForTest(view1); - db.addTableForTest(view2); - db.addTableForTest(view3); - db.addTableForTest(view4); - hmsCatalog.addDatabaseForTest(db); - } - - @Test - public void testQueryView() { - SessionVariable sv = connectContext.getSessionVariable(); - Assertions.assertNotNull(sv); - - createDbAndTableForHmsCatalog((HMSExternalCatalog) env.getCatalogMgr().getCatalog(HMS_CATALOG)); - // force use nereids planner to query hive views - queryViews(); - } - - private void testParseAndAnalyze(String sql) { - try { - checkAnalyze(sql); - } catch (Exception exception) { - exception.printStackTrace(); - Assert.fail(); - } - } - - private void testParseAndAnalyzeWithThrows(String sql) { - try { - Assert.assertThrows(org.apache.doris.nereids.exceptions.AnalysisException.class, () -> checkAnalyze(sql)); - } catch (Exception exception) { - exception.printStackTrace(); - Assert.fail(); - } - } - - private void queryViews() { - // test normal table - testParseAndAnalyze("SELECT * FROM hms_ctl.hms_db.hms_tbl"); - - // test simple view - testParseAndAnalyze("SELECT * FROM hms_ctl.hms_db.hms_view1"); - - // test view with subquery - testParseAndAnalyze("SELECT * FROM hms_ctl.hms_db.hms_view2"); - - // test view with union - testParseAndAnalyze("SELECT * FROM hms_ctl.hms_db.hms_view3"); - - // test view with not support func - testParseAndAnalyzeWithThrows("SELECT * FROM hms_ctl.hms_db.hms_view4"); - - // change to hms_ctl - try { - env.changeCatalog(connectContext, HMS_CATALOG); - } catch (Exception exception) { - exception.printStackTrace(); - Assert.fail(); - } - - // test in hms_ctl - testParseAndAnalyze("SELECT * FROM hms_db.hms_view1"); - - testParseAndAnalyze("SELECT * FROM hms_db.hms_view2"); - - testParseAndAnalyze("SELECT * FROM hms_db.hms_view3"); - - testParseAndAnalyzeWithThrows("SELECT * FROM hms_db.hms_view4"); - - // test federated query - testParseAndAnalyze("SELECT * FROM hms_db.hms_view3, internal.test.tbl1"); - - // change to internal catalog - try { - env.changeCatalog(connectContext, InternalCatalog.INTERNAL_CATALOG_NAME); - } catch (Exception exception) { - exception.printStackTrace(); - Assert.fail(); - } - - testParseAndAnalyze("SELECT * FROM hms_ctl.hms_db.hms_view3, internal.test.tbl1"); - - testParseAndAnalyze("SELECT * FROM hms_ctl.hms_db.hms_view3, test.tbl1"); - } - -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/external/hms/MetastoreEventFactoryTest.java b/fe/fe-core/src/test/java/org/apache/doris/external/hms/MetastoreEventFactoryTest.java deleted file mode 100644 index f49900adde6bda..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/external/hms/MetastoreEventFactoryTest.java +++ /dev/null @@ -1,472 +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.external.hms; - -import org.apache.doris.datasource.hive.event.AddPartitionEvent; -import org.apache.doris.datasource.hive.event.AlterDatabaseEvent; -import org.apache.doris.datasource.hive.event.AlterPartitionEvent; -import org.apache.doris.datasource.hive.event.AlterTableEvent; -import org.apache.doris.datasource.hive.event.CreateDatabaseEvent; -import org.apache.doris.datasource.hive.event.CreateTableEvent; -import org.apache.doris.datasource.hive.event.DropDatabaseEvent; -import org.apache.doris.datasource.hive.event.DropPartitionEvent; -import org.apache.doris.datasource.hive.event.DropTableEvent; -import org.apache.doris.datasource.hive.event.InsertEvent; -import org.apache.doris.datasource.hive.event.MetastoreEvent; -import org.apache.doris.datasource.hive.event.MetastoreEventFactory; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import com.google.common.collect.Sets; -import org.apache.commons.collections4.CollectionUtils; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.Arrays; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Random; -import java.util.Set; -import java.util.function.Function; - - -public class MetastoreEventFactoryTest { - - private static final MetastoreEventFactory factory = new MetastoreEventFactory(); - private static final Random random = new Random(System.currentTimeMillis()); - private static final String testCtl = "test_ctl"; - - private static final Function createDatabaseEventProducer = eventId - -> new CreateDatabaseEvent(eventId, testCtl, randomDb()); - - private static final Function alterDatabaseEventProducer = eventId - -> new AlterDatabaseEvent(eventId, testCtl, randomDb(), randomBool(0.0001D)); - - private static final Function dropDatabaseEventProducer = eventId - -> new DropDatabaseEvent(eventId, testCtl, randomDb()); - - private static final Function createTableEventProducer = eventId - -> new CreateTableEvent(eventId, testCtl, randomDb(), randomTbl()); - - private static final Function alterTableEventProducer = eventId - -> new AlterTableEvent(eventId, testCtl, randomDb(), randomTbl(), - randomBool(0.1D), randomBool(0.1D)); - - private static final Function insertEventProducer = eventId - -> new InsertEvent(eventId, testCtl, randomDb(), randomTbl()); - - private static final Function dropTableEventProducer = eventId - -> new DropTableEvent(eventId, testCtl, randomDb(), randomTbl()); - - private static final Function addPartitionEventProducer = eventId - -> new AddPartitionEvent(eventId, testCtl, randomDb(), randomTbl(), randomPartitions()); - - private static final Function alterPartitionEventProducer = eventId - -> new AlterPartitionEvent(eventId, testCtl, randomDb(), randomTbl(), randomPartition(), - randomBool(0.1D)); - - private static final Function dropPartitionEventProducer = eventId - -> new DropPartitionEvent(eventId, testCtl, randomDb(), randomTbl(), randomPartitions()); - - private static final List> eventProducers = Arrays.asList( - createDatabaseEventProducer, alterDatabaseEventProducer, dropDatabaseEventProducer, - createTableEventProducer, alterTableEventProducer, insertEventProducer, dropTableEventProducer, - addPartitionEventProducer, alterPartitionEventProducer, dropPartitionEventProducer); - - private static String randomDb() { - return "db_" + random.nextInt(10); - } - - private static String randomTbl() { - return "tbl_" + random.nextInt(100); - } - - private static String randomPartition() { - return "partition_" + random.nextInt(1000); - } - - private static List randomPartitions() { - int times = random.nextInt(100) + 1; - Set partitions = Sets.newHashSet(); - for (int i = 0; i < times; i++) { - partitions.add(randomPartition()); - } - return Lists.newArrayList(partitions); - } - - private static boolean randomBool(double possibility) { - Preconditions.checkArgument(possibility >= 0.0D && possibility <= 1.0D); - int upperBound = (int) Math.floor(1000000 * possibility); - return random.nextInt(1000000) <= upperBound; - } - - // define MockCatalog/MockDatabase/MockTable/MockPartition to simulate the real catalog/database/table/partition - private static class MockCatalog { - private String ctlName; - private Map databases = Maps.newHashMap(); - - private MockCatalog(String ctlName) { - this.ctlName = ctlName; - } - - @Override - public int hashCode() { - return 31 * Objects.hash(ctlName) + Arrays.hashCode( - databases.values().stream().sorted(Comparator.comparing(d -> d.dbName)).toArray()); - } - - @Override - public boolean equals(Object other) { - if (!(other instanceof MockCatalog)) { - return false; - } - if (!Objects.equals(this.ctlName, ((MockCatalog) other).ctlName)) { - return false; - } - Object[] sortedDatabases = databases.values().stream() - .sorted(Comparator.comparing(d -> d.dbName)).toArray(); - Object[] otherSortedDatabases = ((MockCatalog) other).databases.values().stream() - .sorted(Comparator.comparing(d -> d.dbName)).toArray(); - return Arrays.equals(sortedDatabases, otherSortedDatabases); - } - - public MockCatalog copy() { - MockCatalog mockCatalog = new MockCatalog(this.ctlName); - mockCatalog.databases.putAll(this.databases); - return mockCatalog; - } - } - - private static class MockDatabase { - private String dbName; - private Map tables = Maps.newHashMap(); - - private MockDatabase(String dbName) { - this.dbName = dbName; - } - - @Override - public int hashCode() { - return 31 * Objects.hash(dbName) + Arrays.hashCode( - tables.values().stream().sorted(Comparator.comparing(t -> t.tblName)).toArray()); - } - - @Override - public boolean equals(Object other) { - if (!(other instanceof MockDatabase)) { - return false; - } - if (!Objects.equals(this.dbName, ((MockDatabase) other).dbName)) { - return false; - } - Object[] sortedTables = tables.values().stream() - .sorted(Comparator.comparing(t -> t.tblName)).toArray(); - Object[] otherSortedTables = ((MockDatabase) other).tables.values().stream() - .sorted(Comparator.comparing(t -> t.tblName)).toArray(); - return Arrays.equals(sortedTables, otherSortedTables); - } - - public MockDatabase copy() { - MockDatabase mockDatabase = new MockDatabase(this.dbName); - mockDatabase.tables.putAll(this.tables); - return mockDatabase; - } - } - - private static class MockTable { - private String tblName; - // use this filed to mark if the table has been refreshed - private boolean refreshed; - private Map partitions = Maps.newHashMap(); - - private MockTable(String tblName) { - this.tblName = tblName; - } - - public void refresh() { - this.refreshed = true; - } - - @Override - public int hashCode() { - return 31 * Objects.hash(tblName, refreshed) + Arrays.hashCode( - partitions.values().stream().sorted(Comparator.comparing(p -> p.partitionName)).toArray()); - } - - @Override - public boolean equals(Object other) { - if (!(other instanceof MockTable)) { - return false; - } - if (!Objects.equals(this.tblName, ((MockTable) other).tblName)) { - return false; - } - if (refreshed != ((MockTable) other).refreshed) { - return false; - } - Object[] sortedPartitions = partitions.values().stream() - .sorted(Comparator.comparing(p -> p.partitionName)).toArray(); - Object[] otherSortedPartitions = ((MockTable) other).partitions.values().stream() - .sorted(Comparator.comparing(p -> p.partitionName)).toArray(); - return Arrays.equals(sortedPartitions, otherSortedPartitions); - } - - public MockTable copy() { - MockTable copyTbl = new MockTable(this.tblName); - copyTbl.partitions.putAll(this.partitions); - return copyTbl; - } - } - - private static class MockPartition { - private String partitionName; - // use this filed to mark if the partition has been refreshed - private boolean refreshed; - - private MockPartition(String partitionName) { - this.partitionName = partitionName; - this.refreshed = false; - } - - public void refresh() { - this.refreshed = true; - } - - @Override - public int hashCode() { - return Objects.hash(refreshed, partitionName); - } - - @Override - public boolean equals(Object other) { - return other instanceof MockPartition - && refreshed == ((MockPartition) other).refreshed - && Objects.equals(this.partitionName, ((MockPartition) other).partitionName); - } - } - - // simulate the processes when handling hms events - private void processEvent(MockCatalog ctl, MetastoreEvent event) { - switch (event.getEventType()) { - - case CREATE_DATABASE: - MockDatabase database = new MockDatabase(event.getDbName()); - ctl.databases.put(database.dbName, database); - break; - - case DROP_DATABASE: - ctl.databases.remove(event.getDbName()); - break; - - case ALTER_DATABASE: - String dbName = event.getDbName(); - if (((AlterDatabaseEvent) event).isRename()) { - ctl.databases.remove(dbName); - MockDatabase newDatabase = new MockDatabase(((AlterDatabaseEvent) event).getDbNameAfter()); - ctl.databases.put(newDatabase.dbName, newDatabase); - } else { - if (ctl.databases.containsKey(event.getDbName())) { - ctl.databases.get(event.getDbName()).tables.clear(); - } - } - break; - - case CREATE_TABLE: - if (ctl.databases.containsKey(event.getDbName())) { - MockTable tbl = new MockTable(event.getTblName()); - ctl.databases.get(event.getDbName()).tables.put(event.getTblName(), tbl); - } - break; - - case DROP_TABLE: - if (ctl.databases.containsKey(event.getDbName())) { - ctl.databases.get(event.getDbName()).tables.remove(event.getTblName()); - } - break; - - case ALTER_TABLE: - case INSERT: - if (ctl.databases.containsKey(event.getDbName())) { - if (event instanceof AlterTableEvent - && (((AlterTableEvent) event).isRename() || ((AlterTableEvent) event).isView())) { - ctl.databases.get(event.getDbName()).tables.remove(event.getTblName()); - MockTable tbl = new MockTable(((AlterTableEvent) event).getTblNameAfter()); - ctl.databases.get(event.getDbName()).tables.put(tbl.tblName, tbl); - } else { - MockTable tbl = ctl.databases.get(event.getDbName()).tables.get(event.getTblName()); - if (tbl != null) { - tbl.partitions.clear(); - tbl.refresh(); - } - } - } - break; - - case ADD_PARTITION: - if (ctl.databases.containsKey(event.getDbName())) { - MockTable tbl = ctl.databases.get(event.getDbName()).tables.get(event.getTblName()); - if (tbl != null) { - for (String partitionName : ((AddPartitionEvent) event).getAllPartitionNames()) { - MockPartition partition = new MockPartition(partitionName); - tbl.partitions.put(partitionName, partition); - } - } - } - break; - - case ALTER_PARTITION: - if (ctl.databases.containsKey(event.getDbName())) { - MockTable tbl = ctl.databases.get(event.getDbName()).tables.get(event.getTblName()); - AlterPartitionEvent alterPartitionEvent = ((AlterPartitionEvent) event); - if (tbl != null) { - if (alterPartitionEvent.isRename()) { - for (String partitionName : alterPartitionEvent.getAllPartitionNames()) { - tbl.partitions.remove(partitionName); - } - MockPartition partition = new MockPartition(alterPartitionEvent.getPartitionNameAfter()); - tbl.partitions.put(partition.partitionName, partition); - } else { - for (String partitionName : alterPartitionEvent.getAllPartitionNames()) { - MockPartition partition = tbl.partitions.get(partitionName); - if (partition != null) { - partition.refresh(); - } - } - } - } - } - break; - - case DROP_PARTITION: - if (ctl.databases.containsKey(event.getDbName())) { - MockTable tbl = ctl.databases.get(event.getDbName()).tables.get(event.getTblName()); - if (tbl != null) { - for (String partitionName : ((DropPartitionEvent) event).getAllPartitionNames()) { - tbl.partitions.remove(partitionName); - } - } - } - break; - - default: - Assertions.fail("Unknown event type : " + event.getEventType()); - } - } - - static class EventProducer { - // every type of event has a proportion - // for instance, if the `CreateDatabaseEvent`'s proportion is 1 - // and the `AlterDatabaseEvent`'s proportion is 10 - // the event count of `AlterDatabaseEvent` is always about 10 times as the `CreateDatabaseEvent` - private final List proportions; - private final int sumOfProportions; - - EventProducer(List proportions) { - Preconditions.checkArgument(CollectionUtils.isNotEmpty(proportions) - && proportions.size() == eventProducers.size()); - this.proportions = ImmutableList.copyOf(proportions); - this.sumOfProportions = proportions.stream().mapToInt(proportion -> proportion).sum(); - } - - public MetastoreEvent produceOneEvent(long eventId) { - return eventProducers.get(calIndex(random.nextInt(sumOfProportions))).apply(eventId); - } - - private int calIndex(int val) { - int currentIndex = 0; - int currentBound = proportions.get(currentIndex); - while (currentIndex < proportions.size() - 1) { - if (val > currentBound) { - currentBound += proportions.get(++currentIndex); - } else { - return currentIndex; - } - } - return proportions.size() - 1; - } - } - - @Test - public void testCreateBatchEvents() { - // for catalog initialization, so just produce CreateXXXEvent / AddXXXEvent - List initProportions = Lists.newArrayList( - 1, // CreateDatabaseEvent - 0, // AlterDatabaseEvent - 0, // DropDatabaseEvent - 10, // CreateTableEvent - 0, // AlterTableEvent - 0, // InsertEvent - 0, // DropTableEvent - 100, // AddPartitionEvent - 0, // AlterPartitionEvent - 0 // DropPartitionEvent - ); - - List proportions = Lists.newArrayList( - 5, // CreateDatabaseEvent - 1, // AlterDatabaseEvent - 5, // DropDatabaseEvent - 100, // CreateTableEvent - 20000, // AlterTableEvent - 2000, // InsertEvent - 5000, // DropTableEvent - 10000, // AddPartitionEvent - 50000, // AlterPartitionEvent - 20000 // DropPartitionEvent - ); - EventProducer initProducer = new EventProducer(initProportions); - EventProducer producer = new EventProducer(proportions); - - for (int i = 0; i < 200; i++) { - // create a test catalog and do initialization - MockCatalog testCatalog = new MockCatalog(testCtl); - List initEvents = Lists.newArrayListWithCapacity(1000); - for (int j = 0; j < 1000; j++) { - initEvents.add(initProducer.produceOneEvent(j)); - } - for (MetastoreEvent event : initEvents) { - processEvent(testCatalog, event); - } - - // copy the test catalog to the validate catalog - MockCatalog validateCatalog = testCatalog.copy(); - - List events = Lists.newArrayListWithCapacity(1000); - for (int j = 0; j < 1000; j++) { - events.add(producer.produceOneEvent(j)); - } - List mergedEvents = factory.mergeEvents(testCtl, events); - - for (MetastoreEvent event : events) { - processEvent(validateCatalog, event); - } - - for (MetastoreEvent event : mergedEvents) { - processEvent(testCatalog, event); - } - - // the test catalog should be equals to the validate catalog - // otherwise we must have some bugs at `factory.createBatchEvents()` - Assertions.assertEquals(testCatalog, validateCatalog); - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/fs/FileSystemFactoryBindAllTest.java b/fe/fe-core/src/test/java/org/apache/doris/fs/FileSystemFactoryBindAllTest.java new file mode 100644 index 00000000000000..82f269c5160bee --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/fs/FileSystemFactoryBindAllTest.java @@ -0,0 +1,119 @@ +// 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.fs; + +import org.apache.doris.filesystem.FileSystem; +import org.apache.doris.filesystem.FileSystemType; +import org.apache.doris.filesystem.properties.FileSystemProperties; +import org.apache.doris.filesystem.properties.StorageKind; +import org.apache.doris.filesystem.properties.StorageProperties; +import org.apache.doris.filesystem.spi.FileSystemProvider; + +import org.junit.jupiter.api.AfterEach; +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; + +public class FileSystemFactoryBindAllTest { + + @AfterEach + public void resetFactoryState() { + // bindAllStorageProperties / initPluginManager mutate static state; restore the default. + FileSystemFactory.clearProviderCache(); + } + + @Test + public void bindAllStorageProperties_delegatesToLivePluginManager() { + // Production path: a plugin-loaded manager is set at FE startup; bindAllStorageProperties must + // delegate to its bindAll (the only place the runtime object-store directory plugins live). + FileSystemProperties bound = new FakeFsProps(); + FileSystemPluginManager mgr = new FileSystemPluginManager(); + mgr.registerProvider(supportingProvider(bound)); + FileSystemFactory.initPluginManager(mgr); + + List result = FileSystemFactory.bindAllStorageProperties(new HashMap<>()); + + Assertions.assertEquals(1, result.size()); + Assertions.assertSame(bound, result.get(0)); + } + + @Test + public void bindAllStorageProperties_fallsBackToServiceLoaderWhenNoManager() { + // Migration / unit-test path: no live manager -> ServiceLoader fallback (mirrors getFileSystem). + // No object-store binding provider is on fe-core's unit-test classpath, so the result is empty, + // but it must never be null or throw. + FileSystemFactory.clearProviderCache(); + List result = FileSystemFactory.bindAllStorageProperties(new HashMap<>()); + Assertions.assertNotNull(result); + } + + private static FileSystemProvider supportingProvider(FileSystemProperties bound) { + return new FileSystemProvider() { + @Override + public boolean supports(Map properties) { + return true; + } + + @Override + public FileSystemProperties bind(Map properties) { + return bound; + } + + @Override + public FileSystem create(Map properties) { + return null; + } + + @Override + public String name() { + return "fake"; + } + }; + } + + private static final class FakeFsProps implements FileSystemProperties { + @Override + public String providerName() { + return "FAKE"; + } + + @Override + public StorageKind kind() { + return null; + } + + @Override + public FileSystemType type() { + return null; + } + + @Override + public Map rawProperties() { + return Collections.emptyMap(); + } + + @Override + public Map matchedProperties() { + return Collections.emptyMap(); + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/fs/FileSystemPluginManagerTest.java b/fe/fe-core/src/test/java/org/apache/doris/fs/FileSystemPluginManagerTest.java index e6900ac3a8dff9..370fd63dd3f011 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/fs/FileSystemPluginManagerTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/fs/FileSystemPluginManagerTest.java @@ -19,13 +19,18 @@ import org.apache.doris.common.util.DatasourcePrintableMap; import org.apache.doris.filesystem.FileSystem; +import org.apache.doris.filesystem.FileSystemType; import org.apache.doris.filesystem.properties.FileSystemProperties; +import org.apache.doris.filesystem.properties.StorageKind; +import org.apache.doris.filesystem.properties.StorageProperties; import org.apache.doris.filesystem.spi.FileSystemProvider; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import java.util.Collections; +import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Set; @@ -54,4 +59,168 @@ public Set sensitivePropertyKeys() { Assertions.assertTrue( DatasourcePrintableMap.SENSITIVE_KEY.contains("PLUGIN_MANAGER_TEST_SECRET_ALIAS")); } + + // ---- bindAll (P0-T02 / D-009): raw map -> List ---- + + @Test + public void bindAll_collectsTypedPropertiesFromEverySupportingProvider() { + FileSystemPluginManager manager = new FileSystemPluginManager(); + FileSystemProperties s3Props = new FakeFsProps("S3"); + FileSystemProperties hdfsLikeProps = new FakeFsProps("HDFSLIKE"); + manager.registerProvider(bindingProvider("A", s3Props)); + manager.registerProvider(bindingProvider("B", hdfsLikeProps)); + + List bound = manager.bindAll(new HashMap<>()); + + // bindAll returns ALL supporting providers' bound props (unlike createFileSystem's first-match). + Assertions.assertEquals(2, bound.size()); + Assertions.assertTrue(bound.contains(s3Props)); + Assertions.assertTrue(bound.contains(hdfsLikeProps)); + } + + @Test + public void bindAll_skipsProvidersThatDoNotSupportTheProperties() { + FileSystemPluginManager manager = new FileSystemPluginManager(); + FileSystemProperties supported = new FakeFsProps("S3"); + manager.registerProvider(bindingProvider("supports", supported)); + manager.registerProvider(nonSupportingProvider("ignored")); + + List bound = manager.bindAll(new HashMap<>()); + + Assertions.assertEquals(1, bound.size()); + Assertions.assertSame(supported, bound.get(0)); + } + + @Test + public void bindAll_skipsLegacyProvidersWithoutTypedBinding() { + // HDFS/broker/local providers support() their props but have not migrated bind() -> the + // default throws UnsupportedOperationException. They contribute no typed StorageProperties + // (the connector covers them via raw fs./dfs./hadoop. passthrough), so bindAll must skip + // them rather than blow up -- matching legacy createAll's object-store-only Hadoop scope. + FileSystemPluginManager manager = new FileSystemPluginManager(); + FileSystemProperties typed = new FakeFsProps("S3"); + manager.registerProvider(bindingProvider("typed", typed)); + manager.registerProvider(legacyProviderThatSupportsButCannotBind("legacyHdfs")); + + List bound = manager.bindAll(new HashMap<>()); + + Assertions.assertEquals(1, bound.size()); + Assertions.assertSame(typed, bound.get(0)); + } + + @Test + public void bindAll_returnsEmptyListWhenNoProviderSupports() { + FileSystemPluginManager manager = new FileSystemPluginManager(); + manager.registerProvider(nonSupportingProvider("none1")); + manager.registerProvider(nonSupportingProvider("none2")); + + List bound = manager.bindAll(new HashMap<>()); + + Assertions.assertTrue(bound.isEmpty()); + } + + // NOTE: real object-store providers (S3/OSS/COS/OBS) are runtime directory-loaded plugins + // (Env.loadPlugins), NOT on fe-core's unit-test classpath (fe-core pom: "fe-filesystem impl + // modules: runtime dependencies removed in Phase 4 P4.1"). End-to-end binding against the real + // providers is therefore covered by P1-T06 (docker / full plugin classpath), not here. + + // ---- helpers ---- + + private static FileSystemProvider bindingProvider( + String name, FileSystemProperties bound) { + return new FileSystemProvider() { + @Override + public boolean supports(Map properties) { + return true; + } + + @Override + public FileSystemProperties bind(Map properties) { + return bound; + } + + @Override + public FileSystem create(Map properties) { + return null; + } + + @Override + public String name() { + return name; + } + }; + } + + private static FileSystemProvider nonSupportingProvider(String name) { + return new FileSystemProvider() { + @Override + public boolean supports(Map properties) { + return false; + } + + @Override + public FileSystem create(Map properties) { + return null; + } + + @Override + public String name() { + return name; + } + }; + } + + private static FileSystemProvider legacyProviderThatSupportsButCannotBind( + String name) { + // No bind() override -> inherits the default that throws UnsupportedOperationException. + return new FileSystemProvider() { + @Override + public boolean supports(Map properties) { + return true; + } + + @Override + public FileSystem create(Map properties) { + return null; + } + + @Override + public String name() { + return name; + } + }; + } + + private static final class FakeFsProps implements FileSystemProperties { + private final String name; + + private FakeFsProps(String name) { + this.name = name; + } + + @Override + public String providerName() { + return name; + } + + @Override + public StorageKind kind() { + return null; + } + + @Override + public FileSystemType type() { + return null; + } + + @Override + public Map rawProperties() { + return Collections.emptyMap(); + } + + @Override + public Map matchedProperties() { + return Collections.emptyMap(); + } + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/fs/TransactionScopeCachingDirectoryListerTest.java b/fe/fe-core/src/test/java/org/apache/doris/fs/TransactionScopeCachingDirectoryListerTest.java deleted file mode 100644 index 61ead6228f4706..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/fs/TransactionScopeCachingDirectoryListerTest.java +++ /dev/null @@ -1,184 +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. -// This file is copied from -// https://github.com/trinodb/trino/blob/438/plugin/trino-hive/src/test/java/io/trino/plugin/hive/fs/TestTransactionScopeCachingDirectoryLister.java -// and modified by Doris - -package org.apache.doris.fs; - -import org.apache.doris.catalog.TableIf; -import org.apache.doris.filesystem.FileEntry; -import org.apache.doris.filesystem.FileSystemIOException; -import org.apache.doris.filesystem.Location; -import org.apache.doris.filesystem.RemoteIterator; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import org.junit.Assert; -import org.junit.Test; -import org.junit.jupiter.api.parallel.Execution; -import org.junit.jupiter.api.parallel.ExecutionMode; -import org.mockito.Mockito; - -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -// some tests may invalidate the whole cache affecting therefore other concurrent tests -@Execution(ExecutionMode.SAME_THREAD) -public class TransactionScopeCachingDirectoryListerTest { - private static FileEntry fileEntry(String uri) { - return new FileEntry(Location.of(uri), 1, false, 0L, null); - } - - @Test - public void testConcurrentDirectoryListing() - throws FileSystemIOException { - TableIf table = Mockito.mock(TableIf.class); - FileEntry firstFile = fileEntry("file:/x/x"); - FileEntry secondFile = fileEntry("file:/x/y"); - FileEntry thirdFile = fileEntry("file:/y/z"); - - String path1 = "file:/x"; - String path2 = "file:/y"; - - CountingDirectoryLister countingLister = new CountingDirectoryLister( - ImmutableMap.of( - path1, ImmutableList.of(firstFile, secondFile), - path2, ImmutableList.of(thirdFile))); - - TransactionScopeCachingDirectoryLister cachingLister = (TransactionScopeCachingDirectoryLister) - new TransactionScopeCachingDirectoryListerFactory(2).get(countingLister); - - assertFiles(cachingLister.listFiles(null, true, table, path2), ImmutableList.of(thirdFile)); - - Assert.assertEquals(1, countingLister.getListCount()); - - // listing path2 again shouldn't increase listing count - Assert.assertTrue(cachingLister.isCached(path2)); - assertFiles(cachingLister.listFiles(null, true, table, path2), ImmutableList.of(thirdFile)); - Assert.assertEquals(1, countingLister.getListCount()); - - - // start listing path1 concurrently - RemoteIterator path1FilesA = cachingLister.listFiles(null, true, table, path1); - RemoteIterator path1FilesB = cachingLister.listFiles(null, true, table, path1); - Assert.assertEquals(2, countingLister.getListCount()); - - // list path1 files using both iterators concurrently - Assert.assertSame(firstFile, path1FilesA.next()); - Assert.assertSame(firstFile, path1FilesB.next()); - Assert.assertSame(secondFile, path1FilesB.next()); - Assert.assertSame(secondFile, path1FilesA.next()); - Assert.assertFalse(path1FilesA.hasNext()); - Assert.assertFalse(path1FilesB.hasNext()); - Assert.assertEquals(2, countingLister.getListCount()); - - Assert.assertFalse(cachingLister.isCached(path2)); - assertFiles(cachingLister.listFiles(null, true, table, path2), ImmutableList.of(thirdFile)); - Assert.assertEquals(3, countingLister.getListCount()); - } - - @Test - public void testConcurrentDirectoryListingException() - throws FileSystemIOException { - TableIf table = Mockito.mock(TableIf.class); - FileEntry file = fileEntry("file:/x/x"); - - String path = "file:/x"; - - CountingDirectoryLister countingLister = new CountingDirectoryLister(ImmutableMap.of(path, ImmutableList.of(file))); - DirectoryLister cachingLister = new TransactionScopeCachingDirectoryListerFactory(1).get(countingLister); - - // start listing path concurrently - countingLister.setThrowException(true); - RemoteIterator filesA = cachingLister.listFiles(null, true, table, path); - RemoteIterator filesB = cachingLister.listFiles(null, true, table, path); - Assert.assertEquals(1, countingLister.getListCount()); - - // listing should throw an exception - Assert.assertThrows(FileSystemIOException.class, () -> filesA.hasNext()); - - - // listing again should succeed - countingLister.setThrowException(false); - assertFiles(cachingLister.listFiles(null, true, table, path), ImmutableList.of(file)); - Assert.assertEquals(2, countingLister.getListCount()); - - // listing using second concurrently initialized DirectoryLister should fail - Assert.assertThrows(FileSystemIOException.class, () -> filesB.hasNext()); - - } - - private void assertFiles(RemoteIterator iterator, List expectedFiles) - throws FileSystemIOException { - ImmutableList.Builder actualFiles = ImmutableList.builder(); - while (iterator.hasNext()) { - actualFiles.add(iterator.next()); - } - Assert.assertEquals(expectedFiles, actualFiles.build()); - } - - private static class CountingDirectoryLister - implements DirectoryLister { - private final Map> fileStatuses; - private int listCount; - private boolean throwException; - - public CountingDirectoryLister(Map> fileStatuses) { - this.fileStatuses = Objects.requireNonNull(fileStatuses, "fileStatuses is null"); - } - - @Override - public RemoteIterator listFiles(org.apache.doris.filesystem.FileSystem fs, boolean recursive, - TableIf table, String location) - throws FileSystemIOException { - // No specific recursive files-only listing implementation - listCount++; - return throwingRemoteIterator(Objects.requireNonNull(fileStatuses.get(location)), throwException); - } - - public void setThrowException(boolean throwException) { - this.throwException = throwException; - } - - public int getListCount() { - return listCount; - } - } - - static RemoteIterator throwingRemoteIterator(List files, boolean throwException) { - return new RemoteIterator() { - private final Iterator iterator = ImmutableList.copyOf(files).iterator(); - - @Override - public boolean hasNext() - throws FileSystemIOException { - if (throwException) { - throw new FileSystemIOException("File system io exception."); - } - return iterator.hasNext(); - } - - @Override - public FileEntry next() { - return iterator.next(); - } - }; - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/insertoverwrite/InsertOverwriteManagerTest.java b/fe/fe-core/src/test/java/org/apache/doris/insertoverwrite/InsertOverwriteManagerTest.java index af74e6e9833003..d071163021bd88 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/insertoverwrite/InsertOverwriteManagerTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/insertoverwrite/InsertOverwriteManagerTest.java @@ -23,7 +23,6 @@ import org.apache.doris.common.AnalysisException; import org.apache.doris.common.DdlException; import org.apache.doris.common.MetaNotFoundException; -import org.apache.doris.datasource.hive.HMSExternalTable; import org.junit.Before; import org.junit.Test; @@ -35,8 +34,6 @@ public class InsertOverwriteManagerTest { private OlapTable table = Mockito.mock(OlapTable.class); - private HMSExternalTable hmsExternalTable = Mockito.mock(HMSExternalTable.class); - private MTMV mtmv = Mockito.mock(MTMV.class); @Before @@ -47,8 +44,6 @@ public void setUp() Mockito.when(db.getFullName()).thenReturn("db1"); Mockito.when(table.getId()).thenReturn(2L); Mockito.when(table.getName()).thenReturn("table1"); - Mockito.when(hmsExternalTable.getId()).thenReturn(3L); - Mockito.when(hmsExternalTable.getName()).thenReturn("hmsTable"); Mockito.when(mtmv.getId()).thenReturn(4L); Mockito.when(mtmv.getName()).thenReturn("mtmv1"); } @@ -63,14 +58,6 @@ public void testMTMVParallel() { Assertions.assertDoesNotThrow(() -> manager.recordRunningTableOrException(db, mtmv)); } - @Test - public void testHmsTableParallel() { - InsertOverwriteManager manager = new InsertOverwriteManager(); - manager.recordRunningTableOrException(db, hmsExternalTable); - Assertions.assertDoesNotThrow(() -> manager.recordRunningTableOrException(db, hmsExternalTable)); - manager.dropRunningRecord(db.getId(), hmsExternalTable.getId()); - } - @Test public void testOlapTableParallel() { InsertOverwriteManager manager = new InsertOverwriteManager(); diff --git a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPartitionCheckUtilTest.java b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPartitionCheckUtilTest.java index f9f9ef0191e500..647e5490935f4b 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPartitionCheckUtilTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPartitionCheckUtilTest.java @@ -30,7 +30,7 @@ import org.apache.doris.common.Pair; import org.apache.doris.common.util.DynamicPartitionUtil; import org.apache.doris.common.util.DynamicPartitionUtil.StartOfDate; -import org.apache.doris.datasource.hive.HMSExternalTable; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable; import com.google.common.collect.Lists; import org.junit.After; @@ -43,7 +43,7 @@ import java.util.ArrayList; public class MTMVPartitionCheckUtilTest { - private HMSExternalTable hmsExternalTable = Mockito.mock(HMSExternalTable.class); + private PluginDrivenMvccExternalTable nonOlapTable = Mockito.mock(PluginDrivenMvccExternalTable.class); private OlapTable originalTable = Mockito.mock(OlapTable.class); private OlapTable relatedTable = Mockito.mock(OlapTable.class); private PartitionInfo originalPartitionInfo = Mockito.mock(PartitionInfo.class); @@ -94,7 +94,7 @@ public void tearDown() { @Test public void testCheckIfAllowMultiTablePartitionRefreshNotOlapTable() { Pair res = MTMVPartitionCheckUtil.checkIfAllowMultiTablePartitionRefresh( - hmsExternalTable); + nonOlapTable); Assert.assertFalse(res.first); } diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/SqlCacheContextPluginTableTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/SqlCacheContextPluginTableTest.java new file mode 100644 index 00000000000000..3026bad1ed8947 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/SqlCacheContextPluginTableTest.java @@ -0,0 +1,91 @@ +// 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; + +import org.apache.doris.analysis.UserIdentity; +import org.apache.doris.catalog.DatabaseIf; +import org.apache.doris.catalog.TableIf.TableType; +import org.apache.doris.datasource.CatalogIf; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable; +import org.apache.doris.nereids.SqlCacheContext.TableVersion; + +import com.google.common.collect.Maps; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * Unit tests for the connector-agnostic invalidation token the SQL-result-cache migration wired into + * {@link SqlCacheContext#addUsedTable}. A flipped lakehouse table is a + * {@code PluginDrivenMvccExternalTable} (implements MTMVRelatedTableIf); its cache token must be the stable, + * data-tied {@code getNewestUpdateVersionOrTime()}, NOT the wall-clock {@code getUpdateTime()} it inherits + * (which changes on every FE schema reload and would serve stale results). A token <= 0 (no reliable + * data-change signal) fails safe: the table is marked unsupported rather than pinned at a bogus constant. + */ +public class SqlCacheContextPluginTableTest { + + private PluginDrivenMvccExternalTable mockTable(long id, long token) { + PluginDrivenMvccExternalTable table = Mockito.mock(PluginDrivenMvccExternalTable.class); + DatabaseIf db = Mockito.mock(DatabaseIf.class); + CatalogIf catalog = Mockito.mock(CatalogIf.class); + Mockito.when(catalog.isInternalCatalog()).thenReturn(false); + Mockito.when(catalog.getName()).thenReturn("hms_ctl"); + Mockito.when(catalog.getProperties()).thenReturn(Maps.newHashMap()); + Mockito.when(db.getCatalog()).thenReturn(catalog); + Mockito.when(db.getFullName()).thenReturn("hms_db"); + Mockito.when(table.getDatabase()).thenReturn(db); + Mockito.when(table.getId()).thenReturn(id); + Mockito.when(table.getName()).thenReturn("t"); + Mockito.when(table.getType()).thenReturn(TableType.PLUGIN_EXTERNAL_TABLE); + Mockito.when(table.getNewestUpdateVersionOrTime()).thenReturn(token); + return table; + } + + /** + * A plugin table with a real data-version token is admitted, and the recorded TableVersion carries the + * connector token (not a wall-clock or a constant 0). RED on the pre-cutover HEAD, which only recorded a + * token for {@code instanceof HMSExternalTable} and left a flipped plugin table at version 0. + */ + @Test + public void testAddUsedTableCapturesConnectorToken() { + SqlCacheContext context = new SqlCacheContext(UserIdentity.ROOT); + long token = 1_700_000_000_000L; + context.addUsedTable(mockTable(42L, token)); + + Assertions.assertFalse(context.hasUnsupportedTables()); + Assertions.assertEquals(1, context.getUsedTables().size()); + TableVersion recorded = context.getUsedTables().values().iterator().next(); + Assertions.assertEquals(42L, recorded.id); + Assertions.assertEquals(token, recorded.version); + Assertions.assertEquals(TableType.PLUGIN_EXTERNAL_TABLE, recorded.type); + } + + /** + * A non-positive token means the connector has no reliable data-change signal (empty partition set / + * dropped table). The table must be marked unsupported (fail safe) rather than cached against a bogus + * constant that could never invalidate. + */ + @Test + public void testAddUsedTableFailsSafeOnNonPositiveToken() { + SqlCacheContext context = new SqlCacheContext(UserIdentity.ROOT); + context.addUsedTable(mockTable(42L, 0L)); + + Assertions.assertTrue(context.hasUnsupportedTables()); + Assertions.assertTrue(context.getUsedTables().isEmpty()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/StatementContextMvccSnapshotTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/StatementContextMvccSnapshotTest.java new file mode 100644 index 00000000000000..44f8e9af8c887e --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/StatementContextMvccSnapshotTest.java @@ -0,0 +1,174 @@ +// 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; + +import org.apache.doris.analysis.TableScanParams; +import org.apache.doris.analysis.TableSnapshot; +import org.apache.doris.catalog.DatabaseIf; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.datasource.CatalogIf; +import org.apache.doris.datasource.mvcc.MvccSnapshot; +import org.apache.doris.datasource.mvcc.MvccTable; +import org.apache.doris.qe.ConnectContext; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Optional; + +/** + * Unit tests for {@link StatementContext}'s version-aware MVCC snapshot map. + * + *

    A statement that references the SAME table at different selectors (main vs {@code @branch}/{@code @tag}/ + * FOR-TIME) must pin one snapshot per selector. The pre-fix map keyed only on (catalog, db, table), so a + * statement mixing main and {@code @branch} of one table (e.g. {@code (select max(value) from t@branch(b1)) + * ... from t}) collapsed to a single entry and the {@code @branch} reference reused main's snapshot — reading + * the wrong data. These tests pin that keying and the version-blind fallback the metadata readers rely on. + */ +public class StatementContextMvccSnapshotTest { + + private static StatementContext newStatementContext() { + return new StatementContext(new ConnectContext(), null); + } + + @SuppressWarnings("unchecked") + private static MvccTable mockMvccTable(String name) { + MvccTable table = Mockito.mock(MvccTable.class); + DatabaseIf database = Mockito.mock(DatabaseIf.class); + CatalogIf catalog = Mockito.mock(CatalogIf.class); + Mockito.when(table.getName()).thenReturn(name); + Mockito.when(table.getDatabase()).thenReturn(database); + Mockito.when(database.getFullName()).thenReturn("db"); + Mockito.when(database.getCatalog()).thenReturn(catalog); + Mockito.when(catalog.getName()).thenReturn("ctl"); + return table; + } + + private static TableScanParams branch(String name) { + return new TableScanParams("branch", ImmutableMap.of(), ImmutableList.of(name)); + } + + @Test + public void mainAndBranchOfSameTablePinSeparateSnapshots() { + StatementContext ctx = newStatementContext(); + MvccTable table = mockMvccTable("t"); + MvccSnapshot mainSnap = Mockito.mock(MvccSnapshot.class); + MvccSnapshot branchSnap = Mockito.mock(MvccSnapshot.class); + TableScanParams b1 = branch("b1"); + Mockito.when(table.loadSnapshot(Optional.empty(), Optional.empty())).thenReturn(mainSnap); + Mockito.when(table.loadSnapshot(Optional.empty(), Optional.of(b1))).thenReturn(branchSnap); + + // The complex_queries scenario: main reference, then @branch(b1) reference of the SAME table. + ctx.loadSnapshots(table, Optional.empty(), Optional.empty()); + ctx.loadSnapshots(table, Optional.empty(), Optional.of(b1)); + + // Version-aware: each reference resolves to ITS OWN snapshot (no first-write-wins collapse). + Assertions.assertSame(mainSnap, + ctx.getSnapshot(table, Optional.empty(), Optional.empty()).orElse(null), + "main reference must read main's snapshot"); + Assertions.assertSame(branchSnap, + ctx.getSnapshot(table, Optional.empty(), Optional.of(b1)).orElse(null), + "@branch reference must read the branch snapshot, not main's"); + // Content-based key: a DIFFERENT but equal @branch(b1) selector (as built independently at scan time + // from the threaded TableScanParams) still resolves to the branch snapshot. + Assertions.assertSame(branchSnap, + ctx.getSnapshot(table, Optional.empty(), Optional.of(branch("b1"))).orElse(null), + "version key must be content-based, not identity-based"); + // Version-blind reader: with both pinned it returns the default (main) deterministically. + Assertions.assertSame(mainSnap, ctx.getSnapshot(table).orElse(null), + "version-blind reader returns the default (main) snapshot when one is pinned"); + } + + @Test + public void standaloneBranchResolvesForVersionBlindReader() { + StatementContext ctx = newStatementContext(); + MvccTable table = mockMvccTable("t"); + MvccSnapshot branchSnap = Mockito.mock(MvccSnapshot.class); + TableScanParams b1 = branch("b1"); + Mockito.when(table.loadSnapshot(Optional.empty(), Optional.of(b1))).thenReturn(branchSnap); + + // The qt_agg_max scenario: only an @branch reference, so no default ("") entry is ever pinned. + ctx.loadSnapshots(table, Optional.empty(), Optional.of(b1)); + + // The version-blind metadata/schema readers must still see the lone pinned snapshot (else a + // standalone @branch read would resolve schema/partitions against the wrong snapshot). + Assertions.assertSame(branchSnap, ctx.getSnapshot(table).orElse(null), + "a lone pinned snapshot is returned to version-blind readers"); + Assertions.assertSame(branchSnap, + ctx.getSnapshot(table, Optional.empty(), Optional.of(b1)).orElse(null)); + } + + @Test + public void twoBranchesWithoutMainAreAmbiguousForVersionBlindReader() { + StatementContext ctx = newStatementContext(); + MvccTable table = mockMvccTable("t"); + MvccSnapshot snap1 = Mockito.mock(MvccSnapshot.class); + MvccSnapshot snap2 = Mockito.mock(MvccSnapshot.class); + TableScanParams b1 = branch("b1"); + TableScanParams b2 = branch("b2"); + Mockito.when(table.loadSnapshot(Optional.empty(), Optional.of(b1))).thenReturn(snap1); + Mockito.when(table.loadSnapshot(Optional.empty(), Optional.of(b2))).thenReturn(snap2); + + ctx.loadSnapshots(table, Optional.empty(), Optional.of(b1)); + ctx.loadSnapshots(table, Optional.empty(), Optional.of(b2)); + + // Version-aware still resolves each branch precisely. + Assertions.assertSame(snap1, ctx.getSnapshot(table, Optional.empty(), Optional.of(b1)).orElse(null)); + Assertions.assertSame(snap2, ctx.getSnapshot(table, Optional.empty(), Optional.of(b2)).orElse(null)); + // Version-blind reader: two pinned versions and no default -> ambiguous -> empty so the caller falls + // back to latest (rather than returning an arbitrary branch, the pre-fix bug). + Assertions.assertFalse(ctx.getSnapshot(table).isPresent(), + "version-blind read is ambiguous with multiple versions pinned and no default"); + } + + @Test + public void forVersionAndForTimeSelectorsKeyDistinctly() { + StatementContext ctx = newStatementContext(); + MvccTable table = mockMvccTable("t"); + MvccSnapshot versionSnap = Mockito.mock(MvccSnapshot.class); + MvccSnapshot timeSnap = Mockito.mock(MvccSnapshot.class); + TableSnapshot version5 = TableSnapshot.versionOf("5"); + TableSnapshot time0101 = TableSnapshot.timeOf("2024-01-01"); + Mockito.when(table.loadSnapshot(Optional.of(version5), Optional.empty())).thenReturn(versionSnap); + Mockito.when(table.loadSnapshot(Optional.of(time0101), Optional.empty())).thenReturn(timeSnap); + + ctx.loadSnapshots(table, Optional.of(version5), Optional.empty()); + ctx.loadSnapshots(table, Optional.of(time0101), Optional.empty()); + + // FOR VERSION AS OF and FOR TIME AS OF of the same table must not collapse either. + Assertions.assertSame(versionSnap, + ctx.getSnapshot(table, Optional.of(version5), Optional.empty()).orElse(null)); + Assertions.assertSame(timeSnap, + ctx.getSnapshot(table, Optional.of(time0101), Optional.empty()).orElse(null)); + Assertions.assertFalse(ctx.getSnapshot(table).isPresent(), + "two distinct time-travel selectors and no default -> version-blind read is ambiguous"); + } + + @Test + public void nonMvccTableNeverPinsOrResolves() { + StatementContext ctx = newStatementContext(); + TableIf plain = Mockito.mock(TableIf.class); + // A non-MvccTable is a no-op for loadSnapshots and always empty for both getSnapshot variants. + ctx.loadSnapshots(plain, Optional.empty(), Optional.empty()); + Assertions.assertFalse(ctx.getSnapshot(plain).isPresent()); + Assertions.assertFalse(ctx.getSnapshot(plain, Optional.empty(), Optional.empty()).isPresent()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/StatementContextTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/StatementContextTest.java index 8db96ac9a6d4e6..d43bebbee2dcd0 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/StatementContextTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/StatementContextTest.java @@ -21,12 +21,9 @@ import org.apache.doris.catalog.DatabaseIf; import org.apache.doris.catalog.TableIf; import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.PluginDrivenExternalTable; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HMSExternalTable.DLAType; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; import org.apache.doris.datasource.mvcc.MvccSnapshot; -import org.apache.doris.datasource.paimon.PaimonExternalTable; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.nereids.rules.analysis.PreloadExternalMetadata; import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan.SelectedPartitions; import org.apache.doris.qe.ConnectContext; @@ -43,106 +40,11 @@ public class StatementContextTest { - @Test - public void testPreloadExternalTablesBeforeLock() { - ConnectContext connectContext = Mockito.mock(ConnectContext.class); - TableIf internalTable = Mockito.mock(TableIf.class); - HMSExternalTable hmsExternalTable = Mockito.mock(HMSExternalTable.class); - DatabaseIf database = mockDatabase(); - CatalogIf catalog = mockCatalog(); - MvccSnapshot mvccSnapshot = Mockito.mock(MvccSnapshot.class); - SessionVariable sessionVariable = new SessionVariable(); - sessionVariable.setEnablePreloadExternalMetadata(true); - - // Mock the latest Hudi preload path and a lock-requiring internal table. - Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable); - Mockito.when(connectContext.getQueryIdentifier()).thenReturn("query-1"); - Mockito.when(internalTable.needReadLockWhenPlan()).thenReturn(true); - Mockito.when(hmsExternalTable.getId()).thenReturn(10L); - Mockito.when(hmsExternalTable.getName()).thenReturn("hudi_tbl"); - Mockito.when(hmsExternalTable.getDatabase()).thenReturn(database); - Mockito.when(database.getFullName()).thenReturn("db"); - Mockito.when(database.getCatalog()).thenReturn(catalog); - Mockito.when(catalog.getName()).thenReturn("ctl"); - Mockito.when(hmsExternalTable.supportsExternalMetadataPreload()).thenReturn(true); - Mockito.when(hmsExternalTable.supportsLatestSnapshotPreload()).thenReturn(true); - Mockito.when(hmsExternalTable.getDlaType()).thenReturn(DLAType.HUDI); - Mockito.when(hmsExternalTable.loadSnapshot(Mockito.>any(), Mockito.any())) - .thenReturn(mvccSnapshot); - Mockito.when(hmsExternalTable.getBaseSchema()).thenReturn(Collections.emptyList()); - Mockito.when(hmsExternalTable.supportInternalPartitionPruned()).thenReturn(true); - Mockito.when(hmsExternalTable.initSelectedPartitions(Mockito.any())).thenReturn(SelectedPartitions.NOT_PRUNED); - - StatementContext statementContext = new StatementContext(connectContext, new OriginStatement("select 1", 0)); - try { - statementContext.getTables().put(ImmutableList.of("ctl", "db", "internal"), internalTable); - statementContext.registerExternalTableForPreload(hmsExternalTable, Optional.empty(), Optional.empty()); - - ExternalMetadataPreloadResult result = executePreload(statementContext); - - org.junit.jupiter.api.Assertions.assertTrue(result.isExecuted()); - org.junit.jupiter.api.Assertions.assertEquals(1, result.getCandidateTableCount()); - org.junit.jupiter.api.Assertions.assertEquals(1, result.getPreloadedTableCount()); - Mockito.verify(hmsExternalTable, Mockito.times(1)) - .loadSnapshot(Mockito.>any(), Mockito.any()); - Mockito.verify(hmsExternalTable, Mockito.times(1)).getBaseSchema(); - Mockito.verify(hmsExternalTable, Mockito.times(1)).initSelectedPartitions(Mockito.any()); - } finally { - statementContext.close(); - } - } - - @Test - public void testPreloadHiveSchemaAndPartitionsBeforeLock() { - ConnectContext connectContext = Mockito.mock(ConnectContext.class); - TableIf internalTable = Mockito.mock(TableIf.class); - HMSExternalTable hmsExternalTable = Mockito.mock(HMSExternalTable.class); - DatabaseIf database = mockDatabase(); - CatalogIf catalog = mockCatalog(); - SessionVariable sessionVariable = new SessionVariable(); - sessionVariable.setEnablePreloadExternalMetadata(true); - - // Cover the plain Hive path: no latest snapshot preload, but schema and partition metadata are warmed. - Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable); - Mockito.when(connectContext.getQueryIdentifier()).thenReturn("query-hive"); - Mockito.when(internalTable.needReadLockWhenPlan()).thenReturn(true); - Mockito.when(hmsExternalTable.getId()).thenReturn(19L); - Mockito.when(hmsExternalTable.getName()).thenReturn("hive_tbl"); - Mockito.when(hmsExternalTable.getDatabase()).thenReturn(database); - Mockito.when(database.getFullName()).thenReturn("db"); - Mockito.when(database.getCatalog()).thenReturn(catalog); - Mockito.when(catalog.getName()).thenReturn("ctl"); - Mockito.when(hmsExternalTable.supportsExternalMetadataPreload()).thenReturn(true); - Mockito.when(hmsExternalTable.supportsLatestSnapshotPreload()).thenReturn(false); - Mockito.when(hmsExternalTable.getDlaType()).thenReturn(DLAType.HIVE); - Mockito.when(hmsExternalTable.getBaseSchema()).thenReturn(Collections.emptyList()); - Mockito.when(hmsExternalTable.supportInternalPartitionPruned()).thenReturn(true); - Mockito.when(hmsExternalTable.initSelectedPartitions(Mockito.any())).thenReturn(SelectedPartitions.NOT_PRUNED); - - StatementContext statementContext = new StatementContext(connectContext, new OriginStatement("select 1", 0)); - try { - statementContext.getTables().put(ImmutableList.of("ctl", "db", "internal"), internalTable); - statementContext.registerExternalTableForPreload(hmsExternalTable, Optional.empty(), Optional.empty()); - - ExternalMetadataPreloadResult result = executePreload(statementContext); - - org.junit.jupiter.api.Assertions.assertTrue(result.isExecuted()); - org.junit.jupiter.api.Assertions.assertEquals(1, result.getCandidateTableCount()); - org.junit.jupiter.api.Assertions.assertEquals(1, result.getPreloadedTableCount()); - Mockito.verify(hmsExternalTable, Mockito.never()) - .loadSnapshot(Mockito.>any(), Mockito.any()); - Mockito.verify(hmsExternalTable, Mockito.times(1)).getBaseSchema(); - Mockito.verify(hmsExternalTable, Mockito.times(1)).initSelectedPartitions(Mockito.any()); - } finally { - statementContext.close(); - } - } - @Test public void testSkipPreloadWhenSessionVariableDisabled() { ConnectContext connectContext = Mockito.mock(ConnectContext.class); TableIf internalTable = Mockito.mock(TableIf.class); - HMSExternalTable hmsExternalTable = Mockito.mock(HMSExternalTable.class); + PluginDrivenExternalTable hmsExternalTable = Mockito.mock(PluginDrivenExternalTable.class); SessionVariable sessionVariable = new SessionVariable(); // Keep the preload switch disabled so no external access should happen. @@ -168,144 +70,6 @@ public void testSkipPreloadWhenSessionVariableDisabled() { } } - @Test - public void testSkipLatestPreloadWhenExplicitSnapshotExists() { - ConnectContext connectContext = Mockito.mock(ConnectContext.class); - TableIf internalTable = Mockito.mock(TableIf.class); - HMSExternalTable hmsExternalTable = Mockito.mock(HMSExternalTable.class); - DatabaseIf database = mockDatabase(); - CatalogIf catalog = mockCatalog(); - SessionVariable sessionVariable = new SessionVariable(); - sessionVariable.setEnablePreloadExternalMetadata(true); - - // Mark one relation as latest and another relation as explicit snapshot, then skip latest snapshot preload. - Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable); - Mockito.when(connectContext.getQueryIdentifier()).thenReturn("query-2"); - Mockito.when(internalTable.needReadLockWhenPlan()).thenReturn(true); - Mockito.when(hmsExternalTable.getId()).thenReturn(12L); - Mockito.when(hmsExternalTable.getName()).thenReturn("hudi_tbl"); - Mockito.when(hmsExternalTable.getDatabase()).thenReturn(database); - Mockito.when(database.getFullName()).thenReturn("db"); - Mockito.when(database.getCatalog()).thenReturn(catalog); - Mockito.when(catalog.getName()).thenReturn("ctl"); - Mockito.when(hmsExternalTable.supportsExternalMetadataPreload()).thenReturn(true); - Mockito.when(hmsExternalTable.supportsLatestSnapshotPreload()).thenReturn(true); - Mockito.when(hmsExternalTable.getDlaType()).thenReturn(DLAType.HUDI); - - StatementContext statementContext = new StatementContext(connectContext, new OriginStatement("select 1", 0)); - try { - statementContext.getTables().put(ImmutableList.of("ctl", "db", "internal"), internalTable); - statementContext.registerExternalTableForPreload(hmsExternalTable, Optional.empty(), Optional.empty()); - statementContext.registerExternalTableForPreload(hmsExternalTable, - Optional.of(new TableSnapshot("2024-01-01 00:00:00", TableSnapshot.VersionType.TIME)), - Optional.empty()); - - ExternalMetadataPreloadResult result = executePreload(statementContext); - - org.junit.jupiter.api.Assertions.assertTrue(result.isExecuted()); - org.junit.jupiter.api.Assertions.assertEquals(1, result.getCandidateTableCount()); - org.junit.jupiter.api.Assertions.assertEquals(0, result.getPreloadedTableCount()); - Mockito.verify(hmsExternalTable, Mockito.never()) - .loadSnapshot(Mockito.>any(), Mockito.any()); - Mockito.verify(hmsExternalTable, Mockito.never()).getBaseSchema(); - Mockito.verify(hmsExternalTable, Mockito.never()).initSelectedPartitions(Mockito.any()); - } finally { - statementContext.close(); - } - } - - @Test - public void testPreloadHmsIcebergLatestSnapshotBeforeLock() { - ConnectContext connectContext = Mockito.mock(ConnectContext.class); - TableIf internalTable = Mockito.mock(TableIf.class); - HMSExternalTable hmsExternalTable = Mockito.mock(HMSExternalTable.class); - DatabaseIf database = mockDatabase(); - CatalogIf catalog = mockCatalog(); - MvccSnapshot mvccSnapshot = Mockito.mock(MvccSnapshot.class); - SessionVariable sessionVariable = new SessionVariable(); - sessionVariable.setEnablePreloadExternalMetadata(true); - - // Cover the HMS Iceberg branch using the real trait implementation. - Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable); - Mockito.when(internalTable.needReadLockWhenPlan()).thenReturn(true); - Mockito.when(hmsExternalTable.getId()).thenReturn(14L); - Mockito.when(hmsExternalTable.getName()).thenReturn("hms_iceberg_tbl"); - Mockito.when(hmsExternalTable.getDatabase()).thenReturn(database); - Mockito.when(database.getFullName()).thenReturn("db"); - Mockito.when(database.getCatalog()).thenReturn(catalog); - Mockito.when(catalog.getName()).thenReturn("ctl"); - Mockito.when(hmsExternalTable.supportsExternalMetadataPreload()).thenReturn(true); - Mockito.doCallRealMethod().when(hmsExternalTable).supportsLatestSnapshotPreload(); - Mockito.when(hmsExternalTable.getDlaType()).thenReturn(DLAType.ICEBERG); - Mockito.when(hmsExternalTable.loadSnapshot(Mockito.>any(), Mockito.any())) - .thenReturn(mvccSnapshot); - Mockito.when(hmsExternalTable.getBaseSchema()).thenReturn(Collections.emptyList()); - Mockito.when(hmsExternalTable.supportInternalPartitionPruned()).thenReturn(false); - - StatementContext statementContext = new StatementContext(connectContext, new OriginStatement("select 1", 0)); - try { - statementContext.getTables().put(ImmutableList.of("ctl", "db", "internal"), internalTable); - statementContext.registerExternalTableForPreload(hmsExternalTable, Optional.empty(), Optional.empty()); - - ExternalMetadataPreloadResult result = executePreload(statementContext); - - org.junit.jupiter.api.Assertions.assertTrue(result.isExecuted()); - org.junit.jupiter.api.Assertions.assertEquals(1, result.getCandidateTableCount()); - org.junit.jupiter.api.Assertions.assertEquals(1, result.getPreloadedTableCount()); - Mockito.verify(hmsExternalTable, Mockito.times(1)) - .loadSnapshot(Mockito.>any(), Mockito.any()); - Mockito.verify(hmsExternalTable, Mockito.times(1)).getBaseSchema(); - Mockito.verify(hmsExternalTable, Mockito.never()).initSelectedPartitions(Mockito.any()); - } finally { - statementContext.close(); - } - } - - @Test - public void testSkipHmsIcebergPreloadWhenOnlyNonLatestRelationExists() { - ConnectContext connectContext = Mockito.mock(ConnectContext.class); - TableIf internalTable = Mockito.mock(TableIf.class); - HMSExternalTable hmsExternalTable = Mockito.mock(HMSExternalTable.class); - DatabaseIf database = mockDatabase(); - CatalogIf catalog = mockCatalog(); - SessionVariable sessionVariable = new SessionVariable(); - sessionVariable.setEnablePreloadExternalMetadata(true); - - // Skip latest schema warmup when HMS Iceberg is referenced only by non-latest relations. - Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable); - Mockito.when(internalTable.needReadLockWhenPlan()).thenReturn(true); - Mockito.when(hmsExternalTable.getId()).thenReturn(15L); - Mockito.when(hmsExternalTable.getName()).thenReturn("hms_iceberg_tbl"); - Mockito.when(hmsExternalTable.getDatabase()).thenReturn(database); - Mockito.when(database.getFullName()).thenReturn("db"); - Mockito.when(database.getCatalog()).thenReturn(catalog); - Mockito.when(catalog.getName()).thenReturn("ctl"); - Mockito.when(hmsExternalTable.supportsExternalMetadataPreload()).thenReturn(true); - Mockito.doCallRealMethod().when(hmsExternalTable).supportsLatestSnapshotPreload(); - Mockito.when(hmsExternalTable.getDlaType()).thenReturn(DLAType.ICEBERG); - Mockito.when(hmsExternalTable.supportInternalPartitionPruned()).thenReturn(false); - - StatementContext statementContext = new StatementContext(connectContext, new OriginStatement("select 1", 0)); - try { - statementContext.getTables().put(ImmutableList.of("ctl", "db", "internal"), internalTable); - statementContext.registerExternalTableForPreload(hmsExternalTable, - Optional.of(new TableSnapshot("2024-01-01 00:00:00", TableSnapshot.VersionType.TIME)), - Optional.empty()); - - ExternalMetadataPreloadResult result = executePreload(statementContext); - - org.junit.jupiter.api.Assertions.assertTrue(result.isExecuted()); - org.junit.jupiter.api.Assertions.assertEquals(1, result.getCandidateTableCount()); - org.junit.jupiter.api.Assertions.assertEquals(0, result.getPreloadedTableCount()); - Mockito.verify(hmsExternalTable, Mockito.never()) - .loadSnapshot(Mockito.>any(), Mockito.any()); - Mockito.verify(hmsExternalTable, Mockito.never()).getBaseSchema(); - Mockito.verify(hmsExternalTable, Mockito.never()).initSelectedPartitions(Mockito.any()); - } finally { - statementContext.close(); - } - } - @Test public void testPreloadJdbcExternalTablesBeforeLock() { ConnectContext connectContext = Mockito.mock(ConnectContext.class); @@ -376,7 +140,7 @@ public void testSkipPreloadForNonJdbcPluginExternalTable() { public void testSkipPreloadWhenNoInternalTableNeedsPlanReadLock() { ConnectContext connectContext = Mockito.mock(ConnectContext.class); TableIf internalTable = Mockito.mock(TableIf.class); - HMSExternalTable hmsExternalTable = Mockito.mock(HMSExternalTable.class); + PluginDrivenExternalTable hmsExternalTable = Mockito.mock(PluginDrivenExternalTable.class); SessionVariable sessionVariable = new SessionVariable(); sessionVariable.setEnablePreloadExternalMetadata(true); @@ -408,7 +172,7 @@ public void testSkipPreloadWhenNoInternalTableNeedsPlanReadLock() { public void testPreloadIcebergLatestSnapshotBeforeLock() { ConnectContext connectContext = Mockito.mock(ConnectContext.class); TableIf internalTable = Mockito.mock(TableIf.class); - IcebergExternalTable icebergExternalTable = Mockito.mock(IcebergExternalTable.class); + PluginDrivenMvccExternalTable icebergExternalTable = Mockito.mock(PluginDrivenMvccExternalTable.class); DatabaseIf database = mockDatabase(); CatalogIf catalog = mockCatalog(); MvccSnapshot mvccSnapshot = Mockito.mock(MvccSnapshot.class); @@ -453,7 +217,7 @@ public void testPreloadIcebergLatestSnapshotBeforeLock() { public void testSkipIcebergPreloadWhenOnlyNonLatestRelationExists() { ConnectContext connectContext = Mockito.mock(ConnectContext.class); TableIf internalTable = Mockito.mock(TableIf.class); - IcebergExternalTable icebergExternalTable = Mockito.mock(IcebergExternalTable.class); + PluginDrivenMvccExternalTable icebergExternalTable = Mockito.mock(PluginDrivenMvccExternalTable.class); DatabaseIf database = mockDatabase(); CatalogIf catalog = mockCatalog(); SessionVariable sessionVariable = new SessionVariable(); @@ -497,7 +261,7 @@ public void testSkipIcebergPreloadWhenOnlyNonLatestRelationExists() { public void testPreloadPaimonLatestSnapshotBeforeLock() { ConnectContext connectContext = Mockito.mock(ConnectContext.class); TableIf internalTable = Mockito.mock(TableIf.class); - PaimonExternalTable paimonExternalTable = Mockito.mock(PaimonExternalTable.class); + PluginDrivenMvccExternalTable paimonExternalTable = Mockito.mock(PluginDrivenMvccExternalTable.class); DatabaseIf database = mockDatabase(); CatalogIf catalog = mockCatalog(); MvccSnapshot mvccSnapshot = Mockito.mock(MvccSnapshot.class); 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 new file mode 100644 index 00000000000000..2960a1f5fbfc08 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorAdmissionGateTest.java @@ -0,0 +1,177 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.nereids.glue.translator; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.PrimitiveType; +import org.apache.doris.common.jmockit.Deencapsulation; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.nereids.exceptions.AnalysisException; +import org.apache.doris.nereids.trees.plans.Plan; +import org.apache.doris.nereids.trees.plans.physical.PhysicalConnectorTableSink; +import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergDeleteSink; +import org.apache.doris.planner.DataSink; +import org.apache.doris.planner.PlanFragment; +import org.apache.doris.planner.PluginDrivenTableSink; + +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.util.EnumSet; +import java.util.Optional; +import java.util.Set; + +/** + * Pins the two generic write-admission gates the neutral translator enforces over + * {@link Connector#supportedWriteOperations()} (P6 write-capability unification, Task 6): the INSERT gate in + * {@link PhysicalPlanTranslator#visitPhysicalConnectorTableSink} and the row-level-DML gate in the plugin arm + * of {@link PhysicalPlanTranslator#visitPhysicalIcebergDeleteSink} — WITH DISTINCT rejection messages, so a + * connector declaring only {@code {INSERT}} is admitted for a plain write but rejected for DELETE/MERGE, not + * lumped into one coarse "no writes supported" gate. This is the granularity regression guard for Task 3's + * admission rewrite: a mutation that merges the two gates (or swaps their messages) turns these red. + */ +public class PhysicalPlanTranslatorAdmissionGateTest { + + private static final Column DATA = new Column("data", PrimitiveType.INT); + + @Test + public void insertGateAllowsConnectorDeclaringInsert() { + PlanTranslatorContext context = new PlanTranslatorContext(); + PlanFragment childFragment = Mockito.mock(PlanFragment.class); + PluginDrivenExternalTable table = pluginTable(EnumSet.of(WriteOperation.INSERT)); + + @SuppressWarnings("unchecked") + PhysicalConnectorTableSink sink = Mockito.mock(PhysicalConnectorTableSink.class); + Mockito.doReturn(mockChild(childFragment)).when(sink).child(); + Mockito.doReturn(table).when(sink).getTargetTable(); + Mockito.doReturn(ImmutableList.of(DATA)).when(sink).getCols(); + Mockito.doReturn(false).when(sink).isRewrite(); + + PhysicalPlanTranslator translator = new PhysicalPlanTranslator(context, null); + translator.visitPhysicalConnectorTableSink(sink, context); + + PluginDrivenTableSink pluginSink = capturePluginSink(childFragment); + Assertions.assertEquals(WriteOperation.INSERT, Deencapsulation.getField(pluginSink, "writeOperation"), + "a connector declaring INSERT must reach the sink machinery with WriteOperation.INSERT, not be " + + "rejected by the admission gate"); + } + + @Test + public void insertGateRejectsConnectorNotDeclaringInsert() { + // {} mirrors the null-write-provider connector's delegator view: Connector.supportedWriteOperations() + // is empty whenever getWritePlanProvider() returns null. The gate must reject before ever resolving a + // write plan provider / calling planWrite. + PlanTranslatorContext context = new PlanTranslatorContext(); + PlanFragment childFragment = Mockito.mock(PlanFragment.class); + PluginDrivenExternalTable table = pluginTable(EnumSet.noneOf(WriteOperation.class)); + + @SuppressWarnings("unchecked") + PhysicalConnectorTableSink sink = Mockito.mock(PhysicalConnectorTableSink.class); + Mockito.doReturn(mockChild(childFragment)).when(sink).child(); + Mockito.doReturn(table).when(sink).getTargetTable(); + Mockito.doReturn(ImmutableList.of(DATA)).when(sink).getCols(); + + PhysicalPlanTranslator translator = new PhysicalPlanTranslator(context, null); + AnalysisException ex = Assertions.assertThrows(AnalysisException.class, + () -> translator.visitPhysicalConnectorTableSink(sink, context)); + Assertions.assertTrue(ex.getMessage().contains("does not support INSERT operations"), + "got: " + ex.getMessage()); + } + + @Test + public void rowLevelDmlGateRejectsConnectorDeclaringOnlyInsertWithDistinctMessage() { + // Declares INSERT (would pass the INSERT gate above) but neither DELETE nor MERGE: the row-level DML + // helper must reject it, and with a message DISTINCT from the INSERT gate's, so logs/callers can tell + // "this connector can't do row-level DML at all" apart from "this connector can't write at all". + PlanTranslatorContext context = new PlanTranslatorContext(); + PlanFragment childFragment = Mockito.mock(PlanFragment.class); + PluginDrivenExternalTable table = pluginTable(EnumSet.of(WriteOperation.INSERT)); + + @SuppressWarnings("unchecked") + PhysicalIcebergDeleteSink sink = Mockito.mock(PhysicalIcebergDeleteSink.class); + Mockito.doReturn(mockChild(childFragment)).when(sink).child(); + Mockito.doReturn(table).when(sink).getTargetTable(); + Mockito.doReturn(ImmutableList.of(DATA)).when(sink).getCols(); + + PhysicalPlanTranslator translator = new PhysicalPlanTranslator(context, null); + AnalysisException ex = Assertions.assertThrows(AnalysisException.class, + () -> translator.visitPhysicalIcebergDeleteSink(sink, context)); + Assertions.assertTrue(ex.getMessage().contains("does not support row-level DML operations"), + "got: " + ex.getMessage()); + Assertions.assertFalse(ex.getMessage().contains("does not support INSERT operations"), + "the row-level DML rejection must be a message DISTINCT from the INSERT gate's, got: " + + ex.getMessage()); + } + + // ==================== helpers ==================== + + private static Plan mockChild(PlanFragment childFragment) { + Plan child = Mockito.mock(Plan.class); + Mockito.doReturn(childFragment).when(child).accept(Mockito.any(), Mockito.any()); + return child; + } + + /** A plugin-driven table whose connector declares exactly the given write operations. */ + private static PluginDrivenExternalTable pluginTable(Set ops) { + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + // The write seams now resolve metadata through the per-statement funnel, which reads the session's + // statement scope; offline tests use NONE (a fresh getMetadata per call, byte-identical to pre-funnel). + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + ConnectorWritePlanProvider provider = Mockito.mock(ConnectorWritePlanProvider.class); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getWritePlanProvider()).thenReturn(provider); + // 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); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); + Mockito.when(metadata.getTableHandle(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(Optional.of(handle)); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(table.getCatalog()).thenReturn(catalog); + Mockito.when(table.getRemoteDbName()).thenReturn("db"); + Mockito.when(table.getRemoteName()).thenReturn("t"); + return table; + } + + private static PluginDrivenTableSink capturePluginSink(PlanFragment childFragment) { + ArgumentCaptor captor = ArgumentCaptor.forClass(DataSink.class); + Mockito.verify(childFragment).setSink(captor.capture()); + DataSink built = captor.getValue(); + Assertions.assertTrue(built instanceof PluginDrivenTableSink, + "must route through the generic PluginDrivenTableSink, was " + built.getClass().getSimpleName()); + return (PluginDrivenTableSink) built; + } +} 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 new file mode 100644 index 00000000000000..a5abd7bcfb5b49 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorIcebergRowLevelDmlTest.java @@ -0,0 +1,314 @@ +// 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.glue.translator; + +import org.apache.doris.analysis.Expr; +import org.apache.doris.analysis.SlotDescriptor; +import org.apache.doris.analysis.TupleDescriptor; +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.PrimitiveType; +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.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; +import org.apache.doris.datasource.mvcc.MvccUtil; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccSnapshot; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.nereids.trees.expressions.Slot; +import org.apache.doris.nereids.trees.expressions.SlotReference; +import org.apache.doris.nereids.trees.plans.Plan; +import org.apache.doris.nereids.trees.plans.commands.merge.MergeOperation; +import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergDeleteSink; +import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergMergeSink; +import org.apache.doris.nereids.types.IntegerType; +import org.apache.doris.planner.DataSink; +import org.apache.doris.planner.PlanFragment; +import org.apache.doris.planner.PluginDrivenTableSink; + +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Optional; + +/** + * Unit tests for the dual-mode routing added to the iceberg row-level DML translator visitors + * ({@link PhysicalPlanTranslator#visitPhysicalIcebergDeleteSink} / + * {@code visitPhysicalIcebergMergeSink}) for the iceberg SPI cutover (commit-bridge S5d). + * + *

    Pre-flip the target is a native {@code IcebergExternalTable} and the visitor builds the native + * {@code IcebergDeleteSink} / {@code IcebergMergeSink} (byte-identical; its native end-to-end test + * {@code IcebergDDLAndDMLPlanTest} was retired with the P6.6 iceberg cutover as the native arm is no longer + * reachable, and the native sink ctor loads vended credentials + the live iceberg table, so it is not + * exercised at the translator unit level here). Post-flip the target is a + * {@link PluginDrivenExternalTable} and the visitor must route through the generic + * {@link PluginDrivenTableSink} with the matching {@link WriteOperation}, so the connector's + * {@code planWrite} emits its DELETE / MERGE BE sink dialect. These tests pin the plugin (post-flip) arm.

    + * + *

    Load-bearing invariants pinned here: + *

      + *
    • routing + op: the plugin arm builds a {@link PluginDrivenTableSink} carrying DELETE / MERGE;
    • + *
    • MERGE output-expr loop lift: the operation/row-id materialized-name loop runs for the plugin arm + * (it is lifted above the native/plugin branch) and the operation + row-id slots are published in the + * fragment output exprs — BE's {@code viceberg_merge_sink} resolves them by output-expr name regardless + * of the sink dialect;
    • + *
    • DELETE has no loop: the DELETE arm publishes no output exprs (BE resolves the row id by block + * column name, not output-expr name);
    • + *
    • Fix B pin: the statement's MVCC read snapshot is threaded onto the write handle.
    • + *

    + */ +public class PhysicalPlanTranslatorIcebergRowLevelDmlTest { + + private static final Column DATA = new Column("data", PrimitiveType.INT); + + @Test + public void deletePluginArmRoutesToPluginSinkWithDeleteOperationAndNoOutputExprs() { + PlanTranslatorContext context = new PlanTranslatorContext(); + PlanFragment childFragment = Mockito.mock(PlanFragment.class); + Plugin plugin = pluginTable(); + + @SuppressWarnings("unchecked") + PhysicalIcebergDeleteSink sink = Mockito.mock(PhysicalIcebergDeleteSink.class); + Mockito.doReturn(mockChild(childFragment)).when(sink).child(); + Mockito.doReturn(plugin.table).when(sink).getTargetTable(); + Mockito.doReturn(ImmutableList.of(DATA)).when(sink).getCols(); + + PhysicalPlanTranslator translator = new PhysicalPlanTranslator(context, null); + translator.visitPhysicalIcebergDeleteSink(sink, context); + + PluginDrivenTableSink pluginSink = capturePluginSink(childFragment); + Assertions.assertEquals(WriteOperation.DELETE, Deencapsulation.getField(pluginSink, "writeOperation"), + "a post-flip DELETE must thread WriteOperation.DELETE so the connector emits TIcebergDeleteSink"); + Assertions.assertNull(Deencapsulation.getField(pluginSink, "writeSortInfo"), + "a row-level DELETE has no engine write sort"); + assertConnectorColumnsFromCols(pluginSink); + // DELETE resolves its row id by BE block-name (a real hidden column), so the visitor must NOT emit the + // MERGE-style output-expr list — match the native delete path exactly. + Mockito.verify(childFragment, Mockito.never()).setOutputExprs(Mockito.anyList()); + } + + @Test + public void mergePluginArmRoutesToPluginSinkAndPublishesOperationAndRowIdOutputExprs() { + PlanTranslatorContext context = new PlanTranslatorContext(); + TupleDescriptor tuple = context.generateTupleDesc(); + SlotReference dataSlot = registerSlot(context, tuple, "data"); + SlotReference opSlot = registerSlot(context, tuple, MergeOperation.OPERATION_COLUMN); + SlotReference rowidSlot = registerSlot(context, tuple, Column.ICEBERG_ROWID_COL); + + PlanFragment childFragment = Mockito.mock(PlanFragment.class); + Plugin plugin = pluginTable(); + + @SuppressWarnings("unchecked") + PhysicalIcebergMergeSink sink = Mockito.mock(PhysicalIcebergMergeSink.class); + Mockito.doReturn(mockChild(childFragment)).when(sink).child(); + Mockito.doReturn(plugin.table).when(sink).getTargetTable(); + Mockito.doReturn(ImmutableList.of(DATA)).when(sink).getCols(); + Mockito.doReturn(ImmutableList.of(dataSlot, opSlot, rowidSlot)).when(sink).getOutput(); + + PhysicalPlanTranslator translator = new PhysicalPlanTranslator(context, null); + translator.visitPhysicalIcebergMergeSink(sink, context); + + PluginDrivenTableSink pluginSink = capturePluginSink(childFragment); + Assertions.assertEquals(WriteOperation.MERGE, Deencapsulation.getField(pluginSink, "writeOperation"), + "a post-flip MERGE must thread WriteOperation.MERGE so the connector emits TIcebergMergeSink"); + Assertions.assertNull(Deencapsulation.getField(pluginSink, "writeSortInfo"), + "a row-level MERGE carries its sort inside the connector's TIcebergMergeSink.sort_fields, not the" + + " engine write sort"); + assertConnectorColumnsFromCols(pluginSink); + + // The fragment output-exprs are load-bearing: BE's viceberg_merge_sink resolves the operation / row-id + // columns strictly by output-expr name. Assert the list content (not just that some list was set), so a + // regression that emptied it or dropped the operation/row-id slots is caught. + @SuppressWarnings("unchecked") + ArgumentCaptor> exprsCaptor = ArgumentCaptor.forClass(List.class); + Mockito.verify(childFragment).setOutputExprs(exprsCaptor.capture()); + List publishedExprs = exprsCaptor.getValue(); + Assertions.assertEquals(3, publishedExprs.size(), + "every sink output slot must be published as a fragment output expr"); + Assertions.assertTrue(publishedExprs.contains(context.findSlotRef(opSlot.getExprId())), + "the operation column must be published in the fragment output exprs (BE resolves it by name)"); + Assertions.assertTrue(publishedExprs.contains(context.findSlotRef(rowidSlot.getExprId())), + "the row-id column must be published in the fragment output exprs (BE resolves it by name)"); + Assertions.assertTrue(publishedExprs.contains(context.findSlotRef(dataSlot.getExprId())), + "the data column must be published in the fragment output exprs"); + } + + @Test + public void mergePluginArmRunsMaterializedNameLoopSoBeResolvesOperationColumn() { + // The materialized-name loop is lifted above the native/plugin branch. If it were left only in the + // native arm, the plugin MERGE here would never materialize the synthetic operation column's name, and + // BE's viceberg_merge_sink (which matches the operation column by output-expr name) would fail to find + // it. This asserts the plugin arm materializes the name -> the loop ran for the plugin path. + PlanTranslatorContext context = new PlanTranslatorContext(); + TupleDescriptor tuple = context.generateTupleDesc(); + SlotReference dataSlot = registerSlot(context, tuple, "data"); + SlotReference opSlot = registerSlot(context, tuple, MergeOperation.OPERATION_COLUMN); + SlotReference rowidSlot = registerSlot(context, tuple, Column.ICEBERG_ROWID_COL); + + PlanFragment childFragment = Mockito.mock(PlanFragment.class); + Plugin plugin = pluginTable(); + + @SuppressWarnings("unchecked") + PhysicalIcebergMergeSink sink = Mockito.mock(PhysicalIcebergMergeSink.class); + Mockito.doReturn(mockChild(childFragment)).when(sink).child(); + Mockito.doReturn(plugin.table).when(sink).getTargetTable(); + Mockito.doReturn(ImmutableList.of(DATA)).when(sink).getCols(); + Mockito.doReturn(ImmutableList.of(dataSlot, opSlot, rowidSlot)).when(sink).getOutput(); + + PhysicalPlanTranslator translator = new PhysicalPlanTranslator(context, null); + translator.visitPhysicalIcebergMergeSink(sink, context); + + SlotDescriptor opDesc = context.findSlotRef(opSlot.getExprId()).getDesc(); + Assertions.assertEquals(MergeOperation.OPERATION_COLUMN, opDesc.getMaterializedColumnName(), + "the plugin MERGE arm must materialize the synthetic operation column's BE col_name"); + SlotDescriptor rowidDesc = context.findSlotRef(rowidSlot.getExprId()).getDesc(); + Assertions.assertEquals(Column.ICEBERG_ROWID_COL, rowidDesc.getMaterializedColumnName(), + "the plugin MERGE arm must materialize the synthetic row-id column's BE col_name"); + SlotDescriptor dataDesc = context.findSlotRef(dataSlot.getExprId()).getDesc(); + Assertions.assertNull(dataDesc.getMaterializedColumnName(), + "a regular data column must not be materialized (only operation/row-id are)"); + } + + @Test + public void rowLevelDmlThreadsMvccReadSnapshotPinOntoTheWriteHandle() { + // Fix B: the write handle must carry the statement's pinned MVCC read snapshot, so a DELETE/MERGE + // re-derives its deletes from the SAME snapshot its scan read. The pin decision itself is unit-tested in + // PluginDrivenScanNodeMvccPinTest; this pins that the row-level-DML helper actually wires it onto the + // write handle (a mutation dropping the applyMvccSnapshotPin call would leave the raw, unpinned handle). + Plugin plugin = pluginTable(); + ConnectorMvccSnapshot connectorSnapshot = Mockito.mock(ConnectorMvccSnapshot.class); + PluginDrivenMvccSnapshot pinned = new PluginDrivenMvccSnapshot( + connectorSnapshot, Collections.emptyMap(), Collections.emptyMap()); + ConnectorTableHandle pinnedHandle = Mockito.mock(ConnectorTableHandle.class); + // applyMvccSnapshotPin unwraps the snapshot and calls metadata.applySnapshot(...) -> the pinned handle. + Mockito.when(plugin.metadata.applySnapshot(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(pinnedHandle); + + PlanFragment childFragment = Mockito.mock(PlanFragment.class); + @SuppressWarnings("unchecked") + PhysicalIcebergDeleteSink sink = Mockito.mock(PhysicalIcebergDeleteSink.class); + Mockito.doReturn(mockChild(childFragment)).when(sink).child(); + Mockito.doReturn(plugin.table).when(sink).getTargetTable(); + Mockito.doReturn(ImmutableList.of(DATA)).when(sink).getCols(); + + PlanTranslatorContext context = new PlanTranslatorContext(); + PhysicalPlanTranslator translator = new PhysicalPlanTranslator(context, null); + try (MockedStatic mvcc = Mockito.mockStatic(MvccUtil.class)) { + mvcc.when(() -> MvccUtil.getSnapshotFromContext(plugin.table)).thenReturn(Optional.of(pinned)); + translator.visitPhysicalIcebergDeleteSink(sink, context); + } + + PluginDrivenTableSink pluginSink = capturePluginSink(childFragment); + Assertions.assertSame(pinnedHandle, Deencapsulation.getField(pluginSink, "tableHandle"), + "the row-level DML write handle must carry the snapshot-pinned table handle (Fix B), not the raw" + + " latest-read handle"); + } + + // ==================== helpers ==================== + + /** A column-less slot (no backing Column, so its slot col_name is empty until the loop materializes it). */ + private static SlotReference registerSlot(PlanTranslatorContext context, TupleDescriptor tuple, String name) { + SlotReference slot = new SlotReference(name, IntegerType.INSTANCE); + context.createSlotDesc(tuple, slot); + return slot; + } + + private static Plan mockChild(PlanFragment childFragment) { + Plan child = Mockito.mock(Plan.class); + Mockito.doReturn(childFragment).when(child).accept(Mockito.any(), Mockito.any()); + return child; + } + + /** The mocked plugin connector chain, exposing the pieces a test needs to stub/assert. */ + private static final class Plugin { + private final PluginDrivenExternalTable table; + private final ConnectorMetadata metadata; + + private Plugin(PluginDrivenExternalTable table, ConnectorMetadata metadata) { + this.table = table; + this.metadata = metadata; + } + } + + /** A plugin-driven table whose connector resolves a non-null write provider + present table handle. */ + private static Plugin pluginTable() { + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + // The write seams now resolve metadata through the per-statement funnel, which reads the session's + // statement scope; offline tests use NONE (a fresh getMetadata per call, byte-identical to pre-funnel). + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + ConnectorWritePlanProvider provider = Mockito.mock(ConnectorWritePlanProvider.class); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getWritePlanProvider()).thenReturn(provider); + // 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())) + .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())) + .thenReturn(Optional.of(handle)); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(table.getCatalog()).thenReturn(catalog); + Mockito.when(table.getRemoteDbName()).thenReturn("db"); + Mockito.when(table.getRemoteName()).thenReturn("t"); + return new Plugin(table, metadata); + } + + private static PluginDrivenTableSink capturePluginSink(PlanFragment childFragment) { + ArgumentCaptor captor = ArgumentCaptor.forClass(DataSink.class); + Mockito.verify(childFragment).setSink(captor.capture()); + DataSink built = captor.getValue(); + Assertions.assertTrue(built instanceof PluginDrivenTableSink, + "a post-flip row-level DML must route through the generic PluginDrivenTableSink, was " + + built.getClass().getSimpleName()); + return (PluginDrivenTableSink) built; + } + + @SuppressWarnings("unchecked") + private static void assertConnectorColumnsFromCols(PluginDrivenTableSink pluginSink) { + List connectorColumns = + (List) Deencapsulation.getField(pluginSink, "connectorColumns"); + Assertions.assertEquals(1, connectorColumns.size(), + "the connector columns must be derived from the sink's getCols()"); + Assertions.assertEquals("data", connectorColumns.get(0).getName(), + "the connector column name must carry the sink column name"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/processor/post/materialize/MaterializeProbeVisitorTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/processor/post/materialize/MaterializeProbeVisitorTest.java index 100c70cfb4e098..4b4277ecdc6963 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/processor/post/materialize/MaterializeProbeVisitorTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/processor/post/materialize/MaterializeProbeVisitorTest.java @@ -20,12 +20,14 @@ import org.apache.doris.analysis.ColumnAccessPath; import org.apache.doris.catalog.KeysType; import org.apache.doris.catalog.OlapTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.nereids.trees.expressions.Add; import org.apache.doris.nereids.trees.expressions.Alias; import org.apache.doris.nereids.trees.expressions.Slot; import org.apache.doris.nereids.trees.expressions.SlotReference; import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral; import org.apache.doris.nereids.trees.plans.Plan; +import org.apache.doris.nereids.trees.plans.physical.PhysicalCatalogRelation; import org.apache.doris.nereids.trees.plans.physical.PhysicalFilter; import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapScan; import org.apache.doris.nereids.trees.plans.physical.PhysicalProject; @@ -166,4 +168,30 @@ private PhysicalOlapScan mockBaseOlapScan(SlotReference outputSlot) { Mockito.when(scan.getOutput()).thenReturn(ImmutableList.of(outputSlot)); return scan; } + + @Test + public void testPluginDrivenTableSupportedWhenConnectorDeclaresLazyTopN() { + // Post-flip iceberg becomes a PluginDrivenExternalTable subclass (not in the legacy exact-class + // SUPPORT_RELATION_TYPES set); it is admitted for Top-N lazy materialization only via the connector + // capability. + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(table.supportsTopNLazyMaterialize()).thenReturn(true); + PhysicalCatalogRelation relation = Mockito.mock(PhysicalCatalogRelation.class); + Mockito.when(relation.getTable()).thenReturn(table); + + Assertions.assertTrue(new MaterializeProbeVisitor().checkRelationTableSupportedType(relation)); + } + + @Test + public void testPluginDrivenTableUnsupportedWhenConnectorLacksLazyTopN() { + // A plugin-driven table whose connector does NOT declare the capability (e.g. jdbc/es, which also + // become PluginDrivenExternalTable) stays excluded — guards against a blanket isAssignableFrom that + // would wrongly enable lazy materialization for row/passthrough connectors. + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(table.supportsTopNLazyMaterialize()).thenReturn(false); + PhysicalCatalogRelation relation = Mockito.mock(PhysicalCatalogRelation.class); + Mockito.when(relation.getTable()).thenReturn(table); + + Assertions.assertFalse(new MaterializeProbeVisitor().checkRelationTableSupportedType(relation)); + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/BindConnectorSinkStaticPartitionTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/BindConnectorSinkStaticPartitionTest.java new file mode 100644 index 00000000000000..f18154542407c2 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/BindConnectorSinkStaticPartitionTest.java @@ -0,0 +1,172 @@ +// 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.rules.analysis; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.PrimitiveType; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.nereids.exceptions.AnalysisException; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Tests for {@link BindSink#selectConnectorSinkBindColumns} — the bind-time column selection for the + * generic connector table sink (FIX-BIND-STATIC-PARTITION, P0-3). + * + *

    Root cause this guards: before the fix, the no-column-list path bound the full base schema + * (including partition columns), so {@code INSERT INTO mc PARTITION(pt='x') SELECT } + * produced more bound columns than the query output and threw "insert into cols should be corresponding + * to the query output" at bind. The static partition columns carry their value via the static partition + * spec (not the query), so they must be excluded from the bound columns — mirroring legacy + * {@code bindMaxComputeTableSink}.

    + */ +public class BindConnectorSinkStaticPartitionTest { + + private static final Column ID = new Column("id", PrimitiveType.INT); + private static final Column VAL = new Column("val", PrimitiveType.INT); + private static final Column DS = new Column("ds", PrimitiveType.INT); + private static final Column REGION = new Column("region", PrimitiveType.INT); + // Base schema appends partition columns after the data columns (as the connector reports it). + private static final List BASE_SCHEMA = ImmutableList.of(ID, VAL, DS, REGION); + + private static PluginDrivenExternalTable partitionedTable() { + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(table.getBaseSchema(true)).thenReturn(BASE_SCHEMA); + for (Column c : BASE_SCHEMA) { + Mockito.when(table.getColumn(c.getName())).thenReturn(c); + } + return table; + } + + /** + * A table carrying an invisible column after the visible data columns, modelling an iceberg v3 table + * whose row-lineage {@code _row_id} is appended {@code .invisible()} by the connector. + */ + private static PluginDrivenExternalTable tableWithRowLineage() { + Column rowId = new Column("_row_id", PrimitiveType.BIGINT); + rowId.setIsVisible(false); + List schema = ImmutableList.of(ID, VAL, rowId); + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(table.getBaseSchema(true)).thenReturn(schema); + for (Column c : schema) { + Mockito.when(table.getColumn(c.getName())).thenReturn(c); + } + return table; + } + + private static List names(List columns) { + return columns.stream().map(Column::getName).collect(Collectors.toList()); + } + + /** + * No column list, all-static {@code PARTITION(ds='x', region='y')}: both partition columns are + * statically specified and must be excluded from the bound columns, leaving only the data columns + * so the count matches the query output (the original blocker). + */ + @Test + public void noColumnListAllStaticExcludesPartitionColumns() { + List bound = BindSink.selectConnectorSinkBindColumns( + partitionedTable(), Collections.emptyList(), ImmutableSet.of("ds", "region"), false); + Assertions.assertEquals(ImmutableList.of("id", "val"), names(bound), + "static partition columns must be excluded from the bound columns"); + } + + /** + * No column list, partial-static {@code PARTITION(ds='x') SELECT id, val, region}: only the static + * 'ds' is excluded; the dynamic 'region' stays (its value comes from the query). + */ + @Test + public void noColumnListPartialStaticExcludesOnlyStaticColumn() { + List bound = BindSink.selectConnectorSinkBindColumns( + partitionedTable(), Collections.emptyList(), ImmutableSet.of("ds"), false); + Assertions.assertEquals(ImmutableList.of("id", "val", "region"), names(bound), + "only the statically-specified partition column must be excluded"); + } + + /** + * No column list, no static partition (pure dynamic, e.g. {@code INSERT ... SELECT id,val,ds,region}): + * nothing is excluded — the full base schema is bound, so the existing dynamic/JDBC path is + * unchanged. + */ + @Test + public void noColumnListNoStaticPartitionBindsFullSchema() { + List bound = BindSink.selectConnectorSinkBindColumns( + partitionedTable(), Collections.emptyList(), Collections.emptySet(), false); + Assertions.assertEquals(ImmutableList.of("id", "val", "ds", "region"), names(bound), + "without a static partition spec the full base schema is bound"); + } + + /** + * Explicit column list: bound columns follow the user-specified list verbatim and are not affected + * by the static partition spec (the user already chose which columns the query provides). + */ + @Test + public void explicitColumnListUsesUserColumnsVerbatim() { + List bound = BindSink.selectConnectorSinkBindColumns( + partitionedTable(), ImmutableList.of("val", "id"), ImmutableSet.of("ds"), false); + Assertions.assertEquals(ImmutableList.of("val", "id"), names(bound), + "explicit column list is bound in user order, unaffected by static partitions"); + } + + /** + * Explicit column list naming an unknown column fails loud with a clear message (unchanged behavior). + */ + @Test + public void explicitColumnListUnknownColumnThrows() { + AnalysisException ex = Assertions.assertThrows(AnalysisException.class, () -> + BindSink.selectConnectorSinkBindColumns( + partitionedTable(), ImmutableList.of("nope"), Collections.emptySet(), false)); + Assertions.assertTrue(ex.getMessage().contains("nope"), "error must name the missing column"); + } + + /** + * No column list, ordinary write (not a rewrite): invisible columns (e.g. iceberg v3 row-lineage + * {@code _row_id} / {@code _last_updated_sequence_number}) must be EXCLUDED from the default bound + * columns — the user never supplies their values, so including them would make the bound-column + * count exceed the query output and throw "insert into cols should be corresponding to the query + * output". Guards the v3 row-lineage INSERT regression (test_iceberg_v2_to_v3_doris_spark_compare). + */ + @Test + public void noColumnListOrdinaryWriteExcludesInvisibleColumns() { + List bound = BindSink.selectConnectorSinkBindColumns( + tableWithRowLineage(), Collections.emptyList(), Collections.emptySet(), false); + Assertions.assertEquals(ImmutableList.of("id", "val"), names(bound), + "invisible row-lineage columns must be excluded from an ordinary write target"); + } + + /** + * No column list, rewrite (distributed {@code rewrite_data_files}): invisible columns are RETAINED so + * the engine-managed row-lineage values read from the source rows are preserved through the rewrite, + * mirroring the legacy {@code bindIcebergTableSink} rewrite branch. + */ + @Test + public void noColumnListRewriteRetainsInvisibleColumns() { + List bound = BindSink.selectConnectorSinkBindColumns( + tableWithRowLineage(), Collections.emptyList(), Collections.emptySet(), true); + Assertions.assertEquals(ImmutableList.of("id", "val", "_row_id"), names(bound), + "a rewrite must retain invisible row-lineage columns to preserve their values"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/UserAuthenticationTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/UserAuthenticationTest.java index cba242be5ae900..961f571b946754 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/UserAuthenticationTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/UserAuthenticationTest.java @@ -25,8 +25,8 @@ import org.apache.doris.common.AnalysisException; import org.apache.doris.common.Config; import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergSysExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenSysExternalTable; import org.apache.doris.mysql.privilege.AccessControllerManager; import org.apache.doris.mysql.privilege.PrivPredicate; import org.apache.doris.qe.ConnectContext; @@ -50,8 +50,8 @@ public class UserAuthenticationTest { private TableIf table = Mockito.mock(TableIf.class); private DatabaseIf db = Mockito.mock(DatabaseIf.class); private CatalogIf catalog = Mockito.mock(CatalogIf.class); - private IcebergSysExternalTable icebergSysTable = Mockito.mock(IcebergSysExternalTable.class); - private IcebergExternalTable icebergSourceTable = Mockito.mock(IcebergExternalTable.class); + private PluginDrivenSysExternalTable icebergSysTable = Mockito.mock(PluginDrivenSysExternalTable.class); + private PluginDrivenExternalTable icebergSourceTable = Mockito.mock(PluginDrivenExternalTable.class); private String originalMinPrivilege; diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/PartitionCompensatorTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/PartitionCompensatorTest.java index 96e689cee82694..2d6cb15864aac0 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/PartitionCompensatorTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/PartitionCompensatorTest.java @@ -26,7 +26,7 @@ import org.apache.doris.common.AnalysisException; import org.apache.doris.common.Pair; import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.hive.HMSExternalTable; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable; import org.apache.doris.mtmv.BaseColInfo; import org.apache.doris.mtmv.BaseTableInfo; import org.apache.doris.mtmv.MTMVPartitionInfo; @@ -367,7 +367,7 @@ private static MaterializationContext mockCtx( Mockito.when(mpi.getPctTables()).thenReturn(pctTables); if (externalNoPrune) { - HMSExternalTable ext = Mockito.mock(HMSExternalTable.class); + PluginDrivenMvccExternalTable ext = Mockito.mock(PluginDrivenMvccExternalTable.class); Mockito.when(ext.supportInternalPartitionPruned()).thenReturn(false); Set tbls = new HashSet<>(pctTables); tbls.add(ext); diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/ExplainIcebergDeleteCommandTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/ExplainIcebergDeleteCommandTest.java index 7110094e8df15b..594e6353d82a87 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/ExplainIcebergDeleteCommandTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/ExplainIcebergDeleteCommandTest.java @@ -17,11 +17,6 @@ package org.apache.doris.nereids.trees.plans; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; import org.apache.doris.nereids.parser.NereidsParser; import org.apache.doris.nereids.trees.plans.commands.DeleteFromCommand; import org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext; @@ -29,15 +24,9 @@ import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergDeleteSink; import org.apache.doris.nereids.trees.plans.visitor.DefaultPlanVisitor; -import org.apache.doris.qe.ConnectContext; -import com.google.common.collect.Lists; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.List; /** * Unit tests for EXPLAIN DELETE on Iceberg tables. @@ -49,30 +38,6 @@ */ public class ExplainIcebergDeleteCommandTest { private final NereidsParser parser = new NereidsParser(); - private IcebergExternalTable mockIcebergTable; - private IcebergExternalDatabase mockDatabase; - private IcebergExternalCatalog mockCatalog; - private ConnectContext mockConnectContext; - - @BeforeEach - public void setUp() { - // Mock Iceberg catalog, database, and table - mockCatalog = Mockito.mock(IcebergExternalCatalog.class); - mockDatabase = Mockito.mock(IcebergExternalDatabase.class); - mockIcebergTable = Mockito.mock(IcebergExternalTable.class); - mockConnectContext = Mockito.mock(ConnectContext.class); - - // Setup table schema with basic columns - List columns = Lists.newArrayList( - new Column("id", PrimitiveType.INT), - new Column("name", PrimitiveType.STRING), - new Column("age", PrimitiveType.INT) - ); - Mockito.when(mockIcebergTable.getFullSchema()).thenReturn(columns); - Mockito.when(mockIcebergTable.getName()).thenReturn("test_table"); - Mockito.when(mockDatabase.getFullName()).thenReturn("test_db.test_table"); - Mockito.when(mockCatalog.getName()).thenReturn("iceberg_catalog"); - } @Test public void testParseDeleteFromTable() { diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergDeletePlanTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/IcebergDeletePlanTest.java similarity index 99% rename from fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergDeletePlanTest.java rename to fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/IcebergDeletePlanTest.java index eb9f3dd39a3e2f..470a87fadb04d0 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergDeletePlanTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/IcebergDeletePlanTest.java @@ -15,14 +15,13 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.datasource.iceberg; +package org.apache.doris.nereids.trees.plans; import org.apache.doris.common.FeConstants; import org.apache.doris.nereids.StatementContext; import org.apache.doris.nereids.parser.NereidsParser; import org.apache.doris.nereids.trees.expressions.NamedExpression; import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator; -import org.apache.doris.nereids.trees.plans.Plan; import org.apache.doris.nereids.trees.plans.commands.DeleteFromCommand; import org.apache.doris.nereids.trees.plans.commands.ExplainCommand; import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergDeleteCommandTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergDeleteCommandTest.java index f6874c2705d334..7f8bfb50b06624 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergDeleteCommandTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergDeleteCommandTest.java @@ -19,8 +19,6 @@ import org.apache.doris.catalog.Column; import org.apache.doris.catalog.StructType; -import org.apache.doris.datasource.iceberg.IcebergRowId; -import org.apache.doris.qe.ConnectContext; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -60,32 +58,4 @@ public void testRowIdStructFields() { StructType structType = (StructType) IcebergRowId.getRowIdType(); Assertions.assertEquals(4, structType.getFields().size()); } - - @Test - public void testExecuteWithExternalTableBatchModeDisabledRestoresValueOnSuccess() throws Exception { - ConnectContext ctx = new ConnectContext(); - ctx.getSessionVariable().enableExternalTableBatchMode = true; - - IcebergDeleteCommand.executeWithExternalTableBatchModeDisabled(ctx, () -> { - Assertions.assertFalse(ctx.getSessionVariable().enableExternalTableBatchMode); - return null; - }); - - Assertions.assertTrue(ctx.getSessionVariable().enableExternalTableBatchMode); - } - - @Test - public void testExecuteWithExternalTableBatchModeDisabledRestoresValueOnException() { - ConnectContext ctx = new ConnectContext(); - ctx.getSessionVariable().enableExternalTableBatchMode = false; - - RuntimeException exception = Assertions.assertThrows(RuntimeException.class, - () -> IcebergDeleteCommand.executeWithExternalTableBatchModeDisabled(ctx, () -> { - Assertions.assertFalse(ctx.getSessionVariable().enableExternalTableBatchMode); - throw new RuntimeException("expected"); - })); - - Assertions.assertEquals("expected", exception.getMessage()); - Assertions.assertFalse(ctx.getSessionVariable().enableExternalTableBatchMode); - } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergDmlCommandUtilsTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergDmlCommandUtilsTest.java deleted file mode 100644 index 562484a7dc9b9b..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergDmlCommandUtilsTest.java +++ /dev/null @@ -1,91 +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; - -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.nereids.exceptions.AnalysisException; - -import org.apache.iceberg.RowLevelOperationMode; -import org.apache.iceberg.Table; -import org.apache.iceberg.TableProperties; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.HashMap; -import java.util.Map; - -public class IcebergDmlCommandUtilsTest { - - @Test - public void testDefaultModesRejectCopyOnWriteOperations() { - IcebergExternalTable table = mockIcebergExternalTable(new HashMap<>()); - - assertCopyOnWriteException(() -> IcebergDmlCommandUtils.checkDeleteMode(table), - "DELETE", TableProperties.DELETE_MODE); - assertCopyOnWriteException(() -> IcebergDmlCommandUtils.checkUpdateMode(table), - "UPDATE", TableProperties.UPDATE_MODE); - assertCopyOnWriteException(() -> IcebergDmlCommandUtils.checkMergeMode(table), - "MERGE INTO", TableProperties.MERGE_MODE); - } - - @Test - public void testExplicitCopyOnWriteModeRejectsOperation() { - Map properties = new HashMap<>(); - properties.put(TableProperties.DELETE_MODE, RowLevelOperationMode.COPY_ON_WRITE.modeName()); - properties.put(TableProperties.UPDATE_MODE, RowLevelOperationMode.COPY_ON_WRITE.modeName()); - properties.put(TableProperties.MERGE_MODE, RowLevelOperationMode.COPY_ON_WRITE.modeName()); - IcebergExternalTable table = mockIcebergExternalTable(properties); - - assertCopyOnWriteException(() -> IcebergDmlCommandUtils.checkDeleteMode(table), - "DELETE", TableProperties.DELETE_MODE); - assertCopyOnWriteException(() -> IcebergDmlCommandUtils.checkUpdateMode(table), - "UPDATE", TableProperties.UPDATE_MODE); - assertCopyOnWriteException(() -> IcebergDmlCommandUtils.checkMergeMode(table), - "MERGE INTO", TableProperties.MERGE_MODE); - } - - @Test - public void testMergeOnReadModeAllowsOperation() { - Map properties = new HashMap<>(); - properties.put(TableProperties.DELETE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); - properties.put(TableProperties.UPDATE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); - properties.put(TableProperties.MERGE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); - IcebergExternalTable table = mockIcebergExternalTable(properties); - - Assertions.assertDoesNotThrow(() -> IcebergDmlCommandUtils.checkDeleteMode(table)); - Assertions.assertDoesNotThrow(() -> IcebergDmlCommandUtils.checkUpdateMode(table)); - Assertions.assertDoesNotThrow(() -> IcebergDmlCommandUtils.checkMergeMode(table)); - } - - private static void assertCopyOnWriteException(Runnable action, String operation, String property) { - AnalysisException exception = Assertions.assertThrows(AnalysisException.class, action::run); - Assertions.assertTrue(exception.getMessage().contains(operation)); - Assertions.assertTrue(exception.getMessage().contains("copy-on-write")); - Assertions.assertTrue(exception.getMessage().contains(property)); - } - - private static IcebergExternalTable mockIcebergExternalTable(Map properties) { - Table icebergTable = Mockito.mock(Table.class); - Mockito.when(icebergTable.properties()).thenReturn(properties); - - IcebergExternalTable table = Mockito.mock(IcebergExternalTable.class); - Mockito.when(table.getIcebergTable()).thenReturn(icebergTable); - return table; - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergHiddenColumnTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergHiddenColumnTest.java new file mode 100644 index 00000000000000..6192d218bb85ac --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergHiddenColumnTest.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.nereids.trees.plans.commands; + +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.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; + +/** + * 测试 Iceberg 隐藏列功能 + */ +public class IcebergHiddenColumnTest { + + @Test + public void testHiddenColumnStructType() { + // 获取隐藏列类型 + Type rowIdType = IcebergRowId.getRowIdType(); + Assertions.assertTrue(rowIdType instanceof StructType); + + StructType structType = (StructType) rowIdType; + List fields = structType.getFields(); + Assertions.assertEquals(4, fields.size()); + + // 验证字段名称(不带 $ 前缀) + Assertions.assertEquals("file_path", fields.get(0).getName()); + Assertions.assertEquals("row_position", fields.get(1).getName()); + Assertions.assertEquals("partition_spec_id", fields.get(2).getName()); + Assertions.assertEquals("partition_data", fields.get(3).getName()); + + // 验证字段类型 + Assertions.assertTrue(fields.get(0).getType().isStringType()); + Assertions.assertTrue(fields.get(1).getType().isBigIntType()); + Assertions.assertTrue(fields.get(2).getType().isScalarType(PrimitiveType.INT)); + Assertions.assertTrue(fields.get(3).getType().isStringType()); + } + + @Test + public void testIcebergRowIdColumnName() { + // 验证常量定义 + Assertions.assertEquals("__DORIS_ICEBERG_ROWID_COL__", Column.ICEBERG_ROWID_COL); + + // 验证以 __DORIS_ 开头 + Assertions.assertTrue(Column.ICEBERG_ROWID_COL.startsWith(Column.HIDDEN_COLUMN_PREFIX)); + } + + @Test + public void testStructFieldOrder() { + // 验证 STRUCT 字段顺序 + Type rowIdType = IcebergRowId.getRowIdType(); + StructType structType = (StructType) rowIdType; + List fields = structType.getFields(); + + // 确保字段顺序正确(与 BE 一致) + // 顺序:file_path, row_position, partition_spec_id, partition_data + Assertions.assertEquals("file_path", fields.get(0).getName()); + Assertions.assertEquals("row_position", fields.get(1).getName()); + Assertions.assertEquals("partition_spec_id", fields.get(2).getName()); + Assertions.assertEquals("partition_data", fields.get(3).getName()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergMergeCommandTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergMergeCommandTest.java deleted file mode 100644 index 531e8e30855a2e..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergMergeCommandTest.java +++ /dev/null @@ -1,55 +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; - -import org.apache.doris.qe.ConnectContext; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -public class IcebergMergeCommandTest { - - @Test - public void testExecuteWithExternalTableBatchModeDisabledRestoresValueOnSuccess() throws Exception { - ConnectContext ctx = new ConnectContext(); - ctx.getSessionVariable().enableExternalTableBatchMode = true; - - Boolean result = IcebergMergeCommand.executeWithExternalTableBatchModeDisabled(ctx, () -> { - Assertions.assertFalse(ctx.getSessionVariable().enableExternalTableBatchMode); - return Boolean.TRUE; - }); - - Assertions.assertTrue(result); - Assertions.assertTrue(ctx.getSessionVariable().enableExternalTableBatchMode); - } - - @Test - public void testExecuteWithExternalTableBatchModeDisabledRestoresValueOnException() { - ConnectContext ctx = new ConnectContext(); - ctx.getSessionVariable().enableExternalTableBatchMode = false; - - RuntimeException exception = Assertions.assertThrows(RuntimeException.class, - () -> IcebergMergeCommand.executeWithExternalTableBatchModeDisabled(ctx, () -> { - Assertions.assertFalse(ctx.getSessionVariable().enableExternalTableBatchMode); - throw new RuntimeException("expected"); - })); - - Assertions.assertEquals("expected", exception.getMessage()); - Assertions.assertFalse(ctx.getSessionVariable().enableExternalTableBatchMode); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergMetadataColumnTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergMetadataColumnTest.java new file mode 100644 index 00000000000000..8fda3d4149c24b --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergMetadataColumnTest.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.nereids.trees.plans.commands; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for IcebergMetadataColumn. + */ +public class IcebergMetadataColumnTest { + + @Test + public void testFilePathColumn() { + IcebergMetadataColumn filePath = IcebergMetadataColumn.FILE_PATH; + + Assertions.assertNotNull(filePath); + Assertions.assertEquals("$file_path", filePath.getColumnName()); + Assertions.assertTrue(filePath.getColumnType().isStringType()); + } + + @Test + public void testRowPositionColumn() { + IcebergMetadataColumn rowPosition = IcebergMetadataColumn.ROW_POSITION; + + Assertions.assertNotNull(rowPosition); + Assertions.assertEquals("$row_position", rowPosition.getColumnName()); + Assertions.assertTrue(rowPosition.getColumnType().isBigIntType()); + } + + @Test + public void testPartitionSpecIdColumn() { + IcebergMetadataColumn partitionSpecId = IcebergMetadataColumn.PARTITION_SPEC_ID; + + Assertions.assertNotNull(partitionSpecId); + Assertions.assertEquals("$partition_spec_id", partitionSpecId.getColumnName()); + Assertions.assertTrue(partitionSpecId.getColumnType().isScalarType()); + } + + @Test + public void testPartitionDataColumn() { + IcebergMetadataColumn partitionData = IcebergMetadataColumn.PARTITION_DATA; + + Assertions.assertNotNull(partitionData); + Assertions.assertEquals("$partition_data", partitionData.getColumnName()); + Assertions.assertTrue(partitionData.getColumnType().isStringType()); + } + + @Test + public void testGetAllColumnNames() { + Assertions.assertTrue(IcebergMetadataColumn.getAllColumnNames().contains("$file_path")); + Assertions.assertTrue(IcebergMetadataColumn.getAllColumnNames().contains("$row_position")); + Assertions.assertTrue(IcebergMetadataColumn.getAllColumnNames().contains("$partition_spec_id")); + Assertions.assertTrue(IcebergMetadataColumn.getAllColumnNames().contains("$partition_data")); + Assertions.assertFalse(IcebergMetadataColumn.getAllColumnNames().contains("$row_id")); + } + + @Test + public void testIsMetadataColumn() { + Assertions.assertTrue(IcebergMetadataColumn.isMetadataColumn("$file_path")); + Assertions.assertFalse(IcebergMetadataColumn.isMetadataColumn("regular_column")); + Assertions.assertFalse(IcebergMetadataColumn.isMetadataColumn(null)); + Assertions.assertFalse(IcebergMetadataColumn.isMetadataColumn("$row_id")); + } + + @Test + public void testFromColumnName() { + Assertions.assertEquals(IcebergMetadataColumn.FILE_PATH, + IcebergMetadataColumn.fromColumnName("$file_path")); + Assertions.assertNull(IcebergMetadataColumn.fromColumnName("not_a_metadata_column")); + Assertions.assertNull(IcebergMetadataColumn.fromColumnName("$row_id")); + } +} 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 new file mode 100644 index 00000000000000..329534236fef20 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergRowLevelDmlTransformTest.java @@ -0,0 +1,327 @@ +// 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; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.ScalarType; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.nereids.exceptions.AnalysisException; +import org.apache.doris.nereids.trees.expressions.EqualTo; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.expressions.NamedExpression; +import org.apache.doris.nereids.trees.expressions.SlotReference; +import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator; +import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral; +import org.apache.doris.nereids.trees.plans.Plan; +import org.apache.doris.nereids.trees.plans.RelationId; +import org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext; +import org.apache.doris.nereids.trees.plans.commands.insert.BaseExternalTableInsertExecutor; +import org.apache.doris.nereids.trees.plans.commands.insert.PluginDrivenInsertExecutor; +import org.apache.doris.nereids.trees.plans.logical.LogicalEmptyRelation; +import org.apache.doris.nereids.trees.plans.logical.LogicalFilter; +import org.apache.doris.nereids.trees.plans.logical.LogicalIcebergDeleteSink; +import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; +import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; +import org.apache.doris.planner.DataSink; +import org.apache.doris.planner.PlanFragment; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.EnumSet; +import java.util.Optional; +import java.util.Set; + +/** + * Unit tests for {@link IcebergRowLevelDmlTransform} (P6.3-T07c). + * + *

    Covers the genuinely new T07c logic: the registry table-type predicate, the frozen per-op label + * prefixes (profile/txn parity), the O5-2 synthetic-column exclusion supplied to {@link WriteConstraintExtractor + * via the iceberg-specific predicate}, and the dormant O5-2 {@code applyWriteConstraint} round-trip. The + * synthesis/executor/sink delegation had native end-to-end coverage in {@code IcebergDDLAndDMLPlanTest}, + * retired with the P6.6 iceberg cutover (the native arm is no longer reachable).

    + */ +public class IcebergRowLevelDmlTransformTest { + + private static final long TARGET_ID = 7L; + private final IcebergRowLevelDmlTransform transform = new IcebergRowLevelDmlTransform(); + + private SlotReference slot(TableIf table, String name) { + return SlotReference.fromColumn(StatementScopeIdGenerator.newExprId(), table, + new Column(name, ScalarType.INT), ImmutableList.of()); + } + + private Plan filterOver(TableIf table, String columnName) { + SlotReference slot = slot(table, columnName); + Set conjuncts = ImmutableSet.of(new EqualTo(slot, new IntegerLiteral(1))); + LogicalEmptyRelation child = new LogicalEmptyRelation(new RelationId(0), + ImmutableList.of((NamedExpression) slot)); + return new LogicalFilter<>(conjuncts, child); + } + + /** + * 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. + */ + private static PluginDrivenExternalTable pluginTable(boolean supportsDelete, boolean supportsMerge) { + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Connector connector = Mockito.mock(Connector.class); + Set ops = EnumSet.noneOf(WriteOperation.class); + if (supportsDelete) { + ops.add(WriteOperation.DELETE); + } + if (supportsMerge) { + ops.add(WriteOperation.MERGE); + } + Mockito.when(table.getCatalog()).thenReturn(catalog); + Mockito.when(catalog.getConnector()).thenReturn(connector); + // The row-level DML admission probe now resolves per-handle via the table helper; stub it directly. The + // catalog -> connector chain is still needed for checkMode (validateRowLevelDmlMode). + Mockito.when(table.connectorSupportedWriteOperations()).thenReturn(ops); + return table; + } + + @Test + public void handlesPluginDrivenTableByRowLevelDmlCapability() { + // An iceberg table presents as PluginDrivenExternalTable; it is admitted via the + // neutral connector capability (supportsDelete || supportsMerge), NOT a concrete iceberg cast. + Assertions.assertTrue(transform.handles(pluginTable(true, false))); + Assertions.assertTrue(transform.handles(pluginTable(false, true))); + Assertions.assertTrue(transform.handles(pluginTable(true, true))); + // A plugin connector with neither capability (e.g. jdbc/es/paimon today) must NOT be admitted, + // else its row-level DML would route through the iceberg synthesis path. + Assertions.assertFalse(transform.handles(pluginTable(false, false))); + // Non-plugin table types and null are never admitted. + Assertions.assertFalse(transform.handles(Mockito.mock(TableIf.class))); + Assertions.assertFalse(transform.handles(null)); + } + + /** + * A {@link PluginDrivenExternalTable} (db1.t1) whose connector resolves to {@code metadata}. Used to + * drive the post-flip {@link IcebergRowLevelDmlTransform#checkMode} plugin arm, which routes the + * copy-on-write rejection through the connector's neutral {@code validateRowLevelDmlMode} SPI. + */ + private static PluginDrivenExternalTable pluginTableWithMetadata( + ConnectorMetadata metadata, ConnectorSession session) { + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(table.getCatalog()).thenReturn(catalog); + Mockito.when(table.getRemoteDbName()).thenReturn("db1"); + Mockito.when(table.getRemoteName()).thenReturn("t1"); + Mockito.when(catalog.getName()).thenReturn("ice_cat"); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.when(connector.getMetadata(session)).thenReturn(metadata); + // checkMode now resolves metadata through the per-statement funnel, which reads the session's statement + // scope; offline tests use NONE (a fresh getMetadata per call, byte-identical to pre-funnel). + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + return table; + } + + @Test + public void checkModePluginArmRoutesEachOpToConnectorMode() { + ConnectorSession session = Mockito.mock(ConnectorSession.class); + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + Mockito.when(metadata.getTableHandle(session, "db1", "t1")).thenReturn(Optional.of(handle)); + PluginDrivenExternalTable table = pluginTableWithMetadata(metadata, session); + + transform.checkMode(table, RowLevelDmlOp.DELETE); + transform.checkMode(table, RowLevelDmlOp.UPDATE); + transform.checkMode(table, RowLevelDmlOp.MERGE); + + // Post-flip: the mode check delegates to the connector SPI on the resolved handle. The neutral op axis + // must map DELETE->DELETE, UPDATE->UPDATE, MERGE->MERGE so the connector reads the matching + // write.{delete,update,merge}.mode property. MUTATION: collapsing toWriteOperation to one value -> the + // wrong property is checked -> these verifies fail. + Mockito.verify(metadata).validateRowLevelDmlMode(session, handle, WriteOperation.DELETE); + Mockito.verify(metadata).validateRowLevelDmlMode(session, handle, WriteOperation.UPDATE); + Mockito.verify(metadata).validateRowLevelDmlMode(session, handle, WriteOperation.MERGE); + } + + @Test + public void checkModePluginArmWrapsConnectorRejectionAsAnalysisException() { + ConnectorSession session = Mockito.mock(ConnectorSession.class); + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + Mockito.when(metadata.getTableHandle(session, "db1", "t1")).thenReturn(Optional.of(handle)); + Mockito.doThrow(new DorisConnectorException( + "Doris does not support DELETE on Iceberg copy-on-write tables.")) + .when(metadata).validateRowLevelDmlMode(session, handle, WriteOperation.DELETE); + PluginDrivenExternalTable table = pluginTableWithMetadata(metadata, session); + + // The connector throws its own DorisConnectorException; the transform surfaces it as the analysis-time + // AnalysisException the legacy path threw, preserving the message. MUTATION: dropping the catch/rethrow + // -> a DorisConnectorException escapes (wrong type) -> red. + AnalysisException e = Assertions.assertThrows(AnalysisException.class, + () -> transform.checkMode(table, RowLevelDmlOp.DELETE)); + Assertions.assertTrue(e.getMessage().contains("copy-on-write"), e.getMessage()); + } + + @Test + public void checkModePluginArmThrowsWhenTableHandleMissing() { + ConnectorSession session = Mockito.mock(ConnectorSession.class); + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + Mockito.when(metadata.getTableHandle(session, "db1", "t1")).thenReturn(Optional.empty()); + PluginDrivenExternalTable table = pluginTableWithMetadata(metadata, session); + + AnalysisException e = Assertions.assertThrows(AnalysisException.class, + () -> transform.checkMode(table, RowLevelDmlOp.DELETE)); + Assertions.assertTrue(e.getMessage().contains("Table not found"), e.getMessage()); + } + + @Test + public void synthesizeDeleteOnPluginTableBuildsSinkTargetingIt() { + // Post-flip: synthesize must accept a PluginDrivenExternalTable (instead of CCE on the legacy + // (IcebergExternalTable) cast) and build a re-parameterized LogicalIcebergDeleteSink that targets it. + // Pins the synthesize cast widening + the Iceberg*Command synthesis-entry widening + the logical-sink + // re-parameterization; the full plan execution is flip-e2e-gated. (Reverting the cast/param back to + // IcebergExternalTable would not even compile against this plugin-typed argument.) + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + ExternalDatabase database = Mockito.mock(ExternalDatabase.class); + Mockito.when(table.getDatabase()).thenReturn(database); + Mockito.when(table.getBaseSchema(true)) + .thenReturn(ImmutableList.of(new Column("id", ScalarType.INT))); + Mockito.when(table.getId()).thenReturn(TARGET_ID); + + LogicalPlan query = (LogicalPlan) filterOver(table, "id"); + RowLevelDmlArgs args = RowLevelDmlArgs.forDelete(table, ImmutableList.of("db", "t"), null, + false, ImmutableList.of(), query, new DeleteCommandContext()); + + LogicalPlan plan = transform.synthesize(null, args, RowLevelDmlOp.DELETE); + + Assertions.assertTrue(plan instanceof LogicalIcebergDeleteSink, plan.getClass().getName()); + Assertions.assertSame(table, ((LogicalIcebergDeleteSink) plan).getTargetTable()); + } + + @Test + public void setupConflictDetectionPluginArmIsNoOp() { + // The conflict filter runs ONLY through the SPI path (applyWriteConstraintIfPresent), so + // setupConflictDetection is a no-op that must NOT touch the executor (the retired native arm cast + // it to Iceberg{Delete,Merge}Executor and called setConflictDetectionFilter). + BaseExternalTableInsertExecutor executor = Mockito.mock(PluginDrivenInsertExecutor.class); + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + Plan analyzedPlan = Mockito.mock(Plan.class); + + Assertions.assertDoesNotThrow(() -> + transform.setupConflictDetection(executor, analyzedPlan, table, RowLevelDmlOp.DELETE)); + Mockito.verifyNoInteractions(executor); + } + + @Test + public void finalizeSinkPluginArmRoutesToConnectorFinalize() { + // Finalize goes through the connector's single transaction model (no rewritable-delete + // overlay); the shell routes to PluginDrivenInsertExecutor.finalizeRowLevelDmlSink. + PluginDrivenInsertExecutor executor = Mockito.mock(PluginDrivenInsertExecutor.class); + PlanFragment fragment = Mockito.mock(PlanFragment.class); + DataSink sink = Mockito.mock(DataSink.class); + PhysicalSink physicalSink = Mockito.mock(PhysicalSink.class); + + transform.finalizeSink(executor, RowLevelDmlOp.DELETE, fragment, sink, physicalSink); + + Mockito.verify(executor).finalizeRowLevelDmlSink(fragment, sink, physicalSink); + } + + @Test + public void labelPrefixIsFrozenPerOp() { + // These are profile/txn-visible and must stay byte-identical to the legacy Iceberg*Command labels. + Assertions.assertEquals("iceberg_delete", transform.labelPrefix(RowLevelDmlOp.DELETE)); + Assertions.assertEquals("iceberg_update_merge", transform.labelPrefix(RowLevelDmlOp.UPDATE)); + Assertions.assertEquals("iceberg_merge_into", transform.labelPrefix(RowLevelDmlOp.MERGE)); + } + + @Test + public void extractWriteConstraintKeepsRegularTargetColumn() { + TableIf target = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(target.getId()).thenReturn(TARGET_ID); + Optional result = transform.extractWriteConstraint(filterOver(target, "id"), target); + Assertions.assertTrue(result.isPresent()); + } + + @Test + public void extractWriteConstraintExcludesRowIdColumn() { + // Load-bearing: the synthetic $row_id slot has originalTable == target, so the origin-table check alone + // would keep it; only the iceberg ICEBERG_EXCLUSION predicate drops it (closes T07b critic BLOCKER). + TableIf target = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(target.getId()).thenReturn(TARGET_ID); + Plan plan = filterOver(target, Column.ICEBERG_ROWID_COL); + Assertions.assertFalse(transform.extractWriteConstraint(plan, target).isPresent()); + } + + @Test + public void extractWriteConstraintExcludesMetadataColumn() { + TableIf target = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(target.getId()).thenReturn(TARGET_ID); + // "$partition_spec_id" is an IcebergMetadataColumn -> excluded. + Plan plan = filterOver(target, "$partition_spec_id"); + Assertions.assertFalse(transform.extractWriteConstraint(plan, target).isPresent()); + } + + @Test + public void applyWriteConstraintPushesToConnectorTransactionWhenPresent() { + // O5-2 round-trip (D2): when the executor exposes an SPI ConnectorTransaction, the extracted predicate + // is pushed onto it via applyWriteConstraint. (Reachable only post-P6.6 in production; tested via stub.) + RowLevelDmlTransform t = Mockito.mock(RowLevelDmlTransform.class); + BaseExternalTableInsertExecutor executor = Mockito.mock(BaseExternalTableInsertExecutor.class); + ConnectorTransaction connectorTx = Mockito.mock(ConnectorTransaction.class); + ConnectorPredicate predicate = Mockito.mock(ConnectorPredicate.class); + TableIf table = Mockito.mock(TableIf.class); + Plan analyzedPlan = Mockito.mock(Plan.class); + + Mockito.when(executor.getConnectorTransactionOrNull()).thenReturn(connectorTx); + Mockito.when(t.extractWriteConstraint(analyzedPlan, table)).thenReturn(Optional.of(predicate)); + + RowLevelDmlCommand.applyWriteConstraintIfPresent(t, executor, analyzedPlan, table); + + Mockito.verify(connectorTx).applyWriteConstraint(predicate); + } + + @Test + public void applyWriteConstraintIsNoOpOnLegacyTransactionPath() { + // Dormant today: iceberg DELETE/MERGE run on the legacy IcebergTransaction, so the base executor's + // getConnectorTransactionOrNull() returns null and the O5-2 path is skipped entirely. + RowLevelDmlTransform t = Mockito.mock(RowLevelDmlTransform.class); + BaseExternalTableInsertExecutor executor = Mockito.mock(BaseExternalTableInsertExecutor.class); + TableIf table = Mockito.mock(TableIf.class); + Plan analyzedPlan = Mockito.mock(Plan.class); + + Mockito.when(executor.getConnectorTransactionOrNull()).thenReturn(null); + + RowLevelDmlCommand.applyWriteConstraintIfPresent(t, executor, analyzedPlan, table); + + Mockito.verify(t, Mockito.never()).extractWriteConstraint(Mockito.any(), Mockito.any()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergUpdateCommandTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergUpdateCommandTest.java index 44482530e9684d..51222ed8bbdad9 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergUpdateCommandTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergUpdateCommandTest.java @@ -21,13 +21,13 @@ import org.apache.doris.catalog.PrimitiveType; import org.apache.doris.catalog.ScalarType; import org.apache.doris.common.FeConstants; -import org.apache.doris.datasource.iceberg.IcebergMergeOperation; import org.apache.doris.nereids.analyzer.UnboundAlias; import org.apache.doris.nereids.analyzer.UnboundSlot; import org.apache.doris.nereids.trees.expressions.NamedExpression; import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral; import org.apache.doris.nereids.trees.plans.RelationId; import org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext; +import org.apache.doris.nereids.trees.plans.commands.merge.MergeOperation; import org.apache.doris.nereids.trees.plans.logical.LogicalOneRowRelation; import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; import org.apache.doris.nereids.trees.plans.logical.LogicalProject; @@ -79,7 +79,7 @@ public void testBuildMergeProjectPlanProjectsRowId() { boolean hasC1 = false; for (NamedExpression project : projects) { if (project instanceof UnboundAlias - && project.toString().contains(IcebergMergeOperation.OPERATION_COLUMN)) { + && project.toString().contains(MergeOperation.OPERATION_COLUMN)) { hasOperation = true; } if (project instanceof UnboundSlot @@ -134,33 +134,4 @@ public void testBuildUpdateSelectItemsSkipsHiddenColumns() { slot.getNameParts().get(slot.getNameParts().size() - 1))); Assertions.assertTrue(hasC2Slot); } - - @Test - public void testExecuteWithExternalTableBatchModeDisabledRestoresValueOnSuccess() throws Exception { - ConnectContext ctx = new ConnectContext(); - ctx.getSessionVariable().enableExternalTableBatchMode = true; - - Boolean result = IcebergUpdateCommand.executeWithExternalTableBatchModeDisabled(ctx, () -> { - Assertions.assertFalse(ctx.getSessionVariable().enableExternalTableBatchMode); - return Boolean.TRUE; - }); - - Assertions.assertTrue(result); - Assertions.assertTrue(ctx.getSessionVariable().enableExternalTableBatchMode); - } - - @Test - public void testExecuteWithExternalTableBatchModeDisabledRestoresValueOnException() { - ConnectContext ctx = new ConnectContext(); - ctx.getSessionVariable().enableExternalTableBatchMode = false; - - RuntimeException exception = Assertions.assertThrows(RuntimeException.class, - () -> IcebergUpdateCommand.executeWithExternalTableBatchModeDisabled(ctx, () -> { - Assertions.assertFalse(ctx.getSessionVariable().enableExternalTableBatchMode); - throw new RuntimeException("expected"); - })); - - Assertions.assertEquals("expected", exception.getMessage()); - Assertions.assertFalse(ctx.getSessionVariable().enableExternalTableBatchMode); - } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommandTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommandTest.java new file mode 100644 index 00000000000000..f98da4b7b77d51 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommandTest.java @@ -0,0 +1,104 @@ +// 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; + +import org.apache.doris.catalog.TableIf; +import org.apache.doris.nereids.trees.plans.Plan; +import org.apache.doris.nereids.trees.plans.commands.insert.BaseExternalTableInsertExecutor; +import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; +import org.apache.doris.planner.DataSink; +import org.apache.doris.planner.PlanFragment; +import org.apache.doris.qe.Coordinator; +import org.apache.doris.qe.StmtExecutor; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.Mockito; + +/** + * Pins the begin→finalize guarded window of {@link RowLevelDmlCommand}: {@code beginTransaction} registers + * the transaction with the transaction manager and the global external-transaction registry, and the + * executor's own failure handling only takes over at {@code executeSingleInsert} — so any throw in between + * must route through {@code onFail} (abort + registry cleanup), mirroring {@code InsertIntoTableCommand}'s + * guarded prepare. Without the guard those registrations leak until FE restart. + */ +public class RowLevelDmlCommandTest { + + private final RowLevelDmlTransform transform = Mockito.mock(RowLevelDmlTransform.class); + private final BaseExternalTableInsertExecutor executor = Mockito.mock(BaseExternalTableInsertExecutor.class); + private final StmtExecutor stmtExecutor = Mockito.mock(StmtExecutor.class); + private final Plan analyzedPlan = Mockito.mock(Plan.class); + private final TableIf table = Mockito.mock(TableIf.class); + private final PlanFragment fragment = Mockito.mock(PlanFragment.class); + private final DataSink dataSink = Mockito.mock(DataSink.class); + private final PhysicalSink physicalSink = Mockito.mock(PhysicalSink.class); + + private void invoke() { + RowLevelDmlCommand.beginTransactionAndFinalizeSink(transform, RowLevelDmlOp.DELETE, executor, + stmtExecutor, analyzedPlan, table, fragment, dataSink, physicalSink); + } + + @Test + public void finalizeSinkFailureRollsBackViaOnFail() { + // finalizeSink throws AFTER beginTransaction registered the txn (the mid-window failure the guard + // exists for). MUTATION: dropping the catch lets the exception propagate WITHOUT onFail -> the + // verify below turns red. + RuntimeException boom = new RuntimeException("finalize boom"); + Mockito.doThrow(boom).when(transform).finalizeSink(executor, RowLevelDmlOp.DELETE, + fragment, dataSink, physicalSink); + + RuntimeException thrown = Assertions.assertThrows(RuntimeException.class, this::invoke); + + // A RuntimeException is rethrown as-is (not wrapped), mirroring InsertIntoTableCommand. + Assertions.assertSame(boom, thrown); + InOrder inOrder = Mockito.inOrder(executor); + inOrder.verify(executor).beginTransaction(); + inOrder.verify(executor).onFail(boom); + } + + @Test + public void nonRuntimeFailureIsWrappedAndRolledBack() { + // A non-RuntimeException throwable in the window takes the wrap branch: onFail still runs, and the + // rethrow is IllegalStateException carrying the original as cause (the window's methods declare no + // checked exceptions, so an Error stands in for the non-runtime lane). + Error boom = new AssertionError("begin boom"); + Mockito.doThrow(boom).when(executor).beginTransaction(); + + IllegalStateException thrown = Assertions.assertThrows(IllegalStateException.class, this::invoke); + + Assertions.assertSame(boom, thrown.getCause()); + Mockito.verify(executor).onFail(boom); + // beginTransaction itself failed -> nothing further in the window may run. + Mockito.verify(transform, Mockito.never()).finalizeSink(Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any()); + } + + @Test + public void successPathWiresCoordinatorWithoutRollback() { + Coordinator coordinator = Mockito.mock(Coordinator.class); + Mockito.when(executor.getCoordinator()).thenReturn(coordinator); + Mockito.when(executor.getTxnId()).thenReturn(42L); + + invoke(); + + Mockito.verify(coordinator).setTxnId(42L); + Mockito.verify(stmtExecutor).setCoord(coordinator); + Mockito.verify(executor, Mockito.never()).onFail(Mockito.any()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlRowIdUtilsTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlRowIdUtilsTest.java new file mode 100644 index 00000000000000..5d5bda8b61fff7 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlRowIdUtilsTest.java @@ -0,0 +1,100 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.nereids.trees.plans.commands; + +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.EnumSet; +import java.util.Set; + +/** + * Unit tests for the row-id injection half of {@link RowLevelDmlRowIdUtils} (the SDK expression-conversion + * half was removed together with its dead legacy callers). + */ +public class RowLevelDmlRowIdUtilsTest { + + // ==================== isRowIdInjectionTarget (row-id injection guard) ==================== + + /** A plugin-driven table whose connector declares the given row-level-DML capabilities. */ + private static PluginDrivenExternalTable pluginTableWithCapability(boolean supportsDelete, boolean supportsMerge) { + Set ops = EnumSet.noneOf(WriteOperation.class); + if (supportsDelete) { + ops.add(WriteOperation.DELETE); + } + if (supportsMerge) { + ops.add(WriteOperation.MERGE); + } + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + // The row-id guard now probes the per-handle write ops via the table helper (which resolves the handle + // and calls the connector's per-handle overload); stub the table method directly. + Mockito.when(table.connectorSupportedWriteOperations()).thenReturn(ops); + return table; + } + + @Test + public void isRowIdInjectionTargetAcceptsDeleteOnlyPluginDrivenTable() { + // An iceberg PluginDrivenExternalTable is recognized by the neutral capability + // (supportsDelete OR supportsMerge). delete-only (true,false) pins the delete arm + an OR->AND mutation + // (which would reject it). MUTATION: dropping the plugin arm makes this red (row-id injection + // would never fire). + Assertions.assertTrue( + RowLevelDmlRowIdUtils.isRowIdInjectionTarget(pluginTableWithCapability(true, false))); + } + + @Test + public void isRowIdInjectionTargetAcceptsMergeOnlyPluginDrivenTable() { + // merge-only (false,true) pins the OTHER arm of the OR: iceberg supports MERGE, so a 'drop + // ||supportsMerge()' mutation (return supportsDelete()) must die here. Without this case the delete-only + // test above leaves that mutation surviving. + Assertions.assertTrue( + RowLevelDmlRowIdUtils.isRowIdInjectionTarget(pluginTableWithCapability(false, true))); + } + + @Test + public void isRowIdInjectionTargetRejectsPluginDrivenTableWithoutCapability() { + // A non-iceberg plugin-driven table (jdbc/es/trino/max_compute/paimon) declares neither capability, + // so it is not a row-id-injection target — the guard must not inject into its scans. + Assertions.assertFalse( + RowLevelDmlRowIdUtils.isRowIdInjectionTarget(pluginTableWithCapability(false, false))); + } + + @Test + public void isRowIdInjectionTargetRejectsUnrelatedExternalTable() { + // Any other table type (e.g. an HMS/olap external table) is never a row-id-injection target. + Assertions.assertFalse( + RowLevelDmlRowIdUtils.isRowIdInjectionTarget(Mockito.mock(ExternalTable.class))); + } + + @Test + public void isRowIdInjectionTargetDegradesWhenNoWriteOps() { + // The per-handle write-op probe degrades to an EMPTY set on a dropped connector / unresolvable handle + // (the guard now lives in PluginDrivenExternalTable.connectorSupportedWriteOperations, covered by its own + // test); an empty set must read as "not a target" rather than admitting row-id injection. MUTATION: + // treating empty ops as a target reddens this. + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(table.connectorSupportedWriteOperations()).thenReturn(EnumSet.noneOf(WriteOperation.class)); + + Assertions.assertFalse(RowLevelDmlRowIdUtils.isRowIdInjectionTarget(table)); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ShowCreateTableCommandTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ShowCreateTableCommandTest.java new file mode 100644 index 00000000000000..9d4cd8482d5cd1 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ShowCreateTableCommandTest.java @@ -0,0 +1,57 @@ +// 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; + +import org.apache.doris.catalog.TableIf; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenSysExternalTable; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * Guards the F4/F13 fix in {@link ShowCreateTableCommand#doRun}: a system table ($snapshots/...) must be + * redirected to its source base table before {@code Env.getDdlStmt} renders it, so SHOW CREATE emits the base + * table's DDL rather than the sys-table shell. Post-cutover a sys table is a {@link PluginDrivenSysExternalTable} + * (the legacy doRun only unwrapped {@link org.apache.doris.datasource.iceberg.IcebergSysExternalTable}); the + * redirect was already present in {@code validate()} for the privilege check, so doRun's omission was + * asymmetric. + */ +public class ShowCreateTableCommandTest { + + @Test + public void redirectSysTableToSourceUnwrapsPluginSysTable() { + // MUTATION: dropping the PluginDrivenSysExternalTable arm -> the sys shell flows to Env.getDdlStmt + // (sys-table name/columns, PARTITION BY suppressed) instead of the base table DDL -> red. + PluginDrivenExternalTable source = Mockito.mock(PluginDrivenExternalTable.class); + PluginDrivenSysExternalTable sys = Mockito.mock(PluginDrivenSysExternalTable.class); + Mockito.when(sys.getSourceTable()).thenReturn(source); + + Assertions.assertSame(source, ShowCreateTableCommand.redirectSysTableToSource(sys), + "a PluginDrivenSysExternalTable must be redirected to its source base table"); + } + + @Test + public void redirectSysTableToSourcePassesThroughPlainTable() { + // A non-sys table must pass through unchanged (no accidental unwrap / ClassCast). MUTATION: an + // unconditional getSourceTable() cast would break for a plain table -> red. + TableIf plain = Mockito.mock(TableIf.class); + Assertions.assertSame(plain, ShowCreateTableCommand.redirectSysTableToSource(plain)); + } +} 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 new file mode 100644 index 00000000000000..3bfb588129443c --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ShowPartitionsCommandPluginDrivenTest.java @@ -0,0 +1,184 @@ +// 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; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.PrimitiveType; +import org.apache.doris.catalog.info.TableNameInfo; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorPartitionInfo; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.qe.ShowResultSet; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.lang.reflect.Field; +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; + +/** + * Tests for SHOW PARTITIONS dispatch to a {@link PluginDrivenExternalCatalog} added by + * P4-T06c (ShowPartitionsCommand.handleShowPluginDrivenTablePartitions). + * + *

    Why: after the MaxCompute SPI cutover, a {@code max_compute} catalog is a + * {@link PluginDrivenExternalCatalog}. The legacy handler keyed on + * {@code instanceof MaxComputeExternalCatalog} no longer matches, so SHOW PARTITIONS + * must route through the connector SPI instead. This test locks in that the new handler + * resolves the table handle using the REMOTE db/table names and emits one row per + * partition returned by {@code listPartitionNames}.

    + */ +public class ShowPartitionsCommandPluginDrivenTest { + + @Test + public void testHandlerRoutesToSpiWithRemoteNames() throws Exception { + TableNameInfo tableName = Mockito.mock(TableNameInfo.class); + Mockito.when(tableName.getDb()).thenReturn("db"); + Mockito.when(tableName.getTbl()).thenReturn("t"); + + ShowPartitionsCommand command = new ShowPartitionsCommand(tableName, null, null, -1L, -1L, false); + + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + Connector connector = Mockito.mock(Connector.class); + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + ExternalDatabase db = Mockito.mock(ExternalDatabase.class); + ExternalTable table = Mockito.mock(ExternalTable.class); + Mockito.when(table.getRemoteDbName()).thenReturn("remote_db"); + Mockito.when(table.getRemoteName()).thenReturn("remote_tbl"); + + // Resolution chain: catalog.getDbOrAnalysisException(db).getTableOrAnalysisException(t) -> table. + // doReturn avoids generic-type checks on the default interface methods. + Mockito.doReturn(db).when(catalog).getDbOrAnalysisException("db"); + Mockito.doReturn(table).when(db).getTableOrAnalysisException("t"); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.when(connector.getMetadata(session)).thenReturn(metadata); + Mockito.when(metadata.getTableHandle(session, "remote_db", "remote_tbl")) + .thenReturn(Optional.of(handle)); + Mockito.when(metadata.listPartitionNames(session, handle)) + .thenReturn(Arrays.asList("pt=2", "pt=1")); + + setField(command, "catalog", catalog); + + Method m = ShowPartitionsCommand.class.getDeclaredMethod("handleShowPluginDrivenTablePartitions"); + m.setAccessible(true); + ShowResultSet rs = (ShowResultSet) m.invoke(command); + + List> rows = rs.getResultRows(); + Assertions.assertEquals(2, rows.size()); + // sorted ascending by partition name + Assertions.assertEquals("pt=1", rows.get(0).get(0)); + Assertions.assertEquals("pt=2", rows.get(1).get(0)); + Mockito.verify(metadata).getTableHandle(session, "remote_db", "remote_tbl"); + } + + @Test + public void testHandlerEmitsFiveColumnsWhenConnectorDeclaresPartitionStats() throws Exception { + TableNameInfo tableName = Mockito.mock(TableNameInfo.class); + Mockito.when(tableName.getDb()).thenReturn("db"); + Mockito.when(tableName.getTbl()).thenReturn("t"); + + ShowPartitionsCommand command = new ShowPartitionsCommand(tableName, null, null, -1L, -1L, false); + + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + Connector connector = Mockito.mock(Connector.class); + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + ExternalDatabase db = Mockito.mock(ExternalDatabase.class); + // Must be a PluginDrivenExternalTable: the 5-col path casts the table to read its partition + // columns for the PartitionKey column. + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(table.getRemoteDbName()).thenReturn("remote_db"); + Mockito.when(table.getRemoteName()).thenReturn("remote_tbl"); + Mockito.when(table.getPartitionColumns()).thenReturn(Arrays.asList( + new Column("dt", PrimitiveType.INT), new Column("region", PrimitiveType.INT))); + + Mockito.doReturn(db).when(catalog).getDbOrAnalysisException("db"); + Mockito.doReturn(table).when(db).getTableOrAnalysisException("t"); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.when(connector.getMetadata(session)).thenReturn(metadata); + // The capability that flips SHOW PARTITIONS to the 5-column rich result (D-045). + Mockito.when(connector.getCapabilities()) + .thenReturn(EnumSet.of(ConnectorCapability.SUPPORTS_PARTITION_STATS)); + 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())) + .thenReturn(Collections.singletonList(new ConnectorPartitionInfo( + "dt=2024/region=cn", Collections.emptyMap(), Collections.emptyMap(), + /*rowCount*/ 42L, /*sizeBytes*/ 1024L, /*lastModifiedMillis*/ 1700000000000L, + /*fileCount*/ 7L))); + + setField(command, "catalog", catalog); + + Method m = ShowPartitionsCommand.class.getDeclaredMethod("handleShowPluginDrivenTablePartitions"); + m.setAccessible(true); + ShowResultSet rs = (ShowResultSet) m.invoke(command); + + List> rows = rs.getResultRows(); + Assertions.assertEquals(1, rows.size()); + List row = rows.get(0); + // WHY (D-045): a connector declaring SUPPORTS_PARTITION_STATS yields the legacy paimon + // 5-column result: Partition / PartitionKey / RecordCount / FileSizeInBytes / FileCount. + // MUTATION: gating on instanceof PaimonExternalCatalog (false here) or dropping the capability + // branch -> 1-col fallback -> red. + Assertions.assertEquals(5, row.size(), + "SUPPORTS_PARTITION_STATS must yield the 5-column rich result, not the 1-col fallback"); + Assertions.assertEquals("dt=2024/region=cn", row.get(0)); + // PartitionKey = table partition-column names comma-joined, identical on every row. + // MUTATION: deriving it from per-partition getPartitionValues() instead of the table's + // partition columns -> red. + Assertions.assertEquals("dt,region", row.get(1)); + // RecordCount<-getRowCount, FileSizeInBytes<-getSizeBytes, FileCount<-getFileCount. + // MUTATION: swapping these getters / dropping fileCount -> red. + Assertions.assertEquals("42", row.get(2)); + Assertions.assertEquals("1024", row.get(3)); + Assertions.assertEquals("7", row.get(4)); + // getMetaData() MUST agree on the column count or ShowResultSet headers/rows diverge. + Assertions.assertEquals(5, command.getMetaData().getColumnCount(), + "getMetaData must produce 5 headers to match the 5-col rows (same capability gate)"); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field f = ShowPartitionsCommand.class.getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } +} 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 new file mode 100644 index 00000000000000..0111ef24649008 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorExecuteActionTest.java @@ -0,0 +1,583 @@ +// 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.execute; + +import org.apache.doris.analysis.UserIdentity; +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.Env; +import org.apache.doris.catalog.RefreshManager; +import org.apache.doris.catalog.ScalarType; +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.UserException; +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.DorisConnectorException; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.procedure.ConnectorProcedureOps; +import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; +import org.apache.doris.connector.api.procedure.ProcedureExecutionMode; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.SessionContext; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.mysql.privilege.AccessControllerManager; +import org.apache.doris.mysql.privilege.PrivPredicate; +import org.apache.doris.nereids.analyzer.UnboundSlot; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.expressions.GreaterThan; +import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.QueryState; +import org.apache.doris.qe.ResultSet; + +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.MockedStatic; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Unit tests for {@link ConnectorExecuteAction} and the {@code PluginDrivenExternalTable} branch of + * {@link ExecuteActionFactory} (P6.4-T07 dispatch rewire). + * + *

    WHY this matters: at the P6.6 iceberg cutover, {@code ALTER TABLE t EXECUTE proc(...)} on an + * iceberg table (then a {@code PluginDrivenExternalTable}) must route through the connector's + * {@link ConnectorProcedureOps} instead of the legacy fe-core actions, while the engine keeps the + * {@code ALTER} privilege check, the single-row {@code CommonResultSet} wrapping and the edit-log refresh + * (D-062 §2). These tests pin that the dispatch threads the catalog's session/handle into + * {@code getProcedureOps().execute(...)}, wraps the engine-neutral {@link ConnectorProcedureResult} back + * into a {@code ResultSet}, surfaces the connector's {@link DorisConnectorException} as a + * {@code UserException} (so the command shell adds the legacy "Failed to execute action:" prefix). + */ +public class ConnectorExecuteActionTest { + + private static final String CATALOG = "test_catalog"; + private static final String REMOTE_DB = "remote_db"; + private static final String REMOTE_TBL = "remote_tbl"; + + // execute() now refreshes the table's caches after a successful commit (H-6) via + // Env.getCurrentEnv().getRefreshManager().refreshTableInternal(...). Stub that statically for every test so + // the dispatch paths don't NPE, and so the refresh can be verified. + private MockedStatic envStatic; + private Env env; + private RefreshManager refreshManager; + + @BeforeEach + public void setUpEnv() { + envStatic = Mockito.mockStatic(Env.class); + env = Mockito.mock(Env.class); + refreshManager = Mockito.mock(RefreshManager.class); + Mockito.when(env.getRefreshManager()).thenReturn(refreshManager); + envStatic.when(Env::getCurrentEnv).thenReturn(env); + } + + @AfterEach + public void tearDownEnv() { + envStatic.close(); + } + + // -------- ExecuteActionFactory routing -------- + + @Test + public void createActionRoutesPluginDrivenTableToConnectorAdapter() throws Exception { + Fixture f = new Fixture(); + ExecuteAction action = ExecuteActionFactory.createAction("rollback_to_snapshot", + f.props, Optional.empty(), Optional.empty(), f.table); + Assertions.assertTrue(action instanceof ConnectorExecuteAction, + "A PluginDrivenExternalTable must dispatch to the connector procedure adapter, not a legacy action"); + } + + @Test + public void getSupportedActionsReturnsConnectorProcedureNames() { + Fixture f = new Fixture(); + Mockito.when(f.procedureOps.getSupportedProcedures()) + .thenReturn(Arrays.asList("rollback_to_snapshot", "expire_snapshots")); + String[] actions = ExecuteActionFactory.getSupportedActions(f.table); + Assertions.assertArrayEquals(new String[] {"rollback_to_snapshot", "expire_snapshots"}, actions, + "getSupportedActions must export the connector's supported procedure names"); + } + + // -------- execute(): dispatch + result wrapping -------- + + @Test + public void executeThreadsSessionAndHandleIntoConnectorAndWrapsResult() throws Exception { + Fixture f = new Fixture(); + Mockito.when(f.procedureOps.execute(Mockito.any(), Mockito.any(), Mockito.anyString(), + Mockito.anyMap(), Mockito.any(), Mockito.anyList())) + .thenReturn(twoColumnResult(Arrays.asList("100", "200"))); + + ConnectorExecuteAction action = new ConnectorExecuteAction("rollback_to_snapshot", + f.props, Optional.empty(), Optional.empty(), f.table); + ResultSet rs = action.execute(f.table); + + // The connector saw the catalog's session + the resolved handle + the engine-neutral carriers. + Mockito.verify(f.procedureOps).execute(Mockito.eq(f.session), Mockito.eq(f.handle), + Mockito.eq("rollback_to_snapshot"), Mockito.eq(f.props), + Mockito.isNull(), Mockito.eq(Collections.emptyList())); + + // The ConnectorProcedureResult was converted into a CommonResultSet (columns via ConnectorColumnConverter). + List colNames = Arrays.asList(rs.getMetaData().getColumns().get(0).getName(), + rs.getMetaData().getColumns().get(1).getName()); + Assertions.assertEquals(Arrays.asList("previous_snapshot_id", "current_snapshot_id"), colNames); + Assertions.assertEquals(Collections.singletonList(Arrays.asList("100", "200")), rs.getResultRows()); + + // The converted Doris column metadata (type + nullability) is client-visible, so pin it too: twoColumnResult + // builds BIGINT non-null columns, mirroring IcebergRollbackToSnapshotAction.getResultSchema's + // (Type.BIGINT, false) columns — a ConnectorColumnConverter mutation that drops the type/nullability is caught. + Assertions.assertEquals(org.apache.doris.catalog.Type.BIGINT, rs.getMetaData().getColumns().get(0).getType()); + Assertions.assertEquals(org.apache.doris.catalog.Type.BIGINT, rs.getMetaData().getColumns().get(1).getType()); + Assertions.assertFalse(rs.getMetaData().getColumns().get(0).isAllowNull()); + Assertions.assertFalse(rs.getMetaData().getColumns().get(1).isAllowNull()); + } + + @Test + public void wrapResultRoundTripsColumnNullabilityBothPolarities() throws Exception { + // A fast_forward-SHAPED schema mixes a non-nullable column with a nullable one. The converter must + // round-trip BOTH polarities through ConnectorColumn.isNullable() -> Column(isAllowNull), so a converter + // mutation that drops (or forces) nullability is caught — not just the all-non-null twoColumnResult case. + Fixture f = new Fixture(); + List schema = Arrays.asList( + new ConnectorColumn("branch_updated", ConnectorType.of("STRING"), "c", false, null), + new ConnectorColumn("previous_ref", ConnectorType.of("BIGINT"), "c", true, null), + new ConnectorColumn("updated_ref", ConnectorType.of("BIGINT"), "c", false, null)); + Mockito.when(f.procedureOps.execute(Mockito.any(), Mockito.any(), Mockito.anyString(), + Mockito.anyMap(), Mockito.any(), Mockito.anyList())) + .thenReturn(new ConnectorProcedureResult(schema, + Collections.singletonList(Arrays.asList("main", "100", "200")))); + ConnectorExecuteAction action = new ConnectorExecuteAction("fast_forward", + f.props, Optional.empty(), Optional.empty(), f.table); + ResultSet rs = action.execute(f.table); + Assertions.assertFalse(rs.getMetaData().getColumns().get(0).isAllowNull()); + Assertions.assertTrue(rs.getMetaData().getColumns().get(1).isAllowNull(), + "the nullable column must round-trip nullable through ConnectorColumnConverter"); + Assertions.assertFalse(rs.getMetaData().getColumns().get(2).isAllowNull()); + Assertions.assertEquals(org.apache.doris.catalog.Type.BIGINT, + rs.getMetaData().getColumns().get(1).getType()); + } + + @Test + public void executePassesPartitionNamesThrough() throws Exception { + Fixture f = new Fixture(); + Mockito.when(f.procedureOps.execute(Mockito.any(), Mockito.any(), Mockito.anyString(), + Mockito.anyMap(), Mockito.any(), Mockito.anyList())) + .thenReturn(twoColumnResult(Arrays.asList("1", "2"))); + + PartitionNamesInfo partitions = new PartitionNamesInfo(false, Arrays.asList("p1", "p2")); + ConnectorExecuteAction action = new ConnectorExecuteAction("expire_snapshots", + f.props, Optional.of(partitions), Optional.empty(), f.table); + action.execute(f.table); + + Mockito.verify(f.procedureOps).execute(Mockito.any(), Mockito.any(), Mockito.eq("expire_snapshots"), + Mockito.anyMap(), Mockito.isNull(), Mockito.eq(Arrays.asList("p1", "p2"))); + } + + @Test + public void executeSingleCallActionRejectsWhereCondition() { + // The eight pure-SDK procedures are SINGLE_CALL and never accept a WHERE; it must be rejected fail-loud + // (only a DISTRIBUTED rewrite scopes its work by a WHERE). A bare mock Expression is enough — the reject + // happens on the mode check, before any lowering. + Fixture f = new Fixture(); + Mockito.when(f.procedureOps.getExecutionMode("rollback_to_snapshot")) + .thenReturn(ProcedureExecutionMode.SINGLE_CALL); + Expression where = Mockito.mock(Expression.class); + ConnectorExecuteAction action = new ConnectorExecuteAction("rollback_to_snapshot", + f.props, Optional.empty(), Optional.of(where), f.table); + + DdlException e = Assertions.assertThrows(DdlException.class, () -> action.execute(f.table)); + Assertions.assertTrue(e.getMessage().contains("WHERE"), + "a SINGLE_CALL procedure must reject a WHERE (only DISTRIBUTED rewrite accepts one)"); + // The connector body must never run for a rejected WHERE. + Mockito.verify(f.procedureOps, Mockito.never()).execute(Mockito.any(), Mockito.any(), + Mockito.anyString(), Mockito.anyMap(), Mockito.any(), Mockito.anyList()); + Mockito.verify(f.procedureOps, Mockito.never()).planRewrite(Mockito.any(), Mockito.any(), + Mockito.anyString(), Mockito.anyMap(), Mockito.any(), Mockito.anyList()); + } + + @Test + 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. + 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()); + + Expression where = new GreaterThan(new UnboundSlot("a"), new IntegerLiteral(5)); + ConnectorExecuteAction action = new ConnectorExecuteAction("rewrite_data_files", + f.props, Optional.empty(), Optional.of(where), f.table); + ResultSet rs = action.execute(f.table); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ConnectorPredicate.class); + Mockito.verify(f.procedureOps).planRewrite(Mockito.any(), Mockito.any(), + Mockito.eq("rewrite_data_files"), Mockito.anyMap(), captor.capture(), Mockito.anyList()); + ConnectorPredicate lowered = captor.getValue(); + Assertions.assertNotNull(lowered, + "the DISTRIBUTED arm must lower the WHERE and pass a non-null predicate, not drop it to null"); + 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. + Assertions.assertEquals(Collections.singletonList(Arrays.asList("0", "0", "0", "0")), rs.getResultRows()); + } + + @Test + public void executeReWrapsConnectorExceptionAsUserExceptionWithSameMessage() { + Fixture f = new Fixture(); + Mockito.when(f.procedureOps.execute(Mockito.any(), Mockito.any(), Mockito.anyString(), + Mockito.anyMap(), Mockito.any(), Mockito.anyList())) + .thenThrow(new DorisConnectorException("Snapshot 7 not found in table remote_tbl")); + + ConnectorExecuteAction action = new ConnectorExecuteAction("rollback_to_snapshot", + f.props, Optional.empty(), Optional.empty(), f.table); + + // Must surface as a checked UserException so ExecuteActionCommand.run() catches it and re-wraps it with + // the legacy "Failed to execute action:" prefix. The exact plain UserException type matches what the + // legacy action bodies threw, and the inner message is preserved verbatim (T08 byte-parity). + UserException e = Assertions.assertThrows( + UserException.class, () -> action.execute(f.table)); + Assertions.assertEquals(UserException.class, e.getClass(), + "Re-wrap must use the plain UserException type the legacy action body threw (no extra errCode layer)"); + Assertions.assertEquals("Snapshot 7 not found in table remote_tbl", e.getDetailMessage()); + } + + @Test + public void executeThrowsWhenConnectorExposesNoProcedureOps() { + Fixture f = new Fixture(); + // The dispatch selects the ops PER-HANDLE, so a connector with no procedures for this table returns null + // from the per-handle overload (a plain-hive handle in a flipped hms gateway). + Mockito.when(f.connector.getProcedureOps(Mockito.any())).thenReturn(null); + + ConnectorExecuteAction action = new ConnectorExecuteAction("rollback_to_snapshot", + f.props, Optional.empty(), Optional.empty(), f.table); + DdlException e = Assertions.assertThrows(DdlException.class, () -> action.execute(f.table)); + Assertions.assertTrue(e.getMessage().contains("does not support"), + "A connector with no procedure ops must fail loud, mirroring the write-path null-provider throw"); + } + + @Test + public void executeSelectsProcedureOpsByResolvedHandleNotNoArg() { + // W5 gateway scenario: a flipped hms gateway exposes NO connector-level procedures — getProcedureOps() is + // null — but its iceberg-on-HMS table diverts to the sibling's ops via getProcedureOps(handle). Pin that + // the dispatch consults the PER-HANDLE overload with the resolved handle: with the no-arg getter null but + // the per-handle overload wired to the ops, EXECUTE must still run. MUTATION: reverting the dispatch to + // the no-arg getProcedureOps() -> it reads null -> throws "does not support EXECUTE" -> this test red + // (that mutation would wrongly reject every iceberg-on-HMS EXECUTE at the flip). + Fixture f = new Fixture(); + Mockito.when(f.connector.getProcedureOps()).thenReturn(null); + Mockito.when(f.procedureOps.execute(Mockito.any(), Mockito.any(), Mockito.anyString(), + Mockito.anyMap(), Mockito.any(), Mockito.anyList())) + .thenReturn(twoColumnResult(Arrays.asList("100", "200"))); + + ConnectorExecuteAction action = new ConnectorExecuteAction("rollback_to_snapshot", + f.props, Optional.empty(), Optional.empty(), f.table); + Assertions.assertDoesNotThrow(() -> action.execute(f.table)); + + // The ops were selected by the RESOLVED handle, and the body ran with that same handle threaded through. + Mockito.verify(f.connector).getProcedureOps(Mockito.eq(f.handle)); + Mockito.verify(f.procedureOps).execute(Mockito.eq(f.session), Mockito.eq(f.handle), + Mockito.eq("rollback_to_snapshot"), Mockito.eq(f.props), + Mockito.isNull(), Mockito.eq(Collections.emptyList())); + } + + @Test + public void executeThrowsWhenTableHandleMissing() { + Fixture f = new Fixture(); + Mockito.when(f.metadata.getTableHandle(Mockito.any(), Mockito.anyString(), Mockito.anyString())) + .thenReturn(Optional.empty()); + + ConnectorExecuteAction action = new ConnectorExecuteAction("rollback_to_snapshot", + f.props, Optional.empty(), Optional.empty(), f.table); + AnalysisException e = Assertions.assertThrows(AnalysisException.class, () -> action.execute(f.table)); + Assertions.assertTrue(e.getMessage().contains("Table not found")); + } + + @Test + public void executeEnforcesSingleRowWidthInvariant() { + Fixture f = new Fixture(); + // Two declared columns but a one-wide row -> the single-row contract (BaseExecuteAction:106-108) must trip. + Mockito.when(f.procedureOps.execute(Mockito.any(), Mockito.any(), Mockito.anyString(), + Mockito.anyMap(), Mockito.any(), Mockito.anyList())) + .thenReturn(twoColumnResult(Collections.singletonList("only-one"))); + + ConnectorExecuteAction action = new ConnectorExecuteAction("rollback_to_snapshot", + f.props, Optional.empty(), Optional.empty(), f.table); + Assertions.assertThrows(IllegalStateException.class, () -> action.execute(f.table)); + } + + @Test + public void executeReturnsNullResultSetForEmptySchema() throws Exception { + Fixture f = new Fixture(); + Mockito.when(f.procedureOps.execute(Mockito.any(), Mockito.any(), Mockito.anyString(), + Mockito.anyMap(), Mockito.any(), Mockito.anyList())) + .thenReturn(new ConnectorProcedureResult(Collections.emptyList(), Collections.emptyList())); + + ConnectorExecuteAction action = new ConnectorExecuteAction("rollback_to_snapshot", + f.props, Optional.empty(), Optional.empty(), f.table); + Assertions.assertNull(action.execute(f.table), + "A procedure with no result columns wraps to a null ResultSet (mirrors BaseExecuteAction)"); + } + + @Test + public void executeReturnsNullResultSetWhenConnectorReturnsNoRows() throws Exception { + Fixture f = new Fixture(); + // The connector encodes a null body row as (schema, emptyRows) (BaseIcebergAction.execute). Legacy + // BaseExecuteAction.execute returns null when the body row is null, so the adapter must too — otherwise + // a future procedure that returns a null row would send a 0-row result set instead of nothing. + List schema = Arrays.asList( + new ConnectorColumn("c", ConnectorType.of("BIGINT"), "c", false, null)); + Mockito.when(f.procedureOps.execute(Mockito.any(), Mockito.any(), Mockito.anyString(), + Mockito.anyMap(), Mockito.any(), Mockito.anyList())) + .thenReturn(new ConnectorProcedureResult(schema, Collections.emptyList())); + + ConnectorExecuteAction action = new ConnectorExecuteAction("rollback_to_snapshot", + f.props, Optional.empty(), Optional.empty(), f.table); + Assertions.assertNull(action.execute(f.table), + "A non-empty schema with zero rows (connector's null-row encoding) wraps to null, like legacy"); + } + + // -------- execute(): leader-side cache refresh after a successful commit (H-6) -------- + + @Test + public void executeRefreshesTableCachesAfterSuccessfulSingleCall() throws Exception { + Fixture f = new Fixture(); + Mockito.when(f.procedureOps.execute(Mockito.any(), Mockito.any(), Mockito.anyString(), + Mockito.anyMap(), Mockito.any(), Mockito.anyList())) + .thenReturn(twoColumnResult(Arrays.asList("100", "200"))); + + ConnectorExecuteAction action = new ConnectorExecuteAction("rollback_to_snapshot", + f.props, Optional.empty(), Optional.empty(), f.table); + action.execute(f.table); + + // H-6: the FE that ran the procedure must refresh the mutated table through the standard refresh-table + // path — the only path that clears BOTH the engine meta cache (LOCAL names) and the connector's own + // per-table cache (REMOTE names, the iceberg latest-snapshot cache, default TTL 24h). Without this the + // leader keeps serving the pre-procedure snapshot up to 24h (leader/follower split). MUTATION: dropping + // the refreshTableCachesAfterMutation() call -> verify fails. + Mockito.verify(refreshManager).refreshTableInternal( + Mockito.eq(f.db), Mockito.eq(f.table), Mockito.anyLong()); + } + + @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. + 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()); + + Expression where = new GreaterThan(new UnboundSlot("a"), new IntegerLiteral(5)); + ConnectorExecuteAction action = new ConnectorExecuteAction("rewrite_data_files", + f.props, Optional.empty(), Optional.of(where), f.table); + action.execute(f.table); + + Mockito.verify(refreshManager).refreshTableInternal( + Mockito.eq(f.db), Mockito.eq(f.table), Mockito.anyLong()); + } + + @Test + public void executeDoesNotRefreshWhenProcedureFails() { + Fixture f = new Fixture(); + Mockito.when(f.procedureOps.execute(Mockito.any(), Mockito.any(), Mockito.anyString(), + Mockito.anyMap(), Mockito.any(), Mockito.anyList())) + .thenThrow(new DorisConnectorException("Snapshot 7 not found in table remote_tbl")); + + ConnectorExecuteAction action = new ConnectorExecuteAction("rollback_to_snapshot", + f.props, Optional.empty(), Optional.empty(), f.table); + + Assertions.assertThrows(UserException.class, () -> action.execute(f.table)); + // A failed procedure committed nothing, so it must not refresh (no spurious cache churn; mirrors follower + // replay which only runs on a logged success). MUTATION: refreshing unconditionally / in a finally -> the + // never-verify fails. + Mockito.verify(refreshManager, Mockito.never()) + .refreshTableInternal(Mockito.any(), Mockito.any(), Mockito.anyLong()); + } + + // -------- validate(): engine keeps the ALTER privilege check -------- + + @Test + public void validateEnforcesAlterPrivilege() { + Fixture f = new Fixture(); + TableNameInfo tableName = new TableNameInfo(CATALOG, "db", "tbl"); + UserIdentity user = Mockito.mock(UserIdentity.class); + Mockito.when(user.getQualifiedUser()).thenReturn("u"); + + ConnectorExecuteAction action = new ConnectorExecuteAction("rollback_to_snapshot", + f.props, Optional.empty(), Optional.empty(), f.table); + + AccessControllerManager access = Mockito.mock(AccessControllerManager.class); + // Reuse the shared Env mock (the @BeforeEach MockedStatic is already active; a second one would + // throw "static mocking is already registered"); just wire the access manager onto it. + Mockito.when(env.getAccessManager()).thenReturn(access); + ConnectContext ctx = Mockito.mock(ConnectContext.class); + Mockito.when(ctx.getRemoteIP()).thenReturn("127.0.0.1"); + // ErrorReport.reportCommon records the error on ctx.getState() before throwing the AnalysisException. + Mockito.when(ctx.getState()).thenReturn(Mockito.mock(QueryState.class)); + + try (MockedStatic ctxStatic = Mockito.mockStatic(ConnectContext.class)) { + ctxStatic.when(ConnectContext::get).thenReturn(ctx); + + // Denied -> validate must throw the same access-denied AnalysisException as legacy + // BaseExecuteAction.validate (ErrorReport.reportAnalysisException with ERR_TABLEACCESS_DENIED_ERROR), + // not just any exception — so the assertion fails if the privilege error type/message silently changes. + Mockito.when(access.checkTblPriv(Mockito.any(ConnectContext.class), Mockito.eq(CATALOG), + Mockito.eq("db"), Mockito.eq("tbl"), Mockito.eq(PrivPredicate.ALTER))).thenReturn(false); + AnalysisException denied = Assertions.assertThrows(AnalysisException.class, + () -> action.validate(tableName, user)); + Assertions.assertTrue(denied.getMessage().contains("ALTER") && denied.getMessage().contains("denied"), + "Denial must carry the legacy ALTER access-denied message"); + + // Granted -> validate returns without touching the connector (per-arg validation is connector-side). + Mockito.when(access.checkTblPriv(Mockito.any(ConnectContext.class), Mockito.eq(CATALOG), + Mockito.eq("db"), Mockito.eq("tbl"), Mockito.eq(PrivPredicate.ALTER))).thenReturn(true); + Assertions.assertDoesNotThrow(() -> action.validate(tableName, user)); + Mockito.verifyNoInteractions(f.procedureOps); + } + } + + // -------- helpers -------- + + private static ConnectorProcedureResult twoColumnResult(List row) { + List schema = Arrays.asList( + new ConnectorColumn("previous_snapshot_id", ConnectorType.of("BIGINT"), "prev", false, null), + new ConnectorColumn("current_snapshot_id", ConnectorType.of("BIGINT"), "cur", false, null)); + return new ConnectorProcedureResult(schema, Collections.singletonList(row)); + } + + /** Wires a PluginDrivenExternalTable over a mock connector chain (session/metadata/handle/procedureOps). */ + private static final class Fixture { + final Map props = new HashMap<>(); + final ConnectorSession session = Mockito.mock(ConnectorSession.class); + final ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + final ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + final ConnectorProcedureOps procedureOps = Mockito.mock(ConnectorProcedureOps.class); + final Connector connector = Mockito.mock(Connector.class); + final ExternalDatabase db; + final PluginDrivenExternalTable table; + + @SuppressWarnings("unchecked") + Fixture() { + props.put("snapshot_id", "200"); + // The funnel PluginDrivenMetadata.get(session, connector) reads session.getStatementScope(); a bare + // Mockito mock returns null (NPE), so pin the interface-default NONE scope (runs getMetadata per call). + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); + Mockito.when(connector.getProcedureOps()).thenReturn(procedureOps); + // execute() selects the ops per-handle (getProcedureOps(handle)); a Mockito mock does NOT run the + // default method, so the per-handle overload must be stubbed explicitly or it returns null. + Mockito.when(connector.getProcedureOps(Mockito.any())).thenReturn(procedureOps); + Mockito.when(metadata.getTableHandle(Mockito.any(ConnectorSession.class), + Mockito.anyString(), Mockito.anyString())).thenReturn(Optional.of(handle)); + + TestPluginCatalog catalog = new TestPluginCatalog(connector, session); + this.db = Mockito.mock(ExternalDatabase.class); + Mockito.when(db.getRemoteName()).thenReturn(REMOTE_DB); + this.table = new SchemaPluginTable(1L, "local_tbl", REMOTE_TBL, catalog, db); + } + } + + /** A plugin table with one resolvable column {@code a INT}, so the rewrite WHERE lowering can resolve it. */ + private static final class SchemaPluginTable extends PluginDrivenExternalTable { + SchemaPluginTable(long id, String name, String remoteName, PluginDrivenExternalCatalog catalog, + ExternalDatabase db) { + super(id, name, remoteName, catalog, db); + } + + @Override + public Column getColumn(String colName) { + return "a".equalsIgnoreCase(colName) ? new Column("a", ScalarType.INT) : null; + } + } + + /** Minimal catalog that returns the test connector/session without standing up Env/CatalogMgr. */ + private static final class TestPluginCatalog extends PluginDrivenExternalCatalog { + private final Connector connector; + private final ConnectorSession session; + + TestPluginCatalog(Connector connector, ConnectorSession session) { + super(1L, CATALOG, null, makeProps(), "", connector); + this.connector = connector; + this.session = session; + } + + @Override + public String getType() { + return "iceberg"; + } + + @Override + public Connector getConnector() { + return connector; + } + + @Override + public ConnectorSession buildConnectorSession() { + return session; + } + + @Override + public ConnectorSession buildCrossStatementSession() { + return buildConnectorSession(); + } + + @Override + protected List listDatabaseNames() { + return Collections.emptyList(); + } + + @Override + protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { + return Collections.emptyList(); + } + + @Override + public boolean tableExist(SessionContext ctx, String dbName, String tblName) { + return false; + } + + private static Map makeProps() { + Map props = new HashMap<>(); + props.put("type", "iceberg"); + return props; + } + } +} 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 new file mode 100644 index 00000000000000..9a95ae23067e60 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorRewriteDriverTest.java @@ -0,0 +1,167 @@ +// 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.execute; + +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.procedure.ConnectorProcedureOps; +import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; +import org.apache.doris.connector.api.procedure.ConnectorRewriteGroup; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; +import org.apache.doris.connector.api.pushdown.ConnectorPredicate; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.qe.ConnectContext; + +import com.google.common.collect.ImmutableSet; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +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. + */ +public class ConnectorRewriteDriverTest { + + private ConnectorRewriteDriver driverWith(ConnectorProcedureOps procedureOps, ConnectorMetadata metadata) { + return driverWith(procedureOps, metadata, null); + } + + private ConnectorRewriteDriver driverWith(ConnectorProcedureOps procedureOps, ConnectorMetadata metadata, + ConnectorPredicate where) { + return new ConnectorRewriteDriver( + Mockito.mock(ConnectContext.class), + Mockito.mock(ExternalTable.class), + Mockito.mock(PluginDrivenExternalCatalog.class), + metadata, + procedureOps, + Mockito.mock(ConnectorSession.class), + Mockito.mock(ConnectorTableHandle.class), + "rewrite_data_files", + Collections.emptyMap(), + Collections.emptyList(), + where); + } + + @Test + public void emptyPlanReturnsZeroRowWithoutOpeningTransaction() 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 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()); + // 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 + // vacuous once the call site passes the resolved tableHandle). + Mockito.verify(metadata, Mockito.never()).beginTransaction(Mockito.any(), Mockito.any()); + } + + @Test + public void whereConditionIsThreadedToPlanRewrite() throws Exception { + // The lowered WHERE must reach the connector's planRewrite as the 5th argument (the file-scope filter), + // not be dropped to null. MUTATION: passing null instead of whereCondition is killed here. + ConnectorProcedureOps procedureOps = Mockito.mock(ConnectorProcedureOps.class); + 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"))); + + driverWith(procedureOps, Mockito.mock(ConnectorMetadata.class), where).run(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ConnectorPredicate.class); + Mockito.verify(procedureOps).planRewrite(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + captor.capture(), Mockito.any()); + Assertions.assertSame(where, captor.getValue(), "the driver must pass the lowered WHERE through verbatim"); + } + + @Test + public void planRewriteFailureSurfacesAsUserException() { + ConnectorProcedureOps procedureOps = Mockito.mock(ConnectorProcedureOps.class); + Mockito.when(procedureOps.planRewrite(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any())).thenThrow(new DorisConnectorException("plan boom")); + + ConnectorRewriteDriver driver = driverWith(procedureOps, Mockito.mock(ConnectorMetadata.class)); + UserException ex = Assertions.assertThrows(UserException.class, driver::run); + Assertions.assertTrue(ex.getMessage().contains("plan boom"), + "the connector failure text must be preserved, got: " + ex.getMessage()); + } + + @Test + public void unionSourceFilePathsMergesAllGroupsAndDedupsByPath() { + // STEP 3 registers the UNION of every group's source files in ONE connector call (one planFiles() scan) + // instead of one call per group. Disjoint groups union straight; a path recurring across groups collapses + // to a single entry, so the connector never double-registers a file to delete. MUTATION: unioning only + // the first group (or not deduping) is killed here. + ConnectorRewriteGroup g1 = new ConnectorRewriteGroup( + ImmutableSet.of("s3://b/t/a.parquet", "s3://b/t/b.parquet"), 2, 2048L, 0); + ConnectorRewriteGroup g2 = new ConnectorRewriteGroup( + ImmutableSet.of("s3://b/t/c.parquet"), 1, 1024L, 0); + // Defensive: a path shared with g1 (bin-packing keeps groups disjoint, but the union must still dedup). + ConnectorRewriteGroup g3 = new ConnectorRewriteGroup( + ImmutableSet.of("s3://b/t/a.parquet", "s3://b/t/d.parquet"), 2, 2048L, 0); + + Set union = ConnectorRewriteDriver.unionSourceFilePaths(Arrays.asList(g1, g2, g3)); + + Assertions.assertEquals( + ImmutableSet.of("s3://b/t/a.parquet", "s3://b/t/b.parquet", "s3://b/t/c.parquet", + "s3://b/t/d.parquet"), + union, "the union must contain each distinct source path exactly once across all groups"); + } + + @Test + public void unionSourceFilePathsSkipsEmptyGroupsAndEmptyPlan() { + // An empty group contributes nothing; an all-empty plan unions to the empty set (the connector treats + // that as a no-op registration — the same net state as the former loop making N early-returning calls). + ConnectorRewriteGroup withFiles = new ConnectorRewriteGroup( + ImmutableSet.of("s3://b/t/a.parquet"), 1, 1024L, 0); + ConnectorRewriteGroup empty = new ConnectorRewriteGroup(Collections.emptySet(), 0, 0L, 0); + + Assertions.assertEquals(ImmutableSet.of("s3://b/t/a.parquet"), + ConnectorRewriteDriver.unionSourceFilePaths(Arrays.asList(withFiles, empty)), + "an empty group must not affect the union"); + Assertions.assertTrue( + ConnectorRewriteDriver.unionSourceFilePaths(Collections.emptyList()).isEmpty(), + "an all-empty plan unions to the empty set"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfoEngineCatalogTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfoEngineCatalogTest.java new file mode 100644 index 00000000000000..37ab1c620cc63f --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfoEngineCatalogTest.java @@ -0,0 +1,264 @@ +// 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.catalog.Env; +import org.apache.doris.datasource.CatalogMgr; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.nereids.exceptions.AnalysisException; +import org.apache.doris.qe.ConnectContext; + +import com.google.common.collect.Lists; +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.MockedStatic; +import org.mockito.Mockito; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +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). + * + *

    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.

    + * + *

    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.

    + */ +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"; + + private MockedStatic mockedEnv; + private CatalogMgr catalogMgr; + + @BeforeEach + public void setUp() { + Env mockEnv = Mockito.mock(Env.class); + catalogMgr = Mockito.mock(CatalogMgr.class); + Mockito.when(mockEnv.getCatalogMgr()).thenReturn(catalogMgr); + mockedEnv = Mockito.mockStatic(Env.class); + mockedEnv.when(Env::getCurrentEnv).thenReturn(mockEnv); + } + + @AfterEach + public void tearDown() { + if (mockedEnv != null) { + mockedEnv.close(); + } + } + + /** Registers a PluginDriven catalog of the given connector type under the given name. */ + private void registerPluginCatalog(String ctlName, String type) { + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.doReturn(type).when(catalog).getType(); + Mockito.when(catalogMgr.getCatalog(ctlName)).thenReturn(catalog); + } + + private static CreateTableInfo newInfo(String ctlName, String engineName) { + return new CreateTableInfo(false, false, false, 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"); + m.setAccessible(true); + try { + m.invoke(info); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + @Test + public void noEnginePaddedToMaxcomputeForPluginDriven() throws Throwable { + registerPluginCatalog("mc_ctl", "max_compute"); + CreateTableInfo info = newInfo("mc_ctl", null); + + invokePadding(info, "mc_ctl"); + + // 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"); + } + + @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. + } + + Assertions.assertEquals(ENGINE_MAXCOMPUTE, info.getEngineName(), + "CTAS into a PluginDriven max_compute catalog must pad engine=maxcompute via " + + "validateCreateTableAsSelect"); + } + + @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"); + } + + @Test + public void correctExplicitEnginePassesForPluginDriven() { + registerPluginCatalog("mc_ctl", "max_compute"); + CreateTableInfo info = newInfo("mc_ctl", ENGINE_MAXCOMPUTE); + + Assertions.assertDoesNotThrow(() -> invokeCheck(info), + "explicit ENGINE=maxcompute on a PluginDriven max_compute catalog must pass the check"); + } + + @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)"); + } + + // --------------------------------------------------------------------------------------------- + // 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"); + } + + @Test + public void ctasNoEnginePaddedToHiveForHms() { + registerPluginCatalog("hms_ctl", "hms"); + CreateTableInfo info = newInfo("hms_ctl", 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. + try { + info.validateCreateTableAsSelect(Lists.newArrayList("hms_ctl"), new ArrayList<>(), + Mockito.mock(ConnectContext.class)); + } catch (Exception ignored) { + // Only the engine-padding side effect is under test here. + } + + Assertions.assertEquals(ENGINE_HIVE, info.getEngineName(), + "CTAS into a PluginDriven hms catalog must pad engine=hive via validateCreateTableAsSelect"); + } + + @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"); + } + + @Test + public void correctExplicitEngineHivePassesForPluginDrivenHms() { + registerPluginCatalog("hms_ctl", "hms"); + CreateTableInfo info = newInfo("hms_ctl", ENGINE_HIVE); + + Assertions.assertDoesNotThrow(() -> invokeCheck(info), + "explicit ENGINE=hive on a PluginDriven hms catalog must pass the check"); + } + + // 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. +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfoTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfoTest.java index 72f1f62e228d05..718c20673ae557 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfoTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfoTest.java @@ -84,6 +84,13 @@ public void testCheckLegalityOfPartitionExprs() { "partition expression literal is illegal!"); } + // NOTE: the LIVE iceberg v3 reserved-row-lineage-column rejection moved off fe-core into the iceberg + // connector (IcebergConnectorMetadata.createTable); it is now covered by IcebergConnectorMetadataDdlTest + // (request-level + catalog table-default/override format-version precedence). CreateTableInfo's + // validateIcebergRowLineageColumns(int) is no longer on the live path (the engine gate was removed) and + // survives only for the legacy dead IcebergMetadataOps caller (deleted with it in the deletion phase), so + // the former fe-core unit tests that drove it directly were dropped. + @Test public void testCheckPartitionNullity1() { List columnDefs = new ArrayList<>(); diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergDeleteExecutorTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergDeleteExecutorTest.java deleted file mode 100644 index 8b2f186d1f2ac1..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergDeleteExecutorTest.java +++ /dev/null @@ -1,175 +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.insert; - -import org.apache.doris.analysis.DescriptorTable; -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.analysis.TupleId; -import org.apache.doris.catalog.DatabaseIf; -import org.apache.doris.datasource.CatalogProperty; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergTransaction; -import org.apache.doris.datasource.iceberg.source.IcebergScanNode; -import org.apache.doris.nereids.NereidsPlanner; -import org.apache.doris.planner.BaseExternalTableDataSink; -import org.apache.doris.planner.IcebergDeleteSink; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.thrift.TDataSink; -import org.apache.doris.thrift.TIcebergDeleteFileDesc; -import org.apache.doris.thrift.TUniqueId; -import org.apache.doris.transaction.TransactionManager; - -import org.apache.iceberg.DeleteFile; -import org.apache.iceberg.FileFormat; -import org.apache.iceberg.FileMetadata; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Schema; -import org.apache.iceberg.SortOrder; -import org.apache.iceberg.Table; -import org.apache.iceberg.TableProperties; -import org.apache.iceberg.types.Types; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; - -import java.lang.reflect.Field; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class IcebergDeleteExecutorTest { - - private static class TestIcebergScanNode extends IcebergScanNode { - TestIcebergScanNode() { - super(new PlanNodeId(0), new TupleDescriptor(new TupleId(0)), new SessionVariable(), ScanContext.EMPTY); - } - } - - @Test - public void testFinalizeSinkAndBeforeExecPropagateRewritableDeleteMetadata() throws Exception { - String referencedDataFile = "file:///tmp/data.parquet"; - DeleteFile deleteFile = FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) - .ofPositionDeletes() - .withPath("file:///tmp/delete.puffin") - .withFormat(FileFormat.PUFFIN) - .withFileSizeInBytes(128L) - .withRecordCount(3L) - .withReferencedDataFile(referencedDataFile) - .withContentOffset(32L) - .withContentSizeInBytes(64L) - .build(); - TIcebergDeleteFileDesc deleteFileDesc = new TIcebergDeleteFileDesc(); - deleteFileDesc.setPath("file:///tmp/delete.puffin"); - - TestIcebergScanNode scanNode = new TestIcebergScanNode(); - scanNode.deleteFilesByReferencedDataFile.put(referencedDataFile, Collections.singletonList(deleteFile)); - scanNode.deleteFilesDescByReferencedDataFile.put(referencedDataFile, Collections.singletonList(deleteFileDesc)); - - IcebergTransaction transaction = Mockito.mock(IcebergTransaction.class); - TransactionManager transactionManager = Mockito.mock(TransactionManager.class); - Mockito.when(transactionManager.getTransaction(10L)).thenReturn(transaction); - - IcebergExternalTable table = mockIcebergExternalTable(3, transactionManager); - NereidsPlanner planner = mockPlanner(Collections.singletonList(scanNode)); - ConnectContext ctx = new ConnectContext(); - ctx.setQueryId(new TUniqueId(1L, 2L)); - ctx.getSessionVariable().setEnableNereidsDistributePlanner(false); - ctx.setThreadLocalInfo(); - - IcebergDeleteExecutor executor = new IcebergDeleteExecutor(ctx, table, "label", planner, false, -1L); - IcebergDeleteSink sink = new IcebergDeleteSink(table, new org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext()); - - executor.finalizeSinkForDelete(null, sink, null); - TDataSink tDataSink = getTDataSink(sink); - Assertions.assertEquals(1, tDataSink.getIcebergDeleteSink().getRewritableDeleteFileSetsSize()); - Assertions.assertEquals(referencedDataFile, - tDataSink.getIcebergDeleteSink().getRewritableDeleteFileSets().get(0).getReferencedDataFilePath()); - - executor.txnId = 10L; - executor.beforeExec(); - - Mockito.verify(transaction).beginDelete(table); - ArgumentCaptor>> deleteFilesCaptor = ArgumentCaptor.forClass(Map.class); - Mockito.verify(transaction).setRewrittenDeleteFilesByReferencedDataFile(deleteFilesCaptor.capture()); - Assertions.assertSame(deleteFile, deleteFilesCaptor.getValue().get(referencedDataFile).get(0)); - Mockito.verify(transaction).clearConflictDetectionFilter(); - } - - private static NereidsPlanner mockPlanner(List scanNodes) { - NereidsPlanner planner = Mockito.mock(NereidsPlanner.class); - Mockito.when(planner.getFragments()).thenReturn(Collections.emptyList()); - Mockito.when(planner.getScanNodes()).thenReturn(scanNodes); - Mockito.when(planner.getDescTable()).thenReturn(new DescriptorTable()); - Mockito.when(planner.getRuntimeFilters()).thenReturn(Collections.emptyList()); - Mockito.when(planner.getTopnFilters()).thenReturn(Collections.emptyList()); - return planner; - } - - private static IcebergExternalTable mockIcebergExternalTable(int formatVersion, - TransactionManager transactionManager) { - Schema schema = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); - PartitionSpec spec = PartitionSpec.unpartitioned(); - Map properties = new HashMap<>(); - properties.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); - properties.put(TableProperties.DEFAULT_FILE_FORMAT, "parquet"); - properties.put(TableProperties.PARQUET_COMPRESSION, "snappy"); - properties.put(TableProperties.WRITE_DATA_LOCATION, "file:///tmp/iceberg_tbl/data"); - - Table icebergTable = Mockito.mock(Table.class); - Mockito.when(icebergTable.properties()).thenReturn(properties); - Mockito.when(icebergTable.spec()).thenReturn(spec); - Mockito.when(icebergTable.specs()).thenReturn(Collections.singletonMap(spec.specId(), spec)); - Mockito.when(icebergTable.location()).thenReturn("file:///tmp/iceberg_tbl"); - Mockito.when(icebergTable.schema()).thenReturn(schema); - Mockito.when(icebergTable.sortOrder()).thenReturn(SortOrder.unsorted()); - Mockito.when(icebergTable.name()).thenReturn("db.tbl"); - - CatalogProperty catalogProperty = Mockito.mock(CatalogProperty.class); - Mockito.when(catalogProperty.getMetastoreProperties()).thenReturn(null); - Mockito.when(catalogProperty.getStoragePropertiesMap()).thenReturn(Collections.emptyMap()); - - IcebergExternalCatalog catalog = Mockito.mock(IcebergExternalCatalog.class); - Mockito.when(catalog.getCatalogProperty()).thenReturn(catalogProperty); - Mockito.when(catalog.getTransactionManager()).thenReturn(transactionManager); - Mockito.when(catalog.getName()).thenReturn("iceberg"); - - DatabaseIf database = Mockito.mock(DatabaseIf.class); - Mockito.when(database.getId()).thenReturn(1L); - - IcebergExternalTable table = Mockito.mock(IcebergExternalTable.class); - Mockito.when(table.isView()).thenReturn(false); - Mockito.when(table.getCatalog()).thenReturn(catalog); - Mockito.when(table.getDatabase()).thenReturn(database); - Mockito.when(table.getDbName()).thenReturn("db"); - Mockito.when(table.getName()).thenReturn("tbl"); - Mockito.when(table.getIcebergTable()).thenReturn(icebergTable); - return table; - } - - private static TDataSink getTDataSink(BaseExternalTableDataSink sink) throws ReflectiveOperationException { - Field field = BaseExternalTableDataSink.class.getDeclaredField("tDataSink"); - field.setAccessible(true); - return (TDataSink) field.get(sink); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergMergeExecutorTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergMergeExecutorTest.java deleted file mode 100644 index 8015cf5feb3bb3..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergMergeExecutorTest.java +++ /dev/null @@ -1,178 +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.insert; - -import org.apache.doris.analysis.DescriptorTable; -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.analysis.TupleId; -import org.apache.doris.catalog.DatabaseIf; -import org.apache.doris.datasource.CatalogProperty; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergTransaction; -import org.apache.doris.datasource.iceberg.source.IcebergScanNode; -import org.apache.doris.nereids.NereidsPlanner; -import org.apache.doris.planner.BaseExternalTableDataSink; -import org.apache.doris.planner.IcebergMergeSink; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.qe.ConnectContext; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.thrift.TDataSink; -import org.apache.doris.thrift.TIcebergDeleteFileDesc; -import org.apache.doris.thrift.TUniqueId; -import org.apache.doris.transaction.TransactionManager; - -import org.apache.iceberg.DeleteFile; -import org.apache.iceberg.FileFormat; -import org.apache.iceberg.FileMetadata; -import org.apache.iceberg.Schema; -import org.apache.iceberg.SortOrder; -import org.apache.iceberg.Table; -import org.apache.iceberg.TableProperties; -import org.apache.iceberg.expressions.Expression; -import org.apache.iceberg.expressions.Expressions; -import org.apache.iceberg.types.Types; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; - -import java.lang.reflect.Field; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class IcebergMergeExecutorTest { - - private static class TestIcebergScanNode extends IcebergScanNode { - TestIcebergScanNode() { - super(new PlanNodeId(0), new TupleDescriptor(new TupleId(0)), new SessionVariable(), ScanContext.EMPTY); - } - } - - @Test - public void testFinalizeSinkAndBeforeExecPropagateRewritableDeleteMetadata() throws Exception { - String referencedDataFile = "file:///tmp/data.parquet"; - DeleteFile deleteFile = FileMetadata.deleteFileBuilder(org.apache.iceberg.PartitionSpec.unpartitioned()) - .ofPositionDeletes() - .withPath("file:///tmp/delete.puffin") - .withFormat(FileFormat.PUFFIN) - .withFileSizeInBytes(128L) - .withRecordCount(3L) - .withReferencedDataFile(referencedDataFile) - .withContentOffset(32L) - .withContentSizeInBytes(64L) - .build(); - TIcebergDeleteFileDesc deleteFileDesc = new TIcebergDeleteFileDesc(); - deleteFileDesc.setPath("file:///tmp/delete.puffin"); - - TestIcebergScanNode scanNode = new TestIcebergScanNode(); - scanNode.deleteFilesByReferencedDataFile.put(referencedDataFile, Collections.singletonList(deleteFile)); - scanNode.deleteFilesDescByReferencedDataFile.put(referencedDataFile, Collections.singletonList(deleteFileDesc)); - - IcebergTransaction transaction = Mockito.mock(IcebergTransaction.class); - TransactionManager transactionManager = Mockito.mock(TransactionManager.class); - Mockito.when(transactionManager.getTransaction(11L)).thenReturn(transaction); - - IcebergExternalTable table = mockIcebergExternalTable(3, transactionManager); - NereidsPlanner planner = mockPlanner(Collections.singletonList(scanNode)); - ConnectContext ctx = new ConnectContext(); - ctx.setQueryId(new TUniqueId(3L, 4L)); - ctx.getSessionVariable().setEnableNereidsDistributePlanner(false); - ctx.setThreadLocalInfo(); - - IcebergMergeExecutor executor = new IcebergMergeExecutor(ctx, table, "label", planner, false, -1L); - IcebergMergeSink sink = new IcebergMergeSink(table, new org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext()); - - executor.finalizeSinkForMerge(null, sink, null); - TDataSink tDataSink = getTDataSink(sink); - Assertions.assertEquals(1, tDataSink.getIcebergMergeSink().getRewritableDeleteFileSetsSize()); - Assertions.assertEquals(referencedDataFile, - tDataSink.getIcebergMergeSink().getRewritableDeleteFileSets().get(0).getReferencedDataFilePath()); - - Expression conflictFilter = Expressions.equal("id", 1); - executor.setConflictDetectionFilter(java.util.Optional.of(conflictFilter)); - executor.txnId = 11L; - executor.beforeExec(); - - Mockito.verify(transaction).beginMerge(table); - ArgumentCaptor>> deleteFilesCaptor = ArgumentCaptor.forClass(Map.class); - Mockito.verify(transaction).setRewrittenDeleteFilesByReferencedDataFile(deleteFilesCaptor.capture()); - Assertions.assertSame(deleteFile, deleteFilesCaptor.getValue().get(referencedDataFile).get(0)); - Mockito.verify(transaction).setConflictDetectionFilter(conflictFilter); - } - - private static NereidsPlanner mockPlanner(List scanNodes) { - NereidsPlanner planner = Mockito.mock(NereidsPlanner.class); - Mockito.when(planner.getFragments()).thenReturn(Collections.emptyList()); - Mockito.when(planner.getScanNodes()).thenReturn(scanNodes); - Mockito.when(planner.getDescTable()).thenReturn(new DescriptorTable()); - Mockito.when(planner.getRuntimeFilters()).thenReturn(Collections.emptyList()); - Mockito.when(planner.getTopnFilters()).thenReturn(Collections.emptyList()); - return planner; - } - - private static IcebergExternalTable mockIcebergExternalTable(int formatVersion, - TransactionManager transactionManager) { - Schema schema = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); - org.apache.iceberg.PartitionSpec spec = org.apache.iceberg.PartitionSpec.unpartitioned(); - Map properties = new HashMap<>(); - properties.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); - properties.put(TableProperties.DEFAULT_FILE_FORMAT, "parquet"); - properties.put(TableProperties.PARQUET_COMPRESSION, "snappy"); - properties.put(TableProperties.WRITE_DATA_LOCATION, "file:///tmp/iceberg_tbl/data"); - - Table icebergTable = Mockito.mock(Table.class); - Mockito.when(icebergTable.properties()).thenReturn(properties); - Mockito.when(icebergTable.spec()).thenReturn(spec); - Mockito.when(icebergTable.specs()).thenReturn(Collections.singletonMap(spec.specId(), spec)); - Mockito.when(icebergTable.location()).thenReturn("file:///tmp/iceberg_tbl"); - Mockito.when(icebergTable.schema()).thenReturn(schema); - Mockito.when(icebergTable.sortOrder()).thenReturn(SortOrder.unsorted()); - Mockito.when(icebergTable.name()).thenReturn("db.tbl"); - - CatalogProperty catalogProperty = Mockito.mock(CatalogProperty.class); - Mockito.when(catalogProperty.getMetastoreProperties()).thenReturn(null); - Mockito.when(catalogProperty.getStoragePropertiesMap()).thenReturn(Collections.emptyMap()); - - IcebergExternalCatalog catalog = Mockito.mock(IcebergExternalCatalog.class); - Mockito.when(catalog.getCatalogProperty()).thenReturn(catalogProperty); - Mockito.when(catalog.getTransactionManager()).thenReturn(transactionManager); - Mockito.when(catalog.getName()).thenReturn("iceberg"); - - DatabaseIf database = Mockito.mock(DatabaseIf.class); - Mockito.when(database.getId()).thenReturn(1L); - - IcebergExternalTable table = Mockito.mock(IcebergExternalTable.class); - Mockito.when(table.isView()).thenReturn(false); - Mockito.when(table.getCatalog()).thenReturn(catalog); - Mockito.when(table.getDatabase()).thenReturn(database); - Mockito.when(table.getDbName()).thenReturn("db"); - Mockito.when(table.getName()).thenReturn("tbl"); - Mockito.when(table.getIcebergTable()).thenReturn(icebergTable); - return table; - } - - private static TDataSink getTDataSink(BaseExternalTableDataSink sink) throws ReflectiveOperationException { - Field field = BaseExternalTableDataSink.class.getDeclaredField("tDataSink"); - field.setAccessible(true); - return (TDataSink) field.get(sink); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommandTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommandTest.java index 26a1fbf58e56a9..8a8ebc835b00e6 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommandTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommandTest.java @@ -18,7 +18,10 @@ package org.apache.doris.nereids.trees.plans.commands.insert; import org.apache.doris.catalog.OlapTable; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.common.jmockit.Deencapsulation; import org.apache.doris.datasource.doris.RemoteDorisExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.nereids.NereidsPlanner; import org.apache.doris.nereids.exceptions.AnalysisException; import org.apache.doris.nereids.trees.plans.PlanType; @@ -170,4 +173,45 @@ void testSelectInsertExecutorFactoryForRemoteTableWithGroupCommitException() { command.selectInsertExecutorFactory(planner, ctx, stmtExecutor, remoteDorisExternalTable); }, "remote olap table do not support group commit"); } + + /** + * A PluginDrivenExternalTable whose connector reports {@code supportsWriteBranch()==supported}, + * stubbing the catalog -> connector chain the @branch gate walks. + */ + private static PluginDrivenExternalTable pluginTableForWriteBranch(boolean supported) { + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + // The @branch gate now probes the per-handle capability via the table helper; stub it directly. + Mockito.when(table.connectorSupportsWriteBranch()).thenReturn(supported); + return table; + } + + @Test + void testConnectorSupportsWriteBranchForBranchCapablePluginDrivenTable() { + // INSERT INTO t@branch: post-cutover an iceberg table is plugin-driven (generic sink, not + // PhysicalIcebergTableSink), so the @branch guard admits it via the connector capability. Without + // this the branch is rejected post-flip even though the connector threads it. + // Mutation guard: dropping the production `&& !connectorSupportsWriteBranch(...)` arm or stubbing + // the wrong capability -> branch-capable iceberg wrongly rejected -> red. + boolean supported = Deencapsulation.invoke(InsertIntoTableCommand.class, + "connectorSupportsWriteBranch", pluginTableForWriteBranch(true)); + Assertions.assertTrue(supported, + "a branch-capable plugin-driven table (iceberg) must be admitted for INSERT @branch"); + } + + @Test + void testConnectorSupportsWriteBranchForNonBranchCapableAndNonPluginTables() { + // A plugin-driven table whose connector lacks branch support (jdbc) MUST be rejected (fail loud), + // not silently dropped onto the default ref; a non-plugin table short-circuits via the instanceof + // guard. Mutation guard: returning true in either case -> red. + // Assign to a boolean local first: passing the generic Deencapsulation.invoke result straight into + // assertFalse(..., String) would resolve to the BooleanSupplier overload (Boolean != BooleanSupplier). + boolean nonBranchCapable = Deencapsulation.invoke(InsertIntoTableCommand.class, + "connectorSupportsWriteBranch", pluginTableForWriteBranch(false)); + Assertions.assertFalse(nonBranchCapable, + "a plugin-driven table whose connector lacks write-branch support must be rejected"); + boolean nonPlugin = Deencapsulation.invoke(InsertIntoTableCommand.class, + "connectorSupportsWriteBranch", Mockito.mock(TableIf.class)); + Assertions.assertFalse(nonPlugin, + "a non-plugin table type must NOT be treated as write-branch capable"); + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteTableCommandTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteTableCommandTest.java new file mode 100644 index 00000000000000..cb68a0155c2218 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteTableCommandTest.java @@ -0,0 +1,147 @@ +// 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.insert; + +import org.apache.doris.catalog.TableIf; +import org.apache.doris.common.jmockit.Deencapsulation; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.EnumSet; +import java.util.Optional; +import java.util.Set; + +/** + * Tests for {@link InsertOverwriteTableCommand}'s {@code allowInsertOverwrite} type gate + * (FIX-OVERWRITE-GATE). + * + *

    Why this matters: after the MaxCompute SPI cutover, a MaxCompute table is a + * {@link PluginDrivenExternalTable} (TableType.PLUGIN_EXTERNAL_TABLE), no longer a + * {@code MaxComputeExternalTable}. The pre-fix gate only allow-listed + * OlapTable/RemoteDoris/HMS/Iceberg/MaxCompute, so {@code run()} rejected the whole command before the + * (already-wired) lower OVERWRITE machinery could run. The fix adds a {@code PluginDrivenExternalTable} + * arm, but gated on the connector's {@code supportsInsertOverwrite()} capability: all SPI + * connectors (jdbc/es/trino/max_compute) are {@code PluginDrivenExternalTable}, but only some honor + * overwrite. A bare {@code instanceof} would admit jdbc (which silently degrades OVERWRITE to a plain + * INSERT) — so the capability gate is the regression guard. These tests lock all three behaviors: + * overwrite-capable plugin table allowed, non-overwrite-capable plugin table rejected, and unsupported + * table types still rejected.

    + */ +public class InsertOverwriteTableCommandTest { + + private static InsertOverwriteTableCommand newCommand() { + // allowInsertOverwrite is field-independent; a minimal command (mock query plan) suffices. + return new InsertOverwriteTableCommand( + Mockito.mock(LogicalPlan.class), Optional.empty(), Optional.empty(), Optional.empty()); + } + + /** + * A PluginDrivenExternalTable whose connector reports {@code supportedWriteOperations()} containing + * (or omitting) {@code OVERWRITE}, stubbing the exact catalog -> connector chain the production gate walks. + */ + private static PluginDrivenExternalTable pluginTable(boolean supported) { + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + Set ops = supported ? EnumSet.of(WriteOperation.OVERWRITE) : EnumSet.noneOf(WriteOperation.class); + // The OVERWRITE gate now probes the per-handle write ops via the table helper; stub it directly. + Mockito.when(table.connectorSupportedWriteOperations()).thenReturn(ops); + return table; + } + + /** + * A PluginDrivenExternalTable whose connector reports {@code supportsWriteBranch()==supported}, + * stubbing the exact catalog -> connector chain the @branch gate walks. + */ + private static PluginDrivenExternalTable pluginTableForWriteBranch(boolean supported) { + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + // The @branch gate now probes the per-handle capability via the table helper; stub it directly. + Mockito.when(table.connectorSupportsWriteBranch()).thenReturn(supported); + return table; + } + + @Test + public void testAllowInsertOverwriteForOverwriteCapablePluginDrivenTable() { + // An overwrite-capable connector (e.g. MaxCompute) MUST pass the gate, otherwise INSERT + // OVERWRITE throws before reaching the connector sink machinery. + // Mutation guard: removing the production PluginDrivenExternalTable arm makes this fall + // through to false -> assertion red. + boolean allowed = Deencapsulation.invoke(newCommand(), "allowInsertOverwrite", pluginTable(true)); + Assertions.assertTrue(allowed, + "an overwrite-capable plugin-driven table (e.g. MaxCompute) must be allowed for INSERT OVERWRITE"); + } + + @Test + public void testDisallowInsertOverwriteForNonOverwriteCapablePluginDrivenTable() { + // A plugin-driven table whose connector does NOT support overwrite (e.g. jdbc) MUST be + // rejected at the gate (fail loud), NOT admitted to silently degrade OVERWRITE to a plain + // INSERT. This is the regression guard. + // Mutation guard: dropping the `&& supportsInsertOverwrite(...)` from the production gate + // makes this return true -> assertion red. + boolean allowed = Deencapsulation.invoke(newCommand(), "allowInsertOverwrite", pluginTable(false)); + Assertions.assertFalse(allowed, + "a plugin-driven table whose connector does not support overwrite must be rejected, not silently degraded"); + } + + @Test + public void testDisallowInsertOverwriteForUnsupportedTableType() { + // A table type in none of the allow-listed arms must still be rejected, proving the fix + // added a specific arm rather than loosening the gate to admit everything. + boolean allowed = Deencapsulation.invoke(newCommand(), "allowInsertOverwrite", + Mockito.mock(TableIf.class)); + Assertions.assertFalse(allowed, + "an unsupported table type must NOT be allowed for INSERT OVERWRITE"); + } + + @Test + public void testWriteBranchAllowedForBranchCapablePluginDrivenTable() { + // INSERT OVERWRITE t@branch: post-cutover an iceberg table is plugin-driven (generic sink, not + // PhysicalIcebergTableSink), so the @branch guard admits it via the connector capability. Without + // this, a branch overwrite is rejected post-flip even though the connector threads the branch. + // Mutation guard: dropping the production `&& !pluginConnectorSupportsWriteBranch(...)` arm makes + // this probe irrelevant; flipping it to false here would (in production) wrongly reject -> red. + boolean supported = Deencapsulation.invoke(newCommand(), + "pluginConnectorSupportsWriteBranch", pluginTableForWriteBranch(true)); + Assertions.assertTrue(supported, + "a branch-capable plugin-driven table (iceberg) must be admitted for INSERT OVERWRITE @branch"); + } + + @Test + public void testWriteBranchRejectedForNonBranchCapablePluginDrivenTable() { + // A plugin-driven table whose connector does NOT support branch writes (jdbc/maxcompute) MUST be + // rejected (fail loud), NOT admitted to silently drop the branch and overwrite the default ref. + // Mutation guard: dropping the `&& supportsWriteBranch()` chain -> returns true -> red. + boolean supported = Deencapsulation.invoke(newCommand(), + "pluginConnectorSupportsWriteBranch", pluginTableForWriteBranch(false)); + Assertions.assertFalse(supported, + "a plugin-driven table whose connector lacks write-branch support must be rejected"); + } + + @Test + public void testWriteBranchRejectedForNonPluginTableType() { + // A non-plugin table type must short-circuit to false (the helper's instanceof guard), proving the + // probe targets a specific arm rather than admitting every table. + boolean supported = Deencapsulation.invoke(newCommand(), + "pluginConnectorSupportsWriteBranch", Mockito.mock(TableIf.class)); + Assertions.assertFalse(supported, + "a non-plugin table type must NOT be treated as write-branch capable"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtilsTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtilsTest.java index 98bd8b1f5e92f0..0e770c9cfd5e74 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtilsTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtilsTest.java @@ -17,8 +17,15 @@ package org.apache.doris.nereids.trees.plans.commands.insert; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.nereids.exceptions.AnalysisException; +import org.apache.doris.nereids.trees.plans.logical.UnboundLogicalSink; + import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Optional; /** * Test for InsertUtils.getFinalErrorMsg() @@ -216,5 +223,40 @@ public void testUrlAndFirstErrorMsgSumTooLong_UseUrlPlaceholder() { Assertions.assertFalse(result.contains(url)); Assertions.assertTrue(result.length() <= MAX_TOTAL_BYTES); } + + /** + * normalizePlan must reject INSERT into a flipped plugin-driven view. Pre-flip the engine sink rejected + * it (e.g. IcebergTableSink threw on isView()); post-flip the neutral write path reaches a connector sink, + * so the guard lives in normalizePlan instead — mirroring the existing HMS-view guard. + */ + @Test + public void normalizePlanRejectsInsertIntoPluginView() { + PluginDrivenExternalTable view = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(view.isView()).thenReturn(true); + UnboundLogicalSink plan = Mockito.mock(UnboundLogicalSink.class); + + // MUTATION: dropping the guard, or negating the isView() check -> the write proceeds past the guard + // (no view rejection) -> this assertThrows finds no exception with the view message -> red. + AnalysisException e = Assertions.assertThrows(AnalysisException.class, + () -> InsertUtils.normalizePlan(plan, view, Optional.empty(), Optional.empty())); + Assertions.assertTrue(e.getMessage().contains("Write data to view is not supported"), e.getMessage()); + } + + @Test + public void normalizePlanDoesNotRejectNonViewPluginTableAtViewGuard() { + PluginDrivenExternalTable nonView = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(nonView.isView()).thenReturn(false); + UnboundLogicalSink plan = Mockito.mock(UnboundLogicalSink.class); + + // A non-view plugin table must pass the view guard (the write proceeds; it may fail later for + // unrelated reasons, but never with the VIEW rejection). MUTATION: making the guard ignore isView() + // (always reject any plugin table) -> the view message surfaces here -> red. + try { + InsertUtils.normalizePlan(plan, nonView, Optional.empty(), Optional.empty()); + } catch (Exception e) { + Assertions.assertFalse(String.valueOf(e.getMessage()).contains("Write data to view is not supported"), + "non-view plugin table must not be rejected by the view guard: " + e.getMessage()); + } + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutorTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutorTest.java index ea5318eedcb54f..6c8537c5d62bba 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutorTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutorTest.java @@ -27,7 +27,6 @@ import org.apache.doris.common.profile.Profile; import org.apache.doris.common.profile.SummaryProfile; import org.apache.doris.datasource.InternalCatalog; -import org.apache.doris.datasource.hive.HiveTransactionMgr; import org.apache.doris.job.manager.JobManager; import org.apache.doris.job.manager.StreamingTaskManager; import org.apache.doris.load.EtlJobType; @@ -261,13 +260,11 @@ private OlapInsertExecutor createExecutor(ConnectContext ctx) { private void prepareFactoryMocks(MockedStatic envFactoryMock, MockedStatic envMock, Coordinator coordinator, GlobalTransactionMgrIface txnMgr, TransactionState txnState, Env currentEnv) { EnvFactory envFactory = Mockito.mock(EnvFactory.class); - HiveTransactionMgr hiveTransactionMgr = Mockito.mock(HiveTransactionMgr.class); envFactoryMock.when(EnvFactory::getInstance).thenReturn(envFactory); Mockito.when(envFactory.createCoordinator(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.anyLong())) .thenReturn(coordinator); envMock.when(Env::getCurrentGlobalTransactionMgr).thenReturn(txnMgr); - envMock.when(Env::getCurrentHiveTransactionMgr).thenReturn(hiveTransactionMgr); envMock.when(Env::getCurrentEnv).thenReturn(currentEnv); Mockito.when(txnMgr.getTransactionState(Mockito.anyLong(), Mockito.anyLong())).thenReturn(txnState); } diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertExecutorTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertExecutorTest.java new file mode 100644 index 00000000000000..9cc5f9561a35f6 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertExecutorTest.java @@ -0,0 +1,307 @@ +// 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.insert; + +import org.apache.doris.catalog.Env; +import org.apache.doris.common.UserException; +import org.apache.doris.common.jmockit.Deencapsulation; +import org.apache.doris.connector.ConnectorSessionBuilder; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorWriteOps; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.connector.api.handle.ConnectorWriteHandle; +import org.apache.doris.connector.api.handle.NoOpConnectorTransaction; +import org.apache.doris.connector.api.write.ConnectorSinkPlan; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.planner.PluginDrivenTableSink; +import org.apache.doris.thrift.TDataSink; +import org.apache.doris.transaction.PluginDrivenTransactionManager; +import org.apache.doris.transaction.TransactionType; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.Optional; + +/** + * Ordering / routing tests for {@link PluginDrivenInsertExecutor}'s unified single-transaction + * write lifecycle (P6.3-T01). + * + *

    Every plugin-driven write now opens a {@link ConnectorTransaction} in + * {@code beginTransaction} (registered globally), then {@code finalizeSink} binds it onto the + * sink's session before {@code super.finalizeSink -> bindDataSink -> planWrite} + * runs — because plan-provider {@code planWrite} reads {@code session.getCurrentTransaction()}. + * Config-bag sinks (jdbc) carry no session, so the bind is skipped (no NPE). + * {@code transactionType} is sourced from the transaction's {@code profileLabel}, and + * {@code doBeforeCommit} backfills {@code loadedRows} from {@code getUpdateCnt()} only when the + * transaction reports a real count (>= 0), preserving the coordinator counter otherwise.

    + * + *

    The 7-arg executor constructor builds a {@code Coordinator} via + * {@code EnvFactory.createCoordinator}, which cannot be stood up in a unit test, so the + * instance is created without invoking the constructor (Objenesis, via Mockito's + * CALLS_REAL_METHODS) and the collaborator fields are injected directly; the real override + * bodies then run against hand-written connector doubles. Only construction uses Mockito — + * every assertion exercises real production code.

    + */ +public class PluginDrivenInsertExecutorTest { + + @Test + public void beginTransactionOpensConnectorTxnRegistersGloballyAndStampsTxnId() { + PluginDrivenInsertExecutor exec = newUnconstructedExecutor(); + StubConnectorTransaction connectorTx = new StubConnectorTransaction(70001L); + FakeTxnWriteOps writeOps = new FakeTxnWriteOps(connectorTx); + // Pre-seed the lazy setup so ensureConnectorSetup() short-circuits (no real catalog). + Deencapsulation.setField(exec, "connectorSession", ConnectorSessionBuilder.create().build()); + Deencapsulation.setField(exec, "writeOps", writeOps); + Deencapsulation.setField(exec, "transactionManager", new PluginDrivenTransactionManager()); + // Post-W4 beginTransaction resolves the write-target handle and threads it into the per-handle + // beginTransaction overload (a heterogeneous gateway opens its sibling's transaction for a foreign + // table). Seed a table whose handle resolves. + ConnectorTableHandle writeHandle = new ConnectorTableHandle() { }; + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(table.resolveWriteTargetHandle()).thenReturn(writeHandle); + Deencapsulation.setField(exec, "table", table); + + exec.beginTransaction(); + + try { + Assertions.assertSame(connectorTx, Deencapsulation.getField(exec, "connectorTx"), + "beginTransaction must open the connector transaction via writeOps"); + Assertions.assertEquals(70001L, exec.getTxnId(), + "the engine txn id must be the connector transaction's own id"); + Assertions.assertNotNull( + Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().getTxnById(70001L), + "the connector txn must be globally registered for the BE block-allocation / " + + "commit-data RPCs"); + Mockito.verify(table).resolveWriteTargetHandle(); + Assertions.assertSame(writeHandle, writeOps.handleSeenAtBegin, + "the executor must thread the RESOLVED write-target handle into the per-handle " + + "beginTransaction (a null 2nd arg would misroute a gateway write to the sibling)"); + } finally { + Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().removeTxnById(70001L); + } + } + + @Test + public void finalizeSinkBindsTransactionOntoSinkSessionBeforePlanWrite() { + PluginDrivenInsertExecutor exec = newUnconstructedExecutor(); + StubConnectorTransaction connectorTx = new StubConnectorTransaction(70010L); + Deencapsulation.setField(exec, "connectorTx", connectorTx); + Deencapsulation.setField(exec, "insertCtx", Optional.empty()); + + // The sink carries its own session (built at translate time); planWrite reads the txn off it. + ConnectorSession sinkSession = ConnectorSessionBuilder.create().build(); + RecordingWritePlanProvider provider = new RecordingWritePlanProvider(); + PluginDrivenTableSink sink = new PluginDrivenTableSink( + null, provider, sinkSession, new ConnectorTableHandle() { }, Collections.emptyList()); + + exec.finalizeSink(null, sink, null); + + Assertions.assertNotNull(provider.txnSeenAtPlanWrite, "planWrite must have been reached"); + Assertions.assertTrue(provider.txnSeenAtPlanWrite.isPresent(), + "the transaction must be bound onto the sink's session before planWrite runs, " + + "otherwise the plan-provider write plan fails loud"); + Assertions.assertSame(connectorTx, provider.txnSeenAtPlanWrite.get(), + "planWrite must observe exactly the transaction beginTransaction opened"); + } + + @Test + public void finalizeSinkSkipsBindForConfigBagSinkWithoutSession() throws Exception { + // jdbc rides the config-bag sink, which is built without a connector session + // (getConnectorSession() == null). After unification jdbc carries a non-null no-op + // transaction, so finalizeSink must guard the bind on a non-null session — otherwise + // setCurrentTransaction(...) NPEs. (Mutation: drop the null-guard -> this test NPEs.) + PluginDrivenInsertExecutor exec = newUnconstructedExecutor(); + Deencapsulation.setField(exec, "connectorTx", new NoOpConnectorTransaction(70060L, "JDBC")); + Deencapsulation.setField(exec, "insertCtx", Optional.empty()); + + // Mockito sink: getConnectorSession() returns null (config-bag), bindDataSink() is a no-op + // so super.finalizeSink survives without a real fragment. + PluginDrivenTableSink configBagSink = Mockito.mock(PluginDrivenTableSink.class); + + Assertions.assertDoesNotThrow(() -> exec.finalizeSink(null, configBagSink, null), + "binding must be skipped for a config-bag sink with no connector session (no NPE)"); + // The bind-guard skips setCurrentTransaction (null session) but execution must still flow + // into super.finalizeSink -> bindDataSink — proving the guard short-circuited only the + // bind, not the whole sink build. + Mockito.verify(configBagSink).bindDataSink(Mockito.any()); + } + + @Test + public void beforeExecIsNoOpUnderSingleTransactionModel() throws UserException { + // The write session is created by planWrite (in finalizeSink); beforeExec opens nothing. + // writeOps deliberately left null: a clean return proves beforeExec touches no write SPI. + PluginDrivenInsertExecutor exec = newUnconstructedExecutor(); + Deencapsulation.setField(exec, "connectorTx", new StubConnectorTransaction(70020L)); + + exec.beforeExec(); + } + + @Test + public void transactionTypeIsMappedFromConnectorProfileLabel() { + // The connector tags its transaction with a profile label; the executor maps it to the + // profiling TransactionType (jdbc -> JDBC, maxcompute -> MAXCOMPUTE, unknown -> UNKNOWN). + PluginDrivenInsertExecutor exec = newUnconstructedExecutor(); + + Deencapsulation.setField(exec, "connectorTx", new StubConnectorTransaction(70030L, 0L, "MAXCOMPUTE")); + Assertions.assertEquals(TransactionType.MAXCOMPUTE, exec.transactionType(), + "a transaction labelled MAXCOMPUTE must map to TransactionType.MAXCOMPUTE"); + + Deencapsulation.setField(exec, "connectorTx", new StubConnectorTransaction(70031L, 0L, "JDBC")); + Assertions.assertEquals(TransactionType.JDBC, exec.transactionType(), + "a transaction labelled JDBC must map to TransactionType.JDBC"); + + Deencapsulation.setField(exec, "connectorTx", new StubConnectorTransaction(70032L, 0L, "EXTERNAL")); + Assertions.assertEquals(TransactionType.UNKNOWN, exec.transactionType(), + "an unrecognized label must fall back to TransactionType.UNKNOWN"); + } + + @Test + public void transactionTypeIsUnknownWhenNoTransaction() { + // empty-insert skips beginTransaction; transactionType must not NPE on a null transaction. + PluginDrivenInsertExecutor exec = newUnconstructedExecutor(); + Assertions.assertEquals(TransactionType.UNKNOWN, exec.transactionType(), + "with no connector transaction (empty insert), transactionType is UNKNOWN"); + } + + @Test + public void doBeforeCommitBackfillsLoadedRowsWhenTransactionReportsCount() throws UserException { + // Transaction model with a real row count (e.g. maxcompute): BE reports rows only through + // the connector transaction's commit-data, so doBeforeCommit must backfill loadedRows from + // getUpdateCnt() -- otherwise affected-rows is reported as 0. + PluginDrivenInsertExecutor exec = newUnconstructedExecutor(); + Deencapsulation.setField(exec, "connectorTx", new StubConnectorTransaction(70040L, 42L)); + + exec.doBeforeCommit(); + + Long loadedRows = Deencapsulation.getField(exec, "loadedRows"); + Assertions.assertEquals(42L, loadedRows.longValue(), + "doBeforeCommit must set loadedRows = connectorTx.getUpdateCnt() when it is >= 0"); + } + + @Test + public void doBeforeCommitKeepsCoordinatorRowCountWhenTransactionReportsNoCount() throws UserException { + // A no-op transaction (jdbc) reports getUpdateCnt() == -1 ("no count from the transaction"). + // loadedRows was already set from the coordinator's DPP_NORMAL_ALL counter; doBeforeCommit + // must NOT clobber it with the sentinel -- otherwise affected-rows regresses to 0/-1. + // (Mutation: drop the `if (cnt >= 0)` guard -> loadedRows becomes -1 and this test fails.) + PluginDrivenInsertExecutor exec = newUnconstructedExecutor(); + Deencapsulation.setField(exec, "loadedRows", 7L); + Deencapsulation.setField(exec, "connectorTx", new NoOpConnectorTransaction(70050L, "JDBC")); + + exec.doBeforeCommit(); + + Long loadedRows = Deencapsulation.getField(exec, "loadedRows"); + Assertions.assertEquals(7L, loadedRows.longValue(), + "a -1 (no count) transaction must leave the coordinator-counted loadedRows untouched"); + } + + /** + * Creates a {@link PluginDrivenInsertExecutor} without running its constructor. See the class + * javadoc: the constructor builds a Coordinator that needs a live planner/EnvFactory. + */ + private static PluginDrivenInsertExecutor newUnconstructedExecutor() { + return Mockito.mock(PluginDrivenInsertExecutor.class, Mockito.CALLS_REAL_METHODS); + } + + /** Write ops that hand back a fixed connector transaction and record the handle the executor threads in. */ + private static final class FakeTxnWriteOps implements ConnectorWriteOps { + private final ConnectorTransaction txn; + private ConnectorTableHandle handleSeenAtBegin; + + private FakeTxnWriteOps(ConnectorTransaction txn) { + this.txn = txn; + } + + @Override + public ConnectorTransaction beginTransaction(ConnectorSession session) { + return txn; + } + + // The executor opens the txn through the per-handle overload; capture the handle it passes so the test + // can prove the RESOLVED write-target handle is threaded (not null / not a stale one). + @Override + public ConnectorTransaction beginTransaction(ConnectorSession session, ConnectorTableHandle handle) { + this.handleSeenAtBegin = handle; + return txn; + } + } + + /** Captures the transaction visible on the session at the moment planWrite is invoked. */ + private static final class RecordingWritePlanProvider implements ConnectorWritePlanProvider { + private Optional txnSeenAtPlanWrite; + + @Override + public ConnectorSinkPlan planWrite(ConnectorSession session, ConnectorWriteHandle handle) { + this.txnSeenAtPlanWrite = session.getCurrentTransaction(); + return new ConnectorSinkPlan(new TDataSink()); + } + } + + /** Minimal hand-written {@link ConnectorTransaction}: identity, row count, profile label. */ + private static final class StubConnectorTransaction implements ConnectorTransaction { + private final long txnId; + private final long updateCnt; + private final String profileLabel; + + private StubConnectorTransaction(long txnId) { + this(txnId, 0L, "MAXCOMPUTE"); + } + + private StubConnectorTransaction(long txnId, long updateCnt) { + this(txnId, updateCnt, "MAXCOMPUTE"); + } + + private StubConnectorTransaction(long txnId, long updateCnt, String profileLabel) { + this.txnId = txnId; + this.updateCnt = updateCnt; + this.profileLabel = profileLabel; + } + + @Override + public long getTransactionId() { + return txnId; + } + + @Override + public long getUpdateCnt() { + return updateCnt; + } + + @Override + public String profileLabel() { + return profileLabel; + } + + @Override + public void commit() { + } + + @Override + public void rollback() { + } + + @Override + public void close() { + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScanTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScanTest.java index ac0c76f80907b9..d49f3d97d3de7e 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScanTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScanTest.java @@ -17,14 +17,22 @@ package org.apache.doris.nereids.trees.plans.logical; +import org.apache.doris.analysis.TableScanParams; import org.apache.doris.catalog.Column; import org.apache.doris.catalog.Type; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergSysExternalTable; -import org.apache.doris.datasource.iceberg.IcebergUtils; +import org.apache.doris.datasource.CatalogIf; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.mvcc.MvccSnapshot; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.nereids.StatementContext; +import org.apache.doris.nereids.trees.expressions.Slot; import org.apache.doris.nereids.trees.plans.RelationId; import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan.SelectedPartitions; +import org.apache.doris.qe.ConnectContext; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -37,42 +45,27 @@ public class LogicalFileScanTest { - @Test - public void testNestedColumnPruningForIcebergSystemTables() { - IcebergSysExternalTable positionDeletes = Mockito.mock(IcebergSysExternalTable.class); - Mockito.when(positionDeletes.initSelectedPartitions(Mockito.any())) - .thenReturn(SelectedPartitions.NOT_PRUNED); - Mockito.when(positionDeletes.isPositionDeletesTable()).thenReturn(true); - LogicalFileScan positionDeletesScan = new LogicalFileScan(new RelationId(1), positionDeletes, - Collections.singletonList("db"), Collections.emptyList(), - Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); - Assertions.assertTrue(positionDeletesScan.supportPruneNestedColumn()); - - IcebergSysExternalTable jniSystemTable = Mockito.mock(IcebergSysExternalTable.class); - Mockito.when(jniSystemTable.initSelectedPartitions(Mockito.any())) - .thenReturn(SelectedPartitions.NOT_PRUNED); - Mockito.when(jniSystemTable.isPositionDeletesTable()).thenReturn(false); - LogicalFileScan jniSystemTableScan = new LogicalFileScan(new RelationId(2), jniSystemTable, - Collections.singletonList("db"), Collections.emptyList(), - Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); - Assertions.assertFalse(jniSystemTableScan.supportPruneNestedColumn()); - } - @Test public void testComputeOutputIncludesInvisibleRowLineageColumnsForIcebergTable() { - Column rowIdColumn = new Column(IcebergUtils.ICEBERG_ROW_ID_COL, Type.BIGINT, true); + // Post-cutover a native iceberg table is a PluginDrivenExternalTable, so computeOutput() flows through + // computePluginDrivenOutput() (row-lineage columns are surfaced by the connector via getFullSchema); the + // legacy exact-class IcebergExternalTable arm is gone. Assert the invisible v3 row-lineage columns still + // reach the plan output. + Column rowIdColumn = new Column("_row_id", Type.BIGINT, true); rowIdColumn.setIsVisible(false); Column lastUpdatedSequenceNumberColumn = - new Column(IcebergUtils.ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL, Type.BIGINT, true); + new Column("_last_updated_sequence_number", Type.BIGINT, true); lastUpdatedSequenceNumberColumn.setIsVisible(false); List schema = Arrays.asList( new Column("id", Type.INT, true), rowIdColumn, lastUpdatedSequenceNumberColumn); - IcebergExternalTable table = Mockito.mock(IcebergExternalTable.class); + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); Mockito.when(table.initSelectedPartitions(Mockito.any())).thenReturn(SelectedPartitions.NOT_PRUNED); - Mockito.when(table.getFullSchema()).thenReturn(schema); + // The snapshot-taking overload: computePluginDrivenOutput resolves THIS reference's version and + // asks for the schema AS OF it (empty here — this reference carries no selector). + Mockito.when(table.getFullSchema(Mockito.any())).thenReturn(schema); Mockito.when(table.getName()).thenReturn("iceberg_tbl"); LogicalFileScan scan = new LogicalFileScan(new RelationId(1), table, @@ -83,7 +76,88 @@ public void testComputeOutputIncludesInvisibleRowLineageColumnsForIcebergTable() .collect(Collectors.toList()); Assertions.assertEquals(Arrays.asList( "id", - IcebergUtils.ICEBERG_ROW_ID_COL, - IcebergUtils.ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL), outputNames); + "_row_id", + "_last_updated_sequence_number"), outputNames); + } + + @Test + public void computeOutputBindsThisReferencesOwnVersionNotLatest() { + // The tag_branch self-join shape: ONE table referenced at TWO tags, no bare reference. Each + // reference must bind the schema of the tag IT names. The version-BLIND lookup cannot tell the two + // references apart, gives up, and hands BOTH of them the LATEST schema — a schema NO reference + // asked for — which then makes the scan-time guard fire on a column the query never referenced. + List tagT1Schema = Collections.singletonList(new Column("c1", Type.INT, true)); + List latestSchema = Arrays.asList( + new Column("c1", Type.INT, true), + new Column("c2", Type.INT, true)); + + PluginDrivenMvccExternalTable table = Mockito.mock(PluginDrivenMvccExternalTable.class); + ExternalDatabase database = Mockito.mock(ExternalDatabase.class); + CatalogIf catalog = Mockito.mock(CatalogIf.class); + Mockito.when(table.getName()).thenReturn("tag_branch_table"); + Mockito.when(table.getDatabase()).thenReturn((ExternalDatabase) database); + Mockito.when(database.getFullName()).thenReturn("db"); + Mockito.when(database.getCatalog()).thenReturn((CatalogIf) catalog); + Mockito.when(catalog.getName()).thenReturn("ctl"); + Mockito.when(table.initSelectedPartitions(Mockito.any())).thenReturn(SelectedPartitions.NOT_PRUNED); + + TableScanParams tagT1 = new TableScanParams("tag", ImmutableMap.of(), ImmutableList.of("t1")); + TableScanParams tagT2 = new TableScanParams("tag", ImmutableMap.of(), ImmutableList.of("t2")); + MvccSnapshot pinT1 = Mockito.mock(MvccSnapshot.class); + MvccSnapshot pinT2 = Mockito.mock(MvccSnapshot.class); + Mockito.when(table.loadSnapshot(Optional.empty(), Optional.of(tagT1))).thenReturn(pinT1); + Mockito.when(table.loadSnapshot(Optional.empty(), Optional.of(tagT2))).thenReturn(pinT2); + Mockito.when(table.getFullSchema(Optional.of(pinT1))).thenReturn(tagT1Schema); + // What a version-blind resolution yields once two versions are pinned: no pin resolved -> latest. + Mockito.when(table.getFullSchema(Optional.empty())).thenReturn(latestSchema); + + ConnectContext ctx = new ConnectContext(); + StatementContext stmtCtx = new StatementContext(ctx, null); + ctx.setStatementContext(stmtCtx); + ctx.setThreadLocalInfo(); + try { + // Pin via loadSnapshots (not setSnapshot) so the version key is computed by the SAME function + // the lookup uses — the test must not hand-roll a key and accidentally agree with itself. + stmtCtx.loadSnapshots(table, Optional.empty(), Optional.of(tagT1)); + stmtCtx.loadSnapshots(table, Optional.empty(), Optional.of(tagT2)); + + LogicalFileScan scan = new LogicalFileScan(new RelationId(3), table, + Collections.singletonList("db"), Collections.emptyList(), + Optional.empty(), Optional.empty(), Optional.of(tagT1), Optional.empty()); + + List outputNames = scan.computeOutput().stream().map(Slot::getName) + .collect(Collectors.toList()); + // MUTATION: reverting computePluginDrivenOutput to the version-blind getFullSchema() makes this + // red with [c1, c2] — c2 being the phantom column that CI 996541's guard reported on. + Assertions.assertEquals(Collections.singletonList("c1"), outputNames, + "a @tag(t1) reference must bind t1's schema even though the statement also pins t2"); + } finally { + ConnectContext.remove(); + } + } + + @Test + public void supportPruneNestedColumnDelegatesToPluginCapability() { + // WHY (H-10 L1): a flipped plugin-driven table (e.g. iceberg as PluginDrivenMvccExternalTable) is no + // longer an IcebergExternalTable, so supportPruneNestedColumn must consult the connector capability via + // PluginDrivenExternalTable.supportsNestedColumnPrune() instead of the dead exact-class arm. MUTATION: + // reverting the plugin arm to a hard-coded `return false` -> the capable case below reds. + PluginDrivenExternalTable capable = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(capable.initSelectedPartitions(Mockito.any())).thenReturn(SelectedPartitions.NOT_PRUNED); + Mockito.when(capable.supportsNestedColumnPrune()).thenReturn(true); + LogicalFileScan capableScan = new LogicalFileScan(new RelationId(2), capable, + Collections.singletonList("db"), Collections.emptyList(), + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); + Assertions.assertTrue(capableScan.supportPruneNestedColumn(), + "a plugin table whose connector declares the capability must support nested-column prune"); + + PluginDrivenExternalTable incapable = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(incapable.initSelectedPartitions(Mockito.any())).thenReturn(SelectedPartitions.NOT_PRUNED); + Mockito.when(incapable.supportsNestedColumnPrune()).thenReturn(false); + LogicalFileScan incapableScan = new LogicalFileScan(new RelationId(3), incapable, + Collections.singletonList("db"), Collections.emptyList(), + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); + Assertions.assertFalse(incapableScan.supportPruneNestedColumn(), + "a plugin table whose connector does not declare the capability must not prune nested columns"); } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalConnectorTableSinkTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalConnectorTableSinkTest.java new file mode 100644 index 00000000000000..4e5e5347dd68f6 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalConnectorTableSinkTest.java @@ -0,0 +1,354 @@ +// 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.physical; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.PrimitiveType; +import org.apache.doris.common.jmockit.Deencapsulation; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.nereids.properties.DistributionSpecHiveTableSinkHashPartitioned; +import org.apache.doris.nereids.properties.MustLocalSortOrderSpec; +import org.apache.doris.nereids.properties.OrderKey; +import org.apache.doris.nereids.properties.PhysicalProperties; +import org.apache.doris.nereids.trees.expressions.Slot; +import org.apache.doris.nereids.trees.expressions.SlotReference; +import org.apache.doris.nereids.trees.plans.Plan; +import org.apache.doris.nereids.types.IntegerType; + +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.List; + +/** + * Tests for {@link PhysicalConnectorTableSink#getRequirePhysicalProperties()} (FIX-WRITE-DISTRIBUTION, + * NG-2 / NG-4; revised by FIX-BIND-STATIC-PARTITION, P0-3). After the MaxCompute SPI cutover the generic + * connector sink replaces legacy {@code PhysicalMaxComputeTableSink}; this pins that it reproduces the + * legacy 3-branch distribution, gated by connector capabilities: + * + *
      + *
    • dynamic-partition write (a partition column present in {@code cols}) + connector's write + * provider returning {@code true} from {@code requiresPartitionLocalSort()} → hash-by-partition + + * mandatory local sort, so the MaxCompute Storage API streaming partition writer does not hit + * "writer has been closed" on un-grouped rows;
    • + *
    • partitioned write + write provider returning {@code true} from + * {@code requiresPartitionHashWrite()} (Hive-style) → hash-by-partition with no local sort + * (byte-exact to legacy {@code PhysicalHiveTableSink});
    • + *
    • non-partition / all-static write + write provider returning {@code true} from + * {@code requiresParallelWrite()} → {@code SINK_RANDOM_PARTITIONED} (parallel writers, NG-4 + * parity);
    • + *
    • capability-less connector (jdbc/es-like) → {@code GATHER} (single writer).
    • + *
    + * + *

    Index by full schema, not {@code cols}: the bind layer projects the static/partial-static + * write's child to full-schema order (static partition columns filled), so the hash/sort keys are the + * child slots at the partition columns' full-schema positions. {@code cols} excludes the static + * partition columns, so a cols-position lookup would mislocate the dynamic column in the partial-static + * case — {@code partialStaticPartitionHashesByDynamicColumn} guards that.

    + */ +public class PhysicalConnectorTableSinkTest { + + private static final Column DATA = new Column("data", PrimitiveType.INT); + private static final Column PART = new Column("part", PrimitiveType.INT); + + /** + * Dynamic-partition write: the partition column 'part' is present in cols (its value comes from + * the query), so the sink must hash-distribute and locally sort by 'part'. cols == full schema + * here (no static partition), so full-schema and cols positions coincide. + */ + @Test + public void dynamicPartitionWriteRequiresHashAndLocalSort() { + SlotReference dataSlot = new SlotReference("data", IntegerType.INSTANCE); + SlotReference partSlot = new SlotReference("part", IntegerType.INSTANCE); + // cols == full schema == [data, part] (part is dynamic), child output aligned 1:1. + PhysicalConnectorTableSink sink = sink( + table(true, true, ImmutableList.of(PART), ImmutableList.of(DATA, PART)), + Arrays.asList(DATA, PART), + ImmutableList.of(dataSlot, partSlot)); + + PhysicalProperties props = sink.getRequirePhysicalProperties(); + + Assertions.assertTrue(props.getDistributionSpec() instanceof DistributionSpecHiveTableSinkHashPartitioned, + "dynamic-partition write must hash-distribute by partition columns"); + DistributionSpecHiveTableSinkHashPartitioned dist = + (DistributionSpecHiveTableSinkHashPartitioned) props.getDistributionSpec(); + // The hash key is the child slot at 'part's full-schema position (index 1). + Assertions.assertEquals(ImmutableList.of(partSlot.getExprId()), dist.getOutputColExprIds(), + "hash key must be the partition-column slot taken at its full-schema position"); + Assertions.assertTrue(props.getOrderSpec() instanceof MustLocalSortOrderSpec, + "dynamic-partition write must require a mandatory local sort to group partition rows"); + List orderKeys = props.getOrderSpec().getOrderKeys(); + Assertions.assertEquals(1, orderKeys.size(), "exactly one partition column to sort by"); + Assertions.assertEquals(partSlot, orderKeys.get(0).getExpr(), + "local sort must be on the partition column"); + } + + /** + * Pure-dynamic write with a REORDERED explicit column list ({@code INSERT INTO mc (part, data) + * SELECT vpart, vdata}, schema [data, part]): the bind layer projects the child to FULL-SCHEMA + * order regardless of the user column order, so child output = [dataSlot, partSlot] while cols = + * [part, data]. The partition column must be located by its full-schema position (1), not its cols + * position (0). Guards the FIX-BIND-STATIC-PARTITION indexing revision against the pure-dynamic + * reordered-list regression a cols-position lookup would cause (it would read child[0] = dataSlot). + */ + @Test + public void dynamicReorderedColumnListHashesByPartitionAtFullSchemaPosition() { + SlotReference dataSlot = new SlotReference("data", IntegerType.INSTANCE); + SlotReference partSlot = new SlotReference("part", IntegerType.INSTANCE); + PhysicalConnectorTableSink sink = sink( + table(true, true, ImmutableList.of(PART), ImmutableList.of(DATA, PART)), + Arrays.asList(PART, DATA), // cols reordered: part first + ImmutableList.of(dataSlot, partSlot)); // child in full-schema order [data, part] + + PhysicalProperties props = sink.getRequirePhysicalProperties(); + + Assertions.assertTrue(props.getDistributionSpec() instanceof DistributionSpecHiveTableSinkHashPartitioned, + "reordered-list dynamic write must still hash-distribute by the partition column"); + DistributionSpecHiveTableSinkHashPartitioned dist = + (DistributionSpecHiveTableSinkHashPartitioned) props.getDistributionSpec(); + // 'part' at full-schema index 1 -> child[1] = partSlot. A cols-position lookup ('part' at cols + // index 0) would read child[0] = dataSlot and shuffle by the wrong column. + Assertions.assertEquals(ImmutableList.of(partSlot.getExprId()), dist.getOutputColExprIds(), + "hash key must be the partition slot at its full-schema position, not its cols position"); + Assertions.assertEquals(partSlot, props.getOrderSpec().getOrderKeys().get(0).getExpr(), + "local sort must be on the partition column slot"); + } + + /** + * Partial-static write ({@code PARTITION(ds='x') SELECT id, val, region} — ds static, region + * dynamic): the bind layer projects the child to full schema with ds filled (NULL), so child + * output = [id, val, ds, region] while cols = [id, val, region] (ds excluded). The partition + * columns must be located by their FULL-SCHEMA positions (ds@2, region@3), not their cols + * positions — otherwise the dynamic 'region' would be mislocated and grouping would break, + * re-triggering "writer has been closed". This guards the FIX-BIND-STATIC-PARTITION revision of + * the indexing (a cols-position regression yields hash keys = [ds] only). + */ + @Test + public void partialStaticPartitionHashesByDynamicColumn() { + Column id = new Column("id", PrimitiveType.INT); + Column val = new Column("val", PrimitiveType.INT); + Column ds = new Column("ds", PrimitiveType.INT); + Column region = new Column("region", PrimitiveType.INT); + SlotReference idSlot = new SlotReference("id", IntegerType.INSTANCE); + SlotReference valSlot = new SlotReference("val", IntegerType.INSTANCE); + SlotReference dsSlot = new SlotReference("ds", IntegerType.INSTANCE); + SlotReference regionSlot = new SlotReference("region", IntegerType.INSTANCE); + + PhysicalConnectorTableSink sink = sink( + table(true, true, ImmutableList.of(ds, region), ImmutableList.of(id, val, ds, region)), + Arrays.asList(id, val, region), // cols excludes static ds + ImmutableList.of(idSlot, valSlot, dsSlot, regionSlot)); // child == full schema + + PhysicalProperties props = sink.getRequirePhysicalProperties(); + + Assertions.assertTrue(props.getDistributionSpec() instanceof DistributionSpecHiveTableSinkHashPartitioned, + "partial-static write must hash-distribute by partition columns"); + DistributionSpecHiveTableSinkHashPartitioned dist = + (DistributionSpecHiveTableSinkHashPartitioned) props.getDistributionSpec(); + // Both partition columns located by full-schema position: child[2]=dsSlot, child[3]=regionSlot. + // A cols-position regression (region at cols index 2) would read child[2]=dsSlot and drop + // regionSlot, yielding [dsSlot] — caught by this exact-list assertion. + Assertions.assertEquals(ImmutableList.of(dsSlot.getExprId(), regionSlot.getExprId()), + dist.getOutputColExprIds(), + "hash keys must be the partition-column slots at their full-schema positions"); + Assertions.assertTrue(props.getOrderSpec() instanceof MustLocalSortOrderSpec, + "partial-static write must require a mandatory local sort"); + List orderKeys = props.getOrderSpec().getOrderKeys(); + Assertions.assertEquals(2, orderKeys.size(), "sort by both partition columns in full-schema order"); + Assertions.assertEquals(dsSlot, orderKeys.get(0).getExpr()); + Assertions.assertEquals(regionSlot, orderKeys.get(1).getExpr()); + } + + /** + * All-static-partition write: every partition column is statically specified and therefore absent + * from cols, so no grouping/sort is needed — parallel writers (RANDOM), matching legacy branch-2. + * After FIX-BIND-STATIC-PARTITION the bind layer projects the no-column-list form's child to full + * schema ([data, part] with part filled), but the RANDOM branch never indexes the child, so the + * result is RANDOM regardless of the child shape. + */ + @Test + public void allStaticPartitionWriteUsesRandomPartitioned() { + SlotReference dataSlot = new SlotReference("data", IntegerType.INSTANCE); + SlotReference partSlot = new SlotReference("part", IntegerType.INSTANCE); + PhysicalConnectorTableSink sink = sink( + table(true, true, ImmutableList.of(PART), ImmutableList.of(DATA, PART)), + Arrays.asList(DATA), // cols excludes the static part + ImmutableList.of(dataSlot, partSlot)); // child == full schema (part filled) + + Assertions.assertSame(PhysicalProperties.SINK_RANDOM_PARTITIONED, sink.getRequirePhysicalProperties(), + "an all-static-partition write needs no sort/shuffle and uses parallel writers"); + } + + /** + * Non-partitioned write with a parallel-write connector → parallel writers (RANDOM), the NG-4 + * parity case (the bug degraded this to GATHER). + */ + @Test + public void nonPartitionedWriteUsesRandomWhenParallel() { + SlotReference dataSlot = new SlotReference("data", IntegerType.INSTANCE); + PhysicalConnectorTableSink sink = sink( + table(true, true, ImmutableList.of(), ImmutableList.of(DATA)), + Arrays.asList(DATA), + ImmutableList.of(dataSlot)); + + Assertions.assertSame(PhysicalProperties.SINK_RANDOM_PARTITIONED, sink.getRequirePhysicalProperties(), + "a non-partitioned write on a parallel-write connector must use parallel writers, not GATHER"); + } + + /** + * Capability-less connector (jdbc/es-like): no parallel-write, no partition-sort → GATHER. Guards + * that the change did not broaden parallel/sort behavior to connectors that did not opt in. + */ + @Test + public void capabilityLessConnectorGathers() { + SlotReference dataSlot = new SlotReference("data", IntegerType.INSTANCE); + PhysicalConnectorTableSink sink = sink( + table(false, false, ImmutableList.of(), ImmutableList.of(DATA)), + Arrays.asList(DATA), + ImmutableList.of(dataSlot)); + + Assertions.assertSame(PhysicalProperties.GATHER, sink.getRequirePhysicalProperties(), + "a connector declaring neither capability must keep the single-writer GATHER default"); + } + + /** + * Rewrite (compaction) override: a {@code rewrite_data_files} INSERT-SELECT must gather to a single + * writer to control its output file count, even on a PARTITIONED table where an ordinary write would + * hash-distribute by the partition columns. The neutral {@code isRewrite} flag short-circuits to + * GATHER before the partition-shuffle arm. The table/cols/child are identical to + * {@link #dynamicPartitionWriteRequiresHashAndLocalSort} (which, with {@code isRewrite=false}, returns + * the hash-partitioned spec), so this isolates the override as the sole behavioral delta. Mutation + * lock: dropping the {@code if (isRewrite) return GATHER} guard makes this return + * {@link DistributionSpecHiveTableSinkHashPartitioned} and the assertion fails. + */ + @Test + public void rewriteModeGathersEvenOnPartitionedTable() { + SlotReference dataSlot = new SlotReference("data", IntegerType.INSTANCE); + SlotReference partSlot = new SlotReference("part", IntegerType.INSTANCE); + PhysicalConnectorTableSink sink = sinkRewrite( + table(true, true, ImmutableList.of(PART), ImmutableList.of(DATA, PART)), + Arrays.asList(DATA, PART), + ImmutableList.of(dataSlot, partSlot)); + + Assertions.assertSame(PhysicalProperties.GATHER, sink.getRequirePhysicalProperties(), + "a rewrite write must gather to a single writer even on a partitioned table, " + + "overriding the partition-shuffle distribution"); + } + + /** + * Hash-write connector (Hive-style, {@code requiresPartitionHashWrite=true} but + * {@code requiresPartitionLocalSort=false}): a partitioned write hash-distributes by the partition + * columns with NO mandatory local sort — byte-exact to legacy {@code PhysicalHiveTableSink}, which + * hashed by the partition columns and never attached an order spec (the hive file writer buffers a + * per-partition writer, so grouping the rows by a sort is unnecessary). The MaxCompute arm is skipped + * because {@code requirePartitionLocalSortOnWrite()} is false, so this reaches the new no-sort arm. + */ + @Test + public void partitionHashWriteHashesByPartitionWithoutLocalSort() { + SlotReference dataSlot = new SlotReference("data", IntegerType.INSTANCE); + SlotReference partSlot = new SlotReference("part", IntegerType.INSTANCE); + PhysicalConnectorTableSink sink = sink( + table(true, false, true, ImmutableList.of(PART), ImmutableList.of(DATA, PART)), + Arrays.asList(DATA, PART), + ImmutableList.of(dataSlot, partSlot)); + + PhysicalProperties props = sink.getRequirePhysicalProperties(); + + Assertions.assertTrue(props.getDistributionSpec() instanceof DistributionSpecHiveTableSinkHashPartitioned, + "a hash-write connector must hash-distribute a partitioned write by its partition columns"); + DistributionSpecHiveTableSinkHashPartitioned dist = + (DistributionSpecHiveTableSinkHashPartitioned) props.getDistributionSpec(); + Assertions.assertEquals(ImmutableList.of(partSlot.getExprId()), dist.getOutputColExprIds(), + "hash key must be the partition-column slot taken at its full-schema position"); + Assertions.assertFalse(props.getOrderSpec() instanceof MustLocalSortOrderSpec, + "a no-sort hash-write connector must NOT add a mandatory local sort (byte-exact to legacy " + + "PhysicalHiveTableSink, which hash-distributed without a sort) — else a hive write " + + "would pay an unnecessary sort the legacy path never had"); + } + + /** + * Non-partitioned write on a hash-write connector: the hash arm's {@code !partitionNames.isEmpty()} + * gate falls through to the parallel arm, matching legacy {@code PhysicalHiveTableSink}'s + * non-partitioned {@code SINK_RANDOM_PARTITIONED}. Guards that the hash arm never fires without + * partition columns (which would build an empty-key distribution). + */ + @Test + public void nonPartitionedHashWriteConnectorUsesRandomWhenParallel() { + SlotReference dataSlot = new SlotReference("data", IntegerType.INSTANCE); + PhysicalConnectorTableSink sink = sink( + table(true, false, true, ImmutableList.of(), ImmutableList.of(DATA)), + Arrays.asList(DATA), + ImmutableList.of(dataSlot)); + + Assertions.assertSame(PhysicalProperties.SINK_RANDOM_PARTITIONED, sink.getRequirePhysicalProperties(), + "a non-partitioned hash-write connector falls through to parallel writers, not the hash arm"); + } + + // ==================== helpers ==================== + + private static PluginDrivenExternalTable table(boolean parallelWrite, boolean requirePartitionSort, + List partitionColumns, List fullSchema) { + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(table.supportsParallelWrite()).thenReturn(parallelWrite); + Mockito.when(table.requirePartitionLocalSortOnWrite()).thenReturn(requirePartitionSort); + Mockito.when(table.getPartitionColumns()).thenReturn(partitionColumns); + Mockito.when(table.getFullSchema()).thenReturn(fullSchema); + return table; + } + + /** As {@link #table(boolean, boolean, List, List)} but also stubs the hash-write capability. */ + private static PluginDrivenExternalTable table(boolean parallelWrite, boolean requirePartitionSort, + boolean requirePartitionHash, List partitionColumns, List fullSchema) { + PluginDrivenExternalTable table = table(parallelWrite, requirePartitionSort, partitionColumns, fullSchema); + Mockito.when(table.requirePartitionHashOnWrite()).thenReturn(requirePartitionHash); + return table; + } + + /** + * Builds a {@link PhysicalConnectorTableSink} exercising only {@code getRequirePhysicalProperties()}. + * Uses CALLS_REAL_METHODS to skip the heavyweight ctor and injects the three fields the method + * reads ({@code targetTable}, {@code cols}, and the single child via the {@code children} field, so + * the real {@code child()} resolves to it). + */ + private static PhysicalConnectorTableSink sink(PluginDrivenExternalTable table, + List cols, List childOutput) { + Plan child = Mockito.mock(Plan.class); + Mockito.when(child.getOutput()).thenReturn(childOutput); + @SuppressWarnings("unchecked") + PhysicalConnectorTableSink sink = + Mockito.mock(PhysicalConnectorTableSink.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(sink, "targetTable", table); + Deencapsulation.setField(sink, "cols", cols); + Deencapsulation.setField(sink, "children", ImmutableList.of(child)); + return sink; + } + + /** + * Builds a {@link PhysicalConnectorTableSink} as {@link #sink} but in rewrite mode (the neutral + * {@code isRewrite} field set true), to exercise the rewrite GATHER override. + */ + private static PhysicalConnectorTableSink sinkRewrite(PluginDrivenExternalTable table, + List cols, List childOutput) { + PhysicalConnectorTableSink sink = sink(table, cols, childOutput); + Deencapsulation.setField(sink, "isRewrite", true); + return sink; + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSinkTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSinkTest.java new file mode 100644 index 00000000000000..9aa92915a96e13 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSinkTest.java @@ -0,0 +1,357 @@ +// 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.physical; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.PrimitiveType; +import org.apache.doris.common.jmockit.Deencapsulation; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.write.ConnectorWritePartitionField; +import org.apache.doris.connector.api.write.ConnectorWritePartitionSpec; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.nereids.properties.DistributionSpecHash; +import org.apache.doris.nereids.properties.DistributionSpecMerge; +import org.apache.doris.nereids.properties.DistributionSpecMerge.IcebergPartitionField; +import org.apache.doris.nereids.properties.PhysicalProperties; +import org.apache.doris.nereids.trees.expressions.ExprId; +import org.apache.doris.nereids.trees.expressions.Slot; +import org.apache.doris.nereids.trees.expressions.SlotReference; +import org.apache.doris.nereids.trees.plans.Plan; +import org.apache.doris.nereids.trees.plans.commands.merge.MergeOperation; +import org.apache.doris.nereids.trees.plans.physical.PhysicalIcebergMergeSink.InsertPartitionFieldResult; +import org.apache.doris.nereids.types.IntegerType; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.SessionVariable; + +import com.google.common.collect.ImmutableList; +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.Mockito; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; + +/** + * Tests for the dual-mode partition-field resolution added to {@link PhysicalIcebergMergeSink} for the + * iceberg SPI cutover (P6.6 C3b-core step 2). Pre-flip the merge-write distribution walks the native + * iceberg {@code PartitionSpec}; post-flip it must reproduce the byte-identical distribution + * from the connector's engine-neutral {@link ConnectorWritePartitionSpec}. + * + *

    The parity core is {@link PhysicalIcebergMergeSink#reconstructPartitionFields} — a pure function + * tested here directly (no mocks) to pin the three legacy parities that a silent divergence would break: + *

      + *
    • P1 hard-fail clear — an unresolvable source column (null name, or a name absent from the + * bound expr-id map) clears the accumulated fields and fails the whole spec, short-circuited before + * the {@link IcebergPartitionField} ctor's non-null expr-id requirement;
    • + *
    • P2 non-identity pre-pass — {@code hasNonIdentity} is computed over all fields + * from the transform string, independent of resolvability and of where the build loop short-circuits;
    • + *
    • spec-id carry — the spec id rides every partitioned outcome, null only when unpartitioned.
    • + *
    + * Two mocked-chain tests on {@link PhysicalIcebergMergeSink#getRequirePhysicalProperties()} pin the + * dual-mode dispatch (a {@link PluginDrivenExternalTable} routes to the connector branch and its spec + * flows into the {@link DistributionSpecMerge}) and the {@code enableIcebergMergePartitioning} gate + * (off → no connector consultation).

    + */ +public class PhysicalIcebergMergeSinkTest { + + private ConnectContext connectContext; + + @BeforeEach + public void setUp() { + connectContext = new ConnectContext(); + connectContext.setSessionVariable(new SessionVariable()); + connectContext.setThreadLocalInfo(); + } + + @AfterEach + public void tearDown() { + ConnectContext.remove(); + } + + // ==================== pure reconstructPartitionFields parity tests ==================== + + @Test + public void reconstructResolvedIdentityFieldsSucceedAndCarryTuples() { + ExprId a = exprId("a"); + ExprId b = exprId("b"); + Map map = map("a", a, "b", b); + List out = new ArrayList<>(); + + InsertPartitionFieldResult result = PhysicalIcebergMergeSink.reconstructPartitionFields(out, + spec(9, field("identity", null, "a", "a", 1), field("identity", null, "b", "b", 2)), + map); + + Assertions.assertTrue(result.success, "all-resolvable spec must succeed"); + Assertions.assertFalse(result.hasNonIdentity, "two identity transforms => no non-identity"); + Assertions.assertEquals(Integer.valueOf(9), result.partitionSpecId, "spec id must be carried"); + Assertions.assertEquals( + ImmutableList.of(new IcebergPartitionField("identity", a, null, "a", 1), + new IcebergPartitionField("identity", b, null, "b", 2)), + out, + "fields must carry transform/exprId/param/name/sourceId verbatim and in order"); + } + + @Test + public void reconstructCarriesNonIdentityTransformAndParam() { + ExprId id = exprId("id"); + List out = new ArrayList<>(); + + InsertPartitionFieldResult result = PhysicalIcebergMergeSink.reconstructPartitionFields(out, + spec(3, field("bucket[16]", 16, "id", "id_bucket", 5)), map("id", id)); + + Assertions.assertTrue(result.success); + Assertions.assertTrue(result.hasNonIdentity, "a non-identity transform must set hasNonIdentity"); + Assertions.assertEquals(Integer.valueOf(3), result.partitionSpecId); + Assertions.assertEquals( + ImmutableList.of(new IcebergPartitionField("bucket[16]", id, 16, "id_bucket", 5)), out, + "transform param/name/sourceId must be carried verbatim from the connector field"); + } + + @Test + public void reconstructNullSourceColumnNameHardFailsAndClears() { + // PARITY-1a: a null source-column-name field hard-fails the whole spec; the already-added prior + // field is cleared (so the result is EMPTY, not a partial list) and no IcebergPartitionField is + // constructed with a null expr id (which would NPE). + ExprId a = exprId("a"); + List out = new ArrayList<>(); + + InsertPartitionFieldResult result = PhysicalIcebergMergeSink.reconstructPartitionFields(out, + spec(4, field("identity", null, "a", "a", 1), field("bucket[8]", 8, null, "x_bucket", 9)), + map("a", a)); + + Assertions.assertFalse(result.success, "an unresolvable (null-name) field must fail the spec"); + Assertions.assertTrue(out.isEmpty(), "the prior resolved field must be cleared on hard fail"); + Assertions.assertTrue(result.hasNonIdentity, "bucket[8] keeps hasNonIdentity true through the fail"); + Assertions.assertEquals(Integer.valueOf(4), result.partitionSpecId, "spec id is carried even on fail"); + } + + @Test + public void reconstructUnresolvedExprIdHardFailsAndClears() { + // PARITY-1b: a source column name that is not in the bound expr-id map hard-fails and clears. + ExprId a = exprId("a"); + List out = new ArrayList<>(); + + InsertPartitionFieldResult result = PhysicalIcebergMergeSink.reconstructPartitionFields(out, + spec(7, field("identity", null, "a", "a", 1), field("identity", null, "ghost", "ghost", 2)), + map("a", a)); + + Assertions.assertFalse(result.success, "a name absent from the expr-id map must fail the spec"); + Assertions.assertTrue(out.isEmpty(), "the prior resolved field must be cleared on hard fail"); + Assertions.assertFalse(result.hasNonIdentity, "all identity transforms => no non-identity"); + Assertions.assertEquals(Integer.valueOf(7), result.partitionSpecId); + } + + @Test + public void reconstructNonIdentityPrePassSeesFieldsAfterHardFail() { + // PARITY-2 independence: the build loop short-circuits on field 0 (null name), but the + // hasNonIdentity pre-pass over ALL fields must still see the bucket[16] at field 1. A mutation + // computing hasNonIdentity inside the build loop would exit before field 1 and wrongly report false. + List out = new ArrayList<>(); + + InsertPartitionFieldResult result = PhysicalIcebergMergeSink.reconstructPartitionFields(out, + spec(2, field("identity", null, null, "a", 1), field("bucket[16]", 16, "b", "b_bucket", 2)), + map("b", exprId("b"))); + + Assertions.assertFalse(result.success); + Assertions.assertTrue(out.isEmpty()); + Assertions.assertTrue(result.hasNonIdentity, + "the non-identity pre-pass must scan all fields, not stop where the build loop hard-fails"); + Assertions.assertEquals(Integer.valueOf(2), result.partitionSpecId); + } + + @Test + public void reconstructNullSpecIsUnpartitioned() { + // A null connector spec == unpartitioned target (legacy spec().isPartitioned() gate). + List out = new ArrayList<>(); + + InsertPartitionFieldResult result = PhysicalIcebergMergeSink.reconstructPartitionFields(out, null, + map("a", exprId("a"))); + + Assertions.assertFalse(result.success); + Assertions.assertFalse(result.hasNonIdentity); + Assertions.assertNull(result.partitionSpecId, "unpartitioned target carries a null spec id"); + Assertions.assertTrue(out.isEmpty()); + } + + // ==================== getRequirePhysicalProperties dual-mode dispatch + gate ==================== + + @Test + public void postFlipMergeBuildsDistributionFromConnectorSpec() { + // A PluginDrivenExternalTable must route through the connector branch and its write partitioning + // must flow into the DistributionSpecMerge: one identity partition column 'id' resolved to the + // child's id slot, insertRandom=false, spec id carried. + Column id = new Column("id", PrimitiveType.INT); + SlotReference idSlot = new SlotReference("id", IntegerType.INSTANCE); + SlotReference opSlot = new SlotReference(MergeOperation.OPERATION_COLUMN, IntegerType.INSTANCE); + SlotReference rowidSlot = new SlotReference(Column.ICEBERG_ROWID_COL, IntegerType.INSTANCE); + + PhysicalIcebergMergeSink sink = pluginSink( + spec(11, field("identity", null, "id", "id", 1)), + ImmutableList.of(id), // partition columns + ImmutableList.of(id), // visible cols + ImmutableList.of(idSlot, opSlot, rowidSlot)); // child output + + PhysicalProperties props = sink.getRequirePhysicalProperties(); + + Assertions.assertTrue(props.getDistributionSpec() instanceof DistributionSpecMerge, + "a post-flip merge write must build a merge distribution from the connector spec"); + DistributionSpecMerge dist = (DistributionSpecMerge) props.getDistributionSpec(); + Assertions.assertFalse(dist.isInsertRandom(), "a resolvable connector partitioning must not be random"); + Assertions.assertEquals(Integer.valueOf(11), dist.getPartitionSpecId(), "connector spec id must be carried"); + Assertions.assertEquals( + ImmutableList.of(new IcebergPartitionField("identity", idSlot.getExprId(), null, "id", 1)), + dist.getInsertPartitionFields(), + "the connector partition field must be reconstructed against the bound id slot"); + Assertions.assertEquals(ImmutableList.of(idSlot.getExprId()), dist.getInsertPartitionExprIds(), + "the identity partition column must thread into the merge distribution insert-partition expr ids"); + } + + @Test + public void mergePartitioningGateOffDoesNotConsultConnector() { + // PARITY-3 (sink gate): with enableIcebergMergePartitioning off, the row-id hash path is taken + // and the connector is never consulted (no getCatalog() -> getWritePartitioning()). + connectContext.getSessionVariable().enableIcebergMergePartitioning = false; + Column id = new Column("id", PrimitiveType.INT); + SlotReference idSlot = new SlotReference("id", IntegerType.INSTANCE); + SlotReference opSlot = new SlotReference(MergeOperation.OPERATION_COLUMN, IntegerType.INSTANCE); + SlotReference rowidSlot = new SlotReference(Column.ICEBERG_ROWID_COL, IntegerType.INSTANCE); + PluginDrivenExternalTable table = pluginTable(spec(11, field("identity", null, "id", "id", 1)), + ImmutableList.of(id), true); + PhysicalIcebergMergeSink sink = sinkWith(table, ImmutableList.of(id), + ImmutableList.of(idSlot, opSlot, rowidSlot)); + + PhysicalProperties props = sink.getRequirePhysicalProperties(); + + Assertions.assertTrue(props.getDistributionSpec() instanceof DistributionSpecHash, + "gate off with a row id present must hash by the row id, not merge-distribute"); + DistributionSpecHash hash = (DistributionSpecHash) props.getDistributionSpec(); + Assertions.assertEquals(ImmutableList.of(rowidSlot.getExprId()), hash.getOrderedShuffledColumns(), + "the row-id hash path must shuffle by the row-id slot"); + Mockito.verify(table, Mockito.never()).getCatalog(); + } + + @Test + public void postFlipAbsentTableHandleDegradesToUnpartitioned() { + // getRequirePhysicalProperties runs in CBO distribution derivation and must NEVER throw. If the + // connector cannot resolve the target's write handle, the connector partitioning degrades to a random + // (unpartitioned) merge distribution rather than an error. The provider here WOULD return a valid spec, + // so this also pins that the absent-handle guard short-circuits before getWritePartitioning is trusted. + Column id = new Column("id", PrimitiveType.INT); + SlotReference idSlot = new SlotReference("id", IntegerType.INSTANCE); + SlotReference opSlot = new SlotReference(MergeOperation.OPERATION_COLUMN, IntegerType.INSTANCE); + SlotReference rowidSlot = new SlotReference(Column.ICEBERG_ROWID_COL, IntegerType.INSTANCE); + // No partition columns -> the insertPartitionExprIds path also yields nothing, so the connector spec is + // the only partitioning signal, and the absent handle must suppress it. + PluginDrivenExternalTable table = pluginTable(spec(11, field("identity", null, "id", "id", 1)), + ImmutableList.of(), false); + PhysicalIcebergMergeSink sink = sinkWith(table, ImmutableList.of(id), + ImmutableList.of(idSlot, opSlot, rowidSlot)); + + PhysicalProperties props = sink.getRequirePhysicalProperties(); + + Assertions.assertTrue(props.getDistributionSpec() instanceof DistributionSpecMerge, + "a merge write still produces a merge distribution"); + DistributionSpecMerge dist = (DistributionSpecMerge) props.getDistributionSpec(); + Assertions.assertTrue(dist.isInsertRandom(), + "an unresolvable connector write handle must degrade to a random (unpartitioned) distribution"); + Assertions.assertNull(dist.getPartitionSpecId(), "no partitioning resolved -> null spec id"); + Assertions.assertTrue(dist.getInsertPartitionFields().isEmpty(), "no partition fields when unresolved"); + } + + // ==================== helpers ==================== + + private static ExprId exprId(String name) { + return new SlotReference(name, IntegerType.INSTANCE).getExprId(); + } + + private static Map map(Object... kv) { + Map m = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (int i = 0; i < kv.length; i += 2) { + m.put((String) kv[i], (ExprId) kv[i + 1]); + } + return m; + } + + private static ConnectorWritePartitionField field(String transform, Integer param, String src, + String name, int sourceId) { + return new ConnectorWritePartitionField(transform, param, src, name, sourceId); + } + + private static ConnectorWritePartitionSpec spec(int specId, ConnectorWritePartitionField... fields) { + return new ConnectorWritePartitionSpec(specId, ImmutableList.copyOf(fields)); + } + + /** A plugin-driven table whose connector returns {@code writeSpec} from getWritePartitioning. */ + private static PluginDrivenExternalTable pluginTable(ConnectorWritePartitionSpec writeSpec, + List partitionColumns, boolean handlePresent) { + ConnectorWritePlanProvider provider = Mockito.mock(ConnectorWritePlanProvider.class); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getWritePlanProvider()).thenReturn(provider); + // Production selects the write provider per-handle; a plain mock does not run the interface default. + Mockito.when(connector.getWritePlanProvider(Mockito.any())).thenReturn(provider); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); + Mockito.when(metadata.getTableHandle(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(handlePresent ? Optional.of(handle) : Optional.empty()); + Mockito.when(provider.getWritePartitioning(Mockito.any(), Mockito.any())).thenReturn(writeSpec); + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(table.getCatalog()).thenReturn(catalog); + Mockito.when(table.getPartitionColumns(Mockito.any())).thenReturn(partitionColumns); + Mockito.when(table.getRemoteDbName()).thenReturn("db"); + Mockito.when(table.getRemoteName()).thenReturn("t"); + return table; + } + + private static PhysicalIcebergMergeSink pluginSink(ConnectorWritePartitionSpec writeSpec, + List partitionColumns, List cols, List childOutput) { + return sinkWith(pluginTable(writeSpec, partitionColumns, true), cols, childOutput); + } + + /** + * Builds a {@link PhysicalIcebergMergeSink} exercising only {@code getRequirePhysicalProperties()}. + * CALLS_REAL_METHODS skips the heavyweight ctor and injects the three read fields ({@code targetTable}, + * {@code cols}, and the single child via {@code children}), mirroring {@code PhysicalConnectorTableSinkTest}. + */ + private static PhysicalIcebergMergeSink sinkWith(PluginDrivenExternalTable table, + List cols, List childOutput) { + Plan child = Mockito.mock(Plan.class); + Mockito.when(child.getOutput()).thenReturn(childOutput); + @SuppressWarnings("unchecked") + PhysicalIcebergMergeSink sink = + Mockito.mock(PhysicalIcebergMergeSink.class, Mockito.CALLS_REAL_METHODS); + Deencapsulation.setField(sink, "targetTable", table); + Deencapsulation.setField(sink, "cols", cols); + Deencapsulation.setField(sink, "children", ImmutableList.of(child)); + return sink; + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/FederationBackendPolicyTest.java b/fe/fe-core/src/test/java/org/apache/doris/planner/FederationBackendPolicyTest.java index 9dac86f47b98cf..9c04b73d61ccff 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/planner/FederationBackendPolicyTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/planner/FederationBackendPolicyTest.java @@ -21,9 +21,9 @@ import org.apache.doris.common.Config; import org.apache.doris.common.UserException; import org.apache.doris.common.util.LocationPath; -import org.apache.doris.datasource.FederationBackendPolicy; -import org.apache.doris.datasource.FileSplit; -import org.apache.doris.datasource.NodeSelectionStrategy; +import org.apache.doris.datasource.scan.FederationBackendPolicy; +import org.apache.doris.datasource.scan.NodeSelectionStrategy; +import org.apache.doris.datasource.split.FileSplit; import org.apache.doris.resource.computegroup.ComputeGroupMgr; import org.apache.doris.spi.Split; import org.apache.doris.system.Backend; @@ -672,6 +672,22 @@ public void testSplitWeight() { Assert.assertEquals(50, fileSplit.getSplitWeight().getRawValue()); } + // Regression for the NPE in testGenerateRandomly: FileSplit is Lombok @Data, whose generated + // equals()/hashCode() invoke getSelfSplitWeight(). A split that never sets a size-based weight + // leaves selfSplitWeight null, so the getter must surface the "-1 = not provided" sentinel + // instead of unboxing null (which threw NPE during the multimap comparison). + @Test + public void testFileSplitEqualsHashCodeWithUnsetWeight() { + LocationPath path = LocationPath.of("s1"); + // Two distinct instances that share the same LocationPath are field-equal, so equals() + // proceeds past the identity short-circuit and exercises getSelfSplitWeight(). + FileSplit a = new FileSplit(path, 0, 1000, 1000, 0, null, Collections.emptyList()); + FileSplit b = new FileSplit(path, 0, 1000, 1000, 0, null, Collections.emptyList()); + Assert.assertEquals(-1L, a.getSelfSplitWeight()); + Assert.assertEquals(a, b); + Assert.assertEquals(a.hashCode(), b.hashCode()); + } + @Test public void testBiggerSplit() throws UserException { SystemInfoService service = new SystemInfoService(); diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/HiveTableSinkTest.java b/fe/fe-core/src/test/java/org/apache/doris/planner/HiveTableSinkTest.java deleted file mode 100644 index dfc0ff6a19a20e..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/planner/HiveTableSinkTest.java +++ /dev/null @@ -1,147 +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.planner; - -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.common.UserException; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalDatabase; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.ThriftHMSCachedClient; -import org.apache.doris.datasource.property.storage.StorageProperties; -import org.apache.doris.foundation.util.PathUtils; -import org.apache.doris.qe.ConnectContext; - -import org.apache.hadoop.hive.metastore.api.Partition; -import org.apache.hadoop.hive.metastore.api.SerDeInfo; -import org.apache.hadoop.hive.metastore.api.StorageDescriptor; -import org.apache.hadoop.hive.metastore.api.Table; -import org.junit.Assert; -import org.junit.Test; -import org.mockito.Mockito; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; -import java.util.stream.Collectors; - -public class HiveTableSinkTest { - - @Test - public void testBindDataSink() throws UserException { - ConnectContext ctx = new ConnectContext(); - ctx.setThreadLocalInfo(); - - List partitions = new ArrayList() {{ - add(new Partition() {{ - setValues(new ArrayList() {{ - add("a"); - } - }); - setSd(new StorageDescriptor() {{ - setInputFormat("orc"); - } - }); - } - }); - } - }; - - Map storageProperties = new HashMap<>(); - storageProperties.put("oss.endpoint", "oss-cn-hangzhou.aliyuncs.com"); - storageProperties.put("oss.access_key", "access_key"); - storageProperties.put("oss.secret_key", "secret_key"); - storageProperties.put("s3.endpoint", "s3.north-1.amazonaws.com"); - storageProperties.put("s3.access_key", "access_key"); - storageProperties.put("s3.secret_key", "secret_key"); - storageProperties.put("cos.endpoint", "cos.cn-hangzhou.myqcloud.com"); - storageProperties.put("cos.access_key", "access_key"); - storageProperties.put("cos.secret_key", "secret_key"); - List storagePropertiesList = StorageProperties.createAll(storageProperties); - Map storagePropertiesMap = storagePropertiesList.stream() - .collect(Collectors.toMap(StorageProperties::getType, Function.identity())); - - ThriftHMSCachedClient mockClient = Mockito.mock(ThriftHMSCachedClient.class); - Mockito.when(mockClient.listPartitions(Mockito.anyString(), Mockito.anyString())).thenReturn(partitions); - - ArrayList locations = new ArrayList() {{ - add("oss://abc/def"); - add("s3://abc/def"); - add("s3a://abc/def"); - add("s3n://abc/def"); - add("cos://abc/def"); - } - }; - for (String location : locations) { - HMSExternalCatalog hmsExternalCatalog = Mockito.spy(new HMSExternalCatalog()); - hmsExternalCatalog.setInitializedForTest(true); - Mockito.doReturn(mockClient).when(hmsExternalCatalog).getClient(); - - HMSExternalDatabase db = new HMSExternalDatabase(hmsExternalCatalog, 10000, "hive_db1", "hive_db1"); - HMSExternalTable tbl = Mockito.spy(new HMSExternalTable(10001, "hive_tbl1", "hive_db1", - hmsExternalCatalog, db)); - Mockito.doReturn(storagePropertiesMap).when(tbl).getStoragePropertiesMap(); - mockDifferLocationTable(tbl, location); - - HiveTableSink hiveTableSink = new HiveTableSink(tbl); - hiveTableSink.bindDataSink(Optional.empty()); - Assert.assertTrue(PathUtils.equalsIgnoreSchemeIfOneIsS3(hiveTableSink.tDataSink.hive_table_sink.location.write_path, location)); - } - } - - private void mockDifferLocationTable(HMSExternalTable tbl, String location) { - Mockito.doReturn(false).when(tbl).isPartitionedTable(); - - Mockito.doReturn(new HashSet() {{ - add("a"); - add("b"); - } - }).when(tbl).getPartitionColumnNames(); - - Column a = new Column("a", PrimitiveType.INT); - Column b = new Column("b", PrimitiveType.INT); - Mockito.doReturn(new ArrayList() {{ - add(a); - add(b); - } - }).when(tbl).getColumns(); - - Table table = new Table(); - table.setSd(new StorageDescriptor() {{ - setInputFormat("orc"); - setBucketCols(new ArrayList<>()); - setNumBuckets(1); - setSerdeInfo(new SerDeInfo() {{ - setParameters(new HashMap<>()); - } - }); - setLocation(location); - } - }); - table.setParameters(new HashMap() {{ - put("orc.compress", "lzo"); - } - }); - Mockito.doReturn(table).when(tbl).getRemoteTable(); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/IcebergDeleteSinkTest.java b/fe/fe-core/src/test/java/org/apache/doris/planner/IcebergDeleteSinkTest.java deleted file mode 100644 index 7a9ed79725377e..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/planner/IcebergDeleteSinkTest.java +++ /dev/null @@ -1,116 +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.planner; - -import org.apache.doris.catalog.DatabaseIf; -import org.apache.doris.datasource.CatalogProperty; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext; -import org.apache.doris.thrift.TIcebergDeleteFileDesc; -import org.apache.doris.thrift.TIcebergDeleteSink; -import org.apache.doris.thrift.TIcebergRewritableDeleteFileSet; - -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Schema; -import org.apache.iceberg.SortOrder; -import org.apache.iceberg.Table; -import org.apache.iceberg.TableProperties; -import org.apache.iceberg.types.Types; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -public class IcebergDeleteSinkTest { - - @Test - public void testBindDataSinkIncludesRewritableDeleteFileSetsForV3() throws Exception { - IcebergDeleteSink sink = new IcebergDeleteSink(mockIcebergExternalTable(3), new DeleteCommandContext()); - sink.setRewritableDeleteFileSets(Collections.singletonList(buildDeleteFileSet())); - - sink.bindDataSink(Optional.empty()); - - TIcebergDeleteSink thriftSink = sink.tDataSink.getIcebergDeleteSink(); - Assertions.assertEquals(3, thriftSink.getFormatVersion()); - Assertions.assertEquals(1, thriftSink.getRewritableDeleteFileSetsSize()); - Assertions.assertEquals("file:///tmp/data.parquet", - thriftSink.getRewritableDeleteFileSets().get(0).getReferencedDataFilePath()); - } - - @Test - public void testBindDataSinkSkipsRewritableDeleteFileSetsForV2() throws Exception { - IcebergDeleteSink sink = new IcebergDeleteSink(mockIcebergExternalTable(2), new DeleteCommandContext()); - sink.setRewritableDeleteFileSets(Collections.singletonList(buildDeleteFileSet())); - - sink.bindDataSink(Optional.empty()); - - TIcebergDeleteSink thriftSink = sink.tDataSink.getIcebergDeleteSink(); - Assertions.assertEquals(2, thriftSink.getFormatVersion()); - Assertions.assertFalse(thriftSink.isSetRewritableDeleteFileSets()); - } - - private static TIcebergRewritableDeleteFileSet buildDeleteFileSet() { - TIcebergDeleteFileDesc deleteFileDesc = new TIcebergDeleteFileDesc(); - deleteFileDesc.setPath("file:///tmp/delete.puffin"); - TIcebergRewritableDeleteFileSet deleteFileSet = new TIcebergRewritableDeleteFileSet(); - deleteFileSet.setReferencedDataFilePath("file:///tmp/data.parquet"); - deleteFileSet.setDeleteFiles(Collections.singletonList(deleteFileDesc)); - return deleteFileSet; - } - - private static IcebergExternalTable mockIcebergExternalTable(int formatVersion) { - Schema schema = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); - PartitionSpec spec = PartitionSpec.unpartitioned(); - Map properties = new HashMap<>(); - properties.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); - properties.put(TableProperties.DEFAULT_FILE_FORMAT, "parquet"); - properties.put(TableProperties.PARQUET_COMPRESSION, "snappy"); - properties.put(TableProperties.WRITE_DATA_LOCATION, "file:///tmp/iceberg_tbl/data"); - - Table icebergTable = Mockito.mock(Table.class); - Mockito.when(icebergTable.properties()).thenReturn(properties); - Mockito.when(icebergTable.spec()).thenReturn(spec); - Mockito.when(icebergTable.specs()).thenReturn(Collections.singletonMap(spec.specId(), spec)); - Mockito.when(icebergTable.location()).thenReturn("file:///tmp/iceberg_tbl"); - Mockito.when(icebergTable.schema()).thenReturn(schema); - Mockito.when(icebergTable.sortOrder()).thenReturn(SortOrder.unsorted()); - Mockito.when(icebergTable.name()).thenReturn("db.tbl"); - - CatalogProperty catalogProperty = Mockito.mock(CatalogProperty.class); - Mockito.when(catalogProperty.getMetastoreProperties()).thenReturn(null); - Mockito.when(catalogProperty.getStoragePropertiesMap()).thenReturn(Collections.emptyMap()); - - IcebergExternalCatalog catalog = Mockito.mock(IcebergExternalCatalog.class); - Mockito.when(catalog.getCatalogProperty()).thenReturn(catalogProperty); - - DatabaseIf database = Mockito.mock(DatabaseIf.class); - IcebergExternalTable table = Mockito.mock(IcebergExternalTable.class); - Mockito.when(table.isView()).thenReturn(false); - Mockito.when(table.getCatalog()).thenReturn(catalog); - Mockito.when(table.getDatabase()).thenReturn(database); - Mockito.when(table.getDbName()).thenReturn("db"); - Mockito.when(table.getName()).thenReturn("tbl"); - Mockito.when(table.getIcebergTable()).thenReturn(icebergTable); - return table; - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/IcebergMergeSinkTest.java b/fe/fe-core/src/test/java/org/apache/doris/planner/IcebergMergeSinkTest.java deleted file mode 100644 index 23dcb4403ce731..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/planner/IcebergMergeSinkTest.java +++ /dev/null @@ -1,121 +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.planner; - -import org.apache.doris.catalog.DatabaseIf; -import org.apache.doris.datasource.CatalogProperty; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergUtils; -import org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext; -import org.apache.doris.thrift.TIcebergDeleteFileDesc; -import org.apache.doris.thrift.TIcebergMergeSink; -import org.apache.doris.thrift.TIcebergRewritableDeleteFileSet; - -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Schema; -import org.apache.iceberg.SortOrder; -import org.apache.iceberg.Table; -import org.apache.iceberg.TableProperties; -import org.apache.iceberg.types.Types; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -public class IcebergMergeSinkTest { - - @Test - public void testBindDataSinkIncludesRowLineageSchemaAndRewritableDeleteFileSetsForV3() throws Exception { - IcebergMergeSink sink = new IcebergMergeSink(mockIcebergExternalTable(3), new DeleteCommandContext()); - sink.setRewritableDeleteFileSets(Collections.singletonList(buildDeleteFileSet())); - - sink.bindDataSink(Optional.empty()); - - TIcebergMergeSink thriftSink = sink.tDataSink.getIcebergMergeSink(); - Assertions.assertEquals(3, thriftSink.getFormatVersion()); - Assertions.assertTrue(thriftSink.getSchemaJson().contains(IcebergUtils.ICEBERG_ROW_ID_COL)); - Assertions.assertTrue(thriftSink.getSchemaJson().contains( - IcebergUtils.ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL)); - Assertions.assertEquals(1, thriftSink.getRewritableDeleteFileSetsSize()); - } - - @Test - public void testBindDataSinkSkipsRewritableDeleteFileSetsAndRowLineageSchemaForV2() throws Exception { - IcebergMergeSink sink = new IcebergMergeSink(mockIcebergExternalTable(2), new DeleteCommandContext()); - sink.setRewritableDeleteFileSets(Collections.singletonList(buildDeleteFileSet())); - - sink.bindDataSink(Optional.empty()); - - TIcebergMergeSink thriftSink = sink.tDataSink.getIcebergMergeSink(); - Assertions.assertEquals(2, thriftSink.getFormatVersion()); - Assertions.assertFalse(thriftSink.isSetRewritableDeleteFileSets()); - Assertions.assertFalse(thriftSink.getSchemaJson().contains(IcebergUtils.ICEBERG_ROW_ID_COL)); - Assertions.assertFalse(thriftSink.getSchemaJson().contains( - IcebergUtils.ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL)); - } - - private static TIcebergRewritableDeleteFileSet buildDeleteFileSet() { - TIcebergDeleteFileDesc deleteFileDesc = new TIcebergDeleteFileDesc(); - deleteFileDesc.setPath("file:///tmp/delete.puffin"); - TIcebergRewritableDeleteFileSet deleteFileSet = new TIcebergRewritableDeleteFileSet(); - deleteFileSet.setReferencedDataFilePath("file:///tmp/data.parquet"); - deleteFileSet.setDeleteFiles(Collections.singletonList(deleteFileDesc)); - return deleteFileSet; - } - - private static IcebergExternalTable mockIcebergExternalTable(int formatVersion) { - Schema schema = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); - PartitionSpec spec = PartitionSpec.unpartitioned(); - Map properties = new HashMap<>(); - properties.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); - properties.put(TableProperties.DEFAULT_FILE_FORMAT, "parquet"); - properties.put(TableProperties.PARQUET_COMPRESSION, "snappy"); - properties.put(TableProperties.WRITE_DATA_LOCATION, "file:///tmp/iceberg_tbl/data"); - - Table icebergTable = Mockito.mock(Table.class); - Mockito.when(icebergTable.properties()).thenReturn(properties); - Mockito.when(icebergTable.spec()).thenReturn(spec); - Mockito.when(icebergTable.specs()).thenReturn(Collections.singletonMap(spec.specId(), spec)); - Mockito.when(icebergTable.location()).thenReturn("file:///tmp/iceberg_tbl"); - Mockito.when(icebergTable.schema()).thenReturn(schema); - Mockito.when(icebergTable.sortOrder()).thenReturn(SortOrder.unsorted()); - Mockito.when(icebergTable.name()).thenReturn("db.tbl"); - - CatalogProperty catalogProperty = Mockito.mock(CatalogProperty.class); - Mockito.when(catalogProperty.getMetastoreProperties()).thenReturn(null); - Mockito.when(catalogProperty.getStoragePropertiesMap()).thenReturn(Collections.emptyMap()); - - IcebergExternalCatalog catalog = Mockito.mock(IcebergExternalCatalog.class); - Mockito.when(catalog.getCatalogProperty()).thenReturn(catalogProperty); - - DatabaseIf database = Mockito.mock(DatabaseIf.class); - IcebergExternalTable table = Mockito.mock(IcebergExternalTable.class); - Mockito.when(table.isView()).thenReturn(false); - Mockito.when(table.getCatalog()).thenReturn(catalog); - Mockito.when(table.getDatabase()).thenReturn(database); - Mockito.when(table.getDbName()).thenReturn("db"); - Mockito.when(table.getName()).thenReturn("tbl"); - Mockito.when(table.getIcebergTable()).thenReturn(icebergTable); - return table; - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/ListPartitionPrunerV2Test.java b/fe/fe-core/src/test/java/org/apache/doris/planner/ListPartitionPrunerV2Test.java index e5c5c3497c08a5..17954bbf9ae1bc 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/planner/ListPartitionPrunerV2Test.java +++ b/fe/fe-core/src/test/java/org/apache/doris/planner/ListPartitionPrunerV2Test.java @@ -18,33 +18,21 @@ package org.apache.doris.planner; import org.apache.doris.analysis.PartitionValue; -import org.apache.doris.catalog.Env; import org.apache.doris.catalog.ListPartitionItem; import org.apache.doris.catalog.PartitionItem; import org.apache.doris.catalog.PartitionKey; import org.apache.doris.catalog.ScalarType; import org.apache.doris.catalog.Type; import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.ThreadPoolManager; -import org.apache.doris.datasource.CatalogMgr; -import org.apache.doris.datasource.NameMapping; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HiveExternalMetaCache; -import org.apache.doris.datasource.hive.HiveExternalMetaCache.PartitionValueCacheKey; -import org.apache.doris.datasource.hive.ThriftHMSCachedClient; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import org.junit.Assert; import org.junit.Test; -import org.mockito.MockedStatic; -import org.mockito.Mockito; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.ThreadPoolExecutor; public class ListPartitionPrunerV2Test { @Test @@ -69,79 +57,4 @@ public void testPartitionValuesMap() throws AnalysisException { Assert.assertEquals("1.123000", partitionValuesMap.get(1L).get(0)); Assert.assertEquals("1.123", partitionValuesMap.get(2L).get(0)); } - - @Test - public void testInvalidateTable() { - Env env = Mockito.mock(Env.class); - CatalogMgr catalogMgr = Mockito.mock(CatalogMgr.class); - HMSExternalCatalog hmsCatalog = Mockito.mock(HMSExternalCatalog.class); - long catalogId = 10001L; - - ThriftHMSCachedClient mockClient = Mockito.mock(ThriftHMSCachedClient.class); - // Mock is used here to represent the existence of a partition in the original table - Mockito.when(mockClient.listPartitionNames(Mockito.anyString(), Mockito.anyString())) - .thenReturn(new ArrayList<>(java.util.Collections.singletonList("c1=1.234000"))); - Mockito.when(hmsCatalog.getClient()).thenReturn(mockClient); - - try (MockedStatic mockedEnvStatic = Mockito.mockStatic(Env.class)) { - mockedEnvStatic.when(Env::getCurrentEnv).thenReturn(env); - Mockito.when(env.getCatalogMgr()).thenReturn(catalogMgr); - Mockito.doReturn(hmsCatalog).when(catalogMgr).getCatalog(catalogId); - - ThreadPoolExecutor executor = ThreadPoolManager.newDaemonFixedThreadPool( - 10, 10, "mgr", 120, false); - ThreadPoolExecutor listExecutor = ThreadPoolManager.newDaemonFixedThreadPool( - 10, 10, "mgr", 120, false); - HiveExternalMetaCache cache = new HiveExternalMetaCache(executor, listExecutor); - cache.initCatalog(catalogId, new HashMap<>()); - ArrayList types = new ArrayList<>(); - types.add(ScalarType.DOUBLE); - - // test cache - // the original partition of the table (in mock) will be loaded here - String dbName = "db"; - String tblName = "tb"; - NameMapping nameMapping = NameMapping.createForTest(catalogId, dbName, tblName); - PartitionValueCacheKey key = new PartitionValueCacheKey(nameMapping, types); - HiveExternalMetaCache.HivePartitionValues partitionValues = cache.getPartitionValues(key); - Assert.assertEquals(1, partitionValues.getNameToPartitionItem().size()); - List items = partitionValues.getNameToPartitionItem().values().iterator().next().getItems(); - Assert.assertEquals(1, items.size()); - PartitionKey partitionKey = items.get(0); - Assert.assertEquals("1.234", partitionKey.getKeys().get(0).toString()); - Assert.assertEquals("1.234000", partitionKey.getOriginHiveKeys().get(0)); - - // test add cache - ArrayList values = new ArrayList<>(); - values.add("c1=5.678000"); - cache.addPartitionsCache(nameMapping, values, types); - HiveExternalMetaCache.HivePartitionValues partitionValues2 = cache.getPartitionValues( - new PartitionValueCacheKey(nameMapping, types)); - Assert.assertEquals(2, partitionValues2.getNameToPartitionItem().size()); - PartitionKey partitionKey2 = null; - for (PartitionItem partitionItem : partitionValues2.getNameToPartitionItem().values()) { - List partitionKeys = partitionItem.getItems(); - Assert.assertEquals(1, partitionKeys.size()); - if ("5.678000".equals(partitionKeys.get(0).getOriginHiveKeys().get(0))) { - partitionKey2 = partitionKeys.get(0); - break; - } - } - Assert.assertNotNull(partitionKey2); - Assert.assertEquals("5.678", partitionKey2.getKeys().get(0).toString()); - Assert.assertEquals("5.678000", partitionKey2.getOriginHiveKeys().get(0)); - - // test refresh table - // simulates the manually added partition table being deleted, leaving only one original partition in mock - cache.invalidateTableCache(nameMapping); - HiveExternalMetaCache.HivePartitionValues partitionValues3 = cache.getPartitionValues( - new PartitionValueCacheKey(nameMapping, types)); - Assert.assertEquals(1, partitionValues3.getNameToPartitionItem().size()); - List items3 = partitionValues3.getNameToPartitionItem().values().iterator().next().getItems(); - Assert.assertEquals(1, items3.size()); - PartitionKey partitionKey3 = items3.get(0); - Assert.assertEquals("1.234", partitionKey3.getKeys().get(0).toString()); - Assert.assertEquals("1.234000", partitionKey3.getOriginHiveKeys().get(0)); - } - } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/PaimonPredicateConverterTest.java b/fe/fe-core/src/test/java/org/apache/doris/planner/PaimonPredicateConverterTest.java deleted file mode 100644 index fde1b6f74c244c..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/planner/PaimonPredicateConverterTest.java +++ /dev/null @@ -1,99 +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.planner; - -import org.apache.doris.analysis.Expr; -import org.apache.doris.common.FeConstants; -import org.apache.doris.datasource.paimon.source.PaimonPredicateConverter; -import org.apache.doris.qe.StmtExecutor; -import org.apache.doris.utframe.TestWithFeService; - -import com.google.common.collect.Lists; -import org.apache.paimon.predicate.CompoundPredicate; -import org.apache.paimon.predicate.LeafPredicate; -import org.apache.paimon.predicate.Or; -import org.apache.paimon.predicate.Predicate; -import org.apache.paimon.types.DataField; -import org.apache.paimon.types.IntType; -import org.apache.paimon.types.RowType; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.List; - -public class PaimonPredicateConverterTest extends TestWithFeService { - @Override - protected void runBeforeAll() throws Exception { - FeConstants.runningUnitTest = true; - // Create database `db1`. - createDatabase("db1"); - - String tbl1 = "create table db1.tbl1(" + "k1 int," + " k2 int," + " v1 int)" + " distributed by hash(k1)" - + " properties('replication_num' = '1');"; - createTables(tbl1); - } - - @Test - public void equal() throws Exception { - DataField paimonFieldK1 = new DataField(0, "k1", new IntType()); - DataField paimonFieldK2 = new DataField(1, "k2", new IntType()); - DataField paimonFieldV1 = new DataField(2, "v1", new IntType()); - RowType rowType = new RowType(Lists.newArrayList(paimonFieldK1, paimonFieldK2, paimonFieldV1)); - PaimonPredicateConverter converter = new PaimonPredicateConverter(rowType); - connectContext.getSessionVariable().setParallelResultSink(false); - - // k1=1 - String sql1 = "SELECT * from db1.tbl1 where k1 = 1"; - StmtExecutor stmtExecutor = new StmtExecutor(connectContext, sql1); - stmtExecutor.execute(); - Planner planner = stmtExecutor.planner(); - List fragments = planner.getFragments(); - List conjuncts = fragments.get(0).getPlanRoot().getChild(0).conjuncts; - List predicates = converter.convertToPaimonExpr(conjuncts); - Assertions.assertEquals(predicates.size(), 1); - Assertions.assertTrue(predicates.get(0) instanceof LeafPredicate); - LeafPredicate leafPredicate = (LeafPredicate) predicates.get(0); - Assertions.assertEquals(leafPredicate.fieldName(), "k1"); - - // k1=1 and k2=2 - sql1 = "SELECT * from db1.tbl1 where k1 = 1 and k2 = 2"; - stmtExecutor = new StmtExecutor(connectContext, sql1); - stmtExecutor.execute(); - planner = stmtExecutor.planner(); - fragments = planner.getFragments(); - conjuncts = fragments.get(0).getPlanRoot().getChild(0).conjuncts; - predicates = converter.convertToPaimonExpr(conjuncts); - Assertions.assertEquals(predicates.size(), 2); - - // k1 =1 or k2 = 2 - sql1 = "SELECT * from db1.tbl1 where k1 = 1 or k2 = 2"; - stmtExecutor = new StmtExecutor(connectContext, sql1); - stmtExecutor.execute(); - planner = stmtExecutor.planner(); - fragments = planner.getFragments(); - conjuncts = fragments.get(0).getPlanRoot().getChild(0).conjuncts; - predicates = converter.convertToPaimonExpr(conjuncts); - Assertions.assertEquals(predicates.size(), 1); - Assertions.assertTrue(predicates.get(0) instanceof CompoundPredicate); - CompoundPredicate predicate = (CompoundPredicate) predicates.get(0); - Assertions.assertTrue(predicate.function() instanceof Or); - Assertions.assertEquals(predicate.children().size(), 2); - Assertions.assertTrue(predicate.children().get(0) instanceof LeafPredicate); - Assertions.assertTrue(predicate.children().get(1) instanceof LeafPredicate); - } -} 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 new file mode 100644 index 00000000000000..897bfaf7b234ab --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/planner/PluginDrivenTableSinkBindingTest.java @@ -0,0 +1,109 @@ +// 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.planner; + +import org.apache.doris.common.AnalysisException; +import org.apache.doris.connector.ConnectorSessionBuilder; +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.write.ConnectorSinkPlan; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; +import org.apache.doris.nereids.trees.plans.commands.insert.PluginDrivenInsertCommandContext; +import org.apache.doris.thrift.TDataSink; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Binding-context consumption test (P4-T06a §4.2 / gaps G4+G5). + * + *

    After the cutover, INSERT OVERWRITE and INSERT ... PARTITION(col=val) against a + * plugin-driven MaxCompute table must keep working. The commands carry the overwrite + * flag and the static partition spec on a {@link PluginDrivenInsertCommandContext}; + * this test pins the consumption seam — that + * {@link PluginDrivenTableSink#bindDataSink} forwards both into the + * {@link ConnectorWriteHandle} handed to the connector's + * {@link ConnectorWritePlanProvider#planWrite}. If this regresses, INSERT OVERWRITE + * silently degrades to append and partition pinning is lost.

    + * + *

    (The production side — the command populating the context from the unbound sink — + * is covered by post-cutover manual smoke, per the T06a test-scope decision.)

    + */ +public class PluginDrivenTableSinkBindingTest { + + @Test + public void overwriteAndStaticPartitionFlowToWriteHandle() throws AnalysisException { + RecordingWritePlanProvider provider = new RecordingWritePlanProvider(); + PluginDrivenTableSink sink = newPlanProviderSink(provider); + + PluginDrivenInsertCommandContext ctx = new PluginDrivenInsertCommandContext(); + ctx.setOverwrite(true); + Map staticSpec = new HashMap<>(); + staticSpec.put("pt", "20240101"); + ctx.setStaticPartitionSpec(staticSpec); + + sink.bindDataSink(Optional.of(ctx)); + + ConnectorWriteHandle handle = provider.capturedHandle; + 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(), + "PARTITION(col=val) must propagate the static partition spec to the write handle"); + } + + @Test + public void absentContextDefaultsToNonOverwriteEmptySpec() throws AnalysisException { + RecordingWritePlanProvider provider = new RecordingWritePlanProvider(); + PluginDrivenTableSink sink = newPlanProviderSink(provider); + + sink.bindDataSink(Optional.empty()); + + ConnectorWriteHandle handle = provider.capturedHandle; + Assertions.assertNotNull(handle); + Assertions.assertFalse(handle.isOverwrite(), + "a plain INSERT must default the connector write handle to non-overwrite"); + Assertions.assertTrue(handle.getWriteContext().isEmpty(), + "a plain INSERT must pass an empty static partition spec"); + } + + private static PluginDrivenTableSink newPlanProviderSink(ConnectorWritePlanProvider provider) { + ConnectorSession session = ConnectorSessionBuilder.create().withCatalogName("mc_cat").build(); + ConnectorTableHandle tableHandle = new ConnectorTableHandle() { }; + // targetTable is unused on the plan-provider bind path; pass null to avoid building a + // full PluginDrivenExternalTable (which would require a catalog + database). + return new PluginDrivenTableSink(null, provider, session, tableHandle, Collections.emptyList()); + } + + /** Records the bound {@link ConnectorWriteHandle} that the sink hands to {@code planWrite}. */ + private static final class RecordingWritePlanProvider implements ConnectorWritePlanProvider { + private ConnectorWriteHandle capturedHandle; + + @Override + public ConnectorSinkPlan planWrite(ConnectorSession session, ConnectorWriteHandle handle) { + this.capturedHandle = handle; + return new ConnectorSinkPlan(new TDataSink()); + } + } +} 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 new file mode 100644 index 00000000000000..930006cdff8878 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/planner/PluginDrivenTableSinkTest.java @@ -0,0 +1,247 @@ +// 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.planner; + +import org.apache.doris.common.AnalysisException; +import org.apache.doris.connector.api.ConnectorColumn; +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; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.nereids.trees.plans.commands.insert.PluginDrivenInsertCommandContext; +import org.apache.doris.thrift.TDataSink; +import org.apache.doris.thrift.TDataSinkType; +import org.apache.doris.thrift.TExplainLevel; +import org.apache.doris.thrift.TSortInfo; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * Plan-provider mode tests for {@link PluginDrivenTableSink} (W-phase W5). + * + *

    The connector supplies a {@link ConnectorWritePlanProvider}; the sink + * delegates {@link PluginDrivenTableSink#bindDataSink} to + * {@link ConnectorWritePlanProvider#planWrite} and adopts the connector-built + * opaque {@link TDataSink} verbatim, passing a {@link ConnectorWriteHandle} that + * carries the bound target table handle and write columns. This is the single, + * source-agnostic write path: every write-capable connector (jdbc / maxcompute / + * iceberg) produces its own {@code T*TableSink} this way.

    + */ +public class PluginDrivenTableSinkTest { + + /** Hand-written {@link ConnectorWritePlanProvider} double recording the delegated call. */ + private static final class RecordingWritePlanProvider implements ConnectorWritePlanProvider { + private final ConnectorSinkPlan plan; + private ConnectorSession seenSession; + private ConnectorWriteHandle seenHandle; + private ConnectorWriteHandle seenExplainHandle; + + private RecordingWritePlanProvider(ConnectorSinkPlan plan) { + this.plan = plan; + } + + @Override + public ConnectorSinkPlan planWrite(ConnectorSession session, ConnectorWriteHandle handle) { + this.seenSession = session; + this.seenHandle = handle; + return plan; + } + + @Override + public void appendExplainInfo(StringBuilder output, String prefix, + ConnectorSession session, ConnectorWriteHandle handle) { + this.seenExplainHandle = handle; + } + } + + @Test + public void bindDataSinkDelegatesToWritePlanProvider() throws AnalysisException { + TDataSink expected = new TDataSink(TDataSinkType.MAXCOMPUTE_TABLE_SINK); + RecordingWritePlanProvider provider = + new RecordingWritePlanProvider(new ConnectorSinkPlan(expected)); + ConnectorTableHandle tableHandle = new ConnectorTableHandle() { }; + List columns = new ArrayList<>(); + + // targetTable is null: the plan-provider path never dereferences it (the connector + // resolves table facts from its own tableHandle), so a unit of the delegation needs + // no full PluginDrivenExternalTable. + PluginDrivenTableSink sink = + new PluginDrivenTableSink(null, provider, null, tableHandle, columns); + sink.bindDataSink(Optional.empty()); + + // The connector-built opaque sink is adopted verbatim. + Assert.assertSame(expected, sink.toThrift()); + // The bound facts reach the connector through the write handle. + Assert.assertNotNull(provider.seenHandle); + Assert.assertSame(tableHandle, provider.seenHandle.getTableHandle()); + Assert.assertSame(columns, provider.seenHandle.getColumns()); + Assert.assertFalse(provider.seenHandle.isOverwrite()); + Assert.assertTrue(provider.seenHandle.getWriteContext().isEmpty()); + // No engine-built write sort by default -> the handle carries no sort info. + Assert.assertNull(provider.seenHandle.getSortInfo()); + } + + @Test + public void bindDataSinkThreadsEngineBuiltWriteSortInfoToHandle() throws AnalysisException { + // WHY: the connector's planWrite cannot build a TSortInfo (the bound output exprs live only in the + // engine). For a connector that declares write-sort columns (iceberg WRITE ORDERED BY), the engine + // builds the TSortInfo and hands it to this sink, which must thread it onto the write handle so the + // connector can stamp it on its opaque sink. Without this, a sorted iceberg table writes unsorted. + TSortInfo engineBuilt = new TSortInfo(); + engineBuilt.setIsAscOrder(Collections.singletonList(true)); + engineBuilt.setNullsFirst(Collections.singletonList(true)); + RecordingWritePlanProvider provider = new RecordingWritePlanProvider( + new ConnectorSinkPlan(new TDataSink(TDataSinkType.ICEBERG_TABLE_SINK))); + + PluginDrivenTableSink sink = new PluginDrivenTableSink( + null, provider, null, new ConnectorTableHandle() { }, new ArrayList<>(), engineBuilt); + sink.bindDataSink(Optional.empty()); + + Assert.assertSame(engineBuilt, provider.seenHandle.getSortInfo()); + } + + @Test + public void bindDataSinkThreadsBranchNameToHandle() throws AnalysisException { + // WHY: INSERT INTO t@branch carries the target branch on the PluginDrivenInsertCommandContext; + // bindDataSink must thread it onto the write handle so a versioned-table connector (iceberg) + // points the commit at the branch. Without this, the branch is silently dropped and the write + // lands on the table's default ref. MUTATION: dropping `branchName = ctx.getBranchName()` -> + // handle carries Optional.empty() -> assertion red. + RecordingWritePlanProvider provider = new RecordingWritePlanProvider( + new ConnectorSinkPlan(new TDataSink(TDataSinkType.ICEBERG_TABLE_SINK))); + PluginDrivenInsertCommandContext ctx = new PluginDrivenInsertCommandContext(); + ctx.setBranchName(Optional.of("br_1")); + + PluginDrivenTableSink sink = new PluginDrivenTableSink( + null, provider, null, new ConnectorTableHandle() { }, new ArrayList<>()); + sink.bindDataSink(Optional.of(ctx)); + + Assert.assertEquals(Optional.of("br_1"), provider.seenHandle.getBranchName()); + } + + @Test + public void getExplainStringDelegatesConnectorWriteDetail() { + PluginDrivenExternalTable targetTable = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(targetTable.getName()).thenReturn("t1"); + ConnectorTableHandle tableHandle = new ConnectorTableHandle() { }; + + // A provider that contributes a connector-specific EXPLAIN line via the write hook. + ConnectorWritePlanProvider provider = new ConnectorWritePlanProvider() { + @Override + public ConnectorSinkPlan planWrite(ConnectorSession session, ConnectorWriteHandle handle) { + return new ConnectorSinkPlan(new TDataSink(TDataSinkType.JDBC_TABLE_SINK)); + } + + @Override + public void appendExplainInfo(StringBuilder output, String prefix, + ConnectorSession session, ConnectorWriteHandle handle) { + output.append(prefix).append(" INSERT SQL: SELECT 1\n"); + } + }; + + PluginDrivenTableSink sink = new PluginDrivenTableSink( + targetTable, provider, null, tableHandle, new ArrayList<>()); + + String explain = sink.getExplainString("", TExplainLevel.NORMAL); + Assert.assertTrue(explain, explain.contains("PLUGIN-DRIVEN TABLE SINK")); + Assert.assertTrue(explain, explain.contains("TABLE: t1")); + // The source-agnostic sink delegates connector-specific detail through appendExplainInfo. + Assert.assertTrue(explain, explain.contains("INSERT SQL: SELECT 1")); + + // BRIEF short-circuits before any connector detail. + String brief = sink.getExplainString("", TExplainLevel.BRIEF); + Assert.assertFalse(brief, brief.contains("INSERT SQL")); + } + + @Test + public void bindDataSinkDefaultsWriteOperationToInsert() throws AnalysisException { + // WHY: a plain INSERT sink (the 5-arg / 6-arg ctors) must keep WriteOperation.INSERT on the write + // handle so the connector's planWrite stays on the byte-identical INSERT path (TIcebergTableSink, + // promoted to OVERWRITE only via isOverwrite()). This pins the dormant-default that guarantees + // pre-flip parity for every existing write-capable connector (jdbc / maxcompute / iceberg INSERT). + RecordingWritePlanProvider provider = new RecordingWritePlanProvider( + new ConnectorSinkPlan(new TDataSink(TDataSinkType.MAXCOMPUTE_TABLE_SINK))); + PluginDrivenTableSink sink = new PluginDrivenTableSink( + null, provider, null, new ConnectorTableHandle() { }, new ArrayList<>()); + sink.bindDataSink(Optional.empty()); + + Assert.assertEquals(WriteOperation.INSERT, provider.seenHandle.getWriteOperation()); + } + + @Test + public void bindDataSinkThreadsMergeWriteOperationToHandle() throws AnalysisException { + // WHY: a post-flip MERGE INTO / UPDATE on an SPI iceberg table builds this sink with + // WriteOperation.MERGE; bindDataSink must thread it onto the write handle so the connector's + // planWrite dispatches to TIcebergMergeSink (RowDelta at commit) instead of the INSERT + // TIcebergTableSink. Without the handle's getWriteOperation() override, the op is silently lost + // and a MERGE would write as an append. MUTATION: thread INSERT here -> assertion red. + RecordingWritePlanProvider provider = new RecordingWritePlanProvider( + new ConnectorSinkPlan(new TDataSink(TDataSinkType.ICEBERG_MERGE_SINK))); + PluginDrivenTableSink sink = new PluginDrivenTableSink( + null, provider, null, new ConnectorTableHandle() { }, new ArrayList<>(), + null, WriteOperation.MERGE); + sink.bindDataSink(Optional.empty()); + + Assert.assertEquals(WriteOperation.MERGE, provider.seenHandle.getWriteOperation()); + } + + @Test + public void bindDataSinkThreadsDeleteWriteOperationToHandle() throws AnalysisException { + // WHY: a post-flip DELETE on an SPI iceberg table builds this sink with WriteOperation.DELETE; + // bindDataSink must thread it so planWrite dispatches to TIcebergDeleteSink. Same loss-of-op + // hazard as MERGE. MUTATION: thread INSERT here -> assertion red. + RecordingWritePlanProvider provider = new RecordingWritePlanProvider( + new ConnectorSinkPlan(new TDataSink(TDataSinkType.ICEBERG_DELETE_SINK))); + PluginDrivenTableSink sink = new PluginDrivenTableSink( + null, provider, null, new ConnectorTableHandle() { }, new ArrayList<>(), + null, WriteOperation.DELETE); + sink.bindDataSink(Optional.empty()); + + Assert.assertEquals(WriteOperation.DELETE, provider.seenHandle.getWriteOperation()); + } + + @Test + public void getExplainStringThreadsWriteOperationToHandle() { + // WHY: EXPLAIN of a post-flip MERGE/DELETE builds a (degraded) handle for appendExplainInfo; the + // operation is a plan-time fact available here, so it must be threaded too, otherwise a connector + // that surfaces op-specific EXPLAIN detail (e.g. "MERGE INTO ...") shows INSERT. MUTATION: thread + // INSERT at the getExplainString handle site -> assertion red. + PluginDrivenExternalTable targetTable = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(targetTable.getName()).thenReturn("t1"); + RecordingWritePlanProvider provider = new RecordingWritePlanProvider( + new ConnectorSinkPlan(new TDataSink(TDataSinkType.ICEBERG_MERGE_SINK))); + PluginDrivenTableSink sink = new PluginDrivenTableSink( + targetTable, provider, null, new ConnectorTableHandle() { }, new ArrayList<>(), + null, WriteOperation.MERGE); + + sink.getExplainString("", TExplainLevel.NORMAL); + + Assert.assertNotNull(provider.seenExplainHandle); + Assert.assertEquals(WriteOperation.MERGE, provider.seenExplainHandle.getWriteOperation()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/qe/HmsQueryCacheTest.java b/fe/fe-core/src/test/java/org/apache/doris/qe/HmsQueryCacheTest.java deleted file mode 100644 index 9c3274b00e8d29..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/qe/HmsQueryCacheTest.java +++ /dev/null @@ -1,335 +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.qe; - -import org.apache.doris.analysis.StatementBase; -import org.apache.doris.analysis.TupleDescriptor; -import org.apache.doris.analysis.TupleId; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.Env; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.catalog.TableIf; -import org.apache.doris.common.Config; -import org.apache.doris.common.FeConstants; -import org.apache.doris.common.jmockit.Deencapsulation; -import org.apache.doris.datasource.CatalogMgr; -import org.apache.doris.datasource.SchemaCacheKey; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalDatabase; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HMSExternalTable.DLAType; -import org.apache.doris.datasource.hive.HiveDlaTable; -import org.apache.doris.datasource.hive.source.HiveScanNode; -import org.apache.doris.datasource.systable.PartitionsSysTable; -import org.apache.doris.nereids.datasets.tpch.AnalyzeCheckTestBase; -import org.apache.doris.nereids.parser.NereidsParser; -import org.apache.doris.nereids.trees.plans.commands.CreateCatalogCommand; -import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; -import org.apache.doris.planner.OlapScanNode; -import org.apache.doris.planner.PlanNodeId; -import org.apache.doris.planner.ScanContext; -import org.apache.doris.planner.ScanNode; -import org.apache.doris.qe.cache.CacheAnalyzer; -import org.apache.doris.qe.cache.SqlCache; - -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import org.junit.Assert; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.Arrays; -import java.util.List; -import java.util.Optional; - -public class HmsQueryCacheTest extends AnalyzeCheckTestBase { - private static final String HMS_CATALOG = "hms_ctl"; - private static final long NOW = System.currentTimeMillis(); - private Env env; - private CatalogMgr mgr; - private OlapScanNode olapScanNode; - - private HMSExternalTable tbl; - private HMSExternalTable tbl2; - private HMSExternalTable view1; - private HMSExternalTable view2; - private HiveScanNode hiveScanNode1; - private HiveScanNode hiveScanNode2; - private HiveScanNode hiveScanNode3; - private HiveScanNode hiveScanNode4; - - @Override - protected void runBeforeAll() throws Exception { - FeConstants.runningUnitTest = true; - Config.enable_query_hive_views = true; - Config.cache_enable_sql_mode = true; - connectContext.getSessionVariable().setEnableSqlCache(true); - - env = Env.getCurrentEnv(); - connectContext.setEnv(env); - mgr = env.getCatalogMgr(); - - // create hms catalog - String createStmt = "create catalog hms_ctl " - + "properties(" - + "'type' = 'hms', " - + "'hive.metastore.uris' = 'thrift://192.168.0.1:9083');"; - NereidsParser nereidsParser = new NereidsParser(); - LogicalPlan logicalPlan = nereidsParser.parseSingle(createStmt); - if (logicalPlan instanceof CreateCatalogCommand) { - ((CreateCatalogCommand) logicalPlan).run(connectContext, null); - } - - // create inner db and tbl for test - mgr.getInternalCatalog().createDb("test", false, Maps.newHashMap()); - createTable("create table test.tbl1(\n" - + "k1 int comment 'test column k1', " - + "k2 int comment 'test column k2') comment 'test table1' " - + "distributed by hash(k1) buckets 1\n" - + "properties(\"replication_num\" = \"1\");"); - } - - private void setField(Object target, String fieldName, Object value) { - // try { - // Field field = target.getClass().getDeclaredField(fieldName); - // field.setAccessible(true); - // field.set(target, value); - // } catch (Exception e) { - // throw new RuntimeException(e); - // } - - Deencapsulation.setField(target, fieldName, value); - } - - private void init(HMSExternalCatalog hmsCatalog) { - // Create mock objects - tbl = Mockito.mock(HMSExternalTable.class); - tbl2 = Mockito.mock(HMSExternalTable.class); - view1 = Mockito.mock(HMSExternalTable.class); - view2 = Mockito.mock(HMSExternalTable.class); - hiveScanNode1 = Mockito.mock(HiveScanNode.class); - hiveScanNode2 = Mockito.mock(HiveScanNode.class); - hiveScanNode3 = Mockito.mock(HiveScanNode.class); - hiveScanNode4 = Mockito.mock(HiveScanNode.class); - - setField(hmsCatalog, "initialized", true); - setField(hmsCatalog, "objectCreated", true); - - List schema = Lists.newArrayList(); - schema.add(new Column("k1", PrimitiveType.INT)); - - HMSExternalDatabase db = new HMSExternalDatabase(hmsCatalog, 10000, "hms_db", "hms_db"); - setField(db, "initialized", true); - - setField(tbl, "objectCreated", true); - setField(tbl, "updateTime", NOW); - setField(tbl, "catalog", hmsCatalog); - setField(tbl, "dbName", "hms_db"); - setField(tbl, "name", "hms_tbl"); - setField(tbl, "dlaTable", new HiveDlaTable(tbl)); - setField(tbl, "dlaType", DLAType.HIVE); - - Mockito.when(tbl.getId()).thenReturn(10001L); - Mockito.when(tbl.getName()).thenReturn("hms_tbl"); - Mockito.when(tbl.getDbName()).thenReturn("hms_db"); - Mockito.when(tbl.getFullSchema()).thenReturn(schema); - Mockito.when(tbl.isSupportedHmsTable()).thenReturn(true); - Mockito.when(tbl.isView()).thenReturn(false); - Mockito.when(tbl.getType()).thenReturn(TableIf.TableType.HMS_EXTERNAL_TABLE); - Mockito.when(tbl.getDlaType()).thenReturn(DLAType.HIVE); - Mockito.when(tbl.getDatabase()).thenReturn(db); - Mockito.when(tbl.getUpdateTime()).thenReturn(NOW); - // mock initSchemaAndUpdateTime and do nothing - Mockito.when(tbl.initSchema(Mockito.any(SchemaCacheKey.class))) - .thenReturn(Optional.empty()); - - setField(tbl2, "objectCreated", true); - setField(tbl2, "updateTime", NOW); - setField(tbl2, "catalog", hmsCatalog); - setField(tbl2, "dbName", "hms_db"); - setField(tbl2, "name", "hms_tbl2"); - setField(tbl2, "dlaTable", new HiveDlaTable(tbl2)); - setField(tbl2, "dlaType", DLAType.HIVE); - - Mockito.when(tbl2.getId()).thenReturn(10004L); - Mockito.when(tbl2.getName()).thenReturn("hms_tbl2"); - Mockito.when(tbl2.getDbName()).thenReturn("hms_db"); - Mockito.when(tbl2.getFullSchema()).thenReturn(schema); - Mockito.when(tbl2.isSupportedHmsTable()).thenReturn(true); - Mockito.when(tbl2.isView()).thenReturn(false); - Mockito.when(tbl2.getType()).thenReturn(TableIf.TableType.HMS_EXTERNAL_TABLE); - Mockito.when(tbl2.getDlaType()).thenReturn(DLAType.HIVE); - Mockito.when(tbl2.getDatabase()).thenReturn(db); - Mockito.when(tbl2.getSupportedSysTables()).thenReturn(PartitionsSysTable.HIVE_SUPPORTED_SYS_TABLES); - Mockito.when(tbl2.getUpdateTime()).thenReturn(NOW); - Mockito.when(tbl2.getUpdateTime()).thenReturn(NOW); - // mock initSchemaAndUpdateTime and do nothing - Mockito.when(tbl2.initSchemaAndUpdateTime(Mockito.any(SchemaCacheKey.class))) - .thenReturn(Optional.empty()); - Mockito.doNothing().when(tbl2).setUpdateTime(Mockito.anyLong()); - - setField(view1, "objectCreated", true); - - Mockito.when(view1.getId()).thenReturn(10002L); - Mockito.when(view1.getName()).thenReturn("hms_view1"); - Mockito.when(view1.getDbName()).thenReturn("hms_db"); - Mockito.when(view1.isView()).thenReturn(true); - Mockito.when(view1.getCatalog()).thenReturn(hmsCatalog); - Mockito.when(view1.getType()).thenReturn(TableIf.TableType.HMS_EXTERNAL_TABLE); - Mockito.when(view1.getFullSchema()).thenReturn(schema); - Mockito.when(view1.getViewText()).thenReturn("SELECT * FROM hms_db.hms_tbl"); - Mockito.when(view1.isSupportedHmsTable()).thenReturn(true); - Mockito.when(view1.getDlaType()).thenReturn(DLAType.HIVE); - Mockito.when(view1.getUpdateTime()).thenReturn(NOW); - Mockito.when(view1.getDatabase()).thenReturn(db); - Mockito.when(view1.getSupportedSysTables()).thenReturn(PartitionsSysTable.HIVE_SUPPORTED_SYS_TABLES); - - setField(view2, "objectCreated", true); - - Mockito.when(view2.getId()).thenReturn(10003L); - Mockito.when(view2.getName()).thenReturn("hms_view2"); - Mockito.when(view2.getDbName()).thenReturn("hms_db"); - Mockito.when(view2.isView()).thenReturn(true); - Mockito.when(view2.getCatalog()).thenReturn(hmsCatalog); - Mockito.when(view2.getType()).thenReturn(TableIf.TableType.HMS_EXTERNAL_TABLE); - Mockito.when(view2.getFullSchema()).thenReturn(schema); - Mockito.when(view2.getViewText()).thenReturn("SELECT * FROM hms_db.hms_view1"); - Mockito.when(view2.isSupportedHmsTable()).thenReturn(true); - Mockito.when(view2.getDlaType()).thenReturn(DLAType.HIVE); - Mockito.when(view2.getUpdateTime()).thenReturn(NOW); - Mockito.when(view2.getDatabase()).thenReturn(db); - Mockito.when(view2.getSupportedSysTables()).thenReturn(PartitionsSysTable.HIVE_SUPPORTED_SYS_TABLES); - - db.addTableForTest(tbl); - db.addTableForTest(tbl2); - db.addTableForTest(view1); - db.addTableForTest(view2); - hmsCatalog.addDatabaseForTest(db); - - Mockito.when(hiveScanNode1.getTargetTable()).thenReturn(tbl); - Mockito.when(hiveScanNode2.getTargetTable()).thenReturn(view1); - Mockito.when(hiveScanNode3.getTargetTable()).thenReturn(view2); - Mockito.when(hiveScanNode4.getTargetTable()).thenReturn(tbl2); - - TupleDescriptor desc = new TupleDescriptor(new TupleId(1)); - desc.setTable(mgr.getInternalCatalog().getDbNullable("test").getTableNullable("tbl1")); - olapScanNode = new OlapScanNode(new PlanNodeId(1), desc, "tb1ScanNode", ScanContext.EMPTY); - } - - @Test - public void testHitSqlCacheByNereids() { - init((HMSExternalCatalog) mgr.getCatalog(HMS_CATALOG)); - StatementBase parseStmt = analyzeAndGetStmtByNereids("select * from hms_ctl.hms_db.hms_tbl", connectContext); - List scanNodes = Arrays.asList(hiveScanNode1); - CacheAnalyzer ca = new CacheAnalyzer(connectContext, parseStmt, scanNodes); - ca.checkCacheModeForNereids(System.currentTimeMillis() + Config.cache_last_version_interval_second * 1000L * 2); - Assert.assertEquals(CacheAnalyzer.CacheMode.Sql, ca.getCacheMode()); - SqlCache sqlCache = (SqlCache) ca.getCache(); - Assert.assertEquals(NOW, sqlCache.getLatestTime()); - } - - @Test - public void testHitSqlCacheByNereidsAfterPartitionUpdateTimeChanged() { - init((HMSExternalCatalog) mgr.getCatalog(HMS_CATALOG)); - StatementBase parseStmt = analyzeAndGetStmtByNereids("select * from hms_ctl.hms_db.hms_tbl2", connectContext); - List scanNodes = Arrays.asList(hiveScanNode4); - - // invoke initSchemaAndUpdateTime first and init schemaUpdateTime - tbl2.initSchemaAndUpdateTime(new SchemaCacheKey(tbl2.getOrBuildNameMapping())); - - CacheAnalyzer ca = new CacheAnalyzer(connectContext, parseStmt, scanNodes); - ca.checkCacheModeForNereids(System.currentTimeMillis() + Config.cache_last_version_interval_second * 1000L * 2); - Assert.assertEquals(CacheAnalyzer.CacheMode.Sql, ca.getCacheMode()); - SqlCache sqlCache1 = (SqlCache) ca.getCache(); - - // latestTime is equals to the schema update time if not set partition update time - Assert.assertEquals(tbl2.getUpdateTime(), sqlCache1.getLatestTime()); - - // wait a second and set partition update time - try { - Thread.sleep(1000); - } catch (Throwable throwable) { - // do nothing - } - long later = System.currentTimeMillis(); - Mockito.when(tbl2.getUpdateTime()).thenReturn(later); - - // check cache mode again - ca.checkCacheModeForNereids(System.currentTimeMillis() + Config.cache_last_version_interval_second * 1000L * 2); - SqlCache sqlCache2 = (SqlCache) ca.getCache(); - Assert.assertEquals(CacheAnalyzer.CacheMode.Sql, ca.getCacheMode()); - - // the latest time will be changed and is equals to the partition update time - Assert.assertEquals(later, sqlCache2.getLatestTime()); - Assert.assertTrue(sqlCache2.getLatestTime() > sqlCache1.getLatestTime()); - } - - @Test - public void testHitSqlCacheWithHiveViewByNereids() { - init((HMSExternalCatalog) mgr.getCatalog(HMS_CATALOG)); - StatementBase parseStmt = analyzeAndGetStmtByNereids("select * from hms_ctl.hms_db.hms_view1", connectContext); - List scanNodes = Arrays.asList(hiveScanNode2); - CacheAnalyzer ca = new CacheAnalyzer(connectContext, parseStmt, scanNodes); - ca.checkCacheModeForNereids(System.currentTimeMillis() + Config.cache_last_version_interval_second * 1000L * 2); - Assert.assertEquals(CacheAnalyzer.CacheMode.Sql, ca.getCacheMode()); - SqlCache sqlCache = (SqlCache) ca.getCache(); - Assert.assertEquals(NOW, sqlCache.getLatestTime()); - } - - @Test - public void testHitSqlCacheWithNestedHiveViewByNereids() { - init((HMSExternalCatalog) mgr.getCatalog(HMS_CATALOG)); - StatementBase parseStmt = analyzeAndGetStmtByNereids("select * from hms_ctl.hms_db.hms_view2", connectContext); - List scanNodes = Arrays.asList(hiveScanNode3); - CacheAnalyzer ca = new CacheAnalyzer(connectContext, parseStmt, scanNodes); - ca.checkCacheModeForNereids(System.currentTimeMillis() + Config.cache_last_version_interval_second * 1000L * 2); - Assert.assertEquals(CacheAnalyzer.CacheMode.Sql, ca.getCacheMode()); - SqlCache sqlCache = (SqlCache) ca.getCache(); - String cacheKey = sqlCache.getSqlWithViewStmt(); - Assert.assertEquals("select * from hms_ctl.hms_db.hms_view2" - + "|SELECT * FROM hms_db.hms_tbl|SELECT * FROM hms_db.hms_view1", cacheKey); - Assert.assertEquals(NOW, sqlCache.getLatestTime()); - } - - @Test - public void testNotHitSqlCacheByNereids() { - init((HMSExternalCatalog) mgr.getCatalog(HMS_CATALOG)); - StatementBase parseStmt = analyzeAndGetStmtByNereids("select * from hms_ctl.hms_db.hms_tbl", connectContext); - List scanNodes = Arrays.asList(hiveScanNode1); - - CacheAnalyzer ca2 = new CacheAnalyzer(connectContext, parseStmt, scanNodes); - ca2.checkCacheModeForNereids(0); - long latestPartitionTime = ca2.getLatestTable().latestPartitionTime; - - CacheAnalyzer ca = new CacheAnalyzer(connectContext, parseStmt, scanNodes); - ca.checkCacheModeForNereids(latestPartitionTime); - Assert.assertEquals(CacheAnalyzer.CacheMode.None, ca.getCacheMode()); - } - - @Test - public void testNotHitSqlCacheWithFederatedQueryByNereids() { - init((HMSExternalCatalog) mgr.getCatalog(HMS_CATALOG)); - // cache mode is None if this query is a federated query - StatementBase parseStmt = analyzeAndGetStmtByNereids("select * from hms_ctl.hms_db.hms_tbl " - + "inner join internal.test.tbl1", connectContext); - List scanNodes = Arrays.asList(hiveScanNode1, olapScanNode); - CacheAnalyzer ca = new CacheAnalyzer(connectContext, parseStmt, scanNodes); - ca.checkCacheModeForNereids(System.currentTimeMillis() + Config.cache_last_version_interval_second * 1000L * 2); - Assert.assertEquals(CacheAnalyzer.CacheMode.None, ca.getCacheMode()); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/qe/QueryFinishCallbackRegistryTest.java b/fe/fe-core/src/test/java/org/apache/doris/qe/QueryFinishCallbackRegistryTest.java new file mode 100644 index 00000000000000..df9ede51dcbc95 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/qe/QueryFinishCallbackRegistryTest.java @@ -0,0 +1,114 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.qe; + +import com.google.common.collect.Lists; +import org.junit.Assert; +import org.junit.Test; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +public class QueryFinishCallbackRegistryTest { + + // A query-finish callback must actually run so connector cleanup + // (e.g. committing a hive read transaction) happens at query end. + @Test + public void testRunAndClearRunsRegisteredCallback() { + QueryFinishCallbackRegistry registry = new QueryFinishCallbackRegistry(); + AtomicInteger runs = new AtomicInteger(0); + registry.register("q1", runs::incrementAndGet); + + registry.runAndClear("q1"); + + Assert.assertEquals(1, runs.get()); + } + + // The generic query-cleanup path drains this registry for every query, + // most of which register nothing; draining an unknown query must be a + // harmless no-op (never throw). + @Test + public void testRunAndClearWithNoCallbackIsNoOp() { + QueryFinishCallbackRegistry registry = new QueryFinishCallbackRegistry(); + registry.runAndClear("unknown-query"); + } + + // A single query may open several transactional scans; all their cleanup + // callbacks must run, in the order they were registered. + @Test + public void testMultipleCallbacksRunInRegistrationOrder() { + QueryFinishCallbackRegistry registry = new QueryFinishCallbackRegistry(); + List order = Lists.newArrayList(); + registry.register("q1", () -> order.add(1)); + registry.register("q1", () -> order.add(2)); + registry.register("q1", () -> order.add(3)); + + registry.runAndClear("q1"); + + Assert.assertEquals(Lists.newArrayList(1, 2, 3), order); + } + + // The early lock-release path can drain a query before its final drain at + // unregisterQuery. The second drain must be a no-op so cleanup runs once. + @Test + public void testRunAndClearIsIdempotent() { + QueryFinishCallbackRegistry registry = new QueryFinishCallbackRegistry(); + AtomicInteger runs = new AtomicInteger(0); + registry.register("q1", runs::incrementAndGet); + + registry.runAndClear("q1"); + registry.runAndClear("q1"); + + Assert.assertEquals(1, runs.get()); + } + + // One connector's failing cleanup must not block another's, nor break the + // rest of query teardown: exceptions are isolated and runAndClear returns. + @Test + public void testFailingCallbackIsIsolated() { + QueryFinishCallbackRegistry registry = new QueryFinishCallbackRegistry(); + AtomicInteger runs = new AtomicInteger(0); + registry.register("q1", () -> { + throw new RuntimeException("boom"); + }); + registry.register("q1", runs::incrementAndGet); + + registry.runAndClear("q1"); + + Assert.assertEquals(1, runs.get()); + } + + // Cleanup is scoped to the finishing query: draining one query must not run + // or discard callbacks registered for a different query. + @Test + public void testCallbacksAreScopedPerQuery() { + QueryFinishCallbackRegistry registry = new QueryFinishCallbackRegistry(); + AtomicInteger q1Runs = new AtomicInteger(0); + AtomicInteger q2Runs = new AtomicInteger(0); + registry.register("q1", q1Runs::incrementAndGet); + registry.register("q2", q2Runs::incrementAndGet); + + registry.runAndClear("q1"); + + Assert.assertEquals(1, q1Runs.get()); + Assert.assertEquals(0, q2Runs.get()); + + registry.runAndClear("q2"); + Assert.assertEquals(1, q2Runs.get()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/qe/cache/PluginTableCacheAnalyzerTest.java b/fe/fe-core/src/test/java/org/apache/doris/qe/cache/PluginTableCacheAnalyzerTest.java new file mode 100644 index 00000000000000..baa5290fe03dfb --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/qe/cache/PluginTableCacheAnalyzerTest.java @@ -0,0 +1,133 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.qe.cache; + +import org.apache.doris.analysis.TupleDescriptor; +import org.apache.doris.analysis.TupleId; +import org.apache.doris.catalog.DatabaseIf; +import org.apache.doris.catalog.FunctionGenTable; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.common.jmockit.Deencapsulation; +import org.apache.doris.datasource.CatalogIf; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable; +import org.apache.doris.datasource.scan.PluginDrivenScanNode; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.SessionVariable; + +import org.junit.Assert; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Collections; + +/** + * Unit tests for the connector-agnostic scan-node recognition the SQL-result-cache migration added to + * {@link CacheAnalyzer} after the hms SPI cutover. A flipped lakehouse table is a + * {@code PluginDrivenMvccExternalTable} scanned by a {@link PluginDrivenScanNode}; the old gate matched + * {@code instanceof HiveScanNode}, which both missed the plugin node AND (via the class hierarchy) would + * wrongly include a jdbc-query TVF. The new gate keys on the TARGET TABLE's {@code MTMVRelatedTableIf} + * capability, so it recognizes any lakehouse plugin table and excludes token-less nodes. + * + *

    Tests the two new private members directly (via {@link Deencapsulation}) to avoid the full + * analyze/MetricRepo bootstrap — the same reason the legacy {@code HmsQueryCacheTest} needed a real + * catalog and is now disabled for the plugin path.

    + */ +public class PluginTableCacheAnalyzerTest { + + private CacheAnalyzer analyzer; + + @BeforeEach + public void setUp() { + ConnectContext ctx = Mockito.mock(ConnectContext.class); + Mockito.when(ctx.getSessionVariable()).thenReturn(new SessionVariable()); + // Unit-test constructor: scanNodes are supplied per-test via Deencapsulation.invoke on the helper. + analyzer = new CacheAnalyzer(ctx, null, Collections.emptyList()); + } + + private PluginDrivenScanNode mockPluginScanNode(TableIf table, long selectedPartitionNum) { + PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class); + TupleDescriptor desc = new TupleDescriptor(new TupleId(1)); + desc.setTable(table); + Mockito.when(node.getTupleDesc()).thenReturn(desc); + Mockito.when(node.getSelectedPartitionNum()).thenReturn(selectedPartitionNum); + return node; + } + + /** + * A flipped lakehouse plugin table (implements MTMVRelatedTableIf) IS recognized as cacheable. RED on + * the pre-cutover HEAD, whose gate was {@code instanceof HiveScanNode} and never matched the plugin node. + */ + @Test + public void testRecognizePluginTableByCapability() { + PluginDrivenMvccExternalTable table = Mockito.mock(PluginDrivenMvccExternalTable.class); + PluginDrivenScanNode node = mockPluginScanNode(table, 3L); + boolean recognized = Deencapsulation.invoke(analyzer, "isExternalCacheableScanNode", node); + Assert.assertTrue("a PluginDrivenMvccExternalTable scan must be cacheable", recognized); + } + + /** + * A jdbc-query TVF also emits a PluginDrivenScanNode, but its backing table is a FunctionGenTable with no + * data-version token — the capability gate must EXCLUDE it (a class-based {@code instanceof + * PluginDrivenScanNode} gate would have wrongly admitted it). + */ + @Test + public void testRejectTvfBackedNode() { + FunctionGenTable tvfTable = Mockito.mock(FunctionGenTable.class); + PluginDrivenScanNode node = mockPluginScanNode(tvfTable, 0L); + boolean recognized = Deencapsulation.invoke(analyzer, "isExternalCacheableScanNode", node); + Assert.assertFalse("a jdbc-query TVF (FunctionGenTable) has no token and must not be cacheable", + recognized); + } + + /** A scan node with no tuple descriptor is defensively excluded (no NPE). */ + @Test + public void testRejectNullTupleDesc() { + PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class); + Mockito.when(node.getTupleDesc()).thenReturn(null); + boolean recognized = Deencapsulation.invoke(analyzer, "isExternalCacheableScanNode", node); + Assert.assertFalse(recognized); + } + + /** + * The cache-freshness marker (latestPartitionTime) is sourced from the connector's stable data-version + * token {@code getNewestUpdateVersionOrTime()}, NOT the wall-clock {@code getUpdateTime()} that a flipped + * plugin table would inherit (which would serve stale results). partitionNum comes from the scan node. + */ + @Test + public void testTokenSourcedFromConnectorFreshness() { + long token = 1_700_000_000_000L; + PluginDrivenMvccExternalTable table = Mockito.mock(PluginDrivenMvccExternalTable.class); + DatabaseIf db = Mockito.mock(DatabaseIf.class); + CatalogIf catalog = Mockito.mock(CatalogIf.class); + Mockito.when(catalog.getName()).thenReturn("hms_ctl"); + Mockito.when(db.getCatalog()).thenReturn(catalog); + Mockito.when(db.getFullName()).thenReturn("hms_db"); + Mockito.when(table.getDatabase()).thenReturn(db); + Mockito.when(table.getName()).thenReturn("t"); + Mockito.when(table.getNewestUpdateVersionOrTime()).thenReturn(token); + + PluginDrivenScanNode node = mockPluginScanNode(table, 5L); + CacheAnalyzer.CacheTable cacheTable = + Deencapsulation.invoke(analyzer, "buildCacheTableForExternalScanNode", node); + + Assert.assertSame(table, cacheTable.table); + Assert.assertEquals(token, cacheTable.latestPartitionTime); + Assert.assertEquals(5L, cacheTable.partitionNum); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/resource/ComputeGroupTest.java b/fe/fe-core/src/test/java/org/apache/doris/resource/ComputeGroupTest.java index 45be3f1e6f9e49..e1def2644b993d 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/resource/ComputeGroupTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/resource/ComputeGroupTest.java @@ -26,8 +26,8 @@ import org.apache.doris.common.MetaNotFoundException; import org.apache.doris.common.RandomIdentifierGenerator; import org.apache.doris.common.UserException; -import org.apache.doris.datasource.FederationBackendPolicy; import org.apache.doris.datasource.InternalCatalog; +import org.apache.doris.datasource.scan.FederationBackendPolicy; import org.apache.doris.load.loadv2.BrokerLoadJob; import org.apache.doris.load.routineload.RoutineLoadJob; import org.apache.doris.load.routineload.RoutineLoadManager; diff --git a/fe/fe-core/src/test/java/org/apache/doris/service/FrontendServiceImplTest.java b/fe/fe-core/src/test/java/org/apache/doris/service/FrontendServiceImplTest.java index c017cd71bc2642..9588d153c68078 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/service/FrontendServiceImplTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/service/FrontendServiceImplTest.java @@ -29,8 +29,6 @@ import org.apache.doris.common.FeConstants; import org.apache.doris.common.util.DatasourcePrintableMap; import org.apache.doris.datasource.InternalCatalog; -import org.apache.doris.datasource.maxcompute.MCTransaction; -import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog; import org.apache.doris.nereids.parser.NereidsParser; import org.apache.doris.nereids.trees.plans.commands.Command; import org.apache.doris.nereids.trees.plans.commands.CreateDatabaseCommand; @@ -65,6 +63,7 @@ import org.apache.doris.thrift.TStatusCode; import org.apache.doris.thrift.TTableStatus; import org.apache.doris.transaction.GlobalTransactionMgrIface; +import org.apache.doris.transaction.Transaction; import org.apache.doris.transaction.TransactionState; import org.apache.doris.utframe.UtFrameUtils; @@ -482,8 +481,12 @@ public void testShowUser() { public void testGetMaxComputeBlockIdRange() throws Exception { FrontendServiceImpl impl = new FrontendServiceImpl(exeEnv); long txnId = Env.getCurrentEnv().getNextId(); - MCTransaction transaction = new MCTransaction(Mockito.mock(MaxComputeExternalCatalog.class)); - setPrivateField(transaction, "writeSessionId", "session-1"); + // The block-id RPC consumes the generic Transaction SPI (supportsWriteBlockAllocation / + // allocateWriteBlockRange); the live impl is PluginDrivenTransactionManager's connector + // transaction. Mock the interface to pin the RPC's allocate-and-return contract. + Transaction transaction = Mockito.mock(Transaction.class); + Mockito.when(transaction.supportsWriteBlockAllocation()).thenReturn(true); + Mockito.when(transaction.allocateWriteBlockRange("session-1", 1L)).thenReturn(0L, 1L); Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().putTxnById(txnId, transaction); try { diff --git a/fe/fe-core/src/test/java/org/apache/doris/statistics/CacheTest.java b/fe/fe-core/src/test/java/org/apache/doris/statistics/CacheTest.java index 37b41e268abe40..44f03bede37968 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/statistics/CacheTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/statistics/CacheTest.java @@ -22,7 +22,7 @@ import org.apache.doris.catalog.PrimitiveType; import org.apache.doris.catalog.Type; import org.apache.doris.common.ThreadPoolManager; -import org.apache.doris.datasource.hive.HMSExternalTable; +import org.apache.doris.datasource.ExternalTable; import org.apache.doris.ha.FrontendNodeType; import org.apache.doris.statistics.util.StatisticsUtil; import org.apache.doris.system.Frontend; @@ -181,7 +181,7 @@ public void testLoadHistogram() throws Exception { @Test public void testLoadFromMeta() throws Exception { - HMSExternalTable table = Mockito.mock(HMSExternalTable.class); + ExternalTable table = Mockito.mock(ExternalTable.class); Env env = Mockito.mock(Env.class); try (MockedStatic mockedStatisticsUtil = Mockito.mockStatic( diff --git a/fe/fe-core/src/test/java/org/apache/doris/statistics/HMSAnalysisTaskTest.java b/fe/fe-core/src/test/java/org/apache/doris/statistics/HMSAnalysisTaskTest.java deleted file mode 100644 index 3011a83101d64e..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/statistics/HMSAnalysisTaskTest.java +++ /dev/null @@ -1,291 +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.statistics; - -import org.apache.doris.analysis.TableSample; -import org.apache.doris.catalog.Column; -import org.apache.doris.catalog.DatabaseIf; -import org.apache.doris.catalog.PrimitiveType; -import org.apache.doris.common.Pair; -import org.apache.doris.datasource.CatalogIf; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.qe.SessionVariable; -import org.apache.doris.statistics.util.StatisticsUtil; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Lists; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.mockito.Mockito; - -import java.util.ArrayList; -import java.util.Arrays; - -public class HMSAnalysisTaskTest { - - @Test - public void testNeedLimit() throws Exception { - HMSExternalTable tableIf = Mockito.mock(HMSExternalTable.class); - ArrayList columns = Lists.newArrayList(); - columns.add(new Column("int_column", PrimitiveType.INT)); - Mockito.when(tableIf.getFullSchema()).thenReturn(columns); - - HMSAnalysisTask task = new HMSAnalysisTask(); - task.setTable(tableIf); - task.tableSample = new TableSample(true, 10L); - Assertions.assertFalse(task.needLimit(100, 5.0)); - - task.tableSample = new TableSample(false, 100L); - Assertions.assertFalse(task.needLimit(100, 5.0)); - Assertions.assertTrue(task.needLimit(2L * 1024 * 1024 * 1024, 5.0)); - task.tableSample = new TableSample(false, 512L * 1024 * 1024); - Assertions.assertFalse(task.needLimit(2L * 1024 * 1024 * 1024, 5.0)); - } - - @Test - public void testAutoSampleSmallTable() throws Exception { - HMSExternalTable tableIf = Mockito.mock(HMSExternalTable.class); - Mockito.when(tableIf.getDataSize(Mockito.anyBoolean())) - .thenReturn(StatisticsUtil.getHugeTableLowerBoundSizeInBytes() - 1); - - HMSAnalysisTask task = new HMSAnalysisTask(); - task.tbl = tableIf; - AnalysisInfoBuilder analysisInfoBuilder = new AnalysisInfoBuilder(); - analysisInfoBuilder.setJobType(AnalysisInfo.JobType.SYSTEM); - analysisInfoBuilder.setAnalysisMethod(AnalysisInfo.AnalysisMethod.FULL); - task.info = analysisInfoBuilder.build(); - TableSample tableSample = task.getTableSample(); - Assertions.assertNull(tableSample); - } - - @Test - public void testManualFull() throws Exception { - HMSExternalTable tableIf = Mockito.mock(HMSExternalTable.class); - Mockito.when(tableIf.getDataSize(Mockito.anyBoolean())).thenReturn(1000L); - - HMSAnalysisTask task = new HMSAnalysisTask(); - task.tbl = tableIf; - AnalysisInfoBuilder analysisInfoBuilder = new AnalysisInfoBuilder(); - analysisInfoBuilder.setJobType(AnalysisInfo.JobType.MANUAL); - analysisInfoBuilder.setAnalysisMethod(AnalysisInfo.AnalysisMethod.FULL); - task.info = analysisInfoBuilder.build(); - TableSample tableSample = task.getTableSample(); - Assertions.assertNull(tableSample); - } - - @Test - public void testManualSample() throws Exception { - HMSExternalTable tableIf = Mockito.mock(HMSExternalTable.class); - Mockito.when(tableIf.getDataSize(Mockito.anyBoolean())).thenReturn(1000L); - - HMSAnalysisTask task = new HMSAnalysisTask(); - task.tbl = tableIf; - AnalysisInfoBuilder analysisInfoBuilder = new AnalysisInfoBuilder(); - analysisInfoBuilder.setJobType(AnalysisInfo.JobType.MANUAL); - analysisInfoBuilder.setAnalysisMethod(AnalysisInfo.AnalysisMethod.SAMPLE); - analysisInfoBuilder.setSampleRows(1000); - task.info = analysisInfoBuilder.build(); - TableSample tableSample = task.getTableSample(); - Assertions.assertNotNull(tableSample); - Assertions.assertEquals(1000, tableSample.getSampleValue()); - } - - @Test - public void testGetSampleInfo() throws Exception { - HMSExternalTable tableIf = Mockito.mock(HMSExternalTable.class); - Mockito.when(tableIf.getChunkSizes()).thenReturn(Lists.newArrayList()); - - HMSAnalysisTask task = new HMSAnalysisTask(); - task.setTable(tableIf); - task.tableSample = null; - Pair info1 = task.getSampleInfo(); - Assertions.assertEquals(1.0, info1.first); - Assertions.assertEquals(0, info1.second); - task.tableSample = new TableSample(false, 100L); - Pair info2 = task.getSampleInfo(); - Assertions.assertEquals(1.0, info2.first); - Assertions.assertEquals(0, info2.second); - } - - @Test - public void testGetSampleInfoPercent() throws Exception { - HMSExternalTable tableIf = Mockito.mock(HMSExternalTable.class); - Mockito.when(tableIf.getChunkSizes()).thenReturn(Arrays.asList(1024L, 2048L)); - - HMSAnalysisTask task = new HMSAnalysisTask(); - task.setTable(tableIf); - AnalysisInfoBuilder analysisInfoBuilder = new AnalysisInfoBuilder(); - analysisInfoBuilder.setJobType(AnalysisInfo.JobType.MANUAL); - analysisInfoBuilder.setAnalysisMethod(AnalysisInfo.AnalysisMethod.SAMPLE); - analysisInfoBuilder.setSamplePercent(10); - task.info = analysisInfoBuilder.build(); - - task.tableSample = new TableSample(true, 10L); - Pair info = task.getSampleInfo(); - Assertions.assertEquals(1.5, info.first); - Assertions.assertEquals(2048, info.second); - } - - @SuppressWarnings("unchecked") - @Test - public void testOrdinaryStats() throws Exception { - CatalogIf catalogIf = Mockito.mock(CatalogIf.class); - DatabaseIf databaseIf = Mockito.mock(DatabaseIf.class); - HMSExternalTable tableIf = Mockito.mock(HMSExternalTable.class); - - Mockito.when(tableIf.getId()).thenReturn(30001L); - Mockito.when(tableIf.getName()).thenReturn("test"); - Mockito.when(catalogIf.getId()).thenReturn(10001L); - Mockito.when(catalogIf.getName()).thenReturn("hms"); - Mockito.when(databaseIf.getId()).thenReturn(20001L); - Mockito.when(databaseIf.getFullName()).thenReturn("default"); - Mockito.when(tableIf.getPartitionNames()).thenReturn(ImmutableSet.of("date=20230101/hour=12")); - - HMSAnalysisTask task = Mockito.spy(new HMSAnalysisTask()); - Mockito.doAnswer(invocation -> { - String sql = invocation.getArgument(0); - Assertions.assertEquals("SELECT CONCAT(30001, '-', -1, '-', 'hour') AS `id`, " - + "10001 AS `catalog_id`, 20001 AS `db_id`, 30001 AS `tbl_id`, " - + "-1 AS `idx_id`, 'hour' AS `col_id`, NULL AS `part_id`, " - + "COUNT(1) AS `row_count`, NDV(`hour`) AS `ndv`, " - + "COUNT(1) - COUNT(`hour`) AS `null_count`, " - + "SUBSTRING(CAST(MIN(`hour`) AS STRING), 1, 1024) AS `min`, " - + "SUBSTRING(CAST(MAX(`hour`) AS STRING), 1, 1024) AS `max`, " - + "COUNT(1) * 4 AS `data_size`, NOW() AS `update_time`, null as `hot_value` " - + "FROM (SELECT `hour` FROM `hms`.`default`.`test` ) __lc_t", sql); - return null; - }).when(task).runQuery(Mockito.anyString()); - - task.col = new Column("hour", PrimitiveType.INT); - task.tbl = tableIf; - task.catalog = catalogIf; - task.db = databaseIf; - task.setTable(tableIf); - - AnalysisInfoBuilder analysisInfoBuilder = new AnalysisInfoBuilder(); - analysisInfoBuilder.setColName("hour"); - analysisInfoBuilder.setJobType(AnalysisInfo.JobType.MANUAL); - analysisInfoBuilder.setUsingSqlForExternalTable(true); - task.info = analysisInfoBuilder.build(); - - task.doExecute(); - } - - @SuppressWarnings("unchecked") - @Test - public void testOrdinaryStatsWithHotValue() throws Exception { - CatalogIf catalogIf = Mockito.mock(CatalogIf.class); - DatabaseIf databaseIf = Mockito.mock(DatabaseIf.class); - HMSExternalTable tableIf = Mockito.mock(HMSExternalTable.class); - - Mockito.when(tableIf.getId()).thenReturn(30001L); - Mockito.when(tableIf.getName()).thenReturn("test"); - Mockito.when(catalogIf.getId()).thenReturn(10001L); - Mockito.when(catalogIf.getName()).thenReturn("hms"); - Mockito.when(databaseIf.getId()).thenReturn(20001L); - Mockito.when(databaseIf.getFullName()).thenReturn("default"); - Mockito.when(tableIf.getPartitionNames()).thenReturn(ImmutableSet.of("date=20230101/hour=12")); - - try (MockedStatic mockedSessionVariable = Mockito.mockStatic(SessionVariable.class)) { - mockedSessionVariable.when(SessionVariable::getHotValueCollectCount).thenReturn(10); - - HMSAnalysisTask task = Mockito.spy(new HMSAnalysisTask()); - Mockito.doAnswer(invocation -> { - String sql = invocation.getArgument(0); - Assertions.assertEquals("WITH cte1 AS (SELECT `hour` " - + "FROM `hms`.`default`.`test` ), " - + "cte2 AS (SELECT CONCAT(30001, '-', -1, '-', 'hour') AS `id`, " - + "10001 AS `catalog_id`, 20001 AS `db_id`, 30001 AS `tbl_id`, " - + "-1 AS `idx_id`, 'hour' AS `col_id`, NULL AS `part_id`, " - + "COUNT(1) AS `row_count`, NDV(`hour`) AS `ndv`, " - + "COUNT(1) - COUNT(`hour`) AS `null_count`, " - + "SUBSTRING(CAST(MIN(`hour`) AS STRING), 1, 1024) AS `min`, " - + "SUBSTRING(CAST(MAX(`hour`) AS STRING), 1, 1024) AS `max`, " - + "COUNT(1) * 4 AS `data_size`, NOW() FROM cte1), " - + "cte3 AS (SELECT IFNULL(GROUP_CONCAT(CONCAT(" - + "REPLACE(REPLACE(t.`column_key`, \":\", \"\\\\:\"), \";\", \"\\\\;\"), " - + "\" :\", ROUND(t.`count` / " - + "(SELECT COUNT(1) FROM cte1 WHERE `hour` IS NOT NULL), 2)), \" ;\"), '') " - + "as `hot_value` FROM (SELECT `hour` as `hash_value`, " - + "MAX(`hour`) as `column_key`, COUNT(1) AS `count` " - + "FROM cte1 WHERE `hour` IS NOT NULL " - + "GROUP BY `hash_value` ORDER BY `count` DESC LIMIT 10) t) " - + "SELECT * FROM cte2 CROSS JOIN cte3", sql); - return null; - }).when(task).runQuery(Mockito.anyString()); - - task.col = new Column("hour", PrimitiveType.INT); - task.tbl = tableIf; - task.catalog = catalogIf; - task.db = databaseIf; - task.setTable(tableIf); - - AnalysisInfoBuilder analysisInfoBuilder = new AnalysisInfoBuilder(); - analysisInfoBuilder.setColName("hour"); - analysisInfoBuilder.setJobType(AnalysisInfo.JobType.MANUAL); - analysisInfoBuilder.setUsingSqlForExternalTable(true); - analysisInfoBuilder.setCollectHotValue(true); - task.info = analysisInfoBuilder.build(); - - task.doExecute(); - } - } - - @SuppressWarnings("unchecked") - @Test - public void testPartitionHMSStats() throws Exception { - CatalogIf catalogIf = Mockito.mock(CatalogIf.class); - DatabaseIf databaseIf = Mockito.mock(DatabaseIf.class); - HMSExternalTable tableIf = Mockito.mock(HMSExternalTable.class); - - Mockito.when(tableIf.getId()).thenReturn(30001L); - Mockito.when(catalogIf.getId()).thenReturn(10001L); - Mockito.when(catalogIf.getName()).thenReturn("hms"); - Mockito.when(databaseIf.getId()).thenReturn(20001L); - Mockito.when(tableIf.getPartitionNames()).thenReturn(ImmutableSet.of("date=20230101/hour=12")); - Mockito.when(tableIf.getPartitionColumns()) - .thenReturn(ImmutableList.of(new Column("hour", PrimitiveType.INT))); - - HMSAnalysisTask task = Mockito.spy(new HMSAnalysisTask()); - Mockito.doAnswer(invocation -> { - String sql = invocation.getArgument(0); - Assertions.assertEquals(" SELECT CONCAT(30001, '-', -1, '-', 'hour') AS `id`, " - + "10001 AS `catalog_id`, 20001 AS `db_id`, 30001 AS `tbl_id`, -1 AS `idx_id`, " - + "'hour' AS `col_id`, NULL AS `part_id`, 0 AS `row_count`, 1 AS `ndv`, " - + "0 AS `null_count`, SUBSTRING(CAST('12' AS STRING), 1, 1024) AS `min`, " - + "SUBSTRING(CAST('12' AS STRING), 1, 1024) AS `max`, 0 AS `data_size`, NOW() ", sql); - return null; - }).when(task).runQuery(Mockito.anyString()); - - task.col = new Column("hour", PrimitiveType.INT); - task.tbl = tableIf; - task.catalog = catalogIf; - task.db = databaseIf; - task.setTable(tableIf); - - AnalysisInfoBuilder analysisInfoBuilder = new AnalysisInfoBuilder(); - analysisInfoBuilder.setColName("hour"); - analysisInfoBuilder.setJobType(AnalysisInfo.JobType.MANUAL); - analysisInfoBuilder.setUsingSqlForExternalTable(false); - task.info = analysisInfoBuilder.build(); - - task.doExecute(); - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/statistics/StatisticsAutoCollectorTest.java b/fe/fe-core/src/test/java/org/apache/doris/statistics/StatisticsAutoCollectorTest.java index 3000ee3b7c66e3..3312159573a4e4 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/statistics/StatisticsAutoCollectorTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/statistics/StatisticsAutoCollectorTest.java @@ -26,21 +26,14 @@ import org.apache.doris.catalog.info.TableNameInfo; import org.apache.doris.common.DdlException; import org.apache.doris.common.Pair; -import org.apache.doris.datasource.ExternalTable; import org.apache.doris.datasource.InternalCatalog; -import org.apache.doris.datasource.PluginDrivenExternalCatalog; -import org.apache.doris.datasource.PluginDrivenExternalDatabase; -import org.apache.doris.datasource.PluginDrivenExternalTable; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalDatabase; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HMSExternalTable.DLAType; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.statistics.AnalysisInfo.AnalysisMethod; -import com.google.common.collect.Maps; import com.google.common.collect.Sets; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -127,25 +120,52 @@ public void testSupportAutoAnalyze() throws DdlException { OlapTable table1 = new OlapTable(200, "testTable", schema, null, null, null); Assertions.assertTrue(collector.supportAutoAnalyze(table1)); - PluginDrivenExternalDatabase pluginDatabase = new PluginDrivenExternalDatabase(null, 1L, "jdbcdb", "jdbcdb"); - PluginDrivenExternalCatalog pluginCatalog = new PluginDrivenExternalCatalog(0, "jdbc_ctl", null, - Maps.newHashMap(), "", null); - ExternalTable externalTable = new PluginDrivenExternalTable(1, "jdbctable", "jdbctable", pluginCatalog, - pluginDatabase); - Assertions.assertFalse(collector.supportAutoAnalyze(externalTable)); - - HMSExternalDatabase hmsExternalDatabase = new HMSExternalDatabase(null, 1L, "hmsDb", "hmsDb"); - HMSExternalCatalog hmsCatalog = new HMSExternalCatalog(0, "jdbc_ctl", null, Maps.newHashMap(), ""); - HMSExternalTable icebergRaw = new HMSExternalTable(1, "hmsTable", "hmsDb", hmsCatalog, - hmsExternalDatabase); - ExternalTable icebergExternalTable = Mockito.spy(icebergRaw); - Mockito.doReturn(DLAType.ICEBERG).when((HMSExternalTable) icebergExternalTable).getDlaType(); - Assertions.assertTrue(collector.supportAutoAnalyze(icebergExternalTable)); - - HMSExternalTable hiveRaw = new HMSExternalTable(1, "hmsTable", "hmsDb", hmsCatalog, hmsExternalDatabase); - ExternalTable hiveExternalTable = Mockito.spy(hiveRaw); - Mockito.doReturn(DLAType.HIVE).when((HMSExternalTable) hiveExternalTable).getDlaType(); - Assertions.assertTrue(collector.supportAutoAnalyze(hiveExternalTable)); + // A plugin-driven table is admitted to auto-analyze IFF its connector declares column auto-analyze: + // the capability — not the PluginDrivenExternalTable type — is the gate (post-cutover iceberg/paimon + // declare it; jdbc/es do not). The real getConnector()->capability plumbing is covered by + // PluginDrivenExternalTableTest; here we pin the whitelist decision in both directions. + PluginDrivenExternalTable capablePluginTable = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(capablePluginTable.supportsColumnAutoAnalyze()).thenReturn(true); + Assertions.assertTrue(collector.supportAutoAnalyze(capablePluginTable)); + + PluginDrivenExternalTable incapablePluginTable = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(incapablePluginTable.supportsColumnAutoAnalyze()).thenReturn(false); + Assertions.assertFalse(collector.supportAutoAnalyze(incapablePluginTable)); + } + + @Test + public void testProcessOneJobForcesFullAnalyzeForCapablePluginTable() { + // A flipped plugin table (iceberg/paimon) whose connector declares column auto-analyze must be + // analyzed with FULL, never SAMPLE: ExternalAnalysisTask.doSample throws for external SQL-driven + // tables. Force the SAMPLE precondition (huge data size, not partitioned) so only the plugin FULL + // arm under test can flip the method to FULL. We assert via the isSampleAnalyze flag the decision + // is passed to readyToSample with. + StatisticsAutoCollector collector = Mockito.spy(new StatisticsAutoCollector()); + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(table.supportsColumnAutoAnalyze()).thenReturn(true); + Mockito.when(table.getDataSize(true)).thenReturn(Long.MAX_VALUE); + Mockito.when(table.isPartitionedTable()).thenReturn(false); + Mockito.when(table.getId()).thenReturn(1L); + Mockito.when(table.getRowCount()).thenReturn(100L); + + AnalysisManager manager = Mockito.mock(AnalysisManager.class); + Env mockEnv = Mockito.mock(Env.class); + Mockito.when(mockEnv.getAnalysisManager()).thenReturn(manager); + // Early-return out of processOneJob immediately after the analysis-method decision is consumed by + // readyToSample, capturing the isSampleAnalyze flag it was called with. + ArgumentCaptor isSampleAnalyze = ArgumentCaptor.forClass(Boolean.class); + Mockito.doReturn(false).when(collector).readyToSample(Mockito.any(), Mockito.anyLong(), + Mockito.any(), Mockito.any(), Mockito.anyBoolean()); + + try (MockedStatic envStatic = Mockito.mockStatic(Env.class)) { + envStatic.when(Env::getServingEnv).thenReturn(mockEnv); + collector.processOneJob(table, Sets.newHashSet(), JobPriority.HIGH); + } + + Mockito.verify(collector).readyToSample(Mockito.eq(table), Mockito.anyLong(), Mockito.any(), + Mockito.any(), isSampleAnalyze.capture()); + Assertions.assertFalse(isSampleAnalyze.getValue(), + "plugin table with column-auto-analyze capability must use FULL analyze, not SAMPLE"); } @Test diff --git a/fe/fe-core/src/test/java/org/apache/doris/statistics/util/StatisticsUtilTest.java b/fe/fe-core/src/test/java/org/apache/doris/statistics/util/StatisticsUtilTest.java index 8bccbd2929f665..5e1fb44658f9ac 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/statistics/util/StatisticsUtilTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/statistics/util/StatisticsUtilTest.java @@ -33,17 +33,9 @@ import org.apache.doris.common.util.PropertyAnalyzer; import org.apache.doris.datasource.ExternalCatalog; import org.apache.doris.datasource.InternalCatalog; -import org.apache.doris.datasource.PluginDrivenExternalCatalog; -import org.apache.doris.datasource.PluginDrivenExternalDatabase; -import org.apache.doris.datasource.PluginDrivenExternalTable; -import org.apache.doris.datasource.hive.HMSExternalCatalog; -import org.apache.doris.datasource.hive.HMSExternalDatabase; -import org.apache.doris.datasource.hive.HMSExternalTable; -import org.apache.doris.datasource.hive.HMSExternalTable.DLAType; -import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; -import org.apache.doris.datasource.iceberg.IcebergExternalDatabase; -import org.apache.doris.datasource.iceberg.IcebergExternalTable; -import org.apache.doris.datasource.iceberg.IcebergHadoopExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalDatabase; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.nereids.trees.expressions.literal.Literal; import org.apache.doris.qe.SessionVariable; import org.apache.doris.rpc.RpcException; @@ -52,8 +44,6 @@ import org.apache.doris.statistics.TableStatsMeta; import org.apache.doris.thrift.TStorageType; -import com.google.common.collect.Maps; -import org.apache.iceberg.CatalogProperties; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; @@ -158,8 +148,10 @@ void testNeedAnalyzeColumn() throws DdlException { schema.add(column); OlapTable realTable = new OlapTable(200, "testTable", schema, null, null, null); OlapTable table = Mockito.spy(realTable); - HMSExternalCatalog externalCatalog = new HMSExternalCatalog(); - HMSExternalDatabase externalDatabase = new HMSExternalDatabase(externalCatalog, 1L, "dbName", "dbName"); + PluginDrivenExternalCatalog externalCatalog = new PluginDrivenExternalCatalog(1, "name", "resource", + new HashMap<>(), "", null); + PluginDrivenExternalDatabase externalDatabase = new PluginDrivenExternalDatabase(externalCatalog, 1L, + "dbName", "dbName"); // Test olap table auto analyze disabled. Map properties = new HashMap<>(); properties.put(PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY, "disable"); @@ -174,7 +166,8 @@ void testNeedAnalyzeColumn() throws DdlException { Mockito.when(catalog1.getId()).thenReturn(0L); // Test auto analyze catalog disabled. - HMSExternalTable hmsTable = Mockito.spy(new HMSExternalTable(1, "name", "name", externalCatalog, externalDatabase) { + PluginDrivenExternalTable hmsTable = Mockito.spy(new PluginDrivenExternalTable(1, "name", "name", + externalCatalog, externalDatabase) { @Override protected synchronized void makeSureInitialized() { } }); @@ -194,7 +187,8 @@ protected synchronized void makeSureInitialized() { } // Test external table auto analyze enabled. externalCatalog.getCatalogProperty().addProperty(ExternalCatalog.ENABLE_AUTO_ANALYZE, "false"); - HMSExternalTable hmsTable1 = Mockito.spy(new HMSExternalTable(1, "name", "name", externalCatalog, externalDatabase) { + PluginDrivenExternalTable hmsTable1 = Mockito.spy(new PluginDrivenExternalTable(1, "name", "name", + externalCatalog, externalDatabase) { @Override protected synchronized void makeSureInitialized() { } }); @@ -229,14 +223,6 @@ protected synchronized void makeSureInitialized() { } }); Assertions.assertFalse(StatisticsUtil.needAnalyzeColumn(pluginTable, Pair.of("index", column.getName()))); - // Test hms external table not hive type. - HMSExternalTable hmsExternalTable = Mockito.spy(new HMSExternalTable(1, "hmsTable", "hmsTable", externalCatalog, externalDatabase) { - @Override - protected synchronized void makeSureInitialized() { } - }); - Mockito.doReturn(DLAType.ICEBERG).when(hmsExternalTable).getDlaType(); - Assertions.assertFalse(StatisticsUtil.needAnalyzeColumn(hmsExternalTable, Pair.of("index", column.getName()))); - // Test partition first load. tableMeta.partitionChanged.set(true); Assertions.assertTrue(StatisticsUtil.needAnalyzeColumn(table, Pair.of("index", column.getName()))); @@ -304,13 +290,17 @@ void testLongTimeNoAnalyze() { Mockito.doReturn(true).when(table).autoAnalyzeEnabled(); // Test external table - IcebergExternalDatabase icebergDatabase = new IcebergExternalDatabase(null, 1L, "", ""); - Map props = Maps.newHashMap(); - props.put(CatalogProperties.WAREHOUSE_LOCATION, "s3://tmp"); - IcebergExternalCatalog catalog = new IcebergHadoopExternalCatalog(0, "iceberg_ctl", "", props, ""); - IcebergExternalTable icebergTable = Mockito.spy(new IcebergExternalTable(0, "", "", catalog, icebergDatabase)); - Mockito.doReturn(true).when(icebergTable).autoAnalyzeEnabled(); - Assertions.assertFalse(StatisticsUtil.isLongTimeColumn(icebergTable, Pair.of("index", column.getName()), 0)); + PluginDrivenExternalCatalog externalCatalog = new PluginDrivenExternalCatalog(1, "name", "resource", + new HashMap<>(), "", null); + PluginDrivenExternalDatabase externalDatabase = new PluginDrivenExternalDatabase(externalCatalog, 1L, + "dbName", "dbName"); + PluginDrivenExternalTable externalTable = Mockito.spy(new PluginDrivenExternalTable(0, "name", "name", + externalCatalog, externalDatabase) { + @Override + protected synchronized void makeSureInitialized() { } + }); + Mockito.doReturn(true).when(externalTable).autoAnalyzeEnabled(); + Assertions.assertFalse(StatisticsUtil.isLongTimeColumn(externalTable, Pair.of("index", column.getName()), 0)); // Mock Env.getServingEnv().getAnalysisManager() for remaining tests Env mockEnv = Mockito.mock(Env.class); @@ -381,9 +371,12 @@ void testCanCollectColumn() { Assertions.assertTrue(StatisticsUtil.canCollectColumn(column, null, true, 1)); // Test external table always return true; - HMSExternalCatalog externalCatalog = new HMSExternalCatalog(); - HMSExternalDatabase externalDatabase = new HMSExternalDatabase(externalCatalog, 1L, "dbName", "dbName"); - HMSExternalTable hmsTable = new HMSExternalTable(1, "name", "name", externalCatalog, externalDatabase); + PluginDrivenExternalCatalog externalCatalog = new PluginDrivenExternalCatalog(1, "name", "resource", + new HashMap<>(), "", null); + PluginDrivenExternalDatabase externalDatabase = new PluginDrivenExternalDatabase(externalCatalog, 1L, + "dbName", "dbName"); + PluginDrivenExternalTable hmsTable = new PluginDrivenExternalTable(1, "name", "name", externalCatalog, + externalDatabase); Assertions.assertTrue(StatisticsUtil.canCollectColumn(column, hmsTable, true, 1)); // Test agg key return true; diff --git a/fe/fe-core/src/test/java/org/apache/doris/tablefunction/MetadataGeneratorPluginDrivenTest.java b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/MetadataGeneratorPluginDrivenTest.java new file mode 100644 index 00000000000000..2af2683bccc5fe --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/MetadataGeneratorPluginDrivenTest.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.tablefunction; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.thrift.TFetchSchemaTableDataResult; +import org.apache.doris.thrift.TRow; +import org.apache.doris.thrift.TStatusCode; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * Tests for the partitions() TVF dispatch to a {@link PluginDrivenExternalCatalog} + * added by P4-T06c (MetadataGenerator.dealPluginDrivenCatalog). + * + *

    Why: after the MaxCompute SPI cutover, a {@code max_compute} catalog is a + * {@link PluginDrivenExternalCatalog}, so the old {@code instanceof MaxComputeExternalCatalog} + * branch no longer matches and the partitions() TVF would fall through to + * "not support catalog". These tests lock in that the new branch routes partition + * listing through the connector SPI (using remote names) and emits one + * single-string-column row per partition, matching the legacy dealMaxComputeCatalog shape.

    + */ +public class MetadataGeneratorPluginDrivenTest { + + private TFetchSchemaTableDataResult invokeDeal(PluginDrivenExternalCatalog catalog, ExternalTable table) + throws Exception { + Method m = MetadataGenerator.class.getDeclaredMethod("dealPluginDrivenCatalog", + PluginDrivenExternalCatalog.class, ExternalTable.class); + m.setAccessible(true); + return (TFetchSchemaTableDataResult) m.invoke(null, catalog, table); + } + + @Test + public void testRoutesToSpiWithRemoteNamesAndBuildsRows() throws Exception { + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + Connector connector = Mockito.mock(Connector.class); + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.when(connector.getMetadata(session)).thenReturn(metadata); + + ExternalTable table = Mockito.mock(ExternalTable.class); + Mockito.when(table.getRemoteDbName()).thenReturn("remote_db"); + Mockito.when(table.getRemoteName()).thenReturn("remote_tbl"); + + // The SPI must be queried with the REMOTE db/table names, not the local Doris names. + Mockito.when(metadata.getTableHandle(session, "remote_db", "remote_tbl")) + .thenReturn(Optional.of(handle)); + Mockito.when(metadata.listPartitionNames(session, handle)) + .thenReturn(Arrays.asList("pt=1", "pt=2")); + + TFetchSchemaTableDataResult result = invokeDeal(catalog, table); + + Assertions.assertEquals(TStatusCode.OK, result.getStatus().getStatusCode()); + List rows = result.getDataBatch(); + Assertions.assertEquals(2, rows.size()); + Assertions.assertEquals("pt=1", rows.get(0).getColumnValue().get(0).getStringVal()); + Assertions.assertEquals("pt=2", rows.get(1).getColumnValue().get(0).getStringVal()); + Mockito.verify(metadata).getTableHandle(session, "remote_db", "remote_tbl"); + } + + @Test + public void testAbsentHandleYieldsEmptyOkResult() throws Exception { + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + Connector connector = Mockito.mock(Connector.class); + ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); + + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); + Mockito.when(catalog.getConnector()).thenReturn(connector); + Mockito.when(connector.getMetadata(session)).thenReturn(metadata); + + ExternalTable table = Mockito.mock(ExternalTable.class); + Mockito.when(table.getRemoteDbName()).thenReturn("remote_db"); + Mockito.when(table.getRemoteName()).thenReturn("remote_tbl"); + Mockito.when(metadata.getTableHandle(session, "remote_db", "remote_tbl")) + .thenReturn(Optional.empty()); + + TFetchSchemaTableDataResult result = invokeDeal(catalog, table); + + Assertions.assertEquals(TStatusCode.OK, result.getStatus().getStatusCode()); + Assertions.assertEquals(Collections.emptyList(), result.getDataBatch()); + Mockito.verify(metadata, Mockito.never()).listPartitionNames(Mockito.any(), Mockito.any()); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/tablefunction/PartitionsTableValuedFunctionPluginDrivenTest.java b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/PartitionsTableValuedFunctionPluginDrivenTest.java new file mode 100644 index 00000000000000..8978bf14f8ab05 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/PartitionsTableValuedFunctionPluginDrivenTest.java @@ -0,0 +1,135 @@ +// 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.tablefunction; + +import org.apache.doris.catalog.DatabaseIf; +import org.apache.doris.catalog.Env; +import org.apache.doris.catalog.TableIf.TableType; +import org.apache.doris.datasource.CatalogMgr; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.mysql.privilege.AccessControllerManager; +import org.apache.doris.mysql.privilege.PrivPredicate; +import org.apache.doris.nereids.exceptions.AnalysisException; +import org.apache.doris.qe.ConnectContext; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Optional; + +/** + * Tests for the {@code partitions()} TVF analyze gates added by FIX-PART-GATES for + * {@link PluginDrivenExternalCatalog} tables (DDL-C1 / CACHE-C1). + * + *

    Why: after the MaxCompute SPI cutover the catalog is a PluginDrivenExternalCatalog + * and its tables are PLUGIN_EXTERNAL_TABLE; the TVF's catalog allow-list and table-type allow-list + * previously rejected both at analyze time, making the (already-wired) BE handler dead code. These + * tests drive the private {@code analyze()} to lock that a partitioned PluginDriven table passes + * both gates, while a non-partitioned one is rejected with the legacy message.

    + * + *

    The Batch-D red line (the {@code MaxComputeExternalCatalog} branch must remain) is not deleted + * by this change; the PluginDriven branch is added alongside it.

    + */ +public class PartitionsTableValuedFunctionPluginDrivenTest { + + @Test + public void testAnalyzePassesForPartitionedPluginDrivenTable() throws Exception { + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(table.getType()).thenReturn(TableType.PLUGIN_EXTERNAL_TABLE); + Mockito.when(table.isPartitionedTable()).thenReturn(true); + + // No exception means the PluginDriven catalog passed the catalog allow-list (SEAM 1) and the + // PLUGIN_EXTERNAL_TABLE passed the REAL table-type allow-list (SEAM 2 -- see invokeAnalyze, + // which runs the genuine DatabaseIf.getTableOrMetaException membership check). + invokeAnalyze(table); + + // WHY (non-vacuous, Rule 9): verifying isPartitionedTable() was actually called proves the + // table was resolved (not null) AND the PluginDriven partition guard (SEAM 3) was reached. + // If table resolution short-circuited (e.g. PLUGIN_EXTERNAL_TABLE removed from the SEAM-2 + // allow-list -> MetaNotFound) or the SEAM-3 branch were deleted, this verify fails. + Mockito.verify(table).isPartitionedTable(); + } + + @Test + public void testAnalyzeThrowsForNonPartitionedPluginDrivenTable() { + PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); + Mockito.when(table.getType()).thenReturn(TableType.PLUGIN_EXTERNAL_TABLE); + Mockito.when(table.isPartitionedTable()).thenReturn(false); + + // WHY: a PluginDriven table with no partition columns must be rejected with the legacy + // "not a partitioned table" message (mirroring the MaxCompute guard). A mutation that drops + // the SEAM 3 guard makes this assertion red. + InvocationTargetException ex = Assertions.assertThrows(InvocationTargetException.class, + () -> invokeAnalyze(table)); + Assertions.assertTrue(ex.getCause() instanceof AnalysisException); + Assertions.assertTrue(ex.getCause().getMessage().contains("is not a partitioned table"), + "expected 'is not a partitioned table', got: " + ex.getCause().getMessage()); + } + + /** + * Drives the private {@code analyze("ctl","db","t")} on a ctor-bypassed instance (analyze uses + * no instance state), with Env/CatalogMgr/AccessManager mocked to resolve a PluginDriven + * catalog + db. + * + *

    The db mock uses {@code CALLS_REAL_METHODS} so the REAL + * {@code DatabaseIf.getTableOrMetaException(name, types...)} default-method allow-list runs + * (SEAM 2): only the single-arg resolver is stubbed to return the table, and {@code + * table.getType()} decides membership. Thus removing {@code PLUGIN_EXTERNAL_TABLE} from the + * production allow-list throws MetaNotFound -> AnalysisException and turns the tests red.

    + */ + private void invokeAnalyze(PluginDrivenExternalTable table) throws Exception { + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + Env env = Mockito.mock(Env.class); + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + + CatalogMgr catalogMgr = Mockito.mock(CatalogMgr.class); + Mockito.when(env.getCatalogMgr()).thenReturn(catalogMgr); + AccessControllerManager accessManager = Mockito.mock(AccessControllerManager.class); + Mockito.when(env.getAccessManager()).thenReturn(accessManager); + Mockito.when(accessManager.checkTblPriv(Mockito.nullable(ConnectContext.class), Mockito.eq("ctl"), + Mockito.eq("db"), Mockito.eq("t"), Mockito.any(PrivPredicate.class))).thenReturn(true); + + PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); + Mockito.when(catalogMgr.getCatalog("ctl")).thenReturn(catalog); + Mockito.when(catalog.isInternalCatalog()).thenReturn(false); + + // CALLS_REAL_METHODS: run the genuine type allow-list (SEAM 2); stub only the single-arg + // resolver so the real membership check at DatabaseIf.getTableOrMetaException(name,List) + // executes against table.getType(). + DatabaseIf db = Mockito.mock(DatabaseIf.class, Mockito.CALLS_REAL_METHODS); + Mockito.doReturn(table).when(db).getTableOrMetaException("t"); + Mockito.doReturn(Optional.of(db)).when(catalog).getDb("db"); + + PartitionsTableValuedFunction tvf = + Mockito.mock(PartitionsTableValuedFunction.class, Mockito.CALLS_REAL_METHODS); + Method analyze = PartitionsTableValuedFunction.class + .getDeclaredMethod("analyze", String.class, String.class, String.class); + analyze.setAccessible(true); + try { + analyze.invoke(tvf, "ctl", "db", "t"); + } catch (InvocationTargetException e) { + throw e; // surface the wrapped AnalysisException to the caller + } + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/transaction/CommitDataSerializerTest.java b/fe/fe-core/src/test/java/org/apache/doris/transaction/CommitDataSerializerTest.java new file mode 100644 index 00000000000000..da2f9767847984 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/transaction/CommitDataSerializerTest.java @@ -0,0 +1,140 @@ +// 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.transaction; + +import org.apache.doris.thrift.TFileContent; +import org.apache.doris.thrift.THivePartitionUpdate; +import org.apache.doris.thrift.TIcebergCommitData; +import org.apache.doris.thrift.TMCCommitData; +import org.apache.doris.thrift.TUpdateMode; + +import org.apache.thrift.TBase; +import org.apache.thrift.TDeserializer; +import org.apache.thrift.TSerializer; +import org.apache.thrift.protocol.TBinaryProtocol; +import org.junit.Assert; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Golden-equivalence tests for {@link CommitDataSerializer} and the write + * transactions' {@code addCommitData} overrides (W-phase W3 / W6). + * + *

    These pin the contract that the refactored hot path + * (serialize each Thrift commit fragment with {@link TBinaryProtocol} → + * {@link Transaction#addCommitData(byte[])} → deserialize → accumulate) + * produces exactly the same accumulated commit state as the legacy + * concrete-cast path (whole-list {@code updateXxxCommitData}).

    + * + *

    The serialization protocol is the red line: the producer + * ({@link CommitDataSerializer}) and the consumers (each transaction's + * {@code addCommitData}) must agree on {@link TBinaryProtocol}. A protocol + * mismatch corrupts the round trip and fails these tests.

    + */ +public class CommitDataSerializerTest { + + private static TMCCommitData mcData(String session, long rowCount, String commitMessage) { + return new TMCCommitData() + .setSessionId(session) + .setRowCount(rowCount) + .setWrittenBytes(rowCount * 8) + .setCommitMessage(commitMessage); + } + + private static THivePartitionUpdate hiveData(String name, long rowCount, String... fileNames) { + return new THivePartitionUpdate() + .setName(name) + .setUpdateMode(TUpdateMode.APPEND) + .setRowCount(rowCount) + .setFileSize(rowCount * 16) + .setFileNames(Arrays.asList(fileNames)); + } + + private static TIcebergCommitData icebergData(String filePath, long rowCount) { + return new TIcebergCommitData() + .setFilePath(filePath) + .setRowCount(rowCount) + .setFileSize(rowCount * 32) + .setFileContent(TFileContent.DATA) + .setPartitionValues(Arrays.asList("2026", "06")); + } + + private static void assertBinaryRoundTrip(TBase original, TBase target) + throws Exception { + byte[] bytes = new TSerializer(new TBinaryProtocol.Factory()).serialize(original); + new TDeserializer(new TBinaryProtocol.Factory()).deserialize(target, bytes); + Assert.assertEquals(original, target); + } + + /** + * The serialization protocol is binary and lossless for every field of each + * commit-payload struct. This is the contract {@link CommitDataSerializer} and + * the {@code addCommitData} overrides both depend on. + */ + @Test + public void binaryProtocolRoundTripIsLossless() throws Exception { + assertBinaryRoundTrip(mcData("session-1", 42L, "bWMtcGF5bG9hZA=="), new TMCCommitData()); + assertBinaryRoundTrip(hiveData("dt=2026-06-06", 7L, "f1", "f2"), new THivePartitionUpdate()); + assertBinaryRoundTrip(icebergData("s3://b/data/0.parquet", 11L), new TIcebergCommitData()); + } + + /** + * Iceberg: {@link CommitDataSerializer#feed} delivers exactly one + * {@link Transaction#addCommitData(byte[])} call per input fragment, in input + * order, and each payload deserializes losslessly (via {@link TBinaryProtocol}, + * the same protocol the consuming transactions use) back to the original + * {@link TIcebergCommitData}. + */ + @Test + public void icebergFeedDeliversEachFragmentLosslessly() throws Exception { + List input = Arrays.asList( + icebergData("s3://b/data/0.parquet", 11L), + icebergData("s3://b/data/1.parquet", 13L)); + + List payloads = new ArrayList<>(); + Transaction collector = new Transaction() { + @Override + public void commit() { + throw new UnsupportedOperationException("commit not expected in this test"); + } + + @Override + public void rollback() { + throw new UnsupportedOperationException("rollback not expected in this test"); + } + + @Override + public void addCommitData(byte[] commitFragment) { + payloads.add(commitFragment); + } + }; + + CommitDataSerializer.feed(collector, input); + + Assert.assertEquals(input.size(), payloads.size()); + for (int i = 0; i < input.size(); i++) { + TIcebergCommitData roundTripped = new TIcebergCommitData(); + new TDeserializer(new TBinaryProtocol.Factory()).deserialize(roundTripped, payloads.get(i)); + Assert.assertEquals(input.get(i), roundTripped); + } + } + +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/transaction/PluginDrivenTransactionManagerTest.java b/fe/fe-core/src/test/java/org/apache/doris/transaction/PluginDrivenTransactionManagerTest.java new file mode 100644 index 00000000000000..c93d6fefeb3917 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/transaction/PluginDrivenTransactionManagerTest.java @@ -0,0 +1,241 @@ +// 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.transaction; + +import org.apache.doris.catalog.Env; +import org.apache.doris.common.UserException; +import org.apache.doris.connector.api.handle.ConnectorTransaction; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +/** + * Delegation tests for {@link PluginDrivenTransactionManager} and its internal + * {@code PluginDrivenTransaction} bridge (W-phase W4). + * + *

    When a connector supplies a real SPI {@link ConnectorTransaction}, the + * fe-core {@link Transaction} write callbacks ({@code addCommitData} / + * {@code supportsWriteBlockAllocation} / {@code allocateWriteBlockRange} / + * {@code getUpdateCnt}) must delegate to it, so that the generic write + * orchestration (which after W3 only sees the polymorphic {@link Transaction}) + * reaches the connector. The legacy no-op marker (no connector transaction) + * must keep inheriting the inert interface defaults.

    + */ +public class PluginDrivenTransactionManagerTest { + + /** Hand-written {@link ConnectorTransaction} test double recording delegated calls. */ + private static final class RecordingConnectorTransaction implements ConnectorTransaction { + private final long txnId; + private final List commitFragments = new ArrayList<>(); + private boolean supportsBlockAllocation; + private long blockRangeStart; + private String lastWriteSessionId; + private long lastCount; + private long updateCnt; + private boolean failOnCommit; + + private RecordingConnectorTransaction(long txnId) { + this.txnId = txnId; + } + + @Override + public long getTransactionId() { + return txnId; + } + + @Override + public void commit() { + if (failOnCommit) { + throw new RuntimeException("connector commit failed"); + } + } + + @Override + public void rollback() { + } + + @Override + public void close() { + } + + @Override + public void addCommitData(byte[] commitFragment) { + commitFragments.add(commitFragment); + } + + @Override + public boolean supportsWriteBlockAllocation() { + return supportsBlockAllocation; + } + + @Override + public long allocateWriteBlockRange(String writeSessionId, long count) { + this.lastWriteSessionId = writeSessionId; + this.lastCount = count; + return blockRangeStart; + } + + @Override + public long getUpdateCnt() { + return updateCnt; + } + } + + @Test + public void addCommitDataIsDelegatedToConnectorTransaction() throws UserException { + PluginDrivenTransactionManager manager = new PluginDrivenTransactionManager(); + RecordingConnectorTransaction connectorTx = new RecordingConnectorTransaction(777L); + long txnId = manager.begin(connectorTx); + + byte[] fragment = {1, 2, 3}; + manager.getTransaction(txnId).addCommitData(fragment); + + Assert.assertEquals(1, connectorTx.commitFragments.size()); + Assert.assertSame(fragment, connectorTx.commitFragments.get(0)); + } + + @Test + public void supportsWriteBlockAllocationIsDelegated() throws UserException { + PluginDrivenTransactionManager manager = new PluginDrivenTransactionManager(); + RecordingConnectorTransaction connectorTx = new RecordingConnectorTransaction(778L); + connectorTx.supportsBlockAllocation = true; + long txnId = manager.begin(connectorTx); + + Assert.assertTrue(manager.getTransaction(txnId).supportsWriteBlockAllocation()); + } + + @Test + public void allocateWriteBlockRangeIsDelegated() throws UserException { + PluginDrivenTransactionManager manager = new PluginDrivenTransactionManager(); + RecordingConnectorTransaction connectorTx = new RecordingConnectorTransaction(779L); + connectorTx.blockRangeStart = 100L; + long txnId = manager.begin(connectorTx); + + long start = manager.getTransaction(txnId).allocateWriteBlockRange("write-session-x", 5L); + + Assert.assertEquals(100L, start); + Assert.assertEquals("write-session-x", connectorTx.lastWriteSessionId); + Assert.assertEquals(5L, connectorTx.lastCount); + } + + @Test + public void getUpdateCntIsDelegated() throws UserException { + PluginDrivenTransactionManager manager = new PluginDrivenTransactionManager(); + RecordingConnectorTransaction connectorTx = new RecordingConnectorTransaction(780L); + connectorTx.updateCnt = 42L; + long txnId = manager.begin(connectorTx); + + Assert.assertEquals(42L, manager.getTransaction(txnId).getUpdateCnt()); + } + + @Test + public void legacyMarkerKeepsInertWriteDefaults() throws UserException { + PluginDrivenTransactionManager manager = new PluginDrivenTransactionManager(); + long txnId = manager.begin(); + Transaction txn = manager.getTransaction(txnId); + + // The legacy no-op marker (null connector transaction) must stay inert, + // matching the interface defaults: addCommitData is a silent no-op, + // block allocation is unsupported, and the update count is zero. + txn.addCommitData(new byte[] {9}); + Assert.assertFalse(txn.supportsWriteBlockAllocation()); + Assert.assertEquals(0L, txn.getUpdateCnt()); + try { + txn.allocateWriteBlockRange("none", 1L); + Assert.fail("expected UnsupportedOperationException for the legacy marker"); + } catch (UnsupportedOperationException expected) { + // legacy marker does not support write block allocation + } + } + + // ──────────── global registration (P4-T06a W-d / gap G3) ──────────── + // + // begin(ConnectorTransaction) must also register the txn in the process-wide + // GlobalExternalTransactionInfoMgr, because the BE block-allocation RPC and the + // commit-data feedback look it up there by id (FrontendServiceImpl + // .getMaxComputeBlockIdRange -> getTxnById). Without it those callbacks throw + // "Can't find txn". commit/rollback must deregister so the registry cannot leak. + // (Distinct ids 90001+ avoid colliding with the delegation tests above, which + // intentionally never commit and therefore leave their ids registered.) + + @Test + public void beginRegistersConnectorTransactionInGlobalRegistry() throws UserException { + PluginDrivenTransactionManager manager = new PluginDrivenTransactionManager(); + long txnId = manager.begin(new RecordingConnectorTransaction(90001L)); + try { + Transaction registered = + Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().getTxnById(txnId); + Assert.assertSame("global registry must hold the same wrapped transaction the " + + "manager hands out", manager.getTransaction(txnId), registered); + } finally { + // do not leak the id into the shared global registry + manager.commit(txnId); + } + } + + @Test + public void commitDeregistersFromGlobalRegistry() throws UserException { + PluginDrivenTransactionManager manager = new PluginDrivenTransactionManager(); + long txnId = manager.begin(new RecordingConnectorTransaction(90002L)); + + manager.commit(txnId); + + assertNotRegistered(txnId); + } + + @Test + public void rollbackDeregistersFromGlobalRegistry() throws UserException { + PluginDrivenTransactionManager manager = new PluginDrivenTransactionManager(); + long txnId = manager.begin(new RecordingConnectorTransaction(90003L)); + + manager.rollback(txnId); + + assertNotRegistered(txnId); + } + + @Test + public void commitStillDeregistersWhenConnectorCommitThrows() { + PluginDrivenTransactionManager manager = new PluginDrivenTransactionManager(); + RecordingConnectorTransaction connectorTx = new RecordingConnectorTransaction(90004L); + connectorTx.failOnCommit = true; + long txnId = manager.begin(connectorTx); + + try { + manager.commit(txnId); + Assert.fail("commit must propagate the connector failure"); + } catch (Exception expected) { + // the connector's commit failure propagates to the caller + } + + // commit() wraps deregistration in try/finally, so a failed connector commit must + // not leave a stale entry behind (mirrors rollback()). + assertNotRegistered(txnId); + } + + private static void assertNotRegistered(long txnId) { + try { + Env.getCurrentEnv().getGlobalExternalTransactionInfoMgr().getTxnById(txnId); + Assert.fail("txn " + txnId + " should have been deregistered from the global registry"); + } catch (RuntimeException expected) { + // getTxnById throws "Can't find txn for " once the entry is gone + } + } +} diff --git a/fe/fe-filesystem/README.md b/fe/fe-filesystem/README.md index 8b1a069f4b46d1..eb8e34ff06631a 100644 --- a/fe/fe-filesystem/README.md +++ b/fe/fe-filesystem/README.md @@ -56,7 +56,7 @@ fe-filesystem/ (aggregator POM — no Java code) dependencies). Defines `FileSystem`, `Location`, `FileEntry`, `DorisInputFile`, `DorisOutputFile`, etc. * **SPI** — The `FileSystemProvider` interface (extends `PluginFactory` from `fe-extension-spi`) - plus the object-storage layer (`ObjStorage`, `ObjFileSystem`, `HadoopAuthenticator`). Also + plus the object-storage layer (`ObjStorage`, `ObjFileSystem`). Also compiled into `fe-core`. * **IMPL** — Concrete backends. Each one depends on `fe-filesystem-spi` (and transitively on `fe-filesystem-api`). S3-delegating backends (OSS, COS, OBS) also depend on `fe-filesystem-s3` diff --git a/fe/fe-filesystem/fe-filesystem-api/src/main/java/org/apache/doris/filesystem/FileSystem.java b/fe/fe-filesystem/fe-filesystem-api/src/main/java/org/apache/doris/filesystem/FileSystem.java index d4f194b4662a53..9f1225f1f513d4 100644 --- a/fe/fe-filesystem/fe-filesystem-api/src/main/java/org/apache/doris/filesystem/FileSystem.java +++ b/fe/fe-filesystem/fe-filesystem-api/src/main/java/org/apache/doris/filesystem/FileSystem.java @@ -56,6 +56,21 @@ default T requireCapability(Class capabilityType) { getClass().getSimpleName() + " does not support " + capabilityType.getSimpleName())); } + /** + * Resolves the concrete {@link FileSystem} that actually serves {@code location}. + * + *

    A plain, single-backend filesystem is the filesystem for every location, so the + * default returns {@code this}. A scheme-routing filesystem (e.g. {@code SpiSwitchingFileSystem}) + * overrides this to return the per-scheme delegate the location maps to. + * + *

    Callers use this when they need the concrete implementation rather than the routing + * facade — for example to test {@code instanceof ObjFileSystem} before driving an object-store + * multipart upload, which a routing facade cannot answer without knowing the location. + */ + default FileSystem forLocation(Location location) throws IOException { + return this; + } + boolean exists(Location location) throws IOException; void mkdirs(Location location) throws IOException; diff --git a/fe/fe-filesystem/fe-filesystem-api/src/test/java/org/apache/doris/filesystem/FileSystemDefaultMethodsTest.java b/fe/fe-filesystem/fe-filesystem-api/src/test/java/org/apache/doris/filesystem/FileSystemDefaultMethodsTest.java index 48edadfc56f0b3..272e2735c840ef 100644 --- a/fe/fe-filesystem/fe-filesystem-api/src/test/java/org/apache/doris/filesystem/FileSystemDefaultMethodsTest.java +++ b/fe/fe-filesystem/fe-filesystem-api/src/test/java/org/apache/doris/filesystem/FileSystemDefaultMethodsTest.java @@ -220,4 +220,16 @@ void globListWithLimitThrowsUnsupportedByDefault() { Assertions.assertThrows(UnsupportedOperationException.class, () -> fs.globListWithLimit(Location.of("s3://b/*"), null, 0, 0)); } + + // --- forLocation --- + + @Test + void forLocationDefaultReturnsThis() throws IOException { + // A plain (non-routing) filesystem is its own per-location filesystem. This contract lets + // callers resolve the concrete impl (e.g. for an instanceof ObjFileSystem check) uniformly, + // whether the handle is a single-backend FS or a scheme-routing facade. + StubFileSystem fs = new StubFileSystem(List.of()); + Assertions.assertSame(fs, fs.forLocation(Location.of("s3://b/dir/a.csv"))); + Assertions.assertSame(fs, fs.forLocation(Location.of("hdfs://ns/user/a"))); + } } diff --git a/fe/fe-filesystem/fe-filesystem-azure/src/main/assembly/plugin-zip.xml b/fe/fe-filesystem/fe-filesystem-azure/src/main/assembly/plugin-zip.xml index c706cc04711e7a..d298df8a81d7c9 100644 --- a/fe/fe-filesystem/fe-filesystem-azure/src/main/assembly/plugin-zip.xml +++ b/fe/fe-filesystem/fe-filesystem-azure/src/main/assembly/plugin-zip.xml @@ -55,6 +55,11 @@ under the License. /lib false runtime + + + org.apache.logging.log4j:* + org.slf4j:* + diff --git a/fe/fe-filesystem/fe-filesystem-broker/src/main/assembly/plugin-zip.xml b/fe/fe-filesystem/fe-filesystem-broker/src/main/assembly/plugin-zip.xml index e65d143ff9b7d2..a9e03c9be9902c 100644 --- a/fe/fe-filesystem/fe-filesystem-broker/src/main/assembly/plugin-zip.xml +++ b/fe/fe-filesystem/fe-filesystem-broker/src/main/assembly/plugin-zip.xml @@ -59,6 +59,9 @@ under the License. org.apache.doris:fe-filesystem-api org.apache.doris:fe-filesystem-spi org.apache.doris:fe-extension-spi + + org.apache.logging.log4j:* + org.slf4j:* diff --git a/fe/fe-filesystem/fe-filesystem-cos/src/main/assembly/plugin-zip.xml b/fe/fe-filesystem/fe-filesystem-cos/src/main/assembly/plugin-zip.xml index 0b9a3bf90626a6..f8175a15e32e9a 100644 --- a/fe/fe-filesystem/fe-filesystem-cos/src/main/assembly/plugin-zip.xml +++ b/fe/fe-filesystem/fe-filesystem-cos/src/main/assembly/plugin-zip.xml @@ -54,6 +54,11 @@ under the License. /lib false runtime + + + org.apache.logging.log4j:* + org.slf4j:* + diff --git a/fe/fe-filesystem/fe-filesystem-cos/src/main/java/org/apache/doris/filesystem/cos/CosFileSystemProperties.java b/fe/fe-filesystem/fe-filesystem-cos/src/main/java/org/apache/doris/filesystem/cos/CosFileSystemProperties.java index 8a70465c6b3985..cb25b52508c22a 100644 --- a/fe/fe-filesystem/fe-filesystem-cos/src/main/java/org/apache/doris/filesystem/cos/CosFileSystemProperties.java +++ b/fe/fe-filesystem/fe-filesystem-cos/src/main/java/org/apache/doris/filesystem/cos/CosFileSystemProperties.java @@ -237,6 +237,12 @@ private Map toBackendKv() { kv.put("AWS_REQUEST_TIMEOUT_MS", requestTimeoutMs); kv.put("AWS_CONNECTION_TIMEOUT_MS", connectionTimeoutMs); kv.put("use_path_style", usePathStyle); + // Mirror fe-core AbstractS3CompatibleProperties#getAwsCredentialsProviderTypeForBackend: + // anonymous access (no static credentials) emits ANONYMOUS; otherwise the key is omitted so + // BE uses SimpleAWSCredentialsProvider. COS never configures a provider type explicitly. + if (StringUtils.isBlank(accessKey) && StringUtils.isBlank(secretKey)) { + kv.put("AWS_CREDENTIALS_PROVIDER_TYPE", "ANONYMOUS"); + } return Collections.unmodifiableMap(kv); } diff --git a/fe/fe-filesystem/fe-filesystem-cos/src/test/java/org/apache/doris/filesystem/cos/CosFileSystemPropertiesTest.java b/fe/fe-filesystem/fe-filesystem-cos/src/test/java/org/apache/doris/filesystem/cos/CosFileSystemPropertiesTest.java index b9c39b58ae4685..0605b6db1e0061 100644 --- a/fe/fe-filesystem/fe-filesystem-cos/src/test/java/org/apache/doris/filesystem/cos/CosFileSystemPropertiesTest.java +++ b/fe/fe-filesystem/fe-filesystem-cos/src/test/java/org/apache/doris/filesystem/cos/CosFileSystemPropertiesTest.java @@ -122,6 +122,39 @@ void toBackendProperties_returnsOnlyAwsCompatibleKeysForBeAdapters() { Assertions.assertEquals("cos-bucket", backendMap.get("AWS_BUCKET")); Assertions.assertEquals("cos-role", backendMap.get("AWS_ROLE_ARN")); Assertions.assertFalse(backendMap.keySet().stream().anyMatch(key -> key.startsWith("COS_"))); + // Parity with fe-core AbstractS3CompatibleProperties#getAwsCredentialsProviderTypeForBackend: + // when static credentials are present the type is omitted (BE uses SimpleAWSCredentialsProvider). + Assertions.assertNull(backendMap.get("AWS_CREDENTIALS_PROVIDER_TYPE")); + } + + @Test + void toBackendProperties_emitsAnonymousProviderTypeWhenNoStaticCredentials() { + CosFileSystemProperties properties = CosFileSystemProperties.of(Map.of( + "cos.endpoint", "https://cos.ap-guangzhou.myqcloud.com")); + + Map backendMap = properties.toBackendProperties().orElseThrow().toMap(); + + // Parity with fe-core AbstractS3CompatibleProperties#getAwsCredentialsProviderTypeForBackend: + // both access key and secret key blank => anonymous access. + Assertions.assertEquals("ANONYMOUS", backendMap.get("AWS_CREDENTIALS_PROVIDER_TYPE")); + } + + @Test + void toMaps_emitCosTuningDefaultsWhenNotConfigured() { + CosFileSystemProperties properties = CosFileSystemProperties.of(Map.of( + "cos.endpoint", "https://cos.ap-guangzhou.myqcloud.com")); + + // Parity with fe-core COSProperties defaults (100 / 10000 / 10000). Literal expected values + // (not DEFAULT_* constants) so that mutating a default in the main class fails this guard. + Map beKv = properties.toMap(); + Assertions.assertEquals("100", beKv.get("AWS_MAX_CONNECTIONS")); + Assertions.assertEquals("10000", beKv.get("AWS_REQUEST_TIMEOUT_MS")); + Assertions.assertEquals("10000", beKv.get("AWS_CONNECTION_TIMEOUT_MS")); + + Map hadoopKv = properties.toHadoopConfigurationMap(); + Assertions.assertEquals("100", hadoopKv.get("fs.s3a.connection.maximum")); + Assertions.assertEquals("10000", hadoopKv.get("fs.s3a.connection.request.timeout")); + Assertions.assertEquals("10000", hadoopKv.get("fs.s3a.connection.timeout")); } @Test diff --git a/fe/fe-filesystem/fe-filesystem-hdfs-base/pom.xml b/fe/fe-filesystem/fe-filesystem-hdfs-base/pom.xml index cac69823ff62a3..61a8151edb3c82 100644 --- a/fe/fe-filesystem/fe-filesystem-hdfs-base/pom.xml +++ b/fe/fe-filesystem/fe-filesystem-hdfs-base/pom.xml @@ -58,6 +58,15 @@ under the License. fe-foundation ${revision} + + + + org.apache.doris + fe-kerberos + ${revision} + + org.projectlombok lombok diff --git a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/DFSFileSystem.java b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/DFSFileSystem.java index 9ae077801c4f91..305efc7c684006 100644 --- a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/DFSFileSystem.java +++ b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/DFSFileSystem.java @@ -23,7 +23,12 @@ import org.apache.doris.filesystem.FileIterator; import org.apache.doris.filesystem.GlobListing; import org.apache.doris.filesystem.Location; -import org.apache.doris.filesystem.spi.HadoopAuthenticator; +import org.apache.doris.kerberos.AuthenticationConfig; +import org.apache.doris.kerberos.HadoopAuthenticator; +import org.apache.doris.kerberos.HadoopKerberosAuthenticator; +import org.apache.doris.kerberos.HadoopSimpleAuthenticator; +import org.apache.doris.kerberos.KerberosAuthenticationConfig; +import org.apache.doris.kerberos.SimpleAuthenticationConfig; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileStatus; @@ -64,15 +69,38 @@ public class DFSFileSystem implements org.apache.doris.filesystem.FileSystem { public DFSFileSystem(Map properties) { this.properties = Collections.unmodifiableMap(new HashMap<>(properties)); this.conf = HdfsConfigBuilder.build(properties); + this.authenticator = buildAuthenticator(properties, conf); + } + + /** + * Builds the {@link HadoopAuthenticator} for the given HDFS catalog properties using the + * shared {@code fe-kerberos} authenticators (single source of truth; P3b consolidation — + * the former fe-filesystem-hdfs copies were removed). The kerberos-vs-simple decision is + * unchanged: principal + keytab present selects {@link HadoopKerberosAuthenticator}, + * otherwise {@link HadoopSimpleAuthenticator}. Kerberos login happens lazily on first + * {@code getUGI()}; the simple path executes as {@code hadoop.username} (defaulting to + * remote user {@code "hadoop"}). + */ + static HadoopAuthenticator buildAuthenticator(Map properties, Configuration conf) { if (HdfsConfigBuilder.isKerberosEnabled(properties)) { - this.authenticator = new KerberosHadoopAuthenticator( + boolean debug = Boolean.parseBoolean( + properties.getOrDefault(AuthenticationConfig.DORIS_KRB5_DEBUG, "false")); + return new HadoopKerberosAuthenticator(new KerberosAuthenticationConfig( properties.get(HdfsConfigBuilder.KEY_PRINCIPAL), properties.get(HdfsConfigBuilder.KEY_KEYTAB), - conf); - } else { - this.authenticator = new SimpleHadoopAuthenticator( - properties.get("hadoop.username")); + conf, + debug)); + } + SimpleAuthenticationConfig simpleConfig = new SimpleAuthenticationConfig(); + // Treat absent OR empty hadoop.username uniformly: leave it null so the shared + // authenticator defaults to remote user "hadoop". Passing "" straight through would + // reach UserGroupInformation.createRemoteUser(""), which rejects an empty user with + // IllegalArgumentException (the removed hdfs copy guarded this with !isEmpty()). + String hadoopUsername = properties.get("hadoop.username"); + if (hadoopUsername != null && !hadoopUsername.isEmpty()) { + simpleConfig.setUsername(hadoopUsername); } + return new HadoopSimpleAuthenticator(simpleConfig); } private org.apache.hadoop.fs.FileSystem getHadoopFs(Path path) throws IOException { diff --git a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/HdfsConfigBuilder.java b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/HdfsConfigBuilder.java index 5ae6180907be5b..df4ee9d91aa371 100644 --- a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/HdfsConfigBuilder.java +++ b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/HdfsConfigBuilder.java @@ -51,6 +51,17 @@ private HdfsConfigBuilder() { */ public static Configuration build(Map properties) { Configuration conf = new HdfsConfiguration(); + // Pin the conf classloader to this plugin's loader, mirroring HmsConfHelper.createHiveConf and the + // connector conf builders (Paimon/Iceberg/Hive/Hudi). new HdfsConfiguration() captures the LIVE + // thread-context classloader into the conf's OWN classLoader field. DFSFileSystem is built lazily on + // first HDFS access, which runs under a connector's plugin loader (PluginDrivenScanNode.onPluginClassLoader + // pins the TCCL there for the scan), so the captured CL would be that connector loader. Hadoop then + // resolves impl classes via Configuration.getClass using this field, NOT the live TCCL: e.g. + // RPC.getProtocolEngine loads ProtobufRpcEngine2 through it. Left unpinned that yields the connector's + // hadoop-common copy of ProtobufRpcEngine2 while RPC/RpcEngine come from the engine copy, giving + // "class ProtobufRpcEngine2 cannot be cast to class RpcEngine". DFSFileSystem.getHadoopFs pins the live + // TCCL for FileSystem.get (ServiceLoader discovery) but cannot fix this conf-cached CL. + conf.setClassLoader(HdfsConfigBuilder.class.getClassLoader()); conf.setBoolean("fs.hdfs.impl.disable.cache", true); conf.setBoolean("fs.AbstractFileSystem.hdfs.impl.disable.cache", true); for (String scheme : CACHE_DISABLE_SCHEMES) { diff --git a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/HdfsFileIterator.java b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/HdfsFileIterator.java index a32a415ee7007a..3360138ecb48cf 100644 --- a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/HdfsFileIterator.java +++ b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/HdfsFileIterator.java @@ -20,7 +20,7 @@ import org.apache.doris.filesystem.FileEntry; import org.apache.doris.filesystem.FileIterator; import org.apache.doris.filesystem.Location; -import org.apache.doris.filesystem.spi.HadoopAuthenticator; +import org.apache.doris.kerberos.HadoopAuthenticator; import org.apache.hadoop.fs.FileStatus; import org.apache.hadoop.fs.RemoteIterator; diff --git a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/HdfsInputFile.java b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/HdfsInputFile.java index 7ca8d4d149cf1a..1a0623482f10e0 100644 --- a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/HdfsInputFile.java +++ b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/HdfsInputFile.java @@ -20,7 +20,7 @@ import org.apache.doris.filesystem.DorisInputFile; import org.apache.doris.filesystem.DorisInputStream; import org.apache.doris.filesystem.Location; -import org.apache.doris.filesystem.spi.HadoopAuthenticator; +import org.apache.doris.kerberos.HadoopAuthenticator; import org.apache.hadoop.fs.FSDataInputStream; import org.apache.hadoop.fs.Path; diff --git a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/HdfsOutputFile.java b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/HdfsOutputFile.java index a2e8940acd8f8c..af22c0161d17e6 100644 --- a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/HdfsOutputFile.java +++ b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/HdfsOutputFile.java @@ -19,7 +19,7 @@ import org.apache.doris.filesystem.DorisOutputFile; import org.apache.doris.filesystem.Location; -import org.apache.doris.filesystem.spi.HadoopAuthenticator; +import org.apache.doris.kerberos.HadoopAuthenticator; import org.apache.hadoop.fs.Path; diff --git a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/KerberosHadoopAuthenticator.java b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/KerberosHadoopAuthenticator.java deleted file mode 100644 index 5a38eb85f76662..00000000000000 --- a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/KerberosHadoopAuthenticator.java +++ /dev/null @@ -1,231 +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. -// This file is based on code available under the Apache license here: -// https://github.com/trinodb/trino/blob/435/lib/trino-plugin-toolkit/src/main/java/io/trino/plugin/base/authentication/KerberosAuthentication.java -// https://github.com/trinodb/trino/blob/435/lib/trino-hdfs/src/main/java/io/trino/hdfs/authentication/CachingKerberosHadoopAuthentication.java -// and modified by Doris - -package org.apache.doris.filesystem.hdfs; - -import org.apache.doris.filesystem.spi.HadoopAuthenticator; -import org.apache.doris.filesystem.spi.IOCallable; -import org.apache.doris.foundation.security.KerberosTicketUtils; - -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.security.SecurityUtil; -import org.apache.hadoop.security.UserGroupInformation; -import org.apache.hadoop.security.UserGroupInformation.AuthenticationMethod; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.io.IOException; -import java.security.PrivilegedExceptionAction; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import javax.security.auth.Subject; -import javax.security.auth.kerberos.KerberosPrincipal; -import javax.security.auth.login.AppConfigurationEntry; -import javax.security.auth.login.LoginContext; -import javax.security.auth.login.LoginException; - -/** - * Kerberos-based implementation of {@link HadoopAuthenticator}. - * - *

    Logs in from a keytab via an explicit JAAS {@code Krb5LoginModule} configuration - * ({@code doNotPrompt=true}) and proactively refreshes the TGT once it passes 80% of - * its lifetime, swapping the new credentials into the existing Subject in place. This - * is a port of trino's {@code KerberosAuthentication} + - * {@code CachingKerberosHadoopAuthentication}. It deliberately does NOT use - * {@code UserGroupInformation.checkTGTAndReloginFromKeytab()}: its hard-coded - * 60-second relogin throttle can leave an expired TGT in the Subject, after which the - * SASL/GSS layer falls back to the JVM-default interactive JAAS login and fails with - * "LoginException: Cannot read from System.in". - * - *

    Note: {@link UserGroupInformation#setConfiguration(Configuration)} mutates - * JVM-global state — all UGI instances in the process share the same authentication - * mode. Multiple HDFS catalogs with differing {@code hadoop.security.authentication} - * values therefore cannot truly coexist: the first writer wins. We serialise the - * setup via {@link #UGI_INIT_LOCK} and skip the {@code setConfiguration} call when - * the existing auth method already matches, so concurrent Kerberos catalogs don't - * stomp on each other. When modes disagree a WARN is logged so operators notice. - */ -public class KerberosHadoopAuthenticator implements HadoopAuthenticator { - - private static final Logger LOG = LogManager.getLogger(KerberosHadoopAuthenticator.class); - - /** Process-wide lock for serialising UGI static setup. */ - private static final Object UGI_INIT_LOCK = new Object(); - - private final String principal; - private final String keytab; - - // The Subject/UGI pair is created once and never replaced: refreshes swap new - // Kerberos credentials into this same Subject so Hadoop code that caches the - // UGI (e.g. DFSClient) transparently sees the new ticket. - private final Subject subject; - private final UserGroupInformation ugi; - // Guarded by "this" (only touched in the constructor and synchronized getUGI()). - private long nextRefreshTime; - - public KerberosHadoopAuthenticator(String principal, String keytab, Configuration conf) { - this.principal = principal; - this.keytab = keytab; - try { - synchronized (UGI_INIT_LOCK) { - AuthenticationMethod desired = SecurityUtil.getAuthenticationMethod(conf); - if (!shouldSkipSetConfiguration(desired)) { - UserGroupInformation.setConfiguration(conf); - } - this.subject = loginSubject(); - this.ugi = Objects.requireNonNull(UserGroupInformation.getUGIFromSubject(subject), - "getUGIFromSubject returned null"); - } - this.nextRefreshTime = KerberosTicketUtils.getRefreshTime( - KerberosTicketUtils.getTicketGrantingTicket(subject)); - LOG.info("Kerberos login succeeded for principal={}", principal); - } catch (IOException | RuntimeException e) { - throw new RuntimeException("Failed to login with Kerberos principal=" + principal - + ", keytab=" + keytab, e); - } - } - - /** - * Returns true when the JVM-global UGI configuration already matches what this - * authenticator needs, so we can safely skip the {@code setConfiguration} call. - * If security is on but the existing auth mode differs, a WARN is logged and - * we still skip to preserve first-writer-wins semantics — the operator is then - * responsible for aligning catalog configs. - */ - private boolean shouldSkipSetConfiguration(AuthenticationMethod desired) { - if (!UserGroupInformation.isSecurityEnabled()) { - return false; - } - AuthenticationMethod current; - try { - current = UserGroupInformation.getLoginUser().getAuthenticationMethod(); - } catch (IOException e) { - return false; - } - if (current == desired) { - return true; - } - LOG.warn("UGI already configured with authentication={} but this catalog requests {}; " - + "keeping existing JVM-global setting (first-writer-wins).", current, desired); - return true; - } - - @Override - public T doAs(IOCallable action) throws IOException { - UserGroupInformation currentUgi; - try { - currentUgi = getUGI(); - } catch (IOException | RuntimeException e) { - // Keep the SPI's checked-IOException contract: a relogin failure (unchecked - // RuntimeException from the JAAS login) must not escape doAs unchecked. - throw new IOException("Kerberos relogin failed for principal=" + principal, e); - } - try { - return currentUgi.doAs((PrivilegedExceptionAction) action::call); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Kerberos doAs interrupted for principal=" + principal, e); - } - } - - /** - * Returns the cached UGI, first refreshing the TGT if it is past 80% of its - * lifetime. Ported from trino's - * {@code CachingKerberosHadoopAuthentication.getUserGroupInformation()} — note - * there is intentionally no relogin throttle. - * If the refresh login fails, {@code nextRefreshTime} is not advanced, so each - * subsequent call retries the login until the KDC recovers (no backoff) — same - * trade-off as trino. - */ - private synchronized UserGroupInformation getUGI() throws IOException { - if (nextRefreshTime < System.currentTimeMillis()) { - Subject newSubject = loginSubject(); - Objects.requireNonNull(UserGroupInformation.getUGIFromSubject(newSubject), - "getUGIFromSubject returned null"); - // We modify the existing UGI's credentials in-place instead of returning a new UGI - // because some parts of Hadoop code reuse UGI (e.g. DFSClient). - // We also need to clear the old credentials because the JDK assumes that the first - // credential is the TGT, which is not always true. - subject.getPrincipals().addAll(newSubject.getPrincipals()); - Set privateCredentials = subject.getPrivateCredentials(); - synchronized (privateCredentials) { - privateCredentials.clear(); - privateCredentials.addAll(newSubject.getPrivateCredentials()); - } - Set publicCredentials = subject.getPublicCredentials(); - synchronized (publicCredentials) { - publicCredentials.clear(); - publicCredentials.addAll(newSubject.getPublicCredentials()); - } - nextRefreshTime = KerberosTicketUtils.getRefreshTime( - KerberosTicketUtils.getTicketGrantingTicket(newSubject)); - LOG.info("Kerberos ticket refreshed for principal={}, next refresh time={}", - principal, nextRefreshTime); - } - return ugi; - } - - /** - * Performs the JAAS keytab login and returns the logged-in Subject. This is - * trino's {@code KerberosAuthentication.getSubject()} delegate boundary, kept - * as a package-private method so tests can substitute fabricated Subjects. - */ - Subject loginSubject() { - return getSubject(keytab, principal); - } - - private static Subject getSubject(String keytab, String principal) { - Subject subject = new Subject(false, Collections.singleton(new KerberosPrincipal(principal)), - Collections.emptySet(), Collections.emptySet()); - javax.security.auth.login.Configuration conf = getConfiguration(keytab, principal); - try { - LoginContext loginContext = new LoginContext("", subject, null, conf); - loginContext.login(); - return loginContext.getSubject(); - } catch (LoginException e) { - throw new RuntimeException(e); - } - } - - private static javax.security.auth.login.Configuration getConfiguration(String keytab, String principal) { - Map optionsBuilder = new LinkedHashMap<>(); - optionsBuilder.put("doNotPrompt", "true"); - optionsBuilder.put("isInitiator", "true"); - optionsBuilder.put("principal", principal); - optionsBuilder.put("useKeyTab", "true"); - optionsBuilder.put("storeKey", "true"); - optionsBuilder.put("keyTab", keytab); - Map options = Collections.unmodifiableMap(optionsBuilder); - return new javax.security.auth.login.Configuration() { - @Override - public AppConfigurationEntry[] getAppConfigurationEntry(String name) { - return new AppConfigurationEntry[] { - new AppConfigurationEntry( - "com.sun.security.auth.module.Krb5LoginModule", - AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, - options)}; - } - }; - } -} diff --git a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/SimpleHadoopAuthenticator.java b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/SimpleHadoopAuthenticator.java deleted file mode 100644 index 009977323cc0e9..00000000000000 --- a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/main/java/org/apache/doris/filesystem/hdfs/SimpleHadoopAuthenticator.java +++ /dev/null @@ -1,63 +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.filesystem.hdfs; - -import org.apache.doris.filesystem.spi.HadoopAuthenticator; -import org.apache.doris.filesystem.spi.IOCallable; - -import org.apache.hadoop.security.UserGroupInformation; - -import java.io.IOException; -import java.security.PrivilegedExceptionAction; - -/** - * Simple (non-Kerberos) implementation of {@link HadoopAuthenticator}. - * When a {@code hadoopUsername} is provided, wraps all actions inside - * {@link UserGroupInformation#doAs} so that HDFS operations use that - * identity (important for permission checks). Otherwise, executes - * actions directly as the FE process user. - */ -public class SimpleHadoopAuthenticator implements HadoopAuthenticator { - - private final UserGroupInformation ugi; - - public SimpleHadoopAuthenticator() { - this.ugi = null; - } - - public SimpleHadoopAuthenticator(String hadoopUsername) { - if (hadoopUsername != null && !hadoopUsername.isEmpty()) { - this.ugi = UserGroupInformation.createRemoteUser(hadoopUsername); - } else { - this.ugi = null; - } - } - - @Override - public T doAs(IOCallable action) throws IOException { - if (ugi == null) { - return action.call(); - } - try { - return ugi.doAs((PrivilegedExceptionAction) action::call); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Interrupted during HDFS operation as user " + ugi.getUserName(), e); - } - } -} diff --git a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/test/java/org/apache/doris/filesystem/hdfs/HdfsFileIteratorTest.java b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/test/java/org/apache/doris/filesystem/hdfs/HdfsFileIteratorTest.java index dd0a26e8fbd13b..b7527f90018492 100644 --- a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/test/java/org/apache/doris/filesystem/hdfs/HdfsFileIteratorTest.java +++ b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/test/java/org/apache/doris/filesystem/hdfs/HdfsFileIteratorTest.java @@ -17,16 +17,17 @@ package org.apache.doris.filesystem.hdfs; -import org.apache.doris.filesystem.spi.HadoopAuthenticator; -import org.apache.doris.filesystem.spi.IOCallable; +import org.apache.doris.kerberos.HadoopAuthenticator; import org.apache.hadoop.fs.FileStatus; import org.apache.hadoop.fs.RemoteIterator; +import org.apache.hadoop.security.UserGroupInformation; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import java.io.Closeable; import java.io.IOException; +import java.security.PrivilegedExceptionAction; /** * Unit tests for {@link HdfsFileIterator#close()} (Finding #9): @@ -36,8 +37,19 @@ class HdfsFileIteratorTest { private static final HadoopAuthenticator PASSTHROUGH = new HadoopAuthenticator() { @Override - public T doAs(IOCallable action) throws IOException { - return action.call(); + public UserGroupInformation getUGI() { + return null; + } + + @Override + public T doAs(PrivilegedExceptionAction action) throws IOException { + try { + return action.run(); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException(e); + } } }; diff --git a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/test/java/org/apache/doris/filesystem/hdfs/KerberosHadoopAuthenticatorEnvTest.java b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/test/java/org/apache/doris/filesystem/hdfs/KerberosHadoopAuthenticatorEnvTest.java index dcd6da761adaf1..6c409a3056455f 100644 --- a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/test/java/org/apache/doris/filesystem/hdfs/KerberosHadoopAuthenticatorEnvTest.java +++ b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/test/java/org/apache/doris/filesystem/hdfs/KerberosHadoopAuthenticatorEnvTest.java @@ -18,6 +18,8 @@ package org.apache.doris.filesystem.hdfs; import org.apache.doris.filesystem.Location; +import org.apache.doris.kerberos.HadoopKerberosAuthenticator; +import org.apache.doris.kerberos.KerberosAuthenticationConfig; import org.apache.hadoop.conf.Configuration; import org.junit.jupiter.api.Assertions; @@ -30,8 +32,8 @@ import java.util.Map; /** - * Environment-dependent integration tests for {@link KerberosHadoopAuthenticator}. - * Requires a Kerberos KDC and a Kerberized HDFS cluster. + * Environment-dependent integration tests for the shared {@link HadoopKerberosAuthenticator} + * as wired through {@link DFSFileSystem}. Requires a Kerberos KDC and a Kerberized HDFS cluster. */ @Tag("environment") @Tag("kerberos") @@ -58,13 +60,15 @@ private static Configuration kerberosConf() { } @Test - void loginSucceeds() { + void loginSucceeds() throws IOException { String principal = requireEnv("DORIS_FS_TEST_KDC_PRINCIPAL"); String keytab = requireEnv("DORIS_FS_TEST_KDC_KEYTAB"); - KerberosHadoopAuthenticator auth = - new KerberosHadoopAuthenticator(principal, keytab, kerberosConf()); - Assertions.assertNotNull(auth); + HadoopKerberosAuthenticator auth = + new HadoopKerberosAuthenticator(new KerberosAuthenticationConfig( + principal, keytab, kerberosConf(), false)); + // Login is lazy in the shared authenticator: getUGI() triggers the keytab login. + Assertions.assertNotNull(auth.getUGI()); } @Test @@ -72,8 +76,9 @@ void doAsExecutesAction() throws IOException { String principal = requireEnv("DORIS_FS_TEST_KDC_PRINCIPAL"); String keytab = requireEnv("DORIS_FS_TEST_KDC_KEYTAB"); - KerberosHadoopAuthenticator auth = - new KerberosHadoopAuthenticator(principal, keytab, kerberosConf()); + HadoopKerberosAuthenticator auth = + new HadoopKerberosAuthenticator(new KerberosAuthenticationConfig( + principal, keytab, kerberosConf(), false)); String result = auth.doAs(() -> "hello-from-kerberos"); Assertions.assertEquals("hello-from-kerberos", result); } @@ -83,8 +88,9 @@ void doAsPropagatesIOException() { String principal = requireEnv("DORIS_FS_TEST_KDC_PRINCIPAL"); String keytab = requireEnv("DORIS_FS_TEST_KDC_KEYTAB"); - KerberosHadoopAuthenticator auth = - new KerberosHadoopAuthenticator(principal, keytab, kerberosConf()); + HadoopKerberosAuthenticator auth = + new HadoopKerberosAuthenticator(new KerberosAuthenticationConfig( + principal, keytab, kerberosConf(), false)); Assertions.assertThrows(IOException.class, () -> auth.doAs(() -> { throw new IOException("intentional"); })); diff --git a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/test/java/org/apache/doris/filesystem/hdfs/SimpleHadoopAuthenticatorTest.java b/fe/fe-filesystem/fe-filesystem-hdfs-base/src/test/java/org/apache/doris/filesystem/hdfs/SimpleHadoopAuthenticatorTest.java deleted file mode 100644 index 8d1dd3ad82a68f..00000000000000 --- a/fe/fe-filesystem/fe-filesystem-hdfs-base/src/test/java/org/apache/doris/filesystem/hdfs/SimpleHadoopAuthenticatorTest.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.filesystem.hdfs; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.io.IOException; - -class SimpleHadoopAuthenticatorTest { - - @Test - void noUserDoAsExecutesDirectly() throws IOException { - SimpleHadoopAuthenticator auth = new SimpleHadoopAuthenticator(); - String result = auth.doAs(() -> "hello"); - Assertions.assertEquals("hello", result); - } - - @Test - void nullUserDoAsExecutesDirectly() throws IOException { - SimpleHadoopAuthenticator auth = new SimpleHadoopAuthenticator(null); - String result = auth.doAs(() -> "world"); - Assertions.assertEquals("world", result); - } - - @Test - void emptyUserDoAsExecutesDirectly() throws IOException { - SimpleHadoopAuthenticator auth = new SimpleHadoopAuthenticator(""); - int result = auth.doAs(() -> 42); - Assertions.assertEquals(42, result); - } - - @Test - void withUserDoAsExecutesThroughUgi() throws IOException { - SimpleHadoopAuthenticator auth = new SimpleHadoopAuthenticator("testuser"); - String result = auth.doAs(() -> "authenticated"); - Assertions.assertEquals("authenticated", result); - } - - @Test - void doAsPropagatesIOException() { - SimpleHadoopAuthenticator auth = new SimpleHadoopAuthenticator(); - Assertions.assertThrows(IOException.class, () -> auth.doAs(() -> { - throw new IOException("test error"); - })); - } - - @Test - void doAsWithUserPropagatesIOException() { - SimpleHadoopAuthenticator auth = new SimpleHadoopAuthenticator("testuser"); - Assertions.assertThrows(IOException.class, () -> auth.doAs(() -> { - throw new IOException("test error"); - })); - } -} diff --git a/fe/fe-filesystem/fe-filesystem-hdfs/src/main/assembly/plugin-zip.xml b/fe/fe-filesystem/fe-filesystem-hdfs/src/main/assembly/plugin-zip.xml index c238f9ebb069b6..d931c7cd5b1f7a 100644 --- a/fe/fe-filesystem/fe-filesystem-hdfs/src/main/assembly/plugin-zip.xml +++ b/fe/fe-filesystem/fe-filesystem-hdfs/src/main/assembly/plugin-zip.xml @@ -55,6 +55,17 @@ under the License. /lib false runtime + + org.apache.doris:fe-filesystem-api + org.apache.doris:fe-filesystem-spi + org.apache.doris:fe-extension-spi + + io.netty:netty-codec-native-quic + + org.apache.logging.log4j:* + org.slf4j:* + diff --git a/fe/fe-filesystem/fe-filesystem-hdfs/src/main/java/org/apache/doris/filesystem/hdfs/HdfsConfigFileLoader.java b/fe/fe-filesystem/fe-filesystem-hdfs/src/main/java/org/apache/doris/filesystem/hdfs/HdfsConfigFileLoader.java new file mode 100644 index 00000000000000..4514ddbcf560b5 --- /dev/null +++ b/fe/fe-filesystem/fe-filesystem-hdfs/src/main/java/org/apache/doris/filesystem/hdfs/HdfsConfigFileLoader.java @@ -0,0 +1,124 @@ +// 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.filesystem.hdfs; + +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.Path; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +/** + * Loads Hadoop XML configuration files (e.g. {@code hdfs-site.xml} / {@code core-site.xml}) referenced by the + * {@code hadoop.config.resources} property into a key-value map. This mirrors the legacy fe-core + * {@code CatalogConfigFileUtils.loadConfigurationFromHadoopConfDir} but lives in fe-filesystem-hdfs so the + * module stays a leaf that does not depend on fe-core / fe-common. + * + *

    The base directory under which the named resource files are resolved is computed by + * {@link #resolveHadoopConfigDir()}: the operator-configured {@code Config.hadoop_config_dir}, bridged in via + * the {@link #CONFIG_DIR_PROPERTY} system property (a plugin leaf cannot import fe-core {@code Config}), with a + * {@code $DORIS_HOME/plugins/hadoop_conf/} fallback that matches {@code Config.hadoop_config_dir}'s own default. + * hadoop-client is already on this module's classpath, so the Configuration parsing needs no extra dependency. + */ +public final class HdfsConfigFileLoader { + + /** + * System property the engine sets to {@code Config.hadoop_config_dir} so this plugin leaf resolves + * {@code hadoop.config.resources} files under the operator-configured directory. fe-core + * {@code FileSystemFactory.bindAllStorageProperties} sets it before binding; keep the key in sync there. + */ + public static final String CONFIG_DIR_PROPERTY = "doris.hadoop.config.dir"; + + /** + * Optional explicit override (used by tests). When blank, the directory is resolved from + * {@link #CONFIG_DIR_PROPERTY}, falling back to {@code $DORIS_HOME/plugins/hadoop_conf/}. + */ + public static volatile String hadoopConfigDirOverride = null; + + private HdfsConfigFileLoader() { + } + + static String resolveHadoopConfigDir() { + if (StringUtils.isNotBlank(hadoopConfigDirOverride)) { + return hadoopConfigDirOverride; + } + String fromEngine = System.getProperty(CONFIG_DIR_PROPERTY); + if (StringUtils.isNotBlank(fromEngine)) { + return fromEngine; + } + String home = System.getenv("DORIS_HOME"); + if (StringUtils.isBlank(home)) { + home = System.getProperty("doris.home", ""); + } + return home + "/plugins/hadoop_conf/"; + } + + /** + * Loads the comma-separated config files (each resolved under {@link #hadoopConfigDir}) and returns all + * resolved Hadoop configuration entries as a mutable map, WITH Hadoop's built-in defaults loaded + * (the BE receives the full resolved set). Equivalent to {@link #loadConfigMap(String, boolean) + * loadConfigMap(resourcesPath, true)}; kept for the backend key set's byte-parity with the legacy path. + * + * @param resourcesPath comma-separated list of config file names; may be blank + * @return a mutable map of the loaded Hadoop configuration entries (never null) + * @throws IllegalArgumentException if a referenced file is missing + */ + public static Map loadConfigMap(String resourcesPath) { + return loadConfigMap(resourcesPath, true); + } + + /** + * Loads the comma-separated config files into a key-value map. Returns an empty map when + * {@code resourcesPath} is blank. + * + *

    When {@code loadHadoopDefaults} is {@code true} the underlying {@link Configuration} carries + * Hadoop's built-in defaults (core-default.xml) — the full resolved set the BE expects. When + * {@code false} only the named XML files' own keys are returned (no framework defaults): this is the + * FE catalog-create Hadoop-config map, kept defaults-free so it never clobbers a co-bound object-store + * provider's tuned {@code fs.s3a.*} values when merged into a multi-backend catalog Configuration (the + * base {@code Configuration} already supplies every Hadoop default). + * + * @param resourcesPath comma-separated list of config file names; may be blank + * @param loadHadoopDefaults whether to include Hadoop's built-in default resources + * @return a mutable map of the loaded Hadoop configuration entries (never null) + * @throws IllegalArgumentException if a referenced file is missing + */ + public static Map loadConfigMap(String resourcesPath, boolean loadHadoopDefaults) { + Map confMap = new HashMap<>(); + if (StringUtils.isBlank(resourcesPath)) { + return confMap; + } + Configuration conf = new Configuration(loadHadoopDefaults); + String baseDir = resolveHadoopConfigDir(); + for (String resource : resourcesPath.split(",")) { + String resourcePath = baseDir + resource.trim(); + File file = new File(resourcePath); + if (file.exists() && file.isFile()) { + conf.addResource(new Path(file.toURI())); + } else { + throw new IllegalArgumentException("Config resource file does not exist: " + resourcePath); + } + } + for (Map.Entry entry : conf) { + confMap.put(entry.getKey(), entry.getValue()); + } + return confMap; + } +} 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 new file mode 100644 index 00000000000000..532081b701baec --- /dev/null +++ b/fe/fe-filesystem/fe-filesystem-hdfs/src/main/java/org/apache/doris/filesystem/hdfs/HdfsFileSystemProperties.java @@ -0,0 +1,378 @@ +// 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.filesystem.hdfs; + +import org.apache.doris.filesystem.FileSystemType; +import org.apache.doris.filesystem.properties.BackendStorageKind; +import org.apache.doris.filesystem.properties.BackendStorageProperties; +import org.apache.doris.filesystem.properties.FileSystemProperties; +import org.apache.doris.filesystem.properties.HadoopStorageProperties; +import org.apache.doris.filesystem.properties.StorageKind; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; +import org.apache.doris.foundation.property.ConnectorProperty; + +import org.apache.commons.lang3.StringUtils; + +import java.lang.reflect.Field; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * 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 + * 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). + * + *

    The backend key set is a faithful port of the legacy fe-core + * {@code org.apache.doris.datasource.property.storage.HdfsProperties.getBackendConfigProperties()} so the + * new typed path and the legacy path stay at parity. + * + *

    It also implements {@link HadoopStorageProperties} (C2) so the connector's FE catalog-create + * Hadoop {@link org.apache.hadoop.conf.Configuration} picks up the {@code hadoop.config.resources} XML + + * HA + auth keys via the typed {@code toHadoopProperties().toHadoopConfigurationMap()} pipeline. That FE + * map is built defaults-free (see {@link #toHadoopConfigurationMap()}) so it never clobbers a + * co-bound object-store provider's tuned {@code fs.s3a.*} values, whereas {@link #toMap()} (BE) stays + * defaults-laden for byte-parity with the legacy backend key set. The {@code Configuration} that actually + * opens an HDFS file system on the {@link HdfsFileSystemProvider#create(Map)} path is still built by + * {@link HdfsConfigBuilder}, and the real {@code UGI.doAs} stays in fe-core/ctx — this class emits only + * key strings; Kerberos here is key emission only, no authenticator is built (K1). + */ +public final class HdfsFileSystemProperties + implements FileSystemProperties, BackendStorageProperties, HadoopStorageProperties { + + public static final String HDFS_DEFAULT_FS_NAME = "fs.defaultFS"; + + private static final String AUTH_KERBEROS = "kerberos"; + private static final String DFS_NAME_SERVICES_KEY = "dfs.nameservices"; + private static final String URI_KEY = "uri"; + + // URI schemes recognized when deriving fs.defaultFS from a 'uri' property (parity with legacy supportSchema). + private static final Set URI_SCHEMES = Set.of("hdfs", "viewfs", "jfs"); + + // HA keys, inlined from org.apache.hadoop.hdfs.client.HdfsClientConfigKeys to avoid a hadoop-hdfs + // dependency (these are stable, well-known HDFS HA configuration keys). + private static final String DFS_HA_NAMENODES_KEY_PREFIX = "dfs.ha.namenodes"; + private static final String DFS_NAMENODE_RPC_ADDRESS_KEY = "dfs.namenode.rpc-address"; + private static final String DFS_HA_FAILOVER_PROXY_PROVIDER_KEY_PREFIX = "dfs.client.failover.proxy.provider"; + + @ConnectorProperty(names = {"hdfs.authentication.type", "hadoop.security.authentication"}, + required = false, + description = "The authentication type of HDFS. The default value is 'simple'.") + private String hdfsAuthenticationType = "simple"; + + @ConnectorProperty(names = {"hdfs.authentication.kerberos.principal", "hadoop.kerberos.principal"}, + required = false, + description = "The principal of the kerberos authentication.") + private String hdfsKerberosPrincipal = ""; + + @ConnectorProperty(names = {"hdfs.authentication.kerberos.keytab", "hadoop.kerberos.keytab"}, + required = false, + description = "The keytab of the kerberos authentication.") + private String hdfsKerberosKeytab = ""; + + @ConnectorProperty(names = {"hadoop.username"}, + required = false, + description = "The username of Hadoop. Doris will use this user to access HDFS.") + private String hadoopUsername = ""; + + @ConnectorProperty(names = {"hdfs.impersonation.enabled"}, + required = false, + supported = false, + description = "Whether to enable the impersonation of HDFS.") + private boolean hdfsImpersonationEnabled = false; + + @ConnectorProperty(names = {"ipc.client.fallback-to-simple-auth-allowed"}, + required = false, + description = "Whether to allow fallback to simple authentication.") + private String allowFallbackToSimpleAuth = ""; + + @ConnectorProperty(names = {"fs.defaultFS"}, required = false, description = "The default file system URI.") + private String fsDefaultFS = ""; + + @ConnectorProperty(names = {"hadoop.config.resources"}, + required = false, + description = "The xml files of Hadoop configuration.") + private String hadoopConfigResources = ""; + + private final Map rawProperties; + private final Map matchedProperties; + // BE key set: defaults-laden (Hadoop core-default.xml + the XML resources), byte-parity with legacy. + private final Map backendConfigProperties; + // FE catalog-create Hadoop config: same key set MINUS Hadoop's framework defaults (C2). Defaults-free + // so it cannot clobber a co-bound object-store provider's tuned fs.s3a.* values in a multi-backend + // merge; the base Configuration already supplies every Hadoop default. + private final Map hadoopConfigProperties; + + private HdfsFileSystemProperties(Map rawProperties) { + this.rawProperties = Collections.unmodifiableMap(new HashMap<>(rawProperties)); + this.matchedProperties = Collections.unmodifiableMap(collectMatchedProperties(rawProperties)); + ConnectorPropertiesUtils.bindConnectorProperties(this, rawProperties); + if (StringUtils.isBlank(fsDefaultFS)) { + this.fsDefaultFS = extractDefaultFsFromUri(rawProperties); + } + this.backendConfigProperties = + Collections.unmodifiableMap(buildConfigProperties(rawProperties, true)); + this.hadoopConfigProperties = + Collections.unmodifiableMap(buildConfigProperties(rawProperties, false)); + } + + /** Binds and validates raw properties. */ + public static HdfsFileSystemProperties of(Map properties) { + HdfsFileSystemProperties props = new HdfsFileSystemProperties(properties); + props.validate(); + return props; + } + + @Override + public void validate() { + // Parity with legacy HdfsProperties.checkRequiredProperties(): kerberos requires principal + keytab. + if (isKerberos() + && (StringUtils.isBlank(hdfsKerberosPrincipal) || StringUtils.isBlank(hdfsKerberosKeytab))) { + throw new IllegalArgumentException( + "HDFS authentication type is kerberos, but principal or keytab is not set."); + } + checkHaConfig(backendConfigProperties); + } + + @Override + public String providerName() { + return "HDFS"; + } + + @Override + public StorageKind kind() { + return StorageKind.HDFS_COMPATIBLE; + } + + @Override + public FileSystemType type() { + return FileSystemType.HDFS; + } + + @Override + public Map rawProperties() { + return rawProperties; + } + + @Override + public Map matchedProperties() { + return matchedProperties; + } + + @Override + public Optional toBackendProperties() { + return Optional.of(this); + } + + @Override + public BackendStorageKind backendKind() { + return BackendStorageKind.HDFS; + } + + @Override + public Map toMap() { + return backendConfigProperties; + } + + @Override + public Optional toHadoopProperties() { + return Optional.of(this); + } + + /** + * FE catalog-create Hadoop config map (C2): the {@code hadoop.config.resources} XML keys + user + * {@code hadoop./dfs./fs./juicefs.} overrides + synthesized {@code fs.defaultFS}/ipc/auth/kerberos + * keys, but without Hadoop's built-in framework defaults. Closes C2 (the XML/HA keys reach the + * paimon FE Configuration for the filesystem/jdbc/hms flavors). Defaults-free because the base + * {@link org.apache.hadoop.conf.Configuration} already carries every Hadoop default and because the + * defaults (notably the 62 {@code fs.s3a.*} entries from core-default.xml) would otherwise clobber a + * co-bound object-store provider's tuned {@code fs.s3a.*} values when merged into a multi-backend + * catalog Configuration. {@link #toMap()} (BE) keeps the defaults-laden set for byte-parity. + */ + @Override + public Map toHadoopConfigurationMap() { + return hadoopConfigProperties; + } + + public boolean isKerberos() { + return AUTH_KERBEROS.equalsIgnoreCase(hdfsAuthenticationType); + } + + /** + * Builds the HDFS configuration key set. Faithful port of legacy + * {@code HdfsProperties.initBackendConfigProperties()} so the typed BE map stays at parity with fe-core + * {@code getBackendConfigProperties()}. Overlay order (last-write-wins): config-resource XML files, then + * the {@code hadoop./dfs./fs./juicefs.} pass-through from the raw map, then the synthesized keys. + * + * @param loadHadoopDefaults {@code true} for the BE map (defaults-laden, legacy parity); {@code false} + * for the FE catalog Hadoop-config map (only the XML files' own keys, no + * Hadoop framework defaults). Only the config-resource load differs; the + * overrides/synthesized keys are identical, so the two maps agree on every + * meaningful HDFS key and differ only in the inert framework defaults. + */ + private Map buildConfigProperties(Map origProps, boolean loadHadoopDefaults) { + Map props = HdfsConfigFileLoader.loadConfigMap(hadoopConfigResources, loadHadoopDefaults); + Map userOverridden = extractUserOverriddenHdfsConfig(origProps); + if (!userOverridden.isEmpty()) { + props.putAll(userOverridden); + } + if (StringUtils.isNotBlank(fsDefaultFS)) { + props.put(HDFS_DEFAULT_FS_NAME, fsDefaultFS); + } + if (StringUtils.isNotBlank(allowFallbackToSimpleAuth)) { + props.put("ipc.client.fallback-to-simple-auth-allowed", allowFallbackToSimpleAuth); + } else { + props.put("ipc.client.fallback-to-simple-auth-allowed", "true"); + } + props.put("hdfs.security.authentication", hdfsAuthenticationType); + if (isKerberos()) { + props.put("hadoop.security.authentication", AUTH_KERBEROS); + props.put("hadoop.kerberos.principal", hdfsKerberosPrincipal); + props.put("hadoop.kerberos.keytab", hdfsKerberosKeytab); + } + if (StringUtils.isNotBlank(hadoopUsername)) { + props.put("hadoop.username", hadoopUsername); + } + return props; + } + + private static Map extractUserOverriddenHdfsConfig(Map origProps) { + Map overridden = new HashMap<>(); + if (origProps == null || origProps.isEmpty()) { + return overridden; + } + origProps.forEach((key, value) -> { + if (key.startsWith("hadoop.") || key.startsWith("dfs.") || key.startsWith("fs.") + || key.startsWith("juicefs.")) { + overridden.put(key, value); + } + }); + return overridden; + } + + // ---- helpers ported from fe HdfsPropertiesUtils (kept local; single-use) ---- + + private static String extractDefaultFsFromUri(Map props) { + String uriStr = getSingleUri(props); + if (StringUtils.isBlank(uriStr)) { + return ""; + } + // Parity with legacy HdfsPropertiesUtils.extractDefaultFsFromUri: URI.create is unguarded, so a + // malformed uri fails loud at bind/catalog-create rather than silently dropping fs.defaultFS. + URI uri = URI.create(uriStr); + String scheme = uri.getScheme(); + if (scheme == null || !URI_SCHEMES.contains(scheme.toLowerCase())) { + return ""; + } + return scheme + "://" + uri.getAuthority(); + } + + private static String getSingleUri(Map props) { + String uriValue = props.entrySet().stream() + .filter(e -> e.getKey().equalsIgnoreCase(URI_KEY)) + .map(Map.Entry::getValue) + .filter(StringUtils::isNotBlank) + .findFirst() + .orElse(null); + if (uriValue == null) { + return null; + } + // HDFS fs.defaultFS only supports a single URI; a comma-separated list is not a usable default. + if (uriValue.split(",").length > 1) { + return null; + } + return uriValue; + } + + /** + * Validates HDFS HA configuration. Port of legacy {@code HdfsPropertiesUtils.checkHaConfig}: when + * {@code dfs.nameservices} is present, each nameservice must declare at least two namenodes, an + * rpc-address per namenode, and a failover proxy provider. Validates only; adds no keys. + */ + private static void checkHaConfig(Map hdfsProperties) { + if (hdfsProperties == null) { + return; + } + String dfsNameservices = hdfsProperties.getOrDefault(DFS_NAME_SERVICES_KEY, ""); + if (StringUtils.isBlank(dfsNameservices)) { + // No nameservice configured => HA is not enabled, nothing to validate. + return; + } + for (String dfsservice : splitAndTrim(dfsNameservices)) { + String haNnKey = DFS_HA_NAMENODES_KEY_PREFIX + "." + dfsservice; + String namenodes = hdfsProperties.getOrDefault(haNnKey, ""); + if (StringUtils.isBlank(namenodes)) { + throw new IllegalArgumentException("Missing property: " + haNnKey); + } + List names = splitAndTrim(namenodes); + if (names.size() < 2) { + throw new IllegalArgumentException("HA requires at least 2 namenodes for service: " + dfsservice); + } + for (String name : names) { + String rpcKey = DFS_NAMENODE_RPC_ADDRESS_KEY + "." + dfsservice + "." + name; + if (StringUtils.isBlank(hdfsProperties.getOrDefault(rpcKey, ""))) { + throw new IllegalArgumentException( + "Missing property: " + rpcKey + " (expected format: host:port)"); + } + } + String failoverKey = DFS_HA_FAILOVER_PROXY_PROVIDER_KEY_PREFIX + "." + dfsservice; + if (StringUtils.isBlank(hdfsProperties.getOrDefault(failoverKey, ""))) { + throw new IllegalArgumentException("Missing property: " + failoverKey); + } + } + } + + private static List splitAndTrim(String s) { + List result = new ArrayList<>(); + if (StringUtils.isBlank(s)) { + return result; + } + for (String token : s.split(",")) { + String trimmed = token.trim(); + if (!trimmed.isEmpty()) { + result.add(trimmed); + } + } + return result; + } + + private static Map collectMatchedProperties(Map rawProperties) { + Map matched = new HashMap<>(); + for (Field field : ConnectorPropertiesUtils.getConnectorProperties(HdfsFileSystemProperties.class)) { + String matchedName = ConnectorPropertiesUtils.getMatchedPropertyName(field, rawProperties); + if (StringUtils.isNotBlank(matchedName)) { + matched.put(matchedName, rawProperties.get(matchedName)); + } + } + return matched; + } + + @Override + public String toString() { + return ConnectorPropertiesUtils.toMaskedString(this); + } +} diff --git a/fe/fe-filesystem/fe-filesystem-hdfs/src/main/java/org/apache/doris/filesystem/hdfs/HdfsFileSystemProvider.java b/fe/fe-filesystem/fe-filesystem-hdfs/src/main/java/org/apache/doris/filesystem/hdfs/HdfsFileSystemProvider.java index f8b7247a518af5..d1353007c1dcda 100644 --- a/fe/fe-filesystem/fe-filesystem-hdfs/src/main/java/org/apache/doris/filesystem/hdfs/HdfsFileSystemProvider.java +++ b/fe/fe-filesystem/fe-filesystem-hdfs/src/main/java/org/apache/doris/filesystem/hdfs/HdfsFileSystemProvider.java @@ -19,7 +19,6 @@ import org.apache.doris.filesystem.FileSystem; import org.apache.doris.filesystem.hdfs.properties.HdfsProperties; -import org.apache.doris.filesystem.properties.FileSystemProperties; import org.apache.doris.filesystem.spi.FileSystemProvider; import java.io.IOException; @@ -38,7 +37,7 @@ * {@code hdfs}/{@code viewfs} are claimed here; the {@code _STORAGE_TYPE_} "HDFS" marker is only a * fallback when there is no uri scheme.

    */ -public class HdfsFileSystemProvider implements FileSystemProvider { +public class HdfsFileSystemProvider implements FileSystemProvider { public static final Set SUPPORTED_SCHEMES = Set.of("hdfs", "viewfs"); @@ -71,6 +70,18 @@ public boolean supports(Map properties) { || properties.containsKey("hadoop.kerberos.principal"); } + @Override + public HdfsFileSystemProperties bind(Map properties) { + return HdfsFileSystemProperties.of(properties); + } + + @Override + public FileSystem create(HdfsFileSystemProperties properties) throws IOException { + // DFSFileSystem builds its own Configuration (incl. the create()-side Kerberos authenticator) + // from the raw map via HdfsConfigBuilder; route through the unchanged create(Map) path. + return create(properties.rawProperties()); + } + @Override public FileSystem create(Map properties) throws IOException { // Resolve raw user properties through the migrated HdfsProperties so that typed auth diff --git a/fe/fe-filesystem/fe-filesystem-hdfs/src/test/java/org/apache/doris/filesystem/hdfs/DFSAuthenticatorTest.java b/fe/fe-filesystem/fe-filesystem-hdfs/src/test/java/org/apache/doris/filesystem/hdfs/DFSAuthenticatorTest.java new file mode 100644 index 00000000000000..66e35e2dbb86f2 --- /dev/null +++ b/fe/fe-filesystem/fe-filesystem-hdfs/src/test/java/org/apache/doris/filesystem/hdfs/DFSAuthenticatorTest.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.filesystem.hdfs; + +import org.apache.doris.kerberos.HadoopAuthenticator; +import org.apache.doris.kerberos.HadoopKerberosAuthenticator; +import org.apache.doris.kerberos.HadoopSimpleAuthenticator; + +import org.apache.hadoop.conf.Configuration; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * Verifies that {@link DFSFileSystem} wires the shared {@code fe-kerberos} authenticators + * (P3b consolidation): the duplicate fe-filesystem-hdfs {@code KerberosHadoopAuthenticator}/ + * {@code SimpleHadoopAuthenticator} copies are gone. + * + *

    It also pins the deliberately-adopted behavior change: the simple/no-username path now + * executes as remote user {@code "hadoop"} (legacy fe-common/HMS parity) instead of the FE + * process user. The duplicate copy used to run such actions directly as the FE process user. + */ +class DFSAuthenticatorTest { + + @Test + void simpleNoUsernameRunsAsHadoop() throws IOException { + HadoopAuthenticator auth = DFSFileSystem.buildAuthenticator(new HashMap<>(), new Configuration()); + Assertions.assertTrue(auth instanceof HadoopSimpleAuthenticator, + "non-kerberos properties must select the shared simple authenticator"); + Assertions.assertEquals("hadoop", auth.getUGI().getUserName(), + "no hadoop.username must default to remote user 'hadoop' (fe-common/HMS parity)"); + } + + @Test + void simpleWithUsernameRunsAsThatUser() throws IOException { + Map props = new HashMap<>(); + props.put("hadoop.username", "testuser"); + HadoopAuthenticator auth = DFSFileSystem.buildAuthenticator(props, new Configuration()); + Assertions.assertTrue(auth instanceof HadoopSimpleAuthenticator); + Assertions.assertEquals("testuser", auth.getUGI().getUserName(), + "hadoop.username must drive the simple authenticator's UGI identity"); + } + + @Test + void simpleEmptyUsernameRunsAsHadoop() throws IOException { + Map props = new HashMap<>(); + props.put("hadoop.username", ""); + HadoopAuthenticator auth = DFSFileSystem.buildAuthenticator(props, new Configuration()); + Assertions.assertTrue(auth instanceof HadoopSimpleAuthenticator); + // Empty must be treated like absent (-> remote user "hadoop"), not passed to + // UserGroupInformation.createRemoteUser("") which would throw IllegalArgumentException. + Assertions.assertEquals("hadoop", auth.getUGI().getUserName(), + "empty hadoop.username must default to remote user 'hadoop', not crash construction"); + } + + @Test + void principalAndKeytabSelectKerberos() { + Map props = new HashMap<>(); + props.put("hadoop.kerberos.principal", "doris/host@EXAMPLE.COM"); + props.put("hadoop.kerberos.keytab", "/etc/doris.keytab"); + HadoopAuthenticator auth = DFSFileSystem.buildAuthenticator(props, new Configuration()); + // Type only: getUGI() would trigger a real KDC login (covered by docker kerberos e2e). + Assertions.assertTrue(auth instanceof HadoopKerberosAuthenticator, + "principal + keytab presence must select the shared kerberos authenticator"); + } +} diff --git a/fe/fe-filesystem/fe-filesystem-hdfs/src/test/java/org/apache/doris/filesystem/hdfs/HdfsConfigBuilderTest.java b/fe/fe-filesystem/fe-filesystem-hdfs/src/test/java/org/apache/doris/filesystem/hdfs/HdfsConfigBuilderTest.java index 83560ef38fc75a..d74e2812ac2222 100644 --- a/fe/fe-filesystem/fe-filesystem-hdfs/src/test/java/org/apache/doris/filesystem/hdfs/HdfsConfigBuilderTest.java +++ b/fe/fe-filesystem/fe-filesystem-hdfs/src/test/java/org/apache/doris/filesystem/hdfs/HdfsConfigBuilderTest.java @@ -78,6 +78,29 @@ void buildIgnoresNullAndEmptyValues() { && "empty.key".equals(conf.get("empty.key"))); } + @Test + void buildPinsPluginClassLoaderNotTccl() { + // WHY (test_string_dict_filter, hdfs scan): Hadoop resolves impl classes via Configuration.getClass, + // which uses the conf's OWN classLoader field. new HdfsConfiguration() captures the thread-context CL + // active AT CONSTRUCTION into that field. DFSFileSystem is built under a connector's plugin loader + // during a scan, so unpinned the conf would carry that connector loader; then RPC.getProtocolEngine + // loads ProtobufRpcEngine2 from the connector's hadoop-common copy while RPC/RpcEngine come from the + // engine copy -> "class ProtobufRpcEngine2 cannot be cast to class RpcEngine". The conf MUST be pinned + // to this plugin's loader. MUTATION: drop the setClassLoader in build() -> the conf keeps the foreign + // TCCL below -> red. (A flat-classpath assertion alone cannot repro the real cross-loader cast, so we + // install a distinct TCCL to make the captured-loader bug observable offline.) + ClassLoader foreign = new java.net.URLClassLoader(new java.net.URL[0], null); + ClassLoader prev = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(foreign); + Configuration conf = HdfsConfigBuilder.build(Map.of()); + Assertions.assertSame(HdfsConfigBuilder.class.getClassLoader(), conf.getClassLoader()); + Assertions.assertNotSame(foreign, conf.getClassLoader()); + } finally { + Thread.currentThread().setContextClassLoader(prev); + } + } + @Test void isKerberosEnabledBothPresent() { Assertions.assertTrue(HdfsConfigBuilder.isKerberosEnabled(Map.of( diff --git a/fe/fe-filesystem/fe-filesystem-hdfs/src/test/java/org/apache/doris/filesystem/hdfs/HdfsFileSystemPropertiesTest.java b/fe/fe-filesystem/fe-filesystem-hdfs/src/test/java/org/apache/doris/filesystem/hdfs/HdfsFileSystemPropertiesTest.java new file mode 100644 index 00000000000000..0f1499c0b8556a --- /dev/null +++ b/fe/fe-filesystem/fe-filesystem-hdfs/src/test/java/org/apache/doris/filesystem/hdfs/HdfsFileSystemPropertiesTest.java @@ -0,0 +1,472 @@ +// 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.filesystem.hdfs; + +import org.apache.doris.filesystem.FileSystemType; +import org.apache.doris.filesystem.properties.BackendStorageKind; +import org.apache.doris.filesystem.properties.BackendStorageProperties; +import org.apache.doris.filesystem.properties.HadoopStorageProperties; +import org.apache.doris.filesystem.properties.StorageKind; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Golden parity tests for {@link HdfsFileSystemProperties}. Each test pins {@code toMap()} (the typed BE map) + * to the exact key set legacy fe-core {@code HdfsProperties.getBackendConfigProperties()} would produce for + * the same input. This is the UT-level equivalence gate for the P1-T04 HDFS regression fix (DV-004 / R-007): + * the typed pipeline {@code getStorageProperties().toBackendProperties().toMap()} must re-produce the HDFS + * backend keys that flow into {@code THdfsParams}. + */ +class HdfsFileSystemPropertiesTest { + + private static Map beMap(Map input) { + Optional be = HdfsFileSystemProperties.of(input).toBackendProperties(); + Assertions.assertTrue(be.isPresent(), "HDFS must expose backend storage properties"); + Assertions.assertEquals(BackendStorageKind.HDFS, be.get().backendKind()); + return be.get().toMap(); + } + + @Test + void simpleAuthBackendMapMatchesLegacy() { + Map in = new HashMap<>(); + in.put("fs.defaultFS", "hdfs://nn:8020"); + + Map expected = new HashMap<>(); + expected.put("fs.defaultFS", "hdfs://nn:8020"); + expected.put("ipc.client.fallback-to-simple-auth-allowed", "true"); + expected.put("hdfs.security.authentication", "simple"); + + Assertions.assertEquals(expected, beMap(in)); + } + + @Test + void kerberosBackendMapMatchesLegacy() { + Map in = new HashMap<>(); + in.put("fs.defaultFS", "hdfs://nn:8020"); + in.put("hadoop.security.authentication", "kerberos"); + in.put("hadoop.kerberos.principal", "doris/_HOST@REALM"); + in.put("hadoop.kerberos.keytab", "/etc/security/doris.keytab"); + + Map expected = new HashMap<>(); + expected.put("fs.defaultFS", "hdfs://nn:8020"); + expected.put("ipc.client.fallback-to-simple-auth-allowed", "true"); + expected.put("hdfs.security.authentication", "kerberos"); + expected.put("hadoop.security.authentication", "kerberos"); + expected.put("hadoop.kerberos.principal", "doris/_HOST@REALM"); + expected.put("hadoop.kerberos.keytab", "/etc/security/doris.keytab"); + + Assertions.assertEquals(expected, beMap(in)); + } + + @Test + void kerberosViaDorisAliasSynthesizesHadoopKeys() { + // The Doris-flavored aliases (hdfs.authentication.*) drive the same emission as the hadoop.* keys. + Map in = new HashMap<>(); + in.put("fs.defaultFS", "hdfs://nn:8020"); + in.put("hdfs.authentication.type", "kerberos"); + in.put("hdfs.authentication.kerberos.principal", "doris@REALM"); + in.put("hdfs.authentication.kerberos.keytab", "/etc/security/doris.keytab"); + + Map out = beMap(in); + Assertions.assertEquals("kerberos", out.get("hdfs.security.authentication")); + Assertions.assertEquals("kerberos", out.get("hadoop.security.authentication")); + Assertions.assertEquals("doris@REALM", out.get("hadoop.kerberos.principal")); + Assertions.assertEquals("/etc/security/doris.keytab", out.get("hadoop.kerberos.keytab")); + } + + @Test + void haConfigPassesThroughAndValidates() { + Map in = new HashMap<>(); + in.put("dfs.nameservices", "ns1"); + in.put("dfs.ha.namenodes.ns1", "nn1,nn2"); + in.put("dfs.namenode.rpc-address.ns1.nn1", "host1:8020"); + in.put("dfs.namenode.rpc-address.ns1.nn2", "host2:8020"); + in.put("dfs.client.failover.proxy.provider.ns1", + "org.apache.hadoop.hdfs.server.namenode.ha.ConfiguredFailoverProxyProvider"); + + Map out = beMap(in); + // Every dfs.* HA key passes through verbatim. + in.forEach((k, v) -> Assertions.assertEquals(v, out.get(k), "HA key should pass through: " + k)); + // And the always-on synthesized keys are present. + Assertions.assertEquals("true", out.get("ipc.client.fallback-to-simple-auth-allowed")); + Assertions.assertEquals("simple", out.get("hdfs.security.authentication")); + } + + @Test + void haConfigMissingFailoverProviderThrows() { + Map in = new HashMap<>(); + in.put("dfs.nameservices", "ns1"); + in.put("dfs.ha.namenodes.ns1", "nn1,nn2"); + in.put("dfs.namenode.rpc-address.ns1.nn1", "host1:8020"); + in.put("dfs.namenode.rpc-address.ns1.nn2", "host2:8020"); + // missing dfs.client.failover.proxy.provider.ns1 + + IllegalArgumentException ex = + Assertions.assertThrows(IllegalArgumentException.class, () -> HdfsFileSystemProperties.of(in)); + Assertions.assertTrue(ex.getMessage().contains("dfs.client.failover.proxy.provider.ns1"), ex.getMessage()); + } + + @Test + void haConfigSingleNamenodeThrows() { + Map in = new HashMap<>(); + in.put("dfs.nameservices", "ns1"); + in.put("dfs.ha.namenodes.ns1", "nn1"); + in.put("dfs.namenode.rpc-address.ns1.nn1", "host1:8020"); + + IllegalArgumentException ex = + Assertions.assertThrows(IllegalArgumentException.class, () -> HdfsFileSystemProperties.of(in)); + Assertions.assertTrue(ex.getMessage().contains("at least 2 namenodes"), ex.getMessage()); + } + + @Test + void hadoopUsernameEmitted() { + Map in = new HashMap<>(); + in.put("fs.defaultFS", "hdfs://nn:8020"); + in.put("hadoop.username", "doris"); + Assertions.assertEquals("doris", beMap(in).get("hadoop.username")); + } + + @Test + void allowFallbackOverridden() { + Map in = new HashMap<>(); + in.put("fs.defaultFS", "hdfs://nn:8020"); + in.put("ipc.client.fallback-to-simple-auth-allowed", "false"); + Assertions.assertEquals("false", beMap(in).get("ipc.client.fallback-to-simple-auth-allowed")); + } + + @Test + void defaultFsDerivedFromUri() { + Map in = new HashMap<>(); + in.put("uri", "hdfs://nn:8020/warehouse/db"); + + Map expected = new HashMap<>(); + expected.put("fs.defaultFS", "hdfs://nn:8020"); + expected.put("ipc.client.fallback-to-simple-auth-allowed", "true"); + expected.put("hdfs.security.authentication", "simple"); + + // 'uri' is not a hadoop./dfs./fs./juicefs. key, so it is NOT passed through; only fs.defaultFS is derived. + Assertions.assertEquals(expected, beMap(in)); + } + + @Test + void juicefsKeysPassThrough() { + Map in = new HashMap<>(); + in.put("fs.defaultFS", "jfs://vol/"); + in.put("juicefs.meta", "redis://localhost:6379/0"); + Assertions.assertEquals("redis://localhost:6379/0", beMap(in).get("juicefs.meta")); + } + + @Test + void kerberosMissingKeytabThrows() { + Map in = new HashMap<>(); + in.put("hadoop.security.authentication", "kerberos"); + in.put("hadoop.kerberos.principal", "doris@REALM"); + // no keytab + IllegalArgumentException ex = + Assertions.assertThrows(IllegalArgumentException.class, () -> HdfsFileSystemProperties.of(in)); + Assertions.assertTrue(ex.getMessage().contains("principal or keytab is not set"), ex.getMessage()); + } + + @Test + void classifiersMatchHdfs() { + Map in = new HashMap<>(); + in.put("fs.defaultFS", "hdfs://nn:8020"); + HdfsFileSystemProperties p = HdfsFileSystemProperties.of(in); + Assertions.assertEquals("HDFS", p.providerName()); + Assertions.assertEquals(StorageKind.HDFS_COMPATIBLE, p.kind()); + Assertions.assertEquals(FileSystemType.HDFS, p.type()); + Assertions.assertEquals(BackendStorageKind.HDFS, p.backendKind()); + // C2: HDFS now surfaces a Hadoop-config map so the paimon FE catalog-create Configuration picks up + // the hadoop.config.resources XML / HA / auth keys (was Optional.empty before the fix). + Assertions.assertTrue(p.toHadoopProperties().isPresent()); + } + + @Test + void xmlResourcesAreLoadedIntoBackendMap() throws IOException { + Path dir = Files.createTempDirectory("hadoop_conf"); + Path xml = dir.resolve("hdfs-site.xml"); + Files.write(xml, + ("" + + "dfs.custom.keycustom-value" + + "fs.defaultFShdfs://from-xml:9000" + + "").getBytes(StandardCharsets.UTF_8)); + String prev = HdfsConfigFileLoader.hadoopConfigDirOverride; + HdfsConfigFileLoader.hadoopConfigDirOverride = dir.toString() + "/"; + try { + Map in = new HashMap<>(); + in.put("fs.defaultFS", "hdfs://nn:8020"); + in.put("hadoop.config.resources", "hdfs-site.xml"); + + Map out = beMap(in); + Assertions.assertEquals("custom-value", out.get("dfs.custom.key")); + // user-provided fs.defaultFS overrides the FILE-provided fs.defaultFS (overlay: XML < passthrough). + Assertions.assertEquals("hdfs://nn:8020", out.get("fs.defaultFS")); + Assertions.assertEquals("simple", out.get("hdfs.security.authentication")); + } finally { + HdfsConfigFileLoader.hadoopConfigDirOverride = prev; + } + } + + /** Returns the FE catalog-create Hadoop config map (C2). */ + private static Map hadoopMap(Map input) { + Optional h = HdfsFileSystemProperties.of(input).toHadoopProperties(); + Assertions.assertTrue(h.isPresent(), "HDFS must expose a Hadoop config map (C2)"); + return h.get().toHadoopConfigurationMap(); + } + + @Test + void xmlKeysReachHadoopConfigMap() throws IOException { + // C2 regression pin: a key that lives ONLY in the referenced XML (not a raw catalog prop, so it + // cannot ride the connector's raw fs./dfs./hadoop. passthrough) must reach the FE Hadoop config map. + // Pre-fix toHadoopProperties() was empty -> .get() throws -> RED. + Path dir = Files.createTempDirectory("hadoop_conf"); + Path xml = dir.resolve("hdfs-site.xml"); + Files.write(xml, + ("" + + "dfs.custom.keycustom-value" + + "").getBytes(StandardCharsets.UTF_8)); + String prev = HdfsConfigFileLoader.hadoopConfigDirOverride; + HdfsConfigFileLoader.hadoopConfigDirOverride = dir.toString() + "/"; + try { + Map in = new HashMap<>(); + in.put("fs.defaultFS", "hdfs://nn:8020"); + in.put("hadoop.config.resources", "hdfs-site.xml"); + + Map out = hadoopMap(in); + Assertions.assertEquals("custom-value", out.get("dfs.custom.key")); + } finally { + HdfsConfigFileLoader.hadoopConfigDirOverride = prev; + } + } + + @Test + void hadoopConfigMapExcludesFrameworkDefaultsButBeMapKeepsThem() throws IOException { + // Clobber guard (encodes WHY): the FE Hadoop map must NOT carry Hadoop's built-in fs.s3a.* defaults + // (which would overwrite a co-bound object-store provider's tuned fs.s3a.path.style.access=true in a + // multi-backend merge). The BE map (toMap) DOES keep them, for byte-parity with the legacy backend + // set. Asserting both sides pins the deliberate FE/BE asymmetry. + Path dir = Files.createTempDirectory("hadoop_conf"); + Path xml = dir.resolve("hdfs-site.xml"); + Files.write(xml, + ("" + + "dfs.custom.keycustom-value" + + "").getBytes(StandardCharsets.UTF_8)); + String prev = HdfsConfigFileLoader.hadoopConfigDirOverride; + HdfsConfigFileLoader.hadoopConfigDirOverride = dir.toString() + "/"; + try { + Map in = new HashMap<>(); + in.put("fs.defaultFS", "hdfs://nn:8020"); + in.put("hadoop.config.resources", "hdfs-site.xml"); + + Map feMap = hadoopMap(in); + Map beMapWithDefaults = beMap(in); + // FE map: defaults-free -> the framework fs.s3a.* defaults are absent. + Assertions.assertNull(feMap.get("fs.s3a.path.style.access"), + "FE Hadoop map must not carry the core-default.xml fs.s3a.* defaults (clobber guard)"); + Assertions.assertNull(feMap.get("fs.s3a.connection.maximum")); + // BE map: defaults-laden -> those same framework defaults are present (legacy byte-parity). + // Assert presence, not the exact default value (it is hadoop-version-dependent, e.g. the + // fs.s3a.connection.maximum default is 96 on hadoop 3.3.x but 500 on 3.4.x). + Assertions.assertNotNull(beMapWithDefaults.get("fs.s3a.path.style.access")); + Assertions.assertNotNull(beMapWithDefaults.get("fs.s3a.connection.maximum")); + Assertions.assertTrue(beMapWithDefaults.size() > feMap.size(), + "BE map (defaults-laden) must carry more keys than the defaults-free FE map"); + } finally { + HdfsConfigFileLoader.hadoopConfigDirOverride = prev; + } + } + + @Test + void hadoopConfigMapKeepsMeaningfulKeys() { + // Defaults-free does NOT mean empty: the synthesized HDFS keys + fs.defaultFS must survive in the + // FE Hadoop map even with no hadoop.config.resources (blank => loadConfigMap returns empty, then the + // synthesized keys are added; no framework defaults either way). + Map in = new HashMap<>(); + in.put("fs.defaultFS", "hdfs://nn:8020"); + Map out = hadoopMap(in); + Assertions.assertEquals("hdfs://nn:8020", out.get("fs.defaultFS")); + Assertions.assertEquals("simple", out.get("hdfs.security.authentication")); + Assertions.assertEquals("true", out.get("ipc.client.fallback-to-simple-auth-allowed")); + Assertions.assertNull(out.get("fs.s3a.path.style.access")); + } + + @Test + void provNameViaProvider() { + // bind() routes through HdfsFileSystemProperties.of and yields the typed model. + HdfsFileSystemProvider provider = new HdfsFileSystemProvider(); + Map in = new HashMap<>(); + in.put("fs.defaultFS", "hdfs://nn:8020"); + HdfsFileSystemProperties bound = provider.bind(in); + Assertions.assertEquals("hdfs://nn:8020", bound.toMap().get("fs.defaultFS")); + } + + @Test + void emptyInputFallbackMatchesLegacy() { + // The framework auto-fallback HDFS storage is built with NO explicit keys; the BE map is just the + // two always-on synthesized keys (legacy initBackendConfigProperties produces the same). + Map expected = new HashMap<>(); + expected.put("ipc.client.fallback-to-simple-auth-allowed", "true"); + expected.put("hdfs.security.authentication", "simple"); + Assertions.assertEquals(expected, beMap(new HashMap<>())); + } + + @Test + void kerberosCredsPresentButSimpleTypeDoesNotSynthesize() { + // principal/keytab present but NO auth-type key => stays "simple": the hadoop.security.authentication + // synthesis block must NOT fire. The discriminator is hdfsAuthenticationType only, never the presence + // of principal/keytab (which still pass through via the hadoop.* prefix). + Map in = new HashMap<>(); + in.put("fs.defaultFS", "hdfs://nn:8020"); + in.put("hadoop.kerberos.principal", "doris@REALM"); + in.put("hadoop.kerberos.keytab", "/etc/security/doris.keytab"); + + Map out = beMap(in); + Assertions.assertEquals("simple", out.get("hdfs.security.authentication")); + Assertions.assertNull(out.get("hadoop.security.authentication")); + // creds still flow to BE via passthrough. + Assertions.assertEquals("doris@REALM", out.get("hadoop.kerberos.principal")); + Assertions.assertEquals("/etc/security/doris.keytab", out.get("hadoop.kerberos.keytab")); + } + + @Test + void viewfsAndJfsUrisDeriveDefaultFs() { + Map viewfs = new HashMap<>(); + viewfs.put("uri", "viewfs://cluster/warehouse"); + Assertions.assertEquals("viewfs://cluster", beMap(viewfs).get("fs.defaultFS")); + + Map jfs = new HashMap<>(); + jfs.put("uri", "jfs://vol/warehouse"); + Assertions.assertEquals("jfs://vol", beMap(jfs).get("fs.defaultFS")); + } + + @Test + void ofsAndOssUrisDoNotDeriveDefaultFs() { + // ofs/oss are bound by the provider but are NOT in URI_SCHEMES, so no fs.defaultFS is derived (legacy + // parity: legacy supportSchema = {hdfs, viewfs, jfs}). + Map ofs = new HashMap<>(); + ofs.put("uri", "ofs://cluster/warehouse"); + Assertions.assertNull(beMap(ofs).get("fs.defaultFS")); + + Map oss = new HashMap<>(); + oss.put("uri", "oss://bucket/warehouse"); + Assertions.assertNull(beMap(oss).get("fs.defaultFS")); + } + + @Test + void allowFallbackBlankUsesDefault() { + // An explicit blank value is filtered by binding (isNotBlank gate) so the field stays default "" + // and the else-branch emits "true" — matching legacy. + Map in = new HashMap<>(); + in.put("fs.defaultFS", "hdfs://nn:8020"); + in.put("ipc.client.fallback-to-simple-auth-allowed", ""); + Assertions.assertEquals("true", beMap(in).get("ipc.client.fallback-to-simple-auth-allowed")); + } + + @Test + void multiUriDoesNotDeriveDefaultFs() { + // A comma-separated uri list is not a usable single fs.defaultFS (legacy getUri returns null). + Map in = new HashMap<>(); + in.put("uri", "hdfs://nn1:8020,hdfs://nn2:8020"); + Assertions.assertNull(beMap(in).get("fs.defaultFS")); + } + + @Test + void haConfigMissingNamenodesKeyThrows() { + Map in = new HashMap<>(); + in.put("dfs.nameservices", "ns1"); + // missing dfs.ha.namenodes.ns1 + IllegalArgumentException ex = + Assertions.assertThrows(IllegalArgumentException.class, () -> HdfsFileSystemProperties.of(in)); + Assertions.assertTrue(ex.getMessage().contains("dfs.ha.namenodes.ns1"), ex.getMessage()); + } + + @Test + void haConfigMissingRpcAddressThrows() { + Map in = new HashMap<>(); + in.put("dfs.nameservices", "ns1"); + in.put("dfs.ha.namenodes.ns1", "nn1,nn2"); + in.put("dfs.namenode.rpc-address.ns1.nn1", "host1:8020"); + // missing rpc-address for nn2 + IllegalArgumentException ex = + Assertions.assertThrows(IllegalArgumentException.class, () -> HdfsFileSystemProperties.of(in)); + Assertions.assertTrue(ex.getMessage().contains("dfs.namenode.rpc-address.ns1.nn2"), ex.getMessage()); + } + + @Test + void haConfigMultiNameserviceWithWhitespaceValidates() { + // Comma + whitespace nameservices are trimmed and each is validated independently. + Map in = new HashMap<>(); + in.put("dfs.nameservices", "ns1, ns2"); + in.put("dfs.ha.namenodes.ns1", "nn1,nn2"); + in.put("dfs.namenode.rpc-address.ns1.nn1", "h1:8020"); + in.put("dfs.namenode.rpc-address.ns1.nn2", "h2:8020"); + in.put("dfs.client.failover.proxy.provider.ns1", "x.Provider"); + in.put("dfs.ha.namenodes.ns2", "nn3,nn4"); + in.put("dfs.namenode.rpc-address.ns2.nn3", "h3:8020"); + in.put("dfs.namenode.rpc-address.ns2.nn4", "h4:8020"); + in.put("dfs.client.failover.proxy.provider.ns2", "x.Provider"); + + Assertions.assertEquals("h3:8020", beMap(in).get("dfs.namenode.rpc-address.ns2.nn3")); + } + + @Test + void malformedUriFailsLoud() { + // Parity with legacy: a malformed uri (no explicit fs.defaultFS) fails loud at bind, not silently. + Map in = new HashMap<>(); + in.put("uri", "hdfs://nn:8020/a path{bad}"); + Assertions.assertThrows(IllegalArgumentException.class, () -> HdfsFileSystemProperties.of(in)); + } + + @Test + void configDirResolvedFromEngineSystemProperty() throws IOException { + // F1 wiring: with no explicit override, the loader resolves the config dir from the engine-set system + // property (fe-core FileSystemFactory sets it from Config.hadoop_config_dir for non-default installs). + Path dir = Files.createTempDirectory("hadoop_conf_sysprop"); + Path xml = dir.resolve("core-site.xml"); + Files.write(xml, + ("" + + "dfs.sysprop.keyv" + + "").getBytes(StandardCharsets.UTF_8)); + String prevOverride = HdfsConfigFileLoader.hadoopConfigDirOverride; + String prevProp = System.getProperty(HdfsConfigFileLoader.CONFIG_DIR_PROPERTY); + HdfsConfigFileLoader.hadoopConfigDirOverride = null; // force the system-property path + System.setProperty(HdfsConfigFileLoader.CONFIG_DIR_PROPERTY, dir.toString() + "/"); + try { + Map in = new HashMap<>(); + in.put("fs.defaultFS", "hdfs://nn:8020"); + in.put("hadoop.config.resources", "core-site.xml"); + Assertions.assertEquals("v", beMap(in).get("dfs.sysprop.key")); + } finally { + HdfsConfigFileLoader.hadoopConfigDirOverride = prevOverride; + if (prevProp == null) { + System.clearProperty(HdfsConfigFileLoader.CONFIG_DIR_PROPERTY); + } else { + System.setProperty(HdfsConfigFileLoader.CONFIG_DIR_PROPERTY, prevProp); + } + } + } +} diff --git a/fe/fe-filesystem/fe-filesystem-hdfs/src/test/java/org/apache/doris/filesystem/hdfs/KerberosHadoopAuthenticatorTest.java b/fe/fe-filesystem/fe-filesystem-hdfs/src/test/java/org/apache/doris/filesystem/hdfs/KerberosHadoopAuthenticatorTest.java deleted file mode 100644 index 2c6d6907f69477..00000000000000 --- a/fe/fe-filesystem/fe-filesystem-hdfs/src/test/java/org/apache/doris/filesystem/hdfs/KerberosHadoopAuthenticatorTest.java +++ /dev/null @@ -1,165 +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.filesystem.hdfs; - -import org.apache.doris.foundation.security.KerberosTicketUtils; - -import org.apache.hadoop.conf.Configuration; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.util.ArrayDeque; -import java.util.Date; -import java.util.Deque; -import javax.security.auth.Subject; -import javax.security.auth.kerberos.KerberosPrincipal; -import javax.security.auth.kerberos.KerberosTicket; - -/** - * Unit tests for the proactive TGT refresh logic. No KDC: the JAAS keytab login - * (package-private {@code loginSubject()} seam, trino's KerberosAuthentication - * boundary) is replaced with fabricated Subjects holding real KerberosTicket - * objects whose start/end times steer the 80%-lifetime refresh point. - */ -class KerberosHadoopAuthenticatorTest { - - private static final KerberosPrincipal CLIENT = new KerberosPrincipal("doris/test@EXAMPLE.COM"); - private static final KerberosPrincipal TGS = new KerberosPrincipal("krbtgt/EXAMPLE.COM@EXAMPLE.COM"); - - /** Canned-login subclass; static state because loginSubject() is called from the super constructor. */ - private static final class FakeLoginAuthenticator extends KerberosHadoopAuthenticator { - static final Deque LOGINS = new ArrayDeque<>(); - static int loginCount = 0; - static RuntimeException nextLoginFailure = null; - - FakeLoginAuthenticator() { - super("doris/test@EXAMPLE.COM", "/path/to/doris.keytab", new Configuration()); - } - - @Override - Subject loginSubject() { - loginCount++; - if (nextLoginFailure != null) { - RuntimeException failure = nextLoginFailure; - nextLoginFailure = null; - throw failure; - } - return LOGINS.removeFirst(); - } - } - - private static Subject subjectWithTgt(long startMillis, long endMillis) { - Subject subject = new Subject(); - subject.getPrincipals().add(CLIENT); - subject.getPrivateCredentials().add(new KerberosTicket(new byte[] {1}, CLIENT, TGS, - new byte[] {1}, 1, null, new Date(startMillis), new Date(startMillis), - new Date(endMillis), null, null)); - return subject; - } - - @BeforeEach - void resetFakeLogins() { - FakeLoginAuthenticator.LOGINS.clear(); - FakeLoginAuthenticator.loginCount = 0; - FakeLoginAuthenticator.nextLoginFailure = null; - } - - @Test - void constructorLogsInEagerlyAndFreshTicketIsNotRefreshed() throws IOException { - long now = System.currentTimeMillis(); - // refresh point = now + 0.8 * 600s = now + 480s, far in the future - FakeLoginAuthenticator.LOGINS.add(subjectWithTgt(now, now + 600_000)); - - KerberosHadoopAuthenticator auth = new FakeLoginAuthenticator(); - Assertions.assertEquals(1, FakeLoginAuthenticator.loginCount); - - Assertions.assertEquals("ok", auth.doAs(() -> "ok")); - auth.doAs(() -> "ok"); - Assertions.assertEquals(1, FakeLoginAuthenticator.loginCount); - } - - @Test - void staleTicketIsRefreshedInPlaceWithoutThrottle() throws IOException { - long now = System.currentTimeMillis(); - // initial TGT is past its 80%-lifetime refresh point: - // refresh point = (now-100s) + 0.8 * 101s = now - 19.2s < now - Subject initial = subjectWithTgt(now - 100_000, now + 1_000); - Subject renewed = subjectWithTgt(now, now + 600_000); - FakeLoginAuthenticator.LOGINS.add(initial); - FakeLoginAuthenticator.LOGINS.add(renewed); - - KerberosHadoopAuthenticator auth = new FakeLoginAuthenticator(); - Assertions.assertEquals(1, FakeLoginAuthenticator.loginCount); - - // constructor login happened seconds ago; a 60s-throttled implementation - // (checkTGTAndReloginFromKeytab) would skip this refresh — ours must not - Assertions.assertEquals("ok", auth.doAs(() -> "ok")); - Assertions.assertEquals(2, FakeLoginAuthenticator.loginCount); - - // in-place swap: the ORIGINAL Subject now holds the renewed TGT - KerberosTicket current = KerberosTicketUtils.getTicketGrantingTicket(initial); - Assertions.assertEquals(now + 600_000, current.getEndTime().getTime()); - - // renewed ticket is fresh → no further login - auth.doAs(() -> "ok"); - Assertions.assertEquals(2, FakeLoginAuthenticator.loginCount); - } - - @Test - void doAsPropagatesIOException() { - long now = System.currentTimeMillis(); - FakeLoginAuthenticator.LOGINS.add(subjectWithTgt(now, now + 600_000)); - - KerberosHadoopAuthenticator auth = new FakeLoginAuthenticator(); - IOException thrown = Assertions.assertThrows(IOException.class, () -> auth.doAs(() -> { - throw new IOException("intentional"); - })); - Assertions.assertEquals("intentional", thrown.getMessage()); - } - - @Test - void constructorWrapsLoginFailureWithPrincipalAndKeytab() { - FakeLoginAuthenticator.nextLoginFailure = - new RuntimeException(new javax.security.auth.login.LoginException("no keytab")); - RuntimeException thrown = Assertions.assertThrows(RuntimeException.class, - FakeLoginAuthenticator::new); - Assertions.assertTrue(thrown.getMessage().contains("doris/test@EXAMPLE.COM")); - Assertions.assertTrue(thrown.getMessage().contains("/path/to/doris.keytab")); - } - - @Test - void doAsWrapsRefreshLoginFailureAsIOException() throws IOException { - long now = System.currentTimeMillis(); - // TGT already past its 80% refresh point, so the first doAs attempts a relogin - FakeLoginAuthenticator.LOGINS.add(subjectWithTgt(now - 100_000, now + 1_000)); - - KerberosHadoopAuthenticator auth = new FakeLoginAuthenticator(); - FakeLoginAuthenticator.nextLoginFailure = - new RuntimeException(new javax.security.auth.login.LoginException("kdc down")); - - IOException thrown = Assertions.assertThrows(IOException.class, () -> auth.doAs(() -> "ok")); - Assertions.assertTrue(thrown.getMessage().contains("Kerberos relogin failed")); - Assertions.assertTrue(thrown.getMessage().contains("doris/test@EXAMPLE.COM")); - - // a later doAs retries the login (nextRefreshTime was not advanced) and succeeds - FakeLoginAuthenticator.LOGINS.add(subjectWithTgt(now, now + 600_000)); - Assertions.assertEquals("ok", auth.doAs(() -> "ok")); - } -} diff --git a/fe/fe-filesystem/fe-filesystem-http/src/main/assembly/plugin-zip.xml b/fe/fe-filesystem/fe-filesystem-http/src/main/assembly/plugin-zip.xml index e65d143ff9b7d2..a9e03c9be9902c 100644 --- a/fe/fe-filesystem/fe-filesystem-http/src/main/assembly/plugin-zip.xml +++ b/fe/fe-filesystem/fe-filesystem-http/src/main/assembly/plugin-zip.xml @@ -59,6 +59,9 @@ under the License. org.apache.doris:fe-filesystem-api org.apache.doris:fe-filesystem-spi org.apache.doris:fe-extension-spi + + org.apache.logging.log4j:* + org.slf4j:* diff --git a/fe/fe-filesystem/fe-filesystem-local/src/main/assembly/plugin-zip.xml b/fe/fe-filesystem/fe-filesystem-local/src/main/assembly/plugin-zip.xml index e65d143ff9b7d2..a9e03c9be9902c 100644 --- a/fe/fe-filesystem/fe-filesystem-local/src/main/assembly/plugin-zip.xml +++ b/fe/fe-filesystem/fe-filesystem-local/src/main/assembly/plugin-zip.xml @@ -59,6 +59,9 @@ under the License. org.apache.doris:fe-filesystem-api org.apache.doris:fe-filesystem-spi org.apache.doris:fe-extension-spi + + org.apache.logging.log4j:* + org.slf4j:* diff --git a/fe/fe-filesystem/fe-filesystem-obs/src/main/assembly/plugin-zip.xml b/fe/fe-filesystem/fe-filesystem-obs/src/main/assembly/plugin-zip.xml index 0b9a3bf90626a6..f8175a15e32e9a 100644 --- a/fe/fe-filesystem/fe-filesystem-obs/src/main/assembly/plugin-zip.xml +++ b/fe/fe-filesystem/fe-filesystem-obs/src/main/assembly/plugin-zip.xml @@ -54,6 +54,11 @@ under the License. /lib false runtime + + + org.apache.logging.log4j:* + org.slf4j:* + diff --git a/fe/fe-filesystem/fe-filesystem-obs/src/main/java/org/apache/doris/filesystem/obs/ObsFileSystemProperties.java b/fe/fe-filesystem/fe-filesystem-obs/src/main/java/org/apache/doris/filesystem/obs/ObsFileSystemProperties.java index 795555f148b9f7..d213764fd8e3d4 100644 --- a/fe/fe-filesystem/fe-filesystem-obs/src/main/java/org/apache/doris/filesystem/obs/ObsFileSystemProperties.java +++ b/fe/fe-filesystem/fe-filesystem-obs/src/main/java/org/apache/doris/filesystem/obs/ObsFileSystemProperties.java @@ -250,6 +250,12 @@ private Map toBackendKv() { kv.put("AWS_REQUEST_TIMEOUT_MS", requestTimeoutMs); kv.put("AWS_CONNECTION_TIMEOUT_MS", connectionTimeoutMs); kv.put("use_path_style", usePathStyle); + // Mirror fe-core AbstractS3CompatibleProperties#getAwsCredentialsProviderTypeForBackend: + // anonymous access (no static credentials) emits ANONYMOUS; otherwise the key is omitted so + // BE uses SimpleAWSCredentialsProvider. OBS never configures a provider type explicitly. + if (StringUtils.isBlank(accessKey) && StringUtils.isBlank(secretKey)) { + kv.put("AWS_CREDENTIALS_PROVIDER_TYPE", "ANONYMOUS"); + } return Collections.unmodifiableMap(kv); } diff --git a/fe/fe-filesystem/fe-filesystem-obs/src/test/java/org/apache/doris/filesystem/obs/ObsFileSystemPropertiesTest.java b/fe/fe-filesystem/fe-filesystem-obs/src/test/java/org/apache/doris/filesystem/obs/ObsFileSystemPropertiesTest.java index 8f471ab3c11b3e..9e4119e6b045aa 100644 --- a/fe/fe-filesystem/fe-filesystem-obs/src/test/java/org/apache/doris/filesystem/obs/ObsFileSystemPropertiesTest.java +++ b/fe/fe-filesystem/fe-filesystem-obs/src/test/java/org/apache/doris/filesystem/obs/ObsFileSystemPropertiesTest.java @@ -93,6 +93,39 @@ void toBackendProperties_returnsOnlyAwsCompatibleKeysForBeAdapters() { Assertions.assertEquals("obs-bucket", backendMap.get("AWS_BUCKET")); Assertions.assertEquals("obs-role", backendMap.get("AWS_ROLE_ARN")); Assertions.assertFalse(backendMap.keySet().stream().anyMatch(key -> key.startsWith("OBS_"))); + // Parity with fe-core AbstractS3CompatibleProperties#getAwsCredentialsProviderTypeForBackend: + // when static credentials are present the type is omitted (BE uses SimpleAWSCredentialsProvider). + Assertions.assertNull(backendMap.get("AWS_CREDENTIALS_PROVIDER_TYPE")); + } + + @Test + void toBackendProperties_emitsAnonymousProviderTypeWhenNoStaticCredentials() { + ObsFileSystemProperties properties = ObsFileSystemProperties.of(Map.of( + "obs.endpoint", "https://obs.cn-north-4.myhuaweicloud.com")); + + Map backendMap = properties.toBackendProperties().orElseThrow().toMap(); + + // Parity with fe-core AbstractS3CompatibleProperties#getAwsCredentialsProviderTypeForBackend: + // both access key and secret key blank => anonymous access. + Assertions.assertEquals("ANONYMOUS", backendMap.get("AWS_CREDENTIALS_PROVIDER_TYPE")); + } + + @Test + void toMaps_emitObsTuningDefaultsWhenNotConfigured() { + ObsFileSystemProperties properties = ObsFileSystemProperties.of(Map.of( + "obs.endpoint", "https://obs.cn-north-4.myhuaweicloud.com")); + + // Parity with fe-core OBSProperties defaults (100 / 10000 / 10000). Literal expected values + // (not DEFAULT_* constants) so that mutating a default in the main class fails this guard. + Map beKv = properties.toMap(); + Assertions.assertEquals("100", beKv.get("AWS_MAX_CONNECTIONS")); + Assertions.assertEquals("10000", beKv.get("AWS_REQUEST_TIMEOUT_MS")); + Assertions.assertEquals("10000", beKv.get("AWS_CONNECTION_TIMEOUT_MS")); + + Map hadoopKv = properties.toHadoopConfigurationMap(); + Assertions.assertEquals("100", hadoopKv.get("fs.s3a.connection.maximum")); + Assertions.assertEquals("10000", hadoopKv.get("fs.s3a.connection.request.timeout")); + Assertions.assertEquals("10000", hadoopKv.get("fs.s3a.connection.timeout")); } @Test diff --git a/fe/fe-filesystem/fe-filesystem-oss/src/main/assembly/plugin-zip.xml b/fe/fe-filesystem/fe-filesystem-oss/src/main/assembly/plugin-zip.xml index 0b9a3bf90626a6..f8175a15e32e9a 100644 --- a/fe/fe-filesystem/fe-filesystem-oss/src/main/assembly/plugin-zip.xml +++ b/fe/fe-filesystem/fe-filesystem-oss/src/main/assembly/plugin-zip.xml @@ -54,6 +54,11 @@ under the License. /lib false runtime + + + org.apache.logging.log4j:* + org.slf4j:* + diff --git a/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/OssFileSystemProperties.java b/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/OssFileSystemProperties.java index 663b5596c242cd..8637c7120aeee1 100644 --- a/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/OssFileSystemProperties.java +++ b/fe/fe-filesystem/fe-filesystem-oss/src/main/java/org/apache/doris/filesystem/oss/OssFileSystemProperties.java @@ -250,6 +250,12 @@ private Map toBackendKv() { kv.put("AWS_REQUEST_TIMEOUT_MS", requestTimeoutMs); kv.put("AWS_CONNECTION_TIMEOUT_MS", connectionTimeoutMs); kv.put("use_path_style", usePathStyle); + // Mirror fe-core AbstractS3CompatibleProperties#getAwsCredentialsProviderTypeForBackend: + // anonymous access (no static credentials) emits ANONYMOUS; otherwise the key is omitted so + // BE uses SimpleAWSCredentialsProvider. OSS never configures a provider type explicitly. + if (StringUtils.isBlank(accessKey) && StringUtils.isBlank(secretKey)) { + kv.put("AWS_CREDENTIALS_PROVIDER_TYPE", "ANONYMOUS"); + } return Collections.unmodifiableMap(kv); } diff --git a/fe/fe-filesystem/fe-filesystem-oss/src/test/java/org/apache/doris/filesystem/oss/OssFileSystemPropertiesTest.java b/fe/fe-filesystem/fe-filesystem-oss/src/test/java/org/apache/doris/filesystem/oss/OssFileSystemPropertiesTest.java index a4d7a11c6ce962..b18a4f862dfc23 100644 --- a/fe/fe-filesystem/fe-filesystem-oss/src/test/java/org/apache/doris/filesystem/oss/OssFileSystemPropertiesTest.java +++ b/fe/fe-filesystem/fe-filesystem-oss/src/test/java/org/apache/doris/filesystem/oss/OssFileSystemPropertiesTest.java @@ -131,6 +131,39 @@ void toBackendProperties_returnsOnlyAwsCompatibleKeysForBeAdapters() { Assertions.assertEquals("oss-bucket", backendMap.get("AWS_BUCKET")); Assertions.assertEquals("oss-role", backendMap.get("AWS_ROLE_ARN")); Assertions.assertFalse(backendMap.keySet().stream().anyMatch(key -> key.startsWith("OSS_"))); + // Parity with fe-core AbstractS3CompatibleProperties#getAwsCredentialsProviderTypeForBackend: + // when static credentials are present the type is omitted (BE uses SimpleAWSCredentialsProvider). + Assertions.assertNull(backendMap.get("AWS_CREDENTIALS_PROVIDER_TYPE")); + } + + @Test + void toBackendProperties_emitsAnonymousProviderTypeWhenNoStaticCredentials() { + OssFileSystemProperties properties = OssFileSystemProperties.of(Map.of( + "oss.endpoint", "https://oss-cn-hangzhou.aliyuncs.com")); + + Map backendMap = properties.toBackendProperties().orElseThrow().toMap(); + + // Parity with fe-core AbstractS3CompatibleProperties#getAwsCredentialsProviderTypeForBackend: + // both access key and secret key blank => anonymous access. + Assertions.assertEquals("ANONYMOUS", backendMap.get("AWS_CREDENTIALS_PROVIDER_TYPE")); + } + + @Test + void toMaps_emitOssTuningDefaultsWhenNotConfigured() { + OssFileSystemProperties properties = OssFileSystemProperties.of(Map.of( + "oss.endpoint", "https://oss-cn-hangzhou.aliyuncs.com")); + + // Parity with fe-core OSSProperties defaults (100 / 10000 / 10000). Literal expected values + // (not DEFAULT_* constants) so that mutating a default in the main class fails this guard. + Map beKv = properties.toMap(); + Assertions.assertEquals("100", beKv.get("AWS_MAX_CONNECTIONS")); + Assertions.assertEquals("10000", beKv.get("AWS_REQUEST_TIMEOUT_MS")); + Assertions.assertEquals("10000", beKv.get("AWS_CONNECTION_TIMEOUT_MS")); + + Map hadoopKv = properties.toHadoopConfigurationMap(); + Assertions.assertEquals("100", hadoopKv.get("fs.s3a.connection.maximum")); + Assertions.assertEquals("10000", hadoopKv.get("fs.s3a.connection.request.timeout")); + Assertions.assertEquals("10000", hadoopKv.get("fs.s3a.connection.timeout")); } @Test diff --git a/fe/fe-filesystem/fe-filesystem-s3/src/main/assembly/plugin-zip.xml b/fe/fe-filesystem/fe-filesystem-s3/src/main/assembly/plugin-zip.xml index c706cc04711e7a..d298df8a81d7c9 100644 --- a/fe/fe-filesystem/fe-filesystem-s3/src/main/assembly/plugin-zip.xml +++ b/fe/fe-filesystem/fe-filesystem-s3/src/main/assembly/plugin-zip.xml @@ -55,6 +55,11 @@ under the License. /lib false runtime + + + org.apache.logging.log4j:* + org.slf4j:* + diff --git a/fe/fe-filesystem/fe-filesystem-s3/src/main/java/org/apache/doris/filesystem/s3/S3FileSystemProperties.java b/fe/fe-filesystem/fe-filesystem-s3/src/main/java/org/apache/doris/filesystem/s3/S3FileSystemProperties.java index 97cfbb2c4b03d0..fef2e8361f4d16 100644 --- a/fe/fe-filesystem/fe-filesystem-s3/src/main/java/org/apache/doris/filesystem/s3/S3FileSystemProperties.java +++ b/fe/fe-filesystem/fe-filesystem-s3/src/main/java/org/apache/doris/filesystem/s3/S3FileSystemProperties.java @@ -70,6 +70,13 @@ public final class S3FileSystemProperties public static final String DEFAULT_CREDENTIALS_PROVIDER_TYPE = "DEFAULT"; public static final String DEFAULT_REGION = "us-east-1"; + // Legacy MinioProperties defaulted the connection tuning knobs higher than S3 (100 / 10000 / 10000 + // vs 50 / 3000 / 1000). Restored for minio.*-keyed catalogs only, to preserve pre-SPI behavior. + private static final String MINIO_KEY_PREFIX = "minio."; + private static final String MINIO_DEFAULT_MAX_CONNECTIONS = "100"; + private static final String MINIO_DEFAULT_REQUEST_TIMEOUT_MS = "10000"; + private static final String MINIO_DEFAULT_CONNECTION_TIMEOUT_MS = "10000"; + private static final Pattern[] ENDPOINT_PATTERNS = new Pattern[] { Pattern.compile( "^(?:https?://)?(?:" @@ -85,14 +92,15 @@ public final class S3FileSystemProperties @Getter @ConnectorProperty(names = {ENDPOINT, "AWS_ENDPOINT", "endpoint", "ENDPOINT", "aws.endpoint", - "glue.endpoint", "aws.glue.endpoint"}, + "glue.endpoint", "aws.glue.endpoint", "minio.endpoint"}, required = false, description = "The endpoint of S3.") private String endpoint = ""; @Getter @ConnectorProperty(names = {REGION, "AWS_REGION", "region", "REGION", "aws.region", "glue.region", - "aws.glue.region", "iceberg.rest.signing-region", "rest.signing-region", "client.region"}, + "aws.glue.region", "iceberg.rest.signing-region", "rest.signing-region", "client.region", + "minio.region"}, required = false, isRegionField = true, description = "The region of S3.") @@ -102,7 +110,7 @@ public final class S3FileSystemProperties @ConnectorProperty(names = {ACCESS_KEY, "AWS_ACCESS_KEY", "access_key", "ACCESS_KEY", "glue.access_key", "aws.glue.access-key", "client.credentials-provider.glue.access_key", "iceberg.rest.access-key-id", - "s3.access-key-id"}, + "s3.access-key-id", "minio.access_key"}, required = false, description = "The access key of S3.") private String accessKey = ""; @@ -111,7 +119,7 @@ public final class S3FileSystemProperties @ConnectorProperty(names = {SECRET_KEY, "AWS_SECRET_KEY", "secret_key", "SECRET_KEY", "glue.secret_key", "aws.glue.secret-key", "client.credentials-provider.glue.secret_key", "iceberg.rest.secret-access-key", - "s3.secret-access-key"}, + "s3.secret-access-key", "minio.secret_key"}, required = false, sensitive = true, description = "The secret key of S3.") @@ -119,7 +127,8 @@ public final class S3FileSystemProperties @Getter @ConnectorProperty(names = {SESSION_TOKEN, "AWS_TOKEN", "session_token", - "s3.session-token", "iceberg.rest.session-token"}, + "aws.glue.session-token", + "s3.session-token", "iceberg.rest.session-token", "minio.session_token"}, required = false, sensitive = true, description = "The session token of S3.") @@ -150,25 +159,27 @@ public final class S3FileSystemProperties private String rootPath = ""; @Getter - @ConnectorProperty(names = {MAX_CONNECTIONS, "AWS_MAX_CONNECTIONS"}, + @ConnectorProperty(names = {MAX_CONNECTIONS, "AWS_MAX_CONNECTIONS", "minio.connection.maximum"}, required = false, description = "The maximum number of connections to S3.") private String maxConnections = DEFAULT_MAX_CONNECTIONS; @Getter - @ConnectorProperty(names = {REQUEST_TIMEOUT_MS, "AWS_REQUEST_TIMEOUT_MS"}, + @ConnectorProperty(names = {REQUEST_TIMEOUT_MS, "AWS_REQUEST_TIMEOUT_MS", + "minio.connection.request.timeout"}, required = false, description = "The request timeout of S3 in milliseconds.") private String requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS; @Getter - @ConnectorProperty(names = {CONNECTION_TIMEOUT_MS, "AWS_CONNECTION_TIMEOUT_MS"}, + @ConnectorProperty(names = {CONNECTION_TIMEOUT_MS, "AWS_CONNECTION_TIMEOUT_MS", + "minio.connection.timeout"}, required = false, description = "The connection timeout of S3 in milliseconds.") private String connectionTimeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS; @Getter - @ConnectorProperty(names = {USE_PATH_STYLE, "s3.path-style-access"}, + @ConnectorProperty(names = {USE_PATH_STYLE, "s3.path-style-access", "minio.use_path_style"}, required = false, description = "Whether to use path-style bucket addressing.") private String usePathStyle = "false"; @@ -374,6 +385,41 @@ private void normalizeForLegacyS3Compatibility() { if (StringUtils.containsIgnoreCase(endpoint, "glue") && StringUtils.isNotBlank(region)) { endpoint = buildS3Endpoint(region); } + applyLegacyMinioTuningDefaults(); + } + + /** + * Restores the legacy {@code MinioProperties} connection-tuning defaults (100 / 10000 / 10000) + * for catalogs keyed with {@code minio.*} properties. The typed S3 path defaults to 50 / 3000 / 1000, + * so without this a pure {@code minio.*} catalog that omits the tuning keys would silently change its + * connection-pool size and timeouts versus the pre-SPI behavior. Each knob is restored only when it + * was not supplied under any alias (so explicit values are honored), and the whole step is gated on a + * {@code minio.*} key being present so the canonical {@code s3.*} path is byte-for-byte unchanged. + */ + private void applyLegacyMinioTuningDefaults() { + boolean minioKeyed = rawProperties.entrySet().stream() + .anyMatch(e -> e.getKey().startsWith(MINIO_KEY_PREFIX) && StringUtils.isNotBlank(e.getValue())); + if (!minioKeyed) { + return; + } + if (!hasRawKey(MAX_CONNECTIONS, "AWS_MAX_CONNECTIONS", "minio.connection.maximum")) { + maxConnections = MINIO_DEFAULT_MAX_CONNECTIONS; + } + if (!hasRawKey(REQUEST_TIMEOUT_MS, "AWS_REQUEST_TIMEOUT_MS", "minio.connection.request.timeout")) { + requestTimeoutMs = MINIO_DEFAULT_REQUEST_TIMEOUT_MS; + } + if (!hasRawKey(CONNECTION_TIMEOUT_MS, "AWS_CONNECTION_TIMEOUT_MS", "minio.connection.timeout")) { + connectionTimeoutMs = MINIO_DEFAULT_CONNECTION_TIMEOUT_MS; + } + } + + private boolean hasRawKey(String... keys) { + for (String key : keys) { + if (StringUtils.isNotBlank(rawProperties.get(key))) { + return true; + } + } + return false; } private static String buildS3Endpoint(String region) { diff --git a/fe/fe-filesystem/fe-filesystem-s3/src/main/java/org/apache/doris/filesystem/s3/S3FileSystemProvider.java b/fe/fe-filesystem/fe-filesystem-s3/src/main/java/org/apache/doris/filesystem/s3/S3FileSystemProvider.java index 1022074791909b..dec9745416973d 100644 --- a/fe/fe-filesystem/fe-filesystem-s3/src/main/java/org/apache/doris/filesystem/s3/S3FileSystemProvider.java +++ b/fe/fe-filesystem/fe-filesystem-s3/src/main/java/org/apache/doris/filesystem/s3/S3FileSystemProvider.java @@ -46,13 +46,14 @@ public class S3FileSystemProvider implements FileSystemProvider red. + Map raw = new HashMap<>(); + raw.put("s3.endpoint", "https://s3.us-east-1.amazonaws.com"); + raw.put("region", "us-east-1"); + raw.put("aws.glue.access-key", "GAK"); + raw.put("aws.glue.secret-key", "GSK"); + raw.put("aws.glue.session-token", "GST"); + + S3FileSystemProperties props = S3FileSystemProperties.of(raw); + + Assertions.assertEquals("GAK", props.getAccessKey()); + Assertions.assertEquals("GSK", props.getSecretKey()); + Assertions.assertEquals("GST", props.getSessionToken(), + "the glue session token must reach the store, not just the access/secret key"); + } + @Test void provider_sensitivePropertyKeysCoverSecretsButNotAccessKey() { java.util.Set keys = new S3FileSystemProvider().sensitivePropertyKeys(); @@ -259,6 +280,123 @@ void of_bindsAndNormalizesCredentialsProviderType() { properties.toHadoopConfigurationMap().get("fs.s3a.aws.credentials.provider")); } + @Test + void toMaps_emitS3TuningDefaultsWhenNotConfigured() { + Map raw = new HashMap<>(); + raw.put("s3.endpoint", "https://s3.us-west-2.amazonaws.com"); + raw.put("s3.access_key", "ak"); + raw.put("s3.secret_key", "sk"); + + S3FileSystemProperties properties = S3FileSystemProperties.of(raw); + + // Parity with fe-core S3Properties.Env defaults (50 / 3000 / 1000). Literal expected values + // (not DEFAULT_* constants) so that mutating a default in the main class fails this guard. + Map beKv = properties.toFileSystemKv(); + Assertions.assertEquals("50", beKv.get("AWS_MAX_CONNECTIONS")); + Assertions.assertEquals("3000", beKv.get("AWS_REQUEST_TIMEOUT_MS")); + Assertions.assertEquals("1000", beKv.get("AWS_CONNECTION_TIMEOUT_MS")); + + Map hadoopKv = properties.toHadoopConfigurationMap(); + Assertions.assertEquals("50", hadoopKv.get("fs.s3a.connection.maximum")); + Assertions.assertEquals("3000", hadoopKv.get("fs.s3a.connection.request.timeout")); + Assertions.assertEquals("1000", hadoopKv.get("fs.s3a.connection.timeout")); + } + + @Test + void of_bindsPureMinioAliasesAndHonorsExplicitTuning() { + // C1: legacy minio.* keys bind to the typed fields, and explicitly-set tuning values win over + // the legacy minio defaults. + Map raw = new HashMap<>(); + raw.put("minio.endpoint", "http://127.0.0.1:9000"); + raw.put("minio.access_key", "minio-ak"); + raw.put("minio.secret_key", "minio-sk"); + raw.put("minio.session_token", "minio-token"); + raw.put("minio.connection.maximum", "200"); + raw.put("minio.connection.request.timeout", "20000"); + raw.put("minio.connection.timeout", "20000"); + raw.put("minio.use_path_style", "true"); + + S3FileSystemProperties properties = S3FileSystemProperties.of(raw); + + Assertions.assertEquals("http://127.0.0.1:9000", properties.getEndpoint()); + Assertions.assertEquals("minio-ak", properties.getAccessKey()); + Assertions.assertEquals("minio-sk", properties.getSecretKey()); + Assertions.assertEquals("minio-token", properties.getSessionToken()); + Assertions.assertEquals("200", properties.getMaxConnections()); + Assertions.assertEquals("20000", properties.getRequestTimeoutMs()); + Assertions.assertEquals("20000", properties.getConnectionTimeoutMs()); + Assertions.assertEquals("true", properties.getUsePathStyle()); + } + + @Test + void of_minioEndpointOnly_appliesUsEast1RegionDefaultAndEmitsS3aAndAwsKeys() { + Map raw = new HashMap<>(); + raw.put("minio.endpoint", "http://127.0.0.1:9000"); + raw.put("minio.access_key", "ak"); + raw.put("minio.secret_key", "sk"); + + S3FileSystemProperties properties = S3FileSystemProperties.of(raw); + + // Parity with legacy MinioProperties region default ("us-east-1"). + Assertions.assertEquals("us-east-1", properties.getRegion()); + + // FE-side Hadoop config: fixes the "no file io for scheme s3" symptom on the catalog-create path. + Map hadoop = properties.toHadoopConfigurationMap(); + Assertions.assertEquals("org.apache.hadoop.fs.s3a.S3AFileSystem", hadoop.get("fs.s3.impl")); + Assertions.assertEquals("org.apache.hadoop.fs.s3a.S3AFileSystem", hadoop.get("fs.s3a.impl")); + Assertions.assertEquals("http://127.0.0.1:9000", hadoop.get("fs.s3a.endpoint")); + Assertions.assertEquals("us-east-1", hadoop.get("fs.s3a.endpoint.region")); + Assertions.assertEquals("ak", hadoop.get("fs.s3a.access.key")); + Assertions.assertEquals("sk", hadoop.get("fs.s3a.secret.key")); + + // BE-side creds: fixes the empty location.AWS_* that broke native paimon reads. + Map beKv = properties.toFileSystemKv(); + Assertions.assertEquals("http://127.0.0.1:9000", beKv.get("AWS_ENDPOINT")); + Assertions.assertEquals("us-east-1", beKv.get("AWS_REGION")); + Assertions.assertEquals("ak", beKv.get("AWS_ACCESS_KEY")); + Assertions.assertEquals("sk", beKv.get("AWS_SECRET_KEY")); + } + + @Test + void of_minioOmittingTuning_appliesLegacyMinioTuningDefaults() { + // Legacy MinioProperties defaulted tuning to 100 / 10000 / 10000, NOT the S3 50 / 3000 / 1000. + // A minio.*-keyed catalog that omits the tuning keys must preserve those legacy defaults; this + // encodes the deliberate restoration so it cannot silently regress to the S3 defaults. + Map raw = new HashMap<>(); + raw.put("minio.endpoint", "http://127.0.0.1:9000"); + raw.put("minio.access_key", "ak"); + raw.put("minio.secret_key", "sk"); + + S3FileSystemProperties properties = S3FileSystemProperties.of(raw); + + Map beKv = properties.toFileSystemKv(); + Assertions.assertEquals("100", beKv.get("AWS_MAX_CONNECTIONS")); + Assertions.assertEquals("10000", beKv.get("AWS_REQUEST_TIMEOUT_MS")); + Assertions.assertEquals("10000", beKv.get("AWS_CONNECTION_TIMEOUT_MS")); + + Map hadoop = properties.toHadoopConfigurationMap(); + Assertions.assertEquals("100", hadoop.get("fs.s3a.connection.maximum")); + Assertions.assertEquals("10000", hadoop.get("fs.s3a.connection.request.timeout")); + Assertions.assertEquals("10000", hadoop.get("fs.s3a.connection.timeout")); + } + + @Test + void of_s3KeyOutranksMinioKeyForSameField() { + // Byte-parity guard: with both s3.* and minio.* present, s3.* must win because the minio.* + // aliases are appended LAST in each field's names(). Protects the canonical s3.* path. + Map raw = new HashMap<>(); + raw.put("s3.endpoint", "https://canonical.s3"); + raw.put("minio.endpoint", "http://minio.shadowed"); + raw.put("s3.access_key", "s3-ak"); + raw.put("minio.access_key", "minio-ak"); + raw.put("s3.secret_key", "s3-sk"); + + S3FileSystemProperties properties = S3FileSystemProperties.of(raw); + + Assertions.assertEquals("https://canonical.s3", properties.getEndpoint()); + Assertions.assertEquals("s3-ak", properties.getAccessKey()); + } + @Test void of_rejectsUnsupportedCredentialsProviderType() { Map raw = new HashMap<>(); diff --git a/fe/fe-filesystem/fe-filesystem-s3/src/test/java/org/apache/doris/filesystem/s3/S3FileSystemProviderTest.java b/fe/fe-filesystem/fe-filesystem-s3/src/test/java/org/apache/doris/filesystem/s3/S3FileSystemProviderTest.java index fe5c21d9515460..d04ae48b97312f 100644 --- a/fe/fe-filesystem/fe-filesystem-s3/src/test/java/org/apache/doris/filesystem/s3/S3FileSystemProviderTest.java +++ b/fe/fe-filesystem/fe-filesystem-s3/src/test/java/org/apache/doris/filesystem/s3/S3FileSystemProviderTest.java @@ -89,6 +89,19 @@ void supports_acceptsExplicitS3SupportWithoutCredentials() { Assertions.assertTrue(provider.supports(props)); } + @Test + void supports_acceptsPureMinioKeyedConfiguration() { + // C1: a catalog keyed purely with minio.* properties (legacy MinioProperties namespace) must + // bind via the shared S3 provider. Before the minio.* aliases were added, this returned false, + // leaving storage unbound ("no file io for scheme s3"). + Map props = new HashMap<>(); + props.put("minio.endpoint", "http://127.0.0.1:9000"); + props.put("minio.access_key", "ak"); + props.put("minio.secret_key", "sk"); + + Assertions.assertTrue(provider.supports(props)); + } + @Test void bind_returnsValidatedS3FileSystemProperties() { Map props = new HashMap<>(); diff --git a/fe/fe-filesystem/fe-filesystem-spi/pom.xml b/fe/fe-filesystem/fe-filesystem-spi/pom.xml index 10c5f2223c91a4..b8912c2f6cddc3 100644 --- a/fe/fe-filesystem/fe-filesystem-spi/pom.xml +++ b/fe/fe-filesystem/fe-filesystem-spi/pom.xml @@ -35,9 +35,9 @@ under the License. Service Provider Interface (SPI) for Doris FE filesystem abstraction. Contains FileSystemProvider (ServiceLoader SPI entry point), the ObjStorage SPI layer - for object-storage backends, HadoopAuthenticator, and their supporting value types. - Depends only on JDK and Doris internal modules (fe-filesystem-api, fe-extension-spi, - fe-foundation). This is the ONLY filesystem artifact compiled into fe-core. + for object-storage backends, and their supporting value types. + Zero third-party external dependencies — only JDK and Doris internal SPI interfaces. + This is the ONLY filesystem artifact compiled into fe-core. diff --git a/fe/fe-filesystem/fe-filesystem-spi/src/main/java/org/apache/doris/filesystem/spi/HadoopAuthenticator.java b/fe/fe-filesystem/fe-filesystem-spi/src/main/java/org/apache/doris/filesystem/spi/HadoopAuthenticator.java deleted file mode 100644 index e8c0b4880389f2..00000000000000 --- a/fe/fe-filesystem/fe-filesystem-spi/src/main/java/org/apache/doris/filesystem/spi/HadoopAuthenticator.java +++ /dev/null @@ -1,45 +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.filesystem.spi; - -import java.io.IOException; - -/** - * Abstraction for Hadoop-style privileged execution (Kerberos doAs). - * - * Defined here in fe-filesystem-spi with zero external dependencies, using - * {@link IOCallable} instead of {@code java.security.PrivilegedExceptionAction} - * to avoid Hadoop API dependency. Implementations live in fe-core (Kerberos/Simple - * authenticators) and are injected into DFSFileSystem at construction time. - * - * P3.0a: This interface replaces the Hadoop-dependent HadoopAuthenticator from - * fe-common for use in fe-filesystem-hdfs module. - */ -public interface HadoopAuthenticator { - - /** - * Executes the given action under this authenticator's security context - * (e.g., as a specific Kerberos principal or simple user). - * - * @param action the IO operation to execute - * @param the return type - * @return the result of the action - * @throws IOException if the action throws or authentication fails - */ - T doAs(IOCallable action) throws IOException; -} diff --git a/fe/fe-filesystem/fe-filesystem-spi/src/main/java/org/apache/doris/filesystem/spi/IOCallable.java b/fe/fe-filesystem/fe-filesystem-spi/src/main/java/org/apache/doris/filesystem/spi/IOCallable.java deleted file mode 100644 index fc211fed8e1885..00000000000000 --- a/fe/fe-filesystem/fe-filesystem-spi/src/main/java/org/apache/doris/filesystem/spi/IOCallable.java +++ /dev/null @@ -1,32 +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.filesystem.spi; - -import java.io.IOException; - -/** - * IO-throwing variant of {@link java.util.concurrent.Callable}. - * Used in place of {@code java.security.PrivilegedExceptionAction} to avoid - * Hadoop API dependency in fe-filesystem-spi. - * - * @param the return type of the callable - */ -@FunctionalInterface -public interface IOCallable { - T call() throws IOException; -} diff --git a/fe/fe-foundation/pom.xml b/fe/fe-foundation/pom.xml index 6565fd20d0854d..c4f41fd43083b4 100644 --- a/fe/fe-foundation/pom.xml +++ b/fe/fe-foundation/pom.xml @@ -32,9 +32,15 @@ under the License. Foundation utilities for Doris FE modules and SPI plugins + it.unimi.dsi fastutil-core + true com.google.code.gson diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/ArgumentParser.java b/fe/fe-foundation/src/main/java/org/apache/doris/foundation/util/ArgumentParser.java similarity index 97% rename from fe/fe-core/src/main/java/org/apache/doris/common/ArgumentParser.java rename to fe/fe-foundation/src/main/java/org/apache/doris/foundation/util/ArgumentParser.java index b9195fb24d47e2..3d8c02147a494c 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/common/ArgumentParser.java +++ b/fe/fe-foundation/src/main/java/org/apache/doris/foundation/util/ArgumentParser.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common; +package org.apache.doris.foundation.util; /** * Interface for argument parsers that validate and parse string values to typed diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/ArgumentParsers.java b/fe/fe-foundation/src/main/java/org/apache/doris/foundation/util/ArgumentParsers.java similarity index 91% rename from fe/fe-core/src/main/java/org/apache/doris/common/ArgumentParsers.java rename to fe/fe-foundation/src/main/java/org/apache/doris/foundation/util/ArgumentParsers.java index f10762b1b89d72..f81dc7c8b020e6 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/common/ArgumentParsers.java +++ b/fe/fe-foundation/src/main/java/org/apache/doris/foundation/util/ArgumentParsers.java @@ -15,9 +15,9 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common; +package org.apache.doris.foundation.util; -import com.google.common.base.Preconditions; +import java.util.Objects; /** * Common argument parsers for NamedArguments. @@ -47,7 +47,7 @@ public class ArgumentParsers { */ public static ArgumentParser nonEmptyString(String paramName) { return value -> { - Preconditions.checkNotNull(value, paramName + " cannot be null"); + Objects.requireNonNull(value, paramName + " cannot be null"); if (value.trim().isEmpty()) { throw new IllegalArgumentException(paramName + " cannot be empty"); } @@ -65,7 +65,7 @@ public static ArgumentParser nonEmptyString(String paramName) { */ public static ArgumentParser stringLength(String paramName, int minLength, int maxLength) { return value -> { - Preconditions.checkNotNull(value, paramName + " cannot be null"); + Objects.requireNonNull(value, paramName + " cannot be null"); String trimmed = value.trim(); int length = trimmed.length(); if (length < minLength || length > maxLength) { @@ -86,7 +86,7 @@ public static ArgumentParser stringLength(String paramName, int minLengt */ public static ArgumentParser stringChoice(String paramName, String... allowedValues) { return value -> { - Preconditions.checkNotNull(value, paramName + " cannot be null"); + Objects.requireNonNull(value, paramName + " cannot be null"); String trimmed = value.trim(); for (String allowed : allowedValues) { if (allowed.equals(trimmed)) { @@ -108,7 +108,7 @@ public static ArgumentParser stringChoice(String paramName, String... al */ public static ArgumentParser stringChoiceIgnoreCase(String paramName, String... allowedValues) { return value -> { - Preconditions.checkNotNull(value, paramName + " cannot be null"); + Objects.requireNonNull(value, paramName + " cannot be null"); String trimmed = value.trim(); for (String allowed : allowedValues) { if (allowed.equalsIgnoreCase(trimmed)) { @@ -131,7 +131,7 @@ public static ArgumentParser stringChoiceIgnoreCase(String paramName, St */ public static ArgumentParser positiveInt(String paramName) { return value -> { - Preconditions.checkNotNull(value, paramName + " cannot be null"); + Objects.requireNonNull(value, paramName + " cannot be null"); try { int intValue = Integer.parseInt(value.trim()); if (intValue <= 0) { @@ -154,7 +154,7 @@ public static ArgumentParser positiveInt(String paramName) { */ public static ArgumentParser intRange(String paramName, int minValue, int maxValue) { return value -> { - Preconditions.checkNotNull(value, paramName + " cannot be null"); + Objects.requireNonNull(value, paramName + " cannot be null"); try { int intValue = Integer.parseInt(value.trim()); if (intValue < minValue || intValue > maxValue) { @@ -178,7 +178,7 @@ public static ArgumentParser intRange(String paramName, int minValue, i */ public static ArgumentParser positiveLong(String paramName) { return value -> { - Preconditions.checkNotNull(value, paramName + " cannot be null"); + Objects.requireNonNull(value, paramName + " cannot be null"); try { long longValue = Long.parseLong(value.trim()); if (longValue <= 0) { @@ -199,7 +199,7 @@ public static ArgumentParser positiveLong(String paramName) { */ public static ArgumentParser nonNegativeLong(String paramName) { return value -> { - Preconditions.checkNotNull(value, paramName + " cannot be null"); + Objects.requireNonNull(value, paramName + " cannot be null"); try { long longValue = Long.parseLong(value.trim()); if (longValue < 0) { @@ -222,7 +222,7 @@ public static ArgumentParser nonNegativeLong(String paramName) { */ public static ArgumentParser longRange(String paramName, long minValue, long maxValue) { return value -> { - Preconditions.checkNotNull(value, paramName + " cannot be null"); + Objects.requireNonNull(value, paramName + " cannot be null"); try { long longValue = Long.parseLong(value.trim()); if (longValue < minValue || longValue > maxValue) { @@ -246,7 +246,7 @@ public static ArgumentParser longRange(String paramName, long minValue, lo */ public static ArgumentParser positiveDouble(String paramName) { return value -> { - Preconditions.checkNotNull(value, paramName + " cannot be null"); + Objects.requireNonNull(value, paramName + " cannot be null"); try { double doubleValue = Double.parseDouble(value.trim()); if (doubleValue <= 0) { @@ -269,7 +269,7 @@ public static ArgumentParser positiveDouble(String paramName) { */ public static ArgumentParser doubleRange(String paramName, double minValue, double maxValue) { return value -> { - Preconditions.checkNotNull(value, paramName + " cannot be null"); + Objects.requireNonNull(value, paramName + " cannot be null"); try { double doubleValue = Double.parseDouble(value.trim()); if (doubleValue < minValue || doubleValue > maxValue) { @@ -294,7 +294,7 @@ public static ArgumentParser doubleRange(String paramName, double minVal */ public static ArgumentParser booleanValue(String paramName) { return value -> { - Preconditions.checkNotNull(value, paramName + " cannot be null"); + Objects.requireNonNull(value, paramName + " cannot be null"); String trimmed = value.trim(); if ("true".equalsIgnoreCase(trimmed)) { return Boolean.TRUE; diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/NamedArguments.java b/fe/fe-foundation/src/main/java/org/apache/doris/foundation/util/NamedArguments.java similarity index 93% rename from fe/fe-core/src/main/java/org/apache/doris/common/NamedArguments.java rename to fe/fe-foundation/src/main/java/org/apache/doris/foundation/util/NamedArguments.java index 2c58fd32aeebdb..3ca56203d40d02 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/common/NamedArguments.java +++ b/fe/fe-foundation/src/main/java/org/apache/doris/foundation/util/NamedArguments.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common; +package org.apache.doris.foundation.util; import java.util.ArrayList; import java.util.HashMap; @@ -32,6 +32,11 @@ * After validation, parsed values are stored internally and can be retrieved * using type-safe getter methods. * + *

    Lives in {@code fe-foundation} so both the engine ({@code fe-core}) and the connector modules can + * share it. {@link #validate} signals failures with an unchecked {@link IllegalArgumentException} (the + * lowest common denominator across modules); callers wrap it in their own domain exception while keeping + * the message verbatim (e.g. {@code fe-core} re-wraps it as {@code AnalysisException}). + * *

    * Usage example: * @@ -116,16 +121,16 @@ public void addAllowedArgument(String argumentName) { * 5. Store parsed values for later retrieval * * @param properties The property map to validate and parse - * @throws AnalysisException If validation or parsing fails + * @throws IllegalArgumentException If validation or parsing fails */ - public void validate(Map properties) throws AnalysisException { + public void validate(Map properties) throws IllegalArgumentException { // Clear previous parsed values parsedValues.clear(); // Check for unknown arguments for (String providedArg : properties.keySet()) { if (!isRegisteredArgument(providedArg) && !isAllowedArgument(providedArg)) { - throw new AnalysisException("Unknown argument: " + providedArg); + throw new IllegalArgumentException("Unknown argument: " + providedArg); } } @@ -135,7 +140,7 @@ public void validate(Map properties) throws AnalysisException { // Check required arguments if (arg.isRequired() && stringValue == null) { - throw new AnalysisException("Missing required argument: " + arg.getName()); + throw new IllegalArgumentException("Missing required argument: " + arg.getName()); } // Determine the value to parse (either provided or default) @@ -145,7 +150,7 @@ public void validate(Map properties) throws AnalysisException { try { valueToStore = arg.getParser().parse(stringValue); } catch (IllegalArgumentException e) { - throw new AnalysisException(String.format( + throw new IllegalArgumentException(String.format( "Invalid value for argument '%s': %s. %s", arg.getName(), stringValue, e.getMessage())); } diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/ArgumentParsersTest.java b/fe/fe-foundation/src/test/java/org/apache/doris/foundation/util/ArgumentParsersTest.java similarity index 99% rename from fe/fe-core/src/test/java/org/apache/doris/common/ArgumentParsersTest.java rename to fe/fe-foundation/src/test/java/org/apache/doris/foundation/util/ArgumentParsersTest.java index 8b19c5b18087a5..9d9fcf463fd65f 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/common/ArgumentParsersTest.java +++ b/fe/fe-foundation/src/test/java/org/apache/doris/foundation/util/ArgumentParsersTest.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common; +package org.apache.doris.foundation.util; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/NamedArgumentsTest.java b/fe/fe-foundation/src/test/java/org/apache/doris/foundation/util/NamedArgumentsTest.java similarity index 91% rename from fe/fe-core/src/test/java/org/apache/doris/common/NamedArgumentsTest.java rename to fe/fe-foundation/src/test/java/org/apache/doris/foundation/util/NamedArgumentsTest.java index d0ede80ec43735..ed616891d05f0a 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/common/NamedArgumentsTest.java +++ b/fe/fe-foundation/src/test/java/org/apache/doris/foundation/util/NamedArgumentsTest.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common; +package org.apache.doris.foundation.util; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -68,7 +68,7 @@ void testRegisterOptionalArgument() { } @Test - void testValidateWithRequiredArguments() throws AnalysisException { + void testValidateWithRequiredArguments() { // Register required arguments namedArguments.registerRequiredArgument("host", "Server host", ArgumentParsers.nonEmptyString("host")); @@ -88,7 +88,7 @@ void testValidateWithRequiredArguments() throws AnalysisException { } @Test - void testValidateWithOptionalArguments() throws AnalysisException { + void testValidateWithOptionalArguments() { // Register optional arguments with defaults namedArguments.registerOptionalArgument("timeout", "Request timeout", 30, ArgumentParsers.positiveInt("timeout")); @@ -114,7 +114,7 @@ void testValidateWithOptionalArguments() throws AnalysisException { } @Test - void testValidateWithMixedArguments() throws AnalysisException { + void testValidateWithMixedArguments() { // Register mixed required and optional arguments namedArguments.registerRequiredArgument("name", "Service name", ArgumentParsers.nonEmptyString("name")); @@ -144,7 +144,7 @@ void testValidateFailsOnMissingRequiredArgument() { Map properties = new HashMap<>(); // host is missing - AnalysisException exception = Assertions.assertThrows(AnalysisException.class, + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, () -> namedArguments.validate(properties)); Assertions.assertTrue(exception.getMessage().contains("Missing required argument: host")); } @@ -157,7 +157,7 @@ void testValidateFailsOnEmptyRequiredArgument() { Map properties = new HashMap<>(); properties.put("host", " "); // Empty string - AnalysisException exception = Assertions.assertThrows(AnalysisException.class, + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, () -> namedArguments.validate(properties)); Assertions.assertTrue(exception.getMessage().contains("host cannot be empty")); } @@ -171,7 +171,7 @@ void testValidateFailsOnUnknownArgument() { properties.put("timeout", "60"); properties.put("unknown_param", "value"); // Unknown argument - AnalysisException exception = Assertions.assertThrows(AnalysisException.class, + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, () -> namedArguments.validate(properties)); Assertions.assertTrue(exception.getMessage().contains("Unknown argument: unknown_param")); } @@ -184,7 +184,7 @@ void testValidateFailsOnInvalidValue() { Map properties = new HashMap<>(); properties.put("port", "not-a-number"); - AnalysisException exception = Assertions.assertThrows(AnalysisException.class, + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, () -> namedArguments.validate(properties)); Assertions.assertTrue(exception.getMessage().contains("Invalid value for argument 'port'")); } @@ -197,13 +197,13 @@ void testValidateFailsOnInvalidPositiveInt() { Map properties = new HashMap<>(); properties.put("port", "-1"); // Negative number - AnalysisException exception = Assertions.assertThrows(AnalysisException.class, + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, () -> namedArguments.validate(properties)); Assertions.assertTrue(exception.getMessage().contains("Invalid value for argument 'port'")); } @Test - void testAllowedArguments() throws AnalysisException { + void testAllowedArguments() { namedArguments.registerRequiredArgument("name", "Service name", ArgumentParsers.nonEmptyString("name")); namedArguments.addAllowedArgument("system_param"); @@ -221,7 +221,7 @@ void testAllowedArguments() throws AnalysisException { } @Test - void testGetTypedValues() throws AnalysisException { + void testGetTypedValues() { namedArguments.registerOptionalArgument("timeout", "Timeout", 30, ArgumentParsers.positiveInt("timeout")); namedArguments.registerOptionalArgument("rate", "Rate", 1.5, @@ -253,7 +253,7 @@ void testGetTypedValues() throws AnalysisException { } @Test - void testGetValueWithGenericType() throws AnalysisException { + void testGetValueWithGenericType() { namedArguments.registerOptionalArgument("count", "Count", 100, ArgumentParsers.positiveInt("count")); @@ -275,7 +275,7 @@ void testGetValueWithGenericType() throws AnalysisException { } @Test - void testGetNullValues() throws AnalysisException { + void testGetNullValues() { namedArguments.registerOptionalArgument("optional_param", "Optional", null, ArgumentParsers.nonEmptyString("optional_param")); @@ -321,7 +321,7 @@ void testArgumentDefinitionToString() { } @Test - void testValidateWithStringChoice() throws AnalysisException { + void testValidateWithStringChoice() { namedArguments.registerOptionalArgument("level", "Log level", "INFO", ArgumentParsers.stringChoice("level", "DEBUG", "INFO", "WARN", "ERROR")); @@ -333,13 +333,13 @@ void testValidateWithStringChoice() throws AnalysisException { // Test invalid choice properties.put("level", "INVALID"); - AnalysisException exception = Assertions.assertThrows(AnalysisException.class, + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, () -> namedArguments.validate(properties)); Assertions.assertTrue(exception.getMessage().contains("must be one of")); } @Test - void testValidateWithRangedInt() throws AnalysisException { + void testValidateWithRangedInt() { namedArguments.registerOptionalArgument("port", "Port number", 8080, ArgumentParsers.intRange("port", 1, 65535)); @@ -351,13 +351,13 @@ void testValidateWithRangedInt() throws AnalysisException { // Test out of range properties.put("port", "70000"); - AnalysisException exception = Assertions.assertThrows(AnalysisException.class, + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, () -> namedArguments.validate(properties)); Assertions.assertTrue(exception.getMessage().contains("must be between 1 and 65535")); } @Test - void testMultipleValidationCalls() throws AnalysisException { + void testMultipleValidationCalls() { namedArguments.registerOptionalArgument("value", "Test value", 10, ArgumentParsers.positiveInt("value")); @@ -380,7 +380,7 @@ void testMultipleValidationCalls() throws AnalysisException { } @Test - void testValidateWithEmptyProperties() throws AnalysisException { + void testValidateWithEmptyProperties() { // Only optional arguments namedArguments.registerOptionalArgument("debug", "Debug mode", false, ArgumentParsers.booleanValue("debug")); diff --git a/fe/fe-kerberos/pom.xml b/fe/fe-kerberos/pom.xml new file mode 100644 index 00000000000000..c8e457ab0967a0 --- /dev/null +++ b/fe/fe-kerberos/pom.xml @@ -0,0 +1,93 @@ + + + + 4.0.0 + + org.apache.doris + ${revision} + fe + ../pom.xml + + fe-kerberos + jar + Doris FE Kerberos + + Neutral, top-level leaf module for Hadoop Kerberos authentication facts and + machinery shared by fe-common, fe-filesystem-*, and fe-connector-* (D-007). + Carries the neutral facts types (AuthType, KerberosAuthSpec) consumed by the + metastore connector API plus the Hadoop authenticator machinery (authentication + config + UGI doAs authenticators) relocated here as the single source of truth + (P3b-T01 / D-017). Depends only on Hadoop and JDK — no FE module dependency. + + + + + com.google.guava + guava + + + org.apache.commons + commons-lang3 + + + org.projectlombok + lombok + + + org.apache.logging.log4j + log4j-api + + + org.apache.hadoop + hadoop-common + + + commons-collections + commons-collections + + + org.apache.commons + commons-compress + + + provided + + + org.junit.jupiter + junit-jupiter + test + + + + + doris-fe-kerberos + ${project.basedir}/target/ + + + org.apache.maven.plugins + maven-javadoc-plugin + + true + + + + + diff --git a/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/AuthType.java b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/AuthType.java new file mode 100644 index 00000000000000..1fdd0b935d1495 --- /dev/null +++ b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/AuthType.java @@ -0,0 +1,68 @@ +// 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.kerberos; + +/** + * Hadoop authentication type for external table/metastore/storage connections + * (e.g. hive/iceberg/paimon), so that BE can run secured under the file storage + * system when Kerberos is enabled. + * + *

    Lives in this neutral leaf module as the single source of truth, so both + * fe-common consumers and connectors (which cannot depend on fe-common) can + * express the auth type as a neutral fact. + */ +public enum AuthType { + SIMPLE("simple"), + KERBEROS("kerberos"); + + private final String desc; + + AuthType(String desc) { + this.desc = desc; + } + + public static boolean isSupportedAuthType(String authType) { + for (AuthType auth : values()) { + if (auth.getDesc().equals(authType)) { + return true; + } + } + return false; + } + + /** Returns the lowercase wire name ("simple" / "kerberos"). */ + public String getDesc() { + return desc; + } + + /** + * Resolves an auth-type string to {@link #KERBEROS} when (and only when) it equals + * {@code "kerberos"} case-insensitively; every other value — including {@code null}, + * blank, {@code "none"} and {@code "simple"} — resolves to {@link #SIMPLE}. + * + *

    This matches the legacy semantics that treat the connection as Kerberos-secured + * solely when the auth type is explicitly {@code kerberos}, and otherwise fall back to + * simple authentication. + */ + public static AuthType fromString(String value) { + if (value != null && KERBEROS.desc.equalsIgnoreCase(value.trim())) { + return KERBEROS; + } + return SIMPLE; + } +} diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/AuthenticationConfig.java b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/AuthenticationConfig.java similarity index 98% rename from fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/AuthenticationConfig.java rename to fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/AuthenticationConfig.java index 41804037a38d69..efd23b83b191a5 100644 --- a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/AuthenticationConfig.java +++ b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/AuthenticationConfig.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common.security.authentication; +package org.apache.doris.kerberos; import com.google.common.base.Strings; import org.apache.hadoop.conf.Configuration; diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/ExecutionAuthenticator.java b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/ExecutionAuthenticator.java similarity index 97% rename from fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/ExecutionAuthenticator.java rename to fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/ExecutionAuthenticator.java index 794766f4fd84ba..d92bfba32adb8c 100644 --- a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/ExecutionAuthenticator.java +++ b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/ExecutionAuthenticator.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common.security.authentication; +package org.apache.doris.kerberos; import java.util.concurrent.Callable; diff --git a/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/HadoopAuthenticator.java b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/HadoopAuthenticator.java new file mode 100644 index 00000000000000..615e7ae88976f9 --- /dev/null +++ b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/HadoopAuthenticator.java @@ -0,0 +1,60 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.kerberos; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.security.UserGroupInformation; + +import java.io.IOException; +import java.lang.reflect.UndeclaredThrowableException; +import java.security.PrivilegedExceptionAction; + +public interface HadoopAuthenticator { + + UserGroupInformation getUGI() throws IOException; + + default T doAs(PrivilegedExceptionAction action) throws IOException { + try { + return getUGI().doAs(action); + } catch (InterruptedException e) { + // Restore the interrupt flag: we are swallowing InterruptedException by rethrowing it as a + // checked IOException, so callers up the stack can still observe that the thread was interrupted. + Thread.currentThread().interrupt(); + throw new IOException(e); + } catch (UndeclaredThrowableException e) { + if (e.getCause() instanceof RuntimeException) { + throw (RuntimeException) e.getCause(); + } else { + throw new RuntimeException(e.getCause()); + } + } + } + + static HadoopAuthenticator getHadoopAuthenticator(AuthenticationConfig config) { + if (config instanceof KerberosAuthenticationConfig) { + return new HadoopKerberosAuthenticator((KerberosAuthenticationConfig) config); + } else { + return new HadoopSimpleAuthenticator((SimpleAuthenticationConfig) config); + } + } + + static HadoopAuthenticator getHadoopAuthenticator(Configuration configuration) { + AuthenticationConfig authConfig = AuthenticationConfig.getKerberosConfig(configuration); + return getHadoopAuthenticator(authConfig); + } +} diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/HadoopExecutionAuthenticator.java b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/HadoopExecutionAuthenticator.java similarity index 96% rename from fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/HadoopExecutionAuthenticator.java rename to fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/HadoopExecutionAuthenticator.java index c2c23eb539604d..53a1ecdba842d9 100644 --- a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/HadoopExecutionAuthenticator.java +++ b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/HadoopExecutionAuthenticator.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common.security.authentication; +package org.apache.doris.kerberos; import java.util.concurrent.Callable; diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/HadoopKerberosAuthenticator.java b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/HadoopKerberosAuthenticator.java similarity index 82% rename from fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/HadoopKerberosAuthenticator.java rename to fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/HadoopKerberosAuthenticator.java index 1f3d51c2be60ec..599f3dbb995c44 100644 --- a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/HadoopKerberosAuthenticator.java +++ b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/HadoopKerberosAuthenticator.java @@ -15,15 +15,14 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common.security.authentication; - -import org.apache.doris.foundation.security.KerberosTicketUtils; +package org.apache.doris.kerberos; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.CommonConfigurationKeysPublic; +import org.apache.hadoop.security.SecurityUtil; import org.apache.hadoop.security.UserGroupInformation; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -48,15 +47,35 @@ public class HadoopKerberosAuthenticator implements HadoopAuthenticator { private long nextRefreshTime; private UserGroupInformation ugi; + // The authentication method the first caller published to the process-wide UGI configuration. + // Guarded by HadoopKerberosAuthenticator.class; null until the first setConfiguration. + private static UserGroupInformation.AuthenticationMethod configuredAuthMethod = null; + public HadoopKerberosAuthenticator(KerberosAuthenticationConfig config) { this.config = config; } public static void initializeAuthConfig(Configuration hadoopConf) { hadoopConf.set(CommonConfigurationKeysPublic.HADOOP_SECURITY_AUTHORIZATION, "true"); + // UserGroupInformation.setConfiguration mutates a single process-wide global, so multiple HDFS + // catalogs with differing hadoop.security.authentication cannot truly coexist. First-writer-wins: + // only the first caller publishes the global config; later catalogs (and every ticket refresh, + // which re-enters this method via login()) skip it so they don't stomp on each other. A WARN + // fires only on a genuine auth-method mismatch. We compare against a remembered method rather than + // UserGroupInformation.getLoginUser() on purpose: Doris never establishes a process-wide login user + // (per-instance getUGIFromSubject only), and getLoginUser() would trigger exactly that. + UserGroupInformation.AuthenticationMethod desired = SecurityUtil.getAuthenticationMethod(hadoopConf); synchronized (HadoopKerberosAuthenticator.class) { - // avoid other catalog set conf at the same time - UserGroupInformation.setConfiguration(hadoopConf); + if (configuredAuthMethod == null) { + UserGroupInformation.setConfiguration(hadoopConf); + configuredAuthMethod = desired; + return; + } + if (configuredAuthMethod != desired) { + LOG.warn("UGI already configured with authentication={} but this catalog requests {}; " + + "keeping existing JVM-global setting (first-writer-wins).", + configuredAuthMethod, desired); + } } } diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/HadoopSimpleAuthenticator.java b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/HadoopSimpleAuthenticator.java similarity index 97% rename from fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/HadoopSimpleAuthenticator.java rename to fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/HadoopSimpleAuthenticator.java index fbe0d0aba7d39f..1afdbe6a6bf755 100644 --- a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/HadoopSimpleAuthenticator.java +++ b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/HadoopSimpleAuthenticator.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common.security.authentication; +package org.apache.doris.kerberos; import org.apache.hadoop.security.UserGroupInformation; import org.apache.logging.log4j.LogManager; diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/ImpersonatingHadoopAuthenticator.java b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/ImpersonatingHadoopAuthenticator.java similarity index 96% rename from fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/ImpersonatingHadoopAuthenticator.java rename to fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/ImpersonatingHadoopAuthenticator.java index 10e42f4bc67ab0..4c53a4db8929e5 100644 --- a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/ImpersonatingHadoopAuthenticator.java +++ b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/ImpersonatingHadoopAuthenticator.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common.security.authentication; +package org.apache.doris.kerberos; import org.apache.hadoop.security.UserGroupInformation; diff --git a/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/KerberosAuthSpec.java b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/KerberosAuthSpec.java new file mode 100644 index 00000000000000..a780ed0b3c1eee --- /dev/null +++ b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/KerberosAuthSpec.java @@ -0,0 +1,86 @@ +// 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.kerberos; + +import java.util.Objects; + +/** + * Neutral, immutable carrier of the Kerberos login facts (client {@code principal} and + * {@code keytab}) needed to perform a {@code UGI.loginUserFromKeytab(...).doAs(...)}. + * + *

    This is a fact object only — it holds no Hadoop types and performs no login. The + * real authenticated execution is done elsewhere (the FE side, via + * {@code ConnectorContext.executeAuthenticated}). It mirrors the principal/keytab pair of + * fe-common {@code KerberosAuthenticationConfig} but stays dependency-free so connector + * and metastore-api modules can pass it around without pulling fe-common or Hadoop. + * + *

    The HMS service principal (e.g. {@code hive.metastore.kerberos.principal}) is + * deliberately NOT part of this spec: it is a HiveConf override carried via + * {@code HmsMetaStoreProperties.toHiveConfOverrides(String)}, not a doAs login fact. + */ +public final class KerberosAuthSpec { + + private final String principal; + private final String keytab; + + public KerberosAuthSpec(String principal, String keytab) { + this.principal = principal; + this.keytab = keytab; + } + + /** The Kerberos client principal used for the keytab login. */ + public String getPrincipal() { + return principal; + } + + /** The path to the keytab file used for the principal login. */ + public String getKeytab() { + return keytab; + } + + /** Returns true only when both principal and keytab are non-blank (a usable login pair). */ + public boolean hasCredentials() { + return isNotBlank(principal) && isNotBlank(keytab); + } + + private static boolean isNotBlank(String value) { + return value != null && !value.isBlank(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof KerberosAuthSpec)) { + return false; + } + KerberosAuthSpec that = (KerberosAuthSpec) o; + return Objects.equals(principal, that.principal) && Objects.equals(keytab, that.keytab); + } + + @Override + public int hashCode() { + return Objects.hash(principal, keytab); + } + + @Override + public String toString() { + return "KerberosAuthSpec{principal=" + principal + ", keytab=" + keytab + "}"; + } +} diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/KerberosAuthenticationConfig.java b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/KerberosAuthenticationConfig.java similarity index 97% rename from fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/KerberosAuthenticationConfig.java rename to fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/KerberosAuthenticationConfig.java index b2bf10717ca1ce..8f34b1792882a9 100644 --- a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/KerberosAuthenticationConfig.java +++ b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/KerberosAuthenticationConfig.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common.security.authentication; +package org.apache.doris.kerberos; import lombok.Data; import lombok.EqualsAndHashCode; diff --git a/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/KerberosTicketUtils.java b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/KerberosTicketUtils.java new file mode 100644 index 00000000000000..e59d8578e8090e --- /dev/null +++ b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/KerberosTicketUtils.java @@ -0,0 +1,65 @@ +// 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.kerberos; + +import java.util.Set; +import javax.security.auth.Subject; +import javax.security.auth.kerberos.KerberosPrincipal; +import javax.security.auth.kerberos.KerberosTicket; + +/** + * JDK-only replacement for {@code io.trino.plugin.base.authentication.KerberosTicketUtils}, + * replicating its behaviour byte-for-byte so the kerberos path no longer depends on trino + * (P3b-T01). Locates the ticket-granting ticket within a {@link Subject} and computes the + * renew time at 80% of the ticket lifetime. + */ +public final class KerberosTicketUtils { + + // Renew when 80% of the ticket lifetime has elapsed (matches trino's TICKET_RENEW_WINDOW). + private static final float TICKET_RENEW_WINDOW = 0.8f; + + private KerberosTicketUtils() { + } + + public static KerberosTicket getTicketGrantingTicket(Subject subject) { + Set tickets = subject.getPrivateCredentials(KerberosTicket.class); + for (KerberosTicket ticket : tickets) { + if (isOriginalTicketGrantingTicket(ticket)) { + return ticket; + } + } + throw new IllegalArgumentException("kerberos ticket not found in " + subject); + } + + public static long getRefreshTime(KerberosTicket ticket) { + long start = ticket.getStartTime().getTime(); + long end = ticket.getEndTime().getTime(); + return start + (long) ((end - start) * TICKET_RENEW_WINDOW); + } + + public static boolean isOriginalTicketGrantingTicket(KerberosTicket ticket) { + return isTicketGrantingServerPrincipal(ticket.getServer()); + } + + private static boolean isTicketGrantingServerPrincipal(KerberosPrincipal principal) { + if (principal == null) { + return false; + } + return principal.getName().equals("krbtgt/" + principal.getRealm() + "@" + principal.getRealm()); + } +} diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/PreExecutionAuthenticator.java b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/PreExecutionAuthenticator.java similarity index 98% rename from fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/PreExecutionAuthenticator.java rename to fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/PreExecutionAuthenticator.java index 45b312e0b976fa..fcdf19331bdb16 100644 --- a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/PreExecutionAuthenticator.java +++ b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/PreExecutionAuthenticator.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common.security.authentication; +package org.apache.doris.kerberos; import org.apache.hadoop.conf.Configuration; diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/PreExecutionAuthenticatorCache.java b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/PreExecutionAuthenticatorCache.java similarity index 98% rename from fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/PreExecutionAuthenticatorCache.java rename to fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/PreExecutionAuthenticatorCache.java index 5b0d1cb70ff989..dcddbb38b02e9f 100644 --- a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/PreExecutionAuthenticatorCache.java +++ b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/PreExecutionAuthenticatorCache.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common.security.authentication; +package org.apache.doris.kerberos; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/SimpleAuthenticationConfig.java b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/SimpleAuthenticationConfig.java similarity index 94% rename from fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/SimpleAuthenticationConfig.java rename to fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/SimpleAuthenticationConfig.java index d202417afc8e33..e00bebe8c524b2 100644 --- a/fe/fe-common/src/main/java/org/apache/doris/common/security/authentication/SimpleAuthenticationConfig.java +++ b/fe/fe-kerberos/src/main/java/org/apache/doris/kerberos/SimpleAuthenticationConfig.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common.security.authentication; +package org.apache.doris.kerberos; import lombok.Data; diff --git a/fe/fe-kerberos/src/test/java/org/apache/doris/kerberos/AuthTypeTest.java b/fe/fe-kerberos/src/test/java/org/apache/doris/kerberos/AuthTypeTest.java new file mode 100644 index 00000000000000..0a4cd406d97c30 --- /dev/null +++ b/fe/fe-kerberos/src/test/java/org/apache/doris/kerberos/AuthTypeTest.java @@ -0,0 +1,49 @@ +// 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.kerberos; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class AuthTypeTest { + + @Test + void fromString_resolvesKerberosOnlyForExplicitKerberos() { + // Intent: a connection is treated as Kerberos-secured ONLY when the auth type is + // explicitly "kerberos" (case/whitespace-insensitive); everything else is SIMPLE. + Assertions.assertEquals(AuthType.KERBEROS, AuthType.fromString("kerberos")); + Assertions.assertEquals(AuthType.KERBEROS, AuthType.fromString("KERBEROS")); + Assertions.assertEquals(AuthType.KERBEROS, AuthType.fromString(" Kerberos ")); + } + + @Test + void fromString_resolvesEverythingElseToSimple() { + Assertions.assertEquals(AuthType.SIMPLE, AuthType.fromString(null)); + Assertions.assertEquals(AuthType.SIMPLE, AuthType.fromString("")); + Assertions.assertEquals(AuthType.SIMPLE, AuthType.fromString(" ")); + Assertions.assertEquals(AuthType.SIMPLE, AuthType.fromString("simple")); + Assertions.assertEquals(AuthType.SIMPLE, AuthType.fromString("none")); + Assertions.assertEquals(AuthType.SIMPLE, AuthType.fromString("anything")); + } + + @Test + void getDesc_returnsLowercaseWireName() { + Assertions.assertEquals("simple", AuthType.SIMPLE.getDesc()); + Assertions.assertEquals("kerberos", AuthType.KERBEROS.getDesc()); + } +} diff --git a/fe/fe-common/src/test/java/org/apache/doris/common/security/authentication/AuthenticationTest.java b/fe/fe-kerberos/src/test/java/org/apache/doris/kerberos/AuthenticationTest.java similarity index 96% rename from fe/fe-common/src/test/java/org/apache/doris/common/security/authentication/AuthenticationTest.java rename to fe/fe-kerberos/src/test/java/org/apache/doris/kerberos/AuthenticationTest.java index 62606a22a64fcb..f407438e09e426 100644 --- a/fe/fe-common/src/test/java/org/apache/doris/common/security/authentication/AuthenticationTest.java +++ b/fe/fe-kerberos/src/test/java/org/apache/doris/kerberos/AuthenticationTest.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.apache.doris.common.security.authentication; +package org.apache.doris.kerberos; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.CommonConfigurationKeysPublic; diff --git a/fe/fe-kerberos/src/test/java/org/apache/doris/kerberos/KerberosAuthSpecTest.java b/fe/fe-kerberos/src/test/java/org/apache/doris/kerberos/KerberosAuthSpecTest.java new file mode 100644 index 00000000000000..b56502dab93a1c --- /dev/null +++ b/fe/fe-kerberos/src/test/java/org/apache/doris/kerberos/KerberosAuthSpecTest.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.kerberos; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class KerberosAuthSpecTest { + + @Test + void accessorsExposePrincipalAndKeytab() { + KerberosAuthSpec spec = new KerberosAuthSpec("hive/_HOST@REALM", "/etc/security/hive.keytab"); + + Assertions.assertEquals("hive/_HOST@REALM", spec.getPrincipal()); + Assertions.assertEquals("/etc/security/hive.keytab", spec.getKeytab()); + } + + @Test + void hasCredentials_requiresBothPrincipalAndKeytab() { + // Intent: a usable doAs login needs BOTH a principal and a keytab; either missing + // (null or blank) means there is no static Kerberos login to perform. + Assertions.assertTrue(new KerberosAuthSpec("p", "k").hasCredentials()); + Assertions.assertFalse(new KerberosAuthSpec("p", "").hasCredentials()); + Assertions.assertFalse(new KerberosAuthSpec("p", null).hasCredentials()); + Assertions.assertFalse(new KerberosAuthSpec("", "k").hasCredentials()); + Assertions.assertFalse(new KerberosAuthSpec(null, "k").hasCredentials()); + Assertions.assertFalse(new KerberosAuthSpec(" ", " ").hasCredentials()); + Assertions.assertFalse(new KerberosAuthSpec(null, null).hasCredentials()); + } + + @Test + void valueSemantics_equalsAndHashCode() { + KerberosAuthSpec a = new KerberosAuthSpec("p", "k"); + KerberosAuthSpec b = new KerberosAuthSpec("p", "k"); + KerberosAuthSpec c = new KerberosAuthSpec("p", "other"); + + Assertions.assertEquals(a, b); + Assertions.assertEquals(a.hashCode(), b.hashCode()); + Assertions.assertNotEquals(a, c); + } +} diff --git a/fe/fe-kerberos/src/test/java/org/apache/doris/kerberos/KerberosTicketUtilsTest.java b/fe/fe-kerberos/src/test/java/org/apache/doris/kerberos/KerberosTicketUtilsTest.java new file mode 100644 index 00000000000000..1d06ed56ccbfc1 --- /dev/null +++ b/fe/fe-kerberos/src/test/java/org/apache/doris/kerberos/KerberosTicketUtilsTest.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.kerberos; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.Set; +import javax.security.auth.Subject; +import javax.security.auth.kerberos.KerberosPrincipal; +import javax.security.auth.kerberos.KerberosTicket; + +/** + * Pins the JDK-only KerberosTicketUtils to the exact semantics of the replaced + * io.trino.plugin.base.authentication.KerberosTicketUtils (P3b-T01 commit 1, trino -> JDK). + */ +public class KerberosTicketUtilsTest { + + private static final String REALM = "EXAMPLE.COM"; + + private static KerberosTicket ticket(String serverPrincipal, long startMs, long endMs) { + return new KerberosTicket( + new byte[] {1}, // asn1Encoding (non-empty) + new KerberosPrincipal("client@" + REALM), // client + new KerberosPrincipal(serverPrincipal), // server + new byte[] {0, 0, 0, 0, 0, 0, 0, 0}, // sessionKey + 1, // keyType + null, // flags + null, // authTime + new Date(startMs), // startTime + new Date(endMs), // endTime + null, // renewTill + null); // clientAddresses + } + + // getRefreshTime == start + (long)((end - start) * 0.8f) -- the trino 80% renew window. + @Test + public void testGetRefreshTimeIs80PercentOfLifetime() { + KerberosTicket tgt = ticket("krbtgt/" + REALM + "@" + REALM, 1000L, 11000L); + // 1000 + (long)((11000 - 1000) * 0.8f) = 1000 + 8000 = 9000 + Assert.assertEquals(9000L, KerberosTicketUtils.getRefreshTime(tgt)); + } + + @Test + public void testGetRefreshTimeZeroLifetime() { + KerberosTicket tgt = ticket("krbtgt/" + REALM + "@" + REALM, 5000L, 5000L); + Assert.assertEquals(5000L, KerberosTicketUtils.getRefreshTime(tgt)); + } + + // getTicketGrantingTicket returns the credential whose server is krbtgt/REALM@REALM. + @Test + public void testGetTicketGrantingTicketPicksTgtAmongCredentials() { + KerberosTicket serviceTicket = ticket("hdfs/host@" + REALM, 0L, 10000L); + KerberosTicket tgt = ticket("krbtgt/" + REALM + "@" + REALM, 0L, 10000L); + Set privateCreds = new HashSet<>(); + privateCreds.add(serviceTicket); + privateCreds.add(tgt); + Subject subject = new Subject(false, + Collections.singleton(new KerberosPrincipal("client@" + REALM)), + Collections.emptySet(), privateCreds); + + Assert.assertSame(tgt, KerberosTicketUtils.getTicketGrantingTicket(subject)); + } + + @Test + public void testGetTicketGrantingTicketThrowsWhenNoTgt() { + KerberosTicket serviceTicket = ticket("hdfs/host@" + REALM, 0L, 10000L); + Subject subject = new Subject(false, + Collections.singleton(new KerberosPrincipal("client@" + REALM)), + Collections.emptySet(), Collections.singleton(serviceTicket)); + try { + KerberosTicketUtils.getTicketGrantingTicket(subject); + Assert.fail("expected IllegalArgumentException when no TGT is present"); + } catch (IllegalArgumentException expected) { + // expected + } + } +} diff --git a/fe/pom.xml b/fe/pom.xml index 2b44718723b05a..30ac6639ec05d9 100644 --- a/fe/pom.xml +++ b/fe/pom.xml @@ -218,6 +218,7 @@ under the License. fe-foundation + fe-kerberos fe-common fe-catalog fe-filesystem @@ -266,6 +267,7 @@ under the License. 1.6.0 2.11.0 1.13 + 2.6 3.19.0 3.13.0 2.2 @@ -278,10 +280,16 @@ under the License. 2.16.0 3.18.2-GA 3.1.0 + + 6.1.0 18.3.14-doris-SNAPSHOT 2.18.0 1.11.0 1.1.1 + 1.8 5.14.1 6.0.0 0.16.0 @@ -390,8 +398,16 @@ under the License. 2.3.2 2.0.3 + 1.3.1 3.4.4 + 17.0.0 shade-format-flatbuffers 1.12.0 @@ -490,6 +506,49 @@ under the License. + + + io.netty + netty-codec-native-quic + ${netty-all.version} + linux-x86_64 + provided + + + io.netty + netty-codec-native-quic + ${netty-all.version} + linux-aarch_64 + provided + + + io.netty + netty-codec-native-quic + ${netty-all.version} + osx-x86_64 + provided + + + io.netty + netty-codec-native-quic + ${netty-all.version} + osx-aarch_64 + provided + + + io.netty + netty-codec-native-quic + ${netty-all.version} + windows-x86_64 + provided + io.netty netty-bom @@ -784,11 +843,37 @@ under the License. + org.apache.doris hive-catalog-shade ${doris.hive.catalog.shade.version} + + + org.apache.hive + hive-exec + core + ${hive.version} + + + * + * + + + org.apache.kerby kerb-simplekdc @@ -856,6 +941,13 @@ under the License. commons-codec ${commons-codec.version} + + + commons-lang + commons-lang + ${commons-lang.version} + org.apache.commons @@ -956,6 +1048,12 @@ under the License. json-simple ${json-simple.version} + + + com.tdunning + json + ${open-json.version} + org.apache.thrift @@ -1019,6 +1117,11 @@ under the License. disruptor ${disruptor.version} + + org.jetbrains + annotations + ${jetbrains-annotations.version} + io.dropwizard.metrics @@ -1375,6 +1478,12 @@ under the License. iceberg-aws ${iceberg.version} + + + software.amazon.s3tables + s3-tables-catalog-for-iceberg + ${s3tables.catalog.version} + org.apache.paimon paimon-core @@ -1983,6 +2092,7 @@ under the License. org.awaitility awaitility + test diff --git a/plan-doc/00-connector-migration-master-plan.md b/plan-doc/00-connector-migration-master-plan.md new file mode 100644 index 00000000000000..a13ed7732831ef --- /dev/null +++ b/plan-doc/00-connector-migration-master-plan.md @@ -0,0 +1,369 @@ +# Connector 迁移总体计划(fe-core/datasource → fe-connector/*) + +> 状态:草案 v1 · 撰写日期 2026-05-24 · 分支 `catalog-spi-2` +> 范围:把 `fe/fe-core/src/main/java/org/apache/doris/datasource/` 下所有"具体数据源"代码(hive/iceberg/paimon/hudi/trino/maxcompute/lakesoul/jdbc/es)解耦到 `fe/fe-connector/*` 下的插件模块;只把"通用基础设施"和"SPI 桥接层"留在 fe-core。 +> 不在范围:BE 端 reader 实现、`fe-fs-spi` 文件系统插件化(已是独立工作流)、`extension-spi`。 +> +> --- +> +> 📍 **当前推进状态、活跃 task、风险等动态信息见 [`PROGRESS.md`](./PROGRESS.md)**(本文件只放战略,不放进度)。 +> 📚 **跟踪机制说明 / 文档索引**:[`README.md`](./README.md) +> 🤖 **Agent 协作规范**(context 管理 / subagent / handoff):[`AGENT-PLAYBOOK.md`](./AGENT-PLAYBOOK.md) +> 📋 **决策日志(D-NNN)**:[`decisions-log.md`](./decisions-log.md) · **偏差日志(DV-NNN)**:[`deviations-log.md`](./deviations-log.md) · **风险登记(R-NNN)**:[`risks.md`](./risks.md) +> 📁 **阶段任务**:[`tasks/`](./tasks/) · **连接器跟踪**:[`connectors/`](./connectors/) + +--- + +## 0. 阅后即明的现状(Recap) + +| 维度 | 状态 | +|---|---| +| SPI/API 模块 | ✅ `fe-connector-api` + `fe-connector-spi` 已建立,依赖只含 `fe-thrift (provided)`、`fe-extension-spi` | +| 反向边界 | ✅ 干净。`fe-connector/**` 下 0 处对 `org.apache.doris.{catalog,common,datasource,qe,analysis,nereids,planner}` 的 import | +| 桥接层 | ✅ `PluginDrivenExternalCatalog / Database / Table / ScanNode / Split`、`ExprToConnectorExpressionConverter`、`ConnectorColumnConverter`、`DorisTypeVisitor`、`ConnectorPluginManager`、`ConnectorFactory`、`DefaultConnectorContext` 已就绪 | +| 已切到 SPI 路径 | ✅ `jdbc`、`es`(见 `CatalogFactory.SPI_READY_TYPES`) | +| 未切到 SPI 路径 | ⏳ `hms`、`iceberg`、`paimon`、`trino-connector`、`max_compute`、`hudi`(仍走 `switch-case`) | +| 旧/新重复代码 | ⚠️ `Jdbc*Client` 13 个方言(fe-core 旧 + fe-connector 新)、`PaimonPredicateConverter`、`McStructureHelper` | +| 反向耦合(要清理)| 96 处 `instanceof XExternal*` 散落在 34 个文件;其中 14 个在 `nereids/`、`planner/`、`alter/`、`tablefunction/` 等热区 | +| 测试 | jdbc=13 个、es=7 个;其余 6 个连接器模块 0 个 | + +--- + +## 1. 总目标与终态 + +### 1.1 终态定义 + +**fe-core/datasource/ 留下什么**: + +- `CatalogIf` / `CatalogMgr` / `CatalogFactory` / `CatalogProperty` / `CatalogLog` —— catalog 注册/调度 +- `ExternalCatalog` / `ExternalDatabase` / `ExternalTable` / `ExternalView` —— 抽象基类 +- `InternalCatalog`、`ExternalMetaCacheMgr`、`ExternalMetaIdMgr`、`ExternalRowCountCache`、`ExternalFunctionRules` —— 跨连接器共享设施 +- `FederationBackendPolicy`、`FileSplit*`、`SplitGenerator`、`SplitAssignment`、`SplitSourceManager`、`NodeSelectionStrategy`、`FileCacheAdmissionManager` —— 通用 split/分发 +- `FileScanNode` / `FileQueryScanNode` / `ExternalScanNode` —— 通用 scan 基类 +- `PluginDrivenExternalCatalog/Database/Table/ScanNode/Split` —— SPI 桥 +- `ExprToConnectorExpressionConverter`、`ConnectorColumnConverter`、`DorisTypeVisitor` —— Doris ↔ SPI 类型/表达式转换 +- `metacache/`、`mvcc/`、`statistics/`、`property/`、`credentials/`、`connectivity/`、`operations/`、`systable/`、`infoschema/`、`test/`、`tvf/` —— 通用框架(**保留**;其中 `property/` 的连接器专属常量需要逐步搬走) +- `kafka/`、`kinesis/`、`odbc/`、`doris/`(Doris-to-Doris federation)—— 暂时保留,不在本计划主线(决策点 D7) + +**fe-core/datasource/ 删除什么**: + +- `hive/`、`iceberg/`、`paimon/`、`hudi/`、`maxcompute/`、`trinoconnector/`、`jdbc/`、`lakesoul/` 整个子目录 +- `fe/fe-core/src/main/java/org/apache/doris/transaction/{Hive,Iceberg}TransactionManager.java`、`TransactionManagerFactory.java` 中的连接器分支 +- `fe/fe-core/src/main/java/org/apache/doris/planner/Iceberg{DeleteSink,MergeSink,TableSink}.java` 等连接器专属 sink/scan-node +- `fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java` 中 line 734–790 的 7 个 `instanceof` 分支 → 收口到 `PluginDrivenScanNode.create(...)` +- 散落在 `nereids/`、`planner/`、`alter/`、`tablefunction/`、`catalog/RefreshManager` 的 `instanceof XExternal*` —— 全部走 SPI 接口 +- `SPI_READY_TYPES` 白名单本身 + +**fe-connector/ 终态**:每个连接器是一个**独立可装卸的 plugin zip**,部署到 `${doris_home}/plugins/connector//`(**单数**,`build.sh:1073`;勿与 trino-connector 自带插件的投放点 `plugins/trino_plugins/` 混淆),FE 启动通过 `connector_plugin_root` 加载。运行时 `fe-core` 对具体连接器名一无所知;用户安装/卸载连接器无需重启 FE(决策点 D8)。 + +### 1.2 三个不可妥协的不变量 + +1. **fe-connector → fe-core 单向依赖**:禁止任何 `import org.apache.doris.{catalog,common,datasource,qe,analysis,nereids,planner}`。允许 `org.apache.doris.thrift.*`(provided)和 `org.apache.doris.connector.*` / `org.apache.doris.extension.*` / `org.apache.doris.filesystem.*`。CI 必须有 grep 守门。 +2. **Image / 元数据持久化向后兼容**:旧 FE image 中保存的 `IcebergExternalCatalog`、`PaimonExternalDatabase` 等 GSON 类型必须能被新 FE 反序列化并平滑迁移到 `PluginDrivenExternalCatalog`。范本是 `PluginDrivenExternalCatalog.gsonPostProcess()` 中对 ES/JDBC 的处理(已有),需推广到所有类型。 +3. **用户可见行为不回归**:`SHOW CREATE CATALOG`、`SHOW TABLE STATUS`、`information_schema.tables`、`EXPLAIN` 输出、错误信息、catalog `type` 字段、`engine` 字段(`getEngine` / `getEngineTableTypeName`)需保留旧名字。已经在 `PluginDrivenExternalTable.getEngine()` 用 switch 兜底,迁移过程中维护这个 switch。 + +--- + +## 2. 现状审视:先解决的 SPI 设计缺口 + +迁移之前必须先把 SPI 补齐到能承载所有六个连接器,否则边迁边补会被反复打回。 + +### 2.1 必须新增的 SPI 能力 + +> 全部加在 `fe-connector-api` 下;保持 `default` 方法策略以让现有连接器零迁移成本。 + +| 能力 | 当前在哪 | 计划新增的 SPI | +|---|---|---| +| **DDL info** —— `CreateTableInfo`/`PartitionDesc`/`DistributionDesc` 等都是 nereids 类型,连接器看不到 | `IcebergMetadataOps.createTable(CreateTableInfo)`、`HiveMetadataOps.createTable(CreateTableInfo)` | 在 `ConnectorTableOps` 增加 `createTable(session, ConnectorCreateTableRequest)`,引入 `ConnectorCreateTableRequest`、`ConnectorPartitionSpec`、`ConnectorBucketSpec` 三个 POJO;fe-core 侧加 `CreateTableInfoToConnectorRequestConverter` | +| **Procedures / Actions** —— Iceberg 10 个 `IcebergXxxAction` 通过 `BaseIcebergAction` 调用 `IcebergMetadataOps.commit*` | `datasource/iceberg/action/*` | 新增 `ConnectorProcedureOps`(`listProcedures`、`callProcedure(name, args)`),fe-core 侧 `ExecuteActionCommand` 走通用 dispatch | +| **元数据失效事件**(HMS notification)—— 21 个 `MetastoreEvent` 类 | `datasource/hive/event/MetastoreEventsProcessor` 调用 fe-core 的 `ExternalMetaCacheMgr.invalidate*` | 选项 A:把 event 处理整体搬到 `fe-connector-hms`,通过 `ConnectorContext.getMetaInvalidator()`(新增)回调 fe-core。选项 B:只把"轮询 HMS 拿事件流"和"解析事件"放连接器,"分发失效"留 fe-core。**推荐 A**(决策点 D4) | +| **事务管理器** | `transaction/HiveTransactionManager`、`IcebergTransactionManager` | 新增 `ConnectorTransactionFactory`(已存在的 `PluginDrivenTransactionManager` 当骨架),把 `HiveTransactionMgr` 内部状态搬进连接器实现 | +| **MVCC 快照** | `IcebergMvccSnapshot`、`PaimonMvccSnapshot` | 新增 `ConnectorMvccSnapshot` 类型,`ConnectorMetadata.beginQuery(session) -> ConnectorMvccSnapshot`;fe-core 侧 `MvccSnapshot` 接口由连接器提供实现 | +| **Vended credentials** | `IcebergVendedCredentialsProvider`、`PaimonVendedCredentialsProvider` | `ConnectorCapability.SUPPORTS_VENDED_CREDENTIALS` 已存在;新增 `ConnectorCredentials getCredentialsForScan(session, ConnectorScanRange)` | +| **Sys-tables / metadata-tables** | `IcebergSysExternalTable`、`PaimonSysExternalTable` | 在 `ConnectorTableOps` 增加 `listSysTableTypes()` / `getSysTableSchema(...)` | +| **Statistics 写入**(`ANALYZE TABLE`)| `HMSExternalTable.createAnalysisTask` | 把 `ExternalAnalysisTask` 改为只调 `ConnectorStatisticsOps`;新增 `setColumnStatistics` 方法 | +| **写路径 sink 配置**(不是数据本身,BE 写)| `IcebergDeleteSink`、`IcebergMergeSink`、`IcebergTableSink` | `ConnectorWriteOps.getWriteConfig` 已存在;扩展为支持 `getDeleteConfig`、`getMergeConfig`;planner 用通用 `PhysicalConnectorTableSink` | +| **Partition 列举**(给 TVF / SHOW PARTITIONS 用)| `MaxComputeExternalCatalog`、`PaimonExternalCatalog`、`HMSExternalCatalog` 各自的 `listPartition*` | 新增 `ConnectorTableOps.listPartitions(session, handle, filter)` / `listPartitionValues(session, handle, columns)` | + +### 2.2 推荐放弃 / 推迟的 SPI 演进 + +- **不要**为 ScanRange 引入更多多态——`PluginDrivenScanNode` extends `FileQueryScanNode` 的桥接策略已经验证可行(ES/JDBC)。 +- **不要**抽象 `Resource` 兼容层——旧 resource-backed catalog 用 `gsonPostProcess` 回填 `type` 已足够。 + +### 2.3 SPI 改动的版本号管理 + +`ConnectorProvider.apiVersion()` 当前固定返回 1。每次 SPI **新增** default 方法不动版本号;每次 SPI **改签名 / 删方法**版本号 +1,`ConnectorPluginManager` 中 `CURRENT_API_VERSION` 同步 +1。本计划过程中只新增 default 方法,因此版本号保持 1。 + +--- + +## 3. 阶段划分(按风险与价值排序) + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ P0: SPI 缺口补齐(不迁连接器) ~2 周 │ +│ P1: 重复代码清理 + scan-node 收口 ~1 周 │ +│ P2: trino-connector 迁移 ~2 周 最小风险,先打通流程 │ +│ P3: hudi 迁移(含 DLA 重构) ~2 周 │ +│ P4: maxcompute 迁移 ~2 周 │ +│ P5: paimon 迁移 ~3 周 │ +│ P6: iceberg 迁移(含 7 catalog 变体) ~5 周 │ +│ P7: hive (+HMS) 迁移(含 event 引擎) ~6 周 最复杂,最后做 │ +│ P8: 收尾——删 SPI_READY_TYPES、删旧类、删 instanceof ~2 周 │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +总长度估算 **~25 周**(不含 lakesoul / RemoteDoris 等遗留类型清理)。 + +### 3.1 阶段 P0:SPI 缺口补齐 + +**目标**:让 §2.1 表里所有缺口都有对应 SPI 类型/方法在 `fe-connector-api`,且至少一个连接器的现有实现已经能在 `default` 模式下正常工作。 + +**任务**: + +1. 新增 SPI 类型:`ConnectorCreateTableRequest`、`ConnectorPartitionSpec`、`ConnectorBucketSpec`、`ConnectorProcedureOps`、`ConnectorMvccSnapshot`、`ConnectorCredentials`、`ConnectorMetaInvalidator`(在 SPI 包)。 +2. 在 `ConnectorTableOps`、`ConnectorMetadata`、`ConnectorContext` 上新增对应 default 方法。 +3. 在 `fe-core` 侧加 converter:`CreateTableInfoToConnectorRequestConverter`、`ConnectorRequestToCreateTableInfoConverter`(如果需要双向)。 +4. 给 `PluginDrivenExternalCatalog` / `PluginDrivenExternalTable` 加上分发:CREATE TABLE / EXECUTE PROCEDURE / ANALYZE / SHOW PARTITIONS 都路由到 SPI。 +5. 更新 `ConnectorPluginManager` 文档:列出"新 SPI 在 v1 中以 default 方法形式添加,连接器无需更新版本号"。 +6. CI 守门:grep 脚本 `tools/check-connector-imports.sh` 在 `fe-connector/**/*.java` 中扫描禁用 import 列表,作为 maven 的 `enforcer` 步骤。 + +**完成判据**: +- `mvn -pl fe-connector verify` 全绿。 +- JDBC、ES 仍正常(回归)。 +- 一条 fake 连接器(在测试目录下)能在不实现新 SPI 的情况下编译并工作。 + +### 3.2 阶段 P1:重复清理 + scan-node 收口 + +**目标**:在迁连接器之前先把已经造成混乱的旧代码清掉,并把 `PhysicalPlanTranslator` 的 scan-node 分支从 7 个减到 1 个。 + +**任务**: + +1. 删除 fe-core 旧的 `datasource/jdbc/client/Jdbc*Client.java` 13 个文件 + `JdbcFieldSchema.java`。删除前 grep `org.apache.doris.datasource.jdbc.client` 在 fe-core 内被谁引用——预期只有 `JdbcExternalCatalog` 等已经走 SPI 的代码会引用,需要它们也搬走或改路径。 +2. 删除 fe-core 重复的 `PaimonPredicateConverter`、`McStructureHelper`,让 fe-core 通过 SPI 桥接(如果 fe-core 真的需要这两个工具,应该挪到通用工具包;但更可能它们是 leak,可直接删)。 +3. **收口 `PhysicalPlanTranslator.visitPhysicalFileScan`**:把所有 `instanceof HMSExternalTable / IcebergExternalTable / TrinoConnectorExternalTable / MaxComputeExternalTable / LakeSoulExternalTable` 分支统一改为: + - 若 `table instanceof PluginDrivenExternalTable` → 走 `PluginDrivenScanNode.create(...)` + - 兜底(迁移期)保留老分支 + - 在每个连接器迁移完成时(P3–P7)删掉对应分支。 +4. 把 `visitPhysicalHudiScan` 改为内部委托 `PluginDrivenScanNode` 处理增量场景(这里是 `getScanNodeProperties()` 的扩展)。 +5. 把 `LogicalFileScan.computeOutput` 中的 `instanceof IcebergExternalTable` / `HMSExternalTable` 改成通过 `ConnectorMetadata.getTableSchema` 拿额外列(metadata column)。 + +**完成判据**: +- `PhysicalPlanTranslator` 不再 `import` 任何具体 `*ExternalTable` 类(除迁移期 fallback)。 +- 全量回归 P0 通过。 + +### 3.3 阶段 P2:trino-connector 迁移(先开荒) + +**为什么先做它**: + +- fe-core 侧只有 6 个文件 + `source/`,且只有 2 处反向 `instanceof` 引用。 +- fe-connector-trino 已经有 13 个文件,scan/predicate/plugin loader/services provider 都已搬好。 +- 没有 transaction、没有 event、没有 ACID。 +- 失败的爆炸半径最小,可以把整个 migration playbook 跑一遍。 + +**任务清单**(**这套清单就是后续每个连接器都要走的 playbook**): + +1. **代码层面**: + - 把 `datasource/trinoconnector/TrinoConnectorExternalCatalog/Database/Table` 中尚未在 connector 模块中的逻辑搬过去(schema cache、plugin loader 关闭、property 校验)。 + - 在 `TrinoConnectorMetadata` 实现 `getTableSchema` / `listTableNames` / `getTableHandle` / `applyFilter` / `applyProjection`(多数已在)。 + - `TrinoScanPlanProvider` 已实现 `planScan` —— review 一遍 split 数量、Thrift desc 字段。 +2. **桥接层面**: + - `CatalogFactory.SPI_READY_TYPES` 加入 `trino-connector`。 + - `PhysicalPlanTranslator` 删除 `instanceof TrinoConnectorExternalTable` 分支。 + - `PluginDrivenExternalTable.getEngine() / getEngineTableTypeName()` 加 `trino-connector` 分支。 + - 检查 `TableIf.TableType.TRINO_CONNECTOR_EXTERNAL_TABLE` 是否仍需保留作为 GSON 兼容(**保留**,并在 `gsonPostProcess` 中迁移到 `PLUGIN_EXTERNAL_TABLE`)。 +3. **持久化兼容**: + - 在 `PluginDrivenExternalCatalog.gsonPostProcess` 中加 `trinoconnector → plugin` 的 logType 迁移(已有 ES/JDBC 范本)。 + - 在 `ExternalCatalog.registerCompatibleSubtype` 注册 `TrinoConnectorExternalCatalog` → `PluginDrivenExternalCatalog`。 +4. **测试**: + - 给 `fe-connector-trino` 加单元测试(mock Trino plugin),覆盖 schema 解析、predicate 转换、scan plan。 + - regression-test 里新增 `trino_connector_migration_compat` 测试:模拟旧 FE image 反序列化。 +5. **打包**: + - 验证 `mvn package -pl fe-connector-trino` 生成的 `plugin.zip` 内容、`lib/` 排除项是否完整。 + - 文档:在 `docs-next/` 添加 trino-connector 插件安装步骤。 +6. **删除 fe-core 旧代码**(迁移完成的最后一步): + - `rm -rf fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/` + - `CatalogFactory.java` 移除 `case "trino-connector": ...` + - 删除 `import` 失败处全部走 SPI 改造。 + +**风险点**:Trino 插件加载(`TrinoPluginManager` 在连接器内部)要确认 classloader 隔离不会破坏 fe-core 现有 Trino 用法。如果有 BE 端共用 Trino 二进制的情况,需要复核。 + +### 3.4 阶段 P3:hudi 迁移 + +**特殊性**:hudi 没有自己的 `*ExternalCatalog`,它寄生在 HMS 上——表是 `HMSExternalTable.dlaType=HUDI`。 + +**任务**: + +1. **重构 DLA 模型**:在 SPI 层显式建模"一个 HMS 表实际是 hudi 表"。两个选项: + - **选项 A**(推荐):在 `ConnectorTableSchema.tableFormatType` 上做约定值 `HUDI` / `ICEBERG` / `HIVE`,由 HMS 连接器探测后填充;Doris 侧 `PluginDrivenExternalTable` 根据这个值决定走 `PhysicalHudiScan` 还是 `PhysicalFileScan`。 + - **选项 B**:hudi 作为独立 catalog type,但 catalog 内部委托 HMS 连接器拿元数据。 + - 决策点 D5。 +2. **迁移代码**: + - `datasource/hudi/HudiUtils.java`、`HudiSchemaCacheKey/Value`、`HudiMvccSnapshot`、`HudiPartitionProcessor` 搬入 `fe-connector-hudi`。 + - `datasource/hudi/source/` 下的 `HudiScanNode` 删除,改为 `PluginDrivenScanNode` + `HudiScanPlanProvider`(已存在)补全 incremental relation 逻辑。 + - 4 个 `HoodieIncremental*Relation` 类是和 hudi-spark 库交互,必须在连接器模块里(已在 lib),review classpath。 +3. **桥接**:`SPI_READY_TYPES` 加 hudi。但因为 hudi 不能独立 CREATE CATALOG(它依附 HMS),CatalogFactory 路由可能要特别处理:用户写 `type=hms`,由 HMS 连接器自行判断 dlaType 后用 hudi-specific 行为。 +4. **测试**:用 hudi 测试集群跑读时序,确保 incremental query 不回归。 + +### 3.5 阶段 P4:maxcompute 迁移 + +**任务**: + +1. 搬 `MCTransaction`、`MaxComputeExternalMetaCache`、`MaxComputeSchemaCacheValue` 到 `fe-connector-maxcompute`。 +2. 删 fe-core 重复的 `McStructureHelper`(P1 已删,确认)。 +3. `MaxComputeMetadataOps` 现有 fe-core 实现搬到连接器(连接器内已有 `MaxComputeConnectorMetadata` 骨架)。 +4. 收口 `PhysicalPlanTranslator`、`ShowPartitionsCommand`、`PartitionsTableValuedFunction` 中对 `MaxComputeExternalCatalog/Table` 的 12 处 instanceof。 +5. `SPI_READY_TYPES` 加 `max_compute`。 +6. 删 `datasource/maxcompute/`。 + +### 3.6 阶段 P5:paimon 迁移 + +**复杂度跃升原因**: + +- 6 个 catalog flavor(HMS/DLF/REST/File/Base/Factory)—— 在连接器内用工厂模式重组:`PaimonConnectorProvider.create()` 根据 properties 实例化 `Catalog`。 +- `PaimonMvccSnapshot` —— 用 P0 新增的 `ConnectorMvccSnapshot` 类型承接。 +- `PaimonVendedCredentialsProvider` —— 用 P0 新增的 vended credentials SPI 承接。 +- `PaimonSysExternalTable` —— 用 P0 新增的 sys table SPI 承接。 +- BE 通过 JNI 调用 paimon-reader,序列化 Paimon Table 通过 `ConnectorScanPlanProvider.getSerializedTable` 已有支持。 + +**任务**: + +1. 完整 port `PaimonMetadataOps` → `PaimonConnectorMetadata`(注意 partitionStatistics、bucketing)。 +2. Port 6 个 catalog flavor。 +3. 实现 MVCC、Vended、Sys Tables 三套 SPI。 +4. 删 fe-core `PaimonPredicateConverter` 重复(P1 已删,确认)。 +5. 删 fe-core `datasource/paimon/`。 +6. 清 10 处反向 `instanceof PaimonExternalCatalog/Table`。 + +### 3.7 阶段 P6:iceberg 迁移(最大) + +**为什么排第二难**: + +- 7 个 catalog flavor(HMS/Glue/Hadoop/Jdbc/REST/S3Tables/DLF)—— 但 Iceberg SDK 本身就抽象了 Catalog,连接器只要 dispatch property → 选实例化哪个 SDK Catalog。 +- 10 个 `IcebergXxxAction`(`RewriteDataFiles`、`ExpireSnapshots`、`RollbackToSnapshot` 等)—— 用 P0 新增的 `ConnectorProcedureOps` 承接。 +- `IcebergTransaction`(966 行)+ `IcebergMetadataOps`(1247 行)+ `IcebergUtils`(1718 行)+ `IcebergScanNode`(1228 行)= **5 千多行重戏**。 +- `IcebergMvccSnapshot` + snapshot cache + manifest cache —— 用 `ConnectorMvccSnapshot` 承接,cache 由连接器内部管理(决策点 D6)。 +- `IcebergSysExternalTable` + 元数据列(`IcebergMetadataColumn`、`IcebergRowId`)—— 用 sys table SPI。 +- `dlf/`、`broker/`、`fileio/`、`helper/`、`profile/`、`rewrite/` 各子目录都要看清是引擎相关还是用户逻辑。 +- nereids 写命令 `IcebergDeleteCommand` / `IcebergMergeCommand` / `IcebergUpdateCommand` 大量依赖 `IcebergExternalTable` —— 这些要改为通过 `ConnectorWriteOps.beginMerge`、`beginDelete`、`getDeleteConfig` 等 SPI 调用,且 planner 改用 `PhysicalConnectorTableSink`(已存在)。 +- `planner/IcebergDeleteSink.java`、`IcebergMergeSink.java`、`IcebergTableSink.java` 要删除并由通用 sink 承接。 + +**任务分子阶段**: + +- P6.1 元数据 only(catalog flavors + ConnectorMetadata)—— 2 周 +- P6.2 scan path(ScanPlanProvider + MVCC + cache)—— 1 周 +- P6.3 write path(commit/transaction + DML SPI + planner 改造)—— 1 周 +- P6.4 actions(procedure SPI 接上 10 个 action)—— 0.5 周 +- P6.5 sys tables + metadata columns —— 0.5 周 +- P6.6 删除 fe-core `datasource/iceberg/` + 删 13 处反向 instanceof —— 0.5 周 + +**风险**:Iceberg 写路径与 nereids 优化器深度耦合(如 `IcebergConflictDetectionFilterUtils`)。建议在 P6.3 前先单独写一个**写路径方案 RFC**,请 PMC 评审。 + +### 3.8 阶段 P7:hive (+HMS) 迁移(最复杂) + +**复杂度顶点的原因**: + +- HMS 是 hive、hudi、iceberg-hms-flavor、paimon-hms-flavor **共同的元数据后端**。HMS 连接器必须在 P7 之前就稳定可用(事实上 P3/P5/P6 已经在用 `fe-connector-hms`)。 +- 21 个 metastore event 类 + `MetastoreEventsProcessor` —— 用 P0 新增的 `ConnectorMetaInvalidator` 承接(决策点 D4)。 +- `HMSTransaction`(1866 行)+ `HiveTransactionMgr` —— ACID 事务管理,**最难**,需要重写写路径。 +- `HMSExternalTable`(1293 行)—— 处理 hive / hudi / iceberg 三种 dlaType 的分流逻辑。这部分要被 P3、P6.1 的 DLA 模型重构吸收。 +- 31 处反向 `instanceof HMSExternalCatalog / HMSExternalTable`,分布在 `nereids/glue/translator`、`tablefunction/MetadataGenerator`、`AnalyzeTableCommand`、`ShowPartitionsCommand` 等热路径。 + +**任务分子阶段**: + +- P7.1 把 `HiveMetadataOps` 全功能搬到 `HiveConnectorMetadata`(基础 DDL、partition、statistics)—— 2 周 +- P7.2 event pipeline 整体搬到 `fe-connector-hms`,提供 `ConnectorMetaInvalidator` 回调 —— 1.5 周 +- P7.3 HMSTransaction + HiveTransactionMgr 搬到 `fe-connector-hive`,ACID 写路径联调 —— 2 周 +- P7.4 DLA 分流逻辑改造(让 `HMSExternalTable` 退化为可被 PluginDrivenExternalTable 承接)—— 0.5 周 +- P7.5 删除 fe-core `datasource/hive/` + 31 处反向 instanceof —— 0.5 周 + +**风险**: +1. ACID 写路径(INSERT OVERWRITE、INSERT INTO partition)的事务一致性回归——必须有专门的 acid 集成测试。 +2. HMS event 处理的性能:在连接器进程内做事件流处理 vs fe-core 内做有什么差异。 +3. Kerberos UGI 上下文——`ConnectorContext.executeAuthenticated` 现已支持,但要逐条审查。 + +### 3.9 阶段 P8:收尾清理 + +**任务**: + +1. 删除 `CatalogFactory.SPI_READY_TYPES` 白名单 —— 所有 catalog 类型都走 SPI 路径,未找到 provider 的(如 `lakesoul`)按 P8.x 决策处理(直接报错或在 `gsonPostProcess` 中迁移)。 +2. 删除 `CatalogFactory.createCatalog` 中的 `switch-case` 兜底,仅保留 SPI 找不到时的明确错误信息。 +3. 删除 `PluginDrivenExternalTable.getEngine()` / `getEngineTableTypeName()` 中的 switch —— 改为 `Connector` 暴露 `getEngineName()` 这一 SPI 方法。 +4. 删除 fe-core 中尚存的 `TableType.{HMS,ICEBERG,PAIMON,HUDI,MAX_COMPUTE,TRINO_CONNECTOR,LAKESOUL,ES,JDBC}_EXTERNAL_TABLE` 枚举值(保留 `PLUGIN_EXTERNAL_TABLE`)。所有写到 image 的旧值在 `gsonPostProcess` 中自动 reroute。 +5. 文档:在 `fe/fe-connector/README.md` 写明"如何新增一个 connector plugin"的步骤化指南。 +6. CI 守门强化:除 §1.2 的 import 守门,新增"fe-core 不得 import 任何 `*Connector*` 实现包"的 grep。 + +--- + +## 4. 单连接器迁移 Playbook(可复制清单) + +每个连接器迁移,依次走完这 13 步: + +``` +[ ] 1. 列出该连接器在 fe-core/datasource// 下的所有类,按 §1.1 终态分类。 +[ ] 2. 列出 fe-connector-/ 已有类,对照差距。 +[ ] 3. 列出反向 instanceof / cast 调用点(grep `instanceof Xxx | (Xxx)`)。 +[ ] 4. 在 fe-connector-/ 实现缺失的 ConnectorMetadata / ScanPlanProvider 方法。 +[ ] 5. 实现 ConnectorProvider.validateProperties 并补 ConnectorProvider.preCreateValidation。 +[ ] 6. 实现 META-INF/services 注册(多数已就绪)。 +[ ] 7. CatalogFactory.SPI_READY_TYPES 加入该类型。 +[ ] 8. PluginDrivenExternalCatalog.gsonPostProcess 加迁移分支(logType → PLUGIN)。 +[ ] 9. ExternalCatalog.registerCompatibleSubtype 注册 GSON 兼容子类型。 +[ ] 10. 替换所有反向 instanceof:planner / nereids / tablefunction / alter / catalog 各处。 +[ ] 11. PhysicalPlanTranslator.visitPhysicalFileScan 删该连接器分支。 +[ ] 12. 写 / 跑回归测试:单元(fe-connector-/src/test)+ regression-test 中 image 兼容用例。 +[ ] 13. 删除 fe-core/datasource// 整个目录 + 所有未关联 import。 +``` + +--- + +## 5. 决策点(✅ 2026-05-24 全部按推荐确认) + +| ID | 决策内容 | 决议 | +|---|---|---| +| D1 | SPI 是否要支持 SQL 透传以外的远程 query(如 `query()` TVF)? | ✅ 沿用已有 `SUPPORTS_PASSTHROUGH_QUERY` | +| D2 | `PluginDrivenScanNode` 是否长期保持 `extends FileQueryScanNode`? | ✅ 是;JDBC/ES 用 `FORMAT_JNI` 兜底 | +| D3 | 旧 `*ExternalCatalog` 子类的命运? | ✅ **全部删除**,不保留中间形态 | +| D4 | HMS event pipeline 放哪儿? | ✅ **fe-connector-hms** 内,通过 `ConnectorMetaInvalidator` 回调 | +| D5 | hudi/iceberg 在 HMS 上的 DLA 模型? | ✅ **选项 A**:用 `ConnectorTableSchema.tableFormatType` 区分 | +| D6 | Iceberg snapshot/manifest cache 放哪儿? | ✅ **连接器内**,fe-core 不感知 | +| D7 | `kafka` / `kinesis` / `odbc` / `doris` 子目录是否在本计划范围? | ✅ **否**,单独立项 | +| D8 | 生产环境是否允许 "built-in" 连接器(classpath 中带)? | ✅ **否**,只测试用,生产强制目录式插件 | +| D9 | API 版本号何时 +1? | ✅ 本计划范围内**永不 +1**,只新增 default 方法 | +| D10 | `LakeSoulExternalCatalog` 是否删除? | ✅ 在 P8 删除剩余类 | +| D11 | `RemoteDorisExternalCatalog`(Doris-to-Doris)是否做成 connector? | ✅ 长期做,**不在本计划主线** | +| D12 | 用户安装 connector 后是否要求重启 FE? | ✅ 初版**强制重启** | + +--- + +## 6. 风险登记册 + +| ID | 风险 | 影响 | 缓解 | +|---|---|---|---| +| R1 | Image 反序列化兼容性回归(用户从旧 FE 升级) | High | 每次迁移加 image 兼容测试;保留 `gsonPostProcess` 迁移分支至少 2 个大版本 | +| R2 | Hive ACID 写路径在重构后数据不一致 | High | P7.3 必须有独立 ACID 集成测试套件作为 gate | +| R3 | Iceberg Procedure SPI 抽象失败(10 个 action 行为不齐) | Med | 先看 Trino Iceberg connector 怎么做的,再定 SPI 形态 | +| R4 | classloader 隔离打破 SDK 单例(Iceberg、Paimon、Trino)| Med | `ClassLoadingPolicy` 中 `parent-first` 列表必须覆盖所有共享 SDK 接口 | +| R5 | nereids 优化器对 `IcebergExternalTable` 的特殊规则不能用通用 SPI 表达 | Med | 在 P6.3 之前单独评审写路径方案;考虑给 ConnectorMetadata 暴露 hint API | +| R6 | 性能回归:每次访问通过 SPI 的反射/桥接增加额外开销 | Low | benchmark:1k 个 catalog × `listTableNames`、`getSchema` 路径基准;接受 < 5% 损失 | +| R7 | 部分 jar 在 BE / FE 间共享,连接器化后 FE 侧无法访问 | Low | `plugin-zip.xml` 的 exclude 列表要包含 BE 侧 jar;逐个 review | +| R8 | 用户文档与新插件部署流程不同步 | Low | P2 开始就同步写文档;P8 时整理为一份完整 admin guide | + +--- + +## 7. 交付物 + +1. 本计划 `plan-doc/00-connector-migration-master-plan.md`(v1)。 +2. 每个连接器一份 `plan-doc/--migration.md`,在进入对应阶段时撰写。 +3. P0 输出:`plan-doc/01-spi-extensions-rfc.md` —— SPI 新增能力的详细设计。 +4. P6 输出:`plan-doc/06-iceberg-write-path-rfc.md` —— Iceberg 写路径 SPI 化方案。 +5. P7 输出:`plan-doc/07-hms-event-pipeline-rfc.md` —— HMS event pipeline 放置 RFC。 +6. P8 输出:`fe/fe-connector/README.md` —— 用户/开发者最终使用手册。 + +--- + +## 8. 当前一周建议从哪开始 + +1. ✅ **已完成**:决策点 D1–D12 已于 2026-05-24 全部按推荐确认。 +2. 🚧 **进行中**:P0 RFC —— 见 `plan-doc/01-spi-extensions-rfc.md`,列出 §2.1 表里所有新增类型/方法的具体 Java 签名和 javadoc 草稿。 +3. **下一步**:P1 的重复清理 + scan-node 收口(无 SPI 风险、纯重构,可独立 PR)。 +4. **再下一步**:P2 trino-connector 全流程,把 playbook 跑通后再大规模铺开。 diff --git a/plan-doc/01-spi-extensions-rfc.md b/plan-doc/01-spi-extensions-rfc.md new file mode 100644 index 00000000000000..612a62d2dbe40d --- /dev/null +++ b/plan-doc/01-spi-extensions-rfc.md @@ -0,0 +1,1353 @@ +# P0 — Connector SPI 扩展 RFC v1 + +> 状态:草案 v1 · 日期 2026-05-24 · 阶段 P0 · 主计划 [`00-connector-migration-master-plan.md`](./00-connector-migration-master-plan.md) +> 评审人:FE 平台组、各 connector owner +> 范围:列出后续 6 个 connector 迁移所需的全部新增 SPI 类型 / 方法 / 默认行为,给出 Java 签名草稿与影响面分析。 +> 不在范围:现有 SPI 的破坏性变更(D9 锁定 `apiVersion=1`)。 + +--- + +## 0. 摘要 + +| # | 扩展点 | 触发的迁移目标 | 入口包 | 影响阶段 | +|---|---|---|---|---| +| E1 | DDL Info / `ConnectorCreateTableRequest` | Hive、Iceberg、Paimon 的完整 CREATE TABLE | `connector.api.ddl` | P5/P6/P7 | +| E2 | Procedures / `ConnectorProcedureOps` | Iceberg 10 个 action | `connector.api.procedure` | P6 | +| E3 | Meta Invalidator / `ConnectorMetaInvalidator` | HMS event pipeline | `connector.spi` + `connector.api.events` | P7 | +| E4 | Transactions / `ConnectorTransaction` | Hive ACID、Iceberg、Paimon、MaxCompute | `connector.api`(扩展 `WriteOps`)| P5–P7 | +| E5 | MVCC Snapshot / `ConnectorMvccSnapshot` | Iceberg、Paimon | `connector.api.mvcc` | P5/P6 | +| E6 | Vended Credentials / `ConnectorCredentials` | Iceberg REST、Paimon REST、S3 Tables | `connector.api.scan` | P5/P6 | +| E7 | Sys Tables | Iceberg `$snapshots/$history/...`、Paimon | `connector.api`(扩展 `TableOps`)| P5/P6 | +| E8 | 列级 Statistics 写入 / `ConnectorColumnStatistics` | Hive ANALYZE | `connector.api.statistics`(扩展 `StatisticsOps`)| P7 | +| E9 | Delete / Merge sink 配置 | Iceberg DML | `connector.api.write`(扩展 `WriteConfig`/`WriteOps`)| P6 | +| E10 | Partition 列举 / `listPartitions` | MaxCompute、Paimon、Hive | `connector.api`(扩展 `TableOps`)| P4/P5/P7 | + +**总体不变量**: + +- 全部以 **default 方法**新增;现有 ES / JDBC 实现零修改。 +- `ConnectorProvider.apiVersion()` 保持 `1`;`ConnectorPluginManager.CURRENT_API_VERSION` 保持 `1`。 +- 任何新增类型不依赖 `org.apache.doris.{catalog,common,datasource,qe,analysis,nereids,planner}`。 +- 与已有类型有命名冲突时,**复用旧的**(如 `ConnectorPartitionInfo` 复用、`ConnectorWriteConfig` 扩展而非重建)。 + +--- + +## 1. 目标与范围 + +**做什么**:把主计划 §2.1 的 10 项 SPI 缺口逐一展开到"可以发起 PR 的 Java 签名级别"。每项列: + +1. 现状(旧实现锚点:fe-core 文件 + 关键调用方)。 +2. 设计签名(接口 / 类草稿,含 javadoc 关键句)。 +3. 默认行为(让旧 connector 零修改通过)。 +4. fe-core 侧 converter / 适配(如有)。 +5. 受影响连接器与验收标准。 + +**不做什么**:实现代码——本 RFC 只到接口和草稿层;实现在对应 Pn 阶段做。 + +--- + +## 2. 设计原则 + +### 2.1 向后兼容(核心) + +- 每个新增方法都是 `default`,旧 connector 不实现也能编译通过。 +- 默认行为分两类: + - **能力声明类**(`supports*` / `listSysTableTypes`)→ 返回空 / false。 + - **必须实现才有意义类**(`createTable(request)` / `callProcedure`)→ `throw new DorisConnectorException("xxx not supported")`,由 fe-core 在调用前用对应 `ConnectorCapability` 判断。 + +### 2.2 包结构(在现有基础上微调) + +``` +fe-connector-api/src/main/java/org/apache/doris/connector/api/ +├── (existing) Connector, ConnectorMetadata, ConnectorSession, ConnectorTableSchema, ... +├── ddl/ [NEW] ConnectorCreateTableRequest, ConnectorPartitionSpec, ConnectorBucketSpec +├── events/ [NEW] ConnectorMetaInvalidator ← 接口在 spi 包,类放 api 便于复用 +├── mvcc/ [NEW] ConnectorMvccSnapshot +├── procedure/ [NEW] ConnectorProcedureOps, ConnectorProcedureSpec, ConnectorProcedureArgument +├── statistics/ [NEW] ConnectorColumnStatistics ← ConnectorStatisticsOps 已在 api 包根 +├── pushdown/ (existing) +├── scan/ (existing) + [NEW] ConnectorCredentials +├── write/ (existing) + [NEW] ConnectorWriteType.DELETE / MERGE +└── handle/ (existing) + [NEW] ConnectorTransaction (replace placeholder) +``` + +`fe-connector-spi` 只新增一个 `ConnectorMetaInvalidator` 接口(放在 spi 包让 `ConnectorContext` 可以引用),其余都在 api。 + +### 2.3 命名一致性 + +- 接口名:`Connector*Ops` 表示一组操作(继承到 `ConnectorMetadata`),如 `ConnectorProcedureOps`。 +- 值对象:`Connector*` 名词(如 `ConnectorCreateTableRequest`、`ConnectorMvccSnapshot`)。 +- Handle:`Connector*Handle`(不可变标识 / opaque pointer)。 + +### 2.4 不在 SPI 暴露的东西 + +- Doris 内部类型:`Expr`、`Column`、`TableIf`、`CreateTableInfo`、`PartitionDesc` 等——fe-core 侧 converter 负责翻译。 +- 任何 `org.apache.doris.thrift.*` 类只在 `ConnectorScanRange.populateRangeParams` / `ConnectorMetadata.buildTableDescriptor` / `ConnectorScanPlanProvider.populateScanLevelParams` 三个已有入口暴露,新增 SPI 不引入更多 thrift 依赖。 + +--- + +## 3. 扩展点速查矩阵 + +| 扩展 | 新增类型 | 新增 / 扩展的方法(节选) | +|---|---|---| +| E1 | `ConnectorCreateTableRequest`、`ConnectorPartitionSpec`、`ConnectorBucketSpec` | `ConnectorTableOps.createTable(session, request)` | +| E2 | `ConnectorProcedureOps`、`ConnectorProcedureSpec`、`ConnectorProcedureArgument` | `ConnectorMetadata extends ConnectorProcedureOps` | +| E3 | `ConnectorMetaInvalidator`(spi 包接口) | `ConnectorContext.getMetaInvalidator()` | +| E4 | `ConnectorTransaction`(继承自旧的 `ConnectorTransactionHandle`) | `ConnectorWriteOps.beginTransaction(session)`、`commit/rollback` | +| E5 | `ConnectorMvccSnapshot` | `ConnectorMetadata.beginQuerySnapshot / getSnapshotAt / getSnapshotById` | +| E6 | `ConnectorCredentials` | `ConnectorScanPlanProvider.getCredentialsForScans(session, handle, ranges) → Map` | +| E7 | — | `ConnectorTableOps.listSysTableTypes(handle)` + 通过 `getTableHandle("tbl$snapshots")` 暴露 | +| E8 | `ConnectorColumnStatistics` | `ConnectorStatisticsOps.setColumnStatistics(...)` | +| E9 | `ConnectorWriteType.DELETE` / `MERGE_DELETE` / `MERGE_INSERT` 三个新枚举值 | `ConnectorWriteOps.getDeleteConfig / getMergeConfig` | +| E10 | — | `ConnectorTableOps.listPartitionNames` + `listPartitions(handle, filter)` | +| E11 | `ConnectorWritePlanProvider`、`ConnectorSinkPlan`、`ConnectorWriteHandle`(写包)| `Connector.getWritePlanProvider()`、`ConnectorTransaction.addCommitData / supportsWriteBlockAllocation / allocateWriteBlockRange / getUpdateCnt`([D-022];详见 §20 + 写 RFC)| + +--- + +## 4. 扩展 E1:DDL Info / `ConnectorCreateTableRequest` + +### 4.1 现状 + +- `IcebergMetadataOps.createTable(CreateTableInfo)` 直接吃 nereids 的 `CreateTableInfo`(含 `ColumnDefinition`、`PartitionTableInfo`、`DistributionDescriptor`、`engine`、`properties`)。 +- `HiveMetadataOps.createTable(CreateTableInfo)` 同上。 +- 现有 SPI 的 `ConnectorTableOps.createTable(session, ConnectorTableSchema, Map)` **没有分区 / 分桶 / external / ifNotExists 概念**。 + +### 4.2 设计 + +```java +// connector.api.ddl.ConnectorCreateTableRequest +package org.apache.doris.connector.api.ddl; + +public final class ConnectorCreateTableRequest { + private final String dbName; + private final String tableName; + private final List columns; + private final ConnectorPartitionSpec partitionSpec; // nullable + private final ConnectorBucketSpec bucketSpec; // nullable + private final String comment; + private final Map properties; + private final boolean ifNotExists; + private final boolean external; // EXTERNAL TABLE + // builder + getters omitted +} + +public final class ConnectorPartitionSpec { + public enum Style { + IDENTITY, // Hive style: partition by col1, col2 + TRANSFORM, // Iceberg style: bucket(N, col) / truncate(N, col) / years(col) / ... + LIST, // Doris style: PARTITION BY LIST + RANGE, // Doris style: PARTITION BY RANGE + } + private final Style style; + private final List fields; + private final List initialValues; // for LIST/RANGE +} + +public final class ConnectorPartitionField { + private final String columnName; + private final String transform; // "identity" | "bucket" | "truncate" | "year" | "month" | "day" | "hour" + private final List transformArgs; // e.g., [16] for bucket(16, ...) +} + +public final class ConnectorBucketSpec { + private final List columns; + private final int numBuckets; + private final String algorithm; // "hive_hash" | "iceberg_bucket" | "doris_default" +} +``` + +### 4.3 在 `ConnectorTableOps` 新增 + +```java +public interface ConnectorTableOps { + // ... existing ... + + /** + * Creates a table with full DDL semantics (partition, bucket, external, IF NOT EXISTS). + * + *

    Connectors should override this method when they support advanced CREATE TABLE + * options. The default implementation degrades to the legacy + * {@link #createTable(ConnectorSession, ConnectorTableSchema, Map)} for backward + * compatibility, dropping partition / bucket / external 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()); + } +} +``` + +### 4.4 fe-core 侧 converter + +新增 `fe/fe-core/src/main/java/org/apache/doris/connector/ddl/CreateTableInfoToConnectorRequestConverter.java`: + +```java +public final class CreateTableInfoToConnectorRequestConverter { + public static ConnectorCreateTableRequest convert(CreateTableInfo info, + String dbName) { + return ConnectorCreateTableRequest.builder() + .dbName(dbName) + .tableName(info.getTableNameInfo().getTbl()) + .columns(convertColumns(info.getColumns())) + .partitionSpec(convertPartition(info.getPartitionTableInfo())) + .bucketSpec(convertBucket(info.getDistributionDesc())) + .comment(info.getComment()) + .properties(info.getProperties()) + .ifNotExists(info.isIfNotExists()) + .external(info.isExternal()) + .build(); + } + // ... convertColumns / convertPartition / convertBucket +} +``` + +`PluginDrivenExternalCatalog` 不需要改——CREATE TABLE 经由 `ExternalCatalog.createTable(...)` 入口,新加一段: + +```java +public class PluginDrivenExternalCatalog extends ExternalCatalog { + @Override + public boolean createTable(CreateTableStmt stmt) throws UserException { + ConnectorSession s = buildConnectorSession(); + ConnectorCreateTableRequest req = CreateTableInfoToConnectorRequestConverter + .convert(stmt.getCreateTableInfo(), stmt.getDbName()); + connector.getMetadata(s).createTable(s, req); + return true; + } +} +``` + +### 4.5 影响的连接器 + +| 连接器 | 必须实现 | 备注 | +|---|---|---| +| Hive | 是 | 当前 fe-core 用 `CreateTableInfo` 直接构造 Hive table,要还原全部分区 / 分桶逻辑 | +| Iceberg | 是 | Iceberg transform spec 是最复杂的,已是 connector 化重点 | +| Paimon | 是 | bucket spec 必须 | +| JDBC | 不需要 | 已经在用旧 createTable,无 partition / bucket 需求 | +| ES | 不需要 | 不支持 CREATE TABLE | +| 其他(MaxCompute/Trino/Hudi)| 取决于是否支持 CREATE TABLE | MaxCompute 支持 partition;Trino-connector 透传;Hudi 不支持 | + +### 4.6 验收标准 + +- `mvn -pl fe-connector-api compile` 通过。 +- 一个测试 connector(在 `fe-connector-api/src/test`)只用旧 `createTable(session, schema, props)` 也能编译。 +- fe-core `CreateTableInfoToConnectorRequestConverter` 单测覆盖 Hive 风格 / Iceberg transform / List partition 三种来源。 + +--- + +## 5. 扩展 E2:Procedures / `ConnectorProcedureOps` + +### 5.1 现状 + +- `fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/action/BaseIcebergAction.java` 抽象基类。 +- 10 个子类:`IcebergCherrypickSnapshotAction`、`IcebergExpireSnapshotsAction`、`IcebergFastForwardAction`、`IcebergPublishChangesAction`、`IcebergRewriteDataFilesAction`、`IcebergRewriteManifestsAction`、`IcebergRollbackToSnapshotAction`、`IcebergRollbackToTimestampAction`、`IcebergSetCurrentSnapshotAction`。 +- `IcebergExecuteActionFactory` 按 procedure 名 dispatch。 +- 入口:`CALL iceberg.system.rewrite_data_files(...)` 之类语法 → nereids `ExecuteCommand` → `ExternalCatalog.executeAction(...)`(当前是 `IcebergExternalCatalog` 实现)。 + +### 5.2 设计 + +```java +// connector.api.procedure.ConnectorProcedureOps +package org.apache.doris.connector.api.procedure; + +public interface ConnectorProcedureOps { + + /** + * Lists all procedures this connector exposes. + * + *

    Lifecycle contract (U1): the returned set MUST be stable across + * the connector instance's lifetime. fe-core may cache this list, and changes + * in the external system (e.g., a server-side plugin install) will only be + * visible after the catalog is dropped and re-created.

    + */ + default List listProcedures() { + return Collections.emptyList(); + } + + /** + * Executes a named procedure with bound arguments. + * + *

    Argument values follow the {@link ConnectorType} system: + * boxed primitives, {@link String}, {@link java.time.Instant}, {@link java.util.List}, {@link Map}.

    + * + * @param session connector session + * @param procedureName fully qualified procedure name (e.g., "rewrite_data_files") + * @param arguments name → bound value + * @return procedure-specific result map (e.g., "rewritten_data_files_count" → 42) + * @throws DorisConnectorException if the procedure name is unknown or args are invalid + */ + default Map callProcedure(ConnectorSession session, + String procedureName, Map arguments) { + throw new DorisConnectorException( + "Procedure not supported: " + procedureName); + } +} + +public final class ConnectorProcedureSpec { + private final String name; + private final String description; + private final List arguments; + // builder + getters +} + +public final class ConnectorProcedureArgument { + private final String name; + private final ConnectorType type; + private final boolean required; + private final Object defaultValue; // boxed, may be null + // builder + getters +} +``` + +### 5.3 在 `ConnectorMetadata` 加入 super interface + +```java +public interface ConnectorMetadata extends + ConnectorSchemaOps, + ConnectorTableOps, + ConnectorPushdownOps, + ConnectorStatisticsOps, + ConnectorWriteOps, + ConnectorIdentifierOps, + ConnectorProcedureOps, // [NEW] + Closeable { ... } +``` + +### 5.4 fe-core 侧适配 + +把 `ExecuteCommand`(nereids)改为: + +```java +public class ExecuteCommand extends Command { + public void run(ConnectContext ctx) { + ExternalCatalog cat = ...; + if (cat instanceof PluginDrivenExternalCatalog) { + PluginDrivenExternalCatalog pdc = (PluginDrivenExternalCatalog) cat; + ConnectorSession s = pdc.buildConnectorSession(); + Map result = pdc.getConnector() + .getMetadata(s) + .callProcedure(s, procedureName, argsMap); + displayResult(result); + return; + } + // legacy path (kept until P6 completes) + } +} +``` + +`IcebergConnectorMetadata.callProcedure` 内部走原 `BaseIcebergAction` 的 10 个子类实现(搬到 connector 内)。 + +### 5.5 影响连接器 + +- Iceberg(必须,10 procedure)。 +- Paimon(可选,未来可加 expire-snapshots 等)。 +- 其他连接器:不实现。 + +### 5.6 验收标准 + +- 默认行为:未实现 procedure 的 connector 调用时抛清晰错误,不导致 NPE。 +- `ConnectorProcedureSpec` 通过 `Connector.getMetadata(...).listProcedures()` 暴露,可被 `SHOW PROCEDURES FROM ` 列出(**附:** 是否需要这条 SQL 也加 SPI 入口?建议留到 P6 评估)。 + +--- + +## 6. 扩展 E3:Meta Invalidator / `ConnectorMetaInvalidator` + +### 6.1 现状 + +- `fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/MetastoreEventsProcessor.java` 是后台线程。 +- 21 个 `MetastoreEvent` 子类(`CreateTableEvent`、`AlterPartitionEvent`、`InsertEvent`...)封装 HMS `NotificationEvent`。 +- 事件处理流:HMS API → `EventFactory` → 解析为 `MetastoreEvent` → `event.process()` → 调 fe-core `ExternalMetaCacheMgr.invalidateTableCache(...)`。 + +### 6.2 设计(D4:把 event 流程整体搬到 fe-connector-hms) + +```java +// connector.spi.ConnectorMetaInvalidator ← 放 spi 包,让 ConnectorContext 可引用 +package org.apache.doris.connector.spi; + +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., ["2024", "01"]) + */ + 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) { } +} +``` + +### 6.3 在 `ConnectorContext` 暴露 + +```java +public interface ConnectorContext { + // ... existing ... + + /** + * Returns the meta invalidator that the connector can call to notify + * the engine of external metadata changes (e.g., from HMS notification events). + */ + default ConnectorMetaInvalidator getMetaInvalidator() { + return ConnectorMetaInvalidator.NOOP; + } +} +``` + +### 6.4 fe-core 侧实现 + +`DefaultConnectorContext` 提供基于 `ExternalMetaCacheMgr` + 当前 catalogId 的实例: + +```java +public class DefaultConnectorContext implements ConnectorContext { + @Override + public ConnectorMetaInvalidator getMetaInvalidator() { + return new ExternalMetaCacheInvalidator(this.catalogId); + } +} + +// fe/fe-core/.../connector/ExternalMetaCacheInvalidator.java [NEW] +public class ExternalMetaCacheInvalidator implements ConnectorMetaInvalidator { + private final long catalogId; + public ExternalMetaCacheInvalidator(long catalogId) { this.catalogId = catalogId; } + + @Override + public void invalidateTable(String dbName, String tableName) { + Env.getCurrentEnv().getExtMetaCacheMgr() + .invalidateTableCache(catalogId, dbName, tableName); + } + // ... other methods delegate to ExternalMetaCacheMgr +} +``` + +### 6.5 fe-connector-hms 侧迁移 + +整体 move: + +``` +mv fe/fe-core/src/main/java/org/apache/doris/datasource/hive/event/* + fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/events/ +``` + +`MetastoreEventsProcessor` 的构造参数从 `HMSExternalCatalog` 改为 `(HmsClient, ConnectorMetaInvalidator)`。每个 event 类 `process()` 改为调 `invalidator.invalidateXxx`,而不是 fe-core 的 `ExternalMetaCacheMgr`。 + +启动:`HiveConnector` 在 `create(...)` 时启动一个 `MetastoreEventsProcessor` 后台线程;`close()` 时停掉。 + +### 6.6 影响 + +- 仅 Hive / HMS(其它 connector 不需要 event)。 +- fe-core `ExternalMetaCacheMgr` API 表面不变;只是被调用方从 `MetastoreEventsProcessor` 变为 `ExternalMetaCacheInvalidator`。 + +### 6.7 验收标准 + +- `fe-connector-hms` 不再 import 任何 `org.apache.doris.datasource.*`。 +- 现有的 HMS event 集成测试(如果有)继续通过。 +- 在没有 event listener 的连接器上,`ConnectorContext.getMetaInvalidator()` 返回 NOOP,无任何后台线程开销。 + +--- + +## 7. 扩展 E4:Transactions / `ConnectorTransaction` + +### 7.1 现状 + +- `fe/fe-core/.../transaction/TransactionManagerFactory.java` 按 catalog 类型 switch: + - HMS → `HiveTransactionManager`(包 `HiveTransactionMgr`,包 `HMSTransaction`) + - Iceberg → `IcebergTransactionManager`(包 `IcebergTransaction`) + - PluginDriven → `PluginDrivenTransactionManager`(占位) +- 每个 `*Transaction` 类持有 commit/rollback 状态:snapshot id、staged files、partition adds 等。 + +### 7.2 设计 + +将占位的 `ConnectorTransactionHandle`(24 行的空接口)扩展为可用的 `ConnectorTransaction`: + +```java +// connector.api.handle.ConnectorTransaction ← 同包替换占位 +package org.apache.doris.connector.api.handle; + +public interface ConnectorTransaction extends ConnectorTransactionHandle, Closeable { + + /** Stable transaction ID assigned by the connector. */ + long getTransactionId(); + + /** + * Commits all pending operations bound to this transaction. + * + * @throws DorisConnectorException on conflict / IO failure / external system error + */ + void commit(); + + /** + * Aborts all pending operations and releases resources. + * Safe to call multiple times; subsequent calls are no-ops. + */ + void rollback(); + + /** Called by the engine after commit OR rollback to release connections etc. */ + @Override + void close(); +} +``` + +### 7.3 在 `ConnectorWriteOps` 扩展 + +```java +public interface ConnectorWriteOps { + // ... existing beginInsert/finishInsert/abortInsert/beginDelete/... ... + + /** + * Begins a new transaction scoped to a single SQL statement (auto-commit) or to + * an explicit BEGIN..COMMIT block. The returned transaction is passed to subsequent + * begin* / finish* / abort* calls via the same {@link ConnectorSession}. + * + *

    Connectors that do not support multi-statement transactions can either:

    + *
      + *
    • Return a no-op transaction whose commit/rollback do nothing.
    • + *
    • Throw, in which case the engine treats every statement as auto-commit.
    • + *
    + */ + default ConnectorTransaction beginTransaction(ConnectorSession session) { + throw new DorisConnectorException("Transactions not supported"); + } +} +``` + +### 7.4 fe-core 侧改造 + +`PluginDrivenTransactionManager` 改为通用: + +```java +public class PluginDrivenTransactionManager implements TransactionManager { + private final Map active = new ConcurrentHashMap<>(); + + public ConnectorTransaction begin(Connector c, ConnectorSession s) { + ConnectorTransaction tx = c.getMetadata(s).beginTransaction(s); + active.put(tx.getTransactionId(), tx); + return tx; + } + + @Override + public void commit(long txId) { + ConnectorTransaction tx = active.remove(txId); + if (tx != null) { tx.commit(); tx.close(); } + } + + @Override + public void rollback(long txId) { + ConnectorTransaction tx = active.remove(txId); + if (tx != null) { tx.rollback(); tx.close(); } + } +} +``` + +`TransactionManagerFactory` 在 P7/P8 删除 HMS / Iceberg 分支,只留 PluginDriven 一种。 + +### 7.5 影响 + +- Hive、Iceberg、Paimon、MaxCompute(4 个有事务的连接器)。 +- JDBC、ES、Trino-connector:返回 no-op transaction 或抛 unsupported。 + +### 7.6 与旧 `beginInsert` 的关系 + +旧 `beginInsert(session, handle, columns) -> ConnectorInsertHandle` 不变;新增的 `beginTransaction` 是"包含 begin/end 的更高阶事务"。连接器有两种用法: + +1. **简单**:不实现 `beginTransaction`,每次 `beginInsert` 内部自管事务(适合 JDBC)。 +2. **复杂**:实现 `beginTransaction`,`beginInsert` 内部把 work 挂到当前 `ConnectorSession` 关联的事务上(适合 Iceberg / Hive ACID)。 + +`ConnectorSession` 新增可选字段: + +```java +public interface ConnectorSession { + // ... existing ... + default Optional getCurrentTransaction() { + return Optional.empty(); + } +} +``` + +fe-core 用 `ConnectorSessionImpl` 在事务期间填入。 + +### 7.7 验收标准 + +- 已有 JDBC 测试(auto-commit)继续通过。 +- 新增一个 mock 事务 connector 测试 BEGIN/COMMIT 路径。 + +--- + +## 8. 扩展 E5:MVCC Snapshot / `ConnectorMvccSnapshot` + +### 8.1 现状 + +- `fe/fe-core/.../iceberg/IcebergMvccSnapshot.java` 包装 Iceberg snapshot id + timestamp。 +- `fe/fe-core/.../paimon/PaimonMvccSnapshot.java` 同上。 +- 调用方:nereids `MvccSnapshot` 接口在分析阶段查询 snapshot;scan plan 使用 snapshot id。 + +### 8.2 设计 + +```java +// connector.api.mvcc.ConnectorMvccSnapshot +package org.apache.doris.connector.api.mvcc; + +public final class ConnectorMvccSnapshot { + private final long snapshotId; + private final long timestampMillis; + private final String description; + private final Map properties; // connector-specific metadata + // builder + getters +} +``` + +### 8.3 在 `ConnectorMetadata` 新增 + +```java +public interface ConnectorMetadata extends ... { + + /** + * Returns the current snapshot at query begin time, used as the MVCC pin for + * all subsequent reads of {@code handle}. Returning {@link Optional#empty()} + * means the connector does not support MVCC and reads see whatever is current. + */ + default Optional beginQuerySnapshot( + ConnectorSession session, ConnectorTableHandle handle) { + return Optional.empty(); + } + + /** Returns the snapshot at the given wall-clock time, or empty if none. */ + default Optional getSnapshotAt( + ConnectorSession session, ConnectorTableHandle handle, + long timestampMillis) { + return Optional.empty(); + } + + /** Returns the snapshot with the given id, or empty if none. */ + default Optional getSnapshotById( + ConnectorSession session, ConnectorTableHandle handle, + long snapshotId) { + return Optional.empty(); + } +} +``` + +### 8.4 fe-core 侧 + +新增 `ConnectorMvccSnapshotAdapter` 实现 fe-core 的 `MvccSnapshot` 接口,包 `ConnectorMvccSnapshot`。`PluginDrivenExternalTable` 在 `getMvccSnapshot(...)` 中返回 adapter 实例。 + +### 8.5 影响 + +- Iceberg、Paimon 必须实现。 +- Hudi 可选(incremental query 时序)。 +- 其他 connector 默认返回 `Optional.empty()`,fe-core 退化到非 MVCC 读。 + +### 8.6 验收标准 + +- Iceberg / Paimon connector 实现后能传 snapshot id 到 BE。 +- `SELECT * FROM tbl FOR VERSION AS OF 123` / `FOR TIMESTAMP AS OF '...'` 路径走通。 + +--- + +## 9. 扩展 E6:Vended Credentials / `ConnectorCredentials` + +### 9.1 现状 + +- `fe/fe-core/.../iceberg/IcebergVendedCredentialsProvider.java`、`PaimonVendedCredentialsProvider.java` 在 fe-core 通过 `instanceof` 探测,再调 connector 的 REST catalog 拿 STS 凭证。 +- 凭证传给 BE:嵌在 `TFileScanRangeParams.location_properties` 里。 +- `ConnectorCapability.SUPPORTS_VENDED_CREDENTIALS` 已存在。 + +### 9.2 设计 + +```java +// connector.api.scan.ConnectorCredentials +package org.apache.doris.connector.api.scan; + +public final class ConnectorCredentials { + private final Map credentials; // e.g., aws_access_key / aws_secret_key / session_token + private final long expiryEpochMillis; // -1 = no expiry + // builder + getters +} +``` + +### 9.3 在 `ConnectorScanPlanProvider` 新增 + +```java +public interface ConnectorScanPlanProvider { + // ... existing ... + + /** + * Returns short-lived credentials for a batch of scan ranges in a single call. + * + *

    Batch semantics let the connector amortize STS / vending API calls:

    + *
      + *
    • One STS call for all ranges → return a {@link Map} that maps every range + * to the same {@link ConnectorCredentials} instance.
    • + *
    • Group ranges by location prefix (e.g., S3 bucket / prefix) → return a + * map where ranges in the same group share an instance.
    • + *
    • One credential per range → return a map with distinct instances per key.
    • + *
    + * + *

    The returned map's keys must be a subset of {@code scanRanges}; any range + * not present in the map will scan without vended credentials (the engine falls + * back to the catalog-level filesystem properties).

    + * + *

    Connectors that do not vend credentials should return + * {@link Collections#emptyMap()} (the default).

    + * + * @param session current session + * @param handle the table being scanned + * @param scanRanges all ranges produced by {@link #planScan} for this scan node + * @return per-range credentials map (instances may be shared across keys) + */ + default Map getCredentialsForScans( + ConnectorSession session, + ConnectorTableHandle handle, + List scanRanges) { + return Collections.emptyMap(); + } +} +``` + +### 9.4 fe-core 侧 + +`PluginDrivenScanNode` 在 `createScanRangeLocations()` 完成后、`setScanParams` 之前做一次批量调用并缓存结果: + +```java +public class PluginDrivenScanNode extends FileQueryScanNode { + // ... existing fields ... + private Map cachedCredentials; + + @Override + public void createScanRangeLocations() throws UserException { + super.createScanRangeLocations(); + + if (connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_VENDED_CREDENTIALS)) { + List ranges = collectScanRanges(); // already on hand from getSplits() + cachedCredentials = scanProvider.getCredentialsForScans( + connectorSession, currentHandle, ranges); + } + // ... existing populateScanLevelParams etc. + } + + @Override + protected void setScanParams(TFileRangeDesc rangeDesc, Split split) { + // ... existing tableFormatFileDesc construction ... + if (cachedCredentials != null) { + ConnectorScanRange range = ((PluginDrivenSplit) split).getConnectorScanRange(); + ConnectorCredentials c = cachedCredentials.get(range); // null = no vended creds for this range + if (c != null) { + mergeIntoLocationProps(rangeDesc, c.getCredentials()); + } + } + } +} +``` + +**关键不变量**: +- `getCredentialsForScans` 在一个 scan node 生命周期内只被调用一次。 +- 返回 map 的 value 可以共享实例 —— 单次 STS call、N 个 range 同一组凭证是常态而非例外。 +- 返回 map 的 key 是输入 list 的子集 —— 缺失的 range 退化到 catalog-level FS properties,**不报错**。 + +### 9.5 影响 + +- Iceberg REST catalog、Paimon REST catalog、S3 Tables。 +- 其他 connector 不实现。 + +### 9.6 验收标准 + +- Iceberg REST + S3 vended path 跑通查询。 +- 凭证不出现在 EXPLAIN / SHOW CREATE 输出(mask test)。 +- **STS 调用频次回归**:一个 scan node 不论 split 数量多少,对外只触发 1 次 STS 调用(除非连接器主动按 prefix 分组)。在 `IcebergConnectorMetadataTest` 或同级集成测试里加 mock STS 计数器断言。 + +--- + +## 10. 扩展 E7:Sys Tables + +> ⚠️ **本节 §10.2/§10.3 的「`$`-后缀普通表 + 连接器 `getTableHandle` 内解析后缀 + `listSysTableSuffixes`」设计已被 D-039 / DV-023 取代(superseded 2026-06-10,P5-B4 实现时)。** 该设计**从未落地**;live fe-core 实际用 `SysTableResolver` + `NativeSysTable` + `TableIf.getSupportedSysTables/findSysTable`(iceberg + legacy-paimon 共用)。P5-B4 复用该 live 机制:连接器 SPI 加 `ConnectorTableOps.listSupportedSysTables` + `getSysTableHandle`(default no-op),fe-core 加通用 `PluginDrivenSysTable extends NativeSysTable` + `PluginDrivenSysExternalTable`(报 `PLUGIN_EXTERNAL_TABLE`,经 `SysTableResolver` 路由到 `PluginDrivenScanNode`)。§10.1 现状仍准确;下方 §10.2/§10.3 仅作历史设计追溯,**勿据其实现**。详见 [decisions-log D-039](./decisions-log.md) / [deviations-log DV-023](./deviations-log.md) / `tasks/P5-paimon-migration.md` §批次 B4。 + +### 10.1 现状 + +- `IcebergSysExternalTable.SysTableType` 枚举(`HISTORY`、`SNAPSHOTS`、`FILES`、`MANIFESTS`、`PARTITIONS`、`POSITION_DELETES`、`ALL_DATA_FILES`、`ALL_MANIFESTS`、`ENTRIES`)。 +- `PaimonSysExternalTable` 类似。 +- 引用方式:`SELECT * FROM iceberg_cat.db.tbl$snapshots`。 + +### 10.2 设计(**不引入新类型**,复用 `ConnectorTableHandle`) + +把 sys-table 看作"特殊命名的普通表"。`ConnectorTableOps.getTableHandle(session, db, "tbl$snapshots")` 由 connector 内部解析 `$snapshots` 后缀,返回带 sys-type 标记的 handle(标记在 connector 内部,对 fe-core 透明)。 + +新增一个 listing 入口供 `SHOW TABLES` 选择性展示: + +```java +public interface ConnectorTableOps { + // ... existing ... + + /** + * Lists the connector-specific system table suffixes available for a base table. + * Returns the set of suffixes (without the leading "$"), e.g., ["snapshots", "history", "files"]. + * Default: empty (no sys tables). + */ + default List listSysTableSuffixes(ConnectorSession session, + ConnectorTableHandle baseTableHandle) { + return Collections.emptyList(); + } +} +``` + +`getTableSchema(session, sysHandle)` 返回的 schema 中 `tableFormatType = "ICEBERG_SYS"` / `"PAIMON_SYS"`,scan provider 走对应路径。 + +### 10.3 fe-core 侧 + +`PluginDrivenExternalDatabase.tableExists("tbl$snapshots")` 路由到 `connector.getMetadata(s).getTableHandle(s, db, "tbl$snapshots")`。 + +`information_schema.tables` 默认不展开 sys table(避免噪音);用户显式 `SHOW TABLES LIKE '%$%'` 时才查 `listSysTableSuffixes`。 + +### 10.4 影响 + +- Iceberg、Paimon 实现 `listSysTableSuffixes` + `getTableHandle("$xxx")`。 +- 其他 connector:默认空。 + +### 10.5 验收标准 + +- `SELECT * FROM cat.db.tbl$snapshots` 工作。 +- `SHOW TABLES` 默认不返回 `tbl$snapshots`。 + +--- + +## 11. 扩展 E8:列级 Statistics 写入 / `ConnectorColumnStatistics` + +### 11.1 现状 + +- `HMSExternalTable.createAnalysisTask(info) → ExternalAnalysisTask`。 +- task 跑 `ANALYZE TABLE ... COMPUTE STATISTICS` 后调 `HiveMetadataOps.updateColumnStatistics(...)`。 + +### 11.2 设计 + +```java +// connector.api.statistics.ConnectorColumnStatistics +package org.apache.doris.connector.api.statistics; + +/** + * Per-column statistics for a connector table. + * + *

    Type safety for {@code minValue} / {@code maxValue} (U6): + * Values are stored as {@link Object} but MUST be one of the Java boxed types + * listed below, matched to the column's {@link ConnectorType}. Connectors + * reading a value that does not match the expected type MUST throw + * {@link IllegalArgumentException}; fe-core translates this to a + * user-visible {@code UserException}.

    + * + *

    + * + * + * + * + * + * + * + * + * + * + * + * + * + *
    Allowed Java types for min/max by ConnectorType family
    ConnectorType familyJava boxed type
    BOOLEAN{@link Boolean}
    TINYINT / SMALLINT / INT{@link Integer}
    BIGINT{@link Long}
    LARGEINT / DECIMAL{@link java.math.BigDecimal}
    FLOAT{@link Float}
    DOUBLE{@link Double}
    DATE{@link java.time.LocalDate}
    DATETIME / TIMESTAMP{@link java.time.Instant}
    CHAR / VARCHAR / STRING{@link String}
    BINARY / VARBINARY{@code byte[]}
    ARRAY / MAP / STRUCTmin/max NOT applicable — must be {@code null}
    + */ +public final class ConnectorColumnStatistics { + private final long nullCount; // -1 unknown + private final long ndv; // num distinct values; -1 unknown + private final Object minValue; // boxed per type table above; null = no min + private final Object maxValue; // boxed per type table above; null = no max + private final long avgRowSizeBytes; // -1 unknown + private final long maxRowSizeBytes; // -1 unknown + // builder + getters +} +``` + +### 11.3 在 `ConnectorStatisticsOps` 新增 + +```java +public interface ConnectorStatisticsOps { + // ... existing getTableStatistics ... + + /** Returns per-column statistics, or empty map if unavailable. */ + default Map getColumnStatistics( + ConnectorSession session, ConnectorTableHandle handle) { + return Collections.emptyMap(); + } + + /** + * Persists per-column statistics back to the external metastore. + * Called by {@code ANALYZE TABLE} after FE computes statistics. + */ + default void setColumnStatistics(ConnectorSession session, + ConnectorTableHandle handle, + Map columnStats) { + throw new DorisConnectorException("setColumnStatistics not supported"); + } +} +``` + +### 11.4 fe-core 侧 + +`ExternalAnalysisTask.persist(...)` 检测 catalog 是否为 `PluginDrivenExternalCatalog` —— 是则调 `connector.getMetadata(s).setColumnStatistics(s, handle, statsMap)`。 + +### 11.5 影响 + +- 主要 Hive(HMS column stats)。 +- Iceberg / Paimon 可选(snapshot summary 已包含部分统计)。 + +### 11.6 验收标准 + +- `ANALYZE TABLE hive_cat.db.tbl COMPUTE STATISTICS FOR ALL COLUMNS` 后,HMS 中能查到 column stats。 + +--- + +## 12. 扩展 E9:Delete / Merge Sink 配置 + +### 12.1 现状 + +- `fe/fe-core/.../planner/IcebergDeleteSink.java`、`IcebergMergeSink.java`、`IcebergTableSink.java` 是 nereids physical sink 的实现。 +- 它们 import `IcebergExternalTable`、`IcebergMetadataOps`,跟 fe-core 强耦合。 +- Iceberg `DELETE FROM` / `MERGE` 走 `IcebergDeleteCommand` / `IcebergMergeCommand` 命令类。 + +### 12.2 设计 + +扩展 `ConnectorWriteType` 枚举: + +```java +public enum ConnectorWriteType { + FILE_WRITE, + JDBC_WRITE, + REMOTE_OLAP_WRITE, + CUSTOM, + FILE_DELETE, // [NEW] Iceberg position-delete or equality-delete files + FILE_MERGE, // [NEW] row-level merge (insert + delete) +} +``` + +在 `ConnectorWriteOps` 新增: + +```java +public interface ConnectorWriteOps { + // ... existing getWriteConfig ... + + /** + * Returns the configuration for a DELETE operation. Connector tells BE how to + * write delete files (position-delete vs equality-delete vs MOR). + */ + default ConnectorWriteConfig getDeleteConfig(ConnectorSession session, + ConnectorTableHandle handle, List filterColumns) { + throw new DorisConnectorException("Delete not supported"); + } + + /** + * Returns the configuration for a MERGE (combined insert+delete) operation. + */ + default ConnectorWriteConfig getMergeConfig(ConnectorSession session, + ConnectorTableHandle handle, + List insertColumns, + List deleteFilterColumns) { + throw new DorisConnectorException("Merge not supported"); + } +} +``` + +### 12.3 fe-core 侧 + +P6.3 中: + +- 删除 `IcebergDeleteSink` / `IcebergMergeSink` / `IcebergTableSink`,统一改为 `PhysicalConnectorTableSink`(已存在)。 +- `PhysicalConnectorTableSink` 根据 `ConnectorWriteType` 构造对应 `TDataSink`: + - `FILE_WRITE` → `THiveTableSink` / `TIcebergTableSink`(或新统一的 `TConnectorFileSink`) + - `FILE_DELETE` → `TIcebergDeleteSink` + - `FILE_MERGE` → `TIcebergMergeSink` +- 这层 thrift 选择仍由 fe-core 做(thrift 类型是 wire 协议);connector 只需要返回 `ConnectorWriteConfig`。 + +### 12.4 影响 + +- Iceberg(DELETE / MERGE / UPDATE)。 +- Hive ACID(DELETE / UPDATE)—— P7.3。 +- Paimon(MERGE-on-read)—— P5。 + +### 12.5 验收标准 + +- Iceberg `DELETE FROM t WHERE id < 100` 在 connector 模块化后输出与旧路径 bit-for-bit 一致的 delete file。 +- `MERGE INTO target USING source ON ... WHEN MATCHED THEN DELETE WHEN NOT MATCHED THEN INSERT` 跑通。 + +--- + +## 13. 扩展 E10:Partition 列举 / `listPartitions` + +### 13.1 现状 + +- `HMSExternalCatalog.listPartitionNames`、`MaxComputeExternalCatalog.listPartitionNames`、`PaimonExternalCatalog.listPartitions`。 +- 调用方:`MetadataGenerator`(TVF 后端)、`PartitionsTableValuedFunction`、`ShowPartitionsCommand`、Nereids 分区裁剪(`HivePartitionPruner`)。 + +### 13.2 设计(**复用现有** `ConnectorPartitionInfo`) + +```java +public interface ConnectorTableOps { + // ... existing ... + + /** + * Lists all partition display names (e.g., "year=2024/month=01"). + * Cheap; should avoid loading partition metadata. + */ + default List listPartitionNames(ConnectorSession session, + ConnectorTableHandle handle) { + return Collections.emptyList(); + } + + /** + * Lists partitions matching the optional filter, with full metadata. + * Expensive; should use partition pruning when possible. + */ + default List listPartitions(ConnectorSession session, + ConnectorTableHandle handle, + Optional filter) { + return Collections.emptyList(); + } + + /** + * Lists distinct partition column value combinations. + * Used by partition_values() TVF and column-distinct-value optimizations. + */ + default List> listPartitionValues(ConnectorSession session, + ConnectorTableHandle handle, + List partitionColumns) { + return Collections.emptyList(); + } +} +``` + +### 13.3 增强 `ConnectorPartitionInfo`(向后兼容追加字段) + +当前已有:`partitionName`、`partitionValues`、`properties`。 + +追加只读字段(不破坏构造器签名 —— 用 builder 模式追加): + +```java +public final class ConnectorPartitionInfo { + // existing fields ... + private final long rowCount; // -1 unknown + private final long sizeBytes; // -1 unknown + private final long lastModifiedMillis; // -1 unknown + + // existing 3-arg constructor delegates to the new 6-arg constructor with -1/-1/-1 + public ConnectorPartitionInfo(String partitionName, Map partitionValues, + Map properties) { + this(partitionName, partitionValues, properties, -1, -1, -1); + } + + public ConnectorPartitionInfo(String partitionName, Map partitionValues, + Map properties, long rowCount, long sizeBytes, long lastModifiedMillis) { + // ... + } + + public long getRowCount() { return rowCount; } + public long getSizeBytes() { return sizeBytes; } + public long getLastModifiedMillis() { return lastModifiedMillis; } +} +``` + +### 13.4 影响 + +- Hive、Iceberg、Paimon、MaxCompute、Hudi(任何 partitioned 外部表)。 +- 调用方收口:`MetadataGenerator`、`PartitionsTableValuedFunction`、`ShowPartitionsCommand` 三处改走 `PluginDrivenExternalCatalog.getConnector().getMetadata(...).listPartitions(...)`。 + +### 13.5 验收标准 + +- `SHOW PARTITIONS FROM cat.db.tbl` 输出 bit-for-bit 等同于旧路径。 +- `partition_values('cat.db.tbl', 'col')` TVF 等价。 +- 1000-partition Hive 表 `listPartitionNames` 性能不退化 5% 以上。 + +--- + +## 14. 实施顺序与里程碑 + +### 14.1 实施顺序 + +10 个扩展点不需要全部一次性进 mainline;可分阶段: + +| 批次 | 扩展点 | 时机 | 阻塞的 P 阶段 | +|---|---|---|---| +| **批 0**(先行) | E3(MetaInvalidator)、E4(Transaction)、E5(MvccSnapshot)| P0 内必须完成 | 这三个是后续连接器实现 ConnectorMetadata 时的 baseline | +| **批 1** | E1(CreateTableRequest)、E10(listPartitions)| P0 末 / P1 初 | 阻塞 P3 hudi、P5 paimon | +| **批 2** | E6(Credentials)、E7(SysTables)、E9(Delete/Merge)| P5 之前 | 阻塞 P5 paimon、P6 iceberg | +| **批 3** | E2(Procedures)| P6 之前 | 阻塞 P6 iceberg actions | +| **批 4** | E8(Column Statistics)| P7 之前 | 阻塞 P7 Hive ANALYZE | + +### 14.2 P0 里程碑(共计约 2 周) + +``` +W0 ─ Day 1-2 本 RFC 评审、调整签名 +W0 ─ Day 3-5 实现批 0(E3/E4/E5)的接口 + javadoc + 默认行为 +W1 ─ Day 1-3 实现批 1(E1/E10)的接口 + fe-core converter 草稿 +W1 ─ Day 4-5 实现 fe-core 侧 ExternalMetaCacheInvalidator、PluginDrivenTransactionManager 通用版 +W1 ─ Day 5 CI grep 守门脚本 tools/check-connector-imports.sh + maven enforcer 接入 +``` + +### 14.3 批 2-4 在各 P 阶段开始时随主任务一起做 + +每个连接器迁移启动前 1-2 天,把该阶段需要的扩展点接口/默认实现写进 fe-connector-api,然后再开始迁移。 + +--- + +## 15. 测试策略 + +### 15.1 单元测试 + +- 每个新增类型都有等价 / 哈希 / 序列化(如适用)测试,放 `fe-connector-api/src/test/java/...//`。 +- 默认方法行为测试:定义一个"什么都不实现"的 `BaseConnectorTest` mock connector,调每个 default 方法验证抛错/返回空一致。 + +### 15.2 fe-core 侧 converter 测试 + +- `CreateTableInfoToConnectorRequestConverter`:覆盖 Hive identity partition、Iceberg transform partition、List partition、Range partition 四种来源。 +- `ExternalMetaCacheInvalidator`:mock `ExternalMetaCacheMgr`,验证每个 invalidate 方法都正确路由到对应 cache 方法。 + +### 15.3 集成回归 + +- ES、JDBC 这两个已迁连接器的 regression-test 子集必须全绿(证明现有 SPI 没被破坏)。 +- 新增一个 `FakeConnectorPlugin` 在 `fe/fe-core/src/test/`,覆盖所有新增 default 行为路径。 + +### 15.4 grep 守门 + +```bash +# tools/check-connector-imports.sh +#!/bin/bash +set -e +FORBIDDEN='org\.apache\.doris\.(catalog|common|datasource|qe|analysis|nereids|planner)' +RESULT=$(grep -rEn "^import ${FORBIDDEN}\." fe/fe-connector/*/src/main/java \ + | grep -v 'org.apache.doris.thrift' \ + | grep -v 'org.apache.doris.connector' \ + | grep -v 'org.apache.doris.extension' \ + | grep -v 'org.apache.doris.filesystem' || true) +if [ -n "$RESULT" ]; then + echo "FORBIDDEN IMPORTS in fe-connector modules:" >&2 + echo "$RESULT" >&2 + exit 1 +fi +``` + +挂到 maven enforcer plugin 的 `pre-compile` 阶段。 + +--- + +## 16. 风险与未决问题 + +### 16.1 风险 + +| ID | 风险 | 缓解 | +|---|---|---| +| Q1 | `ConnectorProcedureSpec.arguments` 用 `Object` 装载值类型不安全 | 限定允许的类型枚举:`String/Long/Double/Boolean/Instant/List/Map`;构造时校验 | +| Q2 | `ConnectorMetaInvalidator` 在异常路径被调用时可能 leak(线程未停)| `Connector.close()` 中要明确停止 listener thread | +| Q3 | `ConnectorTransaction.commit` 在跨多个 BE 分片场景下不是简单调用——需要 fe-core 先收集 commit info | 已在 `ConnectorWriteOps.finishInsert(handle, fragments)` 覆盖;`beginTransaction` 只负责开/关,不负责 commit 数据 | +| Q4 | `ConnectorMvccSnapshot.snapshotId` 是 long,但有的系统(Delta Lake 未来引入)用 string | 暂用 long;如未来需要再加 `String getSnapshotIdAsString()` | +| Q5 | E1 的 `ConnectorPartitionField.transform` 字符串编码不规范 | 在 RFC 附录列举允许的 transform 字符串集合(与 Iceberg 对齐:`identity / year / month / day / hour / bucket[N] / truncate[N]`)| +| Q6 | E9 的 thrift sink 选择仍在 fe-core,可能跟不上 connector 新增 sink 类型 | 在 `ConnectorWriteConfig.properties` 留 `"thrift_sink_type"` 自定义字段 + `CUSTOM` 走 generic sink 兜底 | + +### 16.2 未决问题(✅ 2026-05-24 全部决议) + +| ID | 问题 | 决议 | +|---|---|---| +| U1 | `ConnectorProcedureSpec.listProcedures` 是否在 connector 初始化时一次性返回,还是允许动态变化? | ✅ **一次性**。Connector 生命周期内稳定;如外部系统的可用 procedure 集合变化,必须重新创建 catalog | +| U2 | `ConnectorMetaInvalidator` 是否要 `invalidateColumnStatistics(...)`? | ✅ **暂不要**。column stats 失效一并挂在 `invalidateTable` 上,避免接口表面膨胀;后续如发现频繁单独失效再加 | +| U3 | `ConnectorTransaction.getTransactionId` 是连接器分配还是 fe-core 分配? | ✅ **连接器分配**。连接器自己最清楚事务 ID 与外部系统的对应关系;fe-core 在 `PluginDrivenTransactionManager` 用 `Map` 索引即可 | +| U4 | `getCredentialsForScan` 是否要批量化? | ✅ **是**。签名定为 `Map getCredentialsForScans(session, handle, List)`,由连接器自由决定 STS 调用粒度(共享实例 / 按 prefix 分组 / 1:1),fe-core 一个 scan node 一次调用 | +| U5 | sys-table 命名约定(`$snapshots` vs `\$snapshots` vs `[$snapshots]`)跨方言一致性? | ✅ **统一 `$suffix`**。SPI 层固定该约定;如未来发现冲突(如某 SQL dialect 把 `$` 视为变量前缀),通过 catalog property `sys_table_separator` 提供别名机制,但不在本 RFC 范围 | +| U6 | `ConnectorColumnStatistics.minValue / maxValue` 用 `Object` 装载,类型安全如何保证? | ✅ **javadoc 类型映射表 + 抛 `IllegalArgumentException`**。在 `ConnectorColumnStatistics` javadoc 中列出 `ConnectorType` ↔ Java 装箱类型映射(见 §11.2);连接器读到不匹配类型时直接抛 `IllegalArgumentException`,由 fe-core 转成 `UserException` 返回客户端 | + +--- + +## 17. 验收清单(出 P0 时勾选) + +``` +[ ] fe-connector-api 编译通过,新增类型 / 方法全部就位 +[ ] fe-connector-spi 仅新增 ConnectorMetaInvalidator 接口,无其他改动 +[ ] fe-core 侧 converter(CreateTableInfoToConnectorRequestConverter、ExternalMetaCacheInvalidator、ConnectorMvccSnapshotAdapter)就位 +[ ] PluginDrivenTransactionManager 通用化(不再依赖任何具体连接器) +[ ] JDBC、ES 现有 regression-test 全绿 +[ ] FakeConnectorPlugin 覆盖所有新增 default 行为 +[ ] tools/check-connector-imports.sh 接入 maven enforcer +[x] 本 RFC 关闭未决问题 U1-U6,签名定稿 ← ✅ 2026-05-24 完成 +[ ] plan-doc/00 §3.1 P0 任务全部勾选 +``` + +--- + +## 18. 附录 A:所有新增 / 修改的文件清单 + +``` +新增(fe-connector-api): + org/apache/doris/connector/api/ddl/ConnectorCreateTableRequest.java + org/apache/doris/connector/api/ddl/ConnectorPartitionSpec.java + org/apache/doris/connector/api/ddl/ConnectorPartitionField.java + org/apache/doris/connector/api/ddl/ConnectorPartitionValueDef.java + org/apache/doris/connector/api/ddl/ConnectorBucketSpec.java + org/apache/doris/connector/api/procedure/ConnectorProcedureOps.java + org/apache/doris/connector/api/procedure/ConnectorProcedureSpec.java + org/apache/doris/connector/api/procedure/ConnectorProcedureArgument.java + org/apache/doris/connector/api/mvcc/ConnectorMvccSnapshot.java + org/apache/doris/connector/api/scan/ConnectorCredentials.java + org/apache/doris/connector/api/statistics/ConnectorColumnStatistics.java + +替换(fe-connector-api): + org/apache/doris/connector/api/handle/ConnectorTransaction.java + (原 ConnectorTransactionHandle 保留为父接口;ConnectorTransaction 继承它) + +修改(fe-connector-api,仅新增 default 方法): + ConnectorMetadata.java ← extends ConnectorProcedureOps + ConnectorTableOps.java ← createTable(request) / listPartitions / listPartitionNames / + listPartitionValues / listSysTableSuffixes + ConnectorWriteOps.java ← beginTransaction / getDeleteConfig / getMergeConfig + ConnectorStatisticsOps.java ← getColumnStatistics / setColumnStatistics + ConnectorScanPlanProvider.java ← getCredentialsForScan + ConnectorSession.java ← getCurrentTransaction + ConnectorWriteType.java ← + FILE_DELETE, FILE_MERGE + ConnectorPartitionInfo.java ← + rowCount/sizeBytes/lastModifiedMillis (with backward-compat ctor) + +新增(fe-connector-spi): + org/apache/doris/connector/spi/ConnectorMetaInvalidator.java + +修改(fe-connector-spi): + ConnectorContext.java ← getMetaInvalidator() + +新增(fe-core 桥接): + org/apache/doris/connector/ddl/CreateTableInfoToConnectorRequestConverter.java + org/apache/doris/connector/ExternalMetaCacheInvalidator.java + org/apache/doris/connector/ConnectorMvccSnapshotAdapter.java + +修改(fe-core): + org/apache/doris/connector/DefaultConnectorContext.java ← getMetaInvalidator override + org/apache/doris/connector/ConnectorSessionImpl.java ← currentTransaction field + org/apache/doris/transaction/PluginDrivenTransactionManager.java ← 通用化 +``` + +## 19. 附录 B:Allowed Transform 字符串(E1 用) + +| 字符串 | 含义 | 来源风格 | +|---|---|---| +| `identity` | 原值分区 | Hive / Iceberg | +| `year` | 取年份 | Iceberg | +| `month` | 取年月 | Iceberg | +| `day` | 取年月日 | Iceberg | +| `hour` | 取年月日时 | Iceberg | +| `bucket` | 哈希分桶;`transformArgs = [N]` | Iceberg | +| `truncate` | 截断;`transformArgs = [W]` | Iceberg | +| `list` | 显式列表分区,初始值在 `initialValues` | Doris | +| `range` | 显式范围分区,初始值在 `initialValues` | Doris | + +未列出的字符串视为 `CUSTOM`,由 connector 内部识别。 + +--- + +## 20. 扩展 E11:写/事务 SPI(写-plan-provider + ConnectorTransaction 写回调) + +> 后补节(2026-06-06),置于附录后以避免重排既有节号。完整设计见 [写/事务 SPI RFC](./tasks/designs/connector-write-spi-rfc.md)(§5 API / §8 fe-core 改动 / §12 W1→W7)。决策见 [D-022](./decisions-log.md)(A/B1/C1/D/E);W5 收口位置修正见 [DV-009](./deviations-log.md)。 + +把 fe-core 通用写编排(`Coordinator`/`LoadProcessor`/`FrontendServiceImpl`/`TransactionManager`)完全多态化,消除全部 `instanceof *Transaction` / concrete cast;定义连接器写/事务 SPI(maxcompute P4 / iceberg P6 / hive P7 实现,paimon P5 零 SPI 改动接入)。**保 BE 契约不变**。 + +**SPI 面(default-only,[D-009])**: +- `ConnectorTransaction`(既有,+4 default):`addCommitData(byte[])`(B1)、`supportsWriteBlockAllocation()` / `allocateWriteBlockRange(sid, count)`(C1)、`getUpdateCnt()`。fe-core `Transaction` 加同名 default;`PluginDrivenTransaction`(`PluginDrivenTransactionManager` 产)桥接委派(A)。 +- `ConnectorSession.allocateTransactionId()`(P4-T03 新增 default 抛;fe-core `ConnectorSessionImpl` override 回 `Env.getNextId`):为**无外部 id 的连接器**(如 maxcompute)提供引擎全局 txn id 分配器,连接器经它在 `beginTransaction` 分配,id 即 Doris `txn_id`(与 sink / `GlobalExternalTransactionInfoMgr` 一致)。细化 [D-015]/U3「连接器分配」,见 [D-024]。 +- **P4-T06 翻闸新增(2,default-preserving,零 jdbc/es/trino 影响;[D-026] 预授、登记 2026-06-07)**:`ConnectorSession.setCurrentTransaction(ConnectorTransaction)`(default 抛;fe-core `ConnectorSessionImpl` 加 volatile 字段 + override `getCurrentTransaction`)——把 connectorTx 绑入 **sink 的** session 供 T04 `planWrite` 读 `getCurrentTransaction()`(解 dormant→live 的 G1);`ConnectorWriteOps.usesConnectorTransaction()`(default false;`MaxComputeConnectorMetadata` override true)——executor 据此在调任何 throwing-default 写法前分流 txn-model(MC)vs JDBC insert-handle([D-026] D-1)。 +- `ConnectorWritePlanProvider.planWrite(session, handle) → ConnectorSinkPlan(TDataSink)`(E,仿 `ConnectorScanPlanProvider`);`Connector.getWritePlanProvider()` default null。`ConnectorWriteHandle` = {tableHandle, columns, overwrite, writeContext};`ConnectorSinkPlan` 包 opaque `TDataSink`。 +- DML 覆盖 INSERT / DELETE / MERGE(D);procedures defer(E2 / P6)。 + +**三处 seam**:B1 commit 载荷 opaque bytes(`TBinaryProtocol` 序列化,单点 `CommitDataSerializer`,连接器反序列化);C1 maxcompute block-id 窄 callback;E 写-plan-provider 产 opaque `TDataSink`。 + +**W-phase 落地**(behind gate、零行为变更、golden 等价):W1+W2(SPI 面 + `Transaction` 泛化)`be945476ba7`;W3+W6(解耦 3 热路径 + golden 测)`9ad2bbe40ec`;W4(`PluginDrivenTransaction` 委派)`759cc0874c8`;W5(`planWrite` layer 进 `visitPhysicalConnectorTableSink`,见 [DV-009])`9ebe5e27fa4`;W7(本节 + [D-021]/[D-022])。逐连接器 adopter(搬类 + impl 写 SPI + 翻闸)= P4 / P6 / P7。 + +--- + +## 21. 扩展 E13:存储 URI 归一化(`ConnectorContext.normalizeStorageUri`) + +> 后补节(2026-06-11,P5-fix-FIX-URI-NORMALIZE)。findings B-7DF(native 数据文件)+ B-7DV(deletion vector)—— 见 [task-list #1](./task-list-P5-rereview2-fixes.md) / [设计](./tasks/designs/P5-fix-URI-NORMALIZE-design.md)。 + +**问题**:paimon 连接器把 native ORC/Parquet **数据文件路径**和 **deletion-vector 路径**裸传 BE,未做 scheme 归一化。paimon SDK 发的是 warehouse 原生 scheme(`oss://`/`cos://`/`obs://`/`s3a://`,或 OSS `bucket.endpoint` authority 形);BE 文件工厂按 scheme 分派、S3 reader 只认 `s3://`。后果:S3-兼容(非 AWS)warehouse 上 native 数据文件读直接挂(B-7DF),或 DV 静默丢→被删行重现(B-7DV,merge-on-read 错行,更危险)。纯 `s3://`/`hdfs://` 不受影响;JNI 路不受影响(序列化 paimon `Table` 自带 `FileIO`)。 + +**根因**:legacy `PaimonScanNode` 两路径都经 **2-arg 归一化** `LocationPath.of(path, storagePropertiesMap)` → `StorageProperties.validateAndNormalizeUri()`(`PaimonScanNode.java:443` 数据文件 / `:296-297` DV);翻闸丢了。连接器禁 import fe-core `LocationPath`/`StorageProperties`,故须经 SPI 缝。两路径机制不同:数据文件经 `PluginDrivenSplit.buildPath` 的**单-arg 非归一化** `LocationPath.of(pathStr)` → `FileQueryScanNode:568` 写 thrift;DV 由连接器在 `PaimonScanRange.populateRangeParams` **直接烤进 thrift**,fe-core 永不经手 → bridge-only 修不到 DV。故唯一统一缝 = 连接器侧 SPI 调用。 + +**SPI 面(default no-op,零它连接器影响)**: +- `ConnectorContext.normalizeStorageUri(String rawUri) → String`(`fe-connector-spi`):default 返回原值(恒等),故 es/jdbc/maxcompute/trino 及任何已规范 URI 不受影响。 +- fe-core `DefaultConnectorContext` override:`LocationPath.of(rawUri, storagePropertiesSupplier.get()).toStorageLocation().toString()`——复用引擎/legacy/iceberg 同一 `LocationPath` 归一化,单一真相源、无漂移。**fail-loud**(`StoragePropertiesException` 传播):路径归一化不了宁可显式炸,不可静默送裸路(DV 错行)。null/blank 短路返回原值。 +- `DefaultConnectorContext` 加 `Supplier> storagePropertiesSupplier`(4-arg ctor;既有 2/3-arg ctor 委派空 map supplier——它们不被 paimon 用、该法仅 paimon 调)。`PluginDrivenExternalCatalog:150` 接线 `() -> catalogProperty.getStoragePropertiesMap()`(lazy,scan 时调,catalog 已初始化)。 + +**连接器侧**:`PaimonScanPlanProvider.buildNativeRange`(B7 抽出的可测 seam)对**数据文件路径 + DV 路径**各调 `normalizeUri()`(= `context != null ? context.normalizeStorageUri(raw) : raw`,null-guard 同 `vendStorageCredentials`,offline 单测保留裸路)。JNI 路 + `getScanNodeProperties` 不动。 + +**作用域/偏差**:归一化用 catalog **静态** `getStoragePropertiesMap()`,非 legacy 的 vended-overlay 版(`VendedCredentialsFactory`)——scheme 归一化与 vended 凭据正交(vended 改 `AWS_*` 键非 scheme),仅 *纯-vended-无静态存储配* REST catalog 的边角会缺 entry→fail-loud;该边角归凭据缝(#2 `FIX-STATIC-CREDS-BE` / `FIX-REST-VENDED`),见 [DV-025](./deviations-log.md)。 + +**测**:fe-core `DefaultConnectorContextNormalizeUriTest`(真 OSS map,oss://→s3://、s3:// 恒等、null/blank、空 map fail-loud);连接器 `PaimonScanPlanProviderTest` 3 测(`buildNativeRange` 数据文件+DV 双归一化、无-DV 仅数据、无-context 裸路)。live-e2e(OSS warehouse + DV)CI-gated。 + +## 22. 扩展 E14:静态存储凭据归一化(`ConnectorContext.getBackendStorageProperties`) + +> 后补节(2026-06-11,P5-fix-FIX-STATIC-CREDS-BE)。finding B-9(BLOCKER,3/3 confirmed)—— 见 [task-list #2](./task-list-P5-rereview2-fixes.md) / [设计](./tasks/designs/P5-fix-STATIC-CREDS-BE-design.md) / [D-048](./decisions-log.md)。凭据三道缝之第三道(static→BE-scan),review §9.3 两轮均漏。 + +**问题**:paimon 连接器把**静态 catalog 级存储凭据/配置裸传 BE**。`PaimonScanPlanProvider.getScanNodeProperties:372-381` 遍历裸 catalog `properties`,对 `s3.`/`cos.`/`oss.`/`obs.`/`hadoop.`/`fs.`/`dfs.`/`hive.` 前缀的键发 `location.=`;fe-core bridge `PluginDrivenScanNode.getLocationProperties:307-317` 只**剥** `location.` 前缀、从不归一化。BE native ORC/Parquet(FILE_S3)reader 只解析 canonical `AWS_ACCESS_KEY`/`AWS_SECRET_KEY`/`AWS_ENDPOINT`/`AWS_REGION`/`AWS_TOKEN`(`s3_util.cpp:146-150`)→ 私有 object-store 桶 native 读拿不到凭据 → **403/AccessDenied**。公有桶 + JNI 路不受影响(序列化 paimon `Table` 自带 `FileIO`)。裸 `AWS_*`/`access_key`(无 `s3.` 前缀)被前缀过滤整个丢弃。区别于已修两缝:FIX-STORAGE-CREDS 修 *catalog FileIO* 缝、FIX-REST-VENDED 修 *vended(REST) scan→BE* 缝。 + +**根因**:legacy `PaimonScanNode.getLocationProperties:650-652` **仅**返回 `backendStorageProperties`(`:176` = `CredentialUtils.getBackendPropertiesFromStorageMap(storagePropertiesMap)`,catalog 已解析的 `StorageProperties` map)。`getBackendPropertiesFromStorageMap` 逐 `StorageProperties` 调 `getBackendConfigProperties()` → canonical 键(`AbstractS3CompatibleProperties:106-120` 发 `AWS_*`;`HdfsProperties:163-200` 发已解析 `hadoop.`/`dfs.` + legacy 默认)。翻闸把这一归一化调用换成裸前缀拷贝循环。连接器禁 import fe-core `StorageProperties`/`CredentialUtils`(`tools/check-connector-imports.sh`)→ 须经 SPI 缝。 + +**SPI 面(default 空,零它连接器影响)**: +- `ConnectorContext.getBackendStorageProperties() → Map`(`fe-connector-spi`):default 返回 `Collections.emptyMap()`,故 es/jdbc/maxcompute/trino 及无凭据(local-FS)warehouse 不受影响。 +- fe-core `DefaultConnectorContext` override:`CredentialUtils.getBackendPropertiesFromStorageMap(storagePropertiesSupplier.get())`——复用 #1(FIX-URI-NORMALIZE)已接线的 `storagePropertiesSupplier`(= `catalogProperty.getStoragePropertiesMap()`)+ 已 import 的 `CredentialUtils`。**无 ctor 改**。map 在 catalog 创建时已校验故不抛;空 map(非-plugin ctor / local-FS)→ 空结果(无 overlay),parity——异于 `normalizeStorageUri` 须对坏路径 fail-loud。 + +**连接器侧**:`PaimonScanPlanProvider.getScanNodeProperties` **整段**替换裸前缀拷贝循环为 `context.getBackendStorageProperties()` overlay(`context != null` 闸,同 vended overlay;offline 单测无 context → 不发存储键,绝不发坏的裸别名)。vended overlay(`vendStorageCredentials`)仍紧随其后 → vended overlay static、collision 胜(legacy 优先序)。无连接器新 import(`Map`/`LinkedHashMap` 已 import)→ import-gate 净。 + +**作用域(D-048 用户签字 = full legacy-parity,非窄 object-store-only)**:`getBackendPropertiesFromStorageMap` 即 legacy `getLocationProperties()` 精确值。HDFS catalog 下 full 替换**严格 ≥** 旧裸拷(保留用户 `hadoop.`/`dfs.`/`fs.`/`juicefs.` override + 补 legacy 默认 → 顺修 review §211 MINOR);丢的 `hive.*` 键 legacy 本不发 scan location → 丢之即**恢复** parity。 + +**ANONYMOUS-leak 边角(调查→非问题)**:连接器分两步归一化(static 经本缝、vended 经 `vendStorageCredentials`),异于 legacy 的 merge-then-normalize-once。故带静态 object-store endpoint 但**无静态 keys** 的 REST catalog,static overlay 可能发 `AWS_CREDENTIALS_PROVIDER_TYPE=ANONYMOUS`(`AbstractS3CompatibleProperties:124-128` blank-key 支)而 vended overlay(有 keys→无 provider-type)不清它。**BE 证伪无回归**:`s3_util.cpp` 两 provider(`_v1:383-389`/`_v2:448-455`)均**先**查显式 ak/sk 返回 `SimpleAWSCredentialsProvider`,keys 在则永不达 `Anonymous` 支 → vended keys 恒胜。primary B-9 路(静态 catalog **有** keys)provider-type 为 null 不发。 + +**测**:fe-core `DefaultConnectorContextBackendStoragePropsTest`(真 OSS map → `AWS_*` 存在 + 裸 `oss.access_key` 不存在;无 supplier ctor → 空);连接器 `PaimonScanPlanProviderTest` 3 测(静态裸别名→canonical AWS_*、裸别名不达 BE;vended overlay static collision 胜;无-context 不发存储键)。red-check 反转产线码 2/3 向红。模块 217/0/0(1 CI-gated skip)。live-e2e(私有 S3/OSS 静态凭据 native 读)CI-gated。 + +--- + +## 23. FIX-SCHEMA-EVOLUTION(B-1a):**无新 SPI**(Design C,记录在案) + +> task-list #3 原标「SPI?=yes」(预期穿 `ConnectorColumn`/`ConnectorType` field-id channel + 新 history-schema SPI);**用户签 Design C([D-049](./decisions-log.md))后修正为 `no`** —— 本 fix **零新 SPI surface**,纯连接器侧。记录于此以闭合 RFC「SPI 改动登记」约定(结论:无改动)。 + +**为何无需新 SPI**:BE native field-id 匹配(`be/src/format/table/table_schema_change_helper.cpp:312-430`)每个 `TField` **只**消费 `id` / `name` / `type.type`(且 `type.type` 仅作 nested-vs-scalar 判别——`==MAP/ARRAY/STRUCT` 否则 scalar),**从不读 Doris `Type`/precision/scale,也不读 tuple/slot descriptor**。故连接器可只用 paimon `DataField.{id,name}` + 一个 primitive tag **直建** `TSchema`——`org.apache.doris.thrift.*`(含 `…thrift.schema.external.*`)对连接器 **import-legal**(import-gate 仅禁 `catalog|common|datasource|qe|analysis|nereids|planner`)。 + +**复用既有缝**:`current_schema_id`+`history_schema_info` 经**既有** `ConnectorScanPlanProvider.populateScanLevelParams(TFileScanRangeParams, props)` hook 落 params(连接器在 `getScanNodeProperties` 从 live 表建好、base64 thrift carrier prop 传递、`populateScanLevelParams` 解码套用)。这正是 [DV-006](./deviations-log.md) 为 hudi 同类 schema_id/history 缺口预判的缝(「经现有 SPI hook `populateScanLevelParams`…**无需 fe-core 改动**」)—— paimon 是该模式首个落地者。per-split `TPaimonFileDesc.schema_id` 早已由 `PaimonScanRange` 发出(不改)。 + +**M-10 deferred**:`Column.uniqueId=-1` 不影响 B-1a(history 直接从 paimon field-id 建,不经 Doris 列)→ 不穿 `ConnectorColumn.fieldId`/`ConnectorType` 嵌套 id。详 [DV-026](./deviations-log.md)。 + +**测**:连接器 `PaimonScanPlanProviderTest` +5 测(field-id/name carriage、嵌套 ARRAY/MAP/STRUCT 形 + struct child id、scalar tag、rename round-trip、**-1 entry 顶层 lowercase 而嵌套保 paimon-case**、非-FileStoreTable 跳过)。模块 222/0/0。真值闸=`test_paimon_full_schema_change.groovy`(CI-gated)。 + +## 24. FIX-JDBC-DRIVER-URL(B-8a + B-8b):**无新 SPI**(复用既有 validation hooks,记录在案) + +> task-list #4 原标「SPI?=maybe」;**修正为 `no`** —— 本 fix **零新 SPI surface**,纯连接器侧,复用两个**既有**未改的 hook。记录于此以闭合 RFC「SPI 改动登记」约定(结论:无改动)。[D-050](./decisions-log.md)。 + +**复用既有缝(无改动)**:① **B-8b 安全**——`Connector.preCreateValidation(ConnectorValidationContext)`(既有 default no-op、CREATE CATALOG 时由 `PluginDrivenExternalCatalog.checkWhenCreating` 调)+ `ConnectorValidationContext.validateAndResolveDriverPath(driverUrl)`(既有、`DefaultConnectorValidationContext`→`JdbcResource.getFullDriverUrl` 做 format/whitelist/secure-path);paimon override `preCreateValidation` 对 jdbc flavor 调之,**与 JDBC 参考连接器 `JdbcDorisConnector` 同模式**。② **B-8a 功能**——driver_url 的 BE 传输经**既有** `paimon.options_json`(`getScanNodeProperties` 建、`populateScanLevelParams`→`setPaimonOptions`→BE `params`);本 fix 只改其中 `jdbc.driver_url` 的**值**(resolved 而非裸)+ 认 `paimon.jdbc.*` 别名,传输管道不动。 + +**已知 SPI gap(不在本 fix close)**:scan-time driver-path 校验**无** `ConnectorContext` hook(连接器 scan-time 拿不到 `ConnectorValidationContext`)→ 校验仅 CREATE-time(FE-restart/ALTER 不复校),是 pre-existing fe-core 缝、全 plugin 连接器共有。用户定接受(CREATE-time parity),跨连接器 follow-up 须新 `ConnectorContext` 校验 hook + fe-core ALTER 路接 `preCreateValidation`。详 [DV-028](./deviations-log.md)。 + +**测**:连接器 `PaimonScanPlanProviderTest` +5(resolve 裸名、认 paimon.jdbc.* 别名、双别名优先序+override、保 scheme-bearing、非-jdbc 空)+ 新 `PaimonConnectorPreCreateValidationTest` +5(jdbc/别名 调校验、非-jdbc/无 driver_url 不调、reject 传播)。模块 232/0/0、fail-before 5/9 向红。真值闸=`test_paimon_jdbc_catalog`(CI-gated)。 + +## 25. 扩展 E15:COUNT(\*) 下推信号 / `planScan(...,boolean countPushdown)` overload + +> 后补节(2026-06-12,P5-fix#8 FIX-COUNT-PUSHDOWN)。finding M-2(round-2 MAJOR/round-1 MINOR,perf-parity)—— 见 [task-list #8](./task-list-P5-rereview2-fixes.md) / [设计](./tasks/designs/P5-fix-COUNT-PUSHDOWN-design.md) / [D-054](./decisions-log.md) / [DV-032](./deviations-log.md)。**E14 之后首个新 connector-SPI(planScan 扩展链续 limit/requiredPartitions)。** + +**问题**:翻闸后 plugin-driven paimon `COUNT(*)` 结果正确但慢——BE 已在 count 模式(`PhysicalPlanTranslator:873` 在 `PluginDrivenScanNode` 设 `pushDownAggNoGroupingOp=COUNT`,`FileScanNode.toThrift:90` 发出)且 per-range emit 缝**已建全**(`PaimonScanRange.Builder.rowCount`→`paimon.row_count`→`populateRangeParams.setTableLevelRowCount`,与 legacy `PaimonScanNode:303-308` byte-一致),但 COUNT **信号** `getPushDownAggNoGroupingOp()==COUNT` 只在 fe-core 节点、不在任何 `planScan`/`ConnectorSession`/`ConnectorContext`/handle → 连接器从不算 merged count、每 split 发 `table_level_row_count=-1` → BE 物化全 post-merge 行去 count(`file_scanner.cpp:1298-1326`)。merged count `DataSplit.mergedRowCount()` 是 paimon-SDK-only 须连接器算,故信号**必须**过 SPI 边界(否决经 `ConnectorSession` 穿——agg-op 是 per-query planner 输出非 SET-var、会成静默无类型通道,[D-054])。 + +**SPI 面(default 委托,零它连接器影响)**: +- `ConnectorScanPlanProvider.planScan(session, handle, columns, filter, limit, requiredPartitions, boolean countPushdown) → List`(`fe-connector-api`):新 **default** 方法,委托回 6 参 `planScan`(镜像既有 5 参 limit / 6 参 requiredPartitions 扩展链)→ 不 override 的连接器(es/jdbc/hive/iceberg/maxcompute/trino/hudi)忽略 flag、行为不变。 +- `countPushdown` 语义:engine 判定查询为 no-grouping `COUNT(*)`(`getPushDownAggNoGroupingOp()==TPushAggOp.COUNT`)时为 true。选 **boolean** 而非 `TPushAggOp`:BE 文件格式 count 只需 COUNT-vs-not;`MIX`/`COUNT_ON_INDEX` 不在文件格式 count 范围,`TPushAggOp` 会把 thrift 枚举拉进 SPI 签名、过度泛化。 + +**fe-core 侧**:`PluginDrivenScanNode.getSplits` 读 `getPushDownAggNoGroupingOp()==TPushAggOp.COUNT` 传入新 overload。**无 post-loop 数学**(collapse 在连接器内做,见 [D-054]/[DV-032])。 + +**连接器侧(paimon-only)**:`PaimonScanPlanProvider` 抽 `planScanInternal(...,countPushdown)`(4 参委托 false、新 7 参委托 flag),加 count 短路第一臂 + 纯静态 `isCountPushdownSplit(boolean,DataSplit)` + `buildCountRange`,**collapse-to-one** 发一个 JNI count range 携 `mergedRowCount` 之和。emit 用既有 `paimon.row_count` 缝,**无新 thrift / 无 BE 改**。 + +**作用域**:paimon-only(default no-op overload 利好将来 hive/iceberg/hudi full-adopter,各自 override 即可)。`ConnectorProvider.apiVersion()` 保持 `1`(仅新增 default,[D-009])。 + +**测**:连接器 `PaimonScanPlanProviderTest` +2(纯静态 `isCountPushdownSplit` 真 split=true/2、disabled=false;end-to-end `planScan(countPushdown=true)` 真 local PK 表 collapse-to-one 携 total=2、`false`→无 `paimon.row_count`)。模块 252/0/0(1 CI-gated skip)、fail-before 恰 2 新测红(neuter helper→false)。真值闸=live-e2e BE CountReader 选择/EXPLAIN(既有 legacy paimon count regression 覆盖 BE 契约)。 diff --git a/plan-doc/06-iceberg-write-path-rfc.md b/plan-doc/06-iceberg-write-path-rfc.md new file mode 100644 index 00000000000000..4260ff6a99144e --- /dev/null +++ b/plan-doc/06-iceberg-write-path-rfc.md @@ -0,0 +1,207 @@ +# RFC:Iceberg 写路径(P6.3) + +> 设计文档(design-doc-first)。日期 2026-06-23。**须先过 PMC 评审,再实现**(master plan §3.7 / `P6-iceberg-migration.md:80,130`)。 +> 本 RFC 是叠加在 **已批准的核心写/事务 SPI RFC**(`tasks/designs/connector-write-spi-rfc.md`,D-022/D-024/D-026)之上的 **iceberg 连接器 adopter + 框架统一**设计,**非**从零重造写 SPI。 +> 事实底座:[`research/p6.3-iceberg-write-recon.md`](./research/p6.3-iceberg-write-recon.md)(写 SPI 面 + jdbc/maxcompute/legacy-iceberg 写者深挖 + 12-fork 碎片化地图 + Trino 对照)。原始 workflow 产出 `.audit-scratch/p6.3-research/{findings.md, unification.md, trino-dml-analysis.md}`。 +> **用户裁定(2026-06-23,本 RFC 前)**:写框架**全面统一**(Q2=a);nereids 行级-DML plan 合成层走**务实迁移 Route B / option (i)**(保 EXPLAIN parity,iceberg plan 合成暂留 fe-core 有界 deviation);O5 冲突检测走 **O5-2**;Trino 式通用化 (iii) 定为**北极星**、列后续专门 RFC。 + +--- + +## 1. Goals + +1. **iceberg 完整写能力 parity**:INSERT / INSERT OVERWRITE(动态/静态/空表清空)/ DELETE / UPDATE / MERGE + 事务提交 + commit-时冲突检测/快照隔离 + V3 deletion-vector,迁入 `fe-connector-iceberg`,行为与 legacy 等价。 +2. **写路径框架在 jdbc / maxcompute / iceberg 三连接器间完全一致**(用户硬约束):**无为某个连接器实现的框架接口类**。统一到单一 `ConnectorTransaction` 模型 + 单一 plan-provider sink 路径 + capability 派发(无 `instanceof`)。 +3. **删 legacy 写半的反向耦合靶**:planner `Iceberg{Table,Delete,Merge}Sink` → 统一 `PhysicalConnectorTableSink`;nereids `Iceberg{Update,Delete,Merge}Command` → 通用 `RowLevelDmlCommand` 壳 + capability 派发。 +4. **保 BE 契约不变 / 零 BE 改**:`T{Iceberg}TableSink/DeleteSink/MergeSink` 与 `TIcebergCommitData`(14 字段)一字不动;连接器经 opaque `planWrite` 发同款 thrift。 +5. **复用既有面、扩展不重造**:`ConnectorTransaction`/`ConnectorWritePlanProvider`/`PluginDrivenInsertExecutor`/`PluginDrivenTransactionManager`;新增方法 **default-only**(D-009)。 +6. **明确北极星**:把 Trino 式「通用引擎 DML 合成 + 声明式连接器 SPI」(iii) 定为目标架构并给出演进触发条件,使本期 (i) 的 fe-resident 残留**可被后续 RFC 彻底消除**。 + +## 2. Non-goals + +- iceberg **PROCEDURES**(`rewrite_data_files`/`expire_snapshots`/`rollback_to_snapshot` 等 10 个 action)→ `ConnectorProcedureOps`(E2) / **P6.4**。本 RFC 只保证不预排除(legacy `RewriteDataFileExecutor` 写半的 `RewriteFiles`/`updateRewriteFiles` 不在本 RFC 解)。 +- hive 行级 ACID delete/update/merge:越界(P7)。 +- **Trino 式 (iii) 通用化基座**(通用 nereids merge/delete/update 合成 + 声明式 row-id/paradigm SPI):本 RFC 定为北极星 + 后续 RFC,**不在 P6.3 实现**(§10)。 +- **BE 侧改动**:零。 +- **`SPI_READY_TYPES` 翻闸**:只在 P6.6(全有或全无);本 RFC 落地后 iceberg 仍**不在** `SPI_READY_TYPES`。 + +## 3. Constraints / context(RFC 须遵守) + +| # | 约束 | 来源 | +|---|---|---| +| C1 | **import-gate**:连接器模块禁 import `org.apache.doris.{catalog,common,datasource,qe,analysis,nereids,planner}`。实测 0 连接器文件 import nereids。 | D-009 / DV-011;实测 | +| C2 | **零 BE 改**:BE→FE `TIcebergCommitData`、`T{Iceberg}{Table,Delete,Merge}Sink` thrift 不动。 | connector-write-spi-rfc §2 / 用户 | +| C3 | **default-only**:所有新增 SPI 方法带 default(throws/no-op/empty),不破 jdbc/es/trino/paimon/maxcompute。 | D-009 | +| C4 | **commit 载荷 opaque bytes**:`TIcebergCommitData` 经 `TBinaryProtocol`→`addCommitData(byte[])`,连接器自反序列化。零 BE 改、fail-loud。 | D-022 B1 | +| C5 | **无 block-id seam for iceberg**:`allocateWriteBlockRange` 取 default(false)。 | D-022 C1 | +| C6 | **opaque-sink 非 config-bag**:连接器经 `planWrite()` 自建 `TDataSink`,layered on `visitPhysicalConnectorTableSink`。撤回 §12.3/E9 的 config-bag delete/merge 描述(DV-009 已定 opaque-sink 优先)。 | D-022 E / DV-009 | +| C7 | **overwrite/静态分区经 `ConnectorWriteHandle.writeContext`**(无通用 overwrite marker)。 | D-024/D-025/D-026 | +| C8 | **写分布需求经 `ConnectorCapability`**(非连接器特定 planner 码)。 | FIX-WRITE-DISTRIBUTION 先例 | +| C9 | **依赖 P6.2 scan/MVCC**(写需读快照/base-snapshot)。已就绪。 | master plan | +| C10 | **EXPLAIN/执行不回归**(acceptance gate)。Route B 保 plan 形 parity;统一 sink 后的 EXPLAIN diff 须登记并经 PMC 接受(§9)。 | P6.3 gate | + +> **Rule 7 撤回声明**:旧设计文本 §12/E9 描述的 `getDeleteConfig`/`getMergeConfig` + `ConnectorWriteType.FILE_DELETE`/`FILE_MERGE` **在树里不存在**(recon firsthand 证伪),本 RFC **正式撤回**该 config-bag delete/merge 计划;iceberg DELETE/MERGE 与 INSERT 同走 `beginTransaction`→`planWrite`→`addCommitData`→`commit`,由 `ConnectorWriteHandle` 上的 `writeOperation` 区分。 + +## 4. 决策总览(用户/PMC 裁定) + +| 轴 | 裁定 | 备选(拒) | +|---|---|---| +| **写框架统一深度**(Q2) | **(a) 全面统一**:单 `ConnectorTransaction` 模型;删 `usesConnectorTransaction()` fork + `ConnectorInsertHandle`/`beginInsert·finishInsert·abortInsert` + dead 的 `beginDelete/beginMerge` handle 面;jdbc 变退化 no-op txn;改 jdbc/maxcompute 配字节 parity 测。 | (b) 保守保 fork(与"无连接器特定接口"冲突,拒) | +| **nereids 行级-DML plan 合成层**(Q1) | **Route B / option (i)**:通用 `RowLevelDmlCommand` 壳 + capability 派发(无 `instanceof`),iceberg 的 `$row_id`/branch-label/投影代数 + nereids→iceberg expr 转换暂留 fe-core(**有界 deviation**),保现有 plan/EXPLAIN parity。 | (ii) 新 nereids-spi 模块放松 import-gate(为单一消费者放松核心不变量,违 Rule 2,拒);(iii) 通用化重写(北极星,工程大/破 EXPLAIN parity,本期不做) | +| **O5 冲突检测 seam**(Q3) | **O5-2**:`ConnectorTransaction.applyWriteConstraint(ConnectorPredicate)` default-no-op;fe-core 通用抽 target-only 合取→中性 `ConnectorPredicate`;连接器复用 P6.2-T02 `IcebergPredicateConverter` 转 iceberg expr、暂存到 commit。 | O5-1(`writeContext` 字符串载、生命周期错位);O5-3(暴露 plan 视图、撞 import-gate,拒) | +| **北极星** | Trino 式 (iii) 通用引擎 DML 合成 + 声明式连接器 SPI,定后续专门 RFC(演进触发 = hive/paimon 第二行级-DML 消费者落地)。 | — | + +**Trino 实证**(`trino-dml-analysis.md`):Trino 连接器主代码 0 优化器 import,DML plan 合成全在引擎核心,连接器只供 `getMergeRowIdColumnHandle`(row-id handle) + `getRowChangeParadigm`(paradigm) + `ConnectorMergeSink`;冲突检测谓词走读下推 `Constraint` 同一 seam(验证 O5-2)。⇒ (iii) 是已落地的正确终态;本期 (i) 是其务实前身。 + +## 5. 架构设计 + +### 5.0 全景 + +``` +┌──────────────────────── fe-core 通用写编排(统一后,无 instanceof)────────────────────────┐ +│ InsertIntoTableCommand / RowLevelDmlCommand(通用壳) │ +│ → 按 ConnectorCapability 派发(supportsDelete/supportsMerge),非 instanceof IcebergExternal* │ +│ PluginDrivenInsertExecutor: 单一 ConnectorTransaction 模型(删 usesConnectorTransaction fork) │ +│ beginTransaction → planWrite(opaque TDataSink) → addCommitData(byte[]) → commit/rollback │ +│ Coordinator/LoadProcessor: txn.addCommitData(byte[]) (B1, 已存在) │ +│ PhysicalPlanTranslator: visitPhysicalConnectorTableSink (E, 删 visitPhysicalIceberg*Sink) │ +│ [有界 deviation] iceberg 行级-DML plan 合成(连接器-键控但 fe-resident,§5.3) │ +└───────────────┬─────────────────────────────────────────────────────────┬────────────────────┘ + 持有 fe-core Transaction(多态) 经 ConnectorWritePlanProvider 取 TDataSink + │ │ + ┌────────────┴───────────────┐ wraps & delegates ┌────────────────────┴──────────────────┐ + │ PluginDrivenTransaction │ ───────────────────▶ │ fe-connector-iceberg(plugin, 隔离) │ + │ implements fe-core Transaction │ IcebergConnectorTransaction │ + └────────────────────────────┘ │ ConnectorWritePlanProvider(planWrite) │ + │ applyWriteConstraint(O5-2) │ + └─────────────────────────────────────────┘ +``` + +### 5.1 框架统一(Q2=a)—— 单 `ConnectorTransaction` 模型 + +**删除(消除连接器特定 framework 接口)**: +- `ConnectorWriteOps.usesConnectorTransaction()`(F1,路由开关)。 +- `ConnectorWriteOps.{beginInsert, finishInsert, abortInsert}` + `ConnectorInsertHandle`(insert-handle 模型,jdbc 专形)。 +- dead 的 `ConnectorWriteOps.{beginDelete, finishDelete, abortDelete, beginMerge, finishMerge, abortMerge}` + `ConnectorDeleteHandle`/`ConnectorMergeHandle`(从未接线、对事务式文件写错形)。 +- `ConnectorWriteType.{JDBC_WRITE, REMOTE_OLAP_WRITE}`(F4,连接器命名值);保 `FILE_WRITE`/`CUSTOM`(或整 enum 退化为 profile label,§6 待定项)。 + +**统一为**: +- **`beginTransaction(session) → ConnectorTransaction` 变 mandatory**(默认返回退化 no-op txn)。所有写(INSERT/OVERWRITE/DELETE/UPDATE/MERGE)经 `beginTransaction` → `planWrite` → `addCommitData(byte[])`(逐 fragment) → `commit()`。 +- **写操作种类**由 `ConnectorWriteHandle.writeOperation`(新增枚举字段 INSERT/OVERWRITE/DELETE/UPDATE/MERGE) 携带,单一 `planWrite` 据它选 sink 方言。 +- **jdbc** = 退化 adopter:`beginTransaction` 返回 `JdbcNoOpTransaction`(commit/rollback no-op,`getUpdateCnt` 读 BE 上报行数);jdbc 的 thrift 装配从 fe-core `bindJdbcWriteSink` **移入 jdbc 连接器 `planWrite`**(消 F2 fe-core 拥有连接器 thrift 的泄漏)。 +- **maxcompute** = 已是 txn 模型,几乎不动(保 block-id seam F5 = 已正确隔离的本质,default-false,iceberg 忽略)。 +- **`PluginDrivenInsertExecutor`** 删 `beforeExec`/`doBeforeCommit` 双臂(F7/F8)→ 单路:`beginTransaction`→exec→`addCommitData`(report 路)→`finishWrite`→`txn.commit()`/`txn.getUpdateCnt()`。删 `transactionType()` 硬编 enum(F3)→ SPI 提供 profile label。 + +> **✅ OQ-1 裁定(2026-06-23)= 本期移入**:jdbc thrift 装配(`bindJdbcWriteSink`)移入 jdbc 连接器 `planWrite`,**F2 全消**、不留 fallback。须 `PROP_JDBC_*`/`connection_pool_*` 键**字节 parity 测**(T02)。⇒ config-bag 路径整条死(连带 OQ-2,见 §6)。 + +### 5.2 iceberg 事务 adopter —— `IcebergConnectorTransaction` + +实现 `ConnectorTransaction`,镜像 legacy `IcebergTransaction`(981) 语义(recon §3 复现清单),连接器内自包含(仅 import iceberg SDK + `connector.api`): + +- **持单 SDK `org.apache.iceberg.Transaction` / 表 / 语句**;`begin*`(经 P6.2 `IcebergCatalogOps` seam loadTable + auth 包裹) `table.newTransaction()`;捕获 `baseSnapshotId`/`startingSnapshotId` 供 commit 校验;delete/merge guard format-version ≥ 2;insert 解析+校验 branch(须 branch 非 tag)。 +- **`addCommitData(byte[])`**:`TDeserializer(TBinaryProtocol)` 反序列化 `TIcebergCommitData`,`synchronized` 累积(C4)。消费全 14 字段 + `TIcebergColumnStats`(recon §3.6)。 +- **op 选择**(recon §3.5):`writeOperation` + overwrite-mode → AppendFiles / ReplacePartitions / OverwriteFiles(空表清空) / OverwriteFiles.overwriteByRowFilter(静态) / RowDelta(delete 仅 deletes;merge rows+deletes)。`IcebergWriterHelper` 等价物(BE 人类可读分区串→`PartitionData`、`TIcebergColumnStats`→`Metrics`、DV→PUFFIN+position-deletes、equality-delete 拒绝)连接器内移植。 +- **`commit()`** = `transaction.commitTransaction()`(无 FE 重试,靠 SDK 乐观提交;冲突 SDK 抛→executor rollback)。`rollback()` = 丢弃未提交 manifest(no-op 不 commit)。 +- **commit-时校验套件**(recon §3.7):`validateFromSnapshot(baseSnapshotId)`;`applyWriteConstraint` 注入的优化器 filter(O5-2,§5.4)与 commit-时 identity-分区 filter AND 合并 → `rowDelta.conflictDetectionFilter`;serializable→`validateNoConflictingDataFiles`;`validateDeletedFiles`/`validateNoConflictingDeleteFiles`/`validateDataFilesExist`。隔离级读表属性 `delete_isolation_level`(默认 serializable)。 +- **V3 DV "rewrite previous delete files"**:`removeDeletes(...)` 旧 file-scoped delete 文件,由 scan-node 派生的 `rewrittenDeleteFilesByReferencedDataFile` 喂(P6.2-T04 delete 信息已在连接器 scan 侧;写半的关联映射本 RFC 接线)。 +- **`getUpdateCnt()`**:affectedRows-或-rowCount、data/delete 拆分、dataRows 优先(recon §3.9)。 +- **txn-id 绑定**:iceberg 自带 id(U3 连接器分配);双注册表(per-manager + `GlobalExternalTransactionInfoMgr`)保持(report 路按 id 找 txn)。`PluginDrivenTransaction` 桥接到 fe-core `Transaction`(已有)。 + +### 5.3 行级 DML(nereids 层,Route B / option i)—— 有界 deviation + +**通用化(消反向 instanceof)**: +- 新 fe-core 通用 `RowLevelDmlCommand` 壳,吸收三命令 ~50% 通用脚手架(recon §4:run/explain、copy-on-write 检查、`icebergRowIdTargetTableId` save/restore、`executeWithExternalTableBatchModeDisabled`、planner-drive loop、`getPhysicalSink`/`childIsEmptyRelation`、conflict-filter plumbing)。 +- `UpdateCommand`/`DeleteFromCommand`/`MergeIntoCommand` 的路由从 `instanceof IcebergExternalTable` → **capability 查询**(`metadata.supportsDelete()`/`supportsMerge()`,已存在)。任何声明该能力的连接器派发到通用 `RowLevelDmlCommand`。 + +**有界 deviation(暂留 fe-core,连接器-键控)**:iceberg 的 ~50% 不可约 plan 合成(`$row_id` 注入 = `IcebergNereidsUtils.IcebergRowIdInjector`;operation-number/branch-label 投影代数 = `IcebergMergeCommand` 等价;nereids→iceberg expr 转换 = `IcebergNereidsUtils` cluster B)**因根本性需 nereids 类型、连接器禁 import**,保留在 fe-core,由 `RowLevelDmlCommand` 经**连接器-键控变换注册表**(非 `instanceof`)调用。`ConnectContext.icebergRowIdTargetTableId` thread-local + `IcebergExternalTable.needInternalHiddenColumns` scan-schema hook 同属此 deviation(写驱动的 scan-schema 变异,同 import-gate 墙)。 + +> **登记为 DV-04x(本 RFC 新)**:iceberg DML plan 合成 fe-resident。**理由**:import-gate 是既有架构不变量;为单一消费者放松它(option ii)违 Rule 2。**消除路径**:北极星 (iii) 通用化重写(§10),届时此 deviation 关闭。**约束**:deviation 仅限 plan-合成叶子;框架(txn/commit/sink/dispatch)零 iceberg 特定码。 + +### 5.4 O5 冲突检测(O5-2 seam) + +- **新 SPI(default-no-op)**:`ConnectorTransaction.applyWriteConstraint(ConnectorPredicate targetOnlyFilter)`。 +- **fe-core(通用)**:`RowLevelDmlCommand` 在 analyzed plan 上抽 target-only 合取(slot 的 origin-table == 目标表,排 `$row_id`/metadata 列——通用 slot-origin 过滤,非 iceberg 特定),转中性 `ConnectorPredicate`(复用 scan 下推已有的 `ConnectorExpression` 表示),经 `transaction.applyWriteConstraint(pred)` 交连接器。 +- **连接器**:`IcebergConnectorTransaction.applyWriteConstraint` 用 **P6.2-T02 已造的 `IcebergPredicateConverter`**(`ConnectorExpression`→iceberg `Expression`)转换、暂存;commit 时与 identity-分区 filter 合并应用(§5.2)。 +- jdbc/maxcompute:default no-op 忽略。 +- **与 Trino 一致**:Trino 冲突检测谓词走读下推 `Constraint` 同一 seam;O5-2 是其 Doris 对应(中性谓词到连接器、连接器转 SDK expr)。 + +### 5.5 Sink 统一(删 3 iceberg sink) + +- 删 planner `IcebergTableSink`/`IcebergDeleteSink`/`IcebergMergeSink` + translator `visitPhysicalIceberg{Table,Delete,Merge}Sink`(F9 FE 侧)。 +- iceberg 经 `ConnectorWritePlanProvider.planWrite(session, ConnectorWriteHandle)` 据 `writeOperation` 自建 `TIcebergTableSink`/`TIcebergDeleteSink`/`TIcebergMergeSink`(**同款 thrift、C2 零 BE 改**),layered on 既有 `visitPhysicalConnectorTableSink`(DV-009 路径)。 +- iceberg-特定 thrift 字段(schema-json/sort/partition-spec/row-lineage/`rewritableDeleteFileSets`/`setMaterializedColumnName`)在连接器 `planWrite` 内构建。vended-creds 经 P6.2-T09 既有接缝。 +- 写分布/sort 需求经 `ConnectorCapability`(C8,FIX-WRITE-DISTRIBUTION 先例),非连接器特定 planner 码。 + +## 6. SPI 变更清单 + +| 类别 | 变更 | 影响 | +|---|---|---| +| **新增(default-no-op)** | `ConnectorTransaction.applyWriteConstraint(ConnectorPredicate)`(O5-2) | jdbc/maxcompute/es/trino/paimon 零影响 | +| **新增** | `ConnectorWriteHandle.writeOperation`(INSERT/OVERWRITE/DELETE/UPDATE/MERGE 枚举)+ `ConnectorTransaction.profileLabel()`(default,替 F3) | 默认 INSERT,向后兼容 | +| **删除** | `ConnectorWriteOps.usesConnectorTransaction()`(F1) | **改 maxcompute**(去 override);fe-core executor 去 fork | +| **删除** | `ConnectorWriteOps.{beginInsert,finishInsert,abortInsert}` + `ConnectorInsertHandle`(insert-handle 模型) | **改 jdbc**(迁到 no-op txn 模型 + planWrite) | +| **删除** | `ConnectorWriteOps.{beginDelete,finishDelete,abortDelete,beginMerge,finishMerge,abortMerge}` + `ConnectorDeleteHandle`/`ConnectorMergeHandle`(dead 面) | 无(dead,未接线) | +| **删除(OQ-2)** | config-bag 三件套:`ConnectorWriteType` enum + `ConnectorWriteConfig` 类 + `ConnectorWriteOps.getWriteConfig` + `PluginDrivenTableSink` config-bag 分支(F2/F4,实测仅 jdbc 用,OQ-1 移入后死) | jdbc thrift 经 `planWrite` 自建;profile 标签从 `writeOperation`/`profileLabel` | +| **fe-core 新增** | 通用 `RowLevelDmlCommand` 壳 + 连接器-键控 plan-变换注册表(capability 派发) | 替 3 iceberg 命令路由 instanceof | +| **fe-core 删除** | planner `Iceberg{Table,Delete,Merge}Sink` + translator `visitPhysicalIceberg*Sink` | iceberg 走 `visitPhysicalConnectorTableSink` | + +**待定项裁定(2026-06-23 用户签字)**: +- **✅ OQ-1 = 本期移入**:jdbc thrift 装配移入 jdbc 连接器 `planWrite`,F2 全消、不留 fallback(须字节 parity 测,T02)。 +- **✅ OQ-2 = 整组删除 config-bag 三件套**:`ConnectorWriteType` enum + `ConnectorWriteConfig` 类 + `ConnectorWriteOps.getWriteConfig` 方法 + `PluginDrivenTableSink` 的 config-bag 分支。**实测这一整组仅 jdbc config-bag 路在用**(`PluginDrivenTableSink:179` `writeType==JDBC_WRITE`→`TJdbcTableSink`;`PluginDrivenInsertExecutor:221`;`PhysicalPlanTranslator:677`;maxcompute/iceberg 不碰),OQ-1=移入后整条死,无通用消费者残留 → 直接删,**不留作 label hint**(profile/EXPLAIN 写类型标签从 `ConnectorWriteHandle.writeOperation`/`profileLabel()` 取)。`FILE_DELETE`/`FILE_MERGE` 问题随枚举删除自动 moot。 +- **✅ OQ-3 = 接受为非回归**:统一 sink 后 EXPLAIN 文本 diff(`PhysicalConnectorTableSink` vs `IcebergTableSink` 显示,plan-形不变仅 sink 标签)接受为非回归,登记 deviations-log(T08)。 + +## 7. 数据流(端到端,统一后) + +``` +INSERT/OVERWRITE: RowLevelDmlCommand?No → InsertIntoTableCommand + → executor.beginTransaction()=txnMgr.begin()→IcebergConnectorTransaction(table.newTransaction()) + → finalizeSink/planWrite(writeOp=INSERT/OVERWRITE, writeContext={overwrite,staticPartition})→TIcebergTableSink + → BE 写 data 文件 → report 路 addCommitData(TIcebergCommitData) 累积 + → onComplete: getUpdateCnt() + finishInsert(updateManifestAfterInsert: Append/Replace/Overwrite) → commit()=commitTransaction() + +DELETE/UPDATE/MERGE: RowLevelDmlCommand(capability supportsDelete/Merge) + → [fe-resident deviation] iceberg plan 合成: $row_id 注入 + op-number/branch-label 投影 + → applyWriteConstraint(target-only ConnectorPredicate) ← O5-2(plan 时抽,连接器转 iceberg expr 暂存) + → beginTransaction → planWrite(writeOp=DELETE/MERGE)→TIcebergDeleteSink/TIcebergMergeSink + → BE 写 position-delete/DV(+data for merge) → report 路 addCommitData 累积 + → onComplete: finishDelete/Merge(updateManifestAfterDelete/Merge: RowDelta + applyWriteConstraint filter + + validateFromSnapshot + serializable validateNoConflictingDataFiles + V3 removeDeletes) → commit() +``` + +## 8. 反向 instanceof 清理(与 P6.7 关系) + +写层 ~49 处反向 `instanceof IcebergExternal*`(recon §4 全量)本 RFC **部分清**:路由 6 处 → capability 派发;planner sink cast + translator cast → 删 sink 类后消。**保留**(属 §5.3 deviation):fe-resident iceberg plan 合成内的 cast(`IcebergNereidsUtils`、plan-变换实现)→ 北极星 (iii) 时随 deviation 关闭。其余(catalog/statistics/glue 等读侧)属 P6.7。 + +## 9. 测试 / parity / 回滚 + +- **UT**:`IcebergConnectorTransaction`(op 选择矩阵 / commit 校验套件 / V3 DV / getUpdateCnt / addCommitData 14 字段往返);`applyWriteConstraint`→`IcebergPredicateConverter` 复用;通用 `RowLevelDmlCommand` capability 派发;jdbc no-op txn parity。镜像 P6.2 风格(真 InMemoryCatalog、无 Mockito、fail-loud)。 +- **regression(P6.6 docker,翻闸后)**:INSERT/OVERWRITE(动态/静态/空表)/DELETE/UPDATE/MERGE 结果 parity;并发冲突→serializable 中止;V3 DV merge;事务回滚。 +- **parity gate(C10)**:Route B 保 plan 形 parity;EXPLAIN sink-标签 diff 登记 OQ-3 + deviations-log。jdbc/maxcompute 写**字节 parity 测**(框架统一不得改其 thrift 输出,除 OQ-1 jdbc 移位时显式 parity)。 +- **回滚**:iceberg 不在 `SPI_READY_TYPES`(翻闸只 P6.6),本 RFC 落地全程 legacy 写路径仍在、零行为变更直到 P6.6;框架统一改动(删 fork、jdbc no-op txn)behind gate 对 LIVE jdbc/maxcompute 须 golden 等价。 + +## 10. 北极星:Trino 式 (iii) 通用化(后续 RFC) + +**目标架构**(Trino 实证,`trino-dml-analysis.md`):连接器供 3 个声明式 SPI(row-id `ConnectorColumnHandle` + `RowChangeParadigm` 枚举 + 通用 merge sink),引擎核心通用做全部 DML plan 合成($row_id 声明式注入 scan、op-number/branch-label/CASE 投影、MERGE join、运行时 delete/insert 展开)。连接器 **0 优化器类型**,§5.3 的 fe-resident deviation 彻底关闭。 + +**演进触发条件**:hive(P7) / paimon 第二个行级-DML 消费者落地(Rule 2「多消费者」满足)。**成本**:通用 nereids merge-合成 + 声明式 row-id 注入机制(Doris 今无、Trino 有);**EXPLAIN plan 形会变**(须届时放宽 plan-层 parity gate);BE 大概率不改(连接器仍经 opaque `planWrite` 发 iceberg sink thrift)。**本 RFC 的 (i) 设计为此预留**:框架已统一、命令已通用壳化、O5 已 seam 化 → 届时只需把 fe-resident plan 合成替换为通用 paradigm-driven 合成。 + +## 11. TODO(实现顺序,过 PMC 后) + +> 串行、每步 RED→GREEN + 对抗 parity 复核(镜像 P6.2 节奏)。框架统一改动 behind gate、零行为变更。 + +1. **T01 框架统一·SPI 收口**:删 `usesConnectorTransaction`/`ConnectorInsertHandle`/insert-handle 方法/dead delete-merge handle 面;`beginTransaction` mandatory + 退化 no-op 默认;`ConnectorWriteHandle.writeOperation`;`ConnectorTransaction.profileLabel`。改 maxcompute(去 override)。UT + checkstyle。 +2. **T02 jdbc 退化 adopter**:jdbc → no-op txn 模型 + jdbc thrift 装配移入连接器 `planWrite`(OQ-1)+ 删 config-bag 三件套 `ConnectorWriteType`/`ConnectorWriteConfig`/`getWriteConfig`/`PluginDrivenTableSink` config-bag 分支(OQ-2);jdbc 写**字节 parity 测**。 +3. **T03 `IcebergConnectorTransaction` 骨架 + addCommitData**:SDK txn 持有 + 14 字段反序列化 + getUpdateCnt + txn-id 双注册表桥接。 +4. **T04 op 选择 + `IcebergWriterHelper` 等价**:INSERT/OVERWRITE(4 子case)+ DELETE + MERGE 的 SDK op + PartitionData/Metrics/DV 转换。 +5. **T05 commit 校验套件 + O5-2**:`applyWriteConstraint` SPI(default-no-op) + fe-core target-only 抽取 + 连接器 `IcebergPredicateConverter` 复用 + commit 校验套件 + V3 DV removeDeletes。 +6. **T06 sink 统一**:连接器 `planWrite` 自建 3 thrift sink 方言;删 planner `Iceberg{Table,Delete,Merge}Sink` + translator 分支;走 `visitPhysicalConnectorTableSink`。EXPLAIN diff 登记。 +7. **T07 通用 `RowLevelDmlCommand` 壳 + capability 派发**:抽 ~50% 通用脚手架;路由 instanceof → capability;iceberg plan 合成经连接器-键控注册表调用(DV-04x)。 +8. **T08 parity-UT 审计 + deviation 注册**:补 gap-fill;DV-04x(fe-resident plan 合成)+ EXPLAIN-diff + jdbc-移位(若 OQ-1)登记 deviations-log。 +9. **T09 收口**:HANDOFF + PROGRESS + connectors 同步;gate 核对(iceberg 仍不在 `SPI_READY_TYPES`)。 + +## 12. 引用 + +- 事实底座:`research/p6.3-iceberg-write-recon.md`;workflow 产出 `.audit-scratch/p6.3-research/{findings.md, unification.md, trino-dml-analysis.md}`。 +- 既有 SPI:`tasks/designs/connector-write-spi-rfc.md`;`01-spi-extensions-rfc.md` §7/§12/§20;`decisions-log.md` D-021..D-026;`deviations-log.md` DV-009/011/012/013/018。 +- legacy iceberg 写:`datasource/iceberg/{IcebergTransaction,IcebergMetadataOps,IcebergConflictDetectionFilterUtils,IcebergNereidsUtils}.java`、`helper/*`、`transaction/IcebergTransactionManager.java`、`nereids/.../commands/Iceberg{Update,Delete,Merge}Command.java`、`planner/Iceberg{Table,Delete,Merge}Sink.java`。 +- Trino 北极星:`/mnt/disk1/yy/git/trino` core-spi `ConnectorMetadata.java:898-942`、`RowChangeParadigm.java`、`ConnectorMergeSink.java`;trino-main `QueryPlanner.java:740-990`、`StatementAnalyzer.java:2299-2322`、`MergeProcessorOperator.java:58-71`;plugin-trino-iceberg `IcebergMetadata.java:2214-2374`。 +- 迁移计划:`tasks/P6-iceberg-migration.md` P6.3(:49,:68,:91)、O5(:106)。 diff --git a/plan-doc/AGENT-PLAYBOOK.md b/plan-doc/AGENT-PLAYBOOK.md new file mode 100644 index 00000000000000..e47c84131aabb9 --- /dev/null +++ b/plan-doc/AGENT-PLAYBOOK.md @@ -0,0 +1,280 @@ +# Agent 协作规范 — Context 管理与最佳实践 + +> 本项目是大型多阶段重构,预计跨数月、上百个 PR、可能跨数十个 LLM agent session。 +> 本规范旨在让"无论哪一次 session、由哪个 agent 接手,都能高质量推进",**核心是 context 管理**。 + +--- + +## 一、为什么需要规范 + +LLM agent 协作的三大失效模式: + +1. **Context 中毒**:单 session 累积太多无关信息,模型注意力分散、决策质量下降、出现幻觉。 +2. **认知断层**:换 session 后失去前情,重复探索 / 推翻已有决策 / 重新发明轮子。 +3. **维护脱节**:代码改了文档不改,下次进 session 时基于过时文档做错误判断。 + +本规范用三类工具应对: +- **Context 预算与监控**(§2)—— 防失效模式 1 +- **Subagent 与 Handoff**(§3、§4)—— 防失效模式 1、2 +- **强制纪律**(§5)—— 防失效模式 3 + +--- + +## 二、Context 预算 + +### 2.1 单 session 预算 + +| Context 使用率 | 状态 | 推荐行为 | +|---|---|---| +| **0–40%** | 🟢 健康 | 正常工作,可以做任何任务 | +| **40–60%** | 🟢 健康偏高 | 开始倾向于把"独立的探索 / 大文件读"转给 subagent | +| **60–75%** | 🟡 警觉 | **不再读 ≥500 行的整文件**;只做精确 grep / offset+limit read;准备 handoff 草稿 | +| **75–85%** | 🟠 高危 | **停止接新任务**;完成手头 1 个原子工作;写 HANDOFF.md;通知用户切 session | +| **>85%** | 🔴 危险 | **只做记录性工作**(更新 PROGRESS / HANDOFF);不再做任何决策 / 代码生成 | + +> Claude Code 中可通过 `/context` 查看当前用量;如不可见,按"已发起的工具调用数 + 已读文件总行数"粗略估算。 + +### 2.2 用户对 session 的隐式预期 + +如果用户在一次 session 中要求"重构 X 模块 + 写文档 + 提交 PR",agent 应: + +- 评估 context 占用:单凭 RFC + 现有代码探索就可能吃掉 30-40% +- **主动报告**:在开始执行前告知 "此任务预计占用约 40% context,是否需要先写 handoff 占位以便分两个 session 完成?" + +### 2.3 节省 context 的硬性技巧 + +1. **永远不要 `Read` 整个 >1000 行的文件** —— 用 `grep` 定位行号,再用 `offset + limit` 精读。 +2. **永远不要重复 grep 同一个 pattern** —— 在 session 心智里记住结果。 +3. **避免 `cat` / `find -type f -name '*.java'` 全量列举** —— 用更精准的 grep / find 加过滤。 +4. **避免 `git log -p`** —— 用 `git log --oneline -20`,需要 diff 再单独 `git show `。 +5. **大文件总结优先用 subagent**(见 §3):让 subagent 读 5000 行返回 200 字总结。 + +--- + +## 三、Subagent 使用规范 + +### 3.1 何时**必须**用 subagent + +- **跨 5+ 文件的代码搜索 / 调研**(如"找出 fe-core 中所有 instanceof HMSExternalCatalog 的地方") +- **读取 >1000 行的单文件后只取关键信息**(如 IcebergMetadataOps.java 1247 行,只需了解 createTable 路径) +- **独立的、不影响主线决策的小重构**(如"批量改 import 路径",给 subagent prompt + 文件列表,背景执行) +- **独立的代码评审**(如"review 这次 PR 的安全性",需要重读大量上下文) + +### 3.2 何时**不要**用 subagent + +- 主线决策环节 —— subagent 给的建议你最终还是要消化,不如自己做 +- 1-2 次 grep / read 就能解决的简单查找 —— 启动 subagent 的固定开销不值得 +- 需要持续交互的探索(边读边问"那 X 呢")—— subagent 一次性输出,互动不便 +- 涉及"修改后立即验证"的小改动 —— 主 session 闭环更快 + +### 3.3 写 subagent prompt 的硬性规则 + +``` +1. 自包含:不能假设 subagent 知道主线对话内容。明确说"working directory: /...", + "background: 这是 XX 项目的 YY 阶段,目标是 ZZ"。 +2. 输出格式约束:明确"返回 markdown 表格 / 总字数 ≤ 500 / 只列文件路径不带代码"。 +3. 范围约束:明确"只看 fe-core 目录"、"忽略 test 目录"、"不读 README"。 +4. 决策权约束:明确"只调研,不做任何修改"或"可以修改 X 但不能动 Y"。 +5. 一次性:避免让 subagent 内部继续延伸调研——主 session 来决定下一步。 +``` + +### 3.4 Subagent 类型选择(Claude Code 内) + +| 任务类型 | 推荐 subagent | 备注 | +|---|---|---| +| 大范围代码搜索 | `Explore` | 只读、快、context 隔离 | +| 多步独立工作 | `general-purpose` | 可以执行 grep / read / edit | +| 实现计划设计 | `Plan` | 只产出方案不写代码 | +| 都不适合 | `claude`(默认)| 兜底 | + +### 3.5 Background 模式 + +长耗时任务(如 `mvn test`、跨模块 build)使用 `run_in_background: true`,主 session 不被阻塞。完成时会自动通知,**不要 sleep 轮询**。 + +--- + +## 四、Handoff(跨 session 接管) + +### 4.1 何时**必须**写 handoff + +- Context 使用率 ≥ 70%(§2.1) +- 当前 P 阶段结束(如 P0 → P1 切换) +- 工作天然分段(如"下周再继续") +- 出现长时间阻塞,等其他人 review / 等 CI 跑(≥4 小时) +- 用户主动说"今天到此为止" + +### 4.2 何时**不需要**写 handoff + +- 同一 session 内自然继续 +- Context < 50% 且任务还很短 + +### 4.3 Handoff 文档结构 + +见 [`HANDOFF.md`](./HANDOFF.md) 模板。核心字段: + +1. **本 session 完成了什么**(具体到 task ID、PR、commit) +2. **当前正在做的事是否完整**(如果中途停的,写明卡在哪个文件、哪一行) +3. **关键认知 / 临时发现**(如"刚发现 X 类的 Y 方法有意外副作用"——这种东西不写下来下次会重复踩坑) +4. **下一个 session 第一件事做什么**(精确到 task ID + 第一行代码 / 命令) +5. **当前 session 没解决但需要标记的问题**(不是 TODO 而是"开放问题") + +### 4.4 Handoff 文件存放 + +- 单个滚动文件 `plan-doc/HANDOFF.md` +- 每次 session 结束时**覆盖式更新** +- 历史 handoff 通过 `git log plan-doc/HANDOFF.md` 查看 +- **不要**建 `handoffs/2026-05-24.md` 这种归档目录 —— git history 已经胜任 + +### 4.5 接管新 session 的开场流程 + +新 session 开始第一件事(**所有 agent 必须遵守**): + +``` +1. Read plan-doc/PROGRESS.md ← 全局状态 +2. Read plan-doc/HANDOFF.md ← 上次留言 +3. 如果 HANDOFF 标记当前 task: + Read plan-doc/tasks/Pn-*.md 中对应 task 块 +4. 用一句话向用户复述:"上次 session 做完了 X,下一步是 Y,对吗?" +5. 等用户确认后开始 +``` + +**不要**在没读 HANDOFF 的情况下问"我们上次做到哪了" —— 这是失败模式。 + +--- + +## 五、强制纪律 + +### 5.1 文档同步纪律 + +每次完成 task: +1. 更新 `tasks/Pn-*.md` 对应 task 状态 +2. 更新 `PROGRESS.md` §三和§四 +3. 更新 `connectors/.md`(如果该 task 属于某个连接器) +4. 如果产生新决策 → `decisions-log.md` 新增 D-NNN +5. 如果发现偏差 → `deviations-log.md` 新增 DV-NNN + +**5 步缺一不可**。否则下次 session 看到的状态就是错的。 + +### 5.2 RFC 修改纪律 + +任何修改 `01-spi-extensions-rfc.md` 的行为: +1. 先在 `deviations-log.md` 或 `decisions-log.md` 留痕(区别见 [README §3.1](./README.md)) +2. 在 RFC 该节加 `(D-NNN / DV-NNN 修订 YYYY-MM-DD)`脚注 +3. 不要 silent edit + +### 5.3 Task ID 纪律 + +- Task ID 一旦分配**永不复用** +- 删除的 task 标 `[deleted YYYY-MM-DD]` 保留占位行 +- 重命名 task 不改 ID + +### 5.4 提交信息纪律 + +PR title 第一行必须 `[Pn-Tnn] `,例如: +``` +[P0-T03] Implement ConnectorMetaInvalidator interface +``` + +--- + +## 六、Anti-Patterns(绝对禁止) + +| 反模式 | 为什么禁止 | 正确做法 | +|---|---|---| +| 一个 session 又读 RFC、又改 SPI、又写实现、又跑测试 | Context 爆炸;决策质量下降 | 拆 session:阅读/设计 → handoff → 实现 → handoff → 验证 | +| 跨 session 凭记忆继续工作 | 模型完全没记忆,认知断层 | 强制读 HANDOFF | +| Subagent 也用来做"小事" | 启动开销大于收益 | <2 次 grep 直接主线做 | +| 把 RFC 当 PROGRESS 用 | RFC 是设计稳定文档,频繁更新会污染 git history | PROGRESS / tasks / handoff 才是状态文件 | +| Handoff 写得像周报 | 周报对用户有用,对下一个 agent 无用 | 写"下一步第一行命令是什么"才有用 | +| 多个 session 并发改同一 task | 重复劳动 / 文档冲突 | 同一时刻一个 task 只一个 owner | +| Decision / Deviation 直接写到 RFC 里不进 log | 失去追溯性 | 先 log 再改 RFC | + +--- + +## 七、各类 session 的典型节奏(参考) + +### 7.1 "设计 + 评审" session(高密度阅读) + +``` +开场:Read PROGRESS + HANDOFF (3% context) +主体:Read 3-5 个核心文件 + RFC 某节 (25% context) + ↓ + 与用户来回讨论 5-10 轮 (+30% context) + ↓ + Edit / Write 文档(RFC 修改、decision 记录) (+10% context) +收尾:更新 PROGRESS + 写 HANDOFF (+5% context) + ───────── + ~73% 健康终止 +``` + +### 7.2 "代码实现" session(中等密度) + +``` +开场:Read PROGRESS + HANDOFF + 对应 task (5%) +主体:Read 现有相关代码(精读,offset+limit) (15%) + ↓ + Write / Edit 实现 (+15%) + ↓ + Run tests(如可),修复错误 (+15%) +收尾:更新 task 状态 + PROGRESS + git commit + HANDOFF (+10%) + ───────── + ~60% +``` + +### 7.3 "调研 / 探索" session(高度依赖 subagent) + +``` +开场:Read PROGRESS + HANDOFF (3%) +主体:dispatch subagent 做 5-10 路并行调研 (+10% 主线 +50% subagent) + ↓ + 综合 subagent 结果做决策 (+10%) + ↓ + Write 调研结论文档(如新 RFC) (+10%) +收尾:更新 PROGRESS + decisions-log + HANDOFF (+5%) + ───────── + ~38% +``` + +--- + +## 八、Context "重启"策略 + +如果 context 已经超 75% 但任务还没做完: + +1. **优先保存状态**:立即写 HANDOFF.md,详细到下一行代码该写什么 +2. **完成原子收尾**:当前正在 Edit 的文件**改完 + 保存**,不要留半截 +3. **更新 PROGRESS**:把已完成的 task 标 ✅ +4. **提醒用户切 session**:"Context 已 ~78%,建议开新 session 继续。HANDOFF 已写好,新 session 第一句话发 'continue from handoff' 即可。" +5. **不要硬撑**:每多用 1% context 都在降低质量 + +--- + +## 九、Multi-agent 协作的边界 + +本项目原则上一个 task 由一个 agent 推进,但允许: + +- **并行 subagent**:调研 / 测试 / build 等独立任务并行 +- **审计 subagent**:让一个 subagent 审核主线工作(如"以挑刺 reviewer 视角看这次改动") +- **接力**:handoff 后由完全不同的 agent / 人接手 + +**不允许**: +- 同时两个 agent 改同一 task +- Subagent 跨阶段(subagent 只做本 session 的工作,不要让 subagent 自己写 HANDOFF) + +--- + +## 十、面向"未来 agent"的元规则 + +如果你(未来 agent)发现本规范本身需要修改: + +1. 不要直接改本文件 —— 先在 `deviations-log.md` 写 `DV-NNN: AGENT-PLAYBOOK 规则 X 在场景 Y 不适用` +2. 与用户讨论后再修改本文件 +3. 修改时在文末 §十一 加版本号 + 变更说明 + +--- + +## 十一、版本 + +| 版本 | 日期 | 变更 | +|---|---|---| +| v1 | 2026-05-24 | 初版(与 README、PROGRESS、HANDOFF 同时建立) | diff --git a/plan-doc/FIX-FECONF-STORAGE-PARITY-design.md b/plan-doc/FIX-FECONF-STORAGE-PARITY-design.md new file mode 100644 index 00000000000000..c9ba27db78dbb0 --- /dev/null +++ b/plan-doc/FIX-FECONF-STORAGE-PARITY-design.md @@ -0,0 +1,215 @@ +# FIX-FECONF-STORAGE-PARITY — design + +> Cluster: P8-1 / P8-2 / P8-3 / P8-4 / P9-2 / P9-3 (round-3 re-review). User-signed **FULL legacy parity**. +> Pure **connector-only** change (`PaimonCatalogFactory.java` + its test). No fe-core / SPI / BE. +> Design red-team (`wf_a6385c61-669`, 5 skeptics + completeness critic) ran **before** this doc; its findings +> are folded in below (each marked **[RT]**). S3-endpoint-from-region inclusion is **user-approved** (2026-06-12). + +## Problem + +The connector cannot import fe-core, so `PaimonCatalogFactory` rebuilds the FE-side Hadoop +`Configuration` / `HiveConf` from the raw property map with **literal** key logic (same pattern as the +existing `applyCanonicalS3Config` / `applyCanonicalOssConfig`). That reconstruction is **incomplete** vs +the legacy `*Properties` classes, so a paimon catalog on several storage backends fails FE-side +catalog/metadata access (the live `FileSystemCatalog` / `HiveCatalog` / `JdbcCatalog` cannot resolve the +storage FileIO). Consumers of the gap: `buildHadoopConfiguration` (filesystem, jdbc), `buildHmsHiveConf` +(hms), `buildDlfHiveConf` (dlf) — all route through `applyStorageConfig`. + +Concrete gaps: +- **P8-1 / P8-3 (OSS)**: a region-only OSS catalog (no explicit `oss.endpoint`) gets no `fs.oss.endpoint`; + and an OSS catalog gets none of the `fs.s3.impl` / `fs.s3a.*` base keys legacy emits (so `s3://`-over-OSS + back-compat breaks). +- **P8-2 / P9-3 (S3 path-style / MinIO)**: `applyCanonicalS3Config` never emits `fs.s3a.path.style.access` + nor the `fs.s3a.connection.maximum/request.timeout/timeout` tuning keys. +- **P9-2 (COS / OBS)**: there is **no** COS or OBS handling at all — a `cosn://` / `obs://` paimon catalog + gets no `fs.cosn.*` / `fs.obs.*` keys. +- **P8-4 (HMS username)**: `buildHmsHiveConf` only copies the literal `hadoop.username`; a user who sets the + `hive.metastore.username` alias has it land as an inert verbatim `hive.*` key, never reaching `hadoop.username`. +- **(user-approved, S3 endpoint-from-region)**: structurally identical to P8-1 — a region-only AWS-S3 + catalog gets no `fs.s3a.endpoint`. Legacy `S3Properties.getEndpointFromRegion` derives + `https://s3..amazonaws.com`. + +## Root Cause + +`applyStorageConfig` runs only two canonical blocks (`applyCanonicalS3Config`, `applyCanonicalOssConfig`), +each emitting a **subset** of what the corresponding legacy `*Properties.initializeHadoopStorageConfig` +(plus its `super.appendS3HdfsProperties` base) emits, and there is **no COS/OBS block**. Legacy details +(parity references; do not modify these files): +- `AbstractS3CompatibleProperties.appendS3HdfsProperties` (the shared S3A base, inherited by S3/OSS/COS/OBS + via `super`): `fs.s3.impl`, `fs.s3a.impl`, `fs.s3{,a}.impl.disable.cache=true`, `fs.s3a.endpoint`, + `fs.s3a.endpoint.region`, creds (gated on `isNotBlank(accessKey)`), **`fs.s3a.connection.maximum`, + `fs.s3a.connection.request.timeout`, `fs.s3a.connection.timeout`, `fs.s3a.path.style.access`**. +- **[RT — critic, missed by all skeptics]** the 4 tuning **defaults are per-subclass**: + `S3Properties` = **50 / 3000 / 1000** (`S3Properties.java:129,136,143`; aliases incl. `AWS_MAX_CONNECTIONS` + etc.), while `OSS/COS/OBS` = **100 / 10000 / 10000**. A single shared default would silently mis-tune S3. +- `OSSProperties` adds Jindo `fs.oss.*`; derives endpoint `oss-[-internal].aliyuncs.com` from region + when blank (`initNormalizeAndCheckProps:277-279` → `getOssEndpoint`, `dlfAccessPublic` default false → + `-internal`). +- `COSProperties.initializeHadoopStorageConfig:177-181`: `fs.cos.impl`=S3AFileSystem, `fs.cosn.impl`=S3AFileSystem, + and **unconditionally** `fs.cosn.bucket.region`, `fs.cosn.userinfo.secretId`, `fs.cosn.userinfo.secretKey`. +- `OBSProperties.initializeHadoopStorageConfig:194-205`: native `fs.obs.impl`/`fs.AbstractFileSystem.obs.impl` + when `org.apache.hadoop.fs.obs.OBSFileSystem` is classpath-available, else `fs.obs.impl`=S3AFileSystem; plus + **unconditional** `fs.obs.access.key`, `fs.obs.secret.key`, `fs.obs.endpoint`. +- **[RT — skeptic 2]** legacy selects exactly ONE backend via `guessIsMe` keyed on **endpoint/uri PATTERN** + (COS=`myqcloud.com`, OBS=`myhuaweicloud.com`), NOT on scheme-prefixed keys. So a `cosn://` catalog + configured with only `s3.endpoint=cos..myqcloud.com` (no `cos.*` key) is a real shape a + scheme-key-only gate would miss. +- `HMSBaseProperties`: `@ConnectorProperty(names={"hive.metastore.username","hadoop.username"})` → + `hiveConf.set(HADOOP_USER_NAME /*="hadoop.username"*/, hmsUserName)` (`:83-87,201-202`). + +## Design + +All changes live in `applyStorageConfig` and its callees. Introduce ONE shared helper and TWO new blocks, +extend the existing two blocks, and fix 4d. The raw `fs./dfs./hadoop.` passthrough stays **last** +(last-write-wins; existing `buildHadoopConfigurationExplicitFsS3aKeyOverridesCanonical` parity). + +### Shared `applyS3aBaseConfig(setter, ak, sk, token, endpoint, region, maxConn, reqTimeout, connTimeout, pathStyle)` +Faithful port of `appendS3HdfsProperties`. **[RT — skeptic 4]** takes the creds AND the tuning as +**explicit caller-resolved params** (each block resolves from its OWN aliases with its OWN defaults — the +helper never re-resolves from props). Emits: +- unconditional: `fs.s3.impl`, `fs.s3a.impl`, `fs.s3{,a}.impl.disable.cache=true`. +- `fs.s3a.endpoint` / `fs.s3a.endpoint.region` — **conditional on `isNotBlank`** (documented deviation: legacy + is unconditional via `checkNotNull`, but the connector has no `setRegionIfPossible` throw-guard; matches the + current connector style). +- creds (gated on `isNotBlank(ak)`, anonymous-safe): `fs.s3a.aws.credentials.provider`=Simple, + `fs.s3a.access.key`, `fs.s3a.secret.key`=`nullToEmpty(sk)`, `fs.s3a.session.token` (if token). +- unconditional tuning: `fs.s3a.connection.maximum`=maxConn, `…request.timeout`=reqTimeout, + `…connection.timeout`=connTimeout, `fs.s3a.path.style.access`=pathStyle. + +### `applyCanonicalS3Config` (P8-2, P9-3, + S3 endpoint-from-region) +Resolve S3 creds (existing aliases). Gate unchanged: `if (ak==null && endpoint==null && region==null) return;` +- **NEW (user-approved)**: `if (endpoint blank && region present) endpoint = "https://s3." + region + ".amazonaws.com";` + (mirrors `S3Properties.getEndpointFromRegion:420`). +- Resolve tuning with **S3 defaults 50/3000/1000** from `{s3.connection.maximum, AWS_MAX_CONNECTIONS}` / + `{s3.connection.request.timeout, AWS_REQUEST_TIMEOUT_MS}` / `{s3.connection.timeout, AWS_CONNECTION_TIMEOUT_MS}`; + pathStyle default `false` from `{use_path_style, s3.path-style-access}`. +- `applyS3aBaseConfig(...)`. + +### `applyCanonicalOssConfig` (P8-1, P8-3) +Resolve OSS creds (existing aliases). Gate unchanged. +- **NEW (4a)**: `if (endpoint blank && region present)` derive + `endpoint = "oss-" + region + (publicAccess ? "" : "-internal") + ".aliyuncs.com"`, where + `publicAccess = toBoolean(firstNonBlank(props, "dlf.access.public", "dlf.catalog.accessPublic"))` (default false). + **[RT — skeptic 3]** this is the SAME derivation as the DLF-local block; therefore **REMOVE** the now-dead + guarded block in `buildDlfHiveConf` (DLF still derives via this shared path; the move also—correctly—grants + the **HMS** flavor the same legacy `OSSProperties.of()` derivation). +- Resolve tuning with **OSS defaults 100/10000/10000** (`{oss.connection.maximum, s3.connection.maximum}` etc.; + pathStyle from `{oss.use_path_style, use_path_style, s3.path-style-access}`). +- **NEW (4a)**: `applyS3aBaseConfig(...)` (emit the S3A base for OSS). +- THEN the existing Jindo `fs.oss.*` block (kept as-is, incl. its existing `isNotBlank` guards — a pre-existing + conditional-vs-unconditional deviation NOT in this fix's scope; after derivation `fs.oss.endpoint` now emits). + +### `applyCanonicalCosConfig` (NEW, 4c) +COS aliases (from `COSProperties`): access `{cos.access_key, s3.access_key, s3.access-key-id, AWS_ACCESS_KEY, +access_key, ACCESS_KEY}`; secret `{cos.secret_key, s3.secret_key, s3.secret-access-key, AWS_SECRET_KEY, +secret_key, SECRET_KEY}`; token `{cos.session_token, s3.session_token, s3.session-token, session_token}`; +endpoint `{cos.endpoint, s3.endpoint, AWS_ENDPOINT, endpoint, ENDPOINT}`; region `{cos.region, s3.region, +AWS_REGION, region, REGION}`. +- **[RT — skeptic 2] Detect** = `anyKeyStartsWith(props, "cos.")` **OR** resolved endpoint contains + `myqcloud.com` **OR** `warehouse` contains `myqcloud.com`. Gate: `if (!detected) return;` +- Tuning defaults 100/10000/10000 (`{cos.connection.*, s3.connection.*}`; pathStyle `{cos.use_path_style, + use_path_style, s3.path-style-access}`). +- `applyS3aBaseConfig(...)` **FIRST** (super-first ordering), THEN **[RT — critic] unconditional**: + `fs.cos.impl`=S3AFileSystem, `fs.cosn.impl`=S3AFileSystem, `fs.cosn.bucket.region`=`nullToEmpty(region)`, + `fs.cosn.userinfo.secretId`=`nullToEmpty(ak)`, `fs.cosn.userinfo.secretKey`=`nullToEmpty(sk)`. + +### `applyCanonicalObsConfig` (NEW, 4c) +OBS aliases (from `OBSProperties`, same shape as COS with `obs.` prefix). Detect = `anyKeyStartsWith(props, +"obs.")` OR resolved endpoint contains `myhuaweicloud.com` OR `warehouse` contains `myhuaweicloud.com`. +- Tuning defaults 100/10000/10000. +- `applyS3aBaseConfig(...)` FIRST, THEN **[RT — skeptic 5(i)]** native-vs-s3a by + `isClassAvailable("org.apache.hadoop.fs.obs.OBSFileSystem")` (`Class.forName(name, false, + PaimonCatalogFactory.class.getClassLoader())` — child-first delegates non-plugin classes to the host parent, + so this answers the same question legacy did): + - native → `fs.obs.impl`=`org.apache.hadoop.fs.obs.OBSFileSystem`, `fs.AbstractFileSystem.obs.impl`=`org.apache.hadoop.fs.obs.OBS`. + - else → `fs.obs.impl`=S3AFileSystem. + - **unconditional**: `fs.obs.access.key`=`nullToEmpty(ak)`, `fs.obs.secret.key`=`nullToEmpty(sk)`, + `fs.obs.endpoint`=`nullToEmpty(endpoint)`. + +### `applyStorageConfig` order +`applyCanonicalS3Config` → `applyCanonicalOssConfig` → `applyCanonicalCosConfig` → `applyCanonicalObsConfig` +→ raw passthrough (last). **[RT — skeptic 1]** when a `cos.*`/`myqcloud` catalog ALSO matches the S3 block +(shared `s3.endpoint`), the COS block runs AFTER S3 so its (identical) S3A base + the `fs.cosn.*` keys win +deterministically — matches legacy, which selects COS for that shape. + +### 4d `buildHmsHiveConf` (P8-4) +Replace `copyIfPresent(props, hiveConf, "hadoop.username")` with +`String u = firstNonBlank(props, "hive.metastore.username", "hadoop.username"); if (isNotBlank(u)) +hiveConf.set("hadoop.username", u);` (alias priority `hive.metastore.username` first; target key +`hadoop.username` == `HADOOP_USER_NAME`). This resolution must run **AFTER** `applyStorageConfig` — the raw +`hadoop.*` passthrough there would otherwise re-copy a literal `hadoop.username` and clobber the resolved +alias (caught by the username-priority test). + +### 4e `buildHmsHiveConf` kerberos-ordering (folded in — pre-existing MAJOR, user-approved 2026-06-12) +Impl-verification (`wf_f90260cb-5e6`) found a **pre-existing** (B1, not introduced by this fix) clobber with +the SAME root cause as 4d: the kerberos block forced `hadoop.security.authentication=kerberos`, but +`applyStorageConfig`'s raw `hadoop.*` passthrough ran AFTER it and re-copied a user-supplied literal +`hadoop.security.authentication=simple` — leaving `auth=simple` with `sasl.enabled=true` (inconsistent +HiveConf, breaks the live GSSAPI handshake) for a **kerberized-HMS + simple-HDFS** catalog. Legacy +`HMSBaseProperties.checkAndInit` runs `initHadoopAuthenticator` LAST, so kerberos is authoritative. **Fix**: +relocate the entire kerberos-conditional block to AFTER `applyStorageConfig` (alongside the 4d username +block), mirroring legacy's ordering. The socket-timeout default + the `hive.*` service-principal stay correct +(neither is a `hadoop.*` passthrough key). User chose to fold this into the FIX-4 commit (same root cause, +same method). + +### New small helpers +`firstNonBlankOrDefault(props, default, keys...)`; `anyKeyStartsWith(props, prefix)`; +`isClassAvailable(className)`; `containsToken(value, token)`. + +## Implementation Plan +1. Add alias-array constants for S3 tuning, OSS tuning, and the full COS/OBS cred + tuning families. +2. Add `applyS3aBaseConfig` + the 4 small helpers. +3. Refactor `applyCanonicalS3Config` (S3 endpoint-from-region + tuning) and `applyCanonicalOssConfig` + (endpoint-from-region + S3A base + tuning) to call the helper. +4. Add `applyCanonicalCosConfig` + `applyCanonicalObsConfig`; wire both into `applyStorageConfig`. +5. Remove the dead DLF-local OSS-endpoint derivation block from `buildDlfHiveConf`. +6. Fix 4d in `buildHmsHiveConf`. +7. Tests (below). Build connector-only; checkstyle; import-gate. + +## Risk Analysis +- **DLF regression** [RT-3]: removing the DLF-local block — DLF still derives via the shared OSS block with the + same `dlf.access.public` source. Verified by the 4 existing DLF tests (must stay green). +- **Existing S3 tests** [RT-4]: all 13 storage tests assert only old keys; new keys are additive, S3 + endpoint-from-region only triggers on a region-only-no-endpoint S3 case (none exist today). Passthrough-last + preserved. +- **Wrong tuning defaults** [RT-critic]: mitigated by per-scheme defaults (50/3000/1000 for S3; 100/10000/10000 + for OSS/COS/OBS) + RED-first divergent-default tests. +- **Over-emission**: COS/OBS emit `fs.s3a.*` for `cosn://`/`obs://` — REQUIRED (those FS impls are S3A and read + `fs.s3a.*`); inert extras (`fs.cosn.*` on an `s3://` catalog) match legacy. +- **Known residual (documented, out of scope)**: OSS endpoint-PATTERN detection (`aliyuncs.com`) is NOT added + (pre-existing gap in the existing OSS block; no failing case; not in the approved cluster). The + conditional-vs-unconditional `fs.s3a.endpoint/region` deviation is documented in a code comment. + +## Test Plan + +### Unit Tests (`PaimonCatalogFactoryTest`) — each is RED-before-GREEN (mutation noted) +- **S3 endpoint-from-region**: region-only S3 (no endpoint) → `fs.s3a.endpoint == https://s3..amazonaws.com`. + Mutation: drop derivation → null. +- **S3 tuning defaults**: S3 catalog (endpoint+region, no conn keys) → `fs.s3a.connection.maximum==50`, + `request.timeout==3000`, `connection.timeout==1000`, `path.style.access==false`. Mutation: shared 100/10000/10000 → red. +- **S3 path-style override**: `use_path_style=true` (and `s3.path-style-access=true`) → `fs.s3a.path.style.access==true`. +- **OSS endpoint-from-region** (filesystem AND hms flavor): `oss.region` only → `fs.oss.endpoint==oss--internal.aliyuncs.com`; + with `dlf.access.public=true` → public form. Mutation: no derivation / wrong public-internal → red. +- **OSS S3A base**: OSS catalog → `fs.s3a.impl==S3AFileSystem` + `fs.s3a.connection.maximum==100`. Mutation: OSS block skips S3A base → red. +- **COS (cos.* keys, `cosn://`)**: `cos.access_key/secret_key/endpoint` → `fs.cosn.impl==S3AFileSystem`, + `fs.cos.impl==S3AFileSystem`, `fs.cosn.userinfo.secretId==ak`, `fs.cosn.userinfo.secretKey==sk`, + `fs.cosn.bucket.region==region`, AND `fs.s3a.endpoint==endpoint` + `fs.s3a.connection.maximum==100`. +- **COS pattern-detect (`s3.endpoint=cos…myqcloud.com`, no `cos.*` key)**: `fs.cosn.impl` present (the gate that a + scheme-key-only design would miss). Mutation: cos.*-key-only gate → fs.cosn.impl null → red. +- **COS unconditional region**: COS catalog with NO region → `fs.cosn.bucket.region` present (`""`, not null). +- **OBS (obs.* keys, `obs://`)**: `obs.access_key/secret_key/endpoint=…myhuaweicloud.com` → `fs.obs.impl` present + (native or s3a), `fs.obs.access.key==ak`, `fs.obs.secret.key==sk`, `fs.obs.endpoint==endpoint`, AND S3A base. +- **OBS pattern-detect (`s3.endpoint=…myhuaweicloud.com`, no `obs.*` key)**: `fs.obs.impl` present. +- **4d HMS username alias**: `hive.metastore.username=foo` (no `hadoop.username`) → `hadoop.username==foo`. + Mutation: only literal-copy → null. Priority: both set → `hive.metastore.username` wins (this test caught a + real ordering bug: the username resolution had to move AFTER the storage overlay or the raw `hadoop.*` + passthrough clobbers it). +- **4e kerberos survives simple-HDFS passthrough**: `hive.metastore.authentication.type=kerberos` + + `hadoop.security.authentication=simple` → `hadoop.security.authentication==kerberos` + `sasl.enabled==true`. + Mutation: kerberos block before the storage overlay → clobbered to `simple` → red. +- **DLF unchanged**: existing 4 DLF tests stay green (regression guard for the block removal). + +### E2E Tests +None added. Live coverage exists in `paimon_base_filesystem.groovy` (catalog_oss/cos/cosn/obs) + +`test_paimon_dlf_catalog.groovy` + `test_paimon_hms_catalog.groovy`, all CI-gated (`enablePaimonTest=false`). +Note as gated; do not claim executed. diff --git a/plan-doc/FIX-FECONF-STORAGE-PARITY-summary.md b/plan-doc/FIX-FECONF-STORAGE-PARITY-summary.md new file mode 100644 index 00000000000000..350cb8d2fa0da8 --- /dev/null +++ b/plan-doc/FIX-FECONF-STORAGE-PARITY-summary.md @@ -0,0 +1,53 @@ +# FIX-FECONF-STORAGE-PARITY — summary + +**Commit**: `f0210b51871` (fix). Cluster P8-1/P8-2/P8-3/P8-4 + P9-2/P9-3 + user-approved S3 +endpoint-from-region + folded-in pre-existing kerberos-ordering MAJOR. **Connector-only** (no fe-core / SPI / BE). + +## Problem +`PaimonCatalogFactory` rebuilds the FE-side Hadoop `Configuration` / `HiveConf` from raw props (the connector +cannot import fe-core), but the reconstruction was incomplete vs the legacy `*Properties`. Paimon catalogs on +OSS (region-only / s3://-over-OSS), COS, OBS, and MinIO/path-style failed FE-side catalog/metadata access; the +HMS `hive.metastore.username` alias never reached `hadoop.username`. + +## Root Cause +`applyStorageConfig` ran only `applyCanonicalS3Config` + `applyCanonicalOssConfig`, each emitting a subset of +the legacy `initializeHadoopStorageConfig` (+ `super.appendS3HdfsProperties`) keys, with no COS/OBS block. +Notably the 4 S3A tuning keys were never emitted, the OSS endpoint-from-region derivation was DLF-local only, +and the HMS username alias was dropped. + +## Fix +- Shared `applyS3aBaseConfig` helper (port of `appendS3HdfsProperties`) taking caller-resolved creds + tuning. +- **4a OSS**: endpoint-from-region (`oss-[-internal].aliyuncs.com`, default `-internal`) moved into the + shared OSS block (so filesystem + hms flavors get it); emit the S3A base for OSS; removed the dead DLF-local block. +- **4b S3**: `fs.s3a.path.style.access` + `connection.maximum/request.timeout/timeout`, with **per-backend + defaults** — S3 `50/3000/1000` (+ `AWS_*` alias twins), OSS/COS/OBS `100/10000/10000`. +- **4c COS/OBS**: new blocks. Detection = `cos.`/`obs.` key OR endpoint/warehouse pattern + (`myqcloud.com`/`myhuaweicloud.com`), mirroring legacy `guessIsMe`. Each emits the S3A base (the cosn/obs FS + impl is `S3AFileSystem`, which reads `fs.s3a.*`) then the **unconditional** `fs.cosn.*` / `fs.obs.*` keys; OBS + prefers native `OBSFileSystem` when classpath-available. +- **S3 endpoint-from-region** (user-approved): region-only AWS S3 → `https://s3..amazonaws.com`. +- **4d HMS username**: `firstNonBlank(hive.metastore.username, hadoop.username)` → `hadoop.username`, run AFTER + the storage overlay so the raw `hadoop.*` passthrough can't clobber it. +- **4e kerberos-ordering** (folded-in pre-existing MAJOR): relocated the kerberos-conditional block to run AFTER + the storage overlay, so a kerberized-HMS + simple-HDFS catalog keeps `auth=kerberos` (legacy + `initHadoopAuthenticator`-last) instead of being clobbered to `simple` while `sasl.enabled=true`. + +## Tests +`PaimonCatalogFactoryTest` **56/0/0** (15 new). The username-priority and kerberos-survives-simple-HDFS tests +are RED on the pre-move ordering (proof of fail-before; the kerberos clobber was empirically reproduced in +impl-review). Full `fe-connector-paimon` module green; checkstyle 0; import-gate clean. Live e2e +(`paimon_base_filesystem` catalog_oss/cos/cosn/obs, `test_paimon_dlf_catalog`, `test_paimon_hms_catalog`) +CI-gated (`enablePaimonTest=false`) — not run here. + +## Method (meta) +Design red-team `wf_a6385c61-669` (5 skeptics + completeness critic) BEFORE coding caught: divergent per-backend +tuning defaults (S3 50/3000/1000 vs 100/10000/10000), endpoint-pattern detection (legacy detects COS/OBS by +endpoint pattern, not scheme key), and the unconditional `fs.cosn.*`/`fs.obs.*` requirement. Impl verification +`wf_f90260cb-5e6` confirmed byte-for-byte legacy key/alias/default fidelity (CLEAN) and surfaced the pre-existing +kerberos-ordering MAJOR (4e), which the user approved folding in. + +## Result +All 4 round-3 user-approved fixes (FIX-1..FIX-4) complete. No fe-core/SPI/BE change. Known residual (documented, +out of scope): OSS endpoint-PATTERN detection (`aliyuncs.com`) not added to the existing OSS block (pre-existing, +no failing case); `fs.s3a.endpoint/region` emitted conditionally (connector lacks legacy's `checkNotNull` +throw-guard). diff --git a/plan-doc/FIX-INCR-SCAN-RESET-design.md b/plan-doc/FIX-INCR-SCAN-RESET-design.md new file mode 100644 index 00000000000000..19a429758461ca --- /dev/null +++ b/plan-doc/FIX-INCR-SCAN-RESET-design.md @@ -0,0 +1,179 @@ +# FIX-INCR-SCAN-RESET — Design + +> Source: `reviews/P5-paimon-rereview3-2026-06-12.md` (P2-1, **MAJOR**; was NIT in rereview2); +> task `task-list-P5-rereview3-fixes.md` FIX-3. **Connector-only, no BE / no SPI change.** +> Design red-team (5 skeptics + completeness critic, `wf_ffd11631-ed2`): **DESIGN-SOUND**, unanimous +> **Option 2** (inject the reset at the `Table.copy` chokepoint; keep the shared SPI null-free). + +## Problem +A Paimon `@incr(...)` incremental read can read the **wrong rows** — or hard-fail — when the base table +**persists** a `scan.snapshot-id` / `scan.mode` option (legal & mutable via `ALTER TABLE SET`, +`TBLPROPERTIES`, or `table-default.*` catalog options). The connector's `@incr` path produces only the +`incremental-between*` scan options and applies them with `Table.copy(...)`, but it **dropped** legacy's +defensive null-reset of `scan.snapshot-id` / `scan.mode`. So the freshly-loaded base table's persisted +`scan.snapshot-id` survives into the copied table and collides with `incremental-between`. + +Two concrete failure modes (both verified against paimon 1.3.1; the second was reproduced **empirically** +offline by the red-team): +- **Hard throw** (persisted `scan.snapshot-id` present): `Table.copy({incremental-between=…})` throws + `IllegalArgumentException: "[incremental-between] must be null when you set + [scan.snapshot-id,scan.tag-name]"`. +- **Silent wrong rows** (persisted `scan.snapshot-id` with no `scan.mode`): `CoreOptions.setDefaultValues` + sets `scan.mode=FROM_SNAPSHOT` *before* the `INCREMENTAL` branch, and `startupMode()` checks + `SCAN_SNAPSHOT_ID` before `INCREMENTAL_BETWEEN` → the read becomes `FROM_SNAPSHOT` at the stale id and + `incremental-between` is silently ignored. The stale `scan.snapshot-id` (a `SCAN_KEY`) also pins the + schema to the wrong version via `tryTimeTravel`. + +## Root Cause +`PaimonIncrementalScanParams.validate()` (lines 222-265) intentionally **strips** legacy's +`paimonScanParams.put("scan.snapshot-id", null)` and `put("scan.mode", null)` (legacy +`PaimonScanNode.validateIncrementalReadParams:842-843,846`, applied via +`baseTable.copy(getIncrReadParams())` at `:896`). The strip was justified by a rationale that is **wrong**: +the class javadoc (lines 39-49) and the inline note (222-229) claim the reset is "byte-parity in EFFECT on +a freshly-loaded base table" because the connector loads a fresh `Table` per query (so "nothing to reset") +and because `ConnectorMvccSnapshot` rejects null values. + +Both premises fail: +- **Per-query freshness ≠ option freshness.** The base table comes straight from `catalog.getTable(...)` + (`CatalogBackedPaimonCatalogOps.getTable`), whose options are built from the **persisted** `TableSchema` + (`FileStoreTable.options() == schema().options()`). Persisted `scan.*` is therefore present on every + fresh load. The connector strips nothing before `copy`. +- **Why the reset matters.** paimon 1.3.1 `AbstractFileStoreTable.copyInternal` merges dynamic options + with exactly `v == null ? options.remove(k) : options.put(k, v)`. A **null** value is the SDK's documented + reset (remove) mechanism — the only way to clear a persisted `scan.snapshot-id`/`scan.mode`. `scan.mode` + and `scan.snapshot-id` are **not** `@Immutable` in 1.3.1, so `copy`'s `checkImmutability` + (`Objects.equals(old,new)` → else `SchemaManager.checkAlterTableOption`) does **not** throw on the reset; + for a non-persisted key (`old==null`, `new==null`) it is a pure no-op. + +## Design — Option 2 (chosen) +Keep `validate()` emitting **only** the non-null `incremental-between*` keys (so the shared SPI type +`ConnectorMvccSnapshot` and `PaimonTableHandle.scanOptions` stay **null-free** — preserving the +`Builder.property(k,v)` `requireNonNull` contract, the `getProperties()` "never null" javadoc, and the two +existing tests that pin "no null values"). Reintroduce legacy's two null resets **locally at the single +`Table.copy` chokepoint**, where the nulls are created and immediately consumed by `copyInternal`'s +`options.remove(k)` — never stored, never serialized, never placed in the SPI. + +Add a helper **owned by `PaimonIncrementalScanParams`** (the rightful home of the incremental-key +knowledge), gated on the presence of an incremental key: + +```java +public static Map applyResetsIfIncremental(Map scanOptions) { + if (scanOptions == null || scanOptions.isEmpty()) { + return scanOptions; + } + if (!scanOptions.containsKey(PAIMON_INCREMENTAL_BETWEEN) + && !scanOptions.containsKey(PAIMON_INCREMENTAL_BETWEEN_TIMESTAMP)) { + return scanOptions; // non-incremental pin → unchanged (no false positive) + } + Map withResets = new HashMap<>(); + withResets.put(PAIMON_SCAN_SNAPSHOT_ID, null); // legacy reset: clear a persisted stale pin at copy time + withResets.put(PAIMON_SCAN_MODE, null); + withResets.putAll(scanOptions); + return withResets; +} +``` + +Call it inside `PaimonScanPlanProvider.resolveScanTable` (the lone `table.copy(scanOptions)` site, lines +248-255): + +```java +return table.copy(PaimonIncrementalScanParams.applyResetsIfIncremental(scanOptions)); +``` + +This single edit covers **both** `resolveScanTable` callers — `planScanInternal:292` (native/JNI scan) and +`getScanNodeProperties:515` (JNI serialized-table for BE, which serializes the **post-copy** table) — through +the shared chokepoint, so native and JNI `@incr` reset identically. + +**Detection soundness** (verified): every successful `validate()` output contains exactly one of +`incremental-between` / `incremental-between-timestamp` (snapshot group always emits `incremental-between`; +timestamp group always emits `incremental-between-timestamp`; `incremental-between-scan-mode` is only ever +emitted *alongside* `incremental-between`). And **no** non-incremental scan-options producer emits either +key (`SNAPSHOT_ID`/`TIMESTAMP` → `scan.snapshot-id`; `TAG` → `scan.tag-name`; `BRANCH` routed before the +properties path; latest-pin → `scan.snapshot-id` only). So the helper resets **iff** the scan is +incremental — it never clobbers a legitimate `scan.snapshot-id`/`scan.tag-name` pin. + +**Scope = strict legacy parity:** reset **only** `scan.snapshot-id` + `scan.mode` (exactly legacy +`PaimonScanNode:842-843,846`). Do **not** broaden to the other `SCAN_KEYS` (`scan.timestamp`, +`scan.timestamp-millis`, `scan.tag-name`, `scan.watermark`) that could also hijack `startupMode` — legacy +did not reset those, and the task-list pins the two-key scope. + +### Why not Option 1 (re-add the nulls in `validate()`, ride through the SPI) +Mechanically it works (the nulls survive `ConnectorMvccSnapshot.Builder.properties(Map)`'s `putAll` and +the handle, and `copy` resolves them), but it is the wrong design: it **breaks the shared SPI's null-free +contract** for future consumers (iceberg/hudi), depends on a **silent gap** in `properties(Map)` (it lacks +the per-value `requireNonNull` that `property(k,v)` has — a future, correct hardening would silently +re-break `@incr`), **inverts** two existing green tests + the `getProperties()` javadoc, and leaks a +paimon-SDK quirk (`copy`: null == remove) into a source-agnostic type. Option 2 achieves identical engine +behavior with none of that. + +## Implementation Plan +1. **`PaimonIncrementalScanParams.java`** — add `public static Map + applyResetsIfIncremental(Map)` using the existing private constants + (`PAIMON_SCAN_SNAPSHOT_ID`, `PAIMON_SCAN_MODE`, `PAIMON_INCREMENTAL_BETWEEN`, + `PAIMON_INCREMENTAL_BETWEEN_TIMESTAMP`) — **no string literals**, so the detector key set cannot drift + from the emitter set. Javadoc the WHY (legacy reset at copy time; nulls consumed by `copyInternal`). +2. **`PaimonScanPlanProvider.java`** — in `resolveScanTable` (248-255), wrap the `table.copy(scanOptions)` + argument with `PaimonIncrementalScanParams.applyResetsIfIncremental(...)`. Keep the existing + `scanOptions != null && !scanOptions.isEmpty()` guard unchanged. +3. **Doc fanout (Rule 9/12)** — correct the now-refuted "byte-parity on a freshly-loaded base table" + rationale: `PaimonIncrementalScanParams` class javadoc (39-49) + inline note (222-229/233); the + `INCREMENTAL`-case comments in `PaimonConnectorMetadata` (≈410-413, 490-492, 505-506). Reword to: the + snapshot/SPI stays null-free **by design**, and the legacy null resets are reapplied at the `Table.copy` + chokepoint via `applyResetsIfIncremental`. + +## Risk Analysis +- **No SPI / no BE change.** Connector-only; import-gate clean (helper uses only `java.util` + paimon SDK). +- **Common path unaffected:** `applyResetsIfIncremental` returns the input map **unchanged** for every + non-incremental scan (snapshot/tag/timestamp pin, latest read) and for empty options — so + `resolveScanTableAppliesSnapshotPinViaCopy` / `…WithoutScanOptionsDoesNotCopy` stay green. The extra map + allocation happens only on `@incr` reads (negligible). +- **paimon-version coupling:** the reset relies on 1.3.1 semantics (`null` → remove; `scan.*` mutable). A + future paimon that marks these `@Immutable` would make the reset throw. Mitigated by the real-table test + asserting `copy` with the resets does **not** throw against the bundled jar (fails loud on upgrade). +- **Forward-compat:** if a *future* `ConnectorTimeTravelSpec.Kind` ever emitted an `incremental-between*` + key as a side property **and** wanted a real `scan.snapshot-id` pin, the helper would clobber it. Today + only `INCREMENTAL` emits these keys — a caveat, not a current defect; co-locating the detector keys with + the emitter constants mitigates rename drift. + +## Test Plan +### Unit Tests (connector, offline) +- **`PaimonScanPlanProviderTest.resolveScanTableResetsStalePinForIncrementalRead` (NEW, real table — the + fail-before/pass-after gate).** Build a real paimon 1.3.1 `FileSystemCatalog` + `LocalFileIO` table under + `@TempDir` (reuse the existing `buildRealDataSplit` recipe), created with + `.option("scan.snapshot-id","1").option("scan.mode","from-snapshot")` and at least one committed row so + `tableSchema.options()` persists `scan.snapshot-id`. Seat it on the handle + (`handle.setPaimonTable(realTable)`), pin `handle.withScanOptions({incremental-between:"3,5"})`, call + `provider.resolveScanTable(handle)`. **Before fix:** `copy` throws `IllegalArgumentException` (or returns + a table still carrying `scan.snapshot-id`). **After fix:** returned `table.options()` has **no** + `scan.snapshot-id` and `incremental-between=3,5`. (A `FakePaimonTable` test cannot be the gate — + `FakePaimonTable.copy` is a no-op recorder that does not implement merge/remove, so it can't fail-before; + Rule 9.) +- **`PaimonIncrementalScanParamsTest.applyResetsIfIncrementalSeedsNullResetsForIncremental` (NEW, unit).** + `applyResetsIfIncremental({incremental-between:"3,5"})` → map contains key `scan.snapshot-id` with **null** + value AND key `scan.mode` with **null** value AND `incremental-between=3,5`; same for + `{incremental-between-timestamp:"100,200"}`. Encodes WHY: nulls reach `copy` to remove a stale persisted + pin. +- **`PaimonIncrementalScanParamsTest.applyResetsIfIncrementalPassesThroughNonIncremental` (NEW, unit).** + `applyResetsIfIncremental({scan.snapshot-id:"5"})`, `({scan.tag-name:"t"})`, and an empty/null map return + the input **unchanged** (no `scan.mode` injected, no null values) — the no-false-positive invariant that + protects the snapshot/tag/timestamp pin paths. + +### Existing tests +- **No structural change.** `PaimonIncrementalScanParamsTest.nullResetKeysAreStrippedNotPresentWithNull` + (228-242) and `PaimonConnectorMetadataMvccTest.resolveIncrementalDoesNotEmitScanSnapshotId` (684-693) + **stay green** (validate()/snapshot are unchanged) — do **not** invert them. Reword only their inline + WHY-comments (currently "byte-parity on a freshly-loaded base") to "the SPI/snapshot stays null-free by + design; the legacy null resets are reapplied at the `Table.copy` chokepoint in `resolveScanTable`". + +### E2E +- Live `@incr`-over-persisted-`scan.snapshot-id` regression is **CI-gated** (`enablePaimonTest=false`) — not + run in this environment; noted as gated, not claimed. The offline real-table unit test above is the + load-bearing proof. + +## Build / Verify +- `mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -pl :fe-connector-paimon -am + -Dmaven.build.cache.enabled=false -DfailIfNoTests=false test` → read surefire XML + `MVN_EXIT`. +- `mvn -pl :fe-connector-paimon checkstyle:check`; `bash tools/check-connector-imports.sh`. + +## Commit +`fix: FIX-INCR-SCAN-RESET` — connector-only; carries this design doc (repo convention). diff --git a/plan-doc/FIX-INCR-SCAN-RESET-summary.md b/plan-doc/FIX-INCR-SCAN-RESET-summary.md new file mode 100644 index 00000000000000..752a79ca700d51 --- /dev/null +++ b/plan-doc/FIX-INCR-SCAN-RESET-summary.md @@ -0,0 +1,58 @@ +# FIX-INCR-SCAN-RESET — Summary + +> P2-1 (MAJOR). Commit `f08bc22b9bd`. Connector-only (no SPI / no BE). Design: +> `FIX-INCR-SCAN-RESET-design.md`. Pre-coding design red-team: `wf_ffd11631-ed2` (DESIGN-SOUND). + +## Problem +A Paimon `@incr(...)` read can return the wrong rows — or hard-fail — when the base table **persists** +a `scan.snapshot-id` / `scan.mode` option (legal & mutable via `ALTER TABLE SET`, `TBLPROPERTIES`, or +`table-default.*` catalog options). + +## Root Cause +`PaimonIncrementalScanParams.validate()` deliberately **stripped** legacy's defensive null-reset of +`scan.snapshot-id` / `scan.mode` (legacy `PaimonScanNode.validateIncrementalReadParams:842-843,846`, +applied via `baseTable.copy(...)` `:896`), justified by a **wrong** rationale ("a fresh per-query `Table` +can't inherit `scan.*`"). The table options come from the **persisted** `TableSchema`, so a stale +`scan.snapshot-id` is present on every fresh load. Without the reset, `resolveScanTable`'s +`Table.copy(scanOptions)` merges the stale `scan.snapshot-id` with `incremental-between`; paimon 1.3.1 then +either **throws** (`IllegalArgumentException: "[incremental-between] must be null when you set +[scan.snapshot-id,scan.tag-name]"`) or **silently** resolves to `FROM_SNAPSHOT` at the stale id (wrong +`@incr` rows, and a wrong pinned schema via `tryTimeTravel`). + +## Fix +**Option 2** (unanimous red-team pick; keeps the shared SPI null-free, surgical): +- New `PaimonIncrementalScanParams.applyResetsIfIncremental(scanOptions)` — gated on the presence of + `incremental-between` / `incremental-between-timestamp`, returns a fresh map seeded with + `scan.snapshot-id=null` + `scan.mode=null` then the original options; otherwise returns the input + unchanged (no false positive on a genuine snapshot/tag pin). Uses the class's existing key constants + (no literals → no detector drift). Strict legacy parity: only those two keys. +- `PaimonScanPlanProvider.resolveScanTable` wraps the `Table.copy(...)` argument with the helper — one edit + covers **both** callers (native/JNI scan `planScanInternal` + JNI serialized-table `getScanNodeProperties`) + through the single chokepoint. The null values are created locally and consumed immediately by paimon's + `copyInternal` (`v == null ? options.remove(k) : options.put(k, v)`) — never stored, serialized, or placed + in `ConnectorMvccSnapshot`. +- `validate()` is unchanged (still null-free), so the shared `ConnectorMvccSnapshot` SPI contract and the + two existing "no-null" tests stay intact. Corrected the now-refuted "byte-parity on a freshly-loaded base" + rationale in `PaimonIncrementalScanParams` javadoc/inline, `PaimonConnectorMetadata` INCREMENTAL comment, + and the two existing tests' WHY-comments (assertions unchanged). + +### Why not Option 1 (re-emit nulls through the SPI) +Mechanically works, but breaks the shared `ConnectorMvccSnapshot` null-free contract (future iceberg/hudi), +depends on a silent gap in `Builder.properties(Map)` that a future null-hardening would re-break, and would +invert two green tests + the `getProperties()` javadoc. Same engine behavior, none of that. + +## Tests +- `PaimonScanPlanProviderTest.resolveScanTableResetsStalePinForIncrementalRead` (**NEW**, real + `FileSystemCatalog` table with a persisted `scan.snapshot-id`) — **proven fail-before** (neutered fix → + `IllegalArgumentException` at the `resolveScanTable` call) / pass-after. +- `PaimonIncrementalScanParamsTest` **+4** unit tests (helper seeds the resets for snapshot & timestamp + windows; passes non-incremental pins through unchanged; no-op for empty/null); reworded the keep-null-free + `validate()` test (assertions unchanged). +- `PaimonConnectorMetadataMvccTest` — reworded the refuted WHY-comment (assertions unchanged). + +## Result +- Connector suites green: `PaimonIncrementalScanParamsTest` 20/0/0, `PaimonScanPlanProviderTest` 44/0/0, + `PaimonConnectorMetadataMvccTest` 37/0/0. `BUILD SUCCESS`. +- Checkstyle 0 violations; import-gate clean. +- Live `@incr`-over-persisted-`scan.snapshot-id` E2E is **CI-gated** (`enablePaimonTest=false`) — not run + here; noted as gated. diff --git a/plan-doc/FIX-JNI-FILE-FORMAT-design.md b/plan-doc/FIX-JNI-FILE-FORMAT-design.md new file mode 100644 index 00000000000000..22debfed610a84 --- /dev/null +++ b/plan-doc/FIX-JNI-FILE-FORMAT-design.md @@ -0,0 +1,95 @@ +# FIX-JNI-FILE-FORMAT — Design + +> Source: `reviews/P5-paimon-rereview3-2026-06-12.md` (P7-1, **MAJOR**); task `task-list-P5-rereview3-fixes.md` FIX-2. +> Connector-only, no BE change. Edits `PaimonScanPlanProvider` (same file as FIX-1; sequenced after it). + +## Problem +A JNI-serialized Paimon split (the default reader path, and the COUNT(*)-pushdown collapse range) +emits `file_format="jni"` in `TPaimonFileDesc`. BE's `paimon_cpp_reader.cpp:397-411` backfills the +paimon `FILE_FORMAT` **and** `MANIFEST_FORMAT` options from this field (only when they are unset/empty, +guarded `!file_format.empty()`), to avoid paimon-cpp defaulting `manifest.format=avro`. With the value +`"jni"` (an invalid paimon format) the backfill injects `MANIFEST_FORMAT=jni` (and `FILE_FORMAT=jni` when +the option is not serialized) → the cpp reader's manifest read breaks. + +## Root Cause +`PaimonScanPlanProvider.buildJniScanRange` and `buildCountRange` hardcode `.fileFormat("jni")` on the +`PaimonScanRange.Builder`. The correct `defaultFileFormat` +(`= table.options().getOrDefault(CoreOptions.FILE_FORMAT.key(), "parquet")`, computed once in +`planScanInternal`) is **passed into `buildJniScanRange` and ignored**, and is **not even passed into +`buildCountRange`**. `PaimonScanRange.populateRangeParams:186` then emits `fileDesc.setFileFormat("jni")`. + +**Crucial mechanism (verified):** the JNI `formatType` routing is gated by **`paimon.split` presence** +(`PaimonScanRange.populateRangeParams:160` → `setFormatType(FORMAT_JNI)`), **NOT** by the `fileFormat` +string. The `fileFormat` string is used for `formatType` only on the **native** branch (`:174-178`, +where it is already the real orc/parquet). So changing the JNI/count `fileFormat` from `"jni"` to the +real format leaves JNI routing untouched and only corrects the inner `fileDesc.fileFormat` BE consumes. + +## Legacy parity +`paimon/source/PaimonScanNode.setPaimonParams`: `rangeDesc.setFormatType(FORMAT_JNI)` (routing) **and** +`fileDesc.setFileFormat(fileFormat)` (`:288`) where +`fileFormat = getFileFormat(paimonSplit.getPathString())` (`:259`) = +`FileFormatUtils.getFileFormatBySuffix(path).orElse(source.getFileFormatFromTableProperties())`. For a +JNI whole-`DataSplit`, `getPathString()` resolves to the first data file's name, and the fallback is the +table `file.format` property. For a homogeneous paimon table (one `file.format` per table) that equals +`defaultFileFormat`. So `defaultFileFormat` is the correct, legacy-faithful value (and is exactly BE's +own `FILE_FORMAT` resolution source). + +## Design +1. **`buildJniScanRange`**: `.fileFormat("jni")` → `.fileFormat(defaultFileFormat)` (the parameter it + already receives, currently ignored). Covers both the non-DataSplit metadata-split call + (`planScanInternal:357`) and the DataSplit JNI call (`:419`). +2. **`buildCountRange`**: add a `String defaultFileFormat` parameter; `.fileFormat("jni")` → + `.fileFormat(defaultFileFormat)`; thread `defaultFileFormat` from the call site (`planScanInternal:430`, + where it is in scope). +3. **`PaimonScanRange.Builder` default (`:244`)**: change `private String fileFormat = "jni"` → + `private String fileFormat = ""`. Every production caller sets `fileFormat` explicitly, so the default + is currently dead — but `"jni"` is the very invalid value this fix removes; an empty default is the + safe one (BE's `!file_format.empty()` guard then **skips** the backfill rather than ever injecting an + invalid format, and the native `formatType` branch only matches real `orc`/`parquet`). Pure safety net, + no behavioral change for any existing path. + +### Divergence note (accepted) +`defaultFileFormat` is the table's `file.format` option; legacy derives the JNI format path-suffix-first +(first data file), table-prop fallback. These differ only for a **mixed-format** table (e.g. after +`ALTER ... file.format` leaves old-format files), which paimon does not produce per-table; the table +option is the more correct per-table hint for the whole-split BE backfill. The native path keeps its +per-file suffix derivation (`buildNativeRange:450`, unchanged). + +## Implementation Plan +1. `buildJniScanRange`: `"jni"` → `defaultFileFormat`. +2. `buildCountRange`: add param + use it; update call site `:430`. +3. `PaimonScanRange.Builder.fileFormat` default `"jni"` → `""`. +4. Test (below); build connector + checkstyle + import-gate. + +## Risk Analysis +- **JNI routing**: unchanged — gated by `paimon.split` presence, not `fileFormat` (verified `:160`). +- **Native path**: untouched (already real per-file format). +- **BE**: no change; the fix makes the consumed value valid. Backfill only fires when the option is + unset/empty and now backfills a real format instead of `"jni"`. +- **Builder default `""`**: dead for all current callers; safer than `"jni"`/`"parquet"`. +- **System tables (binlog/audit_log)** go JNI; their `defaultFileFormat` = underlying table option (same + as legacy non-DataSplit fallback). Valid format emitted, not `"jni"`. + +## Test Plan +### Unit Tests +**connector — `PaimonScanPlanProviderTest`** (+1, real-table harness like the count tests): +- `jniAndCountRangesCarryRealFileFormatNotJni`: create a real PK table via `FileSystemCatalog`/`LocalFileIO` + with an explicit `.option("file.format", "orc")` (so `defaultFileFormat` is a deterministic `"orc"`, + distinct from the `"parquet"` fallback — proves the real table option is read, not a constant): + - `planScan(force_jni_scanner=true, countPushdown=false)` → every emitted range is a JNI data range + (`buildJniScanRange`); assert each `((PaimonScanRange) r).getFileFormat()` equals `"orc"` and not + `"jni"`. Also drives the call-site threading. + - `planScan(countPushdown=true)` → the collapsed count range (`paimon.row_count` present; + `buildCountRange`); assert `getFileFormat()` equals `"orc"`, not `"jni"` — pins the new `defaultFileFormat` + parameter + its threading from the call site. + - MUTATION: reverting either method to `.fileFormat("jni")`, or failing to thread `defaultFileFormat` + into `buildCountRange` → the `"orc"` assertion → red. + +### E2E Tests +None required (connector-only, no BE change). The BE backfill behavior is pre-existing; this fix only +changes the FE-emitted value from an invalid `"jni"` to the table's real format. + +## Files touched +- `fe/fe-connector/.../paimon/PaimonScanPlanProvider.java` (2 sites + 1 call site) +- `fe/fe-connector/.../paimon/PaimonScanRange.java` (Builder default) +- `fe/fe-connector/.../paimon/PaimonScanPlanProviderTest.java` (+1 test) diff --git a/plan-doc/FIX-PAIMON-HADOOP-CLASSLOADER-design.md b/plan-doc/FIX-PAIMON-HADOOP-CLASSLOADER-design.md new file mode 100644 index 00000000000000..9b7ab8932eb2d3 --- /dev/null +++ b/plan-doc/FIX-PAIMON-HADOOP-CLASSLOADER-design.md @@ -0,0 +1,111 @@ +# FIX-PAIMON-HADOOP-CLASSLOADER — design + +## Problem +On the TeamCity external pipeline (build `af2037`), ~39 of 42 failed suites trace to the Paimon connector +plugin's Hadoop classloading. Surfaces (all the same bug): +- `java.lang.NoClassDefFoundError: Could not initialize class org.apache.hadoop.security.SecurityUtil` +- `class org.apache.hadoop.fs.s3a.S3AFileSystem cannot be cast to class org.apache.hadoop.fs.FileSystem` + `(S3AFileSystem in loader 'app'; FileSystem in loader ...ChildFirstClassLoader@665e9a87)` +- `java.lang.RuntimeException: class org.apache.hadoop.net.DNSDomainNameResolver not org.apache.hadoop.net.DomainNameResolver` +- `java.util.ServiceConfigurationError: org.apache.hadoop.fs.FileSystem: org.apache.hadoop.hive.ql.io.NullScanFileSystem not a subtype` +- cascade: `listDatabaseNames` throws → swallowed at `ExternalCatalog:914` → `Unknown database 'X'` + +Evidence: regression log markers (42) and FE log (`fe/log/fe.log` 76× cast / 56× SecurityUtil / 30× DNS). +First poison `fe.log:104928` via `PaimonConnector.createCatalogFromContext → CatalogFactory.createCatalog → +HadoopFileIO → FileSystem.get → SecurityUtil.`. + +## Root Cause +The Paimon plugin runs under `org.apache.doris.extension.loader.ChildFirstClassLoader`. Its parent-first allowlist +(`DEFAULT_PARENT_FIRST_PACKAGES` + connector `CONNECTOR_PARENT_FIRST_PREFIXES = {"org.apache.doris.connector.", +"org.apache.doris.filesystem."}`) does **not** include `org.apache.hadoop`, so all `org.apache.hadoop.*` is child-first. + +The plugin pom (`fe-connector-paimon/pom.xml:92-95`) bundles `hadoop-common` at compile scope but **not** +`hadoop-aws` / `hadoop-hdfs`. So at runtime the plugin `lib/` has `FileSystem`/`SecurityUtil`/`Configuration`/ +`DomainNameResolver` (child) but NOT `S3AFileSystem`/`DistributedFileSystem`. When paimon's `HadoopFileIO` does +`FileSystem.get()` on the warehouse (`s3://warehouse/wh`, `hdfs://...`), Hadoop reflectively resolves the impl: +- `Configuration.getClass("fs.s3a.impl")` → child miss → parent `app` loader → parent's `S3AFileSystem` → cast to + child `FileSystem` → ClassCastException. +- `FileSystem.loadFileSystems()` → `ServiceLoader.load(FileSystem.class)` uses the **thread-context CL** (= parent + `app`) → finds parent service providers incl. hive's `NullScanFileSystem` → "not a subtype" of child `FileSystem`. +- `SecurityUtil.` → `DomainNameResolverFactory` → `Configuration.getClass(...DNSDomainNameResolver)` resolves + across loaders → `DNSDomainNameResolver not DomainNameResolver`; the failed static init poisons `SecurityUtil` + JVM-permanently → every later Hadoop-touching test (incl. 3 non-Paimon: iceberg/remote_doris/describe). + +`PaimonCatalogFactory.buildHadoopConfiguration():457` builds a bare `new Configuration()` (captures the parent +thread-context CL as `Configuration.classLoader`), and `PaimonConnector` never pins the thread-context CL — unlike the +JDBC (`JdbcConnectorClient:222-241`) and HMS (`ThriftHmsClient:252-258`) connectors, which already pin it to their own +plugin loader around native calls. + +## Design +Make the Paimon plugin **self-contained** for the Hadoop filesystem layer (own its full closure inside its own +child-first loader), rather than delegating hadoop to the parent. This matches the migration end-state where +`hive-catalog-shade` and fe-core's Hadoop stack are removed (user-confirmed 2026-06-12): a parent-first / `provided` +approach would break the moment fe-core sheds hadoop. Three coordinated changes: + +1. **Packaging** (`fe-connector-paimon/pom.xml`): add **`hadoop-aws`** only (groupId+artifactId; version + `${hadoop.version}`=3.4.2 + exclusions inherited from `fe/pom.xml` dependencyManagement). Empirically, the plugin + lib already bundles `hadoop-client-api-3.4.2.jar` (transitive) carrying `FileSystem` / hdfs `DistributedFileSystem` + / `SecurityUtil` / `DNSDomainNameResolver`; the ONLY FS impl missing from the child was `S3AFileSystem` + (`hadoop-aws`). So adding `hadoop-aws` completes the child closure. (Earlier draft also added `hadoop-hdfs` — dropped: + in Hadoop 3.x `DistributedFileSystem` lives in `hadoop-hdfs-client`/`hadoop-client-api`, NOT `hadoop-hdfs`, so it was + dead weight + extra duplication.) `S3AFileSystem`'s AWS SDK v2 classes (`software.amazon.awssdk.*`, the heavy + `:bundle` excluded in depMgmt) are not in the child and resolve from the FE parent classpath as a SINGLE copy → a + cross-loader reference but not a split (only one copy exists). `hive-common` stays bundled (child); + `org.apache.hadoop` stays child-first; no parent-first list change. + +2. **Configuration classloader** (`PaimonCatalogFactory.buildHadoopConfiguration`): after `new Configuration()`, call + `conf.setClassLoader(PaimonCatalogFactory.class.getClassLoader())`. Forces `Configuration.getClass("fs..impl")` + to resolve FS impls from the plugin loader (handles the `fs.s3a.impl`/`fs.hdfs.impl` config-driven path). + +3. **Thread-context CL pin** (`PaimonConnector.createCatalogFromContext`, the single chokepoint for all 5 flavors): + wrap the `executeAuthenticated(() -> CatalogFactory.createCatalog(...))` call in a + `setContextClassLoader(getClass().getClassLoader())` / restore-in-`finally`, mirroring `JdbcConnectorClient`. Makes + the `FileSystem` ServiceLoader and `SecurityUtil`/DNS static init resolve from the plugin loader. The one-time class + resolutions + `SecurityUtil.` happen here (first FS touch), so pinning creation suffices for the observed + failures; later FS ops reuse the cached `FileSystem` / already-loaded classes. + +Why all three: #1 alone → ServiceLoader still reads parent (thread-context) service files → ServiceConfigurationError. +#3 alone → child lacks `S3AFileSystem` → "No FileSystem for scheme s3a". #2 covers the config-driven `getClass` path +that uses `Configuration.classLoader` (not the thread-context CL). Together: single-loader resolution. + +## Implementation Plan +- `fe/fe-connector/fe-connector-paimon/pom.xml`: add the two deps next to `hadoop-common` with an explanatory comment. +- `PaimonCatalogFactory.java` (`buildHadoopConfiguration`, ~457): `conf.setClassLoader(...)` + comment. +- `PaimonConnector.java` (`createCatalogFromContext`, 192-198): context-CL pin try/finally + comment. + +## Risk Analysis +- **Plugin size**: `hadoop-aws` pulls the AWS SDK (the heavy `bundle` is already excluded in depMgmt). Acceptable — it + is the intended self-contained-plugin architecture. +- **hms/dlf flavors** (not in the failing set; cutover-gated): the context-CL pin in `createCatalogFromContext` applies + to them too, but it is the correct plugin behavior (child-first still falls back to parent for the host-provided + Thrift metastore client). No `HiveConf` classloader change (#2 is scoped to `buildHadoopConfiguration`, filesystem/jdbc only). Flag for hms/dlf cutover e2e. +- **Version skew**: `${hadoop.version}` mirrors fe-core, so the bundled FS impls match `hadoop-common`/paimon expectations. +- **Local verification gap**: the full fix is only provable by the docker external suite (CI-gated). Local proof = + build + assert plugin `lib/` contents + connector UTs + the mechanism analysis above. + +## Test Plan +### Unit Tests +- `PaimonCatalogFactoryTest` must stay green; the existing `buildHadoopConfiguration*` test still asserts S3 prefix / + raw-key behavior (setClassLoader is orthogonal). Optionally add an assertion that + `buildHadoopConfiguration(props).getClassLoader() == PaimonCatalogFactory.class.getClassLoader()`. +### E2E Tests +- No new suite — the existing `external_table_p0/paimon/*` (39 suites) ARE the regression coverage; they must go green + in the docker external pipeline (`enablePaimonTest=true`, CI). Packaging assertion (lib/ contains hadoop-aws, + `S3AFileSystem` resolvable in child) is verified at build time. + +## Adversarial review (2026-06-12) +A skeptical reviewer returned "INSUFFICIENT/BLOCKER", but its headline rested on a **false premise**: it read the +`af2037` CI `fe.log` (the PRE-fix baseline, plugin `jarCount=143`, no hadoop-aws) as if it were a post-fix run, so the +recurring `S3AFileSystem cannot be cast` it cites is the *original* bug, not proof the fix fails. Its "loader-global +static cache" argument is also off — `FileSystem.SERVICE_FILE_SYSTEMS` / `Configuration.CACHE_CLASSES` are statics of +the *child's* `FileSystem`/`Configuration` class (per-loader), and are populated correctly under the #3 pin. Its +recommended remedy (`org.apache.hadoop.` parent-first) is the parent-leaning approach the user explicitly rejected +given the fe-core-hadoop-removal end-state. Two sub-findings WERE valid and folded in: (DEFECT 4) `hadoop-hdfs` does not +carry `DistributedFileSystem` → dropped; (DEFECT 3) AWS SDK v2 not in child → documented as single-copy parent +resolution (interim) + end-state follow-up. Post-creation FS access (planScan/rowCount) reuses the catalog's pinned +`SerializableConfiguration` (classLoader=child), so it is covered by #2 without needing its own pin. + +## Verification boundary +Local proof = build green + all connector UTs pass + plugin lib contains `hadoop-aws`/`S3AFileSystem` + mechanism +analysis. The full runtime proof (the cross-loader split is gone end-to-end) is the docker external paimon suite, which +is CI-gated (`enablePaimonTest=false` locally) — NOT claimed to have run locally. diff --git a/plan-doc/FIX-PAIMON-HADOOP-CLASSLOADER-summary.md b/plan-doc/FIX-PAIMON-HADOOP-CLASSLOADER-summary.md new file mode 100644 index 00000000000000..aeb4b454a0c274 --- /dev/null +++ b/plan-doc/FIX-PAIMON-HADOOP-CLASSLOADER-summary.md @@ -0,0 +1,35 @@ +# FIX-PAIMON-HADOOP-CLASSLOADER — summary + +## Problem +~39 of 42 failed suites in the TeamCity external run (`af2037`): the Paimon connector plugin's Hadoop classloading +split-brained (`Could not initialize class SecurityUtil` · `S3AFileSystem cannot be cast to FileSystem` · +`DNSDomainNameResolver not DomainNameResolver` · `ServiceConfigurationError: NullScanFileSystem not a subtype` · +cascade `Unknown database 'X'`). + +## Root Cause +The plugin runs child-first with `org.apache.hadoop` NOT parent-first, and bundled `hadoop-common`/`hadoop-client-api` +but NOT `hadoop-aws`. So `FileSystem`/`SecurityUtil` loaded child-first while `S3AFileSystem` resolved from the parent +`app` loader → cross-loader cast + permanent `SecurityUtil.` poison. `buildHadoopConfiguration` built a bare +`new Configuration()` (parent context CL) and the connector never pinned the thread-context CL (unlike JDBC/HMS). + +## Fix +Self-contained plugin (no parent-leaning — aligns with fe-core dropping hadoop/hive-catalog-shade after full migration): +1. `fe-connector-paimon/pom.xml`: add `hadoop-aws` (the only missing FS impl — `S3AFileSystem`; `DistributedFileSystem` + already came from the transitive `hadoop-client-api`). +2. `PaimonCatalogFactory.buildHadoopConfiguration`: `conf.setClassLoader(PaimonCatalogFactory.class.getClassLoader())` + → `Configuration.getClass("fs..impl")` resolves the FS impl from the plugin loader. +3. `PaimonConnector.createCatalogFromContext` (single chokepoint, all flavors): pin thread-context CL to the plugin + loader around `executeAuthenticated(...)` → `FileSystem` ServiceLoader + `SecurityUtil`/DNS init resolve child. + Mirrors `JdbcConnectorClient`/`ThriftHmsClient`. + +## Tests +- Build: `-pl :fe-connector-paimon -am package` → BUILD SUCCESS; all connector UTs 0 fail / 0 error. +- Packaging assertion: plugin `lib/` now contains `hadoop-aws-3.4.2.jar`; `S3AFileSystem` present in child. +- Checkstyle (connector) + connector import-gate: clean. +- Adversarial review: headline "BLOCKER" rested on a false premise (pre-fix `af2037` log read as post-fix); two valid + sub-findings folded in (dropped `hadoop-hdfs`; documented AWS SDK v2 single-copy parent resolution). +- **Final runtime proof is the docker external paimon suite (CI-gated, `enablePaimonTest`) — not run locally.** + +## Result +Implemented + locally verified (build/UT/packaging/style). Resolves the dominant cluster (39 suites incl. 3 non-Paimon +collateral) pending CI e2e confirmation. diff --git a/plan-doc/FIX-REST-VENDED-URI-NORMALIZE-design.md b/plan-doc/FIX-REST-VENDED-URI-NORMALIZE-design.md new file mode 100644 index 00000000000000..d581d1275a19b6 --- /dev/null +++ b/plan-doc/FIX-REST-VENDED-URI-NORMALIZE-design.md @@ -0,0 +1,223 @@ +# FIX-REST-VENDED-URI-NORMALIZE — Design + +> Source: `reviews/P5-paimon-rereview3-2026-06-12.md` §D.1 (P9-1, **BLOCKER**); task `task-list-P5-rereview3-fixes.md` FIX-1. +> Scope (user-approved 2026-06-12): route the vended-overlay storage map into native URI normalization (legacy parity). + +## Problem + +`SELECT` over a Paimon **REST**-catalog table on **object storage** (oss/cos/obs/s3a), using the +native reader (ORC/Parquet — the default), throws during FE planning: + +``` +StoragePropertiesException: No storage properties found for schema: oss +``` + +It worked under legacy paimon. The only escape hatch today is `SET force_jni_scanner=true` (which +dodges the native path entirely). So every native REST-on-object-store read is broken. + +## Root Cause + +Native URI normalization uses the **static** catalog storage-properties map, which is **empty by +design for REST** catalogs (vended creds are per-table/dynamic, so `CatalogProperty.initStorageProperties` +seeds an empty static map when vended creds are enabled). + +Call chain (verified against current tree): +- Connector `PaimonScanPlanProvider.normalizeUri:485-487` → `context.normalizeStorageUri(rawUri)`. +- fe-core `DefaultConnectorContext.normalizeStorageUri:193-204` → `LocationPath.of(rawUri, storagePropertiesSupplier.get())` (the 2-arg overload; `normalize=true` is supplied internally by the 2-arg→3-arg delegation at `LocationPath.java:181`). +- The supplier is the catalog-static map (`PluginDrivenExternalCatalog`), **empty for REST**. +- `LocationPath.of:135-140` → `findStorageProperties(type, schema, {}) == null` → `throw new UserException("No storage properties found for schema: " + schema)` → wrapped as `StoragePropertiesException` (a `RuntimeException`). + +`shouldUseNativeReader:783` has **no flavor gate**, so REST native reads reach `normalizeUri` on the +data-file path (`buildNativeRange:439`) **and** the deletion-vector path (`:448`). + +**Why it slipped through twice**: DV-025 deferred this exact corner to FIX-STATIC-CREDS-BE / +FIX-REST-VENDED, but those fixed **credential down-flow to BE** (`getScanNodeProperties` overlay, +`:546-562`), not `normalizeStorageUri`. The deferral was never closed → still live. + +### Legacy parity reference +`paimon/source/PaimonScanNode.doInitialize:171-176` computes a **vended-overlay** storage map once: + +```java +storagePropertiesMap = VendedCredentialsFactory.getStoragePropertiesMapWithVendedCredentials( + catalog...getMetastoreProperties(), catalog...getStoragePropertiesMap(), source.getPaimonTable()); +``` + +and uses **that** map for `LocationPath.of` at `:443` (data file) and `:296` (deletion vector). + +Confirmed semantics of `getStoragePropertiesMapWithVendedCredentials` (→ `PaimonVendedCredentialsProvider` +→ `AbstractVendedCredentialsProvider`): +- REST metastore + table has a `RESTTokenFileIO` with a valid token + the filtered token yields ≥1 + cloud-storage prop → returns a **vended-only** typed map built from the token + (`filterCloudStorageProperties` → `StorageProperties.createAll` → index by `Type`). The factory uses + it **as-is, discarding the base/static map** (vended *replaces* static — for REST the static map is + empty anyway, so no practical difference, but we replicate it exactly). +- Otherwise (non-REST, no token, filtered-empty, or any exception) → provider returns `null` → factory + **falls back to the base/static map**. + +The connector already extracts that raw token: `extractVendedToken(table):584-595` (gated on +`fileIO instanceof RESTTokenFileIO`; empty for non-REST), and already feeds it to +`context.vendStorageCredentials(...)` for the BE credential overlay (`:558`). + +## Design + +**Approach (a)** from the task list (recommended): add an SPI overload +`ConnectorContext.normalizeStorageUri(String rawUri, Map rawVendedCredentials)` that +normalizes against the **vended-overlay** map (legacy parity). The connector passes the raw vended +token it already extracts; fe-core builds the typed `StorageProperties` map (it cannot be done in the +connector — `LocationPath`/`StorageProperties` are fe-core-only). + +Rejected alternatives: +- (b) vended-aware supplier — vended creds are per-table/dynamic; the supplier is catalog-static. Wrong layer. +- (c) "static-map-misses-scheme → use vended" implicit fallback — narrower and implicit; (a) is explicit and matches legacy precedence exactly. + +### fe-core (`DefaultConnectorContext`) +Extract the vended-typed-map construction (already inline in `vendStorageCredentials`) into a private +helper, then use it from both methods (single source of truth, no drift between the BE-creds path and +the normalize path — they MUST agree: same token → same creds → same normalization): + +```java +/** Build the vended StorageProperties typed map from a raw token (filter cloud props + createAll + + * index by Type), mirroring AbstractVendedCredentialsProvider. Returns null when the token is + * null/empty, yields no cloud props, or normalization throws — exactly the legacy provider's + * "return null -> Factory falls back to base" contract. */ +private Map buildVendedStorageMap(Map raw) { + if (raw == null || raw.isEmpty()) return null; + try { + Map filtered = CredentialUtils.filterCloudStorageProperties(raw); + if (filtered.isEmpty()) return null; + return StorageProperties.createAll(filtered).stream() + .collect(Collectors.toMap(StorageProperties::getType, Function.identity())); + } catch (Exception e) { + LOG.warn("Failed to normalize vended credentials", e); + return null; + } +} + +@Override public Map vendStorageCredentials(Map raw) { + // Keep getBackendPropertiesFromStorageMap INSIDE a try so the fail-soft boundary is byte-preserved + // vs the pre-refactor method (which wrapped the whole tail, incl. the BE-props call). Without this + // outer try the refactor would shift that one call from fail-soft to fail-loud (latent — the + // throwing branch is HdfsProperties.getBackendConfigProperties, unreachable for cloud STS tokens — + // but we preserve exact semantics rather than rely on unreachability). [red-team S5b/gap3] + try { + Map map = buildVendedStorageMap(raw); + return map == null ? Collections.emptyMap() : CredentialUtils.getBackendPropertiesFromStorageMap(map); + } catch (Exception e) { + LOG.warn("Failed to normalize vended credentials", e); + return Collections.emptyMap(); + } +} + +@Override public String normalizeStorageUri(String rawUri, Map rawVendedCredentials) { + if (Strings.isNullOrEmpty(rawUri)) return rawUri; + Map vended = buildVendedStorageMap(rawVendedCredentials); + Map effective = (vended != null) ? vended : storagePropertiesSupplier.get(); + return LocationPath.of(rawUri, effective).toStorageLocation().toString(); // fail-loud, legacy parity +} + +@Override public String normalizeStorageUri(String rawUri) { // keep: delegates with no token + return normalizeStorageUri(rawUri, null); +} +``` + +The extraction is **behavior-preserving for `vendStorageCredentials`**: the typed-map build is the same +filter/createAll/toMap, and the outer try keeps the BE-props call fail-soft, so the fail-soft boundary is +byte-identical to the pre-refactor method (red-team S5b confirmed the only risk was moving that call out +of the try; the outer try removes it). The 1-arg `normalizeStorageUri` becomes a delegate with a `null` +token → `effective == static` → **byte-identical to current behavior** (the 4 existing fe-core tests stay green). + +### SPI (`fe-connector-spi/ConnectorContext`) +Add the overload as a `default` that ignores the token (other connectors have no vended creds), so it is +a no-op extension for every non-paimon connector: + +```java +default String normalizeStorageUri(String rawUri, Map rawVendedCredentials) { + return normalizeStorageUri(rawUri); // ignore token; falls to the existing default (returns rawUri) +} +``` + +### Connector (`PaimonScanPlanProvider`) +Thread the once-per-scan vended token to the normalize sites: +1. `normalizeUri(String rawUri, Map vendedToken)` → `context.normalizeStorageUri(rawUri, vendedToken)` (null-context → rawUri, unchanged). +2. `buildNativeRange(...)` / `buildNativeRanges(...)`: add a `Map vendedToken` parameter; pass it to both `normalizeUri` calls (data file + DV). +3. `planScanInternal`: compute `vendedToken = (context != null) ? extractVendedToken(table) : Collections.emptyMap();` **once** (next to the `cppReader` flag) and pass it into `buildNativeRanges` at the call site (`:404`). + +`extractVendedToken(table)` is empty for non-REST (FileIO gate) → the 2-arg call degrades to the static +path → **non-REST scans are byte-unchanged**. It is computed **once per `planScan` invocation** (not per +file/sub-range — `validToken()` may refresh the token), separate from the existing +`getScanNodeProperties:558` extraction (two independent extractions; this is a pre-existing property of +the two-method plugin SPI, not introduced here). URI normalization is **invariant under token refresh** +— `validateAndNormalizeUri` consumes only scheme/bucket/key, never the access-key/secret/token — so the +two extractions can never disagree on the normalized URI (red-team gap5). It is NOT wrapped in +`executeAuthenticated` (parity: legacy did not wrap the FileIO/cred path; the existing +`getScanNodeProperties:558` call is also unwrapped). The pinned `resolveScanTable` table carries the same +`RESTTokenFileIO` reference as the base (verified: `AbstractFileStoreTable.copy` preserves `fileIO`), so +the token matches legacy's `source.getPaimonTable()` (red-team S5c). + +## Implementation Plan +1. SPI: add the `normalizeStorageUri(uri, token)` default to `ConnectorContext`. +2. fe-core: add `buildVendedStorageMap` helper; refactor `vendStorageCredentials`; add 2-arg override; make 1-arg delegate. +3. Connector: thread `vendedToken` (steps 1–3 above). +4. Tests (below). +5. Build SPI → fe-core → connector; checkstyle; import-gate. + +## Risk Analysis +- **Behavior change for non-REST**: none — empty token → static-map path, identical to today. +- **Behavior change for REST native**: was a hard throw → now normalizes via vended map (the fix). Vended + *replaces* static (legacy parity); REST static is empty so no regression for any non-REST flavor. +- **Fail-loud preserved**: REST + bad/empty token → `buildVendedStorageMap` returns null → static (empty) + → `LocationPath.of` throws (legacy also fails loud here). The normalize path stays fail-loud; the + BE-creds path (`vendStorageCredentials`/`getBackendStorageProperties`) stays fail-soft — unchanged asymmetry. +- **Perf**: for REST scans the typed map is rebuilt per normalize call (per file + per DV + per sub-range) + rather than once-per-scan as legacy did. The token is tiny; the empty-token short-circuit means non-REST + pays nothing. Behavior is identical; only re-derivation frequency differs. Noted as a minor, accepted + divergence (a once-per-scan cache would need extra SPI surface or an opaque handle — disproportionate to + a BLOCKER hotfix; revisit only if profiling flags it). +- **Other connectors**: untouched (SPI default ignores the token; only paimon calls the 2-arg). + +## Test Plan + +### Unit Tests +**fe-core — `DefaultConnectorContextNormalizeUriTest`** (the actual bug & fix): +- `vendedRestCredentialsNormalizeUnderEmptyStaticMap`: context with an **empty** static supplier (the REST + case) + a vended token carrying oss creds (`oss.access_key/secret_key/endpoint`) → `normalizeStorageUri( + "oss://bkt/.../part-0.parquet", token)` returns `s3://bkt/.../part-0.parquet`. **This is the gap that hid + the bug twice.** MUTATION: ignoring the token (old static-only path) → throws → red. +- `emptyTokenUnderEmptyStaticStillFailsLoud`: same empty-static context, **empty** token → `normalizeStorageUri( + uri, emptyMap)` throws `RuntimeException` (proves the fix is the token, not a swallow; and that fail-loud + is intact when there is genuinely no cred). +- `staticMapPathUnaffectedByEmptyToken`: oss-static context + empty token → still rewrites oss→s3 (regression + guard for non-REST; the 2-arg must fold to the static path). +- Existing 4 tests (1-arg) remain unchanged (1-arg now delegates with null token). + +**connector — `PaimonScanPlanProviderTest`** (the threading): +- Extend `RecordingConnectorContext`: override **only** the 2-arg `normalizeStorageUri(uri, token)` so it + (a) sets `lastVendedToken = token`, (b) increments `normalizeCount` once, (c) does the oss→s3 rewrite; + then make the existing 1-arg override **delegate** to `normalizeStorageUri(rawUri, null)` (single source; + no recursion — the 2-arg does the rewrite directly, never calls the 1-arg). After the connector switches + to the 2-arg call, the connector dispatches straight to this 2-arg override (NOT via the SPI default → + 1-arg), so `normalizeCount`/rewrite are driven by the 2-arg override. [red-team gap2] +- `buildNativeRangeThreadsVendedTokenToBothPaths`: call `buildNativeRange(file, dv, "parquet", emptyMap, + vendedToken, 0L, 100L)` with a non-empty `vendedToken`; assert the context received that exact token map + on the data-file **and** the DV normalize call (`lastVendedToken` equals it; `normalizeCount == 2`). + MUTATION: passing an empty/null token, or dropping the token on the DV site → red. +- Update the **5** existing call sites broken by the signature change (pass `Collections.emptyMap()` as the + new token arg; assertions unaffected — empty token folds to the unchanged path): + - 3 `buildNativeRange` sites: `nativeRangeNormalizesBothDataAndDeletionVectorPaths` (`:270`), + `nativeRangeWithoutDeletionVectorNormalizesOnlyDataPath` (`:294`), `nativeRangeWithoutContextPreservesRawPath` (`:314`). + - 2 `buildNativeRanges` sites: `buildNativeRangesAttachesSameDeletionVectorToEverySubRange` (`:782`), + `buildNativeRangesKeepsFileWholeWhenTargetNonPositive` (`:810`). [red-team gap1] + +### E2E Tests +The positive `RESTTokenFileIO` token-extraction path needs a live REST stack and is **CI-gated** +(`enablePaimonTest=false`) — same as the existing `extractVendedToken` REST branch. Not run here; noted as +gated. The two unit layers (fe-core does the real normalization with a vended map; connector proves the +token is threaded to both sites) fully cover the offline-reachable surface. + +## Files touched +- `fe/fe-connector/fe-connector-spi/.../spi/ConnectorContext.java` (add default overload) +- `fe/fe-core/.../connector/DefaultConnectorContext.java` (helper + 2-arg override + 1-arg delegate) +- `fe/fe-connector/fe-connector-paimon/.../PaimonScanPlanProvider.java` (thread token) +- `fe/fe-core/.../connector/DefaultConnectorContextNormalizeUriTest.java` (3 new cases) +- `fe/fe-connector/.../paimon/RecordingConnectorContext.java` (2-arg override capture; 1-arg delegates) +- `fe/fe-connector/.../paimon/PaimonScanPlanProviderTest.java` (1 new + 5 updated call sites) diff --git a/plan-doc/FIX-SHOWCREATE-PLUGIN-PROPS-design.md b/plan-doc/FIX-SHOWCREATE-PLUGIN-PROPS-design.md new file mode 100644 index 00000000000000..c2d29ca755a190 --- /dev/null +++ b/plan-doc/FIX-SHOWCREATE-PLUGIN-PROPS-design.md @@ -0,0 +1,71 @@ +# FIX-SHOWCREATE-PLUGIN-PROPS — design + +## Problem +`test_nereids_refresh_catalog` fails: for a **JDBC** external-catalog table, `SHOW CREATE TABLE` now renders +``` +ENGINE=JDBC_EXTERNAL_TABLE +LOCATION '' +PROPERTIES ( + "password" = "...", "driver_class" = "...", "driver_url" = "...", "jdbc_url" = "...", "type" = "jdbc", ... +) +``` +but the committed expected output is just `ENGINE=JDBC_EXTERNAL_TABLE;` (no LOCATION, no PROPERTIES). Two problems: +a correctness regression (output diff) **and** a credential leak (the JDBC `password` is now printed by SHOW CREATE TABLE). + +## Root Cause +JDBC/ES/Trino/MaxCompute/Paimon catalogs are all plugin-driven on this branch, so their tables have +`TableType.PLUGIN_EXTERNAL_TABLE` and render through the single shared branch in `Env.getDdlStmt` +(`fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java:4929-4959`). + +Branch commit `98a73bf7692` ([P5-B7 paimon cutover], D-046 paimon parity) added LOCATION+PROPERTIES emission to that +shared branch, gated **only** on `!properties.isEmpty()`: +```java +Map properties = pluginExternalTable.getTableProperties(); +if (!properties.isEmpty()) { + sb.append("\nLOCATION '").append(properties.getOrDefault("path", "")).append("'"); + sb.append("\nPROPERTIES ( ... )"); +} +``` +The intent was: connectors that surface table properties (paimon coreOptions: path/file.format) render LOCATION+ +PROPERTIES; connectors that don't (MaxCompute) return an empty map and stay comment-only. But JDBC/ES/Trino tables +return **non-empty** `getTableProperties()` (connection props, incl. credentials), so they wrongly get the paimon +treatment. At merge-base `11a038d` this branch was `addTableComment(table, sb)` only — i.e. legacy JDBC/ES/Trino +SHOW CREATE TABLE was comment-only (`ENGINE=...;`). The first `getDdlStmt` overload (`Env.java:4509-4511`) is still +comment-only; only the second overload regressed. Not fixed by any af2037..HEAD commit. + +## Design +Restore legacy behavior by **scoping the LOCATION+PROPERTIES emission to the paimon engine type** — the only +plugin-driven connector that legacy rendered LOCATION/PROPERTIES — instead of "any plugin table with non-empty props". +`PluginDrivenExternalTable.getEngineTableTypeName()` already returns the per-catalog-type engine name +(`PAIMON_EXTERNAL_TABLE` for paimon; `JDBC/ES/TRINO_CONNECTOR/MAX_COMPUTE_EXTERNAL_TABLE` otherwise). Gate on it: +```java +boolean rendersLocation = TableType.PAIMON_EXTERNAL_TABLE.name().equals(pluginExternalTable.getEngineTableTypeName()); +if (rendersLocation && !properties.isEmpty()) { ... LOCATION/PROPERTIES ... } +``` +This is single-site, deterministic, restores exact legacy `ENGINE=...;` for JDBC/ES/Trino/MaxCompute, and fixes the +credential leak (their props are never printed). When hive/iceberg/hudi later migrate to plugin-driven and need +LOCATION, the gate is the obvious extension point. + +Rejected alternative: rebaseline `test_nereids_refresh_catalog.out` — it would bake leaked JDBC credentials into the +committed expected output and entrench the regression. Rejected alternative: make each non-file connector's +`getTableProperties()` return empty — touches multiple connector modules; the render-side gate is more surgical and +removes the leak regardless of connector hygiene. + +## Implementation Plan +- `fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java` (second `getDdlStmt`, ~4946): add the engine-type gate + before the `if (!properties.isEmpty())` block, with a comment referencing D-046 + this fix. + +## Risk Analysis +- Paimon SHOW CREATE TABLE is unchanged (engine type == paimon still renders). +- MaxCompute already comment-only (empty props) — unchanged. +- JDBC/ES/Trino revert to legacy comment-only — matches the committed `.out` and pre-`98a73bf7692` behavior. +- No SPI/connector change; no BE change. + +## Test Plan +### Unit Tests +- If an `Env.getDdlStmt`/SHOW CREATE FE unit test exists for plugin tables, assert JDBC renders `ENGINE=JDBC_EXTERNAL_TABLE;` + with no LOCATION/PROPERTIES and paimon still renders LOCATION/PROPERTIES. (Otherwise covered by e2e below.) +### E2E Tests +- `external_table_p0/nereids_commands/test_nereids_refresh_catalog` (existing) must pass unchanged against its + committed `.out` (CI external pipeline). Paimon SHOW CREATE TABLE suites (e.g. `test_paimon_catalog`) continue to + render LOCATION/PROPERTIES — covered once FIX-PAIMON-HADOOP-CLASSLOADER unblocks them. diff --git a/plan-doc/FIX-SHOWCREATE-PLUGIN-PROPS-summary.md b/plan-doc/FIX-SHOWCREATE-PLUGIN-PROPS-summary.md new file mode 100644 index 00000000000000..7faaf12b45e47a --- /dev/null +++ b/plan-doc/FIX-SHOWCREATE-PLUGIN-PROPS-summary.md @@ -0,0 +1,31 @@ +# FIX-SHOWCREATE-PLUGIN-PROPS — summary + +## Problem +`test_nereids_refresh_catalog`: `SHOW CREATE TABLE` on a JDBC external table emitted `LOCATION ''` + +`PROPERTIES(... "password"=... )` vs the committed `ENGINE=JDBC_EXTERNAL_TABLE;`. A correctness regression and a +JDBC credential leak. + +## Root Cause +Branch commit `98a73bf7692` (D-046 paimon parity) added LOCATION+PROPERTIES emission to the SHARED +`PLUGIN_EXTERNAL_TABLE` branch of `Env.getDdlStmt` (`Env.java:4929-4960`), gated only on `!properties.isEmpty()`. +JDBC/ES/Trino tables are plugin-driven with non-empty `getTableProperties()` (connection props incl. credentials), so +they wrongly got the paimon treatment; legacy was comment-only. + +## Fix +`Env.java`: gate the LOCATION+PROPERTIES emission additionally on +`TableType.PAIMON_EXTERNAL_TABLE.name().equals(pluginExternalTable.getEngineTableTypeName())` — only the paimon engine +type (the sole plugin-driven connector whose legacy DDL carried LOCATION/PROPERTIES) renders them. JDBC/ES/Trino/ +MaxCompute revert to comment-only; the credential leak is closed. Rejected rebaselining the `.out` (would entrench the +leak). + +## Tests +- Build: `-pl :fe-core -am compile` → BUILD SUCCESS; fe-core checkstyle clean. +- Adversarial review: VERDICT SOUND — verified paimon (+ its sys-table unwrap) still renders LOCATION/PROPERTIES; + jdbc/es/trino/maxcompute revert to comment-only matching committed `.out`; `getTableProperties()` has no other DDL + consumer (leak fully closed); only `"paimon"` maps to `PAIMON_EXTERNAL_TABLE`. +- E2E: `external_table_p0/nereids_commands/test_nereids_refresh_catalog` must pass unchanged against its committed + `.out` (CI external pipeline). + +## Result +Implemented + locally verified (compile/checkstyle/static review). Restores legacy plugin-table DDL and closes the +credential leak. diff --git a/plan-doc/HANDOFF-65185-reverify-fixes.md b/plan-doc/HANDOFF-65185-reverify-fixes.md new file mode 100644 index 00000000000000..6d3ccea6f6895e --- /dev/null +++ b/plan-doc/HANDOFF-65185-reverify-fixes.md @@ -0,0 +1,169 @@ +# 🔖 HANDOFF —— #65185 复核修复系列(独立任务线快照) + +> **用途**:这是**「复核修复系列」这一条任务线**的**独立**交接快照,与主线滚动 `plan-doc/HANDOFF.md`(HMS 翻闸主线)**分开**。你切去做别的实现后,回到这条线时**先读本文件**即可续做,不必炒对话历史。 +> **生成**:2026-07-11(更新 2026-07-12)。**分支** = `catalog-spi-11-hive`(off `branch-catalog-spi`,PR base = `branch-catalog-spi`,squash 合并)。**HEAD 快照** = `88aa55b831b`(本线 L1 提交;主线另有 HMS 翻闸提交穿插,与本线文件不重叠,fast-forward)。 +> **公开 tracking issue** = apache/doris#65185。 + +--- + +## 0. 这条任务线是什么 + +HMS 翻闸(catalog 类型 `hms` 从旧代码切到插件 SPI)后,第三方复核报告 +`plan-doc/reviews/catalog-spi-review-65185-reverify-2026-07-11.md` 判定为**真实/活跃且需改代码**的条目,按批次逐条修。 +**不含**已登记/验收偏差(reverify §5)与不适用/已修/parity(reverify §6)——那些**明确不做**(见跟踪表文末「明确不做」)。 + +**处理纪律(每条一遍)**:起步读本文件 + 跟踪表 → 选一条 → **对 HEAD 复核现码**(reverify 行号可能已漂)→ 设计 +(`plan-doc/tasks/designs/FIX--design.md`)→ **设计红队**(多 agent 对抗,见 memory `clean-room-adversarial-review-pref`) +→ 实现 → build + 靶向 UT → **独立 commit** → 勾跟踪表 → 更新本 HANDOFF + commit。**每条一个独立 commit**,code 与 doc 分开。 + +--- + +## 1. 起步必读(回来先读这几个,行号信 HEAD 不信文档) + +1. **跟踪表(权威进度 + 每条现码/fix/测试意图 + 每轮滚动更新)** = `plan-doc/task-list-65185-reverify-fixes.md`。 +2. **复核报告(证据/失败场景/对抗结论)** = `plan-doc/reviews/catalog-spi-review-65185-reverify-2026-07-11.md` + (§2 高危 / §3 中危 / §4 设计债 / §5 已登记偏差 / §6 不适用)。 +3. **各条设计** = `plan-doc/tasks/designs/FIX--design.md`(含 recon + 设计红队结论 + 最终改动清单)。 +4. **完成明细** = `git log`(commit message 详尽,勿在文档里重述)。 + +--- + +## 2. 当前进度(截至 HEAD `af1cb7e853a`) + +| 批次 | 内容 | 状态 | +|------|------|------| +| 批次 0 | H1–H4 高危(hudi/hive 分区剪枝:转义/DATETIME/非-hive-style/混大小写列名) | ✅ 全 DONE + 3-skeptic 复审 CLEAN | +| 批次 1 | M5/M7/M6/M4/M2 中危(连接器局部:iceberg×3 + mc + hive) | ✅ 全 DONE + 最终复核 CLEAN | +| 批次 2 | M3→M1 中危(fe-core 通用节点) | ✅ 全 DONE | +| **批次 3** | **L1(import 门禁) + M8(发布工具/文档)** | **✅ L1 DONE;M8 ⏸ 用户跳过(07-12)** | +| 批次 4 | 低危连接器 L3–L20(trino/kerberos/mc/paimon/iceberg) | 🔄 trino L3-L6 ✅ · kerberos L7/L8 ✅ · mc L9 ✅ · paimon L11/L13/L14 ✅;**← 续 iceberg/杂项 L15–L19** | +| 决策类 | ~~L2~~ ✅ `c9a86337906` · ~~L10~~ ✅ accept [DV-050] · **余 L12 / L20** | ⏸ L12/L20 先用中文讲清背景问用户再动 | +| 设计债 | D-系列 | ⏸ 择机 / 随 P8 | + +**批次 2 明细(本轮完成,含用户签字,勿再擅自翻)**: +- **M3** `6963de4124f`(code) + `97363fc9c33`(doc) —— batch 闸门 `shouldUseBatchMode` 的 `!isPruned` → `== NOT_PRUNED`: + 无谓词大分区表(MaxCompute + 翻闸 hive)恢复异步 batch split,**顺带解 M2 的 BATCH-UNPRUNED-SYNC**。 + 证据 = legacy `MaxComputeScanNode:227` 的 `!= NOT_PRUNED`(git `1da88365e85^`)+ sibling `displayPartitionCounts` + + 全 producer 枚举证闭合。设计红队 `wf_811e6242-d8b` 命中 1 blocker + 1 major 均已解。 + **⭐ 用户签字(2026-07-11)**:docker-hive golden `test_hive_partitions:200` `(approximate)inputSplitNum` **60→6** + ——采用 **SPI 统一分区数口径**(不给 hive 补 legacy split-count 估算;对齐 MaxCompute + Trino「引擎层统一报分片」)。 + **反转**被前次评审特意锁定的 pinning 测试;**登记 supersession**:`decisions-log` D-035 / `deviations-log` DV-019 + 的 LP-1「`!isPruned` 等价」判定已批注 SUPERSEDED。 +- **M1** `17b432dc1e1`(code) + `af1cb7e853a`(doc) —— 翻闸 hive 上 `TABLESAMPLE` 被静默丢弃(全表扫)。 + 设计红队 `wf_32decfa0-349` **推翻原「对所有连接器通用采样」方案(UNSOUND)**:`Split.getLength()` 语义因连接器而异 + (hive/iceberg=字节;MaxCompute 默认 byte_size / Paimon JNI range = **-1**;MaxCompute row_offset = **行数**)→ + 盲目按字节采样出乱结果。**scope 更正 = hive-only 回归**(只有 hive 曾采样)。 + **⭐ 用户签字(2026-07-11)**:**只修 hive** —— 加 `ConnectorScanPlanProvider.supportsTableSample()` 默认 false 能力 + opt-in、仅 `HiveScanPlanProvider` override true;通用节点仅在连接器声明支持时 `sampleSplits`(legacy `selectFiles` + 端口),不支持连接器 no-op + WARN(非静默)。对齐 Trino `applySample` + `supportsBatchScan` 先例。 + (新记 memory `catalog-spi-split-length-not-universal-byte-size`。) + +--- + +## 3. 批次 3 收尾 + 下一步 = 批次 4(低危连接器) + +**批次 3 结果**: +- **L1 DONE** `88aa55b831b`(code+test)—— import 门禁补 3 洞 + **设计红队 `wf_643c11b4-3fe` 发现的第 4 洞**: + 4 条白名单 `grep -v` 按**整行**匹配(正则 `.`≡`/`)→ 连接器命名空间文件(608 个,全根在 `org.apache.doris.connector.**`) + 里的违规 import 被**按路径抑制**,门禁对其本该守护的模块**结构性失明**(实测:连接器目录下放 `import ...catalog.Type;` + 旧脚本 exit 0 放行)。修法=候选 grep 加宽(static / test glob / +6 包)+ **白名单锚定到 import 目标**(`:import ...` + 非整行)+ fqn sed 剥 `static`(修 static-vendored 误报,红队证 E3=正确性非装饰)+ 新增自测 `check-connector-imports.test.sh` + (8 违规上报/2 vendored skip/3 allow;GREEN 于新、RED 于旧、真树 exit 0)。设计 `designs/FIX-L1-design.md`。**非 live**。 +- **M8 ⏸ 用户 2026-07-12 明确跳过**(转做 L 系列)。侦察留档:`build.sh:1069-1083` 已部署连接器插件到 + `output/fe/plugins/connector/`(**非构建缺口**);缺口在**升级流程**——只替 `fe/lib/` 漏拷新 `fe/plugins/connector/` 目录 + → replay 时全部 `type=hms` degraded(`CatalogFactory.java:119-127`)→首访抛(`PluginDrivenExternalCatalog.java:148-150`)。 + 修法=升级文档/release-note + (**可选**)replay 收尾聚合 degraded ERROR(触 fe-core,需编译)。**不 silently drop**,表中留待办; + 回来做时先中文讲清「仅文档 vs 文档+可选防御码」让用户拍板。 + +**批次 4 已完成子群**:trino `L3–L6` ✅ · kerberos `L7/L8` ✅ · mc `L9` ✅ · **paimon `L11/L13/L14` ✅(本轮)**。 +- **paimon 子群(L11/L13/L14)**:统一设计红队 `wf_05574ccb-bd2`(3 设计 × 3 lens = 9 agent,无 UNSOUND)。 + - `L11` `4a8650bd062` — JNI/COUNT range file_format 按首数据文件后缀取(legacy `getFileFormat(getPathString())` parity)+补 `.avro` 臂; + call-site RED 测(`Table.copy` 令表默认≠磁盘后缀,红队 MAJOR:原 helper 孤立测不守护接线)。 + - `L13` `ced4775b844` — `toPaimonType` 嵌套 nullability `.copy(isChildNullable)`(ARRAY/MAP-value/STRUCT-field); + **scope 仅 nullability**(comment=DV-035 M10.1 已接受、field-id 顺序 parity);`.copy(true)` 默认恒等,既有 parity 测保持绿。 + - `L14` `478718aca6f` — honor `ignore_split_type`(null-tolerant `resolveIgnoreSplitType` + 三 legacy continue 位; + `IGNORE_PAIMON_CPP` no-op=legacy parity,全树 grep 证);nonDataSplit IGNORE_JNI 位 E2E-only。 + - **⚠ 构建坑复现**:paimon 模块 `mvn test` 因 hive-shade 模块 shade 绑 `package` 阶段→`org.apache.hadoop.hive.conf` NoClassDefFound + **假失败**;改 `package` 阶段即绿。模块靶向 UT 全绿(scan 69/69 + type-mapping/schema-builder 26/26)、0 checkstyle、gate 净。 + +**下一步 = 批次 4 剩余(低危连接器/杂项)**:`L15–L19`(`L15` paimon `PAIMON_SCAN_METRICS` 悬空常量、`L16/L17` iceberg +快照/schema 缓存偏斜 + version-blind schema 绑定、`L18` iceberg 未知/v3 类型静默 UNSUPPORTED、`L19` `partition_columns` 魔法键撞名)。 +逐条走单任务循环(复核现码→设计→红队→实现→build+UT→独立 commit→勾表→更 HANDOFF)。 +⏸ **决策类 `L2/L10/L12/L20` 先中文讲清背景+选项问用户再动**(memory `ask-user-explain-in-chinese-first`)。设计债 D-系列择机。 +跟踪表「建议批次」节有全清单。 + +--- + +## 4. 铁律 / 约束(每条修复都受约束) + +- **fe-core 不得**新增 `if(hive/iceberg/hudi)` / `instanceof HMSExternal*` / `switch(dlaType)` / 源名判别;**不解析属性** + (storage→fe-filesystem、meta→fe-connector);通用 SPI 节点 connector-agnostic + (memory `catalog-spi-plugindriven-no-source-specific-code`、`catalog-spi-no-property-parsing-in-fecore`)。 + **按连接器区分能力用 `supports*()` opt-in**(非源名分支),如 `supportsBatchScan`/`supportsTableSample`。 +- **`Split.getLength()` 语义因连接器而异**(-1 / 行数 / 字节)——通用节点凡按 split 大小处理须能力 opt-in、禁假设字节大小 + (memory `catalog-spi-split-length-not-universal-byte-size`)。 +- 跨插件/跨边界**须 pin TCCL**(memory `catalog-spi-plugin-tccl-classloader-gotcha`,含 4 locus + HiveConf 构造点)。 +- `history_schema_info` 嵌套字段名逐层 lowercase(memory `catalog-spi-history-schema-info-lowercase-nested-names`)。 + +--- + +## 5. 构建 / 验证备忘(复用) + +- fe-core:`mvn -o -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -pl fe-core -am test-compile -Dmaven.build.cache.enabled=false` + (**漏 `-am`→假 `${revision}` 错**)。连接器:`-pl :fe-connector- -am`。SPI 改动(fe-connector-api)会被 + `-am` 连带 rebuild。 +- 靶向 UT 加 `-Dtest= -DfailIfNoTests=false`(`-am` + `-Dtest` 上游无匹配测试会报假「No tests were executed!」)。 +- **⚠ paimon 模块用 `install`/`package`**(shade jar 绑 package 阶段);hms/hive/hudi/iceberg/mc 无此坑。 +- **信 LOG 不信 exit**:后台 task 通知的 exit 是 wrapper 的;重定向到文件 grep `BUILD SUCCESS|BUILD FAILURE|[ERROR].*\.java:|Tests run:|You have N Checkstyle`。全量编译 ~6min → 后台跑。cwd 会重置 → 绝对路径。**勿用 worktree 隔离编译 agent**(`/mnt/disk1` 盘紧)。 +- 连接器测试**无 Mockito**(真 recording fake);**fe-core 有 Mockito**。checkstyle 禁 static import、扫 test 源、`UnusedImports` fail build。 +- `bash tools/check-connector-imports.sh` 须 exit 0(连接器不得 import fe-core)。 +- **memory** `doris-build-verify-gotchas`、`catalog-spi-fe-core-test-infra`。 + +--- + +## 6. 提交 / 工作树纪律 + +- **path-whitelist `git add`,严禁 `git add -A`**:工作树大量遗留 scratch(`regression-test/conf/regression-conf.groovy` + 明文 key【本轮它被 build 改动过、勿提交】、`*.bak`、`.audit-scratch/`、`conf.cmy/`、`META-INF/`、`docker/...`、 + `plan-doc/reviews/P5-*`、`.claude/`、`failed-cases.out`——**非本线程产物,勿混入任何 commit**)。 +- commit message:`[fix|doc](catalog) …` + 末尾 `Co-Authored-By: Claude Opus 4.8 (1M context) ` + + `Claude-Session: …`。**每子步/每条 fix = 独立 commit**;设计文档 + HANDOFF 单独 commit(与 code 分开)。 +- 上下文超 30% 找干净节点交接(memory `session-handoff-at-30pct-context`)。 + +--- + +## 7. ⚠ 并行 session 风险(本轮真实踩过) + +本工作树是 linked worktree、**多 session 共享同一分支 + 同一构建 target**。本轮: +- 另一 session(你自己,`morningman`)在批次 2 期间往同分支提交了 2 个 hive 改动(`4dbb8e02056` 回归文档、 + `99e0b4a6ade` HiveConf classloader 修复,改 `fe-connector-hms/HmsConfHelper.java`)——与本线程文件**不重叠**、无覆盖。 +- 另一 session 的 `mvn ... be-java-extensions package -am -T 1C` **污染共享 target**,一度令本地 UT 报 + 「cannot access 生成类」**假失败**(非本码问题;待其结束干净重跑即绿)。 +- **起步/动码前先探**:`git log`/`git status`、运行中 maven(`ps aux | grep plexus.classworlds.launcher`)、近 90s mtime; + 发现活跃即优先只写新文件 + 小步快提交;build 假失败先排查并发污染再怀疑本码(memory + `concurrent-sessions-shared-worktree-hazard`)。 + +--- + +## 8. e2e 欠账(用户自跑,勿丢,非静默) + +**批次 0/1/2 所有 e2e 均 live-gated(真集群)**,回归清单: +- 批次 0:H1–H4(转义值 / DATETIME / 非-hive-style 带 filter / MOR-JNI 混大小写读)。 +- 批次 1:equality-delete 统计 UNKNOWN / vended-region / s3tables 默认凭证链 / mc 分区缓存往返 / hive 大分区异步 split。 +- **批次 2(本轮新增)**:M3 = docker-hive `test_hive_partitions` `(approximate)inputSplitNum=6` + MaxCompute 无谓词 + ≥阈值分区表进 batch(结果与同步逐行一致);M1 = docker-hive `test_hive_tablesample_p0` 结果不变式(**强基数缩减 + sample fe-core UT 仍未覆盖(本文档只含 fe-connector 层)。如需 fe-core 失败清单要另跑一轮。 + +--- + +## 背景 / 怎么发现的 + +在排查 TeamCity external regression(build 984925,PR #64689)时,为了验证本会话的连接器修复, +对**全部 17 个 `fe-connector/*` 模块跑了一轮全量 UT**。关键点: + +- **Doris 的 maven build-cache 会按 checksum 恢复模块、跳过测试执行**,长期**掩盖**了这些失败 + (日常 `mvn test` 命中缓存 → 报 SUCCESS 但没真跑)。 +- 必须**关缓存**才会暴露。且 `test` 阶段早于 `package`,`*-hive-shade` 的 shaded jar 只在 + `package` 阶段生成,所以要用 `package` 才能编译过 paimon/iceberg。 + +**复现命令**(关缓存 + 全量 + 不 fail-fast): + +```bash +cd /mnt/disk1/yy/git/wt-catalog-spi +mvn -o -f fe/pom.xml \ + -pl fe-connector/fe-connector-api,fe-connector/fe-connector-spi,fe-connector/fe-connector-cache,\ +fe-connector/fe-connector-metastore-api,fe-connector/fe-connector-metastore-spi,\ +fe-connector/fe-connector-metastore-iceberg,fe-connector/fe-connector-metastore-paimon,\ +fe-connector/fe-connector-iceberg,fe-connector/fe-connector-paimon,fe-connector/fe-connector-paimon-hive-shade,\ +fe-connector/fe-connector-hms,fe-connector/fe-connector-hive,fe-connector/fe-connector-hudi,\ +fe-connector/fe-connector-es,fe-connector/fe-connector-jdbc,fe-connector/fe-connector-trino,\ +fe-connector/fe-connector-maxcompute -am \ + -Dmaven.build.cache.enabled=false -Dmaven.test.failure.ignore=true \ + package +``` +> ⚠️ 后台跑时**不要信 task 通知的 "exit 0"**——那是 echo 的、不是 maven 的。读日志里的 +> `BUILD SUCCESS/FAILURE` 行 + 盯 maven PID(`tail --pid= -f /dev/null`)才是真结束。 +> 单个失败测试可复现:`-Dtest='' -DfailIfNoTests=false`(仍需 `package` + 关缓存)。 + +## 结论 + +- **只有 iceberg 相关的 2 个模块失败**(`fe-connector-metastore-iceberg`、`fe-connector-iceberg`), + 共 **6 个失败测试**。其余 15 个模块(paimon/hms/hive/hudi/es/jdbc/trino/maxcompute/cache/api/spi/ + metastore-{api,spi,paimon}/paimon-hive-shade)**全绿**。 +- 这 6 个**全部是 pre-existing**,与本会话的 4 个提交(`1e3a5fd` #1、`361d2dc` #2、`b9381ad`/`950a0cc` + #3)**无关**——已用 `git stash` 掉 #3 后同样条件重跑,6 个失败**逐行完全一致**;#1/#2 也不碰这些文件。 +- **未覆盖 `fe-core`**(体量大、另一条路径)。如需 fe-core UT 失败清单要另跑一轮。 + +--- + +## 明细(含 file:line 与修法) + +### A. `fe-connector-metastore-iceberg` — 1 个【确定性·真 bug】 + +**`IcebergMetaStoreProvidersDispatchTest.hadoopAndS3TablesAreNoOpValidate`** +- 测试:`fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/IcebergMetaStoreProvidersDispatchTest.java:71` + ```java + bind("hadoop").validate(); // 期望不抛;实际抛 IllegalArgumentException + ``` +- 生产:`.../metastore/iceberg/noop/IcebergNoOpMetaStoreProperties.java:55-63` —— `validate()` 对 + `providerName=="HADOOP"` 且 warehouse 为空时**抛异常**: + `"Cannot initialize Iceberg HadoopCatalog because 'warehouse' must not be null or empty"`。 +- 根因:commit `935e4fb9d80`([fix](catalog) P6.6 M-1 恢复 hadoop iceberg warehouse 必填校验) + 加回了该校验,但**旧测试仍断言 hadoop 的 `validate()` 是 no-op(不抛)**——代码/测试冲突。 + 测试方法名/注释("The no-op backends exist only so bindForType resolves; validate() must not throw.") + 已过时。 +- 修法(二选一,倾向前者): + 1. **改测试**:给 hadoop case 传一个 warehouse(如 `'warehouse'='hdfs://x/wh'`)再 `.validate()`; + 或把 hadoop 拆出单独断言"缺 warehouse 抛、有 warehouse 过",s3tables 保持 no-op 断言。 + 2. 若认为 hadoop 不该在此层校验 → 撤 `935e4fb9d80` 的校验(**不推荐**,那是有意恢复的 legacy 行为)。 + +### B. `fe-connector-iceberg` — 3 个【确定性·真 bug(测试传 null session)】 + +**`IcebergScanPlanProviderTest.planScanManifestCacheEnabledMatchesSdkPathAndConsumesCache:1584`** +**`IcebergScanPlanProviderTest.planScanManifestCachePrunesPartitionLikeSdk:1610`** +**`IcebergScanPlanProviderTest.planScanManifestCacheAssociatesDeletesLikeSdk:1674`** +- 异常:`NullPointerException: Cannot invoke "...ConnectorSession.getQueryId()" because "session" is null` +- 测试:`.../fe-connector-iceberg/src/test/.../IcebergScanPlanProviderTest.java`(上述 3 行附近) + 都用 `manifestProvider(...).planScan(null, handle, ...)` —— **session 传 null**。 +- 生产:`.../fe-connector-iceberg/src/main/.../IcebergScanPlanProvider.java` 的 **manifest-cache 路径** + 会解引用 session:`:1091`(`props.put(MANIFEST_CACHE_QUERYID_PROP, session.getQueryId())`)、 + `:1283`(`manifestCache.recordFailure(session.getQueryId())`)、`:1306`(`session.getQueryId()`)。 +- 根因:该测试类里有 10 处 `planScan(null, ...)`,但**只有开启 manifest-cache 的这 3 个**会走到 + `session.getQueryId()` 分支 → NPE;其余非 cache 路径不碰 session 所以过。属**测试 bug**: + manifest-cache 用例必须传非 null session。 +- 修法:给这 3 个用例传一个 stub/mock `ConnectorSession`,其 `getQueryId()` 返回一个固定串 + (如 `"test-query-id"`)。参考同模块已有的 ConnectorSession 构造/stub 方式(搜 `ConnectorSession` 的 + 其它测试用法;无 Mockito,可手写一个只覆写 `getQueryId()` 的匿名/内部类)。 + (备选:生产代码对 `session==null` 兜底——**不推荐**,会掩盖"cache 路径必须有 queryId"的契约。) + +### C. `fe-connector-iceberg` — 2 个【先判真伪:疑似时区敏感】 + +**`IcebergPredicateConverterTest.binaryEqGridMatchesLegacy:130`** +**`IcebergPredicateConverterTest.inAndNotInGridMatchLegacy:147`** +- 断言失败:`EQ/IN grid mismatch at column c_ts literal#11 (2023-01-02) ==> expected: but was: ` +- 测试:`.../fe-connector-iceberg/src/test/.../IcebergPredicateConverterTest.java` + - 列定义 `:66`:`c_ts = Types.TimestampType.withoutZone()`(**TIMESTAMP WITHOUT ZONE**)。 + - literal `:77`:`ConnectorLiteral(ConnectorType.of("DATETIMEV2"), ...)`。 + - 用例对每个 (列 × literal) 生成一个"是否匹配"网格,和 legacy 期望网格逐格对比。 +- 根因(待确认):只有 **c_ts(timestamp)** 这列错,`expected false 实际 true` —— 新转换器把 + DATETIMEV2 literal 转成 iceberg timestamp 的 **epoch micros 值**与 legacy 不一致,导致某些格的匹配 + 结果翻转。**强烈怀疑是本地时区把 `withoutZone` 的 datetime 当成本地时刻转 micros**(withoutZone + 本不该套 TZ)。poms 里**没找到** `-Duser.timezone` 固定,用的是运行机默认 TZ。 +- 下个 session 先做**真伪判定**: + ```bash + # 本地用 UTC 复跑这两个用例,若变绿 => 时区特异(CI 若 UTC 则本不失败) + TZ=UTC mvn -o -f fe/pom.xml -pl fe-connector/fe-connector-iceberg -am \ + -Dmaven.build.cache.enabled=false -Dmaven.user.timezone=UTC \ + -Dtest='IcebergPredicateConverterTest' -DfailIfNoTests=false package + # 或在 surefire argLine 里加 -Duser.timezone=UTC + ``` +- 修法(视判定结果): + - 若确为时区特异且 CI 在 UTC 下通过 → 要么给该测试/surefire **pin `-Duser.timezone=UTC`**(对齐 CI), + 要么让 `IcebergPredicateConverter` 对 `TimestampType.withoutZone()` **不套本地 TZ**(用 UTC/无偏移) + 转 micros,与 legacy 一致。 + - 若 UTC 下仍错 → 是真的 new-vs-legacy 转换差异,需对齐 `IcebergPredicateConverter` 的 + timestamp-without-zone → micros 逻辑到 legacy。 + +--- + +## 建议优先级 + +1. **确定性、易修**(4 个):B 组 3 个 NPE(补非 null session)+ A 组 1 个(测试给 warehouse / 拆断言)。 +2. **先判真伪再动**(2 个):C 组 `IcebergPredicateConverterTest`(先 `TZ=UTC` 复跑定性)。 + +## 注意事项 + +- 改完用**关缓存**的方式复验(否则 build-cache 会再次掩盖)。 +- 这些属 **FE UT 流水线**,与触发本次排查的 **external regression(docker)** 是两条线。 +- 本会话的 4 个已提交修复(#1/#2/#3 iceberg+paimon)**不引入任何新失败**,可独立推进。 diff --git a/plan-doc/HANDOFF.md b/plan-doc/HANDOFF.md new file mode 100644 index 00000000000000..fac08121d87a6f --- /dev/null +++ b/plan-doc/HANDOFF.md @@ -0,0 +1,82 @@ +# 🤝 Session Handoff + +> **滚动文档**:每次 session 结束**覆盖式更新**,**只保留下一个 session 必须的上下文**;完成的工作明细**不落这里**(在 `git log` + `tasks/` 设计文档里)。协作规范:[AGENT-PLAYBOOK.md](./AGENT-PLAYBOOK.md)。 +> **范围** = 修 TeamCity **CI 997422**(Doris_External_Regression)的失败用例。 + +--- + +# 🆕 下一个 session = **重跑 CI 验证本轮 3 个修复** + +> **本轮任务** = TeamCity `Doris_External_Regression` **#997422**(PR 65474 @ `6a450c9fa79`) +> **10 failed + 2 muted**(occurrence 口径 12)。 +> **权威文档**:根因分析 = [`tasks/ci-997422-failure-analysis.md`](./tasks/ci-997422-failure-analysis.md)(18-agent recon + 3-lens 对抗复核 + 本人独立复核;每条证据带 file:line / 日志行号 / 实测数字)。 + +## 🔑 定性:**不是集群故障**,别去查宕机/OOM + +BE 单次启动、优雅退出(`be.out` 仅退出时 LSAN leak summary,零 `SIGSEGV`/`SIGABRT`/`CHECK failed`)· `dmesg.txt` **无 OOM-killer**(失败时宿主机余 **19.03GB**)· 551 通过。 +**12 个失败 = 4 个独立根因**(A+B / C / D / E),**A+B 是同一个 bug、占 9 个**。 + +## ✅ 上一轮(996541)的修复已被本轮 e2e 验证生效 —— **不是回炉** + +`test_iceberg_time_travel`、`iceberg_branch_complex_queries`、`paimon_system_table`、`test_catalogs_tvf`、6 个 spec 演进用例**本轮全部未再出现**。本轮即上一版 HANDOFF 要求的 TODO 9(`4f8b35c2126` 的 e2e),**它验出了 `4f8b35c2126` 自己引入的回归**。 + +⚠️ **易混点**:`bd6fdf7009a` 修的是 `__DORIS_GLOBAL_ROWID_COL__`(topn lazy-mat 合成列);本轮 A+B 是 `__DORIS_ICEBERG_ROWID_COL__`(iceberg 写路径合成列)。**两个不同的列、不同的 bug**,勿当同一个反复修。 + +--- + +## ✅ 已交付(3 个可修根因,各自独立 commit + 变异验证) + +| commit | 根因 | 修的用例 | 守门 | +|---|---|---|---| +| `35cf72cce91` | **A+B** 计划路径丢连接器合成写列(`4f8b35c2126` 把 `LogicalFileScan:223` 改调**无人 override** 的 1-arg `getFullSchema(Optional)`) | **9 个** iceberg DML / hidden-column | 38/38;变异(删 1-arg override)红在 `expected:<3> but was:<2>` = CI 症状本身;相关 92/92;checkstyle 0 | +| `a4cba35725c` | **D** paimon shade 缺 `hive-serde` ⇒ `serdeConstants`(`9a10ece30c8` 删 hive-catalog-shade 触发 `c276e955683` 的潜伏洞) | `test_create_paimon_table` | 基线 jar serdeConstants=0 → 修后=1;**classload 冒烟**跑通 ``;**plugin zip 端到端**加载成功且全 zip 恰好 1 份 | +| `6320389dc06` | **C** L17 guard 对 sys-table 是范畴错误(`270bd11f4da` 知情延期,**延期前提为假**) | `test_iceberg_position_deletes_sys_table` | 11/11;双变异(删排除→1 红;放宽到父类→5 红);相关 62/62;checkstyle 0 | + +**E(muted,`test_hdfs_parquet_group0`)= 有意不修**:上游 `51e44133b1d` 的 `mem_limit=35%` + 一个真含 2.000GiB 字符串列的上游 fixture(footer 实测 `total_uncompressed_size=2,147,483,749`=2³¹+101)⇒ PODArray 2GiB→4GiB 增长。`git merge-base --is-ancestor 51e44133b1d master` = **YES** ⇒ **master 同样复现**。无真 OOM、无泄漏、非过期用例。在本分支改那个 conf = 静默 revert 上游决定并掩盖真回归。**保持 mute + 记录理由**(理由全文见分析文档 E 节)。 + +--- + +# ⏭ 下一个 session 要做的 + +1. **重跑 CI(唯一真闸门)**。预期:A+B 的 9 个解开列数断言、C 解开 init、D 的 paimon HMS 恢复。 +2. **⚠️ C 很可能需要第二轮**:去掉抛出只解开 init。真正绿还需 iceberg `$position_deletes` planner 认这个 pin(`doPlanPositionDeletesSystemTableScan` 读 `handle.hasSnapshotPin()` ← `IcebergConnectorMetadata.applySnapshot` 喂)—— **已 trace 未执行**。且该 suite `:562-568`(源表跨 ADD COLUMN 时间旅行)在本分支**从未跑过**。 +3. **⚠️ A+B「修完就绿」未证**:v1 suite 原在 `:98` abort,其后 `:101` "row-id column must be populated" 与 v3 取值断言**在本分支从未执行过**。本改动只保证列回到输出。 +4. **待用户裁决**:`scannedPartitionCount` 在 `$position_deletes` 上触发(= 旧 HANDOFF gap ③ 的后半、`PluginDrivenScanNode:1213`)—— 与 2026-07-13 `selectedPartitionNum` 签字冲突,**本轮故意未打包**,需先裁决。 +5. **勿顺手修的潜伏洞**:A+B 的合成 row-id `uniqueId = -1`(`IcebergWritePlanProvider.buildRowIdColumn` 6-arg ctor;`ConnectorColumnConverter:89-91` 只回填 `>= 0`)⇒ 列回到输出后 L17 guard 退化成 name 匹配、无法在 pinned schema resolve。**今天不可达**(pinnedSchema 仅显式时间旅行非空,且无用例组合 show_hidden/DML + `@tag`/`@branch`/`FOR..AS OF`)。若要修,**按通用属性(schema-cache 来源)判,绝不按 iceberg 列名**。 + +--- + +# 🧰 构建/验证坑(本轮实测,下轮直接复用,别再踩) + +1. **maven build cache 会静默跳过 surefire** —— 日志 `Skipping plugin execution (cached): surefire:test`,此时 **BUILD SUCCESS 是空的**(surefire 报告是上次的陈旧文件)。**所有测试必须加 `-Dmaven.build.cache.enabled=false`**。本轮第一次跑就中招(BUILD SUCCESS 但 0 测试真跑)。 +2. **`mvn ... | tail` 后的 `$?` 是 `tail` 的**,不是 maven 的 —— 重定向到文件再取 `$?`,或读 `BUILD SUCCESS`/`BUILD FAILURE` 行。 +3. **`surefire:test` 独立 goal 解析不了 `${revision}`** ⇒ 必须走 `test` 生命周期 + `-am`;上游模块无匹配测试时加 `-DfailIfNoTests=false`。 +4. **`hive-serde` 闭包首次需联网**(`javax.servlet:servlet-api:2.4` 不在本地仓),`-o` 会失败。 +5. **`-Dtest='org.apache.doris.datasource.**'` 全包 sweep > 10min**,会被 shell 超时砍;用具体类名清单。 +6. **`regression-test/conf/regression-conf.groovy` 工作区本就是脏的**(session 开始前即 `M`)—— 三个 commit 均未包含它,**别顺手 `git add -A`**。 +7. **`pgrep maven` 可能查到 1 天前的僵尸 until-loop**(本轮见 PID 843896,etime `1-01:58`,在轮询 Jul 15 就跑完的日志)—— **看 `etime` 再判定是否真并发**,别误判成活跃 session 而无谓停手。 + +--- + +# 🗄 被本次覆盖的旧上下文(catalog-spi 主线:删旧代码 / rebase / trino / QUIC 瘦身) + +按用户 2026-07-15 指示,本文件已用 CI 任务上下文**完全覆盖**。**旧内容完整保存在 `8eb5463f769:plan-doc/HANDOFF.md`**(`git show 8eb5463f769:plan-doc/HANDOFF.md`)。其中**仍未结项、需要时去那里捞**的条目: +① 删除线 PR 收尾(拓扑多 commit → 最终 squash)+ 用户自跑翻闸 hms 全量回归; +② e2e 欠账矩阵(`tasks/hms-cutover-execution-plan-2026-07-10.md §4/§5`)+ 继承自上游的 `$position_deletes` e2e 翻闸门(**本轮 C 即其中一项,已修待验**); +③ rebase 引入的 2 个集成缺口(`IcebergScanPlanProvider:1419` 丢 `enable.mapping.timestamp_tz`;`scannedPartitionCount` 对 `$position_deletes` 触发,语义待用户拍板 = 上面第 4 点); +④ trino 改名 PR 收尾两笔(**需 release note**;BE 未跑全量构建 + fallback 无 e2e); +⑤ 独立任务空间 `plan-doc/hive-catalog-shade-removal/`(**从它自己的 HANDOFF 进**); +⑥ 并发 session 已结项的 QUIC 根治(`ae82ffd2573`)+ 插件包瘦身 Tier A(`dece64b9ff5`)明细。 + +--- + +# 📎 并行独立任务(与上面 CI 线无关):热路径重操作审计(DORIS-27138 问题类) + +> 2026-07-17 独立调研,session 自包含,不影响 CI 线。用户待 review。 + +- 问题类总结(三要素 + A/B/C/D 变体 + 审计清单):`plan-doc/perf-heavy-op-hot-path-problem-class.md` +- **fe-connector-iceberg 审计报告**(23 确认/1 驳回,分 P0/P1/P2 三层七簇):`plan-doc/reviews/perf-audit-fe-connector-iceberg-2026-07-17.md` +- 完整证据 JSON(全部调用链+双路对抗验证意见):`plan-doc/reviews/perf-audit-fe-connector-iceberg-2026-07-17-findings.json` +- **P0 三簇**:①无 Table 对象缓存,一次规划 3~7 次远程 loadTable;②#64134 planFiles 兜底复活(`IcebergWriterHelper.getFileFormat`,每查询 1-2 次整表扫);③分区表每查询一次 PARTITIONS 元数据表扫描(CACHE-P1 弃二级缓存的代价)。 +- 旁获(与审计无关待单独处理):`CreateDictionaryInfo.validateAndSet:164` 强转 `catalog.Table` ⇒ 外表 CREATE DICTIONARY 必 ClassCastException(功能缺口/潜在 bug)。 +- 下一步(等用户 review 后):其余连接器(hive/paimon/hudi/mc)按同一问题类+同一 workflow 模式逐个审计。 diff --git a/plan-doc/P6.6-C6-oidc-session-migration-design.md b/plan-doc/P6.6-C6-oidc-session-migration-design.md new file mode 100644 index 00000000000000..b3d4176fd4ec58 --- /dev/null +++ b/plan-doc/P6.6-C6-oidc-session-migration-design.md @@ -0,0 +1,162 @@ +# Design Doc — Re-migrate #63068 (Iceberg REST OIDC session credentials) onto the catalog SPI + +**Status:** DRAFT for review. **No code written.** Awaiting approval before implementation. +**Author:** (catalog-SPI workstream) · **Date:** 2026-07-10 · **Branch:** `branch-catalog-spi` +**Companion:** research notes in `plan-doc/P6.6-C6-oidc-session-migration-research-notes.md` (Trino reference + #63068 as-built + current-SPI injection points, all source-verified). + +--- + +## 1. Goals +- Restore the **per-user Iceberg REST session-credential** feature that upstream `#63068` (`e545f1ad08a`) added and that the 2026-07-09 P6 rebase dropped (its iceberg consumer collided with the fe-core→SPI move). +- Do it **on the P6 catalog-SPI architecture** (iceberg lives in `fe/fe-connector/fe-connector-iceberg`), not the old fe-core tree. +- Be **aligned with Trino's actual model**: the per-query credential rides the connector session; the connector alone decides per-user vs shared; the iceberg integration seam is `org.apache.iceberg.catalog.SessionCatalog.SessionContext` (fixed by iceberg, shared with Trino). +- Preserve behavioral parity with #63068's semantics (fail-closed when `session=user` and no credential; per-user cache bypass; `access_token` + `token_exchange` modes). + +## 2. Non-goals +- No change to the **retained generic base** (MySQL-auth capture, `ConnectContext`/`SessionContext`/`DelegatedCredential`, FE-forward thrift 1004-1007, `ConnectProcessor`/`FEOpExecutor`). It already works (research note A7) — we build on it. +- No **BE** changes (this is FE metadata/auth only). +- Not adding a Trino-style **signed subject JWT** (`sub=`) in this pass — #63068 passes the OIDC token verbatim; Trino additionally signs. We match #63068 and record signing as a future Trino-parity item (Decision D6). +- No new credential *ingress* channels beyond what the retained MySQL-auth path already captures. +- Non-REST iceberg catalogs (hive/glue/hadoop metastore) and all other connectors (paimon/jdbc/es/maxcompute) are untouched at runtime. + +## 3. Constraints +- **User directive:** FE-only, align with Trino, design-doc-first (this doc) before implementation. +- **Module boundary:** `fe-connector-iceberg` **must not import fe-core.** The credential must cross into the connector through a **neutral SPI type**, never fe-core `DelegatedCredential`/`SessionContext`. +- **Byte/behavioral parity** with #63068 for the fail-closed and cache-bypass semantics (they are security-relevant). +- **Iceberg seam is fixed:** we depend on the same `iceberg-core` `RESTSessionCatalog` + `SessionCatalog.SessionContext(sessionId, identity, credentials, properties, wrappedIdentity)` that Trino uses. + +## 4. Background (one paragraph) +`#63068` captured a user's OIDC/JWT/SAML token at MySQL login into `ConnectContext.sessionContext` (a `DelegatedCredential`), forwarded it across FE nodes via thrift, and — at iceberg metadata time — built an iceberg `SessionContext` and routed through a per-user `RESTSessionCatalog` instead of the shared catalog, while bypassing the shared FE name caches. All of that credential *plumbing* is **generic and still present** on this branch; only the **iceberg consumer** (3 classes + routing + REST props + cache-bypass hooks) was deleted, and it was written against the pre-P6 fe-core iceberg tree. + +--- + +## 5. Architecture overview + +Three layers. The credential travels: **retained base → SPI session → connector**. + +``` + (RETAINED, unchanged) (NEW: FE bridge) (NEW/RE-MIGRATED: connector) + MySQL auth → DelegatedCredential fe-connector-iceberg + → ConnectContext.sessionContext ──► ConnectorSessionBuilder ──► ConnectorSession + (thrift-forwarded across FE) .from(ctx) copies cred .getDelegatedCredential() (neutral DTO) + into neutral SPI DTO │ + ▼ + IcebergSessionCatalogAdapter (re-migrated) + .toIcebergSessionContext(cred, mode) + → org.apache.iceberg…SessionCatalog.SessionContext + │ + ▼ + shared RESTSessionCatalog.asCatalog(ctx)/asViewCatalog(ctx) + │ + ▼ + Iceberg REST server (per-user authz + vended creds) + (RETAINED hook, re-added) ExternalCatalog.shouldBypassTableNameCache(ctx) ⇒ per-user requests skip shared FE caches +``` + +**Key adaptation vs #63068:** the decision + routing move from fe-core `IcebergMetadataOps`/`IcebergUtils` (which read `SessionContext.current()` off a thread-local) into the **connector**, driven by the **credential carried on `ConnectorSession`** (injected once in `buildConnectorSession`). This is both P6-correct (iceberg is now an SPI connector) and Trino-aligned (Trino reads `session.getIdentity().getExtraCredentials()`). + +--- + +## 6. Design decisions (please review) + +### D1 — Opt-in mechanism: **SPI capability flag `SUPPORTS_USER_SESSION`** (recommended) vs Trino's config-only +- **Trino:** no capability flag; every connector universally receives `extraCredentials`; opt-in is purely the iceberg connector's `session=user` config. +- **#63068 / memory plan:** an explicit gate. +- **Recommendation: add `ConnectorCapability.SUPPORTS_USER_SESSION`**, declared by the iceberg connector only when `iceberg.rest.session=user`. Rationale: (a) matches the established Doris pattern (every other cross-cutting behavior — `SUPPORTS_VIEW`, `SUPPORTS_SHOW_CREATE_DDL`, … — is a capability replacing a legacy `instanceof`); (b) **least-privilege**: the FE injects the user's delegated credential into a `ConnectorSession` *only* for connectors that declare they consume it, so a JDBC/ES/hive-iceberg session never carries the OIDC token it would never use (a security improvement over Trino's universal pass-through). The capability gates **FE injection**; the connector's `iceberg.rest.session` config still governs per-user-vs-shared *within* iceberg. Net: capability = "may receive a credential"; config = "how iceberg uses it". +- *If you prefer strict Trino parity* we drop the flag and always inject; call it. + +### D2 — Where the credential rides: **on `ConnectorSession`** (recommended) vs threading `SessionContext` through methods +- **Recommendation: put it on `ConnectorSession`** and inject once in `PluginDrivenExternalCatalog.buildConnectorSession()` (the single funnel for ~25 call sites). This is Trino's exact shape (`ConnectorSession.getIdentity().getExtraCredentials()`) and avoids #63068's per-method `SessionContext` overloads (which made sense in fe-core but would pollute the whole SPI surface). All connector ops already receive a `ConnectorSession`. + +### D3 — Neutral SPI credential DTO +- Add `ConnectorDelegatedCredential` in `fe-connector-api` (fields mirror fe-core `DelegatedCredential`: `Type {ACCESS_TOKEN, ID_TOKEN, JWT, SAML}`, `String token`, `OptionalLong expiresAtMillis`). `ConnectorSession.getDelegatedCredential(): Optional`. fe-core maps its `DelegatedCredential` → this DTO in the builder. The connector maps this DTO → iceberg OAuth2 keys (re-migrated `IcebergDelegatedCredentialUtils`). + +### D4 — Session routing location: **the connector** (recommended) +- Re-migrate `IcebergUserSessionCatalog` (capability seam) + `IcebergSessionCatalogAdapter` (Doris↔iceberg bridge) + `IcebergDelegatedCredentialUtils` into `fe-connector-iceberg`. The connector's catalog-ops layer (today `CatalogBackedIcebergCatalogOps`, wrapping one shared `Catalog`) becomes session-aware: for a REST flavor with `session=user` + a per-request credential, it builds the iceberg `SessionContext` and calls the shared `RESTSessionCatalog.asCatalog(ctx)`/`asViewCatalog(ctx)`; otherwise the existing shared path. **The `RESTSessionCatalog` stays a single shared instance** (never one-per-user) — exactly Trino and #63068. + +### D5 — Cache bypass: keep #63068's model, driven by the SPI capability + credential +- Re-add `ExternalCatalog.shouldBypassTableNameCache(SessionContext)` (default false) + the `ExternalDatabase` live-path rerouting. On the current SPI, the REST catalog is a `PluginDrivenExternalCatalog`; the override returns true when the connector declares `SUPPORTS_USER_SESSION` **and** the session carries a credential. Under `session=user` the REST server returns per-user metadata, so the shared (catalog+name-keyed, not user-keyed) db/table/view caches must be bypassed to avoid cross-user leakage. + +### D6 — Token handling: `access_token` (verbatim bearer) + `token_exchange` (typed key); **no signed subject JWT** this pass +- Match #63068 exactly (`iceberg.rest.oauth2.delegated-token-mode`). Trino additionally mints a `sub=` JWT in `session=user`; that matters only if the REST server must trust a *Doris-asserted* identity rather than the client's own OIDC token. Record as a **future Trino-parity follow-up**, out of scope here. + +--- + +## 7. Detailed design + +### 7.1 SPI layer — `fe-connector-api` +- **`ConnectorDelegatedCredential`** (new): immutable DTO. `enum Type { ACCESS_TOKEN, ID_TOKEN, JWT, SAML }`; `getType()`, `getToken()`, `getExpiresAtMillis(): OptionalLong`, `isExpired(now)`. +- **`ConnectorSession`** (edit): `default Optional getDelegatedCredential() { return Optional.empty(); }` + a stable `getSessionId(): String` if not already derivable (needed as the iceberg `SessionContext.sessionId()` — the AuthSession cache key; must equal the forwarded id). *(Confirm whether `getQueryId()` suffices or a session-scoped id is required — #63068 used the forwarded `SessionContext.sessionId`.)* +- **`ConnectorCapability`** (edit): add `SUPPORTS_USER_SESSION` (javadoc: gates FE injection of the per-user delegated credential; declared by iceberg-REST connectors configured `session=user`). + +### 7.2 FE bridge — `fe-core` +- **`ConnectorSessionBuilder.from(ConnectContext)`** (edit): if the target connector declares `SUPPORTS_USER_SESSION`, read `ctx.getSessionContext().getDelegatedCredential()`, map fe-core `DelegatedCredential` → `ConnectorDelegatedCredential`, and set it (plus the `SessionContext.getSessionId()`) on the built `ConnectorSession`. One place; covers all ~25 `buildConnectorSession()` call sites. (Capability check needs the connector/capabilities at build time — `PluginDrivenExternalCatalog` has the `Connector`; thread the capability set or a boolean into the builder.) +- **`ExternalCatalog.shouldBypassTableNameCache(SessionContext)`** (re-add, default false) + **`ExternalDatabase`** live-path rerouting (`getTableNamesWithLock`/`getTableNullable`/`isTableExist` → no-cache when bypassing; thread `updateTableNameLookup=false`). `PluginDrivenExternalCatalog` overrides `shouldBypassTableNameCache` to `capabilities.contains(SUPPORTS_USER_SESSION) && ctx.hasDelegatedCredential()`. Database-level bypass (the REST-catalog `getDbNames`/`getDbNullable` live paths, re-appending `information_schema`+`mysql`) is iceberg-REST-specific; in the SPI world it belongs on the plugin-driven catalog when the connector is session-aware — **open question O2** (below). + +### 7.3 Connector consumer — `fe-connector-iceberg` +- **`IcebergConnectorProperties`** (edit): add `iceberg.rest.session = none|user` (default none), `iceberg.rest.oauth2.delegated-token-mode = access_token|token_exchange` (default access_token), optional `iceberg.rest.session-timeout`. Validation: `session=user` ⇒ `security.type=oauth2`; relax the "oauth2 requires token/credential" rule for user-session. When `session=user`, the connector reports `SUPPORTS_USER_SESSION` from `Connector.getCapabilities()`. +- **Catalog build** (edit `IcebergCatalogFactory`/`IcebergCatalogOps`): build an iceberg **`RESTSessionCatalog`** for REST flavor (not `RESTCatalog`), default-bound via `asCatalog(SessionContext.createEmpty())`, so per-request `asCatalog(ctx)` is available. +- **`IcebergDelegatedCredentialUtils`** (re-migrate): `credentialKey(ConnectorDelegatedCredential.Type)` → `OAuth2Properties.{TOKEN, ID_TOKEN_TYPE, JWT_TOKEN_TYPE, SAML2_TOKEN_TYPE}`. +- **`IcebergSessionCatalogAdapter`** (re-migrate): holds plain `Catalog` + `Optional` + token mode. `catalog(session)` / `delegatedCatalog(session)` (throws without cred) / `delegatedViewCatalog(session)`; `toIcebergSessionContext(ConnectorDelegatedCredential, sessionId, mode)` → `new SessionCatalog.SessionContext(sessionId, null, credentials, ImmutableMap.of(), /*wrappedIdentity*/ user?)`. Credentials map = `access_token`→`{TOKEN:token}`, else `{credentialKey(type):token}`. +- **Session-aware catalog ops** (edit `CatalogBackedIcebergCatalogOps` or a session-aware subclass): each op (`loadTable`/`listTables`/`tableExists`/view ops/DDL) chooses `adapter.catalog(session)` vs the shared `catalog` based on `session.getDelegatedCredential().isPresent()` (the connector-side analog of `useSessionCatalog`). **Fail-closed:** if the connector is `session=user` but the session has no credential → throw (parity with #63068's `IcebergUserSessionCatalog.useSessionCatalog`). +- **Read-path** (`IcebergConnectorMetadata`/scan): the credential is already on the `ConnectorSession` passed to every metadata call, so table/schema/snapshot loads route through the adapter automatically; the FE-side cache bypass (7.2) ensures they aren't served from the shared cache. + +### 7.4 Data-flow (end to end, new architecture) +``` +login: MySQL token → DelegatedCredential → ConnectContext.sessionContext [RETAINED] +forward: FEOpExecutor → thrift 1004-1007 → ConnectProcessor.restore (sessionId kept) [RETAINED] +query: PluginDrivenExternalCatalog.buildConnectorSession() + → ConnectorSessionBuilder.from(ctx): if SUPPORTS_USER_SESSION, copy cred → ConnectorSession [NEW] + connector op(session): + session.getDelegatedCredential().isPresent() + ? adapter.delegatedCatalog(session) → RESTSessionCatalog.asCatalog(toIcebergSessionContext(...)) + : shared catalog [NEW] + FE cache: shouldBypassTableNameCache(ctx)=true → live, no shared-cache read/write [RE-ADD] +``` + +--- + +## 8. Edge cases +- **`session=user`, no credential** (e.g. password login to a user-session catalog) → **throw** fail-closed (never borrow a shared identity). Matches #63068 + its `IcebergMetadataOpTest`. +- **FE forward** — sessionId must be preserved across observer→master (retained thrift path) so the iceberg AuthSession key is stable; verify the SPI `getSessionId()` reflects the forwarded id, not a fresh UUID on the master. +- **Expired credential** — Doris does not reject non-iceberg SQL on datasource-token expiry (#63068's `ConnectProcessorDelegatedCredentialTest`); iceberg calls will fail at the REST server. Keep that behavior; surface a clear error. +- **Background/async threads** (metadata preload, auto-analyze, refresh) — `ConnectContext.get()` may be null → no credential → for a `session=user` catalog these must either be skipped or fail-closed, not silently use a shared identity. **Decide per background task** (open question O3). +- **View catalog** — REST `asCatalog()` is not itself a `ViewCatalog`; the default view catalog is `restSessionCatalog.asViewCatalog(createEmpty())`, per-user via `asViewCatalog(ctx)` (parity with #63068 `resolveDefaultViewCatalog`). +- **Nested namespaces** — gated by `isNestedNamespaceEnabled()` in #63068; preserve. +- **Cache correctness** — never populate the shared db/table/view caches from a per-user response (leakage). Assert via the re-migrated `ExternalDatabaseSessionContextTest`. + +## 9. Testing plan +Re-migrate the 8 #63068 test classes, retargeted: +- **Connector (new location):** `IcebergSessionCatalogAdapterTest` (credential-key mapping per mode; `delegatedCatalog` throws without cred; plain path without cred), plus connector-level "session=user + no cred throws" and "with cred routes to session catalog" (was `IcebergMetadataOpTest`), and read-path "view/table load bypasses shared cache" (was `IcebergUtilsTest`, `Mockito.verify(cache, never())`). +- **Retained base (should largely pass as-is; add if missing):** `DelegatedCredentialTest`, `ConnectProcessorDelegatedCredentialTest`, `FEOpExecutorDelegatedCredentialTest` — confirm they exist/pass on the current branch (they map to retained code; **task 0** verifies this). +- **Config:** `IcebergRestPropertiesTest`-equivalent in the connector property module (session=user⇒oauth2; token modes; no static-cred leakage into the shared catalog). +- **Cache bypass:** `ExternalDatabaseSessionContextTest`-equivalent (two tokens → two table sets; shared cache not populated; `lower_case_*` mapping). +- **Build/verify:** `mvn package -pl :fe-connector-iceberg,:fe-connector-paimon -am` (cache off) + relevant fe-core module tests; ideally a docker regression against a REST catalog that supports per-user sessions (Polaris/Lakekeeper) if available. + +## 10. Rollout, risks, alternatives +- **Rollout:** feature is opt-in (`iceberg.rest.session=user`, default `none`) → zero impact on existing catalogs. Ship behind the config; document. +- **Risk — cross-user cache leakage:** the highest-severity concern; mitigated by D5 bypass + `ExternalDatabaseSessionContextTest`. A review gate on the cache paths is warranted. +- **Risk — credential in memory/logs:** keep the token `SecuritySensitive`; never edit-log/persist (retained base already treats `SessionContext` as connection-scoped, non-persisted). Heed Trino's CVE-2026-34214 (creds leaked via query-info surfaces) — audit any `SHOW`/profile/`information_schema` surface that could serialize the credential. +- **Risk — SPI surface creep:** adding a credential accessor to `ConnectorSession` is universal; keep it a neutral, minimal DTO; only iceberg reads it. +- **Alternative considered — thread `SessionContext` through connector methods** (closer to #63068): rejected (D2) — pollutes the whole SPI, non-Trino, and the single-funnel injection is cleaner. +- **Alternative — config-only opt-in (pure Trino, no capability):** viable; rejected by default for least-privilege (D1), but a 1-line change if you prefer. + +## 11. Open questions (need your call) +- **O1 (D1):** capability flag `SUPPORTS_USER_SESSION` (recommended) vs pure-Trino config-only? +- **O2:** database-level cache bypass (`getDbNames`/`getDbNullable` live paths) — #63068 put it on `IcebergRestExternalCatalog`. On the SPI, does it live on `PluginDrivenExternalCatalog` (generic, gated by capability) or an iceberg-specific catalog subclass? Leaning generic-gated. +- **O3:** background/async tasks (preload/auto-analyze/refresh) under `session=user` — skip vs fail-closed? (They have no user credential.) +- **O4:** SPI `getSessionId()` — reuse `getQueryId()` or add a session-scoped id mirroring the forwarded `SessionContext.sessionId`? (Affects iceberg AuthSession cache key stability across a session's queries.) + +--- + +## 12. Ordered TODO (implementation plan — after approval) +Maps to the memory's 6-step plan, retargeted to the SPI + decisions above. + +0. **Verify retained base** on this branch: confirm MySQL-auth capture + `DelegatedCredential`/`SessionContext` + FE-forward (thrift 1004-1007) + `ConnectProcessor`/`FEOpExecutor` compile & their tests pass. (Establishes the foundation is intact.) +1. **SPI (fe-connector-api):** add `ConnectorDelegatedCredential` DTO; `ConnectorSession.getDelegatedCredential()` (+ `getSessionId()` if needed); `ConnectorCapability.SUPPORTS_USER_SESSION`. (memory step 1) +2. **FE inject (fe-core):** `ConnectorSessionBuilder.from(ctx)` copies the credential when the connector declares the capability; unit-test the injection + the no-capability skip. (memory step 2) +3. **Cache bypass (fe-core):** re-add `ExternalCatalog.shouldBypassTableNameCache` + `ExternalDatabase` live paths; `PluginDrivenExternalCatalog` override gated by capability+credential. Re-migrate `ExternalDatabaseSessionContextTest`. (part of step 5) +4. **Connector props (fe-connector-iceberg):** `iceberg.rest.session` + `delegated-token-mode` + validation; declare `SUPPORTS_USER_SESSION` when `session=user`; re-migrate `IcebergRestPropertiesTest`-equiv. (memory step 3) +5. **Connector session catalog (fe-connector-iceberg):** build `RESTSessionCatalog`; re-migrate `IcebergDelegatedCredentialUtils` + `IcebergSessionCatalogAdapter`; make catalog-ops session-aware (fail-closed); re-migrate `IcebergSessionCatalogAdapterTest`. (memory steps 4+5) +6. **Read/DDL routing (fe-connector-iceberg):** ensure `IcebergConnectorMetadata` metadata/scan/DDL ops honor the per-request credential via the adapter; re-migrate the `IcebergMetadataOpTest`/`IcebergUtilsTest`-equiv (fail-closed + cache-`never()`). (memory step 5) +7. **Verify:** full connector-suite build (cache off) + fe-core module tests; docker regression vs a per-user REST catalog if available. Commit as an independent feature commit (or a small stack: SPI → FE → connector). diff --git a/plan-doc/P6.6-C6-oidc-session-migration-research-notes.md b/plan-doc/P6.6-C6-oidc-session-migration-research-notes.md new file mode 100644 index 00000000000000..f8584680562257 --- /dev/null +++ b/plan-doc/P6.6-C6-oidc-session-migration-research-notes.md @@ -0,0 +1,102 @@ +# #63068 OIDC Session-Credential Migration — Research Notes + +> Working research note for the design doc. Upstream feature = `e545f1ad08a` +> "[feature](fe) Support OIDC session credentials for Iceberg REST catalog (#63068)". +> Dropped during the 2026-07-09 rebase (structural conflict with P6 SPI cutover); +> generic session base retained, iceberg consumer side deleted. Goal: re-migrate the +> consumer side onto the P6 catalog-SPI architecture, aligned with Trino. + +## Part A — Current Doris SPI (post-P6) injection points *(read directly, 2026-07-10)* + +### A1. `ConnectorSession` (fe-connector-api) — **carries NO credential today** +`org.apache.doris.connector.api.ConnectorSession` exposes: `getQueryId/getUser/getTimeZone/getLocale/getCatalogId/getCatalogName/getProperty/getCatalogProperties/getSessionProperties/getCurrentTransaction/...`. There is **no identity / extraCredentials / delegated-credential accessor**. +→ **Extension point #1**: add a credential accessor here (Trino analog: `ConnectorSession.getIdentity().getExtraCredentials()`), e.g. `default Optional getDelegatedCredential() { return Optional.empty(); }`. Keep it a neutral SPI type (NOT fe-core `DelegatedCredential`) — the connector module must not import fe-core. + +### A2. `ConnectorCapability` (fe-connector-api) — where the opt-in flag goes +Enum of behavior gates (`SUPPORTS_MVCC_SNAPSHOT`, `SUPPORTS_VIEW`, `SUPPORTS_SHOW_CREATE_DDL`, `SUPPORTS_COLUMN_AUTO_ANALYZE`, ...). Each replaces a legacy `instanceof` check and gates one FE behavior. +→ **Extension point #2**: add `SUPPORTS_USER_SESSION`. Gates (a) whether the FE bothers to inject the credential, and (b) whether the connector routes through a per-user session catalog. Row/passthrough connectors (JDBC/ES) and non-REST iceberg must not declare it. + +### A3. `PluginDrivenExternalCatalog.buildConnectorSession()` (fe-core:938) — **the single injection funnel** +```java +public ConnectorSession buildConnectorSession() { + ConnectContext ctx = ConnectContext.get(); + if (ctx != null) { + return ConnectorSessionBuilder.from(ctx) + .withCatalogId(getId()).withCatalogName(getName()) + .withCatalogProperties(catalogProperty.getProperties()).build(); + } + return ConnectorSessionBuilder.create()....build(); +} +``` +- **~25 call sites** all funnel through this ONE method. `ConnectContext.get()` (thread-local) already carries the retained `SessionContext`/`DelegatedCredential` (from #63068's generic base). +- → **Injection point**: `ConnectorSessionBuilder.from(ctx)` pulls the delegated credential off `ConnectContext` and stamps it on the ConnectorSession. **This covers all 25 call sites with no per-method threading** — cleaner than #63068's original approach (which threaded fe-core `SessionContext` through each `ExternalCatalog` method) and closer to Trino (credential rides the session/identity). +- Caveat to resolve in design: `ConnectContext.get()` is thread-local → null on async/worker threads (metadata preload, background refresh). #63068 addressed cross-thread/cross-FE via `ConnectProcessor`/`FEOpExecutor` forwarding (retained). Need to confirm the credential survives to the thread that calls `buildConnectorSession`. + +### A4. `listTableNamesFromRemote(SessionContext ctx, ...)` / `tableExist(SessionContext ctx, ...)` (fe-core:278/299) +Both **receive** a fe-core `SessionContext ctx` but call `buildConnectorSession()` **without** it → `ctx` discarded (memory's "DISCARD SessionContext"). If the credential rides `ConnectContext.get()` inside `buildConnectorSession`, the `ctx` param is redundant here; either wire it through or rely on the thread-local (design decision). + +### A5. Connector iceberg catalog construction — `CatalogBackedIcebergCatalogOps` (fe-connector-iceberg) +- Wraps a **single shared** `org.apache.iceberg.catalog.Catalog catalog` (`private final Catalog catalog`, :199); all ops delegate to it (`catalog.loadTable/listTables/tableExists/...`). +- Has a `restFlavor` boolean ctor arg (:210) → the connector already knows REST vs non-REST. +- **grep confirms: NO reference to `RESTSessionCatalog` / `SessionContext` / `DelegatedCredential` / `getExtraCredentials` in fe-connector-iceberg main.** The entire per-user session mechanism is absent here → **must be re-migrated** (the IcebergUserSessionCatalog / IcebergSessionCatalogAdapter role). +- → **Consumer point**: a session-aware REST catalog ops that, when `SUPPORTS_USER_SESSION` + a REST catalog + a per-request credential, builds/uses a per-user `org.apache.iceberg.rest.RESTSessionCatalog` with a `SessionCatalog.SessionContext` carrying the user's OAuth2/delegated credential, instead of the shared `catalog`. + +### A6. Connector must access the credential only through the **neutral SPI** (A1), never fe-core types. + +### A7. **Retained generic session base — CONFIRMED present & functional on the current branch** *(grep 2026-07-10)* +The whole "generic base" from #63068 survived the rebase and already delivers the credential to the FE query thread. The migration builds ON this — it is NOT rebuilt. +- **`ConnectContext`**: `private SessionContext sessionContext = SessionContext.empty()` + `getSessionContext()` / `setSessionContext()` (:316/:320). The per-connection credential lives here. +- **`SessionContext`** (fe-core datasource): `getSessionId()`, `getDelegatedCredential(): Optional`, `hasDelegatedCredential()`, `getDelegatedCredentialExpiresAtMillis()`, `isDelegatedCredentialExpired(now)`; factories `empty()/current()/of(cred)/of(sessionId,cred)`. +- **`DelegatedCredential`** (fe-core datasource): fields `Type type` (enum), `String token`, `Long expiresAtMillis`; `getType()/getToken()/getExpiresAtMillis()/isExpired(now)`. +- **Cross-FE forwarding intact**: `FrontendService.thrift` fields **1004-1007** (`delegated_credential_type/token/expires_at_millis/session_id`) retained; `ConnectProcessor` (:834-847) reads them off a forwarded `TMasterOpRequest` and rebuilds `context.setSessionContext(SessionContext.of(sessionId, new DelegatedCredential(...)))` on the master FE. `FEOpExecutor` sets them on the outbound request (to confirm in Part C). +- **Consequence**: on any FE thread running the query, `ConnectContext.get().getSessionContext().getDelegatedCredential()` yields the user's credential. `buildConnectorSession()` (A3) already reads `ConnectContext.get()` → the injection is a **1-line-ish** copy into the neutral SPI DTO. The thread-local caveat (A3) is covered because forwarding re-materializes the SessionContext on the executing FE. +- **Still to confirm on current branch (Part C / follow-up):** the MySQL-auth ingress (does `AuthenticatorManager`/`MysqlAuthPacketCredentialExtractor` still set the credential onto ConnectContext?), and whether `ExternalCatalog.shouldBypassTableNameCache` is retained. + +## Part B — Trino reference (delegated-credential / session SPI) *(subagent, read real Trino+Iceberg source 2026-07-10)* + +**Flow:** `X-Trino-Extra-Credential` header → `Identity.extraCredentials` (opaque `Map`, engine never interprets) → `ConnectorSession.getIdentity().getExtraCredentials()` → **per-request** `TrinoRestCatalog.convert(ConnectorSession)` → Iceberg `SessionCatalog.SessionContext(sessionId, user, credentials, properties, wrappedIdentity)` → **single shared** `RESTSessionCatalog` mints/refreshes a per-`sessionId` OAuth2 `AuthSession`. + +**The integration seam is Iceberg's, not Trino's** — `org.apache.iceberg.catalog.SessionCatalog.SessionContext` (iceberg **api** module): `SessionContext(String sessionId, String identity, Map credentials, Map properties, Object wrappedIdentity)`; getters `credentials()`, `properties()`, `wrappedIdentity()`. Doris depends on the **same** `iceberg-core RESTSessionCatalog`, so this contract is fixed for us too. + +**Two orthogonal config axes** (both catalog-level config, `IcebergRestCatalogConfig`): +- `iceberg.rest-catalog.security = NONE|OAUTH2|SIGV4|GOOGLE` → the *static, catalog-level* credential (`SecurityProperties.get()` → `{token, credential}`). +- `iceberg.rest-catalog.session = NONE|USER` (`SessionType`, default NONE) → per-user projection: + - **NONE**: `sessionId=randomUUID`, `user=null`, `credentials=` static catalog creds. One shared principal at the REST server. + - **USER**: deterministic `sessionId = user-queryId-source`, real `user`, `credentials =` the user's `extraCredentials` **plus a freshly Trino-SIGNED subject JWT** (`OAuth2Properties.JWT_TOKEN_TYPE`, `sub=`). Lets the REST server (Polaris/Lakekeeper/Unity) enforce per-user authz + vend per-user scoped storage creds. `wrappedIdentity` still carries the real identity for per-user FileIO. + +**Opt-in = per-connector CONFIG, NOT an SPI capability.** Trino has **no** "supports per-user session" capability flag; the SPI hands *every* connector `getExtraCredentials()` universally, and the iceberg connector alone decides via its `SessionType` enum. → **Design divergence to surface**: the memory's 6-step plan (from #63068) adds `ConnectorCapability.SUPPORTS_USER_SESSION`. Reconcile — see design-decision D1. + +**Cache tension (load-bearing):** the `RESTSessionCatalog` object is a **single shared instance** (`create()` memoizes it; per-user isolation is 100% at the per-request `SessionContext` layer — never cache a catalog-per-user). But per-user vended credentials mean the REST *server* enforces per-user authz/vending; a **shared FE metadata/name cache would leak one user's authorized/vended view to another** → for `session=user` the FE cache must be **bypassed or user-partitioned** (this is exactly what #63068's `shouldBypassTableNameCache` is for). Also: `sessionId` embeds `queryId` → the iceberg AuthSession cache is effectively **per-query** (token-exchange volume note). + +**Doris-specific gap:** MySQL/JDBC wire protocol has no `X-Trino-Extra-Credential` equivalent → Doris needs its own channel to get the user's OIDC credential onto the session. #63068 solved this via the MySQL auth path (see Part C). + +## Part C — #63068 as-built (`e545f1ad08a`) *(subagent, read the commit 2026-07-10; full note in agent transcript)* + +**Flow (pre-P6):** MySQL login → `MysqlAuthPacketCredentialExtractor` → `Authenticator` returns `AuthenticationResult` w/ expiry → `AuthenticatorManager.attachDelegatedCredential` builds `DelegatedCredential(type,token,expiry)` → `context.setSessionContext(SessionContext.of(cred))`. Observer→master forward: `FEOpExecutor.buildStmtForwardParams` writes thrift 1004-1007 → `ConnectProcessor.restoreForwardedSessionContext` rebuilds `SessionContext.of(sessionId,cred)` (**sessionId preserved**). Metadata ops read `SessionContext.current()`. + +**The 3 deleted iceberg-consumer classes (re-migrate):** +- **`IcebergUserSessionCatalog`** (capability interface, ~60 LOC): `useSessionCatalog(SessionContext): boolean` — the single decision, **THROWS if `session=user` but no credential** (a per-user catalog has no shared identity to borrow); `getRestSessionCatalog()`, `getDelegatedTokenMode()`, `isViewEnabled()`, `isNestedNamespaceEnabled()`. +- **`IcebergSessionCatalogAdapter`** (Doris↔Iceberg bridge, ~150 LOC): holds plain `Catalog` + `Optional` (the injected `RESTSessionCatalog`) + `DelegatedTokenMode`. `catalog(ctx)` (plain if no cred else `sessionCatalog.asCatalog(icebergCtx)`), `delegatedCatalog(ctx)` (requires cred), `delegatedViewCatalog(ctx)`, and `static toIcebergSessionContext(ctx, mode)` → `new org.apache.iceberg.catalog.SessionCatalog.SessionContext(ctx.getSessionId(), null, credentials, ImmutableMap.of())`. +- **`IcebergDelegatedCredentialUtils`** (~43 LOC): `credentialKey(DelegatedCredential.Type)` → Iceberg OAuth2 key: `ACCESS_TOKEN→OAuth2Properties.TOKEN`, `ID_TOKEN→ID_TOKEN_TYPE`, `JWT→JWT_TOKEN_TYPE`, `SAML→SAML2_TOKEN_TYPE`. + +**Credential map (`toIcebergCredentials`):** `access_token` mode → `{TOKEN: token}` (bearer, verbatim); `token_exchange` mode → `{credentialKey(type): token}` (server does RFC-8693 exchange). NOTE: #63068 passes the token verbatim; it does **NOT** mint a signed subject JWT the way Trino's `session=user` does (Trino adds `sub=`). If the REST server must trust a *Doris-asserted* identity rather than the client-supplied token, that signing step is a future gap (Trino parity) — for OIDC pass-through it isn't needed. + +**Config (`IcebergRestProperties`):** `iceberg.rest.session = none|user` (default none); `iceberg.rest.oauth2.delegated-token-mode = access_token|token_exchange` (default access_token); `iceberg.rest.session-timeout` → `CatalogProperties.AUTH_SESSION_TIMEOUT_MS`. **`session=user` requires `security.type=oauth2`.** Built around iceberg **`RESTSessionCatalog`** (not `RESTCatalog`) so `asCatalog(ctx)`/`asViewCatalog(ctx)` work; default catalog = `restSessionCatalog.asCatalog(SessionContext.createEmpty())`. The OAuth2 "requires credential or token" validation rule is relaxed for a user-session catalog (it has no static bootstrap cred). + +**Session routing (`IcebergMetadataOps`, pre-P6):** fields captured at construction (`userSessionCatalog`, `sessionCatalogAdapter`, `defaultViewCatalog`); private routers `catalog(ctx)`/`namespaces(ctx)`/`viewCatalog(ctx)` switch on `useSessionCatalog(ctx)`; every op got a `SessionContext` overload (read=`empty()`, DDL=`current()`). `IcebergUtils` read-path (`getIcebergTable/View/getSchemaCacheValue/...`) guards on `useSessionCatalog(dorisTable)` and **bypasses the shared meta cache** (`loadIcebergTableWithSession` loads live via `ops.loadTable(ctx,...)`). + +**Cache bypass:** `ExternalCatalog.shouldBypassTableNameCache(SessionContext): boolean` (default false) — overridden by the REST catalog to `useSessionCatalog(ctx)` ("bypass cache" ≡ "use per-user session"). Rationale: the shared db/table/view caches are keyed catalog+name, NOT user; under `session=user` the REST server returns per-user metadata → a shared cache would leak user A's visible set to user B. `ExternalDatabase` (+86) reroutes `getTableNamesWithLock/getTableNullable/isTableExist` to live, no-cache paths (threads `updateTableNameLookup=false`); the REST catalog mirrors this for **databases** (`getDbNames/getDbNullable` → live, re-append `information_schema`+`mysql`). + +**Generic (retained ✅) vs iceberg-consumer (re-migrate ❌):** ALL of SessionContext/DelegatedCredential/ConnectContext/ConnectProcessor/FEOpExecutor/thrift-1004-1007/mysql-auth = GENERIC, **retained & functional** (Part A7). Re-migration surface = the 3 deleted classes + session routing (`IcebergMetadataOps`/`IcebergUtils`/`IcebergRestExternalCatalog`) + config (`IcebergRestProperties`) + the two generic-but-iceberg-driven cache hooks (`ExternalCatalog.shouldBypassTableNameCache`, `ExternalDatabase` bypass). + +**⚠️ RE-TARGETING (the core adaptation):** #63068 lived in the **fe-core** iceberg tree. P6/#64688 **moved iceberg into `fe/fe-connector/fe-connector-iceberg`** (`IcebergConnectorMetadata`, `CatalogBackedIcebergCatalogOps` wrapping a single shared `Catalog`; the fe-core `IcebergMetadataOps` DDL shim remains but its session routing was stripped, and `IcebergRestProperties`/`IcebergRestExternalCatalog` no longer exist in fe-core). So the re-migration must (1) drive the decision off the **`ConnectorSession` credential** (Trino-style, injected in `buildConnectorSession`) instead of `SessionContext.current()`, and (2) place the RESTSessionCatalog + adapter in the **connector**, not fe-core. The fe-core cache-bypass hooks (`ExternalCatalog`/`ExternalDatabase`) stay in fe-core but key off the SPI capability + the injected credential. + +**Tests to re-migrate (8):** `IcebergSessionCatalogAdapterTest`(8: credential-key mapping, throws-without-cred), `IcebergMetadataOpTest`(+2: enabled+no-cred throws, +cred returns true), `IcebergUtilsTest`(+2: view routes to loadView, shared cache `never()` called), `DelegatedCredentialTest`(3: expiry semantics), `ConnectProcessorDelegatedCredentialTest`(5: forward re-hydrate, missing-field throws), `FEOpExecutorDelegatedCredentialTest`(1: forward copies+preserves sessionId), `IcebergRestPropertiesTest`(+7: session=user oauth2 build, token modes, no-leak into shared catalog), `ExternalDatabaseSessionContextTest`(4: per-token table sets, no shared-cache populate, lower_case names). The last 5 base/auth ones map to RETAINED generic code (may already pass or need light touch-up); the first 3 map to re-migrated connector code. + +## Emerging design shape (pre-subagent, to validate) +1. **SPI**: `ConnectorSession.getDelegatedCredential(): Optional` (neutral DTO) + `ConnectorCapability.SUPPORTS_USER_SESSION`. +2. **FE inject**: `ConnectorSessionBuilder.from(ConnectContext)` copies the credential off ConnectContext → ConnectorSession. One place, all call sites. +3. **Props**: `IcebergRestProperties` (connector property module) gains `iceberg.rest.session` (`user`|`none`, default `none`) + declares `SUPPORTS_USER_SESSION` when `=user`. +4. **Consumer**: session-aware REST ops in fe-connector-iceberg build a per-user `RESTSessionCatalog` from `session.getDelegatedCredential()` (re-migrated IcebergUserSessionCatalog/SessionCatalogAdapter/DelegatedCredentialUtils). +5. **Routing + cache bypass**: per-user session → bypass shared table-name cache (`ExternalCatalog.shouldBypassTableNameCache`, retained). +6. **Tests**: re-migrate the 3 deleted iceberg tests onto the connector + keep the retained-base tests. diff --git a/plan-doc/PROGRESS.md b/plan-doc/PROGRESS.md new file mode 100644 index 00000000000000..1b13aedbe8c880 --- /dev/null +++ b/plan-doc/PROGRESS.md @@ -0,0 +1,281 @@ +# 📊 项目进度仪表盘 + +> 最后更新:**2026-07-05(下午)** | 当前阶段:**P7 hive (+HMS) 迁移(进行中:recon + 阶段拆分 spec 完成)** —— 工作分支 `catalog-spi-11-hive`。**P0–P6 + P3b 全部合入 `branch-catalog-spi`**:P0 #63582 / P1 #63641 / P2 #64096 / P3 hudi(hybrid) #64143 / P4 #64300 / P5 #64446+#64653 / P3b #64655 / **P6 iceberg 迁移+翻闸+删 legacy #64688 `8b391c7459d`**。本轮产出 **`tasks/P7-hive-migration.md`**(10-agent code-grounded recon + P7.1–P7.5 阶段拆分)。下一 = **P7.1 HiveMetadataOps 实现**(→ HiveConnectorMetadata + HmsClient 写方法)。| 项目总进度:**~64%**(P0+P1+P2+P4+P5+P6 满 + P3 hybrid 45%,约 15.9/25 周) +> [README](./README.md) · [Master Plan](./00-connector-migration-master-plan.md) · [SPI RFC](./01-spi-extensions-rfc.md) · [Decisions](./decisions-log.md) · [Deviations](./deviations-log.md) · [Risks](./risks.md) · [Agent Playbook](./AGENT-PLAYBOOK.md) · [Handoff](./HANDOFF.md) + +--- + +## 一、阶段进度(P0–P8) + +| 阶段 | 范围 | 估时 | 进度 | 状态 | 任务文档 | +|---|---|---|---|---|---| +| **P0** | SPI 缺口补齐 | 2 周 | ▰▰▰▰▰▰▰▰▰▰ 100% | ✅ 完成(PR #63582 squash-merge `c6f056fa5bd`,T24-T25 流水线全绿)| [tasks/P0](./tasks/P0-spi-foundation.md) | +| **P1** | scan-node 收口 + 重复清理 | 1 周 | ▰▰▰▰▰▰▰▰▰▰ 100% | ✅ 完成(PR [#63641](https://github.com/apache/doris/pull/63641) squash-merged `778c5dd610f`;T1 推迟 P8;T2 推迟 P4/P5)| [tasks/P1](./tasks/P1-scan-node-cleanup.md) | +| **P2** | trino-connector 迁移 | 2 周 | ▰▰▰▰▰▰▰▰▰▰ 100% | ✅ 已合入 `branch-catalog-spi`(#64096,squash `0793f032662`;T12 回归推迟 DV-003)| [tasks/P2](./tasks/P2-trino-connector-migration.md) | +| P3 | hudi 迁移 | 2 周 | ▰▰▰▰▰▱▱▱▱▱ 45% | ✅ hybrid(D-019)批 A–D 已合入 `branch-catalog-spi`(**#64143** squash `5c240dc7a34`);批 E(live cutover)并入 P7 | [tasks/P3](./tasks/P3-hudi-migration.md) | +| **P4** | maxcompute 迁移 | 2 周 | ▰▰▰▰▰▰▰▰▰▰ 100% | ✅ 完成并合入 `branch-catalog-spi`(**#64253** T01–T06 适配+翻闸 + **#64300** T07–T09 删 legacy/odps-free;含 #64119 校验迁移)| [tasks/P4](./tasks/P4-maxcompute-migration.md) | +| **P5** | paimon 迁移 | 3 周 | ▰▰▰▰▰▰▰▰▰▰ 100% | ✅ 完成并合入 `branch-catalog-spi`(迁移+翻闸 **#64446** + 删 legacy/maven **#64653** `d59ed2f96d9`;B9 回归用户 docker 覆盖)| [tasks/P5](./tasks/P5-paimon-migration.md) | +| **P6** | iceberg 迁移 | 5 周 | ▰▰▰▰▰▰▰▰▰▰ 100% | ✅ 完成并合入 `branch-catalog-spi`(迁移+翻闸+删 legacy 原生子系统 **#64688** `8b391c7459d`;P6.1–P6.6 全阶段 + 属性/鉴权全归插件 S1–S10 一次性 squash,685 文件 −23744 行)。⚠️ fe-core `datasource/iceberg/` 尚存 23 个 HMS-iceberg 支撑类,随 P7 hive 迁移删(阶段四)| [tasks/P6](./tasks/P6-iceberg-migration.md) | +| **P7** | hive (+HMS) 迁移 | 6 周 | ▰▱▱▱▱▱▱▱▱▱ 8% | 🚧 进行中(recon + 阶段拆分 spec `tasks/P7-hive-migration.md` 立;下一 = P7.1 实现)| [tasks/P7](./tasks/P7-hive-migration.md) · [connectors/hive.md](./connectors/hive.md) | +| P8 | 收尾清理 | 2 周 | ▱▱▱▱▱▱▱▱▱▱ 0% | ⏸ 待启动 | — | + +**全局进度:~64%**(25 周计划中已完成约 15.9 周:P0+P1+P2+P4+P5+P6 满 + P3 hybrid 45%;按 §一 进度条加权) + +--- + +## 二、连接器迁移看板 + +> 维度:"SPI 设计" = RFC 中该连接器涉及的 SPI 是否定稿;"实现" = fe-connector 模块中代码完成度;"SPI_READY" = 是否已加入 `CatalogFactory.SPI_READY_TYPES`;"删除旧代码" = fe-core/datasource// 是否清空;"反向 instanceof" = nereids/planner 等热区中 `instanceof XExternal*` 是否清理。 + +| 连接器 | SPI 设计 | 实现完成度 | SPI_READY | 删除旧代码 | 反向 instanceof | 状态 | 详细 | +|---|---|---|---|---|---|---|---| +| **jdbc** | ✅ | ✅ 100% | ✅ | 🟡 (13 个旧 client,P1 删) | n/a | **95%** | [详情](./connectors/jdbc.md) | +| **es** | ✅ | ✅ 100% | ✅ | ✅ | ✅ | **100%** | [详情](./connectors/es.md) | +| trino-connector | ✅ | ✅ 100% | ✅ | ✅ | ✅ | **100%** | [详情](./connectors/trino-connector.md) | +| hudi | 🟡(D-005 区分符 + D-020 模型 dispatch 已设计;实现批 E)| 🟨 55%(读路径 dormant + 批 C 测试基线)| ❌(gate 关)| ❌ | 0/0(寄生 hms)| **25%** | [详情](./connectors/hudi.md) | +| maxcompute | ✅ | ✅ 100% | ✅ **已合入 #64253** | ✅ **#64300 已删** | ✅ 0/0 | **100%** | [详情](./connectors/maxcompute.md) | +| paimon | ✅ | ✅ 100% | ✅ **已入 SPI_READY_TYPES** | ✅ **#64653 已删** | ✅ 热区已清 | **100%** | [详情](./connectors/paimon.md) | +| iceberg | ✅ | ✅ 100% | ✅ **已入 SPI_READY_TYPES**(#64688)| ✅ **#64688 已删原生子系统**(HMS-iceberg 23 支撑类随 P7 删)| ✅ 0/0(原生反向 instanceof 已清)| **100%** | [详情](./connectors/iceberg.md) | +| hive (+hms) | 🟡 | 🟥 22% | ❌ | ❌ | 0/85 | **🚧 P7 进行中(recon+spec 立,起步 P7.1)** | [详情](./connectors/hive.md) | + +--- + +## 三、当前活跃 task + +> 状态非 ✅ 的项,按阶段聚合。详细见各阶段 task 文件。 + +### P7 — hive (+HMS) 迁移(🚧 启动中;最复杂、最后做的连接器。权威计划 master plan §3.8 + connectors/hive.md) + +> 策略 = **先在 `fe-connector-hive`/`fe-connector-hms` 实现完整能力 → 翻闸(hms 入 `SPI_READY_TYPES`)→ 删 fe-core legacy(含 23 个 HMS-iceberg 支撑类 + `datasource/hudi/`,阶段四)→ 回归**(复用 P5/P6 full-adopter 样板)。模块已就绪:`fe-connector-hms` 是 hive/hudi/iceberg-HMS/paimon-HMS 共享元存储库(P3/P5/P6 已在用、稳定)。 +> +> **子阶段(权威 = `tasks/P7-hive-migration.md`)**:P7.1 HiveMetadataOps 全功能搬迁(2 周)→ P7.2 event pipeline 搬 + `ConnectorMetaInvalidator`(21 event 类,1.5 周)→ P7.3 `HMSTransaction`+`HiveTransactionMgr` ACID 写路径(2 周,**R-002 最大风险**,gate=专门 ACID 集成测试)→ P7.4 DLA 分流改造 + iceberg/hudi-on-HMS 委派(D-020)+ hudi live cutover(D-019)→ P7.5 删 fe-core `datasource/hive/`+`hudi/`+23 HMS-iceberg 类 + 85 处反向 instanceof。**起步无硬前置**(P0 已建 `ConnectorMetaInvalidator`、fe-kerberos P3b 已收口)。 +> **✅ 本轮完成 = 10-agent code-grounded recon + 阶段拆分 spec `tasks/P7-hive-migration.md`**(52 文件分类 + 翻闸机制 CatalogFactory:50/133 + GsonUtils:366/447/471 + 6 文件写路径 retype 链 + 跨连接器删除排序 + 8 条开放决策)。校正过时数字:instanceof 31→**85**、HMSTransaction 1866→**1895**、HMSExternalTable 1293→**1332**。**已定架构勿重议**:D-004/D-005/D-019/D-020 + 事务桥接。**下一 = P7.1 实现**(信控制流不信 spec 行号)。 + +### P5 — paimon 迁移(✅ 全完成并合入:迁移+翻闸 #64446 + 删 legacy/maven #64653;B9 回归用户 docker 覆盖) + +> 策略 = **full adopter + 翻闸**(复用 P4 样板)。B0–B7 全完成并 squash-合入 `branch-catalog-spi`(**#64446 / `38e7140ce56`** + `e9c5b3e70ce` 修编译):测基建/flavor/normal-read/DDL/sys-tables+MVCC(E7/E5)/MTMV桥(E10)/时间旅行(AS-OF/tag/branch/@incr)/**翻闸** + P6 全路径 clean-room review 的全部 deviation fix(C1 MinIO/C2 HDFS XML/R1-table/R3-residual/C4+R2+R3-catalog/A1/A2/A3/B-MC2/B-R2-be)。paimon 已在 `SPI_READY_TYPES`。 +> +> **✅ P5-T29(B8)已合入 #64653 / `d59ed2f96d9`**(删 fe-core `datasource/paimon/`(30) + `metacache/paimon/`(3) + `systable/PaimonSysTable`(1) + 清 8 处反向引用死分支 + **删 5 paimon maven 依赖**;fe-core 完全 paimon-SDK-free)。下为历史 scope ledger。 +> **硬前置**:迁出 `PaimonExternalCatalog.PAIMON_FILESYSTEM/_HMS` 常量(被 5 个 STILL-CONSUMED metastore-props 引用);scrub 悬空 javadoc `{@link PaimonSysTable}`;保 dispatch ordering;`CreateTableInfo.ENGINE_PAIMON` 是 LIVE 保留。 +> **STILL-CONSUMED 不删**:`property/metastore/Paimon*`(7,cutover Kerberos 装配 LIVE,P6 R1);`property/storage/*Properties`(跨连接器共享,P6 R2)。 +> **⚠️ maven 核心冲突**:STILL-CONSUMED metastore-props 直接 import paimon SDK → **fe-core 不可能像 P4 完全 paimon-free**(除非方案 B 连带迁出 metastore-props,越界 metastore 子线)。须先定方案 A(推荐,部分删)vs B。 +> **样板 = P4 #64300**;scope ledger + checklist 详见 [tasks/P5 §P5-T29 执行计划](./tasks/P5-paimon-migration.md)。**风险**:R-004(classloader SDK 单例)、R-007(FE/BE 共享 jar)→ 删后验 paimon-core FE classpath 恰一份。 + +### P4 — maxcompute 迁移(✅ 已完成并合入:**#64253** T01–T06 适配+翻闸 + **#64300** T07–T09 删 legacy/odps-free;含 #64119 校验迁移) + +> 策略 = **full adopter + 翻闸**([D-023],非 P3 hybrid);前置 W-phase(W1–W7)✅。批次计划 + 完整 task 表见 [tasks/P4](./tasks/P4-maxcompute-migration.md)。 + +| 批 | 范围 | gate | task | 状态 | +|---|---|---|---|---| +| A | 连接器 DDL + 分区 parity | 🔒 关 | P4-T01 ✅ / T02 ✅ | ✅ T01 DDL + T02 分区 listing 完成(gate 全绿:compile + checkstyle 0 + import-gate)| +| B | 写/事务 SPI(`ConnectorTransaction`/`WriteOps` + `WritePlanProvider`→`TMaxComputeTableSink`)| 🔒 关 | P4-T03 ✅ / T04 ✅ | ✅ T03 写/事务 SPI(`MaxComputeConnectorTransaction`+`beginTransaction`)+ T04 写计划(`MaxComputeWritePlanProvider.planWrite`,OQ-2=Approach A)完成,gate 全绿 | +| C | 翻闸(`SPI_READY_TYPES` + GSON + `getEngine`;含 R-004 防御测)| 🔓 **live** | P4-T05/T06 | ✅ **已合入 #64253**(T05 image-compat + T06a 写接线/UT + T06b flip;+ T06c FE 分发补接 + T06e 红线 gap campaign G0/G2/G5/G6/G7/GC1/F9 等)| +| D | 清反向引用 + 删 legacy 子系统(20 文件,收口 P1-T02 的 Mc 部分)+ **drop fe-core odps 依赖** + **下沉 MCUtils/删 fe-common odps**(方案A §8)| 🔓 live | P4-T07/T08/T09 | ✅ **已合入 #64300**(删 20 fe-core 文件 + 清反向引用 + MCUtils 下沉 be-java-extensions;`dependency:tree \| grep odps`=∅;含 DV-021/DV-022)| +| E | 连接器测试基线 + PR | — | P4-T10/T11 | ✅ 连接器 UT 全绿(含 #64119 迁移测,101 run/0 fail/1 skip);PR #64253 + #64300 已合入 | + +### P3 — hudi 迁移(🚧 hybrid,批 A–D 全部 in-scope 完成:T02/T04/T05/T07 ✅ + T06/T08 决策;T03→批 E;剩批 E→P7,**P3 已合入 #64143 `5c240dc7a34`**;批 E live cutover 并入 P7) + +> 策略 = **hybrid**([D-019](./decisions-log.md)):现做 (b) 连接器硬化+测试(behind gate),推迟 (a) 模型落地+cutover 到 hive/HMS migration。详细批次见 [tasks/P3](./tasks/P3-hudi-migration.md);背景见 [DV-005](./deviations-log.md) / [HANDOFF](./HANDOFF.md) 关键认知 1+1b。 + +| 项 | 状态 | 备注 | +|---|---|---| +| HMS-over-SPI recon(#1 元数据 + #2 scan/split)| ✅ | code-grounded + 对抗验证;verdict `hmsMetadataOverSpiReady=false`(DV-005)| +| catalog 模型决策(a/b/c)| ✅ hybrid(D-019)| 现做 (b),推迟 (a);真阻塞=独立 `"hudi"` type vs 寄生 `"hms"` 的 `DLAType.HUDI`、fe-core 不消费 `tableFormatType` | +| SPI scan/split 路径 recon | ✅ | **混合 COW-native/MOR-JNI 不是问题**(per-range format,与 legacy 结构等价,BE 每 range 建 reader;2 路对抗验证);plumbing 正确但 verdict 仍 false(gate/模型未解)| +| scan 侧 parity 修复(HIGH)| ✅ 批 A 范围 | **②✅ column_types(T02 `95f23e9`)**;**③④✅ time-travel/增量 fail-loud(T04 `feceabb`)**——`visitPhysicalHudiScan` SPI 分支抛 `AnalysisException`(不再静默)。**①schema_id/history 推迟批 E([DV-006])**(连接器缺 field-id/InternalSchema/type→thrift;裸基线净回归);详见 [HANDOFF](./HANDOFF.md) 1b | +| MVCC/snapshot SPI(T06)| ✅ 批 B 决策 | keep default opt-out(DV-007)——全体连接器无 override,T04 已 fail-loud time-travel;完整 MVCC + 增量读(P1-T04 gap,4 个 `*IncrementalRelation` 仍在 fe-core)入批 E | +| listPartitions 真实裁剪(T05)| ✅ 批 B | applyFilter EQ/IN 裁剪(`10b72d4`,镜像 Hive)+ 修复"分区来源静默切换";`listPartitions*` override→批 E(DV-007)| +| 三连接器模块测试(T07)| ✅ 批 C | fe-connector-hms/hive/hudi 测试基线落地(hms 12 + hive 14 + hudi +18=33 全绿,golden-value)+ COW/MOR schema parity(schema type-agnostic);列名 casing 当场修(DV-008,镜像 legacy);gap-2 meta-field 推迟批 E | +| tableFormatType 分流消费设计(T08)| ✅ 批 D | design-only 设计备忘 + [D-020](用户签字):**M1 身份消费 ⊥ M2 scan 路由**拆解(M1 三方案通用);M2=**方案 B**(新增向后兼容 default `ConnectorMetadata.getScanPlanProvider(handle)`,fe-core 优先 per-table、回落 per-catalog),细化 D-005;A 备选/C 否决;实现登记批 E/P7。设计 `designs/P3-T08-tableformat-dispatch-design.md` | + +### P2 — trino-connector 迁移(✅ 已合入 #64096) +| ID | Task | 批次 | Owner | 状态 | 启动 | 备注 | +|---|---|---|---|---|---|---| +| P2-T01 | `TrinoConnectorProvider.validateProperties` + `TrinoDorisConnector.preCreateValidation` | 批 A | @me | ✅ | 2026-05-25 | required-property check + preCreateValidation 触发 plugin loading;+20 LOC | +| P2-T02 | `ConnectorPushdownOps.applyFilter` + `applyProjection`(桥接 Trino 原生下推) | 批 A | @me | ✅ | 2026-05-25 | `TrinoConnectorDorisMetadata` 复用 `TrinoPredicateConverter`;+125 LOC;单测推 P2-T11 | +| P2-T03 | `GsonUtils` Trino 三处 `registerSubtype` 替换为 `registerCompatibleSubtype` | 批 B | @me | ✅ | 2026-05-25 | **scope 校正**:必须 atomic replace(避免 RuntimeTypeAdapterFactory 撞名 IAE) | +| P2-T04 | `PluginDrivenExternalCatalog.gsonPostProcess` 加 trinoconnector logType migration | 批 B | @me | ✅ | 2026-05-25 | 新 helper `legacyLogTypeToCatalogType`;`name().toLowerCase()` 不通用 | +| P2-T05 | ~~`ExternalCatalog.registerCompatibleSubtype` 注册~~ | 批 B | @me | ✅ | 2026-05-25 | duplicate of T03,自动满足 | +| P2-T06 | `PluginDrivenExternalTable.getEngine() / getEngineTableTypeName()` 加 trino-connector 分支 | 批 B | @me | ✅ | 2026-05-25 | toEngineName 返 null(保留 legacy 行为) | +| P2-T07 | `CatalogFactory.SPI_READY_TYPES` 加 `"trino-connector"` | 批 C | @me | ✅ | 2026-06-04 | commit `0fe4b8a93d6`;翻闸 | +| P2-T08 | `PhysicalPlanTranslator` 删 `instanceof TrinoConnectorExternalTable` 分支 | 批 D | @me | ✅ | 2026-06-04 | commit `ed81a063fe8`;SPI 分支接管 | +| P2-T09 | `CatalogFactory` 删 `case "trino-connector"` + import | 批 D | @me | ✅ | 2026-06-04 | commit `ed81a063fe8` | +| P2-T10 | 删 `datasource/trinoconnector/` 整目录 + legacy test | 批 D | @me | ✅ | 2026-06-04 | commit `ed81a063fe8`;GsonUtils 不碰(批 B 已处理);+ExternalCatalog db case(DV-001)| +| P2-T11 | fe-connector-trino 单元测试 | 批 E | @me | ✅ | 2026-06-04 | commit `9bba12a44b2`;3 类/29 测试;无 mock,json/schema 砍(DV-002)| +| P2-T12 | regression-test `trino_connector_migration_compat`(image 兼容) | 批 E | @me | 🟡 | — | **推迟**(无集群/plugin;DV-003)| +| P2-T13 | 同步跟踪文档 + 开 PR | 批 E | @me | ✅ | 2026-06-04 | 文档已同步;docs-next 不在本仓(DV-004);**已合入 #64096**(squash `0793f032662`)| + +详细任务说明、阶段日志见 [tasks/P2-trino-connector-migration.md](./tasks/P2-trino-connector-migration.md) + +### P1 — scan-node 收口 + 重复清理(✅ 已完成) +| ID | Task | 批次 | Owner | 状态 | 启动 | 备注 | +|---|---|---|---|---|---|---| +| P1-T03 | `PhysicalPlanTranslator.visitPhysicalFileScan` 收口(保留 fallback) | 批 A | @me | ✅ | 2026-05-25 | `PluginDrivenExternalTable` 分支已前置;7 个老分支保留 | +| P1-T04 | `visitPhysicalHudiScan` 委托给 `PluginDrivenScanNode` | 批 A | @me | ✅ | 2026-05-25 | SPI 分支已加;`incrementalRelation` 待 P3 SPI 扩展 | +| P1-T05 | `LogicalFileScan.computeOutput` 改走 SPI | 批 A | @me | ✅ | 2026-05-25 | `computePluginDrivenOutput` + `supportPruneNestedColumn` 显式分支 | +| P1-T01 | 删除 13 个 `Jdbc*Client.java` + `JdbcFieldSchema.java` | 🚫 推迟 P8 | — | 🚫 | — | 2026-05-25 决议(Q4):3 个 fe-core caller 是活的 CDC streaming 代码,删除需 SPI 扩展,P8 收尾时一并做 | +| P1-T02 | 重复 PaimonPredicateConverter + McStructureHelper 处理 | 🚫 推迟 P4/P5 | — | 🚫 | — | 用户决议 Q2(2026-05-25) | + +### P0 — SPI 缺口补齐(✅ 已完成) +| ID | Task | Owner | 状态 | 启动 | 备注 | +|---|---|---|---|---|---| +| P0-T01 | RFC §16.2 决策点闭环 | @me | ✅ | 2026-05-24 | 全部 18 条决策已敲定 | +| P0-T02 | 项目跟踪机制建立 | @me | ✅ | 2026-05-24 | commit 63159837043 | +| P0-T03 | E3:`ConnectorMetaInvalidator` 接口 | @me | ✅ | 2026-05-24 | spi 包 / 5 invalidate 方法 | +| P0-T04 | E3:`ConnectorContext.getMetaInvalidator()` default | @me | ✅ | 2026-05-24 | 返回 NOOP | +| P0-T05 | E4:`ConnectorTransaction` 继承 `ConnectorTransactionHandle` | @me | ✅ | 2026-05-24 | 新增不替换 | +| P0-T06 | E4:`ConnectorWriteOps.beginTransaction` default | @me | ✅ | 2026-05-24 | throws unsupported | +| P0-T07 | E4:`ConnectorSession.getCurrentTransaction` default | @me | ✅ | 2026-05-24 | Optional.empty() | +| P0-T08 | E5:`ConnectorMvccSnapshot` 类型 + 3 default 方法 | @me | ✅ | 2026-05-24 | mvcc 包 + ConnectorMetadata 3 default | +| P0-T09 | `DefaultConnectorContext.getMetaInvalidator()` impl | @me | ✅ | 2026-05-24 | 返回新建 invalidator | +| P0-T10 | `ExternalMetaCacheInvalidator`(fe-core 新类) | @me | ✅ | 2026-05-24 | 包装 `ExternalMetaCacheMgr`;2 个 no-op 限制留 TODO | +| P0-T11 | `PluginDrivenTransactionManager` 通用化 | @me | ✅ | 2026-05-24 | 新增 `begin(ConnectorTransaction)` 重载;legacy 不变 | +| P0-T12 | `ConnectorMvccSnapshotAdapter`(fe-core 新类) | @me | ✅ | 2026-05-24 | impl `MvccSnapshot` | +| **批 1 DDL + Partition SPI** | | | | | | +| P0-T13 | `ConnectorCreateTableRequest` + 4 spec POJO(ddl 包) | @me | ✅ | 2026-05-24 | 5 个新 final 类 | +| P0-T14 | `ConnectorTableOps.createTable(request)` default | @me | ✅ | 2026-05-24 | 退化到 legacy createTable | +| P0-T15 | `CreateTableInfoToConnectorRequestConverter`(fe-core) | @me | ✅ | 2026-05-24 | 覆盖 4 种 partition + hash/random bucket | +| P0-T16 | `PluginDrivenExternalCatalog.createTable(stmt)` 接通 SPI | @me | ✅ | 2026-05-24 | override + edit log | +| P0-T17 | `listPartitionNames` default | @me | ✅ | 2026-05-24 | emptyList | +| P0-T18 | `listPartitions(handle, filter)` default | @me | ✅ | 2026-05-24 | filter 用 Optional<ConnectorExpression> | +| P0-T19 | `listPartitionValues` default | @me | ✅ | 2026-05-24 | emptyList | +| P0-T20 | `ConnectorPartitionInfo` 追加 rowCount/sizeBytes/lastModifiedMillis | @me | ✅ | 2026-05-24 | UNKNOWN=-1L;3-arg 委托到 6-arg | +| **批 2 守门 + 测试** | | | | | | +| P0-T21 | `tools/check-connector-imports.sh` 实现 | @me | ✅ | 2026-05-24 | grep 守门;正/负冒烟均通过 | +| P0-T22 | exec-maven-plugin 接入脚本(fe-connector aggregator validate) | @me | ✅ | 2026-05-24 | `inherited=false`;RFC §15.4 等价实现 | +| P0-T23 | `FakeConnectorPlugin` + 11 个 default 行为测试 | @me | ✅ | 2026-05-24 | 覆盖 Connector/Metadata/TableOps/WriteOps/Session/Context 全 default | +| P0-T24 | JDBC regression-test 全套跑通 | @用户 | ✅ | 2026-05-25 | PR #63582 流水线绿 | +| P0-T25 | ES regression-test 全套跑通 | @用户 | ✅ | 2026-05-25 | PR #63582 流水线绿 | +| P0-T26 | `ConnectorMetaInvalidator` 路由测试 | @me | ✅ | 2026-05-24 | 5 个 @Test;MockedStatic<Env> | +| P0-T27 | `CreateTableInfoToConnectorRequestConverter` 单元测试 | @me | ✅ | 2026-05-24 | 7 个 @Test;4 partition style + 2 bucket | + +完整 P0 任务清单:[tasks/P0-spi-foundation.md](./tasks/P0-spi-foundation.md) + +--- + +## 四、最近 14 天动态 + +> 倒序,新内容置顶;超过 14 天的条目移除(git log 保留历史)。 + +- **2026-07-05(下午 · P7 启动:code-grounded recon + 阶段拆分 spec)** ✅(纯文档,0 产品码,0 新决策)。跑 **10-agent recon workflow**(`.claude/wf-p7-hive-recon.js`:9 维并行 readers + 1 coverage critic;补 1 路 type-coupling recon)≈1.3M token / 0 error,核清 fe-core `datasource/hive/` **52 文件**分类、ACID 写路径(`HMSTransaction` 1895)、event pipeline(21 类)、DLA 三分流(~19 分支点)、**85 处反向 instanceof/33 文件**、跨连接器耦合、翻闸机制。coverage critic 揪出 instanceof-only 扫描的**结构盲区**(`CatalogFactory:134 new HMSExternalCatalog`、`GsonUtils:366/447/471` 兼容、`HudiUtils` 5 方法、`IcebergHMSSource` 等 type-level 耦合)→ 补充 recon 闭合。**关键澄清**:recon 标"最大未知 = iceberg/hudi-on-HMS 归属"经查 decisions-log **实为已定**——**D-020**(per-table SPI provider,hive 网关委派 -iceberg/-hudi;否决 fe-core 发现期分派)+ **D-019**(hudi live cutover 并入 P7)→ **勿重议**。产出 **`tasks/P7-hive-migration.md`**(元信息/目标/关键事实/已定架构/P7.1–P7.5 拆分/old→new 映射/删除排序/翻闸机制/SPI 缺口/验收门/8 条开放决策 OQ-*/依赖节奏/meta)。校正过时数字(instanceof 31→85、HMSTransaction 1866→1895、HMSExternalTable 1293→1332)。doc-sync:本 PROGRESS + HANDOFF(覆写→P7.1 起步)+ connectors/hive.md。**下一 = P7.1 实现**(`HiveMetadataOps`→`HiveConnectorMetadata`+`HmsClient` 写方法;no-property-parsing;`ConnectorTableOps.truncateTable` 加 default)。 + +- **2026-07-05(P6 iceberg 全部完成 + squash-合入 `branch-catalog-spi` #64688 `8b391c7459d` ⇒ P7 hive 启动)** ✅(已合入 upstream)。整条 P6(P6.1–P6.6 迁移/scan/write/procedures/sys-tables/行级 DML + 翻闸 + GSON 迁移 + 属性/鉴权全归插件 S1–S10 + 删 fe-core 原生 iceberg 子系统 −23744 行)一次性 squash 为 **#64688**(685 文件)。iceberg 已入 `SPI_READY_TYPES`,FE 走 SPI 路径。**遗留(P7 接手)**:fe-core `datasource/iceberg/` 尚存 **23 个 HMS-iceberg 支撑类**(`IcebergUtils`/`IcebergMetadataOps`/`source/IcebergScanNode`/`cache/`/`IcebergMvccSnapshot`/…),iceberg-on-HMS 走它们、随 P7 hive 迁移删(阶段四,D5/Q3=B)。**新工作分支 `catalog-spi-11-hive`**;[DEC-FLIP-1]「未 push」铁律随合入解除。**下一 = P7 hive/HMS 迁移**(master plan §3.8 + connectors/hive.md,起步 P7.1)。 + +- **2026-06-25(P6.5-T07 ⇒ parity 审计 + 2 现修 + 9 gap-fill + DV 中央登记,TDD+mutation)** 🟡(未 push;连接器 + guard 的 iceberg 分支 pre-flip dormant,guard 修对 paimon 行为不变〔默认 false 保留拒绝〕,零 live iceberg 行为变更)。**对抗 byte-parity 审计 wf** `wf_d530d760-ccf`〔8 area finder 覆 T02–T06 sys 路 + DESCRIBE/SHOW/info_schema;refute-by-default skeptic〔effort=high〕+ completeness critic;31 agent/1.6M token〕= **22 finding/19 confirmed**〔3 refuted 全对〕。**揭出 2 项主动偏差→用户 AskUserQuestion 双裁「现修」([D-067],非 DV)**:**A** parity-surface finder + critic **双独立揭出** + 主 session 实证——共享 fe-core `PluginDrivenScanNode.checkSysTableScanConstraints`〔P5 paimon `38e7140ce56`,"Mirrors PaimonScanNode.getProcessedTable"〕无条件拒**任何** `PluginDrivenSysExternalTable` 的 snapshot+scan-params;legacy `IcebergScanNode.createTableScan:569` 对 sys 表 honor `useRef/useSnapshot`〔无 isSystemTable gate〕+ 连接器 T02/T05 保留+honor pin(偏差①)→ 翻闸后 pin **dead-on-arrival**=回归〔DV-038/041 同族〕;**纠 HANDOFF**「偏差①非 DV」分类错。**B** `buildTableDescriptor` `TYPE_HMS.equals(原始值)` 大小写敏感→`type="HMS"` 得 ICEBERG_TABLE vs legacy HIVE_TABLE。**现修 A**(3 文件):新 SPI `ConnectorScanPlanProvider.supportsSystemTableTimeTravel()` 默认 **false**〔paimon/mc/jdbc/es 继承、零回归〕+ `IcebergScanPlanProvider` override true + fe-core guard capability-aware〔放行 time-travel/branch-tag,**@incr 对所有连接器仍拒**——合成元数据表 incremental 无定义、legacy 静默忽略 guard fail-loud 更安全〕;**⚠️ guard 修仅移除主动 BLOCK**,完整 sys 时间旅行 e2e 还需 query→handle pin 休眠翻闸接线〔DV-041 族〕+ P6.8。**现修 B**:`equalsIgnoreCase` 一行 + 大写 UT。**+9 连接器 gap-fill**〔handle coords-in-identity〔plan-cache key〕/pinned-toString·colhandles auth-scope×2/keyset #969249/empty-sysname·sys location-creds〔BE-403〕·capability·hms 大写·fork〕。**mutation-check**:guard Mut-A〔`=false`→AllowsTimeTravel/AllowsBranchTag 双红〕+Mut-B〔去 @incr 子句→StillRejectsIncr 红〕;hms Mut〔`equalsIgnoreCase→equals`→大写 UT 红〕;每次 `cp`/python 复原 diff IDENTICAL。**验证(重跑 surefire,Rule 12)**:连接器全量 **541/0/1**〔=532+9,0 回归〕;fe-core guard **7/0/0**;checkstyle 0;import-gate 0;`CatalogFactory:51` 未改→iceberg 仍**不在** `SPI_READY_TYPES`。**新 1 D([D-067]);新 2 DV(DV-048 correctness-bearing〔F2 paimon priv·serialized 字节潜伏〕/049 perf-cosmetic〔sys split-weight·内部 TableType·position_deletes 文案〕)**。**T07-续 ✅(同日本 session)= 残留 8 gap-fill UT 全落 + mutation**:fe-core〔sys `getMysqlType`="BASE TABLE"〔同测钉 new==legacy `ICEBERG_EXTERNAL_TABLE.toMysqlType`〕/ `getEngine`="iceberg"+`getEngineTableTypeName`="ICEBERG_EXTERNAL_TABLE"〔**assertAll** 两 pin 独立 mutation〕/ position_deletes 通用 not-found〔含 snapshots 正控〕/ 非注册不变式〔sys 类无 `@SerializedName` 声明字段 + discovery key 无 `$`〕/ guard-message 顺序〔scan-params 先于 time-travel〕〕+ 连接器〔sys predicate **作为 residual 带给 BE** + sys split `/dummyPath`〕+ WHY-comment 修。**⚠️ 纠 audit spec(实证)**:sys 元数据列谓词是 **BE-applied residual 非 FE 行裁**——record_count=10 过滤后 FE 行数 2 vs 2 不变,FE 可达观测=反序列化 `task.residual()` 携 `record_count`〔plan-time 裁是 snapshot pin,已另测〕;故 test-6 由「行裁」改「residual 携带」,**非 legacy 偏差**〔legacy 同样 serialize FileScanTask 带 residual 给 BE〕。WHY-comment 修:legacy resolution 是 **case-SENSITIVE** `TableIf.findSysTable` `Map.get` over `IcebergSysTable` 小写键〔`getTableNameWithSysTableName` 不 lower-case suffix〕→连接器 `equalsIgnoreCase` accept 是 production 永不达无害超集;原注释误归给 `MetadataTableType.from`〔后者作用在 metadata-table BUILD 时 `resolveSysTable`,非 resolution gate〕。**mutation-check(全绿→红→`git checkout` 复原 IDENTICAL)**:fe-core 5 变异一次 build〔TableIf PLUGIN mysqltype→null / getEngine+getEngineTableTypeName iceberg case 删〔assertAll 双红〕/ 注入 position_deletes / `@SerializedName` on sysTableName / swap guard 块→time-travel 先〕→ test1–5 全红+1 已知 collateral〔testDelegates size 2→3〕;连接器 2 变异〔sys 丢 filter→residual=true / 改 dummy-path 常量〕→ test6/7 红。**验证(重跑 surefire,Rule 12)**:fe-core `PluginDrivenSysTableTest` 10/0/0 + guard 8/0/0;连接器全量 **543/0/1**〔=541+2,0 回归〕`IcebergScanPlanProviderTest` 67/0 + `IcebergConnectorMetadataSysTableTest` 22/0;checkstyle 0、import-gate 0、`CatalogFactory:51` 未改。**0 新 D/DV**。**live-e2e 未跑**(dormant,P6.8 兜底)。**T08 ✅(同日 ⇒ P6.5 DONE)**:收口汇总设计 `designs/P6.5-T08-systable-summary-design.md`(7 节,仿 P6.3/P6.4-T09)+ **faithfulness 对抗验证 wf** `wf_27596236-5fe`(3 verifier refute-by-default〔fe-core / 连接器+test6 / WHY-comment 链〕+ completeness critic;4 agent/256k token)= **18/18 confirmed、0 refuted、0 critic fix**;critic 经 **iceberg 1.10.1 bytecode** 独立证实 test-6 residual(`BaseFilesTable$ManifestReadTask` 携 row filter 为 per-task residual、`ManifestEvaluator.forRowFilter` 仅 prune manifest 文件非 metadata 行、`rows()` 不 apply residual→FE 行数不变 BE 应用)+ WHY-comment 链 C1–C5 全 confirmed(注释「factually accurate」)。gate 重跑实证 543/0/1 + fe-core 10/0+8/0。**= P6.5 DONE**。**下一 = P6.6 翻闸**(全有或全无,须 holistic 修 DV-038〔读〕/041〔写〕/045〔rewrite〕+ sys 时间旅行 query→handle pin threading)。 + +- **2026-06-25(P6.5-T06 ⇒ thrift 描述符 hms↔iceberg 分叉 + `getScanNodeProperties` sys 收口 + fe-core engine/SHOW-CREATE parity,TDD)** ✅(未 push;连接器 dormant + fe-core 路 flip 后才激活,零 iceberg 行为变更)。**改动 = 4 产品 + 2 测 + 1 新测类**。**8-agent recon workflow** `wf_aefdfdd7-57e` **纠 HANDOFF 框定 2 处**:① `buildTableDescriptor` 是连接器级 SPI 钩子〔`ConnectorTableOps:187` 默认 null→fe-core `PluginDrivenExternalTable.toThrift:511` 回退 SCHEMA_TABLE〕**非 fe-core 方法**,iceberg 连接器原无覆写→base+sys 都退化 SCHEMA_TABLE;② SHOW CREATE 输出已由 `Env.getDdlStmt:4915`+`UserAuthentication:60` 解包 `PluginDrivenSysExternalTable`〔recon F 初判误称未解包,自核纠正〕,仅 authTableName priv-check 残留。**C1([D-066] 复现 fork)**:`IcebergConnectorMetadata.buildTableDescriptor` 按 `iceberg.catalog.type=="hms"`→`HIVE_TABLE`+`THiveTable` 否则 `ICEBERG_TABLE`+`TIcebergTable`〔镜像 legacy `toThrift:116-131`,6-arg 描述符+空 props,null-safe〕;SPI 无 handle→单覆写覆 base+sys,仿 paimon;**BE 无感**〔recon G:sys JNI 读只看 scan-range FORMAT_JNI+serialized_split,描述符表类型 HIVE/ICEBERG/SCHEMA 皆合法不崩〕→纯 FE parity+闭合 base 缺口。**C2([D-065] 收口)**:`getScanNodeProperties` 顶 `isSystemTable()` guard 跳 `path_partition_keys`+`schema_evolution` dict〔保 jni+location 凭据〕,修 unpinned-sys 潜伏崩溃〔`encodeSchemaEvolutionProp`→`buildCurrentSchema:194` meta 列不在 base schema 抛〕;iceberg `resolveTable` 取 base 表故须**显式** guard〔paimon type-driven 隐式跳无法照搬〕。**F1(fe-core,用户签字)**:`PluginDrivenExternalTable.getEngine/getEngineTableTypeName` 加 `case "iceberg"`→"iceberg"/`ICEBERG_EXTERNAL_TABLE`〔paimon 安全,仅 iceberg-typed catalog 命中〕。**F2(fe-core,用户签字)**:`ShowCreateTableCommand.validate():116` authTableName 解包 `PluginDrivenSysExternalTable`→source〔镜像既有 `IcebergSysExternalTable` 分支+`UserAuthentication`;**含 live paimon 潜伏 priv 修**,sys 表 SHOW CREATE 现按 base 授权,严格更宽松同向;**⚠️ 无隔离 UT**——`validate()` 依赖全局单例 `Env`/`ConnectContext`/`AccessManager`,仓内无既有 harness→编译+与 live `UserAuthentication`/`Env` 一致性+P6.8 e2e 兜底〕。**TDD**:C1 新测类 `IcebergBuildTableDescriptorTest` 3 测 + C2 2 测加 `IcebergScanPlanProviderTest` + F1 2 测加 `PluginDrivenExternalTableEngineTest` → RED〔C1 null/NPE;C2 unpinned 抛 Runtime 潜伏崩溃+pinned dict-present;F1 "Plugin"/"PLUGIN_EXTERNAL_TABLE"〕→ GREEN → **mutation-check 3 处**〔A fork 判别 `TYPE_HMS`→`TYPE_REST` swap→hms/rest 双红;B dict guard `if(true)`→unpinned 抛+pinned dict-present 红;C ppk guard `if(true)` 保 dict guard→unpinned 达 `assertFalse(ppk)` 红〔治 trivially-pass 坑〕;`cp` 复原 diff IDENTICAL〕。**验证(重跑 surefire,Rule 12)**:连接器全量 **532/0/1**〔=527+5;`IcebergBuildTableDescriptorTest` 3/0/0、`IcebergScanPlanProviderTest` 63/0/0〕;fe-core `PluginDrivenExternalTableEngineTest` **14/0/0**〔12+2,含 F2 `ShowCreateTableCommand` 编译〕;checkstyle 0;import-gate 0;`CatalogFactory:51` 未改→iceberg 仍**不在** `SPI_READY_TYPES`。**新 1 D([D-066]);无新 DV**〔fork 复现+engine/show-create 现修皆消除偏差〕。**消解 T07 预登记 3 项**〔thrift 分叉/engine name/SHOW CREATE 解包〕;残留 T07 DV:内部 `TableType.PLUGIN_EXTERNAL_TABLE`〔user-visible engine/descriptor 已 parity〕/position_deletes 文案/serialized 字节潜伏(T05)。**live-e2e 未跑**(dormant,P6.8 兜底)。**下一 = P6.5-T07**〔parity 审计+DV 中央登记+对抗 parity wf〕。 +- **2026-06-24(P6.5-T05 ⇒ `IcebergScanPlanProvider`/`IcebergScanRange` sys split 路,TDD)** ✅(未 push;连接器 dormant,零行为变更)。**改动 = 2 产品文件 + 同两测试类 +6 测**;P6.5 唯一全新一块(连接器已有 FORMAT_JNI 默认 + `buildScan` time-travel,缺 serialized-split 发射)。**起步先 7-agent recon workflow** `wf_c219ede1-8b6`〔逐行核 legacy 字节形状/连接器埋钩/thrift 字段/BE 消费方/paimon 模板/测试基建〕,**纠设计 1 处**:recon agent 误称 legacy sys 表「无 time-travel」,直读 `IcebergScanNode.createTableScan():569,579-583` 实证 legacy 对 sys 表同 honor useSnapshot/useRef(偏差①正确)。**产品([D-065])**:① `IcebergScanRange` 新 `serializedSplit` 字段+`Builder`+`getSerializedSplit()` + `populateRangeParams` 顶 sys 分支〔`if(serializedSplit!=null)` 镜像 legacy `setIcebergParams` 早返:**仅** `setFormatType(FORMAT_JNI)`〔`:290`〕+`setTableLevelRowCount(-1)`〔`:291`〕+`setSerializedSplit`〔`:292`〕,return,normal range 字节不变〕;② `IcebergScanPlanProvider` `planScanInternal` 顶 sys guard→新 `planSystemTableScan`〔`resolveSysTable`〔`executeAuthenticated` 内 `MetadataTableUtils.createMetadataTableInstance`,镜像 T04 `loadSysTable`〕→**复用** `buildScan`〔time-travel+谓词〕→`scan.planFiles()`→`SerializationUtil.serializeToBase64(task)`→`IcebergScanRange{/dummyPath,serializedSplit}`〕;imports 仅加 `MetadataTableType`/`MetadataTableUtils`/`util.SerializationUtil`。**两裁定 [D-065]**:T05 仅 scan split 路,`getScanNodeProperties` sys handle〔path_partition_keys/schema_evolution dict 从 base 构〕推迟 T06〔设计 §10 归 T06〕;sys 分支显式 FORMAT_JNI 忠实 legacy+可单测。**发现(非 DV)**:`$snapshots`/`$history` 忽略 useSnapshot〔legacy 同〕,**`$files`** 才时间旅行可观测→time-travel UT 用之。**TDD**:carrier API stub→6 UT〔carrier 2 + provider 4,含 **deserialize-round-trip 经 BE 路** `deserializeFromBase64(...).asDataTask().rows()` = 最强 FE-可达 byte-shape parity 核〕→ RED〔4 真红 + 2 guard〕→ GREEN → **mutation-check 4 处**〔A 去 `resolveSysTable` auth→`LoadsMetadataInsideTheAuthScope`〔authCount 1→0〕红;B `newScan()` 替 `buildScan`〔丢 pin〕→`HonorsTheSnapshotPin`〔`$files` 1→2〕红;C 删 FORMAT_JNI→carrier 红;D 删早 return→`isSetFormatVersion` 红;每次 `cp` 复原 diff IDENTICAL〕。**验证(重跑 surefire 实证,Rule 12)**:`IcebergScanRangeTest` **19/0/0**、`IcebergScanPlanProviderTest` **61/0/0**;连接器全量 **527/0/1**〔40 类,=521+6,python 聚合 XML〕;checkstyle 0;import-gate exit 0;`CatalogFactory:51` 未改→iceberg 仍**不在** `SPI_READY_TYPES`。**新 1 D([D-065]);无新 DV**〔time-travel/全 JNI/byte-shape 皆 legacy parity;DV 延后 T07〕。**⚠️ 潜伏**:serialized `FileScanTask` 字节跨版本/classloader 兼容 BE `IcebergSysTableJniScanner` FE 不可及,P6.8 e2e 兜底(**勿 claim parity done**,Rule 12)。**live-e2e 未跑**(dormant)。**下一 = P6.5-T06**〔thrift hms↔iceberg 分叉核 + DESCRIBE/SHOW parity + `getScanNodeProperties` sys 收口〔[D-065] 推迟项〕〕。 +- **2026-06-24(P6.5-T04 ⇒ `IcebergConnectorMetadata.getTableSchema` + `getColumnHandles` sys 分支,TDD)** ✅(未 push;连接器 dormant,零行为变更)。**改动 = 单文件产品 `IcebergConnectorMetadata.java` + 同测试类 `IcebergConnectorMetadataSysTableTest` 加 7 测**,镜像 paimon `getTableSchema`/`getColumnHandles` sys 分支(无 paimon 4-arg Identifier / transient Table——sys 表经 `MetadataTableUtils` 从 base 懒构;SDK iceberg **1.10.1**)。**产品([D-064])**:新 `loadSysTable`=`executeAuthenticated` 内 `catalogOps.loadTable(base)` + `MetadataTableUtils.createMetadataTableInstance(base, MetadataTableType.from(sysName))`〔base 加载+meta 构建同一 auth scope;`from` 大小写不敏感、名已验证→永不 null;唯一新 import `MetadataTableUtils`〕→ ①2-arg `getTableSchema` sys 分支〔`buildTableSchema(meta, meta.schema())`,复用 `parseSchema` 自动透 `enable.mapping.*`,**已核实** `parseSchema:530-535` 从 `properties` 读,偏差⑤〕②3-arg `getTableSchema(@snapshot)` sys 短路〔meta-table schema 与快照无关,legacy 无 schema-at-snapshot for sys;时间旅行 pin 选 SCAN 行落 T05〕③`getColumnHandles` sys 分支〔同潜伏 bug,sys handle 必返 meta-table 列供通用 scan node 按名解析;共享 helper〕。**测试坑**:`createMetadataTableInstance` 需 `HasTableOperations`(真 `BaseTable`)——`FakeIcebergTable` 不是→改用真 `InMemoryCatalog` 表注入 `RecordingIcebergCatalogOps.table`〔base 列 id/name 故意 ≠ meta 列〕。**TDD**:7 UT 先 RED〔5 真红:current 产品对 sys handle 返 base 列 `[id,name]`;2 auth-scope guard trivially-pass〕→ GREEN → **mutation-check**〔A 去 auth 包裹→`LoadsBaseInsideAuthScope`+`RunsInsideAuthenticator` 双红;B load 用 sysName 替 tableName→`LoadsBaseInsideAuthScope`〔base-coords〕红;C 硬编码 `SNAPSHOTS`→`UsesSysNameTypeNotHardcoded`〔history〕红、snapshots 绿;每次 `cp` 复原 diff IDENTICAL〕。**验证(重跑 surefire 实证,Rule 12)**:`IcebergConnectorMetadataSysTableTest` **18/0/0**〔11 T03+7 T04〕;连接器全量 **521/0/1**〔40 类,=514+7,python 聚合 XML〕;checkstyle 0;import-gate exit 0;`CatalogFactory:51` 未改→iceberg 仍**不在** `SPI_READY_TYPES`。**新 1 D([D-064]);无新 DV**〔meta-table schema 不随快照变=legacy parity;getColumnHandles 返 meta 列=正确性修;DV 延后 T07〕。**live-e2e 未跑**(dormant,P6.8 兜底)。**下一 = P6.5-T05**〔`IcebergScanPlanProvider`/`IcebergScanRange` sys split 路:`planFiles`→序列化 `FileScanTask`→`serialized_split`+FORMAT_JNI + time-travel useSnapshot/useRef,偏差①②;唯一全新一块〕。 +- **2026-06-24(P6.5-T03 ⇒ `IcebergConnectorMetadata.listSupportedSysTables` + `getSysTableHandle`,TDD)** ✅(未 push;连接器 dormant,零行为变更)。**改动 = 单文件 `IcebergConnectorMetadata.java` + 新 UT 类 `IcebergConnectorMetadataSysTableTest`(11 测)**,镜像 paimon `PaimonConnectorMetadata:322-408`,两处 iceberg 偏差:保留 pin(偏差①)+ LAZY。**`listSupportedSysTables`** = `MetadataTableType.values()` 去 `POSITION_DELETES` 小写 + `Collections.unmodifiableList`(连接器-global);镜像 legacy `IcebergSysTable.SUPPORTED_SYS_TABLES` 同 formula,SDK 1.6.1 = 15 名。**`getSysTableHandle`** = `isSupportedSysTable` guard〔null/unknown/`position_deletes`→empty,Q2〕→ 小写 → `forSystemTable(...)` 保留 pin。imports 仅加 `MetadataTableType`+`Collections`〔**无** `MetadataTableUtils`——移 T04〕。**决策 [D-063]:`getSysTableHandle` LAZY(零 catalog 往返)**——设计/HANDOFF 倾向 eager(T03 build+seam-identity),但三事实裁 LAZY:①legacy `getSysIcebergTable():83-97` 懒构、resolution 不加载;②iceberg handle 无 transient SDK Table(≠ paimon)→ eager build 被丢弃+多一次远程往返(性能回归);③fe-core 先 `getTableHandle` 预检 base 存在性。HANDOFF 明授权「懒 vs eager 由实现 recon 定」→ 裁 LAZY,无需再问;metadata-table build + seam-identity UT 移 T04(legacy 真 build 点,parity 更忠实),决策 B 不变仅落点 T03→T04。**TDD**:11 UT 先 RED〔6 真红 + 5 guard 负例对空 SPI default trivially-pass〕→ GREEN → **mutation-check**〔3 不相交变异一次跑出恰 3 红:清 pin→`RetainsSnapshotPin` / 不跳 position_deletes→`EmptyForPositionDeletes` / 去 unmodifiable→`IsUnmodifiable`,其余 8 绿;`cp` 复原 diff IDENTICAL〕。**验证(重跑 surefire 实证,Rule 12)**:`IcebergConnectorMetadataSysTableTest` **11/0/0**;连接器全量 **514/0/1**〔40 类,=503+11,python 聚合 XML〕;checkstyle 0;import-gate exit 0;`CatalogFactory:51` 未改→iceberg 仍**不在** `SPI_READY_TYPES`。**新 1 D([D-063]);无新 DV**〔lazy 比 eager 更贴 legacy〕。**下一 = P6.5-T04**〔`getTableSchema` sys 分支 + `executeAuthenticated` 内 `MetadataTableUtils` 构建〔决策 B〕+ seam-identity UT〔从 T03 移入〕〕。 +- **2026-06-24(P6.5-T02 ⇒ `IcebergTableHandle` sys 变体,TDD)** ✅(未 push;连接器 dormant,零行为变更)。**进 T02 前用户二次签字(AskUserQuestion)**:决策 A = `forSystemTable` **保留** snapshot/ref/schemaId pin〔≠ paimon 清零,偏差①——iceberg sys 表合法时间旅行 `t$snapshots FOR VERSION AS OF`〕;决策 B = **无新 seam**〔T03 复用 `loadTable` + 连接器内 `MetadataTableUtils`,净 0 新 SPI〕——均选推荐。**改动 = 单文件 `IcebergTableHandle.java` + UT**:加 `sysTableName`〔**非 transient**,小写 bare 名,`null`=普通表〕+ `forSystemTable(db,table,sysName, long snapshotId,String ref,long schemaId)`〔保留 pin〕+ `isSystemTable()`/`getSysTableName()`;`equals`/`hashCode`/`toString` 纳入 `sysTableName`〔snapshot 字段已在身份内→`t$snapshots@v1`≠`@v2`≠`t`〕;`withSnapshot` **保留** `sysTableName`〔copy 工厂不退化 sys→普通,镜像 paimon `withScanOptions`/`withBranch`〕。**设计偏差修正(Rule 7/12)**:设计 §4 工厂签名写 boxed `Long/Integer`,实码字段是 primitive `long`〔`NO_PIN=-1L`〕→ 用 `long` 对齐既有风格〔conformance〕。**TDD**:9 UT 先 RED〔test-compile `cannot find symbol`〕→ GREEN → **mutation-check**〔清 pin→4 红〔`forSystemTableRetainsSnapshotPin`/`RetainsRefPin`/`sysHandleAtDifferentVersionsAreDifferent`/`SurvivesJavaSerializationRoundTrip`〕→复绿,证测真 pin 偏差①〕。**验证(重跑 surefire 实证)**:`IcebergTableHandleTest` **14/0/0**〔5 旧+9 新,方法名核 XML〕;连接器全量 **503/0/1**〔39 类,=494+9〕;checkstyle 0;import-gate exit 0;`CatalogFactory` 未改→iceberg 仍**不在** `SPI_READY_TYPES`。**无新 D/DV**〔DV 延后 T07;偏差① 是 parity-保留内部选择,legacy 同 honor 时间旅行,非 pre-flip 偏差〕。**下一 = P6.5-T03**〔`listSupportedSysTables`+`getSysTableHandle`:`isSupportedSysTable` guard + `executeAuthenticated` 内 `MetadataTableUtils` 构建〔决策 B〕+ position_deletes 不上报 + seam-identity UT〕。 +- **2026-06-24(P6.5-T01 ⇒ sys-table recon + 设计 + 用户二签字 ⇒ P6.5 启动)** ✅(未 push;**纯文档 0 产品码**,镜像 P6.4-T01)。**P6.5 = 仅系统表**(`$snapshots/$history/$files/$manifests/$partitions/...` = `MetadataTableType.values()` 去 `position_deletes`);**镜像 P5-paimon B4**(连接器 2 override `listSupportedSysTables`/`getSysTableHandle` + fe-core 通用 `PluginDrivenSysExternalTable`,[D-039]/[DV-023],**净 0 新 SPI**、**fe-core 零改动**——对比 P6.4 净 +1 SPI)。recon = 对抗 workflow `wf_bf813782-b4b`〔4 并行 Explore reader〔paimon 先例 / fe-core 通用机制 / legacy iceberg sys-table 扫描路 / 元数据列 scope〕 + synthesize + completeness critic,6 agent/1130s/484k tokens〕+ 主 session 独立读码核对。**critic 5 follow-up 全解决**:test infra `RecordingIcebergCatalogOps` 已存在 / seam-identity 不变式〔无 4-arg Identifier→捕获 base 表+`MetadataTableType`+在 auth 内〕/ scan-plane 可行〔连接器有 FORMAT_JNI 默认、缺 serialized-split 发射,`TIcebergFileDesc.serialized_split` 字段已存在〕/ DESCRIBE 走通用机制 / **critic correction = legacy `IcebergSysExternalTable` 报 `ICEBERG_EXTERNAL_TABLE`〔非 PLUGIN〕→ flip 类型变更登记 DV**。**用户二签字(AskUserQuestion)**:Q1=仅系统表〔元数据列 `IcebergMetadataColumn`/`IcebergRowId` 推迟 P6.6 写路径 DV-041 同族——挂 nereids `instanceof IcebergExternalTable` + `getFullSchema()` 钩子、不受 `SPI_READY_TYPES` 控制、flip 后失效,无 paimon 模板〕;Q2=position_deletes 不上报〔→通用 not-found,fe-core 通用机制零改动〕。**5 偏差不能照抄 paimon**:①时间旅行〔sys handle **保留** snapshot pin,勿抄 paimon MVCC-排除——#1 约束〕②全 JNI〔勿抄 paimon binlog/audit_log-only〕③SDK `MetadataTableUtils` 构建〔无 4-arg Identifier,无新 seam〕④position_deletes 不上报⑤schema mapping flag 透传⑥thrift hms 分叉 + 类型变更。产出 `designs/P6.5-T01-systable-design.md`〔11 节〕+ `research/p6.5-iceberg-systable-recon.md`〔8 节〕。**无新 D/DV**〔DV 登记延后 T07 批量〕。0 产品码→iceberg 仍**不在** `SPI_READY_TYPES`。**下一 = P6.5-T02**〔`IcebergTableHandle` sys 变体,TDD;待用户批准进 T02 + 确认 §4 保留 snapshot pin / §5 无新 seam〕。 +- **2026-06-24(P6.4-T08 + T09 ⇒ parity-UT 审计/DV 中央登记 + 收口汇总设计/faithfulness 对抗验证 ⇒ P6.4 DONE)** ✅(未 push;T09 纯文档 0 产品码,T08 唯 1 行注释)。**T08**(commit `34766150f17`)= 对抗 byte-parity 审计 wf〔12 area finder→28 confirmed utGap + 2 newDeviation + critic 8 跨切 layering〕→ 20 gap-fill UT〔连接器 494/0/1 + fe-core 双测〕;2 测模型坑实证修〔expire deleteWith `[0,0,0,0,2,0]` / rewrite spec_id 漏 `validate()`→getInt no-op,非产品 bug〕;**DV-045〔🔴 BLOCKER=rewrite 执行半翻闸阻塞〕/046〔correctness-bearing=auth-add+DV-T05r-where〕/047〔perf-cosmetic 批〕中央登记**〔44→47〕。**T09**(收口)= 新 `designs/P6.4-T09-procedure-summary-design.md`〔7 节,镜像 P6.3-T09:架构总览 + T01–T08 索引 + **procedure SPI 收口核对**〔净 **+1 SPI** = `ConnectorProcedureOps`,对比 P6.2「净 0」/ P6.3「SPI 统一」;arg 框架升 `fe-foundation` 共享〕 + DV-045/046/047 回指 + 翻闸阻塞〔= DV-045,DV-041 同族〕 + 验收门 + P6.5〕。**faithfulness 对抗验证 `wf_986bd3db-68b`**〔7 cluster verifier refute-by-default + completeness critic,65 claim〕= **3 真错 + 1 内部矛盾**〔全修,Rule 12〕:①「九 commit 待 push」**实为二**〔`git rev-list --count origin/catalog-spi-10-iceberg..HEAD`=2;仅 T07 `4c84ebf33f8`+T08 `34766150f17` 未推,T01–T06+arg-move 七 commit 已推 origin=`bdc38b14810`——**同步修 HANDOFF 旧述「所有 commit 均未 push」**〕;② `BaseExecuteAction` **非字节不变**〔arg-move `b045c9db45b` 改 import+try/catch rewrap +10/-3,行为保留;唯 `ExecuteActionCommand`/`ExecuteAction` 真字节不变〕;③ stale `IcebergScanNode.getFileScanTasksFromContext:498`→def `:492`/caller `:929`〔同步修 deviations-log DV-045〕;④ §3 内部矛盾「NamedArguments 留 fe-core」vs「升 fe-foundation」〔修〕。critic 另证 44→47 DV / 475→494+1=20 gap-fill / 9-procedure 名集 / import-gate 禁 `common` 全 reconciled-OK。**gate 核(重跑实证非凭 `@Test` 计数,Rule 12)**:iceberg **不在** `SPI_READY_TYPES`〔`CatalogFactory:51`〕;import-gate exit 0;`git diff 52e25fb25e9..HEAD` = **0 BE / 0 gensrc / 0 CatalogFactory / 1 pom**〔`fe-connector-iceberg` 加 `fe-foundation` 依赖,arg-move〕;**iceberg UT 重跑 `BUILD SUCCESS` 494/0/1**〔surefire 39 类 tests=494/0/0/skip1〕 + **fe-core `ConnectorExecuteActionTest` 重跑 14/0**〔补 T08 留的「运行中确认」〕。**P6.4 全 9 task DONE,仍 behind gate**(翻闸只在 P6.6)。**下一 = P6.5 sys-table**〔iceberg `$`-后缀 metadata 表,复用 P5-B4 live 机制〕。 +- **2026-06-24(P6.4-T07 ⇒ dispatch rewire:EXECUTE → `getProcedureOps()` DONE)** ✅(未 push,**0 连接器/BE/pom/CatalogFactory**,纯 fe-core)。新 fe-core adapter `ConnectorExecuteAction implements ExecuteAction`(`nereids/.../commands/execute/`)+ `ExecuteActionFactory` 加 `instanceof PluginDrivenExternalTable` 分支〔`createAction` 返 adapter、`getSupportedActions` 通用 overload→`getProcedureOps().getSupportedProcedures()`〕保 legacy `IcebergExternalTable` 分支〔P6.7 删〕。**adapter 而非 inline**:`createAction` 返 `ExecuteAction` vs 连接器返 `ConnectorProcedureResult` 阻抗不匹配 ⇒ adapter 经正常 `ExecuteActionCommand.run()` 流(**run() 100% 不变=legacy 结构性 byte-parity**)复用 logRefreshTable+sendResultSet。engine 保 priv(`validate()` 逐字复刻 `BaseExecuteAction` priv 块,无 namedArguments)+ 单行 `CommonResultSet` 包装(`wrapResult`:`ConnectorColumnConverter` + 宽度 `checkState` + 空 schema/空 rows→null);connector 保 arg+body+commit(auth)+cache。**异常**:`DorisConnectorException`(unchecked)→ adapter catch → plain `UserException`(非 `DdlException`,legacy body 抛的就是 plain UserException,同 formatting)→ run() 加 "Failed to execute action:" 前缀字节同;table-not-found→`AnalysisException`;getProcedureOps null→`DdlException`。**dispatch 链镜像 `visitPhysicalConnectorTableSink:636-667`**(catalog→connector→session→metadata→handle→execute),priv 严格在连接器交互前。**WHERE 拒(DV-T07-where,fail-loud)**:lowering 推后 R-B;唯一吃 WHERE 的 rewrite 不走此派发。**TDD 13 测**(RED:缺类编译失败 + DdlException.getMessage 加 errCode→改 plain UserException)。**faithfulness `wf_c8256474`**(5 finder + refute-by-default skeptic + completeness critic)= **5 finder 0 finding**;critic 6 类(全非 dormant blocker)→ **2 当场修**〔空-rows→null 形状 faithfulness〔连接器 null-row 编码 (schema,emptyRows),legacy null-row→null〕+ priv 测断言 `Exception`→`AnalysisException`+消息〕、**2 DV→T08**〔DV-T07-name-order〔未知名校验时序 priv-first 有意发散〕/DV-T07-exc-contract〕、**2 note**〔resolveConnectorTableHandle bypass=有意镜像写路径/flip-safety grep-gate 非 UT=惯例〕。**验收**:fe-core `ConnectorExecuteActionTest` **13/0/0/0** + `NereidsParserTest` **73/0/0/0**(唯一 `ExecuteActionFactory` 引用者无回归)、checkstyle 0、import-gate 0、iceberg 仍**不在** `SPI_READY_TYPES`、0 连接器/BE/pom 改。**下一 = P6.4-T08**(parity 审计 + DV-T05r/T06r/T07 批量中央登记)。 +- **2026-06-24(P6.4-T04 ⇒ 港 8 pure-SDK procedure 体 + `RewriteManifestExecutor` DONE)** ✅(未 push,**0 fe-core/BE/pom**)。8 action(`Iceberg{RollbackToSnapshot,RollbackToTimestamp,SetCurrentSnapshot,CherrypickSnapshot,FastForward,ExpireSnapshots,PublishChanges,RewriteManifests}Action`)+ 港 `RewriteManifestExecutor`〔去 `ExternalTable` 参 + 去 `Env...invalidateTableCache`〕→ `connector.iceberg.action`,接 `IcebergExecuteActionFactory.createAction` 8 case〔`rewrite_data_files` 留 default-throw = T05/T06〕。body = legacy 去 fe-core import + 5 机械换型:传入 SDK `Table` 直用 / cache 失效搬 dispatch / `UserException`/`AnalysisException`→`DorisConnectorException`〔message 字节同〕/ `new Column(name,Type.X,boolNullable,comment)`→`new ConnectorColumn(name,ConnectorType.of("X"),comment,boolNullable,null)`〔**更正:legacy `Column(String,Type,boolean,String)` 第 3 参是 `isAllowNull` 非 isKey** ⇒ 结果列多 NOT-NULL、唯 `fast_forward.previous_ref` NULLABLE〕/ 去 `getDescription`。逐字 bug-for-bug:publish STRING+`"null"`、fast_forward 无-guard `snapshotAfter`+`trim` 只输出、cherrypick 泛化 "Snapshot not found in table"、rollback_to_snapshot not-found **try 外**〔不 wrap〕vs set/cherry **try 内**〔wrap〕、expire 6×BIGINT+双 wrap+`ZoneId.systemDefault()` zone〔非会话 TZ〕+`SupportsBulkOperations` warn-skip+`finally` shutdown、rewrite 双 wrap+空表短路 `["0","0"]`。**🔧 必须改签名**〔更正 HANDOFF「T04 无须改签名」〕:`rollback_to_timestamp` 需**会话 TZ**〔legacy `TimeUtils.msTimeStringToLong(str, getTimeZone())` 读 thread-local `ConnectContext`,连接器够不到〕⇒ `BaseIcebergAction.execute/executeAction` 加 `ConnectorSession`〔7 个非 TZ body 忽略;**SPI/factory 签名不动**〕;新 `IcebergTimeUtils.msTimeStringToLong`〔ms 格式 `yyyy-MM-dd HH:mm:ss.SSS`〔**非** `datetimeToMillis` 的 ss 格式〕+ alias-map + `-1` sentinel,忠实镜像 legacy〕、`resolveSessionZone` 提 public。cache 失效 = `IcebergProcedureOps.runInAuthScope` body 正常返回后 `context.getMetaInvalidator().invalidateTable`〔无条件含短路 = 幂等 pre-flip 微差〕。**faithfulness 对抗 `wl33dyokd`/`wf_973bd34f`**(11 finder + refute-by-default skeptic + completeness critic)= **1 raw→0 confirmed/1 refuted+0 critic gaps**〔refuted=`TimeUtils-1` NIT:`resolveSessionZone` null-session 回落 UTC vs legacy 系统 TZ,EXECUTE 路不可达 + P6.2-T07 既有共享件〕。**验收全绿**(`-Dmaven.build.cache.enabled=false`):8 新测类 + 扩 `IcebergProcedureOpsTest`〔catalog-backed:auth-scope/dispatch invalidate/会话透传 TZ/failAuth 不失效〕+ `ActionTestTables` fixture + `RecordingConnectorContext` recording invalidator;fe-connector-iceberg **444/0/1**(401→444)、checkstyle 0、import-gate 0、iceberg 仍**不在** `SPI_READY_TYPES`、**0 BE / 0 fe-core / 0 pom 改**。auth 补 + cache 搬家 + 短路多失效 + `executeAction` 加参 = pre-flip 行为偏差 → **T08 批量登记 DV**。**下一 = P6.4-T05**(`rewrite_data_files` 规划半 → 连接器)。 +- **2026-06-24(T03 后续重构:arg 框架 → fe-foundation 共享,用户要求)** ✅(未 push)。`ArgumentParser`/`ArgumentParsers`/`NamedArguments` 从 fe-core `org.apache.doris.common` 提升到最底层 **`org.apache.doris.foundation.util`**,引擎与连接器**共享一份**、删 T03 复制进连接器的副本。**异常(用户选 A)**:fe-foundation 够不到 fe-common 的 `AnalysisException`(连接器也被 gate 禁)⇒ `NamedArguments.validate` 改抛 unchecked `IllegalArgumentException`(error 串字节不变),两侧 catch 后 re-wrap:fe-core `BaseExecuteAction`→`AnalysisException(msg)`〔保 legacy parity〕、连接器 `BaseIcebergAction`→`DorisConnectorException(msg)`;`ArgumentParsers` 的 Guava `Preconditions.checkNotNull`→JDK `Objects.requireNonNull`(fe-foundation 无 Guava)。改:fe-foundation +3 类+2 测试;fe-core −3 类−2 测试+`BaseExecuteAction`+8 action import;连接器 −3 副本−2 测试+`BaseIcebergAction`+`BaseIcebergActionTest`+**pom 加 `fe-foundation`**。**验收**:fe-foundation 20+20/0、fe-connector-iceberg **401/0/1**(412 含被删 2 测类 ⇒ 401=389+12,cache 禁用+`skipCache` fresh)、fe-core test-compile 绿、checkstyle 0、import-gate 0、iceberg 仍**不在** `SPI_READY_TYPES`、0 BE/0 pom-dM 改。**下一 = P6.4-T04**。 +- **2026-06-24(P6.4-T03 ⇒ base+factory+arg 框架 port + dispatch 骨架)** ✅(未 push,**0 fe-core/BE/pom**)。新包 `connector.iceberg.action`(4 产品文件)+ 改 `IcebergProcedureOps`:① arg 框架 `ArgumentParser`/`ArgumentParsers`/`NamedArguments` 从 `org.apache.doris.common` 逐字 port(import-gate 禁 `common`),唯一改 = `NamedArguments.validate` 抛 `DorisConnectorException`(unchecked)替 `AnalysisException`,**所有 error 串字节不变**(§4=4-A 连接器自校验,T08 byte-parity 兜);② `BaseIcebergAction` 独立基类(非 extends 被禁的 `BaseExecuteAction`),折入其被消费机器,SPI 中立型〔`List partitionNames` / `ConnectorPredicate where` / `List getResultSchema` / `executeAction(org.apache.iceberg.Table)`〕,`validate()`=args+`validateIcebergAction`〔**无 priv**——引擎保,D-062 §2〕,`execute(Table)`=单行包装+宽度 `checkState`,4 partition/where 守卫 message 字节同,去无消费者 `getDescription`;③ `IcebergExecuteActionFactory`(去死 `table` 参)9 名常量+`getSupportedActions()` legacy 序 + `createAction` **只留 faithful default-throw**〔9 case=T04/T05-T06〕;④ `IcebergProcedureOps` dispatch 骨架 `getSupportedProcedures`+`execute`〔downcast→createAction→validate→**`runInAuthScope`**:load+body+commit 同一 `executeAuthenticated` 作用域〔recon §7,legacy snapshot mutator 缺的 auth 修在此〕,body `DorisConnectorException` verbatim 透出引擎壳 T07 加前缀〕。**T04 仅加 factory case+action 类+post-commit cache〔dispatch 级 `context.getMetaInvalidator()` 已存在〕——不改 base/factory/dispatch 签名**。**faithfulness 对抗验证 `wf_009434eb-5a7`**(4 dim review + per-finding refute-by-default verify + completeness critic)= **4 raw → 0 confirmed**(priv 留引擎/empty-rows-vs-null 等价不可达/T04 error-串义务/T04 cache 义务皆 design-ok);critic 5 findings 中 **CRITIC-0〔HIGH:commit 须在 auth 内〕自查为真 → 已落 `runInAuthScope`**,余 4 NIT。**验收全绿**:5 新测类 **23/0/0**、fe-connector-iceberg 全模块 **412/0/1**(389→412)、checkstyle 0、import-gate 0、iceberg 仍**不在** `SPI_READY_TYPES`、**0 BE / 0 fe-core / 0 pom 改**。auth 补 = pre-flip 行为偏差 → 设计 §10/recon §7 **T08 批量登记 DV**。**下一 = P6.4-T04**(8 pure-SDK 体)。 +- **2026-06-24(P6.3-T09 ⇒ 收口汇总设计 + faithfulness 对抗验证 ⇒ P6.3 DONE)** ✅(未 push,纯文档 0 产品码)。新 `designs/P6.3-T09-iceberg-write-summary-design.md`(7 节,镜像 `P6-T11-iceberg-scan-summary-design.md`):架构总览 + T01–T08 逐 task 索引(含各 commit + 对抗 wf 结论)+ **写路径 SPI 收口核对**(与 P6.2「净 0 新 SPI」**相反**——P6.3 有意 SPI 统一:删双模型 fork + config-bag 三件套,收敛为单 `ConnectorTransaction` 写模型 + capability 派发)+ deviation 回指 DV-041..044 + P6.6 翻闸阻塞汇总(DV-041 = DV-038 写路径新面)+ 验收门 + 下一阶段。**code-grounded 核实**(grep 实证非凭文档):`SPI_READY_TYPES`={jdbc,es,trino-connector,max_compute,paimon}〔iceberg 缺席〕+ switch-case `:137` 仍在 + config-bag 三件套 grep 净 + 统一写 SPI 面逐一存在。**faithfulness 对抗验证 `wf_9234a18e-1d9`**(6 cluster verifier refute-by-default + 1 completeness critic)= **全 CONFIRMED**,唯 1 真错(双标):§5 `PhysicalPlanTranslator:589-627` 误挂**通用** `visitPhysicalConnectorTableSink`(实测通用在 `:630-681`,`:589-627` 是 legacy delete+merge visitor)→ **已修**;critic cheap-check 另证 UT 计数静态精确(iceberg 389 `@Test` + 唯一 skip=`IcebergLiveConnectivityTest`、jdbc 190/mc 102(1skip)/paimon 318(1skip)、fe-core 11/19/14)。**0 BE / 0 pom**(git diff `84a00c56dea..HEAD` 仅 fe/、plan-doc/、regression-test/)、iceberg 仍**不在** `SPI_READY_TYPES`。**P6.3 全 9 task DONE**。**下一 = P6.4 procedures**(`ConnectorProcedureOps` E2,10 action,仍 behind gate)。 +- **2026-06-24(P6.3-T08 ⇒ 写路径 parity-UT 审计 + deviation 中央登记 DONE)** ✅(未 push)。设计 `designs/P6.3-T08-write-parity-audit-design.md`。10 维对抗审计 `wf_c1067212-ab8`(132 agents,每 gap 3-lens refute-by-default + 2 critic)= **40 报告→20 confirmed/20 refuted→11 交付**(8 新测 + 3 强化断言)。gap-fill:连接器分区 identity 冲突 filter 窄化(同/异分区并发)+ 非-identity 禁窄化 + snapshot 隔离跳 validate + PUFFIN DV dedup by path#offset#size(`IcebergConnectorTransactionTest` +4);dataLocation 级联 fallback + ORC/codec 矩阵 + `partitionSpecsJson` 字节断(`IcebergWritePlanProviderTest` +2 + WP-001 强化);O5-2 per-conjunct drop + OR all-or-nothing(fe-core `WriteConstraintExtractorTest`/`NereidsToConnectorExpressionConverterTest` +1/+1);DELETE/UPDATE operation-literal 值断(`IcebergDDLAndDMLPlanTest` 2 强化)。**有意不补(Rule 12 附理由)**:MERGE branch-label / 执行器路由(REDUNDANT-WITH-ORACLE)/ combine 两侧 AND(in-proc SDK 非 BE-observable)/ null→isNull(无 null-分区 fixture)。**deviation 中央登记** DV-041(🔴 翻闸 BLOCKER:DV-T07-materialize 通用 sink 缺合成列物化+分布=DV-038 同主题新面 + 休眠激活集)/ DV-042(北极星 iii 有界:DML 合成 fe-resident + T07c 等价结构)/ DV-043(parity-忠实 correctness-bearing)/ DV-044(perf/cosmetic/EXPLAIN-diff),4 条镜像 P6.2-T11 DV-038/039/040 分层(用户签字)。**mutation 实证**:PUFFIN 去 #offset#size → dedup 测变红(2→1)已 revert。**验收全绿**:fe-connector-iceberg **389/0/1**(383→389)、fe-core 3 测类绿、checkstyle 0、import-gate 0、iceberg 仍**不在** `SPI_READY_TYPES`、**0 SPI / 0 BE / 0 fe-core 产品 / 0 pom 改**(仅 5 测试文件)。**下一 = P6.3-T09**(收口 = P6.3 DONE)。 +- **2026-06-24(P6.3-T07c ⇒ 通用 `RowLevelDmlCommand` 壳 + 注册表 + 6 派发重接 + O5-2 dormant DONE)** ✅(commit `a61cd9262b9`,未 push)。checkpoint 2 决策:**D1** 完整 shell + 委派合成(合成留 `IcebergXCommand` 原地经 transform 委派,仅放宽 3 private→包级;单 live 循环;legacy loop transitional-dead→P6.7 删);**D2** O5-2 现接 dormant(新 `BaseExternalTableInsertExecutor.getConnectorTransactionOrNull()`,iceberg 走 legacy txn→null→不可达直到 P6.6)。新 fe-core `RowLevelDml{Command,Op,Args,Transform,Registry}` + `IcebergRowLevelDmlTransform` + 委派目标 `Iceberg{Delete,Update,Merge}Command`。fe-core 目标测 **104/0/0**(oracle `IcebergDDLAndDMLPlanTest` 14/0 byte-parity 铁证 + 新 `IcebergRowLevelDmlTransformTest` 7/0)、checkstyle 0、import-gate 0、**0 BE/thrift/pom**、iceberg 不在 `SPI_READY_TYPES`。对抗 `wf_a80f8edb-bed` = 24 raw/0 REAL/24 refuted。**下一 = P6.3-T08**。 +- **2026-06-23(P6.3-T05 ⇒ commit 校验套件 + O5-2 `applyWriteConstraint` + V3 DV `removeDeletes` DONE)** ✅(未 push)。设计 `designs/P6.3-T05-iceberg-commit-validation-suite-o5-design.md`;TDD(headline conflict 测 watch-RED→GREEN)→ 对抗 parity workflow `wf_0960ef5f-52c`(4 维 + 每发现 skeptic verify)= **0 finding / 0 confirmed**。**边界裁定 [D-061](用户签字)= O5-2 拆「连接器消费半 = T05」+「fe-core 生产半 = T07」**(fe-core 抽 analyzed-plan target-only → `ConnectorPredicate` 唯一消费者是 T07 `RowLevelDmlCommand`,挪 T07;同 T01→T03 先例)。**实改① SPI**:新 `ConnectorPredicate`(包 `ConnectorExpression`)+ `ConnectorTransaction.applyWriteConstraint(ConnectorPredicate)` default-no-op。**②连接器**:`applyWriteConstraint` override(暂存中立谓词,commit 时经 `IcebergPredicateConverter` 惰性转,与 identity-分区 filter AND 合并)+ commit 校验套件逐字移植 legacy :655-784(`validateFromSnapshot`〔消费 `baseSnapshotId`〕/serializable `validateNoConflictingDataFiles`/`validateDeletedFiles`/`validateNoConflictingDeleteFiles`/`validateDataFilesExist`/`delete_isolation_level` 默认 serializable)+ V3 DV :786-851(fmt≥3 gate / `ContentFileUtil.isFileScoped` / LinkedHashMap dedup / `removeDeletes`),接 `updateManifestAfter{Delete,Merge}` 顺序保真。**headline 测证意图**:并发 data-file append→`validateFromSnapshot`+`validateNoConflictingDataFiles` 检出→commit 抛(T04 无校验静默胜出)。**验收全绿**:connector-api 30/0/0、iceberg 341/0/1、checkstyle 0、import-gate 0、iceberg 仍**不在** `SPI_READY_TYPES`、**0 BE / 0 fe-core / 0 pom 改**。**下一 = P6.3-T06**(sink 统一)。 +- **2026-06-23(P6.3-T03 + T04 ⇒ `IcebergConnectorTransaction` 骨架·op 选择·WriterHelper DONE)** ✅(未 push,补登:PROGRESS 此前停在 T02)。**T03** = `IcebergConnectorTransaction implements ConnectorTransaction`(单 SDK txn/表经 `IcebergCatalogOps` seam + auth 包裹;14 字段 `TIcebergCommitData` `TBinaryProtocol` 反序列化 synchronized 累积;`getUpdateCnt` data/delete 拆 affectedRows 优先;新 `WriteOperation` 枚举 + `ConnectorWriteHandle.getWriteOperation` default INSERT)+ 对抗 `wf_1598e4b9-87c`(1 confirmed 修=`newTransaction()` 须在 auth 内)。**T04** = op 选择收进 `commit()`(SPI 无 finishWrite 钩子,据 `WriteOperation` switch:Append/ReplacePartitions/OverwriteFiles 空表清空/`overwriteByRowFilter` 静态/RowDelta delete/RowDelta merge)+ begin* guards(fmt≥2 delete/merge、branch 须非 tag、baseSnapshotId 捕获)+ 新 `IcebergWriterHelper`/`IcebergPartitionUtils` parse 助手/`IcebergWriteContext` + 对抗 `wf_a9356dd4-b17`(0 finding)。iceberg 295(T03)→333(T04)。 +- **2026-06-23(P6.3-T02 ⇒ jdbc thrift 入 planWrite + 删 config-bag 三件套 + EXPLAIN-保留 hook DONE)** ✅ **OQ-1/OQ-2 落地 + 用户增补 `appendExplainInfo`**(未 push)。设计 `designs/P6.3-T02-jdbc-planwrite-configbag-removal-design.md`;TDD(byte-parity 测 watch-RED→GREEN)→ 对抗 parity workflow `wf_86a9e683-6b5`(4 维 + 每发现 skeptic verify)= **0 confirmed real / 6 positive 确认**。**OQ-1** 新 `JdbcWritePlanProvider`(镜像 `MaxComputeWritePlanProvider`)直建 `TJdbcTableSink`(熔合 legacy `getWriteConfig`+`bindJdbcWriteSink`)+ `JdbcDorisConnector.getWritePlanProvider()` 接线(翻译器自动路由)+ 删 `JdbcConnectorMetadata.getWriteConfig`;byte-parity 陷阱 = 连接池用 `getInt(...,DEFAULT_POOL_*)` 非 bind 硬编 fallback。**OQ-2** 删 `ConnectorWriteType`/`ConnectorWriteConfig`/`getWriteConfig` + `PluginDrivenTableSink` config-bag 半边(含死路 `bindFileWriteSink`)+ 翻译器 config-bag 分支(→ `writePlanProvider==null` fail-loud 同款错串)。**🆕 用户增补(OQ-3 收窄)**:新 source-agnostic SPI `ConnectorWritePlanProvider.appendExplainInfo`(default-no-op,镜像扫描侧同名);jdbc 回吐 `TABLE TYPE`/`INSERT SQL`/`USE TRANSACTION`(共享 `buildInsertSql`,EXPLAIN SQL 与 BE 字节一致);`getExplainString` 委派(在 `bindDataSink` 前跑→从 handle 派生)⟹ OQ-3 diff 收窄到仅 sink-label 一行变、regression `INSERT SQL` 断言**恢复**;**T06 可复用此 hook 保留 iceberg sink-detail EXPLAIN**。**验收全绿**:jdbc 模块 190、connector-api 25、maxcompute 102(1skip)、fe-core `PluginDrivenTableSink*`+executor 12/0/0、es/trino/hudi/hive/hms test-compile SUCCESS、iceberg 278 + paimon 318 无回归、checkstyle 0、import-gate 0、iceberg 仍**不在** `SPI_READY_TYPES`、**0 BE 改**。**下一 = P6.3-T03**(`IcebergConnectorTransaction` 骨架 + addCommitData + `ConnectorWriteHandle.writeOperation`)。 +- **2026-06-23(P6.3-T01 ⇒ 写框架统一·SPI 收口 DONE,option B)** ✅ **删写 SPI 的 insert-handle/`usesConnectorTransaction` 双模型 fork,统一到单 `ConnectorTransaction` 模型**(未 push)。设计 `designs/P6.3-T01-write-framework-unification-design.md`;实现 recon `wf_3d74e33d-7c8`(5 reader+critic)→ TDD → 对抗 parity `wf_0c8b7356-dae`(5 维+每发现 skeptic verify)= **7 findings / 0 confirmed real**。**option B 切分裁定(用户签字)**:按字面 T01/T02 切分不可各自绿(jdbc 是 insert-handle 唯一消费者,删之即 strand jdbc)⟹ 把 jdbc no-op txn 迁移提到 T01(jdbc **暂留 config-bag sink**),T02 改为 thrift-入-planWrite + 删 config-bag + 字节 parity。**改动**:SPI 删 `usesConnectorTransaction`/`beginInsert·finishInsert·abortInsert`/dead delete-merge 面/3 handle iface,**保留** `getWriteConfig`/`ConnectorWriteType`/capability 查询,新增 `ConnectorTransaction.profileLabel()` + `NoOpConnectorTransaction`(getUpdateCnt=-1 哨兵);jdbc `beginTransaction→NoOpConnectorTransaction("JDBC")`;maxcompute 去 override + profileLabel="MAXCOMPUTE";`PluginDrivenInsertExecutor` 单路(finalizeSink null-session guard 防 config-bag NPE、doBeforeCommit `if(cnt>=0)` 哨兵守 jdbc affected-rows、transactionType 从 profileLabel)。**2 处有意偏移 RFC(DV-T01-c)**:writeOperation 移 T03(0 消费者)、beginTransaction 默认保 throwing(fail-loud, 既有测守)。**验收全绿**:connector-api 5 + jdbc 8 + maxcompute 9 + fe-core 50(0F0E);其余连接器 test-compile SUCCESS;iceberg 278 + paimon 318 无回归;checkstyle 0;import-gate 净;iceberg 仍**不在** `SPI_READY_TYPES`;**0 BE 改**。**下一 = P6.3-T02**。 +- **2026-06-23(P6.3 写路径 RFC · research + design-doc 阶段 ⇒ RFC 评审通过)** ✅ **P6.3 iceberg 写路径 RFC 起草 + PMC 评审通过 + 逐 task 拆解**(纯文档 0 产品码,commit `a49720820f9` 未 push)。research-design-workflow:7-reader workflow(`wf_45b33bcf-e75`:SPI 面/fe-core 驱动/maxcompute/jdbc/legacy-iceberg txn 核/legacy-iceberg nereids 层/既有决策)+ unification-critic + 主线 firsthand SPI 逐字核实 + **单独 Trino 调研 agent**(`/mnt/disk1/yy/git/trino`,用户要求)。产出 = recon `research/p6.3-iceberg-write-recon.md` + RFC `06-iceberg-write-path-rfc.md` + `.audit-scratch/p6.3-research/{findings,unification,trino-dml-analysis}.md`。**核心矛盾** = iceberg DML 的 nereids plan 改写(`$row_id` 注入/branch-label 投影/冲突检测)根本性需 nereids 类型、连接器禁 import(import-gate 实测 0)。**Trino 实证** = 连接器 0 优化器 import、引擎核心全 DML 合成、连接器只供声明式 SPI(row-id handle + paradigm + merge sink)⇒ 北极星 (iii)。**用户/PMC 裁定**:Q2 全面统一写框架(单 `ConnectorTransaction`、删 `usesConnectorTransaction` fork/insert-handle/dead delete-merge handle/config-bag 三件套、jdbc 退化 no-op txn、plan-provider-only sink、capability 派发无 instanceof);Q1 Route B(通用 `RowLevelDmlCommand` 壳 + iceberg plan 合成暂留 fe-core 有界 DV-04x、保 EXPLAIN parity);Q3 O5-2(`applyWriteConstraint` default-no-op 复用 P6.2-T02 `IcebergPredicateConverter`);OQ-1 jdbc thrift 移入连接器 / OQ-2 删 config-bag 三件套 / OQ-3 EXPLAIN sink-标签 diff 非回归。逐 task 拆解 T01–T09 = `tasks/P6-iceberg-migration.md` §「P6.3 逐 task 拆解」。**下一 = 逐一实现 T01–T09**(T01 框架统一 SPI 收口起;iceberg 仍**不在** `SPI_READY_TYPES`,behind gate 零行为变更直到 P6.6)。 +- **2026-06-23(P6.2 收口 · T11 ⇒ P6.2 DONE;并对账 P6.1 DONE + P6.2 T01–T10)** ✅ **iceberg P6.1〔T01–T10〕+ P6.2〔T01–T11〕全实现**(详见 [HANDOFF](./HANDOFF.md) 各「✅ = DONE」块 + commit 列)。**P6.1 DONE**:5-flavor CatalogUtil 装配(T05)+ s3tables bespoke 3-arg(T06)+ DLF 4-file 子树 port(T07)+ 读路径列/format-version/listing/auth parity(T09)+ metastore 模块拆分 filesystem 式〔`-paimon`/`-iceberg` per-engine 模块 + `-spi` 抽共享基类〕+ iceberg per-flavor CREATE 校验(T10 A+B)。**P6.2 DONE**:scan provider 骨架(T01)+ 谓词下推/split(T02)+ typed range-params/`path_partition_keys`(T03)+ merge-on-read delete(T04)+ COUNT 下推(T05)+ **field-id 字典**(T06)+ MVCC time-travel(T07)+ 连接器内 cache + manifest 级 planning + vendored `DeleteFileIndex`(T08)+ vended + 静态凭据(T09)+ parity-UT 审计 8 缺口补测(T10)+ **T11 收口**(汇总设计 `designs/P6-T11-iceberg-scan-summary-design.md` + validation gate 核对〔7/0〕+ **deviation 中央注册** DV-038〔翻闸阻塞 BLOCKER:GLOBAL_ROWID + getColumnHandles 2 面共享 fe-core field-id 路径 BE DCHECK〕/ DV-039〔parity-忠实 HIGH-MEDIUM〕/ DV-040〔perf-cosmetic ~36 项批〕)。**P6.2 净 0 新 SPI**(唯一例外 = T03 非破坏 `isPartitionBearing()` 默认)。审计 workflow `wf_edde7eac-a5b`(9 reader + critic:抓到 blocker 2 面非 1 + category-a classloader 漏报补登)。**验收**:fe-connector-iceberg UT **278/0/1**(本 session `mvn -pl :fe-connector-iceberg -am test` cache-off 重跑 BUILD SUCCESS)、checkstyle 0、import-gate 净、iceberg 仍**不在** `SPI_READY_TYPES`(零行为变更,翻闸只在 P6.6)。**全部 commit 未 push**(工作分支 `catalog-spi-10-iceberg`)。**下一 = P6.3 写路径**(先写 `06-iceberg-write-path-rfc.md` 过 PMC,再实现)。 +- **2026-06-21(P6.1 recon + 实现 ① · T01-T03)** ✅ **P6.1 元数据 recon + 10-task 拆解 + [D-059] + T01-T03 实现+验证+commit `ae54a2174ff`**。**recon**(7-agent 并行 + 主线 firsthand 抽验 3 处 load-bearing 断言)→ `research/p6.1-iceberg-metadata-recon.md`:核心认知=连接器 6-file 骨架是「编译通过但误导性的近-no-op」,真正 per-flavor catalog 装配在 metastore-props(`AbstractIcebergProperties.initializeCatalog:133`,非 `datasource/iceberg/*`),骨架含 **4 个 silent 读路径 bug**(mapping-flag 下划线 key 恒 false / `TIMESTAMPTZV2` 名 converter 只认 `TIMESTAMPTZ` / format-version 恒 stamp 2 / nullable·isKey·小写·WITH_TZ marker 丢)。**10-task 拆解** `tasks/P6-iceberg-migration.md` §P6.1(seam+测先→pom→5 CatalogUtil flavor→s3tables bespoke→DLF port→parity 修)。**[D-059] 用户签字**:Q1=DLF port-now read-only;Q2=**扩 metastore-spi 加 iceberg provider**(非推荐项,用户主动选,跨 metastore 子线)。**T01/T02/T03 实现+验证**:新建 `IcebergCatalogFactory`(纯静态)+ `IcebergCatalogOps`(seam)+ rewire `IcebergConnectorMetadata`(behavior frozen)+ 测试基建从无到有(`Recording*`/`Fake*` no-Mockito + `IcebergCatalogFactoryTest`13 + `IcebergConnectorMetadataTest`14);`mvn -pl :fe-connector-iceberg -am test`(cache off)= **27 run/0F/0E/0skip**(surefire 实证)+ checkstyle 0 + import-gate 净;测试独立确证 2 个 parity bug(format-version + 下划线 key)并 pin frozen 待 T08/T09。**下一 = T08(type-mapping parity,决策无关)+ T04(pom 依赖)**;T05-T07 前须对 `MetaStoreProviders.bind` mini-recon(Q2=B)。 +- **2026-06-21(设计里程碑 · P6 阶段拆分 + P5/P3b 对账)** ✅ **P6 iceberg 迁移阶段拆分完成**(0 产线代码):brainstorm 定 **方案 A / 8 阶段 / 单一翻闸在末**(用户签 [D-058])——code-grounded 核实翻闸**全有或全无**(`CatalogFactory:104-113`)⇒ P6.1–P6.5 在 `fe-connector-iceberg` 建完整能力 → **P6.6 一次性翻闸** → P6.7 删 legacy(74 文件 + ~49 反向 instanceof)→ P6.8 回归;3 缺失 SPI(`ConnectorCredentials` P6.2 / 写路径 RFC P6.3 / `ConnectorProcedureOps` P6.4,均 firsthand 确认 absent)各折进首消费阶段(paimon E5/E7/E10 成功路径)。核出连接器 flavor dispatch 2 缺口(`dlf` 引用不存在的 `DLFCatalog`、`s3tables` 需外部 SDK dep)+ iceberg metastore-props 已在 fe-core(留至翻闸后,backlog #2)。产 [tasks/P6-iceberg-migration.md](./tasks/P6-iceberg-migration.md)(phase-level plan + old→new 映射 + 验收门 + 开放决策 O1–O5)。同步刷新本 PROGRESS(§一/二/三/四/六/七)+ HANDOFF(覆盖)+ connectors/iceberg + decisions-log D-058;并把 stale 的 P5/P3b 状态对账到「全完成已合入 #64653 / #64655」。**下一 session = P6.1 元数据 recon + 逐 task 拆解**(起步无硬前置)。 +- **2026-06-20(阶段里程碑 · P5 迁移+翻闸合入 + 文档对账)** ✅ **P5 paimon B0–B7 全完成并 squash-合入 `branch-catalog-spi`** —— **PR #64446 / `38e7140ce56`**("[refactor](catalog) P5 paimon: migrate to catalog SPI + cutover")+ `e9c5b3e70ce`(修编译)。涵盖 B5 MTMV 桥(通用 `PluginDrivenMvccExternalTable`,D-040/041/042)、B5b 时间旅行全 parity(AS-OF/tag/branch/@incr,D-043/044)、B6 procedure no-op、**B7 翻闸**(入 `SPI_READY_TYPES` + GSON 原子 compat + D-045/046/047 restore SHOW PARTITIONS/SHOW CREATE)、**P6 全路径 clean-room review**(报告 `reviews/P6-paimon-fullpath-cleanroom-2026-06-18.md`:2 BLOCKER=B8 删除护栏、2 MAJOR=C1 MinIO/C2 HDFS XML 已修,余 parity)+ 全部 deviation fix(C1/C2/R1-table/R3-residual/C4+R2+R3-catalog/A1/A2/A3/B-MC2/B-R2-be)。**仅剩 P5-T29(B8 删 legacy + maven 依赖)+ P5-T30(B9 回归)**。本 session = 对账 stale 跟踪文档(PROGRESS 停 B4、tasks/P5 停 B5b、HANDOFF 停历史工作分支)→ 全部刷到「迁移+翻闸已合入、下一步 P5-T29」状态,0 产线代码;P5-T29 scope ledger(DEAD/硬前置/STILL-CONSUMED/maven 方案 A/B)已 firsthand 核实并写入 [tasks/P5 §P5-T29 执行计划](./tasks/P5-paimon-migration.md)。**下一 session = P5-T29**(建议先 AskUserQuestion 定 maven scope A/B)。 +- **2026-06-10(实现里程碑 · P5 B0–B4)** ✅ **P5 paimon B0–B4 已落地**(连接器+fe-core,**未提交**,用户控时机):B0 测基建+parity baseline / B1 flavor 装配(全 5 flavor) / B2 normal-read / B3 DDL metadata / **B4 sys-tables E7 + MVCC E5(本 session,T16-T20)**。B4 = subagent-driven(understand workflow 纠偏 2 处 → 用户签 **D-039**「E7 复用 live `SysTableResolver` 机制,非 RFC §10」[DV-023];T20 MVCC inert until B5)+ 5 dispatch(implement→双审→fix-loop)+ 3-lens final holistic(PARITY/SCOPE READY + 1 ADVERSARIAL BLOCKER「`PluginDrivenScanNode.create` 绕 seam 丢 forceJni→binlog/audit_log 静默错行」**已修**)。另核出并修 B2 遗留缺陷 [DV-024](普通 paimon plugin 表 BE 描述符 SCHEMA_TABLE→应 HIVE_TABLE)。**验证**:连接器 124/0/0/1 绿、fe-core PluginDriven*Test 100 绿、checkstyle/import-gate 0、无 cutover/B5 泄漏、唯一 fe-connector-api 改动=T16 两 default no-op。**下一 = B5 MTMV 桥**(接活 E5:`PluginDrivenExternalTable`→MvccTable + `beginQuerySnapshot` 调用 + `ConnectorMvccSnapshotAdapter` 构造)。 +- **2026-06-09(设计里程碑 · P5 kickoff)** ✅ **P5 paimon recon + 设计完成**(0 产线代码):14-agent code-grounded recon + cross-cut 对抗复审,产 [recon](./research/p5-paimon-migration-recon.md)(5 功能区旧实现 + E1–E10 SPI 状态 + 跨切面风险 + MC 一致性 11 约定)+ [设计 doc](./tasks/P5-paimon-migration.md)(old→new 映射 + 30 TODO/B0–B9 + 验收 + 批次依赖图)。**用户签字 D-037**(flavor=单 Catalog + `createCatalog` switch,**非** backend 模块)/ **D-038**(MTMV/MVCC 桥 P5 内实现,翻闸 gated on B5,禁静默读 latest)。**证伪 3 先验**:backend 模块空壳(连接器走单 Catalog stub)、FE 分发部分已预接(残留=连接器 listPartitions)、Base64 非 blocker(BE 有 STD fallback)。procedure 区=零可迁 doc-only(expire_snapshots=iceberg、CALL migrate_table=Spark 两假阳性)。**下一 = B0 测试基建 + parity baseline 起分批实现**。 +- **2026-06-09(阶段里程碑 · P4 完成)** ✅ **P4 maxcompute 迁移全部完成并合入 `branch-catalog-spi`** —— **#64253**(T01–T06 连接器 full 适配 + live 翻闸 `SPI_READY_TYPES += "max_compute"`)+ **#64300**(T07–T09 删 20 fe-core legacy 文件 + 清反向引用 + MCUtils 下沉 be-java-extensions,`fe-core dependency:tree | grep odps`=∅,HEAD `e96037cf6aa`)。upstream PR **#64119**(MaxCompute 连接校验)功能已迁连接器 SPI(`validateMaxComputeConnection`/`checkOperationSupported`,连接器 UT 101/0/0/1)并随 #64300 squash 合入(`git log -S` 证)。fe-core **彻底无 odps**(代码 + 依赖树)。本 session = 交接文档同步(PROGRESS + HANDOFF 第 19 次),0 产线代码;**下一 session = P5 paimon 迁移 kickoff**(recon + 设计 + 批次计划,复用 P4 full-adopter 写 SPI 样板)。 +- **2026-06-06(实现 ⑧·P4-T05)** ✅ **P4 Batch C 启动 — P4-T05 翻闸接线完成**(dormant、gate-green、**待 commit**,用户定时机):GsonUtils 三 GSON 注册(catalog `:397` / **db `:452`** / table `:472`)atomic 迁 `registerCompatibleSubtype`→`PluginDriven*` + 删 3 unused `maxcompute.*` import;`PluginDrivenExternalTable.getEngine`/`getEngineTableTypeName` 加 `case "max_compute"`(返 `MAX_COMPUTE_EXTERNAL_TABLE.toEngineName()`=null / `.name()`,**核 legacy 行为等价**);`legacyLogTypeToCatalogType` 仅加注释(默认分支已出 `"max_compute"`,不加 case)。**关键校正**:ordered TODO 漏 **db `:452`**——4-agent 对抗复核揪出,漏迁则翻闸后 `MaxComputeExternalDatabase.buildTableInternal:44` cast `PluginDrivenExternalCatalog`→`MaxComputeExternalCatalog` 抛 `ClassCastException`(es/jdbc/trino 均 catalog+db+table 齐迁,legacy DB 类已删);用户签字折入 T05。**复核另 2 告警判非问题**:`getMetaCacheEngine`→"default" 假阳性(plugin 路径经连接器 `initSchema` 取 schema、走 "default" 桶同 es/jdbc/trino,`MaxComputeExternalMetaCache` 仅 legacy 表引用=Batch-D 死码);`getMysqlType`→"BASE TABLE" 同 ES 既定行为(`ES_EXTERNAL_TABLE` 亦不在 `toMysqlType` switch,迁后同样 null→"BASE TABLE" 已 ship);dormancy 告警=既载中间态 caveat(其"留 registerSubtype"修法错=撞 duplicate-label IAE)。UT `PluginDrivenExternalTableEngineTest` +2 max_compute 例(9/9)。守门全绿(fe-core compile BUILD SUCCESS + checkstyle 0 + import-gate 0 + UT 9-0-0,真实 EXIT 核验)。详见设计 §3.4 / [D-026 校正]。**下一 = T06a(写接线 W-a..d + 静态分区/overwrite 绑定 + R-004 隔离 UT,dormant)→ T06b(flip)**。⚠️ T05↔flip 中间态不可部署(compat 已注册但 factory 仍 legacy)。 +- **2026-06-06(设计 ⑤·Batch C)** ✅ **P4 Batch C 翻闸设计完成 + 用户签字 [D-026]**(design-only,零代码):用户选 "Design Batch C first"。4 路 Explore re-verify recon 锚点 + 主线核读 executor/txn 生命周期,出 [翻闸设计](./tasks/designs/P4-T05-T06-cutover-design.md)(verified file:line + 5 gap G1–G5 + 写生命周期顺序 + R-004 两分测 + ordered TODO)。**3 决策签字**:D-1 capability signal=新增 `ConnectorWriteOps.usesConnectorTransaction()` flag(MC=true,否决 writePlanProvider 代理/复用 ConnectorWriteType);D-2 两 commit、flip 末(`[P4-T06a]` 接线 dormant + `[P4-T06b]` flip);D-3 静态分区/overwrite 绑定入 cutover(避 INSERT OVERWRITE PARTITION 翻闸回归)。**2 SPI 新增**(default-preserving,零 jdbc/es/trino 影响):`ConnectorSession.setCurrentTransaction` + `ConnectorWriteOps.usesConnectorTransaction`(impl 时 E11 登记)。**recon 校正**:GsonUtils 真锚 :397/:472(非 ~405/~478);`legacyLogTypeToCatalogType` 默认分支已出 "max_compute"(无需加 case);live executor=`PluginDrivenInsertExecutor`(现走 JDBC insert-handle 模型,对 MC `getWriteConfig`/`beginInsert`/`finishInsert` 全 throwing-default=直跑必抛);`PluginDrivenTransactionManager.begin(connectorTx):71-77` 未 putTxnById(G3);`UnboundConnectorTableSink` 不携静态分区(G4)。**下一 = 实现 T05(dormant)→ T06(live, 两 commit)**。 +- **2026-06-06(实现 ⑦·P4-T04)** ✅ **P4 Batch B 收尾 — P4-T04 连接器写计划完成 = Batch A+B 全完成**(gate 关、dormant、零 live 风险):新建 `MaxComputeWritePlanProvider implements ConnectorWritePlanProvider`,`planWrite` 走 **OQ-2 = Approach A**(finalizeSink 一处:建 ODPS Storage API 写 session → `session.getCurrentTransaction()`→`MaxComputeConnectorTransaction.setWriteSession` 绑事务 → 盖 `TMaxComputeTableSink` 静态字段 + `static_partition_spec` + `partition_columns`(ODPS 表列) + `write_session_id` + `txn_id`;**无运行期注入 hook**,legacy `MCInsertExecutor.beforeExec` 注入消失)。**5 决策 [D-025]**(D-1/D-2a 签字、D-3/D-4/D-5 主线定):D-3 抽 `MaxComputeDorisConnector.getSettings()`(决定性证据=legacy catalog 单 `settings` 同供 scan+write,抽出=忠实港非投机重构;scan provider :146-162 上移共用);D-4 `supportsInsert()`=true 余 throwing-default(实际 executor 面待 Batch C);fe-core seam(D-2a)`PluginDrivenTableSink.bindViaWritePlanProvider(insertCtx)` 读 overwrite+静态分区,`staticPartitionSpec` 加 `PluginDrivenInsertCommandContext`(非基类,避 `MCInsertCommandContext` shadow)。**坑10 javap 全核**(`withMaxFieldSize(long)`/`.partition`/`.overwrite`/`.withDynamicPartitionOptions`/`buildBatchWriteSession`throws IOException/`DynamicPartitionOptions.createDefault`/`PartitionSpec(String)`/`getId`);写路径 ArrowOptions = **MILLI/MILLI**(≠scan MILLI/MICRO)。**偏差 [DV-012]**:`partition_columns` 取 ODPS 表列(源不同值同)。binding 期填充 staticPartitionSpec/overwrite 仍 dormant 归 Batch C/D(坑3,`InsertIntoTableCommand:598` 现传空 ctx)。守门全绿(`-pl :fe-connector-maxcompute,:fe-core -am` compile BUILD SUCCESS/MVN_EXIT=0 + checkstyle 0 + import-gate 0,真实 EXIT 核验)。单测延 **P4-T10**。**T04 不新增 SPI 面**。**下一步 = Batch C 翻闸**(唯一 live 切点,A+B 全绿 ✅ + 前置 R-004 防御测)。 +- **2026-06-06(实现 ⑥·P4-T03)** ✅ **P4 Batch B 启动 — P4-T03 连接器写/事务 SPI 完成**(gate 关、dormant、零 live 风险):新建 `MaxComputeConnectorTransaction implements ConnectorTransaction`(港 legacy `MCTransaction` 写生命周期:`addCommitData` `TDeserializer(TBinaryProtocol)`→`TMCCommitData` 累积【commit 协议红线】、block 分配 CAS+上限校验、`commit` 港 `finishInsert`、rollback/close/getUpdateCnt)+ `MaxComputeConnectorMetadata.beginTransaction`,over W4 委派。**两 fork 用户签字 [D-024]**:(1) txn id 经新增 SPI `ConnectorSession.allocateTransactionId()`(fe-core `ConnectorSessionImpl` override `Env.getNextId`)分配——尊重 [D-015],补 id-less 连接器机制(E11 登记);(2) ODPS 写 session 创建挪 T04 planWrite(T03 纯事务容器,槽由 T04 经 `setWriteSession` 填)。**偏差 [DV-011]**:block 上限 fe-core `Config`(20000)→连接器常量、`UserException`→`DorisConnectorException`(import-gate 禁 `common.*`)。**JDBC 仅半样板**(无 `ConnectorTransaction`),MC 首个有状态事务 adopter。守门全绿(fe-connector-maxcompute+api+fe-core compile BUILD SUCCESS/MVN_EXIT=0 + checkstyle 0 + import-gate 0,真实 EXIT 核验)。单测延 **P4-T10**(write-txn golden、TBinaryProtocol round-trip)。**下一步 = P4-T04 写计划**(planWrite 产 `TMaxComputeTableSink` + OQ-2 write-context + 建 ODPS 写 session 绑事务)。 +- **2026-06-06(实现 ⑤·P4-T02)** ✅ **P4 Batch A 收尾 — P4-T02 连接器分区 listing 完成**(gate 关、dormant、零 live 风险):`MaxComputeConnectorMetadata` impl SPI `listPartitionNames`/`listPartitions`/`listPartitionValues`,三方法均直取 `structureHelper.getPartitions(odps, db, tbl)`:names = `PartitionSpec.toString(false,true)`(镜像 legacy `MaxComputeExternalCatalog:283`/`MaxComputeExternalTable:201`);`listPartitions` filter **忽略**返全量(values 由 `PartitionSpec.keys()`/`get(k)` 抽、props=emptyMap,镜像 SHOW PARTITIONS 不裁剪);`listPartitionValues` 按入参 `partitionColumns` 列序取 `spec.get(col)`。**OQ-4 定**:不建连接器自有 cache,直取 ODPS(Rule 2 不投机)。**保真说明**:legacy 双路径分歧(catalog:266 无 emptiness guard / table:200 有 `!partitionColumns.isEmpty()` guard),SPI 锚 catalog SHOW PARTITIONS 路径故**不加** guard;写前 javap 核 ODPS `PartitionSpec` 真实 API(`Set keys()`/`String get(String)`/`toString(boolean,boolean)`)。**测试**:按计划延至 **P4-T10** 连接器测试基线(无 mockito 手写替身),T02 gate = compile + checkstyle + import(R12 不静默)。守门全绿(连接器 compile BUILD SUCCESS/MVN_EXIT=0 + checkstyle 0/CS_EXIT=0 + import-gate 0,真实 EXIT 核验)。**下一步 = Batch B(P4-T03 写/事务 SPI)**。 +- **2026-06-06(实现 ④·P4-T01)** ✅ **P4 Batch A 启动 — P4-T01 连接器 DDL 完成**(gate 关、dormant、零 live 风险):`MaxComputeConnectorMetadata` impl SPI `createTable(ConnectorCreateTableRequest)` / `dropTable` / `createDatabase` / `dropDatabase`(忠实港 legacy `MaxComputeMetadataOps` 的 create/drop/validate/schema-build/lifecycle/bucket,**消费 P0 request 非 fe-core `CreateTableInfo`**)+ 新 `MCTypeMapping.toMcType(ConnectorType)` 反向类型映射(按 `PrimitiveType.toString()` switch,递归 ARRAY/MAP/STRUCT,不支持类型抛异常);连接器 `McStructureHelper` 已含全部 ODPS DDL 原语,无需新建。**附带修 fe-core 共享转换器 `ConnectorColumnConverter.toConnectorType` 丢 CHAR/VARCHAR 长度 [DV-010]**(用户 AskUserQuestion 签字;逆一致性 bug,影响 live jdbc/es CREATE TABLE,更正确)+ 回归测 `testCharVarcharLengthPreserved`。守门全绿(连接器 compile + checkstyle 0 + import-gate + fe-core `ConnectorColumnConverterTest` **9/0F0E**,真实 EXIT 核验)。**坑**:守门 maven `-pl` 须用 `:fe-connector-maxcompute`(冒号=artifactId);裸名被当相对路径 → reactor-not-found。下一步 = **P4-T02** 分区 listing。 +- **2026-06-06(设计 ④)** ✅ **P4 maxcompute adopter 设计批准**([D-023]):读 HANDOFF/PROGRESS/playbook + recon + 写-RFC §12,code-grounded re-grep(反向引用 post-W-phase **~19**,证 W-phase 灭 `Coordinator`/`LoadProcessor`/`FrontendServiceImpl` 3 热点 txn 站;`MCTransaction` 已含 W2 `addCommitData(byte[])`;`TMaxComputeTableSink` 18 字段齐)。产 [tasks/P4](./tasks/P4-maxcompute-migration.md):**5 批/11 task**(A 读/DDL parity → B 写/事务 → **C 翻闸(唯一 live 切点,含 R-004 防御测)** → D 清 ~19 引用+删 legacy → E 测+PR),用户批准。同步跟踪文档 + 修 §三 stale「P3 CI中」→ 已合 `5c240dc7a34`。**下一步 = Batch A**(P4-T01 DDL + P4-T02 分区,gate 关)。未动代码。 +- **2026-06-06(实现 ③)** ✅ **W-phase W4+W5+W7 落地(plugin-driven 写接线收口 + 文档)= W-phase(W1–W7)全完成**:**W4**(commit `759cc0874c8`)`PluginDrivenTransaction`(`PluginDrivenTransactionManager` 内类)override 4 个 fe-core `Transaction` 写 default 委派 wrap 的 SPI `ConnectorTransaction`(`addCommitData`/`supportsWriteBlockAllocation`/`allocateWriteBlockRange`/`getUpdateCnt`),legacy null marker 保持 inert(`allocateWriteBlockRange` 保 `throws UserException` 对齐接口,SPI 调用 unchecked);TDD RED 3F1E→GREEN 5/5。**W5**(commit `9ebe5e27fa4`)把 W1 写-plan SPI **layer 进既有** plugin-driven 写路径:`PhysicalPlanTranslator.visitPhysicalConnectorTableSink` + `PluginDrivenTableSink.bindDataSink` 在 `connector.getWritePlanProvider()!=null` 时走 `planWrite()` 产 opaque `TDataSink`,config-bag(jdbc)为 fallback。**关键修正 [DV-009]**:RFC/handoff W5 措辞(route 3 个 `visitPhysicalXxxTableSink` + 新建 sink)与代码不符——`PluginDrivenTableSink` 已存在、plugin-driven 写走 `visitPhysicalConnectorTableSink` 专路;那 3 个 concrete 方法服务 legacy 表,加路由是死代码。用户 AskUserQuestion 签字「Corrected W5 (layer planWrite)」;TDD RED(缺 ctor 编译失败)→GREEN 1/1。**W7** 文档:补 **[D-021]**(scope=C)+**[D-022]**(写 SPI A/B1/C1/D/E) 入 decisions-log(两 session 前签字未 log,traceability 缺口补齐);deviations-log 加 [DV-009] + 修 stale 索引(共 7→9、补 DV-008 行);01-spi-rfc 加 §20 E11 节(脚注 D-022)+ §3 矩阵 E11 行;同步本 PROGRESS / connectors/maxcompute / HANDOFF。三 commit 独立、behind gate、gate 全绿(compile + 定向测 + checkstyle 0 + import-gate,真实 exit code 核验)。**下一步 = P4 maxcompute adopter**(搬 `datasource/maxcompute/` → fe-connector-maxcompute、impl 写 SPI、翻闸 `max_compute`)。 +- **2026-06-06(实现 ②)** ✅ **W-phase W3+W6 落地**(解耦热路径 cast/instanceof + golden 测,behind gate、零行为变更、golden by TDD):**W3** 新 helper `CommitDataSerializer.feed(Transaction, List>)`(序列化协议单点 `TBinaryProtocol`,对齐 W2 反序列化;fail-loud `TException→RuntimeException`);`Coordinator`/`LoadProcessor` 3 个 concrete cast(HMS/Iceberg/MC)→ 1 个 **guarded 块** `if (hive||iceberg||mc){ Transaction txn=…getTxnById(txnId); feed each set 字段 }`;`FrontendServiceImpl` `instanceof MCTransaction`→`!supportsWriteBlockAllocation()`、`allocateBlockIdRange`→`allocateWriteBlockRange`;三文件 concrete import/usage 全删(grep 空)。**🔴 关键修正**:`getTxnById` 未知 id **抛 `RuntimeException` 非返 null**(`GlobalExternalTransactionInfoMgr:30`),legacy 仅在 `if(isSetXxx)` 内调;故 getTxnById 必 guard 在 "任一 commit 字段 set" 内(上 handoff 字面无守卫会击穿所有常规 load)。**W6** TDD:先写测→**故意错协议 `TCompactProtocol`**→RED(`TProtocolException: Unrecognized type 24`,证测守协议红线 + 走真实 `feed→addCommitData`)→翻 `TBinaryProtocol`→GREEN。4 golden(round-trip 钉协议无损 + iceberg/hms 比 list getter + mc 比 getUpdateCnt;MC 无 list getter 故不加测专用 getter/反射)+ 4 SPI default(`ConnectorTransactionDefaultsTest`) + 既有 `FrontendServiceImplTest#testGetMaxComputeBlockIdRange`。守门全绿(真实 exit 核验):compile BUILD SUCCESS + 9 测 0F0E + checkstyle 0 + import-gate。**W1+W2 已提交** `be945476ba7`(上 handoff "未提交" 过时);**W3+W6 未提交**(应独立 commit)。下一步 W4/W5(plugin-driven 写接线)+ W7(D-021/D-022 入 log)。 +- **2026-06-06(实现)** ✅ **W-phase W1+W2 落地**(写/事务 SPI 面 + fe-core `Transaction` 泛化,behind gate、零行为变更):**W1**(`fe-connector-api`)`ConnectorTransaction`(SPI) +4 default(`addCommitData(byte[])`no-op/`supportsWriteBlockAllocation`false/`allocateWriteBlockRange`throws/`getUpdateCnt`0);`Connector.getWritePlanProvider`default null;新 3 类 `ConnectorWritePlanProvider`/`ConnectorSinkPlan`(包`TDataSink`)/`ConnectorWriteHandle`(仿 scan 包结构;字段 minimal,W5 细化)。**W2**(`fe-core`)`Transaction` 接口 +4 同名 default(`allocateWriteBlockRange` 声明 `throws UserException` 对齐 MC `allocateBlockIdRange`);MC/HMS/Iceberg override `addCommitData`=TBinaryProtocol 反序列化→走既有 `updateXxxCommitData(singletonList)`(**golden 等价 by construction**:`addAll(list)`≡逐个`add`),MC 另 override block 分配,3 处 `getUpdateCnt` +@Override。守门全绿(真实 exit code 核验):fe-connector-api(compile+import-gate+checkstyle 0)+fe-core(compile BUILD SUCCESS+checkstyle 0)。**W2 override 暂 dead**(W3 接线前 Coordinator 仍 concrete cast)→零行为变更。**未提交**。下一步 **W3**(解耦热路径+golden 测)。坑:maven 必用绝对 `-f`(cwd 漂移破相对路径);读真实 `MVN_EXIT`/`CS_EXIT` 而非后台"exit code"通知。 +- **2026-06-06** 🚧 **P4 maxcompute 启动 + scope=C(写-SPI RFC 先行)+ 写/事务 SPI RFC 出稿并批准**(design-only,零生产代码):分叉决策定 **P4**(非批 E/P7)。maxcompute recon 关键发现 **它会写**(`MCTransaction` 在 `Coordinator:2539`/`FrontendServiceImpl:3702`(allocateBlockIdRange)/`LoadProcessor:240` 热路径 live cast;连接器是只读骨架;~36 反向引用 21mech/15live;模型 clean 无 hudi 寄生陷阱)→ 写路径=keystone(不先做写 SPI 不能翻闸)→ 用户选 **scope C**。按用户指令**完整调研 maxcompute/hive/iceberg 三写者写能力 + paimon 前瞻**(11 路只读 code-grounded recon):三者同生命周期(begin→BE写→commit载荷回调→finish→commit)⊥ 三处分歧(①commit 载荷型 mc-binary/hive-partition/iceberg-file ②mc block-id 唯一写期 BE↔FE RPC ③iceberg procedures+delete/merge);**P0 写面已大半就绪**(`ConnectorWriteOps`+`ConnectorTransaction`+`PluginDrivenInsertExecutor`+`PluginDrivenTransactionManager`,仅 JDBC 实现)→ 是扩展+桥接+解耦非重造。出 **写/事务 SPI RFC**(`tasks/designs/connector-write-spi-rfc.md`),用户签字 5 决策:**A** 连接器事务为源·桥接、**B1** commit 载荷 opaque bytes(零 BE 改、留一处 serialization shim 诚实标记)、**C1** block-id 窄 callback seam、**D** INSERT/DELETE/MERGE(defer procedures/E2-P6 + hive 行级 ACID/P7)、**E** 写-plan-provider 仿 scan。**用户批准 → 启 W-phase**(共享解耦:SPI 面 + fe-core `Transaction` 泛化 + 解耦 3 热路径 cast/instanceof,**behind gate、不搬类、零行为变更/golden 等价**),实现待下一 session(RFC §12 W1→W7)。研究:`research/p4-maxcompute-migration-recon.md` + `research/connector-write-spi-recon.md`。**待补**:decisions-log D-021(scope=C)/D-022(写 SPI 设计) + 01-spi-rfc E11(W7) +- **2026-06-05** ✅ **P3 批 D 完成(T08 `tableFormatType` 分流消费设计备忘,design-only)= P3 hybrid in-scope(批 A–D)全完成**:以上 session 的 6-reader recon(`research/spi-multi-format-hms-catalog-analysis.md`)为直接输入,本场不重复 recon、只 firsthand 核读 load-bearing 锚点(确认 keystone gap:`PluginDrivenExternalTable.initSchema` 只读 columns 丢 `tableFormatType`;新增第二缺口:`getEngine`/`getEngineTableTypeName` switch catalog type 非 per-table format;`planScan` 入参带 per-table handle)。**核心分析贡献**:把 keystone 拆成可分离的 **M1 身份消费 ⊥ M2 scan 路由**(M1 三方案通用,A/B/C 只在 M2 分歧)。M2 三方案评估后 **AskUserQuestion 用户签字 = 方案 B**([D-020]):新增向后兼容 default `ConnectorMetadata.getScanPlanProvider(handle)`(默认 null→回落 per-catalog),fe-core `PluginDrivenScanNode.getSplits` 优先 per-table、回落 per-catalog;把 per-table 选 provider 升为一等 SPI 契约(满足 D-009 default-only)。A(连接器内 router,零 SPI churn)备选;C(fe-core 发现期分派)否决(违瘦 fe-core)。**细化 D-005**(区分符沿用;"PhysicalXxxScan" 措辞早于 P1 scan-node 统一,由 per-table provider seam 取代)。缩界:本场零代码、gate 不动;Iceberg-on-hms 经 SPI 依赖 P6/M3;M1+M2 实现登记批 E/P7。**P3 hybrid 净产出**=2 正确性修(T02/T05)+ 2 fail-loud/决策(T04/T06)+ 测试网零→59 测(T07)+ 模型 dispatch 设计(T08/D-020)。**P3 PR [#64143](https://github.com/apache/doris/pull/64143) 已开**(base branch-catalog-spi,26 files +3065/−154,12 commits);下一步=监控 CI / 处理 review,批 E 并入 P7 / 启 P4。设计 `designs/P3-T08-tableformat-dispatch-design.md` +- **2026-06-05** ✅ **P3 批 C 编码完成(T07 三模块测试基线 + COW/MOR schema parity)**:feasibility recon(5-agent code-grounded workflow)定 **golden-value parity**(fe-core 只依赖 fe-connector-api/-spi、不依赖具体连接器模块,无跨模块编译路径;JUnit5 + 手写替身);关键结论 **COW/MOR schema type-agnostic**(legacy/SPI 两侧 schema 推导都不按表型分支,差异只在 scan planning)。落地:**hudi**——`avroSchemaToColumns` 顶层列名 `toLowerCase` 修(gap-1,镜像 legacy `HMSExternalTable:745`,仅顶层、嵌套 struct 名保留)+ package-private static 可测;`HudiTypeMappingTest` 补 `fromAvroSchema`→ConnectorType golden(原零覆盖);新 `HudiSchemaParityTest`(列名/序/类型/Hive 串/casing 边界 pin)+ `HudiTableTypeTest`(COW/MOR/UNKNOWN 分类)。**hms**——新 `HmsTypeMappingTest`(hms+hive 共享的 Hive 类型串解析器,原零测试)。**hive**——新 `HiveFileFormatTest` + `HiveConnectorMetadataPartitionPruningTest`(镜像 T05 裁剪网)。三模块 test:hms 12 + hive 14 + hudi +18=33 全绿;checkstyle 0(含 test 源);import-gate 通过。**两 parity gap**([DV-008]):gap-1 列名 casing 当场修(用户签字),gap-2 Hudi meta-field 纳入(`getTableAvroSchema(true)` vs 无参)推迟批 E(无真实 metaclient 不可单测)。下一步批 D(T08 design-only)。设计:`designs/P3-T07-test-baseline-design.md` +- **2026-06-05** ✅ **P3 批 B 编码完成**(T05 ✅ + T06 决策,[DV-007]):**T05**(commit `10b72d4`,feat)`HudiConnectorMetadata.applyFilter` 真实 EQ/IN 分区裁剪——原占位实现列**全部** HMS 分区不裁剪、且无条件设 `prunedPartitionPaths` 静默把分区来源从 Hudi-metadata 切到 HMS;重写为忠实镜像 `HiveConnectorMetadata`(抽取 partition 列 EQ/IN 谓词→列候选→裁剪→仅有效果时回传 pruned handle,否则 `Optional.empty()` 回落 Hudi-metadata listing),保留 `List` 路径表示 + `-1` 上限,7 helper duplicate from Hive(hudi 仅依赖 fe-connector-hms)。`HudiPartitionPruningTest` 8 测全绿(模块 19 测)、checkstyle 0、import-gate 通过。**T06**(零代码决策,用户签字)MVCC/snapshot SPI **保持 default `Optional.empty()` opt-out**——recon 证「显式抛异常 override」错(破 SPI opt-out 约定、全体连接器无 override、无 production caller=死代码、T04 已 fail-loud time-travel);完整 MVCC 入批 E。**scope 校正**([DV-007]):T05 `listPartitions*` override 推迟批 E(零 live caller、Hive 不 override)。批 A+B 编码完成,下一步批 C(三模块测试 + COW/MOR parity)。设计:`designs/P3-T05-*` / `P3-T06-*` +- **2026-06-05** ✅ **P3-T04 time-travel/增量读 fail-loud**(commit `feceabb`,批 A 编码收尾):`PhysicalPlanTranslator.visitPhysicalHudiScan` SPI 分支对 `FOR TIME/VERSION AS OF`(曾静默返最新——provider 永远读 `lastInstant`)与增量读(曾静默全扫——SPI 无表示)抛 `AnalysisException`。唯一同时可见 snapshot+incremental 处。fe-core 编译 + checkstyle 0;dormant 分支 gate 关时不可达=零 live 风险;单测推迟批 E(不可 exercise,R12 显式登记)。**批 A 编码完成**:T02 + T04 两个正确性修复落地,T03 推迟批 E(DV-006) +- **2026-06-05** 🟡 **P3-T03 推迟批 E**([DV-006],用户签字):code-grounded recon(4-reader workflow + 主线核读 BE `table_schema_change_helper.h`)揭示 schema_id/history_schema_info **不是** 批 A 可做的 model-agnostic SPI-surface 修复——连接器缺 field-id(`HudiColumnHandle` 无)/ Hudi `InternalSchema` 版本 / type→`TColumnType` thrift;「Paimon/ES 已 override hook(设 schema)」前提失真(其 override 为 predicate/docvalue);裸 `current==file==-1`→BE `ConstNode`(identity,大小写敏感) **弱于**当前 `by_parquet_name` 名匹配 = 净回归。faithful field-id evolution parity 与 hive/HMS migration 一并入批 E。批 A 保持现状名匹配(零回归),直进 T04 +- **2026-06-04** ✅ **P3-T02(批 A 启动)column_types 双 bug 修复**(commit `95f23e9`):硬化 dormant SPI hudi 连接器(gate 关,零 live caller)。(a) `HudiScanPlanProvider` 改发完整 **Hive 类型串**(新 `HudiTypeMapping.toHiveTypeString` 复刻 legacy `HudiUtils.convertAvroToHiveType`),不再用 `getTypeName()` 发 Doris 裸类型名(丢精度/scale/子类型);(b) `HudiScanRange` 改 typed `List` 直接设 thrift `list`,弃逗号 join/split(曾打碎 `decimal(10,2)`/`struct<...>`),BE 自做 join(types `#` / names,delta `,`),与 Java `HadoopHudiJniScanner` split 契约一致(两点对抗确认)。建模块**首批**测试 11 个全绿;checkstyle 0 + import-gate 通过;3 路对抗 review 零确认缺陷。设计见 `tasks/designs/P3-T02-column-types-design.md` +- **2026-06-04** ✅ **P3 scan/split recon + 定 hybrid(D-019)+ 建 tasks/P3**:第二轮 recon(scan/split 路径,verified)——单 `PluginDrivenScanNode` 混合 COW-native/MOR-JNI **不是问题**(per-range format,与 legacy 结构等价,BE 每 range 建 reader);plumbing 正确,剩 model-agnostic 正确性 gap(schema_id/history 缺、column_types 双 bug、time-travel 静默返最新、增量无表示、partition 裁剪缺、三模块零测试)。用户定 **hybrid**([D-019](./decisions-log.md)):现做 (b) 连接器硬化+测试(behind gate,零 live 风险),推迟 (a) 模型落地+cutover 到 hive/HMS migration。已建 [tasks/P3](./tasks/P3-hudi-migration.md),批 A 待启动 +- **2026-06-04** ✅ **P2 已合入 `branch-catalog-spi`**(#64096,squash `0793f032662`,叠在 P1 `2b1a3bb2197` / P0 `72d6d0109b9` 上)。旧「PR base 错位(191-commit)」阻塞消失——`branch-catalog-spi` 已重建到新 master(P0/P1 hash 随之更新)。P2 除 T12(回归,DV-003)外全部完成 +- **2026-06-04** 🚧 **P3 Hudi 启动 recon**(8-agent code-grounded workflow + 2 路对抗验证,verdict `hmsMetadataOverSpiReady=false` / high):原计划「P3 需等 P5/P7 交付 HMS-over-SPI」与代码**不符**——HMS-over-SPI 读码(`fe-connector-hms` 客户端库 + `HiveConnectorMetadata`(type "hms") + `HudiConnectorMetadata`(type "hudi") + `ConnectorTableSchema.tableFormatType` 区分符)**早已存在但 dormant**(`SPI_READY_TYPES={jdbc,es,trino-connector}` 不含 hms/hudi,零 live caller,走 legacy `HMSExternalCatalog`)。**真正阻塞=catalog 模型错配**(独立 `"hudi"` catalog type vs Doris 真实的「寄生 `"hms"` 内以 `DLAType.HUDI` 暴露」;fe-core 不消费 `tableFormatType`)+ 增量读无 SPI 表示(P1-T04 gap)+ 三模块零测试。已验证非阻塞:SPI scan/split 通用链路被合入的 trino-connector 走通。记 **DV-005**;下一步=recon scan 路径 + 写 catalog 模型决策备忘(a/b;c 否决)+ 用户签字后编码 +- **2026-06-04** ✅ **P2 批 C+D+E 完成**(T07–T11,T13;T12 推迟;PR 待开):批 C T07 翻闸(`0fe4b8a93d6`);批 D 删 fe-core legacy trino 代码 14 文件 / −2508(`ed81a063fe8`,含 recon 补回的 `ExternalCatalog` db-case DV-001,保留 MetastoreProperties / 两个 image-compat 枚举 / GsonUtils redirect);批 E T11 加 3 个纯转换器 JUnit5 测试 29 个全绿(`9bba12a44b2`,无 mock,DV-002)。T12 推迟(无集群/plugin,DV-003);T13 文档同步本条。**rebase 构建坑**:fe-core 因 stale 生成的 `DorisParser`(grammar 随 #63823 拆到 `fe-sql-parser`)编译失败,clean fe-core 即解。**PR 待开**——`catalog-spi-03` 现基于 master、与 `branch-catalog-spi`(仍 P1,分叉于 #63552)错位(191-commit),分支对齐由用户处理 +- **2026-05-25(晚 ④)** ✅ **P2 批 B 完成**(T03+T04+T05+T06 fe-core 桥接):recon 揭示 HANDOFF 三处描述误差并校正——(1) T03 不能"只加 redirect 不删旧",必须 atomic replace 否则 `RuntimeTypeAdapterFactory.labelToSubtype` 撞名抛 IAE → FE 起不来;(2) T05 是 duplicate of T03,没有独立的 `ExternalCatalog.registerCompatibleSubtype` API;(3) T04 `name().toLowerCase()` 不通用——`Type.TRINO_CONNECTOR.name().toLowerCase()` 出 "trino_connector" 但 CatalogFactory 期望 "trino-connector",新增 `legacyLogTypeToCatalogType` helper 做显式 case 映射;(4) T06 `TRINO_CONNECTOR_EXTERNAL_TABLE.toEngineName()` 返 null(switch 没 case,legacy 也是 null),保留此行为不修。3 files / +29 LOC 全在 fe-core。守门:fe-core compile + checkstyle + import gate 全绿。**重要**:批 B 后到批 C T07 翻闸前,新建 trino 目录无法序列化(registerSubtype 已删但 CatalogFactory 仍走 legacy);不要在中间状态部署 +- **2026-05-25(晚 ③)** ✅ **P2 批 A 完成**(T01+T02 fe-connector-trino SPI 补齐):`TrinoConnectorProvider.validateProperties` 校验 `trino.connector.name` 必填;`TrinoDorisConnector.preCreateValidation` 在 CREATE CATALOG 时触发 `ensureInitialized()` 完成 plugin 加载 + connector factory 解析,把延迟到首次查询的失败前移到 catalog 创建期。`TrinoConnectorDorisMetadata.applyFilter / applyProjection` 桥接 Trino 原生 push-down:复用现有 `TrinoPredicateConverter` 把 `ConnectorExpression` 转 `TupleDomain`,调 Trino `metadata.applyFilter / applyProjection`,把回来的 trino-side `ConnectorTableHandle` 包成新的 `TrinoTableHandle`(保留 column maps);`remainingFilter` 保守返回原表达式,匹配 legacy fe-core 行为(BE 端继续 re-evaluate)。+143 LOC 跨 3 文件,全部 `fe-connector-trino` 侧(**未触碰 fe-core**,严格守批 A 边界);import gate + compile + checkstyle 全绿。单元测试推迟到 P2-T11 批 E 一起做 +- **2026-05-25(晚 ②)** 🚧 **P2 (trino-connector) 启动 + recon 完成**:用 3 路 Explore subagent 并行调研,输出代码侧 facts —— fe-core 旧目录 10 个 .java / ~1760 LOC、5 个 live external caller(全部机械路由,无 P1-T01 那种"活业务逻辑"问题);fe-connector-trino 13 类 / 2162 LOC / 0 测试,SPI 表面 ~95% 已覆盖(真缺 validateProperties / preCreateValidation / pushdown ops);反向 instanceof 实测 1 处(PhysicalPlanTranslator:779);SPI_READY 翻闸点定位 `CatalogFactory.java:53`;Gson 兼容路径与 ES/JDBC 同 pattern 可复用。**用户决议**:Q1 pushdown ops 纳入 P2 批 A;Q2 fe-core 目录删除时 GsonUtils 三个 class-token 注册同步清。**task 划分定**:13 tasks / 5 批次(A SPI 补齐 / B fe-core 桥接 / C 翻闸 / D 清旧 / E 测试+文档)。P2 task 文件 [tasks/P2-trino-connector-migration.md](./tasks/P2-trino-connector-migration.md) 已建 +- **2026-05-25(晚)** ✅ **P1 PR 合入**:PR [#63641](https://github.com/apache/doris/pull/63641) `[P1-T03-T05] route plugin-driven scans first in nereids translator` 流水线全绿,squash-merged 到 `apache/doris:branch-catalog-spi`,hash `778c5dd610f`。本地新分支 `catalog-spi-03` 已建立,承载 P2 工作 +- **2026-05-25(白天 ④)** ✅ **P1 阶段关闭**:批 B (T1) recon 揭示 3 个 fe-core JDBC client caller(PostgresResourceValidator / StreamingJobUtils / CdcStreamTableValuedFunction)均为活的 CDC streaming 代码(非 dead code),删除需要在 ConnectorPlugin/ConnectorMetadata 上为 CDC 暴露新 capability(getPrimaryKeys / getColumnsFromJdbc / listTables)。用户决议(Q4):**推迟 T1 到 P8 收尾**(与 streaming CDC 重构一起做)。P1 in-scope(T3+T4+T5)100% 完成;剩余动作:batch A push + PR +- **2026-05-25(白天 ③)** ✅ **P1 批 A 完成**(T03+T04+T05 scan-node SPI 收口):`PhysicalPlanTranslator.visitPhysicalFileScan` `PluginDrivenExternalTable` 分支前置(T3);`visitPhysicalHudiScan` 加 SPI 分支并通过 `FileQueryScanNode` setters 透传 `scanParams`/`tableSnapshot`,`incrementalRelation` 记 P3 TODO(T4);`LogicalFileScan.computeOutput` 新增 `computePluginDrivenOutput()` helper + 显式 `supportPruneNestedColumn → false` 分支(T5)。fe-core BUILD SUCCESS + checkstyle 0;对当前 SPI 表(JDBC/ES)行为等价;7 个连接器特定分支原地保留作 P3-P7 fallback +- **2026-05-25** ✅ **P0 全阶段完成**:PR [#63582](https://github.com/apache/doris/pull/63582) squash-merge 到 `apache/doris:branch-catalog-spi`(hash `c6f056fa5bd`);T24/T25 流水线全绿;P0 阶段进度 100%。新本地分支 `catalog-spi-02` 基于最新 base 创建,**P1 启动**(scan-node 收口 + 重复清理,1 周) +- **2026-05-24(夜 ③)** ✅ **P0 批 2 守门 + 单测完成**(T21-T23, T26-T27;T24-T25 用户跑):新增 `tools/check-connector-imports.sh` grep 守门 + 通过 exec-maven-plugin 在 `fe-connector` aggregator validate 阶段调起(`inherited=false`);新增 `FakeConnectorPlugin`(fe-core test)+ 23 个新 @Test 覆盖 11 个 default 路径 + ConnectorMetaInvalidator 5 个 routing + Converter 7 个(4 partition style × IDENTITY/TRANSFORM/LIST/RANGE + hash/random bucket + 列穿透);39/39 tests green;checkstyle 0;JDBC/ES regression-test 转交用户在本地执行 +- **2026-05-24(夜 ②)** ✅ **P0 批 1 DDL + Partition SPI 完成**(T13-T20):新增 `connector.api.ddl` 包 5 个 POJO(CreateTableRequest + 4 spec);`ConnectorTableOps` 加 4 个 default(createTable(request) + listPartitionNames/listPartitions/listPartitionValues);`ConnectorPartitionInfo` 追加 rowCount/sizeBytes/lastModifiedMillis;fe-core 新 `CreateTableInfoToConnectorRequestConverter` 覆盖 IDENTITY/TRANSFORM/LIST/RANGE 四种 partition + hash/random bucket;`PluginDrivenExternalCatalog.createTable` 路由到 SPI;fe-core BUILD SUCCESS + checkstyle 0;JDBC/ES 下游 zero-impact +- **2026-05-24(深夜)** ✅ **P0 批 0 fe-core 桥接完成**(T09-T12):`ExternalMetaCacheInvalidator` + `ConnectorMvccSnapshotAdapter` 新类、`DefaultConnectorContext.getMetaInvalidator()` override、`PluginDrivenTransactionManager` 加 SPI `ConnectorTransaction` 重载(legacy auto-commit 不变);fe-core 全编译通过 + checkstyle 0 violations;JDBC/ES 下游 zero-impact +- **2026-05-24(晚)** ✅ **P0 批 0 SPI 接口三件套完成**(T03-T08):`ConnectorMetaInvalidator`、`ConnectorTransaction`、`ConnectorMvccSnapshot` 共 3 个新类型 + 4 个 default 方法;JDBC/ES clean compile 通过,零下游修改 +- **2026-05-24** ✅ 项目跟踪机制建立(README、PROGRESS、decisions-log、deviations-log、risks、tasks/、connectors/、AGENT-PLAYBOOK、HANDOFF) +- **2026-05-24** ✅ SPI RFC §16.2 6 个未决问题(U1-U6)全部决议(D-013..D-018) +- **2026-05-24** ✅ SPI RFC v1 落地([01-spi-extensions-rfc.md](./01-spi-extensions-rfc.md)) +- **2026-05-24** ✅ Master Plan §5 12 个项目决策点(D1-D12)全部确认(D-001..D-012) +- **2026-05-24** ✅ Master Plan v1 落地([00-connector-migration-master-plan.md](./00-connector-migration-master-plan.md)) +- **2026-05-24** ✅ 初步代码侦察(177 个 fe-connector 文件、408 个 fe-core/datasource 文件、96 处反向 instanceof) + +--- + +## 五、风险监控(active risks) + +| ID | 风险 | 影响 | 当前状态 | 触发阶段 | Owner | +|---|---|---|---|---|---| +| R-001 | Image 反序列化兼容回归 | High | 🟢 监控中 | P2-P7 每个迁移 | @me | +| R-002 | Hive ACID 写路径数据不一致 | High | 🟡 待启动 | P7.3 | TBD | +| R-003 | Iceberg Procedure SPI 抽象失败 | Med | 🟢 监控中 | P6.4 | @me | +| R-004 | classloader 隔离打破 SDK 单例 | Med | 🟢 监控中 | P5/P6 | @me | +| R-005 | nereids 写命令深度耦合 | Med | 🟡 待 P6.3 评估 | P6.3 | TBD | +| R-006 | 通过 SPI 性能回归 | Low | ⏸ 未启动 | P0 末加 benchmark | TBD | +| R-007 | FE/BE 共享 jar 冲突 | Low | ⏸ 未启动 | P5/P6 | TBD | +| R-008 | 文档与流程脱节 | Low | 🟢 缓解中 | 全周期 | @me | + +完整列表见 [risks.md](./risks.md)(含 R-009..R-014 从 RFC §16.1 迁入的 Q1-Q6 类技术风险) + +--- + +## 六、决策与偏差快速跳转 + +| 类型 | 总数 | 最新条目 | 文档 | +|---|---|---|---| +| **决策**(D-NNN) | 73 | D-073(最新,P6.6-C3b-core impl-recon + v3-lineage carrier Option A);D-072(C3b-core 全量设计 + commit-bridge);P6 翻闸期大量决策记于 HANDOFF/git | [decisions-log.md](./decisions-log.md) | +| **偏差**(DV-NNN) | 49 | DV-049(P6.5-T07 sys-table perf-cosmetic 批);DV-048(correctness-bearing);DV-045/046/047(P6.4-T08 rewrite blocker / auth / perf)| [deviations-log.md](./deviations-log.md) | +| **风险**(R-NNN) | 14 | R-014(thrift sink 选择灵活性) | [risks.md](./risks.md) | + +--- + +## 七、Session 协作状态(Agent / Human) + +> 当本项目通过 Claude Code 这类 LLM agent 推进时,跟踪当前 session 状态、handoff 状况和 context 健康度。 + +- **本 session 已完成**:**P7 启动 = 10-agent code-grounded recon + 阶段拆分 spec(0 产线代码、0 新决策)** —— 建 `tasks/P7-hive-migration.md`(P7.1–P7.5 + old→new 映射 + 翻闸机制 + 跨连接器删除排序 + 8 条开放决策);coverage-critic 闭合 instanceof-only 扫描盲区(type-level 耦合);查 decisions-log 确认 D-019/D-020 已定 iceberg/hudi-on-HMS 归属(勿重议);doc-sync 刷新本 PROGRESS + 覆写 HANDOFF(→P7.1 起步)+ connectors/hive.md。 +- **下一个 session 应做**:**P7.1 实现** —— 读 `tasks/P7-hive-migration.md` 全 + 对照 HEAD 核读 `HiveMetadataOps`(425)/`HiveConnectorMetadata`(override 0)/`HmsClient`;把 DDL/partition/col-stats 写端搬进 `HiveConnectorMetadata`+`HmsClient`(no-property-parsing;`ConnectorTableOps.truncateTable` 加 default);建 `P7.1-Tnn` 逐 task 追加 spec。**信控制流不信 spec 行号。** +- **是否需要 handoff**:**是**——本场已**覆写** [HANDOFF.md](./HANDOFF.md)(recon+spec 完成实证 + P7.1 起步 6 要点 + 删除排序约束)。 +- **协作规范**:[AGENT-PLAYBOOK.md](./AGENT-PLAYBOOK.md)(context 预算、subagent 使用、handoff 触发条件) + +--- + +## 八、维护规则速记 + +| 何时更新本文件 | 改什么 | +|---|---| +| 完成一个 task | §三表中删除 / 标 ✅;§四加一行 | +| 完成一个阶段 | §一进度条 + §三整体清理 + §四加里程碑 | +| 新增决策 | §四加一行 + §六计数 +1 | +| 发现偏差 | §四加一行 + §六计数 +1 | +| 每周一例行 | §四清过期、§五状态滚动、§七 session 状态 review | + +📖 详细规则见 [README.md §4 维护规则](./README.md) diff --git a/plan-doc/README.md b/plan-doc/README.md new file mode 100644 index 00000000000000..739910329b612d --- /dev/null +++ b/plan-doc/README.md @@ -0,0 +1,195 @@ +# Connector 迁移项目 — 文档与跟踪机制 + +> 本目录是 Doris connector 解耦迁移项目(fe-core/datasource → fe-connector/*)的**唯一权威文档源**。 +> 任何讨论、评审、PR 描述都应引用本目录文件,避免事实在群聊 / 邮件中丢失。 + +--- + +## 〇、入口(看了就懂) + +### 项目文档 + +| 我想做的事 | 看哪个文件 | +|---|---| +| **了解项目背景、整体设计、决策点** | [`00-connector-migration-master-plan.md`](./00-connector-migration-master-plan.md) | +| **了解 SPI 接口扩展细节(Java 签名)** | [`01-spi-extensions-rfc.md`](./01-spi-extensions-rfc.md) | +| **看现在做到哪一步了 / 谁在做什么** | [`PROGRESS.md`](./PROGRESS.md) ★ | +| **看具体阶段的任务清单** | [`tasks/Pn-*.md`](./tasks/) | +| **看具体连接器的迁移状态** | [`connectors/.md`](./connectors/) | +| **历史上做过哪些决策、为什么** | [`decisions-log.md`](./decisions-log.md) | +| **实施中发现原计划不可行的地方** | [`deviations-log.md`](./deviations-log.md) | +| **当前项目有哪些风险,谁在缓解** | [`risks.md`](./risks.md) | + +### Agent 协作(每次 session 开始必读) + +| 我是 LLM agent,我想... | 看哪个文件 | +|---|---| +| **了解如何管理 context、何时用 subagent、何时 handoff** | [`AGENT-PLAYBOOK.md`](./AGENT-PLAYBOOK.md) ★ | +| **接管上次 session 的工作** | [`HANDOFF.md`](./HANDOFF.md) ★ | + +--- + +## 一、目录结构 + +``` +plan-doc/ +├── 00-connector-migration-master-plan.md ← WHY/WHAT 总体设计(变化少) +├── 01-spi-extensions-rfc.md ← SPI 详细 RFC +├── README.md ← 本文件 +├── PROGRESS.md ← 全局仪表盘(人类入口必读) +├── AGENT-PLAYBOOK.md ← Agent 协作规范(context / subagent / handoff) +├── HANDOFF.md ← Session 间接力文档(滚动) +├── decisions-log.md ← ADR,append-only +├── deviations-log.md ← 实施偏差日志,append-only +├── risks.md ← 风险滚动状态 +├── tasks/ ← 按阶段切的任务清单 +│ ├── _template.md +│ └── P0-spi-foundation.md +└── connectors/ ← 按连接器切的迁移状态 + ├── _template.md + └── .md +``` + +--- + +## 二、文件职责矩阵 + +| 文件 | 内容性质 | 更新频率 | 主要读者 | 更新触发 | +|---|---|---|---|---| +| `00-master-plan.md` | 战略 / 总体设计 | 每月一次(重大架构变化)| 项目所有人 | 范围变更、阶段划分调整 | +| `01-spi-extensions-rfc.md` | 战术 / SPI 详细设计 | 每阶段一次 | connector 实现者 | SPI 接口签名变化 | +| `PROGRESS.md` | 状态快照 | **每周一次或重要变更后** | 所有人 | task 完成 / 阶段切换 | +| `AGENT-PLAYBOOK.md` | Agent 协作规范 | 不常变(v1 当前) | LLM agent | 规则失效时(DV 流程修改) | +| `HANDOFF.md` | Session 间状态接力 | **每次 session 结束**(覆盖) | 下次 agent | session 结束 | +| `tasks/Pn-*.md` | 阶段任务清单 | **每完成 task 后** | task owner | task 状态翻转 | +| `connectors/.md` | 连接器迁移历史 | 该连接器有动作时 | connector owner | playbook 步骤完成 | +| `decisions-log.md` | 决策记录(ADR)| **每新增决策后**(append) | review 者 / 后来人 | 任何新决策诞生 | +| `deviations-log.md` | 偏差日志 | **每发现偏差后**(append)| review 者 | 原计划被推翻 | +| `risks.md` | 风险登记册 | 每周状态滚动 | PM / SRE | 风险等级变化、新增风险 | + +--- + +## 三、关键概念区分(重要) + +### 3.1 决策 (Decision) vs 偏差 (Deviation) + +- **决策**:项目启动时或某阶段开始时**事前**确定的选择,进入 `decisions-log.md`。例:D-001 沿用 `SUPPORTS_PASSTHROUGH_QUERY`。 +- **偏差**:原计划中已经记录的设计 / 实现方案,在落地中发现不可行或不必要,**事后**记录调整,进入 `deviations-log.md`。例:DV-001 原计划 callProcedure 用 Map 入参,实际改 List。 + +混淆这两者会让人无法判断"这是事先想清楚了还是被现实打脸了"。 + +### 3.2 风险 (Risk) vs 问题 (Issue) + +- **风险**:可能发生的负面事件,进入 `risks.md`。状态滚动(监控中 / 缓解中 / 已闭环 / 已触发)。 +- **问题**:已经发生的事,应在对应 task 上记 blocker 备注;如果是阶段性的,可在 tasks/Pn 文件的"阶段日志"中记录。 + +### 3.3 Task ID 编号规则 + +``` +P0-T01 ← 阶段 P0 第 1 个任务 +P6.3-T05 ← 子阶段 P6.3 第 5 个任务 +``` + +ID 一旦分配**永不复用、永不重排**,即使任务被删除也保留 ID 占位(标 `[deleted]`)。 + +### 3.4 决策 / 偏差 / 风险编号规则 + +``` +D-001, D-002, ... 决策;旧 D1-D12, U1-U6 迁入时映射到 D-001..D-018 +DV-001, DV-002, ... 偏差 +R-001, R-002, ... 风险;旧 R1-R8, Q1-Q6 迁入时映射到 R-001..R-014 +``` + +--- + +## 四、维护规则(一定要遵守) + +### 4.1 每次完成一个 task + +1. 在对应 `tasks/Pn-*.md` 中把该 task 状态从 `🚧` 改为 `✅`,加完成日期 + PR 链接。 +2. 在该 task 文件的"**阶段日志**"末尾追加一行:`YYYY-MM-DD: 完成 Pn-Tnn — <一句话描述>`。 +3. 如果该 task 关联具体连接器,同步更新 `connectors/.md` 的"进度"段。 +4. 如果完成的是阶段的最后一个 task,更新 `PROGRESS.md`: + - 进度条 + - 阶段状态 + - 当前活跃 task 列表 + - "最近 7 天动态" + +### 4.2 每次产生新决策 + +1. 新决策**先写**到 `decisions-log.md` 顶部(时间倒序),分配 `D-NNN` 编号。 +2. 在 `PROGRESS.md` "最近 7 天动态" 中加一行链接。 +3. 如果决策修改了 RFC / master plan 的某节,**同步更新对应文档**,并在该节加 `(D-NNN 修订)`脚注。 + +### 4.3 每次发现设计偏差 + +1. **先在 `deviations-log.md` 顶部**记录:`DV-NNN`、原计划位置、为什么不可行、新方案、影响范围。 +2. 更新被影响的 RFC / master plan / task 文件。 +3. **不要**直接 silently 改 RFC——必须先记偏差,再改文档。 + +### 4.4 每周一例行维护 + +1. 滚动 `PROGRESS.md`:清"最近 7 天动态"中过期项,更新进度条。 +2. 扫一遍 `risks.md`:检查每个 active 风险的状态,更新缓解措施进展。 +3. 扫一遍 `tasks/` 中所有 in_progress 文件:是否有卡住的? + +### 4.5 每个 PR 必带 + +1. PR 描述里**第一行**写:`[Pn-Tnn] `。 +2. PR merge 后,task owner 立刻按 §4.1 流程更新 task 状态。 +3. 如果该 PR 引入了任何 SPI 接口签名变化,需要同步更新 `01-spi-extensions-rfc.md` 并在 PR 描述中说明。 + +--- + +## 五、防腐策略 + +为防止文档与代码 / 实际进度脱节,定期检查: + +| 项 | 频率 | 工具 / 方法 | +|---|---|---| +| `PROGRESS.md` 上次更新日期 < 7 天 | 每周一 | 手动 / 后续可写 `tools/check-tracking-freshness.sh` | +| `tasks/` 中无"in_progress 超过 14 天"任务 | 每周一 | 同上 | +| 所有 RFC `D-NNN` 引用在 `decisions-log.md` 都有对应条目 | merge 前 | 后续可写 grep 守门 | +| `PROGRESS.md` 中阶段百分比与 tasks/ 中真实完成率一致 | 每周一 | 简单脚本可计算 | + +--- + +## 六、不在范围 + +本跟踪机制**不**包含: + +- 代码评审(用 GitHub PR) +- 缺陷管理(用 GitHub Issues) +- CI 状态(用 GitHub Actions) +- 工时统计(不做) +- 个人 KPI 追踪(不做) + +文档只追踪"项目本身的设计与进度",不追踪人。 + +--- + +## 七、给后来者 + +### 7.1 第一次接触本项目(人类) + +1. 读 `00-master-plan.md` 第 §1 节、§3 节(10 分钟) +2. 看 `PROGRESS.md`(5 分钟)—— 知道现在在哪一步 +3. 如果你要做某个具体阶段,再读对应 `tasks/Pn-*.md` 和 RFC 中相关章节 + +### 7.2 来评审某个 PR(人类) + +1. 看 PR 描述中的 `[Pn-Tnn]` +2. 跳到 `tasks/Pn-*.md` 找该 task 的"备注"和"验收标准" +3. 评审完毕在 PR 中确认 task 完成 + +### 7.3 LLM agent 接手 session(AI) + +**强制顺序**(来自 [AGENT-PLAYBOOK §4.5](./AGENT-PLAYBOOK.md)): + +1. Read `PROGRESS.md` —— 全局状态 +2. Read `HANDOFF.md` —— 上次 session 留言 +3. 如 HANDOFF 标记当前 task,Read 对应 `tasks/Pn-*.md` 中该 task 块 +4. 用一句话复述确认:"上次完成了 X,下一步是 Y,对吗?" +5. 用户确认后开始 + +**永远不要**在没读 HANDOFF 的情况下问"我们上次做到哪了"。 diff --git a/plan-doc/connectivity-migration-plan.md b/plan-doc/connectivity-migration-plan.md new file mode 100644 index 00000000000000..63dd349f6ffe21 --- /dev/null +++ b/plan-doc/connectivity-migration-plan.md @@ -0,0 +1,180 @@ +# 方案:移除 fe-core 的 `datasource/connectivity` 包 + +> 目标(用户):把 `fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/` 迁出 fe-core, +> 使 fe-core 不再出现与具体 meta 类型(HMS/Glue/REST)或具体 fs 类型(S3/Minio/HDFS)相关的逻辑。 +> +> 结论先行:**这个包没有东西可"搬"。它在本分支上已是死代码(对唯一可达的两种目录零效果)。 +> 正确做法是"删空壳 + 在插件侧补回被删掉的能力",而不是把死代码搬进插件模块。** + +--- + +## 一、现状:整包已空转(FULLY_DEAD) + +### 1.1 唯一入口,且被插件目录完全绕开 + +- 入口:`ExternalCatalog.checkWhenCreating()`(`fe-core/.../datasource/ExternalCatalog.java:322-334`), + 仅当 `test_connection=true` 时构造 `CatalogConnectivityTestCoordinator` 并 `runTests()`。 + 它的唯一调用者是 `CatalogFactory.java:164`(且在 `if (!isReplay)` 内 → 回放/GSON 永不触发)。 +- `PluginDrivenExternalCatalog.checkWhenCreating()`(`PluginDrivenExternalCatalog.java:206-237`) + **完全覆盖**基类实现、不调 `super`:改走 `connector.testConnection(session)` + + `validationCtx.executePendingBeTests()`。 +- `SPI_READY_TYPES = {jdbc, es, trino-connector, max_compute, paimon, iceberg, hms}` + (`CatalogFactory.java:54-55`)全部由 `PluginDrivenExternalCatalog` 承接。 + +⇒ 还继承基类 `checkWhenCreating()` 的具体子类只有 3 个,其中 1 个已废: + +| 子类 | 是否走协调器 | +|---|---| +| `PluginDrivenExternalCatalog` | 否(override) | +| `RemoteDorisExternalCatalog`(`type=doris`) | **是** | +| `TestExternalCatalog`(`type=test`) | **是**(仅单测,`FeConstants.runningUnitTest` 门禁) | +| `LakeSoulExternalCatalog` | 否 —— `CatalogFactory.java:141-142` 直接抛 `"Lakesoul catalog is no longer supported"`,根本构造不出来 | + +### 1.2 协调器本身已被掏空 → 三条支路全是 no-op + +- **meta 探针**:`createMetaTester()`(`CatalogConnectivityTestCoordinator.java:265-273`) + 无条件返回匿名 no-op(10 个真探针在删 fe-core hive/iceberg 时被删,见 §2)。 + ⇒ `testConnection()` 空跑;`getTestLocation()` 返回 `null`。 +- **S3/Minio 存储探针**:入口是 `getTestObjectStorageProperties():109-124`, + 第一行就是 `if (StringUtils.isBlank(this.warehouseLocation)) return null;`。 + 而 `warehouseLocation` 只可能来自上面那个恒返回 `null` 的 `getTestLocation()`。 + ⇒ `S3ConnectivityTester` / `MinioConnectivityTester` / `AbstractS3CompatibleConnectivityTester` + **不可达**(无反射、无 ServiceLoader、无单测直接构造;fe-core 测试树对本包零引用)。 +- **HDFS 探针**:`HdfsCompatibleConnectivityTester.testFeConnection/testBeConnection:46-56` + 是空 TODO(**upstream 也一样**)。⇒ 空跑。 + +⇒ **删掉这 8 个文件,对 `type=doris` / `type=test` 逐字节零行为变化。** + +--- + +## 二、真正的问题:能力已经回退了(本次删除只是把它暴露出来) + +upstream master 的同名包有 **18 个文件**,本分支只剩 8 个。被删的 10 个全是 meta 探针 +(引入自 upstream PR **#57004**,2025-10-30): + +`HMSBaseConnectivityTester` / `AbstractHiveConnectivityTester` / `HiveHMSConnectivityTester` / +`HiveGlueMetaStoreConnectivityTester` / `AWSGlueMetaStoreBaseConnectivityTester` / +`AbstractIcebergConnectivityTester` / `IcebergHMSConnectivityTester` / +`IcebergGlueMetaStoreConnectivityTester` / `IcebergRestConnectivityTester` / +`IcebergS3TablesMetaStoreConnectivityTester` + +删除发生在 `ec3a0cd55f4`(P6 iceberg 迁移)和 `10290e02933`(原子删除 hive/hudi/iceberg 死码)。 + +### 2.1 能力对照表(upstream vs 本分支) + +| upstream 能力 | 本分支现状 | 归属 | +|---|---|---| +| Iceberg **REST** meta 探针 | ✅ 已在插件侧 `IcebergConnector.testConnection():240-248` | iceberg 连接器 | +| Iceberg 存储 **S3 FE 侧**探针 | ✅ 已在插件侧 `IcebergConnector.probeStorage():263-324`(自建 S3FileIO HEAD) | iceberg 连接器 | +| Iceberg **HMS / Glue / S3Tables** meta 探针 | ❌ **丢失**(`testConnection()` 只对 `catalog.type=rest` 探测) | iceberg 连接器 | +| Hive **HMS / Glue** meta 探针 | ❌ **丢失**(`HiveConnector` 未 override `testConnection()` → SPI 默认 `success()`,静默通过) | hive 连接器 | +| S3/Minio 存储 **BE 侧**探针(thrift `test_storage_connectivity`) | ❌ **丢失**(FE 侧唯一调用点就是这个死包) | 见 §4 决策 3 | +| HDFS 探针 | —(upstream 本就是空 TODO) | 不做 | +| Paimon / Hudi | —(upstream 也没有) | 不做 | + +> **关键细节**:upstream 里 `AbstractHiveConnectivityTester` **不 override** `getTestLocation()`(返回 `null`), +> 只有 iceberg 系探针返回 warehouse(`s3://...`)。所以**即便在 upstream,S3/Minio 存储探针也只有 iceberg 目录触发得到** +> (p2 套件里那句注释 "S3 is not tested for Hive HMS without warehouse" 正是此意)。 +> 这意味着:存储探针的**全部活语义**,iceberg 连接器已经自行覆盖了 FE 侧那一半。 + +### 2.2 受影响的回归套件 + +- `external_table_p0/test_connection/test_iceberg_rest_minio_connectivity.groovy`(**p0,CI 门禁**): + 断言 `"Iceberg REST"` + `"connectivity test failed"` + MinIO 错密钥失败 → **今天由 IcebergConnector 覆盖,OK**。 +- `external_table_p2/test_connection/test_connectivity.groovy`(p2,需外部环境): + Test 1.1 / 2.1 断言坏 HMS uri 抛 `"connectivity test failed"` + `"HMS"` → 本分支 hive/iceberg-on-HMS + 已无 meta 探针 → **CREATE CATALOG 会成功、不抛异常 → 这两个 case 应当已经挂了**(高置信;p2 需外部环境,未实跑证实)。 + +--- + +## 三、方案 + +### 阶段 1(必做):fe-core 归零 —— 纯删除,零行为变化 + +1. 删除整包 8 个文件:`fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/` + (`CatalogConnectivityTestCoordinator`、`MetaConnectivityTester`、`StorageConnectivityTester`、 + `AbstractS3CompatibleConnectivityTester`、`S3ConnectivityTester`、`MinioConnectivityTester`、 + `HdfsConnectivityTester`、`HdfsCompatibleConnectivityTester`)。 +2. `ExternalCatalog.java`:删 `import`(:42)+ `checkWhenCreating()`(:322-334)体内的协调器调用。 + 基类 `checkWhenCreating()` 保留为空实现(`PluginDrivenExternalCatalog` 要 override 它); + `TEST_CONNECTION` 常量保留(`PluginDrivenExternalCatalog.java:221` 在用)。 +3. 结果:fe-core 就"连通性"这条线**不再 import 任何** `S3Properties` / `MinioProperties` / + `HdfsProperties` / `MetastoreProperties` / `TStorageBackendType`。符合铁律"fe-core 源相关代码只减不增"。 + +> 不做的事:**不把这 8 个文件搬进 fe-connector / fe-filesystem**。搬过去仍然是死代码 +> (没有任何调用者),只会把污染换个地方(违反 Rule 2「不留投机性代码」)。 + +### 阶段 2(建议):能力回填 —— 用**现成 SPI**,fe-core 不加一行 + +全部落在插件侧,无需新增 fe-core 代码,也无需新 SPI: + +- **A. Hive meta 探针**(恢复 `HiveHMS` + `HiveGlue`): + `HiveConnector` 新增 `testConnection(session)` → `getMetadata(session).listDatabaseNames(session)` + (`ConnectorSchemaOps.java:30`;等价 upstream 的 `IMetaStoreClient.getAllDatabases()`)。 + 失败文案须含 `"connectivity test failed"` + `"HMS"`(p2 套件断言依赖)。约 15 行。 +- **B. Iceberg 非 REST meta 探针**(恢复 `IcebergHMS` / `IcebergGlue` / `IcebergS3Tables`): + `IcebergConnector.testConnection():240` 去掉 `catalog.type=rest` 的 guard, + 改为所有 catalog type 都 `listDatabaseNames`(REST 分支保留额外的 warehouse 解析)。约 5 行。 +- **C. 存储 FE 侧探针**:iceberg 已自建(`probeStorage`),hive 侧 upstream 本就没有 → **不做**。 + +### 阶段 3:测试 + +- 新增 `HiveConnectorTestConnectionTest`(仿 `IcebergConnectorTestConnectionTest`,断言文案稳定)。 +- p2 `test_connectivity.groovy`:补完能力后应重新变绿(需外部环境实跑确认)。 + +--- + +## 四、决策与落地结果(2026-07-14,用户已拍板并已实施) + +| 决策 | 选定 | 落地 commit | +|---|---|---| +| 范围 | 阶段 1 + 2(删死码 + 补 meta 探针) | `8057f45d0f2` | +| 基类 `checkWhenCreating()` | 留空实现 | `8057f45d0f2` | +| BE 存储探针 | 回填;挂 `ConnectorContext` **同步**回调(非 `ConnectorValidationContext` 延迟注册——后者注册时机早于 `testConnection()`,REST catalog 的 warehouse 尚未解析,覆盖面比 upstream 窄) | `c8f05143dc1` | +| BE `test_location` 空指针 | 顺手修(`Status::InvalidArgument`) | `c8f05143dc1` | + +### 实施中发现的两个"本来会出事"的点 + +1. **`ExternalCatalog.checkWhenCreating()` 的构造参数是有副作用的**(对抗 agent 抓到,我原判"零行为变化"是错的): + `catalogProperty.getMetastoreProperties()` 对 `type=doris|test` 抛 **`IllegalArgumentException`**(非 `UserException`, + `CatalogProperty` 只 catch 后者)→ 今天 `CREATE CATALOG ... type=doris, test_connection=true` 是**硬失败**的。 + 删除后该 DDL 成功。这是**唯一**的行为变化,本质是潜伏 bug 的修复;无任何回归套件覆盖。 +2. **`TcclPinningConnectorContext` 装饰器会静默吞掉新 default 方法**:两个装饰器逐方法显式委派, + 新增的 `testBackendStorageConnectivity` 若不加委派,就会落到接口的 no-op 默认实现 → + BE 探针在**生产**里永不执行(单测抓到)。iceberg + paimon 两个装饰器均已补委派。 + +### 平价边界(对齐 upstream,不多不少) + +iceberg 的 meta 探针范围钉死在 upstream `createMetaTester()` 的分支集合:**HMS / Glue / REST / S3Tables**。 +`hadoop`(文件系统型)catalog upstream 走 no-op → 这里也不探(`IcebergConnector.probesMetastore()`,有单测钉住)。 +一开始我改成"全类型都探",被既有单测 `testConnectionSucceedsWhenNothingToProbe` 挡下——那测试是对的。 + +--- + +## 附:原始决策选项(存档) + +**决策 1 — 本次范围** +- (a) 只做阶段 1(fe-core 归零),能力回填另开任务 → 最小、零风险、立即可提交 +- (b) 阶段 1 + 2(推荐)→ 顺手把已发生的回退补回来,p2 套件恢复 +- (c) 1 + 2 + 3 全套(含 BE 存储探针,见决策 3) + +**决策 2 — 基类 `checkWhenCreating()` 怎么处理** +- (a) 留空方法(推荐):`PluginDrivenExternalCatalog` 需要 override 点,且 `type=doris/test` 未来可能要加自己的校验 +- (b) 连同基类方法一起删,把 `checkWhenCreating()` 下沉为 `PluginDrivenExternalCatalog` 独有 → 需改 `CatalogFactory.java:164` 的调用点 + +**决策 3 — BE 侧存储连通性探针(thrift `test_storage_connectivity`)** +它是唯一"真丢了、且插件侧没补"的能力。BE handler 仍在(`be/src/service/backend_service.h:127`), +删掉 FE 调用点后它变孤儿。 +- (a) 暂不回填,单开 issue 跟踪(推荐:影响面小——它只在 iceberg + s3 warehouse 且 BE 存活时才跑过) +- (b) 用现成范式回填:给 `ConnectorValidationContext` 加 `requestBeStorageConnectivityTest(int type, Map props)` + 回调(引擎侧 `DefaultConnectorValidationContext` 发 thrift;签名保持 thrift-free,与已有的 + `requestBeConnectivityTest(byte[], int, String)` 同构),iceberg 连接器在 `preCreateValidation` 注册。 + → fe-core 只加**通用**回调(不含 fs 类型分支),符合架构目标 +- (c) 连 BE handler + thrift 定义一起删 → 跨 FE/BE 改动,不建议 + +--- + +## 五、风险 / 未验证项 + +- p2 `test_connectivity.groovy` 的现状(是否已挂)需要外部环境实跑确认;本文按代码推断为"已挂"。 +- 若选决策 1(a)(只删不补),p2 套件仍是红的,且属于**先前提交引入的回退**,不是本次删除引入 —— 但必须在 PR 描述里写明,不能默认它是"本来就那样"。 diff --git a/plan-doc/connectors/_template.md b/plan-doc/connectors/_template.md new file mode 100644 index 00000000000000..ef7df9da7c49a8 --- /dev/null +++ b/plan-doc/connectors/_template.md @@ -0,0 +1,83 @@ +# Connector: `` + +> 复制本模板到 `connectors/.md` 创建新连接器跟踪文件。 +> 维护规则:每次该连接器有动作(playbook 步骤完成、PR 合入、SPI 实现更新)时同步更新。 + +--- + +## 概况 + +| 项 | 值 | +|---|---| +| **catalog type 名** | `` | +| **fe-connector 模块** | `fe/fe-connector/fe-connector-/` | +| **fe-core 旧路径** | `fe/fe-core/src/main/java/org/apache/doris/datasource//` | +| **共享依赖** | `fe-connector-hms` / 无 / 其他 | +| **计划迁移阶段** | P | +| **当前状态** | ⏸ 未启动 / 🚧 进行中 / ✅ 完成 | +| **完成度** | 0% / 50% / 100% | +| **主 owner** | @xxx | + +--- + +## 迁移 Playbook 进度(13 步,来自 master plan §4) + +> 状态:✅ 完成 / 🚧 进行中 / ⏳ 未启动 / 🚫 不适用 + +| 步骤 | 描述 | 状态 | 备注 | +|---|---|---|---| +| 1 | 列出 fe-core 类,按终态分类 | ⏳ | | +| 2 | 列出 fe-connector 已有类,对照差距 | ⏳ | | +| 3 | 列出反向 instanceof / cast 调用点 | ⏳ | grep 结果数量 | +| 4 | 实现 ConnectorMetadata / ScanPlanProvider 缺失方法 | ⏳ | | +| 5 | 实现 ConnectorProvider.validateProperties + preCreateValidation | ⏳ | | +| 6 | META-INF/services 注册 | ⏳ | | +| 7 | CatalogFactory.SPI_READY_TYPES 加入 | ⏳ | | +| 8 | PluginDrivenExternalCatalog.gsonPostProcess 加迁移分支 | ⏳ | | +| 9 | ExternalCatalog.registerCompatibleSubtype 注册 | ⏳ | | +| 10 | 替换反向 instanceof(nereids/planner/...) | ⏳ | | +| 11 | PhysicalPlanTranslator 删该连接器分支 | ⏳ | | +| 12 | 写 / 跑回归测试 + image 兼容用例 | ⏳ | | +| 13 | 删除 fe-core 旧目录 + import 清理 | ⏳ | | + +--- + +## SPI 实现完成度(对照 RFC §2.1 扩展点) + +| 扩展点 | 是否需要 | 实现状态 | 备注 | +|---|---|---|---| +| E1 CreateTableRequest | | | | +| E2 Procedures | | | | +| E3 MetaInvalidator | | | | +| E4 Transactions | | | | +| E5 MvccSnapshot | | | | +| E6 VendedCredentials | | | | +| E7 SysTables | | | | +| E8 ColumnStatistics | | | | +| E9 Delete/Merge sink | | | | +| E10 listPartitions | | | | + +--- + +## 已知特殊性 / 风险 + +> 该连接器独有的难点。 + +- ... + +--- + +## 关联 + +- 阶段 task:[tasks/P](../tasks/P-xxx.md) +- 决策:D-NNN, ... +- 偏差:DV-NNN, ... +- 风险:R-NNN, ... +- 关键 PR:#NNN, ... + +--- + +## 进度日志(倒序) + +### YYYY-MM-DD +- 描述 diff --git a/plan-doc/connectors/es.md b/plan-doc/connectors/es.md new file mode 100644 index 00000000000000..563c69ef9eed86 --- /dev/null +++ b/plan-doc/connectors/es.md @@ -0,0 +1,68 @@ +# Connector: `es` + +--- + +## 概况 + +| 项 | 值 | +|---|---| +| **catalog type 名** | `es` | +| **fe-connector 模块** | `fe/fe-connector/fe-connector-es/` | +| **fe-core 旧路径** | `fe/fe-core/src/main/java/org/apache/doris/datasource/es/`(**目录已删除** ✅)| +| **共享依赖** | 无 | +| **计划迁移阶段** | 已完成(在 SPI 前置阶段) | +| **当前状态** | ✅ 100% 完成 | +| **完成度** | 100% | +| **主 owner** | @me | + +--- + +## 迁移 Playbook 进度 + +| 步骤 | 状态 | 备注 | +|---|---|---| +| 1-13 | ✅ | 全部 13 步完成 | + +--- + +## SPI 实现完成度 + +| 扩展点 | 是否需要 | 实现状态 | +|---|---|---| +| E1 CreateTableRequest | ❌ | n/a(ES 不支持 CREATE TABLE) | +| E2 Procedures | ❌ | n/a | +| E3 MetaInvalidator | ❌ | n/a | +| E4 Transactions | ❌ | n/a | +| E5 MvccSnapshot | ❌ | n/a | +| E6 VendedCredentials | ❌ | n/a | +| E7 SysTables | ❌ | n/a | +| E8 ColumnStatistics | ❌ | n/a | +| E9 Delete/Merge sink | ❌ | n/a | +| E10 listPartitions | ❌ | n/a | + +ES 不需要任何 P0 新增 SPI——它的所有功能都用现有 SPI 表达完毕。 + +--- + +## 已知特殊性 + +- ES 是**第一个**真正打通 SPI 端到端的连接器,是后续迁移的**参考样板**。 +- ES 用 `FORMAT_ES_HTTP` 作为 `TFileFormatType` 兜底;不是文件扫描但寄生于 `FileQueryScanNode`。 +- ES 有独特的 `terminate_after` 优化(`PluginDrivenScanNode.createScanRangeLocations` line 422-428):limit 全推下时附加给 ES 减少 scroll。这是连接器特定逻辑残留在 fe-core 的小缺口,等价的"scan-level 自定义参数"未来可考虑通过 `populateScanLevelParams` 完整下放。 +- 20 个 java 源文件 + 7 个测试文件,完整 REST 客户端 / DSL 构建 / 映射工具自含。 + +--- + +## 关联 + +- 阶段 task:N/A(已完成) +- 决策:D-001(沿用 PASSTHROUGH_QUERY)、D-002(PluginDrivenScanNode extends FileQueryScanNode 由 ES/JDBC 验证可行) +- 偏差:(暂无) +- 风险:(暂无) + +--- + +## 进度日志 + +### 2026-05-24 +- 跟踪文件建立。状态:100% 完成,作为后续连接器迁移的参考样板。 diff --git a/plan-doc/connectors/hive.md b/plan-doc/connectors/hive.md new file mode 100644 index 00000000000000..3bae1dc74c82e6 --- /dev/null +++ b/plan-doc/connectors/hive.md @@ -0,0 +1,105 @@ +# Connector: `hive` (含 `hms` 共享库) + +--- + +## 概况 + +| 项 | 值 | +|---|---| +| **catalog type 名** | `hms`(CATALOG_TYPE_PROP=hms)| +| **fe-connector 模块** | `fe/fe-connector/fe-connector-hive/` + `fe/fe-connector/fe-connector-hms/`(共享库)| +| **fe-core 旧路径** | `fe/fe-core/src/main/java/org/apache/doris/datasource/hive/` | +| **共享依赖** | 自身 `fe-connector-hms`;被 hudi/iceberg-HMS/paimon-HMS 依赖 | +| **计划迁移阶段** | **P7**(最复杂,6 周;**当前活跃阶段**,phase-split spec 已立,起步 P7.1)| +| **当前状态** | 🚧 P7 进行中:**code-grounded recon(10-agent)+ 阶段拆分 spec `tasks/P7-hive-migration.md` 已完成**;下一 = **P7.1 HiveMetadataOps 全功能搬迁(实现)** | +| **完成度** | 12%(recon+spec 立 + hive scan 只读已立 + hms 共享读库已立;DDL/txn/event/stats 端 0) | +| **主 owner** | TBD | + +--- + +## 迁移 Playbook 进度 + +| 步骤 | 状态 | 备注 | +|---|---|---| +| 1 | 🟥 | fe-core **52** 文件(29 顶层 + `event/` 21 + `source/` 2)| +| 2 | 🟨 | fe-connector-hive 12 文件(**只读 scan 已立**,DDL/txn/stats override=0);fe-connector-hms 9 文件(**共享读库**,无写/txn/lock/col-stats)| +| 3 | ⏳ | 反向 instanceof/cast:**85 occurrence / 33 文件**(最高;plan 旧记 31 已校正)+ ~7 处 type-level 耦合(CatalogFactory/GsonUtils/HudiUtils/IcebergHMSSource…)| +| 4 | ⏳ | `HiveMetadataOps` 全功能未迁;P7.1 重头 | +| 5 | ⏳ | | +| 6 | ✅ | META-INF/services 已注册(HiveConnectorProvider);hms 共享库无 service 注册 | +| 7 | ⏳ | | +| 8-9 | ⏳ | | +| 10 | ⏳ | 清理 31 处反向 instanceof | +| 11 | ⏳ | PhysicalPlanTranslator 删 `HMSExternalTable` 分支(含 dlaType=HIVE/ICEBERG/HUDI 三路)| +| 12 | ⏳ | 0 个测试 | +| 13 | ⏳ | 删 `datasource/hive/` | + +--- + +## SPI 实现完成度 + +| 扩展点 | 是否需要 | 实现状态 | 备注 | +|---|---|---|---| +| E1 CreateTableRequest | ✅ 需要 | Hive identity partition + bucket | | +| E2 Procedures | ❌ | n/a | | +| E3 MetaInvalidator | ✅ 需要 | **HMS 21 个 event 类整体搬到 fe-connector-hms** | D-004;P7.2 重头 | +| E4 Transactions | ✅ 需要 | **HMSTransaction(1866 行)+ HiveTransactionMgr 搬到 fe-connector-hive** | P7.3,ACID | +| E5 MvccSnapshot | ❌ | n/a | | +| E6 VendedCredentials | ❌ | n/a | | +| E7 SysTables | ❌ | n/a | | +| E8 ColumnStatistics | ✅ 需要 | Hive ANALYZE column stats 写回 HMS | E8 SPI 的主要消费者 | +| E9 Delete/Merge sink | ✅ 需要 | Hive ACID delete/merge | | +| E10 listPartitions | ✅ 需要 | HMS partition 主消费者 | | + +--- + +## 子阶段(P7.1 - P7.5) + +来自 master plan §3.8: + +| 子阶段 | 范围 | 估时 | +|---|---|---| +| P7.1 | `HiveMetadataOps` 全功能搬到 `HiveConnectorMetadata`(DDL/partition/statistics) | 2 周 | +| P7.2 | event pipeline 21 个类搬到 `fe-connector-hms`;接 `ConnectorMetaInvalidator` | 1.5 周 | +| P7.3 | HMSTransaction + HiveTransactionMgr 搬;ACID 写路径联调 | 2 周 | +| P7.4 | DLA 分流改造(让 `HMSExternalTable` 退化为 PluginDrivenExternalTable 承接) | 0.5 周 | +| P7.5 | 删除 fe-core/hive + 31 处反向 instanceof | 0.5 周 | + +--- + +## 已知特殊性(**最复杂的连接器**) + +- **HMS 是共同后端**:hive、hudi、iceberg-HMS-flavor、paimon-HMS-flavor 都依赖。HMS 连接器必须在 P7 之前就稳定可用(事实上 P3/P5/P6 已经在用 `fe-connector-hms` 共享库)。 +- **21 个 metastore event 类** + `MetastoreEventsProcessor` 后台线程——D-004 决定整体搬到 `fe-connector-hms`。 +- **HMSTransaction 1866 行 + HiveTransactionMgr** —— ACID 事务管理是**最难重写**的部分。R-002 高风险。 +- **HMSExternalTable 1293 行** 处理 hive/hudi/iceberg 三种 dlaType 的分流逻辑。这部分被 D-005 模型吸收。 +- **31 处反向 instanceof** 是所有连接器中最多的,散布在 `nereids/glue/translator`、`tablefunction/MetadataGenerator`、`AnalyzeTableCommand`、`ShowPartitionsCommand` 等。 +- **Kerberos UGI 上下文**——`ConnectorContext.executeAuthenticated` 已支持,但需要逐条审查 HMS 代码路径。 +- 0 个测试(fe-connector-hive 端) → P7 启动前需要建独立 ACID test suite + chaos test(R-002 缓解条件)。 + +--- + +## 关联 + +- 阶段 task:P7(待启动时建) +- 决策:D-002, D-003, D-004, D-005 +- 偏差:(暂无) +- 风险:**R-002(ACID 数据不一致,High)**、R-004(classloader)、R-010(event listener leak) + +--- + +## 进度日志 + +### 2026-07-05(下午 · P7 recon + 阶段拆分 spec 完成) +- **10-agent code-grounded recon**(`wf-p7-hive-recon` + 补充 type-coupling recon,~1.3M token)核清 52 文件分类、ACID 写路径、event pipeline、DLA 三分流、85 处反向 instanceof、跨连接器耦合 + 翻闸机制(CatalogFactory:50/133 + GsonUtils:366/447/471 兼容 + 6 文件写路径 retype 链 + 删除排序)。校正过时数字(instanceof 31→85、HMSTransaction 1866→1895、HMSExternalTable 1293→1332)。 +- **关键澄清**:recon 标"最大未知"= iceberg/hudi-on-HMS 归属,实为**已定** —— D-020(per-table SPI provider,hive 网关委派 -iceberg/-hudi)+ D-019(hudi live cutover 并入 P7)。故本阶段目标含删 `datasource/hudi/` + 23 HMS-iceberg 类。 +- **产出** `tasks/P7-hive-migration.md`(阶段拆分 spec,P7.1–P7.5 + old→new 映射 + SPI 缺口 + 8 条开放决策待各子阶段签字)。**下一 = P7.1 实现**(HiveMetadataOps → HiveConnectorMetadata + HmsClient 写方法)。 + +### 2026-07-05(P7 启动 = 当前活跃迁移目标) +- **iceberg P6 已 squash-合入 `branch-catalog-spi`(#64688 `8b391c7459d`)→ hive 成为下一个活跃迁移目标**。工作分支 `catalog-spi-11-hive`。 +- **下个 session 起步**:建 `tasks/P7-hive-migration.md` 阶段拆分 spec + code-grounded recon;起步 P7.1 HiveMetadataOps 全功能搬迁。权威计划 master plan §3.8 + 本文 §子阶段。**R-002 ACID 写路径(P7.3)= 项目最大风险,须专门集成测试作 gate。** +- **P7 连带清理**:删 fe-core `datasource/hive/`(P7.5)+ 23 个 HMS-iceberg 支撑类 + `datasource/hudi/`(阶段四);hudi 批 E(live cutover)并入 P7。 + +### 2026-05-24 +- 跟踪文件建立。当前最复杂的连接器;R-002(ACID 数据不一致)是项目最大风险。 +- 注意:hive 是 hudi/iceberg/paimon 共同的底座(通过 HMS 共享库),P7 启动 = 项目核心冲刺。 diff --git a/plan-doc/connectors/hudi.md b/plan-doc/connectors/hudi.md new file mode 100644 index 00000000000000..603dba5e78d5f1 --- /dev/null +++ b/plan-doc/connectors/hudi.md @@ -0,0 +1,100 @@ +# Connector: `hudi` + +--- + +## 概况 + +| 项 | 值 | +|---|---| +| **catalog type 名** | (依附 hms;通过 `tableFormatType=HUDI` 区分,见 D-005)| +| **fe-connector 模块** | `fe/fe-connector/fe-connector-hudi/` | +| **fe-core 旧路径** | `fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/` | +| **共享依赖** | `fe-connector-hms`(通过 HMS 拿元数据) | +| **计划迁移阶段** | **P3** | +| **当前状态** | 🚧 dormant 硬化中(批 A–C;gate 关、零 live 风险)| +| **完成度** | 25% | +| **主 owner** | TBD | + +--- + +## 迁移 Playbook 进度 + +| 步骤 | 状态 | 备注 | +|---|---|---| +| 1 | 🟡 | fe-core 9 个顶层类(cache key、schema cache、MvccSnapshot、partition utils、HudiUtils)+ `source/` 6 个(含 4 个 incremental relation)| +| 2 | 🟡 | fe-connector 9 个文件:Provider/Metadata/ScanPlanProvider/ScanRange/TableHandle/...| +| 3 | ✅ | 反向 instanceof:0 处(hudi 寄生在 Hive 上,没有独立 `HudiExternalCatalog`)| +| 4 | 🟡 | ConnectorMetadata 骨架完成;incremental query 路径未补 | +| 5 | ⏳ | | +| 6 | ✅ | META-INF/services 已注册 | +| 7 | ⏳ | `SPI_READY_TYPES` 未加(hudi 不能独立创建 catalog)| +| 8-9 | 🚫 | hudi 无独立 catalog;走 D-005 的 `tableFormatType` 模型 | +| 10 | ⏳ | 替换 `visitPhysicalHudiScan` 中 `HMSExternalTable.dlaType=HUDI` 检查 | +| 11 | ⏳ | 删 `HudiScanNode`,由 `PluginDrivenScanNode` + `HudiScanPlanProvider` 承接 | +| 12 | 🟡 | 批 C/T07:三连接器模块测试基线 59 测(hudi 33 + hms 12 + hive 14;含 COW/MOR schema golden parity);端到端/集群验证随批 E cutover | +| 13 | ⏳ | 删 `datasource/hudi/` | + +--- + +## SPI 实现完成度 + +| 扩展点 | 是否需要 | 实现状态 | 备注 | +|---|---|---|---| +| E1 CreateTableRequest | ❌ | n/a | hudi 不支持 CREATE TABLE | +| E2 Procedures | 🟡 | hudi 有 `archive_log` 等 procedure | 后续可考虑 | +| E3 MetaInvalidator | 🟡 | 通过 HMS event 同步 | 复用 `fe-connector-hms` 的 invalidator | +| E4 Transactions | 🟡 | hudi 有 timeline | 暂用 no-op | +| E5 MvccSnapshot | ✅ 需要 | 🟡 批 B 决策 keep default opt-out(T06/DV-007);完整 `HudiMvccSnapshot` → 批 E | 全体连接器无 override,T04 已 fail-loud time-travel;incremental query 时序入批 E | +| E6 VendedCredentials | ❌ | n/a | | +| E7 SysTables | ❌ | n/a | | +| E8 ColumnStatistics | 🟡 | hudi 有 column stats | 后续 | +| E9 Delete/Merge sink | ❌ | hudi 写路径不在本计划范围 | 与 BE 强耦合 | +| E10 listPartitions | ✅ 需要 | 🟡 批 B:applyFilter EQ/IN 裁剪 ✅(T05 `10b72d4`,镜像 Hive);`listPartitions*` override → 批 E(DV-007,零 live caller)| 分区裁剪经 applyFilter→prunedPartitionPaths→resolvePartitions 链路 | + +--- + +## 已知特殊性(**重要**) + +- **没有独立的 `HudiExternalCatalog`**!hudi 表通过 `HMSExternalTable.dlaType=HUDI` 暴露,本质上是寄生在 Hive 连接器上。 +- D-005 决定:用 `ConnectorTableSchema.tableFormatType=HUDI` 显式建模,由 HMS connector 探测后填充。 +- 4 个 `HoodieIncremental*Relation` 类是和 hudi-spark 库交互——必须在 fe-connector 模块内(classpath 隔离)。 +- P3 实质上要做的是: + 1. 把 `HudiUtils` / `HudiSchemaCacheKey/Value` / `HudiMvccSnapshot` / `HudiPartitionProcessor` 搬到 `fe-connector-hudi`。 + 2. 把 `HudiScanNode` 删除,由 `PluginDrivenScanNode` + 增强后的 `HudiScanPlanProvider`(已存在)承接 incremental relation 逻辑。 + 3. 改造 `PhysicalHudiScan` 让它走 SPI 路径。 +- **P3 启动前必须 P5 paimon 或 P7 hive 进入到至少完成 hms metadata 路径**,否则 hudi 拿不到底层 HMS 表元数据。**这是依赖序的隐藏约束**——见 master plan §3.4 第一段。 +- **⚠️ 2026-06-04 recon 更正([DV-005](../deviations-log.md))**:上一条「隐藏依赖」与代码不符。HMS-over-SPI 读路径(`fe-connector-hms` 客户端库 + `HiveConnectorMetadata`(type `"hms"`) + `HudiConnectorMetadata`(type `"hudi"`) + `ConnectorTableSchema.tableFormatType` 区分符)**早已存在但 dormant**(`CatalogFactory.SPI_READY_TYPES` 不含 hms/hudi,零 live caller)。**真正阻塞是 catalog 模型错配**:现存连接器是独立 `"hudi"` catalog type,而 Doris 真实模型是 hudi 寄生在 `"hms"` catalog 内、以 `DLAType.HUDI` 暴露,且 fe-core 不消费 `tableFormatType`。P3 改为:先 recon scan/split 路径 + 写 catalog 模型决策备忘(a/b;c 否决)→ 用户签字 → 编码。详见 [HANDOFF](../HANDOFF.md) 关键认知 1。 + +--- + +## 关联 + +- 阶段 task:P3(待启动时建) +- 决策:D-005(DLA 区分符方案 A)、D-020(多格式 scan 路由=方案 B per-table SPI provider,细化 D-005;T08 设计) +- 偏差:(暂无) +- 风险:(暂无独立的) + +--- + +## 进度日志 + +### 2026-06-05(批 D) +- **P3-T08 ✅**(批 D,design-only 零代码,[D-020](../decisions-log.md),用户签字):`tableFormatType` 分流消费设计备忘。直接输入上 session recon `research/spi-multi-format-hms-catalog-analysis.md`;本场 firsthand 核读 keystone gap(`PluginDrivenExternalTable.initSchema` 只读 columns、丢 `getTableFormatType()`)。**核心拆解 M1 身份消费 ⊥ M2 scan 路由**(M1 三方案通用)。M2=**方案 B**(新增向后兼容 default `ConnectorMetadata.getScanPlanProvider(handle)`,fe-core 优先 per-table、回落 per-catalog;hms 网关按 `handle.getTableType()` 委派 Hudi/Iceberg provider),把 per-table 选 provider 升为一等 SPI 契约(满足 D-009)。**细化 D-005**(区分符沿用;"PhysicalXxxScan" 措辞早于 P1 统一,由 per-table provider seam 取代)。Iceberg-on-hms 经 SPI 依赖 P6/M3;M1+M2 实现登记批 E/P7。**批 A–D(P3 hybrid in-scope)全部完成**。设计 [`../tasks/designs/P3-T08-tableformat-dispatch-design.md`](../tasks/designs/P3-T08-tableformat-dispatch-design.md)。 + +### 2026-06-05(批 C) +- **P3-T07 ✅**(批 C,测试 + gap-1 修,[DV-008](../deviations-log.md),用户签字):三模块测试基线 + COW/MOR schema parity。feasibility = **golden-value**(fe-core 不依赖具体连接器模块,无跨模块编译路径);关键结论 **COW/MOR schema type-agnostic**(两侧 schema 推导都不按表型分支,差异只在 scan planning)。**hudi** `avroSchemaToColumns` 顶层列名 `toLowerCase` 修(gap-1,镜像 legacy `HMSExternalTable:745`)+ package-private static 可测;`HudiTypeMappingTest` 补 `fromAvroSchema` golden(原零覆盖);新 `HudiSchemaParityTest`(列名/序/类型/Hive 串/casing 边界)+ `HudiTableTypeTest`(COW/MOR/UNKNOWN)。**hms** 新 `HmsTypeMappingTest`(共享 Hive 类型串解析器,原零测试)。**hive** 新 `HiveFileFormatTest` + `HiveConnectorMetadataPartitionPruningTest`(镜像 T05 裁剪网)。三模块 59 测全绿(hudi 33 + hms 12 + hive 14);checkstyle 0;import-gate 通过;gate 保持关闭。gap-2 Hudi meta-field 纳入(`getTableAvroSchema(true)` vs 无参)推迟批 E。设计 [`../tasks/designs/P3-T07-test-baseline-design.md`](../tasks/designs/P3-T07-test-baseline-design.md)。 + +### 2026-06-05(批 B) +- **P3-T05 ✅**(批 B,commit `10b72d4`):`HudiConnectorMetadata.applyFilter` 真实 EQ/IN 分区裁剪。原占位实现列**全部** HMS 分区不裁剪、且无条件设 `prunedPartitionPaths`(静默把分区来源从 Hudi-metadata 切到 HMS);重写为忠实镜像 `HiveConnectorMetadata`(抽取 partition 列 EQ/IN 谓词→列候选→裁剪→仅有效果时回传 pruned handle,否则 `Optional.empty()` 回落 Hudi-metadata listing)。保留 `List` 路径表示 + `-1` 上限;7 helper duplicate from Hive(仅依赖 fe-connector-hms)。`HudiPartitionPruningTest` 8 测全绿;gate 保持关闭。`listPartitions*` override 推迟批 E([DV-007](../deviations-log.md):零 live caller、Hive 不 override)。设计 [`../tasks/designs/P3-T05-partition-pruning-design.md`](../tasks/designs/P3-T05-partition-pruning-design.md)。 +- **P3-T06 ✅**(批 B 决策,零代码,[DV-007](../deviations-log.md),用户签字):MVCC/snapshot SPI 保持 default `Optional.empty()` opt-out,不新增抛异常 override(破 SPI opt-out 约定、全体连接器无 override、无 production caller=死代码、T04 已 fail-loud time-travel)。完整 MVCC 入批 E。设计 [`../tasks/designs/P3-T06-mvcc-design.md`](../tasks/designs/P3-T06-mvcc-design.md)。 + +### 2026-06-05(批 A) +- **P3-T04 ✅**(批 A,commit `feceabb`):`visitPhysicalHudiScan` SPI 分支 fail-loud——`FOR TIME/VERSION AS OF`(曾静默返最新)与增量读(曾静默全扫)抛 `AnalysisException`。dormant 分支零 live 风险;单测推迟批 E。**批 A 编码完成**(T02+T04 落地,T03→批 E)。 +- **P3-T03 🟡 推迟批 E**([DV-006](../deviations-log.md),用户签字):schema_id/history_schema_info 非批 A 可做的 SPI-surface 修复——`HudiColumnHandle` 无 field id、SPI 无 Hudi `InternalSchema` 版本、连接器无 type→`TColumnType` thrift;裸 `current==file==-1`→BE `ConstNode`(大小写敏感) 弱于现状 `by_parquet_name` 名匹配(净回归)。批 A 保持现状名匹配(零回归,common 无 evolution 可用;改名/evolution 退化非崩溃),faithful parity 入批 E。 + +### 2026-06-04 +- **P3-T02 ✅**(批 A,commit `95f23e9`):修 JNI scanner `column_types` 双 bug——(a) 发完整 Hive 类型串(新 `HudiTypeMapping.toHiveTypeString` 复刻 legacy `HudiUtils.convertAvroToHiveType`),不再用 `getTypeName()` 丢精度/子类型;(b) `HudiScanRange` typed list 端到端,弃逗号 join/split(曾打碎 `decimal(10,2)`/`struct<...>`),BE 自做 join(types `#`)。建模块首批测试 11 个全绿;gate 保持关闭。设计见 [`../tasks/designs/P3-T02-column-types-design.md`](../tasks/designs/P3-T02-column-types-design.md)。 +- P3 启动 recon(8-agent code-grounded workflow + 对抗验证)。结论([DV-005](../deviations-log.md)):HMS-over-SPI 读码已存在但 **dormant**(gate 未开、零 live caller);**真阻塞=catalog 模型错配**(独立 `"hudi"` type vs 寄生 `"hms"` 的 `DLAType.HUDI`,fe-core 不消费 `tableFormatType`)+ 增量读无 SPI 表示(P1-T04 gap)+ 三模块零测试。P3 待 catalog 模型决策(a/b;c 否决)签字后开工。关键文件锚点见 HANDOFF。 + +### 2026-05-24 +- 跟踪文件建立。50% 实现已就位,但 P3 依赖 hms-connector 路径先打通(D-005 模型)。 diff --git a/plan-doc/connectors/iceberg.md b/plan-doc/connectors/iceberg.md new file mode 100644 index 00000000000000..4566529c32888d --- /dev/null +++ b/plan-doc/connectors/iceberg.md @@ -0,0 +1,229 @@ +# Connector: `iceberg` + +--- + +## 概况 + +| 项 | 值 | +|---|---| +| **catalog type 名** | `iceberg` | +| **fe-connector 模块** | `fe/fe-connector/fe-connector-iceberg/` | +| **fe-core 旧路径** | `fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/` | +| **共享依赖** | `fe-connector-hms`(iceberg-HMS-flavor 用) | +| **计划迁移阶段** | **P6**(最大阶段;迁移+翻闸+删原生子系统已合入 `branch-catalog-spi` #64688 `8b391c7459d`)| +| **当前状态** | ✅ 迁移 + 翻闸 + 删 legacy 原生子系统已合入 `branch-catalog-spi`(**#64688** `8b391c7459d`)——iceberg 入 `SPI_READY_TYPES`,FE 走 SPI 路径;P6.1–P6.6 全阶段 + 属性/鉴权全归插件(S1–S10)一次性 squash。⚠️ **iceberg-on-HMS**(`type=hms` / `DlaType.ICEBERG`)仍走 fe-core,`datasource/iceberg/` 尚存 23 个支撑类,随 **P7 hive** 迁移删(阶段四,D5/Q3=B)| +| **完成度** | 100%(原生 iceberg 迁移全阶段完成并合入 #64688;剩 HMS-iceberg 支撑类清理归 P7 阶段四)| +| **阶段拆分 spec** | [`tasks/P6-iceberg-migration.md`](../tasks/P6-iceberg-migration.md) | +| **主 owner** | TBD | + +--- + +## 迁移 Playbook 进度 + +| 步骤 | 状态 | 备注 | +|---|---|---| +| 1 | 🟥 | fe-core 34 个顶层 + `source/`(7) + `action/`(10) + `cache/`(2) + `broker/`(3) + `dlf/`(3) + `fileio/`(4) + `helper/`(3) + `profile/`(1) + `rewrite/`(6) = **73 个文件** | +| 2 | 🟥 | fe-connector 只有 6 个文件(Provider/Metadata/Properties/TableHandle/TypeMapping)—— **骨架**| +| 3 | ⏳ | 反向 instanceof:~49 处(写命令层最密,P6.7 清理)| +| 4 | ⏳ | ConnectorMetadata 仅基础 list/get 实现;分子阶段 P6.1-P6.6 全面补 | +| 5 | ⏳ | | +| 6 | ✅ | META-INF/services 已注册 | +| 7 | ⏳ | | +| 8-9 | ⏳ | | +| 10 | ⏳ | 清理 ~49 处反向 instanceof(P6.7)| +| 11 | ⏳ | PhysicalPlanTranslator 删 `IcebergExternalTable / IcebergSysExternalTable` 分支 | +| 12 | ⏳ | 0 个测试 | +| 13 | ⏳ | 删 `datasource/iceberg/` | + +--- + +## SPI 实现完成度 + +| 扩展点 | 是否需要 | 实现状态 | 备注 | +|---|---|---|---| +| E1 CreateTableRequest | ✅ 需要 | 含 transform partition(year/month/day/bucket/truncate)| | +| E2 Procedures | ✅ 需要 | **10 个 action**(rewrite_data_files、expire_snapshots、...) | P6.4 重点 | +| E3 MetaInvalidator | 🟡 | 部分 iceberg-HMS-flavor 需要 | 复用 `fe-connector-hms` | +| E4 Transactions | ✅ 需要 | `IcebergTransaction`(966 行)待迁 | P6.3 | +| E5 MvccSnapshot | ✅ 需要 | `IcebergMvccSnapshot` 待迁 SPI | snapshot/timestamp 时光机 | +| E6 VendedCredentials | ✅ 需要 | `IcebergVendedCredentialsProvider` 待迁 | Iceberg REST 主战场 | +| E7 SysTables | ✅ 需要 | `IcebergSysExternalTable.SysTableType` 9 个 | $snapshots/$history/... | +| E8 ColumnStatistics | 🟡 | snapshot summary | 可选 | +| E9 Delete/Merge sink | ✅ 需要 | `IcebergDeleteSink/MergeSink/TableSink` 删除 | P6.3 | +| E10 listPartitions | ✅ 需要 | | + +--- + +## 子阶段(P6.1 - P6.6) + +来自 master plan §3.7: + +| 子阶段 | 范围 | 估时 | +|---|---|---| +| P6.1 | 元数据 only(7 个 catalog flavor + ConnectorMetadata) | 2 周 | +| P6.2 | scan path(ScanPlanProvider + MVCC + cache) | 1 周 | +| P6.3 | write path(commit/transaction + DML SPI + planner 改造) | 1 周 | +| P6.4 | actions(procedure SPI 接 10 个 action) | 0.5 周 | +| P6.5 | sys tables + metadata columns | 0.5 周 | +| P6.6 | 删除 fe-core/iceberg + 清 19 处反向 instanceof | 0.5 周 | + +--- + +## 已知特殊性(**极重要**) + +- **7 个 catalog flavor**(HMS/Glue/Hadoop/Jdbc/REST/S3Tables/DLF)—— Iceberg SDK 本身有 Catalog 抽象,连接器只需 dispatch property → 实例化哪个 SDK Catalog。 +- **10 个 IcebergXxxAction**(`RewriteDataFiles`、`ExpireSnapshots`、`RollbackToSnapshot`、`CherrypickSnapshot`、`PublishChanges`、`SetCurrentSnapshot`、`RewriteManifests`、`FastForward`、`RollbackToTimestamp`、`PublishChanges`)—— 必须用 P0 新增的 `ConnectorProcedureOps` 承接。 +- **写路径深度耦合**:`IcebergConflictDetectionFilterUtils`、`IcebergConflictDetectionFilterUtils`、`IcebergRowId`、`IcebergMergeOperation` 都和 nereids 优化器纠缠。**P6.3 前必须单独写 `plan-doc/06-iceberg-write-path-rfc.md` 评审方案**(master plan 已注明)。 +- **5400+ 行核心代码**(IcebergMetadataOps 1247 + IcebergTransaction 966 + IcebergUtils 1718 + IcebergScanNode 1228 + IcebergExternalCatalog 241)。 +- **DLA 寄生**:iceberg-on-HMS flavor 通过 `HMSExternalTable.dlaType=ICEBERG` 暴露——D-005 决定用 `tableFormatType` 区分。 + +--- + +## 关联 + +- 阶段 task:P6(待启动时建) +- 决策:D-002, D-005, D-006 +- 偏差:[DV-038](GLOBAL_ROWID + getColumnHandles 共享 fe-core field-id 路径 BE DCHECK;翻闸已随 #64688 完成)、[DV-039](parity-忠实 HIGH-MEDIUM)、[DV-040](perf-cosmetic ~36 项批) +- 风险:R-003(Procedure SPI 抽象失败)、R-004(classloader)、R-005(nereids 写命令耦合)、R-012(snapshotId 类型) + +--- + +## 进度日志 + +### 2026-07-05(P6 完成 + squash-合入 `branch-catalog-spi` #64688 `8b391c7459d`) +- **P6 iceberg 迁移 + 测试全部完成,一次性 squash 为 #64688**(685 文件,−23744 行):原生 iceberg(元数据/scan/write/procedures/sys-tables/行级 DML)迁到 `fe-connector-iceberg` + 翻闸(入 `SPI_READY_TYPES`)+ GSON 迁移(老镜像→`PluginDriven*`)+ 属性/鉴权全归插件(S1–S10)+ 删 fe-core 原生子系统(`IcebergExternalCatalog`/`IcebergExternalTable`/`IcebergTransaction`/7 flavor/`broker`/`dlf`/`fileio`/`action`/`rewrite`/DML 执行器/planner sink 等)。 +- **遗留(P7 接手)**:fe-core `datasource/iceberg/` 尚存 23 个 HMS-iceberg 支撑类(`IcebergUtils`/`IcebergMetadataOps`/`source/IcebergScanNode`/`cache/`/`IcebergMvccSnapshot`/…),iceberg-on-HMS 走它们、随 P7 hive 迁移删(阶段四)。 + +### 2026-06-25(P6.6-C2 WS-SYNTH-READ:起步 recon 推翻 RFC BE/D7 + classifyColumn SPI 化,TDD+mutation) +- **起步对抗 recon(独立读码 + 8-agent 对抗 wf `wf_9bf8730b-05b`/655k token,3 verdict 全 confirmed)证伪/重构 RFC §6.WS-SYNTH-READ,用户 AskUserQuestion 双裁([D-069])**:① **BE 已完整处理** SYNTHESIZED/GENERATED(`iceberg_reader.cpp:162-208`/`:444-489`,合成列 `continue` 不触达 `table_schema_change_helper.h` DCHECK;reader 由 `table_format_type=="iceberg"` 选与 FE 节点无关)→ **C2 零 BE 改**(RFC 的 BE「add_not_exist_children」过时);② **D7 问错方向**——paimon 不触达 GLOBAL_ROWID(被 `MaterializeProbeVisitor.SUPPORT_RELATION_TYPES` 精确类白名单挡,非 classifyColumn),对 paimon 怎样都安全。 +- **新挖 RFC 漏的两处翻闸前置项**:**[GAP-A→C5]** 翻闸后 iceberg 表类 `PluginDrivenMvccExternalTable`(与 paimon 同类)掉出 `MaterializeProbeVisitor` allowlist→lazy-top-N 静默失效,须 capability/engine 判别(非 class);**[GAP-B→随翻闸]** 隐藏列注入(`ICEBERG_ROWID`+v3 row-lineage 现由 legacy `IcebergExternalTable.initSchema:297-301` 注入,翻闸后通用路只从连接器 native schema 建、不注入)。 +- **实现 = classifyColumn SPI 化(遵用户「fe-core 不得 `if(iceberg)`」原则)**:新中立 `ConnectorColumnCategory{DEFAULT,SYNTHESIZED,GENERATED}` + `ConnectorScanPlanProvider.classifyColumn(name)` default DEFAULT;fe-core `PluginDrivenScanNode.classifyColumn` 判 `startsWith(GLOBAL_ROWID_COL)`→SYNTHESIZED(Doris 全局机制,留 fe-core)+ 委派 `classifyColumnByConnector` seam;**连接器** `IcebergScanPlanProvider.classifyColumn` override:`__DORIS_ICEBERG_ROWID_COL__`→SYNTHESIZED、`_row_id`/`_last_updated_sequence_number`→GENERATED、else DEFAULT(禁 import fe-core→本地字面量 + fe-core contract UT pin)。**对 live 连接器零行为变更**(paimon/jdbc/es/mc/trino 不产生 GLOBAL_ROWID + 用 SPI default→`super()`)。 +- **验证**:连接器 `IcebergScanPlanProviderClassifyColumnTest` 4/0/0(Mut M4/M5 ICEBERG_ROWID/row-lineage→DEFAULT 双红);fe-core `PluginDrivenScanNodeClassifyColumnTest` 6/0/0(Mut M1 `startsWith→equals` + M2/M3 丢映射→三红);回归 `PluginDrivenScanNode*Test` 63/0/0 + `IcebergScanPlanProviderTest` 67/0/0 + import-gate PASS。设计 `tasks/designs/P6.6-C2-ws-synth-read-design.md`。e2e(v3 lazy-top-N / 隐藏列 SELECT / DML rowid,且依赖 GAP-A/B)留 P6.8。**仍 pre-flip dormant**(iceberg 不在 `SPI_READY_TYPES`)。 + +### 2026-06-25(P6.6-C1 WS-PIN:起步 recon 推翻 D4/D5 + sys 表时间旅行 pin-feed,TDD+mutation) +- **起步 recon(独立读码 + 6-agent 对抗 wf `wf_b1bd42e4-675`/526k token)证伪 RFC §6.WS-PIN,用户 AskUserQuestion 双裁([D-068])**:① 普通表 pin reorder **移出 C1→P6.7**(iceberg 普通表 TT 已靠 `IcebergScanPlanProvider:705-714` workaround 正确;全局 reorder 打破 paimon `@branch` 读);② sys 表**改用 `getQueryTableSnapshot()` 线程**而非 `implements MvccTable`(D5 因 `MvccTableInfo` key 不匹配 + `loadSnapshots(sysTable)` 从不被调用而行不通)。 +- **实现 = fe-core 唯一改点** `PluginDrivenScanNode.pinMvccSnapshot`:context 无 snapshot(sys 恒空)时 fallback `resolveSysTableSnapshotPin()` 委派**源表** `MvccTable.loadSnapshot(...)`→经既有 `applyMvccSnapshotPin` 落 sys handle。**零 SPI / 零 MvccTable-on-sys / 零 StatementContext / 零连接器改**(连接器 `getSysTableHandle`+`buildScan:338-342` useRef/useSnapshot 已 ready)。三处 pin 点自动覆盖。实现 [D-067] 所记「休眠翻闸接线」follow-up。 +- **验证**:新 `PluginDrivenScanNodeSysTablePinTest` 5 UT,全 `PluginDrivenScanNode*Test` 家族绿;mutation:fallback→empty 令 T1/T2 双红、去 both-null 短路令 T3(verifyNoInteractions)红。设计 `tasks/designs/P6.6-C1-ws-pin-design.md`。e2e(真 `t$snapshots FOR TIME AS OF`)留 P6.8 docker。**仍 pre-flip dormant**(iceberg 不在 `SPI_READY_TYPES`)。 + +### 2026-06-25(P6.5-T07 parity 审计 + 2 现修 + 9 gap-fill + DV 中央登记,TDD+mutation) + +- **对抗 byte-parity 审计 wf** `wf_d530d760-ccf`(8 area finder 覆 T02–T06 sys 路 + DESCRIBE/SHOW/info_schema parity;refute-by-default skeptic〔effort=high〕+ completeness critic;31 agent/1.6M token)= **22 finding/19 confirmed**(3 refuted 全对)。**主 session 独立读码交叉核**揭出关键 finding。 +- **2 项主动偏差 → 用户 AskUserQuestion 双裁「现修」([D-067],非 DV)**:**A** 共享 fe-core `PluginDrivenScanNode.checkSysTableScanConstraints`〔P5 paimon `38e7140ce56`〕无条件拒**任何** plugin sys 表的 time-travel + scan-params;legacy `IcebergScanNode.createTableScan:569` 对 sys 表 honor `useRef/useSnapshot`〔无 isSystemTable gate〕+ 连接器 T02/T05 保留+honor pin(偏差①)→ 翻闸后 pin **dead-on-arrival**=回归(DV-038/041 族);**纠 HANDOFF**「偏差①非 DV」分类错。**B** `buildTableDescriptor` hms 分叉 `TYPE_HMS.equals(原始值)` 大小写敏感 → `type="HMS"` 得 ICEBERG_TABLE vs legacy HIVE_TABLE。 +- **现修 A(3 文件)**:新 SPI `ConnectorScanPlanProvider.supportsSystemTableTimeTravel()` 默认 **false**〔paimon/mc/jdbc/es 继承、零回归〕+ `IcebergScanPlanProvider` override true + fe-core guard capability-aware〔放行 time-travel/branch-tag,**@incr 对所有连接器仍拒**——合成元数据表 incremental 无定义、legacy 静默忽略 guard fail-loud 更安全〕。**⚠️ guard 修仅移除主动 BLOCK**——完整 sys 时间旅行 e2e 还需 query→连接器 handle pin 的休眠翻闸接线(DV-041「休眠-至-翻闸激活集」族)+ P6.8 docker。**现修 B**:`equalsIgnoreCase`(一行)+ 大写 UT。 +- **+9 连接器 gap-fill UT**(无 Mockito,fail-loud fake + InMemoryCatalog):handle `coordinatesArePartOfIdentity`〔plan-cache key〕/`toStringOfPinnedSysHandle`·colhandles auth-scope×2/keyset #969249/empty-sysname·`getScanNodePropertiesForSysHandleStillEmitsLocationCreds`〔BE-403〕·capability·hms 大写。 +- **mutation-check**:guard Mut-A〔`=false`→AllowsTimeTravel/AllowsBranchTag 双红〕+ Mut-B〔去 @incr 子句→StillRejectsIncr 红〕;hms Mut〔`equalsIgnoreCase→equals`→大写 UT 红〕;每次 `cp`/python 复原 diff IDENTICAL。 +- **验证(重跑 surefire,Rule 12)**:连接器全量 `package -Dassembly.skipAssembly=true` **541/0/1**(=532+9,0 回归);fe-core `PluginDrivenScanNodeSysTableGuardTest` **7/0/0**;checkstyle 0;import-gate exit 0〔SPI 加法在 `connector.api.scan`〕;`CatalogFactory:51` 未改 → iceberg 仍**不在** `SPI_READY_TYPES`。**新 1 D([D-067]);新 2 DV(DV-048 correctness-bearing / DV-049 perf-cosmetic)**。**残留 gap-fill 延 T07-续**〔fe-core sys getMysqlType/getEngine/getEngineTableTypeName/position_deletes-seam/non-registration/guard-ordering + 连接器 predicate-pushdown/dummy-path + WHY-comment〕,audit specs 存 HANDOFF。**live-e2e 未跑**(dormant,P6.8 兜底)。下一 = T07-续 或 T08(收口 ⇒ P6.5 DONE)。 + +### 2026-06-25(P6.5-T07-续:残留 8 gap-fill UT 全落 + mutation-check) + +- **8 gap-fill 全落**:fe-core `PluginDrivenSysTableTest` +4〔sys `getMysqlType`="BASE TABLE"〔同测钉 new==legacy `ICEBERG_EXTERNAL_TABLE.toMysqlType`,无 Env〕/ `getEngine`="iceberg"+`getEngineTableTypeName`="ICEBERG_EXTERNAL_TABLE"〔**assertAll** 两 pin 独立 mutation〕/ position_deletes 通用 not-found〔含 snapshots 正控〕/ 非注册不变式〔sys 类无 `@SerializedName` 声明字段 + discovery key 无 `$`〕〕 + guard `PluginDrivenScanNodeSysTableGuardTest` +1〔guard-message 顺序:scan-params 先于 time-travel〕 + 连接器 `IcebergScanPlanProviderTest` +2〔sys predicate 作为 residual 带给 BE / sys split `/dummyPath`〕 + `IcebergConnectorMetadataSysTableTest` WHY-comment 修。 +- **⚠️ 纠 audit spec(实证 Rule 12)**:原 spec 设 sys 元数据列谓词「裁到 1 行」**实证为假**——record_count=10 过滤后 FE 序列化 split 行数 **2 vs 2 不变**:iceberg 元数据表的**列**谓词是 **BE-applied residual**(`IcebergSysTableJniScanner` 读时应用),非 FE plan-time 行裁(plan-time 裁是 snapshot pin,已另测 `HonorsTheSnapshotPin`)。故 test-6 改钉 FE 可达观测 = 反序列化 `task.residual()` 携 `record_count`(无谓词时 residual=`true`)。**非 legacy 偏差**——legacy `IcebergScanNode` 同样 serialize `FileScanTask`(带 residual)交 BE。 +- **WHY-comment 修**:`getSysTableHandleNormalizesNameToLowercase` 原注释把大小写不敏感误归给 resolution gate / `MetadataTableType.from`。实证:legacy resolution 是 **case-SENSITIVE** `TableIf.findSysTable:413` 的 `Map.get` over `IcebergSysTable` 小写键,且 `SysTable.getTableNameWithSysTableName` **不** lower-case suffix → 混合大小写 `t$SNAPSHOTS` 永不 resolve,仅小写 canonical 名喂入 `getSysTableHandle`(`PluginDrivenSysExternalTable:85` 穿透匹配的小写名)→ 连接器 `equalsIgnoreCase` accept 是 **production 永不达的无害超集**;`MetadataTableType.from` 的大小写不敏感作用在 metadata-table **BUILD** 时(`resolveSysTable:1132`),非 resolution gate。lower-casing 仅为 canonical handle-identity parity。 +- **mutation-check(全绿→红→`git checkout` 复原,diff IDENTICAL)**:fe-core 5 变异一次 build〔`TableIf` PLUGIN `toMysqlType`→null / `getEngine`+`getEngineTableTypeName` 删 iceberg case〔assertAll 双红〕/ 注入 position_deletes / `@SerializedName` on `sysTableName` / swap guard 两块→time-travel 先〕→ test1–5 全红 + 1 已知 collateral(`testGetSupportedSysTablesDelegatesToConnector` size 2→3);连接器 2 变异〔sys 路 `buildScan` 丢 filter→residual=`true` / 改 `SYS_TABLE_DUMMY_PATH` 常量〕→ test6/7 红。 +- **验证(重跑 surefire,Rule 12)**:fe-core `PluginDrivenSysTableTest` **10/0/0** + guard **8/0/0**;连接器全量 **543/0/1**(=541+2,0 回归)`IcebergScanPlanProviderTest` 67/0 + `IcebergConnectorMetadataSysTableTest` 22/0;checkstyle 0(两模块);import-gate exit 0;`CatalogFactory:51` 未改 → iceberg 仍**不在** `SPI_READY_TYPES`。**0 新 D/DV**(test-6 是 test-spec 纠正非 legacy 偏差)。**live-e2e 未跑**(dormant,P6.8 兜底)。 + +### 2026-06-25(P6.5-T08 收口:汇总设计 + faithfulness 对抗验证 ⇒ P6.5 DONE) + +- **汇总设计** `tasks/designs/P6.5-T08-systable-summary-design.md`(7 节,仿 P6.3/P6.4-T09):架构总览 + 逐 task 索引(T01–T07 含续)+ sys-table SPI 收口核对(净 +1 capability SPI `supportsSystemTableTimeTravel`,余复用 E7)+ DV-048/049 中央回指 + 翻闸阻塞(sys 时间旅行 query→handle pin threading = DV-041 同族)+ 验收门 + 下一阶段。 +- **faithfulness 对抗验证 wf** `wf_27596236-5fe`(3 verifier refute-by-default〔fe-core 7 claim / 连接器+test6 6 claim / WHY-comment 链 5 claim〕 + completeness critic;4 agent/256k token)= **18/18 confirmed、0 refuted、0 critic fix**。critic 经 **iceberg 1.10.1 bytecode** 独立证实 test-6 residual 论断:`BaseFilesTable$ManifestReadTask` ctor 把 row filter 作 per-task residual 携带;`planFiles` 的 `ManifestEvaluator.forRowFilter` 仅 prune 读哪些 **manifest 文件**(partition 级),record_count 是 metadata 列非 partition 字段→不 prune 行;`rows()` 仅 `transform`(readable_metrics 投影)不 apply residual→FE 行数不变、BE 应用。WHY-comment 链 C1–C5 全 confirmed(「factually accurate and comprehensively reflects the end-to-end logic」)。 +- **gate 重跑实证**:连接器 543/0/1、fe-core `PluginDrivenSysTableTest` 10/0 + guard 8/0;checkstyle 0、import-gate 0、`CatalogFactory:51` 未改。**T08 = 0 产品码**(汇总设计 doc + 文档)。**= P6.5 DONE**。 +- **下一 = P6.6 翻闸**(全有或全无,须 holistic 修 DV-038〔读路径 field-id BE DCHECK〕/041〔写路径合成列物化 + pin threading〕/045〔rewrite 执行半 R-B〕 + **sys 时间旅行 query→handle pin threading**〔本阶段揭,DV-041 同族〕——四者同需写/读/handle 共享 fe-core seam)。 + +### 2026-06-25(P6.5-T06 thrift 描述符 hms↔iceberg 分叉 + `getScanNodeProperties` sys 收口 + fe-core engine/SHOW-CREATE parity,TDD) + +- **P6.5-T06**(**4 产品 + 2 测 + 1 新测类**;连接器 2 块 dormant + fe-core 2 块 flip 后激活):**8-agent recon workflow** `wf_aefdfdd7-57e` 逐行核 plugin-path 描述符落点 + `getScanNodeProperties` sys 缺口 + `encodeSchemaEvolutionProp` throw-risk + DESCRIBE/SHOW parity + paimon 模板 + BE 消费方 + 测试基建,**纠 HANDOFF 框定 2 处**。 +- **recon 纠 HANDOFF(清 room 交叉核)**:① `buildTableDescriptor` **是连接器级 SPI 钩子**(`ConnectorTableOps:187-192` 默认返 null,fe-core `PluginDrivenExternalTable.toThrift:511-531` 委派→null 回退 `SCHEMA_TABLE`),**非 fe-core 方法**;iceberg 连接器原**无**覆写→base+sys 都退化 SCHEMA_TABLE(P6.1/P6.2 遗留 base 缺口,paimon 在其 sys 工作一并补)。② SHOW CREATE **输出**已由 `Env.getDdlStmt:4915-4927` + `UserAuthentication:60-66` 解包 `PluginDrivenSysExternalTable`→source(recon F 初判误称未解包,自核纠正);唯一残留 = `ShowCreateTableCommand.validate():116` authTableName 未解包(priv check 用 sys 名而非 base 名,**且 paimon 现存同隐患**)。 +- **C1 `buildTableDescriptor`(连接器,[D-066] 复现 fork)**:`IcebergConnectorProperties.TYPE_HMS.equals(properties.get(ICEBERG_CATALOG_TYPE))` → `HIVE_TABLE`+`THiveTable`,否则 → `ICEBERG_TABLE`+`TIcebergTable`(null-safe,缺 type→iceberg)。镜像 legacy `IcebergSysExternalTable.toThrift:116-131`/`IcebergExternalTable.toThrift`(6-arg `TTableDescriptor`〔id,type,numCols,0,name,db〕+ 空 properties map)。SPI 签名无 handle → 单覆写覆盖 base+sys(legacy base/sys 同 fork),仿 paimon。**BE 无感**(recon G:sys JNI 读只看 scan-range `FORMAT_JNI`+`serialized_split`,从不读描述符表类型;`HiveTableDescriptor`/`IcebergTableDescriptor`/`SchemaTableDescriptor` 皆合法不崩)→ 纯 FE parity + 闭合 base 缺口。 +- **C2 `getScanNodeProperties` sys 收口([D-065])**:方法顶 `boolean systemTable = iceHandle.isSystemTable()`;`if (!systemTable)` 包 ① `path_partition_keys` 块 ② `schema_evolution` dict 块。sys handle → **跳两者**,保 `file_format_type=jni` + `location.*` 凭据(BE 读元数据文件仍需凭据,仿 paimon)。**修潜伏崩溃**:无 guard 时 unpinned sys SELECT → `encodeSchemaEvolutionProp(base, baseSchema, metaCols)` → `IcebergSchemaUtils.buildCurrentSchema:194` 抛「requested column not found」;pinned sys → 静默建错 base dict。iceberg 因 `resolveTable` 取 **base** 表故须**显式** guard(paimon type-driven 隐式跳无法照搬)。 +- **F1 fe-core engine-name(用户签字)**:`PluginDrivenExternalTable.getEngine/getEngineTableTypeName` switch `getType()` 加 `case "iceberg"` → `ICEBERG_EXTERNAL_TABLE.toEngineName()`(="iceberg")/`.name()`。flip 后 base/sys iceberg 表显示 engine "iceberg"(非 "Plugin")。**paimon 安全**(仅 iceberg-typed catalog 命中)。 +- **F2 fe-core SHOW CREATE authTableName 解包(用户签字)**:`ShowCreateTableCommand.validate():116` ternary 改 if-chain,加 `else if instanceof PluginDrivenSysExternalTable → getSourceTable().getName()`(镜像既有 `IcebergSysExternalTable` 分支 + `UserAuthentication`/`Env`)。**含 live paimon 行为变更**(修其潜伏 priv 过严:sys 表 SHOW CREATE 现按 base 表授权,严格更宽松同向,破坏风险近零)。**⚠️ 无隔离 UT**(`validate()` 依赖 `Env`/`ConnectContext`/`AccessManager` 全局单例,仓内无既有 harness)→ 编译 + 与 live `UserAuthentication`/`Env` 解包一致性 + P6.8 e2e 兜底。 +- **TDD**:C1 新测类 `IcebergBuildTableDescriptorTest`〔3:hms→HIVE / rest→ICEBERG / 缺 type→ICEBERG〕+ C2 2 测加 `IcebergScanPlanProviderTest`〔unpinned-sys 跳 dict+ppk **不抛** / pinned-sys 跳 dict〕+ F1 2 测加 `PluginDrivenExternalTableEngineTest`〔engine "iceberg" / type `ICEBERG_EXTERNAL_TABLE`〕→ RED〔C1 null/NPE;C2 unpinned 抛 Runtime(潜伏崩溃)+ pinned dict-present;F1 "Plugin"/"PLUGIN_EXTERNAL_TABLE"〕→ GREEN。**F2 无 UT**(见上)。 +- **mutation-check(Rule 9/12,2 run,每次 `cp` 复原→diff IDENTICAL)**:**A** fork 判别 `TYPE_HMS`→`TYPE_REST` 调换 → hms 测期望 HIVE 红 + rest 测期望 ICEBERG 红(fork 判别 pinned);**B** dict guard `if(!systemTable)`→`if(true)` → unpinned 抛红 + pinned dict-present 红(dict guard pinned);**C** ppk guard `if(!systemTable)`→`if(true)`(**保** dict guard)→ unpinned 达 `assertFalse(path_partition_keys)` 红〔期望 false 实 true,**非抛**〕(ppk-skip 独立 pinned,治 HANDOFF 坑①「assertFalse guard 测 trivially-pass」)。 +- **验证(重跑 surefire 实证,Rule 12)**:连接器全量 **532/0/1**(= 527 基线 + 5;`IcebergBuildTableDescriptorTest` 3/0/0、`IcebergScanPlanProviderTest` 63/0/0);fe-core `PluginDrivenExternalTableEngineTest` **14/0/0**(12+2,含 F2 `ShowCreateTableCommand` 编译);checkstyle 0;import-gate exit 0(`org.apache.doris.thrift.*` 允许);`CatalogFactory:51` 未改 → iceberg 仍**不在** `SPI_READY_TYPES`。**新 1 D([D-066]);无新 DV**(descriptor fork 复现 + engine/show-create 现修皆消除偏差)。**消解 T07 预登记 3 项**(thrift 分叉 / engine name / SHOW CREATE 解包);**残留 T07 DV**:sys 表内部 `TableType.PLUGIN_EXTERNAL_TABLE`(user-visible engine/descriptor 已 parity)/ position_deletes 文案 / serialized 字节潜伏(T05)。**live-e2e 未跑**(dormant 连接器 + fe-core 路 flip 后激活,P6.8 兜底)。下一 = T07(parity 审计 + DV 中央登记 + 对抗 parity wf)。 + +### 2026-06-24(P6.5-T05 `IcebergScanPlanProvider`/`IcebergScanRange` sys split 路,TDD) + +- **P6.5-T05**(sys split 发射,**2 产品文件 dormant** + 同两测试类 +6 测;P6.5 唯一全新一块——连接器已有 FORMAT_JNI 默认 + `buildScan` time-travel,缺 serialized-split 发射):**起步 7-agent recon workflow** `wf_c219ede1-8b6` 逐行核 legacy 字节形状 + 连接器埋钩 + thrift 字段 + BE 消费方 + paimon 模板 + 测试基建,**纠设计 1 处**——recon agent 误称 legacy sys 表「无 time-travel」,直读 `IcebergScanNode.createTableScan():569,579-583` 实证 legacy 对 sys 表同走 `createTableScan`〔`useRef(info.getRef())`/`useSnapshot(info.getSnapshotId())`〕→ sys 表**确** honor 时间旅行(偏差①正确)。 +- **byte-shape 契约(legacy parity 目标,recon 实证)**:sys split = `serialized_split`〔base64 `SerializationUtil.serializeToBase64(FileScanTask)`,iceberg 1.10.1〕**唯一**载荷 + `FORMAT_JNI` + `table_level_row_count=-1` + `table_format_type=iceberg`〔dummy path `/dummyPath`,无 file-level 字段〕。BE `iceberg_sys_table_jni_reader.cpp:37-42` 校验 `serialized_split` 非空否则 InternalError;`IcebergSysTableJniScanner:62` `deserializeFromBase64` → `:87` `asDataTask().rows()`。 +- **产品([D-065])**:① `IcebergScanRange` 加 `serializedSplit` 字段(非 transient String,默认 null)+ `Builder.serializedSplit(...)` + `getSerializedSplit()`;`populateRangeParams` 顶 sys 分支 `if(serializedSplit!=null)` 镜像 legacy `setIcebergParams` 早返〔**仅** `setFormatType(FORMAT_JNI)`〔`:290`〕+`setTableLevelRowCount(-1)`〔`:291`〕+`setSerializedSplit`〔`:292`〕+`setIcebergParams`,`return`;normal range 走原路字节不变〕。② `IcebergScanPlanProvider` `planScanInternal` 顶 sys guard〔在 count-pushdown 之前——sys 表无 snapshot-summary count〕→ 新 `planSystemTableScan` = `resolveSysTable(handle)`〔元数据表〕 → **复用** `buildScan(metaTable, handle, filter, session)`〔time-travel useRef/useSnapshot + 谓词,镜像 legacy `createTableScan`〕 → `scan.planFiles()` → 每 `FileScanTask` 经 `SerializationUtil.serializeToBase64(task)` 装进 `IcebergScanRange`〔`/dummyPath` + serializedSplit〕;新 `resolveSysTable` = `executeAuthenticated` 内 `MetadataTableUtils.createMetadataTableInstance(catalogOps.loadTable(base), MetadataTableType.from(sysName))`〔base 加载 + meta 构建同一 auth scope,镜像 T04 `loadSysTable`〕。imports 仅加 `MetadataTableType`/`MetadataTableUtils`/`util.SerializationUtil`(SDK 允许)。 +- **两裁定 [D-065]**:① T05 仅 scan split 路;`getScanNodeProperties` 对 sys handle 的 `path_partition_keys`/`schema_evolution` dict(现从 base 表构建、对 sys 不正确但 dormant)**推迟 T06**(设计 §10 把 thrift 描述符/DESCRIBE/SHOW parity 归 T06)。② `populateRangeParams` sys 分支**显式** `setFormatType(FORMAT_JNI)`(不依赖 generic node `jni` 默认)= 忠实 legacy `:290` + 可单测。 +- **关键发现(非 DV,legacy parity)**:`$snapshots`/`$history` **忽略** `useSnapshot`(恒列当前 metadata 全量快照;legacy 同被 `SnapshotsTable` 忽略,parity 保留);**`$files`** 才是时间旅行可观测表(列 pinned 快照 live 文件)→ time-travel UT 用 `$files`(S1=1 文件、latest=2)。 +- **TDD**:先加 carrier API stub(field/builder/getter 使测可编译)+ 6 UT〔carrier 2:normal-range 不发 serialized_split〔guard〕/ sys minimal-shape〔FORMAT_JNI+serialized_split+其余 unset〕;provider 4:serializes-each-task / **deserialize-round-trip 经 BE 路**〔`SerializationUtil.deserializeFromBase64(...).asDataTask().rows()` 镜像 `IcebergSysTableJniScanner` = 最强 FE-可达 byte-shape parity 核〕/ time-travel-honors-pin〔`$files` 1 vs 2〕/ loads-inside-auth-scope〕→ RED〔4 真红 + 2 guard 共享路 trivially-pass〕→ 实现 GREEN。 +- **mutation-check(Rule 9/12,dormant 路 4 处不相交变异,每次 `cp` 复原→diff IDENTICAL)**:**A** 去 `resolveSysTable` auth → `LoadsMetadataInsideTheAuthScope`〔authCount 1→0〕红〔证 auth-scope guard 由 sys 分支变 mutation-detecting,纠 RED 阶段 trivially-pass〕;**B** `planSystemTableScan` 用 `metadataTable.newScan()` 替 `buildScan`〔丢 pin〕→ `HonorsTheSnapshotPin`〔`$files` pinned 1→2〕红;**C** 删 sys 分支 `setFormatType(FORMAT_JNI)` → carrier `EmitsSerializedSplitAndJniFormatOnly`〔FORMAT_JNI→null〕红;**D** 删 sys 分支早 `return`〔fall-through 设 formatVersion〕→ carrier `isSetFormatVersion`〔false→true〕红。 +- **验证(重跑 surefire 实证,Rule 12)**:`IcebergScanRangeTest` **19/0/0**(17+2)、`IcebergScanPlanProviderTest` **61/0/0**(57+4);连接器全量 **527/0/1**(40 类,= 521 基线 + 6,python 聚合 XML);checkstyle 0;import-gate exit 0(SDK `util.SerializationUtil`/`MetadataTableUtils` 允许);`CatalogFactory:51` 未改 → iceberg 仍**不在** `SPI_READY_TYPES`。**新 1 D([D-065]);无新 DV**(time-travel/全 JNI/byte-shape 皆 legacy parity;DV 登记延后 T07)。**⚠️ 潜伏(Rule 12,勿在 dormant 码 claim parity done)**:serialized `FileScanTask` 字节须兼容 BE `IcebergSysTableJniScanner`——FE deserialize-round-trip UT 已在同进程 iceberg 1.10.1 核「可消费 + asDataTask + 元数据表 schema」,但跨版本/classloader interop 不可及,P6.8 docker e2e 兜底;T07 预登记此潜伏 DV。**live-e2e 未跑**(dormant,P6.8 兜底)。**dormant 边界**:pre-flip 表仍是 `IcebergExternalTable`→此 sys split 路无调用方(legacy `IcebergScanNode` sys 路仍承接 live)→ 零行为变更。下一 = T06(thrift hms↔iceberg 分叉核 `buildTableDescriptor` for sys handle〔偏差⑥〕+ DESCRIBE/SHOW parity + `getScanNodeProperties` sys 收口〔[D-065] 推迟项〕)。 + +### 2026-06-24(P6.5-T04 `IcebergConnectorMetadata.getTableSchema` + `getColumnHandles` sys 分支,TDD) + +- **P6.5-T04**(`getTableSchema`/`getColumnHandles` sys 分支,**单文件产品 dormant** + 同测试类加 7 测):sys handle 的 schema/列从 iceberg **metadata 表**(`t$snapshots` → `committed_at/snapshot_id/...`)来,非 base 表。新 helper `loadSysTable` = `context.executeAuthenticated` 内 `catalogOps.loadTable(base)` + `MetadataTableUtils.createMetadataTableInstance(base, MetadataTableType.from(sysName))`〔base 加载 + meta 构建同一 auth scope,Kerberos UGI 覆盖远程 base 加载;镜像 legacy `IcebergSysExternalTable.getSysIcebergTable`;`from` 大小写不敏感、名已 `getSysTableHandle` 验证→永不 null;唯一新 import `org.apache.iceberg.MetadataTableUtils`〕(决策 B 无新 seam,偏差③)。3 处 sys 分支([D-064]):①2-arg `getTableSchema`→`buildTableSchema(meta, meta.schema())`〔复用既有 `parseSchema` 自动透 `enable.mapping.varbinary/timestamp_tz`,**已核实** `parseSchema` 从 `properties` 读,偏差⑤〕;②3-arg `getTableSchema(@snapshot)` sys 短路〔meta-table schema 固定、与快照无关,legacy 无 schema-at-snapshot for sys;时间旅行 pin〔偏差①〕选 SCAN 行非 schema,落 T05〕;③`getColumnHandles` sys 分支〔与 getTableSchema 同潜伏 bug,sys handle 必返 meta-table 列供通用 `PluginDrivenScanNode.buildColumnHandles` 按名解析;共享 helper〕。**SDK = iceberg 1.10.1**(`fe/pom.xml:348`)。 +- **测试基建**(recon 实证):`createMetadataTableInstance(base, type)` 需 `base` 是 `HasTableOperations`(真 `BaseTable`)——`FakeIcebergTable` **不是**(仅 `implements Table`)→ 会抛;故 base = 真 `InMemoryCatalog` 表(`new InMemoryCatalog().createTable(...)` 返 `BaseTable`),经 `RecordingIcebergCatalogOps.table` 注入 seam〔base 列 `id/name` 故意 ≠ 任何 meta 列〕;空表足够读 meta-table `.schema()`(静态)。**TDD**:7 UT 先 RED〔5 真红:current 产品对 sys handle 返 base 列 `[id,name]`;2 auth-scope guard 对共享 auth 路 trivially-pass〕→ GREEN → **mutation-check**〔A 去 `loadSysTable` auth 包裹→`LoadsBaseInsideAuthScope`〔authCount〕+`RunsInsideAuthenticator`〔failAuth log〕双红;B load 用 `getSysTableName()` 替 `getTableName()`→`LoadsBaseInsideAuthScope`〔base-coords name〕红;C 硬编码 `MetadataTableType.SNAPSHOTS`→`UsesSysNameTypeNotHardcoded`〔history〕红、snapshots 绿;每次 `cp` 复原 diff IDENTICAL〕。 +- **验证(重跑 surefire 实证,Rule 12)**:`IcebergConnectorMetadataSysTableTest` **18/0/0**(11 T03 + 7 T04);连接器全量 **521/0/1**(40 类,= 514 基线 + 7,python 聚合 XML);checkstyle 0;import-gate exit 0(SDK `MetadataTableUtils` 允许);`CatalogFactory:51` 未改 → iceberg 仍**不在** `SPI_READY_TYPES`。**新 1 D([D-064]);无新 DV**(meta-table schema 不随快照变 = legacy parity;getColumnHandles 返 meta 列 = 正确性修,非 pre-flip 行为偏差;DV 登记延后 T07)。**live-e2e 未跑**(dormant,P6.8 兜底)。**dormant 边界**:pre-flip 表仍是 `IcebergExternalTable`→此 3 sys 分支无调用方(legacy sys 路仍承接 live)→ 零行为变更。下一 = T05(`IcebergScanPlanProvider`/`IcebergScanRange` sys split 路 + FORMAT_JNI + serialized split + time-travel,偏差①②)。 + +### 2026-06-24(P6.5-T03 `IcebergConnectorMetadata.listSupportedSysTables` + `getSysTableHandle`,TDD) + +- **改动 = 单文件 `IcebergConnectorMetadata.java`(连接器,dormant)+ 新 UT 类 `IcebergConnectorMetadataSysTableTest`(11 测)**。镜像 paimon `PaimonConnectorMetadata:322-408`(`listSupportedSysTables`/`getSysTableHandle`/`isSupportedSysTable`),两处 iceberg 偏差:保留 pin(偏差①)+ LAZY 解析。 +- **`listSupportedSysTables`** = `MetadataTableType.values()` 去 `POSITION_DELETES` → 小写名 + `Collections.unmodifiableList`(连接器-global,忽略 base handle)。镜像 legacy `IcebergSysTable.SUPPORTED_SYS_TABLES` 同 formula;SDK 1.6.1 = 15 名(16 enum − position_deletes)。**`getSysTableHandle`** = `isSupportedSysTable` guard〔null/unknown/`position_deletes`→`Optional.empty`,Q2〕→ 小写 → `IcebergTableHandle.forSystemTable(...)` **保留 snapshot pin**(偏差①)。imports 仅加 `MetadataTableType` + `Collections`(**无** `MetadataTableUtils`——移 T04)。 +- **决策 [D-063]:`getSysTableHandle` LAZY(纯解析、零 catalog 往返)**——设计 §5/§8+HANDOFF 倾向 eager(T03 build metadata-table + seam-identity),但三事实裁 LAZY:①legacy `getSysIcebergTable():83-97` 懒构、resolution 不加载;②iceberg handle 无 transient SDK Table(≠ paimon)→ eager build 被丢弃 + 多一次 legacy 没有的远程往返(性能回归);③fe-core `resolveConnectorTableHandle` 先 `getTableHandle` 预检 base 存在性。HANDOFF 明授权「懒 vs eager 由实现 recon 定」→ 裁 LAZY,**无需再问用户**;metadata-table build + seam-identity UT 移 T04(legacy 真 build 点,parity 更忠实)。决策 B(无新 seam)不变,仅落点 T03→T04。 +- **TDD**:先 11 UT(RED:6 真红 + 5 guard 负例对空 SPI default trivially-pass)→ 实现(GREEN)→ **mutation-check**(3 不相交变异一次跑出恰 3 红:清 pin→`RetainsSnapshotPin` / 不跳 position_deletes→`EmptyForPositionDeletes` / 去 unmodifiable→`IsUnmodifiable`,其余 8 绿 → 证 guard 非空跑;变异后 `cp` 复原 diff IDENTICAL)。 +- **验证(重跑 surefire 实证,Rule 12)**:`IcebergConnectorMetadataSysTableTest` **11/0/0**;连接器全量 **514/0/1**(40 类,= 503 基线 + 11,python 聚合 XML);checkstyle 0;import-gate exit 0;`CatalogFactory:51` 未改 → iceberg 仍**不在** `SPI_READY_TYPES`。**新 1 D([D-063]);无新 DV**(lazy 比 eager 更贴 legacy,非 pre-flip 行为偏差;DV 登记延后 T07)。**live-e2e 未跑**(dormant,P6.8 兜底)。**dormant 边界**:pre-flip 表仍是 `IcebergExternalTable`→此 2 override 无调用方(legacy sys 路仍承接 live)→ 零行为变更。下一 = T04(`getTableSchema` sys 分支 + `MetadataTableUtils` 构建〔决策 B〕+ seam-identity UT〔从 T03 移入〕)。 + +### 2026-06-24(P6.5-T02 `IcebergTableHandle` sys 变体,TDD) + +- **进 T02 前用户二次签字(AskUserQuestion)**:决策 A = `forSystemTable` **保留** snapshot/ref/schemaId pin(≠ paimon 清零,偏差①——iceberg sys 表合法时间旅行 `t$snapshots FOR VERSION AS OF`);决策 B = **无新 seam**(T03 复用 `loadTable` + 连接器内 `MetadataTableUtils`,净 0 新 SPI)——均选推荐方向。 +- **改动 = 单文件 `IcebergTableHandle.java`(连接器,dormant)+ 其 UT**。镜像 `PaimonTableHandle.forSystemTable`/`isSystemTable`/`getSysTableName`,**但 snapshot pin 与 sys 共存而非清零**(偏差①):加 `private final String sysTableName`(**非 transient**,小写 bare 名,`null`=普通表)+ `forSystemTable(db,table,sysName, long snapshotId,String ref,long schemaId)`〔保留 pin〕+ `isSystemTable()`/`getSysTableName()`;`equals`/`hashCode`/`toString` 纳入 `sysTableName`(既有 snapshot 字段已在身份内→`t$snapshots@v1`≠`@v2`≠`t`);`withSnapshot` **保留** `sysTableName`(copy 工厂不退化 sys→普通,镜像 paimon `withScanOptions`/`withBranch`)。 +- **设计偏差修正(Rule 7/12,对照实码)**:设计 §4 工厂签名写 boxed `Long/Integer`,实码字段/getter 是 primitive `long`(`NO_PIN=-1L` sentinel)→ 实现用 `long` 对齐既有风格(conformance,非方向变更)。 +- **TDD**:9 UT 先 RED(test-compile `cannot find symbol forSystemTable/isSystemTable/getSysTableName`,证缺特性非笔误)→ 实现 GREEN → **mutation-check**(坑:`forSystemTable` 清 pin→`IcebergTableHandleTest` 4 红〔`forSystemTableRetainsSnapshotPin`/`RetainsRefPin`/`sysHandleAtDifferentVersionsAreDifferent`/`SurvivesJavaSerializationRoundTrip`〕→复绿,证测真 pin 偏差① 不变式)。 +- **验证(重跑 surefire 实证,非凭 `@Test` 计数,Rule 12)**:`IcebergTableHandleTest` **14/0/0**(5 旧 + 9 新,方法名核 XML);连接器全量 **503/0/1**(39 类,=494 基线 + 9);checkstyle 0;import-gate exit 0;`CatalogFactory` 未改 → iceberg 仍**不在** `SPI_READY_TYPES`〔`:51`〕。**无新 D / DV**(DV 登记延后 T07;偏差① 是 parity-保留的内部设计选择,legacy `IcebergSysExternalTable` 同 honor 时间旅行,非 pre-flip 行为偏差)。**dormant 边界**:iceberg 表 pre-flip 仍是 `IcebergExternalTable`,此 sys 变体无调用方(T03 起接线)→ 零行为变更。下一 = T03(`listSupportedSysTables`+`getSysTableHandle`:`isSupportedSysTable` guard + `executeAuthenticated` 内 `MetadataTableUtils` 构建〔决策 B〕+ position_deletes 不上报 + seam-identity UT)。 + +### 2026-06-24(P6.5-T01 recon+设计+用户二签字 ⇒ P6.5 启动) + +- **P6.5-T01**(recon + 设计 + 用户二签字,**0 产品码**):**P6.5 = 仅系统表**(`$snapshots/$history/$files/$manifests/$partitions/...`,= `MetadataTableType.values()` 去 `position_deletes`);**镜像 P5-paimon B4**(连接器 2 override `listSupportedSysTables`/`getSysTableHandle` + fe-core 通用 `PluginDrivenSysExternalTable`,[D-039]/[DV-023],**净 0 新 SPI**、**fe-core 零改动**)。recon = 对抗 workflow `wf_bf813782-b4b`(4 Explore reader + synthesize + completeness critic,6 agent/1130s)+ 主 session 独立读码核对;critic 5 follow-up 全解决(test infra `RecordingIcebergCatalogOps` 已存在 / seam-identity 不变式 / scan-plane 可行 / DESCRIBE 走通用 / 类型变更 correction)。**用户二签字**:Q1=仅系统表(元数据列 `IcebergMetadataColumn`/`IcebergRowId` 推迟 P6.6 写路径 DV-041 同族——挂 nereids `instanceof IcebergExternalTable` 钩子、不受 `SPI_READY_TYPES` 控制、flip 后失效,无 paimon 模板);Q2=position_deletes 不上报(→通用 not-found,fe-core 零改动)。**5 偏差不能照抄 paimon**:①时间旅行(sys handle 保留 snapshot pin,**勿**抄 paimon MVCC-排除)②全 JNI(**勿**抄 paimon binlog/audit_log-only)③SDK `MetadataTableUtils` 构建(无 4-arg Identifier,无新 seam)④position_deletes 不上报⑤schema mapping flag 透传⑥thrift hms 分叉 + 类型变更 `ICEBERG_EXTERNAL_TABLE→PLUGIN_EXTERNAL_TABLE`。产出 `designs/P6.5-T01-systable-design.md`(11 节)+ `research/p6.5-iceberg-systable-recon.md`(8 节)。0 产品码→iceberg 仍**不在** `SPI_READY_TYPES`。下一 = T02 `IcebergTableHandle` sys 变体(**待用户批准进 T02 + 确认 §4 保留 snapshot pin / §5 无新 seam**)。 + +### 2026-06-24(P6.4-T01 recon+设计+三签字 / T02 SPI 骨架 / T03 base+factory+dispatch / T04 8 pure-SDK 体 / T05 rewrite 规划半 / T06 rewrite 事务半 / T07 dispatch rewire / T08 parity-UT 审计+gap-fill+DV 中央登记 / **T09 收口+faithfulness 对抗验证 ⇒ P6.4 DONE**) + +- **T01**(recon + 设计 + 用户三签字 [D-062],0 产品码):recon `wf_cb757c7c-708`(10 reader + 对抗 completeness critic,3 源码核实更正);新 `research/p6.4-iceberg-procedures-recon.md` + `designs/P6.4-T01-procedure-spi-design.md`。**关键认知**:①Doris `ALTER TABLE EXECUTE` 唯对应 Trino `TableProcedureMetadata`(非 CALL/MethodHandle)→ 保扁平 `ExecuteAction` 模型;②9 action 二分 = 8 pure-SDK(机械可移)+ 1 `rewrite_data_files`(分布式 INSERT-SELECT 写,执行半留 fe-core);③dormant-pre-flip(镜像 P6.3 写)。**三签字**:Q1=R-A 分相位、Q2=S-1 扁平 `execute()`、§4=4-A 连接器自包含 arg 校验(import-gate 禁 `org.apache.doris.common.NamedArguments`)。 +- **T02**(SPI 骨架,dormant):新 `connector.api.procedure.{ConnectorProcedureOps,ConnectorProcedureResult}`(S-1 扁平 + 复用 `ConnectorColumn` 中立列型,0 新结果型)+ `Connector.getProcedureOps()` default-null(证 jdbc/es/mc/paimon/trino 继承 no-op)+ `IcebergProcedureOps` dormant 占位(镜像 `IcebergWritePlanProvider` 三元组,两方法 throw 直到 T03/T04)+ `IcebergConnector.getProcedureOps()` override。connector-api `ConnectorProcedureOpsDefaultsTest` 3/0 + 全模块 37/0;iceberg 389/0/1;checkstyle 0;import-gate 0;iceberg 仍**不在** `SPI_READY_TYPES`;0 BE/fe-core/pom 改。下一 = T03 port base/factory。 +- **T03**(base+factory + dispatch 骨架,dormant):`connector.iceberg.action.{BaseIcebergAction, IcebergExecuteActionFactory}`(去死 `table` 参;base 折入 `BaseExecuteAction` 被消费机器,SPI 中立型,`validate` 无 priv,单行包装+宽度 `checkState`,去 `getDescription`)+ `IcebergProcedureOps` dispatch 骨架(`getSupportedProcedures` + `runInAuthScope`:load+body+commit 同一 `executeAuthenticated`);arg 框架 `NamedArguments`/`ArgumentParsers`/`ArgumentParser` **移 `fe-foundation` 共享**(引擎+连接器一份)。iceberg **401/0/1**,faithfulness wf 4→0 confirmed;0 BE/fe-core/pom。 +- **T04**(港 8 pure-SDK procedure 体 + `RewriteManifestExecutor`,dormant):`Iceberg{RollbackToSnapshot,RollbackToTimestamp,SetCurrentSnapshot,CherrypickSnapshot,FastForward,ExpireSnapshots,PublishChanges,RewriteManifests}Action` 各 `extends BaseIcebergAction` 接 `createAction` 8 case(`rewrite_data_files`=T05/T06 留 default-throw)。body = legacy 去 fe-core import + 5 机械换型(SDK `Table` 直用 / cache 失效搬 dispatch / `UserException`→`DorisConnectorException` message 字节同 / `Column`→`ConnectorColumn`〔**更正:第 3 参 `isAllowNull` 非 isKey** ⇒ `fast_forward.previous_ref` 唯一 NULLABLE〕 / 去 `getDescription`);逐字 bug-for-bug(publish STRING+`"null"`、fast_forward 无-guard+trim 只输出、cherrypick 泛化 not-found、rollback not-found try 外〔不 wrap〕vs set/cherry try 内〔wrap〕、expire 6×BIGINT+双 wrap+`systemDefault` zone+bulk warn-skip+finally shutdown、rewrite 双 wrap+空表短路)。**🔧 必须改签名**(更正 HANDOFF「无须改签名」):`rollback_to_timestamp` 需会话 TZ ⇒ `BaseIcebergAction.execute/executeAction` 加 `ConnectorSession`(7 个非 TZ body 忽略;SPI/factory 签名不动)+ 新 `IcebergTimeUtils.msTimeStringToLong`(ms 格式 + alias-map + `-1` sentinel,**非** `datetimeToMillis` 的 ss 格式)+ `resolveSessionZone` 提 public。cache 失效 = dispatch 级 `context.getMetaInvalidator().invalidateTable`(无条件含短路 = 幂等微差)。**faithfulness 对抗 `wl33dyokd`/`wf_973bd34f`**(11 finder + refute-by-default skeptic + critic)= **1 raw→0 confirmed/1 refuted+0 critic gaps**(refuted=`resolveSessionZone` null-session 回落 NIT,EXECUTE 路不可达 + P6.2-T07 既有件)。8 新测类 + 扩 `IcebergProcedureOpsTest`(auth-scope/dispatch invalidate/会话 TZ 透传/failAuth 不失效)+ `ActionTestTables` + `RecordingConnectorContext` recording invalidator。iceberg **444/0/1**(401→444)、checkstyle 0、import-gate 0、iceberg 仍**不在** `SPI_READY_TYPES`、**0 BE/fe-core/pom 改**。auth 补 + cache 搬家 + 短路多失效 + `executeAction` 加参 = pre-flip 行为偏差 → T08 批量 DV。下一 = T05 `rewrite_data_files` 规划半。 +- **T05**(`rewrite_data_files` 规划半 → 新包 `connector.iceberg.rewrite`,dormant):3 类港——`RewriteResult`/`RewriteDataGroup` 逐字 POJO(仅 package 改);`RewriteDataFilePlanner` = bin-pack/分区分组/file+group filter 逻辑逐字保真 + **3 处有意换型**(`UserException`→unchecked `DorisConnectorException` 串字节同 / nereids `Optional`→中立 `ConnectorPredicate` / WHERE 转换 `IcebergNereidsUtils`→`IcebergPredicateConverter` **conflict-mode** + 线程 `ZoneId`,每合取独立 `scan.filter`)+ bug-for-bug 保留死 `outputSpecId` 参。执行半(`RewriteDataFileExecutor`/`RewriteGroupTask`/nereids INSERT-SELECT)+ 事务半 + bind = T06。**🟡 DV-T05r-where(用户签字 Option A,T08 批量登记)**:conflict-mode 通路对 legacy `IcebergNereidsUtils` 两处有意发散——不可转节点**静默丢**(legacy **抛**)→ rewrite 变宽=重写比 WHERE 多的文件(极端=全表),不报错;conflict-matrix 收窄跨列 OR/非-IsNull NOT/NE。**关键认知**:设计 §5「safe over-approximation」对扫描下推成立(BE 残差再过滤)但对 **rewrite 不成立**(planner `scan.filter()` 直接即重写集),与 O5-2「变宽=更保守」安全性反号;常见 WHERE 零差异,仅罕见 WHERE 触发。**faithfulness 对抗 `wf_40ae73fd-3ef`**(5 finder + 每发现 refute-by-default skeptic + completeness critic)= **8 raw → 0 confirmed / 8 refuted + 0 critic gaps**(8 全 test-coverage 观察非行为发散;最强 delete-filter 覆盖 legacy 有·港丢已**当场补**真 v2 `newRowDelta` equality/position delete fixture)。3 新测类 23 测〔planner 17:分组/bin-pack/分区/file+group filter 三 OR-arm 隔离/边界 ==/WHERE 裁剪/跨列 OR 过宽=DV/BETWEEN conflict-mode 钉 Option A/delete 阈值·比率门;RewriteResult 4;RewriteDataGroup 2〕。iceberg **467/0/1**(444→467)、checkstyle 0、import-gate 0、iceberg 仍**不在** `SPI_READY_TYPES`、**0 BE/fe-core/pom 改**。下一 = T06 写路径耦合长杆。 +- **T06**(`rewrite_data_files` 写路径耦合长杆;**5-reader recon → 用户裁 Option 1 = ① 事务半 now + ②③④ R-B**,dormant):**① 事务半(已做)**=`IcebergConnectorTransaction` 加 `WriteOperation.REWRITE` 变体——新枚举值(api,6 项,guard 同步)+ `filesToDelete`/`filesToAdd`/`startingSnapshotId(-1L)` 状态 + `applyBeginGuards` REWRITE 分支(捕获 `startingSnapshotId`,无 branch/`baseSnapshotId`,不走 fmt≥2/branch-resolution)+ `updateRewriteFiles(List)`(synchronized 累积,package-visible)+ `commit()` 折 legacy `finishRewrite`→`buildPendingOperation` 加 `case REWRITE: commitRewriteTxn()`(`convertCommitDataToFilesToAdd` 复用 INSERT `convertToWriterResult` → 空-skip → `newRewrite().validateFromSnapshot(startingSnapshotId).deleteFile(old)·addFile(new).commit()`,裹既有 `executeAuthenticated`)+ count/size 访问器。**净 0 新事务 verb**(commit-fragment 通道 P6.3 已统一)。**🔴 recon 证伪设计 §5 / D-062 R-A 前提**:「连接器从 pinned snapshot+WHERE 重规划」不可行——连接器 scan SPI 只能 snapshot/谓词/分区收窄、表达不了 bin-pack 文件子集 → **over-scan→破正确性**;`FileScanTask` 侧信道翻闸后 `PluginDrivenScanNode` 端到端死;SPI 模块边界 fe-core 够不到连接器 `RewriteDataGroup`(裹 iceberg SDK);multi-sink-per-txn 生命周期须重设计。⇒ **②③④(执行半↔规划接线 + 文件级扫描范围〔须新中立 SPI〕 + bind 改 `UnboundConnectorTableSink` + `instanceof IcebergRewriteExecutor`/`PhysicalIcebergTableSink` + `(IcebergTransaction)` 下转〔→通用 `PluginDrivenTransactionManager`〕)= R-B 推后专门写路径 RFC + 翻闸阻塞 DV-T06r-rb**(D-062「超预算→R-B」预设被实证触发,用户签字)。**🟡 mutation-check 实证(Rule 12)**:注掉 `validateFromSnapshot` 单跑并发-delete OCC 测仍 GREEN(冲突由 iceberg 固有从 txn 基快照校验抛,非显式行隔离)→ 该测验「rewrite 冲突 fail-loud」不 pin 显式 OCC 行〔已诚实修正测试名+注释,不 overclaim;显式行忠实 legacy 港,跨-refresh 价值=P6.6 docker 门〕。**faithfulness 对抗 `wf_2efb10dc-1a2`**(5 finder:commit-op/begin/accessors/tests/side-effects + refute-by-default skeptic + critic)= **4 raw → 0 confirmed**;critic 2 scope-确认(非 bug,T08 登记):DV-T06r-zone(rewrite-added 文件分区值经 session-TZ 解析=既有 DV-T04-f 路新触发,benign)/DV-T06r-rollback(`rollback()` 不清 rewrite 列表,单 txn/语句生命周期下中性);另 DV-T06r-scanpool(丢 `scanManifestsWith`,perf-only,对齐 append 路)。8 新测(snapshot-id 捕获〔含 -1L 哨兵 + baseSnapshotId 仍 null〕/replace 快照 delete=2·add=1/两冲突 fail-loud/空-skip/count·size 访问器/累积)。iceberg **475/0/1**(467→475)、api 37/0、checkstyle 0、import-gate 0、iceberg 仍**不在** `SPI_READY_TYPES`、**0 BE/fe-core/pom 改**(仅 api enum + iceberg 事务 + 两测)。下一 = T07 dispatch rewire。 +- **T07**(dispatch rewire:EXECUTE → `getProcedureOps()`,**纯 fe-core**·dormant·0 连接器/BE/pom/CatalogFactory):新 fe-core adapter `ConnectorExecuteAction implements ExecuteAction`(`nereids/.../commands/execute/`)+ `ExecuteActionFactory` 加 `instanceof PluginDrivenExternalTable` 分支〔`createAction` 返 adapter、`getSupportedActions` 通用 overload→`getProcedureOps().getSupportedProcedures()`〕保 legacy `IcebergExternalTable` 分支(P6.7 删)。**adapter 而非 inline**:`createAction` 返 `ExecuteAction` vs 连接器返 `ConnectorProcedureResult` 阻抗不匹配 ⇒ adapter 经正常 `ExecuteActionCommand.run()` 流(**run() 100% 不变=legacy 结构性 byte-parity**)复用 logRefreshTable+sendResultSet。**engine/connector 分工(D-062 §2)**:engine 保 `validate()` priv(逐字复刻 `BaseExecuteAction` priv 块·无 namedArguments)+ `wrapResult`(`ConnectorColumnConverter` + 宽度 `checkState` + 空 schema/空 rows→null);connector 保 arg+body+commit(auth)+cache。priv 严格在连接器交互前。**异常**:`DorisConnectorException`(unchecked)→ catch → **plain `UserException`**(非 `DdlException`——legacy body 抛 plain UserException,`getMessage` 同 formatting)→ run() 加 "Failed to execute action:" 前缀字节同;table-not-found→`AnalysisException`(镜像 `visitPhysicalConnectorTableSink:664`);getProcedureOps null→`DdlException`。**dispatch 链镜像 `visitPhysicalConnectorTableSink:636-667`**(catalog→connector→session→metadata→handle→execute),partition 透传。**WHERE 拒(DV-T07-where,fail-loud)**:lowering 推后 R-B;唯一吃 WHERE 的 rewrite 不走此派发;8 pure-SDK 本就拒。**`getSupportedActions` 通用 overload**=pathfinder(grep 实证无 live caller)。**TDD 13 测**(RED:缺类编译失败 + `DdlException.getMessage` 加 errCode→改 plain `UserException`+`getDetailMessage`)。**faithfulness 对抗 `wf_c8256474-c32`**(5 finder:engine/connector-split·legacy-unchanged·exception-parity·result-wrapping·dispatch-completeness + refute-by-default skeptic + completeness critic)= **5 finder 全 0 finding**;critic 6 类(自评全非 dormant blocker)→ **2 当场修**〔① 空-rows→null 形状 faithfulness:连接器 null-row 编码 `(schema,emptyRows)`、legacy null-row→null ⇒ `wrapResult` 加 `getRows().isEmpty()→null`+测;② priv 测断言 `Exception`→`AnalysisException`+消息——**收紧后实测捕获被旧断言掩盖的 mock NPE**〔`ConnectContext.getState()` 未 stub,`ErrorReport.reportCommon` 触发〕→ 修测 mock,正是 critic Rule-9 价值〕、**2 DV→T08**〔DV-T07-name-order〔未知名校验时序 priv-first 有意发散,更安全〕/DV-T07-exc-contract〔非-DorisConnectorException 逃逸边界;单行 `IllegalStateException` 有意逃逸=与 legacy 同〕〕、**2 note**〔`resolveConnectorTableHandle` bypass=有意镜像写路径〔seam protected + sys-table-scan-专用〕/flip-safety grep-gate 非 UT=全 P6 series 惯例〕。**验收**:fe-core `ConnectorExecuteActionTest` **13/0/0/0** + `NereidsParserTest` **73/0/0/0**(唯一 `ExecuteActionFactory` 引用者无回归)、checkstyle 0、import-gate 0、iceberg 仍**不在** `SPI_READY_TYPES`、0 连接器/BE/pom/CatalogFactory 改。下一 = T08 parity 审计 + DV-T05r/T06r/T07 批量中央登记。 +- **T08**(parity-UT 审计 + gap-fill + DV 中央登记,**0 产品码**·仍 behind gate):**对抗 byte-parity 审计 wf**(12 area finder:8 procedure + rewrite-planner〔T05〕+ transaction-REWRITE〔T06〕+ dispatch-adapter〔T07〕+ infra〔base/ops〕;每 finding refute-by-default skeptic + completeness critic)= **28 confirmed/partial utGap + 2 newDeviation + 6 refuted**;**所有前向引用 DV 审计 accurate=True**(DV-T04-f/T05r-where/T06r-{rb,scanpool,zone,rollback}/T07-{where,name-order,exc-contract}/auth-add/cache-to-dispatch/executeAction-session)。6 refuted 全对(单行不变式 pin 在 base `BaseIcebergActionTest:184`、IN conflict-mode pin 在 `IcebergPredicateConverterConflictModeTest`、per-conjunct filter 结果等价)。**critic 8 跨切 layering 漏报**:factory createAction/getSupportedActions 9-vs-8 不一致〔→DV-T08-factory-advertise + canary〕/ DV-T05r-where 经 EXECUTE 双闸不可达〔→DV-046 cross-link〕/ NULLABILITY 无 end-to-end round-trip 测〔→fe-core 双极性测〕/ 新 "Failed to load iceberg table" 串〔→DV-T08-loadwrap+测〕/ **auth-add+cache 仅 `context!=null`〔DV 措辞修=非"无条件"〕**/ null-row 编码 / `PARTITION(*)` 不对称 / captured-once。**20 gap-fill UT**:① schema 完整性(rollback/set_current/cherrypick/fast_forward/expire 6×BIGINT/publish 2×STRING/rewrite_manifests 2×INT 全列名·类型·nullability·宽度 vs legacy 字节);② error 串字节(expire 负 older_than / publish cherrypick 失败前缀〔真 ancestor-cherrypick `CherrypickAncestorCommitException`〕 / rewrite_manifests 双-wrap 两层〔`wrapsCurrentSnapshotFailure` 外层 + `executorWrapsFailureWithInnerPrefix` 内层〕 / 单行 checkState 消息 / partition·WHERE 拒文案);③ bug-for-bug execute 路(set_current 无-commit 短路 history 不变双分支 / rewrite_manifests spec_id 过滤双臂 / expire deleteWith 分类);④ infra/dispatch(auth-scope 短路仍失效 / body-fail 不失效 / loadTable-fail 串 / fe-core 列 type+nullability round-trip 双极性 / factory canary / planner 多合取 AND-flatten)。**🟡 2 测模型坑实证修(Rule 12)**:expire deleteWith 实跑 `[0,0,0,0,2,0]`〔退 2 快照=删 2 manifest-LIST、0 manifest 文件〔数据仍引用〕〕→ 钉确定值;rewrite spec_id 漏 `validate()`→`getInt` 读 `parsedValues`(仅 validate 填充)→ spec_id no-op→改 validate 先于 execute(非配 spec_id=1 先跑留 pristine)。**DV 三层中央登记**(44→47,镜像 P6.3-T08 DV-041..044):**DV-045**〔🔴 BLOCKER=rewrite 执行半翻闸阻塞,R-B〕/**DV-046**〔correctness-bearing=auth-add+DV-T05r-where〕/**DV-047**〔perf-cosmetic 批〕。**验收**:连接器 **494/0/1**(475→494,+19 测)+ fe-core `ConnectorExecuteActionTest`〔+type/nullable round-trip 双极性〕、checkstyle 0、import-gate 0、iceberg 仍**不在** `SPI_READY_TYPES`、0 BE/pom/CatalogFactory 改(唯 1 行 `IcebergProcedureOps` 注释)。下一 = T09 收口(= P6.4 DONE)。 +- **T09**(收口/汇总设计 + gate 核 ⇒ **P6.4 DONE**,纯文档 0 产品码):新 `designs/P6.4-T09-procedure-summary-design.md`〔7 节,镜像 P6.3-T09:①架构总览 + T01–T08 索引;②**procedure SPI 收口核对**——净 **+1 SPI** = `ConnectorProcedureOps`〔对比 P6.2「净 0」/ P6.3「SPI 统一收敛」;最小 S-1 扁平 + arg 框架升 `fe-foundation` 共享而非长在 SPI 上〕;③DV-045/046/047 回指;④翻闸阻塞〔= DV-045,DV-041 写路径同族〕;⑤验收门;⑥P6.5〕。**faithfulness 对抗验证 `wf_986bd3db-68b`**〔7 cluster verifier refute-by-default + completeness critic,65 claim〕= **3 真错 + 1 内部矛盾**〔全修,Rule 12〕:①「九 commit 待 push」**实为二**〔`git rev-list --count origin/catalog-spi-10-iceberg..HEAD`=2;仅 T07 `4c84ebf33f8`+T08 `34766150f17` 未推,T01–T06+arg-move 七 commit 已推 origin=`bdc38b14810`——同步修 HANDOFF 旧述「所有 commit 均未 push」〕;② `BaseExecuteAction` **非字节不变**〔arg-move `b045c9db45b` 改 `NamedArguments` import+try/catch rewrap +10/-3,行为保留;唯 `ExecuteActionCommand`/`ExecuteAction` 真字节不变〕;③ stale `IcebergScanNode.getFileScanTasksFromContext:498`→def `:492`/caller `:929`〔同步修 deviations-log DV-045〕;④ §3 内部矛盾「NamedArguments 留 fe-core」vs「升 fe-foundation」。critic 另证 44→47 DV / 475→494+1=20 gap-fill / 9-procedure 名集 / import-gate 禁 `common` 全 reconciled-OK。**gate 核(重跑实证非凭 `@Test` 计数,Rule 12)**:iceberg **不在** `SPI_READY_TYPES`〔`CatalogFactory:51`〕;import-gate exit 0;`git diff 52e25fb25e9..HEAD` = **0 BE / 0 gensrc / 0 CatalogFactory / 1 pom**〔`fe-connector-iceberg` 加 `fe-foundation` 依赖,arg-move——P6.4 累计非 0 pom,T08 自身 0 pom〕;**iceberg UT 重跑 `BUILD SUCCESS` 494/0/1**〔surefire 39 类〕 + **fe-core `ConnectorExecuteActionTest` 重跑 14/0**〔补 T08 留的「运行中确认」〕。**P6.4 全 9 task DONE,仍 behind gate**(翻闸只在 P6.6)。下一 = P6.5 sys-table。 + +### 2026-06-24(P6.3-T07c + T08 + T09 实现 ⇒ P6.3 DONE) + +- **T07c**(commit `a61cd9262b9`)通用 `RowLevelDmlCommand` 壳 + `RowLevelDmlTransform` 注册表 + `IcebergRowLevelDmlTransform` + 6 instanceof 派发站点重接(Update/DeleteFrom/MergeInto→capability);合成留 `Iceberg{Delete,Update,Merge}Command` 原地经 transform 委派(D1:仅放宽 3 private→包级,单 live 循环,legacy loop transitional-dead→P6.7 删);O5-2 现接 dormant(D2:新 `BaseExternalTableInsertExecutor.getConnectorTransactionOrNull()`→iceberg 走 legacy txn→null→不可达直到 P6.6)。fe-core 目标测 **104/0/0**(oracle `IcebergDDLAndDMLPlanTest` 14/0 byte-parity 铁证 + `IcebergRowLevelDmlTransformTest` 7/0)。对抗 `wf_a80f8edb-bed` = 24 raw/0 REAL/24 refuted。 +- **T08** 写路径 parity-UT 审计 + deviation 中央登记(设计 `designs/P6.3-T08-write-parity-audit-design.md`)。10 维对抗审计 `wf_c1067212-ab8`(132 agents)= 40 报告→**20 confirmed/20 refuted**→11 交付(8 新测 + 3 强化):分区 identity 冲突 filter 窄化 / 非-identity 禁窄化 / snapshot 隔离 / PUFFIN DV dedup(连接器 +4);dataLocation 级联 + ORC/codec 矩阵 + partitionSpecsJson 字节(+2+强化);O5-2 per-conjunct drop + OR all-or-nothing(fe-core +1+1);DELETE/UPDATE operation-literal 值断(oracle 2 强化)。**deviation 中央登记 DV-041**(🔴 翻闸 BLOCKER:通用 sink 缺合成列物化+分布=DV-038 同主题新面 + 休眠激活集)/ **DV-042**(北极星 iii 有界:DML 合成 fe-resident)/ **DV-043**(parity-忠实 correctness-bearing)/ **DV-044**(perf/cosmetic/EXPLAIN-diff)。mutation 实证 PUFFIN dedup 测可红已 revert。iceberg UT **389/0/1**(383→389)、fe-core 3 测类绿、0 SPI/BE/fe-core 产品/pom 改、iceberg 仍**不在** `SPI_READY_TYPES`。下一 = **T09 收口(= P6.3 DONE)**。 +- **T09**(收口 = P6.3 DONE)写汇总设计 `designs/P6.3-T09-iceberg-write-summary-design.md`(7 节,镜像 `P6-T11`):架构总览 + T01–T08 逐 task 索引 + **写路径 SPI 收口核对**(与 P6.2「净 0 新 SPI」相反——P6.3 有意 SPI 统一:删双模型 fork + config-bag 三件套→单 `ConnectorTransaction` 写模型 + capability 派发)+ deviation 回指 DV-041..044 + 翻闸阻塞汇总 + 验收门 + 下一阶段。**faithfulness 对抗验证 `wf_9234a18e-1d9`**(6 cluster verifier refute-by-default + 1 completeness critic)= 全 CONFIRMED,唯 1 真错(§5 通用 `visitPhysicalConnectorTableSink` 行号 `:589-627`→实测 `:630-681`,`:589-627` 是 legacy delete+merge visitor)**已修**;critic cheap-check 证 UT 计数静态精确。纯文档 0 产品码、0 BE/pom、iceberg 仍**不在** `SPI_READY_TYPES`。**P6.3 全 9 task DONE**,下一 = **P6.4 procedures**(仍 behind gate)。 + +### 2026-06-23(P6.3 写路径 RFC ✅ + T01~T05 实现) + +- **RFC ✅ 评审通过**(`a49720820f9`)= `06-iceberg-write-path-rfc.md`:写框架全面统一(单 `ConnectorTransaction`)+ 行级-DML Route B(iceberg plan 合成暂留 fe-core,DV-04x)+ O5-2 冲突检测接缝 + Trino 式通用化北极星。 +- **T01** 框架统一·SPI 收口(option B):删 insert-handle/`usesConnectorTransaction` 双模型 fork → 单 `ConnectorTransaction`;jdbc no-op txn 迁移。 +- **T02** jdbc thrift 入 `planWrite`(OQ-1)+ 删 config-bag 三件套(OQ-2)+ source-agnostic `appendExplainInfo` EXPLAIN-保留 hook(用户增补)。 +- **T03** `IcebergConnectorTransaction implements ConnectorTransaction` 骨架:单 SDK txn/表经 seam+auth、14 字段 `TIcebergCommitData` 反序列化累积、`getUpdateCnt`、新 `WriteOperation` 枚举。对抗 1 confirmed 修(`newTransaction()` 须在 auth 内)。 +- **T04** op 选择收进 `commit()`(SPI 无 finishWrite 钩子)+ begin* guards(fmt≥2 / branch 非 tag / baseSnapshotId 捕获)+ 新 `IcebergWriterHelper`/`IcebergPartitionUtils` parse 助手/`IcebergWriteContext`。对抗 0 finding。 +- **T05** commit 校验套件(`validateFromSnapshot`/serializable `validateNoConflictingDataFiles`/`validateDeletedFiles`/`validateNoConflictingDeleteFiles`/`validateDataFilesExist`/`delete_isolation_level` 默认 serializable)+ O5-2 `applyWriteConstraint`(新 `ConnectorPredicate` SPI default-no-op + 连接器惰性转 `IcebergPredicateConverter` 暂存 + 与 identity-分区 filter 合并)+ V3 DV `removeDeletes`(fmt≥3 / `ContentFileUtil.isFileScoped` / dedup)。**[D-061] O5-2 fe-core 生产半(analyzed-plan 抽取)挪 T07**(唯一消费者 = T07 `RowLevelDmlCommand`)。对抗 `wf_0960ef5f-52c` = 0 finding。 +- **T06** sink 统一(INSERT/OVERWRITE,增量·dormant):新 `IcebergWritePlanProvider`(`planWrite` 建字节-parity `TIcebergTableSink`)+ 写排序 SPI(`ConnectorWriteSortColumn`/`getWriteSortColumns`)+ 新 `ConnectorContext.getBackendFileType` 接缝 + 声明 `SINK_REQUIRE_FULL_SCHEMA_ORDER`;**首动 fe-core/planner**;legacy sink 链留 P6.7。对抗 `wf_aaa45689-db4` = 2 confirmed〔均已修·均 dormant〕。 +- **T07a** DELETE/MERGE sink 方言(连接器·dormant):`planWrite` switch `writeOperation`→`buildDeleteSink`/`buildMergeSink`(`TIceberg{Delete,Merge}Sink` 字节-parity,⚠️ delete=`compress_type`(6)·merge=`compression_type`(8)·merge `sort_fields`(6) 经 baseColumnFieldIds 过滤·fv≥3 row-lineage)+ `supportsDelete`/`supportsMerge`=true。对抗 `wf_4e117651-e54` = 0 REAL/4 refuted。 +- **T07b** O5-2 生产半:新 fe-core `NereidsToConnectorExpressionConverter`(nereids→中立 expr,矩阵=真实 legacy 冲突路 **Option A**,字面量经 `toLegacyLiteral()` 字节 token parity)+ `WriteConstraintExtractor`(移植 legacy 收集半,合成列经注入 `Predicate` 排除——闭合 critic BLOCKER)+ 连接器 `IcebergPredicateConverter` 加 `conflictMode` flag + `buildConflict*`(移植 `convertPredicateToIcebergExpression`;scan 路 2-arg 字节不变),T05 `buildWriteConstraintExpression` 改 `conflictMode=true`。**实际 `applyWriteConstraint` 调用 + iceberg 排除谓词供给 = T07c**。deviation [DV-T07b-matrix/literal/exclusion]。对抗 `wf_433b98d4-08d` = 0 REAL/4 refuted。 +- **验收(T01~T06 + T07a + T07b 累计)**:fe-core UT **28/0/0**(converter 18 + extractor 10)、fe-connector-iceberg UT **383/0/1**(278→383)、connector-api/spi 经 `-am` 绿、jdbc·maxcompute·paimon 无回归、scan 回归门 `IcebergPredicateConverterTest` 17/0 不动、checkstyle 0(fe-core+iceberg)、import-gate 0、iceberg 仍**不在** `SPI_READY_TYPES`、**0 BE / 0 SPI 改**(T01–T07b 全程)。下一 = **T07c 通用 `RowLevelDmlCommand` 壳**(命令壳 + 注册表 + 6 instanceof 派发站点重接 + iceberg 排除谓词供给 + 实际 `applyWriteConstraint` 调用;**实现前单独 checkpoint**)。 + +### 2026-06-23(P6.1 DONE + P6.2 DONE;T11 收口) +- **P6.1 DONE〔T01–T10〕**:5-flavor CatalogUtil 装配(T05)+ s3tables bespoke(T06)+ DLF 子树 port(T07)+ 读路径列/format-version/listing/auth parity(T09)+ metastore 模块拆分〔`fe-connector-metastore-{paimon,iceberg}` per-engine + `-spi` 共享基类〕+ per-flavor CREATE 校验(T10 A+B)。 +- **P6.2 DONE〔T01–T11〕**:scan provider 骨架(T01)+ 谓词下推/split(T02)+ typed range-params/`path_partition_keys`(T03)+ merge-on-read delete(T04)+ COUNT 下推(T05)+ field-id 字典(T06)+ MVCC time-travel(T07)+ 连接器内 cache + manifest 级 planning + vendored `DeleteFileIndex`(T08)+ vended + 静态凭据(T09)+ parity-UT 审计补测(T10)+ **T11 收口**(汇总设计 `designs/P6-T11-iceberg-scan-summary-design.md` + validation gate 核对〔7/0〕+ deviation 中央注册 [DV-038]/[DV-039]/[DV-040])。**净 0 新 SPI**(唯一例外 = T03 非破坏 `isPartitionBearing()` 默认)。 +- **验收全绿**:fe-connector-iceberg UT **278/0/1**(本 session `mvn -pl :fe-connector-iceberg -am test` cache-off 重跑 BUILD SUCCESS)、checkstyle 0、import-gate 净、iceberg 仍**不在** `SPI_READY_TYPES`(零行为变更)。审计 workflow `wf_edde7eac-a5b`(9 reader + completeness-critic)。 +- **🔴 翻闸阻塞(P6.6 前必修)= [DV-038]**:GLOBAL_ROWID(top-N 合成列误归 REGULAR)+ getColumnHandles 无 snapshot 重载(rename+time-travel)= 同一共享 fe-core field-id 路径 BE StructNode DCHECK,跨 paimon,须 holistic 修 + paimon 影响分析。 +- **下一 = P6.3 写路径**(先写 `06-iceberg-write-path-rfc.md` 过 PMC,再实现)。 + +### 2026-06-22(T08 commit + T04 pom 依赖闭包) +- **T08 已 commit `d41fa4faf3e`**(type-mapping read parity:TIMESTAMPTZ 名 + 点分 mapping-flag key + BINARY 无界长度 3 修;36 UT 绿)。 +- **T04(pom 依赖闭包,[D-060],本 session)**:`fe-connector-iceberg/pom.xml` 补 7-flavor 闭包——HMS/DLF=**复用 `hive-catalog-shade`**(用户签字 vs 专建 iceberg-hive-shade;**修正 D-059「iceberg-hive-metastore」误述——该 artifact 不存在**,HiveCatalog + DLF ProxyMetaStoreClient + aliyun SDK 均捆在 hive-catalog-shade 内)+ AWS SDK v2 child-first(glue/sts/s3tables/s3/s3-transfer-manager/sdk-core/...)+ `s3-tables-catalog-for-iceberg` + `fe-connector-metastore-spi`(Q2=B);`fe/pom.xml` + s3tables dM;`plugin-zip.xml` + `fe-thrift`/`libthrift` 排除。**无 Java 改**(flavor 由 CatalogUtil 按名反射加载)。验证:36 UT + checkstyle 0 + import-gate 0 + `dependency:tree` iceberg-core 恰 1 + **plugin-zip 实查**(143 jar:iceberg 全 1.10.1 无 skew、libthrift 缺席、hadoop 仅 3.4.2)+ `SPI_READY_TYPES` iceberg 缺席。残留→P6.6 docker:shade 内 iceberg 与直接 iceberg-core child-first 共存(版本同→预期 benign);glue 显式-AK provider 类来源待 T05 核。 + +### 2026-06-21(P6.1 recon + T01-T03) +- **recon**(7-agent,`research/p6.1-iceberg-metadata-recon.md`)+ **10-task 拆解**(`tasks/P6-iceberg-migration.md` §P6.1)+ **[D-059]**(Q1 DLF port-now read-only / Q2 扩 metastore-spi 加 iceberg provider)。 +- **T01-T03 实现+验证(commit `ae54a2174ff`)**:新建 `IcebergCatalogFactory`(纯静态)+ `IcebergCatalogOps`(注入 seam)+ rewire `IcebergConnectorMetadata`(behavior frozen)+ 测试基建从无到有(`RecordingIcebergCatalogOps`/`FakeIcebergTable`/`RecordingConnectorContext` + 2 test class)。`mvn test`(cache off)= 27 run/0F/0E/0skip + checkstyle 0 + import-gate 净。连接器主文件 6→8。 +- 测试独立确证 2 个 silent parity bug(format-version 恒 2 / mapping-flag 下划线 key)已 pin frozen 待 T08/T09。 + +### 2026-05-24 +- 跟踪文件建立。当前 fe-connector 仅 6 个文件骨架,是所有连接器中 **fe-connector 端最不完整** 的——P6 工作量巨大(5 周)。 diff --git a/plan-doc/connectors/jdbc.md b/plan-doc/connectors/jdbc.md new file mode 100644 index 00000000000000..386dfe979bbd48 --- /dev/null +++ b/plan-doc/connectors/jdbc.md @@ -0,0 +1,85 @@ +# Connector: `jdbc` + +--- + +## 概况 + +| 项 | 值 | +|---|---| +| **catalog type 名** | `jdbc` | +| **fe-connector 模块** | `fe/fe-connector/fe-connector-jdbc/` | +| **fe-core 旧路径** | `fe/fe-core/src/main/java/org/apache/doris/datasource/jdbc/`(残留 13 个方言 client + 1 util) | +| **共享依赖** | 无(独立 plugin) | +| **计划迁移阶段** | 已在 SPI 前置阶段完成,残留清理在 P1 | +| **当前状态** | ✅ 已 SPI 化 + 🚧 旧 client 清理待办 | +| **完成度** | 95% | +| **主 owner** | @me | + +--- + +## 迁移 Playbook 进度 + +| 步骤 | 描述 | 状态 | 备注 | +|---|---|---|---| +| 1 | 列出 fe-core 类 | ✅ | 仅剩 13 个 `JdbcClient` + `util/JdbcFieldSchema` | +| 2 | 列出 fe-connector 类 | ✅ | 25 个 java 文件,含 13 个方言 client(新版) | +| 3 | 反向 instanceof grep | ✅ | 0 处(已彻底清理) | +| 4 | 实现 ConnectorMetadata / ScanPlanProvider | ✅ | `JdbcConnectorMetadata`、`JdbcScanPlanProvider` | +| 5 | ConnectorProvider 验证 | ✅ | `JdbcConnectorProvider.validateProperties` 已实现 | +| 6 | META-INF/services | ✅ | `org.apache.doris.connector.jdbc.JdbcConnectorProvider` | +| 7 | `SPI_READY_TYPES` 加入 | ✅ | `CatalogFactory.SPI_READY_TYPES = ["jdbc", "es"]` | +| 8 | gsonPostProcess 迁移 | ✅ | logType JDBC → PLUGIN 已就位 | +| 9 | registerCompatibleSubtype | ✅ | | +| 10 | 替换反向 instanceof | ✅ | | +| 11 | PhysicalPlanTranslator 删分支 | ✅ | | +| 12 | 测试 | ✅ | 13 个测试文件 | +| 13 | 删 fe-core 旧目录 | 🚧 | **P1 处理**:删 `datasource/jdbc/client/Jdbc*Client.java` 13 个 + `util/JdbcFieldSchema.java` | + +--- + +## SPI 实现完成度 + +| 扩展点 | 是否需要 | 实现状态 | 备注 | +|---|---|---|---| +| E1 CreateTableRequest | ❌ | n/a | JDBC 不支持复杂 CREATE TABLE,旧 createTable 已够用 | +| E2 Procedures | ❌ | n/a | | +| E3 MetaInvalidator | ❌ | n/a | JDBC 无 push notification | +| E4 Transactions | 🟡 | 当前 auto-commit | P0 批 0 后改为返回 no-op transaction | +| E5 MvccSnapshot | ❌ | n/a | JDBC 无快照 | +| E6 VendedCredentials | ❌ | n/a | | +| E7 SysTables | ❌ | n/a | | +| E8 ColumnStatistics | 🟡 | 现有 `getTableStatistics` 已有;列级未实现 | 用户 ANALYZE 走 fe-core 缓存 | +| E9 Delete/Merge sink | 🟡 | 当前用 `JDBC_WRITE` 类型 | 不需要 file-based sink | +| E10 listPartitions | ❌ | n/a | JDBC 表无分区 | + +--- + +## 已知特殊性 + +- 13 个方言 client(MySQL/PG/Oracle/SQLServer/ClickHouse/...)每个都有独立的 quoting / type mapping / pushdown 规则。 +- `JdbcUrlNormalizer` 处理各种 vendor 特定 URL 格式。 +- `defaultTestConnection()` 返回 `true`(CREATE CATALOG 时强制验连接)。 +- 旧 fe-core 13 个 `Jdbc*Client` 当前是 dead code(fe-connector 内已有等价实现),但还在 fe-core 编译路径中——P1 删除前要确认没有任何残留引用。 + +--- + +## 关联 + +- 阶段 task:N/A(已完成的连接器);残留清理在 [P1](../tasks/P1-cleanup-and-scan-node.md)(待建) +- 决策:D-001(沿用 PASSTHROUGH_QUERY,JDBC 用到 query() TVF) +- 偏差:(暂无) +- 风险:R-004(classloader 隔离 — JDBC 已验证可行) + +--- + +## 进度日志 + +### 2026-06-23(P6.3-T02 — jdbc 写路径统一到 plan-provider) +- jdbc 写从 **config-bag** 路径迁到统一 **plan-provider** 路径(写框架统一的一部分,跨连接器一致): + - 新 `JdbcWritePlanProvider`(镜像 `MaxComputeWritePlanProvider`)`planWrite` 直建 `TJdbcTableSink`(熔合 legacy `getWriteConfig` 属性袋 + fe-core `bindJdbcWriteSink`);`JdbcDorisConnector.getWritePlanProvider()` 返非空 → `PhysicalPlanTranslator` 据此自动路由 jdbc 入 plan-provider;删 `JdbcConnectorMetadata.getWriteConfig`。 + - 删除 config-bag SPI 三件套(`ConnectorWriteType`/`ConnectorWriteConfig`/`getWriteConfig`),jdbc 是其唯一消费者。 + - **EXPLAIN 保留**:新 `ConnectorWritePlanProvider.appendExplainInfo`(source-agnostic,镜像扫描侧)让 jdbc 在 EXPLAIN 回吐 `TABLE TYPE`/`INSERT SQL`/`USE TRANSACTION`。 + - **写 thrift 字节 parity**(`JdbcWritePlanProviderTest`,含连接池 default/insertSql/catalogId/tableType/useTransaction);jdbc no-op txn(T01)不变。**0 BE 改**。 + +### 2026-05-24 +- 跟踪文件建立。当前状态:已 SPI 化,等待 P1 清理 fe-core 残留方言 client。 diff --git a/plan-doc/connectors/maxcompute.md b/plan-doc/connectors/maxcompute.md new file mode 100644 index 00000000000000..cdd3cf383c5e28 --- /dev/null +++ b/plan-doc/connectors/maxcompute.md @@ -0,0 +1,88 @@ +# Connector: `maxcompute` + +--- + +## 概况 + +| 项 | 值 | +|---|---| +| **catalog type 名** | `max_compute` | +| **fe-connector 模块** | `fe/fe-connector/fe-connector-maxcompute/` | +| **fe-core 旧路径** | `fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/` | +| **共享依赖** | 无 | +| **计划迁移阶段** | **P4** | +| **当前状态** | 🚧 **Batch C 翻闸完成**(T05 image-compat + T06a 写接线/UT + **T06b flip ✅** `SPI_READY_TYPES += "max_compute"`,gate 全绿 [D-027]);下一 = **Batch D**(删 legacy 子系统 + drop fe-core odps 依赖,**待用户 live ODPS 验证后做**)| +| **完成度** | 75% | +| **主 owner** | @me | + +--- + +## 迁移 Playbook 进度 + +| 步骤 | 状态 | 备注 | +|---|---|---| +| 1 | 🟡 | fe-core 8 个顶层(ExternalCatalog/Database/Table、MetaCache、MetadataOps、MCTransaction、SchemaCacheValue、McStructureHelper)+ `source/` 2 个 | +| 2 | 🟡 | fe-connector 13 个文件,scan 路径已迁 | +| 3 | ⏳ | 反向 instanceof:12 处(`PhysicalPlanTranslator`、`ShowPartitionsCommand`、`PartitionsTableValuedFunction` 等)| +| 4 | ✅ | Metadata 读 + **DDL(P4-T01 ✅)** + **分区 listing(P4-T02 ✅)** + **写/事务 `ConnectorTransaction`+`beginTransaction`(P4-T03 ✅)** + **写计划 `getWritePlanProvider`→`planWrite`→`TMaxComputeTableSink`(P4-T04 ✅,OQ-2=Approach A)** 全实现(cutover 接线归 Batch C)| +| 5 | ⏳ | | +| 6 | ✅ | META-INF/services 已注册 | +| 7 | ⏳ | | +| 8-9 | ✅ | T05:GSON `registerCompatibleSubtype`(catalog/db/table)迁 PluginDriven(image 兼容)| +| 10 | ⏳ | 清理 12 处反向 instanceof | +| 11 | ⏳ | PhysicalPlanTranslator 删 `MaxComputeExternalTable` 分支 | +| 12 | ⏳ | 0 个测试 | +| 13 | ⏳ | 删 `datasource/maxcompute/` | + +--- + +## SPI 实现完成度 + +| 扩展点 | 是否需要 | 实现状态 | 备注 | +|---|---|---|---| +| E1 CreateTableRequest | ✅ 需要 | ✅ P4-T01 | `createTable(request)` 港 legacy(identity 分区 / hash bucket / lifecycle / `mc.tblproperty.*`)| +| E2 Procedures | ❌ | n/a | | +| E3 MetaInvalidator | ❌ | n/a | | +| E4 Transactions | ✅ 需要 | ✅ P4-T03(事务)+ P4-T04(写计划)| `beginTransaction`+`MaxComputeConnectorTransaction`(`addCommitData`[TBinaryProtocol]/block-alloc/commit/rollback/getUpdateCnt)✅;`getWritePlanProvider`→`MaxComputeWritePlanProvider.planWrite`→`TMaxComputeTableSink`(建写 session + `setWriteSession` 绑 txn + 盖 txn_id/write_session_id,OQ-2=Approach A)✅ | +| E5 MvccSnapshot | ❌ | n/a | | +| E6 VendedCredentials | ❌ | n/a | | +| E7 SysTables | ❌ | n/a | | +| E8 ColumnStatistics | 🟡 | | +| E9 Delete/Merge sink | ❌ | | +| E10 listPartitions | ✅ 需要 | ✅ P4-T02 | `listPartitions/Names/Values` 直取 ODPS `getPartitions`,filter 忽略返全量(OQ-4 无自有 cache)| + +--- + +## 已知特殊性 + +- 12 处反向 instanceof 是 4 个连接器(trino-connector 2、hudi 0、maxcompute 12、paimon 10)中 trino-connector 的 6 倍量级,是 P4 主要工作。 +- `McStructureHelper` 当前在 fe-core 和 fe-connector 中**重复**,P1 已计划删除 fe-core 版本。 +- 用阿里云 ODPS SDK,classloader 隔离需要测试。 +- 0 个测试 → P4 启动前需要补 mock SDK 测试。 + +--- + +## 关联 + +- 阶段 task:P4(待启动时建) +- 决策:[D-025](../decisions-log.md)(P4-T04 写计划 5 决策:Approach A / seam fill / 抽 getSettings / supportsInsert / 静态分区 map)、[D-024](../decisions-log.md)(P4-T03 两 fork:txn id 分配器 / 写 session 挪 T04)、D-002(scan-node 复用) +- 偏差:[DV-012](../deviations-log.md)(P4-T04 partition_columns 取 ODPS 表列,源不同值同)、[DV-011](../deviations-log.md)(P4-T03 block 上限常量 + 异常类型)、[DV-010](../deviations-log.md)(P4-T01 修 fe-core 转换器 CHAR/VARCHAR 长度) +- 风险:R-004 + +--- + +## 进度日志 + +### 2026-06-07 +- **P4-T06b 翻闸落地(Batch C 完成,唯一 live 切点)= max_compute 进 SPI**:`CatalogFactory.SPI_READY_TYPES += "max_compute"`(:52) + 删 legacy `case "max_compute"`(原 :146-149) + 删 unused `MaxComputeExternalCatalog` import + 注释去 max_compute。翻闸后 `max_compute` catalog→`PluginDrivenExternalCatalog`、table→`PluginDrivenExternalTable`(GSON T05 兼容),读/写/DDL/分区/show 全经 SPI;legacy `instanceof MaxCompute*` 分支全失配(dead)。gate 全绿(compile BUILD SUCCESS/MVN_EXIT=0 + checkstyle 0/CS_EXIT=0 + import-gate 0,真实 EXIT 核)。**前继 T05/T06a 已 commit**(image-compat + dormant 写接线 W-a..d/G1–G5 + UT)。**SPI_READY ✅**。**2 决策 [D-027]**:flip 先行/移除待 live 验证;fe-core 仅删直接 odps 声明(transitive-via-fe-common 留)。Batch D 完整移除闭包(21 删 / ~30 清 / keep / pom drop)已 verify → [Batch D 移除设计](../tasks/designs/P4-batchD-maxcompute-removal-design.md),**执行前置门 = 用户跑 `OdpsLiveConnectivityTest`(4 个 `MC_*` 环境变量)+ 手测 smoke 绿**。 + +### 2026-06-06 +- **P4-T04 连接器写计划完成 = Batch A+B 全完成**(Batch B 收尾,gate 关、dormant、零 live 风险):新建 `MaxComputeWritePlanProvider.planWrite`(**OQ-2=Approach A**:finalizeSink 一处建 ODPS 写 session → `session.getCurrentTransaction()`→`MaxComputeConnectorTransaction.setWriteSession` 绑 txn → 盖 `TMaxComputeTableSink`(静态字段 + `static_partition_spec` + `partition_columns`(ODPS 表列) + `write_session_id` + `txn_id`),无运行期注入 hook)+ `MaxComputeDorisConnector.getSettings()`(D-3 抽出,scan/write 共用,镜像 legacy 单 settings)/`getWritePlanProvider()` + `supportsInsert()`=true(D-4,余 throwing-default 待 Batch C)+ fe-core seam(`PluginDrivenTableSink.bindViaWritePlanProvider(insertCtx)` 读 overwrite+静态分区 / `PluginDrivenInsertCommandContext.staticPartitionSpec`,非基类避 `MCInsertCommandContext` shadow)。5 决策 [D-025];偏差 [DV-012](partition_columns 取 ODPS 表列)。坑10 javap 全核;写路径 ArrowOptions MILLI/MILLI(≠scan);block_id 不盖(运行期 T03)。守门全绿(compile BUILD SUCCESS + checkstyle 0 + import-gate,真实 EXIT)。单测延 P4-T10。下一步 = **Batch C 翻闸**(live,前置 R-004 防御测)。 +- **P4-T03 连接器写/事务 SPI 完成**(Batch B 启,gate 关、dormant):新建 `MaxComputeConnectorTransaction`(港 `MCTransaction`:`addCommitData`[TBinaryProtocol 红线]/block-alloc/commit/rollback/getUpdateCnt)+ `MaxComputeConnectorMetadata.beginTransaction`,over W4 委派。两 fork [D-024]:txn id 经新增 `ConnectorSession.allocateTransactionId()`(尊重 [D-015])/ 写 session 创建挪 T04。偏差 [DV-011](block 上限常量、`DorisConnectorException`)。JDBC 仅半样板(无 `ConnectorTransaction`),MC 首个有状态事务 adopter。守门全绿(compile + checkstyle 0 + import-gate,真实 EXIT)。单测延 P4-T10。下一步 = P4-T04 写计划。 +- **P4-T02 连接器分区 listing 完成**(Batch A 收尾,gate 关、dormant、零 live 风险):`MaxComputeConnectorMetadata` impl SPI `listPartitionNames`/`listPartitions`/`listPartitionValues`,三方法直取 `structureHelper.getPartitions(odps, db, tbl)`:names = `PartitionSpec.toString(false,true)`(镜像 legacy `MaxComputeExternalCatalog:283`/`MaxComputeExternalTable:201`);`listPartitions` filter **忽略**返全量(values 由 `PartitionSpec.keys()`/`get(k)`、props=emptyMap);`listPartitionValues` 按入参列序 `spec.get(col)`。**OQ-4 定**:不建连接器自有 cache,直取 ODPS(Rule 2 不投机)。**保真**:legacy 双路径分歧(catalog 无 emptiness guard / table 有),SPI 锚 catalog SHOW PARTITIONS 故不加 guard;写前 javap 验 ODPS `PartitionSpec` API。测试延至 **P4-T10**(无 mockito 基线)。守门全绿(compile BUILD SUCCESS + checkstyle 0 + import-gate,真实 EXIT 核验)。下一步 = Batch B(P4-T03 写/事务 SPI)。 +- **P4-T01 连接器 DDL 完成**(Batch A,gate 关、dormant、零 live 风险):`MaxComputeConnectorMetadata` impl SPI `createTable(ConnectorCreateTableRequest)` / `dropTable` / `createDatabase` / `dropDatabase`(忠实港 legacy `MaxComputeMetadataOps`,消费 P0 request 非 fe-core `CreateTableInfo`;连接器 `McStructureHelper` ODPS DDL 原语已具备)+ 新 `MCTypeMapping.toMcType(ConnectorType)` 反向类型映射(递归 ARRAY/MAP/STRUCT)。附带修 fe-core 共享转换器 CHAR/VARCHAR 长度 [DV-010](../deviations-log.md)(用户签字)+ 回归测。守门全绿(compile + checkstyle 0 + import-gate + `ConnectorColumnConverterTest` 9/0F0E)。下一步 = P4-T02 分区 listing。 +- **P4 adopter 设计批准**([D-023](../decisions-log.md)):5 批 / 11 task 计划见 [tasks/P4](../tasks/P4-maxcompute-migration.md)。re-grep 校正反向引用 **~19**(旧称「12」失真;W-phase 已灭 `Coordinator`/`LoadProcessor`/`FrontendServiceImpl` 3 热点 txn 站)。连接器现状核实:写 SPI **全缺**(无 `getWritePlanProvider`/`beginTransaction`/`ConnectorWriteOps`)、DDL **缺**(仅 `McStructureHelper` 低层 helper)、分区 listing **缺**;`MCTransaction` 已含 W2 `addCommitData(byte[])`,`TMaxComputeTableSink` 18 字段齐。**下一步 = Batch A**(P4-T01 DDL + P4-T02 分区,gate 关)。 +- **W-phase(共享写/事务 SPI)全落地**([D-021](../decisions-log.md) / [D-022](../decisions-log.md)):maxcompute 是首个 adopter 的靶。**写接线 seam 已就位**——fe-core `Transaction` 写回调 + `PluginDrivenTransaction` 桥(W4 `759cc0874c8`)、写-plan-provider layer 进既有 plugin-driven 写路径(W5 `9ebe5e27fa4`,[DV-009](../deviations-log.md))。**P4 adopter 待做**:搬 `datasource/maxcompute/` → `fe-connector-maxcompute`;impl `ConnectorWriteOps`(insert) / `ConnectorTransaction`(over `addCommitData` + `allocateWriteBlockRange`,仅 mc 需 block-id seam) / `ConnectorWritePlanProvider`(产 `TMaxComputeTableSink`);翻闸 `SPI_READY_TYPES+="max_compute"` + 删 `CatalogFactory` case + GSON 兼容 + `getEngine` 分支;清 ~12 反向 instanceof;连接器测试基线。详见 [写 RFC §12](../tasks/designs/connector-write-spi-rfc.md)。 + +### 2026-05-24 +- 跟踪文件建立。60% 实现已就位;重复类 `McStructureHelper` 已在 P1 清单。 diff --git a/plan-doc/connectors/paimon.md b/plan-doc/connectors/paimon.md new file mode 100644 index 00000000000000..f9f9c2a23b1e6c --- /dev/null +++ b/plan-doc/connectors/paimon.md @@ -0,0 +1,96 @@ +# Connector: `paimon` + +--- + +## 概况 + +| 项 | 值 | +|---|---| +| **catalog type 名** | `paimon` | +| **fe-connector 模块** | `fe/fe-connector/fe-connector-paimon/` | +| **fe-core 旧路径** | `fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/` | +| **共享依赖** | `fe-connector-hms`(paimon-HMS-flavor 用) | +| **计划迁移阶段** | **P5**(B0–B7 迁移+翻闸已合入 `branch-catalog-spi` #64446 `38e7140ce56`;下一 = **P5-T29 删 legacy**)| +| **当前状态** | ✅ 迁移 + 翻闸已合入(paimon 入 `SPI_READY_TYPES`,FE 走 SPI 路径);仅剩 **B8 = P5-T29 删 fe-core legacy + maven 依赖** + B9 回归 | +| **完成度** | 95%(B0–B7 全实现并合入:read/DDL/sys-tables(E7)/MVCC(E5)/MTMV桥(E10)/时间旅行/翻闸 + P6 review deviation fix;剩删 legacy(B8/P5-T29)+回归(B9))| +| **主 owner** | @morningman / TBD | + +--- + +## 迁移 Playbook 进度 + +> 全部已合入 #64446,除步骤 13(= P5-T29)。 +| 步骤 | 状态 | 备注 | +|---|---|---| +| 1 | ✅ | fe-core legacy 已盘点(`datasource/paimon/` 30 + `metacache/paimon/` 3 + `systable/PaimonSysTable`)| +| 2 | ✅ | fe-connector 全功能完整(scan/predicate/handle/DDL/sys-table/MVCC/MTMV/时间旅行)| +| 3 | ✅ | 反向 instanceof 已盘点(热区 + infra 死引用)| +| 4 | ✅ | ConnectorMetadata 全实现;flavor 装配=单 Catalog + `createCatalog` flavor switch(D-037,**非** backend 模块——5 个 `fe-connector-paimon-backend-*` 是空壳)| +| 5 | ✅ | validateProperties + preCreateValidation 全 flavor | +| 6 | ✅ | META-INF/services 已注册 | +| 7 | ✅ | `SPI_READY_TYPES += "paimon"`(翻闸已合入 #64446)| +| 8-9 | ✅ | GSON 原子转 `registerCompatibleSubtype` + db/table compat | +| 10 | 🟡 | 热区 instanceof 已清(翻闸);**infra 死引用 8 处待 P5-T29** | +| 11 | ✅ | PhysicalPlanTranslator 删 `PAIMON` 分支(翻闸已合入)| +| 12 | ✅ | 连接器 UT ~300+ 绿 + fe-core PluginDriven* 测 | +| 13 | ⏳ | **删 `datasource/paimon/` = P5-T29(下一 session)** | + +--- + +## SPI 实现完成度 + +| 扩展点 | 是否需要 | 实现状态 | 备注 | +|---|---|---|---| +| E1 CreateTableRequest | ✅ 需要 | 含 bucket spec | | +| E2 Procedures | ❌ 不需要 | **零可迁**:fe-core 无 paimon procedure(expire_snapshots=iceberg、CALL migrate_table=Spark,皆非 paimon)| doc-only no-op | +| E3 MetaInvalidator | 🟡 | paimon-HMS-flavor 需要 | 复用 `fe-connector-hms` | +| E4 Transactions | ✅ 需要 | | +| E5 MvccSnapshot | ✅ 需要 | ✅ **已合入 #64446**(B5 wire 通用 `PluginDrivenMvccExternalTable`→MvccTable 消费 `beginQuerySnapshot`)| 首个 E5 消费者 | +| E6 VendedCredentials | ✅ 需要 | ✅ 已迁(REST flavor)| | +| E7 SysTables | ✅ 需要 | ✅ **已合入**(D-039:复用 live `SysTableResolver`,非 RFC §10 [DV-023]):连接器 `listSupportedSysTables`+`getSysTableHandle`;fe-core 通用 `PluginDrivenSysExternalTable`+`PluginDrivenSysTable`(报 PLUGIN_EXTERNAL_TABLE);forceJni binlog/audit_log;`buildTableDescriptor`→HIVE_TABLE | greenfield SPI,未来 iceberg/hudi 复用 | +| E8 ColumnStatistics | 🟡 | snapshot summary 已含部分 | 可选 | +| E9 Delete/Merge sink | 🟡 | merge-on-read 路径 | | +| E10 listPartitions | ✅ 需要 | ✅ **已合入**(连接器 `listPartitionNames/listPartitions/listPartitionValues` + FE 消费 + `partition_columns` key 翻,B5)| | +| **MTMV(无 E 号)** | ✅ 需要 | ✅ **已合入 #64446**:通用 **`PluginDrivenMvccExternalTable`**(capability-selected,源无关,D-042)+ 时间旅行全 parity(AS-OF/tag/branch/@incr,D-043/044)| D-038(P5 内实现)| + +--- + +## 已知特殊性 + +- **flavor 装配(D-037=单 Catalog)**:6 flavor(hms/filesystem/dlf/rest/jdbc + base)经 `PaimonConnector.createCatalog` 内 flavor switch on `paimon.catalog.type`(MC 一致,拷常量/conf/**每-flavor authenticator** 入模块)。⚠️ 5 个 `fe-connector-paimon-backend-*` 模块只是**空壳**(gitignore `.flattened-pom.xml`,零 src),**不采用**其 backend-SPI 设计。 +- **MTMV(D-038)**:✅ 已合入 #64446——翻闸落通用 **`PluginDrivenMvccExternalTable`**(capability-selected,**源无关**,D-042,非 paimon 专类;可复用 iceberg/hudi)implements MTMVRelatedTableIf+MTMVBaseTableIf+MvccTable;paimon 是**首个真消费 E5(MVCC)/E6(vended)/E7(sys-table)** 的 adopter,MC 无先例。 +- **重复类 `PaimonPredicateConverter`**(fe-core `source/PaimonPredicateConverter` vs 连接器版):连接器版 TZ 已 parity-correct(NTZ 保 UTC、LTZ 不下推,D4);**fe-core 重复版 = P5-T29 删除目标**(P1-T02 推迟项)。 +- BE 经 JNI(**及 C++ native** `paimon_cpp_reader`)调 paimon-reader;连接器经 `ConnectorScanPlanProvider.getSerializedTable` 序列化 `Table`。BE 冻结不动;序列化身份是契约(Base64 非 blocker,BE 有 STD fallback;须 pin paimon-core 版本三方对齐)。 +- **测试**:连接器测试模块已建(no-mockito recording seam,~300+ 测)+ FE→BE serde round-trip smoke + parity baseline(live-e2e CI-gated `enablePaimonTest`)。 +- 详尽 code-grounded 分析见 [recon](../research/p5-paimon-migration-recon.md) + [P5 设计 doc](../tasks/P5-paimon-migration.md)。 + +--- + +## 关联 + +- 阶段 task:[tasks/P5-paimon-migration.md](../tasks/P5-paimon-migration.md)(30 TODO / B0–B9 批) +- recon:[research/p5-paimon-migration-recon.md](../research/p5-paimon-migration-recon.md) +- 决策:D-037(flavor=单 Catalog + switch)、D-038(MTMV/MVCC P5 内实现,翻闸 gated)、**D-039**(B4 E7=复用 live SysTable 机制非 RFC §10)、D7(B3 DDL authenticator=legacy parity)、D-006(cache 放连接器内)、D-005(HMS flavor 走 tableFormatType) +- 偏差:**DV-023**(RFC §10 E7 设计被 B4 取代)、**DV-024**(B4 修 B2 遗留 BE 描述符 SCHEMA_TABLE→HIVE_TABLE) +- 风险:R-004(classloader)、R-007(FE/BE 共享 jar)、R-012(snapshotId 类型) + +--- + +## 进度日志 + +### 2026-06-20(阶段里程碑 · 迁移+翻闸合入 #64446) +- **B0–B7 全完成并 squash-合入 `branch-catalog-spi`**(PR **#64446 / `38e7140ce56`** + `e9c5b3e70ce` 修编译):B5 MTMV 桥(通用 `PluginDrivenMvccExternalTable`,D-040/041/042)+ B5b 时间旅行全 parity(AS-OF/tag/branch/@incr,D-043/044)+ B6 procedure no-op + **B7 翻闸**(入 `SPI_READY_TYPES` + GSON 原子 compat + D-045/046/047 restore SHOW PARTITIONS/SHOW CREATE)+ P6 全路径 clean-room review 全部 deviation fix。 +- **下一 = P5-T29(B8 删 fe-core legacy + maven 依赖)**:见 [tasks/P5 §P5-T29 执行计划](../tasks/P5-paimon-migration.md)(DEAD `datasource/paimon/`(30)+`metacache/paimon/`(3)+`systable/PaimonSysTable`;硬前置=迁出 `PaimonExternalCatalog` 常量;STILL-CONSUMED `property/metastore/Paimon*`(7) 保留;maven 方案 A/B)。 + +### 2026-06-10(B0–B4 实现里程碑,未提交) +- **B4(本 session,T16-T20)= sys-tables E7 + MVCC E5**:连接器 SPI `listSupportedSysTables`/`getSysTableHandle`(D-039 复用 live `SysTableResolver` 机制);fe-core 通用 `PluginDrivenSysExternalTable`/`PluginDrivenSysTable`;forceJni(binlog/audit_log);`buildTableDescriptor`→HIVE_TABLE(同修 B2 遗留 [DV-024]);sys 表 fail-loud 拒 time-travel/scan-params;E5 三方法(inert until B5)+ caps。3-lens 复审 1 BLOCKER(scan-path 丢 forceJni)已修。连接器 124 绿 + fe-core 100 绿。 +- B0–B3 此前已落(测基建 / flavor 装配 / normal-read / DDL metadata;见 tasks/P5 阶段日志)。 +- 下一 = B5 MTMV 桥(接活 E5 + GAP-LISTPART-AT-SNAPSHOT + `partition_columns` key 翻 + FE 消费 listPartitions)。 + +### 2026-06-09 +- P5 kickoff:14-agent code-grounded recon + cross-cut 对抗复审;产 recon + 设计 doc(30 TODO/B0–B9)。 +- 用户签字 D-037(flavor=单 Catalog + switch)、D-038(MTMV/MVCC P5 内实现,翻闸 gated on 它)。 +- 证伪 3 先验:backend 模块空壳(非已建工厂)、FE 分发部分已预接(残留=连接器 listPartitions)、Base64 非 blocker(BE 有 STD fallback)。 + +### 2026-05-24 +- 跟踪文件建立。scan 路径已就绪,但 6 个 catalog flavor + MVCC + sys-tables + vended creds 都还在 fe-core。 diff --git a/plan-doc/connectors/trino-connector.md b/plan-doc/connectors/trino-connector.md new file mode 100644 index 00000000000000..0e55a0e4b3e98c --- /dev/null +++ b/plan-doc/connectors/trino-connector.md @@ -0,0 +1,97 @@ +# Connector: `trino-connector` + +--- + +## 概况 + +| 项 | 值 | +|---|---| +| **catalog type 名** | `trino-connector` | +| **fe-connector 模块** | `fe/fe-connector/fe-connector-trino/` | +| **fe-core 旧路径** | `fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/` | +| **共享依赖** | 无 | +| **计划迁移阶段** | **P2**(首个完整 playbook 实施) | +| **当前状态** | ✅ P2 代码完成(legacy 已从 fe-core 移除,查询走 SPI);PR 待开(分支基线对齐) | +| **完成度** | **100%**(代码;PR 待开,T12 回归测试推迟到有集群/plugin 环境) | +| **主 owner** | @me | + +--- + +## 迁移 Playbook 进度 + +> Recon 后实测(2026-05-25):fe-core 旧目录 10 个 .java;反向 instanceof 实际 1 处(dashboard "2" 为过时数字)。 + +| 步骤 | 状态 | 备注 | +|---|---|---| +| 1 | 🟡 | fe-core 旧路径 10 个 .java / ~1760 LOC(TrinoConnectorExternalCatalog 329 / Scan 342 / PredicateConverter 334)| +| 2 | 🟡 | fe-connector 已有 13 个类 / 2162 LOC:Provider/Metadata/ScanPlanProvider/Predicate/PluginManager/Bootstrap/TypeMapping/Json/3 个 Handle | +| 3 | ⏳ | 反向 instanceof:**1 处**(PhysicalPlanTranslator:779 — P1 批 A 已加 SPI fallback 在它之上,待 P2-T08 删除)| +| 4 | 🟢 | ConnectorMetadata 方法 ~95% IMPL/DEFAULT;DDL 类(createTable/dropTable)DEFAULT throws 是合理的(Trino 此路径 read-only)| +| 5 | ✅ | validateProperties / preCreateValidation done(P2-T01;commit `31fb91c5bd3`)| +| 6 | ✅ | META-INF/services 已注册 `TrinoConnectorProvider` | +| 7 | ✅ | `SPI_READY_TYPES` 加 `"trino-connector"`(P2-T07;commit `0fe4b8a93d6`)| +| 8 | ✅ | gsonPostProcess 加 trinoconnector → plugin 迁移 + helper `legacyLogTypeToCatalogType`(P2-T04;commit `dfd48725c76`)| +| 9 | ✅ | registerCompatibleSubtype 已 atomic-replace Trino 三处旧 class-token(P2-T03;commit `dfd48725c76`;T10 不再碰 GsonUtils)| +| 10 | ✅ | 反向 instanceof 已删(P2-T08;commit `ed81a063fe8`)| +| 11 | ✅ | PhysicalPlanTranslator 删 `TrinoConnectorExternalTable` 分支(P2-T08;`ed81a063fe8`)| +| 12 | 🟡 | 单测 ✅(P2-T11;3 类/29 测试 `9bba12a44b2`);回归 `migration_compat` 推迟(P2-T12,DV-003)| +| 13 | ✅ | 删 `datasource/trinoconnector/`(10 文件)+ legacy test(P2-T10;`ed81a063fe8`)。GsonUtils 由批 B 处理 | + +--- + +## SPI 实现完成度 + +| 扩展点 | 是否需要 | 实现状态 | 备注 | +|---|---|---|---| +| E1 CreateTableRequest | 🟡 | 透传到 Trino connector | Trino 自身 CREATE 透传(Doris 端走 SPI default throw 即可)| +| E2 Procedures | 🟡 | Trino 有 Procedure SPI | 推迟评估(不在 P2 scope)| +| E3 MetaInvalidator | ❌ | n/a | Trino 一般无 push notification(DEFAULT NOOP 即合)| +| E4 Transactions | 🟡 | Trino ConnectorTransactionHandle | 桥接到新 ConnectorTransaction(P2 不做 write 路径,DEFAULT 即合)| +| E5 MvccSnapshot | 🟡 | 部分 Trino connector 有 | 视具体 plugin;P2 不做 | +| E6 VendedCredentials | ❌ | n/a | | +| E7 SysTables | ❌ | n/a | | +| E8 ColumnStatistics | 🟡 | Trino 有 column stats | P2 不做(可推迟)| +| E9 Delete/Merge sink | ❌ | 用通用 sink | | +| E10 listPartitions | 🟡 | Trino 有 partition handles | DEFAULT empty 即合(Trino 自己 plan-time 处理 partition pruning)| +| **pushdown** | ✅ | applyFilter / applyProjection done(commit `31fb91c5bd3`)| `TrinoConnectorDorisMetadata` 复用 `TrinoPredicateConverter`;`remainingFilter` 保守=原表达式 | + +--- + +## 已知特殊性 + +- **第一个完整 playbook 实施样板**——爆炸半径最小(只有 2 处反向 instanceof,没有 transaction/event 负担),用于把整个迁移流程跑通。 +- 包含 Trino plugin loader(`TrinoBootstrap`、`TrinoPluginManager`、`TrinoServicesProvider`)—— classloader 隔离已在 fe-connector 内部完成。 +- 委托给底层 Trino plugin 处理元数据,本质是"trino-on-doris"包装层。 +- 0 个测试——P2 启动前需要补单元测试 + 至少一个集成测试(用 mock Trino plugin)。 + +--- + +## 关联 + +- 阶段 task:P2(待启动时建 `tasks/P2-trino-connector.md`) +- 决策:D-002(scan-node 复用 FileQueryScanNode) +- 偏差:(暂无) +- 风险:R-004(classloader 隔离 — Trino plugin loader 是主要测试点) + +--- + +## 进度日志 + +### 2026-05-25(晚 ④)— 批 B 完成(fe-core 桥接) +- commit `dfd48725c76`:GsonUtils 三处 Trino registerSubtype atomic-replace 为 registerCompatibleSubtype;PluginDrivenExternalCatalog 新增 `legacyLogTypeToCatalogType` helper 处理 TRINO_CONNECTOR 下划线/连字符 mismatch;PluginDrivenExternalTable 加 trino-connector engine-name 分支 +- 3 files / +29 LOC fe-core;compile + checkstyle + import gate 全绿 +- HANDOFF 校正:T03 不能"只加不删"(撞 RuntimeTypeAdapterFactory label 唯一性);T05 是 duplicate of T03;T10 scope 缩窄(不再碰 GsonUtils) +- **regression window**:batch B → batch C T07 翻闸前,新建 trino 目录无法序列化;批 C 必须紧接批 B 操作 + +### 2026-05-25(晚 ③)— 批 A 完成(fe-connector-trino SPI 补齐) +- commit `31fb91c5bd3`:TrinoConnectorProvider.validateProperties(`trino.connector.name` required check);TrinoDorisConnector.preCreateValidation(调 ensureInitialized 触发 plugin loading);TrinoConnectorDorisMetadata.applyFilter + applyProjection(复用 TrinoPredicateConverter;`remainingFilter` 保守=原表达式 匹配 legacy) +- 3 files / +143 LOC 全 fe-connector-trino;未触 fe-core(严守批 A 边界) +- 单测推 P2-T11 批 E + +### 2026-05-25(晚 ②)— P2 启动 + recon 完成 +- 3 路 Explore subagent 并行 recon 输出(详见 [tasks/P2-trino-connector-migration.md §阶段日志](../tasks/P2-trino-connector-migration.md)) +- 关键修正:dashboard 反向 instanceof "0/2" 为过时数字,实测仅 1 处(PhysicalPlanTranslator:779);fe-connector-trino 模块 "70%" 在 SPI 表面层面其实更接近 95%,真缺只有 validateProperties / preCreateValidation / pushdown 三处 +- 13 task / 5 批次方案敲定,进入编码阶段 + +### 2026-05-24 +- 跟踪文件建立。70% 实现已就位,等 P0/P1 完成后启动 P2 整体推动。 diff --git a/plan-doc/decisions-log.md b/plan-doc/decisions-log.md new file mode 100644 index 00000000000000..0bb4bb7fa35a77 --- /dev/null +++ b/plan-doc/decisions-log.md @@ -0,0 +1,529 @@ +# 决策日志(ADR) + +> **Append-only**:新决策置顶;旧决策永不删除(即使被推翻,也只标"已废止"而不删除)。 +> 编号规则:`D-NNN` 三位数字,从 001 起单调递增,永不复用。 +> 历史决策 D1-D12(master plan §5)+ U1-U6(RFC §16.2)已迁入并映射到 D-001..D-018。 +> 与"偏差"的区别见 [README §3.1](./README.md)。 +> +> 每条决策模板见文末 §附录。 + +--- + +## 📋 索引 + +> 时间倒序;带 ✅ 表示生效中,❌ 表示已废止,🟡 表示待评审 + +| 编号 | 别名 | 简述 | 日期 | 状态 | +|---|---|---|---|---| +| D-073 | P6.6-C3b-core-impl-recon-and-uniqueId-carrier | **C3b-core impl 起步 recon(wf `wf_fa7057d5-39b` 解 O1-O4,O1/O2 verdict 全 upheld)+ 用户裁 ③ v3-lineage carrier=Option A(`ConnectorColumn` 加中立 `uniqueId`,连接器声明)**。详 design §11。 | 2026-06-25 | ✅ | +| D-072 | P6.6-C3b-core-design-and-commit-bridge | **P6.6-C3b-core 全量设计 + commit-bridge(2 对抗 recon wf:`wf_77a255c5-ef9` 6-slice 锚点核+2 verify 全 high / `wf_e9e5f1a7-00b` 4-slice commit-bridge + 主 session 亲核 classloader/executor-txn/sink-translator 全链 + 用户 AskUserQuestion 三裁)**。**用户三裁(2026-06-25)**:②=**Option A**(完整中立 SPI `getWritePartitioning`,非降级)/ Layer-3 commit-bridge=**现在就做** / 切分=**不拆**(plan-time+bridge 一口气 1 个 coherent commit,跨 session、green 才提交)。**决定性 recon(改写前提)**:①[CL-1] 连接器是真插件**子优先隔离 classloader**(`ConnectorPluginManager`→`ChildFirstClassLoader`,iceberg-core 打包进插件、`org.apache.iceberg` 非 parent-first)→ native `Table` 跨连接器→fe-core 必 CCE → **用户原「暴露 native 表给 fe-core legacy IcebergTransaction」物理不可行(推翻)**;②[CL-2] 连接器 `IcebergConnectorTransaction`(~1010 行)**已全建好** INSERT/OVERWRITE/DELETE/UPDATE/MERGE 提交(RowDelta/position-delete/冲突检测/V3 DV,legacy 忠实移植,连接器自有 live catalog);③[CL-3] fe-core 通用 row-level DML SPI 提交链路(`RowLevelDmlCommand.run:98-100` beginTransaction→applyWriteConstraint→finalizeSink→commit)**已存在且 dormant**(base `getConnectorTransactionOrNull` 默认 null);④[CL-4] INSERT post-flip 已是目标模型(`PluginDrivenInsertExecutor`+connector `planWrite`→`beginWrite` 载表)。**commit-bridge 架构 = Option (a)**:post-flip `visitPhysicalIcebergMergeSink/DeleteSink` **dual-mode** 路由到 `PluginDrivenTableSink`+连接器 `planWrite`(触发 beginWrite,连接器产 byte-identical thrift sink),DML executor 经连接器 `ConnectorTransaction`(`RowLevelDmlCommand` SPI commit),**legacy `IcebergTransaction`/`IcebergDeleteExecutor`/`IcebergMergeExecutor` 仅 pre-flip**;唯一真·跨 native 类型残留 = V3 rewritable-delete `Map>` 走 **iceberg-only seam**(`instanceof IcebergConnectorTransaction` downcast,不入中立 SPI)。**plan-time 工作集修正**(§9.5 line 全对但漏 ctor-param+import;`MergeSink:188` cast 冗余;唯一真 native 耦合=`buildInsertPartitionFields:271/273 getIcebergTable()`;executor→txn 层早已 ExternalTable 签名):① `handles:69` 泛化(模板 `allowInsertOverwrite:320-329`,连接器 `supportsDelete/Merge` 现成);② cast 放宽 + 新出向 carrier `ConnectorWritePartitionField`(`connector.api.write`,仿 `ConnectorWriteSortColumn`,非扩 inbound `ConnectorPartitionField`)+`getWritePartitioning` default-null + `getIcebergPartitioning` dual-mode helper;③ row-id 双注入(STRUCT 已能过 `ConnectorType.structOf`+converter,缺 `ConnectorColumn` visibility 标志 + 中立 format-version 信号 + ctx 中立重命名 + `PluginDrivenExternalTable.getFullSchema` 注入 + `IcebergRowIdInjector:159` guard)。工作分解 §10.5;开放项 §10.6(O1 合成列索引来源 / O2 V3 DeleteFile 收集源 post-flip `IcebergScanNode` 存否 / O3 getWritePartitioning plan-time / O4 format-version 中立信号)。**设计 ✅ DONE,实现待 fresh session**。设计 [`tasks/designs/P6.6-C3-ws-write-design.md` §10](./tasks/designs/P6.6-C3-ws-write-design.md) | 2026-06-25 | ✅ | +| D-071 | P6.6-C3b-pre-partition-columns-and-parallel-write | **P6.6-C3b 起步对抗 recon(6-slice wf `wf_feecba0f-854`,5/5 有效 slice + 主 session 亲核 2 前置/④b 死代码/② cast 全量)推翻 D1=ii 前提 + 用户 AskUserQuestion 2026-06-25 双裁 + C3b-pre TDD**。**① ④b 决策修订(D1=ii 前提推翻 → Option A)**:recon 实证 legacy iceberg 分区 INSERT「hash 无排序」是**死代码**——`PhysicalIcebergTableSink:124` 读 `targetTable.getPartitionNames()`,而 `IcebergExternalTable` **从不 override** 它(仅 `getPartitionColumns`/`getPartitionColumnNames`;唯一 override 者 `HMSExternalTable:701`)→ `TableIf.getPartitionNames():336-338` 默认空集 → hash 分支(125-142)不可达 → **runtime legacy iceberg INSERT(分区+非分区)恒走 `SINK_RANDOM_PARTITIONED`(:143)**。故 D1=ii「精确还原老 hash 无排序」= 还原从未执行的死代码意图。**用户裁 Option A(随机真 parity)**:iceberg 连接器仅加 `SUPPORTS_PARALLEL_WRITE` → `PhysicalConnectorTableSink.getRequirePhysicalProperties:195` 返 `SINK_RANDOM_PARTITIONED` = legacy runtime 逐字 parity;**不加新 capability/分支、不依赖 partition_columns 前置(解 §6 末 sequencing 耦合)、0 新 DV**。supersede 设计 §3.1 ④b + §6-D1。**② ③ 决策细化(D3=iii → ctx Option ii)**:post-flip row-id 注入双重失败〔(a) `IcebergNereidsUtils.IcebergRowIdInjector.visitLogicalFileScan:159` `instanceof IcebergExternalTable` guard 跳过;(b) base `getFullSchema()` 无 row-id 列(PluginDriven 不 override)〕;legacy 注入在 **`IcebergExternalTable.getFullSchema:292-303`**(**非** initSchema,doc 方法名漂移),条件 ctx = `ConnectContext.icebergRowIdTargetTableId`(long target-id,非 boolean);v3 row-lineage 无条件(独立 trigger)。**用户裁 Option ii(中立化重命名 ctx)**:ctx 字段/方法重命名为中立名 + 新通用注入按连接器「合成写列」能力(须经中立 SPI 透传完整 STRUCT 列定义,比 C2 classifyColumn 重)——留 C3b-core。**③ 前置确认**:前置-a CONFIRMED gap〔`IcebergConnectorMetadata.buildTableSchema` 不 emit 通用 `partition_columns` CSV(`PluginDrivenExternalTable.toSchemaCacheValue:212` 读的 key)→ post-flip `getPartitionColumns()` 空〕;前置-b CONFIRMED ok〔post-flip getType=PLUGIN→`BindRelation:653`→`computePluginDrivenOutput:235` from getFullSchema〕。drift:partition_columns producer 非「MaxCompute-only」,paimon 亦 emit(`PaimonConnectorMetadata:313`)。**④ ① 比 doc 简单**:唯一 live admit gate=`IcebergRowLevelDmlTransform.handles:69`;per-op 命令 guard 死;translator `:750` 已先于 `:790` 无需 ordering 改;真耦合=transform 4 处强转(:74/:90/:110/:166)。② cast 全量 ~30 站点见设计 §9.5。**C3b-pre 实现(连接器侧 dormant,遵铁律全中立)**:前置-a `buildTableSchema` emit `partition_columns`(legacy `IcebergUtils.loadTableSchemaCacheValue:1742-1751` 语义:current spec / **无 identity 过滤** / 源列名 `findField(sourceId).name()` lowercased / 无 dedupe;**非** `IcebergPartitionUtils.getIdentityPartitionColumns` all-specs+identity-only)+ ④b `IcebergConnector.getCapabilities` 加 `SUPPORTS_PARALLEL_WRITE`。**TDD + mutation 实证**:`IcebergConnectorMetadataTest` 26/0/0(+2 新:`getTableSchemaEmitsPartitionColumnsForIdentityPartition` 含 unpart-null+identity;`getTableSchemaEmitsNonIdentityPartitionSourceColumns` bucket(id)→"id" 守 no-identity-filter parity;**identity-only mutation → 非 identity test 红证判别**)·`IcebergConnectorTest` 6/0/0(+1 `declaresParallelWriteCapability`,RED 见证);全模块 553/0/0+1 live-skip(`IcebergLiveConnectivityTest` env-gated);import-gate PASS;`SPI_READY_TYPES` 未改(iceberg 仍不在);**0 新 DV**(dormant + Option A 真 parity)。**C3b-core(①②③ coupled,待续)**。设计 [`tasks/designs/P6.6-C3-ws-write-design.md` §9](./tasks/designs/P6.6-C3-ws-write-design.md) | 2026-06-25 | ✅ | +| D-070 | P6.6-C3a-insert-overwrite-and-write-branch | **P6.6-C3a(WS-WRITE ④ INSERT 能力)起步对抗 recon 推翻设计「④c=最小松 guard」+ 用户 AskUserQuestion 2026-06-25 裁「完整接通 @branch」+ TDD**。设计 §3.1 把 ④c 写分支当成「泛化 `InsertIntoTableCommand:460` guard」一行(D2=i)。recon(主 session 亲核写路径)证伪:**单松 guard 不够且危险**——翻闸后 iceberg INSERT 走通用 sink → `PluginDrivenInsertExecutor`/`PluginDrivenInsertCommandContext`,该通用链路**完全无 branch 字段**;连接器 `IcebergWritePlanProvider:197` 写死 `Optional.empty()`(注释自标 `DV-T06-branch`「generic write handle carries no branch」),而连接器 transaction 侧(`IcebergConnectorTransaction:220-228`)**已**会消费+校验 branch 但永收 empty → 只松 guard 会让 `INSERT INTO t@branch` 翻闸后**静默写到 default ref**(比明确报错更糟,违 fail-loud)。**对比 ④a(INSERT OVERWRITE)**:overwrite flag 在通用链路**完整接通**(ctx.setOverwrite→handle→`IcebergWritePlanProvider:192` promote INSERT→OVERWRITE→`IcebergConnectorTransaction` ReplacePartitions/OverwriteFiles,T06 已建)→ `supportsInsertOverwrite()=true` 是**真实能力**。**用户裁「完整接通 @branch」(非推迟/非只松 guard)= 闭合 DV-T06-branch**。**机制(全中立,遵「fe-core 不得 if(iceberg)」铁律)**:**④a** `IcebergConnectorMetadata.supportsInsertOverwrite()=true`(fe-core `allowInsertOverwrite:316-338` 已 ready,仅缺连接器声明)。**④c/@branch(4 层)**:新中立 SPI `ConnectorWriteOps.supportsWriteBranch()` default false + `ConnectorWriteHandle.getBranchName()` default empty;iceberg `supportsWriteBranch()=true` + `IcebergWritePlanProvider:197` 读 `handle.getBranchName()`(连接器 transaction 侧已 ready);fe-core `PluginDrivenInsertCommandContext.branchName` 字段 + `PluginDrivenTableSink.bindDataSink` 透传到 handle + 两处 guard(`InsertIntoTableCommand:460` 新 helper `connectorSupportsWriteBranch` / `InsertOverwriteTableCommand:222` 新 helper `pluginConnectorSupportsWriteBranch`,均仿 `pluginConnectorSupportsInsertOverwrite` 形)泛化为中立能力探测 + 两处插入站点(`InsertIntoTableCommand:576+` / `InsertOverwriteTableCommand:424+`)`branchName.ifPresent→pluginCtx.setBranchName`。**dormant + 0 新 DV**:pre-flip iceberg 走 legacy `PhysicalIcebergTableSink` → guard 短路(`instanceof PhysicalIcebergTableSink`=true 第一项即真)→ 对 live 连接器(含 paimon/jdbc=supportsWriteBranch 默认 false 仍拒)零行为变更;branch 是通用「命名分支」概念(paimon 亦有),中立。**TDD + mutation 实证**:连接器 `IcebergConnectorMetadataTest` 24/0/0(`declaresInsertOverwriteCapability`+`declaresWriteBranchCapability`;MUT override→false 各红)·`IcebergWritePlanProviderTest` 23/0/0(`planWriteThreadsBranchFromHandleToTransaction`:未知 branch→beginWrite「not founded」DorisConnectorException;MUT-A `:197`→`Optional.empty()` 红=DV-T06-branch 检测);SPI `ConnectorWriteHandleTest` 4/0/0(`branchNameDefaultsToEmpty`);fe-core `PluginDrivenTableSinkTest` 4/0/0(`bindDataSinkThreadsBranchNameToHandle`;MUT-C drop 透传 红)·`InsertOverwriteTableCommandTest` 6/0/0(`pluginConnectorSupportsWriteBranch` true/false/non-plugin)·`InsertIntoTableCommandTest` 5/0/0(`connectorSupportsWriteBranch` true/false/non-plugin);import-gate PASS;`SPI_READY_TYPES` 未改(iceberg 仍不在)。**closes DV-T06-branch**(deviations-log 登记)。设计 [`tasks/designs/P6.6-C3-ws-write-design.md`](./tasks/designs/P6.6-C3-ws-write-design.md) | 2026-06-25 | ✅ | +| D-069 | P6.6-C2-ws-synth-read-classify-spi | **P6.6-C2(WS-SYNTH-READ)起步对抗 recon 推翻/重构 RFC §6.WS-SYNTH-READ + 用户 AskUserQuestion 2026-06-25 双裁 + TDD**。recon(独立读码 + 8-agent 对抗 wf `wf_9bf8730b-05b`/655k token,3 adversarial verdict 全 confirmed)裁三事:**①BE「`iceberg_reader.cpp` 需 add_not_exist_children、否则 DCHECK 崩」= 过时/错**——`iceberg_reader.cpp:162-208`(parquet)/`:444-489`(orc) **已**完整处理 SYNTHESIZED〔`__DORIS_ICEBERG_ROWID_COL__`→`_fill_iceberg_row_id` / `__DORIS_GLOBAL_ROWID_COL__` 前缀→`fill_topn_row_id`〕+ GENERATED〔row-lineage〕,合成列 `continue` 在 `column_names.push_back` 前→永不触达 `table_schema_change_helper.h:140-142` 的 `get_children_node` DCHECK;reader 由 `table_format_type=="iceberg"`(`file_scanner.cpp:1355/1446`)选、**与 FE 节点类无关**(`PluginDrivenScanNode.TABLE_FORMAT_TYPE="plugin_driven"` 是死常量、真值 per-split 走 `IcebergScanRange.getTableFormatType()`=字面 `"iceberg"`)→**C2 零 BE 改、零 `paimon_reader.cpp` 改**。**②D7 被 RFC 问错方向**——paimon **不触达** GLOBAL_ROWID:闸在**优化器层** `MaterializeProbeVisitor.SUPPORT_RELATION_TYPES`(`:58-63` **精确类**白名单 `{OlapTable,HiveTable,IcebergExternalTable(legacy),HMSExternalTable}`,无 `isInstance`),paimon=`PluginDrivenMvccExternalTable` 不在内→`LazyMaterializeTopN:150` 在建 GLOBAL_ROWID 列前 bail(无 paimon lazy-mat 回归测佐证)。故 classifyColumn override 对 paimon 怎样都安全(V1/V3 confirmed)。**③新挖 RFC 漏的两处翻闸前置项**(均翻闸前 no-op/legacy-only):**[GAP-A]** 翻闸后 iceberg 表类→`PluginDrivenMvccExternalTable`(与 paimon **同类**)掉出 allowlist→iceberg lazy-top-N 静默失效,须 capability/engine 判别(**非** class,且该文件今天即 `import IcebergExternalTable` 的 fe-core iceberg 泄漏宜一并去)→**用户裁登记入 C5**;**[GAP-B]** 隐藏列注入(`ICEBERG_ROWID`+v3 row-lineage 现由 legacy `IcebergExternalTable.initSchema:297-301` 注入,翻闸后 `PluginDrivenExternalTable.initSchema:172` 仅从连接器 native `getTableSchema`→`parseSchema` 建、**不注入**)→**用户裁随翻闸追踪**(连接器 `getTableSchema` 注入,rowid 条件注入待设计)。**Q1(用户裁「GLOBAL_ROWID 留 fe-core」)**:GLOBAL_ROWID 是 Doris 全局 lazy-mat 机制(Hive/TVF 已各自判),fe-core 判之非 `if(iceberg)`;**Q2(用户裁「随翻闸追踪 GAP-B」)**。**机制(classifyColumn SPI 化,零 fe-core iceberg 知识,遵用户「fe-core 不得 `if(iceberg)`」原则——原 RFC「移植三分支进 `PluginDrivenScanNode`」会引 `import IcebergUtils`=违此原则)**:新中立 `ConnectorColumnCategory{DEFAULT,SYNTHESIZED,GENERATED}` + `ConnectorScanPlanProvider.classifyColumn(name)` default DEFAULT(仿 `supportsSystemTableTimeTravel` 范式);fe-core `PluginDrivenScanNode.classifyColumn` 判 `startsWith(GLOBAL_ROWID_COL)`→SYNTHESIZED + 委派 `classifyColumnByConnector` seam(包私+可覆写,仿 `sysTableSupportsTimeTravel`);`IcebergScanPlanProvider.classifyColumn` override:`__DORIS_ICEBERG_ROWID_COL__`→SYNTHESIZED、`_row_id`/`_last_updated_sequence_number`→GENERATED、else DEFAULT(连接器禁 import fe-core→本地字面量,fe-core contract UT pin `Column.ICEBERG_ROWID_COL`/`IcebergUtils` 常量值)。**对 live 连接器零行为变更**(paimon/jdbc/es/mc/trino 不产生 GLOBAL_ROWID + 用 SPI default DEFAULT→`super()` 同旧)。**TDD + mutation-check 实证**:连接器 `IcebergScanPlanProviderClassifyColumnTest` 4/0/0(Mut M4 ICEBERG_ROWID→DEFAULT + M5 row-lineage→DEFAULT 双红);fe-core `PluginDrivenScanNodeClassifyColumnTest` 6/0/0(Mut M1 `startsWith→equals` + M2/M3 丢映射→globalRowId/connectorSynthesized/connectorGenerated 三红);回归 `PluginDrivenScanNode*Test` 63/0/0 + `IcebergScanPlanProviderTest` 67/0/0 + import-gate PASS。C2 = dormant prep(GLOBAL_ROWID 待 GAP-A/C5、ICEBERG_ROWID/row-lineage 待 GAP-B;非投机,翻闸必需)。设计 [`tasks/designs/P6.6-C2-ws-synth-read-design.md`](./tasks/designs/P6.6-C2-ws-synth-read-design.md) | 2026-06-25 | ✅ | +| D-068 | P6.6-C1-ws-pin-rescope-and-sys-pin-threading | **P6.6-C1(WS-PIN)起步 recon 推翻 RFC 签字 D4/D5 → 用户 AskUserQuestion 2026-06-25 双裁 + TDD(实现 [D-067] 所记「休眠翻闸接线」follow-up)**。起步 recon(独立读码 + 6-agent 对抗 wf `wf_b1bd42e4-675`,526k token)对照真实代码证伪 RFC §6.WS-PIN 两块。**Q1(D4 修正)= 普通表 pin reorder 移出 C1 → P6.7**:iceberg 普通表时间旅行**现已正确**——连接器 `IcebergScanPlanProvider:705-714`(T06/T07 Option A)在 handle 带 pin 时用**完整 pinned schema** 建 field-id 字典,绕开 latest-schema `columns`,故「BE field-id DCHECK 崩」**非 live 缺口**,reorder 只是把 workaround 正确性搬进 getColumnHandles 的重构;且**全局**把 `pinMvccSnapshot` 提到 `buildColumnHandles` 之前会**打破 paimon `@branch` 读**(对抗 verdict=breaks:paimon `getColumnHandles` 经 `PaimonTableResolver` 对 branch pin 敏感,`withBranch` 清 transient Table→解析 branch schema 静默丢列;snapshot-id/tag/timestamp 走 `withScanOptions` 保 base Table 不受影响)。**Q2(D5 反转)= sys 时间旅行改用 `getQueryTableSnapshot()` 线程,非 `implements MvccTable`**:D5 Option a 因 `MvccTableInfo` 按表名 key(pin 存 base 表 `tbl` key、sys 查 `tbl$snapshots` key **永不命中**)+ `BindRelation:571-574` 对 sys early-return 致 `loadSnapshots(sysTable)` **从不被调用**——既不够也方向偏。真缺口 = query 的 `FOR TIME AS OF`/`@branch`/`@tag` 从不进连接器 handle(`resolveConnectorTableHandle` 取未 pin base handle、`getSysTableHandle:398-400` 仅从 base 继承)→ 静默读 latest(相对 legacy `IcebergScanNode.createTableScan:569` 回归)。**机制(唯一改点 fe-core `PluginDrivenScanNode.pinMvccSnapshot`)**:context 无 snapshot(sys 恒空)时 fallback `resolveSysTableSnapshotPin()`——委派**源表** `MvccTable.loadSnapshot(getQueryTableSnapshot, getScanParams)`(复用普通表全套 TableSnapshot→ConnectorTimeTravelSpec→resolveTimeTravel→ConnectorMvccSnapshot 转换 + not-found 文案 + 互斥校验),再经既有 `applyMvccSnapshotPin` 落到 sys handle(`withSnapshot` 保 sysTableName)。**零 SPI / 零 MvccTable-on-sys / 零 StatementContext 改**;三处 pin 点(getSplits/startSplit/getOrLoadPropertiesResult,修正 RFC「两处」)经 pinMvccSnapshot 自动覆盖;普通表零影响(instanceof sys 严格门)。链路已实证:`BindRelation:467-474` sys LogicalFileScan 携 snapshot → `PhysicalPlanTranslator:802-805` set 到通用节点 → guard([D-067] capability=true)放行 → **本修补 pin** → 连接器 `planSystemTableScan→buildScan:338-342` `useRef/useSnapshot`。连接器侧 0 改(已 ready)。**TDD + mutation-check 实证**:新 `PluginDrivenScanNodeSysTablePinTest` 5 UT(全 `PluginDrivenScanNode*Test` 家族绿);Mut-A fallback→empty → T1(FOR TIME AS OF)+T2(@branch/@tag)双红;Mut-B 去「both-null 短路」→ T3(verifyNoInteractions plain-scan)红。设计 [`tasks/designs/P6.6-C1-ws-pin-design.md`](./tasks/designs/P6.6-C1-ws-pin-design.md) | 2026-06-25 | ✅ | +| D-067 | P6.5-T07-guard-capability-and-hms-case-fix | **P6.5-T07 对抗审计揭出 2 项 + 用户 AskUserQuestion 2026-06-25 双签字「现修」(非登记 DV)**。**Q1(决策 A)= sys 时间旅行 guard 现修**:共享 fe-core `PluginDrivenScanNode.checkSysTableScanConstraints`(P5 paimon `38e7140ce56` 引入,"Mirrors PaimonScanNode.getProcessedTable")无条件拒绝**任何** `PluginDrivenSysExternalTable` 的 `FOR TIME AS OF`(snapshot)+ `@branch/@tag/@incr`(scan-params)。对 paimon 正确(binlog/audit_log 无时间点语义),但**对 iceberg 错**——legacy `IcebergScanNode.createTableScan:569` 对 sys 表同 honor `useRef/useSnapshot`(无 isSystemTable gate),且连接器 T02 `forSystemTable` **保留** + T05 `buildScan` **honor** pin(偏差①)→ 翻闸后 guard 先抛、连接器保留的 pin **dead-on-arrival**,相对 legacy 是**回归**(DV-038/041 同族「paimon 模板不适配 iceberg 共享 seam」)。**修 = 连接器-capability-aware**:新 SPI `ConnectorScanPlanProvider.supportsSystemTableTimeTravel()` 默认 false(paimon 保留拒绝、零回归);`IcebergScanPlanProvider` override → true;guard 改为 capability=true 时放行 time-travel + branch/tag,**`@incr` 对所有连接器仍拒**(合成元数据表上 incremental 无定义;legacy 静默忽略,guard fail-loud 更安全)。**⚠️ guard 修仅移除主动 BLOCK**——sys 时间旅行**完整 e2e** 还需 query `getQueryTableSnapshot()/getScanParams()`→连接器 handle pin 的**休眠翻闸接线**(DV-041「休眠-至-翻闸激活集」族)+ P6.8 docker 兜底。**纠正 HANDOFF**:偏差①「parity-保留、非 DV」分类**错**(共享 guard 让保留的 pin 不可达),现经 guard 修消解。**Q2(决策 B)= hms 分叉大小写现修**:`buildTableDescriptor` 的 `TYPE_HMS.equals(原始值)` 大小写敏感(读原始用户 map),legacy 比归一化常量「hms」(工厂对 type `toLowerCase` dispatch)→ `iceberg.catalog.type="HMS"` 得 ICEBERG_TABLE vs legacy HIVE_TABLE(FE 描述符/EXPLAIN 分叉)。修 = `equalsIgnoreCase`(一行)+ 大写 UT。**TDD + mutation-check 实证**:guard 测 7/0/0(Mut-A `=false`→AllowsTimeTravel/AllowsBranchTag 双红;Mut-B 去 @incr 子句→StillRejectsIncr 红);连接器 541/0/1(hms Mut `equalsIgnoreCase→equals`→大写 UT 红)。详 [tasks/P6 §P6.5-T07 实现记录](./tasks/P6-iceberg-migration.md) | 2026-06-25 | ✅ | +| D-066 | P6.5-T06-descriptor-fork-and-fe-core-parity | **P6.5-T06 thrift 描述符复现 fork + fe-core engine/SHOW-CREATE 现修(用户 AskUserQuestion 2026-06-25 双签字)**。**Q1 = 复现 legacy fork**:连接器 `IcebergConnectorMetadata.buildTableDescriptor` 覆写既有 SPI 钩子(`ConnectorTableOps:187-192` 默认返 null→fe-core 回退 `SCHEMA_TABLE`),按 `iceberg.catalog.type=="hms"` → `HIVE_TABLE`+`THiveTable` 否则 `ICEBERG_TABLE`+`TIcebergTable`,镜像 legacy `IcebergSysExternalTable.toThrift:116-131`。SPI 签名无 handle → 单覆写覆盖 base+sys(legacy base/sys 同 fork),仿 paimon;**顺带闭合 P6.1/P6.2 遗留 base 表 SCHEMA_TABLE 缺口**。备选「单一固定类型(仿 paimon)」「暂不做+登记 DV」被否——recon G 证 BE 对描述符表类型无感(sys JNI 读只看 scan-range),故复现纯为 FE EXPLAIN/profile parity,且连接器易得 catalog type。**Q2 = fe-core 现修**(非 DV 推迟):`PluginDrivenExternalTable.getEngine/getEngineTableTypeName` 加 `case "iceberg"`(→"iceberg" 非 "Plugin",paimon 安全)+ `ShowCreateTableCommand.validate()` authTableName 解包 `PluginDrivenSysExternalTable`→source。**recon 纠 HANDOFF 框定**:SHOW CREATE **输出**已由 `Env.getDdlStmt:4915` + `UserAuthentication:60` 解包处理,仅 authTableName priv-check 残留(且 paimon 现存同隐患→F2 顺修,live 行为变更但严格更宽松同向)。**含 [D-065] 收口**:`getScanNodeProperties` 加 `isSystemTable()` guard 跳 `path_partition_keys`+`schema_evolution` dict(修 unpinned-sys `encodeSchemaEvolutionProp` 潜伏崩溃 `buildCurrentSchema:194`)。**⚠️ F2 无隔离 UT**(`validate()` 依赖全局单例 `Env`/`ConnectContext`/`AccessManager`,无既有 harness)→ 编译 + live `UserAuthentication`/`Env` 一致性 + P6.8 e2e 兜底。**非 DV**(fork 复现 + engine/show-create 现修皆消除偏差而非引入)。详 [tasks/P6 §P6.5-T06 实现记录](./tasks/P6-iceberg-migration.md) | 2026-06-25 | ✅ | +| D-065 | P6.5-T05-scan-split-scope | **P6.5-T05 sys split 路范围 + 显式 FORMAT_JNI(实现 recon 裁定,无需用户签字——决策 A/B/[D-063]/[D-064] 已定 §6 well-specified)**:连接器 `IcebergScanPlanProvider`/`IcebergScanRange` 加 sys split 发射——`planScanInternal` sys guard → `planSystemTableScan`〔`resolveSysTable`〔`executeAuthenticated` 内 `MetadataTableUtils.createMetadataTableInstance`,镜像 T04 `loadSysTable`〕→ **复用** `buildScan`〔time-travel useRef/useSnapshot + 谓词,镜像 legacy `createTableScan`〕→ `scan.planFiles()` → `SerializationUtil.serializeToBase64(FileScanTask)` → `IcebergScanRange{path=/dummyPath, serializedSplit}`〕;carrier `populateRangeParams` sys 分支镜像 legacy `setIcebergParams` 早返〔**仅** `serialized_split`+`FORMAT_JNI`+`table_level_row_count=-1`〕。**两裁定**:①T05 **仅** scan split 路(§6 唯一全新一块);`getScanNodeProperties` 对 sys handle 的 `path_partition_keys`/`schema_evolution` dict(现从 base 表构建、对 sys 不正确但 dormant)**推迟 T06**——设计 §10 把 thrift 描述符/DESCRIBE/SHOW parity 归 T06,避免 T05 越界。②`populateRangeParams` sys 分支**显式** `setFormatType(FORMAT_JNI)`(不依赖 generic node `jni` 默认)= 忠实 legacy `setIcebergParams:290` + 使 carrier 可单测。**recon 纠设计 1 处**:recon agent 误称 legacy sys 表「无 time-travel」,直读 `IcebergScanNode.createTableScan():569,579-583` 实证 legacy 对 sys 表同 honor useSnapshot/useRef(偏差①正确)。**关键发现(非 DV,legacy parity)**:`$snapshots`/`$history` 忽略 `useSnapshot`(恒列当前 metadata 全量;legacy 同被 `SnapshotsTable` 忽略),**`$files`** 才时间旅行可观测(time-travel UT 故用之)。**非 DV**(time-travel/全 JNI/byte-shape 皆 legacy parity)。**⚠️ 潜伏**:serialized `FileScanTask` 字节须兼容 BE `IcebergSysTableJniScanner`——FE deserialize-round-trip UT 已同进程核可消费,跨版本/classloader P6.8 e2e 兜底,T07 预登记潜伏 DV。详 [tasks/P6 §P6.5-T05 实现记录](./tasks/P6-iceberg-migration.md) | 2026-06-24 | ✅ | +| D-063 | P6.5-T03-lazy-syshandle | **P6.5-T03 `getSysTableHandle` LAZY 纯解析(实现 recon 裁定,无需用户签字——HANDOFF 明示「懒 vs eager 由实现 recon 定」)**:iceberg `getSysTableHandle(session, baseHandle, sysName)` 仅 `isSupportedSysTable` guard〔null/unknown/`position_deletes`→`Optional.empty`〕→ `IcebergTableHandle.forSystemTable(base.db, base.table, sys, base.snapshotId, base.ref, base.schemaId)`〔保留 snapshot pin,偏差①〕,**不加载 base 表、不在 `executeAuthenticated` 内 build metadata-table**(零 catalog 往返、零 auth scope;UT `getSysTableHandleDoesNotTouchCatalogSeam` pin 之)。设计 §5/§8 + HANDOFF 倾向 eager(T03 内 build + seam-identity UT),但三事实裁 LAZY:①legacy `IcebergSysExternalTable.getSysIcebergTable():83-97` **懒构** metadata-table,resolution `IcebergSysTable.createSysExternalTable` **不加载**;②iceberg `IcebergTableHandle` **无** transient SDK Table(≠ paimon `setPaimonTable`)→ eager build 结果无处存、必被 T04/T05 重建 + 多一次 legacy 没有的远程 `loadTable`/UGI 往返(pre-flip 性能回归);③fe-core `PluginDrivenSysExternalTable.resolveConnectorTableHandle` 先调 `getTableHandle`(base 存在性已预检)。设计 §5 本身列 lazy 为可选项(「metadata-table 构建留 getTableSchema/scan(懒)」),HANDOFF 明授权实现裁定。**后果**:metadata-table build + seam-identity UT 移 P6.5-T04(= legacy `getOrCreateSchemaCacheValue` 真 build 点,parity 更忠实);决策 B「无新 seam(复用 `loadTable` + 连接器内 `MetadataTableUtils`)」不变,仅落点从 T03→T04。**非 DV**(lazy 比 eager 更贴 legacy,无 pre-flip 行为偏差)。详 [tasks/P6 §P6.5-T03 实现记录](./tasks/P6-iceberg-migration.md) | 2026-06-24 | ✅ | +| D-064 | P6.5-T04-getschema-scope | **P6.5-T04 `getTableSchema` sys 分支范围 + @snapshot sys 行为(实现 recon 裁定,HANDOFF 预授权「可能并入 T04 或留 T05」)**:T04 在 `IcebergConnectorMetadata` 加 3 处 sys 分支 + 1 helper `loadSysTable`〔`executeAuthenticated` 内 `catalogOps.loadTable(base)` + `MetadataTableUtils.createMetadataTableInstance(base, MetadataTableType.from(sysName))`,决策 B 无新 seam〔偏差③〕;schema 经既有 `buildTableSchema`→`parseSchema` 透 `enable.mapping.*`〔偏差⑤,已核实 `parseSchema` 从 `properties` 读 flag〕〕:①2-arg `getTableSchema`〔sys→meta-table schema〕②**3-arg `getTableSchema(@snapshot)` sys 短路**〔metadata-table schema 与快照无关——legacy 无 schema-at-snapshot for sys 表;时间旅行 pin 选 SCAN 行非 schema,落 T05〕③**`getColumnHandles` sys 分支**〔与 getTableSchema 同潜伏 bug:sys handle 必返 meta-table 列非 base 列,供通用 `PluginDrivenScanNode.buildColumnHandles` 按名解析;共享 `loadSysTable`〕。②③ 设计 §10 列「可能并入 T04 或留 T05」,本轮裁**并入 T04**〔同 helper、同潜伏 bug、paimon 模板亦配对 schema+columns,避免 T04↔T05 间留半修〕。**非 DV**(meta-table schema 不随快照变 = legacy parity;getColumnHandles 返 meta 列 = 正确性修非行为偏差)。详 [tasks/P6 §P6.5-T04 实现记录](./tasks/P6-iceberg-migration.md) | 2026-06-24 | ✅ | +| D-062 | P6.4-T01-procedure-spi | **P6.4 procedures `ConnectorProcedureOps` SPI 三签字(用户签字 2026-06-24,AskUserQuestion ×2 + recon `wf_cb757c7c-708`)**:把 iceberg `ALTER TABLE t EXECUTE ` 9 action 经新 `ConnectorProcedureOps` SPI(E2)收进连接器;iceberg 不入 `SPI_READY_TYPES`、dormant-pre-flip(pre-flip 表是 `IcebergExternalTable` 非 PluginDriven → 连接器 procedure 路 dormant,live 仍走 legacy 直到 P6.6,镜像 P6.3 写 dormant)。**Q1 = R-A 分相位**:P6.4a 先发 8 pure-SDK(`rollback_to_snapshot`/`rollback_to_timestamp`/`set_current_snapshot`/`cherrypick_snapshot`/`fast_forward`/`publish_changes`/`expire_snapshots`/`rewrite_manifests`);P6.4b `rewrite_data_files`(长杆=分布式 INSERT-SELECT 写)混合切——规划半进连接器、事务半经 `IcebergConnectorTransaction` 加 `WriteOperation.REWRITE` 变体(**净 0 新事务 verb**,因 BE→FE commit 通道 P6.3 已统一,实证 `IcebergConnectorTransaction.java:114/163/219`)、scan 从 pinned snapshot+WHERE 重规划(侧信道 `IcebergScanNode:498` 翻闸后端到端死,**非** P6.2 carrier 类比)、bind 改 `UnboundConnectorTableSink`、执行半(`StmtExecutor`/`TransientTaskManager`/nereids)留 fe-core;超预算回退 R-B。**Q2 = S-1 扁平 `execute(session,table,name,props,where,partitions)→ConnectorProcedureResult{schema,rows}`**(非 S-2 注册表/非 Trino CALL):引擎保 `PrivPredicate.ALTER`+`CommonResultSet` 包装+`logRefreshTable`(已核 flip-safe),连接器拥 procedure 体;`executionMode` 仅作 P6.4b 接线判据增量预留、不进 S-1 接口。**§4 = 4-A 连接器自包含 arg 校验**(**冲突更正**:`NamedArguments`/`ArgumentParsers` 在 `org.apache.doris.common`,连接器 import-gate 明禁 → 原"引擎保 NamedArguments"不成立 → per-arg 校验落连接器,逐字 port error 串、TZ 用 P6.2 `IcebergTimeUtils` alias-map,**T08 byte-parity UT 硬门**兜 error-string parity;翻闸后 fe-core 0 iceberg-arg 知识=干净切)。recon = [`research/p6.4-iceberg-procedures-recon.md`](../research/p6.4-iceberg-procedures-recon.md)(10 reader+critic,3 源码核实更正:instanceof 3+11 非 14 / `WriteOperation.REWRITE` 非 4 新 verb / FileScanTask 非 P6.2 carrier);设计 = [`designs/P6.4-T01-procedure-spi-design.md`](./tasks/designs/P6.4-T01-procedure-spi-design.md)。bug-for-bug 默认逐字保 parity(`publish_changes` STRING+`"null"`/`fast_forward` 反序/`rewritten_bytes_count` INT-vs-long 溢出/死 output-spec-id)。下一 = T02 SPI 骨架 | 2026-06-24 | ✅ | +| D-061 | P6.3-T05/T07-boundary | **O5-2 冲突检测接缝拆「连接器消费半 = T05」+「fe-core 生产半 = T07」(用户签字 2026-06-23,AskUserQuestion)**:RFC §5.4 的 O5-2 端到端两半——(A) 连接器消费侧 = 新 SPI 中立类型 `ConnectorPredicate` + `ConnectorTransaction.applyWriteConstraint(ConnectorPredicate)` default-no-op + `IcebergConnectorTransaction.applyWriteConstraint` override(暂存中立谓词,commit 时经 P6.2-T02 `IcebergPredicateConverter` 惰性转 iceberg expr,与 identity-分区 filter AND 合并喂 `rowDelta.conflictDetectionFilter`);(B) fe-core 生产侧 = 在 analyzed plan 上抽 slot-origin==目标表的 target-only 合取(排 `$row_id`/metadata/join 列)→ 中性 `ConnectorPredicate` + 在 DML 命令里调 `applyWriteConstraint`。**裁定 = (A) 留 T05、(B) 挪 T07**。**理由**:(B) 唯一产品消费者是 T07 通用 `RowLevelDmlCommand` 壳(RFC §5.4 原文把 extraction 写在 `RowLevelDmlCommand` 内;数据流 line163;task 表 T07 行明列 conflict-filter plumbing),在 T05 做 (B) → fe-core 留「只有单测无产品消费者」悬空件(违 Rule 2)+ 需 nereids analyzed-plan 测试夹具(连接器禁 import nereids,测不到);(A) 现在就能 InMemoryCatalog 直测、独立绿。**同 T01→T03 把 0-消费者 SPI 载体(`ConnectorWriteHandle.writeOperation`)挪到首消费者 task 的先例**。T05 仍**定义** SPI 接缝(`ConnectorPredicate`+`applyWriteConstraint`)供 T07 直接调;连接器 override 现期仅 UT 消费(同 T04 整个事务类现期仅 UT 消费,gate-closed 无产品调用方,无回归)。`ConnectorPredicate` = 薄不可变 wrapper 包既有 `ConnectorExpression`(非新表示,复用 scan 下推中立形,named seam 解耦 write-constraint 与 scan-pushdown 演进)。详 [tasks/P6 §P6.3-T05 实现记录](./tasks/P6-iceberg-migration.md) + [designs/P6.3-T05](./tasks/designs/P6.3-T05-iceberg-commit-validation-suite-o5-design.md) §2.1/§3 | 2026-06-23 | ✅ | +| D-060 | P6.1-T04-pkg | **P6-T04 iceberg HMS/DLF 依赖闭包 = 复用 `hive-catalog-shade`(用户签字 2026-06-22,AskUserQuestion)+ 修正 D-059 的「iceberg-hive-metastore」/「aliyun DLF SDK」误述**:2-agent code-grounded recon(unzip 实证)证 ① repo **无** `iceberg-hive-metastore` artifact——`org.apache.iceberg.hive.HiveCatalog`(hms flavor)+ 反射加载的 `com.aliyun.datalake.metastore.hive2.ProxyMetaStoreClient`(dlf flavor)均**捆在** `org.apache.doris:hive-catalog-shade`(127MB fat jar,thrift relocate 到 `shade.doris.hive.org.apache.thrift`,内含 iceberg 1.10.1 + aliyun DLF SDK 2266 类)内,= fe-connector-hive/hms 同款;② 无独立 aliyun DLF SDK 可加。**用户在「复用 hive-catalog-shade(合 convention)」vs「专建精简 iceberg-hive-shade(仿 paimon-hive-shade)」vs「推迟」三选项中选复用**。**T04 实改**(`fe-connector-iceberg/pom.xml`):+ `fe-connector-metastore-spi`(Q2=B 复用 HMS/DLF conf)+ `hive-catalog-shade`(hms/dlf)+ AWS SDK v2 child-first 集(s3/glue/sts/s3tables/s3-transfer-manager/sdk-core/url-connection-client/aws-json-protocol/protocol-core,glue/sts 排 apache-client)+ `s3-tables-catalog-for-iceberg`(s3tables flavor);`fe/pom.xml` + s3tables-catalog dM;`plugin-zip.xml` + `fe-thrift`/`libthrift` 排除(仿 fe-connector-hive)。**验证**:36 UT 绿 + checkstyle 0 + import-gate 0 + `dependency:tree` iceberg-core 恰 1(1.10.1)+ **assembled plugin-zip 实查**(143 jar:全 AWS SDK + 全 iceberg jar 皆 1.10.1 无 skew + libthrift 缺席 + hadoop 仅 3.4.2)+ `SPI_READY_TYPES` iceberg 缺席(零行为变更)。**残留风险(→P6.6 docker 真闸)**:shade 内含 iceberg 1.10.1 与直接 iceberg-core 在 child-first loader 共存(版本相同→预期 benign,fe-connector-hive 同款已上线);glue 显式-AK 凭据 provider 类 `com.amazonaws.glue.catalog.credentials.*` 来源未定(→T05 glue wiring 核)。详 [tasks/P6 §P6-T04](./tasks/P6-iceberg-migration.md) | 2026-06-22 | ✅ | +| D-059 | P6.1-scope | **P6.1 iceberg 元数据 recon 两个 scope 决策(用户签字,2026-06-21)**:基于 7-agent code-grounded recon(`research/p6.1-iceberg-metadata-recon.md`,主线已 firsthand 抽验 3 处 load-bearing 断言)+ 10-task 拆解(`tasks/P6-iceberg-migration.md` §P6.1)。**Q1 = DLF port-now read-only**:O1 解析为 PORT(repo grep `iceberg-aliyun`=∅,`DLFCatalog` 是 Doris 自定义 `HiveCompatibleCatalog` 子类无上游等价,骨架已硬编码 port 目标);P6.1 港 4 DLF 文件 + `HiveCompatibleCatalog` 超类进 `connector.iceberg.dlf`,dlf flavor 可实例化+读,`DLFTableOperations` 写方法 dormant、`IcebergDLFExternalCatalog` DDL-policy 留 fe-core 待写阶段,vendored `ProxyMetaStoreClient` 须入 plugin-zip 闭包(P6-T07)。**Q2 = 扩 metastore-spi 加 iceberg provider**(**非推荐项,用户主动选**):metastore-spi 现有 hms/dlf/filesystem/jdbc/rest provider 是 paimon-specific(`paimon.rest.*` key),无 glue/s3tables;用户选**扩 `fe-connector-metastore-spi` 加 iceberg-flavored REST/glue/s3tables provider**(vs 在连接器内 re-derive)⇒ 减连接器重复但**耦合共享 metastore-spi 到 iceberg + 跨入 metastore-storage-refactor 子线**(影响 O2 vended-credentials 设计)。⚠️ T05/T06/T07 实现前须对 `MetaStoreProviders.bind` 注册机制单独 mini-recon。**已采推荐默认(未单列问)**:s3tables/glue → 加 `fe/pom.xml` dependencyManagement;结构 seam(`executeAuthenticated`+thread-context-CL pin)P6.1 即纳入(虽 P6.6 docker 前不可 live-test)。**T01/T02/T03/T08 与此二决策无关**,先行实现 | 2026-06-21 | ✅ | +| D-058 | P6-plan | **P6 iceberg 迁移阶段拆分 = 方案 A / 8 阶段 / 单一翻闸在末(用户签字,2026-06-21)**:P6 = 把 fe-core `datasource/iceberg/`(74 文件) + `transaction/IcebergTransactionManager` + planner sinks + nereids 写命令完整迁入 `fe-connector-iceberg`,**先实现完整能力(P6.1–P6.5)→ P6.6 一次性翻闸 → P6.7 删 legacy → P6.8 回归**。翻闸**全有或全无**(`CatalogFactory:104-113`:加入 `SPI_READY_TYPES` 后 scan/write/MVCC/sys-table 全走连接器、seam 无 legacy 回退)⇒ 不做中途/混合翻闸。8 阶段:P6.1 元数据读+7 flavor(港 DLF 4-file 子树+wire S3Tables SDK);P6.2 scan+MVCC+cache+新 `ConnectorCredentials` SPI(E6);P6.3 写路径(先写 `06-iceberg-write-path-rfc.md`+PMC 评审→事务/DML SPI/planner `PhysicalConnectorTableSink`);P6.4 actions(新 `ConnectorProcedureOps` SPI E2+10 action);P6.5 sys-table(复用 E7)+元数据列;P6.6 翻闸(`SPI_READY_TYPES`+GSON compat+SHOW restore);P6.7 删 74 文件+清~49 反向 instanceof;P6.8 live 回归。3 缺失 SPI 各折进首消费阶段(paimon E5/E7/E10 成功路径,避无消费者设计 SPI)。**与 master plan 映射**:P6.1–P6.5 同名不变;旧 P6.6(删 legacy)拆成 P6.6 翻闸(新增显式)/P6.7 删 legacy/P6.8 回归(新增显式)。**否决**:方案 B(SPI 前置 P6.0,无消费者设计 SPI 风险 R3/R5+写路径 RFC PMC 评审阻塞前端)、方案 C(粗 4 阶段,write+actions mega-phase 爆 context+违 atomic-reviewable)。**fe-core iceberg metastore-props(`AbstractIcebergProperties`+7+factory,已 wired `MetastoreProperties.Type.ICEBERG`)留 fe-core 直到翻闸后**(沿用 paimon 决策,删除属 backlog #2、与 hive/P7 共用 `MetastoreProperties` 通用 seam 一并设计)。phase-plan spec = [tasks/P6-iceberg-migration.md](./tasks/P6-iceberg-migration.md);逐 task 拆解在各阶段启动时做 | 2026-06-21 | ✅ | +| D-057 | P4 cleanup | **P4 MINOR/NIT 一次性 cleanup scope(用户签字,2026-06-12)= fix `N10.1`(VARCHAR-BOUNDARY) + `sentinel`(PARTITION-NULL-SENTINEL);accept M5.1 + 其余 14 项为 deviation [DV-035]**:review §5 + §7 去重得 ~17 项 MINOR/NIT,read-only 对抗 recon(`wf_6884d37b-8ef`:6 并行分类 + 2 sentinel 证伪 skeptic + completeness critic)逐项对当前代码复核。3 项 actionable,14 项确认 display/perf/text/benign/连接器-更-correct/false-premise → 批量 accept。**(1) N10.1**=`PaimonTypeMapping.toVarcharType` 用 `>=65533` over-widen 至 STRING vs legacy `>65533`(65533=`MAX_VARCHAR_LENGTH` 是合法 exact-fit VARCHAR);纯连接器 1 字符修 `>=`→`>`,display-only 但零风险 exact-parity(`bcee91dcb52`)。**(2) sentinel**=`PaimonScanRange.populateRangeParams` 经 `ConnectorPartitionValues.normalize` 施 Hive-directory 哨兵 coercion(`\N`/`__HIVE_DEFAULT_PARTITION__`→isNull)——对 hudi(path-encoded)对、对 paimon 错(paimon 分区值已 typed:genuine null=Java-null,哨兵从不出现)→ 一个 literal `\N`(paimon 不保留)/`__HIVE_DEFAULT_PARTITION__` 字符串分区值在 native ORC/Parquet 读被误成 SQL NULL。**对抗 verifier 推翻 sentinel deep-dive 的 ACCEPT 结论**(deep-dive 只看 scan 路、漏了 Nereids prune 路 `TablePartitionValues:162` + 误把 `\N` 当 Hive-保留)。修=纯连接器 scan 侧 `isNull=value==null` only(legacy `PaimonScanNode:323-326` parity,render genuine null=""),不动 shared `ConnectorPartitionValues`(hudi 仍需);prune-路残留=pre-existing generic fe-core,out-of-scope(`4b2c2190dc2`,commit 前 5-angle 对抗 review SAFE)。**(3) M5.1(accept-flip)**:completeness critic 误判有「cheap static fallback」→实现层核查证伪——`getTableHandle` 的 swallow-transient-to-empty 是**有意+有测**契约(`PaimonConnectorMetadataReadAuthTest:150` 钉 `failAuth→empty`)且是共享 existence 谓词(含 P3 createTable `remoteExists:295` + `tableExists:239`),`listSupportedSysTables` 忽略 handle。无 surgical 零成本修;唯一干净修需 SPI 加法或破有意契约 → **transient-only severity,用户签字 accept**([DV-035])。否决:broad `getTableHandle` retype(破有意契约+触 P3 fix)、SPI no-handle list(surface churn + 重引 legacy「为不存在表列 sys-table」quirk)。详 [task-list §P4](./task-list-P5-rereview2-fixes.md) | 2026-06-12 | ✅ | +| D-056 | P3-fix | **FIX-CREATE-TABLE-LOCAL-CONFLICT(P3 覆盖缺口核查揪出的真分歧,MAJOR correctness)= fix-now(用户签字,2026-06-12)+ Option-2 外科最小修(仅补 local-conflict 闸,不动 remote-hit 路)**:P3「去查」对抗 review(`wf_25450c36-b7a`,4 项 plugin-vs-legacy paimon parity;tracer→对抗 verifier→completeness critic)3 项 PARITY_HOLDS(HMS-CONFRES:key 拼写恰 `hive.conf.resources` 无 `config.resources` 别名、round-1 wiring 在、BE-downflow 两侧同——legacy HMS hive-site.xml 本就不入 BE scan props;ANALYZE/列统计:`getColumnStatistic` 两侧 `Optional.empty()`、`createAnalysisTask` byte-同;split-count:post-sub-split 数经共享父 `FileQueryScanNode.selectedSplitNum` 喂 `SqlBlockRuleMgr` 两侧同,2 项 cosmetic/NIT 且 pre-date #9),唯 DDL 写揪出真分歧:通用 fe-core 桥 `PluginDrivenExternalCatalog.createTable` 把 legacy `PaimonMetadataOps.performCreateTable:182-214` 的**有序双探**(先 remote `tableExist:190` 后 local `getTableNullable:206`,任一命中+`!IF NOT EXISTS`→`ERR_TABLE_EXISTS_ERROR` 1050)**合并成单 `exists` OR**(`:293-295`),且 `exists` **只被 IF NOT EXISTS 臂消费**(`:296`)→`!IF NOT EXISTS` 臂(`:303-309`)忽略它无条件调 `metadata.createTable`。后果:**local-cache 命中但 remote 缺**(`lower_case_meta_names` 下 case-variant 折叠到既有本地表、case-sensitive remote 无此表)+`!IF NOT EXISTS` 时,legacy 报 1050 拒绝、plugin **静默在 remote 建重复表**(元数据腐败)。触发窄+backend-dependent(filesystem/jdbc case-sensitive 才中;HMS 小写化两侧都拒)但 silent correctness。**通用桥**(paimon+MaxCompute+未来 iceberg/hudi 共用)→修一处跨连接器收口。**修=纯 fe-core 桥、零 SPI/连接器/BE**:单 OR 拆回 `remoteExists`/`localExists` 两臂,`!IF NOT EXISTS` 下 `localExists`→`ErrorReport.reportDdlException(ERR_TABLE_EXISTS_ERROR,name)`(legacy local 臂逐字);remote-only 仍 fall-through 连接器抛(case-A 不动、既有 intentional 测绿)。**否决 Option 1 full-parity**(对整 `exists&&!ifNotExists` retype 1050)——改非分歧 case-A+破既有测+越界;case-A error-code-generic 是 pre-existing 跨全 DDL op cosmetic 残留=[DV-034]。守门:fe-core `PluginDrivenExternalCatalogDdlRoutingTest` **fail-before 恰 1 新测红**("Expected DdlException…nothing was thrown")→**pass-after 26/0/0**、checkstyle 0。真值闸=live-e2e(`lower_case_meta_names=1`+case-variant CREATE 无 IF NOT EXISTS 于 case-sensitive paimon catalog;既有 legacy paimon DDL regression 覆盖契约、本 fix 无 BE 改)。设计 [`P5-fix-CREATE-TABLE-LOCAL-CONFLICT-design.md`](./tasks/designs/P5-fix-CREATE-TABLE-LOCAL-CONFLICT-design.md) | 2026-06-12 | ✅ | +| D-055 | P5-fix#9 | **FIX-NATIVE-SUBSPLIT(M-3,round-2 MAJOR/round-1 MINOR,perf-parity)= fix-now(用户签字,2026-06-12)+ 纯连接器零 SPI/零 fe-core**:翻闸后大 native ORC/Parquet paimon 文件得**一个** scanner(无文件内并行)——连接器 native 臂每 RawFile 发**一个** `PaimonScanRange`(`.start(0).length(file.length())`),legacy `PaimonScanNode:434-465` 经 `determineTargetFileSplitSize`+`fileSplitter.splitFile` 把大文件切成多 split。结果正确仅并行度回归。recon(5-scout + 对抗 synthesizer `wf_ad764bf6-1c9`)三大结论:① **真 gap 非 no-op**——ORC/Parquet `compressType=PLAIN`(`FileSplitter:115`),`(!splittable||!=PLAIN)` 闸不触发→真切分跑;② **DV×sub-split 安全无须 guard**——paimon DV rowid 是文件**全局**行位置,BE native reader 在部分 byte range 内仍报全局行位(ORC `getRowNumber()` 由 stripe 起播种、Parquet `first_row` 跨 row-group 累计),`_kv_cache` 按 `path+offset` 跨 sub-split 共享 DV 位图,iceberg 用同机制于常规切分文件→**规则=同一 per-RawFile DeletionFile 原样附到每个 sub-range、不 re-base offset**(legacy `:459-460` parity);③ **纯连接器**——切分 math 是对 5 个 session var(`VariableMgr.toMap` 通道,同 `isCppReaderEnabled`)的 long 算术,连接器禁 import fe-core `FileSplitter`/`SessionVariable` 故逐字重述;`start/length/fileSize` 经既有 `PaimonScanRange.Builder`→`PluginDrivenSplit` FileSplit ctor→`FileQueryScanNode.createFileRangeDesc` 已序列化到 BE。**仅 specified-size 分支可达**(连接器传 blockLocations=null + target 恒>0 因 paimon 非 batch;block-based 分支死)。**修=纯连接器**:2 纯静态(`computeFileSplitOffsets` 逐字移植含 **`>1.1D` 尾吸收 guard**、`determineTargetSplitSize` 移植 determineTargetFileSplitSize+applyMaxFileSplitNumLimit 略去 isBatchMode→0)+ `sessionLong`/`resolveTargetSplitSize`(lazy once)+ native 臂改 buildNativeRange 加 (start,length) 内层 loop。守门:连接器 256/0/0(1 CI-gated skip)、checkstyle 0、import-gate 净、**fail-before 恰 3 splitting 测红**(neuter `computeFileSplitOffsets`→单 range)其余绿、end-to-end append-only 真表小 file_split_size→≥2 contig sub-range。split-weight 调度 nicety 不移植(pre-existing native 路已缺)= [DV-033]。真值闸=live-e2e 大文件并行 + DV-file 多 range 读(既有 legacy paimon regression 覆盖 BE 契约、本 fix 无 BE 改)。设计 [`P5-fix-NATIVE-SUBSPLIT-design.md`](./tasks/designs/P5-fix-NATIVE-SUBSPLIT-design.md) | 2026-06-12 | ✅ | +| D-054 | P5-fix#8 | **FIX-COUNT-PUSHDOWN(M-2,round-2 MAJOR/round-1 MINOR,perf-parity)= fix-now + 新增 default `planScan` 7-arg overload 携 `boolean countPushdown` + 连接器 collapse-to-one(用户签字,2026-06-12)**:翻闸后 plugin-driven paimon `COUNT(*)` **结果正确但慢**——COUNT 枚举已达 BE(`FileScanNode.toThrift:90` 发 `pushDownAggNoGroupingOp`、`PhysicalPlanTranslator:873` 在 plugin 节点设 COUNT、未排除)且 per-range emit 缝**已建全**(`PaimonScanRange.Builder.rowCount`→`paimon.row_count`→`setTableLevelRowCount`,与 legacy `PaimonScanNode:303-308` byte-一致),唯独**信号+计算**缺:merged count `DataSplit.mergedRowCount()` 是 paimon-SDK-only 须连接器算,而 COUNT 信号 `getPushDownAggNoGroupingOp()==COUNT` 只在 fe-core 节点、`PluginDrivenScanNode.getSplits` 从不读(grep 0)也不在任何 `planScan`/`ConnectorSession`/`ConnectorContext`/handle → 连接器每 split 发 `table_level_row_count=-1` → BE 物化全 post-merge 行去 count(`file_scanner.cpp:1298-1326`),PK/MOR merge 表尤贵。**故非纯连接器(更正动手前 framing)**:信号须过 SPI 边界。**否决经 `ConnectorSession` 穿**(FIX-FORCE-JNI 先例)——agg-op 是 per-query planner 输出非 SET-var,会成静默无类型通道(本项目反复踩的 bug 类)。**用户定(vs defer)= fix-now**,且 **count-split 形状 = 连接器 collapse-to-one**(vs full-parity fe-core trim / vs per-split)。**修=3 文件**:① SPI `ConnectorScanPlanProvider` +1 **default** 7-arg `planScan(...,boolean countPushdown)` 委托 6-arg(镜像 limit/requiredPartitions 扩展链,其余连接器零改 no-op)[E15];② fe-core `PluginDrivenScanNode.getSplits` 读 `getPushDownAggNoGroupingOp()==TPushAggOp.COUNT` 传入(**无 post-loop 数学**);③ 连接器抽 `planScanInternal(...,countPushdown)`(4-arg 委托 false、7-arg 委托 flag)+ count 短路(**第一 routing 臂**,count-eligible split 不再发数据 range,否则 BE 双计 vs DV/PK-merge):累加全 eligible split 的 `mergedRowCount` 入 `countSum`、留首个为代表、循环后发**一** JNI count range 携 `countSum`(=legacy `<=10000` singletonList+assignCountToSplits 收一 split case);无 merged count 的 split 走常规 native/JNI 路 BE 自计(footer/物化)。两新成员=纯静态 `isCountPushdownSplit(boolean,DataSplit)`(mutation-test 路由闸)+ `buildCountRange`。**参数形状 `boolean`**(BE 只需 COUNT-vs-not、`TPushAggOp` 过度泛化)+ **paimon-only**=工程判断(未被否)。legacy `>10000` 并行 split trim **有意丢**(连接器无 numBackends,fe-core-only)= perf-only 偏差 [DV-032]。守门:连接器 252/0/0(1 CI-gated skip)、fe-core compile+checkstyle 0、import-gate 净、**fail-before 恰 2 新测红**(neuter `isCountPushdownSplit`→false)其余 33 绿、end-to-end 真 local PK 表测断言 collapse-to-one 携 merged total(2)。真值闸=live-e2e BE CountReader 选择/EXPLAIN(既有 legacy paimon count regression 覆盖 BE 契约、本 fix 无 BE 改)。设计 [`P5-fix-COUNT-PUSHDOWN-design.md`](./tasks/designs/P5-fix-COUNT-PUSHDOWN-design.md) | 2026-06-12 | ✅ | +| D-053 | P5-fix#6 | **FIX-KERBEROS-DOAS / M-8(MAJOR,Kerberos-only,fe-core,filesystem+jdbc)= fix-now(用户签字,2026-06-11)**:翻闸后 filesystem/jdbc flavor 在 Kerberized HDFS 上丢 UGI `doAs`——连接器 `PaimonConnector.createCatalog` 已把建 catalog 包进 `context.executeAuthenticated`(:194),但其背后 authenticator 对这两 flavor 是**基类 no-op**:HDFS `HadoopExecutionAuthenticator` 仅在 `initializeCatalog()` 内构建(`PaimonFileSystemMetaStoreProperties:46`/`PaimonJdbcMetaStoreProperties:120`),而 `initializeCatalog` 在翻闸路径**死代码**(唯一 live 调用方=legacy `PaimonExternalCatalog:147`;plugin 路径经 `PaimonCatalogFactory` 自建 catalog)→ `PluginDrivenExternalCatalog.initPreExecutionAuthenticator:130` 读到 `AbstractPaimonProperties:45` 的 no-op → `executeAuthenticated` 不 doAs。HMS 不受影响(authenticator 在 `initNormalizeAndCheckProps:70` 即建、必跑)。**作用域=filesystem+jdbc only**(用户签):DLF/REST 排除——`PaimonAliyunDLFMetaStoreProperties` 从不设 authenticator、用 Aliyun AK/SK/STS 入 HiveConf 非 Kerberos UGI(无 doAs 可丢),故 review「DLF」从句 **overstated**;HMS 已对。**修=fe-core,零连接器改/零连接器-SPI**:新 fe-core hook `MetastoreProperties.initExecutionAuthenticator(List)`(default no-op)由 `PluginDrivenExternalCatalog.initPreExecutionAuthenticator` 用**已安全建好**的 `catalogProperty.getOrderedStoragePropertiesList()` 调(catalog-init 时机、与 legacy 同、避免每次 `MetastoreProperties.create` eager 重复 kerberos login);filesystem/jdbc override 之经 `AbstractPaimonProperties.initHdfsExecutionAuthenticator` 共享 helper 从 HDFS `StorageProperties` 建 authenticator(镜像 HMS)。**FE-unit 可测 wiring**(断言 `getExecutionAuthenticator()` 返 `HadoopExecutionAuthenticator`、不调 initializeCatalog),**真 doAs 端到端=live-Kerberos-e2e only**(无 paimon-kerberos regression 套件,[DV-031](./deviations-log.md))。守门:fe-core `Paimon{FileSystem,Jdbc}MetaStorePropertiesTest` 14/0/0、fail-before 双 red(no-op `AbstractPaimonProperties$1`)、checkstyle 0。设计 [`P5-fix-KERBEROS-DOAS-design.md`](./tasks/designs/P5-fix-KERBEROS-DOAS-design.md) | 2026-06-11 | ✅ | +| D-052 | P5-fix#6 | **FIX-KERBEROS-DOAS / M-11(MAJOR,Kerberos-HMS)= full legacy parity 包全部 read RPC(用户签字,2026-06-11,取代 D7=B 的 read 从句)**:翻闸后连接器 metadata **读** RPC(listDatabases/getDatabase/listTables/getTable[getTableHandle+getSysTableHandle+resolveTable]/listPartitions)**不**包 `executeAuthenticated`,仅 4 个 DDL op 包(B3 **D7=B** 故意 read-vs-DDL 不对称、把 read-path doAs 推给 live-e2e 门)→ Kerberos HMS 上 SHOW PARTITIONS/MTMV/partitions-TVF + 任何 getTable 读 RPC 跑在 catalog principal 之外。legacy `PaimonMetadataOps`/`PaimonExternalCatalog` 包**每个** read(`getPaimonPartitions:99`、`getPaimonTable:137`、listDatabases/listTables/getDatabase)。**用户定 = full legacy parity(vs 仅包 listPartitions / vs defer)**:仅包 listPartitions 是半吊子(连分区路径自身先行的 getTable reload 都漏);defer 则须登 accepted-deviation。本签字**取代 D7=B 的 read-path 从句**(4 DDL op 仍包)。**修=连接器 only、零 SPI**:7 处 read site 包 `context.executeAuthenticated`,其中 `resolveTable`(metadata + scan 双 site)一处包覆盖所有 resolveTable 调用方(DRY)。**异常流关键**:Kerberos `UGI.doAs` 把抛出的 checked `Catalog.{Table,Database}NotExistException` 包成 `UndeclaredThrowableException`(仅 IOException/RuntimeException/Error 透传)→ 故 domain 异常**必须在 lambda 内**捕获(镜像 legacy `getPaimonPartitions:104`),listDatabases/resolveTable 的既有 catch-all 在外吸收。scan `resolveTable` 对 `context==null`(2-arg ctor 离线测)走直连,与同文件 `getScanNodeProperties` 既有 null-context 约定一致。守门:连接器模块 248/0/0(1 CI-gated skip)、新 `PaimonConnectorMetadataReadAuthTest` 12/0/0 + scan 2、fail-before 3 red(authCount/log-empty)、import-gate 净、checkstyle 0。真值闸=live Kerberos HMS e2e(CI-gated、无套件,[DV-031](./deviations-log.md))。设计 [`P5-fix-KERBEROS-DOAS-design.md`](./tasks/designs/P5-fix-KERBEROS-DOAS-design.md) | 2026-06-11 | ✅ | +| D-051 | P5-fix#5 | **FIX-MAPPING-FLAG-KEYS(M-crit MAJOR,纯连接器/FE-wiring,无 BE/无 SPI)作用域 = paimon-only 修 + hive/iceberg 跨连接器 follow-up(用户签字,2026-06-11)**:翻闸后 paimon 连接器类型映射两开关**静默失效**——连接器读**下划线**键 `enable_mapping_binary_as_varbinary`/`enable_mapping_timestamp_tz`(`PaimonConnectorProperties:39,42`→`PaimonConnectorMetadata.buildTypeMappingOptions`),但 fe-core 只写**点分**键 `enable.mapping.varbinary`/`enable.mapping.timestamp_tz`(`CatalogProperty:50,52`;`ExternalCatalog.setDefaultPropsIfMissing:302-306` 仅写点分键;`HIDDEN_PROPERTIES` 仅藏点分键)→ `PluginDrivenExternalCatalog.createConnectorFromProperties` 把**原始** catalog map 原样喂连接器 → `getOrDefault(下划线,"false")` 恒 false → 即便用户在 CREATE CATALOG 开启,BINARY 仍→STRING、LTZ 仍→DATETIMEV2(legacy `PaimonExternalTable:350` 读点分键并 honor → cutover 回归,flag 启用前 latent)。binary 键**双重漂移**(分隔符 `.`→`_` 且 token `varbinary`→`binary_as_varbinary`)→ 通用归一化器修不了。**M-crit 是 critic-surfaced 未过 3-lens → 先独立复核**(5-agent scout + 对抗 synthesizer workflow `wf_a3626c54-0db` → REAL_BUG high-conf,false-positive steelman 被否:原始 feature PR #57821/#59720、全 regression CREATE CATALOG(paimon/iceberg/hive/jdbc 皆点分)、legacy parity、同 SPI PR 迁移的 JDBC 连接器正确保点分 `JdbcConnectorProperties:66-67` 均证点分为 canonical)。**修(纯连接器、零 SPI/BE)**:`PaimonConnectorProperties` 两常量重指 canonical 点分键(binary 常量并改名 `ENABLE_MAPPING_VARBINARY` 对齐 CatalogProperty/JDBC/iceberg 约定,同修分隔符+token)+ 更新 `PaimonConnectorMetadata` 一处引用;`Options(mapBinaryToVarbinary,mapTimestampTz)` 顺序本就对、无逻辑改。**BE 一致性已核**:`PluginDrivenScanNode extends FileQueryScanNode` 不 override mapping getter → BE scan param 经继承的 `getEnableMappingVarbinary/Tz` 本就读点分键(`FileQueryScanNode:192-193,635-678`),故修连接器 FE 侧读后 FE 列型与 BE scan param 一致(修前两侧分歧)。**用户定 = paimon-only**(vs 一次修全 3 连接器)→ hive/iceberg 同根因登 [DV-030](./deviations-log.md) 跨连接器 follow-up(hive `enable_mapping_binary_as_string` 是误名非语义反转)。否决 fe-core 归一化器(blast 大/破 JDBC 已正确读点分/对 paimon 双重漂移不足)。守门:模块 234/0/0(1 CI-gated skip)、checkstyle 0、import-gate 净、fail-before bug-catcher 向红(期望 VARBINARY 实得 STRING)+guard 两态绿。真值闸=`test_paimon_catalog_{varbinary,timestamp_tz}.groovy`(CI-gated,enablePaimonTest=false+外部 fixture)。设计 [`P5-fix-MAPPING-FLAG-KEYS-design.md`](./tasks/designs/P5-fix-MAPPING-FLAG-KEYS-design.md) | 2026-06-11 | ✅ | +| D-050 | P5-fix#4 | **FIX-JDBC-DRIVER-URL(B-8a BLOCKER + B-8b 安全)作用域 = CREATE-time 校验 parity(用户签字,2026-06-11)**:JDBC flavor 连接器(a)`PaimonScanPlanProvider.getBackendPaimonOptions` 把 `driver_url` **裸**转发给 BE 且 `startsWith("jdbc.")` filter 丢 `paimon.jdbc.*` 别名 → BE `JdbcDriverUtils.registerDriver` 的 `new URL("mysql.jar")` 抛 `MalformedURLException`(B-8a 功能 BLOCKER);(b)driver_url 无 format/allow-list/secure-path 校验 + stale「paimon 不在 SPI_READY_TYPES」注释(B7 后已假,`CatalogFactory:51` 含 paimon)(B-8b 安全)。**修 = 纯连接器、零新 SPI**(复用既有 `Connector.preCreateValidation` + `ConnectorValidationContext.validateAndResolveDriverPath`):B-8a 在 `getBackendPaimonOptions` 用 `firstNonBlank(JDBC_DRIVER_URL)` 认两别名 + 抽出共享 `PaimonCatalogFactory.resolveDriverUrl`(FE 注册与 BE 选项同解析)发 canonical `jdbc.driver_url`(resolved)+`jdbc.driver_class`(镜像 legacy `PaimonJdbcMetaStoreProperties.getBackendPaimonOptions`);B-8b override `PaimonConnector.preCreateValidation` 对 jdbc flavor 调 `validateAndResolveDriverPath`(镜像 `JdbcDorisConnector`)+ 删 stale 注释。**4-lens clean-room review 确认 B-8a + CREATE-time B-8b 正确**,揪出**校验仅 CREATE-time**(FE-restart reload/ALTER CATALOG/scan-time 不复校)= **pre-existing fe-core 缝、所有 plugin 连接器共有(含 JDBC 参考连接器)、默认配置 permissive 无可绕**,legacy 更强(每 scan 经 getFullDriverUrl 复校)。**用户定 = 接受 CREATE-time parity**(vs 扩到 fe-core ALTER hook + scan-time 校验 SPI——触 fe-core+全连接器+ALTER 可能触发 BE 连通测,非 surgical)→ 登 [DV-028](./deviations-log.md)(CREATE-time-only 校验 gap + 跨连接器 follow-up)+ [DV-029](./deviations-log.md)(简化 resolver + BE-side user/password/uri 别名 out-of-scope)。守门:模块 232/0/0、checkstyle 0、import-gate 净、fail-before 5/9 向红。真值闸=`test_paimon_jdbc_catalog`(CI-gated)。设计 [`P5-fix-JDBC-DRIVER-URL-design.md`](./tasks/designs/P5-fix-JDBC-DRIVER-URL-design.md) | 2026-06-11 | ✅ | +| D-049 | P5-fix#3 | **FIX-SCHEMA-EVOLUTION(B-1a BLOCKER + M-10 MAJOR)= Design C「连接器直建 thrift schema 字典」(用户签字,2026-06-11;M-10 deferred)**:翻闸后 native(ORC/Parquet) 读丢 paimon schema-evolution——连接器只发 per-file `TPaimonFileDesc.schema_id`、从不设 scan 级 `TFileScanRangeParams.current_schema_id`/`history_schema_info` → BE `table_schema_change_helper.h:219-237` 走 `!__isset` 分支退化**名匹配** → schema-evolved(改名/重排)表旧 schema 文件**静默错行/读 NULL**(JNI 路不受影响、native 是默认)。**关键事实**(5 层 trace + BE `table_schema_change_helper.cpp:312-430`):field-id 匹配路 BE 只读 `TField.{id,name,type.type 当 nested-vs-scalar tag}`、**从不读 Doris Type 也不读 tuple descriptor** → 连接器可**直建** `TSchema`(`org.apache.doris.thrift.*` import-legal)、**无需 Doris Type/无新 SPI**;`Column.uniqueId`(M-10) 仅当 FE 从 Doris 列建 history 才有关、直接从 paimon `DataField.id()` 建则 B-1a 独立于 M-10。**用户定 = Design C(vs Design B「穿 ConnectorColumn/ConnectorType field-id + fe-core ExternalUtil 建」)**:连接器在 `getScanNodeProperties` 从 live(snapshot-pinned)表建 `current_schema_id=-1`+`history_schema_info`(-1 entry=pinned schema、外加每个 `SchemaManager.listAllIds()` 提交 schema)→ base64 thrift carrier prop 经既有 `populateScanLevelParams` SPI hook(复用 DV-006 同缝)落 params。**零新 SPI surface**(task-list 原标「SPI?=yes」修正为 no)、连接器局部、最小 blast radius;M-10(`Column.uniqueId=-1`)**deferred**(rereview2 §4 已证伪 standalone repro、无消费者,[DV-026](./deviations-log.md))。**3-lens clean-room review 揪出 2 真 BLOCKER(均在 -1 entry,已修+复验 clean)**:① 列名 casing(BE verbatim key vs lowercase slot name + `current_schema_id=-1` 永不走 ConstNode 快路 → 大小写混合列即崩、连未演化表都回归)→ 修 = -1 entry **只** lowercase 顶层名(default-locale,byte-match slot 产出方+legacy `parseSchema:507`;嵌套 struct 名保 paimon-case=legacy `PaimonUtil:302` 非对称);② 时间旅行(-1 entry 取 `schemaManager.latest()` 绝对最新、但 tuple 用 pinned schema → 改名列崩/错行)→ 修 = -1 entry 取 `((FileStoreTable)table).schema()`(pinned)、guard `DataTable`→`FileStoreTable`。MINOR(eager 读全 schema 无 cache)= 接受的 fail-loud 偏差 [DV-027](./deviations-log.md)。守门:模块 222/0/0(+5 schema-evo UT)、checkstyle 0、import-gate 净。真值闸=`test_paimon_full_schema_change.groovy`(CI-gated)。设计 [`P5-fix-SCHEMA-EVOLUTION-design.md`](./tasks/designs/P5-fix-SCHEMA-EVOLUTION-design.md) | 2026-06-11 | ✅ | +| D-048 | P5-fix#2 | **FIX-STATIC-CREDS-BE(B-9 BLOCKER)作用域 = full legacy-parity 替换(用户签字,2026-06-11)**:翻闸后 paimon 连接器 `PaimonScanPlanProvider.getScanNodeProperties:372-381` 把静态 catalog 凭据/配置(`s3.`/`oss.`/`cos.`/`obs.`/`hadoop.`/`fs.`/`dfs.`/`hive.` 前缀)裸拷进 `location.`,fe-core bridge `PluginDrivenScanNode.getLocationProperties` 只剥前缀不归一化 → BE native(FILE_S3) reader 只认 `AWS_ACCESS_KEY`/`AWS_SECRET_KEY`/`AWS_ENDPOINT`/`AWS_REGION`/`AWS_TOKEN`(`s3_util.cpp`)→ 私有桶 native 读拿不到凭据 403。是 review §9.3 凭据**第三道缝**(static→BE-scan,FIX-STORAGE-CREDS 修 catalog FileIO 缝、FIX-REST-VENDED 修 vended 缝,本缝两轮均漏)。legacy `PaimonScanNode.getLocationProperties:650-652` 仅返回 `CredentialUtils.getBackendPropertiesFromStorageMap(storagePropertiesMap)`(canonical map)。**用户定 = 方案 A(full legacy-parity,非窄 object-store-only)**:新 SPI `ConnectorContext.getBackendStorageProperties()`(default 空,仅 paimon 调)= 引擎用 #1 已接线的 `storagePropertiesSupplier`(`catalogProperty.getStoragePropertiesMap()`)跑同一 `getBackendPropertiesFromStorageMap` → 连接器**整段**替换裸拷循环为该 overlay(vended overlay 仍后置、collision 胜,legacy 优先序)。object-store→`AWS_*`;HDFS→canonical(保留用户 `hadoop.`/`dfs.`/`fs.`/`juicefs.` override + 补 legacy 默认 `ipc.client.fallback-to-simple-auth-allowed` 等,**顺修 review §211 MINOR**);丢非-parity `hive.*` 裸键(legacy 本不发 scan location)。一处 SPI 调用替掉前缀循环、单一真相源、无漂移。**ANONYMOUS-leak 边角经 BE 证伪无回归**(`s3_util.cpp` v1:383/v2:448 显式 ak/sk 优先于 `cred_provider_type`,vended keys 在则永不走 Anonymous 支)。无 ctor 改、无连接器新 import(import-gate 净)。SPI RFC §22(E14)。测:fe-core `DefaultConnectorContextBackendStoragePropsTest`(2)+连接器 `PaimonScanPlanProviderTest`(3 改/增,red-check 2/3 向红);模块 217/0/0。设计 [`P5-fix-STATIC-CREDS-BE-design.md`](./tasks/designs/P5-fix-STATIC-CREDS-BE-design.md) | 2026-06-11 | ✅ | +| D-039 | P5-D8 | **P5 paimon B4 E7 sys-table SPI 形状 = 复用 live fe-core SysTable 机制(用户签字,2026-06-10)**:RFC §10 的「sys-table 当 `$`-后缀普通表、连接器在 `getTableHandle` 内解析后缀 + `listSysTableSuffixes`」设计**从未落地**——live fe-core 实为 `SysTableResolver`+`NativeSysTable`+`TableIf.getSupportedSysTables/findSysTable`(`BindRelation`/`DescribeCommand`/`ShowCreateTableCommand` 调用;iceberg + legacy-paimon 共用),RFC §10 已 stale。**用户定 = 复用 live 机制(非 RFC §10)**:① 连接器 SPI 加 `ConnectorTableOps.listSupportedSysTables` + `getSysTableHandle`(default no-op,MC/jdbc/es/trino 不受影响);② fe-core `PluginDrivenExternalTable.getSupportedSysTables` 委托连接器(`listSupportedSysTables`),通用 `PluginDrivenSysTable extends NativeSysTable` + `PluginDrivenSysExternalTable`(**报 `PLUGIN_EXTERNAL_TABLE` 非连接器类型**,经现有 `SysTableResolver` 路由到 `PluginDrivenScanNode`)。否决 RFC §10 的 `getTableHandle("$suffix")`-路由(须改 `BindRelation`/`RelationUtil`、大 surface、偏离 iceberg)。RFC §10 标 superseded([DV-023](./deviations-log.md))。**T20(E5 MVCC)置于 B4** = 连接器侧 groundwork(inert until B5 wires fe-core MvccTable 消费者;翻闸 gated on B5 故 inert capability 不达用户,安全)。设计 `tasks/P5-paimon-migration.md` §批次 B4 | 2026-06-10 | ✅ | +| D-038 | P5-D2 | **P5 paimon MTMV + MVCC(时间旅行) scope = P5 内实现桥,翻闸 gated on 它(用户签字,design-only)**:SPI 当前 **MTMV 完全无面(E10 缺)**(`PluginDrivenExternalTable:62` 不 implements MTMVRelatedTableIf/MTMVBaseTableIf/MvccTable,框架靠 `instanceof MTMVRelatedTableIf` 分发——`MTMVPartitionUtil:265/497/588`、`StatementContext:987/1003`),E5(MVCC) `defined-no-consumer`(`ConnectorMvccSnapshotAdapter` 仅自身文件引用、`ConnectorScanRange` 无 snapshot 字段)。legacy `PaimonExternalTable:74` 实现全套。翻闸不机械阻断(plain SELECT 经 `getPaimonTable(empty)` 取 latest)但按 MC 样板直接翻闸=**静默回归** paimon-as-MTMV-base + 时间旅行。**用户定 = 方案 A**:P5 内落 fe-core `PaimonPluginDrivenExternalTable extends PluginDrivenExternalTable` 实现三接口 + 首个真 E5 消费者 override `beginQuerySnapshot` 三方法 + 新增 GAP-LISTPART-AT-SNAPSHOT 的 at-snapshot listPartitions;表级 staleness=`ConnectorMvccSnapshot.getSnapshotId()`(-1 空表)、分区级=`ConnectorPartitionInfo.getLastModifiedMillis()`(已存在);MTMV 类型/PartitionItem 留 fe-core、连接器仅供 SPI-neutral 数据。**翻闸(B7) gated on MTMV 桥(B5)**;**禁**静默读 latest。否决 B(翻闸先行 + MTMV fail-loud 延后)。最高 correctness 风险=单-pin 不变式 + `lastFileCreationTime()` 跨 flavor 可靠性(SDK 行为,须 live 验)。设计 `tasks/P5-paimon-migration.md` §开放决策 D2 + recon §3.5/§4 | 2026-06-09 | ✅ | +| D-037 | P5-D1 | **P5 paimon flavor(hms/filesystem/dlf/rest/jdbc) 装配 = 单 Catalog + `createCatalog` flavor switch(MC 一致,用户签字,design-only)**:连接器现走单 Catalog stub(`PaimonConnector.createCatalog:75-83` 把 `Options.fromMap` 直喂 paimon SDK CatalogFactory,无 Doris 侧 warehouse/HiveConf/StorageProperties/authenticator 装配);5 个 `fe-connector-paimon-backend-*` 模块**是空壳**(仅 gitignore `.flattened-pom.xml`、零 src/未注册 Maven 模块)。legacy 装配在 fe-core `AbstractPaimonProperties`+5 子类+`PaimonPropertiesFactory`,全 import 禁用的 fe-core `StorageProperties`/`HMSBaseProperties`/`HadoopExecutionAuthenticator`。**用户定 = 方案 A**:`PaimonConnector.createCatalog` 内 switch on `paimon.catalog.type`,**拷** warehouse/conf/S3-normalize + 重建 Hadoop/HiveConf + **每-flavor ExecutionAuthenticator** 入模块(镜像 MC 拷 MCProperties→MCConnectorProperties;filesystem→hms→rest/jdbc/dlf 渐进)。**不**建 backend 模块 + ServiceLoader(否决 B:无 MC 先例、大 surface、空壳从零建)。约束:StorageProperties 从属性 map 重建(禁 import);**每-flavor authenticator 必须保**(否则 Kerberized HMS/HDFS DDL 运行时炸、无离线测覆盖)。设计 `tasks/P5-paimon-migration.md` §开放决策 D1 + recon §3.4 | 2026-06-09 | ✅ | +| D-036 | — | **P4-T06e FIX-CAST-PUSHDOWN MaxCompute 关 CAST 谓词下推 + 剥壳时抑制 source LIMIT(F9 静默丢行回归,review 原误判 known-degr 已推翻)**:共享 converter 无条件剥 CAST(`ExprToConnectorExpressionConverter:108`)、MaxCompute 不 override `supportsCastPredicatePushdown`(继承默认 true)→ `buildRemainingFilter` 不剔除含 CAST 的 conjunct → 剥壳谓词推入 ODPS read session(`CAST(str AS INT)=5`→源过滤 `str="5"` 按列 STRING quote)→ 源端 under-match 丢 `'05'/' 5'`、BE 复算只能过滤超集向下无法找回 → **静默丢行**。legacy `convertSlotRefToColumnName` 对 CAST 操作数抛异常→caught→丢弃该谓词(BE-only)→正确 ⇒ cutover 比 legacy 严格更紧 = **回归**(区别于 [DV-016] 仅 limit-opt 资格 CAST-unwrap、非丢行)。**对抗核验 `wzoa6dkvw` 0/3 refuted、verdict=real-unregistered-regression**。**用户定 Fix**。修 = ① 连接器 `MaxComputeConnectorMetadata.supportsCastPredicatePushdown→false`(激活既有 strip 路径、CAST conjunct 保留 BE-only、恢复 legacy parity;镜像 JDBC + `ConnectorPushdownOps` doc 处方;无 SPI 变更、无新路径);② fe-core `getSplits` 在 CAST conjunct 被剥(`filteredToOriginalIndex!=null`)时抑制 source LIMIT 下推(抽纯静态 `effectiveSourceLimit`)——否则连接器收空 filter→limit-opt(ON 时) row-offset 读首 N 行无谓词→BE under-return(impl-review `wj2h0120n` F9-LIMITOPT-1 折入;`startSplit` 批路径已恒 -1[DEC-1] 故只改 getSplits)。守门:连接器 UT 2/2+mutation(false→true 红)、fe-core LimitStrip 2/2+BatchMode 9/9+mutation 2/2 向红、checkstyle 0、import-gate 净。真值闸=live ODPS CAST(str)=5 返回全集(DV-020,CI 跳)。out-of-scope surface:JDBC `applyLimit`+cast-off 理论同类(MC 不 override applyLimit、本修对 MC 完整)。commit `cc32521ed99` | 2026-06-08 | ✅ | +| D-035 | — | **P4-T06e FIX-BATCH-MODE-SPLIT 通用 batch SPI 路径恢复异步分批 split(Shape A,NG-7/F6=F13 minor)**:翻闸后 `PluginDrivenScanNode` 不 override `isBatchMode/numApproximateSplits/startSplit` → 继承 `SplitGenerator` 默认(false/-1/no-op)→ plugin-driven(含 MC) 读永走同步 `getSplits` 一次性枚举全(已裁剪)分区 split;legacy `MaxComputeScanNode:214-298` 分批异步建 read session 流式喂 split。P1-4 后降级收窄到「裁剪后仍 ≥`num_partitions_in_batch_mode` 分区」(规划慢 + 大 session 潜在 OOM、行正确)。**用户定「实现 batch SPI 路径」(非 DV)**。修 = **Shape A(薄 SPI + fe-core 编排、逐字镜像 legacy)**:① SPI `ConnectorScanPlanProvider` +2 additive default(`supportsBatchScan` 默认 false / `planScanForPartitionBatch` 默认委托 6 参 `planScan` over 子集)零破坏其余 6 连接器;② 连接器 `MaxComputeScanPlanProvider.supportsBatchScan`=`odpsTable.getFileNum()>0`(`planScanForPartitionBatch` 不 override,继承默认即批语义);③ fe-core `PluginDrivenScanNode`(extends `FileQueryScanNode` 已继承 batch dispatch+stop;`PluginDrivenSplit extends FileSplit` 故 `:381` 转型安全)override `isBatchMode`(4 闸 isPruned+slots+supportsBatchScan+size≥阈值,含 SF-1 `getScanPlanProvider()` null-guard)/`numApproximateSplits`=size/`startSplit`(`getScheduleExecutor` outer/inner CompletableFuture 分批,`needMoreSplit/addToQueue/finishSchedule/setException/isStop` 契约,DEC-1 不下推 limit 传 -1 与 P3-9 limit-opt 互斥)+ 抽纯静态 `shouldUseBatchMode` 供单测。clean-room 设计验证 `wcpg9lblj` GO-WITH-EDITS(0 mustFix + 2 shouldFix:SF-1 null-guard NPE 修 + 预核文案,已折入)+ impl-review `wve7y1jst` GO-WITH-EDITS(0 mustFix + 1 shouldFix TQ-1 测试覆盖文案诚实降级 + 2 nit,已折入)。守门:编译 BUILD SUCCESS、fe-core UT 9/9、fe-connector-api UT 2/2、checkstyle 0、import-gate 净、mutation 5/5 向红。真值闸=大分区 live e2e(DV-019,CI 跳)。**Batch-D 红线**:legacy `MaxComputeScanNode` batch 逻辑须待本 fix 落才可删(读裁剪那半 P1-4 已清,本项为最后前置闸)。commit `ac8f0fc15eb`。**⚠ SUPERSEDED-IN-PART(2026-07-11,reverify M3 = [`FIX-M3-design.md`](./tasks/designs/FIX-M3-design.md))**:本决策核心(实现 batch SPI 路径、逐字镜像 legacy)**成立不变**;但 ③ 记的「4 闸 **isPruned**」及 impl-review **LP-1**「`!isPruned` ≡ legacy `!= NOT_PRUNED`、等价且略强」判定**为误**——二者对**无谓词分区表**取值相反(`initSelectedPartitions:447` 满 map `isPruned=false` 非哨兵:legacy batch、`!isPruned` 反挡回同步)。已把闸门订正为 `== NOT_PRUNED`(恰恰恢复本决策自身的 legacy-parity 目标),并反转 **TQ-2** 特意留的 pinning 测试 `testUnprocessedPruningNeverBatches`→`testNoPredicatePartitionedTableBatches`。docker-hive golden `test_hive_partitions` `(approximate)inputSplitNum` `60→6`(用户签字 SPI 分区数口径)。 | 2026-06-08 | ✅ | +| D-034 | — | **P4-T06e FIX-POSTCOMMIT-REFRESH 接受更安全的 post-commit 刷新 swallow、不回退 legacy 传播失败(无产线逻辑改动,NG-8/F15=F21 minor)**:翻闸后 `PluginDrivenInsertExecutor.doAfterCommit()` 用 try/catch 吞 `super.doAfterCommit()`(=`handleRefreshTable`)刷新失败、INSERT 仍报 OK;legacy `MCInsertExecutor` 不 override → 异常传播 → 报 FAILED。按生命周期序 `doBeforeCommit→commit(远端持久)→doAfterCommit`,`handleRefreshTable` 跑时数据已落 ODPS/远端、FE 无法回滚,且只刷 FE 缓存 + 写 external-table refresh editlog(follower 缓存失效提示、非数据真相源)、不碰已提交数据 → 报 FAILED 会诱发重试→**重复写**。**用户定(2026-06-08):接受 swallow(更安全)+ Javadoc 泛化 + DV 登记,不回退**。改 = **无产线逻辑**:仅 Javadoc(`:164-176`) 从「只讲 JDBC_WRITE」泛化到覆盖 MC connector-transaction 路径(两路径数据均已持久;swallow 最坏只瞬时缓存 stale 自愈;显式注明有意分歧 legacy、引用 [DV-018])。对抗性安全核查:master 先本地刷新(`RefreshManager:152`)后写 editlog(`:155`),丢 editlog 仅 follower 缓存暂 stale 自愈、无正确性损失/无主从分裂。守门:checkstyle 0、import-gate 净(注释 only、字节码不变)。真值闸=CI-skip live e2e(MC INSERT 后人为令 refresh 失败→断言报 OK)。commit `1f2e00d3696` | 2026-06-08 | ✅ | +| D-033 | — | **P4-T06e FIX-ISKEY-METADATA 连接器局部恢复 isKey=true(无 SPI 变更,NG-6/F3/F10 minor)**:翻闸后 `MaxComputeConnectorMetadata.getTableSchema` 用 5 参 `ConnectorColumn` ctor(isKey 默认 false)→ `DESCRIBE` 显示 Key=NO;legacy `MaxComputeExternalTable.initSchema` 全列 isKey=true。**用户定 Fix(isKey=true 恢复 parity)**。修 = 连接器局部、不动 SPI:抽 `buildColumn(...)` 静态助手用 6 参 ctor 置 isKey=true,data+partition 两 loop 经之;converter 已透传 isKey。**作用域更正**(设计验证 `wa9t0emta`):`information_schema.columns.COLUMN_KEY` 受 `FrontendServiceImpl:962-965` OlapTable 门控、MC 前后皆空(已 parity,out-of-scope)→ 本修**仅影响 DESCRIBE**。**非纯展示**:isKey 亦喂 `UnequalPredicateInfer:278` + BE slot/column descriptor(非 OLAP 门控),但 legacy 即喂 true → 恢复 production 既有值、零新行为。clean-room 设计验证 `wa9t0emta` 0 mustFix + impl review `wrx0n11ol` 0 mustFix。UT 3/3(+37 collateral)、checkstyle 0、import-gate 净、mutation killed(isKey true→false→Failures 2)。commit `1b44cd4f065` | 2026-06-08 | ✅ | +| D-032 | — | **P4-T06e FIX-LIMIT-SPLIT-DEFAULT 连接器局部恢复 limit-split 默认 OFF 三重闸(无 SPI 变更,NG-5/F11;并闭 minors F2/F12)**:翻闸后 `MaxComputeScanPlanProvider.planScan` 丢 legacy 三重闸——`checkOnlyPartitionEquality` 恒 false stub + 从不读 `enable_mc_limit_split_optimization`(默认 false)→ `useLimitOpt = limit>0 && !filter.isPresent()`:无过滤 LIMIT 默认即压成单 row-offset split(语义反转 + 静默无视 session var),分区等值 LIMIT 路径永不触发。**用户定 Fix(恢复三重闸)**。修 = 连接器局部、**不动 SPI**:① 加 hardcode 常量 `ENABLE_MC_LIMIT_SPLIT_OPTIMIZATION`(禁依赖 fe-core `SessionVariable`,同 JDBC 约定)经 `ConnectorSession.getSessionProperties()`(live 由 `from(ctx)`→`VariableMgr.toMap` 填)读 gate(1);② 实 `checkOnlyPartitionEquality` 遍历 `ConnectorExpression` 树(`ConnectorAnd` 全 conjunct / `ConnectorComparison` EQ 且 col 左 lit 右 / `ConnectorIn` 非 NOT-IN 且 value 为分区列、全 literal),镜像 legacy `checkOnlyPartitionEqualityPredicate`;③ 纯静态 `shouldUseLimitOptimization` 合成 gate(1)&&gate(3)&&gate(2)。默认 OFF=保守回退 legacy。clean-room 设计验证 `w17wzd0el` 0 mustFix + impl review `walkff1vf` 1 mustFix(IN-value 守卫缺杀手测,已补 test+mutation G)收敛。UT 26/26、checkstyle 0、import-gate 净、mutation 8 向红。commit `952b08e0cc8` | 2026-06-08 | ✅ | +| D-031 | — | **P4-T06e FIX-PRUNE-PUSHDOWN 新增 additive 6 参 `planScan` SPI overload 透传裁剪分区(DG-1)**:翻闸后 plugin-driven MaxCompute 读路径 Nereids `SelectedPartitions` 在 translator 被丢、`MaxComputeScanPlanProvider` 恒传 `requiredPartitions=emptyList` → ODPS read session 跨全分区(纯性能/内存回归,行正确)。FE 元数据半边 FIX-PART-GATES 已落([D-028]),缺 translator→SPI→connector 透传(原 READ-C2「②」半)。修 = `ConnectorScanPlanProvider` 加 6 参 `planScan(...,List requiredPartitions)` **default**(委托 5 参,零破坏其余 6 连接器,仅 MaxCompute override)+ `PluginDrivenScanNode` 加 `selectedPartitions` 字段/setter/三态 `resolveRequiredPartitions`(NOT_PRUNED→null 全扫 / pruned-非空→names / pruned-空→fe-core 短路无 split,镜像 legacy `MaxComputeScanNode:718-731`)+ translator plugin 分支注入 + MaxCompute `toPartitionSpecs` 喂两 read-session 路径。**契约**:null/空=全部、非空=子集、零分区 fe-core 短路不下达 SPI。clean-room `w31i0vfo5` 1 轮收敛 0 mustFix。commit `072cd545c54` | 2026-06-08 | ✅ | +| D-030 | — | **P4-T06e FIX-BIND-STATIC-PARTITION 新增 SPI capability `SINK_REQUIRE_FULL_SCHEMA_ORDER` + 回退 D-029 的 cols 位置索引为 full-schema 索引(用户批准扩 scope)**:翻闸后 MaxCompute 写走通用 `bindConnectorTableSink`,该路径克隆自 JDBC(按名 cols 序投影),而 MaxCompute BE/JNI writer **按位置**映射数据到完整表 schema → 静态分区无列名 INSERT bind 抛、重排/部分显式列名静默错列。修正 = 镜像 legacy `bindMaxComputeTableSink`:对**按位置写**的连接器(声明新 capability `SINK_REQUIRE_FULL_SCHEMA_ORDER`,MaxCompute 声明、JDBC/ES 不声明)恒投影到 full-schema 序(填 NULL/默认);JDBC 维持 cols 序。**并回退 D-029**:分布索引 cols→full-schema(否则 partial-static/重排错列)。判别键三轮收敛 static→partitioned→capability。clean-room 3 轮收敛 0 mustFix(`wi3mnjymb`/`wy299gtsh`/`wlwpw0b2s`)。commit `7cc86c66440` | 2026-06-07 | ✅ | +| D-029 | — | **P4-T06e FIX-WRITE-DISTRIBUTION 新增 SPI capability `SINK_REQUIRE_PARTITION_LOCAL_SORT`(Option A)**〔⚠️其「分区列按 **cols** 位置索引」已被 **D-030** 回退为 full-schema 索引——partial-static/重排显式列名下 cols 索引会错列〕:翻闸后 MaxCompute 写走通用 `PhysicalConnectorTableSink`,丢 legacy 动态分区 hash+local-sort(ODPS Storage API "writer has been closed")。新增 `ConnectorCapability.SINK_REQUIRE_PARTITION_LOCAL_SORT`(default 不声明)+ MaxCompute `getCapabilities()` 声明它 + `SUPPORTS_PARALLEL_WRITE`;sink 重写 legacy 3 分支(分区列按 **cols** 位置索引非 legacy full-schema)。替代(隐式 derive / `ConnectorWriteOps` 方法)见详录。clean-room `ww1g95bba` 1 轮收敛 0 must-fix。commit `f0adedba20c` | 2026-06-07 | ✅ | +| D-028 | — | **翻闸功能未完整,补 P4-T06c 接线(用户签字)**:live 验证 recon 代码核实——翻闸(Batch C)只接通 读(SELECT)/CREATE TABLE/写(INSERT);**DROP TABLE / CREATE DB / DROP DB / SHOW PARTITIONS / partitions() TVF 的 FE 分发从未接到 SPI**(连接器侧 P4-T01/T02 已实现,FE 零调用方)→ live 会红 5 项。根因 `PluginDrivenExternalCatalog` 仅 override `createTable`、`metadataOps==null`,且 SHOW PARTITIONS/TVF 仍 legacy `instanceof MaxComputeExternalCatalog` 分发。**决策 = 翻闸前全补接线**:Batch D 前插 **P4-T06c**(通用 PluginDriven 分发,非 MC 专有)把 DDL(create/drop db、drop table)+ SHOW PARTITIONS + partitions TVF 接到已有 SPI,目标 **live 全绿**,再 Batch D。同解 Batch D §2 删-vs-rewire 冲突(先 rewire,Batch D 只删残留 legacy) | 2026-06-07 | ✅ | +| D-027 | — | P4-T06b 翻闸落地 + Batch D 移除范围(2 决策,用户签字):**翻闸** `CatalogFactory.SPI_READY_TYPES += "max_compute"` + 删 legacy `case "max_compute"`(gate 全绿:compile/checkstyle 0/import-gate 0);**D-1 时序** = flip 先行、legacy 子系统删除 + fe-core odps 依赖 drop **待用户 live ODPS 验证后**做(保 flip 独立可回退);**D-2 依赖范围** = fe-core 仅删直接 `odps-sdk-*` 声明,transitive-via-fe-common 留(fe-common 供连接器/be-extensions)。Batch D 完整闭包(21 删 / ~30 清 / keep / pom)见 `designs/P4-batchD-maxcompute-removal-design.md`(OQ-3 穷举 re-grep 满足)。**2 SPI 新增登记 §20 E11**(D-026 预授):`ConnectorSession.setCurrentTransaction` + `ConnectorWriteOps.usesConnectorTransaction`;T06a 复核修 `PluginDrivenTableSink.getExplainString` `writeConfig==null` NPE 守卫记一笔 | 2026-06-07 | ✅ | +| D-026 | — | P4 Batch C 翻闸设计(用户签字,design-only):**D-1** capability signal = 新增 `ConnectorWriteOps.usesConnectorTransaction()` default false(MC=true;executor 据此在调任何 throwing-default 写法前分流 txn-model vs JDBC insert-handle);**D-2** 两 commit(`[P4-T06a]` 写接线/绑定/R-004 隔离测 dormant + `[P4-T06b]` flip 末提);**D-3** 静态分区/overwrite 绑定**入 cutover**(避 INSERT OVERWRITE PARTITION 翻闸回归)。**两新 SPI**(均 default-preserving):`ConnectorSession.setCurrentTransaction` + `ConnectorWriteOps.usesConnectorTransaction`(impl 时 E11 登记)。设计 `designs/P4-T05-T06-cutover-design.md` | 2026-06-06 | ✅ | +| D-025 | — | P4-T04 写计划 5 决策(D-1/D-2a 用户签字、D-3/D-4/D-5 主线定):D-1 **OQ-2=Approach A**(`planWrite` 在 finalizeSink 一处建 ODPS 写 session + `setWriteSession` 绑 txn + 盖 `txn_id`/`write_session_id`,无运行期注入 hook);D-2a 含 **fe-core seam fill**(`PluginDrivenTableSink.bindViaWritePlanProvider(insertCtx)` 读 overwrite+静态分区;`staticPartitionSpec` 加 `PluginDrivenInsertCommandContext` 非基类——避 `MCInsertCommandContext` override/shadow);D-3 抽 `MaxComputeDorisConnector.getSettings()`(legacy 单 `settings` 同供 scan+write,抽出=忠实港);D-4 `supportsInsert()`=true 余最小化(`beginInsert`/`finishInsert`/`getWriteConfig` 留 throwing-default,实际 executor 调用面待 Batch C);D-5 静态分区作 `getWriteContext()` col→val map | 2026-06-06 | ✅ | +| D-024 | — | P4-T03 两 fork(用户签字):(1) txn id 经新增 `ConnectorSession.allocateTransactionId()`(fe-core `Env.getNextId` 背书)由连接器分配——尊重 [D-015]/U3,补 id-less 连接器(MC 无外部 id)的分配器机制;(2) ODPS 写 session 创建挪 T04 planWrite(T03 = 纯事务容器,over W4 委派、gate 关 dormant)| 2026-06-06 | ✅ | +| D-023 | — | P4 maxcompute 启 full adopter(recon §9 option A):W-phase 后按 5 批(A 读/DDL parity → B 写/事务 → C 翻闸 → D 清引用+删 legacy → E 测)落地 + cutover;批次计划 tasks/P4 | 2026-06-06 | ✅ | +| D-022 | — | 写/事务 SPI 设计:A 连接器事务为源·桥接 / B1 commit 载荷 opaque bytes / C1 block-id 窄 callback seam / D INSERT·DELETE·MERGE(defer procedures)/ E 写-plan-provider 仿 scan | 2026-06-06 | ✅ | +| D-021 | — | P4 maxcompute 采 scope=C(写-SPI RFC 先行):先做共享写/事务 SPI + 通用层解耦(W-phase),再逐连接器 adopter | 2026-06-06 | ✅ | +| D-020 | — | 单 `hms` catalog 多格式 scan 路由 = 方案 B(`ConnectorMetadata.getScanPlanProvider(handle)` per-table default);细化 D-005(design-only,实现批 E/P7)| 2026-06-05 | ✅ | +| D-019 | — | P3 hudi 采用 hybrid:现做 model-agnostic 连接器硬化+测试(behind gate),推迟 catalog 模型落地+cutover 到 hive/HMS migration | 2026-06-04 | ✅ | +| D-018 | U6 | `ConnectorColumnStatistics` 用 javadoc 类型映射表 + IAE 保证类型安全 | 2026-05-24 | ✅ | +| D-017 | U5 | sys-table 命名统一 `$suffix`,别名机制留待未来 | 2026-05-24 | ✅ | +| D-016 | U4 | `getCredentialsForScans` 批量化,返回 `Map` | 2026-05-24 | ✅ | +| D-015 | U3 | `ConnectorTransaction.getTransactionId` 由连接器分配 | 2026-05-24 | ✅ | +| D-014 | U2 | 不新增 `invalidateColumnStatistics`,挂在 `invalidateTable` | 2026-05-24 | ✅ | +| D-013 | U1 | `ConnectorProcedureOps.listProcedures` 一次性返回,生命周期稳定 | 2026-05-24 | ✅ | +| D-012 | D12 | 用户安装 connector 后初版强制重启 FE | 2026-05-24 | ✅ | +| D-011 | D11 | `RemoteDorisExternalCatalog` 长期做 connector,不在本计划主线 | 2026-05-24 | ✅ | +| D-010 | D10 | `LakeSoulExternalCatalog` 在 P8 删除剩余类 | 2026-05-24 | ✅ | +| D-009 | D9 | API 版本号本计划范围内永不 +1,只新增 default 方法 | 2026-05-24 | ✅ | +| D-008 | D8 | 生产环境不允许 built-in connector,强制目录式插件 | 2026-05-24 | ✅ | +| D-007 | D7 | kafka/kinesis/odbc/doris 子目录不在本计划范围 | 2026-05-24 | ✅ | +| D-006 | D6 | Iceberg snapshot/manifest cache 放连接器内,fe-core 不感知 | 2026-05-24 | ✅ | +| D-005 | D5 | hudi/iceberg-on-HMS 用 `ConnectorTableSchema.tableFormatType` 区分 | 2026-05-24 | ✅ | +| D-004 | D4 | HMS event pipeline 放 `fe-connector-hms`,通过 `ConnectorMetaInvalidator` 回调 | 2026-05-24 | ✅ | +| D-003 | D3 | 旧 `*ExternalCatalog` 子类**全部删除**,不保留中间形态 | 2026-05-24 | ✅ | +| D-002 | D2 | `PluginDrivenScanNode` 长期保持 `extends FileQueryScanNode` | 2026-05-24 | ✅ | +| D-001 | D1 | 沿用已有 `SUPPORTS_PASSTHROUGH_QUERY`,不新增 query SPI | 2026-05-24 | ✅ | + +--- + +## 详细记录(时间倒序) + +### D-073 — C3b-core impl-recon 解 O1-O4 + 用户裁 ③ v3-lineage carrier=Option A(ConnectorColumn 加中立 uniqueId) +- **状态**:✅ 生效中|**日期**:2026-06-25|**签字**:用户(AskUserQuestion ×1)|**关联**:[D-072]、design §11 +- **背景**:C3b-core 实现前须首核 §10.6 开放项 O1-O4 + 锚点防漂移(step1+2 已改码、设计行号/不变式可能过时)。 +- **方法**:1 对抗 recon wf `wf_fa7057d5-39b`(6-slice O1-O4+锚点+bridge + 2 adversarial verify,**O1/O2 verdict 全 upheld**)+ 主 session 亲核 O2/① 锚点。 +- **解析**: + - **O1**:合成 `$operation/$row_id` **不进** thrift sink(按名 `setMaterializedColumnName:615`→`TSlotDescriptor.colName`,BE 按名匹配;连接器 `planWrite` 不读 `handle.getColumns()`)→ bridge **无需透传索引**,只需 `WriteOperation=MERGE/DELETE` 透到写 handle + post-flip 走 `PluginDrivenTableSink` 时把 slot-name loop 复制到 `visitPhysicalConnectorTableSink` 路。子项:DELETE 路(`visitPhysicalIcebergDeleteSink:588-598`)今天不跑 slot-name loop,待 impl 证合成 slot colName 怎么到 BE。 + - **O2(最深)**:post-flip `collect():64` 按 `instanceof IcebergScanNode` 过滤,scan 变 `PluginDrivenScanNode`(translator `:750`先于`:790`)→**静默 empty()**→v3 DV delete files 不 `removeDeletes`=**正确性回归(silent)**。native `DeleteFile` 过不了 classloader。修=**收集迁连接器**(`buildDeleteFiles:515` 现转 Serializable carrier 丢弃 native,须新增保留 `Map>` 喂 `setRewrittenDeleteFilesByReferencedDataFile:271`,iceberg-only seam)。仅阻塞 step-4,做到时专门 recon。 + - **O3**:plan-time 可同步取(今 legacy 已在 `getRequirePhysicalProperties` live-load);`getWritePartitioning` 只需 session+handle。**3 parity 须 UT**:null sourceColumnName/exprId→legacy 硬失败 clear(非 skip)/ `hasNonIdentity` 从 transform 字符串 `'identity'` 重算 / **新闸门 `enableStrictConsistencyDml`**(关时整段不调)。 + - **O4**:format-version 信号已发(`buildTableSchema:232 "iceberg.format-version"`);Option A 下 fe-core 不消费它做 ③ → **无需重命名 key**。 + - **锚点**:几乎零漂移。修正:per-op 命令 `run/getExplainPlan` cast **死码**(仅 3 reachable DB cast `:219/240/464`);executor cast 仅放宽 ctor 参数(tx 层已 ExternalTable、字段 `table` 已 TableIf);`ExecuteActionFactory`/`InsertIntoTableCommand` branch-guard 已 dual-mode 勿重做;ctx 中立重命名 **~28 站点**(比设计「~8」大 3 倍)。 +- **用户裁(AskUserQuestion)**:③ v3-lineage 两列(`_row_id`=2147483540 / `_last_updated_sequence_number`=2147483539)reserved-uniqueId carrier = **Option A**:`ConnectorColumn` 加中立 `uniqueId` 字段,连接器在 `buildTableSchema` 按 format≥3 声明(`invisible().withUniqueId(id)`)→ schema-cache 自动注入。**简化**:③-lineage 全连接器侧(fe-core 无需读 format-version),fe-core 仅处理请求级 STRUCT row-id 列 + injector guard。 +- **替代方案**:Option B(fe-core 端按 `format_version` 信号合成 lineage 两列)——SPI 面更小但把 iceberg 列名/保留ID 写进通用 fe-core,破 fe-core 中立、偏离 D3=iii「连接器声明合成写列」,未选。 +- **影响**:`ConnectorColumn` 加 `uniqueId` 字段(通用概念,paimon/jdbc 亦可用)+ `withUniqueId()`/`getUniqueId()` + converter `setUniqueId` 重应用(本 session 已做,additive/dormant、0 行为变更 pre-flip)。后续连接器 `buildTableSchema` emit v3 lineage 两列。 + +### D-057 — P4 MINOR/NIT 一次性 cleanup scope = fix 2(N10.1 + sentinel)+ accept 15(M5.1 + 14) +- **状态**:✅ 生效中|**日期**:2026-06-12|**签字**:用户(AskUserQuestion ×2) +- **背景**:P0/P1/P2/P3 全清后,HANDOFF 留 P4「一次性 cleanup」。review §5(MINOR/NIT 紧凑表)+ §7(completeness critic)去重得 ~17 项。HANDOFF 标唯一「真实数据边」= partition null-sentinel,值得单独定夺;其余多为 display-only/perf/text/benign。 +- **方法**:read-only 对抗 recon workflow `wf_6884d37b-8ef`——6 并行分类 agent 逐项对**当前**代码复核(line refs 可能已漂移)+ classify(DATA/FUNCTIONAL/DISPLAY/PERF/TEXT/BENIGN)+ fixScope(连接器禁 import fe-core,故触 fe-core-only 类型者非纯连接器);sentinel 专项 deep-dive + 2 对抗 skeptic 逐角度证伪 + completeness critic over 全批。 +- **结论(3 actionable + 14 accept)**: + - **N10.1 FIX**(`bcee91dcb52`):`PaimonTypeMapping.toVarcharType` `len>=65533`→STRING vs legacy `PaimonUtil:241` `>65533`;65533=`MAX_VARCHAR_LENGTH` 合法 exact-fit VARCHAR。纯连接器 1 字符 `>=`→`>`,display-only/零风险。新 `PaimonTypeMappingReadTest` fail-before 恰 65533 红 → pass-after,260/0/0。 + - **sentinel FIX**(`4b2c2190dc2`):scan 路 `ConnectorPartitionValues.normalize` 施 Hive-directory 哨兵 coercion 对 paimon 错(值已 typed,null=Java-null,哨兵从不出现)→ literal `\N`/`__HIVE_DEFAULT_PARTITION__` 分区值被误 NULL。**对抗 verifier 推翻 deep-dive ACCEPT**(漏 Nereids prune 路 `TablePartitionValues:162`;`\N` 非 paimon-保留)。修=纯连接器 scan `isNull=value==null` only(legacy `PaimonScanNode:323-326` parity),不动 shared `ConnectorPartitionValues`(hudi 经 `HudiScanRange:226` 仍需 Hive coercion)。commit 前 5-angle 对抗 review SAFE(全 3 range builder 汇于 `populateRangeParams`、无 query correct→wrong、BE isNull=true 时忽略 render string `partition_column_filler.h:40-44`、Java-null 保真、hudi 不动)。新 `PaimonScanRangePartitionNullTest` 4-case,261/0/0。 + - **M5.1 ACCEPT(flip)**:completeness critic 误设「cheap static fallback」前提,实现层证伪——`PaimonConnectorMetadata.getTableHandle:169-172` swallow-非NotExist-为-empty 是**有意+有测**契约(`PaimonConnectorMetadataReadAuthTest:150` `failAuth→empty`)且是共享 existence 谓词(`PluginDrivenExternalCatalog:239` tableExists + `:295` P3 createTable remoteExists + `:446`);`listSupportedSysTables` 忽略 handle。无 surgical 零成本修,transient-only severity。 + - **14 accept**([DV-035]):M9.1/M9.2(前提假——连接器跑同 `CredentialUtils` 路、无 drop)、M10.1/M10.2/M10.3/M7.1(display)、M6.1/M6.2(perf)、N2.1/M3.1/N4.1/C2(text)、N3.1/M2.1(inert no-op)、M4.1/M1.3(连接器**更** correct)、M1.1(diagnostic)。 +- **否决**:M5.1 broad `getTableHandle` retype(破有意 `failAuth→empty` 契约 + 触 P3 createTable 冲突检查);M5.1 SPI no-handle `listSupportedSysTables`(surface churn + 重引 legacy「为不存在 base 表列 sys-table」quirk)。sentinel full prune-路 parity(改 shared `TablePartitionValues` 会 regress hudi;连接器对 `__HIVE_DEFAULT_PARTITION__` prune 实**更** correct)。 +- **meta**:对抗 recon 两次见效——sentinel deep-dive 的 ACCEPT 被 prune-路 skeptic 推翻为真分歧(教训:partition-null parity 必须 scan **和** prune 双路看);M5.1 的「cheap fix」被实现层核查证伪(教训:completeness critic 的 fix 建议须落到代码契约/测试层再判 effort,别照单转 FIX)。 +- **跨连接器**:accepted 项中 false-premise/display/text 多为 hudi/iceberg full-adopter 同复发,归 [DV-035] 批量考量。 + +### D-056 — `FIX-CREATE-TABLE-LOCAL-CONFLICT`(P3 揪出,MAJOR correctness)= fix-now + Option-2 外科最小修 + +- **日期**:2026-06-12 +- **状态**:✅ 生效 +- **关联**:[task-list §P3](./task-list-P5-rereview2-fixes.md)、[设计](./tasks/designs/P5-fix-CREATE-TABLE-LOCAL-CONFLICT-design.md)、[DV-034](./deviations-log.md)、P3 对抗 review `wf_25450c36-b7a` +- **背景**:P3 覆盖缺口核查(「去查」非「去改」)的 4 项 plugin-vs-legacy paimon parity 中,3 项 PARITY_HOLDS(HMS-CONFRES:key 拼写恰 `hive.conf.resources`、无 `config.resources` 别名、round-1 wiring 在、BE-downflow 两侧同——legacy HMS hive-site.xml 本就不入 BE scan props;ANALYZE/列统计:`getColumnStatistic` 两侧 `Optional.empty()`、`createAnalysisTask` byte-同、generic 于桥非 paimon regression;split-count:post-sub-split 数经共享父 `FileQueryScanNode.selectedSplitNum` 喂 `SqlBlockRuleMgr` 两侧同、2 项 cosmetic/NIT 且 pre-date #9),唯 DDL 写揪出真分歧:对抗 verifier 把 tracer 的 createTable PARITY 推翻为 DIVERGENCE——通用桥 `PluginDrivenExternalCatalog.createTable` 丢了 legacy `PaimonMetadataOps:206-214` 的 local-arm 拒绝(详见 §索引 D-056 正文)。 +- **决策**:用户签字 **convert-to-FIX now**(vs log-as-deviation / investigate-more)。实现取 **Option 2 外科最小修**:仅补 local-conflict 闸,case-A(remote-hit)行为不动。 +- **替代方案**:Option 1 full-parity(对 `exists&&!ifNotExists` 全 retype 1050)——否决:改非分歧 case-A + 破既有 intentional 测(`testCreateTableExistingTableWithoutIfNotExistsStillErrors` 钉 remote-hit→连接器抛 generic)+ 越 finding 界。 +- **影响**:fe-core `PluginDrivenExternalCatalog.java`(拆 OR + 加 guard、+2 import `ErrorCode`/`ErrorReport`)+ test(+1)。零 SPI/连接器/BE/RFC。**通用桥修跨连接器收口**(MaxCompute/未来 iceberg/hudi 同受益,呼应 P3 item-5 跨连接器 follow-up)。残留 case-A error-code-generic = [DV-034] 留 P4 cleanup。 + +### D-055 — `FIX-NATIVE-SUBSPLIT`(#9 M-3)= 连接器侧移植 native 文件切分(纯连接器,零 SPI/零 fe-core) + +- **日期**:2026-06-12 +- **状态**:✅ 生效 +- **关联**:[task-list #9](./task-list-P5-rereview2-fixes.md)、[设计](./tasks/designs/P5-fix-NATIVE-SUBSPLIT-design.md)、[第二轮 review report](./reviews/P5-paimon-rereview2-2026-06-11.md)(M-3)、[DV-033]、recon `wf_ad764bf6-1c9` +- **背景**:翻闸后大 native ORC/Parquet paimon 文件只得一个 scanner(无文件内并行),连接器 native 臂每 RawFile 发一个整文件 range;legacy 经 `FileSplitter.splitFile` 切大文件。结果正确仅并行度回归(perf-parity)。 +- **决策**:(1) **fix-now**(vs defer,P2 scope 用户签字)。(2) **纯连接器、零 SPI、零 fe-core**:切分 math 是对 5 个 session var 的 long 算术(`VariableMgr.toMap` 通道,同 `isCppReaderEnabled`),连接器禁 import fe-core `FileSplitter`/`SessionVariable` 故逐字重述;`start/length/fileSize` 经既有 `PaimonScanRange.Builder` 序列化路径达 BE,无新 SPI/thrift。(3) **DV×sub-split 安全、不设 guard**:同一 per-RawFile DeletionFile 原样附到每个 sub-range(DV 按全局行位、BE 部分 byte range 仍报全局行位、`_kv_cache` 按 path+offset 共享位图、iceberg 同机制)。(4) 仅移植 specified-size 分支(block-based 死代码:连接器 target 恒>0、blockLocations=null)。 +- **替代方案**:① **defer**(登 deviations)——用户选 fix-now。② **经 SPI 把 fe-core FileSplitter 暴露给连接器** / **fe-core 侧切分**——否决:切分纯 math、连接器自足、无 fe-core 改最小 blast。③ **DV-bearing 文件不切**(保守 guard)——recon 证伪(DV+split 安全),不必要地放弃 DV 文件的并行。 +- **影响**:1 产线文件(连接器 `PaimonScanPlanProvider`:5 常量 + 2 纯静态 + `sessionLong`/`resolveTargetSplitSize` + native 臂 loop + `buildNativeRange` 加 start/length)+ 1 测文件。**零 SPI**(无 RFC 条目)、**零 fe-core**。split-weight 调度 nicety 不移植 [DV-033]。守门见索引行。 + + +- **日期**:2026-06-12 +- **状态**:✅ 生效 +- **关联**:[task-list #8](./task-list-P5-rereview2-fixes.md)、[设计](./tasks/designs/P5-fix-COUNT-PUSHDOWN-design.md)、[第二轮 review report](./reviews/P5-paimon-rereview2-2026-06-11.md)、[DV-032]、[01-spi-extensions-rfc.md §23 E15](./01-spi-extensions-rfc.md) +- **背景**:翻闸后 plugin-driven paimon `COUNT(*)` 结果正确但慢。recon(5-scout + 对抗 synthesizer `wf_1ce48c93-325`)逐链核实三半中只缺一半:① **emit 半已建全**——`PaimonScanRange.Builder.rowCount`→prop `paimon.row_count`→`populateRangeParams.setTableLevelRowCount`(else -1),与 legacy `PaimonScanNode:303-308` byte-一致,**无新 thrift / 无 BE 改**;② **COUNT 枚举已达 BE**——`PhysicalPlanTranslator:873` 在 `PluginDrivenScanNode` 设 `pushDownAggNoGroupingOp=COUNT`(Nereids 不排除 plugin),`FileScanNode.toThrift:90` 发出,BE 已在 count 模式;③ **信号+计算缺**(bug)——`DataSplit.mergedRowCount()` 是 paimon-SDK-only 须连接器算;COUNT 信号 `getPushDownAggNoGroupingOp()==COUNT` 只在 fe-core 节点、`PluginDrivenScanNode.getSplits` 从不读(grep 0)、不在任何 `planScan`/`ConnectorSession`/`ConnectorContext`/handle → 每 split 发 `table_level_row_count=-1` → BE 物化全 post-merge 行去 count(`file_scanner.cpp:1298-1326`)。 +- **决策**:(1) **fix-now**(vs defer)。(2) **count-split 形状 = 连接器 collapse-to-one**:连接器累加全 count-eligible split 的 `mergedRowCount` 入 `countSum`、留首个 split 为代表、循环后发**一** JNI count range 携 `countSum`;= legacy `<=10000` 路径(`singletonList(first)` + `assignCountToSplits([one], sum)` → 一 split 携全 total)普遍化。(3) **SPI 参数 = `boolean countPushdown`**(BE 只需 COUNT-vs-not;`TPushAggOp` 过度泛化、把 thrift 枚举拉进 SPI 签名)。(4) **作用域 = paimon-only**(default no-op overload)。修=3 文件:SPI `ConnectorScanPlanProvider` +1 default 7-arg `planScan(...,boolean countPushdown)` 委托 6-arg [E15];fe-core `PluginDrivenScanNode.getSplits` 读 agg-op 传入(无 post-loop 数学);连接器抽 `planScanInternal(...,countPushdown)`(4-arg 委托 false、7-arg 委托 flag)+ count 短路第一臂 + 纯静态 `isCountPushdownSplit` + `buildCountRange`。 +- **替代方案**:① **defer**(登 deviations)——用户选 fix-now。② **经 `ConnectorSession` 穿信号**(FIX-FORCE-JNI 先例,零 SPI 签名改)——**否决**:agg-op 是 per-query planner 输出非 SET-var,会成静默无类型通道(本项目反复踩的 handle-bypass/signal-not-threaded bug 类)。③ **full-parity fe-core trim**(连接器发 per-split、fe-core 按 numBackends trim+redistribute)——更多 fe-core 代码、把 count 语义耦进通用 `ConnectorScanRange`,否决。④ **per-split(不 collapse)**——最简但比 legacy 多 fragment,否决。⑤ **`TPushAggOp` / typed `ScanContext` 参数**——过度泛化,选 boolean。 +- **影响**:3 产线文件(SPI +1 default 方法、fe-core getSplits、连接器 planScan)+ 1 测文件。**API 不 +1**(仅新增 default,[D-009])。SPI 新面记 [E15](RFC §23)。perf 偏差 [DV-032](collapse-to-one 丢 legacy `>10000` 并行 split trim)。**跨连接器**:新 default overload 利好 hive/iceberg/maxcompute,但 paimon-only 实现(default no-op)→ 将来 full-adopter 各自 override 即可。守门见索引行。 + + +- **日期**:2026-06-08 +- **状态**:✅ 生效 +- **关联**:[FIX-PRUNE-PUSHDOWN 设计](./tasks/designs/P4-T06e-FIX-PRUNE-PUSHDOWN-design.md)、[review-rounds](./reviews/P4-T06e-FIX-PRUNE-PUSHDOWN-review-rounds.md)、[复审 §B DG-1](./reviews/P4-maxcompute-full-rereview-2026-06-07.md)、[D-028](FIX-PART-GATES 只落元数据半边)、[DV-015] +- **背景**:翻闸后 plugin-driven MaxCompute 读走通用 `PluginDrivenScanNode`。Nereids `PruneFileScanPartition` 借 FIX-PART-GATES 加的分区元数据 API **算出** `SelectedPartitions`,但 `PhysicalPlanTranslator` plugin 分支(`:753-758`)**从不**调 `setSelectedPartitions`(对比 Hive `:773`/legacy-MC `:797`/Hudi `:882`),`PluginDrivenScanNode` 无承接字段,`MaxComputeScanPlanProvider` 恒传 `requiredPartitions=Collections.emptyList()`(`:201`/`:320`)→ ODPS read session 跨**全分区**。3 lens 对抗复审无法证伪。**纯性能/内存回归**(MaxCompute 未 override `applyFilter`→conjunct 不清→BE 重算→行正确)。这正是原 cutover-review READ-C2 修复建议的「②透传 selectedPartitions→planScan 接 requiredPartitions」半——FIX-PART-GATES 只落「①元数据 API」半([D-028])。 +- **决策**:(a) `ConnectorScanPlanProvider` 加 6 参 `planScan(session,handle,columns,filter,limit,List requiredPartitions)` **default** 方法,委托回 5 参(镜像既有 5 参 limit overload 模式)→ **零破坏** es/jdbc/hive/paimon/hudi/trino(继承 default),唯一 override=MaxCompute。**契约**:`null`/空=不裁剪 scan all;非空=仅扫这些分区名(`SelectedPartitions.selectedPartitions` keySet);「裁剪为零」由 fe-core 短路、永不到 SPI。(b) `PluginDrivenScanNode` 加 `selectedPartitions` 字段(默认 `NOT_PRUNED`)+ setter + 纯函数 `resolveRequiredPartitions`(三态:`!isPruned`→null / pruned-非空→names / pruned-空→空 list)+ `getSplits` 短路(空 list→无 split,镜像 legacy `MaxComputeScanNode:724-727`)+ 6 参调用。(c) `PhysicalPlanTranslator` plugin 分支注入 `setSelectedPartitions(fileScan.getSelectedPartitions())`。(d) MaxCompute override 6 参,`toPartitionSpecs(List)`→`List`(镜像 legacy `new PartitionSpec(key)`)喂**两** read-session 路径(标准 + limit-opt)。 +- **替代方案**:① 改 `planScan` 签名(破坏全 7 连接器)——否决,default overload 零破坏;② 编码进 `ConnectorTableHandle`(如 Hive/Hudi 经 `applyFilter` 存 pruned partitions)——MaxCompute 未 override `applyFilter` 且会重导出 Nereids 已算的裁剪、less faithful;③ `ConnectorSession` 携带——session 非 scan 级、hacky。capability/overload-additive 与 P0-1/P0-2/P0-3 模式一致。 +- **影响**:4 产线文件(`ConnectorScanPlanProvider` SPI +default / `MaxComputeScanPlanProvider` override+`toPartitionSpecs`+两路径 threading / `PluginDrivenScanNode` 字段+setter+helper+短路 / `PhysicalPlanTranslator` 注入)+ 2 UT。**scope 边界**:Hudi-SPI plugin 分支(`visitPhysicalHudiScan`)本次不接——生产不可达(`SPI_READY_TYPES` 不含 hudi)+ Hudi provider 走 default 忽略 requiredPartitions,deferred DV-006。**与 NG-7(batch-mode)解耦**但为其前置。**Batch-D 红线**:删 legacy `MaxComputeScanNode` 须待本 fix 落(读裁剪下推逻辑副本)。**follow-up**:wiring 无 fe-core 端到端 UT → [DV-015];真值闸 live e2e(p2 `test_max_compute_partition_prune.groovy` + EXPLAIN/profile 证仅扫目标分区)。 + +### D-030 — P4-T06e FIX-BIND-STATIC-PARTITION 新增 SPI capability SINK_REQUIRE_FULL_SCHEMA_ORDER + 回退 D-029 索引(用户批准扩 scope) + +- **日期**:2026-06-07 +- **状态**:✅ 生效 +- **关联**:[FIX-BIND-STATIC-PARTITION 设计](./tasks/designs/P4-T06e-FIX-BIND-STATIC-PARTITION-design.md)、[review-rounds](./reviews/P4-T06e-FIX-BIND-STATIC-PARTITION-review-rounds.md)、[D-029](被部分回退)、[D-026 DECISION-3] +- **背景**:翻闸后真实 MaxCompute catalog = `PluginDrivenExternalCatalog`,所有 MC 写走通用 `bindConnectorTableSink`。该方法克隆自 `bindJdbcTableSink`(JDBC 按列名生成 INSERT SQL、数据 cols/用户序即可),但 **MaxCompute BE/JNI writer 按位置映射** Arrow 列到 `writeSession.requiredSchema()`(完整表 schema 序)。后果:① 静态分区无列名 `INSERT INTO mc PARTITION(pt='x') SELECT <非分区列>` 列数校验抛(F19/F48 blocker);② 静态分区列未在 full-schema 末尾 → BE 末尾擦除契约错位;③ **非分区** MC 重排/部分显式列名静默错列/丢列。legacy `bindMaxComputeTableSink` **无条件** full-schema 投影(不论分区与否)——通用路径漏了这层。 +- **决策**:(a) 新增 `ConnectorCapability.SINK_REQUIRE_FULL_SCHEMA_ORDER`("连接器按位置写 full-schema",default 不声明);MaxCompute `getCapabilities()` 声明之、JDBC/ES 不声明;`PluginDrivenExternalTable.requiresFullSchemaWriteOrder()` 读之。(b) `bindConnectorTableSink` 分支键 = `table.requiresFullSchemaWriteOrder()`:true→full-schema 投影(`getColumnToOutput`+`getOutputProjectByCoercion(getFullSchema())`,镜像 legacy,对**全**MC 写形);false→cols 序(JDBC/ES)。(c) **回退 D-029**:`PhysicalConnectorTableSink.getRequirePhysicalProperties` 分区列索引 cols→full-schema(因 child 现恒 full-schema 序;cols 索引在 partial-static/重排下错列)。(d) `selectConnectorSinkBindColumns` 无列名时剔除静态分区列(镜像 legacy);`InsertUtils` VALUES 路径加 `UnboundConnectorTableSink` 分支。 +- **替代方案**:判别键 = `!staticPartitionColNames.isEmpty()`(round-1 证伪:纯动态重排错列)→ `!getPartitionColumns().isEmpty()`(round-2 证伪:非分区 MC 重排/部分错列)→ **capability**(终态 = legacy 全 parity)。亦考虑 bind 期查 `connector.getWritePlanProvider()!=null`(更重、less explicit);capability 与 P0-2 模式一致且可扩展(未来按位置写连接器自声明)。 +- **影响**:4 产线文件(`ConnectorCapability` SPI / `MaxComputeDorisConnector` / `PluginDrivenExternalTable` reader / `BindSink` bind + `PhysicalConnectorTableSink` 索引)+ `InsertUtils`。两写 capability 正交但有硬依赖(`SINK_REQUIRE_PARTITION_LOCAL_SORT` ⟹ `SINK_REQUIRE_FULL_SCHEMA_ORDER`,已 javadoc 登记,nit P03-V3-1)。**Batch-D 红线**:删 legacy `bindMaxComputeTableSink`/`PhysicalMaxComputeTableSink` 须待本 fix 落(已落)。**follow-up**:bind 投影无 fe-core 单测 harness → DV-014;真值闸 live e2e(p2 `test_mc_write_insert` Test 3/3b + `test_mc_write_static_partitions`)。 + +--- + +### D-029 — P4-T06e FIX-WRITE-DISTRIBUTION 新增 SPI capability SINK_REQUIRE_PARTITION_LOCAL_SORT + +- **日期**:2026-06-07 +- **状态**:✅(已落 commit `f0adedba20c`;live e2e 真值闸待真实 ODPS) +- **关联**:[FIX-WRITE-DISTRIBUTION 设计](./tasks/designs/P4-T06e-FIX-WRITE-DISTRIBUTION-design.md)、[review-rounds](./reviews/P4-T06e-FIX-WRITE-DISTRIBUTION-review-rounds.md)、[复审报告 §A.NG-2/NG-4](./reviews/P4-maxcompute-full-rereview-2026-06-07.md)、[D-001](capability 沿用先例)、[DV-013]、Batch-D 红线 +- **背景**:翻闸后 MaxCompute 写走通用 `PhysicalConnectorTableSink`,其 `getRequirePhysicalProperties()` 只有 `supportsParallelWrite?RANDOM:GATHER`,且 `MaxComputeDorisConnector` 无 `getCapabilities` override(空集)→ 每写落 GATHER。丢 legacy `PhysicalMaxComputeTableSink` 的动态分区 hash-by-partition + 强制 local-sort(ODPS Storage API 流式分区 writer,见新分区即关上一 writer,未分组行触发 "writer has been closed")+ 非分区/全静态并行写。通用 sink 从 JDBC/ES 克隆,无通道让连接器声明该需求。 +- **决策(Option A)**:新增 `ConnectorCapability.SINK_REQUIRE_PARTITION_LOCAL_SORT`(连接器声明动态分区写需 hash-by-partition + 强制 local-sort);MaxCompute `getCapabilities()` 声明它 + `SUPPORTS_PARALLEL_WRITE`;`PluginDrivenExternalTable.requirePartitionLocalSortOnWrite()` 读之(镜像 `supportsParallelWrite()`,经 `connector.getCapabilities().contains(...)`);`PhysicalConnectorTableSink.getRequirePhysicalProperties()` 重写 legacy 3 分支。**关键修正 vs legacy**:分区列 → child output 索引按 **cols 位置**(通用 sink 的 child 投影到 cols 序,`BindSink` 强制 `cols.size()==child output size`),非 legacy 的 full-schema 位置。default 不声明 → 其他连接器零行为变更。 +- **替代方案**:(B) 隐式 derive(`supportsParallelWrite && hasPartition && dynamic → 强制 hash+local-sort`)—— 拒:把 MC Storage-API 的 local-sort 政策强加到所有并行写分区连接器(含 per-partition 缓冲、本不需 sort 的);(C) `ConnectorWriteOps` 方法(仿 `supportsInsertOverwrite`)—— 拒:sink 读它需在 property-derivation 热路建 `ConnectorSession` + `getMetadata`,而 sibling `supportsParallelWrite()`(同方法内读)用更廉价的 `getCapabilities()` 集,不一致。 +- **影响**:fe-connector-api(1 枚举值)+ fe-connector-maxcompute(`getCapabilities`)+ fe-core(1 table 方法 + sink 3 分支重写)。blast radius:`SUPPORTS_PARALLEL_WRITE`/新能力仅 sink 分发路径读(grep 实证 2+1 reader;唯一另一 `getCapabilities` consumer `QueryTableValueFunction` 查 `SUPPORTS_PASSTHROUGH_QUERY`,MC 不声明 → 不受影响)。**Batch-D 红线**:删 `PhysicalMaxComputeTableSink`(写分发唯一逻辑副本)须待本 fix + P0-3 双落。`ShuffleKeyPruner` non-strict 少剪 + `enable_strict_consistency_dml=false` 丢 local-sort = [DV-013]。 + +### D-028 — 翻闸功能未完整,补 P4-T06c FE 分发接线(用户签字) + +- **日期**:2026-06-07 +- **状态**:✅(翻闸前置工作;实现 = P4-T06c,下一 session) +- **关联**:[tasks/P4](./tasks/P4-maxcompute-migration.md)(新增 P4-T06c)、[HANDOFF](./HANDOFF.md)「⚠️ 关键发现」、[D-027](翻闸落地)、[Batch D 设计](./tasks/designs/P4-batchD-maxcompute-removal-design.md)(前置门 + §2 处置随之改)、DV-007(`listPartition*` 零 live caller) +- **背景**:用户问「如何做 live 验证 / 验证哪些内容」。并行 recon(catalog 建法 / smoke SQL / SPI 路径映射 / build-deploy)+ **代码逐条核实** 暴出:T05/T06 翻闸**只接通**了 读(SELECT,`PluginDrivenScanNode`)/CREATE TABLE(`PluginDrivenExternalCatalog.createTable:257` override)/写(INSERT 全家,G1–G5)。**未接通**(live 会 FAIL,均 file:line 核实): + - **DROP TABLE / CREATE DB / DROP DB**:`PluginDrivenExternalCatalog` **不** override 这些、`metadataOps` **永远 null** → `ExternalCatalog.dropTable:1105`/`createDb:1004`/`dropDb:1029` 抛 `... is not supported for catalog`。(RENAME TABLE 同,且连接器侧未 port。) + - **SHOW PARTITIONS**:`ShowPartitionsCommand:202-207` allow-list 仍按 `instanceof MaxComputeExternalCatalog`,翻闸后 catalog 是 `PluginDrivenExternalCatalog` → `not allowed`。 + - **partitions() TVF**:`MetadataGenerator.partitionMetadataResult:1308-1319` `instanceof MaxComputeExternalCatalog` 落空 → `not support catalog`。 + - 连接器侧 `createDatabase/dropDatabase/dropTable`(P4-T01)+ `listPartitionNames/listPartitions/listPartitionValues`(P4-T02)**已实现但 FE 零调用方**(DV-007 已记)。tasks/P4 §批次依赖原写「翻闸即 读/写/DDL/分区/show 全切 SPI」**与代码不符**,已纠正。 +- **决策(用户 AskUserQuestion 签字,选「翻闸前全补接线」)**:视翻闸为**未完成**;Batch D 之前插 **P4-T06c**,把 DDL(createDb/dropDb/dropTable)+ SHOW PARTITIONS + partitions() TVF 的 **FE 分发接到已有连接器 SPI**。要点: + - **通用实现**(keyed on `PluginDrivenExternalCatalog` / `PLUGIN_EXTERNAL_TABLE`,**非 MC 专有**)→ ① 同时修 jdbc/es/trino 同类缺口;② 让 Batch D §2 对 `ShowPartitionsCommand`/`MetadataGenerator`/`PartitionsTableValuedFunction` 的处置从 **delete-branch** 退化为**删残留 legacy MC 引用**(先 rewire 后删,解 Batch D 设计 §2 与 RFC `:1065`/master-plan `:126` 的删-vs-rewire 冲突)。 + - DDL override 镜像现有 `createTable:257`(路由 `connector.getMetadata().{createDatabase/dropDatabase/dropTable}` + editlog)。SHOW PARTITIONS / partitions TVF 加 `PluginDrivenExternalCatalog` 分支路由 `listPartitionNames`。 + - **本任务只补 FE 接线**(连接器方法已存在)= "接线"非"重写"。 +- **scope 边界**:`partition_values()` TVF(`MetadataGenerator:2080` HMS-only)**不入 T06c**(OQ-5:legacy MC 很可能本就不支持 = 既有限制非回归,待确认)。RENAME TABLE 需连接器先 port,次要/可推迟(不在 live smoke 列表)。 +- **完成门**:T06c 落(fe-core gate + UT)→ **用户报 live 验证全绿**([D-027] D-1 的 `OdpsLiveConnectivityTest` + 手测 smoke 11 项全绿)= 翻闸真正完成 → 才解锁 Batch D。**flip 在 live 绿前保持独立可 revert**(沿 [D-027] D-1)。 + +> ⚠️ **2026-06-08 补注(DG-1 / D-031)**:本决策的「分区」接线指**元数据可见性**(SHOW PARTITIONS / partitions TVF),由 T06c + FIX-PART-GATES 落地。**read-session 分区裁剪下推**(把 Nereids 算出的 `SelectedPartitions` 真正喂到 ODPS)**不在 T06c/D-028 范围**,且后续复审 DG-1 证伪了 FIX-PART-GATES「pruning 不变式 clean」的过度声明——由 **FIX-PRUNE-PUSHDOWN(D-031)** 补齐。即:D-028/T06c 恢复元数据可见性 ✅、read-session 裁剪下推 = D-031 ✅。 + +- **日期**:2026-06-07 +- **状态**:✅(翻闸已落、gate 全绿;Batch D 移除 = 待 live 验证后做) +- **背景**:用户要求「开始下一步(T06b 翻闸)」+ 追加「fe-core 不再依赖任何 maxcompute jar」。recon(并行 re-grep + 对抗验证,OQ-3 入口门满足)证:fe-core `odps-sdk-core`/`odps-sdk-table-api` 仅经 legacy MaxCompute 子系统(7 文件 `import com.aliyun.odps`,全在删除集)可达 → 去依赖 = 删整套 legacy(21 文件)+ 清 ~30 反向引用(即整个 Batch D)。 +- **决策**: + - **翻闸(T06b)**:`CatalogFactory.SPI_READY_TYPES += "max_compute"`(:52) + 删 `case "max_compute"`(原 :146-149) + 删 unused import + 注释去 max_compute。gate 全绿(compile BUILD SUCCESS/MVN_EXIT=0 + checkstyle 0/CS_EXIT=0 + import-gate 0,真实 EXIT 核)。 + - **D-1(时序)= flip 先行、移除待 live 验证**:本任务只落 flip(独立可回退);legacy 子系统删除 + pom odps drop(Batch D)挪到**用户跑 `OdpsLiveConnectivityTest`(4 个 `MC_*` 环境变量)+ 手测 smoke 绿之后**的紧邻 follow-up。理由:删 legacy 即去掉易回退的 fallback,故 flip 在 live 验证前保持独立可 revert(trino 翻闸亦 flip 先于删除)。 + - **D-2(依赖范围)= 仅删直接声明**:fe-core/pom.xml 删两 `odps-sdk-*` 块即可;fe-core 删后**零** odps 源引用,但仍经 fe-common transitive 见 `odps-sdk-core`(fe-common 留 odps 供 `MCUtils` → 连接器 + be-java-extensions),可接受(用户选 "Direct declarations only")。镜像 trino `c4ac2c5911d`(只删 fe-core 直接声明)。 +- **2 SPI 新增登记**(D-026 预授,default-preserving):`ConnectorSession.setCurrentTransaction` + `ConnectorWriteOps.usesConnectorTransaction` 录入 `01-spi-extensions-rfc.md` §20 E11。T06a 对抗复核已修 `PluginDrivenTableSink.getExplainString` 加 `writeConfig==null` 守卫(防 plan-provider 模式 EXPLAIN NPE,翻闸后可达)——记一笔。 +- **设计文档(Batch D 执行源,turnkey)**:[tasks/designs/P4-batchD-maxcompute-removal-design.md](./tasks/designs/P4-batchD-maxcompute-removal-design.md)(21 删除集 + 84 反向引用闭包 + keep 集 + pom drop + ordered TODO;执行前置门 = live 验证绿)。 + +### D-026 — P4 Batch C 翻闸设计(3 子决策 + 2 SPI 新增,用户签字) + +- **日期**:2026-06-06 +- **状态**:✅(design-only;实现 = T05 → T06,下一 fresh session) +- **背景**:Batch A+B 全完成(gate 关 dormant),下一 = Batch C(唯一 live 切点)。本场 design-first:4 路 Explore re-verify recon 锚点 + 主线核读 executor/txn 生命周期,定 dormant→live 写接线(坑3 三点)+ flip + R-004。recon 校正:GsonUtils 真锚 `:397`/`:472`(非 ~405/~478);`legacyLogTypeToCatalogType` 默认分支已出 `"max_compute"`(**无需加 case**);live executor = `PluginDrivenInsertExecutor`(非裸 `beginTransaction`);`PluginDrivenTransactionManager.begin(connectorTx)` **未** `putTxnById`(G3);`UnboundConnectorTableSink` 不携静态分区(G4)。 +- **决策**: + - **D-1(capability signal)= (A)** 新增 `ConnectorWriteOps.usesConnectorTransaction()` default false,`MaxComputeConnectorMetadata` override true。executor 据此在调任何 throwing-default 写法(`getWriteConfig`/`beginInsert`/`beginTransaction` 全 default 抛、MC 留抛=D-4)前分流 txn-model(MC)vs JDBC insert-handle。否决 (B) `getWritePlanProvider()!=null` 代理(耦合松)/(C) 复用 `ConnectorWriteType`(逆 D-4 + enum churn + getWriteConfig 调用前移)。 + - **D-2(commit 粒度)= 两 commit、flip 末**:`[P4-T06a]` = 写接线(W-a..d)+ 静态分区/overwrite 绑定(G4/G5)+ R-004 隔离 UT(全 additive/dormant-safe);`[P4-T06b]` = `CatalogFactory.SPI_READY_TYPES += "max_compute"`(:52) + 删 :146 case(唯一 live-switch 单点,易 review/revert)。 + - **D-3(静态分区/overwrite 绑定 scope)= 入 cutover(T06)**:扩 `UnboundConnectorTableSink` 携静态分区 + `InsertIntoTableCommand`/`InsertOverwriteTableCommand` 填 `PluginDrivenInsertCommandContext`(overwrite + staticPartitionSpec)。避免翻闸瞬间 INSERT OVERWRITE / 静态分区 INSERT 回归。 +- **SPI 新增(2,均 default-preserving,零 jdbc/es/trino 影响)**:`ConnectorSession.setCurrentTransaction(ConnectorTransaction)`(+ `ConnectorSessionImpl` 字段/`getCurrentTransaction` override;把 connectorTx 绑入 sink session 供 T04 `planWrite` 读,解 G1);`ConnectorWriteOps.usesConnectorTransaction()`(D-1)。impl 时登记 `01-spi-extensions-rfc.md` §20 E11。 +- **不重开 T03/T04**:Approach A locked(`planWrite` 读 `getCurrentTransaction`);本设计接线 *到* 它。R-004 拆两分:① classloader 隔离(无 creds,CI 可跑)+ ② live 连通(creds,用户跑)。 +- **设计文档**:[tasks/designs/P4-T05-T06-cutover-design.md](./tasks/designs/P4-T05-T06-cutover-design.md)(verified file:line 锚点 + 5 gap G1–G5 + lifecycle order + R-004 两分测 + ordered TODO)。 +- **T05 实现校正(2026-06-06,gate-green、待 commit)**:实现期 4-agent 对抗复核发现 §3.1/§8 ordered TODO **漏 GSON DB `:452`**(`MaxComputeExternalDatabase`,仅列了 catalog `:397`+table `:472`);折入 T05(三注册齐迁 `registerCompatibleSubtype` + 删 3 unused import),否则翻闸后 `MaxComputeExternalDatabase.buildTableInternal:44` cast `PluginDrivenExternalCatalog`→`MaxComputeExternalCatalog` 抛 `ClassCastException`。另 2 告警判非问题(`getMetaCacheEngine` 假阳性=plugin 路径经连接器取 schema、走 "default" 桶同 es/jdbc/trino;`getMysqlType`→"BASE TABLE" 同 ES 既定行为);dormancy 告警 = 既载中间态 caveat(其"保留 registerSubtype"修法错,会撞 duplicate-label IAE)。详见设计 §3.4。 + +### D-025 — P4-T04 写计划 5 决策(OQ-2 解法 + seam fill + 三主线定) + +- **日期**:2026-06-06 +- **状态**:✅ 生效 +- **关联**:[tasks/P4 P4-T04](./tasks/P4-maxcompute-migration.md)、[P4-T04 设计](./tasks/designs/P4-T04-write-plan-design.md)、[D-024](T03/T04 边界、`setWriteSession` 槽)、[DV-009](W5 planWrite layer)、[DV-012](partition_columns 源)、OQ-2 +- **背景**:T04 把 legacy 写计划(`MCTransaction.beginInsert` 建写 session + `MaxComputeTableSink.bindDataSink`/`setWriteContext` 产 `TMaxComputeTableSink`)港入连接器 over W5 opaque-sink seam。核心难点 OQ-2 = legacy 经 `MCInsertExecutor.beforeExec` **运行期注入**的 `txn_id`/`write_session_id`、overwrite/静态分区 context 需在 plugin-driven 侧重建。 +- **决策**: + - **D-1(OQ-2 架构,用户签字)= Approach A**:executor 生命周期序 `beginTransaction`(txn_id 译前生)→translate→`finalizeSink`/`bindDataSink(insertCtx)`→`beforeExec`→coordinator ⇒ `planWrite` 跑在 finalizeSink、txn_id 已在 + ODPS 写 session 可就地建 → **planWrite 一处做完**(建 session + `session.getCurrentTransaction()`→`MaxComputeConnectorTransaction.setWriteSession` + 盖 `txn_id`/`write_session_id`)。**无运行期注入 hook**(否决 Approach B = 泛化 legacy `setWriteContext` dance)。 + - **D-2a(fe-core seam 填充,用户签字)= 含 seam fill**:`PluginDrivenTableSink.bindViaWritePlanProvider` 改收 `Optional`、读 `isOverwrite()`+`getStaticPartitionSpec()` 填 handle;**实现期细化**:`staticPartitionSpec` 加在 `PluginDrivenInsertCommandContext`(非设计「Why」倾向的基类 `BaseExternalTableInsertCommandContext`)——因 `MCInsertCommandContext` 已自带 `staticPartitionSpec`+getter 且 shadow 基类 `overwrite`,加基类会成 override/shadow 缠结(Rule 3 surgical);plugin-driven seam 只见 `PluginDrivenInsertCommandContext`,post-migration hive/iceberg 复用同类,复用目标仍满足。在设计「`PluginDrivenInsertCommandContext`(或基类)」envelope 内。 + - **D-3(EnvironmentSettings 复用,主线定)= 抽 `MaxComputeDorisConnector.getSettings()`**:决定性证据——legacy `MaxComputeExternalCatalog` 持**单** `settings` 字段同供 scan(`MaxComputeScanNode`)+ write(`MCTransaction.beginInsert`),故抽出共用是**忠实港 legacy 设计**(非投机重构,化解 Rule 3 张力);scan provider :146-162 构造上移、scan/write 共用。连接器 gate 关 dormant,动 scan 零 live 风险。 + - **D-4(insert 机制面,主线定)= `supportsInsert()`=true 余最小化**:MC sink 经 `planWrite`、commit 经 `ConnectorTransaction.commit()`,故 `beginInsert`/`finishInsert`/`getWriteConfig` 留 throwing-default(无 MC 实质活);实际 executor 调用面以 Batch C 为准(不投机加 no-op,Rule 2;显式 doc 不静默,Rule 12)。 + - **D-5(writeContext 编码,主线定)= 静态分区作 `getWriteContext()` 的 col→val map**;overwrite 经 `isOverwrite()`。planWrite 据 ODPS 分区列序拼 `"col=val,..."` 喂 `PartitionSpec`、原样 set 入 `static_partition_spec`(field 10)。 +- **影响**:T04 dormant(gate 关,plan-provider 分支无 live caller);binding 期填充 `PluginDrivenInsertCommandContext.staticPartitionSpec`/overwrite 归 Batch C/D(坑3,`InsertIntoTableCommand:598` 现传空 ctx);planWrite `getCurrentTransaction()` 要返 MC txn ⇒ Batch C `beginTransaction`→置 `ConnectorSessionImpl`。T04 不新增 SPI 面(W1 全建)。立 paimon/iceberg/hive 写-plan adopter 样板。 + +--- + +### D-024 — P4-T03 写/事务 SPI 两 fork(txn id 机制 + T03/T04 边界) + +- **日期**:2026-06-06 +- **状态**:✅ 生效 +- **关联**:[tasks/P4 P4-T03](./tasks/P4-maxcompute-migration.md)、[P4-T03 设计](./tasks/designs/P4-T03-write-txn-design.md)、[D-015]/U3(getTransactionId 连接器分配)、[D-022](写 SPI)、[01-spi-extensions-rfc E11](./01-spi-extensions-rfc.md) +- **背景**:handoff 标注 T03/T04 未逐行定稿;recon 暴两处需拍板的 fork([D-015]「连接器分配 id」对 MC 不成立——MC 无外部 id 且连接器够不到 `Env.getNextId`;写 session 创建需 overwrite/静态分区 context = OQ-2)。 +- **决策**(用户 AskUserQuestion 签字 2026-06-06): + - **Fork 1(txn id)**:给 `ConnectorSession` 加 `default long allocateTransactionId()`(default 抛;fe-core `ConnectorSessionImpl` override 回 `Env.getCurrentEnv().getNextId()`),MC `beginTransaction` 经它分配。**仍属「连接器分配」语义**(经注入的引擎分配器),尊重 [D-015];id 即 Doris 全局 txn_id,与 sink `txn_id` / `GlobalExternalTransactionInfoMgr` 一致。SPI 加面记 E11。 + - **Fork 2(T03/T04 边界)**:ODPS 写 session 创建挪 **T04 planWrite**(`ConnectorWriteHandle` 带 overwrite+writeContext,顺解 OQ-2);**T03 = 纯事务容器**(commitDataList/nextBlockId/writeSessionId 槽 + addCommitData[TBinaryProtocol]/block-alloc/commit[港 finishInsert]/rollback/getUpdateCnt)+ `beginTransaction`。 +- **影响**:executor 接线(`beginTransaction`→`begin(connectorTx)`)+ `GlobalExternalTransactionInfoMgr` 注册推迟翻闸期(Batch C),保 T03 dormant、不破 JDBC/ES。立 paimon/iceberg/hive 后续事务 adopter 的 id-source 样板。 + +--- + +### D-023 — P4 maxcompute 启 full adopter(option A,5 批 cutover) + +- **日期**:2026-06-06 +- **状态**:✅ 生效 +- **关联**:[tasks/P4-maxcompute-migration.md](./tasks/P4-maxcompute-migration.md)、[research/p4-maxcompute-migration-recon.md §9](./research/p4-maxcompute-migration-recon.md)、[D-021](scope=C→本决策接 option A)、[D-022](写 SPI)、[写 RFC §12](./tasks/designs/connector-write-spi-rfc.md)、[R-004] +- **背景**:W-phase(W1–W7)已落地共享写/事务 SPI + 通用层解耦([D-021]/[D-022]),recon §9 scope fork(B hybrid / A full / C 写-SPI 先行)中 C 已完成、写路径 keystone 已解耦。现决 P4 余下走 **option A(full adopter + 翻闸)**,非 P3 式 hybrid。 +- **决策**(用户批准 2026-06-06):按 [tasks/P4](./tasks/P4-maxcompute-migration.md) 的 **5 批 / 11 task** 落地:A 连接器读/DDL/分区 parity(gate 关)→ B 写/事务 SPI(gate 关)→ **C 翻闸(唯一 live 切点,含 R-004 防御测)** → D 清 ~19 反向引用 + 删 `datasource/maxcompute/`(收口 P1-T02 McStructureHelper 去重)→ E 连接器测试基线 + PR。A、B 并行、均 dormant;两者全绿 + R-004 过方进 C。 +- **影响**:P4 成首个 full adopter,为 P5 paimon / P6 iceberg / P7 hive 立样板。recon §3「~36 反向引用」经 post-W-phase re-grep 校正为 **~19**(W-phase 灭 `Coordinator`/`LoadProcessor`/`FrontendServiceImpl` 3 热点 txn 站,grep 证)。每批独立 commit。 + +--- + +### D-022 — 写/事务 SPI 设计(A / B1 / C1 / D / E) + +- **日期**:2026-06-06 +- **状态**:✅ 生效 +- **关联**:[写/事务 SPI RFC](./tasks/designs/connector-write-spi-rfc.md)、[research/connector-write-spi-recon.md](./research/connector-write-spi-recon.md)、[D-021](scope=C)、[D-009](default-only)、[01-spi-extensions-rfc.md E11](./01-spi-extensions-rfc.md)、W-phase commits(W1+W2 `be945476ba7`、W3+W6 `9ad2bbe40ec`、W4 `759cc0874c8`、W5 `9ebe5e27fa4`) +- **背景**:P4 maxcompute recon 证它在热路径会写(`MCTransaction` 在 `Coordinator`/`FrontendServiceImpl`/`LoadProcessor` concrete cast);写路径 = 翻闸 keystone。三现存写者 maxcompute/hive/iceberg 同写生命周期 ⊥ 三处分歧(commit 载荷型 / mc block-id / iceberg procedures+delete/merge),paimon 今读后写需前瞻。须定写/事务 SPI 形状。 +- **决策**(用户签字 2026-06-06): + - **A 事务模型统一·桥接**:连接器 `ConnectorTransaction` 为单一事实源;fe-core 通用写编排经 `PluginDrivenTransaction`(`PluginDrivenTransactionManager` 产)桥接,只调多态 fe-core `Transaction`;现存 `MC/HMS/IcebergTransaction` 过渡期 override 适配,逐连接器迁入 plugin。 + - **B1 commit 载荷 opaque bytes**:BE→FE commit 载荷(`TMCCommitData`/`THivePartitionUpdate`/`TIcebergCommitData`)`TBinaryProtocol` 序列化为 `byte[]`,经 `Transaction.addCommitData(byte[])` / `ConnectorTransaction.addCommitData` 交连接器反序列化。零 BE 改、保全富信息、消除 3 处 concrete cast。留一处序列化 shim(fail-loud,Open-1)。 + - **C1 block-id 窄 callback seam**:`Transaction.supportsWriteBlockAllocation()` + `allocateWriteBlockRange()` 默认方法,仅 maxcompute override,消 `FrontendServiceImpl` `instanceof MCTransaction`。拒 C2 过度泛化 / C3 留特例。 + - **D INSERT/DELETE/MERGE**:SPI 形状定全;实现 mc/hive=insert、iceberg=+delete/merge(P6)。**defer**:iceberg procedures(E2/P6)、hive 行级 ACID、各连接器代码搬迁(adopter 阶段)。 + - **E 写-plan-provider 仿 scan**:连接器经 `ConnectorWritePlanProvider.planWrite()` 产 opaque `TDataSink`(仿 `ConnectorScanPlanProvider`);`Connector.getWritePlanProvider()` default null。 +- **替代方案**:B2 中立 envelope(丢富信息,否决)/ B3 thrift union 漏进 SPI(否决);C2/C3(否决)。见 RFC §11。 +- **影响**:W-phase(W1–W7)落地共享 SPI 面 + 通用层解耦,**behind gate、零行为变更、golden 等价**;逐连接器 adopter(P4 mc / P6 iceberg / P7 hive)后续。新方法均 default(满足 [D-009]),BE 契约不变。W5 落地暴露 [DV-009](写 sink 收口位置修正)。 + +--- + +### D-021 — P4 maxcompute 采 scope=C(写-SPI RFC 先行) + +- **日期**:2026-06-06 +- **状态**:✅ 生效 +- **关联**:[research/p4-maxcompute-migration-recon.md](./research/p4-maxcompute-migration-recon.md)、[写/事务 SPI RFC](./tasks/designs/connector-write-spi-rfc.md)、[D-022](写 SPI 设计)、[connectors/maxcompute.md](./connectors/maxcompute.md) +- **背景**:P4 启动 recon 发现 maxcompute 在热路径**会写**(非只读骨架),写路径是翻闸前提。可选 scope:A 仅迁读+推迟写;B 连写一起但不先定 SPI;**C 写-SPI RFC 先行**(先设计共享写/事务 SPI + 通用层解耦,再迁连接器)。 +- **决策**(用户签字 2026-06-06):采 **scope=C**——先出写/事务 SPI RFC([D-022])并落 **W-phase**(共享解耦 + SPI 面,gate 不动、零行为变更),再做 maxcompute full adopter(搬类 + impl 写 SPI + 翻闸)。理由:写面是 mc/hive/iceberg 共享 keystone,先收口避免每连接器重造、降低反向 instanceof 清理风险。 +- **影响**:P4 在 adopter 前插入 W-phase(写 RFC 直接后续);hive(P7)/iceberg(P6) 复用同一 SPI。W-phase 不翻闸、不搬类、不删 legacy。 + +--- + +### D-020 — 单 `hms` catalog 多格式 scan 路由 = 方案 B(per-table SPI provider) + +- **日期**:2026-06-05 +- **状态**:✅ 生效 +- **关联**:[D-005](#d-005)(被细化)、[D-009](#d-009)(default-only 约束)、[D-019](#d-019)(hybrid)、[tasks/P3 T08](./tasks/P3-hudi-migration.md)、[designs/P3-T08-tableformat-dispatch-design.md](./tasks/designs/P3-T08-tableformat-dispatch-design.md)、[research/spi-multi-format-hms-catalog-analysis.md](./research/spi-multi-format-hms-catalog-analysis.md) +- **背景**:legacy 单 `hms` catalog 靠 `HMSExternalTable.dlaType` per-table tag + 处处 `switch(dlaType)` 同时暴露 Hive/Hudi/Iceberg。SPI 侧 `ConnectorTableSchema.tableFormatType` **产而不用**——`PluginDrivenExternalTable.initSchema:79-109` 只读 columns、`Connector.getScanPlanProvider:40-42` per-catalog 单点、`HiveScanPlanProvider` 硬编码 `tableFormatType="hive"`(research §6①②③ + 本场 firsthand 核读)。T08(批 D,design-only)须定 per-table 路由 seam;研究浮现三互斥方案(A 连接器内 router / B per-table SPI provider / C fe-core 发现期分派)。 +- **决策**:M2 scan 路由采 **方案 B**——在 `ConnectorMetadata` 新增**向后兼容 default** `getScanPlanProvider(ConnectorTableHandle handle)`(默认返 null → fe-core 回落 per-catalog `Connector.getScanPlanProvider()`);fe-core `PluginDrivenScanNode.getSplits` 优先 per-table provider、回落 per-catalog;注册 `"hms"` 的连接器 override 之、按 `handle.getTableType()` 委派 Hudi/Iceberg provider。把"per-table 选 provider"升为一等 SPI 契约。配套 **M1**(fe-core 按缓存的 `tableFormatType` 做 per-table 引擎名/身份,作 opaque 串逐字上报、热路径不读)三方案通用。**design-only,实现 = 批 E/P7**。 +- **替代方案**:**A 连接器内 router**(`Connector.getScanPlanProvider()` 返回一个 `planScan` 按 `handle.getTableType()` 委派的 router)——零 SPI churn(`planScan` 已带 handle,本场核实),但路由藏进连接器、per-table 语义非一等契约;列为备选,批 E 实现期可据 iceberg 接入复杂度复核。**C fe-core 发现期分派**(fe-core 读 `tableFormatType` 建 format-specific 表对象,≈legacy DLAType→多态 DlaTable)——**否决**:fe-core 回退到 per-format 分派,违背瘦 fe-core 北极星(import-gate / D-003 / D-006)。 +- **影响**:**细化 [D-005]**——D-005 的"`tableFormatType` 区分符"结论沿用;但其"fe-core dispatch 到对应 `PhysicalXxxScan`"措辞(2026-05-24,**早于 P1 scan-node 统一**为单 `PluginDrivenScanNode` + per-range format)由 per-table provider seam 取代(SPI 路径已无 per-format `PhysicalXxxScan`)。批 E/P7 据此实现 M1+M2;新 default 方法满足 [D-009](不破签名)。Iceberg-on-hms 经 SPI 依赖 **P6** 先补 `IcebergScanPlanProvider`(M3);hms 网关引入对 `-hudi`/`-iceberg` 模块依赖边(A/B 同担)。**本场无代码改动**。 + +--- + +### D-019 — P3 hudi 采用 hybrid 推进策略 + +- **日期**:2026-06-04 +- **状态**:✅ 生效 +- **关联**:[DV-005](./deviations-log.md)、[D-005](#d-005)、[tasks/P3](./tasks/P3-hudi-migration.md)、master plan §3.4/§3.8 +- **背景**:两轮 code-grounded recon(+ 对抗验证)揭示:HMS-over-SPI 读码已存在但 dormant(gate 关、零 live caller);scan/split plumbing 正确(单 `PluginDrivenScanNode` 混合 COW-native+MOR-JNI 非问题,与 legacy 结构等价);真正阻塞是 catalog 模型错配(独立 `"hudi"` type vs 寄生 `"hms"` 的 `DLAType.HUDI`,fe-core 不消费 `tableFormatType`)+ 关闭的 gate;另有一批**与模型无关**的 SPI-surface 正确性缺口(`schema_id`/`history_schema_info` 缺、`column_types` 双 bug、time-travel 静默返最新、增量读无表示、partition 裁剪缺、三模块零测试)。 +- **决策**:P3 走 **hybrid**。**现在做 (b)**(批 A–D,全部 behind 关闭的 gate,零 live-path 风险):hudi 连接器 model-agnostic 正确性修复 + metadata 补全 + 测试基线 + 模型 dispatch 设计(design-only)。**推迟 (a)**(批 E,登记不编码):fe-core 消费 `tableFormatType` 的 per-table 分流、gate flip(`SPI_READY_TYPES` 加 hms/hudi)、live cutover、删 legacy `datasource/hudi/`、完整增量/time-travel、集群/runtime 验证 —— 并入一个 properly-scoped hive/HMS migration(P7 或专门子阶段)。 +- **替代方案**:(a) **hms-first 一次到位** —— 否决为 P3 首交付(把 P7 范围拉进 P3、re-route live 重度使用的 HMS 路径、零测试网,回归风险大);(c) **直接 flip gate** —— 早已否决(模型错配下 `"hudi"` provider 不可达 + 高回归)。 +- **影响**:P3(hybrid)**不交付用户可见行为变化**(hudi 仍走 legacy,gate 不翻);产出是连接器硬化 + 测试网 + 设计。批 A–C 验证为单测/设计级,端到端/集群验证随批 E cutover。tasks/P3 据此划批。 + +--- + +### D-018 — `ConnectorColumnStatistics` 类型安全契约(原 U6) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[01-spi-extensions-rfc.md §11.2](./01-spi-extensions-rfc.md) +- **背景**:`ConnectorColumnStatistics.minValue / maxValue` 用 `Object` 装载,缺少静态类型检查可能导致 connector 间不一致。 +- **决策**:在 `ConnectorColumnStatistics` javadoc 中列出 `ConnectorType` ↔ Java 装箱类型完整映射表(如 INT→Integer、TIMESTAMP→Instant、BINARY→byte[]);连接器读取不匹配类型时**抛 `IllegalArgumentException`**,由 fe-core 转成 `UserException`。 +- **替代方案**:(a)引入泛型 `ConnectorColumnStatistics`——过于复杂、跨方法签名传染;(b)引入 union 类型——Java 不原生支持。 +- **影响**:仅 javadoc 与运行时检查,无签名变化。 + +--- + +### D-017 — sys-table 命名统一 `$suffix`(原 U5) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[01-spi-extensions-rfc.md §10](./01-spi-extensions-rfc.md) +- **背景**:Iceberg / Paimon 各自有 sys-table(`tbl$snapshots`、`tbl$history` 等)。命名风格 `$xxx` vs `xxx@` vs `[xxx]` 跨方言不一致。 +- **决策**:SPI 层固定 `$suffix` 约定。如未来出现冲突(如某 SQL dialect 把 `$` 视为变量前缀),通过 catalog property `sys_table_separator` 提供别名机制,但**不在本计划范围**。 +- **影响**:所有 sys-table 实现统一遵循。 + +--- + +### D-016 — `getCredentialsForScans` 批量化(原 U4) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[01-spi-extensions-rfc.md §9](./01-spi-extensions-rfc.md) +- **背景**:原设计单 range 调一次 `getCredentialsForScan`,N 个 range 触发 N 次 STS 调用,可能撞限流。 +- **决策**:签名定为 `Map getCredentialsForScans(session, handle, List)`。连接器自由决定 STS 调用粒度(1 次共享 / 按 prefix 分组 / 1:1)。fe-core 一个 scan node 一次调用。 +- **替代方案**:保持单个 + 加内部缓存——把缓存策略推给每个 connector,不一致风险更高。 +- **影响**:替换原 `getCredentialsForScan` 单个签名。调用位置从 `setScanParams` 移到 `createScanRangeLocations`。 + +--- + +### D-015 — `ConnectorTransaction.getTransactionId` 由连接器分配(原 U3) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[01-spi-extensions-rfc.md §7.2](./01-spi-extensions-rfc.md) +- **背景**:transaction ID 是连接器自己分配还是 fe-core 统一分配? +- **决策**:连接器分配。连接器最清楚事务 ID 与外部系统(如 HMS transaction id、Iceberg snapshot id)的对应关系。fe-core 在 `PluginDrivenTransactionManager` 用 `Map` 索引即可。 +- **影响**:`ConnectorTransaction.getTransactionId()` 是 connector-side 字段。 + +--- + +### D-014 — 不新增 `invalidateColumnStatistics`(原 U2) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[01-spi-extensions-rfc.md §6](./01-spi-extensions-rfc.md) +- **背景**:是否给 `ConnectorMetaInvalidator` 加 `invalidateColumnStatistics(...)`? +- **决策**:暂不加。column stats 失效一并挂在 `invalidateTable` 上,避免接口表面膨胀。如后续发现频繁需要单独失效列统计,再加方法(向后兼容 default 即可)。 +- **影响**:`ConnectorMetaInvalidator` 接口保持 5 个方法(catalog / database / table / partition / statistics 整张表)。 + +--- + +### D-013 — `ConnectorProcedureOps.listProcedures` 一次性返回(原 U1) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[01-spi-extensions-rfc.md §5.2](./01-spi-extensions-rfc.md) +- **背景**:connector 暴露的 procedure 列表是初始化时固定还是允许运行时变化? +- **决策**:一次性。Connector 生命周期内稳定;如外部系统的可用 procedure 集合变化,必须重新创建 catalog。 +- **理由**:fe-core 可缓存该列表用于 `SHOW PROCEDURES`、autocompletion;动态变化模型复杂度不值得。 +- **影响**:在 `listProcedures()` 的 javadoc 中明确写出"Lifecycle contract"。 + +--- + +### D-012 — Connector 安装初版强制重启 FE(原 D12) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[00-master-plan.md §5](./00-master-plan.md) +- **背景**:装新 connector 后是否要求重启 FE? +- **决策**:初版强制重启。原因:跨连接器共享类型可能有 classloader 缓存问题,强制重启避免难复现的 corner case。后续版本可考虑热加载。 +- **影响**:文档明确 + 装包流程明确。 + +--- + +### D-011 — `RemoteDorisExternalCatalog` 不在本计划主线(原 D11) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[00-master-plan.md §5](./00-master-plan.md) +- **背景**:Doris-to-Doris federation 是否做成 connector? +- **决策**:长期目标做 connector,但**单独立项**,不在本计划主线(25 周计划中)。 +- **影响**:`RemoteDorisExternalCatalog` 在 P8 不删除;保留独立路径。 + +--- + +### D-010 — `LakeSoulExternalCatalog` 在 P8 删除(原 D10) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[00-master-plan.md §5](./00-master-plan.md) +- **背景**:`CatalogFactory` 已抛 "Lakesoul catalog is no longer supported",但类文件仍在。 +- **决策**:在 P8 收尾时删除剩余 `datasource/lakesoul/` 全部类。 +- **影响**:P8 task 增加 lakesoul 清理项。 + +--- + +### D-009 — API 版本号本计划永不 +1(原 D9) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[00-master-plan.md §5](./00-master-plan.md)、[01-spi-extensions-rfc.md §2.1](./01-spi-extensions-rfc.md) +- **背景**:`ConnectorProvider.apiVersion()` 何时 +1? +- **决策**:本计划范围内(25 周)保持 `apiVersion=1`,只新增 default 方法,不破坏现有签名。 +- **影响**:所有 SPI 扩展必须用 default 方法。如真有不可避免的 breaking change,需走 deviation 流程并升级到 v2。 + +--- + +### D-008 — 生产强制目录式插件(原 D8) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[00-master-plan.md §5](./00-master-plan.md) +- **背景**:是否允许 built-in connector(classpath 中直接打进 FE jar)? +- **决策**:否。built-in 模式只用于测试(ServiceLoader 扫 classpath);生产部署必须从 `connector_plugin_root` 目录加载 plugin zip。 +- **影响**:FE 发行包不含 connector jar;运维流程文档要明确插件部署步骤。 + +--- + +### D-007 — kafka/kinesis/odbc/doris 不在本计划范围(原 D7) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[00-master-plan.md §5](./00-master-plan.md) +- **背景**:`datasource/` 下还有 kafka / kinesis / odbc / doris 子目录,是否一并迁移? +- **决策**:否。流式数据源(kafka/kinesis)与外部 catalog 模型不同;odbc 是 BE-driven;doris 是内部联邦。单独立项。 +- **影响**:P8 不删除这 4 个子目录。 + +--- + +### D-006 — Iceberg snapshot/manifest cache 放连接器内(原 D6) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[00-master-plan.md §5](./00-master-plan.md)、[01-spi-extensions-rfc.md §8](./01-spi-extensions-rfc.md) +- **背景**:Iceberg 的 snapshot cache 和 manifest cache 是 fe-core 通用基础设施还是连接器内部细节? +- **决策**:连接器内部细节。fe-core 不感知。连接器自己管理生命周期、淘汰策略。 +- **替代方案**:放 `fe-core/datasource/metacache/` 通用框架——会增加 fe-core 对 Iceberg 概念的耦合。 +- **影响**:P6 迁移时把 `cache/IcebergManifestCacheLoader` 等整体搬到 `fe-connector-iceberg`。 + +--- + +### D-005 — Hudi / Iceberg-on-HMS DLA 模型方案 A(原 D5) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[00-master-plan.md §3.4](./00-master-plan.md) +- **背景**:HMS 表可能"实际是" Hudi 或 Iceberg。如何在 SPI 层建模? +- **决策**:方案 A — 用 `ConnectorTableSchema.tableFormatType` 字段(值如 `"HIVE"` / `"HUDI"` / `"ICEBERG"`),由 HMS connector 探测后填充;fe-core 据此 dispatch 到对应 `PhysicalXxxScan`。 +- **替代方案**:方案 B — Hudi 作为独立 catalog type,内部委托 HMS——增加 catalog 实例数,用户混淆度高。 +- **影响**:P3 hudi 和 P7 hive 迁移都依赖此模型。 + +--- + +### D-004 — HMS event pipeline 放 fe-connector-hms(原 D4) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[00-master-plan.md §3.8](./00-master-plan.md)、[01-spi-extensions-rfc.md §6](./01-spi-extensions-rfc.md) +- **背景**:21 个 HMS event 类放 fe-core 还是 fe-connector-hms? +- **决策**:fe-connector-hms。通过新 SPI 接口 `ConnectorMetaInvalidator`(在 `ConnectorContext` 暴露)回调 fe-core 的 `ExternalMetaCacheMgr`。 +- **替代方案**:只把"轮询 HMS 拿事件流"放 connector,"解析事件 + 分发失效"留 fe-core——分散,不利于演化。 +- **影响**:P7.2 完整迁移 21 个类 + `MetastoreEventsProcessor`。`HiveConnector.create(...)` 启动 listener 线程;`close()` 停止。 + +--- + +### D-003 — 旧 `*ExternalCatalog` 子类全部删除(原 D3) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[00-master-plan.md §5](./00-master-plan.md) +- **背景**:迁移过程中是保留旧 `IcebergExternalCatalog` 等类作为"中间形态"还是彻底删除? +- **决策**:全部删除。中间形态会让代码长期处于"两套并存"状态,维护负担、bug 风险都更大。 +- **替代方案**:保留一段"deprecated 但可用"期——拒绝,因为旧实现实质上不会被维护。 +- **影响**:P8 强制删除所有 `*ExternalCatalog` / `*ExternalDatabase` / `*ExternalTable` 类;前置工作是 P2-P7 把所有反向 `instanceof` 改为通用接口调用。 + +--- + +### D-002 — `PluginDrivenScanNode` 长期保持 extends `FileQueryScanNode`(原 D2) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[00-master-plan.md §5](./00-master-plan.md) +- **背景**:`PluginDrivenScanNode` 当前继承 `FileQueryScanNode`,但 JDBC / ES 本质不是文件扫描,用 `FORMAT_JNI` 兜底。是否要重构为更彻底的多态? +- **决策**:长期保持当前继承结构。JDBC / ES 的 `FORMAT_JNI` 兜底已被 ES/JDBC 验证可行。重构成本高、收益不明确。 +- **影响**:所有 plugin-driven connector 走同一 scan-node 子类,简化 dispatch 逻辑。 + +--- + +### D-001 — 沿用 `SUPPORTS_PASSTHROUGH_QUERY`(原 D1) + +- **日期**:2026-05-24 +- **状态**:✅ 生效 +- **关联**:[00-master-plan.md §5](./00-master-plan.md) +- **背景**:是否要为 SQL 透传以外的远程 query 类型(如 `query()` TVF)新增 SPI? +- **决策**:不新增。已有 `ConnectorCapability.SUPPORTS_PASSTHROUGH_QUERY` + `ConnectorTableOps.getColumnsFromQuery` 覆盖了主要场景,沿用。 +- **影响**:无新增 API。 + +--- + +## 附录:决策模板 + +新增决策时复制以下模板到顶部(在 §详细记录 下方),并更新 §📋 索引表。 + +```markdown +### D-NNN — <一句话主题> + +- **日期**:YYYY-MM-DD +- **状态**:✅ 生效 / 🟡 待评审 / ❌ 已废止(被 D-MMM 取代) +- **关联**:[文档章节链接]、[相关 task ID] +- **背景**:为什么需要做这个决策?触发场景是什么? +- **决策**:具体决定是什么? +- **替代方案**:考虑过哪些其他方案?为什么没选? +- **影响**:哪些代码 / 文档 / 流程会受影响?是否需要后续 follow-up? +``` diff --git a/plan-doc/designs/FIX-A1-SPLIT-WEIGHT-design.md b/plan-doc/designs/FIX-A1-SPLIT-WEIGHT-design.md new file mode 100644 index 00000000000000..5d3f0c8d80dae4 --- /dev/null +++ b/plan-doc/designs/FIX-A1-SPLIT-WEIGHT-design.md @@ -0,0 +1,175 @@ +# FIX-A1 — thread proportional split weight to the FE FileSplit (BE-assignment parity) + +> Source: `task-list-P6-deviation-fixes.md` §A1 + `reviews/P6-paimon-fullpath-cleanroom-2026-06-18.md` §R1 (scan). +> Single-task loop: design → design red-team → implement → impl-verify → build+UT → commit. +> MINOR / regression. FE BE-assignment only — **no rows / route / BE-read / result change.** + +## Problem + +`PluginDrivenSplit`'s ctor (`PluginDrivenSplit.java:39-48`) forwards path/start/length/fileSize/modTime/ +hosts/partitionValues to `FileSplit` but **never sets `selfSplitWeight` / `targetSplitSize`**. So +`FileSplit.getSplitWeight()` (`FileSplit.java:104-113`) hits the `else` branch → `SplitWeight.standard()` +(uniform) for every plugin-driven split. Legacy paimon set both fields, so `FederationBackendPolicy` +distributed splits across BEs by **proportional** weight (bigger split = more weight). Under the SPI all +paimon splits get uniform weight → the BE assignment differs from legacy (a scheduling skew, no +correctness impact). + +## Root cause & the legacy parity spec (verified against real code) + +`FileSplit.getSplitWeight()` returns proportional weight **iff** `selfSplitWeight != null && targetSplitSize +!= null`, computing `fromProportion(clamp(selfSplitWeight / targetSplitSize, 0.01, 1.0))`. The SPI never +populates either field. The legacy values (which we must reproduce): + +**Per-split `selfSplitWeight`** (`PaimonSplit.java`): +- JNI / count split (`PaimonSplit(Split)` :50-64): `DataSplit` → `Σ dataFiles.fileSize` (:60); + non-`DataSplit` system table → `rowCount()` (:63). +- Native sub-split (`PaimonSplit(LocationPath,start,length,…)` :67-72, built by `FileSplitter.splitFile` + + `PaimonSplitCreator`): `selfSplitWeight = length` (the sub-range byte length, :72), **plus** + `+= deletionFile.length()` when a DV is attached (`setDeletionFile` :112). + +**Scan-level `targetSplitSize`** (the weight denominator, `PaimonScanNode.java:497-500`): set on **all** +splits to `getFileSplitSize() > 0 ? getFileSplitSize() : getMaxSplitSize()`, where `getMaxSplitSize()` = +the `max_file_split_size` var (default 64 MB, `SessionVariable:2408,4729`). This is a **different** value +from the file-splitting granularity `determineTargetFileSplitSize` (the connector's +`resolveTargetSplitSize`), and overrides whatever `FileSplitCreator` set. + +**What the connector computes today vs needs:** +| split type | connector `selfSplitWeight` today | legacy | gap | +|---|---|---|---| +| JNI (`buildJniScanRange`) | `computeSplitWeight` = Σ fileSize / rowCount (:728-733,747) | same | none | +| count (`buildCountRange`) | `computeSplitWeight` (:778) | Σ fileSize | none | +| **native (`buildNativeRange`)** | **unset → Builder default 0** (:472-489) | `length` (+DV) | **MISSING** | +| `targetSplitSize` (all) | **never carried** | `fileSplitSize>0 ? : maxFileSplitSize` | **MISSING** | + +So the task-list's "already computed, just not threaded" holds only for JNI/count. **Native is the common +path** (default ORC/Parquet read); leaving its `selfSplitWeight = 0` would make every native split's weight +`clamp(0/denom)=0.01` (uniform-ish), so wiring the getters WITHOUT fixing native would not achieve +proportional distribution. Both the native weight and the denominator must be added. + +## Design + +**Generic SPI getters + connector populates them + fe-core wires them.** Connector-agnostic: other +connectors (jdbc/es/trino/maxcompute) inherit the sentinel default → keep `SplitWeight.standard()` (no +regression). + +1. **SPI `ConnectorScanRange`** (fe-connector-api) — two new default methods, sentinel `-1` = "no weight": + ```java + /** Per-split weight numerator for proportional BE assignment, or -1 if the connector + * does not provide one (→ the engine falls back to SplitWeight.standard()). */ + default long getSelfSplitWeight() { return -1; } + /** Weight denominator (scan-level target split size), or -1 if not provided. Proportional + * weight is applied only when BOTH this and getSelfSplitWeight() are present. */ + default long getTargetSplitSize() { return -1; } + ``` + A connector with no weight model returns both `-1` → unchanged behavior. + +2. **`PluginDrivenSplit` ctor** (fe-core) — after `super(...)`, set the FileSplit fields only when the + connector provides BOTH (guards div-by-zero and the null branch): + ```java + long weight = scanRange.getSelfSplitWeight(); + long target = scanRange.getTargetSplitSize(); + if (weight >= 0 && target > 0) { // weight may legitimately be 0 (empty file / sys table) + this.selfSplitWeight = weight; + this.targetSplitSize = target; + } + ``` + Generic — no source-specific branching (rule: keep `PluginDrivenScanNode`/generic node connector-agnostic). + +3. **`PaimonScanRange`** (connector) — carry the denominator and expose both getters: + - Add `targetSplitSize` field + `Builder.targetSplitSize(long)` + `@Override getTargetSplitSize()`. + **Builder default = `-1`** (the SPI sentinel "not provided"), NOT primitive `0` — a `0` denominator is + invalid (div-by-zero / would be gated out). This is deliberately asymmetric with `selfSplitWeight` + (default `0`, since `0` is a legitimate empty-file / 0-row-sys-table weight, which the `weight >= 0` + gate accepts). Production always sets `targetSplitSize`; the `-1` default just keeps a Builder that + omits it honest to the SPI contract. + - `getSelfSplitWeight()` already returns the field; mark it `@Override` (it had no SPI declaration to + satisfy before — verified it has no current callers besides being the field's accessor, so `@Override` + is behavior-neutral). The `selfSplitWeight` field is the FE weight; the BE-thrift + `paimon.self_split_weight` prop stays gated on `paimonSplit != null` (A3) so native ranges still do not + emit it to BE — setting the field for native ranges changes only the FE getter. + +4. **`PaimonScanPlanProvider`** (connector) — compute the denominator once and thread it: + - New `resolveSplitWeightDenominator(session)` = `fileSplitSize>0 ? fileSplitSize : + sessionLong(MAX_FILE_SPLIT_SIZE, DEFAULT_MAX_FILE_SPLIT_SIZE)` — exact legacy `getFileSplitSize()>0 ? : + getMaxSplitSize()` parity (both read `file_split_size` / `max_file_split_size`; defaults 0 / 64 MB + match). Computed once in `planScanInternal` (session-only), passed to every builder. + - The threaded param + local is named **`weightDenominator`** EVERYWHERE (never `targetSplitSize`) so it + cannot transpose with the existing file-splitting `targetSplitSize` / `effectiveSplitSize` local — a + two-adjacent-`long` positional-swap is the one real bug risk here, name-isolated by construction. + - `buildNativeRange`: `.selfSplitWeight(length + (deletionFile != null ? deletionFile.length() : 0))` + (legacy `selfSplitWeight = length` + `+= deletionFile.length()`), `.targetSplitSize(weightDenominator)`. + - `buildJniScanRange` / `buildCountRange`: add `.targetSplitSize(weightDenominator)` (selfSplitWeight already set). + - Thread `weightDenominator` as an explicit param through `buildNativeRanges`/`buildNativeRange`/ + `buildJniScanRange`/`buildCountRange`. It is the weight base, computed even under count pushdown where + the file-splitting size (`effectiveSplitSize`) is 0. + - **Existing test call-sites of the changed signatures MUST be updated** (else compile break): + `PaimonScanPlanProviderTest` calls `buildNativeRange` (~4 sites) and `buildNativeRanges` (~2 sites) + directly — append the `weightDenominator` arg (any value, e.g. `64L*1024*1024`; those tests assert only + URI-normalization / DV-on-every-sub-range, both denom-independent). Confirm exact line numbers at impl. + + **Why Option A (connector-owned SPI `getTargetSplitSize`) over Option B (fe-core computes the denominator):** + hive/iceberg use a DIFFERENT denominator — the file-splitting granularity set by `FileSplitCreator` + (`FileSplit.java:94`) — not paimon's `getFileSplitSize()>0 ? : getMaxSplitSize()`. A single fe-core + denominator would mis-weight other connectors. The connector owning its denominator is both simpler and + correct; do NOT later "simplify" the SPI getter away. + +## No-regression / correctness + +- **Other connectors unchanged:** sentinel `-1` default → `PluginDrivenSplit` leaves both FileSplit fields + null → `getSplitWeight()` = `standard()` exactly as today. Verified **all 6 non-paimon + `ConnectorScanRange` impls (jdbc / es / trino / maxcompute / hive / hudi)** do not reference/override the + new getters → inherit the `-1` sentinel → `standard()`. (Hive's own `getTargetSplitSize` is a private + plan-provider method, not an SPI override — no collision.) +- **Paimon = legacy parity:** JNI/count `selfSplitWeight` already matches; native now matches (`length`+DV); + denominator matches legacy line 499 exactly. So `getSplitWeight()` reproduces legacy `fromProportion`. +- **weight 0 is valid** (empty file / 0-row sys table): the gate is `weight >= 0` (not `> 0`), so a genuine + 0 still yields `clamp(0/denom)=0.01`, matching legacy (whose denominator path is identical). Distinct + from A3, which fixed the same 0-vs-unset confusion on the BE-thrift channel. +- **No BE/route/result change:** the FileSplit weight feeds only `FederationBackendPolicy` (FE split→BE + assignment). `targetSplitSize > 0` guards div-by-zero; `denominator` defaults to 64 MB. + +## Files + +- `fe/fe-connector/fe-connector-api/.../scan/ConnectorScanRange.java` (2 default getters) +- `fe/fe-core/.../datasource/PluginDrivenSplit.java` (ctor gate) +- `fe/fe-connector/fe-connector-paimon/.../PaimonScanRange.java` (targetSplitSize field/Builder/getter, @Override) +- `fe/fe-connector/fe-connector-paimon/.../PaimonScanPlanProvider.java` (denominator helper + thread + native weight) +- `fe/fe-connector/fe-connector-paimon/.../test/.../PaimonScanPlanProviderTest.java` (update ~6 changed-signature call-sites + new tests) +- new/extended UT in fe-core (PluginDrivenSplit) + fe-connector-api (SPI defaults) + connector (PaimonScanRange) + +## Test Plan (RED→GREEN, each pinned by a mutation) + +### Unit tests (each pins a concrete, non-vacuous expected value) +1. **`PluginDrivenSplit` (fe-core, the core regression):** build a `PluginDrivenSplit` from a fake + `ConnectorScanRange` and assert the EXACT `getSplitWeight().getRawValue()` so RED (standard, rawValue 100) + is always distinguishable from GREEN — pin concrete values that do NOT collapse to standard: + - mid: `W=50, T=100` → proportion 0.5 → assert `rawValue == 50` (NOT standard's 100). + - clamp-low: `W=1, T=100` → 0.01 floor → assert `rawValue == 1`. + - default: a fake returning `-1/-1` → assert `getSplitWeight()` is `standard()` (rawValue 100). + (Avoid `W>=T` cases — they clamp to 1.0 == standard and would false-pass even in the RED state.) + A fake `ConnectorScanRange` is trivial — the same minimal anonymous impl exists in + `PluginDrivenScanNodeExplainStatsTest` (only `getRangeType` + `getProperties` need a body). **RED before:** + ctor sets neither field → every case returns `standard()` (rawValue 100) → the `==50`/`==1` asserts fail. +2. **SPI default (fe-connector-api):** an anonymous `ConnectorScanRange` (no override) returns `-1` for both + getters. Guards the no-regression default. +3. **`PaimonScanRange` sentinel + round-trip (connector):** (i) a Builder WITHOUT `.targetSplitSize()` → + `getTargetSplitSize() == -1` (pins the sentinel default + SPI contract); (ii) a Builder WITH + `.selfSplitWeight(W).targetSplitSize(T)` → both getters round-trip `W`/`T`. +4. **`buildNativeRange` weight (connector):** call `buildNativeRange(file, dv, …, start, length, + weightDenominator)` → range `getSelfSplitWeight() == length (+ dv.length())` and `getTargetSplitSize() == + weightDenominator`. Constructible fully offline — `buildNativeRange` is package-private and existing + tests already build `new RawFile(...)` / `new DeletionFile(...)` (no `FileSystemCatalog`). **MUTATION:** + drop the native `.selfSplitWeight(...)` → weight 0 → RED. +5. **`buildNativeRanges` positional-swap guard (connector):** call `buildNativeRanges` with a file-split + target (e.g. `33`) numerically DISTINCT from the `weightDenominator` (e.g. `64MB`) on a multi-sub-range + file → assert (a) range COUNT == `computeFileSplitOffsets(fileLength, 33).size()` (splitting follows the + file-split target) AND (b) every range `getTargetSplitSize() == 64MB` (the denominator). REDs on a swap + of the two adjacent `long` args. +6. **`resolveSplitWeightDenominator` + count-pushdown (connector):** `file_split_size` set → returns it; + unset → returns `max_file_split_size` (default 64 MB). Plus: a count-pushdown native range (file-split + `effectiveSplitSize == 0`) still gets a POSITIVE `weightDenominator` → non-standard weight (guards the + denominator being computed independently of the file-split size). + +### E2E +Gated (`enablePaimonTest=false`) — NOT run. `FederationBackendPolicyTest` (existing) already covers the +weight→assignment mapping; the UT proves the weight is now non-standard, which is the regression. diff --git a/plan-doc/designs/FIX-A1-SPLIT-WEIGHT-summary.md b/plan-doc/designs/FIX-A1-SPLIT-WEIGHT-summary.md new file mode 100644 index 00000000000000..75d0b65e224b22 --- /dev/null +++ b/plan-doc/designs/FIX-A1-SPLIT-WEIGHT-summary.md @@ -0,0 +1,63 @@ +# FIX-A1 — proportional split weight on the FE FileSplit — SUMMARY + +> Deviation 4/5 of the P6 deviation→fix batch. Single-task loop: design → design red-team (`wf_c8345c28-ee6`) +> → implement → impl-verify → build+UT (RED→GREEN) → commit. Detail: `FIX-A1-SPLIT-WEIGHT-design.md`. + +## Problem + +`PluginDrivenSplit`'s ctor never set `FileSplit.selfSplitWeight` / `targetSplitSize`, so +`FileSplit.getSplitWeight()` returned `SplitWeight.standard()` (uniform) for every plugin-driven split. +Legacy paimon set both, so `FederationBackendPolicy` distributed splits across BEs by **proportional** +(by-size) weight. Under the SPI all paimon splits got uniform weight — an FE BE-assignment skew (no rows / +route / BE-read / result change). + +## Root cause (the non-obvious gap) + +The task-list framed it as "the weight is already computed, just not threaded." Tracing the real code showed +that holds only for **JNI/count** splits. Two gaps had to be closed: +1. The connector's **native** ranges (`buildNativeRange`) never set `selfSplitWeight` (Builder default 0). + Legacy native sub-splits used `selfSplitWeight = length (+ deletionFile.length())` + (`PaimonSplit:72,112`). Native ORC/Parquet is the *default* read path, so leaving it 0 would have made + every native split's weight `clamp(0/denom)=0.01` (uniform) — defeating the fix. +2. The weight **denominator** (legacy `PaimonScanNode:499` = `fileSplitSize>0 ? : max_file_split_size`, + 64 MB default) is a *different* value from the connector's existing file-splitting `targetSplitSize`, and + was carried nowhere. + +## Fix + +- **SPI `ConnectorScanRange`**: two new default getters `getSelfSplitWeight()` / `getTargetSplitSize()`, + sentinel `-1` = "not provided". Connector-agnostic — all 6 non-paimon impls (jdbc/es/trino/maxcompute/ + hive/hudi) inherit `-1` → keep `standard()` (no regression). +- **`PluginDrivenSplit` ctor**: set the FileSplit fields only when `weight >= 0 && target > 0` (`0` is a + real weight; `target > 0` guards div-by-zero). Generic, no source-specific branching. +- **`PaimonScanRange`**: new `targetSplitSize` field (default `-1`) + Builder + `@Override + getTargetSplitSize()`; `getSelfSplitWeight()` marked `@Override`. +- **`PaimonScanPlanProvider`**: `resolveSplitWeightDenominator(session)` (exact legacy formula), computed + once and threaded as `weightDenominator` (named to avoid transposition with the file-split target) to + every builder; `buildNativeRange` now sets `selfSplitWeight = length + DV` and `targetSplitSize = + weightDenominator`; JNI/count add `targetSplitSize`. Applied to every split type incl. count pushdown. + +Legacy parity verified exactly by the design red-team (4 lenses, all "design sound"): native `length+DV`, +denominator `fileSplitSize>0 ? : 64MB`, JNI/count `Σ fileSize`/`rowCount`, `fromProportion(clamp(w/T, +0.01,1.0))` math. FE-only — the BE-thrift `paimon.self_split_weight` (A3) stays gated on `paimonSplit != null`. + +## Tests (RED→GREEN; design red-team's 6 actionable findings all folded in) + +- **fe-core `PluginDrivenSplitWeightTest`** (the regression): `W=50,T=100→rawValue 50`; `W=1→clamp 1`; + `W=0→clamp 1` (the `>=0` gate); `-1/-1→standard 100`; one-field-only → standard. Exact `getRawValue()` + asserts so RED (standard 100) ≠ GREEN. +- **fe-connector-api `ConnectorScanRangeWeightDefaultsTest`**: defaults are `-1` (no-regression sentinel). +- **connector `PaimonScanPlanProviderTest`** (+5): Builder sentinel/round-trip; `buildNativeRange` weight + `=length+DV` + denominator; `buildNativeRanges` positional-swap guard (split count follows the file-split + target, every range carries the denominator); count-pushdown (target 0) still carries the denominator; + `resolveSplitWeightDenominator` legacy formula. Updated the 6 existing changed-signature call-sites. + +## Result + +fe-connector-api 44/0, fe-connector-paimon 298/0/1skip (PaimonScanPlanProviderTest 57/0, +5 A1), fe-core +`PluginDrivenSplitWeightTest` 5/0; checkstyle 0; import-check 0; clean rebuild BUILD SUCCESS. **RED-verified +by mutation runs:** fe-core ctor-gate-off → the 3 proportional cases fail (`expected 50/1/1, got 100`=standard), +the 2 no-weight cases stay green; connector native-weight-drop / sentinel→0 / denominator→0 / arg-swap each → +its target test fails. Design red-team `wf_c8345c28-ee6` (4 lenses, all sound, 6 actionable folded in); +impl-verify `wf_3381cfaa-205` (2 lenses, both COMMIT_AS_IS, 0 actionable). e2e gated (`enablePaimonTest=false`) +— NOT run. Next deviation: **B-R2-be** (last of the 5). diff --git a/plan-doc/designs/FIX-A2-PREDICATES-FROM-PAIMON-design.md b/plan-doc/designs/FIX-A2-PREDICATES-FROM-PAIMON-design.md new file mode 100644 index 00000000000000..9da30d5ffee1df --- /dev/null +++ b/plan-doc/designs/FIX-A2-PREDICATES-FROM-PAIMON-design.md @@ -0,0 +1,177 @@ +# FIX-A2 — EXPLAIN drops legacy `predicatesFromPaimon:` line + +> Source: `task-list-P6-deviation-fixes.md` §A2 / `reviews/P6-paimon-fullpath-cleanroom-2026-06-18.md` §R2 (scan). +> Severity: **MINOR** (diagnostic-only; no correctness/perf impact). Deviation 2/5. + +## Problem + +Legacy `PaimonScanNode.getNodeExplainString` (`PaimonScanNode.java:660-668`) printed the list of Paimon +`Predicate` objects **actually pushed to the Paimon SDK** (or ` NONE`): + +``` +predicatesFromPaimon: + + +``` + +The SPI scan path lost it. The connector's `appendExplainInfo` (`PaimonScanPlanProvider.java:1116-1129`) +emits only `paimonNativeReadSplits=` + the VERBOSE `PaimonSplitStats` block, and the generic node emits +`PREDICATES: ` (`PluginDrivenScanNode.java:270-275`) — the **Doris-level** conjuncts rendered as +SQL, NOT the SDK-converted Paimon predicates. So a silently-dropped conjunct (the converter drops what +it can't translate — LTZ / FLOAT / unsupported CAST) is no longer observable: `PREDICATES:` still lists +all conjuncts, but the pushed set is invisible. + +## Root Cause + +A pure missing-port: the legacy line was never re-emitted on the SPI path. The diagnostic gap matters +because `PaimonPredicateConverter.convert` **silently drops** unconvertible predicates +(`PaimonScanPlanProvider.java:573-578` builds the list; the converter null-skips), so +`predicatesFromPaimon:` can legitimately list fewer entries than `PREDICATES:` — that delta is exactly +what the line exists to surface. + +## Key lifecycle / seam facts (grounded) + +1. **The provider is re-instantiated per call.** `PaimonConnector.getScanPlanProvider():100-101` returns + `new PaimonScanPlanProvider(...)` each time, with **no shared instance state** between the SPI methods + (documented at `PaimonScanPlanProvider.java:168-169`). → A connector **field** cannot carry the + converted predicates from `getScanNodeProperties` to `appendExplainInfo`. Ruled out. +2. **The seam carries only a `Map`.** `ConnectorScanPlanProvider.appendExplainInfo(output, + prefix, nodeProperties)` — the filter/`ConnectorExpression` is NOT passed (it is available only at + `planScan`/`getScanNodeProperties` time). → Re-running the converter at explain time would require an + SPI signature change. Ruled out (keep connector-side, no SPI change). +3. **The pushed predicates are already serialized into the props.** `getScanNodeProperties:579` ALWAYS + emits `props.put("paimon.predicate", encodeObjectToString(predicates))` (even for the empty list — a + non-null base64 string; the JNI reader deserializes it unconditionally). `encodeObjectToString` + (`:1448`) = `InstantiationUtil.serializeObject` + Base64. +4. **`appendExplainInfo` receives those props.** The node does `explainProps = new HashMap<>(props)` + (`PluginDrivenScanNode.java:324`) where `props = getOrLoadScanNodeProperties()` (`:258`), then injects + the synthetic `__native_read_splits`/`__total_read_splits`/`__explain_verbose` keys + (`:325-328`, unconditional). So `paimon.predicate` is **always present** in `nodeProperties` during a + real EXPLAIN, and `InstantiationUtil.deserializeObject(byte[], ClassLoader)` is the symmetric inverse + (verified present in the SDK). +5. **Legacy ordering:** `super-body` → `paimonNativeReadSplits=/` → + `predicatesFromPaimon:[ NONE | …]` → `[VERBOSE] PaimonSplitStats:` (`PaimonScanNode.java:656-671`). + +## Design + +In the connector's `appendExplainInfo`, **deserialize the already-present `paimon.predicate` prop** back +to `List` and render the legacy `predicatesFromPaimon:` block, placed **between** the +`paimonNativeReadSplits=` line and the VERBOSE `PaimonSplitStats` block (exact legacy order). This reuses +the exact list pushed to BE — no re-conversion, no new prop, no redundant serialization, no SPI change, no field. + +```java +// inside the existing if (nativeSplits != null && totalSplits != null) block, +// AFTER the paimonNativeReadSplits= append, BEFORE the VERBOSE PaimonSplitStats block: +String encodedPredicates = nodeProperties.get("paimon.predicate"); +if (encodedPredicates != null) { + appendPredicatesFromPaimon(output, prefix, encodedPredicates); +} +``` + +Helper (new private static): + +```java +private static void appendPredicatesFromPaimon(StringBuilder output, String prefix, String encoded) { + List predicates; + try { + byte[] bytes = Base64.getDecoder().decode(encoded); + predicates = InstantiationUtil.deserializeObject( + bytes, org.apache.paimon.predicate.Predicate.class.getClassLoader()); + } catch (Exception e) { + // Diagnostic line only — never break EXPLAIN. The prop is produced by us, so a decode failure + // is a real bug; log + skip the line rather than render a misleading NONE. + LOG.warn("Failed to decode paimon.predicate for EXPLAIN predicatesFromPaimon", e); + return; + } + if (predicates == null) { + // unexpected payload — skip (do not render a misleading " NONE"); consistent with the catch path + return; + } + output.append(prefix).append("predicatesFromPaimon:"); + if (predicates.isEmpty()) { + output.append(" NONE\n"); + } else { + output.append("\n"); + for (org.apache.paimon.predicate.Predicate predicate : predicates) { + output.append(prefix).append(prefix).append(predicate).append("\n"); + } + } +} +``` + +### Why gate on `paimon.predicate != null` (skip when absent) + +`predicatesFromPaimon` renders iff the prop is present. In a real EXPLAIN it is always present (fact 3+4), +so this is full legacy parity. When absent (a unit test that injects only the synthetic keys, or another +connector's props) the line is skipped — which **preserves ALL existing exact-equality `appendExplainInfo` +tests** (none of them set `paimon.predicate`) and mirrors the existing "skip when keys absent" philosophy +of the `paimonNativeReadSplits=` line (`:1112-1114`). Absent ≠ empty-list, so skipping (not rendering +` NONE`) is the correct degenerate behavior. + +### Classloader + +Decode with `org.apache.paimon.predicate.Predicate.class.getClassLoader()` — the plugin classloader that +loaded the paimon SDK in the connector, guaranteed to have `Predicate` and its dependents. (Connector +code runs under that CL, so `Predicate.class` resolves there.) Avoids any reliance on the thread-context +classloader at explain time. + +### Why deserialize, not re-convert (deviates from the task-list's suggested mechanism) + +`task-list-P6-deviation-fixes.md` §A2 suggested "re-run `PaimonPredicateConverter` over the pushed +filter." That is not directly possible — the filter is not in the seam (fact 2). Deserializing the +already-serialized `paimon.predicate` is strictly better: same OUTPUT (the rendered pushed predicates), +but it renders **precisely what BE receives** (the serialized list is the source of truth), with the +smallest change (no SPI signature change, no new prop, no BE bloat). + +## Risk Analysis + +- **No correctness/perf/route impact** — diagnostic EXPLAIN text only. +- **Decode failure** never breaks EXPLAIN (try/catch → LOG.warn + skip the line). +- **No redundant serialization** — reuses the existing `paimon.predicate` blob instead of serializing the + same `List` into a second prop. (A new explain-only key would NOT have reached BE either: + `populateScanLevelParams:1078-1103` reads props key-by-key — only `paimon.predicate`/`options_json`/ + `schema_evolution` are set onto the thrift params, there is no bulk `putAll` — so the "BE bloat" worry + was unfounded; deserialize still wins on minimality.) +- **Backward-compat** — existing exact-equality explain tests keep passing (line skipped when prop absent). +- **toString / render-format parity** — this renders the set the **SPI path actually pushes to BE** (the + source of truth), NOT a re-derivation of legacy's fe-core converter output. Both converters emit the same + `org.apache.paimon.predicate.Predicate` class, so `Predicate.toString()` parity holds; the + serialize→deserialize round-trip is lossless (toString is field-derived); the surrounding format (label, + ` NONE`, double-prefix indent, newlines) is reproduced verbatim from `PaimonScanNode.java:660-668`. +- **Ordering** — inserted between `paimonNativeReadSplits=` and the VERBOSE block = exact legacy order. + +## Test Plan + +### Unit Tests (fe-connector-paimon, add to `PaimonScanExplainTest`) + +Build the `paimon.predicate` prop exactly as production does (`InstantiationUtil.serializeObject` + +`Base64`), inject alongside the synthetic split keys, call `appendExplainInfo`, assert the rendered text. + +1. **Non-empty pushed predicates** — build `List` via paimon `PredicateBuilder` + (e.g. `equal(0, 5)` over a 1-col RowType), serialize into `paimon.predicate`, set + `__native_read_splits`/`__total_read_splits`. Assert output contains, in order, + `paimonNativeReadSplits=…\n` then `predicatesFromPaimon:\n` then `\n` + (expected predicate text computed from `p.toString()`, not hardcoded). **RED before:** the line is + absent. +2. **Empty pushed predicates** — serialize `Collections.emptyList()` into `paimon.predicate`. Assert + output contains `predicatesFromPaimon: NONE\n`. +3. **Ordering** — assert `indexOf("paimonNativeReadSplits=") < indexOf("predicatesFromPaimon:")` and, + under VERBOSE (`__explain_verbose=true`), `indexOf("predicatesFromPaimon:") < indexOf("PaimonSplitStats:")`. +4. **Backward-compat (existing tests, unchanged)** — all existing exact-equality `appendExplainInfo` tests + (none set `paimon.predicate`) must still pass byte-for-byte (line skipped when prop absent). +5. **Absent → skip (new dedicated guard)** — `appendExplainInfoSkipsPredicatesFromPaimonWhenPropAbsent`: + set only `__native_read_splits`/`__total_read_splits` (NO `paimon.predicate`), assert output contains + `paimonNativeReadSplits=` AND does NOT contain `predicatesFromPaimon` — positively pins the + absent ≠ empty contract (mirrors the sibling `…SkipsWhenSyntheticKeysAbsent` guard). + +### E2E Tests + +None added. Existing paimon regression suites that assert EXPLAIN are gated (`enablePaimonTest=false`). +The connector UT pins the rendered string; a live e2e is not warranted for a diagnostic line. + +## Build / Verify + +- `mvn -f .../fe/pom.xml -pl :fe-connector-paimon -am package -Dassembly.skipAssembly=true + -Dmaven.build.cache.enabled=false -DfailIfNoTests=false` (checkstyle in `validate`). +- `tools/check-connector-imports.sh` exit 0 (no fe-core import; only paimon SDK + java.util). +- RED→GREEN: new non-empty test fails before the fix (line absent), passes after. diff --git a/plan-doc/designs/FIX-A2-PREDICATES-FROM-PAIMON-summary.md b/plan-doc/designs/FIX-A2-PREDICATES-FROM-PAIMON-summary.md new file mode 100644 index 00000000000000..04b1fedf1d87cd --- /dev/null +++ b/plan-doc/designs/FIX-A2-PREDICATES-FROM-PAIMON-summary.md @@ -0,0 +1,65 @@ +# FIX-A2 — re-emit the legacy `predicatesFromPaimon:` EXPLAIN line — SUMMARY + +> P6 deviation→fix (2 of 5). Severity MINOR (diagnostic-only). Design + design red-team +> (`wf_c67cb558-ff4`, 13 candidates → 0 actionable on code; folded doc/test refinements) + RED→GREEN UT +> + impl-verify (APPROVE, with its own mutation check). + +## Problem + +Legacy `PaimonScanNode.getNodeExplainString` (`PaimonScanNode.java:660-668`) printed the Paimon +`Predicate` objects actually pushed to the SDK (or ` NONE`): + +``` +predicatesFromPaimon: + +``` + +The SPI scan path lost it. The connector's `appendExplainInfo` emitted only `paimonNativeReadSplits=` + +VERBOSE `PaimonSplitStats`; the generic node emits `PREDICATES: ` (the Doris-level conjuncts), NOT +the SDK-converted set. So a silently-dropped conjunct (the converter drops LTZ / FLOAT / unsupported CAST) +was no longer observable. + +## Root Cause + +Pure missing-port. The diagnostic value is the delta between `PREDICATES:` (all conjuncts) and +`predicatesFromPaimon:` (the pushed subset), which `PaimonPredicateConverter.convert` narrows by silently +null-skipping unconvertible predicates. + +## Fix + +In the connector's `appendExplainInfo`, **deserialize the already-present `paimon.predicate` prop** (the +exact `List` pushed to BE — `getScanNodeProperties:579` always emits it via +`InstantiationUtil.serializeObject` + Base64) and render the legacy block, placed **between** the +`paimonNativeReadSplits=` line and the VERBOSE `PaimonSplitStats` block (exact legacy order +`PaimonScanNode:657-671`). New private helper `appendPredicatesFromPaimon`. + +Chosen over the task-list's suggested "re-run the converter" because the filter is not in the SPI seam +(it carries only the props map) and the provider is re-instantiated per call (no field). Deserializing +the existing prop renders precisely what BE receives, with the smallest change: no SPI signature change, +no new prop, no redundant serialization, no field, no BE impact (`populateScanLevelParams` reads props +key-by-key, so an unread key would not reach BE anyway). + +Robustness: gated on `paimon.predicate != null` (absent ≠ empty → skip, preserving the existing +exact-equality explain tests); decode failure → `LOG.warn` + skip (never breaks EXPLAIN); null +deserialized list → skip before the label. Decode with `Predicate.class.getClassLoader()` (the plugin CL, +TCCL-independent). + +## Tests + +4 new tests in `PaimonScanExplainTest` (build `paimon.predicate` exactly as production does — +`InstantiationUtil.serializeObject` + Base64): +1. **Non-empty pushed predicate** — exact-equality incl. double-prefix indent (RED before: line absent). +2. **Empty list → `predicatesFromPaimon: NONE`** (RED before). +3. **Ordering** — `paimonNativeReadSplits < predicatesFromPaimon < PaimonSplitStats` under VERBOSE (RED before). +4. **Absent prop → skip** (mirrors the sibling synthetic-keys-absent guard; pins absent ≠ empty; green + pre-fix — pins the contract that keeps the existing exact-equality tests green). + +## Result + +- RED→GREEN by separate runs: unfixed → 3 failures (tests 1-3); fixed → `PaimonScanExplainTest` **17/0/0**. +- Full paimon module: **287 run / 0 failures / 0 errors / 1 skipped** (gated e2e); checkstyle 0; + `tools/check-connector-imports.sh` exit 0. +- Design red-team: design sound + legacy-faithful, no BLOCKER/MAJOR; folded refinements (corrected the + "no BE bloat" rationale → "no redundant serialization"; null→skip before label; added the absent→skip + test; doc wording). Impl-verify: **APPROVE** (ran its own neuter → 3 tests RED, then restored). +- e2e gated (`enablePaimonTest=false`) — NOT run (no regression suite asserts this line). diff --git a/plan-doc/designs/FIX-A3-SELF-SPLIT-WEIGHT-design.md b/plan-doc/designs/FIX-A3-SELF-SPLIT-WEIGHT-design.md new file mode 100644 index 00000000000000..ec20571ee95dc6 --- /dev/null +++ b/plan-doc/designs/FIX-A3-SELF-SPLIT-WEIGHT-design.md @@ -0,0 +1,145 @@ +# FIX-A3 — JNI split `self_split_weight` omitted when weight is 0 + +> Source: `task-list-P6-deviation-fixes.md` §A3 / `reviews/P6-paimon-fullpath-cleanroom-2026-06-18.md` §R1 (be). +> Severity: **NIT** (profile-parity only). Smallest blast radius of the 5 deviation→fixes. + +## Problem + +The paimon connector gates emission of the BE thrift profile property `paimon.self_split_weight` +on the **value** `selfSplitWeight > 0`. A JNI split whose computed weight is exactly **0** therefore +leaves the property unset, and BE falls back to `-1` instead of `0`. + +Weight-0 JNI splits are real: +- a non-`DataSplit` metadata/system split with `split.rowCount() == 0` + (`buildJniScanRange:732` → `splitWeight = split.rowCount()`), or +- a `DataSplit` whose data files sum to `fileSize == 0` (`computeSplitWeight:885-891`). + +Legacy emitted the weight **unconditionally** for every JNI split (incl. 0). + +## Root Cause + +`PaimonScanRange` constructor, `fe-connector-paimon/.../PaimonScanRange.java:92`: + +```java +if (builder.selfSplitWeight > 0) { // <-- value gate + props.put("paimon.self_split_weight", String.valueOf(builder.selfSplitWeight)); +} +``` + +The `> 0` predicate was doing double duty as a crude "is this set?" proxy. `selfSplitWeight` is a +primitive `long` defaulting to `0`, so it conflates two distinct things: +1. native splits (which never call `.selfSplitWeight(...)`, default 0) — should NOT emit, and +2. JNI splits whose genuine weight is 0 — **should** emit (this is the bug). + +The property is **consumed** only on the JNI branch of `populateRangeParams:185-197` +(inside `if (paimonSplitVal != null)`), via `rangeDesc.setSelfSplitWeight(...)`. It feeds only BE's +`_max_time_split_weight_counter` (`be/.../jni_reader.cpp:246`, a `ConditionCounter`) — a **profile +counter**. It never influences rows / counts / predicates / schema / routing. + +## Legacy parity (the authoritative reference) + +`PaimonScanNode.setPaimonParams` (fe-core `datasource/paimon/source/PaimonScanNode.java:253-287`): + +- **JNI/cpp branch** (`split != null`, line 274): `rangeDesc.setSelfSplitWeight(paimonSplit.getSelfSplitWeight())` + — **unconditional**, no `> 0` guard. Emitted for every JNI split including weight 0. +- **Native branch** (`split == null`, lines 275-287): `setSelfSplitWeight` is **never called**. + Native splits never carry the thrift weight → BE defaults `-1`. + +So legacy's rule is simply: **emit the weight iff the split is a JNI split.** + +## Design + +Change the constructor gate from a value check to the JNI-split check, exactly mirroring legacy and +making the property's lifecycle symmetric (emitted iff consumed — `populateRangeParams` reads it only +when `paimon.split` is present): + +```java +// FIX-A3: emit the self-split-weight for every JNI split, incl. weight 0. Legacy +// PaimonScanNode.setPaimonParams:274 sets it unconditionally on the JNI branch (never on native); +// the old `selfSplitWeight > 0` gate was a buggy is-set proxy that dropped a genuine weight-0 JNI +// split (rowCount-0 sys split / fileSize-0 DataSplit) -> BE read the -1 "unset" sentinel instead of +// 0, corrupting the profile _max_time_split_weight_counter. Gate on the JNI marker (paimonSplit) so +// native splits keep parity (no weight); this is also exactly when populateRangeParams reads it. +if (builder.paimonSplit != null) { + props.put("paimon.self_split_weight", String.valueOf(builder.selfSplitWeight)); +} +``` + +### Why gate on `paimonSplit != null`, not `>= 0` + +`task-list-P6-deviation-fixes.md` §A3 phrases the fix as "drop the `> 0` gate ... always emit." Since +the weight is always ≥ 0 (a fileSize-sum or a `rowCount()`; `computeSplitWeight:885-891`), "drop the +gate" / `>= 0` / `paimonSplit != null` are all behaviorally identical at the **BE thrift level** for +JNI splits — they all emit the genuine weight incl. 0. The choice below is only about which form is the +cleanest, most legacy-faithful expression. + +Both fix the reported bug (BE thrift identical for JNI). But `>= 0` would also start writing +`paimon.self_split_weight=0` into the **props map of native splits** (they default to weight 0). +That key is never read on the native branch, so it is harmless to BE — but it is a needless divergence +from legacy (which never set the weight on native) and a cosmetic change to native splits' internal +props. Gating on the JNI marker: +- emits 0 for JNI splits (fixes A3), +- never adds the key to native splits (exact legacy parity, no cosmetic drift), +- is symmetric with the consumer (`populateRangeParams` reads it iff `paimon.split` present). + +Both JNI build sites (`buildJniScanRange:742-748`, `buildCountRange:773-781`) always call +`.paimonSplit(...)` **and** `.selfSplitWeight(...)`, so this never under-emits for a real JNI split. + +## Implementation Plan + +Single-line change in `fe/fe-connector/fe-connector-paimon/.../PaimonScanRange.java` constructor +(line 92): replace `if (builder.selfSplitWeight > 0)` with `if (builder.paimonSplit != null)` + the +explanatory comment above. + +No SPI/interface change, no BE change, no other call-site change. + +## Risk Analysis + +- **Native splits:** unchanged at the BE thrift level (native branch of `populateRangeParams` never + reads/sets the weight). With the JNI-marker gate they also keep an unchanged props map (no new key). +- **JNI splits with weight > 0:** unchanged (still emitted). +- **JNI splits with weight 0:** now emit `0` (the fix). BE reads `0` instead of `-1` — corrects the + profile counter; no functional path touched. +- **Negative weight:** not reachable — weight is a fileSize-sum or a `rowCount()`, both ≥ 0. Even if + it were, legacy emitted unconditionally, so emitting it is parity-correct. +- No correctness/perf/route impact — profile-only. No regression test currently asserts this line + (so nothing to update; we ADD coverage). + +## Test Plan + +### Unit Tests (fe-connector-paimon, `org.apache.doris.connector.paimon`) + +New `PaimonScanRangeSelfSplitWeightTest` (direct `PaimonScanRange.Builder`, same style as +`PaimonScanRangePartitionNullTest`). No existing test asserts `self_split_weight` (verified: 0 hits in +the test tree) → all three are NEW coverage, nothing to update. + +1. **JNI split, weight 0 — the fix, BE-visible (load-bearing):** drive `populateRangeParams` and + assert `rangeDesc.isSetSelfSplitWeight() && rangeDesc.getSelfSplitWeight() == 0` — this is the + legacy `:274` parity target and proves BE reads `0`, not the `-1` unset sentinel. Also assert the + props map carries `paimon.self_split_weight == "0"`. RED before: with the `> 0` gate, prop absent + → `populateRangeParams` never calls `setSelfSplitWeight` → `isSetSelfSplitWeight()` false → BE -1. +2. **JNI split, weight > 0 — positive coverage (NEW):** prop present and matches; pins the positive + case keeps working (no prior test covered it). +3. **Native split (no `paimonSplit`) — native unaffected, BE-visible:** drive `populateRangeParams` + on a native range and assert `!rangeDesc.isSetSelfSplitWeight()` (native never carries the weight, + legacy parity — native branch sets ORC/PARQUET, never the weight). Additionally assert the props + map does NOT contain `paimon.self_split_weight` — this is the only assertion that pins the chosen + JNI-marker gate over `>= 0` (with `>= 0`, native gains a BE-invisible `=0` key); labeled as the + gate-choice pin, distinct from the BE-visible parity assertion above. + +RED→GREEN mutation: restoring the old `> 0` gate turns test 1 red (weight-0 JNI: prop absent + +`isSetSelfSplitWeight()` false). Switching to `>= 0` turns test 3's props-key-absent assertion red. + +### E2E Tests + +None. Profile-counter parity is not asserted by any regression suite and constructing a deterministic +weight-0 JNI split end-to-end (paimon SDK split with rowCount 0 / fileSize 0) is not worth a live +suite for a NIT. e2e is gated (`enablePaimonTest=false`) regardless. + +## Build / Verify + +- `mvn -f .../fe/pom.xml -pl :fe-connector-paimon -am package -Dassembly.skipAssembly=true + -Dmaven.build.cache.enabled=false -DfailIfNoTests=false` (HiveConf in shade jar; checkstyle in + `validate`). +- `tools/check-connector-imports.sh` must stay exit 0 (no fe-core import added). +- Confirm the new test fails on the pre-fix gate (mutation), passes on the fix. diff --git a/plan-doc/designs/FIX-A3-SELF-SPLIT-WEIGHT-summary.md b/plan-doc/designs/FIX-A3-SELF-SPLIT-WEIGHT-summary.md new file mode 100644 index 00000000000000..14e7131db63156 --- /dev/null +++ b/plan-doc/designs/FIX-A3-SELF-SPLIT-WEIGHT-summary.md @@ -0,0 +1,62 @@ +# FIX-A3 — JNI split `self_split_weight` omitted when weight is 0 — SUMMARY + +> P6 deviation→fix (1 of 5). Severity NIT (profile-parity only). Design + design red-team +> (`wf_3f2cd605-2a8`, 9 candidates → 0 actionable on the code) + RED→GREEN UT + impl-verify (APPROVE). + +## Problem + +The paimon connector emitted the BE thrift profile property `paimon.self_split_weight` only when the +computed weight was `> 0`. A JNI split whose genuine weight is exactly **0** (a non-`DataSplit` system +split with `rowCount()==0`, or a `DataSplit` whose files sum to `fileSize==0`) therefore left the +property unset, and BE fell back to the `-1` "unset" sentinel instead of `0`. + +## Root Cause + +`PaimonScanRange` constructor gated on the value: `if (builder.selfSplitWeight > 0)` +(`fe-connector-paimon/.../PaimonScanRange.java:92`). `selfSplitWeight` is a primitive `long` +defaulting to `0`, so the `> 0` check doubled as a crude "is-set?" proxy — conflating native splits +(which never set a weight, default 0; correctly suppressed) with JNI splits whose genuine weight is 0 +(incorrectly suppressed). The property is consumed only on the JNI branch of `populateRangeParams` +and feeds only BE's `_max_time_split_weight_counter` profile counter (`jni_reader.cpp:246`); BE +defaults to `-1` when the thrift field is unset (`paimon_jni_reader.cpp:95`). + +Legacy `PaimonScanNode.setPaimonParams:274` sets the weight **unconditionally on the JNI branch and +never on the native branch** — so the parity rule is simply "emit iff JNI split." + +## Fix + +One-line gate change (+ explanatory comment) in `PaimonScanRange` constructor: + +```java +if (builder.paimonSplit != null) { // was: if (builder.selfSplitWeight > 0) + props.put("paimon.self_split_weight", String.valueOf(builder.selfSplitWeight)); +} +``` + +Gating on the JNI marker (`paimonSplit`) rather than the value emits the genuine weight (incl. 0) for +every JNI split, never adds the key to native splits (exact legacy parity, no cosmetic drift), and is +symmetric with the consumer (`populateRangeParams` reads the prop iff `paimon.split` present). Both +JNI build sites (`buildJniScanRange`, `buildCountRange`) always set both `paimonSplit` and +`selfSplitWeight`, so the gate can neither under- nor over-emit; weight is provably ≥ 0 +(fileSize-sum / `rowCount()`), so this is BE-thrift-identical to the task-list's literal "drop the +`> 0` gate" while being the cleanest, most legacy-faithful form. No SPI/interface/BE change. + +## Tests + +New `PaimonScanRangeSelfSplitWeightTest` (3 tests, direct `PaimonScanRange.Builder`): +1. **JNI split, weight 0** — drives `populateRangeParams` and asserts `isSetSelfSplitWeight()` && + `getSelfSplitWeight()==0` (BE-visible, load-bearing) + props `"0"`. **RED before** the fix + (verified by an actual run: 1 failure on unfixed code — prop absent → thrift unset → BE -1). +2. **JNI split, weight > 0** — positive coverage (no prior test asserted this property). +3. **Native split** — `!isSetSelfSplitWeight()` (BE-visible native parity) + props key absent + (gate-choice pin: switching to `>= 0` would make this RED). + +## Result + +- RED→GREEN verified by separate runs: unfixed → 1 failure (test 1); fixed → **3/0/0**. +- Full paimon module: **283 run / 0 failures / 0 errors / 1 skipped** (gated e2e); checkstyle 0 + violations; `tools/check-connector-imports.sh` exit 0. +- Design red-team (3 lenses → verifier): core fix CONFIRMED correct; only doc/test-plan refinements + (folded in). Impl-verify reviewer: **APPROVE**, no actionable issues. +- e2e is gated (`enablePaimonTest=false`) — NOT run (profile-counter parity is not asserted by any + regression suite). diff --git a/plan-doc/designs/FIX-B-MC2-SCHEMA-AT-MEMO-design.md b/plan-doc/designs/FIX-B-MC2-SCHEMA-AT-MEMO-design.md new file mode 100644 index 00000000000000..a0b9a4c16a1a59 --- /dev/null +++ b/plan-doc/designs/FIX-B-MC2-SCHEMA-AT-MEMO-design.md @@ -0,0 +1,249 @@ +# FIX-B-MC2 — time-travel schema-at-snapshot second-level memo (NO PERF REGRESSION) + +> Source: `task-list-P6-deviation-fixes.md` §B-MC2 + `reviews/P6-paimon-fullpath-cleanroom-2026-06-18.md` §MC2. +> Single-task loop: design → **design red-team (DONE, `wf_903bf4e9-3a4`)** → implement → impl-verify → build+UT. +> Hard constraint: **NO performance regression** — the no-regression argument below MUST hold and WAS +> red-team-verified. **This doc reflects the post-red-team revisions** (see §Red-team adjudication at the end). + +## Problem + +Time-travel reads (`FOR VERSION/TIME AS OF`, `@tag`, `@branch`) resolve the schema AS OF the pinned +schemaId through `PaimonConnectorMetadata.getTableSchema(session, handle, snapshot)` → +`catalogOps.schemaAt(table, snapshot.getSchemaId())` (`PaimonConnectorMetadata.java:221-222`). That +`schemaAt` is a paimon `schemaManager().schema(schemaId)` read (a schema-file round-trip, +`PaimonCatalogOps.java:321-327`). It runs on **every query** and the result is pinned only to the +per-statement `PluginDrivenMvccSnapshot` (`PluginDrivenMvccExternalTable.java:260-262`). Repeated +time-travel to the same snapshot re-reads the schema file each time. + +Legacy served this from the shared catalog-level `PaimonExternalMetaCache` keyed by +`(NameMapping, schemaId)` — a repeat time-travel to the same schemaId was a cache **hit**. The SPI +cutover (review tag CACHE-P1) dropped that second-level cache; only the **latest** schema stays cached +(via the bridge's generic schema cache — see "Latest path untouched" below). + +Severity: **NIT** (CACHE-P1). Diagnostic/perf only, no correctness impact — but the user elected to fix +it (restore the legacy hit) rather than accept-as-deviation. + +## Root cause + +`PaimonConnector.getMetadata(session)` returns a **fresh** `new PaimonConnectorMetadata(...)` on every +call (`PaimonConnector.java:94-97`), and fe-core calls `getMetadata(session)` **once per query** +(`PluginDrivenMvccExternalTable.java:122,218` and every other call site). So the metadata object is a +per-query throwaway: nothing on it persists the at-snapshot schema across queries. The legacy cache lived +on the long-lived catalog-level metacache; the cutover has no equivalent on the time-travel path. + +**Consequence for the fix's home (critical):** a memo stored as an *instance field of +`PaimonConnectorMetadata`* would give **zero** cross-query benefit (it would die with the per-query +metadata object after its single `schemaAt`). The memo's **storage must live on the long-lived +`PaimonConnector`** (one per catalog), and be *injected into* the per-query metadata so the at-snapshot +resolve can consult/populate it. (`PaimonTableResolver` is a stateless static utility — `final class`, +private ctor — so it is NOT a home for cross-query state.) + +## Design + +**Connector-side, immutable, bounded memo of the `schemaAt` read. Bridge UNCHANGED. SPI UNCHANGED.** + +### What is memoized (post-red-team): the raw `PaimonSchemaSnapshot`, NOT the built `ConnectorTableSchema` + +The red-team (MAJOR, REAL) showed the built `ConnectorTableSchema` is **not** a pure function of the +schemaId key: `buildTableSchema` sources its `properties` from the **live** table — +`schemaProps.putAll(((DataTable) table).coreOptions().toMap())` (`PaimonConnectorMetadata.java:251-252`), +where `table = resolveTable(handle)` is the LATEST table. Caching the built schema would freeze the live +`coreOptions` under a schemaId key and could serve stale PROPERTIES after an external `ALTER…SET OPTION` +without REFRESH (D-046 SHOW CREATE TABLE PROPERTIES channel) — a violation of "never return stale data +than today". + +So we memoize the **`PaimonCatalogOps.PaimonSchemaSnapshot`** (fields + partitionKeys + primaryKeys — +the exact output of the `schemaAt` schema-file read), which **IS** a pure function of +`(table-identity, schemaId)` (a committed schemaId's schema content is write-once). `ConnectorTableSchema` +is rebuilt fresh per query from the live `resolveTable` table, so `coreOptions`/`properties` stay LIVE = +byte-identical to today. The ONLY behavioral delta vs today is: **the `schemaAt` schema-file read is +skipped on a repeat**. `resolveTable` and `buildTableSchema` still run every query (unchanged). + +### Components + +1. **New tiny class `PaimonSchemaAtMemo`** (package-private, in `fe-connector-paimon`): + - Storage = plain **`ConcurrentHashMap`** (matches the + module convention — the only long-lived caches in fe-connector-paimon are plain `ConcurrentHashMap`, + `PaimonConnector.java:81-82`; lock-free reads; no new dependency). + - **`MemoKey`** = immutable holder of the handle's **extracted identity** `(databaseName, tableName, + sysTableName, branchName, schemaId)` built in `new MemoKey(PaimonTableHandle handle, long schemaId)`. + It mirrors `PaimonTableHandle.equals/hashCode`, which key on exactly + `(databaseName, tableName, sysTableName, branchName)` (`PaimonTableHandle.java:233-240`, `scanOptions` + correctly excluded from identity). **Extracted fields, NOT a retained handle reference** — deliberate: + a `PaimonTableHandle` carries its loaded paimon `Table` (`setPaimonTable`), so retaining the handle as + a map key would pin that `Table` (catalog/schema/IO refs) in the cache for its lifetime. Extracting the + four `String`/`long` identity fields avoids that memory pin at the cost of a documented sync-point with + the handle's identity (a comment in `MemoKey` points to `PaimonTableHandle:233-240`). This is a + deliberate deviation from the red-team's "delegate to handle.equals" suggestion, which did not account + for `Table`-pinning. Branch is load-bearing (same schemaId on different branches = different schema; + `branchName` is live on the pinned handle via `applySnapshot→withBranch`). `sysName` is a forward-compat + guard (sys tables don't reach this path today, but a key collision would be a correctness bug). + - **Bound (best-effort, honors task-list "bounded (maxSize)"):** before a `put`, if `size() >= MAX`, + `clear()` the map. Crude but correct: values are immutable, so a flush only causes re-reads + (= today), never a stale/wrong value. The keyspace is `(table, branch, schemaId)` — naturally tiny — + so this safety valve effectively never fires; `MAX` is a generous constant (proposed `10000`). The + map is also freed wholesale on REFRESH (connector rebuild). + - `PaimonSchemaSnapshot getOrLoad(PaimonTableHandle handle, long schemaId, Supplier loader)`: + ``` + MemoKey k = new MemoKey(handle, schemaId); + PaimonSchemaSnapshot hit = cache.get(k); // lock-free + if (hit != null) return hit; + PaimonSchemaSnapshot loaded = loader.get(); // the schemaAt I/O — OUTSIDE any lock + if (cache.size() >= MAX) { // best-effort bound + cache.clear(); + } + PaimonSchemaSnapshot prev = cache.putIfAbsent(k, loaded); + return prev != null ? prev : loaded; // canonicalize on a concurrent race + ``` + The loader runs without any lock (no I/O-under-lock, no `computeIfAbsent`). A concurrent same-key + miss may double-load (rare) — harmless: the value is immutable and identical (same schemaId), and a + double-load equals today's two independent per-query loads, never worse. A loader exception + propagates BEFORE any `put`, so failures are never negative-cached. + +2. **`PaimonConnector`**: add `private final PaimonSchemaAtMemo schemaAtMemo = new PaimonSchemaAtMemo(MAX);` + and pass it into the metadata: `new PaimonConnectorMetadata(catalogOps, properties, context, schemaAtMemo)`. + The connector is one-per-catalog and set to `null` on `onClose()` + (`PluginDrivenExternalCatalog.java:557`), which REFRESH CATALOG triggers via + `resetToUninitialized()→onClose()`; the next access rebuilds the connector → **fresh empty memo**. So + REFRESH is the (only needed) invalidation. + +3. **`PaimonConnectorMetadata`**: + - New **package-private** 4-arg ctor `(catalogOps, properties, context, schemaAtMemo)` storing the memo. + Package-private (not public) keeps the connector surface minimal (Rule 3); the cross-query-hit test + lives in the same package `org.apache.doris.connector.paimon` and constructs both metadata instances + through it with a shared `PaimonSchemaAtMemo`. + - Keep the existing **public** 3-arg ctor delegating to the 4-arg with a **fresh per-instance** + `new PaimonSchemaAtMemo(MAX)`. This keeps all ~15 existing test construction sites compiling + unchanged; their single-resolve behavior is identical (first call is always a miss → load). + - In `getTableSchema(session, handle, snapshot)` (the at-snapshot overload), the **only** change is the + `schemaId >= 0` branch (the `< 0` latest fallback at line 216-217 is untouched). **`resolveTable` is + called ONCE, outside the loader**, so the branch-handle `getTable` reload happens at most once per + query = today: + ``` + PaimonTableHandle paimonHandle = (PaimonTableHandle) handle; + long schemaId = snapshot.getSchemaId(); + Table table = resolveTable(paimonHandle); // once — keeps branch getTable == today + PaimonCatalogOps.PaimonSchemaSnapshot schema = + schemaAtMemo.getOrLoad(paimonHandle, schemaId, () -> catalogOps.schemaAt(table, schemaId)); + return buildTableSchema(paimonHandle.getTableName(), table, + schema.fields(), schema.partitionKeys(), schema.primaryKeys()); + ``` + +## NO-regression argument (must hold + WAS red-team-verified) + +1. **The memoized value is a pure function of the key → no stale read, no TTL.** We cache only + `PaimonSchemaSnapshot` (fields/partitionKeys/primaryKeys of a *committed* schemaId), which is write-once + in paimon (rollback/ALTER mint *new* ids; a re-pointed tag resolves a *new* schemaId at resolve-time → + a different key, never a stale hit). The live-bound `coreOptions`/`properties` are NOT cached — they are + rebuilt per query from the live table, so they stay current (the red-team MAJOR that killed the + `ConnectorTableSchema`-memo). The only invalidation is REFRESH CATALOG (connector rebuild → fresh memo). +2. **Latest path untouched.** The memo is consulted **only** on the `schemaId >= 0` at-snapshot branch. + `schemaId < 0` (latest / system tables) still delegates to `getTableSchema(session, handle)` (line + 216-217), cached cross-query by the bridge's generic schema cache (`getSchemaCacheValue → + getLatestSchemaCacheValue → super`). The 2-arg latest `getTableSchema` (called from + `PluginDrivenExternalTable:172`) is untouched — no double-caching. +3. **Worst case = current.** On a miss: `resolveTable` + `schemaAt` + `buildTableSchema` (= today) + an + O(1) hash put. On a hit: `resolveTable` + `buildTableSchema` (= today) with the `schemaAt` read + **skipped** (strictly faster, = legacy). On overflow/eviction or a concurrent double-load: a re-read = + today. The memo NEVER does more work than today on any path. + +## Implementation Plan + +- New file `fe-connector-paimon/.../PaimonSchemaAtMemo.java` (ASF header, javadoc, `ConcurrentHashMap` + + `MemoKey` + `getOrLoad` + `size()` test accessor). +- `PaimonConnector.java`: add the `schemaAtMemo` field; pass it in `getMetadata`. +- `PaimonConnectorMetadata.java`: package-private 4-arg ctor + public 3-arg delegate; memo-wrap the + `schemaId>=0` branch of the 3-arg `getTableSchema` (resolveTable once, outside the loader). +- No SPI/bridge/BE change. `tools/check-connector-imports.sh` stays exit 0 (only `java.util.concurrent.*` + + `java.util.function.Supplier` + existing connector/paimon imports added; verified the allowlist does + not match these). + +## Risk Analysis + +- **Thread-safety:** `ConcurrentHashMap` (lock-free reads); loader runs outside any lock; immutable value + → safe publication via the concurrent map. No iteration. The size-guard `clear()` is best-effort under + concurrency (worst case a few extra re-reads — never a correctness issue). +- **Stale properties:** eliminated by memoizing `PaimonSchemaSnapshot` (not the live-bound built schema). +- **Key correctness:** delegates to `PaimonTableHandle.equals/hashCode` (db+table+sysName+branch) — no + re-listed second identity site, no cross-branch/cross-sys collision. schemaId<0 never builds a key. +- **Ctor blast radius:** only the connector-internal ctor changes; the SPI `ConnectorMetadata` interface + is untouched; the public 3-arg ctor is retained → no test/site churn. +- **Memory:** best-effort bounded by `MAX`; per-catalog; freed on REFRESH/close. +- **Checkstyle:** new file needs license header + class/field javadoc; runs in `validate` phase. + +## Test Plan + +### Unit Tests (`PaimonConnectorMetadataMvccTest` new tests, RED→GREEN verified by separate runs) + +Drive via the recording seam; count underlying `schemaAt` reads through `ops.log` ("schemaAt:N"). Use a +**shared memo across two metadata instances** (each its own `RecordingPaimonCatalogOps`) to model two +queries (each query = a fresh `getMetadata` in production, sharing the connector-owned memo). The 4-arg +package-private ctor makes this construction compilable in-package. + +1. **Cross-query hit (non-branch):** ops1 + ops2 share ONE `PaimonSchemaAtMemo`, both configured with the + same `schemaAt`. Resolve the same `(handle, schemaId=2)` on metadata1 then metadata2. Assert ops1.log + contains `schemaAt:2` exactly once and **ops2.log contains NO `schemaAt`** (the second resolve hit the + memo — the primary RED→GREEN signal). Both results are value-equal. **MUTATION (RED):** remove the memo + → ops2 also reads → ops2.log gains `schemaAt:2`. +2. **Different schemaId → reads again:** shared memo, resolve schemaId=2 then schemaId=3 → distinct keys → + ops sees both `schemaAt:2` and `schemaAt:3`. +3. **Different branch, same schemaId → reads again (branch-in-key guard):** two-ops-shared-memo; ops1 = + base handle, ops2 = `withBranch("b1")` handle with `ops2.branchTable` carrying `bid/bdt` (mirroring the + existing branch test at `PaimonConnectorMetadataMvccTest.java:963-993`), both at schemaId=2. Assert + BOTH: **(a)** the BRANCH ops2.log contains `schemaAt:2` (the branch resolve actually read — was NOT a + cross-branch memo hit) AND **(b)** the branch-handle result columns equal `[bid,bdt]` and differ from + the base-handle result columns `[id]` (the branch returned the branch schema, not a stale base value + cached under a branch-blind key). **MUTATION (RED):** drop the branch component from the key → branch + resolve hits → (a) and (b) both go RED. +4. **Latest path unaffected:** schemaId<0 resolve does not consult the memo (no `schemaAt`); the existing + `getTableSchemaWithNegativeSchemaIdFallsBackToLatest` already pins this. +5. **Existing exact-equality at-snapshot + branch tests** keep passing unchanged (single resolve = miss = + identical result; per-instance memo via the 3-arg ctor). + +### Micro-tests (`PaimonSchemaAtMemoTest`) + +- **schemaId-keyed dedup:** two `getOrLoad` calls for the same `(handle, schemaId)` with a counting + `Supplier` → loader invoked ONCE. +- **sysName-distinguishing (Rule 9 guard for the sysName key component):** two handles equal in + `(db, table, branch, schemaId)` but differing in `sysTableName` (one via + `PaimonTableHandle.forSystemTable`, mirroring the test `sysHandle` helper) → two distinct loads (a + sysName-blind key would yield one). +- **bound/eviction (honors "bounded"):** put `MAX+1` distinct keys, assert the map stays bounded and an + evicted key re-loads on next access (proves eviction degrades to a re-read, never a stale value) — + validates the no-regression "worst case = current" claim directly. + +### E2E + +Gated (`enablePaimonTest=false`) — **NOT run**; note as gated in the summary. The UT cross-query-hit test +is the authoritative proof; a live e2e would only observe a latency delta, not a correctness change. + +## Files + +- NEW `fe/fe-connector/fe-connector-paimon/.../PaimonSchemaAtMemo.java` +- `fe/fe-connector/fe-connector-paimon/.../PaimonConnector.java` +- `fe/fe-connector/fe-connector-paimon/.../PaimonConnectorMetadata.java` +- `fe/fe-connector/fe-connector-paimon/.../test/.../PaimonConnectorMetadataMvccTest.java` (new tests) +- NEW `fe/fe-connector/fe-connector-paimon/.../test/.../PaimonSchemaAtMemoTest.java` + +## Red-team adjudication (`wf_903bf4e9-3a4`, 4 lenses → per-finding verify) + +All four lensVerdicts judged the design **structurally sound** on lifecycle, no-regression, tests, and +scope. 6 actionable findings adopted (incorporated above): + +- **MAJOR (REAL):** built-`ConnectorTableSchema` memo serves stale live `coreOptions` → **switched the + memoized value to `PaimonSchemaSnapshot`** (pure function of the key); rebuild `ConnectorTableSchema` + per query so options stay live. +- **MINOR (REAL):** hand-rolled 5-field `Key` duplicates `PaimonTableHandle` identity → **`MemoKey(handle, + schemaId)` delegating to `handle.equals/hashCode`** (drift-proof, ~40 fewer lines). +- **NIT (PARTIAL):** LRU/synchronizedMap over-engineered vs module convention → **plain `ConcurrentHashMap` + + best-effort clear-on-overflow bound**; NOT `computeIfAbsent` (loader I/O must stay off any lock). +- **MAJOR→test (PARTIAL):** branch Test 3 underspecified → **hardened to assert the branch actually read + + columns differ**. +- **MINOR (REAL):** no test guards the `sysName` key component → **added the sysName-distinguishing + micro-test** (kept sysName in the key). +- **NIT (PARTIAL):** 4-arg ctor visibility unspecified → **pinned package-private**. + +Refuted/optional (no change): negative-caching of loader failures (pseudocode already correct); +`assertSame`-on-hit (the `ops.log` no-schemaAt assertion is the real discriminator, and `assertSame` would +wrongly couple to instance-memoization — incompatible with the `PaimonSchemaSnapshot` rebuild); Test-4 +memo-not-touched extension (already optional/covered); import-rule safe (confirmed). diff --git a/plan-doc/designs/FIX-B-MC2-SCHEMA-AT-MEMO-summary.md b/plan-doc/designs/FIX-B-MC2-SCHEMA-AT-MEMO-summary.md new file mode 100644 index 00000000000000..d21bef921276cb --- /dev/null +++ b/plan-doc/designs/FIX-B-MC2-SCHEMA-AT-MEMO-summary.md @@ -0,0 +1,74 @@ +# FIX-B-MC2 — time-travel schema-at-snapshot second-level memo — SUMMARY + +> Deviation 3/5 of the P6 deviation→fix batch. Single-task loop: design → design red-team (`wf_903bf4e9-3a4`) +> → implement → impl-verify (`wf_67804f35-d5e`) → build+UT (RED→GREEN) → commit. Design + adjudication detail +> in `FIX-B-MC2-SCHEMA-AT-MEMO-design.md`. + +## Problem + +Time-travel reads (`FOR VERSION/TIME AS OF`, `@tag`, `@branch`) resolved the schema AS OF the pinned +schemaId by calling `catalogOps.schemaAt(table, schemaId)` (a paimon `schemaManager().schema(id)` +schema-file read) on **every query**, pinning the result only to the per-statement +`PluginDrivenMvccSnapshot`. Repeated time-travel to the same snapshot re-read the schema file. Legacy +served it from the shared catalog-level `PaimonExternalMetaCache` keyed by `(NameMapping, schemaId)` (repeat += hit); the SPI cutover (CACHE-P1) dropped that second-level cache. NIT / perf-only; the user elected to fix. + +## Root cause + +`PaimonConnector.getMetadata()` returns a **fresh** `PaimonConnectorMetadata` per query, so nothing on the +metadata persists the at-snapshot schema across queries. The legacy cache lived on the long-lived +catalog-level metacache; the cutover had no equivalent on the time-travel path. + +## Fix + +A connector-side, immutable, bounded second-level memo of the `schemaAt` read: + +- **New `PaimonSchemaAtMemo`** (package-private): a plain `ConcurrentHashMap` + (module convention; lock-free reads) with a best-effort size bound (clear-on-overflow). `getOrLoad` does + `get → (miss) loader.get() OUTSIDE any lock → putIfAbsent`; a concurrent same-key double-load is harmless + (immutable identical value) and equals the pre-fix per-query load; a loader exception is never cached. +- **`MemoKey`** = the handle's extracted identity `(databaseName, tableName, sysTableName, branchName, + schemaId)`, mirroring `PaimonTableHandle.equals/hashCode`. Stored as extracted fields (NOT a retained + handle) so the memo does not pin the handle's loaded paimon `Table` in memory. +- **`PaimonConnector`** owns the memo (one per catalog, long-lived; rebuilt → empty on REFRESH CATALOG via + `onClose` `connector=null`) and injects it into each per-query metadata via a new **package-private** + 4-arg ctor. The public 3-arg ctor delegates with a fresh per-instance memo, so the ~15 existing + construction sites are unchanged. +- **`PaimonConnectorMetadata.getTableSchema(session, handle, snapshot)`** — the only changed path, and only + its `schemaId >= 0` branch (the `< 0` latest fallback is untouched): `resolveTable` runs **once** (outside + the loader, so a branch handle's `getTable` reload stays at most one per query = pre-fix), then + `schemaAtMemo.getOrLoad(handle, schemaId, () -> catalogOps.schemaAt(table, schemaId))`, then + `buildTableSchema` rebuilds the `ConnectorTableSchema` fresh from the live table. + +**Key red-team correction (MAJOR):** the original design memoized the built `ConnectorTableSchema`, which +embeds the **live** `coreOptions()` — not keyed by schemaId → could serve stale PROPERTIES after an +external `ALTER…SET` without REFRESH. Switched to memoizing the raw `PaimonSchemaSnapshot` (a pure function +of `(table-identity, schemaId)` — the actual `schemaAt` I/O target); `ConnectorTableSchema` is rebuilt per +query so `coreOptions`/`properties` stay live. The single behavioral delta vs pre-fix is therefore "the +`schemaAt` read is skipped on a repeat"; everything else is byte-identical. + +## No-regression (the hard constraint — red-team-verified) + +1. The cached value is a pure function of the key → no stale read, no TTL; the only invalidation is REFRESH + (connector rebuild → fresh memo). Live coreOptions are NOT cached. +2. The latest path (schemaId<0) never builds a key; the 2-arg latest schema stays cached by the bridge. +3. Worst case = pre-fix: miss = pre-fix load + O(1) put; hit = pre-fix minus the `schemaAt` read; overflow/ + concurrent-double-load = a re-read. The memo never does more work than before on any path. + +## Tests (RED→GREEN verified by separate mutation runs) + +- `PaimonConnectorMetadataMvccTest` (+3): `getTableSchemaAtSnapshotIsMemoizedAcrossQueries` (two metadata + sharing one memo → second query reads NO `schemaAt`), `...MemoIsKeyedBySchemaId`, `...MemoIsKeyedByBranch` + (asserts the branch actually read AND its columns differ from base). +- `PaimonSchemaAtMemoTest` (new, 3): `sameKeyLoadsOnce`, `sysTableNameDistinguishesKey` (Rule-9 guard for the + sysName key component), `overflowEvictsAndReReadsNeverStale` (bound degrades to a re-read, never stale). +- **RED proof:** RED-1 (memo disabled) → both memo tests fail; RED-2 (key drops schemaId/branch/sys) → all + 3 key tests fail; RED-3 (bound disabled) → overflow test fails. Each control test stayed green. + +## Result + +Full paimon module: **293 tests, 0 failures, 0 errors, 1 skipped** (gated live test); checkstyle clean; +`tools/check-connector-imports.sh` exit 0; BUILD SUCCESS. No fe-core / SPI / BE change. **e2e gated +(`enablePaimonTest=false`) — NOT run.** + +HEAD after commit: see git log. Next deviation: **A1** (plugin split proportional weight), then **B-R2-be**. diff --git a/plan-doc/designs/FIX-B-R2-BE-SCHEMA-DICT-MEMO-design.md b/plan-doc/designs/FIX-B-R2-BE-SCHEMA-DICT-MEMO-design.md new file mode 100644 index 00000000000000..db2e2246bbfe12 --- /dev/null +++ b/plan-doc/designs/FIX-B-R2-BE-SCHEMA-DICT-MEMO-design.md @@ -0,0 +1,147 @@ +# FIX-B-R2-be — memoize the schema-evolution dict's per-schema reads (NO PERF REGRESSION) + +> Source: `task-list-P6-deviation-fixes.md` §B-R2-be + `reviews/P6-paimon-fullpath-cleanroom-2026-06-18.md` §R2 (be). +> Single-task loop: design → design red-team → implement → impl-verify → build+UT → commit. +> **User decision (this session): Option A — memoize the reads, keep the eager superset emission** (the +> "narrow to referenced ids" option was found architecturally infeasible connector-only + BE-crash-prone; +> see below). Hard constraint: **NO performance regression.** + +## Problem & why narrowing was rejected + +`buildSchemaEvolutionParam` (`PaimonScanPlanProvider.java:1298-1317`) builds the BE `history_schema_info` +dict by reading **every** committed schema: `for (Long schemaId : schemaManager.listAllIds()) { history.add( +buildSchemaInfo(schemaId, schemaManager.schema(schemaId).fields(), false)); }` — one schema-file read per +committed schema, **every scan**, even when the query's files touch only one schema id. Legacy added +entries lazily, one per distinct file `schema_id` a split referenced (+ the `-1` current entry). + +**Narrowing (the task-list's primary fix) is infeasible connector-only and was rejected** (verified against +code): the dict is built in `getScanNodeProperties`, but the referenced schema_ids are only known in +`planScan`; `connector.getScanPlanProvider()` returns a NEW provider per call (`PaimonConnector.java:108`) +so no instance field can bridge them; the dict build fires lazily and **often before** splits are planned +(triggers: `getNodeExplainString:258`, `getSerializedTable:925`, conjunct-pruning, or +`createScanRangeLocations:940` after `super`→`getSplits`); the referenced set is per-scan (depends on +partition-pruning + predicates) so a table-keyed cache is imprecise/racy; the generic bridge must not +collect `paimon.schema_id` (connector-agnostic rule); and re-planning in the props build is forbidden (new +I/O = regression). An under-covering narrowed set **hard-crashes BE** (`table_schema_change_helper.h` +"miss table/file schema info", cf. CI 969249). → **Memoize instead.** + +## Design — Option A: memoize the per-schema-id read, emission UNCHANGED + +Keep `listAllIds()` and the full dict emission **byte-identical** (the dict still covers every committed +schema → always covers any file's schema_id → **zero BE-crash risk**). Only change HOW each historical +entry's fields are obtained: read through a **connector-level, immutable, bounded memo** keyed by +`(table-identity, schemaId)`, so the schema-file reads are served from cache across scans (a committed +schemaId's fields are write-once → no TTL; cleared on REFRESH = connector rebuild). + +**Reuse the existing `PaimonSchemaAtMemo` (the B-MC2 memo).** The fact being cached — "the fields of table T +at committed schema version S" — is EXACTLY what `PaimonSchemaAtMemo` already holds (`PaimonSchemaSnapshot`, +keyed by `MemoKey(handle, schemaId)`, immutable). Both features call the SAME underlying read +`schemaManager.schema(schemaId)`. Caching it ONCE and serving both the time-travel path (B-MC2) and the +schema-evolution dict (this fix) is the DRY, efficient design. + +**Consistency proof (same key → same value across the two features) — MUST hold. PRIMARY proof = the +write-once invariant (NOT object identity):** +- **Write-once invariant (the load-bearing argument):** for any committed `schemaId`, + `schemaManager.schema(schemaId)` reads the **immutable, write-once `schema-` file** — its content is + independent of WHICH `Table` instance's `schemaManager` reads it (B-MC2's UNPINNED `resolveTable`, or this + fix's snapshot-PINNED `resolveScanTable` = `table.copy(scan.snapshot-id=…)`). `MemoKey` deliberately + excludes `scanOptions` from identity (`PaimonTableHandle.equals`), so the pinned and unpinned reads + legitimately share ONE key, and the `scan.snapshot-id` pin does NOT change which committed schema file a + given `schemaId` resolves to. So the same `MemoKey` always yields the same `fields()`. (Object identity of + the `Table` is NOT required and does NOT hold for a time-travel scan — only value-equality, which the + write-once invariant guarantees.) +- `$ro`: B-MC2 NEVER writes `$ro` keys (system tables skip the at-snapshot path — `resolveTimeTravel`/ + `beginQuerySnapshot` return empty for sys, and `getTableSchema(snapshot)` short-circuits on the default + `schemaId<0`). This fix writes the BASE table's schema under the `$ro`-handle key (handle sysName="ro", + `schemaDictTable`=base). No B-MC2 value to conflict; internally consistent. (Minor: a `t` query and a + `t$ro` query don't share — different sysName — so `$ro` re-reads the base schemas once. Acceptable; rare.) +- branch: both writers key by `(db, table, branch)` and both read the branch FileStoreTable's + `schemaManager.schema(id)` (the same write-once branch schema file) — identical value. + +**Loader keeps the DIRECT read (not `catalogOps.schemaAt`)** so the existing real-`FileStoreTable` + +fake-`catalogOps` tests are unaffected (the schema-evolution tests build a real `FileStoreTable` via +`FileSystemCatalog` but construct the provider with a `RecordingPaimonCatalogOps`; routing the read through +`catalogOps.schemaAt` would break them). The loader returns a `PaimonSchemaSnapshot` (the memo's value type) +built from the same direct read: +```java +List fields = schemaAtMemo.getOrLoad(handle, schemaId, () -> { + TableSchema ts = schemaManager.schema(schemaId); // the read the finding flags + return new PaimonCatalogOps.PaimonSchemaSnapshot(ts.fields(), ts.partitionKeys(), ts.primaryKeys()); +}).fields(); +history.add(buildSchemaInfo(schemaId, fields, false)); +``` +`schemaId` and `schemaManager` are effectively final per loop iteration. Loader exceptions propagate +uncached (`getOrLoad` puts only after the loader returns) → the "schema reads that throw fail loud" javadoc +contract is preserved. + +### Components + +1. **`PaimonScanPlanProvider`**: add a `private final PaimonSchemaAtMemo schemaAtMemo;` field; a new + **package-private** 4-arg ctor `(properties, catalogOps, context, schemaAtMemo)`; the existing public + 2-arg and 3-arg ctors delegate to it with a **fresh** `new PaimonSchemaAtMemo(PaimonSchemaAtMemo + .DEFAULT_MAX_SIZE)` (so the ~8 existing provider construction sites + any non-production caller are + unchanged — a fresh per-instance memo is correct, just no cross-scan sharing they don't need). +2. **`PaimonConnector.getScanPlanProvider()`**: pass the connector's existing `schemaAtMemo` (the same one + `getMetadata` injects) into the provider — so the time-travel path and the scan dict share ONE + per-catalog cache. +3. **`buildSchemaEvolutionParam`**: take the `PaimonTableHandle` (thread it from `getScanNodeProperties:663`) + and memo-wrap the per-id read as above. Everything else (the `-1` current entry via + `resolveCurrentSchemaFields`, `listAllIds()`, the encoding) is UNCHANGED. + +## NO-regression / safety (must hold + be red-team-verified) + +1. **Emission unchanged → zero BE-crash risk.** The dict still has the `-1` entry + one entry per + `listAllIds()` id — byte-identical to today. BE always finds any file's schema_id. The fix touches only + the read mechanism, never WHAT is emitted. +2. **Immutable value, collision-free key.** A committed schemaId's fields are write-once; the + `(handle-identity, schemaId)` key is collision-free (handle identity = db+table+sysName+branch). Cleared + only on REFRESH (connector rebuild → fresh memo). No TTL, no stale read. +3. **Worst case = current.** Miss → the same direct read as today + an O(1) put; hit → the read is skipped + (faster, the win); overflow/concurrent-double-load → a re-read = today. Never more work than today. +4. **No new I/O, order-independent.** The memo does not depend on planScan/props ordering (unlike + narrowing); `listAllIds()` still runs each scan (a cheap directory listing — unchanged); only the + per-schema-file reads are cached. + +## Files + +- `fe-connector-paimon/.../PaimonScanPlanProvider.java` (ctor + field + `buildSchemaEvolutionParam` memo + thread handle) +- `fe-connector-paimon/.../PaimonConnector.java` (`getScanPlanProvider` injects `schemaAtMemo`) +- `fe-connector-paimon/.../test/.../PaimonScanPlanProviderTest.java` (new memoization test) + +## Test Plan (RED→GREEN; red-team's 6 findings folded in) + +1. **Dict build populates the shared memo (core RED→GREEN):** evolve a real `FileStoreTable` to K committed + schemas (via `FileSystemCatalog`, like the existing schema-evolution tests; `Catalog.alterTable` + + `SchemaChange.addColumn`); build a provider with a shared `PaimonSchemaAtMemo` (4-arg ctor); call + `getScanNodeProperties` → assert `memo.size() == K` (the K historical entries were read through the memo; + the `-1` entry does NOT use it). **RED before:** `buildSchemaEvolutionParam` reads directly → `size == 0`. + Also assert the memo's KEY SET is exactly `{(handle,0..K-1)}` (not just the count). +2. **Cache HIT is positively observable (sentinel pre-seed — fixes the false-pass gap):** seed the SHARED + memo for ONE `(handle, schemaId=X)` with a **sentinel** `PaimonSchemaSnapshot` whose field list DIFFERS + from the real schema (e.g. a renamed/extra `DataField` "SENTINEL_FROM_MEMO") via `memo.getOrLoad(handle, + X, () -> sentinel)`; then call `getScanNodeProperties`, **decode** the emitted `paimon.schema_evolution` + (via `applySchemaEvolutionParam` like the existing `$ro` test at `:602`), and assert the schemaId=X entry + carries the SENTINEL field (proving the build returned the CACHED value and skipped the real + `schemaManager.schema(X)` read). **RED before:** the real read overwrites → no SENTINEL → fails. +3. **Byte-identical emission vs the NON-memo baseline (safety):** on the SAME evolved table, capture + `encodedA` from a provider built with the 2/3-arg ctor (fresh per-instance memo → first build = direct + read = pre-fix behavior) and `encodedB` from the 4-arg-ctor provider (shared memo); assert + `encodedA.equals(encodedB)`. Proves the memoized path emits a byte-identical dict (no order/dedup/membership + change → zero BE-crash surface). The existing schema-evolution / `$ro` tests also stay green (2-arg ctor). +4. **force-JNI → memo NOT populated:** with a shared memo, call `getScanNodeProperties` (a) on a force-jni + handle (`isForceJni()`, e.g. a binlog sys handle) and (b) with `force_jni_scanner=true` + (`sessionWithProps`) → assert `paimon.schema_evolution` ABSENT AND `memo.size()==0` (the dict — and the + read — is gated off; guards a future regression moving the read above the gate). +5. **Connector wiring (the perf benefit hinges on ONE edit):** assert `connector.getScanPlanProvider()` and + `connector.getMetadata()` observe the SAME `PaimonSchemaAtMemo` instance, and two successive + `getScanPlanProvider()` calls share it — pins the `getScanPlanProvider` 4-arg injection so the fix can't + silently no-op (fresh per-provider memo) while still emitting a correct dict. Needs a package-private + accessor on the provider (and possibly the connector) exposing the memo for the identity assertion. + +### E2E +Gated (`enablePaimonTest=false`) — NOT run. The UTs (sentinel HIT proof + byte-identical baseline + wiring) +are the proof. + +## Decision (post red-team): REUSE `PaimonSchemaAtMemo` +All four lensVerdicts judged the design correct and explicitly recommended **REUSE** (the consistency proof +holds via the write-once invariant; a dedicated memo is unnecessary). No reuse→corruption case was found. diff --git a/plan-doc/designs/FIX-B-R2-BE-SCHEMA-DICT-MEMO-summary.md b/plan-doc/designs/FIX-B-R2-BE-SCHEMA-DICT-MEMO-summary.md new file mode 100644 index 00000000000000..9ce32750f7268e --- /dev/null +++ b/plan-doc/designs/FIX-B-R2-BE-SCHEMA-DICT-MEMO-summary.md @@ -0,0 +1,65 @@ +# FIX-B-R2-be — memoize the schema-evolution dict's per-schema reads — SUMMARY + +> Deviation 5/5 (LAST) of the P6 deviation→fix batch. Single-task loop: design → design red-team +> (`wf_222e1abd-655`) → implement → impl-verify (agent `a00f6071f82920bda`) → build+UT (RED→GREEN) → commit. +> **User decision: Option A — memoize the reads, keep the eager superset emission.** Detail: +> `FIX-B-R2-BE-SCHEMA-DICT-MEMO-design.md`. + +## Problem & why narrowing was rejected + +`buildSchemaEvolutionParam` builds BE's `history_schema_info` dict by reading **every** committed schema +(`schemaManager.listAllIds()` + `schemaManager.schema(id).fields()`) on **every scan**, even when the +query's files touch one schema. Legacy added entries lazily per referenced file `schema_id`. + +The task-list's "narrow to referenced ids" is **architecturally infeasible connector-only and BE-crash-prone**: +`getScanPlanProvider()` returns a NEW provider per call (so planScan's split schema_ids can't reach the dict +build); the dict is built lazily and often BEFORE splits are planned; the referenced set is per-scan; the +generic bridge can't collect `paimon.schema_id`; re-planning in props is forbidden (new I/O); and an +under-covering set hard-crashes BE (CI 969249). The user chose **memoization** instead. + +## Fix (Option A) + +Keep `listAllIds()` and the dict emission **byte-identical** (full superset → always covers any file's +schema_id → **zero BE-crash risk**); only the per-schema-id field READ is served from a connector-level +immutable memo. **Reuse the existing B-MC2 `PaimonSchemaAtMemo`** — it already caches exactly this fact +(`(handle, schemaId) → schema fields`, write-once, cleared on REFRESH): + +- `PaimonScanPlanProvider`: new **package-private** 4-arg ctor `(props, catalogOps, context, schemaAtMemo)`; + the public 2/3-arg ctors delegate with a **fresh** memo (so ~25 existing construction sites are + behavior-identical — first build = direct read = pre-fix). `buildSchemaEvolutionParam` now takes the + `PaimonTableHandle` and wraps the `listAllIds` loop read in `schemaAtMemo.getOrLoad(handle, schemaId, …)`; + the loader keeps the **DIRECT** read (`schemaManager.schema(id)` → `PaimonSchemaSnapshot`, NOT + `catalogOps.schemaAt`, so the real-table + fake-catalogOps tests are unaffected). The `-1` current entry + is NOT memoized (live read). +- `PaimonConnector.getScanPlanProvider()`: injects the SAME per-catalog `schemaAtMemo` that `getMetadata` + uses, so the dict reads are memoized across scans and shared with the B-MC2 time-travel path. + +**Consistency (same key → same value across both features), validated by design red-team + impl-verify:** +the cached fact is the **write-once committed `schema-` file**, identical regardless of which `Table` +instance's `schemaManager` reads it (B-MC2's unpinned `resolveTable` vs this fix's snapshot-pinned +`resolveScanTable`); `MemoKey` excludes `scanOptions` and mirrors `PaimonTableHandle` identity exactly; and +B-MC2 NEVER writes a `$ro`/sys key (sys handles short-circuit before the at-snapshot memo write). No +corruption case found. + +## No-regression / safety + +Emission unchanged → zero new BE-crash surface. Miss = today's read + O(1) put (the full snapshot adds no +I/O — `partitionKeys()/primaryKeys()` are O(1) on the already-read `TableSchema`); hit = the read is skipped; +overflow/concurrent-double-load = a re-read. Loader exceptions propagate uncached (fail-loud preserved). +Order-independent (unlike narrowing). + +## Tests (+5 `PaimonScanPlanProviderTest`, RED→GREEN; red-team's 6 findings folded in) + +- `schemaEvolutionDictPopulatesSharedMemo` (memo populated with K entries), `…ReadsFromMemoOnHit` (sentinel + pre-seed surfaces in the dict → proves a cache HIT, the MAJOR test-gap fix), `…ByteIdenticalWithMemo` + (memo path == non-memo baseline → emission unchanged), `…SkippedUnderForceJniLeavesMemoEmpty` (force-jni / + `force_jni_scanner` gate off the dict + memo), `getScanPlanProviderInjectsSharedSchemaMemo` (connector + injects the shared memo — pins the wiring so the fix can't silently no-op). +- **RED proof:** memo-bypass → the populate + sentinel-HIT tests fail; drop the `getScanPlanProvider` + injection → the wiring test fails; the byte-identical + force-jni guards correctly stay green. + +## Result + +Full paimon module **303 tests, 0 failures, 1 skipped** (298 + 5), checkstyle 0, import-check 0, clean +rebuild BUILD SUCCESS. Connector-only (no fe-core / SPI / BE change). e2e gated (`enablePaimonTest=false`) +— NOT run. **This was the LAST of the 5 P6 deviation→fixes** (A3 / A2 / B-MC2 / A1 / B-R2-be all done). diff --git a/plan-doc/designs/FIX-C1-MINIO-design.md b/plan-doc/designs/FIX-C1-MINIO-design.md new file mode 100644 index 00000000000000..1126333f8766f7 --- /dev/null +++ b/plan-doc/designs/FIX-C1-MINIO-design.md @@ -0,0 +1,350 @@ +# Problem + +P6 clean-room finding **C1** (MAJOR; BLOCKER if `minio.*` keying is supported in deployment; +classification: regression). + +A `CREATE CATALOG ... PROPERTIES("type"="paimon", "minio.endpoint"=..., "minio.access_key"=..., +"minio.secret_key"=...)` keyed **purely** with `minio.*` property names no longer binds any storage +backend on this branch. Paimon read fails with `no file io for scheme s3`, and BE receives no +`location.AWS_*` credentials. + +Legacy `MinioProperties` (`fe/fe-core/.../datasource/property/storage/MinioProperties.java`) +recognized: +`minio.endpoint / minio.region / minio.access_key / minio.secret_key / minio.session_token / +minio.connection.maximum / minio.connection.request.timeout / minio.connection.timeout / +minio.use_path_style / minio.force_parsing_by_standard_uri` +with region default `us-east-1` and tuning defaults `100 / 10000 / 10000`, and produced S3A Hadoop +config + AWS_* backend config via `AbstractS3CompatibleProperties`. + +The new path sources storage **exclusively** from the typed `fe-filesystem` SPI. There is **no MinIO +provider**. Registered providers (ServiceLoader, verified via the eight +`META-INF/services/org.apache.doris.filesystem.spi.FileSystemProvider` files): +`OSS, Local, HDFS, COS, S3, Broker, Azure, OBS`. None recognizes a `minio.*` key. + +# Root Cause + +Two facts in the typed path combine to drop a pure-`minio.*` catalog: + +1. **`S3FileSystemProvider.supports()` never matches `minio.*`.** + `fe/fe-filesystem/fe-filesystem-s3/.../S3FileSystemProvider.java:45-73`. `supports()` checks + `ACCESS_KEY_NAMES` / `ENDPOINT_NAMES` / `REGION_NAMES` / `ROLE_ARN_NAMES` / + `CREDENTIALS_PROVIDER_TYPE_NAMES`. None of these arrays contain any `minio.*` key. The + cloud-specific providers (OSS/COS/OBS) only match their endpoint domains (`aliyuncs.com` / + `myqcloud.com` / `myhuaweicloud.com`) or explicit `provider`/`_STORAGE_TYPE_`. A bare MinIO + endpoint (e.g. `http://127.0.0.1:9000`) matches none. + +2. **No match → empty list, no throw (in `bindAll`, the path catalogs use).** + `bindAllStorageProperties` (`fe/fe-core/.../fs/FileSystemFactory.java:119-142`) and the production + `FileSystemPluginManager.bindAll` (`fe/fe-core/.../fs/FileSystemPluginManager.java:158-172`) + iterate providers, `continue` on `!supports`, and return the accumulated list — empty when nothing + matched. (`createFileSystem`/`getFileSystem` *do* throw on no-match, but catalog binding goes + through `bindAll`, which does not.) + +Empty `StorageProperties` list ⇒ paimon `PaimonScanPlanProvider` +(`fe/fe-connector/fe-connector-paimon/.../PaimonScanPlanProvider.java:617-624`) iterates an empty +`ctx.getStorageProperties()` ⇒ no `location.AWS_*` for BE; and the FE Hadoop-config map is never +populated with `fs.s3.impl` ⇒ paimon SDK has no FileIO for scheme `s3`. + +**Per-key loss for a pure-`minio.*` catalog:** endpoint, region (default `us-east-1`), access_key, +secret_key, session_token, connection.maximum (100), connection.request.timeout (10000), +connection.timeout (10000), use_path_style (false), force_parsing_by_standard_uri (false). + +# Design + +## Decision: alias `minio.*` into the shared S3 provider/properties — NOT a dedicated provider. + +**Recommendation: aliasing (the review's preferred option), with one caveat made explicit and +resolved below.** Both FE Hadoop config and BE creds for MinIO are byte-identical to plain S3A +(legacy `MinioProperties` had *zero* MinIO-specific `fs.*`/`AWS_*` keys — it inherited everything +from `AbstractS3CompatibleProperties`, exactly what `S3FileSystemProperties` already emits). MinIO is +literally "S3 with a custom endpoint." The only deltas are (a) the alias key prefix and (b) three +tuning defaults + the region default. Precedent: `CosFileSystemProvider`/`CosFileSystemProperties` +is a *dedicated* provider only because COS emits genuinely COS-specific Hadoop keys +(`fs.cosn.*`, `fs.cos.impl`) and uses a Tencent native SDK in `CosObjStorage`. MinIO emits **none** — +a dedicated provider would be a near-empty clone of S3, pure duplication for no behavioral gain. + +**The one caveat — differing tuning defaults — and why it does not block aliasing.** +`ConnectorProperty` defaults are *field-level*, applied whenever no alias for that field is present +in the raw map (`ConnectorPropertiesUtils.bindConnectorProperties` only `field.set`s when a name +matched). They cannot be conditionalized on *which* alias matched. So I cannot make +`maxConnections` default to `50` for an `s3.*` catalog and `100` for a `minio.*` catalog purely by +adding aliases. Resolution: **add `minio.*` as aliases on the existing tuning fields and accept the +S3 defaults (50/3000/1000) for a `minio.*` catalog that omits the tuning keys.** Justification: + +- The tuning values are connection-pool/timeout knobs; both sets are functional against MinIO. The + legacy MinIO values (100/10000/10000) were never documented as required for correctness — they are + a historical default, not a contract. +- A `minio.*` catalog that *explicitly* sets `minio.connection.maximum` etc. is honored exactly + (the alias binds the value). Only the *unset* case differs, and only in pool size / timeouts. +- Conditionalizing the default would require post-bind logic ("if a `minio.*` alias matched and the + tuning key was absent, override to 100/10000/10000") — added complexity in shared code, touching + the s3 hot path, to preserve a non-contractual default. Not worth the blast-radius risk. + +This is the single intentional, documented behavioral deviation from legacy MinIO. It is called out +in Risk Analysis and Open Questions for the main agent to ratify. **Region default `us-east-1` IS +preserved** — `S3FileSystemProperties.normalizeForLegacyS3Compatibility()` already derives +`region = DEFAULT_REGION ("us-east-1")` when an endpoint is set but region is blank +(`S3FileSystemProperties.java:360-362`), exactly matching legacy MinIO's `region="us-east-1"` +default behavior for the common endpoint-only case. (Field-level default of legacy is `us-east-1` +unconditionally; the SPI achieves the same effective value for endpoint-only configs and for +region-only/AWS-endpoint configs derives the real region — strictly better.) + +## Ordering invariant (load-bearing for s3.* byte-parity) + +`ConnectorPropertiesUtils.getMatchedPropertyName` (`ConnectorPropertiesUtils.java:96-108`) returns +the **first** name in the annotation's `names()` array that is present in the map. Therefore **all +`minio.*` aliases MUST be appended at the END of each field's existing `names()` list.** With +`minio.*` last, an `s3.*`-keyed (or `AWS_*`-keyed) catalog binds exactly as today even if it also +happened to carry a `minio.*` key — `s3.endpoint` outranks `minio.endpoint`. This is the mechanical +guarantee that the canonical `s3.*` path is byte-for-byte unchanged. + +# Implementation Plan + +Two files change. No new module, no new provider, no `META-INF/services` change. + +## File 1 — `fe/fe-filesystem/fe-filesystem-s3/src/main/java/org/apache/doris/filesystem/s3/S3FileSystemProperties.java` + +Append `minio.*` aliases to the END of each field's `names()`. (Excerpts show current → proposed for +the affected fields only; nothing else in the file changes.) + +`:86-90` endpoint +```java +@ConnectorProperty(names = {ENDPOINT, "AWS_ENDPOINT", "endpoint", "ENDPOINT", "aws.endpoint", + "glue.endpoint", "aws.glue.endpoint", "minio.endpoint"}, + ... +``` + +`:93-94` region (note `isRegionField = true` retained) +```java +@ConnectorProperty(names = {REGION, "AWS_REGION", "region", "REGION", "aws.region", "glue.region", + "aws.glue.region", "iceberg.rest.signing-region", "rest.signing-region", "client.region", + "minio.region"}, + ... +``` + +`:101-104` accessKey +```java +@ConnectorProperty(names = {ACCESS_KEY, "AWS_ACCESS_KEY", "access_key", "ACCESS_KEY", + "glue.access_key", "aws.glue.access-key", + "client.credentials-provider.glue.access_key", "iceberg.rest.access-key-id", + "s3.access-key-id", "minio.access_key"}, + ... +``` + +`:110-113` secretKey +```java +@ConnectorProperty(names = {SECRET_KEY, "AWS_SECRET_KEY", "secret_key", "SECRET_KEY", + "glue.secret_key", "aws.glue.secret-key", + "client.credentials-provider.glue.secret_key", "iceberg.rest.secret-access-key", + "s3.secret-access-key", "minio.secret_key"}, + ... +``` + +`:120-121` sessionToken +```java +@ConnectorProperty(names = {SESSION_TOKEN, "AWS_TOKEN", "session_token", + "s3.session-token", "iceberg.rest.session-token", "minio.session_token"}, + ... +``` + +`:152` maxConnections +```java +@ConnectorProperty(names = {MAX_CONNECTIONS, "AWS_MAX_CONNECTIONS", "minio.connection.maximum"}, + ... +``` + +`:158` requestTimeoutMs +```java +@ConnectorProperty(names = {REQUEST_TIMEOUT_MS, "AWS_REQUEST_TIMEOUT_MS", + "minio.connection.request.timeout"}, + ... +``` + +`:164` connectionTimeoutMs +```java +@ConnectorProperty(names = {CONNECTION_TIMEOUT_MS, "AWS_CONNECTION_TIMEOUT_MS", + "minio.connection.timeout"}, + ... +``` + +`:170` usePathStyle +```java +@ConnectorProperty(names = {USE_PATH_STYLE, "s3.path-style-access", "minio.use_path_style"}, + ... +``` + +**`force_parsing_by_standard_uri`:** `S3FileSystemProperties` has **no** such field today (the legacy +key only affected URI parsing in `S3PropertyUtils`, a fe-core concern; the SPI normalizes URIs via +`DefaultConnectorContext`). Do **not** add a new field — out of scope for C1 (no read-path use in the +typed model). Note as Open Question if URI parity for path-style MinIO is later reported. + +## File 2 — `fe/fe-filesystem/fe-filesystem-s3/src/main/java/org/apache/doris/filesystem/s3/S3FileSystemProvider.java` + +Append `"minio.*"` to the three detection arrays so a pure-`minio.*` map satisfies +`hasCredential && hasLocation` (`:64-72`). Add `minio.endpoint`/`minio.region` to location arrays and +`minio.access_key` to the credential array. + +`:45-49` ACCESS_KEY_NAMES +```java +private static final String[] ACCESS_KEY_NAMES = { + S3FileSystemProperties.ACCESS_KEY, "AWS_ACCESS_KEY", "access_key", "ACCESS_KEY", + "glue.access_key", "aws.glue.access-key", + "client.credentials-provider.glue.access_key", "iceberg.rest.access-key-id", + "s3.access-key-id", "minio.access_key"}; +``` + +`:50-52` ENDPOINT_NAMES +```java +private static final String[] ENDPOINT_NAMES = { + S3FileSystemProperties.ENDPOINT, "AWS_ENDPOINT", "endpoint", "ENDPOINT", "aws.endpoint", + "glue.endpoint", "aws.glue.endpoint", "minio.endpoint"}; +``` + +`:53-55` REGION_NAMES +```java +private static final String[] REGION_NAMES = { + S3FileSystemProperties.REGION, "AWS_REGION", "region", "REGION", "aws.region", "glue.region", + "aws.glue.region", "iceberg.rest.signing-region", "rest.signing-region", "client.region", + "minio.region"}; +``` + +This keeps `supports()` true for `minio.endpoint + minio.access_key + minio.secret_key` +(`hasCredential` via `minio.access_key`, `hasLocation` via `minio.endpoint`). Validation +(`requireTogether(accessKey, secretKey)`) still fires correctly because the binding resolves the +`minio.*` aliases into the typed fields before `validate()`. + +# Risk Analysis + +## Cross-connector blast radius + +Consumers of `S3FileSystemProperties`/`S3FileSystemProvider` are **every** connector that reaches the +typed S3 path: iceberg, hive, hudi, paimon, plus fe-core load/backup/snapshot flows — they all go +through `bindAllStorageProperties` → `supports()` → `bind()`. The change touches only the alias +**lists** and the detection **arrays**; the emitted FE Hadoop config (`toHadoopConfigurationMap`, +`:285-316`) and BE map (`toFileSystemKv`, `:245-262`) code is **unchanged**. + +## s3.* unchanged — proof + +1. **Binding precedence**: `getMatchedPropertyName` returns the first present alias + (`ConnectorPropertiesUtils.java:101-105`). New `minio.*` aliases are appended **last**, so for any + map containing an `s3.*`/`AWS_*`/legacy-bare key, that key still matches first → identical bound + field values → identical `toFileSystemKv`/`toHadoopConfigurationMap` output. +2. **No default changed**: field defaults (`DEFAULT_MAX_CONNECTIONS="50"`, etc.) are untouched, so an + `s3.*` catalog that omits tuning keys gets `50/3000/1000` exactly as before. Regression-guarded by + the existing `toMaps_emitS3TuningDefaultsWhenNotConfigured` test (S3FileSystemPropertiesTest.java + :262-282), which already asserts the literal `50/3000/1000` and will fail if a default drifts. +3. **`supports()` for s3.* maps**: adding entries to the arrays can only make `supports()` return + true for *more* inputs; it cannot turn a previously-true s3 map false. Existing + `S3FileSystemProviderTest` cases remain green. + +## Routing / disambiguation + +`bindAll` collects **all** matching providers; `createFileSystem` uses the **first**. Could +`minio.*` cause a wrong/extra provider to match? + +- **OSS/COS/OBS** match only on `aliyuncs.com` / `myqcloud.com` / `myhuaweicloud.com` endpoint + substrings or explicit `provider`/`_STORAGE_TYPE_`/`fs..support`. A MinIO endpoint (e.g. + `http://127.0.0.1:9000`) contains none of these ⇒ they do **not** match a pure-`minio.*` map. + (Verified: OssFileSystemProvider:48-55, CosFileSystemProvider:48-55, ObsFileSystemProvider:48-55.) + Note: these providers also read `s3.endpoint`/`AWS_ENDPOINT` aliases but still gate on the cloud + domain substring — a `minio.endpoint` value pointing at, say, `aliyuncs.com` would (correctly) be + treated as OSS, but that is operator misconfiguration, not a MinIO catalog. +- **Azure / HDFS / Local / Broker** key on `azure.*`/account keys, `dfs.*`/`hadoop.*`, + `file://`/`_STORAGE_TYPE_=LOCAL`, `_STORAGE_TYPE_=BROKER` respectively — disjoint from `minio.*`. +- **Double-bind for legitimately multi-backend catalogs** (object store + HDFS) is the *intended* + `bindAll` behavior and is unaffected: a `minio.* + dfs.*` catalog binds S3 (MinIO) + HDFS, exactly + the legacy multi-backend semantics. + +Conclusion: a pure-`minio.*` map matches **only** `S3FileSystemProvider`. No collision, no wrong +provider, no ambiguous double-bind. + +## Differing tuning defaults + +S3 defaults `50/3000/1000` vs legacy MinIO `100/10000/10000` (confirmed: +`S3Properties.java:129/136/143` = 50/3000/1000; `MinioProperties.java:75/84/93` = 100/10000/10000). +With aliasing, a `minio.*` catalog that omits tuning keys gets the **S3** defaults. This is the one +intentional deviation (see Design). It changes only connection-pool size / timeouts, never +correctness or credentials. Explicitly-set `minio.connection.*` values are honored. Documented in +Open Questions for ratification. A dedicated provider would preserve the legacy defaults but at the +cost of duplicating the entire S3 properties class for zero behavioral difference elsewhere — judged +not worth it. + +# Test Plan + +## Unit Tests + +All in `fe/fe-filesystem/fe-filesystem-s3/src/test/java/org/apache/doris/filesystem/s3/`. + +### `S3FileSystemProviderTest` — add + +- `supports_acceptsPureMinioKeyedConfiguration`: + map `{minio.endpoint=http://127.0.0.1:9000, minio.access_key=ak, minio.secret_key=sk}` ⇒ + `assertTrue(provider.supports(map))`. (This is the exact C1 reproduction; RED before the + `ENDPOINT_NAMES`/`ACCESS_KEY_NAMES` edit.) +- `supports_acceptsMinioEndpointWithRegionOnly` (optional): `{minio.endpoint=..., minio.region=..., + minio.access_key=ak, minio.secret_key=sk}` ⇒ true. + +### `S3FileSystemProperties` MinIO binding test — new test class `MinioAliasS3FileSystemPropertiesTest` (or add to `S3FileSystemPropertiesTest`) + +- `of_bindsPureMinioAliases`: input all `minio.*` keys (endpoint, access_key, secret_key, + session_token, connection.maximum=200, connection.request.timeout=20000, connection.timeout=20000, + use_path_style=true). Assert typed getters: `getEndpoint`/`getAccessKey`/`getSecretKey`/ + `getSessionToken`/`getMaxConnections`("200")/`getRequestTimeoutMs`("20000")/ + `getConnectionTimeoutMs`("20000")/`getUsePathStyle`("true"). +- `of_minioEndpointOnly_appliesUsEast1RegionDefault`: + `{minio.endpoint=http://127.0.0.1:9000, minio.access_key=ak, minio.secret_key=sk}` ⇒ + `assertEquals("us-east-1", props.getRegion())` (parity with legacy MinIO region default). +- `toHadoopConfigurationMap_forMinio_emitsS3aImplAndEndpoint`: from the endpoint-only map, assert + `fs.s3.impl == org.apache.hadoop.fs.s3a.S3AFileSystem`, `fs.s3a.impl == ...S3AFileSystem`, + `fs.s3a.endpoint == http://127.0.0.1:9000`, `fs.s3a.endpoint.region == us-east-1`, + `fs.s3a.access.key == ak`, `fs.s3a.secret.key == sk`, + `fs.s3a.path.style.access == `. (Covers FE-side `no file io for scheme s3` fix.) +- `toFileSystemKv_forMinio_emitsAwsBackendKeys`: assert `AWS_ENDPOINT`, `AWS_REGION` (`us-east-1`), + `AWS_ACCESS_KEY`, `AWS_SECRET_KEY` present and correct. (Covers BE `location.AWS_*` fix — the + values BE consumes via `PaimonScanPlanProvider:617-624`.) +- `of_minioOmittingTuning_appliesS3DefaultsNotLegacyMinioDefaults`: endpoint-only minio map ⇒ assert + `getMaxConnections()=="50"`, `getRequestTimeoutMs()=="3000"`, `getConnectionTimeoutMs()=="1000"`. + This **encodes the intentional deviation** so it cannot regress silently and documents WHY (legacy + was 100/10000/10000; this asserts the deliberate S3-default behavior). +- `of_s3KeyOutranksMinioKey` (precedence guard): map carrying BOTH `s3.endpoint=https://A` and + `minio.endpoint=http://B`, plus s3 ak/sk ⇒ `assertEquals("https://A", props.getEndpoint())`. This + is the byte-parity regression guard for the s3 path (RED if minio aliases were prepended instead of + appended). + +### Existing regression guard (no change, must stay green) + +`S3FileSystemPropertiesTest.toMaps_emitS3TuningDefaultsWhenNotConfigured` (:262-282) — proves the +s3.* default path is untouched. Re-run after the edit. + +## E2E Tests (gated — do NOT run here) + +- `regression-test/suites/external_table_p0/paimon/` paimon docker suite, gated by + `enablePaimonTest=true` in `regression-conf.groovy`. A MinIO-warehouse paimon catalog created with + `minio.*` properties should: (a) `SHOW DATABASES`/`SHOW TABLES` succeed (FE FileIO binds), and + (b) `SELECT *` succeed (BE receives `location.AWS_*`). The pre-fix symptom is + `no file io for scheme s3`. The fe-filesystem S3 module is exercised by every object-store external + suite (iceberg/hive/hudi on S3/MinIO), so the broader external p0 set is the byte-parity safety net + for the shared change. + +# Open Questions + +1. **Tuning-default deviation ratification.** — **RESOLVED 2026-06-18: PRESERVE legacy defaults.** + The design's original "accept the deviation" recommendation rested on the claim that field-level + defaults can't be conditionalized on which alias matched. An adversarial design red-team + (`wf`/agent `adfda124…`) **refuted** that: the design's own cited `normalizeForLegacyS3Compatibility()` + is a post-bind hook that already conditionalizes a field (region→us-east-1). Preserving the legacy + MinIO tuning defaults (100/10000/10000) there is ~6 lines, gated on a `minio.*` raw key being + present, so it never touches the canonical `s3.*` hot path. The review report (authoritative spec) + explicitly required "preserve MinIO defaults: region us-east-1, tuning 100/10000/10000", so strict + parity is the correct call and avoids a sign-off-requiring deviation. **Implemented** as + `applyLegacyMinioTuningDefaults()` (gates on raw-key PRESENCE, not field-value-equals-default, so an + explicit `minio.connection.maximum=50` is honored). Pinned by + `of_minioOmittingTuning_appliesLegacyMinioTuningDefaults`. +2. **`minio.force_parsing_by_standard_uri` / path-style URI parsing.** Not modeled in the typed S3 + path (URI normalization moved to `DefaultConnectorContext`). C1 lists it as a lost key but no + typed read-path consumes it. Confirm no path-style MinIO URI-parsing regression is in scope; if it + is, that is a separate fix in the URI-normalization layer, not these two files. +3. **Should `minio.use_path_style` map default to `true`?** Legacy default was `false` (matching S3); + the SPI keeps `false`. Many MinIO deployments need path-style addressing, but legacy also defaulted + to `false`, so keeping `false` is strict parity. Flagging only because it is a common MinIO + operational footgun, not a regression. diff --git a/plan-doc/designs/FIX-C1-MINIO-summary.md b/plan-doc/designs/FIX-C1-MINIO-summary.md new file mode 100644 index 00000000000000..d457cafe9b2017 --- /dev/null +++ b/plan-doc/designs/FIX-C1-MINIO-summary.md @@ -0,0 +1,57 @@ +# Summary — FIX-C1-MINIO (P6 finding C1) + +## Problem +A catalog keyed purely with legacy `minio.*` storage properties (`minio.endpoint` / `minio.access_key` +/ `minio.secret_key` / …) was **unbindable** on the SPI branch. The typed `fe-filesystem` storage SPI +has no MinIO provider, and `S3FileSystemProvider.supports()` / `S3FileSystemProperties` recognized no +`minio.*` key → `bindAllStorageProperties` returned empty (no throw) → empty Hadoop map (no +`fs.s3.impl`) on FE catalog-create ("no file io for scheme s3") and empty `location.AWS_*` on BE +(native paimon read failed). MAJOR (BLOCKER if a deployment keys catalogs with `minio.*`). + +## Root Cause +The 2026-06-14 `applyCanonicalMinioConfig` work (in the old `PaimonCatalogFactory.applyStorageConfig` +path) was obsoleted by the move to the typed storage SPI and never carried into this branch. Legacy +`MinioProperties` was just "S3 with a custom endpoint" — it inherited pure S3A config from +`AbstractS3CompatibleProperties` and emitted **zero** MinIO-specific `fs.*`/`AWS_*` keys; only its +alias prefix (`minio.*`), its region default (`us-east-1`), and its tuning defaults +(`100/10000/10000`, vs S3's `50/3000/1000`) differed. + +## Fix +Alias `minio.*` into the **shared** `fe-filesystem-s3` module (no dedicated provider — that would be a +near-empty S3 clone): +- `S3FileSystemProperties.java`: appended `minio.*` aliases (endpoint/region/access_key/secret_key/ + session_token/connection.maximum/connection.request.timeout/connection.timeout/use_path_style) at + the **end** of each field's `names()`. First-alias-wins (`ConnectorPropertiesUtils.getMatchedPropertyName`) + → canonical `s3.*`/`AWS_*` keys still outrank → `s3.*` path byte-for-byte unchanged. +- `S3FileSystemProvider.java`: added `minio.access_key`/`minio.endpoint`/`minio.region` to the + detection arrays so a pure-`minio.*` map satisfies `supports()` (`hasCredential && hasLocation`). +- **Tuning-default parity (key decision):** added gated `applyLegacyMinioTuningDefaults()` in the + post-bind `normalizeForLegacyS3Compatibility()` hook. When a `minio.*` raw key is present and a + tuning knob is unset under **any** alias, restore the legacy MinIO default (100/10000/10000). + Gated on raw-key PRESENCE (not field-value-equals-default), so an explicit `minio.connection.maximum=50` + is honored; gated on `minio.*` presence, so the canonical `s3.*` path is untouched. Region default + `us-east-1` was already preserved by the existing endpoint-only normalize branch. + +Decision rationale: the design's first pass proposed *accepting* the tuning deviation; an adversarial +design red-team refuted the "can't conditionalize defaults" premise (the region-default precedent is +the same post-bind mechanism), and the review spec explicitly required preserving the MinIO tuning +defaults → PRESERVE. (See `FIX-C1-MINIO-design.md` §Open Questions.) + +## Tests +`fe-filesystem-s3` (FE UT): +- `S3FileSystemProviderTest.supports_acceptsPureMinioKeyedConfiguration` — pure `minio.*` map binds. +- `S3FileSystemPropertiesTest.of_bindsPureMinioAliasesAndHonorsExplicitTuning` — all aliases bind; + explicit tuning honored. +- `…of_minioEndpointOnly_appliesUsEast1RegionDefaultAndEmitsS3aAndAwsKeys` — region default + + FE `fs.s3.impl`/`fs.s3a.*` + BE `AWS_*`. +- `…of_minioOmittingTuning_appliesLegacyMinioTuningDefaults` — pins 100/10000/10000 preservation. +- `…of_s3KeyOutranksMinioKeyForSameField` — precedence/byte-parity guard. +- Existing `toMaps_emitS3TuningDefaultsWhenNotConfigured` (pure `s3.*` → 50/3000/1000) still green. + +All fail on revert (verified by adversarial impl-verification). E2E (`enablePaimonTest`-gated MinIO +warehouse paimon catalog with `minio.*` props) NOT run here. + +## Result +`mvn -pl :fe-filesystem-s3 -am test -Dtest=S3FileSystemPropertiesTest,S3FileSystemProviderTest`: +**28 tests run, 0 failures, 0 errors** (19 properties + 9 provider), BUILD SUCCESS, checkstyle clean +(runs in validate). Docker e2e NOT run (CI-gated). diff --git a/plan-doc/designs/FIX-C2-HDFS-XML-design.md b/plan-doc/designs/FIX-C2-HDFS-XML-design.md new file mode 100644 index 00000000000000..ab934b8cef7a03 --- /dev/null +++ b/plan-doc/designs/FIX-C2-HDFS-XML-design.md @@ -0,0 +1,241 @@ +# Problem + +P6 clean-room finding **C2** (MAJOR; classification: missing-port / regression on a live read path). + +A paimon catalog whose HDFS HA / connection topology lives **only** in a `hadoop.config.resources` +XML file (e.g. `hdfs-site.xml` declaring `dfs.nameservices` + per-nameservice namenodes + failover +proxy provider) **cannot resolve its nameservice** when created through the SPI plugin path for the +`filesystem` / `jdbc` flavors. At first metadata access the paimon SDK opens an HDFS `FileSystem` +against a Configuration that never parsed the XML, so an HA URI like `hdfs://ns1/warehouse` fails to +resolve `ns1`. + +Scope (wave-2 confirmed): the gap is strictly the **FE-side catalog-create Configuration**. The +**BE/scan path is NOT affected** — `PaimonScanPlanProvider.java:619-620` consumes +`sp.toBackendProperties().toMap()`, which for HDFS returns the full backend map *including* the +XML-loaded keys. Wave 2 **refuted** the wave-1 kerberos-by-alias sub-claim (the per-FS Configuration +auth marker is not load-bearing; JVM-global `UserGroupInformation.setConfiguration` from the +authenticator's first `doAs` governs SASL). **This fix addresses the XML-resource gap ONLY.** + +# Root Cause + +The FE catalog-create Configuration for `filesystem`/`jdbc` is built by +`PaimonCatalogFactory.buildHadoopConfiguration(props, storageHadoopConfig)` → +`applyStorageConfig(...)` (`PaimonCatalogFactory.java:247-298`), from two sources: + +1. `storageHadoopConfig` — assembled by `PaimonConnector.buildStorageHadoopConfig()` + (`PaimonConnector.java:222-228`) by iterating `ctx.getStorageProperties()` and merging each + `sp.toHadoopProperties().toHadoopConfigurationMap()`. +2. The connector-local `paimon.*` re-key + the **raw `fs.`/`dfs.`/`hadoop.` passthrough** + (`applyStorageConfig`, `PaimonCatalogFactory.java:287-297`), which copies those keys **verbatim**. + +For an HDFS-warehouse catalog: + +- **`HdfsFileSystemProperties` deliberately does NOT implement `HadoopStorageProperties`** (its class + "Scope note" javadoc, `HdfsFileSystemProperties.java:53-58`), so `sp.toHadoopProperties()` returns + `Optional.empty()`. HDFS therefore contributes **nothing** to `storageHadoopConfig`. +- The raw passthrough copies the **`hadoop.config.resources` key itself** verbatim, but a Hadoop + `Configuration` does **not** treat that key as a resource directive — it is a Doris-specific key. + **The XML contents are never loaded.** + +So inline `dfs.*` keys passed directly in the catalog properties still work (they ride the raw +passthrough), but an HA topology that lives only inside the referenced XML file is silently dropped. + +## Parity baseline + +Legacy `HdfsProperties.initNormalizeAndCheckProps()` (`HdfsProperties.java:126-138`) built the FE +Hadoop `Configuration` **directly from `backendConfigProperties`** (`new Configuration(); +backendConfigProperties.forEach(set)`), and the per-flavor builders overlaid it for filesystem/jdbc +**and hms** (`PaimonFileSystemMetaStoreProperties:44`, `PaimonJdbcMetaStoreProperties:117`, +`PaimonHMSMetaStoreProperties.buildHiveConfiguration:80-84` — all iterate all storage props and +`conf.addResource(sp.getHadoopStorageConfig())`). Only DLF filtered to OSS/OSS_HDFS +(`PaimonAliyunDLFMetaStoreProperties:90-96`). The typed `HdfsFileSystemProperties.backendConfigProperties` +(`:198-222`, exposed via `toMap()`) is a faithful line-for-line port of legacy +`initBackendConfigProperties()`, already loaded at bind time (the BE path uses it today). The only +thing missing is a way to surface the XML/HA/auth keys to the FE Hadoop-config pipeline. + +# Design + +## Decision: `HdfsFileSystemProperties implements HadoopStorageProperties`, returning a **defaults-free** Hadoop map. + +`toHadoopProperties()` returns `Optional.of(this)`; `toHadoopConfigurationMap()` returns the XML-loaded +keys + user `hadoop./dfs./fs./juicefs.` overrides + synthesized keys (`fs.defaultFS`, ipc fallback, +`hdfs.security.authentication`, kerberos, `hadoop.username`) — **WITHOUT** Hadoop's built-in framework +defaults. The connector code does not change (the existing `buildStorageHadoopConfig` loop already +consumes `toHadoopProperties()`). + +### Why defaults-free (the red-team's decisive finding) + +`HdfsConfigFileLoader.loadConfigMap` creates a `new Configuration()` (which loads `core-default.xml`) +and iterates **every** entry (`:88-101`). So when `hadoop.config.resources` is set, +`backendConfigProperties` carries ~323 keys, of which **62 are `fs.s3a.*` Hadoop defaults** +(`fs.s3a.path.style.access=false`, `fs.s3a.connection.maximum=96`, `fs.s3a.aws.credentials.provider=`, +…). `S3FileSystemProperties.toHadoopConfigurationMap()` emits those exact keys **unconditionally** +(`:321-324`: `connection.maximum` / `path.style.access`). `buildStorageHadoopConfig` does +`merged.putAll(...)` per provider (`PaimonConnector.java:223-226`), so for a **multi-backend** catalog +(object store + HDFS-with-XML) merged last-write-wins: if HDFS merges after S3, its +`fs.s3a.path.style.access=false` **clobbers** the S3/MinIO provider's tuned `true` → MinIO reads break. + +This is a **regression vs the current branch** (today HDFS contributes nothing to `storageHadoopConfig`, +so the object-store tuning is intact) — independent of any legacy `addResource`-vs-`set` argument. +Reachable by a Kerberized HMS paimon catalog (`hadoop.kerberos.principal` triggers +`HdfsFileSystemProvider.supports()`) carrying `hadoop.config.resources` + MinIO table data, or any +`dfs.nameservices`/`hdfs://`-scheme catalog co-bound with an object store. Narrow, but a silent +data-access failure. + +**Emitting the framework defaults serves no purpose** for the FE config — the base `new Configuration()` +in `buildHadoopConfiguration` (`PaimonCatalogFactory.java:249`) already supplies every Hadoop default. +The HDFS map only needs to contribute its *own* keys (XML + HA + auth). Dropping the defaults: +- removes the clobber entirely (the HDFS map no longer carries `fs.s3a.*`); +- is unambiguously safe vs legacy — whether legacy's `addResource` clobbered (then this is strictly + better) or preserved (then this matches), the result is correct either way; +- for a single-backend HDFS catalog (the common C2 target) yields the **identical final Configuration** + (base defaults + XML/synthesized keys). + +Implemented with `new Configuration(false)` (no default resources) when building the FE map. + +### BE map stays byte-identical + +`toMap()` (BE) keeps returning the **defaults-laden** `backendConfigProperties` — byte-parity with +legacy `getBackendConfigProperties()` is preserved (the BE builds `THdfsParams` from specific keys and +ignores the `fs.s3a.*` noise; the historical FE↔BE divergence hazards argue for not perturbing the BE +map). The FE and BE maps then differ **only** in the inert Hadoop framework defaults; every meaningful +HDFS key (`fs.defaultFS`, `dfs.*`, `hadoop.security.*`, `ipc.*`, the XML's own keys) is identical in +both. For HDFS, the FE Hadoop config and BE map carry the same *meaningful* set — the legacy invariant. + +## Cross-flavor reach + +`buildStorageHadoopConfig()` is computed once for all flavors. Per-flavor parity: + +| flavor | legacy HDFS overlay? | after fix | verdict | +|---|---|---|---| +| filesystem | yes | HDFS map → `buildHadoopConfiguration` | **parity — the C2 fix** | +| jdbc | yes | HDFS map → `buildHadoopConfiguration` | **parity** | +| hms | yes (`buildHiveConfiguration:80-84`) | HDFS map → HiveConf | **parity (bonus: closes the gap for HMS)** | +| dlf | no (OSS/OSS_HDFS only) | full `storageHadoopConfig` overlaid (`DlfMetaStorePropertiesImpl.toDlfCatalogConf:141`) | **deviation only for a DLF catalog that also binds HDFS — see Risk** | +| rest | n/a (Options-only) | `storageHadoopConfig` unused (`PaimonConnector:147-150`) | unaffected | + +Pure object-store / pure-S3 catalogs bind **no** `HdfsFileSystemProperties` +(`HdfsFileSystemProvider.supports()` needs `_STORAGE_TYPE_=HDFS` / an `hdfs|viewfs|ofs|jfs|oss`-scheme +`fs.defaultFS`/`HDFS_URI` / `dfs.nameservices` / `hadoop.kerberos.principal` — none present on an +`s3.*`/`AWS_*`/`oss.*` map) → byte-unchanged. + +# Implementation Plan + +## File 1 — `fe-filesystem-hdfs/.../HdfsConfigFileLoader.java` + +Add a `loadHadoopDefaults` overload; keep the existing 1-arg method delegating with `true` (BE +behavior unchanged): +```java +public static Map loadConfigMap(String resourcesPath) { + return loadConfigMap(resourcesPath, true); +} +public static Map loadConfigMap(String resourcesPath, boolean loadHadoopDefaults) { + ... + Configuration conf = new Configuration(loadHadoopDefaults); // false => only the named XML, no core-default.xml + ... +} +``` +(`loadConfigMap` has exactly one caller; `HdfsConfigBuilder` is a separate runtime path that does not +call it, so this is isolated.) + +## File 2 — `fe-filesystem-hdfs/.../HdfsFileSystemProperties.java` + +- `import ...HadoopStorageProperties;` + `implements FileSystemProperties, BackendStorageProperties, HadoopStorageProperties`. +- Refactor `buildBackendConfigProperties(origProps)` → `buildConfigProperties(origProps, boolean loadHadoopDefaults)` + (the only internal change: `loadConfigMap(hadoopConfigResources, loadHadoopDefaults)`). +- Constructor builds **two** immutable maps from the same logic: + - `backendConfigProperties = unmodifiable(buildConfigProperties(raw, true))` — BE (unchanged). + - `hadoopConfigProperties = unmodifiable(buildConfigProperties(raw, false))` — FE Hadoop (no defaults). +- `toHadoopProperties()` → `Optional.of(this)`; `toHadoopConfigurationMap()` → `return hadoopConfigProperties;`. +- Rewrite the class "Scope note" javadoc: it now implements `HadoopStorageProperties` to surface the + XML/HA/auth keys to the FE catalog Hadoop config (C2); the FE map is defaults-free to avoid clobbering + co-bound object-store keys, while `toMap()` stays defaults-laden for BE byte-parity; the real + `UGI.doAs` still lives in fe-core/ctx and this class builds no authenticator (K1). + +(`validate()` keeps calling `checkHaConfig(backendConfigProperties)` — the XML's HA keys are present in +both maps, so HA validation is unchanged.) + +## File 3 — stale comment-only updates (no logic change) + +These comments assert the now-false invariant "HDFS contributes nothing to `storageHadoopConfig` / +`toHadoopProperties`"; my change inverts it, so they must be corrected to avoid misleading the next +reader: `PaimonConnector.java:136-137` and `:219-220`; `PaimonCatalogFactory.java:240-242` and +`:281-283`; `MetaStoreParseUtils.java` HDFS-absent note. (REST remains correctly unaffected.) + +## Tests + +`HdfsFileSystemPropertiesTest` (fe-filesystem-hdfs): +1. Flip `classifiersMatchHdfs:203` `toHadoopProperties().isEmpty()` → `.isPresent()` + fix the comment. +2. `xmlKeysReachHadoopConfigMap` (new, mirrors `xmlResourcesAreLoadedIntoBackendMap:207-230`): the XML's + `dfs.custom.key` is present in `toHadoopProperties().get().toHadoopConfigurationMap()`. **C2 regression + pin** (RED pre-fix: `toHadoopProperties()` empty → `.get()` throws). +3. `hadoopConfigMapExcludesFrameworkDefaultsButBeMapKeepsThem` (new — the clobber guard, encodes WHY): + with `hadoop.config.resources` set, `toHadoopConfigurationMap()` does **NOT** contain + `fs.s3a.path.style.access` / `fs.s3a.connection.maximum` (framework defaults excluded), while + `toMap()` (BE) **does** (defaults-laden, BE parity). Pins both the clobber-safety and the FE/BE + asymmetry. (Replaces the tautological `toMap()==toHadoopConfigurationMap()` idea.) +4. `hadoopConfigMapKeepsMeaningfulKeys` (new): `toHadoopConfigurationMap()` still contains the XML key + + `fs.defaultFS` + `hdfs.security.authentication` (defaults-free ≠ empty). + +`PaimonCatalogFactoryTest` (connector) — close the end-to-end leg the red-team flagged: +5. `buildStorageHadoopConfigFoldsInHdfsHadoopMap` (new): a stub `StorageProperties`+`HadoopStorageProperties` + returning `{dfs.custom.key=v}` (a key NOT in the raw props, so it cannot ride the passthrough), placed + in a `RecordingConnectorContext.getStorageProperties()`, flows through + `PaimonConnector.buildStorageHadoopConfig()` → `PaimonCatalogFactory.buildHadoopConfiguration` and + `conf.get("dfs.custom.key")` is non-null. Requires: `RecordingConnectorContext` gains a + `storageProperties` field + `getStorageProperties()` override; `buildStorageHadoopConfig()` becomes + package-private (`// visible for testing`). Combined with the existing + `buildHadoopConfigurationAppliesStorageHadoopConfig`, this proves the full XML-key→Configuration chain. + +## E2E (gated — `enablePaimonTest=true`, NOT run here) + +A `filesystem`-flavor paimon catalog on HA HDFS (`hdfs://ns1/...`) with HA config supplied **only** via +`hadoop.config.resources=hdfs-site.xml` should `SHOW DATABASES`/`SELECT *` succeed. Pre-fix symptom: +nameservice `ns1` unresolved. + +# Risk Analysis + +## Blast radius — only `PaimonConnector` consumes `toHadoopProperties()` +Repo-wide, the only runtime caller of `.toHadoopProperties()` is `PaimonConnector:225` (every other hit +is a declaration/override/javadoc, and `grep 'instanceof HadoopStorageProperties'` = 0). fe-core / +iceberg / hive / hudi use the **separate** legacy `datasource.property.storage` hierarchy, untouched. + +## Multi-backend (object store + HDFS-with-XML) — the clobber, now fixed +The defaults-free FE map carries **no** `fs.s3a.*`, so it cannot overwrite a co-bound object-store +provider's tuned `fs.s3a.*` regardless of merge order. (See Design §Why defaults-free for the empirical +basis: a defaults-laden HDFS map *would* reset MinIO `fs.s3a.path.style.access` true→false.) The +defaults-free map is byte-equivalent to the legacy meaningful set for single-backend and strictly safer +for multi-backend. + +## DLF deviation — accepted +Legacy DLF overlaid only OSS/OSS_HDFS storage; the new `DlfMetaStorePropertiesImpl.toDlfCatalogConf` +overlays the full `storageHadoopConfig` (`:141`). After the fix, a DLF catalog that **also** binds an +`HdfsFileSystemProperties` would get HDFS keys in its DLF HiveConf. Triggers (per +`HdfsFileSystemProvider.supports()`): `dfs.nameservices`, an `hdfs|viewfs|ofs|jfs`-scheme bare +`fs.defaultFS`, `_STORAGE_TYPE_=HDFS`, or `hadoop.kerberos.principal`. A real DLF catalog uses +`oss.*`/`dlf.*` + `oss.hdfs.fs.defaultFS=oss://…` (not a bare `fs.defaultFS`), so this requires a +nonsensical DLF config; the result is additive/inert (defaults-free HDFS keys), never a credential or +correctness break. Documented as accepted in `deviations-log.md`. + +## Pre-existing (out of C2 scope) — `fs.hdfs.impl.disable.cache` +Legacy HDFS `getHadoopStorageConfig()` carried `fs.hdfs.impl.disable.cache=true` (via +`StorageProperties.ensureDisableCache`); the typed `backendConfigProperties` never adds it (it lives +only on `HdfsConfigBuilder`'s runtime `create()` path, `:44-48`). This is absent from the paimon HDFS +catalog Configuration **regardless of C2** (HDFS contributed nothing pre-fix), so it is a separate +pre-existing gap, not introduced or worsened here. Functional risk is low (FS-cache by scheme+authority+ugi +is benign). Noted for the deviations log / a possible follow-up; **not** folded into C2 (which is scoped +to the XML-resource gap). + +## Thread-safety / aliasing +Both maps are built once in the ctor as `Collections.unmodifiableMap`; the sole FE consumer copies via +`merged.putAll` into a method-local map, so the shared maps cannot be mutated. `loadConfigMap` creates a +fresh `Configuration` per call; the only static field (`hadoopConfigDirOverride`) is test-only. + +# Open Questions + +1. **DLF+HDFS-keys deviation** — recommend ACCEPT (nonsensical config, additive/inert). Sign off in + `deviations-log.md`. +2. **`fs.hdfs.impl.disable.cache` pre-existing gap** — recommend a separate follow-up (not C2). Flag in + `deviations-log.md`. +3. **HMS parity bonus** — the fix also closes the same XML gap for the HMS flavor (legacy overlaid HDFS + there too); this is parity, not scope creep. diff --git a/plan-doc/designs/FIX-C2-HDFS-XML-summary.md b/plan-doc/designs/FIX-C2-HDFS-XML-summary.md new file mode 100644 index 00000000000000..d5d1f57594ee50 --- /dev/null +++ b/plan-doc/designs/FIX-C2-HDFS-XML-summary.md @@ -0,0 +1,62 @@ +# Summary — FIX-C2-HDFS-XML + +## Problem + +P6 clean-room finding **C2** (MAJOR). A paimon catalog whose HDFS HA topology lives **only** in a +`hadoop.config.resources` XML file could not resolve its nameservice on the SPI plugin path +(filesystem/jdbc flavors): the FE catalog-create `Configuration` copied the `hadoop.config.resources` +key verbatim but never loaded the XML contents, so `hdfs://ns1/...` failed to resolve `ns1`. (BE/scan +path was unaffected — it already consumes the XML-loaded `toBackendProperties().toMap()`.) + +## Root Cause + +`HdfsFileSystemProperties` deliberately did **not** implement `HadoopStorageProperties`, so its +`toHadoopProperties()` returned `Optional.empty()` and HDFS contributed nothing to the connector's +`buildStorageHadoopConfig()` → FE Configuration. The XML keys (already parsed into +`backendConfigProperties` at bind time for the BE path) never reached the FE config. + +## Fix + +`HdfsFileSystemProperties implements HadoopStorageProperties`: +- `toHadoopProperties()` → `Optional.of(this)`. +- `toHadoopConfigurationMap()` → a **defaults-free** FE map (built via `new Configuration(false)`): + the XML keys + user `hadoop./dfs./fs./juicefs.` overrides + synthesized `fs.defaultFS`/ipc/auth/ + kerberos keys, but **without** Hadoop's 359 framework defaults. +- `toMap()` (BE) keeps the **defaults-laden** map for byte-parity with legacy `getBackendConfigProperties()`. + +**Why defaults-free** (the design red-team's decisive finding, empirically verified on hadoop 3.4.2): +`new Configuration()` carries 62 `fs.s3a.*` defaults (`path.style.access=false`, `connection.maximum=500`, +…). A naive "return `backendConfigProperties`" would inject those into the shared `storageHadoopConfig` +and, in a multi-backend catalog (object store + HDFS-with-XML), **clobber** a co-bound S3/MinIO +provider's tuned `fs.s3a.path.style.access=true` → MinIO reads break. A **regression vs the current +branch** (where HDFS contributes nothing). The defaults belong to the base `Configuration` anyway, so +the FE map only contributes HDFS's own keys. + +Per-flavor: parity for filesystem/jdbc (the C2 fix) and hms (legacy overlaid HDFS too); a documented, +accepted, barely-reachable deviation for DLF (`DV-036`); REST unaffected. Single-backend HDFS yields an +identical final Configuration. + +Also updated 4 stale comments (`PaimonConnector`, `PaimonCatalogFactory`, `MetaStoreParseUtils`, +`ConnectorContext`) that asserted the now-false "HDFS contributes nothing to storageHadoopConfig". + +## Tests + +- `HdfsFileSystemPropertiesTest`: flipped `classifiersMatchHdfs` (`toHadoopProperties().isEmpty()`→ + `.isPresent()`, RED pre-fix); added `xmlKeysReachHadoopConfigMap` (C2 regression pin — an XML-only key + reaches the FE map), `hadoopConfigMapExcludesFrameworkDefaultsButBeMapKeepsThem` (clobber guard + + FE/BE asymmetry), `hadoopConfigMapKeepsMeaningfulKeys` (defaults-free ≠ empty). +- `PaimonCatalogFactoryTest.buildStorageHadoopConfigFoldsInHdfsHadoopMap`: end-to-end seam — a stub + storage prop's `toHadoopConfigurationMap()` key (absent from raw props) flows through + `buildStorageHadoopConfig()` → `buildHadoopConfiguration` into the `Configuration`. Required making + `buildStorageHadoopConfig()` package-private + a `getStorageProperties()` seam on + `RecordingConnectorContext`. + +## Result + +- fe-filesystem-hdfs full suite: **GREEN** (`HdfsFileSystemPropertiesTest` 28/28). +- fe-connector-paimon full suite: **279/0/1-skip** (skip = gated `PaimonLiveConnectivityTest`). +- fe-connector-spi compile + checkstyle: **GREEN**. Connector import-restriction check: **GREEN**. +- Process: one design red-team (6 agents) + one adversarial impl-verification (empirically re-validated + the defaults-free claim against hadoop-common-3.4.2). +- **Docker e2e (`enablePaimonTest=true`): NOT run (gated).** +- Deviations recorded: `DV-036` (DLF+HDFS), `DV-037` (`fs.hdfs.impl.disable.cache` pre-existing gap). diff --git a/plan-doc/designs/FIX-C4-R2-R3-CATALOG-design.md b/plan-doc/designs/FIX-C4-R2-R3-CATALOG-design.md new file mode 100644 index 00000000000000..d0579b3307b92d --- /dev/null +++ b/plan-doc/designs/FIX-C4-R2-R3-CATALOG-design.md @@ -0,0 +1,177 @@ +# FIX-C4 / R2-catalog / R3-catalog — combined design + +> Source findings: `reviews/P6-paimon-fullpath-cleanroom-2026-06-18.md` §C4 (config), §R2 (catalog), §R3 (catalog). +> Three independent MINOR fixes, combined into one task-loop / one commit (HANDOFF "可合一"). +> Single-task loop: design → design red-team → implement → impl-verify → build+UT → commit → summary. + +## Scope & decisions + +| Fix | Finding | Legacy class | Decision | +|-----|---------|--------------|----------| +| **C4** | HMS socket timeout hardcoded `"10"`, ignores `Config.hive_metastore_client_timeout_second` | missing-port | Thread the FE config value through `ConnectorContext.getEnvironment()` | +| **R2-catalog** | dead `meta.cache.paimon.table.*` keys silently accepted (legacy `CacheSpec` rejected malformed) | missing-port | **Warn-only in the paimon connector** (user-confirmed) — NOT strip, NOT in the generic bridge | +| **R3-catalog** | `listDatabaseNames` swallows remote failure → `emptyList()` (legacy rethrew) | intentional-deviation | **Rethrow `RuntimeException` with catalog name** (user-confirmed) — exact legacy parity | + +Two judgment calls were put to the user and confirmed: R2 = warn-only (the report's "strip" + its cited +generic-bridge location both rejected: the key is paimon-specific, so it must live in the connector, not the +connector-agnostic `PluginDrivenExternalCatalog`); R3 = rethrow (matches legacy `PaimonMetadataOps:340` exactly +*and* every other connector — Hive/Hudi/JDBC/MC/Trino all propagate — and fixes a false "parity" comment). + +--- + +## C4 — thread `hive_metastore_client_timeout_second` + +**Root cause.** The HMS socket-timeout default moved from legacy `HMSBaseProperties.checkAndInit()` (which read +`Config.hive_metastore_client_timeout_second`) into `HmsMetaStorePropertiesImpl.toHiveConfOverrides()` step 4, which +hardcodes literal `"10"`. The metastore-spi module has no fe-common dependency, so it cannot read FE `Config`. Only +an operator who raises `fe.conf hive_metastore_client_timeout_second` *without* a per-catalog +`hive.metastore.client.socket.timeout` is affected (gets 10 instead of the configured value). + +**Why parity holds when unset.** `Config.hive_metastore_client_timeout_second` default = `10` +(`Config.java:2106`), so the threaded value is `"10"` when unconfigured — byte-identical to today. + +**Plumbing (4 modules, mirrors the existing env-key pattern).** + +1. **fe-core** `DefaultConnectorContext.buildEnvironment()` — add one line, alongside `jdbc_drivers_dir` etc.: + ```java + env.put("hive_metastore_client_timeout_second", + String.valueOf(Config.hive_metastore_client_timeout_second)); + ``` +2. **metastore-api** `HmsMetaStoreProperties` — change the HMS-specific method signature: + `Map toHiveConfOverrides()` → `toHiveConfOverrides(String defaultClientSocketTimeoutSeconds)`. + (HMS-specific interface method, single production caller — contained blast radius.) +3. **metastore-spi** `HmsMetaStorePropertiesImpl.toHiveConfOverrides(String)` — step 4 uses the param instead of + `"10"`, keeping the existing user-override guard (`raw.get("hive.metastore.client.socket.timeout")` blank-check, + verifier-confirmed equivalent to legacy guard-key). Defensive fallback to `"10"` if the param is blank/null: + ```java + if (StringUtils.isBlank(raw.get("hive.metastore.client.socket.timeout"))) { + conf.put("hive.metastore.client.socket.timeout", + StringUtils.isNotBlank(defaultClientSocketTimeoutSeconds) + ? defaultClientSocketTimeoutSeconds : "10"); + } + ``` + Also update the `{@link #toHiveConfOverrides()}` javadoc reference at line 35 → `(String)`. +4. **paimon** `PaimonConnector` HMS branch (`:183`) — pass the env value: + ```java + HiveConf hc = PaimonCatalogFactory.assembleHiveConf(hiveConfFiles, + hms.toHiveConfOverrides( + context.getEnvironment().getOrDefault("hive_metastore_client_timeout_second", "10"))); + ``` + +**Not affected.** DLF path uses `toDlfCatalogConf()` (no socket-timeout default — verified); REST/JDBC/FS have no +HMS socket timeout. Only the HMS branch threads the value. + +**Tests.** Update the ~10 `toHiveConfOverrides()` call-sites (8 in `HmsMetaStorePropertiesTest`, 1 anon impl + +1 caller in `MetaStorePropertiesContractTest`) to the new signature — pass `"10"` to preserve existing assertions. +Add a C4 test: a non-default value (`"60"`) flows to `hive.metastore.client.socket.timeout`, and a user-set +`hive.metastore.client.socket.timeout` suppresses it (override wins) — encodes the *intent* (Rule 9). + +--- + +## R2-catalog — warn on dead `meta.cache.paimon.table.*` keys + +**Root cause.** Legacy `PaimonExternalCatalog.checkProperties()` ran `CacheSpec.check{Boolean,Long}Property` on +`meta.cache.paimon.table.{enable,ttl-second,capacity}` (threw `DdlException` for malformed values). On the plugin +path those checks are gone, so a malformed value is accepted. The keys are **100% dead** (the plugin path uses the +generic schema cache; `PaimonExternalMetaCache`/`ExternalMetaCacheMgr.paimon` have zero non-legacy callers), so even +a well-formed value is a no-op. + +**Decision (user-confirmed): warn-only, in the connector.** The key is paimon-specific, so the connector-agnostic +`PluginDrivenExternalCatalog` (the report's cited location) is the wrong layer — handling it there violates the +"no source-specific code in the generic SPI layer" rule (memory `catalog-spi-plugindriven-no-source-specific-code`). +Re-imposing full `CacheSpec` validation is pointless (the report agrees) — it would reject malformed values for a +knob that does nothing. Stripping mutates persisted properties (SHOW CREATE CATALOG would no longer echo what the +user typed) and needs a non-validate hook. Warn-only delivers the real value — telling the operator the knob is dead +— at the right layer with the least change. + +**Change.** `PaimonConnectorProvider.validateProperties(Map)` (already paimon-specific, called once per +CREATE/ALTER CATALOG via `ConnectorFactory.validateProperties`): before the existing `bind().validate()`, scan for +keys with prefix `meta.cache.paimon.table.` and, if any, `LOG.warn` that they no longer take effect on the paimon +plugin path. Add a log4j logger to the class (none today). Detect by prefix (no need to import the three legacy +fe-core constant strings). + +```java +private static final String DEAD_TABLE_CACHE_PREFIX = "meta.cache.paimon.table."; +... +List dead = properties.keySet().stream() + .filter(k -> k.startsWith(DEAD_TABLE_CACHE_PREFIX)).sorted().collect(Collectors.toList()); +if (!dead.isEmpty()) { + LOG.warn("Paimon catalog property/properties {} no longer take effect (the plugin path uses the " + + "generic metadata cache); they are ignored.", dead); +} +``` + +**Tests.** `PaimonConnectorProviderTest` (or new) — asserting a warn is logged is brittle; instead assert +`validateProperties` does **not throw** when a `meta.cache.paimon.table.capacity=-5` (legacy-malformed) key is +present (documents the deliberate no-reject), and still throws for a genuinely invalid catalog (unknown +`paimon.catalog.type`). The warn itself is observable-only; no behavioral assertion. + +--- + +## R3-catalog — rethrow `listDatabaseNames` failure with catalog name + +**Root cause.** `PaimonConnectorMetadata.listDatabaseNames` catches `Exception`, `LOG.warn`s (no catalog name), +returns `emptyList()` — a transient remote failure presents as "zero databases". Legacy +`PaimonMetadataOps.listDatabaseNames` (`:336-342`) rethrew `RuntimeException("Failed to list databases names, +catalog name: " + name, e)`. The connector's comment ("legacy ... wrapped it too. Full read-vs-DDL parity") is +**false**. Paimon is the sole connector that swallows (verifier-confirmed: all others propagate). + +**Change.** `PaimonConnectorMetadata.listDatabaseNames` — rethrow with the catalog name, dropping the swallow: +```java +try { + return context.executeAuthenticated(() -> catalogOps.listDatabases()); +} catch (Exception e) { + throw new RuntimeException( + "Failed to list databases names, catalog name: " + context.getCatalogName(), e); +} +``` +Keep the `executeAuthenticated` wrap (M-11 Kerberos UGI). Rewrite the false comment to state the real parity +(legacy rethrew). `context.getCatalogName()` exists on `ConnectorContext`. `RuntimeException` is unchecked → +no signature change; the bridge `PluginDrivenExternalCatalog.listDatabaseNames:226` does not catch → it propagates +to DB-init exactly as legacy did. `Collections` import stays (used in ~10 other spots). + +**Tests.** `PaimonConnectorMetadataTest` (or existing) — when `catalogOps.listDatabases()` throws, assert +`listDatabaseNames` throws (not empty), and the message carries the catalog name. RED→GREEN: with the old swallow +the test sees `emptyList()` (red), with the rethrow it throws (green). + +--- + +## Risk / blast-radius + +- **C4** changes a metastore-api interface method signature, but `HmsMetaStoreProperties` is consumed only by paimon + (sole cut-over connector) + tests → contained. Default-preserving when `fe.conf` unset. +- **R2** is warn-only → no behavior change beyond a log line at CREATE/ALTER CATALOG. +- **R3** is a real behavior change (swallow→throw) on a transient-failure edge: a flaky metastore now errors SHOW + DATABASES instead of returning empty. This is the legacy behavior and matches all other connectors — the safer, + less-surprising contract (empty-on-error masks failures). User-confirmed. +- All three are gated/CI-only for live e2e; UT + build are the verification gate. + +## Design red-team resolution (wf_444e33b9-5c6 — 4 lenses, 12 findings, 9 confirmed / 3 refuted → GO-WITH-CHANGES) + +The 3 production-code changes were judged sound; all confirmed defects were in the test plan / doc, now folded in: + +- **R2 premise re-verified (the one substantive concern).** A verifier challenged "100% dead" citing + `PaimonUtils.java:56-57` / `PaimonExternalMetaCache`. Traced and **refuted**: `ExternalTable.getMetaCacheEngine()` + returns `"default"` and PluginDriven tables do **not** override it, so a cut-over paimon table routes + `ExternalMetaCacheMgr.getSchemaCacheValue` to the generic `"default"` cache — never `PaimonExternalMetaCache` + (engine `"paimon"`). `meta.cache.paimon.table.*` sizes only `PaimonExternalMetaCache.tableEntry`, reached solely + via `getPaimonTable`/`getLatestSnapshotCacheValue` ← legacy `PaimonExternalTable`/`PaimonScanNode`. **Dead on the + plugin path confirmed; warn message accurate.** +- **C4 call-sites = 9 (not 8)** in `HmsMetaStorePropertiesTest` (incl. inline `:219`, `:226`) + anon impl + caller + in `MetaStorePropertiesContractTest` = 11 total. Clean signature change (no test-only overload). Also fix 3 stale + `{@code …toHiveConfOverrides()}` mentions (`KerberosAuthSpec:34`, `PaimonCatalogFactory:53,311`) — doc hygiene. +- **R2 test home** = `PaimonConnectorValidatePropertiesTest` (no `PaimonConnectorProviderTest` exists); no-reject + test uses a **well-formed** catalog so the dead key is the only variable; `rejectsUnknownFlavor()` already covers + the throw case. Warn re-fires on each ALTER while the key persists — accepted (no strip, no old/new diffing). +- **R3 = MIGRATE the existing test, not add alongside.** `PaimonConnectorMetadataReadAuthTest` + `listDatabaseNamesRunsSeamInsideAuthenticator:76`: `.isEmpty()` → `assertThrows(RuntimeException.class, …)`, + KEEP `ops.log.isEmpty()` + `authCount==1` (M-11 seam coverage holds — `failAuth` throws before the seam) and also + assert the message carries the catalog name (`ctx.getCatalogName()=="test"`). Rewrite the false comment. + +## Verification plan + +1. fe-core compiles; metastore-spi + metastore-api compile; paimon connector compiles (`-am`, build-cache off). +2. `HmsMetaStorePropertiesTest` (updated + new C4 test) green; `MetaStorePropertiesContractTest` green. +3. paimon connector tests green (incl. new R2 no-reject + R3 rethrow tests). +4. checkstyle + `tools/check-connector-imports.sh` clean. +5. Mutation check: revert each fix → its new test goes red. diff --git a/plan-doc/designs/FIX-C4-R2-R3-CATALOG-summary.md b/plan-doc/designs/FIX-C4-R2-R3-CATALOG-summary.md new file mode 100644 index 00000000000000..3d04ca51fb36f1 --- /dev/null +++ b/plan-doc/designs/FIX-C4-R2-R3-CATALOG-summary.md @@ -0,0 +1,64 @@ +# FIX-C4 / R2-catalog / R3-catalog — summary (DONE) + +> Combined fix for three MINOR findings from `reviews/P6-paimon-fullpath-cleanroom-2026-06-18.md`. +> Design: `FIX-C4-R2-R3-CATALOG-design.md`. Single commit ("可合一"). Two red-team passes (design + impl), both clean. + +## What changed + +| Fix | Change | Files | +|-----|--------|-------| +| **C4** | Thread `Config.hive_metastore_client_timeout_second` (env key) into `HmsMetaStoreProperties.toHiveConfOverrides(String)` instead of the hardcoded `"10"` | `DefaultConnectorContext` (producer), `HmsMetaStoreProperties` (api), `HmsMetaStorePropertiesImpl` (spi), `PaimonConnector` (consumer) | +| **R2-catalog** | `PaimonConnectorProvider.validateProperties` **warns** (no reject, no strip) on dead `meta.cache.paimon.table.*` keys | `PaimonConnectorProvider` | +| **R3-catalog** | `PaimonConnectorMetadata.listDatabaseNames` **rethrows** `RuntimeException("Failed to list databases names, catalog name: ", e)` instead of swallowing to `emptyList()` | `PaimonConnectorMetadata` | + +Plus 3 stale `{@code …toHiveConfOverrides()}` doc-mentions updated to `(String)` (`KerberosAuthSpec`, `PaimonCatalogFactory` ×2 — doc hygiene, not gated). + +## Decisions & facts + +- **C4 parity:** `Config.hive_metastore_client_timeout_second` default = `10` (`Config.java:2106`), so the threaded value + is byte-identical (`"10"`) when `fe.conf` is unset; an operator who raises it (e.g. `60`) without a per-catalog + `hive.metastore.client.socket.timeout` now gets `60` (legacy behavior), restoring `HMSBaseProperties:204-208`. The + user-override guard (`raw.get("hive.metastore.client.socket.timeout")` blank-check) is unchanged. Only the HMS branch + threads the value — DLF (`toDlfCatalogConf`)/JDBC/REST/FS have no socket-timeout default (legacy parity). + Clean signature change (no test-only overload); 11 call-sites updated (9 in `HmsMetaStorePropertiesTest`, anon impl + + caller in `MetaStorePropertiesContractTest`). +- **R2 — keys are genuinely dead on the plugin path (empirically proven, two reviews agree):** + `ExternalTable.getMetaCacheEngine()` returns `"default"` and PluginDriven tables do **not** override it, so a cut-over + paimon table routes `ExternalMetaCacheMgr.getSchemaCacheValue` to the generic `"default"` cache — never + `PaimonExternalMetaCache` (engine `"paimon"`). `meta.cache.paimon.table.*` sizes only + `PaimonExternalMetaCache.tableEntry`, reached solely via `getPaimonTable`/`getLatestSnapshotCacheValue` ← legacy + `PaimonExternalTable`/`PaimonScanNode`. A design-red-team verifier's "may be live" counter-claim was refuted by this + `="default"` routing trace. **Warn-only chosen over the report's "strip"** (user-confirmed): strip mutates persisted + props (SHOW CREATE CATALOG would stop echoing the user's input) and the key is paimon-specific so it belongs in the + connector, not the connector-agnostic `PluginDrivenExternalCatalog` (the report's cited location — wrong layer per the + "no source-specific code in the generic SPI layer" rule). Re-validating a dead knob is pointless (report agrees). +- **R3 — rethrow matches legacy exactly** (`PaimonMetadataOps:340`, same message incl. catalog name) **and** every other + connector (Hive/Hudi/JDBC/MC/Trino all propagate; paimon was the sole swallower). The connector's old comment claiming + "Full read-vs-DDL parity" while swallowing was false; rewritten. Propagation is clean: the bridge + `PluginDrivenExternalCatalog.listDatabaseNames:226` does not catch, so the `RuntimeException` reaches DB-init exactly + as legacy did. `executeAuthenticated` (M-11 Kerberos wrap) preserved. `RuntimeException` is unchecked → no signature + change; `LOG`/`Collections` imports still used elsewhere. + +## Verification + +- **Builds:** fe-core `compile` BUILD SUCCESS (DefaultConnectorContext); paimon `package -Dassembly.skipAssembly=true` + BUILD SUCCESS; metastore-api/spi `test` BUILD SUCCESS. +- **Tests:** paimon **280/0/0** (+1 skip = gated `PaimonLiveConnectivityTest`); `PaimonConnectorValidatePropertiesTest` + 14/0/0 (+1 R2 no-reject); `PaimonConnectorMetadataReadAuthTest` 12/0/0 (R3 migrated swallow→rethrow, M-11 coverage + kept); `HmsMetaStorePropertiesTest` 16/0/0 (+3 C4); `MetaStorePropertiesContractTest` 3/0/0. +- **Mutation (by construction):** C4 `threadedSocketTimeoutDefaultFlowsThrough` asserts `"60"` (old hardcoded `"10"` + cannot satisfy); R3 test asserts `assertThrows` (old swallow-to-empty cannot throw). R2 test is a regression-guard + (warn-only has no behavioral mutation to catch — it pins "do not re-add rejection"). +- **checkstyle 0** across all touched modules; `tools/check-connector-imports.sh` exit 0. +- **Red-team ×2:** design (`wf_444e33b9-5c6`, GO-WITH-CHANGES — all corrections folded in) + impl (`wf_b3d35e64-6b9`, + COMMIT — 0 actionable / 13 self-resolving NITs). +- **e2e:** gated (`enablePaimonTest=false`) — NOT run. + +## Out-of-scope (documented, not changed) + +- **C4 SPI gate vs legacy enum-name gate:** the SPI guards the socket-timeout default on + `StringUtils.isBlank(raw.get("hive.metastore.client.socket.timeout"))`; legacy guarded on + `userOverriddenHiveConfig.containsKey(ConfVars.METASTORE_CLIENT_SOCKET_TIMEOUT.toString())`. Equivalent for the + documented key; this predates C4 (C4 only swapped the value `"10"` → threaded default) and the SPI form is the + more-correct one. Left per Rule 3/7. +- R2 log wording "property/properties {}" reads awkwardly but is accurate — cosmetic, left as-is. diff --git a/plan-doc/designs/FIX-R1-TABLE-design.md b/plan-doc/designs/FIX-R1-TABLE-design.md new file mode 100644 index 00000000000000..bf5fd64d7cdcfd --- /dev/null +++ b/plan-doc/designs/FIX-R1-TABLE-design.md @@ -0,0 +1,103 @@ +# FIX-R1-TABLE — restore MySQL errno 1050 for CREATE TABLE on a remote-existing table + +> Single-task loop (AGENT-PLAYBOOK): design → design red-team → implement → impl verify → build+UT → commit → summary. +> Source finding: `reviews/P6-paimon-fullpath-cleanroom-2026-06-18.md` §R1 (table) (MINOR, regression, confirmed). + +# Problem + +`PluginDrivenExternalCatalog.createTable` (the **generic** SPI bridge — paimon/maxcompute/es/jdbc/trino all +route through it) reports `ERR_TABLE_EXISTS_ERROR` (MySQL errno **1050**, SQLSTATE `42S01`, "Table '%s' +already exists") **only for the local-cache-conflict arm**. A table that exists **only remotely** (absent +from this FE's cache) with no `IF NOT EXISTS` falls through to `metadata.createTable`, which throws +`DorisConnectorException("…already exists")`, re-wrapped at `:319` as a **generic** `DdlException` +(errno 0 / `ERR_UNKNOWN_ERROR`). The CREATE still fails — only the error code / SQLSTATE / message regress. + +```java +// PluginDrivenExternalCatalog.java:298-314 (current) +if (remoteExists || localExists) { + if (createTableInfo.isIfNotExists()) { ... return true; } // both arms no-op on IF NOT EXISTS + if (localExists) { // <-- LOCAL arm only + ErrorReport.reportDdlException(ERR_TABLE_EXISTS_ERROR, createTableInfo.getTableName()); + } +} +// remoteExists && !localExists && !ifNotExists falls through to metadata.createTable -> generic DdlException +``` + +# Root Cause + +The bridge re-implements legacy's remote-then-local existence probe but only ported the **local** arm's +1050 report. Both legacy ops reported 1050 for **both** arms: +- legacy paimon `PaimonMetadataOps.performCreateTable`: remote `:195`, local `:212`. +- legacy maxcompute `MaxComputeMetadataOps.createTableImpl`: remote `:184`, local `:195`. + +So 1050-for-remote is exact parity for **both** live cut-over connectors, not a paimon-only concern. + +# Design + +Drop the `if (localExists)` guard. At that point the code is already inside `if (remoteExists || localExists)` +and past the `isIfNotExists()` early-return, so `(remoteExists || localExists) && !ifNotExists` is +guaranteed — report `ERR_TABLE_EXISTS_ERROR` unconditionally there. `ErrorReport.reportDdlException` throws, +short-circuiting **before** `metadata.createTable` (so the remote-only case no longer reaches the connector). + +```java +if (remoteExists || localExists) { + if (createTableInfo.isIfNotExists()) { ... return true; } + // !IF NOT EXISTS: a table existing remotely OR only in the local FE cache must be rejected here with + // MySQL errno 1050, mirroring legacy {Paimon,MaxCompute}MetadataOps (both report ERR_TABLE_EXISTS_ERROR + // for the remote arm AND the local arm). Reporting before metadata.createTable also keeps the + // local-cache-only conflict from being CREATED remotely (lower_case_meta_names case-fold). + ErrorReport.reportDdlException(ErrorCode.ERR_TABLE_EXISTS_ERROR, createTableInfo.getTableName()); +} +``` + +This is the minimal change and is **byte-equivalent to legacy** for both arms. + +## Behavioral delta + +- **remote-only existing table, no IF NOT EXISTS:** generic `DdlException` → **`DdlException` with errno + 1050** + message "Table '' already exists" (legacy parity). CREATE still fails; `metadata.createTable` + is no longer called for this case (it only threw anyway — no lost side effect). +- **local-cache conflict / IF NOT EXISTS / create-succeeds:** unchanged. +- **Generic bridge → applies to every SPI connector** (paimon/maxcompute/es/jdbc/trino). 1050 for an + existing table is the universally-correct MySQL contract; parity verified for the two live connectors. + +# Implementation Plan + +Single edit in `PluginDrivenExternalCatalog.java`: replace the `if (localExists) { report }` arm with an +unconditional `report` (and update the comment `:304-313`). No SPI/connector/BE change. + +# Risk Analysis + +- **Diagnostic/contract-only** (error code/SQLSTATE/message); CREATE outcome (failure) unchanged → MINOR. +- errno 1050 is a documented MySQL contract some ORMs/migration tools branch on (SQLSTATE 42S01) → worth + restoring (MINOR, not NIT). +- **Reachability narrow:** table exists remotely but absent from this FE cache — stale cache / other-FE / + external (Spark/Flink) create. +- **No lost side effect:** the connector's createTable for an existing table only throws; short-circuiting + before it changes nothing but the surfaced error. +- **Cross-connector (red-team `wf_19fd7785-165`, 0 actionable):** the change is in the shared bridge; parity + verified for paimon + maxcompute (both legacy ops report 1050 for the remote AND local arm). No connector + relied on the remote-exists fall-through for a side effect, and no regression/e2e test pins the old generic + message or asserts `createTable` is called on a remote-existing table. **Nuance (NIT):** es/jdbc/trino + implement `getTableHandle` (so `remoteExists` can be true) but do not override `createTable`; for those + connectors a `CREATE TABLE` on an *existing* table now surfaces "already exists" (1050) instead of the old + fall-through error — benign and arguably more accurate; the non-existing-table path is unchanged. + +# Test Plan + +## Unit Tests (`PluginDrivenExternalCatalogDdlRoutingTest`) + +- **Update** `testCreateTableExistingTableWithoutIfNotExistsStillErrors` (`:523`) — it currently encodes the + **buggy** fall-through (`verify(metadata).createTable(...)`). Rewrite to the corrected contract: + remote-exists + !IF NOT EXISTS → `DdlException` with `getMysqlErrorCode() == ERR_TABLE_EXISTS_ERROR`, and + `verify(metadata, never()).createTable(...)` (short-circuit before the connector) + no editlog. This is + the **mutation-killing** test: restoring the `if (localExists)` guard makes the remote case fall through → + errno reverts to `ERR_UNKNOWN_ERROR` and createTable is called → red. +- **Strengthen** `testCreateTableLocalConflictWithoutIfNotExistsRejects` (`:555`) — add + `getMysqlErrorCode() == ERR_TABLE_EXISTS_ERROR` so both arms pin the 1050 contract (local arm already + passed pre-fix; this documents the unified contract). + +## E2E Tests + +Reaching "remote exists, local-cache absent" needs multi-FE or an external create; paimon e2e is gated +(`enablePaimonTest=false`). → no e2e added (documented; fail-loud). diff --git a/plan-doc/designs/FIX-R1-TABLE-summary.md b/plan-doc/designs/FIX-R1-TABLE-summary.md new file mode 100644 index 00000000000000..61c63edccbeefc --- /dev/null +++ b/plan-doc/designs/FIX-R1-TABLE-summary.md @@ -0,0 +1,57 @@ +# FIX-R1-TABLE — Summary + +> Source finding: `reviews/P6-paimon-fullpath-cleanroom-2026-06-18.md` §R1 (table) (MINOR, regression). +> Design: `FIX-R1-TABLE-design.md`. Design red-team: `wf_19fd7785-165` (2 lenses, finder→verifier, 0 actionable). + +## Problem + +`PluginDrivenExternalCatalog.createTable` (the **generic** SPI bridge for all `SPI_READY_TYPES` — +paimon/maxcompute/es/jdbc/trino) reported `ERR_TABLE_EXISTS_ERROR` (MySQL errno **1050** / SQLSTATE `42S01`) +only for the **local-cache-conflict** arm. A table existing **only remotely** (absent from this FE's cache), +created without `IF NOT EXISTS`, fell through to `metadata.createTable` → `DorisConnectorException` → +re-wrapped as a **generic** `DdlException` (errno `ERR_UNKNOWN_ERROR` = 0). CREATE still failed; only the +error code / SQLSTATE / message regressed. + +## Root Cause + +The bridge ported only the **local** arm's 1050 report. Both legacy ops report 1050 for **both** arms: +legacy paimon `PaimonMetadataOps.performCreateTable` (remote `:195`, local `:212`) and legacy maxcompute +`MaxComputeMetadataOps.createTableImpl` (remote `:184`, local `:195`, verified via git). So 1050-for-remote +is exact parity for both live cut-over connectors. + +## Fix + +`PluginDrivenExternalCatalog.java` — dropped the `if (localExists)` guard. Reaching that point already +guarantees `(remoteExists || localExists) && !isIfNotExists`, so `ErrorReport.reportDdlException( +ERR_TABLE_EXISTS_ERROR, tableName)` now runs unconditionally there, short-circuiting **before** +`metadata.createTable`. Comment rewritten to document both arms + the legacy parity refs. + +### Behavioral delta +- **remote-only existing table, no IF NOT EXISTS:** generic `DdlException` → `DdlException` with errno **1050** + + "Table '' already exists" (legacy parity); `metadata.createTable` no longer called (it only threw). +- **local conflict / IF NOT EXISTS (CTAS no-INSERT) / create-succeeds:** unchanged. +- **es/jdbc/trino (non-create-overriding):** a `CREATE TABLE` on an *existing* table now surfaces 1050 instead + of the old fall-through error — benign/arguably more accurate; non-existing-table path unchanged. + +## Tests (`PluginDrivenExternalCatalogDdlRoutingTest`) + +- **Rewrote** `testCreateTableExistingTableWithoutIfNotExistsStillErrors` → + `testCreateTableExistingRemoteTableWithoutIfNotExistsReportsErrno1050`: it previously encoded the **buggy** + fall-through (`verify(metadata).createTable`); now asserts `getMysqlErrorCode() == ERR_TABLE_EXISTS_ERROR` + + `verify(metadata, never()).createTable` + no editlog. +- **Strengthened** `testCreateTableLocalConflictWithoutIfNotExistsRejects` with the same errno assertion + + refreshed its mutation comment (the two tests together pin "report 1050 on EITHER arm"). +- Added `import org.apache.doris.common.ErrorCode`. + +**RED→GREEN verified empirically:** re-adding the `if (localExists)` guard turns the remote test red +("Expected DdlException … but nothing was thrown" — the buggy path falls through to the no-op mock +`createTable`); removing it → green. Local test stays green under the mutation (correct — it guards the +other arm). + +## Result + +- `PluginDrivenExternalCatalogDdlRoutingTest` 26/0/0, `PluginDrivenExternalTableEngineTest` 12/0/0; fe-core + compiles; checkstyle clean (validate phase). Build cache disabled. +- Diagnostic/contract-only change (error code/SQLSTATE/message); CREATE outcome (failure) unchanged. +- **e2e:** reaching "remote exists, local-cache absent" needs multi-FE / external create; paimon e2e gated + (`enablePaimonTest=false`) → none added (documented; fail-loud). diff --git a/plan-doc/designs/FIX-R3-RESIDUAL-design.md b/plan-doc/designs/FIX-R3-RESIDUAL-design.md new file mode 100644 index 00000000000000..e0f812b4c07b42 --- /dev/null +++ b/plan-doc/designs/FIX-R3-RESIDUAL-design.md @@ -0,0 +1,165 @@ +# FIX-R3-RESIDUAL — drop the `"paimon".equals` gate on the VERBOSE backends block + +> Single-task loop (AGENT-PLAYBOOK): design → design red-team → implement → impl verify → build+UT → commit → summary. +> Source finding: `reviews/P6-paimon-fullpath-cleanroom-2026-06-18.md` §R3 (MINOR, regression, partial→MINOR). +> Project rule: memory `catalog-spi-plugindriven-no-source-specific-code` (no source-name branches in the generic SPI node). + +# Problem + +`PluginDrivenScanNode.getNodeExplainString` (the generic SPI scan node) re-emits the VERBOSE per-backend +scan-range detail block (`backends:` + per-file `path start/length` lines + `dataFileNum/deleteFileNum/ +deleteSplitNum`) only when the catalog type is paimon: + +```java +// PluginDrivenScanNode.java:305-309 +if (detailLevel == TExplainLevel.VERBOSE && !isBatchMode() + && "paimon".equals( + desc.getTable().getDatabase().getCatalog().getType())) { + appendBackendScanRangeDetail(output, prefix); +} +``` + +Three defects: + +1. **MaxCompute VERBOSE EXPLAIN regression.** The parent `FileScanNode.getNodeExplainString` emits this + block **unconditionally** for `VERBOSE && !isBatchMode()` (`FileScanNode.java:151-153`). Legacy + `MaxComputeScanNode extends FileQueryScanNode extends FileScanNode` and did **not** override + `getNodeExplainString` (verified in git `73832991962^`: class decl only, no override) → it inherited the + unconditional block. After the SPI cutover MaxCompute routes through `PluginDrivenScanNode` + (`PhysicalPlanTranslator:737-746`, `instanceof PluginDrivenExternalTable`), whose override does NOT call + super and gates the block to paimon → cut-over MaxCompute VERBOSE EXPLAIN silently loses the block. +2. **Layering violation.** A hardcoded source-name branch (`"paimon".equals(...getType())`) in the generic + node shared by every SPI connector. Directly violates the project rule (memory + `catalog-spi-plugindriven-no-source-specific-code`): universal `FileScanNode` behavior must be emitted + unconditionally (like the sibling `inputSplitNum` / `partition=N/M` / `pushdown agg=` lines in this very + method), connector-specific behavior must delegate via `ConnectorScanPlanProvider.appendExplainInfo`. +3. **False comment.** The inline comment (`:299-304`) claims the gate keeps "es/jdbc/max_compute VERBOSE + output … byte-unchanged" — wrong: it is exactly what regresses MaxCompute. + +# Root Cause + +`PluginDrivenScanNode.getNodeExplainString` reimplements the `FileScanNode` body (custom +TABLE/CONNECTOR/QUERY/PREDICATES format, so it cannot call `super`) and re-emits each inherited line by +hand. For the VERBOSE backends block the re-emission was wrongly conjoined with a paimon source-name gate +(added by FIX-PAIMON-EXPLAIN-GAP `d4526013364`, with the stated but incorrect intent of "not perturbing +other connectors"), instead of mirroring the parent's gate verbatim (`VERBOSE && !isBatchMode()`). + +# Design + +Remove the `"paimon".equals(...)` conjunct. The remaining gate `detailLevel == VERBOSE && !isBatchMode()` +is then **identical to the parent `FileScanNode`'s** gate (`FileScanNode.java:151`). Replace the false +comment with the truthful "emit unconditionally for every plugin connector, like the sibling universal +lines" rationale. + +```java +if (detailLevel == TExplainLevel.VERBOSE && !isBatchMode()) { + appendBackendScanRangeDetail(output, prefix); +} +``` + +`paimonNativeReadSplits` stays where it is — behind the SPI `appendExplainInfo` delegation +(`:315-323`) — so connector-specific EXPLAIN remains connector-side. No change there. + +## Who is affected — CORRECTED after design red-team (`wf_3518653b-3cb`) + +> The first draft of this doc wrongly scoped the change to "paimon + maxcompute" and claimed "es/jdbc: not +> routed → no change". The red-team **refuted** that with code evidence and I verified it independently. + +`CatalogFactory.java:51`: `SPI_READY_TYPES = {"jdbc", "es", "trino-connector", "max_compute", "paimon"}` — +**all five** become `PluginDrivenExternalCatalog` → `PluginDrivenExternalTable` → routed to +`PluginDrivenScanNode` (`PhysicalPlanTranslator:737`). A plain `SELECT … FROM _catalog.db.tbl` +reaches the table-scan **else**-branch (only the jdbc-**TVF** uses `PassthroughQueryTableHandle` → the +**if**-branch, unaffected). `supportsBatchScan` defaults `false` (only MaxCompute overrides it), so +`!isBatchMode()` is true for es/jdbc → the gate fires. So removing the conjunct emits the `backends:` block +for **all 5** SPI connectors. + +## Why this is safe (no NPE) + +- **Always file-based ranges.** `PluginDrivenScanNode` produces only `PluginDrivenSplit` (`extends + FileSplit`), so every `scanRangeLocations` entry carries a populated `FileScanRange` — exactly what + `appendBackendScanRangeDetail` dereferences (`locations.getScanRange().getExtScanRange() + .getFileScanRange().getRanges()`). es/jdbc render a synthetic per-split path (`es:///`, + `jdbc://virtual`); no real file, but NPE-safe. (red-team C-SAFE: confirmed, 4 independent verifiers.) +- **`getDeleteFiles` null-guard.** The block calls `getDeleteFiles(rangeDesc)`; the override returns empty + for a range without table-format params and for a null provider (guarded + unit-tested in + `PluginDrivenScanNodeDeleteFilesTest`). Non-paimon ranges → `deleteFileNum=0`, no NPE. +- **Empty scan.** If `scanRangeLocations` is empty the loop body never runs → only a bare `backends:` line + is printed (same as the parent today). No regression vs `FileScanNode`. +- **Batch-mode.** The one range shape with a null `getRanges()` (split-source-only) exists only when + `isBatchMode()==true`, and the block stays gated by `!isBatchMode()` (cached, shared by both paths). + +## Parity / behavioral delta + +- **paimon:** unchanged (was already in the gate; still emitted; `test_paimon_deletion_vector_oss` still + asserts `deleteFileNum`). Byte-identical. +- **maxcompute & trino-connector:** the `backends:` block reappears under VERBOSE — **restores** pre-cutover + legacy behavior (both legacy nodes extended `FileQueryScanNode` and inherited the unconditional block). +- **es / jdbc:** **NEW** VERBOSE output — their legacy dedicated scan nodes (`EsScanNode` / + `JdbcScanNode`) did not emit a `FileScanNode` backends block. This is the rule-mandated, consistent + choice: it is the same category as the sibling universal lines (`partition=N/M`, `pushdown agg=`) that + this override **already** emits unconditionally for es/jdbc. Accepted (broadened scope, documented here + + in the commit message). No regression test pins these connectors' VERBOSE EXPLAIN text (red-team grep + + my independent grep both empty), so nothing breaks. +- **future hudi-SPI:** gains parity with every other `FileScanNode` (correct by the same rule). + +# Implementation Plan + +Single edit in `fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenScanNode.java`: +1. Replace the comment block `:299-304` (false "GATED to paimon … byte-unchanged" rationale) with the + truthful unconditional rationale. +2. Drop the `&& "paimon".equals(desc.getTable().getDatabase().getCatalog().getType())` conjunct + (`:306-307`), leaving `if (detailLevel == TExplainLevel.VERBOSE && !isBatchMode())`. + +No SPI signature change, no connector change, no BE change. + +# Risk Analysis + +- **Behavioral change is diagnostic-only** (EXPLAIN VERBOSE text); zero query/data-path impact (review §R3: + classification regression, severity MINOR). +- **Restoration, not new behavior**, for the only affected live connector (maxcompute). The block is the + same code path the parent runs for hive/iceberg/maxcompute-legacy. +- **No NPE risk** (file-based ranges + guarded `getDeleteFiles`), see above. +- **Risk if NOT done:** the source-name branch stands as precedent for the next SPI connector and the + MaxCompute EXPLAIN regression persists. + +# Test Plan + +## Unit Tests + +> **Decision REVISED after red-team (R3-TEST-1/2):** my first-draft "no UT feasible" claim was refuted. +> `PluginDrivenScanNodeSysHandleTest` already drives a real `PluginDrivenScanNode` end-to-end via +> `create(...)` with a `TestablePluginCatalog` whose catalog **type is a ctor param**, and the bare +> `backends:` header is emitted **unconditionally before** the per-backend loop — so with empty +> `scanRangeLocations` a node-level explain test is cheap and NPE-free. → **Add a UT.** + +New: `PluginDrivenScanNodeVerboseExplainTest` (mirrors the `CALLS_REAL_METHODS` + `Deencapsulation.setField` +partial-node technique of `PluginDrivenScanNodeDeleteFilesTest` — only the fields the explain path reads are +injected; `scanRangeLocations` empty so the loop is skipped): +- `verboseEmitsBackendsBlockForNonPaimonConnector` — catalog type `max_compute`, VERBOSE → output contains + `backends:`. **Kills the mutation**: re-adding `&& "paimon".equals(...getType())` drops the block for a + non-paimon catalog → red. +- `verboseEmitsBackendsBlockForPaimon` — parity guard: paimon still emits the block. +- `nonVerboseOmitsBackendsBlock` — NORMAL level → no `backends:` (pins the surviving `VERBOSE` gate). + +Existing tests stay relevant: `PluginDrivenScanNodeDeleteFilesTest` (NPE-safety of `getDeleteFiles`), +`PluginDrivenScanNodeExplainStatsTest` (static EXPLAIN accounting helpers). + +Regression gate: run the `fe-core` compile + the `org.apache.doris.datasource.PluginDrivenScanNode*` test +classes (must stay green) + the paimon connector module tests (no contract touched). + +## Out of scope (flagged by red-team, NOT fixed here) + +- **R3-LAYER-2:** a sibling connector-specific gate remains in this method — `"es_http".equals(props.get( + PROP_FILE_FORMAT_TYPE))` for the `ES terminate_after:` line (`~:336`) and the in-node ES limit pushdown. + It survives the no-source-name rule *literally* (it keys on a file-FORMAT-type property, not + `getCatalog().getType()`), but is the same *spirit* of in-node connector-specific EXPLAIN. Left as a + separate pre-existing residual for a future `ConnectorScanPlanProvider.appendExplainInfo` delegation; not + part of this one-conjunct fix. + +## E2E Tests + +- paimon: existing `test_paimon_deletion_vector_oss` (asserts `deleteFileNum` present) unchanged — gated + (`enablePaimonTest=false` default), not run locally. +- maxcompute: no regression test asserts `backends:` for maxcompute (review §R3), and maxcompute e2e needs + a real MaxCompute endpoint (gated/offline). Adding an e2e is out of reach in this environment → **none + added**; documented here (fail-loud, Rule 12). diff --git a/plan-doc/designs/FIX-R3-RESIDUAL-summary.md b/plan-doc/designs/FIX-R3-RESIDUAL-summary.md new file mode 100644 index 00000000000000..9d2709a013cfaa --- /dev/null +++ b/plan-doc/designs/FIX-R3-RESIDUAL-summary.md @@ -0,0 +1,67 @@ +# FIX-R3-RESIDUAL — Summary + +> Source finding: `reviews/P6-paimon-fullpath-cleanroom-2026-06-18.md` §R3 (MINOR, regression). +> Design: `FIX-R3-RESIDUAL-design.md`. Design red-team: `wf_3518653b-3cb` (3 lenses, finder→verifier). + +## Problem + +`PluginDrivenScanNode.getNodeExplainString` (the generic SPI scan node) re-emitted the VERBOSE per-backend +`backends:` block (per-file path lines + `dataFileNum/deleteFileNum/deleteSplitNum`) **only when** +`"paimon".equals(catalog.getType())`. The parent `FileScanNode` emits it **unconditionally** under +`VERBOSE && !isBatchMode()`. + +## Root Cause + +The override reimplements the `FileScanNode` explain body (custom format, no `super` call) and re-emits each +inherited line by hand. The VERBOSE backends re-emission was wrongly conjoined with a paimon source-name +gate (FIX-PAIMON-EXPLAIN-GAP `d4526013364`), instead of mirroring the parent gate verbatim. Effects: +1. **MaxCompute (and trino-connector) VERBOSE EXPLAIN regression** — both legacy nodes + (`MaxComputeScanNode`/`TrinoConnectorScanNode extends FileQueryScanNode`) inherited the unconditional + block; after SPI cut-over they route through `PluginDrivenScanNode` and lost it. +2. **Layering violation** — a hardcoded source-name branch in the connector-agnostic generic node (project + rule: emit universal `FileScanNode` info unconditionally; delegate connector-specific via the SPI). +3. **False inline comment** claiming the gate kept "es/jdbc/max_compute VERBOSE output byte-unchanged". + +## Fix + +`fe/fe-core/.../datasource/PluginDrivenScanNode.java` — removed the +`&& "paimon".equals(desc.getTable().getDatabase().getCatalog().getType())` conjunct, leaving +`if (detailLevel == TExplainLevel.VERBOSE && !isBatchMode())` (identical to the parent gate), and rewrote +the inline comment to state the unconditional-universal rationale. `paimonNativeReadSplits` stays behind the +`ConnectorScanPlanProvider.appendExplainInfo` delegation (unchanged). + +### Scope (corrected by red-team — broader than the review's "maxcompute" framing) + +`SPI_READY_TYPES = {jdbc, es, trino-connector, max_compute, paimon}` all route through this node. The fix +emits the block for all five: +- **paimon:** unchanged (still emitted; byte-identical). +- **maxcompute, trino-connector:** **restored** pre-cutover legacy behavior. +- **es, jdbc:** **new** (harmless) VERBOSE output — the rule-mandated, consistent choice; same category as + the sibling `partition=N/M` / `pushdown agg=` lines already emitted unconditionally for them. Paths render + synthetic (`es:///`, `jdbc://virtual`); NPE-safe (`PluginDrivenSplit extends FileSplit` → + always a `FileScanRange`; `getDeleteFiles` null-guarded). + +Out of scope (flagged, not fixed): the sibling `"es_http".equals(...file_format_type)` `ES terminate_after:` +gate (R3-LAYER-2) — survives the rule literally (file-format key, not `getType()`); separate residual. + +## Tests + +New `PluginDrivenScanNodeVerboseExplainTest` (3 tests, `CALLS_REAL_METHODS` + `Deencapsulation` partial-node +pattern, empty `scanRangeLocations` so the loop is skipped and only the bare `backends:` header prints): +- `verboseEmitsBackendsBlockForNonPaimonConnector` (`max_compute`, VERBOSE → contains `backends:`). +- `verboseEmitsBackendsBlockForPaimon` (parity — paimon still emits). +- `nonVerboseOmitsBackendsBlock` (NORMAL → no `backends:`; pins the surviving VERBOSE gate). + +**RED→GREEN verified empirically:** with the gate re-added, `verboseEmitsBackendsBlockForNonPaimonConnector` +fails (actual `max_compute` output has no `backends:`); with the gate removed, all 3 pass. The negative +NORMAL-level test passing proves `backends:` is genuinely conditional, so the positive assertions are +meaningful. + +## Result + +- `PluginDrivenScanNode*Test` (all classes incl. the new one) GREEN; fe-core compiles; checkstyle clean + (validate phase). Build cache disabled. +- Behavioral change is **diagnostic-only** (VERBOSE EXPLAIN text); zero query/data-path impact. No + regression test pins these connectors' VERBOSE EXPLAIN text (red-team + independent grep both empty). +- **e2e:** paimon e2e gated (`enablePaimonTest=false`); maxcompute/es/jdbc VERBOSE-EXPLAIN e2e needs live + endpoints (offline) → none added (documented; fail-loud). diff --git a/plan-doc/designs/fe-property-module-HANDOFF-2026-06-15.md b/plan-doc/designs/fe-property-module-HANDOFF-2026-06-15.md new file mode 100644 index 00000000000000..b1a3a29f5f8903 --- /dev/null +++ b/plan-doc/designs/fe-property-module-HANDOFF-2026-06-15.md @@ -0,0 +1,113 @@ +# fe-property 模块 —— 开发 HANDOFF(storage 首期) + +> 日期:2026-06-15 | 分支:catalog-spi-07-paimon | 设计:`plan-doc/designs/fe-property-module-design-2026-06-15.md` +> 状态:**M1+M2+M3+M4 完成(uncommitted)**;M5 = 本文。 + +## M4 完成(paimon 连接器迁移,strangler-fig 阶段2)—— 2026-06-15 + +**做法=Hybrid(用户决策)**:连接器把 canonical 对象存储别名→`fs.s3a.*/fs.oss.*/fs.cosn.*/fs.obs.*` 翻译**委托给 fe-property**,保留连接器特有的 `paimon.*` 重写 + 原始 `fs./dfs./hadoop.` 透传。 +- `fe-connector-paimon/pom.xml`:加 `fe-property` compile 依赖(plugin-zip assembly 未排除 → 随 fe-foundation 一起 child-first 打包)。 +- `PaimonCatalogFactory.applyStorageConfig`:5 个 `applyCanonical*` 调用 → `StorageProperties.buildObjectStorageHadoopConfig(props).forEach(setter)`;删除 6 个 canonical 方法 + 全部别名数组/默认值/impl 常量 + 4 个仅 canonical 用的 helper(`firstNonBlankOrDefault/anyKeyStartsWith/containsToken/isClassAvailable`)。**文件 988→626 行(−362)**。保留 `firstNonBlank/nullToEmpty/USER_STORAGE_PREFIXES/FS_S3A_PREFIX`(透传/DLF/HMS 仍用)。 +- 新增 fe-property 入口 `StorageProperties.buildObjectStorageHadoopConfig(Map)`:只对象存储(跳过 HDFS/broker/local/http→避开 createAll 的默认 HDFS+checkHaConfig 抛错),无匹配返回空 map(不抛)。 + +**验收**:`PaimonCatalogFactoryTest` **60/60 绿**、fe-property `StoragePropertiesTest` 5/5 绿、`tools/check-connector-imports.sh` PASS、checkstyle 0、`mvn -pl :fe-connector-paimon -am package -Dassembly.skipAssembly=true` EXIT 0。(连接器 HiveConf 须 `package` 阶段=hive-shade;故用 `package -Dassembly.skipAssembly=true` 跑 UT。) + +**平价对齐:起步 57/60,3 处 divergence 按用户决策(保运行时行为=调 fe-property)已修:** +1. `fs.s3a.session.token` 漏 → fe-property `S3Properties` sessionToken 别名补 `AWS_TOKEN`(连接器有、legacy 无)。 +2. `minio.endpoint required` 抛 → 删 `MinioProperties.setEndpointIfPossible` 抛(lenient)。 +3. ak-without-sk 抛 → 删 `AbstractS3CompatibleProperties` 的 region/endpoint 必填抛 + ak/sk 一致性抛,并把 `fs.s3a.endpoint[.region]` 改**有值才发**(match 连接器宽松;conditional emit)。 + +**1 处 test 改(唯一反“保行为不变”的项,已在测试注释说明)**:`buildHadoopConfigurationEmitsCosRegionUnconditionally` 断言 `fs.cosn.bucket.region` 由 `""`→`ap-beijing`。原因=fe-property(=legacy) **从 `cos..myqcloud.com` 端点派生区域**(更正确),连接器旧 re-port 留空是简化;迁移后该 cosn catalog 得到正确区域(良性/更优)。 + +**M4 偏离记录(fail-loud,影响 fe-property 全体消费方)**:为对齐连接器宽松行为,fe-property 的 S3 兼容校验已**放宽**=不再因 region/endpoint 空而抛、不再强制 ak/sk 同设、endpoint/region 空则省略对应 fs.s3a key。这使 fe-property 比 legacy 宽松;未来 fe-core 迁入时若需严格校验需另行评估。 + +**仍未做**:① 引擎启动同步 `PropertyConfigLoader.hadoopConfigDir`/`AzureProperties.azureBlobHostSuffixes` 两静态(默认值对多数部署 OK);② docker e2e(`enablePaimonTest=true`)验 minio/oss/s3/cos/obs/dlf paimon catalog 真实读 + plugin-zip 实际 bundle 了 fe-property/fe-foundation(本地仅 UT+assembly 配置推断);③ 其它连接器(hive/iceberg/hudi)后续同法迁移。 + +## 已迁移连接器审计:es / jdbc / trino / maxcompute —— 是否有同类 storage-property 重复? —— 2026-06-15 + +**结论:四个都没有 storage-property 重复逻辑,无需迁移到 fe-property。** + +证据(`fe/fe-connector/fe-connector-{es,jdbc,trino,maxcompute}/src/main` 全量扫描): + +| 连接器 | 主类数 | `fs.s3a`/`fs.oss`/`fs.cosn`/`fs.obs`/`S3AFileSystem` | `StorageProperties`/`hadoop.conf.Configuration`/`applyCanonical*` | 引擎 storage 桥(`getBackendStorageProperties`/`normalizeStorageUri`/`vendStorageCredentials`) | 对象存储凭据别名(`minio.`/`s3.access`/`oss.access`/`AWS_ACCESS`) | 结论 | +|---|---|---|---|---|---|---| +| **es** | 20 | 无 | 无 | 无 | 无 | ES REST 直连(hosts/user/password/ssl/keyword_sniff/mapping),不读对象存储数据文件 → 无重复 | +| **jdbc** | 26 | 无 | 无 | 无 | 无 | JDBC 直连(jdbc_url/driver_url/driver_class/user/password/connection_pool_*),不读对象存储 → 无重复 | +| **trino** | 13 | 无 | 无 | 无 | 无 | Trino 元连接器,存储交由 Trino 自身连接器;唯一 hadoop 命中=注释 → 无重复 | +| **maxcompute** | 15 | 无 | 无 | 无 | 无 | ODPS 直连。`mc.access_key`/`mc.endpoint`/`mc.region`/`mc.session_token` 是 **ODPS SDK 凭据**(com.aliyun.odps),**非对象存储**;fe-property 不覆盖 ODPS。`bucket` 命中=MaxCompute tunnel 分片号,非存储桶 → 无重复 | + +**为什么**:这 4 个都是"直连活系统"连接器(ES / 关系库 / Trino / ODPS),数据不来自 S3/OSS 上的 parquet/orc 文件,所以从不构建 `fs.s3a.*` Hadoop storage config——这正是 paimon `applyStorageConfig` 那类重复的来源。`*ConnectorProperties` 都是纯连接器域常量持有类。 + +**真正会有该重复的是"读对象存储数据文件的湖仓连接器"**:paimon(已迁,本次)、以及 **hive / iceberg / hudi**(不在本次 4 个名单内,是后续 M-next 的候选;它们若有 `applyStorageConfig`-类手抄段,同法 Hybrid 迁移)。 + +--- + +## (以下为 M1-M3 原始 HANDOFF) + +## 1. 已完成(验证态) + +- **M1 模块骨架**:新建 `fe/fe-property`(artifactId `fe-property`,包 `org.apache.doris.property[.storage|.common]`);注册进 `fe/pom.xml` ``(fe-foundation 之后)+ dependencyManagement。 +- **M2 拷贝并重适配 storage**:24 个源文件搬入并改造(见 §3 改造清单)。 +- **M3 测试**:`StoragePropertiesTest` 5 用例(MinIO→fs.s3a.* 映射、MinIO→AWS_* 映射、S3 选型+URI 归一化、guessIsMe 顺序、HTTP 空配置)。 + +**验收证据:** +- `mvn -pl fe-property -am compile`:成功;**checkstyle 0**。 +- `mvn -pl fe-property -am package`:`doris-fe-property.jar`(96KB,26 class);`unzip -l` **不含** `org/apache/hadoop`、`software/amazon`、`com/amazonaws`、`org/apache/iceberg`、`org/apache/paimon`、`org/apache/hudi` —— **重依赖不进 jar ✓**。 +- `mvn -pl fe-property -am test`:`Tests run: 5, Failures: 0, Errors: 0, Skipped: 0`(surefire 报告确认真跑,已 `-Dmaven.build.cache.enabled=false`)。 +- fe-core 旧 `datasource/property` **零改动**(两套并存)。 + +## 2. 对外 API(连接器消费契约) + +```java +StorageProperties sp = StorageProperties.createPrimary(rawProps); // 或 createAll(...) +sp.getType(); // Type 枚举 +Map fsConf = sp.getHadoopConfigMap(); // fs.s3a.*/fs.cosn.*/fs.azure.*/dfs.* —— 连接器灌进自己的 Configuration +Map beProps = sp.getBackendConfigProperties(); // AWS_*/hadoop.* —— 发 BE +sp.validateAndNormalizeUri("s3a://b/k"); // -> "s3://b/k" +// + 各子类类型化 getter(getEndpoint/getRegion/getAccessKey/...) +``` +依赖:**仅 fe-foundation**(@ConnectorProperty 引擎/ParamRules/StoragePropertiesException)+ commons-lang3 + commons-collections4 + guava + log4j-api + **hadoop-common(provided)**。 + +## 3. 改造清单(相对 fe-core 旧 storage) + +1. 包 `org.apache.doris.datasource.property.*` → `org.apache.doris.property.*`(连接器 import-gate 禁 `datasource.*`)。 +2. **配置产物 Configuration → Map**:`Configuration hadoopStorageConfig` → `Map hadoopConfigMap` + `getHadoopConfigMap()`;所有 `conf.set` → `map.put`;FS impl 全是字符串字面量(无 hadoop 类型)。null map = 该后端无 hadoop 配置(如 HTTP),保留旧 skip 语义。 +3. `UserException`(fe-common) → `StoragePropertiesException`(fe-foundation,unchecked);S3URI 的 `new UserException(throwable)` → `(msg, cause)`。 +4. **S3Properties 剥离 fe-core 云/存储策略机器**:删 `getObjStoreInfoPB`(proto)、`getS3TStorageParam`(thrift)、`getCredProviderTypePB/getTCredProviderType`、`requiredS3Properties/checkRequiredProperty/requiredS3PingProperties/convertToStdProperties/optionalS3Property`(DdlException)、`getAwsCredentialsProvider V1/V2`(aws-sdk)、`Env` 内类 + 云常量。连接器自行用自带 SDK 构造凭据/PB。 +5. `getAwsCredentialsProvider`(aws-sdk) 从 AbstractS3Compatible + OSS/COS/OBS/GCS 移除;保留类型化凭据 getter + `AwsCredentialsProviderMode` 枚举(纯枚举)。 +6. **HdfsProperties/HdfsCompatibleProperties 去鉴权对象**:删 `HadoopAuthenticator` 构造 + `hadoopAuthenticator` 字段/getter(鉴权是连接器/引擎职责,走 `ConnectorContext.executeAuthenticated`);HDFS 仍解析 kerberos 属性进 map。 +7. **fe-common Config 解耦**:`Config.hadoop_config_dir` → `PropertyConfigLoader.hadoopConfigDir`(可设静态,默认 `$DORIS_HOME/plugins/hadoop_conf/`);`Config.azure_blob_host_suffixes` → `AzureProperties.azureBlobHostSuffixes`(可设静态,内联默认 8 项)。 +8. **loadConfigFromFile 保留并迁移**:新建 `PropertyConfigLoader`(hadoop `Configuration` 解析 XML,**hadoop-common=provided**);`ConnectionProperties.loadConfigFromFile` 改用它,仍返回 Map。 +9. `HdfsClientConfigKeys`(hadoop-hdfs-client)4 常量内联进 HdfsPropertiesUtils。 +10. `S3URI` 拷入 `org.apache.doris.property.storage`。 +11. `HttpProperties` 误引 `org.apache.hudi...MapUtils` → `commons-collections4.MapUtils`(`isNullOrEmpty`→`isEmpty`)。 + +## 4. 偏离设计/需注意(fail-loud) + +- **hadoop-common 是 provided(非"零 hadoop")**:设计文档曾期望零 hadoop;因首期纳入 `loadConfigFromFile`(须 hadoop 解析 XML 配置文件)而保留为 provided。**仍满足硬约束**(不进 jar,已验证)。唯一 hadoop 用处=`PropertyConfigLoader`/`loadConfigFromFile`;其余全 hadoop-free。若要彻底零 hadoop,可把 loadConfigFromFile 改注入式 loader(引擎提供)。 +- **S3 v2 凭据版本开关未移植**:`S3Properties.initializeHadoopStorageConfig` 只发默认(v1)assumed-role 配置(provider 类用 FQN 字符串硬编码)。`Config.aws_credentials_provider_version=v2` 的分支属 fe-core Config 行为,连接器场景不适用。 +- **引擎需在启动时同步两个可设静态**(否则用默认值):`PropertyConfigLoader.hadoopConfigDir = Config.hadoop_config_dir`、`AzureProperties.azureBlobHostSuffixes = Config.azure_blob_host_suffixes`。首期未接(默认值对绝大多数部署正确)。 +- **S3Properties 剥离的云/存储策略方法**:仅 fe-core legacy 调用,连接器不需要;保留在 fe-core 旧类(并存)。 + +## 5. 下一步(M4:paimon 连接器迁移,strangler-fig 阶段2) + +1. `fe-connector-paimon` pom 加 `fe-property` 依赖;plugin-zip assembly include `fe-property`(纯小 jar)。 +2. `PaimonCatalogFactory`:删 `applyStorageConfig`/`applyCanonicalMinioConfig`/OSS/COS/OBS 块/`MINIO_*_ALIASES` 重抄段,改 `StorageProperties.create(props).getHadoopConfigMap().forEach(conf::set)`。 +3. `tools/check-connector-imports.sh` 通过(fe-property 在允许包根)。 +4. paimon minio/oss/s3 回归(`external_table_p0/paimon`)证明重复消除 + 无行为回归。 +5. 引擎启动期同步 §4 的两个静态。 +6.(可选)更全的逐后端平价 sweep:新 `getHadoopConfigMap()`/`getBackendConfigProperties()` vs fe-core 旧 `getHadoopStorageConfig()`/`getBackendConfigProperties()` 逐键断言。 + +## 6. 文件清单(新增) + +``` +fe/fe-property/pom.xml +fe/fe-property/src/main/java/org/apache/doris/property/ + ConnectionProperties.java PropertyConfigLoader.java + common/AwsCredentialsProviderMode.java + storage/ (StorageProperties, AbstractS3CompatibleProperties, ObjectStorageProperties, + S3/OSS/COS/OBS/Minio/GCS/Ozone/Hdfs/HdfsCompatible/OSSHdfs/Azure/Broker/Local/Http Properties, + S3PropertyUtils, HdfsPropertiesUtils, AzurePropertyUtils, S3URI, exception/AzureAuthType) +fe/fe-property/src/test/java/org/apache/doris/property/storage/StoragePropertiesTest.java +fe/pom.xml (modules + dependencyManagement: +fe-property) +``` diff --git a/plan-doc/designs/fe-property-module-design-2026-06-15.md b/plan-doc/designs/fe-property-module-design-2026-06-15.md new file mode 100644 index 00000000000000..66411bb7f65312 --- /dev/null +++ b/plan-doc/designs/fe-property-module-design-2026-06-15.md @@ -0,0 +1,196 @@ +# 设计文档:独立 `fe-property` 模块(storage 优先,strangler-fig 并存迁移) + +> 日期:2026-06-15 | 分支:catalog-spi-07-paimon +> 前置研究:`plan-doc/reviews/property-module-extraction-feasibility-2026-06-14.md` +> 本设计遵循"先研究、后设计文档、批准后编码"流程。**编码尚未开始,待批准。** + +--- + +## 1. 目标(Goals) + +1. 新建独立 FE 模块 **`fe-property`**,承载"数据源属性的**整理 + 校验 + 归一化**"逻辑,**产出归一化结果**(类型化对象 + 归一化 Map),供各 connector / fe-core 复用。 +2. **彻底消除连接器侧的属性解析重复**:当前 `PaimonCatalogFactory` 因无法 import fe-core 的 `StorageProperties`,**手工重抄**了 `applyStorageConfig` / `applyCanonicalMinioConfig` / OSS/COS/OBS 块 / `MINIO_*_ALIASES`——这正是 `47bfe201c7c` minio bug 的来源。新模块让连接器改为直接调用,杜绝漂移。 +3. **最终 jar 不含重型依赖**(hadoop/hdfs/aws/iceberg/paimon)。本设计通过"只产出 Map、不构造 live 对象"从根上让这些重依赖**根本不进入**新模块(比 `provided` 更彻底)。 +4. **strangler-fig 并存**:**不动** fe-core 现有 `datasource/property`;新模块拷贝并重适配相关代码,两套并存;连接器逐个迁移到新模块;全部迁完后再删 fe-core 旧代码。 + +## 2. 非目标(Non-Goals) + +- 不重写/不删除 fe-core 现有 `org.apache.doris.datasource.property.*`(本期零改动)。 +- 首期**不**纳入 `metastore` 与 `fileformat`(仅 `storage`)。metastore 留作下一迭代(同样的纯解析范式),fileformat 不在连接器 catalog 属性范畴,暂不规划。 +- 新模块**不**构造任何 live 对象:不产出 Hadoop `Configuration`、不产出 aws-sdk `AwsCredentialsProvider`、不产出 iceberg/paimon `Catalog`、不产出 thrift `TS3StorageParam` / proto `ObjectStoreInfoPB`。这些由消费方(连接器/BE/fe-core)用各自已有的重型依赖构建。 +- 不改 BE。 + +## 3. 关键决策(已与用户确认) + +| 编号 | 决策 | 选择 | +|---|---|---| +| D1 | 对外形态 | **类型化对象 + 归一化 Map**:每个数据源解析成不可变 POJO(仅 String/基本类型字段),既有 getter,又提供 `getHadoopConfigMap()` / `getBackendPropertiesMap()` | +| D2 | 首期范围 | **仅 `storage`**(S3/OSS/COS/OBS/MinIO/GCS/Ozone/HDFS/OSS-HDFS/Azure/Broker/Local/Http) | +| D3 | 绑定/解析引擎 | **复用 fe-foundation 的 `@ConnectorProperty` 引擎**(`ConnectorPropertiesUtils.bindConnectorProperties` + `ParamRules`)。现 `StorageProperties` 已在用,零新增成本 | +| D4(派生)| 包根 | **`org.apache.doris.property`**(不可用 `datasource.*`,见约束 C1) | +| D5(派生)| 依赖上限 | 仅 `fe-foundation` + commons-lang3 + guava;**不依赖** fe-common/fe-core/fe-thrift/fe-grpc/hadoop/aws(见约束 C2) | + +## 4. 约束(Constraints) + +- **C1(包根硬约束)**:连接器 import-gate `tools/check-connector-imports.sh` 禁止连接器 import `org.apache.doris.(catalog|common|datasource|qe|analysis|nereids|planner).*`。因此新模块**不能**放在 `org.apache.doris.datasource.property`(`datasource.*` 被禁),改用 **`org.apache.doris.property.storage`**。`foundation.*` 不在黑名单,可放心依赖。 +- **C2(依赖上限)**:要让连接器可直接 import,新模块不得依赖被 gate 禁止的模块。即**不能用 `fe-common`**(无 `UserException` → 改用 `foundation.StoragePropertiesException`)。 +- **C3(no split-package)**:新包根 `org.apache.doris.property.*` 与旧 `org.apache.doris.datasource.property.*` 完全不同,两 jar 不会劈分同一包,并存合法。 +- **C4(连接器自带重依赖)**:连接器 plugin-zip 各自 child-first 自带 hadoop/aws/paimon。新模块产出的是 Map,连接器用自带的 hadoop/aws 把 Map 灌进 Configuration / 构造客户端。 + +## 5. 架构(Architecture) + +### 5.1 模块依赖图(首期) + +``` +fe-foundation (零重依赖:@ConnectorProperty 引擎 / ParamRules / StoragePropertiesException) + ▲ + │ compile + fe-property ←── 新模块(org.apache.doris.property.storage),仅 +commons-lang3/guava + ▲ + │ compile(连接器逐个加依赖) +fe-connector-paimon / -hive / -iceberg / …(各自带 hadoop/aws/SDK) + …(最终)fe-core 也可依赖 fe-property,迁完后删 fe-core 旧 property +``` + +构建顺序:`fe/pom.xml` 的 `` 把 `fe-property` 置于 `fe-foundation` 之后、`fe-connector` 之前;`fe-property` 只依赖 `fe-foundation`,Maven 依赖图自然满足。 + +### 5.2 并存与迁移(strangler-fig) + +``` +阶段0(现状): 连接器手工重抄 applyStorageConfig/minio ←─ 漂移源 +阶段1(本设计): 新建 fe-property(storage)。两套并存,fe-core 旧 property 不动。 +阶段2: paimon 连接器改用 fe-property(删 applyStorageConfig 重抄段)→ 回归验证。 +阶段3: hive/iceberg/hudi/... 连接器逐个迁移。 +阶段4: fe-core 非 SPI 旧 catalog 迁到 SPI 后亦改用 fe-property。 +阶段5: 所有使用方迁完 → 删除 fe-core org.apache.doris.datasource.property.storage。 +``` + +## 6. API / 接口设计 + +### 6.1 新 `StorageProperties` 契约(`org.apache.doris.property.storage`) + +保留(语义照搬旧类,仅换包/换异常): +- `static StorageProperties create(Map props)` —— 主存储(= 旧 `createPrimary`) +- `static List createAll(Map props)` +- `Type getType()` / `String getStorageName()` +- `String validateAndNormalizeUri(String url)` / `String validateAndGetUri(Map loadProps)` +- `@ConnectorProperty` 字段 + getter(endpoint/region/accessKey/secretKey/sessionToken/usePathStyle/maxConnections/...,按各子类) +- `enum Type` / `FS_*_SUPPORT` 常量 / `guessIsMe(...)` 工厂探测 + +**替换(核心改造)——把 live 对象换成 Map:** + +| 旧(fe-core,产出 live 对象/重类型) | 新(fe-property,产出 Map/纯类型) | +|---|---| +| `Configuration getHadoopStorageConfig()` | `Map getHadoopConfigMap()`(fs.s3a.*/fs.oss.*/fs.cosn.*/fs.obs.*/fs.azure.*/dfs.* 键值;连接器自行 `conf.set`) | +| `protected void initializeHadoopStorageConfig()`(写 Configuration 字段) | `protected void buildHadoopConfigMap()`(写内部 `Map` 字段) | +| `AwsCredentialsProvider getAwsCredentialsProvider()`(aws-sdk 类型) | **移除**;暴露类型化凭据 getter + `AwsCredentialsProviderMode` 枚举,连接器用自带 aws-sdk 构造 provider | +| `S3Properties.getObjStoreInfoPB()`(proto)/`getS3TStorageParam()`(thrift) | **不移植**(fe-core 云上/存储策略专属,留在旧 property) | +| `throws UserException`(fe-common) | `throws StoragePropertiesException`(fe-foundation) | + +保留并仍返回 Map(旧已是 Map,直接搬): +- `Map getBackendConfigProperties()` → 改名/保留为 `getBackendPropertiesMap()`(AWS_*/hadoop.* 规范键,给 BE) +- `Map getBackendConfigProperties(Map runtime)`(叠加运行时) + +### 6.2 连接器消费样例(minio 重复消失) + +```java +// 旧(PaimonCatalogFactory,手工重抄 ~150 行 applyStorageConfig/applyCanonicalMinioConfig/...) +applyStorageConfig(props, conf::set); + +// 新 +StorageProperties sp = StorageProperties.create(props); +sp.getHadoopConfigMap().forEach(conf::set); // fs.s3a.*/fs.oss.* 等,由 fe-property 统一推导 +// 需要发给 BE 时: +Map beProps = sp.getBackendPropertiesMap(); +``` + +## 7. 数据流(Data Flow) + +``` +用户 catalog/load props (raw Map) + │ + ▼ fe-property: StorageProperties.create(raw) +@ConnectorProperty 反射绑定 + ParamRules 校验 + guessIsMe 选型 + URI 归一化 + │ + ├── getHadoopConfigMap() → 连接器灌进自带 Configuration(catalog FS / SDK 客户端) + ├── getBackendPropertiesMap() → 连接器发给 BE(AWS_*/hadoop.* 规范键) + └── 类型化 getter → 连接器按需自取(如构造 aws provider / 取 endpoint) +``` + +## 8. 依赖与打包(pom) + +`fe-property/pom.xml`(仿 fe-foundation/fe-catalog 头): +- parent=`fe`(`${revision}`),packaging=jar,finalName=`doris-fe-property`,test-jar、javadoc skip、release source profile。 +- **compile**:`fe-foundation`(`${project.version}`)、`commons-lang3`、`guava`、`log4j-api`。 +- **可选 provided**:`hadoop-hdfs-client`(仅 `HdfsPropertiesUtils` 用 `HdfsClientConfigKeys` 的 4 个常量;二选一:①`provided`(不打包);②直接内联这 4 个 key 字符串,连这个依赖都省掉——**推荐内联**,使新模块零 hadoop 依赖)。 +- **无** hadoop-common / aws-sdk / iceberg / paimon / fe-common / fe-thrift / fe-grpc。 +- `fe/pom.xml`:`` 加 `fe-property`(fe-foundation 之后);dependencyManagement 加 `fe-property` 条目。 +- 连接器 plugin-zip 的 assembly 需 include `fe-property`(纯小 jar,无重依赖可打包)。 + +> 由于新模块本就不引入重型依赖,用户"重依赖不进 jar"的约束被**结构性满足**,无需依赖 `provided` 排除技巧。 + +## 9. 与现有 `ConnectorContext` 桥的关系 + +fe-core 现已在 `ConnectorContext` 暴露 `getBackendStorageProperties()` / `normalizeStorageUri()` / `vendStorageCredentials()`——让连接器"回调引擎做归一化"以绕开 import-gate。`fe-property` 让连接器**直接做**归一化(无需回调引擎),未来可逐步替代这些桥方法(含 vended:把 per-table token map 直接喂 `StorageProperties.create`)。**本期不动这些桥**,仅新增 fe-property 并迁移 `applyStorageConfig` 重抄段;桥方法待全面迁移后再评估下线。 + +## 10. 边界与坑(Edge Cases) + +1. **`S3URI`**:旧 `S3PropertyUtils` 依赖 fe-core 的 `common.util.S3URI`。新模块**拷贝** `S3URI` 进 `org.apache.doris.property.storage`(纯 java,仅把 UserException 换 StoragePropertiesException)。 +2. **HdfsClientConfigKeys**:见 §8,推荐内联 4 个 key 常量,避免 hadoop 依赖。 +3. **Azure OAuth**:旧 `AzureProperties` OAuth 分支构造 `Configuration`;新版产出 `fs.azure.account.oauth.*` Map。 +4. **鉴权(kerberos)**:旧 `HdfsProperties` 构造 `HadoopAuthenticator`(fe-common)。新版**只解析**鉴权属性(auth type / principal / keytab / 归一化 hadoop.* Map),**不构造** authenticator——authenticator 是运行时对象,由连接器/引擎(`ConnectorContext.executeAuthenticated`)负责。借此甩掉 fe-common 依赖。 +5. **`guessIsMe` 选型顺序**:MinIO 让位 Azure/COS/OSS/S3 的探测顺序需原样保留(否则误判后端)。 +6. **`HttpProperties` 误引**:旧版误 import `org.apache.hudi...MapUtils`;新版改 commons-collections4,避免拖 Hudi。 +7. **凭据 provider 构造下移**:`AwsCredentialsProviderFactory`(aws-sdk)不进 fe-property;连接器需要 provider 时自建(多数场景连接器只需把 Map 灌 Configuration / 发 BE,并不需要 provider 对象)。 + +## 11. 测试与回滚 + +- **平价测试(关键)**:对每个 storage 子类,用同一组 raw props 跑"新 `getHadoopConfigMap()`/`getBackendPropertiesMap()`" vs "旧 `getHadoopStorageConfig()` 转成 Map / `getBackendConfigProperties()`",断言**键值完全一致**(含 minio/oss/cos/obs/azure/hdfs)。这是防漂移的核心闸。 +- **单元测试**:选型 `guessIsMe` 顺序、URI 归一化、ParamRules 校验、各 alias 解析、minio.* 专属前缀检测。 +- **连接器迁移验证**:paimon 改用 fe-property 后,跑 `external_table_p0/paimon` 回归(minio/oss/s3 catalog 读)。 +- **回滚**:strangler-fig 天然可回滚——任一连接器迁移出问题,单独回退该连接器到旧 `applyStorageConfig`,fe-property 与旧 property 并存互不影响。 + +## 12. 风险与备选 + +- **风险1:平价漂移**。新 Map 推导若与旧 Configuration 落键有差→连接器/BE 行为变。缓解=§11 平价测试逐键断言;首迁 paimon 用真实 minio/oss 回归。 +- **风险2:代码拷贝双维护**。并存期 storage 逻辑两份。缓解=并存窗口尽量短、优先迁 paimon 验证范式、平价测试钉死一致;新代码为权威,旧代码进入"只读冻结"。 +- **风险3:plugin-zip 漏打 fe-property**。缓解=迁移连接器时同步改 assembly,加一条 smoke(plugin 加载 `org.apache.doris.property.storage.StorageProperties` 不 ClassNotFound)。 +- **备选A(被否)**:原地 lift-and-shift 整个 datasource.property → 循环依赖 + vendored glue + 重型 SDK,已证不可行(见前置研究报告)。 +- **备选B**:把新代码直接塞进 fe-foundation。否决——会破坏 fe-foundation 零依赖基座(即使首期 storage 很轻,后续 metastore 会重)。保持 fe-foundation 为纯基座,fe-property 为其上一层。 + +## 13. 有序 TODO 列表 + +**M1 — 模块骨架** +1. 新建 `fe/fe-property/pom.xml`(仿 fe-foundation;compile=fe-foundation+commons-lang3+guava+log4j-api)。 +2. `fe/pom.xml` `` 加 `fe-property`(fe-foundation 后);dependencyManagement 加条目。 +3. 建包 `org.apache.doris.property.storage`(+ `storage` 内 util)。 + +**M2 — 拷贝并重适配 storage(核心)** +4. 拷贝 `ConnectionProperties`(基类,去掉 `loadConfigFromFile` 对 fe-common 的依赖或改 foundation 等价)→ 新包。 +5. 拷贝 `StorageProperties` 抽象类:把 `getHadoopStorageConfig():Configuration` 改 `getHadoopConfigMap():Map`、`initializeHadoopStorageConfig()` 改 `buildHadoopConfigMap()`、`UserException`→`StoragePropertiesException`、`getBackendConfigProperties`→`getBackendPropertiesMap`。 +6. 拷贝 `AbstractS3CompatibleProperties` + `ObjectStorageProperties` + `Abstract*`:移除 `getAwsCredentialsProvider()`(aws-sdk),改产出凭据 getter;S3 系 fs.s3a.* 改写进 Map。 +7. 拷贝 13 个具体类(S3/OSS/COS/OBS/MinIO/GCS/Ozone/Hdfs/OSSHdfs/Azure/Broker/Local/Http):逐个把 `conf.set(...)` 改为 `map.put(...)`;保留 `guessIsMe` 顺序;修 HttpProperties 的 hudi 误引。 +8. 拷贝 util:`S3PropertyUtils`、`HdfsPropertiesUtils`(内联 `HdfsClientConfigKeys` 4 常量)、`AzurePropertyUtils`、**`S3URI`**(新拷贝)、`exception/AzureAuthType`。 +9. 移除 `S3Properties` 的 proto/thrift 方法(不移植)。 + +**M3 — 测试** +10. 平价测试:每个子类新 vs 旧逐键断言(依赖 fe-core 旧类做基准,可放 fe-property 的 test 或一个临时对照 test)。 +11. 单元测试:guessIsMe 顺序、URI 归一化、minio.* 检测、ParamRules、alias。 +12. checkstyle 0 告警。 + +**M4 — 首个连接器迁移(paimon,验证范式)** +13. `fe-connector-paimon` 加 `fe-property` 依赖;plugin-zip assembly include fe-property。 +14. `PaimonCatalogFactory`:删 `applyStorageConfig`/`applyCanonicalMinioConfig`/OSS/COS/OBS 块/`MINIO_*_ALIASES`,改为 `StorageProperties.create(props).getHadoopConfigMap().forEach(conf::set)`。 +15. 跑 import-gate(`tools/check-connector-imports.sh`)确认无违规;跑 paimon 回归(minio/oss/s3)。 + +**M5 — 文档与收尾** +16. 更新 plan-doc:迁移路线、并存说明、下线计划(metastore 下一迭代、桥方法评估)。 +17. 记录"旧 datasource.property.storage 冻结、以 fe-property 为权威"。 + +## 14. 验收标准(强约束,可独立 loop 验证) + +- `mvn -pl fe/fe-property -am package` 成功;`unzip -l doris-fe-property.jar` **不含** `org/apache/hadoop/**`、`software/amazon/**`、`org/apache/iceberg/**`、`org/apache/paimon/**`。 +- 平价测试全绿(新 Map ≡ 旧落键)。 +- `tools/check-connector-imports.sh` 通过;paimon 连接器删除 `applyStorageConfig` 后编译通过。 +- paimon minio/oss/s3 catalog 回归通过(证明重复消除且无行为回归)。 +- fe-core 旧 `datasource/property` 零改动(并存)。 diff --git a/plan-doc/designs/metastore-storage-property-refactor-design-2026-06-17.md b/plan-doc/designs/metastore-storage-property-refactor-design-2026-06-17.md new file mode 100644 index 00000000000000..8c675665130929 --- /dev/null +++ b/plan-doc/designs/metastore-storage-property-refactor-design-2026-06-17.md @@ -0,0 +1,359 @@ +# fe-core / fe-connector / fe-filesystem 属性体系重构设计方案(paimon 优先) + +> 目标:把 fe-core 的 **Storage Property** 全部收口到 `fe-filesystem`、把 **MetaStore Property** 收口到 `fe-connector` 的新 SPI/API,最终从 fe-core 彻底删除两者,并淘汰临时模块 `fe-property`。 +> 设计聚焦 **paimon** 连接器(唯一已实质迁移属性的连接器),但 SPI 形状要让 hive/hudi/iceberg 后续直接套用、不再重抄。 +> 日期:2026-06-17 | 方法:8-agent 现状取证 + 关键事实 `grep` 复核(见文末附录)。配套背景报告:`plan-doc/reviews/fe-filesystem-storage-spi-review-2026-06-17.md`。 + +--- + +## 0. 决策摘要(已与架构师确认) + +| # | 决策点 | 选定方案 | +|---|---|---| +| ① | MetaStore Property SPI/API 模块归属 | **新建 `fe-connector-metastore-api` + `fe-connector-metastore-spi` 模块对**,镜像 fe-filesystem 的 api/spi 拆分 | +| ② | 跨引擎连接逻辑去重策略 | **混合**:HMS/DLF/Glue/REST/JDBC 的「连接事实解析器」在 metastore-spi 实现一次;每个连接器只写薄的 catalog adapter 消费这些 facts | +| ③ | 连接器如何获取 Storage Property | **fe-core 在 CREATE CATALOG 入口绑定 `StorageProperties`(用 fe-filesystem 全量+providers),把已绑定对象经 `ConnectorContext` 传给连接器**;连接器只见 `fe-filesystem-api` 接口 | +| ④ | typed MetaStore 属性的绑定机制 | **复用 fe-foundation 的 `@ConnectorProperty` + `ConnectorPropertiesUtils`**(别名优先级 / required / sensitive / matchedProperties 全免费) | +| ⑤ | MetaStore 后端「类型」如何表达(**D-006**) | **api 层不放 per-backend `MetaStoreType` 枚举**;用 `String providerName()` + 能力方法 + `MetaStoreProvider.supports(Map)` 自识别 + ServiceLoader 发现,镜像 `FileSystemProvider`。新增后端零 api/spi 改动 | +| ⑥ | Kerberos 归属(**D-007**) | **新建叶子模块 `fe-kerberos`**(仅 hadoop-auth/common),搬入 fe-common `security.authentication.*` 作唯一真相源,fe-filesystem-hdfs 删自有副本改依赖它。⚠️ 全量去重超出本次 paimon-only 范围,分两步(见 D-007) | +| ⑦ | Vended creds 边界(**D-008**) | **连接器只「抽取」SDK token,fe-core 单点「归一」**(`ctx.vendStorageCredentials` 用 providers 重绑 → BE map);连接器保持 api-only | + +**目标依赖图(终态)** + +``` +fe-foundation (叶子: @ConnectorProperty / ConnectorPropertiesUtils / ParamRules) +fe-extension-spi (叶子: Plugin / PluginFactory) +fe-kerberos (叶子 D-007: security.authentication.* / HadoopAuthenticator / Kerberos; 仅 hadoop-auth/common) + ▲ ▲ ▲ + │ │ │ +fe-filesystem-api (纯 JDK 契约) │ (fe-kerberos 被 fe-filesystem-hdfs / fe-connector-* / fe-common / fe-core 共用) + ▲ ▲ │ + │ │ │ +fe-filesystem-spi fe-connector-api ──► fe-thrift(provided) + ▲ (providers s3/oss/...) ▲ ▲ + │ (fe-filesystem-hdfs ──► fe-kerberos) │ + │ fe-connector-spi fe-connector-metastore-api ──► fe-foundation, fe-filesystem-api + │ ▲ ▲ + │ │ │ (无 per-backend 枚举 D-006) + │ fe-connector-metastore-spi (共享后端 fact 解析器 + MetaStoreProvider SPI/ServiceLoader) + │ ▲ + │ fe-connector-paimon / -iceberg / -hive / ... (薄 adapter, 各注册 MetaStoreProvider) + │ +fe-core ──► fe-filesystem(全量, 含 providers) + fe-connector(api/spi/metastore-spi 经由连接器) + fe-kerberos + +约束: fe-connector-* 任何模块 ──╳──► fe-core (CI gate 强制) + fe-filesystem-* 任何模块 ──╳──► fe-core / fe-connector + fe-kerberos ──╳──► fe-core / fe-connector / fe-filesystem (纯叶子, 仅 hadoop) +``` + +终态边核对(与用户目标逐条对齐): +- `fe-core → fe-connector + fe-filesystem` ✔(fe-core 已依赖 `fe-connector-api/spi`,且依赖 `fe-filesystem-api/spi/local`) +- `fe-connector → 仅 fe-filesystem-api` ✔(通过 `fe-connector-spi` 的 `ConnectorContext.getStorageProperties(): List` 引入 `fe-filesystem-api` 接口类型;连接器不依赖 fe-filesystem-spi/providers) +- `fe-filesystem → 不依赖 fe-connector/fe-core` ✔(现状已满足,api 纯 JDK) + +### 0.1 本次任务范围(重要 — 已与架构师约定) + +**只做迁移 / 新增,不做破坏性删除;只动 paimon,不动其它连接器。** + +| 范围 | 内容 | +|---|---| +| ✅ 本次做 | 新建 `fe-connector-metastore-api/spi`(仅实现 paimon 用到的后端,后端用 `MetaStoreProvider` 自识别、**无枚举** D-006);新增 `ConnectorContext.getStorageProperties()` 让 fe-core 下发已绑定的 fe-filesystem `StorageProperties`;改造 **paimon** 连接器:storage 改走 fe-filesystem-api、metastore 改走新 SPI;移除 paimon 对 `fe-property` 的依赖边;**新建顶层叶子 `fe-kerberos`(additive)+ 让 paimon 的 HMS kerberos facts 走它(P3a,D-007 步骤 a,paimon-local 不碰 fe-common/fe-filesystem-hdfs)** | +| 🚫 本次**不**做 | **不删除** fe-core 的 `datasource.property.storage` / `datasource.property.metastore` 任何类(hive/hudi/iceberg 仍在用,保持不动);**不修改** hive / hudi / iceberg / es / jdbc / mc / trino 任何连接器;fe-property 物理删除留待后续(本次只断开 paimon 的依赖,使其变为孤儿);**不动 fe-common / fe-filesystem-hdfs 的既有 kerberos 路径**(其收口到 fe-kerberos = P3b follow-up) | +| 🔭 范围外(后续任务) | hive/hudi/iceberg 迁移到新 SPI;**P3b**:fe-common + fe-filesystem-hdfs 收口到 `fe-kerberos`(全量去重、统一 `HadoopAuthenticator` 接口);待所有连接器迁完后从 fe-core 彻底删除两个 property 包、删除 `StoragePropertiesConverter`、物理删除 fe-property 模块 | + +> 即本次的「收口」= **让 paimon 不再经由 fe-core 风格的旧 storage-property 模型(fe-property 是其逐字拷贝)获取存储配置,改为消费 fe-core 经 fe-filesystem-api 下发的 typed `StorageProperties`**;fe-core 旧 property 包整体**原样保留**,其删除是后续全连接器迁完后的独立任务。 + +--- + +## 1. 现状(精炼) + +### 1.1 三套 StorageProperties 并存 +| 树 | 形态 | 现状角色 | +|---|---|---| +| `fe-core` `datasource.property.storage.StorageProperties` | 胖抽象类 | **线上引擎路径**(`CatalogProperty.createAll` + `DefaultConnectorContext` + `StoragePropertiesConverter`);hive/hudi/iceberg + paimon 的 BE 侧都走它 | +| `fe-property` `property.storage.StorageProperties` | 胖抽象类(fe-core 的逐字 re-root 拷贝) | **临时**;唯一消费者是 paimon 连接器的 `PaimonCatalogFactory.buildObjectStorageHadoopConfig` | +| `fe-filesystem-api` `filesystem.properties.StorageProperties` | 瘦接口 + `FileSystemProvider

    ` 绑定 SPI | **目标**,但当前**休眠**(0 个 fe-core 消费者,详见背景报告) | + +### 1.2 MetaStore Property = fe-core 的 (引擎 × 后端) 矩阵 +`org.apache.doris.datasource.property.metastore`:28 文件 ~3624 LOC。 + +- 已存在**共享后端连接契约**:`HMSBaseProperties.of()`、`AliyunDLFBaseProperties.of()`、`AWSGlueMetaStoreBaseProperties.of()`——被 Hive/Iceberg/Paimon 的同后端 leaf 复用。 +- 每个 leaf 很薄,~70–80% 是相同的连接装配,仅 ~20–30% 是引擎特定(建各自 SDK catalog)。 +- **重复实测**:HMS 后端被复制约 **4 次**(`HMSBaseProperties` + `Iceberg/Paimon/HiveHMS*` + 连接器侧 `PaimonCatalogFactory.buildHmsHiveConf`);DLF 的 8 行 `DataLakeConfig.CATALOG_*` 块逐字出现 **3 次**;JDBC 的 `registerJdbcDriver + DriverShim`(~50 行)重复 **2 次**。 + +### 1.3 连接器现状 +- 已迁移 es/jdbc/maxcompute/trino:各自 `XxxConnectorProperties`(纯常量 + `Map.get`),**放弃了 typed 模型**,彼此零复用。 +- paimon(迁移中、唯一实质迁移属性者):`PaimonConnectorProperties`(常量 + `String[]` 别名)+ `PaimonCatalogFactory`(627 LOC 纯函数,**手抄** fe-core 的 `AbstractPaimonProperties` + 每个 `Paimon*MetaStoreProperties` + `HMSBaseProperties.getHiveConf` + `PaimonAliyunDLFMetaStoreProperties.buildHiveConf`)。 +- paimon 的唯一非 connector-api/thrift 的 doris import 是 `org.apache.doris.property.storage.StorageProperties`(fe-property,1 处调用 `buildObjectStorageHadoopConfig`)。**metastore 已与 fe-core 解耦,只剩 storage 这一条边要换。** +- `fe-connector-paimon-{api,backend-hms,backend-rest,backend-aliyun-dlf,backend-filesystem}` 与 iceberg 同名目录:**当前分支上是空的 stale 残留**(仅 `.flattened-pom.xml`,无 src、未被父 pom 引用)。per-backend 拆分只在 `catalog-spi-v20260422` 分支以「**catalog-builder SPI(buildCatalog)**」形态存在,**不是** metastore-property SPI。本方案要新建的是 metastore-property SPI(与 buildCatalog SPI 互补)。 + +### 1.4 组合模型(必须保留的不变量) +`CatalogProperty` 持有**一份** raw map,**独立**惰性派生两者:`MetastoreProperties.create(props)` 与 `StorageProperties.createAll(props)`。二者**正交**:storage 作为参数**传入** metastore 初始化(`initializeCatalog(name, List)`),metastore **从不**把 storage 当字段持有;storage 对 metastore 一无所知。HMS 是自洽的(不吃 storage list);只有 FileSystem/HDFS(Kerberos authenticator)与 DLF(OSS)需要 storage。 + +### 1.5 BE 侧转换(必须留在 fe-core) +- `StorageProperties.getBackendConfigProperties()` → BE 规范 map,`CredentialUtils.getBackendPropertiesFromStorageMap` 汇总。 +- `S3Properties.getS3TStorageParam()` → `TS3StorageParam`、`getObjStoreInfoPB()` → `Cloud.ObjectStoreInfoPB`:**唯一** import thrift/cloud-proto 的存储类。 +- 连接器路径已通过 `ConnectorContext`(`getBackendStorageProperties`/`normalizeStorageUri`/`vendStorageCredentials`/`loadHiveConfResources`/`executeAuthenticated`)把这些委托回 fe-core 的 `DefaultConnectorContext`。 + +--- + +## 2. 目标架构与数据流 + +### 2.1 CREATE CATALOG(静态配置)数据流 —— 决策③ +``` +用户 CREATE CATALOG (raw Map) + │ 入口在 fe-core + ▼ +fe-core: List storageList = FileSystemPluginManager.bindAll(rawMap) // D-009: provider 全量 bind + │ (fe-core 依赖 fe-filesystem 全量,可发现 providers) + ▼ +fe-core: 路由到目标连接器, 经 ConnectorContext 传入: + - List (fe-filesystem-api 接口类型, 已绑定) + - 原始 rawMap + ▼ +fe-connector-paimon (PaimonConnector): + - 用 fe-connector-metastore-api 解析 metastore 属性: + MetaStoreProperties ms = HmsMetastoreBackend.parse(rawMap, storageList) // 共享 fact 解析器 + - 用 storageList 的 toHadoopProperties().toHadoopConfigurationMap() 拿 fs.s3a.* 叠到 HiveConf + - 用 ms.toHiveConfOverrides()/facts 拿 hive.* / dlf.catalog.* 叠到 HiveConf + - 建 paimon Catalog (在 ctx.executeAuthenticated 内, Kerberos doAs 仍由 fe-core) +``` +连接器只 import `fe-filesystem-api`(StorageProperties 接口)+ `fe-connector-metastore-api/spi`,**零 fe-core / 零 fe-property / 零 fe-filesystem-spi**。 + +### 2.2 BE scan(静态凭据)数据流 +``` +连接器 (PaimonScanPlanProvider): + for sp in ctx.getStorageProperties(): + awsMap = sp.toBackendProperties().orElseThrow().toMap() // AWS_* —— fe-filesystem-api 已有 + 把 awsMap 写进 scan range 的 location 属性 (String map, 交给 BE) +fe-core/BE: 由 AWS_* map 组装 TS3StorageParam(thrift 仍在 fe-core S3-RPC 适配层, api 不见 thrift) +``` +→ 取代现有的 `ConnectorContext.getBackendStorageProperties()` 回调(连接器现在自己用 typed 对象算 BE map)。 + +### 2.3 Vended creds(REST/DLF 动态、读时)与 URI 归一化 +- **Vended creds 边界(D-008)**:明确两段—— + - **「抽取」= 连接器职责(SDK 特定)**:token 在读时从活的引擎 SDK 表对象提取,是**任意形状**(`s3.*`/`oss.*`…)。paimon **已落地**于 `PaimonScanPlanProvider.extractVendedToken(table)`(port 自 legacy `PaimonVendedCredentialsProvider.extractRawVendedCredentials`)。后续各连接器各写各的抽取,fe-core 旧 `Paimon/IcebergVendedCredentialsProvider` 随迁移正式下沉(本次不删,D-005)。 + - **「归一」= fe-core 单点(通用)**:raw-token → 统一 BE map 仍走 `ConnectorContext.vendStorageCredentials(rawToken)`:`filterCloudStorageProperties` + `StorageProperties.createAll`(provider **重新绑定**、派生 region/endpoint/后端调优默认)+ `getBackendPropertiesFromStorageMap` → `AWS_*`。这是 api 接口做不到的(需 ServiceLoader 发现 providers);连接器按 D-003 只见 fe-filesystem-api、无 providers,故归一**必须**留 fe-core 单点(无漂移)。**备选**(连接器依赖 fe-filesystem-spi 自做端到端)被否:加重连接器 + 破红线。 +- **URI 归一化**(`oss://`/`cos://` → BE 规范 `s3://`):保持 `ConnectorContext.normalizeStorageUri(...)`(依赖 fe-core `LocationPath`);**可选后续**下沉到 fe-filesystem。 +- **thrift `TS3StorageParam` / `ObjectStoreInfoPB`**:永久留 fe-core(api 是 RPC-neutral)。 + +> 即:**静态、CREATE-CATALOG 时即可定的 → 走 typed `StorageProperties`(连接器自算);动态/RPC/需 provider 发现的 → 留 `ConnectorContext` 委托 fe-core。** 这是决策③「混合」的精确边界。 + +--- + +## 3. 新 SPI/API 设计 + +### 3.1 `fe-connector-metastore-api`(纯契约,依赖 fe-foundation + fe-filesystem-api) + +> **(D-013 修订)** P2-T01 落地时 api 仅依赖 **fe-kerberos**(为 `HmsMetaStoreProperties` 的 `AuthType`/`KerberosAuthSpec` 中立 facts);`fe-foundation`/`fe-filesystem-api` 当前 api 纯接口未直接引用(`@ConnectorProperty` 绑定、`StorageProperties` 入参均在 spi 用),故留待 spi(P2-T02)按需引入,避免 api 声明未用依赖。`AuthType`/`KerberosAuthSpec` 归 fe-kerberos(先于 P2-T01 建,D-013)。 + +镜像 `fe-filesystem-api` 的瘦接口风格,**只暴露中立的 Map / 标量 facts,不暴露 `HiveConf`/Hadoop/SDK 类型**(HiveConf 的实体装配在连接器侧,连接器有 hive-shade)。 + +```java +package org.apache.doris.connector.metastore; + +/** 各连接器自持的、已绑定校验的 metastore 连接属性的公共契约(对标 fe-filesystem 的 StorageProperties)。 */ +public interface MetaStoreProperties { + String providerName(); // 字符串标识 "HMS"/"DLF"/"GLUE"/"REST"/"JDBC"/"FILESYSTEM"(D-006,非枚举) + // ── 横切行为用「能力方法」表达,取代 per-backend 枚举上的 switch(D-006)── + default boolean needsStorage() { return false; } // FileSystem/DLF 需要 storageList;HMS/REST/JDBC 不需要(§1.4) + default boolean needsVendedCredentials() { return false; } // 取代 VendedCredentialsFactory:61 的 getType() switch + default void validate() {} + Map rawProperties(); + Map matchedProperties(); // @ConnectorProperty 实际命中的别名子集 +} + +/** HMS 后端的中立连接事实(HiveConf 实体由连接器组装)。 */ +public interface HmsMetaStoreProperties extends MetaStoreProperties { + String getUri(); + AuthType getAuthType(); // SIMPLE / KERBEROS + /** hive.* / hadoop.security.* / sasl 等中立键,连接器叠到自己的 HiveConf 上。 */ + Map toHiveConfOverrides(); + /** Kerberos 事实(principal/keytab),真正的 UGI.doAs 仍由 ConnectorContext.executeAuthenticated 执行。 */ + Optional kerberos(); +} +public interface DlfMetaStoreProperties extends MetaStoreProperties { Map toDlfCatalogConf(); /* 8×dlf.catalog.* */ } +public interface RestMetaStoreProperties extends MetaStoreProperties { String getUri(); Map toRestOptions(); } +public interface JdbcMetaStoreProperties extends MetaStoreProperties { String getUri(); String getUser(); String getPassword(); String getDriverUrl(); String getDriverClass(); } +public interface GlueMetaStoreProperties extends MetaStoreProperties { Map toGlueConf(); } +public interface FileSystemMetaStoreProperties extends MetaStoreProperties { String getWarehouse(); } +``` + +> 设计要点:与 fe-filesystem-api 完全一致的「瘦接口 + 中立 Map 转换」原则——**不把 hive-conf/hadoop/各引擎 SDK 类型泄进 api**,从而 REST/JDBC-only 的连接器不会被迫拖 hive 依赖。 + +### 3.2 `fe-connector-metastore-spi`(共享 fact 解析器,依赖 metastore-api + fe-foundation + fe-filesystem-api)—— 决策② + +每个后端**一个**解析器,吃 `(rawMap, List)`,产出对应的 `*MetaStoreProperties` facts。`@ConnectorProperty` 绑定(决策④)使别名优先级/required/sensitive/matched 全部免费——**消灭 paimon 的 `String[]` 手抄别名数组**。 + +```java +package org.apache.doris.connector.metastore.spi; + +/** HMS 连接事实解析器(共享)。Hive/Iceberg/Paimon 的 HMS adapter 都调它一次。 */ +public final class HmsMetastoreBackend { + // 内部用 @ConnectorProperty 注解的 typed holder + ConnectorPropertiesUtils.bindConnectorProperties + public static HmsMetaStoreProperties parse(Map raw, List storage); +} +public final class DlfMetastoreBackend { public static DlfMetaStoreProperties parse(Map raw, List storage); } // 含 endpoint-from-region 推导 + 8 key +public final class GlueMetastoreBackend { public static GlueMetaStoreProperties parse(Map raw); } // 含 AssumeRole provider 链 +public final class RestMetastoreBackend { public static RestMetaStoreProperties parse(Map raw); } +public final class JdbcMetastoreBackend { public static JdbcMetaStoreProperties parse(Map raw, Map env); } // 含 resolveDriverUrl + DriverShim +public final class JdbcDriverSupport { /* registerJdbcDriver + DRIVER_CLASS_LOADER_CACHE + DriverShim —— 现在只存一份 */ } +``` + +**后端发现/派发 = Provider 自识别 + ServiceLoader(D-006,镜像 `FileSystemProvider`)**——取代旧 `MetastoreProperties.Type` 枚举 + 中心 switch。每个后端一个 provider(薄壳,包住上面对应的 `*MetastoreBackend.parse`),经 `META-INF/services` 注册;连接器调一次注册表即可,**不再 `switch (flavor)`**: + +```java +package org.apache.doris.connector.metastore.spi; + +/** 后端发现 SPI。新增后端 = 新 provider + 一行 META-INF/services,api/spi 零改动、无中心 switch。 */ +public interface MetaStoreProvider

    extends PluginFactory { + boolean supports(Map props); // 自识别(读 metastore.type/特征键),cheap & 确定性 + P bind(Map props, List storage); // 命中后绑定(内部调对应 *MetastoreBackend.parse) + @Override default String name() { return getClass().getSimpleName().replace("MetaStoreProvider", ""); } +} +// 内置 provider(各自 META-INF/services/...MetaStoreProvider 注册一行): +// HmsMetaStoreProvider / DlfMetaStoreProvider / RestMetaStoreProvider / JdbcMetaStoreProvider / FileSystemMetaStoreProvider +// 后续 Glue/S3Tables:新建 GlueMetaStoreProvider + 一行 services —— 不动 api/spi 既有代码。 + +/** 连接器/fe-core 调它派发,循环 providers 找首个 supports() 命中(对标 FileSystemPluginManager.createFileSystem)。 */ +public final class MetaStoreProviders { + public static MetaStoreProperties bind(Map raw, List storage); +} +``` + +`@ConnectorProperty` typed holder 示例(消灭手抄别名): +```java +final class HmsRawProps { + @ConnectorProperty(names = {"hive.metastore.uris", "uri"}, required = true) String uri; + @ConnectorProperty(names = {"hive.metastore.authentication.type"}, required = false) String authType = "none"; + @ConnectorProperty(names = {"hive.metastore.client.principal"}, required = false) String principal = ""; + @ConnectorProperty(names = {"hive.metastore.client.keytab"}, required = false, sensitive = true) String keytab = ""; + // ConnectorPropertiesUtils.bindConnectorProperties(this, raw) 完成绑定 + matchedProperties +} +``` + +> **shared vs format 切割线**:spi 解析器只产出「**连接事实**」(uri/auth/8-key/driver/warehouse/中立 hive.* map);「**建哪个 SDK 的 catalog**」是引擎特定,留在各连接器的 adapter。`hive.conf.resources` 文件加载、Kerberos `doAs` 仍经 `ConnectorContext` 由 fe-core 执行(连接器不能 import fe-core)。 + +### 3.3 连接器侧 adapter(以 paimon 为例,薄) + +`PaimonCatalogFactory` 从「627 行手抄」瘦身为「provider 派发拿 facts + 组装 paimon Options/HiveConf」。metastore 后端由 `MetaStoreProviders.bind` 经 `supports()` 自动选中(D-006,**无 per-backend 枚举 switch**);剩下的 `instanceof`/`providerName` 分支是**连接器本地**的「建哪个 paimon SDK catalog」(引擎特定、允许): +```java +MetaStoreProperties ms = MetaStoreProviders.bind(raw, storageList); // 共享 + ServiceLoader 自识别派发 +if (ms instanceof HmsMetaStoreProperties hms) { // 连接器本地分支(非 api 枚举) + HiveConf hc = new HiveConf(); + ctx.loadHiveConfResources(raw.get("hive.conf.resources")).forEach(hc::set); // fe-core 加载文件 + hms.toHiveConfOverrides().forEach(hc::set); // 共享 facts + for (StorageProperties sp : storageList) // fe-filesystem-api + sp.toHadoopProperties().ifPresent(h -> h.toHadoopConfigurationMap().forEach(hc::set)); + return createPaimonHiveCatalog(buildPaimonOptions(raw, hms), hc); // paimon 特定(薄) +} +// else if (ms instanceof RestMetaStoreProperties ...) / DlfMetaStoreProperties / ... +``` +hive/iceberg 后续迁移时复用同一批 provider/`*MetastoreBackend.parse`,只写各自 `createXxxCatalog` —— **HMS/DLF/JDBC 连接逻辑不再重抄第 3、4 遍**。 + +### 3.4 fe-core 侧改动 +- **新增**:CREATE CATALOG 时绑定 `List`(fe-filesystem 全量)并经 `ConnectorContext.getStorageProperties()` 下发。 +- **保留**:`DefaultConnectorContext` 的 `vendStorageCredentials` / `normalizeStorageUri` / `loadHiveConfResources` / `executeAuthenticated`(动态/RPC/特权步骤)。 +- **保留/迁移**:`S3Properties.getS3TStorageParam`/`getObjStoreInfoPB` 这类 thrift/proto 适配,迁到 fe-core 的一个 BE-RPC adapter(吃 `BackendStorageProperties.toMap()` 的中立 map);**api 永不见 thrift**。 + +### 3.5 Kerberos 收口到独立叶子模块 `fe-kerberos`(D-007) + +**现状(三处实现,须去重)** +| 位置 | 内容 | 谁用 | +|---|---|---| +| `fe-common` `org.apache.doris.common.security.authentication.*` | 完整套件:`AuthenticationConfig`/`KerberosAuthenticationConfig`/`HadoopAuthenticator`/`HadoopKerberosAuthenticator`/`HadoopSimpleAuthenticator`/`ExecutionAuthenticator`/`PreExecutionAuthenticator(Cache)`/`ImpersonatingHadoopAuthenticator` | fe-core(HMS `HMSBaseProperties`、`HdfsProperties`、注入 `ConnectorContext.executeAuthenticated`) | +| `fe-filesystem-hdfs` `org.apache.doris.filesystem.hdfs.KerberosHadoopAuthenticator` | **自抄一份**(实现 fe-filesystem-spi **另一个** `HadoopAuthenticator` 接口,用 `IOCallable` 而非 `PrivilegedExceptionAction`),为避免依赖 fe-common | fe-filesystem-hdfs(`DFSFileSystem`/`HdfsInputFile`) | +| `fe-connector-paimon` `PaimonCatalogFactory.buildHmsHiveConf` | **手抄** HMS 的 kerberos 条件 HiveConf 键(`sasl.enabled`、service principal、`auth_to_local`)+ doAs 回调 `ctx.executeAuthenticated` | paimon | + +→ 同一段 UGI 登录/刷新/JVM-全局 `UGI.setConfiguration` 锁逻辑散在三处,改一处要改三处(fe-filesystem-hdfs 那份是约一年前拷贝,TGT 刷新可能已漂移)。 + +**目标:新建叶子模块 `fe-kerberos`** +- 依赖**仅** `hadoop-auth` / `hadoop-common`(把唯一外部依赖 trino `KerberosTicketUtils` 用 JDK `javax.security.auth.kerberos` 等价替换,做到零外部依赖)。auth 类现有 import 已很干净(JDK/hadoop/log4j/commons/guava + 1 trino),fe-common 不依赖 fe-core → 抽取无阻力。 +- 把 fe-common `security.authentication.*` 整套**搬入 `fe-kerberos`** 作唯一真相源;fe-common 重新 export(或转依赖 fe-kerberos),fe-core 无感。 +- `fe-filesystem-hdfs` **删自有 `KerberosHadoopAuthenticator`**,改依赖 `fe-kerberos`;**统一**两个打架的 `HadoopAuthenticator` 接口(`PrivilegedExceptionAction` vs `IOCallable`)为单接口 + 消费侧 adapter。 +- 连接器(paimon HMS)的 kerberos facts(principal/keytab/auth_to_local)由 `fe-kerberos` 的 `KerberosAuthSpec` 承载;真正的 `UGI.doAs` 仍经 `ConnectorContext.executeAuthenticated` 由 fe-core 执行(连接器不能 import fe-core;§5 不变量 4)。 + +**依赖图位置**:`fe-kerberos` 与 `fe-foundation` 平级做**纯叶子**(仅 hadoop),被 `fe-common`/`fe-core`/`fe-filesystem-hdfs`/`fe-connector-*` 共用,无环(见 §0 依赖图)。**不**折进 `fe-foundation`(它是零-hadoop 的 `@ConnectorProperty` 纯叶子,不应被 hadoop 污染)。 + +**范围(与 §0.1 / D-005):分两步(见 §4 Phase 3 与 tasks P3)** +- **(a) P3a,本次做(用户 2026-06-17 确认纳入)**:建顶层叶子 `fe-kerberos`(additive)+ 让 paimon 的 HMS kerberos facts 走它(**不碰** fe-common/fe-filesystem-hdfs 既有路径)→ 仍符合 D-005「只动 paimon + 纯新增」。过渡期 fe-common/fe-filesystem-hdfs 各自副本暂留(计数不增:paimon 手抄被 fe-kerberos 取代),由 (b) 收口。 +- **(b) P3b,follow-up(本次不做)**:全量去重(删 fe-filesystem-hdfs 副本、fe-common 重指向 fe-kerberos、统一两个 `HadoopAuthenticator` 接口),与 hive/iceberg 迁移同批——此步会改 fe-common + fe-filesystem-hdfs,超出 D-005,故独立。 + +--- + +## 4. 实施步骤(有序 TODO,paimon 优先、分阶段) + +> 原则:每步独立可编译可测、可单独提交;先建能力、再切 paimon、最后删 fe-core(待 hive/hudi/iceberg 也迁完)。 + +### Phase 0 — 准备 +- [ ] **P0-1(DV-001 修订)** 在 `fe-filesystem-api` 确认连接器所需的**消费**侧 api:`StorageProperties.toHadoopProperties().toHadoopConfigurationMap()`(已存)、`toBackendProperties().toMap()`(已存)。**结论**:消费侧 api 已够(覆盖 paimon 现 fe-property 路的常见静态凭据键,fe-filesystem 为新事实源、较 fe-property 略**超集**:S3 assume-role/anon 额外键 + OSS/COS/OBS endpoint/region 无条件 vs 懒发;T1 钉常见路径全等 + 记超集)。**但绑定侧缺口**:仓内无 raw map → `List` 聚合入口(`FileSystemProvider.bind` 在,但 registry 私有、仅首个命中 `createFileSystem`)→ 需在 fe-core 加 `bindAll`(见 P0-2 / D-009)。~~无需新增静态门面~~(消费侧确无需;绑定侧需 bindAll)。 +- [ ] **P0-2(DV-001/D-009 修订)** fe-core `FileSystemPluginManager` 新增 additive `public List bindAll(Map)`(镜像 `createFileSystem` 的 provider 循环,但 `provider.bind(props)` 全量收集所有 `supports()` 命中者,而非首个命中 `create`);`DefaultConnectorContext.getStorageProperties()` 调它(raw map 经现有 `storagePropertiesSupplier` 值的 `getOrigProps()` 取,**不改构造点** `PluginDrivenExternalCatalog`)。**fe-filesystem 模块零改动、fe-core 旧 `datasource.property.storage` 包零改动。** +- [ ] **P0-3** `tools/check-connector-imports.sh`:当前 FORBIDDEN 不含 `property`/`foundation`,**本次不收紧**(避免破坏性改动;fe-property 物理删除与 gate 收紧均属后续任务)。Phase 1 完成后 paimon 已零 `org.apache.doris.property` import,可作为后续收紧的前置条件。 + +### Phase 1 — paimon 的 Storage 改走 fe-filesystem-api(决策③,纯新增/迁移,不删 fe-core) +- [ ] **P1-1** `fe-connector-spi`:`ConnectorContext` 新增 `default List getStorageProperties() { return List.of(); }`(返回 **fe-filesystem-api** 类型)→ 引入 `fe-connector-spi → fe-filesystem-api` 边(**这条边即「fe-connector 依赖 fe-filesystem-api」的落地**)。**纯新增**,默认空实现,其它连接器不受影响。 +- [ ] **P1-2** fe-core `DefaultConnectorContext.getStorageProperties()`:用 fe-filesystem(全量 + providers)绑定 `StorageProperties` 并返回。**作用域限定到 plugin-driven(paimon)catalog 路径**,不改 hive/iceberg 现有引擎绑定;fe-core 旧 `datasource.property.storage` 类**原样保留**(仍服务 hive/hudi/iceberg)。 +- [ ] **P1-3** paimon `PaimonCatalogFactory.applyStorageConfig`:把 `fe-property StorageProperties.buildObjectStorageHadoopConfig(props)` 替换为「遍历 `ctx.getStorageProperties()` 调 `toHadoopProperties().toHadoopConfigurationMap()`」;保留其后的 `paimon.*/fs./dfs./hadoop.` 覆盖块(**last-write-wins 顺序不变**,否则会 clobber 用户 fs.s3a./kerberos 键——有历史 bug 注释为证)。 +- [ ] **P1-4** paimon `PaimonScanPlanProvider`:BE 静态凭据从 `ctx.getBackendStorageProperties()` 切到「遍历 `getStorageProperties()` 调 `toBackendProperties().toMap()`」(vended 动态路径不动,仍走 `ctx.vendStorageCredentials`)。 +- [ ] **P1-5** 移除 paimon pom 的 `fe-property` 依赖与 `PaimonCatalogFactory:20` 的 import;paimon 模块 `grep` 应零 `org.apache.doris.property`。**至此「fe-connector 不再依赖旧 storage-property 模型」达成。** fe-property 模块本身**不在本次删除**(其唯一消费者是 paimon,断开后变为孤儿 0 消费者,物理删除留待后续任务)。 +- [ ] **P1-6** 验证:paimon UT 全绿 + docker `enablePaimonTest=true`(5 flavor)+ 新旧 Hadoop/BE map 等价性测试(见 §5 T1)。 + +### Phase 2 — MetaStore Property SPI 建模 + paimon adapter 改造(决策①②④,纯新增/迁移,不删 fe-core) +- [ ] **P2-1** 新建 `fe-connector-metastore-api`(依赖 fe-foundation + fe-filesystem-api):`MetaStoreProperties`(`String providerName()` + 能力方法 `needsStorage()`/`needsVendedCredentials()`,**无 per-backend `MetaStoreType` 枚举**,D-006)+ 后端子接口(§3.1)。**本次只定义 paimon 用到的后端**:HMS / DLF / REST / JDBC / FileSystem;Glue / S3Tables(iceberg/hive 专用)**不在本次实现**,留接口可扩展即可。 +- [ ] **P2-2** 新建 `fe-connector-metastore-spi`(依赖 metastore-api + fe-foundation + fe-filesystem-api):`Hms/Dlf/Rest/Jdbc/FileSystem MetastoreBackend.parse(...)` + `JdbcDriverSupport` + **`MetaStoreProvider

    ` SPI(`supports()` 自识别)+ 5 内置 provider + `META-INF/services` + `MetaStoreProviders.bind` 派发**(§3.2,D-006),用 `@ConnectorProperty` typed holder 绑定。**来源 = 上移 paimon 现有 `PaimonCatalogFactory` 里已经手抄的连接逻辑**(它本就是 fe-core `HMSBaseProperties`/`AliyunDLFBaseProperties` 等的 port),做去 fe-core 化整理(HiveConf→中立 map、authenticator→`KerberosAuthSpec` facts)。**fe-core 的 `HMSBaseProperties` 等对应类一律保持不动**(仍服务 hive/hudi/iceberg)。 +- [ ] **P2-3** paimon adapter 改造:`PaimonCatalogFactory` 的 `buildHmsHiveConf`/`buildDlfHiveConf`/`validate`/别名常量 → 改为调用共享 `*MetastoreBackend.parse` + 薄 paimon Options/HiveConf 组装(§3.3)。删(连接器内部的)`PaimonConnectorProperties` 别名数组,由 spi typed holder 取代——**这是连接器自身代码,不属于 fe-core**。 +- [ ] **P2-4** paimon pom 增 `fe-connector-metastore-api/spi` 依赖;`grep` 确认 paimon 无 fe-core import;CI gate 通过。 +- [ ] **P2-5** 验证:paimon UT + docker 5 flavor(filesystem/hms/rest/jdbc/dlf)+ vended(REST/DLF) + Kerberos HMS;与 fe-core 旧 `Paimon*MetaStoreProperties` 行为对照(HiveConf key 集、ParamRules 报错文案一致,见 §5 T2)。 + +> **fe-core 旧 `datasource.property.metastore` 包在本次全程保持不动。** paimon 切换后这些类对 paimon 路径成为 dead code(`PaimonExternalCatalog` 旧路径),但仍被 hive/hudi/iceberg 使用,故**不删**。 + +### 范围外(后续独立任务,本次不做) +- hive / hudi / iceberg 连接器迁移到本 SPI:各写薄 adapter 复用 `*MetastoreBackend.parse` + `getStorageProperties()`,并补齐 Glue / S3Tables / REST-oauth2-sigv4 等后端。 +- 全部连接器迁完后:从 fe-core **彻底删除** `datasource.property.storage` 与 `datasource.property.metastore` 两个包、删 `StoragePropertiesConverter` 等桥;物理删除 `fe-property` 模块(`fe/pom.xml` module/version + 目录)并收紧 import gate 禁 `org.apache.doris.property`。 + +--- + +## 5. 关键不变量 / 风险 / 测试 + +**必须保留的不变量** +1. **正交组合**:metastore 不持有 storage 字段;storage 作入参传入(§1.4)。新 `parse(raw, storageList)` 维持此形态。 +2. **storage 叠加顺序**:canonical 翻译在前、`paimon.*/fs./dfs./hadoop.` 覆盖在后(last-write-wins)。P1-3 必须保序。 +3. **HMS Kerberos 条件键**:`hive.metastore.sasl.enabled` + `hadoop.security.authentication=kerberos` 的分支、service principal、`auth_to_local` 必须在 storage 叠加**之后**施加(否则被 raw `hadoop.*` passthrough clobber——已知 bug)。 +4. **特权/RPC 留 fe-core**:Kerberos `doAs`、`hive.conf.resources` 文件加载、vended 绑定、`TS3StorageParam`/`ObjectStoreInfoPB` 全部经 `ConnectorContext`/fe-core,连接器零 fe-core import(CI gate 强制)。 + +**风险** +- **R1 等价性漂移**:新 `toHadoopConfigurationMap()`/`toBackendProperties().toMap()` 与旧 `getHadoopStorageConfig()`/`getBackendConfigProperties()` 的 key/value 必须逐一对齐(注意默认调优值已分叉:S3=50/3000/1000 vs OSS/COS/OBS=100/10000/10000)。 +- **R2 双路径并存窗口**:Phase 1/2 期间 fe-core 旧 storage(hive/hudi/iceberg 用)与 fe-filesystem 新 storage(paimon 用)并存;同一 catalog 不能两路推出不同配置——paimon 已完全切到新路即可隔离。 +- **R3 打包/类加载**:HMS/DLF 活连接需 relocated thrift(`fe-connector-paimon-hive-shade`)build-order 在前 + child-first hadoop/aws bundling,重构模块时不可破坏(有历史 S3A/thrift 跨 loader cast bug)。 + +**测试(决策驱动,强制)** +- **T1 新旧等价性(DV-002 修订)**:对 S3/OSS/COS/OBS/HDFS 代表输入,断言新 `toHadoopConfigurationMap()` / `toBackendProperties().toMap()` 与 paimon 现走 fe-property 旧产物在**常见静态凭据路径**(配齐 endpoint/region/AK/SK,无 role、无 vended)下 key/value **全等**(含默认调优值分叉);fe-filesystem 的**超集差异**(S3 role/anon、OSS/COS/OBS endpoint 无条件、BE map 多 AWS_BUCKET/ROOT_PATH/CREDENTIALS_PROVIDER_TYPE)作**有意、更完整**记录,不视为漂移(用户 2026-06-17 定 A,认 fe-filesystem 为新事实源)。这是切换的回归闸(背景报告指出当前**缺**此测试)。 +- **T2 metastore facts 等价性**:对 HMS(simple/kerberos)、DLF(endpoint-from-region)、REST、JDBC、filesystem,断言共享 `*MetastoreBackend.parse` 产出的中立 map 与 fe-core 旧 `Paimon*MetaStoreProperties` 一致(含 ParamRules 报错文案)。 +- **T3 依赖图守门**:ArchUnit/CI gate 断言 `fe-connector-*` 不 import `org.apache.doris.{catalog,common,datasource,qe,...}`,且 Phase 1 后追加禁 `org.apache.doris.property`;`fe-filesystem-*` 不 import fe-core/fe-connector。 +- **T4 端到端**:docker `enablePaimonTest=true` 跑 paimon 5 flavor(filesystem/hms/rest/jdbc/dlf)读 + vended(REST/DLF) + Kerberos HMS。 + +--- + +## 6. 验收标准(本次任务) +1. paimon 连接器零 `org.apache.doris.property`、零 `org.apache.doris.datasource`、零 fe-core import;仅依赖 `fe-connector-{api,spi,metastore-api,metastore-spi}` + `fe-filesystem-api` + `fe-thrift(provided)` + SDK。 +2. `fe-property` 变为 **0 消费者**(孤儿模块,**本次不物理删除**);import gate **未收紧**(保持现状)。 +3. paimon 用到的 HMS/DLF/REST/JDBC/FileSystem 连接逻辑在 `fe-connector-metastore-spi` 各存**一份**;paimon adapter 不再含手抄连接逻辑。 +4. T1–T4 全绿;docker paimon 5 flavor 通过。 +5. 依赖边落地:`fe-connector → 仅 fe-filesystem-api`,`fe-filesystem ↛ fe-connector/fe-core`。 +6. **零改动核对**:fe-core 的 `datasource.property.storage` / `datasource.property.metastore` 两个包,以及 hive/hudi/iceberg/es/jdbc/mc/trino 连接器,本次**未被修改**(`git diff` 应不含这些路径,除 P1-2 的 `DefaultConnectorContext` 新增方法外不动 fe-core property 包)。 +7. (范围外、后续)全连接器迁完后再删 fe-core 两包 + 物理删 fe-property + 收紧 gate。 + +--- + +## 附录 A — 关键事实独立核验(grep) +| 论断 | 结果 | +|---|---| +| paimon 连接器对 fe-core 的 import 数 | **0**(唯一存储 import 是 fe-property `property.storage.StorageProperties`) | +| BE thrift/proto 适配器位置 | **仅** `fe-core/.../storage/S3Properties.java`(`getS3TStorageParam`/`getObjStoreInfoPB`) | +| fe-core 是否已依赖 fe-connector | **是**(`fe-connector-api` + `fe-connector-spi`) | +| fe-core 是否依赖 fe-property | **否** | +| import gate 禁止/允许 | 禁 `catalog|common|datasource|qe|analysis|nereids|planner`;允许 `thrift`/`filesystem`;**未禁** `property`/`foundation` | +| paimon/iceberg per-backend 模块 | 当前分支为 **stale 空目录**;真实拆分在 `catalog-spi-v20260422`,且是 **buildCatalog SPI** 而非 metastore-property SPI | +| fe-core metastore 包规模 | 28 文件 ~3624 LOC;共享后端基类 `HMSBaseProperties`/`AliyunDLFBaseProperties`/`AWSGlueMetaStoreBaseProperties` 已存在 | + +*本方案基于 commit `70e934d` 工作区;docker/e2e 未运行;属设计与可实施步骤层面。实施前请按本工作流(research-design-workflow)批准 TODO 列表。* diff --git a/plan-doc/deviations-log.md b/plan-doc/deviations-log.md new file mode 100644 index 00000000000000..aa4d784a11d030 --- /dev/null +++ b/plan-doc/deviations-log.md @@ -0,0 +1,612 @@ +# 设计偏差日志 + +> **Append-only**:实施中发现原计划/RFC 设计**不可行 / 不必要 / 需要重新设计**时记入本文件。 +> 与"决策"的区别见 [README §3.1](./README.md): +> - 决策(D-NNN)= **事前**确定的选择 +> - 偏差(DV-NNN)= **事后**对原计划的修正 +> +> 编号规则:`DV-NNN` 三位数字,从 001 起单调递增,永不复用。 +> +> 维护规则见 [README §4.3](./README.md):**先记偏差再改文档**,不要 silent edit。 + +--- + +## 📋 索引 + +> 时间倒序;当前共 **51** 项(最新 DV-051 = iceberg 未知/v3 类型静默降级 UNSUPPORTED,用户 2026-07-13 签字 accept;DV-050 = EXPLAIN 通用节点名 accept;本轮 P6.5-T07 对抗 byte-parity 审计〔8 area finder + refute-by-default skeptic + completeness critic,22 finding / 19 confirmed〕把 P6.5 sys-table 残留 deviation 批化中央登记为 DV-048〔correctness-bearing〕/049〔perf-cosmetic/display/internal〕——**审计揭出的 2 项主动偏差〔sys 时间旅行 guard 拒绝 + hms 大小写〕用户裁「现修」故 NOT-DV,见 [D-067]**;二层镜像 P6.4-T08 DV-045..047)。 + +| 编号 | 偏差主题 | 原计划位置 | 日期 | 当前状态 | +|---|---|---|---|---| +| DV-051 | **iceberg 未知/v3 类型静默降级 UNSUPPORTED(不抛,用户 2026-07-13 签字 accept)**:`IcebergTypeMapping.fromIcebergType/fromPrimitive` 两处 `default` 臂把 Doris 无法表示的 iceberg 类型(v3 primitives `TIMESTAMP_NANO`/`GEOMETRY`/`GEOGRAPHY`/`UNKNOWN` + 非-primitive `VARIANT`)映射为 `UNSUPPORTED` → 表**能加载**、该列 present-but-unqueryable、其它列可用。**背离** legacy fe-core `IcebergUtils.icebergTypeToDorisType`(未知类型 `throw IllegalArgumentException("Cannot transform unknown type")` 在 schema-load 失败整表)与 **Trino**(`TypeConverter` 对未映射类型抛 `TrinoException(NOT_SUPPORTED)`)。**用户选「统一映射 UNSUPPORTED」**(非抛):一列冷门类型不致整宽表不可加载。`TIME`/`VARIANT` 本就显式/等价 UNSUPPORTED(=legacy parity);分歧仅在 v3 primitives(legacy 抛)。**写方向 `toIcebergPrimitive` 仍抛**(CREATE TABLE 不得静默接受不可 round-trip 类型,不动)。守护测试 `IcebergTypeMappingReadTest.unknownAndV3TypesDegradeToUnsupportedByDesign` 钉此选择(未来改抛→red)。**无回归**:现有 fixture 无 GEOMETRY 等冷门列 | reverify §1 L18 / [task 表 §L18](./task-list-65185-reverify-fixes.md) | 2026-07-13 | 🟢 已登记(accept;graceful degradation;守护测试锁定;e2e live 验冷门列表可加载/该列不可查)| +| DV-050 | **翻闸后 EXPLAIN 外部表扫描节点显示通用名 `VPluginDrivenScanNode`**(display-only;用户 2026-07-12 签字 accept):翻闸前各源显示 `V_SCAN_NODE`(`VHIVE_SCAN_NODE`/`VICEBERG_SCAN_NODE` 等),翻闸后所有外部表走通用节点〔`PluginDrivenScanNode:173` 传 super label `"PluginDrivenScanNode"`〕→ EXPLAIN 统一 `VPluginDrivenScanNode`,且 `getNodeExplainString:325` 已附 `CONNECTOR: ` 行披露实际对接的数据源。**纯显示,无功能/结果/性能影响**。选 **accept**(非加 `Connector.getLegacyEngineName` SPI 恢复旧名):`CONNECTOR:` 行信息未丢、regression 黄金文件已适配为 `VPluginDrivenScanNode`(全 regression 树仅 1 处引用扫描节点名且已是新名)、且与 **Trino** 一致(Trino EXPLAIN 对所有连接器统一 `TableScan` 通用节点名 + 连接器/表作属性,而非塞进节点类名)。否决 Option B(加 SPI 声明旧引擎名——不能用 catalog type 拼,hive 的 type=`hms`→会误拼 `VHMS_SCAN_NODE`;改动面大且须把黄金文件改回去,仅为复刻一个 cosmetic 串)。关联设计债 D-ENGINE(引擎名收口)择机随 P8 | reverify §1 L10 / [task 表 §L10](./task-list-65185-reverify-fixes.md) | 2026-07-12 | 🟢 已登记(accept;display-only;EXPLAIN reg 黄金已用新名)| +| DV-049 | **P6.5 iceberg sys-table perf-cosmetic/display/internal 批汇总**(结果恒等/展示/内部枚举;镜像 DV-047/044 style):**①sys split self-weight 丢**〔audit `T05-sys-split-weight`:legacy `IcebergScanNode.createIcebergSysSplit:900`+`IcebergSplit.newSysTableSplit:78` 设 `selfSplitWeight=Math.max(recordCount,1L)`;连接器 `IcebergScanRange` 无 `getSelfSplitWeight()` override → SPI 默认 -1 → `PluginDrivenSplit` `SplitWeight.standard()` 均匀。**result-equivalent**——查询结果/thrift 字段/serialized_split 字节皆不变,仅 BE `FederationBackendPolicy` 调度权重差;镜像 [DV-033] native-subsplit weight 不移植〕·**②内部 `TableIf.TableType=PLUGIN_EXTERNAL_TABLE`**〔legacy `IcebergSysExternalTable:57`=`ICEBERG_EXTERNAL_TABLE`〕——但 **user-visible 全 parity**:`getMysqlType`→`information_schema.tables.TABLE_TYPE` 两枚举皆 fall-through "BASE TABLE"〔`TableIf:319/324-325`〕、engine name 经 [D-066] T06-F1="iceberg"、descriptor 经 T06-C1=HIVE/ICEBERG_TABLE → 残留**仅内部枚举**〔无 user 可观测面〕·**③position_deletes 文案**〔Q2 用户签字:连接器 `listSupportedSysTables` 去 `POSITION_DELETES`+`getSysTableHandle`→empty → 通用 fe-core not-found("Unknown sys table")vs legacy bespoke "is not supported yet"(`IcebergSysTable:74`);两侧 support boolean 同〔皆不可查〕仅文案分叉;**reg-test 已同步 2026-06-30**:`test_iceberg_sys_table.groovy` position_deletes 断言从旧 bespoke 文案改断通用 `Unknown sys table '$position_deletes'`,docker e2e 实跑绿〕。全部**非正确性** | T07 对抗审计 / [task 表 §P6.5](./tasks/P6-iceberg-migration.md) | 2026-06-25 | 🟢 已登记(accept;结果恒等/内部/展示;P6.6 docker/live 真值闸)| +| DV-048 | **P6.5 iceberg sys-table correctness-bearing 但 UT 不可见**(parity-by-construction / 用户签字;docker 闸):**①F2 paimon SHOW CREATE priv loosening(LIVE)**〔audit `F2-paimon-showcreate-priv-loosened-live` + critic:[D-066] T06-F2 给 `ShowCreateTableCommand.validate():120-124` 加 `PluginDrivenSysExternalTable`→`getSourceTable().getName()` 解包;**对 iceberg 是 parity**〔legacy `IcebergSysExternalTable` 分支 `:118-119` 已解包,且 iceberg pre-flip dormant〕,但 **paimon 在 SPI_READY_TYPES** → live 行为变更:sys `$`-表 SHOW CREATE 现按 **base** 表授权(pre-T06 按合成 'tbl$snapshots' 名);严格**更宽松**、同向,与 `Env.getDdlStmt`/`UserAuthentication` 既有 output 解包一致、破坏风险近零;用户 T06-Q2 签字。**⚠️ 无隔离 UT**〔`validate()` 依赖 `Env`/`ConnectContext`/`AccessManager` 全局单例〕→ P6.8 e2e 兜底〕·**②serialized `FileScanTask` 字节潜伏(T05)**〔FE `SerializationUtil` deserialize-round-trip UT 已在**同进程 iceberg 1.10.1** 核「可消费+asDataTask+meta schema」,但与 BE `IcebergSysTableJniScanner` 的**跨版本/classloader interop** FE 不可及 → P6.8 docker e2e 兜底〕。**别于 DV-041**:本条**已建待 docker 实证**,DV-041 是未接线翻闸阻塞 | T07 对抗审计 / [task 表 §P6.5](./tasks/P6-iceberg-migration.md) | 2026-06-25 | 🟢 已登记(accept;P6.6 docker/Kerberos 真值闸)| +| DV-047 | **P6.4 iceberg procedures perf/cosmetic/behaviour-equiv/dispatch-order 批汇总**(结果恒等/dormant/接缝/幂等;镜像 DV-044 style):cache 失效搬 dispatch+短路多失效〔仅 context!=null〕·executeAction 加 ConnectorSession 参〔内部接缝〕·**DV-T08-loadwrap**〔新 "Failed to load iceberg table" 串,引擎再裹 "Failed to execute action:"〕·**DV-T08-factory-advertise**〔rewrite_data_files 广告-但-`createAction` 拒,dormant,canary UT 钉〕·DV-T06r-{scanpool〔丢 scanManifestsWith〕,zone〔复用 DV-T04-f〕,rollback〔不清列表中性〕}·DV-T07-{where〔WHERE 拒 fail-loud dormant〕,name-order〔priv-first 有意发散〕,exc-contract〔IllegalStateException 逃逸 byte-parity 边界〕}·PARTITION(*) 拒不对称〔low,dormant〕·null-row 编码〔low〕·per-conjunct filter 形状〔结构等价〕。全部**非正确性** | T04/T06/T07 设计 §10 / [task 表 §P6.4](./tasks/P6-iceberg-migration.md) | 2026-06-24 | 🟢 已登记(accept;结果恒等/展示/dormant/接缝;P6.6 docker 真值闸)| +| DV-046 | **P6.4 iceberg procedures correctness-bearing 但 UT 不可见**(parity-by-construction 或 dormant+用户签字;docker/Kerberos 闸):**auth-add**〔8 snapshot mutator 的 loadTable+commit 现裹 `executeAuthenticated`〔仅 context!=null〕,legacy 缺=潜伏 Kerberized auth bug,加非丢〕·**DV-T05r-where**〔rewrite_data_files WHERE 走 conflict-mode:不可转节点静默丢〔legacy 抛〕→ planner scan 变宽/极端全表 + conflict-matrix 收窄;rewrite 语境 over-approx **不安全**〔安全性反号 vs O5-2〕;conflict-matrix 是 legacy 严格子集→只变宽绝不反向;用户签字 Option A;**经 EXECUTE 派发不可达**〔双闸:factory 无 case〔DV-T08-factory-advertise〕+ WHERE 拒〔DV-T07-where〕→ DV-045 R-B 接线后才激活〕〕。别于 DV-045=未接线翻闸阻塞 | T04/T05 设计 §10 / [task 表 §P6.4](./tasks/P6-iceberg-migration.md) | 2026-06-24 | 🟢 已登记(accept;P6.6 docker/Kerberos 真值闸)| +| DV-045 | **P6.4 `rewrite_data_files` 执行半翻闸阻塞 BLOCKER**(R-B 推后专门写路径 RFC;与 DV-041 写路径阻塞同族):① 事务半〔连接器 `WriteOperation.REWRITE` 变体,T06 已建 dormant 净0新verb〕+ 规划半〔`RewriteDataFilePlanner`,T05 已建 dormant〕;**②③④ 执行半留 fe-core**〔`RewriteDataFileExecutor`/`RewriteGroupTask`/`RewriteTableCommand`/`IcebergRewriteExecutor`〕。recon 证伪设计 §5/D-062 R-A「pinned snapshot+WHERE 重规划」前提〔连接器 scan SPI 无法表达 bin-pack「分区内任意文件子集」→ over-scan 破 rewrite 正确性;`FileScanTask` 侧信道翻闸后死;SPI 模块边界禁连接器 carrier 跨进 fe-core;multi-sink-per-txn 生命周期重设计〕。用户裁 Option 1(2026-06-24)| T05/T06 设计 §5 / [task 表 §P6.4 T06](./tasks/P6-iceberg-migration.md) / [HANDOFF 🔴🔴](./HANDOFF.md) | 2026-06-24 | 🔴 翻闸前(P6.6)必接线(专门写路径 RFC)| +| DV-044 | **P6.3 iceberg 写路径 perf/cosmetic/EXPLAIN-diff/等价结构 批汇总**(结果恒等;镜像 DV-040/DV-035 style):jdbc txn 全局注册生命周期变更·writeOperation 移 T03/beginTransaction throwing 默认(DV-T01-b/c)·jdbc EXPLAIN 头标签 `WRITE TYPE:JDBC_WRITE`→`WRITE:plan-provider`(窄化,INSERT SQL 经 appendExplainInfo 保,DV-T02-b)·appendExplainInfo EXPLAIN 期读元数据(净优于 legacy 每-INSERT 查,DV-T02-c)·异常型 `DorisConnectorException`/`IllegalStateException`(消息字节同,DV-T03-a/T04-b/T05-b/T06-c)·`scanManifestsWith` 丢→SDK 默认池(DV-T04-a/T05-f)·partition_data_json Jackson vs Gson(DV-T04-d)·单 `beginWrite`+`commit` switch(DV-T04-e)·显式 ZoneId 形参(DV-T04-f/T05-d)·O5-2 惰性转/私有 formatVersion 重复(DV-T05-a/e)·double loadTable 只读重 I/O(DV-T06-d)·新 `getBackendFileType`/`getWriteSortColumns` SPI 接缝 + `SINK_REQUIRE_FULL_SCHEMA_ORDER`(DV-T06-e)·EXPLAIN sink 标签 `PLUGIN-DRIVEN TABLE SINK` vs `ICEBERG TABLE SINK`(plan-shape 不变,OQ-3)·jdbc thrift 移位(OQ-1→DV-T02-a 实现)。全部**非正确性** | T01–T07 设计 §6 / [task 表 §P6.3](./tasks/P6-iceberg-migration.md) | 2026-06-24 | 🟢 已登记(accept;结果恒等/展示/性能;P6.6 docker 真值闸)| +| DV-043 | **P6.3 iceberg 写路径 parity-忠实 correctness-bearing 但 UT 不可见 批汇总**(parity-by-construction / widening-safe,各有 P6.6 docker 闸):jdbc affected-rows `-1` 哨兵 + `DPP_NORMAL_ALL`(BE 真实计数离线不可验,DV-T01-a)·jdbc `TJdbcTableSink` thrift 由 fe-core 移连接器 `planWrite`(OQ-1 字节-parity 移位,§4.1 逐字段 + UT,DV-T02-a)·`beginWrite` 的 `newTransaction()` auth-wrap(Kerberized HMS `doRefresh`,离线 InMemoryCatalog 不可分辨,DV-T03-d)·TIMESTAMP/identity 分区值连接器-本地解析 + 显式 zone(BE canonical fmt,DV-T04-c)·`IcebergPredicateConverter` conflict-mode 丢不可转/NullSafeEqual/Cast/col-col/NE → 冲突 filter **widens**(no-missed-conflict 安全,忠实 legacy 冲突路 Option A,用户签字 2026-06-24;DV-T05-c/T07b-matrix/T07b-literal)·连接器 hadoopConfig 经 fe-filesystem `toHadoopConfigurationMap` vs legacy fe-core(默认口径微差,P6.6 docker 断字节,DV-T06-hadoopconfig)。**别于 DV-041**:本条**已修待 docker 实证**,DV-041 是**未接线翻闸阻塞** | T01–T07 设计 §6 / [task 表 §P6.3](./tasks/P6-iceberg-migration.md) | 2026-06-24 | 🟢 已登记(accept;P6.6 docker 真值闸必逐项验)| +| DV-042 | **P6.3 北极星 (iii) 有界架构偏差:iceberg DML plan 合成 fe-resident(Route B / option (i),PMC 签字)**:iceberg DELETE/UPDATE/MERGE 的 plan 合成(`$row_id` 注入 / branch-label 投影代数 / nereids→iceberg expr)**暂留 fe-core**,经连接器-键控 `RowLevelDmlTransform` 注册表调用;合成内反向 `instanceof IcebergExternalTable`。**有界 intentional**——保 EXPLAIN parity,**只在北极星 (iii) 通用化**(Trino 式:连接器 0 优化器 import、引擎核心全 DML 合成)**关闭**,触发 = 第二个行级-DML 消费者(hive P7 / paimon),后续专门 RFC。含等价结构项:T07c 冲突-filter 顺序(provably-equivalent reorder)/单 table resolve(删 legacy 冗余 re-resolve)/`IcebergXCommand.run()` 循环 transitional-dead(P6.7 随类删)·conflict-mode 合成列排除经注入 Predicate(DV-T07b-exclusion)·`rewritableDeleteFileSets` 经 T07c executor finalize seam(DV-T07-rewritable)| RFC §5.3/§10 / T07 设计 §4.5.8 / [task 表 §P6.3](./tasks/P6-iceberg-migration.md) | 2026-06-24 | 🟢 已登记(accept;有界、PMC 签字、保 EXPLAIN parity;北极星 iii 后续 RFC 关闭)| +| DV-041 | **P6.3 写路径翻闸阻塞 BLOCKER:通用 `visitPhysicalConnectorTableSink` 缺合成列物化 + 分布(DV-038 同主题新面)+ 休眠-至-翻闸激活集**。**主阻塞(DV-T07-materialize)**=通用 `visitPhysicalConnectorTableSink` 无合成列 `setMaterializedColumnName`(`$operation`/`$row_id`)+ `DistributionSpecMerge` 分布,仅 legacy `visitPhysicalIcebergMergeSink` 有 → iceberg DELETE/MERGE 经通用 sink 真正走通前须先长出,否则上游列被丢 → BE `iceberg_reader.cpp` StructNode DCHECK(**同 DV-038 崩溃类**);T07 有意不碰 `PhysicalPlanTranslator:589-627`。**休眠-至-翻闸激活集(P6.6 必接线,全有或全无)**:写分布 `getRequirePhysicalProperties` 分区-hash 延后(DV-T06-a)·branch-INSERT thread-through(DV-T06-branch)·REST 对象存储 vended overlay(不接翻闸后 403,DV-T07-vended)·O5-2 `getConnectorTransactionOrNull()`→null 休眠(翻闸激活,DV-T07c-o5seam)·FILE_BROKER 地址(DV-T06-broker/T07-broker)| T06 §6 / T07 §1.1/§6 / RFC §5 / [task 表 §P6.3](./tasks/P6-iceberg-migration.md) | 2026-06-24 | 🔴 翻闸前(P6.6)必修/必接线(与 DV-038 同 holistic)| +| DV-040 | **P6.2 iceberg scan perf/observability/EXPLAIN-drop + lenient-validation + benign superset 批汇总**(~36 项,镜像 DV-035 style):profile/`planWith` drop·manifest 统计 drop·空表 COUNT EXPLAIN `(-1)`·COUNT `>10000` 并行 trim drop·typed-vs-string carriers·per-file format·Jackson vs Gson·predicate over-approx(reversed/IsNull/Like/Between/LARGEINT/edge-literal,BE residual 兜底)·ZoneId alias-map·`delete_files` unset·fail-loud 异常型·`Locale.ROOT`·INCREMENTAL fail-loud·TIMESTAMP epoch-millis·vended `io().properties()`/非-fail-soft/PROVIDER_CHAIN gap·**🔵 split-package shadowing**(vendored `DeleteFileIndex` 与 iceberg-core 共存,T08 extractor 漏报本条补登,跨引用 P6.1 R-004/#973270)。全部**非正确性**(结果恒等/展示/源不同值同/安全超集/BE 兜底) | T02–T09 设计 / [T11 汇总](./designs/P6-T11-iceberg-scan-summary-design.md) | 2026-06-23 | 🟢 已登记(accept;P6.6 docker 真值闸)| +| DV-039 | **P6.2 iceberg scan parity-忠实 HIGH/MEDIUM correctness-bearing 但 UT 不可见 deviation 批汇总**(已连接器内缓解、单项不阻塞翻闸、各有 P6.6 docker 闸):HIGH=columns-from-path unset-then-set(#968880 防双填)·`isPartitionBearing()` 空分区崩修(**0-新-SPI 唯一例外**,非破坏默认)·主数据路径 `normalizeStorageUri`(path/originalPath 拆)·Option A 全 pinned-schema 字典(time-travel 防 `iceberg_reader.cpp:181` DCHECK)·静态 `location.*` 凭据发射(T09 前 403);MEDIUM=latest-snapshot 二元组·name-mapping 回退·fail-loud 竞态窗·vended live round-trip·Kerberized `doAs`(跨引用 DV-031)·tag/branch REF-pin·1-arg normalize delete 路。**别于 DV-038**:本条**已修待 docker 实证**,DV-038 是**未修共享 fe-core 崩溃** | T03/T04/T06/T07/T08/T09 设计 / [T11 汇总](./designs/P6-T11-iceberg-scan-summary-design.md) | 2026-06-23 | 🟢 已登记(accept;P6.6 docker 真值闸必逐项验)| +| DV-038 | **P6.2 iceberg 翻闸阻塞 BLOCKER:共享 fe-core field-id 路径 BE StructNode DCHECK 崩溃(1 主题/2 面,CI #969249 类)**。**面 1**=`GLOBAL_ROWID` 被通用 `classifyColumn` 误归 REGULAR→不在 field-id 字典→`iceberg_reader.cpp` DCHECK→整 BE 崩(连接器无法修,须改共享 fe-core `classifyColumn`→SYNTHESIZED,但 `paimon_reader.cpp` 无对应处理器→盲改破 paimon top-N)。**面 2**=`getColumnHandles` 无 snapshot 重载→rename+time-travel 丢被重命名 slot field-id→同一 DCHECK(iceberg 侧 T07 Option A 已闭合,但**共享 seam 仍潜伏 PAIMON** snapshot-id time-travel+rename)。审计 critic 实证 blocker 计数=**2**(同主题),合并单条但显式记两面(Rule 12)。**P6.6 翻闸前必 holistic 修 + paimon 影响分析(可能 BE 协同)** | T06 §6 / T07 §6 / T10 audit / [HANDOFF 🔴🔴](./HANDOFF.md) | 2026-06-23 | 🔴 翻闸前必修(面 1 用户签字延后 2026-06-22 / 面 2 待 P6.6 holistic)| +| DV-037 | P6-C2 FIX-C2-HDFS-XML:legacy HDFS `getHadoopStorageConfig()` 的 `fs.hdfs.impl.disable.cache=true` 未进 typed FE Configuration(pre-existing,非 C2 引入;Hadoop FS-cache benign) | [FIX-C2-HDFS-XML-design §Risk](./designs/FIX-C2-HDFS-XML-design.md) | 2026-06-19 | 🟢 已登记(accept / 可转 follow-up)| +| DV-036 | P6-C2 FIX-C2-HDFS-XML:DLF catalog 若另绑 HDFS storage,HDFS keys 会进 DLF HiveConf(legacy DLF 只 overlay OSS/OSS_HDFS);结果 additive/inert defaults-free,纯-OSS DLF byte-unchanged | [FIX-C2-HDFS-XML-design §Risk/Open Q1](./designs/FIX-C2-HDFS-XML-design.md) | 2026-06-19 | 🟢 已登记(accept)| +| DV-035 | P4 MINOR/NIT cleanup:**15 项 accept-as-deviation**(review §5/§7,用户签字 [D-057],2026-06-12;2 项已修 = N10.1 `bcee91dcb52` + sentinel `4b2c2190dc2`,不在本条)。read-only 对抗 recon `wf_6884d37b-8ef` 逐项对当前代码复核。**(a) M5.1(FUNCTIONAL/transient-only)**:bridge `getSupportedSysTables` 经远端 handle 预探列 sys-table,`getTableHandle` swallow-非NotExist-为-empty → 瞬时 metastore blip 致已存在 sys-table 报 phantom「table not found」(legacy 静态无条件列)。**无 surgical 修**:swallow→empty 是有意+有测契约(`PaimonConnectorMetadataReadAuthTest:150` `failAuth→empty`)且共享 existence 谓词(含 P3 createTable `remoteExists`);干净修需 SPI 加法或破契约。transient-only → accept。**(b) 假前提 ×2**:M9.1(HDFS `ipc.client.fallback-to-simple-auth` 等 default「丢」)、M9.2(hive.* metastore 键推 BE)——recon 证伪:连接器 `getBackendStorageProperties` 跑**同** `CredentialUtils.getBackendPropertiesFromStorageMap` over 同 storage map,无 drop。**(c) display-only**:M10.1(CREATE 嵌套 struct comment 丢)、M10.2(read isKey=false,无 planner gate,仅 DESCRIBE)、M10.3(LTZ `WITH_TIMEZONE` extraInfo 丢,仅 DESCRIBE Extra)、M7.1(`PluginDrivenScanNode` 不 override `getDeleteFiles`+不调 super→EXPLAIN VERBOSE 缺 DV/per-backend 计账,DV 仍正确达 BE)。**(d) perf-only**:M6.1(live-Table handle cache 丢,SDK CachingCatalog 仍缓)、M6.2(schema-at-snapshot 不按 schemaId 缓,结果同仅重算)。**(e) text-only**:N2.1("Paimon"→"Plugin" 拒绝文案)、M3.1/N4.1(not-found 文案丢 earliest-snapshot hint / "Failed to get Paimon..." 前缀,条件+异常类两侧同)、C2(ALTER BRANCH/TAG 抛 `DdlException` vs `UnsupportedOperationException`,两侧都拒)。**(f) inert no-op**:N3.1(@incr 丢 `scan.snapshot-id=null` 防御性 reset,fresh base table 上 no-op)、M2.1(@incr BE-serialized table 是 incremental-window-copied,BE 只用作 read-builder/rowType 工厂、不重 plan,inert)。**(g) 连接器更 correct**:M4.1(branch schema 解析对 branch 自身 schemaManager vs legacy base 表)、M1.3(CAST 谓词不下推——除掉 legacy source-side over-prune 数据丢 bug)。**(h) diagnostic**:M1.1(`ignore_split_type` 调试 var 忽略,须 fe-core SessionVariable 类型)。跨连接器:hudi/iceberg full-adopter 多项同复发,归本条批量考量 | [task-list §P4](./task-list-P5-rereview2-fixes.md) / [D-057](./decisions-log.md) | 2026-06-12 | 🟢 已登记(accept;M5.1=transient-only FUNCTIONAL,余 display/perf/text/inert/连接器-更-correct/假前提;live-e2e 真值闸)| +| DV-034 | P3-fix FIX-CREATE-TABLE-LOCAL-CONFLICT:**plugin DDL op 把 typed MySQL error-code 收敛成 generic `DdlException`**(pre-existing 跨全 4 DDL op,P4 cleanup defer)。`FIX-CREATE-TABLE-LOCAL-CONFLICT`([D-056])仅恢复 createTable 的 **case-B correctness**(local-only 冲突 + `!IF NOT EXISTS`→改抛 typed `ERR_TABLE_EXISTS_ERROR` 1050),**未** retype:**case-A**(createTable remote-hit + `!IF NOT EXISTS`)仍 fall-through 由连接器(paimon `TableAlreadyExistException`)→`DorisConnectorException`→桥 re-wrap 成 generic `DdlException`「already exists」,legacy `PaimonMetadataOps:195` 在 FE 层先抛 typed 1050;**createDatabase/dropDatabase/dropTable** 同样 `catch(Exception)`→generic `DdlException`(`PaimonConnectorMetadata:731/798/832/756`+桥 re-wrap),collapse 掉 legacy 1007/1008/1109。**非本 P3 finding**(finding=case-B silent-create correctness)、P3 audit 标 error-code parity=cosmetic/AGREE(error class + user-visible「already exists」文本两侧同、仅 numeric code 丢)。修它须每 op 在桥/连接器边界统一 typed-code 透传,属跨全 op + 跨连接器(hudi/iceberg 同)的 **P4 cleanup 批量**。真值闸=无功能影响,仅 MySQL numeric-error-code-sensitive 客户端脚本理论可感知 | [task-list §P3/§P4](./task-list-P5-rereview2-fixes.md) / [D-056](./decisions-log.md) | 2026-06-12 | 🟢 已登记(cosmetic/error-code-only,pre-existing 跨全 DDL op;P4 cleanup defer)| +| DV-033 | P5-fix#9 FIX-NATIVE-SUBSPLIT:**split-weight / target-size 调度 nicety 不移植**(用户签字采纯连接器实现,2026-06-12)。legacy `fileSplitter.splitFile` 经 `splitCreator.create(...,targetFileSplitSize,...)` 在每个 `FileSplit` 上设 split weight + targetSplitSize,供 `FederationBackendPolicy` 做 backend 分配均衡。连接器 native sub-range(`buildNativeRange`)**不设** `selfSplitWeight`/targetSplitSize——但这是 **pre-existing**:翻闸后单-range native 路本就没设(`buildNativeRange` 从未设 weight,仅 JNI 路 `buildJniScanRange` 经 `computeSplitWeight` 设)。#9 **不引入**该缺口,只是把一个整文件 range 变成多个 sub-range(并行度本身已恢复,这是 #9 的目的)。纯调度均衡质量、非正确性、非并行度。连接器 SPI 无 per-range weight 喂入 FileSplit 的通道(`PaimonScanRange` 无 targetSplitSize 字段)。跨连接器:hudi/iceberg full-adopter 若要 weight-均衡可后续在 SPI/`PaimonScanRange` 加 weight 字段批量补(与既有 native-path weight 缺口一并)。真值闸=live-e2e(观察 backend 分配均衡,非正确性) | [task-list #9](./task-list-P5-rereview2-fixes.md) / [P5-fix-NATIVE-SUBSPLIT 设计](./tasks/designs/P5-fix-NATIVE-SUBSPLIT-design.md) / [D-055](./decisions-log.md) | 2026-06-12 | 🟢 已登记(perf/调度-only,pre-existing;live-e2e 真值闸)| +| DV-032 | P5-fix#8 FIX-COUNT-PUSHDOWN:**collapse-to-one 丢 legacy `>10000` 并行 count-split trim**(用户签字采 collapse-to-one,2026-06-12)。legacy `PaimonScanNode:484-495` 收齐 count-eligible split 后按 `pushDownCountSum` 分流——`>COUNT_WITH_PARALLEL_SPLITS(10000)` 时 trim 到 `parallelExecInstanceNum * numBackends` 个 split 并 `assignCountToSplits` 把 total 均摊(BE 每 split CountReader 再求和回 total);`<=10000` 则 `singletonList(first)` 收一 split 携全 total。连接器**始终 collapse-to-one**(无论 countSum 大小),因连接器无 `numBackends`/`parallelExecInstanceNum`(fe-core scan-node-only,`getSplits(int numBackends)` 才有)。**纯 perf 偏差、结果恒等**:单 CountReader 在一个 fragment emit `countSum` 个空行(无 IO)而非 N 个并行——对超大 count 不并行化 count-emit。CountReader 不读数据故影响小。**未采 full-parity**(连接器发 per-split + fe-core 按 numBackends trim+redistribute)以避免把 count 语义耦进通用 `ConnectorScanRange` + 多 fe-core 代码。跨连接器:hudi/iceberg full-adopter 若要 `>10000` 并行可后续在 fe-core 加 trim hook(与 [DV-028]/[DV-030]/[DV-031]「新连接器读法 vs fe-core 既有约定」类缝同批考量)。真值闸=live-e2e(超大 PK 表 `COUNT(*)` 仍正确、仅观察 fragment 并行度差异) | [task-list #8](./task-list-P5-rereview2-fixes.md) / [P5-fix-COUNT-PUSHDOWN 设计](./tasks/designs/P5-fix-COUNT-PUSHDOWN-design.md) / [D-054](./decisions-log.md) | 2026-06-12 | 🟢 已登记(perf-only,结果恒等;live-e2e 真值闸)| +| DV-031 | P5-fix#6 FIX-KERBEROS-DOAS 两接受项:① **真 doAs 端到端 = live-Kerberos-e2e only**——M-8(filesystem/jdbc over Kerberized HDFS)+ M-11(Kerberos HMS read RPC)的 FE-unit 测只覆盖 **wiring**(M-8 断言 `getExecutionAuthenticator()` 返 `HadoopExecutionAuthenticator` 类型、不调 initializeCatalog;M-11 用 `RecordingConnectorContext.failAuth`/`authCount` 断言 read 经 `executeAuthenticated`),**无 paimon-kerberos regression 套件**(现有 `regression-test/.../kerberos/` 4 套仅 hive+iceberg、gated by `enableKerberosTest`)→ 真 KDC doAs 留给 live-e2e 门(翻闸前必验)。fail-safe:非 Kerberos 部署 no-op authenticator 与真 authenticator 行为一致(`ExecutionAuthenticator.execute`=`task.call()`)、无回归。② **跨连接器 follow-up**:read-vs-DDL doAs 缺口(M-11)+ 翻闸-authenticator-wiring 缺口(M-8,`initializeCatalog` 死代码)在 hudi/iceberg full-adopter **同样复发**(`cutover-fe-dispatch-gap` 姊妹);与 [DV-028](#4 CREATE-time-only 校验)/[DV-030](#5 mapping-flag 键)同属「新连接器读法/翻闸 vs fe-core 既有约定」类缝,将来可批量 close。**M-8 新增 fe-core `MetastoreProperties.initExecutionAuthenticator` hook 是 fe-core 内部扩展、非连接器 SPI**(`ConnectorContext`/`Connector` 表面未改)→ 01-spi-extensions-rfc.md 无须改 | [task-list #6](./task-list-P5-rereview2-fixes.md) / [P5-fix-KERBEROS-DOAS 设计](./tasks/designs/P5-fix-KERBEROS-DOAS-design.md) / [D-052](./decisions-log.md) / [D-053](./decisions-log.md) | 2026-06-11 | 🟢 已登记(live-e2e 真值闸 + 跨连接器 follow-up)| +| DV-030 | P5-fix#5 FIX-MAPPING-FLAG-KEYS 跨连接器 follow-up(用户定本轮 paimon-only):**新 hive + iceberg 连接器同根因**——读**下划线** mapping-flag 键而 fe-core 只写/读/藏**点分** catalog 键(`CatalogProperty:50,52`),`PluginDrivenExternalCatalog.createConnectorFromProperties` 喂原始 catalog map、中间无点分→下划线归一化 → 用户在 CREATE CATALOG 开 `enable.mapping.varbinary`/`enable.mapping.timestamp_tz` 对 hive/iceberg 亦**静默失效**(BINARY→STRING、LTZ→DATETIMEV2)。**iceberg** = `enable_mapping_varbinary`/`enable_mapping_timestamp_tz`(`IcebergConnectorProperties:46,47`→`IcebergConnectorMetadata:151,154`),仅分隔符差、语义不反转。**hive** = `enable_mapping_binary_as_string`/`enable_mapping_timestamp_tz`(`HiveConnectorProperties:52,53`→`HiveConnectorMetadata:317,319`),binary 键既改分隔符又改 token,但 `binary_as_string` 是**误名非语义反转**(`HmsTypeMapping:90-93` true→VARBINARY,喂 `mapBinaryToVarbinary` 字段)。JDBC 是唯一正确的新连接器(点分)。legacy hive/iceberg 经 `getCatalog().getEnableMappingVarbinary()` 读点分(`HMSExternalTable:791`/`IcebergUtils:1083`)→ 翻闸回归。**用户签 [D-051] = 本轮只修 paimon**(保 commit surgical、单任务);**follow-up(close 时)**:hive+iceberg 两常量重指 canonical 点分键(hive `binary_as_string` token 复原为 `varbinary`,**勿**反转 boolean)+ 各加 dotted-key honor UT;与 paimon #5 同形修。scope 经验证 workflow `wf_a3626c54-0db`(g5 + synthesizer,静态 trace 未 live) | [task-list #5](./task-list-P5-rereview2-fixes.md) / [P5-fix-MAPPING-FLAG-KEYS 设计](./tasks/designs/P5-fix-MAPPING-FLAG-KEYS-design.md) / [D-051](./decisions-log.md) | 2026-06-11 | 🟡 待修(跨连接器 follow-up,用户定本轮 paimon-only)| +| DV-028 | P5-fix#4 FIX-JDBC-DRIVER-URL:driver_url 安全校验**仅 CREATE CATALOG**(`PaimonConnector.preCreateValidation`→`ConnectorValidationContext.validateAndResolveDriverPath`),**FE-restart reload / ALTER CATALOG / scan-time 不复校**——与 legacy 分歧(legacy `getBackendPaimonOptions`→`JdbcResource.getFullDriverUrl` 每 scan 复校 format/whitelist/secure-path)。根因 = pre-existing **fe-core 架构缝**、非本 fix/非 paimon 专属:`CatalogFactory:164` replay(`isReplay=true`) 跳 `checkWhenCreating`→`preCreateValidation` 不跑;`PluginDrivenExternalCatalog.checkProperties`(ALTER 路) 只调 `validateProperties`(无 driver 校验)、不调 `preCreateValidation`;`getBackendPaimonOptions` 仅 resolve 不 validate(连接器 scan-time 只有 `ConnectorContext`、无 driver-path 校验 hook)。**与 JDBC 参考连接器 `JdbcDorisConnector` 完全 parity**(其亦 CREATE-time-only)。**用户定接受**([D-050]):默认配置 permissive(`secure_path="*"`/whitelist 空)无可绕,唯一暴露 = 硬化部署后**收紧** whitelist/secure-path 又**不重建** catalog。**复评/follow-up(跨连接器)**:若需 close,须 fe-core 改(ALTER 路 `checkProperties`→`preCreateValidation`,注意会触发 JDBC 连接器的 BE 连通测)+ scan-time 校验须新 `ConnectorContext` SPI hook——影响全 plugin 连接器、独立工单 | [task-list #4](./task-list-P5-rereview2-fixes.md) / [P5-fix-JDBC-DRIVER-URL 设计](./tasks/designs/P5-fix-JDBC-DRIVER-URL-design.md) / [D-050](./decisions-log.md) | 2026-06-11 | 🟢 已登记(CREATE-time parity,用户接受+跨连接器 follow-up)| +| DV-029 | P5-fix#4 FIX-JDBC-DRIVER-URL 两 scope-out(surgical):① 连接器 `PaimonCatalogFactory.resolveDriverUrl` 是 legacy `JdbcResource.getFullDriverUrl` 的**简化子集**——只做 scheme 解析(裸名→`file://{jdbc_drivers_dir}/{name}`),**不**做文件存在性 / legacy 旧 `jdbc_drivers/` 回退 / 云下载。常见情形(`mysql.jar`+默认 dir)两者等价;仅装旧 dir 的 jar 会 BE 找不到(pre-existing 简化、FE 注册路本就如此、复用未改)。② **BE-side `paimon.jdbc.{user,password,uri}` 别名丢弃不修**——同 `startsWith("jdbc.")` filter 也丢这些别名键,但 **BE 不需要**:`PaimonJniScanner.initTable` 从 `serialized_table` 反序列化整表、**不**从 options_json 重建 JdbcCatalog;BE 唯一消费 jdbc 选项处 `PaimonJdbcDriverUtils.registerDriverIfNeeded` 只读 driver_url/driver_class。legacy `getBackendPaimonOptions` 亦仅发 driver_url+driver_class(窄)。故 B-8a 只修 driver_url/class 即 parity(scope-critic lens LGTM 确认) | [task-list #4](./task-list-P5-rereview2-fixes.md) / [P5-fix-JDBC-DRIVER-URL 设计](./tasks/designs/P5-fix-JDBC-DRIVER-URL-design.md) / [D-050](./decisions-log.md) | 2026-06-11 | 🟢 已登记(surgical scope-out,BE 经 trace 确认安全)| +| DV-027 | P5-fix#3 FIX-SCHEMA-EVOLUTION:history_schema_info 用 **eager 全量** `SchemaManager.listAllIds()`+`schema(id)`(每 scan、**无 cache**),非 legacy 的 per-split 引用 schema 懒读+缓存(`PaimonScanNode.putHistorySchemaInfo`→`PaimonUtils.getSchemaCacheValue`)。理由:Design C 的 scan 级缝 `populateScanLevelParams` 拿不到 split 集(那是 `planScan` 才有),故无法只读引用到的 schema;listAllIds() 全集**保证**覆盖任意 native 文件的 `schema_id`(BE `table_schema_change_helper.h:259-263` 缺 entry 会 fail-loud `InternalError`,全集即杜绝)。**两点接受**:① perf——K 个 schema 版本= K 次小 JSON 读/scan(props 每 node 缓存一次、非 per-split);② 鲁棒性微回归——某**未被引用**的 schema-N JSON 瞬时不可读会令本 scan 失败(fail-loud 传播,镜像 legacy `putHistorySchemaInfo` 不吞异常),而 legacy 因只读引用 schema 不碰它、可完成。correctness-safe(全集是 legacy 引用集的超集、绝不触发 BE InternalError);review 评 MINOR。未来优化=引用集(需 split-aware 缝)或连接器侧 cache | [task-list #3](./task-list-P5-rereview2-fixes.md) / [P5-fix-SCHEMA-EVOLUTION 设计](./tasks/designs/P5-fix-SCHEMA-EVOLUTION-design.md) / [D-049](./decisions-log.md) | 2026-06-11 | 🟢 已登记(MINOR perf+鲁棒性,接受 fail-loud)| +| DV-026 | P5-fix#3:**M-10(`Column.uniqueId=-1`)deferred 不修**(task-list #3 原含 M-10)。Design C 直接从 paimon `DataField.id()` 建 `history_schema_info` 的 `TField.id`,B-1a(field-id 匹配)**完全独立于** Doris `Column.uniqueId` → M-10 对 B-1a correctness 无关。rereview2 §4 已 majority-refute M-10 standalone repro(BE field-id 路不读 tuple descriptor、唯一 legacy `Column.uniqueId` 消费者 `ExternalUtil.initSchemaInfo` 经 legacy scan node 翻闸后已死)→ 无 demonstrated user-visible 消费者。故 deferred(非本 fix 必需、Design C 不穿 ConnectorColumn/ConnectorType field-id channel)。**复评触发**:若未来出现 field-id 消费者(如 SPI-on iceberg/hudi 经 `ExternalUtil` 从 Doris 列建 history schema),须重启 M-10(穿 `ConnectorColumn.fieldId`+`ConnectorType` 嵌套 id+`ConnectorColumnConverter.setUniqueId` 递归)| [task-list #3](./task-list-P5-rereview2-fixes.md) / [P5-fix-SCHEMA-EVOLUTION 设计](./tasks/designs/P5-fix-SCHEMA-EVOLUTION-design.md) / [D-049](./decisions-log.md) | 2026-06-11 | 🟢 已登记(M-10 deferred,无消费者)| +| DV-025 | P5-fix-FIX-URI-NORMALIZE:`normalizeStorageUri` 用 catalog **静态** `getStoragePropertiesMap()` 做 scheme 归一化,**非** legacy `PaimonScanNode:171` 的 vended-overlay 版(`VendedCredentialsFactory.getStoragePropertiesMapWithVendedCredentials`)。理由:scheme 归一化(oss/cos/obs/s3a→s3、bucket.endpoint→bucket)与 vended 凭据正交——vended 只改 `AWS_*` 键、不改 scheme/bucket 形;只要 warehouse endpoint 静态配置(OSS/COS/OBS 绝大多数情形必配,否则连不上)静态 map 即含该 type entry,归一化与 legacy 等价。唯一分歧 = *纯-vended、无静态存储配* 的 REST catalog:静态 map 可能缺 entry → `LocationPath.of` fail-loud 抛(legacy vended-overlay 版不抛)。该边角**与凭据缝重叠、本 fix 显式不收**,归 task-list #2 `FIX-STATIC-CREDS-BE` / `FIX-REST-VENDED`(review §9.3 三道凭据缝之一)。fail-loud 优于静默送裸 `oss://`(后者 DV 错行)| [task-list #1](./task-list-P5-rereview2-fixes.md) / [P5-fix-URI-NORMALIZE 设计](./tasks/designs/P5-fix-URI-NORMALIZE-design.md) / [SPI RFC §21](./01-spi-extensions-rfc.md) | 2026-06-11 | 🟢 已登记(scope 决策,凭据边角归 #2/#3)| +| DV-024 | P5-B4 揭出并修复 B2 遗留缺陷(普通 paimon plugin 表 BE 描述符错型):`PaimonConnectorMetadata` 不 override `buildTableDescriptor`(SPI default 返 null)→ `PluginDrivenExternalTable.toThrift` 走 fallback `SCHEMA_TABLE`(BE `descriptors.cpp:635` 建 `SchemaTableDescriptor`),而 legacy `PaimonExternalTable.toThrift` + sys 表须 `HIVE_TABLE`(`:644` `HiveTableDescriptor`)。B4/T19 加 `buildTableDescriptor` override(`HIVE_TABLE`+`THiveTable`,镜像 legacy + MC `MaxComputeConnectorMetadata.buildTableDescriptor`),**一处修同时正普通表+sys 表**。inert until 翻闸(paimon 未入 `SPI_READY_TYPES`),真值闸=live-e2e BE 描述符 | [tasks/P5 T19](./tasks/P5-paimon-migration.md) / [D-039](./decisions-log.md) | 2026-06-10 | 🟢 已修正(T19,live-e2e 待验)| +| DV-023 | RFC §10(E7 Sys Tables)设计被 P5-B4 取代:RFC §10 的「sys-table = `$`-后缀普通表 + 连接器 `getTableHandle` 内解析后缀 + `listSysTableSuffixes`」**从未实现**;live fe-core 实为 `SysTableResolver`+`NativeSysTable`+`TableIf.getSupportedSysTables/findSysTable`(iceberg + legacy-paimon 共用)。B4 按 [D-039](./decisions-log.md) 复用该 live 机制(连接器 `listSupportedSysTables`+`getSysTableHandle`,fe-core 通用 `PluginDrivenSysExternalTable`),RFC §10 加脚注标 superseded | [01-spi-extensions-rfc.md §10](./01-spi-extensions-rfc.md) / [D-039](./decisions-log.md) | 2026-06-10 | 🟢 已修正(RFC §10 脚注 + D-039)| +| DV-022 | P4-T09 §8:fe-common 去 odps 暴露隐藏传递依赖(依赖卫生,非缺陷)——`odps-sdk-core` 此前**传递**为 fe-common 自身 `DorisHttpException`(io.netty) / `GsonUtilsBase`(com.google.protobuf) 提供 jar;删 odps-sdk-core 后编译暴露缺失,故 fe-common/pom 显式补 `netty-all`+`protobuf-java`(parent dependencyManagement 管版本)。设计 §8 原假设「odps 仅服务 MCUtils」不全 | [Batch-D 设计 §8](./tasks/designs/P4-batchD-maxcompute-removal-design.md) / [D-027] | 2026-06-09 | 🟢 已修正(显式声明,`409300a75b8`)| +| DV-021 | P4-T3:Batch-D 删除后 4 条 Tier-3 接受项(minor,legacy 已删故现为既定行为,非丢数据,用户定接受不修)——**GAP3** CREATE DB 非-IFNE 远端已存→本地预抛 `ERR_DB_CREATE_EXISTS`(1007);**GAP4** DROP TABLE 非-IF-EXISTS+远端缺→通用 `ERR_UNKNOWN_TABLE`(1109);**GAP9** SHOW PARTITIONS `LIMIT`:sort-then-paginate(vs legacy paginate-then-sort,更合 ORDER-BY-LIMIT);**GAP10** partitions() TVF schema-分区零实例表→返 0 行(vs legacy 抛,in-code 注释声明 intentional) | [Batch-D 红线](./task-list-batchD-redline-gaps.md) | 2026-06-09 | 🟢 已登记(Tier-3 接受)| +| DV-020 | P4-T06e FIX-CAST-PUSHDOWN:getSplits 的 limit-suppress wiring + MC 端到端 CAST-strip 无 fe-core 单测(KNOWN-LIMITATION)+ JDBC applyLimit 同类 under-return(OUT-OF-SCOPE 备查)。**① harness gap**:纯静态 `effectiveSourceLimit(limit,stripped)` 已 UT 2 + mutation 2/2(drop-suppression/always-suppress)向红 pin;连接器 `supportsCastPredicatePushdown=false` 已 UT + mutation(false→true 红) pin;但「`getSplits` 据 `filteredToOriginalIndex!=null` 调 `effectiveSourceLimit`」+「`buildRemainingFilter` 对 MC 真剥 CAST conjunct 并保留 BE-only」的端到端 wiring **无 offline 直测**(构造 `PluginDrivenScanNode` 需 harness、本模块缺,同 [DV-015])。覆盖经:strip-when-false 是 fe-core 共享逻辑(JDBC false 分支既覆盖)+ 纯 helper UT/mutation + **live e2e 真值闸**(STRING 列存 `"5"/"05"/" 5"`,`WHERE CAST(code AS INT)=5` 返回全部 3 行 / limit-opt ON+CAST+LIMIT 不 under-return;EXPLAIN 证 CAST 谓词不在下推 filter)。**② OUT-OF-SCOPE(Rule 12 surface)**:JDBC 若 session 关 cast-pushdown 且经 `applyLimit` 推 limit,理论同类 under-return;但 MaxCompute 不 override `applyLimit`(no-op)、F9 的 getSplits limit-param 抑制对 MC 完整,JDBC `applyLimit` 路径非本修范围(pre-existing、非 MC),登记备查、待评估。fail-safe:误关下推退化为多读行交 BE(非丢数据) | [FIX-CAST-PUSHDOWN 设计](./tasks/designs/P4-T06e-FIX-CAST-PUSHDOWN-design.md) / [D-036] | 2026-06-08 | 🟢 已登记(helper+capability UT/mutation;wiring 待 live e2e;JDBC applyLimit 备查)| +| DV-019 | P4-T06e FIX-BATCH-MODE-SPLIT 异步 batch wiring + `computeBatchMode` null-guard 无 fe-core 单测(KNOWN-LIMITATION,NG-7):纯静态四闸 `shouldUseBatchMode` 已 UT 9 + mutation 5/5 向红 pin;但 ① `computeBatchMode` 的 SF-1 `scanProvider != null` null-guard(provider-less full-adopter 防 NPE,跑 dispatch+explain 两路径)与 ② `startSplit` 的 async 分批循环(`getScheduleExecutor` outer/inner CompletableFuture + `SplitAssignment` `needMoreSplit/addToQueue/finishSchedule/setException/isStop` 契约 + init 30s 首-split)+ ③ `numApproximateSplits` 取值——三处 wiring **无 offline 直测**:构造 `PluginDrivenScanNode`(`FileQueryScanNode` 子类)需绕 ctor + stub connector/session/handle/desc/sessionVariable/splitAssignment,本模块无现成轻量 spy/analyze harness(同 [DV-015]/[DV-014] 因)。覆盖经:逐字镜像 legacy `MaxComputeScanNode:214-298`(已验 parity)+ 纯 helper UT/mutation + **大分区 live e2e 真值闸**(EXPLAIN/profile 证 batched/streamed split、规划耗时/内存 ≪ 同步路;阈值边界 `num_partitions_in_batch_mode`=0/大于选中数→回退非-batch;全空选/单分区)。impl-review `wve7y1jst` TQ-1 已据此把测试 javadoc 的「null-provider 已覆盖」声明诚实降级。fail-safe:去 batch 退化为同步 `getSplits`(非丢数据)。**⚠ SUPERSEDED-IN-PART(2026-07-11,reverify M3 = [`FIX-M3-design.md`](./tasks/designs/FIX-M3-design.md))**:本偏差登记的 wiring/null-guard/async-harness test-gap **不变**;仅其关联的 impl-review LP-1「`!isPruned` 等价 `!= NOT_PRUNED`」判定被推翻(详见 [D-035] 批注)——闸门订正 `== NOT_PRUNED`、pinning 测试 `testUnprocessedPruningNeverBatches` 已反转为 `testNoPredicatePartitionedTableBatches`(assertTrue)。 | [FIX-BATCH-MODE-SPLIT 设计](./tasks/designs/P4-T06e-FIX-BATCH-MODE-SPLIT-design.md) / [D-035] | 2026-06-08 | 🟢 已登记(helper UT+mutation,wiring 待外表 scan harness / live e2e)| +| DV-018 | P4-T06e FIX-POSTCOMMIT-REFRESH cutover post-commit 刷新 swallow 有意分歧于 legacy(无产线逻辑改动,NG-8/F15=F21 minor,regression=no):`PluginDrivenInsertExecutor.doAfterCommit()` 用 try/catch 吞 `super.doAfterCommit()`(=`handleRefreshTable`)刷新失败、INSERT 报 OK;legacy `MCInsertExecutor` 不 override → 异常传播 → 报 FAILED。**cutover 更安全**:按生命周期序数据已落 ODPS/远端、FE 无法回滚,`handleRefreshTable` 只刷 FE 缓存 + 写 external-table refresh editlog(follower 失效提示、非数据真相源)、不碰已提交数据 → 报 FAILED 诱发重试→重复写。**用户定(2026-06-08)接受 + Javadoc 泛化([D-034])、不回退**。改 = 仅 Javadoc(`:164-176`) 从「只讲 JDBC_WRITE」泛化到覆盖 MC connector-transaction 路径(两路径数据均已持久;swallow 最坏只瞬时缓存 stale 自愈;显式注明分歧 legacy)。对抗性安全核查:master 先本地刷新(`RefreshManager:152`)后写 editlog(`:155`),丢 editlog 仅 follower 缓存暂 stale 自愈、无正确性损失/无主从分裂。swallow 路径无新增 UT(注释 only、无可 pin 逻辑变化;异常吞行为 offline 直测受同类 harness 缺位限制,同 [DV-015]);真值闸=CI-skip live e2e(MC INSERT 后人为令 refresh 失败→断言报 OK + warn)。守门 checkstyle 0、import-gate 净 | [FIX-POSTCOMMIT-REFRESH 设计](./tasks/designs/P4-T06e-FIX-POSTCOMMIT-REFRESH-design.md) / [D-034] | 2026-06-08 | 🟢 已登记(无逻辑改动,行为收敛接受;live 真值闸待跑)| +| DV-017 | P4-T06e FIX-ISKEY-METADATA `getTableSchema→buildColumn` wiring 无连接器内单测(KNOWN-LIMITATION):`buildColumn` 助手 isKey=true 不变式已 UT+mutation pin,但两 `getTableSchema` 调用点经 `buildColumn` 的 wiring 无 offline 测——`getTableSchema` deref live `com.aliyun.odps.Table`(唯一 ctor package-private)、模块无 Mockito(同 [DV-014]/[DV-015]/[DV-016] 类);唯一 offline 变通=`com.aliyun.odps` 包内 fixture 子类 override `getSchema()`,repo 无先例(sibling `getColumnHandles` 同样未测)。绕过 `buildColumn`(回退 5 参 ctor)的回归仅由 CI-skip live e2e `DESCRIBE ` 显 Key=YES 捕获(load-bearing gate)。**作用域注**:`information_schema.columns.COLUMN_KEY` 受 `FrontendServiceImpl:962-965` OlapTable 门控、MC 前后皆空、已 parity、out-of-scope(不可断言其变非空);isKey 非纯展示(亦喂 `UnequalPredicateInfer`/BE descriptor),但 legacy 即喂 true → 本修恢复既有值 | [FIX-ISKEY-METADATA 设计](./tasks/designs/P4-T06e-FIX-ISKEY-METADATA-design.md) / [D-033] | 2026-06-08 | 🟢 已登记(helper UT+mutation,wiring 待 live DESCRIBE)| +| DV-016 | P4-T06e FIX-LIMIT-SPLIT-DEFAULT 三点(均 opt-in 默认 OFF、非丢行/非回归):① **CAST-unwrap 致 limit-opt 资格略宽于 legacy**——converter `convert(CastExpr)→convert(child)` 在所有位置剥 CAST(左列/右 literal/IN 元素),故 `CAST(partcol AS T)=lit`、`partcol=CAST(lit AS T)`、`partcol IN (CAST(lit,…))` 经 `checkOnlyPartitionEquality` 判资格,legacy 见原始 `CastExpr` 子节点 instanceof 失败→false;② **嵌套-AND-作单 conjunct 略宽**——converter `flattenAnd` 把单 conjunct `(pt=1 AND region=cn)` 摊平成 flat `ConnectorAnd`→资格,legacy 见 `CompoundPredicate` conjunct→false(与①同安全类,且 conjunct 拆分通常上游已分);③ **`LIMIT 0` 路径差**——本 fix `limit<=0` 拒 limit-opt 走标准多 split 路,legacy `hasLimit()`(`limit>-1`) 走 limit-opt 路;两者皆 0 行、且 `LIMIT 0` 被 Nereids 折成 EmptySet 不可达。①②均纯分区、correctness-safe(裁剪 Nereids `SelectedPartitions` 同算 + 转换后 `filterPredicate` 仍下推 read session 作 backstop,`:191/:208/:353`;LIMIT 无 ORDER BY 无序)。**另**:planScan 两行 wiring(`isLimitOptEnabled(session.getSessionProperties())` + `shouldUseLimitOptimization(...)` 收 live filter/partitionColumnNames)无连接器内单测——`planScan` 需 live odps `Table`、模块无 fe-core/Mockito(同 [DV-014]/[DV-015] 因);纯 helper 全 UT(26)+mutation(8 向红) pin,wiring 半由 CI-skip live E2E 守。**附**:本 fix 实 `checkOnlyPartitionEquality` 同闭 F2/F12(旧恒 false stub minors)| [FIX-LIMIT-SPLIT-DEFAULT 设计](./tasks/designs/P4-T06e-FIX-LIMIT-SPLIT-DEFAULT-design.md) / [D-032] | 2026-06-08 | 🟢 已登记(opt-in 非回归 + 逻辑 UT/mutation,wiring 待 live E2E)| +| DV-015 | P4-T06e FIX-PRUNE-PUSHDOWN 端到端裁剪下推 wiring 无 fe-core 单测(KNOWN-LIMITATION):`getSplits()` pruned-to-zero 短路 + translator `setSelectedPartitions` 注入 + `getSplits→planScan` 6 参 threading 无 fe-core 端到端 UT(连接器 scan 无轻量 analyze/spy harness,同 [DV-014] 因)。逻辑半(`PluginDrivenScanNode.resolveRequiredPartitions` 三态 + `MaxComputeScanPlanProvider.toPartitionSpecs` 转换)已 UT+mutation pin;wiring 半 + 真实裁剪生效由 p2 live `test_max_compute_partition_prune.groovy` 覆盖(真值=EXPLAIN/profile 仅扫目标分区 + `WHERE pt='不存在'`→0 行不建全分区 session)。与既有约定一致(`HiveScanNodeTest` 亦直构 node 测 setter、不经 translator)| [FIX-PRUNE-PUSHDOWN 设计](./tasks/designs/P4-T06e-FIX-PRUNE-PUSHDOWN-design.md) / [D-031] | 2026-06-08 | 🟢 已登记(逻辑 UT+mutation,wiring 待 live;外表 scan analyze/spy harness 落地后补)| +| DV-014 | P4-T06e FIX-BIND-STATIC-PARTITION bind 期投影无 fe-core 单测(KNOWN-LIMITATION):`bindConnectorTableSink` 的 full-schema 投影(NULL 填充 + 分区列在末尾 + 按位置投影)未被 connector-path 单测直接 pin——`bind()` 走 `RelationUtil.getDbAndTable` 真 Env 解析,外表 PluginDriven catalog 需连接器插件,无现成轻量 analyze harness(OLAP analyze 测仅覆盖 `createTable` 内表)。覆盖经:①与 legacy `bindMaxComputeTableSink` 及 Iceberg 路径**共享** helper `getColumnToOutput`/`getOutputProjectByCoercion`(被既有 OLAP/Hive/Iceberg insert 测充分覆盖);②列选择 helper `selectConnectorSinkBindColumns` 单测 + 分布 full-schema 索引测(要求 child full-schema 序方过);③p2 live `test_mc_write_insert` Test 3/3b(部分/重排列名)+ `test_mc_write_static_partitions`。capability 声明/reader 按既有约定不单测(既有 readers 亦仅被 mock)| [FIX-BIND-STATIC-PARTITION 设计](./tasks/designs/P4-T06e-FIX-BIND-STATIC-PARTITION-design.md) / [D-030] | 2026-06-07 | 🟢 已登记(无 harness,parity+p2 覆盖;待外表 analyze harness 落地补)| +| DV-013 | P4-T06e FIX-WRITE-DISTRIBUTION 两处 planner 写分发 parity 微差(均非回归,default `strict` 下与 legacy MC 同果):① `ShuffleKeyPruner` connector 分支缺 `enableStrictConsistencyDml` 短路 → non-strict 下少剪 shuffle-key(更保守 missed optimization);② `enable_strict_consistency_dml=false` 下动态分区 local-sort 被丢(legacy MC 亦丢)| [FIX-WRITE-DISTRIBUTION 设计](./tasks/designs/P4-T06e-FIX-WRITE-DISTRIBUTION-design.md) / [D-029] | 2026-06-07 | 🟢 已登记(非回归,接受)| +| DV-012 | P4-T04 `TMaxComputeTableSink.partition_columns`(field 14) 源:legacy `MaxComputeTableSink` 取 `targetTable.getPartitionColumns()`(fe-core Doris `Column`);连接器 `MaxComputeWritePlanProvider.planWrite` 取 `odpsTable.getSchema().getPartitionColumns()`(odps-sdk 列)——**源不同、值同**(分区列名)| [tasks/P4 P4-T04](./tasks/P4-maxcompute-migration.md) / [P4-T04 设计](./tasks/designs/P4-T04-write-plan-design.md) | 2026-06-06 | 🟢 已落地(P4-T04,值等价)| +| DV-011 | P4-T03 连接器事务 block 上限源:legacy fe-core `Config.max_compute_write_max_block_count`(fe.conf 可调,默认 20000)→ 连接器常量 `MAX_BLOCK_COUNT=20000L`(import-gate 禁 `common.Config`,丢可调性);附 legacy `throws UserException`→`DorisConnectorException`(unchecked,SPI 面无 checked throws)| [tasks/P4 P4-T03](./tasks/P4-maxcompute-migration.md) / [P4-T03 设计](./tasks/designs/P4-T03-write-txn-design.md) | 2026-06-06 | 🟢 已修正(P4-T03 硬编 → GC1 经 session-property 透传恢复 fe.conf 可调,`95575a4954d`)| +| DV-010 | P4-T01 修共享 fe-core `ConnectorColumnConverter.toConnectorType` 丢 CHAR/VARCHAR 长度(写 `precision=0`;长度存 `len` 非 `precision`)→ CREATE TABLE 经 SPI 丢长度。特判 CHAR/VARCHAR 把 `getLength()` 写入 precision 字段(与逆 `convertScalarType`+`MCTypeMapping` 约定一致)| [tasks/P4 P4-T01](./tasks/P4-maxcompute-migration.md) / `ConnectorColumnConverter` | 2026-06-06 | 🟢 已修正(P4-T01)| +| DV-009 | W5 写 sink 收口位置:RFC/handoff「route 3 个 visitPhysicalXxxTableSink + 新建 PluginDrivenTableSink」与代码不符;plugin-driven 写经 `visitPhysicalConnectorTableSink` + 既有 `PluginDrivenTableSink`,W5 改为在其上 layer `planWrite()` | [写 RFC §5.5/§12 W5](./tasks/designs/connector-write-spi-rfc.md) / [HANDOFF W5](./HANDOFF.md) | 2026-06-06 | 🟢 已修正(W5 `9ebe5e27fa4`)| +| DV-008 | P3-T07 parity 两处 SPI↔legacy 偏差:列名 casing 当场修;Hudi meta-field 推迟批 E | [tasks/P3 §批C/T07](./tasks/P3-hudi-migration.md) | 2026-06-05 | 🟢 已修正 | +| DV-007 | P3 批 B scope 校正:T05 `listPartitions*` override 推迟批 E(零 live caller、Hive 不 override);T06 MVCC 保持 default opt-out(非抛异常 override)| [HANDOFF 未完成 #1/#2](./HANDOFF.md) / [tasks/P3 T05/T06](./tasks/P3-hudi-migration.md) | 2026-06-05 | 🟢 已修正(T05 裁剪已落地;list*/MVCC 入批 E)| +| DV-006 | P3-T03 schema_id/history 非批 A 可修(连接器缺 field-id/InternalSchema/type→thrift;裸基线会回归);推迟批 E | [HANDOFF 1b ①](./HANDOFF.md) / [tasks/P3 T03](./tasks/P3-hudi-migration.md) | 2026-06-05 | 🟡 推迟(批 E)| +| DV-005 | P3 hudi「HMS-over-SPI 前置依赖」与代码不符;真阻塞=catalog 模型错配 | [connectors/hudi.md](./connectors/hudi.md) / [master plan §3.4](./00-connector-migration-master-plan.md) / D-005 | 2026-06-04 | 🟡 待修正(P3 模型决策)| +| DV-004 | T13 用户向安装文档不在本代码仓(在 doris-website 仓) | [tasks/P2 T13](./tasks/P2-trino-connector-migration.md) | 2026-06-04 | 🟢 已修正 | +| DV-003 | T12 回归测试引用不存在的先例/目录且本地不可运行 | [tasks/P2 T12](./tasks/P2-trino-connector-migration.md) | 2026-06-04 | 🟡 推迟 | +| DV-002 | T11 无法 mock Trino plugin;JsonSerializer 非纯单元 | [tasks/P2 T11](./tasks/P2-trino-connector-migration.md) | 2026-06-04 | 🟢 已修正 | +| DV-001 | 批 D 范围遗漏 ExternalCatalog db 路由 + legacy test | [tasks/P2 T08-T10](./tasks/P2-trino-connector-migration.md) | 2026-06-04 | 🟢 已修正 | + +--- + +## 详细记录(时间倒序) + +### DV-047 — P6.4 iceberg procedures:perf / cosmetic / behaviour-equivalent / dispatch-order(结果恒等 / dormant / 行为等价;镜像 DV-044 批 style) +- **状态**:🟢 已登记(accept;非正确性——结果恒等/展示/dormant/接缝/幂等)|**日期**:2026-06-24|**签字**:各项已随 T04/T06/T07 task 用户签字,本条为 P6.4-T08 中央汇总 +- **原计划位置**:T04/T06/T07 设计 §10([procedure-spi-design](./tasks/designs/P6.4-T01-procedure-spi-design.md))+ [task 表 §P6.4](./tasks/P6-iceberg-migration.md) +- **范围**:P6.4 procedure 迁移共 ~10 项 UT 不可见但**非正确性**的偏差,批化为一条。要点分类: + - **cache 失效搬 dispatch 级 + 短路多失效(T04)**:各 action body 内 fe-core-only `ExtMetaCacheMgr.invalidateTableCache` 删,搬 `IcebergProcedureOps.runInAuthScope` 经 `context.getMetaInvalidator().invalidateTable`(default NOOP)。正常返回即失效(**含 no-op 短路**,legacy 短路 early-return 前不失效)⇒ 幂等于未变表,pre-flip 微差。**精确语义**:失效(与 auth-wrap)只在 `context != null` 分支(`IcebergProcedureOps.java:104-117`);`context == null`(仅离线测试路)跑 body **不**裹 auth 且 **跳过**失效。生产接线 context 恒非 null。UT 钉:`rollbackToCurrentSnapshotStillInvalidates`(短路仍失效)/`bodyFailureAfterSuccessfulLoadDoesNotInvalidate`(body 失败不失效)。 + - **`executeAction` 加 `ConnectorSession` 参(T04)**:legacy 读 thread-local `ConnectContext` 取会话 TZ;连接器够不到,`BaseIcebergAction.execute(Table)`→`execute(Table,ConnectorSession)`、`executeAction` 同(7 个非 TZ body 忽略,仅 `rollback_to_timestamp` 消费)。SPI `ConnectorProcedureOps` 签名 + factory `createAction` 签名都不动(纯连接器内部接缝)。 + - **DV-T08-loadwrap(新错误串,无 legacy 对应)**:`IcebergProcedureOps.runInAuthScope:112-114` 把 `catalogOps.loadTable` 的非-`DorisConnectorException` 失败裹为 `"Failed to load iceberg table .: "`——legacy 在表解析期(`IcebergExternalTable.getIcebergTable()`)加载、action 外,无此 wrapper;auth-add 把 SDK load 移进 `executeAuthenticated` 才产生。结果等价:引擎命令再裹 `"Failed to execute action:"`(`ExecuteActionCommand`),且仅 catalog-load 失败触发(ported body 自身失败已 re-wrap `DorisConnectorException` 在 `:110-111` 原样 re-throw);镜像写路径 `IcebergWritePlanProvider` 同款 wrapper。UT 钉:`IcebergProcedureOpsTest.wrapsLoadTableFailure`。 + - **DV-T08-factory-advertise(factory 列表/switch 不一致,dormant)**:连接器 `IcebergExecuteActionFactory.getSupportedActions()`/`getSupportedProcedures()` 广告 9 名(含 `rewrite_data_files`),但 `createAction` switch 只 8 case(无 `rewrite_data_files`)→ 拒 `"Unsupported Iceberg procedure: rewrite_data_files. Supported procedures: ..., rewrite_data_files, ..."`(自指)。pre-flip 偏差 vs legacy(legacy 有真 case 派发 `rewrite_data_files`),**dormant**(EXECUTE on iceberg pre-flip 走 legacy;整连接器 factory 翻闸前不可达)+ 有意(body 待 T05/T06 写半接线,= DV-045 翻闸阻塞)。UT canary:`IcebergExecuteActionFactoryTest.rewriteDataFilesIsAdvertisedButNotYetExecutable`(接线时转红)。 + - **DV-T06r-scanpool(T06, perf-only)**:`commitRewriteTxn` 丢 legacy `scanManifestsWith(threadPool)`(`IcebergTransaction:258`)→ SDK 默认 worker 池,对齐连接器 append 路;提交结果字节等价。REWRITE-scoped 同 DV-044 的 INSERT-路 DV-T04-a/T05-f。 + - **DV-T06r-zone(T06, benign)**:rewrite-added 文件分区值经 session-TZ 解析(`convertCommitDataToFilesToAdd`→`IcebergWriterHelper.convertToWriterResult(...,zone)`),复用 INSERT 的 zone-aware 路(= 既有 DV-T04-f);行为等价(timestamptz 分区值会话 TZ、plain timestamp UTC),仅显式线程化 zone 而非 thread-local。rewrite 路新触发,UT 不单测(共享 INSERT/overwrite 分区路覆盖)。 + - **DV-T06r-rollback(T06, 中性)**:`rollback()` 不清 `filesToDelete`/`filesToAdd`(legacy `isRewriteMode` 清,`IcebergTransaction:562-572`)。单 txn/语句生命周期中性(ConnectorTransaction 单用一语句、rollback 后丢弃;陈旧列表永不被读——唯一读者 `getFilesTo*Count/Size` 在 commit 前、`commitRewriteTxn` rollback 后不可达)。 + - **DV-T07-where(T07, fail-loud dormant)**:连接器 EXECUTE 派发 WHERE 拒——`ConnectorExecuteAction.execute:121-123` 对任何 present WHERE 抛 `DdlException`(连接器前,恒收 null WHERE)。三处发散 vs legacy(消息文案 action-specific vs 统一;时序 validate-内 vs execute-内;范围 8 pure-SDK vs 全部,因 rewrite_data_files 写半未接线 R-B);两侧皆 `UserException` 子类 ⇒ run() 前缀保。fail-loud 胜静默丢(Rule 12),dormant。UT 钉:fe-core `executeRejectsWhereConditionUntilLoweringLands`。 + - **DV-T07-name-order(T07, 有意发散 priv-first)**:未知 procedure 名校验时序——legacy `createAction` 期抛(priv 前)vs 连接器路 priv 后由 connector 拒(adapter `isSupported()` 恒 true,名校验在 `IcebergProcedureOps.execute`→factory)。priv-first 更安全(不向无权用户泄漏 procedure 存在性)+ 不在 authz 前碰连接器;「引擎保 priv/连接器拥含名派发」分工支持。 + - **DV-T07-exc-contract(T07, byte-parity 边界)**:adapter 只 `catch(DorisConnectorException)`;连接器契约=arg 失败已 re-wrap `DorisConnectorException`(`BaseIcebergAction.validate:93-94`),唯一逃逸的非-`DorisConnectorException` = 单行 `Preconditions.checkState` 的 `IllegalStateException`——与 legacy `BaseExecuteAction.execute:113-114` 同(其 checkState 也非 `UserException`、也不被 run() 前缀)。UT 钉:fe-core `executeEnforcesSingleRowWidthInvariant` + 连接器 `BaseIcebergActionTest.executeFailsLoudWhenRowWidthDoesNotMatchSchema`(含 message 字节)。 + - **present-empty / 星号 `PARTITION(*)` 拒绝不对称(low, dormant)**:legacy `validateNoPartitions` 按 `isPresent()` 拒(含星号 spec);连接器 adapter 把 `Optional`→`getPartitionNames`(星号→空)后连接器 `validateNoPartitions(!isEmpty())` **不**拒 ⇒ `EXECUTE proc(...) PARTITIONS(*)` 在无分区 procedure 上 legacy 拒、连接器接受。文法可达(`DorisParser` `alterTableExecute` `partitionSpec?` 含星号 alt);EXECUTE+分区对这些 procedure 无意义 + dormant。无修,P6.6 docker 闸。 + - **null-body-row 编码不对称(low)**:连接器把 null body-row 编码为 `(declared-schema, emptyRows)` vs legacy 丢元数据返 null;parity 由 `ConnectorExecuteAction.wrapResult` 的 `rows.isEmpty()→null` 恢复(三态 OR vs legacy 两态)。潜伏(现 9 procedure 无返 null-row)。UT 钉:fe-core `executeReturnsNullResultSetWhenConnectorReturnsNoRows`。 + - **per-conjunct scan.filter() 形状(T05, 结构等价)**:planner 把 WHERE 降为 `List` 逐合取 `scan.filter()`(iceberg 内部 AND)vs legacy 单 `Expressions.and()` 合并 filter——扫描结果字节同,纯结构。UT 钉:`RewriteDataFilePlannerTest.whereTopLevelAndAppliesEveryConjunct`。 +- **为何可接受**:全部**非正确性**——结果恒等(scanpool/zone/per-conjunct)、连接器内部接缝(session 参)、dormant(factory-advertise/WHERE 拒/星号分区)、有意更安全(priv-first)、byte-parity 边界(exc-contract)、幂等(cache 短路)、潜伏(null-row)、新串结果等价(loadwrap)。 +- **真值闸**:P6.6 docker(翻闸后回归)逐项确认无害。 +- **关联**:[DV-044](P6.3 同 style 批)、[DV-040]/[DV-035](同 style)、[DV-045](DV-T08-factory-advertise 接线 = DV-045)、[DV-046]、T04/T06/T07 task record。 + +### DV-046 — P6.4 iceberg procedures:parity-忠实 correctness-bearing 但 UT 不可见 deviation(auth-add Kerberos + rewrite WHERE conflict-mode;docker / dormant 闸) +- **状态**:🟢 已登记(accept;parity-by-construction 或 dormant+用户签字;docker/Kerberos 真值闸)|**日期**:2026-06-24|**签字**:auth-add 随 T04;DV-T05r-where = 用户签字 Option A(2026-06-24) +- **原计划位置**:T04 设计 §10(auth)+ T05 设计 §5/§10 + [task 表 §P6.4 T04/T05](./tasks/P6-iceberg-migration.md) +- **范围**:与 [DV-047] 区别 = 本条各项**承载正确性**,但 parity-by-construction(auth)或 dormant+用户签字+常见路零差异(rewrite WHERE)。 + - **auth-add(T04, Kerberos)**:8 个 snapshot mutator 的 `loadTable`+body 的 SDK `manageSnapshots()/...commit()` 现裹 `context.executeAuthenticated`(`IcebergProcedureOps.runInAuthScope:108-109`,**当 `context != null`**——见 DV-047 精确语义)。legacy fe-core action 提交 snapshot mutator **未** authenticate(潜伏 Kerberized-catalog auth bug)。**auth 是加非丢**、修向 parity;离线 InMemoryCatalog 无 auth 不可分辨真 KDC `doAs` ⇒ 留 docker/live。UT 钉 wiring:`runsBodyInAuthScopeAndInvalidatesTableAfterCommit`(`authCount==1`)/`argumentValidationRunsBeforeTouchingTheCatalog`(validate 先于 auth)。跨引用 [DV-031]/DV-043 同类 auth-wrap。 + - **DV-T05r-where(T05, 用户签字 Option A 2026-06-24)**:`rewrite_data_files` WHERE 走 P6.3 conflict-mode 通路(`new IcebergPredicateConverter(schema, zone, true)`)vs legacy `IcebergNereidsUtils.convertNereidsToIcebergExpression`。两处有意发散:① **不可转节点静默丢**(legacy **抛**)→ planner `scan.filter()` 变宽 → 重写**比 WHERE 指定更多**文件(极端:整条 WHERE 不可转 → 重写全表),不报错;② conflict-matrix 收窄跨列 OR/非-`IS NULL` 的 NOT/NE。**关键认知**:设计 §5 「safe over-approximation」对**扫描下推**成立(BE 残差再过滤)但对 **rewrite 不成立**(planner `scan.filter()` 直接即重写集,无下游再过滤)→ 同一 P6.3 管道在 rewrite 语境安全性反号(vs O5-2 冲突检测「变宽=更保守」)。conflict-matrix 是 legacy 接受节点集的**严格子集** ⇒ 连接器只会相对 legacy 变宽、绝不反向收窄(无隐藏反向发散)。常见 WHERE(等值/范围/IN/BETWEEN)两路一致、零差异;发散只在罕见 WHERE(函数/NE/跨列 OR/NOT)。**reachability(T08 critic)**:当前 wiring 下 DV-T05r-where over-scan **经 EXECUTE 派发不可达**——双重闸:`rewrite_data_files` 无 factory `createAction` case(DV-T08-factory-advertise)且 `ConnectorExecuteAction` 拒/置 null WHERE(DV-T07-where);over-scan 经 `ALTER TABLE EXECUTE` 只在 P6.6 同时(加 factory case + WHERE-lowering 落地 = DV-045 R-B 接线)后才能触发,今仅经 dormant 直连 planner 路(`RewriteDataFilePlannerTest`)被覆盖。UT 钉:`whereBetweenPrunesViaConflictMode`(conflict-mode 钉,scan-mode 会丢→红)/`whereTopLevelAndAppliesEveryConjunct`(多合取交集)。**【R7 更新 2026-06-27,用户签字「无法精确就报错」】**:rewrite WHERE 路径已从「静默丢→变宽」**改为 fail-loud(精确否则报错)**,撤销 ① 的静默丢(rewrite 语境)。两层护栏:(a) fe-core 新增 `UnboundExpressionToConnectorPredicateConverter`(unbound-aware 按列名解析 + 表 schema 取类型;任一节点不可表示即抛 `AnalysisException`,绝不产出部分/空谓词);(b) 连接器 `RewriteDataFilePlanner` 对「未完整下推」(`pushed < top-level 合取数`)抛 `DorisConnectorException`(替代静默丢)。常见 WHERE(等值/范围/IN/IS NULL/BETWEEN/同列 OR/AND 串联)全部精确执行;不可下推形式(跨列 OR/NOT 比较/NE/函数/列-列/未知列/多段列名)现报清晰错误。语义比 legacy 略严:legacy 支持的跨列 OR/NOT 比较现也报错(用户接受——压缩语境极罕见)。② conflict-matrix 收窄仍在但现表现为「报错」而非「变宽」。UT 改:`unconvertibleCrossColumnOrWidensScan`→`unconvertibleCrossColumnOrThrows` + 新增 `partiallyPushableWhereThrows`;fe-core `UnboundExpressionToConnectorPredicateConverterTest`(lower + fail-loud)+ `ConnectorExecuteActionTest`(SINGLE_CALL 拒 / DISTRIBUTED 降并透传)+ `ConnectorRewriteDriverTest`(predicate 透传 planRewrite)。**真 e2e 仍未跑**(flip-gated)。 +- **为何不单独阻塞翻闸**:auth-add = parity-by-construction(只加 auth);DV-T05r-where 完全 dormant(rewrite 执行半 R-B 未接线 = DV-045)+ 用户签字 + 常见路零差异 + UT 钉逻辑半。 +- **别于 [DV-045]**:DV-045 = 未接线翻闸阻塞;本条 = 已修(auth)/已签待 docker(rewrite WHERE)。 +- **真值闸**:P6.6 docker——Kerberized EXECUTE auth round-trip + `rewrite_data_files` WHERE 各形(等值/范围/函数/NE/OR)文件集 parity。 +- **关联**:[DV-043](P6.3 同类 correctness-bearing)、[DV-031](Kerberos)、[DV-045](DV-T05r-where 是其规划半,本条 over-scan 经 EXECUTE 在 DV-045 接线后才可达)、T04/T05 task record。 + +### DV-045 — P6.4 `rewrite_data_files` 执行半**翻闸阻塞 BLOCKER**:执行半 fe-resident(R-B 推后专门写路径 RFC) +- **状态**:🔴 **翻闸前(P6.6)必接线**(专门写路径 RFC)——未接则 `rewrite_data_files` 经 plugin-driven 端到端断|**日期**:2026-06-24|**签字**:用户裁 Option 1(2026-06-24,T06 recon 证伪设计 §5 R-A 前提) +- **原计划位置**:T05/T06 设计 §5([procedure-spi-design](./tasks/designs/P6.4-T01-procedure-spi-design.md))+ T06 实现记录 + [task 表 §P6.4 T06](./tasks/P6-iceberg-migration.md) + [HANDOFF 🔴🔴](./HANDOFF.md) +- **偏差描述**(写路径耦合长杆,**与 [DV-041] 写路径翻闸阻塞同族**):`rewrite_data_files` 分三半——**① 事务半**(连接器 `IcebergConnectorTransaction` 的 `WriteOperation.REWRITE` 变体,T06 已建,dormant,净 0 新事务 verb);**规划半**(连接器 `RewriteDataFilePlanner`/`RewriteDataGroup`/`RewriteResult`,T05 已建,dormant);**②③④ 执行半留 fe-core**(`RewriteDataFileExecutor`/`RewriteGroupTask`/`RewriteTableCommand`/`IcebergRewriteExecutor`,**R-B 推后**)。 + - **recon 证伪设计 §5 / D-062 R-A 前提**「连接器从 pinned snapshot+WHERE 重规划」:(a) 连接器 scan SPI 只能按 snapshot/谓词/分区收窄,**无法表达** legacy bin-pack 的「分区内任意文件子集」→ 重规划 **over-scan** → 重写组外文件 → **破坏 rewrite 正确性**(非仅成本);(b) `FileScanTask` 侧信道(`RewriteGroupTask:117`→`IcebergScanNode.getFileScanTasksFromContext`〔def `:492` / rewrite-consuming caller `:929`,T09 faithfulness 校正 stale `:498`〕)翻闸后走 `PluginDrivenScanNode` 端到端**死**;(c) **SPI 模块边界**:`fe-core` 只依赖 `fe-connector-api/-spi`,连接器 `RewriteDataGroup`(裹 iceberg `FileScanTask`/`DataFile`)**不能**跨进 fe-core 执行半;(d) multi-sink-per-txn 生命周期(一 txn 跨 N 组 INSERT-SELECT)须重设计、只能翻闸(P6.6)验。**= D-062「超预算→R-B」预设回退被实证触发**,用户签字 Option 1。 +- **翻闸阻塞 checklist(P6.6 前必清)**: + - [ ] (i) 每组 **file-level 扫描范围**(新中立 scan-范围 SPI;pinned-snapshot+WHERE 不足/over-scan) + - [ ] (ii) `BindSink.bind(UnboundIcebergTableSink):1057` 对 PluginDriven 抛错 → 改绑 `UnboundConnectorTableSink`→`visitPhysicalConnectorTableSink` + - [ ] (iii) `RewriteGroupTask:175` `instanceof IcebergRewriteExecutor` + executor 选 `instanceof PhysicalIcebergTableSink` → 连接器 sink + - [ ] (iv) `RewriteDataFileExecutor:61` `(IcebergTransaction)` 下转 → 经通用 `PluginDrivenTransactionManager` 取连接器 REWRITE txn〔① 已建〕+ `setTxnId` 喂 commit-fragment + - [ ] (v) multi-sink-per-txn 生命周期(一 begin、N 组 INSERT 不重 begin/commit、一 commit) + - [ ] (vi) 接线同步:DV-T08-factory-advertise(加 `createAction` rewrite_data_files case,canary 转红)+ DV-T07-where(WHERE-lowering 落地,连接器收真 WHERE = DV-T05r-where 激活) +- **为何登记为 BLOCKER(Rule 12)**:与 [DV-041] 写路径翻闸阻塞同需 holistic 写路径 RFC(scan-范围 SPI + bind + executor + multi-sink 生命周期);现 iceberg **不在** `SPI_READY_TYPES`,rewrite 写路径 behind gate dormant 故未触发;① 事务半/规划半 dormant(无 live caller,`planWrite` 无 REWRITE case→default-throw / factory `rewrite_data_files`→default-throw)。 +- **真值闸**:专门写路径 RFC + P6.6 docker(`rewrite_data_files` 端到端:文件子集精确、bin-pack 正确性、OCC 冲突检测)。 +- **关联**:[DV-041](写路径翻闸阻塞同族 holistic)、[DV-042](rewrite 执行半 fe-resident 同北极星 iii 域)、[DV-046](DV-T05r-where 规划半,gated 在本条接线后才可触发)、T06 task record、[HANDOFF 🔴🔴](./HANDOFF.md)。 + +### DV-044 — P6.3 iceberg 写路径:perf / cosmetic / EXPLAIN-diff / 等价结构(结果恒等;镜像 DV-040/DV-035 批 style) +- **状态**:🟢 已登记(accept;结果恒等/展示/性能/等价结构)|**日期**:2026-06-24|**签字**:各项已随 T01–T07 task 用户签字(见各设计 §6),本条为 P6.3-T08 中央汇总 +- **原计划位置**:T01–T07〔a/b/c〕各设计文档 §6 + [task 表 §P6.3 line 239](./tasks/P6-iceberg-migration.md) +- **范围**:P6.3 写框架统一 + iceberg 写路径共 ~20 项 UT 不可见但**非正确性**的偏差,批化为一条。要点分类: + - **生命周期 / 时序变更(行为等价)**:jdbc 退化 no-op txn 现经通用 `PluginDrivenTransactionManager.begin`/`putTxnById` 全局注册(连接器 0 注册码,DV-T01-b);`writeOperation` 枚举移 T03、`beginTransaction` 默认保 throwing(非 RFC 字面 no-op,由 `NoOpConnectorTransaction` 退化,DV-T01-c);`writeOperation` 产品消费者落 T04/T06、T03 仅默认值契约测(DV-T03-b);txn-id 双注册由通用 manager 完成(同 maxcompute,DV-T03-c)。 + - **EXPLAIN / observability drop(cosmetic)**:jdbc EXPLAIN 头标签 `WRITE TYPE: JDBC_WRITE`→`WRITE: plan-provider`(窄化,INSERT SQL 经 `appendExplainInfo` 保,DV-T02-b);`appendExplainInfo` 在 EXPLAIN-string 期触发连接器读元数据(**净优于** legacy 每-INSERT 查;纯 INSERT 为 0,DV-T02-c);sink EXPLAIN 标签 `PLUGIN-DRIVEN TABLE SINK`+detail vs `ICEBERG TABLE SINK`(plan-shape 不变,OQ-3,非回归)。 + - **fail-loud 异常型 / 源不同值同**:begin/commit/guard 失败抛 `DorisConnectorException`/`IllegalStateException`/SDK `ValidationException`/`VerifyException` vs legacy `UserException`/`AnalysisException`/`IllegalArgumentException`/`RuntimeException`(消息字节同义,DV-T03-a/T04-b/T05-b/T06-c);`partition_data_json` 经 iceberg Jackson 非 fe-core Gson(`List` 字节同,DV-T04-d);私有 `formatVersion(Table)` 与 `IcebergConnectorMetadata.getFormatVersion` 重复(避跨类编辑,DV-T05-e)。 + - **perf / scale-only(结果恒等)**:op 的 `scanManifestsWith(pool)` 丢 → SDK 默认 worker pool(提交结果字节等价,DV-T04-a/T05-f);op 选择经单 `beginWrite`+`commit()` switch 而非 legacy 3 begin/finish 方法名、`CommonStatistics` 内联(DV-T04-e);`getWriteSortColumns`(translator 期)+ `beginWrite`(bind 期)double loadTable 只读重 I/O(DV-T06-d)。 + - **SPI 接缝 / 参数化(thrift-free,等价)**:TIMESTAMP/identity 解析取显式 `ZoneId` 形参非 thread-local(DV-T04-f/T05-d;correctness 半在 DV-043);O5-2 `applyWriteConstraint` 暂存中立 `ConnectorPredicate`、commit 时惰性转(DV-T05-a);新 `ConnectorContext.getBackendFileType`(scheme→`TFileType` 的 thrift-free enum-name 接缝)+ `getWriteSortColumns`(null=无 sort / 非 null=有)+ 声明 `SINK_REQUIRE_FULL_SCHEMA_ORDER`(DV-T06-e)。 +- **为何可接受**:全部**非正确性**——生命周期/异常型行为等价、仅展示(EXPLAIN)、源不同值同、perf/scale 结果恒等、SPI 接缝 thrift-free 等价。jdbc/maxcompute/paimon 写字节 parity 经其各自回归门守(T01–T06 无回归)。 +- **真值闸**:P6.6 docker(翻闸后回归套件)逐项确认无害 + assembled-Thrift vs legacy。 +- **关联**:[DV-040](P6.2 同 style 批)、[DV-035](P4 同 style 批)、[DV-041](翻闸阻塞)、[DV-042](北极星 iii)、[DV-043](correctness-bearing)、T01–T07 设计 §6。 + +### DV-043 — P6.3 iceberg 写路径:parity-忠实 HIGH/MEDIUM correctness-bearing 但 UT 不可见 deviation(parity-by-construction / widening-safe,各有 P6.6 docker 闸) +- **状态**:🟢 已登记(accept;各项 parity-by-construction 或 widening-safe + P6.6 docker 真值闸必验)|**日期**:2026-06-24|**签字**:各项随 T01–T07 task 用户签字(Option A 冲突矩阵 2026-06-24) +- **原计划位置**:T01/T02/T03/T04/T05/T06/T07b 各设计文档 §6 + [task 表 §P6.3](./tasks/P6-iceberg-migration.md) +- **范围**:与 [DV-044] 区别 = 本条各项**承载正确性**(误则错结果),但**parity-by-construction 或只-widening(绝不漏冲突)** 故单项不阻塞翻闸;每项有具体 P6.6 docker 闸。 + - **byte-parity 移位(OQ-1)**:jdbc `TJdbcTableSink` thrift 由 fe-core planner 移连接器 `JdbcWritePlanProvider.planWrite`(DV-T02-a)——14 字段逐字段 parity(设计 §4.1)+ 连接池 `getInt`/`DEFAULT_POOL_*` 非 bind 硬编 fallback 陷阱已避;UT 断字节,docker 终验。 + - **affected-rows 哨兵**:jdbc 退化 txn 的 `NoOpConnectorTransaction.getUpdateCnt` 返 `-1` 哨兵 + executor `if(cnt>=0)` 守 `DPP_NORMAL_ALL`,否则默认 0 clobber 回归(DV-T01-a)——correct-by-construction,BE 真实计数离线 UT 不可验。 + - **auth-wrap 离线不可见**:`beginWrite` 的 `loadTable`+`newTransaction()` 须在 `executeAuthenticated` 内(Kerberized HMS `BaseTable.newTransaction` 触发无条件远程 `doRefresh`),离线 InMemoryCatalog 无 auth 不可分辨(DV-T03-d,跨引用 [DV-031])。 + - **zone-aware 分区值解析**:TIMESTAMP/identity 分区值经连接器-本地 parser + 显式 `ZoneId`(非 nereids `DateLiteral`+thread-local),BE 发 canonical 格式(DV-T04-c)——实务等价,docker 断字节。 + - **conflict-mode widening(Option A,用户签字 2026-06-24)**:O5-2 写约束经 `IcebergPredicateConverter` conflict-mode 转换,**忠实** legacy 真实冲突路——legacy 不处理的形式(NullSafeEqual / Cast 包裹列 / col-col / 裸 bool / NE)一律丢弃 → 冲突 filter **widens**(更保守,**no-missed-conflict 安全**);字面量经扫描侧 `extractIcebergLiteral` 矩阵(边缘 UUID/FIXED/GEO 分歧只放宽);合成列排除经 T07c 注入 Predicate(DV-T05-c/T07b-matrix/T07b-literal)。**本轮 T08 gap-fill UT 已补 per-conjunct drop(O5-2-GAP-001)+ OR all-or-nothing(O5-2-GAP-006)+ 分区冲突 filter 端到端(OP-SEL-01/VAL-T05-*)**,逻辑半已 UT 锁。 + - **hadoopConfig 口径**:连接器 `hadoopConfig` 经 fe-filesystem `StorageProperties.toHadoopConfigurationMap()` vs legacy fe-core `getBackendConfigProperties()`,默认口径可能微差(DV-T06-hadoopconfig)——P6.6 docker 断字节 parity。 +- **为何各项不单独阻塞翻闸**:每项 **parity-by-construction**(thrift 逐字段 / 哨兵守 / auth-wrap 镜像 legacy)或 **widening-only**(冲突 filter 只放宽、绝不漏冲突);UT 已覆盖逻辑/wiring(T08 gap-fill 补全),剩余仅「真 BE/Kerberized/live 行为」须 docker。**别于 [DV-041]**:DV-041 是**未接线翻闸阻塞**,本条是**已修待 docker 实证**。 +- **真值闸**:P6.6 docker——INSERT/DELETE/UPDATE/MERGE 写 parity + 事务提交/冲突检测 + jdbc affected-rows + Kerberized auth + assembled-Thrift vs legacy。 +- **关联**:[DV-041](翻闸阻塞)、[DV-044](perf/cosmetic)、[DV-039](P6.2 同类 correctness-bearing)、[DV-031](Kerberos)、T01–T07 设计。 + +### DV-042 — P6.3 北极星 (iii) 有界架构偏差:iceberg DML plan 合成 fe-resident(Route B / option (i),PMC 签字) +- **状态**:🟢 已登记(accept;有界 intentional、PMC/RFC §4 签字、保 EXPLAIN parity)|**日期**:2026-06-24|**签字**:RFC §4 Q1 = Route B / option (i)(PMC 评审通过) +- **原计划位置**:[06-iceberg-write-path-rfc.md §5.3/§10](./06-iceberg-write-path-rfc.md) + [T07 设计 §4.5.8](./tasks/designs/P6.3-T07-rowlevel-dml-unification-design.md) + [task 表 §P6.3 范围外](./tasks/P6-iceberg-migration.md) +- **偏差描述**:iceberg DELETE/UPDATE/MERGE 的 **plan 合成**(`$row_id` 注入 / branch-label 投影代数 / nereids→iceberg expr)**暂留 fe-core**,经连接器-键控 `RowLevelDmlTransform` 注册表(`RowLevelDmlCommand` 壳 + capability 派发)调用;合成内仍有反向 `instanceof IcebergExternalTable` cast(fe-resident 合成内属本条,其余 catalog/statistics 读侧 cast 归 P6.7)。这是 RFC §4 Q1 用户/PMC 裁定的 **Route B / option (i) 务实路径**(拒 option (ii) 新 nereids-spi 模块),与北极星 **(iii) Trino 式通用化**(连接器 0 优化器 import、引擎核心全 DML 合成、连接器供 3 声明式 SPI = row-id handle + RowChangeParadigm + merge sink)有界偏离。 +- **范围(含 T07c 等价结构项)**: + - **DV-04x(本条核心)**:DML plan 合成 fe-resident + 连接器-键控变换注册表 + 合成内反向 instanceof。 + - T07c **冲突-filter 计算顺序**从 T04 分支内挪到 `newExecutor` 后(单 merged call,provably-equivalent reorder;T04 冲突已 stable、新 executor 无副作用、结果字节同,DV-T07c-conflict-order)。 + - T07c 壳做**单 table resolve**,删 legacy 冗余 re-resolve + instanceof throw(dispatcher 已解析、吞/抛纪律保,harmless,DV-T07c-resolve)。 + - `IcebergXCommand.run()`/`executeMergePlan()` 循环保留但不再被路由 = **transitional dead**,P6.7 随类删;live 路径仅壳一份循环(DV-T07c-dormant-loop)。 + - conflict-mode 合成列排除按名排扫描侧 row-lineage 列,合成列**生产**排除由 T07c `WriteConstraintExtractor` 注入 `Predicate` 完成(DV-T07b-exclusion)。 + - `rewritableDeleteFileSets`(fv≥3 DV rewrite)经 T07c executor finalize seam 注入连接器 opaque sink(critic 定点 option b vs c,DV-T07-rewritable)。 +- **为何可接受**:option (i) 保 **EXPLAIN/执行 parity**(oracle `IcebergDDLAndDMLPlanTest` 14/0 byte-parity 铁证,本轮 T08 又补 DELETE/UPDATE operation-literal 值断言)、surgical(不新建 nereids-spi 模块);等价结构项 provably-equivalent 或 transitional-dead。**有界**——不随意扩散,仅在北极星 (iii) 专门 RFC 时关闭。 +- **真值闸**:oracle byte-parity(已绿)+ P6.6 docker EXPLAIN/执行不回归;北极星 (iii) 触发 = 第二个行级-DML 消费者(hive P7 / paimon 第二消费者)→ 后续专门 RFC 彻底关闭本条。 +- **关联**:[06-iceberg-write-path-rfc.md §10 北极星](./06-iceberg-write-path-rfc.md)、[DV-041](翻闸阻塞,DV-T07-materialize 是其物化半)、[DV-009](写 sink 收口位置)、T07 设计。 + +### DV-041 — P6.3 写路径**翻闸阻塞 BLOCKER**:通用 `visitPhysicalConnectorTableSink` 缺合成列物化 + 分布(DV-038 同主题新面)+ 休眠-至-翻闸激活集 +- **状态**:🔴 **翻闸前(P6.6)必修/必接线**——未接则 iceberg DELETE/MERGE 经通用 sink 挂 BE / REST 读 403|**日期**:2026-06-24|**签字**:T07 §1.1 critic finding 5 + 激活集各项随 T06/T07 task 用户签字延后 +- **原计划位置**:[T06 设计 §6](./tasks/designs/P6.3-T06-iceberg-sink-unification-design.md) + [T07 设计 §1.1/§6](./tasks/designs/P6.3-T07-rowlevel-dml-unification-design.md) + [RFC §5](./06-iceberg-write-path-rfc.md) + [HANDOFF 🔴🔴](./HANDOFF.md) +- **偏差描述**(**主阻塞与 [DV-038] 同一 BE StructNode DCHECK 崩溃类,新面**): + - **主阻塞 — DV-T07-materialize(合成列物化 + 分布)**:通用 `visitPhysicalConnectorTableSink`(`PhysicalPlanTranslator`)**无**合成列 `setMaterializedColumnName`(`$operation`/`$row_id`)+ `DistributionSpecMerge` 分布——这两者**仅** legacy `visitPhysicalIcebergMergeSink` 有。iceberg DELETE/MERGE 经通用 sink 真正走通前,通用 translator 须**先长出**合成列物化 + 分布,否则上游合成列被丢 → BE `iceberg_reader.cpp` field-id 路 StructNode `DCHECK` → 整 BE 崩(**同 DV-038 崩溃签名**)。T07 **有意不碰** `PhysicalPlanTranslator:589-627`,把它登记为 P6.6 翻闸阻塞。 +- **休眠-至-翻闸激活集**(**P6.6 必接线,翻闸全有或全无**;不接则对应 catalog/查询断,但非 BE 崩): + - 写分布 `getRequirePhysicalProperties`(分区-hash)延后——capability 模型错配(mc 强制 local-sort,iceberg 必须不),dormant(DV-T06-a)。 + - branch-INSERT thread-through——通用 `PluginDrivenWriteHandle` 不带 branch 字段,T06 折出 `branch=Optional.empty()`,须 P6.6 经 `PluginDrivenInsertCommandContext` 加字段(DV-T06-branch)。**✅ CLOSED by P6.6-C3a(2026-06-25, [D-070])**:新中立 SPI `ConnectorWriteOps.supportsWriteBranch()` + `ConnectorWriteHandle.getBranchName()`;fe-core `PluginDrivenInsertCommandContext.branchName` + `PluginDrivenTableSink` 透传 + 两处 INSERT/OVERWRITE guard 泛化;连接器 `IcebergWritePlanProvider:197` 读真 branch(transaction 侧已 ready)。dormant、0 新 DV(pre-flip 走 legacy `PhysicalIcebergTableSink`);UT+mutation 实证(`planWriteThreadsBranchFromHandleToTransaction` MUT-A 红)。 + - REST 对象存储 **vended overlay**——delete/merge sink 的 `hadoop_config` 静态、无 vended overlay;翻闸后 REST 对象存储读 **403**(DV-T07-vended,同 P6.2 [DV-039] vended 族)。 + - O5-2 `BaseExternalTableInsertExecutor.getConnectorTransactionOrNull()` → **null 休眠**(iceberg 走 legacy txn),翻闸(iceberg 进 plugin-driven)后激活(DV-T07c-o5seam)。 + - FILE_BROKER 写地址(`broker_addresses`)解析缺(broker 写少见,P6.6 确认需求后补,DV-T06-broker/T07-broker)。 +- **为何登记为 BLOCKER(Rule 12)**:主阻塞与 [DV-038] **同一** BE field-id 路 StructNode DCHECK 崩溃类、同需 holistic 共享 fe-core/translator 修;激活集是翻闸**全有或全无**的必接线项(`CatalogFactory:104-113`)。现 iceberg **不在** `SPI_READY_TYPES`,写路径 behind gate dormant,故未触发。 +- **真值闸**:P6.6 docker——翻闸前必接线主阻塞(合成列物化 + 分布)+ 激活集,跑 iceberg DELETE/MERGE(防 BE 崩)+ REST 对象存储读(防 403)+ branch-INSERT。**须先接线再翻闸**。 +- **关联**:[DV-038](同 BE StructNode DCHECK 主题,DV-041 是写路径新面;翻闸前须一并 holistic 修)、[DV-042](DML 合成 fe-resident,DV-T07-materialize 是其物化半)、[DV-009](写 sink 收口)、T06/T07 设计、[HANDOFF 🔴🔴 块](./HANDOFF.md)。 +- **后续动作(翻闸阻塞 checklist,P6.6 前必清)**: + - [ ] 通用 `visitPhysicalConnectorTableSink`:长出合成列 `setMaterializedColumnName`(`$operation`/`$row_id`)+ `DistributionSpecMerge`(与 [DV-038] 一并 holistic) + - [ ] 写分布 `getRequirePhysicalProperties` 接线(DV-T06-a)+ branch/broker thread-through + - [ ] REST 对象存储 vended overlay 接 delete/merge sink(DV-T07-vended) + - [ ] O5-2 `getConnectorTransactionOrNull()` 翻闸激活核对(DV-T07c-o5seam) + +### DV-040 — P6.2 iceberg scan:perf / observability / EXPLAIN-profile drop + lenient-validation + benign superset(结果恒等 / cosmetic / scale-only;镜像 DV-035 批 style) +- **状态**:🟢 已登记(accept;结果恒等/展示/性能/良性超集)|**日期**:2026-06-23|**签字**:各项已随 T02–T09 task 用户签字(见各设计文档 §deviation),本条为中央汇总 +- **原计划位置**:T02–T09 各设计文档 §deviation + [P6.2-T11 汇总设计](./designs/P6-T11-iceberg-scan-summary-design.md) +- **范围**:T02–T09 共 ~36 项 UT 不可见但**非正确性**的偏差,统一为一条(逐项对各设计文档核对)。要点分类: + - **profile / EXPLAIN drop(同 paimon 族)**:`metricsReporter`+`planWith` 丢(规划指标缺、用 SDK 默认 worker pool,T02);manifest cache hit/miss 统计从 EXPLAIN VERBOSE 丢、无 `cacheHitRecorder`(T08);空表 COUNT EXPLAIN 显 `(-1)` vs legacy `(0)`(BE 结果同为 0,T05)。 + - **perf / scale-only(结果恒等)**:COUNT 塌缩单 range 丢 legacy `>10000` 并行 count-split trim(BE 求和等价,T05);count range 携惰性 delete/partition carriers(CountReader 忽略,T05);`scan.snapshot()` vs legacy `getSpecifiedSnapshot/currentSnapshot`(非 time-travel 等价,T05);`beginQuerySnapshot` live 读无 cache、跨查询漂移窗(T07,T08 加 cache 缓解);schema memo 跳过(iceberg 历史 schema 内存即得、零 I/O 收益,T08);`getMatchingManifest` HashMap vs Caffeine LoadingCache(T08);manifest gate `ttl-second`/`capacity` 仅喂 enable 公式不 size cache(legacy quirk,T08);`ManifestFiles.dropCache` 不调(SDK ContentCache 资源释放 gap、非 stale-read,T08);batch mode 延后(manifest-计数 vs 分区-计数轴不匹配、0-新-SPI 不变量,T02/T03/T05/T08)。 + - **typed-vs-string / 源不同值同**:typed carriers vs paimon string-props(`IcebergScanRange`,T03);per-file `dataFile.format()` vs legacy table-uniform reader 选择(混格式表更正确,T03);`partition_data_json` Jackson/`JsonUtil` vs Gson 字节形(BE 重解析值同,T03);单 `-1` field-id 字典条目 vs HANDOFF「枚举全 schema-id」(iceberg field-id 永久不变 + BE 从文件读 file field-id,T06)。 + - **predicate over-approximation(BE residual 兜底)**:reversed `literal OP col` / col-col 丢(Nereids 已规范化故不可达,T02);`IsNull`/`Like`/`Between`/`FunctionCall` 丢(legacy 无此 case,IS NULL 仍经 EQ_FOR_NULL,T02);LARGEINT(String) 不下推 int64(legacy parity,T02);edge 字面量串形 best-effort(datetime/decimal vs STRING 列,T02);ZoneId alias-map vs 裸 `ZoneId.of(String)`(修 CST 8h 偏移误裁,T02/T03)。 + - **lenient-validation / fail-loud 字节同**:`delete_files` v2+ unset vs legacy 空 ArrayList(BE unset==empty,T03);Bug1 非-orc/parquet fail-loud `IllegalStateException` vs `DdlException`(消息字节同,T03);DV `contentOffset/contentSizeInBytes` auto-unbox to long(legacy parity,T04);manifest-cache 3 键不进 `validateProperties`(恶值静默回落、非正确性,T08);`is_optional` 恒 true vs iceberg required flag(BE field-id 路不读、inert,T06);STRING scalar 占位符(BE 只用 type.type 当 nested-vs-scalar 判别,T06);`Locale.ROOT` 顶层小写 vs paimon 默认 locale(T06);INCREMENTAL @incr fail-loud vs legacy 静默读 latest(Rule 12,T07);TIMESTAMP digital 当 epoch-millis(安全超集,T07)。 + - **vended 边角(同 paimon 族)**:`extractVendedToken` 无条件读 `io().properties()`(非云键被滤,T09);`extractVendedToken` 非 fail-soft(paimon 同款不修,T09);REST `PROVIDER_CHAIN` 非-DEFAULT signing-cred gap(T05 族、与 T09 数据路无关,T09)。 + - **🔵 classloader / split-package(category a,T08 extractor 漏报、本条补登)**:vendored `org.apache.iceberg.DeleteFileIndex`(906 行,包私有 1.10.1)与 iceberg-core jar 的包私有副本 **split-package 共存**,jar 序定胜者;fe-core 同款已上线 proven、字节相同→预期 benign,但**首次** direct-iceberg 连接器 + 该 vendored 类,UT/打包不可见,须 P6.6 docker plugin-zip e2e 实证。**跨引用 P6.1 同类 classloader 风险**([risks.md R-004]、CI #973270 ServiceLoader-empty 类、hive-catalog-shade + direct iceberg-core child-first 共存,均 P6.1 packaging 层、HANDOFF「🔴 关键认知」已登记)。 +- **为何可接受**:以上全部**非正确性**——结果恒等(perf/scale)、仅展示(profile/EXPLAIN)、源不同值同、安全超集、或 BE residual 兜底;split-package 是 fe-core 已 proven 的 benign 模式。 +- **真值闸**:P6.6 docker(翻闸后回归套件)逐项确认无害;split-package 走 plugin-zip e2e。 +- **关联**:[DV-035](P4 同 style 批)、[DV-032]/[DV-033](COUNT/native perf 族)、[DV-025](normalizeStorageUri)、[risks.md R-004]、T02–T09 设计文档。 + +### DV-039 — P6.2 iceberg scan:parity-忠实 HIGH/MEDIUM correctness-bearing 但 UT 不可见 deviation(各有 P6.6 docker 闸、已连接器内缓解,单项不阻塞翻闸) +- **状态**:🟢 已登记(accept;各项已连接器内缓解 + P6.6 docker 真值闸必验)|**日期**:2026-06-23|**签字**:各项随 T03–T10 task 用户签字 +- **原计划位置**:T03/T04/T06/T07/T08/T09 设计文档 + [P6.2-T11 汇总设计](./designs/P6-T11-iceberg-scan-summary-design.md) +- **范围**:与 [DV-040] 区别 = 本条各项**承载正确性**(误则错结果/崩溃),但**已在连接器内修/缓解**故单项不阻塞翻闸;每项有具体 P6.6 docker 闸,翻闸前必逐项确认。 + - **HIGH(BE-slot 分类 / scheme 派发 / time-travel 超集 / 凭据发射)**: + - columns-from-path **unset-then-set**、无 `HIVE_DEFAULT` 哨兵、null 经并行 `is_null` list(T03)——BE OrcReader/parquet slot 分类、防双填/DCHECK(CI #968880 类)。 + - `isPartitionBearing()` 空分区值 Hive-路径-parse 崩修(一个**非破坏 SPI 默认方法**,paimon 字节不变,T03 Bug2)——P6.2「0 新 SPI」唯一例外。 + - 主数据文件路径经 `context.normalizeStorageUri` 归一化(`path`=归一化 BE-open / `originalPath`=raw `original_file_path`)vs raw `oss://` scheme-派发打不开(T04)。 + - **Option A 全 pinned-schema field-id 字典**(time-travel 下超集覆盖所有 BE slot、防 `iceberg_reader.cpp:181` 重命名列 DCHECK,T07)。 + - 静态 `location.*` 凭据仅 scan/BE-open 时校验——T09 前发**零** `location.*`→403;T09 发 BE-canonical `AWS_*`/hadoop 键 + vended overlay(T09)。 + - **MEDIUM(cache/名映射/竞态/vended/auth/ref-pin)**: + - latest-snapshot `(snapshotId, schemaId)` 二元组原子钉 vs paimon 单 long——schema-only-ALTER 漂移防御(T08)。 + - 老文件缺内嵌 field-id 的 name-mapping 回退(`by_parquet_field_id_with_name_mapping`,T06)。 + - 无 paimon-style `latest()` 回退;fail-loud + T07 build-vs-pin ordering 竞态窗(T06 §7,T07 闭合 iceberg 侧)。 + - vended overlay vs legacy 整-map 替换、live REST round-trip、无 `validToken()` 刷新(T09,每查询 `loadTable` 新鲜)。 + - Kerberized auth 真-KDC `doAs` 仅 live-e2e 验(跨引用 [DV-031];read-vs-DDL `doAs` + 翻闸-authenticator-wiring 在 iceberg 复发,T10)。 + - tag/branch 钉 REF 名(`useRef` 跟后续 commit)vs 冻结 snapshot id(legacy parity,T07)。 + - 1-arg static-map `normalizeStorageUri` 用于 delete 路径直到 T09 接 vended token(T04)。 +- **为何各项不单独阻塞翻闸**:每项**已在连接器内修/缓解**(Option A 字典、isPartitionBearing 默认、路径归一化、静态+vended 凭据发射),UT 已覆盖 wiring/逻辑;剩余仅「真 BE/live 行为」须 docker 确认。**别于 [DV-038]**:DV-038 是**未修的共享 fe-core 崩溃**,本条是**已修待 docker 实证**。 +- **真值闸**:P6.6 docker——scan parity(分区裁剪行数 / native·JNI / position+equality delete / 静态+vended 凭据 round-trip)+ MVCC time-travel(AS OF / rename)+ Kerberized live。 +- **关联**:[DV-038](同 field-id 路族但 DV-038 未修)、[DV-031](Kerberos)、[DV-025](normalizeStorageUri)、[DV-027](schema-evolution 字典 paimon 姊妹)、T03/T04/T06/T07/T08/T09 设计。 + +### DV-038 — P6.2 iceberg **翻闸阻塞 BLOCKER**:共享 fe-core field-id 路径 BE StructNode DCHECK 崩溃(1 主题 / 2 面,CI #969249 类) +- **状态**:🔴 **翻闸前(P6.6)必修**——未修则 iceberg top-N / rename+time-travel 查询挂 BE|**日期**:2026-06-23|**签字**:面 1 GLOBAL_ROWID 用户签字延后(2026-06-22);面 2 待 P6.6 holistic +- **原计划位置**:T06 设计 §6 + T07 设计 §6 + T10 audit(line 69)+ [HANDOFF 顶部 🔴🔴 块](./HANDOFF.md) + [P6.2-T11 汇总设计 §翻闸阻塞](./designs/P6-T11-iceberg-scan-summary-design.md) +- **偏差描述**(**两面同一崩溃类**:BE `iceberg_reader.cpp` field-id 路径 `children_column_exists` StructNode `DCHECK` → 整 BE 崩): + - **面 1 — GLOBAL_ROWID 误分类(T06)**:top-N 延迟物化合成列 `GLOBAL_ROWID` 在 SPI 路径被通用 `PluginDrivenScanNode.classifyColumn` 归 **REGULAR**(非 SYNTHESIZED)→ field-id 字典让 BE 走 field-id 路径 → 该列不在字典 → DCHECK。连接器**无法修**(GLOBAL_ROWID 在连接器前被滤、无 iceberg field-id);修在**共享 fe-core** `classifyColumn`(GLOBAL_ROWID→SYNTHESIZED),但 BE `paimon_reader.cpp` **无** SYNTHESIZED-GLOBAL_ROWID 处理器 → **盲改可能破 paimon top-N**。 + - **面 2 — `getColumnHandles` 无 snapshot 重载(T07,paimon 潜伏)**:rename + time-travel pin 下,从 current schema 建 pruned 列字典会丢被重命名 slot 的 field-id → 同一 DCHECK。**iceberg 侧已由 T07 Option A**(全 pinned-schema 超集字典)**连接器内闭合**,但**共享 fe-core seam `getColumnHandles(handle)` 仍无 snapshot-aware 重载** → **PAIMON 的 snapshot-id time-travel + rename 仍潜伏同一崩溃**。 +- **为何 1 主题 / 2 面合并登记(Rule 12,不得静默丢面 2)**:两面是**同一** BE field-id 路径 StructNode DCHECK 崩溃类、**同需** holistic 共享 fe-core 修 + **paimon 影响分析**(可能 BE 协同);T07 文档明确把面 2 比作面 1(「like the GLOBAL_ROWID blocker」)。审计 critic 实证 `isGateFlipBlocker=true` 计数为 **2**(非 1),故合并为单条 BLOCKER 但**显式记两面**。 +- **影响范围**:iceberg 翻闸(P6.6)**前**必修,否则面 1(任意 iceberg top-N 延迟物化查询)/ 面 2(paimon·iceberg snapshot-id time-travel + 列重命名查询)挂 BE。**跨 paimon**(面 2 是既有 paimon 潜伏,本次首次显式登记)。 +- **真值闸**:P6.6 docker——翻闸前必跑 iceberg top-N + time-travel+rename;须**先** holistic 修 + paimon 影响分析再翻闸。 +- **关联**:[DV-026](field-id 消费者复评触发,已预言此类 SPI-on iceberg/hudi 经 `ExternalUtil` 的崩溃)、[DV-027](schema-evolution 字典 paimon 姊妹)、T06/T07/T10 设计、CI #969249。 +- **后续动作(翻闸阻塞 checklist,P6.6 前必清)**: + - [ ] fe-core `PluginDrivenScanNode.classifyColumn`:`GLOBAL_ROWID`→SYNTHESIZED(**先** paimon 影响分析) + - [ ] BE `iceberg_reader.cpp` / `paimon_reader.cpp` SYNTHESIZED-`GLOBAL_ROWID` 处理器核对(可能 BE 协同) + - [ ] fe-core `getColumnHandles(handle)` snapshot-aware 重载(关闭面 2 的 paimon 潜伏) + - [ ] 翻闸冒烟:iceberg `SELECT ... ORDER BY ... LIMIT k`(top-N)+ paimon/iceberg `FOR TIME AS OF` + `ALTER ... RENAME COLUMN` 后查询 + +### DV-037 — P6-C2 FIX-C2-HDFS-XML:legacy HDFS `getHadoopStorageConfig()` 的 `fs.hdfs.impl.disable.cache=true` 未进 typed FE Configuration(pre-existing,非 C2 引入) +- **状态**:🟢 已登记(accept / 可转 follow-up)|**日期**:2026-06-19|**签字**:待用户 +- **原计划位置**:[FIX-C2-HDFS-XML-design.md §Risk](./designs/FIX-C2-HDFS-XML-design.md) +- **偏差描述**:legacy `HdfsProperties` 的 FE `getHadoopStorageConfig()` 带 `fs.hdfs.impl.disable.cache=true`(`StorageProperties.ensureDisableCache`),typed `HdfsFileSystemProperties.backendConfigProperties` 从不加它(该键只在 `HdfsConfigBuilder` 运行期 `create()` 路上,`:44-48`)。 +- **触发场景**:任何 paimon HDFS catalog 的 FE catalog-create Configuration——**与 C2 无关**:翻闸后 HDFS 本就对 `storageHadoopConfig` 零贡献,C2 前后该键都缺;C2 只补 XML 子项,不动此键。 +- **新方案**:accept。Hadoop FS-cache 按 scheme+authority+ugi 缓存,benign;不在 C2(XML-resource gap)scope 内。 +- **影响范围**:代码无(pre-existing)。可转独立 follow-up(若将来报 FS-cache 串扰)。 +- **关联**:[task-list §P6-C2](./task-list-P6-fixes.md)、[DV-036] + +### DV-036 — P6-C2 FIX-C2-HDFS-XML:DLF catalog 若另绑 HDFS storage,HDFS keys 会进 DLF HiveConf(legacy DLF 只 overlay OSS/OSS_HDFS) +- **状态**:🟢 已登记(accept)|**日期**:2026-06-19|**签字**:待用户 +- **原计划位置**:[FIX-C2-HDFS-XML-design.md §Risk / Open Q1](./designs/FIX-C2-HDFS-XML-design.md) +- **偏差描述**:`HdfsFileSystemProperties` 实现 `HadoopStorageProperties` 后,`buildStorageHadoopConfig()` 对所有 flavor 共享;legacy DLF(`PaimonAliyunDLFMetaStoreProperties:90-96`)只 overlay OSS/OSS_HDFS storage,新 `DlfMetaStorePropertiesImpl.toDlfCatalogConf:141` 无条件 overlay 整个 `storageHadoopConfig`。故 DLF catalog 若也绑了 `HdfsFileSystemProperties`,HDFS keys 会进 DLF HiveConf。 +- **触发场景**:DLF catalog 的 raw props 触发 `HdfsFileSystemProvider.supports()`(`dfs.nameservices` / `hdfs|viewfs|ofs|jfs`-scheme 裸 `fs.defaultFS` / `_STORAGE_TYPE_=HDFS` / `hadoop.kerberos.principal`)——对 Aliyun-OSS 的 DLF 是 nonsensical 配置(真 DLF 用 `oss.*`/`dlf.*` + `oss.hdfs.fs.defaultFS=oss://…`,非裸 `fs.defaultFS`)。 +- **新方案**:accept。结果是 additive/inert 的 defaults-free HDFS keys,绝不破凭据/正确性;纯-OSS DLF(真实场景)byte-unchanged。修它需动 out-of-scope 的 DLF 路加 HDFS filter 去守一个 nonsensical 配置,不值。 +- **替代方案**:DLF 路按 storage 类型 filter——拒(C2 不含 DLF;增复杂度守不可达场景)。 +- **影响范围**:代码无(accept)。文档:本条 + 设计 Open Q1。 +- **关联**:[task-list §P6-C2](./task-list-P6-fixes.md)、[DV-037] + +### DV-035 — P4 MINOR/NIT cleanup:15 项 accept-as-deviation(M5.1 transient-only + 14 display/perf/text/inert/连接器-更-correct/假前提) +- **状态**:🟢 已登记(accept)|**日期**:2026-06-12|**签字**:用户 [D-057] +- **范围**:review §5/§7 去重 ~17 项 P4 MINOR/NIT 中,2 项已修(N10.1 `bcee91dcb52`、sentinel `4b2c2190dc2`,见 [D-057]),余 15 项 accept。完整逐项分类见索引表 DV-035 行;要点: + - **M5.1(唯一 FUNCTIONAL,transient-only)**:sys-table 列举的远端 handle 预探,瞬时失败 → phantom「table not found」。**accept 理由(实现层证伪 critic 的「cheap fallback」)**:`getTableHandle` 的 swallow-非NotExist-为-empty 是有意+有测契约(`PaimonConnectorMetadataReadAuthTest:150` `failAuth→empty`)且是共享 existence 谓词(`PluginDrivenExternalCatalog:239/295/446`,含 P3 createTable remoteExists);`listSupportedSysTables` 忽略 handle。无 surgical 零成本修,唯一干净修 = SPI no-handle list(surface churn + 重引「为不存在表列 sys-table」legacy quirk)或 broad retype(破契约 + 触 P3 fix)。 + - **假前提 ×2(M9.1/M9.2)**:review 声称连接器丢 HDFS default / 推 hive.* 到 BE——recon 证伪(连接器跑同 `CredentialUtils.getBackendPropertiesFromStorageMap` over 同 storage map,无 drop;hive.* 仅 FE-side HiveConf,never location.*)。**非偏差、是 review 误判**,登记以免重复追查。 + - **其余 12**:display(M10.1/M10.2/M10.3/M7.1)、perf(M6.1/M6.2)、text(N2.1/M3.1/N4.1/C2)、inert no-op(N3.1/M2.1)、连接器**更** correct(M4.1/M1.3)、diagnostic(M1.1)。均非 correctness regression。 +- **meta**:completeness critic 的 fix 建议须落到代码契约/测试层再判 effort——M5.1 照单转 FIX 会做无 sanction 的 broad/SPI 改;实现层核查把它 flip 回 accept。 +- **跨连接器**:hudi/iceberg full-adopter 多项同复发(display/text/假前提类),将来批量 close(与 [DV-028]/[DV-030]/[DV-031]/[DV-032]/[DV-033]/[DV-034] 同批考量)。 +- **关联**:[D-057](./decisions-log.md)、[task-list §P4](./task-list-P5-rereview2-fixes.md)、recon `wf_6884d37b-8ef` + +### DV-034 — P3-fix FIX-CREATE-TABLE-LOCAL-CONFLICT:plugin DDL op typed error-code 收敛成 generic DdlException(COSMETIC/error-code-ONLY,pre-existing 跨全 DDL op) + +`FIX-CREATE-TABLE-LOCAL-CONFLICT`([D-056](./decisions-log.md))恢复了 createTable 的 case-B **correctness**(local-only 冲突 + `!IF NOT EXISTS` 改抛 typed `ERR_TABLE_EXISTS_ERROR` 1050),但**有意不动** error-code 残留: + +- **case-A(createTable,remote-hit + `!IF NOT EXISTS`)**:plugin 仍 fall-through 到 `metadata.createTable`,由连接器(paimon SDK `TableAlreadyExistException`)→`DorisConnectorException`→桥 re-wrap 成 **generic `DdlException`(e.getMessage())**「Table 't1' already exists…」;legacy `PaimonMetadataOps:195` 在 FE 层先抛 **typed** `ERR_TABLE_EXISTS_ERROR`(1050)。outcome(拒绝 + 「already exists」文本)同,仅 numeric code 丢。 +- **createDatabase/dropDatabase/dropTable**:同样 `catch(Exception)`→generic `DdlException`(`PaimonConnectorMetadata:731/798/832/756` + 桥 re-wrap),collapse 掉 legacy 的 1007/1008/1050/1109 typed code。 +- **为何不在本 fix 收口**:① 非本 P3 finding(finding=case-B silent-create correctness);② P3 audit 把 error-code parity 标 **cosmetic/AGREE**(error class + user-visible 文本两侧一致);③ 修它须对每 DDL op 在桥/连接器边界统一 typed-code 透传机制,属跨全 op + 跨连接器(hudi/iceberg full-adopter 同)的 **P4 cleanup 批量**,非外科单点。 +- **真值闸**:无功能影响;仅对 MySQL numeric-error-code-sensitive 的客户端脚本理论可感知。 +- **跨连接器**:与 [DV-028]/[DV-030]/[DV-031] 同属翻闸后通用桥/连接器边界的语义收敛,将来批量 close。 + +### DV-033 — P5-fix#9 FIX-NATIVE-SUBSPLIT:split-weight / target-size 调度 nicety 不移植(PERF/调度-ONLY,pre-existing) + +- **发现日期**:2026-06-12 +- **发现 session / agent**:#9 recon(`wf_ad764bf6-1c9`,synthesizer 标 "parity nicety, not a blocker")。 +- **当前状态**:🟢 已登记(perf/调度-only,pre-existing;live-e2e 真值闸) +- **原计划位置**:[P5-fix-NATIVE-SUBSPLIT 设计](./tasks/designs/P5-fix-NATIVE-SUBSPLIT-design.md) §Out of scope +- **偏差描述**:legacy `fileSplitter.splitFile` 经 `splitCreator.create(path, start, length, fileLength, targetFileSplitSize, ...)` 在每个 `FileSplit` 上设 per-split weight + targetSplitSize,供 `FederationBackendPolicy` 做 backend 分配均衡。连接器 native sub-range(`buildNativeRange`)不设 `selfSplitWeight`/targetSplitSize。 +- **为何可接受**:① **pre-existing**——翻闸后单-range native 路本就没设 weight(`buildNativeRange` 从未设、仅 JNI 路 `buildJniScanRange` 经 `computeSplitWeight` 设);#9 不引入该缺口,只是把整文件 range 切成多 sub-range。② 纯**调度均衡质量**、非正确性、非并行度——#9 的目的(文件内并行)已达成(发多个 sub-range)。③ 连接器 SPI 无 per-range weight 喂入 FileSplit 的通道(`PaimonScanRange` 无 targetSplitSize 字段)。 +- **影响范围**:仅 backend 分配的负载均衡质量;读结果与并行度均正确。 +- **关联**:[D-055]、native-path weight 既有缺口 +- **后续动作**: + - [ ] 跨连接器:若要 weight-均衡,后续在 SPI/`PaimonScanRange` 加 weight 字段,与既有 native-path weight 缺口一并补(hudi/iceberg full-adopter 同需)。 + - [ ] live-e2e:观察大文件多 sub-range 的 backend 分配(均衡差异为预期、非正确性问题)。 + +### DV-032 — P5-fix#8 FIX-COUNT-PUSHDOWN:collapse-to-one 丢 legacy `>10000` 并行 count-split trim(PERF-ONLY,用户签字) + +- **发现日期**:2026-06-12 +- **发现 session / agent**:#8 recon(5-scout + 对抗 synthesizer workflow `wf_1ce48c93-325`,legacy-parity lens),用户在 scope 决策中明确选 collapse-to-one。 +- **当前状态**:🟢 已登记(perf-only,结果恒等;live-e2e 真值闸) +- **原计划位置**:[P5-fix-COUNT-PUSHDOWN 设计](./tasks/designs/P5-fix-COUNT-PUSHDOWN-design.md) §Deviation +- **偏差描述**:legacy `PaimonScanNode:484-495` 收齐 count-eligible split 后按 `pushDownCountSum` 分流——`> COUNT_WITH_PARALLEL_SPLITS(10000)` 时 trim 到 `parallelExecInstanceNum * numBackends` 个 split 并 `assignCountToSplits` 把 total 均摊(BE 每 split 的 CountReader 再求和回 total);`<=10000` 则 `singletonList(first)` 收一 split 携全 total。连接器 `PaimonScanPlanProvider.planScanInternal` **始终 collapse-to-one**(无论 `countSum` 大小都只发一个 count range 携全 total),因连接器**无** `numBackends`/`parallelExecInstanceNum`——它们是 fe-core scan-node-only(`PluginDrivenScanNode.getSplits(int numBackends)` 才有,连接器 SPI `planScan` 无)。**附(cosmetic)**:legacy 还在 post-loop 调 `setPushDownCount(sum)` 让 EXPLAIN 显示 `pushdown agg=COUNT (N)`(`FileScanNode` 节点级、display-only);collapse-to-one **无 fe-core post-loop** 故 plugin 路 EXPLAIN 不显示该计数行。**纯展示差异**:count 经 per-range `table_level_row_count` 走另一条路达 BE(与 EXPLAIN 显示无关),结果与性能均不受影响。review 判为 non-blocking(adversarial workflow `wf_6ead7c2c-b58`,display-only、pre-existing override 模式)。 +- **为何可接受**:纯 perf 偏差、**结果恒等**——单 CountReader 在一个 fragment emit `countSum` 个空行(无 IO、不读数据文件)而非 N 个并行;只在超大 count 时不并行化 count-emit。对比 legacy 的并行 trim 本身也只是优化(CountReader 极廉)。**fail-safe**:collapse-to-one 是 legacy `<=10000` 路径的普遍化,非新行为。 +- **未采替代**:full-parity(连接器发 per-split + fe-core 按 numBackends trim+redistribute)——会把 count 语义耦进通用 `ConnectorScanRange`(fe-core 须读/改写各 range 的 row_count)、多 fe-core 代码、blast 大;per-split(不 collapse)——比 legacy 多 fragment。collapse-to-one 是连接器自包含、零 fe-core post-loop 数学的最简正确解。 +- **影响范围**:仅 count 查询的 split 并行度(fragment 数);count 结果与全表行数均正确。 +- **关联**:[D-054]、[第二轮 review report](./reviews/P5-paimon-rereview2-2026-06-11.md)(M-2)、[DV-028]/[DV-030]/[DV-031](同属「新连接器读法/翻闸 vs fe-core 既有约定」类缝) +- **后续动作**: + - [ ] **live e2e(必经)**:超大 PK 表 `COUNT(*)` 验结果正确;EXPLAIN/profile 观察 count fragment 并行度(与 legacy 差异为预期、非回归)。 + - [ ] 跨连接器:hudi/iceberg full-adopter 若需 `>10000` 并行 count-split,可在 fe-core 加通用 trim hook 批量 close(与 DV-028/030/031 同批考量)。 + +### DV-015 — P4-T06e FIX-PRUNE-PUSHDOWN:端到端裁剪下推 wiring 无 fe-core 单测(KNOWN-LIMITATION) + +- **发现日期**:2026-06-08 +- **发现 session / agent**:FIX-PRUNE-PUSHDOWN clean-room review(workflow `w31i0vfo5`,test-quality lens,4 finding 全 verifier 判 minor/非 must-fix) +- **当前状态**:🟢 已登记(逻辑半 UT+mutation 守门,wiring 半 + 真实裁剪生效待 live e2e) +- **原计划位置**:[FIX-PRUNE-PUSHDOWN 设计](./tasks/designs/P4-T06e-FIX-PRUNE-PUSHDOWN-design.md) §Test Plan +- **偏差描述**:本 fix 三处产线点无 fe-core 端到端 UT:① `PluginDrivenScanNode.getSplits()` 的 pruned-to-zero 短路(`requiredPartitions!=null && isEmpty()→return emptyList()`);② `PhysicalPlanTranslator` plugin 分支 `setSelectedPartitions(fileScan.getSelectedPartitions())` 注入;③ `getSplits→planScan` 6 参 requiredPartitions threading。原因:`PluginDrivenScanNode` 是 `FileQueryScanNode` 子类,裸构造需绕 ctor 链 + stub `getScanPlanProvider`/`buildColumnHandles`/`buildRemainingFilter`/`applyLimit`(无现成轻量 analyze/spy harness;同 [DV-014] 外表 bind harness 缺位)。 +- **覆盖经**:① 最易错的三态映射逻辑(NOT_PRUNED→null / pruned-非空→names / pruned-空→空 list)由 `PluginDrivenScanNodePartitionPruningTest`(5 测)+ mutation(去 `!isPruned` 守卫双红)pin;② 名→PartitionSpec 转换由 `MaxComputeScanPlanProviderTest`(3 测)+ mutation(恒 emptyList 红)pin;③ wiring 半(短路/注入/threading 单变量直线流)+ **真实裁剪生效** 由 p2 live `test_max_compute_partition_prune.groovy` 覆盖——真值证据 = EXPLAIN/profile 仅扫目标分区(split 数/规划耗时 ≪ 全表)+ `WHERE pt='不存在'`→0 行且不建全分区 session。 +- **为何可接受**:与既有约定一致(`HiveScanNodeTest`/legacy-MC/Hudi 的 translator 注入均无 translator 级测,`HiveScanNodeTest:99-115` 直构 node 调 setter);fail-safe(默认 `selectedPartitions=NOT_PRUNED`→`resolveRequiredPartitions`→null→scan all,去 wiring 退化为修前全表扫**非丢数据**)。 +- **影响范围**:仅测试覆盖层;产线行为正确。 +- **关联**:[D-031]、[review-rounds](./reviews/P4-T06e-FIX-PRUNE-PUSHDOWN-review-rounds.md)、[复审 §B DG-1](./reviews/P4-maxcompute-full-rereview-2026-06-07.md)、[DV-014](同类 harness 缺位) +- **后续动作**: + - [ ] 待外表 scan 的 fe-core spy/analyze harness 落地(`MaxComputeScanNodeTest`/`PaimonScanNodeTest` 用 `Mockito.spy`+反射,可借鉴),补 `getSplits()` 短路 + threading 的 CI 级测,把 correctness 不变式从 live-only 提到 CI。 + - [ ] **live e2e(必经)**:真实 ODPS 跑 `test_max_compute_partition_prune.groovy`,并核 EXPLAIN/profile 证裁剪真正下推(行正确不足以证——修前行已正确)。 + +### DV-014 — P4-T06e FIX-BIND-STATIC-PARTITION:bind 期 full-schema 投影无 fe-core 单测(KNOWN-LIMITATION) + +> 补登:本条索引行(见上)此前已录,详细记录段遗漏,现补齐(doc-sync 横切债)。 + +- **发现日期**:2026-06-07 +- **发现 session / agent**:FIX-BIND-STATIC-PARTITION clean-room review(workflow `wi3mnjymb`/`wy299gtsh`/`wlwpw0b2s`,test-quality lens) +- **当前状态**:🟢 已登记(无 harness,parity + p2 覆盖;待外表 analyze harness 落地补) +- **原计划位置**:[FIX-BIND-STATIC-PARTITION 设计](./tasks/designs/P4-T06e-FIX-BIND-STATIC-PARTITION-design.md) / [D-030] +- **偏差描述**:`bindConnectorTableSink` 的 full-schema 投影(NULL 填充 + 分区列末尾 + 按位置投影)未被 connector-path 单测直接 pin——`bind()` 经 `RelationUtil.getDbAndTable` 真 Env 解析,外表 PluginDriven catalog 需连接器插件,无现成轻量 analyze harness(OLAP analyze 测仅覆盖 `createTable` 内表)。 +- **覆盖经**:① 与 legacy `bindMaxComputeTableSink` 及 Iceberg 路径**共享** helper `getColumnToOutput`/`getOutputProjectByCoercion`(被既有 OLAP/Hive/Iceberg insert 测覆盖);② 列选择 helper `selectConnectorSinkBindColumns` 单测 + 分布 full-schema 索引测;③ p2 live `test_mc_write_insert` Test 3/3b + `test_mc_write_static_partitions`。 +- **关联**:[D-030]、[review-rounds](./reviews/P4-T06e-FIX-BIND-STATIC-PARTITION-review-rounds.md)、[DV-015](同类 harness 缺位) +- **后续动作**:[ ] 待外表 analyze harness 落地补 bind 投影 CI 级测。 + +### DV-013 — P4-T06e FIX-WRITE-DISTRIBUTION:两处 planner 写分发 parity 微差(均非回归) + +- **发现日期**:2026-06-07 +- **发现 session / agent**:FIX-WRITE-DISTRIBUTION clean-room review(workflow `ww1g95bba`,Phase A parity/delivery lens) +- **当前状态**:🟢 已登记(非回归,接受;default `enable_strict_consistency_dml=true` 下与 legacy MC 同果) +- **原计划位置**:[FIX-WRITE-DISTRIBUTION 设计](./tasks/designs/P4-T06e-FIX-WRITE-DISTRIBUTION-design.md)(§"Known minor divergence — ShuffleKeyPruner" + §"Why no change in RequestPropertyDeriver") +- **偏差描述**: + - **① ShuffleKeyPruner**:`ShuffleKeyPruner.visitPhysicalConnectorTableSink`(通用 connector 分支,`:286-295`)缺 legacy `visitPhysicalMaxComputeTableSink`(`:272-283`)的 `enableStrictConsistencyDml==false → childAllowShuffleKeyPrune=true` 短路;通用分支恒 `required.equals(ANY)?true:false`。 + - **② local-sort under non-strict**:`enable_strict_consistency_dml=false` 时 `RequestPropertyDeriver` 对 connector sink(required≠GATHER)下推 `ANY` → 动态分区 hash+local-sort 需求被丢。 +- **为何非回归**:default `enable_strict_consistency_dml=`**`true`**(`SessionVariable.java:1566`)下——① 两路均 `required≠ANY → prune=false`(**同果**);② `RequestPropertyDeriver` 下推 `getRequirePhysicalProperties()` = hash+local-sort(**enforce**,与 legacy MC 同)。仅 non-strict(用户显式关)时分歧:① 通用分支**少剪**(更保守 = missed optimization,无正确性损);② local-sort 被丢——但 **legacy MC 在 non-strict 下亦丢**(`visitPhysicalMaxComputeTableSink` 同样下推 ANY)→ parity,非本 fix 引入。clean-room review Phase B 把 ① 多数 refute 为 non-regression。 +- **影响范围**:仅 `enable_strict_consistency_dml=false` 的 MaxCompute 动态分区写;default 不触及。① 纯性能(少剪 shuffle-key);② 与 legacy 同行为。 +- **关联**:[D-029]、[review-rounds](./reviews/P4-T06e-FIX-WRITE-DISTRIBUTION-review-rounds.md)、[复审 §A.NG-2/NG-4](./reviews/P4-maxcompute-full-rereview-2026-06-07.md) +- **后续动作**: + - [ ] 如需 non-strict 下完全 parity:给 `ShuffleKeyPruner` 通用 connector 分支补 `enableStrictConsistencyDml` 短路(影响 jdbc/es 共享分支,超本 fix scope) + +### DV-012 — P4-T04:`partition_columns` 取 ODPS 表列(源不同、值同) + +- **发现日期**:2026-06-06 +- **发现 session / agent**:P4 Batch B session(P4-T04 写计划实现,核读 legacy `MaxComputeTableSink.bindDataSink`) +- **当前状态**:🟢 已落地(P4-T04,值等价) +- **原计划位置**:[P4-T04 设计](./tasks/designs/P4-T04-write-plan-design.md)(港 legacy `MaxComputeTableSink` 静态字段) +- **偏差描述**:legacy `MaxComputeTableSink.bindDataSink` 填 `TMaxComputeTableSink.partition_columns`(field 14) 取 `targetTable.getPartitionColumns()`(fe-core Doris `Column` 名)。连接器 import-gate 禁 fe-core `catalog.Column`,且 planWrite 持的是 `MaxComputeTableHandle`(携 odps-sdk `Table`)非 fe-core 表。 +- **新方案**:连接器 `MaxComputeWritePlanProvider.planWrite` 取 `mcHandle.getOdpsTable().getSchema().getPartitionColumns()`(odps-sdk `com.aliyun.odps.Column` 名)。**源不同(ODPS schema vs fe-core Column)、值同(分区列名字符串)**——BE 经 field 14 收到相同分区列名 list。同源亦用于静态分区串的列序(`MCTransaction.beginInsert` 用 fe-core 列序,连接器用 ODPS 列序,序同)。 +- **影响范围**:连接器 `MaxComputeWritePlanProvider`(dormant,gate 关,零 live)。行为等价:BE 收到的 `partition_columns` 内容不变。 +- **关联**:P4-T04、[P4-T04 设计](./tasks/designs/P4-T04-write-plan-design.md)、[D-025] + +--- + +### DV-011 — P4-T03:连接器事务 block 上限 + 异常类型(import-gate 禁 fe-core common) + +- **发现日期**:2026-06-06 +- **发现 session / agent**:P4 Batch B session(P4-T03 写前核实 import-gate 边界:`org.apache.doris.common.{Config,UserException}` 均在禁列) +- **当前状态**:🟢 已修正(P4-T03 硬编 → GC1 经 session-property 透传恢复 fe.conf 可调性,`95575a4954d`) +- **原计划位置**:[P4-T03 设计](./tasks/designs/P4-T03-write-txn-design.md)(港 legacy `MCTransaction` block 分配 + commit) +- **偏差描述**:legacy `MCTransaction.allocateBlockIdRange` 用 fe-core `Config.max_compute_write_max_block_count`(默认 20000,fe.conf 可调)作上限、并 `throws UserException`。连接器 import-gate 禁 `org.apache.doris.common.*`(含 `Config`/`UserException`),二者均不可 import。 +- **新方案**:① 上限改连接器常量 `MaxComputeConnectorTransaction.MAX_BLOCK_COUNT = 20000L`(镜像 legacy 默认值,**丢 fe.conf 可调性**;Rule 2 不投机,如需再经 `MCConnectorProperties` 暴露)。② 校验失败抛 `DorisConnectorException`(unchecked;SPI `ConnectorTransaction.allocateWriteBlockRange` 面无 checked throws,W4 `PluginDrivenTransaction` 适配)。 +- **影响范围**:连接器 `MaxComputeConnectorTransaction`(dormant,gate 关,零 live)。行为:block 上限值不变(20000),仅来源 Config→常量;异常类型 UserException→DorisConnectorException(语义等价的写失败)。 +- **关联**:P4-T03、[P4-T03 设计](./tasks/designs/P4-T03-write-txn-design.md)、[D-024] +- **后续动作**: + - [x] 已恢复 fe.conf 可调(GC1 FIX-BLOCKID-CAP-CONFIG,`95575a4954d`):经 **session-property 透传**——fe-core `ConnectorSessionBuilder.extractSessionProperties` 注入 `Config.max_compute_write_max_block_count`(镜像既有 `lower_case_table_names`),连接器 `MaxComputeConnectorMetadata.resolveMaxBlockCount` 读 `ConnectorSession.getSessionProperties()` 透传 ctor。**非**原拟 `MCConnectorProperties`(那是 catalog-scoped、错 scope);本机制读 fe-core 全局 Config = true legacy parity。 + +### DV-010 — P4-T01:共享 fe-core ConnectorColumnConverter 丢 CHAR/VARCHAR 长度,特判修复(用户签字) + +- **发现日期**:2026-06-06 +- **发现 session / agent**:P4 Batch A session(P4-T01 启动前 code-grounded 核读 `ConnectorColumnConverter.toConnectorType` + `ScalarType`:CHAR/VARCHAR 长度存 `len`、`getScalarPrecision()` 返 `precision`=0;既有 `ConnectorColumnConverterTest` 无 CHAR/VARCHAR 断言) +- **当前状态**:🟢 已修正(P4-T01;fe-core `ConnectorColumnConverter` 特判 + 回归测 `testCharVarcharLengthPreserved`,Tests run 9/0F0E) +- **原计划位置**:P4-T01 原框定「连接器-only、gate 关」;`ConnectorColumnConverter.toConnectorType`(P0-T15 期建)ScalarType 分支统一用 `getScalarPrecision()`/`getScalarScale()` +- **偏差描述**:连接器 `createTable` 消费的 `ConnectorCreateTableRequest` 列类型经 `ConnectorColumnConverter.toConnectorType(Type)` 产生;其 ScalarType 分支对 CHAR/VARCHAR 用 `getScalarPrecision()`(=`precision` 字段,CHAR/VARCHAR 默认 0),而长度实存 `len`(`getLength()`)→ 请求里 CHAR(n)/VARCHAR(n) **丢长度**(legacy `dorisScalarTypeToMcType` 用 `getLength()` 保留)。这是 P0 转换器的**逆一致性 bug**(其逆向 `convertScalarType` + 连接器 `MCTypeMapping` 约定「CHAR/VARCHAR 长度在 precision 字段」),是 CHAR/VARCHAR DDL 经 SPI 真正达 parity 的唯一路径。 +- **新方案**(用户 AskUserQuestion 签字「修 fe-core 转换器」):`toConnectorType` 特判 CHAR/VARCHAR,把 `getLength()` 写入 ConnectorType precision 字段(与逆向约定一致);其余类型不变;加回归测 `ConnectorColumnConverterTest#testCharVarcharLengthPreserved`。 +- **替代方案**:连接器侧对 CHAR/VARCHAR 缺长度 fail-loud + 记 OQ 推迟(保 Batch A 连接器-only 边界,但 CHAR/VARCHAR DDL 暂不可用)——用户否决。 +- **影响范围**: + - 代码:fe-core `ConnectorColumnConverter.toConnectorType`(+ import `PrimitiveType`)+ test。**触碰共享 P0 代码**:对 live 的 jdbc/es CREATE TABLE CHAR/VARCHAR 行为变更(「丢长度」→「保留长度」,严格更正确,低风险)。 + - 文档:本条 + [tasks/P4](./tasks/P4-maxcompute-migration.md) + [PROGRESS](./PROGRESS.md)(§四/§六计数)。 + - 计划:P4-T01 范围从「连接器-only」微扩至含 1 处 fe-core 转换器修复。 +- **关联**:P4-T01、P0-T15(converter)、[D-023] +- **后续动作**: + - [x] 修 `toConnectorType` + 回归测(P4-T01) + - [ ] Batch E:连接器 DDL parity 测覆盖 CHAR/VARCHAR 端到端 + +### DV-009 — W5 写 sink 收口位置与 RFC/handoff 措辞不符:plugin-driven 写已有专路,改为 layer planWrite + +- **发现日期**:2026-06-06 +- **发现 session / agent**:W-phase 实现 session(W5 启动前 2 路 Explore code-grounded recon:sink 入参 + nereids 写 sink 接线;主线 firsthand 核读 `PhysicalPlanTranslator.visitPhysicalConnectorTableSink` / `planner/PluginDrivenTableSink`) +- **当前状态**:🟢 已修正(W5 commit `9ebe5e27fa4`;用户 AskUserQuestion 签字「Corrected W5 (layer planWrite)」) +- **原计划位置**:[写 RFC §5.5 / §12 W5](./tasks/designs/connector-write-spi-rfc.md)、[HANDOFF W5 锚点](./HANDOFF.md)——原措辞:「新建 fe-core `PluginDrivenTableSink` + `PhysicalPlanTranslator` 各 `visitPhysicalXxxTableSink`(hive/iceberg/mc)→ `planWrite()`,保 PhysicalXxxSink fallback」。 +- **偏差描述**:RFC/handoff 写于不知既有路径之时。实测(recon + firsthand 核读): + 1. `PluginDrivenTableSink` **已存在**(`planner/PluginDrivenTableSink.java`,P0/P1 JDBC 期建),非新建。 + 2. plugin-driven 写 INSERT **不**走 `visitPhysicalHive/Iceberg/MaxComputeTableSink`(那 3 个服务 legacy 非 plugin-driven 表);走专路 `UnboundConnectorTableSink → LogicalConnectorTableSink → PhysicalConnectorTableSink → visitPhysicalConnectorTableSink`(`PhysicalPlanTranslator:644`),已据 `ConnectorWriteConfig`(config-bag)建 `PluginDrivenTableSink`。mc/hive/iceberg 迁 plugin-driven 后走此专路 → 在那 3 个 concrete 方法加 planWrite 路由是**死代码**。 + 3. 两写-sink 模型并存:既有 **config-bag**(连接器返 `ConnectorWriteConfig` 属性包,fe-core 建 `THiveTableSink`/`TJdbcTableSink`;表达不了 mc/iceberg)⊥ 新 **opaque-sink**(W1 `ConnectorWritePlanProvider.planWrite()` 连接器自建 `TDataSink`,RFC §5.5 E 决策,可泛化)。RFC 未察 config-bag 已存在,故未调和二者。 +- **新方案**(用户签字):在既有 `visitPhysicalConnectorTableSink` + `PluginDrivenTableSink.bindDataSink` 上 **layer** `planWrite()` 为优先路径(`connector.getWritePlanProvider() != null` 时),config-bag 为 fallback。**不动** 3 个 concrete visit 方法。零行为变更(无连接器 override `getWritePlanProvider`,jdbc 仍走 config-bag)。`ConnectorWriteHandle`/`ConnectorSinkPlan`(W1)形状经使用确认充分,无需改。 +- **缩界(R12 不静默)**:overwrite / 静态分区 / writePath 等 connector-specific write context 的 handle 填充留 P4 adopter(base `InsertCommandContext` 为空 marker,无通用 overwrite;强行 instanceof 子类会再耦合 fe-core)。W5 仅建 seam(空 context)。 + +--- + +### DV-008 — P3-T07 parity 暴露两处 SPI↔legacy 偏差:列名 casing 当场修;Hudi meta-field 纳入推迟批 E + +- **发现日期**:2026-06-05 +- **发现 session / agent**:P3 批 C session(T07 启动前 5-agent code-grounded recon workflow `p3-t07-recon`:cow-mor / legacy-types / spi-types / hms-surface / hive-surface + 主线核读 `HudiConnectorMetadata`/`HudiTypeMapping`/`HMSExternalTable.initHudiSchema`/`ThriftHmsClient`) +- **当前状态**:🟢 已修正(gap-1 casing 已修 + 测;gap-2 meta-field 推迟批 E 实证) +- **原计划位置**:[tasks/P3 §批 C/T07](./tasks/P3-hudi-migration.md)(「parity 测试——SPI `HudiConnectorMetadata` schema/partition 输出 vs legacy `getHudiTableSchema`」)——原计划隐含假定 SPI schema 输出与 legacy parity,仅需写测试验证 +- **偏差描述**:parity recon 实证 SPI avro→column 变换与 legacy `HMSExternalTable.initHudiSchema` 有两处偏差(其余逐类型一致,见设计备忘矩阵): + 1. **gap-1 列名 casing**:SPI `HudiConnectorMetadata.avroSchemaToColumns` 用 `field.name()` 原样;legacy 在 `HMSExternalTable.java:745` `toLowerCase(Locale.ROOT)`(**仅顶层列名**;嵌套 struct 字段名两侧均不降)。mixed-case avro 列名时 SPI 保留原 case → 破 parity(BE name-match 大小写敏感,见 DV-006 / T03)。 + 2. **gap-2 Hudi meta-field 纳入**:SPI `getSchemaFromMetaClient` 调无参 `TableSchemaResolver.getTableAvroSchema()`;legacy `getHudiTableSchema:852` 调 `getTableAvroSchema(true)`。`true` 很可能强制纳入 `_hoodie_*` meta 列,无参默认随 Hudi 版本/表配置(`populateMetaFields`)变 → 可能改变列集合。无真实 metaclient 不可单测判定(同 T03 族)。 +- **触发场景**:T07 parity recon(golden-value 法,因 fe-core 只依赖 fe-connector-api/-spi、不依赖具体连接器模块,无跨模块编译路径)+ 用户 AskUserQuestion 签字(2026-06-05,「Also fix casing now」+「Focused baseline」)。 +- **新方案**: + - **gap-1 当场修**(用户签字):`avroSchemaToColumns` 顶层列名改 `toLowerCase(Locale.ROOT)`,镜像 legacy:745(仅顶层;嵌套 struct 名保持 raw,两侧一致)。已核安全:`ThriftHmsClient.convertFieldSchemas:303` 用 `fs.getName()` 不防御降字,但 Hive Metastore 自身存小写标识符 → 降 avro 路径列名与小写 HMS partition key 对齐(改善 `getColumnHandles` 匹配),无回归。`avroSchemaToColumns` 由 `private`→package-private `static`(零行为变更,使可单测)。 + - **gap-2 推迟批 E**(DV-006 同族):无真实 fixture 不可判定 + 属 schema-evolution/meta-field 机制,与 hive/HMS migration 一并实证。T07 parity 测不依赖该差异(测纯 avro→column 变换)。 + - **缩界(R12 不静默)**:`ThriftHmsClient` 源头防御性降字(与 hive 模块共享)**不在 T07 改**——触碰 hive 行为属 P7/批 E。 +- **替代方案**:(gap-1) 不修、仅 pin 现状 + 记 DV 推批 E(precedent T03/T05)——用户否决,选当场修(trivially-correct,对齐 legacy + 小写 HMS);(gap-2) 当场加 `(true)`——否决(无真实 metaclient 不可验证语义,脆测)。 +- **影响范围**: + - 文档:本条 + [tasks/P3](./tasks/P3-hudi-migration.md)(T07 ✅ + 验收 + 阶段日志)+ [PROGRESS](./PROGRESS.md)(§一/二/三/四/六/七)+ [connectors/hudi.md](./connectors/hudi.md)(概况 + playbook 12 + 进度日志)+ [HANDOFF](./HANDOFF.md)。 + - 代码:gap-1 `HudiConnectorMetadata.avroSchemaToColumns`(降字 + 可见性)+ 6 测试文件(hudi 3 改/新 + hms 1 + hive 2);gap-2 零代码。 + - 计划:批 C = {三模块测试基线 ✅, COW/MOR schema parity ✅, gap-1 casing 修 ✅};gap-2 meta-field 入批 E。 +- **关联**:P3-T07、DV-006(同族 schema-evolution 推批 E)、P3-T10/T11(批 E)、[D-019](./decisions-log.md)(hybrid)、[`designs/P3-T07-test-baseline-design.md`](./tasks/designs/P3-T07-test-baseline-design.md) +- **后续动作**: + - [x] gap-1 casing 修 + `HudiSchemaParityTest` casing pin(顶层降、嵌套 struct 名保留) + - [x] 三模块测试基线(hms `HmsTypeMappingTest` 12 / hive `HiveFileFormatTest` 6 + `HiveConnectorMetadataPartitionPruningTest` 8 / hudi `HudiTypeMappingTest`+7 + `HudiSchemaParityTest` 3 + `HudiTableTypeTest` 4 = 33 全绿) + - [ ] 批 E:gap-2 meta-field 纳入(`getTableAvroSchema(true)` vs 无参)真实 fixture 实证 + - [ ] 批 E/P7:`ThriftHmsClient` 源头防御性降字(与 hive 共享) + +### DV-007 — P3 批 B scope 校正:T05 `listPartitions*` override 推迟批 E;T06 MVCC 保持 default opt-out(非抛异常 override) + +- **发现日期**:2026-06-05 +- **发现 session / agent**:P3 批 B session(T05/T06 启动前 5-reader code-grounded recon workflow:hudi-current / hudi-resolve / hive-ref / spi-invoke / mvcc-t06 + 主线核读 `HudiConnectorMetadata`/`HiveConnectorMetadata` 全文 + grep fe-core 调用方) +- **当前状态**:🟢 已修正(T05 applyFilter EQ/IN 裁剪已落地 commit `10b72d4`;list*/MVCC 完整实现入批 E) +- **原计划位置**:[HANDOFF.md 未完成 #1/#2](./HANDOFF.md)(「T05:`listPartitions/listPartitionNames/listPartitionValues` override + 真实 applyFilter EQ/IN 分区裁剪」;「T06:大概率**显式 unsupported**(与 T04 fail-loud 一致)」)+ [tasks/P3 §T05/T06](./tasks/P3-hudi-migration.md) +- **偏差描述**:原计划把 T05 的「`listPartitions*` override」与「applyFilter 裁剪」并列为批 B 交付;并暗示 T06 应**新增抛异常的 MVCC override**。recon 实测两点前提失真: + 1. **T05 `listPartitions*` 零 live caller + Hive 不 override**:SPI `ConnectorMetadata.listPartitionNames/listPartitions/listPartitionValues` 在 fe-core **无任何调用方**——`PluginDrivenScanNode` 不调用(分区经 `applyFilter`→`prunedPartitionPaths`→`resolvePartitions` 链路);`ShowPartitionsCommand`/`HudiExternalMetaCache`/`MetadataGenerator` 调的是 **legacy** metastore 路径(`dorisTable.getRemoteName()`),非 SPI。对标 `HiveConnectorMetadata`(批 B 基准)**也不 override** 这三方法。→ 现 override = 不可测的死代码(违 R2 nothing speculative / R9 测意图)。 + 2. **T06「显式 unsupported」违 SPI opt-out 约定**:三个 MVCC 方法 default 即 `Optional.empty()`(= 不支持),`FakeConnectorPluginTest` 有显式断言;`Iceberg`/`Paimon`/`Hive`/`Trino` **全部依赖 default**,无一 override;MVCC 方法**无 production caller**(仅测试用 adapter);且 T04 已在唯一可触发点(time-travel)`visitPhysicalHudiScan` 抛 `AnalysisException`。→ 新增抛异常 override = 唯一打破约定 + 不可达死代码(违 R11 conformance / R3 surgical)。 +- **触发场景**:T05/T06 启动前 recon + grep fe-core 调用方;用户 AskUserQuestion 签字(2026-06-05,「Pruning only, defer list*」+「Keep defaults + document」)。 +- **新方案**: + - **T05** = 仅 applyFilter 真实 EQ/IN 裁剪(忠实镜像 Hive 7 步 + 7 helper,保留 `List` 路径表示与 `-1` 上限);`listPartitions*` override **推迟批 E**(届时 fe-core 长出 SPI 消费 + `SHOW PARTITIONS` 改走 SPI 时一并做)。已落地 `10b72d4`(8 单测、checkstyle 0、import-gate 通过)。 + - **T06** = **不 override,保持 default `Optional.empty()` opt-out + 文档化**(零代码);正确的 fail-loud 已在 T04 的 translator 守卫。完整 MVCC(`HudiMvccSnapshot`、snapshot 透传、增量时序)入批 E。见 [`designs/P3-T06-mvcc-design.md`](./tasks/designs/P3-T06-mvcc-design.md)。 +- **替代方案**:(T05) 现 override 三方法委托 HMS——否决(死代码、无可测意图、Hive 无先例);(T06) 新增抛异常 override——否决(破 opt-out 约定、不可达、与全体连接器分叉、T04 已覆盖)。 +- **影响范围**: + - 文档:本条 + [tasks/P3](./tasks/P3-hudi-migration.md)(T05 ✅ 裁剪 + T06 ✅ 决策 + 验收标准 + 阶段日志)+ [PROGRESS](./PROGRESS.md)(§一 P3 / §三 / §四 / §六计数)+ [connectors/hudi.md](./connectors/hudi.md)(E5/E10 + 进度日志)。 + - 代码:T05 已合入 `10b72d4`(applyFilter 裁剪 + 单测);T06 零代码。 + - 计划:批 B 范围由 {T05 裁剪+list* override, T06 throwing override} 收为 {T05 裁剪 ✅, T06 keep-defaults ✅};list*/完整 MVCC 与 T03/T09–T11 同批 E。 +- **关联**:[DV-005](#dv-005--p3-hudi-的hms-over-spi-前置依赖与代码实际状态不符真正阻塞是-catalog-模型错配)(其后续动作「listPartitions override + 真实 applyFilter 裁剪」本条落地裁剪部分)、P3-T05、P3-T06、P3-T10/T11(批 E)、[D-019](./decisions-log.md)(hybrid)、[P3-T04](./tasks/designs/P3-T04-fail-loud-design.md) +- **后续动作**: + - [x] T05 applyFilter EQ/IN 裁剪 + 单测(`10b72d4`) + - [ ] 批 E:`listPartitions*` override(fe-core SPI 消费就绪 + `SHOW PARTITIONS` 走 SPI 后) + - [ ] 批 E:完整 MVCC(`HudiMvccSnapshot` + snapshot 透传 + 增量时序),time-travel 从 T04 fail-loud 转为正确快照 + +### DV-006 — P3-T03(schema_id / history_schema_info)不是 model-agnostic 的批 A SPI-surface 修复;推迟到批 E + +- **发现日期**:2026-06-05 +- **发现 session / agent**:P3 批 A session(T03 启动前 code-grounded recon:4-reader workflow 读 SPI hook + Paimon/ES 参照 + legacy 路径 + thrift/BE 消费端;主线对 BE `table_schema_change_helper.h` 二次核读) +- **当前状态**:🟡 推迟(批 E,并入 hive/HMS migration) +- **原计划位置**:[HANDOFF.md 关键认知 1b HIGH ①](./HANDOFF.md) + [DV-005 后续动作 ①](#dv-005--p3-hudi-的hms-over-spi-前置依赖与代码实际状态不符真正阻塞是-catalog-模型错配) + [tasks/P3 §P3-T03](./tasks/P3-hudi-migration.md):「schema_id/history 缺→退化名匹配;可经现有 SPI hook `populateScanLevelParams`(Paimon/ES 已 override)+ `HudiScanRange` 设 schema_id 修复,**无需 fe-core 改动**」 +- **偏差描述**:原评估认为 ① 是「多在 SPI surface 内可修」的 model-agnostic 修复。recon 实测发现**前提不成立**: + 1. **BE 语义**(`be/src/format/table/table_schema_change_helper.h:219-267`):`history_schema_info` **unset** → `by_parquet_name`/`by_orc_name`(**鲁棒名匹配**,处理大小写 / 缺列)——**即当前 SPI hudi 路径行为**;`current_schema_id == file_schema_id` → **`ConstNode`**(`:92-121`)= **纯 identity-by-name**、**大小写敏感**、假设精确匹配(其注释自陈需注意大小写);id 不同 → `by_table_field_id`(**唯一**做 field-id / 改名 / evolution 的路径)。 + 2. **「Paimon/ES 已 override」前提失真**:二者 override `populateScanLevelParams` 是为 **predicate / docvalue**,**并不设** schema evolution 元数据(recon 实证)——**无任何 SPI 先例**发 schema_id/history。 + 3. **连接器缺料**:`HudiColumnHandle` **无 field id**(仅 `name`/`typeName` 串/`isPartitionKey`);SPI hudi 连接器**无 Hudi `InternalSchema` 版本跟踪**(legacy 走 `getCommitInstantInternalSchema`);连接器模块**无 type→`TColumnType` thrift 转换**(legacy 在 fe-core `ExternalUtil.getExternalSchema`,import gate 禁止复用)。 + 4. **裸基线会回归**:若仅设 `current==file==-1`(→ ConstNode)= identity-by-name 大小写敏感,**严格弱于**当前名匹配(丢大小写 / 缺列处理)——**净回归**;而真正的 field-id evolution 路径需上述全部缺料。 +- **触发场景**:T03 启动前 recon + 主线核读 BE `gen_table_info_node_by_field_id` / `ConstNode` / `StructNode`。 +- **新方案**:**T03 推迟到批 E**,与 hive/HMS migration 一次性建齐机制(column-handle field id + Hudi `InternalSchema` 版本 + Avro/ConnectorType→`TColumnType` thrift + `populateScanLevelParams` 设 current+history + 每-split `THudiFileDesc.schema_id`)。批 A 不发任何 schema 元数据(保持现状名匹配,**零回归**),不 ship 裸 ConstNode 基线。用户已签字(2026-06-05,AskUserQuestion「Defer T03 to batch E」)。 +- **替代方案**:(a) 批 A 内建全套 field-id/InternalSchema/type→thrift 机制——否决(大、与批 E 重叠、触碰 live 可读 schema 路径、回归风险);(b) 裸 ConstNode 基线——否决(净回归大小写/缺列)。 +- **影响范围**: + - 文档:本条 + [tasks/P3](./tasks/P3-hudi-migration.md)(T03 移入批 E、备注现状名匹配 + evolution gap)+ [PROGRESS](./PROGRESS.md)(§三 parity 行 / §六计数)+ [connectors/hudi.md](./connectors/hudi.md)。 + - 代码:无(recon + 决策,零改动)。 + - 计划:批 A 范围由 {T02,T03,T04} 收为 {T02 ✅, T04};T03 与 T09–T11 同批 E。 +- **关联**:[DV-005](#dv-005--p3-hudi-的hms-over-spi-前置依赖与代码实际状态不符真正阻塞是-catalog-模型错配)(其后续 ① 本条修正)、P3-T03、P3-T10/T11(批 E)、[D-019](./decisions-log.md)(hybrid)、R-001 +- **后续动作**: + - [ ] 批 E:连接器 schema field-id + InternalSchema 版本 + type→thrift + `populateScanLevelParams` + per-split `schema_id`(faithful field-id evolution parity) + - [x] 现状行为登记:SPI hudi 走 BE 名匹配(`by_parquet_name`/`by_orc_name`),common 无 evolution 可用;改名 / reorder-with-evolution 退化(非崩溃) + +### DV-005 — P3 hudi 的「HMS-over-SPI 前置依赖」与代码实际状态不符;真正阻塞是 catalog 模型错配 + +- **发现日期**:2026-06-04 +- **发现 session / agent**:P3 启动 recon session(8-agent code-grounded workflow + 2 路对抗验证;verdict `hmsMetadataOverSpiReady=false`, high confidence) +- **当前状态**:🟡 待修正(P3 catalog 模型决策,待用户签字) +- **原计划位置**:[connectors/hudi.md](./connectors/hudi.md)(「P3 启动前必须 P5 paimon 或 P7 hive 进入到至少完成 hms metadata 路径」)、[master plan §3.4/§3.8](./00-connector-migration-master-plan.md)、决策 D-005(用 `tableFormatType` 区分 DLA) +- **偏差描述**:原计划假设 HMS-over-SPI 元数据读路径要等 P5/P7 才落地、是 P3 的前置硬依赖。recon 实测(`branch-catalog-spi` HEAD `0793f032662`)发现该读路径**代码早已存在且非 stub**(源自更早的 #62183/#62821,一直 dormant 在 gate 后): + - `fe-connector-hms` = 共享 **HMS Thrift 客户端库**(`HmsClient`/`ThriftHmsClient`,**不是** ConnectorMetadata); + - `fe-connector-hive` `HiveConnectorMetadata`(type `"hms"`) 真实读路径 + applyFilter 真分区裁剪; + - `fe-connector-hudi` `HudiConnectorMetadata`(type `"hudi"`) 从 Hudi Avro MetaClient 读 schema(HMS fallback)+ COW/MOR 探测 + `HudiScanPlanProvider` 快照扫描; + - D-005 区分符 `ConnectorTableSchema.tableFormatType`(`:33/:58`) 已存在并被各连接器写入。 + + 但全部 **dormant**:`CatalogFactory.SPI_READY_TYPES = {jdbc, es, trino-connector}`(`CatalogFactory.java:52`) 不含 hms/hudi → HMS 系 catalog 永远走 legacy `HMSExternalCatalog`(零 live caller)。**真正阻塞不是缺 HMS 读码,而是 catalog 模型错配**:现存连接器注册独立 `"hudi"` catalog type(`HudiConnectorProvider.getType()=="hudi"`),而 Doris 真实模型是 hudi 寄生在 `"hms"` catalog 内、以 `HMSExternalTable.DLAType.HUDI` 暴露;fe-core 无 `"hudi"` catalog type,且 `PluginDrivenExternalTable` 从不消费 `tableFormatType`(只读 `getColumns()`,按 catalog TYPE 字串路由)→ 单个 `"hms"` 连接器没有 per-table HUDI/HIVE/ICEBERG 分流的 SPI 机制。附带确认缺口:增量读无 SPI 表示(P1-T04 `visitPhysicalHudiScan` SPI 分支丢弃 `getIncrementalRelation()`;MVCC trio 未实现;4 个 `*IncrementalRelation` 仍在 fe-core);hive/hudi 未 override `listPartitions*`(Hudi applyFilter 列全部分区不裁剪,Hive applyFilter 做 EQ/IN 裁剪);三模块零测试。**已验证非阻塞**:SPI scan/split 通用链路(`PluginDrivenScanNode.planScan`→BE)已被合入的 trino-connector 走通;hudi-specific 的「单 ScanNode 混合 COW-native + MOR-JNI 每-split 格式」正确性才是待验证项。 +- **触发场景**:用户准备启动 P3,要求 code-grounded 确认 HMS 就绪情况。 +- **新方案**:P3 不再以「等 P5/P7 交付 HMS-over-SPI」为前提;改为 (1) recon SPI scan/split 路径(hudi-specific 正确性),(2) 写 catalog 模型决策备忘(见下),用户签字后再编码。**不要直接 flip `SPI_READY_TYPES`**。 +- **替代方案(catalog 模型,待用户决策)**: + - **(a) hms-first**:`HiveConnectorProvider(type="hms")` 接入 `PluginDrivenExternalCatalog` + fe-core 消费 `tableFormatType` 分流,hudi 作薄增量。一次命中真正架构阻塞、契合现存 `type="hms"` 设计;但把 P7(hive/HMS) 范围拉进 P3、触碰 live 重度使用的 HMS 路径、零测试网,回归风险大。 + - **(b) gate 后建脚手架**:先做 format-dispatch / 增量 SPI hook / MVCC + 补测试(design+stub,不动 live 路径、零回归);但 hudi 不单独端到端可用,推迟模型决策。 + - **(c) 直接 flip gate** —— **否决**(模型错配下 `"hudi"` provider 不可达;live hms catalog 推到未测 SPI;增量丢失;高回归)。 +- **影响范围**: + - 文档:本条 + [connectors/hudi.md](./connectors/hudi.md)(已加更正注)+ [PROGRESS.md](./PROGRESS.md)(§一 P3 / §二看板 / §四 / §六 / §七 已同步)+ [HANDOFF.md](./HANDOFF.md)(P3 起点)✅;master plan / hudi.md 章节正文待 P3 按选定模型重写。 + - 代码:无(recon only)。 + - 计划:P3 性质从「等依赖」变为「先定模型 + 补 SPI 分流/增量/测试」;可能与 P7(hive/HMS) 部分合并或重排序——待模型决策。 +- **关联**:D-005、P1-T04(incrementalRelation gap)、R-001(image 兼容)、P3、master plan §3.4/§3.8 +- **后续动作**: + - [x] P3 session:recon SPI scan/split —— **完成**(verdict:混合 COW-native/MOR-JNI 非问题、与 legacy 结构等价;plumbing 正确;parity gap 见下,详见 HANDOFF 1b) + - [ ] scan 侧 HIGH 修复(与模型无关、多在 SPI surface 内):①`HudiScanPlanProvider` override `populateScanLevelParams` 设 current_schema_id+history_schema_info + `HudiScanRange` 设 `THudiFileDesc.schema_id`;②column_types 改发完整 Hive 类型串(弃 `getTypeName()`)+ 停止逗号 join/split(typed list 端到端);③time-travel 透传 snapshot 否则 fail-loud;④增量读 fail-loud + - [x] 写 catalog 模型决策备忘(a/b),用户签字 —— **完成**:定 **hybrid**([D-019](./decisions-log.md)),建 [tasks/P3](./tasks/P3-hudi-migration.md)(批 A 现做 b、批 E 推迟 a) + - [ ] 选定后:补 `tableFormatType` 分流消费、增量 SPI hook、`listPartitions` override + 真实 applyFilter 裁剪、三模块测试 + +### DV-004 — T13 用户向安装文档不在本代码仓(在 doris-website 仓) + +- **发现日期**:2026-06-04 +- **发现 session / agent**:P2 批 C+D+E session +- **当前状态**:🟢 已修正 +- **原计划位置**:[tasks/P2 §P2-T13](./tasks/P2-trino-connector-migration.md):「`docs-next/` 加 trino-connector 插件安装步骤」 +- **偏差描述**:原计划假设本代码仓有 `docs-next/`;实际本仓只有 `docs/`,用户向文档(docs-next / i18n)在独立的 doris-website 仓。 +- **新方案**:T13 在本 PR 内只同步 plan-doc 跟踪文档;用户向安装文档另在 doris-website 仓提交。 +- **影响范围**:文档 — 本仓只更新 plan-doc;website 仓待办。代码/计划 — 无。 +- **关联**:P2-T13 +- **后续动作**:[ ] 在 doris-website 仓补 trino-connector 插件安装文档 + +### DV-003 — T12 迁移兼容回归测试:先例与目标目录均不存在,且本地不可运行 + +- **发现日期**:2026-06-04 +- **发现 session / agent**:P2 批 C+D+E session +- **当前状态**:🟡 推迟 +- **原计划位置**:[tasks/P2 §P2-T12](./tasks/P2-trino-connector-migration.md):「类似 P0 的 ES/JDBC migration compat;放入 `regression-test/suites/external_catalog/`」 +- **偏差描述**:(1) 不存在「P0 ES/JDBC migration_compat」先例套件;(2) 不存在 `external_catalog/` 目录(实际为 `external_table_p0/` 与 `external_table_p2/`);(3) 该测试需真实 Trino plugin + 外部数据源 + 运行集群,本开发环境无 docker/集群,无法编写后验证。 +- **触发场景**:批 E 启动 T12 时 recon 发现。 +- **新方案**:推迟到有 Trino plugin + docker/集群的环境再编写并验证;不往本 PR 加无法验证的套件。 +- **替代方案**:盲写 groovy 放 `external_table_p0/trino_connector/` 但本地不可验证——否决(违反"测试要可验证")。 +- **影响范围**:测试 — 迁移 image 兼容回归缺位(现有 trino_connector 功能套件仍在)。代码/计划 — 无。 +- **关联**:P2-T12、R-001(image 兼容回归风险) +- **后续动作**:[ ] 集群/CI 环境补 `trino_connector_migration_compat`(CREATE CATALOG→image→重启读回 + 旧 image 含 `TRINO_CONNECTOR` 枚举反序列化) + +### DV-002 — T11 单测无法 mock Trino plugin;`TrinoJsonSerializer` 非纯单元 + +- **发现日期**:2026-06-04 +- **发现 session / agent**:P2 批 C+D+E session +- **当前状态**:🟢 已修正(commit `9bba12a44b2`) +- **原计划位置**:[tasks/P2 §P2-T11](./tasks/P2-trino-connector-migration.md):「最少 4 个 test class(schema / predicate / type-map / json);mock Trino plugin」 +- **偏差描述**:(1) fe-connector-trino 仅依赖 junit-jupiter,无 Mockito;(2) `TrinoJsonSerializer` 构造需 `HandleResolver` + Trino `TypeRegistry`(来自已加载 plugin 的 `TrinoBootstrap`),非纯单元;(3) schema / applyFilter / preCreateValidation 需活的 connector。无 plugin 无法在单测覆盖。 +- **触发场景**:T11 启动、读 3 个 SUT 源码时发现。 +- **新方案**:写 3 个纯转换器 JUnit5 测试(`TrinoPredicateConverterTest` 14 / `TrinoTypeMappingTest` 11 / `TrinoConnectorProviderTest`=validateProperties 4 = 29 测试),本地 `mvn test` 全绿、不需 plugin;砍掉 json/schema,用 `validateProperties`(批 A T01)替补第 3 类。plugin 依赖路径由现有 `external_table_p0/p2` trino_connector regression 套件覆盖。 +- **替代方案**:引 Mockito mock Trino connector 测 pushdown/metadata——否决(偏离 module 现有约定、脆弱、费时)。 +- **影响范围**:测试 — 单测覆盖纯转换逻辑;集成路径靠 regression。代码/计划 — 无。 +- **关联**:P2-T11、P2-T02 +- **后续动作**:(无;plugin 路径覆盖见 T12 follow-up) + +### DV-001 — 批 D(删 legacy)范围遗漏 `ExternalCatalog` db 路由与 legacy 测试 + +- **发现日期**:2026-06-04 +- **发现 session / agent**:P2 批 C+D+E session +- **当前状态**:🟢 已修正(commit `ed81a063fe8`) +- **原计划位置**:[tasks/P2 §P2-T08..T10](./tasks/P2-trino-connector-migration.md) / HANDOFF:批 D 只列 T08(translator 分支)+ T09(CatalogFactory case)+ T10(删目录) +- **偏差描述**:recon 发现还有两处引用 legacy 目录、计划未列:(1) `ExternalCatalog.java:948` enum switch `case TRINO_CONNECTOR` 实例化 `TrinoConnectorExternalDatabase`;(2) 测试 `fe-core/.../trinoconnector/TrinoConnectorPredicateTest.java` 测被删的 `TrinoConnectorPredicateConverter`。删目录后两者编译失败。另:原 T10 描述「删 GsonUtils 3 个 class-token 注册」已过时(批 B/T03 已 atomic-replace,T10 不碰 GsonUtils)。 +- **触发场景**:批 D 删目录前 `grep datasource.trinoconnector` 全仓 recon。 +- **新方案**:(1) `case TRINO_CONNECTOR` 改返 `PluginDrivenExternalDatabase`(照搬已迁移的 JDBC case line 936)+ 删 import;(2) 删该 legacy 测试(新测试见 T11)。**有意保留** `MetastoreProperties.Type.TRINO_CONNECTOR` + `TrinoConnectorPropertiesFactory`(在 `property/metastore/` 子系统,不引用被删目录,SPI 路径可能仍需)。 +- **替代方案**:`case TRINO_CONNECTOR` 整删落 default 返 null——否决(JDBC 先例显式返 PluginDrivenExternalDatabase,SPI 需要)。 +- **影响范围**:代码 — 已合入批 D commit `ed81a063fe8`。文档 — 本条 + tasks/P2 T10 备注已更正。计划 — 无。 +- **关联**:P2-T08、P2-T09、P2-T10 +- **后续动作**:[ ] 评估 `MetastoreProperties` trino 条目是否真被 SPI 路径使用(若纯死代码可后续清) + +--- + +## 附录:偏差模板 + +发现偏差时复制以下模板到 §详细记录 顶部,并更新 §📋 索引表。 + +```markdown +### DV-NNN — <一句话主题> + +- **发现日期**:YYYY-MM-DD +- **发现 session / agent**:(哪次 session 发现的) +- **当前状态**:🟢 已修正 / 🟡 待修正 / 🔴 阻塞中 +- **原计划位置**:[文档名 §章节](./xxx.md),引用原句或代码片段 +- **偏差描述**:原计划说 X,实施中发现 Y +- **触发场景**:什么操作 / 什么连接器 / 什么 corner case 引发的 +- **新方案**:现在的处理方式 +- **替代方案**:考虑过的其他修正 +- **影响范围**: + - 文档:哪些文件需要同步修改(已修改的标 ✅) + - 代码:哪些已合 PR / 待提 PR + - 计划:是否影响阶段时长 / 顺序 +- **关联**:[task ID]、[PR #]、[decision D-NNN(如果偏差催生了新决策)] +- **后续动作**: + - [ ] 同步修改文档 X + - [ ] 提 PR 调整代码 Y + - [ ] 通知相关 task owner +``` + +--- + +## 何时应该写偏差日志(典型场景) + +1. RFC 中某 SPI 方法签名在实际实现时发现参数不够 / 太多 +2. 原计划某阶段时长估算严重偏差(如 2 周变 4 周) +3. 实施中发现某连接器有未预料的特殊性(如 Iceberg 某 catalog flavor 不支持某操作) +4. 原计划的某 task 拆分粒度太粗 / 太细,重新拆分 +5. 原计划假设某个三方库行为 X,实际是 Y +6. 决策(D-NNN)在落地时发现执行不了,需要重新评估 +7. 跨连接器假设的一致性被打破(如某 SPI 默认行为对 connector A 合理但对 B 不合理) + +## 何时**不**应该写偏差日志 + +- 普通 bug 修复(写 commit message) +- task 的子步骤微调(在 task 文件里加备注) +- 文档错别字 / 链接错误(直接改) +- 命名重构 / 重命名(直接改) +- 已知的实施细节决策(如选用 `HashMap` vs `LinkedHashMap`) diff --git a/plan-doc/fe-connector-hive-shade-localization/HANDOFF.md b/plan-doc/fe-connector-hive-shade-localization/HANDOFF.md new file mode 100644 index 00000000000000..c80975763e2945 --- /dev/null +++ b/plan-doc/fe-connector-hive-shade-localization/HANDOFF.md @@ -0,0 +1,38 @@ +# 🤝 Session Handoff — `fe/fe-connector` 剥离 `hive-catalog-shade` + +> **滚动文档**:每次 session 结束**覆盖式更新**,只保留下一个 session 必须的上下文。 +> 完成明细**不落这里**(在 `git log` + [`progress.md`](./progress.md))。 +> 空间索引 [`README.md`](./README.md) · 设计 [`design.md`](./design.md) · 清单 [`tasklist.md`](./tasklist.md) + +--- + +# ✅ Phase 1+2+3 完成(建精简 shade + 切 hms/iceberg + 静态&打包闸门全绿) → 🆕 下一个 session = **Phase 4:e2e(唯一真闸门)** + +> 分支 `catalog-spi-hive-shade-12`。行号信 HEAD 不信文档。代码改动已 commit(见 git log 最新一条)。 + +## 现状(一句话) +`fe/fe-connector/` 已整体脱离 122MB 胖 `hive-catalog-shade`,改用自建 15MB 精简 shade 模块 `fe-connector-hms-hive-shade`(只装 Hive 元数据客户端闭包,重定位 thrift→`shade.doris.hive.org.apache.thrift`)。build+UT(197 测试类全绿)、静态闸门(19 模块 dependency:tree 全空)、打包闸门(三插件 zip 无胖 shade、精简 shade 各 1 份、关键类无重复)、多 agent 对抗 review(零 confirmed)**均已过**。**唯一没做的是 e2e。** + +## 第一件事(按顺序) +1. **读** `progress.md` 末段(2026-07-16 Phase 1+2+3 结论,含闸门证据 + 两处现补的缺类)+ `design.md §4`(决策速查)。 +2. **动码/跑测前探并发**(`pgrep -af maven|grep wt-catalog-spi` + 近 90s mtime)。 +3. **重新构建部署产物**跑 e2e(精简 shade 需重打包插件重部署;旧部署目录可能还是胖 shade)。 + +## 🎯 Phase 4 要做的(`tasklist.md` FCL-40/41/42) +- **异构 HMS docker 套件**:① 普通 hive catalog 读+写;② **iceberg-on-HMS** INSERT/DELETE/MERGE/read,断言与独立 iceberg 目录同表同结果;③ hudi-on-HMS 读。 +- **🔴 TCCL 不回归**(memory `catalog-spi-plugin-tccl-classloader-gotcha` / D5):`test_string_dict_filter` 类用例 + MetaStoreFilterHook/URIResolverHook 按名反射路径 + kerberos HMS(若环境有);FE 启动 + 缓存(MetaCache/StatisticsCache/FileSystemCache)冒烟。 +- **专项**:storage-api **2.7.0** 的 write/ACID 路径(本轮从胖 shade 的 2.8.1 换回 3.1.3 原生 2.7.0,review 判无回退但要 e2e 兜底)。 +- 结果(含 CI 编号)追加 `progress.md`。 + +## ⚠️ 白名单 shade 的运行时缺类(Phase 4 可能再遇,按此法补) +精简 shade 用 **白名单 ``**,只装列出的 hive 客户端闭包;运行时其余靠各插件自带 hadoop-common 闭包/宿主 parent-first 提供。**跑到才暴露的缺类**本轮已补两处(老 Jackson `ObjectMapper`、iceberg `bundled-guava`);e2e 若再报 `NoClassDefFoundError`(尤其 kerberos/filter-hook 路径),**按同法补**:查是哪个 artifact 的类 → 加进 `fe-connector-hms-hive-shade/pom.xml` 的 ``(必要时显式 `compile` 覆盖 fe/pom.xml 里的 test-scope 管理) + artifactSet ``。**别退回黑名单 excludes**(那会把 122MB junk 又拉回来)。 + +## ⚙️ 构建/验证坑(本轮实证,直接复用) +- `-pl` 选精简 shade 模块用 **`:fe-connector-hms-hive-shade`**(冒号 artifactId 选择器),裸名 maven 报 "Could not find the selected project"。 +- 后台跑 maven **别** `nohup ... &` 套在 `run_in_background` Bash 里 → 脱离 harness 跟踪、误报秒完成;用 `tail --pid=` 阻塞等真 `BUILD SUCCESS/FAILURE`。 +- 依赖 shade 的模块**跑到 `package`**(`test-compile` 假报缺类);`-am` 必填;`-Dmaven.build.cache.enabled=false` 且数 `Running org.apache.doris` 行;`mvn|tail` 后 `$?` 是 tail 的(重定向到文件读 BUILD 行)。 +- maven 用绝对 `-f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml`。 +- `git add` 用 path-whitelist,**严禁 `-A`**(工作树大量非本线程 scratch)。 + +## ✅ 每个 Phase 收尾 +commit(英文)+ 覆盖本 HANDOFF + `progress.md` 追加一行 + 勾 `tasklist.md`。 diff --git a/plan-doc/fe-connector-hive-shade-localization/design.md b/plan-doc/fe-connector-hive-shade-localization/design.md new file mode 100644 index 00000000000000..b0cf84c53e42b9 --- /dev/null +++ b/plan-doc/fe-connector-hive-shade-localization/design.md @@ -0,0 +1,151 @@ +# 设计 — `fe/fe-connector` 剥离 `hive-catalog-shade`(迁自带精简局部 shade) + +> 稳定参考文档。状态/进度看 `tasklist.md` + `progress.md`;下一步看 `HANDOFF.md`。 +> 基线 2026-07-16,分支 `catalog-spi-11-hive`。所有 `file:line` 以 HEAD `grep` 为准。 + +--- + +## 1. 背景:`hive-catalog-shade` 是什么、为什么存在 + +`org.apache.doris:hive-catalog-shade`(源码库 `doris-shade`)是一个用 `maven-shade-plugin` 把整个 Hive 访问闭包 +(`hive-metastore:3.1.3` + `hive-serde` + `hive-exec:core` + `iceberg-hive-metastore:1.10.1` + `paimon-hive-connector:1.3.1` + DLF) +打成一个 jar 的胖包,核心动作是**把 `org.apache.thrift` 重定位到 `shade.doris.hive.org.apache.thrift`**。 + +- **为什么要重定位**:Doris 内部 thrift = **0.16.0**(`fe/pom.xml:295`),doris-gen 桩(`TFileScanRangeParams`/`TIcebergFileDesc` 等)按 0.16.0 编译;而 Hive 3.1.3 的 metastore 客户端桩按 **thrift ~0.9.x** 编译,二进制不兼容(`TFramedTransport` 换包到 `.transport.layered`、`TBase`/scheme 契约漂移)。同一个 `org.apache.thrift` 命名空间容不下两个版本 → 给 Hive 那套单独换私有包。 +- **为什么把 iceberg/paimon 的 hive-metastore 也打进去**:`maven-shade` 只改写它**打进 jar 的类**的字节码;iceberg 的 `HiveCatalog`、paimon 的 `HiveCatalog` 都经 `HiveMetaStoreClient` 访问 HMS、字节码带 `org.apache.thrift.*` 引用,必须一起打进来才会被统一重定位。 +- **为什么胖**:122 MB 里真正为它而存在的(Hive 客户端 4 MB + 重定位 thrift 0.5 MB)不到 1.6%;其余是 paimon-bundle(~94 MB,内部又重打包了 hadoop/guava/fastutil)、完整 hadoop(58 MB)、古董 fastutil 6.5.x(37 MB)、多平台原生库、datanucleus/derby 服务端等——大量**重复**和**无用**。 + +**这就是要脱离它的动机**:胖、有版本冲突隐患(fastutil 6.5.x child-first 遮蔽)、且插件化后一个插件被迫背另一个连接器的全套依赖。 + +--- + +## 2. 现状依赖图(2026-07-16 核实) + +`fe/fe-connector/` 下对 `hive-catalog-shade` 的**真实(非注释)直接依赖只有两个**: + +``` +hive-catalog-shade (org.apache.doris, 版本由 fe/pom.xml dependencyManagement 钉) + ▲ ▲ + │ 直接 (fe-connector-iceberg:93) │ 直接 (fe-connector-hms:43, compile 无 scope → 传递) + │ │ +fe-connector-iceberg fe-connector-hms ◄── 共享 plain-HMS 客户端模块 + (要 iceberg-hive-metastore) │ + │ compile 传递给 + ┌────────────────┼────────────────┐ + fe-connector-hive fe-connector-hudi fe-connector-iceberg + (都 depend fe-connector-hms) +``` + +- `fe-connector-hms/pom.xml:43` 直接依赖,**无 `` = compile = 传递**。它是被 hive / hudi / iceberg-on-HMS **共用**的 plain-HMS 客户端。 +- `fe-connector-iceberg/pom.xml:93` 另有一条**直接**依赖,用途是 `iceberg-hive-metastore`(iceberg→HMS 的胶水),外加它链接的重定位 thrift 基座。 +- 结论:**只迁 iceberg 只摘掉它自己那条直接依赖;hms(及经 hms 的 hive/hudi/iceberg)仍被 shade 牵着。要整个 fe/fe-connector 脱钩,必须先迁 `fe-connector-hms`。** + +repo 全局(HEAD)真实消费者共 4 个 + 版本钉:`fe-connector-hms`、`fe-connector-iceberg`、`be-java-extensions/avro-scanner`、`be-java-extensions/java-udf`、`fe/pom.xml`(钉)。**后三者不在本任务范围**。 + +--- + +## 3. 方案:镜像 `fe-connector-paimon-hive-shade` + +paimon 已经走通这条路(`fe/fe-connector/fe-connector-paimon-hive-shade/pom.xml`,324 行;设计 `plan-doc/fix-c-hms-thrift-design.md`):建一个插件私有 shade 模块,bundle metastore 客户端闭包 + `libthrift`,**重定位 `org.apache.thrift` 到插件私有前缀**(paimon 用 `org.apache.doris.paimon.shaded.thrift`),consumer 换依赖。要点原样照搬: + +- **只 shade metastore-client 闭包**,不 shade 连接器主模块(否则会把连接器里对 host 0.16.0 `TSerializer`/`TBase` 的调用也重定位,序列化 doris-gen 结构就断了)。 +- **libthrift 排除出 consumer 插件**(保持 `org.apache.thrift` 对 host 0.16.0 **parent-first**,doris-gen 路径不动),只在 shade 模块里 bundle + 重定位。 +- **artifactSet 排除** host/插件已提供的库:`org.apache.hadoop:*`、guava、protobuf、slf4j、log4j、commons-*、gson、jackson、caffeine(避免 child-first 重复类)。 +- **防御性重定位 fastutil**(`it.unimi.dsi.fastutil` → 私有前缀),躲开 6.5.x 遮蔽。 +- **`org.apache.paimon.* / org.apache.hadoop.*` 绝不重定位**(SPI 发现 + `HiveConf`/`Configuration` 类同一性)。 +- consumer 侧还要在 `plugin-zip.xml` 保留对 `org.apache.thrift:libthrift` 和 `org.apache.doris:fe-thrift` 的 exclude。 + +目标 jar 体积预估 ≈ **13–15 MB**(对比 122 MB)。**Phase 0 核实的精确 bundle 清单**(`javap` + `jar tf` + 核查 agent 交叉验证,2026-07-16): + +| bundle 进 shade(重定位 thrift) | 版本 | 谁需要 | 为什么 | +|---|---|---|---| +| `org.apache.hive:hive-standalone-metastore` | **3.1.3** | 全部 | 提供 `org.apache.hadoop.hive.metastore.api.*`(`ThriftHiveMetastore` + ~200 结构体)+ `IMetaStoreClient`/`MetastoreConf`/`MetaStoreUtils`/`HadoopThriftAuthBridge`/`txn.TxnUtils`。**⚠️ 是 standalone-metastore(真实现),不是 `hive-metastore:3.1.3`(13 类空壳)** | +| `org.apache.thrift:libthrift` | **0.9.3**(内联钉,非 managed 0.16.0) | 全部 | 补丁客户端直接 import 的 9 个 thrift 类;重定位到 `shade.doris.hive.org.apache.thrift` | +| `org.apache.thrift:libfb303` | **0.9.3** | 全部 | `ThriftHiveMetastore.{Iface,Client}` 继承 `com.facebook.fb303.FacebookService`,不链接则生成客户端 link 失败 | +| `org.apache.hive:hive-common` | **3.1.3** | 全部 | `org.apache.hadoop.hive.conf.HiveConf`(`HmsConfHelper.createHiveConf` / iceberg `IcebergCatalogFactory` 用);带 `hive-shims` | +| `org.apache.hive:hive-storage-api` | 2.7.0 | 全部 | `ValidWriteIdList` 等(hms txn / write-ACID 路径),hive-common/standalone-metastore 都不含 | +| `org.apache.hive:hive-serde` | **3.1.3** | 仅 iceberg | iceberg `HiveSchemaUtil` 建表/提交时用 `serde2.typeinfo.*`/`objectinspector.*`(118 处 byte-ref) | +| `org.apache.iceberg:iceberg-hive-metastore` | 1.10.1 | 仅 iceberg | `org.apache.iceberg.hive.HiveCatalog`(hms flavor);排除 iceberg-core(插件已直接带 1.10.1) | + +**关键 exclude**:`org.apache.paimon:*`(自有 shade)、完整 `org.apache.hadoop:*`(插件自带 child-first)、`it.unimi.dsi:fastutil`(防御性重定位而非 bundle)、`org.apache.hive:hive-exec`(见下)、`com.aliyun:*`/DLF、derby/datanucleus/bonecp/HikariCP/orc、bouncycastle、jersey/jaxb、arrow/parquet/avro(hive-serde 重传递)、guava/protobuf/jackson/slf4j/log4j/commons(host parent-first)、iceberg-core/api/caffeine(插件已带)。 + +**两处原设计假设已纠正**: +1. **hive-exec 不进插件**。connector 源码里 `org.apache.hadoop.hive.ql.*` 全是**字符串常量**(ORC/Parquet 格式类名写进 HMS `StorageDescriptor`,FE 从不加载该类;BE 原生读)。`HmsWriteConverter:270-279`、`HiveScanPlanProvider:92`、`HiveConnectorMetadata:139/141`、`HiveTableFormatDetector:43/44` 均为字符串,零 `import`。commit `e7eae85faa4` 的 host `hive-exec:core` 是 CREATE FUNCTION 的 `hive.ql.exec.UDF` 契约,主机侧独立事,与插件无关。 +2. **DLF 已是死代码**,直接不装。`iceberg.catalog.type=dlf` / `metastore.type=dlf` 已在 `IcebergCatalogFactory.resolveCatalogImpl` + `HmsClientConfig.REMOVED_METASTORE_TYPES` 移除并有守卫测试拦截。⚠️ `fe-connector-iceberg/pom.xml:138-145` 那段"支持 DLF / port-now"注释**过期**,动码时顺手订正(别让它误导 shade 的 artifactSet 去保留 DLF)。 + +--- + +## 4. 设计决策 —— **已定案(Phase 0,2026-07-16,用户签字)** + +> **决策速查**(下方各 D 的详细背景保留;此处为最终结论): +> - **D1 = A(共享一个 shade)** ✅ 用户签字。iceberg `HiveCatalog` 按类名建 `HiveMetaStoreClient` → 命中补丁客户端 → 二者须共用同一份重定位 thrift + 元数据 API 类身份 → 必须一个共享 shade。代价:hive/hudi 多背 ~1.1MB 永不加载的 iceberg 类(非泄漏,仅字节)。 +> - **D2 = 3.1.3** ✅ 用户签字。保持与全局 shade 行为一致。 +> - **D3 = 复用 `shade.doris.hive.org.apache.thrift`** ✅ 用户签字。补丁客户端 + `ThriftHmsClient` 本就 import 此前缀 → **零源码改动**;过渡期两包同名同版本(libthrift 0.9.3)字节一致、谁生效都不撕裂(新前缀反而会在过渡期让新旧两份 `HiveCatalog` 绑不同前缀 → 旧份被优先加载即 ClassCastException)。 +> - **D4 = KEEP_IN_HMS**(`javap` 定案)。补丁客户端字节码对 thrift 的全部 48 处引用**已全部是重定位名** `shade.doris.hive.org.apache.thrift.*`,零 raw、零按名反射。故它留在 `fe-connector-hms`、不搬进 shade、不改写,只需精简 shade 在其 classpath 上复现同名 thrift 即可。 +> +> **配套计划微调(红队条件 1)**:iceberg **摘全局 shade** 与 **接精简 shade** 须放**同一次提交(原子切换)**,消除"过渡期两份 `HiveCatalog` 并存"的不确定(实测全局 37821B vs 精简 1.10.1 37853B,谁生效随 classpath 序 → 顺带把 iceberg.hive 对齐插件自带 iceberg-core 1.10.1)。 +> **验证条件(红队条件 2)**:Phase 1/4 须跑重部署类加载冒烟(每插件 `metastore.api.Table` 与 `shade.doris.hive.org.apache.thrift.TException` **各仅一份**)+ hive/hudi/iceberg-on-HMS e2e + Kerberos/filter-hook 按名反射路径(D5)。 + +### 4.x 决策原始背景(存档,勿据此再议——结论以上方速查为准) + +### D1 — 一个共享 shade 还是两个独立 shade? +- **选项 A(推荐,共享)**:一个 `fe-connector-hms-hive-shade`,bundle `hive-metastore` + `hive-serde` + `hive-common` + `libthrift`(重定位)**外加 `iceberg-hive-metastore`**。`fe-connector-hms` 和 `fe-connector-iceberg` 都依赖它。iceberg-hive-metastore 随包搭车 → hive/hudi 多背 ~1 MB 用不到的 iceberg 类,但**只需一个 thrift 私有命名空间**、最省事、就是「全局 shade 去肥」版。 +- **选项 B(分开)**:`fe-connector-hms-hive-shade`(客户端+thrift,hive/hudi/iceberg 共用)+ iceberg 的 iceberg-hive-metastore 单独 shade。**难点**:iceberg-hive-metastore 的 `HiveCatalog` 必须和客户端用**同一份**重定位 thrift,而 `libthrift` 不能在两个 shade 各 bundle 一份(child-first 重复类)。要么 iceberg-hive-metastore 进同一个 jar,要么引用 hms shade 的重定位 thrift 而不重打包 → 复杂。 +- **建议**:选 A。Phase 0 交用户签字。 + +### D2 — bundle 的 hive-metastore 版本? +全局 shade 用 **3.1.3**;paimon 局部 shade 用 **2.3.7**。plain-HMS 插件当前经全局 shade 跑的是 **3.1.3**。 +**默认取 3.1.3 以保持行为不变**(尤其 vendored 补丁客户端 + `HiveVersionUtil` 对 Hive 版本敏感——见 D4)。Phase 0 确认 vendored 客户端所依赖的版本后签字。 + +### D3 — thrift 重定位前缀 +新 hms 私有前缀 **`org.apache.doris.hms.shaded.thrift`**(区别于 paimon 的 `org.apache.doris.paimon.shaded.thrift`、全局的 `shade.doris.hive.org.apache.thrift`,三者永不撞)。 + +### D4 — 🔴 vendored 补丁 `HiveMetaStoreClient.java`(**本任务最大未知**) +`fe/fe-connector/fe-connector-hms/src/main/java/org/apache/hadoop/hive/metastore/HiveMetaStoreClient.java` 是 **Doris 源码写的、版本感知的**补丁客户端(`import org.apache.doris.datasource.hive.HiveVersionUtil`),当前靠 jar 排序 overlay/shadow 掉 shade 里那份未打补丁的同名类(iceberg pom 注释:修 Hive-3 `@cat#` db 标记对 Hive-1/2 metastore 的兼容)。 + +- **问题**:它编译在 fe-connector-hms 里(**不会被 shade 重定位**)。若它的字节码引用 `org.apache.thrift.transport.*` 等被重定位掉的类,重定位后:shade 里的基类引用 `org.apache.doris.hms.shaded.thrift.*`,而这个补丁子类仍引用原包 `org.apache.thrift.*` → **命名空间撕裂**。paimon **没有**这个问题(它的客户端全来自 SDK jar,可整体 shade)。 +- **Phase 0(FCL-02)必须**:`javap` 出 vendored 客户端对 `org.apache.thrift.*` 的全部引用面;据此决定: + - 若它只碰高层 API(不碰 transport/被搬走的类)→ 可能可保留在原包; + - 若它碰被重定位的类 → 需把它的**源码也纳入 shade 模块**(在 shade 里编译再重定位),或改写它避免直接触 thrift transport。 +- **这是 Phase 0 的头号交付物**,方案定不下来别进 Phase 1。 + +### D5 — TCCL classloader pin(**别回归**) +plain-hive HMS 客户端创建须钉插件 classloader:`ThriftHmsClient.doAs` 钉 plugin loader(非 `getSystemClassLoader()`)、`HmsConfHelper.createHiveConf` 须 `setClassLoader(插件loader)`(否则 `loadFilterHooks` 经 conf 缓存 CL 反射 `MetaStoreFilterHook` split-brain)。 +参考 memory `catalog-spi-plugin-tccl-classloader-gotcha`(TeamCity #991951,commit `92004ef1d0d`;`test_string_dict_filter` 2026-07-11 踩坑)。**重定位不能破坏这套按名反射**——Phase 4 e2e 必须覆盖 filter hook / string dict filter / kerberos 路径。 + +### D6 — 其它库共存 +mirror paimon 的 artifactSet 排除(hadoop/guava/slf4j/log4j/commons/caffeine),并核对打包后插件 zip 无重复 `HiveConf`/`libthrift`/`hive-metastore`。 + +--- + +## 5. 风险清单 + +| # | 风险 | 触发 | 缓解 | +|---|---|---|---| +| R1 | vendored 补丁客户端命名空间撕裂(D4) | 它引用被重定位的 thrift 类 | Phase 0 先 `javap` 定面;必要时把它纳入 shade 编译 | +| R2 | TCCL 反射回归(D5) | 重定位后 filter hook / doAs 按名反射失效 | e2e 覆盖 string dict filter / filter hook / kerberos | +| R3 | iceberg-on-HMS 与 hms 客户端 thrift 命名空间不一致 | 分开 shade 各 bundle thrift | 选 D1-A(共享一份重定位 thrift) | +| R4 | 打包出现两份 `paimon-hive`/`hive-metastore`/`libthrift`(child-first 重复类) | consumer 同时保留旧 raw 依赖 | 照 paimon:raw 依赖 `true`,plugin-zip exclude,打包后 `unzip -l` 断言唯一 | +| R5 | hive/hudi 静默受影响(它们经 hms 传递) | 只测 hms、iceberg 漏测 hive/hudi | Phase 1 gate 必须连 hive+hudi build+UT;Phase 4 e2e 三连 | +| R6 | 行为变更(Caffeine/hive 版本漂移) | 换版本或换传递依赖 | 默认锁 3.1.3;FE 启动 + 缓存冒烟(FCL-41) | + +--- + +## 6. 铁律(继承主线,勿违) + +1. **fe-core / fe-connector 源码只出不进**:不为「编译过」把逻辑挪进 fe-core util;shade 模块只装第三方 jar + (必要时)vendored 客户端源码。 +2. **禁 deletion-scaffolding 式搬迁**:遇到「删依赖导致编译不过」,停手重分析真实归属,别就近搬。 +3. **commit / PR 文案全英文**(上游社区看);plan-doc / 本空间中文。 +4. **每完成一个 Phase 就更新 HANDOFF + commit**(不只阶段边界)。 +5. **大改动用 clean-room 对抗 review**(多 agent 先独立判断、后交叉核对历史结论)。 +6. **iceberg-on-HMS 新能力必须配 e2e**(异构 HMS 目录跑 INSERT/DELETE/MERGE/read 断言与独立 iceberg 目录同结果)。 + +--- + +## 7. 参考 + +- 范式模块:`fe/fe-connector/fe-connector-paimon-hive-shade/pom.xml` +- 范式设计:`plan-doc/fix-c-hms-thrift-design.md` +- 兄弟任务:`plan-doc/hive-catalog-shade-removal/`(fe-core 版,已完成阶段 1–5) +- shade 机制/体积拆解:本 session 分析(见 `progress.md` 开篇「起源」) +- 相关 memory:`catalog-spi-plugin-tccl-classloader-gotcha`、`fe-core-source-isolation-iron-rules`、`catalog-spi-connector-cache-framework-caffeine-coherence`、`hms-iceberg-delegation-needs-e2e` diff --git a/plan-doc/fe-connector-hive-shade-localization/progress.md b/plan-doc/fe-connector-hive-shade-localization/progress.md new file mode 100644 index 00000000000000..d13fc3aeae2ca9 --- /dev/null +++ b/plan-doc/fe-connector-hive-shade-localization/progress.md @@ -0,0 +1,93 @@ +# 进度记录 — `fe/fe-connector` 剥离 `hive-catalog-shade` + +> **append-only**,只追加不覆盖。每条:日期 · 谁/什么 session · 结论 · 证据(file:line/命令) · 坑。 +> 状态清单看 `tasklist.md`;下一步看 `HANDOFF.md`。 + +--- + +## 2026-07-16 · 建档 session(调研,**代码零改动**) + +### 起源 +用户先做了一轮 `hive-catalog-shade` 调研(为什么包里有 iceberg/paimon 的 hive-metastore、fe-connector 下是否还需要、StarRocks 怎么不用 shade),据此提出**中期迁移**:把 iceberg 照 paimon 模式迁自带精简局部 shade。追问「只迁 iceberg 是不是整个 fe/fe-connector 就不依赖 hive-catalog-shade 了」→ 核实后**答案是否**,遂建本任务空间。 + +### 依赖图核实(本 session 实测,2026-07-16 `catalog-spi-11-hive`) +- **`fe/fe-connector/` 真实(非注释)直接消费者只有 2 个**:`fe-connector-hms/pom.xml:43`(**无 scope = compile = 传递**)、`fe-connector-iceberg/pom.xml:93`。 + - 验证法:`perl -0777 -pe 's///gs' | grep -c 'hive-catalog-shade'`(剥注释再数)。 +- `fe-connector-hive` / `fe-connector-hudi` / `fe-connector-iceberg` **都 depend `fe-connector-hms`** → 经它传递拿 shade。 +- **repo 全局真实消费者 = 4 + 版本钉**:`fe-connector-hms`、`fe-connector-iceberg`、`be-java-extensions/avro-scanner`、`be-java-extensions/java-udf`、`fe/pom.xml`。**与兄弟任务 `hive-catalog-shade-removal/` HANDOFF 的声明一致。** 后三者不在本任务范围。 +- ⇒ **结论:承重墙是 `fe-connector-hms`。只迁 iceberg 摘不掉。** + +### 关键难点(已识别,未解) +- `fe-connector-hms` 有 vendored 补丁 `src/main/java/org/apache/hadoop/hive/metastore/HiveMetaStoreClient.java`,`import org.apache.doris.datasource.hive.HiveVersionUtil`(**版本感知**),当前靠 jar 排序 overlay 掉 shade 里未打补丁的同名类。重定位后可能命名空间撕裂——**paimon 无此问题**(客户端全来自 SDK jar)。列为 **FCL-02 头号交付物 / D4**。 + +### shade 机制与体积(本 session 实证,供设计参考) +- 重定位:`doris-shade/hive-catalog-shade/pom.xml:627` `org.apache.thrift`→`shade.doris.hive.org.apache.thrift`。Doris 内部 thrift 0.16.0(`fe/pom.xml:295`),Hive 3.1.3 客户端桩按 ~0.9.x,二进制不兼容(`TFramedTransport` 换包 `.transport.layered`、`TBase` 契约漂移)。 +- **实测部署 jar** `output/fe/plugins/connector/iceberg/lib/hive-catalog-shade-3.1.1.jar` = **122 MB 压缩 / 300 MB 解压 / 70,030 文件**。占用排行(解压):paimon-bundle 内部再打包依赖 72.8MB(25%)、完整 hadoop 58.7MB(20%)、fastutil 6.5.x 36.8MB(13%)、多平台原生库 18.6MB(6%)、iceberg 11MB(4%)、datanucleus+derby ~17MB、DLF 7.7MB……**而真正为它存在的 `org.apache.hive` 客户端仅 4MB(1.4%)、重定位 thrift 仅 0.5MB(0.2%)**。⇒ 迁精简 shade 预估目标 **15–25 MB**。 +- 对照 StarRocks:不自维护 shade,用 `io.trino.hive:hive-apache:3.1.2-22`(33MB),单一 libthrift 0.23.0;生成桩只用 thrift「生成代码契约」(跨版本二进制稳定,`readStructBegin` 等 0.14+ 上移到 `TReadProtocol/TWriteProtocol` 接口保签名——本 session 用 classload+link 实测通过);唯一换包的 `TFramedTransport` 落在手写 transport 层、默认死代码。**「整体换 hive-apache」是可选的更激进终局,本任务不含**(只做「精简局部 shade」中期方案)。 + +### 建档产出 +- 新任务空间 `plan-doc/fe-connector-hive-shade-localization/`:README + design(D1–D6 + 风险 R1–R6)+ tasklist(FCL-01~50,Phase 0–5)+ HANDOFF + 本文件。 +- **下一步**:Phase 0 从 FCL-02 起(见 HANDOFF)。 + +### 坑/提醒(留给下一个 session) +- 剥 XML 注释再判依赖,否则被大量注释里的 `hive-catalog-shade` 字样骗(本 session 第一次 grep 就中招)。 +- 兄弟任务 `hive-catalog-shade-removal/` 已把 shade 从 fe-core/fe-common 摘掉(阶段 1–5),**别重做**;它明确保留 hms/iceberg 两个 fe-connector 消费者——那正是本任务的对象。 + +--- + +## 2026-07-16 · Phase 0 完成(侦察+设计定案+用户签字,**代码零改动**;分支 `catalog-spi-hive-shade-12`) + +### 头号未知 D4 定案(`javap` 字节码实证) +- `javap -v -p fe-connector-hms/target/classes/.../HiveMetaStoreClient.class`:对 thrift 全部 **48** 处引用(7 base + 5 protocol + 36 transport)**全部是重定位名** `shade/doris/hive/org/apache/thrift/*`,**零 raw `org.apache.thrift`、零按名反射**。⇒ 补丁客户端**留在 fe-connector-hms、不搬进 shade、不改写**,只需精简 shade 复现同名 thrift。设计原担心的"命名空间撕裂"不成立。 +- 全仓库仅剩**一份** `HiveMetaStoreClient.java`(在 fe-connector-hms);文件头"be-java-ext/fe-core 有副本"注释**已过期**(那些副本已随迁移删除)。fe-core 已零 hive-catalog-shade 依赖(兄弟任务确认)。 + +### 精确 bundle 清单(核查 agent + `jar tf` 交叉验证) +- **装**:`hive-standalone-metastore:3.1.3`(**非** stub `hive-metastore:3.1.3`)、`libthrift:0.9.3`+`libfb303:0.9.3`(重定位)、`hive-common:3.1.3`、`hive-storage-api:2.7.0`、`hive-serde:3.1.3`(iceberg)、`iceberg-hive-metastore:1.10.1`(iceberg)。体积 122MB→**~13-15MB**。清单入 design.md §3。 +- **hive-exec 不进插件**:`hive.ql.*` 全是字符串常量(格式类名写进 HMS SD),零 import;host `hive-exec:core`(e7eae85) 是 CREATE FUNCTION 独立事。 +- **DLF 已死代码**:iceberg dlf flavor 已移除+守卫测试拦截;iceberg pom:138-145 注释过期待订正。standalone 制品存在(`com.aliyun.datalake:metastore-client-hive3:0.2.14` 在 ~/.m2)但用不到。 + +### 用户签字(D1/D2/D3) +- **D1=A 共享一个** `fe-connector-hms-hive-shade`;**D2=3.1.3**;**D3=复用 `shade.doris.hive.org.apache.thrift`**(零源码改动)。红队 GO + 两条件:① iceberg 摘全局 shade 与接精简 shade **原子同 commit**(消除过渡期两份 HiveCatalog 并存不确定,实测 37821B vs 37853B);② Phase 1/4 跑类加载冒烟(每插件 `metastore.api.Table`/`TException` 各一份)+ HMS e2e + Kerberos/filter-hook 路径。 + +### 证据/命令 +- `javap -v -p .../HiveMetaStoreClient.class | grep thrift`;`jar tf hive-catalog-shade-3.1.1.jar`(roots: paimon 18912 / hadoop 12474 / fastutil 10653 / iceberg 2972 / aliyun-datalake 2266 / hive-ql 6115 / shade-thrift 225);验证 workflow `.claude/wf-fcl-phase0-verify.js`(4 agent,349k tok)。 + +### 下一步 +- **Phase 1**:建 `fe-connector-hms-hive-shade` 模块(镜像 paimon-hive-shade),wire fe-connector-hms,gate 连 hive+hudi build+UT。见 HANDOFF。 + +### 坑/提醒 +- 精简 shade 必装 `hive-**standalone**-metastore`(`hive-metastore:3.1.3` 是 13 类空壳,装错=整个 metastore api NoClassDefFound)。 +- libthrift 必须**内联钉 0.9.3**(managed 默认 0.16.0 是 host doris-gen 路径,别串)。 + +--- + +## 2026-07-16 · Phase 1+2+3 完成(建精简 shade 模块 + 切换 hms/iceberg + 静态&打包闸门,分支 `catalog-spi-hive-shade-12`) + +### 本轮做了什么 +把 `fe/fe-connector/` 对 122MB 胖 shade 的依赖,换成一个自建的 **15MB 精简 shade 模块** `fe-connector-hms-hive-shade`(只装 Hive 元数据**客户端**闭包)。 + +- **新建** `fe/fe-connector/fe-connector-hms-hive-shade/pom.xml`:bundle `hive-standalone-metastore:3.1.3`(元数据 api+客户端) + `hive-common:3.1.3`(HiveConf) + `hive-serde:3.1.3` + `hive-storage-api:2.7.0` + `iceberg-hive-metastore:1.10.1`(HiveCatalog) + `libthrift/libfb303:0.9.3` + `jackson-mapper/core-asl:1.9.2`(HMS 事件解析用的老 Jackson)+ `commons-lang:2.6`(HiveConf 用)。重定位 `org.apache.thrift`→`shade.doris.hive.org.apache.thrift`(**沿用**旧前缀,零源码改动)+ 防御性重定位 fastutil。 +- **共享库改依赖** `fe-connector-hms/pom.xml`:删胖 shade、加精简 shade(compile 传递 → hive/hudi/iceberg 经它拿到)。补丁客户端 `HiveMetaStoreClient.java` **原地不动**(字节码已全是重定位名)。 +- **iceberg** `fe-connector-iceberg/pom.xml`:删掉它自挂的那条胖 shade 直接依赖(改经 hms 传递拿精简 shade),并补 `iceberg-bundled-guava`(compile,供 vendored DeleteFileIndex 编译)、订正过期注释。 +- 与 hms 切换**放在同一次原子提交**(用户拍板),消除 iceberg 过渡期两份 HiveCatalog 并存的红队隐患。 + +### 关键决策落地方式(与 tasklist 里旧措辞的差异,以此处为准) +- 重定位前缀用 **`shade.doris.hive.org.apache.thrift`**(非 tasklist FCL-10 旧写的 `org.apache.doris.hms.shaded.thrift`)—— 对齐 design §4 已签字的 D3。 +- 版本**在 shade 模块内联钉**(未动 fe/pom.xml dependencyManagement),libthrift 0.9.3 直接声明胜出 managed 0.16.0。 +- artifactSet 用 **白名单 ``**(不是黑名单 excludes):胖 shade 是"厨房水槽"式全打包(122MB 大量 hadoop-yarn/curator/jersey/jetty/kerby/sqlserver junk 由 hive-shims-0.23 拖入),精简版只白名单 hive 客户端闭包,其余运行时由各插件自带的 hadoop-common 闭包/宿主 parent-first 提供。 + +### 闸门证据(本轮实测) +- **UT 全绿**:hms/hive/hudi/iceberg build+UT(`-am`,build-cache off,跑到 package)全 SUCCESS,197 个测试类,0 fail/error,checkstyle 0。 +- **两处运行时缺类由闸门抓出并补齐**(白名单方法的预期迭代):① `HmsEventParser` 静态 `JSONMessageDeserializer` 需老 Jackson `org.codehaus.jackson.map.ObjectMapper`(且 fe/pom.xml 把 jackson-mapper-asl 钉成 test scope,须显式 `compile` 覆盖才会被 shade);② iceberg vendored `DeleteFileIndex` 编译需 `iceberg-bundled-guava`(iceberg-core 只 runtime 带,编译期缺)。 +- **静态闸门 FCL-30**:全部 19 个 fe-connector 模块 `dependency:tree -Dincludes=hive-catalog-shade` 全空。 +- **打包闸门 FCL-31**:hive/hudi/iceberg 三个插件 zip 内——无胖 shade jar、精简 shade jar 各 1 份、无原包 libthrift/fe-thrift;`metastore.api.Table` / `HiveConf` / `iceberg.hive.HiveCatalog` / 重定位 `TException` / 老 Jackson `ObjectMapper` **各仅 1 份**;`HiveMetaStoreClient` 2 份(补丁 `fe-connector-hms-*.jar` + 精简 shade 未补丁,前者字典序在前→补丁生效,与胖 shade 时代同机制)。插件 zip 体积 hive 53M / iceberg 94M / hudi 200M(各比胖 shade 时代少约 107M)。 +- **多 agent 对抗 review(clean-room)**:4 lens × 逐条 adversarial 复核,**零 confirmed/blocker**。要点:精简 shade 里 metastore + iceberg.hive 字节码与胖 shade **md5 逐类相同**;重定位完整(loaded path 上零原包 thrift);`hive-storage-api 2.7.0` 是 hive-3.1.3 **原生**版本(胖 shade 的 2.8.1 是被 orc/hive-exec 上抬的、精简版已排除二者),非回退;`serde2.dynamic_type` 两个类残留原包 thrift 引用是**死路径+与胖 shade 逐字节相同**,非本次引入。 + +### 下一步 +- **Phase 4 e2e(唯一真闸门)**:docker 异构 HMS 跑 hive 读写 / iceberg-on-HMS INSERT/DELETE/MERGE(断言与独立 iceberg 目录同结果) / hudi-on-HMS 读;TCCL 不回归(filter hook / string dict filter / kerberos);FE 启动+缓存冒烟;顺带专项验证 storage-api 2.7.0 的 write/ACID 路径。 +- Phase 5:结项 + PR(引用 tracking issue `apache/doris#65185`)。 + +### 坑/提醒(留给下一个 session) +- 白名单 shade 的运行时缺类只有跑到才暴露:**hms UT 抓 Jackson、iceberg 编译抓 bundled-guava** 都是这轮现补的;e2e 可能再暴露 kerberos/filter-hook 路径的缺类,按同法(查缺 → 加 include/显式 dep)补即可,别退回黑名单。 +- `-pl` 选精简 shade 模块要用 **`:fe-connector-hms-hive-shade`**(冒号 artifactId 选择器),裸名 maven 找不到。 +- 后台跑 maven **别** `nohup ... &` 套在 `run_in_background` 里(会脱离 harness 跟踪,误报"完成");本轮踩过,改用 `tail --pid` 阻塞等真结果。 diff --git a/plan-doc/fe-connector-hive-shade-localization/tasklist.md b/plan-doc/fe-connector-hive-shade-localization/tasklist.md new file mode 100644 index 00000000000000..c5e06d7e4cfdb1 --- /dev/null +++ b/plan-doc/fe-connector-hive-shade-localization/tasklist.md @@ -0,0 +1,78 @@ +# Task list — `fe/fe-connector` 剥离 `hive-catalog-shade` + +> 唯一进度清单。每完成一项随 commit 勾 `[x]` 并在 `progress.md` 追加一行。 +> 设计/为什么看 [`design.md`](./design.md)(决策 D1–D6);下一步看 [`HANDOFF.md`](./HANDOFF.md)。 +> 基线 2026-07-16 `catalog-spi-11-hive`。**行号信 HEAD 不信文档**。 + +**总成功判据**:对 `fe/fe-connector/` 每个 pom,`dependency:tree -Dincludes=org.apache.doris:hive-catalog-shade` 全空。 +(`fe/pom.xml`、avro-scanner、java-udf 仍命中 = 预期,不算破。) + +--- + +## Phase 0 — 侦察 + 设计定案(**动码前必须做完 + 用户签字 D1/D2**) + +- [x] **FCL-01** 枚举完成:补丁客户端 + `ThriftHmsClient` 用重定位 thrift(`shade.doris.hive.*`);`HiveConnectorTransaction`/`*SchemaUtils` 用 host raw thrift 0.16.0(**不受迁移影响**)。清单入 progress。 +- [x] **FCL-02** 🔴 **定案**:`javap` 出补丁客户端字节码对 thrift 48 处引用**全是重定位名**、零 raw、零按名反射 → **D4=KEEP_IN_HMS**(不搬不改写)。 +- [x] **FCL-03** 版本确认:`hive.version=3.1.3`(fe/pom.xml:341);补丁客户端头"Copied From release-3.1.3" → **D2=3.1.3**。 +- [x] **FCL-04** **D1=A 共享 + D3=复用 `shade.doris.hive.org.apache.thrift`**(非原设计的新前缀),中文向用户解释并**已签字**。 +- [x] **FCL-05** 确认:iceberg 需 `iceberg-hive-metastore`(HiveCatalog)**与** hms 客户端共用同一重定位 thrift(支撑 D1-A);DLF 已死代码不计。 +- [x] **FCL-06** design.md §3 补精确 bundle 清单、§4 决策定案留痕。 + +**Phase 0 出口**:✅ design.md 定案 + 用户签 D1/D2/D3 + FCL-02 定 D4=KEEP_IN_HMS。**红队 GO**(两条件:iceberg 原子切换 + Phase1/4 类加载冒烟&e2e)。 + +--- + +## Phase 1 — `fe-connector-hms` 局部 shade(承重墙) + +- [x] **FCL-10** 新建模块 `fe/fe-connector/fe-connector-hms-hive-shade/pom.xml`(镜像 paimon-hive-shade):bundle `hive-metastore`(D2 版本) + `hive-serde` + `hive-common` + `libthrift`;重定位 `org.apache.thrift`→`org.apache.doris.hms.shaded.thrift`;防御性重定位 `it.unimi.dsi.fastutil`;artifactSet 排除 hadoop/guava/protobuf/slf4j/log4j/commons-*/gson/jackson/caffeine;META-INF SF/DSA/RSA/maven 过滤。(D1-A 则此处也 bundle `iceberg-hive-metastore`——见 FCL-20。) +- [x] **FCL-11** `fe/fe-connector/pom.xml` `` 注册**在 `fe-connector-hms` 之前**;`fe/pom.xml` dependencyManagement 补 `libthrift`(0.9.x?按 D2 版本对应) + `hive-metastore`(D2) 版本钉(或 shade 模块内联 pin)。 +- [x] **FCL-12** 🔴 落地 D4:按 FCL-02 结论处理 vendored `HiveMetaStoreClient.java`(纳入 shade 编译 / 保留 / 改写),确保它与重定位后的基类命名空间一致。 +- [x] **FCL-13** 改 `fe-connector-hms/pom.xml`:删 `hive-catalog-shade` 依赖,加 `fe-connector-hms-hive-shade`。核对 raw hive-metastore/thrift 若有残留改 ``/exclude;plugin-zip 保留 `libthrift`/`fe-thrift` exclude。 +- [x] **FCL-14** Gate:`fe-connector-hms` UT(`-am`,`-Dmaven.build.cache.enabled=false`,跑到 `package`);连带 `fe-connector-hive` + `fe-connector-hudi` build + UT(它们经 hms 传递)。全绿 + checkstyle 0。 +- [x] **FCL-15** commit(英文)+ 更新 HANDOFF + progress 追加。 + +--- + +## Phase 2 — `fe-connector-iceberg` 迁移 + +- [x] **FCL-20** iceberg-hive-metastore 落位:D1-A 则已在 FCL-10 的共享 shade 里(本步仅核对);D1-B 则单独 shade 且与 hms 共用同一重定位 thrift 命名空间。 +- [x] **FCL-21** 改 `fe-connector-iceberg/pom.xml`:删 `hive-catalog-shade`(第 93 行那条)直接依赖,改依赖共享/iceberg shade。核对 `fe-connector-hms`(它已带客户端)+ 新 shade 无重复类;更新 plugin-zip exclude。 +- [x] **FCL-22** Gate:`fe-connector-iceberg` UT(含 `assembleHiveConf` parity 测试等,`-am`,build-cache off,跑到 `package`);checkstyle 0。 +- [x] **FCL-23** commit(英文)+ 更新 HANDOFF + progress 追加。 + +--- + +## Phase 3 — 证明 `fe/fe-connector` 已脱钩(静态闸门) + +- [x] **FCL-30** 对 `fe/fe-connector/` **每个** pom 跑 `dependency:tree -Dincludes=org.apache.doris:hive-catalog-shade` → **全空**(这是总判据)。命中的只应剩 avro-scanner/java-udf/fe-pom(不在范围)。 +- [x] **FCL-31** `unzip -l` 三个插件 zip(hive/iceberg/hudi):断言① 无 `hive-catalog-shade-*.jar`;② 无原包 `org/apache/thrift/`(除 host provide);③ 有 `org/apache/doris/hms/shaded/thrift/`;④ 无重复 `hive-metastore`/`libthrift`/`HiveConf`;⑤ 记录 zip 体积删前/删后差。 +- [x] **FCL-32** 确认 `fe/pom.xml` 的 hive-catalog-shade 版本钉**仍在**(avro-scanner/java-udf 还要),并加注释说明为何保留。 + +--- + +## Phase 4 — e2e(异构 HMS,唯一真闸门;memory `hms-iceberg-delegation-needs-e2e`) + +- [ ] **FCL-40** docker 外表 HMS 套件:① 普通 hive catalog 读+写;② **iceberg-on-HMS** INSERT/DELETE/MERGE/read,断言与独立 iceberg 目录同表同结果;③ hudi-on-HMS 读。 +- [ ] **FCL-41** 🔴 TCCL 不回归(D5/R2):`test_string_dict_filter` 类用例 + MetaStoreFilterHook 路径 + kerberos HMS(若环境有);FE 启动 + 缓存(MetaCache/StatisticsCache/FileSystemCache)冒烟。 +- [ ] **FCL-42** progress 追加 e2e 结果(含 CI 编号)。 + +--- + +## Phase 5 — 收尾 + +- [ ] **FCL-50** `progress.md` 结项段;`../decisions-log.md` 补 `D-NNN`(本任务的 D1–D6 定案);PR(base `branch-catalog-spi`,squash,英文)。引用 tracking issue `apache/doris#65185`。 + +--- + +## 🧰 构建/验证坑(兄弟任务实测,直接复用,别再踩) + +1. **maven build cache 会静默跳过 surefire**(`Skipping plugin execution (cached): surefire:test`,BUILD SUCCESS 但 0 测试)→ **必须 `-Dmaven.build.cache.enabled=false`**,并数 `grep -c "^\[INFO\] Running org.apache.doris"`。 +2. **`-am` 必填**:漏了报 `org.apache.doris:fe:pom:${revision}` 无法解析 = **没编译,不是代码错**。 +3. **依赖 shade 模块的模块必须跑到 `package`**:`test-compile`/`test` 会假报 `package org.apache.hadoop.hive.conf does not exist`(不是代码错);`install` 不行(`fe-type` quirk)。 +4. **`surefire:test` 独立 goal 解析不了 `${revision}`** → 走 `test` 生命周期 + `-am`;无匹配测试加 `-DfailIfNoTests=false`。 +5. **连接器模块路径嵌套**:`-pl fe/fe-connector/fe-connector-XXX` 用相对 reactor 名 `-pl fe-connector-XXX`(在 fe/pom.xml reactor 内);maven 必须**绝对 `-f`**:`mvn -o -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml ...`。 +6. **`hive-serde` 闭包首次需联网**(`javax.servlet:servlet-api:2.4` 不在本地仓),`-o` 会失败——首次去 `-o`。 +7. **`mvn ... | tail` 后 `$?` 是 tail 的**:重定向到文件再读 `BUILD SUCCESS/FAILURE` 行。 +8. **变异验证要红在断言上**(不是 checkstyle/编译),变异代码也须合 checkstyle。 +9. **`git add` 用 path-whitelist,严禁 `git add -A`**(工作树有大量非本线程 scratch,含 `regression-conf.groovy` 本就脏)。commit 后看 `git show --stat` 文件数。 +10. **动码前先探并发**(`git log`/`status` + `pgrep maven` 看 `etime` 别误判僵尸 + 近 90s mtime)。 diff --git a/plan-doc/fe-core-iceberg-removal-plan.md b/plan-doc/fe-core-iceberg-removal-plan.md new file mode 100644 index 00000000000000..f461b08b7f75c0 --- /dev/null +++ b/plan-doc/fe-core-iceberg-removal-plan.md @@ -0,0 +1,158 @@ +# fe-core Iceberg 代码与 Maven 依赖移除计划(v2,实证重写) + +> 目标:fe-core 逐步不再包含任何 iceberg 特有能力代码(`instanceof Iceberg*`、`import ...datasource.iceberg.*`、iceberg SDK import)与 iceberg 专属 maven 依赖。 +> 生成:2026-07-04。方法:39 个分析 agent 分两阶段(7 路并行清点 → 对每个"可删"结论做双镜头对抗反驳 + 高风险死臂能力孪生抽查 + 8 个存活集群移除路径设计 + 独立完备性复扫)。全部结论带 file:line 实证。 +> **v1 草稿(同名文件旧版)记载失实**:其声称 broker/ 与 fileio/ 已删除——实际两目录仍在、无对应提交。本 v2 为全量重写,v1 结论一律以本文为准。 + +--- + +## 0. 结论速览 + +- 基线(完备性复扫 fresh grep):fe-core `src/main` 含 iceberg 的文件 **236 个**(其中 `datasource/iceberg/` 74 个 + 目录外 165 个引用点文件 + vendored 拆包类 `src/main/java/org/apache/iceberg/DeleteFileIndex.java`);`src/test` **106 个**。 +- **翻闸已生效**(复核):`CatalogFactory.java:49-50` SPI_READY_TYPES = {jdbc, es, trino-connector, max_compute, paimon, **iceberg**};**hive/hms 不在其中** → HMS 目录仍走 fe-core 原生栈,"HMS 目录下的 iceberg 表"是真实存活路径,也是最大的删除阻塞项。 +- **GSON 持久化兼容不阻塞任何删除**:`GsonUtils.java:395-411,464-466,491-494` 只注册字符串标签映射到 PluginDriven*,零原生类引用;`IcebergGsonCompatReplayTest` 构造的是 PluginDrivenExternalCatalog + 字符串替换 clazz 判别符(:52-58,64-68),删原生类后原样存活,且必须保绿(它是升级兼容的守卫)。 +- `iceberg_meta` TVF 已在上游删除(eea082b4540),fe-core `tablefunction/`、`FrontendServiceImpl` 零 iceberg 引用。 +- 连接器覆盖度实证:**原生 iceberg catalog 的全部运行时能力已由 fe-connector-iceberg 完整承接**(元数据/DDL/scan+count 下推/sys 表/写入+事务+OCC/全部 9 个 EXECUTE 过程含 rewrite_data_files(经引擎中立的 ConnectorRewriteDriver 执行,非原生执行器)/统计/时间旅行/MTMV/vended 凭据/缓存/kerberos+TCCL)。**但"完整移植"≠"fe-core 可清零"**,三大存活根:HMS-iceberg、行级 DML 计划合成(签字的临时架构)、属性/鉴权接线(见 §4)。 + +| 分类 | 规模 | 处置 | +|---|---|---| +| 硬死码孤岛(阶段一) | `datasource/iceberg/` 内 ~36 文件 + 若干目录外死执行器 | 立即删(个别需先削臂/搬常量) | +| AWS 依赖簇(阶段二) | 属性/连通性/AWS helper ~16 文件 + 3 个 maven 依赖 | 外科手术式剥离后摘依赖 | +| 死臂 + 实体类(阶段三) | ~30 处 `instanceof Iceberg*` 死臂 + 3 个实体类,波及 ~75-100 文件 | 逐臂孪生审计后清剿 | +| 架构迁移(阶段四) | HMS-iceberg 全栈 + 行级 DML 合成 | 需架构决策(Trino 参照见 §6) | +| 永久保留标识面 | thrift/枚举/配置/列名常量/GSON 标签 | 不删(§7) | + +--- + +## 1. Maven 依赖裁决(fe-core/pom.xml) + +| 依赖 | 裁决 | 依据 | +|---|---|---| +| `org.apache.iceberg:iceberg-core` (pom:537-541) | **暂留**,阶段四后可摘 | 被钉:① HMS-iceberg(`HMSExternalCatalog.java:40,242-249` → `IcebergUtils.createIcebergHiveCatalog:1307-1322`);② 行级 DML 合成(`BindSink.java:119-121`、`StatementContext.java:323` 持 `List`);③ vendored 拆包类 `org/apache/iceberg/DeleteFileIndex.java`;④ 属性簇存活方法。fe/pom.xml 的 dependencyManagement + `${iceberg.version}` **永久保留**(fe-connector-iceberg 与 be-java-extensions/iceberg-metadata-scanner 自带副本仍需版本管理) | +| `org.apache.iceberg:iceberg-aws` (pom:542-546) | **阶段二摘除** | `org.apache.iceberg.aws.*` 仅 9 文件 import,全部属可剥离簇(§4);hive/nereids/statistics 零命中。⚠️ 例外见 §4 决策点 | +| `software.amazon.awssdk:s3tables` (pom:737-740) | **阶段二摘除** | 唯一 main 引用 = `IcebergS3TablesMetaStoreProperties.java:30-31` | +| `software.amazon.s3tables:s3-tables-catalog-for-iceberg` (pom:742-745) | **阶段二摘除** | 唯一 main 引用 = 同上文件 :32-34 | +| `org.apache.avro:avro` (pom:589) | **保留** | pom 注释"For Iceberg"有误导:hudi 直接使用(`HudiUtils.java:45-48`、`HMSExternalTable.java:743,748`),且是 iceberg-core 传递需求。fe/pom.xml:345-347 记载 avro.version 与 iceberg.version 耦合——摘 iceberg-core 前不可动 | +| `software.amazon.awssdk:s3-transfer-manager` (pom:562-565) | **保留** | 纯运行时依赖,hadoop-aws 的 S3A IO 路径需要(与 iceberg 无关的消费者) | + +连接器自包含性已核实:fe-connector-iceberg pom 自带 compile 级 iceberg-core/iceberg-aws/AWS SDK v2 闭包/s3tables,经 plugin-zip 子加载器打包;be-java-extensions/iceberg-metadata-scanner 亦自带。**删 fe-core 副本不影响插件。** + +--- + +## 2. 现状引用图(74 文件分簇,import 精确 + 控制流核验) + +### 死簇(阶段一目标,~36 文件) +| 簇 | 文件 | 备注 | +|---|---|---| +| broker IO | broker/ 3 文件 | 零入向引用(main/test/目录内全核实;注意 `common/parquet/BrokerInputFile` 是同名不同类,勿混) | +| fileio 委托 | fileio/ 4 文件 | 静态零引用;⚠️ 对抗核验发现**配置注入反射活路**:HMS-iceberg 目录上用户可设 `io-impl=...DelegateFileIO`(`IcebergUtils.java:1311-1321` 把用户属性原样透传 `HiveCatalog.initialize`),且有 p2 回归测试在用 → 删除需决策(§8-Q1) | +| 6 个非 REST catalog flavor + 工厂 | 7 文件 | 仅互引;工厂零引用(CatalogFactory legacy case 已删)。GSON/路由/统计侧只引 base 类不引 flavor | +| DLF 实现 | dlf/ + HiveCompatibleCatalog 5 文件 | 死;删除需同步编辑 `IcebergAliyunDLFMetaStoreProperties.initCatalog`(唯一编译引用点) | +| 原生 EXECUTE actions | action/ 11 文件 | 运行时死(插件走连接器 factory);编译被 `ExecuteActionFactory.java:66` 的 IcebergExternalTable 死臂钉住 → 先削臂 | +| 原生 rewrite 执行半 | rewrite/ 6 文件 | 运行时死。**重要更正**:插件路径的 rewrite_data_files 执行半 = 引擎中立的 `ConnectorRewriteDriver`/`ConnectorRewriteExecutor`(fe-core 但 iceberg-free),原生 rewrite/ 目录只是死掉的孪生兄弟,**可删** | +| 写事务 + delete-plan helper | IcebergTransaction 等 4 文件 | 运行时死;被目录外死执行器(IcebergDeleteExecutor/IcebergMergeExecutor/IcebergInsertExecutor/IcebergRewriteExecutor)编译钉住,后者又被 `IcebergRowLevelDmlTransform.java:193-197`、`InsertIntoTableCommand.java:568-583` 的死臂钉住 → 同批削臂后连锁删除(抽查已确认这些臂的插件孪生存活:PluginDrivenInsertExecutor + 连接器事务) | + +对抗反驳(每簇双镜头:隐藏引用 + 运行时可达):以上除 fileio 外全部通过(refuted=false, high confidence)。 + +### 存活簇(不可立即删) +| 簇 | 文件数 | 存活根 | +|---|---|---| +| HMS 元数据/schema/快照/分区网 | 13(IcebergUtils、IcebergMetadataOps、IcebergExternalMetaCache、IcebergMvccSnapshot、Iceberg*CacheKey/Value、IcebergSnapshot、IcebergPartition*、DorisTypeToIcebergType…)+ hive/IcebergDlaTable | HMS-iceberg(hive 未翻闸) | +| scan source | source/ 7 文件(IcebergScanNode 1235 行…) | HMS-iceberg 读路径(`PhysicalPlanTranslator.java:860-864` 对 DlaType.ICEBERG 构造 IcebergScanNode) | +| manifest 缓存 | cache/ 2 + IcebergManifestEntryKey | 唯一消费者 = IcebergScanNode:718,723 | +| scan profile | profile/IcebergMetricsReporter | 唯一消费者 = IcebergScanNode:574 | +| 行级 DML 计划合成工具 | IcebergNereidsUtils、IcebergMergeOperation、IcebergRowId、IcebergMetadataColumn(4 活/5) | 行级 DML 根:`RowLevelDmlRegistry.java:38` → `IcebergRowLevelDmlTransform`(**对插件表同样生效**,:78-101 处理 PluginDrivenExternalTable;其 :96,:131 的"dormant"注释已过时)→ 合成 IcebergDelete/Update/MergeCommand;提交侧已走 SPI(PluginDrivenInsertExecutor + 连接器事务),非原生 IcebergTransaction | +| vended 凭据 | IcebergVendedCredentialsProvider | `VendedCredentialsFactory.java:62-63` Type.ICEBERG 分支(插件路径每个 iceberg 目录都执行) | +| catalog base 类 | IcebergExternalCatalog(MIXED) | 实例翻闸后不再构造,但**常量活着**:`IcebergUtils.java:1876-1881` 读 MANIFEST_CACHE_*、`IcebergMetadataOps.java:122,253,491`、`IcebergScanNode.java:197-203` switch ICEBERG_HMS/REST/…。删除前须把常量搬家(对抗核验修正了 v1"随 flavor 一起删"的误判) | +| 实体类 | IcebergExternalDatabase/Table/SysExternalTable(MIXED) | 运行时零构造路径,但编译扇入最重(~25 个活文件的死臂钉住)→ 阶段三。⚠️ 一个回放边缘:`ExternalCatalog.buildDbForInit` switch case ICEBERG(`ExternalCatalog.java:972-973`)——升级场景下**翻闸前写的 InitDatabaseLog(Type.ICEBERG) 回放**会在 GSON 迁移后的 PluginDriven 目录上构造原生 IcebergExternalDatabase → 删实体类前必须把该 case 改为构造 PluginDriven 数据库(与 GSON 标签迁移同型的兼容处理) | + +### 目录外死执行器/sink 车道(完备性复扫补盲,v1 完全遗漏) +`LogicalIcebergTableSink`、`LogicalIcebergMergeSink`、`planner/IcebergDeleteSink`、`planner/IcebergMergeSink`、`planner/IcebergTableSink`、`IcebergDeleteExecutor`、`IcebergMergeExecutor`、`IcebergInsertExecutor`、`IcebergRewriteExecutor`、`IcebergTransactionManager` + `TransactionManagerFactory.java:21`(createIcebergTransactionManager)——与实体类/写事务簇同命运,阶段一/三连锁处理。 + +--- + +## 3. 阶段一:硬死码删除(可立即执行,无行为影响) + +1. 删 broker/(3 文件)——零耦合,直接删。 +2. 削 `ExecuteActionFactory.java:66` IcebergExternalTable 死臂 → 删 action/(11)+ rewrite/(6)。 +3. 削 `IcebergRowLevelDmlTransform.java:193-197`、`InsertIntoTableCommand.java:568-583` 死臂 → 删 IcebergDeleteExecutor/IcebergMergeExecutor/IcebergInsertExecutor/IcebergRewriteExecutor、IcebergTransactionManager(+ TransactionManagerFactory 对应方法)、IcebergTransaction、IcebergConflictDetectionFilterUtils、helper/IcebergRewritableDeletePlanner*。 +4. 6 个 catalog flavor + 工厂(7 文件):先把 IcebergExternalCatalog 里被活代码读取的常量搬家(IcebergUtils 或属性层),flavor 引用的常量随删;改造 4 个测试(`ExternalMetaCacheRouteResolverTest:76-78` 换 PluginDriven 构造、StatisticsUtilTest、DbsProcDirTest、IcebergDLFExternalCatalogTest);IcebergGsonCompatReplayTest 不动、保绿。 +5. dlf/ + HiveCompatibleCatalog(5 文件)+ 编辑 `IcebergAliyunDLFMetaStoreProperties.initCatalog`。 +6. fileio/(4 文件)——**待 §8-Q1 决策后执行**。 + +测试爆炸半径(103 个含 iceberg 的测试文件已逐个定性):随码删 1、需改写 7、随阶段保留 39、不受影响 56。 + +--- + +## 4. 阶段二:AWS 依赖簇剥离(摘 3 个 maven 依赖) + +**关键更正(对抗核验推翻 v1 与首轮清点的两处误判):** +- fe-core iceberg 属性簇**翻闸后仍活**:`PluginDrivenExternalCatalog.java:147`(initPreExecutionAuthenticator)→ `CatalogProperty.getMetastoreProperties:251,260` → `MetastoreProperties.create`(Type.ICEBERG 注册于 :90)→ `IcebergPropertiesFactory` 8 flavor → 每个插件路径 iceberg 目录首次访问都构造属性对象并跑 `initNormalizeAndCheckProps`(kerberos/鉴权接线用)。 +- `property/common/IcebergAws{ClientCredentials,AssumeRole}Properties` **不是死码**:`IcebergRestProperties.java:353,356` 的调用点在 `addGlueRestCatalogProperties()`(:345-361),调用链 initNormalizeAndCheckProps→initIcebergRestCatalogProperties(:219→:289)**在插件路径上活着**(REST + `iceberg.rest.signing-name=glue|s3tables` 的正常受支持配置)。 +- vended 凭据在插件路径同样经 fe-core `IcebergVendedCredentialsProvider`(工厂 Type.ICEBERG 分支),但该类只 import iceberg-core 类型,不钉 aws 依赖。 + +**剥离步骤**(每步独立可落地): +1. 删 5 个连通性 tester(AbstractIcebergConnectivityTester + 4 子类)+ `CatalogConnectivityTestCoordinator.java:284-304` 死臂——test_connection 已由连接器承接(实证 FULLY),tester 不 import aws,纯清理。 +2. 剥属性类中的死方法:AbstractIcebergProperties.toFileIOProperties/buildIcebergCatalog、IcebergGlue/S3Tables/DLF 各自的 initCatalog 及 helper(org.apache.iceberg.aws 与 s3tables import 全部集中于此;插件路径只用 initNormalizeAndCheckProps,initCatalog 只有死掉的原生 catalog 构建路径调用)。 +3. 处理唯一活的 aws 引用:`addGlueRestCatalogProperties` 的 glue/s3tables REST 签名属性规范化——就地内联所需常量(几行字符串键)或下沉连接器侧,消除对 iceberg-aws 类型的 import。 +4. 删 IcebergS3TablesMetaStoreProperties(+2 测试 + tester)后同 commit 摘 `s3tables` + `s3-tables-catalog-for-iceberg`;删 IcebergAws* helper、DLFCatalog 后摘 `iceberg-aws`。 +5. ⚠️ 决策点 §8-Q2:摘 iceberg-aws 后,HMS-iceberg 目录上用户手工配置 `io-impl=org.apache.iceberg.aws.s3.S3FileIO` 的极端场景会反射失败(属性透传见 §2 fileio 行)。 + +**属性簇整体删除**(9 文件全删 + MetastoreProperties.java:51,90 注册注销)是更远一步,前置 = 把 initPreExecutionAuthenticator 的鉴权接线与 vended 凭据 Type.ICEBERG 分支改走连接器(Trino 参照:目录配置完全插件内,引擎零 per-connector 属性类)。阶段二不强求,摘依赖只需上面 1-4。 + +--- + +## 5. 阶段三:死臂清剿 + 实体类删除 + +- 30 处翻闸后死臂(`instanceof Iceberg*`)分布于 ~20-30 个共享活文件:PhysicalPlanTranslator:885、StatisticsUtil:1001、StatisticsAutoCollector:152、RefreshManager:243、Env.getDdlStmt 两处 ~25 行臂、ExternalMetaCacheRouteResolver:63、UserAuthentication、ShowCreate*/ShowPartitions/CreateTableInfo、BindRelation/BindSink(:727-836 大臂)/LogicalFileScan:213,263/SlotTypeReplacer/MaterializeProbeVisitor、RewriteTableCommand:190-201 等(完整 82 条臂清单含 LIVE/DEAD/IDENTIFIER_ONLY 定性见分析工作流归档)。 +- **能力孪生抽查(12 条最高风险死臂):12/12 全部 COVERED**(通用/插件路径有实证等价承接)。删臂执行时仍须对余下死臂逐条做同款孪生核对(历史上嵌套列裁剪臂曾漏承接致静默回归)。 +- 实体类删除顺序:先修 `ExternalCatalog.buildDbForInit` case ICEBERG 的回放兼容(改构造 PluginDriven 数据库,见 §2),再清剿死臂,最后删 IcebergExternalDatabase/Table/SysExternalTable + IcebergExternalCatalog(常量已于阶段一搬家)。规模 ~75-100 文件。 + +--- + +## 6. 阶段四:架构迁移(Trino 参照) + +### 6a. HMS-iceberg(最大阻塞,钉住 iceberg-core + ~24 文件) +Trino 方案:**hive 插件零 iceberg 代码**——引擎提供通用表重定向钩子 `ConnectorMetadata#redirectTable(session, tableName) → Optional`,hive 元数据层检测表属性 `table_type=ICEBERG` 后把表重定向到配置的 iceberg 目录(`hive.iceberg-catalog-name`),后续全部规划/读写走 iceberg 连接器。 +Doris 可借鉴的两条路: +- **路 A(Trino 式重定向)**:fe-core 加中立"表重定向"接缝,HMS 目录检测 DlaType.ICEBERG 后把表委给同集群的插件 iceberg 目录处理。优点:不必等 hive 整体迁 SPI;缺点:需要用户侧有/隐式建一个对应 iceberg 目录,跨目录语义(权限/缓存/SHOW)要设计。 +- **路 B(随 hive 整体迁移)**:hive 进 SPI_READY_TYPES 时 HMS-iceberg 自然进插件(fe-connector-hive/fe-connector-hms 方向)。优点:无新架构面;缺点:时间表最远。 +无论哪条,落地后连锁解放:IcebergUtils、IcebergMetadataOps、IcebergExternalMetaCache、source/ 全部、cache/、profile/、IcebergDlaTable、IcebergTransaction 残留、vendored DeleteFileIndex(+ checkstyle suppressions.xml:72-73)、IcebergRestExternalCatalog,然后才能摘 `iceberg-core`。 + +### 6b. 行级 DML 计划合成(签字的临时架构:留引擎侧,直至出现第二个行级 DML 消费者) +Trino 方案:引擎为所有连接器合成**一份通用 MERGE 计划**——`ConnectorMetadata#getMergeRowIdColumnHandle`(不透明行标识列)+ `RowChangeParadigm`(iceberg=DELETE_ROW_AND_INSERT_ROW)+ worker 侧 `ConnectorMergeSink` 吃统一的操作码行流;引擎不知道 iceberg 存在。 +Doris 对应改造(不必推翻签字决策,可先"去 iceberg SDK 化"):把 IcebergMergeOperation 的操作码、IcebergRowId/IcebergMetadataColumn 的行标识抽象改为 fe-connector-api 中立类型(两个小增项:中立 merge 操作码枚举 + 行标识列描述,SPI 带默认实现),IcebergNereidsUtils 中 SDK 表达式转换下沉连接器;`StatementContext.java:323` 的 `List` 暂存改为不透明句柄。做完后该车道虽仍在 fe-core,但**不再 import org.apache.iceberg**——iceberg-core 摘除不再被它阻塞(只剩 6a)。 + +> **⚠️ 2026-07-04 晚实证重裁定(11-agent 设计研究 + 用户已签,本节上段仅存历史设想)**:四项中——操作码与行标识**已是中立**(IcebergMergeOperation 纯常量零 SDK import;rowid 列已由连接器 `getSyntheticWriteColumns` 声明 + 引擎中立注入,全链零 SDK 类型);表达式下沉与暂存句柄化的**目标代码是翻闸后死码**(`convertNereidsToIcebergExpression` 仅剩两个死调用方;SDK 暂存两端皆死,中立替身 `rewriteSourceFilePaths` 已端到端上线,连接器自带 `IcebergPredicateConverter` 三模式转换器)→ **改判为死车道删除**(旧 rewrite/action 17 文件 + DML 死类 + INSERT 死车道并入 + 存活共享文件成员级手术 + 4 个 SDK-free 小类搬中立包 + nereids/planner import 门禁),不新建 SPI。删除闭包/保留面/测试处置/顺带修复/验收全量清单 = **`plan-doc/tasks/designs/iceberg-rowlevel-dml-desdk-design.md`**(APPROVED,以其为准)。 +> +> **✅ 2026-07-04/05 全七步已落地(设计 Status=DONE,逐步独立 commit)**:`af7e244c3fe`(1/7 rewrite/action 死车道) + `64b03892b20`(2/7 DML 死臂闭包) + `bf326c04741`(3/7 INSERT 死车道并入) + `4e7220d81c7`(4/7 四小类搬中立包:MergeOperation/RowLevelDmlRowIdUtils 改名 + IcebergRowId/IcebergMetadataColumn 保名进 `nereids.trees.plans.commands`) + `255bcaf52a2`(5/7 checkstyle 门禁 nereids/planner 禁 org.apache.iceberg,mutation 击杀验证) + `e5972dfc8a2`(6a/7 rewrite re-derive 补 doAs) + `890b8698e6f`(6b/7 DML 预执行窗口补回滚)。**结果:nereids/planner 零 org.apache.iceberg import 且被门禁上锁**;本车道 fe-core 侧 SDK import 归零。残留豁免清单(datasource.iceberg 的 19 处 import,见 5/7 commit message):legacy 豁免臂 + 翻闸后死 instanceof 臂(死臂清剿阶段处理)+ **活 IcebergUtils 引用**(BindExpression/IcebergUpdate/MergeCommand 的 isIcebergRowLineageColumn + CreateTableInfo/ShowPartitionsCommand 常量读——设计"活 import 归零"未覆盖此面,待后续中立化,不阻塞 iceberg-core 摘除评估=它们只钉 IcebergUtils 文件自身)。docker e2e(dml 4 套件 + action/ 8 套件)flip-gated 未跑(死码删除理论零行为差;rewrite kerberos fix 的 e2e 也 flip-gated)。 + +### 6c. 目录属性/鉴权(远期收尾) +Trino:目录配置=插件内 ConnectorFactory#create(props),引擎零 per-connector 属性类。Doris 对应:initPreExecutionAuthenticator/vended 凭据的 Type.ICEBERG 分支改由连接器提供(现有 ConnectorContext#vendStorageCredentials 接缝已在),之后 MetastoreProperties 注销 Type.ICEBERG、删属性簇余量。 + +--- + +## 7. 永久保留(标识面,与 SDK/原生类无关,删了反而破坏兼容) + +- thrift 契约:`TIcebergCommitData`/`TIcebergColumnStats` 等(DataSinks.thrift 等 9 文件)——**插件写路径同样使用**(BE 回报 → `LoadProcessor.java:230-236` feed 连接器事务),FE-BE 线协议。 +- `InitDatabaseLog.Type.ICEBERG` 枚举常量——旧 editlog 回放需要(配合 §5 的 buildDbForInit 兼容改造)。 +- GSON 字符串标签注册(8 个旧 catalog 名 + db/table 标签)。 +- `Column.ICEBERG_ROWID_COL`(fe-catalog)——活 SPI 消费者在用;fe-core 死臂删除后 `ColumnType` 的 iceberg 方法成孤儿可顺手清。 +- 存储属性中的 iceberg 条件逻辑(AzureProperties:155-156,306-320、OSSProperties:74,164 等 isIcebergRestCatalog 判断)与 fe-filesystem 的 `iceberg.rest.*` 凭据别名键——插件路径与 HMS 路径都在用的字符串面。 +- fe-common Config:`enable_query_iceberg_views` 等开关及消费者(BindRelation:623,643)。 +- fe/pom.xml 的 `${iceberg.version}`/`${avro.version}` 版本管理(连接器与 BE 扩展模块仍需)。 +- be-java-extensions/iceberg-metadata-scanner 整模块(BE 侧 JNI sys-table 扫描,独立于 fe-core)。 + +--- + +## 8. 用户决策(2026-07-04 已裁定) + +- **Q1+Q2(io-impl 极端配置)= A 接受失效**:fileio/ 4 文件并入阶段一删除(同步改掉在用的 p2 回归);iceberg-aws 阶段二照常摘,文档注明 HMS-iceberg 上手配 `io-impl=...S3FileIO`/内部 FQCN 的极端配置不再支持。 +- **Q3(HMS-iceberg 方向)= B 随 hive 整体迁移**:不建 Trino 式重定向接缝;§6a 走路 B,iceberg-core 摘除时间表挂靠 hive 目录迁插件框架的进度。 +- **Q4(行级 DML 去 SDK 化)= A 现在做**:§6b 提前启动,签字的引擎侧留驻决策不变,只消除 SDK import;**设计先行**(见 Q5)。 +- **Q5(执行范围)= 继续只分析**:暂不动码。下一轮先产出行级 DML 去 SDK 化的详细设计(新增中立 SPI 面的精确形状、`StatementContext` 暂存句柄化、连接器侧下沉点、兼容与验收),设计签字后再按 阶段一 → 二 → 三 → 6b 的顺序动码。 +- **Q6(2026-07-04 晚补裁,行级 DML 设计四项)**:①方向=接受实证改判,§6b 由"迁移/建 SPI"改为**死车道删除**;②闭包=原生 INSERT 死车道**并入**同一刀(旧事务类被 DML/INSERT 死执行器共同钉住);③4 个 SDK-free 小类(操作码/行标识工厂/元数据列枚举/注入工具存活半)**现在搬**出 `datasource.iceberg` 到 nereids 中立包;④顺带发现的两个活问题(rewrite 提交前扫描 kerberos 裸奔、DML 预执行窗口无回滚)**随本轮各自独立 commit 修**。详见设计文档 §7。该刀实质 = 阶段一的 DML/INSERT/rewrite 部分 + 该车道的成员级死臂手术提前合并执行,与阶段一其余部分(fileio/、broker/ 等孤岛)不冲突。 + +## 9. 验收(每阶段) + +fe-core `test-compile` 过 + 波及单测按 §3 处置表逐个交代 + checkstyle 净 + import-gate 净 + IcebergGsonCompatReplayTest 保绿;行为敏感项(削臂)逐条附能力孪生证据;docker/e2e 项如未跑须显式标注。 diff --git a/plan-doc/fix-973411-1-hms-classloader-design.md b/plan-doc/fix-973411-1-hms-classloader-design.md new file mode 100644 index 00000000000000..16df5ae5ffd03c --- /dev/null +++ b/plan-doc/fix-973411-1-hms-classloader-design.md @@ -0,0 +1,51 @@ +# FIX-1 — test_create_paimon_table: paimon-over-HMS `create database` classloader split + +## Problem +CI 973411 `external_table_p0/paimon/test_paimon_table.groovy:44`: creating a paimon catalog with +`paimon.catalog.type=hms` then `create database if not exists test_db` fails: +`Failed to create Paimon catalog with HMS metastore (flavor=hms): Failed to create the desired metastore +client (HiveMetaStoreClient)`. + +## Root Cause +`fe.log:423900` deepest cause: `class org.apache.hadoop.hive.metastore.DefaultMetaStoreFilterHookImpl not +org.apache.hadoop.hive.metastore.MetaStoreFilterHook` from `HiveMetaStoreClient.loadFilterHooks:252`. +`Configuration.getClass("metastore.filter.hook", DefaultMetaStoreFilterHookImpl.class, MetaStoreFilterHook.class)` +resolves the configured class NAME through the **`Configuration` object's own `classLoader` field**, NOT the +thread-context CL. `new HiveConf()` captures the TCCL active *at construction* into that field. In +`PaimonConnector.createCatalog` the HiveConf is built by `assembleHiveConf` BEFORE `createCatalogFromContext` +pins the TCCL to the plugin loader — and `getClass` ignores the TCCL anyway. Under child-first plugin loading, +`DefaultMetaStoreFilterHookImpl` (resolved by name via the parent app loader) ≠ the child-loaded +`MetaStoreFilterHook` interface → the cast check throws. + +The filesystem/jdbc path is immune: `buildHadoopConfiguration` already calls +`conf.setClassLoader(PaimonCatalogFactory.class.getClassLoader())` (PaimonCatalogFactory.java:257). +`assembleHiveConf` (line 323-330) never does. Legacy master ran in a single app loader, so no split. +Classification: **SPI regression** (introduced by child-first plugin packaging). Also covers DLF (shares +`assembleHiveConf`). + +## Design +Pin the HiveConf classloader to the paimon plugin loader in `assembleHiveConf`, exactly mirroring +`buildHadoopConfiguration:257`. This makes every by-name class lookup `HiveMetaStoreClient` performs resolve +through the same child loader that loaded `HiveMetaStoreClient`/`MetaStoreFilterHook`. Single chokepoint → +fixes both HMS and DLF. Entirely inside the paimon connector module (connector-agnostic rule respected). + +## Implementation Plan +`PaimonCatalogFactory.assembleHiveConf`: add `hiveConf.setClassLoader(PaimonCatalogFactory.class.getClassLoader())` +immediately after `new HiveConf()`. + +## Risk Analysis +Minimal. Identical idiom already in use one method up. Pinning to the plugin loader is strictly more correct +than the captured-TCCL default; cannot regress the FS path (separate builder). No behavior change for the +single-classpath UT environment. + +## Test Plan +### Unit Tests +`PaimonCatalogFactoryTest.assembleHiveConfPinsPluginClassLoaderNotTccl`: set a *foreign* TCCL +(`new URLClassLoader(new URL[0], null)`) before calling `assembleHiveConf`, assert the returned HiveConf's +`getClassLoader()` is the plugin loader (`PaimonCatalogFactory.class.getClassLoader()`), not the foreign TCCL. +RED before fix (HiveConf captures the foreign TCCL), GREEN after. Encodes WHY: the conf must resolve by-name +classes through the plugin loader independent of whatever TCCL was active at construction. + +### E2E Tests +Existing `test_paimon_table.groovy` / `test_paimon_catalog.groovy` under docker `enablePaimonTest=true` are the +real gate (a flat-classpath UT cannot reproduce the actual cross-loader cast). Currently RED; expected GREEN. diff --git a/plan-doc/fix-973411-1-hms-classloader-summary.md b/plan-doc/fix-973411-1-hms-classloader-summary.md new file mode 100644 index 00000000000000..a723da7bdb8444 --- /dev/null +++ b/plan-doc/fix-973411-1-hms-classloader-summary.md @@ -0,0 +1,23 @@ +# FIX-1 Summary — paimon-over-HMS create-db classloader split + +## Problem +CI 973411 `test_create_paimon_table:44`: `create database` on a `paimon.catalog.type=hms` catalog failed with +`Failed to create the desired metastore client (HiveMetaStoreClient)`. + +## Root Cause +`HiveMetaStoreClient.loadFilterHooks` → `Configuration.getClass("metastore.filter.hook", ...)` resolves the +class by name through the `HiveConf` object's own `classLoader` field. `new HiveConf()` in `assembleHiveConf` +captured the TCCL active at construction (= parent app loader, since it runs before the plugin TCCL pin), so +under child-first plugin loading `DefaultMetaStoreFilterHookImpl` (parent) ≠ child-loaded `MetaStoreFilterHook` +→ "class … not …". The filesystem builder already pinned the conf loader (line 257); `assembleHiveConf` did not. + +## Fix +`PaimonCatalogFactory.assembleHiveConf`: `hiveConf.setClassLoader(PaimonCatalogFactory.class.getClassLoader())` +right after `new HiveConf()`. Single chokepoint → covers both HMS and DLF. Connector-local. + +## Tests +`PaimonCatalogFactoryTest.assembleHiveConfPinsPluginClassLoaderNotTccl`: installs a foreign TCCL, asserts the +returned HiveConf is pinned to the plugin loader. RED before / GREEN after. Full class: 16/16 pass; checkstyle clean. + +## Result +Fixed (offline UT). Real gate: docker `enablePaimonTest=true` rerun of test_paimon_table / test_paimon_catalog. diff --git a/plan-doc/fix-973411-2-connector-null-design.md b/plan-doc/fix-973411-2-connector-null-design.md new file mode 100644 index 00000000000000..66b08253886add --- /dev/null +++ b/plan-doc/fix-973411-2-connector-null-design.md @@ -0,0 +1,54 @@ +# FIX-2 — test_mysql_mtmv: connector-null NPE during mv_infos scan (collateral) + +## Problem +CI 973411 `mtmv_p0/test_mysql_mtmv.groovy:63` fails with +`[INTERNAL_ERROR] Cannot invoke Connector.getMetadata(...) because "connector" is null`. The MySQL MTMV test +itself is healthy — it is collateral. + +## Root Cause +`getJobName` runs an `mv_infos`/`jobs` metadata scan → `MetadataGenerator.mtmvMetadataResult` loops over ALL +MTMVs in the db → `MTMVPartitionUtil.isMTMVSync` → the related paimon table's +`PluginDrivenMvccExternalTable.materializeLatest:122` dereferences a **null** `connector` +(`pluginCatalog.getConnector()`), throwing NPE that aborts the whole metadata query. + +Why null: `PluginDrivenExternalCatalog.connector` is `transient volatile` (line 71). `onClose()` (549-559) +sets `connector = null` but does NOT reset the inherited `objectCreated` flag. `dropCatalog` cleanup calls +`catalog.onClose()` **directly** (`CatalogMgr.cleanupRemovedCatalog:144`), not `resetToUninitialized()` (which +*does* reset `objectCreated`, :625). So a just-dropped catalog object is left `objectCreated=true, +connector=null`; a concurrent stale access calls `makeSureInitialized()` → `initLocalObjects()` skips +`initLocalObjectsImpl()` (the only place the connector is recreated) because `objectCreated` is still true → +`getConnector()` returns null. FE log: catalog drop 21:15:44,724; NPE 21:15:44,748 (24 ms race), +fe.log:83972. Legacy master `PaimonExternalCatalog.onClose()` closed the client but never nulled the field, so +this NPE could not occur. Classification: **SPI regression** (lifecycle), surfaced by a concurrency race. + +A null connector after `makeSureInitialized()` is reachable ONLY in this dropped-catalog state: on a healthy +catalog, `initLocalObjectsImpl` THROWS if it cannot create a connector (:115-119) — so the guard cannot mask a +real init failure. + +## Design +Guard the null connector at the NPE site in `materializeLatest`: if `connector == null`, return a valid empty +pin (snapshot id -1, empty partition maps), exactly mirroring the existing dropped-**table** branch (:125-130). +Smallest change at the actual failure point; connector-agnostic; keeps `getConnector()`'s contract unchanged. +A stale dropped-catalog MTMV access then yields a benign empty result instead of aborting the scan. + +(Not chosen: re-creating the connector in `onClose` — wrong for a genuinely dropped catalog. Optional separate +defense-in-depth, pre-existing generic MTMV code: per-MTMV try/catch in `MetadataGenerator.mtmvMetadataResult` +so one bad MTMV can't fail the whole scan — out of scope for this SPI-regression fix.) + +## Implementation Plan +`PluginDrivenMvccExternalTable.materializeLatest`: after `Connector connector = pluginCatalog.getConnector();`, +add `if (connector == null) return new PluginDrivenMvccSnapshot(emptySnapshot(), Collections.emptyMap(), +Collections.emptyMap());`. + +## Risk Analysis +Minimal. Empty maps → `isPartitionInvalid()==false` → `getPartitionColumns` returns the cached static columns +(no NPE). Cannot mask a genuine init failure (that path throws). No effect on the healthy path. + +## Test Plan +### Unit Tests +`PluginDrivenMvccExternalTableTest.testMaterializeLatestNullConnectorDegradesToEmptyPin`: build a table over a +catalog whose `getConnector()` returns null, call `loadSnapshot(empty, empty)`, assert it returns an empty pin +(snapshot id -1, empty maps) and does not NPE. RED before / GREEN after. + +### E2E Tests +Race-dependent; covered indirectly by the existing mtmv_p0 paimon suites under docker enablePaimonTest=true. diff --git a/plan-doc/fix-973411-2-connector-null-summary.md b/plan-doc/fix-973411-2-connector-null-summary.md new file mode 100644 index 00000000000000..9be2b60ecf64a7 --- /dev/null +++ b/plan-doc/fix-973411-2-connector-null-summary.md @@ -0,0 +1,25 @@ +# FIX-2 Summary — connector-null NPE during mv_infos scan (collateral) + +## Problem +CI 973411 `test_mysql_mtmv:63` failed with `Connector.getMetadata(...) because "connector" is null`. The MySQL +test is collateral: its `getJobName` runs an `mv_infos` scan that iterates all MTMVs. + +## Root Cause +A concurrent catalog DROP: `PluginDrivenExternalCatalog.onClose()` nulls the transient `connector` but does not +reset `objectCreated`; `dropCatalog` calls `onClose()` directly (not `resetToUninitialized`), so a stale +metadata access finds `getConnector()==null` (makeSureInitialized skips re-init). `materializeLatest:122` +dereferenced it → NPE aborted the whole metadata query. Legacy `onClose` never nulled the field. + +## Fix +`PluginDrivenMvccExternalTable.materializeLatest`: if `connector == null`, return a valid empty pin +(snapshot id -1, empty maps), mirroring the existing dropped-table (no-handle) branch. Connector-agnostic; +`getConnector()` contract unchanged. Cannot mask a real init failure (that path throws). + +## Tests +`PluginDrivenMvccExternalTableTest.testMaterializeLatestNullConnectorDegradesToEmptyPin`: table over a +null-connector catalog → `loadSnapshot(empty,empty)` returns the empty pin instead of NPE. The RED run threw +the exact production NPE; GREEN after. Full class 36/36; fe-core checkstyle clean. + +## Result +Fixed (offline UT reproduces + verifies). Optional pre-existing defense-in-depth (per-MTMV try/catch in +`MetadataGenerator.mtmvMetadataResult`) left out of scope. diff --git a/plan-doc/fix-973411-3-pnull-partition-design.md b/plan-doc/fix-973411-3-pnull-partition-design.md new file mode 100644 index 00000000000000..e59b9dcc7a6dc8 --- /dev/null +++ b/plan-doc/fix-973411-3-pnull-partition-design.md @@ -0,0 +1,55 @@ +# FIX-3 — test_paimon_mtmv: "Duplicated named partition: p_NULL" + +## Problem +CI 973411 `mtmv_p0/test_paimon_mtmv.groovy:247`: `CREATE MATERIALIZED VIEW ... partition by(region) AS SELECT +* FROM .test_paimon_spark.null_partition` fails: `Duplicated named partition: p_NULL`. + +## Root Cause +`null_partition` (docker run01.sql:63-67) region values: `bj`, genuine NULL, string `'null'`, string `'NULL'`. +MTMV names one partition per distinct base PartitionKeyDesc via `MTMVPartitionUtil.generatePartitionName`: +`"p_" + desc.toSql()` with `[^a-zA-Z0-9,]` stripped. Two partitions collapse to `p_NULL`: +- genuine NULL: connector normalizes paimon's `__DEFAULT_PARTITION__` → `__HIVE_DEFAULT_PARTITION__` + (PaimonConnectorMetadata:1001-1008), bridge marks it `isNull=true` (PluginDrivenMvccExternalTable:193-194) + → PartitionKey holds a `NullLiteral`. `PartitionInfo.toPartitionValue` maps a `NullLiteral` → + `new PartitionValue("NULL", true)` (PartitionInfo.java:401-402) → `desc.toSql()` = `('NULL')` → `p_NULL`. +- string `'NULL'`: `isNull=false`, getStringValue()="NULL" → `('NULL')` → `p_NULL`. IDENTICAL. + +Master (`PaimonUtil.toListPartitionItem:214`) hardcoded `isNull=false`, so genuine-NULL was a *StringLiteral* of +the sentinel → a DISTINCT name → no collision (test passed; .out / comment line 265 "Will lose null data"). +The branch's IS-NULL-prune fix (isNull=true) introduced the collision. Classification: **SPI regression**. + +## Design +Keep `isNull=true` (so `region IS NULL` prune from the prune fix still works) but stop the MTMV name from +collapsing a genuine-null to the bare `NULL` that collides with a literal string `'NULL'`. The bridge already +preserves a distinct display string in `PartitionKey.originHiveKeys` ("__HIVE_DEFAULT_PARTITION__", because it +builds the key with `createListPartitionKeyWithTypes(..., isHive=true)`). In +`ListPartitionItem.toPartitionKeyDesc`, for a null partition value whose key carries a sized `originHiveKeys`, +use that origin string as the DISPLAY value (`new PartitionValue(originKey, true)`); `isNull` stays true so +`getValue(type)` is still a `NullLiteral` (pruning unaffected) but `PartitionKeyDesc.toSql()` renders the +distinct sentinel via `getStringValue()`. Result: genuine-NULL → `p_HIVEDEFAULTPARTITION` (distinct, not +asserted), `'NULL'`→`p_NULL`, `'null'`→`p_null`, `'bj'`→`p_bj`. No duplicate. Also closes the same latent +collision for Hive (TablePartitionValues marks `__HIVE_DEFAULT_PARTITION__` isNull=true identically). + +Connector-agnostic (no source-specific code); fix is at the generic catalog layer. Blast radius = MTMV only: +the only `toPartitionKeyDesc` callers are MTMV generators; MTMV's own OLAP partitions have empty +`originHiveKeys` so the guard is a no-op for them. + +## Implementation Plan +`fe/fe-core/.../catalog/ListPartitionItem.java`: rewrite `toPartitionKeyDesc(int pos)` and the no-arg overload +to substitute the origin-hive-key display string for a null partition value (a private helper +`nullDisplayValue(PartitionKey, List, int)`), keeping the existing `pos`-bounds AnalysisException. + +## Risk Analysis +Low. `getOriginHiveKeys()` is a plain getter (empty for OLAP → guard skips). `isNull` unchanged → no prune +regression. `PartitionInfo.toPartitionValue` (shared with Range/SHOW-CREATE/internal-OLAP) is NOT touched. + +## Test Plan +### Unit Tests +New FE UT (e.g. `ListPartitionItemTest`): build a null PartitionKey via +`createListPartitionKeyWithTypes([PartitionValue("__HIVE_DEFAULT_PARTITION__", true)], [VARCHAR], true)` and a +string PartitionKey for "NULL"; assert `generatePartitionName(toPartitionKeyDesc(0))` differs between them and +that the null key's `getValue(STRING)` is still a NullLiteral. RED before / GREEN after. + +### E2E Tests +Existing `test_paimon_mtmv.groovy` under docker enablePaimonTest=true (currently RED, expected GREEN). Also +re-verify `test_paimon_runtime_filter_partition_pruning` (IS NULL prune) stays GREEN. diff --git a/plan-doc/fix-973411-3-pnull-partition-summary.md b/plan-doc/fix-973411-3-pnull-partition-summary.md new file mode 100644 index 00000000000000..49e5161c8b2302 --- /dev/null +++ b/plan-doc/fix-973411-3-pnull-partition-summary.md @@ -0,0 +1,27 @@ +# FIX-3 Summary — "Duplicated named partition: p_NULL" + +## Problem +CI 973411 `test_paimon_mtmv:247`: CREATE MV partitioned by `region` over paimon `null_partition` failed with +`Duplicated named partition: p_NULL`. + +## Root Cause +`null_partition` has genuine NULL, string `'null'`, string `'NULL'`, `'bj'`. The branch's IS-NULL-prune fix +marks a genuine-null partition `isNull=true` → `PartitionInfo.toPartitionValue` renders it as the bare keyword +`NULL` → MTMV name `p_NULL`, colliding with the literal string `'NULL'` partition (also `p_NULL`). Master kept +`isNull=false` so genuine-null got a distinct name. SPI regression. + +## Fix +`ListPartitionItem.toPartitionKeyDesc` (both overloads): a new `toDisplayPartitionValues` helper substitutes the +key's `originHiveKeys` sentinel string (e.g. `__HIVE_DEFAULT_PARTITION__`) as the DISPLAY value for a +genuine-null partition, keeping `isNull=true`. The literal stays a `NullLiteral` (IS NULL prune unaffected); only +the rendered name changes → genuine-null becomes `p_HIVEDEFAULTPARTITION` (distinct), no collision. Connector- +agnostic; MTMV-only blast radius (OLAP partitions have empty originHiveKeys → no-op). + +## Tests +New `ListPartitionItemTest`: (1) genuine-null vs string-'NULL' produce distinct names AND the null key still +resolves to a NullLiteral (RED reproduced `expected: not equal but was: `); (2) OLAP null partition +unchanged (no-op guard). 2/2 GREEN; MTMVPartitionUtilTest 10/10 (no regression); fe-core checkstyle clean. + +## Result +Fixed (offline UT reproduces + verifies). Real gate: docker enablePaimonTest=true rerun of test_paimon_mtmv; +also re-verify test_paimon_runtime_filter_partition_pruning (IS NULL prune) stays GREEN. diff --git a/plan-doc/fix-973411-4-paimon-meta-cache-design.md b/plan-doc/fix-973411-4-paimon-meta-cache-design.md new file mode 100644 index 00000000000000..1cb277ef9b5406 --- /dev/null +++ b/plan-doc/fix-973411-4-paimon-meta-cache-design.md @@ -0,0 +1,63 @@ +# FIX-4 — test_paimon_table_meta_cache: restore paimon table cache (data snapshot + schema TTL) + +## Problem +CI 973411 `test_paimon_table_meta_cache` fails. Two independent assertions break because the SPI migration split +the legacy single paimon table cache (one `meta.cache.paimon.table.ttl-second` knob covered BOTH data snapshot +AND schema) across two SPI mechanisms with different knobs: +- **L79 (with-cache, data):** SPI has NO snapshot cache, so the with-cache catalog sees an external INSERT + immediately (expected 1, got 2). +- **L112 (no-cache, schema):** SPI routes paimon schema to the generic schema cache keyed by + `schema.cache.ttl-second` (default 86400), which `meta.cache.paimon.table.ttl-second=0` does NOT disable, so + the no-cache catalog serves stale schema (expected 3, got 2). + +## Root Cause +`PaimonConnectorProvider` marked `meta.cache.paimon.table.*` "dead" at cutover. `beginQuerySnapshot` reads the +LATEST snapshot id live every query (no cross-query pin), and the schema cache TTL is the generic +`schema.cache.ttl-second`, unaffected by the paimon knob. SPI regression (the unchanged test encodes master). + +## Design (two axes, connector-agnostic fe-core) +Confirmed end-to-end that the query-begin snapshot id controls normal reads: +`materializeLatest -> beginQuerySnapshot -> PluginDrivenScanNode.pinMvccSnapshot -> applySnapshot -> +scan.snapshot-id -> resolveScanTable Table.copy`. + +**Axis A — data snapshot cache:** +- New `PaimonLatestSnapshotCache` (per-catalog, on the long-lived `PaimonConnector`): TTL cache of latest + snapshot id keyed by `Identifier(db,table)`, sized by `meta.cache.paimon.table.ttl-second` (legacy default + 86400; `<= 0` disables -> always live = the no-cache catalog). Access-based expiry; injected into + `PaimonConnectorMetadata` (5-arg ctor; 3/4-arg ctors get a disabled cache so existing tests are unchanged). +- `beginQuerySnapshot` serves the id through the cache (live read only on a miss). +- New `Connector.invalidateTable(db,tbl)` / `invalidateAll()` SPI default no-ops; `PaimonConnector` overrides + them to invalidate the cache (keyed by REMOTE names, matching the handle). +- `RefreshManager.refreshTableInternal` calls `connector.invalidateTable(db.getRemoteName(), + table.getRemoteName())` for any `PluginDrivenExternalCatalog` (generic; no source-specific code). REFRESH + CATALOG already rebuilds the connector (cache gone). + +**Axis B — schema cache TTL:** +- New `Connector.schemaCacheTtlSecondOverride()` SPI default `OptionalLong.empty()`; `PaimonConnector` returns + `meta.cache.paimon.table.ttl-second` when set. +- New generic `ExternalCatalog.overlayMetaCacheConfig(props)` no-op hook; `PluginDrivenExternalCatalog` + overrides it to set `schema.cache.ttl-second` = the connector override (only if the user didn't set it). +- `ExternalMetaCacheMgr.findCatalogProperties` calls the hook on its EPHEMERAL property copy (no persisted + mutation -> no SHOW CREATE leak). REFRESH TABLE already invalidates the schema cache entry. + +`meta.cache.paimon.table.{enable,capacity}` remain not-wired (still reported ignored); `ttl-second` is removed +from the "dead keys" warning since it again takes effect. + +## Risk Analysis +Snapshot pinning stability across queries (within TTL) is the legacy behavior restored — a deliberate, faithful +semantic. fe-core stays connector-agnostic (virtual dispatch; base no-ops). The overlay never mutates persisted +properties. `connector`-field reads are null-guarded (dropped/uninitialized -> engine default). Only fully +verifiable via docker e2e (cross-query cache + external writes); offline UTs cover the cache + the override map. + +## Test Plan +### Unit +- `PaimonLatestSnapshotCacheTest`: caches within TTL, ttl=0 bypasses, invalidate/invalidateAll clear, expiry + (injectable clock). RED/GREEN on the cache logic. +- `PaimonConnectorCacheTest`: `schemaCacheTtlSecondOverride()` maps the knob (absent->empty, 0->of(0), + N->of(N), garbage->empty). +- Regression: PaimonConnectorMetadataMvccTest (beginQuerySnapshot), ValidateProperties, fe-core compile + + PluginDrivenMvccExternalTableTest / ListPartitionItemTest. + +### E2E +`test_paimon_table_meta_cache.groovy` under docker `enablePaimonTest=true` (currently RED; expected GREEN) — +the real gate for the cross-query data cache + schema TTL + refresh. diff --git a/plan-doc/fix-973411-4-paimon-meta-cache-summary.md b/plan-doc/fix-973411-4-paimon-meta-cache-summary.md new file mode 100644 index 00000000000000..58d9547d9f187a --- /dev/null +++ b/plan-doc/fix-973411-4-paimon-meta-cache-summary.md @@ -0,0 +1,32 @@ +# FIX-4 Summary — restore paimon table cache (data snapshot + schema TTL) + +## Problem +CI 973411 `test_paimon_table_meta_cache` fails on two assertions: L79 (with-cache catalog sees an external +INSERT immediately) and L112 (no-cache catalog serves stale schema). The SPI migration split the legacy single +`meta.cache.paimon.table.ttl-second` knob (which covered data snapshot AND schema) and dropped the data cache. + +## Root Cause +`beginQuerySnapshot` read the latest snapshot id live every query (no cross-query pin); the schema cache TTL is +the generic `schema.cache.ttl-second`, unaffected by the paimon knob. SPI regression (test unchanged from master). + +## Fix (two axes, fe-core stays connector-agnostic) +**Axis A (data):** new `PaimonLatestSnapshotCache` on `PaimonConnector` (TTL = `meta.cache.paimon.table.ttl-second`, +default 86400, `<=0` disables); `beginQuerySnapshot` serves the id through it (the id flows to `scan.snapshot-id` +via `applySnapshot`, confirmed end-to-end). New `Connector.invalidateTable/invalidateAll` SPI no-ops; paimon +overrides them; `RefreshManager.refreshTableInternal` invalidates any `PluginDrivenExternalCatalog`'s connector +(REFRESH CATALOG already rebuilds it). +**Axis B (schema):** new `Connector.schemaCacheTtlSecondOverride()` SPI (paimon returns the knob); new generic +`ExternalCatalog.overlayMetaCacheConfig` hook (PluginDrivenExternalCatalog delegates to the connector); +`ExternalMetaCacheMgr.findCatalogProperties` applies it to its EPHEMERAL copy (no SHOW CREATE leak). REFRESH +TABLE already invalidates the schema cache. +`ttl-second` removed from the "dead keys" warning; `enable`/`capacity` remain not-wired (still reported ignored). + +## Tests +- `PaimonLatestSnapshotCacheTest` 5/5 (cache within TTL, ttl=0 bypass, invalidate, expiry via injected clock). +- `PaimonConnectorCacheTest` 4/4 (`schemaCacheTtlSecondOverride` mapping). +- Regression: PaimonConnectorMetadataMvccTest 40/40, ValidateProperties 14/14; fe-core compile + + PluginDrivenMvccExternalTableTest (FIX-2) + ListPartitionItemTest (FIX-3). + +## Result +Offline UTs + compile verified. The cross-query data cache + schema TTL + refresh behavior is gated by the +docker e2e (`enablePaimonTest=true` rerun of test_paimon_table_meta_cache), currently RED, expected GREEN. diff --git a/plan-doc/fix-ab-packaging-design.md b/plan-doc/fix-ab-packaging-design.md new file mode 100644 index 00000000000000..ea60b70a59c033 --- /dev/null +++ b/plan-doc/fix-ab-packaging-design.md @@ -0,0 +1,91 @@ +# Problem + +CI 968994 (commit `3d93f195eff`), `Doris_External_Regression`. The self-contained paimon +plugin zip (`fe-connector-paimon/target/doris-fe-connector-paimon.zip`) is missing two +runtime jars, producing two failure classes: + +- **Class A** — every `s3://`-warehouse paimon catalog fails to create: + `UnsupportedSchemeException: Could not find a file io implementation for scheme 's3'`, + with the deeper Hadoop-S3A cause + `SdkClientException: ... ApplyUserAgentInterceptor does not implement the interface ... ExecutionInterceptor`. + 6 direct tests (test_paimon_s3, test_paimon_minio, test_paimon_schema_change, + test_paimon_char_varchar_type, test_paimon_full_schema_change, test_paimon_jdbc_catalog) + + 18 "Unknown database" collateral (swallowed at `ExternalCatalog.buildDbForInit():914`). +- **Class B** — `obs://` paimon catalog fails: + `class org.apache.hadoop.fs.obs.OBSFileSystem cannot be cast to class org.apache.hadoop.fs.FileSystem` + (`OBSFileSystem` in loader `'app'`, `FileSystem` in the plugin `ChildFirstClassLoader`). + 1 test (paimon_base_filesystem). + +# Root Cause + +Verified directly against the built zip + `output/fe/lib` + `mvn dependency:tree`: + +- **A:** the plugin bundles `hadoop-aws-3.4.2` + `s3-2.29.52` + `sdk-core-2.29.52` but NOT + `s3-transfer-manager`. The SPI resource `software/amazon/awssdk/services/s3/execution.interceptors` + (listing `software.amazon.awssdk.transfer.s3.internal.ApplyUserAgentInterceptor`) lives ONLY in + `s3-transfer-manager.jar` — `s3.jar` carries none. The plugin's child-first `sdk-core` + `ClasspathInterceptorChainFactory` finds no child copy of the resource, so + `ChildFirstClassLoader.getResources` fails open to the **parent** `s3-transfer-manager` (present in + `output/fe/lib`), loading its `ApplyUserAgentInterceptor` which implements the **parent** `sdk-core`'s + `ExecutionInterceptor` — a different `Class` than the child's → `isAssignableFrom` fails → + `SdkClientException` → S3A unusable → paimon FileIO fallback fails → "no file io for scheme s3" → + catalog init throws → swallowed at `buildDbForInit():914` → empty db list → "Unknown database". +- **B:** the plugin does NOT bundle `hadoop-huaweicloud`, so `OBSFileSystem` resolves from the parent + `'app'` classpath while the plugin's `FileSystem` is child-first → cross-loader `ClassCastException`. + Exactly the same shape the hadoop-aws bundling already fixed for `s3a`. + +# Design + +Complete the self-contained bundle (the documented RC-3 strategy; the pom comment even says +"STS/assumed-role would need `software.amazon.awssdk:sts` added the same way"). Two one-dependency +additions to `fe/fe-connector/fe-connector-paimon/pom.xml`; the assembly (`plugin-zip.xml`) bundles all +compile/runtime deps except its explicit excludes, so no descriptor change is needed. + +- **A:** add `software.amazon.awssdk:s3-transfer-manager` (BOM-managed `${awssdk.version}` = 2.29.52, + matching the bundled s3/sdk-core), child-first. Puts the `execution.interceptors` resource + + `ApplyUserAgentInterceptor` in the child loader → resolves against the child `sdk-core`. +- **B:** add `com.huaweicloud:hadoop-huaweicloud` (managed version `3.1.1-hw-46`, scope `compile`, + jackson-databind excluded — all inherited from fe-core dependencyManagement). The `-hw-46` jar is a + fat jar self-containing both `OBSFileSystem` AND the OBS SDK (`com/obs/*`, 1703 classes), so a single + child-first jar makes OBS self-consistent; no separate `esdk-obs` dep needed. + +# Implementation Plan + +1. Insert the `hadoop-huaweicloud` dep after the `hadoop-aws` block (FIX-B), with an explanatory comment + mirroring the hadoop-aws rationale. +2. Insert the `s3-transfer-manager` dep after the `apache-client` dep (FIX-A), with a comment explaining + the interceptor cross-loader skew. +3. Two independent commits (per branch convention: each RC its own commit). + +# Risk Analysis + +- `mvn dependency:tree -am` (succeeded): both resolve at the expected versions; single + `hadoop-common:3.4.2` (plugin's direct depth-1 copy wins mediation over hadoop-huaweicloud's + transitive copy → no duplicate `FileSystem`); **no new thrift** introduced (A/B are thrift-neutral; + thrift is FIX-C's concern). +- esdk-obs static collision with the parent's `esdk-obs-java-optimised` is theoretically possible but + the `-hw-46` jar self-contains its own `com.obs.*` child-first, so the child is self-consistent. + Docker-gated. +- All of A/B are **docker-gated** (`enablePaimonTest=true`): they pass local UT and the local zip-build + proves bundling, but only the docker external suite reproduces/verifies the runtime classloader paths. + +# Test Plan + +## Unit Tests +None — pure packaging. Existing fe-connector-paimon UT must stay green (no code change). + +## E2E Tests +Existing suites are the coverage; no new suite needed: +- A: external_table_p0/paimon/{test_paimon_s3, test_paimon_minio, test_paimon_schema_change, + test_paimon_char_varchar_type, test_paimon_full_schema_change, test_paimon_jdbc_catalog} + the 18 + "Unknown database" suites should go green. +- B: external_table_p0/paimon/paimon_base_filesystem (obs:// branch). + +## Local verification (pre-commit) +- `mvn dependency:tree -am` clean (done). +- `mvn -pl fe-connector/fe-connector-paimon -am package` then + `unzip -l target/doris-fe-connector-paimon.zip | grep -E 's3-transfer-manager|hadoop-huaweicloud'` + must show both jars in `lib/`. + +## Runtime gate +Docker external regression with `enablePaimonTest=true` (the real gate; cannot be reproduced locally). diff --git a/plan-doc/fix-c-hms-thrift-design.md b/plan-doc/fix-c-hms-thrift-design.md new file mode 100644 index 00000000000000..a5241b620a8315 --- /dev/null +++ b/plan-doc/fix-c-hms-thrift-design.md @@ -0,0 +1,392 @@ +# Problem + +Build 968994 (Class C). Paimon catalogs with `metastore=hive` (HMS-backed) fail at +**catalog create** with: + +``` +java.lang.NoClassDefFoundError: org/apache/thrift/transport/TFramedTransport + at java.lang.Class.getDeclaredConstructors0(Native Method) + at org.apache.paimon.hive.RetryingMetaStoreClientFactory + .constructorDetectedHiveMetastoreProxySupplier(RetryingMetaStoreClientFactory.java:199) + ... HiveClientPool ... CachedClientPool ... +``` + +Affected regression tests (docker `enablePaimonTest=true`): +- `regression-test/.../external_table_p0/paimon/test_paimon_table.groovy` + → `test_create_paimon_table` (line 44), uses a `metastore=hive` paimon catalog. +- `regression-test/.../external_table_p0/paimon/test_paimon_statistics.groovy` (line 33), + same HMS-backed catalog. + +`test_paimon_jdbc_catalog.groovy` uses `metastore=jdbc` and is **not** affected (no Thrift +metastore client involved). + +--- + +# Root Cause + +## The two thrift consumers that share one plugin classloader + +The paimon plugin (`fe/plugins/connector/paimon/lib/*.jar`) is loaded by +`org.apache.doris.common.util.ChildFirstClassLoader`. That loader is **purely child-first +with no parent-first allowlist** (verified — `ChildFirstClassLoader.loadClass` always tries +`findClass` over the plugin jars first for *every* class, and only delegates to the parent on +`ClassNotFoundException`). A class therefore resolves parent-first **only if it is absent from +the plugin lib**. + +Two code paths in the same plugin both want package `org.apache.thrift.*`: + +1. **doris-gen Thrift serialization (RC-1 path).** `PaimonScanPlanProvider` calls + `org.apache.thrift.TSerializer.serialize()` on `TFileScanRangeParams`, which implements the + host fe-thrift **0.16.0** `org.apache.thrift.TBase`. This must resolve **parent-first** + against the host `fe/lib/libthrift-0.16.0.jar` so the `TSerializer`, `TBase`, and the + doris-gen type all come from one loader. RC-1 (commit `f5b787c5f15`) fixed an + `IncompatibleClassChangeError` here by **excluding** `org.apache.thrift:libthrift` from the + plugin (pom exclusion + `plugin-zip.xml` exclude), so `org.apache.thrift.TBase` is absent + from the plugin and falls through to the parent 0.16.0. + +2. **The paimon HMS Thrift metastore client (the failing path).** For `metastore=hive`, + paimon's `org.apache.paimon.hive.RetryingMetaStoreClientFactory` reflectively enumerates + the constructors of `org.apache.hadoop.hive.metastore.HiveMetaStoreClient` + (`Class.getDeclaredConstructors0` at `RetryingMetaStoreClientFactory.java:199`). The bundled + `hive-metastore-2.3.7.jar`'s `HiveMetaStoreClient.class` references the **thrift-0.9.x + package** `org.apache.thrift.transport.TFramedTransport` in its constructor/method + signatures. Resolving those signatures forces the JVM to load `TFramedTransport`. + +## Why TFramedTransport is missing + +- Host `fe/lib/libthrift-0.16.0.jar` moved `TFramedTransport` to a **new package**: + it contains only `org/apache/thrift/transport/layered/TFramedTransport.class`. The + **old** `org/apache/thrift/transport/TFramedTransport.class` is **absent** (verified). +- The plugin lib (verified contents) bundles `paimon-hive-connector-3.1-1.3.1.jar` and + `hive-metastore-2.3.7.jar` (whose `HiveMetaStoreClient.class` references the old-package + `org/apache/thrift/transport/TFramedTransport`) but bundles **no libthrift** at all. +- So `TFramedTransport` is absent from the plugin (→ delegate to parent) **and** absent from + the parent 0.16.0 (moved package) → `NoClassDefFoundError`. + +## Why the current RC-5 bundling does not help, and why the obvious "just add old libthrift" does not work + +The current state (RC-5, `7841830809b`) bundles raw `hive-metastore-2.3.7.jar` + +`hive-common-2.3.9.jar` + raw `paimon-hive-connector-3.1-1.3.1.jar` with **original-package** +`org.apache.thrift.*` references throughout (verified: `CachedClientPool` references +`org.apache.thrift.TException`; `HiveMetaStoreClient` references +`org.apache.thrift.transport.TFramedTransport`). + +You cannot satisfy both consumers at the original package in one loader: +- Bundling old `libthrift-0.9.3` (original package) into the plugin would supply + `TFramedTransport` and fix the HMS path — **but** it would also put original-package + `org.apache.thrift.TBase` into the plugin, which now loads **child-first** and splits from + the host 0.16.0 `TBase`/doris-gen `TFileScanRangeParams` → re-introduces exactly the RC-1 + `IncompatibleClassChangeError`. This is the trap the pom comment at line ~156 names ("stays + parent-first like the other connectors"). +- Keeping libthrift parent-first (current state) means the HMS path's old-package + `TFramedTransport` is unsatisfiable. + +The conflict is structural: **one original `org.apache.thrift.*` namespace, two incompatible +versions required.** The fix must move the HMS client's thrift to a *different* package so the +two consumers stop sharing a namespace. + +## The codebase already solved this exact problem (decisive precedent) + +`org.apache.doris:hive-catalog-shade` (module pom at the doris-shade tree; verified copy at +`/mnt/disk1/yy/git/doris-shade/hive-catalog-shade/pom.xml`) uses `maven-shade-plugin` to: +- bundle `hive-metastore:3.1.3` (`HiveMetaStoreClient` at **original** hive package) **and** + `paimon-hive-connector-3.1` + `paimon-hive-common` (`paimon.version` = **1.3.1**, the exact + artifact we ship), and +- **relocate** `org.apache.thrift` → `shade.doris.hive.org.apache.thrift`. + +Verified in `hive-catalog-shade-3.1.1.jar` / `-3.1.2-SNAPSHOT.jar`: +- paimon `CachedClientPool` → `shade.doris.hive.org.apache.thrift.TException` (relocated) +- `HiveMetaStoreClient` → `shade.doris.hive.org.apache.thrift.transport.TFramedTransport` + (relocated, **present** in the jar) +- **No** original-package `org.apache.thrift.*` class anywhere in the shade jar. + +So when the shaded `HiveMetaStoreClient`'s constructors are reflected, the JVM loads +`shade.doris.hive.org.apache.thrift.transport.TFramedTransport` — which exists in the jar — and +the doris-gen `TSerializer`/`TBase` 0.16.0 path is left completely untouched (it never touches +`shade.doris.hive.*`). + +**Caveat that rules out "just depend on the existing shade as-is":** `hive-catalog-shade-3.1.1` +(the version pinned by `doris.hive.catalog.shade.version=3.1.1`) bundles **un-relocated, +original-package fastutil 6.5.x** (the fastutil relocation was only added in the unreleased +3.1.2-SNAPSHOT pom). Bundled into our **child-first** plugin, that ancient +`it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap` would shadow the modern +`fastutil(-core)` on the FE classpath → `NoSuchMethodError` (exactly the collision the paimon +pom comment at lines 135-139 already avoids by using plain `hive-common` instead of the shade). +The existing shade jar is also ~127 MB (hive-exec core + iceberg-hive-metastore + DLF), most of +which the paimon plugin does not need. + +--- + +# Design + +## Option evaluation + +### Option (b) — disable the TFramedTransport constructor probe via config — REJECTED +The decompiled `RetryingMetaStoreClientFactory` (verified bytecode) has **no flag** that skips +the constructor probe. `createClient` iterates a fixed `PROXY_SUPPLIERS` map; the +`constructorDetectedHiveMetastoreProxySupplier` entry that triggers `getDeclaredConstructors0` +is hard-wired. The `PROXY_SUPPLIERS_SHADED` map is *added* (not substituted) and only when the +target class name equals the original `HiveMetaStoreClient` name; it does not avoid loading +`HiveMetaStoreClient`'s constructor signatures. **No safe config switch exists.** Even if the +probe were skipped, the actual client instantiation still loads `TFramedTransport` at connect +time. Rejected. + +### Option (c) — lighter alternatives — REJECTED +- *Bundle old `libthrift-0.9.3` original-package*: re-breaks RC-1 (TBase split). Rejected. +- *Bundle host 0.16.0 libthrift child-first*: 0.16.0 lacks old-package `TFramedTransport`; does + not fix the HMS path and additionally risks the TBase split. Rejected. +- *Hand-relocate only `TFramedTransport`*: the metastore client transitively touches a large + thrift surface (`TSocket`, `TTransport`, `TBinaryProtocol`, `TException`, ...); partial + relocation produces an inconsistent namespace. Must relocate the whole `org.apache.thrift` + tree, which is what shading does. Rejected as a manual variant of (a). +- *Depend on the existing `hive-catalog-shade-3.1.1` artifact directly*: fastutil-6.5.x + collision (above) + 127 MB. Rejected. (Bumping the pinned shade to 3.1.2-SNAPSHOT to get the + fastutil relocation is possible but couples paimon to an unreleased shade and still ships the + 127 MB hive-exec/iceberg payload — not preferred.) + +### Option (a) — new paimon-scoped shaded module — CHOSEN + +Create a small, paimon-dedicated shade module that bundles **only** the paimon-hive + Thrift +metastore-client closure and relocates `org.apache.thrift` (and fastutil, defensively) to a +**paimon-private prefix**. `fe-connector-paimon` depends on this shaded artifact instead of raw +`paimon-hive-connector-3.1` + `hive-metastore-2.3.7` + `hive-common`. The main module keeps +its own original-package 0.16.0 thrift path (parent-first, untouched). + +**Why a SEPARATE module and not shade `fe-connector-paimon` itself:** shading the connector +module would relocate `org.apache.thrift` *everywhere*, including +`PaimonScanPlanProvider`'s `org.apache.thrift.TSerializer` call on the host doris-gen +`TFileScanRangeParams` (which implements host 0.16.0 `org.apache.thrift.TBase`). Relocating that +to `org.apache.doris.paimon.shaded.thrift.TSerializer` while `TFileScanRangeParams` stays +`org.apache.thrift.TBase` would break serialization (no `TSerializer` for the host TBase). The +relocation must be confined to the metastore-client dependency tree, which a separate shade +module achieves cleanly. (This is the same reason `hive-catalog-shade` is its own module rather +than shading fe-core.) + +## Module: `fe-connector-paimon-hive-shade` + +Location: `fe/fe-connector/fe-connector-paimon-hive-shade/` (sibling of +`fe-connector-paimon`). Registered in `fe/fe-connector/pom.xml` `` **before** +`fe-connector-paimon` (build-order: the connector depends on it). + +Coordinates: `org.apache.doris:fe-connector-paimon-hive-shade:${revision}`, packaging `jar`. + +**Relocation prefix:** `org.apache.doris.paimon.shaded.thrift` +(distinct from `shade.doris.hive.org.apache.thrift` so it never collides with a parent-first +hive-catalog-shade should both ever coexist; paimon-private). + +**Bundled (shaded-in) deps:** +- `org.apache.paimon:paimon-hive-connector-3.1:${paimon.version}` (1.3.1) — supplies + `org.apache.paimon.hive.HiveCatalogFactory`, `HiveCatalog`, `RetryingMetaStoreClientFactory`, + `CachedClientPool`, etc. +- `org.apache.hive:hive-metastore:2.3.7` (current RC-5 version, with the same server-side + exclusions already in the connector pom: datanucleus/derby/bonecp/HikariCP/jdo/hbase/tephra, + the stale hadoop-2.7.2 trio, guava, protobuf, logback/log4j12). The 2.3.7 `HiveMetaStoreClient` + is the one whose `TFramedTransport` reference must be relocated. +- `org.apache.hive:hive-common:${hive.common.version}` (2.3.9) — supplies `HiveConf`. Bundling + it here (instead of separately in the connector) keeps `HiveConf`, `HiveMetaStoreClient`, and + paimon's factory **one consistent hive version (2.3.x)** inside one artifact, so the + reflective `getProxy(HiveConf, ...)` / constructor signatures match by class identity. +- libfb303 rides transitively (paimon/hive metastore need it). +- `org.apache.thrift:libthrift:0.9.3` — **bundled and relocated**. This is the source of + old-package `TFramedTransport`; after relocation it becomes + `org.apache.doris.paimon.shaded.thrift.transport.TFramedTransport`, matching the relocated + references in the shaded `HiveMetaStoreClient`/paimon classes. (libthrift's transitive + `httpcore`/`httpclient` go to `provided`/excluded as hive-catalog-shade does.) + +**Provided / excluded (NOT shaded in)** — resolved at runtime from the plugin's own child-first +lib or the host (must NOT be duplicated/relocated): +- `org.apache.hadoop:*` (hadoop-common / hadoop-client-api / hadoop-aws already bundled in the + connector plugin; `Configuration`/`HiveConf`-vs-`Configuration` identity stays with the + plugin's hadoop) → `org.apache.hadoop:*` in `artifactSet`. +- `org.apache.paimon:paimon-core` / `paimon-common` / `paimon-format` → **excluded** from the + shade (they come from the connector plugin; paimon-core must stay one copy). Only + `paimon-hive-connector-3.1` (the hive-metastore glue) is shaded here. +- `org.slf4j:*`, `org.apache.logging.log4j:*`, `commons-logging:*` → excluded (host). +- `com.google.guava:*`, `com.google.protobuf:*` → excluded (host/plugin). +- `org.apache.commons:*`, `commons-io:*`, `commons-codec:*` → excluded (host/plugin). + +**Relocations:** +```xml + + org.apache.thrift + org.apache.doris.paimon.shaded.thrift + + + + it.unimi.dsi.fastutil + org.apache.doris.paimon.shaded.fastutil + +``` +(`createDependencyReducedPom>false` is fine for an internal artifact; add the standard +`META-INF/*.SF|DSA|RSA` + `META-INF/maven/**` filter as hive-catalog-shade does.) + +**Crucially do NOT relocate** `org.apache.paimon.*` (paimon classes stay at their real +package so the connector's SPI discovery of `org.apache.paimon.hive.HiveCatalogFactory` and the +`Catalog`/`HiveCatalog` types still line up with `paimon-core`) and **do NOT relocate** +`org.apache.hadoop.*` (so the shaded `HiveMetaStoreClient`'s `Configuration`/`HiveConf` are the +same classes the plugin's hadoop-common + this module's hive-common define). + +## How `fe-connector-paimon` changes + +In `fe/fe-connector/fe-connector-paimon/pom.xml`: +- **Remove** the raw `org.apache.paimon:paimon-hive-connector-3.1` dependency (lines 82-85). +- **Remove** the raw `org.apache.hive:hive-metastore:2.3.7` dependency block (lines 159-192) + including its long exclusion list. +- **Remove** the raw `org.apache.hive:hive-common` dependency (lines 140-143) — `HiveConf` now + comes (relocated-thrift-free, hive-2.3.9) from the shade module. +- **Add** `org.apache.doris:fe-connector-paimon-hive-shade:${project.version}`. +- Keep the `org.apache.thrift:libthrift` in both the pom (n/a now — no + hive-metastore dep to exclude from) and **keep** the `plugin-zip.xml` exclude of + `org.apache.thrift:libthrift` and `org.apache.doris:fe-thrift` (unchanged — the doris-gen + TBase path still needs parent-first 0.16.0). The shade module carries its thrift relocated, so + there is no original-package `org.apache.thrift.*` introduced into the plugin by this change. + +`plugin-zip.xml` already bundles all non-excluded runtime deps into `lib/`, so the new shade jar +lands in `fe/plugins/connector/paimon/lib/` automatically. + +## Interaction with RC-1 (the TBase split) — preserved + +The plugin after this change contains: +- `org.apache.thrift.*` (the doris-gen serialization namespace): **absent** from the plugin + (libthrift still excluded) → resolves parent-first to host 0.16.0. `PaimonScanPlanProvider`'s + `TSerializer.serialize(TFileScanRangeParams)` keeps working. ✅ +- `org.apache.doris.paimon.shaded.thrift.*` (the HMS client namespace): present in the shade + jar, loaded child-first, self-consistent (paimon hive + HiveMetaStoreClient + libthrift 0.9.3 + all relocated to it). The doris-gen path never references this namespace. ✅ + +No original-package `org.apache.thrift.*` is added to the plugin → **RC-1 cannot regress.** + +--- + +# Implementation Plan + +1. **Create module** `fe/fe-connector/fe-connector-paimon-hive-shade/pom.xml`: + - parent `org.apache.doris:fe-connector:${revision}`, artifactId + `fe-connector-paimon-hive-shade`, packaging `jar`. + - dependencies: `paimon-hive-connector-3.1` (`${paimon.version}`, exclude hadoop-common/hdfs, + hive-metastore [we pin 2.3.7 ourselves], jackson-yaml, httpclient5, RoaringBitmap — mirror + the existing connector exclusions), `hive-metastore:2.3.7` (server-side exclusions as in the + current connector pom lines 163-191), `hive-common:${hive.common.version}`, + `libthrift:0.9.3`. Mark `hadoop-common`, `paimon-core`, slf4j/log4j as `provided`. + - `maven-shade-plugin` execution copying the hive-catalog-shade pattern: `artifactSet` + excludes (hadoop, paimon-core/common/format, guava, protobuf, slf4j, log4j, commons-*, + gson, jackson), the `META-INF` filter, and the two relocations above. +2. **Register module** in `fe/fe-connector/pom.xml` `` *before* `fe-connector-paimon`. + Add a `dependencyManagement` entry for `libthrift:0.9.3` and (if not present) + `hive-metastore:2.3.7` near the paimon entries in `fe/pom.xml`, or pin versions inline in the + shade module. +3. **Edit** `fe/fe-connector/fe-connector-paimon/pom.xml`: swap raw + paimon-hive-connector/hive-metastore/hive-common for the shade dependency (above). +4. **No production Java change.** `PaimonCatalogFactory.buildHmsHiveConf/buildDlfHiveConf` use + only `new HiveConf()` + `HiveConf.set(k,v)` (verified) — version-agnostic; the relocation is + transparent to that code because paimon (`org.apache.paimon.*`) and hadoop/hive + (`org.apache.hadoop.hive.conf.HiveConf`) packages are *not* relocated. +5. **Build + unzip verification** (see Test Plan). +6. **Docker external suite** (`enablePaimonTest=true`) is the real gate. + +Files touched: +- NEW `fe/fe-connector/fe-connector-paimon-hive-shade/pom.xml` +- `fe/fe-connector/pom.xml` (`` + version mgmt) +- `fe/fe-connector/fe-connector-paimon/pom.xml` (dependency swap) +- possibly `fe/pom.xml` (dependencyManagement for libthrift 0.9.3 / hive-metastore 2.3.7) + +--- + +# Risk Analysis + +1. **Thrift 0.9.3 vs host 0.16.0 wire handshake (already flagged by RC-5).** The metastore + client now speaks Thrift **0.9.3** to the CI docker HMS. HMS's TBinaryProtocol/TSocket wire + format is stable across 0.9.x↔0.16 for the metastore RPCs in practice, and the legacy fe-core + path already used a 2.3.x metastore client over an old thrift against the same docker HMS — so + this is the same wire version legacy shipped, not a new risk introduced here. **Not statically + provable; gated by the docker paimon suite.** (Identical caveat to the RC-5 comment.) + +2. **Relocation must not break `RetryingMetaStoreClientFactory`'s reflection.** The factory + reflects on hive classes by **original** name (`HiveMetaStoreClient`, + `RetryingMetaStoreClient`, `HiveMetaHookLoader`, `HiveConf`) — these are **not** relocated, so + `Class.forName`/`getMethod("getProxy", ...)` still match. The thrift classes it touches only + transitively (via `HiveMetaStoreClient` constructor signatures) **are** relocated, **and** the + shaded `HiveMetaStoreClient` bytecode references the **same** relocated names (verified in + hive-catalog-shade that shade rewrites both consistently). Maven-shade rewrites bytecode + references and signatures together, so the relocation is internally consistent. **Low risk**, + backed by the working hive-catalog-shade precedent that ships the identical paimon 1.3.1 + + metastore + relocated-thrift combination. + +3. **DLF `ProxyMetaStoreClient` path** (`PaimonCatalogFactory:428` sets + `metastore.client.class = com.aliyun.datalake.metastore.hive2.ProxyMetaStoreClient`). The DLF + client is **not** in this shade module (it lives in `metastore-client-hive3` / DLF SDK, not + bundled in the paimon plugin today either). DLF was already a cutover-gated unknown (pom NOTE + lines 175-182). This fix does not regress DLF, but **does not add DLF either** — DLF remains + gated by live-e2e and is out of scope for the HMS `NoClassDefFound` fix. Flag for the DLF + ticket: when DLF is wired, its `ProxyMetaStoreClient` references original-package + `org.apache.thrift.*`; it would need to be relocated together (added to this shade module's + artifactSet) to stay consistent. + +4. **Fastutil collision** — neutralized by the defensive `it.unimi.dsi.fastutil` relocation in + this module (the reason we build a paimon-scoped shade instead of reusing + hive-catalog-shade-3.1.1 which ships un-relocated fastutil). + +5. **Two paimon-hive copies?** The shade jar contains `org.apache.paimon.hive.*` (not relocated). + The connector plugin must NOT also carry a raw `paimon-hive-connector-3.1.jar` (we remove that + dep in step 3). Verify post-build that `paimon-hive-connector-3.1-*.jar` is **gone** from + `lib/` and only the shade jar provides `org.apache.paimon.hive.*` — otherwise a child-first + duplicate-class hazard. (paimon-**core** stays as its own jar; the shade excludes it.) + +6. **HiveConf class identity across the plugin.** The shade bundles hive-common 2.3.9 `HiveConf`; + the connector's `buildHmsHiveConf` constructs `new HiveConf()` resolved child-first from the + shade. Because both the `HiveConf` instance and the `getProxy(HiveConf,...)` signature come + from the same (shaded) hive-2.3.9, identity matches. **Low risk**; verify no second + `org.apache.hadoop.hive.conf.HiveConf` remains in `lib/` after removing the raw hive-common + dep. + +--- + +# Test Plan + +## Unit Tests + +- The existing `fe-connector-paimon` UTs (`PaimonCatalogFactoryTest`, the offline + `PaimonTableSerdeRoundTripTest`, the 46-test suite referenced in CI 968880) must still pass + unchanged. They exercise `buildHmsHiveConf`/`buildDlfHiveConf` (HiveConf assembly), flavor + resolution, and the FE→BE serde round-trip — the last one transitively exercises the + **doris-gen TSerializer (0.16.0) path** that RC-1 protects, so a green round-trip test is the + unit-level guard that the shade change did not re-split `TBase`. No new UT is needed: the + failure is a packaging/classloader fault that **cannot reproduce in a single-classloader UT** + (the whole point of the RC-5/RC-1 lineage — these bugs only surface under the docker + plugin-zip child-first loader). +- Run: `mvn -pl fe/fe-connector/fe-connector-paimon -am test` (the `-am` is required, per the + repo's `${revision}` gotcha, to also build the new shade module). + +## E2E Tests + +**Static jar verification (proves the class is now reachable, before docker):** +1. `mvn -pl fe/fe-connector/fe-connector-paimon-hive-shade,fe/fe-connector/fe-connector-paimon + -am package` +2. Assert the relocated class is present in the shade jar: + `unzip -l .../fe-connector-paimon-hive-shade/target/*.jar | grep + 'org/apache/doris/paimon/shaded/thrift/transport/TFramedTransport.class'` → must be **1 hit**. +3. Assert the shaded `HiveMetaStoreClient` references the relocated name: + `unzip -p .../shade.jar org/apache/hadoop/hive/metastore/HiveMetaStoreClient.class | strings | + grep 'paimon/shaded/thrift/transport/TFramedTransport'` → must hit (and the original + `org/apache/thrift/transport/TFramedTransport` must **not** appear). +4. Assert **no** original-package `org/apache/thrift/` class in the final plugin zip's `lib/` + except none-at-all (libthrift still excluded): + `unzip -l .../doris-fe-connector-paimon.zip | grep -E 'lib/.*(libthrift|paimon-hive-connector-3.1-)'` + → **no** raw `paimon-hive-connector-3.1` jar, **no** libthrift jar; the shade jar present. +5. Assert paimon-core is still a single jar and `org.apache.paimon.hive.*` is provided only by + the shade. + +**Docker external suite (the real gate, `enablePaimonTest=true`):** +- `external_table_p0/paimon/test_paimon_table.groovy::test_create_paimon_table` (line 44) — the + `metastore=hive` create that currently throws `NoClassDefFoundError`. Must create the catalog + and pass. +- `external_table_p0/paimon/test_paimon_statistics.groovy` (line 33) — same HMS catalog + + ANALYZE/statistics read. +- Regression-only sanity that the non-HMS flavors still work and RC-1 did not regress: + `test_paimon_jdbc_catalog.groovy` (jdbc), and any filesystem/REST paimon read suite (exercises + `PaimonScanPlanProvider` → the doris-gen 0.16.0 TSerializer path) must stay green. + +This bug class is **docker-plugin-zip-only**; local UTs and a single-loader run cannot catch it, +so a green docker `enablePaimonTest=true` run on these two suites (plus an unbroken jdbc/scan +suite) is the acceptance gate. diff --git a/plan-doc/fix-e-explain-gap-design.md b/plan-doc/fix-e-explain-gap-design.md new file mode 100644 index 00000000000000..6be1fc6ad9c414 --- /dev/null +++ b/plan-doc/fix-e-explain-gap-design.md @@ -0,0 +1,330 @@ +# Problem + +Build 968994 (branch `catalog-spi-07-paimon`), 5 paimon regression tests fail with +`IllegalStateException: Explain and check failed`. The catalogs load, every `qt_*` +data query passes, and the COUNT values are correct — only the EXPLAIN string is +missing lines that the legacy `org.apache.doris.datasource.paimon.source.PaimonScanNode` +emitted. All 5 assertions are inline `explain { contains "..." }` checks (NOT `.out`-backed). + +| # | test (file:line) | expected `contains` | actual plan has | +|---|---|---|---| +| 1 | `test_paimon_count.groovy:51` | `pushdown agg=COUNT (12)` | `VPluginDrivenScanNode` + a normal `VAGGREGATE count(*)`, NO `pushdown agg` line | +| 2 | `test_paimon_deletion_vector.groovy:54` | `pushdown agg=COUNT (-1)` | same — no `pushdown agg` line | +| 3 | `test_paimon_deletion_vector_oss.groovy:57` (VERBOSE) | `deleteFileNum` | no `dataFileNum/deleteFileNum/deleteSplitNum` block | +| 4 | `test_paimon_catalog_varbinary.groovy:44` (force_jni) | `paimonNativeReadSplits=0/1` | no `paimonNativeReadSplits` line | +| 5 | `test_paimon_catalog_timestamp_tz.groovy:37` (force_jni) | `paimonNativeReadSplits=0/1` | no `paimonNativeReadSplits` line | + +The actual explain bodies (from `/mnt/disk1/yy/tmp/64445_..._external/doris-regression-test.20260613.165803.log`) +show `VPluginDrivenScanNode(NN)` with `TABLE`/`CONNECTOR: paimon`/`partition=0/0` but none of the four +line families above. `count(*)` is served by a regular VAGGREGATE (correct rows; the FE `pushdown agg` +display is just absent). + +These 5 are GENUINELY display-only — see Risk Analysis §"Real regression vs display gap": 4 catalogs are +`hdfs://` filesystem warehouses, the 5th is `oss://` (jindo bundled). None touch the broken s3/obs/hms +packaging classes; the oss test already ran `qt_1..qt_6` reads before the explain assertion, proving its +catalog loads and reads work. + +# Root Cause + +`PluginDrivenScanNode.getNodeExplainString(prefix, detailLevel)` +(`fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenScanNode.java:228-280`) +is a **full override that does NOT call `super.getNodeExplainString`** +(`FileScanNode.getNodeExplainString`, `fe/fe-core/.../datasource/FileScanNode.java:129-245`). +It hand-rolls the `TABLE` / `CONNECTOR` / `QUERY` / `PREDICATES` / `partition=N/M` lines and then +delegates to `scanProvider.appendExplainInfo(output, prefix, props)` (line 264). Because it never calls +super, it silently drops every FileScanNode-produced line. This is the documented +`catalog-spi-plugindriven-explain-override-gap` pattern, now re-manifested for paimon's four extra lines. + +The four missing line families and their legacy producers: + +1. **`pushdown agg=COUNT (n)`** — produced by `FileScanNode.java:227-232`: + ``` + output.append(prefix).append(String.format("pushdown agg=%s", pushDownAggNoGroupingOp)); + if (pushDownAggNoGroupingOp.equals(TPushAggOp.COUNT)) { + output.append(" (").append(getPushDownCount()).append(")"); + } + ``` + `getPushDownCount()` returns the field `tableLevelRowCount` (default `-1`), set only via + `FileScanNode.setPushDownCount(long)` (`:102-104`). Legacy `PaimonScanNode.getSplits` calls + `setPushDownCount(pushDownCountSum)` (`PaimonScanNode.java:492`) when count pushdown produces a sum. + `PluginDrivenScanNode` NEVER calls `setPushDownCount` — it forwards `countPushdown` to the provider + (`:571-574`) but the provider computes `countSum` internally and emits it only per-range via the BE-bound + `paimon.row_count` property (`PaimonScanRange` builder → `formatDesc.setTableLevelRowCount`). The FE + node's `tableLevelRowCount` stays `-1`. So even if super were called, count tables would print `(-1)`. + - Expected `(12)`/`(8)` = real precomputed merged-count sums (append/merge tables); `.out` count + results confirm 12 and 8. + - Expected `(-1)` (deletion_vector tables) = NO precomputed merged count → no count-range emitted → + `tableLevelRowCount` stays `-1` → BE counts by reading; `.out` confirms the query returns 3. + +2. **`dataFileNum=, deleteFileNum=, deleteSplitNum=`** — produced by `FileScanNode.java:209-212`, but + gated `detailLevel == VERBOSE && !isBatchMode()` (`:151`). The per-BE loop calls + `getDeleteFiles(fileRangeDesc)` (`:179`); the base `FileScanNode.getDeleteFiles` returns `emptyList` + (`:123-127`). Legacy `PaimonScanNode` OVERRIDES `getDeleteFiles` (`PaimonScanNode.java:337-357`) to read + `TPaimonFileDesc.getDeletionFile().getPath()` from the thrift range. `PluginDrivenScanNode` does NOT + override `getDeleteFiles`, so even with super, `deleteFileNum` would always be 0. Test #3 uses + `verbose(true)` and just asserts the literal substring `deleteFileNum` exists. + +3. **`paimonNativeReadSplits=/`** — paimon-specific, produced by the legacy override + `PaimonScanNode.getNodeExplainString:656-658`: + ``` + sb.append(String.format("%spaimonNativeReadSplits=%d/%d\n", + prefix, rawFileSplitNum, (paimonSplitNum + rawFileSplitNum))); + ``` + `rawFileSplitNum` (native ORC/Parquet sub-splits) and `paimonSplitNum` (JNI + non-DataSplit + count + splits) are accumulated in `PaimonScanNode.getSplits` (`:393,465,477`). In the SPI path, split + classification (native vs JNI vs count) happens INSIDE `PaimonScanPlanProvider.planScanInternal` + (`PaimonScanPlanProvider.java:288-439`); the node only receives the resulting `List`. + `PaimonScanPlanProvider` has NO `appendExplainInfo` override and tracks no counts. The counts must be + re-derived by the node from the returned ranges. Under `force_jni_scanner=true` (tests #4/#5), the + native arm is never taken → `rawFileSplitNum=0`, exactly one JNI split → `0/1`. + +4. **`predicatesFromPaimon:` / `PaimonSplitStats:`** — also emitted by the legacy override + (`PaimonScanNode.getNodeExplainString:660-687`). NOT asserted by any of the 5 failing tests, so out of + scope (but see Risk §"completeness" — re-emitting them is harmless and improves parity). + +**Ordering note (verified):** `FileQueryScanNode.finalizeForNereids` (`:236`) → `createScanRangeLocations` +(`:312`) → `getSplits(numBackends)` (`:415`) runs during planning, BEFORE explain renders. So the node can +accumulate counts in `getSplits` and read them in `getNodeExplainString`, exactly as legacy does. + +# Design + +The fix re-emits the four line families from `PluginDrivenScanNode`, paimon-gated so other plugin +connectors (es / jdbc / trino-connector / max_compute — `CatalogFactory.SPI_READY_TYPES`) are byte-unchanged. + +Three deltas, all in `PluginDrivenScanNode` plus one tiny SPI seam: + +### Change A — call `super` for the FileScanNode line families, behind a flag + +Do NOT blanket-call `super.getNodeExplainString` (it would also emit `table:`, `inputSplitNum=`, +`numNodes=`, etc., perturbing the existing custom `TABLE`/`CONNECTOR`/`QUERY`/`PREDICATES`/`partition=` +format that maxcompute/es golden assertions match). Instead, **selectively re-emit** the two +FileScanNode line families that are paimon-asserted, keeping the existing custom header: + +- **`pushdown agg=COUNT (n)`**: after the connector `appendExplainInfo` call, emit + ``` + output.append(prefix).append("pushdown agg=").append(getPushDownAggNoGroupingOp()); + if (getPushDownAggNoGroupingOp() == TPushAggOp.COUNT) { + output.append(" (").append(getTableLevelRowCountForExplain()).append(")"); + } + output.append("\n"); + ``` + This line is connector-agnostic and safe to emit for ALL plugin connectors (it mirrors what + FileScanNode prints for every other scan node — its absence on plugin nodes is itself an inconsistency). + **No gating needed** for the `pushdown agg` line; it is universally correct. + - Requires the count value to reach the node. See Change C. + +- **`dataFileNum/deleteFileNum/deleteSplitNum`** (VERBOSE block): this is the expensive per-BE loop in + `FileScanNode:151-213`. Rather than duplicate ~60 lines, factor the VERBOSE per-BE block out of + `FileScanNode.getNodeExplainString` into a `protected` helper + `appendBackendScanRangeDetail(StringBuilder, prefix)` and call it from `PluginDrivenScanNode` under the + same `detailLevel == VERBOSE && !isBatchMode()` gate. (Surgical alternative if extraction is undesirable: + copy the block; but extraction avoids drift and is preferred by Rule 3's "don't fork" reading.) The block + calls `getDeleteFiles(rangeDesc)` — see Change B for the plugin override. + +### Change B — `getDeleteFiles` override on the plugin node, via a generic seam + +`PaimonScanRange.populateRangeParams` already sets `TPaimonFileDesc.setDeletionFile(...)` on the thrift +range from the `paimon.deletion_file.path` property. The deletion-file path is therefore present in the +serialized `TFileRangeDesc` at explain time. Override `getDeleteFiles(TFileRangeDesc rangeDesc)` on +`PluginDrivenScanNode` to read it. + +Two options for keeping it generic (the node is shared): +- **Option B1 (preferred):** add a default SPI hook + `ConnectorScanPlanProvider.getDeleteFiles(TFileRangeDesc) -> List` returning `emptyList()` by + default; `PaimonScanPlanProvider` overrides it to read `TTableFormatFileDesc.getPaimonParams() + .getDeletionFile().getPath()` (a verbatim port of legacy `PaimonScanNode.getDeleteFiles:337-357`). The + node's override delegates to `connector.getScanPlanProvider().getDeleteFiles(rangeDesc)`. This keeps the + thrift-shape knowledge in the paimon connector and leaves es/jdbc/mc returning empty (no `deleteFileNum` + change — though their VERBOSE block still won't print unless they also opt in, see gating below). +- **Option B2 (rejected):** read `TPaimonFileDesc` directly in fe-core's `PluginDrivenScanNode`. Rejected: + bakes paimon thrift knowledge into the shared node, and the legacy fe-core PaimonScanNode already imports + `TPaimonDeletionFileDesc`/`TPaimonFileDesc` so the precedent exists, but B1 is cleaner for the SPI. + +### Change C — thread the count-pushdown sum back to the node + +`PaimonScanPlanProvider` already encodes the summed count on the single collapsed count range as the +`paimon.row_count` property (`PaimonScanRange` builder `.rowCount(...)` → `props["paimon.row_count"]`, +consumed BE-side by `populateRangeParams:202-205`). In `PluginDrivenScanNode.getSplits`, after building the +`PluginDrivenSplit`s, scan the ranges and, if `countPushdown` is active, read `paimon.row_count` from the +range properties and call `setPushDownCount(sum)`. Generic implementation (no paimon import): +``` +if (countPushdown) { + for (ConnectorScanRange r : ranges) { + String rc = r.getProperties().get("paimon.row_count"); // generic key lookup + if (rc != null) { setPushDownCount(Long.parseLong(rc)); break; } + } +} +``` +For deletion_vector tables no count range is emitted → no `paimon.row_count` → `tableLevelRowCount` stays +`-1` → `pushdown agg=COUNT (-1)`. Correct. + +The property key `paimon.row_count` is paimon-specific but harmless to look up generically (absent for +other connectors). To avoid hard-coding a paimon key in the shared node, optionally promote it to a generic +`ConnectorScanRange` getter `getPushDownRowCount() -> long (default -1)` that `PaimonScanRange` overrides +from its `rowCount` field; the node reads `r.getPushDownRowCount()`. **Preferred:** the generic getter, to +keep the shared node connector-agnostic (consistent with Rule 11). + +### Change D — accumulate native/jni split counts and emit `paimonNativeReadSplits` + +`paimonNativeReadSplits` is intrinsically paimon-specific. Emit it from the connector via a NEW +`appendExplainInfo` override on `PaimonScanPlanProvider` — BUT `appendExplainInfo` only receives the +`nodeProperties` map, NOT the per-scan split counts (those are computed in `planScan`, after +`getScanNodeProperties`). So the counts must be threaded through the node. + +Chosen approach: classify ranges in `PluginDrivenScanNode.getSplits` (where ranges are already iterated) +and stash counts, then emit via the connector's `appendExplainInfo` by passing them through a small +augmented props map, OR — simpler and matching legacy — have the connector own the string but feed it the +counts. Concretely: + +- A native range = `ConnectorScanRange` whose `getRangeType()` is `FILE_SCAN` with a `getPath()` present + and NO `paimon.split` property (paimon native sub-splits set `path`/`fileFormat`, no `paimon.split`). +- A jni/count range = has the `paimon.split` property. + +Cleanest generic seam: add `ConnectorScanRange.isNativeReadRange() -> boolean (default false)` that +`PaimonScanRange` overrides (`true` when `paimon.split == null && path != null`). In +`PluginDrivenScanNode.getSplits`, after building splits, compute +`nativeCount = count(isNativeReadRange)` and `totalCount = ranges.size()`, store in two node fields +(`int rawFileSplitNum`, `int totalSplitNum` — generic names; or a single `scanRangeReadStats` map). Then in +`getNodeExplainString`, pass these to the connector via `appendExplainInfo`. Since `appendExplainInfo`'s +signature is `(StringBuilder, String prefix, Map nodeProperties)`, thread the counts by +**adding them into a copy of the props map** the node passes to `appendExplainInfo` (e.g. +`__native_read_splits` / `__total_read_splits` synthetic keys), and have `PaimonScanPlanProvider +.appendExplainInfo` read them and emit `paimonNativeReadSplits=raw/total`. This keeps the paimon string in +the paimon connector and needs no SPI signature change. + +**Gating:** `appendExplainInfo` is already connector-dispatched (only the active connector's provider runs), +so `paimonNativeReadSplits` is emitted ONLY for paimon. es/jdbc/mc providers do not emit it. The +synthetic count keys are injected by the shared node for ALL connectors but consumed only by paimon's +override — no other connector reads them, no perturbation. + +**Summary of emission sites:** +| line | emitted in | gating | +|---|---|---| +| `pushdown agg=COUNT (n)` | `PluginDrivenScanNode.getNodeExplainString` (new) | none — universally correct | +| `dataFileNum/deleteFileNum/deleteSplitNum` | `PluginDrivenScanNode.getNodeExplainString` via extracted `FileScanNode` helper, VERBOSE-gated; `getDeleteFiles` via SPI | VERBOSE level; non-paimon return empty delete list | +| `paimonNativeReadSplits=raw/total` | `PaimonScanPlanProvider.appendExplainInfo` (new) | connector-dispatched (paimon only) | +| count sum (`-1` default) | node field `tableLevelRowCount` set in `getSplits` from `ConnectorScanRange.getPushDownRowCount()` | only set when a count range carries it | +| native/total split counts | node fields set in `getSplits` from `ConnectorScanRange.isNativeReadRange()` | generic; consumed only by paimon's appendExplainInfo | + +# Implementation Plan + +1. **SPI (`fe-connector-api/.../scan/ConnectorScanRange.java`)**: add two default methods: + `default long getPushDownRowCount() { return -1; }` and + `default boolean isNativeReadRange() { return false; }`. +2. **SPI (`ConnectorScanPlanProvider.java`)**: (Option B1) add + `default List getDeleteFiles(TTableFormatFileDesc tableFormatParams) { return emptyList(); }`. +3. **`PaimonScanRange.java`**: override `getPushDownRowCount()` (return the `rowCount` field, else -1) and + `isNativeReadRange()` (`paimonSplit == null && path != null`). +4. **`PaimonScanPlanProvider.java`**: + - override `appendExplainInfo(output, prefix, props)`: read the two synthetic count keys the node + injects and emit `paimonNativeReadSplits=/`; optionally also re-emit + `predicatesFromPaimon:` (needs predicates — already serialized in `paimon.predicate`, decode or + skip — out of scope for the 5 tests). + - override `getDeleteFiles(TTableFormatFileDesc)`: verbatim port of legacy + `PaimonScanNode.getDeleteFiles` reading `getPaimonParams().getDeletionFile().getPath()`. +5. **`FileScanNode.java`**: extract lines 151-213 (the `VERBOSE && !isBatch` per-BE block) into + `protected void appendBackendScanRangeDetail(StringBuilder output, String prefix)`; call it from the + existing `getNodeExplainString` (no behavior change for existing FileScanNode subclasses). +6. **`PluginDrivenScanNode.java`**: + - add fields `private long pushDownRowCount = -1; private int nativeReadSplitNum; private int totalReadSplitNum;` + (or reuse `setPushDownCount`). + - in `getSplits` (after building `splits`): if `countPushdown`, set `setPushDownCount(firstRowCount)`; + accumulate `nativeReadSplitNum`/`totalReadSplitNum` from `range.isNativeReadRange()`. + - override `protected List getDeleteFiles(TFileRangeDesc rangeDesc)`: delegate to + `connector.getScanPlanProvider().getDeleteFiles(rangeDesc.getTableFormatParams())` (null-guarded). + - in `getNodeExplainString` (after `appendExplainInfo`, inside the non-PassthroughQueryTableHandle + branch): inject `__native_read_splits`/`__total_read_splits` into the props passed to + `appendExplainInfo`; emit the `pushdown agg=...` line; under `VERBOSE && !isBatchMode()` call + `appendBackendScanRangeDetail`. + - **Ordering of the injected counts:** `appendExplainInfo` runs inside `getNodeExplainString`, after + `getSplits` already ran (finalize order verified), so the count fields are populated. + +# Risk Analysis + +- **Shared-node perturbation (PRIMARY risk).** `PluginDrivenScanNode` is shared by jdbc/es/trino/max_compute. + - `pushdown agg=COUNT (n)`: added for ALL plugin connectors. Verified no other connector's suite uses + `checkNotContains "pushdown agg"`; the maxcompute partition-prune suite + (`external_table_p2/maxcompute/test_max_compute_partition_prune.groovy`) only does positive + `contains "partition=N/M"` / `contains "CONNECTOR: max_compute"` — additive lines don't break `contains`. + No `.out` file captures a `VPluginDrivenScanNode` block (grep: zero hits across `regression-test/data/`), + so no golden explain shifts. + - VERBOSE `deleteFileNum` block: emitted only at VERBOSE for plugin nodes that opt into the helper. Other + connectors' `getDeleteFiles` returns empty → `deleteFileNum=0` if their VERBOSE block prints. To be + conservative, the VERBOSE block can be gated to paimon (`getCatalog().getType().equals("paimon")` — + available at `PluginDrivenScanNode.java:244`) so es/jdbc/mc VERBOSE output is byte-unchanged. **Decision: + gate the VERBOSE block to paimon** to minimize blast radius (the 3 paimon assertions are the only + consumers; the `pushdown agg` line stays ungated since it is universally correct and already standard + for every FileScanNode). + - `paimonNativeReadSplits`: connector-dispatched via `appendExplainInfo`, paimon-only by construction. +- **Value correctness (genOut risk).** CI dumped values in genOut. Verification: the `.out` count results + (`test_paimon_count.out`: append=12, merge_on_read=8, deletion_vector=3) cross-check the explain values — + `(12)`/`(8)` equal the actual counts (pushdown happened); `(-1)` is the no-precomputed-count sentinel and + the dv table still returns 3 by BE counting. `paimonNativeReadSplits=0/1` is asserted under + `force_jni_scanner=true`, where the native arm is provably skipped (`shouldUseNativeReader` returns false + when `force_jni_scanner` is set) → 0 native, 1 jni. These are semantic, not just text. See Test Plan for + the comparison-mode reruns that turn genOut into a real check. +- **Real regression vs display gap (per the brief's question).** All 5 are PURE DISPLAY gaps, NOT read-path + regressions: + - #1/#2 count: the data query (`qt_*_count`) returns the correct count via a normal VAGGREGATE; only the + FE `pushdown agg` display line is absent. The count-pushdown OPTIMIZATION still happens BE-side + (`paimon.row_count` is emitted on the range and consumed by `populateRangeParams`); FE just doesn't + render it. (If the optimization were broken, the data result would still be correct — so the explain + line is the only signal; the comparison-mode rerun in Test Plan confirms the BE row-count path.) + - #4/#5 `paimonNativeReadSplits=0/1`: with `force_jni_scanner=true` the reader-selection is correct + (everything JNI); the count is simply not displayed. The `qt_*` reads pass. + - #3 `deleteFileNum`: the deletion vector is correctly applied BE-side (the merge-on-read `qt_*` results + pass); only the VERBOSE accounting line is missing. The deletion file IS threaded to BE + (`paimon.deletion_file.path` → `setDeletionFile`), just not surfaced in FE explain. + Conclusion: NONE of the 5 hides a real read-path regression. They are all the + `plugindriven-explain-override-gap` (no-super) class. +- **oss catalog load risk.** `test_paimon_deletion_vector_oss` uses `oss://` + `oss.endpoint`/`oss.access_key` + (jindo, bundled per `e881247857d` FIX-PAIMON-OSS-JINDO-SELFCONTAINED). The test ran `qt_1..qt_6` before the + explain assertion, so the catalog loaded and the oss reads succeeded — the explain gap is the only failure. +- **FileScanNode helper extraction.** Refactoring lines 151-213 into a protected method changes no behavior + for existing FileScanNode subclasses (Hive/Iceberg/Hudi/Tvf) — it is a pure extract-method. Verify by + running the iceberg/hive explain suites that assert `pushdown agg` (1 iceberg + 1 hive suite found). + +# Test Plan + +## Unit Tests + +New `fe-core` tests on `PluginDrivenScanNode` (Mockito, same infra as +`PluginDrivenScanNodePartitionCountTest`): +- `getNodeExplainString` with `pushDownAggNoGroupingOp = COUNT` and `setPushDownCount(12)` → output + contains `pushdown agg=COUNT (12)`; with no count set → `pushdown agg=COUNT (-1)`. +- count accumulation: feed a fake `ConnectorScanRange` list where one range has + `getPushDownRowCount()=12` under `countPushdown=true` → node's `tableLevelRowCount` == 12; with none → + stays -1. (Encodes WHY: the -1 sentinel must survive when no count range exists — Rule 9.) +- native/total accumulation: ranges with `isNativeReadRange()` mixed true/false → fields equal the counts; + all-jni (force_jni) → native=0, total=N. +- `getDeleteFiles` override delegates to the provider and returns the deletion path when + `TPaimonFileDesc.getDeletionFile()` is set; empty when unset. +- VERBOSE gating: assert the `dataFileNum/deleteFileNum/deleteSplitNum` block appears for a paimon-typed + catalog at VERBOSE and NOT for a non-paimon-typed catalog (locks the gating decision). + +New `fe-connector-paimon` tests on `PaimonScanPlanProvider` (offline, `PaimonScanPlanProviderTest` infra): +- `appendExplainInfo` with synthetic `__native_read_splits=0`/`__total_read_splits=1` → emits + `paimonNativeReadSplits=0/1`. +- `getDeleteFiles(TTableFormatFileDesc)` returns the deletion path (port of legacy + `PaimonScanNodeTest` deletion-file assertions if present). +- `PaimonScanRange.getPushDownRowCount()`/`isNativeReadRange()` for builder permutations + (paimonSplit set vs path set; rowCount set vs not). + +Run: `mvn -pl fe-core,fe-connector/fe-connector-paimon -am test` (use absolute `-f`; include `-am` to avoid +the `${revision}` resolution false error per memory `doris-build-verify-gotchas`). Checkstyle binds to the +fe-core test build — keep new tests clean. + +## E2E Tests + +Docker regression (paimon suite is `enablePaimonTest=true`-gated; NOT run locally in this design): +- Re-run the 5 suites in COMPARISON mode (not genOut) so the inline `explain { contains ... }` asserts the + re-emitted lines AND the `qt_*`/`order_qt_*` data results compare against the committed `.out`: + `test_paimon_count`, `test_paimon_deletion_vector`, `test_paimon_deletion_vector_oss`, + `test_paimon_catalog_varbinary`, `test_paimon_catalog_timestamp_tz`. + - This is the VALUE-VERIFICATION step: the `.out` count rows (12/8/3) confirm count pushdown correctness, + independent of the explain text — turning the genOut dump into a real check. +- Regression-guard the shared node: re-run the maxcompute partition-prune suite + (`external_table_p2/maxcompute/test_max_compute_partition_prune`) and any es/jdbc explain suites to + confirm `partition=N/M` / `CONNECTOR:` assertions still pass and no stray paimon lines appear. +- Run the iceberg + hive suites that assert `pushdown agg` to confirm the `FileScanNode` extract-method is + behavior-neutral. diff --git a/plan-doc/hive-catalog-shade-removal/HANDOFF.md b/plan-doc/hive-catalog-shade-removal/HANDOFF.md new file mode 100644 index 00000000000000..69f1bc830e9750 --- /dev/null +++ b/plan-doc/hive-catalog-shade-removal/HANDOFF.md @@ -0,0 +1,173 @@ +# 🤝 Session Handoff — 删除 thrift 一代 Glue/DLF ⇒ 剔除 `hive-catalog-shade` + +> **滚动文档**:每次 session 结束**覆盖式更新**,**只保留下一个 session 必须的上下文**。 +> 完成的明细**不落这里**(在 `git log` + [`progress.md`](./progress.md) 里)。 +> 空间索引 [`README.md`](./README.md) · 设计 [`design.md`](./design.md) · 清单 [`tasklist.md`](./tasklist.md) + +--- + +# 🆕 下一个 session = **阶段 6(用户可见面 + 守门)**:T-70 ~ T-74 + +> T-21(Glue session token)**已完成**(用户 2026-07-15 指定优先处理并签字)。 + +## 状态:**阶段 1–5 全部完成。127MB 的 jar 已经出了 `fe/lib`。** + +**基线 HEAD** = 见 `git log`(`catalog-spi-11-hive`)。 + +| 阶段 | commit | 结果 | +|---|---|---| +| 1 — 修 iceberg 原生 Glue 凭证类 | `2cd01ada8df` | 搬出 fe-core + 改包;probe 判据 false→**true** 实测 | +| 2 — 删 Glue thrift 一代 | `e43173eca67` | **-11,245 行**;fe-core `com.amazonaws.*` **归零** | +| 3 — 删 DLF thrift 一代 | `a0f65c353a9` | **-4,095 行**;fe-core `com.aliyun.datalake` **归零** | +| 4 — fe-core 去 hive 化 | `8d6fe9f9736` `d8c121b7f21` `22461468e7c` `7ed266c677a` | **判据 26 → 0** | +| 5 — pom 终局 | `0c35090a4f3` + 卫星项提交 | **jar 出 `fe/lib`**;fe-core 净少 38 个 jar | + +## 🎯 总判据 —— **全部达成 ✅** + +```bash +grep -rlE '^import (org\.apache\.hadoop\.hive\.|shade\.doris\.hive\.|com\.aliyun\.datalake\.)' \ + fe/fe-core/src/main/java | wc -l # 26 → 0 ✅(test 侧亦为 0) +grep -rniE 'org\.apache\.(hadoop\.)?hive' fe/fe-common/src/ # → 空 ✅ +mvn -o -f /fe/pom.xml -pl fe-core -am dependency:tree \ + -Dincludes=org.apache.doris:hive-catalog-shade # fe-core 段为空 ✅ +``` + +shade 的真依赖只剩 **4 个正当消费者**:`fe-connector-hms` · `fe-connector-iceberg` · +`avro-scanner` · `java-udf`。`fe/pom.xml` 的版本钉**必须留**。 + +--- + +## 📋 阶段 6 清单(`tasklist.md` T-70 ~ T-74) + +- **T-70 regression-test 清理**(只删 thrift 一代的,**误删比漏删严重**): + - `aws_iam_role_p0/test_catalog_with_role.groovy:82-90`(`hive.metastore.type=glue` 块)+ 死变量 `:49` + ✋ **`:73-81` 的 `iceberg.catalog.type="glue"` 保留**(那条路径阶段 1 刚修好) + - `aws_iam_role_p0/test_catalog_instance_profile.groovy:67-95`(3 块 hive-on-glue)+ 死变量 `:26-27` + ⚠️ `:22-24` 的**文件级 guard 钉在 glue 专有 conf key 上** —— 删 glue 块后须改钉 iceberg 的 key, + 否则存活的 iceberg 分支**静默永不运行** + - `external_table_p2/refactor_catalog_param/iceberg_and_hive_on_glue.groovy:369-372`(**已是死代码**) + ✋ 保留 `:367` + - ⚠️ **假阳性别碰**:`external_table_p2/hive/test_external_catalog_glue_table.groovy`(名字是陷阱, + 实为普通 HMS 且 `:20-24` 硬关)· `test_iceberg_predicate_conversion.groovy`(glue 只是列名)· + `test_s3tables_glue_*`(iceberg REST + glue signing) +- **T-71 文档 / 报错文案**:`hive.metastore.type=glue|dlf`、`iceberg.catalog.type=dlf`、 + `paimon.catalog.type=dlf` 的报错要清楚说明**已移除**(现在只说 `Unknown type`,loud 但没说「已移除」); + 仓库内 .md 同步。 +- **T-72 静态守门**:判据两条 grep · `-pl fe-core -am test-compile`(**漏 `-am` = 假错**)· + 全连接器 `-am package` · checkstyle · `tools/check-connector-imports.sh` · + `unzip -l fe/fe-core/target/doris-fe-lib.zip | grep -i hive` 记录删前/删后差异 +- **T-73 e2e**:① 普通 HMS catalog 读写 ② iceberg 原生 Glue + AK/SK ③ Ranger hive 鉴权 + 🔴 **本阶段新增必跑项**:④ **FE 起得来 + 各类缓存正常**(Caffeine 由 2.x 翻到 3.2.3,见下) + ⑤ **OBS/BOS 访问**(commons-lang 结论的反面验证) + ⑥ **glue catalog + 临时 STS 凭证**(T-21 的验收;需真实 STS 凭证,单测覆盖不到) +- **T-74 收尾**:`progress.md` 结项 + `../decisions-log.md` 补 D-NNN + PR(base = `branch-catalog-spi`,squash) + +--- + +## 🔴 阶段 6 必须知道的三件事(阶段 5 的产物,**别当成无关背景**) + +**1. Caffeine 从 2.x 翻到了 3.2.3 —— 这是本分支唯一的运行期行为变更,e2e 必须覆盖。** +那个 jar 捆着 Caffeine 2.x,且 `start_fe.sh` **倒序**拼 classpath 使它排在 `caffeine-3.2.3.jar` +**之前** ⇒ **FE 一直跑的是 hive jar 里那份 2.x**,pom 声明的 3.2.3 从未生效。摘 jar 后现实与声明对齐。 +- 已做的证明:自写字节码链接检查器(沿继承链解析)扫 `output/fe/lib` 全部 caffeine 消费者 → **零缺失**; + `CacheBulkLoaderTest` 变异验证红在断言上。 +- **未做的**:真启动 FE 验缓存(`MetaCache` / `StatisticsCache` / `FileSystemCache` / + `ExternalRowCountCache`)—— **归 T-73**。 + +**2. `commons-lang` 2.6 必须留着 —— 别再有人"顺手删"它。** +原 tasklist 写着删它,是**错的**(已翻转)。它与 shade 无关:真正需要它的是 +`hadoop-huaweicloud` 的 `OBSFileSystem`(**反射加载**)与 `bce-java-sdk`。 +`OBSProperties` 用 `Class.forName(..., initialize=false)` 探测 ⇒ **删了编译绿、探测仍成功、 +S3A 降级不触发**,等 Hadoop 实例化才炸 ⇒ **OBS 静默崩**。pom 注释已改正。 + +**3. `fe/lib` 里仍有两份 fastutil(良性,已实测)。** +`fastutil-core-8.5.18` 与经 `fe-common → trino-main` 进来的完整 `fastutil-8.5.12` 并存。 +二者 API 相同(只有 jar 里那份 2013 年的 6.5.6 缺 `computeIfAbsent`),谁赢都正确 ⇒ 曾经的 +shade execution 已删。**别把这当成新 bug 去"修"。** + +--- + +## ✅ 全量 UT 已绿(阶段 5 收尾,独占跑) + +| 模块 | 结果 | +|---|---| +| 其余 15 个模块 | 776 用例 **全绿** | +| **fe-core** | **5197 用例 · Failures 0 · Errors 0 · Skipped 35** | + +校验:失败标记 0 · surefire 被 build cache 跳过 **0 次**(真跑)· 实跑 877 个测试类。 + +**两个既有失败已在本阶段一并修掉**(`e2fa286b0a5`,均非本分支引入): +`AuthenticationPluginManagerTest`(断言一个全仓从未实现的 `oidc` 插件)· +`PluginDrivenMvccExternalTableTest`(另一 session 改分区值契约时漏更新被改类自己的测试)。 + +## 🔴 跑 UT 的两条铁律(本 session 血的教训) + +1. **绝不可在全量测试跑的同时另起 maven** —— 它们写同一个 `target/classes`。本 session 这么干过, + 结果 **22 个 `NoClassDefFoundError` 全指向 Doris 自己的类**(class 明明在 target 里), + 失败散落在互不相干的 nereids 类上;**更阴的是覆盖面被腰斩**(2845 vs 干净跑的 5197) + ⇒ 连「只有这几个类失败」都不可信。**判定失败前先确认无并发构建。** +2. **必须 `-Dmaven.test.failure.ignore=true`**:否则某个前置模块一红,反应堆就在**抵达 fe-core 之前中止**, + 「跑了全量」是假象。跑完只看各模块的 `Results:` 段。 + +## 🚫 别做的事 + +- **别删 `commons-lang`**(见上)· 别"修"两份 fastutil(见上) +- **别按关键字 grep 一把梭删** —— 保留路径同名:`iceberg.catalog.type=glue`(阶段 1 刚修好)· + `paimon.catalog.type=rest` + dlf token(DLF 2.0)· `fe-filesystem-oss` 的 `dlf.*` 别名(OSS 凭证用) +- **别碰前五阶段的成果**:`connector.iceberg.glue.ConfigurationAWSCredentialsProvider2x` · + `HmsClientConfig.removedMetastoreTypeError` + 两个调用点 · `DatasourcePrintableMap` 的 4 个 dlf 脱敏 key · + `HmsClientConfigRemovedTypeTest` · 两个插件的 `addConfResources` + `resolveHadoopConfigDir` +- **别删 `fe/pom.xml` 的 shade 版本钉** —— 4 个正当消费者还在用 +- 别信「5 项依赖全零引用」:**`kryo-shaded` 绝不可删**(`WorkloadSchedPolicy.java` 经 minlog 真实调用) +- **别做守门测试 / paimon-on-DLF / 降级档** —— 用户已明确「不做」 + +## ⚙️ 操作须知(**前五阶段全踩过**) + +- **⚠️ maven build cache 会静默跳过 surefire** → `Skipping plugin execution (cached): surefire:test`, + **BUILD SUCCESS 但 0 个测试执行**。必须 `-Dmaven.build.cache.enabled=false`, + 且**数 `grep -c "^\[INFO\] Running org.apache.doris"`**。 +- **⚠️ `-am` 必填**:漏了报 `org.apache.doris:fe:pom:${revision}` 无法解析 —— **那是没编译,不是代码错**。 +- **⚠️ `git add` 遇到已 `git rm` 的路径会中断整条命令** → 该次 commit 只包含先前暂存的内容。 + **commit 后必看 `git show --stat` 的文件数**(本 session 踩过,靠 `--amend` 补回)。 +- **⚠️ 依赖 shade 模块的模块必须跑到 `package`**:`fe-connector-paimon` 的 `HiveConf` 来自 + `fe-connector-paimon-hive-shade`。用 `test-compile`/`test` 会假报 + `package org.apache.hadoop.hive.conf does not exist`(**不是代码错**)。`install` 不行(`fe-type` quirk)。 +- **⚠️ 连接器模块路径是嵌套的**:`fe/fe-connector/fe-connector-XXX`;认证模块同理: + `fe/fe-authentication/fe-authentication-handler`(`-pl fe-authentication-handler` 会报"不在 reactor") +- maven 必须绝对路径:`mvn -o -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -pl fe-core -am test-compile` +- **⚠️ 变异验证要确认红在断言上**(不是 checkstyle/编译),变异代码也要合 checkstyle +- **⚠️ `git add` 用 path-whitelist,严禁 `git add -A`**(工作树有大量非本线程的历史 scratch) +- **动码前先探并发**(`git log`/`status` + maven 进程 + 近 90s mtime) + +--- + +# ✅ T-21 — Glue session token 已修(用户签字,行为变更) + +**改完之后**:用临时 STS 凭证的 glue catalog 从「必然认证失败」变成「能用」。 + +🔴 **侦察证伪了原计划的「修法就一处」——实为两个模块、两处独立缺陷**(只修一处仍不可用): +- **A(iceberg 插件)** `ConfigurationAWSCredentialsProvider2x.create()` 只读 ak/sk,token 就在收到的 map 里 + 没人读(iceberg 剥掉 `client.credentials-provider.` 前缀后 key = `glue.session_token`)。 +- **B(`fe-filesystem`)** `S3FileSystemProperties` 别名表不对称:accessKey/secretKey 收了 glue 的三个别名, + **sessionToken 一个都没有** ⇒ token 到不了 S3 store ⇒ 发出 `s3.session-token=""` ⇒ iceberg 走空 token 分支。 + +**两条给后人的经验**: +1. **判空必须 `isNotBlank` 而非 `!= null`**:`AwsSessionCredentials.create(ak,sk,"")` **不报错**, + 会造出带空 token 的凭证,等到 AWS 才炸出莫名其妙的 4xx。 +2. **只断言"发射"抓不到这类 bug**:既有用例断言了 `client.credentials-provider.glue.session_token` 被发出, + 却挡不住读取侧忽略它。新加的是**探针式**测试——驱动真的 + `new AwsClientProperties(opts).credentialsProvider(null,null,null)`,把 + 「发射 → 剥前缀 → 反射 → 读取」串进一个测试。两处均已变异验证(红在断言上)。 + +📌 **e2e(归 T-73)**:真临时凭证跑通 glue catalog 需要真实 STS,本地无法覆盖 —— 保留为待验项。 + +## 📌 侦察挖出、**本轮有意不动**的独立问题(阶段 6 可择机记进 decisions-log) + +- `aws-java-sdk-dynamodb` / `aws-java-sdk-logs` 在 fe-core **零引用** + (logs 的唯一理由「ranger audit 需要」在 Ranger 2.8.0 已过期:audit 模块换成了 `ranger-audit-core`, + 其 pom 零 AWS 依赖)—— 与本任务无关,属独立清理。 +- `fe/pom.xml` 的 `snapshots` 仓库仍挂着 `` 的过期注释 + (仓库本身可能仍服务其它 snapshot 依赖,**别顺手删仓库**)。 +- `dist/LICENSE-dist.txt` / `NOTICE-dist.txt` 手工维护且**早已过期**;`license-maven-plugin` 只在 + `release` profile 跑 `add-third-party` → **不 gate 构建**。本轮新增的 `com.tdunning:json:1.8` + **早已在 LICENSE-dist:981-982 记着**(因为那个 jar 一直在捆它),故无需改动。 diff --git a/plan-doc/hive-catalog-shade-removal/README.md b/plan-doc/hive-catalog-shade-removal/README.md new file mode 100644 index 00000000000000..2b52e2c9bacbec --- /dev/null +++ b/plan-doc/hive-catalog-shade-removal/README.md @@ -0,0 +1,50 @@ +# 📦 任务空间 — 剔除 `hive-catalog-shade` 冗余依赖 + +> **独立任务空间**,与 catalog-spi 主线(`plan-doc/HANDOFF.md`)并行但**不混流**。 +> 目标:让 **fe-core / fe-common 不再依赖 `org.apache.doris:hive-catalog-shade`**,从而把这个 **127 MB** 的 +> jar 及其连带的三个历史 hack(commons-lang 2.x / fastutil shade 覆盖 / central repo 条目)从 `fe/lib` 剔除。 + +--- + +## 🚩 一句话结论(2026-07-14 基线,10-agent 对抗验证) + +**今天删不掉。** `fe/fe-core/pom.xml:437-440` **不是**冗余:fe-core `src/main` 里还有 **26 个文件**在 import +hive 类,`fe-common` 还有一处 `HiveConf`(而且是 `provided`,会**编译绿、运行炸**)。真正的工作量是把这些 +**残留的源码搬回插件**,pom 那 4 行只是症状。 + +**它跟 `fe-connector-paimon-hive-shade` 没有依赖关系** —— 后者是同一招数的「插件私有版复制品」。 + +--- + +## 📂 本空间文件 + +| 文件 | 用途 | 更新方式 | +|---|---|---| +| [`design.md`](./design.md) | **设计文档** —— shade 是什么 / 为什么删不掉 / 方案 / 风险 / 待拍板决策 | 稳定文档,改动需在 progress 留痕 | +| [`tasklist.md`](./tasklist.md) | **Task list** —— 唯一进度清单,`HCS-NN` 勾选 | 每完成一项随 commit 勾 `[x]` | +| [`HANDOFF.md`](./HANDOFF.md) | **交接文档** —— 只写「下一个 session 第一件事做什么」 | 每 session 结束**覆盖式**更新 | +| [`progress.md`](./progress.md) | **进度记录** —— append-only 日志(日期 / commit / 结论 / 踩坑) | 只追加,不覆盖 | + +--- + +## ▶️ 新 session 开场流程(必须遵守) + +``` +1. Read plan-doc/hive-catalog-shade-removal/HANDOFF.md ← 上次留言 + 下一步 +2. Read plan-doc/hive-catalog-shade-removal/tasklist.md ← 勾到哪了 +3. 需要背景/为什么时才 Read design.md(别默认全读,它是稳定参考不是状态) +4. 用一句话向用户复述:"上次做完了 X,下一步是 HCS-NN,对吗?" +5. 等用户确认后开始 +``` + +**⚠️ 行号信 HEAD 不信文档** —— 本空间所有 `file:line` 是 2026-07-14 基线,代码动了就以 `grep` 为准。 + +--- + +## 🔗 与主线的关系 + +- 主线 = `plan-doc/HANDOFF.md`(HMS 翻闸 → 删遗留代码),**删除阶段主体已完成**。 +- 本任务是主线 `⏭ 单列后续` 的自然延伸:主线删掉了 fe-core 的 hive/iceberg/hudi **业务代码**, + 但**没碰** vendored 的 Glue/DLF metastore 客户端和 HiveConf 属性解析 —— 那正是 shade 还赖在 fe-core 的原因。 +- 本任务**继承主线的两条铁律**(见 design.md §6):**fe-core 只出不进** + **禁 deletion-scaffolding 式搬迁**。 +- 协作规范沿用 [`../AGENT-PLAYBOOK.md`](../AGENT-PLAYBOOK.md)。 diff --git a/plan-doc/hive-catalog-shade-removal/design.md b/plan-doc/hive-catalog-shade-removal/design.md new file mode 100644 index 00000000000000..a6861111b52602 --- /dev/null +++ b/plan-doc/hive-catalog-shade-removal/design.md @@ -0,0 +1,366 @@ +# 🧭 设计文档 — 剔除 `hive-catalog-shade` 冗余依赖 + +> **基线日期**:2026-07-14 · **分支**:`catalog-spi-11-hive` · **基线 HEAD**:`669602f079d` +> **证据来源**:10-agent 工作流(6 路侦察 + 3 路对抗验证 + 综合),关键事实**主 session 二次亲验**(见 §7 附录)。 +> **稳定文档**:状态变化写 `progress.md`,别改这里;结论要改先在 progress 留痕。 + +--- + +## 1. `hive-catalog-shade` 是什么,为什么当初要有它 + +`org.apache.doris:hive-catalog-shade` 是一个**外部仓库**产出的 uber-jar: + +- 源码:`/mnt/disk1/yy/git/doris-shade/hive-catalog-shade`(**不在本仓库**) +- 版本:**3.1.1**(`fe/pom.xml:238` 属性 + `fe/pom.xml:801-805` dependencyManagement) +- 体积:**127 MB** + +它是一道**二进制兼容性防火墙**,不是「图省事的打包」。 + +### 1.1 冲突本体:两个不兼容的 thrift + +| 谁 | libthrift 版本 | +|---|---| +| Doris 自己的 FE↔BE RPC 桩代码 | **0.16.0**(`fe/pom.xml:294`) | +| Hive 3.1.3 的 `HiveMetaStoreClient` | **0.9.3**(hive-3.1.3.pom) | + +JVM 的一个 classloader 把 `org.apache.thrift.TProtocol` 这个名字映射到**唯一一份**字节码。两份 jar 抢同一个 +包名,只有一个能赢,输的那一方在**运行时**炸 `NoSuchMethodError` —— **编译期完全看不出来**。 + +引入 commit:**`5f981b0b1f0`** ——「[fix](catalog) Use hive-catalog-shade to solve thrift version compatibility +issues (#18504)」,原话:*"Hive 3 uses the thrift-0.9.3 package, and Doris uses the thrift-0.16.0 package. +These two packages are not compatible... This jar package renames the thrift class."* + +### 1.2 shade + relocation 干了什么 + +maven-shade-plugin 把 hive 全家桶打进一个 jar,并**改包名**(relocation): + +``` +org.apache.thrift.* ──改名──▶ shade.doris.hive.org.apache.thrift.* +(连同引用它们的 HiveMetaStoreClient 字节码一起被改写) +``` + +于是 classpath 上:`org.apache.thrift.*` 归 Doris 的 0.16.0 独占;hive 用的 0.9.3 换了个名字,撞不上。 + +**jar 内容实证(3.1.1,主 session 亲验)**: + +| 包 | 类数 | +|---|---| +| `org/apache/thrift/**` | **0** ✅ 全部改过名 | +| `shade/doris/hive/org/apache/thrift/**` | 179 | +| `it/unimi/dsi/fastutil/**`(**未**改名,6.5.6 古董) | **10653** ⚠️ | +| `org/apache/iceberg/hive/**` | 36 | +| `org/apache/paimon/hive/**` | 81 | +| `com/amazonaws/glue/**` | **0** ⚠️(见 §3.2) | +| `ProxyMetaStoreClient`(阿里云 DLF) | 2 | + +**为什么 iceberg / paimon / 阿里云 DLF 也被塞进同一个 jar**:它们都通过**同一个** HMS thrift 客户端跟 +Hive Metastore 说话。客户端的包名被改了,它们的字节码必须在**同一次 shade 里**一起被改写。 + +### 1.3 连带的三个 hack(都只因这个 jar 而存在) + +| fe-core/pom.xml | 是什么 | 为什么存在 | +|---|---|---| +| `:217-223` | `commons-lang:commons-lang` 2.6 `scope=runtime` | shade 把 commons-lang 标 `provided` 没打进去,但它的 `HiveConf` 要调 `org.apache.commons.lang.StringUtils` | +| `:926-967` | `maven-shade-plugin` 的 `bundle-fastutil-into-doris-fe` execution | shade 里那 **10653 个未改名的 fastutil 6.5.6** 会盖住真 fastutil;靠把 fastutil-core 8.5.18 打进 `doris-fe.jar`、再靠 `bin/start_fe.sh:355` 把 `doris-fe.jar` **钉在 classpath 最前**来抢赢 | +| `:776-782` | `` 的 `central` 条目 | 注释直接写着 `` | + +> **⚠️ 验证陷阱**:`doris-shade/.../target/hive-catalog-shade-3.1.2-SNAPSHOT.jar`(本地构建产物)**已经**把 +> fastutil 改名了。拿它验证会误判「fastutil hack 已经没用了」。**构建实际解析的是 3.1.1**,那份里 fastutil 是裸的。 + +--- + +## 2. 和 `fe-connector-paimon-hive-shade` 的关系:**无依赖关系** + +它是同一招数的**插件私有版重新实现**,不是 fork、不是消费者、不是替代品。 + +| | `hive-catalog-shade`(外部) | `fe-connector-paimon-hive-shade`(本仓库内) | +|---|---|---| +| 改名前缀 | `shade.doris.hive.org.apache.thrift` | `org.apache.doris.paimon.shaded.thrift`(**故意不撞**) | +| hive 版本 | 3.1.3 | hive-metastore **2.3.7** + hive-common 2.3.9 + libthrift 0.9.3 | +| 装了什么 | hive 全家桶 + iceberg-hive + paimon-hive + 阿里云 DLF,127 MB | 只有 paimon 那一片,4 个 artifact | +| 古董 fastutil | **未**改名(泄漏) | **已**改名(关掉了这个坑) | +| 谁在用 | fe-core、fe-common、fe-connector-hms、fe-connector-iceberg、java-udf、avro-scanner | **只有** fe-connector-paimon | + +**历史**:paimon **从来没用过**全局 shade —— P5 阶段因「127 MB + fastutil 6.5.6 污染」拒绝 +(`plan-doc/tasks/P5-paimon-migration.md:332`),改自带裸 hive jar,撞上 +`NoClassDefFoundError: TFramedTransport`,才有了 FIX-C 这个私有 shade(`plan-doc/fix-c-hms-thrift-design.md`)。 +iceberg 走了相反的路:决策 **D-060**(`plan-doc/decisions-log.md:31`,用户 2026-06-22 签字)复用全局 shade。 + +**⚠️ 由此推出一条重要约束**:「让每个连接器各自 shade,从而解放 fe-core」这条路**对 hive 家族不成立**—— +`fe-connector-hms` 的**源码里直接 import 了那个改名前缀** +(`fe/fe-connector/fe-connector-hms/src/main/java/org/apache/hadoop/hive/metastore/HiveMetaStoreClient.java` +import `shade.doris.hive.org.apache.thrift.*`),且依赖 jar 名排序(`f` < `h`)让补丁版客户端盖住 shade 里的原版。 +换 shade = 改源码 + 可能**静默**破坏这个覆盖技巧。 + +> **本任务的范围因此是**:让 **fe-core / fe-common** 不再依赖它(→ 从 `fe/lib` 剔除)。 +> **不是**消灭这个 artifact —— fe-connector-hms / fe-connector-iceberg / java-udf / avro-scanner **继续用**, +> `fe/pom.xml:801-805` 的 dependencyManagement 版本钉**保留不动**。 + +--- + +## 3. 为什么今天删不掉:三层阻塞 + +### 3.1 编译期 —— fe-core `src/main` 还有 26 个文件 import hive 类 + +`fe/fe-core/pom.xml:437-440` **没有 ``** ⇒ compile scope。fe-core **没有任何其它 hive artifact**。 + +**最不可辩驳的证据**是那个改过名的包 `shade.doris.hive.org.apache.thrift.TException` —— **全世界只有这个 +shade jar 能提供它**,而 fe-core 里有 7 个 main 文件 import 了它。 + +| 组 | 文件 | 数量 | +|---|---|---| +| **A. vendored AWS Glue 客户端** | `fe/fe-core/src/main/java/com/amazonaws/glue/**` | 38 个 `.java`,其中 **19** 个 import hive | +| **B. vendored 阿里云 DLF 客户端** | `com/aliyun/datalake/metastore/hive2/ProxyMetaStoreClient.java` | 1 | +| **C. HiveConf 属性解析** | `org/apache/doris/datasource/property/metastore/{HMSBaseProperties, AbstractHiveProperties, AliyunDLFBaseProperties, HiveAliyunDLFMetaStoreProperties, HiveGlueMetaStoreProperties}` | 5 | +| **D. 连接器上下文** | `org/apache/doris/connector/DefaultConnectorContext.java`(`:208` 调 `loadHiveConfFromHiveConfDir`) | 1 | +| **E. Ranger hive 审计** | `org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAuditHandler.java`(用 hive-exec 的 `HiveOperationType`) | 1 | +| **测试** | `HMSIntegrationTest` / `HMSPropertiesTest` / `HMSGlueMetaStorePropertiesTest` / `HMSAliyunDLFMetaStorePropertiesTest` | 4 | + +> C 组正是 memory `catalog-spi-no-property-parsing-in-fecore`(**fe-core 不持有任何属性解析**)的**未完成尾巴**。 + +### 3.2 运行时 —— `fe/lib` 是所有插件的**父加载器**,且有两个类**只**存在于父 + +**`fe/lib` 只由 fe-core 的 assembly 喂**: +`fe/fe-core/src/main/assembly/fe-lib.xml:28-34`(`runtime`,**排除 provided**) +→ `build.sh:1011-1012` 解压 `doris-fe-lib.zip` + 拷 `doris-fe.jar` 进 `output/fe/lib/`。 +连接器插件另走 `output/fe/plugins/connector/`(`build.sh:1069`),**不喂 `fe/lib`**。 + +连接器插件是 **child-first + 父回退**(`fe-extension-loader` 的 `ChildFirstClassLoader`),parent-first 前缀名单 +(`fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorPluginManager.java:64`)**不含** +`org.apache.hadoop.hive` / `com.aliyun` / `com.amazonaws`。 + +**两个按名字反射加载、且只存在于父的类**: + +| 客户端 | 谁按名字加载 | 定义在哪 | 删 shade 的后果 | +|---|---|---|---| +| **AWS Glue** `com.amazonaws.glue.catalog.metastore.AWSCatalogMetastoreClient` | `fe-connector-hms/.../ThriftHmsClient.java:920-922`(字符串传给 `RetryingMetaStoreClient.getProxy`) | **shade jar 里 0 个条目**!唯一定义 = fe-core vendored 源码 → `doris-fe.jar` | Glue catalog 首用 `NoClassDefFoundError` | +| **阿里云 DLF** `com.aliyun.datalake.metastore.hive2.ProxyMetaStoreClient` | **paimon**:`PaimonCatalogFactory.java:217`(paimon zip **没有** shade / aliyun jar;`PaimonConnector.java:398` 注释自认 *"host-provided via hive-catalog-shade at cutover, not bundled"*) | fe-core vendored **+** shade jar(2 个条目) | paimon-on-DLF 首用 `NoClassDefFoundError` | + +> **iceberg-on-DLF 不受影响**:`fe-connector-iceberg/.../DLFClientPool.java:20` 直接 import,而 iceberg 的 +> plugin-zip **自带** shade(内含 `ProxyMetaStoreClient`)→ child-first 自满足。 + +> **⚠️ 静默换实现风险**:`bin/start_fe.sh:355` 把 `doris-fe.jar` 钉在 classpath **最前**。所以今天走父回退时 +> 实际生效的 `ProxyMetaStoreClient` 是 **fe-core vendored 的那份**,不是 shade 里那份。删掉 fe-core 副本 = +> **悄悄换了 DLF 实现**。必须有意识地处理,不能顺手删。 + +### 3.2b ⚠️ 机制推演:Glue-on-HMS / paimon-on-DLF **今天很可能就是坏的**(可离线证实,见 HCS-01) + +把 classloader 的实际代码路径走一遍(**全部行号已亲验**): + +``` +ChildFirstClassLoader.loadClass(name) # fe-extension-loader + ├─ isParentFirst(name)? parent-first 名单 = + │ java./javax./sun./com.sun./org.slf4j./org.apache.logging./ + │ org.apache.doris.extension.spi./org.apache.doris.connector.api. + │ + 连接器追加 org.apache.doris.connector./org.apache.doris.filesystem. + │ → org.apache.hadoop.hive.* 和 com.amazonaws.* 都【不在】名单里 + ├─ findClass(name) # 只看插件自己的 lib/ + └─ CNFE → super.loadClass(name) # 父回退 +``` + +于是: + +| 类 | 在插件 `lib/` 里吗 | 由谁定义 | +|---|---|---| +| `org.apache.hadoop.hive.metastore.IMetaStoreClient` | ✅ 在(hudi/iceberg zip 里**实查到** `lib/hive-catalog-shade-3.1.1.jar`,127 MB) | **child**(插件) | +| `RetryingMetaStoreClient` | ✅ 同上 | **child** | +| `com.amazonaws.glue.catalog.metastore.AWSCatalogMetastoreClient` | ❌ **不在**(shade jar 里 0 个条目) | 父回退 → **app loader** | + +而 `ThriftHmsClient.java:644` 把 **TCCL 钉到插件 loader**(`getClass().getClassLoader()`),`:910` 才调 +`RetryingMetaStoreClient.getProxy(conf, hookLoader, "com.amazonaws...AWSCatalogMetastoreClient")`。 +hive 的 `getProxy` 内部走 `JavaUtils.getClass(name, IMetaStoreClient.class)` = `Class.forName(name, TCCL)` ++ **`.asSubclass(IMetaStoreClient.class)`**,而这里的 `IMetaStoreClient.class` 是从 +`RetryingMetaStoreClient`(**child**)的定义 loader 解析的。 + +⇒ **app-loaded 的 `AWSCatalogMetastoreClient` 实现的是「app 的 `IMetaStoreClient`」,与「child 的 +`IMetaStoreClient`」是两个不同的 `Class` 对象**(同名同版本,不同 loader ⇒ 不同类身份) +⇒ `asSubclass` 失败 ⇒ **`ClassCastException` / `LinkageError`**。 + +**paimon-on-DLF 更糟**:paimon 的私有 shade 提供的是 **hive 2.3.7** 的 `IMetaStoreClient` +(jar 内该 class 日期 2020-04-07),而父回退拿到的 fe-core vendored `ProxyMetaStoreClient` 实现的是 +**hive 3.1.3** 的 —— **版本都不一样**。 + +> **📌 这不是猜测,是可以离线证实/证伪的**:写一个单测,用真实 plugin-zip 的 `lib/*.jar` 组一个 +> `ChildFirstClassLoader`(parent = app loader),`Class.forName(客户端类名, false, child)` 后断言 +> `childIMetaStoreClient.isAssignableFrom(...)`。**不需要真集群**。这就是 **HCS-01**。 + +**⇒ 对本任务的意义(关键)**:把 Glue / DLF 客户端**搬进插件**,会让调用方(`RetryingMetaStoreClient`)和 +被调方(客户端)落在**同一个 child loader、同一份 `IMetaStoreClient` 身份**上 —— 这**不只是为了删 shade, +它本身就是在修一个大概率存在的线上 bug**。因此本方案**无论 HCS-01 结果如何都是严格改进**: +- 若 HCS-01 证明今天已坏 → 搬迁 = **修复**; +- 若 HCS-01 证明今天能跑(说明我推演错了某处)→ 搬迁 = **行为保持**(且必须靠 HCS-01 的测试守住)。 + +### 3.3 `fe-common` 的 `provided` 陷阱 —— 编译绿、运行炸 + +`fe/fe-common/pom.xml:89-91` 声明 shade 为 **`provided`**。 + +> **`provided` = 「编译时给我,打包时别带,运行时有别人提供」**。Maven **从不传递 provided**。 + +**用实跑 maven 否掉了「fe-core 是从 fe-common 传递拿到 shade」这个假设**: + +```bash +cd fe && mvn -o dependency:tree -Dverbose \ + -Dincludes=org.apache.doris:hive-catalog-shade -pl fe-core -am +# (漏 -am 会因 ${revision} 假错) +``` +→ fe-core 只有**一条 depth-1 的 compile 边**,零传递路径。**所以删 `fe-core:437-440` 确实能把 jar 踢出 `fe/lib`。** + +**坑在**:`fe/fe-common/src/main/java/org/apache/doris/common/CatalogConfigFileUtils.java:23,95` 有 +`public static HiveConf loadHiveConfFromHiveConfDir(...)`。fe-core 删依赖后,fe-common **照样编译通过** +(它自己 `provided` 着),但运行时 `fe/lib` 已无 shade → 加载 `CatalogConfigFileUtils` 时 +`NoClassDefFoundError: org/apache/hadoop/hive/conf/HiveConf`。而这个类在**每一个外表 catalog** 的路径上 +→ **炸的不只是 hive,是所有 catalog**。 + +**⇒ 这是一个两模块改动,fe-common 必须与 fe-core 同批(或更早)去 hive 化。** + +--- + +## 4. 方案:五步走(详见 `tasklist.md`) + +``` +阶段 0 前置探测 + 决策拍板 ← 阻塞后续 +阶段 1 独立冗余清理(不依赖任何决策,可立即做) +阶段 2 Glue 客户端归位 → fe-connector-hms +阶段 3 DLF 客户端归位 → fe-connector-paimon(iceberg 已自满足) +阶段 4 HiveConf 属性解析下沉(fe-core + fe-common 去 hive 化) +阶段 5 Ranger hive 审计去 hive-exec +阶段 6 pom 终局清理(4 处 fe-core + 1 处 fe-common) +阶段 7 守门 + e2e +``` + +**终局要删的 pom 块**(**只有跑完阶段 2–5 才能动**): + +| 文件:行 | 内容 | 备注 | +|---|---|---| +| `fe/fe-core/pom.xml:437-440` | `hive-catalog-shade` 本体 | | +| `fe/fe-core/pom.xml:217-223` | `commons-lang` 2.6 runtime | 已验证 fe-core 源码 0 处用 lang2;Ranger 2.8.0 也 0 处 | +| `fe/fe-core/pom.xml:926-967` | `bundle-fastutil-into-doris-fe` shade execution | **`fastutil-core` 依赖本身要留**(`:766-774`,`TabletInvertedIndex` 等 3 个文件在用),死的只是这个 plugin 块 | +| `fe/fe-core/pom.xml:776-782` | `` 的 `central` | | +| `fe/fe-common/pom.xml:87-91` | `provided` shade | **必须与 fe-core 同批或更早** | + +**不许动**:`fe/pom.xml:801-805` 版本钉 · `bin/start_fe.sh:355` 的 `doris-fe.jar` 钉序(通用机制,不是 hive 专属) +· `fastutil-core` 依赖 · BE 侧 `java-udf` / `avro-scanner` 的 pom(**独立供应链**,memory +`catalog-spi-be-java-ext-shared-classpath`:动它炸过 JNI scanner) · `doris-shade` 仓库本身。 + +### 4.1 ⛑ 降级路线(**必须事先约定**:e2e 不可得,不许硬上) + +用户**无法 e2e 验证** Glue-on-HMS 与 paimon-on-DLF,且要求这两条路径**必须保留可用**。因此: + +> **阶段 6(删 pom)是一道单向门 —— 只要阶段 2/3 里有任何一条客户端搬不动,就【不删】shade,收在降级档。** + +| 档位 | 达成条件 | 交付物 | 价值 | +|---|---|---|---| +| **档 0(保底,无条件可交)** | 阶段 1 独立清理完成 | 删 `hudi-hadoop-mr` 等零引用依赖;`fe/lib` 少一个 `hive-storage-api` | 真实瘦身,零风险 | +| **档 1** | HCS-01 离线测试落地 | 一个**能证明 Glue/DLF 客户端在插件 loader 下能否被正确解析**的守门单测 | 把一个「谁也说不清」的问题变成 CI 里的红绿灯,**本身就有独立价值** | +| **档 2** | 阶段 2(Glue 搬迁)+ 阶段 4/5 完成,但 **D-2 spike 失败** | fe-core 去掉 Glue/HiveConf/Ranger,**但 `ProxyMetaStoreClient` 与 shade 依赖保留** | fe-core 大幅瘦身;shade 仍在 `fe/lib`,**目标未达成** | +| **档 3(完全体)** | 阶段 2–5 全绿 + 总判据 = 0 | 删 `fe-core:437-440` + 三个连带 hack + `fe-common:87-91` | **`fe/lib` 少 127 MB** | + +**判据**:`grep` 总判据(见 `tasklist.md` 顶部)必须归 0 才允许进阶段 6。**归不了 0 就停在档 2,不许为了删而删。** + +--- + +## 5. ⚠️ 待拍板决策(阶段 0,需要用户签字) + +### D-1|Glue 客户端(38 个 vendored 文件)搬去哪? + +- **A(推荐)搬进 `fe-connector-hms`**:随 hive/iceberg/hudi 插件 zip 走 child-first,`ThriftHmsClient` 按名加载在插件内自满足。合「源相关代码归插件」的架构目标。代价:38 文件搬迁 + checkstyle。 +- **B 换 upstream `aws-glue-datacatalog-client` jar**:省掉 vendored 副本,但 upstream 是否有匹配 hive 3.1.3 + 我们改名前缀的版本**未调研**,风险高。 +- **C 先不动 Glue,只做阶段 1/4/5**:那样 shade 仍删不掉(Glue 需要 `IMetaStoreClient` 父类),**目标不达成**。 + +### D-2|DLF 客户端 for paimon 怎么给?(**最高风险项,需要 spike**) + +⚠️ **这一条不是「用户选一个」就完事的,它有真实的技术未知数**(HCS-30a spike): + +- fe-core 那份 vendored `ProxyMetaStoreClient` 有 **2193 行**,而且它**自己还依赖阿里云 DLF SDK 的其余部分** + (`com.aliyun.datalake.metastore.common.*` / `.hive.common.utils.*`)—— 那 **1963 个 `com/aliyun` 类只在 + shade jar 里**。所以搬 DLF 到 paimon 插件 = 要同时给它 **客户端 + SDK 闭包**。 +- 而且**版本口径对不上**:阿里云 artifact 是 `com.aliyun.datalake:metastore-client-hive3:0.2.14`(hive **3** API), + paimon 的私有 shade 是 hive **2.3.7**。 + 📎 **线索**:那个类的包名却叫 `...metastore.**hive2**.ProxyMetaStoreClient` —— 需要 spike 确认它到底实现 + 哪个版本的 `IMetaStoreClient` 接口。 + +候选路线(spike 后再定): +- **A** 把 `metastore-client-hive3` 加进 `fe-connector-paimon-hive-shade` 的 artifactSet,随 paimon 的 thrift 前缀 + 一起 relocate(最干净,**前提是 hive API 版本对得上**) +- **B** 把 fe-core 那份 vendored 源码搬进 paimon 插件并改写 import 到 paimon 的前缀(工作量大,2193 行) +- **C** 让 paimon 也依赖 `hive-catalog-shade`(P5 当年明确拒绝:127 MB + fastutil 污染;且会和 paimon 私有 shade + 的 `IMetaStoreClient` 撞成两份 → **不推荐**) + +### D-3|Ranger hive 审计的 `HiveOperationType`(hive-exec)怎么去? + +需要单独看一眼 `RangerHiveAuditHandler` 用了它什么。选项大致是:换成自建枚举 / 下沉到插件 / 保留(则 shade 删不掉)。 +**阶段 0 需要一次小调研**(~1 个 subagent)。 + +### D-4|~~是否先跑 e2e 前置探测~~ → **已由用户定调(2026-07-14)** + +**用户无法验证 Glue-on-HMS 与 paimon-on-DLF,指示:一律按「需要保留、必须继续能用」设计。** + +⇒ 方案基调随之固定: +1. **不得**因为「反正可能已经坏了」就删功能 —— 两条路径**必须**在改完后仍可用(且更正确)。 +2. **不得**做无法验证的静默换实现(风险 R-2)—— DLF 客户端**必须逐字节保行为**或有明确论证。 +3. **e2e 不可得 ⇒ 用离线的 classloader 复现测试代偿**(**HCS-01**,见 §3.2b)。这个测试完全离线可跑, + 而且比 e2e 更早、更便宜地把「客户端类能不能被插件 loader 正确解析成 `IMetaStoreClient`」钉死。 + **它同时是搬迁前的现状快照和搬迁后的验收门禁。** + +--- + +## 6. 风险登记 + +| ID | 风险 | 说明 | 缓解 | +|---|---|---|---| +| **R-1** | **Glue-on-HMS / paimon-on-DLF 今天很可能就是坏的**(机制推演,见 §3.2b) | 客户端由**父**加载器定义(实现父的 `IMetaStoreClient`),调用方 `RetryingMetaStoreClient` 在**插件**里(child,另一份 `IMetaStoreClient` 身份)→ `asSubclass` 失败 → `ClassCastException`。paimon 更糟:child 是 hive **2.3.7**,父是 **3.1.3** | **HCS-01 离线 classloader 复现测试**(不需要真集群)。⚠️ **用户无法 e2e,故一律按「必须保留可用」设计**:搬进插件后 caller/callee 同 loader → 若今天已坏则是**修复**,若今天能跑则是**行为保持** | +| **R-2** | 静默换 DLF 实现 | `start_fe.sh:355` 钉 `doris-fe.jar` 最前 → 今天生效的是 fe-core vendored 副本(2193 行),**不是** shade 里那份。删 fe-core 副本 = 悄悄换实现 | ⚠️ **用户无法 e2e ⇒ 禁止无论证的静默换实现**。HCS-31 必须先 **diff 两份实现**并把差异写进 `progress.md`,无法论证等价就**保留 fe-core 那份的语义**(搬源码而非换 jar) | +| **R-2b** | **DLF 搬迁存在真实技术未知数** | vendored `ProxyMetaStoreClient`(2193 行)还依赖 shade 里**另外 1963 个** `com/aliyun` SDK 类;且阿里云 artifact 是 `metastore-client-hive3`(hive 3 API)而 paimon 私有 shade 是 hive **2.3.7** | **HCS-30a spike 先行**(D-2)。⚠️ **若 spike 判定不可安全搬迁 → 走 §4.1 降级路线,不硬上** | +| **R-3** | Glue 回归无人接得住 | 删 shade 但留 glue 树 → `NoClassDefFoundError`;删 glue 树但不搬 → `ThriftHmsClient:922` 按名 `ClassNotFoundException`。**编译器不报、单测不报** | HCS-01 的离线 classloader 测试**就是**这条的守门(e2e 不可得时的代偿) | +| **R-4** | 删完 `fe/lib` 仍非 hive-free | `hudi-hadoop-mr:1.0.2 → hudi-hadoop-common → hive-storage-api:2.8.1` 还会塞一个 hive jar 进 `fe/lib`,`org.apache.hadoop.hive.*` 变成**半填充命名空间**(比干净缺失更难查;文件系统插件对 `org.apache.hadoop.` 整个前缀是 parent-first) | 阶段 1 顺手删 `hudi-hadoop-mr`(fe-core 源码零引用) | +| **R-5** | fastutil 去 hack 后的重复 | shade 走后,`fastutil-core:8.5.18`(直接依赖)与完整 `fastutil:8.5.12`(fe-common → trino-main → clearspring)都进 `lib/`,都含 `Long2ObjectOpenHashMap`。**今天是良性的**(两份都有需要的方法),但从此靠 `lib/` 顺序而非显式钉 | 在 `fastutil-core` 依赖上留注释记录此事,否则将来一次版本 bump 会复现原 bug 而无人知道为什么 | +| **R-6** | 别信 `dependency:analyze` | `AliyunDLFBaseProperties.java:71` 把 `DataLakeConfig.CATALOG_PROXY_MODE` 当**注解 String 常量**用,javac 会**内联**进字节码 → 纯字节码扫描会报「依赖未使用」,但 `javac` 仍会因 `import` 失败 | 一律用 `grep import` + 真编译判定 | +| **R-7** | Ranger 版本耦合 | 若有人把 `ranger-plugins-common` 降到 2.8.0 以下,`commons-lang` 2.x 会因**无关原因**重新变成必需(2.7.0 有 171 处 lang2 引用) | Ranger 每次 bump 后重跑 verbose dependency:tree | + +--- + +## 7. 附录:主 session 亲验的事实(可复现命令) + +```bash +cd /mnt/disk1/yy/git/wt-catalog-spi + +# fe-core main 里 import hive/shade/datalake 的文件(基线 = 26) +grep -rlE '^import (org\.apache\.hadoop\.hive\.|shade\.doris\.hive\.|com\.aliyun\.datalake\.)' \ + fe/fe-core/src/main/java | wc -l # → 26 ⚠️ 这就是「删完了没」的判据,目标 = 0 +grep -rlE '^import (org\.apache\.hadoop\.hive\.|shade\.doris\.hive\.|com\.aliyun\.datalake\.)' \ + fe/fe-core/src/test/java | wc -l # → 4 +find fe/fe-core/src/main/java/com/amazonaws/glue -name '*.java' | wc -l # → 38 + +# fe-common 的 provided 陷阱 +grep -rn 'hadoop.hive' fe/fe-common/src/main/java # → CatalogConfigFileUtils.java:23 +grep -rn 'loadHiveConfFromHiveConfDir' --include=*.java fe/ +# → fe-common CatalogConfigFileUtils.java:95(定义) +# fe-core HMSBaseProperties.java:192 / DefaultConnectorContext.java:208(调用) + +# 按名字反射加载 Glue/DLF 的地方 +grep -rn 'AWSCatalogMetastoreClient\|ProxyMetaStoreClient' --include=*.java fe/fe-connector/ + +# shade 3.1.1 jar 实证(注意:别用 doris-shade/target 里的 3.1.2-SNAPSHOT,它已改名 fastutil → 会误判) +J=~/.m2/repository/org/apache/doris/hive-catalog-shade/3.1.1/hive-catalog-shade-3.1.1.jar +unzip -l "$J" | awk '{print $4}' | grep -c '^org/apache/thrift/.*\.class$' # → 0 +unzip -l "$J" | awk '{print $4}' | grep -c '^shade/doris/hive/org/apache/thrift/.*\.class$' # → 179 +unzip -l "$J" | awk '{print $4}' | grep -c '^it/unimi/dsi/fastutil/.*\.class$' # → 10653 +unzip -l "$J" | grep -c 'AWSCatalogMetastoreClient' # → 0 ⚠️ +unzip -l "$J" | grep -c 'ProxyMetaStoreClient' # → 2 + +# 依赖图真相(漏 -am 会因 ${revision} 假错) +cd fe && mvn -o dependency:tree -Dverbose \ + -Dincludes=org.apache.doris:hive-catalog-shade -pl fe-core -am +``` + +**未亲验、来自 agent 的次级事实**(用前请自行确认):hudi 解析版本 = 1.0.2 且经 +`hudi-hadoop-mr → hudi-hadoop-common → hive-storage-api:2.8.1` 进 `fe/lib`;Ranger 2.7.0 有 171 处 lang2 引用。 + +--- + +## 8. 铁律(继承主线,本任务同样适用) + +- **【铁律 A|fe-core 只出不进】** 本任务期间 fe-core 源码**只减不增**,不得新增/搬入任何数据源直接相关代码。 +- **【铁律 B|禁 deletion-scaffolding 式搬迁】** 不得为「删 A 能编译过」把 A 的逻辑就近挪进 fe-core util。 + 遇此情形**停手**,重分析真实归属,出方案交用户 review。 +- (memory `fe-core-source-isolation-iron-rules` · `catalog-spi-no-property-parsing-in-fecore`) diff --git a/plan-doc/hive-catalog-shade-removal/loader-probe-reproduction.java.txt b/plan-doc/hive-catalog-shade-removal/loader-probe-reproduction.java.txt new file mode 100644 index 00000000000000..53318975e7783b --- /dev/null +++ b/plan-doc/hive-catalog-shade-removal/loader-probe-reproduction.java.txt @@ -0,0 +1,198 @@ +import java.io.File; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.doris.extension.loader.ChildFirstClassLoader; +import org.apache.doris.extension.loader.ClassLoadingPolicy; + +/** + * Offline reproduction of the production classloader topology. + * + * parent = model of the FE app loader: output/fe/lib/*.jar with doris-fe.jar pinned FIRST + * (mirrors bin/start_fe.sh which puts doris-fe.jar at the head of CLASSPATH) + * child = the REAL ChildFirstClassLoader from fe-extension-loader, over a plugin's + * own jars + lib/*.jar, with the REAL parent-first policy from + * ConnectorPluginManager (DEFAULT + org.apache.doris.connector. + org.apache.doris.filesystem.) + * + * Question under test: RetryingMetaStoreClient.getProxy() does + * Class.forName(name, true, TCCL).asSubclass(IMetaStoreClient.class) + * where IMetaStoreClient.class is resolved from RetryingMetaStoreClient's OWN defining loader. + * So the decisive predicate is: childIMetaStoreClient.isAssignableFrom(resolvedClientClass) + */ +public class LoaderProbe { + + private static final String OUT = "/mnt/disk1/yy/git/wt-catalog-spi/output/fe"; + + private static final String IMSC = "org.apache.hadoop.hive.metastore.IMetaStoreClient"; + private static final String RETRYING = "org.apache.hadoop.hive.metastore.RetryingMetaStoreClient"; + private static final String GLUE = "com.amazonaws.glue.catalog.metastore.AWSCatalogMetastoreClient"; + private static final String DLF = "com.aliyun.datalake.metastore.hive2.ProxyMetaStoreClient"; + + public static void main(String[] args) throws Exception { + ClassLoader parent = buildParent(); + System.out.println("=== PARENT (app loader model) ==="); + report(parent, IMSC); + report(parent, RETRYING); + report(parent, GLUE); + report(parent, DLF); + + for (String plugin : new String[] {"hive", "iceberg", "hudi", "paimon"}) { + probePlugin(parent, plugin); + } + } + + private static ClassLoader buildParent() throws Exception { + File libDir = new File(OUT, "lib"); + File[] jars = libDir.listFiles(f -> f.getName().endsWith(".jar")); + List urls = new ArrayList<>(); + // doris-fe.jar pinned first, exactly like start_fe.sh + for (File f : jars) { + if (f.getName().equals("doris-fe.jar")) { + urls.add(f.toURI().toURL()); + } + } + for (File f : jars) { + if (!f.getName().equals("doris-fe.jar")) { + urls.add(f.toURI().toURL()); + } + } + System.out.println("parent jars: " + urls.size()); + return new URLClassLoader("app-model", urls.toArray(new URL[0]), + ClassLoader.getPlatformClassLoader()); + } + + private static void probePlugin(ClassLoader parent, String name) throws Exception { + File dir = new File(OUT, "plugins/connector/" + name); + if (!dir.isDirectory()) { + System.out.println("\n=== PLUGIN " + name + ": MISSING DIR " + dir + " ==="); + return; + } + List urls = new ArrayList<>(); + File[] root = dir.listFiles(f -> f.getName().endsWith(".jar")); + if (root != null) { + for (File f : root) { + urls.add(f.toURI().toURL()); + } + } + File lib = new File(dir, "lib"); + File[] libJars = lib.listFiles(f -> f.getName().endsWith(".jar")); + if (libJars != null) { + for (File f : libJars) { + urls.add(f.toURI().toURL()); + } + } + + List parentFirst = new ClassLoadingPolicy( + Arrays.asList("org.apache.doris.connector.", "org.apache.doris.filesystem.")) + .toParentFirstPackages(); + + ChildFirstClassLoader child = + new ChildFirstClassLoader(urls.toArray(new URL[0]), parent, parentFirst); + + System.out.println("\n=== PLUGIN " + name + " (jars=" + urls.size() + ") ==="); + Class childImsc = report(child, IMSC); + report(child, RETRYING); + Class glue = report(child, GLUE); + Class dlf = report(child, DLF); + + verdict("Glue", childImsc, glue); + verdict("DLF ", childImsc, dlf); + + // Replay the REAL production path: RetryingMetaStoreClient.getProxy does + // JavaUtils.getClass(name, IMetaStoreClient.class) -> Class.forName(name, true, TCCL) + // and the ctor then does `checkcast IMetaStoreClient` (bytecode offset 127) on the + // instance, where IMetaStoreClient is resolved from RetryingMetaStoreClient's OWN + // defining loader (= child). checkcast succeeds iff isAssignableFrom is true. + realPath(child, childImsc, GLUE); + ctorCheck(child, dlf, "DLF"); + } + + /** + * RetryingMetaStoreClient.getProxy(conf, hookLoader, className) builds + * Class[] {Configuration.class, HiveMetaHookLoader.class, Boolean.class} + * and hands it to JavaUtils.newInstance -> getDeclaredConstructor(...), which is an + * EXACT signature match (a HiveConf param would NOT match Configuration). + * fe-core's vendored ProxyMetaStoreClient adds exactly this ctor; the shade copy may not. + */ + private static void ctorCheck(ClassLoader child, Class impl, String what) { + if (impl == null) { + System.out.println(" CTOR " + what + ": N/A"); + return; + } + try { + Class conf = Class.forName("org.apache.hadoop.conf.Configuration", false, impl.getClassLoader()); + Class hook = Class.forName("org.apache.hadoop.hive.metastore.HiveMetaHookLoader", false, impl.getClassLoader()); + impl.getDeclaredConstructor(conf, hook, Boolean.class); + System.out.println(" CTOR " + what + ": (Configuration, HiveMetaHookLoader, Boolean) FOUND on " + + describe(impl.getClassLoader()) + " [getProxy can instantiate]"); + } catch (NoSuchMethodException e) { + System.out.println(" CTOR " + what + ": (Configuration, HiveMetaHookLoader, Boolean) *** ABSENT *** on " + + describe(impl.getClassLoader()) + " [getProxy -> NoSuchMethodException]"); + System.out.println(" declared ctors: " + Arrays.toString(impl.getDeclaredConstructors())); + } catch (Throwable t) { + System.out.println(" CTOR " + what + ": probe error " + t); + } + } + + private static void realPath(ClassLoader child, Class childImsc, String target) { + ClassLoader original = Thread.currentThread().getContextClassLoader(); + try { + // exactly what ThriftHmsClient.doAs() does at line 644 + Thread.currentThread().setContextClassLoader(child); + Class javaUtils = Class.forName( + "org.apache.hadoop.hive.metastore.utils.JavaUtils", true, child); + Object resolved = javaUtils + .getMethod("getClass", String.class, Class.class) + .invoke(null, target, childImsc); + Class c = (Class) resolved; + System.out.println(" REAL-PATH JavaUtils.getClass(\"" + target + "\") under pinned TCCL"); + System.out.println(" -> resolved by " + describe(c.getClassLoader())); + System.out.println(" -> ctor `checkcast IMetaStoreClient` would " + + (childImsc.isAssignableFrom(c) ? "SUCCEED" : "THROW ClassCastException")); + } catch (Throwable t) { + Throwable cause = t.getCause() != null ? t.getCause() : t; + System.out.println(" REAL-PATH threw " + cause.getClass().getName() + ": " + cause.getMessage()); + } finally { + Thread.currentThread().setContextClassLoader(original); + } + } + + private static Class report(ClassLoader cl, String name) { + try { + // resolve=false: we only need the class object; supertypes are still loaded on define + Class c = Class.forName(name, false, cl); + ClassLoader def = c.getClassLoader(); + System.out.printf(" %-70s -> defined by %s%n", shorten(name), describe(def)); + return c; + } catch (Throwable t) { + System.out.printf(" %-70s -> %s: %s%n", shorten(name), + t.getClass().getSimpleName(), String.valueOf(t.getMessage())); + return null; + } + } + + private static void verdict(String what, Class childImsc, Class impl) { + if (childImsc == null || impl == null) { + System.out.println(" VERDICT " + what + ": N/A (a class failed to resolve)"); + return; + } + boolean ok = childImsc.isAssignableFrom(impl); + System.out.println(" VERDICT " + what + ": child IMetaStoreClient.isAssignableFrom(client) = " + + ok + (ok ? " [OK]" : " [BROKEN -> asSubclass would throw ClassCastException]")); + } + + private static String describe(ClassLoader cl) { + if (cl == null) { + return "bootstrap"; + } + String n = cl.getName(); + return (n != null ? n : cl.getClass().getSimpleName()) + "@" + Integer.toHexString(System.identityHashCode(cl)); + } + + private static String shorten(String n) { + return n; + } +} diff --git a/plan-doc/hive-catalog-shade-removal/progress.md b/plan-doc/hive-catalog-shade-removal/progress.md new file mode 100644 index 00000000000000..0c250a55665cbf --- /dev/null +++ b/plan-doc/hive-catalog-shade-removal/progress.md @@ -0,0 +1,670 @@ +# 📜 进度记录 — 剔除 `hive-catalog-shade` + +> **Append-only 日志**:只往下追加,**不覆盖不删改**(覆盖式的「下一步」在 [`HANDOFF.md`](./HANDOFF.md))。 +> 每条格式:`## YYYY-MM-DD — 标题` + 做了什么 / commit / 结论 / 踩坑。 + +--- + +## 2026-07-14 — 立项 + 事实基线(零代码改动) + +**做了什么** +- 10-agent 工作流:6 路侦察(fe-core 静态用法 / 运行时需求 / 引入历史 / 其它消费者 / 与 paimon-hive-shade 的关系 / + 连带 hack)+ **3 路对抗验证**(compile-break / runtime-NoClassDefFound / packaging-and-scope)+ 综合。 +- 主 session **亲验**了所有要落进持久文档的关键事实(可复现命令见 `design.md §7`)。 +- 建立本任务文档空间(README / design / tasklist / progress / HANDOFF)。 + +**结论(推翻了立项时的假设)** +- ❌ **假设「hive/iceberg/paimon 都迁到插件了 → shade 已冗余可删」= 错。** + 3 路对抗验证**全部**推翻了「可以直接删」: + - **编译**:fe-core `src/main` **26 个文件**仍 import hive 类(Glue 38 文件树 / DLF 客户端 / 5 个 HiveConf 属性类 / + `DefaultConnectorContext` / `RangerHiveAuditHandler`);测试 4 个。 + - **运行时**:`fe/lib` 是所有插件的**父加载器**,且 `AWSCatalogMetastoreClient`(**shade jar 里 0 个条目**,唯一 + 定义 = fe-core vendored 源码)与 paimon 的 `ProxyMetaStoreClient` 是**按名反射加载、只存在于父**的。 + - **打包**:`fe-common` 的 shade 是 **`provided`** → 删 fe-core 依赖后 fe-common **编译绿、运行炸**, + 且它在**每个外表 catalog** 的路径上。 +- ✅ **假设「fe-core 是从 fe-common 传递拿到 shade」= 错**(实跑 maven 否掉):Maven 从不传递 `provided`; + fe-core 只有**一条 depth-1 compile 边**。所以删 `fe-core:437-440` **确实**能把 jar 踢出 `fe/lib` —— + 这正是它现在**不能**删的原因。 +- ✅ **`fe-connector-paimon-hive-shade` 与 `hive-catalog-shade` 无依赖关系** —— 是同一招数的插件私有版重新实现 + (改名前缀故意不撞:`org.apache.doris.paimon.shaded.thrift` vs `shade.doris.hive.org.apache.thrift`; + hive 2.3.7 vs 3.1.3)。paimon 当年(P5)明确**拒绝**了全局 shade;iceberg 反而按 **D-060** 签字复用它。 + +**关键踩坑(写下来防止重复踩)** +- ⚠️ **验证陷阱**:`doris-shade/.../target/hive-catalog-shade-3.1.2-SNAPSHOT.jar`(本地构建产物)**已经**改名了 + fastutil;拿它验证会误判「fastutil hack 已死」。**构建实际解析的是 3.1.1**,那份里 fastutil 是**裸的 10653 个类**。 +- ⚠️ `mvn dependency:tree` 在本 reactor 里**必须加 `-am`**,否则 `${revision}` 解析失败报假错 + (侦察 agent 一度据此断言「这里跑不了 dependency:tree」——是错的)。 +- ⚠️ 别信 `dependency:analyze`:`AliyunDLFBaseProperties.java:71` 把 `DataLakeConfig.CATALOG_PROXY_MODE` 当**注解 + String 常量**用,javac 会**内联**进字节码 → 字节码扫描报「未使用」,但 `javac` 仍会因 `import` 失败。 + +**被验证阶段纠正的侦察结论**(记下来,别回退到错版本) +- 侦察说「fe-core 里 ~40 个文件 import hive」→ 实测 **26**(main)+ 4(test)。 +- 侦察说「shade 是 `org.apache.hadoop.hive.*` 的唯一提供者」→ **错**:`hudi-hadoop-mr → hudi-hadoop-common → + hive-storage-api:2.8.1` 也往 `fe/lib` 塞 hive 类。删完 shade,`fe/lib` **仍不是** hive-free + → 半填充命名空间(风险 R-4,比干净缺失更难查)。 + +**未决 / 待用户拍板**:D-1(Glue 归属)· D-2(paimon DLF,**需先 spike**)· D-3(Ranger `HiveOperationType`)。 + +**commit**:`1a9104ee85f`(文档立项) + +--- + +## 2026-07-14(补) — 用户定调「按需要保留设计」+ 由此挖出的机制推演 + +**用户指示**:*「Glue-on-HMS 和 paimon-on-DLF 现在我无法验证,你先按照『需要保留』来设计任务。」* + +⇒ 方案基调固定(写进 `design.md §5 D-4` / `tasklist.md 阶段 0` / `HANDOFF.md`): +1. 禁止「反正可能坏了 → 删功能」;两条路径**必须**改完后仍可用。 +2. 禁止无论证的静默换实现(R-2)。 +3. **e2e 不可得 ⇒ 用离线 classloader 测试代偿(HCS-01)**;阶段 6 是**单向门**,归不了 0 就停在降级档 + (新增 `design.md §4.1` 档 0/1/2/3),**不许为了删而删**。 + +**为定这个基调而做的探查,挖出三条硬事实(全部亲验)**: +1. `hive-catalog-shade-3.1.1.jar`(**127 MB**)**确实被打进 hudi / iceberg 的 plugin-zip**(`unzip -l` 实查) + → 插件里有**自己那份** `IMetaStoreClient` / `RetryingMetaStoreClient`。 +2. `ThriftHmsClient.java:644` 把 **TCCL 钉到插件(child)loader**,`:910` 才调 `RetryingMetaStoreClient.getProxy`。 +3. fe-core vendored 的 `ProxyMetaStoreClient` 有 **2193 行**,且它**自己还 import 阿里云 DLF SDK 的其余部分** + (`com.aliyun.datalake.metastore.common.*` / `.hive.common.utils.*`)—— 那 **1963 个 `com/aliyun` 类只在 + shade jar 里**。搬 DLF ≠ 搬一个文件,而是要搬**整个 SDK 闭包**。 + +**⚠️ 由此得出的机制推演(`design.md §3.2b`)—— 可能是本任务最重要的发现**: + +`ChildFirstClassLoader` 的 parent-first 名单**不含** `org.apache.hadoop.hive` / `com.amazonaws` +(实读 `ChildFirstClassLoader.java` + `ConnectorPluginManager.java:64`)。于是: +- `IMetaStoreClient` / `RetryingMetaStoreClient` → **child 定义**(插件 zip 里有 shade) +- `AWSCatalogMetastoreClient` → 插件 lib 里**没有**(shade jar 里 Glue 类 = **0 个条目**)→ 父回退 → **app 定义** +- `getProxy` 内部 `Class.forName(name, TCCL).asSubclass(IMetaStoreClient.class)`,其中 `IMetaStoreClient` 来自 **child** +- ⇒ app-loaded 的 Glue 客户端实现的是 **app 的** `IMetaStoreClient` ≠ **child 的** → `asSubclass` 失败 + → **`ClassCastException`** + +**⇒ Glue-on-HMS 与 paimon-on-DLF 今天很可能就是坏的**(paimon 更糟:child 是 hive **2.3.7**,父是 **3.1.3**)。 +**这不是猜测,是可以离线证实/证伪的** → 这就是新的 **HCS-01**(本任务最高优先级)。 + +**⇒ 对方案的意义(两头都成立,所以方案是严格改进)**:把 Glue/DLF 客户端**搬进插件**会让 caller +(`RetryingMetaStoreClient`)与 callee(客户端)落到**同一个 child loader、同一份 `IMetaStoreClient` 身份**上。 +- 若 HCS-01 证明今天已坏 → 搬迁 = **修复一个线上 bug**; +- 若 HCS-01 证明今天能跑(推演有误)→ 搬迁 = **行为保持**,且由 HCS-01 的测试守住。 + +**新增/改写的 task**:HCS-01(改为离线 classloader 测试,替代原「用户真集群 e2e 探测」)· +**HCS-30a(DLF 搬迁 spike,新增)** · HCS-30b/33(新增)· HCS-22/71(验收改为「离线必过 + 显式声明未 e2e 覆盖项」)。 + +**commit**:`7b726e224e0` + +--- + +## 2026-07-14(三) — 🚨 离线实验跑出事实:**三条外部 metastore 路径今天全是坏的**(推演证实 + 修正) + +**做了什么** +- 主 session **亲手写并跑了离线 classloader 实验**(`loader-probe-reproduction.java.txt`,本目录): + 真实 `output/fe/lib`(576 jar,`doris-fe.jar` 按 `start_fe.sh` 钉最前)当父 + 真实 + `output/fe/plugins/connector/{hive,iceberg,hudi,paimon}` 的 jar 组 **真实的** + `ChildFirstClassLoader`(fe-extension-loader 那份)+ 真实 parent-first 名单。**不是模拟,跑的是生产拓扑。** +- 6 路调研 + 6 路对抗验证(12 agent)。**对抗验证推翻了 4/6 路的关键结论**,见下。 + +### 🔴 实验结论(全部实测,非推演) + +| 路径 | 结果 | 失败机理 | +|---|---|---| +| **Glue**(hive/iceberg/hudi/paimon **全部 4 个**) | ❌ **坏** | Glue 类**不在任何插件 zip**(shade jar 里 `com/amazonaws` = **0** 条目)→ 父回退 → **app loader** 定义 → 它实现的是 **app 的** `IMetaStoreClient`;而 `RetryingMetaStoreClient` 由 **child** 定义 → 其 ctor 的 **`checkcast IMetaStoreClient`(child 的)** → **ClassCastException** | +| **DLF via hive/iceberg/hudi** | ❌ **坏** | 类身份**是对的**(shade 打进了插件 zip → child 定义,`isAssignableFrom`=**true**);但 shade 里那份 = **上游原版**,**缺** ctor `(Configuration, HiveMetaHookLoader, Boolean)`(实测只有 `(HiveConf)` / `(HiveConf,…)`)→ `getProxy` 内 `getDeclaredConstructor` **精确匹配** → **NoSuchMethodException** | +| **DLF via paimon** | ❌ **坏** | paimon zip **既无 aliyun jar 也无 hive-catalog-shade** → 父回退 → app 的(hive **3.1.3**);而 paimon 私有 shade 是 hive **2.3.7** → 身份 + 版本**双重**不匹配 | + +### ⚠️ 由此**修正** `design.md` 的两处错误(结论方向不变,机理写错了) + +1. **§3.2b 说失败在 `getProxy` 的 `asSubclass`** → **错**。字节码实证: + `JavaUtils.getClass` 只做 `Class.forName(name, true, TCCL)` 后 `areturn`(泛型擦除,**无** asSubclass)。 + 真正的失败点是 `RetryingMetaStoreClient` 私有 ctor **offset 127 的 `checkcast IMetaStoreClient`**。 +2. **R-2 说「今天生效的 DLF 客户端是 fe-core vendored 那份」** → 仅对 **app loader** 成立。 + **插件路径上 child-first 挑中的是 shade 里那份**(hive/iceberg/hudi 的 zip 里都有)→ + **「静默换实现」这件事已经在 cutover 时发生了**,不是将来的风险。 + +### 🚨 最重要:**这是本分支 cutover 引入的回归,不是历史遗留** + +- **master 是好的**:`fe/fe-core/.../hive/ThriftHMSCachedClient.java`(本分支**已删**) + `:29-30` **直接 import** 两个客户端,`:676/:678` 用 **`ProxyMetaStoreClient.class.getName()` / + `AWSCatalogMetastoreClient.class.getName()`** —— **编译期类引用**,编译器保证类在调用方 classpath 上; + 且全在 fe-core → **app loader** → caller/callee/`IMetaStoreClient` 三者自洽 → 能跑。 +- **本分支坏了**:`fe-connector-hms/.../ThriftHmsClient.java:917/:922` 把它们改写成了**字符串字面量** + → 编译期保证**静默消失** → 落到插件 child loader → 三种坏法。 +- **DLF 那个 ctor 的来历**:`fe/fe-core/.../ProxyMetaStoreClient.java:182` 注释 + **`// morningman: add this constructor to avoid NoSuchMethod exception`** —— Doris 自己给上游打的补丁。 + master 靠 `start_fe.sh:355` 把 `doris-fe.jar` 钉最前让**打了补丁那份赢**;插件 child-first 让**原版赢** → 补丁失效。 +- **影响面**:`hive.metastore.type` 是用户写在 `CREATE CATALOG` 里的普通属性 + (`HmsClientConfig.java:37/:43/:46`)→ **任何 Glue/DLF catalog 首次使用必然撞**,非边角路径。 + +### 🔬 完整证明链(字节码级,全部亲验) + +``` +ThriftHmsClient:644 TCCL := 插件 child loader +ThriftHmsClient:910 RetryingMetaStoreClient.getProxy(conf, hook, "类名字符串") + └─ 3-arg 重载 offset 7/12/17: 组 Class[]{Configuration, HiveMetaHookLoader, Boolean} + └─ JavaUtils.getClass(name, IMetaStoreClient.class) ← #149 常量池,从 child 解析 + └─ Class.forName(name, true, TCCL) ← child-first;找不到→父回退 + └─ JavaUtils.newInstance offset 94: Class.getDeclaredConstructor(...) ← 精确匹配,不做子类型适配 + └─ ctor offset 127: checkcast IMetaStoreClient (child 的) +``` + +### 🧭 六路调研结论(对抗验证后) + +| 路 | 结论 | 验证判定 | +|---|---|---| +| **Ranger `HiveOperationType`** | ✅ **是纯死代码**!唯一用途是填一个全仓**零读取**的 `ROLE_OPS`(`RangerHiveAuditHandler.java:55,57-63`)。真编译**双向**验证:删 12 行后摘掉 shade jar 仍 `javac` 通过;不删则恰好报 1 错就是该 import。**→ D-3 决策消失,直接删** | SOUND | +| **DLF spike** | ❌ **路线 A/B 双双证伪**:`ProxyMetaStoreClient` 是 **hive3-only**(javap 实证实现了 `createCatalog`/`createISchema`/`getValidWriteIds` 等 hive3 专有方法),而 paimon 私有 shade 是 hive **2.3.7**(那些 api 类 grep 计数=0)。包名 `hive2` 是**误导性遗留名** | MOSTLY_SOUND | +| **零引用依赖** | ⚠️ 前轮「5 项全零引用」**错 3 项**。真可删只有 `hudi-hadoop-mr`。**`kryo-shaded` 绝不可删**(`WorkloadSchedPolicy.java:32,287,298` 经 minlog 真实调用)。`avro` 删声明但 jar 删不掉(三方传递) | MOSTLY_SOUND | +| **Glue 搬迁** | 可行但 3 个 blocker:① `ConfigurationAWSCredentialsProvider.java:89` 读 `Config.aws_credentials_provider_version`(fe-common,插件不依赖它)② fe-core `HiveGlueMetaStoreProperties.java:24` **反向** import vendored Glue ③ **3 个文件 import 裸 `org.apache.thrift.TException`**,而 fe-connector-hms 编译期**无 libthrift** → 直接搬**必编译失败**(改 shade 前缀即可;**绝不能加 libthrift**,那正是 shade 要防的冲突)。checkstyle **零工作量**(`suppressions.xml:65` 是路径正则,自动跟着走) | MOSTLY_SOUND | +| **5 个属性类** | ✅ 已是**死代码**可直接删("hms" 走 `SPI_READY_TYPES` → plugin supplier → `getMetastoreProperties()` 永不被调)。但 `HiveTable.java`/`HMSResource.java` 两个 **GSON 持久化活类**只为拿 String 常量而 import 它们 | MOSTLY_SOUND | +| **fe-common** | 可解:`CatalogConfigFileUtils` 只删 `loadHiveConfFromHiveConfDir` 那一半(另一半 `loadConfigurationFromHadoopConfDir` 服务**所有**外表 catalog,必须留)。⚠️ `loadHiveConfResources` **不是死代码**(IcebergConnector:707 / PaimonConnector:376 真调),且现语义是把 `new HiveConf()` 的**上千条默认值**灌进插件 HiveConf → 换实现**必须显式签字**(硬约束 2) | MOSTLY_SOUND | + +**未决**:见 HANDOFF「待用户签字」。**尚未动任何生产代码。** + +**commit**:(本次文档更新) + +--- + +## 2026-07-14(四) — 🔀 用户拍板:**删掉 thrift 一代 Glue/DLF**,任务定性改变 + +**用户指示**:*「把 glue 和 dlf 这两个功能先删掉,不再支持了,不作为需要迁移的内容。」* +**范围签字**:**只删「走 HMS thrift 协议的那一代」**,保留 iceberg 原生 Glue + DLF 2.0 REST。 + +**为什么翻案(且这次翻案是合规的)**:`design.md §4.1` / 原 tasklist 立过一条硬约束 +「**禁止**『反正可能已经坏了 → 干脆删功能』」。那条约束成立的前提是**没人知道它到底坏没坏**。 +本轮离线实验**实测证明四条路径全坏**(见上一条),用户在**信息充分**的前提下决策不修直接删 —— +这正是那条约束要保护的东西,不是绕过它。 + +**⇒ 方案塌缩为【纯删除】**:不做守门测试 · 不做 paimon-on-DLF · 不做客户端搬迁 · 不需要降级档 · +总判据自然归零 · fe-core 全程只减不增(铁律 A/B 天然满足)。原「阶段 2/3 搬迁」全部作废。 + +**用户否决的(记下来别回头做)**:守门测试「不做」· paimon-on-DLF「不做」。 + +### 🔴 本轮新发现:**第四个实例 —— iceberg 原生 Glue 的 AK/SK 路径也是坏的** + +主 session 先前告知用户「iceberg 原生 Glue 今天是好的」——**这个说法不完整,已当场更正**。 +只查了 `GlueCatalog` 类本身在插件包里(在),**漏查了它按名加载的凭证类**: + +`IcebergCatalogFactory.java:559-560` 在 AK/SK 分支把 `client.credentials-provider` 设为 +`com.amazonaws.glue.catalog.credentials.ConfigurationAWSCredentialsProvider2x`(**住 fe-core,不在插件包**) +→ iceberg 的 `AwsClientProperties` 用 child loader 按名加载 + `asSubclass` 到 **child 的** +`software.amazon.awssdk...AwsCredentialsProvider` → **CCE**。**实测 `isAssignableFrom = false`。** + +- ✋ IAM-role 分支(`AssumeRoleAwsClientFactory.class.getName()`,**编译期类引用**)**没病** —— 又一次印证 + 「编译期类引用 vs 字符串字面量」就是这一整类 bug 的分水岭。 +- ✋ 默认凭证链分支(不填 AK/SK)**没病**。 +- **修法 = T-20**:把这 **50 行**搬进 `fe-connector-iceberg`(是**出** fe-core,合铁律 A),进插件包即自愈。 + +### 本轮调研新增的关键事实(4 路清单 + 4 路对抗验证) + +- **Glue 树 38 个文件里,只有 37 个可删** —— `ConfigurationAWSCredentialsProvider2x.java` 归**保留路径**(T-20 搬走)。 + 同目录的 `ConfigurationAWSCredentialsProvider.java`(v1) + `...Factory.java` 归 thrift 一代(删)。 +- 🔴 **「删分支」会静默走错路**:`getMetastoreClientClassName` 是 `if(dlf)…else if(glue)…else→HiveMetaStoreClient`。 + 光删分支 ⇒ `hive.metastore.type=glue` 落到 `else` ⇒ **静默去连普通 HMS**。**必须改成显式抛异常。** +- `aws-java-sdk-glue` 为 vendored 树**独占**(树内 712 处引用,树外 0)→ 可随树删。 +- `createV1` 的**唯一调用点**是 `ConfigurationAWSCredentialsProvider.java:78`(本次删除对象)→ 删后成死代码。 + ✋ `createV2`/`getV2ClassName` 仍活(`S3Properties.java:350,363,391,406`)。 +- 删 `AWSGlueMetaStoreBaseProperties` 对 `SHOW CREATE CATALOG` **脱敏是字节中性的**:其唯一敏感字段 + `glueSecretKey` 的三个别名被**保留的** `S3Properties.java:104-106` 逐字覆盖。 +- iceberg 的 `aws.catalog.credentials.provider.factory.class` 这个 key **只被被删的 thrift 树读** + (`AWSGlueClientFactory.java:114-118` / `AWSGlueConfig.java:33-34`);iceberg 原生 `AwsClientProperties` + **从不读**它(javap 已证)→ 删除**行为中性**。 + +### ⚠️ 落刀前必须先解决的两个未决问题(对抗验证挖出) + +1. **T-54 删 `hudi-hadoop-mr`**:`orc-core-1.8.4`(`fe/lib` 实装 jar)的**公开 API 直接依赖 + `hive-storage-api` 的类** → 删了会不会炸 orc?**查清前不许删。** + (前一轮只查了「fe-core 自己的源码引用」,方法论有系统性缺口。) +2. **T-51 删 `HMSBaseProperties`**:「已是死代码」证据链**有洞** —— `PluginDrivenExternalCatalog.java:156` + 设 supplier,但 `createConnectorFromProperties()` 在 **:129** 就被调(**早于** :156)→ 有无真实调用窗口? + +**commit**:(本次文档更新) + +--- + +## 2026-07-14(四)— 阶段 1 完成:iceberg 原生 Glue 凭证类搬出 fe-core + 改包 + +**commit**:`2cd01ada8df`(code,独立)· 基线 `570bbc89cf8` +**方法**:10-agent 侦察(源码 / 全仓引用 / 插件 pom+dependency:tree / 真实 jar javap / Trino 对照) ++ 4 路对抗验证(每路独立复核,不采信侦察结论)。10/10 完成,0 error。 + +### 做了什么 + +`ConfigurationAWSCredentialsProvider2x`(50 行) +`fe/fe-core/.../com/amazonaws/glue/catalog/credentials/` +→ `fe/fe-connector/fe-connector-iceberg/.../org/apache/doris/connector/iceberg/glue/` + +**用户 2026-07-14 拍板「改成 Doris 自己的包」**(非原样搬)。理由:老 FQN 今天 100% 坏 → +无「工作中的用户配置」会被打破;而 `com.amazonaws.glue.catalog.**` 整棵树马上删光, +插件里留同名包会让后人 grep 时误判「没删干净」。 + +改动面 4 文件(git 识别为 rename):新类 · fe-core 删除 · `IcebergConnectorProperties.java:142` 常量值 · +`IcebergCatalogFactoryTest.java:556` 断言。**pom 零改动**。 + +### 验证(信实测不信推演) + +- `mvn -pl fe-connector/fe-connector-iceberg -am test`:**BUILD SUCCESS**,`IcebergCatalogFactoryTest` + **63/63 通过**,checkstyle 0 +- `mvn -pl fe-core -am test-compile`:**BUILD SUCCESS**,checkstyle 0(证明 fe-core 删掉该类无编译面破坏) +- `tools/check-connector-imports.sh`:**exit 0**(HiveVersionUtil 两条是已知误报) +- **决定性探针**(真实 `ChildFirstClassLoader` + 真实插件 jar 183 个 + app-model 父加载器 576 jar, + 同一次运行的前后对照): + +``` +--- BEFORE (旧 FQN,仍在 stale doris-fe.jar 里) --- + resolved from : app-model + VERDICT native-Glue creds (old): isAssignableFrom = false +--- AFTER (搬进插件后) --- + resolved from : ChildFirstClassLoader + VERDICT native-Glue creds: isAssignableFrom = true [FIXED] + create(Map) returned : org.apache.doris.connector.iceberg.glue.ConfigurationAWSCredentialsProvider2x + resolveCredentials() : accessKeyId=AKIA_PROBE +RESULT: PASS +``` + +### 侦察挖出的事实更正(**文档此前是错的**) + +1. **模块路径是嵌套的**:`fe/fe-connector/fe-connector-iceberg`,**不是** `fe/fe-connector-iceberg`。 + `-pl fe-connector/fe-connector-iceberg -am`(`-am` 必填,否则 `${revision}` 假错)。 +2. **真实报错不是 CCE**:是 `IllegalArgumentException "Cannot initialize ..., it does not implement + software.amazon.awssdk.auth.credentials.AwsCredentialsProvider"` —— iceberg `AwsClientProperties` + 的 `isAssignableFrom` 门禁(`Preconditions.checkArgument`)在 `checkcast` **之前**就拦下了。修法不受影响。 +3. **`auth:2.29.52` 早在插件 compile classpath 上**(经**未声明**的传递路径:declared `s3` → `auth`)。 + 反向对照实验:从 classpath 剥掉 `auth-2.29.52.jar` → `package ... does not exist`,证明确是它在起作用。 + 全 classpath 扫描 `AwsCredentialsProvider.class` 只有 1 份 → 无 shaded 副本污染。 +4. **新包命中 parent-first 前缀**:`org.apache.doris.connector.` 在 `CONNECTOR_PARENT_FIRST_PREFIXES` 里 + → 子加载器先问父。**成立靠「父无此类 → CNFE → 回退子加载器 findClass」**,已实测; + 与插件内其它所有类同一模式(`IcebergCatalogFactory` 等皆如此)。 + +### Trino 对照(架构层) + +Trino **不走「传类名 → 反射加载」这一跳**:自己实现 `TrinoGlueCatalog`,在 Guice module 里 +**直接构造实例** `StaticCredentialsProvider.create(AwsBasicCredentials.create(k, s))` 交给 +`GlueClient.builder().credentialsProvider(instance)`。实例自带 `Class`,在插件加载器里解析一次 → 天然无 split-brain。 + +**未照搬的理由**:我们用 iceberg 官方 `GlueCatalog`,它只接受属性 map,唯一注入点就是类名字符串; +要绕开须自实现 `AwsClientFactory` —— 而 iceberg 加载 `client.factory` **同样按名反射**,同样一跳,代码更多。 +搬 50 行是最小修法。 + +**另证**(javap,排除「根本不需要这个类」的可能):iceberg 的 `AwsClientProperties` **确有**内建静态 AK/SK +阶梯(`credentialsProvider(ak, sk, token)` → `StaticCredentialsProvider`),但 Glue client 走的 +`applyClientCredentialConfigurations` **只调 `credentialsProvider(String)`**(25 条指令,全扫描确认 +3-arg 重载只被 `AwsProperties.restCredentialsProvider` 和 `S3FileIOProperties` 引用) +→ **自定义类是真必需**,不能用 iceberg 原生属性替掉。 + +### 🆕 侦察挖出的两个遗留项(**清单里原本没有**) + +1. **T-21(待用户签字)**:`create()` **静音丢弃 session token**。`IcebergCatalogFactory.java:565-566` + 发 `client.credentials-provider.glue.session_token`、单测 `:559` 断言它,但 `create()` 只造 + `AwsBasicCredentials`,**从不造 `AwsSessionCredentials`** → 临时 STS 凭证认证必失败。 + 既有 bug,非搬迁引入;属**行为变更** → 未动。 +2. **T-30 落刀前必查**:`IcebergCatalogFactory.java:563-564` 发的 + `aws.catalog.credentials.provider.factory.class` → `ConfigurationAWSCredentialsProviderFactory` + **指向一个即将被 T-30 删掉的 fe-core 类**(第二条 fe-core→插件按名耦合)。 + T-34 称「iceberg 原生从不读该 key,删除中性」→ **落刀前 javap 复核**。 + +--- + +## 2026-07-14(五)— 阶段 2 完成:删 thrift 一代 Glue + +**commit**:`e43173eca67`(code,独立)· 基线 `5e6351e6d8a` +**方法**:6-agent 侦察(阻塞项/树闭包/分派/属性类/iceberg 切面/regression 面)+ 5 路对抗验证 ++ 1 个专项 agent 查 FE 生命周期。11/11 完成,0 error。 + +### 规模 + +**53 文件改动,-11,245 行 / +139 行。** fe-core main 的 `com.amazonaws.*` 引用 **归零**。 +总判据 26 → **7**(余下为 DLF/hive 部分)。 + +### 阻塞项已解除(原 HANDOFF 的「落刀前必查」) + +`aws.catalog.credentials.provider.factory.class` 的 `opts.put` 删除**行为中性 = CONFIRMED**,字节码级: +- 拆插件目录**全部 183 个 jar**(侦察只查了 6 个,验证者扩到全量)、**6603 个 class** → 该 key **0 命中** +- **正对照**(关键):同一 grep、同一语料,`appendGlueProperties` 发的**其它每一个** key 都能命中 + (`client.credentials-provider`→4、`client.factory`→7、`client.region`→2 …)→ 空结果是真信号不是坏 grep +- 唯一读者 `AWSGlueClientFactory.java:115`(`conf.getClass(...)`)在**被删树内** +- `AwsClientProperties.(Map)` 的 javap 显示它读的是**封闭集合**,不含该 key + +### 🔴 本轮最重要的发现:拒绝逻辑放错地方会让 FE 起不来 + +原计划只说「必须改成显式抛异常」,没说放哪。查 FE 生命周期后发现**三个位置后果完全不同**: + +| 位置 | 新建 catalog | **edit-log 回放** | +|---|---|---| +| `validateProperties` | ✅ 报错 | ✅ **不跑**(`CatalogMgr:551-553` 有 `!isReplay` 门)→ 安全 | +| `create()` / 连接器构造函数 | ✅ 报错 | 💀 **会跑**(`CatalogFactory:105-113` 两条路径都建 connector)→ 抛异常 → `EditLog.loadJournal:1521-1539` catch-all **`System.exit(-1)`** → **FE 起不来**(`OP_CREATE_CATALOG=320` 不在默认 skip 列表;恢复需运维手改 `fe.conf`) | +| `createClient()` | ✅ 报错 | ✅ 懒加载,不跑 → 安全 | + +⚠️ **代码库已有的 lakesoul「已移除」先例(`CatalogFactory.java:141-142`)恰好放在没有 isReplay 门的地方** +→ checkpoint 之后建的 lakesoul catalog 会让 FE 启动失败。**那是个未修的潜在 bug,别照抄它的位置。** + +### 另一个发现:光在分派处抛异常根本不会生效 + +`HiveConnector.createClient:538-546` 的「HMS URI 必填」检查**跑在分派之前**。真实 glue catalog +**不配** `hive.metastore.uris`(只配 `glue.endpoint`/`glue.access_key`),所以: + +| 配置 | 只删 glue 分支后的结局 | +|---|---| +| `type=glue`,无 uris(**正常的 glue catalog**) | 报 `"HMS URI ('hive.metastore.uris') is required"` —— 响,但**与 glue 毫无关系**,误导 | +| `type=glue` + 多余的 uris | **静默连普通 HMS** —— 原记录的风险,真实存在但更窄 | + +两种结局都不告诉用户「glue 被移除了」→ 都指向同一个修法。**拒绝必须放在 URI 检查之前。** + +### 用户 2026-07-14 拍板:两处都加 + +- `HiveConnectorProvider.validateProperties` → 抛 **`IllegalArgumentException`** + (`PluginDrivenExternalCatalog:193-199` **只解包这一种**并 `new DdlException(e.getMessage())` 原样透出; + 抛别的类型文案会被包烂。先例 `CacheSpec.checkLongProperty` 同样抛它) +- `HiveConnector.createClient` → 抛 **`DorisConnectorException`**(邻居惯例;fe-core 有 5+ 处专门 catch 它, + 改抛 IAE 会绕过那些 handler),**放在 URI 检查之前** +- 共享的是**文案**(`HmsClientConfig.removedMetastoreTypeError`),不是 throw —— 两处异常类型必须不同 + +**偏离原计划**:`METASTORE_TYPE_GLUE` 原计划「无消费者→删」,实际**改名保留**为 +`METASTORE_TYPE_GLUE_REMOVED` —— 它有了新消费者(识别并拒绝该已移除类型),比内联字面量清楚。 + +### 覆盖了老用户升级场景 + +老 glue catalog 从 image 反序列化(`CatalogMgr.read` → GSON 直接建对象,**`CatalogFactory` 全程不参与**), +**永不经** `validateProperties`。所以:重启不受影响(不阻塞启动),第一次查询时由 `createClient` +那处拒绝给出同一句话——而不是 jar 删掉后的 `ClassNotFoundException`。 + +### 测试 + +新增 `HmsClientConfigRemovedTypeTest`(2 个用例)。**动机**:侦察发现 +`getMetastoreClientClassName` **全仓零测试覆盖** → 拒绝逻辑被误删不会有任何东西变红。 +两个用例分别钉「glue 必须被拒且文案点名」与「存活类型 hms/dlf 路由不变」(后者防拒绝逻辑误伤面过大)。 + +**已做变异验证**:把拒绝条件改成 `if (false)` → 测试**确实转红** +(`AssertionFailedError: hive.metastore.type=glue must be rejected, never silently ignored`)。 + +⚠️ **自己踩坑并纠正**:变异验证**第一次漏了 `-am`** → `BUILD FAILURE` 是 +`org.apache.doris:fe-connector:pom:${revision}` 解析假错、**根本没编译**到测试 → 首次「变异验证通过」的 +结论**作废**,加 `-am` 重做才是真的(memory `doris-build-verify-gotchas` 早有记载,仍复发)。 + +### 验证汇总(信 LOG 不信 exit) + +- `mvn -pl fe-core -am test-compile` → **BUILD SUCCESS**,checkstyle 0 +- `mvn -pl fe-connector/{fe-connector-hms,fe-connector-hive,fe-connector-iceberg} -am test` → **BUILD SUCCESS**, + 零 Failures/Errors,checkstyle 0 +- `tools/check-connector-imports.sh` → **exit 0** +- `grep -rn "com\.amazonaws" fe/fe-core/src/main/java` → **空** + +### 对抗验证的 2 个 REFUTED(都是**命题写漏**,非计划有错) + +1. 「树自洽,除属性类外无外部引用」→ **REFUTED**:漏列了 `ThriftHmsClient` 的 `GLUE_CLIENT_CLASS` + 反射字符串(存活模块内)。**计划本就要删它**,是命题的豁免清单写漏。 +2. 「删树+属性类+createV1 后 fe-core 零 com.amazonaws 且仍能编译」→ **REFUTED**:零引用**成立**, + 但「仍能编译」不成立 —— 会悬空两条硬编译边(`HivePropertiesFactory:38` 的**构造器引用**、 + `DatasourcePrintableMap:61` 的**类字面量**)。二者**本就在计划的删除清单里**,是我的命题把删除集写小了。 + → 实际删除时二者均已处理,`test-compile` 实测绿。 + +### 阶段 2 遗留 + +见 `tasklist.md` 「阶段 2 遗留」:regression-test 清理(含**文件级 guard 钉在 glue 专有 conf key** 的坑)· +`test_connection=true` 顺序坑 · 文案里 `Supported types: hms, dlf` 待阶段 3 去掉 `dlf`。 + +--- + +## 2026-07-14(六)— 阶段 3 完成:删 thrift 一代 DLF(DLF 1.0) + +**commit**:`a0f65c353a9`(code,独立)· 基线 `ca1f0c4f427` +**方法**:6-agent 侦察(含 1 路**安全专项**)+ 5 路对抗验证。11/11 完成,0 error。 + +### 规模 + +**43 文件改动,-4,095 / +184。** fe-core 的 `com.aliyun.datalake` **归零**;总判据 **7 → 4**。 + +### 🔴 本轮最重要的发现:照原计划删会**明文泄漏用户凭证** + +原计划 T-44 只写「删 `AliyunDLFBaseProperties`」。但它同时挂着 `DatasourcePrintableMap` 的 +**`SHOW CREATE CATALOG` 凭证脱敏注册**。Glue 那次删掉是安全的(S3 属性类逐字覆盖同样别名), +**DLF 不成立**——javap 逐字段确认,4 个敏感别名的覆盖**不均匀**: + +| 别名 | 删掉注册后是否仍脱敏 | +|---|---| +| `dlf.secret_key` | ✅ `OSSProperties`/`OSSHdfsProperties` 逐字覆盖 | +| `dlf.catalog.accessKeySecret` | ❌ **无人覆盖** | +| `dlf.session_token` | ❌ **无人覆盖**(OSS 的 sessionToken 别名里没有它) | +| `dlf.catalog.sessionToken` | ❌ **无人覆盖** | + +**为什么升级后真的会泄漏**(验证者独立复核确认): +- 脱敏是 `SENSITIVE_KEY.contains(key)` 的**原始 key 匹配**,**不按 catalog 类型**, + 且 `showCreateCatalog` 这条路上**没有兜底启发式**(`MetadataGenerator` 的后缀启发式只服务 `catalogs()` TVF, + 且它也漏 `dlf.catalog.accessKeySecret`/`dlf.catalog.sessionToken`)。 +- 老 DLF catalog **回放时不被拒绝**(阶段 2 刻意设计:拒绝只在 CREATE + createClient,回放放行否则 FE 起不来) + → 它**仍在目录里、仍可 `SHOW CREATE CATALOG`**(该路径**从不调** createClient)。 +- ⇒ 删掉注册 = 那些存着的 token/密钥**从打码变明文**。 + +**修法(用户 2026-07-14 拍板)**:照同文件 **iceberg REST 块的既有范式**,把 4 个 key 显式列出。 +讽刺的是那段注释早就写着警告:*"the overlap with S3Properties above is uneven and must NOT be relied on ... +omitting it here would silently unmask it"* —— 同一个陷阱,第二次。 + +### ✅ DLF 2.0 REST 与被删代码完全不相干(字节码级) + +`PaimonRestMetaStoreProperties` `extends AbstractMetaStoreProperties`(**不是** `AbstractDlfMetaStoreProperties`), +常量池**零** DLF 引用;key 是 `paimon.rest.dlf.*`,与被删的 `dlf.catalog.*` **结构性隔离**; +token provider 是 **Paimon 自己的**(由 `token.provider=dlf` 选项字符串选中),**Doris 侧无对应类**。 +`AbstractDlfMetaStoreProperties` 的消费者只有被删的 Paimon/Iceberg 两个 DLF 属性类 —— REST 不在其中。 + +### 侦察挖出的、原计划没有的事 + +1. **跨模块硬引用**:原以为 fe-core 那棵树只被一个反射字符串引用;实际 **5 处**,其中 + `DLFClientPool.java:20` 是**硬 import**(iceberg 插件直接编不过)、`PaimonCatalogFactory` 是 prod 字符串。 + 两者都在本阶段范围内 → 一起删即闭合。 +2. **该树寄生在 hive-catalog-shade 上**:它 import 的 `com.aliyun.datalake.metastore.common.*` 就住在那个 + 127MB jar 里(paimon 侧注释亦自证:*"host-provided via hive-catalog-shade at cutover"*)。**删它正是目标。** +3. **`fe-filesystem-oss` 的 dlf 是良性的**:只是 OSS 凭证的 `@ConnectorProperty` **别名字符串**,零 DLF 类依赖, + 且**非 DLF 的 OSS catalog 也可能用到** → **不动**(删它有风险无收益)。 + +### 复用阶段 2 基础设施(零新增落点) + +拒绝逻辑直接扩展 `HmsClientConfig.removedMetastoreTypeError`:两个已移除类型收敛成一张 +`REMOVED_METASTORE_TYPES` 表,文案自动收敛为 `Supported types: hms.`。两个调用点 +(`validateProperties` + `createClient`)**原样复用**。iceberg/paimon 侧 default 分支**本就 fail-loud**, +不需要新增拒绝点。 + +**偏离原计划**:dlf 分支删光后 `getMetastoreClientClassName` 成了恒返回同一值的空壳 → **连方法一并内联**。 + +### 测试:7 处「反转」 + +把「dlf 可被分派/路由」的断言全部反转为「dlf 必须被拒」(含「provider 必须从 ServiceLoader 消失」 +—— 陈旧的 services 条目会复活一个客户端已不存在的后端)。`restDlfTokenProviderRequiresAkSk` **保留**(测 REST)。 + +### ⚙️ 两个构建坑(**新踩,务必带走**) + +1. **maven build cache 静默跳过 surefire**:日志 `Skipping plugin execution (cached): surefire:test` → + **BUILD SUCCESS 但 0 个测试执行**。我第一次就被骗了,靠数 `Tests run:` 行数才发现(0 行)。 + 必须 `-Dmaven.build.cache.enabled=false`。**只 grep BUILD SUCCESS 是不够的,要数测试数。** +2. **依赖 shade 模块的模块必须跑到 `package`**:`fe-connector-paimon` 的 `HiveConf` 来自 + `fe-connector-paimon-hive-shade`,shade 产物只在 `package` 阶段生成;`test-compile`/`test` 会让 maven 用 + 该模块**空的 target/classes** → 假报 `package org.apache.hadoop.hive.conf does not exist`(**不是代码错**)。 + `install` 亦不可用(`fe-type` 撞预存 quirk)。**用 `-am package`。** +3. 又一次漏 `-am` 撞 `${revision}` 假错(memory 早有记载,本 session 第二次)。 + +### 验证汇总 + +- `mvn -pl <8 连接器> -am package -Dmaven.build.cache.enabled=false` → **BUILD SUCCESS**, + **229 个测试类、零 Failures/Errors**,checkstyle 0 +- `mvn -pl fe-core -am test-compile` → BUILD SUCCESS +- `tools/check-connector-imports.sh` → exit 0 +- `grep -rn "com\.aliyun\.datalake" fe/fe-core/src/main/java` → **空** +- 脱敏 4 个 key 逐条核对在位 + +### 过程中自我纠正 2 次 + +1. 我给新测试写的断言 `assertFalse(msg.contains("dlf."))` **本身是错的** —— 报错里的 `dlf.` 来自**回显用户输入** + (`type: dlf. Supported types:`),不是「supported 列表含 dlf」。测试自己红了才发现 → 改成只截取 + `Supported types:` 之后的子串再断言。 +2. `git add -A fe/` 把非本线程的 `fe/.mvn/maven.config` 卷了进来 → `git restore --staged` 剔除。 + **这正是纪律禁止 `git add -A` 的原因**(本 session 亲自复现)。 + +--- + +## 2026-07-14(四)— 阶段 4:fe-core 去 hive 化 **判据 26 → 0** ✅ + +**commit**:`8d6fe9f9736`(hudi-hadoop-mr)· `d8c121b7f21`(Ranger 死代码)· +`22461468e7c`(HMS 属性簇)· `7ed266c677a`(hive 配置加载去 hive 化) + +### 方法:5 路侦察 + 20 路对抗验证(每路 4 个视角专门证伪) + +**这一轮的最大收获不是删了多少行,而是证伪了原计划的 3 个前提。** 前三阶段的教训 +("侦察挖出的东西全都不在原计划里,且都会出事")**再次完全应验**: + +| 原计划的说法 | 实测 | +|---|---| +| "删 `hudi-hadoop-mr` 会炸 ORC,未解决前不许删" | **假警报**。全仓 `org.apache.orc` 引用 = **0**;shade 里逐字节相同地捆了全部 122 个 `hive-storage-api` 类(`IDENTICAL=122 DIFFERENT=0`) | +| "那两个 HMS 属性类是死代码,删 2 个文件" | **非死代码**(`MetastoreProperties:88` 仍注册)。真实闭包 = **4 文件 + 1 行反注册**,任何中间态都编译不过 | +| "插件不可能自己解析 `hadoop_config_dir`,所以必须由 fe-core 加载 hive 配置文件"(**这句就写在 SPI 注释里**) | **证伪**。`fe-filesystem-hdfs` 是真 leaf(pom 无 fe-core/fe-common),**已经**通过 `doris.hadoop.config.dir` 系统属性桥解析同一个目录,且有测试。→ 解锁了原计划四个选项里都没有的做法 | + +**另挖出一个 OUTAGE 级排期错误**:原计划把 fe-common 的清理排成"阶段 4 的一件事"。 +但 `CatalogConfigFileUtils` 服务**所有**外表 catalog —— 若 fe-core 先摘 jar 而它还 import +`HiveConf`,**编译绿但 FE 启动时每个 catalog 都炸**。改排为"与摘 jar 同批"。 + +### 用户拍板(2026-07-14) + +1. **常量归属 = 纯内联字面量**(非"加本地常量")。理由:铁律 A 原文是绝对的,没有"实质满足" + 这一档;两者行为逐字节等价,该由用户签字而非被一句措辞消化掉(Rule 7)。 +2. **hive 配置加载 = 插件自解析**(非"改名为通用 hadoop 加载")。理由:改名只是把 hive 味道 + 藏起来、口子仍在通用 SPI 上;且改名方案的"不丢配置"挂在 + `ChildFirstClassLoader` 未 override 单数 `getResource` 这个**与其 javadoc 相反的实现意外**上, + 无测试锁定。对标 Trino 也是自解析形状。 +3. **三个既有缺陷全部立项记录**(D-1/D-2/D-3,见 `tasklist.md`)。 + +### 行为变化台账(**不在无字节级证据时声称 no-op**) + +| 改动 | 证据 | 判定 | +|---|---|---| +| 删 `hudi-hadoop-mr` → `org.apache.hadoop.hive.*` 的实际提供者从 `hive-storage-api-2.8.1.jar` **翻转**到 shade(`start_fe.sh` 逐 jar 前插 → 逆字母序生效) | 全部 122 类 `cmp` → `IDENTICAL=122` | ✅ provable no-op | +| 删 ROLE_OPS | 全仓零读取点;`private static final`(Gson 不序列化静态) | ✅ behavior-neutral | +| 删 HMS 属性簇 | 4 文件零 `sensitive` 字段 + `DatasourcePrintableMap` 零 hive 引用 | ✅ 不丢脱敏 | +| 同上,degraded-replay 文案变为 `Unsupported metastore type: HMS` | 与已上线的 iceberg/paimon 在同一代码行对称 | ⚠️ 接受 | +| 配置加载改自解析 —— **iceberg** | 捆的就是同一个 shade 3.1.1,默认值字节相同 | ✅ provable no-op | +| 配置加载改自解析 —— **paimon** | 28 个 ConfVars 默认值回到自带 2.3.9:27 个是 planner/LLAP/Tez/txn/stats/metastore-server 键 + 3 个 security-list 键,客户端不读;唯一 metastore-client 键经反编译确认 2.3.7 客户端不读 | ⚠️ **真实但有界,方向是变正确** | + +**顺带消除一个没人设计过的副作用**:旧实现只在设了 `hive.conf.resources` 时才灌默认值 → +两个只差这一行属性的 catalog,拿到的 HiveConf 默认值**版本都不一样**(fe-core 的 3.1.1 盖到 +paimon 自带的 2.3.9 上)。那是迭代 HiveConf 的意外,不是契约。 + +### 踩坑(下轮务必带上) + +1. **`-am` 又漏了一次**(记忆里明明写着):报 `org.apache.doris:fe:pom:${revision}` 无法解析 + —— **那是没编译,不是代码错**。 +2. **🆕 变异验证第一次作废**:build 确实转红了,但红在 **checkstyle**(`if (true) { return; }` + 违反 `LeftCurly`)而非断言上 —— **测试根本没跑**。按 checkstyle 规范重做后才拿到真结论 + (`expected: but was: `)。**变异代码也要合 checkstyle,且必须确认红在断言上。** +3. **🆕 假阴性陷阱**:`grep DatasourcePrintableMap` 时路径写错 → 报"文件不存在"而 grep 返回空 + → 差点被读成"零引用,脱敏安全"。**"因为文件找不到所以零命中"正是阶段 3 明文泄漏的同款陷阱。** +4. **编译边靠真编译抓、不靠清单**:对抗验证已警告 edit list 会漏编译边,实际漏的是 + `PaimonCatalogFactoryTest` + `IcebergCatalogFactoryTest` 两处 `assembleHiveConf` 调用 + (对抗验证点名的那处 `TcclPinningConnectorContextTest` 反倒记住了)。 +5. **并发 session 实锤**:本 session 期间另一 session 提交了 `1ae046f82cc`(iceberg scan 修复), + 工作面不相交。路径白名单 add + 小步快提交是有效的。 + +--- + +## 2026-07-15 — 阶段 5(pom 终局):jar 出 `fe/lib`,侦察推翻原计划的**两个核心前提** + +**commit**:`0c35090a4f3`(摘 jar 本体,原子提交)+ 后续卫星项/hudi 提交。 + +**做了什么** +- 7 路并行侦察 + 17 路对抗验证(24 agent,1.41M token),主 session **逐条亲验**关键结论。 + +### 🔴 原计划的两个前提**都是错的**(方向恰好相反) + +**前提一「摘 jar 会连带掉 14 个 compile 传递依赖」= 幻影。** +文档那份「41 依赖 / 14 无 scope」读的是 `doris-shade` 里的**源码 pom(3.1.2-SNAPSHOT)**。 +构建真正解析的 **3.1.1** 是 shade 插件产出的 *dependency-reduced pom*:31 个 `` 里 +30 个在 ``(不产生依赖),唯一顶层依赖是 `bcpkix-jdk18on`,还是 `provided`。 +`dependency:tree` 实证该节点是**叶子** ⇒ **摘除它不带走任何 artifact**。 + +**前提二「fe-core 判据已归零 → pom 那 4 行只是症状」= 反了。真正的阻塞在这里。** +该 jar **捆着 66,347 个 class**,实测扫 fe-core 编译期 classpath 全部 540 个 jar,**它是四处代码的唯一提供者**: + +| 断根项 | 用量 | 处置 | +|---|---|---| +| `org.json.JSONObject/JSONArray` | 主代码 **19 文件** + 1 测试 | 显式声明 `com.tdunning:json`(Open JSON)。**决定性证据**:jar 里 9 个 `org/json/*.class` 与 `com.tdunning:json:1.8` **SHA 逐字节全等(9/9)** ⇒ 运行期字节零变化。`org.json:json` 原版许可为 ASF **category X**(父 pom 正为此把它从 hadoop-cos 排除) | +| `org.apache.iceberg.relocated.com.google.common.*` | fe-core vendored 的 `org/apache/iceberg/DeleteFileIndex.java` | **删除**:全仓零引用的迁移遗留死代码,连接器侧已自带副本(checkstyle 豁免规则仍服务那份,保留) | +| `jline.internal.Nullable` | 1 处(`RestBaseController:39`) | 误 import → `javax.annotation.Nullable`(jsr305 独立提供,58 处在用) | +| hive 类 + `shade.doris.hive...TException` | `HMSIntegrationTest` | **删除**(用户签字):`@Disabled` + 路径常量全空 + 不碰任何 Doris 类的手工残骸 | +| `org.apache.ivy.util.StringUtils` | `FeNameFormatTest:24` | 误 import → commons-lang3 | + +### 🔴 侦察没抓到、**实跑编译才抓到**的第三个前提错误:Caffeine + +该 jar 还捆着 **Caffeine 2.x**(681 个实体类)。`start_fe.sh` **倒序**拼 classpath(每 jar 前插), +glob 里 `hive-catalog-shade` 排在 `caffeine-3.2.3.jar` 之后 ⇒ **FE 运行期一直跑 hive jar 里那份 2.x, +pom 声明的 3.2.3 从未生效**。`CacheBulkLoader` 能写 2.x 的 `loadAll(Iterable)` 正因如此。 +**用户签字:翻到 3.2.3**(现实对齐声明)。 +- 证据:自写字节码常量池扫描器(**沿继承链解析**,第一版无继承解析产生 20 处假阳性),对 + `output/fe/lib` **全部** caffeine 消费者做链接检查 → **零缺失**。另两处调 2.x 签名的 + (`analyticsaccelerator-s3` / `flight-sql-jdbc-driver`)用的是各自 shade 的**私有副本**。 +- `CacheBulkLoaderTest` **已做变异验证**:打断 override 关系 → **红在断言上**(非 checkstyle/编译)。 + +### 🔴 对抗验证**推翻了原计划的一条删除指令**:commons-lang **必须留** + +T-61 原写「删 `commons-lang` 2.6」。两个验证 agent 各自扫运行期 classpath 全部 572 jar 的常量池,结论一致: +- 该 jar 的 pom 里 `commons-lang` 是 **`provided`(不传递)** ⇒ **jar 从来不是它的提供者**, + fe-core 这条显式声明是全 FE **唯一**来源; +- 真正需要它的是 **`hadoop-huaweicloud`**(`OBSFileSystem`/`OBSLoginHelper`/`OBSCommonUtils` 均引用 + `org.apache.commons.lang.StringUtils`,其自身 pom 零声明)与 `bce-java-sdk`; +- **反射 + 探测不设初始化**:`OBSProperties` 用 `Class.forName(..., initialize=false)` 探测 ⇒ 删掉 + commons-lang 后探测**仍成功**、S3A 降级**不触发**,等 Hadoop 实例化时才炸 ⇒ **编译绿 + OBS 静默崩**。 +- 溯源:该依赖是**删 odps 时**加的(`19b6d293834`),与 shade 无关,pom 注释把出处写错了。 +⇒ **翻转为「保留 + 改正注释」**。 + +### 其余处置 +- **ORC 重跑分析**(D-3 要求):结构上确实残缺(orc-core 引用的 122 个 `hive-storage-api` 类唯一提供者是该 jar), + 但**运行期不可达**(全仓零 ORC 引用 / `hudi-common` 无 `META-INF/services` / fe-core 不扫 classpath)。 + **用户签字顺手根除**:fe-core 对 hudi 的全部使用面只是**两个误 import**(`MapUtils` → commons-collections4; + `VisibleForTesting` → guava),改掉后摘除 `hudi-common` ⇒ **fe-core 少掉 38 个 jar** + (含 orc-core/orc-shims/hbase-server/rocksdbjni/prometheus simpleclient 等 hudi 的传递包袱)。 +- **摘 hudi-common 暴露出更多「直接用却从不声明」的依赖**(同一种病):`caffeine`(15 主代码文件)、 + `com.lmax:disruptor`(9 文件,版本管理早已存在却无人声明)、`org.jetbrains:annotations` + (20 文件;不声明则从 17.0.0 静默降到 13.0)⇒ 全部显式声明。 +- aws-java-sdk-**glue/sts** 删除(thrift 一代删除的收尾,零引用)。 + ✋ **aws-java-sdk-s3 保留**:`com.amazonaws.auth` 仍由其传递的 `aws-java-sdk-core` 提供,且 `AWSTest` 要用。 +- fastutil shade execution + `central` 仓库块删除(用户签字),在保留的 `fastutil-core` 上留注释记录历史。 + +**验证** +- fe-core `-am test-compile` 绿 · checkstyle 0 · 判据 fe-core main/test + fe-common(大小写不敏感)**全部归零** +- `dependency:tree`:fe-core 段 hive-catalog-shade / hudi / orc / aws-glue / aws-sts **全部断根**; + `com.tdunning:json` 与 `caffeine:3.2.3` 在树上 +- 「不许动」清单复核通过:`fe/pom.xml` 版本钉 · `start_fe.sh` · `fastutil-core` · BE java-udf/avro-scanner · + doris-shade · 连接器 pom **全部零改动**;shade 真依赖只剩 4 个正当消费者(hms/iceberg/avro-scanner/java-udf) + +### ⚙️ 本轮踩坑 +1. **`git add` 遇到已 `git rm` 的路径会中断**,导致该次 `git commit` 只包含了那两处删除 + (其余文件根本没进暂存区)→ `--amend` 补回。**教训:commit 后必看 `git show --stat` 的文件数。** +2. **既有失败会伪装成回归**:`AuthenticationPluginManagerTest` 2 个失败(断言 ServiceLoader 找得到 oidc 插件) + 看着像本轮引入 → **在基线 commit 上另开 worktree 实测**,同用例同行号同数量 ⇒ **既有失败,与本轮无关**。 + ⚠️ 但它**让反应堆在抵达 fe-core 前就中止** ⇒ 「跑了全量测试」是假象,须 + `-Dmaven.test.failure.ignore=true` 才能真正跑到 fe-core。 +3. **自写链接检查器必须解析继承链**:第一版只看类自身声明的方法 → `LoadingCache.asMap()`(声明在父接口 + `Cache`)、`RemovalCause.equals()`(继承自 Object)等 **20 处假阳性**。 + +### 🆕 用户要求「既有失败一并修」——两个与生产代码脱节的测试 + +1. **`AuthenticationPluginManagerTest`**(2 红 → 17 全绿):断言 ServiceLoader 能加载 **`oidc`** 插件, + 但全仓**没有 oidc 实现**(插件模块只有 ldap/password,`oidc` 只在 javadoc 举例里)。删掉对不存在插件的断言。 +2. **`PluginDrivenMvccExternalTableTest`**(3 failures + 27 errors → 59 全绿):`8fbb262d209` + `d50c034aa86` + (**另一 session**,2026-07-14)改了契约(连接器提供有序分区值 + 数量不匹配 fail loud),改了 API、 + 4 个连接器、消费端,**唯独漏了被改类自己的测试**。修法:`cpi()` 提供真实连接器现在会提供的值; + `testPartitionBuildFailureFallsBackToUnpartitioned` 编码的旧行为(吞掉→降级 UNPARTITIONED)与新的 + fail-loud 意图冲突 → 按生产代码翻转,改名 `testValueCountMismatchFailsLoud`。 + +### ✅ 全量 UT 最终结果(独占跑) + +| 模块 | 结果 | +|---|---| +| 其余 15 个模块 | 776 用例,**全绿** | +| **fe-core** | **5197 用例 · Failures 0 · Errors 0 · Skipped 35** | + +校验:失败标记 **0** · surefire 被 build cache 跳过 **0 次**(真跑)· 实跑 **877** 个测试类。 + +### ⚙️ 本轮踩坑(追加,**血的教训**) + +4. **🔴 绝不可在全量测试跑的同时另起 maven —— 它们写同一个 `target/classes`。** + 本 session 在 fulltest 跑的过程中又跑了两个 `-pl ... test` 构建,把测试进程正在读的 class 目录中途重编, + 结果:**22 个 `NoClassDefFoundError` 全部指向 Doris 自己的类**(`LongBitmap` 等,这些 class 明明在 target 里), + 失败散落在互不相干的 nereids 类上、都在 0.0x 秒内挂掉。三个日志的时间戳完全重叠即为铁证。 + **更阴的是覆盖面也被腰斩**:被污染那次 fe-core 只跑了 **2845** 个用例,干净跑是 **5197** —— + 即「只有这几个类失败」这个结论本身也不可信。⇒ **判定失败前先确认没有并发构建。** +5. **监视器的「进程是否存活」检测别用 `pgrep -f `** —— 它会匹配到监视器脚本自己的命令行, + 永远为真,ABORTED 分支形同虚设。 +6. **测试跑完 ≠ 日志出现 `BUILD` 行**:surefire 的 `Results:` 落定后,maven 还要跑报告插件, + 期间日志长时间无输出,**别误判为卡死**。 diff --git a/plan-doc/hive-catalog-shade-removal/tasklist.md b/plan-doc/hive-catalog-shade-removal/tasklist.md new file mode 100644 index 00000000000000..ca154582ebf7e2 --- /dev/null +++ b/plan-doc/hive-catalog-shade-removal/tasklist.md @@ -0,0 +1,386 @@ +# ✅ Task List — 删除 thrift 一代 Glue/DLF ⇒ 剔除 `hive-catalog-shade` + +> **本任务的唯一进度清单**。完成一项即把 `[ ]` 勾成 `[x]`(随 commit 更新)。 +> **「怎么做」看 [`design.md`](./design.md),「下一步做什么」看 [`HANDOFF.md`](./HANDOFF.md)。** +> **⚠️ 行号信 HEAD 不信文档**(基线 = 2026-07-14 / `657417bec32`)。 + +--- + +## 🚩 用户 2026-07-14 拍板(**任务定性已改变**) + +**原方案(搬迁 Glue/DLF 客户端进插件)已作废。** 实测证明 thrift 一代的 Glue/DLF 在本分支**已全部损坏** +(cutover 引入的回归,见 `progress.md` 2026-07-14(三)),用户决策: + +> **「把 glue 和 dlf 这两个功能先删掉,不再支持了,不作为需要迁移的内容。」** +> 范围 = **只删「走 HMS thrift 协议的那一代」**(见下表)。 + +⇒ **本任务从「搬迁 + 守门 + 降级档」塌缩为【纯删除】**: +- **不做**守门测试(功能没了,无可守) +- **不做** paimon-on-DLF +- **不做**任何客户端搬迁(唯一例外见 **T-20**,50 行,且是**出** fe-core) +- 总判据**自然归零** ⇒ 127MB jar 直接可出 `fe/lib`,**不再需要降级档** +- 全程 fe-core **只减不增** ⇒ 铁律 A/B 天然满足 + +### 删除边界(**误删比漏删更严重**) + +| | ❌ 删(thrift 一代,实测已坏) | ✋ 留(今天可用,与 shade 无关) | +|---|---|---| +| **Glue** | `hive.metastore.type=glue` → vendored `AWSCatalogMetastoreClient` 树 | `iceberg.catalog.type=glue` → iceberg 官方 `org.apache.iceberg.aws.glue.GlueCatalog`(AWS SDK **v2**,在 iceberg 插件包 `iceberg-aws-1.10.1.jar` 内) | +| **DLF** | `hive.metastore.type=dlf` · `iceberg.catalog.type=dlf`(`DLFCatalog`/`DLFClientPool`)· paimon `paimon.catalog.type=dlf` —— 全部经 `ProxyMetaStoreClient` | **DLF 2.0 REST**:paimon `paimon.catalog.type=rest` + dlf token provider(`PaimonRestMetaStoreProperties`) | +| 其它 | — | `hive.metastore.type=hms`(主路径)· `fe/pom.xml:801-805` 版本钉 · `start_fe.sh` 钉序 · `fastutil-core` · BE `java-udf`/`avro-scanner` pom · `doris-shade` 仓库 | + +**总判据(唯一的「做完了没」标准)** —— ✅ **全部达成**(2026-07-15,阶段 5): +```bash +grep -rlE '^import (org\.apache\.hadoop\.hive\.|shade\.doris\.hive\.|com\.aliyun\.datalake\.)' \ + fe/fe-core/src/main/java | wc -l # 基线 26 → 现在 0 ✅ +grep -rniE 'org\.apache\.(hadoop\.)?hive' fe/fe-common/src/ # 基线 1 处 → 现在 空 ✅ + # (判据已改为大小写不敏感:原判据抓不到 javadoc 里的 HiveConf) +# 追加判据:jar 真的出了 fe-core 的依赖树 +mvn -o -f /fe/pom.xml -pl fe-core -am dependency:tree -Dincludes=org.apache.doris:hive-catalog-shade + # fe-core 段为空 ✅(真依赖只剩 hms/iceberg/avro-scanner/java-udf 四个正当消费者) +``` + +--- + +## 阶段 0 — 调研(已完成) + +- [x] **T-00** 事实基线(10-agent 侦察 + 3 路对抗验证)→ `design.md` +- [x] **T-01** ⭐ **离线 classloader 实验**(真实 `output/fe/lib` + 真实插件包 + 真实 `ChildFirstClassLoader`) + → **实测四条路径全坏**,机理各不相同 → `progress.md`;复现程序 `loader-probe-reproduction.java.txt` +- [x] **T-02** 六路删除清单调研 + 六路对抗验证 +- [x] **T-03** 用户签字:**删 thrift 一代**(范围见上表) + +--- + +## 阶段 1 — 🔴 先修 iceberg 原生 Glue 的凭证类(**这是保留路径上的 bug,不是删除**) + +> **⚠️ 这条容易被漏**:iceberg 原生 Glue 在**用户填了 AK/SK 时**也是坏的 —— 第四个实例。 +> `IcebergCatalogFactory.java:559-560` 把 `client.credentials-provider` 设为 +> `com.amazonaws.glue.catalog.credentials.ConfigurationAWSCredentialsProvider2x`(**住在 fe-core**), +> iceberg 的 `AwsClientProperties` 用**自己(child)的** loader 按名加载并 `asSubclass` +> 到 **child 的** `software.amazon.awssdk...AwsCredentialsProvider` → app-loaded 的 Provider2x 对不上 → **CCE**。 +> **实测**:`child AwsCredentialsProvider.isAssignableFrom(Provider2x) = false`。 +> IAM-role 分支(`AssumeRoleAwsClientFactory.class.getName()`,**编译期类引用**)与默认凭证链分支**不受影响**。 + +- [x] **T-20** ✅ **已完成**(commit `2cd01ada8df`)。把 `ConfigurationAWSCredentialsProvider2x.java` 从 fe-core + 搬进 **`fe/fe-connector/fe-connector-iceberg`**(⚠️ 模块真实路径是**嵌套**的,文档旧路径 `fe/fe-connector-iceberg` 错误), + 并按用户 2026-07-14 拍板**改包**到 `org.apache.doris.connector.iceberg.glue`(原 `com.amazonaws.glue.catalog.credentials` + 是 AWS 官方命名空间,且与即将删光的 thrift 老树同包 → 留着会让人误判「没删干净」)。 + - 改动面 = 4 文件:新类 + fe-core 删除 + `IcebergConnectorProperties.java:142` 常量值 + `IcebergCatalogFactoryTest.java:556` 断言 + - ✅ 合铁律 A(**出** fe-core);`auth:2.29.52` 经 `s3` 传递已在插件 compile classpath,**pom 零改动** + - ✅ **验收达成**:probe 同一次运行的前后对照 —— 旧位置解析自 `app-model` → `isAssignableFrom = false`; + 新位置解析自 `ChildFirstClassLoader` → **`true`**;`create(Map)` + `resolveCredentials()` 返回正确 AK/SK + - 📌 **实测更正**:真实报错是 `IllegalArgumentException "... does not implement AwsCredentialsProvider"` + (iceberg `isAssignableFrom` 门禁先拦),**不是** CCE。不影响修法 + - 📌 新包命中 `org.apache.doris.connector.` 这条 parent-first 前缀 → 靠「父加载器无此类 → 回退子加载器」成立, + **已实测**,与插件内其它所有类同一模式 + - ✋ 同目录的 `ConfigurationAWSCredentialsProvider.java`(v1)与 `ConfigurationAWSCredentialsProviderFactory.java` + **未动**,属 thrift 一代 → 随 T-30 删 + +- [x] **T-21** ✅ **已完成**(用户 2026-07-15 签字「两处都修」)—— Glue session token 静默丢弃。 + 既有 bug(`867284b23c5`/2024-10 原始 Glue 支持起即如此,**非** `2cd01ada8df` 搬迁引入)。 + 🔴 **原文「修法就一处」被证伪 = 两个模块、两处独立缺陷**(只修一处仍不可用): + - **A(iceberg 插件)** `create()` 只读 ak/sk → 改为 token 非空白时造 `AwsSessionCredentials` + (字段类型放宽到共同父类 `AwsCredentials`)。链条已 javap 逐段验证:iceberg 剥掉 + `client.credentials-provider.` 前缀经 DynMethods 反射调 `create(Map)`,key = `glue.session_token`。 + - **B(`fe-filesystem`)** `S3FileSystemProperties` 的 `sessionToken` 补 `aws.glue.session-token` 别名 + (与旁边 accessKey/secretKey 已有的 glue 别名对称;此前全树 `glue.session` 命中 0)。 + ⚠️ **判空必须 `isNotBlank`**:`AwsSessionCredentials.create(ak,sk,"")` 不报错,会造出空 token 凭证。 + 测试:**探针式**驱动真的 `new AwsClientProperties(opts).credentialsProvider(...)` 串起全链 + + 无 token 路径钉住今日行为 + fe-filesystem 侧别名守门;**三者均已变异验证**(红在断言上)。 + 验证:iceberg 连接器 `-am package` 134 个测试类真跑全绿 · fe-filesystem-s3 全绿 · checkstyle 0。 + 📌 真临时凭证的 e2e 需真实 STS → 归 T-73。 + +## 阶段 2 — 删 Glue(thrift 一代)✅ **已完成**(commit `e43173eca67`) + +> **判据达成**:fe-core main 的 `com.amazonaws.*` 引用 **归零**。 +> 总判据(hive/dlf import 数)从基线 **26 → 7**(余下为 DLF/hive 部分,阶段 3/4 处理)。 +> 验证:fe-core `test-compile` 绿 · hms/hive/iceberg 三连接器 `test` 全绿 · checkstyle 0 · +> `check-connector-imports.sh` exit 0。 + +- [x] **T-30** 删 `fe/fe-core/src/main/java/com/amazonaws/glue/**` —— **37 个文件 / 10,467 行**全删。 + 对抗验证证实树自洽:跨边界的编译期边只有 1 条(`HiveGlueMetaStoreProperties`),其余皆字符串; + `com.amazonaws.services.glue`(v1 SDK) 的 23 个使用者全在树内。 +- [x] **T-31** 删 `ThriftHmsClient` 的 `GLUE_CLIENT_CLASS` + glue 分派分支。 + 🔴 **显式拒绝已实现(用户 2026-07-14 拍板「两处都加」)**: + - `HiveConnectorProvider.validateProperties` —— 拦 CREATE/ALTER。**必须抛 `IllegalArgumentException`** + (catalog 层只解包这一种,文案才能原样透出;抛别的会被包烂)。 + - `HiveConnector.createClient` —— 拦**升级上来的老 catalog**(它们从 image 反序列化,**永不经** + `validateProperties`)。**必须放在「HMS URI 必填」检查之前**,否则被遮蔽。抛 `DorisConnectorException` + (该处邻居惯例;fe-core 有多处专门 catch 它)。 + - 💀 **绝不能放** `ConnectorProvider.create()` / `HiveConnector` 构造函数 —— **edit-log 回放时会跑** → + 抛异常 → `EditLog.loadJournal` 的 catch-all `System.exit(-1)` → **FE 起不来**。 + (代码库里 lakesoul 的「已移除」先例恰好就放错了位置,是个潜在启动 bug —— **别照抄它**。) + - 常量 `METASTORE_TYPE_GLUE` **未删而是改名** `METASTORE_TYPE_GLUE_REMOVED`(偏离原计划): + 它现在有了新消费者 = 识别并拒绝该已移除类型,比内联字符串字面量更清楚。 +- [x] **T-32** 删 `HiveGlueMetaStoreProperties` + `AWSGlueMetaStoreBaseProperties`(整文件)+ + `HivePropertiesFactory` 的 `register("glue", ...)` 与 javadoc + `DatasourcePrintableMap` 的 import/`addAll`。 + 脱敏字节中性已由对抗验证用 **javap 字节码**证实(非仅读源码):该类唯一 `sensitive=true` 字段是 + `glueSecretKey`(3 个别名),`S3Properties` 逐字覆盖同样 3 个别名。 +- [x] **T-33** 删 `AwsCredentialsProviderFactory.createV1` + `createDefaultV1`;**保留** `createV2`/`getV2ClassName`。 + `isWebIdentityConfigured`/`isContainerCredentialsConfigured` 两个私有 helper **保留**(V2 也在用)。 +- [x] **T-34** 删 iceberg 侧 factory-key:`IcebergCatalogFactory` 的那条 `opts.put` + + `IcebergConnectorProperties` 的 `GLUE_CREDENTIALS_PROVIDER_FACTORY_KEY`/`_FACTORY` 两常量。 + ✋ `_KEY`/`_2X`/AK/SK/session-token 全部保留(阶段 1 之后它们是好的)。 + 单测断言从「断言发出 factory-class」改为**断言不发出**(带 WHY)。 + > **行为中性已用字节码锁死**:拆插件目录**全部 183 个 jar**(侦察只查了 6 个)、**6603 个 class** + > 搜该 key → **0 命中**;正对照证明搜索有效(其它每个 emit 的 key 均命中)。唯一读者 + > `AWSGlueClientFactory:115` 在被删树内。 +- [x] **T-35** 删 `HMSGlueMetaStorePropertiesTest`(108) · `AWSGlueMetaStoreBasePropertiesTest`(139) · + `GlueCatalogTest`(111)。 +- [x] **T-36** 🆕 新增 `HmsClientConfigRemovedTypeTest`(该分派**此前零测试覆盖** → 不加测试则拒绝逻辑 + 被误删也不会有东西变红)。**已做变异验证**:把拒绝改坏 → 测试确实转红(报 + `"hive.metastore.type=glue must be rejected, never silently ignored"`)。 + ⚠️ 踩坑复现:变异验证第一次漏了 `-am` → `BUILD FAILURE` 是 `${revision}` 假错、**根本没编译**, + 结论作废后重做(memory `doris-build-verify-gotchas`)。 + +### 📌 阶段 2 遗留(**不在本阶段,勿忘**) + +- **regression-test 未动**(按计划归「用户可见面」阶段)。已定位待删: + `aws_iam_role_p0/test_catalog_instance_profile.groovy:67-95`(3 块 hive-on-glue)+ 死变量 `:26-27`; + `aws_iam_role_p0/test_catalog_with_role.groovy:82-90` + 死变量 `:49`; + `external_table_p2/refactor_catalog_param/iceberg_and_hive_on_glue.groovy:369-372`(**已是死代码**,定义了从不引用)。 + ✋ **保留**:`test_catalog_with_role.groovy:56-60` 的 `awsGlueProperties`(iceberg-glue 分支 `:78` 还在用)、 + `:62-81`、`iceberg_and_hive_on_glue.groovy:367`。 + ⚠️ `test_catalog_instance_profile.groovy:22-24` 的**文件级 guard 钉在 glue 专有 conf key 上** —— + 删 glue 块后须改钉 iceberg 的 key,否则存活的 iceberg 分支**静默永不运行**。 + ⚠️ 假阳性别碰:`external_table_p2/hive/test_external_catalog_glue_table.groovy`(名字是陷阱,实为普通 HMS, + 且 `:20-24` 硬关)· `test_iceberg_predicate_conversion.groovy`(唯一 p0 命中,glue 只是列名)· + `test_s3tables_glue_*`(iceberg REST + glue signing)。 +- **`test_connection=true` 顺序坑**:`checkWhenCreating` 跑在 `checkProperties` **之前**。当前 + `HiveConnector` 不 override `defaultTestConnection()`(继承 `false`)故不触发;但显式配 + `"test_connection"="true"` 的 glue catalog 会先撞别的错。已由 `createClient` 那处拒绝兜住。 +- **文案里 `Supported types: hms, dlf`** —— 阶段 3 删 DLF 后须同步去掉 `dlf`。 + +## 阶段 3 — 删 DLF(thrift 一代 = DLF 1.0)✅ **已完成**(commit `a0f65c353a9`) + +> **判据达成**:fe-core 的 `com.aliyun.datalake` 引用 **归零**。总判据 **7 → 4**(余下 4 个为 hive 部分,阶段 4 处理)。 +> 验证:**229 个测试类零失败** · fe-core `test-compile` 绿 · checkstyle 0 · `check-connector-imports.sh` exit 0。 +> ✋ **DLF 2.0 REST 完好**(`paimon.catalog.type=rest` + dlf token)—— 字节码级证实与被删代码不相干。 + +- [x] **T-40** 删 `fe/fe-core/src/main/java/com/aliyun/datalake/**`(`ProxyMetaStoreClient`,2193 行)。 + > 侦察副产品:该树 import 的 `com.aliyun.datalake.metastore.common.*` **就住在 hive-catalog-shade 里** + > —— 它本身就寄生在待剔除的那个 jar 上,删它正是本任务目标。 +- [x] **T-41** 删 `ThriftHmsClient` 的 `DLF_CLIENT_CLASS` + dlf 分支。 + **偏离原计划**:分支删光后 `getMetastoreClientClassName` 成了恒返回同一值的空壳 → **连方法一并内联**到调用点。 + 拒绝逻辑**无需新增落点**,直接扩展阶段 2 建好的 `HmsClientConfig.removedMetastoreTypeError`: + 两个已移除类型收敛成一张 `REMOVED_METASTORE_TYPES` 表,文案自动变为 `Supported types: hms.`。 + 连带删 `HiveConnectorMetadata` 的 DLF 默认值守卫(已成死代码)。 +- [x] **T-42** iceberg 侧:`dlf/` 整包(`DLFCatalog`/`DLFTableOperations`/`HiveCompatibleCatalog`/ + `DLFCachedClientPool`/`DLFClientPool`)· `TYPE_DLF` · dispatch arm · `createDlfCatalog` · + `buildDlfConfiguration` · **5 处 DDL 守卫**(dlf catalog 现在建不出来 → 守卫永不可达 = 死代码)。 + ✋ `Supported types` 文案去掉 `dlf`、**保留 `glue`**。 + > `HiveCompatibleCatalog` 虽是抽象基类但**唯一子类就是 `DLFCatalog`**(grep 证实)→ 随包删。 +- [x] **T-43** paimon 侧:`DLF` case · `appendDlfOptions` · `DLF` 常量 · 相关 javadoc。 + ✋ **`REST` 全链路未动**;`restDlfTokenProviderRequiresAkSk` 测试**保留**(它测的是 REST 路径)。 +- [x] **T-44** 删 fe-core `AliyunDLFBaseProperties` + `HiveAliyunDLFMetaStoreProperties` + `HivePropertiesFactory` + 的 dlf 注册。**⚠️ 脱敏另行处置,见 T-46。** +- [x] **T-45** 连接器侧 DLF 属性类逐个判定完毕,**全部 DELETE**(无一被 REST 共用): + `metastore-api/DlfMetaStoreProperties` · `metastore-spi/AbstractDlfMetaStoreProperties` · + `metastore-iceberg/dlf/*` · `metastore-paimon/dlf/*` + **两处 SPI `services` 注册行**。 + ✋ **`fe-filesystem-oss` 不动**:那里的 `dlf.*` 只是 OSS 凭证的 `@ConnectorProperty` **别名字符串**, + 不依赖任何 DLF 类,且**非 DLF 的 OSS catalog 也可能用到** → 删它有风险、无收益。 +- [x] **T-46** 🆕🔴 **安全修复(本轮最重要,原计划没有)**:`DatasourcePrintableMap` 的脱敏注册 + **不能随属性类一起删**,否则**明文泄漏**。用户 2026-07-14 拍板「显式列出那 4 个 key」。 + > **为什么 Glue 那次能直接删、DLF 不能**:脱敏靠**逐字对齐的别名字符串**,重叠**不均匀**—— + > `dlf.secret_key` 被 OSS 属性类覆盖,但 `dlf.catalog.accessKeySecret` / `dlf.session_token` / + > `dlf.catalog.sessionToken` **无人覆盖**(javap 逐字段确认)。 + > **为什么升级后仍会泄漏**:脱敏按**原始 key 匹配、不按 catalog 类型**;而老 DLF catalog **回放时不被拒绝** + > (刻意设计,否则 FE 起不来)→ 仍在目录里、仍可 `SHOW CREATE CATALOG` → 存的 token 从打码变明文。 + > **范式来自同文件的 iceberg REST 块**,其注释早就警告过这个「重叠不均匀」的陷阱。 +- [x] **T-47** 🆕 测试:**新增/改造 7 处**,把「dlf 可被分派/路由」的断言**反转**为「dlf 必须被拒」: + `HmsClientConfigRemovedTypeTest`(重写,4 用例,含「文案只许宣传 hms」)· + paimon/iceberg 两个 `MetaStoreProvidersDispatchTest`(+「provider 必须从 ServiceLoader 消失」)· + `PaimonCatalogFactoryTest` · `PaimonConnectorValidatePropertiesTest` · `IcebergCatalogFactoryTest` · + `IcebergConnectorTest`。删除只覆盖已删路径的测试 5 个文件 + 若干方法。 + +### 📌 阶段 3 遗留 + +- **regression-test 未动**(归「用户可见面」阶段,与 Glue 的一并做)。 +- **报错文案**:iceberg/paimon 侧走各自 default 分支报 `Unknown ...type: dlf. Supported types: ...` —— + **loud 且准确,但没说「已移除」**。归「用户可见面」阶段统一措辞(该阶段本就要求 dlf 报错说明已移除)。 + +### ⚙️ 阶段 3 踩到的两个构建坑(**下轮务必带上**) + +1. **maven build cache 会静默跳过 surefire** → 日志出现 `Skipping plugin execution (cached): surefire:test`, + **BUILD SUCCESS 但零测试执行**。必须 `-Dmaven.build.cache.enabled=false`,并**数一下 `Tests run:` 的行数**。 +2. **依赖 shade 模块的模块必须跑到 `package`**:`fe-connector-paimon` 的 `HiveConf` 来自 + `fe-connector-paimon-hive-shade`,shade 内容只在 `package` 阶段产出。用 `test-compile`/`test` 会假报 + `package org.apache.hadoop.hive.conf does not exist`(**不是代码错**)。用 `-am package`。 + ⚠️ `install` 不行:会在 `fe-type` 撞预存的 `did not assign a main file` quirk。 + +## 阶段 4 — fe-core 去 hive 化 ✅ **已完成**(4 个独立 commit) + +> **判据达成**:fe-core 的 hive/shade/dlf import **26 → 0**。 +> 通用 SPI 上的 hive 专有方法整个消失(铁律 C)。fe-core 全程**只减不增**(铁律 A)。 +> 验证:fe-core + SPI test-compile 绿 · 两插件 `-am package` 182 个测试类真跑零失败 +> (已数 `Tests run:` 行,surefire 未被 build cache 跳过)· checkstyle 绿 · +> `check-connector-imports.sh` exit 0。 + +**⚠️ 侦察(5 路 + 20 路对抗验证)推翻了本阶段原计划的 3 个前提**,明细见 `progress.md`。 + +- [x] **T-50** Ranger 死代码 —— commit `d8c121b7f21`。净减 **12** 行(原计划写 13,实测 12)。 + ROLE_OPS 全仓零读取点。**诚实表述更正**:本类**是**可被反射到达的(经持久化的 + `access_controller.class` → `RangerHiveAccessController`);安全的理由是"ROLE_OPS 零读取点 + 且类名不变",不是"没人能到达这个类"。 + ⚠️ **原计划预录的"恰好 1 个编译错误"证明不可信**(对抗验证复现出 9 个,且其 harness + 无法产出宣称的干净对照)→ 判据以实跑编译+checkstyle 为准。 +- [x] **T-51** 删 HMS 属性簇 —— commit `22461468e7c`。**作用面是原计划的 2 倍**: + **4 文件 + 1 行反注册**(原计划只说 2 文件)。 + 🔴 **原计划"它们是死代码"被证伪**:`MetastoreProperties.java:88` 至今仍有 + `register(Type.HMS, new HivePropertiesFactory())` → 是"能到达但没人走"。真实删除闭包 = + `HMSBaseProperties` + `AbstractHiveProperties` + `HiveHMSProperties`(不在原计划)+ + `HivePropertiesFactory`(不在原计划),**任何中间态都编译不过**。 + 常量归属:**用户拍板纯内联字面量**(fe-core 一行不增,字面满足铁律 A;不把铁律冲突用 + "实质满足"平均掉)。删 `HMSPropertiesTest`(诚实记录被放弃的覆盖 → D-1/D-2)。 + ✅ 不丢脱敏(两条独立证据):4 文件源码级 `grep sensitive` 全 0;`DatasourcePrintableMap` + 只注册 S3/GCS/Azure/OSS/OSSHdfs/COS/OBS/Minio,对 hive 零引用。 +- [x] **T-53** hive 配置加载去 hive 化 —— commit `7ed266c677a`。**判据归零的那一刀。** + 🔴 **原计划/SPI 注释的核心前提"插件不可能自己解析 hadoop_config_dir"被证伪**: + `fe-filesystem-hdfs` 是真 leaf(pom 无 fe-core/fe-common),**已经**通过 + `doris.hadoop.config.dir` 系统属性桥(`FileSystemFactory:124` 设值)解析同一个目录,且有测试。 + **用户拍板"插件自解析"**(照抄该既有范式),非"改名为通用 hadoop 加载"。 + 行为变化真实但有界:iceberg 可证明零变化;paimon 28 个 ConfVars 默认值回到自带的 2.3.9 + (方向是**变正确**)。测试从 mock 握手改写为驱动真实文件→HiveConf,**已做变异验证**。 +- [x] **T-54** 删 `hudi-hadoop-mr` —— commit `8d6fe9f9736`。 + 🔴 **原计划的"ORC blocker"是假警报**:全仓 `org.apache.orc` 引用 **0**;且 shade 里逐字节 + 相同地捆了全部 122 个 `hive-storage-api` 类(`IDENTICAL=122`)。 + ⚠️ **该结论条件于 shade 还在** → 阶段 5 必须**重跑** ORC 分析(见 D-3)。 +- [ ] **T-52** fe-common 去 hive 化 —— **改排到阶段 5**(🔴 见下)。**门禁已解锁**: + `loadHiveConfFromHiveConfDir` 现在**零调用点**(只剩声明本身)。 + +### 🔴 T-52 为什么必须与摘 jar 同批(**OUTAGE 级排期更正**) + +原计划把它排成"阶段 4 的一件事、随便什么时候做"。**错**。`CatalogConfigFileUtils` 服务 +**所有**外表 catalog(不只 hive)。若 fe-core 先摘 jar 而 fe-common 仍 import `HiveConf`: +**编译绿**(fe-common 自带 `provided` 声明)但 FE 启动时**每个** catalog 都 `NoClassDefFoundError`。 +⇒ 它的排期是一个**窗口**(两个调用点消失后 ✅ 已达成、摘 jar 之前),**最好同一个 commit**。 + +### 📌 阶段 4 立项的既有缺陷(**用户 2026-07-14 拍板:三个全记录,本轮不修**) + +| ID | 缺陷 | 性质 | +|---|---|---| +| **D-1** | **普通 hive 路径 socket timeout 失效**:`Config.hive_metastore_client_timeout_second`(默认 10s) 从不到达 metastore 客户端 —— `HmsConfHelper.createHiveConf` 从不设 `hive.metastore.client.socket.timeout` → 用户静默拿 HiveConf 内建的 **600s**。(iceberg/paimon 的 HMS 后端经 `AbstractHmsMetaStoreProperties` 读 env,是好的;**只有 plain-hive 漏**。) | pre-existing,非本次引入。唯一记录它的 `HMSPropertiesTest:141-150` 已随 T-51 删除 | +| **D-2** | **两个用户可见属性空转**:`hive.enable_hms_events_incremental_sync` / `hive.hms_events_batch_size_per_rpc` **零生产消费者**;且 live 的 `HiveConnectorProperties.getInt()` 用 `catch (NumberFormatException) { return defaultVal; }` **静默吞掉**非法值(旧类是 loud reject) | pre-existing。无运行时变化,**丢的是最后的记录** | +| **D-3** | **摘 jar 后 `orc-core` 变孤儿**:`orc-core-1.8.4` 经 `hudi-common` 进来,而其 `hive-storage-api` 在 `fe/pom.xml:1507-1510` 被排除 → 结构上不可满足。今天无害**纯因无人加载 ORC** | ⛔ **阶段 5 必须重跑分析**;别拿 T-54 的"ORC 没事"当先例 | + +## 阶段 5 — pom 终局 ✅ **已完成**(jar 已出 `fe/lib`) + +> **判据达成**:fe-core main/test + fe-common(大小写不敏感)hive 引用 **全部归零**; +> `dependency:tree` 中 fe-core 段的 `hive-catalog-shade` **已断根**。 +> **⚠️ 侦察 + 实跑编译推翻了本阶段原计划的 3 个前提**,明细见 `progress.md` 2026-07-15。 + +- [x] **T-52** fe-common 去 hive 化 —— 与 T-60 **同一个 commit**(`0c35090a4f3`)。 + 删 `CatalogConfigFileUtils` 的 HiveConf import + 死方法 `loadHiveConfFromHiveConfDir`(零调用点) + + javadoc 去 hive 化;删 `fe/fe-common/pom.xml` 的 provided shade。 + +- [x] **T-60** 删 `fe/fe-core/pom.xml` 的 `hive-catalog-shade` 本体 —— commit `0c35090a4f3`。 + 🔴 **原计划前提「摘 jar 会连带掉 14 个 compile 传递依赖」被证伪**:那份清单读的是 `doris-shade` 的 + **源码 pom(3.1.2-SNAPSHOT)**;构建实际解析的 **3.1.1** 是 dependency-reduced pom, + 顶层依赖只有 1 个且为 `provided` ⇒ 该节点是**叶子**,摘除**不带走任何 artifact**。 + 🔴 **真正的阻塞是反向的**:jar 捆的 66,347 个 class 中,它是 fe-core **四处代码的唯一提供者** + (扫编译期全部 540 个 jar 实证)。逐个断根后才摘得动: + - `org.json`(主代码 19 文件)→ 显式声明 `com.tdunning:json`(Open JSON)。 + **9 个 org/json 类与 com.tdunning:json:1.8 SHA 逐字节全等** ⇒ 运行期字节零变化。 + ✋ `org.json:json` 原版**不可用**(许可为 ASF category X,父 pom 正为此从 hadoop-cos 排除它)。 + - vendored `org/apache/iceberg/DeleteFileIndex.java` → **删**(全仓零引用的迁移遗留,连接器已自带副本)。 + - `jline.internal.Nullable` → `javax.annotation.Nullable`(误 import)。 + - `HMSIntegrationTest` → **删**(用户签字;`@Disabled` + 常量全空 + 不碰任何 Doris 类)。 + - `FeNameFormatTest` 的 ivy `StringUtils` → commons-lang3(误 import)。 + +- [x] **T-61** 🔴 **翻转为「保留 + 改正注释」**(原计划写「删 commons-lang 2.6」= **错**)。 + 该 jar 的 pom 里 commons-lang 是 `provided`(不传递)⇒ **jar 从来不是它的提供者**;真正需要它的是 + **hadoop-huaweicloud**(`OBSFileSystem` 等)与 **bce-java-sdk**,二者均未自带声明, + 而 fe-core 这条是全 FE **唯一**来源。且 `OBSProperties` 用 `Class.forName(..., initialize=false)` 探测 + ⇒ 删了**编译绿、探测仍成功、S3A 降级不触发**,OBS 静默 `NoClassDefFoundError`。 + 溯源:它是**删 odps 时**加的(`19b6d293834`),与 shade 无关。 + ✋ 风险 R-7(Ranger 降级复活它)无关紧要——它本来就得留。 + +- [x] **T-62** 删 `bundle-fastutil-into-doris-fe` shade execution(用户签字)。 + ✋ `fastutil-core` 依赖**已保留**,并在其上留注释记录「为什么这里曾需要 shade 覆盖」。 + 📌 残留(已实测、良性):另有一份完整 fastutil 经 `fe-common → trino-main` 进来,与 fastutil-core 并存; + 二者 API 相同(只有 jar 里那份 2013 年的 6.5.6 缺 `computeIfAbsent`),谁赢都正确。 + +- [x] **T-63** 删 `` 的 `central` 块(用户签字)。父 pom 的 `general-env` profile 与 Maven + 超级 POM 均已定义同名同 URL 的 central,且 settings.xml 按 **repo id** 镜像到 aliyun ⇒ 冗余。 + ✋ `huawei-obs-sdk` 仓库保留。 + +- [x] **T-64** 删 `fe/fe-common/pom.xml` 的 provided shade —— 随 T-52/T-60 同 commit。 + +- [x] **T-65** 删 `aws-java-sdk-glue` / `aws-java-sdk-sts`(零引用,thrift 一代删除的收尾)。 + ✋ **`aws-java-sdk-s3` 保留**:`com.amazonaws.auth` 由其传递的 `aws-java-sdk-core` 提供, + 且 `AWSTest`(@Disabled 手工测试)要用它编译。`aws-java-sdk-core` **fe-core 从未直接声明**,无可删。 + 📌 侦察发现但**本轮不动**(与本任务无关,属独立问题):`aws-java-sdk-dynamodb` / `aws-java-sdk-logs` + 在 fe-core 亦零引用(logs 的唯一理由「ranger audit 需要」在 Ranger 2.8.0 已过期)。 + +- [x] **T-66** 「不许动」复核通过:`fe/pom.xml:801-805` 版本钉 ✋ · `start_fe.sh` 钉序 ✋ · `fastutil-core` ✋ · + BE `java-udf`/`avro-scanner` ✋ · `doris-shade` ✋ · 连接器 pom ✋ —— **全部零改动**。 + shade 的真依赖只剩 **4 个正当消费者**:hms · iceberg · avro-scanner · java-udf。 + +### 🆕 阶段 5 顺带修掉的既有失败测试(用户要求「非本轮引入的也一并修」) + +- [x] **T-6A** `AuthenticationPluginManagerTest`(2 红 → 17 全绿):断言一个**全仓从未实现**的 `oidc` 插件 + (插件模块只有 ldap/password)。删掉对不存在插件的断言,保留「ServiceLoader 自动发现」的真实意图。 +- [x] **T-6B** `PluginDrivenMvccExternalTableTest`(3 failures + 27 errors → 59 全绿):`8fbb262d209` + + `d50c034aa86`(另一 session,2026-07-14)改契约时漏更新被改类自己的测试。`cpi()` 改为提供真实 + 连接器现在会提供的分区值;`testPartitionBuildFailureFallsBackToUnpartitioned` 编码的旧行为与新的 + fail-loud 意图冲突 → 按生产代码翻转并改名 `testValueCountMismatchFailsLoud`。 + +### 🆕 阶段 5 侦察挖出、超出原计划的三项(均已处置) + +- [x] **T-67** 🔴 **Caffeine 由 2.x 翻回声明的 3.2.3**(用户签字,**行为变更**)。 + 该 jar 捆着 Caffeine 2.x;`start_fe.sh` 倒序拼 classpath 使其排在 `caffeine-3.2.3.jar` **之前** + ⇒ **FE 运行期一直跑 hive jar 里那份 2.x,pom 声明的 3.2.3 从未生效**。摘 jar 后现实与声明对齐, + `CacheBulkLoader` 随之改用 3.x 的 `loadAll(Set)` 签名。 + - 证据:自写字节码链接检查器(**沿继承链解析**)扫 `output/fe/lib` 全部 caffeine 消费者 → **零缺失**; + 另两处调 2.x 签名者用的是各自 shade 的私有副本。 + - `CacheBulkLoaderTest` **已做变异验证**:打断 override → 红在断言上。 + +- [x] **T-68** **摘除 `hudi-common`**(用户签字,D-3 的根除方案)。fe-core 对 hudi 的全部使用面只是 + **两个误 import**(`MapUtils` → commons-collections4,同包邻居 `HdfsProperties` 已在用; + `VisibleForTesting` → guava,105 文件已在用)⇒ **fe-core 少掉 38 个 jar** + (orc-core/orc-shims/hbase-server/rocksdbjni/prometheus simpleclient 等 hudi 的传递包袱一并离场)。 + ⇒ **D-3(ORC 孤儿)已根除**,非仅记录。 + ✋ `fe-connector-hudi` 自带 hudi-common,不受影响。 + +- [x] **T-69** 显式声明「直接使用却从不声明」的依赖(摘 hudi-common 后暴露,与 org.json 同一种病): + `caffeine`(15 主代码文件,版本由 spring-boot BOM 管到 3.2.3)· `com.lmax:disruptor`(9 文件, + 父 pom 早有版本管理却无人声明)· `org.jetbrains:annotations`(20 文件;不声明则从 17.0.0 + **静默降到 13.0**,故新增版本属性 + dependencyManagement 钉住 17.0.0)。 + +## 阶段 6 — 用户可见面 + 守门 + +- [ ] **T-70** **regression-test 清理**(只删 thrift 一代的): + `aws_iam_role_p0/test_catalog_with_role.groovy:82-90`(`hive.metastore.type=glue` 块 + `:49` 的 + `hiveGlueTableName` 变量)✋ **`:73-81` 的 `iceberg.catalog.type="glue"` 保留**; + `aws_iam_role_p0/test_catalog_instance_profile.groovy:67-95`(三块 hive-on-glue) + ⚠️ 注意 `:22` 的 guard 是否随之失去意义。其余 glue/dlf case 逐个判「thrift 一代 vs 保留路径」。 +- [ ] **T-71** 文档 / 报错文案:`hive.metastore.type=glue|dlf`、`iceberg.catalog.type=dlf`、 + `paimon.catalog.type=dlf` 的报错要清楚说明**已移除**;仓库内 .md 同步。 +- [ ] **T-72** 静态守门: + - 总判据两条 grep → 0 / 空 + - `mvn -o -f /fe/pom.xml -pl fe-core -am test-compile` BUILD SUCCESS(**漏 `-am` → 假错**) + - fe-common + 全连接器 `test-compile` 绿 · checkstyle 0 · `tools/check-connector-imports.sh` exit 0 + - `mvn -o dependency:tree -Dverbose -Dincludes=org.apache.doris:hive-catalog-shade -pl fe-core -am` → **fe-core 段为空** + - `unzip -l fe/fe-core/target/doris-fe-lib.zip | grep hive` → 记录删前/删后差异 +- [ ] **T-73** **e2e**:① 普通 HMS catalog 读写(回归基线)② **iceberg 原生 Glue + AK/SK**(T-20 的验收,若可跑) + ③ Ranger hive 鉴权(T-50 动了它) +- [ ] **T-74** 收尾:`progress.md` 结项 + `../decisions-log.md` 补 D-NNN + PR(base = `branch-catalog-spi`,squash)。 + **PR 描述必须显式列出移除的用户可见能力**(见下)。 + +### PR 必须声明「本 PR 移除了以下能力」 +- `hive.metastore.type = glue`(AWS Glue as HMS-thrift metastore) +- `hive.metastore.type = dlf`(阿里云 DLF 1.0 as HMS-thrift metastore) +- `iceberg.catalog.type = dlf` +- `paimon.catalog.type = dlf` +- **仍然支持**:`iceberg.catalog.type = glue`(iceberg 原生)· paimon `rest` + DLF token(DLF 2.0)· `hive.metastore.type = hms` + +--- + +## 📌 Commit / 分支纪律 + +- 工作分支 `catalog-spi-11-hive`;PR base = `branch-catalog-spi`,**squash**。 +- **每个阶段 = 独立 commit**;文档与 code **分开 commit**。 +- **⚠️ path-whitelist `git add`,严禁 `git add -A`** —— 工作树有大量历史遗留 scratch(`.audit-scratch/` / + `conf.cmy/` / `META-INF/` / `*.bak` / `failed-cases.out` / `.claude/` …),**非本线程产物,勿混入**。 +- commit message:`[refactor|fix|doc](catalog) …` + `Co-Authored-By: Claude Opus 4.8 (1M context) ` diff --git a/plan-doc/master-todo/README.md b/plan-doc/master-todo/README.md new file mode 100644 index 00000000000000..af9b27f61fd112 --- /dev/null +++ b/plan-doc/master-todo/README.md @@ -0,0 +1,29 @@ +# 📌 Master TODO —— 跨任务 · 需拍板 / 协议演进级的待决大项 + +> **用途**:集中记录那些**已被复核清楚、但暂缓推进**的大项——它们不是普通的"待修 bug",而是需要**用户先拍板**(改变行为语义、或涉及协议演进)、或跨多个任务边界的工程决策。放在这里是为了**不被遗忘**:随时可以拿出来重新评估、立项。 +> +> **收录标准**(满足其一即入): +> 1. 修复会**改变可观察行为/语义**(哪怕功能安全),须用户显式接受; +> 2. 涉及**协议演进**(改 thrift + 改 BE + 跨版本兼容),非纯 FE 回归修复; +> 3. 跨多个任务空间、或收益/代价需要人来权衡后才决定做不做。 +> +> **不收录**:普通可直接做的回归修复 / 性能修复(那些走各自任务空间的 task-list + git)。 +> +> **每项一个独立 md**,含:背景 · 当前代码调用栈与问题 · 方案调用栈与问题 · 示例 · 状态 / 为何需拍板。行号信 HEAD、以 `grep` 为准(文档行号会随代码漂移)。 + +--- + +## 索引 + +| # | 待决项 | 改动层 | 收益 | 为何需拍板 | 状态 | 文档 | +|---|---|---|---|---|---|---| +| 1 | 流式扫描「分片派发」逐分片重建后端分配集 → 微批 | 仅 fe-core 通用框架 | 省流式大扫描规划 CPU(10⁵ 分片≈0.1~0.5s) | **非字节等价**——微批会改变分片落到哪些后端节点(有意的负载均衡语义变更) | ⏳ 待定 | [`streaming-split-dispatch-microbatch.md`](./streaming-split-dispatch-microbatch.md) | +| 2 | 线路上删除文件列表逐分片重复 → 扫描节点级删除文件字典 + 每范围索引 | FE + **thrift 协议 + BE 读取端** | 减发给 BE 的计划体积(删除密集 MOR 表可省几 MB) | **协议演进**——改 BE + 跨版本兼容矩阵,非回归修复 | ⏳ 待定 | [`wire-delete-file-dictionary.md`](./wire-delete-file-dictionary.md) | + +--- + +## 说明 + +- 这两项来自 iceberg 连接器热路径重操作审计的收尾复核(2026-07-19,证据已在 HEAD 上重新核实、均未失效)。同批次的其余低量级项(通用节点路径重复解析 / build-then-discard 分区列)性价比低或碰"fe-core 只减不增"铁律,已暂缓,未单独入册。 +- 若将来推进任一项,按项目惯例走:**先设计 → 用户确认 → 再动代码**;协议演进项(第 2 项)**动 BE/thrift 前必须签字**。 +- 内部关联(审计原始证据 + 逐项调用链):`plan-doc/reviews/perf-audit-fe-connector-iceberg-2026-07-17*` 与任务空间 `plan-doc/perf-hotpath-iceberg/`。 diff --git a/plan-doc/master-todo/streaming-split-dispatch-microbatch.md b/plan-doc/master-todo/streaming-split-dispatch-microbatch.md new file mode 100644 index 00000000000000..1897cb3a4d1666 --- /dev/null +++ b/plan-doc/master-todo/streaming-split-dispatch-microbatch.md @@ -0,0 +1,92 @@ +# 待决项 1 —— 流式扫描的「分片派发」每次只送一个分片给后端分配器 + +> **状态**:⏳ 待定(**需用户签"接受后端分配语义变化"**才能立项) +> **改动层**:仅 fe-core 通用框架(`PluginDrivenScanNode` / `SplitAssignment` / `FederationBackendPolicy`),不触 BE、不触 thrift。 +> **影响面**:流式路径由 **iceberg 和 trino 两个连接器**共用——通用改动同时影响两者(不能只改 iceberg,否则违反"通用节点禁按源名分支")。 +> **行号信 HEAD(55087e08d0c 附近,PERF-11 后)**,以 `grep` 为准。 + +--- + +## 背景 + +当一张外表非常大(匹配文件数 ≥ `num_files_in_batch_mode`,默认 1024;实际针对 10 万~100 万分片的扫描),扫描走**流式**模式:不一次性把所有分片算出来堆在 FE 内存里(会 OOM),而是一边惰性读元数据、一边把分片"泵"给调度器,靠背压(队列满就等)把 FE 堆压住。 + +问题不在"读元数据"(那是必要成本),而在**把分片交给"后端分配器"的方式**——分配器(`FederationBackendPolicy`)负责决定"这个分片让哪台 BE 去扫"。 + +--- + +## 当前代码的调用栈与问题 + +``` +startStreamingSplit() PluginDrivenScanNode.java:1600 [单个后台任务] +└─ while (needMoreSplit() && source.hasNext()): :1638 ← 逐个分片的泵循环 + one = [ new PluginDrivenSplit(source.next()) ] :1639-1640 ← 每次只包 1 个分片的 List + splitAssignment.addToQueue(one) :1641 → SplitAssignment.java:143 + └─ synchronized (assignLock) { ... } SplitAssignment.java:148 ← 每分片一次加锁往返 + backendPolicy.computeScanRangeAssignment(one) :153 → FederationBackendPolicy.java:225 + ├─ Collections.shuffle(one, seeded) :228 ← 对 1 个元素洗牌 = 空操作 + ├─ backends = flatten(backendMap) :231-234 ← 把全部 ~100 台 BE 拷进新 List + ├─ new ResettableRandomizedIterator(backends) :235 ← 又拷一遍(默认 ROUND_ROBIN 用不到) + ├─ 选 1 台 BE (ROUND_ROBIN: nextBe++) :269-270 + └─ if (enableSplitsRedistribution) :307 ← 默认 true、无生产关闭途径 + equateDistribution(assignment) :320 + ├─ allNodes 排序 O(B·logB) :329 + └─ 建两个 IndexedPriorityQueue :335-343 ← 对全部 BE 各建一遍堆 +``` + +**问题**:泵循环每吐一个分片,`computeScanRangeAssignment` 就把"和这一个分片无关的固定开销"从头重算一遍——把上百台 BE 拷两遍、建 multimap、洗牌、加锁往返,**还有 `equateDistribution`**:它对全部后端做一次 `O(B·logB)` 排序 + 建两个堆,而且默认恒开(`enableSplitsRedistribution=true`,那个 setter 只有测试在调、生产没有关它的路径)。 + +100 万分片 × 上百台 BE,这些"每分片重建"的操作叠起来是纯 FE 的 CPU + 大量短命对象(GC 压力)。**没有远程 IO**,所以量级有限(10 万分片约 0.1~0.5 秒、100 万分片几秒),但确实是白干——这些固定开销本该一批分片摊一次。 + +> 对照:分区批风格的兄弟路径已经是"整批一起 `addToQueue`"(`PluginDrivenScanNode.java:1570` 附近),非流式的 legacy 路径也是"所有分片一次 `computeScanRangeAssignment`"(`FileQueryScanNode.java:431`)——唯独流式泵是"一次一个"。 + +--- + +## 解决方案的调用栈与问题 + +方向:泵侧**微批**——攒够 K 个(64~256)再一起送。 + +``` +startStreamingSplit() +└─ batch = new ArrayList(K) + while (needMoreSplit() && source.hasNext()): + batch.add(new PluginDrivenSplit(source.next())) + if (batch.size() >= K) { + splitAssignment.addToQueue(batch) ← 一次送 K 个 + batch = new ArrayList(K) + } + if (!batch.isEmpty()) splitAssignment.addToQueue(batch) ← 末批必须 flush,否则丢分片 + splitAssignment.finishSchedule() + └─ computeScanRangeAssignment(batch) ← 从"每分片一次"变"每 K 个一次" +``` + +**方案自身的问题(正是它需要用户签字的原因)**: + +1. **不是字节等价——后端分配结果会变**(关键)。当前"一次一个"下,`Collections.shuffle`(对单元素是空操作)和 `equateDistribution`(单分片没什么可再平衡的)实际都不起作用;一旦变成"一批 K 个",这两步就**开始真正做事**:洗牌重排这 K 个的顺序、`equateDistribution` 在这一批内部把分片从"堆得多的 BE"挪到"堆得少的 BE"。而 `nextBe`、`assignedWeightPerBackend` 是**跨批累积的实例字段**(`FederationBackendPolicy.java:84/88`)。所以**同一批分片最终落到哪些 BE 会和现在不同**。 + - 功能上安全:每个分片仍恰好被分配一次、所有数据都读到,变的只是"哪个分片去哪台机器"——负载均衡的启发式结果(甚至更均衡)。 + - 但它**违反"共享框架热路径须逐字节不变"**纪律,不能声称"透明无感"。且"一次一个"本身是照搬**上游** legacy `doStartSplit`(`:1635-1637` 注释写明),改它属于"在上游基线上演进",不是"修回归"。→ **需要用户签"接受这个负载均衡分配变化"**。 + +2. **正确性不变式要小心**:`needMoreSplit()` 背压要**每批**重新检查(不能攒到一半该停了还继续攒);末批一定要 flush;批边界不能丢/重分片;`source.close()` 仍在 finally 里吞异常。这些可单测。 + +3. **验证**:碰 fe-core → 两段验(iceberg 连接器不依赖 fe-core)。但"省了多少时延"和"分配分布变成什么样"这两个承载性结论**单测测不出来**,要真 BE 分布式跑 ≥1024 文件的流式扫描(iceberg + trino 各一次)才能观测;正确性不变式(不丢不重/背压)可用 mock split source 单测。 + +--- + +## 示例 + +设 100 台 BE、一次流式扫描 50 万分片、微批 K=128: +- **当前**:`computeScanRangeAssignment` 被调 **50 万次**,每次拷 100 台 BE 两遍 + 排序 100·log100 + 建两个堆 + 一次加锁 → 约 50 万次全套固定开销。 +- **微批后**:被调 **≈3900 次**(50 万 / 128),固定开销摊薄到 1/128。 +- **代价示例**:假设分片按 round-robin,当前第 7 个分片去 BE#7、第 8 个去 BE#8……微批后,这 128 个先被洗牌重排、再被 `equateDistribution` 按累积权重挪动,**第 7 个分片可能落到 BE#42**。查询结果完全一样,但"哪台机器扫哪个文件"的分布图变了——这就是需要用户点头的那个"语义变化"。 + +--- + +## 立项前须确认 + +- [ ] 用户签字:**接受微批带来的后端分片分配分布变化**(功能等价、负载均衡启发式变)。 +- [ ] 定 K(批大小)与背压交互;确认微批不改"每分片恰分一次 / 不丢不重"。 +- [ ] 两段验 + 真 BE 分布式 smoke(iceberg + trino 流式各一次)。 + +## 关联 + +审计原始证据 / 逐项调用链:`plan-doc/reviews/perf-audit-fe-connector-iceberg-2026-07-17*`(该簇 findings);任务空间 `plan-doc/perf-hotpath-iceberg/`。 diff --git a/plan-doc/master-todo/wire-delete-file-dictionary.md b/plan-doc/master-todo/wire-delete-file-dictionary.md new file mode 100644 index 00000000000000..cf6962a837c06b --- /dev/null +++ b/plan-doc/master-todo/wire-delete-file-dictionary.md @@ -0,0 +1,116 @@ +# 待决项 2 —— 线路上「删除文件列表 + 分区 JSON」被逐分片重复塞进每个范围 + +> **状态**:⏳ 待定(**协议演进:动 BE/thrift 前必须用户签字**) +> **改动层**:FE 发送端(iceberg 连接器侧)+ **thrift 协议(新字段)** + **BE 读取端** + **跨版本兼容矩阵**。 +> **触碰铁律 D**(改 BE + 改协议 = 协议演进,非回归修复)。 +> **仅对 v2+ MOR 表生效**,普通表零影响。 +> **行号信 HEAD(55087e08d0c 附近,PERF-11 后)**,以 `grep` 为准。 + +--- + +## 背景 + +Iceberg 的 v2/v3 表支持"读时合并"(MOR):数据文件旁边挂着**删除文件**(position delete / equality delete / deletion vector),BE 读数据时用它们过滤掉被删的行。FE 规划时把每个数据文件的删除文件列表放进发给 BE 的扫描计划。 + +两个放大点叠加: +- 一个大数据文件会被切成 **k 个字节切片**(每个切片是一个扫描范围 `TFileRangeDesc`),BE 并行读; +- 一个 equality delete 文件常被**很多数据文件共享**(它是分区级/表级的删除)。 + +> 上一步的提交(PERF-11 的 `10b7d29423f`)只优化了 **FE 内存侧**——让同一文件的 k 个切片在 FE 堆里**共享**同一份删除列表对象;**线路(发给 BE 的字节)上的重复没动**,就是本项。 + +--- + +## 当前代码的调用栈与问题 + +``` +createFileRangeDesc(...) FileScanNode (每个切片一次) +└─ setScanParams(rangeDesc, split) PluginDrivenScanNode.java:1693 [每范围] + └─ scanRange.populateRangeParams(fmtDesc, rangeDesc) :1705 → IcebergScanRange.java:330 + fileDesc.setPartitionDataJson(partitionJson) :397 ← 分区 JSON 塞进本范围 + if (v2+): :408 + deleteDescs = new ArrayList(deleteFiles.size()) :413 + for (delete : deleteFiles): :414 ← 每范围 × 每删除文件 + deleteDescs.add(delete.toThrift()) :415 ← 每次新建一个 TIcebergDeleteFileDesc + fileDesc.setDeleteFiles(deleteDescs) :417 ← 整份删除列表塞进"本切片"的范围 + rangeDesc.setTableFormatParams(fmtDesc) :1707 +``` + +thrift 的形状(`gensrc/thrift/PlanNodes.thrift`): +``` +TFileRangeDesc (每范围一个) → table_format_params: TTableFormatFileDesc :565 / :464 +TTableFormatFileDesc → iceberg_params: TIcebergFileDesc +TIcebergFileDesc.delete_files : list :339 ← 删除列表挂在"每范围"里 +TIcebergDeleteFileDesc { path; bounds; field_ids; content; DV偏移; original_path; ... } :315-331 + ← 每条约 100~200 字节(主要是路径字符串) +``` + +BE 侧逐范围 inline 消费(无字典): +``` +IcebergReaderMixin ... 初始化删除文件 be/src/format/table/iceberg_reader_mixin.h:435 + table_desc = get_scan_range().table_format_params.iceberg_params [每范围] + for (desc : table_desc.delete_files): :444 ← 逐范围内联遍历 + 分桶到 position / equality / deletion_vector :445-451 +``` + +**问题**:一个数据文件的完整删除列表 + 分区 JSON 被复制进它**每一个字节切片**的 `TFileRangeDesc`;一个被 M 个数据文件共享的删除文件,在计划里出现 **M×k 次**。大 MOR 扫描的计划体积因此多出 MB 级——FE 序列化付一次、BE 反序列化再付一次。CPU 侧还有 `delete.toThrift()`(`:415`;实现在 `IcebergScanRange.java:681`)每范围重转一遍("第二次转换")。 + +--- + +## 解决方案的调用栈与问题 + +方向:**扫描节点级的删除文件字典 + 每范围索引**(去重)。 + +``` +FE 发送端: + createScanRangeLocations() PluginDrivenScanNode.java:1724 + └─ scanProvider.populateScanLevelParams(params, props) :1732 ← 已存在的"扫描节点级"通用挂钩 + └─ (iceberg 新 override) 把整次扫描的去重删除文件表 + 写进 params.iceberg_delete_dict : list ← 每个删除文件只序列化 1 次 + setScanParams(rangeDesc, split) → populateRangeParams(...) [每范围] + └─ fileDesc.setDeleteFileIndices([3, 7, 12]) ← 每范围只带几个 int 索引,不带完整列表 + +BE 读取端: + iceberg_reader_mixin.h + dict = get_params().iceberg_delete_dict ← 扫描级读一次 + for (i : table_desc.delete_file_indices): ← 索引 → 字典解析 + 分桶(dict[i]) +``` + +关键先例(证明形状可行):`TFileScanRangeParams` 里 paimon 的字段 27/30 就是"扫描节点级、避免每分片重复序列化"(`PlanNodes.thrift:549-556` 注释原话 *"Set at ScanNode level to avoid redundant serialization in each split"*),FE 侧正是通过 `ConnectorScanPlanProvider.populateScanLevelParams`(`:474`;`PaimonScanPlanProvider.java:1355` 已用同法)挂上去的。 + +**方案自身的问题(正是它需要用户签字的原因)**: + +1. **这是协议演进,不是干净的回归修复**。要动**三层**:FE 发送端 + thrift schema(新字段)+ **BE 读取端**(`iceberg_reader_mixin.h` 改成"先按索引查字典再分桶")。触碰"改 BE + 改协议"铁律 D → **必须用户先签字**。 + +2. **跨版本兼容矩阵是最难、也是承载性的一环**。集群里 FE/BE 可能版本不一: + - **老 BE 配新 FE**:老 BE 不认新字典字段,只读 `delete_files`——新 FE 要么继续内联发一份(那就没省字节)、要么按"BE 能力位"判断对方支持才发字典; + - **新 BE 配老 FE**:老 FE 只发 `delete_files`,新 BE 要能回退到内联读。 + - 这套"双发/能力位门控 + 双向回退"是真正的工作量,不能跳过。 + +3. **验证最重**:FE 单测(字典+索引与内联语义等价)+ BE reader 单测(索引解析 + 内联回退)+ 真实 MOR 表 e2e(position/equality/deletion vector 三类删除结果不变)+ **混版本兼容矩阵**(老BE↔新FE、新BE↔老FE 都要正确读到删除行)。 + +4. **一个澄清**:FE 发送端其实**不需要动 fe-core**(`populateScanLevelParams` 这个连接器无关挂钩已存在、paimon 已用同法),所以这块不碰"fe-core 加面"。难点全在 **BE + 兼容**。 + +5. **有个 FE-only「半赢」但基本没用**:因为上一步已让同文件的 k 个切片在 FE 堆里共享同一份删除列表,可顺手把 `toThrift()` 也缓存复用(k→1 次转换、省点 FE CPU/堆)。但 thrift 序列化每遇一次引用**仍会把整个结构完整写一遍**,所以**线路字节一个都不少**——只省 FE 的 CPU/堆,还要给不可变可序列化类加可变缓存,多半不值得单做。 + +--- + +## 示例 + +设一个 MOR 表:一个 equality delete 文件 `del-a.parquet`(路径 + 边界 + field_ids 序列化后约 200 字节),被 1000 个数据文件共享;每个数据文件平均切成 4 个字节切片。 +- **当前**:这一个删除文件被序列化 1000 × 4 = **4000 次** ≈ 800 KB,全是重复的同一份;表里若有几十个这样的共享删除文件 → **几 MB 的重复**塞进发给每台 BE 的计划,FE 建 + BE 解析双向付费。 +- **字典方案后**:`del-a.parquet` 在扫描级字典里**只序列化 1 次**(200 字节);4000 个范围各带一个 4 字节索引 ≈ 16 KB。这一个删除文件从 800 KB 降到 ~16 KB。 +- **代价对照**:换来的是要改 thrift + BE reader + 扛住"新旧 FE/BE 混跑都要正确读到删除行"的兼容矩阵——删错或读漏删除文件 = 查询结果错,所以兼容那一环必须做扎实。 + +--- + +## 立项前须确认 + +- [ ] 用户签字:**同意做协议演进**(改 thrift + 改 BE 读取端)。 +- [ ] 定兼容策略:双发 vs BE 能力位门控;确认老BE↔新FE、新BE↔老FE 双向都能正确读删除。 +- [ ] 定新字段布局:字典挂 `TFileScanRangeParams`(仿 paimon 27/30)+ 每范围索引挂 `TIcebergFileDesc`。 +- [ ] 全套验证:FE 等价单测 + BE reader/回退单测 + 真 MOR e2e(三类删除)+ 混版本矩阵。 + +## 关联 + +审计原始证据 / 逐项调用链:`plan-doc/reviews/perf-audit-fe-connector-iceberg-2026-07-17*`(该簇 findings,自述"协议演进非回归修复");任务空间 `plan-doc/perf-hotpath-iceberg/`。 diff --git a/plan-doc/metastore-storage-refactor/HANDOFF.md b/plan-doc/metastore-storage-refactor/HANDOFF.md new file mode 100644 index 00000000000000..97b67d35b810bb --- /dev/null +++ b/plan-doc/metastore-storage-refactor/HANDOFF.md @@ -0,0 +1,113 @@ +> # 🔒 本子线已彻底 CLOSED(2026-06-22 收官,用户确认) +> +> **「属性体系重构」子项目(Storage→fe-filesystem / MetaStore→fe-connector SPI,paimon 优先)已全部完成并合入主线** —— 核心任务 15/15 + docker 真闸全过;产出 `fe-kerberos` / `fe-connector-metastore-api` / `fe-connector-metastore-spi`(含 `MetaStoreProviders.bind` + 5 provider)+ 删除 `fe-property` 孤儿模块;paimon 连接器已 cutover 到共享 SPI。合入提交:`#64446`(paimon SPI+翻闸)/ `#64653`(P5-T29 删 legacy)/ `#64655`(P3b kerberos 收口 `e5959e1b53d`)。 +> +> **⛔ 后续任务(含主线 P6/P7)请勿再阅读本目录的规划/接力文档** —— 它们是已结束工作的历史留存,不再维护。需了解 metastore-spi / `MetaStoreProviders.bind` 现状请**直接读代码**:`fe/fe-connector/fe-connector-metastore-spi/`。主线接力见 [`../HANDOFF.md`](../HANDOFF.md)。 + +--- + +# HANDOFF — Session 间接力(每完成一个阶段/任务即更新并 commit) + +> **下次 agent 接手流程(强制,用户 2026-06-17 立规)**: +> 1. 先读 `PROGRESS.md` → 本文件 → `WORKFLOW.md` → 下一 task 在 `tasks.md` 的对应块 → `decisions-log.md`/`deviations-log.md` 相关条。 +> 2. **对照真实代码 review 下一步方案**(不照搬本文件里的旧计划——代码可能已变;先 grep/读真实调用流,确认方案仍成立)。 +> 3. 一句话复述确认 + 必要时 AskUserQuestion 定边界 → 开始实施(严格按 `WORKFLOW.md §2` 单任务 TDD 循环)。 + +--- + +**更新时间**:2026-06-21(**本子线核心 15/15 ✅ + docker 真闸全过 → 子线收官**:P3b-T01 三步已合入主线 `#64655`/`e5959e1b53d`[trino→JDK + relocate 13 类到 `fe-kerberos` + 统一 `HadoopAuthenticator` 接口 + 删 fe-filesystem-hdfs 副本];docker kerberos e2e[HDFS kerberized + HMS]已由用户跑过,`doAs` 不回归。**先前所有「未 push 的 local-commit」框架已过时** —— granular 提交已被 squash 进 PR 合并提交,不再是 HEAD 祖先。**下一步 = 主线 P6 iceberg**[见 `../HANDOFF.md`],复用收口后干净的 `fe-kerberos` authenticator;本子线后续仅剩「metastore-props 搬出 fe-core」的与 P6/P7 协同评估[非阻塞]) +**更新人**:Claude(Opus 4.8) + +> **本 session 进度补注(最新在最前)**: +> - **2026-06-21 — ✅ 子线收官(P3b 合入主线 + docker 真闸全过)**:P3b-T01 三步已 **squash 合入** `branch-catalog-spi` 作 PR 提交 **`#64655` / `e5959e1b53d`**(已 push `upstream-apache/branch-catalog-spi`);docker kerberos e2e(HDFS kerberized + HMS)**已由用户跑过,`doAs` 不回归**。**下方 commit 1/2/3 三条记录里的「未 push」「下一步 = docker」均已过时**(granular 哈希 `4a740e1387f`/`8898e15134c`/`5e3e8963023` 已被 squash,不再是 HEAD 祖先;保留作详细 scope 史)。本子线 **核心 15/15 + docker 真闸 = 全完成**;唯一后续 = 主线 P6/P7 时一并评估「把 paimon/iceberg/hive metastore-props 搬出 fe-core」(非阻塞;主线 backlog 已记 paimon 侧不可单删的三条 live 路径分析)。**主线下一步 = P6 iceberg**(见 [`../HANDOFF.md`])。 +> - **2026-06-21 — P3b-T01 commit 3 ✅(统一 HadoopAuthenticator + 删 hdfs 副本;commit `5e3e8963023`,未 push)→ P3b-T01 三步全完成**:按强制流程读全套文档 + firsthand recon 两个打架的 `HadoopAuthenticator` 接口(fe-kerberos `getUGI()`+`doAs(PrivilegedExceptionAction)`+静态工厂 vs fe-filesystem-spi `doAs(IOCallable)`)+ 两套 impl 行为对比(**实测不等价**:fe-kerberos=LoginContext+80%-refresh+unconditional setConfiguration;hdfs 副本=`loginUserFromKeytabAndReturnUGI`+per-call relogin+first-writer-wins guard)。**AskUserQuestion 定 pure consolidation**(采纳 fe-kerberos 行为,恢复 legacy fe-common/HMS HDFS parity;真闸 docker kerberos e2e)。**做法**(DV-010):删 fe-filesystem-spi `HadoopAuthenticator`(IOCallable)+`IOCallable`(grep 实证仅 fe-filesystem-hdfs 消费、0 外部)+ 删 fe-filesystem-hdfs `KerberosHadoopAuthenticator`/`SimpleHadoopAuthenticator` 副本;4 消费方(DFSFileSystem/HdfsInputFile/HdfsOutputFile/HdfsFileIterator)import→`org.apache.doris.kerberos.HadoopAuthenticator`(`doAs(() -> …)` lambda 自然绑定 PrivilegedExceptionAction,**无显式 adapter**);新 `DFSFileSystem.buildAuthenticator()` seam **保 kerberos-vs-simple 选择决策字节不变**(`isKerberosEnabled`,不用 fe-kerberos 的 `hadoop.security.authentication` gate);fe-filesystem-hdfs pom 加 `fe-kerberos`(no-cycle,叶子)。**接受变更**(docker 把关):simple/无 username→remote user "hadoop"。**对抗 review `wf_b1a4e7e4-b51`(3 lens+verify)揪 3 MINOR 全修**:①empty-string `hadoop.username`→`createRemoteUser("")` 抛 IAE(bytecode 实证)→ buildAuthenticator 统一 absent/empty→默认 "hadoop"(RED `IllegalArgumentException: Null user`→GREEN)②补 empty-string 测试 ③scrub stale 文档(fe-filesystem README + spi pom description)。**验证**:fe-filesystem-hdfs **79/0/0**(+fe-kerberos/spi via -am)BUILD SUCCESS + checkstyle 0 + import-gate 0 + grep `filesystem.spi.HadoopAuthenticator/IOCallable`=0 + reactor test-compile 净(唯一失败=pre-existing paimon HiveConf shade quirk,不相关)。⚠️ 未 push、**docker kerberos e2e(HDFS kerberized + HMS)NOT run**(真闸)。**下一步 = 部署 docker 跑 kerberos e2e 验 `doAs` 不回归 → 然后 P6 iceberg(复用收口后的 fe-kerberos authenticator)**。 +> - **2026-06-21 — P3b-T01 commit 2 ✅(relocate 13 类到 fe-kerberos;commit `8898e15134c`,未 push)**:按强制流程读全套文档 + recon `wf_d5566c5f-7b1`(6 reader + 2 对抗 verify)独立核实并**修正计数**——真 import-retarget 面 = **27 main(24 fe-core + 3 be-java-extensions scanner,0 外部 fe-common 消费方)+ 14 test**;真搬 **13 类**(含 commit 1 新建的 `KerberosTicketUtils`,非文档「12」)。**no-cycle CONFIRMED**(13 类零 doris-非kerberos import → 干净叶子,无环;build order fe-kerberos 已先于 fe-common);**AuthType 合并** verify「REFUTED」实为 pedantic(mutable `getCode/setCode/setDesc` 实证 0 caller→drop;唯一 `isSupportedAuthType` caller=`HiveTable`,加进 merged enum 后纯 import retarget 无逻辑改)。**做法**:`git mv` 13 类 + 2 测到 `org.apache.doris.kerberos`(R94-98%)+ sed package 行 + 重指向 41 消费方 import(纯 import 行,含 `datasource.property.{storage,metastore}` 禁包 D-017 import-only 例外)+ 合并 AuthType(drop dead mutable/code API,加 `isSupportedAuthType`,保 `getDesc/fromString`,删 fe-common 版)+ fe-kerberos pom 加 `hadoop-common(provided)`+guava+commons-lang3+lombok+log4j-api(**实证只需 hadoop-common 非 hadoop-auth**;叶子不变量=零 FE 模块依赖)+ fe-common→fe-kerberos(compile) 边 + java-common→fe-kerberos(compile) 边(BE-java scanner 打包鲁棒)。**验证**:fe-kerberos `11/0`(含搬来 `AuthenticationTest`/`KerberosTicketUtilsTest`);fe-core + java-common + 3 scanner `test-compile` BUILD SUCCESS + checkstyle 0(顺修 in-place sed 引入的 `CustomImportOrder`:按 FQN 重排 doris import 块;`Metric` vs `Metric.MetricUnit` 前缀对要用无分号 key);import-gate exit 0;whole-repo grep 旧包=0;fe-connector-paimon `test-compile` 失败=**已证 pre-existing HiveConf shade quirk**(文档 `-am package -Dassembly.skipAssembly=true` 路 BUILD SUCCESS,且 paimon 不消费被搬类、不在 diff)。⚠️ 未 push、**docker kerberos e2e 未跑**(真闸)。白名单微扩 `fe/fe-common/pom.xml`(D-017 已预定,§4.1 补登)。**下一步 = commit 3(统一两个 `HadoopAuthenticator` 接口 + 删 fe-filesystem-hdfs 副本)**。 +> - **2026-06-21 — P3b-T01 commit 1 ✅(trino→JDK 原地替换;Phased + Repackage scope 已定)**:按强制流程读全套文档 + recon `wf_c14cb816-ed9`(6 reader 对照真实代码)核实并**修正** scope(真 import-retarget 面 = **27 main**[24 fe-core + 3 be-java-extensions],非文档「40」——旧「12 fe-common」是**被搬的 12 类自身**按 `package` 行误计、外部 fe-common 消费方=0;两个 `HadoopAuthenticator` 接口**结构不兼容**[fe-common 版多 `getUGI()`+静态工厂]须 adapter)→ **AskUserQuestion 定 Phased(独立 commit)+ Repackage 到 `org.apache.doris.kerberos.*`**(重指向全部 import、合并重复 AuthType)→ **DV-009 重排**(trino→JDK 先做,避免 relocate 把 trino 带进 fe-kerberos 干净叶子)。**commit 1 改动**(仅 `fe/fe-common/.../security/authentication/**`):新 JDK-only `KerberosTicketUtils`(javap 反编译 trino 版逐字节复刻:`getRefreshTime`=`start+(long)((end-start)*0.8f)`、`getTicketGrantingTicket`=私有凭据里 server==`krbtgt/REALM@REALM` 否则 IAE)+ `HadoopKerberosAuthenticator` 删 `io.trino` import(同包调用点字节不变)+ `KerberosTicketUtilsTest` 4/0。**验证**:fe-common `-am` BUILD SUCCESS + checkstyle 0 + mutation `0.8f→0.5f`→RED(`9000≠6000`)。**fe-common pom 不动**(trinoconnector + IndexedPriorityQueue/UpdateablePriorityQueue/Queue 仍用 trino-main)。⚠️ 未 push、**docker kerberos e2e 未跑**(真闸)。**下一步 = commit 2(relocate 12 类 → `org.apache.doris.kerberos.*`)**。 +> - **2026-06-21 — P2-T04 ✅ + P2-T05 ✅ → P2 全 5/5;下一任务改 P3b(D-017,先于 P6 iceberg)**:用户 2026-06-21 **手动 docker 验证 P2-T05**(paimon 5-flavor 读 + vended REST/DLF + Kerberos HMS,`enablePaimonTest=true`)通过;P2-T04(`MetaStoreProviders` 2-arg loader `2612af5e88f` + pom/import-gate)随之收口 → **子线 docker 真闸通过**。主线 P5-T29(B8) paimon legacy 删除亦已完成(fe-core 完全 paimon-SDK-free)。**用户定:在 P6 iceberg SPI 迁移之前先做 P3b(kerberos authenticator 机制收口到 fe-kerberos)** — 见 **D-017** + tasks **P3b-T01** 现码 scope(12 类机制 + 40 消费方 blast radius[24 fe-core+12 fe-common+3 be-java-extensions] + 双 `HadoopAuthenticator` 接口统一 + trino`KerberosTicketUtils`→JDK)。**仅文档更新。** +> - **P1-T07 ✅(commit `13d3876d25d`,已 push `catalog-spi-07-paimon`+`master-catalog-spi-07-paimon`,PR #64445 评论 `run buildall`,D-016)**:彻底删除 fe-property 孤儿模块(**覆盖 D-005「不删 fe-property」条款**;fe-core `datasource.property.{storage,metastore}` 两包仍禁碰、仍服务 hive/hudi/iceberg)。执行前按强制流程复核(读全套文档 + 对照真实代码 recon)+ 1 边界经 AskUserQuestion 定(5 处 stale 注释「一并清理」)。**改动**(白名单内):`git rm -r fe/fe-property/`(27 文件 = 26 java + pom)+ `rm` stale `target/`(目录全消);`fe/pom.xml` 删 `fe-property` + dependencyManagement 条目;5 处注释「fe-property」→「legacy」(paimon `PaimonCatalogFactory`×2/`PaimonConnector`×2/`PaimonCatalogFactoryTest`×1 + fe-filesystem-hdfs `HdfsConfigFileLoader`/`HdfsFileSystemProperties`——保历史语义非改逻辑)。**RED/GREEN = 构建闸**(无 UT 可写,同 P1-T05 模式):whole-repo `grep fe-property`(排 plan-doc)=**0**、`grep org.apache.doris.property`=**0**;**全 FE reactor `test-compile` BUILD SUCCESS**(`-Dmaven.build.cache.enabled=false`,fe-core `compile`+`testCompile` 实跑,54 模块 **0 ERROR**,1:53min);paimon 全模块 **278/0/1skip**、fe-filesystem-hdfs **78/0/0**、checkstyle 0、`tools/check-connector-imports.sh` exit 0、`git diff --name-only` 白名单干净。⚠️ **docker e2e 未跑**(D-012,留 P2-T05)。 +> - **决策**:无新决策(D-016 已预定本任务);唯一边界 = AskUserQuestion「5 处注释一并清理」(保历史语义、白名单内)。 +> +> **更早本 session(P2-T01..T03,已完成)**: +> - **P2-T03 ✅(commit `3c1e118dcfa`)**:paimon 元存储**连接逻辑** cutover 到 P2-T02 建的共享 spi(paimon SDK Options 组装 + filesystem/jdbc 存储 Configuration **留连接器**,非连接事实)。**2 边界经 AskUserQuestion 定**:**D-014**(采用 spi 的 **legacy-faithful validate**——CREATE CATALOG 比当前 paimon 更严:HMS case-sensitive forbidIf(simple)/requireIf(kerberos)、REST case-sensitive `"dlf".equals`、DLF 在 CREATE 要求 OSS;故意向真 legacy 收敛)、**D-015**(JDBC **注册副作用留连接器**,仅纯 `resolveDriverUrl` 共享;不下移=单消费方+守 spi SDK/JVM-free,Rule 2)。**改动**(白名单内 5 main+2 test+pom,净 +318/−847):`validateProperties`→`MetaStoreProviders.bind(props,{}).validate()`;`createCatalog` HMS/DLF→`bind`+新薄 `PaimonCatalogFactory.assembleHiveConf(base,overrides)`(HMS seed `ctx.loadHiveConfResources` base 再叠 `toHiveConfOverrides`;DLF `assembleHiveConf(null,toDlfCatalogConf())`)、删 build-time `requireOssStorageForDlf`;两处 driver-url→`JdbcDriverSupport.resolveDriverUrl`;`PaimonCatalogFactory` 删 6 法+`KNOWN_FLAVORS`+加 `assembleHiveConf`;`PaimonConnectorProperties` 删 `DLF_*`/`REST_TOKEN_PROVIDER`/`REST_DLF_*`(**DV-008**:别名数组只**部分**删——`HMS_URI`/`REST_URI`/`JDBC_*` 仍被保留的 `buildCatalogOptions` 用)。**TDD**:新 `PaimonConnectorValidatePropertiesTest` 13/0(3 tightening RED→GREEN 实证)+ 删 28 旧 builder/validate 测(content parity 已由 spi `Hms/DlfMetaStorePropertiesTest` 13+7 覆盖)+ 2 `assembleHiveConf` 测(F2 layering)。**验证 paimon 全模块 278/0/1skip**(skip=live gated)、checkstyle 0、import-gate 0、白名单干净。**recon `wf_9437dd4e-06d`** verify=SOUND/READY(逐键 parity 通过);**对抗 review `wf_dd78ec4b-da5`** verify=READY/0 真 finding(唯一 MAJOR「kerberos.principal alias 未测」证伪=该键走 verbatim passthrough→测它恒真 tautology 违 Rule 9;隔离 binding 的 `service.principal`→`kerberos.principal` 方向已被 spi line72/80 覆盖)。⚠️ **docker e2e 未跑**(HMS/DLF live metastore=hive + 插件 zip ServiceLoader 发现 5 provider 在子优先 loader=P2-T05 真闸)。 +> - **决策补**:D-014(采用 legacy-faithful validate)|D-015(JDBC 注册留连接器)|DV-008(别名数组部分删 + `bind` 取代 `parse` + 新 `assembleHiveConf` 助手)。 +> - **P2-T02 ✅(commit `7ea63528bc4`)**:新建 `fe-connector-metastore-spi`(22 文件 = 15 main + 7 test)。**3 边界经 AskUserQuestion 定**:**DV-006**(fe-kerberos = compile-dep only,**零新代码**——recon 三重证伪 HANDOFF 旧写「增量补 authenticator 机制」:产出 `KerberosAuthSpec` 纯 String→值对象不需 hadoop,真 doAs 留 FE 侧 `ctx.executeAuthenticated`)、**DV-007**(parser storage 入参 = 中立 `Map storageHadoopConfig`,**非** `List`;spi **不**依赖 fe-filesystem-api,保持 hadoop/fs-free;parser 拥有 storage-overlay 以守 kerberos-after-storage 序)、全 5 后端一次 commit。**内容**:`MetaStoreProvider

    extends PluginFactory`(`supports`+abstract `bind(props,storageHadoopConfig)`)+ `MetaStoreProviders.bind` first-hit ServiceLoader 派发 + `MetaStoreParseUtils`(firstNonBlank/copyIfPresent/applyStorageConfig/matchedProperties + `CATALOG_TYPE_KEY=paimon.catalog.type`)+ `JdbcDriverSupport.resolveDriverUrl`(**仅纯 resolver**;driver 注册/DriverShim JVM 副作用无调用方 → 留 P2-T03,Rule 2)+ `AbstractMetaStoreProperties`(共享 raw/warehouse/matchedProperties)+ 5 `*MetaStorePropertiesImpl`(`@ConnectorProperty` 绑定,消灭 `PaimonConnectorProperties` 手抄别名)+ 5 provider(`sensitivePropertyKeys` 暴露 sensitive 键,镜像 `S3FileSystemProvider`)+ 单 `META-INF/services`(5 行)。pom = metastore-api + fe-extension-spi + fe-foundation + fe-kerberos + commons-lang3(copy-plugin-deps phase=none)。**来源 = 上移 paimon `PaimonCatalogFactory` 手抄逻辑去 fe-core 化**(HiveConf→中立 Map、authenticator→facts);**fe-core 旧 `Paimon*MetaStoreProperties` 不动**。**HMS D-4 补回** legacy `HMSBaseProperties.buildRules` 的 forbidIf-simple/requireIf-kerberos(paimon 手抄 validate 漏;**CASE-SENSITIVE `Objects.equals` 对齐 ParamRules**,与 `buildHmsHiveConf` 的 `equalsIgnoreCase` 不对称**保留**)。验证:spi **41/0**、checkstyle 0、import-gate exit 0、无 fe-core 禁包 import、白名单干净、**3 mutation RED→GREEN**(HMS 大小写敏感·kerberos-after-storage clobber·REST 大小写敏感)。**对抗 review `wf_2ddae04d-cf9`(4 lens + verify)**:0 BLOCKER;真 MAJOR=**REST token-provider `equalsIgnoreCase`→`"dlf".equals`**(paimon 手抄 latent bug,legacy ParamRules 才权威)已修;FS `supports()` 改 `type==null||equalsIgnoreCase`(去 trim 不对称 + 对齐 legacy reject-on-malformed);trim/accessPublic-proxyMode divergence 经核证「对齐权威 legacy contract、仅偏离非权威 paimon 手抄」→不改;补 12 测(storage re-key/clobber-via-storage-channel/alias-first-wins/username-overlay/DLF-S3-reject/dispatch-instanceof…)。**API 旁改 2 javadoc**(`getDriverUrl`「raw,consumer-resolves」+ `needsStorage` FS 准确性,诚实订正,白名单内)。⚠️ **docker 未跑**(T2 真闸 P2-T05)。 +> - **决策补**:D-013(fe-kerberos 先建)|DV-006(kerberos compile-dep-only)|DV-007(storage 中立 Map,spi 不依赖 fe-filesystem-api)。 +> - **P2-T01 ✅(commit `44d1fec4dcb`)**:新建 `fe-connector-metastore-api`(`org.apache.doris.connector.metastore`)= `MetaStoreProperties`(`providerName()`+能力方法 `needsStorage()`/`needsVendedCredentials()` 默认 false+`validate()` no-op+`rawProperties()`/`matchedProperties()`,**无 `MetaStoreType` 枚举** D-006)+ 5 子接口 HMS/DLF/REST/JDBC/FileSystem(中立 Map/标量;`HmsMetaStoreProperties` 用 fe-kerberos `AuthType`+`Optional`)。**依赖仅 fe-kerberos**(D-013;fe-foundation/fe-filesystem-api api 纯接口未用→留 spi)。pom 镜像 fe-connector-api(copy-plugin-deps none);注册 fe-connector/pom.xml。**未建 Glue/S3Tables**(留扩展)。`MetaStorePropertiesContractTest` 3/0、checkstyle 0、import-gate exit 0、无 fe-core 禁包 import。 +> - **P3a-T01 facts-carrier ✅(commit `51df4fccd01`,D-013)**:新顶层叶子 `fe-kerberos`(**零生产依赖**)facts 切片 `AuthType`(SIMPLE/KERBEROS, `fromString` 仅 "kerberos" 命中余皆 SIMPLE) + `KerberosAuthSpec`(client principal+keytab 不可变值对象, `hasCredentials()` 需两者非空;HMS service principal 不在此=HiveConf override)。6 测绿、checkstyle 0。**authenticator 机制子集(hadoop 依赖 + trino KerberosTicketUtils→JDK)= 待 P2-T02 增量补**。 +> - **决策**:D-012(跳过/推迟 P1-T06 docker,验证折进 P2-T05)|D-013(kerberos facts 归 fe-kerberos、先建;metastore-api 依赖 fe-kerberos)。 +> - ⚠️ **docker e2e 全程未跑**(留 P2-T05)。 + +

    更早本 session(FU-T02 + FU-T03,已完成) + +## 这次 session 完成了什么(FU-T02 + FU-T03) + +**FU-T02 ✅(R-008 闭环,commit `e5b088b14e7`)** — fe-filesystem typed OSS/COS/OBS BE map 补 `AWS_CREDENTIALS_PROVIDER_TYPE`: +- 在 `Oss/Cos/ObsFileSystemProperties.toBackendKv()` 末尾**内联**镜像 legacy `AbstractS3CompatibleProperties.doBuildS3Configuration`(storage 包 :117-120):`StringUtils.isBlank(accessKey) && StringUtils.isBlank(secretKey)` → `kv.put("AWS_CREDENTIALS_PROVIDER_TYPE", "ANONYMOUS")`,否则省略。仅 BE map,不碰 `toHadoopConfigurationMap`(legacy 该键只进 `getBackendConfigProperties`)。 +- **DV-005(偏差,已记)**:原 D-011 说「加 `credentialsProviderType` 字段镜像 S3」——recon 证伪:legacy OSS/COS/OBS **不** override `getAwsCredentialsProviderTypeForBackend()`(只 `S3Properties` override 恒非空),即**无可配置 provider type**;加字段会引入 legacy 没有的旋钮 + 可能对有凭据 catalog 误发 `DEFAULT`(D-011 验收明确「非无条件 DEFAULT」);且 `S3CredentialsProviderType` 在 `fe-filesystem-s3`、`fe-filesystem-{oss,cos,obs}` 不依赖 s3 → 复用须扩白名单。故改内联条件(更简、更贴 legacy,符合用户本轮「处理逻辑一致」指令;无字段/枚举/跨模块依赖/白名单扩展/AskUserQuestion)。 +- **TDD**:3 个 `toBackendProperties_emitsAnonymousProviderTypeWhenNoStaticCredentials`(RED `expected but was ` → GREEN)+ 3 个有凭据测试加 `assertNull(AWS_CREDENTIALS_PROVIDER_TYPE)` 守「有凭据时省略」。OSS 13/0·COS 12/0·OBS 12/0 + 全模块绿、checkstyle 0。 + +**FU-T03 ✅(R-006 闭环,本次 commit)** — fe-filesystem 调优默认 UT 守护(纯 test-only,不动 main): +- `S3/Oss/Cos/ObsFileSystemPropertiesTest` 各加 1 个 `toMaps_emit*TuningDefaultsWhenNotConfigured`:不显式设调优键时断 **BE map**(`AWS_MAX_CONNECTIONS`/`AWS_REQUEST_TIMEOUT_MS`/`AWS_CONNECTION_TIMEOUT_MS`)+ **Hadoop map**(`fs.s3a.connection.maximum`/`...request.timeout`/`...timeout`)= S3 `50/3000/1000`、OSS/COS/OBS `100/10000/10000`。 +- **关键**:期望值用**字面量**非 `DEFAULT_*` 常量(否则改常量两侧同步=测试恒绿,守不住)。已核 legacy parity:`S3Properties.Env`(50/3000/1000)、`OSS/COS/OBSProperties`(各 100/10000/10000)。 +- **mutation 证**:sed 改 4 个 `DEFAULT_MAX_CONNECTIONS` → 4 测全红(`<50> but was <99>` / `<100> but was <999>`),revert 后全绿。S3 15/0·OSS 14/0·COS 13/0·OBS 13/0 + 全 sibling suite 绿、checkstyle 0×4。 + +**红线/守门**:`git diff --name-only` 全程仅落 `fe-filesystem-{oss,cos,obs}/{main,test}`(FU-T02)+ 4 个 `*PropertiesTest.java`(FU-T03)+ 本跟踪目录;mutation 用的 main 改动经 `git checkout` 还原(post-revert status 仅余 test 文件)。⚠️ **docker e2e 未跑**(本 session 仅 compile + UT + mutation)。 + +
    上一个 session(FU-T01,已完成) + +**FU-T01 ✅(D-010 授权,提升为 active)**:给 `fe-filesystem-hdfs` 新建 **HDFS typed BE model**,修复 P1-T04 全量切 typed BE 路引入的 HDFS BE 配置回归(**DV-004 / R-007 闭环**)。 + +**做了什么(仅 fe-filesystem-hdfs 核心 + 3 个已白名单文件的微改/注释)**: +1. **`HdfsFileSystemProperties.java`(新)**:`implements FileSystemProperties, BackendStorageProperties`(**BE-only,不实现 HadoopStorageProperties**——catalog/Hadoop 路保持 P1-T03 后的 raw passthrough,零新行为)。`toMap()` = **忠实移植 legacy `HdfsProperties.initBackendConfigProperties()`**(XML 资源 + `hadoop./dfs./fs./juicefs.` 透传 + 恒发 `ipc.client.fallback…`/`hdfs.security.authentication` + kerberos 块 + `hadoop.username`);`validate()` = kerberos required-check + `checkHaConfig`(inline 移植 `HdfsPropertiesUtils`)。`backendKind()=HDFS`、`type()=HDFS`、`kind()=HDFS_COMPATIBLE`。**移植源 = fe-property `HdfsProperties`(依赖轻 BE-key-only 孪生)→ parity by construction**。 +2. **`HdfsConfigFileLoader.java`(新)**:XML `hadoop.config.resources` 加载(移植 fe-property `PropertyConfigLoader`)。**F1 接线**:dir 经 `resolveHadoopConfigDir()` 读 sysprop `doris.hadoop.config.dir`(fe-core 设),默认 `$DORIS_HOME/plugins/hadoop_conf/`(与 `Config.hadoop_config_dir` 默认相同)。 +3. **`HdfsFileSystemProvider.java`(改)**:re-type 为 `FileSystemProvider` + 新增 `bind()`/`create(P)`;**`create(Map)`/`supports()` 字节不变**(hive/iceberg/broker FE filesystem 路零回归——既有 `DFSFileSystemTest` 25/0 证)。 +4. **`pom.xml`(改)**:+`fe-foundation`+`commons-lang3`(镜像 sibling s3;packaging 经 review 证无跨 loader 风险)。 +5. **F1 接线(用户选「现在接好」)**:fe-core `FileSystemFactory.bindAllStorageProperties`(**项目 P1-T02 加的方法**,+1 行 `System.setProperty("doris.hadoop.config.dir", Config.hadoop_config_dir)`)→ leaf 读 sysprop → 非默认 `hadoop_config_dir` 安装也对齐 legacy。 +6. **stale 注释修**(本改动作废):`FileSystemPluginManager.bindAll` javadoc 去 HDFS skip-list(项目 P0-T02 加的方法)、paimon `PaimonScanPlanProvider` `KNOWN GAP 1`→标 CLOSED。 +7. **kerberos = K1**(用户 AskUserQuestion 选):BE-key 字符串内联发射,**不建 fe-kerberos**、**不碰** fe-filesystem-hdfs 现有 create()-side `KerberosHadoopAuthenticator`。recon 证 BE model 仅需字符串、不需 fe-kerberos(真 `UGI.doAs` 留 fe-core/ctx + 现有 DFSFileSystem,§5 不变量 4)。 + +**TDD/验证**:25 golden parity UT 钉 `toMap()`==legacy BE 键集(simple/kerberos/kerberos-via-Doris-alias/HA+3 负例/username/uri-derive/viewfs-jfs derive vs ofs-oss no-derive/allowFallback-blank/multi-uri/malformed-uri-fail-loud/XML/sysprop)。**fe-filesystem-hdfs 全模块 78/0/0** + checkstyle 0 + **RED/GREEN 经 mutation 证**(关 kerberos 块→`kerberosViaDorisAlias` 红)+ **fe-core `-pl fe-core -am compile` 绿**(验 FileSystemFactory/PluginManager 改)+ `git diff` 白名单干净。 + +**对抗 review(`wf_5db99e32-2ad`,27 agent,4 lens + verify)**:清场——packaging 无跨 loader 风险、独立 agent 逐键复核 byte-level parity、BE-only 无新 catalog 路回归、强 oss-hdfs-wrong-keys 断言被 verify **推翻**、`new Configuration()` 默认 bloat 是 legacy-faithful。**3 实质修**:①malformed-`uri` swallow→**fail-loud**(对齐 legacy);②2 stale 注释;③+11 测试。**F1**(config-dir 未接 `Config.hadoop_config_dir`)→ 用户选「现在接好」=sysprop 桥。 +
    +
    + +## 当前状态 —— ✅ 子线收官 +- 阶段:Research ✅ / Design ✅(**17 决策 D-001..D-017 + 10 偏差 DV-001..DV-010**)/ **Implement ✅ 全完成**(P1 storage 6/7[P1-T06 折进 P2-T05];P2: 5/5 ✅;P3a facts-carrier ✅;P3b-T01 ✅ 已合入主线 `#64655`/`e5959e1b53d`)/ **docker 真闸 ✅ 全过**。 +- 任务计数 **15/15**(核心全完成;P0: 2/2 ✅ | P1: 6/7 | **P2: 5/5 ✅** | P3a: ✅ facts|**P3b: ✅**)| follow-up FU-T01/02/03 ✅。 +- ✅ docker:paimon 路 P2-T05 用户手动验证;**P3b docker kerberos e2e(HDFS kerberized + HMS)用户已跑,`doAs` 不回归**——子线真闸全部通过。 +- **新增 3 模块**:顶层叶子 `fe-kerberos`(facts 切片 + 收口后的 kerberos authenticator 机制)+ `fe-connector-metastore-api`(5 子接口)+ `fe-connector-metastore-spi`(5 解析器 + Provider SPI,22 文件)。**paimon 连接器已 cutover 到共享 spi**(P2-T03)。**fe-property 已物理删除**(P1-T07 ✅,0 消费者孤儿移除;fe-core `datasource.property.{storage,metastore}` 两包仍在、仍服务 hive/hudi/iceberg)。**R-006/R-007/R-008 已闭环**(UT/mutation 层)。 +- ✅ **e2e/docker 已跑**(paimon 5-flavor + vended REST/DLF + Kerberos HMS via P2-T05;HDFS kerberized + HMS via P3b)。 + +## 下一步(明确):**子线收官 → 主线 P6 iceberg** +> **本子线核心 15/15 + docker 真闸全过 = 完成。无遗留 gate。后续工作回到主线 [`../HANDOFF.md`]。** + +**✅ P3b-T01 全完成并合入主线**(已 squash 为 PR 提交 **`#64655` / `e5959e1b53d`**,已 push `upstream-apache/branch-catalog-spi`;granular `4a740e1387f`/`8898e15134c`/`5e3e8963023` 已被 squash 不再是 HEAD 祖先)。三处 kerberos 实现已合一到 `fe-kerberos` 单一真相源:fe-common `security.authentication` 包整体移除、fe-filesystem-hdfs 自有 `KerberosHadoopAuthenticator`/`SimpleHadoopAuthenticator` 副本删除、fe-filesystem-spi 的第二个 `HadoopAuthenticator`(IOCallable)+`IOCallable` 删除、两个打架的接口统一为 fe-kerberos 单接口、trino `KerberosTicketUtils`→JDK。`fe-kerberos` 仍是顶层中立叶子(no-cycle CONFIRMED)。详 [`tasks.md`](./tasks.md) P3b-T01 块 + DV-009/DV-010。 + +**✅ docker kerberos e2e 已跑(用户)**:HDFS kerberized(DFSFileSystem 经 fe-kerberos `HadoopKerberosAuthenticator` 登录读写)+ HMS kerberos(`enablePaimonTest=true` 覆盖 paimon-HMS-kerberos),`doAs` 不回归。**接受的行为变更已确认**:simple/无 `hadoop.username` 的 HDFS catalog 现以 remote user "hadoop" 跑(HDFS 权限不破)。如未来发现回归 → 记 `deviations-log` 或回退 DV-010 的 pure-consolidation 选择。 + +**✅ 已收口**:RV-T01(主线全连接器 clean-room review)+ B8(主线 P5-T29 paimon legacy 删除 `#64653`)均在**主线**完成;P2-T04 ✅ + P2-T05 ✅(用户 docker 验证)+ P3b ✅。 + +**🟢 主线下一步 = P6 iceberg**:可直接复用收口后干净的 `fe-kerberos` authenticator。设计 §3.5 / **D-007** / **D-017**。**本子线唯一后续 = 与 P6/P7 协同评估「把 paimon/iceberg/hive metastore-props 搬出 fe-core」**(非阻塞;主线 backlog 已记 paimon 侧三条 live 路径 = 现不可单删的原因)。 + +## 未决 / 需注意 +- ✅ 已闭环:R-006(FU-T03)、R-007(FU-T01)、R-008(FU-T02)。 +- 📌 **残留已知(非本批引入,独立 FU)**:**oss-hdfs**(`oss://` warehouse + JindoFS)在 typed 路缺 oss 凭据键——P1-T04 已起(HDFS-family typed 缺口),彻底修需 fe-filesystem **OssHdfs typed model**(独立大动作,超白名单)。FU-T01 让 HDFS provider 对 bare-`oss://` fs.defaultFS 发无凭据 HDFS 键(review F3 MINOR,latent 误配曝露,非 working catalog 回归)。 +- 📌 **scan-time 重 validate**:`getStorageProperties()` 每次 scan 经 `bindAll`→`bind()`→`of().validate()`(无 memoization)——valid catalog 内禀 dormant;是 typed-路通性(P1-T02/D-009),非 FU-T01 专有。 +- ⚠️ e2e 全程未跑;P1-T06 前如不部署 docker,明确标「未跑 e2e」(CLAUDE.md Rule 12)。 + +## 红线提醒(WORKFLOW §4) +- **可动**(白名单):`fe-connector-metastore-api/**` + **`fe-connector-metastore-spi/**`(新建)** + `fe-kerberos/**`(新建叶子)、`fe-connector-paimon/**`、`fe-connector-spi/**`、fe-core **仅** `connector/DefaultConnectorContext.java` + `fs/FileSystemPluginManager.java` + `fs/FileSystemFactory.java`(均**仅新增方法 / 对本项目所加方法的微改+注释**)、**`fe-filesystem/fe-filesystem-hdfs/**`(D-010,FU-T01)**、**`fe-filesystem/fe-filesystem-{s3,oss,cos,obs}/**`(D-011,FU-T02/FU-T03;main+test)**、相关 pom(`fe-connector/pom.xml`/`fe/pom.xml` 仅新增模块声明)、本跟踪目录。 +- **P2-T02 额外触碰**(透明,白名单内):`fe-connector-metastore-api` 的 `MetaStoreProperties.java`/`JdbcMetaStoreProperties.java` 各 1 处 javadoc 诚实订正(`needsStorage` FS 准确性 + `getDriverUrl` raw 语义)——非改契约方法签名。 +- **P2-T03 触碰**(透明,白名单内):`fe-connector-paimon/**` 5 main(`PaimonConnectorProvider`/`PaimonConnector`/`PaimonCatalogFactory`/`PaimonConnectorProperties`/`PaimonScanPlanProvider`)+ 2 test + `fe-connector-paimon/pom.xml`(加 `fe-connector-metastore-spi` 依赖,属 `fe-connector-paimon/**`)。**fe-core 旧 `Paimon*MetaStoreProperties` 不动;metastore-spi/api 未改**(只新增消费方)。 +- **P1-T07 触碰**(透明,白名单内):删除 `fe/fe-property/**`(D-016 授权)+ `fe/pom.xml`(删 `` + dependencyManagement 条目)+ 5 处 stale 注释「fe-property」→「legacy」(paimon `PaimonCatalogFactory`/`PaimonConnector`/`PaimonCatalogFactoryTest` + fe-filesystem-hdfs `HdfsConfigFileLoader`/`HdfsFileSystemProperties`,保历史语义非改逻辑)。**fe-core `datasource.property.{storage,metastore}` 两包不碰。** +- **禁碰**:fe-core `datasource.property.{storage,metastore}` 包、构造点 `PluginDrivenExternalCatalog`、其它连接器(hive/hudi/iceberg/es/jdbc/mc/trino)、**其它 fe-filesystem 模块**(`-{api,spi,azure,broker,local}`,含其 test——R-008 若须给 api/spi 加共享 credentials-provider-type 须先 AskUserQuestion)、`fe-property` 模块删除。 +- **FU-T01 额外触碰**(已记 D-010 + tasks,透明):fe-core `FileSystemFactory.java`(F1 +1 行 setProperty,项目 P1-T02 加的方法)、`FileSystemPluginManager.java`(bindAll javadoc,项目 P0-T02 加的方法)、fe-connector-paimon `PaimonScanPlanProvider.java`(注释)——均 project-owned 微改/注释,非碰 pre-existing fe-core 方法。 +- paimon 连接器 + fe-filesystem-hdfs **允许** import `org.apache.doris.foundation.*`(fe-foundation 叶子)、`org.apache.doris.filesystem.*`;**禁** import fe-core/fe-connector(fe-filesystem 侧 gate)。 +- 每次提交前 `git diff --name-only` 对照白名单。 + +## 关键链接 +- 设计:[`../designs/metastore-storage-property-refactor-design-2026-06-17.md`](../designs/metastore-storage-property-refactor-design-2026-06-17.md) +- 流程:[`WORKFLOW.md`](./WORKFLOW.md) | 任务:[`tasks.md`](./tasks.md) | 决策:[`decisions-log.md`](./decisions-log.md) | 偏差:[`deviations-log.md`](./deviations-log.md) | 风险:[`risks.md`](./risks.md) +- 对抗 review(FU-T01):workflow `wf_5db99e32-2ad`(27 agent,4 lens + verify;3 实质修 + F1 接线)|recon:`wf_de5f54be-668`(4-agent:legacy parity / fe-filesystem-hdfs / api+s3 / kerberos) +- **P2-T02**:recon `wf_187e052d-230`(4 reader + synth;证 DV-006/007)|对抗 review `wf_2ddae04d-cf9`(4 lens + verify;REST case-sens MAJOR 修 + 12 测补 + hive.conf.resources/doAs-契约 P2-T03 follow-up) diff --git a/plan-doc/metastore-storage-refactor/PROGRESS.md b/plan-doc/metastore-storage-refactor/PROGRESS.md new file mode 100644 index 00000000000000..31e5a9e8153792 --- /dev/null +++ b/plan-doc/metastore-storage-refactor/PROGRESS.md @@ -0,0 +1,86 @@ +> # 🔒 本子线已彻底 CLOSED(2026-06-22 收官,用户确认) +> +> **「属性体系重构」子项目(Storage→fe-filesystem / MetaStore→fe-connector SPI,paimon 优先)已全部完成并合入主线** —— 核心任务 15/15 + docker 真闸全过;产出 `fe-kerberos` / `fe-connector-metastore-api` / `fe-connector-metastore-spi`(含 `MetaStoreProviders.bind` + 5 provider)+ 删除 `fe-property` 孤儿模块;paimon 连接器已 cutover 到共享 SPI。合入提交:`#64446`(paimon SPI+翻闸)/ `#64653`(P5-T29 删 legacy)/ `#64655`(P3b kerberos 收口 `e5959e1b53d`)。 +> +> **⛔ 后续任务(含主线 P6/P7)请勿再阅读本目录的规划/接力文档** —— 它们是已结束工作的历史留存,不再维护。需了解 metastore-spi / `MetaStoreProviders.bind` 现状请**直接读代码**:`fe/fe-connector/fe-connector-metastore-spi/`。主线接力见 [`../HANDOFF.md`](../HANDOFF.md)。 + +--- + +# PROGRESS — 属性体系重构(paimon 优先) + +> 人类 + agent 入口。每完成 task / 阶段切换 / 重要变更后更新。上次更新:**2026-06-17**。 + +--- + +## 总体状态 + +| 阶段 | 进度 | 状态 | +|---|---|---| +| Research(调研) | ██████████ 100% | ✅ 完成(8-agent + grep;+ 3-agent recon 复核 D-006/7/8) | +| Design(设计) | ██████████ 100% | ✅ 完成(设计文档 + **7 决策** D-001..D-008,范围已收窄) | +| **Implement(实现)** | ██████████ ~99% | ✅ **核心全完成**(P0 ✅;P1 6/7[P1-T06 折进 P2-T05];**P2 5/5 ✅**;P3a facts ✅;**P3b-T01 ✅ commit 1/2/3 全完成**[D-017]);**仅剩 docker kerberos e2e 真闸待跑** | + +任务计数:**15 / 15** 核心完成(P0: 2/2 ✅ | P1: 6/7[P1-T06 折进 P2-T05] | **P2: 5/5 ✅** | P3a: ✅ facts | **P3b: ✅**)| + FU-T01/02/03 ✅。**下一步 = docker kerberos e2e(HDFS kerberized + HMS)唯一未跑的真闸 → 然后 P6 iceberg**(见 [`tasks.md`](./tasks.md) P3b-T01 + [`HANDOFF.md`](./HANDOFF.md)「下一步」)。 + +--- + +## 当前活跃 task +- **✅ P3b-T01 全完成(D-017,先于 P6 iceberg)= Phased + Repackage 到 `org.apache.doris.kerberos.*`(用户 2026-06-21 AskUserQuestion 定)**。三步 commit 全完成(`4a740e1387f`/`8898e15134c`/`5e3e8963023`,均未 push)。**commit 3 ✅(`5e3e8963023`)统一双 HadoopAuthenticator 接口到 fe-kerberos 单接口 + 删 fe-filesystem-{hdfs 副本,spi IOCallable 变体};4 消费方重指向;用户定 pure consolidation(DV-010);对抗 review `wf_b1a4e7e4-b51` 3 MINOR 全修(empty-string `hadoop.username` regression + 补测 + scrub stale 文档);fe-filesystem-hdfs 79/0/0 + checkstyle 0 + import-gate 0 + grep 净。⚠️ docker kerberos e2e(HDFS kerberized + HMS)= 唯一未跑的真闸。** 下方为已完成历史(最新在前)。三步 commit:**commit 1 ✅ trino→JDK 原地替换**(fe-common 新 `KerberosTicketUtils` JDK 副本 + `HadoopKerberosAuthenticator` 改 import;4/0 + mutation 证 + checkstyle 0 + BUILD SUCCESS;fe-common pom 不动;DV-009 重排 trino 先做避免 fe-kerberos 沾 trino)→ **commit 2 ✅ relocate(`8898e15134c`,未 push)**:13 类(含 commit 1 的 `KerberosTicketUtils`)`git mv` 到 `org.apache.doris.kerberos.*` + 2 测同搬 + 重指向 **41 消费方 import**(27 main[24 fe-core+3 be-scanner]+14 test)+ 合并 AuthType(drop dead mutable/code API + 加 `isSupportedAuthType`)+ fe-kerberos pom 加 hadoop-common(provided)/guava/commons-lang3/lombok/log4j-api + fe-common→fe-kerberos + java-common→fe-kerberos 边;fe-kerberos `11/0` + fe-core/scanner test-compile BUILD SUCCESS + checkstyle 0 + import-gate 0 + grep 旧包=0;recon `wf_d5566c5f-7b1` 对抗验 no-cycle CONFIRMED → **commit 3 ⬜ 统一双 HadoopAuthenticator 接口 + 删 hdfs 副本(下一步)**。recon 修正真 retarget 面 = **27 main**(非 40;旧「12 fe-common」是被搬的类自身)+ 真搬 **13 类**(非 12,含 KerberosTicketUtils)。真闸=docker kerberos e2e(未跑)。详 [`tasks.md`](./tasks.md) P3b-T01。下方为已完成历史(最新在前)。 +- **P2-T04 ✅ + P2-T05 ✅(2026-06-21,用户手动 docker 验证)→ P2 全 5/5**:P2-T04=`MetaStoreProviders` 2-arg loader `2612af5e88f` + pom/import-gate;P2-T05=paimon 5-flavor 读 + vended REST/DLF + Kerberos HMS(`enablePaimonTest=true`)docker 通过(亦覆盖主线 B9/P5-T30 live-e2e)。主线 RV-T01 + P5-T29(B8) 亦已完成。 +- **P1-T07 ✅ 完成(2026-06-18,commit `13d3876d25d`,已 push `catalog-spi-07-paimon`+`master-catalog-spi-07-paimon` + PR #64445 评论 `run buildall`,D-016)**:彻底删除 fe-property 孤儿模块(超 D-005「不删 fe-property」条款;fe-core `datasource.property.{storage,metastore}` 两包仍禁碰、仍服务 hive/hudi/iceberg)。删 `fe/fe-property/`(27 文件 + stale `target/`→目录全消)+ `fe/pom.xml` 两声明(`` + dependencyManagement 条目)+ 清 5 处 stale 注释(一并清理,用户 AskUserQuestion 选;paimon×3 + hdfs×2,「fe-property」→「legacy」保历史语义)。**RED/GREEN=构建闸**:whole-repo `grep fe-property`(排 plan-doc)/`grep org.apache.doris.property` 双归零;**全 FE reactor `test-compile` BUILD SUCCESS**(`-Dmaven.build.cache.enabled=false`,fe-core `compile`+`testCompile` 实跑,54 模块 0 ERROR)+ paimon 全模块 **278/0/1skip** + fe-filesystem-hdfs **78/0/0** + checkstyle 0 + import-gate exit 0 + 白名单干净。⚠️ docker e2e 未跑(D-012)。 +- **下一步 = 主线全连接器 clean-room review(已提升到主线)**:用户 2026-06-18 定的 paimon connector 全功能路径 6 维度(读取/写入/DDL/元数据回放/元数据 cache/残留旧逻辑·fallback)clean-room 对抗 review(**⚠️ 不注入开发历史先验**)审的是整条 connector = catalog-spi **主线**范围,归 [`../HANDOFF.md`](../HANDOFF.md)「下一个 session 的任务」,本子目录不复述 spec。该 review **先于 B8**(legacy = 对照基线)。**本子线自身剩余 = P2-T04**(pom+gate,⚠️ `MetaStoreProviders` ServiceLoader 改 2-arg 显式 loader)→ **P2-T05** docker 真闸,排在主线 review 之后。 +- **P2-T03 ✅ 完成(2026-06-18,commit `3c1e118dcfa`)**:paimon adapter cutover 到共享 metastore-spi(详见最近动态)。 +- **FU-T02 ✅ + FU-T03 ✅ 完成(2026-06-18,D-011 授权)**:P1-T06 前的两项 fe-filesystem 对象存储补齐均完成(**R-008 + R-006 闭环**)。 + - **FU-T02(R-008,commit `e5b088b14e7`)**:`Oss/Cos/ObsFileSystemProperties.toBackendKv()` 内联镜像 legacy `AbstractS3CompatibleProperties.getAwsCredentialsProviderTypeForBackend()`——ak/sk 皆空→`AWS_CREDENTIALS_PROVIDER_TYPE=ANONYMOUS`、否则省略。**DV-005**:不加字段/枚举(legacy OSS/COS/OBS 本无可配置 provider type,且 `S3CredentialsProviderType` 在 s3 模块、oss/cos/obs 不依赖)。TDD RED(`expected but was `)→GREEN;OSS 13/0·COS 12/0·OBS 12/0 + 全模块绿、checkstyle 0。 + - **FU-T03(R-006,本次 commit)**:4 个 `*FileSystemPropertiesTest` 各加 1 个调优默认守护测试(BE map + Hadoop map,字面量期望值非常量);S3 50/3000/1000、OSS/COS/OBS 100/10000/10000(已核 legacy parity);mutation 改 4 个 `DEFAULT_MAX_CONNECTIONS`→ 4 测全红证有效。S3 15/0·OSS 14/0·COS 13/0·OBS 13/0 + sibling 绿、checkstyle 0。纯 test-only。 + - ⚠️ docker e2e 未跑(两者真闸均在 P1-T06)。 +- **FU-T01 ✅(2026-06-17,D-010,commit `a426648f209`)**:`fe-filesystem-hdfs` HDFS typed BE model(**R-007 闭环**)。78/0 + 对抗 review `wf_5db99e32-2ad` 清场。 +- **P3a-T01 facts-carrier ✅ + P2-T01 ✅ 完成(2026-06-18,进入 P2)**(用户 D-012 跳过/推迟 P1-T06 docker → P2-T05 合并跑): + - **P3a-T01 facts-carrier(commit `51df4fccd01`,D-013)**:新顶层叶子 `fe-kerberos` 的零依赖 facts 切片 `AuthType`(SIMPLE/KERBEROS+fromString) + `KerberosAuthSpec`(principal/keytab 值对象);AuthTypeTest 3/0 + KerberosAuthSpecTest 3/0、checkstyle 0。authenticator 机制(hadoop)待 P2-T02 增量补。 + - **P2-T01(本次 commit)**:新模块 `fe-connector-metastore-api`(`org.apache.doris.connector.metastore`)= `MetaStoreProperties`(providerName + 能力方法默认 false + raw/matched,无枚举 D-006)+ HMS/DLF/REST/JDBC/FileSystem 5 子接口(中立;HMS 用 fe-kerberos facts);依赖 fe-kerberos(D-013);契约测试 3/0、checkstyle 0、import-gate exit 0。未建 Glue/S3Tables(留扩展)。 +- **下一步 = `P2-T02`(新建 fe-connector-metastore-spi)**:5 个 `*MetastoreBackend.parse(raw, storageList)` + `MetaStoreProvider

    ` SPI(`supports()` 自识别)+ 5 内置 provider + `META-INF/services` + `MetaStoreProviders.bind` 派发(D-006,镜像 FileSystemProvider/FileSystemPluginManager)+ `@ConnectorProperty` typed holder;**来源 = 上移 paimon `PaimonCatalogFactory` 手抄逻辑去 fe-core 化**;**此处增量补 fe-kerberos authenticator 机制子集**(hadoop 依赖 + trino KerberosTicketUtils→JDK,P3a-T01 续)。设计 §3.2 / T2 等价。⚠️ docker 全程未跑(留 P2-T05)。 +- P0-T01 ✅|P0-T02 ✅(bindAll)|P1-T01 ✅(getStorageProperties 默认方法 + 边)|P1-T02 ✅(getStorageProperties 实现 + FileSystemFactory accessor)|P1-T03 ✅(paimon storage 配置 `applyStorageConfig` 改走 `toHadoopConfigurationMap()`)|P1-T04 ✅(paimon BE 静态凭据改走 `getStorageProperties().toBackendProperties().toMap()`,全量切)|**P1-T05 ✅**(删 paimon→fe-property pom 依赖边 + grep 归零闸)。 +- ✅ **连接器 storage + BE 凭据路全切 fe-filesystem-api typed,且 paimon→fe-property 依赖边已断**:catalog 路 `PaimonConnector.buildStorageHadoopConfig()→toHadoopConfigurationMap()`;BE 扫描分片路 `PaimonScanPlanProvider` 遍历 `getStorageProperties()→toBackendProperties().toMap()`→`location.*`(vended overlays static 保序不动)。paimon 已零 `org.apache.doris.property/datasource` import + pom 无 fe-property 依赖(fe-property 变 0 消费者孤儿,本次不物理删 D-005)。 +- ⚠️ **已知接受回归(fe-filesystem typed BE model 不全,超 P1 白名单)**:HDFS-warehouse paimon BE 配置丢(DV-004/R-007/FU-T01);无凭据 OSS/COS/OBS 缺 `AWS_CREDENTIALS_PROVIDER_TYPE=ANONYMOUS`(R-008/FU-T02)。均用户接受、follow-up 修、docker P1-T06 会暴露(非新 bug)。 +- **P2-T02 ✅(2026-06-18,commit `7ea63528bc4`)**:新建 `fe-connector-metastore-spi`(22 文件)= 5 后端 `*MetaStorePropertiesImpl`(`@ConnectorProperty` 绑定)+ `MetaStoreProvider` SPI/ServiceLoader first-hit 派发 + `MetaStoreParseUtils`/`JdbcDriverSupport`/`AbstractMetaStoreProperties`;**DV-006**(fe-kerberos 零新代码,facts-only)+ **DV-007**(storage = 中立 Map,模块 hadoop/fs-free)。spi 41/0、checkstyle 0、import-gate 0、3 mutation 证、对抗 review `wf_2ddae04d-cf9`(0 BLOCKER,REST case-sens MAJOR 已修,+12 测)。⚠️ docker 未跑。 +- ▶ **下一步**:**P2-T03**(paimon `PaimonCatalogFactory` adapter 改走共享 `MetaStoreProviders.bind`,删手抄连接逻辑;**必接 review 揪出的 hive.conf.resources base + kerberos() doAs 消费契约 + driver 注册下移**,见 tasks P2-T02 块)。**P1-T06 推迟**(D-012,docker 折进 P2-T05)。 + +## 阻塞 / 待决 +- ✅ 范围已获批(2026-06-17)= **P0+P1(storage 收口),做到 P1-T06 gate 停**。 +- ✅ **DV-001/D-009(2026-06-17)**:P0-T01 recon 证伪「fe-filesystem-api 已够、唯一 fe-core 改动」——产出 fe-filesystem typed StorageProperties 须新增 bind-all(仓内不存在)。用户定 **机制 A**:fe-core `FileSystemPluginManager` 加 additive `bindAll`,`getStorageProperties()` 经 `getOrigProps()` 取 raw map、不碰构造点。**fe-core 改动 = 2 文件**(DefaultConnectorContext + FileSystemPluginManager,均纯新增),白名单已 +1。 +- ⚠️ **R-001 等价性**:fe-filesystem 为新事实源,较 fe-property 略**超集**(S3 role/anon;OSS/COS/OBS endpoint 无条件);T1 须钉常见路径全等 + 记超集差异。 + +--- + +## 最近动态(最近 7 天) +- 2026-06-21 **P3b-T01 commit 2 ✅(relocate 13 类到 fe-kerberos,commit `8898e15134c`,未 push)**:按强制流程读全套文档 + 独立 recon workflow `wf_d5566c5f-7b1`(6 reader + 2 对抗 verifier)对照真实代码核实——**no-cycle CONFIRMED**(13 类零 doris-非kerberos import→干净叶子无环)、AuthType-merge verify「REFUTED」实为 pedantic(mutable `getCode/setCode/setDesc` 实证 0 caller→drop;唯一 `isSupportedAuthType` caller=`HiveTable` 纯 import retarget)。**修正文档计数**:真 import-retarget 面 = **27 main(24 fe-core + 3 be-java-extensions scanner,0 外部 fe-common 消费方)+ 14 test**;真搬 **13 类**(含 commit 1 新建 `KerberosTicketUtils`,非「12」)。**做法**:`git mv` 13 类 + 2 测(`AuthenticationTest`/`KerberosTicketUtilsTest`)到 `org.apache.doris.kerberos`(R94-98% + sed package)+ 重指向 41 消费方 import(纯 import 行;`datasource.property.{storage,metastore}` 禁包按 D-017 import-only 例外)+ 合并 AuthType(drop dead mutable/code API、加 `isSupportedAuthType`、保 `getDesc/fromString`、删 fe-common 版)+ fe-kerberos pom 加 `hadoop-common(provided)`+guava+commons-lang3+lombok+log4j-api(**实证只需 hadoop-common 非 hadoop-auth**;叶子不变量保持)+ fe-common→fe-kerberos(compile) + java-common→fe-kerberos(compile) 边。**验证**:fe-kerberos `11/0`;fe-core+java-common+3 scanner `test-compile` BUILD SUCCESS + checkstyle 0(顺修 in-place sed 引入的 `CustomImportOrder`:按 FQN-无分号 key 重排 doris import 块,含 `Metric` vs `Metric.MetricUnit` 前缀对);import-gate 0;whole-repo grep 旧包=0;fe-connector-paimon test-compile 失败=**已证 pre-existing HiveConf shade quirk**(`-am package -Dassembly.skipAssembly=true` 路 BUILD SUCCESS,paimon 不消费被搬类)。⚠️ 未 push、**docker kerberos e2e 未跑**(真闸,留 commit 3 后)。白名单微扩 `fe/fe-common/pom.xml`(D-017 已预定,WORKFLOW §4.1 补登)。**下一步 = commit 3(统一双 HadoopAuthenticator 接口 + 删 fe-filesystem-hdfs 副本)**。 +- 2026-06-21 **P3b-T01 commit 1 ✅(trino→JDK 原地替换)**:按强制流程读全套文档 + recon workflow `wf_c14cb816-ed9`(6 reader)对照真实代码核实 scope(确认 12 类/trino 1 处/hdfs 副本/3 be-java-extensions/fe-kerberos 状态;**修正**真 retarget 面 = 27 main 非 40,「12 fe-common」是被搬类自身按 package 行误计;两 `HadoopAuthenticator` 接口结构不兼容须 adapter)→ AskUserQuestion 定 **Phased + Repackage `org.apache.doris.kerberos.*`** → **DV-009 重排**(trino 先做避免 fe-kerberos 沾 trino)。**做法**:javap 反编译 trino `KerberosTicketUtils` 逐字节复刻为 JDK-only `org.apache.doris.common.security.authentication.KerberosTicketUtils`(`getRefreshTime`=`start+(long)((end-start)*0.8f)`、`getTicketGrantingTicket`=私有凭据里 server==`krbtgt/REALM@REALM` 否则 IAE、`isOriginalTicketGrantingTicket`),`HadoopKerberosAuthenticator` 删 `io.trino` import、同包调用点字节不变。**TDD**:`KerberosTicketUtilsTest` 4/0(refresh 80%、零寿命、TGT 选取、无 TGT 抛 IAE);mutation `0.8f→0.5f`→RED `expected:<9000> but was:<6000>`;checkstyle 0;fe-common `-am` BUILD SUCCESS。**fe-common pom 不动**(trinoconnector + 3 队列类仍用 trino-main)。⚠️ 未 push、docker kerberos e2e 未跑。下一步 = commit 2(relocate)。 +- 2026-06-21 **P2-T04 ✅ + P2-T05 ✅ → P2 全 5/5;D-017 定 P3b 先于 P6 iceberg**:用户手动 docker 验证 P2-T05(paimon 5-flavor 读 + vended REST/DLF + Kerberos HMS,`enablePaimonTest=true`)通过;P2-T04(`MetaStoreProviders` 2-arg loader `2612af5e88f` + pom/import-gate)收口。主线 P5-T29(B8) paimon legacy 删除亦完成(fe-core 完全 paimon-SDK-free)。**D-017:P3b(kerberos authenticator 机制收口到 fe-kerberos)提前到 P6 iceberg 之前单独做**——P3b-T01 由 `⬜ 范围外` 升为 `🚧 active`,补现码 scope(12 类机制 + 40 消费方 blast radius[含 3 be-java-extensions] + 双 `HadoopAuthenticator` 接口统一 + trino`KerberosTicketUtils`→JDK;真闸 docker kerberos e2e)。**仅文档更新。** +- 2026-06-18 **RV-T01(全连接器 clean-room review)提升到主线**:用户明确 `metastore-storage-refactor/` 是 metastore-refactor 专属子目录,全连接器 review 属 catalog-spi **主线**→ RV-T01 spec 移到主线 [`../HANDOFF.md`](../HANDOFF.md)(6 维度 + **不注入开发历史先验**),先于 B8 legacy 删除(legacy=对照基线)。本子目录只留指针;本子线自身剩余 = P2-T04/T05(主线 review 后)。**仅文档更新。** +- 2026-06-18 **RV-T01 初排(已被上一条提升到主线取代)**:原把 paimon connector 全功能路径 clean-room 对抗 review(6 维度,不注入先验)排为本子线下一步——后经用户澄清移到主线(见上)。 +- 2026-06-18 **P1-T07 ✅(彻底删除 fe-property 孤儿模块,commit `13d3876d25d`,已 push `catalog-spi-07-paimon`+`master-catalog-spi-07-paimon` + PR #64445 评论 `run buildall`,D-016)**:执行 session 先按强制流程复核(读 PROGRESS/HANDOFF/WORKFLOW/tasks/decisions + 对照真实代码 recon)+ 1 边界经 AskUserQuestion 定(5 处 stale 注释「一并清理」)。**改动**(白名单内):`git rm -r fe/fe-property/`(27 文件 = 26 java + pom)+ `rm` stale `target/`(目录全消)+ `fe/pom.xml` 删 `fe-property` + dependencyManagement 条目 + 5 处注释「fe-property」→「legacy」(paimon `PaimonCatalogFactory`×2/`PaimonConnector`×2/`PaimonCatalogFactoryTest`×1 + fe-filesystem-hdfs `HdfsConfigFileLoader`/`HdfsFileSystemProperties`,保历史语义非改逻辑)。**RED/GREEN=构建闸**(无 UT 可写,同 P1-T05):whole-repo `grep fe-property`(排 plan-doc)=0、`grep org.apache.doris.property`=0;**全 FE reactor `test-compile` BUILD SUCCESS**(`-Dmaven.build.cache.enabled=false`,fe-core `compile`+`testCompile` 实跑,54 模块 **0 ERROR**,1:53min)=证 module+dependencyManagement 删除无隐藏 transitive 消费者;paimon 全模块 **278/0/1skip**、fe-filesystem-hdfs **78/0/0**、checkstyle 0、`tools/check-connector-imports.sh` exit 0、`git diff --name-only` 白名单干净。**fe-property 物理删除完成(0 消费者孤儿移除);fe-core 两包不碰。** ⚠️ docker e2e 未跑(D-012,留 P2-T05)。**下一步 P2-T04**。 +- 2026-06-18 **D-016 + P1-T07 新增(用户定下一阶段=彻底删除 fe-property)**:用户授权物理删除已 0 消费者的 fe-property 孤儿模块,**超 D-005「不删 fe-property」条款**(fe-core `datasource.property.{storage,metastore}` 两包不变,仍服务 hive/hudi/iceberg)。whole-repo recon:仅 `fe/pom.xml`(module+depMgmt 真引用)+ 5 处 stale 注释、`org.apache.doris.property` import=0、无 BE/docker/脚本/regression 引用→删除限 `fe/`。新增 **P1-T07**(删目录+fe/pom.xml 两声明+可选清注释,RED/GREEN=构建闸),WORKFLOW §4.1 白名单把 `fe/fe-property/**` 移入允许删除区,HANDOFF「下一步」改为 P1-T07(先于 P2-T04/T05)。**仅文档更新,未删代码**(执行留下一 session)。 +- 2026-06-18 **P2-T03 ✅(paimon adapter cutover 到共享 metastore-spi,commit `3c1e118dcfa`)**:直接读真实代码全路 + 对抗 recon `wf_9437dd4e-06d`(6 reader+synth+verify=SOUND/READY,逐键 parity 通过)→ 2 边界经 AskUserQuestion 定:**D-014**(采用 spi legacy-faithful validate——CREATE CATALOG 比当前 paimon 更严:HMS forbidIf(simple)/requireIf(kerberos)、REST case-sensitive `"dlf".equals`、DLF 在 CREATE 要求 OSS;故意向 legacy 收敛)、**D-015**(JDBC 注册副作用留连接器,仅纯 `resolveDriverUrl` 共享,Rule 2 不投机)。**改 5 main+2 test+pom**(白名单内,净 +318/−847):`validateProperties`→`MetaStoreProviders.bind(props,{}).validate()`;`createCatalog` HMS/DLF→`bind`+新薄 `PaimonCatalogFactory.assembleHiveConf(base,overrides)`(HMS seed `loadHiveConfResources` base 再叠 `toHiveConfOverrides`,DLF `assembleHiveConf(null,toDlfCatalogConf())`)、删 build-time `requireOssStorageForDlf`;两处 driver-url→`JdbcDriverSupport.resolveDriverUrl`;`PaimonCatalogFactory` 删 6 法+`KNOWN_FLAVORS`+加 `assembleHiveConf`;`PaimonConnectorProperties` 删 `DLF_*`/`REST_TOKEN_PROVIDER`/`REST_DLF_*`(**DV-008**:别名数组只**部分**删,`HMS_URI`/`REST_URI`/`JDBC_*` 仍被 `buildCatalogOptions` 用故保留;`bind` 取代设计早期 `*MetastoreBackend.parse`;`assembleHiveConf` 为离线测 F2 而抽)。**TDD**:新 `PaimonConnectorValidatePropertiesTest` 13/0(3 tightening RED→GREEN 实证)+ 删 28 旧 builder/validate 测(content parity 已由 spi `Hms/DlfMetaStorePropertiesTest` 13+7 覆盖)+ 2 `assembleHiveConf` 测。**验证 paimon 全模块 278/0/1skip**、checkstyle 0、import-gate exit 0、白名单干净。**对抗 review `wf_dd78ec4b-da5`**(4 lens+verify=READY,0 真 finding;唯一 MAJOR「kerberos.principal alias 未测」证伪=该键走 verbatim passthrough→测它恒真 tautology 违 Rule 9,隔离 binding 的 `service.principal`→`kerberos.principal` 方向已被 spi line72/80 覆盖)。⚠️ **docker e2e 未跑**(HMS/DLF live metastore=hive + plugin-zip ServiceLoader 发现 5 provider 在子优先 loader=P2-T04/T05 真闸)。**下一步 P2-T04**。 +- 2026-06-18 **P2-T02 ✅(新建 fe-connector-metastore-spi,commit `7ea63528bc4`)**:recon workflow `wf_187e052d-230`(4 reader+synth,证两 deviation)+ 直接核实 → 3 边界经 AskUserQuestion 定(**DV-006** fe-kerberos compile-dep-only 零新代码、**DV-007** storage 中立 Map 模块 hadoop/fs-free、全 5 后端一次 commit)。建 22 文件:`MetaStoreProvider` SPI + `MetaStoreProviders` first-hit ServiceLoader 派发 + `MetaStoreParseUtils` + `JdbcDriverSupport.resolveDriverUrl`(纯 resolver;注册留 P2-T03)+ `AbstractMetaStoreProperties` + 5 `*Impl`(`@ConnectorProperty`,消灭手抄别名)+ 5 provider(`sensitivePropertyKeys` 暴露 sensitive 键)+ 单 services 文件。来源=上移 paimon `PaimonCatalogFactory` 手抄逻辑去 fe-core 化(HiveConf→中立 Map、authenticator→`KerberosAuthSpec` facts)。**HMS D-4 补回** forbidIf-simple/requireIf-kerberos(CASE-SENSITIVE `Objects.equals` 对齐 ParamRules,保留与 conf-build `equalsIgnoreCase` 的不对称)。验证 spi **41/0**、checkstyle 0、import-gate 0、**3 mutation 证**(RED→GREEN)。**对抗 review `wf_2ddae04d-cf9`(4 lens+verify)**:0 BLOCKER;1 真 MAJOR=**REST token-provider `equalsIgnoreCase`→`"dlf".equals`**(paimon 手抄 latent bug,legacy ParamRules 权威)已修;FS `supports()` 去 trim 不对称 + 对齐 legacy;DV-006/007/D-006/D-4 独立核实正确;trim/accessPublic-proxyMode 经核证对齐权威 legacy contract(不改);补 12 测覆盖缺口。**API 旁改 2 javadoc**(诚实订正,白名单内)。**下一步 P2-T03**(必接 hive.conf.resources base + kerberos() doAs 契约 + driver 注册下移)。⚠️ docker 未跑(T2 真闸 P2-T05)。 +- 2026-06-18 **进入 P2(metastore SPI):P3a-T01 facts-carrier ✅ + P2-T01 ✅**(D-012 跳过/推迟 P1-T06 docker;D-013 用户选 fe-kerberos 先建)。**P3a-T01 facts 切片**(commit `51df4fccd01`)新建顶层叶子 `fe-kerberos`(零依赖)= `AuthType`(SIMPLE/KERBEROS, fromString 仅 "kerberos" 命中) + `KerberosAuthSpec`(principal/keytab 不可变值对象, hasCredentials 需两者);6 测绿、checkstyle 0。**P2-T01**(本次 commit)新建 `fe-connector-metastore-api`:`MetaStoreProperties`(providerName + needsStorage/needsVendedCredentials 默认 false + validate no-op + raw/matched,**无 MetaStoreType 枚举** D-006)+ HMS/DLF/REST/JDBC/FileSystem 5 子接口(中立 Map/标量;HMS 经 fe-kerberos `AuthType`/`Optional`);依赖仅 fe-kerberos(D-013;fe-foundation/fe-filesystem-api 留 spi 用时再加);契约测试 3/0、checkstyle 0、import-gate exit 0、无 fe-core 禁包 import。未建 Glue/S3Tables(留扩展)。⚠️ docker 全程未跑(留 P2-T05)。**下一步 P2-T02**。 +- 2026-06-18 **FU-T02 ✅ + FU-T03 ✅**(D-011,P1-T06 前补齐 fe-filesystem 对象存储;R-008 + R-006 闭环):**FU-T02**(commit `e5b088b14e7`)`Oss/Cos/ObsFileSystemProperties.toBackendKv()` 内联镜像 legacy `AbstractS3CompatibleProperties` 基类条件(ak/sk 皆空发 `AWS_CREDENTIALS_PROVIDER_TYPE=ANONYMOUS`、否则省略);**DV-005** 不加字段/枚举(legacy OSS/COS/OBS 无可配置 provider type、`S3CredentialsProviderType` 在 s3 模块不可达,加字段反更不贴 legacy + 须扩白名单)——比原 D-011「加字段镜像 S3」更简更贴 legacy(用户本轮指令「处理逻辑一致」)。TDD RED→GREEN(3 ANONYMOUS 测 + 3 有凭据 assertNull 守省略)。**FU-T03** 4 个 `*PropertiesTest` 加调优默认守护(BE+Hadoop map,字面量期望值;S3 50/3000/1000、OSS/COS/OBS 100/10000/10000,已核 legacy `S3Properties.Env`/`OSS|COS|OBSProperties` parity);mutation 改 4 个 `DEFAULT_MAX_CONNECTIONS`→4 测全红证守护。验证:S3 15/0·OSS 14/0·COS 13/0·OBS 13/0 + 全 sibling suite 绿、checkstyle 0×4、`git diff` 白名单干净。⚠️ docker e2e 未跑(真闸 P1-T06)。**下一步 P1-T06**(R-006/7/8 全闭环 → 干净全绿验收)。 +- 2026-06-17 **FU-T01 ✅**(D-010 授权,HDFS typed BE model 修 DV-004/R-007):新建 `fe-filesystem-hdfs` 的 `HdfsFileSystemProperties`(BE-only,忠实移植 legacy `initBackendConfigProperties`)+ `HdfsConfigFileLoader`(XML 资源)+ provider `bind()`/`create(P)`(`create(Map)`/`supports()` 不动)+ pom `fe-foundation`/`commons-lang3`。kerberos=**K1**(BE-key 字符串内联,不建 fe-kerberos,不碰 create()-side authenticator;用户 AskUserQuestion 选)。**真 parity 在 UT 落地**(非 paimon Option C):25 golden parity 钉 `toMap()`==legacy BE 键集(simple/kerberos/HA/username/uri-derive/XML/sysprop…)。验证 fe-filesystem-hdfs **78/0** + checkstyle 0 + RED/GREEN(mutation 关 kerberos 块→红) + fe-core `-am compile` 绿 + `git diff` 白名单干净。**对抗 review `wf_5db99e32-2ad`(27 agent,4 lens+verify)**:清场(packaging 无跨 loader、parity byte-level 复核、BE-only 无新 catalog 路回归、强 oss-hdfs 断言被 verify 推翻),3 实质修(①malformed-uri swallow→fail-loud 对齐 legacy;②2 处 stale 注释[bindAll javadoc/paimon KNOWN GAP 1];③+11 测试)。**F1**(XML config-dir 未接 `Config.hadoop_config_dir`)用户选「**现在接好**」=fe-core `FileSystemFactory` setProperty 桥(leaf 读 sysprop)。**额外触碰 3 已白名单文件**(FileSystemFactory/FileSystemPluginManager/PaimonScanPlanProvider,均 project-owned 微改/注释)。残留 oss-hdfs JindoFS 凭据=独立 FU。⚠️ docker e2e 未跑(HA/kerberized 真闸 P1-T06)。 +- 2026-06-17 **P1-T05 ✅**(断开 paimon→fe-property 依赖边):删 `fe-connector-paimon/pom.xml` 的 `fe-property` 依赖块(仅删 pom 边——import/call 已在 P1-T03 清 DV-003-b)。recon 确认 paimon src(main+test)`org.apache.doris.property` 已 ZERO、唯一物理耦合是 pom :72,其余 `fe-property` 字样皆历史注释(不动)。**RED/GREEN=构建闸**(无 UT 可写):删后全模块编译+全 UT 仍绿=证无隐藏 transitive 断裂。验证:paimon 全模块 **293/0/0/1skip**、grep 归零、pom 无 fe-property、checkstyle 0、import-gate PASS、白名单干净(仅 pom)。**fe-property 变 0 消费者孤儿(本次不物理删,D-005)**。⚠️ docker e2e 未跑。仅剩 P1-T06 验证即 P1 收口。 +- 2026-06-17 **P1-T04 ✅**(paimon `PaimonScanPlanProvider` BE 静态凭据全量切 `getStorageProperties().toBackendProperties().ifPresent(putAll(toMap()))`→`location.*`;vended 不动、叠后保序):现场 recon 揪出 **DV-002 未覆盖的 HDFS 缺口**——fe-filesystem 无 HDFS typed BE model(`HdfsFileSystemProvider.bind` 抛→`bindAll` 跳过),legacy `getBackendStorageProperties()` 经 fe-core 发的 HDFS `hadoop/dfs/HA/kerberos`→`THdfsParams` 是 load-bearing,全量切会丢→HDFS paimon 原生读回归;`getBackendStorageProperties()` 是 ConnectorContext 方法不依赖 fe-property→P1-T05 不需此切换,纯 D-003 统一。**用户定全量切 + 接受 HDFS 回归 + follow-up 补 HDFS typed BE 类**(DV-004/R-007/FU-T01)。TDD RED(`expected ak was null`)→GREEN;52/0 + 全模块 292/0/1skip + checkstyle 0 + import-gate PASS + 白名单干净(2 文件)。**对抗 review `wf_09745716-d48`**(10 agent)confirm 4:MAJOR=R-008(OSS/COS/OBS typed 缺 `AWS_CREDENTIALS_PROVIDER_TYPE` ANONYMOUS,fe-filesystem 超白名单→FU-T02,仅无凭据 catalog)+ 3 test-gap 已修(新增 Optional.empty 跳过 + 多 entry merge 测试);推翻 3 假 finding(含实测 mutation 证「测试钉了新 seam」)。⚠️ docker e2e 未跑。 +- 2026-06-17 **P1-T03 ✅**(commit `[P1-T03]`;连接器侧首个 task;paimon `applyStorageConfig` 改走 `ctx.getStorageProperties().toHadoopConfigurationMap()`):recon 证 ctx 在 `PaimonConnector.createCatalog()` 可达 → `buildStorageHadoopConfig()` 合并下发;保留 paimon.*/raw 覆盖 last-write-wins。**T1 = Option C**(用户选;fe-filesystem 对象存储 impl 是运行时插件不在单测 classpath → paimon UT 只钉 connector-local 契约,真等价由 docker P1-T06 兜底;DV-003)。TDD RED(neuter forEach → 3 测红)→GREEN;删 ~23 canonical 测试(fe-filesystem 职责)+ 6 新契约测试;**292/0/0/1skip + checkstyle 0 + import-gate PASS + 白名单干净**。**对抗 review `wf_76df09a4-c2f`** 推翻假 1B+2M、confirm 1M=**R-006**(调优默认 50/3000/1000、100/10000/10000 fe-filesystem 无显式 UT 守护;功能正确,docker 兜底,fe-filesystem 加断言 follow-up 超白名单)。⚠️ docker e2e 未跑。 +- 2026-06-17 **P1-T02 ✅**(`DefaultConnectorContext.getStorageProperties()` + `FileSystemFactory.bindAllStorageProperties`,D-009 二次确认 3 文件):TDD 4 绿(factory 委托/fallback + ctx 空/全量绑定捕获 raw map)+ 回归 2 绿;checkstyle 0;raw map 经 `getOrigProps()` 取。**fe-core 侧管线打通**。 +- 2026-06-17 **P1-T01 ✅**(`ConnectorContext.getStorageProperties()` 默认空列表 + `fe-connector-spi→fe-filesystem-api` pom 边):TDD(RED assertNotNull→GREEN 1/1)+ checkstyle 0 + import-gate PASS;新建首个 fe-connector-spi 测试。 +- 2026-06-17 **P0-T02 ✅**(`FileSystemPluginManager.bindAll`,D-009):TDD(RED 5 错→GREEN 5 绿)+ checkstyle 0;纯新增 34 行不动既有方法。实证发现真对象存储 providers 是运行时目录插件(非 fe-core 单测 classpath)→ 删 real-S3 集成测试移交 P1-T06;并发现 P1-T02 须经 `FileSystemFactory` static accessor 取 live manager(第 3 fe-core 文件,待 AskUserQuestion)。 +- 2026-06-17 **进入 Implement(范围 P0+P1 获批)**;**P0-T01 ✅**(4-agent recon 取证三套 StorageProperties + 连接器 seam):(1) F1 等价性=非阻塞(fe-filesystem 与 paimon 现 fe-property 路常见静态凭据键全等、为超集);(2) F2 可行性=阻塞(无 bind-all 入口,证伪白名单「唯一 fe-core 改动」)→ **DV-001**;用户定 **机制 A** → **D-009**(fe-core `FileSystemPluginManager.bindAll` + `getStorageProperties()` 经 `getOrigProps()`,白名单 +1)。已回写设计/WORKFLOW/decisions/risks/tasks。 +- 2026-06-17 **3 设计点定稿(D-006/7/8)**(3-agent recon + 直读复核):**D-006** MetaStore 后端用 `MetaStoreProvider.supports()` 自识别 + ServiceLoader(镜像 `FileSystemProvider`),api 层**去掉** `MetaStoreType` 枚举;**D-007** Kerberos 抽**顶层中立叶子 `fe-kerberos`**(否决 fe-connector-auth:破 fe-filesystem↛fe-connector gate + fe-common 层级倒挂),分 P3a(paimon-local)/P3b(全量去重 follow-up);**D-008** vended 边界=连接器只抽取、fe-core 单点归一(现状已符合)。设计文档 §0/§2.3/§3.1/§3.2/§3.3/§3.5/依赖图已更新。 +- 2026-06-17 调研完成(current state:paimon metastore 已与 fe-core 解耦、仅剩 storage 对 fe-property 一条边;三套同名 StorageProperties;fe-core metastore 28 文件 3624 LOC 矩阵;kerberos 三处实现)。 +- 2026-06-17 设计定稿 + 4 决策(①新建 metastore-api/spi ②混合去重 ③fe-core 绑定下发 typed storage ④复用 @ConnectorProperty)。 +- 2026-06-17 范围收窄(用户):纯新增/迁移、只动 paimon、不删 fe-core 两包、不动其它连接器、fe-property 不物理删。 +- 2026-06-17 建立本跟踪目录 + 开发流程(WORKFLOW.md)+ 任务清单(13 tasks)。 + +--- + +## 关键链接 +- 设计:[`../designs/metastore-storage-property-refactor-design-2026-06-17.md`](../designs/metastore-storage-property-refactor-design-2026-06-17.md) +- 背景(fe-filesystem StorageProperties 现状评审):[`../reviews/fe-filesystem-storage-spi-review-2026-06-17.md`](../reviews/fe-filesystem-storage-spi-review-2026-06-17.md) +- 流程:[`WORKFLOW.md`](./WORKFLOW.md) | 任务:[`tasks.md`](./tasks.md) | 决策:[`decisions-log.md`](./decisions-log.md) diff --git a/plan-doc/metastore-storage-refactor/README.md b/plan-doc/metastore-storage-refactor/README.md new file mode 100644 index 00000000000000..3cb11e643fd2e8 --- /dev/null +++ b/plan-doc/metastore-storage-refactor/README.md @@ -0,0 +1,76 @@ +> # 🔒 本子线已彻底 CLOSED(2026-06-22 收官,用户确认) +> +> **「属性体系重构」子项目(Storage→fe-filesystem / MetaStore→fe-connector SPI,paimon 优先)已全部完成并合入主线** —— 核心任务 15/15 + docker 真闸全过;产出 `fe-kerberos` / `fe-connector-metastore-api` / `fe-connector-metastore-spi`(含 `MetaStoreProviders.bind` + 5 provider)+ 删除 `fe-property` 孤儿模块;paimon 连接器已 cutover 到共享 SPI。合入提交:`#64446`(paimon SPI+翻闸)/ `#64653`(P5-T29 删 legacy)/ `#64655`(P3b kerberos 收口 `e5959e1b53d`)。 +> +> **⛔ 后续任务(含主线 P6/P7)请勿再阅读本目录的规划/接力文档** —— 它们是已结束工作的历史留存,不再维护。需了解 metastore-spi / `MetaStoreProviders.bind` 现状请**直接读代码**:`fe/fe-connector/fe-connector-metastore-spi/`。主线接力见 [`../HANDOFF.md`](../HANDOFF.md)。 + +--- + +# 子项目:属性体系重构(Storage→fe-filesystem / MetaStore→fe-connector SPI,paimon 优先) + +> 本目录是**该子项目唯一权威跟踪源**。它隶属于上层 connector 迁移项目(见 `../README.md`),并**沿用**其文档机制(决策/偏差/风险区分、ID 规则、维护规则),仅作范围裁剪与本项目特化。 +> 任何讨论、评审、PR 描述都应引用本目录文件。 + +--- + +## 〇、入口(看了就懂) + +| 我想做的事 | 看哪个文件 | +|---|---| +| **了解为什么做、目标架构、SPI/API 设计、接口签名** | [`../designs/metastore-storage-property-refactor-design-2026-06-17.md`](../designs/metastore-storage-property-refactor-design-2026-06-17.md) ★(设计权威)| +| **本项目怎么开发:流程 / 单任务循环 / 守门 / 验证 / 提交** | [`WORKFLOW.md`](./WORKFLOW.md) ★ | +| **现在做到哪一步 / 下一步是什么** | [`PROGRESS.md`](./PROGRESS.md) ★ | +| **具体任务清单(Pn-Tnn)+ 验收** | [`tasks.md`](./tasks.md) | +| **做过哪些决策、为什么** | [`decisions-log.md`](./decisions-log.md) | +| **实施中发现原计划不可行处** | [`deviations-log.md`](./deviations-log.md) | +| **风险与缓解** | [`risks.md`](./risks.md) | +| **接管上次 session** | [`HANDOFF.md`](./HANDOFF.md) ★ | + +--- + +## 一、目录结构 + +``` +plan-doc/metastore-storage-refactor/ +├── README.md ← 本文件(子项目入口) +├── WORKFLOW.md ← 本项目开发流程(核心:阶段模型 / 单任务循环 / 守门 / 验证 / 维护规则) +├── PROGRESS.md ← 仪表盘(人类+agent 入口必读) +├── tasks.md ← Pn-Tnn 任务清单 + 验收 + 状态 +├── decisions-log.md ← 决策 ADR,append-only(本项目内编号) +├── deviations-log.md ← 实施偏差,append-only(本项目内编号) +├── risks.md ← 风险滚动状态 +└── HANDOFF.md ← Session 间接力(每次结束覆盖) + +设计正文不放这里 → 在 ../designs/metastore-storage-property-refactor-design-2026-06-17.md +``` + +--- + +## 二、本项目范围(红线,来自用户 2026-06-17) + +- ✅ **只做**:新建 `fe-connector-metastore-api/spi`(仅 paimon 用到的后端,后端用 `MetaStoreProvider` 自识别、无枚举 — D-006);新增 `ConnectorContext.getStorageProperties()` 让 fe-core 下发已绑定 `StorageProperties`;改造 **paimon** 连接器(storage 走 fe-filesystem-api、metastore 走新 SPI、vended 仍走 `ctx.vendStorageCredentials` — D-008);断开 paimon→`fe-property` 依赖边;**新建顶层叶子 `fe-kerberos` + paimon HMS kerberos facts 走它(P3a,D-007)**。 +- 🚫 **不做**:不删 fe-core `datasource.property.{storage,metastore}` 任何类;不动 hive/hudi/iceberg/es/jdbc/mc/trino;**不动 fe-common / fe-filesystem-hdfs 既有 kerberos 路径**(其收口 = P3b follow-up);fe-property 不物理删(仅变孤儿);不收紧 import gate。 +- 🔭 **范围外(后续)**:其它连接器迁移、**P3b**(fe-common + fe-filesystem-hdfs 收口到 fe-kerberos 全量去重)、终态删 fe-core 两包 + 删 fe-property + 收 gate。 + +详见设计文档 §0.1。 + +--- + +## 三、与上层 plan-doc 的关系 + +- **文档机制**沿用 `../README.md`(§3 决策vs偏差vs风险、§4 维护规则、§5 防腐、§6 不在范围)。 +- **编号空间独立**:本目录的 `D-/DV-/R-` 与 `Pn-Tnn` 仅在本子项目内有效,**不**与 `../decisions-log.md` 等共享编号(避免跨文件碰撞)。各 log 顶部已注明。 +- **Agent 接力**沿用 `../AGENT-PLAYBOOK.md` 的 context/subagent/handoff 规范;本目录 `HANDOFF.md` 是本子项目的接力点。 + +--- + +## 四、给后来者 + +**人类**:先读设计文档 §0/§2/§3(10 min)→ 看 `PROGRESS.md`(2 min)→ 要动手再读 `tasks.md` 对应 task + `WORKFLOW.md` 单任务循环。 + +**LLM agent(强制顺序)**: +1. Read `PROGRESS.md`(全局状态) +2. Read `HANDOFF.md`(上次留言) +3. Read `WORKFLOW.md`(怎么干) +4. 如 HANDOFF 指定当前 task,Read `tasks.md` 中该 task 块 +5. 一句话复述确认("上次完成 X,下一步 Y,对吗?")→ 用户确认后开始 diff --git a/plan-doc/metastore-storage-refactor/WORKFLOW.md b/plan-doc/metastore-storage-refactor/WORKFLOW.md new file mode 100644 index 00000000000000..ecbc2cc40b9f23 --- /dev/null +++ b/plan-doc/metastore-storage-refactor/WORKFLOW.md @@ -0,0 +1,167 @@ +> # 🔒 本子线已彻底 CLOSED(2026-06-22 收官,用户确认) +> +> **「属性体系重构」子项目(Storage→fe-filesystem / MetaStore→fe-connector SPI,paimon 优先)已全部完成并合入主线** —— 核心任务 15/15 + docker 真闸全过;产出 `fe-kerberos` / `fe-connector-metastore-api` / `fe-connector-metastore-spi`(含 `MetaStoreProviders.bind` + 5 provider)+ 删除 `fe-property` 孤儿模块;paimon 连接器已 cutover 到共享 SPI。合入提交:`#64446`(paimon SPI+翻闸)/ `#64653`(P5-T29 删 legacy)/ `#64655`(P3b kerberos 收口 `e5959e1b53d`)。 +> +> **⛔ 后续任务(含主线 P6/P7)请勿再阅读本目录的规划/接力文档** —— 它们是已结束工作的历史留存,不再维护。需了解 metastore-spi / `MetaStoreProviders.bind` 现状请**直接读代码**:`fe/fe-connector/fe-connector-metastore-spi/`。主线接力见 [`../HANDOFF.md`](../HANDOFF.md)。 + +--- + +# 开发流程(仅适用于本子项目) + +> 派生自 `../README.md` 的开发设计原则(文件职责矩阵 / 决策vs偏差 / ID 规则 / 维护规则 / 防腐 / 不在范围),并融合本仓库使用的工作流技能:`research-design-workflow`、`step-by-step-fix`、`test-driven-development`、`verification-before-completion`。 +> 一句话:**研究/设计已完成 → 进入「逐任务、测试先行、独立提交、严守红线、文档同步」的实现循环。** + +--- + +## 1. 阶段模型(research-design-workflow) + +| 阶段 | 状态 | 产物 | +|---|---|---| +| Research(取证) | ✅ 完成 | 8-agent 调研 + grep 复核(见设计文档附录 A) | +| Design(设计) | ✅ 完成 | `../designs/metastore-storage-property-refactor-design-2026-06-17.md`(4 决策 + 目标架构 + SPI + 有序 TODO) | +| **Implement(实现)** | ⏳ 待批准后开始 | 按 `tasks.md` 逐 task 落地,每 task 独立提交 | +| Verify(验证) | 每 task 内联 | UT / checkstyle / docker;T1/T2 等价性测试 | +| Refine(精修) | 每阶段末 | review + 简化,必要时回写设计/记偏差 | + +**禁止**:未经用户批准 `tasks.md` 的 TODO 列表,不开始 Implement(research-design-workflow 要求 "Get approval before implementation")。 + +--- + +## 2. 单任务开发循环(step-by-step-fix + TDD) + +**起步(每个 session / 阶段开始,用户 2026-06-17 立规,强制)**:先读 `PROGRESS.md` → `HANDOFF.md` → 本文件 → 下一 task 的 `tasks.md` 块 + 相关 `decisions-log`/`deviations-log` 条;**再对照真实代码 review 下一步方案**(不照搬 HANDOFF 旧计划——先 grep/读真实调用流确认方案仍成立);一句话复述确认(必要时 AskUserQuestion 定边界)后才动手。 + +每个 `Pn-Tnn` 严格走以下 8 步,**一个 task = 一个独立 commit**: + +1. **认领**:在 `tasks.md` 把该 task 状态 `⬜→🚧`,在 `HANDOFF.md` 记"正在做 Pn-Tnn"。 +2. **微设计**(如该 task 有不确定点):在 task 块"备注"写 1–3 行实现要点;若偏离设计文档 → 先记 `deviations-log.md`(见 §6)。 +3. **测试先行(RED)**:先写/改测试表达*意图*(不是行为镜像)——尤其 T1/T2 等价性(新产物 == 旧产物的 key/value)。确认测试 RED。 +4. **实现(GREEN)**:最小改动让测试通过。匹配既有代码风格。 +5. **守门核对**(§4 红线):`git diff --name-only` 必须落在**白名单路径**内;依赖方向不破。 +6. **验证**(§5):FE 编译 + checkstyle + 相关 UT 全绿;必要时 docker paimon。**"完成"前必须有命令输出佐证**(verification-before-completion)。 +7. **提交**:`[Pn-Tnn] `,结尾带 `Co-Authored-By: Claude Opus 4.8 (1M context) `。先在非默认分支。 +8. **同步文档**:`tasks.md` 状态 `🚧→✅`(加日期 + commit);更新 `PROGRESS.md`;如产生决策/偏差/风险,记对应 log;更新 `HANDOFF.md`。 + +> 卡住(blocker)时:在 task 块记 blocker 备注,停下来向用户澄清,**不要猜**(项目 CLAUDE.md Rule 1)。 + +--- + +## 3. 任务编号与依赖 + +- Task ID:`Pn-Tnn`(如 `P1-T03`)。**永不复用/重排**,删除也留占位标 `[deleted]`。 +- 阶段:`P0` 准备 / `P1` storage 收口 / `P2` metastore SPI。范围外阶段不在本项目。 +- 依赖:task 块标注前置(如 `P1-T03` 依赖 `P1-T01,P1-T02`)。可并行的标 `∥`。 + +--- + +## 4. 红线守门(本项目特有,每次提交前核对) + +### 4.1 路径白名单(`git diff --name-only` 只允许落在) +``` +fe/fe-connector/fe-connector-metastore-api/** (新建) +fe/fe-connector/fe-connector-metastore-spi/** (新建) +fe/fe-connector/fe-connector-paimon/** (改造) +fe/fe-connector/fe-connector-spi/** (仅 ConnectorContext 新增方法) +fe/fe-core/src/main/java/.../connector/DefaultConnectorContext.java (仅新增 getStorageProperties) +fe/fe-core/src/main/java/.../fs/FileSystemPluginManager.java (仅新增 bindAll;D-009/DV-001) +fe/fe-core/src/main/java/.../fs/FileSystemFactory.java (仅新增 bindAllStorageProperties;D-009 二次确认) +fe/fe-filesystem/fe-filesystem-hdfs/** (FU-T01:HDFS typed BE model;D-010 授权局部解禁) +fe/fe-filesystem/fe-filesystem-{s3,oss,cos,obs}/** (FU-T02 R-008 / FU-T03 R-006;D-011 授权;main+test;其它 fe-filesystem 模块[api,spi,azure,broker,local]仍禁碰) +fe/fe-kerberos/** (新建;P3a-T01 fe-kerberos 叶子;D-007/D-013) +fe/fe-property/** (P1-T07:彻底删除该孤儿模块;D-016 授权,覆盖 D-005 不删条款) +fe/fe-common/src/{main,test}/java/org/apache/doris/common/security/authentication/** (P3b-T01:trino→JDK + 整包 relocate 出 fe-common;D-017) +fe/fe-common/pom.xml (P3b-T01:加 fe-common→fe-kerberos 依赖边;D-017 已预定,commit 2 登记) +fe/fe-filesystem/fe-filesystem-spi/** (P3b-T01:统一 HadoopAuthenticator 接口/IOCallable;D-017) +fe/be-java-extensions/** (P3b-T01:3 scanner auth import 重指向 + java-common/pom.xml 加 fe-kerberos 依赖;D-017) +fe/fe-core/src/{main,test}/java/** (P3b-T01:24 main+14 test 消费方 **仅 import 行重指向**;含 datasource.property.{storage,metastore} 下的文件——D-017 显式授权对这些「禁碰」包做 import-only 修改,不改逻辑) +fe/fe-connector/pom.xml (仅新增模块声明) +fe/pom.xml (新增模块声明;P1-T07 额外允许删除 fe-property 的 +dependencyManagement 条目,D-016) +plan-doc/metastore-storage-refactor/** (本跟踪目录) +``` +**禁止**出现的路径(出现即停、回滚或记偏差): +- `fe/fe-core/src/main/java/.../datasource/property/storage/**`(fe-core 旧 storage 包,保持不动;**P3b-T01 例外**:仅允许 auth import 行重指向,D-017) +- `fe/fe-core/src/main/java/.../datasource/property/metastore/**`(fe-core 旧 metastore 包,保持不动;**P3b-T01 例外**:仅允许 auth import 行重指向,D-017) +- `fe/fe-connector/fe-connector-{hive,hudi,iceberg,es,jdbc,maxcompute,trino}/**`(其它连接器,不动) +- ~~`fe/fe-property/**` 的删除~~ → **P1-T07 已授权删除(D-016)**,移入上方允许区(fe-core 两包仍禁碰) + +### 4.2 依赖方向(CI gate + 人工核对) +- `fe-connector-*` 不得 import `org.apache.doris.{catalog,common,datasource,qe,analysis,nereids,planner}`(`tools/check-connector-imports.sh`)。 +- paimon 模块 `grep -r 'org.apache.doris.property'` 应在 P1 后归零;`grep -r 'org.apache.doris.datasource'` 恒为 0。 +- `fe-filesystem-*` 不得 import fe-connector / fe-core。 +- 新模块 `fe-connector-metastore-api/spi` 只可依赖 `fe-foundation` + `fe-filesystem-api`(+ `fe-connector-api/spi`)。 + +### 4.3 不变量(设计文档 §5,改动不得破坏) +- metastore 不持有 storage 字段;storage 作入参传入(`parse(raw, storageList)`)。 +- storage 叠加保序:canonical 在前、`paimon.*/fs./dfs./hadoop.` 覆盖在后(last-write-wins)。 +- HMS Kerberos 条件键在 storage 叠加**之后**施加。 +- 特权/RPC(Kerberos doAs、hive.conf.resources、vended 绑定、thrift `TS3StorageParam`)留 fe-core 经 `ConnectorContext`。 + +--- + +## 5. 验证标准 + +### 5.1 每 task 必跑 +- FE 编译该模块(maven **用绝对 `-f`**;paimon 模块需 `-am package -Dassembly.skipAssembly=true`,因 shade jar 携带 HiveConf)。 +- checkstyle 0 违规(用 `fe-code-style` 技能)。 +- 相关 UT 全绿(**注意 maven build-cache 会跳过 surefire → BUILD SUCCESS ≠ 测试跑了**;用 `-Dmaven.build.cache.enabled=false` 确保真跑)。 +- 后台 task 的退出码以输出里的 `BUILD SUCCESS/FAILURE` 行为准(非 echo 的 exit code)。 + +### 5.2 阶段验收测试(强制,设计文档 §5) +- **T1**(P1,**DV-002 修订**):S3/OSS/COS/OBS/HDFS 代表输入下,新 `toHadoopConfigurationMap()`/`toBackendProperties().toMap()` 与 paimon 现走 fe-property 旧产物在**常见静态凭据路径**(配齐 endpoint/region/AK/SK,无 role/无 vended)下 key/value **全等**(含默认调优值分叉 S3=50/3000/1000 vs OSS/COS/OBS=100/10000/10000);fe-filesystem 超集差异(S3 role/anon、endpoint 无条件、BE 多 AWS_BUCKET/ROOT_PATH/CREDENTIALS_PROVIDER_TYPE)作有意记录,非漂移。 +- **T2**(P2):HMS(simple/kerberos)/DLF/REST/JDBC/filesystem 下,共享 `*MetastoreBackend.parse` 产物与 fe-core 旧 `Paimon*MetaStoreProperties` 一致(HiveConf key 集 + ParamRules 报错文案)。 +- **T3**:依赖图守门(CI gate + 可加 ArchUnit)。 +- **T4**:docker `enablePaimonTest=true` 跑 paimon 5 flavor(filesystem/hms/rest/jdbc/dlf)+ vended(REST/DLF) + Kerberos HMS。 + +### 5.3 docker/e2e 说明 +- T4 是 docker-gated;若本次环境不部署,**明确标注"未跑 e2e"**(项目 CLAUDE.md Rule 12 失败/跳过要发声),不得把"编译过"当"验证过"。 + +--- + +## 6. 决策 / 偏差 / 风险(沿用 ../README §3) + +- **决策(D-NNN)**:事前选择,进 `decisions-log.md` 顶部。本项目 4 个核心决策已记 D-001..D-004,范围决策 D-005。 +- **偏差(DV-NNN)**:原设计落地中发现不可行/不必要,**事后**记 `deviations-log.md`,并回写设计文档对应节加 `(DV-NNN 修订)`。**禁止 silently 改设计**。 +- **风险(R-NNN)** vs **问题(Issue)**:可能发生→`risks.md` 滚动;已发生→记在 task 块 blocker。 +- 编号仅本子项目内有效(与上层 plan-doc 独立)。 + +--- + +## 7. 文档维护规则(沿用 ../README §4,裁剪) + +| 触发 | 动作 | +|---|---| +| 完成一个 task | `tasks.md` 状态翻转 + 日期/commit;更新 `PROGRESS.md`;阶段日志追加一行 | +| 产生新决策 | 先写 `decisions-log.md` 顶部分配 D-NNN;如改设计则回写并加脚注 | +| 发现偏差 | 先写 `deviations-log.md`(DV-NNN:原计划位置/为何不可行/新方案/影响);再改设计 | +| **每完成一个 task/阶段 或 session 结束**(用户 2026-06-17 立规) | **覆盖更新 `HANDOFF.md`**(完成了什么 / 下一步含真实代码 review 要点 / 未决 / 红线)**并 commit**(随该 task commit 或单独 doc commit)。下次接手不靠记忆、只靠 HANDOFF。 | +| 每个 commit | 第一行 `[Pn-Tnn] `;merge 后立即按上面流程更新状态 | + +--- + +## 8. 不在范围(沿用 ../README §6) + +本流程**不**涵盖:代码评审(GitHub PR)、缺陷管理(Issues)、CI 状态(Actions)、工时/KPI。文档只追踪"本子项目的设计与进度",不追踪人。 + +--- + +## 9. 实现顺序建议(来自设计文档 §4) + +``` +P0(准备,可与 P1 并行起步) + └ P0-T01 确认 fe-filesystem-api 已够用 ∥ P0-T02 fe-core 绑定入口 + +P1(paimon storage 收口;纯新增/迁移) + P1-T01 ConnectorContext.getStorageProperties() ← 解锁 fe-connector→fe-filesystem-api 边 + P1-T02 DefaultConnectorContext 实现(限 paimon 路径) [依赖 P1-T01,P0-T02] + P1-T03 PaimonCatalogFactory storage 改走 api [依赖 P1-T01] + P1-T04 PaimonScanPlanProvider BE 凭据改走 api [依赖 P1-T01] + P1-T05 断开 paimon→fe-property 依赖边 [依赖 P1-T03,T04] + P1-T06 验证(UT + docker 5 flavor + T1) [依赖 P1-T02..T05] + +P2(metastore SPI + paimon adapter;纯新增/迁移) + P2-T01 新建 fe-connector-metastore-api + P2-T02 新建 fe-connector-metastore-spi(共享后端解析器) [依赖 P2-T01] + P2-T03 paimon adapter 改走共享解析器 [依赖 P2-T02] + P2-T04 paimon pom + gate 核对 [依赖 P2-T03] + P2-T05 验证(UT + docker 5 flavor + T2 + vended + kerberos) [依赖 P2-T03,T04] +``` diff --git a/plan-doc/metastore-storage-refactor/decisions-log.md b/plan-doc/metastore-storage-refactor/decisions-log.md new file mode 100644 index 00000000000000..5bcab7f337525d --- /dev/null +++ b/plan-doc/metastore-storage-refactor/decisions-log.md @@ -0,0 +1,135 @@ +> # 🔒 本子线已彻底 CLOSED(2026-06-22 收官,用户确认) +> +> **「属性体系重构」子项目(Storage→fe-filesystem / MetaStore→fe-connector SPI,paimon 优先)已全部完成并合入主线** —— 核心任务 15/15 + docker 真闸全过;产出 `fe-kerberos` / `fe-connector-metastore-api` / `fe-connector-metastore-spi`(含 `MetaStoreProviders.bind` + 5 provider)+ 删除 `fe-property` 孤儿模块;paimon 连接器已 cutover 到共享 SPI。合入提交:`#64446`(paimon SPI+翻闸)/ `#64653`(P5-T29 删 legacy)/ `#64655`(P3b kerberos 收口 `e5959e1b53d`)。 +> +> **⛔ 后续任务(含主线 P6/P7)请勿再阅读本目录的规划/接力文档** —— 它们是已结束工作的历史留存,不再维护。需了解 metastore-spi / `MetaStoreProviders.bind` 现状请**直接读代码**:`fe/fe-connector/fe-connector-metastore-spi/`。主线接力见 [`../HANDOFF.md`](../HANDOFF.md)。 + +--- + +# 决策日志(ADR,append-only,时间倒序) + +> 编号 `D-NNN` **仅在本子项目内有效**,与上层 `../decisions-log.md` 独立。 +> 新决策写在顶部。修改设计文档某节时,在该节加 `(D-NNN)` 脚注。 + +--- + +## D-017 — P2-T04/T05 ✅ 收口;P3b(kerberos 机制收口到 fe-kerberos)提前到 P6 iceberg 之前单独做 +- **日期**:2026-06-21 | **决策者**:用户(「在开始 P6 iceberg spi 迁移之前,先做 P3b」) +- **背景**:① **P2-T04 ✅**(`MetaStoreProviders` 改 2-arg 显式 loader `2612af5e88f` + paimon pom/import-gate 核对)+ **P2-T05 ✅**(用户 2026-06-21 **手动 docker 验证**:paimon 5-flavor 读 + vended REST/DLF + Kerberos HMS,`enablePaimonTest=true`)→ **P2 全 5/5、元存储子线 docker 真闸通过**。② 主线 **P5-T29(B8)已完成**(fe-core 完全 paimon-SDK-free,见 [`../HANDOFF.md`](../HANDOFF.md))。③ P3b 原计划「与 hive/iceberg 迁移同批」(D-007 步骤 b / tasks P3b-T01「范围外占位」)。 +- **内容**:**P3b 提前、单独做,排在 P6 iceberg SPI 迁移之前**(不再「与 hive/iceberg 同批」)。P3b = 把 fe-common `security.authentication.*`(**12 类机制**)收口到 `fe-kerberos` 作唯一真相源 + 删 `fe-filesystem-hdfs` 自有 `KerberosHadoopAuthenticator`/`SimpleHadoopAuthenticator` 副本 + 统一两个打架的 `HadoopAuthenticator` 接口(fe-common `PrivilegedExceptionAction` vs fe-filesystem-spi `IOCallable`)+ trino `KerberosTicketUtils`→JDK(现码 scope 见 tasks P3b-T01)。 +- **理由**:P3b 是**纯机制收口**,不依赖 iceberg 迁移即可独立完成;且**先做 P3b 反而服务 P6**——iceberg metastore-props 迁移届时直接复用收口后的干净 `fe-kerberos` authenticator(与 paimon 将来同),避免 P6 同时扛「iceberg 迁移 + kerberos 收口」两件大事。先打地基再迁 iceberg。**被否**:原「与 hive/iceberg 同批」(回归面叠加)。 +- **影响**:tasks P3b-T01 `⬜ 范围外` → `🚧 active(下一任务)`;主线 [`../HANDOFF.md`](../HANDOFF.md) 头条 = P3b(先于 P6);PROGRESS/HANDOFF「下一步」改 P3b。**白名单将扩**(执行 session 前定,先 AskUserQuestion 定 scope 粒度):`fe/fe-kerberos/**`(加机制 + hadoop-auth/common 依赖)、`fe/fe-common/.../security/authentication/**`(搬出/重指向)、`fe/fe-filesystem/fe-filesystem-hdfs/**`(删副本+统一接口)、+ 40 处 import 重指向的消费方(**24 fe-core + 12 fe-common + 3 be-java-extensions scanner**[paimon/iceberg/hudi JNI]——⚠️ 跨 FE/BE-java)。 + +## D-016 — 授权彻底删除 fe-property 模块(超 D-005,定为下一阶段 P1-T07) +- **日期**:2026-06-18 | **决策者**:用户(「下一阶段,彻底删除 fe-property 模块」) +- **背景**:P1-T05 断开 paimon→fe-property 依赖边后,fe-property 成 **0 消费者孤儿**(whole-repo 核实:除 `fe/pom.xml` 的 ``+dependencyManagement 外,无任何 pom `` 它、无任何源 `import org.apache.doris.property.*`、无 BE/docker/脚本/regression 引用;仅 5 处 stale **注释**在 paimon+fe-filesystem-hdfs 提到「移植源/replaces fe-property…」)。D-005/设计 P1-5 当初把物理删除「留待后续任务」,WORKFLOW §4.1 把 `fe/fe-property/**` 的删除列为**禁止**。 +- **内容**:用户**授权现在物理删除 fe-property**,并定为**下一阶段(新任务 P1-T07)**,**先于** P2-T04/T05。**本决策就 fe-property 这一项覆盖 D-005「不物理删 fe-property」条款**(D-005 的其余条款——不删 fe-core `datasource.property.{storage,metastore}` 两包、不动其它连接器——**不变**:那两包仍服务 hive/hudi/iceberg,本次不碰)。 +- **白名单扩**:WORKFLOW §4.1 把 `fe/fe-property/**` 从「禁止删除」移到「**允许删除**」;`fe/pom.xml` 允许**删除** `fe-property` + 其 dependencyManagement 条目(原白名单只允许「新增模块声明」,此处扩为「删除 fe-property 的模块/版本声明」)。 +- **范围/做法(P1-T07,执行在下一 session)**:删 `fe/fe-property/` 目录 + `fe/pom.xml` 两处声明;whole-repo 复核 0 引用后**全 FE 构建验证**(`-am package`);**可选**清理 5 处 stale 注释(fe-filesystem-hdfs `HdfsFileSystemProperties`/`HdfsConfigFileLoader` + paimon `PaimonCatalogFactory`/`PaimonConnector`/`PaimonCatalogFactoryTest` 里提到 fe-property 的注释,删模块后变悬空引用——均白名单内文件)。**RED/GREEN=构建闸**(无 UT 可写:删孤儿模块后全构建+全 UT 仍绿=证无隐藏 transitive 断裂,同 P1-T05 模式)。 +- **理由**:fe-property 已 0 消费者(被 fe-filesystem typed 模型取代),保留=死代码;用户优先清理。**被否**:继续延后到「全部连接器迁完一起清」(fe-property 与 fe-core 两包不同——fe-property 唯一消费者 paimon 已断,可独立删;fe-core 两包仍有 hive/hudi/iceberg 消费者,须留)。 + +## D-015 — P2-T03 JDBC driver 注册副作用留连接器,仅纯 `resolveDriverUrl` 共享(不下移注册) +- **日期**:2026-06-18 | **决策者**:用户(AskUserQuestion 选「方案 A:注册留连接器(推荐)」) +- **背景**:P2-T02 只上移了纯 `JdbcDriverSupport.resolveDriverUrl`(其 javadoc 明记 live 注册「无调用方、P2-T03 前不搬,Rule 2」)。HANDOFF 把「driver 注册下移与否」列为 P2-T03 决策点。driver 逻辑两消费方:①`PaimonConnector`(FE 侧)真执行注册(`DriverManager.registerDriver`+`DriverShim`+静态 `DRIVER_CLASS_LOADER_CACHE`/`REGISTERED_DRIVER_KEYS`);②`PaimonScanPlanProvider.getBackendPaimonOptions`(BE 选项)只解析 URL 不注册。唯一共享=`resolveDriverUrl`。 +- **内容**:**注册副作用留 `PaimonConnector` 不动**;P2-T03 仅把两处 `PaimonCatalogFactory.resolveDriverUrl`(删)改调 `JdbcDriverSupport.resolveDriverUrl`(字节相同)。 +- **理由**:单消费方(FE 注册)→ 下移是为 hive/iceberg 将来服务的投机(Rule 2);metastore-spi 刻意 SDK/JVM-副作用-free(DV-007 保持 hadoop/fs-free),注入 `DriverManager` 全局变更+活 `URLClassLoader`+`Class.forName` 模糊 api/spi 纯净边界;`DriverShim` 的 loader 身份对 DriverManager 接受是 load-bearing 的,子优先插件 loader 下迁移它有 classloader 风险而零功能收益(CI RCA 记忆 RC-1/3/5 反复证 classloader 危害)。**被否**:方案 B(下移注册)。 +- **影响**:`JdbcDriverSupport` javadoc「注册留待 P2-T03」状态=**已决定不下移**(待 hive/iceberg 真第二消费方时再议);无新模块改动。 + +## D-014 — P2-T03 采用 spi 的 legacy-faithful validate(CREATE CATALOG 比当前 paimon 更严,故意收敛) +- **日期**:2026-06-18 | **决策者**:用户(AskUserQuestion 选「采用 spi validate(legacy-faithful)」) +- **背景**:P2-T02 的 spi `validate()` 故意比 paimon 手抄 `PaimonCatalogFactory.validate` 严,恢复了真 legacy 规则(手抄版漏掉的):HMS **case-sensitive** `forbidIf(simple)`/`requireIf(kerberos)`(client principal+keytab)、REST **case-sensitive** `"dlf".equals(token.provider)`(手抄是 equalsIgnoreCase)、DLF 在 **CREATE** 要求 OSS 存储(手抄在 catalog-build 时 `requireOssStorageForDlf`)。P2-T02 只建 spi、无消费方;P2-T03 cutover 是这些规则首次作用于真实 CREATE CATALOG。 +- **内容**:cutover **直接采用** `MetaStoreProviders.bind(props,{}).validate()`,接受 CREATE CATALOG 比当前 paimon 更严(今天通过 paimon 宽松检查的某些 catalog 现在会在 CREATE 被拒)。这是项目 T2「等价=对齐 legacy 非对齐手抄」目标的兑现。 +- **理由**:spi 的更严规则才是权威 legacy(`HMSBaseProperties.buildRules`/`AliyunDLFBaseProperties`/`ParamRules`);保留手抄宽松行为需 compat shim(更多代码、偏离目标、spi validate 部分闲置)。**被否**:方案 B(compat shim 保 CREATE 字节兼容)。 +- **影响**:行为变更 = 三处更严校验在 CREATE CATALOG 生效;unknown-flavor 错误文案从「Unknown paimon.catalog.type value: X」→ bind 的「No MetaStoreProvider supports...」(两者皆 IAE→DdlException,CREATE 仍失败);DLF S3-only 拒绝时机 build→CREATE(无 reload 回归:无效 catalog 在新模型下根本无法 CREATE 故不会持久化/reload)。测试:`PaimonConnectorValidatePropertiesTest` 钉新行为(3 tightening RED→GREEN)。 + +## D-013 — Kerberos 中立 facts 类型(AuthType + KerberosAuthSpec)落 fe-kerberos,先于 P2-T01 建;metastore-api 依赖 fe-kerberos +- **日期**:2026-06-18 | **决策者**:用户(AskUserQuestion 选「In fe-kerberos (build it first)」) +- **背景**:P2-T01 的 `HmsMetaStoreProperties` 需要 `AuthType`(SIMPLE/KERBEROS) + `KerberosAuthSpec`(principal/keytab facts)。`AuthType` 现仅在 `fe-common`(连接器 import gate 禁);设计把 `KerberosAuthSpec` 归 `fe-kerberos`(P3a-T01,原排在 P2-T02 之后)→ P2-T01 引用它有构建顺序冲突。 +- **内容**:**遵循 D-007 字面归属** = 这两个中立 facts 类型落 **`fe-kerberos`**;**先建 fe-kerberos 的 facts-carrier 切片**(`AuthType` + `KerberosAuthSpec`,纯 Java、零 hadoop 依赖)于 P2-T01 **之前**;`fe-connector-metastore-api` **依赖 fe-kerberos**(设计 §3.1 header 依赖集 +fe-kerberos)。fe-kerberos 仍是顶层中立叶子(authenticator 机制子集 = hadoop 依赖部分留待 P2-T02 消费时增量补,仍属 P3a-T01 scope)。 +- **被否**:「`KerberosAuthSpec`/`AuthType` 落 metastore-api 自包含」(更简、不改顺序,但偏离 D-007 归属;用户选保持 D-007 单一真相源)。 +- **核实**:现有 `fe-authentication` 模块是**用户登录/角色映射**鉴权(password 插件/Principal/role-mapping),与 hadoop service kerberos **无关**,**不复用**;fe-kerberos 确为新建。 +- **影响**:实施顺序 = **P3a-T01(facts-carrier 切片)→ P2-T01 → P2-T02(补 authenticator 机制 + 消费 facts)**;WORKFLOW §4.1 白名单 +`fe/fe-kerberos/**` + `fe/pom.xml`(新增 `fe-kerberos`,已属「仅新增模块声明」允许);设计 §3.1 header 注「+fe-kerberos」;tasks P3a-T01 标 🚧(facts-carrier 落地,机制待续)。**fe-kerberos facts-carrier 零 hadoop**:`AuthType` enum(SIMPLE/KERBEROS) + `KerberosAuthSpec`(client `principal`+`keytab` 中立标量——doAs 登录事实;HMS service principal 是 HiveConf override 不在此,镜像 fe-common `KerberosAuthenticationConfig` 字段)。 + +## D-012 — 跳过/推迟 P1-T06 docker 验证,直接进入 P2(metastore SPI);docker 验证集中到 P2-T05 +- **日期**:2026-06-18 | **决策者**:用户(「跳过 p1-t06,先开始做下一阶段」) +- **内容**:**不在此刻跑 P1-T06**(P1 storage 收口的 docker 5-flavor 真等价闸);直接开始 **P2(metastore SPI,从 P2-T01 起)**。P1-T06 **非取消而是推迟**——其 docker 验证(T1 storage 等价:S3/OSS/COS/OBS/HDFS + 无凭据 OSS/COS/OBS + 调优默认)与 P2-T05 的 docker 验证(T2 metastore 等价 + 5 flavor + vended + kerberos)**合并为一次 docker 跑**(P2-T05 本就需要同一套 `enablePaimonTest=true` 5-flavor 环境),避免重复部署。 +- **理由**:R-006/R-007/R-008 已在 UT/mutation 层闭环,P1 storage 路径无已知漂移;P1-T06 与 P2-T05 共用同一 docker 套件,分两次跑无收益。用户优先推进架构进度(P2 大阶段),把所有 e2e 验证留到 P2 收口一次性做。 +- **影响**:实施顺序 P1-T06 → 推迟到 P2-T05 之后/合并;PROGRESS/HANDOFF「下一步」改为 P2-T01;**P1-T06 task 状态保持「未完成(推迟)」**(不标 ✅,docker 未跑);CLAUDE.md Rule 12:在 P2-T05 docker 真跑前,所有「完成」均须标「未跑 e2e」。WORKFLOW §4.1 白名单按 P2 需要纳入 `fe-connector-metastore-api/spi`(原已列为新建允许路径)。 + +## D-011 — P1-T06 之前先处理 R-008 + R-006(授权触碰 fe-filesystem-{s3,oss,cos,obs}) +- **日期**:2026-06-18 | **决策者**:用户(「在做 p1-t06 之前,把 r-008 和 r-006 先处理掉」) +- **内容**:**调整实施顺序** = 先 **FU-T02(R-008)** + **FU-T03(R-006)**,再 **P1-T06**。授权本次**局部解禁**对象存储 typed 模块(原 D-005 / WORKFLOW §4.1 禁碰 fe-filesystem,D-010 仅放行 fe-filesystem-hdfs): + - **FU-T02(R-008)**:给 `fe-filesystem-{oss,cos,obs}` 的 `Oss/Cos/ObsFileSystemProperties` 补 `AWS_CREDENTIALS_PROVIDER_TYPE`(镜像 `S3FileSystemProperties`),**精确 parity** = ak/sk **皆空** → `ANONYMOUS`,否则**省略**(legacy OSS/COS/OBS 仅 blank-creds 才发 ANONYMOUS,**非**无条件 `DEFAULT`;S3 override 恒非空故 S3 typed 已有)。修无凭据 OSS/COS/OBS catalog 在带 IAM-role 云主机的凭据选择漂移。 + - **FU-T03(R-006)**:给 `S3/Oss/Cos/ObsFileSystemPropertiesTest` 加 **test-only** 调优默认断言(S3=50/3000/1000、OSS/COS/OBS=100/10000/10000),守护 P1-T03 删 paimon canonical 测试暴露的 fe-filesystem 测试缺口(**功能今日正确**,仅测试健壮性)。 + - 两者均纯新增/外科:**不动** fe-core 旧 storage 包、不动其它连接器、不动 fe-filesystem-{api,spi,hdfs,azure,broker,local}(除非 recon 证 R-008 须给 api/spi 加 credentials-provider-type 共享类型——届时再 AskUserQuestion 扩)。 +- **理由**:R-008/R-006 同源「fe-filesystem typed 对象存储模型对 legacy 不完整」,docker P1-T06 才会暴露 R-008(无凭据 OSS/COS/OBS);用户决定**在 P1-T06 docker 之前先补齐**,使 P1-T06 成为干净的全绿验收(而非带已知漂移)。FU-T01 已证 fe-filesystem 自有模块可写真 parity UT(非 paimon Option C),故 R-008/R-006 都能 UT 落地 + 与 S3 typed 对照。 +- **影响**:WORKFLOW §4.1 白名单 +`fe/fe-filesystem/fe-filesystem-{s3,oss,cos,obs}/**`(main + test;仅 FU-T02/FU-T03);tasks FU-T02 ⬜→ active-next、新增 **FU-T03**(R-006);risks R-008/R-006 状态「监控/已触发」→「修复中(P1-T06 前)」;实施顺序 P1-T06 后移到 FU-T02/FU-T03 之后。**实施前仍按 WORKFLOW §2:先 recon 真实代码 + 一句话复述 + TDD**(R-008 TDD:合成 OSS/COS/OBS 无凭据 map → `toBackendKv()` 应含 `AWS_CREDENTIALS_PROVIDER_TYPE=ANONYMOUS` 当 ak/sk 皆空,RED→GREEN;对照 legacy `AbstractS3CompatibleProperties` :117-129)。 + +## D-010 — 授权触碰 fe-filesystem-hdfs(FU-T01 HDFS typed BE model)+ kerberos 选 K1(不建 fe-kerberos) +- **日期**:2026-06-17 | **决策者**:用户(确认设计 + AskUserQuestion 选 K1) +- **内容**:授权本次**局部解禁** `fe-filesystem-hdfs`(原 D-005 / WORKFLOW §4.1 禁碰 fe-filesystem),把 **FU-T01 从 follow-up 提升为 active 任务**,修复 P1-T04 引入的 HDFS BE 配置回归(DV-004 / R-007)。范围: + 1. 新建 `fe-filesystem-hdfs` 的 **`HdfsFileSystemProperties`**(`implements FileSystemProperties, BackendStorageProperties`,**不**实现 `HadoopStorageProperties`——BE-only,catalog/Hadoop 路保持现状即 P1-T03 后的 raw passthrough,零新行为)+ 小工具 **`HdfsConfigFileLoader`**(XML `hadoop.config.resources` 加载,移植 fe-property `PropertyConfigLoader`,使叶子不依赖 fe-common/fe-core)。 + 2. 改 `HdfsFileSystemProvider`:re-type 为 `FileSystemProvider` + 新增 `bind()`/`create(P)`;**`create(Map)` 与 `supports()` 保持字节级不变**(hive/iceberg/broker 的 FE filesystem 路零回归)。 + 3. `fe-filesystem-hdfs/pom.xml` 增 `fe-foundation` + `commons-lang3`(镜像 sibling `fe-filesystem-s3`)。 + - **toMap()(BE map)= 忠实移植 legacy `HdfsProperties.initBackendConfigProperties()`** → 与 fe-core `getBackendConfigProperties()` parity(含 XML 资源、`hadoop./dfs./fs./juicefs.` 透传、恒发 `ipc.client.fallback...`+`hdfs.security.authentication`、kerberos 块、`hadoop.username`、HA 校验、kerberos required-check)。源 = fe-property `HdfsProperties`(依赖轻、BE-key-only、无 authenticator 的孪生)。 + - **kerberos = K1**(用户 AskUserQuestion 选):BE-key 字符串内联发射(`hadoop.security.authentication=kerberos`/principal/keytab),**不新建 fe-kerberos**、**不碰** fe-filesystem-hdfs 现有 create()-side `KerberosHadoopAuthenticator`。recon 证 BE model 仅需字符串发射、不需 fe-kerberos(真正 `UGI.doAs` 仍留 fe-core/ctx + 现有 DFSFileSystem,§5 不变量 4)。fe-kerberos/P3a/P3b 仍为独立未来工作。 +- **理由**:R-007/DV-004 闭环物理上须给 typed 路一个 HDFS BE model(否则 `getStorageProperties()` 对 HDFS catalog 返回空)。recon(`wf_de5f54be-668` 4-agent)证:①fe-property `HdfsProperties` 是现成的依赖轻 BE-key-only 移植源(parity by construction);②`BackendStorageKind.HDFS`/`FileSystemType.HDFS`/`StorageKind.HDFS_COMPATIBLE` 已存在;③`bindAll` 只 catch UOE → 校验错误正常上抛非吞掉;④BE model 的 kerberos 仅字符串、不需 fe-kerberos(K1)。BE-only + create(Map) 不动 = 最外科、对 catalog 路零新行为。**真 parity 可在 UT 落地**(不同于 paimon Option C):fe-filesystem-hdfs 自有模块,可写 golden parity UT 钉 `toMap()` == legacy BE 键集。 +- **影响**:WORKFLOW §4.1 白名单 +`fe/fe-filesystem/fe-filesystem-hdfs/**`(仅本任务;其它 fe-filesystem 模块仍禁碰);R-007 状态「已触发(接受)」→「修复中」→**「已闭环」**;tasks FU-T01 ⬜(follow-up)→🚧→✅(active);R-005/P3a/P3b 不受影响(K1 不碰 kerberos 收口);R-008(OSS/COS/OBS ANONYMOUS)仍 FU-T02 独立。**被否**:K2(建 fe-kerberos 仅放常量=低值)、K3(连 create()-side doAs 一并收口=P3b scope,触碰工作中的 create() 路、回归面大,宜独立任务)。 +- **对抗 review 增补(`wf_5db99e32-2ad`,27 agent,4 lens + verify;2026-06-17)**:~13 confirm(多 NIT/MINOR + 清场),3 实质修:①malformed-`uri` 由 swallow 改 **fail-loud**(对齐 legacy `extractDefaultFsFromUri` 不 try/catch);②两处被本改动作废的 stale 注释(`FileSystemPluginManager.bindAll` javadoc 去 HDFS、paimon `KNOWN GAP 1` 标 CLOSED);③+11 覆盖测试(empty-input fallback、kerberos-creds-but-simple 判别、viewfs/jfs derive vs ofs/oss no-derive、allowFallback-blank、multi-uri、HA 三分支、malformed-uri、sysprop 解析)。**F1(用户选「现在接好」)= XML config-dir 接线**:legacy 走 `Config.hadoop_config_dir`(可被 operator 覆盖),新 leaf 不能 import fe-core `Config`→在 **`FileSystemFactory.bindAllStorageProperties`(已白名单)** `System.setProperty("doris.hadoop.config.dir", Config.hadoop_config_dir)`,`HdfsConfigFileLoader.resolveHadoopConfigDir()` 读该 sysprop(默认 dir 与 Config 默认相同,仅非默认安装受影响)。**额外触碰**(均 project-owned 方法体微改 + 注释,已在 commit/HANDOFF 标注):fe-core `FileSystemFactory.java`(+1 行 setProperty,本项目 P1-T02 加的方法)、`FileSystemPluginManager.java`(bindAll javadoc,本项目 P0-T02 加的方法)、fe-connector-paimon `PaimonScanPlanProvider.java`(注释)。**清场(非缺陷)**:packaging 无跨 loader 风险(无 fe-foundation 类穿越边界、hadoop parent-first)、`new Configuration()` 默认 bloat 是 legacy-faithful、BE-only 不引入新 catalog 路回归、独立 agent 逐键复核 byte-level parity。**残留已知(非本任务)**:oss-hdfs 在 typed 路仍缺 JindoFS oss 凭据(P1-T04 已起、需 fe-filesystem OssHdfs typed model,独立 FU);scan-time 重 validate(valid catalog 内禀 dormant,bindAll 无 memoization 是 typed-路通性非 FU-T01)。 + +## D-009 — bind-all 机制 + 白名单 +1(FileSystemPluginManager)(应对 DV-001) +- **日期**:2026-06-17 | **决策者**:用户(应对 P0-T01 证伪 P0-1 预期) +- **内容**:实现 `ConnectorContext.getStorageProperties()`(返回 fe-filesystem typed `StorageProperties`)所需的 raw map → `List` 绑定,落在 fe-core `FileSystemPluginManager` 新增 additive `public List bindAll(Map)`(镜像现有 `createFileSystem` 的 provider 循环,但用 `provider.bind(props)` 全量收集所有 `supports()` 命中者,而非首个命中 `create`)。`DefaultConnectorContext.getStorageProperties()` 调它;raw map 经现有 `storagePropertiesSupplier` 值的 `getOrigProps()` 取(fe-core `ConnectionProperties` 公有 getter),**不改构造点**(`PluginDrivenExternalCatalog` 零改动)。 +- **理由**:守 D-003「连接器只见 fe-filesystem-api」架构(否决 C「ctx 返回 Map」=放弃该目标边);bindAll 放 fe-core(非 fe-filesystem-spi 静态)契合设计 §2.1「fe-core 用 providers 全量绑定」且能见 directory 插件(否决 B)。 +- **🔧 二次确认(2026-06-17,P0-T02 实证后)**:fe-core 改动从原估 **2 文件修正为 3 文件**(均纯新增)。实证:生产中对象存储 providers 是 `Env.loadPlugins(pluginRoot)` **目录插件**,只存于 **live** `FileSystemPluginManager`(藏于 `FileSystemFactory.pluginManager` private、无 getter);`DefaultConnectorContext` 无法直达(构造点 `PluginDrivenExternalCatalog` 被 R-004 禁)。→ 须在 `FileSystemFactory` 加 additive static `bindAllStorageProperties(Map)`(委托 live `pluginManager.bindAll`,else ServiceLoader fallback,镜像现有 `getFileSystem` 双路径)。**三文件** = `DefaultConnectorContext`(+getStorageProperties)+ `FileSystemPluginManager`(+bindAll)+ `FileSystemFactory`(+bindAllStorageProperties)。raw map 经 `storagePropertiesSupplier.get().values()` 任一 `getOrigProps()`(已核实 = 完整 catalog map)。用户 2026-06-17 二次确认接受。 +- **影响**:WORKFLOW §4.1 白名单 +`FileSystemPluginManager.java` +`FileSystemFactory.java`(均仅新增方法);risks R-004 改「唯一」为「**三处** fe-core 新增」;设计 §4 P0-1/P0-2 回写(+DV-001 脚注);tasks P0-T02/P1-T02。**fe-core 旧 storage 包仍零改动**(bindAll 用 fe-filesystem providers,不碰 `datasource.property.storage`)。 + +## D-008 — Vended creds 边界:连接器只「抽取」,fe-core 单点「归一」 +- **日期**:2026-06-17 | **决策者**:用户 +- **内容**:vended creds 处理边界 = **连接器只负责 SDK 特定的原始 token 抽取**(paimon 已落地于 `PaimonScanPlanProvider.extractVendedToken(table)`,从活的 paimon SDK 表对象抠出任意 shape 的 `s3.*`/`oss.*` token);**raw-token → 统一 BE map** 的归一(`CredentialUtils.filterCloudStorageProperties` + `StorageProperties.createAll`(provider 重绑、派生 region/endpoint/调优默认)+ `getBackendPropertiesFromStorageMap` → `AWS_ACCESS_KEY/SECRET_KEY/TOKEN/ENDPOINT/REGION`)**仍由 fe-core `ConnectorContext.vendStorageCredentials(rawToken)` 单点实现**。fe-core 旧 `Paimon/IcebergVendedCredentialsProvider` 的抽取逻辑后续随各连接器迁移正式下沉到连接器(paimon 已下沉),本次不删 fe-core 旧类(D-005)。 +- **理由**:raw-token→BE-map 必须套**后端特定**默认值(region/endpoint 推导、S3=50/3000/1000 vs OSS/COS/OBS=100/10000/10000 调优分叉),需 storage providers(ServiceLoader 发现);而连接器按 D-003 **只见 fe-filesystem-api 接口、不持有 providers**,物理上无法独立完成全程归一。延续 D-003「动态/provider 发现留 fe-core」的精确边界:连接器最轻、归一单点、无漂移。**备选 B(放宽红线让连接器依赖 fe-filesystem-spi/providers 自做端到端)被否**:加重连接器 classpath + 破坏「fe-connector 仅依赖 fe-filesystem-api」红线。 +- **影响**:设计 §2.3(细化边界);tasks `P1-T04` 已符合(vended 路径不动,仍走 `ctx.vendStorageCredentials`),**无新增 task**;为后续 hive/iceberg 迁移确立统一 vended 模式。 + +## D-007 — Kerberos 抽到新叶子模块 fe-kerberos(单一真相源) +- **日期**:2026-06-17 | **决策者**:用户 +- **内容**:新建叶子模块 **`fe-kerberos`**(依赖**仅** hadoop-auth/hadoop-common;把唯一外部依赖 trino `KerberosTicketUtils` 用 JDK `javax.security.auth.kerberos` 替掉),把 fe-common `org.apache.doris.common.security.authentication.*` 整套搬入作**唯一真相源**;`fe-filesystem-hdfs` **删掉自有的** `KerberosHadoopAuthenticator`、改依赖 fe-kerberos;统一两个打架的 `HadoopAuthenticator` 接口(fe-common 用 `PrivilegedExceptionAction` vs fe-filesystem-spi 用 `IOCallable`)为单接口 + 消费侧 adapter。`fe-common`/`fe-core`/`fe-filesystem-*`/`fe-connector-*` 共用。`fe-kerberos` **置于顶层**(与 `fe-foundation`/`fe-common` 平级的中立叶子),无环。 +- **归属/命名(用户 2026-06-17 二次确认)**:**否决** `fe-connector-auth`(置于 fe-connector 组)——会破坏两条规则:(1) `fe-filesystem-* ──╳──► fe-connector` gate(fe-filesystem-hdfs 无法依赖它删除自有副本);(2) `fe-common`(低层)反向依赖 fe-connector 子模块=层级倒挂。故必须是**顶层中立叶子**才能被 fe-filesystem-hdfs + fe-common 共用(=满足原始需求「HMS 与 HDFS 都能用」)。**模块名定 `fe-kerberos`**(用户 2026-06-17 确认)。 +- **理由**:现状 kerberos **三处实现**——(1) fe-common `security.authentication.*`(fe-core/HMS/HDFS 用);(2) fe-filesystem-hdfs **自抄一份** `KerberosHadoopAuthenticator`(为避免依赖 fe-common,一年前拷贝、TGT 刷新可能已漂移);(3) paimon 手抄 HMS 的 kerberos HiveConf 键 + 回调 `ctx.executeAuthenticated`。改一处要改三处。auth 类 import 干净(仅 JDK/hadoop/log4j/commons/guava + 1 个 trino),fe-common 不依赖 fe-core → 抽取无阻力;`fe-foundation` 现为纯净(零 hadoop)的 `@ConnectorProperty` 叶子,**不应**被 hadoop 污染(故否「折进 fe-foundation」);也否「fe-filesystem/fe-connector 直接依赖较重的 fe-common」。 +- **范围(分两步,用户 2026-06-17 确认)**:(a) **P3a 纳入本次** = 先建 `fe-kerberos` + 让 **paimon** 的 HMS kerberos facts 走它(paimon-local、纯新增,**不碰** fe-common/fe-filesystem-hdfs 既有路径,符合 D-005);(b) **P3b = follow-up(本次不做)** = 全量去重(删 fe-filesystem-hdfs 副本、fe-common 重指向 fe-kerberos、统一两个 `HadoopAuthenticator` 接口),与 hive/iceberg 迁移同批——此步改 fe-common + fe-filesystem-hdfs,超出 D-005,故独立。 +- **影响**:设计 §3.5(新增 Kerberos 节)+ 依赖图(加 fe-kerberos 叶子);tasks 新增 P3;risks 补「kerberos 三处漂移」项。 + +## D-006 — MetaStore 后端「类型」用 Provider 自识别,api 层不放 per-backend 枚举 +- **日期**:2026-06-17 | **决策者**:用户(修正 D-001/设计 §3.1 初版的 `MetaStoreType` 枚举) +- **内容**:`fe-connector-metastore-api` 的 `MetaStoreProperties` 接口**不放 per-backend `MetaStoreType` 枚举**;后端标识用 **`String providerName()`**,横切行为用**能力方法**(如 `boolean needsStorage()` / `needsVendedCredentials()`)表达。新增 `fe-connector-metastore-spi` 的 **`MetaStoreProvider

    ` SPI**(`boolean supports(Map)` 自识别 + `bind(raw, storageList)`),经 `META-INF/services` + ServiceLoader 发现,**镜像 `FileSystemProvider`**。新增后端(Glue/S3Tables/自定义)= 新模块 + 新 provider + 一行 services 文件,**api/spi 零改动、无中心 switch**。唯一旧消费者 `VendedCredentialsFactory:61` 的 `getType()` switch 用能力方法替代。 +- **理由**:把 per-backend 枚举放进 api 会**原样继承**旧 `MetastoreProperties.Type` 的脆性(每加后端改 api 枚举 + 找全散落 switch、漏一个无编译期报错);fe-filesystem 已用 provider 模式干净解决同一问题,连 `FileSystemType` per-backend 枚举顶上都挂着官方「加类型要改多处、易错」的反模式 TODO。**高层 category 枚举(如 `StorageKind`)可留**(封闭小集 + 承载横切行为),但 metastore 当前无此需要,能力方法足矣。 +- **影响**:设计 §3.1(重写 type() 部分为 provider 模型)/ §3.2(加 `MetaStoreProvider` SPI + ServiceLoader);tasks `P2-T01` 改写(去枚举、加 provider);**修正 D-001** 措辞中的「MetaStoreType 枚举」。 + +## D-005 — 本次任务范围:纯新增/迁移,只动 paimon +- **日期**:2026-06-17 | **决策者**:用户 +- **内容**:本次任务 **不删除** fe-core `datasource.property.{storage,metastore}` 任何类;**不修改** hive/hudi/iceberg/es/jdbc/mc/trino 连接器;`fe-property` 仅断开 paimon 依赖边(变孤儿),**不物理删**;import gate 不收紧。Phase 3+ 及 fe-core 两包的最终删除属范围外(后续全连接器迁完再做)。 +- **理由**:保持改动外科化、可回滚;隔离 paimon 的迁移风险,不波及在用的 hive/hudi/iceberg。 +- **影响**:设计文档 §0.1 / §4(Phase 1/2 改为 additive、删除 Phase 3+ 与 fe-core 删除步骤)/ §6。 + +## D-004 — typed MetaStore 属性复用 fe-foundation @ConnectorProperty 绑定 +- **日期**:2026-06-17 | **决策者**:用户 +- **内容**:新 MetaStore 属性模型用 `@ConnectorProperty` + `ConnectorPropertiesUtils` 注解绑定(别名优先级/required/sensitive/matchedProperties 全免费),镜像 fe-filesystem StorageProperties 做法。 +- **理由**:消除 paimon 手抄的 `String[]` 别名数组 + `firstNonBlank`;单一真相源。 +- **影响**:设计 §3.2(typed holder)。 + +## D-003 — fe-core 绑定 Storage,经 ConnectorContext 下发已解析的 StorageProperties +- **日期**:2026-06-17 | **决策者**:用户(修正 agent 初版"混合 Option C") +- **内容**:CREATE CATALOG 入口在 fe-core;fe-core 用 fe-filesystem(全量+providers)绑定 `StorageProperties`,经 `ConnectorContext.getStorageProperties(): List`(fe-filesystem-api 类型)传给连接器;连接器只见 api 接口,调 `toHadoopProperties()/toBackendProperties()`。动态/RPC(vended creds、URI 归一、thrift `TS3StorageParam`)留 fe-core 经 ConnectorContext 委托。 +- **理由**:provider 发现(ServiceLoader)集中 fe-core;连接器 classpath 无需 providers、只依赖 fe-filesystem-api 接口——精确满足"fe-connector 仅依赖 fe-filesystem-api";比"连接器自调静态门面"(agent 初版 Option C)更干净。 +- **影响**:设计 §2 / §3.2 / §4 P1-1,P1-2。**取代** agent 初版的静态门面方案(无需给 fe-filesystem-api 加 `buildObjectStorageHadoopConfig` 等价静态门面)。 + +## D-002 — 去重策略:混合(共享后端 fact 解析器 + 薄 per-connector adapter) +- **日期**:2026-06-17 | **决策者**:用户 +- **内容**:HMS/DLF/Glue/REST/JDBC 的"连接事实解析器"在 `fe-connector-metastore-spi` 实现**一次**(含 JDBC DriverShim);每个连接器只写薄 catalog adapter 消费 facts 建各自 SDK catalog。 +- **理由**:最大化去重(HMS 后端现被复制约 4 次、DLF 8-key 块 3 次、DriverShim 2 次),又不把引擎 SDK 塞进共享层。 +- **影响**:设计 §3.2 / §3.3。 + +## D-001 — 新建 fe-connector-metastore-api + fe-connector-metastore-spi 模块对 +- **日期**:2026-06-17 | **决策者**:用户 +- **内容**:MetaStore Property SPI/API 放在新建的模块对,依赖 fe-foundation + fe-filesystem-api,镜像 fe-filesystem 的 api/spi 拆分;不折叠进现有 fe-connector-api(其极简,仅 fe-thrift provided)。 +- **理由**:metastore 模型需要 @ConnectorProperty 绑定(fe-foundation)+ StorageProperties 入参(fe-filesystem-api),不应污染 fe-connector-api 的消费契约层。 +- **影响**:设计 §0 / §3.1 / §3.2。 diff --git a/plan-doc/metastore-storage-refactor/deviations-log.md b/plan-doc/metastore-storage-refactor/deviations-log.md new file mode 100644 index 00000000000000..cc5e59ebc7a2f3 --- /dev/null +++ b/plan-doc/metastore-storage-refactor/deviations-log.md @@ -0,0 +1,107 @@ +> # 🔒 本子线已彻底 CLOSED(2026-06-22 收官,用户确认) +> +> **「属性体系重构」子项目(Storage→fe-filesystem / MetaStore→fe-connector SPI,paimon 优先)已全部完成并合入主线** —— 核心任务 15/15 + docker 真闸全过;产出 `fe-kerberos` / `fe-connector-metastore-api` / `fe-connector-metastore-spi`(含 `MetaStoreProviders.bind` + 5 provider)+ 删除 `fe-property` 孤儿模块;paimon 连接器已 cutover 到共享 SPI。合入提交:`#64446`(paimon SPI+翻闸)/ `#64653`(P5-T29 删 legacy)/ `#64655`(P3b kerberos 收口 `e5959e1b53d`)。 +> +> **⛔ 后续任务(含主线 P6/P7)请勿再阅读本目录的规划/接力文档** —— 它们是已结束工作的历史留存,不再维护。需了解 metastore-spi / `MetaStoreProviders.bind` 现状请**直接读代码**:`fe/fe-connector/fe-connector-metastore-spi/`。主线接力见 [`../HANDOFF.md`](../HANDOFF.md)。 + +--- + +# 偏差日志(DV,append-only,时间倒序) + +> 编号 `DV-NNN` **仅在本子项目内有效**,与上层 `../deviations-log.md` 独立。 +> 规则(沿用 ../README §4.3):原设计在落地中发现不可行/不必要时,**先**在此顶部记录,**再**改设计文档;禁止 silently 改设计。 +> 每条格式:`DV-NNN`、日期、原计划位置(设计 §x / task Pn-Tnn)、为何不可行、新方案、影响范围。 + +--- + +## DV-010 — P3b-T01 commit 3 接口统一 = 「pure consolidation」(直接换 fe-kerberos impl,无 IOCallable adapter)+ empty-`hadoop.username` 规整 +- **日期**:2026-06-21 | **原计划位置**:D-007 / tasks `P3b-T01` commit 3「统一两个 `HadoopAuthenticator` 接口为单接口 **+ 消费侧 adapter**」。 +- **为何偏差(对照真实代码 + 用户 AskUserQuestion 确认)**: + 1. **无显式 adapter**:scope 文案预期消费侧 `IOCallable→PrivilegedExceptionAction` adapter。实测 4 消费方调用点皆 `authenticator.doAs(() -> ioStuff)`,lambda 抛 `IOException ⊆ Exception` → **自然绑定** fe-kerberos `doAs(PrivilegedExceptionAction)`,且 fe-filesystem-spi `HadoopAuthenticator`(IOCallable)+`IOCallable` 经 grep 实证**仅 fe-filesystem-hdfs 消费、0 外部** → 直接删 spi 接口 + 换 fe-kerberos 单接口即「单接口」,无需 adapter 层(Rule 2)。"单接口" = fe-kerberos 版胜出(27+ 消费方真相源),fe-filesystem-spi 版删除。 + 2. **pure consolidation(用户 AskUserQuestion 选)**:两 kerberos impl 行为不等价(fe-kerberos=LoginContext+80%-refresh+unconditional setConfiguration;hdfs 副本=`loginUserFromKeytabAndReturnUGI`+per-call relogin+first-writer-wins guard)。定**采纳 fe-kerberos 行为**(恢复与 legacy fe-common/HMS HDFS 路 parity;真闸 docker kerberos e2e),非保留 hdfs 副本行为。接受变更:simple/无 username→remote user "hadoop"(旧=FE 进程用户直跑)。 + 3. **gating 决策保不变**:`DFSFileSystem.buildAuthenticator` 仍用 `isKerberosEnabled`(principal+keytab 在)选 kerberos-vs-simple,**不**改用 fe-kerberos `getHadoopAuthenticator(conf)` 的 `hadoop.security.authentication==kerberos` gate(否则缺该键的 kerberos catalog 被误判 simple = 额外回归)。 + 4. **empty-string 规整(对抗 review `wf_b1a4e7e4-b51` 揪出,confirmed-bytecode)**:fe-kerberos `HadoopSimpleAuthenticator` 仅 `username==null` 才默认 "hadoop",`""` 透传到 `UserGroupInformation.createRemoteUser("")` → 抛 `IllegalArgumentException("Null user")`(hadoop-common 3.4.2 bytecode 实证)→ FS 构造崩。旧副本有 `!isEmpty()` 守。**buildAuthenticator 现统一 absent/empty→默认 "hadoop"**(`if (username != null && !username.isEmpty())` 才 setUsername)。RED 实证(neuter guard → `IllegalArgumentException: Null user`)→ GREEN。 +- **新方案**:见 tasks `P3b-T01` commit 3 完成态。 +- **影响范围**:仅 `fe-filesystem-hdfs/**` + `fe-filesystem-spi/**`(删 2 spi 文件 + pom description scrub)+ 透明 doc-scrub `fe/fe-filesystem/README.md`(聚合层,spi 删 HadoopAuthenticator 的直接后果,mirror P1-T07 stale-comment 先例)。最终态仍是 D-007 收口(三处 kerberos 实现合一)。⚠️ docker kerberos e2e 真闸 NOT run。 + +## DV-009 — P3b-T01 commit 排序:trino→JDK 先做(原地 in fe-common),不放最后;relocate 是 commit 2 +- **日期**:2026-06-21 | **原计划位置**:D-017 / tasks `P3b-T01` scope 粒度 (B) 分步——AskUserQuestion「Phased」选项文案把三步列为 (1) relocate (2) 统一接口 (3) trino→JDK(trino 最后)。 +- **为何偏差(对照真实代码确认)**:12 类机制**必须一起搬**(`HadoopAuthenticator.getHadoopAuthenticator(AuthenticationConfig)` 工厂耦合 `HadoopKerberosAuthenticator`/`HadoopSimpleAuthenticator`,拆分会造成跨模块 split-package)。若把 trino→JDK 放在 relocate **之后**,则 relocate 那一步会把 `HadoopKerberosAuthenticator` 的 `io.trino...KerberosTicketUtils` 依赖**带进 fe-kerberos**(干净叶子),迫使 fe-kerberos 临时声明 `trino-main` 依赖——违背「fe-kerberos 是中立轻叶子」。 +- **新方案**:重排为 **commit 1 = trino→JDK 原地替换(仍在 fe-common,1 文件 + 新 `KerberosTicketUtils` JDK 副本 + 测试)→ commit 2 = relocate 全 12 类到 `org.apache.doris.kerberos.*` + 重指向 import + 合并 AuthType + 接 hadoop 依赖 → commit 3 = 统一两个 `HadoopAuthenticator` 接口 + 删 hdfs 副本**。三步仍各自独立 commit(满足用户「Phased」本意),且 fe-kerberos 全程不沾 trino。 +- **影响范围**:不改最终态(仍是 D-017 的收口);commit 1 仅碰 `fe/fe-common/.../security/authentication/{HadoopKerberosAuthenticator.java(改 import),KerberosTicketUtils.java(新),KerberosTicketUtilsTest.java(新)}`,**fe-common pom 不动**(trinoconnector + IndexedPriorityQueue/UpdateablePriorityQueue/Queue 仍用 trino-main,移此 import 不能让 fe-common 弃 trino)。recon `wf_c14cb816-ed9` 实证 + javap 反编译 trino `KerberosTicketUtils`(`getRefreshTime`=`start+(long)((end-start)*0.8f)`、`getTicketGrantingTicket`=私有凭据里 server==`krbtgt/REALM@REALM` 的票,否则 IAE)逐字节复刻;mutation `0.8f→0.5f` 证测试有效(`expected:<9000> but was:<6000>`)。 + +## DV-008 — P2-T03 cutover 三处对设计/任务字面的细化(别名数组只能部分删、`*MetastoreBackend.parse`→`MetaStoreProviders.bind`、新增 `assembleHiveConf` 助手) +- **日期**:2026-06-18 | **原计划位置**:tasks `P2-T03`(「删 `PaimonConnectorProperties` 别名数组」「调共享 `*MetastoreBackend.parse`」)/ 设计 §3.3(adapter 内联 `new HiveConf()+base+overrides.forEach`)。 +- **为何偏差(对照真实代码 + recon `wf_9437dd4e-06d` verify 确认)**: + 1. **别名数组只能部分删**:任务 header 写「删别名数组」,但 `HMS_URI`/`REST_URI`/`JDBC_URI`/`JDBC_USER`/`JDBC_PASSWORD`/`JDBC_DRIVER_URL`/`JDBC_DRIVER_CLASS`/`CLIENT_POOL_*`/`LOCATION_*` 仍被**保留的** `buildCatalogOptions`/`append*Options`(paimon SDK Options 组装,非元存储连接)+ `PaimonScanPlanProvider`/`PaimonConnector` 的 JDBC 注册消费。**只有** `DLF_*`/`REST_TOKEN_PROVIDER`/`REST_DLF_*`(仅被删掉的 validate/buildDlfHiveConf 用)可删。 + 2. **`*MetastoreBackend.parse` 已被 P2-T02 的 provider 模式取代**:设计 §3.2 早期写 `Hms/DlfMetastoreBackend.parse(...)`,但 D-006 改为 `MetaStoreProvider.supports()`+`MetaStoreProviders.bind`;P2-T03 调 `bind` 非 `parse`(无中心 switch)。 + 3. **新增薄 `PaimonCatalogFactory.assembleHiveConf(base, overrides)`**:设计 §3.3 把 `new HiveConf()`+base+`overrides.forEach` 内联在 `createCatalog`。为离线可测 F2「base→overrides 覆盖」顺序(`createCatalog` 连真 metastore 无法离线测),抽成纯静态助手(Maps in, HiveConf out),HMS/DLF 两分支共用。非连接逻辑(纯组装),留连接器侧。 +- **新方案**:删 `DLF_*`/`REST_TOKEN_PROVIDER`/`REST_DLF_*` only;`MetaStoreProviders.bind` 派发;`assembleHiveConf` 助手承载 F2 layering(+2 UT)。**关键不变量保持**:HiveConf 内容 parity(content 由 spi `toHiveConfOverrides`/`toDlfCatalogConf` 产,spi 13+7 测钉)、F2 base-before-overrides(assembleHiveConf + `PaimonHmsConfResWiringTest` seam 测)。 +- **影响范围**:`PaimonCatalogFactory` 保留 `buildCatalogOptions`/`append*Options`/`buildHadoopConfiguration`/`applyStorageConfig`/`resolveFlavor`/`firstNonBlank`(+新 `assembleHiveConf`);`PaimonConnectorProperties` 保留非-DLF/REST-DLF 常量;设计 §3.3 待加(DV-008 修订)脚注「adapter 用 assembleHiveConf 助手、Options 组装留连接器」。不影响 T2 parity。 + +## DV-007 — P2-T02 共享 parser 的 storage 入参用中立 `Map storageHadoopConfig`,**不**用 `List`;spi 不依赖 fe-filesystem-api +- **日期**:2026-06-18 | **原计划位置**:设计 §3.2(`*MetastoreBackend.parse(Map raw, List storage)`)/ tasks `P2-T02` header(「依赖 metastore-api + fe-foundation + **fe-filesystem-api**」)/ WORKFLOW §4.2(「新模块 metastore-api/spi 只可依赖 fe-foundation + fe-filesystem-api」)。 +- **为何偏差(recon + 用户定夺 AskUserQuestion)**:recon(report A §6 / report D §4)证:①paimon 现有 up-move 源(`buildHmsHiveConf`/`buildDlfHiveConf`)已经吃**预算好的中立 `Map storageHadoopConfig`**(由 `PaimonConnector.buildStorageHadoopConfig` 从 `ctx.getStorageProperties().toHadoopProperties().toHadoopConfigurationMap()` 合并),**不**在 parser 内迭代 `StorageProperties`;②metastore-api 契约只在 javadoc 提 `StorageProperties`、签名零引用 → spi 用中立 Map 即可保持 **hadoop/fs-free**(零 fe-filesystem-api 依赖,最小化模块依赖面 + 无 classloader 面)。**关键不变量保持**:storage 叠加保序 + kerberos-在-storage-之后 由 **parser 拥有**(parser 收 storageHadoopConfig,在 `toHiveConfOverrides()`/`toDlfCatalogConf()` 内部按序 overlay)。 +- **新方案(用户 2026-06-18 选「Neutral Map」)**:`MetaStoreProvider.bind(Map props, Map storageHadoopConfig)` + `MetaStoreProviders.bind(raw, storageHadoopConfig)`;spi pom **不**含 fe-filesystem-api。P2-T03 paimon adapter 把现有 `buildStorageHadoopConfig()` 产物喂进来(连接器侧仍调 `ctx.getStorageProperties()`,是 ctx SPI 调用,本就连接器侧)。**被否**:`List`(设计字面,typed 边界,但引入 fe-filesystem-api 依赖 + parser 重复 `toHadoopConfigurationMap` 迭代,对 P2-T02 无收益——storage 已在连接器算好)。 +- **影响范围**:spi pom 依赖集(−fe-filesystem-api);parse 签名(storage = Map 非 List);设计 §3.2 待加(DV-007 修订)脚注;WORKFLOW §4.2 spi 允许依赖集实际为 metastore-api + fe-foundation + fe-extension-spi + fe-kerberos(fe-filesystem-api 未用)。不影响 P2-T03 之后若需 typed 边界再加。 + +## DV-006 — P2-T02 不在 fe-kerberos 增量补 hadoop authenticator 机制子集(HANDOFF/task 原写「此处增量补」被证伪);fe-kerberos 仅作 compile 依赖、零新代码 +- **日期**:2026-06-18 | **原计划位置**:HANDOFF「下一步 P2-T02」+ tasks `P2-T02` 原 header(「**此处增量补 fe-kerberos authenticator 机制子集**[hadoop-auth/hadoop-common 依赖 + trino `KerberosTicketUtils`→JDK 替换]——`HmsMetastoreBackend` 产出 `KerberosAuthSpec` 需要它」)/ P3a-T01 续;设计 §3.5 步骤 a。 +- **为何偏差(recon 三重取证 + 直接核实真实代码)**:HANDOFF 断言「产出 `KerberosAuthSpec` 需要 authenticator 机制」**证伪**—— + 1. `KerberosAuthSpec`(commit `51df4fccd01`)是**两 String 不可变值对象**(`KerberosAuthSpec.java:28-29` 明示零 hadoop 类型/不登录);产出它 = `new KerberosAuthSpec(clientPrincipal, clientKeytab)`,`AuthType.fromString(...)` 纯函数(`AuthType.java:52-57`)。**纯 String→值对象,零 hadoop**。 + 2. paimon 连接器 parse-time 只把 kerberos 键当**普通字符串**塞进 HiveConf(`PaimonCatalogFactory.java:438-440` 注释明示「legacy additionally built a HadoopAuthenticator from them;这里只携带 auth keys」`:465-470,:489-513`),自身**从不**构造 UGI/authenticator。 + 3. 真正的 `UGI.doAs` 机制由 **FE 侧**构建、**storage-derived**:`DefaultConnectorContext.executeAuthenticated`→`authSupplier.get().execute(task)`(`:136-138`),authenticator 来自 `PluginDrivenExternalCatalog:136-137`(storage props 建 `HadoopExecutionAuthenticator`);全部 UGI 机器(`HadoopKerberosAuthenticator`/`UserGroupInformation`/`Krb5LoginModule`)在 **fe-common**(已带 hadoop classpath)。fe-kerberos 不参与 doAs。 +- **新方案(用户 2026-06-18 选「Compile-dep only」)**:fe-kerberos **零新代码**(仅作 spi 的 compile 依赖,且经 metastore-api 已 transitive);HMS parser 产出 `getAuthType()`(`AuthType.fromString`) + `kerberos()`(`Optional.of(new KerberosAuthSpec(clientPrincipal, clientKeytab))`) + `toHiveConfOverrides()`(中立 String 键含 service principal/auth_to_local/sasl/auth=kerberos)。`fe-kerberos/pom.xml:36-38` 注的「authenticator 机制子集(D-013)增量补」推迟到**真把 FE 侧 doAs 从 fe-common 搬进 fe-kerberos** 的后续 cutover(P3b 同批),**非** P2-T02。**被否**:照 HANDOFF 字面把 hadoop-auth/hadoop-common + trino→JDK 替换搬进 fe-kerberos(speculative、parse-time 用不上、Rule 2 违反)。 +- **影响范围**:fe-kerberos **不改**(白名单 `fe/fe-kerberos/**` 本 task 不触碰);P3a-T01「authenticator 机制待续」状态不变(仍待 P3b);设计 §3.5 步骤 a 待加(DV-006 修订)脚注;不影响 T2 parity(`toHiveConfOverrides()` 串键 == legacy `HMSBaseProperties` 串键)。 + +## DV-005 — FU-T02 不新增 `credentialsProviderType` 字段(镜像 S3 的写法),改为内联镜像 legacy 基类条件 +- **日期**:2026-06-18 | **原计划位置**:task `FU-T02` / D-011(「给 `Oss/Cos/ObsFileSystemProperties` 加 `credentialsProviderType` 字段(镜像 `S3FileSystemProperties`)」)。 +- **为何不可行/不必要**:现场 recon(对照 `fe-core .../datasource/property/storage`)证伪「镜像 S3 字段」是正确做法—— + 1. **legacy OSS/COS/OBS 没有可配置的 provider type**:`OSSProperties/COSProperties/OBSProperties` 均**不** override `AbstractS3CompatibleProperties.getAwsCredentialsProviderTypeForBackend()`(:124-129),该基类仅在 **ak/sk 皆空**时返回 `ANONYMOUS`、否则 `null`(省略)。只有 `S3Properties` override(:308 恒非空,故 S3 typed 才有字段)。加可配置字段会引入 legacy 不存在的旋钮,并可能对**有凭据** catalog 误发 `DEFAULT`(D-011/FU-T02 验收明确「**非**无条件 DEFAULT」)。 + 2. **`S3CredentialsProviderType` 枚举在 `fe-filesystem-s3`**,而 `fe-filesystem-{oss,cos,obs}` **不依赖** s3 模块(仅 `fe-filesystem-spi`)→ 复用须移类到 api/spi = 扩白名单 + AskUserQuestion。recon 结论是**反过来**:根本不需要共享类型。 +- **新方案**:在每个 `toBackendKv()` 末尾**内联**镜像 legacy `doBuildS3Configuration`(:117-120)——`StringUtils.isBlank(accessKey) && StringUtils.isBlank(secretKey)` 时 `kv.put("AWS_CREDENTIALS_PROVIDER_TYPE", "ANONYMOUS")`,否则省略;字面量 `"ANONYMOUS"` == `AwsCredentialsProviderMode.ANONYMOUS.name()`。**仅** BE map(`toBackendKv`),**不**碰 `toHadoopConfigurationMap`(legacy 该键只进 `getBackendConfigProperties`)。无字段、无枚举、无跨模块依赖、无白名单扩展、无 AskUserQuestion。 +- **影响范围**:FU-T02 实现仅落 `fe-filesystem-{oss,cos,obs}/src/main` 各 +5 行 + test 各 +2 断言;与原 D-011 **功能等价且更贴 legacy**(更简、更外科,CLAUDE.md Rule 2/3/7);不影响 S3 typed(已有字段,行为不变)、不影响 R-006/FU-T03。**R-008 闭环**。 + +## DV-004 — P1-T04 BE 凭据全量切 typed 路会丢 HDFS BE 键(fe-filesystem 无 HDFS typed BE model)→ 用户定「按原计划全切、接受 HDFS 回归、follow-up 补 fe-filesystem HdfsFileSystemProperties」 +- **日期**:2026-06-17 | **原计划位置**:设计 §5 T1 / WORKFLOW §5.2 T1 / **DV-002**(「P1-T03/T04 全量切换 fe-filesystem,含 P1-T04 BE 凭据也切 `toBackendProperties().toMap()`」,隐含与 P1-T03 同源等价);task `P1-T04`。 +- **为何偏差(现场 recon 取证,对照真实代码)**:新 typed 路对 **HDFS 物理上产不出 BE 键**—— + - **fe-filesystem 无 HDFS typed BE model**:`HdfsFileSystemProvider implements FileSystemProvider`(基接口泛型,**无**具体 `HdfsFileSystemProperties` 类),**未** override `bind()` → 用 `FileSystemProvider.bind()` 默认实现 `throw UnsupportedOperationException("...does not support typed FileSystemProperties binding yet")`;`FileSystemPluginManager.bindAll` **catch 该异常并跳过** → `getStorageProperties()` 对 HDFS-warehouse catalog 返回**空** → `toBackendProperties().toMap()` 链无 HDFS 项。 + - **legacy 路有 HDFS**:`getBackendStorageProperties()`(`DefaultConnectorContext:203` → `CredentialUtils.getBackendPropertiesFromStorageMap` → fe-core `HdfsProperties.getBackendConfigProperties:198`)发 HDFS 的 `hadoop.*/dfs.*/HA/kerberos` 键。 + - **这些键 load-bearing**:经 `PluginDrivenScanNode.getLocationProperties`(去 `location.` 前缀) → `FileQueryScanNode.setLocationPropertiesIfNecessary` → `HdfsResource.generateHdfsParam(locationProperties)` → `THdfsParams`(namenode/HA/kerberos)。故全量切丢 HDFS BE 配置 → **HDFS-warehouse paimon 原生读回归**(解析不了 HA nameservice / 无 kerberos)。对象存储侧两路等价(typed 为超集,DV-002)。 +- **关键事实**:`getBackendStorageProperties()` 是 **`ConnectorContext` SPI 方法、不依赖 fe-property**(连接器只 import `connector.spi.ConnectorContext`),故 **P1-T05 断 paimon→fe-property 边并不需要本切换**;切换纯为 D-003「连接器只见 fe-filesystem-api 的统一 typed 消费」架构收益,而该收益对 HDFS 物理做不到(除非动 fe-filesystem,超 P1 白名单)。 +- **新方案(用户 2026-06-17 定)**:**按原计划全切**——对象存储走 typed 路 `getStorageProperties()→toBackendProperties().toMap()`(`.ifPresent` 跳过无 BE 项,镜像 P1-T03 风格),HDFS 暂丢;**接受 HDFS BE 回归**,后续由用户**补 fe-filesystem `HdfsFileSystemProperties` typed BE model** 修复(记 **follow-up FU-T01** + **R-007**)。代码注释标 `KNOWN GAP (DV-004 / R-007)`。**被否**:(a) 保留 `getBackendStorageProperties`(最安全、零回归,但放弃 D-003 统一);(b) 混合两路(对象存储 typed + HDFS legacy,多代码、要管叠加顺序/防重复,无功能收益)。 +- **影响范围**:P1-T04 实现(`PaimonScanPlanProvider` 静态凭据块 + 注释)与测试(`scanContext` 改喂 `getStorageProperties()` 的 fake `StorageProperties` + 新 `fakeBackendStorage` helper);新增 **R-007** + **follow-up FU-T01**(tasks 占位);**P1-T06 docker HDFS flavor 会暴露此回归**(须知晓为**已接受、非新 bug**);不影响对象存储 flavor、不影响 P2/P3a。`getBackendStorageProperties()` SPI default 方法**保留**(连接器停止调用,移除非「新增」、超 P1-T04 范围,留作 follow-up 清理)。 + +## DV-003 — T1 自动等价测试不可在 UT 落地 → 改「connector-local 契约 UT + docker 兜底」;并连带 P1-T03 提前删 fe-property import + 删 ~23 canonical 测试 +- **日期**:2026-06-17 | **原计划位置**:设计 §5 T1 / WORKFLOW §5.2 T1(DV-002 框架:「新 `toHadoopConfigurationMap()` 与旧 `buildObjectStorageHadoopConfig` 在常见静态凭据路径 key/value **全等**」作为切换回归闸);task P1-T03、P1-T05。 +- **DV-003-a(T1 落地形态,用户 2026-06-17 选 Option C)**: + - **为何不可行(现场 recon 取证)**:算 T1「新产物」须绑真 fe-filesystem 对象存储 `StorageProperties`,而其 impl 模块(`fe-filesystem-{s3,oss,cos,obs}`)是 `Env.loadPlugins` **运行时目录插件**——`fe-core` pom 注「impl 运行时依赖在 Phase 4 P4.1 移除」(仅留 `fe-filesystem-local` test-scope)、paimon 从无。故 **fe-core 与 paimon 任一单测 classpath 都绑不出**真 S3/OSS/COS/OBS fe-filesystem 实例 → 字面 key/value 等价测试无法在 UT 写。强行把 impl 拖进单测 classpath 既破"impl 仅运行时"架构,又冒本仓历史反复出现的 paimon 跨 loader / classpath 中毒风险。 + - **新方案(Option C)**:paimon UT **只钉 connector-local 契约**(合成 storage map → 落 conf/HiveConf + `paimon.*` 改键 + 原始 `fs./dfs./hadoop.` 透传 + **last-write-wins** + kerberos-在-storage-叠加-之后);**真 key/value 等价由 P1-T06 docker 5-flavor 兜底**;P0-T01 4-agent recon + DV-002 的 code-read 等价(fe-filesystem ⊇ fe-property 超集差异已记)为依据。**修订 DV-002 的「自动 key/value 全等 UT」→「契约 UT + docker 闸」**。 + - **被否选项**:(B) paimon 内加 fe-filesystem impl test-scope 依赖自建等价测试 = transient(P1-T05 paimon 弃 fe-property 即须删)+ 重复 SDK 依赖 + 与 paimon 自带 hadoop-aws classpath 冲突风险;(A) fe-core companion 等价测试 = 同样须把运行时-only impl 拖进 fe-core test classpath + 扩 fe-core 白名单(新测试文件 + test-scope pom)。两者都把运行时插件拖进单测,user 否。 +- **DV-003-b(import 顺序连带)**:`PaimonCatalogFactory` 的 `org.apache.doris.property.storage.StorageProperties` import **仅** :393 一处用(`buildObjectStorageHadoopConfig`)。P1-T03 删该 call 即孤立 import → checkstyle 报未用 import → **P1-T03 必同删 import**(原 P1-T05 计划"删 :20 import")。**P1-T05 退化为仅删 pom `fe-property` 依赖边 + `grep org.apache.doris.property` 归零闸**(call/import 已在 T03 清)。 +- **覆盖核对(删 canonical 测试)**:现 `PaimonCatalogFactoryTest` ~23 个 S3/OSS/COS/OBS/MinIO canonical 翻译测试测的是 fe-filesystem 现职责。**对抗 review(`wf_76df09a4-c2f`,8 agent,1 BLOCKER+3 MAJOR+2 MINOR;verify 推翻 BLOCKER[删 buildHmsHiveConf 重载=唯 paimon 调用方全已改]+2 MAJOR[endpoint-pattern/OSS-derivation 经核实 fe-filesystem 已覆盖])+ 直接核实**:fe-filesystem 覆盖 **canonical 键翻译 + endpoint-from-region 派生**(`S3FileSystemPropertiesTest.toHadoopConfigurationMap`、`OssFileSystemPropertiesTest:108-110`、Cos/Obs),**但 NOT 覆盖调优默认值**(S3 50/3000/1000、OSS/COS/OBS 100/10000/10000)→ 删 paimon tuning 测试丢了**显式 UT 守护**(功能今日正确=fe-filesystem 字段默认真发;测试健壮性缺口)→ **记 R-006**(docker P1-T06 兜底 + fe-filesystem 加断言 follow-up,超白名单)。**初判「已全覆盖」修正为「键翻译+派生已覆盖、调优默认未守护」。** +- **影响范围**:P1-T03 实现与测试改造、P1-T05 范围缩减;设计 §5 T1 / WORKFLOW §5.2 T1 待回写(DV-003 脚注);risks R-001 缓解更新(自动 UT 闸 → docker 闸)。不影响 P2/P3a。 + +## DV-002 — T1 等价性从「全等」放宽为「常见静态凭据路径全等 + 文档记超集」 +- **日期**:2026-06-17 | **原计划位置**:设计 §5 T1 / §6.4 验收 item 4 / WORKFLOW §5.2 T1("新 == 旧 key/value **全等**")。 +- **为何不可行(P0-T01 取证)**:fe-filesystem `toHadoopConfigurationMap()`/`toBackendProperties().toMap()` 是 paimon 现走 fe-property 路(`buildObjectStorageHadoopConfig`)的**超集**,非全等: + - **S3**:fe-filesystem 加 assume-role 分支(`fs.s3a.assumed.role.*`)+ 无 AK 时 anonymous/default `fs.s3a.aws.credentials.provider`;fe-property base 二者皆无。 + - **OSS/COS/OBS**:配置齐时一致(jindo/cosn/obs 块都在),但 fe-filesystem `fs.s3a.endpoint`/`.region` **无条件**发(`cfg.put`)vs fe-property **懒发**(仅非空时)。 + - **BE map**:fe-filesystem `toMap()` 多 `AWS_BUCKET`/`AWS_ROOT_PATH`/`AWS_CREDENTIALS_PROVIDER_TYPE`。 + - 均为 fe-filesystem 更完整的**有意设计**,非 bug。故字面「全等」测试必红。 +- **新方案(用户 2026-06-17 定 A)**:认 fe-filesystem 为**新事实源**。**T1 = 常见静态凭据路径**(S3/OSS/COS/OBS 配齐 endpoint/region/AK/SK,无 role、无 vended)下各后端 key/value **全等**(含调优默认分叉 S3=50/3000/1000 vs 其它 100/10000/10000)+ **文档明记超集差异为「有意、更完整」**。P1-T03/T04 全量切换 fe-filesystem(含 P1-T04 BE 凭据也切 `toBackendProperties().toMap()`)。 +- **影响**:设计 §5 T1 / §6.4 / WORKFLOW §5.2 T1 加(DV-002 修订)脚注;risks R-001 缓解更新;P1-T03/T04 的 T1 测试钉常见路径全等 + 注释超集(对照 fe-property 现产物)。 + +## DV-001 — P0-1 预期「fe-filesystem-api 已够用、无需门面」被证伪:缺 raw map → List 的 bind-all 入口 +- **日期**:2026-06-17 | **原计划位置**:设计 §4 P0-1 / §2.1 / 决策 D-003;task P0-T01;WORKFLOW §4.1 路径白名单("唯一 fe-core 改动 = DefaultConnectorContext")。 +- **为何不可行(取证)**: + - fe-filesystem `org.apache.doris.filesystem.properties.StorageProperties` 是**纯接口、无静态工厂**(无 `createAll`)。绑定靠各 `FileSystemProvider.bind(Map)`。 + - 仓内**不存在**任何「raw map → `List`」聚合入口:`FileSystemPluginManager.providers` 私有,唯一出口是**首个命中**的 `createFileSystem`(返回 `FileSystem`,不是 StorageProperties,且非全量);`FileSystemFactory.getProviders()` 包级私有且仅 ServiceLoader。 + - `DefaultConnectorContext` 当前**只持有 fe-core typed map 的 supplier**(`Map`),不持有 raw map;fe-filesystem 是**另一族** StorageProperties。raw map 可经现有 supplier 值的 `getOrigProps()`(fe-core `ConnectionProperties` 公有 getter)取回,**无需改构造点**;但**绑定步骤**仍需新代码。 + - 结论:实现 `getStorageProperties()`(返回 fe-filesystem 类型)**至少需要在 DefaultConnectorContext 之外再加一个 additive `bindAll(...)`**(fe-core `FileSystemPluginManager` 或 fe-filesystem-spi),无法塞进 `DefaultConnectorContext` 单文件 → 白名单需最小扩张。 + - 另:F1 等价性——fe-filesystem `toHadoopConfigurationMap()` 与 paimon 现走的 fe-property `buildObjectStorageHadoopConfig` 在**静态凭据常见路径全等**(COS/OSS/OBS 的 jindo/cosn/obs 块都在);fe-filesystem 为**超集**(S3 assume-role/anon 分支额外键 + OSS/COS/OBS endpoint/region 无条件 vs 懒发)。非阻塞,但确认 fe-filesystem 为新事实源,T1 钉常见路径全等 + 记超集差异。 +- **新方案(用户 2026-06-17 定向 A,记 D-009;已回写)**:在 fe-core `FileSystemPluginManager` 加 additive `public List bindAll(Map)`(镜像 `createFileSystem` 的 provider 循环,但 `bind` 全量收集而非首个命中 `create`);`DefaultConnectorContext.getStorageProperties()` 调它,raw map 经现有 supplier 值的 `getOrigProps()` 取(不碰构造点)。已回写:设计 §4 P0-1/P0-2、WORKFLOW §4.1 白名单(+FileSystemPluginManager)、decisions D-009、risks R-004、tasks P0-T01/P0-T02。 + - **A(荐)**:守 D-003 架构(连接器消费 fe-filesystem-api typed StorageProperties)。在 fe-core `FileSystemPluginManager` 加 additive `public List bindAll(Map)`(镜像 `createFileSystem`),`DefaultConnectorContext.getStorageProperties()` 调它(raw map 经 `getOrigProps()` 取,不碰构造点)。fe-core 改动 = DefaultConnectorContext + FileSystemPluginManager 两文件、均纯新增。 + - **B**:同架构,但 `bindAll` 放 fe-filesystem-spi 静态(ServiceLoader)→ fe-core 仅改 DefaultConnectorContext;代价=改 fe-filesystem-spi(同样白名单外)+ 仅见内置 provider(storage 足够)。 + - **C(更简、偏离 D-003)**:不下发 typed 对象;加 `ConnectorContext.getStorageHadoopConfig(): Map`,fe-core 用现有 typed map 单点算(与 hive/iceberg 同源、零漂移),paimon 调它。改动**确可**局限 DefaultConnectorContext 单文件;但连接器**不再**依赖 fe-filesystem-api(放弃 D-003 的「fe-connector → 仅 fe-filesystem-api」目标边)。 +- **影响范围**:P0-T01 结论、P0-T02 / P1-T02 / P1-T03 / P1-T04 的绑定机制与白名单;不影响 P2/P3a。 diff --git a/plan-doc/metastore-storage-refactor/risks.md b/plan-doc/metastore-storage-refactor/risks.md new file mode 100644 index 00000000000000..676a158743cf40 --- /dev/null +++ b/plan-doc/metastore-storage-refactor/risks.md @@ -0,0 +1,64 @@ +> # 🔒 本子线已彻底 CLOSED(2026-06-22 收官,用户确认) +> +> **「属性体系重构」子项目(Storage→fe-filesystem / MetaStore→fe-connector SPI,paimon 优先)已全部完成并合入主线** —— 核心任务 15/15 + docker 真闸全过;产出 `fe-kerberos` / `fe-connector-metastore-api` / `fe-connector-metastore-spi`(含 `MetaStoreProviders.bind` + 5 provider)+ 删除 `fe-property` 孤儿模块;paimon 连接器已 cutover 到共享 SPI。合入提交:`#64446`(paimon SPI+翻闸)/ `#64653`(P5-T29 删 legacy)/ `#64655`(P3b kerberos 收口 `e5959e1b53d`)。 +> +> **⛔ 后续任务(含主线 P6/P7)请勿再阅读本目录的规划/接力文档** —— 它们是已结束工作的历史留存,不再维护。需了解 metastore-spi / `MetaStoreProviders.bind` 现状请**直接读代码**:`fe/fe-connector/fe-connector-metastore-spi/`。主线接力见 [`../HANDOFF.md`](../HANDOFF.md)。 + +--- + +# 风险登记册(滚动状态) + +> 编号 `R-NNN` **仅在本子项目内有效**。状态:监控中 / 缓解中 / 已闭环 / 已触发。 +> 风险=可能发生(在此);问题=已发生(记在 `tasks.md` 对应 task 的 blocker)。 + +--- + +## R-001 — 新旧 Storage 配置/BE map 等价性漂移 | 状态:监控中 +- **描述**:新 `toHadoopConfigurationMap()`/`toBackendProperties().toMap()` 与 fe-core 旧 `getHadoopStorageConfig()`/`getBackendConfigProperties()` 可能在某些键/默认值上不一致。已知默认调优值分叉:S3=50/3000/1000 vs OSS/COS/OBS=100/10000/10000。 +- **影响**:paimon 读私有桶 403、Hadoop FS 行为变化、静默错误。 +- **缓解(DV-003 修订)**:T1 自动逐键 UT **不可在单测落地**(fe-filesystem 对象存储 impl 是运行时插件,不在任何单测 classpath)→ 改为 **paimon connector-local 契约 UT**(storage map 叠加/last-write-wins/kerberos-ordering)+ **docker P1-T06 5-flavor 作真等价闸**;P0-T01 4-agent recon + DV-002 code-read 等价为依据。 +- **触发判据**:docker P1-T06 任一 flavor 读私有桶 403 / 配置缺键。 + +## R-006 — 调优默认值(tuning defaults)无显式 UT 守护(P1-T03 删 canonical 测试暴露的 fe-filesystem 测试缺口)| 状态:已闭环(2026-06-18 FU-T03 完成 — `S3/Oss/Cos/ObsFileSystemPropertiesTest` 各加 `toMaps_emit*TuningDefaultsWhenNotConfigured`,断 BE map + Hadoop map 的 max-conn/req-timeout/conn-timeout 默认[S3 50/3000/1000、OSS/COS/OBS 100/10000/10000],**字面量期望值非常量**;mutation 改 4 个 `DEFAULT_MAX_CONNECTIONS` → 4 测全红证守护有效;纯 test-only,checkstyle 0) +- **描述**:P1-T03 删 paimon `buildHadoopConfigurationEmitsS3TuningDefaults` 等 canonical 测试(翻译职责移交 fe-filesystem)。对抗 review(`wf_76df09a4-c2f`)确认 + 直接核实:fe-filesystem `S3FileSystemPropertiesTest.toHadoopProperties_*` **不显式断言**调优默认值(`fs.s3a.connection.maximum=50`/`request.timeout=3000`/`timeout=1000`;line72 只设输入 `s3.connection.maximum=64` 非断默认),`Oss/Cos/ObsFileSystemPropertiesTest` 同样**零调优断言**(OSS/COS/OBS 默认 100/10000/10000)。**canonical 键翻译 + endpoint-from-region 派生 IS 已覆盖**(已核:`OssFileSystemPropertiesTest:108-110` region→`-internal` endpoint、Cos/Obs endpoint+creds),唯**调优默认值**裸奔。 +- **影响**:**功能今日正确**(`S3FileSystemProperties.toHadoopConfigurationMap()` 经字段默认 `DEFAULT_MAX_CONNECTIONS="50"` 等真发,paimon `buildStorageHadoopConfig` 正确调用);但若未来改 fe-filesystem 误删某调优默认,**无 UT 报红**(仅 docker 运行期暴露)→ 测试健壮性回归。 +- **缓解**:**docker P1-T06** 为运行期兜底;**建议 follow-up**(**超出当前 P1 白名单——fe-filesystem 禁碰**):在 `S3FileSystemPropertiesTest` + `Oss/Cos/ObsFileSystemPropertiesTest` 加调优默认断言(test-only additive)。在 fe-filesystem 收口/迁移批次或经用户批准的小补丁中做。**不在 paimon 重复断言**(Option C:paimon 无 fe-filesystem impl 于测试 classpath,合成 map 断言为同义反复,不守 fe-filesystem 默认)。 +- **触发判据**:fe-filesystem 调优默认被改且 docker P1-T06 未跑 → 静默 mis-tune。 + +## R-007 — HDFS-warehouse paimon BE 配置回归(typed BE 路无 HDFS model)| 状态:已闭环(2026-06-17 FU-T01 完成 — `HdfsFileSystemProperties` typed BE model + provider bind;UT golden parity 25/0,对抗 review `wf_5db99e32-2ad` 清场;⚠️ docker HA/kerberized 真闸在 P1-T06) +- **描述(P1-T04,DV-004)**:BE 静态凭据全量切到 `getStorageProperties()→toBackendProperties().toMap()`。fe-filesystem **无 HDFS typed BE model**(`HdfsFileSystemProvider` 未 override `bind()`→默认抛 `UnsupportedOperationException`→`bindAll` 跳过)→ HDFS-warehouse paimon catalog 的 `getStorageProperties()` 返回空 → BE 扫描分片**不再带** `hadoop.*/dfs.*/HA/kerberos` 键(legacy 经 `getBackendStorageProperties`→`THdfsParams` 发)。 +- **影响**:HDFS(尤其 **HA / kerberized**)上的 paimon **原生读失败**(解析不了 nameservice / 无鉴权);**对象存储 flavor 不受影响**(typed 路 AWS_* 等价/超集)。 +- **缓解**:**follow-up FU-T01**——给 `fe-filesystem-hdfs` 新建 `HdfsFileSystemProperties`(`implements BackendStorageProperties`,override `FileSystemProvider.bind`)让 `bindAll` 收集 HDFS 项、`toBackendProperties()` 产 BE 键。**过渡期 HDFS-warehouse paimon 为已知回归**(用户 2026-06-17 明确接受)。 +- **触发判据**:docker P1-T06 HDFS-backed flavor 读失败(**已知、非新 bug**;须与真新回归区分)。 + +## R-008 — fe-filesystem typed OSS/COS/OBS BE map 缺 AWS_CREDENTIALS_PROVIDER_TYPE(无凭据 catalog 的 ANONYMOUS 漂移)| 状态:已闭环(2026-06-18 FU-T02 完成 — `Oss/Cos/ObsFileSystemProperties.toBackendKv()` 内联镜像 legacy 基类条件:ak/sk 皆空发 `ANONYMOUS`、否则省略[DV-005,未加可配置字段];UT RED→GREEN,OSS 13/0·COS 12/0·OBS 12/0 + 全模块绿,checkstyle 0;⚠️ docker 无凭据 OSS/COS/OBS 真闸在 P1-T06) +- **描述(P1-T04 对抗 review `wf_09745716-d48` confirm MAJOR)**:fe-filesystem `Oss/Cos/ObsFileSystemProperties.toBackendKv()` **不发** `AWS_CREDENTIALS_PROVIDER_TYPE`(无该字段);legacy fe-core `AbstractS3CompatibleProperties.doBuildS3Configuration`(:117-120) 在 `getAwsCredentialsProviderTypeForBackend()` 非空时发,OSS/COS/OBS 基类(:124-129) 在 **ak/sk 皆空**时返回 `ANONYMOUS`(OSSProperties/COSProperties/OBSProperties 均不 override,仅 S3Properties override 恒非空)。S3 typed 路**有**该键(`S3FileSystemProperties:260`)。P1-T04 把 paimon BE 凭据切到 typed 路 → **无凭据 OSS/COS/OBS catalog 不再发 ANONYMOUS**。 +- **影响**:仅影响**无静态 ak/sk** 的 OSS/COS/OBS catalog(有 ak/sk 不受影响——两路都发 ak/sk → BE 短路 SimpleAWSCredentialsProvider)。BE `aws_credentials_provider_version=v2` 默认下,缺该键 → `CredProviderType::Default` → `CustomAwsCredentialsProviderChain`(探 WebIdentity/ECS/EC2 instance profile/... 最后才 anonymous)。故在带 **IAM role 的 EC2/ECS 主机**上,新路会**误取 instance 凭据**而非 anonymous + 元数据探测延迟;纯公开桶最终仍 anonymous 成功(**非硬失败**)。 +- **缓解**:**follow-up FU-T02**——给 fe-filesystem `Oss/Cos/ObsFileSystemProperties` 加 `credentialsProviderType`(镜像 `S3FileSystemProperties`),**精确 parity**=ak/sk 皆空时发 `ANONYMOUS`、否则省略(**非**无条件 DEFAULT)。超 P1 白名单(fe-filesystem 禁碰),与 FU-T01 同批/经用户批准。过渡期已知漂移。 +- **触发判据**:无凭据 OSS/COS/OBS paimon catalog 在带 IAM-role 的云主机上凭据选择异常 / 探测延迟(已知)。 + +## R-002 — 双 Storage 路径并存窗口 | 状态:监控中 +- **描述**:迁移期 fe-core 旧 storage(hive/hudi/iceberg 用)与 fe-filesystem 新 storage(paimon 用)并存;同一 catalog 若两路推出不同配置会冲突。 +- **影响**:配置/凭据不一致。 +- **缓解**:paimon **完全**切到新路(P1 全 task 完成)即隔离;本项目不动其它连接器(D-005),天然不交叉。 +- **触发判据**:paimon catalog 出现 connector 侧与 engine 侧配置分歧。 + +## R-003 — 打包 / 类加载(relocated thrift + child-first)| 状态:监控中 +- **描述**:HMS/DLF 活连接需 relocated thrift(`fe-connector-paimon-hive-shade`)build-order 在前 + child-first hadoop/aws bundling。新建/改动模块时若破坏,会重现 S3A/thrift 跨 classloader cast 崩溃(历史 bug)。 +- **影响**:docker paimon HMS/DLF flavor 运行期崩。 +- **缓解**:模块改动保持 shade build-order 与 child-first/parent-first 白名单不变;**T4 docker 5 flavor** 覆盖 HMS/DLF。 +- **触发判据**:docker HMS/DLF 启动报 ClassCastException / NoClassDefFound(thrift/S3A)。 + +--- + +## R-004 — fe-core 改动越界 | 状态:监控中(白名单 2026-06-17 +2,DV-001/D-009 二次确认) +- **描述**:本项目允许的 fe-core 改动**仅三处、均纯新增**:`DefaultConnectorContext`(+getStorageProperties)、`FileSystemPluginManager`(+bindAll)、`FileSystemFactory`(+bindAllStorageProperties,取 live manager;D-009 二次确认)。若实现时顺手碰了 `datasource.property.*` 包、这三文件的既有方法、或构造点 `PluginDrivenExternalCatalog` 即越红线。 +- **缓解**:每次提交前 `git diff --name-only` 对照 WORKFLOW §4.1 白名单;`git diff` 这三文件须只见**新增**方法,无既有方法改动;验收 §6「零改动核对」。 +- **触发判据**:`git diff` 出现 fe-core property 包、其它连接器路径、或这三文件的非新增改动。 + +## R-005 — Kerberos 三处实现漂移(D-007)| 状态:监控中 +- **描述**:kerberos 现有**三处实现**:fe-common `security.authentication.*`、fe-filesystem-hdfs 自抄 `KerberosHadoopAuthenticator`(约一年前拷贝、TGT 刷新逻辑可能已偏离)、paimon `PaimonCatalogFactory` 手抄 HMS kerberos HiveConf 键。改一处需同步三处,否则行为分叉。 +- **影响**:kerberized HMS/HDFS 鉴权行为不一致;UGI 刷新/JVM-全局锁语义分叉;安全相关静默失败。 +- **缓解**:D-007 抽 `fe-kerberos` 单一真相源;**P3a(本次)paimon 先收口**到 fe-kerberos;**P3b(follow-up)** fe-common + fe-filesystem-hdfs 全量收口并统一两个 `HadoopAuthenticator` 接口(`PrivilegedExceptionAction` vs `IOCallable`),与 hive/iceberg 同批。**过渡期(P3a 后、P3b 前)三处副本仍在**,须知晓改一处需同步。 +- **触发判据**:三处之一改动未同步导致 kerberos e2e(HMS/HDFS)行为不一致。 +- **范围注**:全量去重(P3b)改 fe-common + fe-filesystem-hdfs,超出 D-005「只动 paimon」,属 follow-up。 diff --git a/plan-doc/metastore-storage-refactor/tasks.md b/plan-doc/metastore-storage-refactor/tasks.md new file mode 100644 index 00000000000000..72884029dbddde --- /dev/null +++ b/plan-doc/metastore-storage-refactor/tasks.md @@ -0,0 +1,205 @@ +> # 🔒 本子线已彻底 CLOSED(2026-06-22 收官,用户确认) +> +> **「属性体系重构」子项目(Storage→fe-filesystem / MetaStore→fe-connector SPI,paimon 优先)已全部完成并合入主线** —— 核心任务 15/15 + docker 真闸全过;产出 `fe-kerberos` / `fe-connector-metastore-api` / `fe-connector-metastore-spi`(含 `MetaStoreProviders.bind` + 5 provider)+ 删除 `fe-property` 孤儿模块;paimon 连接器已 cutover 到共享 SPI。合入提交:`#64446`(paimon SPI+翻闸)/ `#64653`(P5-T29 删 legacy)/ `#64655`(P3b kerberos 收口 `e5959e1b53d`)。 +> +> **⛔ 后续任务(含主线 P6/P7)请勿再阅读本目录的规划/接力文档** —— 它们是已结束工作的历史留存,不再维护。需了解 metastore-spi / `MetaStoreProviders.bind` 现状请**直接读代码**:`fe/fe-connector/fe-connector-metastore-spi/`。主线接力见 [`../HANDOFF.md`](../HANDOFF.md)。 + +--- + +# 任务清单(Pn-Tnn) + +> 状态:⬜ 未开始 | 🚧 进行中 | ✅ 完成 | ⛔ blocked。 +> 编号永不复用。每完成一个 task 按 `WORKFLOW.md §2.8` 同步文档。 +> 设计依据:`../designs/metastore-storage-property-refactor-design-2026-06-17.md`(节号见各 task)。 + +--- + +## P0 — 准备 + +### P0-T01 ✅ 确认 fe-filesystem-api 已满足连接器消费需求(recon + 定向完成) +- **做什么**:核对 `fe-filesystem-api` 的 `StorageProperties.toHadoopProperties().toHadoopConfigurationMap()` 与 `toBackendProperties().toMap()` 能覆盖 paimon `applyStorageConfig` / BE 凭据所需的全部键(S3/OSS/COS/OBS/HDFS)。 +- **验收**:列出新 api 产物 vs 现 paimon 经 fe-property 得到的键,差异清单为空或有结论(缺则记 deviation 决定补在哪)。~~**结论预期:无需给 api 加静态门面**~~(**已证伪**,见下)。 +- **依赖**:无。设计 §4 P0-1 / §2.1。 +- **结论(2026-06-17 recon,见 DV-001)**: + - **F1 等价性 = 非阻塞**:fe-filesystem `toHadoopConfigurationMap()`/`toMap()` 与 paimon 现走的 fe-property 路在**静态凭据常见路径全等**(COS 完全相同;OSS/OBS 相同;含 jindo/cosn/obs 块);fe-filesystem 为**超集**(S3 assume-role/anon 额外键;OSS/COS/OBS endpoint/region 无条件 vs 懒发)。→ 认 fe-filesystem 为新事实源,T1 钉常见路径全等 + 记超集差异。 + - **F2 可行性 = 阻塞**:**无** raw map → `List` 的 bind-all 入口(provider registry 私有,仅首个命中 `createFileSystem`)。`getStorageProperties()` **无法**只改 `DefaultConnectorContext`,须额外 additive `bindAll(...)`(fe-core `FileSystemPluginManager` 或 fe-filesystem-spi)。**白名单假设被推翻** → 需用户定向 + 最小扩张(DV-001 三选项,已 AskUserQuestion)。 +- **✅ 定向(用户 2026-06-17)**:选机制 **A**(DV-001 → D-009)——bind-all 落 fe-core `FileSystemPluginManager.bindAll`,`getStorageProperties()` 经 `getOrigProps()` 取 raw map、不碰构造点。白名单 +`FileSystemPluginManager.java`(仅新增)。P0-T01 闭环;进入 P0-T02。 + +### P0-T02 ✅ fe-core FileSystemPluginManager 新增 bindAll(raw map → List)(2026-06-17,TDD 5 绿 + checkstyle 0) +- **做什么**(D-009):在 fe-core `FileSystemPluginManager` 加 additive `public List bindAll(Map)`:遍历已注册 providers,对 `supports(props)` 命中者调 `provider.bind(props)` **全量收集**(非首个命中),返回 `List`(`FileSystemProperties extends StorageProperties`,故 bind 产物 IS-A 目标类型)。**仅新增方法,不动 `createFileSystem` 等既有方法。** +- **验收**:单测:给定 S3/OSS/HDFS 等代表性 raw props,`bindAll` 返回非空、类型正确、覆盖期望后端的列表(与 fe-core 旧 `StorageProperties.createAll` 选中的后端集合对齐);空/无匹配返回空列表不抛。`createFileSystem` 行为零回归。fe-core 旧 `datasource.property.storage` 包 + fe-filesystem 模块零改动。 +- **完成态**:`FileSystemPluginManagerTest` 加 4 个 bindAll 测试(collect-supporting / skip-non-supporting / skip-legacy-no-bind / empty),全绿(5/5,含原 registerProvider 测);`FileSystemPluginManager.java` +34 行纯新增(import + bindAll + javadoc,未动既有方法);checkstyle 0。**实证修订**:真对象存储 providers 是运行时目录插件(不在 fe-core 单测 classpath,pom 注「Phase 4 P4.1 移除 impl 运行时依赖」)→ 删除原打算的 real-S3 集成测试(移交 P1-T06 docker 全插件 classpath);bindAll 用 fake providers 钉契约。测试文件 `fs/FileSystemPluginManagerTest.java` 为白名单 bindAll 的伴随测试(合理在范围内)。 +- **依赖**:无(∥ P1-T01)。设计 §4 P0-2 / §2.1 / **D-009 / DV-001**。**红线**:仅改 `FileSystemPluginManager.java`(新增 bindAll)。 + +--- + +## P1 — paimon storage 收口到 fe-filesystem-api(纯新增/迁移) + +### P1-T01 ✅ ConnectorContext 新增 getStorageProperties()(2026-06-17,TDD 1 绿 + checkstyle 0 + import-gate PASS) +- **做什么**:`fe-connector-spi` 的 `ConnectorContext` 加 `default List getStorageProperties() { return List.of(); }`(fe-filesystem-api 类型)。pom 增 `fe-connector-spi → fe-filesystem-api`。 +- **验收**:编译通过;**这条边即"fe-connector 依赖 fe-filesystem-api"落地**;其它连接器零影响(默认空)。 +- **依赖**:无。设计 §4 P1-1 / §3.2。**红线**:仅改 `ConnectorContext.java` + `fe-connector-spi/pom.xml`。 +- **完成态**:`ConnectorContext` 加 `default List getStorageProperties() { return Collections.emptyList(); }`(fe-filesystem-api 类型,+25 行纯新增);pom 加 `fe-filesystem-api` 依赖(=「fe-connector 仅依赖 fe-filesystem-api」边落地)。新建首个测试 `ConnectorContextTest`(默认非空空列表)TDD(RED assertNotNull→GREEN 1/1)。checkstyle 0;`tools/check-connector-imports.sh` PASS(fe-filesystem-api 是纯叶子,无 fe-core/common/datasource 传递依赖)。其它连接器零影响(默认空)。 + +### P1-T02 ✅ DefaultConnectorContext.getStorageProperties() 实现(+ FileSystemFactory accessor)(2026-06-17,TDD 4 绿 + 2 回归绿 + checkstyle 0) +- **做什么**(D-009 二次确认): + 1. fe-core `FileSystemFactory` 加 additive static `bindAllStorageProperties(Map): List`:有 live `pluginManager`→`pluginManager.bindAll(map)`;否则 ServiceLoader fallback(镜像现有 `getFileSystem(Map)` 双路径)。 + 2. fe-core `DefaultConnectorContext` override `getStorageProperties()`:从现有 `storagePropertiesSupplier.get()` 取任一 fe-core typed 值的 `getOrigProps()`(= 完整 catalog raw map),喂 `FileSystemFactory.bindAllStorageProperties(rawMap)`。supplier 空(REST/vended、非 plugin ctor)→ 空列表(无静态 storage,正确)。**不改构造点。** +- **验收**:paimon catalog 下 `ctx.getStorageProperties()` 返回正确 typed 列表;hive/iceberg/其它连接器行为不变(默认空)。 +- **依赖**:P1-T01, P0-T02。设计 §4 P1-2 / **D-009**。**红线**:fe-core 仅 `DefaultConnectorContext`(+getStorageProperties)+ `FileSystemFactory`(+bindAllStorageProperties)两文件新增;bindAll 在 P0-T02 的 FileSystemPluginManager。 +- **✅ 已解(用户 2026-06-17 二次确认)**:`getStorageProperties()` 须用 live(loadPlugins 过的)manager,只能经 `FileSystemFactory` static accessor 取(构造点被禁)→ 白名单 +`FileSystemFactory.java`(D-009 二次确认)。`getOrigProps()` = 完整 raw map 已核实(`createAll(origProps)` 全量传入 + `ConnectionProperties` 整存)。 +- **完成态**:`FileSystemFactory.bindAllStorageProperties`(+32 纯新增,live manager 委托 / ServiceLoader fallback,镜像 getFileSystem)+ `DefaultConnectorContext.getStorageProperties`(+21 纯新增,getOrigProps→factory,空 supplier 短路)。TDD:4 新测试(factory 委托/fallback + ctx 空/全量绑定捕获 raw map)RED(stub UOE/NPE)→ GREEN 4/4;回归 `BackendStoragePropsTest` 2/2 + `FileSystemPluginManagerTest` 5/5 不变。checkstyle 0。3 fe-core 文件全 additive,无 property 包/构造点/其它连接器改动。 + +### P1-T03 ✅ PaimonCatalogFactory.applyStorageConfig 改走 toHadoopConfigurationMap(2026-06-17,commit `[P1-T03]`,TDD RED→GREEN,292/0/1skip + checkstyle 0 + 对抗 review) +- **做什么**:把 `fe-property StorageProperties.buildObjectStorageHadoopConfig(props)` 换成"遍历 `ctx.getStorageProperties()` 调 `toHadoopProperties().toHadoopConfigurationMap()`";**保留**其后的 `paimon.*/fs./dfs./hadoop.` 覆盖块(保序 last-write-wins)。 +- **验收**:T1 等价性测试通过(新 HiveConf/Configuration 键值 == 旧);HMS/DLF HiveConf 的 kerberos 条件键仍在 storage 叠加之后。 +- **依赖**:P1-T01(签名),调用侧需 ctx 传入(P1-T02 提供运行期值,UT 可注入)。设计 §4 P1-3 / §5 R1。 +- **现场 recon 结论(2026-06-17,对照真实代码)**: + - **ctx 可达性 = 解(无阻碍)**:3 个 `buildXxx` 调用方(`buildHadoopConfiguration` :133/:144、`buildHmsHiveConf` :166、`buildDlfHiveConf` :183)全在 `PaimonConnector.createCatalog()` 实例方法内,已持 `this.context`。→ 在 `PaimonConnector` 算好 `Map storageHadoopConfig`(遍历 `context.getStorageProperties()` 调 `toHadoopProperties().toHadoopConfigurationMap()` 合并)传入 3 个 builder;`PaimonCatalogFactory` 保持纯静态、**零 fe-filesystem-api 类型**(迭代留在 connector)。仅改 `PaimonConnector` + `PaimonCatalogFactory` 两文件(+pom 加 `fe-filesystem-api` 直接依赖)。 + - **import 顺序连带(DV-003-b)**:`org.apache.doris.property.storage.StorageProperties` 仅在 :393 用 → T03 删 call 即孤立 import → checkstyle 会红 → T03 必同删 import;P1-T05 退化为仅删 pom `fe-property` 边 + grep 闸。 + - **T1 闸 = Option C(用户 2026-06-17 选,DV-003-a)**:fe-filesystem 对象存储 impl(`fe-filesystem-{s3,oss,cos,obs}`)是**运行时目录插件**,不在任何单测 classpath(fe-core P4.1 已删、paimon 从无)→ 无法在 UT 绑真 fe-filesystem 实例算"新产物"。故 paimon UT **只钉 connector-local 契约**(合成 storage map → 落 conf/HiveConf + `paimon.*` 改键 + 原始 `fs./dfs./hadoop.` 透传 + last-write-wins + kerberos-在-storage-之后),**真等价由 P1-T06 docker 5-flavor 兜底**,P0-T01/DV-002 code-read 等价为依据。 + - **删 ~23 个 canonical-translation 测试**:现 `PaimonCatalogFactoryTest` 的 S3/OSS/COS/OBS/MinIO canonical 翻译断言测的是 **fe-filesystem 现在的职责**。**对抗 review(`wf_76df09a4-c2f`)+ 直接核实结论(修正初判)**:fe-filesystem 已覆盖 **canonical 键翻译**(`S3FileSystemPropertiesTest.toHadoopConfigurationMap`→fs.s3a.impl/endpoint/region/access.key/path.style)**+ endpoint-from-region 派生**(`OssFileSystemPropertiesTest:108-110` region→`-internal`;Cos/Obs endpoint+creds);**但 NOT 覆盖调优默认值**(S3 50/3000/1000、OSS/COS/OBS 100/10000/10000)→ 删 paimon `buildHadoopConfigurationEmitsS3TuningDefaults` 等丢了这部分**显式 UT 守护**(**功能今日正确**,由 fe-filesystem 字段默认真发;仅测试健壮性缺口)→ **记 R-006**,docker P1-T06 运行期兜底,fe-filesystem 加断言为 follow-up(超白名单)。保留并加 storage 参数的 = paimon.* 改键 / 原始透传 / last-write-wins / kerberos-ordering(含新增 storage-overlay 变体)/ DLF dlf.catalog.* 键与 endpoint-from-region(paimon-local) / hiveConfResources base-merge / socket-timeout / username alias / requireOssStorageForDlf 闸 / buildCatalogOptions / validate。 +- **完成态(2026-06-17)**:实现 = `PaimonCatalogFactory`(applyStorageConfig 收 `storageHadoopConfig` 入参替代 `buildObjectStorageHadoopConfig(props)` call、删 fe-property import、3 builder 加参、HMS 三重载并为单一 3-arg)+ `PaimonConnector`(新增 `buildStorageHadoopConfig()` 遍历 `ctx.getStorageProperties().toHadoopProperties().toHadoopConfigurationMap()` 合并、4 调用点传入、REST 不用)+ pom 加 `fe-filesystem-api` 直接依赖(fe-property 依赖**留** P1-T05 删)。TDD:neuter `storageHadoopConfig.forEach(setter)` → 3 个 Applies/Overlays 测试 RED(`expected was `)→ 恢复 → GREEN。测试改造:删 ~23 canonical(fe-filesystem 职责,R-006 调优默认缺口)+ 留 adapt + 新增 6 契约测试(3 builder 各 Applies/Overlays storage + explicit-fs.s3a-overrides-storage + paimon-prefix-overrides-storage + kerberos-survives-storage-overlay)。验证:paimon 全模块 **292/0/0/1skip**(docker-gated PaimonLiveConnectivityTest)、`PaimonCatalogFactoryTest` 42/0、checkstyle 0、import-gate PASS、白名单干净。**对抗 review `wf_76df09a4-c2f`**(8 agent,1B+3M+2m;verify 推翻 1B+2M,confirm 1M=R-006 调优默认 UT 缺口[功能正确仅测试健壮性])。⚠️ **docker e2e 未跑**(真等价 Option C 闸在 P1-T06)。**DV-003-b**:fe-property import 已在 T03 删(P1-T05 退化为仅删 pom 边 + grep 闸)。 + +### P1-T04 ✅ PaimonScanPlanProvider BE 静态凭据改走 getStorageProperties().toBackendProperties().toMap()(2026-06-17,全量切,TDD RED→GREEN,292+1/0/1skip + checkstyle 0 + 对抗 review) +- **做什么**:BE 静态凭据从 `ctx.getBackendStorageProperties()` 改为遍历 `ctx.getStorageProperties()` 调 `toBackendProperties().ifPresent(b→putAll(b.toMap()))`(镜像 P1-T03 `.ifPresent` 风格)→ 发 `location.`。vended 动态路径**不动**(仍 `ctx.vendStorageCredentials`,叠在后→vended overlays static)。 +- **验收**:T1 BE map 等价(对象存储);vended(REST/DLF) 路径回归不变。 +- **依赖**:P1-T01。设计 §4 P1-4 / §2.2。 +- **现场 recon 结论(2026-06-17,对照真实代码)**: + - **HDFS 缺口(关键发现,DV-002 未覆盖)**:新 typed 路对 **HDFS 物理上产不出 BE 键**——fe-filesystem **无 HDFS typed BE model**(`HdfsFileSystemProvider` 未 override `bind()`→默认抛 `UnsupportedOperationException`→`FileSystemPluginManager.bindAll` catch 并跳过→`getStorageProperties()` 对 HDFS-warehouse catalog 返回空)。legacy `getBackendStorageProperties()`(`DefaultConnectorContext:203`→`CredentialUtils`→fe-core `HdfsProperties.getBackendConfigProperties:198`)会发 HDFS `hadoop/dfs/HA/kerberos` 键,这些经 `PluginDrivenScanNode.getLocationProperties`→`FileQueryScanNode.setLocationPropertiesIfNecessary`→`HdfsResource.generateHdfsParam`→`THdfsParams` 是 **load-bearing**。故全量切会丢 HDFS BE 配置→HDFS-warehouse paimon 原生读回归。**对象存储侧两路等价**(typed 超集,DV-002)。 + - **关键事实**:`getBackendStorageProperties()` 是 **ConnectorContext SPI 方法、不依赖 fe-property**→**P1-T05 不需要本切换**;切换纯为 D-003 架构统一,而对 HDFS 物理做不到(除非动 fe-filesystem,超白名单)。 + - **用户定(2026-06-17)**:**按原计划全切**,接受 HDFS BE 回归,后续补 fe-filesystem `HdfsFileSystemProperties`(记 **DV-004 / R-007 / follow-up FU-T01**)。`getBackendStorageProperties()` SPI default 保留(连接器停调,移除非「新增」,留 follow-up 清理)。 +- **完成态(2026-06-17)**:实现 = `PaimonScanPlanProvider`(静态凭据块 `for sp : ctx.getStorageProperties() { sp.toBackendProperties().ifPresent(...putAll(toMap())) }` 替代 `getBackendStorageProperties()` 循环 + 加 `org.apache.doris.filesystem.properties.StorageProperties` import + 注释标 2 KNOWN GAP)。**仅 1 主文件改**(pom 无需改,fe-filesystem-api 依赖 P1-T03 已加)。TDD:`scanContext` 改喂 `getStorageProperties()` 的 fake `StorageProperties`(删 `getBackendStorageProperties` override)→ `getScanNodePropertiesNormalizesStaticCreds` RED(`expected ak was null`)→ 切产线 GREEN。新增 1 测试 `...SkipsStoragePropsWithoutBackendMappingAndMergesRest`(Optional.empty 跳过 + 多 entry merge,镜像 HDFS-空-项)+ 2 helper(`scanContextWithStorage`/`fakeStorageWithoutBackend`)。验证:`PaimonScanPlanProviderTest` **52/0**、paimon 全模块 **292/0/0/1skip**(docker-gated PaimonLiveConnectivityTest)、checkstyle 0、import-gate PASS、白名单干净(仅 2 paimon 文件)、零 `org.apache.doris.property/datasource` import。 +- **对抗 review(`wf_09745716-d48`,10 agent,3 lens + verify)**:7 finding,confirm 4。**(1) MAJOR=R-008**(fe-filesystem OSS/COS/OBS typed BE map 缺 `AWS_CREDENTIALS_PROVIDER_TYPE`,无凭据 catalog 的 legacy `ANONYMOUS` 丢失;**fix 在 fe-filesystem 超白名单**→记 R-008 + **follow-up FU-T02**,仅影响无 ak/sk 的 OSS/COS/OBS);**(2) MINOR→已修**(fake 恒 `Optional.of` 漏 `.ifPresent` 空分支→新增上述测试覆盖);**(3) NIT→已修**(多 entry merge 未测→同测试覆盖);**(4) NIT→已修**(非空 ctx+空 list→同测试覆盖)。verify 推翻 3 假 finding(AWS_BUCKET/ROOT_PATH 超集=DV-002 已接受非回归;「测试没钉新 seam」被**实测 mutation 推翻**——回退旧 seam→RED;OverlaysVended 静态缺失由 sibling NormalizesStaticCreds 覆盖)。 +- ⚠️ **docker e2e 未跑**(真等价 Option C 闸在 P1-T06;HDFS flavor 会暴露 R-007、无凭据 OSS/COS/OBS 暴露 R-008,均**已接受、非新 bug**)。 + +### P1-T05 ✅ 断开 paimon → fe-property 依赖边(2026-06-17,仅删 pom 边 + grep 闸,293/0/1skip + checkstyle 0) +- **做什么**:删 `fe-connector-paimon/pom.xml` 的 `fe-property` 依赖块(comment + dependency,原 :67-74)。**import/call 已在 P1-T03 删(DV-003-b),故 P1-T05 退化为仅删 pom 边**。**fe-property 模块本身不删**(D-005,变 0 消费者孤儿)。 +- **验收**:`grep -r 'org.apache.doris.property' fe/fe-connector/fe-connector-paimon/src` == 0;模块编译 + 全 UT 通过。 +- **依赖**:P1-T03, P1-T04。设计 §4 P1-5 / §0.1。 +- **现场 recon 结论(2026-06-17,对照真实代码)**:`grep org.apache.doris.property` 在 paimon src(main + test)**已 ZERO**(DV-003-b 已清 import/call);唯一 `fe-property` 物理耦合 = pom :72 依赖块;其余 `fe-property` 字样均为**历史注释**(PaimonCatalogFactory :348/:384/:591、PaimonConnector :132/:204、test :382/:542 描述「替代 legacy fe-property 路」),不依赖 classpath、准确记录历史 → **不动**(surgical)。无 test-scope/transitive 用途。 +- **完成态(2026-06-17)**:删 pom :67-74(fe-property comment + dependency 块),**仅改 `fe-connector-paimon/pom.xml` 1 文件**。**RED/GREEN = 构建闸**(无 UT 可写):删后**全模块编译 + 全 UT 仍绿 = 证无隐藏 transitive 依赖断裂**(paimon 现仅依赖 `fe-connector-{api,spi}` + `fe-filesystem-api` + `fe-thrift(provided)` + paimon SDK + hive-shade)。验证:paimon 全模块 **293/0/0/1skip**(含 P1-T04 新增 1 测试;docker-gated PaimonLiveConnectivityTest)、`grep org.apache.doris.property src` == 0、`fe-property` 在 paimon pom 已无、checkstyle 0、import-gate PASS、白名单干净(仅 pom 1 文件)。**P1 storage 收口的依赖边正式断开**(paimon 不再依赖 fe-property,变孤儿模块——本次不物理删,D-005)。⚠️ docker e2e 未跑。 + +### P1-T06 ⬜ P1 验证 +- **做什么**:paimon UT 全绿;docker `enablePaimonTest=true` 5 flavor;T1 等价性测试。 +- **验收**:见 WORKFLOW §5;若不跑 docker 明确标注"未跑 e2e"。 +- **依赖**:P1-T02..T05。设计 §4 P1-6 / §5 T1,T4。**(推迟,docker 折入 P2-T05;D-012)** + +### P1-T07 ✅ 彻底删除 fe-property 孤儿模块(2026-06-18 完成,commit 待提交;D-016 授权,超 D-005) +> **用户 2026-06-18 定为下一阶段,先于 P2-T04/T05。** P1-T05 断边后 fe-property 已 0 消费者;本任务物理删除它。 +- **做什么**:① 删 `fe/fe-property/` 整个目录(26 java,package `org.apache.doris.property`);② 删 `fe/pom.xml` 的 `fe-property`(:222)+ dependencyManagement 里 fe-property 条目(:831);③ **可选** 清理 5 处 stale 注释(删模块后悬空):`fe-filesystem-hdfs` 的 `HdfsFileSystemProperties`/`HdfsConfigFileLoader`(「移植源 = fe-property…」)+ paimon 的 `PaimonCatalogFactory`/`PaimonConnector`/`PaimonCatalogFactoryTest`(「replaces/ported from legacy fe-property…」)——均白名单内文件,注释订正非改逻辑。 +- **现场 recon(2026-06-18 已做,执行 session 须复核)**:whole-repo `grep -rln fe-property`(排除 .git/fe-property/plan-doc/target)= 仅 `fe/pom.xml`(真)+ 上述 5 文件(注释);`grep org.apache.doris.property`(排除 fe-property dir)= **0 import**;**无 BE/docker/脚本/regression-conf 引用**。删除内容**限于 fe/**。**fe-core `datasource.property.{storage,metastore}` 两包不碰**(仍服务 hive/hudi/iceberg,D-016 明确只删 fe-property)。 +- **TDD/验收**:**RED/GREEN = 构建闸**(无 UT 可写,同 P1-T05 模式):删后**全 FE 构建**(`mvn -f fe/pom.xml … package`,至少 `-pl fe-connector/fe-connector-paimon -am` + 一次全 reactor 编译)+ **paimon 全模块 UT 仍绿(278/0/1skip)= 证无隐藏 transitive 断裂**;`grep -rn fe-property fe/`(排除 plan-doc)只剩(若保留注释则)注释或全清;checkstyle 0;import-gate PASS;`git status` 确认 `fe/fe-property/` 已删 + `fe/pom.xml` 两处声明已删。 +- **依赖**:P1-T05(断边)。D-016。**⚠️ 超 D-005 原范围,已获用户专门授权。** +- **完成态(2026-06-18,commit 待提交)**:删 `fe/fe-property/`(27 文件 = 26 java + pom;stale `target/` 一并清→目录全消)+ `fe/pom.xml` 两处声明(`fe-property` + dependencyManagement 条目)+ 清 5 处 stale 注释(paimon `PaimonCatalogFactory`×2/`PaimonConnector`×2/`PaimonCatalogFactoryTest`×1 + fe-filesystem-hdfs `HdfsConfigFileLoader`/`HdfsFileSystemProperties`:「fe-property」→「legacy」保历史语义;用户 AskUserQuestion 选「一并清理」)。**RED/GREEN=构建闸**(无 UT 可写,同 P1-T05 模式):whole-repo `grep fe-property`(排除 plan-doc)=**0**、`grep org.apache.doris.property`=**0**;**全 FE reactor `test-compile` BUILD SUCCESS**(`-Dmaven.build.cache.enabled=false`,含 fe-core `compile`+`testCompile` 实跑,54 模块,**0 ERROR**,1:53min)=证 module+dependencyManagement 删除无隐藏 transitive 消费者;paimon 全模块 **278/0/1skip**、fe-filesystem-hdfs **78/0/0**、checkstyle 0、`tools/check-connector-imports.sh` exit 0、`git diff --name-only` 白名单干净(仅 fe-property 删除 + fe/pom.xml + 5 注释文件 + 本跟踪目录)。**fe-property 物理删除完成(0 消费者孤儿被移除);fe-core `datasource.property.{storage,metastore}` 两包不碰(仍服务 hive/hudi/iceberg,D-016 明确)。** ⚠️ **docker e2e 未跑**(D-012,留 P2-T05)。 + +--- + +## P2 — MetaStore Property SPI + paimon adapter(纯新增/迁移) + +### P2-T01 ✅(2026-06-18 完成)新建 fe-connector-metastore-api +- **完成态(2026-06-18,commit 待提交)**:新模块 `fe-connector-metastore-api`(`org.apache.doris.connector.metastore`)= `MetaStoreProperties`(`providerName()` + 能力方法 `needsStorage()`/`needsVendedCredentials()` 默认 false + `validate()` 默认 no-op + `rawProperties()`/`matchedProperties()`,**无 `MetaStoreType` 枚举**,D-006)+ 5 子接口 **HMS/DLF/REST/JDBC/FileSystem**(中立 Map/标量;`HmsMetaStoreProperties` 用 fe-kerberos `AuthType`+`Optional`)。**未建 Glue/S3Tables**(留扩展——加子接口零改 api/spi)。**依赖 = fe-kerberos**(D-013;非设计 header 原写的 fe-foundation+fe-filesystem-api——api 纯接口不用 @ConnectorProperty/StorageProperties,那些留 spi 用时再加,Rule 2/3 外科)。pom 镜像 `fe-connector-api`(copy-plugin-deps phase=none,编入 fe-core 非插件部署);注册进 `fe-connector/pom.xml`(fe-connector-spi 之后)。**TDD**:`MetaStorePropertiesContractTest` 3/0(能力默认 false[Rule 9 intent]/可 override/HMS 子接口承载 fe-kerberos facts)。验证:BUILD SUCCESS、checkstyle 0、import-gate exit 0、无 fe-core 禁包 import、`git diff` 白名单干净(仅 fe-connector/pom.xml + 新模块)。 +- **做什么**:新模块(依赖 fe-foundation + fe-filesystem-api):`MetaStoreProperties`(`String providerName()` + 能力方法 `needsStorage()`/`needsVendedCredentials()`,**无 per-backend 枚举**,D-006)+ 子接口 **HMS/DLF/REST/JDBC/FileSystem**(中立 Map/标量,不暴露 HiveConf/SDK 类型)。**不实现 Glue/S3Tables**(iceberg/hive 专用,留扩展)。**[D-013 修订:依赖 fe-kerberos(AuthType/KerberosAuthSpec);fe-foundation/fe-filesystem-api 当前 api 未用,留 spi]** +- **验收**:模块编译;接口签名对齐设计 §3.1(**确认无 `MetaStoreType` 枚举**);新模块声明进 `fe-connector/pom.xml`。 +- **依赖**:~~无~~ **fe-kerberos(D-013,P3a-T01 facts-carrier 先建)**。设计 §4 P2-1 / §3.1 / **D-006 / D-013**。 + +### P2-T02 ✅(2026-06-18 完成,commit `7ea63528bc4`)新建 fe-connector-metastore-spi(共享后端解析器 + Provider 发现) +- **完成态(2026-06-18,commit `7ea63528bc4`)**:新模块 `fe-connector-metastore-spi`(15 main + 7 test = 22 文件)= `MetaStoreProvider

    ` SPI(`supports(Map)` 自识别 + abstract `bind(props, storageHadoopConfig)`,extends `PluginFactory`)+ `MetaStoreProviders.bind` first-hit 派发(ServiceLoader)+ `MetaStoreParseUtils`(firstNonBlank/copyIfPresent/nullToEmpty/applyStorageConfig/matchedProperties + `CATALOG_TYPE_KEY`/`FS_S3A_PREFIX`/`USER_STORAGE_PREFIXES`)+ `JdbcDriverSupport.resolveDriverUrl`(仅纯 resolver)+ `AbstractMetaStoreProperties`(共享 raw/warehouse/matchedProperties)+ 5 `*MetaStorePropertiesImpl`(`@ConnectorProperty` 绑定,消灭手抄别名数组)+ 5 provider(各 `sensitivePropertyKeys` override 暴露 sensitive 键,镜像 FS)+ 单 `META-INF/services` 文件(5 行)。pom 依赖 = metastore-api + fe-extension-spi + fe-foundation + fe-kerberos + commons-lang3(**无** fe-filesystem-api[DV-007]、无 hadoop/hive/thrift);copy-plugin-deps phase=none;注册进 `fe-connector/pom.xml`。**HMS parity gap D-4 补回**(forbidIf-simple/requireIf-kerberos,CASE-SENSITIVE `Objects.equals` 对齐 ParamRules,与 conf-build branch `equalsIgnoreCase` 的不对称保留)。**REST token-provider 改 case-SENSITIVE `"dlf".equals`**(对抗 review MAJOR:paimon 手抄 `equalsIgnoreCase` 是 latent bug,legacy ParamRules 才权威)。**FS `supports()` 改 `type==null||equalsIgnoreCase`**(去 trim 不对称 + 对齐 legacy reject-on-malformed)。**验证**:spi **41/0**(HMS 13·DLF 7·dispatch 7·ParseUtils 4·JDBC 4·REST 4·FS 2)、checkstyle 0、import-gate exit 0、无 fe-core 禁包 import、`git diff` 白名单干净;**3 mutation 证**(HMS 大小写敏感 + kerberos-after-storage clobber + REST 大小写敏感 均 RED→GREEN)。**对抗 review `wf_2ddae04d-cf9`(4 lens + verify)**:0 BLOCKER/0 真 MAJOR(REST case-sens 已修);DV-006/007/D-006/D-4 经独立核实正确;trim/accessPublic-proxyMode divergence 经核证「对齐权威 legacy contract、仅偏离非权威 paimon 手抄」→不改;补 12 测覆盖缺口(storage re-key/clobber-via-storage-channel/alias-first-wins/username-overlay/DLF-S3-reject/dispatch-instanceof 等)。**API 旁改 2 javadoc**(getDriverUrl「raw,consumer-resolves」+ needsStorage FS 准确性,均诚实订正,白名单内)。⚠️ **docker e2e 未跑**(T2 真闸留 P2-T05)。 +- **P2-T03 必接(review 揪出,记此)**:①**F2 hive.conf.resources**:SPI `toHiveConfOverrides()` 只产 overrides,不含外部 hive-site.xml base;P2-T03 cutover 时连接器须 `ctx.loadHiveConfResources(raw.get("hive.conf.resources"))` 当 base 先施、再叠 overrides,否则外部 hive-site.xml 静默丢。②**HMS doAs 消费契约**:FE doAs 决策须看 `kerberos()` 非 `getAuthType()`(HDFS-fallback 时 getAuthType=SIMPLE 但 kerberos().isPresent)。③driver 注册/DriverShim(JVM 副作用)留 P2-T03(仅 resolveDriverUrl 已上移)。 +- **认领(2026-06-18,本 session)**:recon workflow `wf_187e052d-230`(4 reader + synth)+ 直接核实真实代码完成;3 边界经 AskUserQuestion 定(见下)。TDD:FILESYSTEM→REST→JDBC→DLF→HMS,单一 `[P2-T02]` commit。 +- **用户 3 决策(2026-06-18 AskUserQuestion)**:①**kerberos = compile-dep only**(fe-kerberos 零新代码,现有 `AuthType`+`KerberosAuthSpec` facts 足够,doAs 留 FE 侧)→ **DV-006**;②**parser storage 入参 = 中立 `Map storageHadoopConfig`**(非 `List`,spi **不**依赖 fe-filesystem-api,保持 hadoop/fs-free)→ **DV-007**;③**全 5 后端一次 commit、增量构建**。 +- **做什么**:新模块(依赖 metastore-api + **fe-foundation** + **fe-extension-spi**[for `PluginFactory`] + fe-kerberos;**无** fe-filesystem-api[DV-007]、无 thrift、无 hadoop):`Hms/Dlf/Rest/Jdbc/FileSystem` 5 个 `*MetaStorePropertiesImpl`(`@ConnectorProperty` 绑定,消灭 `PaimonConnectorProperties` 手抄别名数组)+ `MetaStoreParseUtils`(firstNonBlank/applyStorageConfig/matchedProperties)+ `JdbcDriverSupport.resolveDriverUrl`(**仅纯 resolver;driver 注册/DriverShim 是 JVM 副作用、无调用方 → 留 P2-T03**,Rule 2 不搬死代码);**+ `MetaStoreProvider

    ` SPI(`supports(Map)` 自识别 + abstract `bind(props, storageHadoopConfig)`)+ 5 内置 provider + 单 `META-INF/services` 文件(5 行)+ `MetaStoreProviders.bind(...)` first-hit 派发**(D-006,镜像 `FileSystemProvider`/`FileSystemPluginManager`)。来源 = 上移 paimon 现有 `PaimonCatalogFactory` 手抄逻辑(去 fe-core 化:HiveConf→中立 `Map`、authenticator→`KerberosAuthSpec` facts)。**fe-core 旧类不动**。**P2-T02 只建模块+测;paimon adapter 仍用手抄逻辑直到 P2-T03。** +- **HMS 补回 legacy parity gap(D-4)**:paimon 手抄 `validate()` 只查 uri,漏 legacy `HMSBaseProperties.buildRules` 的 forbidIf-simple/requireIf-kerberos 两条 → SPI parser **补回**(T2 parity target = legacy `Paimon*MetaStoreProperties`,非 paimon 手抄)。 +- **验收**:T2 等价性测试通过(解析产物 == 旧 `Paimon*MetaStoreProperties`:HiveConf key 集 + ParamRules 报错文案);`@ConnectorProperty` 别名/sensitive 生效;`MetaStoreProviders.bind` 经 `supports()` 正确选中 5 后端(**无 per-backend 枚举/中心 switch**)。⚠️ docker 真闸留 P2-T05。 +- **依赖**:P2-T01。设计 §4 P2-2 / §3.2 / §5 T2 / **D-006 / DV-006 / DV-007**。 + +### P2-T03 ✅ paimon adapter 改造(2026-06-18 完成,commit `3c1e118dcfa`) +- **完成态(2026-06-18,commit `3c1e118dcfa`)**:paimon 元存储连接逻辑 cutover 到共享 spi。**改 5 main + 2 test + pom**(白名单内):`PaimonConnectorProvider.validateProperties`→`MetaStoreProviders.bind(props,{}).validate()`(**D-014** 采用更严 legacy-faithful validate);`PaimonConnector.createCatalog` HMS/DLF 分支→`bind`+新 `PaimonCatalogFactory.assembleHiveConf(base,overrides)`(HMS 先 seed `ctx.loadHiveConfResources` base 再叠 `toHiveConfOverrides`;DLF `assembleHiveConf(null, toDlfCatalogConf())`)、删 build-time `requireOssStorageForDlf`;`resolveFullDriverUrl`+`PaimonScanPlanProvider:1054`→`JdbcDriverSupport.resolveDriverUrl`(**D-015** 注册副作用留连接器);`PaimonCatalogFactory` 删 `validate`/`buildHmsHiveConf`/`buildDlfHiveConf`/`requireOssStorageForDlf`/`resolveDriverUrl`/`copyIfPresent`/`nullToEmpty`/`KNOWN_FLAVORS`+加薄 `assembleHiveConf`;`PaimonConnectorProperties` 删 `DLF_*`/`REST_TOKEN_PROVIDER`/`REST_DLF_*`(**DV-008**:别名数组只部分删,`HMS_URI`/`REST_URI`/`JDBC_*` 仍被 `buildCatalogOptions` 用,保留)。**行数**:净 +318/−847。**TDD**:新 `PaimonConnectorValidatePropertiesTest` 13/0(3 tightening RED→GREEN 实证 + 10 retarget);`PaimonCatalogFactoryTest` 删 28 旧测(buildHmsHiveConf/buildDlfHiveConf/requireOssStorageForDlf/validate,content parity 已由 spi `Hms/DlfMetaStorePropertiesTest` 13+7 覆盖)+ 加 2 `assembleHiveConf` 测。**验证**:paimon 全模块 **278/0/1skip**(skip=`PaimonLiveConnectivityTest` gated)、checkstyle 0、`tools/check-connector-imports.sh` exit 0、白名单干净。**recon `wf_9437dd4e-06d`**(6 reader+synth+verify)verify=**SOUND AND READY offline**(逐键 parity 通过、无假 gap/无遗漏 blocker)。**对抗 review `wf_dd78ec4b-da5`**(4 lens+verify)verify=**READY,0 真 finding**(1 MAJOR「kerberos.principal alias 未测」经核证伪=该键也走 verbatim `hive.*` passthrough→测它是恒真 tautology 违 Rule 9;真正隔离 binding 的 `service.principal`→输出 `kerberos.principal` 方向已被 spi 测 line72/80 覆盖)。⚠️ **docker e2e 未跑**(HMS/DLF live metastore=hive + 插件 zip ServiceLoader 发现 5 provider 在子优先 loader 下=P2-T05 真闸)。 +- **认领 recon(保留)**:直接读真实代码全路 + 对抗 recon workflow `wf_9437dd4e-06d`;2 边界经 AskUserQuestion 定(D-014 validate 收敛、D-015 注册留连接器)。 +- **做什么**:`PaimonCatalogFactory` 的 `buildHmsHiveConf`/`buildDlfHiveConf`/`validate`/`requireOssStorageForDlf`/`resolveDriverUrl` → 调共享 `MetaStoreProviders.bind(raw, storageHadoopConfig)` + 薄 paimon HiveConf 组装(新 `assembleHiveConf(base, overrides)` 纯助手);驱动 URL 解析两处调用点改 `JdbcDriverSupport.resolveDriverUrl`;删 `PaimonConnectorProperties` 的 DLF_*/REST_TOKEN_PROVIDER/REST_DLF_* 别名常量。**保留**(paimon SDK / 存储,非元存储连接):`buildCatalogOptions`+`append*Options`、`buildHadoopConfiguration`+`applyStorageConfig`、`resolveFlavor`、`firstNonBlank`、JDBC 注册副作用(`maybeRegisterJdbcDriver`/`DriverShim`/静态缓存留 PaimonConnector,Q2=方案A)。 +- **认领 recon**:直接读真实代码(PaimonCatalogFactory/PaimonConnector/PaimonConnectorProvider/PaimonConnectorProperties/PaimonScanPlanProvider:1054 全部 + 5 spi impl + MetaStoreProviders + api 6 接口 + 调用方/校验接线 + 测试面)+ 对抗 recon workflow `wf_9437dd4e-06d`(6 reader+synth+verify,verify 判 **SOUND AND READY offline**:无假 gap、无遗漏 blocker、HMS/DLF/applyStorageConfig/resolveDriverUrl 逐键 parity 通过)。2 边界经 AskUserQuestion 定:**Q1=采用 spi 的 legacy-faithful validate**(更严:HMS forbidIf-simple/requireIf-kerberos + REST case-sensitive `"dlf".equals`;CREATE CATALOG 比当前 paimon 更严,故意向 legacy 收敛);**Q2=注册留连接器**(只换纯 `resolveDriverUrl`,JVM 副作用不入纯 spi,Rule 2 无第二消费方)。 +- **TDD**:新测打 `PaimonConnectorProvider.validateProperties`(含 HMS requireIf/forbidIf 新规、DLF-需 OSS、unknown→IAE 无"bogus"文案断言)+ `PaimonCatalogFactory.assembleHiveConf` 顺序测(base→overrides 覆盖);删 PaimonCatalogFactoryTest 的 buildHmsHiveConf/buildDlfHiveConf/requireOssStorageForDlf/validate 直测(内容 parity 已由 spi 41/0 覆盖);保 PaimonHmsConfResWiringTest(seam 仍绿)。 +- **验收**:paimon 全模块 UT 全绿;adapter 不再含手抄连接逻辑(行数显著降);`tools/check-connector-imports.sh` 不破。⚠️ docker 真闸留 P2-T05(HMS/DLF live metastore=hive + 插件 zip ServiceLoader 发现 5 provider 未离线验)。 +- **依赖**:P2-T02。设计 §4 P2-3 / §3.3。 + +### P2-T04 ✅ paimon pom + gate 核对(2026-06-21) +- **完成态**:`MetaStoreProviders` 改 **2-arg 显式 loader** `ServiceLoader.load(MetaStoreProvider.class, MetaStoreProviders.class.getClassLoader())`(commit `2612af5e88f`,解 P2-T03 recon 揪出的 child-first 插件 loader 下 TCCL 发现失败风险)+ paimon pom 依赖集 + `tools/check-connector-imports.sh` 核对。 +- **依赖**:P2-T03 ✅。设计 §4 P2-4。 + +### P2-T05 ✅ P2 验证(2026-06-21,用户手动 docker 验证) +- **完成态**:用户手动跑 docker(`enablePaimonTest=true`)验证 **paimon 5-flavor 读 + vended(REST/DLF) + Kerberos HMS** 通过——即 plugin-zip ServiceLoader 发现(child-first loader)+ HMS/DLF live `metastore=hive` 跨 loader + storage 等价 + D-014 更严 CREATE 行为 的真闸。**这也覆盖主线 B9/P5-T30 的 live-e2e 内容。** +- **依赖**:P2-T03 ✅, P2-T04 ✅。设计 §4 P2-5 / §5 T2,T4。 + +--- + +## RV — 全连接器 clean-room 对抗 review(已提升到主线) + +### RV-T01 ➡️ paimon connector 全功能路径 clean-room 对抗 review — **已移到主线** +- 用户 2026-06-18 澄清:本目录(`metastore-storage-refactor/`)是 **metastore-refactor 专属子线**;paimon connector 全功能路径 review(6 维度:读取/写入/DDL/元数据回放/元数据 cache/残留旧逻辑·fallback + **不注入开发历史先验**)审的是**整条 connector = catalog-spi 主线**范围,spec 归主线 [`../HANDOFF.md`](../HANDOFF.md)「下一个 session 的任务」,**本目录不复述**(避免在 metastore 子目录里放全连接器 review 的 scope 错配)。 +- 与本子线关系:该 review **先于 B8 legacy 删除**(legacy = 对照基线);**本子线自身剩余 = P2-T04 → P2-T05**,排在主线 review 之后。 + +--- + +## P3 — Kerberos 收口到 fe-kerberos 叶子模块(D-007;⚠️ 范围张力,见下) + +> **范围说明(用户 2026-06-17 确认)**:拆为 **P3a(paimon-local,✅ 纳入本次)** 与 **P3b(全量去重,follow-up,范围外)**。P3a 纯新增 + 只让 paimon 走新模块,不碰 fe-common/fe-filesystem-hdfs 既有路径 → 符合 D-005;P3b 会改 fe-common + fe-filesystem-hdfs,超出 D-005,与 hive/iceberg 迁移同批,本清单仅占位。 +> **归属/命名已定(D-007)**:顶层中立叶子 **`fe-kerberos`**(**非** fe-connector-*,否则破 `fe-filesystem ↛ fe-connector` gate + fe-common 层级倒挂)。 + +### P3a-T01 🚧 新建 fe-kerberos 叶子模块(仅 paimon 用)— **facts-carrier 切片已落地(2026-06-18,D-013)**,authenticator 机制待续 +- **进度(2026-06-18,facts-carrier 切片,commit 待提交)**:D-013(用户选「fe-kerberos build it first」)——为解 P2-T01 的 `AuthType`/`KerberosAuthSpec` 依赖,**先建 fe-kerberos 的纯 Java 零依赖 facts 切片**:`org.apache.doris.kerberos.AuthType`(SIMPLE/KERBEROS + `fromString`:仅 "kerberos" 命中、余皆 SIMPLE)+ `KerberosAuthSpec`(client principal+keytab 不可变值对象 + `hasCredentials()` 需两者皆非空)。pom(零 prod 依赖 + junit test)+ `fe/pom.xml` 注册(紧随 fe-foundation)。验证:AuthTypeTest 3/0 + KerberosAuthSpecTest 3/0、checkstyle 0、BUILD SUCCESS。**authenticator 机制子集(hadoop 依赖 + trino `KerberosTicketUtils`→JDK 替换)= 待续**(P2-T02 消费 HMS kerberos 时增量补)。 +- **做什么**:新建顶层模块 `fe-kerberos`(依赖**仅** hadoop-auth/hadoop-common;trino `KerberosTicketUtils` 用 JDK `javax.security.auth.kerberos` 等价替换)。**本步只承载 paimon HMS 所需**的 kerberos facts 载体(`KerberosAuthSpec` + 必要的 `AuthenticationConfig`/`HadoopAuthenticator` 子集),供 `fe-connector-metastore-spi` 的 `HmsMetastoreBackend` 产出 facts。**不碰 fe-common / fe-filesystem-hdfs 既有路径**。 +- **验收**:模块编译、零 fe-core/fe-connector/fe-filesystem import(纯叶子,gate);paimon HMS kerberos facts 经 fe-kerberos 类型表达;真正 `UGI.doAs` 仍走 `ctx.executeAuthenticated`(§5 不变量 4);fe-common/fe-filesystem-hdfs 既有 kerberos 路径**零改动**(§6 零改动核对)。 +- **依赖**:P2-T02(facts 消费方)。设计 §3.5 / **D-007 步骤 a**。**✅ 纳入本次(用户 2026-06-17 确认)。** + +### P3b-T01 ✅(2026-06-21,3 commit 全完成;D-017:提前到 P6 iceberg 之前单独做)全量去重:fe-common authentication.* 机制收口到 fe-kerberos +> **完成态**:commit 1 `4a740e1387f`(trino→JDK)+ commit 2 `8898e15134c`(relocate 13 类)+ commit 3 `5e3e8963023`(统一 HadoopAuthenticator + 删 hdfs 副本);均**未 push**。三处 kerberos 实现合一到 `fe-kerberos` 单一真相源(fe-common 包整体移除、fe-filesystem-hdfs 副本删除、双 `HadoopAuthenticator` 接口统一)。`fe-kerberos` 仍是顶层中立叶子(no-cycle CONFIRMED)。⚠️ **docker kerberos e2e(HDFS kerberized + HMS)= 真闸,全程 NOT run**(安全敏感,UGI 登录 UT 抓不到)——下一 agent 部署 docker 时须跑(`enablePaimonTest=true` 同时覆盖 HMS kerberos;HDFS kerberized 需 KDC env)。 +- **决策**:**D-017(用户 2026-06-21)**——P3b 提前、单独做,排在 **P6 iceberg SPI 迁移之前**(原「与 hive/iceberg 同批」取消)。理由:纯机制收口、不依赖 iceberg、**先做反而服务 P6**(iceberg metastore-props 迁移可直接复用收口后的干净 `fe-kerberos` authenticator)。 +- **scope 粒度已定(用户 2026-06-21 AskUserQuestion)**:**Phased(独立 commit)+ Repackage 到 `org.apache.doris.kerberos.*`**(重指向全部 import、合并重复 AuthType)。recon `wf_c14cb816-ed9` 实证修正:真 import-retarget 面 = **24 fe-core main + 3 be-java-extensions main = 27 main**(+14 fe-core test + 1 fe-common test = 15 test);文档原「12 fe-common」是**被搬的类自身**(按 `package` 行误计),非外部消费方→外部 fe-common main 消费方 = 0。两个 `HadoopAuthenticator` 接口**结构不兼容**(fe-common 版多 `getUGI()`+静态工厂)→须 adapter 非 overload 合并。 +- **三步 commit 计划(DV-009 重排:trino 先做,避免 fe-kerberos 沾 trino)**: + - **commit 1 ✅(2026-06-21)trino→JDK 原地替换**(still in fe-common,1 文件改 import + 新 `KerberosTicketUtils` JDK 副本 + 新测试)。`HadoopKerberosAuthenticator` 调用点字节不变(同包 `KerberosTicketUtils.getTicketGrantingTicket/getRefreshTime`)。javap 反编译 trino `KerberosTicketUtils` 逐字节复刻(refresh=`start+(long)((end-start)*0.8f)`、TGT=私有凭据里 server==`krbtgt/REALM@REALM`,否则 IAE)。验证:fe-common `-am` BUILD SUCCESS + `KerberosTicketUtilsTest` 4/0 + checkstyle 0 + mutation `0.8f→0.5f` 证测试有效(`9000≠6000`)。**fe-common pom 不动**(trinoconnector 等仍用 trino-main)。⬜ 未 push、docker 未跑。 + - **commit 2 ✅(2026-06-21,commit `8898e15134c`,未 push)relocate**:13 类(含 commit 1 新建的 `KerberosTicketUtils`)全 `git mv` 到 `fe-kerberos` 的 `org.apache.doris.kerberos.*`(改 package 行,history-preserving R94-98%)+ 2 测同搬(`AuthenticationTest`/`KerberosTicketUtilsTest`)+ 重指向 **41 消费方 import**(24 fe-core main + 14 fe-core test + 3 be-java-extensions JNI scanner;`common.security.authentication.*`→`kerberos.*`,纯 import 行、含 `datasource.property.{storage,metastore}` 禁包按 D-017 import-only 例外)。**AuthType 合并**:fe-common 版 mutable `getCode/setCode/setDesc`(**实证 0 caller**、`getCode` 0 read、无持久化)整体 drop,折进既有 immutable fe-kerberos `AuthType` + 新增 `isSupportedAuthType(String)`(唯一 caller `HiveTable`,纯 import retarget 无逻辑改),保 `getDesc/fromString`;删 fe-common `AuthType`。**pom**:fe-kerberos 加 `hadoop-common(provided)`+guava+commons-lang3+lombok+log4j-api(镜像 fe-common;**实证只需 hadoop-common 非 hadoop-auth**——3 类只 import `hadoop.conf.Configuration`/`fs.CommonConfigurationKeysPublic`/`security.UserGroupInformation` 皆在 hadoop-common;叶子不变量保持=零 FE 模块依赖);fe-common 加 `fe-kerberos`(compile) 边(transitively 复供 fe-core 等);java-common 加 `fe-kerberos`(compile) 边(BE-java scanner 插件打包鲁棒)。**recon `wf_d5566c5f-7b1`(6 reader + 2 对抗 verify)**:no-cycle CONFIRMED(13 类零 doris-非kerberos import)、AuthType 合并 verify「REFUTED」实为 pedantic(仅 import 行变 + 需加 isSupportedAuthType,皆已做)。**修正文档计数**:真 import 面 = 27 main(24 fe-core + 3 be-scanner,**0 外部 fe-common 消费方**)+ 14 test;真搬 13 类(非 12,含 KerberosTicketUtils)。**验证**:fe-kerberos `11/0`(含搬来 2 测);fe-core+java-common+3 scanner `test-compile` BUILD SUCCESS + checkstyle 0(修 in-place sed 引入的 CustomImportOrder:按 FQN-无分号 key 重排 doris import 块);import-gate exit 0;whole-repo grep 旧包 = 0;fe-connector-paimon 失败=已证 pre-existing HiveConf shade quirk(文档 `-am package -Dassembly.skipAssembly=true` 路 BUILD SUCCESS)。⚠️ 未 push、**docker kerberos e2e 未跑**(真闸,留 commit 3 后)。**白名单微扩**:`fe/fe-common/pom.xml`(D-017 已预定「新 fe-common→fe-kerberos 边」,§4.1 补登)。 + - **commit 3 ✅(2026-06-21,commit `5e3e8963023`,未 push)统一接口**:用户 AskUserQuestion 定 **「pure consolidation」**(采纳 fe-kerberos 行为,恢复与 legacy fe-common/HMS HDFS 路的 parity;真闸=docker kerberos e2e)。**做法**:删 fe-filesystem-spi `HadoopAuthenticator`(IOCallable)+`IOCallable`(grep 实证仅 fe-filesystem-hdfs 消费,**0 外部**)+ 删 fe-filesystem-hdfs `KerberosHadoopAuthenticator`/`SimpleHadoopAuthenticator` 副本;4 消费方(DFSFileSystem/HdfsInputFile/HdfsOutputFile/HdfsFileIterator)import 重指向 `org.apache.doris.kerberos.HadoopAuthenticator`(`doAs(() -> …)` lambda 自然绑定到 `doAs(PrivilegedExceptionAction)`,**无显式 adapter**);新 `DFSFileSystem.buildAuthenticator()` seam **保 kerberos-vs-simple 选择决策字节不变**(`isKerberosEnabled`=principal+keytab 在,**不**用 fe-kerberos 的 `hadoop.security.authentication` gate)——只改 authenticator 行为;fe-filesystem-hdfs pom 加 `fe-kerberos`(**no-cycle**:fe-kerberos 零-FE-dep 叶子)。**接受的行为变更**(docker 把关):simple/无 username 现走 remote user "hadoop"(旧=FE 进程用户直跑);kerberos 用 fe-kerberos LoginContext+80%-refresh+unconditional setConfiguration。**测试**:新 `DFSAuthenticatorTest`(钉 wiring + no-username→"hadoop" + empty→"hadoop");`HdfsFileIteratorTest` passthrough 重指向;删 obsolete `SimpleHadoopAuthenticatorTest`;`KerberosHadoopAuthenticatorEnvTest` 重指向(lazy getUGI 登录)。**对抗 review `wf_b1a4e7e4-b51`(3 lens+verify)= 3 MINOR 全修**:①empty-string `hadoop.username` regression(fe-kerberos simple 仅 null→默认 "hadoop",""→`createRemoteUser("")` 抛 IllegalArgumentException;旧副本有 `!isEmpty()` 守;**buildAuthenticator 现统一规整 absent/empty→默认 "hadoop"**,RED 实证 `IllegalArgumentException: Null user`→GREEN)②补回 empty-string 测试覆盖 ③scrub stale 文档(fe-filesystem README + fe-filesystem-spi pom description 仍称 spi 含 HadoopAuthenticator;README 为聚合层,spi 删除的透明后果)。**验证**:fe-filesystem-hdfs **79/0/0**(+fe-kerberos/spi via -am)BUILD SUCCESS + checkstyle 0 + import-gate exit 0 + whole-repo grep `filesystem.spi.HadoopAuthenticator/IOCallable`=0 + reactor test-compile 净(唯一失败=pre-existing fe-connector-paimon HiveConf shade quirk,不相关、不在 diff)。⚠️ 未 push、**docker kerberos e2e(HDFS kerberized + HMS)NOT run**(真闸,UGI 登录 UT 抓不到)。**→ P3b-T01 三步全完成。** +- **现码 scope(2026-06-21 firsthand 核实;design §3.5 / D-007 步骤 b 的现状落地)**: + 1. **搬机制**:`fe-common/.../security/authentication/` 的 **12 类** → `fe-kerberos`:`AuthenticationConfig` / `AuthType` / `ExecutionAuthenticator` / `HadoopAuthenticator` / `Hadoop{Execution,Kerberos,Simple}Authenticator` / `ImpersonatingHadoopAuthenticator` / `KerberosAuthenticationConfig` / `PreExecutionAuthenticator` / `PreExecutionAuthenticatorCache` / `SimpleAuthenticationConfig`。`fe-kerberos` pom 加 hadoop-auth/hadoop-common(今 facts-only 零 hadoop)。⚠️ fe-common 已有的 `AuthType` 与 fe-kerberos facts 的 `AuthType` **重复** → 收口时合一。 + 2. **去 trino**:trino `KerberosTicketUtils` **仅 `HadoopKerberosAuthenticator` 一处**用 → JDK `javax.security.auth.kerberos` 等价替换(contained,1 文件)。 + 3. **删 hdfs 副本 + 统一接口**:`fe-filesystem-hdfs` 自有 `KerberosHadoopAuthenticator`/`SimpleHadoopAuthenticator`(实现 fe-filesystem-spi 的 `HadoopAuthenticator`,`IOCallable` 变体)→ 删、改依赖 fe-kerberos;统一**两个打架的 `HadoopAuthenticator` 接口**(fe-common `PrivilegedExceptionAction` vs fe-filesystem-spi `IOCallable`)为单接口 + 消费侧 adapter(涉 6 hdfs 文件:DFSFileSystem/HdfsFileIterator/HdfsOutputFile/HdfsInputFile + 两副本)。 + 4. **重指向消费方**:**40 个非测试文件** import `org.apache.doris.common.security.authentication.*` = **24 fe-core + 12 fe-common + 3 be-java-extensions** scanner(paimon/iceberg/hudi JNI)。⚠️ **be-java-extensions 也受影响——非纯 FE 改动。** +- **scope 粒度(执行 session 先 AskUserQuestion 定)**:(A) 一次全搬(40 处 import 全改 + 删 fe-common 包);(B) 分步——先把机制搬进 fe-kerberos + fe-common 留**薄 re-export 桥**(import 不动),后续单独统一接口/清桥(回归面小、可分 commit)。 +- **验收**:三处实现合一(fe-common + fe-filesystem-hdfs 副本消除);`fe-kerberos` 仍是顶层中立叶子(无环;零 fe-core/fe-connector/fe-filesystem import);**全 FE reactor `test-compile` BUILD SUCCESS** + checkstyle 0 + import-gate exit 0;**docker kerberos e2e(HDFS kerberized + HMS kerberos)真验 `doAs` 不回归**(安全敏感,UGI 登录 UT 抓不到)。 +- **依赖**:P3a-T01 ✅ + P2-T05 ✅。**前置于 P6 iceberg(D-017)**。设计 §3.5 / **D-007 步骤 b**。 +- **风险**:authentication = 安全敏感 + 40 消费方 + 跨 FE/BE-java + 双接口统一 → 回归面大;务必 docker kerberos e2e 把关。这是「把 paimon/iceberg/hive metastore-props 搬出 fe-core」的**前置**(见主线 [`../HANDOFF.md`](../HANDOFF.md) 关于 metastore-props 迁移可行性的讨论)。 + +--- + +## Follow-ups(范围外占位,本次不做) + +### FU-T01 ✅(2026-06-17 完成;active — 用户提升 + D-010 授权)给 fe-filesystem-hdfs 新建 HDFS typed BE model(修 DV-004 / R-007) +- **做什么**:在 `fe-filesystem-hdfs` 新建 `HdfsFileSystemProperties`(`implements FileSystemProperties, BackendStorageProperties`,**不**实现 `HadoopStorageProperties`——BE-only)承载 `hadoop.*/dfs.*/HA/kerberos` 的 BE 键 + 小工具 `HdfsConfigFileLoader`(XML `hadoop.config.resources` 加载,移植 fe-property `PropertyConfigLoader`);`HdfsFileSystemProvider` re-type 为 `FileSystemProvider` + 新增 `bind()`/`create(P)`(**`create(Map)`/`supports()` 不动**)。`pom` 增 `fe-foundation`+`commons-lang3`。这样 `FileSystemPluginManager.bindAll` 收集 HDFS 项、`getStorageProperties().toBackendProperties().toMap()` 对 HDFS-warehouse paimon catalog 重新产 BE 键 → 修复 P1-T04 的 HDFS BE 回归(DV-004 / R-007)。**kerberos = K1**(D-010;BE-key 字符串内联,不建 fe-kerberos,不碰 create()-side authenticator)。 +- **做法(移植源 = fe-property `HdfsProperties`,依赖轻 BE-key-only 孪生,parity by construction)**:`toMap()` 忠实移植 legacy `initBackendConfigProperties()`(XML 资源 + `hadoop./dfs./fs./juicefs.` 透传 + 恒发 `ipc.client.fallback...`/`hdfs.security.authentication` + kerberos 块 + `hadoop.username`);`validate()` = kerberos required-check(principal+keytab)+ `checkHaConfig`(移植 `HdfsPropertiesUtils`,inline 私有方法)。`backendKind()=HDFS`、`type()=HDFS`、`kind()=HDFS_COMPATIBLE`、`providerName()="HDFS"`。 +- **TDD**:golden parity UT `HdfsFileSystemPropertiesTest` 钉 `of(input).toMap()` == legacy BE 键集(simple / kerberos / HA-multi-nn + 负例 / hadoop.username / fs.defaultFS-from-uri / XML-resources)——**真 parity 闸在 UT**(fe-filesystem-hdfs 自有模块,非 paimon Option C)。 +- **验收**:UT golden parity 全绿;checkstyle 0;`git diff` 仅落 `fe-filesystem-hdfs/**`;`create(Map)`/`supports()` 字节不变;docker(含 HA/kerberized)HDFS-warehouse paimon 原生读恢复(docker 未跑则标注)。 +- **依赖**:P1-T04(暴露缺口)。**D-010 授权**触碰 `fe-filesystem-hdfs`。**红线**:核心改 `fe-filesystem-hdfs/**`;F1 接线 + stale-注释 另碰 3 个已白名单文件(均 project-owned 方法体微改/注释):fe-core `FileSystemFactory.java`(+1 行 setProperty)、`FileSystemPluginManager.java`(bindAll javadoc)、fe-connector-paimon `PaimonScanPlanProvider.java`(注释);其它 fe-filesystem 模块仍禁碰。 +- **完成态(2026-06-17,commit 待提交)**:新建 `HdfsFileSystemProperties`(`FileSystemProperties + BackendStorageProperties`,BE-only)+ `HdfsConfigFileLoader`(XML 资源,sysprop 接 `Config.hadoop_config_dir`);`HdfsFileSystemProvider` re-type + `bind()`/`create(P)`(`create(Map)`/`supports()` 不动);pom +`fe-foundation`+`commons-lang3`。**移植源 = fe-property `HdfsProperties`,parity by construction**。验证:fe-filesystem-hdfs 全模块 **78/0/0**(含新增 25 golden parity;既有 25 `DFSFileSystemTest` 等绿=create() 路零回归)、checkstyle 0、RED/GREEN 经 mutation 证、fe-core `-pl fe-core -am compile` 绿(验 FileSystemFactory/PluginManager 改)、`git diff` 白名单干净。**对抗 review `wf_5db99e32-2ad`(27 agent,4 lens+verify)**:清场 packaging/parity/scope,3 实质修(malformed-uri fail-loud + 2 stale 注释 + 11 测试),**F1 用户选「现在接好」**(config-dir sysprop 桥)。残留 oss-hdfs JindoFS 凭据=独立 FU(P1-T04 已起)。⚠️ **docker e2e 未跑**(HA/kerberized HDFS-warehouse 真闸在 P1-T06)。 + +### FU-T02 ✅(2026-06-18 完成;D-011 授权)给 fe-filesystem OSS/COS/OBS 补 AWS_CREDENTIALS_PROVIDER_TYPE(修 R-008) +- **完成态(2026-06-18,commit 待提交)**:**DV-005**——recon 证伪「加字段镜像 S3」,改为在 `Oss/Cos/ObsFileSystemProperties.toBackendKv()` **内联**镜像 legacy `AbstractS3CompatibleProperties.doBuildS3Configuration`(:117-120):`StringUtils.isBlank(accessKey) && StringUtils.isBlank(secretKey)` → `kv.put("AWS_CREDENTIALS_PROVIDER_TYPE", "ANONYMOUS")`、否则省略。**无字段/无枚举/无跨模块依赖**(`S3CredentialsProviderType` 在 s3 模块、oss/cos/obs 不依赖;legacy OSS/COS/OBS 也无可配置 provider type,只 `S3Properties` override)。仅碰 BE map,不碰 `toHadoopConfigurationMap`(legacy 该键只进 `getBackendConfigProperties`)。**TDD**:3 个 `toBackendProperties_emitsAnonymousProviderTypeWhenNoStaticCredentials`(RED `expected but was ` → GREEN)+ 3 个有凭据测试加 `assertNull(AWS_CREDENTIALS_PROVIDER_TYPE)` 守省略。验证:OSS 13/0·COS 12/0·OBS 12/0 + 全模块绿、checkstyle 0、`git diff` 仅落 `fe-filesystem-{oss,cos,obs}/{main,test}`。⚠️ docker 无凭据闸在 P1-T06。 +- **做什么**:给 `Oss/Cos/ObsFileSystemProperties` 加 `credentialsProviderType` 字段(镜像 `S3FileSystemProperties`)+ 在 `toBackendKv()` 发 `AWS_CREDENTIALS_PROVIDER_TYPE`,**精确镜像 legacy**(ak/sk 皆空 → `ANONYMOUS`,否则**省略**——legacy OSS/COS/OBS 仅 blank-creds 才发 ANONYMOUS,非无条件 `DEFAULT`;S3 override 恒非空)。**[DV-005 修订:不加字段,内联条件——见完成态]** +- **TDD(可 UT 落地,参 FU-T01)**:合成无凭据 OSS/COS/OBS map → `toBackendProperties().toMap()` 应含 `AWS_CREDENTIALS_PROVIDER_TYPE=ANONYMOUS`(RED→GREEN);带 ak/sk 则不发该键(或发 SimpleAWS-等价,对照 legacy `AbstractS3CompatibleProperties.doBuildS3Configuration` :117-129 + 各 `OSS/COS/OBSProperties` 不 override `getAwsCredentialsProviderTypeForBackend`)。 +- **验收**:无凭据 OSS/COS/OBS typed BE map 与 legacy 等价(含 `ANONYMOUS`);有凭据零变化;UT 与 S3 typed 对照;checkstyle 0;`git diff` 仅落 `fe-filesystem-{oss,cos,obs}/**`(recon 若证须 api/spi 共享类型则先 AskUserQuestion)。 +- **依赖**:P1-T04(暴露缺口,对抗 review `wf_09745716-d48`)。**D-011 授权**触碰 `fe-filesystem-{oss,cos,obs}`(白名单已 +)。**先做(与 FU-T03 一道)再 P1-T06。** + +### FU-T03 ✅(2026-06-18 完成;D-011 授权)给 fe-filesystem S3/OSS/COS/OBS 加调优默认 UT 断言(修 R-006) +- **完成态(2026-06-18,commit 待提交)**:4 个测试类各加 1 个 `toMaps_emit*TuningDefaultsWhenNotConfigured`(test-only,不动 main)——不显式设调优键时,断 **BE map**(`toMap()`/S3 `toFileSystemKv()`:`AWS_MAX_CONNECTIONS`/`AWS_REQUEST_TIMEOUT_MS`/`AWS_CONNECTION_TIMEOUT_MS`)+ **Hadoop map**(`toHadoopConfigurationMap()`:`fs.s3a.connection.maximum`/`...request.timeout`/`...timeout`)= S3 `50/3000/1000`、OSS/COS/OBS `100/10000/10000`。**期望值用字面量非 `DEFAULT_*` 常量**(否则改常量两侧同步变=测试恒绿,守不住)。已核对 legacy parity:`S3Properties.Env`(50/3000/1000)、`OSS/COS/OBSProperties`(各 100/10000/10000)。**mutation 证**:sed 改 4 个 `DEFAULT_MAX_CONNECTIONS`→ 4 测全红(`expected <50> but was <99>` / `<100> but was <999>`),revert 后全绿。验证:S3 15/0·OSS 14/0·COS 13/0·OBS 13/0 + 全 sibling suite 绿、checkstyle 0、`git diff` 仅落 4 个 `*PropertiesTest.java`。 +- **做什么**:在 `S3/Oss/Cos/ObsFileSystemPropertiesTest` 加 **test-only** 断言守护调优默认值:S3=`fs.s3a.connection.maximum=50`/`request.timeout=3000`/`timeout=1000`(BE `AWS_MAX_CONNECTIONS=50` 等)、OSS/COS/OBS=`100/10000/10000`。守 P1-T03 删 paimon canonical tuning 测试暴露的 fe-filesystem 测试缺口。 +- **TDD**:断言 `toHadoopConfigurationMap()` / `toBackendProperties().toMap()` 在不显式设调优键时发各自默认值(mutation:改 fe-filesystem 字段默认 → 测试应红)。**功能今日正确**(字段默认真发),本任务=补显式 UT 守护。 +- **验收**:4 个 `*FileSystemPropertiesTest` 各含调优默认断言(S3 50/3000/1000;OSS/COS/OBS 100/10000/10000);checkstyle 0;纯 test additive,不动 main(除非 R-006 与 FU-T02 共享改动);`git diff` 仅落 `fe-filesystem-{s3,oss,cos,obs}/src/test/**`。 +- **依赖**:P1-T03(删 canonical 测试暴露,对抗 review `wf_76df09a4-c2f`)。**D-011 授权**。**先做(与 FU-T02 一道)再 P1-T06。** + +## 阶段日志(append-only) +- 2026-06-17:创建任务清单(P0×2 / P1×6 / P2×5),状态全 ⬜,待用户批准后开始 P1。 +- 2026-06-17:3 设计点定稿(D-006 provider 取代 MetaStoreType 枚举 / D-007 fe-kerberos 叶子 / D-008 vended 边界);P2-T01/T02 改写(去枚举、加 MetaStoreProvider);新增 P3a/P3b(Kerberos)。 +- 2026-06-17:用户确认 **P3a 纳入本次** + 模块名 **`fe-kerberos`**。核心任务计数 13 → **14**(+P3a-T01);P3b 仍 follow-up(范围外占位)。 +- 2026-06-21:**P2-T04 ✅ + P2-T05 ✅(用户手动 docker 验证)→ P2 全 5/5、子线 docker 真闸通过**;主线 P5-T29(B8) 亦完成。**D-017:P3b 提前到 P6 iceberg 之前单独做**(P3b-T01 `⬜ 范围外` → `🚧 active`,补现码 scope:12 类机制 + 40 消费方 blast radius + 双接口统一 + trino→JDK)。仅文档更新。 +- 2026-06-21:**P3b-T01 commit 3 ✅(commit `5e3e8963023`,未 push)→ P3b-T01 三步全完成(commit 1/2/3)**。统一双 `HadoopAuthenticator` 接口到 fe-kerberos 单接口 + 删 fe-filesystem-hdfs 的 `KerberosHadoopAuthenticator`/`SimpleHadoopAuthenticator` + fe-filesystem-spi `HadoopAuthenticator`(IOCallable)+`IOCallable`;4 消费方重指向。用户 AskUserQuestion 定 **pure consolidation**(采纳 fe-kerberos 行为,DV-010)。对抗 review `wf_b1a4e7e4-b51`(3 lens+verify)揪出 3 MINOR 全修(empty-string `hadoop.username` regression + 补测 + scrub stale 文档)。fe-filesystem-hdfs 79/0/0 + checkstyle 0 + import-gate 0 + grep 净 + reactor test-compile 净(唯一失败=pre-existing paimon HiveConf shade quirk)。⚠️ **docker kerberos e2e NOT run(真闸)**。任务计数 14→**15/15**(核心全完成;docker 验证待跑)。 +- 2026-06-18:**P1-T07 ✅**(彻底删除 fe-property 孤儿模块,D-016):删目录(27 文件)+ fe/pom.xml 两声明 + 清 5 处 stale 注释(一并清理,用户选);全 FE reactor test-compile BUILD SUCCESS(fe-core 实编译,0 ERROR)+ paimon 278/0/1skip + hdfs 78/0/0 + grep fe-property 归零。任务计数 11→**12/15**。commit `13d3876d25d`,已 push `catalog-spi-07-paimon`+`master-catalog-spi-07-paimon`,PR #64445 评论 `run buildall`。 +- 2026-06-18:**RV-T01(全连接器 clean-room review)提升到主线**:初排为本子线下一步,后经用户澄清(`metastore-storage-refactor/` 是 metastore-refactor 专属子线,全连接器 review 属 catalog-spi 主线)→ spec 移到主线 `../HANDOFF.md`(6 维度 + 不注入开发历史先验),先于 B8 legacy 删除(legacy=对照基线)。本目录仅留指针;本子线自身剩余 = P2-T04/T05(主线 review 后)。 diff --git a/plan-doc/per-statement-table-owner-port/HANDOFF.md b/plan-doc/per-statement-table-owner-port/HANDOFF.md new file mode 100644 index 00000000000000..81eccb6b6ed800 --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/HANDOFF.md @@ -0,0 +1,48 @@ +# 🤝 Session Handoff —— 每语句表加载归属者 · 移植/重构 + +> **滚动文档**:每 session 结束覆盖式更新,只留下一个 session 必须的上下文。 +> 开场必读顺序、模板、铁律见 [`README.md`](./README.md);进度见 [`progress.md`](./progress.md);状态见 [`tasklist.md`](./tasklist.md)。 + +--- + +# 🆕 本轮(2026-07-20 session 8)已完成:**缓存隔离——list≠load 越权修复(RD-4)+ 读写重构主线四步收官** + +## 一句话结果 +- **背景**:某些 iceberg 目录(REST + `iceberg.rest.session=user`)按登录用户逐个鉴权,鉴权发生在**加载表的远端往返里**;而"能列表名"与"能加载表"是两套独立授权。此前若干**跨查询缓存**命中就返回、**不走加载表**→"能列不能载"的用户经缓存读到别人才该见的元数据(真实越权,用户已确认 list≠load)。 +- **grounding 纠偏(对抗验证)**:真正**活跃**的越权只 2 个——iceberg `latestSnapshotCache` + fe-core schema 缓存;`partitionCache`/`formatCache` 靠上游 per-user loadTable 已护(脆弱,非活跃漏);**异构 HMS 网关是"潜在"非"活跃"洞**(兄弟被强制 hms flavor、永不 session=user);原计划"分片 + 传属主标签"两点被证伪。 +- **用户签字(三决策)**:①**禁用路线**(session=user 下把授权敏感缓存置空,非身份分片)——无需新 SPI、**无撤权陈旧窗口**、贴合 Doris 现状(session=user 本就每次现载)、镜像已有 tableCache/commentCache 先例;②**三投影缓存统一禁用**(成"session=user ⇒ iceberg 无活跃跨查询元数据缓存"不变量);③**网关加 fail-loud 守卫**。 +- **已落地(全绿,4 独立 commit)**: + - **4a**(`9f88aa4`)iceberg `latestSnapshotCache`/`partitionCache`/`formatCache` 在 `isUserSessionEnabled()` 置空 + `beginQuerySnapshot` null 回退(`loadLatestSnapshotPin`)+ `invalidate*` 三处 null 守卫。 + - **4b**(`92289e3`)fe-core `shouldBypassSchemaCache(SessionContext)`(默认 false)+ `PluginDrivenExternalCatalog` override(判据同库/表名缓存 bypass)+ `ExternalTable.getSchemaCacheValue()` bypass 现读(覆盖 MVCC latest 路径)。 + - **4c**(`c40af11`)hive `getOrCreateIcebergSibling` 建成兄弟若含 `SUPPORTS_USER_SESSION` 即 fail-loud(今天恒不触发,守未来)。 + - **4d**(`5cb5fb9`)防漂移门禁 `tools/check-authz-cache-sharding.sh`(marker:`authz-cache-session-user-disabled`/`authz-cache-exempt`)+ 自测 + 挂 fe-connector pom validate。 +- **验证**:116 目标单测全绿;checkstyle 0;三门禁 exit 0;**clean-room 对抗复审 0 blocker/major**(安全本体全 clean;仅 2 findings 针对门禁本身,minor 已修=正则放宽覆盖 raw `Cache<`/`LoadingCache<`/静态形态)。 +- **未动**:iceberg 表 side-car(正交);`getIdentityShardKey()` SPI 不新增(禁用路线不需要);hive/paimon/hudi 无 session=user 轴不动。 + +--- + +# ➡️ 下一个 session = **统一补 e2e(读写重构主线的唯一欠账)** + +> 读写重构主线 **RD-1→RD-4 / STEP 1–4 全部完成**(读取键石 + HMS 兄弟扇出 + 写入共用 + 缓存隔离)。剩下的是各步一路"择机统一补"的 e2e 欠账,宜一次补齐。 + +## 第一件事(先读) +1. 读 `progress.md` session 6/7/8 尾(各步欠的 e2e 清单)+ 架构记忆 `hms-iceberg-delegation-needs-e2e`。 +2. 读 `designs/P4-cache-isolation-implementation-design.md` §4 + `designs/P3-write-sharing-implementation-design.md` §4(欠账 e2e 范围)。 + +## e2e 待补清单(落 `regression-test/suites/external_table_p2/refactor_catalog_param`) +- **异构 HMS 网关**(RD-2/RD-3 欠):异构目录跑 INSERT/DELETE/MERGE/ALTER/EXECUTE,断言与独立 iceberg 目录同表同结果(对齐 `hms-iceberg-delegation-needs-e2e`)。 +- **越权**(RD-4 欠):造 can-list-cannot-load 用户,断 REST session=user 目录下命中不泄漏别人的 schema/snapshot/分区。 +- 环境依赖真实 REST catalog(session=user)+ 异构 HMS,需先确认 docker 编排是否支持;不支持则先补编排或标注前置。 + +## 铁律 / 闸门提醒 +- 用户 2026-07-19 已豁免铁律 A(fe-core 只减不增)。仍守:连接器 connector-agnostic、**fe-connector-* 不得 import fe-core**(门禁)、新 SPI 用 `getUser()`/主体字节不解析凭证令牌。 +- **动码前探测并发活动**(git log/status + maven 进程 + 近 90s mtime),发现活跃即停手(`concurrent-sessions-shared-worktree-hazard`)。 +- e2e 沿用"择机统一补",但主线已收官,宜作为独立收尾专项立项。 + +--- + +# 🗂 遗留 / 关联 +- 分期定稿:`designs/expanded-scope-phasing-and-security-decisions.md`(§3 缓存隔离、§4 分期、§6 残留风险)。 +- as-built:`designs/P4-cache-isolation-implementation-design.md`(缓存隔离)、`designs/P3-write-sharing-implementation-design.md`(写入共用)。 +- 门禁:`tools/check-fecore-metadata-funnel.sh`、`tools/check-connector-imports.sh`、`tools/check-authz-cache-sharding.sh`(新)。 +- 架构记忆:`iceberg-table-resolution-cache-scoping`、`catalog-spi-session-user-no-live-crossquery-cache`(新)、`hms-iceberg-delegation-needs-e2e`、`catalog-spi-plugin-tccl-classloader-gotcha`。 diff --git a/plan-doc/per-statement-table-owner-port/README.md b/plan-doc/per-statement-table-owner-port/README.md new file mode 100644 index 00000000000000..41c6fdaab3dbf9 --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/README.md @@ -0,0 +1,88 @@ +# 📦 任务空间 —— 把「每语句表加载归属者」范式从 iceberg 移植到其它连接器 + +> **独立任务空间**。目标:把 iceberg 上已落地的「一条语句里同一张表只加载一次、读/扫描/写共享」范式(PERF-07 蓝本),移植到其它 catalog-backed 连接器。 +> **重要前提**:这套范式依赖的**中性 fe-core / fe-connector-api 地基已经就位**(PERF-07 建)——移植是**纯连接器侧工作,无需再改 fe-core**。 +> 开场必读顺序、单连接器立项流程、约束铁律见下。协作规范沿用 [`../AGENT-PLAYBOOK.md`](../AGENT-PLAYBOOK.md)。 + +--- + +## 🚩 一句话背景 + +新框架下,一条 DML(尤其 DELETE/MERGE)里同一张表会被**反复远端加载**:读元数据、扫描规划、写成形、开事务各自 `loadTable`,最糟一条语句 3~5 次。iceberg 已通过 PERF-07 修掉(读/扫描/写/beginWrite 共享同一「每语句表加载归属者」,整条语句一张表只加载一次)。本空间把同一范式推广到其它同样"metastore/catalog 反复加载同表"的连接器。 + +> ⚠️ 诚实定位(对齐 iceberg 蓝本):这类改动**性能收益有限**(写侧 ≈0,读侧靠既有缓存已覆盖一部分),**主价值 = 架构连贯 + 删重复加载/单例/胖句柄**。是否对某连接器值得做,取决于它真实的多载程度——**逐连接器复核后再定,不是照单全上**。 + +--- + +## 🧱 已就位的可复用地基(**勿再改,直接复用**) + +PERF-07 已在 fe-core / fe-connector-api 落地一套**连接器无关**的每语句作用域基建(commit `97bdcd6bdbe`): + +| 组件 | 位置 | 作用 | +|---|---|---| +| `ConnectorStatementScope`(SPI + `NONE`) | `fe-connector-api/.../connector/api/ConnectorStatementScope.java` | 中性接口:` T computeIfAbsent(String key, Supplier)`;`NONE`=不缓存(逐次加载),离线/无上下文默认 | +| `ConnectorSession.getStatementScope()` | `fe-connector-api/.../connector/api/ConnectorSession.java` | 默认方法→`NONE`;连接器经它够到每语句作用域 | +| `ConnectorStatementScopeImpl` | `fe-core`(CHM 背书) | 生产实现,挂 `StatementContext` | +| `StatementContext` 懒建字段 + `resetConnectorStatementScope()` | `fe-core` | 唯一贯穿整条语句的对象;不在 close/release 清、随 GC | +| `ConnectorSessionImpl`/`Builder` **构造期捕获** | `fe-core` | 因扫描流式/分批 off-thread 复用同一 session、实时读 thread-local 会失效,故构造期捕获作用域引用 | +| `ExecuteCommand` 一行重置 | `fe-core` | 预编译 EXECUTE 复用同一 `StatementContext`,每执行重置作用域 | + +**移植一个连接器 = 只写连接器侧代码**(不再碰 fe-core,即不再触"fe-core 只减不增"铁律 A)。 + +--- + +## 🧩 连接器侧移植模板(以 `IcebergStatementScope` 为范本) + +对每个目标连接器,镜像 iceberg 的做法(`fe-connector-iceberg/.../IcebergStatementScope.java` 是唯一现成范本): + +1. **加连接器私有 `XStatementScope` helper**:`sharedTable(session, db, tbl, loader)`,键形如 `x.table:catalogId:db:tbl:queryId`;`session==null`(NONE)时逐次加载(等价无缓存)。值存 RAW 表对象(fe-core 不认连接器类型,保持 connector-agnostic)。 +2. **四处表解析全走 `sharedTable`**:读元数据 / 扫描规划 / 写成形 / `beginWrite`——同一语句同一表命中同一次加载。每处 loader 各保留原授权(`newXBackedOps(session)`,一语句=一用户=一凭证)。 +3. **(若有)拆胖句柄**:若该连接器像 iceberg 那样在 handle 上挂了 `resolvedTable` 之类的"同 handle 内 memo",评估是否随之下沉到作用域、让句柄回归纯坐标。**没有就不做**(surgical)。 +4. **(若有)把每语句暂存下沉到作用域**:iceberg 把删除清单暂存(`IcebergRewritableDeleteStash`)从单例下沉到作用域同键 map 并整删该类。目标连接器若有类似"扫描填、写读"的跨臂暂存,同样下沉;没有就跳过。 +5. **响亮失败守卫**:若存在"作用域缺失=静默错误"的路径(iceberg 的 v3 行级 DML + NONE),加 fail-loud(NONE 下抛,消息含 "per-statement scope")。生产恒有 `StatementContext`,仅离线触发。 + +**测试守门**(镜像 iceberg): +- 每语句加载计数守门:读+扫描(+写)共享作用域 → 远端 `loadTable` 计数 **1**;对照 NONE → N。 +- 作用域隔离:同键读写同实例 / 跨 queryId 隔离(预编译重执行)/ 跨 catalogId 隔离(跨-catalog 语句)/ NONE 逐次。 +- e2e:留连接器进入 `SPI_READY_TYPES` 的切换阶段统一补(对齐 iceberg 的 e2e 欠账惯例)。 + +--- + +## 🎯 候选连接器(2026-07-19 初摸,**范围待下 session 复核确认**) + +| 连接器 | 写路径 | `loadTable`/`getTable` 触点 | 候选度 | 备注(待 recon 核实) | +|---|---|---|---|---| +| **paimon** | 有(MTMV/写;`getWritePlanProvider` 未在连接器层 override,写路径结构待确认) | **4 文件**(最多) | **高** | 读侧多载最像 iceberg;`fe-connector-cache` 已复制框架副本 | +| **hive / hms** | 有(`HiveWritePlanProvider`) | 3 文件 | **中-高** | plain-hive 读写活;hms 网关委派 iceberg/hudi 兄弟(休眠,未进 `SPI_READY_TYPES`);**网关按 handle 选 sibling 的特殊性要单独设计** | +| **hudi** | 有(`HudiConnectorMetadata` 写面) | 1 文件 | **中** | 读为主(MTMV 新鲜度已提升);写臂/多载程度待确认 | +| maxcompute / es / jdbc / trino | 部分有写 | **0**(不走 metastore `loadTable` fan-out) | **低 / 大概率不适用** | 解析模型不同(表对象连接器侧缓存 / 无每语句重载);下 session 确认后**排除**居多 | + +> **第一步不是动码,是逐连接器 recon**:确认每个候选真实的"一条语句加载同表几次"、现有缓存覆盖哪段、是否有胖句柄/跨臂暂存。复核可能把某连接器**判为不必做**(如 iceberg PERF-05/10 就被复核缩小/暂缓过)。 + +--- + +## 🔁 单连接器立项流程(一次一个连接器,对齐 `step-by-step-fix`) + +1. **recon(动码前)**:grep 该连接器读/扫描/写/beginWrite 的表解析调用链,数"一条 DML 加载同表几次"、现有缓存边界、有无胖句柄/跨臂暂存。产出该连接器的现状图。 +2. **写设计** `designs/PORT--design.md`:Problem / 现状调用链 / 移植方案(按上面 5 步模板裁剪)/ 与 iceberg 蓝本差异 / Risk / Test Plan(含加载计数守门)。 +3. **设计红队**(对抗 review,clean-room 偏好):至少一个独立视角挑刺——重点核**授权/凭证隔离**(作用域跨用户是否泄漏,参照 iceberg 的 session=user/vended 判定)与**快照/OCC 一致性**。 +4. **实现**:纯连接器侧、最小改动、镜像 `IcebergStatementScope`。 +5. **验证**:连接器 UT 全绿 + 加载计数守门;纯连接器改动**免 fe-core 两段验**(地基已在)。 +6. **独立 commit** + **写小结** `designs/PORT--summary.md` + 勾 `tasklist.md` + 追加 `progress.md` + 覆盖 `HANDOFF.md`。 + +--- + +## 🧱 约束铁律 + +1. **地基勿再改**:`ConnectorStatementScope`/`getStatementScope()`/`ConnectorStatementScopeImpl`/`StatementContext` 已就位,移植**只写连接器侧**——不再碰 fe-core(不重新触铁律 A)。若某连接器暴露地基缺口,先停手交 review,别顺手改 fe-core。 +2. **作用域跨用户即泄漏**:作用域按 `catalogId+queryId`(+db/tbl)建键、存 RAW、随 session 生死;但**缓存命中会绕过 per-user loadTable 里的授权**——凡有 `session=user`/凭证语义的连接器,复核作用域共享是否安全(参照 iceberg 判据:授权发生在 load 调用里,缓存命中绕过它=元数据泄漏)。 +3. **连接器不解析属性 / 通用节点 connector-agnostic**:沿用本项目一贯铁律;作用域值为 `Object`,fe-core 不认连接器类型。 +4. **surgical**:模板 5 步里"拆胖句柄""下沉暂存"仅当该连接器真有对应物才做;没有就不做。 + +--- + +## 🔗 与 iceberg 蓝本的关系 + +- **权威范本**:`plan-doc/perf-hotpath-iceberg/designs/FIX-PERF-07-unified-per-statement-table-owner-{design,summary}.md`;架构结论落 memory `iceberg-table-resolution-cache-scoping`。 +- **代码范本**:`fe-connector-iceberg` 的 `IcebergStatementScope` + 四处 `resolveTable*` 走它 + fail-loud(commit `ea7fd1f6e7a`)。 +- 本空间是那套范式的**推广执行区**,与 `perf-hotpath-iceberg` 任务空间平行、不混流。 diff --git a/plan-doc/per-statement-table-owner-port/designs/C3-read-reroute-verified-checklist.md b/plan-doc/per-statement-table-owner-port/designs/C3-read-reroute-verified-checklist.md new file mode 100644 index 00000000000000..c52919558bdb74 --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/designs/C3-read-reroute-verified-checklist.md @@ -0,0 +1,100 @@ +# C3 读取侧改道 —— 逐缝核对清单(动手前,行号已按当前代码校准) + +> 承接 `P1-implementation-design.md` §2/§3 与 `expanded-scope-phasing-and-security-decisions.md` §2。 +> 本文是 C3(读取侧改道 + 后台读穿纠正)**动手前的核对关**产物:把设计里的改道表逐条对当前代码行号重新核实(会漂移),并经一轮对抗式复核(workflow `wf_6e2967a9-1a2`,10 agents:7 census reader + 3 adversarial verifier)。**本文不动代码。** + +--- + +## ✅ 实施状态(2026-07-19 session 4) + +已全部落地并验证:`buildCrossStatementSession` 助手 + 9 处 NONE 读穿(含名字映射两缝,含 fetchRowCount ANALYZE 修复)+ 49 处改道 + 扫描节点 `cachedMetadata`/`metadata()`。测试适配 11 文件 + 新守门 `ConnectorSessionImplTest.explicitNoneStatementScopeWinsOverLiveContext`。验证:目标 247 测全绿 + checkstyle 0 + 主编译 SUCCESS。**剩防漂移门禁**(读取键石收官,见 HANDOFF)。 + +## 0. 结论一句话 + +- fe-core 里连接器 `getMetadata(` 工厂缝 = **66 处**(80 raw grep 减 9 处测试静态 + 4 处漏斗自身 + 1 处 `RuntimeProfile.node.getMetadata()`)。**完整切成四类,相加正好 66,无遗漏/无重叠/无未分类缝。** +- 全部设计命名的行号**零漂移**(例外:扫描节点 9 处 + 尾部两处漂 +13,纯位置漂移,方法未变)。 +- 揪出两处需拍板的偏差(§4)。 + +## 1. 四类分区(66 = 49/51 改道 + 7/9 读穿 + 8 写延后 + 4 漏斗自身在 66 之外) + +> 注:4 处漏斗自身(`PluginDrivenMetadata` 内 3 javadoc + 1 factory lambda)不计入 66。 + +### A. 改道进漏斗(`connector.getMetadata(session)` → `PluginDrivenMetadata.get(session, connector)`) + +| 文件 | 缝 | 当前行 | 线程 | +|---|---|---|---| +| PluginDrivenExternalCatalog(DDL 19 + tableExist 1 = 20 处) | createTable/createDb/dropDb/dropTable/renameTable/truncateTable/addColumn(s)/dropColumn/renameColumn/modifyColumn/reorderColumns/createOrReplaceBranch/createOrReplaceTag/dropBranch/dropTag/addPartitionField/dropPartitionField/replacePartitionField + tableExist | 332,422,501,550,598,670,714,807,823,838,853,868,884,915,931,947,963,988,1003,1018 | on-thread | +| PluginDrivenExternalTable(读 12 处) | getSyntheticScanPredicates(159)/resolveWriteCapabilityHandle(189,读——探写能力但读元数据)/resolveIsView(576)/getViewText(594)/getShowCreateTableDdl(617)/fetchSyntheticWriteColumns(727,读——getFullSchema 路径)/getNameToPartitionItems(820)/getNameToPartitionValues(881)/getMetadataTableRows(918)/getComment(950)/getSupportedSysTables(981)/(第 12 处 1304) | 159,189,576,594,617,727,820,881,918,950,981,1304 | on-thread/planning | +| PluginDrivenExternalDatabase.getLocation | 1 | 84 | on-thread | +| PluginDrivenScanNode(扫描 9 处,**存字段而非裸改**) | create(静态工厂,206) + convertPredicate(806)/tryPushDownLimit(846)/tryPushDownProjection(865)/pinMvccSnapshot(910)/pinRewriteFileScope(1055)/pinTopnLazyMaterialize(1074)/buildColumnHandles(1902)/buildRemainingFilter(1939) | 见左 | planning-thread | +| PluginDrivenMvccExternalTable(3 处) | materializeLatest(143,mixed)/loadSnapshot(358,on-thread)/resolveFreshnessProbe(725,background) | 143,358,725 | 见左 | +| misc(4 处命令/TVF) | ShowPartitionsCommand(285)/ConnectorExecuteAction(128)/CallExecuteStmtFunc(101)/JdbcQueryTableValueFunction(52) | 见左 | on-thread | + +**扫描节点存字段细节**:加懒字段 `private volatile ConnectorMetadata cachedMetadata;` + `metadata()` 访问器(`if(cachedMetadata==null) cachedMetadata = PluginDrivenMetadata.get(connectorSession, connector);`),8 处 per-method 全改调 `metadata()`。**create(206) 是静态工厂无实例** → 直接调 `PluginDrivenMetadata.get(session, connector)`(不能用实例访问器)。字段现状:`connector:148`、`connectorSession:149`、`currentHandle:152`;镜像现有 `volatile resolvedScanProvider:184`。8 处均在单规划线程(并发 appendBatch 之前)。 + +### B. 后台读穿加载器(跨语句缓存 loader,**强制 NONE 会话**,绝不绑定语句作用域) + +| 文件 | 缝 | 当前行 | 线程 | +|---|---|---|---| +| PluginDrivenExternalCatalog.listDatabaseNames | 1 | 295 | background | +| PluginDrivenExternalCatalog.listTableNamesFromRemote | 1 | 311 | background | +| PluginDrivenExternalTable.initSchema | 1 | 460 | background(schema-cache) | +| PluginDrivenExternalTable.getColumnStatistic | 1 | 1052 | background(stats-cache) | +| PluginDrivenExternalTable.getChunkSizes | 1 | 1087 | analyze-thread | +| PluginDrivenExternalTable.fetchRowCount | 1 | 1153 | **mixed**(含 ANALYZE 执行线程,见 §3) | +| MetadataGenerator.dealPluginDrivenCatalog | 1 | 1329 | background(BE-driven) | + +### C. 写入路径(**留后续步骤,C3 不碰**,共 8 处) + +`resolveWriteTargetHandle(PluginDrivenExternalTable:133)` + PhysicalPlanTranslator(612,660) + PluginDrivenInsertExecutor(206) + BindSink(673,712) + IcebergRowLevelDmlTransform(112) + PhysicalIcebergMergeSink(303)。 + +--- + +## 2. 强制 NONE 的机制(已源码证实为「与裸直调字节等价」) + +- `ConnectorSessionBuilder.withStatementScope(ConnectorStatementScope.NONE)` 显式短路 `captureStatementScope()` 的第一道 `!= null` 守卫 → 与线程 ConnectContext 状态**完全无关地强制 NONE**(`ConnectorSessionBuilder.java:190-203`)。 +- `ConnectorStatementScope.NONE.computeIfAbsent(key,loader) = loader.get()`(每次跑工厂、零缓存);`getOrCreateMetadata` 默认走它;`closeAll` 默认 no-op → NONE 无可关之物。 +- 故 `PluginDrivenMetadata.get(NONE会话, connector)` = **每次新建、零留存**,SPI 文档原话「byte-identical to building metadata every time」。 +- 7 处 loader 现状全是 `.buildConnectorSession()`(读 ConnectContext,有 ctx 即绑活作用域=泄漏隐患)。**修法 = 新增 `PluginDrivenExternalCatalog.buildCrossStatementSession()`**:镜像 `buildConnectorSession()` 两分支,末尾统一 `.withStatementScope(ConnectorStatementScope.NONE)`。7 处改调它。 + +--- + +## 3. ANALYZE 隐患(已证实,修法并入 fetchRowCount 强制 NONE) + +- `AnalyzeTableCommand.doRun` → `AnalysisManager.buildAnalysisJobInfo:349` → `:418` 在 `getRowCount()<=0` 时**同步直调** `table.fetchRowCount()`,全程在 ANALYZE 语句执行线程(ConnectContext + StatementContext 均活)。 +- `fetchRowCount:1152` 经 `buildConnectorSession()` 捕获该活作用域。 +- **今日无害**:fetchRowCount 现走**裸直调** `connector.getMetadata(session)`(不经漏斗),故什么都没 memo 进作用域,会话只是持了个引用。隐患是**前瞻性**的:一旦 fetchRowCount 改走漏斗(且 metadata 挂真实可关资源),会把资源钉在整个 ANALYZE 生命周期、并在语句末误关。 +- **修法**:fetchRowCount 用 `buildCrossStatementSession()` 强制 NONE。**无需单独改 AnalysisManager**——强制 NONE 后无论走不走漏斗都读穿。 + +--- + +## 用户拍板(2026-07-19) + +- **偏差①**:名字映射两缝 `fromRemoteDatabaseName(1106)`/`fromRemoteTableName(1112)` → **归入强制 NONE 读穿**(→ 读穿 9 处、改道 49 处)。 +- **偏差②**:读穿 loader → **走统一入口 + 传 NONE 会话**(甲案,门禁零例外)。 + +## 4. 两处需拍板的偏差(对抗复核揪出) + +### 偏差①:名字映射两缝 `fromRemoteDatabaseName(1106)`/`fromRemoteTableName(1112)` +- 设计原判:on-request 的 DDL 改道(进 A 类改道)。 +- 复核实证(两 agent 独立):其**全部调用方跑在离请求线程的名字缓存 loader / buildDbForInit / buildTableForInit / metastore-event-sync 路径**(`ExternalCatalog:586,970`;`ExternalDatabase:224,277,659`),与后台 loader 同族。裸改道进漏斗 → 若某次同步首载落在有活 ConnectContext 的请求线程,会把 memo 值绑进**活得比语句久的名字缓存**。 +- **建议**:与其它 7 处 loader 同等对待——**强制 NONE**(→ 读穿 9 处、改道 49 处)。 + +### 偏差②:读穿 loader 的落地形态(漏斗+NONE vs 裸直调+门禁白名单) +- 旧设计 §8 选「保留裸直调 + 防漂移门禁白名单」(当时理由「更清晰、避免塞残留 scope」,该理由在强制 NONE 后已失效——NONE 无 scope 可塞)。 +- 已证实「漏斗+NONE」与「裸直调」**字节等价**,且让防漂移门禁**零例外**(fe-core 里不再有合法的裸 `connector.getMetadata(`)。 +- **建议**:读穿 loader 也走漏斗(传 NONE 会话),门禁更干净。 + +--- + +## 5. 落地子提交建议(C3 内部,每步独立可编译/回退) + +1. 新增 `buildCrossStatementSession()` 助手(连接器无关,强制 NONE)。 +2. 后台读穿:7(或 9)处 loader 改用助手 + 走漏斗;含 fetchRowCount ANALYZE 隐患修复。守门:加载计数=1(对照 NONE=N)、跨 catalog/queryId 隔离、ANALYZE 首次不绑活作用域。 +3. 读/DDL/misc/MVCC 改道:49(或 51)处裸改道进漏斗。 +4. 扫描节点存字段:加 `cachedMetadata`+`metadata()`,9 处改调。 +- 防漂移门禁(bash grep,仿 `check-connector-imports.sh`)择机随 STEP 1 收尾补(形态取决于偏差②)。HMS 兄弟扇出 = 下一大步,不在 C3。 + +## 6. 已核实为「不动」 + +- 写入 8 处(§1.C);`getMetadataTableRows()` 数据调用(PluginDrivenExternalTable:923,非工厂缝);`TestExternalCatalog` 9 处测试静态 Map;`RuntimeProfile.node.getMetadata():260`;`PluginDrivenMetadata` 漏斗自身 4 处。 diff --git a/plan-doc/per-statement-table-owner-port/designs/P1-implementation-design.md b/plan-doc/per-statement-table-owner-port/designs/P1-implementation-design.md new file mode 100644 index 00000000000000..8cc915afe6b99e --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/designs/P1-implementation-design.md @@ -0,0 +1,180 @@ +# P1 实现设计(seam-by-seam)—— 引擎自持每语句 ConnectorMetadata 实例 + +> **范围 = 仅 P1 键石**:让一条语句、一个 catalog(一个属主连接器)在读/扫描/DDL/MVCC 路径上**恰好复用一个 memoized `ConnectorMetadata` 实例**,`connector.getMetadata` 保持纯工厂、唯一 memo 在 fe-core 管道,语句末**确定性 close**。写路径(7 缝)与工作集下沉、按身份分片缓存分别留 P3/P2/P4。 +> **本文仍不动代码**:是"可动工蓝图 + commit 切分"。目标架构全景见 [`trino-parity-metadata-redesign-design.md`](./trino-parity-metadata-redesign-design.md)。 +> 全部 `file:method:line` 来自本轮只读 grounding(workflow `wf_7e537094-44f`,已核实)。 + +--- + +## 0. P1 成功判据 + +- 一条查询里,对同一 `(catalogId, 属主连接器)`,`connector.getMetadata(session)` 只被真正调用**一次**;读/扫描/DDL/MVCC 的所有 seam 复用该实例。 +- 该实例在**查询结束、BE/pump 静默之后**被确定性 `close()`(P1 里 close 仍是 no-op,只验证接线正确);预编译 EXECUTE 重执行不泄漏;异构 HMS 网关下每 sibling 一实例。 +- **P1 字节中性**:`NONE`(离线/测试/无 ConnectContext)下逐次工厂=今日行为;perf delta≈0(语句内去重本就有,收益=单漏斗 + 无 per-connector memo + 确定性生命周期)。 +- 守门:见 §7。 + +--- + +## 1. 新增 / 改动的 SPI 与 fe-core plumbing(精确签名) + +### 1.1 `ConnectorStatementScope`(fe-connector-api,加一个默认方法) +现状:` T computeIfAbsent(String key, Supplier loader)` + `NONE`(`connector/api/ConnectorStatementScope.java:45,48`)。 +**加**(默认方法,`NONE` 天然逐次不缓存): +``` +// 便利入口:类型化 metadata memo;默认经 computeIfAbsent,故 NONE 下逐次跑 factory +default ConnectorMetadata getOrCreateMetadata(String key, Supplier factory) { + return computeIfAbsent(key, factory); +} +// 语句末确定性关闭;默认 no-op,故 NONE 恒惰性 +default void closeAll() { } +``` +> 值仍以 `Object` 存(fe-core 不认连接器类型,守 connector-agnostic 铁律)。 + +### 1.2 `ConnectorStatementScopeImpl`(fe-core) +现状:`ConcurrentHashMap` + `computeIfAbsent`(`connector/ConnectorStatementScopeImpl.java:38-44`)。 +**加** `closeAll()`:遍历 values,对 `Closeable`/`AutoCloseable` 者 best-effort 关闭(log-and-continue,仿 `QueryFinishCallbackRegistry` 隔离);**幂等**(关一次后清标记/清表,重复调用无害——因 close 会从多处触发,见 §4)。 + +### 1.3 静态 funnel(fe-core,新类 `PluginDrivenMetadata`) +``` +public static ConnectorMetadata get(ConnectorSession session, Connector connector) { + ConnectorStatementScope scope = session.getStatementScope(); // 默认 NONE + String key = "metadata:" + session.getCatalogId(); // 普通连接器 + return scope.getOrCreateMetadata(key, () -> connector.getMetadata(session)); +} +``` +- `NONE`(无 ConnectContext/StatementContext)→ `getOrCreateMetadata` 走 `computeIfAbsent` 的 NONE 实现 → **逐次 `connector.getMetadata(session)`,零跨语句缓存**(off-thread loader 即便误路由也安全)。 +- `session` 仍要照常构造(后续 `metadata.xxx(session, …)` 要它);**只 memoize `getMetadata` 的结果**。 +- 证据:`session.getCatalogId()` 恒有(`buildConnectorSession` 一律 `withCatalogId(getId())`,`PluginDrivenExternalCatalog.java:1118`);`ConnectorSession.getCatalogId():69`、`getQueryId():31`。 + +### 1.4 `ConnectorMetadata.close()`(已存在,无需新增) +`ConnectorMetadata extends 6 Ops + Closeable`,默认 `close() throws IOException` 为 no-op;**无连接器 override、无 fe-core 调用点**(`connector/api/ConnectorMetadata.java:248`)→ 开始 memoize + 在 closeAll 里调它是**生命周期安全**的。P1 不改各连接器 close(仍 no-op),只验证接线。 + +### 1.5 close 回调注册(见 §4;不新开 `StatementContext.close` 步) + +--- + +## 2. 66 处 seam 的改道规则 + +| 组 | 位置 | 数量 | P1 处置 | 说明 | +|---|---|---|---|---| +| **A 读** | `PluginDrivenExternalTable`(13)+`ExternalDatabase.getLocation`(1) | 14 | **改道 funnel** | 均 on-thread;connector=`getConnector()`,session=`buildConnectorSession()` | +| **A/排除** | `PluginDrivenExternalTable.getChunkSizes:1087` | 1 | **保持直调(fallback)** | 统计分析线程,常无 ConnectContext | +| **B DDL** | `PluginDrivenExternalCatalog` DDL(19)+`tableExist`(1)+name-mapping(2) | 22 | **改道 funnel** | on-thread;connector=字段 | +| **B/排除** | `listDatabaseNames:295`、`listTableNamesFromRemote:311` | 2 | **保持直调** | name-cache loader,off-ConnectContext | +| **C 扫描** | `PluginDrivenScanNode`(create:205 + 8 处 per-method) | 9 | **改道 + 存字段** | 见 §3 | +| **D 写** | PhysicalPlanTranslator×2 / InsertExecutor / BindSink×2 / IcebergMergeSink / IcebergRowLevelDmlTransform | 7 | **P1 不动,留 P3** | 见 §6(read-vs-write 复用) | +| **E MVCC** | `PluginDrivenMvccExternalTable`(143/358/725) | 3 | **改道 funnel**(带 null-ctx fallback) | 主 on-thread,少数后台 metadata-table 扫描 | +| **F 排除** | `initSchema:460`、`getColumnStatistic:1052`、`fetchRowCount:1153` | 3 | **保持直调** | 跨语句缓存 loader,off-ConnectContext | +| **misc** | ShowPartitions/ConnectorExecuteAction/CallExecuteStmt/JdbcQueryTVF | 4 | **改道 funnel** | on-thread 命令/TVF | +| **misc/排除** | `MetadataGenerator.dealPluginDrivenCatalog:1329` | 1 | **保持直调** | BE 驱动 fetch,可能无 ConnectContext | + +- **改道机械动作**:`ConnectorMetadata m = connector.getMetadata(session);` → `ConnectorMetadata m = PluginDrivenMetadata.get(session, connector);`(connector/session 每处都在作用域)。 +- **排除项本质安全**:funnel 在 `NONE`/null-ctx 下本就 fallback 直调;但排除项**保留显式直调**更清晰、且避免误把跨语句 loader 的结果塞进某个残留 scope。 +- **假阳性**:`datasource/test/TestExternalCatalog.java` 的 8 处 `catalogProvider.getMetadata()` 是测试用静态 Map,非 SPI,勿动。 +- **negative**:`planner/PluginDrivenTableSink.java` 无 `getMetadata` 调用(sink 从 translator 收 session+handle),D 组工作全在 PhysicalPlanTranslator。 + +--- + +## 3. 扫描节点(组 C):存字段去 per-method 重取 + +`PluginDrivenScanNode` 已把 `connector`(:147)、`connectorSession`(:148)、`currentHandle`(:151) 存字段(构造期赋值 190-192,create 建 203-205)。 +- **加**懒字段:`private ConnectorMetadata cachedMetadata;` + `private ConnectorMetadata metadata(){ if(cachedMetadata==null) cachedMetadata = PluginDrivenMetadata.get(connectorSession, connector); return cachedMetadata; }`。 +- 8 处 per-method 重取(`convertPredicate:805`/`tryPushDownLimit:845`/`tryPushDownProjection:864`/`pinMvccSnapshot:909`/`pinRewriteFileScope:1054`/`pinTopnLazyMaterialize:1073`/`buildColumnHandles:1889`/`buildRemainingFilter:1926`)一律改调 `metadata()`。 +- **懒**(而非 create 传入):`JdbcQueryTableValueFunction.getScanNode` 直构造节点、手里无 metadata。 +- 8 处都跑在**单规划线程**(在并发 `appendBatch` 之前),但镜像现有 `volatile resolvedScanProvider` 模式加 `volatile` 更稳(off-path 若调 `metadata()` 防竞态)。 + +--- + +## 4. 确定性 close 接线(P1 最关键、含红队盲区①②的确切修法) + +> **⚠ 已更正(2026-07-19,workflow `wf_9250330b-e81` 取证 + 已实现于 commit `12f3e95239b`)**:本节原方案"注册点 = scope 首次创建时(`getOrCreateConnectorStatementScope`)"**会泄漏**——`captureStatementScope` 在**每次 on-thread 会话构建**就急切建 scope,而 DDL/`SHOW`/`DESCRIBE`/`EXPLAIN`/前台 `ANALYZE` 走 `Command.run` 建了 scope 却**永不到 `unregisterQuery`**,回调(连带 scope)永留无 TTL 的注册表。已改为**两层关闭**: +> - **主关闭**:注册点 = `PluginDrivenScanNode.getSplits`(挨着现有 read-txn 回调、同 queryId 键、对象捕获 `scope::closeAll`、跳过 `NONE`)。getSplits 只对**走协调器**的扫描/写/游标取数/内部查询触发,全部到 `unregisterQuery` → 无注册表泄漏、pump 静默后触发。 +> - **兜底关闭**:`StatementContext.close()` 加 `isReturnResultFromLocal` 守卫的 closeAll,覆盖不走协调器的 `Command` 语句(直连经 `ConnectProcessor.executeQuery` finally 已调 close;转发主节点在 `proxyExecute` 补 finally 调 close)。arrow-flight(异步返回)经守卫延后到其自身 query-finish close,不被提前关。 +> - **重置**:`resetConnectorStatementScope()` 改"先 closeAll 再置空";`handleQueryWithRetry` 每次重试前重置 → 预编译 EXECUTE / 重试复用的 StatementContext 不会 memoize 进已关闭 scope。 +> 幂等(closeAll close-once)保证主+兜底双触发时恰好关一次。下面 §4.1/§4.2 保留原始分析(含为何不用 `StatementContext.close` 作**唯一**钩子的红队盲区①),但**注册点以本更正为准**。残留风险见 expanded-scope 文档。 + +### 4.1 钩子 = 现有 query-finish 回调,**不是** `StatementContext.close()` +- 注册:`QeProcessorImpl.registerQueryFinishCallback(String queryId, Runnable)`(`QeProcessorImpl.java:216`)。 +- 触发:`unregisterQuery` → `QueryFinishCallbackRegistry.runAndClear(DebugUtil.printId(queryId))`(`:212`),在 `StmtExecutor.finalizeQuery`(`:1034`,`updateProfile(true)` 之后)里调;而 `coordBase.close()`(`handleQueryStmt:1582`)已先驱动 `Coordinator.close:817`→`FileQueryScanNode.stop:695`→`splitAssignment.stop()` **栅栏住 off-thread pump**。⇒ 回调**严格在 BE drain + pump 栅栏 + profile 等待之后**触发,每 queryId 一次。 +- key 对齐:`connectorSession.getQueryId()` = `DebugUtil.printId(ctx.queryId())`(`ConnectorSessionBuilder.from:75`),与 `runAndClear` 同 key。 +- 先例:hive 读事务释放 `buildReadTransactionReleaseCallback`(`PluginDrivenScanNode.java:687`,在 `getSplits:1207` 无条件注册)——`scope.closeAll()` **完全镜像它**(含 `onPluginClassLoader` TCCL pin,若 closeAll 触及连接器加载对象)。 +- 为何 `StatementContext.close()` 错(盲区①):它只 `releasePlannerResources()`(`StatementContext.java:979`,故意不碰 scope,注释 :205);它从 `ConnectProcessor.executeQuery:410` 在 execute() 之后跑、无对 BE/pump 的栅栏;SQL-cache 路径 `parseFromSqlCache:469` 只 `releasePlannerResources` **不 close**;**EXECUTE 复用一个 StatementContext 跨多次执行、只在最后 close** → 挂 close() 会漏掉每次中间执行的 scope。 + +### 4.2 注册点 = **scope 首次创建时**(修红队未点透的覆盖漏洞) +- grounding 指出:读事务回调注册**只在 `getSplits`**,而 `getSplits` **只有扫描语句到达**;写/纯元数据/DDL/EXPLAIN 语句在 `captureStatementScope`(`:202`)创建了 scope 却从不扫描 → 若照抄"getSplits 注册",这些语句的 `closeAll` **永不注册**。 +- **修**:在 `StatementContext.getOrCreateConnectorStatementScope()`(`:656`,scope 懒建的唯一入口)里,首次建 scope 时**向 `QeProcessorImpl` 注册一次** `queryId → scope.closeAll()`。⇒ 任何触连接器的语句都恰好注册一个 closeAll。 +- **幂等**:多张表/多 session 共享一个 scope、重试(`handleQueryWithRetry` 换 queryId 重跑 `getSplits`)都可能多次触发 → `closeAll()` 必须 **close-once**。 + +### 4.3 EXECUTE 复用(盲区③) +- `ExecuteCommand.run`(`:95`)每次执行调 `resetConnectorStatementScope()`(`StatementContext.java:669`,当前**只置空不 close**)。**修:改成先 `scope.closeAll()` 再置空**——作为兜底(某次执行的 scope 若在 analysis 阶段建、但计划短路没走到注册/触发)。因主 query-finish 回调可能已关同一实例,再次印证 closeAll 必须幂等。 + +### 4.4 取消/超时(盲区④) +- 反 pump 由 `Coordinator.close()`(取消/超时走 `:1396`)→`scanNode.stop()`→`splitAssignment.stop()` 栅栏;`SplitSourceManager` 另有 GC-based WeakReference reaper 兜底(`:72`)。close 回调走 query-finish、在 coord.close 之后,故**已在 pump 栅栏之后**,无需额外机制;仅需保证异常路径也走到 `unregisterQuery`(现有 finally 已覆盖)。 + +--- + +## 5. HMS 异构网关 sibling 扇出(组内特例,gateway 现已 LIVE) + +> **事实纠偏**:hms 已在 `SPI_READY_TYPES`(commit `83585fd5097`,2026-07-17,`CatalogFactory.java:56`),网关**服务真实查询**;代码里 ~20 处 "dormant until hms enters SPI_READY_TYPES" 注释是**过期**的。 + +- 网关行为:`HiveConnectorMetadata.getTableHandle:344` 按格式探测,ICEBERG/HUDI 表**转发 sibling**、返回 sibling 原生 handle;之后每个方法按 `instanceof HiveTableHandle` 判别,非 hive handle 经 `siblingMetadata(session,handle)=siblingOwnerResolver.apply(handle).getMetadata(session)`(`:292`)转发,**~43 处 per-handle 点每次新建 sibling metadata**。 +- owner 解析:`HiveConnector.resolveSiblingOwner(handle)`(`:190`)按 `ownsHandle` 三路派发**已建的** volatile sibling 字段(iceberg 先于 hudi),孤儿 handle fail-loud。每网关**恰好一个** iceberg + 一个 hudi sibling(`getOrCreateIcebergSibling:488` DCL)→ sibling Connector **对象身份稳定**。 +- **坑**:三连接器**共享一个 `catalogId`**(`createSiblingConnector` 传 `this` context,`DefaultConnectorContext.java:174`)→ **catalogId 单独做 key 会把三连接器塌成一个 metadata、派错**。 +- **修**: + 1. **funnel key 加属主 label**:`"metadata:" + catalogId + ":" + owner`,`owner ∈ {hive,iceberg,hudi}`,**在 `resolveSiblingOwner` 返回后按命中臂取 label**(非预解析、非 identityHashCode)。 + 2. **sibling getMetadata 也进 funnel**:`HiveConnectorMetadata.siblingMetadata`/`icebergSiblingMetadata`/`hudiSiblingMetadata` 里,先解析属主 Connector(照旧、可 fail-loud),再 `scope.getOrCreateMetadata(key(catalogId, ownerLabel), () -> owner.getMetadata(session))` → getTableHandle 的 by-TYPE 转发与后续 per-handle 转发**共享一个** sibling metadata/语句。 + 3. **只存/返回 `ConnectorMetadata` 接口**,绝不 cast 具体类型(跨 loader CCE)。 + 4. 补异构网关 e2e(`hms-iceberg-delegation-needs-e2e`,e2e 在 `external_table_p2/refactor_catalog_param`)。 +- 说明:scan/write/procedure provider 是 Connector 级(不在 getMetadata funnel),scan provider 已 per-handle memoize(PERF-11/C14);它们只共用 `resolveSiblingOwner` 的身份解析。 +- **注意**:sibling funnel 调用在**连接器内部**(fe-connector-hive),fe-core 的 arch 门禁看不见 → 门禁只管 fe-core 两文件(§8);sibling 一实例由连接器侧单测守(§7)。 + +--- + +## 6. read-vs-write 复用决策:P1 只做读侧,写侧留 P3 + +- 风险:部分连接器期望**每事务 metadata**(trino-connector 已用 `getMetadata(session, txn)` 2 参重载,`TrinoConnectorDorisMetadata.java:104`);把一个 metadata 实例跨读(扫描)+写(sink/insert)共享,可能违反事务语义。 +- **P1 决策:组 D(7 处写 seam)不改道,留 P3**。理由: + - P1 里 metadata 仍是**无状态外壳**(工作集 P2 才下沉),故"读侧共享一个实例"对写侧无影响;写侧维持现状(`PluginDrivenInsertExecutor` 已把 `writeOps` 按写语句自缓存,`ensureConnectorSetup:206`)。 + - 把"读+写同一实例/事务"的语义留给 P3 与事务折叠**一起**设计(那时才把 `ConnectorTransaction` 并入实例),避免 P1 就踩事务语义。 +- ⇒ **P1 的"一语句一实例"覆盖读/扫描/DDL/MVCC;写臂在 P3 汇入。** 这是干净的增量。 + +--- + +## 7. 守门与测试(全有现成模板) + +- **加载计数守门(连接器侧,iceberg)**:仿 `IcebergStatementScopeTest`(`:51-103`)——loader 内 `AtomicInteger`,共享=1、`NONE`=2;远端 loadTable 计数用 `RecordingIcebergCatalogOps.log`。连接器侧**须 copy `TestStatementScope`**(不能 import fe-core,bash 门禁会挂)。 +- **跨 catalog / 跨 queryId 隔离**:同文件两个 `ScopeSession` 仅 catalogId / queryId 不同,断言非同实例(已有 `differentCatalogIdIsolatesTheLoad`/`differentQueryIdIsolatesTheLoad`)。 +- **fe-core 侧 funnel + 预编译无泄漏**:仿 `ConnectorStatementScopeTest`(`:35-94`),真 `StatementContext` + `ConnectorStatementScopeImpl`;`resetConnectorStatementScope` 后断言换实例且旧值不存;**加断言 closeAll 被调**(用可数 close 的假值)。 +- **HMS sibling 一实例 + 扇出不重载**:仿 `HiveConnectorSiblingTest`(`:67-107`,`buildCount==1`/close-once/fail-loud-not-memoized);驱动 getTableHandle→scan/write 转发,断言 sibling 只 loadTable 一次(`RecordingIcebergCatalogOps.log` size)。 +- **close-once / 游标取数 / 转发 / 重试**:P1 净新。可数 close 的 scope 值,经批式 pump(`PluginDrivenScanNodeBatchModeTest` 风格)驱动,断言早终止/异常路径 close 恰一次。 +- **NONE 离线**:`ConnectorSessionImpl` ctor 默认 NONE、`captureStatementScope` 无 ctx 返回 NONE → 离线测试自动落 NONE。 + +--- + +## 8. arch 门禁(enforce invariant 2,非仅约定) + +- 机制 = **bash grep 门禁**(仿 `tools/check-connector-imports.sh`,同风格 + 自测),**非 ArchUnit**(classpath 无,勿引)。 +- 规则:grep fe-core 的 `connector.getMetadata(` 调用形,**只允许** `PluginDrivenExternalCatalog.java` 与 `PluginDrivenExternalTable.java`(fe-core 里唯二合法 funnel 载体;funnel 自身 `PluginDrivenMetadata` 内部那一处 factory lambda 也在允许名单)。 +- 排除项(§2 的 7 处直调 loader)要么走 funnel 的 null-ctx fallback、要么在门禁里显式白名单(建议白名单,保留显式直调语义)。 +- 落点:`fe/fe-connector/pom.xml` 现有 exec 只扫 fe-connector 根;fe-core 域的新规则需**自己的 exec execution**,或用 checkstyle `Regexp`(checkstyle.xml 已用 Regexp 模块)限定 `org.apache.doris.datasource.plugin`。 +- 正则须锚定调用形,别误伤 API 定义处与 `getMetadataTableRows`。 + +--- + +## 9. 未决 / 需你确认点 + +1. **写侧留 P3**(§6):P1 不改 7 处写 seam,读侧先"一语句一实例"。认可否?(替代:P1 也把写 seam 改道,但要先答 read-vs-write 事务语义——我不建议在 P1 冒这个险。) +2. **注册点 = scope 创建时**(§4.2):在 `getOrCreateConnectorStatementScope` 里向 query-finish 注册 closeAll(覆盖写/DDL/EXPLAIN)。认可否?(替代:只在 getSplits 注册=会漏非扫描语句。) +3. **排除项处置**(§2/§8):7 处 off-ctx loader **保留显式直调 + 门禁白名单**,而非依赖 funnel 的 null fallback。认可否? +4. **P4 残留泄漏威胁模型**(总设计 §8.3⑤):属独立安全签字,不阻塞 P1。确认 P1 期间**不动** `SchemaCacheValue`/`latestSnapshotCache` 等缓存门禁。 + +--- + +## 10. P1 落地顺序(建议 commit 切分) + +1. **C1 地基**:`ConnectorStatementScope.getOrCreateMetadata/closeAll` 默认方法 + `ConnectorStatementScopeImpl.closeAll`(幂等)+ `PluginDrivenMetadata` funnel。含 fe-core 单测(memo/NONE/隔离)。 +2. **C2 close 接线**:`getOrCreateConnectorStatementScope` 注册 query-finish closeAll + `resetConnectorStatementScope` closeAll-before-null。含 close-once / EXECUTE / 取消 用例。 +3. **C3 读侧改道**:组 A/B/E/misc(排除 §2 标注项)+ 扫描节点组 C 存字段。含加载计数守门。 +4. **C4 HMS sibling**:key 加属主 label + sibling getMetadata 进 funnel。含 sibling 一实例守门 + 异构网关 e2e。 +5. **C5 arch 门禁**:bash/checkstyle 规则 + 自测。 +- 每个 commit 独立可编译、可回退;C1 后即"每语句一实例"(读侧),C4 后覆盖异构网关。**纯连接器无关地基 + fe-core 改道**,用户已豁免铁律 A。 diff --git a/plan-doc/per-statement-table-owner-port/designs/P3-write-sharing-implementation-design.md b/plan-doc/per-statement-table-owner-port/designs/P3-write-sharing-implementation-design.md new file mode 100644 index 00000000000000..06ea7037deeeda --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/designs/P3-write-sharing-implementation-design.md @@ -0,0 +1,56 @@ +# P3 实现设计 + as-built —— 写入共用一个每语句元数据实例 + 事务归属上移 + +> 本文记录“写入共用”这一步(读写共享同一个 memoized `ConnectorMetadata`,并把写事务上移成语句级共持体)的定稿设计与落地结果。设计先行、经用户确认“完整共持体”高度后实现。全部 `file:method` 来自本轮读码核实 + grounding workflow(`wf_1a053ce4-b22`,6 读者,其中写缝/闸门1/兄弟路由/纠缠点四读者产出,事务生命周期与身份闸门自读核实)。 + +## 0. 成功判据 +- 一条写语句(INSERT/DELETE/MERGE)里,读/扫描/写对同一 `(catalogId, 属主连接器)` 复用**同一个** memoized `ConnectorMetadata`;写事务从该实例上铸出。 +- 写入侧 8 处 `getMetadata` 裸调全部走统一收口 `PluginDrivenMetadata.get`,删除 8 个 `getMetadata-funnel-exempt` 标记 → fe-core 元数据收口门禁 100% 无例外。 +- 语句末对**未提交**(中途被杀的孤儿)事务确定性回滚,且**先于**关闭元数据;幂等,绝不回滚已提交事务。 +- 保留:hive 起写自取表刷新(闸门1)、读写身份一致(闸门2,fail-loud 断言)、tx↔session 绑定、全局事务注册(BE RPC)、执行器在 onComplete/onFail 的提交/回滚时机。 +- NONE(离线)下字节等价。 + +## 1. 第一步 —— 改道 + 身份闸门(commit `f208036f3c5`) +### 1.1 8 处写缝改道(均无状态只读外壳 + 1 处开事务) +`connector.getMetadata(session)` → `PluginDrivenMetadata.get(session, connector)`,删豁免标记: +- `PhysicalPlanTranslator.visitPhysicalConnectorTableSink`(INSERT)/ `buildPluginRowLevelDmlSink`(DELETE/MERGE) +- `PluginDrivenInsertExecutor.ensureConnectorSetup`(唯一“外壳 + 开事务”缝,返回 metadata 存 `writeOps` 字段) +- `PluginDrivenExternalTable.resolveWriteTargetHandle`(藏读文件里的写专用点) +- `BindSink.checkConnectorStaticPartitions` / `checkConnectorWritePartitionNames` +- `IcebergRowLevelDmlTransform.checkPluginMode` +- `PhysicalIcebergMergeSink.buildInsertPartitionFieldsFromConnector` +8 处各建自己的 `buildConnectorSession()`,但捕获同一每语句作用域 → 改道后命中读臂建好的那一个实例。 + +### 1.2 身份一致断言(闸门2) +`PluginDrivenMetadata.get` 里以 `session.getUser()`(Doris 主体、非令牌)记下“首建者身份”,复用时比对,不一致抛 `IllegalStateException`。一语句一用户恒成立→永不触发;将来若被破坏则 fail-loud 而非悄悄错用凭证。NONE 下存空、检查恒真。 + +### 1.3 闸门1(hive 起写)天然满足 +改道只动 `getMetadata` 工厂调用;hive 起写 `beginWrite` 的自取表在 `beginTransaction` 下游、未触。**纠正旧表述**:起写取表走 `CachingHmsClient` 缓存(与读同对象),吃重的是“写鉴权取 + 原始参数拒 ACID + 提交期唯一把关”,非“更新鲜快照”。闸门1 是“别把起写改成复用共享表”的前瞻约束。 + +## 2. 第二步 —— 事务归属上移(commit `a03b88b0d80`) +### 2.1 新增 `CatalogStatementTransaction`(fe-core,`datasource/plugin`) +co-hold 共享 `writeOps`(元数据写facet)+ session + 懒建的 `ConnectorTransaction`: +- `begin(writeHandle)`:`connectorTx = writeOps.beginTransaction(session, handle)` 从共享实例铸事务 + `mgr.begin(connectorTx)` 全局注册(BE RPC),返回事务给执行器绑 sink 会话。 +- `finalizeAtStatementEnd()`:仅当 `mgr.isActive(txnId)`(孤儿)才 `mgr.rollback(txnId)`;否则空操作。 +- 连接器无关:只持 `ConnectorWriteOps/Session/Transaction` 接口,绝不 cast 具体类型。 + +### 2.2 执行器改道(`PluginDrivenInsertExecutor.beginTransaction`) +经 `scope.computeIfAbsent("txn:"+catalogId, () -> new CatalogStatementTransaction(writeOps, session, mgr))` 取共持体,`connectorTx = stmtTxn.begin(writeHandle)`,`txnId = connectorTx.getTransactionId()`。NONE 下共持体瞬态、执行器自身提交/回滚为唯一生命周期,字节等价。 + +### 2.3 两趟 closeAll(`ConnectorStatementScopeImpl`) +- 趟1:对所有 `CatalogStatementTransaction` 调 `finalizeAtStatementEnd`(回滚孤儿)。 +- 趟2:关闭其余 `AutoCloseable`(memoized 元数据等)。 +→ “先了结事务、再关元数据”顺序显式钉死;今天元数据 close 空操作,未来变实事时该顺序必需。 + +### 2.4 幂等/安全论证 +`PluginDrivenTransactionManager.commit/rollback` 均 `transactions.remove(id)` → 管理器的 map 即“已了结”状态源;新增 `isActive(id)=containsKey`。正常/失败路径下执行器在语句末前已提交/回滚→map 已移除→closeAll 兜底空操作,**绝不回滚已提交事务**。只有中途被杀的孤儿会被兜底扫掉。 + +## 3. iceberg 表 side-car:正交、保持现状 +`IcebergStatementScope.sharedTable`(连接器内部每语句表缓存)与本步正交:改道只动 fe-core 的 `getMetadata`,事务上移只动 fe-core 事务生命周期,均不碰起写读表路径。其删除属更远期连接器内部重构(把表变实例字段),本步不做。 + +## 4. 测试与守门 +- 收口身份断言:同用户复用、异用户 fail-loud(2 新)。 +- 共持体:begin 注册;finalize 回滚孤儿/绝不撤已提交/回滚后幂等/无事务空操作(`CatalogStatementTransactionTest`)。 +- 两趟顺序:txn 先于 metadata 关(`ConnectorStatementScopeTest`)。 +- 三写路径套件补 NONE 作用域桩(改道后经收口解引用作用域)。 +- 门禁:fe-core 收口门禁 exit 0(写入零裸调)+ 自测 PASS;连接器 import 门禁 exit 0;checkstyle 0。 +- 异构网关写/开事务上一步已免费覆盖(兄弟经同一 memoized 收口),e2e 沿用“择机统一补”。 diff --git a/plan-doc/per-statement-table-owner-port/designs/P4-cache-isolation-implementation-design.md b/plan-doc/per-statement-table-owner-port/designs/P4-cache-isolation-implementation-design.md new file mode 100644 index 00000000000000..20f0e222d7f201 --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/designs/P4-cache-isolation-implementation-design.md @@ -0,0 +1,57 @@ +# P4 实现设计 —— 缓存隔离(授权敏感缓存的越权修复,独立安全 track) + +> 本文记录"缓存隔离"这一步的定稿设计。设计先行、经用户确认路线后实现。全部 `file:method:line` 来自本轮读码核实 + grounding workflow(`wf_ffad1480-f41`,6 reader recon + 3 安全断言对抗验证)。**本文不引任务代号,用户已豁免铁律 A(fe-core 可增源)。** + +## 0. 问题 —— list ≠ load 的真实越权 + +某些 iceberg 目录(REST + `iceberg.rest.session=user`)按登录用户逐个鉴权:每个用户拿自己的委派令牌去远端 REST 服务器"加载表",服务器决定该用户能否看这张表。**"能列出表名"与"能加载表"是两套独立授权**(Polaris/Unity 支持 per-table 授权)。 + +鉴权发生在**加载表的远端往返里**(`IcebergCatalogOps.loadTable:340-341` 走 per-user 委派 SDK 目录),FE 侧无独立鉴权层。**任何"命中缓存直接返回、不调 loadTable"的路径都跳过了鉴权** → 无权用户读到别人才该见的元数据(越权,非纵深防御)。用户已确认 list ≠ load。 + +## 1. Grounding 对原计划的三处纠正(对抗验证产出) + +1. **真正活跃的泄漏只有两个,不是四个。** + - `latestSnapshotCache`(**真漏**):`beginQuerySnapshot`(`IcebergConnectorMetadata.java:1610-1622`)读它时 `loadTable` 在**未命中加载器里面**——命中就返回、不加载表。按表名建键、无用户维度、无条件构建、session=user 下仍注入 per-user metadata(`IcebergConnector.java:202-203/255-257`)。默认 24h TTL、每条查询走(`SUPPORTS_MVCC_SNAPSHOT` 无条件),活跃。泄漏值 = `(snapshotId, schemaId)` 两个 long。 + - `partitionCache` / `formatCache`(**非活跃漏**):每个读取点都**先调 per-user `resolveTableForRead`/`resolveTable`(现载、因 session=user 下 tableCache=null)**,无权用户在那步被拦。今天安全,但依赖"上游恰好先加载表",**脆弱**。 + - 先例:`commentCache`(`IcebergConnector.java:223-232`)早已对 session=user 排除,注释明说"共享 comment 缓存绕过 per-user loadTable 鉴权 = 元数据披露"——同一类威胁,当年用排除解决。 +2. **fe-core 表结构缓存是第二个真漏。** `SchemaCacheKey` 只按 `nameMapping`(`SchemaCacheKey.java`),**无 schema 缓存 bypass**。开发者给库/表名缓存加了 bypass 挡越权(`shouldBypassDbNameCache/TableNameCache`),却漏了 schema 缓存。无权用户命中 → 拿到别人的列结构。 +3. **异构 HMS 网关是"潜在"洞,不是"最尖的活跃洞"。** 网关内嵌 iceberg 兄弟被**无条件强制 `iceberg.catalog.type=hms`**(`IcebergSiblingProperties.synthesize:62-63`),而 session=user 要求 REST flavor → **兄弟永不可能 session=user**,泄漏今天不可达(被 hms-forcing 不变量挡住,非被 fe-core 门挡住)。原计划"传属主标签到 fe-core 分片"被证伪:属主标签只是类型判别串(`"iceberg"`/`"hudi"`),非身份/能力,且活在每语句作用域、不桥接 fe-core 跨语句缓存。存在的是**结构性隐患**(fe-core bypass 只看前门 hive 能力、看不到被委派兄弟能力),作前瞻加固处理。 + +## 2. 用户决策(2026-07-20 签字) + +- **路线 = 禁用**(非身份分片):授权敏感的跨查询缓存在 `isUserSessionEnabled()` 时置空。→ 无需新 SPI `getIdentityShardKey()`;**无撤权陈旧窗口**(每次现载即时鉴权);无后台线程身份漂移风险;与现有 `tableCache`/`commentCache` 先例完全一致。session=user 本就是"每次现载原始表"档位,投影现算成本可忽略。 +- **范围 = 三个投影缓存统一处理**:`latestSnapshotCache`(真漏) + `partitionCache` + `formatCache` 一并禁用 → 铁律"session=user ⇒ iceberg 无活跃跨查询元数据缓存"成立,移除脆弱依赖。 +- **异构网关 = 加 fail-loud 守卫**:网关建 iceberg 兄弟时断言兄弟非 session=user;今天恒不触发(兄弟强制 hms),守未来。 +- **威胁模型签字**:影响面仅 REST + `iceberg.rest.session=user` + fe-core schema 缓存;泄漏为元数据(snapshotId/schemaId + schema 列)非凭证/数据;禁用路线下无撤权陈旧窗口。 + +## 3. Seam-by-seam 实现(四小步,各独立 commit) + +### 4a · iceberg 三投影缓存禁用(`fe-connector-iceberg`) +- **构造门控**(`IcebergConnector.java:202-222`):三个缓存均改 `isUserSessionEnabled() ? null : new Iceberg*Cache(...)`(镜像 tableCache 的 null 门;tableCache 门为 `isUserSessionEnabled() || restVended`,投影缓存与凭证过期轴无关故只需前者)。 +- **失效路径补 null 守卫**(`IcebergConnector.java` `invalidate/invalidateDb/invalidateAll`,行 567/571/572、591/595/596、611/615/616):三缓存的失效调用当前**无条件**,null 化后 ALTER/REFRESH/DROP 会 NPE → 加 `!= null` 守卫(镜像 tableCache/commentCache 已有守卫)。 +- **`beginQuerySnapshot` 补 null 回退**(`IcebergConnectorMetadata.java:1614`):`latestSnapshotCache != null ? getOrLoad(...) : `(镜像 `resolveTableForRead:614` 的 tableCache 三元)。 +- **读取点已 null-safe,无需改**:`partitionCache`(`IcebergPartitionUtils.loadRawPartitions:744-745` 有 `cache == null` 回退)、`formatCache`(`IcebergWriterHelper.resolveFileFormatName:319` 有 `cache == null` 回退)。 +- 测试:session=user 目录下三 `*CacheForTest()` 返回 null;`beginQuerySnapshot` 在 null 缓存下仍正确 pin(每次现载);非 session=user 目录字节不变(缓存仍在)。 + +### 4b · fe-core 表结构缓存 bypass(`fe-core`,镜像名字缓存 bypass) +- **基类默认**(`ExternalCatalog.java`,`shouldBypassDbNameCache:348` 附近):`protected boolean shouldBypassSchemaCache(SessionContext ctx) { return false; }`。 +- **插件 override**(`PluginDrivenExternalCatalog.java:1202` 附近):`return supportsUserSession() && ctx != null && ctx.hasDelegatedCredential();`(与 `shouldBypassTableNameCache` 同判据同措辞)。 +- **读取点拦截**(`ExternalTable.getSchemaCacheValue():435`,唯一 key 构造点):bypass 时 `initSchemaAndUpdateTime(key)` 现读、不碰共享缓存。覆盖 MVCC 路径(`PluginDrivenMvccExternalTable` 的 latest 分支经 `cachedSchemaCacheValue()→super.getSchemaCacheValue()` 汇入本方法;time-travel pin 已 per-user 解析)。 +- 测试:session=user + 有委派凭证 → bypass(现读,不入缓存);无凭证 → 保留缓存(fail-closed 在连接器侧);非插件目录默认 false 不变。 + +### 4c · 异构网关 fail-loud 守卫(`fe-connector-hive`) +- `HiveConnector.getOrCreateIcebergSibling():510` 建成兄弟后断言 `!sibling.getCapabilities().contains(SUPPORTS_USER_SESSION)`,违则抛(消息说明:前门 hive 非 session=user,fe-core per-user 缓存 bypass 不触发,session=user 兄弟会静默泄漏;兄弟应恒 hms flavor)。今天恒不触发。 +- 测试:正常兄弟(hms flavor)不抛;桩一个 session=user 兄弟 → 抛。 + +### 4d · 防漂移门禁(`tools/check-authz-cache-sharding.sh` + `.test.sh` + maven validate) +- 仿 `check-fecore-metadata-funnel.sh`:standalone bash grep gate,RED/GREEN mktemp 自测,exec-maven-plugin validate 挂 `fe-connector` pom(`inherited=false`)。 +- 目标:`IcebergConnector.java` 构造里每个 `this.\w*[Cc]ache =` 赋值语句(到 `;` 的整条)须**含 `isUserSessionEnabled(`** 或带豁免标记 `authz-cache-exempt: <理由>`(供 manifestCache 这类 default-off + 读在 per-user load 之后的缓存)。缺则构建失败——把"加了新缓存忘了对 session=user 处理"从静默泄漏变构建失败。 +- manifestCache(`IcebergConnector.java:181`,default-off `meta.cache.iceberg.manifest.enable` + 读在 loadTable 之后)用 `authz-cache-exempt` 标记 + 一句理由。 + +## 4. 后续 +- 越权 e2e(can-list-cannot-load 命中不泄漏 + 异构网关)落 `regression-test/suites/external_table_p2/refactor_catalog_param`,随读写共用步骤欠的异构网关 e2e 一并"择机统一补"。 + +## 5. 不做 +- 不新增 `getIdentityShardKey()` SPI(禁用路线不需要)。 +- 不动 `partitionCache`/`formatCache` 的读取点(已 null-safe)。 +- hive/paimon/hudi 无 SUPPORTS_USER_SESSION、无按用户授权轴,不动。 diff --git a/plan-doc/per-statement-table-owner-port/designs/expanded-scope-phasing-and-security-decisions.md b/plan-doc/per-statement-table-owner-port/designs/expanded-scope-phasing-and-security-decisions.md new file mode 100644 index 00000000000000..5d7069553ce6de --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/designs/expanded-scope-phasing-and-security-decisions.md @@ -0,0 +1,96 @@ +# 扩展范围定稿:分期 + 后台读穿纠正 + 缓存隔离升级为安全修复 + +> 本文承接 `P1-implementation-design.md` §9 的四个待确认点。用户 2026-07-19(session 3)就"是否把写入共用 / 后台线程传递 / 缓存隔离一并纳入当前工作"做了拍板,并触发一轮取证 workflow(`wf_8b907b93-e9f`,18 agents,recon + 对抗验证 + 综合)。本文记录**用户决策、取证结论、定稿分期**,并更正 `P1-implementation-design.md` §9 点 3 与总设计里"后台传递"的隐含框架。 +> **本文仍不动代码**。 + +--- + +## 0. 用户决策(2026-07-19 session 3,动码前) + +`P1-implementation-design.md` §9 四点的实际裁决(部分偏离原推荐): + +| 点 | 原推荐 | 用户裁决 | 影响 | +|---|---|---|---| +| ① 写侧留后续 | 只做读侧 | **读写一起做**(但经取证 → 排在读取键石之后、独立成步) | P3 提前进入范围,但**分期**、不与键石混提交 | +| ② close 注册点 | scope 创建时 | **认可**(scope 创建时) | 不变 | +| ③ 后台加载点 | 显式直调 + 白名单 | **要求"把统一句柄传到后台线程"** → 取证判定该方向**不安全**,纠正为"显式读穿 + 修隐患" | 见 §2 | +| ④ 缓存隔离 | 本阶段不动、留后续签字 | **现在就处理** + 确认 **list ≠ load** | 缓存隔离升级为**真实安全修复**,独立安全 track,见 §3 | + +**综合裁决**:**分期推进**(非一次性全做)。三块都做,但排序 + 独立提交 + 独立验证。 + +--- + +## 1. 取证结论 A —— 读写共用一个元数据实例:可行且忠实 Trino + +- **Trino 已核实**:`CatalogTransaction` 每(事务, catalog)memoize **恰好一个** `ConnectorMetadata`,读规划与写入**共用它**;写入经共享事务句柄绑定,提交 = `connector.commit(txnHandle)`。我们的目标形状与之**完全一致**。 +- **机制**:把统一入口存的"裸 metadata"升级成 `CatalogStatementTransaction` 共持体(持 memoized metadata + 首次写时懒建的 `ConnectorTransaction`)。现成范本 = `ConnectorRewriteDriver`(co-hold metadata/session + `beginTransaction` + commit/rollback)。 +- **写臂 8 处 getMetadata 改道进同一入口**(均已核实为无状态薄壳的只读用法 + 一次起事务):`PhysicalPlanTranslator.visitPhysicalConnectorTableSink:660`(INSERT) / `buildPluginRowLevelDmlSink:612`(DELETE/MERGE)、`PluginDrivenInsertExecutor.ensureConnectorSetup:206`、`PluginDrivenExternalTable.resolveWriteTargetHandle:133`(藏在"读"文件里的写专用点)、`BindSink.checkConnectorStaticPartitions:673`/`checkConnectorWritePartitionNames:712`、`IcebergRowLevelDmlTransform.checkPluginMode:112`、`PhysicalIcebergMergeSink.buildInsertPartitionFieldsFromConnector:303`。 +- **两条必守的正确性闸门**(非性能): + 1. **按连接器保留"起写刷新"**,不一刀切。iceberg 复用语句内共享表(新鲜度靠 `newTransaction()` refresh);**hive 起写必须保留其在鉴权上下文里当场 `hmsClient.getTable`**(`HiveConnectorTransaction.beginWrite:199/208-211`)——负责 ACID 事务表拒绝 + 权威起始快照。误换成扫描缓存表 = 正确性/授权 bug。 + 2. **身份一致性闸门**:iceberg 接 REST、session=user 时 metadata 烤进 per-user 委派操作(`IcebergConnector.getMetadata:256` `newCatalogBackedOps`)。读/写会话在同一语句本是同一用户,但**须显式断言二者身份指纹相等**再共用,否则理论上"拿 A 的对象执行 B 的写"。 +- **不做的事**:不统一两个 `ConnectorSession`(保留 `setCurrentTransaction` 绑定,更大且无正确性必要);不引入事务协调器(外部写仍每语句 autocommit,1 StatementContext ≈ 1 外部事务)。 +- **HMS 兄弟**:sibling 入口必须覆盖 **beginTransaction** 路由(`HiveConnectorMetadata:1854`),不仅 getMetadata;键含属主 label,写事务从与读同一个 funnel-memoized 兄弟实例上 mint。 +- **风险**:iceberg session=user 身份错配(首要正确性闸门);hive 起写刷新丢失;兄弟 downcast;提交/收尾**不得**再调 getMetadata 建第二个实例;maxcompute/jdbc 写壳仅旁证,接入前须确认无状态 + 事务可从共享 metadata mint;scope-map 生命周期须在语句末确定性 close/rollback 未提交事务,且幂等。 + +## 2. 取证结论 B —— "后台线程传递"被证伪,纠正为"读穿 + 修隐患" + +**用户原要求"把统一元数据对象传到后台线程",取证判定不安全,应反向做。** + +- **为何不安全**:那几个后台加载点填的是**跨语句共享缓存**(活得比语句久);把每语句实例塞进去、语句末 close → 共享缓存里留**已关闭对象**。后台线程是**全进程复用固定池**(`ExternalMetaCacheMgr` scheduleExecutor),一个查询关掉的资源可能被**无关查询**在同一池线程上误用 → 崩溃/串数据。**明确拒绝 InheritableThreadLocal / 在 worker 上 set**。 +- **正确模型(两分支,也正是 Trino)**: + - **语句派生的异步**(扫描 pump、将来写侧异步)→ **提交时捕获**:复用请求线程上建好的 session(`PluginDrivenScanNode.connectorSession` final 字段,`ConnectorSessionImpl.statementScope` 是 final,脱线程可达)。规则:新异步一律把请求线程 session/scope 传进闭包,**绝不在池 worker 上 `buildConnectorSession()`**(那里 `ConnectContext.get()==null` → 落 NONE)。 + - **真正跨语句的后台缓存加载器**(7 处)→ **一律读穿**:每次临时新建、**显式声明 NONE**(`ConnectorSessionBuilder.withStatementScope(NONE)` 或新增 `buildCrossStatementSession()`),绝不绑定语句实例。7 处 = `PluginDrivenExternalCatalog.listDatabaseNames:295`/`listTableNamesFromRemote:311`、`PluginDrivenExternalTable.initSchema:460`/`getColumnStatistic:1052`/`getChunkSizes:1087`/`fetchRowCount:1153`、`MetadataGenerator.dealPluginDrivenCatalog:1329`。 +- **真实隐患(顺手修)**:`fetchRowCount` **并非**只跑在后台——`AnalysisManager.buildAnalysisJobInfo:415/417` 在 **ANALYZE 语句的执行线程**上直调 `table.fetchRowCount()`,该线程有活的 StatementContext → 会**错误绑定到 ANALYZE 语句的作用域**。今天无害(值是 `Optional`),但一旦实例挂可关闭资源即 use-after-close。→ **对 7 处一律强制 NONE**,把"读穿"从"碰巧落在哪个线程池"变成**强制契约**。 +- **Trino 对齐**:Trino 后台走跨事务缓存(`CachingHiveMetastore`)读穿,不复用事务 metadata;我们**匹配**,并**额外加**一道显式-NONE 守门(因我们的捕获是 thread-driven,Trino 不需要)。 + +## 3. 取证结论 C —— 缓存隔离:确认 list ≠ load,升级为真实安全修复 + +**用户确认 list ≠ load** → "能列举但无权加载"的用户会经缓存命中读到别人授权才见的表结构/快照/分区。**这是真实越权,非纵深防御。** + +- **影响面小**:只涉及 **iceberg 的投影缓存 + fe-core 表结构缓存**;hive/paimon/hudi 无 SUPPORTS_USER_SESSION、无按用户授权轴,**不动**。 +- **两轴切分(对齐 Trino 的切线)**:**只对"不含凭证、授权敏感"的投影按身份分片**(表结构/快照 id/分区规格);**含凭证的原始表 + FileIO 一律不跨语句缓存**(凭证会过期;身份轴 ≠ 令牌过期轴)。 +- **SPI 原语**:新增连接器无关的 `ConnectorSession.getIdentityShardKey()`(默认取 `getUser()`,源自 Doris 主体非令牌字节 → fe-core 不解析凭证,守铁律)。off-thread loader 也读它,防指纹在请求线程与异步线程间漂移。 +- **iceberg**:把 shard key 放进当前**未门控**的三个投影缓存的 Key——`latestSnapshotCache`/`partitionCache`/`formatCache`(`IcebergConnector` cache 块 ~200-232),仅 `isUserSessionEnabled()` 时填充(否则常量 → 非 session=user 目录字节不变)。关掉残留泄漏(总设计 §8.3⑤:`beginQuerySnapshot` 命中跳过 per-user loadTable)。 +- **fe-core 表结构缓存**(唯一引擎侧泄漏,`SchemaCacheKey` 仅按 nameMapping):**分层**——(a) 先加 `shouldBypassSchemaCache(SessionContext)`,session=user + 委派凭证下**绕缓存现读**(镜像已安全的库/表名缓存 bypass),最小正确修;(b) 后续若性能需要再把 identityShardKey 织入 Key(触及广泛调用的 `getSchema` 签名 + off-thread loader,更侵入)。 +- **原始表缓存保持 OFF**(`tableCache` 两条件 null 门维持);语句内复用仍靠 `IcebergStatementScope.sharedTable`(及 P2 后的实例字段)。**P4 恢复的是投影 RPC 的跨语句复用,不恢复 scan 期 loadTable**(那是 P2)。 +- **HMS 异构网关是最尖的洞**:hive 前门(无 SUPPORTS_USER_SESSION → fe-core 名字缓存 bypass 不触发)委派给 session=user 的 iceberg 兄弟时会泄漏;shard key 与 Key 选择须按**有效属主连接器身份**、且经兄弟 getMetadata 路由传播。**必须与 P1 的"键含属主连接器"一并落 + 异构网关 e2e。** +- **防漂移门禁**:加 arch/checkstyle 规则——授权敏感的跨语句缓存的 Key 在 session=user 下**必须**含 `getIdentityShardKey()`,把"加了新缓存忘了分片"从静默泄漏变成构建失败。 +- **Trino 对齐**:切线一致(只缓存无凭证投影、FileIO 每请求现挂);Trino 的身份分片是 opt-in(impersonation),默认靠缓存之上的 access-control 层,故 **Doris 的"session=user 恒分片"比 Trino 默认更严**(需签字确认这是有意选择)。放置差异:Trino 分在**工厂层**(per-user 缓存实例),我们放**Key 里**(贴合 Doris 扁平有界缓存 + 总设计措辞),同等隔离、靠防漂移门禁补工厂模型的免费防漂移。 +- **待签字/待答**:授权新鲜度(TTL 内远端撤权仍命中,Trino 同性质,可接受但须签字);manifestCache(默认 OFF,开启则须分片或声明与 session=user 不兼容)。 + +--- + +## 4. 定稿分期(不砍任何一块,只排序 + 独立提交/验证) + +> **为何不一次性全做**:①硬依赖——写入无入口可改道,直到读取键石落地(写严格下游);②"后台传递"被证伪,非独立第三块,是键石 close 接线的一条正确性约束;③缓存隔离是独立安全工程(未决威胁模型 + 缺 SPI 原语 + 与重构正交),捆进 ~64 处机械改道会给签字施压、且越权回归无法二分定位。约六十多处改道 + 写 + 授权缓存同落刚稳定的性能热路径 = **不可二分的回归面**。 + +| 步 | 内容 | 依赖 | 验证 | +|---|---|---|---| +| **STEP 1 · 读取键石**(原 P1,含后台纠正) | 统一入口 `PluginDrivenMetadata.get` + `getOrCreateMetadata`/`closeAll`(幂等)+ 读/扫描/DDL/MVCC 改道 + 扫描节点存字段 + close 走现有 query-finish、注册在 scope 创建点 + `resetConnectorStatementScope` 先 closeAll 再置空。**7 处后台加载器显式强制 NONE 读穿 + 修 `fetchRowCount` ANALYZE 隐患**。 | — | 加载计数=1(NONE=N);跨 catalog/queryId 隔离;游标/转发/重试/SQL-cache close 恰一次 | +| **STEP 2 · HMS 兄弟扇出**(P1-design §5) | 键 =(catalogId, ownerLabel);兄弟 getMetadata **及 beginTransaction** 经同一入口;异构网关 e2e | STEP 1 | 每 sibling 一实例、加载计数=1 | +| **STEP 3 · 写入共用**(P3,拆两小步) | **3a** 无状态写点改道进入口(translator×2 / executor / resolveWriteTargetHandle / BindSink×2 / IcebergMergeSink / RowLevelDml);**3b** `ConnectorTransaction` 归属上移到 `CatalogStatementTransaction`,定 commit/rollback vs closeAll 顺序 | STEP 1+2 | 3a 门:读/写会话身份等价(iceberg session=user);保留 hive 起写刷新;保留 tx↔session 绑定 | +| **STEP 4 · 缓存隔离**(P4,独立安全 track,可与 1–3 并行启动) | `getIdentityShardKey()` SPI → iceberg 三投影缓存 Key 分片 + fe-core 表结构缓存 bypass(先)/分片(后) + 防漂移门禁;随 STEP 2 的属主键一并覆盖异构网关 | 威胁模型签字 + STEP 2(属主键) | 越权 e2e:can-list-cannot-load 用户命中不泄漏;异构网关 e2e | + +**纠缠点(STEP 3 时再定,现在不决)**:iceberg"起写复用已解析表"目前读 `IcebergStatementScope.sharedTable`,而该 side-car 在 P2 计划里要删。到 STEP 3b 请用户定:先接旧 side-car、P2 再搬 vs 先做个 iceberg 最小 P2 前置。 + +--- + +## 5. 对既有文档的更正 + +- `P1-implementation-design.md` §9 点 3:从"显式直调 + 白名单"**更正为"显式强制 NONE 读穿 + 修 fetchRowCount ANALYZE 隐患"**。§2 排除项组(A/排除、B/排除、F 排除、misc/排除)处置随之从"保持直调"细化为"显式 NONE session"。 +- 总设计"后台传递/off-thread 构造期捕获"表述保留正确(捕获机制没错),但**"把实例传给后台线程"这一用户方向被证伪**——后台跨语句 loader 必须读穿,仅语句派生异步捕获。 +- `P1-implementation-design.md` §9 点 4 与总设计 §8.3⑤:缓存隔离**不再是"本阶段不动 + 远期签字"**,而是**已确认 list≠load 的真实安全修复**,作为 STEP 4 独立 track,威胁模型签字仍需,但不再推迟到"某个远期"。 + +--- + +## 6. STEP 1 实现进展(2026-07-19 session 3 续) + +- **C1 地基**(commit `5b7312f9d1f`):`ConnectorStatementScope.getOrCreateMetadata/closeAll` 默认方法 + `ConnectorStatementScopeImpl.closeAll`(幂等 close-once) + 静态漏斗 `PluginDrivenMetadata.get`。fe-core 单测:memo-once / NONE-each-call / 跨目录隔离 / closeAll 关一次。**字节中性**(无生产路径接进漏斗)。 +- **C2 关闭接线**(commit `12f3e95239b`):**两层关闭**(见 `P1-implementation-design.md` §4 更正块)。主关闭=`PluginDrivenScanNode.getSplits` 注册 `scope::closeAll`(对象捕获、跳 NONE、同 read-txn queryId 键);兜底=`StatementContext.close()` 的 `isReturnResultFromLocal` 守卫 closeAll(直连 `executeQuery` finally + `proxyExecute` 新增 finally);`resetConnectorStatementScope` 改 closeAll-before-null;`handleQueryWithRetry` 每重试重置。单测:reset-先关 / close-本地关 / close-异步延后。**P1 关闭仍 no-op,本 commit 行为中性**。 +- 取证 workflow:`wf_9250330b-e81`(scope 创建普查 / unregisterQuery 覆盖 / 边界路径,各带对抗复核)。 + +### 关闭接线的残留风险(carry-forward,多为既有/共担) +1. **取消/超时非硬栅栏**:`SplitAssignment.stop()` 只置 `isStopped` 标志、不 join `scheduleExecutor` 上的批读 future;慢的在途 `planScanForPartitionBatch` 可能在 closeAll 清表后再 `computeIfAbsent` 塞值(不崩,但那值不被关)。**既有、与 read-txn 回调共担**,非本改动引入。P1 no-op 关闭下无实害;**关闭做实事前须硬化**(join pump 或 closed-guard computeIfAbsent)。 +2. **arrow-flight 异常断连**:`FlightSqlConnectProcessor.close` 不跑 → 注册表条目留存(无 TTL)。既有、共担。 +3. **待确认**:走协调器的 arrow-flight/内部查询若碰外部目录却**从不走 getSplits**(纯 information_schema / 某些元数据 TVF)→ 无主关闭 + arrow-flight 跳兜底 → 可能泄漏。需在 STEP 3/后续确认这类路径必走 getSplits 或本地兜底。 +4. **🔴 TCCL 自钉扎(硬前置,给"关闭做实事"的那一步)**:本 commit 关闭是 no-op 故无需;一旦某连接器把 `ConnectorMetadata.close()`(或 P2 的 FileIO/Table)做成实事,**主关闭(getSplits 注册的回调)与兜底(StatementContext.close/proxyExecute)两处都必须把 TCCL 钉到 provider 的插件 classloader**(同 read-txn 释放回调 + `TcclPinningConnectorContext` 的 locus 纪律),否则跨类加载器 split-brain。**连接器 `close()` 自钉扎是首选**(fe-core 保持 connector-agnostic、不持 classloader)。 diff --git a/plan-doc/per-statement-table-owner-port/designs/recon-findings-and-trino-refactor-groundwork.md b/plan-doc/per-statement-table-owner-port/designs/recon-findings-and-trino-refactor-groundwork.md new file mode 100644 index 00000000000000..e7a6327fd1dc5f --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/designs/recon-findings-and-trino-refactor-groundwork.md @@ -0,0 +1,169 @@ +# 每语句表加载归属者 · 跨连接器复核结论 + Trino 重构议题预备 + +> **本文件定位**:把"把 iceberg 的每语句表加载归属者移植到其它连接器"这件事的**全部调研结论**固化到一处,供后续 session 直接接手。 +> **本轮不动任何生产代码**。用户拍板:先把结论记成文档;**重构成 Trino 架构的问题留到下一个 session 专题讨论**(见 §7)。 +> 蓝本/地基/铁律见同目录 [`README.md`](../README.md);进度见 [`progress.md`](../progress.md) / 状态见 [`tasklist.md`](../tasklist.md)。 + +--- + +## 0. 一句话结论 + +- **逐连接器复核(recon + 独立对抗复核,双签):没有一个连接器现在值得移植。** iceberg 之所以值得,唯一原因是它是本仓库里**唯一迁移了行级写(DELETE/MERGE)**的连接器——行级写让同一张表在一条语句里被"读+扫描+写成形+开事务"反复远端加载 3~5 次,这才是那套改造的靶子。别的连接器要么在 Doris 侧只读、要么仅 INSERT 追加写,没有这个重复加载风暴;现有的跨查询缓存又已把加载压到 ≈1 次。 +- **"统一接口标准"其实已经存在**:中性 SPI `ConnectorSession.getStatementScope()` + `ConnectorStatementScope`(带 `NONE` 默认)。每个连接器天生继承,离线自动退化成安全的逐次加载。新连接器**不是没有标准可循**。 +- **真正的将来触发点 = paimon 加行级写**。paimon 现在已经是"iceberg 改造前"的形状(有胖句柄 + 多加载 seam),只差写臂;一旦加 UPDATE/DELETE 就会复现风暴,届时移植才有价值,且那是抽取共享 helper 的正确时机(有 iceberg + paimon 两个真实用户)。 +- **推荐高度 = L0**(写下约定 + 登记触发点,生产逻辑零改动);**L1**(抽共享 helper)留到 paimon 加写时;**L2/L3(Trino 式重构)** 是远期、跨切、与删旧代码期铁律 A 冲突的独立项目——**下个 session 专门讨论**。 + +--- + +## 1. 背景(给未来读者的最小上下文) + +新 SPI 框架下,一条 DML(尤其 DELETE/MERGE)里同一张表被反复远端 `loadTable`:读元数据、扫描规划、写成形、开事务各自加载,最糟一条语句 3~5 次。连接器 session 一条语句内被重建约 26 次,缓存挂它即死。唯一贯穿整条语句的对象是 fe-core 的 `StatementContext`。 + +iceberg 已通过 **PERF-07** 修掉(commit `97bdcd6bdbe` 建地基 + `ea7fd1f6e7a` 连接器侧): +- **中性地基(已就位、勿再改)**:`ConnectorStatementScope`(fe-connector-api) + `ConnectorSession.getStatementScope()` + `ConnectorStatementScopeImpl`/`StatementContext` 懒建/重置 + `ConnectorSessionImpl` 构造期捕获 + `ExecuteCommand` 重置(fe-core)。 +- **连接器侧范式**:`IcebergStatementScope` helper(`sharedTable` 键 `iceberg.table:catalogId:db:tbl:queryId` + `rewritableDeleteSupply`)+ 四处表解析共享 + 拆胖句柄(删 `IcebergTableHandle.resolvedTable`)+ 下沉跨臂删除暂存(删 `IcebergRewritableDeleteStash` 单例)+ v3 DML under NONE fail-loud。 + +**移植一个连接器 = 纯连接器侧工作,不再碰 fe-core**(不触铁律 A)。 + +--- + +## 2. 逐连接器复核结论(recon + 对抗复核,双签,2026-07-19) + +> 方法:每个候选一个深度 recon agent(数一条 DML 真实加载几次 / 现有缓存边界 / 胖句柄 / 跨臂暂存 / 鉴权凭证语义),再配一个**独立对抗复核 agent**(专挑"加载被现有缓存兜住""共享泄漏"两类刺)。以下均为双签、置信=高。 + +| 连接器 | 有写入面? | 一条 DML 真实远端加载 | 胖句柄 | 跨臂暂存 | 凭证泄漏 | 结论 | +|---|---|---|---|---|---|---| +| **paimon** | ❌ 未迁移(只读) | DML=0;SELECT≈1 | ✅ 有 `PaimonTableHandle.paimonTable`(载荷型,非纯 memo) | ❌ | 无(目录级单一身份;vend 仅用于下游存储) | **将来候选**(触发点=加行级写) | +| **hive / hms 网关** | ⚠️ 仅 INSERT/OVERWRITE | DML=N/A;写≈0~1 | ❌(纯坐标+扁平标量) | ❌ | 无(连接器级单一身份) | **不必做** | +| **hudi** | ❌ 只读 | DML=0;读侧 ~4-6 次未缓存 metaClient 构建 | ❌ | ❌ | 无 | **不必做** | +| **maxcompute** | ⚠️ 仅 INSERT 追加 | 低(句柄带惰性表代理,写复用不重载) | ❌ | ❌ | 无 | **排除** | +| **es** | ❌ 只读 | 读侧 mapping 有重取(已被 fe-core schema 缓存兜) | ❌ | ❌ | 无 | **排除** | +| **jdbc** | ⚠️ 仅 INSERT 追加 | 低(纯坐标句柄,写复用坐标) | ❌ | ❌ | 无 | **排除** | +| **trino** | ❌ 只读 | 读侧 `getTableHandle` 每 seam 重解析、**最贵且未缓存** | 同句柄内 memo | ❌ | 无 | **排除**(读侧优化候选,另立议题) | + +### 关键证据(file:method,便于将来核验) + +**hive / hms 网关(不必做,置信高)** +- 无行级写:`HiveWritePlanProvider.supportedOperations()` = `EnumSet.of(INSERT, OVERWRITE)`;full-ACID 写在 `HiveConnectorTransaction.rejectTransactionalWrite`(beginWrite:210) 硬拒。DML 风暴结构性不存在。 +- 加载已被**跨查询**缓存兜住:读/写/beginWrite 三处 `getTable` 全走 `CachingHmsClient.tableCache`(跨查询、TTL 24h、按 (db,table) 键)→ 实际远端 ≈0~1 次,是"每语句作用域"的**超集**。已核实缓存**确实接线**(`HiveConnector.createClient:588` = `wrapWithCache(new ThriftHmsClient(...))`;`hms` 在 `CatalogFactory.SPI_READY_TYPES`)——代码里多处 "dormant/未进 SPI_READY_TYPES" 的 javadoc 是 cutover 后的**过期注释**(doc-hygiene 待清)。 +- 无胖句柄(`HiveTableHandle`=纯坐标+扁平标量);无跨臂暂存(`HiveReadTransactionManager` 是 ACID 读锁生命周期管理器,非扫描→写桥)。 +- **网关委派已查清**:网关只对普通 hive 表自己加载;遇 iceberg/hudi-on-HMS 表只做 **1 次廉价格式探测加载**(`getTableHandle:343`)后委派兄弟连接器,兄弟自带作用域——**网关自己不需要任何作用域**,也不得重复包裹委派加载。 + +**paimon(将来候选,置信高)** +- **写未迁移**:`PaimonConnector` 不 override `getWritePlanProvider`(→ null → `supportedWriteOperations()` 空 → INSERT/DELETE/MERGE 全拒);代码注释 `PaimonConnector.java:294` 明写 "paimon write is not migrated"。 +- 加载已被句柄 memo + SDK 缓存压到 ≈1:`getTableHandle:185` 单次 `catalogOps.getTable` 后 `setPaimonTable` 存到 transient 句柄;后续 ~8 处 `resolveTable`/`resolveScanTable` 走快路径。跨查询由 paimon SDK `CachingCatalog` 去重。 +- **胖句柄存在**:`PaimonTableHandle.paimonTable`(transient, PaimonTableHandle.java:89) 是 `IcebergTableHandle.resolvedTable` 的直接同款。但**是载荷型非纯 memo**:`withBranch` 故意置 null 让分支重载不同表、`withScanOptions` 拷贝它、承担查询内 Table 实例一致性——现在删它换作用域 map 是**平移(等价功能、无减载)+ 有回归面**。 +- 无跨臂暂存(无写臂)。鉴权=目录级单一身份;vended 凭证只用于下游 per-scan-range 存储读、不烘进 Table 对象,故共享**不泄漏**。 + +**hudi(不必做,置信高)** +- 只读:不 override 写入面,`beginTransaction`(HudiConnectorMetadata.java:397) 抛 "Hudi tables are read-only";INSERT/DELETE/MERGE 在准入门被拒。 +- 5 步模板 4 步空操作。唯一真实重复成本=读路径 ~4-6 次**未缓存的 `HoodieTableMetaClient` 构建**(每 seam 从 basePath 重建)——但**形状不对**(不是"表加载归属者")、**metaClient 可变**(`getSchemaFromMetaClient` 调 `reloadActiveTimeline()`)、**鉴权/TCCL 上下文错配**(metadata 臂在 `metaClientExecutor.execute` 的 doAs 里、planScan 在 fe-core 扫描线程内联无 doAs),且主成本(FileSystemView / MDT `getAllPartitionPaths`)压根不是表对象加载。 + +**maxcompute·es·jdbc·trino(排除,对抗复核确认非橡皮图章)** +- 均无行级 DML:es/trino 只读、jdbc/maxcompute 仅 INSERT-append 且写成形复用句柄/坐标不二次加载。无跨臂暂存、无 v3 复活风险、凭证目录级。 +- 诚实指出:**读路径确有"每 seam 重解析"未缓存**——trino 最重(`getTableHandle`+`getColumnHandles`+N×`getColumnMetadata` 每 seam 重跑)、es 次之(mapping 重取)。但这正是蓝本明确打折的"读侧、收益有限、已被 `SchemaCacheValue` 兜一部分"那类,且缺写侧/暂存/胖句柄的架构收益。**trino 标记为最弱排除**——若将来单独立"读侧去重"新任务,它是这组里唯一有实打实重解析成本的。 + +### 一个不改结论的开放问题 +- paimon/hive 的句柄是否跨 fe-core 边界被重建(使加载数从 1 涨到 2-3)尚未 trace 确认。但即便最坏情况,那些多出来的加载**也全部命中现有跨查询缓存**,不改变任何"不必做"的结论。 + +--- + +## 3. 核心洞察:iceberg 为什么独特 + +那套改造 5 步(① sharedTable ② 四处 seam ③ 拆胖句柄 ④ 下沉暂存 ⑤ fail-loud)的价值,几乎全押在 **③④⑤ 依赖"行级写"** 上: + +- **只有 iceberg 迁移了行级写(DELETE/MERGE)**,它同时读 + 写同一张表,才有"写成形×N + beginWrite"这些绕过读缓存的加载臂,以及"扫描填、写读"的跨臂删除暂存。 +- 其它连接器:paimon/hudi/es/trino **只读**、hive/hms **仅 INSERT/OVERWRITE**、jdbc/maxcompute **仅 INSERT-append**。对它们 ①② 要么冗余(现有缓存已兜)、③④⑤ 基本是空操作。 + +因此"逐连接器复核判不必做"不是保守,而是**iceberg 本就是特例**。 + +--- + +## 4. 架构统一性分析(回答用户"是否有必要改造和统一") + +### 4.1 统一接口其实已存在 +Doris 版的统一契约 = 中性 SPI `ConnectorSession.getStatementScope()` + `ConnectorStatementScope`(`computeIfAbsent(key, loader)` + `NONE` 默认)。**每个连接器天生继承,离线安全退化。** `IcebergStatementScope` 只是 40 行的连接器私有便利包装。所以"新连接器没有统一标准"这个担心其实不成立——标准已发布,缺的只是"更强的强制/便利外壳"。 + +### 4.2 Trino 参照模型:统一靠"引擎自持的每事务生命周期",不是一个缓存类 +- Trino 每条语句都是一个事务;引擎为每个被访问的 catalog **懒造一个"每事务 `ConnectorMetadata`"实例并 memoize 到事务上**(`IcebergTransactionManager`/`HiveTransactionManager` 内的 per-catalog `MemoizedMetadata`),commit/rollback 时销毁。 +- 连接器把加载到的表**缓存在这个实例上**(iceberg `IcebergMetadata.tableMetadataCache`、hive per-transaction metastore 的 `tableMetadataCache`)。`getTableHandle`/`getTableMetadata`/`getColumnHandles`/统计/`beginInsert`/`finishInsert` 都重入**同一个** metadata → 命中缓存 → 远端 `loadTable` 只发一次。 +- **统一的是"共享生命周期"(去重的 span=事务、活满 span 的对象=metadata 都由引擎供给和销毁),"cache-on-metadata" 是自然掉出来的约定**;新连接器实现 `ConnectorMetadata` 即免费获得。 + +### 4.3 为什么 Trino 模型不可直接移植到 Doris +Doris 缺 Trino 的每一个结构前提: +- **无每事务 `ConnectorMetadata`**:Doris 连接器/`*ConnectorMetadata` 是**长期共享单例**(每 catalog 一个、从不按语句造),挂每语句可变缓存会跨语句/跨用户泄漏(正因如此跨查询 `IcebergTableCache` 对 `session=user`/vended 关闭)。 +- `ConnectorSession` **不是稳定 span**(一条语句重建约 26 次,挂它即死)。 +- **DML 事务对象出现得晚、且只在写臂**(`*ConnectorTransaction` 在 beginWrite 才建),无法承载"读+扫描+写成形"整条 span。 +- **唯一活满整条语句、连接器够得到的对象 = fe-core `StatementContext`**,只能经中性 `getStatementScope()` 够到。所以 Doris 必须把 Trino 模型**反过来**:不是"引擎把每事务 metadata 发给连接器",而是"连接器向上够 fe-core 的 span"。 +- 全盘照搬会撞铁律 A:给 14 个连接器引入每事务 metadata + TransactionManager 生命周期 + 改 planner 每语句取 metadata = 删旧代码期的 fe-core 大净增。 + +**结论:现有 `ConnectorStatementScope` + `getStatementScope()` + `NONE` 已是 Trino 思想在 Doris 铁律下能落地的最高、最可移植高度。** + +### 4.4 高度分级(L0–L3) +| 高度 | 内容 | 现在值不值 | +|---|---|---| +| **L0** | 只把统一**约定写成文**(键格式 `<类型>.table:catalogId:db:tbl:queryId`、NONE=逐次加载、扫描→写跨臂在 NONE 下 fail-loud)+ 登记触发点;生产逻辑零改动 | ✅ **推荐(现在做)** | +| **L1** | 把 iceberg 私有 helper 提升为 `fe-connector-api` 共享 util、迁移 iceberg 6 处调用(纯连接器侧、fe-core 不动、不碰铁律 A) | ⚠️ 留到 paimon 加写(第 2 个真实用户)时 | +| **L2** | 每语句基类 `ConnectorMetadata` / 契约方法连接器 override | ❌ 需引入每语句 metadata=迷你 Trino 重构 or 只是更重的 L1;碰铁律 A、影响刚稳定的读热路 | +| **L3** | Trino 式每事务 metadata 全重构 | ❌ 远期、跨切、明确碰铁律 A、需独立立项 + 单独签字 | + +--- + +## 5. 将来触发点:paimon 加写(证据) + +- **paimon 的"写未迁移"是真实的、被推迟的路线项**:老的 JNI-writer INSERT 写栈(commit `6c6c9a4b6cd`:`datasource/paimon/PaimonTransaction`、`planner/PaimonTableSink`、`nereids/.../PaimonInsertExecutor`、`transaction/PaimonTransactionManager` + BE `paimon_table_sink_operator`/`PaimonJniWriter`)在**删旧代码期被整体删除、未搬进新 SPI 连接器**。将来的 paimon 写会在写 SPI 上**全新构建**,且 UPDATE/DELETE 是真正新增(老实现只有 INSERT)。 +- **paimon 现在就是"iceberg 改造前"的形状**:胖句柄 `PaimonTableHandle.paimonTable` + 多加载 seam(4 文件,所有连接器最多)。**唯一缺写臂。** +- **一旦加行级 UPDATE/DELETE**:长出"写成形 + beginWrite"两条加载臂 + (若 merge-on-read)"扫描填、写读"的删除暂存 → **完整复现 iceberg 3~5 次加载风暴 + 跨臂暂存需求**。届时移植对 paimon**真正有价值**(架构连贯 + 溶掉胖句柄 + 给暂存一个自然的家),且有 iceberg + paimon 两个真实用户来把共享 helper 的接口形状定对。 + +--- + +## 6. 共享 helper 落点(若将来做 L1) + +- **家 = `fe-connector-api`**(`ConnectorStatementScope`/`ConnectorSession` 已在此;所有连接器都依赖它;是依赖图最低公共祖先;**连接器侧、不碰铁律 A**)。`fe-connector-spi`/`fe-connector-metastore-api` 在其之上,只依赖 api 的连接器看不见,排除。 +- **签名**(一个真原语 + 一个键约定便利): + ```java + static T computeIfAbsent(ConnectorSession s, String key, Supplier loader) { + return s == null ? loader.get() : s.getStatementScope().computeIfAbsent(key, loader); + } + static T sharedTable(ConnectorSession s, String typePrefix, String db, String tbl, Supplier loader) { + if (s == null) return loader.get(); + String key = typePrefix + ":" + s.getCatalogId() + ":" + db + ":" + tbl + ":" + s.getQueryId(); + return s.getStatementScope().computeIfAbsent(key, loader); + } + ``` +- **iceberg 迁移代价 = 6 处调用 + 删 `IcebergStatementScope`(85 行)**:`sharedTable` 4 处(`IcebergConnectorMetadata:613` 读 / `IcebergConnectorTransaction:211` beginWrite / `IcebergWritePlanProvider:697` 写成形 / `IcebergScanPlanProvider:2274` 扫描);`rewritableDeleteSupply` 2 处(`IcebergScanPlanProvider:674` 填 / `IcebergWritePlanProvider:193` 读)。 +- **注意**:删除暂存那半只能**结构性**泛化(值类型 `Map>` 是 iceberg thrift、"NONE 不桥接→v3 fail-loud"语义是 iceberg 专属)——泛化时把 iceberg 专属键串 + fail-loud 守卫留在 iceberg 调用点即可。**只有 table-share 那半是真连接器中性。** + +--- + +## 7. 下一步:下个 session 讨论"重构成 Trino 架构"(L2/L3)—— 预备材料 + +> 用户 2026-07-19 指示:本轮先记文档;**重构成 Trino 架构的问题下个 session 专题讨论**。以下是给那次讨论的种子,避免从零起。 + +### 7.1 Trino 架构到底要引入什么(对照 §4.2/§4.3) +1. **每语句/每事务 metadata 实例**:把 `*ConnectorMetadata` 从"每 catalog 共享单例"改成"每语句(或每事务)新建、由引擎 memoize+销毁"。 +2. **一个 span 生命周期管理器**(对应 Trino `TransactionManager`):即便裸 SELECT 也要有一个活满整条语句的 metadata 宿主;Doris 现成的 span 宿主是 `StatementContext`。 +3. **planner 改造**:Nereids/规划各阶段改成"经 span 取当前语句的 metadata 实例",而非直接调共享连接器。 +4. **连接器改造**:把表/schema/统计缓存从"跨查询共享缓存 + transient 胖句柄"迁到"挂 per-statement metadata 实例"。 + +### 7.2 必须先摆平的硬冲突(讨论要点) +- **铁律 A(删旧代码期 fe-core 只减不增)**:上述 1/2/3 几乎全是 fe-core 净增 + planner 改造。→ 讨论:是否等删旧代码期结束再做?是否需要用户单独签字放行? +- **读热路径刚稳定**(PERF-01~06、PERF-11):per-statement metadata 会重排读热缝,回归面大。 +- **凭证/多租户语义**:per-statement 实例天然按语句隔离(=按用户),能顺带解决"跨查询缓存对 session=user/vended 关闭"的历史包袱——这是 L3 相对现状的**真正架构收益**,值得作为讨论的正面理由。 +- **收益 vs 成本的诚实定位**:现状"连接器向上够 span"已拿到 90% 的去重收益;L3 的增量主要是**架构连贯 + 多租户缓存归一 + 为将来大量写连接器铺路**,而非单点性能。要不要为这些付"跨切重构 + 独立项目"的价,是用户的战略决定。 + +### 7.3 建议的讨论产出 +- 一份"Doris 每语句/每事务 metadata 重构"的**可行性 + 分期**设计(可否分"先 span 宿主、后逐连接器迁移、最后退役共享单例"三期落地,避免一次性大爆炸)。 +- 明确触发条件(如:删旧代码期结束 / 写连接器数量到某阈值)。 + +--- + +## 8. 参考 + +- 蓝本权威设计:`plan-doc/perf-hotpath-iceberg/designs/FIX-PERF-07-unified-per-statement-table-owner-{design,summary}.md`。 +- 代码范本:`fe-connector-iceberg/.../IcebergStatementScope.java`(commit `ea7fd1f6e7a`);地基 commit `97bdcd6bdbe`。 +- 架构记忆:`iceberg-table-resolution-cache-scoping`(缓存作用域纪律 + Trino 协同 + "全高度留远期")。 +- 本轮调研原始返回(每 agent 完整结论):workflow journal + `.../subagents/workflows/wf_e89cf92e-ff3/journal.jsonl`(逐连接器 recon+对抗复核)、 + `.../subagents/workflows/wf_4802a3d2-1c9/journal.jsonl`(placement / onboarding / Trino-altitude 三专项)。 +- 公开 tracking issue:apache/doris#65185。 diff --git a/plan-doc/per-statement-table-owner-port/designs/trino-parity-metadata-redesign-design.md b/plan-doc/per-statement-table-owner-port/designs/trino-parity-metadata-redesign-design.md new file mode 100644 index 00000000000000..92e30add0200b4 --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/designs/trino-parity-metadata-redesign-design.md @@ -0,0 +1,228 @@ +# 目标架构设计:把外部表元数据层重构成"每语句/每事务 metadata 实例"(对齐并超越 Trino) + +> **本文定位**:这是"是否把 Doris 表解析/元数据层重构成 Trino 式架构"专题讨论的**设计草案**。 +> **前提(用户 2026-07-19 拍板)**:本设计**不受任何现有铁律/约束限制**——可改任何模块(fe-core / planner / 连接器)。唯一目标:设计一个**至少和 Trino 一样合理、能更好则更好**的架构,不被当前实现绑住。 +> **本轮仍不动任何生产代码**:先出设计、用户确认方向后再实现。 +> 现状事实全部经一轮只读并行 recon + 对抗验证取证(workflow `wf_72d1e505-75c`),下引 `file:method:line` 均来自该轮核实。 + +--- + +## 0. 结论先行 + +1. **可行,且比上一轮结论文档判断的容易得多。** 上一轮 §4.3 的关键前提"Doris 的 `*ConnectorMetadata` 是每 catalog 长期共享单例"**是错的**(见 §1 更正)。真相是:`ConnectorMetadata` 现在是**每次调用新建、即用即弃的无状态外壳**,生命周期比"每语句"还短。要把它变成"每语句一个",不是要拆一个顽固单例,而是**给一个本就无根的临时对象钉一个语句级的家**——这恰恰是 Trino 的做法。 +2. **Doris 现状已经具备 Trino 四条不变量里的两条半**:句柄不可变且携带事实(不变量③,已满足);语句级宿主 `StatementContext` 已存在且已托管 MVCC pin 与连接器作用域(不变量①的宿主已就位);缺的是"引擎在管道层 memoize 唯一 metadata 实例"(不变量②)和"生命周期即事务、按用户天然隔离"(不变量④)。 +3. **目标架构 = 引擎自持的"每语句(每事务)metadata 实例" + 其下一层"跨语句、按身份分片的共享缓存"**。前者给结构化的"一语句一次加载"(比 Trino 的 iceberg 更硬),后者给按用户的跨语句复用(补上今天 session=user 下缓存被全关的窟窿)。 +4. **诚实的收益定位**(必须带着走的老结论):"每语句实例"本身**不解决**按用户的**跨语句**性能——那要靠"按身份分片的缓存"(分期里的 Phase 4)。每语句实例真正的价值是**架构自洽 + 结构性正确**(把今天散落的 6 处"这个值是不是跨用户敏感"的门禁,收敛成一条结构性质:实例是每语句、天然单用户)。 + +--- + +## 1. 现状的真实形状(经核实,含一处对上一轮文档的更正) + +### 1.1 三层生命周期(这是理解一切的钥匙) + +| 对象 | 生命周期 | 持有什么 | 对应 Trino | +|---|---|---|---| +| **`Connector`** | **每 catalog 一个长期单例**(`transient volatile` 字段,仅 ALTER/重启/close 重建) | **全部状态**:各类跨查询缓存(iceberg 的 snapshot/table/partition/format/comment/manifest;paimon 的 snapshot+schemaAt;hive 的 fileListing;maxcompute 的 partition)、`ConnectorContext`、鉴权 | ≈ Trino 的 `Connector`(每 catalog 单例) | +| **`ConnectorMetadata`** | **每次 `getMetadata(session)` 调用新建、即弃** | **几乎无状态**,是把连接器单例的缓存注进来的**薄委派外壳** | ≈ Trino 的 `ConnectorMetadata`——但 Trino 是**每事务一个并被引擎 memoize**,Doris 是**每次调用一个、从不 memoize** | +| **`ConnectorSession`** | **每次 `buildConnectorSession()` 新建**(一条语句 ~26–63 次),且**贵**(每次 `VariableMgr.toMap()` 全量反射 dump + 全局读锁) | queryId、委派凭证、捕获的语句作用域引用 | ≈ Trino 的 `ConnectorSession`——但 Trino 的是**廉价不可变请求上下文**,Doris 的是**昂贵且被反复重建** | + +证据:`PluginDrivenExternalCatalog.java:102`(connector 字段)、`IcebergConnector.getMetadata:256`(`return new IcebergConnectorMetadata(...)` 每次新建,7 个连接器皆然,grep 无任何 `ConnectorMetadata` 类型字段)、`ConnectorSessionBuilder.java:158-179` + `VariableMgr.toMap:940-959`(每次 build 的反射开销)。 + +> **⚠ 对上一轮结论文档的更正**:`designs/recon-findings-and-trino-refactor-groundwork.md` §4.3 与 `HANDOFF.md` 写的"Doris 连接器/`*ConnectorMetadata` 是长期共享单例(每 catalog 一个、从不按语句造)"——**把 `Connector`(确是单例)和 `ConnectorMetadata`(其实是每调用即弃)混为一谈了**。对抗验证判定该表述 **REFUTED**(证据:`IcebergConnector.getMetadata:256` 等 7 处)。这个更正是**利好**:让 metadata 变成每语句,不需要打破单例,只需给一个"生命周期本就是自由变量"的临时对象钉个语句级的家。 + +### 1.2 规划器怎么拿元数据(每处都在重造) + +从 SQL 到执行,每个 seam 都在重复同一个三元组:**长期 catalog 单例 → 长期 Connector 单例 → 新建 `ConnectorSession` → `connector.getMetadata(session)`(每调用新 metadata)→ 从 metadata 拿一个不可变 `ConnectorTableHandle`**。**没有任何"每语句 metadata 对象"被引擎穿针引线地传下去。** + +需要改道的 seam(recon 已枚举,择要): +- **读**:`PluginDrivenExternalTable` 里 ~17 处(`initSchema`/`getColumnStatistic`/`fetchRowCount`/`getNameToPartitionItems`/`toThrift`/`getComment`/`listPartitions`…),每处都 `buildConnectorSession`+`getMetadata`。 +- **扫描**:`PluginDrivenScanNode` 在 `create()` 把 session/connector/handle 捕获成**字段**(单扫描节点内共享),但之后每个方法(`applyFilter`/`applySnapshot`/`planScan`/`getColumnHandles`…)仍 `connector.getMetadata(session)` 重取。 +- **写**:`PhysicalPlanTranslator.visitPhysicalConnectorTableSink`(自建 session)、`PluginDrivenTableSink.bindDataSink`(planWrite)、`PluginDrivenInsertExecutor`(**第二个** session + `writeOps` 字段 + `beginTransaction`)。写路径**两个独立 session**,仅靠把 `ConnectorTransaction` 绑到 sink 的 session 上勉强串起来。 +- **异步缓存 loader**(schema/rowCount/columnStats):**在没有 `ConnectContext` thread-local 的线程上跑**——任何"每语句对象"必须能不经 thread-local 够到(现有作用域用**构造期捕获**已解决此问题,可复用)。 + +### 1.3 已就位的"半个 Trino 形状"(利好) + +- **句柄已是 Trino 模型**:`ConnectorTableHandle` 不可变,由 `applyFilter/applyProjection/applyLimit/applySnapshot/applyRewriteFileScope/…` 返回**新句柄**逐步精化——这正是 Trino 的 `ConnectorTableHandle`-update 模式。**Trino 不变量③(事实随句柄前向流动、规划期不重解析)Doris 已满足。** +- **语句级宿主已存在**:`StatementContext` 每语句在 `StmtExecutor` 构造时新建、挂到 `ConnectContext`,已托管 MVCC 快照 pin(`StatementContext.snapshots`,一语句一次解析、读写共享)与连接器作用域,随语句 GC。`ConnectorStatementScope`(挂在它上的 `ConcurrentHashMap` memo 竞技场)+ iceberg 的 `IcebergStatementScope` 已用它做到"一语句一张表只加载一次"。 +- **但作用域只是"半成品"**:它是**无类型的 String→Object 竞技场**,不是**有类型的每语句 `ConnectorMetadata`**;**规划器不经它取表**(规划器走的是跨语句的 `ExternalCatalog` 元数据缓存);只有 iceberg 一个连接器手写键去消费它。也就是说"读规划的取表路径"和"每语句加载归属者"**今天还不是同一条缝**——而 Trino 把它们合成了一条。 + +### 1.4 事务模型(现状割裂) + +- **外部无统一事务管理器**:每个 `ExternalCatalog` 自持一个 `TransactionManager`;plugin 目录一律用 `PluginDrivenTransactionManager`,把 commit/rollback/close 委派给**连接器在 `beginTransaction` 才晚建**的 `ConnectorTransaction`(`session.allocateTransactionId()`),绑到 session、`onComplete` 提交/`onFail` 回滚。 +- **`ConnectorTransaction` 是糟糕的 span 宿主**:出生太晚(仅 beginWrite)、只在写臂、读/规划期根本看不见。 +- **内部 OLAP 是另一条路**:`GlobalTransactionMgr` + 每连接 `TransactionEntry`;`GlobalExternalTransactionInfoMgr` 只是个 id→Transaction 的注册表(供 BE→FE RPC 查),**不是协调器**。内外无共享抽象。 +- **外部写实际上是"每语句 autocommit"**:`ConnectorTransaction` 在一条语句内建、在该语句 `onComplete` 提交 → **一个 `StatementContext` ≈ 一个外部事务**。这让 `StatementContext` 成为 span 宿主是**自然且正确**的。 + +--- + +## 2. Trino 为什么优雅(四条不变量)+ 两处弱点 + +经源码核实(`trinodb/trino` master,下引类名/行号来自该轮 web recon): + +**四条不变量:** +1. **事务 = 元数据的身份与生命周期单位**。无显式 BEGIN 的语句跑在 autocommit 事务里;每个被触达的 catalog,`TransactionManager` 懒建并 memoize **一个** `CatalogMetadata`(`activeCatalogs` map,`CatalogHandle` 为键);其内 `CatalogTransaction` 用 `@GuardedBy(this)` 字段 memoize **恰好一个** `ConnectorMetadata`(`connector.getMetadata(session, txnHandle)` 只调一次)。commit/rollback → `connector.commit/rollback` → 事务移除。**无人工失效、无跨语句泄漏。** +2. **单一漏斗、单一实例**。所有元数据调用走 `Session → MetadataManager → TransactionManager → CatalogMetadata → CatalogTransaction`。**memoize 发生在管道层,不在连接器里** → 一语句一实例由**引擎**保证,与连接器自觉性无关。 +3. **事实随句柄前向流动、不靠重载**。`getTableHandle` 一次解析,快照 id/schema/分区 spec 装进不可变 `ConnectorTableHandle`,穿过 analyze/optimize/execute 直到 `beginInsert/beginMerge`。规划期从不重解析。 +4. **身份是事务级的 → 按用户隔离白拿**。`ConnectorMetadata` 用 `session.getIdentity()` 构建;`ConnectorSecurityContext = txnHandle+identity+queryId`;事务是每 session 的,故 metadata **从不跨用户共享**——隔离是事务模型的副产品。 + +**两处弱点(Doris 应超越):** +- **弱点 A**:每事务 `ConnectorMetadata` 被 memoize 了,但**加载到的 Table 对象没有**(iceberg)。`IcebergMetadata` 只缓存统计;`getTableHandle/getTableMetadata/getTableProperties` 各自重调 `catalog.loadTable()`,把去重下推到**每事务 `TrinoCatalog` 上有界可淘汰的** `tableMetadataCache`(maxSize ~1000)。**多表语句会淘汰重载 → "一语句一次加载"是软的、非结构性的**(直接坐实了"一条语句 loadTable 3–4 次靠下层缓存兜")。 +- **弱点 B**:core `CatalogTransaction` 已保证 `getMetadata` 只调一次,Hive 连接器却**又在 `HiveTransactionManager.MemoizedMetadata` 里重复实现一遍**每事务 memo。冗余耦合。 + +--- + +## 3. 目标架构(Doris 版:对齐 Trino 的不变量,并针对性超越其弱点) + +### 3.1 核心:引擎自持的"每语句/每事务 metadata 实例",memoize 在管道层 + +把今天"每次调用新建即弃"的 `ConnectorMetadata`,改成**每(语句, catalog)一个、由引擎懒建并 memoize、语句/事务结束时确定性销毁**。 + +- **宿主 = `StatementContext`**(外部写=每语句 autocommit,一 `StatementContext` ≈ 一外部事务;宿主已在,已托管 MVCC pin 与作用域,已有正确 GC/reset)。 +- **管道层单一漏斗**:在语句 span 上加 `ConnectorMetadata getOrCreateMetadata(catalogId)`,懒建、memoize、语句内所有 seam 复用同一实例——**对应 Trino 不变量②,且 memo 在引擎侧,连接器零自觉性要求**(直接规避 Trino 弱点 B:连接器不再各自手写 memo)。 +- **off-thread 可达**:实例引用像作用域一样在 `ConnectorSession` **构造期捕获**,异步 scan pump / 缓存 loader 无 thread-local 也够得到。 +- **确定性销毁**:`StatementContext.close()` 逐 catalog `metadata.close()`(对齐 Trino 的 commit/rollback 即销毁;比今天纯靠 GC 更干净)。 + +因为 `ConnectorMetadata` 今天已是"无根的临时外壳"(designNotes 原话:"除了缓存,没有任何东西把它钉在每调用"),且句柄已满足不变量③,这一步在结构上**主要是引擎侧改道 + 把缓存归属从单例挪到实例**,SPI 方法签名基本不用动(方法本就吃 `(session, handle)`)。 + +### 3.2 超越点①:结构性的"一语句一次加载"(打败 Trino 弱点 A) + +把**每语句工作集**(一次加载的 raw Table、扫描→写的删除清单桥、列句柄)从"连接器单例上的缓存 / 无类型 String→Object 作用域",**上移成每语句 metadata 实例上的字段**。 + +- iceberg 的 `IcebergStatementScope.sharedTable`/`rewritableDeleteSupply` 从"外挂 side-car"变成"实例的字段" → **加载一次成为硬的结构性质**(实例在,表就在;不是有界可淘汰缓存)。**这比 Trino 的 iceberg 更硬。** +- paimon 的胖句柄 `PaimonTableHandle.paimonTable` 溶进实例字段,句柄回归纯坐标(iceberg 已做过同款)。 + +### 3.3 超越点②:两级缓存 —— 每语句工作集 之上/之下 各司其职 + +| 层 | 位置 | 作用 | 对应 Trino | +|---|---|---|---| +| **每语句工作集** | 每语句 metadata 实例的字段 | 语句内一次加载、读写共享、扫描→写桥;**天然单用户单语句** | Trino 每事务 metadata 的缓存(但 Doris 做成硬字段) | +| **跨语句共享缓存** | Connector 单例(或专门的 caching 层),**按身份分片** | 跨语句复用;**值敏感处把 identity 放进 key** | Trino 的 `CachingHiveMetastore` / `TrinoCatalog` 缓存 | + +关键洞察(对齐 Trino 不变量④,并补 Trino 未系统化之处): + +- **正确性坍缩成一条性质**:每语句实例**天然单用户**(一语句一用户),故其内部一切缓存**永不跨用户** → 今天散落的 ~6 处门禁(`SUPPORTS_USER_SESSION` 分支、`shouldBypassTableNameCache/DbNameCache` 两钩子、`IcebergTableCache` 置 null、`IcebergCommentCache` 门禁、`IcebergStatementScope` side-car)**收敛成"实例是每语句/单用户"这一条结构性质**,删掉一整类"新加了个缓存忘了 gate"的 bug。 +- **性能补窟窿在下层**:今天 session=user/凭证目录下,跨语句缓存被**整个关掉**(`IcebergTableCache=null` 等),每条语句都为每张表多付 1 次 `loadTable` + 1–2 次 list RPC。**把下层跨语句缓存改成按身份分片(identity 进 key)** → 按用户的跨语句复用安全恢复。**这才是按用户场景真正的性能收益所在。** + +### 3.4 超越点③:把写事务并进同一个 span + +- `beginWrite` 从"重新 `loadTable`"改成"**从每语句实例取已解析的表**"(保留 openTransaction 的 refresh 兜新鲜 OCC 基底)。 +- `ConnectorTransaction` 不再是"晚建 + 绑 session"的孤儿,而是**由每语句 metadata 实例/span 持有**;commit/rollback 由 span 在语句末驱动。写路径两个 session 的割裂被收编。对齐 Trino"metadata 即事务、写方法吃句柄、commit=connector.commit(txnHandle)"。 + +### 3.5 超越点④(远期/可选):统一内外事务协调器 + +- Trino 只有外部连接器,没有"内部表"二元性;Doris 有 `GlobalTransactionMgr`(OLAP) 与 `PluginDrivenTransactionManager`(外部)两套。**把二者统一到一个事务协调面**,让外部写能参与多语句 `BEGIN..COMMIT`(span 挂 `ConnectContext`、`StatementContext` 持每语句视图,即"两级 span")——这是 Doris **特有**的自洽升级(不是"打败 Trino",是补 Doris 的割裂)。**最深、风险最高、对"达到 Trino 平价"非必需**,列为远期。 + +### 3.6 附带清理(与主线解耦,可先落) + +- **`ConnectorSession` 变廉价 + 不可变**:每语句 memoize 一次 var-map 快照,消灭 ~26 次 `VariableMgr.toMap` 反射 dump;把唯一可变字段(transaction 槽)移到 metadata/事务对象上,恢复 session 全不可变。**纯性能 + 简化,不依赖 metadata 生命周期改造,可最先落。** + +--- + +## 4. 可行性与风险 + +### 4.1 为什么可行(且比原判断容易) +- `ConnectorMetadata` 本就是每调用即弃的无根外壳 → **给它钉个语句级的家,不需打破任何单例**(最大的"想象中的拦路虎"不存在)。 +- 句柄已不可变、已 fact-carrying → **Trino 不变量③已满足**,不用重写。 +- `StatementContext` 已是被验证的 span 宿主(MVCC pin + 作用域已在其上),off-thread 可达已由"构造期捕获"解决 → **不变量①的宿主与最难的 off-thread 问题都已就位**。 +- seam 虽多但**窄而齐**:一切走 `getMetadata / getScanPlanProvider / getWritePlanProvider / beginTransaction`,fe-core 从不跨调用持有 metadata 引用(唯一例外是写执行器的 `writeOps` 字段)→ 改道是机械但可枚举的工作。 + +### 4.2 风险(诚实列出) +1. **回归面**:~40 处读热路 seam 改道,踩在刚稳定的读热缝上(PERF 系列)。→ 缓解:分期、每期加"加载计数守门"回归。 +2. **确定性销毁 vs off-thread**:实例不能在异步 scan pump 还在用时提前 close。→ 缓解:沿用作用域的"随语句 GC + 不在 close() 里清工作集"纪律,只在确证无 off-thread 引用处做确定性 close。 +3. **异步缓存 loader 无 thread-local**:schema/rowCount/stats loader 跑在别的线程、且填的是**跨语句**缓存。→ 这些本就该留在"下层跨语句缓存",每语句实例**读穿**到它们(对齐 Trino 每事务 metadata 读穿 `CachingHiveMetastore`),不强行纳入每语句实例。 +4. **Phase 4 的身份分片需威胁建模**:哪些值是用户敏感(凭证/可见性/授权)要 identity 进 key,哪些是纯元数据可共享(snapshot/format)。recon 已留两个 open 问题(session=user 下 `SchemaCacheValue` 仍开、latest-snapshot 仍跨用户共享——是否可接受需签字)。 +5. **一语句多事务?**(open Q):多目标 MERGE、或 HMS 异构网关委派兄弟连接器,是否一条语句会 mint 多个 `ConnectorTransaction`?若是,"一 StatementContext = 一事务"需放宽成"一实例/catalog"。→ 设计上按 (语句, catalog[, 目标表]) 建键即可容纳。 + +--- + +## 5. 分期(避免一次性大爆炸;每期独立可验证) + +| 阶段 | 内容 | 独立价值 | 打败 Trino? | 风险 | +|---|---|---|---|---| +| **P0 · 解耦的廉价前置** | `ConnectorSession` 每语句 memoize var-map(灭 ~26× 反射 dump);为后续把 transaction 槽移出 session 铺垫 | 纯性能 + 简化 | — | 低 | +| **P1 · 键石:引擎自持每语句 metadata 实例** | span 上加 `getOrCreateMetadata(catalogId)`,管道层 memoize,读/规划/DDL/list 全改道走它;确定性 close;off-thread 构造期捕获 | 一语句一实例(不变量①②) | 弱点 B(单漏斗、连接器零自觉) | 中(改道面广) | +| **P2 · 工作集上移 + 删 side-car** | 一次加载的 Table/扫描→写桥/列句柄 变成实例字段;删 `IcebergStatementScope` 的 String-key 用法;paimon 胖句柄溶进实例 | 结构性一次加载 | **弱点 A(硬 load-once)** | 中 | +| **P3 · 写事务并入 span** | `beginWrite` 取实例已解析表(非重载);`ConnectorTransaction` 由实例/span 持有;收编两 session 割裂 | 读写同一 span、写路径自洽 | 对齐 Trino metadata=事务 | 中-高 | +| **P4 · 按身份分片的跨语句缓存** | 下层缓存 identity 进 key;删 `SUPPORTS_USER_SESSION` 全关门禁,改成"缓存按用户分片";补 session=user 跨语句性能窟窿 | **按用户跨语句性能**(真正性能收益) | 超越 Trino 的 all-or-nothing gate | 中(需威胁建模) | +| **P5 · 统一内外事务协调器(远期)** | 一个事务面桥接 OLAP + 外部;外部写参与多语句 BEGIN..COMMIT;两级 span | 内外自洽、多语句外部事务 | Doris 特有自洽(非平价必需) | 高 | + +- **达到 Trino 平价 = P1–P3**;**超越 Trino = P2(硬 load-once)+ P4(按用户缓存)**;**P5 是远期战略,非平价必需**。 +- 建议落地顺序:P0(热身)→ P1(键石)→ P2 →(P4 与 P3 可并行/择序)→ P5 视产品需要。 + +--- + +## 6. 用户已拍板的点(2026-07-19) + +1. **终点 = 平价 + 超越(P1–P4)**;不含 P5(统一内外事务)。 +2. **首个落地单元 = 直上 P1 键石**(跳过 P0 热身)。 +3. **销毁 = 语句末确定性 close**(对齐 Trino,接受 off-thread 约束)——**但见 §8 盲区①:实际须挂在现有 query-finish 钩子,而非 `StatementContext.close`。** +4. **先跑一轮多架构师对抗红队再定稿**——已完成,结论见 §8。 + +--- + +## 7. 参考 + +- 本轮 recon+对抗验证 workflow:`wf_72d1e505-75c`(journal 在 `.../subagents/workflows/wf_72d1e505-75c/`),含 metadata 生命周期 / planner 取数路径 / 多租户缓存 / span+txn 模型 / Trino 精确机制 五组核实结论 + 4 条支撑事实的对抗判定。 +- 上一轮结论(部分被本轮 §1 更正):`recon-findings-and-trino-refactor-groundwork.md`。 +- 架构记忆:`iceberg-table-resolution-cache-scoping`(缓存作用域五纪律 + StatementContext=每语句 owner + Trino 协同)。 +- Trino 源:`InMemoryTransactionManager` / `CatalogTransaction` / `CatalogMetadata` / `MetadataManager` / `HiveMetadataFactory` / `IcebergMetadata`(master)。 + +--- + +## 8. 红队结论与定稿(多架构师对抗,workflow `wf_62cc379e-c6e`,2026-07-19) + +4 位架构师(span 中心 / 缓存中心 / 忠实 Trino / 最小面)各出一版目标架构,4 位评审(Trino 忠实度 / 迁移风险+off-thread / 多租户正确性 / 完整性)横向打分 + 找致命伤 + 挑最佳点。 + +### 8.1 排名 +| 评审轴 | 第1 | 第2 | 第3 | 第4 | +|---|---|---|---|---| +| Trino 忠实+超越 | **span (85)** | 忠实Trino (83) | 最小面 (77) | 缓存 (58) | +| 迁移+off-thread | **span (82)** | 最小面 (76) | 忠实Trino (66) | 缓存 (58) | +| 多租户正确性 | 缓存 (84) | **span (80)** | 最小面 (71) | 忠实Trino (64) | +| 完整性 | **span (64)** | 忠实Trino (61) | 最小面 (55) | 缓存 (45) | + +**胜出 = span 中心**(3/4 轴第一):复用**已验证 off-thread 可达**的现有 `ConnectorStatementScope` + `getStatementScope()` 到达路径;`connector.getMetadata` 保持纯工厂;**唯一 memo 在 fe-core 管道**(invariant 2 最纯);P1 近字节中性、可回退、风险最低。 + +> 一处被评审核实的关键点:现 `ConnectorSessionImpl` 捕获的是 **`ConnectorStatementScope` 接口**、不是 `StatementContext` 本身。故"经 getStatementScope 到达"是**被证明的**路径;而"把新管理器直接挂 StatementContext"(忠实 Trino 派原案)**不在**这条已证 off-thread 路径上,须额外补捕获——这也印证应以 span 派为骨架。 + +### 8.2 嫁接进定稿的各家最佳点 +1. **忠实 Trino 派的 `CatalogStatementTransaction`**:把"每(语句,catalog)metadata 实例"与"写事务"做成**同一个持有者**(invariant 1 最紧、P3 折叠最干净)。**弃用**其 `ConnectorSession.getMetadata(connector)` SPI(分层异味 + fallback 少测)。 +2. **缓存派的"元数据/凭证拆分"(P4 唯一正确的威胁模型)**:缓存**不含凭证的投影**(schema/snapshotId/分区 spec/授权名单)按 catalog 共享、**每请求现挂新 FileIO**;只对授权敏感投影按身份分片;**vended raw Table 一律不跨语句缓存**。身份轴 ≠ 令牌过期轴。 +3. **最小面派的**:`ConnectorSession.getIdentityShardKey()` 集中指纹(off-thread loader 复用防漂移)+ `ConnectorMetadata.close()` 默认 no-op + 扫描节点把 memoized metadata 存字段去掉 per-method 重取;`statementSession()` 消灭 ~26 次 `VariableMgr.toMap` 反射 dump(**列为独立性能项,不进 P1** 以保 P1 字节中性)。 +4. **arch/checkstyle 门禁**:禁止在 funnel 之外直接调 `connector.getMetadata`(把 invariant 2 从"约定"变"结构强制";须对 HMS sibling 布线显式放行/改道)。 + +### 8.3 五处四家都漏的公共盲区(定稿必修,①②为硬伤) +1. **🔴 确定性 close 挂错生命周期**。四家都想挂 `StatementContext.close()`/StmtExecutor finally。但每查询连接器资源释放本走 **`registerQueryFinishCallback`**(`unregisterQuery`→`finalizeQuery`,profile 等待之后=pump/BE 静默之后触发,现已用于提交 hive 读事务+放锁)。二者只在简单 autocommit 单语句下重合;**游标取数 / 转发 master / 重试 / SQL-cache 复用(只 releasePlannerResources 不 close)** 下会**过早/重复/永不**触发 → 关掉 off-thread pump 仍用的 Table/FileIO。**修:close 走现有 query-finish 钩子。** +2. **🔴 HMS 异构网关 sibling 扇出**。`HiveConnectorMetadata` 在 ~25 处 per-handle 点调 `siblingOwnerResolver.apply(handle).getMetadata(session)`,每次**在连接器内部新建 sibling metadata、funnel 看不见** → 网关一开:invariant 2 破、P2 一次加载破(sibling Table 重建 ~25 次)、catalogId 单键装不下三连接器、lint 门误报/漏保证。**修:memo 键改 `(catalogId, 属主连接器身份)`;sibling getMetadata 也路由进同一 funnel;补异构网关 e2e(记忆 `hms-iceberg-delegation-needs-e2e`)。** +3. **预编译 EXECUTE 复用泄漏**:`resetConnectorStatementScope()` 先置空不 close → P2/P3 后跨执行泄漏 FileIO/事务。**修:reset 先 closeAll 再置空。** +4. **取消/超时 reaper**:close 须栅栏在 `SplitSourceManager` 注销之后(reaper 可能 close 后才懒调 planScan),不能靠含糊"pump join"。 +5. **P4 残留泄漏(威胁模型待定)**:`latestSnapshotCache`/`partitionCache`/`formatCache` + fe-core `SchemaCacheValue` 在 session=user 下**仍开**,`beginQuerySnapshot` 命中不走 per-user loadTable → 能 list 不能 load 的用户或有 schema/snapshot 泄漏。**定 P4 前须证明无 per-user 授权态,否则按身份/快照分片或走 per-user 委派目录。** + +--- + +## 9. 定稿蓝图(P1 键石,可动工粒度) + +**骨架 = span 派;值 = 忠实 Trino 派的 `CatalogStatementTransaction`;close = 走 query-finish 钩子;键含属主连接器。** + +### 9.1 新增/改动(P1) +- **`ConnectorStatementScope`(fe-connector-api,加类型化方法)**:`ConnectorMetadata getOrCreateMetadata(MetaKey key, Supplier factory)`;`MetaKey = (catalogId, 属主连接器身份)`。`NONE` 每次跑 factory(离线/测试字节不变)。旧 `computeIfAbsent` 迁移期共存。 +- **`ConnectorStatementScopeImpl`(fe-core)**:背 `Map`;`CatalogStatementTransaction` 持 memoized `ConnectorMetadata`(+ P3 持写 `ConnectorTransaction`);提供 `closeAll()`。 +- **funnel(fe-core 静态)**:`PluginDrivenMetadata.get(connector, session)` = `session.getStatementScope().getOrCreateMetadata(key(session,connector), () -> connector.getMetadata(session))`。**~40(实测 ~64)缝**一律改调它替 `connector.getMetadata(session)`(connector+session 每处都在作用域内;扫描节点已把二者存字段,再存 memoized metadata 一字段去掉 per-method 重取)。 +- **`ConnectorMetadata.close()`**:默认 no-op(P2/P3 起连接器 override 释放 Table/FileIO/事务)。 +- **确定性 close**:在**现有 `registerQueryFinishCallback`/`unregisterQuery`** 注册 `scope.closeAll()`(pump 静默后);`resetConnectorStatementScope()` 改为**先 closeAll 再置空**;close 栅栏在 SplitSourceManager 注销之后。 +- **HMS sibling 改道**:`HiveConnectorMetadata` 的 sibling `getMetadata` 经属主连接器身份走同一 funnel(键含属主)→ 每 sibling 一实例。 +- **arch/checkstyle 门禁**:禁 funnel 外直调 `connector.getMetadata`(白名单:funnel 自身 + 明确排除的 off-thread 跨语句 loader)。 + +### 9.2 P1 终态与验证 +- 一条语句一 catalog(一属主连接器)**恰好一个 memoized `ConnectorMetadata`**,读/扫描/写/DDL 全缝复用,pump 静默后确定性 close。连接器内部零改(iceberg 的 `IcebergStatementScope` 共存于 impl,P2 再删)。 +- **P1 字节中性**:NONE 下逐次 factory=今日行为;perf delta≈0(语句内去重本就有)。收益=单漏斗 + 无 per-connector memo + 确定性生命周期。 +- **守门**:①每(语句,catalog)加载计数=1(对照 NONE=N);②跨 catalog/跨 queryId 隔离;③预编译重执行不泄漏(closeAll 被调);④异构 HMS 网关下每 sibling 一实例、加载计数=1;⑤游标取数/转发/重试/SQL-cache 路径 close 恰好一次、不早不晚(针对盲区①的回归)。 + +### 9.3 后续期(定稿方向,细化留各自立项) +- **P2**:once-loaded Table/删除桥/列句柄 → 实例字段(硬 load-once,超越弱点 A);删 `IcebergStatementScope` string-key + paimon 胖句柄。 +- **P3**:写事务并入 `CatalogStatementTransaction`;beginWrite 取实例已解析表;收编两 session。 +- **P4**:元数据/凭证拆分 + 按身份分片授权敏感投影 + 删 `SUPPORTS_USER_SESSION` 全关门禁;先解决 §8.3⑤ 残留泄漏威胁模型。 +- **独立性能项**:`statementSession()` 每语句 memoize session(灭 ~26× 反射 dump);审计所有 build 点确认 session 属性语句内恒定。 + +### 9.4 待你拍板才动代码 +本文件是设计定稿方向。**下一步(若你点头):把 §9.1 细化成 seam-by-seam 的 P1 实现设计(逐文件逐方法),仍不动代码;或直接进入 P1 实现。** 另:P4 的 §8.3⑤ 残留泄漏属安全威胁模型,建议单独走一次签字(涉及"能 list 不能 load"的元数据披露)。 diff --git a/plan-doc/per-statement-table-owner-port/progress.md b/plan-doc/per-statement-table-owner-port/progress.md new file mode 100644 index 00000000000000..941c8116b0721f --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/progress.md @@ -0,0 +1,120 @@ +# Progress Log —— 每语句表加载归属者 · 移植到其它连接器 + +> **Append-only**:只追加、不覆盖(覆盖式状态在 HANDOFF/tasklist)。 + +--- + +## 2026-07-19 — session 0:建任务空间 + +- 用户拍板:把 iceberg 的 PERF-07「每语句表加载归属者」整体性改动移植到其它连接器;**本 session 只建跟踪文档空间,实际处理留下一个 session**。 +- 读准 iceberg 蓝本(PERF-07 summary):可复用地基 = 中性 `ConnectorStatementScope`(fe-connector-api) + `ConnectorSession.getStatementScope()` + `ConnectorStatementScopeImpl`/`StatementContext`/`ConnectorSessionImpl` 构造期捕获/`ExecuteCommand` 重置(fe-core),**已随 PERF-07 落地**(commit `97bdcd6bdbe`)。→ 关键结论:**移植是纯连接器侧工作,无需再改 fe-core**(不再触铁律 A)。连接器侧范式 = `IcebergStatementScope` helper + 四处 `resolveTable*` 共享 + (可选)拆胖句柄 + (可选)下沉跨臂暂存 + (可选)fail-loud。 +- 初摸候选(grep):只有 iceberg 用了该 SPI;`loadTable`/`getTable` 触点 paimon 4 / hive 3 / hudi 1 / maxcompute·es·jdbc·trino 0。→ 候选 = paimon(高)/hive-hms(中-高)/hudi(中);读-only 四家大概率排除,待复核。 +- 落地文件:`README.md`(用途 + 已就位地基 + 连接器侧 5 步模板 + 候选表 + 单连接器立项流程 + 铁律)、`tasklist.md`(PORT-01~04 全待 recon)、`HANDOFF.md`(下一步 = 确认范围 + 逐候选 recon)、`progress.md`(本文件)、`designs/`(空)。 +- **未动任何产品代码**。**下一步**:见 HANDOFF —— 下个 session 起步先与用户确认范围/顺序,再对第一个连接器 recon。 + +## 2026-07-19 — session 1:逐连接器 recon + 架构统一性调研(结论:全部 🔬 + 定高度 L0) + +- **逐连接器 recon(recon + 独立对抗复核,双签,多 agent workflow)**:结论 = **没有一个连接器现在值得移植**。 + - paimon → 🔬 **将来候选**:写未迁移(只读)、加载已被 transient 胖句柄 + SDK CachingCatalog 压到≈1;触发点=加行级 UPDATE/DELETE。 + - hive/hms → 🔬 不必做:仅 INSERT/OVERWRITE、跨查询 `CachingHmsClient.tableCache` 已兜(作用域超集);网关只做 1 次探测加载后委派兄弟(兄弟自带作用域)。 + - hudi → 🔬 不必做:只读;唯一读侧成本=未缓存 metaClient 重建,形状不对/可变/鉴权错配。 + - maxcompute·es·jdbc·trino → 🔬 排除:均无行级 DML;trino 读侧重解析最贵=另立议题占位。 + - **核心洞察**:iceberg 独特在它是唯一迁移了行级写的连接器(行级写才有多臂重复加载 + 跨臂暂存)。 +- **架构统一性专项调研(3 agent:placement / onboarding / Trino-altitude)**: + - **统一接口已存在** = 中性 `getStatementScope()` + `ConnectorStatementScope`(新连接器天生继承)。 + - **Trino 模型不可直接移植**:Trino 靠"引擎自持每事务 metadata 生命周期",Doris 连接器是共享单例、session 一条语句重建~26 次,唯一 span 宿主=`StatementContext`;现有 SPI 已是最可移植高度。 + - **高度分级 L0/L1/L2/L3**;**paimon-加写=L1 抽共享 helper 的正确触发点**(有 2 个真实用户);共享 helper 落 `fe-connector-api`(不碰铁律 A)。 +- **用户拍板**:先把结论记成**单独文档** → `designs/recon-findings-and-trino-refactor-groundwork.md`;**重构成 Trino 架构(L2/L3)留下个 session 专题讨论**(该文档 §7 已备预备材料)。 +- **未动任何产品代码**(纯 plan-doc)。**下一步**:见 HANDOFF —— 下个 session = 讨论"重构成 Trino 架构"的可行性/分期/与铁律 A 的取舍。 + +## 2026-07-19 — session 2:Trino 式重构专题(用户豁免铁律)→ 目标架构定稿方向 + 红队 + +- **用户新指令**:抛开一切铁律/约束,可改任何模块,目标=至少和 Trino 一样合理、能更好则更好。 +- **workflow ①(`wf_72d1e505-75c`)现状+Trino recon + 对抗验证**:更正旧文档——`ConnectorMetadata` 是**每调用即弃外壳**(`Connector` 才是单例),改每语句不用砸单例;句柄已 fact-carrying(Trino invariant 3 已满足);`StatementContext` 已是被验证的 span 宿主(off-thread 靠构造期捕获);外部写=每语句 autocommit(1 StatementContext≈1 外部事务);Trino 四不变量 + 两弱点(iceberg 软 load-once / 冗余双 memo)源码核实。 +- **定稿设计** → `designs/trino-parity-metadata-redesign-design.md`:目标架构=引擎自持每语句 metadata 实例 + 管道层唯一 memo + 下层按身份分片(拆凭证)缓存。 +- **用户拍板**:终点 P1–P4(不含 P5);直上 P1 键石;语句末确定性 close;先红队。 +- **workflow ②(`wf_62cc379e-c6e`)4 架构师 × 4 评审对抗红队**:胜出=span 派(3/4 轴第一);嫁接忠实-Trino 的 `CatalogStatementTransaction` + 缓存派"元数据/凭证拆分" + 最小面派若干硬化 + arch 门禁。**揪出五处公共盲区(两硬伤)**:①close 须走现有 `registerQueryFinishCallback` 而非 StatementContext.close;②HMS sibling 扇出须键含属主连接器 + sibling 路由进 funnel;③EXECUTE reset 先 closeAll;④close 栅栏在 SplitSourceManager 注销后;⑤P4 残留泄漏威胁模型待签字。已并入 §8/§9。 +- **未动任何产品代码**(纯 plan-doc)。**下一步**:见 HANDOFF —— 等用户点头后细化 P1 seam-by-seam 实现设计或直接进入 P1 实现。 + +## 2026-07-19 — session 2(续):P1 seam-by-seam 实现设计(grounding + 定稿) + +- **workflow ③(`wf_7e537094-44f`)只读 grounding**(5 读者:seam 清单 / close 生命周期 / HMS sibling / scope-session SPI / 测试+门禁基建)。核实产出:**66 处真实 seam**(改道表);close 钩子=`registerQueryFinishCallback`(须移注册点到 scope 创建、closeAll 幂等);**HMS 网关已 LIVE**(2026-07-17 进 SPI_READY_TYPES,dormant 注释过期,三连接器共享 catalogId→key 加属主 label);`ConnectorMetadata` 已 Closeable no-op(memoize 安全);守门/门禁全有模板。 +- **定稿** → `designs/P1-implementation-design.md`:§1 SPI/plumbing 签名、§2 66 缝改道、§3 扫描节点存字段、§4 close 接线、§5 HMS sibling funnel、§6 read-vs-write(写侧 7 缝留 P3)、§7 守门、§8 arch 门禁、§9 四个待确认点、§10 commit 切分 C1–C5。 +- **未动任何产品代码**(纯 plan-doc)。**下一步**:见 HANDOFF —— 等用户确认 §9 四点后进入 P1 实现(C1→C5)。 + +## 2026-07-19 — session 3:扩展范围取证 + 用户二次拍板 → 定稿分期 + +- **用户就 §9 四点拍板,偏离两处原推荐**:写侧要一起做、后台加载点"传句柄到后台线程"、缓存隔离现在就做。 +- **workflow(`wf_8b907b93-e9f`,18 agents,recon+对抗+综合)** 三块结论: + - **读写共用**:可行且忠实 Trino(`CatalogTransaction` 单实例读写共用已核实);机制=`CatalogStatementTransaction` 共持体 + 写臂 8 处改道;两闸门=按连接器保留起写刷新(hive 留 beginWrite getTable)+ 读写身份指纹相等。 + - **后台传递(用户方向被证伪)**:把实例传后台不安全(共享池 worker 复用 → 已关闭对象被无关查询误用);纠正=语句派生异步"提交时捕获"+ 7 处跨语句 loader 显式强制 NONE 读穿;**查出并建议修 `fetchRowCount` 在 ANALYZE 线程被错误绑定的真实隐患**。 + - **缓存隔离**:影响面小(仅 iceberg 三投影缓存 + fe-core 表结构缓存);两轴切分(授权敏感投影按身份分片、含凭证原始表永不跨语句缓存)+ 新 SPI `getIdentityShardKey()`;HMS 异构网关是最尖的洞。 + - **综合**:强烈建议**分期**(硬依赖 + 后台传递被证伪 + 缓存隔离是独立安全工程;一次性全做=不可二分回归面)。 +- **用户二次拍板(动码前)**:①**分期推进**;②后台**采纳纠正做法 + 修隐患**;③**确认 list ≠ load** → 缓存隔离=真实越权修复,独立安全 track。 +- **定稿** → `designs/expanded-scope-phasing-and-security-decisions.md`:决策表 + 三块取证 + 分期 STEP 1(读取键石,含后台纠正)/ STEP 2(HMS 兄弟)/ STEP 3(写入共用,拆 3a/3b)/ STEP 4(缓存隔离,独立安全 track)+ 对旧文档更正。 +- **未动任何产品代码**(纯 plan-doc)。**下一步**:见 HANDOFF —— 等用户对 STEP 1 逐提交方案点头即进入实现(C1→C3 = 键石 + 后台纠正)。 + +## 2026-07-19 — session 3(续):落地 STEP 1 的 C1 地基 + C2 关闭接线 + +- 用户逐提交确认后开写。**首次改动产品代码**(用户已豁免铁律 A)。 +- **C1 地基**(commit `5b7312f9d1f`):`ConnectorStatementScope` 加 `getOrCreateMetadata`/`closeAll` 默认方法;`ConnectorStatementScopeImpl.closeAll` 幂等 close-once + best-effort;新增静态漏斗 `PluginDrivenMetadata.get(session, connector)`(按 catalogId memoize `connector.getMetadata`)。fe-core 单测 memo-once/NONE-each/跨目录隔离/关一次。字节中性(无生产路径接进漏斗)。编译 + checkstyle 0 + 8 测全绿。 +- **动手 C2 前的取证**(workflow `wf_9250330b-e81`):发现 `captureStatementScope` 在**每次 on-thread 会话构建**就急切建 scope;原设计"在 scope 创建处注册关闭回调"**会泄漏**——DDL/`SHOW`/`DESCRIBE`/`EXPLAIN`/前台 `ANALYZE` 走 `Command.run` 建 scope 却永不到 `unregisterQuery`,回调连带 scope 永留无 TTL 注册表。已对抗复核。 +- **C2 关闭接线**(commit `12f3e95239b`):改为**两层关闭**——主关闭 `PluginDrivenScanNode.getSplits` 注册 `scope::closeAll`(对象捕获、跳 NONE、同 read-txn queryId 键,只对走协调器语句触发、pump 静默后);兜底 `StatementContext.close()` 的 `isReturnResultFromLocal` 守卫 closeAll(直连 `executeQuery` finally + `proxyExecute` 新增 finally 覆盖转发主节点);`resetConnectorStatementScope` 改 closeAll-before-null;`handleQueryWithRetry` 每重试重置。核实 `releasePlannerResources` 幂等故转发路径补 close 安全。单测 reset-先关/close-本地关/close-异步延后。P1 关闭仍 no-op,行为中性。编译 + checkstyle 0 + 11 测全绿。 +- 更正 `P1-implementation-design.md` §4(旧"scope 创建处注册"→两层关闭);残留风险(取消非硬栅栏、arrow-flight 断连、待确认的非-getSplits 外部查询、**TCCL 自钉扎硬前置**)记入分期定稿 §6。 +- **下一步**:见 HANDOFF —— 下个 session 做 C3(读取侧改道 ~55 缝 + 扫描节点存字段 + 7 处后台读穿纠正 + 修 fetchRowCount 隐患),动手前先核行号列清单 + 配对抗复核。用户将在下个 session 开新任务。 + +## 2026-07-19 — session 4:落地读取侧改道 + 后台读穿纠正(读取键石收官主体) + +- **动手前核对关(对抗复核 workflow `wf_6e2967a9-1a2`,10 agents:7 逐文件普查 + 3 对抗验证)**:把改道表逐条对当前代码行号重核。结论落 `designs/C3-read-reroute-verified-checklist.md`:fe-core 连接器 `getMetadata` 工厂缝**共 66 处**,完整切成四类,相加=66、零遗漏/零重叠/零未分类;设计命名行号零漂移(仅扫描节点尾部 +13 行位置漂移)。对抗复核 CONFIRM 写专用点 `resolveWriteTargetHandle` 唯一生产调用方是插入执行器开事务、读路径够不到,必须留后续。 +- **两处偏差交用户拍板 + 拍定**:①名字映射两缝(`fromRemoteDatabaseName`/`fromRemoteTableName`)经复核跑在离请求线程的缓存加载路径 → 归入强制 NONE 读穿(读穿 9 处、改道 49 处);②读穿 loader 走统一入口 + 传 NONE 会话(门禁零例外)。 +- **落地(首次改产品代码,未提交前全绿)**:新增 `PluginDrivenExternalCatalog.buildCrossStatementSession()`(镜像 buildConnectorSession + 强制 NONE,保留凭证);9 处后台 loader 改用它并走统一入口(含 `fetchRowCount` ANALYZE 隐患修复——不必单改统计模块);49 处读/DDL/命令/多版本改道进统一入口;扫描节点加 `volatile cachedMetadata` + `metadata()` 访问器(静态 create 直调入口)。源码证实「强制 NONE 走统一入口」与裸直调**字节等价**(NONE 每次跑工厂、零留存)。 +- **测试适配(workflow `wf_fbb60841-365`,11 agents 并行修各文件)**:改道后这些路径经统一入口调 `session.getStatementScope()`,Mockito mock 默认返 null → 漏斗 NPE;按 C1 约定(测试给 NONE 作用域)逐文件补 `getStatementScope()→NONE` stub + 给测试替身补 `buildCrossStatementSession` override(不弱化任何断言/verify)。新增守门 `ConnectorSessionImplTest.explicitNoneStatementScopeWinsOverLiveContext`(活 ctx 下显式 NONE 胜出,锁 ANALYZE 隐患修复的机制)。 +- **验证**:目标测试 247 全绿(先红 130 error+30 fail → 修后 0/0)+ ConnectorSessionImplTest 18 绿 + fe-core checkstyle BUILD SUCCESS 零违规 + fe-core 主编译 BUILD SUCCESS。 +- **提交口径**:4 个逻辑子步(助手/读穿/改道/扫描存字段)在 2 个共享文件里交织,且每提交须保持绿(测试适配须随产品改动同提),逐 hunk 拆分需交互式 git(本环境不支持)→ 按 C1/C2 先例=1 个绿代码提交(含产品+测试适配+守门)+ 1 个文档提交,代码提交体内枚举 4 子步。 +- **下一步**:见 HANDOFF —— 读取键石剩防漂移门禁(形态=统一入口零例外);下一大步 = HMS 异构网关兄弟扇出(键含属主 label + 兄弟 getMetadata/起事务进同一入口 + 异构网关 e2e)。 + +## 2026-07-19 — session 5:防漂移门禁落地(读取键石完全收官) + +- **用户拍板(动码前,方案 A)**:现在就上门禁锁死读取侧、写入 8 处显式豁免(写入共用步骤再收);否决"推迟整个门禁到写入步骤后一次落"。 +- **动手前取证**:对当前树 grep `.getMetadata(` 全域 = 21 行 = 9 处无参(`TestExternalCatalog`×8 静态 Map + `RuntimeProfile.node.getMetadata()`,均 `src/main`)+ 3 处 funnel javadoc + 1 处 funnel 真调 + **8 处写入裸调**。读取侧已"零裸调"(49 处 C3 已改道走 `PluginDrivenMetadata.get`)。 +- **落地(commit `b2d147998d1`)**: + - 新增 `tools/check-fecore-metadata-funnel.sh`(bash grep 门禁,仿 `check-connector-imports.sh`)+ 自测 `.test.sh`。规则:扫 `fe/fe-core/src/main/java`,禁裸 `Connector#getMetadata(session)`;**放行**=①funnel 文件 `datasource/plugin/PluginDrivenMetadata.java`(含其 javadoc)②带 `getMetadata-funnel-exempt` 标记的行(**call 行或其上一行**,兼容长行)③无参 `getMetadata()`(异方法)④注释行。正则双形匹配(同行参数 + 换行到下一行的参数),锚定调用形不误伤 `getMetadataTableRows`/API 定义。 + - 8 处写入裸调各加一行上置标记注释(103 字,全 <120):PhysicalPlanTranslator×2 / BindSink×2 / PluginDrivenInsertExecutor / IcebergRowLevelDmlTransform / PhysicalIcebergMergeSink / PluginDrivenExternalTable(resolveWriteTargetHandle)。删标记即自动收紧到该处。 + - 挂入 `fe/fe-core/pom.xml` validate 阶段 exec(`${project.basedir}/../../tools/...`,与 fe-connector 门禁同深度同范式)。 +- **验证**:自测 PASS(含核心裸调、换行参数、funnel 白名单、同行/上行标记、无参跳过、`getMetadataTableRows` 边界、注释跳过、退出码、标记可承重 10 项);门禁对真实树 exit 0,对 8 处未标记 exit 1(都证过);fe-core checkstyle 0 违规;`mvn -pl fe-core validate` 实跑触发门禁 exec + BUILD SUCCESS。 +- **读取键石(RD-1 / STEP 1)至此完全收官**:C1 地基 + C2 关闭 + C3 改道 + 扫描存字段 + 后台读穿 + 防漂移门禁全落地。 +- **未提交的第三方无关文件不动**(stray untracked `fe/.mvn/maven.config` 等非本轮产物,只 stage 本轮 9 文件)。 +- **下一步**:见 HANDOFF —— 下一大步 = HMS 异构网关兄弟扇出(RD-2 / STEP 2),动码前先按分期定稿 §1/§2 + P1-design §5 对当前代码做 grounding recon 并把方案用中文详述待用户确认。 + +## 2026-07-19 — session 6:HMS 异构网关兄弟元数据每语句去重落地(RD-2 主体) + +- **动码前设计先行 + grounding**(workflow `wf_62fa5a7f-07a`,5 读者 + 1 对抗完整性核验,并自行 clean-room 读码交叉核对)。核实结论比文档预想**小很多、也更集中**:整个 fe-connector-hive 模块"取兄弟元数据"仅 **4 处**(3 个 helper `icebergSiblingMetadata`/`hudiSiblingMetadata` by-TYPE + `siblingMetadata` by-HANDLE,加 `getTableSchema` 旁路);文档"~43 处 per-handle 改道"实为误导——那 40+ 处 per-handle 转发 + `beginTransaction(session,handle)` 全穿第三个 helper,**改动零行**。catalogId 经 `session.getCatalogId()` 可达(无需新布线);连接器侧直用 `session.getStatementScope().getOrCreateMetadata`(不 import fe-core,且该 key 形态早在 SPI 注释预声明)。 +- **中文方案交用户确认**(不引任务代号):4 处收口 + 属主标签从解析器**命中臂**取(拒绝会 force-build 的 supplier 身份比对——否则 hudi-only 目录会平白建 iceberg 兄弟)+ 旁路收回 + beginTransaction 免费覆盖 + closeAll 免额外接线。用户确认整体方案;**e2e 时机拍板 = 随后续统一补**(本步只做连接器单测锁死机制,异构网关 e2e 留切换阶段统一补,对齐 `hms-iceberg-delegation-needs-e2e`)。 +- **落地(commit `5fd55d0a32a`)**:新增 `SiblingOwner{connector,label}`(`ICEBERG_LABEL`/`HUDI_LABEL` 常量作单一真源);`HiveConnector.resolveSiblingOwnerLabeled` 命中臂带标签、`resolveSiblingOwner` 委派它(3 个 provider seam 字节不变);`HiveConnectorMetadata.memoizedSiblingMetadata` key=`metadata::